feat: 触控互动组件5种(道具选择/找线索/拖拽放置/步骤排序/连线配对)+ 游戏化皮肤
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user