Files
2026-07-01 17:56:31 +08:00

361 lines
9.7 KiB
TypeScript

import Phaser from 'phaser'
import {
TILE_SIZE,
CHAR_SCALE_X, CHAR_SCALE_Y,
CHAR_SHADOW_ALPHA, CHAR_SHADOW_HEIGHT, CHAR_SHADOW_WIDTH, CHAR_SHADOW_Y,
WALK_SPEED_URGENT, WALK_SPEED_NORMAL, WALK_SPEED_RELAXED,
CELEBRATE_DURATION, STATUS_BUBBLE_DURATION,
WANDER_PAUSE_MIN, WANDER_PAUSE_MAX,
WANDER_MOVES_BEFORE_REST_MIN, WANDER_MOVES_BEFORE_REST_MAX,
} from '../config'
import { AgentState, Direction } from '../types'
import type { PathfindingManager } from '../systems/PathfindingManager'
function randomRange(min: number, max: number) {
return min + Math.random() * (max - min)
}
function randomInt(min: number, max: number) {
return Math.floor(randomRange(min, max + 1))
}
export class Agent extends Phaser.GameObjects.Container {
declare body: Phaser.Physics.Arcade.Body
agentId: string
displayName: string
officeId = 'office-0'
agentState: AgentState = AgentState.IDLE
dir: Direction = Direction.DOWN
palette: number
isActive = false
currentTool: string | null = null
seatId: string | null = null
urgency: 'urgent' | 'normal' | 'relaxed' = 'relaxed'
isSubagent = false
parentAgentId: string | null = null
taskSummary?: string
lastEventAt = 0
stateTimer = 0
seatTimer = 0
wanderTimer: number
wanderCount = 0
wanderLimit: number
hueShift = 0
bubbleText: string | null = null
bubbleTimer = 0
myceliumEffect: string | null = null
myceliumEffectTimer = 0
myceliumSession: string | null = null
private sprite: Phaser.GameObjects.Sprite
private shadow: Phaser.GameObjects.Ellipse
private bubbleObj: Phaser.GameObjects.Container | null = null
private currentPath: { x: number; y: number }[] = []
private pathIndex = 0
private pathfinder: PathfindingManager | null = null
private arrivalCallback: (() => void) | null = null
constructor(
scene: Phaser.Scene,
agentId: string,
displayName: string,
palette: number,
tileX: number,
tileY: number,
) {
const px = tileX * TILE_SIZE + TILE_SIZE / 2
const py = tileY * TILE_SIZE + TILE_SIZE / 2
super(scene, px, py)
this.agentId = agentId
this.displayName = displayName
this.palette = palette % 6
this.wanderTimer = randomRange(0.5, 2.5)
this.wanderLimit = randomInt(WANDER_MOVES_BEFORE_REST_MIN, WANDER_MOVES_BEFORE_REST_MAX)
const spriteKey = `char_${this.palette}`
this.shadow = scene.add.ellipse(
0,
CHAR_SHADOW_Y,
CHAR_SHADOW_WIDTH,
CHAR_SHADOW_HEIGHT,
0x151820,
CHAR_SHADOW_ALPHA,
)
this.shadow.setOrigin(0.5, 0.5)
this.add(this.shadow)
this.sprite = scene.add.sprite(0, 0, spriteKey)
this.sprite.setScale(CHAR_SCALE_X, CHAR_SCALE_Y)
this.sprite.setOrigin(0.5, 1)
this.add(this.sprite)
scene.add.existing(this)
scene.physics.world.enable(this)
const bodyW = Math.min(12, TILE_SIZE - 4)
const bodyH = Math.min(6, TILE_SIZE / 2)
this.body.setSize(bodyW, bodyH)
this.body.setOffset(-bodyW / 2, -bodyH)
this.body.setCollideWorldBounds(true)
this.setDepth(py)
this.playAnimForState()
}
setPathfinder(pf: PathfindingManager) {
this.pathfinder = pf
}
getState(): AgentState { return this.agentState }
getTilePos(): { x: number; y: number } {
return {
x: Math.floor(this.x / TILE_SIZE),
y: Math.floor(this.y / TILE_SIZE),
}
}
// ── State management ──────────────────────────────────────
setAgentState(newState: AgentState) {
if (this.agentState === newState) return
this.agentState = newState
this.playAnimForState()
}
setDirection(dir: Direction) {
if (this.dir === dir) return
this.dir = dir
this.playAnimForState()
}
private playAnimForState() {
if (!this.sprite?.anims) return
const key = `char_${this.palette}`
const dir = this.dir
const isLeft = dir === Direction.LEFT
this.sprite.setFlipX(isLeft)
const animDir = isLeft ? 'right' : dir
switch (this.agentState) {
case AgentState.WALK:
this.sprite.play(`${key}_walk_${animDir}`, true)
break
case AgentState.CELEBRATE:
this.sprite.play(`${key}_celebrate_${animDir}`, true)
break
case AgentState.TYPE:
case AgentState.PRESENT:
case AgentState.PRACTICE:
this.sprite.play(`${key}_type_${animDir}`, true)
break
case AgentState.THINK:
case AgentState.REFLECT:
this.sprite.play(`${key}_read_${animDir}`, true)
break
case AgentState.COFFEE:
case AgentState.CHAT:
this.sprite.play(`${key}_coffee_${animDir}`, true)
break
case AgentState.SLEEP:
case AgentState.IDLE:
default:
this.sprite.play(`${key}_idle_${animDir}`, true)
break
}
}
// ── Movement ──────────────────────────────────────────────
async walkTo(tileX: number, tileY: number, onArrival?: () => void): Promise<boolean> {
if (!this.pathfinder || !this.sprite?.anims) return false
const from = this.getTilePos()
const path = await this.pathfinder.findPath(from, { x: tileX, y: tileY })
if (path.length === 0) return false
this.currentPath = path
this.pathIndex = 0
this.arrivalCallback = onArrival ?? null
this.setAgentState(AgentState.WALK)
return true
}
stopMovement() {
this.currentPath = []
this.pathIndex = 0
this.arrivalCallback = null
this.body?.setVelocity(0, 0)
}
get isMoving(): boolean {
return this.agentState === AgentState.WALK && this.currentPath.length > 0
}
private getWalkSpeed(): number {
switch (this.urgency) {
case 'urgent': return WALK_SPEED_URGENT
case 'normal': return WALK_SPEED_NORMAL
default: return WALK_SPEED_RELAXED
}
}
// ── Bubble ────────────────────────────────────────────────
showBubble(text: string, duration = STATUS_BUBBLE_DURATION) {
this.bubbleText = text
this.bubbleTimer = duration
this.updateBubbleDisplay()
}
clearBubble() {
this.bubbleText = null
this.bubbleTimer = 0
if (this.bubbleObj) {
this.bubbleObj.destroy()
this.bubbleObj = null
}
}
private updateBubbleDisplay() {
if (this.bubbleObj) {
this.bubbleObj.destroy()
this.bubbleObj = null
}
if (!this.bubbleText) return
const container = this.scene.add.container(this.x, this.y - 70)
const textObj = this.scene.add.text(0, 0, this.bubbleText, {
fontSize: '10px',
fontFamily: 'monospace',
color: '#1a1a2e',
backgroundColor: '#ffffff',
padding: { x: 4, y: 2 },
resolution: 2,
})
textObj.setOrigin(0.5, 1)
const bg = this.scene.add.graphics()
const w = textObj.width + 8
const h = textObj.height + 4
bg.fillStyle(0xffffff, 0.95)
bg.fillRoundedRect(-w / 2, -h, w, h, 4)
bg.lineStyle(1, 0x666666, 0.5)
bg.strokeRoundedRect(-w / 2, -h, w, h, 4)
container.add([bg, textObj])
container.setDepth(100000)
this.bubbleObj = container
}
// ── Per-frame update ──────────────────────────────────────
update(dt: number) {
// Depth sorting
this.setDepth(this.y)
// Bubble position tracking + timer
if (this.bubbleObj) {
this.bubbleObj.setPosition(this.x, this.y - 70)
if (this.bubbleTimer > 0) {
this.bubbleTimer -= dt
if (this.bubbleTimer <= 0) {
this.clearBubble()
}
}
}
// State timer
if (this.stateTimer > 0) {
this.stateTimer -= dt
if (this.stateTimer <= 0) {
this.stateTimer = 0
if (this.agentState === AgentState.CELEBRATE) {
this.setAgentState(AgentState.IDLE)
}
if (this.agentState === AgentState.COFFEE) {
this.setAgentState(AgentState.IDLE)
this.wanderTimer = randomRange(WANDER_PAUSE_MIN, WANDER_PAUSE_MAX)
}
if (this.agentState === AgentState.CHAT) {
this.setAgentState(AgentState.IDLE)
}
}
}
// Path following
if (this.agentState === AgentState.WALK && this.currentPath.length > 0) {
this.followPath(dt)
}
}
private followPath(dt: number) {
this.body.setVelocity(0, 0)
if (this.pathIndex >= this.currentPath.length) {
this.arriveAtDestination()
return
}
const target = this.currentPath[this.pathIndex]
const targetPx = target.x * TILE_SIZE + TILE_SIZE / 2
const targetPy = target.y * TILE_SIZE + TILE_SIZE / 2
const dx = targetPx - this.x
const dy = targetPy - this.y
const dist = Math.sqrt(dx * dx + dy * dy)
const speed = this.getWalkSpeed()
const step = speed * dt
if (dist <= step + 0.5) {
this.x = targetPx
this.y = targetPy
this.pathIndex++
if (this.pathIndex >= this.currentPath.length) {
this.arriveAtDestination()
}
return
}
this.x += (dx / dist) * step
this.y += (dy / dist) * step
if (Math.abs(dx) > Math.abs(dy)) {
this.setDirection(dx > 0 ? Direction.RIGHT : Direction.LEFT)
} else {
this.setDirection(dy > 0 ? Direction.DOWN : Direction.UP)
}
}
private arriveAtDestination() {
this.currentPath = []
this.pathIndex = 0
this.body.setVelocity(0, 0)
const cb = this.arrivalCallback
this.arrivalCallback = null
if (cb) {
cb()
} else if (this.agentState === AgentState.WALK) {
this.setAgentState(AgentState.IDLE)
}
}
destroy(fromScene?: boolean) {
this.clearBubble()
super.destroy(fromScene)
}
}