diff --git a/out_run_clone/BUILD.md b/out_run_clone/BUILD.md new file mode 100644 index 0000000..db39e55 --- /dev/null +++ b/out_run_clone/BUILD.md @@ -0,0 +1,76 @@ +# 🏎️ OUT RUN β€” Arcade Clone Build Plan + +> A faithful browser-based recreation of Sega's 1986 Out Run arcade game using HTML5 Canvas and vanilla JavaScript. No frameworks, no dependencies β€” just pure pseudo-3D driving bliss. + +This document is the design/build plan for the `out_run_clone/` project. The current implementation covers the pseudo-3D road engine (curves + hills), player physics with gear shifting, procedural roadside sprites and traffic with collision detection, the full 15-stage branching pyramid with fork choices, a HUD, title/radio-select/game-over/course-clear screens, and Web-Audio-based engine sound, procedural radio music, and SFX β€” all with keyboard and touch controls. + +See the project README for how to run it. The sections below are the original design reference used while building it. + +## Tech Stack +- **Rendering**: HTML5 `` (2D context) +- **Logic**: Vanilla JavaScript (ES modules) +- **Styling**: Vanilla CSS +- **Audio**: Web Audio API (procedural β€” no external audio files) +- **Assets**: Procedurally drawn canvas sprites (no external image files) + +## Architecture + +``` +out_run_clone/ +β”œβ”€β”€ index.html # Entry point +β”œβ”€β”€ index.css # Global styles, CRT/retro effects +β”œβ”€β”€ js/ +β”‚ β”œβ”€β”€ main.js # Bootstrap, game loop, state machine +β”‚ β”œβ”€β”€ constants.js # All tuning constants in one place +β”‚ β”œβ”€β”€ render/ +β”‚ β”‚ β”œβ”€β”€ road.js # Pseudo-3D road projection & drawing +β”‚ β”‚ β”œβ”€β”€ background.js # Parallax sky/horizon/ground layers +β”‚ β”‚ β”œβ”€β”€ sprites.js # Procedural sprite drawing/scaling/positioning +β”‚ β”‚ └── hud.js # Speedometer, timer, score, gear indicator +β”‚ β”œβ”€β”€ game/ +β”‚ β”‚ β”œβ”€β”€ player.js # Player car: physics, steering, acceleration +β”‚ β”‚ β”œβ”€β”€ traffic.js # AI traffic cars: spawning, movement, lanes +β”‚ β”‚ β”œβ”€β”€ collision.js # Collision detection (car↔car, car↔roadside) +β”‚ β”‚ β”œβ”€β”€ stages.js # Stage pyramid, theming, track generation +β”‚ β”‚ └── camera.js # Camera height/depth, crash screen-shake +β”‚ β”œβ”€β”€ audio/ +β”‚ β”‚ └── audio.js # Engine sound, procedural radio music, SFX +β”‚ └── utils/ +β”‚ β”œβ”€β”€ math.js # Interpolation, easing, projection, seeded RNG +β”‚ └── input.js # Keyboard/touch input handler +└── BUILD.md # This file +``` + +## Controls + +| Key | Action | +|---|---| +| `↑` / `W` | Accelerate | +| `↓` / `S` | Brake | +| `←` / `A` | Steer left / choose left fork | +| `β†’` / `D` | Steer right / choose right fork | +| `Space` | Shift gear (toggle Low ↔ High) | +| `Enter` | Start / select | +| `1` / `2` / `3` | Quick-select radio station | + +Touch controls (steer, gas, brake, gear, start) appear automatically on coarse-pointer (mobile/tablet) devices. + +## Stage Pyramid + +15 stages across 5 rows, forking left/right at the end of each non-goal stage (adjacent parents share a child, matching the original arcade map): + +``` +Row 1: Coconut Beach +Row 2: Gateway Β· Devil's Canyon +Row 3: Desert Β· Alps Β· Cloudy Mountain +Row 4: Wilderness Β· Old Capital Β· Wheat Field Β· Seaside Town +Row 5: Vineyard(A) Β· Death Valley(B) Β· Desolation Hill(C) Β· Autobahn(D) Β· Lakeside(E) +``` + +Reaching a goal stage's end completes the run; running out of time coasts the car to a stop and ends the game. + +## Stretch ideas not yet implemented +- Gamepad support +- Night mode / weather effects +- Two-road (wide highway) rendering +- Replay system / online leaderboard diff --git a/out_run_clone/index.css b/out_run_clone/index.css new file mode 100644 index 0000000..e8a5178 --- /dev/null +++ b/out_run_clone/index.css @@ -0,0 +1,105 @@ +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + background: #000; + overflow: hidden; + font-family: "Courier New", monospace; + -webkit-user-select: none; + user-select: none; +} + +#game-frame { + position: relative; + width: 100vw; + height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: #000; +} + +#game-canvas { + width: min(100vw, 133.33vh); + height: min(75vw, 100vh); + aspect-ratio: 4 / 3; + image-rendering: optimizeSpeed; + display: block; + background: #000; +} + +#crt-overlay { + position: absolute; + inset: 0; + pointer-events: none; + background: + repeating-linear-gradient( + to bottom, + rgba(0, 0, 0, 0.18) 0px, + rgba(0, 0, 0, 0.18) 1px, + transparent 2px, + transparent 3px + ); + mix-blend-mode: multiply; + opacity: 0.55; +} + +#crt-overlay::after { + content: ""; + position: absolute; + inset: 0; + background: radial-gradient(ellipse at center, rgba(255,255,255,0) 55%, rgba(0,0,0,0.35) 100%); +} + +#touch-controls { + display: none; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 10; +} + +@media (pointer: coarse) { + #touch-controls { display: block; } +} + +.touch-col { + position: absolute; + bottom: 18px; + display: flex; + gap: 12px; + pointer-events: auto; +} +.touch-col.left { left: 18px; align-items: flex-end; } +.touch-col.right { right: 18px; flex-direction: column; align-items: flex-end; gap: 10px; } + +.touch-btn { + width: 68px; + height: 68px; + border-radius: 50%; + border: 2px solid rgba(255,255,255,0.6); + background: rgba(20,20,30,0.55); + color: #fff; + font-size: 20px; + font-weight: 700; + font-family: inherit; + touch-action: manipulation; +} +.touch-btn.small { width: 58px; height: 58px; font-size: 12px; background: rgba(255,120,40,0.5); } +.touch-btn:active { background: rgba(255,255,255,0.35); } + +.touch-btn.start { + position: absolute; + top: 18px; + right: 18px; + width: auto; + height: 44px; + border-radius: 8px; + padding: 0 16px; + font-size: 14px; + pointer-events: auto; + background: rgba(255, 210, 40, 0.5); +} diff --git a/out_run_clone/index.html b/out_run_clone/index.html new file mode 100644 index 0000000..3538e49 --- /dev/null +++ b/out_run_clone/index.html @@ -0,0 +1,31 @@ + + + + + + OUT RUN β€” Arcade Clone + + + + +
+ +
+
+ +
+
+ + +
+
+ + + +
+ +
+ + + + diff --git a/out_run_clone/js/audio/audio.js b/out_run_clone/js/audio/audio.js new file mode 100644 index 0000000..1b03c6c --- /dev/null +++ b/out_run_clone/js/audio/audio.js @@ -0,0 +1,131 @@ +// Web Audio API based sound: a speed-mapped engine drone, tiny procedural +// radio "music" loops (no external audio files), and short SFX blips. + +import { RADIO_STATIONS } from '../constants.js'; + +const SCALES = { + latin: [0, 3, 5, 7, 10, 12, 15, 19], + jazz: [0, 2, 3, 7, 9, 12, 14, 19], + synth: [0, 2, 4, 7, 9, 12, 16, 19], +}; + +export class AudioManager { + constructor() { + this.ctx = null; + this.engineOsc = null; + this.engineGain = null; + this.musicTimer = null; + this.stationIndex = 0; + this.muted = false; + } + + ensureContext() { + if (this.ctx) return; + const Ctx = window.AudioContext || window.webkitAudioContext; + this.ctx = new Ctx(); + this._setupEngine(); + } + + _setupEngine() { + const ctx = this.ctx; + this.engineOsc = ctx.createOscillator(); + this.engineOsc.type = 'sawtooth'; + this.engineGain = ctx.createGain(); + this.engineGain.gain.value = 0.0; + const filter = ctx.createBiquadFilter(); + filter.type = 'lowpass'; + filter.frequency.value = 800; + this.engineOsc.connect(filter); + filter.connect(this.engineGain); + this.engineGain.connect(ctx.destination); + this.engineOsc.frequency.value = 60; + this.engineOsc.start(); + } + + setEngineSpeed(speedPercent) { + if (!this.ctx || this.muted) return; + const freq = 60 + speedPercent * 260; + this.engineOsc.frequency.setTargetAtTime(freq, this.ctx.currentTime, 0.05); + this.engineGain.gain.setTargetAtTime(0.05 + speedPercent * 0.05, this.ctx.currentTime, 0.05); + } + + stopEngine() { + if (this.engineGain) this.engineGain.gain.setTargetAtTime(0, this.ctx.currentTime, 0.1); + } + + playRadio(index) { + this.ensureContext(); + this.stationIndex = index; + this._stopMusic(); + if (this.muted) return; + const station = RADIO_STATIONS[index]; + const scale = SCALES[station.style]; + const root = 220; + const stepMs = 60000 / station.tempo / 2; + let step = 0; + this.musicTimer = setInterval(() => { + const note = scale[Math.floor(Math.random() * scale.length) % scale.length]; + const octave = (step % 8 < 6) ? 0 : 12; + this._pluck(root * Math.pow(2, (note + octave) / 12), stepMs / 1000 * 1.6, step % 4 === 0 ? 0.09 : 0.05); + step++; + }, stepMs); + } + + _stopMusic() { + if (this.musicTimer) clearInterval(this.musicTimer); + this.musicTimer = null; + } + + _pluck(freq, duration, gainAmount) { + if (!this.ctx) return; + const ctx = this.ctx; + const osc = ctx.createOscillator(); + osc.type = 'triangle'; + osc.frequency.value = freq; + const gain = ctx.createGain(); + gain.gain.setValueAtTime(gainAmount, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + duration); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(); + osc.stop(ctx.currentTime + duration); + } + + sfx(name) { + this.ensureContext(); + if (this.muted) return; + const ctx = this.ctx; + if (name === 'crash') { + const bufferSize = ctx.sampleRate * 0.35; + const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < bufferSize; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / bufferSize); + const src = ctx.createBufferSource(); + src.buffer = buffer; + const gain = ctx.createGain(); + gain.gain.value = 0.5; + src.connect(gain); + gain.connect(ctx.destination); + src.start(); + } else if (name === 'checkpoint') { + this._pluck(880, 0.12, 0.2); + setTimeout(() => this._pluck(1320, 0.15, 0.2), 90); + } else if (name === 'gear') { + this._pluck(200, 0.06, 0.15); + } else if (name === 'countdown') { + this._pluck(1000, 0.08, 0.2); + } else if (name === 'select') { + this._pluck(660, 0.08, 0.15); + } else if (name === 'start') { + this._pluck(440, 0.1, 0.2); + setTimeout(() => this._pluck(660, 0.12, 0.2), 100); + setTimeout(() => this._pluck(880, 0.18, 0.2), 200); + } + } + + toggleMute() { + this.muted = !this.muted; + if (this.muted) this._stopMusic(); + return this.muted; + } +} diff --git a/out_run_clone/js/constants.js b/out_run_clone/js/constants.js new file mode 100644 index 0000000..8d7ddd1 --- /dev/null +++ b/out_run_clone/js/constants.js @@ -0,0 +1,69 @@ +// All tuning constants live here so the feel of the game can be adjusted +// from a single place. + +export const WIDTH = 1024; +export const HEIGHT = 768; +export const FPS = 60; +export const STEP = 1 / FPS; + +export const SEGMENT_LENGTH = 200; +export const RUMBLE_LENGTH = 3; +export const LANES = 3; +export const ROAD_WIDTH = 2000; + +export const FIELD_OF_VIEW = 100; +export const CAMERA_HEIGHT = 1200; +export const CAMERA_DEPTH = 1 / Math.tan((FIELD_OF_VIEW / 2) * Math.PI / 180); +export const DRAW_DISTANCE = 300; +export const FOG_DENSITY = 5; + +// Player physics (world units / second, ~km/h scaled). +export const MAX_SPEED_LOW = SEGMENT_LENGTH * 45; // β‰ˆ180 km/h feel +export const MAX_SPEED_HIGH = SEGMENT_LENGTH * 73; // β‰ˆ293 km/h feel +export const ACCEL_LOW = MAX_SPEED_LOW / 3.5; +export const ACCEL_HIGH = MAX_SPEED_HIGH / 9; +export const BRAKE = -MAX_SPEED_HIGH / 1.4; +export const DECEL = -MAX_SPEED_HIGH / 6; +export const OFFROAD_DECEL = -MAX_SPEED_HIGH / 3; +export const OFFROAD_LIMIT = MAX_SPEED_LOW / 3; + +export const CENTRIFUGAL = 0.3; +export const PLAYER_X_MAX = 3; // in road widths, allows driving well off-road +export const STEER_SPEED = 3.2; + +export const PLAYER_Z = CAMERA_HEIGHT * CAMERA_DEPTH; // distance from camera to player sprite + +export const CRASH_DURATION = 1.8; // seconds + +export const KM_PER_WORLD_UNIT = 293 / MAX_SPEED_HIGH; // converts world speed -> displayed km/h + +export const TOTAL_TIME_START = 75; + +export const GEAR = { LOW: 'LOW', HIGH: 'HIGH' }; + +export const STATE = { + TITLE: 'TITLE', + RADIO: 'RADIO', + PLAYING: 'PLAYING', + GAME_OVER: 'GAME_OVER', + COURSE_CLEAR: 'COURSE_CLEAR', +}; + +export const RADIO_STATIONS = [ + { name: 'MAGICAL SOUND SHOWER', tempo: 128, style: 'latin' }, + { name: 'PASSING BREEZE', tempo: 96, style: 'jazz' }, + { name: 'SPLASH WAVE', tempo: 140, style: 'synth' }, +]; + +export const COLORS = { + base: { + sky: '#72c6f0', + road: '#6b6b6b', + roadAlt: '#696969', + rumble: '#ff0000', + rumbleAlt: '#ffffff', + lane: '#ffffff', + grass: '#10aa10', + grassAlt: '#009a00', + }, +}; diff --git a/out_run_clone/js/game/camera.js b/out_run_clone/js/game/camera.js new file mode 100644 index 0000000..5a23935 --- /dev/null +++ b/out_run_clone/js/game/camera.js @@ -0,0 +1,30 @@ +import { CAMERA_HEIGHT, CAMERA_DEPTH, PLAYER_Z } from '../constants.js'; + +// Tracks camera height/lookahead plus a decaying shake offset triggered on +// crashes. +export class Camera { + constructor() { + this.height = CAMERA_HEIGHT; + this.depth = CAMERA_DEPTH; + this.playerZ = PLAYER_Z; + this.shake = 0; + } + + triggerShake(amount = 18) { + this.shake = amount; + } + + update(dt) { + if (this.shake > 0) { + this.shake = Math.max(0, this.shake - dt * 40); + } + } + + shakeOffset() { + if (this.shake <= 0) return { x: 0, y: 0 }; + return { + x: (Math.random() - 0.5) * this.shake, + y: (Math.random() - 0.5) * this.shake, + }; + } +} diff --git a/out_run_clone/js/game/collision.js b/out_run_clone/js/game/collision.js new file mode 100644 index 0000000..33fae08 --- /dev/null +++ b/out_run_clone/js/game/collision.js @@ -0,0 +1,25 @@ +import { ROAD_WIDTH, PLAYER_X_MAX } from '../constants.js'; +import { CAR_WIDTH, CAR_LENGTH } from './traffic.js'; + +const PLAYER_WIDTH = 460; + +// All checks operate in world space (player.x is in road-widths, car.x too). +export function checkTrafficCollision(playerZ, playerX, cars) { + const halfPlayer = (PLAYER_WIDTH / 2) / (ROAD_WIDTH / 2); + const halfCar = (CAR_WIDTH / 2) / (ROAD_WIDTH / 2); + for (const car of cars) { + if (Math.abs(car.z - playerZ) > CAR_LENGTH) continue; + if (Math.abs(car.x - playerX) < halfPlayer + halfCar) { + return car; + } + } + return null; +} + +export function isOffRoad(playerX) { + return Math.abs(playerX) > 1; +} + +export function isOutOfBounds(playerX) { + return Math.abs(playerX) > PLAYER_X_MAX - 0.05; +} diff --git a/out_run_clone/js/game/player.js b/out_run_clone/js/game/player.js new file mode 100644 index 0000000..93836f6 --- /dev/null +++ b/out_run_clone/js/game/player.js @@ -0,0 +1,100 @@ +import { + ACCEL_LOW, ACCEL_HIGH, BRAKE, DECEL, OFFROAD_DECEL, OFFROAD_LIMIT, + MAX_SPEED_LOW, MAX_SPEED_HIGH, CENTRIFUGAL, STEER_SPEED, PLAYER_X_MAX, + CRASH_DURATION, GEAR, KM_PER_WORLD_UNIT, +} from '../constants.js'; +import { clamp, accelerate } from '../utils/math.js'; + +export class Player { + constructor() { + this.x = 0; // -1..1 within the road, can exceed for off-road + this.speed = 0; + this.gear = GEAR.LOW; + this.crashed = false; + this.crashTimer = 0; + this.bounce = 0; + this.steerDir = 0; + this.grace = 0; + } + + get maxSpeed() { + return this.gear === GEAR.HIGH ? MAX_SPEED_HIGH : MAX_SPEED_LOW; + } + + get speedKmh() { + return Math.round(this.speed * KM_PER_WORLD_UNIT); + } + + shiftGear() { + this.gear = this.gear === GEAR.LOW ? GEAR.HIGH : GEAR.LOW; + } + + get invulnerable() { + return this.crashed || this.grace > 0; + } + + crash() { + if (this.crashed) return; + this.crashed = true; + this.crashTimer = CRASH_DURATION; + this.speed *= 0.2; + // Pull back toward the road and hold off further collision checks for a + // moment so a crash near a fixed obstacle can't re-trigger every frame. + this.x = clamp(this.x, -0.85, 0.85); + this.grace = 1.2; + } + + update(dt, input, curve, isOffRoad, forceStop = false) { + if (this.grace > 0) this.grace = Math.max(0, this.grace - dt); + + if (this.crashed) { + this.crashTimer -= dt; + this.bounce = Math.sin(performance.now() / 40) * 4; + this.speed = accelerate(this.speed, DECEL, dt); + this.speed = Math.max(0, this.speed); + if (this.crashTimer <= 0) { + this.crashed = false; + this.bounce = 0; + } + return; + } + + this.steerDir = 0; + if (!forceStop && input.isDown('left')) this.steerDir = -1; + if (!forceStop && input.isDown('right')) this.steerDir = 1; + + if (forceStop) { + this.speed = accelerate(this.speed, DECEL, dt); + this.speed = clamp(this.speed, 0, this.maxSpeed); + const speedPercent = this.speed / MAX_SPEED_HIGH; + this.x -= (curve || 0) * speedPercent * CENTRIFUGAL * dt; + this.x = clamp(this.x, -PLAYER_X_MAX, PLAYER_X_MAX); + return; + } + + if (input.isDown('accelerate')) { + const accel = this.gear === GEAR.HIGH ? ACCEL_HIGH : ACCEL_LOW; + this.speed = accelerate(this.speed, accel, dt); + } else if (input.isDown('brake')) { + this.speed = accelerate(this.speed, BRAKE, dt); + } else { + this.speed = accelerate(this.speed, DECEL, dt); + } + + if (isOffRoad) { + if (this.speed > OFFROAD_LIMIT) { + this.speed = accelerate(this.speed, OFFROAD_DECEL, dt); + } + this.bounce = Math.sin(performance.now() / 25) * (2 + this.speed / MAX_SPEED_HIGH * 6); + } else { + this.bounce = 0; + } + + this.speed = clamp(this.speed, 0, this.maxSpeed); + + const speedPercent = this.speed / MAX_SPEED_HIGH; + this.x += this.steerDir * STEER_SPEED * speedPercent * dt; + this.x -= (curve || 0) * speedPercent * CENTRIFUGAL * dt; + this.x = clamp(this.x, -PLAYER_X_MAX, PLAYER_X_MAX); + } +} diff --git a/out_run_clone/js/game/stages.js b/out_run_clone/js/game/stages.js new file mode 100644 index 0000000..0d0ee19 --- /dev/null +++ b/out_run_clone/js/game/stages.js @@ -0,0 +1,206 @@ +// Stage definitions (the 15-stage Out Run "pyramid") and procedural track +// generation. Each stage's road layout is generated deterministically from +// a seed so it's stable across plays but still varied stage-to-stage. + +import { SEGMENT_LENGTH, RUMBLE_LENGTH, TOTAL_TIME_START } from '../constants.js'; +import { SeededRandom, easeInOut, easeIn } from '../utils/math.js'; + +const THEMES = { + coconut_beach: { + name: 'Coconut Beach', sky: { skyTop: '#2fa9e8', skyBottom: '#bdf0ff', sun: '#ffe27a', cloud: '#ffffff', hillFar: '#1f8fbd', hillNear: '#17a86b', ground: '#0f8a3d' }, + road: { road: '#666666', roadAlt: '#646464', rumble: '#e0442e', rumbleAlt: '#f2f2f2', lane: '#f2f2f2', grass: '#12ad12', grassAlt: '#0a9a0a' }, + sprites: ['palm', 'sign'], curviness: 0.35, hilliness: 0.2, + }, + gateway: { + name: 'Gateway', sky: { skyTop: '#e8712f', skyBottom: '#ffd7a0', sun: '#fff2c0', cloud: '#ffcf9e', hillFar: '#7a4a2f', hillNear: '#5a3a2f', ground: '#4a3a2a' }, + road: { road: '#5c5c5c', roadAlt: '#5a5a5a', rumble: '#e0442e', rumbleAlt: '#f2f2f2', lane: '#f2f2f2', grass: '#3a6a2a', grassAlt: '#2f5a20' }, + sprites: ['building', 'sign'], curviness: 0.4, hilliness: 0.25, + }, + devils_canyon: { + name: "Devil's Canyon", sky: { skyTop: '#b8232f', skyBottom: '#ff9a5a', sun: '#ffdca0', cloud: '#e08a6a', hillFar: '#7a3020', hillNear: '#5c2418', ground: '#6a4a2a' }, + road: { road: '#6a5a4a', roadAlt: '#685848', rumble: '#f2f2f2', rumbleAlt: '#e0442e', lane: '#f2f2f2', grass: '#8a5a3a', grassAlt: '#7a4a2a' }, + sprites: ['rock', 'cactus'], curviness: 0.55, hilliness: 0.45, + }, + desert: { + name: 'Desert', sky: { skyTop: '#cfe8f2', skyBottom: '#fdf3d0', sun: '#fff6d0', cloud: '#f2e6c0', hillFar: '#d8c48a', hillNear: '#c9a86a', ground: '#e2c98a' }, + road: { road: '#8a7a5a', roadAlt: '#887858', rumble: '#f2f2f2', rumbleAlt: '#c94a2a', lane: '#f2f2f2', grass: '#d8b878', grassAlt: '#caa868' }, + sprites: ['cactus', 'rock'], curviness: 0.3, hilliness: 0.15, + }, + alps: { + name: 'Alps', sky: { skyTop: '#4a86c8', skyBottom: '#cfe8ff', sun: '#ffffff', cloud: '#ffffff', hillFar: '#8aa8c8', hillNear: '#e8f2ff', ground: '#eef6ff' }, + road: { road: '#5a5a5a', roadAlt: '#585858', rumble: '#e0442e', rumbleAlt: '#f2f2f2', lane: '#f2f2f2', grass: '#e8f2ff', grassAlt: '#d8ecff' }, + sprites: ['pine', 'building'], curviness: 0.5, hilliness: 0.6, + }, + cloudy_mountain: { + name: 'Cloudy Mountain', sky: { skyTop: '#8a96a0', skyBottom: '#cdd6dc', sun: '#dfe6ea', cloud: '#f4f6f8', hillFar: '#6a7680', hillNear: '#4a5560', ground: '#5a6a5a' }, + road: { road: '#575757', roadAlt: '#555555', rumble: '#cfcfcf', rumbleAlt: '#8a8a8a', lane: '#dedede', grass: '#4a6a4a', grassAlt: '#3f5f3f' }, + sprites: ['pine', 'rock'], curviness: 0.5, hilliness: 0.7, + }, + wilderness: { + name: 'Wilderness', sky: { skyTop: '#274b7a', skyBottom: '#7fa8c9', sun: '#ffe9b0', cloud: '#b8cbe0', hillFar: '#25422f', hillNear: '#1c3524', ground: '#1a3020' }, + road: { road: '#555555', roadAlt: '#535353', rumble: '#e0442e', rumbleAlt: '#f2f2f2', lane: '#f2f2f2', grass: '#1e4a24', grassAlt: '#173d1c' }, + sprites: ['tree', 'rock'], curviness: 0.55, hilliness: 0.4, + }, + old_capital: { + name: 'Old Capital', sky: { skyTop: '#e8b25a', skyBottom: '#ffe3a8', sun: '#fff6d8', cloud: '#f2cf9e', hillFar: '#8a6a4a', hillNear: '#6a4e34', ground: '#6a5a42' }, + road: { road: '#736357', roadAlt: '#716155', rumble: '#f2f2f2', rumbleAlt: '#8a3a2a', lane: '#f2f2f2', grass: '#7a6a4a', grassAlt: '#6a5a3e' }, + sprites: ['building', 'sign'], curviness: 0.45, hilliness: 0.3, + }, + wheat_field: { + name: 'Wheat Field', sky: { skyTop: '#e8c85a', skyBottom: '#fff0b0', sun: '#fffbe0', cloud: '#f6e2a0', hillFar: '#c8a848', hillNear: '#d8b858', ground: '#e0c060' }, + road: { road: '#6a5a45', roadAlt: '#685843', rumble: '#f2f2f2', rumbleAlt: '#c94a2a', lane: '#f2f2f2', grass: '#e2c258', grassAlt: '#d4b448' }, + sprites: ['windmill', 'tree'], curviness: 0.3, hilliness: 0.2, + }, + seaside_town: { + name: 'Seaside Town', sky: { skyTop: '#7ec8e0', skyBottom: '#dff5f5', sun: '#fff6d8', cloud: '#ffffff', hillFar: '#3a8ab0', hillNear: '#eae0c8', ground: '#e6dcc0' }, + road: { road: '#68666a', roadAlt: '#666468', rumble: '#e0442e', rumbleAlt: '#f2f2f2', lane: '#f2f2f2', grass: '#3ab08a', grassAlt: '#2fa07a' }, + sprites: ['building', 'palm'], curviness: 0.4, hilliness: 0.2, + }, + vineyard: { + name: 'Vineyard (Goal A)', goal: 'A', sky: { skyTop: '#e89a4a', skyBottom: '#ffd9a0', sun: '#fff2c8', cloud: '#f2c69e', hillFar: '#5a6a3a', hillNear: '#4a5a2f', ground: '#5a4a2a' }, + road: { road: '#665c4a', roadAlt: '#645a48', rumble: '#f2f2f2', rumbleAlt: '#8a3a2a', lane: '#f2f2f2', grass: '#5a7a3a', grassAlt: '#4c6c30' }, + sprites: ['tree', 'building'], curviness: 0.45, hilliness: 0.35, + }, + death_valley: { + name: 'Death Valley (Goal B)', goal: 'B', sky: { skyTop: '#e0603a', skyBottom: '#ffb27a', sun: '#ffe0a0', cloud: '#d88a5a', hillFar: '#7a3a20', hillNear: '#5c2a18', ground: '#7a5a34' }, + road: { road: '#7a6a52', roadAlt: '#786850', rumble: '#f2f2f2', rumbleAlt: '#e0442e', lane: '#f2f2f2', grass: '#8a6a3a', grassAlt: '#7a5c30' }, + sprites: ['cactus', 'rock'], curviness: 0.4, hilliness: 0.3, + }, + desolation_hill: { + name: 'Desolation Hill (Goal C)', goal: 'C', sky: { skyTop: '#6a6a72', skyBottom: '#b0b0b8', sun: '#d8d8d8', cloud: '#dcdce0', hillFar: '#4a4a50', hillNear: '#3a3a40', ground: '#4a4640' }, + road: { road: '#4f4f52', roadAlt: '#4d4d50', rumble: '#cfcfcf', rumbleAlt: '#8a8a8a', lane: '#dedede', grass: '#5a564a', grassAlt: '#4c483e' }, + sprites: ['rock', 'tree'], curviness: 0.5, hilliness: 0.5, + }, + autobahn: { + name: 'Autobahn (Goal D)', goal: 'D', sky: { skyTop: '#3a5a7a', skyBottom: '#8aa8c0', sun: '#e0dcc0', cloud: '#a8b8c8', hillFar: '#3a4048', hillNear: '#2a3038', ground: '#3a3e42' }, + road: { road: '#454545', roadAlt: '#434343', rumble: '#e0442e', rumbleAlt: '#f2f2f2', lane: '#f2f2f2', grass: '#3a4a3a', grassAlt: '#304030' }, + sprites: ['building', 'sign'], curviness: 0.25, hilliness: 0.15, + }, + lakeside: { + name: 'Lakeside (Goal E)', goal: 'E', sky: { skyTop: '#3a6ab0', skyBottom: '#a8d0e8', sun: '#ffe9b0', cloud: '#dceaf5', hillFar: '#2a5a80', hillNear: '#1e6a9a', ground: '#2a4a3a' }, + road: { road: '#5c5c5c', roadAlt: '#5a5a5a', rumble: '#e0442e', rumbleAlt: '#f2f2f2', lane: '#f2f2f2', grass: '#1e6a4a', grassAlt: '#175a3e' }, + sprites: ['tree', 'palm'], curviness: 0.35, hilliness: 0.25, + }, +}; + +// Pyramid tree: adjacent parents share a child, matching the classic Out Run map. +export const STAGE_TREE = { + coconut_beach: { left: 'gateway', right: 'devils_canyon', row: 1 }, + gateway: { left: 'desert', right: 'alps', row: 2 }, + devils_canyon: { left: 'alps', right: 'cloudy_mountain', row: 2 }, + desert: { left: 'wilderness', right: 'old_capital', row: 3 }, + alps: { left: 'old_capital', right: 'wheat_field', row: 3 }, + cloudy_mountain: { left: 'wheat_field', right: 'seaside_town', row: 3 }, + wilderness: { left: 'vineyard', right: 'death_valley', row: 4 }, + old_capital: { left: 'death_valley', right: 'desolation_hill', row: 4 }, + wheat_field: { left: 'desolation_hill', right: 'autobahn', row: 4 }, + seaside_town: { left: 'autobahn', right: 'lakeside', row: 4 }, + vineyard: { row: 5 }, + death_valley: { row: 5 }, + desolation_hill: { row: 5 }, + autobahn: { row: 5 }, + lakeside: { row: 5 }, +}; + +export const ROOT_STAGE = 'coconut_beach'; + +function hashSeed(str) { + let h = 2166136261; + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} + +function colorSetFor(theme, index, isLaneSet) { + return { + road: theme.road.road, + grass: (Math.floor(index / RUMBLE_LENGTH) % 2) ? theme.road.grass : theme.road.grassAlt, + rumble: (Math.floor(index / RUMBLE_LENGTH) % 2) ? theme.road.rumble : theme.road.rumbleAlt, + lane: isLaneSet ? theme.road.lane : null, + }; +} + +export function buildStageTrack(stageId, row) { + const theme = THEMES[stageId]; + const rng = new SeededRandom(hashSeed(stageId)); + const segments = []; + const CURVE_UNIT = 0.7 * (1 + row * 0.12); + const HILL_UNIT = 900 * (1 + row * 0.1); + + const addSegment = (curve, y) => { + const n = segments.length; + const prevY = n > 0 ? segments[n - 1].p2.world.y : 0; + const isLaneSet = Math.floor(n / RUMBLE_LENGTH) % 2 === 0; + segments.push({ + index: n, + p1: { world: { x: 0, y: prevY, z: n * SEGMENT_LENGTH }, camera: {}, screen: {} }, + p2: { world: { x: 0, y, z: (n + 1) * SEGMENT_LENGTH }, camera: {}, screen: {} }, + curve, + sprites: [], + color: colorSetFor(theme, n, isLaneSet), + }); + }; + + const addRoad = (enter, hold, leave, curve, hillDelta) => { + const startY = segments.length ? segments[segments.length - 1].p2.world.y : 0; + const endY = startY + hillDelta; + const total = enter + hold + leave; + for (let i = 0; i < enter; i++) addSegment(easeIn(0, curve, i / enter), easeInOut(startY, endY, i / total)); + for (let i = 0; i < hold; i++) addSegment(curve, easeInOut(startY, endY, (enter + i) / total)); + for (let i = 0; i < leave; i++) addSegment(easeInOut(curve, 0, i / leave), easeInOut(startY, endY, (enter + hold + i) / total)); + }; + + const targetSegments = 900 + row * 120; + addRoad(40, 40, 40, 0, 0); // starting straight + while (segments.length < targetSegments) { + const roll = rng.next(); + const curveMag = rng.range(1.5, 4.5) * CURVE_UNIT * (rng.next() < 0.5 ? -1 : 1); + const hillMag = rng.range(-1, 1) * HILL_UNIT * theme.hilliness; + if (roll < theme.curviness * 0.5) { + addRoad(30, 60, 30, curveMag, hillMag); + } else if (roll < theme.curviness) { + addRoad(20, 30, 20, curveMag * 1.6, hillMag); + } else if (roll < theme.curviness + theme.hilliness * 0.4) { + addRoad(40, 50, 40, 0, hillMag * 1.4); + } else { + addRoad(30, 40, 30, 0, 0); + } + } + addRoad(30, 60, 30, 0, -((segments[segments.length - 1]?.p2.world.y) || 0)); // ease back to ground before fork + addRoad(20, 60, 20, 0, 0); // final straight for the fork + + // Roadside sprite placement + const spriteTypes = theme.sprites; + for (let n = 20; n < segments.length - 60; n += 1) { + if (rng.next() < 0.09) { + const side = rng.next() < 0.5 ? -1 : 1; + const type = rng.pick(spriteTypes); + segments[n].sprites.push({ type, offset: side * rng.range(1.3, 2.6), palette: theme.road }); + } + } + + const forkAt = segments.length - 45; + return { segments, theme, forkAt, length: segments.length * SEGMENT_LENGTH }; +} + +export function getStageConfig(stageId) { + const node = STAGE_TREE[stageId]; + const theme = THEMES[stageId]; + return { + id: stageId, + name: theme.name, + row: node.row, + leftChild: node.left || null, + rightChild: node.right || null, + isGoal: !node.left && !node.right, + goal: theme.goal || null, + trafficDensity: 0.25 + node.row * 0.18, + timeBonus: 30 + node.row * 8, + }; +} + +export function getTotalTimeStart() { + return TOTAL_TIME_START; +} diff --git a/out_run_clone/js/game/traffic.js b/out_run_clone/js/game/traffic.js new file mode 100644 index 0000000..58fbcba --- /dev/null +++ b/out_run_clone/js/game/traffic.js @@ -0,0 +1,50 @@ +import { SEGMENT_LENGTH } from '../constants.js'; +import { SeededRandom } from '../utils/math.js'; + +const CAR_TYPES = [ + { type: 'sedan', colors: ['#2255cc', '#22aa55', '#dddddd', '#333333', '#cc9922'] }, + { type: 'sedan', colors: ['#aa2244', '#557799'] }, + { type: 'truck', colors: ['#886644', '#445566', '#996633'] }, +]; + +export const CAR_WIDTH = 320; +export const CAR_LENGTH = SEGMENT_LENGTH * 1.1; + +export class TrafficManager { + constructor(seed) { + this.rng = new SeededRandom(seed); + this.cars = []; + } + + populate(trackLength, density, startZ = SEGMENT_LENGTH * 20) { + this.cars = []; + const count = Math.floor((trackLength / SEGMENT_LENGTH) * density * 0.12); + const lanes = [-0.55, 0, 0.55]; + for (let i = 0; i < count; i++) { + const group = this.rng.pick(CAR_TYPES); + this.cars.push({ + z: startZ + this.rng.next() * (trackLength - startZ - SEGMENT_LENGTH * 10), + x: this.rng.pick(lanes) + this.rng.range(-0.08, 0.08), + speed: SEGMENT_LENGTH * this.rng.range(16, 34), + color: this.rng.pick(group.colors), + type: group.type, + }); + } + this.cars.sort((a, b) => a.z - b.z); + } + + update(dt) { + for (const car of this.cars) { + car.z += car.speed * dt; + } + } + + carsInRange(fromZ, toZ) { + // cars array stays roughly sorted; linear scan is fine at this scale (<200 cars) + return this.cars.filter((c) => c.z >= fromZ && c.z <= toZ); + } + + reset() { + this.cars = []; + } +} diff --git a/out_run_clone/js/main.js b/out_run_clone/js/main.js new file mode 100644 index 0000000..096e3d6 --- /dev/null +++ b/out_run_clone/js/main.js @@ -0,0 +1,394 @@ +import { + WIDTH, HEIGHT, STEP, SEGMENT_LENGTH, ROAD_WIDTH, LANES, CAMERA_DEPTH, + DRAW_DISTANCE, FOG_DENSITY, STATE, GEAR, RADIO_STATIONS, +} from './constants.js'; +import { renderRoad, projectPoint } from './render/road.js'; +import { drawBackground } from './render/background.js'; +import { getSprite, drawRoadsideSprite, drawTrafficCar, drawPlayerCar } from './render/sprites.js'; +import { drawHUD } from './render/hud.js'; +import { Player } from './game/player.js'; +import { Camera } from './game/camera.js'; +import { TrafficManager } from './game/traffic.js'; +import { checkTrafficCollision, isOffRoad } from './game/collision.js'; +import { + ROOT_STAGE, buildStageTrack, getStageConfig, getTotalTimeStart, +} from './game/stages.js'; +import { Input } from './utils/input.js'; +import { AudioManager } from './audio/audio.js'; + +const canvas = document.getElementById('game-canvas'); +const ctx = canvas.getContext('2d'); +canvas.width = WIDTH; +canvas.height = HEIGHT; + +const input = new Input(); +const audio = new AudioManager(); + +let state = STATE.TITLE; +let highScore = Number(localStorage.getItem('outrun_highscore') || 0); +let blink = 0; + +const demoTrack = buildStageTrack(ROOT_STAGE, 1); +let demoPosition = 0; + +const G = { + stageId: ROOT_STAGE, + row: 1, + track: null, + position: 0, + player: new Player(), + camera: new Camera(), + traffic: new TrafficManager(1), + score: 0, + timeLeft: getTotalTimeStart(), + radioIndex: 0, + forkHandled: false, + ending: null, +}; + +function findSegment(position, segments) { + return segments[Math.floor(position / SEGMENT_LENGTH) % segments.length]; +} + +function newGame() { + G.stageId = ROOT_STAGE; + const cfg = getStageConfig(G.stageId); + G.row = cfg.row; + G.track = buildStageTrack(G.stageId, G.row); + G.position = 0; + G.player = new Player(); + G.camera = new Camera(); + G.traffic = new TrafficManager(42); + G.traffic.populate(G.track.length, cfg.trafficDensity); + G.score = 0; + G.timeLeft = getTotalTimeStart(); + G.forkHandled = false; + G.ending = null; +} + +function transitionStage(nextId) { + const cfg = getStageConfig(nextId); + G.stageId = nextId; + G.row = cfg.row; + G.track = buildStageTrack(nextId, cfg.row); + G.position = 0; + G.forkHandled = false; + G.player.x *= 0.3; + G.traffic.populate(G.track.length, cfg.trafficDensity); +} + +function endGame(won, goal) { + state = won ? STATE.COURSE_CLEAR : STATE.GAME_OVER; + G.ending = { won, goal }; + audio.stopEngine(); + if (G.score > highScore) { + highScore = Math.floor(G.score); + localStorage.setItem('outrun_highscore', String(highScore)); + } +} + +function handleGlobalRadioKeys() { + if (input.wasPressed('radio1') && G.radioIndex !== 0) { G.radioIndex = 0; audio.playRadio(0); } + if (input.wasPressed('radio2') && G.radioIndex !== 1) { G.radioIndex = 1; audio.playRadio(1); } + if (input.wasPressed('radio3') && G.radioIndex !== 2) { G.radioIndex = 2; audio.playRadio(2); } +} + +function updateTitle(dt) { + demoPosition += 6200 * dt; + if (demoPosition > demoTrack.length - SEGMENT_LENGTH * 50) demoPosition = 0; + blink += dt; + if (input.wasPressed('start')) { + audio.ensureContext(); + audio.sfx('start'); + state = STATE.RADIO; + } +} + +function updateRadio(dt) { + blink += dt; + if (input.wasPressed('left')) { G.radioIndex = (G.radioIndex + RADIO_STATIONS.length - 1) % RADIO_STATIONS.length; audio.sfx('select'); } + if (input.wasPressed('right')) { G.radioIndex = (G.radioIndex + 1) % RADIO_STATIONS.length; audio.sfx('select'); } + handleGlobalRadioKeys(); + if (input.wasPressed('start')) { + newGame(); + audio.playRadio(G.radioIndex); + audio.sfx('start'); + state = STATE.PLAYING; + } +} + +const SPRITE_CANVAS_SIZE = { w: 240, h: 340 }; + +function nearbySprites(segments, segIndex, radius) { + const list = []; + for (let i = -radius; i <= radius; i++) { + const s = segments[((segIndex + i) % segments.length + segments.length) % segments.length]; + if (s.sprites.length) for (const sp of s.sprites) list.push({ ...sp, z: s.p1.world.z }); + } + return list; +} + +function updatePlaying(dt) { + handleGlobalRadioKeys(); + if (input.wasPressed('gear')) { G.player.shiftGear(); audio.sfx('gear'); } + + const track = G.track; + const segments = track.segments; + const segIndex = Math.floor(G.position / SEGMENT_LENGTH) % segments.length; + const seg = segments[segIndex]; + const offRoad = isOffRoad(G.player.x); + const forceStop = G.timeLeft <= 0; + + G.player.update(dt, input, seg.curve, offRoad, forceStop); + G.camera.update(dt); + + if (!G.player.crashed) { + G.position += G.player.speed * dt; + G.score += (G.player.gear === GEAR.HIGH ? 2 : 1) * G.player.speed * dt * 0.012; + } + + G.traffic.update(dt); + const cars = G.traffic.carsInRange(G.position - SEGMENT_LENGTH * 4, G.position + SEGMENT_LENGTH * DRAW_DISTANCE); + if (!G.player.invulnerable) { + const hit = checkTrafficCollision(G.position, G.player.x, cars); + if (hit) { G.player.crash(); G.camera.triggerShake(); audio.sfx('crash'); } + if (offRoad) { + const nearby = nearbySprites(segments, segIndex, 2); + for (const sp of nearby) { + if (Math.abs(sp.z - G.position) < SEGMENT_LENGTH * 1.5 && Math.abs(G.player.x - sp.offset) < 0.45) { + G.player.crash(); + G.camera.triggerShake(); + audio.sfx('crash'); + break; + } + } + } + } + + audio.setEngineSpeed(G.player.speed / G.player.maxSpeed); + + if (!forceStop) { + G.timeLeft -= dt; + if (G.timeLeft <= 10 && G.timeLeft + dt > 10) audio.sfx('countdown'); + if (G.timeLeft < 0) G.timeLeft = 0; + } else if (G.player.speed <= 2) { + endGame(false); + return; + } + + const forkZ = track.forkAt * SEGMENT_LENGTH; + if (!G.forkHandled && G.position >= forkZ) { + G.forkHandled = true; + const cfg = getStageConfig(G.stageId); + if (cfg.isGoal) { + endGame(true, cfg.goal); + } else { + const goLeft = G.player.x <= 0; + const nextId = goLeft ? cfg.leftChild : cfg.rightChild; + G.timeLeft += cfg.timeBonus; + G.score += 400 * cfg.row; + audio.sfx('checkpoint'); + transitionStage(nextId); + } + } +} + +function updateEndScreen() { + blink += STEP; + if (input.wasPressed('start')) { + state = STATE.TITLE; + demoPosition = 0; + } +} + +function update(dt) { + if (state === STATE.TITLE) updateTitle(dt); + else if (state === STATE.RADIO) updateRadio(dt); + else if (state === STATE.PLAYING) updatePlaying(dt); + else updateEndScreen(dt); + input.consume(); +} + +function drawScene(track, position, player, camera, traffic, theme) { + const shake = camera.shakeOffset(); + ctx.save(); + ctx.translate(shake.x, shake.y); + + const segIndex = Math.floor(position / SEGMENT_LENGTH) % track.segments.length; + const seg = track.segments[segIndex]; + drawBackground(ctx, WIDTH, HEIGHT, theme.sky, position * 0.02, seg.curve * 4000); + + const carsByIndex = new Map(); + if (traffic) { + for (const car of traffic.carsInRange(position - SEGMENT_LENGTH * 4, position + SEGMENT_LENGTH * DRAW_DISTANCE)) { + const idx = Math.floor(car.z / SEGMENT_LENGTH) % track.segments.length; + if (!carsByIndex.has(idx)) carsByIndex.set(idx, []); + carsByIndex.get(idx).push(car); + } + } + + renderRoad(ctx, { + segments: track.segments, + trackLength: track.length, + position, + playerX: player.x, + playerY: 0, + cameraHeight: camera.height, + cameraDepth: camera.depth, + fogDensity: FOG_DENSITY, + drawDistance: DRAW_DISTANCE, + width: WIDTH, + height: HEIGHT, + roadWidth: ROAD_WIDTH, + lanes: LANES, + findSegment: (z) => findSegment(z, track.segments), + onSegment: (segment) => { + if (!segment.visible) return; + for (const sp of segment.sprites) { + const sprite = getSprite(sp.type, sp.palette, SPRITE_CANVAS_SIZE.w, SPRITE_CANVAS_SIZE.h); + const s = projectPoint(sp.offset, segment.p1.world.z, 0, segment.projCamX, segment.projCamY, segment.projCamZ, camera.depth, WIDTH, HEIGHT, ROAD_WIDTH); + if (s.scale > 0) drawRoadsideSprite(ctx, sprite, s.x, s.y, s.scale, ROAD_WIDTH, sp.offset > 0 ? 1 : -1, SPRITE_CANVAS_SIZE.w, SPRITE_CANVAS_SIZE.h); + } + const carsHere = carsByIndex.get(segment.index); + if (carsHere) { + for (const car of carsHere) { + const s = projectPoint(car.x, car.z, 0, segment.projCamX, segment.projCamY, segment.projCamZ, camera.depth, WIDTH, HEIGHT, ROAD_WIDTH); + if (s.scale > 0) drawTrafficCar(ctx, s.x, s.y, s.scale, s.w * 1.15, car.color, car.type); + } + } + }, + }); + + // Player car: fixed screen position, only steering/crash affects its pose. + const px = WIDTH / 2 + player.x * WIDTH * 0.12; + const py = HEIGHT - 118; + drawPlayerCar(ctx, px, py, 1.05, player.steerDir, player.crashed, player.bounce); + + if (player.speed > 0 && !player.crashed) { + ctx.globalAlpha = Math.min(0.35, (player.speed / player.maxSpeed) * 0.4); + ctx.strokeStyle = '#ffffff'; + for (let i = 0; i < 10; i++) { + const lx = Math.random() * WIDTH; + ctx.beginPath(); + ctx.moveTo(lx, 0); + ctx.lineTo(lx + (lx - WIDTH / 2) * 0.15, HEIGHT * 0.4); + ctx.stroke(); + } + ctx.globalAlpha = 1; + } + + ctx.restore(); +} + +function render() { + if (state === STATE.TITLE) { + const demoPlayer = { x: Math.sin(demoPosition * 0.0006) * 0.5, crashed: false, steerDir: 0, bounce: 0, speed: 6000, maxSpeed: 6000 }; + drawScene(demoTrack, demoPosition, demoPlayer, G.camera, null, demoTrack.theme); + ctx.save(); + ctx.fillStyle = 'rgba(0,0,0,0.25)'; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + ctx.textAlign = 'center'; + ctx.fillStyle = '#ff5a1f'; + ctx.strokeStyle = '#4a0f00'; + ctx.lineWidth = 8; + ctx.font = '900 96px "Arial Black", sans-serif'; + ctx.strokeText('OUT RUN', WIDTH / 2, HEIGHT * 0.32); + ctx.fillText('OUT RUN', WIDTH / 2, HEIGHT * 0.32); + ctx.font = '600 22px "Courier New", monospace'; + ctx.fillStyle = '#ffffff'; + ctx.fillText('A BROWSER ARCADE DRIVING EXPERIENCE', WIDTH / 2, HEIGHT * 0.32 + 46); + if (Math.floor(blink * 2) % 2 === 0) { + ctx.font = '700 32px "Courier New", monospace'; + ctx.fillStyle = '#fff200'; + ctx.fillText('PRESS ENTER TO START', WIDTH / 2, HEIGHT * 0.62); + } + ctx.font = '400 18px "Courier New", monospace'; + ctx.fillStyle = '#cccccc'; + ctx.fillText(`HIGH SCORE: ${String(highScore).padStart(7, '0')}`, WIDTH / 2, HEIGHT * 0.7); + ctx.textAlign = 'left'; + ctx.restore(); + } else if (state === STATE.RADIO) { + ctx.fillStyle = '#0a0a1a'; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + ctx.save(); + ctx.textAlign = 'center'; + ctx.fillStyle = '#ffffff'; + ctx.font = '700 40px "Courier New", monospace'; + ctx.fillText('SELECT YOUR MUSIC', WIDTH / 2, HEIGHT * 0.18); + + RADIO_STATIONS.forEach((st, i) => { + const y = HEIGHT * 0.38 + i * 90; + const active = i === G.radioIndex; + ctx.font = active ? '700 34px "Courier New", monospace' : '400 26px "Courier New", monospace'; + ctx.fillStyle = active ? '#fff200' : '#8899aa'; + ctx.fillText(`${active ? 'β–Ά ' : ''}${st.name}${active ? ' β—€' : ''}`, WIDTH / 2, y); + }); + + if (Math.floor(blink * 2) % 2 === 0) { + ctx.font = '700 26px "Courier New", monospace'; + ctx.fillStyle = '#fff200'; + ctx.fillText('PRESS ENTER TO DRIVE', WIDTH / 2, HEIGHT * 0.85); + } + ctx.textAlign = 'left'; + ctx.restore(); + } else if (state === STATE.PLAYING) { + drawScene(G.track, G.position, G.player, G.camera, G.traffic, G.track.theme); + const cfg = getStageConfig(G.stageId); + drawHUD(ctx, WIDTH, HEIGHT, { + score: G.score, + timeLeft: G.timeLeft, + stageName: cfg.name, + stageNumber: cfg.row, + speedKmh: G.player.speedKmh, + gear: G.player.gear, + radioName: RADIO_STATIONS[G.radioIndex].name, + crashed: G.player.crashed, + forkChoice: G.position >= G.track.forkAt * SEGMENT_LENGTH - SEGMENT_LENGTH * 30 && !G.forkHandled && !cfg.isGoal, + }); + } else if (state === STATE.GAME_OVER || state === STATE.COURSE_CLEAR) { + drawScene(G.track, G.position, G.player, G.camera, G.traffic, G.track.theme); + ctx.save(); + ctx.fillStyle = 'rgba(0,0,0,0.55)'; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + ctx.textAlign = 'center'; + if (state === STATE.GAME_OVER) { + ctx.fillStyle = '#ff3333'; + ctx.font = '900 72px "Arial Black", sans-serif'; + ctx.fillText('GAME OVER', WIDTH / 2, HEIGHT * 0.38); + } else { + ctx.fillStyle = '#3dff8a'; + ctx.font = '900 56px "Arial Black", sans-serif'; + ctx.fillText('CONGRATULATIONS!', WIDTH / 2, HEIGHT * 0.32); + ctx.font = '600 26px "Courier New", monospace'; + ctx.fillStyle = '#ffffff'; + ctx.fillText(`YOU REACHED GOAL ${G.ending?.goal || ''}`, WIDTH / 2, HEIGHT * 0.32 + 50); + } + ctx.font = '600 30px "Courier New", monospace'; + ctx.fillStyle = '#ffffff'; + ctx.fillText(`SCORE: ${Math.floor(G.score)}`, WIDTH / 2, HEIGHT * 0.55); + ctx.fillText(`HIGH SCORE: ${highScore}`, WIDTH / 2, HEIGHT * 0.62); + if (Math.floor(blink * 2) % 2 === 0) { + ctx.font = '700 26px "Courier New", monospace'; + ctx.fillStyle = '#fff200'; + ctx.fillText('PRESS ENTER FOR TITLE', WIDTH / 2, HEIGHT * 0.78); + } + ctx.textAlign = 'left'; + ctx.restore(); + } +} + +let lastTime = performance.now(); +let acc = 0; +function loop(now) { + const dt = Math.min(0.05, (now - lastTime) / 1000); + lastTime = now; + acc += dt; + while (acc >= STEP) { + update(STEP); + acc -= STEP; + } + render(); + requestAnimationFrame(loop); +} + +requestAnimationFrame((t) => { lastTime = t; requestAnimationFrame(loop); }); diff --git a/out_run_clone/js/render/background.js b/out_run_clone/js/render/background.js new file mode 100644 index 0000000..5c8ba43 --- /dev/null +++ b/out_run_clone/js/render/background.js @@ -0,0 +1,56 @@ +// Parallax sky/hill/cloud layers. Purely procedural gradients + shapes, +// scrolled opposite to road curvature to sell the sense of turning. + +function hillPath(ctx, width, height, baseY, amplitude, seedOffset, color) { + ctx.fillStyle = color; + ctx.beginPath(); + ctx.moveTo(0, height); + const points = 8; + for (let i = 0; i <= points; i++) { + const px = (i / points) * width; + const py = baseY + Math.sin(i * 1.3 + seedOffset) * amplitude; + ctx.lineTo(px, py); + } + ctx.lineTo(width, height); + ctx.closePath(); + ctx.fill(); +} + +export function drawBackground(ctx, width, height, theme, skyOffset, hillOffset) { + const t = theme; + const horizon = height * 0.42; + + const grad = ctx.createLinearGradient(0, 0, 0, horizon); + grad.addColorStop(0, t.skyTop); + grad.addColorStop(1, t.skyBottom); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, width, horizon); + + // Sun/moon disc + ctx.fillStyle = t.sun; + ctx.beginPath(); + ctx.arc(width * 0.78, horizon * 0.55, height * 0.09, 0, Math.PI * 2); + ctx.fill(); + + // Clouds (simple parallax ellipses, wrap with skyOffset) + ctx.fillStyle = t.cloud; + for (let i = 0; i < 6; i++) { + const baseX = (i * 260 - (skyOffset * 0.5) % 1560 + 1560 * 3) % 1560 - 260; + const cy = horizon * (0.18 + (i % 3) * 0.12); + ctx.globalAlpha = 0.8; + ctx.beginPath(); + ctx.ellipse(baseX, cy, 70, 22, 0, 0, Math.PI * 2); + ctx.ellipse(baseX + 40, cy + 6, 45, 16, 0, 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalAlpha = 1; + + // Distant hills / terrain silhouette layers, offset by curve-driven hillOffset + hillPath(ctx, width, height, horizon * 0.92, height * 0.03, hillOffset * 0.001, t.hillFar); + hillPath(ctx, width, height, horizon * 0.97, height * 0.045, hillOffset * 0.002 + 2, t.hillNear); + + // Ground fill below horizon (road renderer draws grass over this, but this + // avoids a seam while segments stream in) + ctx.fillStyle = t.ground; + ctx.fillRect(0, horizon, width, height - horizon); +} diff --git a/out_run_clone/js/render/hud.js b/out_run_clone/js/render/hud.js new file mode 100644 index 0000000..64d5afd --- /dev/null +++ b/out_run_clone/js/render/hud.js @@ -0,0 +1,73 @@ +// Speedometer, timer, score, gear indicator, stage name overlay. + +function digits(n, len) { + return String(Math.max(0, Math.floor(n))).padStart(len, '0'); +} + +export function drawHUD(ctx, width, height, state) { + ctx.save(); + ctx.textBaseline = 'top'; + ctx.font = '700 28px "Courier New", monospace'; + ctx.fillStyle = '#fff200'; + ctx.strokeStyle = '#000'; + ctx.lineWidth = 4; + const scoreText = `SCORE ${digits(state.score, 7)}`; + ctx.strokeText(scoreText, 24, 20); + ctx.fillText(scoreText, 24, 20); + + const timeText = `TIME ${digits(state.timeLeft, 2)}`; + const timeColor = state.timeLeft <= 10 ? '#ff3333' : '#fff200'; + ctx.fillStyle = timeColor; + ctx.strokeText(timeText, width - 200, 20); + ctx.fillText(timeText, width - 200, 20); + + ctx.font = '600 18px "Courier New", monospace'; + ctx.fillStyle = '#ffffff'; + ctx.strokeText(state.stageName.toUpperCase(), 24, 58); + ctx.fillText(state.stageName.toUpperCase(), 24, 58); + ctx.strokeText(`STAGE ${state.stageNumber}/5`, width - 200, 58); + ctx.fillText(`STAGE ${state.stageNumber}/5`, width - 200, 58); + + // Speedometer + const sx = 110, sy = height - 90; + ctx.font = '700 46px "Courier New", monospace'; + ctx.fillStyle = '#ffffff'; + const speedText = digits(state.speedKmh, 3); + ctx.strokeText(speedText, sx - 60, sy - 20); + ctx.fillText(speedText, sx - 60, sy - 20); + ctx.font = '400 14px "Courier New", monospace'; + ctx.fillText('KM/H', sx - 60, sy + 26); + + // Gear + ctx.font = '700 30px "Courier New", monospace'; + ctx.fillStyle = state.gear === 'HIGH' ? '#ff6a3d' : '#3dd6ff'; + const gearText = `GEAR ${state.gear === 'HIGH' ? 'H' : 'L'}`; + ctx.strokeText(gearText, width - 200, height - 60); + ctx.fillText(gearText, width - 200, height - 60); + + // Radio station + ctx.font = '400 16px "Courier New", monospace'; + ctx.fillStyle = '#9be7ff'; + ctx.strokeText(`β™ͺ ${state.radioName}`, 24, height - 40); + ctx.fillText(`β™ͺ ${state.radioName}`, 24, height - 40); + + if (state.crashed) { + ctx.font = '700 42px "Courier New", monospace'; + ctx.fillStyle = '#ff3333'; + ctx.textAlign = 'center'; + ctx.strokeText('CRASH!', width / 2, height / 2 - 20); + ctx.fillText('CRASH!', width / 2, height / 2 - 20); + ctx.textAlign = 'left'; + } + + if (state.forkChoice) { + ctx.font = '700 24px "Courier New", monospace'; + ctx.fillStyle = '#ffffff'; + ctx.textAlign = 'center'; + ctx.strokeText('β—€ STEER TO CHOOSE ROUTE β–Ά', width / 2, height * 0.3); + ctx.fillText('β—€ STEER TO CHOOSE ROUTE β–Ά', width / 2, height * 0.3); + ctx.textAlign = 'left'; + } + + ctx.restore(); +} diff --git a/out_run_clone/js/render/road.js b/out_run_clone/js/render/road.js new file mode 100644 index 0000000..9f51117 --- /dev/null +++ b/out_run_clone/js/render/road.js @@ -0,0 +1,148 @@ +// Pseudo-3D road projection & drawing. Segments are projected from camera +// space to screen space and painted back-to-front (nearest first) with a +// running Y-clip, which is what naturally hides road behind hill crests. + +import { project } from '../utils/math.js'; +import { SEGMENT_LENGTH } from '../constants.js'; + +function rumbleWidth(projectedWidth, lanes) { + return projectedWidth / Math.max(6, 2 * lanes); +} + +function laneMarkerWidth(projectedWidth, lanes) { + return projectedWidth / Math.max(32, 8 * lanes); +} + +function polygon(ctx, x1, y1, x2, y2, x3, y3, x4, y4, color) { + ctx.fillStyle = color; + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.lineTo(x3, y3); + ctx.lineTo(x4, y4); + ctx.closePath(); + ctx.fill(); +} + +function drawSegmentSlice(ctx, width, lanes, x1, y1, w1, x2, y2, w2, color, fog) { + const r1 = rumbleWidth(w1, lanes); + const r2 = rumbleWidth(w2, lanes); + const l1 = laneMarkerWidth(w1, lanes); + const l2 = laneMarkerWidth(w2, lanes); + + ctx.fillStyle = color.grass; + ctx.fillRect(0, y2, width, y1 - y2); + + polygon(ctx, x1 - w1 - r1, y1, x1 - w1, y1, x2 - w2, y2, x2 - w2 - r2, y2, color.rumble); + polygon(ctx, x1 + w1 + r1, y1, x1 + w1, y1, x2 + w2, y2, x2 + w2 + r2, y2, color.rumble); + polygon(ctx, x1 - w1, y1, x1 + w1, y1, x2 + w2, y2, x2 - w2, y2, color.road); + + if (color.lane) { + const lanaW1 = w1 * 2 / lanes; + const lanaW2 = w2 * 2 / lanes; + let lanex1 = x1 - w1 + lanaW1; + let lanex2 = x2 - w2 + lanaW2; + for (let lane = 1; lane < lanes; lane++) { + polygon(ctx, lanex1 - l1 / 2, y1, lanex1 + l1 / 2, y1, lanex2 + l2 / 2, y2, lanex2 - l2 / 2, y2, color.lane); + lanex1 += lanaW1; + lanex2 += lanaW2; + } + } + + if (fog < 1) { + ctx.globalAlpha = 1 - fog; + ctx.fillStyle = '#e8f0f5'; + ctx.fillRect(0, y2, width, y1 - y2); + ctx.globalAlpha = 1; + } +} + +// 1 = fully clear (near camera), approaches 0 towards the draw-distance horizon. +export function fogAt(depthIndex, drawDistance, density) { + const t = depthIndex / drawDistance; + return Math.max(0, Math.min(1, Math.exp(-density * t * t))); +} + +// Projects an arbitrary world point (given as a lateral road-width fraction, +// absolute world Z, and elevation) using the same camera parameters a +// segment was projected with. Lets roadside sprites / traffic cars line up +// with the road even mid-curve. +export function projectPoint(xFraction, worldZ, worldY, camX, camY, camZ, cameraDepth, width, height, roadWidth) { + const p = { world: { x: xFraction * roadWidth, y: worldY, z: worldZ }, camera: {}, screen: {} }; + project(p, camX, camY, camZ, cameraDepth, width, height, roadWidth); + return p.screen; +} + +// Renders the visible road slice and invokes onSegment for every segment +// (farthest-to-nearest) so caller code can layer roadside objects, traffic, +// etc. at the correct point in the painter's algorithm. +export function renderRoad(ctx, opts) { + const { + segments, trackLength, position, playerX, playerY, + cameraHeight, cameraDepth, fogDensity, drawDistance, + width, height, roadWidth, lanes, findSegment, onSegment, + } = opts; + + const baseSegment = findSegment(position); + const basePercent = (position % SEGMENT_LENGTH) / SEGMENT_LENGTH; + let maxy = height; + let x = 0; + let dx = -(baseSegment.curve * basePercent); + + const cameraX = playerX * roadWidth; + const cameraZ = position; + const camY = cameraHeight + playerY; + + for (let n = 0; n < drawDistance; n++) { + const segment = segments[(baseSegment.index + n) % segments.length]; + segment.looped = segment.index < baseSegment.index; + segment.fog = fogAt(n, drawDistance, fogDensity); + segment.clip = maxy; + segment.visible = false; + + const zOffset = segment.looped ? trackLength : 0; + const p1CamX = cameraX - x; + const p1CamZ = cameraZ - zOffset; + + project(segment.p1, p1CamX, camY, p1CamZ, cameraDepth, width, height, roadWidth); + project(segment.p2, cameraX - x - dx, camY, p1CamZ, cameraDepth, width, height, roadWidth); + + // Stash the camera params used for THIS segment so onSegment can project + // roadside sprites / cars that live at this depth with matching curve offset. + segment.projCamX = p1CamX; + segment.projCamY = camY; + segment.projCamZ = p1CamZ; + + x += dx; + dx += segment.curve; + + segment.screenX1 = segment.p1.screen.x; + segment.screenY1 = segment.p1.screen.y; + segment.screenW1 = segment.p1.screen.w; + segment.screenX2 = segment.p2.screen.x; + segment.screenY2 = segment.p2.screen.y; + segment.screenW2 = segment.p2.screen.w; + segment.scale = segment.p2.screen.scale; + + if (segment.p1.camera.z <= cameraDepth) continue; // behind camera + if (segment.screenY2 >= segment.screenY1) continue; // occluded by nearer segment + if (segment.screenY1 >= maxy) continue; + + drawSegmentSlice( + ctx, width, lanes, + segment.screenX1, segment.screenY1, segment.screenW1, + segment.screenX2, segment.screenY2, segment.screenW2, + segment.color, segment.fog, + ); + + maxy = segment.screenY1; + segment.visible = true; + } + + // Sprites/traffic are painted farthest-to-nearest so nearer objects + // correctly overlap farther ones. + for (let n = drawDistance - 1; n >= 0; n--) { + const segment = segments[(baseSegment.index + n) % segments.length]; + if (onSegment) onSegment(segment, n); + } +} diff --git a/out_run_clone/js/render/sprites.js b/out_run_clone/js/render/sprites.js new file mode 100644 index 0000000..a881618 --- /dev/null +++ b/out_run_clone/js/render/sprites.js @@ -0,0 +1,237 @@ +// Procedurally-drawn vector sprites (trees, signs, rocks, buildings, cars). +// No external image assets β€” everything is drawn with canvas primitives and +// cached to an offscreen canvas per (type, variant, theme) the first time +// it's requested, then blitted+scaled like a normal sprite would be. + +const cache = new Map(); + +function getCanvas(w, h) { + const c = document.createElement('canvas'); + c.width = Math.max(1, Math.ceil(w)); + c.height = Math.max(1, Math.ceil(h)); + return c; +} + +function drawTree(ctx, w, h, palette) { + const trunkW = w * 0.12; + ctx.fillStyle = palette.trunk || '#5a3a22'; + ctx.fillRect(w / 2 - trunkW / 2, h * 0.6, trunkW, h * 0.4); + ctx.fillStyle = palette.leaf || '#0d7a0d'; + for (let i = 0; i < 3; i++) { + const cy = h * (0.55 - i * 0.18); + const r = w * (0.5 - i * 0.08); + ctx.beginPath(); + ctx.ellipse(w / 2, cy, r, r * 0.85, 0, 0, Math.PI * 2); + ctx.fill(); + } +} + +function drawPalm(ctx, w, h, palette) { + ctx.strokeStyle = palette.trunk || '#8a6a3a'; + ctx.lineWidth = w * 0.09; + ctx.beginPath(); + ctx.moveTo(w * 0.5, h); + ctx.quadraticCurveTo(w * 0.35, h * 0.55, w * 0.55, h * 0.15); + ctx.stroke(); + ctx.fillStyle = palette.leaf || '#0d8a0d'; + const tipX = w * 0.55, tipY = h * 0.15; + for (let i = 0; i < 6; i++) { + const angle = (Math.PI * 2 * i) / 6 - Math.PI / 2; + ctx.beginPath(); + ctx.moveTo(tipX, tipY); + ctx.quadraticCurveTo( + tipX + Math.cos(angle) * w * 0.5, tipY + Math.sin(angle) * h * 0.25 - h * 0.05, + tipX + Math.cos(angle) * w * 0.65, tipY + Math.sin(angle) * h * 0.35, + ); + ctx.quadraticCurveTo(tipX, tipY + h * 0.05, tipX, tipY); + ctx.fill(); + } +} + +function drawPine(ctx, w, h, palette) { + ctx.fillStyle = palette.trunk || '#4a3222'; + ctx.fillRect(w / 2 - w * 0.05, h * 0.85, w * 0.1, h * 0.15); + ctx.fillStyle = palette.leaf || '#1a5c3a'; + for (let i = 0; i < 4; i++) { + const tierH = h * 0.28; + const y = h * 0.15 + i * tierH * 0.62; + const wgt = w * (0.55 - i * 0.09); + ctx.beginPath(); + ctx.moveTo(w / 2, y); + ctx.lineTo(w / 2 - wgt, y + tierH); + ctx.lineTo(w / 2 + wgt, y + tierH); + ctx.closePath(); + ctx.fill(); + } +} + +function drawRock(ctx, w, h, palette) { + ctx.fillStyle = palette.rock || '#8a7a6a'; + ctx.beginPath(); + ctx.moveTo(w * 0.05, h); + ctx.lineTo(w * 0.1, h * 0.45); + ctx.lineTo(w * 0.4, h * 0.1); + ctx.lineTo(w * 0.75, h * 0.25); + ctx.lineTo(w * 0.95, h * 0.6); + ctx.lineTo(w * 0.9, h); + ctx.closePath(); + ctx.fill(); +} + +function drawCactus(ctx, w, h, palette) { + ctx.fillStyle = palette.leaf || '#2f8f4f'; + ctx.fillRect(w * 0.4, h * 0.15, w * 0.2, h * 0.85); + ctx.fillRect(w * 0.08, h * 0.35, w * 0.2, h * 0.4); + ctx.fillRect(w * 0.72, h * 0.25, w * 0.2, h * 0.5); +} + +function drawSign(ctx, w, h, palette) { + ctx.fillStyle = '#cccccc'; + ctx.fillRect(w * 0.46, h * 0.3, w * 0.08, h * 0.7); + ctx.fillStyle = palette.sign || '#1155cc'; + ctx.fillRect(w * 0.05, 0, w * 0.9, h * 0.35); + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = Math.max(1, w * 0.02); + ctx.strokeRect(w * 0.05, 0, w * 0.9, h * 0.35); +} + +function drawBuilding(ctx, w, h, palette) { + ctx.fillStyle = palette.wall || '#c9a67a'; + ctx.fillRect(0, h * 0.15, w, h * 0.85); + ctx.fillStyle = palette.roof || '#8a4a3a'; + ctx.beginPath(); + ctx.moveTo(-w * 0.05, h * 0.18); + ctx.lineTo(w / 2, 0); + ctx.lineTo(w * 1.05, h * 0.18); + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = palette.window || '#4a6a8a'; + for (let r = 0; r < 3; r++) { + for (let c = 0; c < 2; c++) { + ctx.fillRect(w * (0.15 + c * 0.45), h * (0.32 + r * 0.22), w * 0.25, h * 0.14); + } + } +} + +function drawWindmill(ctx, w, h, palette) { + ctx.fillStyle = palette.wall || '#e8d9b0'; + ctx.beginPath(); + ctx.moveTo(w * 0.35, h); + ctx.lineTo(w * 0.42, h * 0.35); + ctx.lineTo(w * 0.58, h * 0.35); + ctx.lineTo(w * 0.65, h); + ctx.closePath(); + ctx.fill(); + ctx.strokeStyle = palette.blade || '#8a7a5a'; + ctx.lineWidth = w * 0.05; + const cx = w / 2, cy = h * 0.32; + for (let i = 0; i < 4; i++) { + const a = (Math.PI / 2) * i + Math.PI / 6; + ctx.beginPath(); + ctx.moveTo(cx, cy); + ctx.lineTo(cx + Math.cos(a) * w * 0.4, cy + Math.sin(a) * w * 0.4); + ctx.stroke(); + } +} + +const DRAWERS = { + tree: drawTree, + palm: drawPalm, + pine: drawPine, + rock: drawRock, + cactus: drawCactus, + sign: drawSign, + building: drawBuilding, + windmill: drawWindmill, +}; + +export function getSprite(type, palette = {}, w = 200, h = 300) { + const key = `${type}:${JSON.stringify(palette)}:${w}x${h}`; + if (cache.has(key)) return cache.get(key); + const canvas = getCanvas(w, h); + const ctx = canvas.getContext('2d'); + (DRAWERS[type] || drawTree)(ctx, w, h, palette); + cache.set(key, canvas); + return canvas; +} + +// Draws a roadside sprite at screen position (x,y) = bottom-center, scaled +// by `scale`, offset sideways by `offset` road-widths (matches classic +// Outrun-style roadside object placement). +export function drawRoadsideSprite(ctx, sprite, x, y, scale, roadWidth, offset, destW, destH) { + const spriteScale = scale * roadWidth / 2; + const w = destW * spriteScale * 0.9; + const h = destH * spriteScale * 0.9; + const destX = x + (spriteScale * offset * destW) - (offset < 0 ? w : 0); + const destY = y - h; + ctx.drawImage(sprite, destX, destY, w, h); +} + +export function drawTrafficCar(ctx, x, y, scale, width, color, type = 'sedan') { + const w = width; + const h = width * 0.62; + ctx.save(); + ctx.translate(x, y); + ctx.fillStyle = 'rgba(0,0,0,0.35)'; + ctx.beginPath(); + ctx.ellipse(0, h * 0.05, w * 0.42, h * 0.14, 0, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = color; + roundRect(ctx, -w / 2, -h * 0.85, w, h * 0.75, h * 0.18); + ctx.fill(); + + ctx.fillStyle = 'rgba(20,20,25,0.9)'; + if (type === 'truck') { + roundRect(ctx, -w * 0.3, -h * 1.3, w * 0.6, h * 0.55, h * 0.08); + } else { + roundRect(ctx, -w * 0.32, -h * 1.15, w * 0.64, h * 0.42, h * 0.12); + } + ctx.fill(); + + ctx.fillStyle = '#ffcc33'; + ctx.fillRect(-w * 0.42, -h * 0.55, w * 0.12, h * 0.14); + ctx.fillRect(w * 0.3, -h * 0.55, w * 0.12, h * 0.14); + ctx.restore(); +} + +export function drawPlayerCar(ctx, x, y, scale, steer, crashed, bounce) { + const w = 420 * scale; + const h = 260 * scale; + ctx.save(); + ctx.translate(x, y + bounce); + if (crashed) ctx.rotate(Math.sin(performance.now() / 60) * 0.6); + else ctx.rotate(steer * 0.08); + + ctx.fillStyle = 'rgba(0,0,0,0.4)'; + ctx.beginPath(); + ctx.ellipse(0, h * 0.32, w * 0.46, h * 0.12, 0, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = '#c81010'; + roundRect(ctx, -w / 2, -h * 0.25, w, h * 0.5, h * 0.15); + ctx.fill(); + + ctx.fillStyle = '#f4f4f4'; + roundRect(ctx, -w * 0.28, -h * 0.55, w * 0.56, h * 0.45, h * 0.1); + ctx.fill(); + + ctx.fillStyle = '#222'; + ctx.fillRect(-w * 0.5, h * 0.1, w * 0.15, h * 0.18); + ctx.fillRect(w * 0.35, h * 0.1, w * 0.15, h * 0.18); + + ctx.fillStyle = '#ffe38a'; + ctx.fillRect(-w * 0.46, -h * 0.05, w * 0.1, h * 0.1); + ctx.fillRect(w * 0.36, -h * 0.05, w * 0.1, h * 0.1); + ctx.restore(); +} + +function roundRect(ctx, x, y, w, h, r) { + 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(); +} diff --git a/out_run_clone/js/utils/input.js b/out_run_clone/js/utils/input.js new file mode 100644 index 0000000..9c6fb60 --- /dev/null +++ b/out_run_clone/js/utils/input.js @@ -0,0 +1,77 @@ +// Keyboard + touch input handler. Exposes a small boolean action map so the +// rest of the game never touches raw key codes. + +const KEY_MAP = { + ArrowUp: 'accelerate', + KeyW: 'accelerate', + ArrowDown: 'brake', + KeyS: 'brake', + ArrowLeft: 'left', + KeyA: 'left', + ArrowRight: 'right', + KeyD: 'right', + Space: 'gear', + Enter: 'start', + Digit1: 'radio1', + Digit2: 'radio2', + Digit3: 'radio3', +}; + +export class Input { + constructor(target = window) { + this.state = {}; + this.pressed = {}; // edge-triggered, cleared each frame via consume() + this._down = (e) => { + const action = KEY_MAP[e.code]; + if (!action) return; + if (['accelerate', 'brake', 'left', 'right', 'gear', 'start'].includes(action)) e.preventDefault(); + if (!this.state[action]) this.pressed[action] = true; + this.state[action] = true; + }; + this._up = (e) => { + const action = KEY_MAP[e.code]; + if (!action) return; + this.state[action] = false; + }; + target.addEventListener('keydown', this._down); + target.addEventListener('keyup', this._up); + + this._setupTouch(); + } + + _setupTouch() { + this.touch = { left: false, right: false, accelerate: false, brake: false, gear: false, start: false }; + const el = document.getElementById('touch-controls'); + if (!el) return; + const bind = (id, action) => { + const btn = document.getElementById(id); + if (!btn) return; + const on = (e) => { e.preventDefault(); this.touch[action] = true; if (!this.state[action]) this.pressed[action] = true; this.state[action] = true; }; + const off = (e) => { e.preventDefault(); this.touch[action] = false; this.state[action] = false; }; + btn.addEventListener('touchstart', on, { passive: false }); + btn.addEventListener('touchend', off, { passive: false }); + btn.addEventListener('touchcancel', off, { passive: false }); + btn.addEventListener('mousedown', on); + btn.addEventListener('mouseup', off); + btn.addEventListener('mouseleave', off); + }; + bind('btn-left', 'left'); + bind('btn-right', 'right'); + bind('btn-accel', 'accelerate'); + bind('btn-brake', 'brake'); + bind('btn-gear', 'gear'); + bind('btn-start', 'start'); + } + + isDown(action) { + return !!this.state[action]; + } + + wasPressed(action) { + return !!this.pressed[action]; + } + + consume() { + this.pressed = {}; + } +} diff --git a/out_run_clone/js/utils/math.js b/out_run_clone/js/utils/math.js new file mode 100644 index 0000000..fbde0ab --- /dev/null +++ b/out_run_clone/js/utils/math.js @@ -0,0 +1,62 @@ +// Interpolation, easing, and small numeric helpers used across the engine. + +export function clamp(value, min, max) { + return Math.max(min, Math.min(value, max)); +} + +export function lerp(a, b, t) { + return a + (b - a) * t; +} + +export function easeIn(a, b, percent) { + return a + (b - a) * Math.pow(percent, 2); +} + +export function easeOut(a, b, percent) { + return a + (b - a) * (1 - Math.pow(1 - percent, 2)); +} + +export function easeInOut(a, b, percent) { + return a + (b - a) * (-Math.cos(percent * Math.PI) / 2 + 0.5); +} + +export function percentRemaining(n, total) { + return (n % total) / total; +} + +export function accelerate(v, accel, dt) { + return v + accel * dt; +} + +// Deterministic PRNG (mulberry32) so stage layouts are reproducible per seed. +export class SeededRandom { + constructor(seed) { + this.state = seed >>> 0; + } + + next() { + let t = (this.state += 0x6d2b79f5); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + } + + range(min, max) { + return min + this.next() * (max - min); + } + + pick(arr) { + return arr[Math.floor(this.next() * arr.length) % arr.length]; + } +} + +// Projects a world-space point (relative to camera) into screen space. +export function project(p, cameraX, cameraY, cameraZ, cameraDepth, width, height, roadWidth) { + p.camera.x = (p.world.x || 0) - cameraX; + p.camera.y = (p.world.y || 0) - cameraY; + p.camera.z = (p.world.z || 0) - cameraZ; + p.screen.scale = cameraDepth / p.camera.z; + p.screen.x = Math.round((width / 2) + (p.screen.scale * p.camera.x * width / 2)); + p.screen.y = Math.round((height / 2) - (p.screen.scale * p.camera.y * height / 2)); + p.screen.w = Math.round(p.screen.scale * roadWidth * width / 2); +}