576 lines
21 KiB
JavaScript
576 lines
21 KiB
JavaScript
|
|
/**
|
||
|
|
* User Interface & Interactive Controls Module
|
||
|
|
* Binds expanded sliders, preset actions, live HUD badges, population history chart,
|
||
|
|
* entity inspector, and the post-extinction analysis modal.
|
||
|
|
*/
|
||
|
|
|
||
|
|
export class UIManager {
|
||
|
|
constructor(sim, configManager) {
|
||
|
|
this.sim = sim;
|
||
|
|
this.configManager = configManager;
|
||
|
|
|
||
|
|
this.cacheDOMElements();
|
||
|
|
this.bindEvents();
|
||
|
|
this.syncSlidersFromConfig();
|
||
|
|
}
|
||
|
|
|
||
|
|
cacheDOMElements() {
|
||
|
|
// Primary Controls
|
||
|
|
this.btnPlayPause = document.getElementById('btnPlayPause');
|
||
|
|
this.btnRestart = document.getElementById('btnRestart');
|
||
|
|
this.btnFullscreen = document.getElementById('btnFullscreen');
|
||
|
|
this.btnExitFullscreen = document.getElementById('btnExitFullscreen');
|
||
|
|
this.btnSetDefaults = document.getElementById('btnSetDefaults');
|
||
|
|
this.btnDefaults = document.getElementById('btnDefaults');
|
||
|
|
this.btnCopyJson = document.getElementById('btnCopyJson');
|
||
|
|
this.btnRain = document.getElementById('btnRain');
|
||
|
|
this.toastMsg = document.getElementById('toastMsg');
|
||
|
|
|
||
|
|
// Continuous Screen Saver Mode
|
||
|
|
this.checkContinuous = document.getElementById('checkContinuous');
|
||
|
|
|
||
|
|
// Speed buttons
|
||
|
|
this.speedBtns = document.querySelectorAll('.speed-btn');
|
||
|
|
|
||
|
|
// Tool buttons
|
||
|
|
this.toolBtns = document.querySelectorAll('.tool-btn');
|
||
|
|
|
||
|
|
// Sliders & value displays
|
||
|
|
this.sliders = {
|
||
|
|
initialSheep: { input: document.getElementById('sliderSheep'), label: document.getElementById('valSheep'), format: (v) => v },
|
||
|
|
initialWolves: { input: document.getElementById('sliderWolves'), label: document.getElementById('valWolves'), format: (v) => v },
|
||
|
|
initialPlants: { input: document.getElementById('sliderPlants'), label: document.getElementById('valPlants'), format: (v) => v },
|
||
|
|
maxPlantCap: { input: document.getElementById('sliderMaxPlantCap'), label: document.getElementById('valMaxPlantCap'), format: (v) => v },
|
||
|
|
|
||
|
|
sheepHungerDepletionRate: { input: document.getElementById('sliderSheepHunger'), label: document.getElementById('valSheepHunger'), format: (v) => `${Number(v).toFixed(1)}/s` },
|
||
|
|
sheepGrazingReach: { input: document.getElementById('sliderSheepReach'), label: document.getElementById('valSheepReach'), format: (v) => `${v}px` },
|
||
|
|
sheepMatingCooldown: { input: document.getElementById('sliderSheepMating'), label: document.getElementById('valSheepMating'), format: (v) => `${v}s` },
|
||
|
|
|
||
|
|
wolfLungeCost: { input: document.getElementById('sliderWolfLunge'), label: document.getElementById('valWolfLunge'), format: (v) => `${v} HP` },
|
||
|
|
wolfHungerDepletionRate: { input: document.getElementById('sliderWolfHunger'), label: document.getElementById('valWolfHunger'), format: (v) => `${Number(v).toFixed(1)}/s` },
|
||
|
|
wolfAttackDamage: { input: document.getElementById('sliderWolfDamage'), label: document.getElementById('valWolfDamage'), format: (v) => v },
|
||
|
|
wolfMatingCooldown: { input: document.getElementById('sliderWolfMating'), label: document.getElementById('valWolfMating'), format: (v) => `${v}s` },
|
||
|
|
|
||
|
|
plantSeedRate: { input: document.getElementById('sliderSeedRate'), label: document.getElementById('valSeedRate'), format: (v) => `${v}%` },
|
||
|
|
plantDryRate: { input: document.getElementById('sliderDryRate'), label: document.getElementById('valDryRate'), format: (v) => `${Number(v).toFixed(1)}/s` },
|
||
|
|
rainFrequency: { input: document.getElementById('sliderRain'), label: document.getElementById('valRain'), format: (v) => `${v}%` },
|
||
|
|
decaySpeed: { input: document.getElementById('sliderDecay'), label: document.getElementById('valDecay'), format: (v) => `${Number(v).toFixed(1)}x` },
|
||
|
|
|
||
|
|
// Continuous Mode Influx intervals & wave size
|
||
|
|
continuousSheepInterval: { input: document.getElementById('sliderContinuousSheep'), label: document.getElementById('valContinuousSheep'), format: (v) => `${v}s` },
|
||
|
|
continuousWolfInterval: { input: document.getElementById('sliderContinuousWolf'), label: document.getElementById('valContinuousWolf'), format: (v) => `${v}s` },
|
||
|
|
continuousMaxGroupSize: { input: document.getElementById('sliderContinuousGroup'), label: document.getElementById('valContinuousGroup'), format: (v) => `${v}` },
|
||
|
|
};
|
||
|
|
|
||
|
|
// Live HUD badges
|
||
|
|
this.hudSheep = document.getElementById('hudSheep');
|
||
|
|
this.hudWolves = document.getElementById('hudWolves');
|
||
|
|
this.hudPlants = document.getElementById('hudPlants');
|
||
|
|
this.hudTime = document.getElementById('hudTime');
|
||
|
|
this.hudWeather = document.getElementById('hudWeather');
|
||
|
|
|
||
|
|
// Mini chart canvas
|
||
|
|
this.miniChartCanvas = document.getElementById('miniChart');
|
||
|
|
if (this.miniChartCanvas) {
|
||
|
|
this.miniChartCtx = this.miniChartCanvas.getContext('2d');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Extinction modal elements
|
||
|
|
this.extinctionModal = document.getElementById('extinctionModal');
|
||
|
|
this.extinctionChartCanvas = document.getElementById('extinctionChart');
|
||
|
|
if (this.extinctionChartCanvas) {
|
||
|
|
this.extinctionChartCtx = this.extinctionChartCanvas.getContext('2d');
|
||
|
|
}
|
||
|
|
|
||
|
|
this.extTimeSurv = document.getElementById('extTimeSurv');
|
||
|
|
this.extPeakSheep = document.getElementById('extPeakSheep');
|
||
|
|
this.extPeakWolves = document.getElementById('extPeakWolves');
|
||
|
|
this.extPeakPlants = document.getElementById('extPeakPlants');
|
||
|
|
this.extTotalBorn = document.getElementById('extTotalBorn');
|
||
|
|
this.extWolfKills = document.getElementById('extWolfKills');
|
||
|
|
this.extDecayBlooms = document.getElementById('extDecayBlooms');
|
||
|
|
this.extSeeds = document.getElementById('extSeeds');
|
||
|
|
|
||
|
|
this.btnModalRestart = document.getElementById('btnModalRestart');
|
||
|
|
this.btnModalReview = document.getElementById('btnModalReview');
|
||
|
|
|
||
|
|
// Entity Inspector
|
||
|
|
this.inspectorPanel = document.getElementById('inspectorPanel');
|
||
|
|
this.inspectorDetails = document.getElementById('inspectorDetails');
|
||
|
|
this.btnCloseInspector = document.getElementById('btnCloseInspector');
|
||
|
|
}
|
||
|
|
|
||
|
|
showToast(text) {
|
||
|
|
if (!this.toastMsg) return;
|
||
|
|
this.toastMsg.textContent = text;
|
||
|
|
this.toastMsg.classList.remove('hidden');
|
||
|
|
clearTimeout(this._toastTimeout);
|
||
|
|
this._toastTimeout = setTimeout(() => {
|
||
|
|
this.toastMsg.classList.add('hidden');
|
||
|
|
}, 2400);
|
||
|
|
}
|
||
|
|
|
||
|
|
syncSlidersFromConfig() {
|
||
|
|
const cfg = this.configManager.current;
|
||
|
|
|
||
|
|
if (this.checkContinuous && cfg.continuousMode !== undefined) {
|
||
|
|
this.checkContinuous.checked = !!cfg.continuousMode;
|
||
|
|
}
|
||
|
|
|
||
|
|
for (const [key, item] of Object.entries(this.sliders)) {
|
||
|
|
if (item.input && cfg[key] !== undefined) {
|
||
|
|
item.input.value = cfg[key];
|
||
|
|
if (item.label) {
|
||
|
|
item.label.textContent = item.format(cfg[key]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
readSliders() {
|
||
|
|
const config = {};
|
||
|
|
if (this.checkContinuous) {
|
||
|
|
config.continuousMode = this.checkContinuous.checked;
|
||
|
|
}
|
||
|
|
for (const [key, item] of Object.entries(this.sliders)) {
|
||
|
|
if (item.input) {
|
||
|
|
config[key] = parseFloat(item.input.value);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return config;
|
||
|
|
}
|
||
|
|
|
||
|
|
bindEvents() {
|
||
|
|
// 1. Play / Pause
|
||
|
|
this.btnPlayPause.addEventListener('click', () => {
|
||
|
|
this.sim.isRunning = !this.sim.isRunning;
|
||
|
|
this.updatePlayPauseButton();
|
||
|
|
});
|
||
|
|
|
||
|
|
// 2. Restart Simulation
|
||
|
|
this.btnRestart.addEventListener('click', () => {
|
||
|
|
this.extinctionModal.classList.add('hidden');
|
||
|
|
this.sim.restart();
|
||
|
|
this.sim.isRunning = true;
|
||
|
|
this.updatePlayPauseButton();
|
||
|
|
});
|
||
|
|
|
||
|
|
// 3. Save current settings as custom defaults
|
||
|
|
if (this.btnSetDefaults) {
|
||
|
|
this.btnSetDefaults.addEventListener('click', () => {
|
||
|
|
const currentVals = this.readSliders();
|
||
|
|
this.configManager.saveAsNewDefaults(currentVals);
|
||
|
|
this.sim.config = { ...this.configManager.current };
|
||
|
|
this.showToast('✓ Saved As Custom Defaults!');
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// 4. Restore Defaults
|
||
|
|
this.btnDefaults.addEventListener('click', () => {
|
||
|
|
this.configManager.resetToDefaults();
|
||
|
|
this.syncSlidersFromConfig();
|
||
|
|
this.sim.config = { ...this.configManager.current };
|
||
|
|
this.showToast('↺ Restored Defaults');
|
||
|
|
});
|
||
|
|
|
||
|
|
// 5. Copy JSON Config
|
||
|
|
if (this.btnCopyJson) {
|
||
|
|
this.btnCopyJson.addEventListener('click', () => {
|
||
|
|
const jsonStr = JSON.stringify(this.sim.config, null, 2);
|
||
|
|
navigator.clipboard.writeText(jsonStr).then(() => {
|
||
|
|
this.showToast('📋 JSON Copied to Clipboard!');
|
||
|
|
}).catch(() => {
|
||
|
|
this.showToast('Failed to copy to clipboard');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// 6. Summon Rain button
|
||
|
|
if (this.btnRain) {
|
||
|
|
this.btnRain.addEventListener('click', () => {
|
||
|
|
this.sim.weather.forceRain();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// 7. Speed Buttons (1x, 2x, 4x)
|
||
|
|
this.speedBtns.forEach((btn) => {
|
||
|
|
btn.addEventListener('click', () => {
|
||
|
|
this.speedBtns.forEach((b) => b.classList.remove('active'));
|
||
|
|
btn.classList.add('active');
|
||
|
|
const speed = parseFloat(btn.dataset.speed || '1');
|
||
|
|
this.sim.speedMultiplier = speed;
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// 8. Spawn / Inspect Tools
|
||
|
|
this.toolBtns.forEach((btn) => {
|
||
|
|
btn.addEventListener('click', () => {
|
||
|
|
this.toolBtns.forEach((b) => b.classList.remove('active'));
|
||
|
|
btn.classList.add('active');
|
||
|
|
this.sim.activeTool = btn.dataset.tool || 'inspect';
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// 9. Real-time Slider input listeners
|
||
|
|
for (const [key, item] of Object.entries(this.sliders)) {
|
||
|
|
if (item.input) {
|
||
|
|
item.input.addEventListener('input', () => {
|
||
|
|
const val = parseFloat(item.input.value);
|
||
|
|
if (item.label) {
|
||
|
|
item.label.textContent = item.format(val);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Live update simulation config & persist
|
||
|
|
const updated = this.readSliders();
|
||
|
|
this.configManager.save(updated);
|
||
|
|
this.sim.config = { ...this.sim.config, ...updated };
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 10. Canvas click interactions (Spawn or Inspect)
|
||
|
|
this.sim.canvas.addEventListener('click', (e) => {
|
||
|
|
const rect = this.sim.canvas.getBoundingClientRect();
|
||
|
|
const scaleX = this.sim.canvas.width / rect.width;
|
||
|
|
const scaleY = this.sim.canvas.height / rect.height;
|
||
|
|
const clickX = (e.clientX - rect.left) * scaleX;
|
||
|
|
const clickY = (e.clientY - rect.top) * scaleY;
|
||
|
|
|
||
|
|
if (this.sim.activeTool === 'inspect') {
|
||
|
|
this.handleInspectClick(clickX, clickY);
|
||
|
|
} else {
|
||
|
|
this.sim.spawnEntity(this.sim.activeTool, clickX, clickY);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// 11. Extinction modal actions
|
||
|
|
if (this.btnModalRestart) {
|
||
|
|
this.btnModalRestart.addEventListener('click', () => {
|
||
|
|
this.extinctionModal.classList.add('hidden');
|
||
|
|
this.sim.restart();
|
||
|
|
this.sim.isRunning = true;
|
||
|
|
this.updatePlayPauseButton();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
if (this.btnModalReview) {
|
||
|
|
this.btnModalReview.addEventListener('click', () => {
|
||
|
|
this.extinctionModal.classList.add('hidden');
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
if (this.btnCloseInspector) {
|
||
|
|
this.btnCloseInspector.addEventListener('click', () => {
|
||
|
|
this.sim.selectedEntity = null;
|
||
|
|
this.inspectorPanel.classList.add('hidden');
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// 12. Fullscreen Screen Saver Mode
|
||
|
|
if (this.btnFullscreen) {
|
||
|
|
this.btnFullscreen.addEventListener('click', () => {
|
||
|
|
this.enterFullscreen();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
if (this.btnExitFullscreen) {
|
||
|
|
this.btnExitFullscreen.addEventListener('click', () => {
|
||
|
|
this.exitFullscreen();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Keyboard ESC & browser fullscreenchange listener
|
||
|
|
document.addEventListener('fullscreenchange', () => {
|
||
|
|
if (!document.fullscreenElement && this.isFullscreen) {
|
||
|
|
this.exitFullscreen(false);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
window.addEventListener('keydown', (e) => {
|
||
|
|
if (e.key === 'Escape' && this.isFullscreen) {
|
||
|
|
this.exitFullscreen();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// 13. Continuous Mode Toggle
|
||
|
|
if (this.checkContinuous) {
|
||
|
|
this.checkContinuous.addEventListener('change', () => {
|
||
|
|
const isChecked = this.checkContinuous.checked;
|
||
|
|
this.sim.config.continuousMode = isChecked;
|
||
|
|
this.configManager.save({ continuousMode: isChecked });
|
||
|
|
if (isChecked) {
|
||
|
|
this.extinctionModal.classList.add('hidden');
|
||
|
|
this.showToast('📺 Continuous Screen Saver Mode Active');
|
||
|
|
} else {
|
||
|
|
this.showToast('Continuous Mode Disabled');
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
enterFullscreen() {
|
||
|
|
this.isFullscreen = true;
|
||
|
|
const appLayout = document.querySelector('.app-layout');
|
||
|
|
if (appLayout) {
|
||
|
|
appLayout.classList.add('fullscreen-mode');
|
||
|
|
}
|
||
|
|
if (this.btnExitFullscreen) {
|
||
|
|
this.btnExitFullscreen.classList.remove('hidden');
|
||
|
|
}
|
||
|
|
|
||
|
|
if (document.documentElement.requestFullscreen && !document.fullscreenElement) {
|
||
|
|
document.documentElement.requestFullscreen().catch(() => {});
|
||
|
|
}
|
||
|
|
|
||
|
|
setTimeout(() => {
|
||
|
|
window.dispatchEvent(new Event('resize'));
|
||
|
|
}, 60);
|
||
|
|
}
|
||
|
|
|
||
|
|
exitFullscreen(requestExit = true) {
|
||
|
|
this.isFullscreen = false;
|
||
|
|
const appLayout = document.querySelector('.app-layout');
|
||
|
|
if (appLayout) {
|
||
|
|
appLayout.classList.remove('fullscreen-mode');
|
||
|
|
}
|
||
|
|
if (this.btnExitFullscreen) {
|
||
|
|
this.btnExitFullscreen.classList.add('hidden');
|
||
|
|
}
|
||
|
|
|
||
|
|
if (requestExit && document.fullscreenElement && document.exitFullscreen) {
|
||
|
|
document.exitFullscreen().catch(() => {});
|
||
|
|
}
|
||
|
|
|
||
|
|
setTimeout(() => {
|
||
|
|
window.dispatchEvent(new Event('resize'));
|
||
|
|
}, 60);
|
||
|
|
}
|
||
|
|
|
||
|
|
handleInspectClick(x, y) {
|
||
|
|
let bestEntity = null;
|
||
|
|
let minD = 28;
|
||
|
|
|
||
|
|
const checkList = (list) => {
|
||
|
|
for (let i = 0; i < list.length; i++) {
|
||
|
|
const item = list[i];
|
||
|
|
if (item.isDead) continue;
|
||
|
|
const d = Math.hypot(item.x - x, item.y - y);
|
||
|
|
if (d < minD) {
|
||
|
|
minD = d;
|
||
|
|
bestEntity = item;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
checkList(this.sim.wolves);
|
||
|
|
checkList(this.sim.sheep);
|
||
|
|
checkList(this.sim.plants);
|
||
|
|
|
||
|
|
this.sim.selectedEntity = bestEntity;
|
||
|
|
this.updateInspectorPanel();
|
||
|
|
}
|
||
|
|
|
||
|
|
updateInspectorPanel() {
|
||
|
|
const e = this.sim.selectedEntity;
|
||
|
|
if (!e || e.isDead) {
|
||
|
|
this.inspectorPanel.classList.add('hidden');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
this.inspectorPanel.classList.remove('hidden');
|
||
|
|
let html = '';
|
||
|
|
|
||
|
|
if (e.species === 'sheep' || e.species === 'wolf') {
|
||
|
|
const icon = e.species === 'sheep' ? '🐑' : '🐺';
|
||
|
|
const name = e.isBaby ? `Baby ${e.species}` : `Adult ${e.species}`;
|
||
|
|
html = `
|
||
|
|
<div class="insp-title">${icon} ${name}</div>
|
||
|
|
<div class="insp-row"><span>Health:</span> <span>${Math.round(e.health)}/100</span></div>
|
||
|
|
<div class="insp-row"><span>Hunger:</span> <span>${Math.round(e.hunger)}/100</span></div>
|
||
|
|
<div class="insp-row"><span>State:</span> <span class="capitalize">${e.actionState}</span></div>
|
||
|
|
<div class="insp-row"><span>Age:</span> <span>${Math.round(e.age)}s</span></div>
|
||
|
|
${e.isBaby ? `<div class="insp-row"><span>Food for Adult:</span> <span>${e.foodEatenAsBaby}/3 meals</span></div>` : ''}
|
||
|
|
`;
|
||
|
|
} else {
|
||
|
|
const isFlower = e.type === 'flower';
|
||
|
|
html = `
|
||
|
|
<div class="insp-title">${isFlower ? '🌸 Dense Flower Tuft' : '🌿 Grass Tuft'}</div>
|
||
|
|
<div class="insp-row"><span>Moisture:</span> <span>${Math.round(e.moisture)}%</span></div>
|
||
|
|
<div class="insp-row"><span>Growth:</span> <span>${Math.round(e.growth * 100)}%</span></div>
|
||
|
|
<div class="insp-row"><span>Age:</span> <span>${Math.round(e.age)}s</span></div>
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
|
||
|
|
this.inspectorDetails.innerHTML = html;
|
||
|
|
}
|
||
|
|
|
||
|
|
updatePlayPauseButton() {
|
||
|
|
if (this.sim.isRunning) {
|
||
|
|
this.btnPlayPause.innerHTML = `
|
||
|
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
||
|
|
<span>Pause</span>
|
||
|
|
`;
|
||
|
|
this.btnPlayPause.classList.add('btn-active');
|
||
|
|
} else {
|
||
|
|
this.btnPlayPause.innerHTML = `
|
||
|
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||
|
|
<span>Play</span>
|
||
|
|
`;
|
||
|
|
this.btnPlayPause.classList.remove('btn-active');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
updateHUD(stats, sheepCount, wolfCount, plantCount) {
|
||
|
|
if (this.hudSheep) this.hudSheep.textContent = sheepCount;
|
||
|
|
if (this.hudWolves) this.hudWolves.textContent = wolfCount;
|
||
|
|
if (this.hudPlants) this.hudPlants.textContent = plantCount;
|
||
|
|
|
||
|
|
if (this.hudTime) {
|
||
|
|
const totalSec = Math.floor(stats.elapsedSimTime);
|
||
|
|
const mins = Math.floor(totalSec / 60);
|
||
|
|
const secs = totalSec % 60;
|
||
|
|
this.hudTime.textContent = `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (this.hudWeather) {
|
||
|
|
const wState = this.sim.weather.state;
|
||
|
|
let label = '☀️ Sunny';
|
||
|
|
if (wState === 'raining') label = '🌧️ Raining';
|
||
|
|
else if (wState === 'cloudy') label = '☁️ Overcast';
|
||
|
|
else if (wState === 'clearing') label = '⛅ Clearing';
|
||
|
|
this.hudWeather.textContent = label;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Refresh inspector if entity is active
|
||
|
|
if (this.sim.selectedEntity) {
|
||
|
|
this.updateInspectorPanel();
|
||
|
|
}
|
||
|
|
|
||
|
|
// Draw Mini Chart
|
||
|
|
if (this.miniChartCtx && stats.history.length > 1) {
|
||
|
|
this.drawChart(this.miniChartCtx, this.miniChartCanvas.width, this.miniChartCanvas.height, stats.history, false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
showExtinctionModal(stats) {
|
||
|
|
this.extinctionModal.classList.remove('hidden');
|
||
|
|
|
||
|
|
const totalSec = Math.floor(stats.extinctionTime || stats.elapsedSimTime);
|
||
|
|
const mins = Math.floor(totalSec / 60);
|
||
|
|
const secs = totalSec % 60;
|
||
|
|
this.extTimeSurv.textContent = `${mins}m ${secs}s`;
|
||
|
|
|
||
|
|
this.extPeakSheep.textContent = stats.peakSheep;
|
||
|
|
this.extPeakWolves.textContent = stats.peakWolves;
|
||
|
|
this.extPeakPlants.textContent = stats.peakPlants;
|
||
|
|
|
||
|
|
this.extTotalBorn.textContent = `${stats.totalSheepBorn} sheep / ${stats.totalWolvesBorn} wolves`;
|
||
|
|
this.extWolfKills.textContent = `${stats.sheepKilledByWolves} sheep hunted (${stats.sheepStarved} starved)`;
|
||
|
|
this.extDecayBlooms.textContent = `${stats.totalPlantsFromDecay} flower tufts`;
|
||
|
|
this.extSeeds.textContent = `${stats.totalSeedsSprouted} seed puffs sprouted`;
|
||
|
|
|
||
|
|
// Render large extinction history chart
|
||
|
|
if (this.extinctionChartCtx) {
|
||
|
|
this.drawChart(
|
||
|
|
this.extinctionChartCtx,
|
||
|
|
this.extinctionChartCanvas.width,
|
||
|
|
this.extinctionChartCanvas.height,
|
||
|
|
stats.history,
|
||
|
|
true
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
drawChart(ctx, width, height, history, showAxes = true) {
|
||
|
|
ctx.clearRect(0, 0, width, height);
|
||
|
|
|
||
|
|
if (!history || history.length < 2) return;
|
||
|
|
|
||
|
|
const padLeft = showAxes ? 45 : 4;
|
||
|
|
const padRight = showAxes ? 20 : 4;
|
||
|
|
const padTop = showAxes ? 25 : 4;
|
||
|
|
const padBottom = showAxes ? 25 : 4;
|
||
|
|
|
||
|
|
const chartW = width - padLeft - padRight;
|
||
|
|
const chartH = height - padTop - padBottom;
|
||
|
|
|
||
|
|
// Determine max value for Y-scaling
|
||
|
|
let maxY = 10;
|
||
|
|
for (let i = 0; i < history.length; i++) {
|
||
|
|
const h = history[i];
|
||
|
|
if (h.sheep > maxY) maxY = h.sheep;
|
||
|
|
if (h.wolves > maxY) maxY = h.wolves;
|
||
|
|
if (h.plants > maxY) maxY = h.plants;
|
||
|
|
}
|
||
|
|
maxY = Math.ceil(maxY * 1.15);
|
||
|
|
|
||
|
|
// Background grid
|
||
|
|
if (showAxes) {
|
||
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
|
||
|
|
ctx.lineWidth = 1;
|
||
|
|
|
||
|
|
// Horizontal grid lines
|
||
|
|
for (let g = 0; g <= 4; g++) {
|
||
|
|
const yVal = Math.round((maxY / 4) * g);
|
||
|
|
const yPos = padTop + chartH - (g / 4) * chartH;
|
||
|
|
ctx.beginPath();
|
||
|
|
ctx.moveTo(padLeft, yPos);
|
||
|
|
ctx.lineTo(padLeft + chartW, yPos);
|
||
|
|
ctx.stroke();
|
||
|
|
|
||
|
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.45)';
|
||
|
|
ctx.font = '10px monospace';
|
||
|
|
ctx.textAlign = 'right';
|
||
|
|
ctx.fillText(yVal.toString(), padLeft - 6, yPos + 3);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const mapX = (idx) => padLeft + (idx / (history.length - 1)) * chartW;
|
||
|
|
const mapY = (val) => padTop + chartH - (val / maxY) * chartH;
|
||
|
|
|
||
|
|
// Draw Line Series helper
|
||
|
|
const drawSeries = (key, color, lineWidth = 2) => {
|
||
|
|
ctx.strokeStyle = color;
|
||
|
|
ctx.lineWidth = lineWidth;
|
||
|
|
ctx.beginPath();
|
||
|
|
|
||
|
|
for (let i = 0; i < history.length; i++) {
|
||
|
|
const x = mapX(i);
|
||
|
|
const y = mapY(history[i][key]);
|
||
|
|
if (i === 0) ctx.moveTo(x, y);
|
||
|
|
else ctx.lineTo(x, y);
|
||
|
|
}
|
||
|
|
ctx.stroke();
|
||
|
|
};
|
||
|
|
|
||
|
|
// Draw series: Plants (green), Sheep (sky blue/white), Wolves (coral red)
|
||
|
|
drawSeries('plants', '#38D39F', showAxes ? 2.5 : 1.5);
|
||
|
|
drawSeries('sheep', '#4CC9F0', showAxes ? 2.5 : 1.5);
|
||
|
|
drawSeries('wolves', '#FF4D6D', showAxes ? 2.5 : 1.5);
|
||
|
|
|
||
|
|
// Chart Legend on full chart
|
||
|
|
if (showAxes) {
|
||
|
|
ctx.font = '11px sans-serif';
|
||
|
|
ctx.textAlign = 'left';
|
||
|
|
|
||
|
|
// Sheep
|
||
|
|
ctx.fillStyle = '#4CC9F0';
|
||
|
|
ctx.fillRect(padLeft + 10, 10, 10, 10);
|
||
|
|
ctx.fillStyle = '#E0E0E0';
|
||
|
|
ctx.fillText('Sheep', padLeft + 25, 19);
|
||
|
|
|
||
|
|
// Wolves
|
||
|
|
ctx.fillStyle = '#FF4D6D';
|
||
|
|
ctx.fillRect(padLeft + 85, 10, 10, 10);
|
||
|
|
ctx.fillStyle = '#E0E0E0';
|
||
|
|
ctx.fillText('Wolves', padLeft + 100, 19);
|
||
|
|
|
||
|
|
// Plants
|
||
|
|
ctx.fillStyle = '#38D39F';
|
||
|
|
ctx.fillRect(padLeft + 165, 10, 10, 10);
|
||
|
|
ctx.fillStyle = '#E0E0E0';
|
||
|
|
ctx.fillText('Plants / Flowers', padLeft + 180, 19);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|