mirror of
https://github.com/PlayableIntelligence/game-creator.git
synced 2026-09-19 07:34:10 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user