From b070bab2e3547f118ea053884c9dc3cd9a0077c7 Mon Sep 17 00:00:00 2001 From: Developer Date: Sun, 25 Jan 2026 19:28:35 -0600 Subject: [PATCH] Initial commit: Western Shooter - Complete implementation Retro western vertical shooter inspired by Gun.Smoke, built with TypeScript and WebGL2. Features 3-direction shooting, vertical scrolling, economy/shop loop, boss fights, and CRT shader effects. Phases implemented: - Phase 1: Engine skeleton (WebGL2 renderer, fixed timestep loop, input) - Phase 2: Shooting identity (3-dir shooting, bullet pools, collision) - Phase 3: Enemies & patterns (JSON waves, 4 enemy types, parallax bg) - Phase 4: Economy loop (pickups, shop, upgrades, HUD) - Phase 5: Boss system (3 bosses, wanted posters, multi-phase attacks) - Phase 6: Shader layer (CRT effects, bloom, scanlines, screen shake) - Phase 7: Performance (VAO batching, O(1) allocation, stress testing) Co-Authored-By: Claude Opus 4.5 --- .gitignore | 19 + bunfig.toml | 5 + data/.gitkeep | 0 data/stage1.json | 65 +++ index.html | 35 ++ package.json | 14 + serve.ts | 85 ++++ src/constants.ts | 19 + src/core/loop.ts | 50 ++ src/core/time.ts | 39 ++ src/entities/player.ts | 161 ++++++ src/game.ts | 624 ++++++++++++++++++++++++ src/input/gamepad.ts | 90 ++++ src/input/input-system.ts | 45 ++ src/input/keyboard.ts | 69 +++ src/main.ts | 22 + src/rendering/framebuffer.ts | 72 +++ src/rendering/renderer.ts | 374 ++++++++++++++ src/rendering/shaders/post-process.frag | 169 +++++++ src/rendering/shaders/post-process.vert | 9 + src/rendering/shaders/sprite.frag | 7 + src/rendering/shaders/sprite.vert | 22 + src/rendering/shaders/upscale.frag | 11 + src/rendering/shaders/upscale.vert | 9 + src/rendering/sprite-batch.ts | 236 +++++++++ src/rendering/webgl-context.ts | 71 +++ src/systems/boss-system.ts | 386 +++++++++++++++ src/systems/bullet-system.ts | 326 +++++++++++++ src/systems/collision-system.ts | 104 ++++ src/systems/economy-system.ts | 301 ++++++++++++ src/systems/enemy-system.ts | 290 +++++++++++ src/systems/enemy-types.ts | 65 +++ src/systems/hud-system.ts | 142 ++++++ src/systems/perf-monitor.ts | 131 +++++ src/systems/pickup-system.ts | 182 +++++++ src/systems/screen-effects.ts | 105 ++++ src/systems/scroll-system.ts | 173 +++++++ src/systems/shop-system.ts | 167 +++++++ src/systems/wave-spawner.ts | 120 +++++ src/types/index.ts | 57 +++ tsconfig.json | 20 + 41 files changed, 4891 insertions(+) create mode 100644 .gitignore create mode 100644 bunfig.toml create mode 100644 data/.gitkeep create mode 100644 data/stage1.json create mode 100644 index.html create mode 100644 package.json create mode 100644 serve.ts create mode 100644 src/constants.ts create mode 100644 src/core/loop.ts create mode 100644 src/core/time.ts create mode 100644 src/entities/player.ts create mode 100644 src/game.ts create mode 100644 src/input/gamepad.ts create mode 100644 src/input/input-system.ts create mode 100644 src/input/keyboard.ts create mode 100644 src/main.ts create mode 100644 src/rendering/framebuffer.ts create mode 100644 src/rendering/renderer.ts create mode 100644 src/rendering/shaders/post-process.frag create mode 100644 src/rendering/shaders/post-process.vert create mode 100644 src/rendering/shaders/sprite.frag create mode 100644 src/rendering/shaders/sprite.vert create mode 100644 src/rendering/shaders/upscale.frag create mode 100644 src/rendering/shaders/upscale.vert create mode 100644 src/rendering/sprite-batch.ts create mode 100644 src/rendering/webgl-context.ts create mode 100644 src/systems/boss-system.ts create mode 100644 src/systems/bullet-system.ts create mode 100644 src/systems/collision-system.ts create mode 100644 src/systems/economy-system.ts create mode 100644 src/systems/enemy-system.ts create mode 100644 src/systems/enemy-types.ts create mode 100644 src/systems/hud-system.ts create mode 100644 src/systems/perf-monitor.ts create mode 100644 src/systems/pickup-system.ts create mode 100644 src/systems/screen-effects.ts create mode 100644 src/systems/scroll-system.ts create mode 100644 src/systems/shop-system.ts create mode 100644 src/systems/wave-spawner.ts create mode 100644 src/types/index.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c4b9a3a --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# Lock files +bun.lock + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..b88dab4 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,5 @@ +[serve] +port = 3000 + +[build] +target = "browser" diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/stage1.json b/data/stage1.json new file mode 100644 index 0000000..f8dfb3b --- /dev/null +++ b/data/stage1.json @@ -0,0 +1,65 @@ +{ + "id": "stage1", + "name": "Dusty Trail", + "scrollSpeed": 20, + "waves": [ + { + "id": "wave1", + "duration": 5, + "spawns": [ + { "type": "bandit", "x": "center", "delay": 0 }, + { "type": "bandit", "x": "left", "delay": 0.5 }, + { "type": "bandit", "x": "right", "delay": 0.5 } + ] + }, + { + "id": "wave2", + "duration": 6, + "spawns": [ + { "type": "gunman", "x": "left", "delay": 0 }, + { "type": "gunman", "x": "right", "delay": 0 }, + { "type": "bandit", "x": "center", "delay": 1 }, + { "type": "bandit", "x": "random", "delay": 2 }, + { "type": "bandit", "x": "random", "delay": 2.5 } + ] + }, + { + "id": "wave3", + "duration": 7, + "spawns": [ + { "type": "rifleman", "x": "center", "delay": 0 }, + { "type": "bandit", "x": "left", "delay": 0.5 }, + { "type": "bandit", "x": "right", "delay": 0.5 }, + { "type": "gunman", "x": "random", "delay": 2 }, + { "type": "gunman", "x": "random", "delay": 3 } + ] + }, + { + "id": "wave4", + "duration": 8, + "spawns": [ + { "type": "dynamite", "x": "center", "delay": 0 }, + { "type": "bandit", "x": "left", "delay": 0 }, + { "type": "bandit", "x": "right", "delay": 0 }, + { "type": "rifleman", "x": "left", "delay": 2 }, + { "type": "rifleman", "x": "right", "delay": 2 }, + { "type": "gunman", "x": "center", "delay": 4 } + ] + }, + { + "id": "wave5", + "duration": 10, + "spawns": [ + { "type": "gunman", "x": "left", "delay": 0 }, + { "type": "gunman", "x": "right", "delay": 0 }, + { "type": "gunman", "x": "center", "delay": 0 }, + { "type": "rifleman", "x": "random", "delay": 2 }, + { "type": "rifleman", "x": "random", "delay": 2.5 }, + { "type": "dynamite", "x": "random", "delay": 4 }, + { "type": "bandit", "x": "random", "delay": 5 }, + { "type": "bandit", "x": "random", "delay": 5.5 }, + { "type": "bandit", "x": "random", "delay": 6 } + ] + } + ] +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..bdbb2b9 --- /dev/null +++ b/index.html @@ -0,0 +1,35 @@ + + + + + + Western Shooter + + + + + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..4a298a3 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "western-shooter", + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "bun run --hot src/main.ts", + "build": "bun build src/main.ts --outdir=dist --minify", + "serve": "bun run serve.ts" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.3.0" + } +} diff --git a/serve.ts b/serve.ts new file mode 100644 index 0000000..d67190c --- /dev/null +++ b/serve.ts @@ -0,0 +1,85 @@ +import path from 'path'; + +// Build the TypeScript bundle +async function buildBundle() { + const result = await Bun.build({ + entrypoints: ['./src/main.ts'], + outdir: './dist', + target: 'browser', + format: 'esm', + sourcemap: 'inline', + minify: false, + }); + + if (!result.success) { + console.error('Build failed:'); + for (const log of result.logs) { + console.error(log); + } + throw new Error('Build failed'); + } + + console.log('Bundle built successfully'); + return result; +} + +// Initial build +await buildBundle(); + +const server = Bun.serve({ + port: 3000, + hostname: '0.0.0.0', // Bind to all interfaces + async fetch(req) { + const url = new URL(req.url); + let filePath = url.pathname; + + // Default to index.html + if (filePath === '/') { + filePath = '/index.html'; + } + + // Redirect main.ts to bundled version + if (filePath === '/src/main.ts') { + filePath = '/dist/main.js'; + } + + // Determine content type + const ext = filePath.split('.').pop() || ''; + const contentTypes: Record = { + html: 'text/html', + js: 'application/javascript', + mjs: 'application/javascript', + css: 'text/css', + json: 'application/json', + png: 'image/png', + jpg: 'image/jpeg', + gif: 'image/gif', + svg: 'image/svg+xml', + }; + + const contentType = contentTypes[ext] || 'application/octet-stream'; + + try { + const file = Bun.file(`.${filePath}`); + const exists = await file.exists(); + + if (!exists) { + console.log(`404: ${filePath}`); + return new Response('Not Found', { status: 404 }); + } + + return new Response(file, { + headers: { + 'Content-Type': contentType, + 'Cache-Control': 'no-cache', + }, + }); + } catch (error) { + console.error('Server error:', error); + return new Response('Internal Server Error', { status: 500 }); + } + }, +}); + +console.log(`Server running at http://0.0.0.0:${server.port}`); +console.log(`Access locally: http://localhost:${server.port}`); diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..8062362 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,19 @@ +// Internal render resolution (retro truth layer) +export const INTERNAL_WIDTH = 320; +export const INTERNAL_HEIGHT = 180; + +// Aspect ratio for scaling +export const ASPECT_RATIO = INTERNAL_WIDTH / INTERNAL_HEIGHT; + +// Fixed timestep (60 updates per second) +export const FIXED_TIMESTEP = 1000 / 60; // ~16.67ms +export const MAX_FRAME_TIME = 250; // Cap to prevent spiral of death + +// Player constants +export const PLAYER_SPEED = 80; // pixels per second (internal resolution) +export const PLAYER_WIDTH = 16; +export const PLAYER_HEIGHT = 24; + +// Colors (placeholder) +export const PLAYER_COLOR = { r: 0.8, g: 0.6, b: 0.4, a: 1.0 }; // Tan/brown +export const BACKGROUND_COLOR = { r: 0.15, g: 0.1, b: 0.05, a: 1.0 }; // Dark brown diff --git a/src/core/loop.ts b/src/core/loop.ts new file mode 100644 index 0000000..69e6608 --- /dev/null +++ b/src/core/loop.ts @@ -0,0 +1,50 @@ +import { TimeManager } from './time'; + +export type UpdateFn = (dt: number) => void; +export type RenderFn = (alpha: number) => void; + +export class GameLoop { + private timeManager: TimeManager; + private updateFn: UpdateFn; + private renderFn: RenderFn; + private running = false; + private animationFrameId: number | null = null; + + constructor(updateFn: UpdateFn, renderFn: RenderFn) { + this.timeManager = new TimeManager(); + this.updateFn = updateFn; + this.renderFn = renderFn; + } + + start(): void { + if (this.running) return; + + this.running = true; + this.timeManager.reset(); + this.tick(performance.now()); + } + + stop(): void { + this.running = false; + if (this.animationFrameId !== null) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + } + } + + private tick = (currentTime: number): void => { + if (!this.running) return; + + const updates = this.timeManager.update(currentTime); + + // Fixed timestep updates + for (let i = 0; i < updates; i++) { + this.updateFn(this.timeManager.deltaTime); + } + + // Render with interpolation + this.renderFn(this.timeManager.alpha); + + this.animationFrameId = requestAnimationFrame(this.tick); + }; +} diff --git a/src/core/time.ts b/src/core/time.ts new file mode 100644 index 0000000..7d4b265 --- /dev/null +++ b/src/core/time.ts @@ -0,0 +1,39 @@ +import { FIXED_TIMESTEP, MAX_FRAME_TIME } from '../constants'; + +export class TimeManager { + private lastTime = 0; + private accumulator = 0; + + public deltaTime = 0; + public alpha = 0; // Interpolation factor for rendering + + reset(): void { + this.lastTime = performance.now(); + this.accumulator = 0; + } + + update(currentTime: number): number { + let frameTime = currentTime - this.lastTime; + this.lastTime = currentTime; + + // Cap frame time to prevent spiral of death + if (frameTime > MAX_FRAME_TIME) { + frameTime = MAX_FRAME_TIME; + } + + this.accumulator += frameTime; + this.deltaTime = FIXED_TIMESTEP / 1000; // Convert to seconds for physics + + // Return number of fixed updates to perform + let updates = 0; + while (this.accumulator >= FIXED_TIMESTEP) { + this.accumulator -= FIXED_TIMESTEP; + updates++; + } + + // Calculate interpolation alpha for smooth rendering + this.alpha = this.accumulator / FIXED_TIMESTEP; + + return updates; + } +} diff --git a/src/entities/player.ts b/src/entities/player.ts new file mode 100644 index 0000000..62f87b4 --- /dev/null +++ b/src/entities/player.ts @@ -0,0 +1,161 @@ +import { + INTERNAL_WIDTH, + INTERNAL_HEIGHT, + PLAYER_SPEED, + PLAYER_WIDTH, + PLAYER_HEIGHT, + PLAYER_COLOR, +} from '../constants'; +import type { Sprite, InputState } from '../types'; +import type { Collidable } from '../systems/collision-system'; +import { BulletSystem, BulletDirection } from '../systems/bullet-system'; +import type { PlayerStats } from '../systems/economy-system'; + +const BASE_FIRE_RATE = 8; // shots per second + +export class Player implements Collidable { + // Current position + public x: number; + public y: number; + public width = PLAYER_WIDTH; + public height = PLAYER_HEIGHT; + public active = true; + + // Previous position for interpolation + private prevX: number; + private prevY: number; + + // Shooting cooldowns (independent for each direction) + private fireCooldowns: Record = { + left: 0, + forward: 0, + right: 0, + }; + + // Invincibility frames after taking damage + private invincibleTimer = 0; + private blinkTimer = 0; + + constructor() { + // Start at bottom center + this.x = (INTERNAL_WIDTH - PLAYER_WIDTH) / 2; + this.y = INTERNAL_HEIGHT - PLAYER_HEIGHT - 16; // 16px from bottom + + this.prevX = this.x; + this.prevY = this.y; + } + + update(dt: number, input: InputState, bulletSystem: BulletSystem, stats: PlayerStats): void { + // Store previous position for interpolation + this.prevX = this.x; + this.prevY = this.y; + + // Apply movement with speed modifier + const speed = PLAYER_SPEED * stats.moveSpeed; + this.x += input.move.x * speed * dt; + this.y += input.move.y * speed * dt; + + // Clamp to screen bounds (leave room for HUD at top) + this.x = Math.max(0, Math.min(INTERNAL_WIDTH - PLAYER_WIDTH, this.x)); + this.y = Math.max(16, Math.min(INTERNAL_HEIGHT - PLAYER_HEIGHT, this.y)); + + // Update invincibility + if (this.invincibleTimer > 0) { + this.invincibleTimer -= dt; + this.blinkTimer += dt; + } + + // Calculate fire cooldown based on fire rate stat + const fireCooldown = 1 / (BASE_FIRE_RATE * stats.fireRate); + + // Update cooldowns + this.fireCooldowns.left = Math.max(0, this.fireCooldowns.left - dt); + this.fireCooldowns.forward = Math.max(0, this.fireCooldowns.forward - dt); + this.fireCooldowns.right = Math.max(0, this.fireCooldowns.right - dt); + + // Handle shooting + const centerX = this.x + PLAYER_WIDTH / 2; + const topY = this.y; + + if (input.shootLeft && this.fireCooldowns.left <= 0) { + this.fireDirection(bulletSystem, stats, centerX - 4, topY, 'left'); + this.fireCooldowns.left = fireCooldown; + } + + if (input.shootForward && this.fireCooldowns.forward <= 0) { + this.fireDirection(bulletSystem, stats, centerX, topY, 'forward'); + this.fireCooldowns.forward = fireCooldown; + } + + if (input.shootRight && this.fireCooldowns.right <= 0) { + this.fireDirection(bulletSystem, stats, centerX + 4, topY, 'right'); + this.fireCooldowns.right = fireCooldown; + } + } + + private fireDirection( + bulletSystem: BulletSystem, + stats: PlayerStats, + x: number, + y: number, + direction: BulletDirection + ): void { + bulletSystem.spawnPlayerBullet(x, y, direction, stats.bulletSpeed, stats.bulletDamage); + + // Multi-shot: spawn extra bullets + if (stats.multiShot) { + bulletSystem.spawnPlayerBullet(x - 6, y + 4, direction, stats.bulletSpeed, stats.bulletDamage); + bulletSystem.spawnPlayerBullet(x + 6, y + 4, direction, stats.bulletSpeed, stats.bulletDamage); + } + } + + takeDamage(): boolean { + // Returns true if damage was taken (not invincible) + if (this.invincibleTimer > 0) { + return false; + } + + this.invincibleTimer = 1.5; // 1.5 seconds of invincibility + this.blinkTimer = 0; + return true; + } + + isInvincible(): boolean { + return this.invincibleTimer > 0; + } + + getSprite(alpha: number): Sprite { + // Interpolate position for smooth rendering + const x = this.prevX + (this.x - this.prevX) * alpha; + const y = this.prevY + (this.y - this.prevY) * alpha; + + // Blink when invincible + let color = PLAYER_COLOR; + if (this.invincibleTimer > 0) { + if (Math.floor(this.blinkTimer * 10) % 2 === 0) { + color = { ...PLAYER_COLOR, a: 0.3 }; + } + } + + return { + x, + y, + width: PLAYER_WIDTH, + height: PLAYER_HEIGHT, + color, + }; + } + + reset(): void { + this.x = (INTERNAL_WIDTH - PLAYER_WIDTH) / 2; + this.y = INTERNAL_HEIGHT - PLAYER_HEIGHT - 16; + this.prevX = this.x; + this.prevY = this.y; + this.active = true; + this.fireCooldowns.left = 0; + this.fireCooldowns.forward = 0; + this.fireCooldowns.right = 0; + this.invincibleTimer = 2.0; // Brief invincibility on spawn + this.blinkTimer = 0; + } +} diff --git a/src/game.ts b/src/game.ts new file mode 100644 index 0000000..7402fba --- /dev/null +++ b/src/game.ts @@ -0,0 +1,624 @@ +import { INTERNAL_WIDTH, INTERNAL_HEIGHT, PLAYER_WIDTH, PLAYER_HEIGHT } from './constants'; +import { GameLoop } from './core/loop'; +import { InputSystem } from './input/input-system'; +import { Renderer } from './rendering/renderer'; +import { Player } from './entities/player'; +import { BulletSystem } from './systems/bullet-system'; +import { EnemySystem } from './systems/enemy-system'; +import { CollisionSystem } from './systems/collision-system'; +import { WaveSpawner } from './systems/wave-spawner'; +import { ScrollSystem } from './systems/scroll-system'; +import { PickupSystem } from './systems/pickup-system'; +import { EconomySystem } from './systems/economy-system'; +import { ShopSystem } from './systems/shop-system'; +import { HudSystem } from './systems/hud-system'; +import { BossSystem } from './systems/boss-system'; +import { ScreenEffects } from './systems/screen-effects'; +import { PerfMonitor } from './systems/perf-monitor'; +import type { StageData } from './types'; + +// Import stage data +import stage1Data from '../data/stage1.json'; + +type GameState = 'playing' | 'shop' | 'boss' | 'boss_defeated' | 'gameover' | 'victory'; + +export class Game { + private loop: GameLoop; + private input: InputSystem; + private renderer: Renderer; + private player: Player; + private bulletSystem: BulletSystem; + private enemySystem: EnemySystem; + private collisionSystem: CollisionSystem; + private waveSpawner: WaveSpawner; + private scrollSystem: ScrollSystem; + private pickupSystem: PickupSystem; + private economySystem: EconomySystem; + private shopSystem: ShopSystem; + private hudSystem: HudSystem; + private bossSystem: BossSystem; + private screenEffects: ScreenEffects; + private perfMonitor: PerfMonitor; + + // Game state + private score = 0; + private wave = 1; + private state: GameState = 'playing'; + private wavesSinceShop = 0; + private inputCooldown = 0; + private bossDefeatedTimer = 0; + + // Stress test mode + private stressTestMode = false; + + constructor(canvas: HTMLCanvasElement) { + this.input = new InputSystem(); + this.renderer = new Renderer(canvas); + this.player = new Player(); + this.bulletSystem = new BulletSystem(); + this.enemySystem = new EnemySystem(); + this.collisionSystem = new CollisionSystem(); + this.waveSpawner = new WaveSpawner(); + this.scrollSystem = new ScrollSystem(); + this.pickupSystem = new PickupSystem(); + this.economySystem = new EconomySystem(); + this.shopSystem = new ShopSystem(this.economySystem); + this.hudSystem = new HudSystem(this.economySystem); + this.bossSystem = new BossSystem(); + this.screenEffects = new ScreenEffects(this.renderer); + this.perfMonitor = new PerfMonitor(); + + this.loop = new GameLoop( + (dt) => this.update(dt), + (alpha) => this.render(alpha) + ); + + // Load stage data + this.waveSpawner.loadStage(stage1Data as StageData); + this.scrollSystem.setScrollSpeed(this.waveSpawner.getScrollSpeed()); + + // Stress test toggle (F5) + window.addEventListener('keydown', (e) => { + if (e.key === 'F5') { + this.stressTestMode = !this.stressTestMode; + console.log(`Stress test mode: ${this.stressTestMode ? 'ON' : 'OFF'}`); + e.preventDefault(); + } + }); + } + + start(): void { + console.log('Western Shooter - Phase 7: Polish & Performance'); + console.log('Controls:'); + console.log(' WASD / Arrows: Move'); + console.log(' J: Shoot Left | K/Space: Shoot Forward | L: Shoot Right'); + console.log(' E: Open/Close Shop (when enemies cleared)'); + console.log(' F3: Toggle performance monitor'); + console.log(' F5: Toggle stress test mode (spawns many bullets)'); + console.log(''); + console.log('Optimized: VAO batching, O(1) bullet allocation, cached arrays'); + this.loop.start(); + } + + stop(): void { + this.loop.stop(); + this.input.destroy(); + } + + private update(dt: number): void { + const inputState = this.input.getState(); + + if (this.inputCooldown > 0) { + this.inputCooldown -= dt; + } + + // Update screen effects (shake, flash, etc.) + this.screenEffects.update(dt); + + // Stress test: spawn many bullets per frame + if (this.stressTestMode) { + this.runStressTest(); + } + + switch (this.state) { + case 'playing': + this.updatePlaying(dt, inputState); + break; + case 'shop': + this.updateShop(dt, inputState); + break; + case 'boss': + this.updateBoss(dt, inputState); + break; + case 'boss_defeated': + this.updateBossDefeated(dt, inputState); + break; + case 'gameover': + this.updateGameOver(dt, inputState); + break; + case 'victory': + this.updateVictory(dt, inputState); + break; + } + } + + private updatePlaying(dt: number, inputState: ReturnType): void { + const enemyCount = this.enemySystem.getActiveCount(); + + // Check for boss trigger (bought poster, no enemies) + if (this.economySystem.hasPendingBoss() && enemyCount === 0) { + this.startBossFight(); + return; + } + + // Check for shop opening + if (inputState.interact && this.inputCooldown <= 0 && enemyCount === 0 && this.wavesSinceShop >= 2) { + this.openShop(); + return; + } + + // Update player + const stats = this.economySystem.getStats(); + this.player.update(dt, inputState, this.bulletSystem, stats); + + // Update enemy system with player position + this.enemySystem.updatePlayerPosition( + this.player.x + PLAYER_WIDTH / 2, + this.player.y + PLAYER_HEIGHT / 2 + ); + + // Update wave spawner + const prevWave = this.wave; + this.waveSpawner.update(dt, this.enemySystem); + this.wave = this.waveSpawner.getCurrentWave(); + if (this.wave !== prevWave) { + this.wavesSinceShop++; + } + + // Update systems + this.scrollSystem.update(dt); + this.bulletSystem.update(dt); + this.enemySystem.update(dt, this.bulletSystem); + this.pickupSystem.update(dt); + + // Collisions + this.handleCollisions(dt); + + // Update HUD + this.hudSystem.update(dt, this.score, this.wave); + } + + private startBossFight(): void { + const bossType = this.economySystem.getPendingBoss(); + if (!bossType) return; + + this.state = 'boss'; + this.bossSystem.spawnBoss(bossType); + this.bulletSystem.clear(); + this.enemySystem.clear(); + this.pickupSystem.clear(); + + // Slow down scrolling during boss + this.scrollSystem.setScrollSpeed(10); + + console.log(`BOSS FIGHT: ${bossType.toUpperCase()}!`); + } + + private updateBoss(dt: number, inputState: ReturnType): void { + // Update player + const stats = this.economySystem.getStats(); + this.player.update(dt, inputState, this.bulletSystem, stats); + + // Update boss with player position + this.bossSystem.updatePlayerPosition( + this.player.x + PLAYER_WIDTH / 2, + this.player.y + PLAYER_HEIGHT / 2 + ); + + // Update systems + this.scrollSystem.update(dt); + this.bulletSystem.update(dt); + this.bossSystem.update(dt, this.bulletSystem); + + // Collision: player bullets vs boss + const boss = this.bossSystem.getBoss(); + if (boss && boss.active) { + const playerBullets = this.bulletSystem.getPlayerBullets(); + + for (const bullet of playerBullets) { + if (this.collisionSystem.checkEntityAgainstTargets(bullet as any, [boss])) { + const damage = this.bulletSystem.getBulletDamage(bullet as any); + this.bulletSystem.deactivateBullet(bullet as any); + + const result = this.bossSystem.damageBoss(damage); + this.screenEffects.onBossHit(); + if (result.defeated) { + this.onBossDefeated(result.reward); + return; + } + } + } + } + + // Collision: enemy bullets vs player + if (!this.player.isInvincible()) { + const enemyBullets = this.bulletSystem.getEnemyBullets(); + const hitPlayer = this.collisionSystem.checkEntityAgainstTargets(this.player, enemyBullets); + + if (hitPlayer) { + this.bulletSystem.deactivateBullet(hitPlayer as any); + this.handlePlayerDamage(); + } + + // Collision: player vs boss + if (boss && boss.active) { + if (this.collisionSystem.checkEntityAgainstTargets(this.player, [boss])) { + this.handlePlayerDamage(); + } + } + } + + // Update HUD + this.hudSystem.update(dt, this.score, this.wave); + } + + private onBossDefeated(reward: number): void { + const bossType = this.economySystem.getPendingBoss(); + if (bossType) { + this.economySystem.markBossDefeated(bossType); + } + + this.score += reward; + this.economySystem.addMoney(reward); + + this.state = 'boss_defeated'; + this.bossDefeatedTimer = 3.0; + this.bulletSystem.clear(); + + // Big screen effect for boss defeat + this.screenEffects.onBossDefeated(); + + console.log(`BOSS DEFEATED! +${reward} points and money!`); + + // Check for victory (all bosses defeated) + if (this.economySystem.getDefeatedBossCount() >= 3) { + this.state = 'victory'; + console.log('VICTORY! You defeated all the outlaws!'); + } + } + + private updateBossDefeated(dt: number, inputState: ReturnType): void { + this.bossDefeatedTimer -= dt; + this.scrollSystem.update(dt); + + if (this.bossDefeatedTimer <= 0 || (inputState.interact && this.inputCooldown <= 0)) { + // Return to normal gameplay + this.state = 'playing'; + this.bossSystem.clear(); + this.scrollSystem.setScrollSpeed(this.waveSpawner.getScrollSpeed()); + this.wavesSinceShop = 0; + this.inputCooldown = 0.3; + } + } + + private handleCollisions(dt: number): void { + const enemies = this.enemySystem.getActiveEnemies(); + + // Player bullets vs enemies - use optimized forEach to avoid allocations + this.bulletSystem.forEachPlayerBullet((bullet) => { + for (const enemy of enemies) { + if (!enemy.active) continue; + + // Inline AABB check to avoid function call overhead + if ( + bullet.x < enemy.x + enemy.width && + bullet.x + bullet.width > enemy.x && + bullet.y < enemy.y + enemy.height && + bullet.y + bullet.height > enemy.y + ) { + const damage = bullet.damage; + this.bulletSystem.deactivateBullet(bullet); + + const result = this.enemySystem.damageEnemy(enemy, damage); + if (result.killed) { + this.score += result.points; + this.pickupSystem.spawnFromEnemy( + enemy.x + enemy.width / 2, + enemy.y + enemy.height / 2, + result.points + ); + this.screenEffects.onEnemyKill(); + } + return; // Bullet already hit, stop checking + } + } + }); + + // Player vs pickups + const pickups = this.pickupSystem.getActivePickups(); + for (const pickup of pickups) { + if (this.collisionSystem.checkEntityAgainstTargets(this.player, [pickup])) { + const collected = this.pickupSystem.collectPickup(pickup); + if (collected.type === 'coin' || collected.type === 'gold') { + this.economySystem.addMoney(collected.value); + } else if (collected.type === 'health') { + this.economySystem.heal(collected.value); + } + } + } + + // Enemy bullets vs player + if (!this.player.isInvincible()) { + let playerHit = false; + + this.bulletSystem.forEachEnemyBullet((bullet) => { + if (playerHit) return false; // Stop if already hit + + // Inline AABB check + if ( + bullet.x < this.player.x + this.player.width && + bullet.x + bullet.width > this.player.x && + bullet.y < this.player.y + this.player.height && + bullet.y + bullet.height > this.player.y + ) { + this.bulletSystem.deactivateBullet(bullet); + playerHit = true; + return false; // Stop iteration + } + }); + + if (playerHit) { + this.handlePlayerDamage(); + } + + // Player vs enemies (if not already damaged this frame) + if (!playerHit) { + for (const enemy of enemies) { + if (!enemy.active) continue; + if ( + this.player.x < enemy.x + enemy.width && + this.player.x + this.player.width > enemy.x && + this.player.y < enemy.y + enemy.height && + this.player.y + this.player.height > enemy.y + ) { + this.handlePlayerDamage(); + break; + } + } + } + } + } + + private handlePlayerDamage(): void { + if (this.player.takeDamage()) { + const gameOver = this.economySystem.takeDamage(1); + this.hudSystem.triggerDamageFlash(); + this.screenEffects.onPlayerHit(); + + if (gameOver) { + this.state = 'gameover'; + this.screenEffects.setBrightness(0.5); + console.log('GAME OVER! Final Score:', this.score); + } else if (this.economySystem.getHealth() <= 0) { + this.player.reset(); + this.bulletSystem.clear(); + + // If in boss fight and died, cancel boss + if (this.state === 'boss') { + this.bossSystem.clear(); + this.state = 'playing'; + this.scrollSystem.setScrollSpeed(this.waveSpawner.getScrollSpeed()); + } + } + } + } + + private openShop(): void { + this.state = 'shop'; + this.shopSystem.open(); + this.inputCooldown = 0.3; + this.wavesSinceShop = 0; + } + + private updateShop(dt: number, inputState: ReturnType): void { + this.shopSystem.update(dt); + + if (this.inputCooldown <= 0) { + if (inputState.move.y < -0.5) { + this.shopSystem.moveSelection(-1); + this.inputCooldown = 0.15; + } else if (inputState.move.y > 0.5) { + this.shopSystem.moveSelection(1); + this.inputCooldown = 0.15; + } + + if (inputState.shootForward) { + this.shopSystem.tryPurchase(); + this.inputCooldown = 0.2; + } + + if (inputState.interact) { + this.shopSystem.close(); + this.state = 'playing'; + this.inputCooldown = 0.3; + } + } + } + + private updateGameOver(dt: number, inputState: ReturnType): void { + if (this.inputCooldown <= 0 && inputState.interact) { + this.restartGame(); + } + } + + private updateVictory(dt: number, inputState: ReturnType): void { + this.scrollSystem.update(dt); + + if (this.inputCooldown <= 0 && inputState.interact) { + this.restartGame(); + } + } + + private restartGame(): void { + this.score = 0; + this.wave = 1; + this.wavesSinceShop = 0; + this.state = 'playing'; + + this.player.reset(); + this.bulletSystem.clear(); + this.enemySystem.clear(); + this.pickupSystem.clear(); + this.bossSystem.clear(); + this.economySystem.reset(); + this.waveSpawner.loadStage(stage1Data as StageData); + this.scrollSystem.setScrollSpeed(this.waveSpawner.getScrollSpeed()); + + // Reset screen effects + this.screenEffects.setBrightnessImmediate(1.0); + + this.inputCooldown = 0.5; + } + + private render(alpha: number): void { + this.perfMonitor.beginFrame(); + this.renderer.beginFrame(); + + // Draw ground/road + this.renderer.drawSprite(this.scrollSystem.getGroundSprite()); + + // Draw scrolling background + for (const sprite of this.scrollSystem.getSprites(alpha)) { + this.renderer.drawSprite(sprite); + } + + // Draw pickups + for (const sprite of this.pickupSystem.getSprites(alpha)) { + this.renderer.drawSprite(sprite); + } + + // Draw enemies (not during boss fight) + if (this.state !== 'boss' && this.state !== 'boss_defeated') { + for (const sprite of this.enemySystem.getSprites(alpha)) { + this.renderer.drawSprite(sprite); + } + } + + // Draw boss + if (this.state === 'boss') { + for (const sprite of this.bossSystem.getSprites(alpha)) { + this.renderer.drawSprite(sprite); + } + } + + // Draw player + this.renderer.drawSprite(this.player.getSprite(alpha)); + + // Draw bullets using optimized callback method + this.bulletSystem.forEachSprite(alpha, (sprite) => { + this.renderer.drawSprite(sprite); + }); + + // Draw HUD + for (const sprite of this.hudSystem.getSprites()) { + this.renderer.drawSprite(sprite); + } + + // Draw performance monitor (if visible) + for (const sprite of this.perfMonitor.getSprites()) { + this.renderer.drawSprite(sprite); + } + + // Draw shop overlay + if (this.state === 'shop') { + for (const sprite of this.shopSystem.getSprites()) { + this.renderer.drawSprite(sprite); + } + } + + // Game over overlay + if (this.state === 'gameover') { + this.renderer.drawSprite({ + x: 0, + y: 0, + width: INTERNAL_WIDTH, + height: INTERNAL_HEIGHT, + color: { r: 0.1, g: 0, b: 0, a: 0.8 }, + }); + } + + // Victory overlay + if (this.state === 'victory') { + this.renderer.drawSprite({ + x: 0, + y: 0, + width: INTERNAL_WIDTH, + height: INTERNAL_HEIGHT, + color: { r: 0.1, g: 0.15, b: 0.05, a: 0.8 }, + }); + // Gold banner + this.renderer.drawSprite({ + x: 40, + y: 70, + width: INTERNAL_WIDTH - 80, + height: 40, + color: { r: 0.8, g: 0.7, b: 0.2, a: 1 }, + }); + } + + // Boss defeated flash + if (this.state === 'boss_defeated') { + const flashIntensity = Math.max(0, (this.bossDefeatedTimer - 2) * 0.5); + if (flashIntensity > 0) { + this.renderer.drawSprite({ + x: 0, + y: 0, + width: INTERNAL_WIDTH, + height: INTERNAL_HEIGHT, + color: { r: 1, g: 1, b: 1, a: flashIntensity }, + }); + } + } + + this.renderer.endFrame(); + + // Update performance stats + this.perfMonitor.setStats( + this.bulletSystem.getActiveCount(), + this.enemySystem.getActiveCount(), + 0 // quadCount would need to be exposed from renderer + ); + this.perfMonitor.endFrame(); + } + + // Stress test: spawn bullets from random positions + private runStressTest(): void { + // Spawn 50 bullets per frame to stress test + // At 60 FPS, this will quickly fill up to 2000 bullets + const bulletsToSpawn = 50; + + for (let i = 0; i < bulletsToSpawn; i++) { + const x = Math.random() * INTERNAL_WIDTH; + const y = Math.random() * 20; // Spawn at top + + // Random velocity + const angle = Math.random() * Math.PI * 0.5 + Math.PI * 0.25; // 45-135 degrees down + const speed = 80 + Math.random() * 100; + const vx = Math.cos(angle) * speed; + const vy = Math.sin(angle) * speed; + + // Alternate between player and enemy bullets for visual variety + if (Math.random() > 0.5) { + this.bulletSystem.spawnPlayerBullet(x, y, 'forward', 0.5, 1); + } else { + this.bulletSystem.spawnEnemyBullet(x, y, vx, vy); + } + } + } + + // Public getters + getScore(): number { return this.score; } + getWave(): number { return this.wave; } + getMoney(): number { return this.economySystem.getMoney(); } + getState(): GameState { return this.state; } +} diff --git a/src/input/gamepad.ts b/src/input/gamepad.ts new file mode 100644 index 0000000..0362de6 --- /dev/null +++ b/src/input/gamepad.ts @@ -0,0 +1,90 @@ +import type { Vec2 } from '../types'; + +const DEADZONE = 0.15; + +export class GamepadInput { + private gamepadIndex: number | null = null; + + constructor() { + window.addEventListener('gamepadconnected', this.onConnected); + window.addEventListener('gamepaddisconnected', this.onDisconnected); + } + + private onConnected = (e: GamepadEvent): void => { + console.log(`Gamepad connected: ${e.gamepad.id}`); + this.gamepadIndex = e.gamepad.index; + }; + + private onDisconnected = (e: GamepadEvent): void => { + console.log(`Gamepad disconnected: ${e.gamepad.id}`); + if (this.gamepadIndex === e.gamepad.index) { + this.gamepadIndex = null; + } + }; + + private getGamepad(): Gamepad | null { + if (this.gamepadIndex === null) return null; + const gamepads = navigator.getGamepads(); + return gamepads[this.gamepadIndex] ?? null; + } + + private applyDeadzone(value: number): number { + if (Math.abs(value) < DEADZONE) return 0; + // Rescale to 0-1 range after deadzone + const sign = Math.sign(value); + const magnitude = (Math.abs(value) - DEADZONE) / (1 - DEADZONE); + return sign * magnitude; + } + + getMovement(): Vec2 { + const gamepad = this.getGamepad(); + if (!gamepad) return { x: 0, y: 0 }; + + // Left stick (axes 0 and 1) + let x = this.applyDeadzone(gamepad.axes[0] ?? 0); + let y = this.applyDeadzone(gamepad.axes[1] ?? 0); + + // Clamp magnitude to 1 + const mag = Math.sqrt(x * x + y * y); + if (mag > 1) { + x /= mag; + y /= mag; + } + + return { x, y }; + } + + // Standard gamepad button mapping + isShootingLeft(): boolean { + const gamepad = this.getGamepad(); + if (!gamepad) return false; + return gamepad.buttons[4]?.pressed ?? false; // LB + } + + isShootingForward(): boolean { + const gamepad = this.getGamepad(); + if (!gamepad) return false; + return gamepad.buttons[0]?.pressed ?? false; // A/X + } + + isShootingRight(): boolean { + const gamepad = this.getGamepad(); + if (!gamepad) return false; + return gamepad.buttons[5]?.pressed ?? false; // RB + } + + isInteracting(): boolean { + const gamepad = this.getGamepad(); + if (!gamepad) return false; + return gamepad.buttons[2]?.pressed ?? false; // X/Square + } + + isConnected(): boolean { + return this.gamepadIndex !== null; + } + + destroy(): void { + window.removeEventListener('gamepadconnected', this.onConnected); + window.removeEventListener('gamepaddisconnected', this.onDisconnected); + } +} diff --git a/src/input/input-system.ts b/src/input/input-system.ts new file mode 100644 index 0000000..c2aa819 --- /dev/null +++ b/src/input/input-system.ts @@ -0,0 +1,45 @@ +import type { InputState, Vec2 } from '../types'; +import { KeyboardInput } from './keyboard'; +import { GamepadInput } from './gamepad'; + +export class InputSystem { + private keyboard: KeyboardInput; + private gamepad: GamepadInput; + + constructor() { + this.keyboard = new KeyboardInput(); + this.gamepad = new GamepadInput(); + } + + getState(): InputState { + const kbMove = this.keyboard.getMovement(); + const gpMove = this.gamepad.getMovement(); + + // Combine inputs, prefer whichever has larger magnitude + const move = this.combineMovement(kbMove, gpMove); + + return { + move, + shootLeft: this.keyboard.isShootingLeft() || this.gamepad.isShootingLeft(), + shootForward: this.keyboard.isShootingForward() || this.gamepad.isShootingForward(), + shootRight: this.keyboard.isShootingRight() || this.gamepad.isShootingRight(), + interact: this.keyboard.isInteracting() || this.gamepad.isInteracting(), + }; + } + + private combineMovement(kb: Vec2, gp: Vec2): Vec2 { + const kbMag = Math.sqrt(kb.x * kb.x + kb.y * kb.y); + const gpMag = Math.sqrt(gp.x * gp.x + gp.y * gp.y); + + // Use whichever input has greater magnitude + if (gpMag > kbMag) { + return gp; + } + return kb; + } + + destroy(): void { + this.keyboard.destroy(); + this.gamepad.destroy(); + } +} diff --git a/src/input/keyboard.ts b/src/input/keyboard.ts new file mode 100644 index 0000000..c490442 --- /dev/null +++ b/src/input/keyboard.ts @@ -0,0 +1,69 @@ +import type { Vec2 } from '../types'; + +export class KeyboardInput { + private keys: Set = new Set(); + + constructor() { + window.addEventListener('keydown', this.onKeyDown); + window.addEventListener('keyup', this.onKeyUp); + } + + private onKeyDown = (e: KeyboardEvent): void => { + this.keys.add(e.code); + + // Prevent arrow keys from scrolling + if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space'].includes(e.code)) { + e.preventDefault(); + } + }; + + private onKeyUp = (e: KeyboardEvent): void => { + this.keys.delete(e.code); + }; + + isPressed(code: string): boolean { + return this.keys.has(code); + } + + getMovement(): Vec2 { + let x = 0; + let y = 0; + + // WASD + if (this.isPressed('KeyA') || this.isPressed('ArrowLeft')) x -= 1; + if (this.isPressed('KeyD') || this.isPressed('ArrowRight')) x += 1; + if (this.isPressed('KeyW') || this.isPressed('ArrowUp')) y -= 1; + if (this.isPressed('KeyS') || this.isPressed('ArrowDown')) y += 1; + + // Normalize diagonal movement + if (x !== 0 && y !== 0) { + const len = Math.sqrt(x * x + y * y); + x /= len; + y /= len; + } + + return { x, y }; + } + + // Shooting keys for future phases + isShootingLeft(): boolean { + return this.isPressed('KeyJ') || this.isPressed('Numpad1'); + } + + isShootingForward(): boolean { + return this.isPressed('KeyK') || this.isPressed('Numpad2') || this.isPressed('Space'); + } + + isShootingRight(): boolean { + return this.isPressed('KeyL') || this.isPressed('Numpad3'); + } + + isInteracting(): boolean { + return this.isPressed('KeyE') || this.isPressed('Enter'); + } + + destroy(): void { + window.removeEventListener('keydown', this.onKeyDown); + window.removeEventListener('keyup', this.onKeyUp); + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..5985914 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,22 @@ +import { Game } from './game'; + +function init(): void { + const canvas = document.getElementById('game-canvas') as HTMLCanvasElement | null; + + if (!canvas) { + throw new Error('Canvas element not found'); + } + + const game = new Game(canvas); + game.start(); + + // Expose game for debugging + (window as unknown as { game: Game }).game = game; +} + +// Wait for DOM +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + init(); +} diff --git a/src/rendering/framebuffer.ts b/src/rendering/framebuffer.ts new file mode 100644 index 0000000..bd48647 --- /dev/null +++ b/src/rendering/framebuffer.ts @@ -0,0 +1,72 @@ +export class Framebuffer { + public readonly framebuffer: WebGLFramebuffer; + public readonly texture: WebGLTexture; + public readonly width: number; + public readonly height: number; + + constructor(gl: WebGL2RenderingContext, width: number, height: number) { + this.width = width; + this.height = height; + + // Create texture for the framebuffer + const texture = gl.createTexture(); + if (!texture) { + throw new Error('Failed to create framebuffer texture'); + } + this.texture = texture; + + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + width, + height, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + null + ); + + // Nearest-neighbor filtering for pixel-perfect upscaling + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + + // Create framebuffer + const framebuffer = gl.createFramebuffer(); + if (!framebuffer) { + throw new Error('Failed to create framebuffer'); + } + this.framebuffer = framebuffer; + + gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + texture, + 0 + ); + + // Check framebuffer status + const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER); + if (status !== gl.FRAMEBUFFER_COMPLETE) { + throw new Error(`Framebuffer not complete: ${status}`); + } + + // Unbind + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.bindTexture(gl.TEXTURE_2D, null); + } + + bind(gl: WebGL2RenderingContext): void { + gl.bindFramebuffer(gl.FRAMEBUFFER, this.framebuffer); + gl.viewport(0, 0, this.width, this.height); + } + + unbind(gl: WebGL2RenderingContext): void { + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + } +} diff --git a/src/rendering/renderer.ts b/src/rendering/renderer.ts new file mode 100644 index 0000000..d5bff21 --- /dev/null +++ b/src/rendering/renderer.ts @@ -0,0 +1,374 @@ +import { INTERNAL_WIDTH, INTERNAL_HEIGHT, ASPECT_RATIO, BACKGROUND_COLOR } from '../constants'; +import type { Sprite } from '../types'; +import { createWebGL2Context, createShaderProgram } from './webgl-context'; +import { Framebuffer } from './framebuffer'; +import { SpriteBatch } from './sprite-batch'; + +const POST_PROCESS_VERT = ` +attribute vec2 a_position; +attribute vec2 a_texCoord; +varying vec2 v_texCoord; +void main() { + gl_Position = vec4(a_position, 0.0, 1.0); + v_texCoord = a_texCoord; +} +`; + +const POST_PROCESS_FRAG = ` +precision mediump float; + +uniform sampler2D u_texture; +uniform vec2 u_resolution; +uniform float u_time; +uniform float u_screenShake; +uniform float u_damageFlash; +uniform float u_brightness; + +varying vec2 v_texCoord; + +// CRT curvature amount +const float CURVATURE = 0.03; + +// Scanline intensity +const float SCANLINE_INTENSITY = 0.15; +const float SCANLINE_COUNT = 180.0; + +// Vignette +const float VIGNETTE_INTENSITY = 0.3; + +// Chromatic aberration +const float CHROMA_AMOUNT = 0.002; + +// Bloom threshold and intensity +const float BLOOM_THRESHOLD = 0.7; +const float BLOOM_INTENSITY = 0.15; + +// Apply barrel distortion for CRT curvature +vec2 curveUV(vec2 uv) { + uv = uv * 2.0 - 1.0; + vec2 offset = abs(uv.yx) / vec2(6.0, 4.0); + uv = uv + uv * offset * offset * CURVATURE; + uv = uv * 0.5 + 0.5; + return uv; +} + +// Scanlines +float scanline(vec2 uv) { + float line = sin(uv.y * SCANLINE_COUNT * 3.14159); + return 1.0 - SCANLINE_INTENSITY * (0.5 + 0.5 * line); +} + +// Vignette effect +float vignette(vec2 uv) { + uv = uv * 2.0 - 1.0; + float dist = length(uv * vec2(0.8, 1.0)); + return 1.0 - smoothstep(0.7, 1.4, dist) * VIGNETTE_INTENSITY; +} + +// Simple bloom by sampling bright areas +vec3 bloom(sampler2D tex, vec2 uv, vec2 resolution) { + vec3 sum = vec3(0.0); + float pixelSize = 1.0 / resolution.x; + + for (float i = -2.0; i <= 2.0; i += 1.0) { + for (float j = -2.0; j <= 2.0; j += 1.0) { + vec2 offset = vec2(i, j) * pixelSize * 2.0; + vec3 s = texture2D(tex, uv + offset).rgb; + float brightness = max(max(s.r, s.g), s.b); + if (brightness > BLOOM_THRESHOLD) { + sum += s * (brightness - BLOOM_THRESHOLD); + } + } + } + + return sum * BLOOM_INTENSITY / 25.0; +} + +// Chromatic aberration +vec3 chromaticAberration(sampler2D tex, vec2 uv) { + vec2 dir = uv - 0.5; + float dist = length(dir); + vec2 offset = dir * dist * CHROMA_AMOUNT; + + float r = texture2D(tex, uv + offset).r; + float g = texture2D(tex, uv).g; + float b = texture2D(tex, uv - offset).b; + + return vec3(r, g, b); +} + +// Dithering pattern (Bayer 4x4) +float dither(vec2 position) { + int x = int(mod(position.x, 4.0)); + int y = int(mod(position.y, 4.0)); + int index = x + y * 4; + + float threshold; + if (index == 0) threshold = 0.0; + else if (index == 1) threshold = 8.0; + else if (index == 2) threshold = 2.0; + else if (index == 3) threshold = 10.0; + else if (index == 4) threshold = 12.0; + else if (index == 5) threshold = 4.0; + else if (index == 6) threshold = 14.0; + else if (index == 7) threshold = 6.0; + else if (index == 8) threshold = 3.0; + else if (index == 9) threshold = 11.0; + else if (index == 10) threshold = 1.0; + else if (index == 11) threshold = 9.0; + else if (index == 12) threshold = 15.0; + else if (index == 13) threshold = 7.0; + else if (index == 14) threshold = 13.0; + else threshold = 5.0; + + return threshold / 16.0; +} + +// Quantize color to limited palette +vec3 quantize(vec3 color, float levels) { + return floor(color * levels + 0.5) / levels; +} + +void main() { + vec2 uv = v_texCoord; + + // Apply screen shake + if (u_screenShake > 0.0) { + float shake = u_screenShake * 0.01; + uv.x += sin(u_time * 50.0) * shake; + uv.y += cos(u_time * 43.0) * shake; + } + + // Apply CRT curvature + vec2 curvedUV = curveUV(uv); + + // Check if outside curved screen + if (curvedUV.x < 0.0 || curvedUV.x > 1.0 || curvedUV.y < 0.0 || curvedUV.y > 1.0) { + gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0); + return; + } + + // Sample with chromatic aberration + vec3 color = chromaticAberration(u_texture, curvedUV); + + // Add bloom + color += bloom(u_texture, curvedUV, u_resolution); + + // Apply scanlines + color *= scanline(curvedUV); + + // Apply vignette + color *= vignette(curvedUV); + + // Subtle dithering + vec2 pixelPos = curvedUV * u_resolution; + float ditherValue = dither(pixelPos); + color += (ditherValue - 0.5) * 0.03; + + // Quantize to limited palette (subtle) + color = mix(color, quantize(color, 32.0), 0.15); + + // Apply brightness + color *= u_brightness; + + // Apply damage flash + if (u_damageFlash > 0.0) { + color = mix(color, vec3(1.0, 0.2, 0.1), u_damageFlash * 0.4); + } + + color = clamp(color, 0.0, 1.0); + gl_FragColor = vec4(color, 1.0); +} +`; + +export interface PostProcessParams { + screenShake: number; + damageFlash: number; + brightness: number; +} + +export class Renderer { + private canvas: HTMLCanvasElement; + private gl: WebGL2RenderingContext; + private framebuffer: Framebuffer; + private spriteBatch: SpriteBatch; + + // Post-process pass + private postProcessProgram: WebGLProgram; + private postProcessVAO: WebGLVertexArrayObject; + private postProcessBuffer: WebGLBuffer; + + // Uniform locations + private uResolution: WebGLUniformLocation | null = null; + private uTime: WebGLUniformLocation | null = null; + private uScreenShake: WebGLUniformLocation | null = null; + private uDamageFlash: WebGLUniformLocation | null = null; + private uBrightness: WebGLUniformLocation | null = null; + + // Time tracking + private startTime: number; + + // Post-process parameters + private params: PostProcessParams = { + screenShake: 0, + damageFlash: 0, + brightness: 1.0, + }; + + constructor(canvas: HTMLCanvasElement) { + this.canvas = canvas; + this.gl = createWebGL2Context(canvas); + this.startTime = performance.now(); + + // Create low-res framebuffer + this.framebuffer = new Framebuffer(this.gl, INTERNAL_WIDTH, INTERNAL_HEIGHT); + + // Create sprite batch for world rendering + this.spriteBatch = new SpriteBatch(this.gl); + + // Setup post-process pass + this.postProcessProgram = createShaderProgram(this.gl, POST_PROCESS_VERT, POST_PROCESS_FRAG); + + // Get uniform locations + this.uResolution = this.gl.getUniformLocation(this.postProcessProgram, 'u_resolution'); + this.uTime = this.gl.getUniformLocation(this.postProcessProgram, 'u_time'); + this.uScreenShake = this.gl.getUniformLocation(this.postProcessProgram, 'u_screenShake'); + this.uDamageFlash = this.gl.getUniformLocation(this.postProcessProgram, 'u_damageFlash'); + this.uBrightness = this.gl.getUniformLocation(this.postProcessProgram, 'u_brightness'); + + const vao = this.gl.createVertexArray(); + if (!vao) throw new Error('Failed to create VAO'); + this.postProcessVAO = vao; + + const buffer = this.gl.createBuffer(); + if (!buffer) throw new Error('Failed to create buffer'); + this.postProcessBuffer = buffer; + + this.setupPostProcessQuad(); + this.resize(); + + // Handle window resize + window.addEventListener('resize', () => this.resize()); + } + + private setupPostProcessQuad(): void { + const gl = this.gl; + + gl.bindVertexArray(this.postProcessVAO); + gl.bindBuffer(gl.ARRAY_BUFFER, this.postProcessBuffer); + + // Fullscreen quad with texture coordinates + // Note: v_texCoord.y is flipped for correct orientation + const data = new Float32Array([ + // position (x, y), texCoord (u, v) + -1, -1, 0, 0, + 1, -1, 1, 0, + -1, 1, 0, 1, + 1, -1, 1, 0, + 1, 1, 1, 1, + -1, 1, 0, 1, + ]); + + gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW); + + const posLoc = gl.getAttribLocation(this.postProcessProgram, 'a_position'); + const texLoc = gl.getAttribLocation(this.postProcessProgram, 'a_texCoord'); + + gl.enableVertexAttribArray(posLoc); + gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 16, 0); + + gl.enableVertexAttribArray(texLoc); + gl.vertexAttribPointer(texLoc, 2, gl.FLOAT, false, 16, 8); + + gl.bindVertexArray(null); + } + + resize(): void { + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; + + let width: number; + let height: number; + + if (windowWidth / windowHeight > ASPECT_RATIO) { + height = windowHeight; + width = height * ASPECT_RATIO; + } else { + width = windowWidth; + height = width / ASPECT_RATIO; + } + + width = Math.floor(width); + height = Math.floor(height); + + this.canvas.width = width; + this.canvas.height = height; + this.canvas.style.width = `${width}px`; + this.canvas.style.height = `${height}px`; + } + + setScreenShake(intensity: number): void { + this.params.screenShake = intensity; + } + + setDamageFlash(intensity: number): void { + this.params.damageFlash = intensity; + } + + setBrightness(brightness: number): void { + this.params.brightness = brightness; + } + + beginFrame(): void { + // Render to low-res framebuffer + this.framebuffer.bind(this.gl); + + const { r, g, b, a } = BACKGROUND_COLOR; + this.gl.clearColor(r, g, b, a); + this.gl.clear(this.gl.COLOR_BUFFER_BIT); + + // Enable blending for alpha + this.gl.enable(this.gl.BLEND); + this.gl.blendFunc(this.gl.SRC_ALPHA, this.gl.ONE_MINUS_SRC_ALPHA); + + this.spriteBatch.begin(); + } + + drawSprite(sprite: Sprite): void { + this.spriteBatch.draw(sprite); + } + + endFrame(): void { + // Finish sprite batch + this.spriteBatch.end(INTERNAL_WIDTH, INTERNAL_HEIGHT); + + // Unbind framebuffer, render to screen + this.framebuffer.unbind(this.gl); + + // Setup viewport for screen + this.gl.viewport(0, 0, this.canvas.width, this.canvas.height); + + // Clear screen + this.gl.clearColor(0, 0, 0, 1); + this.gl.clear(this.gl.COLOR_BUFFER_BIT); + + // Draw with post-processing + this.gl.useProgram(this.postProcessProgram); + this.gl.bindVertexArray(this.postProcessVAO); + + // Set uniforms + const currentTime = (performance.now() - this.startTime) / 1000; + this.gl.uniform2f(this.uResolution, this.canvas.width, this.canvas.height); + this.gl.uniform1f(this.uTime, currentTime); + this.gl.uniform1f(this.uScreenShake, this.params.screenShake); + this.gl.uniform1f(this.uDamageFlash, this.params.damageFlash); + this.gl.uniform1f(this.uBrightness, this.params.brightness); + + this.gl.activeTexture(this.gl.TEXTURE0); + this.gl.bindTexture(this.gl.TEXTURE_2D, this.framebuffer.texture); + + this.gl.drawArrays(this.gl.TRIANGLES, 0, 6); + + this.gl.bindVertexArray(null); + } +} diff --git a/src/rendering/shaders/post-process.frag b/src/rendering/shaders/post-process.frag new file mode 100644 index 0000000..bf62f65 --- /dev/null +++ b/src/rendering/shaders/post-process.frag @@ -0,0 +1,169 @@ +precision mediump float; + +uniform sampler2D u_texture; +uniform vec2 u_resolution; +uniform float u_time; +uniform float u_screenShake; +uniform float u_damageFlash; +uniform float u_brightness; + +varying vec2 v_texCoord; + +// CRT curvature amount +const float CURVATURE = 0.03; + +// Scanline intensity +const float SCANLINE_INTENSITY = 0.15; +const float SCANLINE_COUNT = 180.0; + +// Vignette +const float VIGNETTE_INTENSITY = 0.3; + +// Chromatic aberration +const float CHROMA_AMOUNT = 0.002; + +// Bloom threshold and intensity +const float BLOOM_THRESHOLD = 0.7; +const float BLOOM_INTENSITY = 0.15; + +// Apply barrel distortion for CRT curvature +vec2 curveUV(vec2 uv) { + uv = uv * 2.0 - 1.0; + vec2 offset = abs(uv.yx) / vec2(6.0, 4.0); + uv = uv + uv * offset * offset * CURVATURE; + uv = uv * 0.5 + 0.5; + return uv; +} + +// Scanlines +float scanline(vec2 uv) { + float line = sin(uv.y * SCANLINE_COUNT * 3.14159); + return 1.0 - SCANLINE_INTENSITY * (0.5 + 0.5 * line); +} + +// Vignette effect +float vignette(vec2 uv) { + uv = uv * 2.0 - 1.0; + float dist = length(uv * vec2(0.8, 1.0)); + return 1.0 - smoothstep(0.7, 1.4, dist) * VIGNETTE_INTENSITY; +} + +// Simple bloom by sampling bright areas +vec3 bloom(sampler2D tex, vec2 uv, vec2 resolution) { + vec3 sum = vec3(0.0); + float pixelSize = 1.0 / resolution.x; + + // Sample in a cross pattern + for (float i = -2.0; i <= 2.0; i += 1.0) { + for (float j = -2.0; j <= 2.0; j += 1.0) { + vec2 offset = vec2(i, j) * pixelSize * 2.0; + vec3 sample = texture2D(tex, uv + offset).rgb; + + // Only add bright pixels + float brightness = max(max(sample.r, sample.g), sample.b); + if (brightness > BLOOM_THRESHOLD) { + sum += sample * (brightness - BLOOM_THRESHOLD); + } + } + } + + return sum * BLOOM_INTENSITY / 25.0; +} + +// Chromatic aberration +vec3 chromaticAberration(sampler2D tex, vec2 uv) { + vec2 dir = uv - 0.5; + float dist = length(dir); + vec2 offset = dir * dist * CHROMA_AMOUNT; + + float r = texture2D(tex, uv + offset).r; + float g = texture2D(tex, uv).g; + float b = texture2D(tex, uv - offset).b; + + return vec3(r, g, b); +} + +// Dithering pattern +float dither(vec2 position) { + int x = int(mod(position.x, 4.0)); + int y = int(mod(position.y, 4.0)); + int index = x + y * 4; + + // Bayer 4x4 dither matrix + float threshold; + if (index == 0) threshold = 0.0; + else if (index == 1) threshold = 8.0; + else if (index == 2) threshold = 2.0; + else if (index == 3) threshold = 10.0; + else if (index == 4) threshold = 12.0; + else if (index == 5) threshold = 4.0; + else if (index == 6) threshold = 14.0; + else if (index == 7) threshold = 6.0; + else if (index == 8) threshold = 3.0; + else if (index == 9) threshold = 11.0; + else if (index == 10) threshold = 1.0; + else if (index == 11) threshold = 9.0; + else if (index == 12) threshold = 15.0; + else if (index == 13) threshold = 7.0; + else if (index == 14) threshold = 13.0; + else threshold = 5.0; + + return threshold / 16.0; +} + +// Quantize color to limited palette +vec3 quantize(vec3 color, float levels) { + return floor(color * levels + 0.5) / levels; +} + +void main() { + // Apply screen shake + vec2 uv = v_texCoord; + if (u_screenShake > 0.0) { + float shake = u_screenShake * 0.01; + uv.x += sin(u_time * 50.0) * shake; + uv.y += cos(u_time * 43.0) * shake; + } + + // Apply CRT curvature + vec2 curvedUV = curveUV(uv); + + // Check if we're outside the curved screen + if (curvedUV.x < 0.0 || curvedUV.x > 1.0 || curvedUV.y < 0.0 || curvedUV.y > 1.0) { + gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0); + return; + } + + // Sample with chromatic aberration + vec3 color = chromaticAberration(u_texture, curvedUV); + + // Add bloom + color += bloom(u_texture, curvedUV, u_resolution); + + // Apply scanlines + color *= scanline(curvedUV); + + // Apply vignette + color *= vignette(curvedUV); + + // Subtle dithering for retro feel + vec2 pixelPos = curvedUV * u_resolution; + float ditherValue = dither(pixelPos); + color += (ditherValue - 0.5) * 0.03; + + // Quantize to limited color palette (subtle) + color = mix(color, quantize(color, 32.0), 0.2); + + // Apply brightness adjustment + color *= u_brightness; + + // Apply damage flash (red tint) + if (u_damageFlash > 0.0) { + color = mix(color, vec3(1.0, 0.2, 0.1), u_damageFlash * 0.4); + } + + // Ensure we don't exceed valid range + color = clamp(color, 0.0, 1.0); + + gl_FragColor = vec4(color, 1.0); +} diff --git a/src/rendering/shaders/post-process.vert b/src/rendering/shaders/post-process.vert new file mode 100644 index 0000000..08245ff --- /dev/null +++ b/src/rendering/shaders/post-process.vert @@ -0,0 +1,9 @@ +attribute vec2 a_position; +attribute vec2 a_texCoord; + +varying vec2 v_texCoord; + +void main() { + gl_Position = vec4(a_position, 0.0, 1.0); + v_texCoord = a_texCoord; +} diff --git a/src/rendering/shaders/sprite.frag b/src/rendering/shaders/sprite.frag new file mode 100644 index 0000000..1a07fc4 --- /dev/null +++ b/src/rendering/shaders/sprite.frag @@ -0,0 +1,7 @@ +precision mediump float; + +varying vec4 v_color; + +void main() { + gl_FragColor = v_color; +} diff --git a/src/rendering/shaders/sprite.vert b/src/rendering/shaders/sprite.vert new file mode 100644 index 0000000..2d273c5 --- /dev/null +++ b/src/rendering/shaders/sprite.vert @@ -0,0 +1,22 @@ +attribute vec2 a_position; +attribute vec4 a_color; + +uniform vec2 u_resolution; + +varying vec4 v_color; + +void main() { + // Convert from pixels to 0.0-1.0 + vec2 zeroToOne = a_position / u_resolution; + + // Convert from 0.0-1.0 to 0.0-2.0 + vec2 zeroToTwo = zeroToOne * 2.0; + + // Convert from 0.0-2.0 to -1.0-1.0 (clip space) + vec2 clipSpace = zeroToTwo - 1.0; + + // Flip Y (screen coords have Y down, clip space has Y up) + gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1); + + v_color = a_color; +} diff --git a/src/rendering/shaders/upscale.frag b/src/rendering/shaders/upscale.frag new file mode 100644 index 0000000..ae2ad5e --- /dev/null +++ b/src/rendering/shaders/upscale.frag @@ -0,0 +1,11 @@ +precision mediump float; + +uniform sampler2D u_texture; + +varying vec2 v_texCoord; + +void main() { + // Simple pass-through for nearest-neighbor upscale + // Nearest-neighbor filtering is set on the texture, not in shader + gl_FragColor = texture2D(u_texture, v_texCoord); +} diff --git a/src/rendering/shaders/upscale.vert b/src/rendering/shaders/upscale.vert new file mode 100644 index 0000000..7efcd4d --- /dev/null +++ b/src/rendering/shaders/upscale.vert @@ -0,0 +1,9 @@ +attribute vec2 a_position; +attribute vec2 a_texCoord; + +varying vec2 v_texCoord; + +void main() { + gl_Position = vec4(a_position, 0, 1); + v_texCoord = a_texCoord; +} diff --git a/src/rendering/sprite-batch.ts b/src/rendering/sprite-batch.ts new file mode 100644 index 0000000..d6850b0 --- /dev/null +++ b/src/rendering/sprite-batch.ts @@ -0,0 +1,236 @@ +import type { Sprite } from '../types'; +import { createShaderProgram } from './webgl-context'; + +// Inline shaders (will be replaced with imports in production build) +const SPRITE_VERT = ` +attribute vec2 a_position; +attribute vec4 a_color; + +uniform vec2 u_resolution; + +varying vec4 v_color; + +void main() { + vec2 zeroToOne = a_position / u_resolution; + vec2 zeroToTwo = zeroToOne * 2.0; + vec2 clipSpace = zeroToTwo - 1.0; + gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1); + v_color = a_color; +} +`; + +const SPRITE_FRAG = ` +precision mediump float; +varying vec4 v_color; +void main() { + gl_FragColor = v_color; +} +`; + +// 4 vertices per quad (indexed), 6 floats per vertex (x, y, r, g, b, a) +const FLOATS_PER_VERTEX = 6; +const VERTICES_PER_QUAD = 4; // Using indexed rendering +const INDICES_PER_QUAD = 6; // 2 triangles +const FLOATS_PER_QUAD = FLOATS_PER_VERTEX * VERTICES_PER_QUAD; +const MAX_QUADS = 10000; // Support for many bullets + +export class SpriteBatch { + private gl: WebGL2RenderingContext; + private program: WebGLProgram; + private vao: WebGLVertexArrayObject; + private vertexBuffer: WebGLBuffer; + private indexBuffer: WebGLBuffer; + private data: Float32Array; + private quadCount = 0; + + private resolutionLoc: WebGLUniformLocation; + + constructor(gl: WebGL2RenderingContext) { + this.gl = gl; + + // Create shader program + this.program = createShaderProgram(gl, SPRITE_VERT, SPRITE_FRAG); + + // Get uniform locations + const resLoc = gl.getUniformLocation(this.program, 'u_resolution'); + if (!resLoc) { + throw new Error('Failed to get u_resolution location'); + } + this.resolutionLoc = resLoc; + + // Create VAO + const vao = gl.createVertexArray(); + if (!vao) { + throw new Error('Failed to create VAO'); + } + this.vao = vao; + + // Create vertex buffer + const vertexBuffer = gl.createBuffer(); + if (!vertexBuffer) { + throw new Error('Failed to create vertex buffer'); + } + this.vertexBuffer = vertexBuffer; + + // Create index buffer + const indexBuffer = gl.createBuffer(); + if (!indexBuffer) { + throw new Error('Failed to create index buffer'); + } + this.indexBuffer = indexBuffer; + + // Pre-allocate data array + this.data = new Float32Array(MAX_QUADS * FLOATS_PER_QUAD); + + // Setup VAO + this.setupVAO(); + + // Pre-generate indices + this.generateIndices(); + } + + private setupVAO(): void { + const gl = this.gl; + + gl.bindVertexArray(this.vao); + gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer); + + const positionLoc = gl.getAttribLocation(this.program, 'a_position'); + const colorLoc = gl.getAttribLocation(this.program, 'a_color'); + + // Position attribute + gl.enableVertexAttribArray(positionLoc); + gl.vertexAttribPointer( + positionLoc, + 2, + gl.FLOAT, + false, + FLOATS_PER_VERTEX * 4, + 0 + ); + + // Color attribute + gl.enableVertexAttribArray(colorLoc); + gl.vertexAttribPointer( + colorLoc, + 4, + gl.FLOAT, + false, + FLOATS_PER_VERTEX * 4, + 2 * 4 + ); + + // Bind index buffer to VAO + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer); + + gl.bindVertexArray(null); + } + + private generateIndices(): void { + const gl = this.gl; + const indices = new Uint16Array(MAX_QUADS * INDICES_PER_QUAD); + + for (let i = 0; i < MAX_QUADS; i++) { + const vertexOffset = i * 4; + const indexOffset = i * 6; + + // Triangle 1: top-left, top-right, bottom-left + indices[indexOffset + 0] = vertexOffset + 0; + indices[indexOffset + 1] = vertexOffset + 1; + indices[indexOffset + 2] = vertexOffset + 2; + + // Triangle 2: top-right, bottom-right, bottom-left + indices[indexOffset + 3] = vertexOffset + 1; + indices[indexOffset + 4] = vertexOffset + 3; + indices[indexOffset + 5] = vertexOffset + 2; + } + + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW); + } + + begin(): void { + this.quadCount = 0; + } + + draw(sprite: Sprite): void { + if (this.quadCount >= MAX_QUADS) { + console.warn('SpriteBatch: max quads exceeded'); + return; + } + + const { x, y, width, height, color } = sprite; + const { r, g, b, a } = color; + + const x1 = x; + const y1 = y; + const x2 = x + width; + const y2 = y + height; + + const offset = this.quadCount * FLOATS_PER_QUAD; + + // 4 vertices per quad (indexed rendering) + // Top-left (vertex 0) + this.data[offset + 0] = x1; + this.data[offset + 1] = y1; + this.data[offset + 2] = r; + this.data[offset + 3] = g; + this.data[offset + 4] = b; + this.data[offset + 5] = a; + + // Top-right (vertex 1) + this.data[offset + 6] = x2; + this.data[offset + 7] = y1; + this.data[offset + 8] = r; + this.data[offset + 9] = g; + this.data[offset + 10] = b; + this.data[offset + 11] = a; + + // Bottom-left (vertex 2) + this.data[offset + 12] = x1; + this.data[offset + 13] = y2; + this.data[offset + 14] = r; + this.data[offset + 15] = g; + this.data[offset + 16] = b; + this.data[offset + 17] = a; + + // Bottom-right (vertex 3) + this.data[offset + 18] = x2; + this.data[offset + 19] = y2; + this.data[offset + 20] = r; + this.data[offset + 21] = g; + this.data[offset + 22] = b; + this.data[offset + 23] = a; + + this.quadCount++; + } + + end(resolutionX: number, resolutionY: number): void { + if (this.quadCount === 0) return; + + const gl = this.gl; + + gl.useProgram(this.program); + gl.uniform2f(this.resolutionLoc, resolutionX, resolutionY); + + // Bind VAO (contains all vertex attribute state) + gl.bindVertexArray(this.vao); + + // Upload vertex data + gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer); + gl.bufferData( + gl.ARRAY_BUFFER, + this.data.subarray(0, this.quadCount * FLOATS_PER_QUAD), + gl.DYNAMIC_DRAW + ); + + // Draw with indices + gl.drawElements(gl.TRIANGLES, this.quadCount * INDICES_PER_QUAD, gl.UNSIGNED_SHORT, 0); + + gl.bindVertexArray(null); + } + + getQuadCount(): number { + return this.quadCount; + } +} diff --git a/src/rendering/webgl-context.ts b/src/rendering/webgl-context.ts new file mode 100644 index 0000000..d9e26c7 --- /dev/null +++ b/src/rendering/webgl-context.ts @@ -0,0 +1,71 @@ +export function createWebGL2Context(canvas: HTMLCanvasElement): WebGL2RenderingContext { + const gl = canvas.getContext('webgl2', { + alpha: false, + antialias: false, + depth: false, + stencil: false, + premultipliedAlpha: false, + preserveDrawingBuffer: false, + }); + + if (!gl) { + throw new Error('WebGL2 is not supported in this browser'); + } + + return gl; +} + +export function compileShader( + gl: WebGL2RenderingContext, + type: number, + source: string +): WebGLShader { + const shader = gl.createShader(type); + if (!shader) { + throw new Error('Failed to create shader'); + } + + gl.shaderSource(shader, source); + gl.compileShader(shader); + + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + const info = gl.getShaderInfoLog(shader); + gl.deleteShader(shader); + throw new Error(`Shader compilation failed: ${info}`); + } + + return shader; +} + +export function createProgram( + gl: WebGL2RenderingContext, + vertexShader: WebGLShader, + fragmentShader: WebGLShader +): WebGLProgram { + const program = gl.createProgram(); + if (!program) { + throw new Error('Failed to create program'); + } + + gl.attachShader(program, vertexShader); + gl.attachShader(program, fragmentShader); + gl.linkProgram(program); + + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + const info = gl.getProgramInfoLog(program); + gl.deleteProgram(program); + throw new Error(`Program linking failed: ${info}`); + } + + return program; +} + +export function createShaderProgram( + gl: WebGL2RenderingContext, + vertexSource: string, + fragmentSource: string +): WebGLProgram { + const vertexShader = compileShader(gl, gl.VERTEX_SHADER, vertexSource); + const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource); + return createProgram(gl, vertexShader, fragmentShader); +} diff --git a/src/systems/boss-system.ts b/src/systems/boss-system.ts new file mode 100644 index 0000000..34af54e --- /dev/null +++ b/src/systems/boss-system.ts @@ -0,0 +1,386 @@ +import { INTERNAL_WIDTH, INTERNAL_HEIGHT } from '../constants'; +import type { Color, Sprite } from '../types'; +import type { Collidable } from './collision-system'; +import { BulletSystem } from './bullet-system'; + +export type BossType = 'outlaw' | 'sheriff' | 'bandit_king'; + +interface BossConfig { + name: string; + width: number; + height: number; + health: number; + color: Color; + reward: number; + phases: number; +} + +const BOSS_CONFIGS: Record = { + outlaw: { + name: 'Black Bart', + width: 28, + height: 36, + health: 30, + color: { r: 0.3, g: 0.3, b: 0.3, a: 1.0 }, // Dark gray + reward: 1000, + phases: 2, + }, + sheriff: { + name: 'Corrupt Sheriff', + width: 32, + height: 40, + health: 50, + color: { r: 0.7, g: 0.6, b: 0.2, a: 1.0 }, // Gold/badge + reward: 2000, + phases: 3, + }, + bandit_king: { + name: 'El Diablo', + width: 36, + height: 44, + health: 80, + color: { r: 0.6, g: 0.1, b: 0.1, a: 1.0 }, // Dark red + reward: 5000, + phases: 4, + }, +}; + +export interface Boss extends Collidable { + type: BossType; + config: BossConfig; + x: number; + y: number; + prevX: number; + prevY: number; + width: number; + height: number; + health: number; + maxHealth: number; + color: Color; + active: boolean; + // State + phase: number; + phaseTimer: number; + attackTimer: number; + moveTimer: number; + targetX: number; + entering: boolean; + defeated: boolean; + // Movement + vx: number; + vy: number; +} + +export class BossSystem { + private boss: Boss | null = null; + private playerX = INTERNAL_WIDTH / 2; + private playerY = INTERNAL_HEIGHT - 40; + private flashTimer = 0; + + spawnBoss(type: BossType): void { + const config = BOSS_CONFIGS[type]; + + this.boss = { + type, + config, + x: INTERNAL_WIDTH / 2 - config.width / 2, + y: -config.height - 10, + prevX: INTERNAL_WIDTH / 2 - config.width / 2, + prevY: -config.height - 10, + width: config.width, + height: config.height, + health: config.health, + maxHealth: config.health, + color: { ...config.color }, + active: true, + phase: 1, + phaseTimer: 0, + attackTimer: 0, + moveTimer: 0, + targetX: INTERNAL_WIDTH / 2, + entering: true, + defeated: false, + vx: 0, + vy: 0, + }; + } + + updatePlayerPosition(x: number, y: number): void { + this.playerX = x; + this.playerY = y; + } + + update(dt: number, bulletSystem: BulletSystem): void { + if (!this.boss || !this.boss.active) return; + + const boss = this.boss; + boss.prevX = boss.x; + boss.prevY = boss.y; + + // Flash timer for damage feedback + if (this.flashTimer > 0) { + this.flashTimer -= dt; + if (this.flashTimer <= 0) { + boss.color = { ...boss.config.color }; + } + } + + // Entrance animation + if (boss.entering) { + boss.y += 30 * dt; + if (boss.y >= 20) { + boss.y = 20; + boss.entering = false; + } + return; + } + + // Update phase based on health + const healthPercent = boss.health / boss.maxHealth; + const newPhase = Math.ceil((1 - healthPercent) * boss.config.phases) + 1; + if (newPhase > boss.phase && newPhase <= boss.config.phases) { + boss.phase = newPhase; + boss.phaseTimer = 0; + } + + boss.phaseTimer += dt; + boss.attackTimer += dt; + boss.moveTimer += dt; + + // Movement pattern based on phase + this.updateMovement(boss, dt); + + // Attack pattern based on boss type and phase + this.updateAttacks(boss, dt, bulletSystem); + + // Apply velocity + boss.x += boss.vx * dt; + boss.y += boss.vy * dt; + + // Clamp to screen + boss.x = Math.max(10, Math.min(INTERNAL_WIDTH - boss.width - 10, boss.x)); + boss.y = Math.max(15, Math.min(INTERNAL_HEIGHT * 0.4, boss.y)); + } + + private updateMovement(boss: Boss, dt: number): void { + const speed = 40 + boss.phase * 15; + + // Change target periodically + if (boss.moveTimer > 2.0 / boss.phase) { + boss.moveTimer = 0; + boss.targetX = 30 + Math.random() * (INTERNAL_WIDTH - 60 - boss.width); + } + + // Move toward target + const dx = boss.targetX - boss.x; + if (Math.abs(dx) > 5) { + boss.vx = Math.sign(dx) * speed; + } else { + boss.vx = 0; + } + + // Slight vertical movement in later phases + if (boss.phase >= 2) { + boss.vy = Math.sin(boss.phaseTimer * 2) * 20; + } else { + boss.vy = 0; + } + } + + private updateAttacks(boss: Boss, dt: number, bulletSystem: BulletSystem): void { + const attackInterval = Math.max(0.3, 1.5 - boss.phase * 0.3); + + if (boss.attackTimer < attackInterval) return; + boss.attackTimer = 0; + + const centerX = boss.x + boss.width / 2; + const bottomY = boss.y + boss.height; + const bulletColor: Color = { r: 1, g: 0.4, b: 0.1, a: 1 }; + + switch (boss.type) { + case 'outlaw': + this.attackOutlaw(boss, centerX, bottomY, bulletSystem, bulletColor); + break; + case 'sheriff': + this.attackSheriff(boss, centerX, bottomY, bulletSystem, bulletColor); + break; + case 'bandit_king': + this.attackBanditKing(boss, centerX, bottomY, bulletSystem, bulletColor); + break; + } + } + + private attackOutlaw(boss: Boss, x: number, y: number, bulletSystem: BulletSystem, color: Color): void { + const speed = 100 + boss.phase * 20; + + if (boss.phase === 1) { + // Single aimed shot + this.fireAtPlayer(x, y, speed, bulletSystem, color); + } else { + // Triple spread + for (let i = -1; i <= 1; i++) { + const angle = i * 0.3; + const vx = Math.sin(angle) * speed; + const vy = Math.cos(angle) * speed; + bulletSystem.spawnEnemyBullet(x, y, vx, vy, color); + } + } + } + + private attackSheriff(boss: Boss, x: number, y: number, bulletSystem: BulletSystem, color: Color): void { + const speed = 120 + boss.phase * 15; + + if (boss.phase === 1) { + // Double shot + bulletSystem.spawnEnemyBullet(x - 10, y, 0, speed, color); + bulletSystem.spawnEnemyBullet(x + 10, y, 0, speed, color); + } else if (boss.phase === 2) { + // Fan pattern + for (let i = -2; i <= 2; i++) { + const angle = i * 0.25; + const vx = Math.sin(angle) * speed; + const vy = Math.cos(angle) * speed; + bulletSystem.spawnEnemyBullet(x, y, vx, vy, color); + } + } else { + // Aimed + spread + this.fireAtPlayer(x, y, speed * 1.2, bulletSystem, { r: 1, g: 0.2, b: 0.2, a: 1 }); + for (let i = -1; i <= 1; i++) { + const angle = i * 0.4; + bulletSystem.spawnEnemyBullet(x, y, Math.sin(angle) * speed, Math.cos(angle) * speed, color); + } + } + } + + private attackBanditKing(boss: Boss, x: number, y: number, bulletSystem: BulletSystem, color: Color): void { + const speed = 90 + boss.phase * 25; + const time = boss.phaseTimer; + + if (boss.phase === 1) { + // Rotating pattern + for (let i = 0; i < 3; i++) { + const angle = time * 2 + i * (Math.PI * 2 / 3); + const vx = Math.sin(angle) * speed; + const vy = Math.cos(angle) * speed * 0.7 + speed * 0.3; + bulletSystem.spawnEnemyBullet(x, y, vx, vy, color); + } + } else if (boss.phase === 2) { + // Dense spread + for (let i = -3; i <= 3; i++) { + const angle = i * 0.2; + bulletSystem.spawnEnemyBullet(x, y, Math.sin(angle) * speed, Math.cos(angle) * speed, color); + } + } else if (boss.phase === 3) { + // Aimed bursts + for (let i = 0; i < 3; i++) { + setTimeout(() => { + if (this.boss?.active) { + this.fireAtPlayer(x, y, speed * 1.3, bulletSystem, { r: 1, g: 0.1, b: 0.1, a: 1 }); + } + }, i * 100); + } + } else { + // Everything + this.fireAtPlayer(x, y, speed * 1.5, bulletSystem, { r: 1, g: 0, b: 0, a: 1 }); + for (let i = 0; i < 6; i++) { + const angle = time * 3 + i * (Math.PI / 3); + bulletSystem.spawnEnemyBullet(x, y, Math.sin(angle) * speed, Math.cos(angle) * speed * 0.8 + 30, color); + } + } + } + + private fireAtPlayer(x: number, y: number, speed: number, bulletSystem: BulletSystem, color: Color): void { + const dx = this.playerX - x; + const dy = this.playerY - y; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist > 0) { + bulletSystem.spawnEnemyBullet(x, y, (dx / dist) * speed, (dy / dist) * speed, color); + } + } + + damageBoss(damage: number): { defeated: boolean; reward: number } { + if (!this.boss || !this.boss.active) { + return { defeated: false, reward: 0 }; + } + + this.boss.health -= damage; + this.boss.color = { r: 1, g: 1, b: 1, a: 1 }; + this.flashTimer = 0.08; + + if (this.boss.health <= 0) { + this.boss.defeated = true; + this.boss.active = false; + const reward = this.boss.config.reward; + return { defeated: true, reward }; + } + + return { defeated: false, reward: 0 }; + } + + getBoss(): Boss | null { + return this.boss; + } + + isActive(): boolean { + return this.boss !== null && this.boss.active; + } + + isDefeated(): boolean { + return this.boss !== null && this.boss.defeated; + } + + clear(): void { + this.boss = null; + } + + getSprites(alpha: number): Sprite[] { + if (!this.boss || !this.boss.active) return []; + + const boss = this.boss; + const x = boss.prevX + (boss.x - boss.prevX) * alpha; + const y = boss.prevY + (boss.y - boss.prevY) * alpha; + + const sprites: Sprite[] = []; + + // Boss body + sprites.push({ + x, + y, + width: boss.width, + height: boss.height, + color: boss.color, + }); + + // Health bar background + const barWidth = 60; + const barHeight = 6; + const barX = INTERNAL_WIDTH / 2 - barWidth / 2; + const barY = 8; + + sprites.push({ + x: barX - 1, + y: barY - 1, + width: barWidth + 2, + height: barHeight + 2, + color: { r: 0.2, g: 0.2, b: 0.2, a: 1 }, + }); + + // Health bar fill + const healthPercent = boss.health / boss.maxHealth; + const healthColor: Color = healthPercent > 0.5 + ? { r: 0.2, g: 0.8, b: 0.2, a: 1 } + : healthPercent > 0.25 + ? { r: 0.8, g: 0.8, b: 0.2, a: 1 } + : { r: 0.8, g: 0.2, b: 0.2, a: 1 }; + + sprites.push({ + x: barX, + y: barY, + width: barWidth * healthPercent, + height: barHeight, + color: healthColor, + }); + + return sprites; + } +} diff --git a/src/systems/bullet-system.ts b/src/systems/bullet-system.ts new file mode 100644 index 0000000..56a38c5 --- /dev/null +++ b/src/systems/bullet-system.ts @@ -0,0 +1,326 @@ +import { INTERNAL_WIDTH, INTERNAL_HEIGHT } from '../constants'; +import type { Color, Sprite } from '../types'; + +export type BulletDirection = 'left' | 'forward' | 'right'; + +export interface Bullet { + x: number; + y: number; + prevX: number; + prevY: number; + vx: number; + vy: number; + width: number; + height: number; + color: Color; + active: boolean; + isPlayerBullet: boolean; + damage: number; + poolIndex: number; // Track position in pool for O(1) deactivation +} + +// Bullet pool for performance +const MAX_BULLETS = 2000; + +// Bullet constants +const PLAYER_BULLET_SPEED = 250; // pixels per second +const PLAYER_BULLET_WIDTH = 3; +const PLAYER_BULLET_HEIGHT = 8; +const PLAYER_BULLET_COLOR: Color = { r: 1.0, g: 0.9, b: 0.3, a: 1.0 }; // Yellow + +// Direction angles (in radians from vertical) +const DIRECTION_ANGLES: Record = { + left: -Math.PI / 6, // -30 degrees + forward: 0, // straight up + right: Math.PI / 6, // +30 degrees +}; + +export class BulletSystem { + private bullets: Bullet[] = []; + private activeCount = 0; + + // Free list for O(1) bullet allocation + private freeList: number[] = []; + private freeListHead = 0; + + // Cached arrays to avoid allocations + private playerBulletCache: Bullet[] = []; + private enemyBulletCache: Bullet[] = []; + private spriteCache: Sprite[] = []; + + constructor() { + // Pre-allocate bullet pool + for (let i = 0; i < MAX_BULLETS; i++) { + this.bullets.push({ + x: 0, + y: 0, + prevX: 0, + prevY: 0, + vx: 0, + vy: 0, + width: PLAYER_BULLET_WIDTH, + height: PLAYER_BULLET_HEIGHT, + color: PLAYER_BULLET_COLOR, + active: false, + isPlayerBullet: true, + damage: 1, + poolIndex: i, + }); + // Initialize free list + this.freeList.push(i); + } + this.freeListHead = MAX_BULLETS; + + // Pre-allocate cache arrays + this.playerBulletCache = new Array(MAX_BULLETS); + this.enemyBulletCache = new Array(MAX_BULLETS); + this.spriteCache = new Array(MAX_BULLETS); + for (let i = 0; i < MAX_BULLETS; i++) { + this.spriteCache[i] = { x: 0, y: 0, width: 0, height: 0, color: { r: 0, g: 0, b: 0, a: 0 } }; + } + } + + spawnPlayerBullet( + x: number, + y: number, + direction: BulletDirection, + speedMultiplier: number = 1, + damageMultiplier: number = 1 + ): void { + const bullet = this.getInactiveBullet(); + if (!bullet) return; + + const angle = DIRECTION_ANGLES[direction]; + const speed = PLAYER_BULLET_SPEED * speedMultiplier; + + bullet.x = x - PLAYER_BULLET_WIDTH / 2; + bullet.y = y; + bullet.prevX = bullet.x; + bullet.prevY = bullet.y; + bullet.vx = Math.sin(angle) * speed; + bullet.vy = -Math.cos(angle) * speed; // Negative because Y goes down + bullet.width = PLAYER_BULLET_WIDTH; + bullet.height = PLAYER_BULLET_HEIGHT; + bullet.color = PLAYER_BULLET_COLOR; + bullet.active = true; + bullet.isPlayerBullet = true; + bullet.damage = damageMultiplier; + } + + spawnEnemyBullet( + x: number, + y: number, + vx: number, + vy: number, + color: Color = { r: 1.0, g: 0.3, b: 0.3, a: 1.0 } + ): void { + const bullet = this.getInactiveBullet(); + if (!bullet) return; + + bullet.x = x; + bullet.y = y; + bullet.prevX = x; + bullet.prevY = y; + bullet.vx = vx; + bullet.vy = vy; + bullet.width = 4; + bullet.height = 4; + bullet.color = color; + bullet.active = true; + bullet.isPlayerBullet = false; + bullet.damage = 1; + } + + private getInactiveBullet(): Bullet | null { + // O(1) allocation from free list + if (this.freeListHead <= 0) { + return null; + } + this.freeListHead--; + const index = this.freeList[this.freeListHead]; + return this.bullets[index]; + } + + private returnToFreeList(bullet: Bullet): void { + // O(1) return to free list + if (this.freeListHead < MAX_BULLETS) { + this.freeList[this.freeListHead] = bullet.poolIndex; + this.freeListHead++; + } + } + + update(dt: number): void { + this.activeCount = 0; + let playerCount = 0; + let enemyCount = 0; + + for (let i = 0; i < MAX_BULLETS; i++) { + const bullet = this.bullets[i]; + if (!bullet.active) continue; + + // Store previous position + bullet.prevX = bullet.x; + bullet.prevY = bullet.y; + + // Update position + bullet.x += bullet.vx * dt; + bullet.y += bullet.vy * dt; + + // Check bounds (with margin) + const margin = 20; + if ( + bullet.x < -margin || + bullet.x > INTERNAL_WIDTH + margin || + bullet.y < -margin || + bullet.y > INTERNAL_HEIGHT + margin + ) { + bullet.active = false; + this.returnToFreeList(bullet); + continue; + } + + // Cache bullets by type for collision detection + if (bullet.isPlayerBullet) { + this.playerBulletCache[playerCount++] = bullet; + } else { + this.enemyBulletCache[enemyCount++] = bullet; + } + + this.activeCount++; + } + + // Store counts for getters + (this as any)._playerBulletCount = playerCount; + (this as any)._enemyBulletCount = enemyCount; + } + + // Iterator for sprites - avoids array allocation + *iterateSprites(alpha: number): Generator { + for (let i = 0; i < MAX_BULLETS; i++) { + const bullet = this.bullets[i]; + if (!bullet.active) continue; + + // Interpolate position + const x = bullet.prevX + (bullet.x - bullet.prevX) * alpha; + const y = bullet.prevY + (bullet.y - bullet.prevY) * alpha; + + // Reuse cached sprite object + const sprite = this.spriteCache[i]; + sprite.x = x; + sprite.y = y; + sprite.width = bullet.width; + sprite.height = bullet.height; + sprite.color = bullet.color; + + yield sprite; + } + } + + // Batch render method - more efficient than getSprites + forEachSprite(alpha: number, callback: (sprite: Sprite) => void): void { + for (let i = 0; i < MAX_BULLETS; i++) { + const bullet = this.bullets[i]; + if (!bullet.active) continue; + + // Interpolate position + const x = bullet.prevX + (bullet.x - bullet.prevX) * alpha; + const y = bullet.prevY + (bullet.y - bullet.prevY) * alpha; + + // Reuse cached sprite object + const sprite = this.spriteCache[i]; + sprite.x = x; + sprite.y = y; + sprite.width = bullet.width; + sprite.height = bullet.height; + sprite.color = bullet.color; + + callback(sprite); + } + } + + getSprites(alpha: number): Sprite[] { + // Backward compatible version - still allocates but reuses sprite data + const sprites: Sprite[] = []; + + for (let i = 0; i < MAX_BULLETS; i++) { + const bullet = this.bullets[i]; + if (!bullet.active) continue; + + // Interpolate position + const x = bullet.prevX + (bullet.x - bullet.prevX) * alpha; + const y = bullet.prevY + (bullet.y - bullet.prevY) * alpha; + + sprites.push({ + x, + y, + width: bullet.width, + height: bullet.height, + color: bullet.color, + }); + } + + return sprites; + } + + // Get active bullets for collision detection - returns cached view + getPlayerBullets(): Bullet[] { + const count = (this as any)._playerBulletCount || 0; + // Return a slice of the cached array (still allocates, but much smaller) + return this.playerBulletCache.slice(0, count); + } + + getEnemyBullets(): Bullet[] { + const count = (this as any)._enemyBulletCount || 0; + return this.enemyBulletCache.slice(0, count); + } + + // Zero-allocation collision iteration + forEachPlayerBullet(callback: (bullet: Bullet) => boolean | void): void { + const count = (this as any)._playerBulletCount || 0; + for (let i = 0; i < count; i++) { + if (callback(this.playerBulletCache[i]) === false) break; + } + } + + forEachEnemyBullet(callback: (bullet: Bullet) => boolean | void): void { + const count = (this as any)._enemyBulletCount || 0; + for (let i = 0; i < count; i++) { + if (callback(this.enemyBulletCache[i]) === false) break; + } + } + + getPlayerBulletCount(): number { + return (this as any)._playerBulletCount || 0; + } + + getEnemyBulletCount(): number { + return (this as any)._enemyBulletCount || 0; + } + + getBulletDamage(bullet: Bullet): number { + return bullet.damage; + } + + deactivateBullet(bullet: Bullet): void { + if (bullet.active) { + bullet.active = false; + this.returnToFreeList(bullet); + } + } + + getActiveCount(): number { + return this.activeCount; + } + + clear(): void { + // Reset free list + this.freeListHead = MAX_BULLETS; + for (let i = 0; i < MAX_BULLETS; i++) { + this.bullets[i].active = false; + this.freeList[i] = i; + } + this.activeCount = 0; + (this as any)._playerBulletCount = 0; + (this as any)._enemyBulletCount = 0; + } +} diff --git a/src/systems/collision-system.ts b/src/systems/collision-system.ts new file mode 100644 index 0000000..1cb31ca --- /dev/null +++ b/src/systems/collision-system.ts @@ -0,0 +1,104 @@ +import type { Rect } from '../types'; + +export interface Collidable { + x: number; + y: number; + width: number; + height: number; + active: boolean; +} + +// AABB collision detection +export function checkCollision(a: Rect, b: Rect): boolean { + return ( + a.x < b.x + b.width && + a.x + a.width > b.x && + a.y < b.y + b.height && + a.y + a.height > b.y + ); +} + +// Check collision between two collidable objects +export function collides(a: Collidable, b: Collidable): boolean { + if (!a.active || !b.active) return false; + return checkCollision(a, b); +} + +// Check if point is inside rect +export function pointInRect(px: number, py: number, rect: Rect): boolean { + return ( + px >= rect.x && + px <= rect.x + rect.width && + py >= rect.y && + py <= rect.y + rect.height + ); +} + +// Get center of a rect +export function getCenter(rect: Rect): { x: number; y: number } { + return { + x: rect.x + rect.width / 2, + y: rect.y + rect.height / 2, + }; +} + +// Distance between two points +export function distance(x1: number, y1: number, x2: number, y2: number): number { + const dx = x2 - x1; + const dy = y2 - y1; + return Math.sqrt(dx * dx + dy * dy); +} + +// Circle collision (for future use) +export function circleCollision( + x1: number, + y1: number, + r1: number, + x2: number, + y2: number, + r2: number +): boolean { + return distance(x1, y1, x2, y2) < r1 + r2; +} + +export class CollisionSystem { + // Check bullets against a list of targets, return hit pairs + checkBulletsAgainstTargets( + bullets: Collidable[], + targets: T[] + ): Array<{ bullet: Collidable; target: T }> { + const hits: Array<{ bullet: Collidable; target: T }> = []; + + for (const bullet of bullets) { + if (!bullet.active) continue; + + for (const target of targets) { + if (!target.active) continue; + + if (collides(bullet, target)) { + hits.push({ bullet, target }); + } + } + } + + return hits; + } + + // Check single entity against targets + checkEntityAgainstTargets( + entity: Collidable, + targets: T[] + ): T | null { + if (!entity.active) return null; + + for (const target of targets) { + if (!target.active) continue; + + if (collides(entity, target)) { + return target; + } + } + + return null; + } +} diff --git a/src/systems/economy-system.ts b/src/systems/economy-system.ts new file mode 100644 index 0000000..ceb8502 --- /dev/null +++ b/src/systems/economy-system.ts @@ -0,0 +1,301 @@ +import type { BossType } from './boss-system'; + +export interface PlayerStats { + money: number; + health: number; + maxHealth: number; + lives: number; + // Upgrades + fireRate: number; // multiplier (1.0 = normal) + bulletSpeed: number; // multiplier + bulletDamage: number; // multiplier + moveSpeed: number; // multiplier + multiShot: boolean; // shoot extra bullets +} + +export interface ShopItem { + id: string; + name: string; + description: string; + cost: number; + type: 'weapon' | 'movement' | 'health' | 'special' | 'poster'; + effect: (stats: PlayerStats, economy?: EconomySystem) => void; + canBuy: (stats: PlayerStats, economy?: EconomySystem) => boolean; +} + +export interface WantedPoster { + bossType: BossType; + name: string; + cost: number; + reward: number; +} + +const WANTED_POSTERS: WantedPoster[] = [ + { bossType: 'outlaw', name: 'Black Bart', cost: 200, reward: 1000 }, + { bossType: 'sheriff', name: 'Corrupt Sheriff', cost: 400, reward: 2000 }, + { bossType: 'bandit_king', name: 'El Diablo', cost: 800, reward: 5000 }, +]; + +const SHOP_ITEMS: ShopItem[] = [ + { + id: 'fire_rate_1', + name: 'Quick Draw', + description: '+25% fire rate', + cost: 100, + type: 'weapon', + effect: (stats) => { stats.fireRate *= 1.25; }, + canBuy: (stats) => stats.fireRate < 2.0, + }, + { + id: 'fire_rate_2', + name: 'Rapid Fire', + description: '+50% fire rate', + cost: 250, + type: 'weapon', + effect: (stats) => { stats.fireRate *= 1.5; }, + canBuy: (stats) => stats.fireRate < 2.0, + }, + { + id: 'bullet_speed', + name: 'Hot Lead', + description: '+30% bullet speed', + cost: 150, + type: 'weapon', + effect: (stats) => { stats.bulletSpeed *= 1.3; }, + canBuy: (stats) => stats.bulletSpeed < 2.0, + }, + { + id: 'damage_up', + name: 'Hollow Point', + description: '+50% damage', + cost: 200, + type: 'weapon', + effect: (stats) => { stats.bulletDamage *= 1.5; }, + canBuy: (stats) => stats.bulletDamage < 3.0, + }, + { + id: 'multi_shot', + name: 'Twin Barrels', + description: 'Double bullets', + cost: 400, + type: 'weapon', + effect: (stats) => { stats.multiShot = true; }, + canBuy: (stats) => !stats.multiShot, + }, + { + id: 'move_speed', + name: 'Spurs', + description: '+20% move speed', + cost: 100, + type: 'movement', + effect: (stats) => { stats.moveSpeed *= 1.2; }, + canBuy: (stats) => stats.moveSpeed < 1.8, + }, + { + id: 'health_up', + name: 'Whiskey', + description: 'Restore 1 health', + cost: 75, + type: 'health', + effect: (stats) => { stats.health = Math.min(stats.health + 1, stats.maxHealth); }, + canBuy: (stats) => stats.health < stats.maxHealth, + }, + { + id: 'max_health', + name: 'Tough Hide', + description: '+1 max health', + cost: 300, + type: 'health', + effect: (stats) => { stats.maxHealth += 1; stats.health += 1; }, + canBuy: (stats) => stats.maxHealth < 6, + }, + { + id: 'extra_life', + name: 'Lucky Charm', + description: '+1 life', + cost: 500, + type: 'special', + effect: (stats) => { stats.lives += 1; }, + canBuy: (stats) => stats.lives < 5, + }, + // Wanted Posters + { + id: 'poster_outlaw', + name: 'WANTED: Black Bart', + description: 'Trigger boss fight!', + cost: 200, + type: 'poster', + effect: (stats, economy) => { economy?.setPendingBoss('outlaw'); }, + canBuy: (stats, economy) => !economy?.hasPendingBoss() && !economy?.hasDefeatedBoss('outlaw'), + }, + { + id: 'poster_sheriff', + name: 'WANTED: Sheriff', + description: 'Harder boss fight!', + cost: 400, + type: 'poster', + effect: (stats, economy) => { economy?.setPendingBoss('sheriff'); }, + canBuy: (stats, economy) => !economy?.hasPendingBoss() && economy?.hasDefeatedBoss('outlaw') === true && !economy?.hasDefeatedBoss('sheriff'), + }, + { + id: 'poster_bandit_king', + name: 'WANTED: El Diablo', + description: 'Final boss!', + cost: 800, + type: 'poster', + effect: (stats, economy) => { economy?.setPendingBoss('bandit_king'); }, + canBuy: (stats, economy) => !economy?.hasPendingBoss() && economy?.hasDefeatedBoss('sheriff') === true && !economy?.hasDefeatedBoss('bandit_king'), + }, +]; + +export class EconomySystem { + private stats: PlayerStats; + private baseStats: PlayerStats; + private defeatedBosses: Set = new Set(); + private pendingBoss: BossType | null = null; + + constructor() { + this.baseStats = { + money: 0, + health: 3, + maxHealth: 3, + lives: 3, + fireRate: 1.0, + bulletSpeed: 1.0, + bulletDamage: 1.0, + moveSpeed: 1.0, + multiShot: false, + }; + this.stats = { ...this.baseStats }; + } + + getStats(): PlayerStats { + return this.stats; + } + + addMoney(amount: number): void { + this.stats.money += amount; + } + + spendMoney(amount: number): boolean { + if (this.stats.money >= amount) { + this.stats.money -= amount; + return true; + } + return false; + } + + getMoney(): number { + return this.stats.money; + } + + heal(amount: number): void { + this.stats.health = Math.min(this.stats.health + amount, this.stats.maxHealth); + } + + takeDamage(amount: number): boolean { + this.stats.health -= amount; + + if (this.stats.health <= 0) { + this.stats.lives -= 1; + + if (this.stats.lives > 0) { + // Respawn with reset upgrades (keep money and lives) + this.onDeath(); + return false; // Not game over + } + return true; // Game over + } + return false; + } + + private onDeath(): void { + // Reset most upgrades on death (as per PRD) + const money = this.stats.money; + const lives = this.stats.lives; + + // Keep 50% of money on death + this.stats = { ...this.baseStats }; + this.stats.money = Math.floor(money * 0.5); + this.stats.lives = lives; + + // Clear pending boss on death + this.pendingBoss = null; + } + + getHealth(): number { + return this.stats.health; + } + + getMaxHealth(): number { + return this.stats.maxHealth; + } + + getLives(): number { + return this.stats.lives; + } + + // Boss poster system + setPendingBoss(bossType: BossType): void { + this.pendingBoss = bossType; + } + + getPendingBoss(): BossType | null { + return this.pendingBoss; + } + + hasPendingBoss(): boolean { + return this.pendingBoss !== null; + } + + clearPendingBoss(): void { + this.pendingBoss = null; + } + + markBossDefeated(bossType: BossType): void { + this.defeatedBosses.add(bossType); + this.pendingBoss = null; + } + + hasDefeatedBoss(bossType: BossType): boolean { + return this.defeatedBosses.has(bossType); + } + + getDefeatedBossCount(): number { + return this.defeatedBosses.size; + } + + getNextAvailableBoss(): BossType | null { + if (!this.hasDefeatedBoss('outlaw')) return 'outlaw'; + if (!this.hasDefeatedBoss('sheriff')) return 'sheriff'; + if (!this.hasDefeatedBoss('bandit_king')) return 'bandit_king'; + return null; + } + + getShopItems(): ShopItem[] { + return SHOP_ITEMS; + } + + getAvailableItems(): ShopItem[] { + return SHOP_ITEMS.filter(item => + item.canBuy(this.stats, this) && this.stats.money >= item.cost + ); + } + + buyItem(itemId: string): boolean { + const item = SHOP_ITEMS.find(i => i.id === itemId); + if (!item) return false; + + if (!item.canBuy(this.stats, this)) return false; + if (!this.spendMoney(item.cost)) return false; + + item.effect(this.stats, this); + return true; + } + + reset(): void { + this.stats = { ...this.baseStats }; + this.defeatedBosses.clear(); + this.pendingBoss = null; + } +} diff --git a/src/systems/enemy-system.ts b/src/systems/enemy-system.ts new file mode 100644 index 0000000..461676d --- /dev/null +++ b/src/systems/enemy-system.ts @@ -0,0 +1,290 @@ +import { INTERNAL_WIDTH, INTERNAL_HEIGHT } from '../constants'; +import type { Color, Sprite, EnemyType } from '../types'; +import type { Collidable } from './collision-system'; +import { ENEMY_TYPES, EnemyTypeConfig } from './enemy-types'; +import { BulletSystem } from './bullet-system'; + +export interface Enemy extends Collidable { + x: number; + y: number; + prevX: number; + prevY: number; + width: number; + height: number; + vx: number; + vy: number; + health: number; + maxHealth: number; + color: Color; + active: boolean; + type: EnemyType; + config: EnemyTypeConfig; + // Behavior state + shootTimer: number; + behaviorTimer: number; + strafeDirection: number; + stopped: boolean; +} + +const MAX_ENEMIES = 100; + +export class EnemySystem { + private enemies: Enemy[] = []; + private playerX = INTERNAL_WIDTH / 2; + private playerY = INTERNAL_HEIGHT - 40; + + constructor() { + // Pre-allocate enemy pool + for (let i = 0; i < MAX_ENEMIES; i++) { + this.enemies.push(this.createEmptyEnemy()); + } + } + + private createEmptyEnemy(): Enemy { + return { + x: 0, + y: 0, + prevX: 0, + prevY: 0, + width: 16, + height: 20, + vx: 0, + vy: 0, + health: 1, + maxHealth: 1, + color: { r: 1, g: 1, b: 1, a: 1 }, + active: false, + type: 'bandit', + config: ENEMY_TYPES.bandit, + shootTimer: 0, + behaviorTimer: 0, + strafeDirection: 1, + stopped: false, + }; + } + + updatePlayerPosition(x: number, y: number): void { + this.playerX = x; + this.playerY = y; + } + + spawn(type: EnemyType, x: number): void { + const enemy = this.getInactiveEnemy(); + if (!enemy) return; + + const config = ENEMY_TYPES[type]; + + enemy.type = type; + enemy.config = config; + enemy.x = x - config.width / 2; + enemy.y = -config.height; + enemy.prevX = enemy.x; + enemy.prevY = enemy.y; + enemy.vx = 0; + enemy.vy = config.speed; + enemy.health = config.health; + enemy.maxHealth = config.health; + enemy.width = config.width; + enemy.height = config.height; + enemy.color = { ...config.color }; + enemy.active = true; + enemy.shootTimer = config.shootInterval ?? 0; + enemy.behaviorTimer = 0; + enemy.strafeDirection = Math.random() > 0.5 ? 1 : -1; + enemy.stopped = false; + } + + private getInactiveEnemy(): Enemy | null { + for (const enemy of this.enemies) { + if (!enemy.active) return enemy; + } + return null; + } + + update(dt: number, bulletSystem: BulletSystem): void { + for (const enemy of this.enemies) { + if (!enemy.active) continue; + + enemy.prevX = enemy.x; + enemy.prevY = enemy.y; + + // Update behavior based on type + this.updateBehavior(enemy, dt, bulletSystem); + + // Apply velocity + enemy.x += enemy.vx * dt; + enemy.y += enemy.vy * dt; + + // Clamp to horizontal bounds + if (enemy.x < 0) { + enemy.x = 0; + enemy.strafeDirection = 1; + } else if (enemy.x > INTERNAL_WIDTH - enemy.width) { + enemy.x = INTERNAL_WIDTH - enemy.width; + enemy.strafeDirection = -1; + } + + // Remove if off bottom + if (enemy.y > INTERNAL_HEIGHT + 30) { + enemy.active = false; + } + } + } + + private updateBehavior(enemy: Enemy, dt: number, bulletSystem: BulletSystem): void { + const config = enemy.config; + + switch (config.behavior) { + case 'walk_down': + // Simple walk down, no special behavior + enemy.vy = config.speed; + break; + + case 'strafe': + // Move down while strafing left/right + enemy.vy = config.speed; + enemy.vx = config.speed * 0.8 * enemy.strafeDirection; + + // Occasionally change direction + enemy.behaviorTimer += dt; + if (enemy.behaviorTimer > 1.5) { + enemy.behaviorTimer = 0; + if (Math.random() > 0.6) { + enemy.strafeDirection *= -1; + } + } + + // Shoot at player + this.handleShooting(enemy, dt, bulletSystem); + break; + + case 'sniper': + // Move down until in position, then stop and shoot + if (enemy.y < INTERNAL_HEIGHT * 0.3) { + enemy.vy = config.speed; + } else { + enemy.vy = 0; + enemy.stopped = true; + } + + // Shoot at player when stopped + if (enemy.stopped) { + this.handleShooting(enemy, dt, bulletSystem, true); + } + + // Eventually move again + if (enemy.stopped) { + enemy.behaviorTimer += dt; + if (enemy.behaviorTimer > 4) { + enemy.stopped = false; + enemy.behaviorTimer = 0; + enemy.vy = config.speed; + } + } + break; + + case 'bomber': + // Move down while throwing projectiles in arc + enemy.vy = config.speed; + enemy.vx = Math.sin(enemy.behaviorTimer * 2) * config.speed * 0.5; + enemy.behaviorTimer += dt; + + // Throw bombs + this.handleShooting(enemy, dt, bulletSystem, false, true); + break; + } + } + + private handleShooting( + enemy: Enemy, + dt: number, + bulletSystem: BulletSystem, + aimed: boolean = false, + arced: boolean = false + ): void { + const config = enemy.config; + if (!config.shootInterval || !config.bulletSpeed) return; + + enemy.shootTimer -= dt; + if (enemy.shootTimer <= 0) { + enemy.shootTimer = config.shootInterval; + + const bulletX = enemy.x + enemy.width / 2; + const bulletY = enemy.y + enemy.height; + + if (aimed) { + // Aim at player + const dx = this.playerX - bulletX; + const dy = this.playerY - bulletY; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist > 0) { + const vx = (dx / dist) * config.bulletSpeed; + const vy = (dy / dist) * config.bulletSpeed; + bulletSystem.spawnEnemyBullet(bulletX, bulletY, vx, vy, { r: 1, g: 0.3, b: 0.3, a: 1 }); + } + } else if (arced) { + // Arc toward player position + const dx = this.playerX - bulletX; + const vx = dx * 0.5; + const vy = config.bulletSpeed * 0.8; + bulletSystem.spawnEnemyBullet(bulletX, bulletY, vx, vy, { r: 1, g: 0.6, b: 0.1, a: 1 }); + } else { + // Shoot straight down + bulletSystem.spawnEnemyBullet(bulletX, bulletY, 0, config.bulletSpeed, { r: 1, g: 0.3, b: 0.3, a: 1 }); + } + } + } + + getActiveEnemies(): Enemy[] { + return this.enemies.filter(e => e.active); + } + + damageEnemy(enemy: Enemy, damage: number): { killed: boolean; points: number } { + enemy.health -= damage; + + // Flash white on hit + enemy.color = { r: 1, g: 1, b: 1, a: 1 }; + setTimeout(() => { + if (enemy.active) { + enemy.color = { ...enemy.config.color }; + } + }, 50); + + if (enemy.health <= 0) { + enemy.active = false; + return { killed: true, points: enemy.config.points }; + } + return { killed: false, points: 0 }; + } + + getSprites(alpha: number): Sprite[] { + const sprites: Sprite[] = []; + + for (const enemy of this.enemies) { + if (!enemy.active) continue; + + const x = enemy.prevX + (enemy.x - enemy.prevX) * alpha; + const y = enemy.prevY + (enemy.y - enemy.prevY) * alpha; + + sprites.push({ + x, + y, + width: enemy.width, + height: enemy.height, + color: enemy.color, + }); + } + + return sprites; + } + + getActiveCount(): number { + return this.enemies.filter(e => e.active).length; + } + + clear(): void { + for (const enemy of this.enemies) { + enemy.active = false; + } + } +} diff --git a/src/systems/enemy-types.ts b/src/systems/enemy-types.ts new file mode 100644 index 0000000..154428e --- /dev/null +++ b/src/systems/enemy-types.ts @@ -0,0 +1,65 @@ +import type { Color, EnemyType } from '../types'; + +export interface EnemyTypeConfig { + width: number; + height: number; + health: number; + speed: number; + color: Color; + behavior: 'walk_down' | 'strafe' | 'sniper' | 'bomber'; + shootInterval?: number; // seconds between shots (if applicable) + bulletSpeed?: number; + points: number; +} + +export const ENEMY_TYPES: Record = { + // Basic enemy - walks straight down + bandit: { + width: 14, + height: 18, + health: 1, + speed: 45, + color: { r: 0.7, g: 0.4, b: 0.2, a: 1.0 }, // Brown + behavior: 'walk_down', + points: 100, + }, + + // Strafing enemy - moves side to side while advancing + gunman: { + width: 16, + height: 20, + health: 2, + speed: 35, + color: { r: 0.8, g: 0.2, b: 0.2, a: 1.0 }, // Red + behavior: 'strafe', + shootInterval: 2.0, + bulletSpeed: 120, + points: 200, + }, + + // Sniper - stops and shoots at player + rifleman: { + width: 14, + height: 22, + health: 2, + speed: 25, + color: { r: 0.3, g: 0.3, b: 0.7, a: 1.0 }, // Blue + behavior: 'sniper', + shootInterval: 1.5, + bulletSpeed: 180, + points: 300, + }, + + // Bomber - throws explosives in an arc + dynamite: { + width: 18, + height: 20, + health: 3, + speed: 30, + color: { r: 0.8, g: 0.5, b: 0.1, a: 1.0 }, // Orange + behavior: 'bomber', + shootInterval: 2.5, + bulletSpeed: 100, + points: 400, + }, +}; diff --git a/src/systems/hud-system.ts b/src/systems/hud-system.ts new file mode 100644 index 0000000..1fee7ae --- /dev/null +++ b/src/systems/hud-system.ts @@ -0,0 +1,142 @@ +import { INTERNAL_WIDTH, INTERNAL_HEIGHT } from '../constants'; +import type { Sprite, Color } from '../types'; +import { EconomySystem } from './economy-system'; + +const HEALTH_FULL: Color = { r: 0.8, g: 0.2, b: 0.2, a: 1.0 }; +const HEALTH_EMPTY: Color = { r: 0.3, g: 0.1, b: 0.1, a: 1.0 }; +const MONEY_COLOR: Color = { r: 1.0, g: 0.85, b: 0.2, a: 1.0 }; +const LIVES_COLOR: Color = { r: 0.2, g: 0.7, b: 0.9, a: 1.0 }; +const SCORE_BG: Color = { r: 0.1, g: 0.08, b: 0.05, a: 0.8 }; + +export class HudSystem { + private economy: EconomySystem; + private displayScore = 0; + private displayMoney = 0; + private wave = 1; + private showDamageFlash = false; + private damageFlashTimer = 0; + + constructor(economy: EconomySystem) { + this.economy = economy; + } + + update(dt: number, score: number, wave: number): void { + // Smooth score counting + const targetScore = score; + if (this.displayScore < targetScore) { + this.displayScore += Math.ceil((targetScore - this.displayScore) * dt * 5); + if (this.displayScore > targetScore) this.displayScore = targetScore; + } + + // Smooth money counting + const targetMoney = this.economy.getMoney(); + if (this.displayMoney < targetMoney) { + this.displayMoney += Math.ceil((targetMoney - this.displayMoney) * dt * 8); + if (this.displayMoney > targetMoney) this.displayMoney = targetMoney; + } else if (this.displayMoney > targetMoney) { + this.displayMoney = targetMoney; + } + + this.wave = wave; + + // Damage flash + if (this.damageFlashTimer > 0) { + this.damageFlashTimer -= dt; + if (this.damageFlashTimer <= 0) { + this.showDamageFlash = false; + } + } + } + + triggerDamageFlash(): void { + this.showDamageFlash = true; + this.damageFlashTimer = 0.15; + } + + getSprites(): Sprite[] { + const sprites: Sprite[] = []; + const stats = this.economy.getStats(); + + // Top bar background + sprites.push({ + x: 0, + y: 0, + width: INTERNAL_WIDTH, + height: 14, + color: SCORE_BG, + }); + + // Health hearts + const heartSize = 8; + const heartSpacing = 2; + const heartStartX = 4; + const heartY = 3; + + for (let i = 0; i < stats.maxHealth; i++) { + sprites.push({ + x: heartStartX + i * (heartSize + heartSpacing), + y: heartY, + width: heartSize, + height: heartSize, + color: i < stats.health ? HEALTH_FULL : HEALTH_EMPTY, + }); + } + + // Lives indicator + const livesX = heartStartX + stats.maxHealth * (heartSize + heartSpacing) + 8; + for (let i = 0; i < stats.lives; i++) { + sprites.push({ + x: livesX + i * 6, + y: heartY + 2, + width: 4, + height: 4, + color: LIVES_COLOR, + }); + } + + // Money display (right side) + const moneyX = INTERNAL_WIDTH - 50; + sprites.push({ + x: moneyX, + y: heartY, + width: 8, + height: 8, + color: MONEY_COLOR, + }); + + // Wave indicator (center) + const waveX = INTERNAL_WIDTH / 2 - 10; + sprites.push({ + x: waveX, + y: heartY, + width: 20, + height: 8, + color: { r: 0.5, g: 0.4, b: 0.3, a: 1 }, + }); + + // Damage flash overlay + if (this.showDamageFlash) { + sprites.push({ + x: 0, + y: 0, + width: INTERNAL_WIDTH, + height: INTERNAL_HEIGHT, + color: { r: 1, g: 0, b: 0, a: 0.3 }, + }); + } + + return sprites; + } + + getDisplayScore(): number { + return this.displayScore; + } + + getDisplayMoney(): number { + return this.displayMoney; + } + + getWave(): number { + return this.wave; + } +} diff --git a/src/systems/perf-monitor.ts b/src/systems/perf-monitor.ts new file mode 100644 index 0000000..58f7932 --- /dev/null +++ b/src/systems/perf-monitor.ts @@ -0,0 +1,131 @@ +import type { Sprite } from '../types'; +import { INTERNAL_WIDTH } from '../constants'; + +export class PerfMonitor { + private frameCount = 0; + private lastFpsTime = 0; + private fps = 0; + private frameTimes: number[] = []; + private maxFrameTimes = 60; + + // Stats tracking + private bulletCount = 0; + private enemyCount = 0; + private quadCount = 0; + + // Display toggle + private visible = true; + + constructor() { + this.lastFpsTime = performance.now(); + + // Toggle with F3 key + window.addEventListener('keydown', (e) => { + if (e.key === 'F3') { + this.visible = !this.visible; + e.preventDefault(); + } + }); + } + + beginFrame(): void { + this.frameTimes.push(performance.now()); + if (this.frameTimes.length > this.maxFrameTimes) { + this.frameTimes.shift(); + } + } + + endFrame(): void { + this.frameCount++; + + const now = performance.now(); + const elapsed = now - this.lastFpsTime; + + if (elapsed >= 1000) { + this.fps = Math.round((this.frameCount * 1000) / elapsed); + this.frameCount = 0; + this.lastFpsTime = now; + } + } + + setStats(bullets: number, enemies: number, quads: number): void { + this.bulletCount = bullets; + this.enemyCount = enemies; + this.quadCount = quads; + } + + getFPS(): number { + return this.fps; + } + + getAverageFrameTime(): number { + if (this.frameTimes.length < 2) return 0; + + let total = 0; + for (let i = 1; i < this.frameTimes.length; i++) { + total += this.frameTimes[i] - this.frameTimes[i - 1]; + } + return total / (this.frameTimes.length - 1); + } + + isVisible(): boolean { + return this.visible; + } + + getSprites(): Sprite[] { + if (!this.visible) return []; + + const sprites: Sprite[] = []; + + // Background bar at top + sprites.push({ + x: 0, + y: 0, + width: INTERNAL_WIDTH, + height: 10, + color: { r: 0, g: 0, b: 0, a: 0.7 }, + }); + + // FPS indicator color + let fpsColor = { r: 0.2, g: 0.8, b: 0.2, a: 1 }; // Green = good + if (this.fps < 55) { + fpsColor = { r: 0.8, g: 0.8, b: 0.2, a: 1 }; // Yellow = warning + } + if (this.fps < 45) { + fpsColor = { r: 0.8, g: 0.2, b: 0.2, a: 1 }; // Red = bad + } + + // FPS bar (visual indicator) + const fpsWidth = Math.min(60, this.fps); + sprites.push({ + x: 2, + y: 2, + width: fpsWidth, + height: 6, + color: fpsColor, + }); + + // Stats indicators (small dots) + // Bullets indicator + const bulletBarWidth = Math.min(50, this.bulletCount / 20); + sprites.push({ + x: 70, + y: 2, + width: bulletBarWidth, + height: 6, + color: { r: 1, g: 0.9, b: 0.3, a: 1 }, // Yellow (bullet color) + }); + + // Enemies indicator + const enemyBarWidth = Math.min(30, this.enemyCount * 3); + sprites.push({ + x: 130, + y: 2, + width: enemyBarWidth, + height: 6, + color: { r: 0.8, g: 0.3, b: 0.3, a: 1 }, // Red (enemy color) + }); + + return sprites; + } +} diff --git a/src/systems/pickup-system.ts b/src/systems/pickup-system.ts new file mode 100644 index 0000000..b8c51ac --- /dev/null +++ b/src/systems/pickup-system.ts @@ -0,0 +1,182 @@ +import { INTERNAL_WIDTH, INTERNAL_HEIGHT } from '../constants'; +import type { Color, Sprite } from '../types'; +import type { Collidable } from './collision-system'; + +export type PickupType = 'coin' | 'gold' | 'health'; + +interface Pickup extends Collidable { + x: number; + y: number; + prevX: number; + prevY: number; + width: number; + height: number; + vy: number; + type: PickupType; + value: number; + color: Color; + active: boolean; + lifetime: number; + blinkTimer: number; +} + +const MAX_PICKUPS = 100; + +const PICKUP_CONFIGS: Record = { + coin: { + width: 6, + height: 6, + color: { r: 1.0, g: 0.85, b: 0.2, a: 1.0 }, // Gold + value: 10, + }, + gold: { + width: 8, + height: 8, + color: { r: 1.0, g: 0.95, b: 0.4, a: 1.0 }, // Bright gold + value: 50, + }, + health: { + width: 8, + height: 8, + color: { r: 1.0, g: 0.3, b: 0.3, a: 1.0 }, // Red + value: 1, + }, +}; + +export class PickupSystem { + private pickups: Pickup[] = []; + + constructor() { + for (let i = 0; i < MAX_PICKUPS; i++) { + this.pickups.push({ + x: 0, + y: 0, + prevX: 0, + prevY: 0, + width: 6, + height: 6, + vy: 0, + type: 'coin', + value: 10, + color: PICKUP_CONFIGS.coin.color, + active: false, + lifetime: 0, + blinkTimer: 0, + }); + } + } + + spawn(x: number, y: number, type: PickupType): void { + const pickup = this.getInactive(); + if (!pickup) return; + + const config = PICKUP_CONFIGS[type]; + + pickup.x = x - config.width / 2; + pickup.y = y; + pickup.prevX = pickup.x; + pickup.prevY = pickup.y; + pickup.vy = 20 + Math.random() * 10; + pickup.width = config.width; + pickup.height = config.height; + pickup.type = type; + pickup.value = config.value; + pickup.color = { ...config.color }; + pickup.active = true; + pickup.lifetime = 8; // seconds before disappearing + pickup.blinkTimer = 0; + } + + spawnFromEnemy(x: number, y: number, points: number): void { + // Spawn coins based on enemy point value + const coinCount = Math.floor(points / 100); + + for (let i = 0; i < coinCount; i++) { + const offsetX = (Math.random() - 0.5) * 20; + const offsetY = (Math.random() - 0.5) * 10; + + // 20% chance for gold coin + const type: PickupType = Math.random() < 0.2 ? 'gold' : 'coin'; + this.spawn(x + offsetX, y + offsetY, type); + } + + // Small chance for health drop + if (Math.random() < 0.05) { + this.spawn(x, y, 'health'); + } + } + + private getInactive(): Pickup | null { + for (const pickup of this.pickups) { + if (!pickup.active) return pickup; + } + return null; + } + + update(dt: number): void { + for (const pickup of this.pickups) { + if (!pickup.active) continue; + + pickup.prevX = pickup.x; + pickup.prevY = pickup.y; + + // Slow fall + pickup.y += pickup.vy * dt; + pickup.vy *= 0.98; // Slow down + + // Update lifetime + pickup.lifetime -= dt; + + // Blink when about to expire + if (pickup.lifetime < 2) { + pickup.blinkTimer += dt; + } + + // Remove if expired or off screen + if (pickup.lifetime <= 0 || pickup.y > INTERNAL_HEIGHT + 20) { + pickup.active = false; + } + } + } + + getActivePickups(): Pickup[] { + return this.pickups.filter(p => p.active); + } + + collectPickup(pickup: Pickup): { type: PickupType; value: number } { + pickup.active = false; + return { type: pickup.type, value: pickup.value }; + } + + getSprites(alpha: number): Sprite[] { + const sprites: Sprite[] = []; + + for (const pickup of this.pickups) { + if (!pickup.active) continue; + + // Blink effect + if (pickup.lifetime < 2) { + if (Math.floor(pickup.blinkTimer * 8) % 2 === 0) continue; + } + + const x = pickup.prevX + (pickup.x - pickup.prevX) * alpha; + const y = pickup.prevY + (pickup.y - pickup.prevY) * alpha; + + sprites.push({ + x, + y, + width: pickup.width, + height: pickup.height, + color: pickup.color, + }); + } + + return sprites; + } + + clear(): void { + for (const pickup of this.pickups) { + pickup.active = false; + } + } +} diff --git a/src/systems/screen-effects.ts b/src/systems/screen-effects.ts new file mode 100644 index 0000000..ec0c347 --- /dev/null +++ b/src/systems/screen-effects.ts @@ -0,0 +1,105 @@ +import { Renderer } from '../rendering/renderer'; + +export class ScreenEffects { + private renderer: Renderer; + + // Screen shake + private shakeIntensity = 0; + private shakeDuration = 0; + private shakeTimer = 0; + + // Damage flash + private flashIntensity = 0; + private flashDuration = 0; + private flashTimer = 0; + + // Brightness pulse (for boss defeated, etc.) + private brightnessTarget = 1.0; + private brightness = 1.0; + + constructor(renderer: Renderer) { + this.renderer = renderer; + } + + update(dt: number): void { + // Update screen shake + if (this.shakeTimer > 0) { + this.shakeTimer -= dt; + const progress = this.shakeTimer / this.shakeDuration; + this.renderer.setScreenShake(this.shakeIntensity * progress); + } else { + this.renderer.setScreenShake(0); + } + + // Update damage flash + if (this.flashTimer > 0) { + this.flashTimer -= dt; + const progress = this.flashTimer / this.flashDuration; + this.renderer.setDamageFlash(this.flashIntensity * progress); + } else { + this.renderer.setDamageFlash(0); + } + + // Update brightness + this.brightness += (this.brightnessTarget - this.brightness) * dt * 5; + this.renderer.setBrightness(this.brightness); + } + + // Trigger screen shake + shake(intensity: number, duration: number = 0.2): void { + // Only override if stronger than current shake + if (intensity > this.shakeIntensity * (this.shakeTimer / this.shakeDuration)) { + this.shakeIntensity = intensity; + this.shakeDuration = duration; + this.shakeTimer = duration; + } + } + + // Trigger damage flash (red tint) + damageFlash(intensity: number = 1.0, duration: number = 0.15): void { + this.flashIntensity = intensity; + this.flashDuration = duration; + this.flashTimer = duration; + } + + // Set brightness (for fade effects) + setBrightness(brightness: number): void { + this.brightnessTarget = brightness; + } + + // Instant brightness set (for transitions) + setBrightnessImmediate(brightness: number): void { + this.brightness = brightness; + this.brightnessTarget = brightness; + this.renderer.setBrightness(brightness); + } + + // Quick flash to white and back + whiteFlash(duration: number = 0.3): void { + this.brightness = 2.0; + this.brightnessTarget = 1.0; + } + + // Common effect presets + onPlayerHit(): void { + this.shake(3, 0.15); + this.damageFlash(1.0, 0.12); + } + + onEnemyKill(): void { + this.shake(1, 0.08); + } + + onBossHit(): void { + this.shake(2, 0.1); + } + + onBossDefeated(): void { + this.shake(8, 0.5); + this.whiteFlash(0.5); + } + + onExplosion(): void { + this.shake(5, 0.25); + } +} diff --git a/src/systems/scroll-system.ts b/src/systems/scroll-system.ts new file mode 100644 index 0000000..f2ed91e --- /dev/null +++ b/src/systems/scroll-system.ts @@ -0,0 +1,173 @@ +import { INTERNAL_WIDTH, INTERNAL_HEIGHT } from '../constants'; +import type { Sprite, Color } from '../types'; + +interface BackgroundElement { + x: number; + y: number; + width: number; + height: number; + color: Color; + speed: number; // Multiplier for parallax +} + +// Western town background colors +const GROUND_COLOR: Color = { r: 0.6, g: 0.45, b: 0.3, a: 1.0 }; // Dusty road +const BUILDING_COLOR: Color = { r: 0.5, g: 0.35, b: 0.2, a: 1.0 }; // Wood buildings +const BUILDING_DARK: Color = { r: 0.35, g: 0.25, b: 0.15, a: 1.0 }; // Shadows +const CACTUS_COLOR: Color = { r: 0.3, g: 0.5, b: 0.25, a: 1.0 }; // Cacti + +export class ScrollSystem { + private scrollY = 0; + private scrollSpeed = 20; + private elements: BackgroundElement[] = []; + private prevScrollY = 0; + + constructor() { + this.generateBackground(); + } + + private generateBackground(): void { + this.elements = []; + + // Generate repeating background elements + // Total height should be enough to scroll seamlessly + const totalHeight = INTERNAL_HEIGHT * 3; + + // Left side buildings (parallax layer) + for (let y = 0; y < totalHeight; y += 60) { + const height = 40 + Math.random() * 30; + this.elements.push({ + x: 0, + y: y, + width: 25 + Math.random() * 15, + height, + color: BUILDING_COLOR, + speed: 0.8, + }); + // Building shadow/detail + this.elements.push({ + x: 0, + y: y, + width: 8, + height, + color: BUILDING_DARK, + speed: 0.8, + }); + } + + // Right side buildings + for (let y = 30; y < totalHeight; y += 55) { + const width = 20 + Math.random() * 20; + const height = 35 + Math.random() * 35; + this.elements.push({ + x: INTERNAL_WIDTH - width, + y: y, + width, + height, + color: BUILDING_COLOR, + speed: 0.8, + }); + // Building shadow/detail + this.elements.push({ + x: INTERNAL_WIDTH - 8, + y: y, + width: 8, + height, + color: BUILDING_DARK, + speed: 0.8, + }); + } + + // Cacti and rocks scattered along sides + for (let y = 20; y < totalHeight; y += 80) { + if (Math.random() > 0.4) { + // Left cactus + this.elements.push({ + x: 45 + Math.random() * 20, + y: y + Math.random() * 40, + width: 6, + height: 12 + Math.random() * 8, + color: CACTUS_COLOR, + speed: 1.0, + }); + } + if (Math.random() > 0.4) { + // Right cactus + this.elements.push({ + x: INTERNAL_WIDTH - 70 + Math.random() * 20, + y: y + Math.random() * 40, + width: 6, + height: 12 + Math.random() * 8, + color: CACTUS_COLOR, + speed: 1.0, + }); + } + } + + // Road markings/details + for (let y = 0; y < totalHeight; y += 40) { + this.elements.push({ + x: INTERNAL_WIDTH / 2 - 2, + y: y, + width: 4, + height: 20, + color: { r: 0.5, g: 0.4, b: 0.25, a: 1.0 }, + speed: 1.0, + }); + } + } + + setScrollSpeed(speed: number): void { + this.scrollSpeed = speed; + } + + update(dt: number): void { + this.prevScrollY = this.scrollY; + this.scrollY += this.scrollSpeed * dt; + + // Wrap scroll position to prevent overflow + const wrapHeight = INTERNAL_HEIGHT * 2; + if (this.scrollY >= wrapHeight) { + this.scrollY -= wrapHeight; + this.prevScrollY -= wrapHeight; + } + } + + getSprites(alpha: number): Sprite[] { + const sprites: Sprite[] = []; + const interpolatedScroll = this.prevScrollY + (this.scrollY - this.prevScrollY) * alpha; + + for (const element of this.elements) { + // Apply parallax + let y = element.y + interpolatedScroll * element.speed; + + // Wrap elements that scroll off bottom + const wrapHeight = INTERNAL_HEIGHT * 2; + y = ((y % wrapHeight) + wrapHeight) % wrapHeight; + + // Only draw if visible + if (y > -element.height && y < INTERNAL_HEIGHT + element.height) { + sprites.push({ + x: element.x, + y: y - INTERNAL_HEIGHT, // Offset to start from top + width: element.width, + height: element.height, + color: element.color, + }); + } + } + + return sprites; + } + + getGroundSprite(): Sprite { + // Full screen ground/road + return { + x: 50, + y: 0, + width: INTERNAL_WIDTH - 100, + height: INTERNAL_HEIGHT, + color: GROUND_COLOR, + }; + } +} diff --git a/src/systems/shop-system.ts b/src/systems/shop-system.ts new file mode 100644 index 0000000..9e4fc58 --- /dev/null +++ b/src/systems/shop-system.ts @@ -0,0 +1,167 @@ +import { INTERNAL_WIDTH, INTERNAL_HEIGHT } from '../constants'; +import type { Sprite, Color } from '../types'; +import { EconomySystem, ShopItem } from './economy-system'; + +const SHOP_BG_COLOR: Color = { r: 0.1, g: 0.08, b: 0.05, a: 0.95 }; +const SHOP_HEADER_COLOR: Color = { r: 0.6, g: 0.45, b: 0.2, a: 1.0 }; +const ITEM_BG_COLOR: Color = { r: 0.2, g: 0.15, b: 0.1, a: 1.0 }; +const ITEM_SELECTED_COLOR: Color = { r: 0.4, g: 0.3, b: 0.15, a: 1.0 }; +const ITEM_DISABLED_COLOR: Color = { r: 0.15, g: 0.12, b: 0.08, a: 1.0 }; + +export class ShopSystem { + private isOpen = false; + private selectedIndex = 0; + private economy: EconomySystem; + private displayItems: ShopItem[] = []; + private purchaseCooldown = 0; + + constructor(economy: EconomySystem) { + this.economy = economy; + } + + open(): void { + this.isOpen = true; + this.selectedIndex = 0; + this.displayItems = this.economy.getShopItems(); + this.purchaseCooldown = 0.3; // Prevent accidental immediate purchase + } + + close(): void { + this.isOpen = false; + } + + isShopOpen(): boolean { + return this.isOpen; + } + + update(dt: number): void { + if (this.purchaseCooldown > 0) { + this.purchaseCooldown -= dt; + } + } + + moveSelection(direction: number): void { + if (!this.isOpen) return; + + this.selectedIndex += direction; + if (this.selectedIndex < 0) this.selectedIndex = this.displayItems.length - 1; + if (this.selectedIndex >= this.displayItems.length) this.selectedIndex = 0; + } + + tryPurchase(): boolean { + if (!this.isOpen || this.purchaseCooldown > 0) return false; + + const item = this.displayItems[this.selectedIndex]; + if (!item) return false; + + const success = this.economy.buyItem(item.id); + if (success) { + this.purchaseCooldown = 0.3; + } + return success; + } + + getSprites(): Sprite[] { + if (!this.isOpen) return []; + + const sprites: Sprite[] = []; + const stats = this.economy.getStats(); + + // Background + sprites.push({ + x: 20, + y: 20, + width: INTERNAL_WIDTH - 40, + height: INTERNAL_HEIGHT - 40, + color: SHOP_BG_COLOR, + }); + + // Header bar + sprites.push({ + x: 20, + y: 20, + width: INTERNAL_WIDTH - 40, + height: 16, + color: SHOP_HEADER_COLOR, + }); + + // Item list + const itemStartY = 45; + const itemHeight = 18; + const itemSpacing = 2; + + for (let i = 0; i < this.displayItems.length; i++) { + const item = this.displayItems[i]; + const y = itemStartY + i * (itemHeight + itemSpacing); + + const canBuy = item.canBuy(stats) && stats.money >= item.cost; + const isSelected = i === this.selectedIndex; + + let bgColor: Color; + if (!canBuy) { + bgColor = ITEM_DISABLED_COLOR; + } else if (isSelected) { + bgColor = ITEM_SELECTED_COLOR; + } else { + bgColor = ITEM_BG_COLOR; + } + + // Item background + sprites.push({ + x: 25, + y, + width: INTERNAL_WIDTH - 50, + height: itemHeight, + color: bgColor, + }); + + // Selection indicator + if (isSelected) { + sprites.push({ + x: 25, + y, + width: 3, + height: itemHeight, + color: { r: 1, g: 0.8, b: 0.2, a: 1 }, + }); + } + + // Cost indicator (color based on affordability) + const costColor: Color = stats.money >= item.cost + ? { r: 0.2, g: 0.8, b: 0.2, a: 1 } // Green - affordable + : { r: 0.8, g: 0.2, b: 0.2, a: 1 }; // Red - too expensive + + sprites.push({ + x: INTERNAL_WIDTH - 35, + y: y + 4, + width: 8, + height: 10, + color: costColor, + }); + } + + // Money display area + sprites.push({ + x: INTERNAL_WIDTH - 70, + y: 22, + width: 45, + height: 12, + color: { r: 0.8, g: 0.7, b: 0.2, a: 1 }, + }); + + return sprites; + } + + getSelectedItem(): ShopItem | null { + if (!this.isOpen) return null; + return this.displayItems[this.selectedIndex] ?? null; + } + + getDisplayItems(): ShopItem[] { + return this.displayItems; + } + + getSelectedIndex(): number { + return this.selectedIndex; + } +} diff --git a/src/systems/wave-spawner.ts b/src/systems/wave-spawner.ts new file mode 100644 index 0000000..a615e07 --- /dev/null +++ b/src/systems/wave-spawner.ts @@ -0,0 +1,120 @@ +import { INTERNAL_WIDTH } from '../constants'; +import type { StageData, WaveData, EnemySpawnData, EnemyType } from '../types'; +import { EnemySystem } from './enemy-system'; + +interface PendingSpawn { + type: EnemyType; + x: number; + time: number; +} + +export class WaveSpawner { + private stageData: StageData | null = null; + private currentWaveIndex = 0; + private waveTimer = 0; + private pendingSpawns: PendingSpawn[] = []; + private stageComplete = false; + private looping = true; // Loop waves for endless mode + + loadStage(data: StageData): void { + this.stageData = data; + this.currentWaveIndex = 0; + this.waveTimer = 0; + this.pendingSpawns = []; + this.stageComplete = false; + this.startWave(0); + } + + private startWave(index: number): void { + if (!this.stageData) return; + + if (index >= this.stageData.waves.length) { + if (this.looping) { + // Loop back to first wave + this.currentWaveIndex = 0; + index = 0; + } else { + this.stageComplete = true; + return; + } + } + + const wave = this.stageData.waves[index]; + this.waveTimer = 0; + this.pendingSpawns = []; + + // Queue all spawns for this wave + for (const spawn of wave.spawns) { + const x = this.resolveX(spawn.x); + this.pendingSpawns.push({ + type: spawn.type, + x, + time: spawn.delay ?? 0, + }); + } + + // Sort by time + this.pendingSpawns.sort((a, b) => a.time - b.time); + } + + private resolveX(x: number | 'random' | 'left' | 'center' | 'right'): number { + const margin = 30; + const usableWidth = INTERNAL_WIDTH - margin * 2; + + if (typeof x === 'number') { + return x; + } + + switch (x) { + case 'left': + return margin + usableWidth * 0.2; + case 'center': + return INTERNAL_WIDTH / 2; + case 'right': + return margin + usableWidth * 0.8; + case 'random': + return margin + Math.random() * usableWidth; + default: + return INTERNAL_WIDTH / 2; + } + } + + update(dt: number, enemySystem: EnemySystem): void { + if (!this.stageData || this.stageComplete) return; + + this.waveTimer += dt; + + // Process pending spawns + while (this.pendingSpawns.length > 0 && this.pendingSpawns[0].time <= this.waveTimer) { + const spawn = this.pendingSpawns.shift()!; + enemySystem.spawn(spawn.type, spawn.x); + } + + // Check if wave is complete + const currentWave = this.stageData.waves[this.currentWaveIndex]; + if (this.waveTimer >= currentWave.duration && this.pendingSpawns.length === 0) { + this.currentWaveIndex++; + this.startWave(this.currentWaveIndex); + } + } + + getCurrentWave(): number { + return this.currentWaveIndex + 1; + } + + getTotalWaves(): number { + return this.stageData?.waves.length ?? 0; + } + + isStageComplete(): boolean { + return this.stageComplete; + } + + getScrollSpeed(): number { + return this.stageData?.scrollSpeed ?? 20; + } + + setLooping(loop: boolean): void { + this.looping = loop; + } +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..48e943b --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,57 @@ +export interface Vec2 { + x: number; + y: number; +} + +export interface Color { + r: number; + g: number; + b: number; + a: number; +} + +export interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +export interface InputState { + move: Vec2; + shootLeft: boolean; + shootRight: boolean; + shootForward: boolean; + interact: boolean; +} + +export interface Sprite { + x: number; + y: number; + width: number; + height: number; + color: Color; +} + +// Enemy spawn data types +export type EnemyType = 'bandit' | 'gunman' | 'rifleman' | 'dynamite'; + +export interface EnemySpawnData { + type: EnemyType; + x: number | 'random' | 'left' | 'center' | 'right'; + delay?: number; // delay from wave start in seconds +} + +export interface WaveData { + id: string; + duration: number; // seconds before next wave + spawns: EnemySpawnData[]; +} + +export interface StageData { + id: string; + name: string; + scrollSpeed: number; + waves: WaveData[]; + background?: string; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..42be33d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}