星尘收集者
by EpicCoder88704 lines19.8 KB
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>星尘收集者</title>
<style>
html, body {
margin: 0;
height: 100%;
background: #05060f;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
font-family: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
-webkit-tap-highlight-color: transparent;
}
canvas {
width: min(96vw, 170vh);
height: auto;
display: block;
border-radius: 14px;
box-shadow: 0 0 80px rgba(110, 130, 255, 0.28), 0 0 0 1px rgba(120, 150, 255, 0.12);
touch-action: none;
}
#touch {
position: fixed;
inset: 0;
pointer-events: none;
display: none;
}
#touch button {
pointer-events: auto;
position: absolute;
bottom: 26px;
width: 78px;
height: 78px;
border-radius: 50%;
border: 2px solid rgba(140, 180, 255, 0.45);
background: rgba(40, 60, 120, 0.45);
color: #cfe0ff;
font-size: 26px;
backdrop-filter: blur(6px);
user-select: none;
}
#touch button:active { background: rgba(90, 130, 230, 0.6); }
#btnL { left: 22px; }
#btnR { left: 118px; }
#btnJ { right: 22px; }
</style>
</head>
<body>
<canvas id="game"></canvas>
<div id="touch">
<button id="btnL">◀</button>
<button id="btnR">▶</button>
<button id="btnJ">▲</button>
</div>
<script>
(() => {
'use strict';
// ===== 画布 =====
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const W = 960, H = 540;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = W * dpr;
canvas.height = H * dpr;
ctx.scale(dpr, dpr);
// ===== 音效 =====
let audioCtx = null;
function initAudio() {
if (!audioCtx) {
try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) {}
}
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
}
function tone(freq, dur, type = 'square', vol = 0.05, slideTo = null) {
if (!audioCtx) return;
const t = audioCtx.currentTime;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, t);
if (slideTo) osc.frequency.exponentialRampToValueAtTime(Math.max(20, slideTo), t + dur);
gain.gain.setValueAtTime(0.0001, t);
gain.gain.exponentialRampToValueAtTime(vol, t + 0.008);
gain.gain.exponentialRampToValueAtTime(0.0001, t + dur);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(t);
osc.stop(t + dur + 0.03);
}
// ===== 关卡数据 =====
const LEVEL = {
spawn: { x: 60, y: 440 },
platforms: [
{ x: 0, y: 500, w: 960, h: 40 },
{ x: 140, y: 405, w: 120, h: 18 },
{ x: 330, y: 330, w: 110, h: 18 },
{ x: 520, y: 405, w: 120, h: 18 },
{ x: 720, y: 330, w: 130, h: 18 },
{ x: 640, y: 225, w: 120, h: 18 },
{ x: 480, y: 150, w: 120, h: 18 },
{ x: 280, y: 215, w: 120, h: 18 },
{ x: 770, y: 130, w: 170, h: 18 }
],
stars: [
{ x: 200, y: 370 }, { x: 385, y: 295 }, { x: 580, y: 375 }, { x: 785, y: 300 },
{ x: 700, y: 195 }, { x: 540, y: 120 }, { x: 340, y: 185 }, { x: 900, y: 465 }
],
enemies: [
{ x: 525, y: 381, w: 22, h: 24, minX: 525, maxX: 640, dir: 1, speed: 1.4 },
{ x: 320, y: 476, w: 22, h: 24, minX: 320, maxX: 780, dir: 1, speed: 1.9 }
],
goal: { x: 822, y: 40, w: 66, h: 90 }
};
// ===== 状态 =====
let player, stars = [], enemies = [], particles = [], bgStars = [];
let collectedCount = 0, deaths = 0, time = 0, frame = 0, shake = 0;
let state = 'playing', winTimer = 0;
const keys = {};
for (let i = 0; i < 130; i++) {
bgStars.push({
x: Math.random() * W,
y: Math.random() * H,
r: Math.random() * 1.5 + 0.35,
p: Math.random() * Math.PI * 2,
s: 0.015 + Math.random() * 0.035
});
}
// ===== 工具 =====
function rectsOverlap(a, b) {
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
}
function roundRect(x, y, w, h, r) {
r = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function starPath(cx, cy, outer, inner, points, rot) {
ctx.beginPath();
for (let i = 0; i < points * 2; i++) {
const r = i % 2 === 0 ? outer : inner;
const a = rot + i * Math.PI / points - Math.PI / 2;
const x = cx + Math.cos(a) * r;
const y = cy + Math.sin(a) * r;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.closePath();
}
function burst(x, y, color, n, power = 4) {
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2;
const sp = 0.8 + Math.random() * power;
particles.push({
x, y,
vx: Math.cos(a) * sp,
vy: Math.sin(a) * sp - 1,
life: 1,
decay: 0.014 + Math.random() * 0.022,
color,
size: 1.5 + Math.random() * 2.6
});
}
}
function formatTime(t) {
const m = Math.floor(t / 60);
const s = Math.floor(t % 60);
const cs = Math.floor((t % 1) * 100);
return `${m}:${String(s).padStart(2, '0')}.${String(cs).padStart(2, '0')}`;
}
// ===== 初始化 =====
function resetPlayer() {
player.x = LEVEL.spawn.x;
player.y = LEVEL.spawn.y;
player.vx = 0;
player.vy = 0;
player.onGround = false;
player.coyote = 0;
player.jumpBuffer = 0;
player.facing = 1;
player.dead = false;
player.respawnTimer = 0;
}
function init() {
player = { x: 0, y: 0, w: 20, h: 28, vx: 0, vy: 0, onGround: false, coyote: 0, jumpBuffer: 0, facing: 1, dead: false, respawnTimer: 0 };
restart();
}
function restart() {
stars = LEVEL.stars.map(s => ({ ...s, collected: false, phase: Math.random() * Math.PI * 2 }));
enemies = LEVEL.enemies.map(e => ({ ...e }));
particles = [];
collectedCount = 0;
deaths = 0;
time = 0;
frame = 0;
shake = 0;
winTimer = 0;
state = 'playing';
resetPlayer();
}
function killPlayer() {
if (player.dead || state !== 'playing') return;
player.dead = true;
player.respawnTimer = 38;
deaths++;
shake = 13;
burst(player.x + player.w / 2, player.y + player.h / 2, '#ff5c7a', 22, 5);
tone(240, 0.32, 'sawtooth', 0.07, 60);
}
// ===== 物理 =====
function moveAndCollide(ent) {
// 水平
ent.x += ent.vx;
for (const p of LEVEL.platforms) {
if (rectsOverlap(ent, p)) {
if (ent.vx > 0) ent.x = p.x - ent.w;
else if (ent.vx < 0) ent.x = p.x + p.w;
ent.vx = 0;
}
}
// 垂直
ent.onGround = false;
ent.y += ent.vy;
for (const p of LEVEL.platforms) {
if (rectsOverlap(ent, p)) {
if (ent.vy > 0) { ent.y = p.y - ent.h; ent.onGround = true; }
else if (ent.vy < 0) { ent.y = p.y + p.h; }
ent.vy = 0;
}
}
}
// ===== 更新 =====
function update() {
frame++;
if (shake > 0.4) shake *= 0.87; else shake = 0;
// 粒子
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.vy += 0.16;
p.vx *= 0.975;
p.x += p.vx;
p.y += p.vy;
p.life -= p.decay;
if (p.life <= 0) particles.splice(i, 1);
}
if (state === 'won') { winTimer++; return; }
if (player.dead) {
player.respawnTimer--;
if (player.respawnTimer <= 0) resetPlayer();
return;
}
time += 1 / 60;
const left = keys['ArrowLeft'] || keys['KeyA'];
const right = keys['ArrowRight'] || keys['KeyD'];
const jumpHeld = keys['Space'] || keys['ArrowUp'] || keys['KeyW'];
// 水平加速度
const ACC = 0.85, MAXV = 5.0;
if (left) { player.vx -= ACC; player.facing = -1; }
if (right) { player.vx += ACC; player.facing = 1; }
if (!left && !right) {
player.vx *= player.onGround ? 0.75 : 0.93;
if (Math.abs(player.vx) < 0.12) player.vx = 0;
}
player.vx = Math.max(-MAXV, Math.min(MAXV, player.vx));
// 土狼时间
if (player.onGround) player.coyote = 8;
else if (player.coyote > 0) player.coyote--;
// 跳跃缓冲
if (player.jumpBuffer > 0) player.jumpBuffer--;
if (player.jumpBuffer > 0 && player.coyote > 0) {
player.vy = -14;
player.jumpBuffer = 0;
player.coyote = 0;
player.onGround = false;
tone(520, 0.11, 'square', 0.045, 900);
for (let i = 0; i < 6; i++) {
burst(player.x + player.w / 2, player.y + player.h, 'rgba(140,200,255,0.9)', 1, 2);
}
}
// 重力(可变跳跃高度)
let g = 0.65;
if (player.vy > 0) g = 0.85;
if (!jumpHeld && player.vy < 0) g = 1.7;
player.vy = Math.min(player.vy + g, 19);
const wasOnGround = player.onGround;
const impact = player.vy;
moveAndCollide(player);
if (!wasOnGround && player.onGround && impact > 6) {
for (let i = 0; i < 7; i++) {
burst(player.x + player.w / 2, player.y + player.h, 'rgba(150,190,255,0.8)', 1, 2.4);
}
tone(150, 0.06, 'sine', 0.028);
}
// 边界
player.x = Math.max(0, Math.min(W - player.w, player.x));
// 掉出屏幕
if (player.y > H + 80) killPlayer();
// 敌人
for (const e of enemies) {
e.x += e.dir * e.speed;
if (e.x <= e.minX) { e.x = e.minX; e.dir = 1; }
if (e.x + e.w >= e.maxX) { e.x = e.maxX - e.w; e.dir = -1; }
if (rectsOverlap(player, e)) killPlayer();
}
// 星星
for (const s of stars) {
if (s.collected) continue;
const dx = (player.x + player.w / 2) - s.x;
const dy = (player.y + player.h / 2) - s.y;
if (dx * dx + dy * dy < 900) {
s.collected = true;
collectedCount++;
burst(s.x, s.y, '#ffd93d', 16, 4);
shake = Math.max(shake, 4);
tone(760 + collectedCount * 45, 0.13, 'sine', 0.06, 1400);
}
}
// 传送门
if (collectedCount === stars.length && state === 'playing') {
if (rectsOverlap(player, LEVEL.goal)) {
state = 'won';
shake = 16;
burst(LEVEL.goal.x + LEVEL.goal.w / 2, LEVEL.goal.y + LEVEL.goal.h / 2, '#7bf1a8', 40, 7);
tone(660, 0.5, 'sine', 0.07, 1320);
setTimeout(() => tone(990, 0.5, 'sine', 0.06, 1760), 120);
}
}
}
// ===== 绘制 =====
function drawBackground() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, '#080c22');
g.addColorStop(0.55, '#111a3c');
g.addColorStop(1, '#191036');
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
for (const s of bgStars) {
const a = 0.22 + Math.sin(frame * s.s + s.p) * 0.24;
ctx.globalAlpha = Math.max(0, a);
ctx.fillStyle = s.r > 1.15 ? '#cfe0ff' : '#7f97d8';
ctx.beginPath();
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
const g2 = ctx.createRadialGradient(W / 2, H + 120, 40, W / 2, H + 120, 620);
g2.addColorStop(0, 'rgba(95,72,210,0.35)');
g2.addColorStop(1, 'rgba(95,72,210,0)');
ctx.fillStyle = g2;
ctx.fillRect(0, 0, W, H);
}
function drawPlatforms() {
for (const p of LEVEL.platforms) {
const g = ctx.createLinearGradient(0, p.y, 0, p.y + p.h);
g.addColorStop(0, '#4d63b0');
g.addColorStop(1, '#232c60');
ctx.fillStyle = g;
roundRect(p.x, p.y, p.w, p.h, 7);
ctx.fill();
ctx.fillStyle = 'rgba(168, 205, 255, 0.65)';
roundRect(p.x + 3, p.y + 2, p.w - 6, 2.4, 1.2);
ctx.fill();
}
}
function drawStarsCollectibles() {
for (const s of stars) {
if (s.collected) continue;
const t = frame * 0.05 + s.phase;
const fy = s.y + Math.sin(t) * 4.5;
ctx.save();
ctx.shadowColor = '#ffd93d';
ctx.shadowBlur = 18;
ctx.fillStyle = '#ffe66d';
starPath(s.x, fy, 11, 5, 5, t * 0.7);
ctx.fill();
ctx.restore();
ctx.save();
ctx.globalAlpha = 0.35 + Math.sin(t * 2) * 0.2;
ctx.fillStyle = '#fff6b0';
starPath(s.x, fy, 4.5, 2, 5, -t);
ctx.fill();
ctx.restore();
}
}
function drawGoal() {
const gx = LEVEL.goal.x + LEVEL.goal.w / 2;
const gy = LEVEL.goal.y + LEVEL.goal.h / 2;
const active = collectedCount === stars.length;
const t = frame * 0.035;
ctx.save();
if (active) {
ctx.shadowColor = '#7bf1a8';
ctx.shadowBlur = 26;
ctx.strokeStyle = 'rgba(123, 241, 168, 0.95)';
} else {
ctx.strokeStyle = 'rgba(110, 135, 190, 0.35)';
}
ctx.lineWidth = 3.5;
for (let i = 0; i < 3; i++) {
const pulse = 1 + Math.sin(t + i * 1.1) * 0.06;
ctx.globalAlpha = active ? (0.9 - i * 0.22) : (0.5 - i * 0.13);
ctx.beginPath();
ctx.ellipse(gx, gy, 26 * pulse + i * 5, 42 * pulse + i * 5, 0, 0, Math.PI * 2);
ctx.stroke();
}
ctx.globalAlpha = 1;
if (active) {
const core = ctx.createRadialGradient(gx, gy, 2, gx, gy, 34);
core.addColorStop(0, 'rgba(200, 255, 225, 0.85)');
core.addColorStop(0.5, 'rgba(123, 241, 168, 0.35)');
core.addColorStop(1, 'rgba(123, 241, 168, 0)');
ctx.fillStyle = core;
ctx.beginPath();
ctx.ellipse(gx, gy, 30, 46, 0, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
function drawEnemies() {
for (const e of enemies) {
const cx = e.x + e.w / 2;
const cy = e.y + e.h / 2;
const rot = frame * 0.045;
ctx.save();
ctx.shadowColor = '#ff4d6d';
ctx.shadowBlur = 16;
ctx.fillStyle = '#ff4d6d';
ctx.beginPath();
for (let i = 0; i < 20; i++) {
const r = i % 2 === 0 ? 13.5 : 9;
const a = rot + i * Math.PI / 10;
const x = cx + Math.cos(a) * r;
const y = cy + Math.sin(a) * r * 1.05;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.closePath();
ctx.fill();
ctx.restore();
ctx.fillStyle = '#2a0410';
ctx.beginPath();
ctx.arc(cx - 4, cy - 2, 2.3, 0, Math.PI * 2);
ctx.arc(cx + 4, cy - 2, 2.3, 0, Math.PI * 2);
ctx.fill();
}
}
function drawPlayer() {
const { x, y, w, h } = player;
ctx.save();
ctx.shadowColor = '#66d9ff';
ctx.shadowBlur = 16;
const g = ctx.createLinearGradient(0, y, 0, y + h);
g.addColorStop(0, '#a8f0ff');
g.addColorStop(1, '#3fb8e8');
ctx.fillStyle = g;
roundRect(x, y, w, h, 7);
ctx.fill();
ctx.restore();
ctx.fillStyle = '#0b1020';
const ex = player.facing > 0 ? 1.6 : -1.6;
ctx.beginPath();
ctx.arc(x + w * 0.34 + ex, y + 10, 2.6, 0, Math.PI * 2);
ctx.arc(x + w * 0.68 + ex, y + 10, 2.6, 0, Math.PI * 2);
ctx.fill();
}
function drawParticles() {
for (const p of particles) {
ctx.globalAlpha = Math.max(0, p.life);
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawHUD() {
ctx.save();
ctx.textBaseline = 'middle';
// 星星计数
ctx.save();
ctx.shadowColor = '#ffd93d';
ctx.shadowBlur = 10;
ctx.fillStyle = '#ffe66d';
starPath(34, 34, 11, 5, 5, frame * 0.02);
ctx.fill();
ctx.restore();
ctx.fillStyle = '#e8ecff';
ctx.font = 'bold 22px system-ui, -apple-system, sans-serif';
ctx.textAlign = 'left';
ctx.fillText(`${collectedCount} / ${stars.length}`, 54, 36);
// 计时 / 失误
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(205, 220, 255, 0.85)';
ctx.font = 'bold 21px system-ui, -apple-system, sans-serif';
ctx.fillText(formatTime(time), W - 24, 32);
ctx.fillStyle = 'rgba(205, 220, 255, 0.45)';
ctx.font = '15px system-ui, -apple-system, sans-serif';
ctx.fillText(`失误 ${deaths}`, W - 24, 58);
// 顶部提示
ctx.textAlign = 'center';
if (state === 'playing') {
if (collectedCount < stars.length) {
ctx.fillStyle = 'rgba(175, 195, 255, 0.3)';
ctx.font = '16px system-ui, -apple-system, sans-serif';
ctx.fillText('收集所有星尘,传送门才会开启', W / 2, 28);
} else {
const a = 0.55 + Math.sin(frame * 0.13) * 0.4;
ctx.fillStyle = `rgba(123, 241, 168, ${a})`;
ctx.font = 'bold 17px system-ui, -apple-system, sans-serif';
ctx.fillText('✦ 传送门已开启 ✦', W / 2, 28);
}
}
ctx.restore();
// 操作提示(前几秒)
if (frame < 300 && state === 'playing') {
ctx.save();
ctx.globalAlpha = Math.max(0, 1 - frame / 300) * 0.5;
ctx.textAlign = 'center';
ctx.fillStyle = '#cfe0ff';
ctx.font = '16px system-ui, -apple-system, sans-serif';
ctx.fillText('← → / A D 移动 空格 / ↑ 跳跃 R 重开', W / 2, H - 34);
ctx.restore();
}
}
function drawWin() {
ctx.save();
ctx.fillStyle = 'rgba(5, 8, 20, 0.74)';
ctx.fillRect(0, 0, W, H);
const scale = 1 + Math.min(1, winTimer / 20) * 0 + 1;
ctx.textAlign = 'center';
ctx.shadowColor = '#7bf1a8';
ctx.shadowBlur = 26;
ctx.fillStyle = '#7bf1a8';
ctx.font = 'bold 58px system-ui, -apple-system, sans-serif';
ctx.fillText('✦ 通关 ✦', W / 2, H / 2 - 70);
ctx.shadowBlur = 0;
ctx.fillStyle = '#e8ecff';
ctx.font = 'bold 30px system-ui, -apple-system, sans-serif';
ctx.fillText(`用时 ${formatTime(time)}`, W / 2, H / 2 + 5);
ctx.fillStyle = 'rgba(200, 215, 255, 0.7)';
ctx.font = '20px system-ui, -apple-system, sans-serif';
ctx.fillText(`失误 ${deaths} 次`, W / 2, H / 2 + 50);
const a = 0.5 + Math.sin(winTimer * 0.1) * 0.4;
ctx.fillStyle = `rgba(160, 200, 255, ${a})`;
ctx.font = 'bold 18px system-ui, -apple-system, sans-serif';
ctx.fillText('按 R 重新开始', W / 2, H / 2 + 110);
ctx.restore();
}
function render() {
ctx.save();
ctx.clearRect(0, 0, W, H);
if (shake > 0.4) {
ctx.translate((Math.random() - 0.5) * shake, (Math.random() - 0.5) * shake);
}
drawBackground();
drawPlatforms();
drawStarsCollectibles();
drawGoal();
drawEnemies();
if (!player.dead) drawPlayer();
drawParticles();
drawHUD();
if (state === 'won') drawWin();
ctx.restore();
}
// ===== 主循环 =====
const STEP = 1000 / 60;
let lastTime = performance.now();
let acc = 0;
function loop(now) {
requestAnimationFrame(loop);
let delta = now - lastTime;
lastTime = now;
if (delta > 200) delta = 200;
acc += delta;
let guard = 0;
while (acc >= STEP && guard++ < 5) {
update();
acc -= STEP;
}
render();
}
// ===== 输入 =====
window.addEventListener('keydown', e => {
initAudio();
if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) e.preventDefault();
if (!e.repeat) {
if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') {
player.jumpBuffer = 8;
}
if (e.code === 'KeyR') restart();
}
keys[e.code] = true;
});
window.addEventListener('keyup', e => {
keys[e.code] = false;
});
// 触屏
const touchBar = document.getElementById('touch');
if ('ontouchstart' in window || navigator.maxTouchPoints > 0) {
touchBar.style.display = 'block';
const bind = (id, code, isJump) => {
const el = document.getElementById(id);
const on = ev => {
ev.preventDefault();
initAudio();
keys[code] = true;
if (isJump) player.jumpBuffer = 8;
};
const off = ev => { ev.preventDefault(); keys[code] = false; };
el.addEventListener('pointerdown', on);
el.addEventListener('pointerup', off);
el.addEventListener('pointercancel', off);
el.addEventListener('pointerleave', off);
};
bind('btnL', 'ArrowLeft', false);
bind('btnR', 'ArrowRight', false);
bind('btnJ', 'Space', true);
}
// ===== 启动 =====
init();
requestAnimationFrame(loop);
})();
</script>
</body>
</html>Game Source: 星尘收集者
Creator: EpicCoder88
Libraries: none
Complexity: complex (704 lines, 19.8 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: game-epiccoder88-murui34x" to link back to the original. Then publish at arcadelab.ai/publish.