arcadia / neon-drift

Neon Drift

game.js8.2 KB
1// Neon Drift — an Arcadia seed game. Weave through traffic at night. Fork it and make it better!
2(() => {
3 const W = 480, H = 760, ROAD_X = 60, ROAD_W = 360, LANES = 4, LANE_W = ROAD_W / LANES;
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 CAR_COLORS = ["#ff4d6d", "#ffd23f", "#3ddc97", "#9d4edd", "#ff9f1c", "#f8f8f8"];
15 const best = () => Number(localStorage.getItem("neondrift.best") || 0);
16 let state = "title", car, traffic = [], orbs = [], speed = 0, dist = 0, bonus = 0, roadOff = 0, spawnT = 0, t = 0, popups = [], paused = false, crashT = 0;
17 const keys = {};
18 let steerTarget = null;
19
20 function reset() {
21 car = { x: W / 2, y: H - 150, w: 44, h: 80, tilt: 0 };
22 traffic = []; orbs = []; speed = 420; dist = 0; bonus = 0; spawnT = 0; popups = []; crashT = 0;
23 state = "play";
24 }
25 const laneX = (i) => ROAD_X + LANE_W * i + LANE_W / 2;
26
27 function spawn() {
28 const difficulty = Math.min(1, dist / 40000);
29 const lane = (Math.random() * LANES) | 0;
30 if (traffic.some((o) => o.y < 60 && Math.abs(o.x - laneX(lane)) < 10)) return;
31 traffic.push({ x: laneX(lane), y: -100, w: 44, h: 80, v: 120 + Math.random() * 120, color: CAR_COLORS[(Math.random() * CAR_COLORS.length) | 0], passed: false });
32 if (Math.random() < 0.35 + difficulty * 0.3) {
33 const lane2 = (lane + 1 + ((Math.random() * (LANES - 1)) | 0)) % LANES;
34 traffic.push({ x: laneX(lane2), y: -260, w: 44, h: 80, v: 150, color: CAR_COLORS[(Math.random() * CAR_COLORS.length) | 0], passed: false });
35 }
36 if (Math.random() < 0.3) orbs.push({ x: laneX((Math.random() * LANES) | 0), y: -40 });
37 }
38
39 addEventListener("keydown", (e) => {
40 keys[e.key] = true;
41 if (["ArrowLeft", "ArrowRight", " "].includes(e.key)) e.preventDefault();
42 if ((e.key === " " || e.key === "Enter") && state !== "play") reset();
43 });
44 addEventListener("keyup", (e) => (keys[e.key] = false));
45 function localX(e) { const r = c.getBoundingClientRect(); return ((e.clientX - r.left) / r.width) * W; }
46 c.addEventListener("pointerdown", (e) => { if (state !== "play" && crashT <= 0) { reset(); return; } steerTarget = localX(e); c.focus(); });
47 addEventListener("pointermove", (e) => { if (e.buttons || e.pointerType === "touch") steerTarget = localX(e); });
48 addEventListener("pointerup", () => (steerTarget = null));
49 if (window.Arcadia) { Arcadia.onPause(() => (paused = true)); Arcadia.onResume(() => (paused = false)); }
50
51 const score = () => Math.floor(dist / 20) + bonus;
52
53 function crash() {
54 state = "over"; crashT = 0.8;
55 const s = score();
56 if (s > best()) localStorage.setItem("neondrift.best", String(s));
57 if (window.Arcadia) { Arcadia.submitScore(s); Arcadia.gameOver(); }
58 }
59
60 function update(dt) {
61 t += dt; crashT = Math.max(0, crashT - dt);
62 for (const p of popups) { p.life -= dt; p.y -= 60 * dt; }
63 popups = popups.filter((p) => p.life > 0);
64 if (state !== "play") { roadOff = (roadOff + 60 * dt) % 80; return; }
65 speed = Math.min(1100, 420 + dist / 60);
66 dist += speed * dt;
67 roadOff = (roadOff + speed * dt) % 80;
68
69 let steer = 0;
70 if (keys.ArrowLeft || keys.a) steer -= 1;
71 if (keys.ArrowRight || keys.d) steer += 1;
72 const before = car.x;
73 if (steerTarget !== null && !steer) car.x += (steerTarget - car.x) * Math.min(1, dt * 10);
74 else car.x += steer * 460 * dt;
75 car.x = Math.max(ROAD_X + car.w / 2 + 4, Math.min(ROAD_X + ROAD_W - car.w / 2 - 4, car.x));
76 car.tilt += (((car.x - before) / Math.max(dt, 0.001)) / 1600 - car.tilt) * Math.min(1, dt * 12);
77
78 spawnT -= dt;
79 if (spawnT <= 0) { spawn(); spawnT = Math.max(0.32, 0.9 - dist / 60000) * (420 / speed) * 1.4; }
80 for (const o of traffic) {
81 o.y += (speed - o.v) * dt;
82 if (Math.abs(o.x - car.x) < (o.w + car.w) / 2 - 8 && Math.abs(o.y - car.y) < (o.h + car.h) / 2 - 10) return crash();
83 if (!o.passed && o.y > car.y + car.h / 2) {
84 o.passed = true;
85 if (Math.abs(o.x - car.x) < LANE_W * 1.05) { bonus += 15; popups.push({ x: car.x, y: car.y - 60, life: 0.7, text: "NEAR MISS +15" }); }
86 }
87 }
88 for (const o of orbs) {
89 o.y += speed * dt;
90 if (!o.got && Math.abs(o.x - car.x) < 34 && Math.abs(o.y - car.y) < 50) { o.got = true; bonus += 50; popups.push({ x: o.x, y: o.y - 30, life: 0.7, text: "+50" }); }
91 }
92 traffic = traffic.filter((o) => o.y < H + 120);
93 orbs = orbs.filter((o) => !o.got && o.y < H + 40);
94 }
95
96 function drawCar(x, y, color, tilt = 0, player = false) {
97 ctx.save(); ctx.translate(x, y); ctx.rotate(tilt);
98 ctx.shadowColor = color; ctx.shadowBlur = player ? 24 : 12;
99 ctx.fillStyle = color; ctx.beginPath(); ctx.roundRect(-22, -40, 44, 80, 12); ctx.fill();
100 ctx.shadowBlur = 0;
101 ctx.fillStyle = "rgba(10,10,30,.85)"; ctx.beginPath(); ctx.roundRect(-16, player ? -22 : -8, 32, 26, 6); ctx.fill();
102 ctx.fillStyle = player ? "#fffbe0" : "#ff2d55";
103 if (player) { ctx.fillRect(-18, -40, 10, 5); ctx.fillRect(8, -40, 10, 5); }
104 else { ctx.fillRect(-18, 35, 10, 5); ctx.fillRect(8, 35, 10, 5); }
105 ctx.restore();
106 }
107
108 function draw() {
109 ctx.fillStyle = "#07060f"; ctx.fillRect(0, 0, W, H);
110 // city lights at the roadside
111 for (let i = 0; i < 12; i++) {
112 const y = ((i * 90 + roadOff * 1.2) % (H + 90)) - 45;
113 ctx.fillStyle = i % 2 ? "#2d1b69" : "#1b2a69";
114 ctx.fillRect(4, y, 40, 70); ctx.fillRect(W - 44, y + 30, 40, 70);
115 ctx.fillStyle = "#ffd23f55"; ctx.fillRect(12, y + 10, 6, 6); ctx.fillRect(W - 30, y + 44, 6, 6);
116 }
117 ctx.fillStyle = "#15131f"; ctx.fillRect(ROAD_X, 0, ROAD_W, H);
118 ctx.strokeStyle = "#ff3df0"; ctx.lineWidth = 4; ctx.shadowColor = "#ff3df0"; ctx.shadowBlur = 14;
119 ctx.beginPath(); ctx.moveTo(ROAD_X, 0); ctx.lineTo(ROAD_X, H); ctx.moveTo(ROAD_X + ROAD_W, 0); ctx.lineTo(ROAD_X + ROAD_W, H); ctx.stroke();
120 ctx.shadowBlur = 0;
121 ctx.fillStyle = "rgba(255,255,255,.35)";
122 for (let l = 1; l < LANES; l++) for (let y = -80 + roadOff; y < H; y += 80) ctx.fillRect(ROAD_X + l * LANE_W - 2, y, 4, 40);
123
124 for (const o of orbs) {
125 ctx.shadowColor = "#2ef2ff"; ctx.shadowBlur = 20; ctx.fillStyle = "#2ef2ff";
126 ctx.beginPath(); ctx.arc(o.x, o.y, 10 + Math.sin(t * 8) * 2, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0;
127 }
128 for (const o of traffic) drawCar(o.x, o.y, o.color);
129 if (car) drawCar(car.x, car.y, "#2ef2ff", car.tilt, true);
130 ctx.font = "900 18px system-ui"; ctx.textAlign = "center";
131 for (const p of popups) { ctx.globalAlpha = p.life / 0.7; ctx.fillStyle = "#ffd23f"; ctx.fillText(p.text, p.x, p.y); }
132 ctx.globalAlpha = 1;
133
134 ctx.fillStyle = "rgba(7,6,15,.7)"; ctx.fillRect(0, 0, W, 54);
135 ctx.fillStyle = "#fff"; ctx.textBaseline = "middle"; ctx.font = "900 22px system-ui";
136 ctx.textAlign = "left"; ctx.fillText(state === "title" ? "0" : String(score()), 16, 28);
137 ctx.textAlign = "center"; ctx.fillStyle = "#2ef2ff"; ctx.fillText(`${Math.round(speed / 6)} km/h`, W / 2, 28);
138 ctx.textAlign = "right"; ctx.fillStyle = "#c9c3ff"; ctx.font = "700 16px system-ui"; ctx.fillText(`Best ${best()}`, W - 16, 28);
139
140 if (state !== "play") {
141 ctx.fillStyle = "rgba(7,6,15,.72)"; ctx.fillRect(0, 0, W, H);
142 ctx.textAlign = "center"; ctx.fillStyle = "#fff"; ctx.font = "900 54px system-ui";
143 ctx.fillText(state === "over" ? "CRASHED" : "NEON DRIFT", W / 2, H / 2 - 60);
144 ctx.font = "600 18px system-ui"; ctx.fillStyle = "#c9c3ff";
145 if (state === "over") ctx.fillText(`Score ${score()} · Best ${best()}`, W / 2, H / 2);
146 else { ctx.fillText("Dodge traffic. Near misses and blue orbs score bonus.", W / 2, H / 2); }
147 ctx.fillText("← → / A D, or drag to steer", W / 2, H / 2 + 34);
148 ctx.fillStyle = "#fff"; ctx.fillText("Tap or press Space to " + (state === "over" ? "race again" : "start"), W / 2, H / 2 + 80);
149 }
150 }
151
152 let last = performance.now();
153 function loop(now) { const dt = Math.min(0.033, (now - last) / 1000); last = now; if (!paused) update(dt); draw(); requestAnimationFrame(loop); }
154 requestAnimationFrame(loop);
155})();
156