western-shooter/src/systems/bullet-system.ts
Developer b070bab2e3 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>
2026-01-25 19:28:35 -06:00

327 lines
8.6 KiB
TypeScript

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