import Phaser from 'phaser' import { TILE_SIZE, WORLD_TILES } from '../config' import { TileType, IMPASSABLE, RESOURCE_TERRAIN } from '../types' import { stateManager } from '../StateManager' const BIOME_COLORS: Record = { 0: '#1565C0', // DEEP_WATER 1: '#42A5F5', // SHALLOW_WATER 2: '#F5DEB3', // SAND 3: '#66BB6A', // GRASS 4: '#43A047', // DARK_GRASS 5: '#33691E', // FOREST 6: '#616161', // ROCK // Built types: show grass below 7: '#66BB6A', 8: '#66BB6A', 9: '#66BB6A', 10: '#66BB6A', } export class WorldSystem { private scene: Phaser.Scene private map!: Phaser.Tilemaps.Tilemap /** * Spatial index: tile keys (tileY * WORLD_TILES + tileX) for every tile * that is currently occupied by a tree or rock resource. * Used by isPassable() to decide if a FOREST or ROCK terrain tile is blocked. */ private resourceTiles = new Set() private tileset!: Phaser.Tilemaps.Tileset private bgImage!: Phaser.GameObjects.Image private builtLayer!: Phaser.Tilemaps.TilemapLayer private bgCanvasTexture!: Phaser.Textures.CanvasTexture /** @param scene - The Phaser scene this system belongs to */ constructor(scene: Phaser.Scene) { this.scene = scene } /** * Generates the terrain background canvas from saved tile data, * creates the built-tile tilemap layer, and sets camera bounds. */ create(): void { const state = stateManager.getState() // --- Canvas background (1px per tile, scaled up, LINEAR filtered) --- const canvasTexture = this.scene.textures.createCanvas('terrain_bg', WORLD_TILES, WORLD_TILES) as Phaser.Textures.CanvasTexture const ctx = canvasTexture.context for (let y = 0; y < WORLD_TILES; y++) { for (let x = 0; x < WORLD_TILES; x++) { const tile = state.world.tiles[y * WORLD_TILES + x] ctx.fillStyle = BIOME_COLORS[tile] ?? '#0a2210' ctx.fillRect(x, y, 1, 1) } } canvasTexture.refresh() this.bgCanvasTexture = canvasTexture this.bgImage = this.scene.add.image(0, 0, 'terrain_bg') .setOrigin(0, 0) .setScale(TILE_SIZE) .setDepth(0) canvasTexture.setFilter(Phaser.Textures.FilterMode.LINEAR) // --- Built tile layer (sparse — only FLOOR, WALL, TILLED_SOIL, WATERED_SOIL) --- this.map = this.scene.make.tilemap({ tileWidth: TILE_SIZE, tileHeight: TILE_SIZE, width: WORLD_TILES, height: WORLD_TILES, }) const ts = this.map.addTilesetImage('tiles', 'tiles', TILE_SIZE, TILE_SIZE, 0, 0, 0) if (!ts) throw new Error('Failed to add tileset') this.tileset = ts const layer = this.map.createBlankLayer('built', this.tileset, 0, 0) if (!layer) throw new Error('Failed to create built layer') this.builtLayer = layer this.builtLayer.setDepth(1) const BUILT_TILES = new Set([7, 8, 9, 10]) // FLOOR, WALL, TILLED_SOIL, WATERED_SOIL for (let y = 0; y < WORLD_TILES; y++) { for (let x = 0; x < WORLD_TILES; x++) { const t = state.world.tiles[y * WORLD_TILES + x] if (BUILT_TILES.has(t)) { this.builtLayer.putTileAt(t, x, y) } } } // Camera bounds this.scene.cameras.main.setBounds(0, 0, WORLD_TILES * TILE_SIZE, WORLD_TILES * TILE_SIZE) this.initResourceTiles() } /** Returns the built-tile tilemap layer (floor, wall, soil). */ getLayer(): Phaser.Tilemaps.TilemapLayer { return this.builtLayer } /** * Places or removes a tile on the built layer. * Built tile types are added; natural types remove the built-layer entry. * @param tileX - Tile column * @param tileY - Tile row * @param type - New tile type to apply */ setTile(tileX: number, tileY: number, type: TileType): void { const BUILT_TILES = new Set([TileType.FLOOR, TileType.WALL, TileType.TILLED_SOIL, TileType.WATERED_SOIL]) if (BUILT_TILES.has(type)) { this.builtLayer.putTileAt(type, tileX, tileY) } else { // Reverting to natural: remove from built layer this.builtLayer.removeTileAt(tileX, tileY) } } /** * Returns whether the tile at the given coordinates can be walked on. * Water and wall tiles are always impassable. * Forest and rock terrain tiles are only impassable when a resource * (tree or rock) currently occupies them — empty forest floor and bare * rocky ground are walkable. * Out-of-bounds tiles are treated as impassable. * @param tileX - Tile column * @param tileY - Tile row */ isPassable(tileX: number, tileY: number): boolean { if (tileX < 0 || tileY < 0 || tileX >= WORLD_TILES || tileY >= WORLD_TILES) return false const state = stateManager.getState() const tile = state.world.tiles[tileY * WORLD_TILES + tileX] if (IMPASSABLE.has(tile)) return false if (RESOURCE_TERRAIN.has(tile)) { return !this.resourceTiles.has(tileY * WORLD_TILES + tileX) } return true } /** * Builds the resource tile index from the current world state. * Called once in create() so that isPassable() has an O(1) lookup. */ private initResourceTiles(): void { this.resourceTiles.clear() const state = stateManager.getState() for (const res of Object.values(state.world.resources)) { this.resourceTiles.add(res.tileY * WORLD_TILES + res.tileX) } } /** * Registers a newly placed resource so isPassable() treats the tile as blocked. * Call this whenever a resource is added at runtime (e.g. a seedling matures). * @param tileX - Resource tile column * @param tileY - Resource tile row */ addResourceTile(tileX: number, tileY: number): void { this.resourceTiles.add(tileY * WORLD_TILES + tileX) } /** * Removes a resource from the tile index so isPassable() treats the tile as free. * Call this when a resource is removed at runtime (e.g. after chopping/mining). * Not strictly required when the tile type also changes (FOREST → DARK_GRASS), * but keeps the index clean for correctness. * @param tileX - Resource tile column * @param tileY - Resource tile row */ removeResourceTile(tileX: number, tileY: number): void { this.resourceTiles.delete(tileY * WORLD_TILES + tileX) } /** * Returns true if a resource (tree or rock) occupies the given tile. * Uses the O(1) resourceTiles index. * @param tileX - Tile column * @param tileY - Tile row */ hasResourceAt(tileX: number, tileY: number): boolean { return this.resourceTiles.has(tileY * WORLD_TILES + tileX) } /** * Converts world pixel coordinates to tile coordinates. * @param worldX - World X in pixels * @param worldY - World Y in pixels * @returns Integer tile position */ worldToTile(worldX: number, worldY: number): { tileX: number; tileY: number } { return { tileX: Math.floor(worldX / TILE_SIZE), tileY: Math.floor(worldY / TILE_SIZE), } } /** * Converts tile coordinates to the world pixel center of that tile. * @param tileX - Tile column * @param tileY - Tile row * @returns World pixel center position */ tileToWorld(tileX: number, tileY: number): { x: number; y: number } { return { x: tileX * TILE_SIZE + TILE_SIZE / 2, y: tileY * TILE_SIZE + TILE_SIZE / 2, } } /** * Returns the tile type at the given tile coordinates from saved state. * @param tileX - Tile column * @param tileY - Tile row */ getTileType(tileX: number, tileY: number): TileType { const state = stateManager.getState() return state.world.tiles[tileY * WORLD_TILES + tileX] as TileType } /** * Updates a single tile's pixel on the background canvas and refreshes the GPU texture. * Used when a natural tile changes at runtime (e.g. DARK_GRASS → GRASS after recovery, * or GRASS → FOREST when a seedling matures). * @param tileX - Tile column * @param tileY - Tile row * @param type - New tile type to reflect visually */ refreshTerrainTile(tileX: number, tileY: number, type: TileType): void { const color = BIOME_COLORS[type] ?? '#0a2210' this.bgCanvasTexture.context.fillStyle = color this.bgCanvasTexture.context.fillRect(tileX, tileY, 1, 1) this.bgCanvasTexture.refresh() } /** Destroys the tilemap and background image. */ destroy(): void { this.map.destroy() this.bgImage.destroy() } }