Files
nissefolk/src/systems/WorldSystem.ts
tekki mariani 5f646d54ca 🐛 fix Nisse outline only shown when actually occluded
Silhouette now hidden by default and toggled on per frame only when
isOccluded() detects a tree, rock or building 1–3 tiles below the Nisse.
Adds WorldSystem.hasResourceAt() for O(1) tile lookup. Outline colour
changed to light blue (0xaaddff) at scale 1.1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 19:52:37 +00:00

242 lines
8.2 KiB
TypeScript

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<number, string> = {
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<number>()
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()
}
}