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 <noreply@anthropic.com>
This commit is contained in:
Developer 2026-01-25 19:28:35 -06:00
commit b070bab2e3
41 changed files with 4891 additions and 0 deletions

19
.gitignore vendored Normal file
View File

@ -0,0 +1,19 @@
# Dependencies
node_modules/
# Build output
dist/
# Lock files
bun.lock
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
# Logs
*.log

5
bunfig.toml Normal file
View File

@ -0,0 +1,5 @@
[serve]
port = 3000
[build]
target = "browser"

0
data/.gitkeep Normal file
View File

65
data/stage1.json Normal file
View File

@ -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 }
]
}
]
}

35
index.html Normal file
View File

@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Western Shooter</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
background: #000;
overflow: hidden;
}
#game-canvas {
display: block;
margin: auto;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
image-rendering: pixelated;
image-rendering: crisp-edges;
}
</style>
</head>
<body>
<canvas id="game-canvas"></canvas>
<script type="module" src="./src/main.ts"></script>
</body>
</html>

14
package.json Normal file
View File

@ -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"
}
}

85
serve.ts Normal file
View File

@ -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<string, string> = {
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}`);

19
src/constants.ts Normal file
View File

@ -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

50
src/core/loop.ts Normal file
View File

@ -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);
};
}

39
src/core/time.ts Normal file
View File

@ -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;
}
}

161
src/entities/player.ts Normal file
View File

@ -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<BulletDirection, number> = {
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;
}
}

624
src/game.ts Normal file
View File

@ -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<InputSystem['getState']>): 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<InputSystem['getState']>): 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<InputSystem['getState']>): 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<InputSystem['getState']>): 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<InputSystem['getState']>): void {
if (this.inputCooldown <= 0 && inputState.interact) {
this.restartGame();
}
}
private updateVictory(dt: number, inputState: ReturnType<InputSystem['getState']>): 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; }
}

90
src/input/gamepad.ts Normal file
View File

@ -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);
}
}

45
src/input/input-system.ts Normal file
View File

@ -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();
}
}

69
src/input/keyboard.ts Normal file
View File

@ -0,0 +1,69 @@
import type { Vec2 } from '../types';
export class KeyboardInput {
private keys: Set<string> = 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);
}
}

22
src/main.ts Normal file
View File

@ -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();
}

View File

@ -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);
}
}

374
src/rendering/renderer.ts Normal file
View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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;
}

View File

@ -0,0 +1,7 @@
precision mediump float;
varying vec4 v_color;
void main() {
gl_FragColor = v_color;
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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);
}

386
src/systems/boss-system.ts Normal file
View File

@ -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<BossType, BossConfig> = {
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;
}
}

View File

@ -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<BulletDirection, number> = {
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<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;
// 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;
}
}

View File

@ -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<T extends Collidable>(
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<T extends Collidable>(
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;
}
}

View File

@ -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<BossType> = 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;
}
}

290
src/systems/enemy-system.ts Normal file
View File

@ -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;
}
}
}

View File

@ -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<EnemyType, EnemyTypeConfig> = {
// 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,
},
};

142
src/systems/hud-system.ts Normal file
View File

@ -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;
}
}

131
src/systems/perf-monitor.ts Normal file
View File

@ -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;
}
}

View File

@ -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<PickupType, { width: number; height: number; color: Color; value: number }> = {
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;
}
}
}

View File

@ -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);
}
}

View File

@ -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,
};
}
}

167
src/systems/shop-system.ts Normal file
View File

@ -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;
}
}

120
src/systems/wave-spawner.ts Normal file
View File

@ -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;
}
}

57
src/types/index.ts Normal file
View File

@ -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;
}

20
tsconfig.json Normal file
View File

@ -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"]
}