luojigou-board.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  1. <template>
  2. <view class="luojigou-board" >
  3. <view class="board" :style="{scale: adaptatio, transformOrigin: '50% 0%'}" >
  4. <image class="board-img" :src="boardUrl" />
  5. <view class="board-header" id="game-label">
  6. <view class="trumpt" @click="emits('playAudio')">
  7. <image class="dog" :src="staticImg.trumptDog" />
  8. <image
  9. class="trumpt-icon"
  10. :src="props.playLoading ? staticImg.trumptGif : staticImg.trumptPng"
  11. />
  12. </view>
  13. <view class="tip ">
  14. <scroll-view
  15. scroll-y
  16. :style="{height: '72rpx'}"
  17. >
  18. {{props.cardDesc ? props.cardDesc : "加載中"}}
  19. </scroll-view>
  20. </view>
  21. </view>
  22. <view class="card" >
  23. <view class="ques" ref="quesRef" id="quesRef" >
  24. <image :src="props.board.quesUrl" alt="" />
  25. </view>
  26. <view class="ans" ref="ansRef" id="ansRef">
  27. <image :src="props.board.ansUrl" alt="" />
  28. </view>
  29. </view>
  30. </view>
  31. <view
  32. class="mark-button"
  33. :style="{
  34. width: 357 * rate + 'rpx',
  35. height: 466 * rate + 'rpx',
  36. top: 102 * rate + 'rpx',
  37. left: '50%', transform: 'translateX(-50%)'
  38. }"
  39. >
  40. <view
  41. v-for="item in props.board.buttons"
  42. class="movable-image"
  43. :style="{
  44. willChange: 'transform',
  45. top: Number(item.y) * rate + 'rpx',
  46. left: Number(item.x) * rate + 'rpx',
  47. width: 46 * rate + 'rpx',
  48. height: 46 * rate + 'rpx',
  49. backgroundColor: item.color,
  50. borderRadius: '50%'
  51. }"
  52. />
  53. </view>
  54. <movable-area
  55. v-if="props.cardType !== undefined"
  56. class="movable-area"
  57. id="movableAreaRef"
  58. :style="{
  59. width: 357 * rate + 'rpx',
  60. height: 466 * rate + 'rpx',
  61. top: 102 * rate + 'rpx',
  62. left: '50%',
  63. transform: 'translateX(-50%)'
  64. }"
  65. >
  66. <movable-view
  67. v-for="item in buttons"
  68. :key="item.id"
  69. :disabled="state.disabled"
  70. :x="item.x"
  71. :y="item.y"
  72. damping="100"
  73. direction="all"
  74. class="movable-view"
  75. :style="{
  76. zIndex: item.zIndex,
  77. width: 46 * rate + 'rpx',
  78. height: 46 * rate + 'rpx',
  79. }"
  80. @touchend="touchend($event, item)"
  81. @touchstart="touchStart(item)"
  82. >
  83. <image
  84. :id="`rock-id-${item.id}`"
  85. :class="`movable-image `"
  86. :style="{willChange: 'transform', transformOrigin: `center bottom`}"
  87. :src="item.url"
  88. />
  89. <image
  90. v-if="item.ans"
  91. class="success-flag"
  92. :src="staticImg.successFlag"
  93. />
  94. </movable-view>
  95. </movable-area>
  96. </view>
  97. </template>
  98. <script setup lang="ts" name="luojigou-board" >
  99. import { defineProps, reactive, ref, computed, getCurrentInstance, defineEmits } from 'vue'
  100. import { useStaticImg, useSchedulerOnce, useQueryElInfo, useAdaptationIpadAndPhone } from '@/hooks/index'
  101. import type { CardModeEnum } from '@/enum/constant';
  102. import { useCalcStartStore, useCalcQuantityStore } from '@/store/index';
  103. interface Buttons {
  104. id: number,
  105. x: number,
  106. y: number,
  107. initX: number,
  108. initY: number,
  109. url: string,
  110. color: API.Color,
  111. index: number,
  112. zIndex: number,
  113. ans: API.Color | null
  114. }
  115. interface IProps {
  116. cardType: 0 | 1, // 0 四钮题卡 | 1 六钮题卡
  117. board: API.Board,
  118. mode: CardModeEnum,
  119. cardDesc: string,
  120. playLoading: boolean,
  121. getExpose: (records: any) => void
  122. }
  123. const staticImg = useStaticImg()
  124. const calcStartStore = useCalcStartStore()
  125. const adaptatio = useAdaptationIpadAndPhone()
  126. const calcQuantityStore = useCalcQuantityStore()
  127. const props = defineProps<IProps>()
  128. const emits = defineEmits(['playAudio', 'submit'])
  129. const state = reactive({
  130. zIndex: 0, // 暂存zIndex
  131. disabled: false, // 是否禁用moveview
  132. correctQuantity: 0, // 正确数量()
  133. totalQuantity: 0 // 总数量 (移动按钮数量)
  134. })
  135. const boardUrl = props.cardType == 1 ? staticImg.boardSix : staticImg.boardFour
  136. calcStartStore.total = props.cardType == 1 ? 6 : 4
  137. const ansRef = ref<UniApp.NodeInfo>()
  138. const quesRef = ref<UniApp.NodeInfo>()
  139. const movableAreaRef = ref<UniApp.NodeInfo>()
  140. useQueryElInfo('#quesRef', (nodeInfo) => quesRef.value = nodeInfo as UniApp.NodeInfo, getCurrentInstance()!)
  141. useQueryElInfo('#ansRef', (nodeInfo) => ansRef.value = nodeInfo as UniApp.NodeInfo, getCurrentInstance()!)
  142. useQueryElInfo('#movableAreaRef', (nodeInfo) => movableAreaRef.value = nodeInfo as UniApp.NodeInfo, getCurrentInstance()!)
  143. const baseRatio = adaptatio
  144. const rate = baseRatio * 2
  145. const { windowWidth, windowHeight } = uni.getSystemInfoSync()
  146. const unit = windowWidth / windowHeight > 0.6 ? 1 : 0.5
  147. const ansItemHeight = computed(() => Math.floor(ansRef.value?.height! / copies))
  148. const TPos = (x: number, y: number) => {
  149. return { x: x - movableAreaRef.value?.left! - 23 * rate * unit , y: y - movableAreaRef.value?.top! - 23 * rate * unit }
  150. }
  151. const getButtonIndex = (_y: number) => Math.floor(_y / ansItemHeight.value)
  152. const getButtonPosByIndex = (index: number) => {
  153. const x = ansRef.value?.width! + quesRef.value?.width!
  154. const y = ansItemHeight.value * index + ansItemHeight.value / 2 - 23 * rate * unit
  155. return { x, y }
  156. }
  157. // 几钮模板
  158. const copies = props.cardType === 0 ? 4 : 6
  159. const VSpace = props.cardType === 0 ? 23 * rate * unit + 46 * rate * unit : 6 * rate * unit + 46 * rate * unit
  160. const Y = 435 * rate
  161. const getX = (index: number) => index * VSpace + 13 * rate * unit
  162. // 注意一下四钮 用哪几个颜色的按钮
  163. let buttons = reactive<Buttons[]>([
  164. {id: 0, ans: null, x: getX(0), y: Y * rate, initX: getX(0), initY: Y * rate, zIndex: 0, index: -1, url: staticImg.red, color: 'red' },
  165. {id: 1, ans: null, x: getX(1), y: Y * rate, initX: getX(1), initY: Y * rate, zIndex: 0, index: -1, url: staticImg.blue, color: 'blue' },
  166. {id: 2, ans: null, x: getX(2), y: Y * rate, initX: getX(2), initY: Y * rate, zIndex: 0, index: -1, url: staticImg.green, color: 'green' },
  167. {id: 3, ans: null, x: getX(3), y: Y * rate, initX: getX(3), initY: Y * rate, zIndex: 0, index: -1, url: staticImg.orange, color: 'orange' },
  168. {id: 4, ans: null, x: getX(4), y: Y * rate, initX: getX(4), initY: Y * rate, zIndex: 0, index: -1, url: staticImg.yellow, color: "yellow" },
  169. {id: 5, ans: null, x: getX(5), y: Y * rate, initX: getX(5), initY: Y * rate, zIndex: 0, index: -1, url: staticImg.purple, color: 'purple' },
  170. ])
  171. buttons.splice(props.cardType == 1 ? buttons.length : buttons.length - 2, buttons.length)
  172. // 让按钮回到原位
  173. const disPatchButtonGoInitPos = (index: number) => {
  174. useSchedulerOnce(() => {
  175. buttons[index].x = buttons[index].initX + Math.random()
  176. buttons[index].y = buttons[index].initY + Math.random()
  177. buttons[index].index = -1
  178. buttons[index].ans = null
  179. })
  180. useSchedulerOnce(() => {
  181. state.disabled = false
  182. }, 200)
  183. }
  184. const touchStart = (item: Buttons) => {
  185. useSchedulerOnce(() => {
  186. state.zIndex ++
  187. item.zIndex = state.zIndex
  188. })
  189. }
  190. // 按钮脱手
  191. const touchend = (ev: TouchEvent, item: Buttons) => {
  192. if (state.disabled) return
  193. const { x: itemX, y: itemY } = TPos(ev.changedTouches[0].pageX, ev.changedTouches[0].pageY)
  194. console.log("itemY:", itemY);
  195. // 返回原点 (没有放在答案区, 按钮回到初始位置)
  196. if (itemX < quesRef.value?.width! || itemY > ansRef.value?.height! ) {
  197. disPatchButtonGoInitPos(item.id)
  198. } else {
  199. const index = getButtonIndex(itemY)
  200. console.log('index:', index);
  201. const { x: targetX, y: targetY } = getButtonPosByIndex(index)
  202. // 直接点击答案区的按钮,答案区按钮回到初始位置
  203. if (item.x == targetX && item.y == targetY) {
  204. disPatchButtonGoInitPos(item.id)
  205. } else {
  206. // 两个按钮都在答案区, 直接进行按钮之间的交换
  207. const isHasIndex = buttons.findIndex(button => button.index == index && button.id !== item.id )
  208. if (isHasIndex >= 0) { // 答案区的目标区域存在按钮
  209. if (item.index == -1) { // 答案区的目标区域存在按钮 操作按钮是从待操作区域移过来的 目标按钮回到原位
  210. disPatchButtonGoInitPos(isHasIndex)
  211. } else { // 答案区的目标区域存在按钮 操作按钮是和目标按钮互换位置
  212. useSchedulerOnce(() => {
  213. const oldPos = getButtonPosByIndex(item.index)
  214. buttons[isHasIndex].x = oldPos.x
  215. buttons[isHasIndex].y = oldPos.y
  216. buttons[isHasIndex].index = item.index
  217. })
  218. }
  219. }
  220. // 将按钮放置到答案区对应的位置
  221. useSchedulerOnce(() => {
  222. item.x = targetX
  223. item.y = targetY
  224. item.index = index
  225. })
  226. checkAns(index, item)
  227. }
  228. }
  229. }
  230. // 判断答案 (学习计划适用逻辑)
  231. const checkAns = (index: number, itemData: Buttons) => {
  232. const targetData = props.board.ansList
  233. console.log(targetData, index, itemData);
  234. // 移动到了正确位置
  235. if (targetData[index].color === itemData.color) {
  236. itemData.ans = targetData[index].color
  237. calcQuantityStore.correctQuantity++
  238. } else { // 移动到了錯誤位置
  239. calcStartStore.wrongCount++
  240. itemData.ans = null
  241. const rockNode = document.getElementById(`rock-id-${itemData.id}`)
  242. rockNode?.classList.add('rock-button-ani')
  243. state.disabled = true
  244. useSchedulerOnce(() => {
  245. disPatchButtonGoInitPos(itemData.id)
  246. rockNode?.classList.remove('rock-button-ani')
  247. }, 1000)
  248. }
  249. calcQuantityStore.totalQuantity++
  250. // 全部正确后触发提交答案的emit
  251. const ansLen = buttons.filter( button => button.ans).length
  252. if ( ansLen === ( props.cardType === 0 ? 4 : 6 )) {
  253. emits('submit')
  254. }
  255. }
  256. </script>
  257. <style lang="less" scoped >
  258. .luojigou-board {
  259. width: 714rpx;
  260. height: 1234rpx;
  261. position: relative;
  262. display: flex;
  263. justify-content: center;
  264. .board {
  265. width: 714rpx;
  266. height: 1234rpx;
  267. position: relative;
  268. .board-img {
  269. width: 100%;
  270. height: 100%;
  271. position: absolute;
  272. top: 0;
  273. left: 0;
  274. z-index: 1;
  275. }
  276. .board-header {
  277. width: 100%;
  278. height: 148rpx;
  279. position: relative;
  280. top: 7rpx;
  281. z-index: 12;
  282. .trumpt {
  283. width: 96rpx;
  284. height: 96rpx;
  285. position: relative;
  286. top: 44rpx;
  287. left: 54rpx;
  288. .dog {
  289. width: 96rpx;
  290. height: 96rpx;
  291. position: absolute;
  292. z-index: 1;
  293. }
  294. .trumpt-icon {
  295. width: 40rpx;
  296. height: 40rpx;
  297. position: absolute;
  298. z-index: 2;
  299. right: -12rpx;
  300. bottom: -6rpx;
  301. }
  302. }
  303. .tip {
  304. width: 490rpx;
  305. height: 72rpx;
  306. font-size: 24rpx;
  307. font-family: PingFangSC-Medium, PingFang SC;
  308. font-weight: 500;
  309. color: #ffffff;
  310. position: absolute;
  311. top: 50rpx;
  312. left: 176rpx;
  313. }
  314. }
  315. .card {
  316. width: 606rpx;
  317. height: 772rpx;
  318. border-radius: 40rpx;
  319. overflow: hidden;
  320. display: flex;
  321. justify-content: space-between;
  322. position: absolute;
  323. top: 206rpx;
  324. left: 26rpx;
  325. z-index: 2;
  326. }
  327. .ques {
  328. width: 450rpx;
  329. height: 772rpx;
  330. border-right: 2rpx solid #006CAA;
  331. image {
  332. width: 100%;
  333. height: 100%;
  334. display: block;
  335. }
  336. }
  337. .ans {
  338. width: 170rpx;
  339. height: 100%;
  340. display: flex;
  341. flex-direction: column;
  342. border-top-right-radius: 40rpx;
  343. border-bottom-right-radius: 40rpx;
  344. overflow: hidden;
  345. background-color: #fff;
  346. image {
  347. width: 100%;
  348. height: 100%;
  349. object-fit: cover;
  350. }
  351. }
  352. .ans .ans-item:last-child {
  353. border-bottom: none
  354. }
  355. .buttons {
  356. position: relative;
  357. top: 200rpx;
  358. left: 20rpx;
  359. width: 680rpx;
  360. height: 1000rpx;
  361. image {
  362. position: absolute;
  363. left: 0;
  364. top: 0;
  365. }
  366. }
  367. }
  368. .mark-button {
  369. width: 100%;
  370. height: 100%;
  371. position: absolute;
  372. top: 0px;
  373. left: 0px;
  374. .movable-image {
  375. position: absolute;
  376. width: 92rpx;
  377. height: 92rpx;
  378. display: block;
  379. }
  380. }
  381. .movable-area {
  382. width: 714rpx;
  383. height: 984rpx;
  384. background-color: transparent;
  385. position: absolute;
  386. top: 192rpx;
  387. left: 50%;
  388. transform: translateX(-50%);
  389. touch-action: none;
  390. left: 0px;
  391. z-index: 30;
  392. -moz-transform: none;
  393. -webkit-transform: none;
  394. -o-transform: none;
  395. -ms-transform: none;
  396. transform: none;
  397. .movable-view {
  398. width: 92rpx;
  399. height: 92rpx;
  400. touch-action: none;
  401. .movable-image {
  402. width: 100%;
  403. height: 100%;
  404. transition: width 0.2s;
  405. z-index: 1;
  406. }
  407. .success-flag {
  408. width: 36rpx;
  409. height: 26rpx;
  410. display: block;
  411. position: absolute;
  412. top: 50%;
  413. left: 50%;
  414. transform: translate(-50%, -50%);
  415. z-index: 2;
  416. }
  417. }
  418. }
  419. .opra {
  420. width: 100vw;
  421. height: 1216rpx;
  422. background: url('../../assets/boardBg.png');
  423. display: flex;
  424. justify-content: center;
  425. padding-top: 46rpx;
  426. }
  427. }
  428. .rock-button-ani {
  429. animation: rock 1s;
  430. }
  431. @keyframes rock {
  432. 0% {
  433. rotate: 0deg;
  434. }
  435. 25% {
  436. rotate: -10deg;
  437. }
  438. 50% {
  439. rotate: 10deg;
  440. }
  441. 75% {
  442. rotate: -10deg;
  443. }
  444. 100% {
  445. rotate: 0deg;
  446. }
  447. }
  448. </style>