1984 lines
82 KiB
JavaScript
1984 lines
82 KiB
JavaScript
/**
|
|
* GameScene - Main game rendering
|
|
* Optimized to prevent memory leaks and CPU accumulation
|
|
*/
|
|
|
|
import { api } from '../api.js';
|
|
import { PROFESSIONS, RESOURCES, ACTIONS, WORLD_ZONES, DISPLAY } from '../constants.js';
|
|
|
|
export default class GameScene extends Phaser.Scene {
|
|
constructor() {
|
|
super({ key: 'GameScene' });
|
|
|
|
this.agents = new Map();
|
|
this.selectedAgent = null;
|
|
this.worldWidth = 20;
|
|
this.worldHeight = 20;
|
|
this.tileSize = DISPLAY.TILE_SIZE;
|
|
this.isAutoMode = false;
|
|
this.isStepPending = false;
|
|
this.autoInterval = null;
|
|
this.autoSpeed = 150;
|
|
this.pollingInterval = null;
|
|
|
|
// Cache DOM elements to avoid repeated queries
|
|
this.domCache = {};
|
|
|
|
// Bound handlers for cleanup
|
|
this.boundHandlers = {};
|
|
|
|
// Statistics history for charts
|
|
this.statsHistory = {
|
|
turns: [],
|
|
population: [],
|
|
deaths: [],
|
|
money: [],
|
|
avgWealth: [],
|
|
giniCoefficient: [],
|
|
professions: {},
|
|
resourcePrices: {},
|
|
tradeVolume: [],
|
|
// Resource tracking (per turn)
|
|
resourcesProduced: {},
|
|
resourcesConsumed: {},
|
|
resourcesSpoiled: {},
|
|
// Resource tracking (cumulative)
|
|
resourcesProducedCumulative: {},
|
|
resourcesConsumedCumulative: {},
|
|
resourcesSpoiledCumulative: {},
|
|
resourcesTraded: {}, // from market trades
|
|
};
|
|
this.maxHistoryPoints = 200;
|
|
|
|
// Chart instances
|
|
this.charts = {};
|
|
|
|
// Current state cache for stats
|
|
this.currentState = null;
|
|
|
|
// Stats view state
|
|
this.statsViewActive = false;
|
|
}
|
|
|
|
create() {
|
|
const state = this.registry.get('simulationState');
|
|
if (state) {
|
|
this.worldWidth = state.world_size?.width || 20;
|
|
this.worldHeight = state.world_size?.height || 20;
|
|
}
|
|
|
|
const gameWidth = this.worldWidth * this.tileSize;
|
|
const gameHeight = this.worldHeight * this.tileSize;
|
|
|
|
this.createWorld(gameWidth, gameHeight);
|
|
this.agentContainer = this.add.container(0, 0);
|
|
|
|
this.cameras.main.setBounds(0, 0, gameWidth, gameHeight);
|
|
this.cameras.main.setBackgroundColor(0x151921);
|
|
this.fitWorldToView(gameWidth, gameHeight);
|
|
this.setupCameraControls();
|
|
|
|
// Cache DOM elements once
|
|
this.cacheDOMElements();
|
|
|
|
// Setup UI with proper cleanup tracking
|
|
this.setupUIControls();
|
|
|
|
if (state) {
|
|
this.updateFromState(state);
|
|
}
|
|
|
|
this.startStatePolling();
|
|
this.updateConnectionStatus(true);
|
|
|
|
// Clean up on scene shutdown
|
|
this.events.on('shutdown', this.cleanup, this);
|
|
this.events.on('destroy', this.cleanup, this);
|
|
}
|
|
|
|
cacheDOMElements() {
|
|
this.domCache = {
|
|
dayDisplay: document.getElementById('day-display'),
|
|
timeDisplay: document.getElementById('time-display'),
|
|
turnDisplay: document.getElementById('turn-display'),
|
|
statAlive: document.getElementById('stat-alive'),
|
|
statDead: document.getElementById('stat-dead'),
|
|
statMoney: document.getElementById('stat-money'),
|
|
professionList: document.getElementById('profession-list'),
|
|
marketPrices: document.getElementById('market-prices'),
|
|
agentDetails: document.getElementById('agent-details'),
|
|
activityLog: document.getElementById('activity-log'),
|
|
connectionStatus: document.getElementById('connection-status'),
|
|
btnStep: document.getElementById('btn-step'),
|
|
btnAuto: document.getElementById('btn-auto'),
|
|
btnInitialize: document.getElementById('btn-initialize'),
|
|
btnStats: document.getElementById('btn-stats'),
|
|
speedSlider: document.getElementById('speed-slider'),
|
|
speedDisplay: document.getElementById('speed-display'),
|
|
// Stats screen elements
|
|
statsScreen: document.getElementById('stats-screen'),
|
|
btnCloseStats: document.getElementById('btn-close-stats'),
|
|
tabButtons: document.querySelectorAll('.tab-btn'),
|
|
tabPanels: document.querySelectorAll('.tab-panel'),
|
|
// Stats summary elements
|
|
statsTurn: document.getElementById('stats-turn'),
|
|
statsLiving: document.getElementById('stats-living'),
|
|
statsDeaths: document.getElementById('stats-deaths'),
|
|
statsGold: document.getElementById('stats-gold'),
|
|
statsAvgWealth: document.getElementById('stats-avg-wealth'),
|
|
statsGini: document.getElementById('stats-gini'),
|
|
// GOAP elements
|
|
goapAgentList: document.getElementById('goap-agent-list'),
|
|
goapPlanView: document.getElementById('goap-plan-view'),
|
|
goapActionsList: document.getElementById('goap-actions-list'),
|
|
chartGoapGoals: document.getElementById('chart-goap-goals'),
|
|
// Stats screen controls (duplicated for stats page)
|
|
btnStepStats: document.getElementById('btn-step-stats'),
|
|
btnAutoStats: document.getElementById('btn-auto-stats'),
|
|
btnInitializeStats: document.getElementById('btn-initialize-stats'),
|
|
speedSliderStats: document.getElementById('speed-slider-stats'),
|
|
speedDisplayStats: document.getElementById('speed-display-stats'),
|
|
};
|
|
|
|
// GOAP state
|
|
this.goapData = null;
|
|
this.selectedGoapAgentId = null;
|
|
}
|
|
|
|
cleanup() {
|
|
// Clear intervals
|
|
if (this.autoInterval) {
|
|
clearInterval(this.autoInterval);
|
|
this.autoInterval = null;
|
|
}
|
|
if (this.pollingInterval) {
|
|
clearInterval(this.pollingInterval);
|
|
this.pollingInterval = null;
|
|
}
|
|
|
|
// Remove event listeners
|
|
const { btnStep, btnAuto, btnInitialize, btnStats, btnCloseStats, speedSlider } = this.domCache;
|
|
|
|
if (btnStep && this.boundHandlers.step) {
|
|
btnStep.removeEventListener('click', this.boundHandlers.step);
|
|
}
|
|
if (btnAuto && this.boundHandlers.auto) {
|
|
btnAuto.removeEventListener('click', this.boundHandlers.auto);
|
|
}
|
|
if (btnInitialize && this.boundHandlers.init) {
|
|
btnInitialize.removeEventListener('click', this.boundHandlers.init);
|
|
}
|
|
if (speedSlider && this.boundHandlers.speed) {
|
|
speedSlider.removeEventListener('input', this.boundHandlers.speed);
|
|
}
|
|
if (btnStats && this.boundHandlers.openStats) {
|
|
btnStats.removeEventListener('click', this.boundHandlers.openStats);
|
|
}
|
|
if (btnCloseStats && this.boundHandlers.closeStats) {
|
|
btnCloseStats.removeEventListener('click', this.boundHandlers.closeStats);
|
|
}
|
|
|
|
// Stats screen controls cleanup
|
|
const { btnStepStats, btnAutoStats, btnInitializeStats, speedSliderStats } = this.domCache;
|
|
if (btnStepStats && this.boundHandlers.step) {
|
|
btnStepStats.removeEventListener('click', this.boundHandlers.step);
|
|
}
|
|
if (btnAutoStats && this.boundHandlers.auto) {
|
|
btnAutoStats.removeEventListener('click', this.boundHandlers.auto);
|
|
}
|
|
if (btnInitializeStats && this.boundHandlers.init) {
|
|
btnInitializeStats.removeEventListener('click', this.boundHandlers.init);
|
|
}
|
|
if (speedSliderStats && this.boundHandlers.speedStats) {
|
|
speedSliderStats.removeEventListener('input', this.boundHandlers.speedStats);
|
|
}
|
|
|
|
// Destroy charts
|
|
Object.values(this.charts).forEach(chart => chart?.destroy());
|
|
this.charts = {};
|
|
|
|
// Clear agents
|
|
this.agents.forEach(sprite => sprite.destroy());
|
|
this.agents.clear();
|
|
}
|
|
|
|
createWorld(gameWidth, gameHeight) {
|
|
const zones = Object.values(WORLD_ZONES);
|
|
|
|
zones.forEach(zone => {
|
|
const x = gameWidth * zone.start;
|
|
const width = gameWidth * (zone.end - zone.start);
|
|
|
|
this.add.rectangle(
|
|
x + width / 2, gameHeight / 2,
|
|
width, gameHeight,
|
|
zone.color, 0.3
|
|
);
|
|
});
|
|
|
|
zones.forEach(zone => {
|
|
const x = gameWidth * ((zone.start + zone.end) / 2);
|
|
this.add.text(x, 10, zone.name, {
|
|
fontSize: '12px',
|
|
fontFamily: 'JetBrains Mono, monospace',
|
|
color: '#6b6560',
|
|
}).setOrigin(0.5, 0);
|
|
});
|
|
|
|
const gridGraphics = this.add.graphics();
|
|
gridGraphics.lineStyle(1, 0x3a4359, 0.15);
|
|
|
|
for (let x = 0; x <= gameWidth; x += this.tileSize) {
|
|
gridGraphics.lineBetween(x, 0, x, gameHeight);
|
|
}
|
|
for (let y = 0; y <= gameHeight; y += this.tileSize) {
|
|
gridGraphics.lineBetween(0, y, gameWidth, y);
|
|
}
|
|
|
|
this.addZoneDecorations(gameWidth, gameHeight);
|
|
}
|
|
|
|
addZoneDecorations(gameWidth, gameHeight) {
|
|
const decoGraphics = this.add.graphics();
|
|
|
|
const riverEnd = gameWidth * WORLD_ZONES.river.end;
|
|
decoGraphics.lineStyle(2, 0x5a8cc8, 0.3);
|
|
for (let y = 20; y < gameHeight; y += 30) {
|
|
const waveY = y + Math.sin(y * 0.1) * 5;
|
|
decoGraphics.lineBetween(5, waveY, riverEnd - 5, waveY + 5);
|
|
}
|
|
|
|
const bushStart = gameWidth * WORLD_ZONES.bushes.start;
|
|
const bushEnd = gameWidth * WORLD_ZONES.bushes.end;
|
|
decoGraphics.fillStyle(0x6bab5e, 0.2);
|
|
for (let i = 0; i < 15; i++) {
|
|
const x = bushStart + Math.random() * (bushEnd - bushStart);
|
|
const y = 30 + Math.random() * (gameHeight - 60);
|
|
decoGraphics.fillCircle(x, y, 8 + Math.random() * 8);
|
|
}
|
|
|
|
const forestStart = gameWidth * WORLD_ZONES.forest.start;
|
|
decoGraphics.fillStyle(0x2d5016, 0.25);
|
|
for (let i = 0; i < 20; i++) {
|
|
const x = forestStart + 20 + Math.random() * (gameWidth - forestStart - 40);
|
|
const y = 30 + Math.random() * (gameHeight - 60);
|
|
decoGraphics.fillRect(x - 2, y, 4, 12);
|
|
decoGraphics.fillTriangle(x, y - 15, x - 10, y + 5, x + 10, y + 5);
|
|
}
|
|
|
|
const villageStart = gameWidth * WORLD_ZONES.village.start;
|
|
const villageEnd = gameWidth * WORLD_ZONES.village.end;
|
|
decoGraphics.fillStyle(0x8b7355, 0.15);
|
|
decoGraphics.lineStyle(1, 0x8b7355, 0.3);
|
|
for (let i = 0; i < 5; i++) {
|
|
const x = villageStart + 30 + (i * ((villageEnd - villageStart - 60) / 4));
|
|
const y = gameHeight / 2 + (Math.random() - 0.5) * 100;
|
|
decoGraphics.fillRect(x - 10, y - 5, 20, 15);
|
|
decoGraphics.strokeRect(x - 10, y - 5, 20, 15);
|
|
decoGraphics.fillTriangle(x, y - 15, x - 12, y - 5, x + 12, y - 5);
|
|
}
|
|
}
|
|
|
|
fitWorldToView(gameWidth, gameHeight) {
|
|
const cam = this.cameras.main;
|
|
const padding = 40;
|
|
const scaleX = (cam.width - padding * 2) / gameWidth;
|
|
const scaleY = (cam.height - padding * 2) / gameHeight;
|
|
const scale = Math.min(scaleX, scaleY, DISPLAY.MAX_ZOOM);
|
|
cam.setZoom(Math.max(scale, DISPLAY.MIN_ZOOM));
|
|
cam.centerOn(gameWidth / 2, gameHeight / 2);
|
|
}
|
|
|
|
setupCameraControls() {
|
|
const cam = this.cameras.main;
|
|
|
|
this.input.on('wheel', (pointer, gameObjects, deltaX, deltaY) => {
|
|
const zoom = cam.zoom - deltaY * 0.001;
|
|
cam.setZoom(Phaser.Math.Clamp(zoom, DISPLAY.MIN_ZOOM, DISPLAY.MAX_ZOOM));
|
|
});
|
|
|
|
this.input.on('pointermove', (pointer) => {
|
|
if (pointer.isDown && pointer.button === 1) {
|
|
cam.scrollX -= (pointer.x - pointer.prevPosition.x) / cam.zoom;
|
|
cam.scrollY -= (pointer.y - pointer.prevPosition.y) / cam.zoom;
|
|
}
|
|
});
|
|
}
|
|
|
|
setupUIControls() {
|
|
const { btnStep, btnAuto, btnInitialize, btnStats, btnCloseStats, speedSlider, speedDisplay, tabButtons } = this.domCache;
|
|
const { btnStepStats, btnAutoStats, btnInitializeStats, speedSliderStats, speedDisplayStats } = this.domCache;
|
|
|
|
// Create bound handlers for later cleanup
|
|
this.boundHandlers.step = () => this.handleStep();
|
|
this.boundHandlers.auto = () => this.toggleAutoMode();
|
|
this.boundHandlers.init = () => this.handleInitialize();
|
|
|
|
// Speed handler that syncs both sliders
|
|
this.boundHandlers.speed = (e) => {
|
|
this.autoSpeed = parseInt(e.target.value);
|
|
if (speedDisplay) speedDisplay.textContent = `${this.autoSpeed}ms`;
|
|
if (speedDisplayStats) speedDisplayStats.textContent = `${this.autoSpeed}ms`;
|
|
if (speedSliderStats) speedSliderStats.value = this.autoSpeed;
|
|
if (this.isAutoMode) this.restartAutoMode();
|
|
};
|
|
|
|
this.boundHandlers.speedStats = (e) => {
|
|
this.autoSpeed = parseInt(e.target.value);
|
|
if (speedDisplay) speedDisplay.textContent = `${this.autoSpeed}ms`;
|
|
if (speedDisplayStats) speedDisplayStats.textContent = `${this.autoSpeed}ms`;
|
|
if (speedSlider) speedSlider.value = this.autoSpeed;
|
|
if (this.isAutoMode) this.restartAutoMode();
|
|
};
|
|
|
|
this.boundHandlers.openStats = () => this.showStatsScreen();
|
|
this.boundHandlers.closeStats = () => this.hideStatsScreen();
|
|
|
|
// Main controls
|
|
if (btnStep) btnStep.addEventListener('click', this.boundHandlers.step);
|
|
if (btnAuto) btnAuto.addEventListener('click', this.boundHandlers.auto);
|
|
if (btnInitialize) btnInitialize.addEventListener('click', this.boundHandlers.init);
|
|
if (speedSlider) speedSlider.addEventListener('input', this.boundHandlers.speed);
|
|
if (btnStats) btnStats.addEventListener('click', this.boundHandlers.openStats);
|
|
if (btnCloseStats) btnCloseStats.addEventListener('click', this.boundHandlers.closeStats);
|
|
|
|
// Stats screen controls (same handlers)
|
|
if (btnStepStats) btnStepStats.addEventListener('click', this.boundHandlers.step);
|
|
if (btnAutoStats) btnAutoStats.addEventListener('click', this.boundHandlers.auto);
|
|
if (btnInitializeStats) btnInitializeStats.addEventListener('click', this.boundHandlers.init);
|
|
if (speedSliderStats) speedSliderStats.addEventListener('input', this.boundHandlers.speedStats);
|
|
|
|
// Tab switching
|
|
tabButtons?.forEach(btn => {
|
|
btn.addEventListener('click', (e) => this.switchTab(e.target.dataset.tab));
|
|
});
|
|
}
|
|
|
|
async handleStep() {
|
|
if (this.isAutoMode || this.isStepPending) return;
|
|
|
|
this.isStepPending = true;
|
|
|
|
try {
|
|
await api.nextStep();
|
|
const state = await api.getState();
|
|
this.updateFromState(state);
|
|
} catch (error) {
|
|
console.error('Step failed:', error);
|
|
this.updateConnectionStatus(false);
|
|
} finally {
|
|
this.isStepPending = false;
|
|
}
|
|
}
|
|
|
|
async handleInitialize() {
|
|
try {
|
|
if (this.isAutoMode) this.toggleAutoMode();
|
|
|
|
await api.initialize();
|
|
const state = await api.getState();
|
|
|
|
// Clear all agents
|
|
this.agents.forEach(sprite => sprite.destroy());
|
|
this.agents.clear();
|
|
this.selectedAgent = null;
|
|
|
|
// Clear activity log
|
|
const logEl = this.domCache.activityLog;
|
|
if (logEl) logEl.innerHTML = '';
|
|
|
|
// Reset statistics history
|
|
this.statsHistory = {
|
|
turns: [],
|
|
population: [],
|
|
deaths: [],
|
|
money: [],
|
|
avgWealth: [],
|
|
giniCoefficient: [],
|
|
professions: {},
|
|
resourcePrices: {},
|
|
tradeVolume: [],
|
|
resourcesProduced: {},
|
|
resourcesConsumed: {},
|
|
resourcesSpoiled: {},
|
|
resourcesProducedCumulative: {},
|
|
resourcesConsumedCumulative: {},
|
|
resourcesSpoiledCumulative: {},
|
|
resourcesTraded: {},
|
|
};
|
|
this.currentState = null;
|
|
|
|
// Destroy existing charts
|
|
Object.values(this.charts).forEach(chart => chart?.destroy());
|
|
this.charts = {};
|
|
|
|
this.updateFromState(state);
|
|
this.updateAgentDetails(null);
|
|
} catch (error) {
|
|
console.error('Initialize failed:', error);
|
|
this.updateConnectionStatus(false);
|
|
}
|
|
}
|
|
|
|
toggleAutoMode() {
|
|
this.isAutoMode = !this.isAutoMode;
|
|
const { btnAuto, btnStep, btnAutoStats, btnStepStats } = this.domCache;
|
|
|
|
if (this.isAutoMode) {
|
|
btnAuto?.classList.add('active');
|
|
btnAutoStats?.classList.add('active');
|
|
btnStep?.setAttribute('disabled', 'true');
|
|
btnStepStats?.setAttribute('disabled', 'true');
|
|
this.startAutoMode();
|
|
} else {
|
|
btnAuto?.classList.remove('active');
|
|
btnAutoStats?.classList.remove('active');
|
|
btnStep?.removeAttribute('disabled');
|
|
btnStepStats?.removeAttribute('disabled');
|
|
this.stopAutoMode();
|
|
}
|
|
}
|
|
|
|
startAutoMode() {
|
|
this.stopAutoMode();
|
|
|
|
this.autoInterval = setInterval(async () => {
|
|
if (this.isStepPending) return;
|
|
this.isStepPending = true;
|
|
|
|
try {
|
|
await api.nextStep();
|
|
const state = await api.getState();
|
|
this.updateFromState(state);
|
|
} catch (error) {
|
|
console.error('Auto step failed:', error);
|
|
this.toggleAutoMode();
|
|
} finally {
|
|
this.isStepPending = false;
|
|
}
|
|
}, this.autoSpeed);
|
|
}
|
|
|
|
stopAutoMode() {
|
|
if (this.autoInterval) {
|
|
clearInterval(this.autoInterval);
|
|
this.autoInterval = null;
|
|
}
|
|
}
|
|
|
|
restartAutoMode() {
|
|
if (this.isAutoMode) this.startAutoMode();
|
|
}
|
|
|
|
startStatePolling() {
|
|
if (this.pollingInterval) {
|
|
clearInterval(this.pollingInterval);
|
|
}
|
|
|
|
this.pollingInterval = setInterval(async () => {
|
|
if (!this.isAutoMode && !this.isStepPending) {
|
|
try {
|
|
await api.getStatus();
|
|
this.updateConnectionStatus(true);
|
|
} catch {
|
|
this.updateConnectionStatus(false);
|
|
}
|
|
}
|
|
}, 5000);
|
|
}
|
|
|
|
updateFromState(state) {
|
|
if (!state) return;
|
|
|
|
this.currentState = state;
|
|
this.registry.set('simulationState', state);
|
|
this.updateHeaderDisplay(state);
|
|
this.updateAgents(state.agents || []);
|
|
this.updateLeftPanel(state);
|
|
this.updateRightPanel(state);
|
|
this.updateActivityLog(state.recent_logs || []);
|
|
this.recordStatsHistory(state);
|
|
|
|
// Update selected agent if still exists
|
|
if (this.selectedAgent) {
|
|
const agentData = (state.agents || []).find(a => a.id === this.selectedAgent);
|
|
if (agentData) {
|
|
this.updateAgentDetails(agentData);
|
|
}
|
|
}
|
|
|
|
// Update stats screen if visible
|
|
if (this.statsViewActive) {
|
|
this.updateStatsScreen();
|
|
}
|
|
}
|
|
|
|
updateHeaderDisplay(state) {
|
|
const { dayDisplay, timeDisplay, turnDisplay } = this.domCache;
|
|
|
|
if (dayDisplay) dayDisplay.textContent = `Day ${state.day || 1}`;
|
|
if (timeDisplay) {
|
|
timeDisplay.textContent = state.time_of_day === 'night' ? '🌙 Night' : '☀️ Day';
|
|
}
|
|
if (turnDisplay) turnDisplay.textContent = `Turn ${state.turn || 0}`;
|
|
}
|
|
|
|
updateAgents(agentsData) {
|
|
const seenIds = new Set();
|
|
|
|
agentsData.forEach(agentData => {
|
|
seenIds.add(agentData.id);
|
|
|
|
let agentSprite = this.agents.get(agentData.id);
|
|
|
|
if (!agentSprite) {
|
|
agentSprite = this.createAgentSprite(agentData);
|
|
this.agents.set(agentData.id, agentSprite);
|
|
}
|
|
|
|
this.updateAgentSprite(agentSprite, agentData);
|
|
});
|
|
|
|
// Remove agents that no longer exist
|
|
this.agents.forEach((sprite, id) => {
|
|
if (!seenIds.has(id)) {
|
|
sprite.destroy();
|
|
this.agents.delete(id);
|
|
}
|
|
});
|
|
}
|
|
|
|
createAgentSprite(agentData) {
|
|
const container = this.add.container(0, 0);
|
|
const profData = PROFESSIONS[agentData.profession] || PROFESSIONS.villager;
|
|
|
|
const body = this.add.circle(0, 0, DISPLAY.AGENT_SIZE / 2, profData.color);
|
|
body.setStrokeStyle(2, 0xe8e4dc);
|
|
container.add(body);
|
|
|
|
const icon = this.add.text(0, 0, profData.icon, { fontSize: '14px' }).setOrigin(0.5);
|
|
container.add(icon);
|
|
|
|
const nameLabel = this.add.text(0, -DISPLAY.AGENT_SIZE / 2 - 8, agentData.name, {
|
|
fontSize: '10px',
|
|
fontFamily: 'JetBrains Mono, monospace',
|
|
color: '#e8e4dc',
|
|
backgroundColor: '#151921',
|
|
padding: { x: 3, y: 1 },
|
|
}).setOrigin(0.5);
|
|
container.add(nameLabel);
|
|
|
|
const healthBg = this.add.rectangle(0, DISPLAY.AGENT_SIZE / 2 + 6, 24, 4, 0x151921);
|
|
container.add(healthBg);
|
|
|
|
const healthBar = this.add.rectangle(-12, DISPLAY.AGENT_SIZE / 2 + 6, 24, 4, 0x4a9c6d);
|
|
healthBar.setOrigin(0, 0.5);
|
|
container.add(healthBar);
|
|
|
|
container.setData('body', body);
|
|
container.setData('icon', icon);
|
|
container.setData('nameLabel', nameLabel);
|
|
container.setData('healthBar', healthBar);
|
|
container.setData('agentId', agentData.id);
|
|
|
|
body.setInteractive({ useHandCursor: true });
|
|
body.on('pointerdown', () => this.selectAgent(agentData.id));
|
|
body.on('pointerover', () => {
|
|
body.setStrokeStyle(3, 0xd4a84b);
|
|
container.setScale(1.1);
|
|
});
|
|
body.on('pointerout', () => {
|
|
const isSelected = this.selectedAgent === agentData.id;
|
|
body.setStrokeStyle(isSelected ? 3 : 2, isSelected ? 0xd4a84b : 0xe8e4dc);
|
|
container.setScale(1);
|
|
});
|
|
|
|
this.agentContainer.add(container);
|
|
return container;
|
|
}
|
|
|
|
updateAgentSprite(sprite, agentData) {
|
|
// Direct position update - NO TWEENS to prevent memory accumulation
|
|
const targetX = agentData.position.x * this.tileSize;
|
|
const targetY = agentData.position.y * this.tileSize;
|
|
|
|
sprite.x = targetX;
|
|
sprite.y = targetY;
|
|
|
|
const body = sprite.getData('body');
|
|
const icon = sprite.getData('icon');
|
|
const healthBar = sprite.getData('healthBar');
|
|
|
|
if (!agentData.is_alive) {
|
|
body.setFillStyle(0x4a4a4a);
|
|
body.setStrokeStyle(2, 0x6b6560);
|
|
icon.setText('💀');
|
|
healthBar.setFillStyle(0x4a4a4a);
|
|
healthBar.setScale(0, 1);
|
|
} else {
|
|
const profData = PROFESSIONS[agentData.profession] || PROFESSIONS.villager;
|
|
body.setFillStyle(profData.color);
|
|
icon.setText(profData.icon);
|
|
|
|
const stats = agentData.stats;
|
|
const minStat = Math.min(
|
|
stats.hunger / stats.max_hunger,
|
|
stats.thirst / stats.max_thirst,
|
|
stats.heat / stats.max_heat
|
|
);
|
|
|
|
healthBar.setScale(minStat, 1);
|
|
|
|
if (minStat < 0.25) {
|
|
healthBar.setFillStyle(0xc45c5c);
|
|
} else if (minStat < 0.5) {
|
|
healthBar.setFillStyle(0xd4a84b);
|
|
} else {
|
|
healthBar.setFillStyle(0x4a9c6d);
|
|
}
|
|
}
|
|
|
|
sprite.setData('agentData', agentData);
|
|
}
|
|
|
|
selectAgent(agentId) {
|
|
if (this.selectedAgent) {
|
|
const prevSprite = this.agents.get(this.selectedAgent);
|
|
if (prevSprite) {
|
|
const prevBody = prevSprite.getData('body');
|
|
prevBody.setStrokeStyle(2, 0xe8e4dc);
|
|
}
|
|
}
|
|
|
|
this.selectedAgent = agentId;
|
|
const sprite = this.agents.get(agentId);
|
|
|
|
if (sprite) {
|
|
const body = sprite.getData('body');
|
|
body.setStrokeStyle(3, 0xd4a84b);
|
|
this.updateAgentDetails(sprite.getData('agentData'));
|
|
}
|
|
}
|
|
|
|
updateLeftPanel(state) {
|
|
const { statAlive, statDead, statMoney, professionList } = this.domCache;
|
|
const stats = state.statistics || {};
|
|
|
|
if (statAlive) statAlive.textContent = stats.living_agents || 0;
|
|
if (statDead) statDead.textContent = stats.total_agents_died || 0;
|
|
if (statMoney) statMoney.textContent = `${stats.total_money_in_circulation || 0}g`;
|
|
|
|
if (professionList) {
|
|
const professions = stats.professions || {};
|
|
professionList.innerHTML = Object.entries(professions)
|
|
.filter(([_, count]) => count > 0)
|
|
.map(([prof, count]) => {
|
|
const profData = PROFESSIONS[prof] || PROFESSIONS.villager;
|
|
return `<div class="profession-item">
|
|
<span class="profession-name">
|
|
<span class="profession-icon">${profData.icon}</span>
|
|
${profData.name}
|
|
</span>
|
|
<span class="profession-count">${count}</span>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
}
|
|
|
|
updateRightPanel(state) {
|
|
const { marketPrices } = this.domCache;
|
|
|
|
if (marketPrices && state.market?.prices) {
|
|
const prices = state.market.prices;
|
|
marketPrices.innerHTML = Object.entries(prices)
|
|
.map(([resource, priceData]) => {
|
|
const resData = RESOURCES[resource] || { icon: '📦', name: resource };
|
|
const price = priceData.lowest_price !== null ? `${priceData.lowest_price}g` : '-';
|
|
const available = priceData.total_available || 0;
|
|
return `<div class="market-price-item">
|
|
<span class="market-resource">${resData.icon} ${resData.name}</span>
|
|
<span><span class="market-price">${price}</span>
|
|
<span class="market-available">(${available})</span></span>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
}
|
|
|
|
updateAgentDetails(agentData) {
|
|
const { agentDetails } = this.domCache;
|
|
if (!agentDetails) return;
|
|
|
|
if (!agentData) {
|
|
agentDetails.innerHTML = '<p class="no-selection">Click an agent to view details</p>';
|
|
return;
|
|
}
|
|
|
|
const profData = PROFESSIONS[agentData.profession] || PROFESSIONS.villager;
|
|
const stats = agentData.stats;
|
|
const action = agentData.current_action;
|
|
const actionData = ACTIONS[action.action_type] || ACTIONS.idle;
|
|
|
|
const renderBar = (label, current, max, type) => {
|
|
const pct = Math.round((current / max) * 100);
|
|
return `<div class="agent-stat-bar">
|
|
<div class="stat-header"><span>${label}</span><span>${current}/${max}</span></div>
|
|
<div class="stat-bar"><div class="stat-bar-fill ${type}" style="width: ${pct}%"></div></div>
|
|
</div>`;
|
|
};
|
|
|
|
// Render skills if available
|
|
const renderSkills = () => {
|
|
if (!agentData.skills) return '';
|
|
const skills = agentData.skills;
|
|
const skillNames = ['hunting', 'gathering', 'woodcutting', 'crafting', 'trading'];
|
|
const skillEmojis = { hunting: '🏹', gathering: '🌿', woodcutting: '🪓', crafting: '🧵', trading: '💰' };
|
|
return `<div class="agent-skills">
|
|
${skillNames.map(s => {
|
|
const val = skills[s] || 1.0;
|
|
if (val <= 1.0) return ''; // Skip base skills
|
|
return `<span class="skill-badge">
|
|
<span class="skill-name">${skillEmojis[s]}</span>
|
|
<span class="skill-value">${val.toFixed(2)}</span>
|
|
</span>`;
|
|
}).filter(Boolean).join('') || '<span style="color: var(--text-muted); font-size: 0.7rem;">No skills yet</span>'}
|
|
</div>`;
|
|
};
|
|
|
|
// Render personal action log
|
|
const renderActionLog = () => {
|
|
const history = agentData.action_history || [];
|
|
if (history.length === 0) {
|
|
return '<div style="color: var(--text-muted); font-size: 0.7rem; padding: 4px;">No actions yet</div>';
|
|
}
|
|
// Show most recent first
|
|
return history.slice().reverse().slice(0, 12).map(entry => {
|
|
const actionIcon = ACTIONS[entry.action]?.icon || '❓';
|
|
return `<div class="agent-log-entry ${entry.success ? 'success' : 'failure'}">
|
|
<span class="agent-log-turn">T${entry.turn}</span>
|
|
<span class="agent-log-action">${actionIcon}</span>
|
|
<span class="agent-log-result">${entry.result}</span>
|
|
</div>`;
|
|
}).join('');
|
|
};
|
|
|
|
agentDetails.innerHTML = `<div class="agent-card">
|
|
<div class="agent-header">
|
|
<div class="agent-avatar">${profData.icon}</div>
|
|
<div class="agent-info">
|
|
<h4>${agentData.name}</h4>
|
|
<span class="profession" style="color: #${profData.color.toString(16)}">${profData.name}</span>
|
|
</div>
|
|
</div>
|
|
<div class="agent-money">
|
|
<span class="money-icon">💰</span>
|
|
<span class="money-value">${agentData.money}g</span>
|
|
<span class="money-label">Gold</span>
|
|
</div>
|
|
<div class="agent-stats">
|
|
${renderBar('Energy', stats.energy, stats.max_energy, 'energy')}
|
|
${renderBar('Hunger', stats.hunger, stats.max_hunger, 'hunger')}
|
|
${renderBar('Thirst', stats.thirst, stats.max_thirst, 'thirst')}
|
|
${renderBar('Heat', stats.heat, stats.max_heat, 'heat')}
|
|
</div>
|
|
<div class="agent-inventory">
|
|
<h5>Inventory</h5>
|
|
<div class="inventory-grid">
|
|
${agentData.inventory.length === 0 ? '<span style="color: var(--text-muted); font-size: 0.75rem;">Empty</span>' :
|
|
agentData.inventory.map(item => {
|
|
const resData = RESOURCES[item.type] || { icon: '📦' };
|
|
return `<span class="inventory-item">${resData.icon} ${item.quantity}</span>`;
|
|
}).join('')}
|
|
</div>
|
|
</div>
|
|
<h5 class="subsection-title" style="margin-top: var(--space-sm); border-top: none; padding-top: 0;">Skills</h5>
|
|
${renderSkills()}
|
|
<div class="agent-action">
|
|
<span class="action-label">Current Action</span>
|
|
<div>${actionData.icon} ${action.message || actionData.verb}</div>
|
|
</div>
|
|
<div class="agent-goap-info" id="agent-goap-section" data-agent-id="${agentData.id}">
|
|
<h5 class="subsection-title" style="display: flex; align-items: center; gap: 6px;">
|
|
🧠 GOAP Plan
|
|
<button class="btn-mini" onclick="window.villsimGame.scene.scenes[1].loadAgentGOAP('${agentData.id}')" style="font-size: 0.6rem; padding: 2px 6px;">↻</button>
|
|
</h5>
|
|
<div id="agent-goap-content" style="font-size: 0.75rem; color: var(--text-muted);">
|
|
Click ↻ to load GOAP info
|
|
</div>
|
|
</div>
|
|
<h5 class="subsection-title">Personal Log</h5>
|
|
<div class="agent-log">
|
|
${renderActionLog()}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
updateActivityLog(logs) {
|
|
const logEl = this.domCache.activityLog;
|
|
if (!logEl || !logs.length) return;
|
|
|
|
const recentLog = logs[logs.length - 1];
|
|
if (!recentLog) return;
|
|
|
|
// Build new entries
|
|
const fragment = document.createDocumentFragment();
|
|
|
|
// Add deaths first
|
|
(recentLog.deaths || []).slice(0, 3).forEach(agentId => {
|
|
const div = document.createElement('div');
|
|
div.className = 'log-entry action-death';
|
|
div.innerHTML = `<span class="log-agent">${agentId}</span> <span class="log-action">died</span>`;
|
|
fragment.appendChild(div);
|
|
});
|
|
|
|
// Add recent actions (limit to 5)
|
|
(recentLog.agent_actions || []).slice(-5).forEach(action => {
|
|
const actionType = action.decision?.action || 'idle';
|
|
const actionData = ACTIONS[actionType] || ACTIONS.idle;
|
|
const div = document.createElement('div');
|
|
div.className = `log-entry action-${actionType}`;
|
|
div.innerHTML = `<span class="log-agent">${action.agent_name}</span> <span class="log-action">${actionData.icon} ${actionData.verb}</span>`;
|
|
fragment.appendChild(div);
|
|
});
|
|
|
|
// Insert at the beginning
|
|
if (logEl.firstChild) {
|
|
logEl.insertBefore(fragment, logEl.firstChild);
|
|
} else {
|
|
logEl.appendChild(fragment);
|
|
}
|
|
|
|
// Remove excess entries - keep max 12
|
|
while (logEl.children.length > 12) {
|
|
logEl.removeChild(logEl.lastChild);
|
|
}
|
|
}
|
|
|
|
updateConnectionStatus(connected) {
|
|
const statusEl = this.domCache.connectionStatus;
|
|
if (!statusEl) return;
|
|
|
|
const dot = statusEl.querySelector('.status-dot');
|
|
const text = statusEl.querySelector('.status-text');
|
|
|
|
if (connected) {
|
|
dot?.classList.remove('disconnected');
|
|
dot?.classList.add('connected');
|
|
if (text) text.textContent = 'Connected';
|
|
} else {
|
|
dot?.classList.remove('connected');
|
|
dot?.classList.add('disconnected');
|
|
if (text) text.textContent = 'Disconnected';
|
|
}
|
|
}
|
|
|
|
// ============== Stats History & Charts ==============
|
|
|
|
recordStatsHistory(state) {
|
|
const stats = state.statistics || {};
|
|
const turn = state.turn || 0;
|
|
|
|
// Avoid duplicate entries
|
|
if (this.statsHistory.turns.includes(turn)) return;
|
|
|
|
this.statsHistory.turns.push(turn);
|
|
this.statsHistory.population.push(stats.living_agents || 0);
|
|
this.statsHistory.deaths.push(stats.total_agents_died || 0);
|
|
this.statsHistory.money.push(stats.total_money_in_circulation || 0);
|
|
this.statsHistory.avgWealth.push(stats.avg_money || 0);
|
|
this.statsHistory.giniCoefficient.push(stats.gini_coefficient || 0);
|
|
this.statsHistory.tradeVolume.push(state.market?.orders?.length || 0);
|
|
|
|
// Track professions
|
|
const professions = stats.professions || {};
|
|
for (const [prof, count] of Object.entries(professions)) {
|
|
if (!this.statsHistory.professions[prof]) {
|
|
this.statsHistory.professions[prof] = [];
|
|
}
|
|
// Pad with zeros if needed
|
|
while (this.statsHistory.professions[prof].length < this.statsHistory.turns.length - 1) {
|
|
this.statsHistory.professions[prof].push(0);
|
|
}
|
|
this.statsHistory.professions[prof].push(count);
|
|
}
|
|
|
|
// Track resource prices
|
|
if (state.market?.prices) {
|
|
for (const [resource, priceData] of Object.entries(state.market.prices)) {
|
|
if (!this.statsHistory.resourcePrices[resource]) {
|
|
this.statsHistory.resourcePrices[resource] = [];
|
|
}
|
|
while (this.statsHistory.resourcePrices[resource].length < this.statsHistory.turns.length - 1) {
|
|
this.statsHistory.resourcePrices[resource].push(null);
|
|
}
|
|
this.statsHistory.resourcePrices[resource].push(priceData.lowest_price);
|
|
}
|
|
}
|
|
|
|
// Track resource production/consumption/spoilage from recent logs and cumulative stats
|
|
const recentLog = state.recent_logs?.[state.recent_logs?.length - 1];
|
|
const resourceStats = state.resource_stats || {};
|
|
const resourceTypes = ['meat', 'berries', 'water', 'wood', 'hide', 'clothes'];
|
|
|
|
for (const resType of resourceTypes) {
|
|
// Per-turn produced
|
|
if (!this.statsHistory.resourcesProduced[resType]) {
|
|
this.statsHistory.resourcesProduced[resType] = [];
|
|
}
|
|
while (this.statsHistory.resourcesProduced[resType].length < this.statsHistory.turns.length - 1) {
|
|
this.statsHistory.resourcesProduced[resType].push(0);
|
|
}
|
|
this.statsHistory.resourcesProduced[resType].push(recentLog?.resources_produced?.[resType] || 0);
|
|
|
|
// Per-turn consumed
|
|
if (!this.statsHistory.resourcesConsumed[resType]) {
|
|
this.statsHistory.resourcesConsumed[resType] = [];
|
|
}
|
|
while (this.statsHistory.resourcesConsumed[resType].length < this.statsHistory.turns.length - 1) {
|
|
this.statsHistory.resourcesConsumed[resType].push(0);
|
|
}
|
|
this.statsHistory.resourcesConsumed[resType].push(recentLog?.resources_consumed?.[resType] || 0);
|
|
|
|
// Per-turn spoiled
|
|
if (!this.statsHistory.resourcesSpoiled[resType]) {
|
|
this.statsHistory.resourcesSpoiled[resType] = [];
|
|
}
|
|
while (this.statsHistory.resourcesSpoiled[resType].length < this.statsHistory.turns.length - 1) {
|
|
this.statsHistory.resourcesSpoiled[resType].push(0);
|
|
}
|
|
this.statsHistory.resourcesSpoiled[resType].push(recentLog?.resources_spoiled?.[resType] || 0);
|
|
|
|
// Cumulative produced (from backend totals)
|
|
if (!this.statsHistory.resourcesProducedCumulative[resType]) {
|
|
this.statsHistory.resourcesProducedCumulative[resType] = [];
|
|
}
|
|
while (this.statsHistory.resourcesProducedCumulative[resType].length < this.statsHistory.turns.length - 1) {
|
|
this.statsHistory.resourcesProducedCumulative[resType].push(0);
|
|
}
|
|
this.statsHistory.resourcesProducedCumulative[resType].push(resourceStats.produced?.[resType] || 0);
|
|
|
|
// Cumulative consumed
|
|
if (!this.statsHistory.resourcesConsumedCumulative[resType]) {
|
|
this.statsHistory.resourcesConsumedCumulative[resType] = [];
|
|
}
|
|
while (this.statsHistory.resourcesConsumedCumulative[resType].length < this.statsHistory.turns.length - 1) {
|
|
this.statsHistory.resourcesConsumedCumulative[resType].push(0);
|
|
}
|
|
this.statsHistory.resourcesConsumedCumulative[resType].push(resourceStats.consumed?.[resType] || 0);
|
|
|
|
// Cumulative spoiled
|
|
if (!this.statsHistory.resourcesSpoiledCumulative[resType]) {
|
|
this.statsHistory.resourcesSpoiledCumulative[resType] = [];
|
|
}
|
|
while (this.statsHistory.resourcesSpoiledCumulative[resType].length < this.statsHistory.turns.length - 1) {
|
|
this.statsHistory.resourcesSpoiledCumulative[resType].push(0);
|
|
}
|
|
this.statsHistory.resourcesSpoiledCumulative[resType].push(resourceStats.spoiled?.[resType] || 0);
|
|
|
|
// Cumulative traded (from backend)
|
|
if (!this.statsHistory.resourcesTraded[resType]) {
|
|
this.statsHistory.resourcesTraded[resType] = [];
|
|
}
|
|
while (this.statsHistory.resourcesTraded[resType].length < this.statsHistory.turns.length - 1) {
|
|
this.statsHistory.resourcesTraded[resType].push(0);
|
|
}
|
|
this.statsHistory.resourcesTraded[resType].push(resourceStats.traded?.[resType] || 0);
|
|
}
|
|
|
|
// Trim history if too long
|
|
if (this.statsHistory.turns.length > this.maxHistoryPoints) {
|
|
this.statsHistory.turns.shift();
|
|
this.statsHistory.population.shift();
|
|
this.statsHistory.deaths.shift();
|
|
this.statsHistory.money.shift();
|
|
this.statsHistory.avgWealth.shift();
|
|
this.statsHistory.giniCoefficient.shift();
|
|
this.statsHistory.tradeVolume.shift();
|
|
for (const prof in this.statsHistory.professions) {
|
|
this.statsHistory.professions[prof].shift();
|
|
}
|
|
for (const res in this.statsHistory.resourcePrices) {
|
|
this.statsHistory.resourcePrices[res].shift();
|
|
}
|
|
for (const res in this.statsHistory.resourcesProduced) {
|
|
this.statsHistory.resourcesProduced[res].shift();
|
|
}
|
|
for (const res in this.statsHistory.resourcesConsumed) {
|
|
this.statsHistory.resourcesConsumed[res].shift();
|
|
}
|
|
for (const res in this.statsHistory.resourcesSpoiled) {
|
|
this.statsHistory.resourcesSpoiled[res].shift();
|
|
}
|
|
for (const res in this.statsHistory.resourcesProducedCumulative) {
|
|
this.statsHistory.resourcesProducedCumulative[res].shift();
|
|
}
|
|
for (const res in this.statsHistory.resourcesConsumedCumulative) {
|
|
this.statsHistory.resourcesConsumedCumulative[res].shift();
|
|
}
|
|
for (const res in this.statsHistory.resourcesSpoiledCumulative) {
|
|
this.statsHistory.resourcesSpoiledCumulative[res].shift();
|
|
}
|
|
for (const res in this.statsHistory.resourcesTraded) {
|
|
this.statsHistory.resourcesTraded[res].shift();
|
|
}
|
|
}
|
|
}
|
|
|
|
showStatsScreen() {
|
|
const { statsScreen } = this.domCache;
|
|
if (statsScreen) {
|
|
statsScreen.classList.remove('hidden');
|
|
this.statsViewActive = true;
|
|
this.updateStatsScreen();
|
|
}
|
|
}
|
|
|
|
hideStatsScreen() {
|
|
const { statsScreen } = this.domCache;
|
|
if (statsScreen) {
|
|
statsScreen.classList.add('hidden');
|
|
this.statsViewActive = false;
|
|
}
|
|
}
|
|
|
|
switchTab(tabName) {
|
|
const { tabButtons, tabPanels } = this.domCache;
|
|
|
|
tabButtons?.forEach(btn => {
|
|
btn.classList.toggle('active', btn.dataset.tab === tabName);
|
|
});
|
|
|
|
tabPanels?.forEach(panel => {
|
|
panel.classList.toggle('active', panel.id === `tab-${tabName}`);
|
|
});
|
|
|
|
// Update charts for the active tab
|
|
this.updateActiveTabCharts(tabName);
|
|
}
|
|
|
|
updateStatsScreen() {
|
|
this.updateStatsSummary();
|
|
// Find active tab
|
|
const activeTab = document.querySelector('.tab-btn.active');
|
|
if (activeTab) {
|
|
this.updateActiveTabCharts(activeTab.dataset.tab);
|
|
}
|
|
}
|
|
|
|
updateStatsSummary() {
|
|
const { statsTurn, statsLiving, statsDeaths, statsGold, statsAvgWealth, statsGini } = this.domCache;
|
|
const state = this.currentState;
|
|
if (!state) return;
|
|
|
|
const stats = state.statistics || {};
|
|
if (statsTurn) statsTurn.textContent = state.turn || 0;
|
|
if (statsLiving) statsLiving.textContent = stats.living_agents || 0;
|
|
if (statsDeaths) statsDeaths.textContent = stats.total_agents_died || 0;
|
|
if (statsGold) statsGold.textContent = `${stats.total_money_in_circulation || 0}g`;
|
|
if (statsAvgWealth) statsAvgWealth.textContent = `${Math.round(stats.avg_money || 0)}g`;
|
|
if (statsGini) statsGini.textContent = (stats.gini_coefficient || 0).toFixed(2);
|
|
}
|
|
|
|
updateActiveTabCharts(tabName) {
|
|
switch (tabName) {
|
|
case 'prices': this.renderPricesChart(); break;
|
|
case 'wealth': this.renderWealthCharts(); break;
|
|
case 'population': this.renderPopulationChart(); break;
|
|
case 'professions': this.renderProfessionCharts(); break;
|
|
case 'resources': this.renderResourceCharts(); break;
|
|
case 'market': this.renderMarketCharts(); break;
|
|
case 'agents': this.renderAgentStatsCharts(); break;
|
|
case 'goap': this.fetchAndRenderGOAP(); break;
|
|
}
|
|
}
|
|
|
|
renderPricesChart() {
|
|
const canvas = document.getElementById('chart-prices');
|
|
if (!canvas) return;
|
|
|
|
if (this.charts.prices) this.charts.prices.destroy();
|
|
|
|
const resColors = {
|
|
meat: '#c45c5c', berries: '#a855a8', water: '#5a8cc8',
|
|
wood: '#a67c52', hide: '#8b7355', clothes: '#6b6560',
|
|
};
|
|
|
|
const datasets = [];
|
|
for (const [resource, data] of Object.entries(this.statsHistory.resourcePrices)) {
|
|
if (data.some(v => v !== null)) {
|
|
datasets.push({
|
|
label: resource.charAt(0).toUpperCase() + resource.slice(1),
|
|
data: data,
|
|
borderColor: resColors[resource] || '#888',
|
|
backgroundColor: 'transparent',
|
|
tension: 0.3,
|
|
spanGaps: true,
|
|
borderWidth: 2,
|
|
});
|
|
}
|
|
}
|
|
|
|
this.charts.prices = new Chart(canvas.getContext('2d'), {
|
|
type: 'line',
|
|
data: { labels: this.statsHistory.turns, datasets },
|
|
options: this.getChartOptions('Market Prices Over Time'),
|
|
});
|
|
}
|
|
|
|
renderWealthCharts() {
|
|
// Wealth distribution bar chart (per agent)
|
|
const distCanvas = document.getElementById('chart-wealth-dist');
|
|
if (distCanvas && this.currentState) {
|
|
if (this.charts.wealthDist) this.charts.wealthDist.destroy();
|
|
|
|
const agents = (this.currentState.agents || [])
|
|
.filter(a => a.is_alive)
|
|
.sort((a, b) => b.money - a.money);
|
|
|
|
const labels = agents.map(a => a.name.substring(0, 8));
|
|
const data = agents.map(a => a.money);
|
|
const colors = agents.map((_, i) => {
|
|
const ratio = i / Math.max(1, agents.length - 1);
|
|
return `hsl(${180 - ratio * 180}, 70%, 55%)`;
|
|
});
|
|
|
|
this.charts.wealthDist = new Chart(distCanvas.getContext('2d'), {
|
|
type: 'bar',
|
|
data: {
|
|
labels,
|
|
datasets: [{ label: 'Gold', data, backgroundColor: colors, borderWidth: 0 }],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
animation: false,
|
|
indexAxis: 'y',
|
|
plugins: {
|
|
legend: { display: false },
|
|
title: {
|
|
display: true,
|
|
text: 'Wealth by Agent',
|
|
color: '#e8e4dc',
|
|
font: { family: "'Crimson Pro', serif", size: 16, weight: '600' },
|
|
},
|
|
tooltip: {
|
|
callbacks: {
|
|
label: (ctx) => `${ctx.raw}g`,
|
|
},
|
|
},
|
|
},
|
|
scales: {
|
|
x: { beginAtZero: true, ticks: { color: '#6b6560' }, grid: { color: 'rgba(58, 67, 89, 0.3)' } },
|
|
y: { ticks: { color: '#6b6560', font: { size: 9 } }, grid: { display: false } },
|
|
},
|
|
interaction: { intersect: true, mode: 'nearest' },
|
|
},
|
|
});
|
|
}
|
|
|
|
// Wealth by profession chart
|
|
const profCanvas = document.getElementById('chart-wealth-prof');
|
|
if (profCanvas && this.currentState) {
|
|
if (this.charts.wealthProf) this.charts.wealthProf.destroy();
|
|
|
|
const agents = (this.currentState.agents || []).filter(a => a.is_alive);
|
|
const profWealth = {};
|
|
const profCount = {};
|
|
|
|
agents.forEach(a => {
|
|
const prof = a.profession || 'villager';
|
|
profWealth[prof] = (profWealth[prof] || 0) + a.money;
|
|
profCount[prof] = (profCount[prof] || 0) + 1;
|
|
});
|
|
|
|
const profColors = {
|
|
hunter: '#c45c5c', gatherer: '#6bab5e', woodcutter: '#a67c52',
|
|
trader: '#d4a84b', crafter: '#8b6fc0', villager: '#7a8899',
|
|
};
|
|
|
|
const labels = Object.keys(profWealth);
|
|
const totalData = labels.map(p => profWealth[p]);
|
|
const avgData = labels.map(p => Math.round(profWealth[p] / profCount[p]));
|
|
const colors = labels.map(p => profColors[p] || '#888');
|
|
|
|
this.charts.wealthProf = new Chart(profCanvas.getContext('2d'), {
|
|
type: 'bar',
|
|
data: {
|
|
labels: labels.map(l => l.charAt(0).toUpperCase() + l.slice(1)),
|
|
datasets: [
|
|
{ label: 'Total Gold', data: totalData, backgroundColor: colors, borderWidth: 0 },
|
|
{ label: 'Avg per Agent', data: avgData, backgroundColor: colors.map(c => c + '80'), borderWidth: 0 },
|
|
],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
animation: false,
|
|
plugins: {
|
|
legend: { position: 'bottom', labels: { color: '#a8a095', boxWidth: 12 } },
|
|
title: {
|
|
display: true,
|
|
text: 'Wealth by Profession',
|
|
color: '#e8e4dc',
|
|
font: { family: "'Crimson Pro', serif", size: 16, weight: '600' },
|
|
},
|
|
},
|
|
scales: {
|
|
x: { ticks: { color: '#6b6560' }, grid: { color: 'rgba(58, 67, 89, 0.3)' } },
|
|
y: { beginAtZero: true, ticks: { color: '#6b6560' }, grid: { color: 'rgba(58, 67, 89, 0.3)' } },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
// Wealth over time chart
|
|
const timeCanvas = document.getElementById('chart-wealth-time');
|
|
if (timeCanvas) {
|
|
if (this.charts.wealthTime) this.charts.wealthTime.destroy();
|
|
|
|
this.charts.wealthTime = new Chart(timeCanvas.getContext('2d'), {
|
|
type: 'line',
|
|
data: {
|
|
labels: this.statsHistory.turns,
|
|
datasets: [
|
|
{
|
|
label: 'Total Gold',
|
|
data: this.statsHistory.money,
|
|
borderColor: '#00d4ff',
|
|
backgroundColor: 'rgba(0, 212, 255, 0.1)',
|
|
fill: true,
|
|
yAxisID: 'y',
|
|
},
|
|
{
|
|
label: 'Avg Wealth',
|
|
data: this.statsHistory.avgWealth,
|
|
borderColor: '#39ff14',
|
|
borderDash: [5, 5],
|
|
yAxisID: 'y1',
|
|
},
|
|
{
|
|
label: 'Gini Index',
|
|
data: this.statsHistory.giniCoefficient,
|
|
borderColor: '#ff0099',
|
|
yAxisID: 'y2',
|
|
},
|
|
],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
animation: false,
|
|
plugins: {
|
|
legend: { position: 'bottom', labels: { color: '#a8a095', boxWidth: 12, padding: 10 } },
|
|
title: {
|
|
display: true,
|
|
text: 'Wealth Over Time',
|
|
color: '#e8e4dc',
|
|
font: { family: "'Crimson Pro', serif", size: 16, weight: '600' },
|
|
},
|
|
},
|
|
scales: {
|
|
x: { ticks: { color: '#6b6560' }, grid: { color: 'rgba(58, 67, 89, 0.3)' } },
|
|
y: { type: 'linear', position: 'left', beginAtZero: true, ticks: { color: '#00d4ff' }, grid: { color: 'rgba(58, 67, 89, 0.3)' } },
|
|
y1: { type: 'linear', position: 'right', beginAtZero: true, grid: { drawOnChartArea: false }, ticks: { color: '#39ff14' } },
|
|
y2: { type: 'linear', position: 'right', min: 0, max: 1, grid: { drawOnChartArea: false }, ticks: { color: '#ff0099' } },
|
|
},
|
|
interaction: { intersect: false, mode: 'index' },
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
renderPopulationChart() {
|
|
const canvas = document.getElementById('chart-population');
|
|
if (!canvas) return;
|
|
|
|
if (this.charts.population) this.charts.population.destroy();
|
|
|
|
this.charts.population = new Chart(canvas.getContext('2d'), {
|
|
type: 'line',
|
|
data: {
|
|
labels: this.statsHistory.turns,
|
|
datasets: [
|
|
{
|
|
label: 'Living',
|
|
data: this.statsHistory.population,
|
|
borderColor: '#00d4ff',
|
|
backgroundColor: 'rgba(0, 212, 255, 0.15)',
|
|
fill: true,
|
|
tension: 0.3,
|
|
},
|
|
{
|
|
label: 'Deaths (Cumulative)',
|
|
data: this.statsHistory.deaths,
|
|
borderColor: '#ff0099',
|
|
borderDash: [5, 5],
|
|
tension: 0.3,
|
|
},
|
|
],
|
|
},
|
|
options: this.getChartOptions('Population Over Time'),
|
|
});
|
|
}
|
|
|
|
renderProfessionCharts() {
|
|
// Pie chart
|
|
const pieCanvas = document.getElementById('chart-prof-pie');
|
|
if (pieCanvas && this.currentState) {
|
|
if (this.charts.profPie) this.charts.profPie.destroy();
|
|
|
|
const professions = this.currentState.statistics?.professions || {};
|
|
const labels = Object.keys(professions);
|
|
const data = Object.values(professions);
|
|
const colors = ['#00d4ff', '#ff0099', '#39ff14', '#ff6600', '#9d4edd', '#ffcc00'];
|
|
|
|
this.charts.profPie = new Chart(pieCanvas.getContext('2d'), {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels,
|
|
datasets: [{ data, backgroundColor: colors.slice(0, labels.length), borderWidth: 0 }],
|
|
},
|
|
options: {
|
|
...this.getChartOptions('Current Distribution'),
|
|
animation: false,
|
|
cutout: '50%',
|
|
},
|
|
});
|
|
}
|
|
|
|
// Stacked area chart over time
|
|
const timeCanvas = document.getElementById('chart-prof-time');
|
|
if (timeCanvas) {
|
|
if (this.charts.profTime) this.charts.profTime.destroy();
|
|
|
|
const colors = { hunter: '#c45c5c', gatherer: '#6bab5e', woodcutter: '#a67c52', trader: '#d4a84b', crafter: '#8b6fc0', villager: '#7a8899' };
|
|
const datasets = [];
|
|
for (const [prof, data] of Object.entries(this.statsHistory.professions)) {
|
|
datasets.push({
|
|
label: prof.charAt(0).toUpperCase() + prof.slice(1),
|
|
data,
|
|
backgroundColor: colors[prof] || '#888',
|
|
fill: true,
|
|
tension: 0.3,
|
|
});
|
|
}
|
|
|
|
this.charts.profTime = new Chart(timeCanvas.getContext('2d'), {
|
|
type: 'line',
|
|
data: { labels: this.statsHistory.turns, datasets },
|
|
options: {
|
|
...this.getChartOptions('Professions Over Time'),
|
|
animation: false,
|
|
scales: { ...this.getChartOptions('').scales, y: { stacked: true, beginAtZero: true, ticks: { color: '#6b6560' }, grid: { color: 'rgba(58, 67, 89, 0.3)' } } },
|
|
plugins: { ...this.getChartOptions('').plugins, filler: { propagate: true } },
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
renderResourceCharts() {
|
|
const resColors = {
|
|
meat: '#c45c5c', berries: '#a855a8', water: '#5a8cc8',
|
|
wood: '#a67c52', hide: '#8b7355', clothes: '#6b6560',
|
|
};
|
|
const resourceTypes = ['meat', 'berries', 'water', 'wood', 'hide', 'clothes'];
|
|
|
|
// Resources Produced chart
|
|
const producedCanvas = document.getElementById('chart-res-produced');
|
|
if (producedCanvas) {
|
|
if (this.charts.resProduced) this.charts.resProduced.destroy();
|
|
|
|
const datasets = resourceTypes.map(res => ({
|
|
label: res.charAt(0).toUpperCase() + res.slice(1),
|
|
data: this.statsHistory.resourcesProduced[res] || [],
|
|
borderColor: resColors[res],
|
|
backgroundColor: 'transparent',
|
|
tension: 0.3,
|
|
})).filter(ds => ds.data.some(v => v > 0));
|
|
|
|
this.charts.resProduced = new Chart(producedCanvas.getContext('2d'), {
|
|
type: 'line',
|
|
data: { labels: this.statsHistory.turns, datasets },
|
|
options: this.getChartOptions('Resources Produced (per turn)'),
|
|
});
|
|
}
|
|
|
|
// Resources Consumed chart
|
|
const consumedCanvas = document.getElementById('chart-res-consumed');
|
|
if (consumedCanvas) {
|
|
if (this.charts.resConsumed) this.charts.resConsumed.destroy();
|
|
|
|
const datasets = resourceTypes.map(res => ({
|
|
label: res.charAt(0).toUpperCase() + res.slice(1),
|
|
data: this.statsHistory.resourcesConsumed[res] || [],
|
|
borderColor: resColors[res],
|
|
backgroundColor: 'transparent',
|
|
tension: 0.3,
|
|
})).filter(ds => ds.data.some(v => v > 0));
|
|
|
|
this.charts.resConsumed = new Chart(consumedCanvas.getContext('2d'), {
|
|
type: 'line',
|
|
data: { labels: this.statsHistory.turns, datasets },
|
|
options: this.getChartOptions('Resources Consumed (per turn)'),
|
|
});
|
|
}
|
|
|
|
// Resources Spoiled chart
|
|
const spoiledCanvas = document.getElementById('chart-res-spoiled');
|
|
if (spoiledCanvas) {
|
|
if (this.charts.resSpoiled) this.charts.resSpoiled.destroy();
|
|
|
|
const datasets = resourceTypes.map(res => ({
|
|
label: res.charAt(0).toUpperCase() + res.slice(1),
|
|
data: this.statsHistory.resourcesSpoiled[res] || [],
|
|
borderColor: resColors[res],
|
|
backgroundColor: 'transparent',
|
|
tension: 0.3,
|
|
})).filter(ds => ds.data.some(v => v > 0));
|
|
|
|
this.charts.resSpoiled = new Chart(spoiledCanvas.getContext('2d'), {
|
|
type: 'line',
|
|
data: { labels: this.statsHistory.turns, datasets },
|
|
options: this.getChartOptions('Resources Spoiled (per turn)'),
|
|
});
|
|
}
|
|
|
|
// Current Stock chart (bar chart showing inventory + market)
|
|
const stockCanvas = document.getElementById('chart-res-stock');
|
|
if (stockCanvas && this.currentState) {
|
|
if (this.charts.resStock) this.charts.resStock.destroy();
|
|
|
|
const resStats = this.currentState.resource_stats || {};
|
|
const inInventory = resStats.in_inventory || {};
|
|
const inMarket = resStats.in_market || {};
|
|
|
|
const labels = resourceTypes.map(r => r.charAt(0).toUpperCase() + r.slice(1));
|
|
const invData = resourceTypes.map(r => inInventory[r] || 0);
|
|
const marketData = resourceTypes.map(r => inMarket[r] || 0);
|
|
const colors = resourceTypes.map(r => resColors[r]);
|
|
|
|
this.charts.resStock = new Chart(stockCanvas.getContext('2d'), {
|
|
type: 'bar',
|
|
data: {
|
|
labels,
|
|
datasets: [
|
|
{ label: 'In Inventory', data: invData, backgroundColor: colors },
|
|
{ label: 'In Market', data: marketData, backgroundColor: colors.map(c => c + '80') },
|
|
],
|
|
},
|
|
options: {
|
|
...this.getChartOptions('Current Resource Stock'),
|
|
animation: false,
|
|
scales: {
|
|
x: { stacked: true, ticks: { color: '#6b6560' }, grid: { color: 'rgba(58, 67, 89, 0.3)' } },
|
|
y: { stacked: true, beginAtZero: true, ticks: { color: '#6b6560' }, grid: { color: 'rgba(58, 67, 89, 0.3)' } },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
// Cumulative Produced chart
|
|
const cumProducedCanvas = document.getElementById('chart-res-cum-produced');
|
|
if (cumProducedCanvas) {
|
|
if (this.charts.resCumProduced) this.charts.resCumProduced.destroy();
|
|
|
|
const datasets = resourceTypes.map(res => ({
|
|
label: res.charAt(0).toUpperCase() + res.slice(1),
|
|
data: this.statsHistory.resourcesProducedCumulative[res] || [],
|
|
borderColor: resColors[res],
|
|
backgroundColor: 'transparent',
|
|
tension: 0.3,
|
|
})).filter(ds => ds.data.some(v => v > 0));
|
|
|
|
this.charts.resCumProduced = new Chart(cumProducedCanvas.getContext('2d'), {
|
|
type: 'line',
|
|
data: { labels: this.statsHistory.turns, datasets },
|
|
options: this.getChartOptions('Cumulative Produced (total)'),
|
|
});
|
|
}
|
|
|
|
// Cumulative Consumed chart
|
|
const cumConsumedCanvas = document.getElementById('chart-res-cum-consumed');
|
|
if (cumConsumedCanvas) {
|
|
if (this.charts.resCumConsumed) this.charts.resCumConsumed.destroy();
|
|
|
|
const datasets = resourceTypes.map(res => ({
|
|
label: res.charAt(0).toUpperCase() + res.slice(1),
|
|
data: this.statsHistory.resourcesConsumedCumulative[res] || [],
|
|
borderColor: resColors[res],
|
|
backgroundColor: 'transparent',
|
|
tension: 0.3,
|
|
})).filter(ds => ds.data.some(v => v > 0));
|
|
|
|
this.charts.resCumConsumed = new Chart(cumConsumedCanvas.getContext('2d'), {
|
|
type: 'line',
|
|
data: { labels: this.statsHistory.turns, datasets },
|
|
options: this.getChartOptions('Cumulative Consumed (total)'),
|
|
});
|
|
}
|
|
|
|
// Cumulative Spoiled chart
|
|
const cumSpoiledCanvas = document.getElementById('chart-res-cum-spoiled');
|
|
if (cumSpoiledCanvas) {
|
|
if (this.charts.resCumSpoiled) this.charts.resCumSpoiled.destroy();
|
|
|
|
const datasets = resourceTypes.map(res => ({
|
|
label: res.charAt(0).toUpperCase() + res.slice(1),
|
|
data: this.statsHistory.resourcesSpoiledCumulative[res] || [],
|
|
borderColor: resColors[res],
|
|
backgroundColor: 'transparent',
|
|
tension: 0.3,
|
|
})).filter(ds => ds.data.some(v => v > 0));
|
|
|
|
this.charts.resCumSpoiled = new Chart(cumSpoiledCanvas.getContext('2d'), {
|
|
type: 'line',
|
|
data: { labels: this.statsHistory.turns, datasets },
|
|
options: this.getChartOptions('Cumulative Spoiled (total)'),
|
|
});
|
|
}
|
|
|
|
// Cumulative Traded chart
|
|
const cumTradedCanvas = document.getElementById('chart-res-cum-traded');
|
|
if (cumTradedCanvas) {
|
|
if (this.charts.resCumTraded) this.charts.resCumTraded.destroy();
|
|
|
|
const datasets = resourceTypes.map(res => ({
|
|
label: res.charAt(0).toUpperCase() + res.slice(1),
|
|
data: this.statsHistory.resourcesTraded[res] || [],
|
|
borderColor: resColors[res],
|
|
backgroundColor: 'transparent',
|
|
tension: 0.3,
|
|
})).filter(ds => ds.data.some(v => v > 0));
|
|
|
|
this.charts.resCumTraded = new Chart(cumTradedCanvas.getContext('2d'), {
|
|
type: 'line',
|
|
data: { labels: this.statsHistory.turns, datasets },
|
|
options: this.getChartOptions('Cumulative Traded (total)'),
|
|
});
|
|
}
|
|
}
|
|
|
|
renderMarketCharts() {
|
|
// Market supply bar chart
|
|
const supplyCanvas = document.getElementById('chart-market-supply');
|
|
if (supplyCanvas && this.currentState) {
|
|
if (this.charts.marketSupply) this.charts.marketSupply.destroy();
|
|
|
|
const prices = this.currentState.market?.prices || {};
|
|
const labels = Object.keys(prices);
|
|
const data = labels.map(r => prices[r].total_available || 0);
|
|
const colors = ['#c45c5c', '#a855a8', '#5a8cc8', '#a67c52', '#8b7355', '#6b6560'];
|
|
|
|
this.charts.marketSupply = new Chart(supplyCanvas.getContext('2d'), {
|
|
type: 'bar',
|
|
data: {
|
|
labels: labels.map(l => l.charAt(0).toUpperCase() + l.slice(1)),
|
|
datasets: [{ label: 'Available', data, backgroundColor: colors.slice(0, labels.length) }],
|
|
},
|
|
options: this.getChartOptions('Market Supply'),
|
|
});
|
|
}
|
|
|
|
// Trade activity over time
|
|
const activityCanvas = document.getElementById('chart-market-activity');
|
|
if (activityCanvas) {
|
|
if (this.charts.marketActivity) this.charts.marketActivity.destroy();
|
|
|
|
this.charts.marketActivity = new Chart(activityCanvas.getContext('2d'), {
|
|
type: 'line',
|
|
data: {
|
|
labels: this.statsHistory.turns,
|
|
datasets: [{
|
|
label: 'Active Orders',
|
|
data: this.statsHistory.tradeVolume,
|
|
borderColor: '#00ffa3',
|
|
backgroundColor: 'rgba(0, 255, 163, 0.1)',
|
|
fill: true,
|
|
tension: 0.3,
|
|
}],
|
|
},
|
|
options: this.getChartOptions('Market Activity'),
|
|
});
|
|
}
|
|
}
|
|
|
|
renderAgentStatsCharts() {
|
|
if (!this.currentState) return;
|
|
|
|
const agents = (this.currentState.agents || []).filter(a => a.is_alive);
|
|
const statTypes = [
|
|
{ id: 'energy', label: 'Energy', max: 'max_energy', color: '#39ff14' },
|
|
{ id: 'hunger', label: 'Hunger', max: 'max_hunger', color: '#ff6600' },
|
|
{ id: 'thirst', label: 'Thirst', max: 'max_thirst', color: '#00d4ff' },
|
|
{ id: 'heat', label: 'Heat', max: 'max_heat', color: '#ff0099' },
|
|
];
|
|
|
|
statTypes.forEach(stat => {
|
|
const canvas = document.getElementById(`chart-stat-${stat.id}`);
|
|
if (!canvas) return;
|
|
|
|
if (this.charts[`stat${stat.id}`]) this.charts[`stat${stat.id}`].destroy();
|
|
|
|
const values = agents.map(a => a.stats?.[stat.id] || 0);
|
|
const maxVal = agents[0]?.stats?.[stat.max] || 100;
|
|
|
|
// Create histogram bins
|
|
const bins = 10;
|
|
const binSize = maxVal / bins;
|
|
const histogram = new Array(bins).fill(0);
|
|
values.forEach(v => {
|
|
const binIndex = Math.min(Math.floor(v / binSize), bins - 1);
|
|
histogram[binIndex]++;
|
|
});
|
|
const binLabels = histogram.map((_, i) => `${Math.round(i * binSize)}-${Math.round((i + 1) * binSize)}`);
|
|
|
|
this.charts[`stat${stat.id}`] = new Chart(canvas.getContext('2d'), {
|
|
type: 'bar',
|
|
data: {
|
|
labels: binLabels,
|
|
datasets: [{
|
|
label: 'Agents',
|
|
data: histogram,
|
|
backgroundColor: stat.color,
|
|
borderWidth: 0,
|
|
}],
|
|
},
|
|
options: {
|
|
...this.getChartOptions(`${stat.label} Distribution`),
|
|
animation: false,
|
|
plugins: { ...this.getChartOptions('').plugins, legend: { display: false } },
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
getChartOptions(title) {
|
|
return {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
animation: false, // Disable animations for smooth real-time updates
|
|
plugins: {
|
|
legend: {
|
|
position: 'bottom',
|
|
labels: {
|
|
color: '#a8a095',
|
|
font: { family: "'JetBrains Mono', monospace", size: 11 },
|
|
boxWidth: 12,
|
|
padding: 15,
|
|
},
|
|
},
|
|
title: {
|
|
display: true,
|
|
text: title,
|
|
color: '#e8e4dc',
|
|
font: { family: "'Crimson Pro', serif", size: 16, weight: '600' },
|
|
padding: { bottom: 15 },
|
|
},
|
|
},
|
|
scales: {
|
|
x: {
|
|
ticks: { color: '#6b6560', font: { family: "'JetBrains Mono', monospace", size: 10 } },
|
|
grid: { color: 'rgba(58, 67, 89, 0.3)' },
|
|
},
|
|
y: {
|
|
beginAtZero: true,
|
|
ticks: { color: '#6b6560', font: { family: "'JetBrains Mono', monospace", size: 10 } },
|
|
grid: { color: 'rgba(58, 67, 89, 0.3)' },
|
|
},
|
|
},
|
|
interaction: {
|
|
intersect: false,
|
|
mode: 'index',
|
|
},
|
|
};
|
|
}
|
|
|
|
// =================================
|
|
// GOAP Visualization Methods
|
|
// =================================
|
|
|
|
async loadAgentGOAP(agentId) {
|
|
const contentEl = document.getElementById('agent-goap-content');
|
|
if (!contentEl) return;
|
|
|
|
contentEl.innerHTML = '<span style="color: var(--text-muted);">Loading...</span>';
|
|
|
|
try {
|
|
const data = await api.getAgentGOAPDebug(agentId);
|
|
const plan = data.current_plan;
|
|
|
|
if (plan && plan.actions.length > 0) {
|
|
contentEl.innerHTML = `
|
|
<div style="margin-bottom: 4px;">
|
|
<strong style="color: var(--accent-sapphire);">Goal:</strong> ${plan.goal_name}
|
|
</div>
|
|
<div style="font-family: var(--font-mono); font-size: 0.7rem;">
|
|
${plan.actions.map((a, i) =>
|
|
`<span style="${i === 0 ? 'color: var(--accent-emerald);' : ''}">${a}</span>`
|
|
).join(' → ')}
|
|
</div>
|
|
<div style="margin-top: 4px; color: var(--text-muted); font-size: 0.65rem;">
|
|
Cost: ${plan.total_cost.toFixed(1)} | Steps: ${plan.plan_length}
|
|
</div>
|
|
`;
|
|
} else {
|
|
contentEl.innerHTML = `
|
|
<div style="color: var(--text-muted);">
|
|
No plan (reactive mode)<br>
|
|
<span style="color: var(--text-primary);">${data.selected_action || 'No action'}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load GOAP info:', error);
|
|
contentEl.innerHTML = '<span style="color: var(--accent-ruby);">Failed to load</span>';
|
|
}
|
|
}
|
|
|
|
async fetchAndRenderGOAP() {
|
|
try {
|
|
const response = await api.get('/goap/debug');
|
|
this.goapData = response;
|
|
this.renderGOAPAgentList();
|
|
|
|
// If we have a selected agent, render their details
|
|
if (this.selectedGoapAgentId) {
|
|
const agent = this.goapData.agents.find(a => a.agent_id === this.selectedGoapAgentId);
|
|
if (agent) {
|
|
this.renderGOAPAgentDetails(agent);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch GOAP data:', error);
|
|
const { goapAgentList } = this.domCache;
|
|
if (goapAgentList) {
|
|
goapAgentList.innerHTML = '<p class="loading-text">Failed to load GOAP data. Make sure the server is running.</p>';
|
|
}
|
|
}
|
|
}
|
|
|
|
renderGOAPAgentList() {
|
|
const { goapAgentList } = this.domCache;
|
|
if (!goapAgentList || !this.goapData) return;
|
|
|
|
if (this.goapData.agents.length === 0) {
|
|
goapAgentList.innerHTML = '<p class="loading-text">No agents found</p>';
|
|
return;
|
|
}
|
|
|
|
goapAgentList.innerHTML = this.goapData.agents.map(agent => `
|
|
<div class="goap-agent-item ${agent.agent_id === this.selectedGoapAgentId ? 'selected' : ''}"
|
|
data-agent-id="${agent.agent_id}">
|
|
<div class="agent-name">${agent.agent_name}</div>
|
|
<div class="agent-action">${agent.selected_action || 'No action'}</div>
|
|
<div class="agent-goal">${agent.current_plan ? '🎯 ' + agent.current_plan.goal_name : '(reactive)'}</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
// Add click handlers
|
|
goapAgentList.querySelectorAll('.goap-agent-item').forEach(item => {
|
|
item.addEventListener('click', () => {
|
|
this.selectGoapAgent(item.dataset.agentId);
|
|
});
|
|
});
|
|
}
|
|
|
|
selectGoapAgent(agentId) {
|
|
this.selectedGoapAgentId = agentId;
|
|
|
|
// Update selection styling
|
|
const { goapAgentList } = this.domCache;
|
|
if (goapAgentList) {
|
|
goapAgentList.querySelectorAll('.goap-agent-item').forEach(item => {
|
|
item.classList.toggle('selected', item.dataset.agentId === agentId);
|
|
});
|
|
}
|
|
|
|
// Render details
|
|
if (this.goapData) {
|
|
const agent = this.goapData.agents.find(a => a.agent_id === agentId);
|
|
if (agent) {
|
|
this.renderGOAPAgentDetails(agent);
|
|
}
|
|
}
|
|
}
|
|
|
|
renderGOAPAgentDetails(agent) {
|
|
this.renderGOAPPlanView(agent);
|
|
this.renderGOAPActionsList(agent);
|
|
this.renderGOAPGoalsChart(agent);
|
|
}
|
|
|
|
getUrgencyClass(urgency) {
|
|
if (urgency <= 0) return 'none';
|
|
if (urgency <= 1) return 'low';
|
|
return 'high';
|
|
}
|
|
|
|
renderGOAPPlanView(agent) {
|
|
const { goapPlanView } = this.domCache;
|
|
if (!goapPlanView) return;
|
|
|
|
const ws = agent.world_state;
|
|
const plan = agent.current_plan;
|
|
|
|
goapPlanView.innerHTML = `
|
|
<div class="goap-world-state">
|
|
<div class="goap-stat-card thirst">
|
|
<div class="label">Thirst</div>
|
|
<div class="value">${Math.round(ws.vitals.thirst * 100)}%<span class="goap-urgency ${this.getUrgencyClass(ws.urgencies.thirst)}"></span></div>
|
|
<div class="bar"><div class="bar-fill" style="width: ${ws.vitals.thirst * 100}%"></div></div>
|
|
</div>
|
|
<div class="goap-stat-card hunger">
|
|
<div class="label">Hunger</div>
|
|
<div class="value">${Math.round(ws.vitals.hunger * 100)}%<span class="goap-urgency ${this.getUrgencyClass(ws.urgencies.hunger)}"></span></div>
|
|
<div class="bar"><div class="bar-fill" style="width: ${ws.vitals.hunger * 100}%"></div></div>
|
|
</div>
|
|
<div class="goap-stat-card heat">
|
|
<div class="label">Heat</div>
|
|
<div class="value">${Math.round(ws.vitals.heat * 100)}%<span class="goap-urgency ${this.getUrgencyClass(ws.urgencies.heat)}"></span></div>
|
|
<div class="bar"><div class="bar-fill" style="width: ${ws.vitals.heat * 100}%"></div></div>
|
|
</div>
|
|
<div class="goap-stat-card energy">
|
|
<div class="label">Energy</div>
|
|
<div class="value">${Math.round(ws.vitals.energy * 100)}%<span class="goap-urgency ${this.getUrgencyClass(ws.urgencies.energy)}"></span></div>
|
|
<div class="bar"><div class="bar-fill" style="width: ${ws.vitals.energy * 100}%"></div></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="goap-plan-steps">
|
|
<h5>Current Plan</h5>
|
|
${plan && plan.actions.length > 0 ? `
|
|
<div class="goap-plan-flow">
|
|
${plan.actions.map((action, i) => `
|
|
<span class="goap-step-node ${i === 0 ? 'current' : ''}">${action}</span>
|
|
${i < plan.actions.length - 1 ? '<span class="goap-step-arrow">→</span>' : ''}
|
|
`).join('')}
|
|
<span class="goap-step-arrow">→</span>
|
|
<span class="goap-goal-result">✓ ${plan.goal_name}</span>
|
|
</div>
|
|
<div style="margin-top: 8px; font-size: 0.75rem; color: var(--text-muted);">
|
|
Total Cost: ${plan.total_cost.toFixed(1)} | Steps: ${plan.plan_length}
|
|
</div>
|
|
` : `
|
|
<div style="color: var(--text-muted); font-size: 0.85rem;">
|
|
No plan - using reactive selection<br>
|
|
Selected: <strong>${agent.selected_action || 'None'}</strong>
|
|
</div>
|
|
`}
|
|
</div>
|
|
|
|
<div style="margin-top: 12px;">
|
|
<h5 style="font-size: 0.7rem; color: var(--text-muted); margin-bottom: 8px;">INVENTORY</h5>
|
|
<div class="goap-inventory">
|
|
<div class="goap-inv-item">💧<span class="count">${ws.inventory.water}</span></div>
|
|
<div class="goap-inv-item">🍖<span class="count">${ws.inventory.meat}</span></div>
|
|
<div class="goap-inv-item">🫐<span class="count">${ws.inventory.berries}</span></div>
|
|
<div class="goap-inv-item">🪵<span class="count">${ws.inventory.wood}</span></div>
|
|
<div class="goap-inv-item">🥩<span class="count">${ws.inventory.hide}</span></div>
|
|
<div class="goap-inv-item">📦<span class="count">${ws.inventory.space}</span></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-top: 12px; display: flex; gap: 16px; font-size: 0.8rem;">
|
|
<span style="color: var(--accent-gold);">💰 ${ws.economy.money}c</span>
|
|
<span style="color: var(--text-muted);">Wealthy: ${ws.economy.is_wealthy ? '✓' : '✗'}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
renderGOAPActionsList(agent) {
|
|
const { goapActionsList } = this.domCache;
|
|
if (!goapActionsList) return;
|
|
|
|
// Sort: plan actions first, then valid, then invalid
|
|
const sortedActions = [...agent.actions].sort((a, b) => {
|
|
if (a.is_in_plan && !b.is_in_plan) return -1;
|
|
if (!a.is_in_plan && b.is_in_plan) return 1;
|
|
if (a.is_in_plan && b.is_in_plan) return a.plan_order - b.plan_order;
|
|
if (a.is_valid && !b.is_valid) return -1;
|
|
if (!a.is_valid && b.is_valid) return 1;
|
|
return (a.cost || 999) - (b.cost || 999);
|
|
});
|
|
|
|
goapActionsList.innerHTML = sortedActions.map(action => `
|
|
<div class="goap-action-item ${action.is_valid ? 'valid' : 'invalid'} ${action.is_in_plan ? 'in-plan' : ''}">
|
|
${action.is_in_plan ? `<span class="action-order">${action.plan_order + 1}</span>` : ''}
|
|
<span class="action-name">${action.name}</span>
|
|
<span class="action-cost">${action.cost >= 0 ? action.cost.toFixed(1) : '∞'}</span>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
renderGOAPGoalsChart(agent) {
|
|
const { chartGoapGoals } = this.domCache;
|
|
if (!chartGoapGoals) return;
|
|
|
|
// Sort goals by priority and take top 10
|
|
const sortedGoals = [...agent.goals]
|
|
.sort((a, b) => b.priority - a.priority)
|
|
.slice(0, 10);
|
|
|
|
// Destroy existing chart
|
|
if (this.charts.goapGoals) {
|
|
this.charts.goapGoals.destroy();
|
|
}
|
|
|
|
this.charts.goapGoals = new Chart(chartGoapGoals, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: sortedGoals.map(g => g.name),
|
|
datasets: [{
|
|
label: 'Priority',
|
|
data: sortedGoals.map(g => g.priority),
|
|
backgroundColor: sortedGoals.map(g => {
|
|
if (g.is_selected) return 'rgba(139, 111, 192, 0.8)';
|
|
if (g.is_satisfied) return 'rgba(74, 156, 109, 0.5)';
|
|
if (g.priority > 0) return 'rgba(90, 140, 200, 0.7)';
|
|
return 'rgba(107, 101, 96, 0.3)';
|
|
}),
|
|
borderColor: sortedGoals.map(g => {
|
|
if (g.is_selected) return '#8b6fc0';
|
|
if (g.is_satisfied) return '#4a9c6d';
|
|
if (g.priority > 0) return '#5a8cc8';
|
|
return '#6b6560';
|
|
}),
|
|
borderWidth: 2,
|
|
}]
|
|
},
|
|
options: {
|
|
indexAxis: 'y',
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
animation: false,
|
|
plugins: {
|
|
legend: { display: false },
|
|
title: {
|
|
display: true,
|
|
text: 'Goal Priorities',
|
|
color: '#e8e4dc',
|
|
font: { family: "'Crimson Pro', serif", size: 14 },
|
|
},
|
|
},
|
|
scales: {
|
|
x: {
|
|
beginAtZero: true,
|
|
grid: { color: 'rgba(58, 67, 89, 0.3)' },
|
|
ticks: { color: '#6b6560' },
|
|
},
|
|
y: {
|
|
grid: { display: false },
|
|
ticks: {
|
|
color: '#e8e4dc',
|
|
font: { family: "'JetBrains Mono', monospace", size: 10 }
|
|
},
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
update(time, delta) {
|
|
// Minimal update loop - no heavy operations here
|
|
}
|
|
}
|