luojigou-board.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698
  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 dog"
  10. :src="props.playLoading ? staticImg.trumptGif : staticImg.trumptPng"
  11. />
  12. </view>
  13. <view class="tip " :key="props.cardDesc">
  14. <scroll-view
  15. scroll-y
  16. :style="{height: '88rpx',
  17. }"
  18. >
  19. {{props.cardDesc ? props.cardDesc : "加載中"}}
  20. </scroll-view>
  21. </view>
  22. <image
  23. class="luojigou-dog"
  24. :src="staticImg.luojigouDog"
  25. />
  26. </view>
  27. <view class="card" :style="{zIndex: viewZindex.quesView}" >
  28. <view class="ques" id="quesRef" >
  29. <image id="ques-url" :src="props.board.quesUrl" alt="" />
  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%',
  38. transform: 'translateX(-50%)',
  39. }"
  40. >
  41. <view
  42. v-for="item in props.board.buttons"
  43. class="movable-image"
  44. :style="{
  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: colorMap.get(item.color),
  50. scale: 0.7,
  51. borderRadius: '50%',
  52. opacity: props.tipsButton === 1 ? 1 : 0
  53. }"
  54. />
  55. </view> -->
  56. </view>
  57. <view class="ans" id="ansRef">
  58. <image :src="props.board.ansUrl" alt="" />
  59. </view>
  60. <movable-area
  61. v-if="props.cardType !== undefined"
  62. class="movable-area"
  63. id="movableAreaRef"
  64. :style="{
  65. width: 357 * rate+ 'rpx',
  66. height: 466 * rate + 'rpx',
  67. top: 102 * rate / adaptatio + 'rpx',
  68. left: 50 + '%',
  69. transform: `translateX(-${50}%)`,
  70. zIndex: viewZindex.moveView,
  71. scale: 1 / adaptatio
  72. }"
  73. >
  74. <movable-view
  75. v-for="item in buttons"
  76. :key="item.id"
  77. :disabled="item.disabled"
  78. :x="item.x "
  79. :y="item.y"
  80. damping="100"
  81. direction="all"
  82. class="movable-view"
  83. :style="{
  84. zIndex: item.zIndex,
  85. width: 46 * rate + 'rpx',
  86. height: 46 * rate+ 'rpx',
  87. }"
  88. @touchend="touchend($event, item)"
  89. @touchstart="touchStart(item)"
  90. >
  91. <image
  92. :id="`rock-id-${item.id}`"
  93. :class="`movable-image `"
  94. :style="{willChange: 'transform', transformOrigin: `center bottom`}"
  95. :src="item.url"
  96. />
  97. <image
  98. v-if="item.ans"
  99. class="success-flag"
  100. :src="staticImg.successFlag"
  101. />
  102. </movable-view>
  103. </movable-area>
  104. </view>
  105. </view>
  106. <!-- <audio :src="audioSrc" autoplay ></audio> -->
  107. </template>
  108. <script setup lang="ts" name="luojigou-board" >
  109. import { defineProps, reactive, ref, computed, getCurrentInstance, defineEmits, watch } from 'vue'
  110. import { useStaticImg, useSchedulerOnce, useQueryElInfo, useAdaptationIpadAndPhone } from '@/hooks/index'
  111. import type { CardModeEnum } from '@/enum/constant';
  112. import { useCalcQuantityStore } from '@/store/index';
  113. import { AudioController } from '@/controller/AudioController';
  114. import Hammer from 'hammerjs'
  115. import { onMounted } from 'vue';
  116. interface Buttons {
  117. id: number,
  118. x: number,
  119. y: number,
  120. initX: number,
  121. initY: number,
  122. url: string,
  123. color: API.Color,
  124. index: number,
  125. zIndex: number,
  126. ans: API.Color | null
  127. disabled: boolean
  128. }
  129. interface IProps {
  130. cardType: 0 | 1, // 0 四钮题卡 | 1 六钮题卡
  131. board: API.Board,
  132. mode: CardModeEnum,
  133. cardDesc: string,
  134. playLoading: boolean,
  135. getExpose: (records: any) => void
  136. tipsButton: number
  137. audioSrc: string
  138. }
  139. const staticImg = useStaticImg()
  140. const adaptatio = useAdaptationIpadAndPhone()
  141. const calcQuantityStore = useCalcQuantityStore()
  142. const props = defineProps<IProps>()
  143. const emits = defineEmits(['playAudio', 'submit'])
  144. const state = reactive({
  145. zIndex: 0, // 暂存zIndex
  146. scale: 1
  147. })
  148. const viewZindex = ref({
  149. moveView: 30,
  150. quesView: 31
  151. })
  152. // 手势操作题卡图片的state
  153. const hammerState = reactive({
  154. x: 0, // 记录水平方向上的偏移量
  155. y: 0, // 记录垂直方向上的偏移量
  156. scaleCount: 2,
  157. scaleIndex: 1
  158. })
  159. const colorMap = new Map([
  160. ['red', '#FF0002'],
  161. ['blue', '#0061B4'],
  162. ['purple', '#A764AB'],
  163. ['yellow', '#FFD800'],
  164. ['green', '#008D46'],
  165. ['orange', '#FF7F00'],
  166. ])
  167. const boardUrl = props.cardType == 1 ? staticImg.boardSix : staticImg.boardFour
  168. const ansRef = ref<UniApp.NodeInfo>()
  169. const quesRef = ref<UniApp.NodeInfo>()
  170. const movableAreaRef = ref<UniApp.NodeInfo>()
  171. useQueryElInfo('#quesRef', (nodeInfo) => quesRef.value = nodeInfo as UniApp.NodeInfo, getCurrentInstance()!)
  172. useQueryElInfo('#ansRef', (nodeInfo) => ansRef.value = nodeInfo as UniApp.NodeInfo, getCurrentInstance()!)
  173. useQueryElInfo('#movableAreaRef', (nodeInfo) => movableAreaRef.value = nodeInfo as UniApp.NodeInfo, getCurrentInstance()!)
  174. const baseRatio = adaptatio
  175. const rate = baseRatio * 2
  176. const { windowWidth, windowHeight } = uni.getSystemInfoSync()
  177. const unit = windowWidth / windowHeight > 0.6 ? 1 : 0.5
  178. // 几钮模板
  179. const copies = props.cardType === 0 ? 4 : 6
  180. const ansItemHeight = computed(() => Math.floor(ansRef.value?.height! / copies))
  181. const TPos = (x: number, y: number) => {
  182. return { x: x - movableAreaRef.value?.left! - 23 * rate * unit , y: y - movableAreaRef.value?.top! - 23 * rate * unit }
  183. }
  184. const getButtonIndex = (_y: number) => {
  185. console.log('ansItemHeight.value', ansItemHeight.value);
  186. return Math.floor(_y / ansItemHeight.value)
  187. }
  188. const getButtonPosByIndex = (index: number) => {
  189. console.log('getButtonPosByIndex:', ansRef.value?.width!, quesRef.value?.width! );
  190. const x = ansRef.value?.width! + quesRef.value?.width!
  191. const y = ansItemHeight.value * index + ansItemHeight.value / 2 - 23 * rate * unit
  192. return { x, y }
  193. }
  194. const buttonWidth = 46 * windowWidth / 375 * baseRatio
  195. const VSpace = props.cardType === 0 ?Math.floor(windowWidth / 375 * 26) * baseRatio + buttonWidth : Math.floor(windowWidth / 375 * 8) * baseRatio + buttonWidth
  196. const Y = 435 * rate
  197. const getX = (index: number) => index * VSpace + Math.floor(windowWidth / 375 * 11) * baseRatio
  198. // 注意一下四钮 用哪几个颜色的按钮
  199. let buttons = reactive<Buttons[]>([
  200. {id: 0, ans: null, x: getX(0), y: Y * rate , disabled: false, initX: getX(0), initY: Y * rate, zIndex: 0, index: -1, url: staticImg.red, color: 'red' },
  201. {id: 1, ans: null, x: getX(1), y: Y * rate , disabled: false, initX: getX(1), initY: Y * rate, zIndex: 0, index: -1, url: staticImg.blue, color: 'blue' },
  202. {id: 2, ans: null, x: getX(2), y: Y * rate , disabled: false, initX: getX(2), initY: Y * rate, zIndex: 0, index: -1, url: staticImg.green, color: 'green' },
  203. {id: 3, ans: null, x: getX(3), y: Y * rate , disabled: false, initX: getX(3), initY: Y * rate, zIndex: 0, index: -1, url: staticImg.orange, color: 'orange' },
  204. {id: 4, ans: null, x: getX(4), y: Y * rate , disabled: false, initX: getX(4), initY: Y * rate, zIndex: 0, index: -1, url: staticImg.yellow, color: "yellow" },
  205. {id: 5, ans: null, x: getX(5), y: Y * rate , disabled: false, initX: getX(5), initY: Y * rate, zIndex: 0, index: -1, url: staticImg.purple, color: 'purple' },
  206. ])
  207. buttons.splice(props.cardType == 1 ? buttons.length : buttons.length - 2, buttons.length)
  208. // 让按钮回到原位
  209. const disPatchButtonGoInitPos = (index: number) => {
  210. useSchedulerOnce(() => {
  211. buttons[index].x = buttons[index].initX + Math.random()
  212. buttons[index].y = buttons[index].initY + Math.random()
  213. buttons[index].index = -1
  214. buttons[index].ans = null
  215. })
  216. }
  217. const touchStart = (item: Buttons) => {
  218. viewZindex.value.moveView = viewZindex.value.quesView + 1
  219. useSchedulerOnce(() => {
  220. state.zIndex ++
  221. item.zIndex = state.zIndex
  222. })
  223. }
  224. // 按钮脱手
  225. const touchend = (ev: TouchEvent, item: Buttons) => {
  226. if (item.disabled) return
  227. const { x: itemX, y: itemY } = TPos(ev.changedTouches[0].pageX, ev.changedTouches[0].pageY)
  228. console.log("itemY:", itemY);
  229. // 返回原点 (没有放在答案区, 按钮回到初始位置)
  230. if (itemX < quesRef.value?.width! || itemY > ansRef.value?.height! ) {
  231. disPatchButtonGoInitPos(item.id)
  232. } else {
  233. const index = getButtonIndex(itemY <= 0 ? 0 : itemY)
  234. console.log('index:', index);
  235. const { x: targetX, y: targetY } = getButtonPosByIndex(index)
  236. console.log(item.x , targetX , item.y, targetY);
  237. // 直接点击答案区的按钮,答案区按钮回到初始位置
  238. if (item.x == targetX && item.y == targetY) {
  239. // disPatchButtonGoInitPos(item.id)
  240. } else {
  241. // 两个按钮都在答案区, 直接进行按钮之间的交换
  242. const isHasIndex = buttons.findIndex(button => button.index == index && button.id !== item.id )
  243. if (isHasIndex >= 0) { // 答案区的目标区域存在按钮
  244. if (item.index == -1) { // 答案区的目标区域存在按钮 操作按钮是从待操作区域移过来的 目标按钮回到原位
  245. // disPatchButtonGoInitPos(isHasIndex)
  246. } else { // 答案区的目标区域存在按钮 操作按钮是和目标按钮互换位置
  247. // useSchedulerOnce(() => {
  248. // const oldPos = getButtonPosByIndex(item.index)
  249. // buttons[isHasIndex].x = oldPos.x
  250. // buttons[isHasIndex].y = oldPos.y
  251. // buttons[isHasIndex].index = item.index
  252. // })
  253. }
  254. }
  255. // 将按钮放置到答案区对应的位置
  256. useSchedulerOnce(() => {
  257. item.x = targetX
  258. item.y = targetY
  259. item.index = index
  260. })
  261. checkAns(index, item)
  262. }
  263. }
  264. // 每次松手后把题卡的zIndex提高1
  265. viewZindex.value.quesView = viewZindex.value.moveView + 1
  266. }
  267. // 判断答案 (学习计划适用逻辑)
  268. const checkAns = (index: number, itemData: Buttons) => {
  269. const targetData = props.board.ansList
  270. console.log(targetData, index, itemData);
  271. // 移动到了正确位置
  272. if (targetData[index].ans === itemData.color) {
  273. itemData.ans = targetData[index].color
  274. calcQuantityStore.correctQuantity++
  275. AudioController.playCorrect()
  276. itemData.disabled = true
  277. } else { // 移动到了錯誤位置
  278. AudioController.playWrong()
  279. calcQuantityStore.wrongCount++
  280. itemData.disabled = true
  281. itemData.ans = null
  282. const rockNode = document.getElementById(`rock-id-${itemData.id}`)
  283. rockNode?.classList.add('rock-button-ani')
  284. useSchedulerOnce(() => {
  285. disPatchButtonGoInitPos(itemData.id)
  286. rockNode?.classList.remove('rock-button-ani')
  287. itemData.disabled = false
  288. }, 1000)
  289. }
  290. calcQuantityStore.totalQuantity++
  291. // 全部正确后触发提交答案的emit
  292. const ansLen = buttons.filter( button => button.ans).length
  293. if ( ansLen === ( props.cardType === 0 ? 4 : 6 )) {
  294. emits('submit')
  295. }
  296. }
  297. // 让题卡图片支持双指捏合
  298. const createHammer = () => {
  299. var square = document.querySelector('#ques-url');
  300. var hammer = new Hammer(square);
  301. hammer.get('pinch').set({ enable: true });
  302. hammer.on('pinchmove pinchstart pinchin pinchout', (e) => {
  303. if (e.type == "pinchstart") {
  304. hammerState.scaleIndex = hammerState.scaleCount || 1
  305. }
  306. if (hammerState.scaleIndex * e.scale <= 1) {
  307. hammerState.scaleCount = 1
  308. hammerState.scaleIndex = 1
  309. return
  310. }
  311. hammerState.scaleCount = hammerState.scaleIndex * e.scale;
  312. console.log(hammerState.scaleCount);
  313. document.getElementById('quesRef')!.style.transform = `scale(${hammerState.scaleIndex * e.scale})`
  314. })
  315. hammer.on('panright panleft panup pandown', (e) => {
  316. document.getElementById('quesRef')!.style.transform = "translateX(" + (e.deltaX + hammerState.x) + "px)" + "translateY(" + (e.deltaY + hammerState.y) + "px)" + "scale(" + (hammerState.scaleCount) + ")";
  317. });
  318. hammer.on('doubletap', (e) => {
  319. hammerState.x = 0;
  320. hammerState.y = 0;
  321. hammerState.scaleCount = 1; // 重置缩放比例为1
  322. document.getElementById('quesRef')!.style.transform = "translateX(0px) translateY(0px) scale(1)"; // 重置位置和缩放效果
  323. });
  324. hammer.on('panend', (e) => {
  325. hammerState.x = e.deltaX + hammerState.x; // 记录水平方向上的偏移量
  326. hammerState.y = e.deltaY + hammerState.y; // 记录垂直方向上的偏移量
  327. console.log(hammerState.x);
  328. const node = document.getElementById('quesRef')
  329. if (hammerState.scaleCount <= 1) {
  330. node!.style.transform = `translateX(0px) translateY(0px) scale(1)`
  331. hammerState.x = 0
  332. hammerState.y = 0
  333. hammerState.scaleCount = 1
  334. hammerState.scaleIndex = 1
  335. } else {
  336. detectionEdge(0, 0)
  337. }
  338. });
  339. // 放大后拖拽露出边缘后, 回弹的判定函数 回弹条件是当前放大的倍数 * 题卡的(宽/高)
  340. const detectionEdge = (x: number, y: number) => {
  341. const node = document.getElementById('quesRef')
  342. const top = (quesRef.value?.height! * hammerState.scaleCount - quesRef.value?.height!) / 2
  343. const bottom = -top
  344. const left = (quesRef.value?.width! * hammerState.scaleCount - quesRef.value?.width!) / 2
  345. const right = -left
  346. if ( Math.abs(hammerState.y) > top) {
  347. hammerState.y = hammerState.y > 0 ? top: bottom
  348. }
  349. if ( Math.abs(hammerState.x) > left) {
  350. hammerState.x = hammerState.x > 0 ? left: right
  351. }
  352. node!.style.transform = `translateX(${hammerState.x}px) translateY(${hammerState.y}px) scale(${hammerState.scaleCount})`
  353. }
  354. }
  355. onMounted(() => {
  356. createHammer()
  357. })
  358. </script>
  359. <style lang="less" scoped >
  360. .luojigou-board {
  361. width: 714rpx;
  362. height: 1234rpx;
  363. position: relative;
  364. display: flex;
  365. justify-content: center;
  366. .board {
  367. width: 714rpx;
  368. height: 1234rpx;
  369. position: relative;
  370. .board-img {
  371. width: 100%;
  372. height: 100%;
  373. position: absolute;
  374. top: 0;
  375. left: 0;
  376. z-index: 1;
  377. }
  378. .board-header {
  379. width: 100%;
  380. height: 148rpx;
  381. position: relative;
  382. top: 7rpx;
  383. z-index: 12;
  384. .trumpt {
  385. width: 96rpx;
  386. height: 96rpx;
  387. position: relative;
  388. top: 44rpx;
  389. left: 54rpx;
  390. .dog {
  391. width: 96rpx;
  392. height: 96rpx;
  393. position: absolute;
  394. z-index: 1;
  395. }
  396. .trumpt-icon {
  397. // width: 40rpx;
  398. // height: 40rpx;
  399. position: absolute;
  400. z-index: 2;
  401. right: -12rpx;
  402. bottom: -6rpx;
  403. }
  404. }
  405. .tip {
  406. width: 490rpx;
  407. height: 72rpx;
  408. font-size: 24rpx;
  409. font-family: PingFangSC-Medium, PingFang SC;
  410. font-weight: 500;
  411. color: #ffffff;
  412. position: absolute;
  413. top: 50rpx;
  414. left: 176rpx;
  415. }
  416. .luojigou-dog {
  417. width: 84rpx;
  418. height: 84rpx;
  419. position: absolute;
  420. right: 46rpx;
  421. top: -48rpx;
  422. }
  423. }
  424. .card {
  425. position: absolute;
  426. top: 206rpx;
  427. left: 26rpx;
  428. z-index: 2;
  429. width: 450rpx;
  430. height: 772rpx;
  431. overflow: hidden;
  432. border-top-left-radius: 40rpx;
  433. border-bottom-left-radius: 40rpx;
  434. }
  435. .ques {
  436. width: 450rpx;
  437. height: 772rpx;
  438. border-right: 2rpx solid #006CAA;
  439. border-top-left-radius: 40rpx;
  440. border-bottom-left-radius: 40rpx;
  441. overflow: hidden;
  442. touch-action: none !important;
  443. scale: 1;
  444. box-sizing: border-box;
  445. image {
  446. width: 100%;
  447. height: 100%;
  448. display: block;
  449. // border-top-left-radius: 40rpx;
  450. // border-bottom-left-radius: 40rpx;
  451. }
  452. }
  453. .ans {
  454. width: 150rpx;
  455. height: 772rpx;
  456. display: flex;
  457. flex-direction: column;
  458. border-top-right-radius: 40rpx;
  459. border-bottom-right-radius: 40rpx;
  460. overflow: hidden;
  461. background-color: #fff;
  462. position: absolute;
  463. top: 206rpx;
  464. left: 476rpx;
  465. z-index: 2;
  466. image {
  467. width: 100%;
  468. height: 100%;
  469. object-fit: cover;
  470. }
  471. }
  472. .ans .ans-item:last-child {
  473. border-bottom: none
  474. }
  475. .buttons {
  476. position: relative;
  477. top: 200rpx;
  478. left: 20rpx;
  479. width: 680rpx;
  480. height: 1000rpx;
  481. image {
  482. position: absolute;
  483. left: 0;
  484. top: 0;
  485. }
  486. }
  487. .movable-area {
  488. width: 714rpx;
  489. height: 984rpx;
  490. background-color: transparent;
  491. position: absolute;
  492. top: 192rpx;
  493. z-index: 30;
  494. touch-action: none;
  495. left: 0px;
  496. -moz-transform: none;
  497. -webkit-transform: none;
  498. -o-transform: none;
  499. -ms-transform: none;
  500. transform: none;
  501. transform-origin: 0% 0%;
  502. .movable-view {
  503. width: 92rpx;
  504. height: 92rpx;
  505. touch-action: none;
  506. .movable-image {
  507. width: 100%;
  508. height: 100%;
  509. transition: width 0.2s;
  510. z-index: 1;
  511. }
  512. .success-flag {
  513. width: 36rpx;
  514. height: 26rpx;
  515. display: block;
  516. position: absolute;
  517. top: 50%;
  518. left: 50%;
  519. transform: translate(-50%, -50%);
  520. z-index: 2;
  521. }
  522. }
  523. }
  524. }
  525. .mark-button {
  526. width: 100%;
  527. height: 100%;
  528. position: absolute;
  529. top: 0px;
  530. left: 0px;
  531. z-index: 29;
  532. .movable-image {
  533. position: absolute;
  534. width: 92rpx;
  535. height: 92rpx;
  536. display: block;
  537. }
  538. }
  539. .opra {
  540. width: 100vw;
  541. height: 1216rpx;
  542. background: url('../../assets/boardBg.png');
  543. display: flex;
  544. justify-content: center;
  545. padding-top: 46rpx;
  546. }
  547. }
  548. .rock-button-ani {
  549. animation: rock 1s;
  550. }
  551. @keyframes rock {
  552. 0% {
  553. rotate: 0deg;
  554. }
  555. 25% {
  556. rotate: -10deg;
  557. }
  558. 50% {
  559. rotate: 10deg;
  560. }
  561. 75% {
  562. rotate: -10deg;
  563. }
  564. 100% {
  565. rotate: 0deg;
  566. }
  567. }
  568. /deep/ .uni-scroll-view-content {
  569. display: flex;
  570. align-items: center;
  571. }
  572. </style>