This commit is contained in:
2025-08-03 17:35:34 -07:00
parent a052b68a8c
commit 671e23cb86
15 changed files with 1672 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
<div id="rts-mode-button" class="menu_button menu_button_icon" title="Toggle RTS UI" data-i18n="[title]Toggle RTS UI">
<i class="fa-solid fa-chess-board fa-fw"></i>
</div>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 567 KiB

+162
View File
@@ -0,0 +1,162 @@
<div id="rts-main-container" class="wide100p height100p">
<!-- RTS Header -->
<div id="rts-header" class="fa-solid fa-grip drag-grabber"></div>
<!-- Main RTS Game Area -->
<div id="rts-game-area" class="flex-container wide100p height100p">
<!-- Left Panel: Resources and Units -->
<div id="rts-left-panel" class="rts-panel flex-container flexFlowColumn">
<div class="rts-panel-header">
<h3>Game Status</h3>
</div>
<!-- Resources Section -->
<div class="rts-section">
<h4 class="rts-section-title">Resources</h4>
<div id="rts-resources" class="rts-resources-grid">
<div class="rts-resource-item">
<i class="fa-solid fa-coins"></i>
<span>Gold: <span id="rts-gold">100</span></span>
</div>
<div class="rts-resource-item">
<i class="fa-solid fa-tree"></i>
<span>Wood: <span id="rts-wood">50</span></span>
</div>
<div class="rts-resource-item">
<i class="fa-solid fa-mountain"></i>
<span>Stone: <span id="rts-stone">25</span></span>
</div>
<div class="rts-resource-item">
<i class="fa-solid fa-seedling"></i>
<span>Food: <span id="rts-food">75</span></span>
</div>
</div>
</div>
<!-- Units Section -->
<div class="rts-section">
<h4 class="rts-section-title">Army</h4>
<div id="rts-units" class="rts-units-list">
<div class="rts-unit-item">
<i class="fa-solid fa-user-shield"></i>
<span>Warriors: <span id="rts-warriors">5</span></span>
</div>
<div class="rts-unit-item">
<i class="fa-solid fa-bow-and-arrow"></i>
<span>Archers: <span id="rts-archers">3</span></span>
</div>
<div class="rts-unit-item">
<i class="fa-solid fa-horse"></i>
<span>Cavalry: <span id="rts-cavalry">2</span></span>
</div>
</div>
</div>
<!-- Buildings Section -->
<div class="rts-section">
<h4 class="rts-section-title">Buildings</h4>
<div id="rts-buildings" class="rts-buildings-list">
<div class="rts-building-item">
<i class="fa-solid fa-home"></i>
<span>Town Hall: Level <span id="rts-town-hall">1</span></span>
</div>
<div class="rts-building-item">
<i class="fa-solid fa-hammer"></i>
<span>Barracks: <span id="rts-barracks">1</span></span>
</div>
<div class="rts-building-item">
<i class="fa-solid fa-warehouse"></i>
<span>Storage: <span id="rts-storage">1</span></span>
</div>
</div>
</div>
</div>
<!-- Center: Game Map -->
<div id="rts-map-container" class="flex1 flex-container flexFlowColumn">
<div class="rts-map-header">
<h3>Battle Map</h3>
<div class="rts-map-controls">
<button id="rts-zoom-in" class="menu_button menu_button_icon" title="Zoom In">
<i class="fa-solid fa-magnifying-glass-plus"></i>
</button>
<button id="rts-zoom-out" class="menu_button menu_button_icon" title="Zoom Out">
<i class="fa-solid fa-magnifying-glass-minus"></i>
</button>
<button id="rts-center-map" class="menu_button menu_button_icon" title="Center Map">
<i class="fa-solid fa-crosshairs"></i>
</button>
</div>
</div>
<div id="rts-map-wrapper" class="flex1 rts-map-wrapper">
<canvas id="rts-game-map" width="800" height="600"></canvas>
<div id="rts-map-overlay" class="rts-map-overlay">
<!-- Selected unit/building info will appear here -->
</div>
</div>
<div class="rts-map-footer">
<div class="rts-turn-info">
<span>Turn: <span id="rts-current-turn">1</span></span>
<span class="rts-separator">|</span>
<span>Season: <span id="rts-season">Spring</span></span>
</div>
</div>
</div>
<!-- Right Panel: Commands and Log -->
<div id="rts-right-panel" class="rts-panel flex-container flexFlowColumn">
<div class="rts-panel-header">
<h3>Command Center</h3>
</div>
<!-- Quick Actions -->
<div class="rts-section">
<h4 class="rts-section-title">Quick Actions</h4>
<div class="rts-quick-actions">
<button id="rts-build-btn" class="menu_button" title="Build Structure">
<i class="fa-solid fa-hammer"></i> Build
</button>
<button id="rts-recruit-btn" class="menu_button" title="Recruit Units">
<i class="fa-solid fa-user-plus"></i> Recruit
</button>
<button id="rts-explore-btn" class="menu_button" title="Explore Territory">
<i class="fa-solid fa-compass"></i> Explore
</button>
<button id="rts-trade-btn" class="menu_button" title="Trade Resources">
<i class="fa-solid fa-handshake"></i> Trade
</button>
</div>
</div>
<!-- Command Input -->
<div class="rts-section flex1">
<h4 class="rts-section-title">Command Input</h4>
<div class="rts-command-input-area">
<textarea id="rts-command-input"
placeholder="Enter your strategy commands here... (e.g., 'Build a barracks north of the town hall' or 'Send 3 warriors to explore the eastern forest')"
class="text_pole textarea_compact"
rows="3"></textarea>
<div class="rts-command-controls">
<button id="rts-execute-btn" class="menu_button menu_button_bold">
<i class="fa-solid fa-play"></i> Execute Turn
</button>
<button id="rts-clear-btn" class="menu_button menu_button_icon" title="Clear Input">
<i class="fa-solid fa-eraser"></i>
</button>
</div>
</div>
</div>
<!-- Game Log -->
<div class="rts-section flex1">
<h4 class="rts-section-title">Battle Log</h4>
<div id="rts-game-log" class="rts-game-log scrollY">
<div class="rts-log-entry rts-log-system">
<span class="rts-log-timestamp">[Turn 1]</span>
<span class="rts-log-message">Welcome to RTS Mode! Your settlement awaits your commands.</span>
</div>
</div>
</div>
</div>
</div>
</div>
+9
View File
@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<title>RTS Chat Mode Settings</title>
</head>
<body>
<h1>RTS Chat Mode Settings</h1>
</body>
</html>
+170
View File
@@ -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');
});
+11
View File
@@ -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": ""
}
+162
View File
@@ -0,0 +1,162 @@
<div id="rts-main-container" class="wide100p height100p">
<!-- RTS Header -->
<div id="rts-header" class="fa-solid fa-grip drag-grabber"></div>
<!-- Main RTS Game Area -->
<div id="rts-game-area" class="flex-container wide100p height100p">
<!-- Left Panel: Resources and Units -->
<div id="rts-left-panel" class="rts-panel flex-container flexFlowColumn">
<div class="rts-panel-header">
<h3>Game Status</h3>
</div>
<!-- Resources Section -->
<div class="rts-section">
<h4 class="rts-section-title">Resources</h4>
<div id="rts-resources" class="rts-resources-grid">
<div class="rts-resource-item">
<i class="fa-solid fa-coins"></i>
<span>Gold: <span id="rts-gold">100</span></span>
</div>
<div class="rts-resource-item">
<i class="fa-solid fa-tree"></i>
<span>Wood: <span id="rts-wood">50</span></span>
</div>
<div class="rts-resource-item">
<i class="fa-solid fa-mountain"></i>
<span>Stone: <span id="rts-stone">25</span></span>
</div>
<div class="rts-resource-item">
<i class="fa-solid fa-seedling"></i>
<span>Food: <span id="rts-food">75</span></span>
</div>
</div>
</div>
<!-- Units Section -->
<div class="rts-section">
<h4 class="rts-section-title">Army</h4>
<div id="rts-units" class="rts-units-list">
<div class="rts-unit-item">
<i class="fa-solid fa-user-shield"></i>
<span>Warriors: <span id="rts-warriors">5</span></span>
</div>
<div class="rts-unit-item">
<i class="fa-solid fa-bow-and-arrow"></i>
<span>Archers: <span id="rts-archers">3</span></span>
</div>
<div class="rts-unit-item">
<i class="fa-solid fa-horse"></i>
<span>Cavalry: <span id="rts-cavalry">2</span></span>
</div>
</div>
</div>
<!-- Buildings Section -->
<div class="rts-section">
<h4 class="rts-section-title">Buildings</h4>
<div id="rts-buildings" class="rts-buildings-list">
<div class="rts-building-item">
<i class="fa-solid fa-home"></i>
<span>Town Hall: Level <span id="rts-town-hall">1</span></span>
</div>
<div class="rts-building-item">
<i class="fa-solid fa-hammer"></i>
<span>Barracks: <span id="rts-barracks">1</span></span>
</div>
<div class="rts-building-item">
<i class="fa-solid fa-warehouse"></i>
<span>Storage: <span id="rts-storage">1</span></span>
</div>
</div>
</div>
</div>
<!-- Center: Game Map -->
<div id="rts-map-container" class="flex1 flex-container flexFlowColumn">
<div class="rts-map-header">
<h3>Battle Map</h3>
<div class="rts-map-controls">
<button id="rts-zoom-in" class="menu_button menu_button_icon" title="Zoom In">
<i class="fa-solid fa-magnifying-glass-plus"></i>
</button>
<button id="rts-zoom-out" class="menu_button menu_button_icon" title="Zoom Out">
<i class="fa-solid fa-magnifying-glass-minus"></i>
</button>
<button id="rts-center-map" class="menu_button menu_button_icon" title="Center Map">
<i class="fa-solid fa-crosshairs"></i>
</button>
</div>
</div>
<div id="rts-map-wrapper" class="flex1 rts-map-wrapper">
<canvas id="rts-game-map" width="800" height="600"></canvas>
<div id="rts-map-overlay" class="rts-map-overlay">
<!-- Selected unit/building info will appear here -->
</div>
</div>
<div class="rts-map-footer">
<div class="rts-turn-info">
<span>Turn: <span id="rts-current-turn">1</span></span>
<span class="rts-separator">|</span>
<span>Season: <span id="rts-season">Spring</span></span>
</div>
</div>
</div>
<!-- Right Panel: Commands and Log -->
<div id="rts-right-panel" class="rts-panel flex-container flexFlowColumn">
<div class="rts-panel-header">
<h3>Command Center</h3>
</div>
<!-- Quick Actions -->
<div class="rts-section">
<h4 class="rts-section-title">Quick Actions</h4>
<div class="rts-quick-actions">
<button id="rts-build-btn" class="menu_button" title="Build Structure">
<i class="fa-solid fa-hammer"></i> Build
</button>
<button id="rts-recruit-btn" class="menu_button" title="Recruit Units">
<i class="fa-solid fa-user-plus"></i> Recruit
</button>
<button id="rts-explore-btn" class="menu_button" title="Explore Territory">
<i class="fa-solid fa-compass"></i> Explore
</button>
<button id="rts-trade-btn" class="menu_button" title="Trade Resources">
<i class="fa-solid fa-handshake"></i> Trade
</button>
</div>
</div>
<!-- Command Input -->
<div class="rts-section flex1">
<h4 class="rts-section-title">Command Input</h4>
<div class="rts-command-input-area">
<textarea id="rts-command-input"
placeholder="Enter your strategy commands here... (e.g., 'Build a barracks north of the town hall' or 'Send 3 warriors to explore the eastern forest')"
class="text_pole textarea_compact"
rows="3"></textarea>
<div class="rts-command-controls">
<button id="rts-execute-btn" class="menu_button menu_button_bold">
<i class="fa-solid fa-play"></i> Execute Turn
</button>
<button id="rts-clear-btn" class="menu_button menu_button_icon" title="Clear Input">
<i class="fa-solid fa-eraser"></i>
</button>
</div>
</div>
</div>
<!-- Game Log -->
<div class="rts-section flex1">
<h4 class="rts-section-title">Battle Log</h4>
<div id="rts-game-log" class="rts-game-log scrollY">
<div class="rts-log-entry rts-log-system">
<span class="rts-log-timestamp">[Turn 1]</span>
<span class="rts-log-message">Welcome to RTS Mode! Your settlement awaits your commands.</span>
</div>
</div>
</div>
</div>
</div>
</div>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

+36
View File
@@ -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;
+54
View File
@@ -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}`);
}
}
+9
View File
@@ -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 `<RTS-STATE>${stateJSON}</RTS-STATE>\n<USER-CMD>${userCmd}</USER-CMD>\nRespond with JSON diff + narrative.`;
}
+336
View File
@@ -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;
}
+318
View File
@@ -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 = `
<div style="position: absolute; top: ${y + 10}px; left: ${x + 10}px;
background: rgba(0,0,0,0.8); color: white; padding: 8px;
border-radius: 4px; font-size: 12px; pointer-events: auto;">
<strong>Main Settlement</strong><br>
Population: 150<br>
Defense: High<br>
<button onclick="this.parentElement.remove()" style="margin-top: 4px;">Close</button>
</div>
`;
} 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;
}
+378
View File
@@ -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 = '<i class="fa-solid fa-spinner fa-spin"></i> 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 = '<i class="fa-solid fa-play"></i> 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 = `
<span class="rts-log-timestamp">[Turn ${turn}]</span>
<span class="rts-log-message">${message}</span>
`;
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();
+24
View File
@@ -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 = `
<li>Gold: 0</li>
<li>Wood: 0</li>
<li>Units: 0</li>
`;
panel.appendChild(list);
if (rootEl) {
rootEl.appendChild(panel);
}
return panel;
}