diff --git a/lib/pixel-office/engine/characters.ts b/lib/pixel-office/engine/characters.ts index ec00bef..184b52c 100644 --- a/lib/pixel-office/engine/characters.ts +++ b/lib/pixel-office/engine/characters.ts @@ -94,6 +94,7 @@ export function createCharacter( matrixEffectSeeds: [], interactionTarget: null, isCat: false, + isLobster: false, codeSnippets: [], photoComments: [], isViewingPhoto: false, @@ -111,8 +112,8 @@ export function updateCharacter( ): void { ch.frameTimer += dt - // Cat-specific update: always wander, never sit - if (ch.isCat) { + // Pet-specific update: always wander, never sit + if (ch.isCat || ch.isLobster) { updateCat(ch, dt, walkableTiles, tileMap, blockedTiles) return } diff --git a/lib/pixel-office/engine/officeState.ts b/lib/pixel-office/engine/officeState.ts index 60f15b2..a1b3a3a 100644 --- a/lib/pixel-office/engine/officeState.ts +++ b/lib/pixel-office/engine/officeState.ts @@ -101,6 +101,7 @@ export class OfficeState { subagentMeta: Map = new Map() private nextSubagentId = -1 private static CAT_ID = -9999 + private static LOBSTER_ID = -9998 constructor(layout?: OfficeLayout) { this.layout = layout || createDefaultLayout() @@ -112,6 +113,7 @@ export class OfficeState { this.interactionPoints = getInteractionPoints(this.layout.furniture, this.tileMap, this.blockedTiles) this.doorwayTiles = getDoorwayTiles(this.layout) this.spawnCat() + this.spawnLobster() } /** Rebuild all derived state from a new layout. Reassigns existing characters. @@ -353,6 +355,24 @@ export class OfficeState { this.characters.set(id, ch) } + /** Spawn the office lobster at a random walkable tile */ + spawnLobster(): void { + const id = OfficeState.LOBSTER_ID + if (this.characters.has(id)) return + const spawn = this.walkableTiles.length > 0 + ? this.walkableTiles[Math.floor(Math.random() * this.walkableTiles.length)] + : { col: 1, row: 1 } + const ch = createCharacter(id, 0, null, null, 0) + ch.isLobster = true + ch.tileCol = spawn.col + ch.tileRow = spawn.row + ch.x = spawn.col * TILE_SIZE + TILE_SIZE / 2 + ch.y = spawn.row * TILE_SIZE + TILE_SIZE / 2 + ch.state = CharacterState.IDLE + ch.wanderTimer = 1 + Math.random() * 3 + this.characters.set(id, ch) + } + removeAgent(id: number): void { const ch = this.characters.get(id) if (!ch) return @@ -754,7 +774,7 @@ export class OfficeState { } // Photo comment particles for characters viewing the photograph - if (ch.isViewingPhoto && ch.state === CharacterState.IDLE && !ch.isCat) { + if (ch.isViewingPhoto && ch.state === CharacterState.IDLE && !ch.isCat && !ch.isLobster) { for (const pc of ch.photoComments) pc.age += dt ch.photoComments = ch.photoComments.filter(pc => pc.age < PHOTO_COMMENT_LIFETIME) if (ch.photoComments.length < 2 && Math.random() < dt * PHOTO_COMMENT_SPAWN_RATE) { @@ -778,7 +798,7 @@ export class OfficeState { } // Code snippet particles for working characters - if (ch.isActive && ch.state === CharacterState.TYPE && !ch.isCat) { + if (ch.isActive && ch.state === CharacterState.TYPE && !ch.isCat && !ch.isLobster) { // Age existing snippets and remove expired ones for (const s of ch.codeSnippets) s.age += dt ch.codeSnippets = ch.codeSnippets.filter(s => s.age < CODE_SNIPPET_LIFETIME) @@ -809,9 +829,9 @@ export class OfficeState { getCharacterAt(worldX: number, worldY: number): number | null { const chars = this.getCharacters().sort((a, b) => b.y - a.y) for (const ch of chars) { - // Skip characters that are despawning or cats + // Skip characters that are despawning or pets if (ch.matrixEffect === 'despawn') continue - if (ch.isCat) continue + if (ch.isCat || ch.isLobster) continue // Character sprite is 16x24, anchored bottom-center // Apply sitting offset to match visual position const sittingOffset = ch.state === CharacterState.TYPE ? CHARACTER_SITTING_OFFSET_PX : 0 diff --git a/lib/pixel-office/engine/renderer.ts b/lib/pixel-office/engine/renderer.ts index ff36cc3..8d1ff6b 100644 --- a/lib/pixel-office/engine/renderer.ts +++ b/lib/pixel-office/engine/renderer.ts @@ -1,4 +1,4 @@ -import { TileType, TILE_SIZE, CharacterState } from '../types' +import { TileType, TILE_SIZE, CharacterState, Direction } from '../types' import type { TileType as TileTypeVal, FurnitureInstance, Character, SpriteData, Seat, FloorColor } from '../types' import { getCachedSprite, getOutlineSprite } from '../sprites/spriteCache' import { getCharacterSprites, BUBBLE_PERMISSION_SPRITE, BUBBLE_WAITING_SPRITE } from '../sprites/spriteData' @@ -297,6 +297,32 @@ export function renderScene( // Characters for (const ch of characters) { + const charZY = ch.y + TILE_SIZE / 2 + CHARACTER_Z_SORT_OFFSET + + if (ch.isLobster) { + const lobsterX = Math.round(offsetX + ch.x * zoom) + const lobsterY = Math.round(offsetY + ch.y * zoom + 2 * zoom) + const lobsterAngle = + ch.dir === Direction.RIGHT ? Math.PI / 2 : + ch.dir === Direction.DOWN ? Math.PI : + ch.dir === Direction.LEFT ? -Math.PI / 2 : + 0 + drawables.push({ + zY: charZY, + draw: (c) => { + c.save() + c.translate(lobsterX, lobsterY) + c.rotate(lobsterAngle) + c.textAlign = 'center' + c.textBaseline = 'middle' + c.font = `${Math.max(14, Math.round(9 * zoom))}px serif` + c.fillText('🦞', 0, 0) + c.restore() + }, + }) + continue + } + const sprites = ch.isCat ? getCatSprites() : getCharacterSprites(ch.palette, ch.hueShift) const spriteData = getCharacterSprite(ch, sprites) const cached = getCachedSprite(spriteData, zoom) @@ -309,7 +335,6 @@ export function renderScene( // Sort characters by bottom of their tile (not center) so they render // in front of same-row furniture (e.g. chairs) but behind furniture // at lower rows (e.g. desks, bookshelves that occlude from below). - const charZY = ch.y + TILE_SIZE / 2 + CHARACTER_Z_SORT_OFFSET // Matrix spawn/despawn effect — skip outline, use per-pixel rendering if (ch.matrixEffect) { diff --git a/lib/pixel-office/types.ts b/lib/pixel-office/types.ts index 1372a62..5b045dd 100644 --- a/lib/pixel-office/types.ts +++ b/lib/pixel-office/types.ts @@ -199,6 +199,7 @@ export interface Character { matrixEffectSeeds: number[] interactionTarget: { col: number; row: number; facingDir: Direction; furnitureType?: string } | null isCat: boolean + isLobster: boolean codeSnippets: Array<{ text: string; age: number; x: number; y: number }> photoComments: Array<{ text: string; age: number; x: number }> isViewingPhoto: boolean