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

999 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import PresetManager from '../src/PresetManager.js';
export class RTSMapCanvas {
constructor(canvasId = 'rts-game-map') {
this.canvas = /** @type {HTMLCanvasElement} */ (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.minZoom = 0.3;
this.maxZoom = 3;
this.offsetX = 0;
this.offsetY = 0;
this.isDragging = false;
this.lastMouseX = 0;
this.lastMouseY = 0;
this.selectedEntities = [];
this.hoveredElement = null;
this.animationFrame = null;
this.lastRenderTime = 0;
this.dirty = true;
this.fogOfWarEnabled = true;
this.fogOfWar = [];
this.exploredAreas = new Set();
this.animations = [];
this.particles = [];
this.mapData = null;
this.tileSize = 32;
this.bindMethods();
this.initializeCanvas();
this.setupEventListeners();
this.startAnimationLoop();
}
bindMethods() {
this.handleClick = this.handleClick.bind(this);
this.handleMouseMove = this.handleMouseMove.bind(this);
this.handleMouseDown = this.handleMouseDown.bind(this);
this.handleMouseUp = this.handleMouseUp.bind(this);
this.handleWheel = this.handleWheel.bind(this);
this.handleRightClick = this.handleRightClick.bind(this);
this.handleTouchStart = this.handleTouchStart.bind(this);
this.handleTouchMove = this.handleTouchMove.bind(this);
this.handleTouchEnd = this.handleTouchEnd.bind(this);
this.resizeCanvas = this.resizeCanvas.bind(this);
}
handleMouseMove(e) {
if (!this.isDragging) return;
const rect = this.canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const deltaX = mouseX - this.lastMouseX;
const deltaY = mouseY - this.lastMouseY;
this.offsetX += deltaX / this.zoom;
this.offsetY += deltaY / this.zoom;
this.lastMouseX = mouseX;
this.lastMouseY = mouseY;
this.dirty = true;
}
initializeCanvas() {
this.resizeCanvas();
window.addEventListener('resize', this.resizeCanvas);
}
resizeCanvas() {
const container = this.canvas.parentElement;
if (container) {
const rect = container.getBoundingClientRect();
this.canvas.width = rect.width;
this.canvas.height = rect.height;
this.dirty = true;
}
}
setupEventListeners() {
this.canvas.addEventListener('click', this.handleClick);
this.canvas.addEventListener('mousemove', this.handleMouseMove);
this.canvas.addEventListener('mousedown', this.handleMouseDown);
this.canvas.addEventListener('mouseup', this.handleMouseUp);
this.canvas.addEventListener('wheel', this.handleWheel);
this.canvas.addEventListener('contextmenu', this.handleRightClick);
this.canvas.addEventListener('touchstart', this.handleTouchStart);
this.canvas.addEventListener('touchmove', this.handleTouchMove);
this.canvas.addEventListener('touchend', this.handleTouchEnd);
}
loadMap(mapData) {
this.mapData = mapData;
this.fogOfWarEnabled = PresetManager.getFeature('fogOfWar');
if (this.fogOfWarEnabled) {
this.initializeFogOfWar();
}
this.dirty = true;
console.log('RTS: Map loaded with', this.mapData?.map?.entities?.length || 0, 'entities');
// Force an immediate render to show updated entities
if (this.mapData) {
this.render();
}
}
render() {
const ctx = this.ctx;
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.drawBackground();
ctx.save();
ctx.translate(this.canvas.width / 2, this.canvas.height / 2);
ctx.scale(this.zoom, this.zoom);
ctx.translate(-this.canvas.width / 2 + this.offsetX, -this.canvas.height / 2 + this.offsetY);
if (this.mapData) {
this.drawTerrain();
this.drawEntities();
if (this.fogOfWarEnabled) {
this.drawFogOfWar();
}
}
this.drawParticles();
ctx.restore();
this.drawUIOverlays();
}
drawBackground() {
const ctx = this.ctx;
const gradient = ctx.createLinearGradient(0, 0, this.canvas.width, this.canvas.height);
gradient.addColorStop(0, '#1a2332');
gradient.addColorStop(0.5, '#2d3748');
gradient.addColorStop(1, '#1a202c');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
}
drawTerrain() {
const ctx = this.ctx;
const { width, height, tiles, tileTypes } = this.mapData.map;
for (let y = 0; y < height; y++) {
if (tiles[y]) {
for (let x = 0; x < width; x++) {
const tileType = tiles[y][x];
let color = '#334155'; // default path color
// Use new tile type system if available
if (tileTypes && tileTypes[tileType]) {
color = tileTypes[tileType].color;
} else {
// Fallback for old format
switch (tileType) {
case 1: color = '#654321'; break; // enclosure fence
case 2: color = '#5A5A5A'; break; // building wall
case 3: color = '#FFD700'; break; // entrance gate
case 4: color = '#4A90E2'; break; // water
case 5: color = '#228B22'; break; // grass
case 6: color = '#006400'; break; // trees
case 7: color = '#90EE90'; break; // enclosure interior
case 8: color = '#D3D3D3'; break; // building interior
case 9: color = '#696969'; break; // parking
default: color = '#8B7D6B'; break; // path
}
}
ctx.fillStyle = color;
ctx.fillRect(x * this.tileSize, y * this.tileSize, this.tileSize, this.tileSize);
// Add terrain-specific visual effects
if (tileTypes && tileTypes[tileType]) {
this.addTerrainEffects(ctx, x, y, tileTypes[tileType]);
}
// Add subtle border for better definition
ctx.strokeStyle = 'rgba(0,0,0,0.1)';
ctx.lineWidth = 0.5;
ctx.strokeRect(x * this.tileSize, y * this.tileSize, this.tileSize, this.tileSize);
}
}
}
// Draw zones
this.drawZones();
// Draw points of interest
this.drawPointsOfInterest();
}
drawZones() {
if (!this.mapData.map.zones) return;
const ctx = this.ctx;
ctx.save();
ctx.globalAlpha = 0.3;
this.mapData.map.zones.forEach((zone, index) => {
const colors = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#ffeaa7', '#dda0dd'];
ctx.fillStyle = colors[index % colors.length];
const width = zone.bounds.x2 - zone.bounds.x1;
const height = zone.bounds.y2 - zone.bounds.y1;
ctx.fillRect(
zone.bounds.x1 * this.tileSize,
zone.bounds.y1 * this.tileSize,
width * this.tileSize,
height * this.tileSize
);
// Zone label
ctx.globalAlpha = 1;
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 12px sans-serif';
ctx.strokeStyle = '#000000';
ctx.lineWidth = 2;
const centerX = (zone.bounds.x1 + width / 2) * this.tileSize;
const centerY = (zone.bounds.y1 + height / 2) * this.tileSize;
ctx.strokeText(zone.name, centerX - 30, centerY);
ctx.fillText(zone.name, centerX - 30, centerY);
ctx.globalAlpha = 0.3;
});
ctx.restore();
}
drawPointsOfInterest() {
if (!this.mapData.map.pointsOfInterest) return;
const ctx = this.ctx;
ctx.save();
this.mapData.map.pointsOfInterest.forEach(poi => {
const x = poi.x * this.tileSize + this.tileSize / 2;
const y = poi.y * this.tileSize + this.tileSize / 2;
// Background circle with gradient
const gradient = ctx.createRadialGradient(x, y, 0, x, y, this.tileSize / 3);
gradient.addColorStop(0, this.getPoiColor(poi.type));
gradient.addColorStop(1, this.darkenColor(this.getPoiColor(poi.type), 0.3));
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(x, y, this.tileSize / 3, 0, Math.PI * 2);
ctx.fill();
// Border with glow effect
ctx.shadowColor = this.getPoiColor(poi.type);
ctx.shadowBlur = 5;
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.stroke();
ctx.shadowBlur = 0;
// Icon with better positioning
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(this.getPoiIcon(poi.type), x, y + 4);
// POI name label
ctx.fillStyle = '#000000';
ctx.font = '8px sans-serif';
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.strokeText(poi.name, x, y + this.tileSize / 2 + 8);
ctx.fillText(poi.name, x, y + this.tileSize / 2 + 8);
});
ctx.restore();
}
getPoiColor(type) {
switch (type) {
case 'exit': return '#ff4757';
case 'building': return '#5f27cd';
case 'medical': return '#00d2d3';
case 'weapon': return '#ff9ff3';
default: return '#747d8c';
}
}
getPoiIcon(type) {
switch (type) {
case 'exit': return '🚪';
case 'building': return '🏢';
case 'medical': return '⚕';
case 'weapon': return '🛡';
default: return '📍';
}
}
getEntityAppearance(entity) {
switch (entity.type) {
case 'player': return { color: '#3b82f6', icon: '🧑' };
case 'visitor': return { color: '#6b7280', icon: '👤' };
case 'keeper': return { color: '#10b981', icon: '👨‍🔬' };
case 'veterinarian': return { color: '#8b5cf6', icon: '👨‍⚕️' };
case 'lion': return { color: '#eab308', icon: '🦁' };
case 'tiger': return { color: '#f59e0b', icon: '🐅' };
case 'jaguar': return { color: '#d97706', icon: '🐆' };
case 'snow_leopard': return { color: '#a16207', icon: '🐆' };
case 'wolf': return { color: '#84cc16', icon: '🐺' };
case 'bear':
case 'grizzly_bear': return { color: '#a3e635', icon: '🐻' };
case 'polar_bear': return { color: '#ecfccb', icon: '🐻‍❄️' };
case 'panda': return { color: '#ffffff', icon: '🐼' };
case 'gorilla': return { color: '#654321', icon: '🦍' };
case 'chimpanzee': return { color: '#8b4513', icon: '🐵' };
case 'crocodile': return { color: '#228b22', icon: '🐊' };
default: return { color: '#9ca3af', icon: '❓' };
}
}
drawPlayerSight() {
if (!this.mapData || !this.mapData.map.entities) return;
const player = this.mapData.map.entities.find(e => e.type === 'player');
if (!player) return;
const ctx = this.ctx;
const playerX = player.x * this.tileSize + this.tileSize / 2;
const playerY = player.y * this.tileSize + this.tileSize / 2;
const sightRadius = 5 * this.tileSize; // 5 tile radius
// Draw sight range circle
ctx.save();
ctx.globalAlpha = 0.1;
ctx.fillStyle = '#3b82f6';
ctx.beginPath();
ctx.arc(playerX, playerY, sightRadius, 0, Math.PI * 2);
ctx.fill();
// Draw sight range border
ctx.globalAlpha = 0.3;
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
// Draw visibility indicators for entities within sight
this.mapData.map.entities.forEach(entity => {
if (entity.type === 'player') return;
const entityX = entity.x * this.tileSize + this.tileSize / 2;
const entityY = entity.y * this.tileSize + this.tileSize / 2;
const distance = Math.sqrt(
Math.pow(entityX - playerX, 2) +
Math.pow(entityY - playerY, 2)
);
if (distance <= sightRadius) {
// Draw line of sight
ctx.save();
ctx.globalAlpha = 0.2;
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 1;
ctx.setLineDash([2, 2]);
ctx.beginPath();
ctx.moveTo(playerX, playerY);
ctx.lineTo(entityX, entityY);
ctx.stroke();
ctx.restore();
// Draw visibility indicator
ctx.save();
ctx.fillStyle = '#3b82f6';
ctx.font = 'bold 8px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('👁', entityX - 12, entityY - 12);
ctx.restore();
}
});
}
drawEntities() {
const ctx = this.ctx;
const { entities } = this.mapData.map;
// Draw player sight indicators first (so they appear behind entities)
this.drawPlayerSight();
entities.forEach(entity => {
const centerX = entity.x * this.tileSize + this.tileSize / 2;
const centerY = entity.y * this.tileSize + this.tileSize / 2;
const radius = this.tileSize / 3;
// Get entity appearance
const appearance = this.getEntityAppearance(entity);
// Draw background circle
ctx.fillStyle = appearance.color;
ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
ctx.fill();
// Draw border
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.stroke();
// Draw icon/emoji
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(appearance.icon, centerX, centerY + 4);
// Draw threat indicator for dangerous animals
if (entity.threat === 'extreme' || entity.threat === 'high') {
ctx.strokeStyle = entity.threat === 'extreme' ? '#dc2626' : '#f59e0b';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(centerX, centerY, radius + 3, 0, Math.PI * 2);
ctx.stroke();
}
// Selection highlight
if (this.selectedEntities.includes(entity)) {
ctx.strokeStyle = '#fbbf24';
ctx.lineWidth = 4;
ctx.beginPath();
ctx.arc(centerX, centerY, radius + 6, 0, Math.PI * 2);
ctx.stroke();
}
// Draw name label for important entities
if (entity.type === 'player' || entity.threat === 'extreme') {
ctx.fillStyle = '#000000';
ctx.font = 'bold 10px sans-serif';
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.strokeText(entity.name || entity.type, centerX, centerY + radius + 15);
ctx.fillText(entity.name || entity.type, centerX, centerY + radius + 15);
}
});
}
initializeFogOfWar() {
if (!this.mapData) return;
const { width, height } = this.mapData.map;
this.fogOfWar = [];
for (let y = 0; y < height; y++) {
this.fogOfWar[y] = [];
for (let x = 0; x < width; x++) {
this.fogOfWar[y][x] = 0; // 0 = unexplored, 1 = explored, 2 = visible
}
}
const player = this.mapData.map.entities.find(e => e.type === 'player');
if (player) {
this.revealArea(player.x, player.y, 5);
}
}
revealArea(centerX, centerY, radius) {
const tileX = Math.floor(centerX);
const tileY = Math.floor(centerY);
const tileRadius = Math.ceil(radius);
for (let y = tileY - tileRadius; y <= tileY + tileRadius; y++) {
for (let x = tileX - tileRadius; x <= tileX + tileRadius; x++) {
if (y >= 0 && y < this.fogOfWar.length && x >= 0 && x < this.fogOfWar[0].length) {
const distance = Math.sqrt((x - tileX) ** 2 + (y - tileY) ** 2);
if (distance <= tileRadius) {
this.fogOfWar[y][x] = Math.max(this.fogOfWar[y][x], 1);
this.exploredAreas.add(`${x},${y}`);
}
}
}
}
}
drawFogOfWar() {
const ctx = this.ctx;
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
for (let y = 0; y < this.fogOfWar.length; y++) {
for (let x = 0; x < this.fogOfWar[0].length; x++) {
if (this.fogOfWar[y][x] === 0) {
ctx.fillRect(x * this.tileSize, y * this.tileSize, this.tileSize, this.tileSize);
}
}
}
}
drawParticles() {
const ctx = this.ctx;
this.particles = this.particles.filter(particle => {
particle.x += particle.vx;
particle.y += particle.vy;
particle.life -= 1;
particle.alpha = particle.life / particle.maxLife;
if (particle.life > 0) {
ctx.save();
ctx.globalAlpha = particle.alpha;
ctx.fillStyle = particle.color;
ctx.beginPath();
ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
return true;
}
return false;
});
}
addParticle(x, y, color = '#ffffff', size = 2, vx = 0, vy = 0, life = 60) {
this.particles.push({
x, y, vx, vy, size, color, life, maxLife: life, alpha: 1
});
}
drawUIOverlays() {
const ctx = this.ctx;
this.drawMinimap();
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
ctx.fillRect(10, this.canvas.height - 60, 100, 20);
ctx.fillStyle = '#ffffff';
ctx.font = '12px sans-serif';
ctx.fillText(`Zoom: ${Math.round(this.zoom * 100)}%`, 15, this.canvas.height - 46);
const centerWorld = this.screenToWorld(this.canvas.width / 2, this.canvas.height / 2);
ctx.fillText(`X: ${Math.round(centerWorld.x)}, Y: ${Math.round(centerWorld.y)}`, 15, this.canvas.height - 25);
}
drawMinimap() {
if (!this.mapData) return;
const ctx = this.ctx;
const minimapSize = 150;
const minimapX = this.canvas.width - minimapSize - 10;
const minimapY = 10;
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(minimapX, minimapY, minimapSize, minimapSize);
ctx.strokeStyle = '#64748b';
ctx.lineWidth = 2;
ctx.strokeRect(minimapX, minimapY, minimapSize, minimapSize);
const scaleX = minimapSize / (this.mapData.map.width * this.tileSize);
const scaleY = minimapSize / (this.mapData.map.height * this.tileSize);
this.mapData.map.entities.forEach(entity => {
let color;
switch (entity.type) {
case 'player': color = '#3b82f6'; break;
case 'guard': color = '#f97316'; break;
default: color = '#9ca3af'; break;
}
ctx.fillStyle = color;
ctx.fillRect(minimapX + entity.x * this.tileSize * scaleX, minimapY + entity.y * this.tileSize * scaleY, 2, 2);
});
const viewportWidth = this.canvas.width / this.zoom;
const viewportHeight = this.canvas.height / this.zoom;
let viewportX = minimapX + (this.canvas.width / 2 - this.offsetX - viewportWidth / 2) * scaleX;
let viewportY = minimapY + (this.canvas.height / 2 - this.offsetY - viewportHeight / 2) * scaleY;
const viewportScaledWidth = viewportWidth * scaleX;
const viewportScaledHeight = viewportHeight * scaleY;
// Constrain viewport indicator to minimap bounds
viewportX = Math.max(minimapX, Math.min(viewportX, minimapX + minimapSize - viewportScaledWidth));
viewportY = Math.max(minimapY, Math.min(viewportY, minimapY + minimapSize - viewportScaledHeight));
// Ensure viewport size doesn't exceed minimap size
const constrainedWidth = Math.min(viewportScaledWidth, minimapSize);
const constrainedHeight = Math.min(viewportScaledHeight, minimapSize);
ctx.strokeStyle = '#fbbf24';
ctx.lineWidth = 2;
ctx.strokeRect(viewportX, viewportY, constrainedWidth, constrainedHeight);
}
startAnimationLoop() {
const animate = () => {
const particlesExist = this.particles.length > 0;
if (this.dirty || particlesExist) {
this.render();
this.dirty = false;
}
this.animationFrame = requestAnimationFrame(animate);
};
this.animationFrame = requestAnimationFrame(animate);
}
stopAnimationLoop() {
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = null;
}
}
screenToWorld(screenX, screenY) {
const centerX = this.canvas.width / 2;
const centerY = this.canvas.height / 2;
const worldX = (screenX - centerX) / this.zoom - this.offsetX + centerX;
const worldY = (screenY - centerY) / this.zoom - this.offsetY + centerY;
return { x: worldX, y: worldY };
}
worldToScreen(worldX, worldY) {
const centerX = this.canvas.width / 2;
const centerY = this.canvas.height / 2;
const screenX = (worldX - centerX + this.offsetX) * this.zoom + centerX;
const screenY = (worldY - centerY + this.offsetY) * this.zoom + centerY;
return { x: screenX, y: screenY };
}
getElementAtPosition(x, y) {
if (!this.mapData) return null;
const tileX = Math.floor(x / this.tileSize);
const tileY = Math.floor(y / this.tileSize);
for (const entity of this.mapData.map.entities) {
if (entity.x === tileX && entity.y === tileY) {
return { type: 'entity', data: entity };
}
}
return null;
}
selectElement(element) {
if (element.type === 'entity') {
this.selectedEntities = [element.data];
}
this.showElementInfo(element);
this.canvas.dispatchEvent(new Event('selectionChanged'));
}
clearSelection() {
this.selectedEntities = [];
this.hideElementInfo();
this.canvas.dispatchEvent(new Event('selectionChanged'));
}
showElementInfo(element) {
console.debug('[showElementInfo]', element);
const overlay = document.getElementById('rts-map-overlay');
if (!overlay) return;
// Clear any existing tooltips
overlay.innerHTML = '';
const screenPos = this.worldToScreen(element.data.x * this.tileSize, element.data.y * this.tileSize);
if (element.type === 'entity') {
const entity = element.data;
const name = entity.name || (entity.type.charAt(0).toUpperCase() + entity.type.slice(1));
const description = entity.description || 'No description available.';
const infoBox = document.createElement('div');
infoBox.className = 'rts-element-info';
/* Position tooltip relative to viewport so it isn't clipped by overlay */
const rect = this.canvas.getBoundingClientRect();
infoBox.style.position = 'fixed';
infoBox.style.top = `${rect.top + screenPos.y + 20}px`;
infoBox.style.left = `${rect.left + screenPos.x + 20}px`;
/* Ensure the tooltip sits above the main UI container */
infoBox.style.zIndex = '2001';
const header = document.createElement('div');
header.className = 'rts-info-header';
const nameEl = document.createElement('strong');
nameEl.textContent = name;
const closeBtn = document.createElement('button');
closeBtn.className = 'rts-info-close';
closeBtn.innerHTML = '×';
closeBtn.onclick = () => infoBox.remove();
header.appendChild(nameEl);
header.appendChild(closeBtn);
const content = document.createElement('div');
content.className = 'rts-info-content';
const descEl = document.createElement('p');
descEl.textContent = description;
const coordsEl = document.createElement('div');
coordsEl.textContent = `Coordinates: ${entity.x}, ${entity.y}`;
content.appendChild(descEl);
content.appendChild(coordsEl);
infoBox.appendChild(header);
infoBox.appendChild(content);
document.body.appendChild(infoBox);
}
}
hideElementInfo() {
// Remove any existing info boxes
document.querySelectorAll('.rts-element-info').forEach(el => el.remove());
const overlay = document.getElementById('rts-map-overlay');
if (overlay) {
overlay.innerHTML = '';
}
}
showContextMenu(worldX, worldY, screenX, screenY) {
const overlay = document.getElementById('rts-map-overlay');
if (!overlay) return;
// Convert world coordinates to tile coordinates
const tileX = Math.floor(worldX / this.tileSize);
const tileY = Math.floor(worldY / this.tileSize);
const contextMenu = document.createElement('div');
contextMenu.className = 'rts-context-menu';
contextMenu.style.position = 'absolute';
contextMenu.style.top = `${screenY}px`;
contextMenu.style.left = `${screenX}px`;
contextMenu.style.background = '#2d3748';
contextMenu.style.border = '1px solid #4a5568';
contextMenu.style.borderRadius = '4px';
contextMenu.style.padding = '4px';
contextMenu.style.zIndex = '9999';
const moveItem = document.createElement('div');
moveItem.className = 'rts-context-item';
moveItem.textContent = `Move to (${tileX}, ${tileY})`;
moveItem.style.padding = '8px 12px';
moveItem.style.cursor = 'pointer';
moveItem.style.color = '#ffffff';
moveItem.addEventListener('click', () => {
this.handleMoveCommand(tileX, tileY);
contextMenu.remove();
});
moveItem.addEventListener('mouseenter', () => {
moveItem.style.background = '#4a5568';
});
moveItem.addEventListener('mouseleave', () => {
moveItem.style.background = 'transparent';
});
const cancelItem = document.createElement('div');
cancelItem.className = 'rts-context-item';
cancelItem.textContent = 'Cancel';
cancelItem.style.padding = '8px 12px';
cancelItem.style.cursor = 'pointer';
cancelItem.style.color = '#a0aec0';
cancelItem.addEventListener('click', () => {
contextMenu.remove();
});
cancelItem.addEventListener('mouseenter', () => {
cancelItem.style.background = '#4a5568';
});
cancelItem.addEventListener('mouseleave', () => {
cancelItem.style.background = 'transparent';
});
contextMenu.appendChild(moveItem);
contextMenu.appendChild(cancelItem);
overlay.innerHTML = '';
overlay.appendChild(contextMenu);
// Auto-remove on outside click
setTimeout(() => {
const handleOutsideClick = (e) => {
if (!contextMenu.contains(e.target)) {
contextMenu.remove();
document.removeEventListener('click', handleOutsideClick);
}
};
document.addEventListener('click', handleOutsideClick);
}, 100);
}
handleMoveCommand(tileX, tileY) {
// Fill the command input with move command
const commandInput = document.getElementById('rts-command-input');
if (commandInput) {
commandInput.value = `Move to coordinates (${tileX}, ${tileY}).`;
commandInput.focus();
// Trigger a visual indication that the command was set
commandInput.style.borderColor = '#3b82f6';
setTimeout(() => {
commandInput.style.borderColor = '';
}, 1000);
}
// Dispatch event for UI controller to handle
this.canvas.dispatchEvent(new CustomEvent('moveCommand', {
detail: { x: tileX, y: tileY }
}));
}
updateCursor() {
if (this.isDragging) {
this.canvas.style.cursor = 'grabbing';
} else if (this.hoveredElement) {
this.canvas.style.cursor = 'pointer';
} else {
this.canvas.style.cursor = 'grab';
}
}
handleMouseDown(e) {
if (e.button === 0) {
this.isDragging = true;
const rect = this.canvas.getBoundingClientRect();
this.lastMouseX = e.clientX - rect.left;
this.lastMouseY = e.clientY - rect.top;
this.canvas.style.cursor = 'grabbing';
}
}
handleMouseUp(e) {
if (e.button === 0) {
this.isDragging = false;
this.updateCursor();
}
}
handleClick(e) {
if (this.isDragging) return;
const rect = this.canvas.getBoundingClientRect();
const screenX = e.clientX - rect.left;
const screenY = e.clientY - rect.top;
const worldPos = this.screenToWorld(screenX, screenY);
const clickedElement = this.getElementAtPosition(worldPos.x, worldPos.y);
console.debug('[handleClick] worldPos', worldPos, 'clickedElement', clickedElement);
if (clickedElement) {
this.clearSelection();
this.selectElement(clickedElement);
} else {
this.clearSelection();
}
this.revealArea(worldPos.x / this.tileSize, worldPos.y / this.tileSize, 5);
this.dirty = true;
}
handleWheel(e) {
e.preventDefault();
const rect = this.canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const worldBeforeZoom = this.screenToWorld(mouseX, mouseY);
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
this.zoom = Math.max(this.minZoom, Math.min(this.maxZoom, this.zoom * zoomFactor));
const worldAfterZoom = this.screenToWorld(mouseX, mouseY);
this.offsetX += (worldAfterZoom.x - worldBeforeZoom.x);
this.offsetY += (worldAfterZoom.y - worldBeforeZoom.y);
this.dirty = true;
}
zoomIn() {
this.zoom = Math.min(this.maxZoom, this.zoom * 1.2);
this.dirty = true;
}
zoomOut() {
this.zoom = Math.max(this.minZoom, this.zoom * 0.8);
this.dirty = true;
}
centerMap() {
this.offsetX = 0;
this.offsetY = 0;
this.dirty = true;
}
handleRightClick(e) {
e.preventDefault();
const rect = this.canvas.getBoundingClientRect();
const screenX = e.clientX - rect.left;
const screenY = e.clientY - rect.top;
const worldPos = this.screenToWorld(screenX, screenY);
this.showContextMenu(worldPos.x, worldPos.y, screenX, screenY);
}
handleTouchStart(e) {
e.preventDefault();
if (e.touches.length === 1) {
const touch = e.touches[0];
const rect = this.canvas.getBoundingClientRect();
this.lastMouseX = touch.clientX - rect.left;
this.lastMouseY = touch.clientY - rect.top;
this.isDragging = true;
}
}
handleTouchMove(e) {
e.preventDefault();
if (e.touches.length === 1 && this.isDragging) {
const touch = e.touches[0];
const rect = this.canvas.getBoundingClientRect();
const touchX = touch.clientX - rect.left;
const touchY = touch.clientY - rect.top;
const deltaX = touchX - this.lastMouseX;
const deltaY = touchY - this.lastMouseY;
this.offsetX += deltaX / this.zoom;
this.offsetY += deltaY / this.zoom;
this.lastMouseX = touchX;
this.lastMouseY = touchY;
this.dirty = true;
}
}
handleTouchEnd(e) {
e.preventDefault();
this.isDragging = false;
}
addTerrainEffects(ctx, x, y, tileType) {
const tileX = x * this.tileSize;
const tileY = y * this.tileSize;
switch (tileType.name) {
case 'water':
// Add water ripple effect
ctx.save();
ctx.globalAlpha = 0.3;
ctx.fillStyle = '#87ceeb';
ctx.fillRect(tileX + 2, tileY + 2, this.tileSize - 4, this.tileSize - 4);
ctx.restore();
break;
case 'grass':
// Add grass texture dots
ctx.fillStyle = '#32cd32';
for (let i = 0; i < 3; i++) {
const dotX = tileX + Math.random() * this.tileSize;
const dotY = tileY + Math.random() * this.tileSize;
ctx.fillRect(dotX, dotY, 1, 1);
}
break;
case 'trees':
// Add tree texture
ctx.fillStyle = '#228b22';
ctx.fillRect(tileX + 4, tileY + 4, this.tileSize - 8, this.tileSize - 8);
break;
case 'enclosure_fence':
// Add fence pattern
ctx.strokeStyle = '#8b4513';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(tileX, tileY + this.tileSize / 2);
ctx.lineTo(tileX + this.tileSize, tileY + this.tileSize / 2);
ctx.stroke();
break;
}
}
darkenColor(color, factor) {
// Simple color darkening function
const rgb = this.hexToRgb(color);
if (!rgb) return color;
const darken = (c) => Math.max(0, Math.floor(c * (1 - factor)));
return `rgb(${darken(rgb.r)}, ${darken(rgb.g)}, ${darken(rgb.b)})`;
}
hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
}
destroy() {
this.stopAnimationLoop();
window.removeEventListener('resize', this.resizeCanvas);
}
}
export function createMapCanvas(rootEl) {
const canvas = document.createElement('canvas');
canvas.id = 'rts-map-canvas';
canvas.width = 640;
canvas.height = 640;
if (rootEl) {
rootEl.appendChild(canvas);
}
return canvas;
}