diff --git a/button.html b/button.html new file mode 100644 index 0000000..4d12976 --- /dev/null +++ b/button.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/default-ui.png b/default-ui.png new file mode 100644 index 0000000..e610f99 Binary files /dev/null and b/default-ui.png differ diff --git a/html/rts-ui.html b/html/rts-ui.html new file mode 100644 index 0000000..4417f46 --- /dev/null +++ b/html/rts-ui.html @@ -0,0 +1,162 @@ +
+ +
+ + +
+ +
+
+

Game Status

+
+ + +
+

Resources

+
+
+ + Gold: 100 +
+
+ + Wood: 50 +
+
+ + Stone: 25 +
+
+ + Food: 75 +
+
+
+ + +
+

Army

+
+
+ + Warriors: 5 +
+
+ + Archers: 3 +
+
+ + Cavalry: 2 +
+
+
+ + +
+

Buildings

+
+
+ + Town Hall: Level 1 +
+
+ + Barracks: 1 +
+
+ + Storage: 1 +
+
+
+
+ + +
+
+

Battle Map

+
+ + + +
+
+
+ +
+ +
+
+ +
+ + +
+
+

Command Center

+
+ + +
+

Quick Actions

+
+ + + + +
+
+ + +
+

Command Input

+
+ +
+ + +
+
+
+ + +
+

Battle Log

+
+
+ [Turn 1] + Welcome to RTS Mode! Your settlement awaits your commands. +
+
+
+
+
+
\ No newline at end of file diff --git a/html/settings.html b/html/settings.html new file mode 100644 index 0000000..35ae775 --- /dev/null +++ b/html/settings.html @@ -0,0 +1,9 @@ + + + + RTS Chat Mode Settings + + +

RTS Chat Mode Settings

