調整行動路線策略,減少碰撞機會(還是會碰)

This commit is contained in:
Dan Lee
2026-03-20 10:52:36 +08:00
parent 45ca402984
commit 039bbf8a7f
4 changed files with 224 additions and 2 deletions
+34
View File
@@ -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 {
+4 -2
View File
@@ -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]
+182
View File
@@ -1950,9 +1950,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<string>,
occupiedKeys: Set<string>,
claimedNext: Map<string, number>,
): { 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<string>()
for (const ch of this.characters.values()) {
occupiedKeys.add(`${ch.tileCol},${ch.tileRow}`)
}
const walkableSet = new Set<string>(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<string, number>()
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<string, Character[]>()
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()) {
@@ -1978,6 +2134,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 {
@@ -1988,6 +2162,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)
+4
View File
@@ -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
}