RTS
Gemini 3.8 Flash · full
◈Preview unavailable — try playing the result
Browser check: Preview unavailable or timed out. This is a technical check, not a quality review.
Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 112,955 bytes · SHA-256 a0b702dc14b3
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>FRONTIER WAR — 3D Sci-Fi Real-Time Strategy</title>
<!-- Three.js CDN for WebGL rendering -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; user-select: none; -webkit-user-select: none; }
body, html { width: 100%; height: 100%; overflow: hidden; background: #050811; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; color: #d0e4ff; }
#canvas-container { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 1; }
canvas { display: block; width: 100%; height: 100%; }
/* HUD Layout */
#hud { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; display: flex; flex-direction: column; justify-content: space-between; }
.hud-panel { pointer-events: auto; background: rgba(8, 14, 26, 0.88); border: 1px solid #1e3557; backdrop-filter: blur(4px); box-shadow: 0 4px 18px rgba(0,0,0,0.6); }
/* Top Bar */
#top-bar { display: flex; align-items: center; justify-content: space-between; padding: 6px 16px; font-size: 13px; font-weight: 600; border-top: none; border-left: none; border-right: none; }
.res-badge { display: inline-flex; align-items: center; gap: 6px; margin-right: 18px; }
.res-icon { width: 14px; height: 14px; border-radius: 3px; display: inline-block; }
.res-ore { background: #00e5ff; box-shadow: 0 0 8px #00e5ff; }
.res-sup { background: #ffaa00; box-shadow: 0 0 8px #ffaa00; }
.top-btn { background: #13243d; border: 1px solid #2d4f7c; color: #aadcff; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px; margin-left: 6px; transition: all 0.15s; }
.top-btn:hover { background: #1f3b64; color: #ffffff; border-color: #00d4ff; }
/* Alert Banner */
#alert-box { position: absolute; top: 48px; left: 50%; transform: translateX(-50%); pointer-events: none; text-align: center; }
.alert-msg { background: rgba(220, 20, 40, 0.9); color: #fff; padding: 6px 16px; border-radius: 4px; font-weight: 600; font-size: 13px; margin-bottom: 4px; animation: fadeout 4s forwards; box-shadow: 0 0 10px rgba(255, 30, 60, 0.6); }
@keyframes fadeout { 0% { opacity: 0; transform: translateY(-8px); } 15% { opacity: 1; transform: translateY(0); } 80% { opacity: 1; } 100% { opacity: 0; transform: translateY(-8px); } }
/* Bottom Bar Area */
#bottom-bar { display: flex; height: 160px; margin: 0 0 0 0; border-bottom: none; border-left: none; border-right: none; background: rgba(8, 14, 26, 0.95); }
#minimap-wrap { width: 160px; height: 160px; border-right: 1px solid #1e3557; position: relative; background: #03070d; flex-shrink: 0; box-shadow: inset 0 0 10px rgba(0,0,0,0.8); }
#minimap { width: 100%; height: 100%; display: block; cursor: crosshair; }
#minimap-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
/* Selection Info Panel */
#info-panel { width: 270px; padding: 10px 14px; border-right: 1px solid #1e3557; display: flex; flex-direction: column; justify-content: space-between; flex-shrink: 0; }
#info-header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #1c304d; padding-bottom: 4px; margin-bottom: 6px; }
#info-title { font-weight: 700; font-size: 14px; color: #64b5f6; }
#info-count { font-size: 12px; color: #8198b5; }
#info-body { font-size: 12px; line-height: 1.4; color: #bcd3eb; flex: 1; overflow-y: auto; }
.hp-bar-bg { width: 100%; height: 7px; background: #1c2738; border-radius: 3px; overflow: hidden; margin: 4px 0 6px 0; }
.hp-bar-fill { height: 100%; background: #00e676; transition: width 0.1s; }
.shield-bar-fill { height: 100%; background: #00b0ff; transition: width 0.1s; }
/* Action Commands Grid */
#action-panel { flex: 1; padding: 10px 14px; display: flex; flex-direction: column; justify-content: space-between; }
#action-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; }
.cmd-btn { background: #101c2e; border: 1px solid #233b5d; border-radius: 4px; padding: 6px 4px; color: #bcd3eb; font-size: 11px; font-weight: 600; cursor: pointer; text-align: center; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 48px; transition: all 0.12s; position: relative; }
.cmd-btn:hover:not(:disabled) { background: #1d3354; border-color: #00d4ff; color: #fff; transform: translateY(-1px); }
.cmd-btn:active:not(:disabled) { transform: translateY(1px); }
.cmd-btn:disabled { opacity: 0.38; cursor: not-allowed; border-color: #1a2433; }
.cmd-btn .key { position: absolute; top: 2px; left: 4px; font-size: 9px; color: #ffb74d; }
.cmd-btn .cost { font-size: 9px; color: #00e5ff; margin-top: 2px; }
.cmd-btn.active { background: #006064; border-color: #00e5ff; color: #fff; }
/* Sub info / Queue bar */
#queue-bar { display: flex; align-items: center; gap: 4px; height: 20px; margin-top: 4px; }
.queue-slot { width: 22px; height: 18px; background: #142236; border: 1px solid #2a4163; border-radius: 2px; font-size: 9px; display: flex; align-items: center; justify-content: center; color: #90caf9; }
.queue-slot.active { border-color: #00e5ff; background: #183d5a; }
/* Drag selection box */
#select-box { position: absolute; border: 1px solid #00e5ff; background: rgba(0, 229, 255, 0.12); pointer-events: none; display: none; z-index: 5; }
/* Modal Overlays (Menu / Pause / Victory / Defeat / Help) */
.modal-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(3, 7, 15, 0.85); z-index: 100; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(6px); }
.modal-card { background: #0c1626; border: 1px solid #274773; border-radius: 8px; width: 560px; max-width: 90%; max-height: 88%; overflow-y: auto; padding: 26px 30px; box-shadow: 0 10px 40px rgba(0,0,0,0.8); color: #d0e4ff; }
.modal-card h1 { font-size: 24px; color: #00e5ff; margin-bottom: 6px; letter-spacing: 1px; }
.modal-card h2 { font-size: 16px; color: #64b5f6; margin-top: 14px; margin-bottom: 6px; }
.modal-card p { font-size: 13px; line-height: 1.5; color: #a5c2e0; margin-bottom: 10px; }
.modal-card ul { font-size: 13px; line-height: 1.6; margin-left: 20px; color: #a5c2e0; margin-bottom: 12px; }
.modal-card strong { color: #fff; }
.modal-btn { background: #0288d1; border: none; color: #fff; font-weight: 700; font-size: 14px; padding: 10px 20px; border-radius: 4px; cursor: pointer; display: inline-block; margin-top: 14px; transition: background 0.15s; }
.modal-btn:hover { background: #03a9f4; }
.modal-btn.secondary { background: #1e3352; margin-left: 10px; }
.modal-btn.secondary:hover { background: #2b4975; }
/* Tooltip */
#tooltip { position: absolute; pointer-events: none; background: #0c182a; border: 1px solid #355f94; padding: 6px 10px; border-radius: 4px; font-size: 11px; color: #e1f5fe; z-index: 50; display: none; max-width: 220px; box-shadow: 0 4px 12px rgba(0,0,0,0.6); }
/* Floating combat numbers */
.combat-text { position: absolute; pointer-events: none; font-size: 11px; font-weight: 700; color: #ff5252; text-shadow: 0 0 3px #000; animation: floatup 0.9s forwards; z-index: 6; }
@keyframes floatup { 0% { opacity: 1; transform: translateY(0); } 100% { opacity: 0; transform: translateY(-24px); } }
</style>
</head>
<body>
<div id="canvas-container"></div>
<div id="select-box"></div>
<div id="tooltip"></div>
<!-- HUD -->
<div id="hud">
<!-- Top Bar -->
<div id="top-bar" class="hud-panel">
<div style="display:flex; align-items:center;">
<span style="font-weight:900; letter-spacing:1px; color:#00e5ff; margin-right:24px;">FRONTIER WAR</span>
<div class="res-badge">
<span class="res-icon res-ore"></span>
<span>ORE: <span id="res-ore-val" style="color:#fff;">300</span></span>
</div>
<div class="res-badge">
<span class="res-icon res-sup"></span>
<span>SUPPLY: <span id="res-sup-val" style="color:#fff;">4 / 15</span></span>
</div>
<div class="res-badge" style="font-size:11px; color:#8198b5;">
<span>EXPEDITION CORPS (BLUE)</span>
</div>
</div>
<div>
<span id="game-timer" style="font-size:12px; color:#8ba7c7; margin-right:14px;">00:00</span>
<button class="top-btn" id="btn-sound" title="Toggle Sound">AUDIO: ON</button>
<button class="top-btn" id="btn-help">HELP (F1)</button>
<button class="top-btn" id="btn-pause">PAUSE (ESC)</button>
</div>
</div>
<!-- Alert message box -->
<div id="alert-box"></div>
<!-- Bottom Control Bar -->
<div id="bottom-bar" class="hud-panel">
<!-- Minimap -->
<div id="minimap-wrap">
<canvas id="minimap" width="160" height="160"></canvas>
<div id="minimap-overlay"></div>
</div>
<!-- Selected Entity Info -->
<div id="info-panel">
<div id="info-header">
<span id="info-title">No Selection</span>
<span id="info-count"></span>
</div>
<div id="info-body">
Select a unit or building to issue orders. Drag-box selects multiple combat units.
</div>
<div id="queue-bar"></div>
</div>
<!-- Commands & Production Grid -->
<div id="action-panel">
<div id="action-grid"></div>
<div style="font-size:10px; color:#6888ab; display:flex; justify-content:space-between; margin-top:4px;">
<span>[A] Attack-Move | [S] Stop | [H] Hold | [1-9] Groups</span>
<span id="tech-status">Armor Lv 0 | Weap Lv 0</span>
</div>
</div>
</div>
</div>
<!-- Modal Start / Victory / Defeat / Help Screen -->
<div id="modal-screen" class="modal-overlay">
<div class="modal-card" id="modal-content">
<h1>FRONTIER WAR</h1>
<p>Command the United Frontier Expedition against the rival Dominion Syndicate on resource-rich Exoplanet Tartarus VII.</p>
<h2>OBJECTIVE</h2>
<p>Harvest crystalline Ore deposits, expand supply infrastructure, build ground and air forces, scout through the fog of war, and <strong>destroy all enemy Headquarters</strong>. You are defeated if you lose all of your Headquarters.</p>
<h2>COMMANDS & CONTROLS</h2>
<ul>
<li><strong>Left-Click / Drag Box:</strong> Select units or structures (Shift to add). Double-click to select all visible units of that type.</li>
<li><strong>Right-Click:</strong> Contextual order (Move, Attack, Harvest crystalline ore deposit, or Return minerals).</li>
<li><strong>Camera Controls:</strong> W/A/S/D or Arrow keys to Pan. Mouse Scroll to Zoom. Hold Middle Mouse or Q/E to Rotate camera. Spacebar to center on selected units or base.</li>
<li><strong>Combat Orders:</strong> <strong>[A]</strong> Attack-Move, <strong>[S]</strong> Stop, <strong>[H]</strong> Hold Position.</li>
<li><strong>Control Groups:</strong> Ctrl + 1-9 to create group; 1-9 to recall group.</li>
</ul>
<h2>ESCALATION & COUNTERS</h2>
<ul>
<li><strong>Mining Rover:</strong> Gathers ore. Can build Headquarters, Solar Reactors (supply), Barracks, Foundry, Aerodrome, Defense Turret.</li>
<li><strong>Scout Raider:</strong> Fast close-combat buggy with light blades. Strong against light ranged units.</li>
<li><strong>Rifle Marine:</strong> Rapid kinetic rifle. Hits ground and air. Counters light aircraft and buggies.</li>
<li><strong>Siege Titan:</strong> Heavy armor, twin plasma cannon. Devastates ground armor and structures at long range. Cannot hit air.</li>
<li><strong>Valkyrie Gunship:</strong> Flying attack craft. Bypasses terrain chokepoints. Strong ground strikes; countered by Marines and Turrets.</li>
<li><strong>Upgrades:</strong> Research Weapons and Armor upgrades at the Engineering Bay to power up your entire army!</li>
</ul>
<div style="margin-top:16px;">
<button class="modal-btn" id="btn-start-game">START MISSION</button>
</div>
</div>
</div>
<script>
/* =========================================================================
FRONTIER WAR — 3D REAL-TIME STRATEGY ENGINE
Single-file zero-dependency WebGL RTS
========================================================================= */
// Audio Synth helper using Web Audio API
class SoundFx {
constructor() {
this.ctx = null;
this.enabled = true;
}
init() {
if (!this.ctx) {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (AudioCtx) this.ctx = new AudioCtx();
}
if (this.ctx && this.ctx.state === 'suspended') {
this.ctx.resume();
}
}
playClick() {
if (!this.enabled || !this.ctx) return;
try {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(480, this.ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(800, this.ctx.currentTime + 0.04);
gain.gain.setValueAtTime(0.08, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.05);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start();
osc.stop(this.ctx.currentTime + 0.05);
} catch(e){}
}
playOrder() {
if (!this.enabled || !this.ctx) return;
try {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(320, this.ctx.currentTime);
osc.frequency.setValueAtTime(540, this.ctx.currentTime + 0.04);
gain.gain.setValueAtTime(0.09, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.09);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start();
osc.stop(this.ctx.currentTime + 0.09);
} catch(e){}
}
playShoot(type) {
if (!this.enabled || !this.ctx) return;
try {
const now = this.ctx.currentTime;
if (type === 'laser') {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(880, now);
osc.frequency.exponentialRampToValueAtTime(220, now + 0.08);
gain.gain.setValueAtTime(0.05, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.08);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start();
osc.stop(now + 0.08);
} else if (type === 'heavy') {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(140, now);
osc.frequency.exponentialRampToValueAtTime(40, now + 0.18);
gain.gain.setValueAtTime(0.12, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.2);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start();
osc.stop(now + 0.2);
} else {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'square';
osc.frequency.setValueAtTime(600, now);
osc.frequency.exponentialRampToValueAtTime(120, now + 0.05);
gain.gain.setValueAtTime(0.04, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.06);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start();
osc.stop(now + 0.06);
}
} catch(e){}
}
playExplode() {
if (!this.enabled || !this.ctx) return;
try {
const now = this.ctx.currentTime;
// White noise buffer
const bufferSize = this.ctx.sampleRate * 0.25;
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
const output = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
output[i] = Math.random() * 2 - 1;
}
const whiteNoise = this.ctx.createBufferSource();
whiteNoise.buffer = buffer;
const filter = this.ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(800, now);
filter.frequency.exponentialRampToValueAtTime(80, now + 0.25);
const gain = this.ctx.createGain();
gain.gain.setValueAtTime(0.16, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.25);
whiteNoise.connect(filter);
filter.connect(gain);
gain.connect(this.ctx.destination);
whiteNoise.start();
whiteNoise.stop(now + 0.25);
} catch(e){}
}
playAlert() {
if (!this.enabled || !this.ctx) return;
try {
const now = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(750, now);
osc.frequency.setValueAtTime(500, now + 0.1);
osc.frequency.setValueAtTime(750, now + 0.2);
gain.gain.setValueAtTime(0.12, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.3);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start();
osc.stop(now + 0.3);
} catch(e){}
}
}
const sfx = new SoundFx();
/* =========================================================================
GAME DATA DEFINITIONS & STATS
========================================================================= */
const FACTION_PLAYER = 'player';
const FACTION_ENEMY = 'enemy';
// Colors
const COLOR_PLAYER = 0x2979ff;
const COLOR_PLAYER_HEX = '#2979ff';
const COLOR_ENEMY = 0xff1744;
const COLOR_ENEMY_HEX = '#ff1744';
const COLOR_NEUTRAL = 0x90a4ae;
const COLOR_ORE = 0x00e5ff;
// Unit & Structure Catalog
const UNIT_STATS = {
worker: {
name: 'Mining Rover',
hp: 120,
armor: 1,
armorType: 'light',
speed: 6.8,
range: 2.2,
damage: 6,
cooldown: 1.0,
targets: 'ground',
damageType: 'kinetic',
sight: 18,
cost: 50,
supply: 1,
buildTime: 12,
radius: 1.0,
desc: 'Harvests ore, returns minerals to Headquarters, and erects base infrastructure.'
},
scout: {
name: 'Scout Raider',
hp: 170,
armor: 1,
armorType: 'light',
speed: 9.5,
range: 2.8,
damage: 15,
cooldown: 0.8,
targets: 'ground',
damageType: 'kinetic',
sight: 24,
cost: 75,
supply: 1,
buildTime: 14,
radius: 1.1,
desc: 'Rapid close-assault reconnaissance vehicle. Effective against light infantry and scouts.'
},
marine: {
name: 'Rifle Marine',
hp: 140,
armor: 1,
armorType: 'light',
speed: 5.6,
range: 13,
damage: 12,
cooldown: 0.7,
targets: 'both',
damageType: 'kinetic',
sight: 20,
cost: 60,
supply: 1,
buildTime: 14,
radius: 0.8,
desc: 'Versatile ranged ground infantry armed with hyper-velocity kinetic rifle. Targets ground & air.'
},
tank: {
name: 'Siege Titan',
hp: 480,
armor: 4,
armorType: 'heavy',
speed: 4.2,
range: 16,
damage: 48,
cooldown: 2.0,
targets: 'ground',
damageType: 'explosive',
sight: 22,
cost: 180,
supply: 3,
buildTime: 28,
radius: 1.6,
desc: 'Heavy tracked armor with twin plasma cannons. Heavy damage vs armor and structures; ground only.'
},
flyer: {
name: 'Valkyrie Gunship',
hp: 260,
armor: 2,
armorType: 'light',
speed: 7.2,
range: 12,
damage: 24,
cooldown: 1.2,
targets: 'both',
damageType: 'energy',
sight: 22,
cost: 160,
supply: 2,
buildTime: 24,
radius: 1.3,
isFlying: true,
flightHeight: 5.5,
desc: 'VTOL strike craft that flies over terrain. Armed with plasma beam batteries. Hits ground & air.'
}
};
const BUILDING_STATS = {
hq: {
name: 'Headquarters',
hp: 2200,
armor: 5,
armorType: 'heavy',
sight: 28,
cost: 350,
supplyGranted: 15,
buildTime: 40,
size: 5.5,
isHQ: true,
isDropoff: true,
canTrain: ['worker'],
desc: 'Primary colony command center. Mineral dropoff point; provides 15 supply. Trains Mining Rovers.'
},
reactor: {
name: 'Solar Reactor',
hp: 750,
armor: 2,
armorType: 'light',
sight: 14,
cost: 80,
supplyGranted: 12,
buildTime: 18,
size: 3.2,
desc: 'Power and logistics generator. Increases available army supply capacity by +12.'
},
barracks: {
name: 'Barracks',
hp: 1100,
armor: 3,
armorType: 'heavy',
sight: 18,
cost: 140,
supplyGranted: 0,
buildTime: 24,
size: 4.2,
canTrain: ['marine', 'scout'],
desc: 'Infantry and light vehicle assembly. Trains Rifle Marines and Scout Raiders.'
},
foundry: {
name: 'Foundry',
hp: 1350,
armor: 4,
armorType: 'heavy',
sight: 18,
cost: 190,
supplyGranted: 0,
buildTime: 32,
size: 4.6,
canTrain: ['tank'],
desc: 'Heavy armored vehicle factory. Manufactures twin-cannon Siege Titans.'
},
aerodrome: {
name: 'Aerodrome',
hp: 1200,
armor: 3,
armorType: 'heavy',
sight: 18,
cost: 210,
supplyGranted: 0,
buildTime: 34,
size: 4.6,
canTrain: ['flyer'],
desc: 'Aeronautics hangar and launching pad. Produces Valkyrie Gunships.'
},
turret: {
name: 'Defense Turret',
hp: 850,
armor: 4,
armorType: 'heavy',
sight: 22,
range: 15,
damage: 26,
cooldown: 0.9,
targets: 'both',
damageType: 'energy',
cost: 110,
supplyGranted: 0,
buildTime: 22,
size: 2.8,
isDefensive: true,
desc: 'Automated twin-laser defensive bastion. Engages both ground and aerial hostiles.'
},
techlab: {
name: 'Research Bay',
hp: 950,
armor: 3,
armorType: 'heavy',
sight: 16,
cost: 150,
supplyGranted: 0,
buildTime: 26,
size: 3.8,
canResearch: ['upgrade_weapons', 'upgrade_armor'],
desc: 'Upgrades combat engineering: improves weapon damage and armor plating across all units.'
}
};
const UPGRADE_STATS = {
upgrade_weapons: {
name: 'Weapons Cal. II',
cost: 130,
time: 25,
desc: 'Increases all combat unit attack damage by +20%.'
},
upgrade_armor: {
name: 'Reinforced Plating II',
cost: 130,
time: 25,
desc: 'Increases all units and structures armor plating by +2.'
}
};
// Combat Damage Multipliers: [damageType][armorType]
const DAMAGE_MULTIPLIER = {
kinetic: {
light: 1.15,
heavy: 0.70
},
explosive: {
light: 0.75,
heavy: 1.35
},
energy: {
light: 1.0,
heavy: 1.05
}
};
/* =========================================================================
MAP & TERRAIN SPECIFICATION
========================================================================= */
const MAP_SIZE = 260; // -130 to +130
const FOG_RES = 130; // 2x2 meter fog cells
const CELL_SIZE = MAP_SIZE / FOG_RES;
/* =========================================================================
GLOBAL GAME STATE
========================================================================= */
const G = {
time: 0,
paused: false,
gameOver: false,
victory: false,
// Player Economy
player: {
ore: 320,
usedSupply: 0,
maxSupply: 15,
weaponLevel: 0,
armorLevel: 0,
researched: {}
},
// AI Economy
enemy: {
ore: 320,
usedSupply: 0,
maxSupply: 15,
weaponLevel: 0,
armorLevel: 0,
researched: {},
strategyTimer: 0,
lastAttackTime: 0,
attackWave: 1,
rallyPoint: new THREE.Vector3(80, 0, 80),
knownPlayerBase: null
},
// Entities
units: [],
buildings: [],
resources: [],
projectiles: [],
particles: [],
// Selection
selectedUnits: [],
selectedBuilding: null,
controlGroups: {},
// Fog of war grid: 0 = unseen, 1 = explored (shrouded), 2 = currently visible
fogGrid: new Uint8Array(FOG_RES * FOG_RES),
enemyFogGrid: new Uint8Array(FOG_RES * FOG_RES),
// Placer state
placingType: null,
placementMesh: null,
activeCommand: null, // 'attack-move', etc.
// Terrain height & obstacles
obstacles: []
};
/* =========================================================================
THREE.JS SETUP & RENDER PIPELINE
========================================================================= */
const container = document.getElementById('canvas-container');
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a101d);
scene.fog = new THREE.FogExp2(0x0a101d, 0.0035);
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 1, 600);
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
container.appendChild(renderer.domElement);
// Lighting
const ambientLight = new THREE.AmbientLight(0x7090b8, 0.7);
scene.add(ambientLight);
const sunLight = new THREE.DirectionalLight(0xfff5e6, 1.25);
sunLight.position.set(-80, 140, -60);
sunLight.castShadow = true;
sunLight.shadow.mapSize.width = 2048;
sunLight.shadow.mapSize.height = 2048;
sunLight.shadow.camera.near = 10;
sunLight.shadow.camera.far = 380;
const d = 140;
sunLight.shadow.camera.left = -d;
sunLight.shadow.camera.right = d;
sunLight.shadow.camera.top = d;
sunLight.shadow.camera.bottom = -d;
sunLight.shadow.bias = -0.0005;
scene.add(sunLight);
const hemiLight = new THREE.HemisphereLight(0x3a5f8a, 0x1b281f, 0.45);
scene.add(hemiLight);
/* Camera Rig / Controller */
class CameraController {
constructor() {
this.target = new THREE.Vector3(-80, 0, -80); // Start at player base
this.distance = 64;
this.pitch = 0.95; // Radians down from horizon (~54 deg)
this.yaw = THREE.MathUtils.degToRad(-45); // angled view
this.moveSpeed = 65;
this.keys = {};
this.isDragging = false;
this.lastMouse = { x: 0, y: 0 };
this.mouseButton = -1;
this.updatePosition();
}
updatePosition() {
// Keep target on map
const limit = MAP_SIZE / 2 - 10;
this.target.x = THREE.MathUtils.clamp(this.target.x, -limit, limit);
this.target.z = THREE.MathUtils.clamp(this.target.z, -limit, limit);
const x = this.target.x + this.distance * Math.cos(this.pitch) * Math.sin(this.yaw);
const z = this.target.z + this.distance * Math.cos(this.pitch) * Math.cos(this.yaw);
const y = this.distance * Math.sin(this.pitch);
camera.position.set(x, y, z);
camera.lookAt(this.target);
// Keep sun shadow frustum centered around camera target for crisp shadows
sunLight.position.set(this.target.x - 70, 140, this.target.z - 50);
sunLight.target.position.copy(this.target);
sunLight.target.updateMatrixWorld();
}
update(dt) {
let dx = 0;
let dz = 0;
// Movement forward/back along ground projection of camera
const forward = new THREE.Vector3(-Math.sin(this.yaw), 0, -Math.cos(this.yaw)).normalize();
const right = new THREE.Vector3(Math.cos(this.yaw), 0, -Math.sin(this.yaw)).normalize();
if (this.keys['KeyW'] || this.keys['ArrowUp']) {
dx += forward.x;
dz += forward.z;
}
if (this.keys['KeyS'] || this.keys['ArrowDown']) {
dx -= forward.x;
dz -= forward.z;
}
if (this.keys['KeyA'] || this.keys['ArrowLeft']) {
dx -= right.x;
dz -= right.z;
}
if (this.keys['KeyD'] || this.keys['ArrowRight']) {
dx += right.x;
dz += right.z;
}
if (dx !== 0 || dz !== 0) {
const len = Math.hypot(dx, dz);
this.target.x += (dx / len) * this.moveSpeed * dt;
this.target.z += (dz / len) * this.moveSpeed * dt;
}
if (this.keys['KeyQ']) this.yaw += 1.8 * dt;
if (this.keys['KeyE']) this.yaw -= 1.8 * dt;
this.updatePosition();
}
zoom(delta) {
this.distance = THREE.MathUtils.clamp(this.distance + delta * 0.05, 25, 120);
this.updatePosition();
}
}
const camCtrl = new CameraController();
/* =========================================================================
TERRAIN CREATION & PROCEDURAL GENERATION
========================================================================= */
// Simple smooth simplex/perlin approximation for height
function getTerrainHeight(x, z) {
// Flat playfield base with slight undulating hills on periphery and specific cliffs
const distFromCenter = Math.hypot(x, z);
let h = Math.sin(x * 0.03) * Math.cos(z * 0.03) * 1.5;
// Ensure bases at (-80, -80) and (80, 80) and center expansions are relatively level
const distBase1 = Math.hypot(x - (-80), z - (-80));
const distBase2 = Math.hypot(x - 80, z - 80);
const distExp1 = Math.hypot(x - (-60), z - 60);
const distExp2 = Math.hypot(x - 60, z - (-60));
const distCenter = Math.hypot(x, z);
const flatFactor = Math.min(distBase1 / 35, distBase2 / 35, distExp1 / 25, distExp2 / 25, distCenter / 25);
h *= Math.min(1.0, flatFactor);
return h;
}
// Generate Terrain Mesh
const terrainGeo = new THREE.PlaneGeometry(MAP_SIZE, MAP_SIZE, 128, 128);
terrainGeo.rotateX(-Math.PI / 2);
const posAttr = terrainGeo.attributes.position;
const colorAttr = new Float32Array(posAttr.count * 3);
for (let i = 0; i < posAttr.count; i++) {
const vx = posAttr.getX(i);
const vz = posAttr.getZ(i);
const vy = getTerrainHeight(vx, vz);
posAttr.setY(i, vy);
// Surface vertex tinting (martian / exoplanet red-tan soil with dark basalt and crater rings)
const n = (Math.sin(vx * 0.1) * Math.cos(vz * 0.1) + 1) * 0.5;
const r = 0.22 + n * 0.12;
const g = 0.18 + n * 0.08;
const b = 0.18 + n * 0.06;
colorAttr[i * 3] = r;
colorAttr[i * 3 + 1] = g;
colorAttr[i * 3 + 2] = b;
}
terrainGeo.setAttribute('color', new THREE.BufferAttribute(colorAttr, 3));
terrainGeo.computeVertexNormals();
const terrainMat = new THREE.MeshStandardMaterial({
vertexColors: true,
roughness: 0.9,
metalness: 0.1
});
const terrainMesh = new THREE.Mesh(terrainGeo, terrainMat);
terrainMesh.receiveShadow = true;
scene.add(terrainMesh);
// Boundary Mountain Walls & Natural Chokepoints
function createMountainRidge(x, z, sx, sy, sz, rot) {
const mesh = new THREE.Mesh(
new THREE.DodecahedronGeometry(1, 1),
new THREE.MeshStandardMaterial({ color: 0x1e2730, roughness: 0.95, flatShading: true })
);
mesh.scale.set(sx, sy, sz);
mesh.rotation.set(0.1, rot, 0.2);
mesh.position.set(x, sy * 0.4 + getTerrainHeight(x, z), z);
mesh.castShadow = true;
mesh.receiveShadow = true;
scene.add(mesh);
// Add bounding circle obstacle for ground pathfinding
G.obstacles.push({
x: x,
z: z,
radius: Math.max(sx, sz) * 0.8
});
}
// Map Boundary Walls
const half = MAP_SIZE / 2;
for (let p = -half; p <= half; p += 18) {
createMountainRidge(p, -half, 16, 18, 16, Math.random() * 3);
createMountainRidge(p, half, 16, 18, 16, Math.random() * 3);
createMountainRidge(-half, p, 16, 18, 16, Math.random() * 3);
createMountainRidge(half, p, 16, 18, 16, Math.random() * 3);
}
// Chokepoints and Ridge Barriers separating paths
// Central ridge dividing top-left / bottom-right with 3 open corridors
createMountainRidge(-20, -15, 14, 15, 14, 0.5);
createMountainRidge(-15, -35, 12, 14, 12, 0.8);
createMountainRidge(-10, -55, 14, 16, 14, 0.2);
createMountainRidge(20, 15, 14, 15, 14, 0.5);
createMountainRidge(15, 35, 12, 14, 12, 0.8);
createMountainRidge(10, 55, 14, 16, 14, 0.2);
// Center plateau rock formations
createMountainRidge(-35, 15, 11, 12, 11, 1.2);
createMountainRidge(35, -15, 11, 12, 11, 1.2);
/* =========================================================================
FOG OF WAR SYSTEM
========================================================================= */
// We project a dynamic 2D canvas texture over the terrain via a custom transparent fog plane
const fogCanvas = document.createElement('canvas');
fogCanvas.width = FOG_RES;
fogCanvas.height = FOG_RES;
const fogCtx = fogCanvas.getContext('2d');
const fogTexture = new THREE.CanvasTexture(fogCanvas);
fogTexture.generateMipmaps = false;
fogTexture.minFilter = THREE.LinearFilter;
fogTexture.magFilter = THREE.LinearFilter;
// Fog Overlay Plane directly above terrain with matched height vertices
const fogPlaneGeo = new THREE.PlaneGeometry(MAP_SIZE, MAP_SIZE, 64, 64);
fogPlaneGeo.rotateX(-Math.PI / 2);
const fogPos = fogPlaneGeo.attributes.position;
for (let i = 0; i < fogPos.count; i++) {
const fx = fogPos.getX(i);
const fz = fogPos.getZ(i);
fogPos.setY(i, getTerrainHeight(fx, fz) + 0.35);
}
fogPlaneGeo.computeVertexNormals();
const fogMat = new THREE.MeshBasicMaterial({
color: 0x050a14,
alphaMap: fogTexture,
transparent: true,
depthWrite: false
});
const fogPlaneMesh = new THREE.Mesh(fogPlaneGeo, fogMat);
scene.add(fogPlaneMesh);
function worldToFogGrid(x, z) {
const gx = Math.floor(((x + MAP_SIZE / 2) / MAP_SIZE) * FOG_RES);
const gz = Math.floor(((z + MAP_SIZE / 2) / MAP_SIZE) * FOG_RES);
return { gx: THREE.MathUtils.clamp(gx, 0, FOG_RES - 1), gz: THREE.MathUtils.clamp(gz, 0, FOG_RES - 1) };
}
function updateFogOfWar() {
// Step 1: degrade currently visible (2) to explored (1)
for (let i = 0; i < G.fogGrid.length; i++) {
if (G.fogGrid[i] === 2) {
G.fogGrid[i] = 1;
}
}
// Step 2: mark vision circles around player units & buildings
const revealCircle = (wx, wz, sightRadius, grid) => {
const { gx: cx, gz: cz } = worldToFogGrid(wx, wz);
const cellRadius = Math.ceil((sightRadius / MAP_SIZE) * FOG_RES);
const r2 = cellRadius * cellRadius;
const minX = Math.max(0, cx - cellRadius);
const maxX = Math.min(FOG_RES - 1, cx + cellRadius);
const minZ = Math.max(0, cz - cellRadius);
const maxZ = Math.min(FOG_RES - 1, cz + cellRadius);
for (let gz = minZ; gz <= maxZ; gz++) {
const dz = gz - cz;
const rowOffset = gz * FOG_RES;
for (let gx = minX; gx <= maxX; gx++) {
const dx = gx - cx;
if (dx * dx + dz * dz <= r2) {
grid[rowOffset + gx] = 2; // fully visible
}
}
}
};
for (const u of G.units) {
if (u.faction === FACTION_PLAYER) {
revealCircle(u.x, u.z, u.sight, G.fogGrid);
} else {
revealCircle(u.x, u.z, u.sight, G.enemyFogGrid);
}
}
for (const b of G.buildings) {
if (b.faction === FACTION_PLAYER) {
revealCircle(b.x, b.z, b.sight, G.fogGrid);
} else {
revealCircle(b.x, b.z, b.sight, G.enemyFogGrid);
}
}
// Step 3: Draw fog alpha texture
// 0 (unexplored) => full opacity (255)
// 1 (explored) => semi-transparent shroud (150)
// 2 (visible) => clear (0)
const imgData = fogCtx.createImageData(FOG_RES, FOG_RES);
const data = imgData.data;
for (let i = 0; i < G.fogGrid.length; i++) {
const val = G.fogGrid[i];
const p = i * 4;
data[p] = 255;
data[p + 1] = 255;
data[p + 2] = 255;
if (val === 2) {
data[p + 3] = 0; // completely visible
} else if (val === 1) {
data[p + 3] = 135; // explored / shroud (softer contrast)
} else {
data[p + 3] = 230; // unexplored fog
}
}
fogCtx.putImageData(imgData, 0, 0);
fogTexture.needsUpdate = true;
// Step 4: Update 3D visibility of enemy objects based on player's fog
for (const u of G.units) {
if (u.faction === FACTION_ENEMY) {
const { gx, gz } = worldToFogGrid(u.x, u.z);
const isVisible = G.fogGrid[gz * FOG_RES + gx] === 2;
u.mesh.visible = isVisible;
if (u.hpBar) u.hpBar.visible = isVisible;
} else {
u.mesh.visible = true;
}
}
for (const b of G.buildings) {
if (b.faction === FACTION_ENEMY) {
const { gx, gz } = worldToFogGrid(b.x, b.z);
const state = G.fogGrid[gz * FOG_RES + gx];
if (state === 2) {
b.mesh.visible = true;
b.lastSeen = true;
if (b.hpBar) b.hpBar.visible = true;
} else if (state === 1 && b.lastSeen) {
// Explored ghost silhouette
b.mesh.visible = true;
if (b.hpBar) b.hpBar.visible = false;
} else {
b.mesh.visible = false;
if (b.hpBar) b.hpBar.visible = false;
}
} else {
b.mesh.visible = true;
}
}
}
/* =========================================================================
CRYSTAL RESOURCE FIELDS
========================================================================= */
class CrystalDeposit {
constructor(x, z, amount = 1600) {
this.x = x;
this.z = z;
this.amount = amount;
this.maxAmount = amount;
this.mesh = this.createMesh();
scene.add(this.mesh);
}
createMesh() {
const group = new THREE.Group();
group.position.set(this.x, getTerrainHeight(this.x, this.z), this.z);
// Cluster of teal crystal shards
const mat = new THREE.MeshStandardMaterial({
color: COLOR_ORE,
emissive: 0x004d60,
roughness: 0.2,
metalness: 0.8,
flatShading: true
});
const numShards = 7;
for (let i = 0; i < numShards; i++) {
const h = 2.2 + Math.random() * 2.2;
const shardGeo = new THREE.ConeGeometry(0.55 + Math.random() * 0.3, h, 5);
const shard = new THREE.Mesh(shardGeo, mat);
const angle = (i / numShards) * Math.PI * 2 + Math.random() * 0.4;
const dist = 0.5 + Math.random() * 1.4;
shard.position.set(Math.cos(angle) * dist, h * 0.45, Math.sin(angle) * dist);
shard.rotation.set((Math.random() - 0.5) * 0.4, Math.random() * 3, (Math.random() - 0.5) * 0.4);
shard.castShadow = true;
group.add(shard);
}
return group;
}
harvest(amt) {
const taken = Math.min(amt, this.amount);
this.amount -= taken;
// Scale down visual cluster as it depletes
const scale = THREE.MathUtils.clamp(0.25 + (this.amount / this.maxAmount) * 0.75, 0.2, 1.0);
this.mesh.scale.set(scale, scale, scale);
if (this.amount <= 0) {
this.destroy();
}
return taken;
}
destroy() {
scene.remove(this.mesh);
const idx = G.resources.indexOf(this);
if (idx !== -1) G.resources.splice(idx, 1);
}
}
function spawnResourceCluster(centerX, centerZ, clusterSize = 5) {
for (let i = 0; i < clusterSize; i++) {
const angle = (i / clusterSize) * Math.PI * 2 + Math.random() * 0.5;
const r = 3.5 + Math.random() * 3.5;
const rx = centerX + Math.cos(angle) * r;
const rz = centerZ + Math.sin(angle) * r;
G.resources.push(new CrystalDeposit(rx, rz, 1400 + Math.floor(Math.random() * 600)));
}
}
// Distribute Resource Fields Across Map
// Starting Player Base deposits
spawnResourceCluster(-92, -68, 5);
// Starting Enemy Base deposits
spawnResourceCluster(92, 68, 5);
// Natural Expansions (North-West, South-East, and Central Contested)
spawnResourceCluster(-60, 60, 5);
spawnResourceCluster(60, -60, 5);
spawnResourceCluster(0, 0, 7); // Rich center site
spawnResourceCluster(-15, 65, 4);
spawnResourceCluster(15, -65, 4);
/* =========================================================================
3D UNIT & BUILDING MESH BUILDERS
========================================================================= */
function createUnitMesh(type, faction) {
const group = new THREE.Group();
const factionColor = faction === FACTION_PLAYER ? COLOR_PLAYER : COLOR_ENEMY;
const hullMat = new THREE.MeshStandardMaterial({ color: 0x273546, roughness: 0.5, metalness: 0.6 });
const trimMat = new THREE.MeshStandardMaterial({ color: factionColor, emissive: factionColor, emissiveIntensity: 0.35, roughness: 0.4 });
const glowMat = new THREE.MeshBasicMaterial({ color: faction === FACTION_PLAYER ? 0x00e5ff : 0xff3d00 });
if (type === 'worker') {
// 6-wheeled mining rover with hydraulic drill and cargo container
const body = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.8, 2.2), hullMat);
body.position.y = 0.7;
body.castShadow = true;
group.add(body);
const cab = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.6, 1.0), trimMat);
cab.position.set(0, 1.2, 0.3);
cab.castShadow = true;
group.add(cab);
// Front arm
const arm = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.15, 1.2), hullMat);
arm.rotation.x = Math.PI / 3;
arm.position.set(0, 0.8, -1.2);
group.add(arm);
// Crystal cargo indicator
const cargo = new THREE.Mesh(new THREE.OctahedronGeometry(0.4), glowMat);
cargo.name = 'cargoMesh';
cargo.position.set(0, 1.3, -0.6);
cargo.visible = false;
group.add(cargo);
} else if (type === 'scout') {
// Scout Raider: Low-slung attack buggy
const chassis = new THREE.Mesh(new THREE.ConeGeometry(1.0, 2.4, 4), trimMat);
chassis.rotation.x = -Math.PI / 2;
chassis.position.y = 0.6;
chassis.castShadow = true;
group.add(chassis);
const wheels = new THREE.Mesh(new THREE.CylinderGeometry(0.35, 0.35, 2.2, 8), hullMat);
wheels.rotation.z = Math.PI / 2;
wheels.position.set(0, 0.35, -0.2);
group.add(wheels);
const gun = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.2, 1.0), hullMat);
gun.position.set(0, 1.0, -0.4);
group.add(gun);
} else if (type === 'marine') {
// Rifle Marine: Powered infantry armor
const torso = new THREE.Mesh(new THREE.BoxGeometry(0.8, 1.0, 0.6), trimMat);
torso.position.y = 1.1;
torso.castShadow = true;
group.add(torso);
const helmet = new THREE.Mesh(new THREE.SphereGeometry(0.35, 8, 8), hullMat);
helmet.position.set(0, 1.8, 0);
group.add(helmet);
const visor = new THREE.Mesh(new THREE.BoxGeometry(0.35, 0.15, 0.2), glowMat);
visor.position.set(0, 1.8, -0.25);
group.add(visor);
const rifle = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.08, 1.2), hullMat);
rifle.rotation.x = Math.PI / 2;
rifle.position.set(0.4, 1.0, -0.5);
group.add(rifle);
} else if (type === 'tank') {
// Siege Titan: Massive dual-tread battle tank with double plasma cannon turret
const hull = new THREE.Mesh(new THREE.BoxGeometry(2.8, 1.0, 3.8), hullMat);
hull.position.y = 0.65;
hull.castShadow = true;
group.add(hull);
// Tread guards
const treadsL = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.8, 4.0), trimMat);
treadsL.position.set(-1.4, 0.5, 0);
treadsL.castShadow = true;
group.add(treadsL);
const treadsR = treadsL.clone();
treadsR.position.x = 1.4;
group.add(treadsR);
// Turret
const turret = new THREE.Mesh(new THREE.CylinderGeometry(1.0, 1.2, 0.7, 8), trimMat);
turret.name = 'turret';
turret.position.set(0, 1.4, -0.2);
turret.castShadow = true;
group.add(turret);
// Twin Cannons
const b1 = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.18, 2.8, 8), hullMat);
b1.rotation.x = Math.PI / 2;
b1.position.set(-0.35, 0.1, -1.4);
turret.add(b1);
const b2 = b1.clone();
b2.position.x = 0.35;
turret.add(b2);
} else if (type === 'flyer') {
// Valkyrie Gunship: Swept-wing stealth gunship
const fuselage = new THREE.Mesh(new THREE.ConeGeometry(1.1, 3.2, 5), trimMat);
fuselage.rotation.x = -Math.PI / 2;
fuselage.castShadow = true;
group.add(fuselage);
const wings = new THREE.Mesh(new THREE.BoxGeometry(4.2, 0.12, 1.4), hullMat);
wings.position.set(0, 0.1, 0.2);
wings.castShadow = true;
group.add(wings);
const engines = new THREE.Mesh(new THREE.CylinderGeometry(0.4, 0.3, 1.6, 8), glowMat);
engines.rotation.x = Math.PI / 2;
engines.position.set(0, 0.2, 1.4);
group.add(engines);
}
// Selection Decal Ring (underneath)
const ringGeo = new THREE.RingGeometry(0.9, 1.15, 24);
ringGeo.rotateX(-Math.PI / 2);
const ringMat = new THREE.MeshBasicMaterial({ color: 0x00e5ff, side: THREE.DoubleSide });
const selectRing = new THREE.Mesh(ringGeo, ringMat);
selectRing.name = 'selectRing';
selectRing.position.y = 0.08;
selectRing.visible = false;
group.add(selectRing);
return group;
}
function createBuildingMesh(type, faction) {
const group = new THREE.Group();
const factionColor = faction === FACTION_PLAYER ? COLOR_PLAYER : COLOR_ENEMY;
const hullMat = new THREE.MeshStandardMaterial({ color: 0x1b2737, roughness: 0.6, metalness: 0.5 });
const trimMat = new THREE.MeshStandardMaterial({ color: factionColor, emissive: factionColor, emissiveIntensity: 0.25, roughness: 0.4 });
const glowMat = new THREE.MeshBasicMaterial({ color: faction === FACTION_PLAYER ? 0x00e5ff : 0xff2a40 });
if (type === 'hq') {
// Large command dome with communications dish and landing pad
const base = new THREE.Mesh(new THREE.CylinderGeometry(4.5, 5.0, 1.4, 8), hullMat);
base.position.y = 0.7;
base.castShadow = true;
group.add(base);
const dome = new THREE.Mesh(new THREE.SphereGeometry(3.0, 12, 10, 0, Math.PI * 2, 0, Math.PI / 2), trimMat);
dome.position.y = 1.4;
dome.castShadow = true;
group.add(dome);
const spire = new THREE.Mesh(new THREE.CylinderGeometry(0.15, 0.4, 4.0), glowMat);
spire.position.set(0, 4.8, 0);
group.add(spire);
} else if (type === 'reactor') {
// Futuristic fusion core with rotating glowing ring
const base = new THREE.Mesh(new THREE.CylinderGeometry(2.4, 2.7, 1.0, 6), hullMat);
base.position.y = 0.5;
base.castShadow = true;
group.add(base);
const pillar = new THREE.Mesh(new THREE.CylinderGeometry(1.2, 1.2, 3.2, 8), trimMat);
pillar.position.y = 2.0;
pillar.castShadow = true;
group.add(pillar);
const orb = new THREE.Mesh(new THREE.SphereGeometry(1.1, 12, 12), glowMat);
orb.position.y = 4.0;
group.add(orb);
} else if (type === 'barracks') {
// Military barracks with troop egress bay
const b = new THREE.Mesh(new THREE.BoxGeometry(4.6, 2.2, 3.8), hullMat);
b.position.y = 1.1;
b.castShadow = true;
group.add(b);
const roof = new THREE.Mesh(new THREE.BoxGeometry(4.2, 0.6, 3.4), trimMat);
roof.position.y = 2.4;
roof.castShadow = true;
group.add(roof);
const door = new THREE.Mesh(new THREE.PlaneGeometry(1.8, 1.4), glowMat);
door.position.set(0, 0.7, 1.92);
group.add(door);
} else if (type === 'foundry') {
// Heavy vehicle foundry with exhaust chimneys
const main = new THREE.Mesh(new THREE.BoxGeometry(5.2, 2.6, 4.8), hullMat);
main.position.y = 1.3;
main.castShadow = true;
group.add(main);
const stripe = new THREE.Mesh(new THREE.BoxGeometry(5.4, 0.5, 5.0), trimMat);
stripe.position.y = 1.5;
group.add(stripe);
const stack1 = new THREE.Mesh(new THREE.CylinderGeometry(0.4, 0.5, 2.2, 8), hullMat);
stack1.position.set(-1.6, 3.4, -1.2);
group.add(stack1);
const stack2 = stack1.clone();
stack2.position.x = 1.6;
group.add(stack2);
} else if (type === 'aerodrome') {
// Runway pad with control tower and hangar
const pad = new THREE.Mesh(new THREE.BoxGeometry(5.2, 0.4, 5.2), hullMat);
pad.position.y = 0.2;
pad.castShadow = true;
group.add(pad);
const runway = new THREE.Mesh(new THREE.PlaneGeometry(2.0, 4.8), trimMat);
runway.rotation.x = -Math.PI / 2;
runway.position.y = 0.42;
group.add(runway);
const tower = new THREE.Mesh(new THREE.CylinderGeometry(0.7, 0.9, 3.8, 6), hullMat);
tower.position.set(-1.8, 1.9, -1.8);
tower.castShadow = true;
group.add(tower);
const beacon = new THREE.Mesh(new THREE.SphereGeometry(0.4, 8, 8), glowMat);
beacon.position.set(-1.8, 4.0, -1.8);
group.add(beacon);
} else if (type === 'turret') {
// Twin-barrel defensive turret
const base = new THREE.Mesh(new THREE.CylinderGeometry(1.6, 2.0, 1.4, 8), hullMat);
base.position.y = 0.7;
base.castShadow = true;
group.add(base);
const swivel = new THREE.Mesh(new THREE.SphereGeometry(1.1, 10, 10), trimMat);
swivel.name = 'swivel';
swivel.position.y = 1.8;
swivel.castShadow = true;
group.add(swivel);
const b1 = new THREE.Mesh(new THREE.CylinderGeometry(0.14, 0.14, 2.0, 8), hullMat);
b1.rotation.x = Math.PI / 2;
b1.position.set(-0.35, 0.1, -1.0);
swivel.add(b1);
const b2 = b1.clone();
b2.position.x = 0.35;
swivel.add(b2);
} else if (type === 'techlab') {
// Spherical research laboratory with orbiting arrays
const base = new THREE.Mesh(new THREE.CylinderGeometry(2.4, 2.8, 1.2, 6), hullMat);
base.position.y = 0.6;
base.castShadow = true;
group.add(base);
const dome = new THREE.Mesh(new THREE.SphereGeometry(1.8, 12, 12), trimMat);
dome.position.y = 2.2;
dome.castShadow = true;
group.add(dome);
const ring = new THREE.Mesh(new THREE.TorusGeometry(2.6, 0.15, 8, 24), glowMat);
ring.rotation.x = Math.PI / 3;
ring.position.y = 2.2;
group.add(ring);
}
// Selection footprint
const ringGeo = new THREE.RingGeometry(BUILDING_STATS[type].size * 0.45, BUILDING_STATS[type].size * 0.55, 32);
ringGeo.rotateX(-Math.PI / 2);
const ringMat = new THREE.MeshBasicMaterial({ color: 0x00e5ff, side: THREE.DoubleSide });
const selectRing = new THREE.Mesh(ringGeo, ringMat);
selectRing.name = 'selectRing';
selectRing.position.y = 0.1;
selectRing.visible = false;
group.add(selectRing);
return group;
}
/* =========================================================================
HP BARS & OVERHEAD HUD
========================================================================= */
function createHpBarMesh(width = 1.8, height = 0.22) {
const group = new THREE.Group();
const bg = new THREE.Mesh(new THREE.PlaneGeometry(width, height), new THREE.MeshBasicMaterial({ color: 0x0a101b, side: THREE.DoubleSide }));
group.add(bg);
const fill = new THREE.Mesh(new THREE.PlaneGeometry(width, height), new THREE.MeshBasicMaterial({ color: 0x00e676, side: THREE.DoubleSide }));
fill.position.z = 0.01;
group.add(fill);
group.fillMesh = fill;
group.barWidth = width;
group.updateHp = function(cur, max, shield = 0) {
const pct = THREE.MathUtils.clamp(cur / max, 0, 1);
fill.scale.x = pct;
fill.position.x = -(width * (1 - pct)) / 2;
if (pct < 0.3) fill.material.color.setHex(0xff3d00);
else if (pct < 0.6) fill.material.color.setHex(0xffea00);
else fill.material.color.setHex(0x00e676);
};
return group;
}
/* =========================================================================
UNIT CLASS
========================================================================= */
let nextEntityId = 1;
class Unit {
constructor(type, faction, x, z) {
this.id = nextEntityId++;
this.type = type;
this.faction = faction;
this.stats = UNIT_STATS[type];
this.radius = this.stats.radius;
this.speed = this.stats.speed;
this.maxHp = this.stats.hp;
this.hp = this.maxHp;
this.armor = this.stats.armor + (faction === FACTION_PLAYER ? G.player.armorLevel * 2 : G.enemy.armorLevel * 2);
this.sight = this.stats.sight;
this.range = this.stats.range;
this.damage = this.stats.damage;
this.cooldown = this.stats.cooldown;
this.attackTimer = 0;
this.isFlying = !!this.stats.isFlying;
this.x = x;
this.z = z;
this.y = this.isFlying ? this.stats.flightHeight : getTerrainHeight(x, z);
this.targetPos = null;
this.order = 'idle'; // 'idle', 'move', 'attack-move', 'attack', 'gather', 'return-ore', 'hold'
this.targetEntity = null;
this.harvestTarget = null;
this.carriedOre = 0;
this.harvestTimer = 0;
this.mesh = createUnitMesh(type, faction);
this.mesh.position.set(this.x, this.y, this.z);
scene.add(this.mesh);
this.hpBar = createHpBarMesh(type === 'tank' ? 2.4 : 1.4, 0.18);
this.hpBar.position.set(this.x, this.y + (this.isFlying ? 2.0 : 1.8), this.z);
scene.add(this.hpBar);
}
setSelected(val) {
const ring = this.mesh.getObjectByName('selectRing');
if (ring) ring.visible = val;
}
issueMove(tx, tz) {
this.order = 'move';
this.targetPos = new THREE.Vector3(tx, 0, tz);
this.targetEntity = null;
this.harvestTarget = null;
}
issueAttackMove(tx, tz) {
this.order = 'attack-move';
this.targetPos = new THREE.Vector3(tx, 0, tz);
this.targetEntity = null;
this.harvestTarget = null;
}
issueAttack(target) {
this.order = 'attack';
this.targetEntity = target;
this.targetPos = null;
this.harvestTarget = null;
}
issueGather(crystal) {
this.order = 'gather';
this.harvestTarget = crystal;
this.targetEntity = null;
this.targetPos = new THREE.Vector3(crystal.x, 0, crystal.z);
}
issueStop() {
this.order = 'idle';
this.targetPos = null;
this.targetEntity = null;
this.harvestTarget = null;
}
issueHold() {
this.order = 'hold';
this.targetPos = null;
this.targetEntity = null;
}
takeDamage(rawDamage, damageType) {
const mult = (DAMAGE_MULTIPLIER[damageType] && DAMAGE_MULTIPLIER[damageType][this.stats.armorType]) || 1.0;
const effectiveArmor = Math.max(0, this.armor);
const damageDealt = Math.max(1, (rawDamage * mult) - effectiveArmor);
this.hp -= damageDealt;
this.hpBar.updateHp(this.hp, this.maxHp);
spawnDamageNumber(this.x, this.y + 1.2, this.z, Math.round(damageDealt));
if (this.hp <= 0) {
this.die();
}
}
die() {
spawnExplosion(this.x, this.y, this.z, this.isFlying ? 2.5 : 1.8);
sfx.playExplode();
scene.remove(this.mesh);
scene.remove(this.hpBar);
// Release supply
if (this.faction === FACTION_PLAYER) {
G.player.usedSupply = Math.max(0, G.player.usedSupply - this.stats.supply);
} else {
G.enemy.usedSupply = Math.max(0, G.enemy.usedSupply - this.stats.supply);
}
const idx = G.units.indexOf(this);
if (idx !== -1) G.units.splice(idx, 1);
const selIdx = G.selectedUnits.indexOf(this);
if (selIdx !== -1) G.selectedUnits.splice(selIdx, 1);
updateHudSelection();
updateTopBar();
}
findNearestDropoff() {
let nearest = null;
let minDist = Infinity;
for (const b of G.buildings) {
if (b.faction === this.faction && b.stats.isDropoff && b.completed) {
const d = Math.hypot(b.x - this.x, b.z - this.z);
if (d < minDist) {
minDist = d;
nearest = b;
}
}
}
return nearest;
}
update(dt) {
if (this.attackTimer > 0) this.attackTimer -= dt;
// Keep HP bar billboard facing camera
this.hpBar.position.set(this.x, this.y + (this.isFlying ? 2.0 : 1.8), this.z);
this.hpBar.quaternion.copy(camera.quaternion);
// Combat auto-targeting if idle or attack-moving
if (this.stats.damage > 0 && (this.order === 'idle' || this.order === 'attack-move' || this.order === 'hold')) {
if (!this.targetEntity || this.targetEntity.hp <= 0) {
this.targetEntity = this.findHostileTarget(this.range + (this.order === 'hold' ? 0 : 8));
}
}
// Process Specific Orders
if (this.targetEntity) {
if (this.targetEntity.hp <= 0) {
this.targetEntity = null;
if (this.order === 'attack') this.order = 'idle';
} else {
const dist = Math.hypot(this.targetEntity.x - this.x, this.targetEntity.z - this.z);
if (dist <= this.range) {
// In range -> shoot
this.engageTarget(this.targetEntity);
} else if (this.order !== 'hold') {
// Pursue into range
this.moveTowards(this.targetEntity.x, this.targetEntity.z, dt);
}
}
} else if (this.order === 'move' || this.order === 'attack-move') {
if (this.targetPos) {
const dist = Math.hypot(this.targetPos.x - this.x, this.targetPos.z - this.z);
if (dist > 1.2) {
this.moveTowards(this.targetPos.x, this.targetPos.z, dt);
} else {
this.targetPos = null;
this.order = 'idle';
}
}
} else if (this.type === 'worker') {
this.updateWorkerHarvesting(dt);
}
// Local separation force so units never stack or clump into a single point
this.applyFlockingSeparation();
// Position mesh
const groundY = getTerrainHeight(this.x, this.z);
this.y = this.isFlying ? groundY + this.stats.flightHeight : groundY;
this.mesh.position.set(this.x, this.y, this.z);
}
updateWorkerHarvesting(dt) {
const cargoMesh = this.mesh.getObjectByName('cargoMesh');
if (cargoMesh) cargoMesh.visible = this.carriedOre > 0;
if (this.carriedOre >= 10) {
// Find headquarters to return minerals
const dropoff = this.findNearestDropoff();
if (dropoff) {
const dist = Math.hypot(dropoff.x - this.x, dropoff.z - this.z);
if (dist > dropoff.stats.size * 0.6) {
this.moveTowards(dropoff.x, dropoff.z, dt);
} else {
// Unload crystals
if (this.faction === FACTION_PLAYER) {
G.player.ore += this.carriedOre;
updateTopBar();
} else {
G.enemy.ore += this.carriedOre;
}
this.carriedOre = 0;
// Resume gathering from previous crystal
if (this.harvestTarget && this.harvestTarget.amount > 0) {
this.order = 'gather';
} else {
// Find nearby crystal
this.harvestTarget = this.findNearestOre();
if (this.harvestTarget) this.order = 'gather';
else this.order = 'idle';
}
}
}
} else if (this.order === 'gather') {
if (!this.harvestTarget || this.harvestTarget.amount <= 0) {
this.harvestTarget = this.findNearestOre();
}
if (this.harvestTarget) {
const dist = Math.hypot(this.harvestTarget.x - this.x, this.harvestTarget.z - this.z);
if (dist > 2.2) {
this.moveTowards(this.harvestTarget.x, this.harvestTarget.z, dt);
} else {
// Harvest over time
this.harvestTimer += dt;
if (this.harvestTimer >= 1.5) {
this.harvestTimer = 0;
this.carriedOre = this.harvestTarget.harvest(10);
}
}
} else {
this.order = 'idle';
}
}
}
findNearestOre() {
let nearest = null;
let minDist = Infinity;
for (const res of G.resources) {
if (res.amount > 0) {
const d = Math.hypot(res.x - this.x, res.z - this.z);
if (d < minDist) {
minDist = d;
nearest = res;
}
}
}
return nearest;
}
findHostileTarget(maxRange) {
let bestTarget = null;
let bestDist = maxRange;
// Check enemy units
for (const u of G.units) {
if (u.faction !== this.faction && u.hp > 0) {
if (u.isFlying && this.stats.targets === 'ground') continue;
// Fog check: Can't acquire target if hidden in fog
if (this.faction === FACTION_PLAYER && !u.mesh.visible) continue;
if (this.faction === FACTION_ENEMY) {
const { gx, gz } = worldToFogGrid(u.x, u.z);
if (G.enemyFogGrid[gz * FOG_RES + gx] !== 2) continue;
}
const d = Math.hypot(u.x - this.x, u.z - this.z);
if (d < bestDist) {
bestDist = d;
bestTarget = u;
}
}
}
// Check enemy buildings
if (!bestTarget) {
for (const b of G.buildings) {
if (b.faction !== this.faction && b.hp > 0) {
if (this.faction === FACTION_PLAYER && !b.mesh.visible) continue;
if (this.faction === FACTION_ENEMY) {
const { gx, gz } = worldToFogGrid(b.x, b.z);
if (G.enemyFogGrid[gz * FOG_RES + gx] !== 2) continue;
}
const d = Math.hypot(b.x - this.x, b.z - this.z);
if (d < bestDist) {
bestDist = d;
bestTarget = b;
}
}
}
}
return bestTarget;
}
engageTarget(target) {
// Face target
const angle = Math.atan2(target.x - this.x, target.z - this.z);
this.mesh.rotation.y = angle;
if (this.attackTimer <= 0) {
this.attackTimer = this.cooldown;
const weaponBuff = (this.faction === FACTION_PLAYER ? G.player.weaponLevel * 0.2 : G.enemy.weaponLevel * 0.2);
const totalDamage = this.damage * (1.0 + weaponBuff);
// Spawn projectile effect
spawnProjectile(this, target, this.stats.damageType);
// Sound
if (this.type === 'marine') sfx.playShoot('rifle');
else if (this.type === 'tank') sfx.playShoot('heavy');
else sfx.playShoot('laser');
}
}
moveTowards(tx, tz, dt) {
let dx = tx - this.x;
let dz = tz - this.z;
const dist = Math.hypot(dx, dz);
if (dist < 0.1) return;
dx /= dist;
dz /= dist;
// Obstacle avoidance for ground units
if (!this.isFlying) {
for (const obs of G.obstacles) {
const ox = this.x - obs.x;
const oz = this.z - obs.z;
const odist = Math.hypot(ox, oz);
const minDist = obs.radius + this.radius + 1.2;
if (odist < minDist) {
// Push direction away from obstacle
const pushX = (ox / (odist || 1));
const pushZ = (oz / (odist || 1));
dx = dx * 0.3 + pushX * 0.7;
dz = dz * 0.3 + pushZ * 0.7;
}
}
// Avoid buildings
for (const b of G.buildings) {
const bx = this.x - b.x;
const bz = this.z - b.z;
const bdist = Math.hypot(bx, bz);
const minDist = b.stats.size * 0.55 + this.radius;
if (bdist < minDist) {
dx = dx * 0.2 + (bx / (bdist || 1)) * 0.8;
dz = dz * 0.2 + (bz / (bdist || 1)) * 0.8;
}
}
}
const moveStep = this.speed * dt;
this.x += dx * moveStep;
this.z += dz * moveStep;
// Smooth unit rotation
const targetAngle = Math.atan2(dx, dz);
let diff = targetAngle - this.mesh.rotation.y;
while (diff < -Math.PI) diff += Math.PI * 2;
while (diff > Math.PI) diff -= Math.PI * 2;
this.mesh.rotation.y += diff * 12 * dt;
}
applyFlockingSeparation() {
for (const other of G.units) {
if (other === this) continue;
// Air and ground don't push each other
if (this.isFlying !== other.isFlying) continue;
const dx = this.x - other.x;
const dz = this.z - other.z;
const d2 = dx * dx + dz * dz;
const minDist = this.radius + other.radius;
if (d2 < minDist * minDist && d2 > 0.001) {
const d = Math.sqrt(d2);
const overlap = (minDist - d) * 0.5;
this.x += (dx / d) * overlap;
this.z += (dz / d) * overlap;
}
}
}
}
/* =========================================================================
BUILDING CLASS
========================================================================= */
class Building {
constructor(type, faction, x, z, instant = false) {
this.id = nextEntityId++;
this.type = type;
this.faction = faction;
this.stats = BUILDING_STATS[type];
this.maxHp = this.stats.hp;
this.hp = instant ? this.maxHp : Math.floor(this.maxHp * 0.15);
this.armor = this.stats.armor + (faction === FACTION_PLAYER ? G.player.armorLevel * 2 : G.enemy.armorLevel * 2);
this.sight = this.stats.sight;
this.completed = instant;
this.buildProgress = instant ? 1.0 : 0.15;
this.buildTime = this.stats.buildTime;
this.lastSeen = false;
this.x = x;
this.z = z;
this.y = getTerrainHeight(x, z);
// Production & Research queues
this.queue = [];
this.queueProgress = 0;
this.rallyPoint = new THREE.Vector3(x + 4.5, 0, z + 4.5);
// Defense turret combat stats
this.attackTimer = 0;
this.targetEntity = null;
this.mesh = createBuildingMesh(type, faction);
this.mesh.position.set(this.x, this.y, this.z);
scene.add(this.mesh);
this.hpBar = createHpBarMesh(this.stats.size * 0.8, 0.28);
this.hpBar.position.set(this.x, this.y + 4.5, this.z);
scene.add(this.hpBar);
if (this.completed) {
this.onComplete();
}
}
setSelected(val) {
const ring = this.mesh.getObjectByName('selectRing');
if (ring) ring.visible = val;
}
onComplete() {
this.completed = true;
this.hp = this.maxHp;
this.hpBar.updateHp(this.hp, this.maxHp);
if (this.stats.supplyGranted > 0) {
if (this.faction === FACTION_PLAYER) {
G.player.maxSupply += this.stats.supplyGranted;
updateTopBar();
} else {
G.enemy.maxSupply += this.stats.supplyGranted;
}
}
}
takeDamage(rawDamage, damageType) {
const mult = (DAMAGE_MULTIPLIER[damageType] && DAMAGE_MULTIPLIER[damageType][this.stats.armorType]) || 1.0;
const effectiveArmor = Math.max(0, this.armor);
const damageDealt = Math.max(1, (rawDamage * mult) - effectiveArmor);
this.hp -= damageDealt;
this.hpBar.updateHp(this.hp, this.maxHp);
spawnDamageNumber(this.x, this.y + 3.0, this.z, Math.round(damageDealt));
if (this.hp <= 0) {
this.die();
}
}
die() {
spawnExplosion(this.x, this.y + 1.5, this.z, this.stats.size * 0.8);
sfx.playExplode();
scene.remove(this.mesh);
scene.remove(this.hpBar);
if (this.completed && this.stats.supplyGranted > 0) {
if (this.faction === FACTION_PLAYER) {
G.player.maxSupply = Math.max(0, G.player.maxSupply - this.stats.supplyGranted);
updateTopBar();
} else {
G.enemy.maxSupply = Math.max(0, G.enemy.maxSupply - this.stats.supplyGranted);
}
}
const idx = G.buildings.indexOf(this);
if (idx !== -1) G.buildings.splice(idx, 1);
if (G.selectedBuilding === this) {
G.selectedBuilding = null;
updateHudSelection();
}
// Check Victory / Defeat if HQ was destroyed
if (this.stats.isHQ) {
checkHQConditions();
}
}
queueProduction(itemId, isResearch = false) {
const isPlayer = this.faction === FACTION_PLAYER;
const eco = isPlayer ? G.player : G.enemy;
const cost = isResearch ? UPGRADE_STATS[itemId].cost : UNIT_STATS[itemId].cost;
const supplyNeed = isResearch ? 0 : UNIT_STATS[itemId].supply;
if (eco.ore < cost) {
if (isPlayer) showAlert('Insufficient Ore minerals!');
return false;
}
if (supplyNeed > 0 && eco.usedSupply + supplyNeed > eco.maxSupply) {
if (isPlayer) {
showAlert('Supply limit reached! Build more Solar Reactors.');
sfx.playAlert();
}
return false;
}
// Deduct cost and reserve supply
eco.ore -= cost;
if (supplyNeed > 0) eco.usedSupply += supplyNeed;
if (isPlayer) updateTopBar();
this.queue.push({
id: itemId,
isResearch: isResearch,
totalTime: isResearch ? UPGRADE_STATS[itemId].time : UNIT_STATS[itemId].buildTime
});
if (isPlayer && G.selectedBuilding === this) {
updateHudSelection();
}
return true;
}
cancelQueue(index) {
if (index < 0 || index >= this.queue.length) return;
const item = this.queue[index];
const isPlayer = this.faction === FACTION_PLAYER;
const eco = isPlayer ? G.player : G.enemy;
// Refund resources
const cost = item.isResearch ? UPGRADE_STATS[item.id].cost : UNIT_STATS[item.id].cost;
const supply = item.isResearch ? 0 : UNIT_STATS[item.id].supply;
eco.ore += cost;
if (supply > 0) eco.usedSupply = Math.max(0, eco.usedSupply - supply);
this.queue.splice(index, 1);
if (index === 0) this.queueProgress = 0;
if (isPlayer) {
updateTopBar();
if (G.selectedBuilding === this) updateHudSelection();
}
}
update(dt) {
// Face HP bar towards camera
this.hpBar.quaternion.copy(camera.quaternion);
// Construction phase
if (!this.completed) {
this.buildProgress += dt / this.buildTime;
this.hp = Math.floor(this.maxHp * THREE.MathUtils.clamp(this.buildProgress, 0.1, 1.0));
this.hpBar.updateHp(this.hp, this.maxHp);
// Visual rise from ground
this.mesh.position.y = this.y - (1.0 - Math.min(1.0, this.buildProgress)) * 2.0;
if (this.buildProgress >= 1.0) {
this.onComplete();
}
return;
}
// Production Queue execution
if (this.queue.length > 0) {
const current = this.queue[0];
this.queueProgress += dt;
if (this.queueProgress >= current.totalTime) {
this.queueProgress = 0;
this.finishProduction(current);
this.queue.shift();
if (this.faction === FACTION_PLAYER && G.selectedBuilding === this) {
updateHudSelection();
}
}
}
// Defense Turret auto-defense
if (this.stats.isDefensive) {
if (this.attackTimer > 0) this.attackTimer -= dt;
if (!this.targetEntity || this.targetEntity.hp <= 0) {
this.targetEntity = this.findHostileTarget(this.stats.range);
}
if (this.targetEntity) {
const d = Math.hypot(this.targetEntity.x - this.x, this.targetEntity.z - this.z);
if (d <= this.stats.range) {
// Aim swivel
const swivel = this.mesh.getObjectByName('swivel');
if (swivel) {
swivel.rotation.y = Math.atan2(this.targetEntity.x - this.x, this.targetEntity.z - this.z);
}
if (this.attackTimer <= 0) {
this.attackTimer = this.stats.cooldown;
spawnProjectile(this, this.targetEntity, this.stats.damageType);
sfx.playShoot('laser');
}
} else {
this.targetEntity = null;
}
}
}
}
finishProduction(item) {
if (item.isResearch) {
// Apply Upgrade
if (item.id === 'upgrade_weapons') {
if (this.faction === FACTION_PLAYER) {
G.player.weaponLevel++;
showAlert('Weapons Upgrade Cal. II Complete!');
} else {
G.enemy.weaponLevel++;
}
} else if (item.id === 'upgrade_armor') {
if (this.faction === FACTION_PLAYER) {
G.player.armorLevel++;
showAlert('Reinforced Plating Upgrade Complete!');
} else {
G.enemy.armorLevel++;
}
}
if (this.faction === FACTION_PLAYER) {
document.getElementById('tech-status').textContent = `Armor Lv ${G.player.armorLevel} | Weap Lv ${G.player.weaponLevel}`;
}
} else {
// Spawn Unit around building exit
const spawnX = this.x + (Math.random() - 0.5) * 2;
const spawnZ = this.z + this.stats.size * 0.65;
const u = new Unit(item.id, this.faction, spawnX, spawnZ);
G.units.push(u);
// Follow rally point
u.issueMove(this.rallyPoint.x + (Math.random() - 0.5) * 4, this.rallyPoint.z + (Math.random() - 0.5) * 4);
if (this.faction === FACTION_PLAYER) {
showAlert(`${u.stats.name} deployed.`);
}
}
}
findHostileTarget(maxRange) {
let best = null;
let bestDist = maxRange;
for (const u of G.units) {
if (u.faction !== this.faction && u.hp > 0) {
if (this.faction === FACTION_PLAYER && !u.mesh.visible) continue;
if (this.faction === FACTION_ENEMY) {
const { gx, gz } = worldToFogGrid(u.x, u.z);
if (G.enemyFogGrid[gz * FOG_RES + gx] !== 2) continue;
}
const d = Math.hypot(u.x - this.x, u.z - this.z);
if (d < bestDist) {
bestDist = d;
best = u;
}
}
}
return best;
}
}
function checkHQConditions() {
const playerHQs = G.buildings.filter(b => b.faction === FACTION_PLAYER && b.stats.isHQ);
const enemyHQs = G.buildings.filter(b => b.faction === FACTION_ENEMY && b.stats.isHQ);
if (enemyHQs.length === 0 && !G.gameOver) {
triggerGameOver(true);
} else if (playerHQs.length === 0 && !G.gameOver) {
triggerGameOver(false);
}
}
function triggerGameOver(isVictory) {
G.gameOver = true;
G.victory = isVictory;
const modal = document.getElementById('modal-screen');
const card = document.getElementById('modal-content');
if (isVictory) {
card.innerHTML = `
<h1 style="color:#00e676;">MISSION ACCOMPLISHED</h1>
<p style="font-size:16px;">The Dominion Syndicate headquarters on Tartarus VII have been eliminated. The frontier crystal deposits are under expeditionary control.</p>
<div style="background:#102235; padding:12px; border-radius:6px; margin:16px 0; font-size:13px;">
<p><strong>Match Time:</strong> ${document.getElementById('game-timer').textContent}</p>
<p><strong>Ore Harvested:</strong> ${G.player.ore} minerals</p>
<p><strong>Army Units Deployed:</strong> ${G.units.filter(u => u.faction === FACTION_PLAYER).length}</p>
</div>
<button class="modal-btn" onclick="restartMatch()">PLAY AGAIN</button>
`;
} else {
card.innerHTML = `
<h1 style="color:#ff1744;">COMMAND CENTER DESTROYED</h1>
<p style="font-size:16px;">All expedition Headquarters have fallen. Dominion Syndicate forces have overrun our frontier operation.</p>
<div style="background:#102235; padding:12px; border-radius:6px; margin:16px 0; font-size:13px;">
<p><strong>Match Time:</strong> ${document.getElementById('game-timer').textContent}</p>
</div>
<button class="modal-btn" onclick="restartMatch()">RESTART MISSION</button>
`;
}
modal.style.display = 'flex';
}
/* =========================================================================
PROJECTILES, PARTICLES & EXPLOSIONS
========================================================================= */
function spawnProjectile(source, target, damageType) {
const isBeam = (damageType === 'energy');
const start = new THREE.Vector3(source.x, source.y + 1.2, source.z);
const end = new THREE.Vector3(target.x, target.y + 0.8, target.z);
if (isBeam) {
// Instant energy beam tracer
const lineMat = new THREE.LineBasicMaterial({
color: source.faction === FACTION_PLAYER ? 0x00e5ff : 0xff3d00,
linewidth: 3
});
const lineGeo = new THREE.BufferGeometry().setFromPoints([start, end]);
const line = new THREE.Line(lineGeo, lineMat);
scene.add(line);
setTimeout(() => scene.remove(line), 90);
target.takeDamage(source.damage || source.stats.damage, damageType);
} else {
// Ballistic / kinetic projectile
const geo = new THREE.SphereGeometry(damageType === 'explosive' ? 0.35 : 0.18, 6, 6);
const mat = new THREE.MeshBasicMaterial({
color: damageType === 'explosive' ? 0xffaa00 : 0xffffff
});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.copy(start);
scene.add(mesh);
G.projectiles.push({
mesh: mesh,
source: source,
target: target,
damage: source.damage || source.stats.damage,
damageType: damageType,
pos: start,
targetPos: end,
speed: damageType === 'explosive' ? 42 : 58
});
}
}
function updateProjectiles(dt) {
for (let i = G.projectiles.length - 1; i >= 0; i--) {
const p = G.projectiles[i];
if (p.target && p.target.hp > 0) {
p.targetPos.set(p.target.x, p.target.y + 0.8, p.target.z);
}
const dir = new THREE.Vector3().subVectors(p.targetPos, p.pos);
const dist = dir.length();
const step = p.speed * dt;
if (dist <= step || dist < 0.8) {
// Hit target
if (p.target && p.target.hp > 0) {
p.target.takeDamage(p.damage, p.damageType);
}
scene.remove(p.mesh);
G.projectiles.splice(i, 1);
} else {
dir.normalize();
p.pos.addScaledVector(dir, step);
p.mesh.position.copy(p.pos);
}
}
}
function spawnExplosion(x, y, z, scale = 1.0) {
const count = Math.floor(12 * scale);
for (let i = 0; i < count; i++) {
const geo = new THREE.SphereGeometry(0.2 * scale, 4, 4);
const mat = new THREE.MeshBasicMaterial({ color: Math.random() > 0.4 ? 0xff6600 : 0xffcc00 });
const p = new THREE.Mesh(geo, mat);
p.position.set(x, y, z);
scene.add(p);
const vel = new THREE.Vector3(
(Math.random() - 0.5) * 14 * scale,
Math.random() * 12 * scale + 4,
(Math.random() - 0.5) * 14 * scale
);
G.particles.push({
mesh: p,
vel: vel,
life: 0.4 + Math.random() * 0.35,
maxLife: 0.75
});
}
}
function updateParticles(dt) {
for (let i = G.particles.length - 1; i >= 0; i--) {
const p = G.particles[i];
p.life -= dt;
if (p.life <= 0) {
scene.remove(p.mesh);
G.particles.splice(i, 1);
} else {
p.vel.y -= 25 * dt; // gravity
p.mesh.position.x += p.vel.x * dt;
p.mesh.position.y += p.vel.y * dt;
p.mesh.position.z += p.vel.z * dt;
const s = p.life / p.maxLife;
p.mesh.scale.set(s, s, s);
}
}
}
function spawnDamageNumber(x, y, z, amount) {
const v = new THREE.Vector3(x, y, z).project(camera);
if (v.z > 1) return; // behind camera
const sx = (v.x * 0.5 + 0.5) * window.innerWidth;
const sy = (-(v.y * 0.5) + 0.5) * window.innerHeight;
const div = document.createElement('div');
div.className = 'combat-text';
div.textContent = `-${amount}`;
div.style.left = `${sx}px`;
div.style.top = `${sy}px`;
document.body.appendChild(div);
setTimeout(() => div.remove(), 900);
}
/* =========================================================================
AUTONOMOUS AI OPPONENT (ECONOMY, PRODUCTION, SCOUTING & ATTACK WAVES)
========================================================================= */
function updateAI(dt) {
const ai = G.enemy;
ai.strategyTimer += dt;
// 1. Worker management: keep miners harvesting
const aiWorkers = G.units.filter(u => u.faction === FACTION_ENEMY && u.type === 'worker');
for (const w of aiWorkers) {
if (w.order === 'idle') {
const ore = w.findNearestOre();
if (ore) w.issueGather(ore);
}
}
// 2. Base & Production planning every 2 seconds
if (ai.strategyTimer >= 2.0) {
ai.strategyTimer = 0;
const aiBuildings = G.buildings.filter(b => b.faction === FACTION_ENEMY);
const hqs = aiBuildings.filter(b => b.stats.isHQ);
const reactors = aiBuildings.filter(b => b.type === 'reactor');
const barracks = aiBuildings.filter(b => b.type === 'barracks');
const foundries = aiBuildings.filter(b => b.type === 'foundry');
const aeros = aiBuildings.filter(b => b.type === 'aerodrome');
const turrets = aiBuildings.filter(b => b.type === 'turret');
const techlabs = aiBuildings.filter(b => b.type === 'techlab');
// Need more supply?
if (ai.usedSupply + 4 >= ai.maxSupply && ai.ore >= BUILDING_STATS.reactor.cost) {
aiBuildStructure('reactor');
}
// Need more workers? (up to ~14 miners)
if (aiWorkers.length < 14 && ai.ore >= UNIT_STATS.worker.cost && hqs.length > 0) {
const readyHQ = hqs.find(h => h.completed && h.queue.length === 0);
if (readyHQ) readyHQ.queueProduction('worker');
}
// Construct Barracks early
if (barracks.length === 0 && ai.ore >= BUILDING_STATS.barracks.cost) {
aiBuildStructure('barracks');
}
// Construct Defense Turret near base perimeter
if (turrets.length < 2 && ai.ore >= BUILDING_STATS.turret.cost && barracks.length > 0) {
aiBuildStructure('turret');
}
// Construct Foundry for heavy armor
if (barracks.length >= 1 && foundries.length === 0 && ai.ore >= BUILDING_STATS.foundry.cost) {
aiBuildStructure('foundry');
}
// Construct Aerodrome for gunships
if (foundries.length >= 1 && aeros.length === 0 && ai.ore >= BUILDING_STATS.aerodrome.cost) {
aiBuildStructure('aerodrome');
}
// Construct Techlab for upgrades
if (techlabs.length === 0 && barracks.length >= 1 && ai.ore >= BUILDING_STATS.techlab.cost) {
aiBuildStructure('techlab');
}
// Research upgrades if techlab is available
if (techlabs.length > 0 && techlabs[0].completed && techlabs[0].queue.length === 0) {
if (ai.weaponLevel === 0 && ai.ore >= UPGRADE_STATS.upgrade_weapons.cost) {
techlabs[0].queueProduction('upgrade_weapons', true);
} else if (ai.armorLevel === 0 && ai.ore >= UPGRADE_STATS.upgrade_armor.cost) {
techlabs[0].queueProduction('upgrade_armor', true);
}
}
// Second HQ Expansion if wealthy
if (hqs.length < 2 && ai.ore >= BUILDING_STATS.hq.cost + 50) {
aiBuildExpansionHQ();
}
// Train Combat Army across all production buildings
for (const b of barracks) {
if (b.completed && b.queue.length === 0) {
if (Math.random() < 0.6) b.queueProduction('marine');
else b.queueProduction('scout');
}
}
for (const f of foundries) {
if (f.completed && f.queue.length === 0) {
f.queueProduction('tank');
}
}
for (const a of aeros) {
if (a.completed && a.queue.length === 0) {
a.queueProduction('flyer');
}
}
}
// 3. Army Assembly and Wave Attacks
const aiCombatUnits = G.units.filter(u => u.faction === FACTION_ENEMY && u.type !== 'worker');
const targetWaveSize = 4 + ai.attackWave * 3;
// After 60 seconds of peace, AI begins escalating assaults
if (G.time > 65 && G.time - ai.lastAttackTime > 50 && aiCombatUnits.length >= targetWaveSize) {
ai.lastAttackTime = G.time;
ai.attackWave++;
// Locate player target (HQ or units)
let attackTarget = G.buildings.find(b => b.faction === FACTION_PLAYER && b.stats.isHQ);
if (!attackTarget) attackTarget = G.buildings.find(b => b.faction === FACTION_PLAYER);
if (!attackTarget) attackTarget = G.units.find(u => u.faction === FACTION_PLAYER);
const tx = attackTarget ? attackTarget.x : -80;
const tz = attackTarget ? attackTarget.z : -80;
for (const u of aiCombatUnits) {
u.issueAttackMove(tx + (Math.random() - 0.5) * 8, tz + (Math.random() - 0.5) * 8);
}
showAlert('Hostile Dominion strike force detected inbound!');
sfx.playAlert();
}
}
function aiBuildStructure(type) {
const stat = BUILDING_STATS[type];
if (G.enemy.ore < stat.cost) return;
// Position near enemy base at (80, 80)
const angle = Math.random() * Math.PI * 2;
const dist = 14 + Math.random() * 22;
const bx = 80 + Math.cos(angle) * dist;
const bz = 80 + Math.sin(angle) * dist;
if (isValidPlacement(type, bx, bz)) {
G.enemy.ore -= stat.cost;
const b = new Building(type, FACTION_ENEMY, bx, bz, false);
G.buildings.push(b);
}
}
function aiBuildExpansionHQ() {
const stat = BUILDING_STATS.hq;
if (G.enemy.ore < stat.cost) return;
// Expand towards the rich center-east deposit (60, -60)
const bx = 60 + (Math.random() - 0.5) * 8;
const bz = -60 + (Math.random() - 0.5) * 8;
if (isValidPlacement('hq', bx, bz)) {
G.enemy.ore -= stat.cost;
const b = new Building('hq', FACTION_ENEMY, bx, bz, false);
G.buildings.push(b);
}
}
/* =========================================================================
BUILDING PLACEMENT LOGIC & VALIDATION
========================================================================= */
function isValidPlacement(type, x, z) {
const radius = BUILDING_STATS[type].size * 0.5;
// Bounds check
const lim = MAP_SIZE / 2 - 12;
if (Math.abs(x) > lim || Math.abs(z) > lim) return false;
// Collision with obstacles
for (const obs of G.obstacles) {
if (Math.hypot(x - obs.x, z - obs.z) < obs.radius + radius) return false;
}
// Collision with existing buildings
for (const b of G.buildings) {
const minDist = radius + b.stats.size * 0.5 + 1.5;
if (Math.hypot(x - b.x, z - b.z) < minDist) return false;
}
// Collision with crystal deposits
for (const res of G.resources) {
if (Math.hypot(x - res.x, z - res.z) < radius + 2.5) return false;
}
return true;
}
function startPlacement(type) {
G.placingType = type;
if (G.placementMesh) scene.remove(G.placementMesh);
G.placementMesh = createBuildingMesh(type, FACTION_PLAYER);
// Give transparent ghost look
G.placementMesh.traverse((child) => {
if (child.isMesh && child.name !== 'selectRing') {
child.material = new THREE.MeshBasicMaterial({
color: 0x00e5ff,
transparent: true,
opacity: 0.55,
wireframe: true
});
}
});
scene.add(G.placementMesh);
}
function cancelPlacement() {
G.placingType = null;
if (G.placementMesh) {
scene.remove(G.placementMesh);
G.placementMesh = null;
}
}
function confirmPlacement(x, z) {
if (!G.placingType) return;
const stat = BUILDING_STATS[G.placingType];
if (G.player.ore < stat.cost) {
showAlert('Insufficient Ore!');
cancelPlacement();
return;
}
if (!isValidPlacement(G.placingType, x, z)) {
showAlert('Invalid building position: terrain or structures obstructed!');
sfx.playAlert();
return;
}
// Deduct cost and spawn building
G.player.ore -= stat.cost;
updateTopBar();
const b = new Building(G.placingType, FACTION_PLAYER, x, z, false);
G.buildings.push(b);
sfx.playOrder();
// If worker was selected, assign to build it
const selectedWorkers = G.selectedUnits.filter(u => u.type === 'worker');
if (selectedWorkers.length > 0) {
for (const w of selectedWorkers) {
w.issueMove(x, z);
}
}
cancelPlacement();
}
/* =========================================================================
USER INPUT & SELECTION SYSTEM
========================================================================= */
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
let dragStart = null;
let isDraggingBox = false;
const selectBoxEl = document.getElementById('select-box');
function getGroundIntersection(event) {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const hits = raycaster.intersectObject(terrainMesh);
if (hits.length > 0) return hits[0].point;
return null;
}
window.addEventListener('mousedown', (e) => {
if (e.target.closest('#hud') || e.target.closest('.modal-overlay')) return;
if (e.button === 0) {
// Left Click: Placement or selection start
const pt = getGroundIntersection(e);
if (G.placingType && pt) {
confirmPlacement(pt.x, pt.z);
return;
}
if (G.activeCommand === 'attack-move' && pt) {
for (const u of G.selectedUnits) {
u.issueAttackMove(pt.x, pt.z);
}
G.activeCommand = null;
updateHudSelection();
sfx.playOrder();
return;
}
dragStart = { x: e.clientX, y: e.clientY };
isDraggingBox = false;
} else if (e.button === 2) {
// Right Click: Order or cancel placement
if (G.placingType) {
cancelPlacement();
return;
}
const pt = getGroundIntersection(e);
if (!pt) return;
// Check if clicked an entity
const targetEntity = getEntityUnderMouse(e);
if (targetEntity) {
if (targetEntity.amount !== undefined) {
// Crystal deposit clicked -> workers gather
for (const u of G.selectedUnits) {
if (u.type === 'worker') u.issueGather(targetEntity);
else u.issueMove(targetEntity.x, targetEntity.z);
}
sfx.playOrder();
} else if (targetEntity.faction !== FACTION_PLAYER) {
// Hostile enemy clicked -> attack order
for (const u of G.selectedUnits) {
u.issueAttack(targetEntity);
}
sfx.playOrder();
} else if (targetEntity.faction === FACTION_PLAYER && targetEntity.stats && targetEntity.stats.isDropoff) {
// Friendly HQ -> deposit ore
for (const u of G.selectedUnits) {
if (u.type === 'worker' && u.carriedOre > 0) {
u.issueMove(targetEntity.x, targetEntity.z);
}
}
sfx.playOrder();
}
} else {
// Ground move
const count = G.selectedUnits.length;
G.selectedUnits.forEach((u, i) => {
// Spread units slightly into formation
const angle = (i / (count || 1)) * Math.PI * 2;
const spread = count > 1 ? Math.min(6, count * 0.75) : 0;
const tx = pt.x + Math.cos(angle) * spread;
const tz = pt.z + Math.sin(angle) * spread;
u.issueMove(tx, tz);
});
sfx.playOrder();
}
}
});
window.addEventListener('mousemove', (e) => {
// Placement ghost tracking
if (G.placingType && G.placementMesh) {
const pt = getGroundIntersection(e);
if (pt) {
G.placementMesh.position.set(pt.x, getTerrainHeight(pt.x, pt.z), pt.z);
}
}
// Drag selection box
if (dragStart && e.buttons === 1) {
const dx = Math.abs(e.clientX - dragStart.x);
const dy = Math.abs(e.clientY - dragStart.y);
if (dx > 4 || dy > 4) {
isDraggingBox = true;
selectBoxEl.style.display = 'block';
const left = Math.min(dragStart.x, e.clientX);
const top = Math.min(dragStart.y, e.clientY);
selectBoxEl.style.left = `${left}px`;
selectBoxEl.style.top = `${top}px`;
selectBoxEl.style.width = `${dx}px`;
selectBoxEl.style.height = `${dy}px`;
}
}
});
window.addEventListener('mouseup', (e) => {
if (e.button === 0 && dragStart) {
if (isDraggingBox) {
// Area box selection
selectBoxEl.style.display = 'none';
const x1 = Math.min(dragStart.x, e.clientX);
const x2 = Math.max(dragStart.x, e.clientX);
const y1 = Math.min(dragStart.y, e.clientY);
const y2 = Math.max(dragStart.y, e.clientY);
if (!e.shiftKey) clearSelection();
for (const u of G.units) {
if (u.faction === FACTION_PLAYER) {
const sp = new THREE.Vector3(u.x, u.y, u.z).project(camera);
const sx = (sp.x * 0.5 + 0.5) * window.innerWidth;
const sy = (-(sp.y * 0.5) + 0.5) * window.innerHeight;
if (sx >= x1 && sx <= x2 && sy >= y1 && sy <= y2) {
selectUnit(u, true);
}
}
}
sfx.playClick();
updateHudSelection();
} else if (!e.target.closest('#hud') && !e.target.closest('.modal-overlay')) {
// Single unit / building click
const entity = getEntityUnderMouse(e);
if (!e.shiftKey) clearSelection();
if (entity) {
if (entity instanceof Unit) {
selectUnit(entity, true);
} else if (entity instanceof Building) {
selectBuilding(entity);
}
sfx.playClick();
}
updateHudSelection();
}
dragStart = null;
isDraggingBox = false;
}
});
function getEntityUnderMouse(event) {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
// Check units
for (const u of G.units) {
if (!u.mesh.visible) continue;
const hits = raycaster.intersectObjects(u.mesh.children, true);
if (hits.length > 0) return u;
}
// Check buildings
for (const b of G.buildings) {
if (!b.mesh.visible) continue;
const hits = raycaster.intersectObjects(b.mesh.children, true);
if (hits.length > 0) return b;
}
// Check crystal deposits
for (const res of G.resources) {
const hits = raycaster.intersectObjects(res.mesh.children, true);
if (hits.length > 0) return res;
}
return null;
}
function clearSelection() {
for (const u of G.selectedUnits) u.setSelected(false);
G.selectedUnits = [];
if (G.selectedBuilding) {
G.selectedBuilding.setSelected(false);
G.selectedBuilding = null;
}
}
function selectUnit(u, add = false) {
if (!add) clearSelection();
if (G.selectedBuilding) {
G.selectedBuilding.setSelected(false);
G.selectedBuilding = null;
}
if (!G.selectedUnits.includes(u)) {
G.selectedUnits.push(u);
u.setSelected(true);
}
}
function selectBuilding(b) {
clearSelection();
G.selectedBuilding = b;
b.setSelected(true);
}
// Double click to select all visible units of same type
window.addEventListener('dblclick', (e) => {
if (G.selectedUnits.length === 1) {
const type = G.selectedUnits[0].type;
for (const u of G.units) {
if (u.faction === FACTION_PLAYER && u.type === type) {
selectUnit(u, true);
}
}
updateHudSelection();
}
});
// Mouse Wheel for Camera Zoom
window.addEventListener('wheel', (e) => {
camCtrl.zoom(e.deltaY);
}, { passive: true });
// Keyboard Hotkeys
window.addEventListener('keydown', (e) => {
camCtrl.keys[e.code] = true;
if (e.code === 'KeyA') {
// Attack move toggle
if (G.selectedUnits.length > 0) {
G.activeCommand = 'attack-move';
showAlert('Click ground destination to Attack-Move.');
updateHudSelection();
}
} else if (e.code === 'KeyS') {
// Stop
for (const u of G.selectedUnits) u.issueStop();
sfx.playOrder();
} else if (e.code === 'KeyH') {
// Hold position
for (const u of G.selectedUnits) u.issueHold();
sfx.playOrder();
} else if (e.code === 'Space') {
// Jump camera to selected units or base
if (G.selectedUnits.length > 0) {
camCtrl.target.set(G.selectedUnits[0].x, 0, G.selectedUnits[0].z);
} else {
const hq = G.buildings.find(b => b.faction === FACTION_PLAYER && b.stats.isHQ);
if (hq) camCtrl.target.set(hq.x, 0, hq.z);
}
} else if (e.code === 'Escape') {
if (G.placingType) cancelPlacement();
else togglePause();
} else if (e.code === 'F1') {
e.preventDefault();
toggleHelp();
}
// Control Groups 1-9
if (e.code.startsWith('Digit') || e.code.startsWith('Numpad')) {
const num = e.code.replace('Digit', '').replace('Numpad', '');
if (num >= '1' && num <= '9') {
if (e.ctrlKey) {
// Assign group
G.controlGroups[num] = [...G.selectedUnits];
showAlert(`Control Group ${num} assigned (${G.selectedUnits.length} units).`);
} else {
// Recall group
if (G.controlGroups[num] && G.controlGroups[num].length > 0) {
clearSelection();
for (const u of G.controlGroups[num]) {
if (u.hp > 0) selectUnit(u, true);
}
updateHudSelection();
}
}
}
}
});
window.addEventListener('keyup', (e) => {
camCtrl.keys[e.code] = false;
});
// Prevent default context menu on right click
window.addEventListener('contextmenu', (e) => e.preventDefault());
/* =========================================================================
HUD, SELECTION & PRODUCTION UI
========================================================================= */
const infoTitleEl = document.getElementById('info-title');
const infoCountEl = document.getElementById('info-count');
const infoBodyEl = document.getElementById('info-body');
const queueBarEl = document.getElementById('queue-bar');
const actionGridEl = document.getElementById('action-grid');
function updateTopBar() {
document.getElementById('res-ore-val').textContent = Math.floor(G.player.ore);
document.getElementById('res-sup-val').textContent = `${G.player.usedSupply} / ${G.player.maxSupply}`;
}
function showAlert(msg) {
const box = document.getElementById('alert-box');
const el = document.createElement('div');
el.className = 'alert-msg';
el.textContent = msg;
box.appendChild(el);
setTimeout(() => el.remove(), 4000);
}
function updateHudSelection() {
actionGridEl.innerHTML = '';
queueBarEl.innerHTML = '';
// Case 1: Multiple Units Selected
if (G.selectedUnits.length > 1) {
infoTitleEl.textContent = 'Expedition Strike Force';
infoCountEl.textContent = `${G.selectedUnits.length} Units`;
// Group counts
const counts = {};
for (const u of G.selectedUnits) counts[u.stats.name] = (counts[u.stats.name] || 0) + 1;
let desc = '<div style="margin-bottom:6px;">';
for (const [k, v] of Object.entries(counts)) {
desc += `<span><strong>${v}x</strong> ${k} </span>`;
}
desc += '</div><div style="font-size:11px; color:#8ba7c7;">Command whole group: Attack-Move, Stop, or Hold.</div>';
infoBodyEl.innerHTML = desc;
// Standard Combat Commands
addCommandButton('A', 'Attack-Move', () => { G.activeCommand = 'attack-move'; showAlert('Click destination to Attack-Move'); }, '', G.activeCommand === 'attack-move');
addCommandButton('S', 'Stop', () => { for (const u of G.selectedUnits) u.issueStop(); sfx.playOrder(); });
addCommandButton('H', 'Hold Pos', () => { for (const u of G.selectedUnits) u.issueHold(); sfx.playOrder(); });
return;
}
// Case 2: Single Unit Selected
if (G.selectedUnits.length === 1) {
const u = G.selectedUnits[0];
infoTitleEl.textContent = u.stats.name;
infoCountEl.textContent = `HP: ${Math.round(u.hp)} / ${u.maxHp}`;
let html = `
<div class="hp-bar-bg"><div class="hp-bar-fill" style="width:${(u.hp/u.maxHp)*100}%;"></div></div>
<div style="font-size:11px; margin-bottom:4px;">
<span>Armor: <strong>${u.armor}</strong> (${u.stats.armorType})</span> |
<span>Damage: <strong>${Math.round(u.damage * (1 + G.player.weaponLevel * 0.2))}</strong></span>
</div>
<div style="font-size:11px; color:#8ba7c7; line-height:1.3;">${u.stats.desc}</div>
`;
if (u.type === 'worker' && u.carriedOre > 0) {
html += `<div style="font-size:11px; color:#00e5ff; margin-top:4px;">Carrying: ${u.carriedOre} Ore crystals</div>`;
}
infoBodyEl.innerHTML = html;
// Unit Commands
if (u.stats.damage > 0) {
addCommandButton('A', 'Attack-Move', () => { G.activeCommand = 'attack-move'; showAlert('Click destination to Attack-Move'); }, '', G.activeCommand === 'attack-move');
addCommandButton('S', 'Stop', () => { u.issueStop(); sfx.playOrder(); });
addCommandButton('H', 'Hold Pos', () => { u.issueHold(); sfx.playOrder(); });
}
// Worker Build Menu
if (u.type === 'worker') {
addCommandButton('B', 'Build HQ', () => startPlacement('hq'), `${BUILDING_STATS.hq.cost} Ore`, false, G.player.ore < BUILDING_STATS.hq.cost);
addCommandButton('R', 'Reactor', () => startPlacement('reactor'), `${BUILDING_STATS.reactor.cost} Ore`, false, G.player.ore < BUILDING_STATS.reactor.cost);
addCommandButton('K', 'Barracks', () => startPlacement('barracks'), `${BUILDING_STATS.barracks.cost} Ore`, false, G.player.ore < BUILDING_STATS.barracks.cost);
addCommandButton('F', 'Foundry', () => startPlacement('foundry'), `${BUILDING_STATS.foundry.cost} Ore`, false, G.player.ore < BUILDING_STATS.foundry.cost);
addCommandButton('D', 'Aerodrome', () => startPlacement('aerodrome'), `${BUILDING_STATS.aerodrome.cost} Ore`, false, G.player.ore < BUILDING_STATS.aerodrome.cost);
addCommandButton('T', 'Turret', () => startPlacement('turret'), `${BUILDING_STATS.turret.cost} Ore`, false, G.player.ore < BUILDING_STATS.turret.cost);
addCommandButton('L', 'Tech Bay', () => startPlacement('techlab'), `${BUILDING_STATS.techlab.cost} Ore`, false, G.player.ore < BUILDING_STATS.techlab.cost);
}
return;
}
// Case 3: Structure Selected
if (G.selectedBuilding) {
const b = G.selectedBuilding;
infoTitleEl.textContent = b.stats.name;
infoCountEl.textContent = b.completed ? `HP: ${Math.round(b.hp)} / ${b.maxHp}` : `Under Construction (${Math.floor(b.buildProgress * 100)}%)`;
let html = `
<div class="hp-bar-bg"><div class="hp-bar-fill" style="width:${(b.hp/b.maxHp)*100}%;"></div></div>
<div style="font-size:11px; margin-bottom:4px;">
<span>Armor: <strong>${b.armor}</strong></span> |
<span>Status: <strong>${b.completed ? 'Operational' : 'Building'}</strong></span>
</div>
<div style="font-size:11px; color:#8ba7c7; line-height:1.3;">${b.stats.desc}</div>
`;
infoBodyEl.innerHTML = html;
// Show Production / Training Buttons
if (b.completed && b.faction === FACTION_PLAYER) {
if (b.stats.canTrain) {
for (const unitId of b.stats.canTrain) {
const ustat = UNIT_STATS[unitId];
const canAfford = G.player.ore >= ustat.cost && (G.player.usedSupply + ustat.supply <= G.player.maxSupply);
addCommandButton(
unitId[0].toUpperCase(),
`Train ${ustat.name.split(' ')[0]}`,
() => b.queueProduction(unitId, false),
`${ustat.cost} Ore (${ustat.supply}s)`,
false,
!canAfford
);
}
}
if (b.stats.canResearch) {
for (const upId of b.stats.canResearch) {
const upStat = UPGRADE_STATS[upId];
const canAfford = G.player.ore >= upStat.cost;
addCommandButton(
'U',
upStat.name,
() => b.queueProduction(upId, true),
`${upStat.cost} Ore`,
false,
!canAfford
);
}
}
// Render Queue slots
b.queue.forEach((item, idx) => {
const slot = document.createElement('div');
slot.className = `queue-slot ${idx === 0 ? 'active' : ''}`;
slot.title = 'Click to cancel and refund';
slot.textContent = item.isResearch ? 'UP' : item.id[0].toUpperCase();
slot.onclick = () => b.cancelQueue(idx);
queueBarEl.appendChild(slot);
});
}
return;
}
// Case 4: No selection
infoTitleEl.textContent = 'Tactical Overview';
infoCountEl.textContent = '';
infoBodyEl.innerHTML = `
<p style="margin-bottom:6px;">Select units or buildings to issue orders. Drag a box to command squads.</p>
<p style="color:#8198b5;">Hint: Construct <strong>Solar Reactors</strong> to expand your unit supply ceiling.</p>
`;
}
function addCommandButton(key, label, onClick, costText = '', isActive = false, isDisabled = false) {
const btn = document.createElement('button');
btn.className = `cmd-btn ${isActive ? 'active' : ''}`;
btn.disabled = isDisabled;
btn.innerHTML = `<span class="key">${key}</span><span>${label}</span>${costText ? `<span class="cost">${costText}</span>` : ''}`;
btn.onclick = () => {
sfx.playClick();
onClick();
};
actionGridEl.appendChild(btn);
}
/* =========================================================================
MINIMAP RENDERING & INTERACTION
========================================================================= */
const minimapCanvas = document.getElementById('minimap');
const minimapCtx = minimapCanvas.getContext('2d');
function updateMinimap() {
const w = minimapCanvas.width;
const h = minimapCanvas.height;
// Background
minimapCtx.fillStyle = '#060c18';
minimapCtx.fillRect(0, 0, w, h);
// Draw Fog / Explored Terrain overlay on minimap
const mapImg = minimapCtx.createImageData(w, h);
const data = mapImg.data;
const scale = FOG_RES / w;
for (let y = 0; y < h; y++) {
const gy = Math.floor(y * scale);
const rowOffset = gy * FOG_RES;
for (let x = 0; x < w; x++) {
const gx = Math.floor(x * scale);
const val = G.fogGrid[rowOffset + gx];
const p = (y * w + x) * 4;
if (val === 0) {
// Unexplored
data[p] = 3;
data[p + 1] = 6;
data[p + 2] = 12;
data[p + 3] = 255;
} else if (val === 1) {
// Explored / Shroud
data[p] = 18;
data[p + 1] = 28;
data[p + 2] = 40;
data[p + 3] = 255;
} else {
// Visible
data[p] = 38;
data[p + 1] = 48;
data[p + 2] = 62;
data[p + 3] = 255;
}
}
}
minimapCtx.putImageData(mapImg, 0, 0);
// World to Minimap coords conversion
const toMinimap = (wx, wz) => {
return {
mx: ((wx + MAP_SIZE / 2) / MAP_SIZE) * w,
my: ((wz + MAP_SIZE / 2) / MAP_SIZE) * h
};
};
// Draw Resources if explored
for (const res of G.resources) {
const { gx, gz } = worldToFogGrid(res.x, res.z);
if (G.fogGrid[gz * FOG_RES + gx] > 0 && res.amount > 0) {
const { mx, my } = toMinimap(res.x, res.z);
minimapCtx.fillStyle = '#00e5ff';
minimapCtx.fillRect(mx - 1.5, my - 1.5, 3, 3);
}
}
// Draw Buildings
for (const b of G.buildings) {
const { gx, gz } = worldToFogGrid(b.x, b.z);
const fogState = G.fogGrid[gz * FOG_RES + gx];
if (b.faction === FACTION_PLAYER || fogState === 2 || (fogState === 1 && b.lastSeen)) {
const { mx, my } = toMinimap(b.x, b.z);
minimapCtx.fillStyle = b.faction === FACTION_PLAYER ? COLOR_PLAYER_HEX : COLOR_ENEMY_HEX;
const size = Math.max(3, (b.stats.size / MAP_SIZE) * w);
minimapCtx.fillRect(mx - size / 2, my - size / 2, size, size);
}
}
// Draw Units
for (const u of G.units) {
if (u.faction === FACTION_PLAYER) {
const { mx, my } = toMinimap(u.x, u.z);
minimapCtx.fillStyle = '#90caf9';
minimapCtx.fillRect(mx - 1, my - 1, 2.5, 2.5);
} else {
const { gx, gz } = worldToFogGrid(u.x, u.z);
if (G.fogGrid[gz * FOG_RES + gx] === 2) {
const { mx, my } = toMinimap(u.x, u.z);
minimapCtx.fillStyle = '#ff5252';
minimapCtx.fillRect(mx - 1.5, my - 1.5, 3, 3);
}
}
}
// Draw Camera Frustum Viewport Rectangle
const { mx: cx, my: cy } = toMinimap(camCtrl.target.x, camCtrl.target.z);
const viewW = (camCtrl.distance / MAP_SIZE) * w * 1.4;
const viewH = (camCtrl.distance / MAP_SIZE) * h * 0.9;
minimapCtx.strokeStyle = '#ffffff';
minimapCtx.lineWidth = 1.2;
minimapCtx.strokeRect(cx - viewW / 2, cy - viewH / 2, viewW, viewH);
}
// Minimap Clicking & Panning
function handleMinimapClick(e) {
const rect = minimapCanvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const wx = (mx / minimapCanvas.width) * MAP_SIZE - MAP_SIZE / 2;
const wz = (my / minimapCanvas.height) * MAP_SIZE - MAP_SIZE / 2;
if (e.buttons === 1) {
// Left-click moves camera
camCtrl.target.set(wx, 0, wz);
camCtrl.updatePosition();
} else if (e.button === 2) {
// Right-click issues move to army
for (const u of G.selectedUnits) {
u.issueMove(wx, wz);
}
sfx.playOrder();
}
}
minimapCanvas.addEventListener('mousedown', handleMinimapClick);
minimapCanvas.addEventListener('mousemove', (e) => {
if (e.buttons === 1) handleMinimapClick(e);
});
/* =========================================================================
MODAL DIALOGS & TOP CONTROLS
========================================================================= */
const modalScreen = document.getElementById('modal-screen');
const modalContent = document.getElementById('modal-content');
document.getElementById('btn-start-game').onclick = () => {
sfx.init();
modalScreen.style.display = 'none';
sfx.playClick();
};
document.getElementById('btn-sound').onclick = (e) => {
sfx.enabled = !sfx.enabled;
e.target.textContent = `AUDIO: ${sfx.enabled ? 'ON' : 'OFF'}`;
sfx.playClick();
};
document.getElementById('btn-pause').onclick = togglePause;
document.getElementById('btn-help').onclick = toggleHelp;
function togglePause() {
if (G.gameOver) return;
G.paused = !G.paused;
if (G.paused) {
modalContent.innerHTML = `
<h1>OPERATION PAUSED</h1>
<p>Mission is currently suspended. Adjust strategic plans.</p>
<button class="modal-btn" onclick="togglePause()">RESUME MISSION</button>
<button class="modal-btn secondary" onclick="restartMatch()">RESTART MISSION</button>
`;
modalScreen.style.display = 'flex';
} else {
modalScreen.style.display = 'none';
}
}
function toggleHelp() {
const isOpen = modalScreen.style.display === 'flex';
if (isOpen) {
modalScreen.style.display = 'none';
} else {
modalContent.innerHTML = `
<h1>TACTICAL FIELD GUIDE</h1>
<h2>COMMAND ESSENTIALS</h2>
<ul>
<li><strong>Left-Click / Box-Drag:</strong> Select units or structures. Double click a unit to select all visible matching units.</li>
<li><strong>Right-Click:</strong> Context command. Move units, attack enemies, or order Mining Rovers to harvest crystal deposits.</li>
<li><strong>[A] + Click:</strong> Attack-Move. Units move forward engaging any hostiles encountered along the route.</li>
<li><strong>W/A/S/D or Mouse Pan:</strong> Pan camera. Wheel: Zoom. Q/E: Rotate. Space: Center on base.</li>
</ul>
<h2>COMBINED-ARMS COUNTERS</h2>
<ul>
<li><strong>Scout Raider:</strong> Fast light buggy; slices light infantry & workers.</li>
<li><strong>Rifle Marine:</strong> Rapid fire kinetic; versatile anti-air and anti-buggy.</li>
<li><strong>Siege Titan:</strong> Heavy armor, twin plasma; pulverizes ground vehicles & defense bases.</li>
<li><strong>Valkyrie Gunship:</strong> High mobility VTOL; bypasses terrain and cliffs; countered by Marines & Turrets.</li>
</ul>
<button class="modal-btn" onclick="toggleHelp()">RETURN TO OPERATION</button>
`;
modalScreen.style.display = 'flex';
}
}
/* =========================================================================
MISSION INITIALIZATION & RESET
========================================================================= */
function setupMatch() {
// Clear entities
for (const u of G.units) { scene.remove(u.mesh); scene.remove(u.hpBar); }
for (const b of G.buildings) { scene.remove(b.mesh); scene.remove(b.hpBar); }
for (const p of G.projectiles) scene.remove(p.mesh);
for (const pt of G.particles) scene.remove(pt.mesh);
G.units = [];
G.buildings = [];
G.projectiles = [];
G.particles = [];
G.selectedUnits = [];
G.selectedBuilding = null;
G.controlGroups = {};
G.time = 0;
G.paused = false;
G.gameOver = false;
G.victory = false;
// Reset Fog
G.fogGrid.fill(0);
G.enemyFogGrid.fill(0);
// Player Starting Base at (-80, -80)
G.player = {
ore: 340,
usedSupply: 4,
maxSupply: 0,
weaponLevel: 0,
armorLevel: 0,
researched: {}
};
const playerHQ = new Building('hq', FACTION_PLAYER, -80, -80, true);
G.buildings.push(playerHQ);
// 4 Starting Workers
G.units.push(new Unit('worker', FACTION_PLAYER, -84, -76));
G.units.push(new Unit('worker', FACTION_PLAYER, -82, -74));
G.units.push(new Unit('worker', FACTION_PLAYER, -78, -76));
G.units.push(new Unit('worker', FACTION_PLAYER, -76, -74));
// Enemy Starting Base at (80, 80)
G.enemy = {
ore: 340,
usedSupply: 4,
maxSupply: 0,
weaponLevel: 0,
armorLevel: 0,
researched: {},
strategyTimer: 0,
lastAttackTime: 0,
attackWave: 1,
rallyPoint: new THREE.Vector3(75, 0, 75)
};
const enemyHQ = new Building('hq', FACTION_ENEMY, 80, 80, true);
G.buildings.push(enemyHQ);
// 4 Enemy Starting Workers
G.units.push(new Unit('worker', FACTION_ENEMY, 84, 76));
G.units.push(new Unit('worker', FACTION_ENEMY, 82, 74));
G.units.push(new Unit('worker', FACTION_ENEMY, 78, 76));
G.units.push(new Unit('worker', FACTION_ENEMY, 76, 74));
// Center Camera on Player HQ
camCtrl.target.set(-80, 0, -80);
camCtrl.updatePosition();
updateTopBar();
updateHudSelection();
}
function restartMatch() {
modalScreen.style.display = 'none';
setupMatch();
}
/* =========================================================================
MAIN GAME LOOP
========================================================================= */
let lastFrameTime = performance.now();
let fogTimer = 0;
function gameLoop(now) {
requestAnimationFrame(gameLoop);
const dt = Math.min((now - lastFrameTime) / 1000, 0.1);
lastFrameTime = now;
if (!G.paused && !G.gameOver) {
G.time += dt;
// Update mission timer
const m = Math.floor(G.time / 60);
const s = Math.floor(G.time % 60);
document.getElementById('game-timer').textContent = `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
// Camera movement
camCtrl.update(dt);
// Update simulation entities
for (let i = G.units.length - 1; i >= 0; i--) {
G.units[i].update(dt);
}
for (let i = G.buildings.length - 1; i >= 0; i--) {
G.buildings[i].update(dt);
}
updateProjectiles(dt);
updateParticles(dt);
// AI Simulation
updateAI(dt);
// Periodic Fog of war calculation & Minimap (runs every 0.12s for speed)
fogTimer += dt;
if (fogTimer >= 0.12) {
fogTimer = 0;
updateFogOfWar();
updateMinimap();
}
}
// Render 3D Scene
renderer.render(scene, camera);
}
// Window Resize Handling
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// Initial boot
setupMatch();
requestAnimationFrame(gameLoop);
</script>
</body>
</html>
<!-- agent-meta {"model":"google/gemini-3.8-flash","provider":"openrouter","persona":"full","sessionId":"21da9ca4-083f-4a6c-a51c-295dfbb9da6d","tokensIn":2119133,"tokensOut":45588,"tokensTotal":2164721,"cost":0.5034743249999999,"turns":33,"toolCalls":32,"failedToolCalls":0,"timestamp":"2026-10-01T15:10:57.234Z"} -->