From 79f511fc245b7082573e7e90ebf2c2196f6d5f24 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Jan 2026 15:53:33 +0000 Subject: [PATCH 1/4] refactor: remove legacy code and migrate Ramp to 256-scale elevation Dead code removed: - Delete unused pathfinding/Grid.ts (162 lines) - Remove TSLWaterPlane legacy water implementation (116 lines) - Remove createTemporalAOBlendNode (33 lines) - unused with any types - Remove legacy world-space selection handlers (220+ lines) - Remove procedural decoration generation from MapDecorations (120+ lines) - Remove legacy visual fields from MapData (skyboxColor, ambientColor, etc.) Ramp elevation migration: - Change Ramp interface from ElevationLevel (0|1|2) to Elevation (0-255) - Update JSON schema to use number instead of 0|1|2 - Add backward compatibility in deserialize.ts for old maps - Update ElevationMapGenerator and ConnectivityFixer to use 256-scale Documentation: - Update networking.md to mark connection code as complete This removes ~700 lines of dead/legacy code and consolidates the decoration pipeline to use instanced decorations exclusively for procedural generation. --- docs/architecture/networking.md | 18 +- src/data/maps/MapTypes.ts | 19 +- src/data/maps/core/ConnectivityFixer.ts | 15 +- src/data/maps/core/ElevationMapGenerator.ts | 12 +- src/data/maps/schema/MapJsonSchema.ts | 4 +- src/data/maps/serialization/deserialize.ts | 5 +- src/engine/pathfinding/Grid.ts | 162 -------------- src/engine/systems/SelectionSystem.ts | 223 +------------------- src/rendering/Terrain.ts | 130 +----------- src/rendering/tsl/TemporalAO.ts | 33 --- src/rendering/tsl/WaterPlane.ts | 116 ---------- src/rendering/tsl/index.ts | 5 - 12 files changed, 28 insertions(+), 714 deletions(-) delete mode 100644 src/engine/pathfinding/Grid.ts delete mode 100644 src/rendering/tsl/WaterPlane.ts diff --git a/docs/architecture/networking.md b/docs/architecture/networking.md index 1e41c81c..f17856e6 100644 --- a/docs/architecture/networking.md +++ b/docs/architecture/networking.md @@ -1331,21 +1331,25 @@ export class PeerRelayNetwork extends EventEmitter { ## Implementation Phases (Updated) ### Phase 1: Connection Codes -**Status**: Ready to implement +**Status**: Complete **Reliability**: 100% **Dependencies**: `pako` (compression, ~30KB) +**Implementation**: `src/networking/ConnectionCode.ts` ``` Tasks: -- [ ] SDP offer compression with pako -- [ ] Base32-like encoding with friendly alphabet -- [ ] Connection code generation (VOID-XXXX format) -- [ ] Connection code parsing and validation -- [ ] Two-way code exchange UI +- [x] SDP offer compression with pako +- [x] Base32-like encoding with friendly alphabet +- [x] Connection code generation (VOID-XXXX format) +- [x] Connection code parsing and validation +- [x] Code expiration (5 minutes) +- [ ] Two-way code exchange UI (superseded by Nostr short codes) - [ ] QR code generation (optional, nice-to-have) -- [ ] Code expiration (5 minutes) ``` +Note: Primary connection flow uses Nostr-based short codes. ConnectionCode.ts +provides fallback/offline capability. + ### Phase 2: LAN Discovery (mDNS) **Status**: Requires Electron/Tauri **Reliability**: 100% diff --git a/src/data/maps/MapTypes.ts b/src/data/maps/MapTypes.ts index 2600c857..60cfc960 100644 --- a/src/data/maps/MapTypes.ts +++ b/src/data/maps/MapTypes.ts @@ -166,8 +166,8 @@ export interface Ramp { width: number; height: number; direction: 'north' | 'south' | 'east' | 'west'; - fromElevation: ElevationLevel; - toElevation: ElevationLevel; + fromElevation: Elevation; // 0-255 scale + toElevation: Elevation; // 0-255 scale } export interface DestructibleRock { @@ -239,11 +239,7 @@ export interface MapData { // Special mode maps (e.g., battle simulator) - hidden from lobby selection isSpecialMode?: boolean; - // Legacy visual settings (deprecated - use biome instead) - skyboxColor?: string; - ambientColor?: string; - sunColor?: string; - fogColor?: string; + // Fog settings (optional - biome provides defaults) fogNear?: number; fogFar?: number; } @@ -535,10 +531,6 @@ export function createRampInTerrain( ): void { const { x, y, width, height, direction, fromElevation, toElevation } = ramp; - // Convert legacy elevations to 256 scale - const fromElev256 = legacyElevationTo256(fromElevation); - const toElev256 = legacyElevationTo256(toElevation); - for (let dy = 0; dy < height; dy++) { for (let dx = 0; dx < width; dx++) { const px = Math.floor(x + dx); @@ -563,9 +555,8 @@ export function createRampInTerrain( break; } - // LINEAR interpolation for STRAIGHT ramps - no curves, just a clean slope - // This creates a straight line from top to bottom - const elevationValue = fromElev256 + (toElev256 - fromElev256) * t; + // Linear interpolation for straight ramps + const elevationValue = fromElevation + (toElevation - fromElevation) * t; grid[py][px] = { terrain: 'ramp', diff --git a/src/data/maps/core/ConnectivityFixer.ts b/src/data/maps/core/ConnectivityFixer.ts index 7bce0eb5..756c68d7 100644 --- a/src/data/maps/core/ConnectivityFixer.ts +++ b/src/data/maps/core/ConnectivityFixer.ts @@ -5,7 +5,7 @@ * Can apply fixes directly to MapData or return paint commands for blueprints. */ -import type { MapData, MapCell, Ramp, ElevationLevel } from '../MapTypes'; +import type { MapData, MapCell, Ramp } from '../MapTypes'; import { type Point, type RampCommand, ramp as createRampCommand, CLIFF_THRESHOLD, toXY } from './ElevationMap'; import { distance, clamp } from '@/utils/math'; import type { @@ -52,15 +52,6 @@ function generateRampCommand(fix: SuggestedFix): RampCommand | null { return createRampCommand(fix.ramp.from, fix.ramp.to, fix.ramp.width); } -/** - * Convert elevation (0-255) to legacy elevation level (0, 1, 2). - */ -function elevationToLevel(elevation: number): ElevationLevel { - if (elevation <= 85) return 0; - if (elevation <= 170) return 1; - return 2; -} - /** * Apply a ramp directly to the terrain grid. */ @@ -155,8 +146,8 @@ function applyRampToTerrain( width: rampW, height: rampH, direction, - fromElevation: elevationToLevel(fromElevation), - toElevation: elevationToLevel(toElevation), + fromElevation, + toElevation, }; } diff --git a/src/data/maps/core/ElevationMapGenerator.ts b/src/data/maps/core/ElevationMapGenerator.ts index a9ca1ee2..243fe92f 100644 --- a/src/data/maps/core/ElevationMapGenerator.ts +++ b/src/data/maps/core/ElevationMapGenerator.ts @@ -630,18 +630,14 @@ function extractRamps(grid: GenerationGrid): Ramp[] { direction = topElev < bottomElev ? 'south' : 'north'; } - // Convert elevation to legacy 0-2 levels - const fromLevel = minElev <= 85 ? 0 : minElev <= 170 ? 1 : 2; - const toLevel = maxElev <= 85 ? 0 : maxElev <= 170 ? 1 : 2; - ramps.push({ x: minX, y: minY, width, height, direction, - fromElevation: fromLevel as 0 | 1 | 2, - toElevation: toLevel as 0 | 1 | 2, + fromElevation: minElev, + toElevation: maxElev, }); } } @@ -1054,10 +1050,6 @@ export function generateMapWithResult( isRanked: true, biome, - skyboxColor: theme.skyboxColor, - ambientColor: theme.ambientColor, - sunColor: theme.sunColor, - fogColor: theme.fogColor, fogNear: theme.fogNear, fogFar: theme.fogFar, }; diff --git a/src/data/maps/schema/MapJsonSchema.ts b/src/data/maps/schema/MapJsonSchema.ts index ca7d2a81..fabf2a74 100644 --- a/src/data/maps/schema/MapJsonSchema.ts +++ b/src/data/maps/schema/MapJsonSchema.ts @@ -109,8 +109,8 @@ export interface RampJson { width: number; height: number; direction: 'north' | 'south' | 'east' | 'west'; - fromElevation: 0 | 1 | 2; - toElevation: 0 | 1 | 2; + fromElevation: number; // 0-255 scale + toElevation: number; // 0-255 scale } /** diff --git a/src/data/maps/serialization/deserialize.ts b/src/data/maps/serialization/deserialize.ts index 36d3b7cc..3729d065 100644 --- a/src/data/maps/serialization/deserialize.ts +++ b/src/data/maps/serialization/deserialize.ts @@ -178,8 +178,9 @@ export function jsonToMapData(json: MapJson): MapData { width: r.width, height: r.height, direction: r.direction, - fromElevation: r.fromElevation, - toElevation: r.toElevation, + // Backward compatibility: convert legacy 0|1|2 format to 256-scale + fromElevation: r.fromElevation <= 2 ? [60, 140, 220][r.fromElevation] : r.fromElevation, + toElevation: r.toElevation <= 2 ? [60, 140, 220][r.toElevation] : r.toElevation, })), destructibles: json.destructibles.map((d): DestructibleRock => ({ diff --git a/src/engine/pathfinding/Grid.ts b/src/engine/pathfinding/Grid.ts deleted file mode 100644 index 8b24daa5..00000000 --- a/src/engine/pathfinding/Grid.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { EntityId } from '../ecs/Entity'; - -interface GridCell { - entities: Set; - walkable: boolean; -} - -/** - * Spatial grid for pathfinding entity tracking - * PERF: Uses numeric keys (cellY * cols + cellX) instead of string keys to avoid GC pressure - */ -export class SpatialGrid { - private grid: Map = new Map(); - private cellSize: number; - private width: number; - private height: number; - private cols: number; // Number of columns in the grid - - // PERF: Reusable result arrays to avoid allocation on every query - private readonly _queryResultSet = new Set(); - private static readonly EMPTY_SET = new Set(); // Shared empty set for cache misses - - constructor(width: number, height: number, cellSize = 2) { - this.width = width; - this.height = height; - this.cellSize = cellSize; - this.cols = Math.ceil(width / cellSize); - } - - // PERF: Numeric key instead of string key - no allocation - private getKey(x: number, y: number): number { - const cellX = Math.floor(x / this.cellSize); - const cellY = Math.floor(y / this.cellSize); - return cellY * this.cols + cellX; - } - - private getOrCreateCell(key: number): GridCell { - let cell = this.grid.get(key); - if (!cell) { - cell = { entities: new Set(), walkable: true }; - this.grid.set(key, cell); - } - return cell; - } - - public insert(entityId: EntityId, x: number, y: number): void { - const key = this.getKey(x, y); - const cell = this.getOrCreateCell(key); - cell.entities.add(entityId); - } - - public remove(entityId: EntityId, x: number, y: number): void { - const key = this.getKey(x, y); - const cell = this.grid.get(key); - if (cell) { - cell.entities.delete(entityId); - } - } - - public move( - entityId: EntityId, - oldX: number, - oldY: number, - newX: number, - newY: number - ): void { - const oldKey = this.getKey(oldX, oldY); - const newKey = this.getKey(newX, newY); - - if (oldKey !== newKey) { - this.remove(entityId, oldX, oldY); - this.insert(entityId, newX, newY); - } - } - - public query( - x: number, - y: number, - radius: number - ): Set { - // PERF: Reuse the result set instead of allocating new one - this._queryResultSet.clear(); - const cellRadius = Math.ceil(radius / this.cellSize); - - const centerCellX = Math.floor(x / this.cellSize); - const centerCellY = Math.floor(y / this.cellSize); - - for (let dy = -cellRadius; dy <= cellRadius; dy++) { - const cellY = centerCellY + dy; - for (let dx = -cellRadius; dx <= cellRadius; dx++) { - const cellX = centerCellX + dx; - // PERF: Numeric key - no string allocation - const key = cellY * this.cols + cellX; - const cell = this.grid.get(key); - if (cell) { - for (const entityId of cell.entities) { - this._queryResultSet.add(entityId); - } - } - } - } - - return this._queryResultSet; - } - - public queryRect( - minX: number, - minY: number, - maxX: number, - maxY: number - ): Set { - // PERF: Reuse the result set instead of allocating new one - this._queryResultSet.clear(); - - const startCellX = Math.floor(minX / this.cellSize); - const startCellY = Math.floor(minY / this.cellSize); - const endCellX = Math.floor(maxX / this.cellSize); - const endCellY = Math.floor(maxY / this.cellSize); - - for (let cy = startCellY; cy <= endCellY; cy++) { - for (let cx = startCellX; cx <= endCellX; cx++) { - // PERF: Numeric key - no string allocation - const key = cy * this.cols + cx; - const cell = this.grid.get(key); - if (cell) { - for (const entityId of cell.entities) { - this._queryResultSet.add(entityId); - } - } - } - } - - return this._queryResultSet; - } - - public setWalkable(x: number, y: number, walkable: boolean): void { - const key = this.getKey(x, y); - const cell = this.getOrCreateCell(key); - cell.walkable = walkable; - } - - public isWalkable(x: number, y: number): boolean { - const key = this.getKey(x, y); - const cell = this.grid.get(key); - return cell ? cell.walkable : true; - } - - public clear(): void { - this.grid.clear(); - } - - public getCellSize(): number { - return this.cellSize; - } - - public getEntitiesInCell(x: number, y: number): Set { - const key = this.getKey(x, y); - const cell = this.grid.get(key); - // PERF: Return shared empty set instead of allocating new one - return cell ? cell.entities : SpatialGrid.EMPTY_SET; - } -} diff --git a/src/engine/systems/SelectionSystem.ts b/src/engine/systems/SelectionSystem.ts index 772c47b8..7605bd9b 100644 --- a/src/engine/systems/SelectionSystem.ts +++ b/src/engine/systems/SelectionSystem.ts @@ -69,11 +69,7 @@ export class SelectionSystem extends System { } private setupEventListeners(): void { - // World-space selection (legacy/fallback) - this.game.eventBus.on('selection:box', this.handleBoxSelection.bind(this)); - this.game.eventBus.on('selection:click', this.handleClickSelection.bind(this)); - - // Screen-space selection (preferred - handles flying units and perspective correctly) + // Screen-space selection (handles flying units and perspective correctly) this.game.eventBus.on('selection:boxScreen', this.handleScreenBoxSelection.bind(this)); this.game.eventBus.on('selection:clickScreen', this.handleScreenClickSelection.bind(this)); @@ -238,113 +234,6 @@ export class SelectionSystem extends System { return distanceSquared <= radius * radius; } - /** - * World-space box selection (legacy fallback) - * Now includes selection radius buffer for partial overlap detection - */ - private handleBoxSelection(data: { - startX: number; - startY: number; - endX: number; - endY: number; - additive: boolean; - playerId: string; - }): void { - const { startX, startY, endX, endY, additive, playerId } = data; - - const minX = Math.min(startX, endX); - const maxX = Math.max(startX, endX); - const minY = Math.min(startY, endY); - const maxY = Math.max(startY, endY); - - // Calculate box size for adaptive buffer - const boxWidth = maxX - minX; - const boxHeight = maxY - minY; - const boxSize = Math.sqrt(boxWidth * boxWidth + boxHeight * boxHeight); - - const entities = this.world.getEntitiesWith('Transform', 'Selectable'); - - // First, deselect all if not additive - if (!additive) { - for (const entity of entities) { - const selectable = entity.get('Selectable')!; - selectable.deselect(); - } - } - - // Collect entities within box, separating units from buildings - const unitIds: number[] = []; - const buildingIds: number[] = []; - - for (const entity of entities) { - const transform = entity.get('Transform')!; - const selectable = entity.get('Selectable')!; - const health = entity.get('Health'); - const unit = entity.get('Unit'); - const building = entity.get('Building'); - - // Only select own units/buildings - if (selectable.playerId !== playerId) continue; - - // Skip dead entities - if (health && health.isDead()) continue; - - // Use selection radius as a buffer - entity is selected if ANY part overlaps box - // This makes selection feel more generous and less frustrating - const visualScale = selectable.visualScale ?? 1; - const effectiveRadius = selectable.selectionRadius * visualScale; - - // For small boxes (near-clicks), use a more generous radius - const isSmallBox = boxSize < 2; - const buffer = isSmallBox ? effectiveRadius * 1.2 : effectiveRadius * 0.8; - - // Check if entity's bounding circle overlaps with selection box - // This is circle-rectangle intersection - const closestX = clamp(transform.x, minX, maxX); - const closestY = clamp(transform.y, minY, maxY); - const dx = transform.x - closestX; - const dy = transform.y - closestY; - const distanceSquared = dx * dx + dy * dy; - - if (distanceSquared <= buffer * buffer) { - if (unit) { - unitIds.push(entity.id); - } else if (building) { - buildingIds.push(entity.id); - } - } - } - - // Prioritize units over buildings - if any units are selected, ignore buildings - const selectedIds = unitIds.length > 0 ? unitIds : buildingIds; - - // Mark entities as selected - for (const entityId of selectedIds) { - const entity = this.world.getEntity(entityId); - if (entity) { - const selectable = entity.get('Selectable'); - if (selectable) { - selectable.select(); - } - } - } - - // Update store - if (additive) { - const current = this.game.statePort.getSelectedUnits(); - // PERF: Avoid multiple spread operations by using Set directly - const combinedSet = new Set(current); - for (let i = 0; i < selectedIds.length; i++) { - combinedSet.add(selectedIds[i]); - } - this.game.statePort.selectUnits(Array.from(combinedSet)); - } else { - this.game.statePort.selectUnits(selectedIds); - } - - this.game.eventBus.emit('selection:changed', { selectedIds }); - } - /** * Screen-space click selection - handles flying units at their visual position */ @@ -469,116 +358,6 @@ export class SelectionSystem extends System { this.game.eventBus.emit('selection:changed', { selectedIds }); } - /** - * World-space click selection (legacy fallback) - * Now with improved selection radius handling - */ - private handleClickSelection(data: { - x: number; - y: number; - additive: boolean; - selectAllOfType?: boolean; - playerId: string; - }): void { - const { x, y, additive, selectAllOfType, playerId } = data; - - const entities = this.world.getEntitiesWith('Transform', 'Selectable'); - let closestEntity: { id: number; distance: number; priority: number } | null = null; - - // Find closest entity to click point (only own units/buildings OR neutral resources) - for (const entity of entities) { - const transform = entity.get('Transform')!; - const selectable = entity.get('Selectable')!; - const health = entity.get('Health'); - - // Allow selecting own units/buildings OR neutral entities (resources) - // Neutral resources (minerals, vespene) should be selectable by anyone - if (selectable.playerId !== playerId && selectable.playerId !== 'neutral') continue; - - // Skip dead units - if (health && health.isDead()) continue; - - const distance = transform.distanceToPoint(x, y); - - // Use visual scale to increase selection radius for larger units - const visualScale = selectable.visualScale ?? 1; - const effectiveRadius = selectable.selectionRadius * visualScale * 1.2; // 20% more generous - - if (distance <= effectiveRadius) { - // Check if this is closer or higher priority - if ( - !closestEntity || - selectable.selectionPriority > closestEntity.priority || - (selectable.selectionPriority === closestEntity.priority && - distance < closestEntity.distance) - ) { - closestEntity = { - id: entity.id, - distance, - priority: selectable.selectionPriority, - }; - } - } - } - - // Handle Ctrl+click or double-click - select all of same type - if (selectAllOfType && closestEntity) { - const clickedEntity = this.world.getEntity(closestEntity.id); - if (clickedEntity) { - const clickedUnit = clickedEntity.get('Unit'); - if (clickedUnit) { - this.selectAllOfUnitType(clickedUnit.unitId, playerId, additive); - return; - } - // Also support buildings for double-click selection - const clickedBuilding = clickedEntity.get('Building'); - if (clickedBuilding) { - this.selectAllOfBuildingType(clickedBuilding.buildingId, playerId, additive); - return; - } - } - } - - // Clear selection if not additive - if (!additive) { - for (const entity of entities) { - const selectable = entity.get('Selectable')!; - selectable.deselect(); - } - } - - // Select the clicked entity - const selectedIds: number[] = []; - if (closestEntity) { - const entity = this.world.getEntity(closestEntity.id); - if (entity) { - const selectable = entity.get('Selectable')!; - - // If additive and already selected, deselect - if (additive && selectable.isSelected) { - selectable.deselect(); - } else { - selectable.select(); - selectedIds.push(entity.id); - } - } - } - - // Update store - if (!additive) { - this.game.statePort.selectUnits(selectedIds); - } else { - const current = this.game.statePort.getSelectedUnits(); - if (closestEntity && selectedIds.length > 0) { - this.game.statePort.selectUnits([...current, ...selectedIds]); - } else if (closestEntity) { - this.game.statePort.selectUnits(current.filter((id) => id !== closestEntity!.id)); - } - } - - this.game.eventBus.emit('selection:changed', { selectedIds }); - } - private selectAllOfUnitType(unitId: string, playerId: string, additive: boolean): void { const entities = this.world.getEntitiesWith('Unit', 'Selectable'); const selectedIds: number[] = []; diff --git a/src/rendering/Terrain.ts b/src/rendering/Terrain.ts index 0275545c..868985b2 100644 --- a/src/rendering/Terrain.ts +++ b/src/rendering/Terrain.ts @@ -1681,12 +1681,9 @@ export class MapDecorations { this.createWatchTowers(mapData); this.createDestructibles(mapData); - // Use explicit decorations from map data if available, otherwise use procedural + // Create explicit decorations from map data (instanced decorations handle procedural) if (mapData.decorations && mapData.decorations.length > 0) { this.createExplicitDecorations(mapData); - } else { - this.createTrees(mapData); - this.createRocks(mapData); } } @@ -2099,131 +2096,6 @@ export class MapDecorations { } } - private createTrees(mapData: MapData): void { - // Scatter trees around the map edges and unbuildable areas - const treePositions: Array<{ x: number; y: number }> = []; - - // Add trees along map edges (inside the playable area) - for (let i = 0; i < 50; i++) { - const edge = Math.floor(Math.random() * 4); - let x: number, y: number; - - switch (edge) { - case 0: // Left - x = 10 + Math.random() * 8; - y = 15 + Math.random() * (mapData.height - 30); - break; - case 1: // Right - x = mapData.width - 18 + Math.random() * 8; - y = 15 + Math.random() * (mapData.height - 30); - break; - case 2: // Top - x = 15 + Math.random() * (mapData.width - 30); - y = 10 + Math.random() * 8; - break; - default: // Bottom - x = 15 + Math.random() * (mapData.width - 30); - y = mapData.height - 18 + Math.random() * 8; - break; - } - - // Check terrain is suitable - const cellX = Math.floor(x); - const cellY = Math.floor(y); - if (cellX >= 0 && cellX < mapData.width && cellY >= 0 && cellY < mapData.height) { - const cell = mapData.terrain[cellY][cellX]; - if (cell.terrain === 'ground' || cell.terrain === 'unbuildable') { - treePositions.push({ x, y }); - } - } - } - - // Create trees - for (const pos of treePositions) { - // Get terrain height at tree position - const terrainHeight = this.terrain.getHeightAt(pos.x, pos.y); - - const treeGroup = new THREE.Group(); - const height = 3 + Math.random() * 2; - - // Trunk - const trunkGeometry = new THREE.CylinderGeometry(0.2, 0.3, height * 0.4, 6); - const trunkMaterial = new THREE.MeshStandardMaterial({ - color: 0x4a3520, - roughness: 0.9, - }); - const trunk = new THREE.Mesh(trunkGeometry, trunkMaterial); - trunk.position.y = height * 0.2; - // PERF: Decorations receive shadows but don't cast - trunk.castShadow = false; - trunk.receiveShadow = true; - treeGroup.add(trunk); - - // Foliage (multiple cones for pine tree look) - const foliageMaterial = new THREE.MeshStandardMaterial({ - color: 0x2d5a2d, - roughness: 0.8, - }); - - for (let layer = 0; layer < 3; layer++) { - const layerHeight = height * 0.25; - const layerRadius = 1.2 - layer * 0.3; - const foliageGeometry = new THREE.ConeGeometry(layerRadius, layerHeight, 8); - const foliage = new THREE.Mesh(foliageGeometry, foliageMaterial); - foliage.position.y = height * 0.4 + layer * layerHeight * 0.7; - // PERF: Decorations receive shadows but don't cast - foliage.castShadow = false; - foliage.receiveShadow = true; - treeGroup.add(foliage); - } - - treeGroup.position.set(pos.x, terrainHeight, pos.y); - treeGroup.rotation.y = Math.random() * Math.PI * 2; - const treeScale = 0.8 + Math.random() * 0.4; - treeGroup.scale.setScalar(treeScale); - this.group.add(treeGroup); - - // Track tree collision for pathfinding - trunk collision radius scaled by tree size - const collisionRadius = treeScale * 0.6; - this.treeCollisions.push({ x: pos.x, z: pos.y, radius: collisionRadius }); - } - } - - private createRocks(mapData: MapData): void { - // Scatter small rocks around the map - for (let i = 0; i < 80; i++) { - const x = 12 + Math.random() * (mapData.width - 24); - const y = 12 + Math.random() * (mapData.height - 24); - - const cellX = Math.floor(x); - const cellY = Math.floor(y); - if (cellX >= 0 && cellX < mapData.width && cellY >= 0 && cellY < mapData.height) { - const cell = mapData.terrain[cellY][cellX]; - if (cell.terrain === 'ground' || cell.terrain === 'unbuildable') { - // Get terrain height at rock position - const terrainHeight = this.terrain.getHeightAt(x, y); - - const rockGeometry = new THREE.DodecahedronGeometry(0.3 + Math.random() * 0.4); - const rockMaterial = new THREE.MeshStandardMaterial({ - color: new THREE.Color(0.3 + Math.random() * 0.1, 0.28 + Math.random() * 0.1, 0.25 + Math.random() * 0.1), - roughness: 0.9, - }); - const rock = new THREE.Mesh(rockGeometry, rockMaterial); - rock.position.set(x, terrainHeight + 0.15 + Math.random() * 0.1, y); - rock.rotation.set( - Math.random() * Math.PI, - Math.random() * Math.PI, - Math.random() * Math.PI - ); - // PERF: Decorations receive shadows but don't cast - rock.castShadow = false; - rock.receiveShadow = true; - this.group.add(rock); - } - } - } - } - public dispose(): void { // Clear emissive decorations (lights are now managed by DecorationLightManager) this.emissiveDecorations = []; diff --git a/src/rendering/tsl/TemporalAO.ts b/src/rendering/tsl/TemporalAO.ts index f11de93e..24e499cd 100644 --- a/src/rendering/tsl/TemporalAO.ts +++ b/src/rendering/tsl/TemporalAO.ts @@ -344,36 +344,3 @@ export class TemporalAOManager { } } -/** - * Simple helper to create a temporal AO blend pass - * This blends quarter-res AO with reprojected history using TSL. - * Uses shared utilities from TemporalUtils for consistent behavior. - */ -export function createTemporalAOBlendNode( - quarterAONode: any, // Quarter-res AO texture node - historyNode: any, // History AO texture node - velocityNode: any, // Velocity texture node - _depthNode: any, // Depth texture node (unused in simplified version) - config: { historyBlend: number; depthThreshold: number } -): ReturnType { - const uHistoryBlend = uniform(config.historyBlend); - - return Fn(() => { - const fragUV = uv(); - - // Use shared utility for velocity reprojection - const prevUV = calculateReprojectedUV(velocityNode, fragUV); - - // Sample current quarter-res AO - const currentAO = quarterAONode.sample(fragUV).r; - - // Use shared utility for bounds checking - const inBounds = isUVInBounds(prevUV); - - // Sample history - const historyAO = historyNode.sample(prevUV).r; - - // Use shared temporal blend - return temporalBlend(currentAO, historyAO, uHistoryBlend, inBounds); - })(); -} diff --git a/src/rendering/tsl/WaterPlane.ts b/src/rendering/tsl/WaterPlane.ts deleted file mode 100644 index 82d53d99..00000000 --- a/src/rendering/tsl/WaterPlane.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * TSL Water/Lava Plane - * - * WebGPU-compatible animated water/lava effect using Three.js Shading Language. - * Works with both WebGPU and WebGL renderers. - */ - -import * as THREE from 'three'; -import { - Fn, - vec3, - vec4, - float, - uniform, - uv, - sin, - mix, - positionLocal, -} from 'three/tsl'; -import { MeshBasicNodeMaterial } from 'three/webgpu'; -import { MapData } from '@/data/maps'; -import { BiomeConfig } from '../Biomes'; - -export class TSLWaterPlane { - public mesh: THREE.Mesh; - private material: MeshBasicNodeMaterial; - - // Uniforms - private uTime = uniform(0); - private uColor1 = uniform(new THREE.Color(0x206090)); - private uColor2 = uniform(new THREE.Color(0x206090)); - private uIsLava = uniform(0); - - constructor(mapData: MapData, biome: BiomeConfig) { - if (!biome.hasWater) { - // Create invisible placeholder - const geometry = new THREE.PlaneGeometry(1, 1); - this.material = new MeshBasicNodeMaterial(); - this.mesh = new THREE.Mesh(geometry, this.material); - this.mesh.visible = false; - return; - } - - const geometry = new THREE.PlaneGeometry(mapData.width, mapData.height, 32, 32); - - // Determine if this is lava or water - const isLava = biome.name === 'Volcanic'; - - // Set uniforms - this.uColor1.value.copy(biome.colors.water); - this.uColor2.value.copy(isLava ? new THREE.Color(0xff8040) : new THREE.Color(0x206090)); - this.uIsLava.value = isLava ? 1.0 : 0.0; - - // Create TSL material - this.material = this.createTSLMaterial(); - - this.mesh = new THREE.Mesh(geometry, this.material); - this.mesh.rotation.x = -Math.PI / 2; - this.mesh.position.set(mapData.width / 2, biome.waterLevel, mapData.height / 2); - } - - private createTSLMaterial(): MeshBasicNodeMaterial { - const material = new MeshBasicNodeMaterial(); - material.transparent = true; - material.side = THREE.DoubleSide; - - // Create animated water/lava effect - const outputNode = Fn(() => { - const uvCoord = uv(); - const pos = positionLocal; - - // Calculate wave height for vertex displacement effect simulation - const wave1 = sin(pos.x.mul(0.5).add(this.uTime)).mul(0.1); - const wave2 = sin(pos.y.mul(0.3).add(this.uTime.mul(0.7))).mul(0.08); - const waveHeight = wave1.add(wave2); - - // Animated color blend - const blend1 = sin(uvCoord.x.mul(10.0).add(this.uTime)).mul(0.5).add(0.5); - const blend2 = sin(uvCoord.y.mul(8.0).add(this.uTime.mul(0.8))).mul(0.5).add(0.5); - const blend = blend1.mul(blend2); - - // Mix colors - let color = mix(this.uColor1, this.uColor2, blend.mul(0.3)); - - // Height-based brightness - color = color.add(waveHeight.mul(0.5)); - - // Lava glow effect - const lavaGlow = sin(this.uTime.mul(2.0)).mul(0.3).add(0.7); - const lavaColor = vec3(0.3, 0.1, 0.0).mul(lavaGlow); - color = mix(color, color.add(lavaColor), this.uIsLava); - - // Transparency - lava is more opaque - const waterAlpha = float(0.7).add(waveHeight.mul(0.2)); - const lavaAlpha = float(0.95); - const alpha = mix(waterAlpha, lavaAlpha, this.uIsLava); - - return vec4(color, alpha); - })(); - - material.colorNode = outputNode; - - return material; - } - - public update(time: number): void { - if (this.mesh.visible) { - this.uTime.value = time; - } - } - - public dispose(): void { - this.mesh.geometry.dispose(); - this.material.dispose(); - } -} diff --git a/src/rendering/tsl/index.ts b/src/rendering/tsl/index.ts index 925f5bf6..9995b74a 100644 --- a/src/rendering/tsl/index.ts +++ b/src/rendering/tsl/index.ts @@ -62,11 +62,6 @@ export { type TSLFogOfWarConfig, } from './FogOfWar'; -// Water/Lava (legacy - main water now in WaterMesh.ts) -export { - TSLWaterPlane, -} from './WaterPlane'; - // Game Overlays export { TSLGameOverlayManager, From 79e0da556e200c19c7e521bac7fe54748ce13789 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Jan 2026 15:56:43 +0000 Subject: [PATCH 2/4] refactor: remove all procedural decoration generation from runtime All decorations must now come from explicit map data. Procedural generation should only occur in the map editor where decorations are placed and then saved to the map file. Removed from EnvironmentManager: - InstancedTrees (procedural trees) - InstancedRocks (procedural rocks) - InstancedGrass (procedural grass) - InstancedPebbles (procedural pebbles) - CrystalField (procedural crystals) Kept: - Water system (terrain feature) - Map border fog (visual effect) - Particles (environmental effect) - MapDecorations (handles explicit decorations, watch towers, destructibles) --- src/rendering/EnvironmentManager.ts | 114 +--------------------------- 1 file changed, 2 insertions(+), 112 deletions(-) diff --git a/src/rendering/EnvironmentManager.ts b/src/rendering/EnvironmentManager.ts index 0d14adfa..3be13782 100644 --- a/src/rendering/EnvironmentManager.ts +++ b/src/rendering/EnvironmentManager.ts @@ -2,15 +2,10 @@ import * as THREE from 'three'; import { MapData } from '@/data/maps'; import { BIOMES, BiomeConfig } from './Biomes'; import { Terrain, MapDecorations } from './Terrain'; -import { CrystalField } from './GroundDetail'; import { TSLMapBorderFog } from './tsl/MapBorderFog'; import { WaterMesh, type WaterQuality } from './WaterMesh'; import { EnvironmentParticles } from './EnhancedDecorations'; -// PERFORMANCE: Use instanced decorations instead of individual meshes -import { InstancedTrees, InstancedRocks, InstancedGrass, InstancedPebbles, updateDecorationFrustum } from './InstancedDecorations'; -// AAA-quality decoration light pooling, frustum culling, and distance falloff import { DecorationLightManager } from './DecorationLightManager'; -// Centralized emissive decoration management with animation support import { EmissiveDecorationManager } from './EmissiveDecorationManager'; import { LightPool } from './LightPool'; import { @@ -39,12 +34,6 @@ export class EnvironmentManager { private scene: THREE.Scene; private mapData: MapData; - // PERFORMANCE: Instanced decoration systems (single draw call per type) - private trees: InstancedTrees | null = null; - private rocks: InstancedRocks | null = null; - private grass: InstancedGrass | null = null; - private pebbles: InstancedPebbles | null = null; - private crystals: CrystalField | null = null; private waterMesh: WaterMesh | null = null; private mapBorderFog: TSLMapBorderFog | null = null; private particles: EnvironmentParticles | null = null; @@ -196,44 +185,6 @@ export class EnvironmentManager { } private createEnhancedDecorations(): void { - const getHeightAt = this.terrain.getHeightAt.bind(this.terrain); - - // Check if map has explicit decorations defined - const hasExplicitDecorations = this.mapData.decorations && this.mapData.decorations.length > 0; - - // Only use instanced random decorations if no explicit decorations defined - if (!hasExplicitDecorations) { - // PERFORMANCE: Instanced trees - single draw call for all trees - if (this.biome.treeDensity > 0) { - this.trees = new InstancedTrees(this.mapData, this.biome, getHeightAt); - this.scene.add(this.trees.group); - } - - // PERFORMANCE: Instanced rocks - single draw call for all rocks - if (this.biome.rockDensity > 0) { - this.rocks = new InstancedRocks(this.mapData, this.biome, getHeightAt); - this.scene.add(this.rocks.group); - } - } - - // PERFORMANCE: Instanced grass - thousands of grass blades in one draw call - if (this.biome.grassDensity > 0) { - this.grass = new InstancedGrass(this.mapData, this.biome, getHeightAt); - this.scene.add(this.grass.group); - } - - // PERFORMANCE: Instanced pebbles - replaces old GroundDebris - if (this.biome.grassDensity > 0 || this.biome.rockDensity > 0.1) { - this.pebbles = new InstancedPebbles(this.mapData, this.biome, getHeightAt); - this.scene.add(this.pebbles.group); - } - - // Crystals (for frozen/void biomes) - if (this.biome.crystalDensity > 0) { - this.crystals = new CrystalField(this.mapData, this.biome, getHeightAt); - this.scene.add(this.crystals.group); - } - // Water system - uses Three.js WaterMesh addon for realistic reflections // Handles both full-map water (Ocean biome) and localized features (lakes, rivers) this.waterMesh = new WaterMesh(); @@ -259,46 +210,13 @@ export class EnvironmentManager { } // AAA decoration light manager - pools lights for hundreds of emissive decorations - // This enables maps like Crystal Caverns (295 crystals) to run at 60+ fps this.decorationLightManager = new DecorationLightManager(this.scene, 50); // Emissive decoration manager with optional light attachment - // Uses separate light pool for permanent emissive lights (vs transient combat lights) this.emissiveLightPool = new LightPool(this.scene, 16); this.emissiveDecorationManager = new EmissiveDecorationManager(this.scene, this.emissiveLightPool); - // Register crystals with emissive decoration manager - // This enables centralized animation and intensity control - if (this.crystals) { - const crystalMesh = this.crystals.getInstancedMesh(); - if (crystalMesh) { - // Get biome-specific emissive color for hints - let emissiveHex = '#204060'; // Default frozen - let pulseSpeed = 0.3; // Subtle pulse - let pulseAmplitude = 0.15; - - if (this.biome.name === 'Void') { - emissiveHex = '#4020a0'; - pulseSpeed = 0.5; // More ethereal pulsing - pulseAmplitude = 0.25; - } else if (this.biome.name === 'Volcanic') { - emissiveHex = '#802010'; - pulseSpeed = 0.8; // Rapid flickering - pulseAmplitude = 0.3; - } - - this.emissiveDecorationManager.registerInstancedDecoration(crystalMesh, { - emissive: emissiveHex, - emissiveIntensity: 0.5, - pulseSpeed, - pulseAmplitude, - }); - } - } - - // MapDecorations handles explicit map decorations, watch towers, and destructibles. - // Required even with instanced systems - InstancedTrees/Rocks only handle random procedural decorations. - // Pass scene and light manager to enable pooled lights for emissive decorations. + // MapDecorations handles explicit map decorations, watch towers, and destructibles this.legacyDecorations = new MapDecorations(this.mapData, this.terrain, this.scene, this.decorationLightManager); this.scene.add(this.legacyDecorations.group); } @@ -340,14 +258,6 @@ export class EnvironmentManager { this.decorationLightManager.update(camera, deltaTime); } - // PERF: Update decoration frustum culling - only render visible instances - if (camera) { - updateDecorationFrustum(camera); - this.trees?.update(); - this.rocks?.update(); - this.grass?.update(); - this.pebbles?.update(); - } } /** @@ -421,17 +331,7 @@ export class EnvironmentManager { public getRockCollisions(): Array<{ x: number; z: number; radius: number }> { const collisions: Array<{ x: number; z: number; radius: number }> = []; - // Get from instanced rocks (if no explicit decorations) - if (this.rocks) { - collisions.push(...this.rocks.getRockCollisions()); - } - - // Get from instanced trees (if no explicit decorations) - if (this.trees) { - collisions.push(...this.trees.getTreeCollisions()); - } - - // Get from legacy/explicit decorations (includes rocks and trees from map data) + // Get from explicit decorations (includes rocks and trees from map data) if (this.legacyDecorations) { collisions.push(...this.legacyDecorations.getRockCollisions()); collisions.push(...this.legacyDecorations.getTreeCollisions()); @@ -793,11 +693,6 @@ export class EnvironmentManager { */ public dispose(): void { this.terrain.dispose(); - this.trees?.dispose(); - this.rocks?.dispose(); - this.grass?.dispose(); - this.pebbles?.dispose(); - this.crystals?.dispose(); this.waterMesh?.dispose(); this.mapBorderFog?.dispose(); this.particles?.dispose(); @@ -819,11 +714,6 @@ export class EnvironmentManager { this.scene.environment = null; } - if (this.trees) this.scene.remove(this.trees.group); - if (this.rocks) this.scene.remove(this.rocks.group); - if (this.grass) this.scene.remove(this.grass.group); - if (this.pebbles) this.scene.remove(this.pebbles.group); - if (this.crystals) this.scene.remove(this.crystals.group); if (this.waterMesh) this.scene.remove(this.waterMesh.group); if (this.mapBorderFog) this.scene.remove(this.mapBorderFog.mesh); if (this.particles) this.scene.remove(this.particles.points); From 336df759a584b2f9279e58e649507be0a2b76237 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Jan 2026 16:32:12 +0000 Subject: [PATCH 3/4] fix: restore instanced rendering for explicit map decorations Trees, rocks, and crystals are now: - Loaded from explicit mapData.decorations (no procedural generation) - Rendered using InstancedMesh (single draw call per model type) - Per-instance frustum culled for performance - Shadow-optimized (playable area casts, border doesn't) Grass and pebbles remain as procedural environmental ground detail (not placed via map decorations, just ambient terrain texture). This correctly separates: - Explicit decorations (from map editor) -> instanced rendering - Environmental detail (procedural) -> ground cover only --- src/rendering/EnvironmentManager.ts | 102 ++- src/rendering/InstancedDecorations.ts | 911 ++++++++++---------------- 2 files changed, 437 insertions(+), 576 deletions(-) diff --git a/src/rendering/EnvironmentManager.ts b/src/rendering/EnvironmentManager.ts index 3be13782..fb71d0fe 100644 --- a/src/rendering/EnvironmentManager.ts +++ b/src/rendering/EnvironmentManager.ts @@ -5,6 +5,7 @@ import { Terrain, MapDecorations } from './Terrain'; import { TSLMapBorderFog } from './tsl/MapBorderFog'; import { WaterMesh, type WaterQuality } from './WaterMesh'; import { EnvironmentParticles } from './EnhancedDecorations'; +import { InstancedTrees, InstancedRocks, InstancedGrass, InstancedPebbles, InstancedCrystals, updateDecorationFrustum } from './InstancedDecorations'; import { DecorationLightManager } from './DecorationLightManager'; import { EmissiveDecorationManager } from './EmissiveDecorationManager'; import { LightPool } from './LightPool'; @@ -34,6 +35,12 @@ export class EnvironmentManager { private scene: THREE.Scene; private mapData: MapData; + // Instanced decoration systems (single draw call per type, frustum culled) + private trees: InstancedTrees | null = null; + private rocks: InstancedRocks | null = null; + private grass: InstancedGrass | null = null; + private pebbles: InstancedPebbles | null = null; + private crystals: InstancedCrystals | null = null; private waterMesh: WaterMesh | null = null; private mapBorderFog: TSLMapBorderFog | null = null; private particles: EnvironmentParticles | null = null; @@ -185,21 +192,39 @@ export class EnvironmentManager { } private createEnhancedDecorations(): void { - // Water system - uses Three.js WaterMesh addon for realistic reflections - // Handles both full-map water (Ocean biome) and localized features (lakes, rivers) - this.waterMesh = new WaterMesh(); + const getHeightAt = this.terrain.getHeightAt.bind(this.terrain); + + // Instanced trees from explicit map data (frustum culled, single draw call per model) + this.trees = new InstancedTrees(this.mapData, this.biome, getHeightAt); + this.scene.add(this.trees.group); + + // Instanced rocks from explicit map data (frustum culled, single draw call per model) + this.rocks = new InstancedRocks(this.mapData, this.biome, getHeightAt); + this.scene.add(this.rocks.group); - // Create full-map water plane for biomes with hasWater (Ocean, Volcanic lava) + // Instanced crystals from explicit map data (frustum culled, emissive) + this.crystals = new InstancedCrystals(this.mapData, this.biome, getHeightAt); + this.scene.add(this.crystals.group); + + // Environmental ground detail (procedural - these are not map decorations) + if (this.biome.grassDensity > 0) { + this.grass = new InstancedGrass(this.mapData, this.biome, getHeightAt); + this.scene.add(this.grass.group); + } + if (this.biome.grassDensity > 0 || this.biome.rockDensity > 0.1) { + this.pebbles = new InstancedPebbles(this.mapData, this.biome, getHeightAt); + this.scene.add(this.pebbles.group); + } + + // Water system - handles both full-map water (Ocean biome) and localized features + this.waterMesh = new WaterMesh(); if (this.biome.hasWater) { this.waterMesh.buildFullMapWater(this.mapData, this.biome); } - - // Add localized water surfaces from terrain features this.waterMesh.buildFromMapData(this.mapData); this.scene.add(this.waterMesh.group); // Map border fog - dark smoky effect around map edges - // Uses TSL for WebGPU/WebGL compatibility this.mapBorderFog = new TSLMapBorderFog(this.mapData); this.scene.add(this.mapBorderFog.mesh); @@ -216,7 +241,34 @@ export class EnvironmentManager { this.emissiveLightPool = new LightPool(this.scene, 16); this.emissiveDecorationManager = new EmissiveDecorationManager(this.scene, this.emissiveLightPool); - // MapDecorations handles explicit map decorations, watch towers, and destructibles + // Register crystals with emissive decoration manager for pulsing animation + if (this.crystals) { + const crystalMesh = this.crystals.getInstancedMesh(); + if (crystalMesh) { + let emissiveHex = '#204060'; + let pulseSpeed = 0.3; + let pulseAmplitude = 0.15; + + if (this.biome.name === 'Void') { + emissiveHex = '#4020a0'; + pulseSpeed = 0.5; + pulseAmplitude = 0.25; + } else if (this.biome.name === 'Volcanic') { + emissiveHex = '#802010'; + pulseSpeed = 0.8; + pulseAmplitude = 0.3; + } + + this.emissiveDecorationManager.registerInstancedDecoration(crystalMesh, { + emissive: emissiveHex, + emissiveIntensity: 0.5, + pulseSpeed, + pulseAmplitude, + }); + } + } + + // MapDecorations handles watch towers and destructibles (non-instanced objects) this.legacyDecorations = new MapDecorations(this.mapData, this.terrain, this.scene, this.decorationLightManager); this.scene.add(this.legacyDecorations.group); } @@ -258,6 +310,15 @@ export class EnvironmentManager { this.decorationLightManager.update(camera, deltaTime); } + // PERF: Update instanced decoration frustum culling - only render visible instances + if (camera) { + updateDecorationFrustum(camera); + this.trees?.update(); + this.rocks?.update(); + this.crystals?.update(); + this.grass?.update(); + this.pebbles?.update(); + } } /** @@ -331,10 +392,12 @@ export class EnvironmentManager { public getRockCollisions(): Array<{ x: number; z: number; radius: number }> { const collisions: Array<{ x: number; z: number; radius: number }> = []; - // Get from explicit decorations (includes rocks and trees from map data) - if (this.legacyDecorations) { - collisions.push(...this.legacyDecorations.getRockCollisions()); - collisions.push(...this.legacyDecorations.getTreeCollisions()); + // Get from instanced decorations + if (this.rocks) { + collisions.push(...this.rocks.getRockCollisions()); + } + if (this.trees) { + collisions.push(...this.trees.getTreeCollisions()); } return collisions; @@ -692,6 +755,14 @@ export class EnvironmentManager { * Dispose all resources */ public dispose(): void { + // Dispose instanced decorations + this.trees?.dispose(); + this.rocks?.dispose(); + this.crystals?.dispose(); + this.grass?.dispose(); + this.pebbles?.dispose(); + + // Dispose other resources this.terrain.dispose(); this.waterMesh?.dispose(); this.mapBorderFog?.dispose(); @@ -701,6 +772,7 @@ export class EnvironmentManager { this.emissiveDecorationManager?.dispose(); this.emissiveLightPool?.dispose(); + // Remove from scene this.scene.remove(this.terrain.mesh); this.scene.remove(this.ambientLight); this.scene.remove(this.directionalLight); @@ -714,6 +786,12 @@ export class EnvironmentManager { this.scene.environment = null; } + // Remove groups from scene + if (this.trees) this.scene.remove(this.trees.group); + if (this.rocks) this.scene.remove(this.rocks.group); + if (this.crystals) this.scene.remove(this.crystals.group); + if (this.grass) this.scene.remove(this.grass.group); + if (this.pebbles) this.scene.remove(this.pebbles.group); if (this.waterMesh) this.scene.remove(this.waterMesh.group); if (this.mapBorderFog) this.scene.remove(this.mapBorderFog.mesh); if (this.particles) this.scene.remove(this.particles.points); diff --git a/src/rendering/InstancedDecorations.ts b/src/rendering/InstancedDecorations.ts index 3354f928..1fc172be 100644 --- a/src/rendering/InstancedDecorations.ts +++ b/src/rendering/InstancedDecorations.ts @@ -1,9 +1,8 @@ import * as THREE from 'three'; import { BiomeConfig } from './Biomes'; -import { MapData } from '@/data/maps'; +import { MapData, MapDecoration } from '@/data/maps'; import AssetManager from '@/assets/AssetManager'; import { DECORATIONS } from '@/data/rendering.config'; -import { distance } from '@/utils/math'; // PERF: Reusable Euler object for instanced decoration loops (avoids thousands of allocations) const _tempEuler = new THREE.Euler(); @@ -47,7 +46,7 @@ export function updateDecorationFrustum(camera: THREE.Camera): void { * Check if a point is within the frustum AND within distance range. * Distance check is faster (squared comparison) and runs first. */ -function isInFrustum(x: number, y: number, z: number, margin: number = 2): boolean { +function isInFrustum(x: number, y: number, z: number): boolean { // PERF: Distance check first (faster) - cull decorations far from camera const dx = x - _cameraX; const dz = z - _cameraZ; @@ -62,110 +61,13 @@ function isInFrustum(x: number, y: number, z: number, margin: number = 2): boole } /** - * Build a set of cells that should be cleared near ramps. - * Uses circular clearance around ramp cells PLUS extended clearance - * in the direction of ramp entry/exit to keep pathways clear. - */ -function buildRampClearanceSet(mapData: MapData): Set { - const clearance = new Set(); - const RAMP_CLEARANCE_RADIUS = DECORATIONS.RAMP_CLEARANCE_RADIUS; - const RAMP_EXIT_EXTENSION = DECORATIONS.RAMP_EXIT_EXTENSION; - - // First pass: circular clearance around all ramp cells - for (let cy = 0; cy < mapData.height; cy++) { - for (let cx = 0; cx < mapData.width; cx++) { - const cell = mapData.terrain[cy][cx]; - if (cell.terrain === 'ramp') { - for (let dy = -RAMP_CLEARANCE_RADIUS; dy <= RAMP_CLEARANCE_RADIUS; dy++) { - for (let dx = -RAMP_CLEARANCE_RADIUS; dx <= RAMP_CLEARANCE_RADIUS; dx++) { - const dist = distance(cx, cy, cx + dx, cy + dy); - if (dist <= RAMP_CLEARANCE_RADIUS) { - clearance.add(`${cx + dx},${cy + dy}`); - } - } - } - } - } - } - - // Second pass: extended clearance at ramp entry/exit based on ramp direction - for (const ramp of mapData.ramps) { - // Calculate ramp center - const rampCenterX = ramp.x + ramp.width / 2; - const rampCenterY = ramp.y + ramp.height / 2; - - // Determine exit direction vectors based on ramp direction - let exitDx = 0, exitDy = 0; - let perpDx = 0, perpDy = 0; // Perpendicular for width - let exitStartX = rampCenterX, exitStartY = rampCenterY; - let entryStartX = rampCenterX, entryStartY = rampCenterY; - - switch (ramp.direction) { - case 'north': - exitDy = -1; perpDx = 1; - exitStartY = ramp.y; // Top of ramp - entryStartY = ramp.y + ramp.height; // Bottom of ramp - break; - case 'south': - exitDy = 1; perpDx = 1; - exitStartY = ramp.y + ramp.height; - entryStartY = ramp.y; - break; - case 'east': - exitDx = 1; perpDy = 1; - exitStartX = ramp.x + ramp.width; - entryStartX = ramp.x; - break; - case 'west': - exitDx = -1; perpDy = 1; - exitStartX = ramp.x; - entryStartX = ramp.x + ramp.width; - break; - } - - // Add extended clearance in exit direction (where units walk) - const halfWidth = Math.max(ramp.width, ramp.height) / 2 + 4; - for (let d = 0; d < RAMP_EXIT_EXTENSION; d++) { - const cx = Math.floor(exitStartX + exitDx * d); - const cy = Math.floor(exitStartY + exitDy * d); - // Add width perpendicular to exit direction - for (let w = -halfWidth; w <= halfWidth; w++) { - clearance.add(`${cx + perpDx * w},${cy + perpDy * w}`); - } - } - - // Add extended clearance in entry direction too - for (let d = 0; d < RAMP_EXIT_EXTENSION; d++) { - const cx = Math.floor(entryStartX - exitDx * d); - const cy = Math.floor(entryStartY - exitDy * d); - for (let w = -halfWidth; w <= halfWidth; w++) { - clearance.add(`${cx + perpDx * w},${cy + perpDy * w}`); - } - } - } - - return clearance; -} - -/** - * PERFORMANCE: Instanced rendering for decorations - * - * Instead of creating individual meshes (1000+ draw calls), - * we use InstancedMesh to render all similar objects in a single draw call. - * This can improve performance by 10-100x for decoration rendering. - * - * When custom GLB models are available, we use individual meshes placed at - * each position. When not available, we fall back to instanced procedural meshes. - */ - -/** - * Extract geometry from a loaded model for instancing + * Extract combined geometry from a loaded model */ function extractGeometry(object: THREE.Object3D): THREE.BufferGeometry | null { let geometry: THREE.BufferGeometry | null = null; object.traverse((child) => { if (child instanceof THREE.Mesh && child.geometry && !geometry) { - geometry = child.geometry.clone(); + geometry = child.geometry; } }); return geometry; @@ -174,7 +76,6 @@ function extractGeometry(object: THREE.Object3D): THREE.BufferGeometry | null { /** * Extract material from a loaded model * Applies rendering hints from assets.json if available - * @param assetId - Asset ID to look up rendering hints */ function extractMaterial(object: THREE.Object3D, assetId?: string): THREE.Material | null { let material: THREE.Material | null = null; @@ -188,23 +89,19 @@ function extractMaterial(object: THREE.Object3D, assetId?: string): THREE.Materi } }); - // Type guard for MeshStandardMaterial + // Apply rendering hints for MeshStandardMaterial if (material && (material as THREE.MeshStandardMaterial).isMeshStandardMaterial) { const stdMaterial = material as THREE.MeshStandardMaterial; - // Check for rendering hints from assets.json const hints = assetId ? AssetManager.getRenderingHints(assetId) : null; if (hints) { - // Apply envMapIntensity from hints (default was 0 for performance) stdMaterial.envMapIntensity = hints.envMapIntensity ?? 0; - // Apply emissive properties if (hints.emissive) { stdMaterial.emissive = new THREE.Color(hints.emissive); stdMaterial.emissiveIntensity = hints.emissiveIntensity ?? 1.0; } - // Apply roughness/metalness overrides if (hints.roughnessOverride !== null && hints.roughnessOverride !== undefined) { stdMaterial.roughness = hints.roughnessOverride; } @@ -212,29 +109,21 @@ function extractMaterial(object: THREE.Object3D, assetId?: string): THREE.Materi stdMaterial.metalness = hints.metalnessOverride; } - // Apply exposure (brightness multiplier) - // For exposure > 1: Add emissive contribution to brighten - // For exposure < 1: Darken the base color const exposure = hints.exposure ?? 1.0; if (exposure !== 1.0) { if (exposure > 1.0) { - // Brighten: add emissive contribution based on base color const baseColor = stdMaterial.color.clone(); - // If already has emissive, blend with it; otherwise set new emissive if (!hints.emissive) { stdMaterial.emissive = baseColor; stdMaterial.emissiveIntensity = exposure - 1.0; } else { - // Already has emissive, boost its intensity stdMaterial.emissiveIntensity = (hints.emissiveIntensity ?? 1.0) * exposure; } } else { - // Darken: multiply base color by exposure factor stdMaterial.color.multiplyScalar(exposure); } } } else { - // Default: disable IBL on decoration materials for performance stdMaterial.envMapIntensity = 0; } } @@ -242,12 +131,6 @@ function extractMaterial(object: THREE.Object3D, assetId?: string): THREE.Materi return material; } -interface InstancedGroupConfig { - geometry: THREE.BufferGeometry; - material: THREE.Material; - maxCount: number; -} - // Instance data for frustum culling interface InstanceData { x: number; @@ -265,21 +148,38 @@ interface CullableInstancedMesh { maxCount: number; } +// Tree decoration types +const TREE_TYPES = new Set([ + 'tree_pine_tall', + 'tree_pine_medium', + 'tree_dead', + 'tree_alien', + 'tree_palm', + 'tree_mushroom', +]); + +// Rock decoration types +const ROCK_TYPES = new Set([ + 'rocks_large', + 'rocks_small', + 'rock_single', +]); + +// Crystal decoration types +const CRYSTAL_TYPES = new Set([ + 'crystal_formation', +]); + /** - * PERFORMANCE: True instanced tree rendering - all trees of same type in ONE draw call - * Previously each tree was a separate mesh causing 400+ draw calls. - * Now we batch by model type for typically 2-6 draw calls total. - * - * Shadow optimization: Trees in playable area cast shadows, border trees don't. - * Frustum culling: Only visible instances are rendered each frame. + * Instanced tree rendering from explicit map data. + * All trees of same model type rendered in ONE draw call. + * Per-instance frustum culling for performance. */ export class InstancedTrees { public group: THREE.Group; private instancedMeshes: CullableInstancedMesh[] = []; private geometries: THREE.BufferGeometry[] = []; private materials: THREE.Material[] = []; - - // Store tree positions for collision detection (x, z are world coords, radius is collision size) private treeCollisions: Array<{ x: number; z: number; radius: number }> = []; // Reusable objects for update loop @@ -290,253 +190,142 @@ export class InstancedTrees { constructor( mapData: MapData, - biome: BiomeConfig, + _biome: BiomeConfig, getHeightAt: (x: number, y: number) => number ) { this.group = new THREE.Group(); - // Skip procedural generation for custom/editor maps - if (mapData.skipProceduralDecorations) return; - if (biome.treeDensity <= 0) return; - - // Tree count - focus on map edges, not cliff edges - const treeCount = Math.floor(mapData.width * mapData.height * biome.treeDensity * 0.01); - const maxTrees = Math.min(treeCount, 400); - - // Get tree model types based on biome - const treeModelIds = this.getTreeModelsForBiome(biome); - - // Build ramp clearance set to avoid blocking pathways (uses extended directional clearance) - const rampClearance = buildRampClearanceSet(mapData); + // Filter tree decorations from map data + const treeDecorations = (mapData.decorations || []).filter(d => TREE_TYPES.has(d.type)); + if (treeDecorations.length === 0) return; - // PERF: Separate trees into playable (cast shadows) vs border (no shadows) - type TreePos = { x: number; y: number; height: number; scale: number; rotation: number }; - const playableTreesByModel = new Map(); - const borderTreesByModel = new Map(); - for (const modelId of treeModelIds) { - playableTreesByModel.set(modelId, []); - borderTreesByModel.set(modelId, []); + // Group by model ID + const treesByModel = new Map(); + for (const dec of treeDecorations) { + if (!treesByModel.has(dec.type)) { + treesByModel.set(dec.type, []); + } + treesByModel.get(dec.type)!.push(dec); } - // Border zone + // Border zone check for shadow optimization const BORDER_MARGIN = DECORATIONS.BORDER_MARGIN; const isInBorder = (x: number, y: number) => x < BORDER_MARGIN || x > mapData.width - BORDER_MARGIN || y < BORDER_MARGIN || y > mapData.height - BORDER_MARGIN; - // Find elevated cliff edges (for placing some trees on base edges) - const cliffEdgePositions: Array<{ x: number; y: number }> = []; - for (let cy = 1; cy < mapData.height - 1; cy++) { - for (let cx = 1; cx < mapData.width - 1; cx++) { - if (rampClearance.has(`${cx},${cy}`)) continue; - const cell = mapData.terrain[cy][cx]; - if (cell.terrain === 'unbuildable') { - const neighbors = [ - mapData.terrain[cy - 1]?.[cx], - mapData.terrain[cy + 1]?.[cx], - mapData.terrain[cy]?.[cx - 1], - mapData.terrain[cy]?.[cx + 1], - ]; - const nearGround = neighbors.some(n => n && n.terrain === 'ground'); - if (nearGround) { - cliffEdgePositions.push({ x: cx + Math.random() * 0.5, y: cy + Math.random() * 0.5 }); - } - } - } - } - - let treesPlaced = 0; - - // Helper to collect a tree position - adds to correct bucket based on position - const collectTree = (x: number, y: number): boolean => { - const cellX = Math.floor(x); - const cellY = Math.floor(y); - if (cellX < 0 || cellX >= mapData.width || cellY < 0 || cellY >= mapData.height) return false; - - const cell = mapData.terrain[cellY][cellX]; - if (cell.terrain === 'ramp') return false; - - const modelId = treeModelIds[Math.floor(Math.random() * treeModelIds.length)]; - if (!AssetManager.hasDecorationModel(modelId)) return false; - - const height = getHeightAt(x, y); - const scale = DECORATIONS.TREE_SCALE_MIN + Math.random() * DECORATIONS.TREE_SCALE_VARIATION; - const rotation = Math.random() * Math.PI * 2; - - const treePos = { x, y, height, scale, rotation }; - // Trees in border don't cast shadows, playable area trees do - if (isInBorder(x, y)) { - borderTreesByModel.get(modelId)!.push(treePos); - } else { - playableTreesByModel.get(modelId)!.push(treePos); - } - - // Store collision data for pathfinding - const collisionRadius = scale * DECORATIONS.TREE_COLLISION_RADIUS; - this.treeCollisions.push({ x, z: y, radius: collisionRadius }); - - return true; - }; - - // Cliff edges (20%) - these are near bases, so important for shadows - const cliffTreeCount = Math.min(Math.floor(maxTrees * 0.2), cliffEdgePositions.length); - for (let i = 0; i < cliffTreeCount && treesPlaced < cliffTreeCount; i++) { - const idx = Math.floor(Math.random() * cliffEdgePositions.length); - const pos = cliffEdgePositions.splice(idx, 1)[0]; - if (collectTree(pos.x, pos.y)) treesPlaced++; - } - - // Map edges (50%) - mostly border decorations - const edgeTreeCount = Math.floor(maxTrees * 0.5); - for (let i = 0; i < edgeTreeCount * 3 && treesPlaced < edgeTreeCount + cliffTreeCount; i++) { - let x: number, y: number; - const edge = Math.floor(Math.random() * 4); - switch (edge) { - case 0: x = 2 + Math.random() * 10; y = 5 + Math.random() * (mapData.height - 10); break; - case 1: x = mapData.width - 12 + Math.random() * 10; y = 5 + Math.random() * (mapData.height - 10); break; - case 2: x = 5 + Math.random() * (mapData.width - 10); y = 2 + Math.random() * 10; break; - default: x = 5 + Math.random() * (mapData.width - 10); y = mapData.height - 12 + Math.random() * 10; break; - } - if (rampClearance.has(`${Math.floor(x)},${Math.floor(y)}`)) continue; - if (collectTree(x, y)) treesPlaced++; - } - - // Corners (30%) - border decorations - for (let i = 0; i < maxTrees * 2 && treesPlaced < maxTrees; i++) { - const corner = Math.floor(Math.random() * 4); - let x: number, y: number; - switch (corner) { - case 0: x = 3 + Math.random() * 18; y = 3 + Math.random() * 18; break; - case 1: x = mapData.width - 21 + Math.random() * 18; y = 3 + Math.random() * 18; break; - case 2: x = 3 + Math.random() * 18; y = mapData.height - 21 + Math.random() * 18; break; - default: x = mapData.width - 21 + Math.random() * 18; y = mapData.height - 21 + Math.random() * 18; break; - } - if (rampClearance.has(`${Math.floor(x)},${Math.floor(y)}`)) continue; - if (collectTree(x, y)) treesPlaced++; - } - - // Helper to create instanced mesh from positions with frustum culling support - const createInstancedMesh = ( - modelId: string, - positions: TreePos[], - castShadow: boolean - ) => { - if (positions.length === 0) return; + // Create instanced meshes for each model type + for (const [modelId, decorations] of treesByModel) { + if (!AssetManager.hasDecorationModel(modelId)) continue; const original = AssetManager.getDecorationOriginal(modelId); - if (!original) return; + if (!original) continue; const geometry = extractGeometry(original); const material = extractMaterial(original, modelId); - if (!geometry || !material) return; + if (!geometry || !material) continue; const instancedGeometry = geometry.clone(); const instancedMaterial = material.clone(); - // Rendering hints (envMapIntensity, emissive, etc.) are applied in extractMaterial this.geometries.push(instancedGeometry); this.materials.push(instancedMaterial); const yOffset = AssetManager.getModelYOffset(modelId); - const instancedMesh = new THREE.InstancedMesh( - instancedGeometry, - instancedMaterial, - positions.length - ); - - // Store instance data for frustum culling - const instances: InstanceData[] = positions.map(p => ({ - x: p.x, - y: p.height + yOffset * p.scale, - z: p.y, // Note: y in TreePos is z in world space - scale: p.scale, - rotation: p.rotation, - yOffset, - })); - - // Initialize with all instances visible (first frame) - const matrix = new THREE.Matrix4(); - const position = new THREE.Vector3(); - const quaternion = new THREE.Quaternion(); - const scale = new THREE.Vector3(); + // Separate into shadow-casting (playable area) and non-shadow (border) + const playable: InstanceData[] = []; + const border: InstanceData[] = []; + + for (const dec of decorations) { + const scale = dec.scale ?? 1.0; + const rotation = dec.rotation ?? Math.random() * Math.PI * 2; + const height = getHeightAt(dec.x, dec.y); + + const inst: InstanceData = { + x: dec.x, + y: height + yOffset * scale, + z: dec.y, // Note: decoration y is world z + scale, + rotation, + yOffset, + }; + + if (isInBorder(dec.x, dec.y)) { + border.push(inst); + } else { + playable.push(inst); + } - for (let i = 0; i < positions.length; i++) { - const inst = instances[i]; - position.set(inst.x, inst.y, inst.z); - _tempEuler.set(0, inst.rotation, 0); - quaternion.setFromEuler(_tempEuler); - scale.set(inst.scale, inst.scale, inst.scale); - matrix.compose(position, quaternion, scale); - instancedMesh.setMatrixAt(i, matrix); + // Track collision data + const collisionRadius = scale * DECORATIONS.TREE_COLLISION_RADIUS; + this.treeCollisions.push({ x: dec.x, z: dec.y, radius: collisionRadius }); } - instancedMesh.instanceMatrix.needsUpdate = true; - instancedMesh.frustumCulled = false; // We handle culling manually per-instance - instancedMesh.castShadow = castShadow; - instancedMesh.receiveShadow = true; + // Create instanced meshes + this.createInstancedMesh(instancedGeometry, instancedMaterial, playable, true); + this.createInstancedMesh(instancedGeometry.clone(), instancedMaterial.clone(), border, false); + } + } - this.instancedMeshes.push({ - mesh: instancedMesh, - instances, - maxCount: positions.length, - }); - this.group.add(instancedMesh); - }; + private createInstancedMesh( + geometry: THREE.BufferGeometry, + material: THREE.Material, + instances: InstanceData[], + castShadow: boolean + ): void { + if (instances.length === 0) return; + + const instancedMesh = new THREE.InstancedMesh(geometry, material, instances.length); + + // Initialize matrices + const matrix = new THREE.Matrix4(); + const position = new THREE.Vector3(); + const quaternion = new THREE.Quaternion(); + const scale = new THREE.Vector3(); - // Create instanced meshes - playable area trees cast shadows, border trees don't - for (const modelId of treeModelIds) { - createInstancedMesh(modelId, playableTreesByModel.get(modelId)!, true); // Cast shadows - createInstancedMesh(modelId, borderTreesByModel.get(modelId)!, false); // No shadows + for (let i = 0; i < instances.length; i++) { + const inst = instances[i]; + position.set(inst.x, inst.y, inst.z); + _tempEuler.set(0, inst.rotation, 0); + quaternion.setFromEuler(_tempEuler); + scale.set(inst.scale, inst.scale, inst.scale); + matrix.compose(position, quaternion, scale); + instancedMesh.setMatrixAt(i, matrix); } + + instancedMesh.instanceMatrix.needsUpdate = true; + instancedMesh.frustumCulled = false; // We handle culling manually + instancedMesh.castShadow = castShadow; + instancedMesh.receiveShadow = true; + + this.instancedMeshes.push({ mesh: instancedMesh, instances, maxCount: instances.length }); + this.group.add(instancedMesh); } - /** - * Update visible instances based on camera frustum. - * Call this every frame after updateDecorationFrustum(). - */ public update(): void { - let totalVisible = 0; - let totalInstances = 0; - for (const { mesh, instances, maxCount } of this.instancedMeshes) { let visibleCount = 0; - totalInstances += maxCount; for (let i = 0; i < maxCount; i++) { const inst = instances[i]; + if (!isInFrustum(inst.x, inst.y, inst.z)) continue; - // PERF: Skip instances outside camera frustum - if (!isInFrustum(inst.x, inst.y, inst.z)) { - continue; - } - - // Set matrix for this visible instance this._tempPosition.set(inst.x, inst.y, inst.z); _tempEuler.set(0, inst.rotation, 0); this._tempQuaternion.setFromEuler(_tempEuler); this._tempScale.set(inst.scale, inst.scale, inst.scale); this._tempMatrix.compose(this._tempPosition, this._tempQuaternion, this._tempScale); mesh.setMatrixAt(visibleCount, this._tempMatrix); - visibleCount++; } mesh.count = visibleCount; mesh.instanceMatrix.needsUpdate = true; - totalVisible += visibleCount; } } - private getTreeModelsForBiome(biome: BiomeConfig): string[] { - switch (biome.name) { - case 'Frozen Wastes': return ['tree_dead', 'tree_pine_tall']; - case 'Desert': return ['tree_dead', 'tree_palm']; - case 'Volcanic': return ['tree_dead']; - case 'Void': return ['tree_alien', 'tree_mushroom']; - case 'Jungle': return ['tree_palm', 'tree_pine_medium', 'tree_mushroom']; - case 'Ocean': return ['tree_palm', 'tree_dead']; - default: return ['tree_pine_tall', 'tree_pine_medium']; - } + public getTreeCollisions(): Array<{ x: number; z: number; radius: number }> { + return this.treeCollisions; } public dispose(): void { @@ -549,35 +338,19 @@ export class InstancedTrees { for (const material of this.materials) { material.dispose(); } - this.instancedMeshes = []; - this.geometries = []; - this.materials = []; - } - - /** - * Get tree collision data for building placement validation and pathfinding - * Returns array of { x, z, radius } for each tree - */ - public getTreeCollisions(): Array<{ x: number; z: number; radius: number }> { - return this.treeCollisions; } } /** - * PERFORMANCE: True instanced rock rendering - all rocks of same type in ONE draw call - * Previously each rock was a separate mesh causing 300+ draw calls. - * Now we batch by model type for typically 3 draw calls total. - * - * Shadow optimization: Rocks in playable area cast shadows, border rocks don't. - * Frustum culling: Only visible instances are rendered each frame. + * Instanced rock rendering from explicit map data. + * All rocks of same model type rendered in ONE draw call. + * Per-instance frustum culling for performance. */ export class InstancedRocks { public group: THREE.Group; private instancedMeshes: CullableInstancedMesh[] = []; private geometries: THREE.BufferGeometry[] = []; private materials: THREE.Material[] = []; - - // Store rock positions for collision detection (x, z are world coords, radius is collision size) private rockCollisions: Array<{ x: number; z: number; radius: number }> = []; // Reusable objects for update loop @@ -588,201 +361,147 @@ export class InstancedRocks { constructor( mapData: MapData, - biome: BiomeConfig, + _biome: BiomeConfig, getHeightAt: (x: number, y: number) => number ) { this.group = new THREE.Group(); - // Skip procedural generation for custom/editor maps - if (mapData.skipProceduralDecorations) return; - - // Rock count - const rockCount = Math.floor(mapData.width * mapData.height * biome.rockDensity * 0.012); - const maxRocks = Math.min(rockCount, 300); + // Filter rock decorations from map data + const rockDecorations = (mapData.decorations || []).filter(d => ROCK_TYPES.has(d.type)); + if (rockDecorations.length === 0) return; - // Rock model types to use for variety - const rockModelIds = ['rocks_large', 'rocks_small', 'rock_single']; - - // Build ramp clearance set to avoid blocking pathways (uses extended directional clearance) - const rampClearance = buildRampClearanceSet(mapData); - - // PERF: Separate rocks into playable (cast shadows) vs border (no shadows) - type RockPos = { x: number; y: number; height: number; scale: number; rotation: number }; - const playableRocksByModel = new Map(); - const borderRocksByModel = new Map(); - for (const modelId of rockModelIds) { - playableRocksByModel.set(modelId, []); - borderRocksByModel.set(modelId, []); + // Group by model ID + const rocksByModel = new Map(); + for (const dec of rockDecorations) { + if (!rocksByModel.has(dec.type)) { + rocksByModel.set(dec.type, []); + } + rocksByModel.get(dec.type)!.push(dec); } - // Border zone + // Border zone check for shadow optimization const BORDER_MARGIN = DECORATIONS.BORDER_MARGIN; const isInBorder = (x: number, y: number) => x < BORDER_MARGIN || x > mapData.width - BORDER_MARGIN || y < BORDER_MARGIN || y > mapData.height - BORDER_MARGIN; - let rocksPlaced = 0; - for (let i = 0; i < maxRocks * 3 && rocksPlaced < maxRocks; i++) { - let x: number, y: number; - - // Place rocks primarily on map edges (60%) and scattered (40%) - if (Math.random() < 0.6) { - const edge = Math.floor(Math.random() * 4); - switch (edge) { - case 0: x = 3 + Math.random() * 10; y = 8 + Math.random() * (mapData.height - 16); break; - case 1: x = mapData.width - 13 + Math.random() * 10; y = 8 + Math.random() * (mapData.height - 16); break; - case 2: x = 8 + Math.random() * (mapData.width - 16); y = 3 + Math.random() * 10; break; - default: x = 8 + Math.random() * (mapData.width - 16); y = mapData.height - 13 + Math.random() * 10; break; - } - } else { - x = 10 + Math.random() * (mapData.width - 20); - y = 10 + Math.random() * (mapData.height - 20); - } - - const cellX = Math.floor(x); - const cellY = Math.floor(y); - - if (rampClearance.has(`${cellX},${cellY}`)) continue; - - if (cellX >= 0 && cellX < mapData.width && cellY >= 0 && cellY < mapData.height) { - const cell = mapData.terrain[cellY][cellX]; - if (cell.terrain === 'ground' || cell.terrain === 'unbuildable') { - const modelId = rockModelIds[Math.floor(Math.random() * rockModelIds.length)]; - if (!AssetManager.hasDecorationModel(modelId)) continue; - - const height = getHeightAt(x, y); - const baseScale = modelId === 'rocks_large' ? 1.0 : (modelId === 'rocks_small' ? 0.8 : 0.6); - const scale = baseScale * (0.7 + Math.random() * 0.6); - const rotation = Math.random() * Math.PI * 2; - - const rockPos = { x, y, height, scale, rotation }; - // Rocks in border don't cast shadows, playable area rocks do - if (isInBorder(x, y)) { - borderRocksByModel.get(modelId)!.push(rockPos); - } else { - playableRocksByModel.get(modelId)!.push(rockPos); - } - - // Store collision data - const collisionRadius = scale * 2.0; - this.rockCollisions.push({ x, z: y, radius: collisionRadius }); - rocksPlaced++; - } - } - } - - // Helper to create instanced mesh from positions with frustum culling support - const createInstancedMesh = ( - modelId: string, - positions: RockPos[], - castShadow: boolean - ) => { - if (positions.length === 0) return; + // Create instanced meshes for each model type + for (const [modelId, decorations] of rocksByModel) { + if (!AssetManager.hasDecorationModel(modelId)) continue; const original = AssetManager.getDecorationOriginal(modelId); - if (!original) return; + if (!original) continue; const geometry = extractGeometry(original); const material = extractMaterial(original, modelId); - if (!geometry || !material) return; + if (!geometry || !material) continue; const instancedGeometry = geometry.clone(); const instancedMaterial = material.clone(); - // Rendering hints (envMapIntensity, emissive, etc.) are applied in extractMaterial this.geometries.push(instancedGeometry); this.materials.push(instancedMaterial); const yOffset = AssetManager.getModelYOffset(modelId); - const instancedMesh = new THREE.InstancedMesh( - instancedGeometry, - instancedMaterial, - positions.length - ); - - // Store instance data for frustum culling - const instances: InstanceData[] = positions.map(p => ({ - x: p.x, - y: p.height + yOffset * p.scale, - z: p.y, // Note: y in RockPos is z in world space - scale: p.scale, - rotation: p.rotation, - yOffset, - })); + // Separate into shadow-casting (playable area) and non-shadow (border) + const playable: InstanceData[] = []; + const border: InstanceData[] = []; + + for (const dec of decorations) { + const scale = dec.scale ?? 1.0; + const rotation = dec.rotation ?? Math.random() * Math.PI * 2; + const height = getHeightAt(dec.x, dec.y); + + const inst: InstanceData = { + x: dec.x, + y: height + yOffset * scale, + z: dec.y, + scale, + rotation, + yOffset, + }; + + if (isInBorder(dec.x, dec.y)) { + border.push(inst); + } else { + playable.push(inst); + } - // Initialize with all instances visible (first frame) - const matrix = new THREE.Matrix4(); - const position = new THREE.Vector3(); - const quaternion = new THREE.Quaternion(); - const scale = new THREE.Vector3(); + // Track collision data - base radius varies by rock type + let baseRadius = 1.0; + if (modelId === 'rocks_large') baseRadius = 2.0; + else if (modelId === 'rocks_small') baseRadius = 1.2; + else if (modelId === 'rock_single') baseRadius = 0.8; - for (let i = 0; i < positions.length; i++) { - const inst = instances[i]; - position.set(inst.x, inst.y, inst.z); - _tempEuler.set(0, inst.rotation, 0); - quaternion.setFromEuler(_tempEuler); - scale.set(inst.scale, inst.scale, inst.scale); - matrix.compose(position, quaternion, scale); - instancedMesh.setMatrixAt(i, matrix); + this.rockCollisions.push({ x: dec.x, z: dec.y, radius: baseRadius * scale }); } - instancedMesh.instanceMatrix.needsUpdate = true; - instancedMesh.frustumCulled = false; // We handle culling manually per-instance - instancedMesh.castShadow = castShadow; - instancedMesh.receiveShadow = true; + // Create instanced meshes + this.createInstancedMesh(instancedGeometry, instancedMaterial, playable, true); + this.createInstancedMesh(instancedGeometry.clone(), instancedMaterial.clone(), border, false); + } + } - this.instancedMeshes.push({ - mesh: instancedMesh, - instances, - maxCount: positions.length, - }); - this.group.add(instancedMesh); - }; + private createInstancedMesh( + geometry: THREE.BufferGeometry, + material: THREE.Material, + instances: InstanceData[], + castShadow: boolean + ): void { + if (instances.length === 0) return; + + const instancedMesh = new THREE.InstancedMesh(geometry, material, instances.length); - // Create instanced meshes - playable area rocks cast shadows, border rocks don't - for (const modelId of rockModelIds) { - createInstancedMesh(modelId, playableRocksByModel.get(modelId)!, true); // Cast shadows - createInstancedMesh(modelId, borderRocksByModel.get(modelId)!, false); // No shadows + const matrix = new THREE.Matrix4(); + const position = new THREE.Vector3(); + const quaternion = new THREE.Quaternion(); + const scale = new THREE.Vector3(); + + for (let i = 0; i < instances.length; i++) { + const inst = instances[i]; + position.set(inst.x, inst.y, inst.z); + _tempEuler.set(0, inst.rotation, 0); + quaternion.setFromEuler(_tempEuler); + scale.set(inst.scale, inst.scale, inst.scale); + matrix.compose(position, quaternion, scale); + instancedMesh.setMatrixAt(i, matrix); } + + instancedMesh.instanceMatrix.needsUpdate = true; + instancedMesh.frustumCulled = false; + instancedMesh.castShadow = castShadow; + instancedMesh.receiveShadow = true; + + this.instancedMeshes.push({ mesh: instancedMesh, instances, maxCount: instances.length }); + this.group.add(instancedMesh); } - /** - * Update visible instances based on camera frustum. - * Call this every frame after updateDecorationFrustum(). - */ public update(): void { - let totalVisible = 0; - let totalInstances = 0; - for (const { mesh, instances, maxCount } of this.instancedMeshes) { let visibleCount = 0; - totalInstances += maxCount; for (let i = 0; i < maxCount; i++) { const inst = instances[i]; + if (!isInFrustum(inst.x, inst.y, inst.z)) continue; - // PERF: Skip instances outside camera frustum - if (!isInFrustum(inst.x, inst.y, inst.z)) { - continue; - } - - // Set matrix for this visible instance this._tempPosition.set(inst.x, inst.y, inst.z); _tempEuler.set(0, inst.rotation, 0); this._tempQuaternion.setFromEuler(_tempEuler); this._tempScale.set(inst.scale, inst.scale, inst.scale); this._tempMatrix.compose(this._tempPosition, this._tempQuaternion, this._tempScale); mesh.setMatrixAt(visibleCount, this._tempMatrix); - visibleCount++; } mesh.count = visibleCount; mesh.instanceMatrix.needsUpdate = true; - totalVisible += visibleCount; } } + public getRockCollisions(): Array<{ x: number; z: number; radius: number }> { + return this.rockCollisions; + } + public dispose(): void { for (const { mesh } of this.instancedMeshes) { mesh.dispose(); @@ -793,35 +512,139 @@ export class InstancedRocks { for (const material of this.materials) { material.dispose(); } - this.instancedMeshes = []; - this.geometries = []; - this.materials = []; } +} - /** - * Get rock collision data for building placement validation - * Returns array of { x, z, radius } for each rock - */ - public getRockCollisions(): Array<{ x: number; z: number; radius: number }> { - return this.rockCollisions; +/** + * Instanced crystal rendering from explicit map data. + * All crystals rendered in ONE draw call with emissive materials. + * Per-instance frustum culling for performance. + */ +export class InstancedCrystals { + public group: THREE.Group; + private instancedMesh: THREE.InstancedMesh | null = null; + private geometry: THREE.BufferGeometry | null = null; + private material: THREE.Material | null = null; + private instances: InstanceData[] = []; + private maxCount = 0; + + // Reusable objects for update loop + private _tempMatrix = new THREE.Matrix4(); + private _tempPosition = new THREE.Vector3(); + private _tempQuaternion = new THREE.Quaternion(); + private _tempScale = new THREE.Vector3(); + + constructor( + mapData: MapData, + biome: BiomeConfig, + getHeightAt: (x: number, y: number) => number + ) { + this.group = new THREE.Group(); + + // Filter crystal decorations from map data + const crystalDecorations = (mapData.decorations || []).filter(d => CRYSTAL_TYPES.has(d.type)); + if (crystalDecorations.length === 0) return; + + const modelId = 'crystal_formation'; + if (!AssetManager.hasDecorationModel(modelId)) return; + + const original = AssetManager.getDecorationOriginal(modelId); + if (!original) return; + + this.geometry = extractGeometry(original)?.clone() || null; + this.material = extractMaterial(original, modelId)?.clone() || null; + if (!this.geometry || !this.material) return; + + const yOffset = AssetManager.getModelYOffset(modelId); + + // Build instance data + for (const dec of crystalDecorations) { + const scale = dec.scale ?? 1.0; + const rotation = dec.rotation ?? Math.random() * Math.PI * 2; + const height = getHeightAt(dec.x, dec.y); + + this.instances.push({ + x: dec.x, + y: height + yOffset * scale, + z: dec.y, + scale, + rotation, + yOffset, + }); + } + + this.maxCount = this.instances.length; + + // Create instanced mesh + this.instancedMesh = new THREE.InstancedMesh(this.geometry, this.material, this.maxCount); + + const matrix = new THREE.Matrix4(); + const position = new THREE.Vector3(); + const quaternion = new THREE.Quaternion(); + const scale = new THREE.Vector3(); + + for (let i = 0; i < this.maxCount; i++) { + const inst = this.instances[i]; + position.set(inst.x, inst.y, inst.z); + _tempEuler.set(0, inst.rotation, 0); + quaternion.setFromEuler(_tempEuler); + scale.set(inst.scale, inst.scale, inst.scale); + matrix.compose(position, quaternion, scale); + this.instancedMesh.setMatrixAt(i, matrix); + } + + this.instancedMesh.instanceMatrix.needsUpdate = true; + this.instancedMesh.frustumCulled = false; + this.instancedMesh.castShadow = false; // Crystals don't cast shadows + this.instancedMesh.receiveShadow = true; + + this.group.add(this.instancedMesh); + } + + public update(): void { + if (!this.instancedMesh) return; + + let visibleCount = 0; + for (let i = 0; i < this.maxCount; i++) { + const inst = this.instances[i]; + if (!isInFrustum(inst.x, inst.y, inst.z)) continue; + + this._tempPosition.set(inst.x, inst.y, inst.z); + _tempEuler.set(0, inst.rotation, 0); + this._tempQuaternion.setFromEuler(_tempEuler); + this._tempScale.set(inst.scale, inst.scale, inst.scale); + this._tempMatrix.compose(this._tempPosition, this._tempQuaternion, this._tempScale); + this.instancedMesh.setMatrixAt(visibleCount, this._tempMatrix); + visibleCount++; + } + + this.instancedMesh.count = visibleCount; + this.instancedMesh.instanceMatrix.needsUpdate = true; + } + + public getInstancedMesh(): THREE.InstancedMesh | null { + return this.instancedMesh; + } + + public dispose(): void { + this.instancedMesh?.dispose(); + this.geometry?.dispose(); + this.material?.dispose(); } } /** - * Instanced grass/ground debris - thousands of small objects in one draw call - * Frustum culling: Only visible instances are rendered each frame. + * Instanced grass - environmental ground detail (procedural). + * Thousands of grass blades in one draw call. */ export class InstancedGrass { public group: THREE.Group; private instancedMesh: THREE.InstancedMesh | null = null; private geometry: THREE.BufferGeometry | null = null; private material: THREE.Material | null = null; - - // Instance data for frustum culling private instances: Array<{ x: number; y: number; z: number; scale: number; rotation: number }> = []; private maxCount = 0; - // Reusable objects for update loop private _tempMatrix = new THREE.Matrix4(); private _tempPosition = new THREE.Vector3(); private _tempQuaternion = new THREE.Quaternion(); @@ -841,7 +664,7 @@ export class InstancedGrass { const grassCount = Math.min(2000, mapData.width * mapData.height * 0.15); - // Generate grass positions + // Generate grass positions on ground terrain for (let i = 0; i < grassCount; i++) { const x = 5 + Math.random() * (mapData.width - 10); const z = 5 + Math.random() * (mapData.height - 10); @@ -855,7 +678,7 @@ export class InstancedGrass { const scale = 0.1 + Math.random() * 0.15; this.instances.push({ x, - y: height + 0.1 + scale * 2, // Include vertical offset in stored position + y: height + 0.1 + scale * 2, z, scale, rotation: Math.random() * Math.PI * 2, @@ -867,24 +690,16 @@ export class InstancedGrass { if (this.instances.length === 0) return; this.maxCount = this.instances.length; - // Grass color based on biome const grassColor = biome.colors.ground[0].clone().multiplyScalar(0.8); this.material = new THREE.MeshBasicMaterial({ color: grassColor, side: THREE.DoubleSide, }); - // Simple grass blade geometry (flat plane) this.geometry = new THREE.PlaneGeometry(0.3, 0.5); - // Create instanced mesh - this.instancedMesh = new THREE.InstancedMesh( - this.geometry, - this.material, - this.maxCount - ); + this.instancedMesh = new THREE.InstancedMesh(this.geometry, this.material, this.maxCount); - // Initialize with all instances visible (first frame) const matrix = new THREE.Matrix4(); const position = new THREE.Vector3(); const quaternion = new THREE.Quaternion(); @@ -893,7 +708,7 @@ export class InstancedGrass { for (let i = 0; i < this.maxCount; i++) { const inst = this.instances[i]; position.set(inst.x, inst.y, inst.z); - _tempEuler.set(0, inst.rotation, 0); + _tempEuler.set(-Math.PI / 2, inst.rotation, 0); quaternion.setFromEuler(_tempEuler); scale.set(inst.scale, inst.scale, inst.scale); matrix.compose(position, quaternion, scale); @@ -901,36 +716,27 @@ export class InstancedGrass { } this.instancedMesh.instanceMatrix.needsUpdate = true; - this.instancedMesh.frustumCulled = false; // We handle culling manually per-instance + this.instancedMesh.frustumCulled = false; + this.instancedMesh.castShadow = false; + this.instancedMesh.receiveShadow = false; this.group.add(this.instancedMesh); } - /** - * Update visible instances based on camera frustum. - * Call this every frame after updateDecorationFrustum(). - */ public update(): void { - if (!this.instancedMesh || this.maxCount === 0) return; + if (!this.instancedMesh) return; let visibleCount = 0; - for (let i = 0; i < this.maxCount; i++) { const inst = this.instances[i]; + if (!isInFrustum(inst.x, inst.y, inst.z)) continue; - // PERF: Skip instances outside camera frustum - if (!isInFrustum(inst.x, inst.y, inst.z)) { - continue; - } - - // Set matrix for this visible instance this._tempPosition.set(inst.x, inst.y, inst.z); - _tempEuler.set(0, inst.rotation, 0); + _tempEuler.set(-Math.PI / 2, inst.rotation, 0); this._tempQuaternion.setFromEuler(_tempEuler); this._tempScale.set(inst.scale, inst.scale, inst.scale); this._tempMatrix.compose(this._tempPosition, this._tempQuaternion, this._tempScale); this.instancedMesh.setMatrixAt(visibleCount, this._tempMatrix); - visibleCount++; } @@ -946,20 +752,17 @@ export class InstancedGrass { } /** - * Instanced small rocks/pebbles - many small objects in few draw calls - * Frustum culling: Only visible instances are rendered each frame. + * Instanced pebbles - environmental ground detail (procedural). + * Small stones scattered on the ground. */ export class InstancedPebbles { public group: THREE.Group; private instancedMesh: THREE.InstancedMesh | null = null; private geometry: THREE.BufferGeometry | null = null; private material: THREE.Material | null = null; - - // Instance data for frustum culling (pebbles have 3D rotation) - private instances: Array<{ x: number; y: number; z: number; scale: number; rotX: number; rotY: number; rotZ: number }> = []; + private instances: Array<{ x: number; y: number; z: number; scale: number; rotation: number }> = []; private maxCount = 0; - // Reusable objects for update loop private _tempMatrix = new THREE.Matrix4(); private _tempPosition = new THREE.Vector3(); private _tempQuaternion = new THREE.Quaternion(); @@ -972,7 +775,7 @@ export class InstancedPebbles { ) { this.group = new THREE.Group(); - const pebbleCount = Math.min(500, mapData.width * mapData.height * 0.04); + const pebbleCount = Math.min(500, mapData.width * mapData.height * 0.03); // Generate pebble positions for (let i = 0; i < pebbleCount; i++) { @@ -985,15 +788,13 @@ export class InstancedPebbles { const cell = mapData.terrain[cellZ][cellX]; if (cell.terrain === 'ground' || cell.terrain === 'unbuildable') { const height = getHeightAt(x, z); - const size = 0.1 + Math.random() * 0.2; + const scale = 0.05 + Math.random() * 0.1; this.instances.push({ x, - y: height + size * 0.3, + y: height + scale * 0.5, z, - scale: size, - rotX: Math.random() * Math.PI, - rotY: Math.random() * Math.PI, - rotZ: Math.random() * Math.PI, + scale, + rotation: Math.random() * Math.PI * 2, }); } } @@ -1002,21 +803,12 @@ export class InstancedPebbles { if (this.instances.length === 0) return; this.maxCount = this.instances.length; - // Pebble material - const pebbleColor = biome.colors.cliff[0].clone().multiplyScalar(0.7); + const pebbleColor = biome.colors.ground[0].clone().multiplyScalar(0.6); this.material = new THREE.MeshBasicMaterial({ color: pebbleColor }); + this.geometry = new THREE.DodecahedronGeometry(0.15); - // Small icosahedron for pebbles - this.geometry = new THREE.IcosahedronGeometry(1, 0); + this.instancedMesh = new THREE.InstancedMesh(this.geometry, this.material, this.maxCount); - // Create instanced mesh - this.instancedMesh = new THREE.InstancedMesh( - this.geometry, - this.material, - this.maxCount - ); - - // Initialize with all instances visible (first frame) const matrix = new THREE.Matrix4(); const position = new THREE.Vector3(); const quaternion = new THREE.Quaternion(); @@ -1025,7 +817,7 @@ export class InstancedPebbles { for (let i = 0; i < this.maxCount; i++) { const inst = this.instances[i]; position.set(inst.x, inst.y, inst.z); - _tempEuler.set(inst.rotX, inst.rotY, inst.rotZ); + _tempEuler.set(Math.random() * Math.PI, inst.rotation, Math.random() * Math.PI); quaternion.setFromEuler(_tempEuler); scale.set(inst.scale, inst.scale, inst.scale); matrix.compose(position, quaternion, scale); @@ -1033,36 +825,27 @@ export class InstancedPebbles { } this.instancedMesh.instanceMatrix.needsUpdate = true; - this.instancedMesh.frustumCulled = false; // We handle culling manually per-instance + this.instancedMesh.frustumCulled = false; + this.instancedMesh.castShadow = false; + this.instancedMesh.receiveShadow = true; this.group.add(this.instancedMesh); } - /** - * Update visible instances based on camera frustum. - * Call this every frame after updateDecorationFrustum(). - */ public update(): void { - if (!this.instancedMesh || this.maxCount === 0) return; + if (!this.instancedMesh) return; let visibleCount = 0; - for (let i = 0; i < this.maxCount; i++) { const inst = this.instances[i]; + if (!isInFrustum(inst.x, inst.y, inst.z)) continue; - // PERF: Skip instances outside camera frustum - if (!isInFrustum(inst.x, inst.y, inst.z)) { - continue; - } - - // Set matrix for this visible instance this._tempPosition.set(inst.x, inst.y, inst.z); - _tempEuler.set(inst.rotX, inst.rotY, inst.rotZ); + _tempEuler.set(0, inst.rotation, 0); this._tempQuaternion.setFromEuler(_tempEuler); this._tempScale.set(inst.scale, inst.scale, inst.scale); this._tempMatrix.compose(this._tempPosition, this._tempQuaternion, this._tempScale); this.instancedMesh.setMatrixAt(visibleCount, this._tempMatrix); - visibleCount++; } From 7853eda6226276cf96aa06b4b5e7e9a41bda7478 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Jan 2026 16:50:30 +0000 Subject: [PATCH 4/4] test: remove test for deleted pathfinding/Grid.ts --- tests/engine/pathfinding/grid.test.ts | 209 -------------------------- 1 file changed, 209 deletions(-) delete mode 100644 tests/engine/pathfinding/grid.test.ts diff --git a/tests/engine/pathfinding/grid.test.ts b/tests/engine/pathfinding/grid.test.ts deleted file mode 100644 index dd72e6f3..00000000 --- a/tests/engine/pathfinding/grid.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { SpatialGrid } from '@/engine/pathfinding/Grid'; - -describe('SpatialGrid (Pathfinding)', () => { - let grid: SpatialGrid; - - beforeEach(() => { - grid = new SpatialGrid(100, 100, 10); - }); - - describe('constructor', () => { - it('creates grid with specified dimensions', () => { - expect(grid.getCellSize()).toBe(10); - }); - - it('uses default cell size of 2', () => { - const defaultGrid = new SpatialGrid(50, 50); - expect(defaultGrid.getCellSize()).toBe(2); - }); - }); - - describe('insert and remove', () => { - it('inserts entity into grid', () => { - grid.insert(1, 15, 25); - - const entities = grid.getEntitiesInCell(15, 25); - expect(entities.has(1)).toBe(true); - }); - - it('removes entity from grid', () => { - grid.insert(1, 15, 25); - grid.remove(1, 15, 25); - - const entities = grid.getEntitiesInCell(15, 25); - expect(entities.has(1)).toBe(false); - }); - - it('handles removing non-existent entity', () => { - // Should not throw - grid.remove(999, 15, 25); - }); - - it('groups entities in same cell', () => { - grid.insert(1, 15, 15); - grid.insert(2, 17, 17); // Same cell (cell size 10) - grid.insert(3, 18, 19); - - const entities = grid.getEntitiesInCell(15, 15); - expect(entities.size).toBe(3); - expect(entities.has(1)).toBe(true); - expect(entities.has(2)).toBe(true); - expect(entities.has(3)).toBe(true); - }); - }); - - describe('move', () => { - it('moves entity between cells', () => { - grid.insert(1, 5, 5); - grid.move(1, 5, 5, 25, 25); - - expect(grid.getEntitiesInCell(5, 5).has(1)).toBe(false); - expect(grid.getEntitiesInCell(25, 25).has(1)).toBe(true); - }); - - it('does nothing if staying in same cell', () => { - grid.insert(1, 5, 5); - grid.move(1, 5, 5, 6, 6); // Same cell - - expect(grid.getEntitiesInCell(5, 5).has(1)).toBe(true); - }); - }); - - describe('query (radius)', () => { - it('finds entities within radius', () => { - grid.insert(1, 50, 50); - grid.insert(2, 55, 55); - grid.insert(3, 90, 90); // Far away - - const result = grid.query(50, 50, 15); - - expect(result.has(1)).toBe(true); - expect(result.has(2)).toBe(true); - expect(result.has(3)).toBe(false); - }); - - it('returns empty set for empty area', () => { - const result = grid.query(50, 50, 10); - expect(result.size).toBe(0); - }); - - it('handles large radius', () => { - grid.insert(1, 10, 10); - grid.insert(2, 90, 90); - - const result = grid.query(50, 50, 100); - - expect(result.has(1)).toBe(true); - expect(result.has(2)).toBe(true); - }); - }); - - describe('queryRect', () => { - it('finds entities within rectangle', () => { - grid.insert(1, 20, 20); - grid.insert(2, 30, 30); - grid.insert(3, 80, 80); - - const result = grid.queryRect(10, 10, 40, 40); - - expect(result.has(1)).toBe(true); - expect(result.has(2)).toBe(true); - expect(result.has(3)).toBe(false); - }); - - it('returns empty set for empty area', () => { - const result = grid.queryRect(0, 0, 10, 10); - expect(result.size).toBe(0); - }); - }); - - describe('walkability', () => { - it('cells are walkable by default', () => { - expect(grid.isWalkable(50, 50)).toBe(true); - }); - - it('sets cell as unwalkable', () => { - grid.setWalkable(50, 50, false); - expect(grid.isWalkable(50, 50)).toBe(false); - }); - - it('sets cell as walkable', () => { - grid.setWalkable(50, 50, false); - grid.setWalkable(50, 50, true); - expect(grid.isWalkable(50, 50)).toBe(true); - }); - - it('treats unvisited cells as walkable', () => { - expect(grid.isWalkable(99, 99)).toBe(true); - }); - }); - - describe('getEntitiesInCell', () => { - it('returns entities in specific cell', () => { - grid.insert(1, 25, 25); - grid.insert(2, 26, 26); - - const entities = grid.getEntitiesInCell(25, 25); - - expect(entities.has(1)).toBe(true); - expect(entities.has(2)).toBe(true); - }); - - it('returns empty set for empty cell', () => { - const entities = grid.getEntitiesInCell(50, 50); - expect(entities.size).toBe(0); - }); - }); - - describe('clear', () => { - it('removes all entities and cells', () => { - grid.insert(1, 10, 10); - grid.insert(2, 20, 20); - grid.setWalkable(30, 30, false); - - grid.clear(); - - expect(grid.getEntitiesInCell(10, 10).size).toBe(0); - expect(grid.getEntitiesInCell(20, 20).size).toBe(0); - // After clear, unvisited cells are walkable by default - expect(grid.isWalkable(30, 30)).toBe(true); - }); - }); - - describe('cell boundary behavior', () => { - it('entities at cell boundaries go to correct cell', () => { - // Cell size 10: cells are 0-9, 10-19, 20-29, etc. - grid.insert(1, 9, 9); // Cell 0 - grid.insert(2, 10, 10); // Cell 1 - grid.insert(3, 19, 19); // Cell 1 - grid.insert(4, 20, 20); // Cell 2 - - expect(grid.getEntitiesInCell(0, 0).has(1)).toBe(true); - expect(grid.getEntitiesInCell(10, 10).has(2)).toBe(true); - expect(grid.getEntitiesInCell(10, 10).has(3)).toBe(true); - expect(grid.getEntitiesInCell(20, 20).has(4)).toBe(true); - }); - }); - - describe('reuses result set (performance)', () => { - it('query reuses internal set', () => { - grid.insert(1, 50, 50); - - const result1 = grid.query(50, 50, 10); - const result2 = grid.query(50, 50, 10); - - // Same reference (reused set) - expect(result1).toBe(result2); - }); - - it('queryRect reuses internal set', () => { - grid.insert(1, 50, 50); - - const result1 = grid.queryRect(40, 40, 60, 60); - const result2 = grid.queryRect(40, 40, 60, 60); - - expect(result1).toBe(result2); - }); - }); -});