Created
March 5, 2026 20:39
-
-
Save senko/596a657b4c0bfd5c8d08f44e4e5347b8 to your computer and use it in GitHub Desktop.
RTS game by GPT-5.4
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Frontier Command RTS</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com" /> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | |
| <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700&family=Rajdhani:wght@400;600;700&display=swap" rel="stylesheet" /> | |
| <style> | |
| :root { | |
| --bg0: #08131a; | |
| --bg1: #13262f; | |
| --panel: rgba(10, 18, 24, 0.92); | |
| --panel-border: rgba(130, 196, 218, 0.3); | |
| --accent: #7ce0c4; | |
| --accent-2: #ffcf68; | |
| --danger: #ff7a6b; | |
| --text: #edf7f5; | |
| --muted: #a8c1bc; | |
| --shadow: rgba(0, 0, 0, 0.4); | |
| } | |
| * { box-sizing: border-box; } | |
| body { | |
| margin: 0; | |
| min-height: 100vh; | |
| font-family: 'Rajdhani', sans-serif; | |
| color: var(--text); | |
| background: | |
| radial-gradient(circle at top, rgba(90, 165, 156, 0.15), transparent 35%), | |
| linear-gradient(160deg, #071016 0%, #0d1d25 45%, #142632 100%); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 8px; | |
| } | |
| .shell { | |
| width: 100%; | |
| height: calc(100vh - 16px); | |
| display: grid; | |
| grid-template-columns: minmax(0, 1fr) 340px; | |
| gap: 16px; | |
| align-items: stretch; | |
| } | |
| .game-panel, | |
| .side-panel { | |
| background: var(--panel); | |
| border: 1px solid var(--panel-border); | |
| box-shadow: 0 20px 60px var(--shadow); | |
| backdrop-filter: blur(10px); | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .game-panel::before, | |
| .side-panel::before { | |
| content: ''; | |
| position: absolute; | |
| inset: 0; | |
| background: linear-gradient(180deg, rgba(124, 224, 196, 0.08), transparent 120px); | |
| pointer-events: none; | |
| } | |
| .topbar { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| z-index: 2; | |
| display: flex; | |
| justify-content: space-between; | |
| gap: 12px; | |
| padding: 10px 14px; | |
| background: linear-gradient(180deg, rgba(7, 14, 19, 0.92), rgba(7, 14, 19, 0.35)); | |
| border-bottom: 1px solid rgba(124, 224, 196, 0.16); | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| font-family: 'Orbitron', sans-serif; | |
| font-size: 12px; | |
| } | |
| .resource-strip, | |
| .objective-strip { | |
| display: flex; | |
| gap: 12px; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| } | |
| .chip { | |
| border: 1px solid rgba(255, 255, 255, 0.12); | |
| padding: 5px 8px; | |
| background: rgba(255, 255, 255, 0.05); | |
| border-radius: 999px; | |
| white-space: nowrap; | |
| } | |
| .chip strong { color: var(--accent-2); } | |
| canvas#game { | |
| display: block; | |
| width: 100%; | |
| height: 100%; | |
| background: #10222b; | |
| cursor: crosshair; | |
| } | |
| .side-panel { | |
| padding: 16px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 14px; | |
| min-height: 0; | |
| overflow: auto; | |
| } | |
| .panel-title { | |
| margin: 0; | |
| font-family: 'Orbitron', sans-serif; | |
| font-size: 22px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| } | |
| .subtle { | |
| color: var(--muted); | |
| margin: 0; | |
| line-height: 1.3; | |
| font-size: 15px; | |
| } | |
| .card { | |
| position: relative; | |
| border: 1px solid rgba(255, 255, 255, 0.08); | |
| background: rgba(255, 255, 255, 0.04); | |
| padding: 12px; | |
| border-radius: 10px; | |
| overflow: hidden; | |
| } | |
| .card h2 { | |
| margin: 0 0 8px; | |
| font-family: 'Orbitron', sans-serif; | |
| font-size: 13px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| color: var(--accent); | |
| } | |
| .selection-name { | |
| font-size: 24px; | |
| font-weight: 700; | |
| line-height: 1; | |
| margin-bottom: 8px; | |
| } | |
| .stats { | |
| display: grid; | |
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| gap: 8px; | |
| font-size: 15px; | |
| } | |
| .stat { | |
| padding: 8px; | |
| background: rgba(0, 0, 0, 0.2); | |
| border-radius: 8px; | |
| } | |
| .progress-list, | |
| .queue-list, | |
| .hint-list { | |
| display: grid; | |
| gap: 8px; | |
| font-size: 15px; | |
| } | |
| .progress-row { | |
| display: grid; | |
| gap: 5px; | |
| } | |
| .bar { | |
| height: 10px; | |
| border-radius: 999px; | |
| overflow: hidden; | |
| background: rgba(255, 255, 255, 0.08); | |
| } | |
| .bar > span { | |
| display: block; | |
| height: 100%; | |
| border-radius: inherit; | |
| background: linear-gradient(90deg, var(--accent), var(--accent-2)); | |
| } | |
| #actions { | |
| display: grid; | |
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| gap: 8px; | |
| } | |
| .action-btn { | |
| border: 1px solid rgba(124, 224, 196, 0.28); | |
| background: linear-gradient(180deg, rgba(124, 224, 196, 0.18), rgba(124, 224, 196, 0.05)); | |
| color: var(--text); | |
| padding: 10px 10px 8px; | |
| border-radius: 10px; | |
| text-align: left; | |
| cursor: pointer; | |
| min-height: 68px; | |
| transition: transform 120ms ease, border-color 120ms ease, background 120ms ease; | |
| font: inherit; | |
| } | |
| .action-btn:hover:not(:disabled) { | |
| transform: translateY(-1px); | |
| border-color: rgba(255, 207, 104, 0.42); | |
| background: linear-gradient(180deg, rgba(255, 207, 104, 0.16), rgba(124, 224, 196, 0.08)); | |
| } | |
| .action-btn:disabled { | |
| opacity: 0.4; | |
| cursor: not-allowed; | |
| } | |
| .action-btn strong { | |
| display: block; | |
| font-family: 'Orbitron', sans-serif; | |
| font-size: 12px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.06em; | |
| margin-bottom: 4px; | |
| } | |
| .action-btn span { | |
| font-size: 14px; | |
| color: var(--muted); | |
| } | |
| .mini { | |
| width: 100%; | |
| aspect-ratio: 1; | |
| display: block; | |
| border-radius: 8px; | |
| background: rgba(0, 0, 0, 0.2); | |
| border: 1px solid rgba(255, 255, 255, 0.08); | |
| } | |
| .status-line { | |
| font-size: 15px; | |
| padding: 10px 12px; | |
| border-radius: 10px; | |
| background: rgba(0, 0, 0, 0.2); | |
| border: 1px solid rgba(255, 255, 255, 0.06); | |
| min-height: 48px; | |
| display: flex; | |
| align-items: center; | |
| } | |
| .status-line.alert { | |
| border-color: rgba(255, 122, 107, 0.32); | |
| color: #ffd8cf; | |
| } | |
| .overlay-message { | |
| position: absolute; | |
| inset: 0; | |
| display: none; | |
| align-items: center; | |
| justify-content: center; | |
| background: rgba(3, 8, 11, 0.76); | |
| z-index: 3; | |
| padding: 24px; | |
| text-align: center; | |
| } | |
| .overlay-message.visible { display: flex; } | |
| .overlay-message .inner { | |
| max-width: 520px; | |
| border: 1px solid rgba(124, 224, 196, 0.25); | |
| background: rgba(8, 17, 21, 0.92); | |
| padding: 28px; | |
| border-radius: 16px; | |
| box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45); | |
| } | |
| .overlay-message h2 { | |
| margin: 0 0 10px; | |
| font-family: 'Orbitron', sans-serif; | |
| font-size: 28px; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| } | |
| .overlay-message p { | |
| margin: 0; | |
| color: var(--muted); | |
| font-size: 17px; | |
| line-height: 1.4; | |
| } | |
| @media (max-width: 1120px) { | |
| body { | |
| padding: 0; | |
| } | |
| .shell { | |
| grid-template-columns: 1fr; | |
| height: auto; | |
| gap: 12px; | |
| } | |
| .side-panel { | |
| min-height: 0; | |
| } | |
| canvas#game { | |
| height: auto; | |
| aspect-ratio: 16 / 10; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="shell"> | |
| <div class="game-panel"> | |
| <div class="topbar"> | |
| <div class="resource-strip" id="resourceStrip"></div> | |
| <div class="objective-strip" id="objectiveStrip"></div> | |
| </div> | |
| <canvas id="game" width="1040" height="650"></canvas> | |
| <div class="overlay-message" id="victoryOverlay"> | |
| <div class="inner"> | |
| <h2>Map Secured</h2> | |
| <p>You explored the entire frontier, established production, and locked down every sector. Press reload if you want a fresh map.</p> | |
| </div> | |
| </div> | |
| </div> | |
| <aside class="side-panel"> | |
| <div> | |
| <h1 class="panel-title">Frontier Command</h1> | |
| <p class="subtle">Single-player RTS skirmish. Build a base, train units, mine ore, cut lumber, and reveal the entire battlefield.</p> | |
| </div> | |
| <section class="card"> | |
| <h2>Selection</h2> | |
| <div id="selectionInfo"></div> | |
| </section> | |
| <section class="card"> | |
| <h2>Actions</h2> | |
| <div id="actions"></div> | |
| </section> | |
| <section class="card"> | |
| <h2>Production</h2> | |
| <div class="queue-list" id="queueInfo"></div> | |
| </section> | |
| <section class="card"> | |
| <h2>Minimap</h2> | |
| <canvas id="minimap" class="mini" width="260" height="260"></canvas> | |
| </section> | |
| <section class="card"> | |
| <h2>Orders</h2> | |
| <div class="status-line" id="statusLine">Select a worker to build, or drag to box-select your units.</div> | |
| </section> | |
| <section class="card"> | |
| <h2>Controls</h2> | |
| <div class="hint-list"> | |
| <div>Left click selects. Drag to box-select units.</div> | |
| <div>Right click to move units, harvest a resource, or set a worker to build.</div> | |
| <div>Use `WASD` or arrow keys to scroll the camera.</div> | |
| <div>Ore funds industry, lumber unlocks faster expansion and advanced troops.</div> | |
| </div> | |
| </section> | |
| </aside> | |
| </div> | |
| <script> | |
| const canvas = document.getElementById('game'); | |
| const ctx = canvas.getContext('2d'); | |
| const minimap = document.getElementById('minimap'); | |
| const miniCtx = minimap.getContext('2d'); | |
| const resourceStrip = document.getElementById('resourceStrip'); | |
| const objectiveStrip = document.getElementById('objectiveStrip'); | |
| const selectionInfo = document.getElementById('selectionInfo'); | |
| const actionsEl = document.getElementById('actions'); | |
| const queueInfo = document.getElementById('queueInfo'); | |
| const statusLine = document.getElementById('statusLine'); | |
| const victoryOverlay = document.getElementById('victoryOverlay'); | |
| const VIEW_W = canvas.width; | |
| const VIEW_H = canvas.height; | |
| const MAP_W = 3200; | |
| const MAP_H = 2200; | |
| const GRID = 40; | |
| const FOG_W = Math.ceil(MAP_W / GRID); | |
| const FOG_H = Math.ceil(MAP_H / GRID); | |
| const TAU = Math.PI * 2; | |
| let nextId = 1; | |
| let lastTime = 0; | |
| const game = { | |
| camera: { x: 0, y: 0, speed: 520 }, | |
| keys: new Set(), | |
| mouse: { x: 0, y: 0, worldX: 0, worldY: 0 }, | |
| selection: [], | |
| units: [], | |
| buildings: [], | |
| resources: [], | |
| resourcesStock: { ore: 420, lumber: 240 }, | |
| supply: { used: 0, cap: 0 }, | |
| buildMode: null, | |
| buildPreview: null, | |
| drag: null, | |
| explored: new Array(FOG_W * FOG_H).fill(false), | |
| visible: new Array(FOG_W * FOG_H).fill(false), | |
| message: 'Select a worker to build, or drag to box-select your units.', | |
| alert: false, | |
| victory: false, | |
| terrainSeeds: [], | |
| uiTimer: 0, | |
| }; | |
| const UnitDefs = { | |
| worker: { | |
| name: 'Worker', | |
| speed: 92, | |
| radius: 11, | |
| color: '#ffc56a', | |
| sight: 175, | |
| hp: 70, | |
| cost: { ore: 50, lumber: 0, supply: 1 }, | |
| gatherRate: 12, | |
| carryMax: 30, | |
| buildOptions: ['barracks', 'depot'], | |
| }, | |
| rifle: { | |
| name: 'Rifleman', | |
| speed: 84, | |
| radius: 10, | |
| color: '#7cccf6', | |
| sight: 190, | |
| hp: 90, | |
| cost: { ore: 80, lumber: 20, supply: 1 }, | |
| }, | |
| scout: { | |
| name: 'Scout', | |
| speed: 124, | |
| radius: 9, | |
| color: '#7ce0c4', | |
| sight: 245, | |
| hp: 65, | |
| cost: { ore: 60, lumber: 45, supply: 1 }, | |
| }, | |
| }; | |
| const BuildingDefs = { | |
| hq: { | |
| name: 'Command Core', | |
| width: 110, | |
| height: 110, | |
| hp: 900, | |
| sight: 210, | |
| buildTime: 0, | |
| color: '#c0d4d8', | |
| production: ['worker'], | |
| dropOff: true, | |
| supply: 8, | |
| }, | |
| barracks: { | |
| name: 'Barracks', | |
| width: 94, | |
| height: 84, | |
| hp: 560, | |
| sight: 180, | |
| buildTime: 14, | |
| color: '#ec8d74', | |
| production: ['rifle', 'scout'], | |
| cost: { ore: 150, lumber: 90 }, | |
| }, | |
| depot: { | |
| name: 'Field Depot', | |
| width: 84, | |
| height: 74, | |
| hp: 380, | |
| sight: 150, | |
| buildTime: 11, | |
| color: '#77ddcf', | |
| dropOff: true, | |
| supply: 6, | |
| cost: { ore: 90, lumber: 60 }, | |
| }, | |
| }; | |
| const ProductionDefs = { | |
| worker: { time: 7 }, | |
| rifle: { time: 8 }, | |
| scout: { time: 9 }, | |
| }; | |
| const uiActions = { | |
| train_worker: { | |
| label: 'Train Worker', | |
| description: '50 ore', | |
| run() { | |
| const hq = getSingleSelectedBuilding('hq'); | |
| if (hq) queueProduction(hq, 'worker'); | |
| }, | |
| }, | |
| train_rifle: { | |
| label: 'Train Rifleman', | |
| description: '80 ore, 20 lumber', | |
| run() { | |
| const barracks = getSingleSelectedBuilding('barracks'); | |
| if (barracks) queueProduction(barracks, 'rifle'); | |
| }, | |
| }, | |
| train_scout: { | |
| label: 'Train Scout', | |
| description: '60 ore, 45 lumber', | |
| run() { | |
| const barracks = getSingleSelectedBuilding('barracks'); | |
| if (barracks) queueProduction(barracks, 'scout'); | |
| }, | |
| }, | |
| build_barracks: { | |
| label: 'Place Barracks', | |
| description: '150 ore, 90 lumber', | |
| run() { | |
| beginBuildMode('barracks'); | |
| }, | |
| }, | |
| build_depot: { | |
| label: 'Place Depot', | |
| description: '90 ore, 60 lumber', | |
| run() { | |
| beginBuildMode('depot'); | |
| }, | |
| }, | |
| cancel_mode: { | |
| label: 'Cancel Order', | |
| description: 'Exit placement mode', | |
| run() { | |
| game.buildMode = null; | |
| game.buildPreview = null; | |
| setMessage('Placement mode cancelled.'); | |
| }, | |
| }, | |
| }; | |
| function seededNoise(x, y) { | |
| const v = Math.sin(x * 127.1 + y * 311.7) * 43758.5453123; | |
| return v - Math.floor(v); | |
| } | |
| function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); } | |
| function dist(ax, ay, bx, by) { return Math.hypot(ax - bx, ay - by); } | |
| function lerp(a, b, t) { return a + (b - a) * t; } | |
| function worldToScreen(x, y) { return { x: x - game.camera.x, y: y - game.camera.y }; } | |
| function screenToWorld(x, y) { return { x: x + game.camera.x, y: y + game.camera.y }; } | |
| function fmtPercent(v) { return `${Math.round(v * 100)}%`; } | |
| function hasSelectedWorker() { return game.selection.some(e => e.kind === 'unit' && e.type === 'worker'); } | |
| function setMessage(text, alert = false) { | |
| game.message = text; | |
| game.alert = alert; | |
| statusLine.textContent = text; | |
| statusLine.className = `status-line${alert ? ' alert' : ''}`; | |
| } | |
| function createUnit(type, x, y) { | |
| const def = UnitDefs[type]; | |
| const unit = { | |
| id: nextId++, | |
| kind: 'unit', | |
| type, | |
| x, | |
| y, | |
| radius: def.radius, | |
| hp: def.hp, | |
| maxHp: def.hp, | |
| selected: false, | |
| target: null, | |
| facing: Math.random() * TAU, | |
| state: 'idle', | |
| harvestTargetId: null, | |
| carry: { ore: 0, lumber: 0 }, | |
| gatherTimer: 0, | |
| assignedSiteId: null, | |
| }; | |
| game.units.push(unit); | |
| game.supply.used += def.cost.supply; | |
| return unit; | |
| } | |
| function createBuilding(type, x, y, opts = {}) { | |
| const def = BuildingDefs[type]; | |
| const building = { | |
| id: nextId++, | |
| kind: 'building', | |
| type, | |
| x, | |
| y, | |
| width: def.width, | |
| height: def.height, | |
| hp: opts.underConstruction ? Math.max(40, def.hp * 0.15) : def.hp, | |
| maxHp: def.hp, | |
| sight: def.sight, | |
| selected: false, | |
| underConstruction: !!opts.underConstruction, | |
| progress: opts.underConstruction ? 0.02 : 1, | |
| queue: [], | |
| assignedBuilderId: opts.assignedBuilderId || null, | |
| }; | |
| game.buildings.push(building); | |
| if (!opts.underConstruction && def.supply) { | |
| game.supply.cap += def.supply; | |
| } | |
| return building; | |
| } | |
| function createResource(type, x, y, amount) { | |
| const radius = type === 'ore' ? 28 : 34; | |
| const resource = { id: nextId++, kind: 'resource', type, x, y, amount, radius }; | |
| game.resources.push(resource); | |
| return resource; | |
| } | |
| function initWorld() { | |
| const hq = createBuilding('hq', 440, 410); | |
| createUnit('worker', 570, 470); | |
| createUnit('worker', 610, 430); | |
| createUnit('worker', 540, 370); | |
| createUnit('scout', 650, 470); | |
| const orePatches = [ | |
| [900, 540], [1180, 370], [1640, 780], [2050, 580], [2400, 900], [2860, 1600], | |
| ]; | |
| const lumberPatches = [ | |
| [780, 900], [1400, 520], [1840, 1280], [2250, 720], [2600, 1320], [2920, 500], [1100, 1650], | |
| ]; | |
| orePatches.forEach(([x, y], i) => { | |
| for (let j = 0; j < 4; j++) { | |
| createResource('ore', x + Math.cos(j * 1.7 + i) * 52, y + Math.sin(j * 1.7 + i) * 44, 400); | |
| } | |
| }); | |
| lumberPatches.forEach(([x, y], i) => { | |
| for (let j = 0; j < 5; j++) { | |
| createResource('lumber', x + Math.cos(j * 1.23 + i) * 48, y + Math.sin(j * 1.23 + i) * 52, 320); | |
| } | |
| }); | |
| game.camera.x = clamp(hq.x - VIEW_W * 0.4, 0, MAP_W - VIEW_W); | |
| game.camera.y = clamp(hq.y - VIEW_H * 0.45, 0, MAP_H - VIEW_H); | |
| setMessage('Base established. Mine ore, harvest lumber, and explore the frontier.'); | |
| } | |
| function canAfford(cost) { | |
| return game.resourcesStock.ore >= (cost.ore || 0) && game.resourcesStock.lumber >= (cost.lumber || 0); | |
| } | |
| function queuedSupply() { | |
| let reserved = 0; | |
| for (const building of game.buildings) { | |
| for (const item of building.queue) { | |
| reserved += UnitDefs[item.type].cost.supply || 0; | |
| } | |
| } | |
| return reserved; | |
| } | |
| function spend(cost) { | |
| game.resourcesStock.ore -= cost.ore || 0; | |
| game.resourcesStock.lumber -= cost.lumber || 0; | |
| } | |
| function refund(cost) { | |
| game.resourcesStock.ore += cost.ore || 0; | |
| game.resourcesStock.lumber += cost.lumber || 0; | |
| } | |
| function getSingleSelectedBuilding(type = null) { | |
| if (game.selection.length !== 1 || game.selection[0].kind !== 'building') return null; | |
| const b = game.selection[0]; | |
| if (type && b.type !== type) return null; | |
| return b; | |
| } | |
| function queueProduction(building, unitType) { | |
| const unitDef = UnitDefs[unitType]; | |
| if (building.underConstruction) { | |
| setMessage('Construction must finish before production begins.', true); | |
| return; | |
| } | |
| if (!canAfford(unitDef.cost)) { | |
| setMessage('Insufficient resources for that unit.', true); | |
| return; | |
| } | |
| if (game.supply.used + queuedSupply() + unitDef.cost.supply > game.supply.cap) { | |
| setMessage('Supply capped. Build a Field Depot first.', true); | |
| return; | |
| } | |
| spend(unitDef.cost); | |
| building.queue.push({ type: unitType, progress: 0, time: ProductionDefs[unitType].time }); | |
| setMessage(`${UnitDefs[unitType].name} added to ${BuildingDefs[building.type].name} queue.`); | |
| updateUI(); | |
| } | |
| function beginBuildMode(type) { | |
| const def = BuildingDefs[type]; | |
| if (!hasSelectedWorker()) { | |
| setMessage('Select at least one worker to place buildings.', true); | |
| return; | |
| } | |
| if (!canAfford(def.cost)) { | |
| setMessage(`Need ${def.cost.ore} ore and ${def.cost.lumber} lumber.`, true); | |
| return; | |
| } | |
| game.buildMode = type; | |
| setMessage(`Placement mode: click the battlefield to place a ${def.name}.`); | |
| } | |
| function issueMoveOrder(units, x, y) { | |
| if (!units.length) return; | |
| const cols = Math.ceil(Math.sqrt(units.length)); | |
| const spacing = 26; | |
| const startX = x - ((cols - 1) * spacing) / 2; | |
| const rows = Math.ceil(units.length / cols); | |
| const startY = y - ((rows - 1) * spacing) / 2; | |
| units.forEach((unit, i) => { | |
| const row = Math.floor(i / cols); | |
| const col = i % cols; | |
| unit.target = { | |
| type: 'move', | |
| x: clamp(startX + col * spacing, unit.radius, MAP_W - unit.radius), | |
| y: clamp(startY + row * spacing, unit.radius, MAP_H - unit.radius), | |
| }; | |
| unit.state = 'moving'; | |
| unit.harvestTargetId = null; | |
| unit.assignedSiteId = null; | |
| }); | |
| setMessage(`Move order issued to ${units.length} unit${units.length > 1 ? 's' : ''}.`); | |
| } | |
| function issueHarvestOrder(workers, resource) { | |
| workers.forEach(worker => { | |
| worker.harvestTargetId = resource.id; | |
| worker.target = { type: 'harvest', x: resource.x, y: resource.y }; | |
| worker.state = 'movingToHarvest'; | |
| worker.assignedSiteId = null; | |
| }); | |
| setMessage(`Workers assigned to ${resource.type === 'ore' ? 'ore' : 'lumber'} harvesting.`); | |
| } | |
| function issueBuildOrder(buildingType, x, y) { | |
| const workers = game.selection.filter(e => e.kind === 'unit' && e.type === 'worker'); | |
| if (!workers.length) { | |
| setMessage('You need a selected worker to build.', true); | |
| return; | |
| } | |
| const def = BuildingDefs[buildingType]; | |
| if (!canAfford(def.cost)) { | |
| setMessage('Insufficient resources for that building.', true); | |
| return; | |
| } | |
| const placement = { x, y, width: def.width, height: def.height }; | |
| if (!isBuildable(placement)) { | |
| setMessage('Invalid construction site. Keep clear of resources and buildings.', true); | |
| return; | |
| } | |
| spend(def.cost); | |
| const builder = workers[0]; | |
| const site = createBuilding(buildingType, x, y, { underConstruction: true, assignedBuilderId: builder.id }); | |
| const approach = getApproachPoint(site, builder); | |
| builder.assignedSiteId = site.id; | |
| builder.harvestTargetId = null; | |
| builder.target = { type: 'build', x: approach.x, y: approach.y }; | |
| builder.state = 'movingToBuild'; | |
| game.buildMode = null; | |
| game.buildPreview = null; | |
| setMessage(`${def.name} placement confirmed. Worker moving to construction site.`); | |
| updateUI(); | |
| } | |
| function isBuildable(rect) { | |
| const left = rect.x - rect.width / 2; | |
| const top = rect.y - rect.height / 2; | |
| const right = rect.x + rect.width / 2; | |
| const bottom = rect.y + rect.height / 2; | |
| if (left < 40 || top < 40 || right > MAP_W - 40 || bottom > MAP_H - 40) return false; | |
| for (const building of game.buildings) { | |
| const pad = building.underConstruction ? 22 : 28; | |
| if (Math.abs(building.x - rect.x) < (building.width + rect.width) / 2 + pad && | |
| Math.abs(building.y - rect.y) < (building.height + rect.height) / 2 + pad) { | |
| return false; | |
| } | |
| } | |
| for (const resource of game.resources) { | |
| if (resource.amount <= 0) continue; | |
| const cx = clamp(resource.x, left, right); | |
| const cy = clamp(resource.y, top, bottom); | |
| if (dist(resource.x, resource.y, cx, cy) < resource.radius + 20) return false; | |
| } | |
| return true; | |
| } | |
| function clearSelection() { | |
| game.selection.forEach(entity => entity.selected = false); | |
| game.selection = []; | |
| } | |
| function setSelection(entities) { | |
| clearSelection(); | |
| entities.forEach(e => e.selected = true); | |
| game.selection = entities; | |
| updateUI(); | |
| } | |
| function pickEntity(worldX, worldY) { | |
| for (let i = game.units.length - 1; i >= 0; i--) { | |
| const unit = game.units[i]; | |
| if (dist(worldX, worldY, unit.x, unit.y) <= unit.radius + 4) return unit; | |
| } | |
| for (let i = game.buildings.length - 1; i >= 0; i--) { | |
| const building = game.buildings[i]; | |
| if (Math.abs(worldX - building.x) <= building.width / 2 && Math.abs(worldY - building.y) <= building.height / 2) return building; | |
| } | |
| return null; | |
| } | |
| function pickResource(worldX, worldY) { | |
| for (let i = game.resources.length - 1; i >= 0; i--) { | |
| const resource = game.resources[i]; | |
| if (resource.amount > 0 && dist(worldX, worldY, resource.x, resource.y) <= resource.radius + 5) return resource; | |
| } | |
| return null; | |
| } | |
| function selectedUnits() { | |
| return game.selection.filter(e => e.kind === 'unit'); | |
| } | |
| function nearestDropOff(x, y) { | |
| let best = null; | |
| let bestDist = Infinity; | |
| for (const building of game.buildings) { | |
| const def = BuildingDefs[building.type]; | |
| if (building.underConstruction || !def.dropOff) continue; | |
| const d = dist(x, y, building.x, building.y); | |
| if (d < bestDist) { | |
| best = building; | |
| bestDist = d; | |
| } | |
| } | |
| return best; | |
| } | |
| function getApproachPoint(building, unit, padding = 18) { | |
| const sidePad = padding + unit.radius; | |
| const dx = unit.x - building.x; | |
| const dy = unit.y - building.y; | |
| if (Math.abs(dx) >= Math.abs(dy)) { | |
| const side = dx >= 0 ? 1 : -1; | |
| return { | |
| x: clamp(building.x + side * (building.width / 2 + sidePad), unit.radius, MAP_W - unit.radius), | |
| y: clamp(unit.y, unit.radius, MAP_H - unit.radius), | |
| }; | |
| } | |
| const side = dy >= 0 ? 1 : -1; | |
| return { | |
| x: clamp(unit.x, unit.radius, MAP_W - unit.radius), | |
| y: clamp(building.y + side * (building.height / 2 + sidePad), unit.radius, MAP_H - unit.radius), | |
| }; | |
| } | |
| function spawnUnitNear(building, unitType) { | |
| const def = UnitDefs[unitType]; | |
| const attempts = [ | |
| [building.width * 0.65, 0], [0, building.height * 0.75], [0, -building.height * 0.75], | |
| [-building.width * 0.7, 0], [building.width * 0.7, building.height * 0.5], [building.width * 0.7, -building.height * 0.5], | |
| ]; | |
| let pos = { x: building.x + building.width * 0.65, y: building.y }; | |
| for (const [dx, dy] of attempts) { | |
| const test = { x: clamp(building.x + dx, def.radius, MAP_W - def.radius), y: clamp(building.y + dy, def.radius, MAP_H - def.radius) }; | |
| if (!isPointBlocked(test.x, test.y, def.radius + 2)) { | |
| pos = test; | |
| break; | |
| } | |
| } | |
| return createUnit(unitType, pos.x, pos.y); | |
| } | |
| function isPointBlocked(x, y, radius = 8) { | |
| for (const building of game.buildings) { | |
| if (Math.abs(x - building.x) < building.width / 2 + radius && Math.abs(y - building.y) < building.height / 2 + radius) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| function updateCamera(dt) { | |
| let dx = 0; | |
| let dy = 0; | |
| if (game.keys.has('ArrowLeft') || game.keys.has('a')) dx -= 1; | |
| if (game.keys.has('ArrowRight') || game.keys.has('d')) dx += 1; | |
| if (game.keys.has('ArrowUp') || game.keys.has('w')) dy -= 1; | |
| if (game.keys.has('ArrowDown') || game.keys.has('s')) dy += 1; | |
| const edge = 18; | |
| if (game.mouse.x < edge) dx -= 1; | |
| if (game.mouse.x > VIEW_W - edge) dx += 1; | |
| if (game.mouse.y < edge) dy -= 1; | |
| if (game.mouse.y > VIEW_H - edge) dy += 1; | |
| if (dx || dy) { | |
| const len = Math.hypot(dx, dy) || 1; | |
| game.camera.x = clamp(game.camera.x + (dx / len) * game.camera.speed * dt, 0, MAP_W - VIEW_W); | |
| game.camera.y = clamp(game.camera.y + (dy / len) * game.camera.speed * dt, 0, MAP_H - VIEW_H); | |
| } | |
| } | |
| function updateUnits(dt) { | |
| for (const unit of game.units) { | |
| const def = UnitDefs[unit.type]; | |
| if (unit.target) { | |
| const dx = unit.target.x - unit.x; | |
| const dy = unit.target.y - unit.y; | |
| const distance = Math.hypot(dx, dy); | |
| if (distance > 3) { | |
| const step = Math.min(distance, def.speed * dt); | |
| const nx = unit.x + dx / distance * step; | |
| const ny = unit.y + dy / distance * step; | |
| if (!isPointBlocked(nx, ny, unit.radius + 1)) { | |
| unit.x = nx; | |
| unit.y = ny; | |
| } | |
| unit.facing = Math.atan2(dy, dx); | |
| } else { | |
| resolveArrival(unit); | |
| } | |
| } | |
| if (unit.type === 'worker') { | |
| updateWorker(unit, dt); | |
| } | |
| } | |
| } | |
| function resolveArrival(unit) { | |
| if (!unit.target) return; | |
| if (unit.target.type === 'move') { | |
| unit.state = 'idle'; | |
| unit.target = null; | |
| } else if (unit.target.type === 'harvest') { | |
| unit.target = null; | |
| unit.state = 'harvesting'; | |
| } else if (unit.target.type === 'deliver') { | |
| unit.target = null; | |
| unit.state = 'delivering'; | |
| } else if (unit.target.type === 'build') { | |
| unit.target = null; | |
| unit.state = 'constructing'; | |
| } | |
| } | |
| function updateWorker(worker, dt) { | |
| if (worker.state === 'harvesting') { | |
| const node = game.resources.find(r => r.id === worker.harvestTargetId && r.amount > 0); | |
| if (!node) { | |
| worker.state = 'idle'; | |
| worker.harvestTargetId = null; | |
| return; | |
| } | |
| if (dist(worker.x, worker.y, node.x, node.y) > node.radius + worker.radius + 8) { | |
| worker.target = { type: 'harvest', x: node.x, y: node.y }; | |
| worker.state = 'movingToHarvest'; | |
| return; | |
| } | |
| worker.gatherTimer += dt; | |
| if (worker.gatherTimer >= 0.75) { | |
| worker.gatherTimer = 0; | |
| const resourceType = node.type; | |
| const remainingCap = UnitDefs.worker.carryMax - (worker.carry.ore + worker.carry.lumber); | |
| const amount = Math.min(UnitDefs.worker.gatherRate, remainingCap, node.amount); | |
| node.amount -= amount; | |
| worker.carry[resourceType] += amount; | |
| if (node.amount <= 0) { | |
| worker.harvestTargetId = null; | |
| } | |
| if (worker.carry.ore + worker.carry.lumber >= UnitDefs.worker.carryMax || node.amount <= 0) { | |
| const depot = nearestDropOff(worker.x, worker.y); | |
| if (depot) { | |
| const approach = getApproachPoint(depot, worker); | |
| worker.target = { type: 'deliver', x: approach.x, y: approach.y, buildingId: depot.id }; | |
| worker.state = 'movingToDeliver'; | |
| } | |
| } | |
| } | |
| } else if (worker.state === 'delivering') { | |
| if (worker.carry.ore || worker.carry.lumber) { | |
| game.resourcesStock.ore += worker.carry.ore; | |
| game.resourcesStock.lumber += worker.carry.lumber; | |
| worker.carry.ore = 0; | |
| worker.carry.lumber = 0; | |
| } | |
| const node = game.resources.find(r => r.id === worker.harvestTargetId && r.amount > 0); | |
| if (node) { | |
| worker.target = { type: 'harvest', x: node.x, y: node.y }; | |
| worker.state = 'movingToHarvest'; | |
| } else { | |
| worker.state = 'idle'; | |
| worker.target = null; | |
| } | |
| } else if (worker.state === 'constructing') { | |
| const site = game.buildings.find(b => b.id === worker.assignedSiteId); | |
| if (!site || !site.underConstruction) { | |
| worker.assignedSiteId = null; | |
| worker.state = 'idle'; | |
| return; | |
| } | |
| const approach = getApproachPoint(site, worker); | |
| if (dist(worker.x, worker.y, approach.x, approach.y) > 10) { | |
| worker.target = { type: 'build', x: approach.x, y: approach.y }; | |
| worker.state = 'movingToBuild'; | |
| return; | |
| } | |
| site.progress = Math.min(1, site.progress + dt / BuildingDefs[site.type].buildTime); | |
| site.hp = lerp(site.maxHp * 0.2, site.maxHp, site.progress); | |
| if (site.progress >= 1) { | |
| site.underConstruction = false; | |
| site.assignedBuilderId = null; | |
| worker.assignedSiteId = null; | |
| worker.state = 'idle'; | |
| if (BuildingDefs[site.type].supply) game.supply.cap += BuildingDefs[site.type].supply; | |
| setMessage(`${BuildingDefs[site.type].name} construction complete.`); | |
| } | |
| } | |
| } | |
| function updateBuildings(dt) { | |
| for (const building of game.buildings) { | |
| if (building.underConstruction || !building.queue.length) continue; | |
| const current = building.queue[0]; | |
| current.progress += dt / current.time; | |
| if (current.progress >= 1) { | |
| const unit = spawnUnitNear(building, current.type); | |
| building.queue.shift(); | |
| setMessage(`${UnitDefs[unit.type].name} ready.`); | |
| } | |
| } | |
| } | |
| function updateFog() { | |
| game.visible.fill(false); | |
| const revealers = [...game.units, ...game.buildings.filter(b => !b.underConstruction)]; | |
| for (const entity of revealers) { | |
| const sight = entity.kind === 'unit' ? UnitDefs[entity.type].sight : entity.sight; | |
| const minX = clamp(Math.floor((entity.x - sight) / GRID), 0, FOG_W - 1); | |
| const maxX = clamp(Math.ceil((entity.x + sight) / GRID), 0, FOG_W - 1); | |
| const minY = clamp(Math.floor((entity.y - sight) / GRID), 0, FOG_H - 1); | |
| const maxY = clamp(Math.ceil((entity.y + sight) / GRID), 0, FOG_H - 1); | |
| const r2 = sight * sight; | |
| for (let gy = minY; gy <= maxY; gy++) { | |
| for (let gx = minX; gx <= maxX; gx++) { | |
| const cx = gx * GRID + GRID / 2; | |
| const cy = gy * GRID + GRID / 2; | |
| if ((cx - entity.x) ** 2 + (cy - entity.y) ** 2 <= r2) { | |
| const idx = gy * FOG_W + gx; | |
| game.visible[idx] = true; | |
| game.explored[idx] = true; | |
| } | |
| } | |
| } | |
| } | |
| const exploredCount = game.explored.reduce((sum, v) => sum + (v ? 1 : 0), 0); | |
| const pct = exploredCount / game.explored.length; | |
| if (pct >= 0.999 && !game.victory) { | |
| game.victory = true; | |
| victoryOverlay.classList.add('visible'); | |
| setMessage('Entire map revealed. Mission complete.'); | |
| } | |
| } | |
| function updateBuildPreview() { | |
| if (!game.buildMode) { | |
| game.buildPreview = null; | |
| return; | |
| } | |
| const def = BuildingDefs[game.buildMode]; | |
| const { worldX, worldY } = game.mouse; | |
| const snapX = Math.round(worldX / 10) * 10; | |
| const snapY = Math.round(worldY / 10) * 10; | |
| game.buildPreview = { | |
| type: game.buildMode, | |
| x: snapX, | |
| y: snapY, | |
| width: def.width, | |
| height: def.height, | |
| }; | |
| } | |
| function renderTerrain() { | |
| const cell = 80; | |
| const startX = Math.floor(game.camera.x / cell) * cell; | |
| const startY = Math.floor(game.camera.y / cell) * cell; | |
| for (let y = startY; y < game.camera.y + VIEW_H + cell; y += cell) { | |
| for (let x = startX; x < game.camera.x + VIEW_W + cell; x += cell) { | |
| const nx = Math.floor(x / cell); | |
| const ny = Math.floor(y / cell); | |
| const a = seededNoise(nx * 0.45, ny * 0.45); | |
| const b = seededNoise(nx * 0.81 + 8, ny * 0.81 + 3); | |
| const mix = a * 0.65 + b * 0.35; | |
| const sx = x - game.camera.x; | |
| const sy = y - game.camera.y; | |
| ctx.fillStyle = mix > 0.56 ? '#284936' : mix > 0.43 ? '#314f3d' : '#3f5746'; | |
| ctx.fillRect(sx, sy, cell, cell); | |
| ctx.fillStyle = `rgba(255,255,255,${0.015 + seededNoise(nx * 2.1, ny * 1.7) * 0.02})`; | |
| ctx.fillRect(sx, sy, cell, cell); | |
| } | |
| } | |
| ctx.strokeStyle = 'rgba(255,255,255,0.035)'; | |
| ctx.lineWidth = 1; | |
| for (let x = -((game.camera.x % 40)); x < VIEW_W; x += 40) { | |
| ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, VIEW_H); ctx.stroke(); | |
| } | |
| for (let y = -((game.camera.y % 40)); y < VIEW_H; y += 40) { | |
| ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(VIEW_W, y); ctx.stroke(); | |
| } | |
| const road = worldToScreen(500, 420); | |
| const roadGrad = ctx.createLinearGradient(road.x - 130, road.y, road.x + 160, road.y + 80); | |
| roadGrad.addColorStop(0, 'rgba(180, 165, 130, 0.26)'); | |
| roadGrad.addColorStop(1, 'rgba(120, 110, 90, 0.08)'); | |
| ctx.fillStyle = roadGrad; | |
| ctx.beginPath(); | |
| ctx.ellipse(road.x, road.y + 30, 220, 120, 0.24, 0, TAU); | |
| ctx.fill(); | |
| } | |
| function drawResource(resource) { | |
| const { x, y } = worldToScreen(resource.x, resource.y); | |
| if (x < -80 || y < -80 || x > VIEW_W + 80 || y > VIEW_H + 80 || resource.amount <= 0) return; | |
| const alpha = 0.72 + 0.18 * Math.sin(performance.now() * 0.003 + resource.id); | |
| ctx.save(); | |
| ctx.translate(x, y); | |
| if (resource.type === 'ore') { | |
| ctx.shadowBlur = 18; | |
| ctx.shadowColor = 'rgba(90, 210, 255, 0.45)'; | |
| for (let i = 0; i < 3; i++) { | |
| ctx.fillStyle = `rgba(110, 220, 255, ${alpha - i * 0.12})`; | |
| ctx.beginPath(); | |
| ctx.moveTo(-10 + i * 9, 22); | |
| ctx.lineTo(-22 + i * 10, -8); | |
| ctx.lineTo(-8 + i * 10, -30); | |
| ctx.lineTo(12 + i * 9, -4); | |
| ctx.lineTo(4 + i * 8, 20); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| } | |
| } else { | |
| for (let i = 0; i < 3; i++) { | |
| ctx.fillStyle = ['#143823', '#1a4e2c', '#26693d'][i]; | |
| ctx.beginPath(); | |
| ctx.arc(-12 + i * 13, -6 - i * 2, 18, 0, TAU); | |
| ctx.fill(); | |
| } | |
| ctx.fillStyle = '#5f3d23'; | |
| ctx.fillRect(-4, 10, 8, 18); | |
| } | |
| ctx.restore(); | |
| } | |
| function drawBuilding(building) { | |
| const { x, y } = worldToScreen(building.x, building.y); | |
| const w = building.width; | |
| const h = building.height; | |
| if (x < -w || y < -h || x > VIEW_W + w || y > VIEW_H + h) return; | |
| ctx.save(); | |
| ctx.translate(x, y); | |
| ctx.shadowBlur = 26; | |
| ctx.shadowColor = 'rgba(0,0,0,0.28)'; | |
| ctx.fillStyle = 'rgba(0,0,0,0.18)'; | |
| ctx.beginPath(); | |
| ctx.ellipse(0, h * 0.34, w * 0.46, h * 0.2, 0, 0, TAU); | |
| ctx.fill(); | |
| ctx.shadowBlur = 0; | |
| if (building.type === 'hq') { | |
| const grad = ctx.createLinearGradient(-w / 2, -h / 2, w / 2, h / 2); | |
| grad.addColorStop(0, '#cdd7d8'); | |
| grad.addColorStop(1, '#596e77'); | |
| ctx.fillStyle = grad; | |
| roundRect(ctx, -w / 2, -h / 2, w, h, 14); ctx.fill(); | |
| ctx.fillStyle = '#233743'; | |
| roundRect(ctx, -w * 0.34, -h * 0.36, w * 0.68, h * 0.42, 10); ctx.fill(); | |
| ctx.fillStyle = '#ffcf68'; | |
| ctx.fillRect(-w * 0.13, -h * 0.42, w * 0.26, h * 0.17); | |
| ctx.fillStyle = '#85e0ff'; | |
| ctx.fillRect(-w * 0.22, -2, w * 0.44, h * 0.15); | |
| } else if (building.type === 'barracks') { | |
| const grad = ctx.createLinearGradient(-w / 2, -h / 2, w / 2, h / 2); | |
| grad.addColorStop(0, '#ef9f7f'); | |
| grad.addColorStop(1, '#6f4545'); | |
| ctx.fillStyle = grad; | |
| roundRect(ctx, -w / 2, -h / 2, w, h, 12); ctx.fill(); | |
| ctx.fillStyle = '#3a2025'; | |
| roundRect(ctx, -w * 0.4, -h * 0.2, w * 0.8, h * 0.44, 8); ctx.fill(); | |
| ctx.fillStyle = '#ffd5a8'; | |
| ctx.fillRect(-w * 0.14, -h * 0.42, w * 0.28, h * 0.16); | |
| } else { | |
| const grad = ctx.createLinearGradient(-w / 2, -h / 2, w / 2, h / 2); | |
| grad.addColorStop(0, '#87efd8'); | |
| grad.addColorStop(1, '#2a5557'); | |
| ctx.fillStyle = grad; | |
| roundRect(ctx, -w / 2, -h / 2, w, h, 18); ctx.fill(); | |
| ctx.fillStyle = 'rgba(10, 30, 36, 0.7)'; | |
| ctx.beginPath(); | |
| ctx.ellipse(0, -8, w * 0.28, h * 0.24, 0, 0, TAU); | |
| ctx.fill(); | |
| ctx.fillStyle = '#dffef4'; | |
| ctx.fillRect(-w * 0.1, h * 0.15, w * 0.2, h * 0.11); | |
| } | |
| if (building.underConstruction) { | |
| ctx.strokeStyle = 'rgba(255, 210, 120, 0.8)'; | |
| ctx.lineWidth = 2; | |
| for (let i = -2; i <= 2; i++) { | |
| ctx.beginPath(); | |
| ctx.moveTo(-w / 2 + i * 18, h / 2); | |
| ctx.lineTo(-w / 2 + i * 18 + 18, -h / 2); | |
| ctx.stroke(); | |
| } | |
| } | |
| if (building.selected) { | |
| ctx.strokeStyle = '#ffcf68'; | |
| ctx.lineWidth = 2; | |
| ctx.strokeRect(-w / 2 - 5, -h / 2 - 5, w + 10, h + 10); | |
| } | |
| drawHealthBar(x, y - h / 2 - 14, w * 0.86, building.hp / building.maxHp, building.underConstruction ? '#ffcf68' : '#7ce0c4'); | |
| if (building.queue.length) { | |
| drawHealthBar(x, y + h / 2 + 10, w * 0.74, building.queue[0].progress, '#7cccf6'); | |
| } | |
| ctx.restore(); | |
| } | |
| function drawUnit(unit) { | |
| const { x, y } = worldToScreen(unit.x, unit.y); | |
| if (x < -40 || y < -40 || x > VIEW_W + 40 || y > VIEW_H + 40) return; | |
| const time = performance.now() * 0.003; | |
| ctx.save(); | |
| ctx.translate(x, y); | |
| ctx.rotate(unit.facing); | |
| ctx.shadowBlur = 16; | |
| ctx.shadowColor = 'rgba(0,0,0,0.28)'; | |
| ctx.fillStyle = 'rgba(0,0,0,0.22)'; | |
| ctx.beginPath(); | |
| ctx.ellipse(0, 11, unit.radius * 1.1, unit.radius * 0.55, 0, 0, TAU); | |
| ctx.fill(); | |
| ctx.shadowBlur = 0; | |
| if (unit.type === 'worker') { | |
| ctx.fillStyle = '#52341b'; | |
| ctx.fillRect(-10, -7, 18, 14); | |
| ctx.fillStyle = '#ffc56a'; | |
| ctx.fillRect(-8, -9, 16, 10); | |
| ctx.fillStyle = '#fff2ca'; | |
| ctx.fillRect(2, -4, 8, 4); | |
| } else if (unit.type === 'rifle') { | |
| ctx.fillStyle = '#26485d'; | |
| ctx.beginPath(); | |
| ctx.arc(-1, 0, 11, 0, TAU); | |
| ctx.fill(); | |
| ctx.fillStyle = '#8bd3ff'; | |
| ctx.fillRect(-3, -14, 8, 22); | |
| ctx.fillStyle = '#d6f5ff'; | |
| ctx.fillRect(3, -2, 10, 3); | |
| } else { | |
| ctx.fillStyle = '#1a4f4d'; | |
| ctx.beginPath(); | |
| ctx.moveTo(12, 0); | |
| ctx.lineTo(-10, -8); | |
| ctx.lineTo(-6, 0); | |
| ctx.lineTo(-10, 8); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| ctx.fillStyle = '#7ce0c4'; | |
| ctx.beginPath(); | |
| ctx.moveTo(14, 0); | |
| ctx.lineTo(-7, -6); | |
| ctx.lineTo(-3, 0); | |
| ctx.lineTo(-7, 6); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| ctx.fillStyle = `rgba(124, 224, 196, ${0.35 + Math.sin(time * 2 + unit.id) * 0.12})`; | |
| ctx.fillRect(-10, 10, 18, 3); | |
| } | |
| if (unit.selected) { | |
| ctx.strokeStyle = '#ffcf68'; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.arc(0, 0, unit.radius + 6, 0, TAU); | |
| ctx.stroke(); | |
| } | |
| ctx.restore(); | |
| drawHealthBar(x, y - unit.radius - 12, 28, unit.hp / unit.maxHp, unit.type === 'worker' ? '#ffcf68' : '#7ce0c4'); | |
| if ((unit.carry.ore || unit.carry.lumber) && unit.type === 'worker') { | |
| ctx.fillStyle = unit.carry.ore ? '#7cccf6' : '#6fd18c'; | |
| ctx.beginPath(); | |
| ctx.arc(x + 12, y - 10, 4, 0, TAU); | |
| ctx.fill(); | |
| } | |
| } | |
| function drawHealthBar(x, y, width, ratio, color) { | |
| ctx.save(); | |
| ctx.fillStyle = 'rgba(0,0,0,0.45)'; | |
| ctx.fillRect(x - width / 2, y, width, 5); | |
| ctx.fillStyle = color; | |
| ctx.fillRect(x - width / 2 + 1, y + 1, (width - 2) * clamp(ratio, 0, 1), 3); | |
| ctx.restore(); | |
| } | |
| function drawBuildPreview() { | |
| if (!game.buildPreview) return; | |
| const p = game.buildPreview; | |
| const { x, y } = worldToScreen(p.x, p.y); | |
| const valid = isBuildable(p); | |
| ctx.save(); | |
| ctx.translate(x, y); | |
| ctx.fillStyle = valid ? 'rgba(124, 224, 196, 0.22)' : 'rgba(255, 122, 107, 0.25)'; | |
| ctx.strokeStyle = valid ? 'rgba(124, 224, 196, 0.9)' : 'rgba(255, 122, 107, 0.9)'; | |
| ctx.lineWidth = 2; | |
| ctx.fillRect(-p.width / 2, -p.height / 2, p.width, p.height); | |
| ctx.strokeRect(-p.width / 2, -p.height / 2, p.width, p.height); | |
| ctx.restore(); | |
| } | |
| function drawDragBox() { | |
| if (!game.drag || !game.drag.active) return; | |
| const { startX, startY, currentX, currentY } = game.drag; | |
| ctx.save(); | |
| ctx.strokeStyle = 'rgba(255, 207, 104, 0.95)'; | |
| ctx.fillStyle = 'rgba(255, 207, 104, 0.12)'; | |
| ctx.lineWidth = 1.5; | |
| ctx.strokeRect(startX, startY, currentX - startX, currentY - startY); | |
| ctx.fillRect(startX, startY, currentX - startX, currentY - startY); | |
| ctx.restore(); | |
| } | |
| function drawFog() { | |
| const startX = Math.floor(game.camera.x / GRID); | |
| const endX = Math.ceil((game.camera.x + VIEW_W) / GRID); | |
| const startY = Math.floor(game.camera.y / GRID); | |
| const endY = Math.ceil((game.camera.y + VIEW_H) / GRID); | |
| for (let gy = startY; gy < endY; gy++) { | |
| for (let gx = startX; gx < endX; gx++) { | |
| const idx = gy * FOG_W + gx; | |
| const screenX = gx * GRID - game.camera.x; | |
| const screenY = gy * GRID - game.camera.y; | |
| if (!game.explored[idx]) { | |
| ctx.fillStyle = 'rgba(2, 6, 9, 0.94)'; | |
| ctx.fillRect(screenX, screenY, GRID, GRID); | |
| } else if (!game.visible[idx]) { | |
| ctx.fillStyle = 'rgba(4, 10, 13, 0.58)'; | |
| ctx.fillRect(screenX, screenY, GRID, GRID); | |
| } | |
| } | |
| } | |
| } | |
| function renderScene() { | |
| ctx.clearRect(0, 0, VIEW_W, VIEW_H); | |
| renderTerrain(); | |
| for (const resource of game.resources) drawResource(resource); | |
| for (const building of game.buildings) drawBuilding(building); | |
| for (const unit of game.units) drawUnit(unit); | |
| drawBuildPreview(); | |
| drawFog(); | |
| drawDragBox(); | |
| if (game.buildMode) { | |
| ctx.save(); | |
| ctx.fillStyle = 'rgba(7, 14, 19, 0.82)'; | |
| ctx.fillRect(14, VIEW_H - 44, 318, 28); | |
| ctx.fillStyle = '#edf7f5'; | |
| ctx.font = '15px Rajdhani'; | |
| ctx.fillText(`Placement mode: ${BuildingDefs[game.buildMode].name} | Left click place, right click cancel`, 24, VIEW_H - 25); | |
| ctx.restore(); | |
| } | |
| } | |
| function renderMinimap() { | |
| miniCtx.clearRect(0, 0, minimap.width, minimap.height); | |
| const sx = minimap.width / MAP_W; | |
| const sy = minimap.height / MAP_H; | |
| miniCtx.fillStyle = '#1b3529'; | |
| miniCtx.fillRect(0, 0, minimap.width, minimap.height); | |
| for (const resource of game.resources) { | |
| if (resource.amount <= 0) continue; | |
| miniCtx.fillStyle = resource.type === 'ore' ? '#72d9ff' : '#6fd18c'; | |
| miniCtx.fillRect(resource.x * sx - 2, resource.y * sy - 2, 4, 4); | |
| } | |
| for (const building of game.buildings) { | |
| miniCtx.fillStyle = building.type === 'hq' ? '#ffcf68' : building.type === 'barracks' ? '#ec8d74' : '#7ce0c4'; | |
| miniCtx.fillRect((building.x - building.width / 2) * sx, (building.y - building.height / 2) * sy, building.width * sx, building.height * sy); | |
| } | |
| for (const unit of game.units) { | |
| miniCtx.fillStyle = unit.type === 'worker' ? '#ffc56a' : unit.type === 'rifle' ? '#7cccf6' : '#7ce0c4'; | |
| miniCtx.fillRect(unit.x * sx - 1, unit.y * sy - 1, 3, 3); | |
| } | |
| for (let gy = 0; gy < FOG_H; gy++) { | |
| for (let gx = 0; gx < FOG_W; gx++) { | |
| const idx = gy * FOG_W + gx; | |
| if (!game.explored[idx]) { | |
| miniCtx.fillStyle = 'rgba(2, 6, 9, 0.95)'; | |
| miniCtx.fillRect(gx * GRID * sx, gy * GRID * sy, GRID * sx + 0.4, GRID * sy + 0.4); | |
| } else if (!game.visible[idx]) { | |
| miniCtx.fillStyle = 'rgba(2, 6, 9, 0.45)'; | |
| miniCtx.fillRect(gx * GRID * sx, gy * GRID * sy, GRID * sx + 0.4, GRID * sy + 0.4); | |
| } | |
| } | |
| } | |
| miniCtx.strokeStyle = 'rgba(255,255,255,0.8)'; | |
| miniCtx.lineWidth = 1; | |
| miniCtx.strokeRect(game.camera.x * sx, game.camera.y * sy, VIEW_W * sx, VIEW_H * sy); | |
| } | |
| function updateTopBar() { | |
| const explored = Math.round(game.explored.reduce((sum, v) => sum + (v ? 1 : 0), 0) / game.explored.length * 100); | |
| resourceStrip.innerHTML = [ | |
| `<div class="chip">Ore <strong>${Math.floor(game.resourcesStock.ore)}</strong></div>`, | |
| `<div class="chip">Lumber <strong>${Math.floor(game.resourcesStock.lumber)}</strong></div>`, | |
| `<div class="chip">Supply <strong>${game.supply.used}/${game.supply.cap}</strong></div>`, | |
| ].join(''); | |
| objectiveStrip.innerHTML = [ | |
| `<div class="chip">Explored <strong>${explored}%</strong></div>`, | |
| `<div class="chip">Objective <strong>Reveal 100%</strong></div>`, | |
| ].join(''); | |
| } | |
| function updateUI() { | |
| updateTopBar(); | |
| if (!game.selection.length) { | |
| selectionInfo.innerHTML = '<div class="selection-name">Nothing Selected</div><p class="subtle">Drag across units or click a structure to inspect it.</p>'; | |
| } else if (game.selection.length === 1) { | |
| const entity = game.selection[0]; | |
| if (entity.kind === 'unit') { | |
| const def = UnitDefs[entity.type]; | |
| const carrying = entity.type === 'worker' ? `${entity.carry.ore ? entity.carry.ore + ' ore' : ''}${entity.carry.ore && entity.carry.lumber ? ', ' : ''}${entity.carry.lumber ? entity.carry.lumber + ' lumber' : ''}` || 'Empty' : 'Combat ready'; | |
| selectionInfo.innerHTML = ` | |
| <div class="selection-name">${def.name}</div> | |
| <div class="stats"> | |
| <div class="stat">HP ${Math.round(entity.hp)}/${entity.maxHp}</div> | |
| <div class="stat">Sight ${def.sight}</div> | |
| <div class="stat">State ${entity.state}</div> | |
| <div class="stat">Cargo ${carrying}</div> | |
| </div> | |
| `; | |
| } else { | |
| const def = BuildingDefs[entity.type]; | |
| selectionInfo.innerHTML = ` | |
| <div class="selection-name">${def.name}</div> | |
| <div class="stats"> | |
| <div class="stat">HP ${Math.round(entity.hp)}/${entity.maxHp}</div> | |
| <div class="stat">Sight ${def.sight}</div> | |
| <div class="stat">Status ${entity.underConstruction ? 'Constructing' : 'Online'}</div> | |
| <div class="stat">Queue ${entity.queue.length}</div> | |
| </div> | |
| `; | |
| } | |
| } else { | |
| const unitCounts = game.selection.reduce((acc, entity) => { | |
| acc[entity.type] = (acc[entity.type] || 0) + 1; | |
| return acc; | |
| }, {}); | |
| selectionInfo.innerHTML = ` | |
| <div class="selection-name">${game.selection.length} Units</div> | |
| <p class="subtle">${Object.entries(unitCounts).map(([type, count]) => `${count} ${UnitDefs[type].name}${count > 1 ? 's' : ''}`).join(' • ')}</p> | |
| `; | |
| } | |
| const actionIds = []; | |
| if (game.buildMode) actionIds.push('cancel_mode'); | |
| if (game.selection.length === 1) { | |
| const entity = game.selection[0]; | |
| if (entity.kind === 'building' && entity.type === 'hq') actionIds.push('train_worker'); | |
| if (entity.kind === 'building' && entity.type === 'barracks') actionIds.push('train_rifle', 'train_scout'); | |
| } | |
| if (game.selection.some(e => e.kind === 'unit' && e.type === 'worker')) { | |
| actionIds.push('build_barracks', 'build_depot'); | |
| } | |
| actionsEl.innerHTML = ''; | |
| if (!actionIds.length) { | |
| actionsEl.innerHTML = '<p class="subtle">No context actions available for the current selection.</p>'; | |
| } else { | |
| const seen = new Set(); | |
| actionIds.forEach(id => { | |
| if (seen.has(id)) return; | |
| seen.add(id); | |
| const action = uiActions[id]; | |
| const button = document.createElement('button'); | |
| button.className = 'action-btn'; | |
| button.innerHTML = `<strong>${action.label}</strong><span>${action.description}</span>`; | |
| button.disabled = (id === 'build_barracks' && !canAfford(BuildingDefs.barracks.cost)) || | |
| (id === 'build_depot' && !canAfford(BuildingDefs.depot.cost)); | |
| button.addEventListener('click', action.run); | |
| actionsEl.appendChild(button); | |
| }); | |
| } | |
| const activeQueues = game.buildings.filter(b => b.queue.length || b.underConstruction); | |
| queueInfo.innerHTML = ''; | |
| if (!activeQueues.length) { | |
| queueInfo.innerHTML = '<p class="subtle">No active construction or production queues.</p>'; | |
| } else { | |
| activeQueues.forEach(building => { | |
| const def = BuildingDefs[building.type]; | |
| const row = document.createElement('div'); | |
| row.className = 'progress-row'; | |
| if (building.underConstruction) { | |
| row.innerHTML = ` | |
| <div>${def.name} construction</div> | |
| <div class="bar"><span style="width:${fmtPercent(building.progress)}"></span></div> | |
| `; | |
| } else { | |
| const current = building.queue[0]; | |
| row.innerHTML = ` | |
| <div>${def.name}: ${UnitDefs[current.type].name} (${building.queue.length} queued)</div> | |
| <div class="bar"><span style="width:${fmtPercent(current.progress)}"></span></div> | |
| `; | |
| } | |
| queueInfo.appendChild(row); | |
| }); | |
| } | |
| } | |
| function handleLeftDown(event) { | |
| const rect = canvas.getBoundingClientRect(); | |
| const x = (event.clientX - rect.left) * (canvas.width / rect.width); | |
| const y = (event.clientY - rect.top) * (canvas.height / rect.height); | |
| const world = screenToWorld(x, y); | |
| game.mouse.x = x; game.mouse.y = y; game.mouse.worldX = world.x; game.mouse.worldY = world.y; | |
| if (game.buildMode) { | |
| issueBuildOrder(game.buildMode, Math.round(world.x / 10) * 10, Math.round(world.y / 10) * 10); | |
| return; | |
| } | |
| const entity = pickEntity(world.x, world.y); | |
| if (entity) { | |
| setSelection([entity]); | |
| } else { | |
| clearSelection(); | |
| updateUI(); | |
| game.drag = { active: true, startX: x, startY: y, currentX: x, currentY: y }; | |
| } | |
| } | |
| function handleLeftUp(event) { | |
| if (!game.drag || !game.drag.active) return; | |
| const rect = canvas.getBoundingClientRect(); | |
| const x = (event.clientX - rect.left) * (canvas.width / rect.width); | |
| const y = (event.clientY - rect.top) * (canvas.height / rect.height); | |
| const minX = Math.min(game.drag.startX, x); | |
| const maxX = Math.max(game.drag.startX, x); | |
| const minY = Math.min(game.drag.startY, y); | |
| const maxY = Math.max(game.drag.startY, y); | |
| const selected = game.units.filter(unit => { | |
| const p = worldToScreen(unit.x, unit.y); | |
| return p.x >= minX && p.x <= maxX && p.y >= minY && p.y <= maxY; | |
| }); | |
| setSelection(selected); | |
| game.drag = null; | |
| } | |
| function handleRightClick(event) { | |
| event.preventDefault(); | |
| const rect = canvas.getBoundingClientRect(); | |
| const x = (event.clientX - rect.left) * (canvas.width / rect.width); | |
| const y = (event.clientY - rect.top) * (canvas.height / rect.height); | |
| const world = screenToWorld(x, y); | |
| if (game.buildMode) { | |
| game.buildMode = null; | |
| game.buildPreview = null; | |
| setMessage('Placement mode cancelled.'); | |
| updateUI(); | |
| return; | |
| } | |
| const units = selectedUnits(); | |
| if (!units.length) return; | |
| const resource = pickResource(world.x, world.y); | |
| const workers = units.filter(unit => unit.type === 'worker'); | |
| if (resource && workers.length) { | |
| issueHarvestOrder(workers, resource); | |
| return; | |
| } | |
| issueMoveOrder(units, world.x, world.y); | |
| } | |
| canvas.addEventListener('mousedown', event => { | |
| if (event.button === 0) handleLeftDown(event); | |
| }); | |
| window.addEventListener('mouseup', event => { | |
| if (event.button === 0) handleLeftUp(event); | |
| }); | |
| canvas.addEventListener('mousemove', event => { | |
| const rect = canvas.getBoundingClientRect(); | |
| const x = (event.clientX - rect.left) * (canvas.width / rect.width); | |
| const y = (event.clientY - rect.top) * (canvas.height / rect.height); | |
| const world = screenToWorld(x, y); | |
| game.mouse.x = x; | |
| game.mouse.y = y; | |
| game.mouse.worldX = world.x; | |
| game.mouse.worldY = world.y; | |
| if (game.drag && game.drag.active) { | |
| game.drag.currentX = x; | |
| game.drag.currentY = y; | |
| } | |
| }); | |
| canvas.addEventListener('contextmenu', handleRightClick); | |
| window.addEventListener('keydown', event => { | |
| game.keys.add(event.key.toLowerCase()); | |
| if (event.key === 'Escape' && game.buildMode) { | |
| game.buildMode = null; | |
| game.buildPreview = null; | |
| updateUI(); | |
| setMessage('Placement mode cancelled.'); | |
| } | |
| }); | |
| window.addEventListener('keyup', event => game.keys.delete(event.key.toLowerCase())); | |
| minimap.addEventListener('click', event => { | |
| const rect = minimap.getBoundingClientRect(); | |
| const x = (event.clientX - rect.left) * (minimap.width / rect.width); | |
| const y = (event.clientY - rect.top) * (minimap.height / rect.height); | |
| game.camera.x = clamp(x / minimap.width * MAP_W - VIEW_W / 2, 0, MAP_W - VIEW_W); | |
| game.camera.y = clamp(y / minimap.height * MAP_H - VIEW_H / 2, 0, MAP_H - VIEW_H); | |
| }); | |
| function roundRect(ctx, x, y, w, h, r) { | |
| ctx.beginPath(); | |
| ctx.moveTo(x + r, y); | |
| ctx.lineTo(x + w - r, y); | |
| ctx.quadraticCurveTo(x + w, y, x + w, y + r); | |
| ctx.lineTo(x + w, y + h - r); | |
| ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); | |
| ctx.lineTo(x + r, y + h); | |
| ctx.quadraticCurveTo(x, y + h, x, y + h - r); | |
| ctx.lineTo(x, y + r); | |
| ctx.quadraticCurveTo(x, y, x + r, y); | |
| ctx.closePath(); | |
| } | |
| function loop(timestamp) { | |
| const dt = Math.min(0.033, (timestamp - lastTime) / 1000 || 0.016); | |
| lastTime = timestamp; | |
| updateCamera(dt); | |
| updateBuildPreview(); | |
| updateUnits(dt); | |
| updateBuildings(dt); | |
| updateFog(); | |
| renderScene(); | |
| renderMinimap(); | |
| updateTopBar(); | |
| game.uiTimer += dt; | |
| if (game.uiTimer >= 0.15) { | |
| game.uiTimer = 0; | |
| updateUI(); | |
| } | |
| requestAnimationFrame(loop); | |
| } | |
| initWorld(); | |
| updateFog(); | |
| updateUI(); | |
| requestAnimationFrame(loop); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment