feat(pixel-office): add wandering lobster with forward-facing direction

This commit is contained in:
xmanrui
2026-03-01 00:01:52 +08:00
parent 68e0cc32ba
commit 5cc8bcfdb3
4 changed files with 55 additions and 8 deletions
+3 -2
View File
@@ -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
}
+24 -4
View File
@@ -101,6 +101,7 @@ export class OfficeState {
subagentMeta: Map<number, { parentAgentId: number; parentToolId: string }> = 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
+27 -2
View File
@@ -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) {
+1
View File
@@ -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