361 lines
9.7 KiB
TypeScript
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)
|
|
}
|
|
}
|