Commit 50759d94 authored by 董政锦's avatar 董政锦

feat: game3 初版完成;

parent 0d467745
...@@ -5,7 +5,7 @@ import MobileStage from '@/components/MobileStage.vue' ...@@ -5,7 +5,7 @@ import MobileStage from '@/components/MobileStage.vue'
import { useGameSocket, joinSharedRoom, sendGameMessage, userJoinStatus } from '@/composables/useGameSocket' import { useGameSocket, joinSharedRoom, sendGameMessage, userJoinStatus } from '@/composables/useGameSocket'
import LoadingView from './views/LoadingView.vue' import LoadingView from './views/LoadingView.vue'
import PlayingView from './views/PlayingView.vue' import PlayingView from './views/PlayingView.vue'
import { $getWechat, $toast } from '@/commons/utils.ts' import { $getWechat, $toast, $is_run_local } from '@/commons/utils.ts'
import { playGame6Music, stopGame6Music } from '@/commons/music'; import { playGame6Music, stopGame6Music } from '@/commons/music';
type GameView = 'loading' | 'playing' type GameView = 'loading' | 'playing'
...@@ -19,6 +19,8 @@ type RankPlayer = { ...@@ -19,6 +19,8 @@ type RankPlayer = {
const imageUrls: Record<string, string> = { const imageUrls: Record<string, string> = {
bg: cssAssetUrl('game3/bg.webp'), bg: cssAssetUrl('game3/bg.webp'),
bg3: cssAssetUrl('game3/bg3.webp'), bg3: cssAssetUrl('game3/bg3.webp'),
left: cssAssetUrl('game3/icon_zuo.webp'),
right: cssAssetUrl('game3/icon_you.webp'),
bg4: cssAssetUrl('game3/pic_paodao.webp'), bg4: cssAssetUrl('game3/pic_paodao.webp'),
logo: cssAssetUrl('logo.png'), logo: cssAssetUrl('logo.png'),
loadingBg1: cssAssetUrl('game6/loadingbg1.png'), loadingBg1: cssAssetUrl('game6/loadingbg1.png'),
...@@ -44,6 +46,7 @@ const wechat = $getWechat() ...@@ -44,6 +46,7 @@ const wechat = $getWechat()
const currentView = ref<GameView>('loading') const currentView = ref<GameView>('loading')
const countdownInterval = ref(60) const countdownInterval = ref(60)
const isDivDescVisible = ref(false) const isDivDescVisible = ref(false)
const isGameOver = ref(false)
const score = ref(0) const score = ref(0)
const rank = ref(0) //排名 const rank = ref(0) //排名
const rankList = ref<RankPlayer[]>([]) const rankList = ref<RankPlayer[]>([])
...@@ -74,6 +77,7 @@ const nickname = ref(wechat?.nickname ?? '') ...@@ -74,6 +77,7 @@ const nickname = ref(wechat?.nickname ?? '')
const avatar = ref(wechat?.avatar ?? '') const avatar = ref(wechat?.avatar ?? '')
const showGameRule = ref(true) const showGameRule = ref(true)
const showGameRank = ref(false) const showGameRank = ref(false)
const isLocalMode = $is_run_local()
const mobileStageRef = ref<InstanceType<typeof MobileStage> | null>(null) const mobileStageRef = ref<InstanceType<typeof MobileStage> | null>(null)
const _offSocketMessage = ref<(() => void) | undefined>() const _offSocketMessage = ref<(() => void) | undefined>()
...@@ -97,19 +101,25 @@ function stopGameCountdown() { ...@@ -97,19 +101,25 @@ function stopGameCountdown() {
} }
function submitScore(save: boolean = false, currentTick = score.value) { function submitScore(save: boolean = false, currentTick = score.value) {
//最后提交保存在数据库中 if (!wechat && !isLocalMode) return
if (save) { if (save) {
//item_num 游戏项目(1到6) if (isLocalMode && !wechat) {
//wechat 原始token console.log('[game3 本地模式] 提交最终分数:', currentTick)
return
}
sendGameMessage('submit_score_save', { sendGameMessage('submit_score_save', {
score: currentTick, score: currentTick,
wechat: wechat.token_origin, wechat: wechat.token_origin,
item_num: 6, item_num: 3,
nickname: wechat.nickname, nickname: wechat.nickname,
avatar: wechat.avatar, avatar: wechat.avatar,
rank: rank.value, rank: rank.value,
}) })
} else { } else {
if (isLocalMode && !wechat) {
console.log('[game3 本地模式] 实时分数:', currentTick)
return
}
sendGameMessage('submit_score', { score: currentTick }) sendGameMessage('submit_score', { score: currentTick })
} }
} }
...@@ -147,8 +157,8 @@ function resetToLoadingView() { ...@@ -147,8 +157,8 @@ function resetToLoadingView() {
rank.value = 0 rank.value = 0
rankList.value = [] rankList.value = []
countdownInterval.value = 60 countdownInterval.value = 60
// currentGu.value = guFrames[0]
isGuPlaying.value = false isGuPlaying.value = false
isGameOver.value = false
userJoinStatus.value = false userJoinStatus.value = false
showGameRank.value = false showGameRank.value = false
currentView.value = 'loading' currentView.value = 'loading'
...@@ -160,7 +170,7 @@ function startGameView() { ...@@ -160,7 +170,7 @@ function startGameView() {
score.value = 0 score.value = 0
rank.value = 0 rank.value = 0
rankList.value = [] rankList.value = []
// currentGu.value = guFrames[0] isGameOver.value = false
showGameRank.value = false showGameRank.value = false
currentView.value = 'playing' currentView.value = 'playing'
startGameCountdown() startGameCountdown()
...@@ -169,21 +179,32 @@ function startGameView() { ...@@ -169,21 +179,32 @@ function startGameView() {
function showGameOverRank() { function showGameOverRank() {
stopGameCountdown() stopGameCountdown()
setDivDescVisible(false) setDivDescVisible(false)
currentView.value = 'loading' // 保持在 playing 视图,结果页由 PlayingView 内部渲染
showGameRank.value = true isGameOver.value = true
userJoinStatus.value = false; userJoinStatus.value = false
if (isLocalMode && !wechat) {
console.log('[game3 本地模式] 游戏结束,最终分数:', score.value)
}
} }
const touchHandler = (mole: boolean) => { const touchHandler = (mole: boolean) => {
let scoreDelta = mole ? 5 : 0; let scoreDelta = mole ? 5 : -3;
let nextTick = score.value + scoreDelta let nextTick = score.value + scoreDelta
if (nextTick < 0) nextTick = 0
score.value = nextTick score.value = nextTick
if (mole) { if (mole) {
playGame6Music(1); playGame6Music(1);
submitScore(false, nextTick)
} else { } else {
playGame6Music(2); playGame6Music(2);
} }
submitScore(false, nextTick)
}
const scoreChangeHandler = (delta: number) => {
let nextTick = score.value + delta
if (nextTick < 0) nextTick = 0
score.value = nextTick
submitScore(false, nextTick)
} }
if (wechat) { if (wechat) {
...@@ -237,6 +258,7 @@ if (wechat) { ...@@ -237,6 +258,7 @@ if (wechat) {
score.value = Number.isFinite(recoveredScore) && recoveredScore > 0 ? recoveredScore : 0 score.value = Number.isFinite(recoveredScore) && recoveredScore > 0 ? recoveredScore : 0
showGameRule.value = false showGameRule.value = false
showGameRank.value = false showGameRank.value = false
isGameOver.value = false
currentView.value = 'playing' currentView.value = 'playing'
startGameCountdown( startGameCountdown(
Number.isFinite(recoveredSeconds) && recoveredSeconds > 0 ? recoveredSeconds : 0, Number.isFinite(recoveredSeconds) && recoveredSeconds > 0 ? recoveredSeconds : 0,
...@@ -263,6 +285,13 @@ onMounted(() => { ...@@ -263,6 +285,13 @@ onMounted(() => {
if (token.value) { if (token.value) {
window.addEventListener('beforeunload', confirmRefresh) window.addEventListener('beforeunload', confirmRefresh)
} }
// // TODO_LOCAL: 本地模拟游戏开始,稍后部署上线前删除此段
// if ($is_run_local()) {
// showGameRule.value = false
// currentView.value = 'playing'
// startGameCountdown()
// }
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
...@@ -289,11 +318,13 @@ const renderBG = () => { ...@@ -289,11 +318,13 @@ const renderBG = () => {
} }
const rankCloseHandler = () => { const rankCloseHandler = () => {
showGameRank.value = false showGameRank.value = false
isGameOver.value = false
resetToLoadingView()
} }
</script> </script>
<template> <template>
<MobileStage v-if="token" :showGameRule="showGameRule" :showGameRank="showGameRank" ref="mobileStageRef" <MobileStage v-if="token || isLocalMode" :showGameRule="showGameRule" :showGameRank="showGameRank" ref="mobileStageRef"
:background="`${renderBG()}`"> :background="`${renderBG()}`">
<template #gameRule> <template #gameRule>
<div class="rule-container"> <div class="rule-container">
...@@ -345,10 +376,13 @@ const rankCloseHandler = () => { ...@@ -345,10 +376,13 @@ const rankCloseHandler = () => {
</div> </div>
<div class="img-close" @click="rankCloseHandler"></div> <div class="img-close" @click="rankCloseHandler"></div>
</template> </template>
<LoadingView v-if="currentView !== 'loading'" :image-urls="imageUrls" :user-join-status="userJoinStatus" <LoadingView v-if="currentView === 'loading'" :image-urls="imageUrls" :user-join-status="userJoinStatus"
@touchGameRule="showGameRuleHandler" /> @touchGameRule="showGameRuleHandler" />
<PlayingView v-else :image-urls="imageUrls" :tick="score" :rank="rank" :countdown-interval="countdownInterval" <PlayingView v-else :image-urls="imageUrls" :tick="score" :rank="rank" :countdown-interval="countdownInterval"
:is-div-desc-visible="isDivDescVisible" @touch="touchHandler" /> :is-div-desc-visible="isDivDescVisible" :is-game-over="isGameOver"
:rank-list="displayRankList" :user-rank="rank" :user-score="rankScore"
:user-nickname="nickname" :user-avatar="avatar"
@touch="touchHandler" @score-change="scoreChangeHandler" @rank-close="rankCloseHandler" />
</MobileStage> </MobileStage>
<div v-else style="text-align: center; width: 100vw; height: 100vh; line-height: 30; font-size: 20px;"> <div v-else style="text-align: center; width: 100vw; height: 100vh; line-height: 30; font-size: 20px;">
请使用微信扫码进入游戏 请使用微信扫码进入游戏
......
...@@ -18,7 +18,7 @@ onMounted(() => { ...@@ -18,7 +18,7 @@ onMounted(() => {
<template> <template>
<div class="h5-page game-stage"> <div class="h5-page game-stage">
<div class="game-desc" @click="$emit('touchGameRule')">游戏规则</div> <div class="game-desc" @click="$emit('touchGameRule')">游戏规则</div>
<div class="img-logo"></div> <!-- <div class="img-logo"></div> -->
<div class="img-loadingbg1">游戏等待开始倒计时60秒</div> <div class="img-loadingbg1">游戏等待开始倒计时60秒</div>
<div class="img-loadingbg2">游戏即将开始 敬请期待</div> <div class="img-loadingbg2">游戏即将开始 敬请期待</div>
<div class="txt-bottom" v-if="userJoinStatus">您已成功加入游戏<br />等待主持人开始</div> <div class="txt-bottom" v-if="userJoinStatus">您已成功加入游戏<br />等待主持人开始</div>
...@@ -33,7 +33,7 @@ onMounted(() => { ...@@ -33,7 +33,7 @@ onMounted(() => {
background: v-bind('imageUrls.loadingBg1') center/cover; background: v-bind('imageUrls.loadingBg1') center/cover;
border-radius: 45px; border-radius: 45px;
position: absolute; position: absolute;
top: 474px; top: 513px;
left: 50%; left: 50%;
margin-left: -251px; margin-left: -251px;
text-align: center; text-align: center;
......
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'; import { onBeforeUnmount, onMounted, ref, watch, nextTick, computed } from 'vue';
import { assetUrl } from '@/commons/assets.ts'
import { playGame6Music } from '@/commons/music'
defineProps<{ const props = defineProps<{
imageUrls: Record<string, string> imageUrls: Record<string, string>
countdownInterval: number countdownInterval: number
isDivDescVisible: boolean isDivDescVisible: boolean
tick: number tick: number
rank: number rank: number
// currentGu: string isGameOver: boolean
}>() rankList: Array<{ rank: number; nickname: string; avatar: string; score: number | string }>
userRank: number
userScore: number | string
userNickname: string
userAvatar: string
}>();
const emit = defineEmits<{ const emit = defineEmits<{
touch: [mole: boolean] touch: [mole: boolean]
}>() scoreChange: [delta: number]
rankClose: []
type TargetKind = 'mole' | 'rabbit' }>();
type TargetState = 'idle' | 'show' | 'hide' | 'hit'
// ========== 常量 ==========
type TargetItem = { const ROAD_TOP = 421.9;
id: number const ROAD_HEIGHT = 1624 - ROAD_TOP;
kind: TargetKind | null const HORSE_W = 131;
state: TargetState const HORSE_H = 285;
showMs: number const HORSE_BOTTOM_PCT = 19.11;
hideMs: number const ITEM_SIZE = 80;
hitMs: number const GAME_DURATION = 60;
timer?: ReturnType<typeof window.setTimeout>
} const LEFT_LANE_X = 170;
const RIGHT_LANE_X = 470;
const list = ref<TargetItem[]>([]);
const moleContainerRef = ref<HTMLElement | null>(null) // ========== 马帧 ==========
for (let i = 0; i < 9; i++) { const HORSE_FRAME_COUNT = 21;
list.value.push({ const horseFrames = Array.from({ length: HORSE_FRAME_COUNT }, (_, i) =>
id: i, assetUrl(`game3/horse/1-ma_${String(i).padStart(2, '0')}.png`)
kind: null, );
state: 'idle',
showMs: 650, // ========== 金币/炸弹帧 ==========
hideMs: 350, const JINBI_FRAME_COUNT = 25;
hitMs: 700, const jinbiFrames = Array.from({ length: JINBI_FRAME_COUNT }, (_, i) =>
}) assetUrl(`game3/jinbi/1-yuanbao_${String(i).padStart(2, '0')}.png`)
} );
let spawnTimer: ReturnType<typeof window.setTimeout> | undefined const BOM_FRAME_COUNT = 28;
const bomFrames = Array.from({ length: BOM_FRAME_COUNT }, (_, i) =>
function clearTargetTimer(item: TargetItem) { assetUrl(`game3/bom/1-zha_${String(i).padStart(2, '0')}.png`)
if (item.timer) { );
window.clearTimeout(item.timer)
item.timer = undefined const fudaiUrl = assetUrl('game3/icon_fudai.webp');
const dileiUrl = assetUrl('game3/icon_dilei.webp');
// ========== 马状态 ==========
const horseLane = ref<'left' | 'right'>('left');
const horseFrameIndex = ref(0);
const horseX = ref(LEFT_LANE_X);
const horseTargetX = ref(LEFT_LANE_X);
const switchToLeft = () => {
if (horseLane.value !== 'left') {
horseLane.value = 'left';
horseTargetX.value = LEFT_LANE_X;
} }
} };
function setTargetState(index: number, state: TargetState) { const switchToRight = () => {
const item = list.value[index] if (horseLane.value !== 'right') {
if (!item) { horseLane.value = 'right';
return horseTargetX.value = RIGHT_LANE_X;
}
};
// ========== 键盘控制 ==========
const handleKeydown = (e: KeyboardEvent) => {
if (!props.isDivDescVisible) return;
if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') {
switchToLeft();
} else if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') {
switchToRight();
} }
};
// ========== 路背景加速滚动 ==========
const roadOffset = ref(0);
let roadRafId: number | undefined;
let roadLastTs = 0;
const BASE_ROAD_SPEED = 200;
const MAX_ROAD_SPEED = 700;
const gameElapsed = ref(0);
const currentRoadSpeed = ref(BASE_ROAD_SPEED);
const startRoadScroll = () => {
stopRoadScroll();
roadLastTs = 0;
gameElapsed.value = 0;
const startTs = performance.now();
const tick = (ts: number) => {
if (!props.isDivDescVisible) {
roadRafId = requestAnimationFrame(tick);
return;
}
if (!roadLastTs) roadLastTs = ts;
const dt = Math.min(ts - roadLastTs, 100);
roadLastTs = ts;
item.state = state gameElapsed.value = (ts - startTs) / 1000;
} const progress = Math.min(gameElapsed.value / GAME_DURATION, 1);
currentRoadSpeed.value = BASE_ROAD_SPEED + (MAX_ROAD_SPEED - BASE_ROAD_SPEED) * progress;
function resetTarget(index: number) { roadOffset.value += currentRoadSpeed.value * dt / 1000;
const item = list.value[index] if (roadOffset.value >= ROAD_HEIGHT) {
if (!item) { roadOffset.value -= ROAD_HEIGHT;
return }
}
clearTargetTimer(item) roadRafId = requestAnimationFrame(tick);
item.kind = null };
item.state = 'idle' roadRafId = requestAnimationFrame(tick);
} };
function playTargetLifecycle(index: number, kind: TargetKind, duration = 1000) { const stopRoadScroll = () => {
const item = list.value[index] if (roadRafId) {
if (!item) { cancelAnimationFrame(roadRafId);
return roadRafId = undefined;
} }
};
clearTargetTimer(item) // ========== 持续道具生成系统 ==========
item.kind = kind type ItemKind = 'coin' | 'bomb';
item.state = 'show'
item.showMs = Math.round(duration * 0.65)
item.hideMs = Math.max(1, duration - item.showMs)
item.hitMs = kind === 'mole' ? 740 : 660
item.timer = window.setTimeout(() => { interface RoadItem {
setTargetState(index, 'hide') id: number;
item.timer = window.setTimeout(() => { kind: ItemKind;
resetTarget(index) lane: 'left' | 'right';
}, item.hideMs) y: number;
}, item.showMs) collected: boolean;
animFrame: number;
animTimer: ReturnType<typeof setTimeout> | undefined;
} }
function randomTargetKind(): TargetKind { const roadItems = ref<RoadItem[]>([]);
return Math.random() < 0.75 ? 'mole' : 'rabbit' const effectAnims = ref<{ id: number; kind: ItemKind; frame: number; x: number; y: number }[]>([]);
interface ScoreEffect {
id: number
x: number
y: number
score: number // 正数表示加分,负数表示扣分
}
const scoreEffects = ref<ScoreEffect[]>([]);
let scoreEffectIdSeq = 0;
let itemIdCounter = 0;
let genTimerId: ReturnType<typeof setTimeout> | undefined;
function seededRandom(seed: number) {
const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;
return x - Math.floor(x);
}
function scheduleNextItem() {
if (!props.isDivDescVisible) return;
// 游戏越接近尾声,生成间隔越短(1800ms → 350ms),加上随机抖动
const progress = Math.min(gameElapsed.value / GAME_DURATION, 1);
const base = 1800 - 1450 * progress;
const jitter = (Math.random() - 0.5) * 800; // ±400ms 随机
const interval = Math.max(250, base + jitter);
genTimerId = setTimeout(generateNewItem, interval);
}
function generateNewItem() {
if (!props.isDivDescVisible) return;
const seed = Date.now() + itemIdCounter;
const kind: ItemKind = seededRandom(seed) < 0.55 ? 'coin' : 'bomb';
// 概率分配车道,但避免与前一个同车道道具太近
let lane: 'left' | 'right' = seededRandom(seed + 1000) < 0.5 ? 'left' : 'right';
// 找到该车道还在顶部附近(y < 200)的未收集道具
const hasNearbyInLane = (ln: 'left' | 'right') =>
roadItems.value.some(it => !it.collected && it.lane === ln && it.y < 200);
if (hasNearbyInLane(lane) && !hasNearbyInLane(lane === 'left' ? 'right' : 'left')) {
// 首选车道已有最近道具,强制换到另一车道
lane = lane === 'left' ? 'right' : 'left';
}
const newItem: RoadItem = {
id: itemIdCounter++,
kind,
lane,
y: -ITEM_SIZE,
collected: false,
animFrame: 0,
animTimer: undefined,
};
roadItems.value.push(newItem);
scheduleNextItem();
}
function stopItemGen() {
if (genTimerId) {
clearTimeout(genTimerId);
genTimerId = undefined;
}
} }
function randomCount(min = 1, max = 3) { // 道具 y 已是 screen-space(content-bg 坐标系),清理逻辑直接判断
return Math.floor(Math.random() * (max - min + 1)) + min function cleanupOffscreenItems() {
roadItems.value = roadItems.value.filter(item => {
// item.y 超出 content-bg 底部即删除,超出顶部也删除
return item.y < ROAD_HEIGHT && item.y > -ITEM_SIZE;
});
} }
function spawnRandomTargets(duration = 1000) {
const idleIndexes = list.value
.map((item, index) => item.state === 'idle' ? index : -1)
.filter(index => index >= 0)
.sort(() => Math.random() - 0.5)
const count = Math.min(randomCount(1, 3), idleIndexes.length)
for (const index of idleIndexes.slice(0, count)) {
playTargetLifecycle(index, randomTargetKind(), duration)
}
}
function scheduleRandomTargets() {
spawnRandomTargets(1000)
spawnTimer = window.setTimeout(scheduleRandomTargets, 1200)
}
function handleTargetTouch(index: number) { // ========== 碰撞检测 ==========
const item = list.value[index] // 马匹在屏幕上的Y坐标(从 content-bg 顶部算起,horse 使用 bottom: 19.11% 定位)
if (!item || item.state === 'idle' || item.state === 'hit') { const horseCSSBottom = ROAD_HEIGHT * (1 - HORSE_BOTTOM_PCT / 100);
return const horseCSSTop = horseCSSBottom - HORSE_H;
const checkCollisions = () => {
const horseCenterX = horseX.value + HORSE_W / 2;
const horseTopCenterY = horseCSSTop + HORSE_H * 0.15;
const horseRadius = HORSE_W * 0.35;
for (const item of roadItems.value) {
if (item.collected) continue;
const itemX = item.lane === 'left' ? LEFT_LANE_X : RIGHT_LANE_X;
// item.y 已经是屏幕坐标(content-bg 内),无需加 roadOffset
const itemCenterX = itemX + ITEM_SIZE / 2;
const itemCenterY = item.y + ITEM_SIZE / 2;
const dist = Math.hypot(horseCenterX - itemCenterX, horseTopCenterY - itemCenterY);
if (dist < horseRadius + ITEM_SIZE * 0.35) {
item.collected = true;
playCollectEffect(item, itemX, item.y);
}
} }
emit('touch', item.kind === 'mole') };
clearTargetTimer(item)
item.state = 'hit' const playCollectEffect = (item: RoadItem, x: number, y: number) => {
item.timer = window.setTimeout(() => { const isCoin = item.kind === 'coin';
resetTarget(index) const delta = isCoin ? 5 : -3;
}, item.hitMs)
} if (isCoin) {
emit('scoreChange', 5);
function handleBoardTouch(event: MouseEvent) { playGame6Music(1);
const container = moleContainerRef.value } else {
if (!container) { emit('scoreChange', -3);
return playGame6Music(2);
} }
const rect = container.getBoundingClientRect() const animId = Date.now() + Math.random();
const x = ((event.clientX - rect.left) / rect.width) * 750 const totalFrames = isCoin ? JINBI_FRAME_COUNT : BOM_FRAME_COUNT;
const y = ((event.clientY - rect.top) / rect.height) * 600 const frameDuration = 1000 / totalFrames;
const colWidth = 250
const rowGap = 70
const rowHeight = (600 - rowGap * 2) / 3
const hitRadius = 225
let targetIndex = -1 // 碰撞动画(大幅放大尺寸)
let targetDistance = Number.POSITIVE_INFINITY effectAnims.value.push({ id: animId, kind: item.kind, frame: 0, x, y });
list.value.forEach((item, index) => { let frameIdx = 0;
if (!item.kind || item.state === 'idle' || item.state === 'hit') { const tick = () => {
return frameIdx++;
if (frameIdx >= totalFrames) {
effectAnims.value = effectAnims.value.filter(a => a.id !== animId);
return;
}
const anim = effectAnims.value.find(a => a.id === animId);
if (anim) anim.frame = frameIdx;
item.animTimer = setTimeout(tick, frameDuration);
};
item.animTimer = setTimeout(tick, frameDuration);
// 积分飘字效果(参考 game5 套中圈 "+10" 动画)
const effectId = ++scoreEffectIdSeq;
scoreEffects.value.push({
id: effectId,
x: x + ITEM_SIZE / 2,
y: y,
score: delta,
});
setTimeout(() => {
const idx = scoreEffects.value.findIndex(e => e.id === effectId);
if (idx >= 0) scoreEffects.value.splice(idx, 1);
}, 800);
};
// ========== 主循环 ==========
let mainRafId: number | undefined;
let mainLastTs = 0;
let perSecondTimer: ReturnType<typeof setInterval> | undefined;
let horseFrameAccum = 0;
const startMainLoop = () => {
stopMainLoop();
mainLastTs = 0;
horseFrameAccum = 0;
const tick = (ts: number) => {
if (!props.isDivDescVisible) {
mainRafId = requestAnimationFrame(tick);
return;
}
if (!mainLastTs) mainLastTs = ts;
const dt = Math.min(ts - mainLastTs, 200);
mainLastTs = ts;
horseFrameAccum += dt;
const horseInterval = 80 * BASE_ROAD_SPEED / currentRoadSpeed.value;
while (horseFrameAccum >= horseInterval) {
horseFrameAccum -= horseInterval;
horseFrameIndex.value = (horseFrameIndex.value + 1) % HORSE_FRAME_COUNT;
} }
const col = index % 3 const lerpFactor = 1 - Math.pow(0.001, dt / 1000);
const row = Math.floor(index / 3) horseX.value += (horseTargetX.value - horseX.value) * lerpFactor;
const centerX = col * colWidth + colWidth / 2
const centerY = row * (rowHeight + rowGap) + rowHeight / 2
const distance = Math.hypot(x - centerX, y - centerY)
if (distance <= hitRadius && distance < targetDistance) { // 先移动所有道具
targetIndex = index for (const item of roadItems.value) {
targetDistance = distance if (!item.collected) {
item.y += currentRoadSpeed.value * dt / 1000;
}
} }
})
// 清理已离开屏幕的道具,避免 roadOffset 回绕后旧道具闪现
cleanupOffscreenItems();
if (targetIndex >= 0) { // 最后再检测碰撞,确保只检测屏幕内确实可见的道具
handleTargetTouch(targetIndex) checkCollisions();
mainRafId = requestAnimationFrame(tick);
};
mainRafId = requestAnimationFrame(tick);
perSecondTimer = setInterval(() => {
if (props.isDivDescVisible) {
emit('scoreChange', 2);
}
}, 1000);
};
const stopMainLoop = () => {
if (mainRafId) {
cancelAnimationFrame(mainRafId);
mainRafId = undefined;
} }
}
if (perSecondTimer) {
clearInterval(perSecondTimer);
perSecondTimer = undefined;
}
};
// ========== 生命周期 ==========
onMounted(() => { onMounted(() => {
scheduleRandomTargets() window.addEventListener('keydown', handleKeydown);
}) nextTick(() => {
startRoadScroll();
startMainLoop();
scheduleNextItem();
});
});
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (spawnTimer) { window.removeEventListener('keydown', handleKeydown);
window.clearTimeout(spawnTimer) stopRoadScroll();
stopMainLoop();
stopItemGen();
for (const item of roadItems.value) {
if (item.animTimer) clearTimeout(item.animTimer);
} }
roadItems.value = [];
for (const item of list.value) { effectAnims.value = [];
clearTargetTimer(item) scoreEffects.value = [];
});
watch(() => props.isDivDescVisible, (visible) => {
if (visible) {
nextTick(() => {
roadItems.value = [];
effectAnims.value = [];
scoreEffects.value = [];
roadOffset.value = 0;
gameElapsed.value = 0;
startRoadScroll();
startMainLoop();
scheduleNextItem();
});
} else {
stopRoadScroll();
stopMainLoop();
stopItemGen();
for (const item of roadItems.value) {
if (item.animTimer) clearTimeout(item.animTimer);
}
roadItems.value = [];
effectAnims.value = [];
scoreEffects.value = [];
}
}, { immediate: true });
// 游戏结束:暂停全部动画,显示结果页
watch(() => props.isGameOver, (over) => {
if (over) {
stopRoadScroll();
stopMainLoop();
stopItemGen();
} }
}) });
</script> </script>
<template> <template>
<div class="h5-page game-stage "> <div class="h5-page game-stage">
<div class="only-bg"></div> <div class="only-bg"></div>
<div class="content-bg" :class="{ scrolling: isDivDescVisible }"> <div class="content-bg">
<div class="road-track"> <div class="road-track" :style="{ transform: `translateY(${roadOffset}px)` }">
<div class="road-inner"></div>
<div class="road-inner"></div> <div class="road-inner"></div>
<div class="road-inner"></div> <div class="road-inner"></div>
</div> </div>
<!-- 道具(屏幕坐标系,独立于跑道滚动) -->
<template v-for="item in roadItems" :key="item.id">
<img
v-if="!item.collected"
:src="item.kind === 'coin' ? fudaiUrl : dileiUrl"
class="road-item"
:class="`item-lane-${item.lane}`"
:style="{
top: `${item.y}px`,
}"
/>
</template>
<!-- 碰撞特效 -->
<template v-for="anim in effectAnims" :key="anim.id">
<img
:src="(anim.kind === 'coin' ? jinbiFrames : bomFrames)[anim.frame]"
class="effect-anim"
:style="{ left: `${anim.x}px`, top: `${anim.y}px` }"
/>
</template>
<!-- 积分飘字 -->
<div
v-for="ef in scoreEffects"
:key="ef.id"
class="score-float"
:class="ef.score > 0 ? 'score-plus' : 'score-minus'"
:style="{ left: ef.x + 'px', top: ef.y + 'px' }"
>{{ ef.score > 0 ? '+' : '' }}{{ ef.score }}</div>
<!-- 马 -->
<div class="horse-wrapper" :style="{ left: `${horseX}px`, bottom: `${HORSE_BOTTOM_PCT}%` }">
<img
v-for="(url, idx) in horseFrames"
:key="idx"
:src="url"
class="horse-frame"
:class="{ active: idx === horseFrameIndex }"
alt=""
/>
</div>
</div> </div>
<Transition name="div-desc-slide" appear> <Transition name="div-desc-slide" appear>
<div v-if="isDivDescVisible" class="div-desc"> <div v-if="isDivDescVisible" class="div-desc">
<span class="desc-clock" aria-hidden="true"></span> <span class="desc-clock" aria-hidden="true"></span>
...@@ -199,17 +485,46 @@ onBeforeUnmount(() => { ...@@ -199,17 +485,46 @@ onBeforeUnmount(() => {
</Transition> </Transition>
<div class="img-logo"></div> <div class="img-logo"></div>
<div class="img-hydt"></div> <div class="img-hydt"></div>
<div class="img-hammer"></div> <div class="img-hammer"></div>
<!-- <div class="play-content"> <div class="img-left" @click="switchToLeft"></div>
<div>当前击鼓次数</div> <div class="img-right" @click="switchToRight"></div>
<div>{{ tick }}</div>
<div>当前排名: &nbsp;&nbsp;<label>{{ rank }}</label>&nbsp;</div> <!-- 游戏结束结果页覆盖层 -->
</div> --> <Transition name="rank-fade">
<!-- <div class="bg-bottom"></div> --> <div v-if="isGameOver" class="rank-overlay">
<!-- <div class="play-gu" :style="{ background: `${currentGu} center/cover` }" @click="$emit('touch')"></div> --> <div class="ranklist-container">
<div class="txt-title">排行</div>
<div class="list-container">
<div class="row row-1">
<div class="col-1">排名</div>
<div class="col-2">用户</div>
<div class="col-3">成绩</div>
</div>
<div class="row-2">
<div class="row rank-row" v-for="(item, index) in rankList" :key="`${item.rank}-${index}`">
<div class="col-1">
<div>{{ item.rank }}</div>
</div>
<div class="col-2">
<div class="avatar" :style="item.avatar ? { backgroundImage: `url(${item.avatar})` } : {}"></div>
<div>{{ item.nickname }}</div>
</div>
<div class="col-3">{{ item.score }}</div>
</div>
</div>
<div class="row row-3">
<div class="col-1">{{ userRank > 0 ? userRank : '未上榜' }}</div>
<div class="col-2">
<div class="avatar" :style="{ backgroundImage: `url(${userAvatar})` }"></div>
<div>{{ userNickname }}</div>
</div>
<div class="col-3">{{ userScore }}</div>
</div>
</div>
</div>
<div class="img-rank-close" @click="$emit('rankClose')"></div>
</div>
</Transition>
</div> </div>
</template> </template>
...@@ -220,228 +535,172 @@ onBeforeUnmount(() => { ...@@ -220,228 +535,172 @@ onBeforeUnmount(() => {
height: 1624px; height: 1624px;
padding: 0; padding: 0;
} }
.only-bg{
.only-bg {
position: absolute; position: absolute;
top: 0; top: 0;
left: var(--stage-viewport-left, 0); left: var(--stage-viewport-left, 0);
width: var(--stage-viewport-width, 750px); width: var(--stage-viewport-width, 750px);
height: 421.9px; height: 421.9px;
background: v-bind('imageUrls.bg3') center/cover no-repeat; background: v-bind('imageUrls.bg3') center / cover no-repeat;
} }
.content-bg{
.content-bg {
position: absolute; position: absolute;
top: 421.9px; top: 421.9px;
left: var(--stage-viewport-left, 0); left: var(--stage-viewport-left, 0);
width: var(--stage-viewport-width, 750px); width: var(--stage-viewport-width, 750px);
height: calc(1624px - 421.9px); height: 1202.1px;
overflow: hidden; overflow: hidden;
z-index: 1; z-index: 1;
} }
.road-track { .road-track {
width: 100%; width: 100%;
height: calc(1624px - 421.9px);
position: absolute; position: absolute;
top: 0; top: -1202.1px;
left: 0; left: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
animation: road-scroll-up 8s linear infinite;
/* animation-play-state: paused; */
}
.content-bg.scrolling .road-track {
animation-play-state: running;
} }
.road-inner { .road-inner {
width: 100%; width: 100%;
height: calc(1624px - 421.9px); height: 1202.1px;
background: v-bind('imageUrls.bg4') center / cover no-repeat; background: v-bind('imageUrls.bg4') center / cover no-repeat;
flex-shrink: 0; flex-shrink: 0;
} }
@keyframes road-scroll-up { .road-item {
0% {
transform: translateY(calc(-1 * (1624px - 421.9px)));
}
100% {
transform: translateY(0);
}
}
.img-hydt {
position: absolute;
width: 454px;
height: 181px;
top: 174px;
left: 52%;
transform: translate(-50%, -50%);
background: v-bind('imageUrls.hydt') center/cover;
}
.rank-content {
width: 600px;
height: 140px;
background: v-bind('imageUrls.rank') center/cover;
position: absolute; position: absolute;
left: 50%; width: 80px;
top: 480px; height: 80px;
display: flex; z-index: 5;
align-items: center; pointer-events: none;
justify-content: space-between;
transform: translate(-50%, -50%);
font-size: 24px;
color: #A10A06;
.txt-bold {
font-size: 40px;
font-weight: bold;
}
>div {
display: flex;
width: 50%;
height: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
}
} }
.img-hammer { .item-lane-left {
width: 148px; left: 170px;
height: 148px;
background: v-bind('imageUrls.hammer') center/cover;
position: absolute;
left: 50%;
bottom: 100px;
transform: translate(-50%, 0);
} }
.mole-container { .item-lane-right {
position: absolute; left: 470px;
width: 750px;
height: 600px;
row-gap: 70px;
top: 560px;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
place-items: center;
>div {
position: relative;
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
}
} }
.img-land, .effect-anim {
.img-mole {
position: absolute; position: absolute;
top: 50%; width: 250px;
left: 50%; height: 250px;
width: 150px; z-index: 10;
height: 150px;
transform: translate(-50%, -50%) scale(3);
transform-origin: center;
}
.img-mole {
display: block;
pointer-events: none; pointer-events: none;
transform: translate(-50%, -50%);
} }
.img-land, /* 积分飘字动画 */
.img-mole { .score-float {
background-position: 0 0; position: absolute;
background-repeat: no-repeat; z-index: 25;
background-size: auto 150px; font-size: 56px;
} font-weight: 900;
.img-land {
background-image: v-bind('imageUrls.land');
pointer-events: none; pointer-events: none;
white-space: nowrap;
transform: translate(-50%, -100%);
animation: score-float-up 0.8s ease-out forwards;
} }
.img-mole.is-mole.is-show { .score-plus {
background-image: v-bind('imageUrls.moleShowSprite'); color: #FFD700;
animation: sprite-show var(--show-duration, 650ms) steps(31) forwards; text-shadow: 0 3px 8px rgba(0, 0, 0, 0.7), 0 0 20px rgba(255, 215, 0, 0.5);
} }
.img-mole.is-mole.is-hide { .score-minus {
background-image: v-bind('imageUrls.moleHideSprite'); color: #FF4444;
animation: sprite-hide var(--hide-duration, 350ms) steps(16) forwards; text-shadow: 0 3px 8px rgba(0, 0, 0, 0.7), 0 0 20px rgba(255, 68, 68, 0.5);
} }
.img-mole.is-mole.is-hit { @keyframes score-float-up {
background-image: v-bind('imageUrls.moleHideprite'); 0% {
animation: sprite-mole-hit var(--hit-duration, 740ms) steps(37) forwards; opacity: 1;
transform: translate(-50%, -100%) scale(0.5);
}
25% {
opacity: 1;
transform: translate(-50%, -140%) scale(1.15);
}
100% {
opacity: 0;
transform: translate(-50%, -260%) scale(0.8);
}
} }
.img-mole.is-rabbit.is-show { .horse-wrapper {
background-image: v-bind('imageUrls.rabbitshowprite'); position: absolute;
animation: sprite-show var(--show-duration, 650ms) steps(31) forwards; width: 131px;
height: 285px;
z-index: 8;
} }
.img-mole.is-rabbit.is-hide { .horse-frame {
background-image: v-bind('imageUrls.rabbitHideprite'); position: absolute;
animation: sprite-hide var(--hide-duration, 350ms) steps(16) forwards; inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
opacity: 0;
pointer-events: none;
} }
.img-mole.is-rabbit.is-hit { .horse-frame.active {
background-image: v-bind('imageUrls.rabbitFaintprite'); opacity: 1;
animation: sprite-rabbit-hit var(--hit-duration, 660ms) steps(33) forwards;
} }
@keyframes sprite-show { .img-hydt {
from { position: absolute;
background-position: 0 0; width: 454px;
} height: 181px;
top: 174px;
to { left: 52%;
background-position: -4650px 0; transform: translate(-50%, -50%);
} background: v-bind('imageUrls.hydt') center / cover;
} }
@keyframes sprite-hide { .img-left {
from { position: absolute;
background-position: 0 0; width: 128px;
} height: 128px;
left: 144px;
to { bottom: 5.48%;
background-position: -2400px 0; z-index: 20;
} background: v-bind('imageUrls.left') center / cover no-repeat;
cursor: pointer;
} }
@keyframes sprite-mole-hit { .img-right {
from { position: absolute;
background-position: 0 0; width: 128px;
} height: 128px;
right: 144px;
to { bottom: 5.48%;
background-position: -5550px 0; z-index: 20;
} background: v-bind('imageUrls.right') center / cover no-repeat;
cursor: pointer;
} }
@keyframes sprite-rabbit-hit { .img-hammer {
from { width: 148px;
background-position: 0 0; height: 148px;
} background: v-bind('imageUrls.hammer') center / cover;
position: absolute;
to { left: 50%;
background-position: -4950px 0; bottom: 100px;
} transform: translate(-50%, 0);
} }
.div-desc { .div-desc {
position: absolute; position: absolute;
top: 150px; top: 150px;
left: var(--stage-viewport-left, 0); left: var(--stage-viewport-left, 0);
z-index: 2; z-index: 20;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
...@@ -452,14 +711,13 @@ onBeforeUnmount(() => { ...@@ -452,14 +711,13 @@ onBeforeUnmount(() => {
background: rgba(40, 12, 8, 0.48); background: rgba(40, 12, 8, 0.48);
color: #fff; color: #fff;
.desc-clock { .desc-clock {
position: absolute; position: absolute;
left: 20px; left: 20px;
width: 38px; width: 38px;
height: 44px; height: 44px;
border-radius: 50%; border-radius: 50%;
background: v-bind('imageUrls.clock') center/cover; background: v-bind('imageUrls.clock') center / cover;
} }
.desc-time { .desc-time {
...@@ -495,67 +753,120 @@ onBeforeUnmount(() => { ...@@ -495,67 +753,120 @@ onBeforeUnmount(() => {
transform: translateX(0); transform: translateX(0);
} }
.bg-bottom { /* 游戏结束结果页覆盖层 */
.rank-overlay {
position: absolute; position: absolute;
bottom: 0; inset: 0;
left: var(--stage-viewport-left, 0); z-index: 100;
width: var(--stage-viewport-width, 750px); background: rgba(0, 0, 0, 0.65);
height: 479px; display: flex;
background: v-bind('imageUrls.bg2') center/cover; flex-direction: column;
opacity: 0.6; align-items: center;
justify-content: center;
} }
.img-logo { .ranklist-container {
position: absolute; width: 550px;
top: 46px; background: v-bind('imageUrls.ranklist') center / 100% 100% no-repeat;
left: 20px; border-radius: 16px;
width: 428px; padding: 60px 30px 30px;
height: 77px; display: flex;
background: v-bind('imageUrls.logo') center/cover; flex-direction: column;
/* transform: translateX(-50%); */ align-items: center;
.txt-title {
font-size: 36px;
font-weight: bold;
color: #AA0000;
letter-spacing: 2px;
margin-bottom: 16px;
}
.list-container {
width: 100%;
text-align: center;
line-height: 56px;
font-size: 24px;
.row {
display: grid;
grid-template-columns: 100px auto 120px;
align-items: center;
}
.row-1 {
color: #AA0000;
font-weight: bold;
background: linear-gradient(-84deg, #FFD396 0%, #FFF3D1 53%, #FFD59C 100%);
border-radius: 12px 12px 0 0;
}
.row-2 {
color: #633911;
max-height: 440px;
overflow-y: auto;
.rank-row:nth-child(odd) {
background: #F2E2AE;
}
.rank-row:nth-child(even) {
background: #FCEEBF;
}
}
.row-3 {
background: linear-gradient(0deg, #F4B64C 0%, #F7C96F 100%);
border-radius: 0 0 12px 12px;
font-weight: bold;
}
.col-2 {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
.avatar {
width: 40px;
height: 40px;
flex: 0 0 auto;
border-radius: 50%;
background-position: center;
background-size: cover;
}
}
}
} }
.play-content { .img-rank-close {
margin-top: 240px; width: 48px;
margin-left: 40px; height: 48px;
margin-right: 40px; margin-top: 24px;
padding: 30px 0; border-radius: 50%;
border-radius: 25px; background: v-bind('imageUrls.close') center / cover no-repeat;
background: rgba(40, 12, 8, 0.52); cursor: pointer;
color: white;
font-size: 28pt;
text-align: center;
} }
.play-content>div:nth-child(2) { .rank-fade-enter-active {
display: inline-block; transition: opacity 0.35s ease-out;
margin: 10px 0;
background: linear-gradient(180deg, #FFE68D 0%, #FAD500 100%);
background-clip: text;
color: transparent;
font-size: 48pt;
font-weight: bold;
transform: scaleY(1.2);
transform-origin: center;
-webkit-background-clip: text;
} }
.play-content>div:nth-child(3) { .rank-fade-leave-active {
position: relative; transition: opacity 0.25s ease-in;
top: -10px;
} }
.play-content>div:nth-child(3) label { .rank-fade-enter-from,
color: white; .rank-fade-leave-to {
font-size: 42pt; opacity: 0;
} }
.play-gu { .img-logo {
position: absolute; position: absolute;
bottom: 350px; top: 46px;
left: 50%; left: 20px;
width: 400px; width: 428px;
height: 400px; height: 77px;
transform: translateX(-50%) scale(2.4); background: v-bind('imageUrls.logo') center / cover;
} }
</style> </style>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment