finish quiz
@ -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,
|
||||
|
||||
@ -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: '奥莱',
|
||||
|
||||
@ -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()
|
||||
|
Before Width: | Height: | Size: 57 KiB |
BIN
src/assets/game/share_mask.webp
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src/assets/game/对.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/assets/game/对.webp
Normal file
|
After Width: | Height: | Size: 726 B |
BIN
src/assets/game/确认按钮.webp
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
src/assets/game/答题卡片背景.webp
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
src/assets/game/罚时.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/assets/game/罚时.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/assets/game/请选择你的答案.webp
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
src/assets/game/错.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/assets/game/错.webp
Normal file
|
After Width: | Height: | Size: 790 B |
BIN
src/assets/game/闹钟.webp
Normal file
|
After Width: | Height: | Size: 978 B |
@ -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,
|
||||
|
||||
@ -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]
|
||||
// 如果img加载失败,它仍然是一个Image对象,但drawImage不会绘制任何内容,
|
||||
// 从而使该帧保持透明,这正是我们想要的效果。
|
||||
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
@ -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>
|
||||
@ -629,4 +629,9 @@ export const arcRetailQuiz: QuizQuestion[] = [
|
||||
'网站(https://global.helpwhereyouare.com)',
|
||||
],
|
||||
},
|
||||
];
|
||||
];
|
||||
|
||||
export function getRandomQuizQuestions(count: number): QuizQuestion[] {
|
||||
const shuffled = [...arcRetailQuiz].sort(() => 0.5 - Math.random());
|
||||
return shuffled.slice(0, count);
|
||||
}
|
||||
@ -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="sun-ani-end-wrapper abs" style="pointer-events: none; inset: 0; width: 100%;z-index: 2;"
|
||||
:style="{ display: showEndAni ? 'block' : 'none' }">
|
||||
|
||||
<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 },
|
||||
]" style="width: 100%; height: auto;" />
|
||||
</div>
|
||||
|
||||
<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;">
|
||||
<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>
|
||||
|
||||
<LastPage :userdata="userdata" :time-spent="timeSpent" v-if="gameState == GAME_STATE.poster" :fmt-time="fmtTime"
|
||||
<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"
|
||||
@click.once="AudioEffects.play('按钮音效'); finishCollect()" class="finish-collect-btn abs">
|
||||
</div>
|
||||
|
||||
|
||||
<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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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%);">
|
||||
|
||||
@ -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 }
|
||||
]" />
|
||||
</template>
|
||||
<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>
|
||||
@ -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
|
||||
|
||||