From cd171c859c26696a7cea24747bea4e385de394d3 Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Mon, 23 Mar 2026 19:40:27 +0000 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9C=A8=20fix=20depth=20sorting=20for=20w?= =?UTF-8?q?orld=20objects=20by=20tileY?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #31. All trees, rocks, seedlings and buildings now use tileY+5 as depth instead of a fixed value, so objects further down the screen always render in front of objects above them regardless of spawn order. Build ghost moved to depth 1000/1001. Co-Authored-By: Claude Sonnet 4.6 --- src/scenes/GameScene.ts | 7 ++++--- src/systems/BuildingSystem.ts | 4 ++-- src/systems/ResourceSystem.ts | 4 ++-- src/systems/TreeSeedlingSystem.ts | 2 +- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index 530018c..7af88ba 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -179,18 +179,19 @@ export class GameScene extends Phaser.Scene { const name = `bobj_${building.id}` if (this.children.getByName(name)) continue + const worldDepth = building.tileY + 5 if (building.kind === 'chest') { - const g = this.add.graphics().setName(name).setDepth(8) + const g = this.add.graphics().setName(name).setDepth(worldDepth) g.fillStyle(0x8B4513); g.fillRect(wx - 10, wy - 7, 20, 14) g.fillStyle(0xCD853F); g.fillRect(wx - 9, wy - 6, 18, 6) g.lineStyle(1, 0x5C3317); g.strokeRect(wx - 10, wy - 7, 20, 14) } else if (building.kind === 'bed') { - this.add.image(wx, wy, 'bed_obj').setName(name).setDepth(8) + this.add.image(wx, wy, 'bed_obj').setName(name).setDepth(worldDepth) } else if (building.kind === 'stockpile_zone') { this.add.image(wx, wy, 'stockpile_obj').setName(name).setDepth(4).setAlpha(0.8) } else if (building.kind === 'forester_hut') { // Draw a simple log-cabin silhouette for the forester hut - const g = this.add.graphics().setName(name).setDepth(8) + const g = this.add.graphics().setName(name).setDepth(worldDepth) // Body g.fillStyle(0x6B3F16); g.fillRect(wx - 12, wy - 9, 24, 18) // Roof diff --git a/src/systems/BuildingSystem.ts b/src/systems/BuildingSystem.ts index 3e52d8a..2d7d7d4 100644 --- a/src/systems/BuildingSystem.ts +++ b/src/systems/BuildingSystem.ts @@ -32,7 +32,7 @@ export class BuildingSystem { create(): void { this.ghost = this.scene.add.rectangle(0, 0, TILE_SIZE, TILE_SIZE, 0x00FF00, 0.35) - this.ghost.setDepth(20) + this.ghost.setDepth(1000) this.ghost.setVisible(false) this.ghost.setStrokeStyle(2, 0x00FF00, 0.8) @@ -40,7 +40,7 @@ export class BuildingSystem { fontSize: '10px', color: '#ffffff', fontFamily: 'monospace', backgroundColor: '#000000aa', padding: { x: 3, y: 2 } }) - this.ghostLabel.setDepth(21) + this.ghostLabel.setDepth(1001) this.ghostLabel.setVisible(false) this.ghostLabel.setOrigin(0.5, 1) diff --git a/src/systems/ResourceSystem.ts b/src/systems/ResourceSystem.ts index d490726..f2a7950 100644 --- a/src/systems/ResourceSystem.ts +++ b/src/systems/ResourceSystem.ts @@ -47,10 +47,10 @@ export class ResourceSystem { sprite.setOrigin(0.5, 0.75) } - sprite.setDepth(5) + sprite.setDepth(node.tileY + 5) const healthBar = this.scene.add.graphics() - healthBar.setDepth(6) + healthBar.setDepth(node.tileY + 6) healthBar.setVisible(false) this.sprites.set(node.id, { sprite, node, healthBar }) diff --git a/src/systems/TreeSeedlingSystem.ts b/src/systems/TreeSeedlingSystem.ts index 5167293..9c42121 100644 --- a/src/systems/TreeSeedlingSystem.ts +++ b/src/systems/TreeSeedlingSystem.ts @@ -110,7 +110,7 @@ export class TreeSeedlingSystem { const key = `seedling_${Math.min(s.stage, 2)}` const sprite = this.scene.add.image(x, y, key) .setOrigin(0.5, 0.85) - .setDepth(5) + .setDepth(s.tileY + 5) this.sprites.set(s.id, sprite) } From 94b2f7f4572862b101e11187bb01e9bacb9de2fb Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Mon, 23 Mar 2026 19:47:51 +0000 Subject: [PATCH 2/5] =?UTF-8?q?=E2=9C=A8=20add=20Nisse=20silhouette=20outl?= =?UTF-8?q?ine=20for=20occlusion=20visibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #33. Each Nisse now has a white filled outline sprite at depth 900 that is always visible above trees and buildings. The main sprite uses Y-based depth (floor(y/TILE_SIZE)+5) so Nisse sort correctly with world objects. Name label, energy bar and job icon moved to depth 901/902 so they remain readable regardless of occlusion. Co-Authored-By: Claude Sonnet 4.6 --- src/systems/VillagerSystem.ts | 45 ++++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/src/systems/VillagerSystem.ts b/src/systems/VillagerSystem.ts index 388331d..609d00d 100644 --- a/src/systems/VillagerSystem.ts +++ b/src/systems/VillagerSystem.ts @@ -15,6 +15,9 @@ const WORK_LOG_MAX = 20 interface VillagerRuntime { sprite: Phaser.GameObjects.Image + /** White silhouette sprite rendered above all world objects so the Nisse is + * always locatable even when occluded by trees or buildings. */ + outlineSprite: Phaser.GameObjects.Image nameLabel: Phaser.GameObjects.Text energyBar: Phaser.GameObjects.Graphics jobIcon: Phaser.GameObjects.Text @@ -118,8 +121,13 @@ export class VillagerSystem { case 'sleeping':this.tickSleeping(v, rt, delta); break } - // Sync sprite to state position - rt.sprite.setPosition(v.x, v.y) + // Sync sprite to state position; depth is Y-based so Nisse sort correctly with world objects + const worldDepth = Math.floor(v.y / TILE_SIZE) + 5 + rt.sprite.setPosition(v.x, v.y).setDepth(worldDepth) + + // Outline sprite mirrors position, flip, and angle so the silhouette matches exactly + rt.outlineSprite.setPosition(v.x, v.y).setFlipX(rt.sprite.flipX).setAngle(rt.sprite.angle) + rt.nameLabel.setPosition(v.x, v.y - 22) rt.energyBar.setPosition(0, 0) this.drawEnergyBar(rt.energyBar, v.x, v.y, v.energy) @@ -569,21 +577,36 @@ export class VillagerSystem { * for a newly added Nisse. * @param v - Villager state to create sprites for */ + /** + * Creates and registers all runtime objects (sprite, outline, label, energy bar, icon) + * for a newly added Nisse. + * @param v - Villager state to create sprites for + */ private spawnSprite(v: VillagerState): void { - const sprite = this.scene.add.image(v.x, v.y, 'villager').setDepth(11) + // Silhouette rendered above all world objects so the Nisse is visible even + // when occluded by a tree or building. + const outlineSprite = this.scene.add.image(v.x, v.y, 'villager') + .setScale(1.3) + .setTintFill(0xffffff) + .setAlpha(0.7) + .setDepth(900) + + // Main sprite depth is updated every frame based on Y position. + const sprite = this.scene.add.image(v.x, v.y, 'villager') + .setDepth(Math.floor(v.y / TILE_SIZE) + 5) const nameLabel = this.scene.add.text(v.x, v.y - 22, v.name, { fontSize: '8px', color: '#ffffff', fontFamily: 'monospace', backgroundColor: '#00000088', padding: { x: 2, y: 1 }, - }).setOrigin(0.5, 1).setDepth(12) + }).setOrigin(0.5, 1).setDepth(901) - const energyBar = this.scene.add.graphics().setDepth(12) - const jobIcon = this.scene.add.text(v.x, v.y - 18, '', { fontSize: '10px' }).setDepth(13) + const energyBar = this.scene.add.graphics().setDepth(901) + const jobIcon = this.scene.add.text(v.x, v.y - 18, '', { fontSize: '10px' }).setDepth(902) sprite.setInteractive() sprite.on('pointerdown', () => this.onNisseClick?.(v.id)) - this.runtime.set(v.id, { sprite, nameLabel, energyBar, jobIcon, path: [], destination: null, workTimer: 0, idleScanTimer: 0, workLog: [] }) + this.runtime.set(v.id, { sprite, outlineSprite, nameLabel, energyBar, jobIcon, path: [], destination: null, workTimer: 0, idleScanTimer: 0, workLog: [] }) } /** @@ -667,14 +690,18 @@ export class VillagerSystem { return result } + /** + * Destroys all Nisse sprites and clears the runtime map. + * Should be called when the scene shuts down. + */ /** * Destroys all Nisse sprites and clears the runtime map. * Should be called when the scene shuts down. */ destroy(): void { for (const rt of this.runtime.values()) { - rt.sprite.destroy(); rt.nameLabel.destroy() - rt.energyBar.destroy(); rt.jobIcon.destroy() + rt.sprite.destroy(); rt.outlineSprite.destroy() + rt.nameLabel.destroy(); rt.energyBar.destroy(); rt.jobIcon.destroy() } this.runtime.clear() } From 5f646d54cab15529a6e4c4be37aef67f82333a0e Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Mon, 23 Mar 2026 19:52:37 +0000 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=90=9B=20fix=20Nisse=20outline=20only?= =?UTF-8?q?=20shown=20when=20actually=20occluded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/systems/VillagerSystem.ts | 39 ++++++++++++++++++++++++++++------- src/systems/WorldSystem.ts | 10 +++++++++ 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/systems/VillagerSystem.ts b/src/systems/VillagerSystem.ts index 609d00d..b008257 100644 --- a/src/systems/VillagerSystem.ts +++ b/src/systems/VillagerSystem.ts @@ -125,8 +125,11 @@ export class VillagerSystem { const worldDepth = Math.floor(v.y / TILE_SIZE) + 5 rt.sprite.setPosition(v.x, v.y).setDepth(worldDepth) - // Outline sprite mirrors position, flip, and angle so the silhouette matches exactly - rt.outlineSprite.setPosition(v.x, v.y).setFlipX(rt.sprite.flipX).setAngle(rt.sprite.angle) + // Show outline only when a world object below the Nisse would occlude them + const tileX = Math.floor(v.x / TILE_SIZE) + const tileY = Math.floor(v.y / TILE_SIZE) + const occluded = this.isOccluded(tileX, tileY) + rt.outlineSprite.setPosition(v.x, v.y).setFlipX(rt.sprite.flipX).setAngle(rt.sprite.angle).setVisible(occluded) rt.nameLabel.setPosition(v.x, v.y - 22) rt.energyBar.setPosition(0, 0) @@ -583,13 +586,14 @@ export class VillagerSystem { * @param v - Villager state to create sprites for */ private spawnSprite(v: VillagerState): void { - // Silhouette rendered above all world objects so the Nisse is visible even - // when occluded by a tree or building. + // Silhouette: same texture, white fill, fixed high depth so it shows through + // trees and buildings. Visibility is toggled per frame by isOccluded(). const outlineSprite = this.scene.add.image(v.x, v.y, 'villager') - .setScale(1.3) - .setTintFill(0xffffff) - .setAlpha(0.7) + .setScale(1.1) + .setTintFill(0xaaddff) + .setAlpha(0.85) .setDepth(900) + .setVisible(false) // Main sprite depth is updated every frame based on Y position. const sprite = this.scene.add.image(v.x, v.y, 'villager') @@ -640,6 +644,27 @@ export class VillagerSystem { if (rt.workLog.length > WORK_LOG_MAX) rt.workLog.length = WORK_LOG_MAX } + // ─── Occlusion check ────────────────────────────────────────────────────── + + /** + * Returns true if a world object (tree, rock, or building) with a higher tileY + * than the Nisse exists on the same column, meaning the Nisse is visually + * behind that object. Checks 1–3 tiles below to account for tall tree canopies. + * @param tileX - Nisse's current tile column + * @param tileY - Nisse's current tile row + */ + private isOccluded(tileX: number, tileY: number): boolean { + const state = stateManager.getState() + for (let dy = 1; dy <= 3; dy++) { + const checkY = tileY + dy + if (this.worldSystem.hasResourceAt(tileX, checkY)) return true + if (Object.values(state.world.buildings).some( + b => b.tileX === tileX && b.tileY === checkY && b.kind !== 'stockpile_zone' + )) return true + } + return false + } + // ─── Public API ─────────────────────────────────────────────────────────── /** diff --git a/src/systems/WorldSystem.ts b/src/systems/WorldSystem.ts index 6c33d1b..5de1ca6 100644 --- a/src/systems/WorldSystem.ts +++ b/src/systems/WorldSystem.ts @@ -172,6 +172,16 @@ export class WorldSystem { 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 From 84b6e517462b21970f0f0ca58e80eb9ca55845c1 Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Mon, 23 Mar 2026 20:02:40 +0000 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=90=9B=20improve=20occlusion=20detect?= =?UTF-8?q?ion=20with=20wider=20tile=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expands isOccluded() from same-column-only to a 3x4 tile window (tileX+-1, tileY+1..4) to catch trees whose canopy extends sideways and well above the trunk tile. Outline scale bumped to 1.15. --- src/systems/VillagerSystem.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/systems/VillagerSystem.ts b/src/systems/VillagerSystem.ts index b008257..da53fa9 100644 --- a/src/systems/VillagerSystem.ts +++ b/src/systems/VillagerSystem.ts @@ -589,7 +589,7 @@ export class VillagerSystem { // Silhouette: same texture, white fill, fixed high depth so it shows through // trees and buildings. Visibility is toggled per frame by isOccluded(). const outlineSprite = this.scene.add.image(v.x, v.y, 'villager') - .setScale(1.1) + .setScale(1.15) .setTintFill(0xaaddff) .setAlpha(0.85) .setDepth(900) @@ -648,19 +648,22 @@ export class VillagerSystem { /** * Returns true if a world object (tree, rock, or building) with a higher tileY - * than the Nisse exists on the same column, meaning the Nisse is visually - * behind that object. Checks 1–3 tiles below to account for tall tree canopies. + * exists in the vicinity of the Nisse and would visually occlude them. + * Checks a 3-column × 4-row window below the Nisse's tile to account for + * wide tree canopies that extend above and to the sides of the trunk tile. * @param tileX - Nisse's current tile column * @param tileY - Nisse's current tile row */ private isOccluded(tileX: number, tileY: number): boolean { const state = stateManager.getState() - for (let dy = 1; dy <= 3; dy++) { - const checkY = tileY + dy - if (this.worldSystem.hasResourceAt(tileX, checkY)) return true - if (Object.values(state.world.buildings).some( - b => b.tileX === tileX && b.tileY === checkY && b.kind !== 'stockpile_zone' - )) return true + const buildings = Object.values(state.world.buildings) + for (let dx = -1; dx <= 1; dx++) { + for (let dy = 1; dy <= 4; dy++) { + const cx = tileX + dx + const cy = tileY + dy + if (this.worldSystem.hasResourceAt(cx, cy)) return true + if (buildings.some(b => b.tileX === cx && b.tileY === cy && b.kind !== 'stockpile_zone')) return true + } } return false } From 4f2e9f73b6897e563cd5fb36c341ed87b41f4ce2 Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Mon, 23 Mar 2026 20:05:53 +0000 Subject: [PATCH 5/5] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20remove=20silhouette=20?= =?UTF-8?q?=E2=80=94=20Nisse=20always=20render=20above=20world=20objects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Depth fixed at 900; isOccluded() and outlineSprite removed. WorldSystem.hasResourceAt() stays as a useful utility. --- src/systems/VillagerSystem.ts | 61 +++++++---------------------------- 1 file changed, 11 insertions(+), 50 deletions(-) diff --git a/src/systems/VillagerSystem.ts b/src/systems/VillagerSystem.ts index da53fa9..7e21b6c 100644 --- a/src/systems/VillagerSystem.ts +++ b/src/systems/VillagerSystem.ts @@ -15,9 +15,6 @@ const WORK_LOG_MAX = 20 interface VillagerRuntime { sprite: Phaser.GameObjects.Image - /** White silhouette sprite rendered above all world objects so the Nisse is - * always locatable even when occluded by trees or buildings. */ - outlineSprite: Phaser.GameObjects.Image nameLabel: Phaser.GameObjects.Text energyBar: Phaser.GameObjects.Graphics jobIcon: Phaser.GameObjects.Text @@ -121,15 +118,8 @@ export class VillagerSystem { case 'sleeping':this.tickSleeping(v, rt, delta); break } - // Sync sprite to state position; depth is Y-based so Nisse sort correctly with world objects - const worldDepth = Math.floor(v.y / TILE_SIZE) + 5 - rt.sprite.setPosition(v.x, v.y).setDepth(worldDepth) - - // Show outline only when a world object below the Nisse would occlude them - const tileX = Math.floor(v.x / TILE_SIZE) - const tileY = Math.floor(v.y / TILE_SIZE) - const occluded = this.isOccluded(tileX, tileY) - rt.outlineSprite.setPosition(v.x, v.y).setFlipX(rt.sprite.flipX).setAngle(rt.sprite.angle).setVisible(occluded) + // Nisse always render above world objects + rt.sprite.setPosition(v.x, v.y) rt.nameLabel.setPosition(v.x, v.y - 22) rt.energyBar.setPosition(0, 0) @@ -586,18 +576,9 @@ export class VillagerSystem { * @param v - Villager state to create sprites for */ private spawnSprite(v: VillagerState): void { - // Silhouette: same texture, white fill, fixed high depth so it shows through - // trees and buildings. Visibility is toggled per frame by isOccluded(). - const outlineSprite = this.scene.add.image(v.x, v.y, 'villager') - .setScale(1.15) - .setTintFill(0xaaddff) - .setAlpha(0.85) - .setDepth(900) - .setVisible(false) - - // Main sprite depth is updated every frame based on Y position. + // Nisse always render above trees, buildings and other world objects. const sprite = this.scene.add.image(v.x, v.y, 'villager') - .setDepth(Math.floor(v.y / TILE_SIZE) + 5) + .setDepth(900) const nameLabel = this.scene.add.text(v.x, v.y - 22, v.name, { fontSize: '8px', color: '#ffffff', fontFamily: 'monospace', @@ -610,7 +591,7 @@ export class VillagerSystem { sprite.setInteractive() sprite.on('pointerdown', () => this.onNisseClick?.(v.id)) - this.runtime.set(v.id, { sprite, outlineSprite, nameLabel, energyBar, jobIcon, path: [], destination: null, workTimer: 0, idleScanTimer: 0, workLog: [] }) + this.runtime.set(v.id, { sprite, nameLabel, energyBar, jobIcon, path: [], destination: null, workTimer: 0, idleScanTimer: 0, workLog: [] }) } /** @@ -644,30 +625,6 @@ export class VillagerSystem { if (rt.workLog.length > WORK_LOG_MAX) rt.workLog.length = WORK_LOG_MAX } - // ─── Occlusion check ────────────────────────────────────────────────────── - - /** - * Returns true if a world object (tree, rock, or building) with a higher tileY - * exists in the vicinity of the Nisse and would visually occlude them. - * Checks a 3-column × 4-row window below the Nisse's tile to account for - * wide tree canopies that extend above and to the sides of the trunk tile. - * @param tileX - Nisse's current tile column - * @param tileY - Nisse's current tile row - */ - private isOccluded(tileX: number, tileY: number): boolean { - const state = stateManager.getState() - const buildings = Object.values(state.world.buildings) - for (let dx = -1; dx <= 1; dx++) { - for (let dy = 1; dy <= 4; dy++) { - const cx = tileX + dx - const cy = tileY + dy - if (this.worldSystem.hasResourceAt(cx, cy)) return true - if (buildings.some(b => b.tileX === cx && b.tileY === cy && b.kind !== 'stockpile_zone')) return true - } - } - return false - } - // ─── Public API ─────────────────────────────────────────────────────────── /** @@ -726,10 +683,14 @@ export class VillagerSystem { * Destroys all Nisse sprites and clears the runtime map. * Should be called when the scene shuts down. */ + /** + * Destroys all Nisse sprites and clears the runtime map. + * Should be called when the scene shuts down. + */ destroy(): void { for (const rt of this.runtime.values()) { - rt.sprite.destroy(); rt.outlineSprite.destroy() - rt.nameLabel.destroy(); rt.energyBar.destroy(); rt.jobIcon.destroy() + rt.sprite.destroy(); rt.nameLabel.destroy() + rt.energyBar.destroy(); rt.jobIcon.destroy() } this.runtime.clear() }