From 916d16c2353c8fa23cdc8b53151b62fa4e8f0c31 Mon Sep 17 00:00:00 2001 From: gahusb Date: Sat, 11 Apr 2026 08:58:07 +0900 Subject: [PATCH] feat(agent-office): AgentSprite movement + OfficeRenderer game loop Co-Authored-By: Claude Opus 4.6 --- src/pages/agent-office/canvas/AgentSprite.js | 84 ++++++++++++ .../agent-office/canvas/OfficeRenderer.js | 129 ++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 src/pages/agent-office/canvas/AgentSprite.js create mode 100644 src/pages/agent-office/canvas/OfficeRenderer.js diff --git a/src/pages/agent-office/canvas/AgentSprite.js b/src/pages/agent-office/canvas/AgentSprite.js new file mode 100644 index 0000000..caad3c9 --- /dev/null +++ b/src/pages/agent-office/canvas/AgentSprite.js @@ -0,0 +1,84 @@ +import { drawAgent, getAnimSpeed } from './SpriteSheet'; + +export class AgentSprite { + constructor(agentId, waypoints) { + this.agentId = agentId; + this.waypoints = waypoints; + this.state = 'idle'; + this.detail = ''; + + const deskKey = `${agentId}_desk`; + const desk = waypoints[deskKey] || { x: 5, y: 3 }; + this.x = desk.x; + this.y = desk.y; + this.targetX = desk.x; + this.targetY = desk.y; + this.deskPos = { x: desk.x, y: desk.y }; + + this.frameIndex = 0; + this._lastFrameTime = 0; + this._moveSpeed = 0.05; + } + + setState(newState, detail = '') { + this.state = newState; + this.detail = detail; + this.frameIndex = 0; + } + + moveTo(target) { + const wp = this.waypoints[target]; + if (wp) { + this.targetX = wp.x; + this.targetY = wp.y; + } + } + + moveToDesk() { + this.targetX = this.deskPos.x; + this.targetY = this.deskPos.y; + } + + update(now) { + const speed = getAnimSpeed(this.state); + if (now - this._lastFrameTime > speed) { + this.frameIndex++; + this._lastFrameTime = now; + } + + const dx = this.targetX - this.x; + const dy = this.targetY - this.y; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist > 0.1) { + const step = Math.min(this._moveSpeed, dist); + this.x += (dx / dist) * step; + this.y += (dy / dist) * step; + } else { + this.x = this.targetX; + this.y = this.targetY; + } + } + + draw(ctx, renderInfo) { + const { scale, offsetX, offsetY, tileSize } = renderInfo; + const canvasX = offsetX + this.x * tileSize * scale + (tileSize * scale) / 2; + const canvasY = offsetY + this.y * tileSize * scale + (tileSize * scale) / 2; + + const isMoving = Math.abs(this.targetX - this.x) > 0.1 || Math.abs(this.targetY - this.y) > 0.1; + const drawState = isMoving ? 'walk' : this.state; + + drawAgent(ctx, this.agentId, canvasX, canvasY, drawState, this.frameIndex, scale * 1.5); + } + + hitTest(canvasX, canvasY, renderInfo) { + const { scale, offsetX, offsetY, tileSize } = renderInfo; + const cx = offsetX + this.x * tileSize * scale + (tileSize * scale) / 2; + const cy = offsetY + this.y * tileSize * scale + (tileSize * scale) / 2; + const hitW = 20 * scale; + const hitH = 30 * scale; + + return canvasX >= cx - hitW && canvasX <= cx + hitW && + canvasY >= cy - hitH && canvasY <= cy + hitH; + } +} diff --git a/src/pages/agent-office/canvas/OfficeRenderer.js b/src/pages/agent-office/canvas/OfficeRenderer.js new file mode 100644 index 0000000..266d0ef --- /dev/null +++ b/src/pages/agent-office/canvas/OfficeRenderer.js @@ -0,0 +1,129 @@ +import { drawTileMap } from './TileMap'; +import { AgentSprite } from './AgentSprite'; +import { getCharLabel } from './SpriteSheet'; + +const STATUS_ICONS = { + idle: null, + working: null, + waiting: '❗', + reporting: '📋', + break: '☕', +}; + +export class OfficeRenderer { + constructor(canvas, mapData) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.mapData = mapData; + this.renderInfo = null; + this.agents = {}; + this._animId = null; + this._onClick = null; + + const agentIds = ['stock', 'music', 'claude']; + for (const id of agentIds) { + this.agents[id] = new AgentSprite(id, mapData.waypoints); + } + } + + start() { + this._loop = this._loop.bind(this); + this._animId = requestAnimationFrame(this._loop); + } + + stop() { + if (this._animId) { + cancelAnimationFrame(this._animId); + this._animId = null; + } + } + + resize(width, height) { + this.canvas.width = width; + this.canvas.height = height; + } + + setOnClick(handler) { + this._onClick = handler; + } + + handleClick(canvasX, canvasY) { + if (!this.renderInfo) return null; + + for (const [id, sprite] of Object.entries(this.agents)) { + if (sprite.hitTest(canvasX, canvasY, this.renderInfo)) { + if (this._onClick) this._onClick(id); + return id; + } + } + return null; + } + + updateAgentState(agentId, state, detail) { + const sprite = this.agents[agentId]; + if (sprite) { + sprite.setState(state, detail); + if (state === 'idle' || state === 'working' || state === 'waiting') { + sprite.moveToDesk(); + } + } + } + + moveAgent(agentId, target) { + const sprite = this.agents[agentId]; + if (sprite) { + sprite.moveTo(target); + } + } + + _loop(timestamp) { + const { ctx, canvas, mapData } = this; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = '#1a1a2e'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + this.renderInfo = drawTileMap(ctx, mapData, canvas.width, canvas.height); + + const now = Date.now(); + for (const sprite of Object.values(this.agents)) { + sprite.update(now); + sprite.draw(ctx, this.renderInfo); + } + + for (const [id, sprite] of Object.entries(this.agents)) { + this._drawOverlay(ctx, sprite, id); + } + + this._animId = requestAnimationFrame(this._loop); + } + + _drawOverlay(ctx, sprite, agentId) { + if (!this.renderInfo) return; + const { scale, offsetX, offsetY, tileSize } = this.renderInfo; + const cx = offsetX + sprite.x * tileSize * scale + (tileSize * scale) / 2; + const cy = offsetY + sprite.y * tileSize * scale - 10 * scale; + + const icon = STATUS_ICONS[sprite.state]; + if (icon) { + ctx.font = `${14 * scale}px serif`; + ctx.textAlign = 'center'; + ctx.fillText(icon, cx, cy - 15 * scale); + } + + ctx.fillStyle = 'rgba(255,255,255,0.7)'; + ctx.font = `${8 * scale}px monospace`; + ctx.textAlign = 'center'; + ctx.fillText(getCharLabel(agentId), cx, cy + 30 * scale + 30); + + if (sprite.detail && (sprite.state === 'working' || sprite.state === 'waiting')) { + const bubbleY = cy - 25 * scale; + ctx.fillStyle = 'rgba(0,0,0,0.7)'; + const textW = ctx.measureText(sprite.detail).width; + ctx.fillRect(cx - textW / 2 - 6, bubbleY - 10, textW + 12, 16); + ctx.fillStyle = '#fff'; + ctx.font = `${7 * scale}px monospace`; + ctx.fillText(sprite.detail, cx, bubbleY); + } + } +}