Pixel Moto Stunts
by MysticKoala951726 lines69.8 KB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Pixel Moto Stunts</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
background: #0b0b14;
overflow: hidden;
font-family: "Courier New", Courier, monospace;
color: #fff;
user-select: none;
touch-action: none;
}
#wrap {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
#box {
position: relative;
width: 960px;
height: 540px;
box-shadow: 0 0 0 4px #1d1d2e, 0 0 60px rgba(0, 0, 0, .9);
border-radius: 4px;
}
#game {
width: 100%;
height: 100%;
display: block;
image-rendering: pixelated;
image-rendering: crisp-edges;
background: #000;
border-radius: 4px;
position: relative;
z-index: 1;
}
/* Touch overlay buttons */
#touch-controls {
position: absolute;
inset: 0;
z-index: 5;
pointer-events: none;
display: none;
/* hidden by default, shown when touch mode is on */
}
#touch-controls.active {
display: block;
pointer-events: none;
}
#touch-controls .btn {
position: absolute;
pointer-events: auto;
background: rgba(255, 255, 255, 0.15);
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: bold;
color: #fff;
text-shadow: 0 0 8px rgba(0, 0, 0, 0.8);
backdrop-filter: blur(3px);
transition: 0.08s ease;
touch-action: none;
user-select: none;
}
#touch-controls .btn:active,
#touch-controls .btn.pressed {
background: rgba(255, 215, 0, 0.35);
border-color: #ffd23f;
transform: scale(0.92);
}
#touch-controls .btn.gas {
bottom: 60px;
right: 20px;
width: 70px;
height: 70px;
font-size: 22px;
background: rgba(46, 204, 113, 0.25);
border-color: rgba(46, 204, 113, 0.5);
}
#touch-controls .btn.brake {
bottom: 60px;
right: 100px;
width: 60px;
height: 60px;
font-size: 18px;
background: rgba(231, 76, 60, 0.25);
border-color: rgba(231, 76, 60, 0.5);
}
#touch-controls .btn.lean-left {
bottom: 130px;
left: 20px;
width: 56px;
height: 56px;
font-size: 16px;
background: rgba(52, 152, 219, 0.25);
border-color: rgba(52, 152, 219, 0.5);
}
#touch-controls .btn.lean-right {
bottom: 60px;
left: 20px;
width: 56px;
height: 56px;
font-size: 16px;
background: rgba(52, 152, 219, 0.25);
border-color: rgba(52, 152, 219, 0.5);
}
#touch-controls .btn.hop {
bottom: 130px;
left: 90px;
width: 52px;
height: 52px;
font-size: 14px;
background: rgba(241, 196, 15, 0.25);
border-color: rgba(241, 196, 15, 0.5);
}
#touch-controls .btn-label {
position: absolute;
bottom: 2px;
font-size: 9px;
opacity: 0.7;
letter-spacing: 0.5px;
}
/* Menu adjustments */
.screen {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(6, 6, 18, .88);
text-align: center;
padding: 4%;
box-sizing: border-box;
border-radius: 4px;
transition: opacity 0.4s ease;
z-index: 10;
}
.screen.hidden {
display: none !important;
}
.screen.fade-out {
opacity: 0;
pointer-events: none;
}
h1 {
font-size: clamp(32px, 8vmin, 72px);
margin: 0 0 0.2em;
letter-spacing: 0.06em;
line-height: 1.05;
color: #ffd23f;
text-shadow: 3px 3px 0 #b3410e, 6px 6px 0 #000;
}
h1.win {
color: #7CFC6B;
text-shadow: 3px 3px 0 #1d6b2a, 6px 6px 0 #000;
}
h1.over {
color: #ff4d4d;
text-shadow: 3px 3px 0 #7a0e0e, 6px 6px 0 #000;
}
.sub {
font-size: clamp(13px, 2.6vmin, 22px);
margin: 0 0 1.2em;
color: #cfe8ff;
text-shadow: 2px 2px 0 #000;
line-height: 1.4;
}
.blink {
animation: bl 1s steps(2, start) infinite;
font-size: clamp(12px, 2.6vmin, 22px);
color: #fff;
text-shadow: 2px 2px 0 #000;
margin: 0.6em 0 0;
}
@keyframes bl {
to {
visibility: hidden;
}
}
.menu-buttons {
display: flex;
flex-direction: column;
gap: 14px;
margin: 10px 0 6px;
width: min(280px, 75%);
}
.menu-btn {
background: #1d1d2e;
border: 3px solid #ffd23f;
color: #ffd23f;
font-family: "Courier New", Courier, monospace;
font-size: clamp(16px, 3vmin, 26px);
font-weight: bold;
padding: 12px 10px;
cursor: pointer;
border-radius: 4px;
text-shadow: 2px 2px 0 #000;
box-shadow: 4px 4px 0 #000;
transition: all 0.1s ease;
letter-spacing: 0.04em;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(2px);
}
.menu-btn:hover {
background: #ffd23f;
color: #0b0b14;
transform: scale(1.02);
box-shadow: 6px 6px 0 #000;
}
.menu-btn:active {
transform: scale(0.96);
box-shadow: 2px 2px 0 #000;
}
.menu-btn.secondary {
border-color: #7a8a9a;
color: #cfe8ff;
}
.menu-btn.secondary:hover {
background: #7a8a9a;
color: #0b0b14;
}
.menu-btn.toggle-on {
border-color: #5ec44a;
color: #7CFC6B;
background: rgba(94, 196, 74, 0.15);
}
.menu-btn.toggle-off {
border-color: #7a8a9a;
color: #aaa;
}
.stats {
font-size: clamp(13px, 2.4vmin, 20px);
line-height: 1.7;
text-shadow: 2px 2px 0 #000;
}
.stats .big {
font-size: clamp(24px, 5vmin, 44px);
color: #ffd23f;
}
.stunts {
font-size: clamp(11px, 2vmin, 16px);
color: #9fe3ff;
margin: 0.4em 0 1em;
line-height: 1.6;
max-width: 90%;
}
.stunts-list {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 4px 14px;
}
#controls-overlay {
position: absolute;
inset: 0;
background: rgba(6, 6, 18, 0.92);
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 5%;
border-radius: 4px;
z-index: 20;
backdrop-filter: blur(4px);
}
#controls-overlay.show {
display: flex;
}
#controls-overlay h2 {
font-size: clamp(22px, 5vmin, 44px);
color: #ffd23f;
text-shadow: 2px 2px 0 #000;
margin-bottom: 16px;
}
.ctrl-grid {
display: grid;
grid-template-columns: auto auto;
gap: 6px 28px;
font-size: clamp(13px, 2.4vmin, 20px);
text-align: left;
background: rgba(0, 0, 0, 0.55);
padding: 18px 28px;
border: 3px solid #ffd23f;
box-shadow: 4px 4px 0 #000;
border-radius: 4px;
margin-bottom: 18px;
color: #cfe8ff;
}
.ctrl-grid .key {
background: #1d1d2e;
padding: 0 8px;
border-radius: 3px;
border: 1px solid #444;
font-weight: bold;
color: #fff;
display: inline-block;
min-width: 28px;
text-align: center;
}
.close-ctrl {
background: transparent;
border: 2px solid #7a8a9a;
color: #cfe8ff;
font-family: "Courier New", Courier, monospace;
font-size: clamp(14px, 2.4vmin, 20px);
padding: 8px 30px;
cursor: pointer;
border-radius: 4px;
transition: 0.15s;
margin-top: 6px;
}
.close-ctrl:hover {
background: #7a8a9a;
color: #0b0b14;
}
@media (max-width: 600px) {
.ctrl-grid {
grid-template-columns: 1fr 1fr;
gap: 4px 12px;
padding: 12px 14px;
font-size: clamp(11px, 2.2vmin, 16px);
}
.menu-buttons {
width: min(220px, 80%);
}
.menu-btn {
padding: 10px 8px;
font-size: clamp(14px, 2.8vmin, 20px);
}
#touch-controls .btn.gas {
width: 56px;
height: 56px;
right: 10px;
bottom: 50px;
font-size: 18px;
}
#touch-controls .btn.brake {
width: 48px;
height: 48px;
right: 76px;
bottom: 50px;
font-size: 14px;
}
#touch-controls .btn.lean-left {
width: 44px;
height: 44px;
left: 10px;
bottom: 110px;
font-size: 13px;
}
#touch-controls .btn.lean-right {
width: 44px;
height: 44px;
left: 10px;
bottom: 50px;
font-size: 13px;
}
#touch-controls .btn.hop {
width: 42px;
height: 42px;
left: 66px;
bottom: 110px;
font-size: 12px;
}
}
@media (max-width: 400px) {
.ctrl-grid {
grid-template-columns: 1fr;
gap: 2px 0;
padding: 10px 12px;
}
.ctrl-grid div {
display: flex;
gap: 6px;
}
#touch-controls .btn.gas {
width: 48px;
height: 48px;
right: 6px;
bottom: 40px;
font-size: 16px;
}
#touch-controls .btn.brake {
width: 40px;
height: 40px;
right: 62px;
bottom: 40px;
font-size: 12px;
}
#touch-controls .btn.lean-left {
width: 38px;
height: 38px;
left: 6px;
bottom: 90px;
font-size: 11px;
}
#touch-controls .btn.lean-right {
width: 38px;
height: 38px;
left: 6px;
bottom: 40px;
font-size: 11px;
}
#touch-controls .btn.hop {
width: 36px;
height: 36px;
left: 52px;
bottom: 90px;
font-size: 10px;
}
}
</style>
</head>
<body>
<div id="wrap">
<div id="box">
<canvas id="game"></canvas>
<!-- Touch controls overlay -->
<div id="touch-controls">
<div class="btn gas" id="t-gas">▲<span class="btn-label">GAS</span></div>
<div class="btn brake" id="t-brake">▼<span class="btn-label">BRAKE</span></div>
<div class="btn lean-left" id="t-leanL">◀<span class="btn-label">LEAN</span></div>
<div class="btn lean-right" id="t-leanR">▶<span class="btn-label">LEAN</span></div>
<div class="btn hop" id="t-hop">⤊<span class="btn-label">HOP</span></div>
</div>
<!-- ====== MAIN MENU ====== -->
<div id="main-menu" class="screen">
<h1>PIXEL MOTO<br>STUNTS</h1>
<p class="sub">Race the clock. Land sick stunts. Reach the finish!</p>
<div class="menu-buttons">
<button class="menu-btn" id="btn-play">▶ RIDE</button>
<button class="menu-btn secondary" id="btn-controls">⌨ CONTROLS</button>
<button class="menu-btn toggle-off" id="btn-touch">📱 TOUCH: OFF</button>
</div>
<p style="font-size:clamp(10px,1.8vmin,14px);color:#6a7a8a;margin-top:8px;">v2.1 — with touch controls</p>
</div>
<!-- ====== CONTROLS OVERLAY ====== -->
<div id="controls-overlay">
<h2>⌨ CONTROLS</h2>
<div class="ctrl-grid">
<div><span class="key">↑</span> / <span class="key">W</span></div><div>Gas / accelerate</div>
<div><span class="key">↓</span> / <span class="key">S</span></div><div>Brake</div>
<div><span class="key">←</span> / <span class="key">A</span></div><div>Lean back · Wheelie · Backflip</div>
<div><span class="key">→</span> / <span class="key">D</span></div><div>Lean forward · Frontflip</div>
<div><span class="key">␣</span></div><div>Hop (jump)</div>
<div><span class="key">R</span></div><div>Restart run</div>
</div>
<p style="color:#7a8a9a;font-size:clamp(10px,1.6vmin,14px);margin-bottom:10px;">💡 Touch controls can be enabled in the main menu.</p>
<button class="close-ctrl" id="close-ctrl">✕ CLOSE</button>
</div>
<!-- ====== END SCREEN ====== -->
<div id="end" class="screen hidden"></div>
</div>
</div>
<script>
(function() {
'use strict';
/* ============================================================
SETUP
============================================================ */
const W = 480,
H = 270;
const box = document.getElementById('box'),
cv = document.getElementById('game'),
ctx = cv.getContext('2d');
const mainMenu = document.getElementById('main-menu');
const endEl = document.getElementById('end');
const ctrlOverlay = document.getElementById('controls-overlay');
const btnPlay = document.getElementById('btn-play');
const btnControls = document.getElementById('btn-controls');
const closeCtrl = document.getElementById('close-ctrl');
const btnTouch = document.getElementById('btn-touch');
const touchControls = document.getElementById('touch-controls');
cv.width = W;
cv.height = H;
ctx.imageSmoothingEnabled = false;
let touchMode = false; // default: keyboard
function fit() {
const s = Math.min(innerWidth / W, innerHeight / H);
box.style.width = Math.floor(W * s) + 'px';
box.style.height = Math.floor(H * s) + 'px';
}
addEventListener('resize', fit);
fit();
// Toggle touch mode
function setTouchMode(on) {
touchMode = on;
if (touchMode) {
touchControls.classList.add('active');
btnTouch.textContent = '📱 TOUCH: ON';
btnTouch.className = 'menu-btn toggle-on';
} else {
touchControls.classList.remove('active');
btnTouch.textContent = '📱 TOUCH: OFF';
btnTouch.className = 'menu-btn toggle-off';
}
}
btnTouch.addEventListener('click', () => {
setTouchMode(!touchMode);
});
// Initially off
setTouchMode(false);
/* ============================================================
CONSTANTS — SPEED INCREASED
============================================================ */
const TRACK = 8000,
FINISH_X = 7600,
BASE = 190,
TIME_LIMIT = 75,
CP_SPACING = 1000;
const G = 420,
L = 26,
R = 6,
MAXSPD = 400,
ACC = 500,
LEAN_AIR = 200,
LEAN_GND = 280,
HOP_V = 2.2,
PIT_DEPTH = 100,
PENALTY = 2;
const GLEN = TRACK + 800;
const PAL = ['#ffffff', '#fff3a0', '#ffd23f', '#ff8c1a', '#ff3d00', '#b71c1c', '#5a5a5a', '#8a8a8a'];
function rng(seed) {
return function() {
seed |= 0;
seed = seed + 0x6D2B79F5 | 0;
let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
/* ============================================================
TERRAIN
============================================================ */
const ground = new Float32Array(GLEN),
pitMask = new Uint8Array(GLEN);
const obstacles = [],
checkpoints = [],
clouds = [];
function gY(x) {
if (x < 0) x = 0;
if (x > GLEN - 2) x = GLEN - 2;
const i = x | 0,
f = x - i;
return ground[i] + (ground[i + 1] - ground[i]) * f;
}
function slopeAt(x) {
return (gY(x + 4) - gY(x - 4)) / 8;
}
function generate() {
const rand = rng(20240601);
let x = 0,
h = 0;
const set = (i, v) => { if (i >= 0 && i < GLEN) ground[i] = BASE - v; };
const addObstacle = (ox) => {
const r = rand();
let o;
if (r < 0.35) o = { type: 'crate', w: 13, h: 13 };
else if (r < 0.65) o = { type: 'barrel', w: 10, h: 14 };
else o = { type: 'rock', w: 18, h: 9 };
o.x = Math.round(ox);
o.y = BASE - h - o.h;
obstacles.push(o);
const kl = 30,
kx = o.x - 50;
for (let i = 0; i < kl; i++) {
const t = i / kl;
set(kx + i, h + 6 * t * (1 - t) + 4);
}
};
const flat = (len, obs) => {
len = Math.round(len);
for (let i = 0; i < len; i++) set(x + i, h);
if (obs && len >= 140 && x > 800 && rand() < 0.6) {
const ox = x + 80 + rand() * (len - 120);
if (ox > x + 40 && ox < x + len - 40) addObstacle(ox);
}
x += len;
};
const gentleHill = (len, amp) => {
len |= 0;
const peak = amp * (0.6 + rand() * 0.4);
for (let i = 0; i < len; i++) {
const t = i / len;
const val = peak * (t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t));
set(x + i, h + val);
}
x += len;
};
const slope = (len, dh) => {
len |= 0;
for (let i = 0; i < len; i++) { set(x + i, h + dh * i / len); }
x += len;
h += dh;
};
const kicker = (len, rise) => {
len |= 0;
for (let i = 0; i < len; i++) {
const t = i / len;
set(x + i, h + rise * t * (0.5 + 0.5 * t));
}
x += len;
};
const plateau = (len, rise) => {
slope(50, rise);
flat(len, false);
h -= rise;
};
flat(220, false);
while (x < FINISH_X - 500) {
const nextCp = Math.ceil((x + 1) / CP_SPACING) * CP_SPACING;
if (nextCp < FINISH_X && nextCp - x < 500) {
const gap = nextCp + 60 - x;
if (gap > 300) { gentleHill(120 + rand() * 80, 12 + rand() * 20);
flat(gap - 80, true); } else flat(gap, false);
continue;
}
const d = x / FINISH_X;
const r = rand();
if (r < 0.25) {
flat(100 + rand() * 140, true);
} else if (r < 0.45) {
gentleHill(100 + rand() * 120, 12 + rand() * 22);
} else if (r < 0.60) {
kicker(50 + rand() * 40, 22 + rand() * 20);
flat(60 + rand() * 80, false);
} else if (r < 0.72) {
const dh = (rand() < 0.5 ? -1 : 1) * (10 + rand() * 20);
if (h + dh < -15 || h + dh > 50) slope(80, -dh * 0.4);
else slope(70 + rand() * 80, dh);
} else if (r < 0.84) {
for (let k = 0; k < 2 + (rand() * 2 | 0); k++) {
gentleHill(20 + rand() * 30, 5 + rand() * 10);
}
flat(40, false);
} else if (r < 0.94) {
plateau(50 + rand() * 60, 15 + rand() * 20);
} else {
flat(90 + rand() * 100, true);
}
if (x > 1200 && x < FINISH_X - 400 && rand() < 0.08 + d * 0.08) {
const pw = 20 + rand() * 20;
const depth = 30 + rand() * 40;
for (let i = 0; i < pw; i++) {
const idx = x + i;
if (idx < GLEN) {
ground[idx] = BASE - h + depth;
pitMask[idx] = 1;
}
}
x += pw;
}
}
flat(GLEN - x, false);
for (let c = CP_SPACING; c < FINISH_X; c += CP_SPACING) {
checkpoints.push({ x: c, passed: false });
}
for (let i = 0; i < 14; i++) {
clouds.push({ x: rand() * 2400, y: 14 + rand() * 60, s: 0.8 + rand() * 1.4 });
}
}
generate();
/* ============================================================
INPUT — KEYBOARD + TOUCH BUTTONS
============================================================ */
const keys = {};
const K = {
gas: () => keys.ArrowUp || keys.KeyW,
brake: () => keys.ArrowDown || keys.KeyS,
back: () => keys.ArrowLeft || keys.KeyA,
fwd: () => keys.ArrowRight || keys.KeyD
};
let hopBuf = 0,
hintSeen = false;
// Keyboard events
addEventListener('keydown', e => {
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space'].includes(e.code)) e.preventDefault();
initAudio();
if (e.repeat) return;
keys[e.code] = true;
if (e.code === 'Space' && state === 'play') hopBuf = 0.15;
if (e.code === 'KeyR') newGame();
if (state === 'play') hintSeen = true;
});
addEventListener('keyup', e => { keys[e.code] = false; });
// Touch buttons (only if touchMode is active)
const touchState = { gas: false, brake: false, leanL: false, leanR: false };
function setupTouchButton(id, keyOn, keyOff) {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener('touchstart', (e) => {
e.preventDefault();
if (state !== 'play') return;
initAudio();
touchState[keyOn] = true;
if (keyOff) touchState[keyOff] = false;
el.classList.add('pressed');
if (id === 't-hop') {
hopBuf = 0.15;
sfxHop();
}
}, { passive: false });
el.addEventListener('touchend', (e) => {
e.preventDefault();
touchState[keyOn] = false;
el.classList.remove('pressed');
}, { passive: false });
el.addEventListener('touchcancel', (e) => {
touchState[keyOn] = false;
el.classList.remove('pressed');
}, { passive: false });
// Also support mouse for debugging
el.addEventListener('mousedown', (e) => {
e.preventDefault();
if (state !== 'play') return;
initAudio();
touchState[keyOn] = true;
if (keyOff) touchState[keyOff] = false;
el.classList.add('pressed');
if (id === 't-hop') {
hopBuf = 0.15;
sfxHop();
}
});
el.addEventListener('mouseup', (e) => {
e.preventDefault();
touchState[keyOn] = false;
el.classList.remove('pressed');
});
el.addEventListener('mouseleave', () => {
touchState[keyOn] = false;
el.classList.remove('pressed');
});
}
setupTouchButton('t-gas', 'gas');
setupTouchButton('t-brake', 'brake');
setupTouchButton('t-leanL', 'leanL');
setupTouchButton('t-leanR', 'leanR');
setupTouchButton('t-hop', 'hop');
// Override K functions to include touch state when touchMode is on
const originalGas = K.gas,
originalBrake = K.brake,
originalBack = K.back,
originalFwd = K.fwd;
K.gas = () => (touchMode ? touchState.gas : false) || originalGas();
K.brake = () => (touchMode ? touchState.brake : false) || originalBrake();
K.back = () => (touchMode ? touchState.leanL : false) || originalBack();
K.fwd = () => (touchMode ? touchState.leanR : false) || originalFwd();
// Menu buttons
btnPlay.addEventListener('click', () => { initAudio();
newGame(); });
btnControls.addEventListener('click', () => { ctrlOverlay.classList.add('show'); });
closeCtrl.addEventListener('click', () => { ctrlOverlay.classList.remove('show'); });
ctrlOverlay.addEventListener('click', (e) => {
if (e.target === ctrlOverlay) ctrlOverlay.classList.remove('show');
});
endEl.addEventListener('click', () => { initAudio();
newGame(); });
/* ============================================================
AUDIO
============================================================ */
let AC = null,
eng = null,
engGain = null;
function initAudio() {
if (AC) return;
try {
AC = new(window.AudioContext || window.webkitAudioContext)();
eng = AC.createOscillator();
eng.type = 'sawtooth';
engGain = AC.createGain();
engGain.gain.value = 0;
const lp = AC.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 700;
eng.connect(lp).connect(engGain).connect(AC.destination);
eng.start();
} catch (_) { AC = null; }
}
function updEngine() {
if (!AC) return;
if (AC.state === 'suspended') AC.resume();
const on = state === 'play';
const f = 38 + Math.abs(bike ? bike.speed : 0) * 0.5 + (K.gas() && on ? 35 : 0);
eng.frequency.setTargetAtTime(f, AC.currentTime, 0.05);
engGain.gain.setTargetAtTime(on ? 0.03 : 0, AC.currentTime, 0.08);
}
function beep(freq, dur, type, vol, when) {
if (!AC) return;
type = type || 'square';
vol = vol || 0.07;
when = when || 0;
const o = AC.createOscillator(),
g = AC.createGain();
o.type = type;
o.frequency.value = freq;
g.gain.setValueAtTime(vol, AC.currentTime + when);
g.gain.exponentialRampToValueAtTime(0.001, AC.currentTime + when + dur);
o.connect(g).connect(AC.destination);
o.start(AC.currentTime + when);
o.stop(AC.currentTime + when + dur + 0.03);
}
function sfxExplode() {
if (!AC) return;
const n = (AC.sampleRate * 0.6) | 0,
buf = AC.createBuffer(1, n, AC.sampleRate),
d = buf.getChannelData(0);
for (let i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / n, 2.5);
const s = AC.createBufferSource();
s.buffer = buf;
const g = AC.createGain();
g.gain.value = 0.5;
const lp = AC.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 900;
s.connect(lp).connect(g).connect(AC.destination);
s.start();
beep(70, 0.4, 'sawtooth', 0.12);
}
function sfxStunt() { beep(660, 0.07);
beep(880, 0.07, 'square', 0.06, 0.07);
beep(1320, 0.15, 'square', 0.06, 0.14); }
function sfxCheckpoint() { beep(523, 0.08);
beep(784, 0.18, 'square', 0.06, 0.08); }
function sfxWin() { [523, 659, 784, 1047, 1319].forEach((f, i) => beep(f, 0.2, 'square', 0.07, i * 0.10)); }
function sfxOver() { [400, 300, 220, 150].forEach((f, i) => beep(f, 0.25, 'sawtooth', 0.07, i * 0.18)); }
function sfxHop() { beep(300, 0.05, 'square', 0.04); }
/* ============================================================
GAME STATE
============================================================ */
let state = 'menu',
timeLeft = TIME_LIMIT,
score = 0,
stunts = {},
stuntCount = 0,
crashes = 0,
lastCp = 60;
let camX = 0,
camY = 30,
shake = 0,
flash = 0,
crashT = 0,
elapsed = 0,
frameCount = 0;
let particles = [],
rings = [],
popups = [];
let bike = null;
function mkWheel(x) {
const y = gY(x) - R;
return { x, y, px: x, py: y };
}
function spawn(x) {
bike = {
p: [mkWheel(x - L / 2), mkWheel(x + L / 2)],
c: [true, true],
spin: 0,
air: 0,
rot: 0,
prevAng: 0,
wheelie: 0,
wScored: 0,
safe: 0.6,
speed: 0,
smokeT: 0
};
const p1 = bike.p[0],
p2 = bike.p[1];
bike.prevAng = Math.atan2(p2.y - p1.y, p2.x - p1.x);
}
function newGame() {
state = 'play';
timeLeft = TIME_LIMIT;
score = 0;
stunts = {};
stuntCount = 0;
crashes = 0;
lastCp = 60;
elapsed = 0;
hintSeen = false;
particles = [];
rings = [];
popups = [];
shake = 0;
flash = 0;
checkpoints.forEach(c => c.passed = false);
spawn(60);
camX = 0;
camY = 30;
mainMenu.classList.add('hidden');
endEl.classList.add('hidden');
ctrlOverlay.classList.remove('show');
// reset touch states
touchState.gas = false;
touchState.brake = false;
touchState.leanL = false;
touchState.leanR = false;
document.querySelectorAll('#touch-controls .btn').forEach(el => el.classList.remove('pressed'));
}
function addPopup(text, color, big) {
popups.push({ text, color: color || '#ffd23f', t: 0, life: 1.6, big: !!big, off: popups.length * 12 });
}
function addStunt(name, pts) {
score += pts;
stunts[name] = (stunts[name] || 0) + 1;
stuntCount++;
addPopup(name + ' +' + pts, '#ffd23f', true);
sfxStunt();
}
spawn(60);
/* ============================================================
PHYSICS
============================================================ */
function step(h) {
const b = bike,
p1 = b.p[0],
p2 = b.p[1];
b.safe -= h;
hopBuf -= h;
for (const p of b.p) {
const vx = p.x - p.px,
vy = p.y - p.py;
p.px = p.x;
p.py = p.y;
p.x += vx * 0.999;
p.y += vy * 0.999 + G * h * h;
}
let dx = p2.x - p1.x,
dy = p2.y - p1.y,
dl = Math.hypot(dx, dy) || 1;
const ux = dx / dl,
uy = dy / dl;
const grounded = b.c[0] || b.c[1];
const spd = (((p1.x - p1.px) + (p2.x - p2.px)) / 2 * ux + ((p1.y - p1.py) + (p2.y - p2.py)) / 2 * uy) / h;
b.speed = spd;
if (K.gas() && b.c[0] && spd < MAXSPD) {
const s = slopeAt(p1.x);
let tx = 1,
ty = s;
const tl = Math.hypot(tx, ty);
tx /= tl;
ty /= tl;
const a = ACC * h * h;
p1.x += tx * a;
p1.y += ty * a;
p2.x += tx * a;
p2.y += ty * a;
}
if (K.brake()) {
for (let i = 0; i < 2; i++) {
if (b.c[i]) {
const p = b.p[i];
p.px += (p.x - p.px) * 0.04;
p.py += (p.y - p.py) * 0.04;
}
}
}
let lean = 0;
if (K.back()) lean -= 1;
if (K.fwd()) lean += 1;
if (lean) {
const a = (grounded ? LEAN_GND : LEAN_AIR) * h * h * (-lean);
p2.x += uy * a;
p2.y += -ux * a;
p1.x -= uy * a;
p1.y -= -ux * a;
}
if (hopBuf > 0 && grounded) {
p1.py += HOP_V;
p2.py += HOP_V;
hopBuf = 0;
sfxHop();
}
for (let i = 0; i < 2; i++) {
if (b.c[i]) {
const p = b.p[i];
p.px += (p.x - p.px) * 0.002;
}
}
for (let k = 0; k < 3; k++) {
dx = p2.x - p1.x;
dy = p2.y - p1.y;
const d = Math.hypot(dx, dy) || 1,
diff = (d - L) / d * 0.5;
p1.x += dx * diff;
p1.y += dy * diff;
p2.x -= dx * diff;
p2.y -= dy * diff;
}
b.c = [false, false];
let crash = null;
for (let i = 0; i < 2; i++) {
const p = b.p[i];
const gy = gY(p.x);
if (p.y + R > gy) {
const s = slopeAt(p.x);
let nx = s,
ny = -1;
const nl = Math.hypot(nx, ny);
nx /= nl;
ny /= nl;
const pen = (p.y + R - gy) * (-ny);
if (pen > 0) {
if (pen > R * 2.5) crash = 'wall';
p.x += nx * pen;
p.y += ny * pen;
b.c[i] = true;
p.px += (p.x - p.px) * 0.02;
if (i === 1) {
const tx = -ny,
ty = nx;
const dot = ux * tx + uy * ty;
if (dot < 0.2) crash = 'angle';
}
if (ux < -0.15) crash = 'flip';
}
}
const xi = p.x | 0;
if (xi >= 0 && xi < GLEN && pitMask[xi] && p.y > gY(xi) - PIT_DEPTH + 30) crash = 'pit';
for (const o of obstacles) {
if (p.x + R < o.x || p.x - R > o.x + o.w || p.y + R < o.y || p.y - R > o.y + o.h) continue;
const cx = Math.max(o.x, Math.min(p.x, o.x + o.w));
const cy = Math.max(o.y, Math.min(p.y, o.y + o.h));
const ddx = p.x - cx,
ddy = p.y - cy,
d2 = ddx * ddx + ddy * ddy;
if (d2 >= R * R) continue;
let nx, ny, pen;
if (d2 < 1e-6) { nx = 0;
ny = -1;
pen = p.y + R - o.y; } else { const d = Math.sqrt(d2);
nx = ddx / d;
ny = ddy / d;
pen = R - d; }
const vn = ((p.x - p.px) * nx + (p.y - p.py) * ny) / h;
if (Math.abs(nx) > 0.6 && vn < -40) crash = 'obstacle';
p.x += nx * pen;
p.y += ny * pen;
if (ny < -0.4) b.c[i] = true;
}
}
const mx = (p1.x + p2.x) / 2,
my = (p1.y + p2.y) / 2;
const hx = mx - 2 * ux + 26 * uy,
hy = my - 2 * uy - 26 * ux;
if (hy + 3 > gY(hx)) crash = 'head';
for (const o of obstacles) {
if (hx > o.x - 3 && hx < o.x + o.w + 3 && hy > o.y - 3 && hy < o.y + o.h + 3) { crash = 'head'; break; }
}
if (crash && b.safe <= 0) doCrash();
}
function doCrash() {
const b = bike,
p1 = b.p[0],
p2 = b.p[1];
const mx = (p1.x + p2.x) / 2,
my = (p1.y + p2.y) / 2 - 8;
state = 'crash';
crashT = 1.2;
crashes++;
timeLeft = Math.max(0, timeLeft - PENALTY);
explode(mx, my, p1, p2);
addPopup('CRASH! -' + PENALTY + 's', '#ff4d4d', true);
sfxExplode();
}
function explode(x, y, p1, p2) {
for (let i = 0; i < 90; i++) {
const a = Math.random() * Math.PI * 2,
sp = 30 + Math.random() * 200;
particles.push({ k: 'px', x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 80, s: 1 + (Math.random() *
4 | 0), ci: (Math.random() * 3) | 0, t: 0, life: 0.4 + Math.random() * 0.9 });
}
for (let i = 0; i < 16; i++) {
particles.push({ k: 'smoke', x: x + (Math.random() - 0.5) * 12, y: y + (Math.random() - 0.5) * 12,
vx: (Math.random() - 0.5) * 35, vy: -25 - Math.random() * 35, s: 3 + Math.random() * 3, t: 0,
life: 1.0 + Math.random() * 0.8 });
}
particles.push({ k: 'wheel', x: p1.x, y: p1.y, vx: -80 - Math.random() * 50, vy: -180, spin: 0, t: 0,
life: 2.2 });
particles.push({ k: 'wheel', x: p2.x, y: p2.y, vx: 80 + Math.random() * 60, vy: -160, spin: 0, t: 0,
life: 2.2 });
rings.push({ x, y, t: 0 });
rings.push({ x, y, t: -0.08 });
flash = 0.10;
shake = 0.8;
}
function afterPhysics(dt) {
const b = bike,
p1 = b.p[0],
p2 = b.p[1];
timeLeft -= dt;
if (timeLeft <= 0) { timeLeft = 0;
gameOver(); return; }
const ux = (p2.x - p1.x) / L,
uy = (p2.y - p1.y) / L;
b.spin += b.speed * dt / R;
const ang = Math.atan2(uy, ux);
let dA = ang - b.prevAng;
if (dA > Math.PI) dA -= 2 * Math.PI;
if (dA < -Math.PI) dA += 2 * Math.PI;
b.prevAng = ang;
const grounded = b.c[0] || b.c[1];
if (!grounded) {
b.air += dt;
b.rot += dA;
b.wheelie = 0;
b.wScored = 0;
} else {
if (b.air > 0.2) {
const flips = Math.floor((Math.abs(b.rot) + 0.8) / (2 * Math.PI));
if (flips > 0) {
const name = b.rot < 0 ? 'BACKFLIP' : 'FRONTFLIP';
addStunt(flips > 1 ? flips + 'x ' + name : name, 400 * flips * flips);
} else if (b.air > 1.2) addStunt('HUGE AIR', 350);
else if (b.air > 0.6) addStunt('BIG AIR', 120);
}
b.air = 0;
b.rot = 0;
if (b.c[0] && !b.c[1] && ux > 0.3) {
b.wheelie += dt;
const s = Math.floor(b.wheelie);
if (s >= 1 && s > b.wScored) { b.wScored = s;
addStunt('WHEELIE ' + s + 's', 80 + s * 20); }
} else { b.wheelie = 0;
b.wScored = 0; }
}
if (K.gas() && grounded) {
b.smokeT += dt;
if (b.smokeT > 0.08) {
b.smokeT = 0;
const mx = (p1.x + p2.x) / 2,
my = (p1.y + p2.y) / 2;
const sx = mx - 14 * ux - 3 * (-uy),
sy = my - 14 * uy - 3 * ux;
particles.push({ k: 'smoke', x: sx, y: sy, vx: -35 + Math.random() * 8, vy: -12 - Math.random() * 8,
s: 1.3, t: 0, life: 0.5 });
}
}
const mx = (p1.x + p2.x) / 2;
for (const cp of checkpoints) {
if (!cp.passed && mx > cp.x) {
cp.passed = true;
lastCp = cp.x;
score += 50;
addPopup('CHECKPOINT!', '#7CFC6B', true);
sfxCheckpoint();
}
}
if (mx > FINISH_X) win();
}
function gameOver() {
state = 'over';
sfxOver();
const mx = (bike.p[0].x + bike.p[1].x) / 2;
endEl.innerHTML =
'<h1 class="over">TIME\'S UP!</h1><p class="sub">You made it ' + Math.round(Math.min(100, mx / FINISH_X *
100)) + '% of the way.</p>' +
'<div class="stats">SCORE <span class="big">' + score + '</span><br>STUNTS ' + stuntCount +
' · CRASHES ' + crashes + '</div>' + stuntList() +
'<p class="blink">PRESS ENTER OR TAP TO RETRY</p>';
endEl.classList.remove('hidden');
}
function win() {
state = 'win';
sfxWin();
const bonus = Math.round(timeLeft) * 10;
score += bonus;
endEl.innerHTML =
'<h1 class="win">🏁 YOU WIN!</h1><p class="sub">Crossed the finish with ' + timeLeft.toFixed(1) +
's to spare!</p>' +
'<div class="stats">FINAL SCORE <span class="big">' + score + '</span><br>TIME BONUS +' + bonus +
' · STUNTS ' + stuntCount + ' · CRASHES ' + crashes + '</div>' + stuntList() +
'<p class="blink">PRESS ENTER OR TAP TO RIDE AGAIN</p>';
endEl.classList.remove('hidden');
}
function stuntList() {
const k = Object.keys(stunts);
if (!k.length) return '<div class="stunts">No stunts performed… try some flips!</div>';
return '<div class="stunts"><span style="opacity:0.7;">STUNTS:</span><br><span class="stunts-list">' + k.map(
n => n + ' × ' + stunts[n]).join(' | ') + '</span></div>';
}
/* ============================================================
PARTICLES / CAMERA
============================================================ */
function updateParticles(dt) {
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.t += dt;
if (p.t > p.life) { particles.splice(i, 1); continue; }
if (p.k === 'px' || p.k === 'wheel') {
p.vy += G * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
const off = p.k === 'wheel' ? R : 0,
gy = gY(p.x);
if (p.y > gy - off) { p.y = gy - off;
p.vy *= -0.4;
p.vx *= 0.65; }
if (p.k === 'wheel') p.spin += p.vx * dt / R;
} else {
p.x += p.vx * dt;
p.y += p.vy * dt;
p.s += dt * 2.2;
}
}
for (let i = rings.length - 1; i >= 0; i--) { rings[i].t += dt; if (rings[i].t > 0.45) rings.splice(i, 1); }
}
function updatePopups(dt) {
for (let i = popups.length - 1; i >= 0; i--) { popups[i].t += dt; if (popups[i].t > popups[i].life) popups
.splice(i, 1); }
}
function updateCamera(dt) {
const p1 = bike.p[0],
p2 = bike.p[1];
const mx = (p1.x + p2.x) / 2,
my = (p1.y + p2.y) / 2;
const tx = Math.max(0, mx - 140),
ty = Math.max(-200, Math.min(60, my - 155));
const k = 1 - Math.pow(0.001, dt);
camX += (tx - camX) * Math.min(1, k * 1.6);
camY += (ty - camY) * k;
}
/* ============================================================
RENDERING
============================================================ */
function txt(s, x, y, c, font, align) {
ctx.font = font || 'bold 10px monospace';
ctx.textAlign = align || 'left';
ctx.textBaseline = 'top';
ctx.fillStyle = '#000';
ctx.fillText(s, x + 1, y + 1);
ctx.fillStyle = c || '#fff';
ctx.fillText(s, x, y);
}
function circ(x, y, r, c) {
ctx.fillStyle = c;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
}
function drawSky(cx, cy) {
const bands = ['#2b6fd6', '#3f86e6', '#5ea2f2', '#86bdf7', '#a9d4fb', '#c8e6fd'];
for (let i = 0; i < 6; i++) { ctx.fillStyle = bands[i];
ctx.fillRect(0, i * 45, W, 45); }
const sx = 390,
sy = 40 - cy * 0.04;
circ(sx, sy, 14, '#ffe9a8');
circ(sx, sy, 11, '#ffd23f');
ctx.fillStyle = '#f4f9ff';
for (const c of clouds) {
let x = ((c.x - cx * 0.2) % 2400 + 2400) % 2400 - 100;
const y = c.y - cy * 0.07,
s = c.s;
if (x > W + 40) continue;
ctx.fillRect(x | 0, y | 0, (18 * s) | 0, (6 * s) | 0);
ctx.fillRect((x + 4 * s) | 0, (y - 4 * s) | 0, (10 * s) | 0, (5 * s) | 0);
ctx.fillRect((x + 9 * s) | 0, (y - 7 * s) | 0, (6 * s) | 0, (4 * s) | 0);
}
ctx.fillStyle = '#6d7fb3';
for (let sx2 = 0; sx2 < W; sx2 += 2) {
const wx = sx2 + cx * 0.25;
const y = 120 - (24 * Math.sin(wx * 0.011) + 16 * Math.sin(wx * 0.027 + 2) + 10 * Math.abs(Math.sin(wx *
0.045))) - cy * 0.14;
ctx.fillRect(sx2, y | 0, 2, H);
}
ctx.fillStyle = '#8f9fcc';
for (let sx2 = 0; sx2 < W; sx2 += 2) {
const wx = sx2 + cx * 0.25;
const y = 120 - (24 * Math.sin(wx * 0.011) + 16 * Math.sin(wx * 0.027 + 2) + 10 * Math.abs(Math.sin(wx *
0.045))) - cy * 0.14;
if (y < 76) ctx.fillRect(sx2, y | 0, 2, 3);
}
ctx.fillStyle = '#3f8f4f';
for (let sx2 = 0; sx2 < W; sx2 += 2) {
const wx = sx2 + cx * 0.5;
const y = 174 - (16 * Math.sin(wx * 0.017) + 10 * Math.sin(wx * 0.041 + 1)) - cy * 0.38;
ctx.fillRect(sx2, y | 0, 2, H);
}
ctx.fillStyle = '#2f7a3f';
for (let sx2 = 0; sx2 < W; sx2 += 2) {
const wx = sx2 + cx * 0.5;
const y = 174 - (16 * Math.sin(wx * 0.017) + 10 * Math.sin(wx * 0.041 + 1)) - cy * 0.38;
ctx.fillRect(sx2, (y + 8) | 0, 2, H);
}
}
function drawGround(cx, cy) {
for (let sx = 0; sx < W; sx++) {
const wx = cx + sx;
const gyw = gY(wx);
const gy = Math.round(gyw - cy);
const xi = wx | 0;
if (xi >= 0 && xi < GLEN && pitMask[xi]) {
const rim = Math.round(gyw - PIT_DEPTH - cy);
ctx.fillStyle = '#1a120c';
ctx.fillRect(sx, rim, 1, H - rim);
const m = ((xi % 8) + 8) % 8,
hh = (m < 4 ? m : 8 - m) * 2 + 2;
ctx.fillStyle = '#b9c0c8';
ctx.fillRect(sx, gy - hh, 1, hh);
ctx.fillStyle = '#3a2a1e';
ctx.fillRect(sx, gy, 1, H - gy);
continue;
}
if (gy >= H) continue;
ctx.fillStyle = '#8a5a2b';
ctx.fillRect(sx, gy + 4, 1, H - gy - 4);
ctx.fillStyle = '#5ec44a';
ctx.fillRect(sx, gy, 1, 3);
ctx.fillStyle = '#3c8f34';
ctx.fillRect(sx, gy + 3, 1, 2);
ctx.fillStyle = '#6b4420';
if ((xi * 7 + (gyw | 0)) % 13 === 0) ctx.fillRect(sx, gy + 8, 1, 1);
if ((xi * 13) % 19 === 0) ctx.fillRect(sx, gy + 16, 1, 2);
if ((xi * 5 + 3) % 31 === 0) ctx.fillRect(sx, gy + 28, 1, 1);
if (gy + 42 < H) { ctx.fillStyle = '#5a3618';
ctx.fillRect(sx, gy + 42, 1, H - gy - 42); }
if (xi % 25 === 0) { ctx.fillStyle = '#7be36a';
ctx.fillRect(sx, gy - 2, 1, 2); }
}
}
function drawCheckpoints(cx, cy) {
for (let i = 0; i < checkpoints.length; i++) {
const cp = checkpoints[i];
const sx = Math.round(cp.x - cx);
if (sx < -20 || sx > W + 20) continue;
const gy = Math.round(gY(cp.x) - cy);
ctx.fillStyle = '#e8e8e8';
ctx.fillRect(sx, gy - 36, 2, 36);
ctx.fillStyle = cp.passed ? '#7CFC6B' : '#ffd23f';
ctx.fillRect(sx + 2, gy - 36, 10, 7);
ctx.fillRect(sx + 12, gy - 35, 3, 5);
ctx.fillStyle = '#222';
ctx.fillRect(sx - 5, gy - 12, 12, 8);
txt(String(i + 1), sx + 1, gy - 12, '#fff', 'bold 7px monospace', 'center');
}
const fx = FINISH_X,
sx = Math.round(fx - cx);
if (sx > -60 && sx < W + 60) {
const gy = Math.round(gY(fx) - cy);
ctx.fillStyle = '#ddd';
ctx.fillRect(sx - 24, gy - 52, 3, 52);
ctx.fillRect(sx + 22, gy - 52, 3, 52);
for (let i = 0; i < 13; i++)
for (let j = 0; j < 4; j++) { ctx.fillStyle = (i + j) % 2 ? '#111' : '#fff';
ctx.fillRect(sx - 24 + i * 4, gy - 54 + j * 4, 4, 4); }
ctx.fillStyle = '#c0392b';
ctx.fillRect(sx - 17, gy - 44, 34, 10);
txt('FINISH', sx, gy - 43, '#fff', 'bold 8px monospace', 'center');
for (let i = 0; i < 4; i++)
for (let j = 0; j < 2; j++) { ctx.fillStyle = (i + j) % 2 ? '#111' : '#fff';
const gx = fx - 4 + i * 2;
ctx.fillRect(sx - 4 + i * 2, Math.round(gY(gx) - cy) - 1 + j * 2, 2, 2); }
}
}
function drawObstacles(cx, cy) {
for (const o of obstacles) {
const x = Math.round(o.x - cx),
y = Math.round(o.y - cy);
if (x + o.w < 0 || x > W) continue;
if (o.type === 'crate') {
ctx.fillStyle = '#b5773a';
ctx.fillRect(x, y, o.w, o.h);
ctx.fillStyle = '#6b4420';
ctx.fillRect(x, y, o.w, 1);
ctx.fillRect(x, y + o.h - 1, o.w, 1);
ctx.fillRect(x, y, 1, o.h);
ctx.fillRect(x + o.w - 1, y, 1, o.h);
for (let i = 1; i < o.w - 1; i++) { ctx.fillRect(x + i, y + i, 1, 1);
ctx.fillRect(x + i, y + o.h - 1 - i, 1, 1); }
ctx.fillStyle = '#d9a066';
ctx.fillRect(x + 1, y + 1, o.w - 2, 1);
} else if (o.type === 'barrel') {
ctx.fillStyle = '#c0392b';
ctx.fillRect(x, y, o.w, o.h);
ctx.fillStyle = '#f1c40f';
ctx.fillRect(x, y + 3, o.w, 2);
ctx.fillRect(x, y + o.h - 5, o.w, 2);
ctx.fillStyle = '#7b1e14';
ctx.fillRect(x, y, 1, o.h);
ctx.fillRect(x + o.w - 1, y, 1, o.h);
ctx.fillStyle = '#ff7b6b';
ctx.fillRect(x + 2, y + 1, 1, o.h - 2);
} else {
ctx.fillStyle = '#6f6f7c';
ctx.fillRect(x + 2, y, o.w - 4, o.h);
ctx.fillRect(x, y + 3, o.w, o.h - 3);
ctx.fillStyle = '#9a9aa8';
ctx.fillRect(x + 4, y + 1, 5, 2);
ctx.fillRect(x + 3, y + 3, 2, 1);
ctx.fillStyle = '#4b4b56';
ctx.fillRect(x + 2, y + o.h - 2, o.w - 4, 2);
ctx.fillRect(x + 10, y + 4, 3, 2);
}
}
}
function drawWheel(x, y, spin) {
circ(x, y, 6, '#151515');
circ(x, y, 3.6, '#8d8d8d');
ctx.strokeStyle = '#e5e5e5';
ctx.lineWidth = 1;
ctx.beginPath();
for (let k = 0; k < 3; k++) {
const a = spin + k * Math.PI * 2 / 3;
ctx.moveTo(x, y);
ctx.lineTo(x + Math.cos(a) * 3.5, y + Math.sin(a) * 3.5);
}
ctx.stroke();
circ(x, y, 1.2, '#333');
}
function drawBike(cx, cy) {
const b = bike,
p1 = b.p[0],
p2 = b.p[1];
const ang = Math.atan2(p2.y - p1.y, p2.x - p1.x);
const mx = (p1.x + p2.x) / 2 - cx,
my = (p1.y + p2.y) / 2 - cy;
drawWheel(p1.x - cx, p1.y - cy, b.spin);
drawWheel(p2.x - cx, p2.y - cy, b.spin);
ctx.save();
ctx.translate(mx, my);
ctx.rotate(ang);
const line = (x1, y1, x2, y2, c, w) => { ctx.strokeStyle = c;
ctx.lineWidth = w;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke(); };
const rect = (x, y, w, h, c) => { ctx.fillStyle = c;
ctx.fillRect(x, y, w, h); };
line(-13, 0, -3, -5, '#4a4a4a', 2);
line(13, 0, 6, -11, '#b0b0b0', 2);
rect(-15, -3, 10, 2, '#bdbdbd');
rect(-6, -8, 10, 7, '#666');
rect(-5, -7, 8, 1, '#999');
rect(-5, -4, 8, 1, '#444');
rect(-16, -7, 7, 2, '#e63946');
rect(8, -9, 8, 2, '#e63946');
line(-3, -5, 3, -12, '#e63946', 2);
rect(-1, -13, 10, 5, '#e63946');
rect(1, -12, 6, 1, '#ff8a92');
rect(-12, -12, 11, 3, '#222');
rect(12, -12, 2, 3, '#ffe66d');
rect(5, -16, 5, 2, '#333');
line(-6, -11, -3, -4, '#2a4d9b', 2);
rect(-4, -4, 4, 2, '#222');
ctx.fillStyle = '#2e86de';
ctx.beginPath();
ctx.moveTo(-10, -11);
ctx.lineTo(-3, -11);
ctx.lineTo(-1, -23);
ctx.lineTo(-8, -23);
ctx.closePath();
ctx.fill();
rect(-7, -19, 4, 3, '#1b5fa8');
line(-3, -20, 6, -15, '#2e86de', 2);
rect(4, -16, 3, 2, '#222');
rect(-6, -30, 8, 8, '#f4f4f4');
rect(-6, -30, 8, 2, '#e63946');
rect(-2, -27, 5, 3, '#1a1a2e');
rect(-5, -23, 3, 1, '#bbb');
ctx.restore();
}
function drawParticles(cx, cy) {
for (const p of particles) {
if (p.k === 'px') {
const age = p.t / p.life;
if (age > 0.7 && (frameCount & 1)) continue;
const ci = Math.min(7, p.ci + Math.floor(age * 6));
ctx.fillStyle = PAL[ci];
ctx.fillRect(Math.round(p.x - cx), Math.round(p.y - cy), p.s, p.s);
} else if (p.k === 'smoke') {
const age = p.t / p.life;
ctx.fillStyle = age < 0.5 ? '#9a9a9a' : '#c9c9c9';
if (age > 0.6 && (frameCount & 1)) continue;
const s = Math.round(p.s);
ctx.fillRect(Math.round(p.x - cx - s / 2), Math.round(p.y - cy - s / 2), s, s);
} else drawWheel(p.x - cx, p.y - cy, p.spin);
}
for (const r of rings) {
if (r.t < 0) continue;
const rad = r.t * 110;
const c = r.t < 0.10 ? '#ffffff' : r.t < 0.22 ? '#ffd23f' : r.t < 0.34 ? '#ff8c1a' : '#ff3d00';
ctx.fillStyle = c;
const n = Math.max(10, Math.round(rad * 1.0));
for (let i = 0; i < n; i++) {
const a = i / n * Math.PI * 2;
ctx.fillRect(Math.round(r.x + Math.cos(a) * rad - cx) - 1, Math.round(r.y + Math.sin(a) * rad * 0.8 -
cy) - 1, 3, 3);
}
}
}
function drawHUD() {
ctx.fillStyle = 'rgba(0,0,0,.45)';
ctx.fillRect(6, 6, 80, 26);
txt('TIME', 10, 8, '#9fe3ff', 'bold 8px monospace');
const low = timeLeft <= 10;
const tc = low ? ((Math.floor(elapsed * 4) % 2) ? '#ff4d4d' : '#ffb3b3') : '#fff';
txt(timeLeft.toFixed(1), 10, 15, tc, 'bold 15px monospace');
ctx.fillStyle = 'rgba(0,0,0,.45)';
ctx.fillRect(W - 96, 6, 90, 26);
txt('SCORE', W - 10, 8, '#9fe3ff', 'bold 8px monospace', 'right');
txt(String(score).padStart(6, '0'), W - 10, 15, '#ffd23f', 'bold 15px monospace', 'right');
const bx = 140,
bw = 200,
by = 12;
ctx.fillStyle = 'rgba(0,0,0,.5)';
ctx.fillRect(bx - 4, by - 5, bw + 12, 16);
ctx.fillStyle = '#333';
ctx.fillRect(bx, by, bw, 5);
const mx = (bike.p[0].x + bike.p[1].x) / 2;
const prog = Math.max(0, Math.min(1, mx / FINISH_X));
ctx.fillStyle = '#5ec44a';
ctx.fillRect(bx, by, Math.round(bw * prog), 5);
for (const cp of checkpoints) {
const tx = bx + Math.round(bw * cp.x / FINISH_X);
ctx.fillStyle = cp.passed ? '#7CFC6B' : '#ddd';
ctx.fillRect(tx, by - 2, 2, 9);
}
for (let i = 0; i < 2; i++)
for (let j = 0; j < 3; j++) { ctx.fillStyle = (i + j) % 2 ? '#111' : '#fff';
ctx.fillRect(bx + bw + 1 + i * 3, by - 2 + j * 3, 3, 3); }
ctx.fillStyle = '#e63946';
ctx.fillRect(bx + Math.round(bw * prog) - 2, by - 3, 5, 11);
ctx.fillStyle = '#fff';
ctx.fillRect(bx + Math.round(bw * prog) - 1, by - 2, 3, 3);
txt('STUNTS ' + stuntCount + ' CRASHES ' + crashes, W - 10, 36, '#fff', 'bold 8px monospace', 'right');
txt('CP ' + (lastCp > 60 ? checkpoints.findIndex(c => c.x === lastCp) + 1 : '-') + '/' + checkpoints.length,
10, 36, '#fff', 'bold 8px monospace');
let i = 0;
for (const p of popups) {
const age = p.t / p.life;
if (age > 0.75 && (frameCount & 2)) continue;
const y = 80 - p.t * 16 + i * 14;
txt(p.text, W / 2, y, p.color, p.big ? 'bold 13px monospace' : 'bold 10px monospace', 'center');
i++;
}
if (!hintSeen && elapsed < 7 && state === 'play') {
ctx.fillStyle = 'rgba(0,0,0,.55)';
ctx.fillRect(0, H - 22, W, 22);
txt('↑ GAS ↓ BRAKE ← → LEAN ␣ HOP R RESTART', W / 2, H - 16, '#fff', 'bold 9px monospace',
'center');
}
if (state === 'crash') {
txt('RESPAWNING…', W / 2, H - 30, '#ffb3b3', 'bold 10px monospace', 'center');
}
}
function render() {
let cx = Math.round(camX),
cy = Math.round(camY);
if (shake > 0) { cx += Math.round((Math.random() - 0.5) * shake * 8);
cy += Math.round((Math.random() - 0.5) * shake * 8); }
drawSky(cx, cy);
drawGround(cx, cy);
drawCheckpoints(cx, cy);
drawObstacles(cx, cy);
if (state !== 'crash') drawBike(cx, cy);
drawParticles(cx, cy);
if (flash > 0) { ctx.fillStyle = 'rgba(255,255,255,' + Math.min(1, flash * 6) + ')';
ctx.fillRect(0, 0, W, H); }
if (state !== 'menu') drawHUD();
}
/* ============================================================
MAIN LOOP
============================================================ */
let last = performance.now(),
acc = 0;
const STEP = 1 / 120;
function frame(now) {
let dt = (now - last) / 1000;
last = now;
if (dt > 0.1) dt = 0.1;
frameCount++;
if (state === 'play') {
acc += dt;
while (acc >= STEP) { step(STEP);
acc -= STEP; if (state !== 'play') { acc = 0; break; } }
if (state === 'play') afterPhysics(dt);
} else if (state === 'crash') {
crashT -= dt;
if (crashT <= 0) {
if (timeLeft <= 0) gameOver();
else { spawn(lastCp);
state = 'play'; }
}
}
updateParticles(dt);
updatePopups(dt);
updateCamera(dt);
elapsed += dt;
shake = Math.max(0, shake - dt * 2);
flash = Math.max(0, flash - dt);
render();
updEngine();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();
</script>
</body>
</html>Game Source: Pixel Moto Stunts
Creator: MysticKoala95
Libraries: none
Complexity: complex (1726 lines, 69.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: pixel-moto-stunts-mystickoala95" to link back to the original. Then publish at arcadelab.ai/publish.