feat: 触控互动组件5种(道具选择/找线索/拖拽放置/步骤排序/连线配对)+ 游戏化皮肤

This commit is contained in:
2026-08-13 14:54:07 +08:00
parent 6950dd6d20
commit 5d309d45b7
5 changed files with 478 additions and 0 deletions
@@ -0,0 +1,96 @@
<template>
<view class="drag-place">
<view class="target" :style="{ background: over ? '#ffe0c2' : '#f8f2e8' }" :class="{ win: winDrop }">
<text class="target-text">{{ over ? '放这里!' : '把道具拖到这里' }}</text>
</view>
<view class="items">
<view
v-for="(it, i) in items"
:key="it.id"
class="item"
:class="{ dragging: dragIdx === i }"
@touchstart="onStart($event, i)"
@touchmove.prevent="onMove"
@touchend="onEnd(it, i)"
>
<view class="item-icon">{{ propVisual(it.name).emoji }}</view>
<view class="item-name">{{ it.name }}</view>
</view>
</view>
</view>
</template>
<script>
import { propVisual } from '../../utils/visual.js'
import { playSound } from '../../utils/sound.js'
export default {
name: 'DragPlace',
props: { config: { type: String, default: '' } },
data() { return { dragIdx: -1, over: false, retries: 0, done: false, winDrop: false } },
computed: {
cfg() {
try { return JSON.parse(this.config) } catch (e) { return { kind: 'drag_place', items: [], answer: 0 } }
},
items() { return this.cfg.items || [] }
},
methods: {
propVisual,
targetRect() {
// 目标框位置(H5:用元素坐标近似,touch 事件 y < 200rpx 视为命中)
const sys = uni.getSystemInfoSync()
return sys.windowHeight * 0.28
},
onStart(e, i) {
if (this.done) return
this.dragIdx = i
},
onMove(e) {
if (this.dragIdx === -1) return
const t = e.touches && e.touches[0]
if (!t) return
// 手指 y 在屏幕上方 28% 内视为拖到目标框
const sys = uni.getSystemInfoSync()
this.over = t.clientY < sys.windowHeight * 0.28
},
onEnd(it, i) {
if (this.done || this.dragIdx === -1) return
this.dragIdx = -1
const hit = this.over
this.over = false
if (hit && it.id === this.cfg.answer) {
this.done = true
this.winDrop = true
playSound('good')
this.$emit('done', { success: true })
} else if (hit) {
this.retries++
playSound('bad')
if (this.retries >= 2) {
this.done = true
this.$emit('done', { success: false })
}
}
}
}
}
</script>
<style scoped>
.target { height: 220rpx; border-radius: 28rpx; border: 4rpx dashed #d5c4ae; display: flex; align-items: center; justify-content: center; margin-bottom: 48rpx; }
.target-text { color: #a08c74; font-size: 30rpx; }
.items { display: flex; gap: 24rpx; justify-content: center; }
.item { display: flex; flex-direction: column; align-items: center; background: #fff; border-radius: 20rpx; padding: 20rpx 24rpx; }
.item.dragging { opacity: .5; transform: scale(1.05); }
.item-icon { font-size: 52rpx; margin-bottom: 8rpx; }
.item-name { font-size: 26rpx; color: #5b4636; }
.item, .target { transition: transform .12s ease, box-shadow .12s ease; }
.item:active { transform: scale(.92); }
.target.win {
transform: scale(1.08); border: 4rpx solid #ffb347;
animation: win-pulse .5s ease;
}
.item.wrong { animation: shake .4s ease; border: 4rpx solid #ff6b6b; }
@keyframes win-pulse { 0% { transform: scale(.9); } 50% { transform: scale(1.15); } 100% { transform: scale(1.08); } }
@keyframes shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-10rpx); } 75% { transform: translateX(10rpx); } }
</style>
@@ -0,0 +1,77 @@
<template>
<view class="find-spot">
<view class="scene" :style="{ background: scene.color + '55' }">
<view
v-for="(it, i) in items"
:key="it.id"
class="spot"
:style="pos(i)"
:class="{ win: foundIdx === i }"
@click="tap(it, i)"
>
<view class="spot-icon">{{ propVisual(it.name).emoji }}</view>
</view>
<view class="scene-emoji">{{ scene.emoji }}</view>
</view>
<view class="hint">场景里藏着线索点一点找到它</view>
</view>
</template>
<script>
import { propVisual, sceneVisual } from '../../utils/visual.js'
import { playSound } from '../../utils/sound.js'
export default {
name: 'FindSpot',
props: { config: { type: String, default: '' } },
data() { return { retries: 0, found: false, foundIdx: -1 } },
computed: {
cfg() {
try { return JSON.parse(this.config) } catch (e) { return { kind: 'find_spot', items: [], answer: 0 } }
},
items() { return this.cfg.items || [] },
scene() { return sceneVisual('场景') }
},
methods: {
propVisual,
pos(i) {
// 5 个固定散布位置(左上/右上/中/左下/右下),按 index 轮转
const spots = [
{ left: '8%', top: '18%' }, { left: '58%', top: '12%' }, { left: '30%', top: '45%' },
{ left: '10%', top: '62%' }, { left: '55%', top: '60%' }
]
return spots[i % spots.length]
},
tap(it, i) {
if (this.found) return
if (it.id === this.cfg.answer) {
this.found = true
this.foundIdx = i
playSound('good')
this.$emit('done', { success: true })
} else {
this.retries++
playSound('bad')
if (this.retries >= 2) this.$emit('done', { success: false })
}
}
}
}
</script>
<style scoped>
.scene { position: relative; height: 420rpx; border-radius: 28rpx; margin-bottom: 24rpx; }
.scene-emoji { position: absolute; right: 20rpx; top: 10rpx; font-size: 120rpx; opacity: .35; }
.spot { position: absolute; width: 100rpx; height: 100rpx; border-radius: 24rpx; background: #fff; box-shadow: 0 4rpx 14rpx rgba(91,70,54,.18); display: flex; align-items: center; justify-content: center; }
.spot-icon { font-size: 52rpx; }
.hint { text-align: center; color: #a08c74; }
.spot { transition: transform .12s ease, box-shadow .12s ease; }
.spot:active { transform: scale(.92); }
.spot.win {
transform: scale(1.08); border: 4rpx solid #ffb347;
animation: win-pulse .5s ease;
}
.spot.wrong { animation: shake .4s ease; border: 4rpx solid #ff6b6b; }
@keyframes win-pulse { 0% { transform: scale(.9); } 50% { transform: scale(1.15); } 100% { transform: scale(1.08); } }
@keyframes shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-10rpx); } 75% { transform: translateX(10rpx); } }
</style>
@@ -0,0 +1,124 @@
<template>
<view class="link-match">
<view class="hint">先点一个人物再点它用的道具</view>
<view class="board">
<view class="col persons">
<view
v-for="p in persons"
:key="p.id"
class="node person"
:class="{ selected: selPerson === p.id, win: winPair }"
@click="tapPerson(p)"
>
<view class="node-avatar" :style="{ background: personVisual(p.id).color }">{{ (p.name || '?').slice(0, 1) }}</view>
<view class="node-name">{{ p.name }}</view>
</view>
</view>
<view class="lines">
<svg width="100%" height="100%">
<line
v-for="(m, i) in matches"
:key="i"
:x1="5" :y1="lineY(i)" :x2="95" :y2="lineY(i)"
stroke="#ff9a3d" stroke-width="4" stroke-linecap="round"
/>
</svg>
</view>
<view class="col items">
<view
v-for="(it, i) in items"
:key="it.id"
class="node item"
:class="{ selected: selItem === it.id, win: winPair }"
@click="tapItem(it)"
>
<view class="node-icon">{{ propVisual(it.name).emoji }}</view>
<view class="node-name">{{ it.name }}</view>
</view>
</view>
</view>
</view>
</template>
<script>
import { propVisual, personVisual } from '../../utils/visual.js'
import { playSound } from '../../utils/sound.js'
export default {
name: 'LinkMatch',
props: { config: { type: String, default: '' } },
data() { return { selPerson: 0, selItem: 0, matches: [], retries: 0, done: false, winPair: false } },
computed: {
cfg() {
try { return JSON.parse(this.config) } catch (e) { return { kind: 'link_match', persons: [], items: [], pairs: [] } }
},
persons() { return this.cfg.persons || [] },
items() { return this.cfg.items || [] }
},
methods: {
propVisual,
personVisual,
lineY(i) {
// 每条线固定插在人物与道具列之间,按匹配顺序错开
return 60 + i * 110 + '%'
},
tapPerson(p) {
if (this.done) return
this.selPerson = this.selPerson === p.id ? 0 : p.id
this.tryMatch()
},
tapItem(it) {
if (this.done) return
this.selItem = this.selItem === it.id ? 0 : it.id
this.tryMatch()
},
tryMatch() {
if (!this.selPerson || !this.selItem) return
const pair = [this.selPerson, this.selItem]
const ans = this.cfg.pairs || []
const correct = ans.some(([p, i]) => p === pair[0] && i === pair[1])
if (correct) {
this.matches.push(pair)
this.winPair = true
this.selPerson = 0
this.selItem = 0
playSound('good')
if (this.matches.length === this.persons.length && this.matches.length === ans.length) {
this.done = true
this.$emit('done', { success: true })
}
} else {
this.retries++
this.selPerson = 0
this.selItem = 0
playSound('bad')
if (this.retries >= 2) {
this.done = true
this.$emit('done', { success: false })
}
}
}
}
}
</script>
<style scoped>
.hint { text-align: center; color: #a08c74; margin-bottom: 28rpx; }
.board { display: flex; justify-content: space-between; align-items: stretch; }
.col { display: flex; flex-direction: column; gap: 24rpx; width: 36%; }
.lines { width: 24%; position: relative; min-height: 400rpx; }
.node { display: flex; align-items: center; background: #fff; border-radius: 20rpx; padding: 16rpx 20rpx; }
.node.selected { border: 4rpx solid #ff9a3d; }
.node-avatar { width: 64rpx; height: 64rpx; border-radius: 50%; color: #fff; font-weight: 700; display: flex; align-items: center; justify-content: center; margin-right: 16rpx; }
.node-icon { font-size: 44rpx; margin-right: 16rpx; }
.node-name { font-size: 28rpx; color: #5b4636; }
.node { transition: transform .12s ease, box-shadow .12s ease; }
.node:active { transform: scale(.92); }
.node.win {
transform: scale(1.08); border: 4rpx solid #ffb347;
animation: win-pulse .5s ease;
}
.node.wrong { animation: shake .4s ease; border: 4rpx solid #ff6b6b; }
@keyframes win-pulse { 0% { transform: scale(.9); } 50% { transform: scale(1.15); } 100% { transform: scale(1.08); } }
@keyframes shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-10rpx); } 75% { transform: translateX(10rpx); } }
</style>
@@ -0,0 +1,74 @@
<template>
<view class="prop-pick">
<view class="hint">点一点选一个道具</view>
<view class="items">
<view
v-for="(it, i) in items"
:key="it.id"
class="item"
:class="{ picked: picked === i, win: winIdx === i, wrong: wrongIdx === i }"
@click="pick(it, i)"
>
<view class="item-icon" :style="{ background: visual.color }">{{ visual.emoji }}</view>
<view class="item-name">{{ it.name }}</view>
</view>
</view>
<view v-if="wrongIdx !== -1" class="wrong-tip">再想想看看情境里发生了什么</view>
</view>
</template>
<script>
import { propVisual } from '../../utils/visual.js'
import { playSound } from '../../utils/sound.js'
export default {
name: 'PropPick',
props: { config: { type: String, default: '' } },
data() {
return { picked: -1, wrongIdx: -1, winIdx: -1, retries: 0 }
},
computed: {
cfg() {
try { return JSON.parse(this.config) } catch (e) { return { kind: 'prop_pick', items: [], answer: 0 } }
},
items() { return this.cfg.items || [] },
visual() { return propVisual(this.items[this.picked] ? this.items[this.picked].name : '') }
},
methods: {
pick(it, i) {
if (it.id === this.cfg.answer) {
this.picked = i
this.winIdx = i
this.wrongIdx = -1
playSound('good')
this.$emit('done', { success: true })
} else {
this.retries++
this.wrongIdx = i
this.picked = -1
playSound('bad')
if (this.retries >= 2) this.$emit('done', { success: false })
}
}
}
}
</script>
<style scoped>
.items { display: flex; gap: 24rpx; flex-wrap: wrap; justify-content: center; }
.item { display: flex; flex-direction: column; align-items: center; background: #fff; border-radius: 20rpx; padding: 24rpx 28rpx; box-shadow: 0 4rpx 14rpx rgba(91,70,54,.08); }
.item.picked { border: 4rpx solid #ff9a3d; }
.item-icon { width: 88rpx; height: 88rpx; border-radius: 24rpx; display: flex; align-items: center; justify-content: center; font-size: 48rpx; margin-bottom: 12rpx; }
.item-name { font-size: 28rpx; color: #5b4636; font-weight: 600; }
.hint { text-align: center; color: #a08c74; margin-bottom: 28rpx; }
.wrong-tip { text-align: center; color: #ff6b6b; margin-top: 24rpx; }
.item { transition: transform .12s ease, box-shadow .12s ease; }
.item:active { transform: scale(.92); }
.item.win {
transform: scale(1.08); border: 4rpx solid #ffb347;
animation: win-pulse .5s ease;
}
.item.wrong { animation: shake .4s ease; border: 4rpx solid #ff6b6b; }
@keyframes win-pulse { 0% { transform: scale(.9); } 50% { transform: scale(1.15); } 100% { transform: scale(1.08); } }
@keyframes shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-10rpx); } 75% { transform: translateX(10rpx); } }
</style>
@@ -0,0 +1,107 @@
<template>
<view class="step-sort">
<view class="hint">按住道具拖一拖按顺序排好</view>
<view
v-for="(it, i) in items"
:key="it.id"
class="slot"
:class="{ over: overIdx === i, dragging: dragIdx === i, win: allWin, wrong: wrong }"
@touchstart="onStart($event, i)"
@touchmove.prevent="onMove($event, i)"
@touchend="onEnd"
>
<view class="order-no">{{ i + 1 }}</view>
<view class="slot-icon">{{ propVisual(it.name).emoji }}</view>
<view class="slot-name">{{ it.name }}</view>
</view>
<view v-if="wrong" class="wrong-tip">顺序不对哦再试试</view>
</view>
</template>
<script>
import { propVisual } from '../../utils/visual.js'
import { playSound } from '../../utils/sound.js'
export default {
name: 'StepSort',
props: { config: { type: String, default: '' } },
data() { return { order: [], dragIdx: -1, overIdx: -1, retries: 0, wrong: false, done: false, allWin: false } },
computed: {
cfg() {
try { return JSON.parse(this.config) } catch (e) { return { kind: 'step_sort', items: [], answer_order: [] } }
},
items() { return this.order }
},
created() {
// 初始乱序:拷贝 items 后随机打乱(保证不是答案顺序)
const arr = (this.cfg.items || []).slice()
const ans = this.cfg.answer_order || []
// 洗牌直至与答案不同(至少 2 项时几乎必然不同)
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[arr[i], arr[j]] = [arr[j], arr[i]]
}
if (ans.length > 1 && arr.every((it, i) => it.id === ans[i])) {
;[arr[0], arr[1]] = [arr[1], arr[0]]
}
this.order = arr
},
methods: {
propVisual,
onStart(e, i) { this.dragIdx = i },
onMove(e, i) {
if (this.dragIdx === -1 || this.dragIdx === i) return
this.overIdx = i
},
onEnd() {
if (this.dragIdx === -1 || this.overIdx === -1) { this.dragIdx = -1; this.overIdx = -1; return }
const arr = this.order.slice()
;[arr[this.dragIdx], arr[this.overIdx]] = [arr[this.overIdx], arr[this.dragIdx]]
this.order = arr
this.dragIdx = -1
this.overIdx = -1
this.check()
},
check() {
const ans = this.cfg.answer_order || []
if (this.order.length !== ans.length) return
const ok = this.order.every((it, i) => it.id === ans[i])
if (ok) {
this.done = true
this.allWin = true
playSound('good')
this.$emit('done', { success: true })
} else {
this.wrong = true
this.retries++
playSound('bad')
setTimeout(() => (this.wrong = false), 800)
if (this.retries >= 2) {
this.done = true
this.$emit('done', { success: false })
}
}
}
}
}
</script>
<style scoped>
.hint { text-align: center; color: #a08c74; margin-bottom: 28rpx; }
.slot { display: flex; align-items: center; background: #fff; border-radius: 20rpx; padding: 20rpx 24rpx; margin-bottom: 20rpx; box-shadow: 0 4rpx 14rpx rgba(91,70,54,.08); }
.slot.over { border: 4rpx solid #ff9a3d; }
.slot.dragging { opacity: .6; }
.order-no { width: 56rpx; height: 56rpx; border-radius: 50%; background: #ff9a3d; color: #fff; font-weight: 800; display: flex; align-items: center; justify-content: center; margin-right: 24rpx; }
.slot-icon { font-size: 44rpx; margin-right: 20rpx; }
.slot-name { font-size: 30rpx; color: #5b4636; font-weight: 600; }
.wrong-tip { text-align: center; color: #ff6b6b; margin-top: 20rpx; }
.slot { transition: transform .12s ease, box-shadow .12s ease; }
.slot:active { transform: scale(.92); }
.slot.win {
transform: scale(1.08); border: 4rpx solid #ffb347;
animation: win-pulse .5s ease;
}
.slot.wrong { animation: shake .4s ease; border: 4rpx solid #ff6b6b; }
@keyframes win-pulse { 0% { transform: scale(.9); } 50% { transform: scale(1.15); } 100% { transform: scale(1.08); } }
@keyframes shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-10rpx); } 75% { transform: translateX(10rpx); } }
</style>