+ + \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..d615ecd --- /dev/null +++ b/index.js @@ -0,0 +1,170 @@ +console.log('RTS-MODE: index.js script loading...'); + +// Test if jQuery is available +console.log('RTS-MODE: jQuery available:', typeof jQuery !== 'undefined'); +console.log('RTS-MODE: $ available:', typeof $ !== 'undefined'); + +import { renderExtensionTemplateAsync } from '../../extensions.js'; +import { eventSource, event_types } from '../../events.js'; +import { createMapCanvas } from './ui/MapCanvas.js'; +import { createResourcePanel } from './ui/ResourcePanel.js'; +import { rtsUI } from './ui/RTSUIController.js'; +import GameStateManager from './src/GameStateManager.js'; +import { sendTurn } from './src/LLMAdapter.js'; +import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js'; +import { SlashCommand } from '../../slash-commands/SlashCommand.js'; +import { ARGUMENT_TYPE, SlashCommandArgument } from '../../slash-commands/SlashCommandArgument.js'; + +console.log('RTS-MODE: All imports successful'); + +const extensionId = 'rts-mode'; +const extensionName = 'RTS Chat Mode'; + +let root; +let topButton; +let uiMounted = false; + +async function addTopBarButton() { + console.log('RTS Chat Mode: Adding top bar button...'); + if (topButton) { + console.warn('RTS Chat Mode: Top button already exists, skipping creation.'); + return; + } + topButton = $(await renderExtensionTemplateAsync('rts-mode', 'button')); + $('#top-settings-holder').append(topButton); + + // Set up direct RTS UI toggle functionality + topButton.on('click', async (e) => { + e.stopPropagation(); + console.log('RTS Mode button clicked'); + + try { + // Toggle between fullscreen RTS UI and normal SillyTavern UI + if (rtsUI.isActive()) { + await rtsUI.exitFullscreen(); + // Update button to show we're back to normal mode + topButton.find('i').removeClass('fa-eye-slash').addClass('fa-chess-board'); + topButton.attr('title', 'Toggle RTS UI').attr('data-i18n', '[title]Toggle RTS UI'); + console.log('RTS UI hidden, returned to SillyTavern interface'); + } else { + await rtsUI.enterFullscreen(); + // Update button to show we're in fullscreen mode + topButton.find('i').removeClass('fa-chess-board').addClass('fa-eye-slash'); + topButton.attr('title', 'Hide RTS UI').attr('data-i18n', '[title]Hide RTS UI'); + console.log('RTS UI activated'); + } + } catch (error) { + console.error('Error toggling RTS UI:', error); + } + }); +} + +function mountUI() { + if (!document.getElementById('rts-mode-root')) { + root = document.createElement('div'); + root.id = 'rts-mode-root'; + document.body.appendChild(root); + + createMapCanvas(root); + createResourcePanel(root); + + console.log('RTS Chat Mode UI mounted.'); + uiMounted = true; + + // Update button icon to show UI is active + topButton?.find('i').removeClass('fa-chess-board').addClass('fa-eye-slash'); + topButton?.attr('title', 'Hide RTS UI').attr('data-i18n', '[title]Hide RTS UI'); + } +} + +function unmountUI() { + if (root) { + root.remove(); + root = null; + console.log('RTS Chat Mode UI unmounted.'); + uiMounted = false; + + // Update button icon to show UI is hidden + topButton?.find('i').removeClass('fa-eye-slash').addClass('fa-chess-board'); + topButton?.attr('title', 'Toggle RTS UI').attr('data-i18n', '[title]Toggle RTS UI'); + } +} + +function onRtsStartCommand() { + console.log('RTS Start command executed.'); + GameStateManager.reset(); + + // If RTS UI is active, update it + if (rtsUI.isActive()) { + rtsUI.updateUI(); + rtsUI.addLogEntry('system', 'Game state reset. New campaign begins!'); + } + + return ''; +} + +function onRtsCmdCommand(args, value) { + console.log('RTS Command executed with args:', args, 'value:', value); + if (value) { + // If RTS UI is active, add to log + if (rtsUI.isActive()) { + rtsUI.addLogEntry('action', value); + } + + sendTurn(value); + } + return ''; +} + +async function onRtsUICommand() { + console.log('RTS UI toggle command executed.'); + + if (rtsUI.isActive()) { + await rtsUI.exitFullscreen(); + // Update button state to match + if (topButton) { + topButton.find('i').removeClass('fa-eye-slash').addClass('fa-chess-board'); + topButton.attr('title', 'Toggle RTS UI').attr('data-i18n', '[title]Toggle RTS UI'); + } + return 'RTS UI hidden. Returned to normal SillyTavern interface.'; + } else { + await rtsUI.enterFullscreen(); + // Update button state to match + if (topButton) { + topButton.find('i').removeClass('fa-chess-board').addClass('fa-eye-slash'); + topButton.attr('title', 'Hide RTS UI').attr('data-i18n', '[title]Hide RTS UI'); + } + return 'RTS UI activated. Use ESC key or /rts-ui to return to normal interface.'; + } +} + +jQuery(async function() { + console.log('RTS Chat Mode: jQuery ready, initializing extension...'); + + await addTopBarButton(); + + // Register slash commands + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'rts-start', + callback: onRtsStartCommand, + helpString: 'Resets the RTS game state.', + })); + + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'rts-cmd', + callback: onRtsCmdCommand, + helpString: 'Sends a command to the RTS game master.', + unnamedArgumentList: [ + new SlashCommandArgument('command', [ARGUMENT_TYPE.STRING], true), + ], + })); + + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'rts-ui', + callback: onRtsUICommand, + aliases: ['rts-toggle', 'rts-fullscreen'], + helpString: 'Toggles the full-screen RTS interface.', + })); + + console.log('RTS Chat Mode: Extension initialized successfully'); +}); \ No newline at end of file diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..e43db45 --- /dev/null +++ b/manifest.json @@ -0,0 +1,11 @@ +{ + "display_name": "RTS Chat Mode", + "loading_order": 10, + "requires": [], + "optional": [], + "js": "index.js", + "css": "style.css", + "author": "Community", + "version": "0.0.1", + "homePage": "" +} \ No newline at end of file diff --git a/rts-ui.html b/rts-ui.html new file mode 100644 index 0000000..4417f46 --- /dev/null +++ b/rts-ui.html @@ -0,0 +1,162 @@ +
+ +
+ + +
+ +
+
+

Game Status

+
+ + +
+

Resources

+
+
+ + Gold: 100 +
+
+ + Wood: 50 +
+
+ + Stone: 25 +
+
+ + Food: 75 +
+
+
+ + +
+

Army

+
+
+ + Warriors: 5 +
+
+ + Archers: 3 +
+
+ + Cavalry: 2 +
+
+
+ + +
+

Buildings

+
+
+ + Town Hall: Level 1 +
+
+ + Barracks: 1 +
+
+ + Storage: 1 +
+
+
+
+ + +
+
+

Battle Map

+
+ + + +
+
+
+ +
+ +
+
+ +
+ + +
+
+

Command Center

+
+ + +
+

Quick Actions

+
+ + + + +
+
+ + +
+

Command Input

+
+ +
+ + +
+
+
+ + +
+

Battle Log

