Sky Hopper
game.js7.7 KB
1// Sky Hopper — an Arcadia seed game. Run, jump, double-jump, grab coins. Fork it and make it better!2(() => {3 const W = 800, H = 450;4 const c = document.getElementById("c");5 const ctx = c.getContext("2d");6 function fit() {7 const s = Math.min(innerWidth / W, innerHeight / H), d = window.devicePixelRatio || 1;8 c.style.width = W * s + "px"; c.style.height = H * s + "px";9 c.width = Math.round(W * s * d); c.height = Math.round(H * s * d);10 ctx.setTransform(s * d, 0, 0, s * d, 0, 0);11 }12 addEventListener("resize", fit); fit();13 14 const GRAV = 2200, JUMP = 760;15 const best = () => Number(localStorage.getItem("skyhopper.best") || 0);16 let state = "title", p, plats = [], coins = [], spikes = [], camX = 0, speed, dist, coinCount, t = 0, paused = false, sparkles = [];17 const clouds = Array.from({ length: 8 }, (_, i) => ({ x: i * 140 + Math.random() * 80, y: 30 + Math.random() * 140, s: 0.6 + Math.random() * 0.8 }));18 19 function reset() {20 p = { x: 120, y: 250, vy: 0, w: 30, h: 36, onGround: false, jumps: 0, squash: 0 };21 plats = [{ x: -50, y: 330, w: 600 }];22 coins = []; spikes = []; camX = 0; speed = 300; dist = 0; coinCount = 0; sparkles = [];23 while (plats[plats.length - 1].x < W * 2) addPlat();24 state = "play";25 }26 function addPlat() {27 const last = plats[plats.length - 1];28 const difficulty = Math.min(1, dist / 6000);29 const gap = 70 + Math.random() * (90 + difficulty * 110);30 const w = 120 + Math.random() * (260 - difficulty * 120);31 const y = Math.max(170, Math.min(380, last.y + (Math.random() - 0.5) * 150));32 const pl = { x: last.x + last.w + gap, y, w };33 plats.push(pl);34 const n = (Math.random() * 4) | 0;35 for (let i = 0; i < n; i++) coins.push({ x: pl.x + 30 + i * 34, y: pl.y - 50 - Math.sin(i / Math.max(1, n - 1) * Math.PI) * 30, got: false });36 if (dist > 1500 && Math.random() < 0.3 + difficulty * 0.3 && w > 170) spikes.push({ x: pl.x + w / 2 - 15 + (Math.random() - 0.5) * (w - 90), y: pl.y, w: 30 });37 }38 39 function jump() {40 if (state !== "play") { reset(); return; }41 if (p.onGround || p.jumps < 2) {42 p.vy = -JUMP * (p.jumps === 1 ? 0.88 : 1);43 p.jumps++; p.onGround = false; p.squash = -0.25;44 }45 }46 addEventListener("keydown", (e) => { if ([" ", "ArrowUp", "w", "Enter"].includes(e.key)) { if (!e.repeat) jump(); e.preventDefault(); } });47 c.addEventListener("pointerdown", () => { jump(); c.focus(); });48 if (window.Arcadia) { Arcadia.onPause(() => (paused = true)); Arcadia.onResume(() => (paused = false)); }49 50 function die() {51 state = "over";52 const s = score();53 if (s > best()) localStorage.setItem("skyhopper.best", String(s));54 if (window.Arcadia) { Arcadia.submitScore(s); Arcadia.gameOver(); }55 }56 const score = () => Math.floor(dist / 10) + coinCount * 25;57 58 function update(dt) {59 t += dt;60 for (const s of sparkles) { s.life -= dt; s.y -= 40 * dt; }61 sparkles = sparkles.filter((s) => s.life > 0);62 if (state !== "play") return;63 speed = Math.min(620, 300 + dist / 25);64 const dx = speed * dt;65 dist += dx; camX += dx; p.x += dx;66 const prevBottom = p.y + p.h;67 p.vy += GRAV * dt; p.y += p.vy * dt;68 p.onGround = false;69 for (const pl of plats) {70 if (p.x + p.w > pl.x && p.x < pl.x + pl.w && p.vy >= 0 && prevBottom <= pl.y + 2 && p.y + p.h >= pl.y) {71 p.y = pl.y - p.h; p.vy = 0; p.onGround = true;72 if (p.jumps) p.squash = 0.25;73 p.jumps = 0;74 }75 }76 p.squash *= Math.pow(0.001, dt);77 for (const co of coins) {78 if (!co.got && Math.abs(co.x - (p.x + p.w / 2)) < 26 && Math.abs(co.y - (p.y + p.h / 2)) < 30) {79 co.got = true; coinCount++;80 sparkles.push({ x: co.x, y: co.y, life: 0.6, text: "+25" });81 }82 }83 for (const s of spikes) {84 if (p.x + p.w - 6 > s.x && p.x + 6 < s.x + s.w && p.y + p.h > s.y - 18) return die();85 }86 if (p.y > H + 60) return die();87 while (plats[plats.length - 1].x < camX + W * 2) addPlat();88 plats = plats.filter((pl) => pl.x + pl.w > camX - 100);89 coins = coins.filter((co) => co.x > camX - 100);90 spikes = spikes.filter((s) => s.x > camX - 100);91 }92 93 function draw() {94 const sky = ctx.createLinearGradient(0, 0, 0, H);95 sky.addColorStop(0, "#5ab0ff"); sky.addColorStop(1, "#c9ecff");96 ctx.fillStyle = sky; ctx.fillRect(0, 0, W, H);97 ctx.fillStyle = "rgba(255,255,255,.9)";98 for (const cl of clouds) {99 const x = ((cl.x - camX * 0.15 * cl.s) % (W + 200) + W + 200) % (W + 200) - 100;100 ctx.beginPath();101 ctx.ellipse(x, cl.y, 50 * cl.s, 18 * cl.s, 0, 0, Math.PI * 2);102 ctx.ellipse(x + 30 * cl.s, cl.y - 12 * cl.s, 34 * cl.s, 20 * cl.s, 0, 0, Math.PI * 2);103 ctx.fill();104 }105 ctx.fillStyle = "#8fd18b";106 for (let i = -1; i < 6; i++) {107 const x = i * 220 - ((camX * 0.35) % 220);108 ctx.beginPath(); ctx.ellipse(x, H + 30, 160, 120, 0, 0, Math.PI * 2); ctx.fill();109 }110 111 ctx.save(); ctx.translate(-camX, 0);112 for (const pl of plats) {113 ctx.fillStyle = "#7a4e2d"; ctx.fillRect(pl.x, pl.y + 12, pl.w, H - pl.y);114 ctx.fillStyle = "#4caf50"; ctx.beginPath(); ctx.roundRect(pl.x - 4, pl.y, pl.w + 8, 16, 6); ctx.fill();115 ctx.fillStyle = "#6fd46f"; ctx.fillRect(pl.x, pl.y + 2, pl.w, 4);116 }117 for (const s of spikes) {118 ctx.fillStyle = "#5b6475";119 for (let i = 0; i < 3; i++) { ctx.beginPath(); ctx.moveTo(s.x + i * 10, s.y); ctx.lineTo(s.x + i * 10 + 5, s.y - 18); ctx.lineTo(s.x + i * 10 + 10, s.y); ctx.fill(); }120 }121 for (const co of coins) {122 if (co.got) continue;123 const wob = Math.abs(Math.cos(t * 4 + co.x));124 ctx.fillStyle = "#ffcf33"; ctx.strokeStyle = "#e0a100"; ctx.lineWidth = 2;125 ctx.beginPath(); ctx.ellipse(co.x, co.y, 10 * wob + 2, 11, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke();126 }127 if (p) {128 const sq = p.squash;129 const w = p.w * (1 + sq), h = p.h * (1 - sq);130 const x = p.x + (p.w - w) / 2, y = p.y + (p.h - h);131 ctx.fillStyle = "#ff5a5f"; ctx.beginPath(); ctx.roundRect(x, y, w, h, 9); ctx.fill();132 ctx.fillStyle = "#fff"; ctx.beginPath(); ctx.arc(x + w - 9, y + 12, 6, 0, Math.PI * 2); ctx.fill();133 ctx.fillStyle = "#222"; ctx.beginPath(); ctx.arc(x + w - 7, y + 12, 3, 0, Math.PI * 2); ctx.fill();134 ctx.fillStyle = "#ffb3b5"; ctx.fillRect(x + 4, y + h - 8, w - 8, 4);135 }136 ctx.font = "800 16px system-ui"; ctx.textAlign = "center";137 for (const s of sparkles) { ctx.globalAlpha = s.life / 0.6; ctx.fillStyle = "#b8860b"; ctx.fillText(s.text, s.x, s.y - 20); }138 ctx.globalAlpha = 1;139 ctx.restore();140 141 ctx.fillStyle = "rgba(0,0,0,.25)"; ctx.beginPath(); ctx.roundRect(12, 12, 250, 40, 12); ctx.fill();142 ctx.fillStyle = "#fff"; ctx.textAlign = "left"; ctx.textBaseline = "middle"; ctx.font = "800 20px system-ui";143 ctx.fillText(`${state === "title" ? 0 : score()} ● ${coinCount || 0}`, 26, 32);144 ctx.textAlign = "right"; ctx.font = "700 16px system-ui"; ctx.fillStyle = "#0b3b66"; ctx.fillText(`Best ${best()}`, W - 16, 30);145 146 if (state !== "play") {147 ctx.fillStyle = "rgba(10,40,80,.55)"; ctx.fillRect(0, 0, W, H);148 ctx.fillStyle = "#fff"; ctx.textAlign = "center";149 ctx.font = "900 60px system-ui"; ctx.fillText(state === "over" ? "Ouch!" : "SKY HOPPER", W / 2, H / 2 - 40);150 ctx.font = "600 20px system-ui";151 ctx.fillText(state === "over" ? `Score ${score()} · Best ${best()}` : "Tap / Space to jump. Tap again in the air to double-jump.", W / 2, H / 2 + 20);152 ctx.fillText("Tap or press Space to " + (state === "over" ? "try again" : "start"), W / 2, H / 2 + 56);153 }154 }155 156 let last = performance.now();157 function loop(now) {158 const dt = Math.min(0.033, (now - last) / 1000); last = now;159 if (!paused) update(dt);160 draw();161 requestAnimationFrame(loop);162 }163 requestAnimationFrame(loop);164})();165