feat(castle-siege): add enemy castle fortress at far end of battlefield

Enemies now emerge from a dark, menacing castle positioned at the
negative Z end of the battlefield. The enemy castle features darker
stone colors, blood-red tower roofs, glowing red windows, skull
decorations above the gate, flickering torches, and pulsing gate glow.
Spawn positions updated so enemies stream out of the enemy castle gate
with Z jitter for a natural streaming effect. LevelBuilder filters
trees/rocks that would overlap the enemy castle footprint.

Also fixes ParticleSystem event listener cleanup on destroy and adds
bounds checking for scorch mark pool access.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
rshtirmer
2026-02-23 17:49:12 -05:00
parent 7905d9d9bd
commit f7d180f49b
8 changed files with 569 additions and 18 deletions
+76 -3
View File
@@ -135,6 +135,79 @@ export const CASTLE = {
BANNER_WAVE_AMOUNT: 0.15,
};
// ---------------------------------------------------------------------------
// Enemy Castle — dark fortress at the far end of the battlefield
// ---------------------------------------------------------------------------
export const ENEMY_CASTLE = {
// Position — sits at the negative Z end of the battlefield
POSITION_Z: -(LEVEL.GROUND_SIZE / 2 - 8),
POSITION_Y: 0,
// Main keep — wider and squatter than the player castle
KEEP_WIDTH: 10,
KEEP_HEIGHT: 8,
KEEP_DEPTH: 8,
KEEP_COLOR: 0x2a2a2a, // dark stone
// Corner towers
TOWER_RADIUS: 2.8,
TOWER_HEIGHT: 11,
TOWER_SEGMENTS: 8,
TOWER_COLOR: 0x1a1a1a, // near-black stone
TOWER_ROOF_COLOR: 0x330000, // dark blood red
TOWER_ROOF_HEIGHT: 2.5,
TOWER_SPREAD_X: 12,
TOWER_SPREAD_Z: 5,
// Connecting walls
WALL_HEIGHT: 6,
WALL_THICKNESS: 1.8,
WALL_COLOR: 0x222222,
// Battlements
MERLON_SIZE: 0.9,
MERLON_SPACING: 1.6,
MERLON_COLOR: 0x1a1a1a,
// Gate — faces toward the player castle (positive Z side)
GATE_WIDTH: 5,
GATE_HEIGHT: 5.5,
GATE_COLOR: 0x1a0a00, // very dark wood
// Glowing windows (red/orange emissive)
WINDOW_COLOR: 0xff2200,
WINDOW_EMISSIVE_INTENSITY: 1.5,
WINDOW_SIZE: 0.6,
WINDOW_ROWS: 2,
WINDOW_COLS: 3,
WINDOW_PULSE_SPEED: 1.5,
WINDOW_PULSE_AMOUNT: 0.3,
// Dark banners
BANNER_COLOR: 0x220000, // dark crimson
BANNER_WAVE_SPEED: 2.5,
BANNER_WAVE_AMOUNT: 0.18,
// Torch lights — eerie red/orange
TORCH_COLOR: 0xff3300,
TORCH_INTENSITY: 1.0,
TORCH_DISTANCE: 12,
TORCH_FLICKER_SPEED: 10,
TORCH_FLICKER_AMOUNT: 0.5,
// Gate glow — menacing constant glow
GATE_GLOW_COLOR: 0xff1100,
GATE_GLOW_INTENSITY: 1.5,
GATE_GLOW_DISTANCE: 15,
// Skull decorations
SKULL_COLOR: 0xccccaa,
SKULL_SIZE: 0.5,
// Smoke/ambient particles from chimneys
SMOKE_COLOR: 0x333333,
};
// ---------------------------------------------------------------------------
// Enemies
// ---------------------------------------------------------------------------
@@ -170,9 +243,9 @@ export const ENEMY = {
BASE_SPEED: 4,
SPEED_INCREASE_PER_WAVE: 0.1, // multiplier added per wave
// Spawn
SPAWN_Z: -(LEVEL.GROUND_SIZE / 2 - 5),
SPAWN_X_RANGE: LEVEL.GROUND_SIZE / 2 - 10,
// Spawn — enemies emerge from the enemy castle gate
SPAWN_Z: ENEMY_CASTLE.POSITION_Z + ENEMY_CASTLE.TOWER_SPREAD_Z + 2,
SPAWN_X_RANGE: ENEMY_CASTLE.GATE_WIDTH * 1.5,
LANE_COUNT: 5,
// Health
+8
View File
@@ -13,6 +13,7 @@ import { CameraShake } from '../systems/CameraShake.js';
import { ScreenEffects } from '../systems/ScreenEffects.js';
import { LevelBuilder } from '../level/LevelBuilder.js';
import { Castle } from '../gameplay/Castle.js';
import { EnemyCastle } from '../gameplay/EnemyCastle.js';
import { EnemyManager } from '../gameplay/EnemyManager.js';
import { ProjectileManager } from '../gameplay/ProjectileManager.js';
import { Menu } from '../ui/Menu.js';
@@ -60,6 +61,7 @@ export class Game {
// Gameplay objects (created in startGame)
this.castle = null;
this.enemyCastle = null;
this.enemyManager = null;
this.projectileManager = null;
@@ -83,6 +85,7 @@ export class Game {
// Create gameplay objects
this.castle = new Castle(this.scene);
this.enemyCastle = new EnemyCastle(this.scene);
this.enemyManager = new EnemyManager(this.scene);
this.projectileManager = new ProjectileManager(
this.scene, this.enemyManager, this.particleSystem
@@ -112,6 +115,10 @@ export class Game {
this.castle.destroy();
this.castle = null;
}
if (this.enemyCastle) {
this.enemyCastle.destroy();
this.enemyCastle = null;
}
if (this.enemyManager) {
this.enemyManager.destroyAll();
this.enemyManager = null;
@@ -137,6 +144,7 @@ export class Game {
if (gameState.started && !gameState.gameOver) {
// Update all gameplay systems
if (this.castle) this.castle.update(delta);
if (this.enemyCastle) this.enemyCastle.update(delta);
if (this.enemyManager) this.enemyManager.update(delta);
if (this.projectileManager) this.projectileManager.update(delta);
}
+3 -3
View File
@@ -9,7 +9,7 @@ import { ENEMY, CASTLE } from '../core/Constants.js';
import { eventBus, Events } from '../core/EventBus.js';
export class Enemy {
constructor(scene, x, speed, wave) {
constructor(scene, x, speed, wave, zJitter = 0) {
this.scene = scene;
this.alive = true;
this.reachedCastle = false;
@@ -64,8 +64,8 @@ export class Enemy {
// Collect all meshes for death animation
this.meshes = [this.body, this.head, shield, sword, handle];
// Position at spawn
this.group.position.set(x, 0, ENEMY.SPAWN_Z);
// Position at spawn (with optional Z jitter for gate streaming effect)
this.group.position.set(x, 0, ENEMY.SPAWN_Z + zJitter);
// Dust timer
this.dustTimer = Math.random() * ENEMY.DUST_INTERVAL;
@@ -0,0 +1,434 @@
// =============================================================================
// EnemyCastle.js — Dark menacing fortress at the far end of the battlefield
// Enemies emerge from its gate and march toward the player's castle.
// Built with the same techniques as Castle.js but darker, wider, and sinister.
// =============================================================================
import * as THREE from 'three';
import { ENEMY_CASTLE } from '../core/Constants.js';
export class EnemyCastle {
constructor(scene) {
this.scene = scene;
this.group = new THREE.Group();
this.group.position.set(0, ENEMY_CASTLE.POSITION_Y, ENEMY_CASTLE.POSITION_Z);
this.allMeshes = [];
this.torchLights = [];
this.banners = [];
this.windowMeshes = [];
this.gateGlowLight = null;
this.elapsedTime = 0;
this.buildKeep();
this.buildTowers();
this.buildWalls();
this.buildBattlements();
this.buildGate();
this.buildBanners();
this.buildTorches();
this.buildGateGlow();
this.buildWindows();
this.buildSkullDecorations();
this.scene.add(this.group);
}
// --- Build Methods ---
buildKeep() {
const geo = new THREE.BoxGeometry(
ENEMY_CASTLE.KEEP_WIDTH, ENEMY_CASTLE.KEEP_HEIGHT, ENEMY_CASTLE.KEEP_DEPTH
);
const mat = new THREE.MeshLambertMaterial({ color: ENEMY_CASTLE.KEEP_COLOR });
const keep = new THREE.Mesh(geo, mat);
keep.position.y = ENEMY_CASTLE.KEEP_HEIGHT / 2;
keep.castShadow = true;
keep.receiveShadow = true;
this.group.add(keep);
this._trackMesh(keep);
// Keep roof — dark flat top with jagged edge feel
const roofGeo = new THREE.BoxGeometry(
ENEMY_CASTLE.KEEP_WIDTH + 0.8, 0.6, ENEMY_CASTLE.KEEP_DEPTH + 0.8
);
const roofMat = new THREE.MeshLambertMaterial({ color: 0x111111 });
const roof = new THREE.Mesh(roofGeo, roofMat);
roof.position.y = ENEMY_CASTLE.KEEP_HEIGHT + 0.3;
roof.castShadow = true;
this.group.add(roof);
this._trackMesh(roof);
}
buildTowers() {
const positions = [
[-ENEMY_CASTLE.TOWER_SPREAD_X, 0, -ENEMY_CASTLE.TOWER_SPREAD_Z],
[ENEMY_CASTLE.TOWER_SPREAD_X, 0, -ENEMY_CASTLE.TOWER_SPREAD_Z],
[-ENEMY_CASTLE.TOWER_SPREAD_X, 0, ENEMY_CASTLE.TOWER_SPREAD_Z],
[ENEMY_CASTLE.TOWER_SPREAD_X, 0, ENEMY_CASTLE.TOWER_SPREAD_Z],
];
const towerGeo = new THREE.CylinderGeometry(
ENEMY_CASTLE.TOWER_RADIUS, ENEMY_CASTLE.TOWER_RADIUS + 0.4,
ENEMY_CASTLE.TOWER_HEIGHT, ENEMY_CASTLE.TOWER_SEGMENTS
);
const towerMat = new THREE.MeshLambertMaterial({ color: ENEMY_CASTLE.TOWER_COLOR });
const roofGeo = new THREE.ConeGeometry(
ENEMY_CASTLE.TOWER_RADIUS + 0.6, ENEMY_CASTLE.TOWER_ROOF_HEIGHT, ENEMY_CASTLE.TOWER_SEGMENTS
);
const roofMat = new THREE.MeshLambertMaterial({ color: ENEMY_CASTLE.TOWER_ROOF_COLOR });
for (const [x, y, z] of positions) {
const tower = new THREE.Mesh(towerGeo, towerMat.clone());
tower.position.set(x, ENEMY_CASTLE.TOWER_HEIGHT / 2, z);
tower.castShadow = true;
tower.receiveShadow = true;
this.group.add(tower);
this._trackMesh(tower);
const roof = new THREE.Mesh(roofGeo, roofMat.clone());
roof.position.set(x, ENEMY_CASTLE.TOWER_HEIGHT + ENEMY_CASTLE.TOWER_ROOF_HEIGHT / 2, z);
roof.castShadow = true;
this.group.add(roof);
this._trackMesh(roof);
}
}
buildWalls() {
const wallMat = new THREE.MeshLambertMaterial({ color: ENEMY_CASTLE.WALL_COLOR });
// Front wall (facing player castle, positive Z side) — has gate opening
const frontWallWidth = ENEMY_CASTLE.TOWER_SPREAD_X * 2;
this._addWall(frontWallWidth, 0, ENEMY_CASTLE.TOWER_SPREAD_Z, wallMat, false);
// Back wall (far side, negative Z)
this._addWall(frontWallWidth, 0, -ENEMY_CASTLE.TOWER_SPREAD_Z, wallMat, false);
// Left wall
const sideWallWidth = ENEMY_CASTLE.TOWER_SPREAD_Z * 2;
this._addWall(sideWallWidth, -ENEMY_CASTLE.TOWER_SPREAD_X, 0, wallMat, true);
// Right wall
this._addWall(sideWallWidth, ENEMY_CASTLE.TOWER_SPREAD_X, 0, wallMat, true);
}
_addWall(width, x, z, material, rotated) {
const geo = new THREE.BoxGeometry(width, ENEMY_CASTLE.WALL_HEIGHT, ENEMY_CASTLE.WALL_THICKNESS);
const wall = new THREE.Mesh(geo, material.clone());
wall.position.set(x, ENEMY_CASTLE.WALL_HEIGHT / 2, z);
if (rotated) {
wall.rotation.y = Math.PI / 2;
}
wall.castShadow = true;
wall.receiveShadow = true;
this.group.add(wall);
this._trackMesh(wall);
}
buildBattlements() {
const merlonGeo = new THREE.BoxGeometry(
ENEMY_CASTLE.MERLON_SIZE, ENEMY_CASTLE.MERLON_SIZE, ENEMY_CASTLE.MERLON_SIZE
);
const merlonMat = new THREE.MeshLambertMaterial({ color: ENEMY_CASTLE.MERLON_COLOR });
// Front wall battlements (positive Z side, facing player)
const halfSpread = ENEMY_CASTLE.TOWER_SPREAD_X;
for (let x = -halfSpread + 1; x < halfSpread; x += ENEMY_CASTLE.MERLON_SPACING) {
const merlon = new THREE.Mesh(merlonGeo, merlonMat.clone());
merlon.position.set(
x,
ENEMY_CASTLE.WALL_HEIGHT + ENEMY_CASTLE.MERLON_SIZE / 2,
ENEMY_CASTLE.TOWER_SPREAD_Z
);
merlon.castShadow = true;
this.group.add(merlon);
this._trackMesh(merlon);
}
// Back wall battlements
for (let x = -halfSpread + 1; x < halfSpread; x += ENEMY_CASTLE.MERLON_SPACING) {
const merlon = new THREE.Mesh(merlonGeo, merlonMat.clone());
merlon.position.set(
x,
ENEMY_CASTLE.WALL_HEIGHT + ENEMY_CASTLE.MERLON_SIZE / 2,
-ENEMY_CASTLE.TOWER_SPREAD_Z
);
merlon.castShadow = true;
this.group.add(merlon);
this._trackMesh(merlon);
}
// Side wall battlements
const halfSide = ENEMY_CASTLE.TOWER_SPREAD_Z;
for (let z = -halfSide + 1; z < halfSide; z += ENEMY_CASTLE.MERLON_SPACING) {
// Left
const mL = new THREE.Mesh(merlonGeo, merlonMat.clone());
mL.position.set(
-ENEMY_CASTLE.TOWER_SPREAD_X,
ENEMY_CASTLE.WALL_HEIGHT + ENEMY_CASTLE.MERLON_SIZE / 2,
z
);
mL.castShadow = true;
this.group.add(mL);
this._trackMesh(mL);
// Right
const mR = new THREE.Mesh(merlonGeo, merlonMat.clone());
mR.position.set(
ENEMY_CASTLE.TOWER_SPREAD_X,
ENEMY_CASTLE.WALL_HEIGHT + ENEMY_CASTLE.MERLON_SIZE / 2,
z
);
mR.castShadow = true;
this.group.add(mR);
this._trackMesh(mR);
}
}
buildGate() {
// Gate on front wall (positive Z side, facing player castle)
const gateGeo = new THREE.BoxGeometry(
ENEMY_CASTLE.GATE_WIDTH, ENEMY_CASTLE.GATE_HEIGHT, ENEMY_CASTLE.WALL_THICKNESS + 0.1
);
const gateMat = new THREE.MeshLambertMaterial({ color: ENEMY_CASTLE.GATE_COLOR });
const gate = new THREE.Mesh(gateGeo, gateMat);
gate.position.set(0, ENEMY_CASTLE.GATE_HEIGHT / 2, ENEMY_CASTLE.TOWER_SPREAD_Z);
this.group.add(gate);
this._trackMesh(gate);
// Gate arch
const archGeo = new THREE.CylinderGeometry(
ENEMY_CASTLE.GATE_WIDTH / 2, ENEMY_CASTLE.GATE_WIDTH / 2,
ENEMY_CASTLE.WALL_THICKNESS + 0.2,
8, 1, false, 0, Math.PI
);
const archMat = new THREE.MeshLambertMaterial({ color: ENEMY_CASTLE.GATE_COLOR });
const arch = new THREE.Mesh(archGeo, archMat);
arch.rotation.x = Math.PI / 2;
arch.rotation.z = Math.PI / 2;
arch.position.set(0, ENEMY_CASTLE.GATE_HEIGHT, ENEMY_CASTLE.TOWER_SPREAD_Z);
this.group.add(arch);
this._trackMesh(arch);
}
buildBanners() {
// Dark banners on front towers (the ones facing the player)
const bannerGeo = new THREE.PlaneGeometry(1.4, 2.5, 4, 4);
const bannerMat = new THREE.MeshLambertMaterial({
color: ENEMY_CASTLE.BANNER_COLOR,
side: THREE.DoubleSide,
});
const poleGeo = new THREE.CylinderGeometry(0.06, 0.06, 3.5, 4);
const poleMat = new THREE.MeshLambertMaterial({ color: 0x222222 });
const towerPositions = [
[-ENEMY_CASTLE.TOWER_SPREAD_X, ENEMY_CASTLE.TOWER_SPREAD_Z],
[ENEMY_CASTLE.TOWER_SPREAD_X, ENEMY_CASTLE.TOWER_SPREAD_Z],
];
for (const [x, z] of towerPositions) {
const poleY = ENEMY_CASTLE.TOWER_HEIGHT + ENEMY_CASTLE.TOWER_ROOF_HEIGHT + 1.5;
const pole = new THREE.Mesh(poleGeo, poleMat);
pole.position.set(x, poleY, z);
this.group.add(pole);
const banner = new THREE.Mesh(bannerGeo.clone(), bannerMat.clone());
banner.position.set(x + 0.8, poleY + 0.5, z);
this.group.add(banner);
this._trackMesh(banner);
this.banners.push(banner);
}
}
buildTorches() {
// Eerie flickering red/orange lights on tower tops
const towerPositions = [
[-ENEMY_CASTLE.TOWER_SPREAD_X, ENEMY_CASTLE.TOWER_HEIGHT + 1, -ENEMY_CASTLE.TOWER_SPREAD_Z],
[ENEMY_CASTLE.TOWER_SPREAD_X, ENEMY_CASTLE.TOWER_HEIGHT + 1, -ENEMY_CASTLE.TOWER_SPREAD_Z],
[-ENEMY_CASTLE.TOWER_SPREAD_X, ENEMY_CASTLE.TOWER_HEIGHT + 1, ENEMY_CASTLE.TOWER_SPREAD_Z],
[ENEMY_CASTLE.TOWER_SPREAD_X, ENEMY_CASTLE.TOWER_HEIGHT + 1, ENEMY_CASTLE.TOWER_SPREAD_Z],
];
for (const [x, y, z] of towerPositions) {
const light = new THREE.PointLight(
ENEMY_CASTLE.TORCH_COLOR,
ENEMY_CASTLE.TORCH_INTENSITY,
ENEMY_CASTLE.TORCH_DISTANCE
);
light.position.set(x, y, z);
this.group.add(light);
this.torchLights.push({
light,
baseIntensity: ENEMY_CASTLE.TORCH_INTENSITY,
phase: Math.random() * Math.PI * 2,
});
// Small flame mesh (emissive sphere) — red-tinged
const flameGeo = new THREE.SphereGeometry(0.25, 4, 4);
const flameMat = new THREE.MeshBasicMaterial({ color: ENEMY_CASTLE.TORCH_COLOR });
const flame = new THREE.Mesh(flameGeo, flameMat);
flame.position.set(x, y, z);
this.group.add(flame);
}
}
buildGateGlow() {
// Menacing red glow emanating from the gate
this.gateGlowLight = new THREE.PointLight(
ENEMY_CASTLE.GATE_GLOW_COLOR,
ENEMY_CASTLE.GATE_GLOW_INTENSITY,
ENEMY_CASTLE.GATE_GLOW_DISTANCE
);
this.gateGlowLight.position.set(0, ENEMY_CASTLE.GATE_HEIGHT / 2, ENEMY_CASTLE.TOWER_SPREAD_Z + 1);
this.group.add(this.gateGlowLight);
}
buildWindows() {
// Glowing red/orange windows on the keep walls (front and sides)
const windowGeo = new THREE.PlaneGeometry(ENEMY_CASTLE.WINDOW_SIZE, ENEMY_CASTLE.WINDOW_SIZE);
const windowMat = new THREE.MeshBasicMaterial({
color: ENEMY_CASTLE.WINDOW_COLOR,
});
const keepHalfWidth = ENEMY_CASTLE.KEEP_WIDTH / 2;
const keepHalfDepth = ENEMY_CASTLE.KEEP_DEPTH / 2;
// Front face windows (positive Z)
for (let row = 0; row < ENEMY_CASTLE.WINDOW_ROWS; row++) {
for (let col = 0; col < ENEMY_CASTLE.WINDOW_COLS; col++) {
const x = -keepHalfWidth + keepHalfWidth * 2 * (col + 1) / (ENEMY_CASTLE.WINDOW_COLS + 1);
const y = ENEMY_CASTLE.KEEP_HEIGHT * 0.4 + row * 2.2;
const win = new THREE.Mesh(windowGeo, windowMat.clone());
win.position.set(x, y, keepHalfDepth + 0.01);
this.group.add(win);
this._trackMesh(win);
this.windowMeshes.push(win);
}
}
// Left face windows
for (let row = 0; row < ENEMY_CASTLE.WINDOW_ROWS; row++) {
const y = ENEMY_CASTLE.KEEP_HEIGHT * 0.4 + row * 2.2;
const win = new THREE.Mesh(windowGeo, windowMat.clone());
win.rotation.y = Math.PI / 2;
win.position.set(-keepHalfWidth - 0.01, y, 0);
this.group.add(win);
this._trackMesh(win);
this.windowMeshes.push(win);
}
// Right face windows
for (let row = 0; row < ENEMY_CASTLE.WINDOW_ROWS; row++) {
const y = ENEMY_CASTLE.KEEP_HEIGHT * 0.4 + row * 2.2;
const win = new THREE.Mesh(windowGeo, windowMat.clone());
win.rotation.y = -Math.PI / 2;
win.position.set(keepHalfWidth + 0.01, y, 0);
this.group.add(win);
this._trackMesh(win);
this.windowMeshes.push(win);
}
}
buildSkullDecorations() {
// Simple skull shapes (sphere + jaw) above the gate and on towers
const skullGeo = new THREE.SphereGeometry(ENEMY_CASTLE.SKULL_SIZE, 6, 5);
const skullMat = new THREE.MeshLambertMaterial({ color: ENEMY_CASTLE.SKULL_COLOR });
const jawGeo = new THREE.BoxGeometry(
ENEMY_CASTLE.SKULL_SIZE * 0.8, ENEMY_CASTLE.SKULL_SIZE * 0.3, ENEMY_CASTLE.SKULL_SIZE * 0.6
);
// Above the gate — three skulls
const gateSkullY = ENEMY_CASTLE.GATE_HEIGHT + ENEMY_CASTLE.SKULL_SIZE + 0.5;
const gateSkullZ = ENEMY_CASTLE.TOWER_SPREAD_Z;
for (let i = -1; i <= 1; i++) {
const skull = new THREE.Mesh(skullGeo, skullMat.clone());
skull.position.set(i * 1.5, gateSkullY, gateSkullZ + 0.3);
this.group.add(skull);
this._trackMesh(skull);
const jaw = new THREE.Mesh(jawGeo, skullMat.clone());
jaw.position.set(i * 1.5, gateSkullY - ENEMY_CASTLE.SKULL_SIZE * 0.5, gateSkullZ + 0.5);
this.group.add(jaw);
this._trackMesh(jaw);
}
// One skull on each front tower
const towerSkullY = ENEMY_CASTLE.TOWER_HEIGHT * 0.7;
const frontTowers = [
[-ENEMY_CASTLE.TOWER_SPREAD_X, ENEMY_CASTLE.TOWER_SPREAD_Z],
[ENEMY_CASTLE.TOWER_SPREAD_X, ENEMY_CASTLE.TOWER_SPREAD_Z],
];
for (const [x, z] of frontTowers) {
const skull = new THREE.Mesh(skullGeo, skullMat.clone());
skull.position.set(x, towerSkullY, z + ENEMY_CASTLE.TOWER_RADIUS + 0.2);
this.group.add(skull);
this._trackMesh(skull);
}
}
// --- Update ---
update(delta) {
this.elapsedTime += delta;
// Torch flickering
for (const torch of this.torchLights) {
const flicker = Math.sin(this.elapsedTime * ENEMY_CASTLE.TORCH_FLICKER_SPEED + torch.phase) *
ENEMY_CASTLE.TORCH_FLICKER_AMOUNT;
const noise = Math.sin(this.elapsedTime * 19 + torch.phase * 3) * 0.2;
torch.light.intensity = torch.baseIntensity + flicker + noise;
}
// Banner wave animation
for (const banner of this.banners) {
const geo = banner.geometry;
const positions = geo.attributes.position;
for (let i = 0; i < positions.count; i++) {
const x = positions.getX(i);
const y = positions.getY(i);
const distFromPole = (x + 0.7);
const wave = Math.sin(
this.elapsedTime * ENEMY_CASTLE.BANNER_WAVE_SPEED + y * 2 + distFromPole * 3
) * ENEMY_CASTLE.BANNER_WAVE_AMOUNT * distFromPole;
positions.setZ(i, wave);
}
positions.needsUpdate = true;
}
// Gate glow pulsing
if (this.gateGlowLight) {
const pulse = ENEMY_CASTLE.GATE_GLOW_INTENSITY +
Math.sin(this.elapsedTime * 2.5) * 0.5;
this.gateGlowLight.intensity = pulse;
}
// Window glow pulsing
for (const win of this.windowMeshes) {
const pulse = ENEMY_CASTLE.WINDOW_EMISSIVE_INTENSITY +
Math.sin(this.elapsedTime * ENEMY_CASTLE.WINDOW_PULSE_SPEED + Math.random() * 0.01) *
ENEMY_CASTLE.WINDOW_PULSE_AMOUNT;
// Modulate the color brightness
const r = ((ENEMY_CASTLE.WINDOW_COLOR >> 16) & 0xff) / 255;
const g = ((ENEMY_CASTLE.WINDOW_COLOR >> 8) & 0xff) / 255;
const b = (ENEMY_CASTLE.WINDOW_COLOR & 0xff) / 255;
win.material.color.setRGB(r * pulse, g * pulse, b * pulse);
}
}
_trackMesh(mesh) {
this.allMeshes.push(mesh);
}
destroy() {
this.scene.remove(this.group);
this.group.traverse((child) => {
if (child.isMesh) {
child.geometry.dispose();
child.material.dispose();
}
});
}
}
@@ -88,14 +88,16 @@ export class EnemyManager {
}
_spawnEnemy() {
// Distribute enemies across lanes
// Distribute enemies across lanes — they emerge from the enemy castle gate
const laneIndex = this.enemiesSpawnedThisWave % ENEMY.LANE_COUNT;
const laneWidth = (ENEMY.SPAWN_X_RANGE * 2) / ENEMY.LANE_COUNT;
const x = -ENEMY.SPAWN_X_RANGE + laneWidth * laneIndex + laneWidth / 2;
// Add small random offset within lane
const jitter = (Math.random() - 0.5) * laneWidth * 0.6;
// Small Z jitter so enemies stream out of the gate, not all on one line
const zJitter = -Math.random() * 2;
const enemy = new Enemy(this.scene, x + jitter, this.currentSpeed, gameState.wave);
const enemy = new Enemy(this.scene, x + jitter, this.currentSpeed, gameState.wave, zJitter);
this.enemies.push(enemy);
this.enemiesSpawnedThisWave++;
@@ -4,7 +4,7 @@
// =============================================================================
import * as THREE from 'three';
import { LEVEL, COLORS, SKY } from '../core/Constants.js';
import { LEVEL, COLORS, SKY, ENEMY_CASTLE } from '../core/Constants.js';
import { eventBus, Events } from '../core/EventBus.js';
export class LevelBuilder {
@@ -155,8 +155,15 @@ export class LevelBuilder {
[-20, -35], [20, -35], [-15, 30], [15, 30],
];
// Enemy castle footprint for collision avoidance
const ecZ = ENEMY_CASTLE.POSITION_Z;
const ecSpreadX = ENEMY_CASTLE.TOWER_SPREAD_X + ENEMY_CASTLE.TOWER_RADIUS + 1;
const ecSpreadZ = ENEMY_CASTLE.TOWER_SPREAD_Z + ENEMY_CASTLE.TOWER_RADIUS + 1;
for (const [x, z] of treePositions) {
if (Math.abs(x) > halfGround - 2 || Math.abs(z) > halfGround - 2) continue;
// Skip trees that overlap the enemy castle
if (Math.abs(x) < ecSpreadX && z > ecZ - ecSpreadZ && z < ecZ + ecSpreadZ) continue;
const trunk = new THREE.Mesh(trunkGeo, trunkMat);
trunk.position.set(x, 1.5, z);
@@ -178,6 +185,9 @@ export class LevelBuilder {
];
for (const [x, z] of rockPositions) {
// Skip rocks that overlap the enemy castle
if (Math.abs(x) < ecSpreadX && z > ecZ - ecSpreadZ && z < ecZ + ecSpreadZ) continue;
const rock = new THREE.Mesh(rockGeo, rockMat);
const scale = 0.5 + Math.random() * 1.0;
rock.scale.set(scale, scale * 0.6, scale);
+7
View File
@@ -51,6 +51,13 @@ window.render_game_to_text = () => {
payload.activeProjectiles = game.projectileManager.projectiles.length;
}
// Enemy castle presence
if (game.enemyCastle) {
payload.enemyCastle = {
z: Math.round(game.enemyCastle.group.position.z * 10) / 10,
};
}
return JSON.stringify(payload);
};
@@ -268,12 +268,18 @@ export class ParticleSystem {
this.trailSegments.push(new TrailSegment(this.scene));
}
// Subscribe to events
eventBus.on(Events.PROJECTILE_IMPACT, (data) => this._onProjectileImpact(data));
eventBus.on(Events.ENEMY_KILLED, (data) => this._onEnemyKilled(data));
eventBus.on(Events.CASTLE_HIT, () => this._onCastleHit());
eventBus.on(Events.ENEMY_DUST, (data) => this._onEnemyDust(data));
eventBus.on(Events.SPAWN_PARTICLES, (data) => this._onSpawnParticles(data));
// Subscribe to events (store bound refs for cleanup in destroy())
this._boundOnProjectileImpact = (data) => this._onProjectileImpact(data);
this._boundOnEnemyKilled = (data) => this._onEnemyKilled(data);
this._boundOnCastleHit = () => this._onCastleHit();
this._boundOnEnemyDust = (data) => this._onEnemyDust(data);
this._boundOnSpawnParticles = (data) => this._onSpawnParticles(data);
eventBus.on(Events.PROJECTILE_IMPACT, this._boundOnProjectileImpact);
eventBus.on(Events.ENEMY_KILLED, this._boundOnEnemyKilled);
eventBus.on(Events.CASTLE_HIT, this._boundOnCastleHit);
eventBus.on(Events.ENEMY_DUST, this._boundOnEnemyDust);
eventBus.on(Events.SPAWN_PARTICLES, this._boundOnSpawnParticles);
}
// --- Get a free particle from pool ---
@@ -340,9 +346,13 @@ export class ParticleSystem {
}
// Scorch mark on ground
const scorch = this.scorchMarks[this.scorchIndex % LEVEL.MAX_SCORCH_MARKS];
scorch.activate(pos);
this.scorchIndex++;
if (this.scorchMarks.length > 0) {
const scorch = this.scorchMarks[this.scorchIndex % this.scorchMarks.length];
if (scorch) {
scorch.activate(pos);
this.scorchIndex++;
}
}
// Camera shake
eventBus.emit(Events.CAMERA_SHAKE, { type: 'impact' });
@@ -503,6 +513,13 @@ export class ParticleSystem {
// --- Cleanup ---
destroy() {
// Unsubscribe event listeners to prevent stale callbacks after restart
eventBus.off(Events.PROJECTILE_IMPACT, this._boundOnProjectileImpact);
eventBus.off(Events.ENEMY_KILLED, this._boundOnEnemyKilled);
eventBus.off(Events.CASTLE_HIT, this._boundOnCastleHit);
eventBus.off(Events.ENEMY_DUST, this._boundOnEnemyDust);
eventBus.off(Events.SPAWN_PARTICLES, this._boundOnSpawnParticles);
// Deactivate all
for (const p of this.pool) {
p.deactivate();