Files
sillytavern-rts-mode/ui/RTSUIController.js
T
2025-08-03 22:35:36 -07:00

575 lines
22 KiB
JavaScript

import { renderExtensionTemplateAsync } from '../../../extensions.js';
import { RTSMapCanvas } from './MapCanvas.js';
import GameStateManager from '../src/GameStateManager.js';
import PresetManager from '../src/PresetManager.js';
import EventManager from '../src/EventManager.js';
import { sendTurn } from '../src/LLMAdapter.js';
import { updateResourcePanel } from './ResourcePanel.js';
export class RTSUIController {
constructor() {
this.isFullscreen = false;
this.originalSheldContent = null;
this.rtsContainer = null;
this.mapCanvas = null;
this.gameLog = [];
this.mapData = null;
this.bindMethods();
}
bindMethods() {
this.handleQuickAction = this.handleQuickAction.bind(this);
this.handleExecuteCommand = this.handleExecuteCommand.bind(this);
this.handleClearCommand = this.handleClearCommand.bind(this);
this.handleMapControls = this.handleMapControls.bind(this);
this.handleMouseMove = this.handleMouseMove.bind(this);
this.handleSelectionChange = this.handleSelectionChange.bind(this);
this.handleNarrativeUpdate = this.handleNarrativeUpdate.bind(this);
this.handleZoneChange = this.handleZoneChange.bind(this);
}
async enterFullscreen() {
if (this.isFullscreen) return;
const sheld = document.getElementById('sheld');
if (sheld) {
this.originalSheldDisplay = sheld.style.display;
sheld.style.display = 'none';
}
await this.createRTSInterface();
this.isFullscreen = true;
document.addEventListener('keydown', this.handleKeydown.bind(this));
}
async exitFullscreen() {
if (!this.isFullscreen) return;
if (this.rtsContainer) {
this.rtsContainer.remove();
this.rtsContainer = null;
}
const sheld = document.getElementById('sheld');
if (sheld) {
sheld.style.display = this.originalSheldDisplay || '';
}
this.isFullscreen = false;
this.mapCanvas = null;
document.removeEventListener('keydown', this.handleKeydown.bind(this));
}
async createRTSInterface() {
try {
const rtsHTML = await renderExtensionTemplateAsync('rts-mode', 'rts-ui');
this.rtsContainer = document.createElement('div');
this.rtsContainer.innerHTML = rtsHTML;
document.body.appendChild(this.rtsContainer);
this.mapCanvas = new RTSMapCanvas('rts-game-map');
this.setupEventListeners();
this.mapCanvas.canvas.addEventListener('selectionChanged', this.handleSelectionChange);
const presetSelect = /** @type {HTMLSelectElement} */ (document.getElementById('rts-preset-select'));
const selectedPreset = presetSelect ? presetSelect.value : '/scripts/extensions/rts-mode/presets/zoo_escape.json';
await PresetManager.loadPreset(selectedPreset);
GameStateManager.reset();
const preset = PresetManager.getPreset();
EventManager.initialize();
// Create quick action buttons after preset is loaded
this.createQuickActionButtons();
const response = await fetch(preset.map);
this.mapData = await response.json();
this.mapCanvas.loadMap(this.mapData);
/* Ensure GameStateManager keeps a reference to the full mapData so that
subsequent AI-driven updates (addOrUpdateMapEntity, etc.) have a
valid entity list to work with. */
GameStateManager.setState({
mapState: {
...(GameStateManager.getState().mapState || {}),
mapData: this.mapData
}
});
this.updateUI();
this.addLogEntry('system', PresetManager.getPrompt('system_initial'));
} catch (error) {
console.error('Error creating RTS interface:', error);
this.addLogEntry('error', `Failed to create RTS interface: ${error.message}`);
throw error;
}
}
setupEventListeners() {
// Command Execution
document.getElementById('rts-execute-btn')?.addEventListener('click', this.handleExecuteCommand);
document.getElementById('rts-clear-btn')?.addEventListener('click', this.handleClearCommand);
// Map Controls
document.getElementById('rts-zoom-in')?.addEventListener('click', () => this.handleMapControls('zoomIn'));
document.getElementById('rts-zoom-out')?.addEventListener('click', () => this.handleMapControls('zoomOut'));
document.getElementById('rts-center-map')?.addEventListener('click', () => this.handleMapControls('center'));
// Command Input Enter Key
const commandInput = document.getElementById('rts-command-input');
if (commandInput) {
commandInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.handleExecuteCommand();
}
});
}
// Narrative and Zone Listeners
document.addEventListener('rts-narrative-update', this.handleNarrativeUpdate);
document.addEventListener('rts-zone-changed', this.handleZoneChange);
document.addEventListener('rts-map-update', this.handleMapUpdate.bind(this));
document.addEventListener('rts-canvas-refresh', this.handleCanvasRefresh.bind(this));
}
handleQuickAction(action) {
const commandInput = /** @type {HTMLInputElement} */ (document.getElementById('rts-command-input'));
if (!commandInput) {
console.error('RTS: Command input not found');
return;
}
try {
const quickActions = PresetManager.getFeature('quickActions');
if (!quickActions || !Array.isArray(quickActions)) {
console.error('RTS: Quick actions not found in preset');
return;
}
const actionTemplate = quickActions.find(a => a.id === action);
if (actionTemplate && actionTemplate.command) {
commandInput.value = actionTemplate.command;
} else {
console.error(`RTS: Action template not found for action: ${action}`);
// Fallback commands
const fallbackCommands = {
'observe': 'Look around carefully to assess the situation.',
'move': 'Move to a specific location.',
'hide': 'Find a hiding spot.',
'interact': 'Interact with something in the environment.'
};
commandInput.value = fallbackCommands[action] || `Perform ${action} action`;
}
commandInput.focus();
commandInput.select();
} catch (error) {
console.error('RTS: Error in handleQuickAction:', error);
}
}
createQuickActionButtons() {
console.log('RTS: Creating quick action buttons...');
const container = document.getElementById('rts-quick-actions');
if (!container) {
console.error('RTS: Quick actions container not found');
return;
}
container.innerHTML = ''; // Clear existing buttons
try {
const quickActions = PresetManager.getFeature('quickActions');
console.log('RTS: Quick actions from preset:', quickActions);
if (!quickActions || !Array.isArray(quickActions)) {
console.warn('RTS: No quick actions found in preset, creating fallback actions');
// Create fallback actions
const fallbackActions = [
{ id: 'observe', label: 'Observe' },
{ id: 'move', label: 'Move' },
{ id: 'hide', label: 'Hide' },
{ id: 'interact', label: 'Interact' }
];
fallbackActions.forEach(action => {
const button = document.createElement('button');
button.id = `rts-${action.id}-btn`;
button.className = 'menu_button rts-action-btn';
button.textContent = action.label;
button.addEventListener('click', () => this.handleQuickAction(action.id));
container.appendChild(button);
console.log(`RTS: Created fallback button: ${action.label}`);
});
console.log(`RTS: Created ${fallbackActions.length} fallback quick action buttons`);
return;
}
quickActions.forEach(action => {
const button = document.createElement('button');
button.id = `rts-${action.id}-btn`;
button.className = 'menu_button rts-action-btn';
button.textContent = action.label;
button.title = action.command || action.label;
button.addEventListener('click', () => this.handleQuickAction(action.id));
container.appendChild(button);
console.log(`RTS: Created preset button: ${action.label}`);
});
console.log(`RTS: Created ${quickActions.length} preset quick action buttons`);
} catch (error) {
console.error('RTS: Error creating quick action buttons:', error);
}
}
async handleExecuteCommand() {
const commandInput = /** @type {HTMLInputElement} */ (document.getElementById('rts-command-input'));
if (!commandInput) return;
const command = commandInput.value.trim();
if (!command) return;
this.addLogEntry('action', command);
commandInput.value = '';
const executeBtn = /** @type {HTMLButtonElement} */ (document.getElementById('rts-execute-btn'));
if (executeBtn) {
executeBtn.disabled = true;
executeBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Processing...';
}
try {
// This is where the command would be sent to the LLM
await sendTurn(command);
// The UI will now be updated by the 'rts-narrative-update' event listener
} catch (error) {
console.error('Failed to execute RTS command:', error);
this.addLogEntry('error', `Failed to execute command: ${error.message}`);
} finally {
if (executeBtn) {
executeBtn.disabled = false;
executeBtn.innerHTML = '<i class="fa-solid fa-play"></i> Take Action';
}
}
}
handleClearCommand() {
const commandInput = /** @type {HTMLInputElement} */ (document.getElementById('rts-command-input'));
if (commandInput) {
commandInput.value = '';
commandInput.focus();
}
}
handleMapControls(action) {
if (!this.mapCanvas) return;
switch (action) {
case 'zoomIn': this.mapCanvas.zoomIn(); break;
case 'zoomOut': this.mapCanvas.zoomOut(); break;
case 'center': this.mapCanvas.centerMap(); break;
}
}
handleMouseMove(e) {
if (this.mapCanvas) {
this.mapCanvas.handleMouseMove(e);
}
}
handleKeydown(e) {
if (e.key === 'Escape') {
this.exitFullscreen();
}
}
updateUI() {
if (!this.mapData) return;
// Update the new status panel with current game state
const gameState = GameStateManager.getState();
try {
updateResourcePanel(gameState);
} catch (error) {
console.warn('RTS: Error updating resource panel from RTSUIController:', error);
}
// Keep the existing zoo-specific updates
this.updateZooStatusDisplay();
this.updateEscapedAnimalsDisplay();
this.updateIncidentsDisplay();
this.updatePeopleStatusDisplay();
this.updateThreatDisplay();
}
handleSelectionChange() {
this.updateEscapedAnimalsDisplay();
this.updatePeopleStatusDisplay();
}
handleEntitySelection(entity) {
this.mapCanvas.clearSelection();
this.mapCanvas.selectElement({ type: 'entity', data: entity });
this.updateEscapedAnimalsDisplay();
}
updateZooStatusDisplay() {
const state = GameStateManager.getState();
const playerCount = (state.entities || []).filter(e => e.type === 'player').length;
const gateCount = this.mapData.map.tiles.flat().filter(t => t === 3).length;
this.updateElementText('rts-alert-level', (state.threatLevel || 'low').toUpperCase());
this.updateElementText('rts-survivors', playerCount > 0 ? 1 : 0); // Assuming 1 player
this.updateElementText('rts-casualties', (state.casualties && state.casualties.total) || 0);
this.updateElementText('rts-breached', `${gateCount} (Main Gate)`);
}
updateEscapedAnimalsDisplay() {
const container = document.getElementById('rts-escaped-animals');
if (!container) return;
container.innerHTML = '';
const state = GameStateManager.getState();
if (state.entities) {
state.entities.forEach(entity => {
if (entity.type !== 'player' && entity.type !== 'guard' && entity.status === 'escaped') {
const element = document.createElement('div');
element.className = `rts-animal-item rts-escaped ${this.mapCanvas.selectedEntities.includes(entity) ? 'selected' : ''}`;
element.innerHTML = `
<i class="fa-solid fa-paw" style="color: #dc2626;"></i>
<span class="rts-animal-name">${entity.name || entity.type.replace('_', ' ')}</span>
<span class="rts-animal-location">${entity.location || 'Roaming'}</span>
<span class="rts-animal-status rts-status-hunting">${entity.mood || 'Hunting'}</span>
`;
element.addEventListener('click', () => this.handleEntitySelection(entity));
container.appendChild(element);
}
});
}
}
updateIncidentsDisplay() {
const container = document.getElementById('rts-active-incidents');
if (!container) return;
container.innerHTML = ''; // Clear previous incidents
const state = GameStateManager.getState();
if (state.incidents) {
state.incidents.forEach(incident => {
const element = document.createElement('div');
element.className = 'rts-incident-item';
element.innerHTML = `
<i class="fa-solid fa-fire" style="color: #f59e0b;"></i>
<span class="rts-incident-desc">${incident.description}</span>
<span class="rts-incident-location">${incident.location}</span>
`;
container.appendChild(element);
});
}
}
updatePeopleStatusDisplay() {
const container = document.getElementById('rts-people-status');
if (!container) return;
container.innerHTML = ''; // Clear previous people
const state = GameStateManager.getState();
if (state.mapState && state.mapState.mapData && state.mapState.mapData.entities) {
// Show visitors, staff, keepers, veterinarians - no guards
const people = state.mapState.mapData.entities.filter(e =>
['visitor', 'staff', 'keeper', 'veterinarian'].includes(e.type)
);
people.forEach((person, index) => {
const element = document.createElement('div');
element.className = 'rts-person-item';
// Choose appropriate icon based on type
let icon = 'fa-user';
let color = '#6b7280';
if (person.type === 'visitor') {
icon = 'fa-user';
color = '#3b82f6';
} else if (person.type === 'staff') {
icon = 'fa-user-tie';
color = '#10b981';
} else if (person.type === 'keeper') {
icon = 'fa-user-check';
color = '#f59e0b';
} else if (person.type === 'veterinarian') {
icon = 'fa-user-doctor';
color = '#8b5cf6';
}
// Add escape status for visitors
let escapeStatus = '';
if (person.type === 'visitor') {
const escapeChance = this.calculateEscapeChance(person);
escapeStatus = `<span class="rts-escape-status rts-escape-${escapeChance.level}">${escapeChance.text}</span>`;
}
element.innerHTML = `
<i class="fa-solid ${icon}" style="color: ${color};"></i>
<span class="rts-person-name">${person.name || `${person.type} ${index + 1}`}</span>
<span class="rts-person-status rts-status-${person.status || 'active'}">${person.status || 'Active'}</span>
${escapeStatus}
<span class="rts-person-location">@ (${person.x}, ${person.y})</span>
`;
element.addEventListener('click', () => this.handleEntitySelection(person));
container.appendChild(element);
});
}
}
updateElementText(id, value) {
const element = document.getElementById(id);
if (element) {
element.textContent = value;
}
}
calculateEscapeChance(person) {
const state = GameStateManager.getState();
const threatLevel = state.threatLevel || 'none';
const playerPos = state.mapState?.playerPosition || { x: 0, y: 0 };
// Calculate distance from player
const distance = Math.sqrt(
Math.pow(person.x - playerPos.x, 2) +
Math.pow(person.y - playerPos.y, 2)
);
// Base escape chance based on threat level
let escapeScore = 0;
switch (threatLevel) {
case 'none': escapeScore = 95; break;
case 'low': escapeScore = 75; break;
case 'medium': escapeScore = 50; break;
case 'high': escapeScore = 25; break;
case 'extreme': escapeScore = 5; break;
default: escapeScore = 50;
}
// Adjust based on distance from player (closer = safer)
if (distance <= 3) escapeScore += 20;
else if (distance >= 10) escapeScore -= 30;
// Adjust based on person status
if (person.status === 'injured') escapeScore -= 40;
else if (person.status === 'panicked') escapeScore -= 20;
else if (person.status === 'hiding') escapeScore += 10;
// Clamp between 5-95
escapeScore = Math.max(5, Math.min(95, escapeScore));
// Determine level and text
if (escapeScore >= 80) {
return { level: 'high', text: 'Safe' };
} else if (escapeScore >= 60) {
return { level: 'medium', text: 'At Risk' };
} else if (escapeScore >= 30) {
return { level: 'low', text: 'Danger' };
} else {
return { level: 'critical', text: 'Critical' };
}
}
addLogEntry(type, message) {
const gameLog = document.getElementById('rts-game-log');
if (!gameLog) return;
const logEntry = document.createElement('div');
logEntry.className = `rts-log-entry rts-log-${type}`;
logEntry.innerHTML = `<span class="rts-log-message">${message}</span>`;
gameLog.appendChild(logEntry);
gameLog.scrollTop = gameLog.scrollHeight;
while (gameLog.children.length > 100) {
gameLog.removeChild(gameLog.firstChild);
}
}
handleNarrativeUpdate(event) {
const { type, message, entityUpdates } = event.detail;
this.addLogEntry(type, message);
// Handle entity updates and refresh map
if (entityUpdates && entityUpdates.length > 0) {
this.refreshMapEntities();
}
this.updateUI();
}
refreshMapEntities() {
if (this.mapCanvas && this.mapData) {
// Get updated map data from GameStateManager
const gameState = GameStateManager.getState();
if (gameState.mapState && gameState.mapState.mapData) {
this.mapData = gameState.mapState.mapData;
this.mapCanvas.loadMap(this.mapData);
console.log('RTS: Map entities refreshed with', this.mapData?.map?.entities?.length || 0, 'entities');
}
}
}
handleZoneChange(event) {
const zone = event.detail;
this.addLogEntry('system', `Entered ${zone.name}.`);
this.updateThreatDisplay();
}
handleMapUpdate(event) {
console.log('RTS: Map update event received:', event.detail);
this.refreshMapEntities();
this.updateUI();
}
handleCanvasRefresh(event) {
console.log('RTS: Canvas refresh event received:', event.detail);
if (this.mapCanvas) {
// Force the map canvas to redraw
this.mapCanvas.dirty = true;
this.refreshMapEntities();
}
this.updateUI();
}
updateThreatDisplay() {
const state = GameStateManager.getState();
const zone = EventManager.zones.find(z => z.id === state.currentZone);
const zoneName = zone ? zone.name : 'Unknown Zone';
this.updateElementText('rts-current-zone', zoneName);
this.updateElementText('rts-threat-level', state.threatLevel);
}
isActive() {
return this.isFullscreen;
}
toggle() {
if (this.isFullscreen) {
this.exitFullscreen();
} else {
this.enterFullscreen();
}
}
async loadPreset(presetPath) {
await PresetManager.loadPreset(presetPath);
GameStateManager.reset();
if (this.isFullscreen) {
// Recreate quick action buttons for the new preset
this.createQuickActionButtons();
// If the UI is already open, we need to recreate it to reflect the new preset
await this.exitFullscreen();
await this.enterFullscreen();
}
}
}
export const rtsUI = new RTSUIController();
document.addEventListener('rts-state-updated', () => {
if (rtsUI.isActive()) {
rtsUI.updateUI();
}
});