仰望星辰工作室

starmusic-pc

starmusic-pc/ frontend/src/components/StarJumpGame.vue 16.2 KB · 639 行 原始文件
1<script setup>
2/**
3 * StarJumpGame — 隐藏彩蛋「星辰跳一跳」
4 *
5 * 玩法:长按(或按住鼠标)蓄力,松开起跳;落在平台上得分,落点越接近平台中心越容易触发
6 * 「完美居中」额外加分;掉出平台即结束。
7 *
8 * 实现要点:
9 * - 世界坐标 + 镜头偏移:把当前平台固定在舞台 26% 处,制造向前推进的观感
10 * - 只保留最近 8 个平台,视野外自动剔除,长期游玩不累积节点
11 * - 蓄力用 50ms 步进(与移动端一致),保证手感一致
12 * - 粒子星星统一在 canvas 内绘制,避免大量 DOM 节点
13 */
14import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
15import Icon from './Icon.vue'
16
17const props = defineProps({
18 /** 主题色(canvas 无法用 CSS 变量) */
19 color: { type: String, default: '#31c27c' },
20})
21
22const emit = defineEmits(['score', 'close', 'finished'])
23
24// ============ 常量(与移动端保持一致的手感参数) ============
25const CAMERA_RATIO = 0.26
26const MAX_JUMP_PX = 190
27const POWER_STEP = 4
28const POWER_INTERVAL = 50
29const JUMP_DURATION = 460
30const PERFECT_RATIO = 0.1
31const W_MIN = 68
32const W_MAX = 104
33const GAP_MIN = 58
34const GAP_MAX = 96
35const KEEP_PLATFORMS = 8
36const BG_STARS = 36
37const H = 380
38
39const STAGE_W = 760
40
41const canvasRef = ref(null)
42const wrapRef = ref(null)
43
44const score = ref(0)
45const best = ref(0)
46const state = ref('idle') // idle | charging | jumping | failed
47const power = ref(0)
48const unlockedIndex = ref(-1)
49
50const MESSAGES = [
51 { score: 3, text: '每一颗星星,都是一个未说出口的愿望' },
52 { score: 6, text: '愿你像星辰一样,即使微弱,也始终发光' },
53 { score: 10, text: '仰望星辰的人,终会成为别人的光' },
54 { score: 15, text: '夏日之瓜 · 仰望星辰工作室 · 感谢你发现这里' },
55]
56
57const unlockedText = computed(() => (unlockedIndex.value >= 0 ? MESSAGES[unlockedIndex.value].text : ''))
58
59// ============ 运行时数据 ============
60let ctx = null
61let dpr = 1
62let rafId = 0
63let lastTs = 0
64
65let platforms = []
66let cameraX = 0
67let cameraTarget = 0
68let playerX = 0
69let playerY = 0
70let jump = null // { fromX, toX, startedAt, duration }
71let powerTimer = null
72let failAnim = null // { startedAt, fromY }
73let bgStars = []
74let particles = []
75let stageW = STAGE_W
76
77const parseColor = (hex) => {
78 const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || '').trim())
79 if (!m) return [49, 194, 124]
80 const n = parseInt(m[1], 16)
81 return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
82}
83
84let rgb = [49, 194, 124]
85const rgba = (a) => `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${a})`
86
87/** 生成一个新平台(紧接在最后一个平台右侧) */
88const makeNextPlatform = () => {
89 const last = platforms.length ? platforms[platforms.length - 1] : null
90 if (!last) return { x: 40, w: 96 }
91 const w = W_MIN + Math.random() * (W_MAX - W_MIN)
92 const gap = GAP_MIN + Math.random() * (GAP_MAX - GAP_MIN)
93 return { x: last.x + last.w + gap, w }
94}
95
96const resetWorld = () => {
97 platforms = [{ x: 40, w: 104 }]
98 for (let i = 0; i < KEEP_PLATFORMS - 1; i++) platforms.push(makeNextPlatform())
99 playerX = platforms[0].x + platforms[0].w / 2
100 playerY = 0
101 cameraX = 0
102 cameraTarget = 0
103 jump = null
104 failAnim = null
105 particles = []
106 score.value = 0
107 unlockedIndex.value = -1
108 power.value = 0
109 state.value = 'idle'
110}
111
112const currentPlatform = () => {
113 for (const p of platforms) {
114 if (playerX >= p.x && playerX <= p.x + p.w) return p
115 }
116 return platforms[0]
117}
118
119// ============ 交互 ============
120
121const startCharge = () => {
122 if (state.value === 'jumping') return
123 if (state.value === 'failed') {
124 resetWorld()
125 state.value = 'charging'
126 } else {
127 state.value = 'charging'
128 }
129 power.value = 0
130 if (powerTimer) clearInterval(powerTimer)
131 powerTimer = setInterval(() => {
132 power.value = Math.min(100, power.value + POWER_STEP)
133 if (power.value >= 100 && powerTimer) {
134 clearInterval(powerTimer)
135 powerTimer = null
136 release()
137 }
138 }, POWER_INTERVAL)
139}
140
141const release = () => {
142 if (state.value !== 'charging' && state.value !== 'idle') {
143 if (powerTimer) {
144 clearInterval(powerTimer)
145 powerTimer = null
146 }
147 return
148 }
149 if (powerTimer) {
150 clearInterval(powerTimer)
151 powerTimer = null
152 }
153 // 单击(未蓄力)给一个固定力度,方便鼠标快速操作
154 const p = power.value > 0 ? power.value : 38
155 const distance = (p / 100) * MAX_JUMP_PX
156 state.value = 'jumping'
157 jump = {
158 fromX: playerX,
159 toX: playerX + distance,
160 startedAt: performance.now(),
161 duration: JUMP_DURATION,
162 }
163 power.value = 0
164}
165
166const checkLanding = () => {
167 const landing = jump.toX
168 let target = null
169 for (const p of platforms) {
170 if (landing >= p.x && landing <= p.x + p.w) {
171 target = p
172 break
173 }
174 }
175 if (!target) {
176 fail()
177 return
178 }
179 playerX = landing
180 const center = target.x + target.w / 2
181 const perfect = Math.abs(center - landing) <= target.w * PERFECT_RATIO
182 score.value += perfect ? 2 : 1
183 emit('score', score.value)
184 if (score.value > best.value) best.value = score.value
185
186 // 解锁寄语
187 const nextIndex = MESSAGES.findIndex((m, i) => score.value >= m.score && i > unlockedIndex.value)
188 if (nextIndex >= 0) {
189 unlockedIndex.value = nextIndex
190 burst(playerX, 1.0)
191 } else {
192 burst(playerX, 0.5)
193 }
194
195 // 前进:补一个新平台,剔除视野外的旧平台
196 platforms.push(makeNextPlatform())
197 while (platforms.length > KEEP_PLATFORMS) platforms.shift()
198 const cp = currentPlatform()
199 cameraTarget = cp.x - stageW * CAMERA_RATIO
200}
201
202const fail = () => {
203 state.value = 'failed'
204 failAnim = { startedAt: performance.now(), fromY: 0 }
205 burst(playerX, 1.2)
206 emit('finished', score.value)
207}
208
209// ============ 粒子 ============
210
211const burst = (x, intensity) => {
212 const count = Math.round(10 + intensity * 14)
213 for (let i = 0; i < count; i++) {
214 particles.push({
215 x,
216 y: 0,
217 vx: (Math.random() - 0.5) * 220 * intensity,
218 vy: -Math.random() * 200 * intensity - 40,
219 life: 0,
220 max: 0.7 + Math.random() * 0.7,
221 })
222 }
223}
224
225// ============ 绘制 ============
226
227const draw = (ts) => {
228 const dt = lastTs ? Math.min(0.05, (ts - lastTs) / 1000) : 0.016
229 lastTs = ts
230 if (!ctx) {
231 rafId = requestAnimationFrame(draw)
232 return
233 }
234
235 // 镜头平滑跟随
236 cameraX += (cameraTarget - cameraX) * Math.min(1, dt * 6)
237
238 // 跳跃插值
239 if (jump) {
240 const t = Math.min(1, (ts - jump.startedAt) / jump.duration)
241 const eased = t
242 playerX = jump.fromX + (jump.toX - jump.fromX) * eased
243 // 抛物线高度(舞台上半部分,留出足够的视觉幅度)
244 playerY = -Math.sin(Math.PI * t) * 108
245 if (t >= 1) {
246 playerY = 0
247 jump = null
248 checkLanding()
249 }
250 }
251
252 // 坠落动画
253 if (failAnim) {
254 const t = Math.min(1, (ts - failAnim.startedAt) / 620)
255 playerY = failAnim.fromY + t * t * H * 1.1
256 if (t >= 1) failAnim = null
257 }
258
259 // 粒子
260 for (const p of particles) {
261 p.life += dt
262 p.x += p.vx * dt
263 p.y += p.vy * dt
264 p.vy += 380 * dt
265 }
266 particles = particles.filter((p) => p.life < p.max)
267
268 // ---- 绘制 ----
269 ctx.clearRect(0, 0, stageW, H)
270
271 // 星野
272 ctx.fillStyle = 'rgba(255,255,255,0.6)'
273 for (const s of bgStars) {
274 const tw = 0.4 + 0.6 * Math.abs(Math.sin(ts / 900 + s.seed))
275 ctx.globalAlpha = tw
276 ctx.beginPath()
277 ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2)
278 ctx.fill()
279 }
280 ctx.globalAlpha = 1
281
282 const groundY = H - 74
283
284 // 平台
285 for (const p of platforms) {
286 const sx = p.x - cameraX
287 if (sx > stageW + 60 || sx + p.w < -60) continue
288 // 平台主体
289 const grad = ctx.createLinearGradient(0, groundY, 0, groundY + 16)
290 grad.addColorStop(0, rgba(0.85))
291 grad.addColorStop(1, rgba(0.42))
292 ctx.fillStyle = grad
293 roundRect(ctx, sx, groundY, p.w, 14, 7)
294 ctx.fill()
295 // 平台高光
296 ctx.fillStyle = 'rgba(255,255,255,0.5)'
297 roundRect(ctx, sx + 3, groundY + 1.5, Math.max(0, p.w - 6), 3, 1.5)
298 ctx.fill()
299 // 中心点(提示完美落点)
300 ctx.fillStyle = 'rgba(255,255,255,0.9)'
301 ctx.beginPath()
302 ctx.arc(sx + p.w / 2, groundY + 7, 1.8, 0, Math.PI * 2)
303 ctx.fill()
304 }
305
306 // 玩家(发光星星)
307 const px = playerX - cameraX
308 const py = groundY - 14 + playerY
309 if (py < H + 60) {
310 const glow = ctx.createRadialGradient(px, py, 0, px, py, 26)
311 glow.addColorStop(0, rgba(0.5))
312 glow.addColorStop(1, rgba(0))
313 ctx.fillStyle = glow
314 ctx.beginPath()
315 ctx.arc(px, py, 26, 0, Math.PI * 2)
316 ctx.fill()
317
318 ctx.fillStyle = '#fff'
319 drawStar(ctx, px, py, 4.6, 11, 5)
320 ctx.fill()
321 ctx.strokeStyle = rgba(0.95)
322 ctx.lineWidth = 1.6
323 drawStar(ctx, px, py, 4.6, 11, 5)
324 ctx.stroke()
325 }
326
327 // 粒子
328 for (const p of particles) {
329 const a = 1 - p.life / p.max
330 ctx.fillStyle = `rgba(255,255,255,${a * 0.9})`
331 ctx.beginPath()
332 ctx.arc(p.x - cameraX, groundY - 14 + p.y, 2.2 * a + 0.6, 0, Math.PI * 2)
333 ctx.fill()
334 }
335
336 // 蓄力条(画在玩家上方)
337 if (state.value === 'charging' && power.value > 0) {
338 const bw = 54
339 const bh = 5
340 const bx = px - bw / 2
341 const by = py - 34
342 ctx.fillStyle = 'rgba(255,255,255,0.28)'
343 roundRect(ctx, bx, by, bw, bh, bh / 2)
344 ctx.fill()
345 ctx.fillStyle = rgba(0.95)
346 roundRect(ctx, bx, by, Math.max(3, (bw * power.value) / 100), bh, bh / 2)
347 ctx.fill()
348 }
349
350 // 落点预判线
351 if (state.value === 'charging' && power.value > 0) {
352 const dist = (power.value / 100) * MAX_JUMP_PX
353 const tx = px + dist
354 ctx.strokeStyle = rgba(0.5)
355 ctx.setLineDash([4, 5])
356 ctx.lineWidth = 1.4
357 ctx.beginPath()
358 ctx.moveTo(px + 8, py)
359 ctx.lineTo(tx, groundY - 6)
360 ctx.stroke()
361 ctx.setLineDash([])
362 }
363
364 rafId = requestAnimationFrame(draw)
365}
366
367const roundRect = (c, x, y, w, h, r) => {
368 const radius = Math.max(0, Math.min(r, w / 2, h / 2))
369 c.beginPath()
370 c.moveTo(x + radius, y)
371 c.lineTo(x + w - radius, y)
372 c.quadraticCurveTo(x + w, y, x + w, y + radius)
373 c.lineTo(x + w, y + h - radius)
374 c.quadraticCurveTo(x + w, y + h, x + w - radius, y + h)
375 c.lineTo(x + radius, y + h)
376 c.quadraticCurveTo(x, y + h, x, y + h - radius)
377 c.lineTo(x, y + radius)
378 c.quadraticCurveTo(x, y, x + radius, y)
379 c.closePath()
380}
381
382const drawStar = (c, cx, cy, inner, outer, points) => {
383 c.beginPath()
384 for (let i = 0; i < points * 2; i++) {
385 const r = i % 2 === 0 ? outer / 2 : inner / 2
386 const a = (Math.PI / points) * i - Math.PI / 2
387 const x = cx + Math.cos(a) * r
388 const y = cy + Math.sin(a) * r
389 if (i === 0) c.moveTo(x, y)
390 else c.lineTo(x, y)
391 }
392 c.closePath()
393}
394
395// ============ 尺寸 ============
396
397const resize = () => {
398 const wrap = wrapRef.value
399 const canvas = canvasRef.value
400 if (!wrap || !canvas) return
401 dpr = Math.min(2, window.devicePixelRatio || 1)
402 stageW = wrap.clientWidth || STAGE_W
403 canvas.width = Math.floor(stageW * dpr)
404 canvas.height = Math.floor(H * dpr)
405 canvas.style.width = `${stageW}px`
406 canvas.style.height = `${H}px`
407 ctx = canvas.getContext('2d')
408 if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
409 bgStars = new Array(BG_STARS).fill(0).map(() => ({
410 x: Math.random() * stageW,
411 y: Math.random() * (H - 90),
412 r: 0.6 + Math.random() * 1.5,
413 seed: Math.random() * 6,
414 }))
415 cameraTarget = currentPlatform().x - stageW * CAMERA_RATIO
416 cameraX = cameraTarget
417}
418
419// ============ 生命周期 ============
420
421let resizeObserver = null
422
423onMounted(() => {
424 rgb = parseColor(props.color)
425 resize()
426 resetWorld()
427 rafId = requestAnimationFrame(draw)
428 if (typeof ResizeObserver !== 'undefined' && wrapRef.value) {
429 resizeObserver = new ResizeObserver(() => resize())
430 resizeObserver.observe(wrapRef.value)
431 }
432})
433
434onBeforeUnmount(() => {
435 if (rafId) cancelAnimationFrame(rafId)
436 if (powerTimer) clearInterval(powerTimer)
437 if (resizeObserver) resizeObserver.disconnect()
438})
439
440defineExpose({ resetWorld })
441</script>
442
443<template>
444 <div class="game">
445 <header class="game-head">
446 <div class="game-title">
447 <Icon name="star" :size="15" />
448 <span>星辰跳一跳</span>
449 </div>
450 <div class="game-stats">
451 <span class="stat">得分 <b>{{ score }}</b></span>
452 <span class="stat">最高 <b>{{ best }}</b></span>
453 <button class="close-btn" title="关闭" @click="emit('close')">
454 <Icon name="close" :size="15" />
455 </button>
456 </div>
457 </header>
458
459 <div
460 ref="wrapRef"
461 class="stage"
462 @pointerdown.prevent="startCharge"
463 @pointerup.prevent="release"
464 @pointerleave="state === 'charging' && release()"
465 >
466 <canvas ref="canvasRef" class="stage-canvas" />
467
468 <Transition name="hint">
469 <div v-if="state === 'idle'" class="stage-overlay">
470 <p class="overlay-title">星辰跳一跳</p>
471 <p class="overlay-sub">长按蓄力 · 松开起跳 · 落在中心额外加分</p>
472 <button class="start-btn press" @click.stop="startCharge">
473 <Icon name="play" :size="14" />
474 <span>开始游戏</span>
475 </button>
476 </div>
477 </Transition>
478
479 <Transition name="hint">
480 <div v-if="state === 'failed'" class="stage-overlay is-fail">
481 <p class="overlay-title">星星坠落了</p>
482 <p class="overlay-sub">本次得分 {{ score }}{{ best > score ? ` · 最高 ${best}` : ' · 新纪录!' }}</p>
483 <button class="start-btn press" @click.stop="resetWorld(); state = 'idle'">
484 <Icon name="refresh" :size="14" />
485 <span>再来一局</span>
486 </button>
487 </div>
488 </Transition>
489 </div>
490
491 <Transition name="hint">
492 <p v-if="unlockedText" :key="unlockedText" class="unlock a-fade-up">
493 <Icon name="sparkle" :size="13" />
494 {{ unlockedText }}
495 </p>
496 </Transition>
497 </div>
498</template>
499
500<style scoped>
501.game {
502 display: flex;
503 flex-direction: column;
504 border-radius: var(--r-xl);
505 overflow: hidden;
506 background: linear-gradient(170deg, #101b2b 0%, #16233a 46%, #0d1622 100%);
507 border: 1px solid rgba(255, 255, 255, 0.1);
508 box-shadow: var(--sh-xl);
509}
510
511.game-head {
512 flex: none;
513 display: flex;
514 align-items: center;
515 justify-content: space-between;
516 gap: 12px;
517 padding: 11px 14px;
518 background: rgba(255, 255, 255, 0.05);
519 border-bottom: 1px solid rgba(255, 255, 255, 0.07);
520}
521
522.game-title {
523 display: inline-flex;
524 align-items: center;
525 gap: 7px;
526 font-size: 13.5px;
527 font-weight: 600;
528 color: #eaf1ff;
529 letter-spacing: 0.4px;
530}
531
532.game-stats {
533 display: flex;
534 align-items: center;
535 gap: 14px;
536}
537
538.stat {
539 font-size: 11.5px;
540 color: rgba(234, 241, 255, 0.62);
541}
542
543.stat b {
544 color: #fff;
545 font-size: 13px;
546 padding-left: 2px;
547}
548
549.close-btn {
550 display: grid;
551 place-items: center;
552 width: 26px;
553 height: 26px;
554 border-radius: var(--r-sm);
555 color: rgba(234, 241, 255, 0.6);
556 transition: background-color var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out);
557}
558
559.close-btn:hover {
560 background: rgba(255, 255, 255, 0.12);
561 color: #fff;
562}
563
564.stage {
565 position: relative;
566 cursor: pointer;
567 touch-action: none;
568 user-select: none;
569}
570
571.stage-canvas {
572 display: block;
573}
574
575.stage-overlay {
576 position: absolute;
577 inset: 0;
578 display: flex;
579 flex-direction: column;
580 align-items: center;
581 justify-content: center;
582 gap: 10px;
583 background: rgba(9, 14, 22, 0.62);
584 backdrop-filter: blur(6px);
585 -webkit-backdrop-filter: blur(6px);
586}
587
588.overlay-title {
589 font-size: 19px;
590 font-weight: 700;
591 color: #fff;
592 letter-spacing: 1.6px;
593}
594
595.overlay-sub {
596 font-size: 12.5px;
597 color: rgba(234, 241, 255, 0.66);
598 letter-spacing: 0.6px;
599}
600
601.start-btn {
602 display: inline-flex;
603 align-items: center;
604 gap: 7px;
605 height: 36px;
606 margin-top: 6px;
607 padding: 0 20px;
608 border-radius: var(--r-full);
609 color: #08121f;
610 font-size: 13.5px;
611 font-weight: 600;
612 background: linear-gradient(135deg, #fff, rgba(var(--theme-color-rgb), 0.9));
613 box-shadow: 0 8px 24px rgba(var(--theme-color-rgb), 0.4);
614}
615
616.unlock {
617 flex: none;
618 display: flex;
619 align-items: center;
620 justify-content: center;
621 gap: 7px;
622 padding: 11px 14px;
623 font-size: 12.5px;
624 letter-spacing: 0.6px;
625 color: rgba(234, 241, 255, 0.86);
626 background: rgba(255, 255, 255, 0.05);
627 border-top: 1px solid rgba(255, 255, 255, 0.07);
628}
629
630.hint-enter-active,
631.hint-leave-active {
632 transition: opacity var(--t-base) var(--ease-out);
633}
634
635.hint-enter-from,
636.hint-leave-to {
637 opacity: 0;
638}
639</style>