diff --git a/app/pixel-office/page.tsx b/app/pixel-office/page.tsx index 39c0ffd..e7df71c 100644 --- a/app/pixel-office/page.tsx +++ b/app/pixel-office/page.tsx @@ -892,6 +892,40 @@ export default function PixelOfficePage() { return () => clearInterval(interval) }, [refreshGatewayHealthSnapshot]) + // Debug helper: expose __pixelOffice on window for console testing + const debugCounterRef = useRef(0) + useEffect(() => { + ;(window as any).__pixelOffice = { + addAgents(count = 1) { + const office = officeRef.current + if (!office) { console.warn('[pixelOffice] office not ready'); return } + for (let i = 0; i < count; i++) { + debugCounterRef.current++ + const id = 9000 + debugCounterRef.current + office.addAgent(id, undefined, undefined, undefined, undefined, true) + } + console.log(`[pixelOffice] added ${count} agent(s), ids 9001–${9000 + debugCounterRef.current}`) + }, + clearDebug() { + const office = officeRef.current + if (!office) return + for (let i = 1; i <= debugCounterRef.current; i++) { + office.removeAgent(9000 + i) + } + debugCounterRef.current = 0 + console.log('[pixelOffice] cleared debug agents') + }, + list() { + const office = officeRef.current + if (!office) return + const rows: any[] = [] + for (const [id, ch] of office.characters) rows.push({ id, state: ch.state, tile: `${ch.tileCol},${ch.tileRow}` }) + console.table(rows) + }, + } + return () => { delete (window as any).__pixelOffice } + }, []) // mount once only + useEffect(() => { if (!selectedAgentId) return try { diff --git a/lib/pixel-office/engine/characters.ts b/lib/pixel-office/engine/characters.ts index 2d284a2..c8ea8d7 100644 --- a/lib/pixel-office/engine/characters.ts +++ b/lib/pixel-office/engine/characters.ts @@ -103,6 +103,8 @@ export function createCharacter( codeSnippets: [], photoComments: [], isViewingPhoto: false, + yieldTimer: 0, + yieldDestination: null, } } @@ -366,8 +368,8 @@ export function updateCharacter( ch.moveProgress = 0 } - // If became active while wandering, repath to seat (skip if under greeting control) - if (ch.isActive && ch.seatId && !ch.greetLocked) { + // If became active while wandering, repath to seat (skip if under greeting control or yielding) + if (ch.isActive && ch.seatId && !ch.greetLocked && !ch.yieldDestination) { const seat = seats.get(ch.seatId) if (seat) { const lastStep = ch.path[ch.path.length - 1] diff --git a/lib/pixel-office/engine/officeState.ts b/lib/pixel-office/engine/officeState.ts index 6e646be..70699f4 100644 --- a/lib/pixel-office/engine/officeState.ts +++ b/lib/pixel-office/engine/officeState.ts @@ -1913,9 +1913,165 @@ export class OfficeState { } } + /** + * Collision avoidance: when two walking characters contest the same next tile, + * the one farther from its destination steps aside (sideways first, then backwards) + * and waits for the closer one to pass before resuming. + */ + /** Find a free adjacent tile for a character to dodge to, or null if none found */ + private findDodgeTile( + ch: Character, + dc: number, dr: number, + walkableSet: Set, + occupiedKeys: Set, + claimedNext: Map, + ): { col: number; row: number } | null { + const candidates: Array<{ col: number; row: number }> = dc !== 0 || dr !== 0 + ? [ + { col: ch.tileCol + dr, row: ch.tileRow + dc }, // side A (perpendicular) + { col: ch.tileCol - dr, row: ch.tileRow - dc }, // side B (perpendicular) + { col: ch.tileCol - dc, row: ch.tileRow - dr }, // backwards + ] + : [ + // No direction info — try all 4 neighbours + { col: ch.tileCol + 1, row: ch.tileRow }, + { col: ch.tileCol - 1, row: ch.tileRow }, + { col: ch.tileCol, row: ch.tileRow + 1 }, + { col: ch.tileCol, row: ch.tileRow - 1 }, + ] + + for (const cand of candidates) { + const key = `${cand.col},${cand.row}` + if (!walkableSet.has(key)) continue + if (this.blockedTiles.has(key)) continue + if (occupiedKeys.has(key)) continue + if (claimedNext.has(key)) continue + return cand + } + return null + } + + private resolveWalkConflicts(): void { + const allHumanoids: Character[] = [] + for (const ch of this.characters.values()) { + if (ch.matrixEffect || ch.isCat || ch.isLobster || ch.greetLocked) continue + if (ch.yieldTimer > 0) continue + allHumanoids.push(ch) + } + if (allHumanoids.length < 2) return + + // Current tile positions of every character (for dodge-target exclusion) + const occupiedKeys = new Set() + for (const ch of this.characters.values()) { + occupiedKeys.add(`${ch.tileCol},${ch.tileRow}`) + } + + const walkableSet = new Set(this.walkableTiles.map(t => `${t.col},${t.row}`)) + + // claimedNext: tiles that are "taken" — no other character may step into them. + // Pre-populate with standing characters' current tiles only. + // Walking characters claim their next tile dynamically in Pass 1 (sorted by priority). + const claimedNext = new Map() + for (const ch of allHumanoids) { + if (ch.state !== CharacterState.WALK) { + claimedNext.set(`${ch.tileCol},${ch.tileRow}`, ch.id) + } + } + + // ── Pass 1: next-tile conflicts ─────────────────────────────────────────── + // All walkers (including mid-step), sorted by remaining path length. + // Shorter path = closer to destination = higher priority = claims the tile. + // Loser is snapped back to current tile and must dodge. + const walkers = allHumanoids.filter( + ch => ch.state === CharacterState.WALK && !ch.yieldDestination && ch.path.length > 0, + ) + walkers.sort((a, b) => a.path.length - b.path.length) + + for (const ch of walkers) { + const next = ch.path[0] + const key = `${next.col},${next.row}` + if (!claimedNext.has(key)) { + // Also claim current tile so no one walks into us from behind + claimedNext.set(`${ch.tileCol},${ch.tileRow}`, ch.id) + claimedNext.set(key, ch.id) + continue + } + // Tile is taken — snap back to current tile and dodge + if (ch.moveProgress > 0) { + // Abort mid-step: snap back to the tile we came from + ch.x = ch.tileCol * TILE_SIZE + TILE_SIZE / 2 + ch.y = ch.tileRow * TILE_SIZE + TILE_SIZE / 2 + ch.moveProgress = 0 + } + const dest = ch.path[ch.path.length - 1] + const dc = next.col - ch.tileCol + const dr = next.row - ch.tileRow + const dodgeTile = this.findDodgeTile(ch, dc, dr, walkableSet, occupiedKeys, claimedNext) + if (dodgeTile) { + ch.path = [dodgeTile] + ch.moveProgress = 0 + ch.yieldDestination = { col: dest.col, row: dest.row } + claimedNext.set(`${ch.tileCol},${ch.tileRow}`, ch.id) + claimedNext.set(`${dodgeTile.col},${dodgeTile.row}`, ch.id) + } else { + ch.path = [] + ch.yieldTimer = 0.5 + Math.random() * 0.4 + ch.yieldDestination = { col: dest.col, row: dest.row } + claimedNext.set(`${ch.tileCol},${ch.tileRow}`, ch.id) + } + } + + // ── Pass 2: hard overlap — same tileCol/tileRow right now ──────────────── + const tileGroups = new Map() + for (const ch of allHumanoids) { + const key = `${ch.tileCol},${ch.tileRow}` + const g = tileGroups.get(key) + if (g) g.push(ch) + else tileGroups.set(key, [ch]) + } + + for (const group of tileGroups.values()) { + if (group.length < 2) continue + + // Highest priority (index 0) stays; others dodge + group.sort((a, b) => { + const aScore = a.state === CharacterState.WALK ? a.path.length : 9999 + const bScore = b.state === CharacterState.WALK ? b.path.length : 9999 + return aScore - bScore + }) + + for (let i = 1; i < group.length; i++) { + const dodger = group[i] + if (dodger.yieldTimer > 0 || dodger.yieldDestination) continue + + const dc = dodger.dir === Direction.RIGHT ? 1 : dodger.dir === Direction.LEFT ? -1 : 0 + const dr = dodger.dir === Direction.DOWN ? 1 : dodger.dir === Direction.UP ? -1 : 0 + + const dodgeTile = this.findDodgeTile(dodger, dc, dr, walkableSet, occupiedKeys, claimedNext) + if (dodgeTile) { + if (dodger.state === CharacterState.WALK && dodger.path.length > 0) { + const orig = dodger.path[dodger.path.length - 1] + dodger.yieldDestination = { col: orig.col, row: orig.row } + } + dodger.path = [dodgeTile] + dodger.moveProgress = 0 + dodger.state = CharacterState.WALK + dodger.frame = 0 + dodger.frameTimer = 0 + claimedNext.set(`${dodgeTile.col},${dodgeTile.row}`, dodger.id) + occupiedKeys.delete(`${dodger.tileCol},${dodger.tileRow}`) + occupiedKeys.add(`${dodgeTile.col},${dodgeTile.row}`) + } else { + dodger.yieldTimer = 0.3 + Math.random() * 0.3 + } + } + } + } + update(dt: number): void { this.ensureGatewaySre() this.bugSystem.update(dt, this.bugWorldWidth, this.bugWorldHeight) + this.resolveWalkConflicts() const toDelete: number[] = [] const firstIdleHumanoid = this.getFirstIdleHumanoid() for (const ch of this.characters.values()) { @@ -1941,6 +2097,24 @@ export class OfficeState { continue } + // Yield timer: character is waiting at dodge tile before resuming + if (ch.yieldTimer > 0) { + ch.yieldTimer = Math.max(0, ch.yieldTimer - dt) + if (ch.yieldTimer === 0 && ch.yieldDestination) { + const dest = ch.yieldDestination + ch.yieldDestination = null + const resumePath = findPath(ch.tileCol, ch.tileRow, dest.col, dest.row, this.tileMap, this.blockedTiles) + if (resumePath.length > 0) { + ch.path = resumePath + ch.moveProgress = 0 + ch.state = CharacterState.WALK + ch.frame = 0 + ch.frameTimer = 0 + } + } + continue // frozen while waiting + } + if (ch.systemRoleType === 'gateway_sre' && !ch.greetLocked) { this.updateGatewaySreCharacter(ch, dt) } else { @@ -1951,6 +2125,14 @@ export class OfficeState { ) } + // If character just finished walking to dodge tile, start wait timer + if (ch.yieldDestination && ch.state !== CharacterState.WALK && ch.path.length === 0) { + ch.yieldTimer = 0.6 + Math.random() * 0.5 + ch.state = CharacterState.IDLE + ch.frame = 0 + ch.frameTimer = 0 + } + if (ch.isLobster) { if (ch.lobsterRageTimer > 0) { ch.lobsterRageTimer = Math.max(0, ch.lobsterRageTimer - dt) diff --git a/lib/pixel-office/types.ts b/lib/pixel-office/types.ts index bd0e8b9..e59582b 100644 --- a/lib/pixel-office/types.ts +++ b/lib/pixel-office/types.ts @@ -214,4 +214,8 @@ export interface Character { systemStatus?: 'unknown' | 'healthy' | 'degraded' | 'down' /** Walk back to this tile after farewell greeting, then despawn */ pendingDespawn?: { col: number; row: number } | true + /** Seconds remaining while character waits at dodge tile before resuming path */ + yieldTimer: number + /** Original destination to repath to after yield completes */ + yieldDestination: { col: number; row: number } | null }