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>
327 lines
8.6 KiB
TypeScript
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;
|
|
}
|
|
}
|