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:
commit
b070bab2e3
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal 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
5
bunfig.toml
Normal file
@ -0,0 +1,5 @@
|
||||
[serve]
|
||||
port = 3000
|
||||
|
||||
[build]
|
||||
target = "browser"
|
||||
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
65
data/stage1.json
Normal file
65
data/stage1.json
Normal 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
35
index.html
Normal 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
14
package.json
Normal 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
85
serve.ts
Normal 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
19
src/constants.ts
Normal 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
50
src/core/loop.ts
Normal 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
39
src/core/time.ts
Normal 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
161
src/entities/player.ts
Normal 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
624
src/game.ts
Normal 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
90
src/input/gamepad.ts
Normal 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
45
src/input/input-system.ts
Normal 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
69
src/input/keyboard.ts
Normal 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
22
src/main.ts
Normal 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();
|
||||
}
|
||||
72
src/rendering/framebuffer.ts
Normal file
72
src/rendering/framebuffer.ts
Normal 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
374
src/rendering/renderer.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
169
src/rendering/shaders/post-process.frag
Normal file
169
src/rendering/shaders/post-process.frag
Normal 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);
|
||||
}
|
||||
9
src/rendering/shaders/post-process.vert
Normal file
9
src/rendering/shaders/post-process.vert
Normal 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;
|
||||
}
|
||||
7
src/rendering/shaders/sprite.frag
Normal file
7
src/rendering/shaders/sprite.frag
Normal file
@ -0,0 +1,7 @@
|
||||
precision mediump float;
|
||||
|
||||
varying vec4 v_color;
|
||||
|
||||
void main() {
|
||||
gl_FragColor = v_color;
|
||||
}
|
||||
22
src/rendering/shaders/sprite.vert
Normal file
22
src/rendering/shaders/sprite.vert
Normal 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;
|
||||
}
|
||||
11
src/rendering/shaders/upscale.frag
Normal file
11
src/rendering/shaders/upscale.frag
Normal 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);
|
||||
}
|
||||
9
src/rendering/shaders/upscale.vert
Normal file
9
src/rendering/shaders/upscale.vert
Normal 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;
|
||||
}
|
||||
236
src/rendering/sprite-batch.ts
Normal file
236
src/rendering/sprite-batch.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
71
src/rendering/webgl-context.ts
Normal file
71
src/rendering/webgl-context.ts
Normal 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
386
src/systems/boss-system.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
326
src/systems/bullet-system.ts
Normal file
326
src/systems/bullet-system.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
104
src/systems/collision-system.ts
Normal file
104
src/systems/collision-system.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
301
src/systems/economy-system.ts
Normal file
301
src/systems/economy-system.ts
Normal 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
290
src/systems/enemy-system.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
65
src/systems/enemy-types.ts
Normal file
65
src/systems/enemy-types.ts
Normal 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
142
src/systems/hud-system.ts
Normal 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
131
src/systems/perf-monitor.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
182
src/systems/pickup-system.ts
Normal file
182
src/systems/pickup-system.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
105
src/systems/screen-effects.ts
Normal file
105
src/systems/screen-effects.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
173
src/systems/scroll-system.ts
Normal file
173
src/systems/scroll-system.ts
Normal 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
167
src/systems/shop-system.ts
Normal 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
120
src/systems/wave-spawner.ts
Normal 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
57
src/types/index.ts
Normal 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
20
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user