999 lines
36 KiB
JavaScript
999 lines
36 KiB
JavaScript
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;
|
||
} |