finish quiz

This commit is contained in:
feie9456 2025-08-04 11:34:29 +08:00
parent 7e9847f287
commit 8338692464
24 changed files with 623 additions and 177 deletions

View File

@ -106,15 +106,15 @@ app.post('/api/score', async (req, res) => {
}
});
// GET /api/score - 获取排行榜(前6名)
// GET /api/score - 获取排行榜(前8名)
app.get('/api/score', async (req, res) => {
try {
const scores = await readScores();
// 按时间排序(时间短的在前)并取前6
// 按时间排序(时间短的在前)并取前8
const leaderBoard = scores
.sort((a, b) => a.time - b.time)
.slice(0, 6)
.slice(0, 8)
.map(score => ({
name: score.name,
time: score.time,

View File

@ -5,7 +5,7 @@ import Page1 from './pages/Page1.vue';
import assets from './assets';
import Loader from './pages/Loader.vue';
const stage = ref(1);
const stage = ref(-1);
const userData = ref({
region: '奥莱',

View File

@ -1,78 +0,0 @@
import os
from PIL import Image
import re
# --- 配置 ---
# 目标尺寸
TARGET_WIDTH = 670
TARGET_HEIGHT = 939
# 裁剪区域 (left, upper, right, lower)
# 我们从左上角 (0, 0) 开始,裁剪出一个 670x939 的区域
CROP_BOX = (0, 0, TARGET_WIDTH, TARGET_HEIGHT)
# 输出文件夹名称
OUTPUT_DIR = "cropped"
# --- 结束配置 ---
def batch_crop_images():
"""
批量裁剪当前目录下的 WebP 图片
"""
# 获取当前脚本所在的目录
current_dir = os.getcwd()
output_path = os.path.join(current_dir, OUTPUT_DIR)
# 如果输出目录不存在,则创建它
if not os.path.exists(output_path):
os.makedirs(output_path)
print(f"已创建输出目录: {output_path}")
# 获取目录下所有文件
files = os.listdir(current_dir)
# 定义一个正则表达式来匹配 '数字.webp' 格式的文件名
file_pattern = re.compile(r"^\d+\.webp$")
image_files_to_process = [f for f in files if file_pattern.match(f)]
if not image_files_to_process:
print("未在当前目录找到符合 '数字.webp' 格式的图片文件。")
return
print(f"找到了 {len(image_files_to_process)} 个待处理的图片文件。")
# 遍历所有符合条件的图片文件
for filename in image_files_to_process:
try:
# 构建完整的文件路径
file_path = os.path.join(current_dir, filename)
# 打开图片
with Image.open(file_path) as img:
print(f"正在处理: {filename} (原始尺寸: {img.size[0]}x{img.size[1]})")
# 检查原始尺寸是否符合预期
if img.size != (671, 940):
print(f" -> 警告: {filename} 的尺寸 ({img.size[0]}x{img.size[1]}) 与预期的 671x940 不符,已跳过。")
continue
# 进行裁剪
cropped_img = img.crop(CROP_BOX)
# 构建输出文件路径
save_path = os.path.join(output_path, filename)
# 保存裁剪后的图片
# quality=100 可以尽量保持高质量,你可以根据需要调整
cropped_img.save(save_path, "WEBP", quality=100)
print(f" -> 已裁剪并保存至: {save_path} (新尺寸: {cropped_img.size[0]}x{cropped_img.size[1]})")
except Exception as e:
print(f"处理 {filename} 时发生错误: {e}")
print("\n所有图片处理完成!")
if __name__ == "__main__":
batch_crop_images()

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
src/assets/game/对.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
src/assets/game/对.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 726 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
src/assets/game/罚时.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
src/assets/game/罚时.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

BIN
src/assets/game/错.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
src/assets/game/错.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 790 B

BIN
src/assets/game/闹钟.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 978 B

View File

@ -20,7 +20,7 @@ export default {
ani:{
下拉蓄力提示: new URL('./ani/下拉蓄力提示.zip', import.meta.url).href,
小太阳总: new URL('./ani/小太阳总.zip', import.meta.url).href,
效果: new URL('./ani/后段效果.zip', import.meta.url).href,
效果: new URL('./ani/后段效果.zip', import.meta.url).href,
主标出现: new URL('./ani/主标出现.zip', import.meta.url).href,
线: new URL('./ani/线.zip', import.meta.url).href,
P1太阳总: new URL('./ani/P1太阳总.zip', import.meta.url).href,

View File

@ -53,7 +53,6 @@ const currentLoopCount = ref(0)
const animationId = ref<number>()
const lastFrameTime = ref(0)
const pendingJumpTo = ref<string>()
// : soft jump Promise resolve
const pendingJumpResolver = ref<((success: boolean) => void) | null>(null)
@ -118,30 +117,45 @@ const loadImageSequence = async (zipUrl: string) => {
}
}
// loadAllImages ()
// loadAllImages ()
const loadAllImages = async () => {
const imageElements: HTMLImageElement[] = []
let dimensionsSet = false; //
for (let i = 0; i < images.value.length; i++) {
const img = new Image()
img.src = images.value[i]
await new Promise<void>((resolve, reject) => {
await new Promise<void>((resolve) => {
img.onload = () => {
if (i === 0) {
// 使
if (!dimensionsSet) {
canvasWidth.value = props.width || img.width
canvasHeight.value = props.height || img.height
dimensionsSet = true
}
resolve()
}
img.onerror = reject
img.onerror = () => {
//
console.warn(`[Animation Component] Image at index ${i} failed to load. It will be rendered as a transparent frame.`);
// 使 resolve
resolve();
}
})
imageElements.push(img)
progress.value = 80 + (i + 1) / images.value.length * 20
}
if (!dimensionsSet) {
console.warn("[Animation Component] All images failed to load. Using specified or default canvas dimensions.");
}
loadedImages.value = imageElements
}
// drawFrame ()
const drawFrame = (frameIndex: number) => {
if (!canvasRef.value || !loadedImages.value.length) return
@ -153,21 +167,19 @@ const drawFrame = (frameIndex: number) => {
ctx.clearRect(0, 0, canvas.width, canvas.height)
const img = loadedImages.value[safeIndex]
// imgImagedrawImage
// 使
if (img) {
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
}
}
// =================================================================
// 1: jumpToRule
// =================================================================
const jumpToRule = (ruleName: string): boolean => {
const ruleIndex = findRuleIndex(ruleName)
if (ruleIndex === -1) {
console.warn(`未找到名为 "${ruleName}" 的规则`)
// Promise
if (pendingJumpResolver.value) {
pendingJumpResolver.value(false); // resolve(false)
pendingJumpResolver.value(false);
pendingJumpResolver.value = null;
}
return false
@ -204,7 +216,6 @@ const jumpToRule = (ruleName: string): boolean => {
lastFrameTime.value = performance.now()
animate()
// : Promise
if (pendingJumpResolver.value) {
pendingJumpResolver.value(true);
pendingJumpResolver.value = null;
@ -319,7 +330,6 @@ const animate = () => {
}
};
// stopAnimation ()
const stopAnimation = () => {
isPlaying.value = false
isPaused.value = false
@ -327,21 +337,18 @@ const stopAnimation = () => {
cancelAnimationFrame(animationId.value)
animationId.value = undefined
}
// :
if (pendingJumpResolver.value) {
pendingJumpResolver.value(false); //
pendingJumpResolver.value(false);
pendingJumpResolver.value = null;
}
pendingJumpTo.value = undefined;
}
// resetAnimation ()
const resetAnimation = () => {
stopAnimation()
currentRuleIndex.value = 0
currentFrame.value = 0
currentLoopCount.value = 0
// pendingJumpTo pendingJumpResolver stopAnimation
if (loadedImages.value.length > 0) {
drawFrame(0)
}
@ -365,9 +372,6 @@ const resumeFromPause = () => {
}
}
// =================================================================
// 2:
// =================================================================
/**
* 立即跳转到指定规则会打断当前动画
* @param {string} ruleName 要跳转到的规则名
@ -391,16 +395,13 @@ const jumpToSoftly = (ruleName: string): Promise<boolean> => {
return;
}
//
if (pendingJumpResolver.value) {
pendingJumpResolver.value(false);
}
// Promise resolver
pendingJumpResolver.value = resolve;
if (isPaused.value || !isPlaying.value) {
if(props.log) console.log(`动画已暂停或停止。立即执行跳转到 "${ruleName}"。`);
// jumpToRule pendingJumpResolver
jumpToRule(ruleName);
} else if (isPlaying.value) {
if(props.log) console.log(`已计划软跳转到 "${ruleName}"。将在当前循环结束后触发。`);
@ -413,7 +414,7 @@ const jumpToSoftly = (ruleName: string): Promise<boolean> => {
// watch ()
watch(() => props.url, (newUrl) => {
if (newUrl) {
resetAnimation() // 使 reset
resetAnimation()
images.value.forEach(url => URL.revokeObjectURL(url));
images.value = [];
loadedImages.value = [];
@ -431,7 +432,7 @@ watch(() => props.rules, () => {
}, { deep: true })
onBeforeUnmount(() => {
resetAnimation(); // 使 reset
resetAnimation();
images.value.forEach(url => URL.revokeObjectURL(url));
});
@ -440,7 +441,7 @@ const findRuleIndex = (ruleName: string): number => {
return props.rules.findIndex(rule => rule.name === ruleName)
}
// defineExpose ()
// defineExpose ()
defineExpose({
images,
loadedImages,

28
src/components/Button.vue Normal file
View File

@ -0,0 +1,28 @@
<script setup lang="ts">
defineEmits<{
(e: 'click'): void;
}>();
import eas from '../assets/sounds';
</script>
<template>
<button class="btn" @click="eas.play('按钮音效'); $emit('click')">
<slot></slot>
</button>
</template>
<style scoped lang="scss">
.btn {
cursor: pointer;
border-radius: 6vw 6vw 6vw 0;
height: 12vw;
width: 34vw;
background-color: black;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 4vw;
}
</style>

View File

@ -630,3 +630,8 @@ export const arcRetailQuiz: QuizQuestion[] = [
],
},
];
export function getRandomQuizQuestions(count: number): QuizQuestion[] {
const shuffled = [...arcRetailQuiz].sort(() => 0.5 - Math.random());
return shuffled.slice(0, count);
}

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, useTemplateRef, watch, type Ref } from 'vue';
import { computed, onMounted, reactive, ref, TransitionGroup, useTemplateRef, watch, type Ref } from 'vue';
import assets from '../assets';
import AniEle from '../components/AniEle.vue';
import LastPage from './Game/LastPage.vue';
@ -7,6 +7,7 @@ import AudioEffects from '../assets/sounds'
import GatheringCloud from './Game/GatheringCloud.vue';
import ScoreBoard from './Game/ScoreBoard.vue';
import ActionArea from './Game/ActionArea.vue';
import Quiz from './Game/Quiz.vue';
defineExpose({
init: () => {
@ -103,6 +104,18 @@ const leaderBoard = ref([
"time": 122.2,
"region": "大南区",
"store": "深圳湾万象城"
},
{
"name": "Elena2",
"time": 122.4,
"region": "大南区",
"store": "深圳湾万象城"
},
{
"name": "Elena3",
"time": 122.7,
"region": "大南区",
"store": "深圳湾万象城"
}
])
const showTitleIcon = ref(true)
@ -382,20 +395,6 @@ function gToggle(pos: 'left' | 'center' | 'right') {
async function gameEnd() {
fetch('/api/score', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
region: props.userdata.region,
store: props.userdata.store,
name: props.userdata.username,
time: timeSpent.value / 1000,
})
}).then(_ => {
fetch('/api/score', {
headers: { 'Content-Type': 'application/json' }
}).then(res => res.json()).then(data => leaderBoard.value = data);
})
document.querySelector('.bed')?.animate([
{ transform: 'translateY(170%)' },
@ -430,23 +429,36 @@ async function gameEnd() {
await wait(1800)
showEndAni.value = true;
sunEndEle.value?.jumpTo('all');
sunEndEle.value?.jumpTo('进入');
await wait(3400);
gameState.value = GAME_STATE.leaderboard;
}
document.querySelector('.bed')?.animate([
{ transform: 'translateY(0)' },
{ transform: 'translateY(170%)' },
async function startQuiz() {
document.querySelector('.start-quiz-btn')?.animate([
{ scale: 1 },
{ scale: 0 },
], {
duration: 600,
duration: 500,
easing: 'cubic-bezier(0.4, 0, 1, 0.5)',
fill: 'forwards',
});
gatheringCloud.value?.titleOut();
await sunEndEle.value?.jumpToSoftly('落下出屏幕');
document.querySelector('.bed')?.animate([
{ transform: 'translateY(0%)' },
{ transform: 'translateY(170%)' },
], {
duration: 400,
easing: 'cubic-bezier(0.4, 0, 1, 0.5)',
fill: 'forwards',
});
await wait(900);
gameState.value = GAME_STATE.quiz;
}
async function finishCollect() {
document.querySelector('.finish-collect')?.animate([
document.querySelector('.finish-collect-btn')?.animate([
{ transform: 'scale(1)' },
{ transform: 'scale(0)' },
], {
@ -455,7 +467,6 @@ async function finishCollect() {
fill: 'forwards',
})
gatheringCloud.value?.titleOut();
showTitleIcon.value = true;
await document.querySelector('.lines')?.animate([
@ -464,7 +475,8 @@ async function finishCollect() {
easing: 'ease-in-out',
fill: 'forwards',
}).finished
await sunEndEle.value?.jumpToSoftly('落下');
await sunEndEle.value?.jumpToSoftly('落下进蹦床');
await document.querySelector('.bar-container')?.animate([
{ transform: 'translateY(300%)' }], {
duration: 500,
@ -487,6 +499,42 @@ const fmtTime = (time: number) => {
const seconds = time % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
async function handleFinishQuiz(time: number, choices: ('none' | 'selected' | 'correct' | 'wrong' | 'miss')[][]) {
//
timeSpent.value = time;
fetch('/api/score', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
region: props.userdata.region,
store: props.userdata.store,
name: props.userdata.username,
time: timeSpent.value,
})
}).then(_ => {
fetch('/api/score', {
headers: { 'Content-Type': 'application/json' }
}).then(res => res.json()).then(data => leaderBoard.value = data);
})
await wait(1500);
sunEndEle.value?.jumpTo('向上进入')
timeSpent.value = time;
console.log('Quiz finished:', time, choices);
await wait(300)
gameState.value = GAME_STATE.leaderboard;
}
import Button from '../components/Button.vue';
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
giftProgress.value = 7;
}
});
</script>
<template>
@ -514,12 +562,12 @@ const fmtTime = (time: number) => {
{ name: '起飞前', frame: 90, loop: 0, pauseAfter: true, duration: 33 },
{ name: '蓄力飞', frame: 181, loop: 1, pauseAfter: false, duration: 33 },
{ name: '天上飞', frame: 90, loop: 0, pauseAfter: true, duration: 33 },
{ name: '落下', frame: 382, loop: 0, pauseAfter: false, duration: 33 },
{ name: '落下', frame: 382, loop: 1, pauseAfter: false, duration: 33 },
]" style="width: 100%; height: auto;" />
</div>
</div>
<div v-if="gameState >= GAME_STATE.ingame && gameState < GAME_STATE.poster">
<div class="in-game" v-if="gameState >= GAME_STATE.ingame && gameState < GAME_STATE.poster">
<ActionArea @gToggle="gToggle" ref="action-area" />
<img class="abs gift-item" v-for="(gift, index) in giftList" :src="gift[0]" style="width: 20vw;"
@ -539,32 +587,46 @@ const fmtTime = (time: number) => {
</div>
</div>
<div class="game-end abs" v-if="gameState >= GAME_STATE.gameEnd"
style="z-index: 2; inset: 0; height: 100%; width: 100%;pointer-events: none;">
<GatheringCloud ref="gatheringCloud" />
<ScoreBoard :leaderBoard="leaderBoard" :fmtTime="fmtTime" v-if="gameState >= GAME_STATE.leaderboard" />
<div class="sun-ani-end-wrapper abs" style="pointer-events: none; inset: 0; width: 100%;z-index: 2;"
:style="{ display: showEndAni ? 'block' : 'none' }">
<AniEle :url="assets.ani.后端效果" ref="sun-ani-end" class="sun-ani-end" :height="2462" :width="1179"
:rules="[
{ name: 'all', frame: 164, loop: 1, pauseAfter: false, duration: 33 },
{ name: '左右跳', frame: 66, loop: 0, pauseAfter: true, duration: 33 },
{ name: '落下', frame: 32, loop: 1, pauseAfter: false, duration: 33 },
{ name: 'loop', frame: 33, loop: 0, pauseAfter: true, duration: 33 },
<AniEle :url="assets.ani.后段效果" ref="sun-ani-end" class="sun-ani-end" :height="2462" :width="1179" :rules="[
// 0 - 97
{ name: '进入', frame: 98, loop: 1, pauseAfter: false, duration: 33 },
// 98 - 129
{ name: '落下前循环', frame: 32, loop: 0, pauseAfter: true, duration: 33 },
// 130 - 161
{ name: '落下出屏幕', frame: 32, loop: 1, pauseAfter: true, duration: 33 },
// 162 - 227
{ name: '向上进入', frame: 66, loop: 1, pauseAfter: false, duration: 33 },
// 228 - 293
{ name: '左右循环', frame: 66, loop: 0, pauseAfter: true, duration: 33 },
// 294 - 326
{ name: '落下进蹦床', frame: 33, loop: 1, pauseAfter: false, duration: 33 },
// 327 - 358
{ name: '蹦床弹跳循环', frame: 32, loop: 0, pauseAfter: false, duration: 33 },
]" style="width: 100%; height: auto;" />
</div>
<Transition name="fade">
<div class="game-end" v-if="gameState == GAME_STATE.gameEnd">
<GatheringCloud ref="gatheringCloud" />
<Button @click="startQuiz" class="start-quiz-btn">开始答题</Button>
</div>
</Transition>
<Quiz :fmt-time="fmtTime" v-if="gameState == GAME_STATE.quiz" @finish-quiz="handleFinishQuiz" />
<div class="scoreboard" v-if="gameState >= GAME_STATE.leaderboard">
<ScoreBoard :leaderBoard="leaderBoard" :fmtTime="fmtTime" v-if="gameState >= GAME_STATE.leaderboard" />
<img src="../assets/game/完成收集.webp" alt="" v-if="gameState >= GAME_STATE.leaderboard"
class="finish-collect abs" @click.once="AudioEffects.play('按钮音效'); finishCollect()"
style="bottom: 8%;left: 33%;width: 34vw;animation: scale-in 0.3s ease-out;pointer-events: all;">
@click.once="AudioEffects.play('按钮音效'); finishCollect()" class="finish-collect-btn abs">
</div>
<LastPage :userdata="userdata" :time-spent="timeSpent" v-if="gameState == GAME_STATE.poster" :fmt-time="fmtTime"
<LastPage :userdata="userdata" :time-spent="fmtTime(timeSpent)" v-if="gameState == GAME_STATE.poster"
@reset-game="emit('restart')" @show-share-mask="showShareMask = true" />
<Transition name="gift-popup">
@ -582,7 +644,7 @@ const fmtTime = (time: number) => {
<Transition name="fade">
<div v-if="showShareMask" class="share-mask" @click="showShareMask = false">
<img src="../assets/game/share_mask.png" alt="">
<img src="../assets/game/share_mask.webp" alt="">
</div>
</Transition>
@ -590,6 +652,26 @@ const fmtTime = (time: number) => {
</template>
<style lang="scss">
.finish-collect-btn {
scale: 0;
animation: scale-in 0.5s ease-out forwards;
animation-delay: 1s;
bottom: 8%;
left: 33%;
width: 34vw;
pointer-events: all;
}
.start-quiz-btn {
animation: scale-in 0.5s ease-out forwards;
scale: 0;
animation-delay: 4.5s;
position: absolute;
bottom: 10%;
left: 33%;
width: 34%;
}
.share-mask {
position: fixed;
inset: 0;
@ -656,21 +738,24 @@ const fmtTime = (time: number) => {
@keyframes bar-in {
from {
transform: translateY(300%);
opacity: 0;
}
to {
transform: translateY(0%);
opacity: 1;
}
}
@keyframes line-in {
from {
transform: translateX(-210%);
opacity: 0;
}
to {
transform: translateX(-0%);
opacity: 1;
}
}

View File

@ -9,8 +9,7 @@ defineProps<{
store: string;
username: string;
},
timeSpent: number;
fmtTime: (time: number) => string;
timeSpent: string;
}>();
import QRCode from 'qrcode';
const qrcodeUrl = ref('');
@ -119,7 +118,7 @@ onMounted(() => {
<div class="time"
style="font-size: 10vw;animation: line-in 0.5s ease-out 0.8s forwards;transform: translateX(-210%);">
{{ fmtTime(timeSpent / 1000) }}</div>
{{ timeSpent}}</div>
<img :src="qrcodeUrl" alt=""
style="width: 100%;bottom:7%;position: absolute;animation: line-in 0.5s ease-out 1s forwards;transform: translateX(-210%);">

View File

@ -1,11 +1,416 @@
<script setup lang="ts">
import { reactive, ref, type Reactive } from 'vue';
import assets from '../../assets';
import AniEle from '../../components/AniEle.vue';
import { getRandomQuizQuestions } from '../../data';
const total = 5; //
const qIndex = ref(0); //
const qs = getRandomQuizQuestions(5)
const time = ref(0);
defineProps<{
fmtTime: (time: number) => string;
}>();
const showSelectNotice = ref(false);
import eas from '../../assets/sounds';
import Button from '../../components/Button.vue';
function playConfirmSound(correct: boolean) {
if (correct) {
eas.play('正确')
} else {
eas.play('错误')
}
}
const choiceState: Reactive<('none' | 'selected' | 'correct' | 'wrong' | 'miss')[][]> = reactive(
qs.map((q) => q.options.map(() => 'none'))
);
setInterval(() => {
if (confirmState.value == 'confirmed') return
time.value += 1;
}, 1000);
const emit = defineEmits<{
(e: 'finishQuiz', time: number, choices: ('none' | 'selected' | 'correct' | 'wrong' | 'miss')[][]): void;
}>();
function finishQuiz() {
document.querySelector('.quiz-card')?.animate([
{ scale: 1, opacity: 1 },
{ scale: 0.5, opacity: 0 }
], {
duration: 600,
easing: 'ease-in-out',
fill: 'forwards',
});
document.querySelector('.confirm-btn')?.animate([
{ scale: 1 },
{ scale: 0 }
], {
duration: 400,
easing: 'ease-in-out',
fill: 'forwards',
});
titleAniRules.value[0].reverse = true;
emit('finishQuiz', time.value, choiceState);
}
function confirmChoices() {
if (confirmState.value === 'confirmed') {
showSelectNotice.value = false;
if (qIndex.value >= total - 1) {
finishQuiz();
return;
}
confirmState.value = 'none';
qIndex.value++;
return;
}
const currentChoices = choiceState[qIndex.value];
if (currentChoices.every(choice => choice === 'none')) {
showSelectNotice.value = true;
return;
} else {
showSelectNotice.value = false;
}
const currentQuestion = qs[qIndex.value];
let correctCount = 0;
let wrongCount = 0;
const correctAnswersIndexs = currentQuestion.answers.map(answer => currentQuestion.options.indexOf(answer));
currentChoices.forEach((choice, index) => {
if (choice === 'selected') {
if (correctAnswersIndexs.includes(index)) {
choiceState[qIndex.value][index] = 'correct';
correctCount++;
} else {
choiceState[qIndex.value][index] = 'wrong';
wrongCount++;
}
} else if (correctAnswersIndexs.includes(index)) {
choiceState[qIndex.value][index] = 'miss';
wrongCount++;
}
});
if (wrongCount > 0) {
playConfirmSound(false);
time.value += wrongCount * 10; // 10
} else {
playConfirmSound(true);
}
confirmState.value = 'confirmed';
}
const titleAniRules = ref([
{ name: 'main', frame: 60, duration: 33, loop: 1, pauseAfter: true, reverse: false }
])
const confirmState = ref<'none' | 'confirmed'>('none');
</script>
<template>
<AniEle :url="assets.ani.答题标题" :width="1000" :height="500" :rules="[
{ name: 'main', frame: 60, duration: 33, loop: 1, pauseAfter: true, reverse: false }
]" />
<div class="quiz">
<AniEle :url="assets.ani.答题标题" :width="1000" :height="500" :rules="titleAniRules" class="quiz-page-title" />
<div class="quiz-card">
<img src="../../assets/game/答题卡片背景.webp" alt="" class="quiz-card-bg">
<div class="quiz-card-main">
<div class="quiz-type">{{ qs[qIndex].type == 'single' ? '单选题' : '多选题' }}</div>
<div class="time">
<img src="../../assets/game/闹钟.webp" alt="" class="time-icon">
<span class="time-text">{{ fmtTime(time) }}</span>
</div>
<div class="quiz-content-wrap" :style="{ transform: `translateX(-${qIndex * 100 / total}%)` }">
<div class="quiz-content" v-for="(qu, index) in qs" :key="index">
<div class="quiz-title">{{ qu.question }}</div>
<div class="quiz-choice-container">
<div class="quiz-choice" v-for="(val, i) in qu.options" :key="val"
:style="{ animationDelay: `${(i + 8) * 0.1}s` }" :class="choiceState[index][i]" @click="() => {
if (choiceState[index][i] === 'none') {
choiceState[index][i] = 'selected';
} else if (choiceState[index][i] === 'selected') {
choiceState[index][i] = 'none';
}
}">
<span class="choice-letter">{{ String.fromCharCode(65 + i) }}</span>
<span class="choice-text">{{ val }}</span>
<img src="../../assets/game/罚时.webp" alt="" class="wrong-add-time-icon"
v-if="choiceState[index][i] === 'wrong' || choiceState[index][i] === 'miss'"></img>
<img src="../../assets/game/对.webp" alt="" class="right-wrong-icon"
v-if="choiceState[index][i] === 'correct' || choiceState[index][i] === 'miss'">
<img src="../../assets/game/错.webp" alt="" class="right-wrong-icon"
v-if="choiceState[index][i] === 'wrong'">
</div>
</div>
</div>
</div>
<div class="quiz-progress dot">
<div class="dot-item" v-for="(val, i) in Array.from({ length: total })" :key="i" @click="qIndex = i"
:class="{ active: i <= qIndex }"></div>
</div>
</div>
</div>
<Transition name="fade">
<img src="../../assets/game/请选择你的答案.webp" alt="" v-if="showSelectNotice" class="select-notice"
style="position: absolute; bottom: 18%; left: 50%; transform: translateX(-50%); width: 34%; z-index: 1000;">
</Transition>
<Button @click="confirmChoices" class="confirm-btn">{{ confirmState === 'confirmed' ?
qIndex === total - 1 ? '完成答题' : '下一题'
: '确认' }}</Button>
</div>
</template>
<style scoped lang="scss">
.confirm-btn {
position: absolute;
bottom: 10%;
left: 50%;
transform: translateX(-50%);
width: 34%;
animation: scale-in 0.3s ease-out forwards;
}
.quiz-page-title {
position: absolute;
top: 5%;
left: 50%;
transform: translateX(-50%);
width: 84%;
}
.quiz {
height: 100%;
width: 100%;
position: absolute;
inset: 0;
}
.quiz-card {
position: absolute;
left: 5%;
width: 90%;
height: fit-content;
overflow: hidden;
top: 23%;
z-index: 99;
animation: scale-in 0.5s ease-out forwards;
}
.quiz-card-bg {
width: 100%;
position: relative;
}
.quiz-card-main {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
mask-image: linear-gradient(to right, transparent 5%, black 10%, black 95%, transparent);
}
.quiz-type {
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, 0);
background: #7194C1;
color: white;
font-size: 5vw;
height: 8vw;
line-height: 8vw;
border-radius: 5vw;
padding: 0 4.5vw;
animation: fade-in 0.5s ease-out forwards;
opacity: 0;
animation-delay: 500ms;
}
.time {
position: absolute;
top: 10%;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 2vw;
animation: fade-in 0.5s ease-out forwards;
opacity: 0;
animation-delay: 600ms;
}
.time-icon {
width: 5vw;
height: 5vw;
}
.time-text {
font-size: 5vw;
color: #333;
}
.quiz-content-wrap {
display: flex;
align-self: flex-start;
width: 500%;
height: 100%;
transition: transform 0.3s ease-in-out;
margin-top: 10%;
}
.quiz-content {
width: 20%;
flex-shrink: 0;
display: flex;
flex-direction: column;
justify-content: flex-start;
padding: 15vw 10vw;
}
.quiz-title {
font-size: 4.2vw;
line-height: 1.6;
color: #333;
margin-bottom: 6%;
font-weight: 500;
text-align: left;
animation: fade-in 0.5s ease-out forwards;
opacity: 0;
animation-delay: 700ms;
}
.quiz-choice-container {
display: flex;
flex-direction: column;
gap: 3vw;
}
.quiz-choice {
display: flex;
align-items: center;
gap: 4vw;
cursor: pointer;
transition: all 0.2s ease;
height: 12vw;
border-radius: 6vw;
padding: 0 5vw;
scale: 0;
animation: scale-in 0.3s ease-out forwards;
.choice-letter {
font-size: 4vw;
flex-shrink: 0;
}
.choice-text {
flex: 1;
font-size: 3vw;
line-height: 1.3;
word-break: break-all;
}
.wrong-add-time-icon {
width: 10vw;
}
.right-wrong-icon {
width: 5vw;
height: 5vw;
}
&.none {
background-color: #F6F6F6;
.choice-letter {
color: #2E2E2E;
}
.choice-text {
color: #5B5C5B;
}
}
&.selected,
&.correct {
background-color: #7396C4;
.choice-letter {
color: white;
}
.choice-text {
color: white;
}
}
&.wrong,
&.miss {
background-color: #000000;
.choice-letter {
color: white;
}
.choice-text {
color: white;
}
}
}
.quiz-progress {
bottom: 8%;
top: unset;
height: 2vw;
}
.dot .dot-item {
background-color: #E2ECF9;
&.active {
background-color: #7396C4;
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@ -25,7 +25,7 @@ defineProps<{
</div>
</div>
<div class="lines">
<div class="line" v-for="(item, index) in leaderBoard.slice(3, 7)" :key="index"
<div class="line" v-for="(item, index) in leaderBoard.slice(3, 9)" :key="index"
:style="{ animationDelay: index * 100 + 400 + 'ms' }">
<div class="rank">{{ index + 4 }}</div>
<div class="name">{{ item.name }}</div>
@ -40,7 +40,7 @@ defineProps<{
.scoreboard {
position: absolute;
top: 44%;
top: 16.4%;
width: 100%;
.bar-container {
@ -63,8 +63,8 @@ defineProps<{
align-items: center;
justify-content: center;
gap: 1.5vw;
animation: bar-in 0.7s ease-out forwards;
transform: translateY(150%);
animation: bar-in 0.5s ease-out forwards;
opacity: 0;
.name {
font-size: 4.5vw;
@ -90,7 +90,7 @@ defineProps<{
}
&.first {
top: 3vw;
top: -5vw;
background-image: url('../../assets/game/first.webp');
animation-delay: 0ms;
}
@ -104,17 +104,17 @@ defineProps<{
&.third {
background-image: url('../../assets/game/third.webp');
animation-delay: 500ms;
top: 9vw;
top: 23vw;
}
}
.lines {
display: flex;
position: relative;
top: -5vw;
top: 16vw;
flex-direction: column;
align-items: center;
gap: 1vw;
gap: 2vw;
.line {
display: flex;
@ -127,7 +127,8 @@ defineProps<{
height: 8vw;
border-radius: 8vw;
animation: line-in 0.5s ease-out forwards;
transform: translateX(-150%);
box-shadow: 0 0 2vw rgba(0, 0, 0, 0.1);
opacity: 0;
.rank {
// italic