+
+
+ [Turn 1] + Welcome to RTS Mode! Your settlement awaits your commands. +
+
+
+
+
+
\ No newline at end of file diff --git a/rts-ui.png b/rts-ui.png new file mode 100644 index 0000000..77bdae2 Binary files /dev/null and b/rts-ui.png differ diff --git a/src/GameStateManager.js b/src/GameStateManager.js new file mode 100644 index 0000000..8bd4705 --- /dev/null +++ b/src/GameStateManager.js @@ -0,0 +1,36 @@ +const defaultState = { + turn: 1, + map: [], + units: [], + resources: { gold: 0, wood: 0 }, + log: [], +}; + +class GameStateManager { + constructor() { + this.state = { ...defaultState }; + } + + getState() { + return this.state; + } + + applyDiff(diff) { + // For now, just shallow merge the diff. + Object.assign(this.state, diff); + this.state.log.push({ turn: this.state.turn, diff }); + console.log('RTS Game State Updated:', this.state); + } + + reset() { + this.state = { ...defaultState, log: [] }; + console.log('RTS Game State Reset.'); + } + + toCompressedJSON() { + return JSON.stringify(this.state); + } +} + +const instance = new GameStateManager(); +export default instance; \ No newline at end of file diff --git a/src/LLMAdapter.js b/src/LLMAdapter.js new file mode 100644 index 0000000..1c995b5 --- /dev/null +++ b/src/LLMAdapter.js @@ -0,0 +1,54 @@ +import GameStateManager from './GameStateManager.js'; +import { buildPrompt } from './PromptCompressor.js'; + +/** + * Extracts the first JSON code block from a string. + * @param {string} text The text to search. + * @returns {object|null} The parsed JSON object or null if not found. + */ +function extractJson(text) { + const match = /```json\n([\s\S]+?)\n```/.exec(text); + if (match && match[1]) { + try { + return JSON.parse(match[1]); + } catch (error) { + console.error('RTS-Mode: Failed to parse JSON from LLM response.', error); + return null; + } + } + return null; +} + +/** + * Sends the user's command to the LLM and processes the turn. + * @param {string} userCmd The user's command. + */ +export async function sendTurn(userCmd) { + try { + const stateJSON = GameStateManager.toCompressedJSON(); + const prompt = buildPrompt(stateJSON, userCmd); + + // Using SillyTavern's built-in LLM broker + const reply = await window.LLMBroker.generate(prompt); + + if (!reply) { + throw new Error('LLM returned an empty response.'); + } + + const diff = extractJson(reply); + if (diff) { + GameStateManager.applyDiff(diff); + // For now, log the narrative part to the console. + const narrative = reply.replace(/```json\n[\s\S]+?\n```/, '').trim(); + console.log('RTS Narrative:', narrative); + } else { + console.warn('RTS-Mode: No valid JSON diff found in LLM response.'); + // Log the raw reply for debugging. + console.log('Raw LLM Response:', reply); + } + } catch (error) { + console.error('RTS-Mode: Error during sendTurn:', error); + // Optionally, display an alert to the user. + alert(`RTS-Mode Error: ${error.message}`); + } +} \ No newline at end of file diff --git a/src/PromptCompressor.js b/src/PromptCompressor.js new file mode 100644 index 0000000..d789d94 --- /dev/null +++ b/src/PromptCompressor.js @@ -0,0 +1,9 @@ +/** + * Builds a prompt for the LLM based on the current game state and user command. + * @param {string} stateJSON - The compressed JSON string of the game state. + * @param {string} userCmd - The user's command. + * @returns {string} The formatted prompt. + */ +export function buildPrompt(stateJSON, userCmd) { + return `${stateJSON}\n${userCmd}\nRespond with JSON diff + narrative.`; +} \ No newline at end of file diff --git a/style.css b/style.css new file mode 100644 index 0000000..0a4590f --- /dev/null +++ b/style.css @@ -0,0 +1,336 @@ +/* RTS Mode Full UI Styles */ + +#rts-main-container { + position: fixed; + top: var(--topBarBlockSize); + left: 0; + right: 0; + bottom: 0; + background: inherit; + color: inherit; + z-index: 1000; + display: flex; + flex-direction: column; +} + +#rts-header { + height: 20px; + background: var(--SmartThemeBlurTintColor); + cursor: move; + display: flex; + align-items: center; + justify-content: center; + border-bottom: 1px solid var(--SmartThemeBorderColor); +} + +#rts-game-area { + flex: 1; + min-height: 0; + gap: 0; + padding: 0; +} + +/* Side Panels - Match SillyTavern's panel styling */ +.rts-panel { + width: calc((100vw - var(--sheldWidth) - 2px) / 2); + width: calc((100dvw - var(--sheldWidth) - 2px) / 2); + min-width: 280px; + background: transparent; + border: none; + border-radius: 0; + padding: 0; + min-height: 0; + position: relative; + overflow-y: auto; + max-height: calc(100vh - var(--topBarBlockSize) - 20px); + max-height: calc(100dvh - var(--topBarBlockSize) - 20px); +} + +.rts-panel-header { + background: var(--SmartThemeBlurTintColor); + padding: 12px; + border-bottom: 1px solid var(--SmartThemeBorderColor); + border-radius: 6px 6px 0 0; +} + +.rts-panel-header h3 { + margin: 0; + font-size: 16px; + font-weight: 600; +} + +.rts-section { + padding: 15px; + margin: 10px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + backdrop-filter: blur(10px); +} + +.rts-section:last-child { + border-bottom: 1px solid var(--SmartThemeBorderColor); +} + +.rts-section-title { + margin: 0 0 10px 0; + font-size: 14px; + font-weight: 600; + color: inherit; + opacity: 0.9; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Resources */ +.rts-resources-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.rts-resource-item { + display: flex; + align-items: center; + gap: 6px; + padding: 6px; + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + font-size: 12px; + color: inherit; +} + +.rts-resource-item i { + opacity: 0.8; + width: 16px; +} + +/* Units and Buildings */ +.rts-units-list, .rts-buildings-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.rts-unit-item, .rts-building-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px; + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + font-size: 12px; + color: inherit; +} + +.rts-unit-item i, .rts-building-item i { + opacity: 0.8; + width: 16px; +} + +/* Map Container - Match SillyTavern's main content area */ +#rts-map-container { + background: var(--SmartThemeBodyColor); + border: none; + border-radius: 0; + min-height: 0; + width: var(--sheldWidth); + margin: 0 auto; + position: relative; +} + +.rts-map-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px; + margin: 10px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + backdrop-filter: blur(10px); +} + +.rts-map-header h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: inherit; + opacity: 0.9; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.rts-map-controls { + display: flex; + gap: 4px; +} + +.rts-map-wrapper { + position: relative; + background: var(--SmartThemeBodyColor); + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; +} + +#rts-game-map { + border: 1px solid var(--SmartThemeBorderColor); + background: #1a2332; + max-width: 100%; + max-height: 100%; +} + +.rts-map-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; +} + +.rts-map-footer { + padding: 15px; + margin: 10px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + backdrop-filter: blur(10px); + display: flex; + justify-content: center; +} + +.rts-turn-info { + display: flex; + gap: 12px; + font-size: 12px; + color: var(--SmartThemeQuoteColor); +} + +.rts-separator { + opacity: 0.5; +} + +/* Quick Actions */ +.rts-quick-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; +} + +.rts-quick-actions .menu_button { + font-size: 11px; + padding: 6px; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + text-align: center; +} + +.rts-quick-actions .menu_button i { + font-size: 14px; +} + +/* Command Input */ +.rts-command-input-area { + display: flex; + flex-direction: column; + gap: 8px; + height: 100%; +} + +#rts-command-input { + flex: 1; + resize: none; + min-height: 80px; +} + +.rts-command-controls { + display: flex; + gap: 6px; +} + +#rts-execute-btn { + flex: 1; +} + +/* Game Log */ +.rts-game-log { + max-height: 200px; + overflow-y: auto; + background: rgba(0, 0, 0, 0.2); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + padding: 8px; +} + +.rts-log-entry { + margin-bottom: 8px; + font-size: 12px; + line-height: 1.4; +} + +.rts-log-entry:last-child { + margin-bottom: 0; +} + +.rts-log-timestamp { + color: var(--SmartThemeQuoteColor); + opacity: 0.7; + font-weight: 500; +} + +.rts-log-message { + margin-left: 8px; +} + +.rts-log-system .rts-log-message { + color: var(--SmartThemeFastUIBGColor); +} + +.rts-log-action .rts-log-message { + color: var(--SmartThemeQuoteColor); +} + +.rts-log-result .rts-log-message { + color: #4CAF50; +} + +.rts-log-error .rts-log-message { + color: #f44336; +} + +/* Legacy small UI styles for when RTS UI is not full-screen */ +#rts-mode-root { + padding: 8px; + background: var(--SmartThemeBodyColor); + color: var(--SmartThemeQuoteColor); + display: flex; + gap: 12px; +} + +#rts-map-canvas { + border: 1px solid var(--SmartThemeBorderColor); + max-width: 100%; + max-height: 100%; +} + +#rts-resource-panel { + width: 200px; + background: var(--SmartThemeEmColor); + padding: 8px; + border: 1px solid var(--SmartThemeBorderColor); +} + +#rts-resource-panel ul { + list-style: none; + padding: 0; + margin: 0; +} + +#rts-resource-panel li { + margin-bottom: 4px; +} \ No newline at end of file diff --git a/ui/MapCanvas.js b/ui/MapCanvas.js new file mode 100644 index 0000000..cf4485f --- /dev/null +++ b/ui/MapCanvas.js @@ -0,0 +1,318 @@ +/** + * Enhanced map canvas for RTS mode with terrain, units, and buildings + */ +export class RTSMapCanvas { + constructor(canvasId = 'rts-game-map') { + this.canvas = document.getElementById(canvasId); + if (!this.canvas) { + this.canvas = document.createElement('canvas'); + this.canvas.id = canvasId; + } + + this.ctx = this.canvas.getContext('2d'); + this.zoom = 1; + this.offsetX = 0; + this.offsetY = 0; + this.selectedUnit = null; + this.selectedBuilding = null; + + this.initializeCanvas(); + this.setupEventListeners(); + } + + initializeCanvas() { + this.canvas.width = 800; + this.canvas.height = 600; + this.drawTerrain(); + this.drawGrid(); + } + + setupEventListeners() { + this.canvas.addEventListener('click', (e) => this.handleClick(e)); + this.canvas.addEventListener('mousemove', (e) => this.handleMouseMove(e)); + } + + drawTerrain() { + const ctx = this.ctx; + + // Clear canvas + ctx.fillStyle = '#1a2332'; + ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + + // Draw terrain features + this.drawForests(); + this.drawMountains(); + this.drawRivers(); + this.drawSettlement(); + } + + drawGrid() { + const ctx = this.ctx; + ctx.strokeStyle = '#334155'; + ctx.lineWidth = 0.5; + ctx.globalAlpha = 0.3; + + const gridSize = 40; + + // Vertical lines + for (let x = 0; x <= this.canvas.width; x += gridSize) { + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, this.canvas.height); + ctx.stroke(); + } + + // Horizontal lines + for (let y = 0; y <= this.canvas.height; y += gridSize) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(this.canvas.width, y); + ctx.stroke(); + } + + ctx.globalAlpha = 1; + } + + drawForests() { + const ctx = this.ctx; + ctx.fillStyle = '#22c55e'; + + // Forest patches + const forests = [ + { x: 100, y: 100, radius: 60 }, + { x: 600, y: 150, radius: 80 }, + { x: 200, y: 400, radius: 50 }, + { x: 650, y: 450, radius: 70 } + ]; + + forests.forEach(forest => { + ctx.beginPath(); + ctx.arc(forest.x, forest.y, forest.radius, 0, Math.PI * 2); + ctx.fill(); + + // Add tree symbols + for (let i = 0; i < 8; i++) { + const angle = (i / 8) * Math.PI * 2; + const treeX = forest.x + Math.cos(angle) * (forest.radius * 0.7); + const treeY = forest.y + Math.sin(angle) * (forest.radius * 0.7); + + ctx.fillStyle = '#16a34a'; + ctx.fillRect(treeX - 2, treeY - 2, 4, 4); + } + ctx.fillStyle = '#22c55e'; + }); + } + + drawMountains() { + const ctx = this.ctx; + ctx.fillStyle = '#64748b'; + + const mountains = [ + { x: 300, y: 80, width: 100, height: 80 }, + { x: 500, y: 300, width: 120, height: 100 } + ]; + + mountains.forEach(mountain => { + ctx.beginPath(); + ctx.moveTo(mountain.x, mountain.y + mountain.height); + ctx.lineTo(mountain.x + mountain.width / 2, mountain.y); + ctx.lineTo(mountain.x + mountain.width, mountain.y + mountain.height); + ctx.closePath(); + ctx.fill(); + }); + } + + drawRivers() { + const ctx = this.ctx; + ctx.strokeStyle = '#3b82f6'; + ctx.lineWidth = 8; + + ctx.beginPath(); + ctx.moveTo(0, 200); + ctx.quadraticCurveTo(200, 180, 400, 220); + ctx.quadraticCurveTo(600, 260, 800, 240); + ctx.stroke(); + } + + drawSettlement() { + const ctx = this.ctx; + const centerX = 400; + const centerY = 350; + + // Town hall (main building) + ctx.fillStyle = '#8b5cf6'; + ctx.fillRect(centerX - 20, centerY - 20, 40, 40); + + // Surrounding buildings + const buildings = [ + { x: centerX - 60, y: centerY - 10, type: 'barracks', color: '#ef4444' }, + { x: centerX + 40, y: centerY - 10, type: 'storage', color: '#f59e0b' }, + { x: centerX - 10, y: centerY - 60, type: 'farm', color: '#22c55e' }, + { x: centerX - 10, y: centerY + 40, type: 'market', color: '#3b82f6' } + ]; + + buildings.forEach(building => { + ctx.fillStyle = building.color; + ctx.fillRect(building.x - 15, building.y - 15, 30, 30); + }); + + // Draw units around settlement + this.drawUnits(); + } + + drawUnits() { + const ctx = this.ctx; + const centerX = 400; + const centerY = 350; + + const units = [ + { x: centerX - 80, y: centerY + 20, type: 'warrior' }, + { x: centerX - 70, y: centerY + 35, type: 'warrior' }, + { x: centerX + 60, y: centerY + 25, type: 'archer' }, + { x: centerX + 20, y: centerY - 80, type: 'cavalry' } + ]; + + units.forEach(unit => { + if (unit.type === 'warrior') { + ctx.fillStyle = '#dc2626'; + ctx.beginPath(); + ctx.arc(unit.x, unit.y, 6, 0, Math.PI * 2); + ctx.fill(); + } else if (unit.type === 'archer') { + ctx.fillStyle = '#059669'; + ctx.fillRect(unit.x - 5, unit.y - 5, 10, 10); + } else if (unit.type === 'cavalry') { + ctx.fillStyle = '#7c3aed'; + ctx.beginPath(); + ctx.moveTo(unit.x, unit.y - 8); + ctx.lineTo(unit.x - 6, unit.y + 4); + ctx.lineTo(unit.x + 6, unit.y + 4); + ctx.closePath(); + ctx.fill(); + } + }); + } + + handleClick(e) { + const rect = this.canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + console.log(`Map clicked at: ${x}, ${y}`); + + // Check if clicked on a unit or building + // This is a simplified example - in a real game you'd have proper hit detection + this.showMapInfo(x, y); + } + + handleMouseMove(e) { + const rect = this.canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // Update cursor based on what's under it + this.canvas.style.cursor = 'default'; + + // Check for interactive elements + if (this.isNearSettlement(x, y)) { + this.canvas.style.cursor = 'pointer'; + } + } + + isNearSettlement(x, y) { + const centerX = 400; + const centerY = 350; + const distance = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2); + return distance < 100; + } + + showMapInfo(x, y) { + const overlay = document.getElementById('rts-map-overlay'); + if (overlay) { + if (this.isNearSettlement(x, y)) { + overlay.innerHTML = ` +
+ Main Settlement
+ Population: 150
+ Defense: High
+ +
+ `; + } else { + overlay.innerHTML = ''; + } + } + } + + zoomIn() { + this.zoom = Math.min(this.zoom * 1.2, 3); + this.redraw(); + } + + zoomOut() { + this.zoom = Math.max(this.zoom / 1.2, 0.5); + this.redraw(); + } + + centerMap() { + this.offsetX = 0; + this.offsetY = 0; + this.zoom = 1; + this.redraw(); + } + + redraw() { + this.ctx.save(); + this.ctx.scale(this.zoom, this.zoom); + this.ctx.translate(this.offsetX, this.offsetY); + + this.drawTerrain(); + this.drawGrid(); + + this.ctx.restore(); + } + + updateFromGameState(gameState) { + // Update the map based on game state changes + console.log('Updating map from game state:', gameState); + this.redraw(); + } +} + +/** + * Legacy function for backward compatibility + * @param {HTMLElement} rootEl - The root element to append to (optional). + * @returns {HTMLCanvasElement} + */ +export function createMapCanvas(rootEl) { + const canvas = document.createElement('canvas'); + canvas.id = 'rts-map-canvas'; + canvas.width = 640; + canvas.height = 640; + + const ctx = canvas.getContext('2d'); + ctx.strokeStyle = '#444'; + ctx.lineWidth = 1; + + for (let i = 0; i <= canvas.width; i += 40) { + ctx.beginPath(); + ctx.moveTo(i, 0); + ctx.lineTo(i, canvas.height); + ctx.stroke(); + } + + for (let i = 0; i <= canvas.height; i += 40) { + ctx.beginPath(); + ctx.moveTo(0, i); + ctx.lineTo(canvas.width, i); + ctx.stroke(); + } + + if (rootEl) { + rootEl.appendChild(canvas); + } + + return canvas; +} \ No newline at end of file diff --git a/ui/RTSUIController.js b/ui/RTSUIController.js new file mode 100644 index 0000000..34db371 --- /dev/null +++ b/ui/RTSUIController.js @@ -0,0 +1,378 @@ +import { renderExtensionTemplateAsync } from '../../../extensions.js'; +import { RTSMapCanvas } from './MapCanvas.js'; +import GameStateManager from '../src/GameStateManager.js'; +import { sendTurn } from '../src/LLMAdapter.js'; + +/** + * Controls the full-screen RTS UI that replaces the main SillyTavern interface + */ +export class RTSUIController { + constructor() { + this.isFullscreen = false; + this.originalSheldContent = null; + this.rtsContainer = null; + this.mapCanvas = null; + this.gameLog = []; + + 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); + } + + async enterFullscreen() { + if (this.isFullscreen) return; + + console.log('RTS: Entering fullscreen mode'); + + // Hide original SillyTavern UI - hide the entire main content area + const sheld = document.getElementById('sheld'); + if (sheld) { + this.originalSheldDisplay = sheld.style.display; + sheld.style.display = 'none'; + } + + // Create and inject RTS UI + await this.createRTSInterface(); + + this.isFullscreen = true; + + // Initialize game state display + this.updateUI(); + + // Add escape key listener + document.addEventListener('keydown', this.handleKeydown.bind(this)); + } + + async exitFullscreen() { + if (!this.isFullscreen) return; + + console.log('RTS: Exiting fullscreen mode'); + + // Remove RTS UI + if (this.rtsContainer) { + this.rtsContainer.remove(); + this.rtsContainer = null; + } + + // Restore original SillyTavern UI + const sheld = document.getElementById('sheld'); + if (sheld) { + sheld.style.display = this.originalSheldDisplay || ''; + } + + this.isFullscreen = false; + this.mapCanvas = null; + + // Remove escape key listener + document.removeEventListener('keydown', this.handleKeydown.bind(this)); + } + + async createRTSInterface() { + try { + console.log('RTS: Loading template...'); + // Load the RTS UI template + const rtsHTML = await renderExtensionTemplateAsync('rts-mode', 'rts-ui'); + + console.log('RTS: Creating container...'); + // Create container and inject into page + this.rtsContainer = document.createElement('div'); + this.rtsContainer.innerHTML = rtsHTML; + document.body.appendChild(this.rtsContainer); + + console.log('RTS: Initializing map canvas...'); + // Initialize map canvas + this.mapCanvas = new RTSMapCanvas('rts-game-map'); + + console.log('RTS: Setting up event listeners...'); + // Set up event listeners + this.setupEventListeners(); + + console.log('RTS: Updating displays...'); + // Initialize with current game state + this.updateResourceDisplay(); + this.updateUnitsDisplay(); + this.updateBuildingsDisplay(); + this.addLogEntry('system', 'RTS Mode activated. Command your forces!'); + + console.log('RTS: Interface created successfully'); + } catch (error) { + console.error('Error creating RTS interface:', error); + throw error; + } + } + + setupEventListeners() { + console.log('RTS: Setting up event listeners for buttons...'); + + // Quick action buttons + const buildBtn = document.getElementById('rts-build-btn'); + const recruitBtn = document.getElementById('rts-recruit-btn'); + const exploreBtn = document.getElementById('rts-explore-btn'); + const tradeBtn = document.getElementById('rts-trade-btn'); + + console.log('RTS: Build button found:', !!buildBtn); + console.log('RTS: Recruit button found:', !!recruitBtn); + console.log('RTS: Explore button found:', !!exploreBtn); + console.log('RTS: Trade button found:', !!tradeBtn); + + buildBtn?.addEventListener('click', () => this.handleQuickAction('build')); + recruitBtn?.addEventListener('click', () => this.handleQuickAction('recruit')); + exploreBtn?.addEventListener('click', () => this.handleQuickAction('explore')); + tradeBtn?.addEventListener('click', () => this.handleQuickAction('trade')); + + // Command execution + const executeBtn = document.getElementById('rts-execute-btn'); + const clearBtn = document.getElementById('rts-clear-btn'); + + console.log('RTS: Execute button found:', !!executeBtn); + console.log('RTS: Clear button found:', !!clearBtn); + + executeBtn?.addEventListener('click', this.handleExecuteCommand); + clearBtn?.addEventListener('click', this.handleClearCommand); + + // Map controls + const zoomInBtn = document.getElementById('rts-zoom-in'); + const zoomOutBtn = document.getElementById('rts-zoom-out'); + const centerBtn = document.getElementById('rts-center-map'); + + console.log('RTS: Zoom in button found:', !!zoomInBtn); + console.log('RTS: Zoom out button found:', !!zoomOutBtn); + console.log('RTS: Center button found:', !!centerBtn); + + zoomInBtn?.addEventListener('click', () => this.handleMapControls('zoomIn')); + zoomOutBtn?.addEventListener('click', () => this.handleMapControls('zoomOut')); + centerBtn?.addEventListener('click', () => this.handleMapControls('center')); + + // Enter key in command input + const commandInput = document.getElementById('rts-command-input'); + console.log('RTS: Command input found:', !!commandInput); + + if (commandInput) { + commandInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + this.handleExecuteCommand(); + } + }); + } + + console.log('RTS: Event listeners setup complete'); + } + + handleQuickAction(action) { + const commandInput = document.getElementById('rts-command-input'); + if (!commandInput) return; + + const actionTemplates = { + build: 'Build a new structure. Specify type and location (e.g., "Build a barracks near the town hall")', + recruit: 'Recruit new units. Specify type and quantity (e.g., "Recruit 5 warriors and 3 archers")', + explore: 'Send units to explore. Specify direction or target (e.g., "Send scouts to explore the northern forest")', + trade: 'Initiate trade with neighbors or manage resources (e.g., "Trade wood for gold with nearby village")' + }; + + commandInput.value = actionTemplates[action] || ''; + commandInput.focus(); + commandInput.select(); + } + + async handleExecuteCommand() { + const commandInput = document.getElementById('rts-command-input'); + if (!commandInput) return; + + const command = commandInput.value.trim(); + if (!command) return; + + // Add to log + this.addLogEntry('action', command); + + // Clear input + commandInput.value = ''; + + // Show processing state + const executeBtn = document.getElementById('rts-execute-btn'); + if (executeBtn) { + executeBtn.disabled = true; + executeBtn.innerHTML = ' Processing...'; + } + + try { + // Send command to LLM + await sendTurn(command); + + // The LLMAdapter will handle updating the game state + // We'll update the UI when the state changes + setTimeout(() => { + this.updateUI(); + this.addLogEntry('result', 'Command executed successfully!'); + }, 1000); + + } catch (error) { + console.error('Failed to execute RTS command:', error); + this.addLogEntry('error', `Failed to execute command: ${error.message}`); + } finally { + // Restore execute button + if (executeBtn) { + executeBtn.disabled = false; + executeBtn.innerHTML = ' Execute Turn'; + } + } + } + + handleClearCommand() { + const commandInput = 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; + } + } + + handleKeydown(e) { + if (e.key === 'Escape') { + this.exitFullscreen(); + + // Update button state to match (find the button in DOM) + const topButton = document.querySelector('#rts-mode-button'); + if (topButton) { + const toggleBtn = topButton.querySelector('#rts-toggle-ui-btn'); + if (toggleBtn) { + const icon = toggleBtn.querySelector('i'); + if (icon) { + icon.classList.remove('fa-eye-slash'); + icon.classList.add('fa-eye'); + } + toggleBtn.setAttribute('title', 'Show RTS UI'); + toggleBtn.setAttribute('data-i18n', '[title]Show RTS UI'); + } + } + } + } + + updateUI() { + this.updateResourceDisplay(); + this.updateUnitsDisplay(); + this.updateBuildingsDisplay(); + this.updateTurnInfo(); + + if (this.mapCanvas) { + this.mapCanvas.updateFromGameState(GameStateManager.getState()); + } + } + + updateResourceDisplay() { + const state = GameStateManager.getState(); + const resources = state.resources || {}; + + this.updateElementText('rts-gold', resources.gold || 100); + this.updateElementText('rts-wood', resources.wood || 50); + this.updateElementText('rts-stone', resources.stone || 25); + this.updateElementText('rts-food', resources.food || 75); + } + + updateUnitsDisplay() { + const state = GameStateManager.getState(); + const units = state.units || []; + + // Count units by type + const unitCounts = units.reduce((counts, unit) => { + counts[unit.type] = (counts[unit.type] || 0) + 1; + return counts; + }, {}); + + this.updateElementText('rts-warriors', unitCounts.warrior || 5); + this.updateElementText('rts-archers', unitCounts.archer || 3); + this.updateElementText('rts-cavalry', unitCounts.cavalry || 2); + } + + updateBuildingsDisplay() { + const state = GameStateManager.getState(); + // For now, use default values - this would be expanded based on game state + + this.updateElementText('rts-town-hall', 1); + this.updateElementText('rts-barracks', 1); + this.updateElementText('rts-storage', 1); + } + + updateTurnInfo() { + const state = GameStateManager.getState(); + this.updateElementText('rts-current-turn', state.turn || 1); + + // Simple season calculation based on turn + const seasons = ['Spring', 'Summer', 'Autumn', 'Winter']; + const season = seasons[Math.floor((state.turn || 1) / 10) % 4]; + this.updateElementText('rts-season', season); + } + + updateElementText(id, value) { + const element = document.getElementById(id); + if (element) { + element.textContent = value; + } + } + + addLogEntry(type, message) { + const gameLog = document.getElementById('rts-game-log'); + if (!gameLog) return; + + const state = GameStateManager.getState(); + const turn = state.turn || 1; + + const logEntry = document.createElement('div'); + logEntry.className = `rts-log-entry rts-log-${type}`; + logEntry.innerHTML = ` + [Turn ${turn}] + ${message} + `; + + gameLog.appendChild(logEntry); + gameLog.scrollTop = gameLog.scrollHeight; + + // Keep log manageable size + while (gameLog.children.length > 50) { + gameLog.removeChild(gameLog.firstChild); + } + } + + // Method to update button state from external calls + updateButtonState(isActive) { + // This will be called from the main extension to update button appearance + // when the UI state changes + console.log('RTS UI state updated:', isActive); + } + + // Public methods for external control + isActive() { + return this.isFullscreen; + } + + toggle() { + if (this.isFullscreen) { + this.exitFullscreen(); + } else { + this.enterFullscreen(); + } + } +} + +// Global instance +export const rtsUI = new RTSUIController(); \ No newline at end of file diff --git a/ui/ResourcePanel.js b/ui/ResourcePanel.js new file mode 100644 index 0000000..4fde5e4 --- /dev/null +++ b/ui/ResourcePanel.js @@ -0,0 +1,24 @@ +/** + * Creates and returns the resource panel element. + * @param {HTMLElement} rootEl - The root element to append to (optional). + * @returns {HTMLDivElement} + */ +export function createResourcePanel(rootEl) { + const panel = document.createElement('div'); + panel.id = 'rts-resource-panel'; + + const list = document.createElement('ul'); + list.innerHTML = ` +
  • Gold: 0
  • +
  • Wood: 0
  • +
  • Units: 0
  • + `; + + panel.appendChild(list); + + if (rootEl) { + rootEl.appendChild(panel); + } + + return panel; +} \ No newline at end of file