Created
May 28, 2026 18:09
-
-
Save senko/24d117e680759989a9fff5b2b9ab4615 to your computer and use it in GitHub Desktop.
RTS game by Opus 4.8 with ultracode via Claude Code
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> | |
| <title>Frontier Foundry — RTS</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700;900&family=Rajdhani:wght@400;500;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| /* ============================================================ THEME */ | |
| :root{ | |
| --bg-void:#0a0d12; --panel-0:rgba(16,22,32,.86); --panel-1:rgba(22,30,44,.94); | |
| --panel-edge:#1d2a3e; --line:rgba(120,170,230,.18); --line-bright:rgba(120,200,255,.45); | |
| --txt:#c7d6ea; --txt-dim:#7e93ad; --accent:#36e0ff; --gold:#ffce5c; | |
| --green:#5cffac; --red:#ff5a6e; --amber:#ffb454; | |
| } | |
| *{margin:0;padding:0;box-sizing:border-box} | |
| html,body{width:100%;height:100%;overflow:hidden;background:var(--bg-void); | |
| font-family:'Rajdhani',sans-serif;color:var(--txt);user-select:none;cursor:default} | |
| #game-root{position:fixed;inset:0} | |
| #world-canvas{position:absolute;inset:0;width:100%;height:100%;display:block; | |
| background:var(--bg-void);image-rendering:auto;cursor:crosshair} | |
| #world-canvas.placing{cursor:cell} | |
| /* ---- HUD overlay ---- */ | |
| #hud{position:absolute;inset:0;pointer-events:none;z-index:10; | |
| font-family:'Rajdhani',sans-serif} | |
| #hud .panel{background:linear-gradient(180deg,var(--panel-1),var(--panel-0)); | |
| border:1px solid var(--panel-edge);border-radius:7px; | |
| box-shadow:0 6px 22px rgba(0,0,0,.45),inset 0 1px 0 rgba(255,255,255,.05); | |
| backdrop-filter:blur(3px)} | |
| .corner-tick{position:absolute;width:9px;height:9px;border:2px solid var(--line-bright);pointer-events:none} | |
| .ct-tl{top:-1px;left:-1px;border-right:0;border-bottom:0} | |
| .ct-tr{top:-1px;right:-1px;border-left:0;border-bottom:0} | |
| .ct-bl{bottom:-1px;left:-1px;border-right:0;border-top:0} | |
| .ct-br{bottom:-1px;right:-1px;border-left:0;border-top:0} | |
| /* ---- top bar ---- */ | |
| #topbar{position:absolute;top:10px;left:50%;transform:translateX(-50%); | |
| display:flex;align-items:center;gap:22px;padding:8px 20px;pointer-events:auto} | |
| .stat{display:flex;flex-direction:column;align-items:center;line-height:1} | |
| .stat .lbl{font-size:9px;letter-spacing:1.5px;color:var(--txt-dim);text-transform:uppercase; | |
| font-family:'Orbitron',sans-serif;margin-bottom:3px} | |
| .stat .val{font-family:'Orbitron',sans-serif;font-weight:700;font-size:18px} | |
| #credit-val{color:var(--gold);min-width:62px;text-align:center;transition:transform .1s} | |
| #credit-val.flash{transform:scale(1.18)} | |
| #rate-val{font-size:11px;color:var(--green);font-weight:600} | |
| .sep{width:1px;height:30px;background:var(--line)} | |
| #power-wrap{display:flex;flex-direction:column;align-items:center;min-width:120px} | |
| #power-bar{position:relative;width:120px;height:11px;border-radius:6px;overflow:hidden; | |
| background:rgba(0,0,0,.5);border:1px solid var(--line);margin-top:2px} | |
| #power-fill{position:absolute;inset:0;width:100%;transform-origin:left; | |
| background:linear-gradient(90deg,#2db5ff,#36e0ff);transition:transform .35s ease} | |
| #power-bar.low #power-fill{background:linear-gradient(90deg,#ff5a6e,#ffb454); | |
| animation:pulseLow 1s infinite} | |
| @keyframes pulseLow{0%,100%{opacity:1}50%{opacity:.55}} | |
| #power-txt{font-family:'Orbitron',sans-serif;font-size:11px;font-weight:700;margin-top:3px} | |
| .accent{color:var(--accent)} .dim{color:var(--txt-dim)} | |
| /* ---- objectives ---- */ | |
| #objectives{position:absolute;top:12px;left:12px;width:236px;pointer-events:auto; | |
| padding:10px 12px 11px} | |
| #obj-head{display:flex;justify-content:space-between;align-items:center;cursor:pointer; | |
| font-family:'Orbitron',sans-serif;font-size:11px;letter-spacing:1.2px;color:var(--accent); | |
| text-transform:uppercase;margin-bottom:8px} | |
| #obj-count{background:rgba(54,224,255,.14);border:1px solid var(--line-bright); | |
| border-radius:10px;padding:1px 8px;font-size:11px;color:var(--txt);transition:transform .25s} | |
| #obj-count.bump{transform:scale(1.35)} | |
| #obj-list.collapsed{display:none} | |
| .obj{display:flex;align-items:center;gap:8px;padding:4px 0;font-size:13.5px;font-weight:500} | |
| .obj .box{flex:0 0 16px;width:16px;height:16px;border-radius:4px;border:1.5px solid var(--txt-dim); | |
| display:flex;align-items:center;justify-content:center;font-size:11px;color:var(--bg-void); | |
| transition:all .3s} | |
| .obj.done .box{background:var(--green);border-color:var(--green); | |
| box-shadow:0 0 9px rgba(92,255,172,.7)} | |
| .obj.done .txt{color:var(--green)} | |
| .obj .txt{flex:1;color:var(--txt)} | |
| .obj .prog{font-size:11px;color:var(--txt-dim);font-family:'Orbitron',sans-serif} | |
| /* ---- bottom bar ---- */ | |
| #bottom-bar{position:absolute;left:0;right:0;bottom:0;height:172px; | |
| display:flex;align-items:flex-end;justify-content:space-between; | |
| padding:0 12px 12px;pointer-events:none} | |
| #minimap-panel{position:relative;width:198px;height:198px;padding:5px;pointer-events:auto; | |
| flex:0 0 auto;margin-bottom:-26px} | |
| #minimap{width:188px;height:188px;border-radius:4px;display:block;background:#05070a;cursor:pointer} | |
| #minimap-label{position:absolute;top:-16px;left:2px;font-family:'Orbitron',sans-serif; | |
| font-size:9px;letter-spacing:1.5px;color:var(--txt-dim)} | |
| #selection-panel{flex:1 1 auto;max-width:520px;height:130px;margin:0 12px; | |
| pointer-events:auto;padding:10px 14px;display:flex;align-items:center;gap:14px;overflow:hidden} | |
| #sel-empty{color:var(--txt-dim);font-size:14px;text-align:center;width:100%;line-height:1.7} | |
| #sel-empty b{color:var(--accent)} | |
| #sel-portrait{flex:0 0 96px;width:96px;height:96px;border-radius:6px; | |
| background:radial-gradient(circle at 50% 38%,#1a2738,#0c121c); | |
| border:1px solid var(--line);position:relative;overflow:hidden} | |
| #sel-portrait canvas{position:absolute;inset:0;width:100%;height:100%} | |
| #sel-info{flex:1;min-width:0} | |
| #sel-name{font-family:'Orbitron',sans-serif;font-weight:700;font-size:17px;color:#fff} | |
| #sel-role{font-size:12px;color:var(--txt-dim);letter-spacing:.5px;margin-bottom:6px} | |
| .hpbar{height:9px;border-radius:5px;background:rgba(0,0,0,.5);border:1px solid var(--line); | |
| overflow:hidden;position:relative;margin:3px 0 4px} | |
| .hpbar>i{position:absolute;inset:0;transform-origin:left; | |
| background:linear-gradient(90deg,#3bd66b,#5cffac);transition:transform .2s} | |
| .sel-stats{display:flex;gap:14px;font-size:12.5px;color:var(--txt);flex-wrap:wrap} | |
| .sel-stats b{font-family:'Orbitron',sans-serif;color:var(--accent);font-weight:700} | |
| #sel-extra{font-size:12px;color:var(--gold);margin-top:5px;min-height:15px} | |
| #sel-multi{display:flex;flex-wrap:wrap;gap:5px;align-content:flex-start;max-height:108px;overflow:hidden;flex:1} | |
| .mini-unit{width:30px;height:30px;border-radius:5px;border:1px solid var(--line); | |
| background:#0f1826;position:relative;display:flex;align-items:flex-end} | |
| .mini-unit i{display:block;width:100%;height:3px;background:var(--green);border-radius:2px} | |
| .mini-unit span{position:absolute;top:2px;left:0;right:0;text-align:center;font-size:9px; | |
| font-family:'Orbitron',sans-serif;color:var(--txt)} | |
| #command-card{flex:0 0 268px;height:148px;pointer-events:auto;padding:9px 10px; | |
| display:flex;flex-direction:column;margin-bottom:-26px} | |
| #card-title{font-family:'Orbitron',sans-serif;font-size:10px;letter-spacing:1.4px; | |
| color:var(--txt-dim);text-transform:uppercase;margin-bottom:7px;display:flex; | |
| justify-content:space-between;align-items:center} | |
| #prod-queue{display:flex;gap:3px} | |
| #prod-queue .pip{width:12px;height:12px;border-radius:3px;background:rgba(255,255,255,.12); | |
| border:1px solid var(--line);position:relative} | |
| #prod-queue .pip.active{background:conic-gradient(var(--accent) calc(var(--p)*360deg),rgba(255,255,255,.1) 0)} | |
| #card-grid{display:grid;grid-template-columns:repeat(4,1fr);grid-auto-rows:54px;gap:6px;flex:1;align-content:start} | |
| .card-btn{position:relative;border:1px solid var(--panel-edge);border-radius:6px; | |
| background:linear-gradient(180deg,#1c2738,#121a27);cursor:pointer;overflow:hidden; | |
| display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1px; | |
| transition:border-color .15s,transform .08s;pointer-events:auto} | |
| .card-btn:hover{border-color:var(--line-bright)} | |
| .card-btn:active{transform:scale(.94)} | |
| .card-btn .ico{width:26px;height:26px} | |
| .card-btn .nm{font-size:9.5px;font-weight:600;line-height:1;color:var(--txt);text-align:center} | |
| .card-btn .cost{position:absolute;bottom:1px;right:3px;font-size:9px;font-family:'Orbitron',sans-serif;color:var(--gold)} | |
| .card-btn .hk{position:absolute;top:1px;left:3px;font-size:9px;font-family:'Orbitron',sans-serif; | |
| color:var(--accent);opacity:.8} | |
| .card-btn .stk{position:absolute;top:1px;right:3px;font-size:10px;font-family:'Orbitron',sans-serif; | |
| color:#fff;background:rgba(0,0,0,.5);border-radius:6px;padding:0 4px} | |
| .card-btn.disabled{opacity:.42;cursor:not-allowed} | |
| .card-btn.disabled .cost{color:var(--red)} | |
| .card-btn.locked{opacity:.35;cursor:not-allowed;filter:grayscale(.6)} | |
| .card-btn.locked::after{content:'🔒';position:absolute;font-size:15px;opacity:.7} | |
| .card-btn.building::before{content:'';position:absolute;left:0;bottom:0;width:100%; | |
| height:calc(var(--p)*100%);background:rgba(54,224,255,.18);transition:height .1s} | |
| .card-btn.ready{border-color:var(--green);animation:readyPulse 1.1s infinite} | |
| .card-btn.ready .nm{color:var(--green)} | |
| @keyframes readyPulse{0%,100%{box-shadow:0 0 0 rgba(92,255,172,0)} | |
| 50%{box-shadow:0 0 12px rgba(92,255,172,.65)}} | |
| .info-card{font-size:12px;color:var(--txt-dim);line-height:1.6;padding:4px 2px} | |
| .info-card b{color:var(--accent);font-family:'Orbitron',sans-serif} | |
| /* ---- help, toasts, tooltip, victory ---- */ | |
| #help-hint{position:absolute;bottom:14px;left:50%;transform:translateX(-50%); | |
| font-size:11px;color:var(--txt-dim);background:rgba(10,13,18,.7);padding:5px 14px; | |
| border-radius:14px;border:1px solid var(--line);pointer-events:none;letter-spacing:.4px; | |
| transition:opacity .5s} | |
| #help-hint b{color:var(--accent)} | |
| #toast-layer{position:absolute;top:64px;left:50%;transform:translateX(-50%); | |
| display:flex;flex-direction:column;align-items:center;gap:6px;pointer-events:none;z-index:15} | |
| .toast{font-family:'Orbitron',sans-serif;font-size:13px;font-weight:600;letter-spacing:.6px; | |
| background:linear-gradient(180deg,rgba(22,30,44,.96),rgba(14,20,30,.96)); | |
| border:1px solid var(--line-bright);border-radius:6px;padding:7px 18px;color:var(--txt); | |
| box-shadow:0 6px 20px rgba(0,0,0,.5);animation:toastIn .35s ease;white-space:nowrap} | |
| .toast.good{border-color:var(--green);color:var(--green);box-shadow:0 0 20px rgba(92,255,172,.3)} | |
| .toast.warn{border-color:var(--amber);color:var(--amber)} | |
| @keyframes toastIn{from{opacity:0;transform:translateY(-14px)}to{opacity:1;transform:none}} | |
| #tooltip{position:absolute;z-index:20;pointer-events:none;max-width:230px; | |
| background:rgba(10,14,20,.97);border:1px solid var(--line-bright);border-radius:6px; | |
| padding:7px 10px;font-size:12.5px;color:var(--txt);box-shadow:0 6px 18px rgba(0,0,0,.6); | |
| opacity:0;transition:opacity .12s;left:0;top:0;line-height:1.45} | |
| #tooltip.show{opacity:1} | |
| #tooltip .tt-name{font-family:'Orbitron',sans-serif;font-weight:700;color:#fff;font-size:13px} | |
| #tooltip .tt-cost{color:var(--gold);font-family:'Orbitron',sans-serif} | |
| #tooltip .tt-desc{color:var(--txt-dim);margin-top:3px;font-size:11.5px} | |
| #tooltip .tt-lock{color:var(--red);margin-top:3px;font-size:11.5px} | |
| #victory-banner{position:absolute;inset:0;z-index:30;display:none;align-items:center; | |
| justify-content:center;flex-direction:column;pointer-events:none; | |
| background:radial-gradient(circle at 50% 50%,rgba(255,206,92,.10),rgba(10,13,18,.78) 70%)} | |
| #victory-banner.show{display:flex;animation:fadeIn .6s ease} | |
| @keyframes fadeIn{from{opacity:0}to{opacity:1}} | |
| #victory-banner .vb-title{font-family:'Orbitron',sans-serif;font-weight:900;font-size:54px; | |
| letter-spacing:4px;color:var(--gold);text-shadow:0 0 30px rgba(255,206,92,.6);text-align:center} | |
| #victory-banner .vb-sub{font-size:18px;color:var(--txt);margin-top:10px;letter-spacing:1px} | |
| #paused-tag{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);z-index:16; | |
| font-family:'Orbitron',sans-serif;font-size:40px;font-weight:900;letter-spacing:6px; | |
| color:rgba(255,255,255,.85);display:none;text-shadow:0 0 20px #000} | |
| #paused-tag.show{display:block} | |
| </style> | |
| </head> | |
| <body> | |
| <div id="game-root"> | |
| <canvas id="world-canvas"></canvas> | |
| <div id="hud"> | |
| <div id="topbar" class="panel" data-notip> | |
| <span class="ct ct-tl corner-tick"></span><span class="ct ct-tr corner-tick"></span> | |
| <span class="ct ct-bl corner-tick"></span><span class="ct ct-br corner-tick"></span> | |
| <div class="stat"><span class="lbl">Credits</span><span class="val" id="credit-val">1500</span></div> | |
| <div class="stat"><span class="lbl">Income</span><span class="val" id="rate-val">+0/s</span></div> | |
| <div class="sep"></div> | |
| <div id="power-wrap"><span class="lbl" style="font-size:9px;letter-spacing:1.5px;color:var(--txt-dim);font-family:Orbitron">Power</span> | |
| <div id="power-bar"><div id="power-fill"></div></div> | |
| <span id="power-txt"><span class="accent">0</span>/<span class="dim">0</span></span></div> | |
| <div class="sep"></div> | |
| <div class="stat"><span class="lbl">Units</span><span class="val" id="units-val">0</span></div> | |
| <div class="stat"><span class="lbl">Bases</span><span class="val" id="bldg-val">0</span></div> | |
| <div class="stat"><span class="lbl">Mapped</span><span class="val" id="map-val">0%</span></div> | |
| <div class="sep"></div> | |
| <div class="stat"><span class="lbl">Time</span><span class="val" id="clock-val" style="font-size:15px">0:00</span></div> | |
| </div> | |
| <div id="objectives" class="panel"> | |
| <span class="ct ct-tl corner-tick"></span><span class="ct ct-br corner-tick"></span> | |
| <div id="obj-head"><span>◆ Objectives</span><span id="obj-count">0/5</span></div> | |
| <div id="obj-list"></div> | |
| </div> | |
| <div id="bottom-bar"> | |
| <div id="minimap-panel" class="panel"> | |
| <span class="ct ct-tl corner-tick"></span><span class="ct ct-tr corner-tick"></span> | |
| <span class="ct ct-bl corner-tick"></span><span class="ct ct-br corner-tick"></span> | |
| <div id="minimap-label">◈ TACTICAL MAP</div> | |
| <canvas id="minimap" width="188" height="188"></canvas> | |
| </div> | |
| <div id="selection-panel" class="panel"> | |
| <span class="ct ct-tl corner-tick"></span><span class="ct ct-tr corner-tick"></span> | |
| <span class="ct ct-bl corner-tick"></span><span class="ct ct-br corner-tick"></span> | |
| <div id="sel-empty">Select a unit or building.<br><b>Left-drag</b> to box-select · <b>Right-click</b> to command<br><b>WASD</b>/edges pan · <b>wheel</b> zoom · <b>Space</b> pause</div> | |
| <div id="sel-portrait" style="display:none"><canvas id="portrait-cv" width="96" height="96"></canvas></div> | |
| <div id="sel-info" style="display:none"> | |
| <div id="sel-name">—</div><div id="sel-role">—</div> | |
| <div class="hpbar"><i id="sel-hp"></i></div> | |
| <div class="sel-stats" id="sel-stats"></div> | |
| <div id="sel-extra"></div> | |
| </div> | |
| <div id="sel-multi" style="display:none"></div> | |
| </div> | |
| <div id="command-card" class="panel"> | |
| <span class="ct ct-tl corner-tick"></span><span class="ct ct-tr corner-tick"></span> | |
| <span class="ct ct-bl corner-tick"></span><span class="ct ct-br corner-tick"></span> | |
| <div id="card-title"><span id="card-label">COMMAND</span><div id="prod-queue"></div></div> | |
| <div id="card-grid"></div> | |
| </div> | |
| </div> | |
| <div id="help-hint"><b>Click</b> a Power Plant in the command card to start building your base.</div> | |
| <div id="toast-layer"></div> | |
| </div> | |
| <div id="paused-tag">PAUSED</div> | |
| <div id="tooltip"></div> | |
| <div id="victory-banner"> | |
| <div class="vb-title">FRONTIER SECURED</div> | |
| <div class="vb-sub" id="vb-sub"></div> | |
| </div> | |
| </div> | |
| <script> | |
| "use strict"; | |
| /* ==================================================================== | |
| FRONTIER FOUNDRY — single-file canvas RTS | |
| ==================================================================== */ | |
| /* ===== S0 CONSTANTS & CONFIG ===================================== */ | |
| const TILE=32, MAP_W=96, MAP_H=96; | |
| const WORLD_W=MAP_W*TILE, WORLD_H=MAP_H*TILE, N_TILES=MAP_W*MAP_H; | |
| const SEED=0xC0FFEE; | |
| const ZOOM_MIN=0.55, ZOOM_MAX=2.0, ZOOM_DEFAULT=1.0, ZOOM_STEP=1.12; | |
| const SIM_DT=1/60, MAX_FRAME=0.25, MAX_CATCHUP=5; | |
| const EDGE_PAN=26, PAN_SPEED=950; | |
| const T_GRASS=0,T_ROCK=1,T_WATER=2,T_CRYSTAL=3; | |
| const PASSABLE=[true,false,false,false]; | |
| const BUILDABLE=[true,false,false,false]; | |
| const START_CREDITS=1500; | |
| const CRYSTAL_PER_TILE=600; | |
| const HARV_BITE=25, HARV_BITE_TIME=0.45; | |
| const HARV_DUMP=75, HARV_DUMP_TIME=0.5; | |
| const HARV_CARGO=150; | |
| const QUEUE_MAX=5, BUILD_RADIUS=6; | |
| const VIS_INTERVAL=1/6, FADE_PER_SEC=255/0.4, MEMORY_DIM=0.62, VIS_THRESHOLD=32; | |
| const FOG_R=0x0a,FOG_G=0x0d,FOG_B=0x12; | |
| const SQRT2=1.41421356; | |
| const MAX_EXPANSIONS=5000, PATHS_PER_FRAME=6; | |
| const ARRIVE_WP=7, ARRIVE_GOAL=4, ARRIVE_DOCK=TILE*1.6; | |
| const ACCEL=16, TURN_RATE=13, SEP_STRENGTH=0.55; | |
| const STUCK_TIME=0.8, STUCK_EPS=2.0; | |
| const MAXP=700; | |
| const P_CHIP=0,P_DUST=1,P_SMOKE=2,P_SPARK=3,P_POOF=4,P_DEATH=5,P_CONFETTI=6; | |
| const CRITTER_COUNT=9, CRITTER_HP=30, CRITTER_RESPAWN=16; | |
| const OBJ_CREDITS=5000, OBJ_EXPLORE=0.80, OBJ_TANKS=3; | |
| const BLDG_DEFS={ | |
| cc: {name:'Command Center',cost:0, time:0, hp:1500,fp:[3,3],power:0, sight:9,dock:true, produces:true}, | |
| power: {name:'Power Plant', cost:300, time:6, hp:500, fp:[2,2],power:+100,sight:5}, | |
| refinery: {name:'Refinery', cost:1500,time:13,hp:900, fp:[3,3],power:-40, sight:6,dock:true, grantsHarvester:true}, | |
| barracks: {name:'Barracks', cost:400, time:8, hp:600, fp:[2,2],power:-20, sight:5,produces:true}, | |
| warfactory:{name:'War Factory', cost:800, time:14,hp:800, fp:[3,3],power:-50, sight:6,produces:true,req:'barracks'}, | |
| watchtower:{name:'Watchtower', cost:200, time:5, hp:400, fp:[1,1],power:-10, sight:11}, | |
| }; | |
| const UNIT_DEFS={ | |
| harvester:{name:'Harvester', cost:700,time:8, hp:600,speed:2.0,sight:4,r:13,cargoMax:150}, | |
| rifleman: {name:'Rifleman', cost:100,time:4, hp:100,speed:3.0,sight:5,r:9, atk:8, range:3.5,rof:0.7}, | |
| scout: {name:'Scout', cost:150,time:5, hp:80, speed:5.0,sight:9,r:9, atk:4, range:3, rof:0.5}, | |
| rocket: {name:'Rocket Soldier',cost:250,time:7, hp:140,speed:2.5,sight:5,r:10,atk:20,range:5, rof:1.4}, | |
| tank: {name:'Battle Tank', cost:600,time:12,hp:700,speed:2.2,sight:6,r:15,atk:30,range:6, rof:1.2}, | |
| }; | |
| const CRITTER_DEF={name:'Critter',hp:CRITTER_HP,speed:1.3,sight:0,r:8,atk:0,range:0}; | |
| /* palettes */ | |
| const PAL={ | |
| grassLo:'#516e38',grassHi:'#74994d',dirtLo:'#5f4d31',dirtHi:'#856b45', | |
| rockLo:'#3a3f47',rockHi:'#5a626e',rockEdge:'#23262c',rockSpec:'#737c8a', | |
| waterLo:'#123b44',waterHi:'#1f5d68',waterFoam:'#49949b', | |
| crystalCore:'#39e0e6',crystalLit:'#bff8fa',crystalDark:'#1b8d98',crystalEdge:'#0c5460', | |
| }; | |
| const TEAM={blue:'#3d7bff',blueLit:'#85adff',blueDark:'#1f3f8c',outline:'#0a1124'}; | |
| const BLD={ | |
| cc:{base:'#4a5a78',lit:'#7286ab',dark:'#2a3346',accent:'#ffcf5c'}, | |
| power:{base:'#5a6470',lit:'#828d9c',dark:'#323843',accent:'#ffd23f'}, | |
| refinery:{base:'#4f5b6b',lit:'#788aa0',dark:'#2c3640',accent:'#46f0f0'}, | |
| barracks:{base:'#6a5a44',lit:'#94805f',dark:'#3b3224',accent:'#d05a3c'}, | |
| warfactory:{base:'#525a64',lit:'#7a8492',dark:'#2d323a',accent:'#ff8a3c'}, | |
| watchtower:{base:'#5c6573',lit:'#8893a4',dark:'#343a44',accent:'#46f0f0'}, | |
| }; | |
| const UNIT={ | |
| harvester:{hull:'#c9a24a',hullLit:'#f0cd76',dark:'#6e561f',cargo:'#46f0f0'}, | |
| rifleman:{body:TEAM.blue,lit:TEAM.blueLit,dark:TEAM.blueDark}, | |
| scout:{body:'#37b6c4',lit:'#7eeaf2',dark:'#1c5d66'}, | |
| rocket:{body:'#8a6cff',lit:'#b9a8ff',dark:'#3a2c80'}, | |
| tank:{body:'#3d7bff',lit:'#85adff',dark:'#1f3f8c',tread:'#1c2029'}, | |
| critter:{body:'#b07a52',lit:'#d6a87f',dark:'#5e3f28'}, | |
| }; | |
| const FX={selValid:'#5dff8a',hpHigh:'#5dff7a',hpMid:'#ffd23f',hpLow:'#ff4d42', | |
| movePing:'rgba(120,255,160,0.95)',gatherPing:'rgba(70,240,240,0.95)',attackPing:'rgba(255,93,82,0.95)', | |
| creditPlus:'#7bff9b'}; | |
| /* ===== S1 UTIL / MATH =========================================== */ | |
| const clamp=(v,a,b)=>v<a?a:v>b?b:v; | |
| const lerp=(a,b,t)=>a+(b-a)*t; | |
| function lerpAngle(a,b,t){let d=((b-a+Math.PI)%(2*Math.PI))-Math.PI;if(d<-Math.PI)d+=2*Math.PI;return a+d*t;} | |
| const dist2=(ax,ay,bx,by)=>{const dx=ax-bx,dy=ay-by;return dx*dx+dy*dy;}; | |
| function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296;};} | |
| function fmtClock(s){s=Math.floor(s);const m=Math.floor(s/60);const ss=(s%60).toString().padStart(2,'0');return m+':'+ss;} | |
| function hexToRgb(h){h=h[0]==='#'?h.slice(1):h;return[parseInt(h.slice(0,2),16),parseInt(h.slice(2,4),16),parseInt(h.slice(4,6),16)];} | |
| function shade(hex,f){const c=hexToRgb(hex);return'rgb('+clamp(c[0]*f|0,0,255)+','+clamp(c[1]*f|0,0,255)+','+clamp(c[2]*f|0,0,255)+')';} | |
| function lerpColor(a,b,t){const ca=hexToRgb(a),cb=hexToRgb(b);return'rgb('+((ca[0]+(cb[0]-ca[0])*t)|0)+','+((ca[1]+(cb[1]-ca[1])*t)|0)+','+((ca[2]+(cb[2]-ca[2])*t)|0)+')';} | |
| /* ===== S2 COORD TRANSFORMS ====================================== */ | |
| const tileIdx=(tx,ty)=>ty*MAP_W+tx; | |
| const inBounds=(tx,ty)=>tx>=0&&ty>=0&&tx<MAP_W&&ty<MAP_H; | |
| const tileCenter=(t)=>t*TILE+TILE/2; | |
| function worldToScreen(wx,wy){const z=G.cam.zoom;return{x:(wx-G.cam.x)*z,y:(wy-G.cam.y)*z};} | |
| function screenToWorld(sx,sy){const z=G.cam.zoom;return{x:sx/z+G.cam.x,y:sy/z+G.cam.y};} | |
| function screenToTile(sx,sy){const w=screenToWorld(sx,sy);return{tx:(w.x/TILE)|0,ty:(w.y/TILE)|0,wx:w.x,wy:w.y};} | |
| function centerCameraOn(wx,wy){const c=G.cam;c.tx=wx-(c.vw/c.zoom)/2;c.ty=wy-(c.vh/c.zoom)/2;clampCamTarget();c.x=c.tx;c.y=c.ty;c.moved=true;} | |
| function viewportTileRect(){const c=G.cam;return{ | |
| x0:Math.max(0,(c.x/TILE|0)-1),y0:Math.max(0,(c.y/TILE|0)-1), | |
| x1:Math.min(MAP_W-1,((c.x+c.vw/c.zoom)/TILE|0)+1),y1:Math.min(MAP_H-1,((c.y+c.vh/c.zoom)/TILE|0)+1)};} | |
| /* ===== S3 GLOBAL STATE (G) ====================================== */ | |
| const G={ | |
| time:0,frame:0,sessionSec:0,paused:false, | |
| terrain:null,occ:null,crystalAmt:null,crystalFieldId:null,passGrid:null,passVersion:0, | |
| noise:null,patchNoise:null,crystalTiles:[], | |
| units:[],buildings:[],projectiles:[],nextId:1,idMap:new Map(), | |
| credits:START_CREDITS,creditsDisplay:START_CREDITS,totalMined:0,maxCredits:START_CREDITS, | |
| incomeRate:0,incomeAccum:0,incomeWindow:0, | |
| powerSupply:0,powerDemand:0,powerRatio:1,throttle:1, | |
| hasBarracks:false,hasWarFactory:false,builtTypes:new Set(), | |
| selection:[],selKind:'none', | |
| controlGroups:[null,[],[],[],[],[],[],[],[],[]],lastRecall:{g:-1,t:-1}, | |
| buildSlots:{},placing:null,buildAura:null, | |
| cam:{x:0,y:0,tx:0,ty:0,zoom:1,tzoom:1,vw:0,vh:0,moved:true}, | |
| input:{mode:'default',mx:0,my:0,wx:0,wy:0,lDown:false,mDown:false, | |
| downSX:0,downSY:0,dragging:false,boxX0:0,boxY0:0, | |
| panSX:0,panSY:0,panCamX:0,panCamY:0,shift:false,ctrl:false, | |
| keys:Object.create(null),attackArmed:false,hoverEnt:null}, | |
| fog:null,particles:null,floaters:null,decals:null, | |
| objectives:[],objDoneCount:0,won:false,wonAt:0, | |
| }; | |
| /* ===== S4 TERRAIN GEN =========================================== */ | |
| let START_TX=46, START_TY=46; | |
| function smoothField(rng,passes){ | |
| let a=new Float32Array(N_TILES); | |
| for(let i=0;i<N_TILES;i++)a[i]=rng(); | |
| for(let p=0;p<passes;p++){ | |
| const b=new Float32Array(N_TILES); | |
| for(let y=0;y<MAP_H;y++)for(let x=0;x<MAP_W;x++){ | |
| let s=0,n=0; | |
| for(let dy=-1;dy<=1;dy++)for(let dx=-1;dx<=1;dx++){ | |
| const xx=x+dx,yy=y+dy;if(xx<0||yy<0||xx>=MAP_W||yy>=MAP_H)continue; | |
| s+=a[yy*MAP_W+xx];n++; | |
| } | |
| b[y*MAP_W+x]=s/n; | |
| } | |
| a=b; | |
| } | |
| let mn=Infinity,mx=-Infinity; | |
| for(let i=0;i<N_TILES;i++){if(a[i]<mn)mn=a[i];if(a[i]>mx)mx=a[i];} | |
| const r=mx-mn||1; | |
| for(let i=0;i<N_TILES;i++)a[i]=(a[i]-mn)/r; | |
| return a; | |
| } | |
| function stampCrystalField(cx,cy,size,rng,fieldId){ | |
| // clear a grass ring first so harvesters can stand adjacent | |
| for(let dy=-3;dy<=3;dy++)for(let dx=-3;dx<=3;dx++){ | |
| const tx=cx+dx,ty=cy+dy;if(!inBounds(tx,ty))continue; | |
| const i=tileIdx(tx,ty);if(G.terrain[i]===T_ROCK||G.terrain[i]===T_WATER){G.terrain[i]=T_GRASS;} | |
| } | |
| let placed=0,ring=0; | |
| const cand=[]; | |
| for(let dy=-2;dy<=2;dy++)for(let dx=-2;dx<=2;dx++){ | |
| if(dx*dx+dy*dy<=4.5)cand.push([dx,dy,dx*dx+dy*dy]); | |
| } | |
| cand.sort((a,b)=>a[2]-b[2]+(rng()-0.5)*0.6); | |
| for(const c of cand){ | |
| if(placed>=size)break; | |
| const tx=cx+c[0],ty=cy+c[1]; | |
| if(tx<2||ty<2||tx>=MAP_W-2||ty>=MAP_H-2)continue; | |
| const i=tileIdx(tx,ty); | |
| G.terrain[i]=T_CRYSTAL;G.crystalAmt[i]=CRYSTAL_PER_TILE;G.crystalFieldId[i]=fieldId; | |
| G.crystalTiles.push({tx,ty,fieldId,i});placed++; | |
| } | |
| } | |
| function generateTerrain(){ | |
| const rng=mulberry32(SEED); | |
| G.terrain=new Uint8Array(N_TILES); | |
| G.occ=new Int32Array(N_TILES); | |
| G.crystalAmt=new Uint16Array(N_TILES); | |
| G.crystalFieldId=new Uint8Array(N_TILES); | |
| G.passGrid=new Uint8Array(N_TILES); | |
| G.crystalTiles=[]; | |
| const elev=smoothField(rng,3); | |
| G.patchNoise=smoothField(rng,4); | |
| G.noise=smoothField(rng,0); | |
| // base biome by elevation | |
| for(let i=0;i<N_TILES;i++){ | |
| const e=elev[i]; | |
| if(e<0.27)G.terrain[i]=T_WATER; | |
| else if(e>0.73)G.terrain[i]=T_ROCK; | |
| else G.terrain[i]=T_GRASS; | |
| } | |
| // clear a buildable clearing around start | |
| for(let dy=-6;dy<=6;dy++)for(let dx=-6;dx<=6;dx++){ | |
| if(dx*dx+dy*dy>52)continue; | |
| const tx=START_TX+1+dx,ty=START_TY+1+dy;if(!inBounds(tx,ty))continue; | |
| G.terrain[tileIdx(tx,ty)]=T_GRASS; | |
| } | |
| // crystal fields (home + 3 spread out, deterministic) | |
| stampCrystalField(START_TX+8,START_TY,12,rng,0); // home field | |
| stampCrystalField(20,22,14,rng,1); | |
| stampCrystalField(74,28,12,rng,2); | |
| stampCrystalField(26,74,13,rng,3); | |
| stampCrystalField(72,72,15,rng,4); | |
| // border ring -> rock + blocked | |
| for(let x=0;x<MAP_W;x++){G.terrain[tileIdx(x,0)]=T_ROCK;G.terrain[tileIdx(x,MAP_H-1)]=T_ROCK;} | |
| for(let y=0;y<MAP_H;y++){G.terrain[tileIdx(0,y)]=T_ROCK;G.terrain[tileIdx(MAP_W-1,y)]=T_ROCK;} | |
| // build passGrid | |
| for(let i=0;i<N_TILES;i++)G.passGrid[i]=PASSABLE[G.terrain[i]]?0:1; | |
| rebuildCrystalTiles(); | |
| } | |
| function rebuildCrystalTiles(){ | |
| G.crystalTiles=[]; | |
| for(let i=0;i<N_TILES;i++)if(G.terrain[i]===T_CRYSTAL&&G.crystalAmt[i]>0){ | |
| G.crystalTiles.push({tx:i%MAP_W,ty:(i/MAP_W)|0,fieldId:G.crystalFieldId[i],i}); | |
| } | |
| } | |
| function markPassDirty(){G.passVersion++;} | |
| function depleteCrystal(i){ | |
| G.terrain[i]=T_GRASS;G.crystalAmt[i]=0;G.passGrid[i]=0;markPassDirty(); | |
| repaintTile(i%MAP_W,(i/MAP_W)|0); | |
| rebuildCrystalTiles(); | |
| } | |
| /* ===== S5 ENTITY FACTORIES ====================================== */ | |
| function makeUnit(type,wx,wy,team){ | |
| const d=UNIT_DEFS[type]||CRITTER_DEF; | |
| const u={id:G.nextId++,kind:'unit',type,team, | |
| x:wx,y:wy,px:wx,py:wy,vx:0,vy:0,facing:Math.random()*6.28,aimFacing:0,r:d.r, | |
| hp:d.hp,hpMax:d.hp,speed:d.speed*TILE,sight:d.sight,atk:d.atk||0, | |
| range:(d.range||0)*TILE,rof:d.rof||1, | |
| order:'idle',moveState:'idle',targetId:0, | |
| waypoints:null,wpIndex:0,goalTile:-1,exactGoal:false,exX:0,exY:0, | |
| pathVersion:-1,pendingGoal:null,_inQueue:false,stuckT:0,stuckCount:0,lastX:wx,lastY:wy, | |
| tileX:(wx/TILE)|0,tileY:(wy/TILE)|0, | |
| cargo:0,cargoMax:d.cargoMax||0,harvState:null,harvTile:-1,harvTimer:0,dockId:0,rallyFieldId:-1, | |
| atkCooldown:0,bob:Math.random()*6.28,wanderT:0, | |
| spawnT:0,selT:0,hpFlash:0,dead:false}; | |
| return u; | |
| } | |
| function makeBuilding(type,tx,ty,built){ | |
| const d=BLDG_DEFS[type];const[fw,fh]=d.fp; | |
| const b={id:G.nextId++,kind:'building',type, | |
| tx,ty,fw,fh,x:tx*TILE+fw*TILE/2,y:ty*TILE+fh*TILE/2, | |
| hp:d.hp,hpMax:d.hp,sight:d.sight,power:d.power, | |
| tileX:tx+(fw>>1),tileY:ty+(fh>>1), | |
| built:!!built,buildT:built?1:0,buildTime:d.time, | |
| queue:[],prodT:0,rally:null,canProduce:!!d.produces, | |
| isDock:!!d.dock, | |
| placeT:0,glow:0,readyPulse:0,hpFlash:0,selT:0,smokeT:0,blinkT:0,sweepT:0,dead:false}; | |
| return b; | |
| } | |
| function stampBuilding(b,blocked){ | |
| for(let ty=b.ty;ty<b.ty+b.fh;ty++)for(let tx=b.tx;tx<b.tx+b.fw;tx++){ | |
| if(!inBounds(tx,ty))continue;const i=tileIdx(tx,ty); | |
| G.passGrid[i]=blocked?1:0;G.occ[i]=blocked?b.id:0; | |
| } | |
| markPassDirty(); | |
| } | |
| function addUnit(u){G.units.push(u);G.idMap.set(u.id,u);G.fog&&(G.fog.dirty=true);return u;} | |
| function addBuilding(b){G.buildings.push(b);G.idMap.set(b.id,b);stampBuilding(b,true); | |
| if(b.built){recomputePower();recomputeTech();G.fog&&(G.fog.dirty=true);}return b;} | |
| function recomputePower(){ | |
| let sup=0,dem=0; | |
| for(const b of G.buildings){if(b.dead||!b.built)continue; | |
| if(b.power>0)sup+=b.power;else dem+=-b.power;} | |
| G.powerSupply=sup;G.powerDemand=dem; | |
| G.powerRatio=dem>0?clamp(sup/dem,0,1):1; | |
| G.throttle=0.35+0.65*G.powerRatio; | |
| } | |
| function recomputeTech(){ | |
| G.builtTypes.clear();G.hasBarracks=false;G.hasWarFactory=false; | |
| for(const b of G.buildings){if(b.dead||!b.built)continue; | |
| G.builtTypes.add(b.type); | |
| if(b.type==='barracks')G.hasBarracks=true; | |
| if(b.type==='warfactory')G.hasWarFactory=true;} | |
| } | |
| /* ===== S6 CAMERA ================================================ */ | |
| function clampCamTarget(){const c=G.cam; | |
| const maxX=Math.max(0,WORLD_W-c.vw/c.zoom),maxY=Math.max(0,WORLD_H-c.vh/c.zoom); | |
| c.tx=clamp(c.tx,0,maxX);c.ty=clamp(c.ty,0,maxY);} | |
| function clampCam(){const c=G.cam; | |
| const maxX=Math.max(0,WORLD_W-c.vw/c.zoom),maxY=Math.max(0,WORLD_H-c.vh/c.zoom); | |
| c.x=clamp(c.x,0,maxX);c.y=clamp(c.y,0,maxY);} | |
| function updateCamera(dt){ | |
| const c=G.cam,inp=G.input; | |
| // zoom (target set in wheel handler via c.tzoom). preserve world point under cursor handled there. | |
| const ze=1-Math.exp(-16*dt); | |
| c.zoom+=(c.tzoom-c.zoom)*ze; | |
| if(Math.abs(c.tzoom-c.zoom)<0.0005)c.zoom=c.tzoom; | |
| // keyboard + edge pan (skip while middle-dragging) | |
| if(!inp.mDown){ | |
| let dx=0,dy=0;const k=inp.keys; | |
| // A=attack-move and S=stop are unit commands when units selected; don't pan with them then. | |
| const usel=G.selKind==='units'; | |
| if(k['KeyW']||k['ArrowUp'])dy-=1; | |
| if((k['KeyS']&&!usel)||k['ArrowDown'])dy+=1; | |
| if((k['KeyA']&&!usel)||k['ArrowLeft'])dx-=1; | |
| if(k['KeyD']||k['ArrowRight'])dx+=1; | |
| // edge pan (only when window focused & mouse inside) | |
| if(inp.insideCanvas){ | |
| if(inp.mx<EDGE_PAN)dx-=1;else if(inp.mx>c.vw-EDGE_PAN)dx+=1; | |
| if(inp.my<EDGE_PAN)dy-=1;else if(inp.my>c.vh-EDGE_PAN)dy+=1; | |
| } | |
| if(dx||dy){const sp=PAN_SPEED*dt/c.zoom;c.tx+=dx*sp;c.ty+=dy*sp;} | |
| } else { | |
| // middle-drag: 1:1 | |
| c.tx=c.panCamX+(inp.panSX-inp.mx)/c.zoom; | |
| c.ty=c.panCamY+(inp.panSY-inp.my)/c.zoom; | |
| c.x=c.tx;c.y=c.ty; | |
| } | |
| clampCamTarget(); | |
| // ease toward target | |
| const s=1-Math.exp(-18*dt); | |
| const ox=c.x,oy=c.y; | |
| c.x+=(c.tx-c.x)*s;c.y+=(c.ty-c.y)*s; | |
| if(Math.abs(c.tx-c.x)<0.05)c.x=c.tx; | |
| if(Math.abs(c.ty-c.y)<0.05)c.y=c.ty; | |
| clampCam(); | |
| if(ox!==c.x||oy!==c.y||c.zoom!==c._lz){c.moved=true;c._lz=c.zoom;} | |
| } | |
| /* ===== S7 PATHING =============================================== */ | |
| const NX=[1,-1,0,0,1,1,-1,-1],NY=[0,0,1,-1,1,-1,1,-1],NC=[1,1,1,1,SQRT2,SQRT2,SQRT2,SQRT2]; | |
| const P={gScore:new Float32Array(N_TILES),fScore:new Float32Array(N_TILES), | |
| cameFrom:new Int32Array(N_TILES),seenStamp:new Int32Array(N_TILES), | |
| closedStamp:new Int32Array(N_TILES),heap:new Int32Array(N_TILES+1),heapLen:0,searchId:0,queue:[]}; | |
| function octile(x0,y0,x1,y1){const dx=Math.abs(x0-x1),dy=Math.abs(y0-y1);return(dx+dy)+(SQRT2-2)*Math.min(dx,dy);} | |
| function heapPush(idx){const h=P.heap,f=P.fScore;let i=P.heapLen++;h[i]=idx; | |
| while(i>0){const p=(i-1)>>1;if(f[h[p]]<=f[h[i]])break;const t=h[p];h[p]=h[i];h[i]=t;i=p;}} | |
| function heapPop(){const h=P.heap,f=P.fScore;const top=h[0];const n=--P.heapLen;h[0]=h[n]; | |
| let i=0;while(true){let l=i*2+1,r=l+1,s=i; | |
| if(l<n&&f[h[l]]<f[h[s]])s=l;if(r<n&&f[h[r]]<f[h[s]])s=r;if(s===i)break; | |
| const t=h[s];h[s]=h[i];h[i]=t;i=s;}return top;} | |
| function nearestPassableTile(tx,ty){ | |
| if(inBounds(tx,ty)&&!G.passGrid[tileIdx(tx,ty)])return{tx,ty}; | |
| for(let r=1;r<14;r++){ | |
| for(let dy=-r;dy<=r;dy++)for(let dx=-r;dx<=r;dx++){ | |
| if(Math.max(Math.abs(dx),Math.abs(dy))!==r)continue; | |
| const nx=tx+dx,ny=ty+dy; | |
| if(inBounds(nx,ny)&&!G.passGrid[tileIdx(nx,ny)])return{tx:nx,ty:ny}; | |
| } | |
| } | |
| return null; | |
| } | |
| function reconstruct(node){ | |
| const out=[];let c=node; | |
| while(c!==-1){out.push(c);c=P.cameFrom[c];} | |
| out.reverse();return out; | |
| } | |
| function aStar(sx,sy,gx,gy){ | |
| if(!inBounds(sx,sy)||!inBounds(gx,gy))return null; | |
| if(G.passGrid[tileIdx(gx,gy)]){const n=nearestPassableTile(gx,gy);if(!n)return null;gx=n.tx;gy=n.ty;} | |
| let start=tileIdx(sx,sy),goal=tileIdx(gx,gy); | |
| if(G.passGrid[start]){const n=nearestPassableTile(sx,sy);if(!n)return null;sx=n.tx;sy=n.ty;start=tileIdx(sx,sy);} | |
| if(start===goal)return[start]; | |
| const sid=++P.searchId;P.heapLen=0; | |
| P.gScore[start]=0;P.seenStamp[start]=sid;P.cameFrom[start]=-1; | |
| P.fScore[start]=octile(sx,sy,gx,gy);heapPush(start); | |
| let exp=0,best=start,bestH=P.fScore[start]; | |
| while(P.heapLen>0){ | |
| const cur=heapPop(); | |
| if(P.closedStamp[cur]===sid)continue; | |
| P.closedStamp[cur]=sid; | |
| if(cur===goal)return reconstruct(cur); | |
| if(++exp>MAX_EXPANSIONS)break; | |
| const cx=cur%MAP_W,cy=(cur/MAP_W)|0; | |
| const h0=octile(cx,cy,gx,gy);if(h0<bestH){bestH=h0;best=cur;} | |
| for(let dir=0;dir<8;dir++){ | |
| const nx=cx+NX[dir],ny=cy+NY[dir]; | |
| if(nx<0||ny<0||nx>=MAP_W||ny>=MAP_H)continue; | |
| const ni=ny*MAP_W+nx; | |
| if(G.passGrid[ni])continue; | |
| if(dir>=4&&(G.passGrid[cy*MAP_W+nx]||G.passGrid[ny*MAP_W+cx]))continue; | |
| const ng=P.gScore[cur]+NC[dir]; | |
| if(P.seenStamp[ni]!==sid||ng<P.gScore[ni]){ | |
| P.seenStamp[ni]=sid;P.gScore[ni]=ng;P.cameFrom[ni]=cur; | |
| P.fScore[ni]=ng+octile(nx,ny,gx,gy); | |
| if(P.closedStamp[ni]!==sid)heapPush(ni); | |
| } | |
| } | |
| } | |
| return reconstruct(best); | |
| } | |
| function tileLOS(x0,y0,x1,y1){ | |
| if(G.passGrid[tileIdx(x0,y0)])return false; | |
| let dx=Math.abs(x1-x0),dy=Math.abs(y1-y0); | |
| let x=x0,y=y0,sx=x1>x0?1:-1,sy=y1>y0?1:-1,err=dx-dy; | |
| let guard=0; | |
| while((x!==x1||y!==y1)&&guard++<512){ | |
| const e2=2*err;let mx=false,my=false; | |
| if(e2>-dy){err-=dy;x+=sx;mx=true;} | |
| if(e2<dx){err+=dx;y+=sy;my=true;} | |
| if(mx&&my){if(G.passGrid[tileIdx(x-sx,y)]||G.passGrid[tileIdx(x,y-sy)])return false;} | |
| if(G.passGrid[tileIdx(x,y)])return false; | |
| } | |
| return true; | |
| } | |
| function buildWaypoints(tp){ | |
| if(!tp||tp.length===0)return null; | |
| if(tp.length===1){const i=tp[0];return[{x:tileCenter(i%MAP_W),y:tileCenter((i/MAP_W)|0)}];} | |
| const pts=tp.map(i=>({tx:i%MAP_W,ty:(i/MAP_W)|0})); | |
| const res=[pts[0]];let a=0; | |
| for(let b=2;b<pts.length;b++){ | |
| if(!tileLOS(pts[a].tx,pts[a].ty,pts[b].tx,pts[b].ty)){res.push(pts[b-1]);a=b-1;} | |
| } | |
| res.push(pts[pts.length-1]); | |
| return res.map(p=>({x:tileCenter(p.tx),y:tileCenter(p.ty)})); | |
| } | |
| function requestPath(u,gx,gy,opts){ | |
| opts=opts||{}; | |
| u.pendingGoal={gx,gy,exact:!!opts.exact,ex:opts.ex||0,ey:opts.ey||0}; | |
| u.goalTile=tileIdx(clamp(gx,0,MAP_W-1),clamp(gy,0,MAP_H-1)); | |
| u.moveState='pending'; | |
| if(!u._inQueue){u._inQueue=true;P.queue.push(u);} | |
| } | |
| function servicePathQueue(){ | |
| let budget=PATHS_PER_FRAME; | |
| while(budget>0&&P.queue.length){ | |
| const u=P.queue.shift();u._inQueue=false; | |
| if(u.dead||!u.pendingGoal)continue; | |
| const sx=clamp((u.x/TILE)|0,0,MAP_W-1),sy=clamp((u.y/TILE)|0,0,MAP_H-1); | |
| const pg=u.pendingGoal; | |
| const tp=aStar(sx,sy,pg.gx,pg.gy); | |
| u.waypoints=buildWaypoints(tp); | |
| if(u.waypoints&&u.waypoints.length){ | |
| if(pg.exact)u.waypoints[u.waypoints.length-1]={x:pg.ex,y:pg.ey}; | |
| u.wpIndex=0;u.pathVersion=G.passVersion;u.moveState='moving'; | |
| } else {u.moveState='idle';onArrive(u);} | |
| u.pendingGoal=null;budget--; | |
| } | |
| } | |
| function repathSameGoal(u){ | |
| if(u.goalTile<0)return; | |
| requestPath(u,u.goalTile%MAP_W,(u.goalTile/MAP_W)|0,{exact:u.exactGoal,ex:u.exX,ey:u.exY}); | |
| } | |
| function issueMove(u,wx,wy,order){ | |
| const gx=clamp((wx/TILE)|0,0,MAP_W-1),gy=clamp((wy/TILE)|0,0,MAP_H-1); | |
| const n=nearestPassableTile(gx,gy); | |
| const tx=n?n.tx:gx,ty=n?n.ty:gy; | |
| u.order=order||'move'; | |
| u.exactGoal=true;u.exX=clamp(wx,TILE,WORLD_W-TILE);u.exY=clamp(wy,TILE,WORLD_H-TILE); | |
| if(u.type==='harvester'&&order==='move'){u.harvState=null;} | |
| requestPath(u,tx,ty,{exact:true,ex:u.exX,ey:u.exY}); | |
| } | |
| function formationSlots(n){ | |
| const slots=[{x:0,y:0}];let ring=1; | |
| while(slots.length<n){ | |
| const count=ring*6;const rad=ring*TILE*1.25; | |
| for(let k=0;k<count&&slots.length<n;k++){ | |
| const a=(k/count)*Math.PI*2;slots.push({x:Math.cos(a)*rad,y:Math.sin(a)*rad}); | |
| } | |
| ring++; | |
| } | |
| return slots; | |
| } | |
| function issueGroupMove(units,wx,wy,order){ | |
| const list=units.filter(u=>!u.dead); | |
| if(list.length<=1){for(const u of list)issueMove(u,wx,wy,order);return;} | |
| const slots=formationSlots(list.length); | |
| const sorted=list.slice().sort((a,b)=>dist2(a.x,a.y,wx,wy)-dist2(b.x,b.y,wx,wy)); | |
| for(let i=0;i<sorted.length;i++){ | |
| const sx=wx+slots[i].x,sy=wy+slots[i].y; | |
| issueMove(sorted[i],sx,sy,order); | |
| } | |
| } | |
| function stepMovement(dt){ | |
| servicePathQueue(); | |
| for(const u of G.units)if(!u.dead)integrateUnit(u,dt); | |
| separateUnits(); | |
| for(const u of G.units)if(!u.dead)resolveBlockedTiles(u); | |
| updateStuck(dt); | |
| for(const u of G.units)if(!u.dead)trackTile(u); | |
| } | |
| function integrateUnit(u,dt){ | |
| u.spawnT+=dt; | |
| if(u.selT<1)u.selT=Math.min(1,u.selT+dt*5); | |
| if(u.hpFlash>0)u.hpFlash-=dt; | |
| if(u.moveState!=='moving'||!u.waypoints){ | |
| u.vx*=Math.exp(-9*dt);u.vy*=Math.exp(-9*dt); | |
| u.x+=u.vx*dt;u.y+=u.vy*dt; | |
| return; | |
| } | |
| let wp=u.waypoints[u.wpIndex]; | |
| if(!wp){u.moveState='arrived';onArrive(u);return;} | |
| let dx=wp.x-u.x,dy=wp.y-u.y,d=Math.hypot(dx,dy); | |
| const isLast=u.wpIndex===u.waypoints.length-1; | |
| const arriveR=isLast?ARRIVE_GOAL:ARRIVE_WP; | |
| if(d<=arriveR){ | |
| if(isLast){u.moveState='arrived';u.vx*=0.3;u.vy*=0.3;onArrive(u);return;} | |
| u.wpIndex++; | |
| if(u.pathVersion!==G.passVersion)repathSameGoal(u); | |
| return; | |
| } | |
| const nx=dx/d,ny=dy/d; | |
| let target=u.speed; | |
| if(isLast)target=u.speed*clamp(d/TILE,0.25,1); | |
| const dvx=nx*target,dvy=ny*target; | |
| const k=1-Math.exp(-ACCEL*dt); | |
| u.vx+=(dvx-u.vx)*k;u.vy+=(dvy-u.vy)*k; | |
| u.x+=u.vx*dt;u.y+=u.vy*dt; | |
| const sp=Math.hypot(u.vx,u.vy); | |
| if(sp>4)u.facing=lerpAngle(u.facing,Math.atan2(u.vy,u.vx),1-Math.exp(-TURN_RATE*dt)); | |
| } | |
| const sepMap=new Map(); | |
| function separateUnits(){ | |
| sepMap.clear(); | |
| for(const u of G.units){if(u.dead)continue; | |
| const cx=(u.x/TILE)|0,cy=(u.y/TILE)|0,key=cx*8192+cy; | |
| let arr=sepMap.get(key);if(!arr){arr=[];sepMap.set(key,arr);}arr.push(u);} | |
| for(const u of G.units){if(u.dead)continue; | |
| const cx=(u.x/TILE)|0,cy=(u.y/TILE)|0; | |
| let pdx=0,pdy=0; | |
| for(let oy=-1;oy<=1;oy++)for(let ox=-1;ox<=1;ox++){ | |
| const arr=sepMap.get((cx+ox)*8192+(cy+oy));if(!arr)continue; | |
| for(const v of arr){if(v===u||v.dead)continue; | |
| let dx=u.x-v.x,dy=u.y-v.y,d2=dx*dx+dy*dy;const rr=u.r+v.r; | |
| if(d2<rr*rr){ | |
| if(d2<0.01){pdx+=(Math.random()-0.5);pdy+=(Math.random()-0.5);continue;} | |
| const d=Math.sqrt(d2),push=(rr-d)/d*SEP_STRENGTH; | |
| pdx+=dx*push;pdy+=dy*push; | |
| } | |
| } | |
| } | |
| u.x+=pdx;u.y+=pdy; | |
| } | |
| } | |
| function resolveBlockedTiles(u){ | |
| const cx=(u.x/TILE)|0,cy=(u.y/TILE)|0,r=u.r; | |
| for(let ty=cy-1;ty<=cy+1;ty++)for(let tx=cx-1;tx<=cx+1;tx++){ | |
| if(!inBounds(tx,ty)||!G.passGrid[tileIdx(tx,ty)])continue; | |
| const rx=tx*TILE,ry=ty*TILE; | |
| const nx=clamp(u.x,rx,rx+TILE),ny=clamp(u.y,ry,ry+TILE); | |
| let dx=u.x-nx,dy=u.y-ny,d2=dx*dx+dy*dy; | |
| if(d2<r*r){ | |
| if(d2<0.001){ | |
| // deep inside: push out toward tile center direction | |
| const ccx=rx+TILE/2,ccy=ry+TILE/2; | |
| dx=u.x-ccx;dy=u.y-ccy;if(dx===0&&dy===0)dx=1; | |
| const dl=Math.hypot(dx,dy);u.x=ccx+dx/dl*(TILE/2+r);u.y=ccy+dy/dl*(TILE/2+r); | |
| } else { | |
| const d=Math.sqrt(d2);u.x=nx+dx/d*r;u.y=ny+dy/d*r; | |
| } | |
| } | |
| } | |
| // world bounds | |
| u.x=clamp(u.x,TILE+r,WORLD_W-TILE-r);u.y=clamp(u.y,TILE+r,WORLD_H-TILE-r); | |
| } | |
| function updateStuck(dt){ | |
| for(const u of G.units){ | |
| if(u.dead)continue; | |
| if(u.moveState!=='moving'){u.stuckT=0;u.lastX=u.x;u.lastY=u.y;continue;} | |
| const moved=Math.hypot(u.x-u.lastX,u.y-u.lastY); | |
| if(moved<u.speed*dt*0.3)u.stuckT+=dt;else u.stuckT=0; | |
| u.lastX=u.x;u.lastY=u.y; | |
| if(u.stuckT>STUCK_TIME){ | |
| u.stuckT=0;u.stuckCount++; | |
| if(u.stuckCount<=2)repathSameGoal(u); | |
| else{u.stuckCount=0;u.moveState='arrived';onArrive(u);} | |
| } | |
| } | |
| } | |
| function onArrive(u){ | |
| u.stuckCount=0; | |
| if(u.order==='harvest'){ | |
| if(u.harvState==='SEEKING'){u.harvState='HARVESTING';u.harvTimer=0;} | |
| else if(u.harvState==='RETURNING'){u.harvState='DUMPING';u.harvTimer=0;} | |
| else if(u.harvState===null){harvesterAutoSeek(u);} | |
| u.vx=0;u.vy=0;return; | |
| } | |
| if(u.order==='attackmove'){ | |
| const t=findEnemyInRange(u,u.sight*TILE); | |
| if(t){u.order='attack';u.targetId=t.id;}else u.order='idle'; | |
| u.vx=0;u.vy=0;return; | |
| } | |
| u.order='idle';u.moveState='idle';u.vx*=0.2;u.vy*=0.2; | |
| } | |
| function trackTile(u){ | |
| const tx=(u.x/TILE)|0,ty=(u.y/TILE)|0; | |
| if(tx!==u.tileX||ty!==u.tileY){u.tileX=tx;u.tileY=ty;if(u.team==='player')G.fog.dirty=true;} | |
| } | |
| /* ----- harvester routing ----- */ | |
| function harvesterSeek(u,fieldId){ | |
| if(fieldId===undefined)fieldId=-1; | |
| let best=null,bd=Infinity; | |
| for(const c of G.crystalTiles){ | |
| if(G.crystalAmt[c.i]<=0)continue; | |
| if(fieldId>=0&&c.fieldId!==fieldId)continue; | |
| const d=dist2(u.x,u.y,tileCenter(c.tx),tileCenter(c.ty)); | |
| if(d<bd){bd=d;best=c;} | |
| } | |
| if(!best){ | |
| if(fieldId>=0)return harvesterSeek(u,-1); // assigned field empty -> fall back to any field | |
| u.order='idle';u.harvState=null;u.moveState='idle';return;} | |
| u.harvTile=best.i; | |
| // pick passable neighbor of crystal nearest to unit | |
| let stand=null,sd=Infinity; | |
| for(let dir=0;dir<8;dir++){ | |
| const nx=best.tx+NX[dir],ny=best.ty+NY[dir]; | |
| if(!inBounds(nx,ny)||G.passGrid[tileIdx(nx,ny)])continue; | |
| const d=dist2(u.x,u.y,tileCenter(nx),tileCenter(ny)); | |
| if(d<sd){sd=d;stand={tx:nx,ty:ny};} | |
| } | |
| if(!stand){u.order='idle';u.harvState=null;return;} | |
| u.harvState='SEEKING';u.order='harvest'; | |
| requestPath(u,stand.tx,stand.ty,{exact:true,ex:tileCenter(stand.tx),ey:tileCenter(stand.ty)}); | |
| } | |
| function harvesterAutoSeek(u){harvesterSeek(u,u.rallyFieldId>=0?u.rallyFieldId:-1);} | |
| function harvesterReturn(u){ | |
| let best=null,bd=Infinity; | |
| for(const b of G.buildings){ | |
| if(b.dead||!b.built||!b.isDock)continue; | |
| const d=dist2(u.x,u.y,b.x,b.y);if(d<bd){bd=d;best=b;} | |
| } | |
| if(!best){u.harvState=null;u.order='idle';return;} | |
| u.dockId=best.id; | |
| // stand tile = passable tile adjacent to footprint nearest unit | |
| let stand=null,sd=Infinity; | |
| for(let ty=best.ty-1;ty<=best.ty+best.fh;ty++)for(let tx=best.tx-1;tx<=best.tx+best.fw;tx++){ | |
| if(!inBounds(tx,ty)||G.passGrid[tileIdx(tx,ty)])continue; | |
| const inFoot=(tx>=best.tx&&tx<best.tx+best.fw&&ty>=best.ty&&ty<best.ty+best.fh); | |
| if(inFoot)continue; | |
| const d=dist2(u.x,u.y,tileCenter(tx),tileCenter(ty));if(d<sd){sd=d;stand={tx,ty};} | |
| } | |
| u.harvState='RETURNING';u.order='harvest'; | |
| if(stand)requestPath(u,stand.tx,stand.ty,{exact:true,ex:tileCenter(stand.tx),ey:tileCenter(stand.ty)}); | |
| else issueMove(u,best.x,best.y,'harvest'); | |
| } | |
| /* ===== S8 FOG OF WAR ============================================ */ | |
| function initFog(){ | |
| const maskCanvas=document.createElement('canvas'); | |
| maskCanvas.width=MAP_W;maskCanvas.height=MAP_H; | |
| const maskCtx=maskCanvas.getContext('2d'); | |
| const maskImage=maskCtx.createImageData(MAP_W,MAP_H); | |
| G.fog={ | |
| explored:new Uint8Array(N_TILES),visible:new Uint8Array(N_TILES),visTarget:new Uint8Array(N_TILES), | |
| exploredCount:0,dirty:true,visAccum:0,maskCanvas,maskCtx,maskImage,discCache:new Map(), | |
| tick(dt){this.visAccum+=dt; | |
| if(this.visAccum>=VIS_INTERVAL){this.visAccum=0;if(this.dirty){this.recompute();this.dirty=false;}} | |
| this.advanceFade(dt);}, | |
| recompute(){this.visTarget.fill(0); | |
| for(const u of G.units){if(u.dead||u.team!=='player'||u.sight<=0)continue;this.stampDisc(u.tileX,u.tileY,u.sight);} | |
| for(const b of G.buildings){if(b.dead||!b.built)continue;this.stampDisc(b.tileX,b.tileY,b.sight);} | |
| const vt=this.visTarget,ex=this.explored; | |
| for(let i=0;i<N_TILES;i++)if(vt[i]&&!ex[i]){ex[i]=1;this.exploredCount++;}}, | |
| stampDisc(cx,cy,r){const d=this.getDisc(r),vt=this.visTarget; | |
| for(let k=0;k<d.length;k+=2){const tx=cx+d[k],ty=cy+d[k+1]; | |
| if(tx<0||ty<0||tx>=MAP_W||ty>=MAP_H)continue;vt[ty*MAP_W+tx]=255;}}, | |
| getDisc(r){let d=this.discCache.get(r);if(d)return d;const o=[],r2=(r+0.5)*(r+0.5); | |
| for(let dy=-r;dy<=r;dy++)for(let dx=-r;dx<=r;dx++)if(dx*dx+dy*dy<=r2)o.push(dx,dy); | |
| d=Int16Array.from(o);this.discCache.set(r,d);return d;}, | |
| advanceFade(dt){const step=Math.min(255,FADE_PER_SEC*dt)|0,v=this.visible,t=this.visTarget; | |
| for(let i=0;i<N_TILES;i++){const c=v[i],g=t[i]; | |
| if(c<g)v[i]=Math.min(g,c+step);else if(c>g)v[i]=Math.max(g,c-step);}}, | |
| fogAlpha(i){if(this.explored[i]===0)return 1.0;return MEMORY_DIM*(1-this.visible[i]/255);}, | |
| buildMask(){const data=this.maskImage.data; | |
| for(let i=0;i<N_TILES;i++){const a=this.fogAlpha(i),o=i<<2; | |
| data[o]=FOG_R;data[o+1]=FOG_G;data[o+2]=FOG_B;data[o+3]=(a*255)|0;} | |
| this.maskCtx.putImageData(this.maskImage,0,0);}, | |
| isVisible(tx,ty){if(!inBounds(tx,ty))return false;return this.visible[tileIdx(tx,ty)]>VIS_THRESHOLD;}, | |
| }; | |
| } | |
| /* ===== S9 PARTICLES / FLOATERS / DECALS ========================= */ | |
| function initFX(){ | |
| G.particles={list:[],n:0}; | |
| G.floaters=[]; | |
| G.decals=[]; | |
| } | |
| function spawnP(type,x,y,vx,vy,life,size,color){ | |
| const p={type,x,y,vx,vy,life,maxLife:life,size,color,rot:Math.random()*6.28}; | |
| G.particles.list.push(p); | |
| if(G.particles.list.length>MAXP)G.particles.list.splice(0,G.particles.list.length-MAXP); | |
| } | |
| function burst(type,x,y,n,opt){ | |
| opt=opt||{}; | |
| for(let i=0;i<n;i++){ | |
| const a=Math.random()*6.28,sp=(opt.sp||40)*(0.4+Math.random()*0.8); | |
| spawnP(type,x+(Math.random()-0.5)*(opt.spread||4),y+(Math.random()-0.5)*(opt.spread||4), | |
| Math.cos(a)*sp,Math.sin(a)*sp-(opt.up||0),(opt.life||0.6)*(0.6+Math.random()*0.6), | |
| opt.size||3,opt.color||'#fff'); | |
| } | |
| } | |
| function spawnFloat(x,y,text,color){ | |
| G.floaters.push({x,y,vy:-26,life:1.1,maxLife:1.1,text,color}); | |
| if(G.floaters.length>40)G.floaters.shift(); | |
| } | |
| function spawnPing(x,y,color){G.decals.push({kind:'ping',x,y,t:0,life:0.55,color});} | |
| function updateParticles(dt){ | |
| const L=G.particles.list; | |
| for(let i=L.length-1;i>=0;i--){const p=L[i]; | |
| p.life-=dt;if(p.life<=0){L.splice(i,1);continue;} | |
| p.x+=p.vx*dt;p.y+=p.vy*dt; | |
| if(p.type===P_SMOKE){p.vy-=14*dt;p.vx*=0.96;} | |
| else if(p.type===P_CHIP||p.type===P_DEATH||p.type===P_CONFETTI){p.vy+=120*dt;p.vx*=0.99;} | |
| else if(p.type===P_DUST||p.type===P_POOF){p.vx*=0.92;p.vy*=0.92;} | |
| else if(p.type===P_SPARK){p.vy+=60*dt;} | |
| } | |
| } | |
| function updateFloaters(dt){ | |
| for(let i=G.floaters.length-1;i>=0;i--){const f=G.floaters[i]; | |
| f.life-=dt;f.y+=f.vy*dt;f.vy*=0.96;if(f.life<=0)G.floaters.splice(i,1);} | |
| } | |
| function updateDecals(dt){ | |
| for(let i=G.decals.length-1;i>=0;i--){const d=G.decals[i]; | |
| d.t+=dt;if(d.t>=d.life)G.decals.splice(i,1);} | |
| } | |
| /* ===== S10 PRODUCTION / ECONOMY / COMBAT ========================= */ | |
| function startBuild(type){ | |
| const d=BLDG_DEFS[type];if(!d)return; | |
| if(type==='warfactory'&&!G.hasBarracks){toast('Requires Barracks','warn');return;} | |
| let slot=G.buildSlots[type]; | |
| if(!slot){slot=G.buildSlots[type]={progress:0,building:false,ready:false};G.buildSlots[type]=slot;} | |
| if(slot.building||slot.ready){ | |
| // already in progress / ready -> enter placement if ready | |
| if(slot.ready){enterPlacement(type);} | |
| return; | |
| } | |
| if(G.credits<d.cost){toast('Insufficient credits','warn');flashCredit();return;} | |
| G.credits-=d.cost; | |
| slot.building=true;slot.progress=0;slot.ready=false; | |
| hideHelp(); | |
| } | |
| function refundBuild(type){ | |
| const slot=G.buildSlots[type];if(!slot||(!slot.building&&!slot.ready))return; | |
| G.credits+=BLDG_DEFS[type].cost; | |
| slot.building=false;slot.ready=false;slot.progress=0; | |
| if(G.placing&&G.placing.type===type)cancelPlacement(); | |
| toast('Construction refunded'); | |
| } | |
| function enterPlacement(type){ | |
| const d=BLDG_DEFS[type];G.placing={type,fw:d.fp[0],fh:d.fp[1]}; | |
| canvas.classList.add('placing'); | |
| hideHelp(); | |
| } | |
| function cancelPlacement(){G.placing=null;canvas.classList.remove('placing');} | |
| function placementValid(tx,ty,fw,fh){ | |
| let nearBase=false; | |
| for(let y=ty;y<ty+fh;y++)for(let x=tx;x<tx+fw;x++){ | |
| if(!inBounds(x,y))return false; | |
| const i=tileIdx(x,y); | |
| if(!BUILDABLE[G.terrain[i]]||G.occ[i]!==0||G.passGrid[i])return false; | |
| } | |
| // within BUILD_RADIUS of any completed friendly building | |
| const ccx=tx+fw/2,ccy=ty+fh/2; | |
| for(const b of G.buildings){ | |
| if(b.dead||!b.built)continue; | |
| const bx=b.tx+b.fw/2,by=b.ty+b.fh/2; | |
| const dx=Math.abs(ccx-bx),dy=Math.abs(ccy-by); | |
| if(Math.max(dx,dy)<=BUILD_RADIUS+Math.max(fw,fh,b.fw,b.fh)/2)return true; | |
| } | |
| return false; | |
| } | |
| function placeBuilding(tx,ty){ | |
| const pl=G.placing;if(!pl)return false; | |
| if(!placementValid(tx,ty,pl.fw,pl.fh))return false; | |
| const slot=G.buildSlots[pl.type]; | |
| const b=makeBuilding(pl.type,tx,ty,true); | |
| b.placeT=1;addBuilding(b); | |
| burst(P_DUST,b.x,b.y,16,{sp:60,life:0.7,size:5,color:'rgba(180,170,150,0.8)',spread:b.fw*TILE*0.6}); | |
| if(slot){slot.building=false;slot.ready=false;slot.progress=0;} | |
| const d=BLDG_DEFS[pl.type]; | |
| toast(d.name+' online','good'); | |
| if(d.grantsHarvester){ | |
| const sp=findSpawnTile(b);const h=addUnit(makeUnit('harvester',sp.x,sp.y,'player')); | |
| h.order='harvest';h.harvState=null;burst(P_POOF,sp.x,sp.y,10,{color:'rgba(70,240,240,0.8)',life:0.5}); | |
| } | |
| if(!G.input.shift)cancelPlacement(); | |
| G.cardSig=null; | |
| return true; | |
| } | |
| function updateProduction(dt){ | |
| // building construction timers (sidebar) | |
| for(const type in G.buildSlots){ | |
| const slot=G.buildSlots[type]; | |
| if(slot.building){ | |
| slot.progress+=dt/BLDG_DEFS[type].time*G.throttle; | |
| if(slot.progress>=1){slot.progress=1;slot.building=false;slot.ready=true; | |
| toast(BLDG_DEFS[type].name+' ready — place it','good');} | |
| } | |
| } | |
| // unit production at buildings | |
| for(const b of G.buildings){ | |
| if(b.dead||!b.built||b.queue.length===0)continue; | |
| const type=b.queue[0],ud=UNIT_DEFS[type]; | |
| b.prodT+=dt/ud.time*G.throttle; | |
| if(b.blinkT>0)b.blinkT-=dt; | |
| if(b.prodT>=1){ | |
| b.prodT=0;b.queue.shift(); | |
| const sp=findSpawnTile(b); | |
| const u=addUnit(makeUnit(type,sp.x,sp.y,'player')); | |
| u.spawnT=0;burst(P_POOF,sp.x,sp.y,10,{color:'rgba(120,200,255,0.7)',life:0.5,sp:50}); | |
| b.readyPulse=1;b.blinkT=0.4; | |
| if(type==='harvester'){u.order='harvest';u.harvState=null; | |
| if(b.rally&&b.rally.gather){u.rallyFieldId=b.rally.fieldId;}} | |
| else if(b.rally){issueMove(u,b.rally.x,b.rally.y,'move');} | |
| } | |
| } | |
| } | |
| function findSpawnTile(b){ | |
| // passable tile just outside footprint (prefer below/front) | |
| const order=[[Math.floor(b.fw/2),b.fh],[b.fw,Math.floor(b.fh/2)],[-1,Math.floor(b.fh/2)], | |
| [Math.floor(b.fw/2),-1]]; | |
| for(const o of order){ | |
| const tx=b.tx+o[0],ty=b.ty+o[1]; | |
| if(inBounds(tx,ty)&&!G.passGrid[tileIdx(tx,ty)])return{x:tileCenter(tx),y:tileCenter(ty)}; | |
| } | |
| for(let r=1;r<6;r++)for(let dy=-r;dy<=r;dy++)for(let dx=-r;dx<=r;dx++){ | |
| const tx=b.tx+dx,ty=b.ty+dy; | |
| if(inBounds(tx,ty)&&!G.passGrid[tileIdx(tx,ty)]&&G.occ[tileIdx(tx,ty)]===0) | |
| return{x:tileCenter(tx),y:tileCenter(ty)}; | |
| } | |
| return{x:b.x,y:b.y+b.fh*TILE/2+TILE}; | |
| } | |
| function trainUnit(b,type){ | |
| const ud=UNIT_DEFS[type];if(!ud)return; | |
| if(type==='rocket'&&!G.hasWarFactory){toast('Requires War Factory','warn');return;} | |
| if(b.queue.length>=QUEUE_MAX){toast('Queue full','warn');return;} | |
| if(G.credits<ud.cost){toast('Insufficient credits','warn');flashCredit();return;} | |
| G.credits-=ud.cost;b.queue.push(type); | |
| } | |
| function refundUnit(b,type){ | |
| // remove last occurrence of type from queue (not the in-progress lead if it's the only one mid-build? allow) | |
| for(let i=b.queue.length-1;i>=0;i--){ | |
| if(b.queue[i]===type){ | |
| if(i===0){G.credits+=Math.round(UNIT_DEFS[type].cost*(1-b.prodT));b.prodT=0;} | |
| else G.credits+=UNIT_DEFS[type].cost; | |
| b.queue.splice(i,1);return; | |
| } | |
| } | |
| } | |
| function updateEconomy(dt){ | |
| let mined=0; | |
| for(const u of G.units){ | |
| if(u.dead||u.type!=='harvester')continue; | |
| mined+=updateHarvester(u,dt); | |
| } | |
| // income smoothing | |
| G.incomeAccum+=mined;G.incomeWindow+=dt; | |
| if(G.incomeWindow>=0.5){G.incomeRate=lerp(G.incomeRate,G.incomeAccum/G.incomeWindow,0.5); | |
| G.incomeAccum=0;G.incomeWindow=0;} | |
| G.totalMined+=mined; | |
| if(G.credits>G.maxCredits)G.maxCredits=G.credits; | |
| } | |
| function updateHarvester(u,dt){ | |
| let gained=0; | |
| if(u.harvState===null){ | |
| if(u.order!=='move'){harvesterAutoSeek(u);} | |
| return 0; | |
| } | |
| if(u.harvState==='SEEKING'){ | |
| // if target crystal gone, re-seek | |
| if(u.harvTile<0||G.crystalAmt[u.harvTile]<=0){if(u.moveState!=='moving')harvesterAutoSeek(u);} | |
| } else if(u.harvState==='HARVESTING'){ | |
| u.vx=0;u.vy=0; | |
| if(u.harvTile<0||G.crystalAmt[u.harvTile]<=0){ | |
| if(u.cargo>0)harvesterReturn(u);else harvesterAutoSeek(u);return 0;} | |
| u.harvTimer+=dt*G.throttle; | |
| // mining chips | |
| if(Math.random()<dt*14){ | |
| const cx=tileCenter(u.harvTile%MAP_W),cy=tileCenter((u.harvTile/MAP_W)|0); | |
| spawnP(P_CHIP,cx+(Math.random()-0.5)*16,cy+(Math.random()-0.5)*16, | |
| (Math.random()-0.5)*40,-30-Math.random()*30,0.5,2.5,PAL.crystalLit); | |
| } | |
| if(u.harvTimer>=HARV_BITE_TIME){ | |
| u.harvTimer=0; | |
| const take=Math.min(HARV_BITE,G.crystalAmt[u.harvTile],u.cargoMax-u.cargo); | |
| u.cargo+=take;G.crystalAmt[u.harvTile]-=take; | |
| if(G.crystalAmt[u.harvTile]<=0)depleteCrystal(u.harvTile); | |
| if(u.cargo>=u.cargoMax){harvesterReturn(u);} | |
| else if(u.harvTile<0||G.terrain[u.harvTile]!==T_CRYSTAL){ | |
| if(u.cargo>0)harvesterReturn(u);else harvesterAutoSeek(u);} | |
| } | |
| } else if(u.harvState==='RETURNING'){ | |
| const dock=G.idMap.get(u.dockId); | |
| if(!dock||dock.dead){harvesterReturn(u);return 0;} | |
| if(u.moveState!=='moving'){ | |
| if(dist2(u.x,u.y,dock.x,dock.y)<ARRIVE_DOCK*ARRIVE_DOCK){u.harvState='DUMPING';u.harvTimer=0;} | |
| else harvesterReturn(u); | |
| } | |
| } else if(u.harvState==='DUMPING'){ | |
| u.vx=0;u.vy=0; | |
| const dock=G.idMap.get(u.dockId); | |
| if(!dock||dock.dead){harvesterReturn(u);return 0;} // dock destroyed mid-dump -> find another | |
| dock.glow=1; | |
| u.harvTimer+=dt*G.throttle; | |
| if(u.harvTimer>=HARV_DUMP_TIME){ | |
| u.harvTimer=0; | |
| const amt=Math.min(HARV_DUMP,u.cargo); | |
| u.cargo-=amt;G.credits+=amt;gained+=amt; | |
| spawnFloat(dock.x,dock.y-dock.fh*TILE/2,'+'+amt,FX.creditPlus); | |
| if(u.cargo<=0){harvesterAutoSeek(u);} | |
| } | |
| } | |
| return gained; | |
| } | |
| function harvesterSeekField(u,fieldId){harvesterSeek(u,fieldId);} | |
| /* ----- critters & flavor combat ----- */ | |
| function spawnCritter(){ | |
| for(let tries=0;tries<40;tries++){ | |
| const edge=Math.floor(Math.random()*4);let tx,ty; | |
| if(edge===0){tx=2+(Math.random()*(MAP_W-4)|0);ty=3;} | |
| else if(edge===1){tx=2+(Math.random()*(MAP_W-4)|0);ty=MAP_H-4;} | |
| else if(edge===2){tx=3;ty=2+(Math.random()*(MAP_H-4)|0);} | |
| else{tx=MAP_W-4;ty=2+(Math.random()*(MAP_H-4)|0);} | |
| if(inBounds(tx,ty)&&!G.passGrid[tileIdx(tx,ty)]){ | |
| const u=makeUnit('critter',tileCenter(tx),tileCenter(ty),'neutral'); | |
| u.hp=CRITTER_HP;u.hpMax=CRITTER_HP;u.r=CRITTER_DEF.r;u.speed=CRITTER_DEF.speed*TILE; | |
| addUnit(u);return; | |
| } | |
| } | |
| } | |
| let critterRespawnT=0; | |
| function updateCritters(dt){ | |
| let alive=0; | |
| for(const u of G.units){ | |
| if(u.dead||u.type!=='critter')continue;alive++; | |
| u.bob+=dt*4; | |
| u.wanderT-=dt; | |
| if(u.wanderT<=0&&u.moveState!=='moving'){ | |
| u.wanderT=2+Math.random()*3; | |
| const tx=clamp(u.tileX+((Math.random()*7|0)-3),2,MAP_W-3); | |
| const ty=clamp(u.tileY+((Math.random()*7|0)-3),2,MAP_H-3); | |
| issueMove(u,tileCenter(tx),tileCenter(ty),'move'); | |
| } | |
| } | |
| if(alive<CRITTER_COUNT){critterRespawnT-=dt;if(critterRespawnT<=0){spawnCritter();critterRespawnT=CRITTER_RESPAWN/CRITTER_COUNT;}} | |
| } | |
| function findEnemyInRange(u,range){ | |
| let best=null,bd=range*range; | |
| for(const v of G.units){ | |
| if(v.dead||v.type!=='critter')continue; | |
| const d=dist2(u.x,u.y,v.x,v.y); | |
| if(d<bd){bd=d;best=v;} | |
| } | |
| return best; | |
| } | |
| function updateCombat(dt){ | |
| for(const u of G.units){ | |
| if(u.dead||u.team!=='player'||u.atk<=0)continue; | |
| if(u.atkCooldown>0)u.atkCooldown-=dt; | |
| let target=null; | |
| if(u.order==='attack'){target=G.idMap.get(u.targetId); | |
| if(!target||target.dead){u.order='idle';u.targetId=0;target=null;}} | |
| if(u.order==='attack'&&target){ | |
| const d=Math.hypot(u.x-target.x,u.y-target.y); | |
| if(d>u.range){if(u.moveState!=='moving'||u.goalTile!==tileIdx(target.tileX,target.tileY)) | |
| issueMove(u,target.x,target.y,'attack');} | |
| else{u.moveState='idle';u.vx*=0.4;u.vy*=0.4;u.aimFacing=lerpAngle(u.aimFacing,Math.atan2(target.y-u.y,target.x-u.x),0.3); | |
| if(u.atkCooldown<=0)fireAt(u,target);} | |
| } else if(u.order==='idle'||u.order==='hold'){ | |
| // auto-fire if a critter is within range (no chasing) | |
| const e=findEnemyInRange(u,u.range); | |
| if(e){u.aimFacing=lerpAngle(u.aimFacing,Math.atan2(e.y-u.y,e.x-u.x),0.3); | |
| if(u.atkCooldown<=0)fireAt(u,e);} | |
| } else if(u.order==='attackmove'){ | |
| const e=findEnemyInRange(u,u.sight*TILE); | |
| if(e){u.order='attack';u.targetId=e.id;} | |
| } | |
| } | |
| // projectiles | |
| for(let i=G.projectiles.length-1;i>=0;i--){ | |
| const p=G.projectiles[i];p.life-=dt; | |
| const t=G.idMap.get(p.targetId); | |
| if(t&&!t.dead){const dx=t.x-p.x,dy=t.y-p.y,d=Math.hypot(dx,dy); | |
| const step=p.speed*dt; | |
| if(d<=step+t.r){applyDamage(t,p.dmg);hitFX(t.x,t.y,p.color);G.projectiles.splice(i,1);continue;} | |
| p.x+=dx/d*step;p.y+=dy/d*step;} | |
| else{p.x+=p.vx*dt;p.y+=p.vy*dt;} | |
| if(p.life<=0)G.projectiles.splice(i,1); | |
| } | |
| } | |
| function fireAt(u,target){ | |
| u.atkCooldown=u.rof; | |
| const ang=Math.atan2(target.y-u.y,target.x-u.x); | |
| const muzzleX=u.x+Math.cos(ang)*u.r,muzzleY=u.y+Math.sin(ang)*u.r; | |
| const color=u.type==='rocket'?'#ff9a3c':u.type==='tank'?'#ffe27a':TEAM.blueLit; | |
| const speed=u.type==='rocket'?260:520; | |
| G.projectiles.push({id:G.nextId++,x:muzzleX,y:muzzleY,vx:Math.cos(ang)*speed,vy:Math.sin(ang)*speed, | |
| speed,targetId:target.id,dmg:u.atk,life:1.6,color}); | |
| burst(P_SPARK,muzzleX,muzzleY,3,{sp:90,life:0.18,size:2,color}); | |
| } | |
| function applyDamage(t,dmg){ | |
| t.hp-=dmg;t.hpFlash=2; | |
| if(t.hp<=0&&!t.dead){ | |
| t.dead=true; | |
| burst(P_DEATH,t.x,t.y,16,{sp:80,life:0.7,size:4,color:UNIT.critter.lit}); | |
| burst(P_POOF,t.x,t.y,8,{sp:40,life:0.5,size:6,color:'rgba(120,90,60,0.6)'}); | |
| } | |
| } | |
| function hitFX(x,y,color){burst(P_SPARK,x,y,6,{sp:120,life:0.3,size:2.5,color});} | |
| /* ===== S11 INPUT ================================================= */ | |
| const canvas=document.getElementById('world-canvas'); | |
| const ctx=canvas.getContext('2d'); | |
| let DPR=1; | |
| function eventToCanvas(e){ | |
| const r=canvas.getBoundingClientRect(); | |
| return{x:e.clientX-r.left,y:e.clientY-r.top}; | |
| } | |
| function pickEntity(wx,wy){ | |
| // units first (topmost = last drawn ~ highest y? just nearest within r), then buildings | |
| let best=null,bd=Infinity; | |
| for(const u of G.units){ | |
| if(u.dead||u.team!=='player')continue; | |
| if(!G.fog.isVisible(u.tileX,u.tileY))continue; | |
| const d=dist2(wx,wy,u.x,u.y); | |
| if(d<(u.r+5)*(u.r+5)&&d<bd){bd=d;best=u;} | |
| } | |
| if(best)return best; | |
| const tx=(wx/TILE)|0,ty=(wy/TILE)|0; | |
| if(inBounds(tx,ty)){const id=G.occ[tileIdx(tx,ty)];if(id){const b=G.idMap.get(id);if(b&&!b.dead)return b;}} | |
| return null; | |
| } | |
| function clearSelection(){for(const e of G.selection)e.selT=0;G.selection.length=0;G.selKind='none';G.selSig=null;} | |
| function setSelection(ents){ | |
| clearSelection(); | |
| for(const e of ents){G.selection.push(e);e.selT=0.01;} | |
| G.selKind=ents.length?(ents[0].kind==='building'?'building':'units'):'none'; | |
| G.selSig=null; | |
| } | |
| function selectAt(wx,wy,shift){ | |
| const e=pickEntity(wx,wy); | |
| if(!e){if(!shift)clearSelection();return;} | |
| if(e.kind==='building'){setSelection([e]);return;} | |
| if(shift&&G.selKind==='units'){ | |
| const idx=G.selection.indexOf(e); | |
| if(idx>=0){e.selT=0;G.selection.splice(idx,1);if(!G.selection.length)G.selKind='none';} | |
| else{G.selection.push(e);e.selT=0.01;} | |
| G.selSig=null; | |
| } else setSelection([e]); | |
| } | |
| function boxSelect(x0,y0,x1,y1,shift){ | |
| const minx=Math.min(x0,x1),maxx=Math.max(x0,x1),miny=Math.min(y0,y1),maxy=Math.max(y0,y1); | |
| const found=[]; | |
| for(const u of G.units){ | |
| if(u.dead||u.team!=='player')continue; | |
| if(!G.fog.isVisible(u.tileX,u.tileY))continue; | |
| if(u.x>=minx&&u.x<=maxx&&u.y>=miny&&u.y<=maxy)found.push(u); | |
| } | |
| if(!found.length){if(!shift)clearSelection();return;} | |
| if(shift&&G.selKind==='units'){for(const u of found)if(G.selection.indexOf(u)<0){G.selection.push(u);u.selT=0.01;}G.selSig=null;} | |
| else setSelection(found); | |
| } | |
| function selectSameType(wx,wy){ | |
| const e=pickEntity(wx,wy);if(!e||e.kind!=='unit')return; | |
| const found=[];const c=G.cam; | |
| for(const u of G.units){ | |
| if(u.dead||u.team!=='player'||u.type!==e.type)continue; | |
| if(!G.fog.isVisible(u.tileX,u.tileY))continue; | |
| const s=worldToScreen(u.x,u.y); | |
| if(s.x>=0&&s.y>=0&&s.x<=c.vw&&s.y<=c.vh)found.push(u); | |
| } | |
| if(found.length)setSelection(found); | |
| } | |
| function commandRight(wx,wy){ | |
| // producer building selected -> set rally | |
| if(G.selKind==='building'){ | |
| const b=G.selection[0]; | |
| if(b&&b.canProduce){ | |
| const tx=(wx/TILE)|0,ty=(wy/TILE)|0; | |
| if(b.type==='warfactory'&&inBounds(tx,ty)&&G.terrain[tileIdx(tx,ty)]===T_CRYSTAL){ | |
| b.rally={x:wx,y:wy,gather:true,fieldId:G.crystalFieldId[tileIdx(tx,ty)]};spawnPing(wx,wy,FX.gatherPing); | |
| } else {b.rally={x:wx,y:wy};spawnPing(wx,wy,FX.movePing);} | |
| } | |
| return; | |
| } | |
| if(G.selKind!=='units'||!G.selection.length)return; | |
| const units=G.selection.filter(u=>!u.dead); | |
| const tx=(wx/TILE)|0,ty=(wy/TILE)|0; | |
| const tgtEnt=pickCommandTarget(wx,wy); | |
| if(tgtEnt&&tgtEnt.type==='critter'){ | |
| // attack | |
| let any=false; | |
| for(const u of units){if(u.atk>0){u.order='attack';u.targetId=tgtEnt.id;issueMove(u,tgtEnt.x,tgtEnt.y,'attack');any=true;} | |
| else issueMove(u,wx,wy,'move');} | |
| spawnPing(tgtEnt.x,tgtEnt.y,FX.attackPing);return; | |
| } | |
| if(inBounds(tx,ty)&&G.terrain[tileIdx(tx,ty)]===T_CRYSTAL){ | |
| const harvs=units.filter(u=>u.type==='harvester'); | |
| for(const u of harvs){u.order='harvest';u.harvState='SEEKING';u.rallyFieldId=-1; | |
| u.harvTile=tileIdx(tx,ty); | |
| // path to a passable neighbor | |
| let stand=null,sd=Infinity; | |
| for(let dir=0;dir<8;dir++){const nx=tx+NX[dir],ny=ty+NY[dir]; | |
| if(!inBounds(nx,ny)||G.passGrid[tileIdx(nx,ny)])continue; | |
| const d=dist2(u.x,u.y,tileCenter(nx),tileCenter(ny));if(d<sd){sd=d;stand={tx:nx,ty:ny};}} | |
| if(stand)requestPath(u,stand.tx,stand.ty,{exact:true,ex:tileCenter(stand.tx),ey:tileCenter(stand.ty)}); | |
| else harvesterSeek(u);} // crystal fully walled in -> seek nearest reachable instead of stalling | |
| const others=units.filter(u=>u.type!=='harvester'); | |
| if(others.length)issueGroupMove(others,tileCenter(tx),tileCenter(ty),'move'); | |
| spawnPing(tileCenter(tx),tileCenter(ty),FX.gatherPing);return; | |
| } | |
| // plain move | |
| issueGroupMove(units,wx,wy,G.input.attackArmed?'attackmove':'move'); | |
| spawnPing(wx,wy,G.input.attackArmed?FX.attackPing:FX.movePing); | |
| G.input.attackArmed=false; | |
| } | |
| function pickCommandTarget(wx,wy){ | |
| let best=null,bd=Infinity; | |
| for(const u of G.units){ | |
| if(u.dead||u.type!=='critter')continue; | |
| if(!G.fog.isVisible(u.tileX,u.tileY))continue; | |
| const d=dist2(wx,wy,u.x,u.y);if(d<(u.r+8)*(u.r+8)&&d<bd){bd=d;best=u;} | |
| } | |
| return best; | |
| } | |
| /* ---- event handlers ---- */ | |
| function onMouseDown(e){ | |
| const p=eventToCanvas(e);G.input.mx=p.x;G.input.my=p.y; | |
| const w=screenToWorld(p.x,p.y);G.input.wx=w.x;G.input.wy=w.y; | |
| if(e.button===1){ // middle -> pan | |
| e.preventDefault();G.input.mDown=true;G.input.panSX=p.x;G.input.panSY=p.y; | |
| G.input.panCamX=G.cam.tx;G.input.panCamY=G.cam.ty;return;} | |
| if(e.button===2){ // right | |
| if(G.placing){cancelPlacement();return;} | |
| if(!G.won)commandRight(w.x,w.y);return;} | |
| if(e.button===0){ | |
| if(G.placing){ | |
| const tx=(w.x/TILE|0)-((G.placing.fw-1)>>1),ty=(w.y/TILE|0)-((G.placing.fh-1)>>1); | |
| placeBuilding(tx,ty);return;} | |
| if(G.input.attackArmed&&G.selKind==='units'){ // armed attack-move: left-click sets destination | |
| issueGroupMove(G.selection.filter(u=>!u.dead),w.x,w.y,'attackmove'); | |
| spawnPing(w.x,w.y,FX.attackPing);G.input.attackArmed=false;return;} | |
| G.input.lDown=true;G.input.downSX=p.x;G.input.downSY=p.y;G.input.dragging=false; | |
| G.input.boxX0=w.x;G.input.boxY0=w.y; | |
| } | |
| } | |
| function onMouseMove(e){ | |
| const p=eventToCanvas(e);G.input.mx=p.x;G.input.my=p.y;G.input.insideCanvas=true; | |
| const w=screenToWorld(p.x,p.y);G.input.wx=w.x;G.input.wy=w.y; | |
| if(G.input.lDown&&!G.placing){ | |
| const dd=Math.hypot(p.x-G.input.downSX,p.y-G.input.downSY); | |
| if(dd>5)G.input.dragging=true; | |
| if(G.input.dragging)G.input.mode='box'; | |
| } | |
| // hover entity (tooltip-ish / hp flash) | |
| } | |
| function onMouseUp(e){ | |
| const p=eventToCanvas(e); | |
| const w=screenToWorld(p.x,p.y); | |
| if(e.button===1){G.input.mDown=false;return;} | |
| if(e.button===0&&G.input.lDown){ | |
| G.input.lDown=false; | |
| if(G.input.dragging){ | |
| boxSelect(G.input.boxX0,G.input.boxY0,w.x,w.y,G.input.shift); | |
| G.input.dragging=false;G.input.mode='default'; | |
| } else { | |
| if(!G.placing)selectAt(w.x,w.y,G.input.shift); | |
| } | |
| } | |
| } | |
| function onWheel(e){ | |
| e.preventDefault(); | |
| const c=G.cam;const p=eventToCanvas(e); | |
| const before=screenToWorld(p.x,p.y); | |
| let z=c.tzoom*(e.deltaY<0?ZOOM_STEP:1/ZOOM_STEP); | |
| z=clamp(z,ZOOM_MIN,ZOOM_MAX);c.tzoom=z; | |
| // adjust target so point under cursor stays put (use new zoom) | |
| c.tx=before.x-p.x/z;c.ty=before.y-p.y/z; | |
| // also snap current for immediate response | |
| c.zoom=z;c.x=before.x-p.x/z;c.y=before.y-p.y/z; | |
| clampCamTarget();clampCam();c.moved=true; | |
| } | |
| function onDblClick(e){ | |
| if(G.placing)return; | |
| const p=eventToCanvas(e);const w=screenToWorld(p.x,p.y); | |
| selectSameType(w.x,w.y); | |
| } | |
| function onKeyDown(e){ | |
| if(e.repeat){if(e.code.startsWith('Arrow')||['KeyW','KeyA','KeyS','KeyD'].includes(e.code))return;} | |
| G.input.keys[e.code]=true; | |
| G.input.shift=e.shiftKey;G.input.ctrl=e.ctrlKey||e.metaKey; | |
| const code=e.code; | |
| if(code==='Space'){e.preventDefault();togglePause();return;} | |
| if(code==='Escape'){G.input.attackArmed=false;if(G.placing){cancelPlacement();}else clearSelection();return;} | |
| // control groups | |
| if(/^Digit[1-9]$/.test(code)){ | |
| const g=+code.slice(5); | |
| if(e.ctrlKey||e.metaKey){e.preventDefault();assignGroup(g);} | |
| else recallGroup(g); | |
| return; | |
| } | |
| // attack-move / stop / hold | |
| if(code==='KeyA'&&G.selKind==='units'){G.input.attackArmed=true;} | |
| if(code==='KeyS'&&G.selKind==='units'){stopUnits();} | |
| if(code==='KeyH'&&G.selKind==='units'){holdUnits();} | |
| // command card hotkeys | |
| handleCardHotkey(code); | |
| } | |
| function onKeyUp(e){G.input.keys[e.code]=false;G.input.shift=e.shiftKey;G.input.ctrl=e.ctrlKey||e.metaKey;} | |
| function stopUnits(){for(const u of G.selection){if(u.kind!=='unit')continue; | |
| u.moveState='idle';u.waypoints=null;u.vx=0;u.vy=0; | |
| if(u.type==='harvester'){u.order='harvest';if(u.cargo>0)harvesterReturn(u);else u.harvState=null;} | |
| else u.order='idle';}} | |
| function holdUnits(){for(const u of G.selection){if(u.kind!=='unit')continue; | |
| u.order='hold';u.moveState='idle';u.waypoints=null;u.vx=0;u.vy=0;}} | |
| function assignGroup(g){ | |
| if(G.selKind!=='units')return; | |
| G.controlGroups[g]=G.selection.filter(u=>!u.dead).map(u=>u.id); | |
| toast('Group '+g+' set'); | |
| } | |
| function recallGroup(g){ | |
| const ids=G.controlGroups[g];if(!ids||!ids.length)return; | |
| const ents=ids.map(id=>G.idMap.get(id)).filter(e=>e&&!e.dead); | |
| G.controlGroups[g]=ents.map(e=>e.id); | |
| if(!ents.length)return; | |
| setSelection(ents); | |
| const now=performance.now()/1000; // wall-clock so double-tap works even right after a pause | |
| if(G.lastRecall.g===g&&now-G.lastRecall.t<0.35){ | |
| let mx=0,my=0;for(const e of ents){mx+=e.x;my+=e.y;}centerCameraOn(mx/ents.length,my/ents.length);} | |
| G.lastRecall={g,t:now}; | |
| } | |
| function handleCardHotkey(code){ | |
| const map={KeyQ:0,KeyE:1,KeyR:2,KeyT:3,KeyF:4}; | |
| if(!(code in map))return; | |
| const slot=map[code]; | |
| const btns=document.querySelectorAll('#card-grid .card-btn'); | |
| const btn=btns[slot]; | |
| if(btn&&!btn.classList.contains('disabled')&&!btn.classList.contains('locked'))btn.click(); | |
| } | |
| function togglePause(){G.paused=!G.paused;document.getElementById('paused-tag').classList.toggle('show',G.paused);} | |
| function hideHelp(){const h=document.getElementById('help-hint');if(h)h.style.opacity='0';} | |
| /* ---- minimap input ---- */ | |
| const minimap=document.getElementById('minimap'); | |
| const mmCtx=minimap.getContext('2d'); | |
| const MM=188; | |
| let mmDrag=false; | |
| function minimapJump(e){ | |
| const r=minimap.getBoundingClientRect(); | |
| const mx=clamp((e.clientX-r.left)/MM,0,1),my=clamp((e.clientY-r.top)/MM,0,1); | |
| centerCameraOn(mx*WORLD_W,my*WORLD_H); | |
| } | |
| minimap.addEventListener('mousedown',e=>{e.preventDefault();mmDrag=true;minimapJump(e);}); | |
| window.addEventListener('mousemove',e=>{if(mmDrag)minimapJump(e);}); | |
| window.addEventListener('mouseup',()=>{mmDrag=false;}); | |
| /* ---- card grid click ---- */ | |
| document.getElementById('card-grid').addEventListener('click',e=>{ | |
| const btn=e.target.closest('.card-btn');if(!btn||btn.classList.contains('locked'))return; | |
| const act=btn.dataset.act,val=btn.dataset.val; | |
| if(act==='build')startBuild(val); | |
| else if(act==='train'){const b=G.selection[0];if(b&&b.kind==='building')trainUnit(b,val);} | |
| }); | |
| document.getElementById('card-grid').addEventListener('contextmenu',e=>{ | |
| e.preventDefault(); | |
| const btn=e.target.closest('.card-btn');if(!btn)return; | |
| const act=btn.dataset.act,val=btn.dataset.val; | |
| if(act==='build')refundBuild(val); | |
| else if(act==='train'){const b=G.selection[0];if(b&&b.kind==='building')refundUnit(b,val);} | |
| }); | |
| /* ===== S12 SIM =================================================== */ | |
| function fixedUpdate(dt){ | |
| G.time+=dt;G.frame++; | |
| updateProduction(dt); | |
| updateEconomy(dt); | |
| stepMovement(dt); | |
| updateCritters(dt); | |
| updateCombat(dt); | |
| G.fog.tick(dt); | |
| updateParticles(dt);updateFloaters(dt);updateDecals(dt); | |
| // building presentation timers | |
| for(const b of G.buildings){ | |
| if(b.placeT>0)b.placeT=Math.max(0,b.placeT-dt*3); | |
| if(b.glow>0)b.glow=Math.max(0,b.glow-dt*2); | |
| if(b.readyPulse>0)b.readyPulse=Math.max(0,b.readyPulse-dt*1.5); | |
| if(b.hpFlash>0)b.hpFlash-=dt; | |
| if(b.type==='warfactory'){b.smokeT-=dt;if(b.smokeT<=0){b.smokeT=0.5; | |
| const sx=b.x+b.fw*TILE*0.28,sy=b.y-b.fh*TILE*0.4; | |
| spawnP(P_SMOKE,sx,sy,(Math.random()-0.5)*8,-18,1.4,7,'rgba(80,80,90,0.5)');}} | |
| } | |
| updateObjectives(); | |
| reapDead(); | |
| } | |
| function reapDead(){ | |
| let removed=false; | |
| for(let i=G.units.length-1;i>=0;i--){const u=G.units[i]; | |
| if(u.dead){G.idMap.delete(u.id);G.units.splice(i,1);removed=true; | |
| const si=G.selection.indexOf(u);if(si>=0){G.selection.splice(si,1);G.selSig=null;}} | |
| } | |
| for(let i=G.buildings.length-1;i>=0;i--){const b=G.buildings[i]; | |
| if(b.dead){stampBuilding(b,false);G.idMap.delete(b.id); | |
| const si=G.selection.indexOf(b);if(si>=0){G.selection.splice(si,1);G.selSig=null;G.selKind=G.selection.length?G.selKind:'none';} | |
| G.buildings.splice(i,1);removed=true;recomputePower();recomputeTech();} | |
| } | |
| if(removed){if(!G.selection.length)G.selKind='none';} | |
| } | |
| /* ----- objectives ----- */ | |
| function initObjectives(){ | |
| G.objectives=[ | |
| {key:'power',label:'Bring a Power Plant online',done:false, | |
| check:()=>G.builtTypes.has('power')}, | |
| {key:'industry',label:'Build a Barracks & War Factory',done:false, | |
| check:()=>G.hasBarracks&&G.hasWarFactory}, | |
| {key:'fortune',label:'Bank 5,000 Credits',done:false, | |
| prog:()=>Math.min(1,G.maxCredits/OBJ_CREDITS),progTxt:()=>Math.min(G.maxCredits,OBJ_CREDITS)|0, | |
| check:()=>G.maxCredits>=OBJ_CREDITS}, | |
| {key:'explore',label:'Chart 80% of the frontier',done:false, | |
| prog:()=>Math.min(1,(G.fog.exploredCount/N_TILES)/OBJ_EXPLORE), | |
| progTxt:()=>Math.round(G.fog.exploredCount/N_TILES*100)+'%', | |
| check:()=>G.fog.exploredCount/N_TILES>=OBJ_EXPLORE}, | |
| {key:'armor',label:'Field 3 Battle Tanks',done:false, | |
| prog:()=>Math.min(1,tankCount()/OBJ_TANKS),progTxt:()=>tankCount()+'/'+OBJ_TANKS, | |
| check:()=>tankCount()>=OBJ_TANKS}, | |
| ]; | |
| } | |
| function tankCount(){let n=0;for(const u of G.units)if(!u.dead&&u.type==='tank'&&u.team==='player')n++;return n;} | |
| function updateObjectives(){ | |
| if(G.won)return; | |
| for(const o of G.objectives){ | |
| if(o.done)continue; | |
| if(o.check()){o.done=true;G.objDoneCount++; | |
| toast('✔ '+o.label,'good');confetti();bumpObjCount();G.objListSig=null;} | |
| } | |
| if(G.objDoneCount>=G.objectives.length&&!G.won){ | |
| G.won=true;G.wonAt=G.sessionSec;showVictory(); | |
| } | |
| } | |
| function confetti(){ | |
| const c=G.cam;const cx=c.x+c.vw/c.zoom/2,cy=c.y+c.vh/c.zoom*0.35; | |
| const cols=['#ffce5c','#5cffac','#36e0ff','#ff5a6e','#b9a8ff']; | |
| for(let i=0;i<60;i++){const a=Math.random()*6.28,sp=80+Math.random()*160; | |
| spawnP(P_CONFETTI,cx+(Math.random()-0.5)*200,cy,Math.cos(a)*sp,Math.sin(a)*sp-80, | |
| 1.6+Math.random(),3+Math.random()*3,cols[i%cols.length]);} | |
| } | |
| /* ===== S13 RENDER ================================================ */ | |
| let terrainCanvas,terrainCtx,minimapBase; | |
| function rr(c,x,y,w,h,r){c.beginPath();c.moveTo(x+r,y);c.arcTo(x+w,y,x+w,y+h,r); | |
| c.arcTo(x+w,y+h,x,y+h,r);c.arcTo(x,y+h,x,y,r);c.arcTo(x,y,x+w,y,r);c.closePath();} | |
| function bakeTerrain(){ | |
| terrainCanvas=document.createElement('canvas'); | |
| terrainCanvas.width=WORLD_W;terrainCanvas.height=WORLD_H; | |
| terrainCtx=terrainCanvas.getContext('2d'); | |
| const c=terrainCtx; | |
| for(let ty=0;ty<MAP_H;ty++)for(let tx=0;tx<MAP_W;tx++){repaintTileTo(c,tx,ty);} | |
| // baked faint grid | |
| c.strokeStyle='rgba(0,0,0,0.07)';c.lineWidth=1; | |
| c.beginPath(); | |
| for(let x=0;x<=MAP_W;x++){c.moveTo(x*TILE,0);c.lineTo(x*TILE,WORLD_H);} | |
| for(let y=0;y<=MAP_H;y++){c.moveTo(0,y*TILE);c.lineTo(WORLD_W,y*TILE);} | |
| c.stroke(); | |
| bakeMinimapBase(); | |
| } | |
| function repaintTile(tx,ty){repaintTileTo(terrainCtx,tx,ty); | |
| // also patch minimap base | |
| if(minimapBase){const mc=minimapBase.getContext('2d');mc.fillStyle=miniColor(tileIdx(tx,ty));mc.fillRect(tx,ty,1,1);}} | |
| function repaintTileTo(c,tx,ty){ | |
| const i=tileIdx(tx,ty),t=G.terrain[i];const x=tx*TILE,y=ty*TILE; | |
| const n=G.noise[i],pn=G.patchNoise[i]; | |
| if(t===T_WATER){ | |
| const g=c.createLinearGradient(x,y,x,y+TILE); | |
| g.addColorStop(0,PAL.waterHi);g.addColorStop(1,PAL.waterLo); | |
| c.fillStyle=g;c.fillRect(x,y,TILE,TILE); | |
| c.strokeStyle='rgba(120,200,210,0.18)';c.lineWidth=1.5; | |
| c.beginPath();c.moveTo(x+2,y+10+n*4);c.lineTo(x+TILE-2,y+8+n*4); | |
| c.moveTo(x+2,y+22-n*4);c.lineTo(x+TILE-2,y+24-n*4);c.stroke(); | |
| // foam where adjacent to land | |
| if(adjLand(tx,ty)){c.strokeStyle=PAL.waterFoam;c.lineWidth=1.5;c.globalAlpha=0.5; | |
| c.strokeRect(x+1.5,y+1.5,TILE-3,TILE-3);c.globalAlpha=1;} | |
| } else if(t===T_ROCK){ | |
| c.fillStyle=PAL.rockLo;c.fillRect(x,y,TILE,TILE); | |
| const sh=lerp(0.3,1,n); | |
| c.fillStyle=shade(PAL.rockHi,sh); | |
| c.beginPath();c.moveTo(x+4,y+TILE-4);c.lineTo(x+TILE*0.4,y+6);c.lineTo(x+TILE-5,y+TILE*0.55); | |
| c.lineTo(x+TILE-8,y+TILE-5);c.closePath();c.fill(); | |
| c.strokeStyle=PAL.rockEdge;c.lineWidth=1.5;c.stroke(); | |
| c.fillStyle=PAL.rockSpec;c.globalAlpha=0.5; | |
| c.fillRect(x+TILE*0.35,y+7,3,3);c.globalAlpha=1; | |
| } else { // grass / dirt (T_GRASS, also under crystal we paint grass base) | |
| const dirt=pn<0.34; | |
| const lo=dirt?PAL.dirtLo:PAL.grassLo,hi=dirt?PAL.dirtHi:PAL.grassHi; | |
| c.fillStyle=lerpColor(lo,hi,clamp(0.25+n*0.7,0,1));c.fillRect(x,y,TILE,TILE); | |
| // speckle | |
| c.globalAlpha=0.18; | |
| c.fillStyle=shade(hi,1.2); | |
| const sx=x+((n*97)%TILE),sy=y+((n*53)%TILE); | |
| c.fillRect(sx,sy,2,2);c.fillRect((x+((n*131)%TILE))|0,(y+((n*29)%TILE))|0,2,2); | |
| c.globalAlpha=1; | |
| // AO bottom/right | |
| c.fillStyle='rgba(0,0,0,0.10)';c.fillRect(x,y+TILE-3,TILE,3);c.fillRect(x+TILE-3,y,3,TILE); | |
| c.fillStyle='rgba(255,255,255,0.05)';c.fillRect(x,y,TILE,2); | |
| } | |
| } | |
| function adjLand(tx,ty){for(let d=0;d<4;d++){const nx=tx+NX[d],ny=ty+NY[d]; | |
| if(inBounds(nx,ny)&&G.terrain[tileIdx(nx,ny)]!==T_WATER)return true;}return false;} | |
| function drawCrystals(vr){ | |
| const c=ctx;const t=G.time; | |
| for(let ty=vr.y0;ty<=vr.y1;ty++)for(let tx=vr.x0;tx<=vr.x1;tx++){ | |
| const i=tileIdx(tx,ty); | |
| if(G.terrain[i]!==T_CRYSTAL||G.crystalAmt[i]<=0)continue; | |
| if(!G.fog.explored[i])continue; | |
| const cx=tileCenter(tx),cy=tileCenter(ty); | |
| const stage=Math.ceil(G.crystalAmt[i]/150); // 1..4 | |
| const sc=0.45+stage*0.14; | |
| // ground glow | |
| const gg=c.createRadialGradient(cx,cy,0,cx,cy,TILE*0.7); | |
| gg.addColorStop(0,'rgba(70,240,240,0.30)');gg.addColorStop(1,'rgba(70,240,240,0)'); | |
| c.fillStyle=gg;c.fillRect(cx-TILE*0.7,cy-TILE*0.7,TILE*1.4,TILE*1.4); | |
| // gems | |
| const count=Math.min(stage+1,4); | |
| for(let k=0;k<count;k++){ | |
| const ang=k/count*6.28+i*0.7; | |
| const ox=Math.cos(ang)*TILE*0.18*(k>0?1:0),oy=Math.sin(ang)*TILE*0.18*(k>0?1:0); | |
| const h=TILE*0.5*sc*(k===0?1:0.7); | |
| const gx=cx+ox,gy=cy+oy; | |
| const grad=c.createLinearGradient(gx-6,gy-h,gx+6,gy+h*0.5); | |
| grad.addColorStop(0,PAL.crystalLit);grad.addColorStop(0.5,PAL.crystalCore);grad.addColorStop(1,PAL.crystalDark); | |
| c.fillStyle=grad; | |
| c.beginPath();c.moveTo(gx,gy-h);c.lineTo(gx+5*sc,gy);c.lineTo(gx,gy+h*0.45);c.lineTo(gx-5*sc,gy);c.closePath(); | |
| c.fill();c.strokeStyle=PAL.crystalEdge;c.lineWidth=1;c.stroke(); | |
| // sparkle | |
| const sp=(Math.sin(t*3+i+k)*0.5+0.5); | |
| if(sp>0.7){c.fillStyle='rgba(255,255,255,'+((sp-0.7)*2.5)+')';c.beginPath(); | |
| c.arc(gx-2,gy-h*0.4,1.4,0,6.28);c.fill();} | |
| } | |
| } | |
| } | |
| function draw(alpha){ | |
| const c=ctx,cam=G.cam,z=cam.zoom; | |
| // clear | |
| ctx.setTransform(DPR,0,0,DPR,0,0); | |
| ctx.fillStyle='#05070a';ctx.fillRect(0,0,cam.vw,cam.vh); | |
| // world transform | |
| ctx.setTransform(DPR*z,0,0,DPR*z,-cam.x*z*DPR,-cam.y*z*DPR); | |
| const vr=viewportTileRect(); | |
| // 2. terrain blit (visible sub-rect) | |
| const sx=vr.x0*TILE,sy=vr.y0*TILE,sw=(vr.x1-vr.x0+1)*TILE,sh=(vr.y1-vr.y0+1)*TILE; | |
| ctx.imageSmoothingEnabled=false; | |
| ctx.drawImage(terrainCanvas,sx,sy,sw,sh,sx,sy,sw,sh); | |
| ctx.imageSmoothingEnabled=true; | |
| // 3. crystals | |
| drawCrystals(vr); | |
| // 4. build aura (placement) | |
| if(G.placing)drawBuildAura(); | |
| // 5. ground decals (pings, rally) | |
| drawDecals(); | |
| drawRally(); | |
| // 6. buildings (y-sorted) | |
| const blds=G.buildings.filter(b=>!b.dead&&G.fog.explored[tileIdx(b.tileX,b.tileY)]); | |
| blds.sort((a,b)=>(a.ty+a.fh)-(b.ty+b.fh)); | |
| for(const b of blds)drawBuilding(b); | |
| // 7. units (only where visible) | |
| const us=G.units.filter(u=>!u.dead); | |
| us.sort((a,b)=>a.y-b.y); | |
| for(const u of us){ | |
| if(!G.fog.isVisible(u.tileX,u.tileY))continue; | |
| const rx=lerp(u.px,u.x,alpha),ry=lerp(u.py,u.y,alpha); | |
| drawUnit(u,rx,ry); | |
| } | |
| // 8. above-entity FX | |
| drawParticles(); | |
| drawProjectiles(); | |
| drawFloaters(); | |
| // 9. placement ghost | |
| if(G.placing)drawGhost(); | |
| // 10. health bars | |
| drawHealthBars(us,blds,alpha); | |
| // 11. fog (world space, smoothing on) | |
| G.fog.buildMask(); | |
| ctx.imageSmoothingEnabled=true; | |
| ctx.drawImage(G.fog.maskCanvas,-TILE/2,-TILE/2,WORLD_W+TILE,WORLD_H+TILE); | |
| // 12. screen-space overlays | |
| ctx.setTransform(DPR,0,0,DPR,0,0); | |
| if(G.input.mode==='box'&&G.input.dragging)drawSelectionBox(); | |
| if(G.input.attackArmed)drawAttackCursor(); | |
| } | |
| function drawBuilding(b){ | |
| const c=ctx,col=BLD[b.type],d=BLDG_DEFS[b.type]; | |
| const x=b.tx*TILE,y=b.ty*TILE,w=b.fw*TILE,h=b.fh*TILE; | |
| const dim=!G.fog.isVisible(b.tileX,b.tileY); | |
| c.save(); | |
| if(dim)c.globalAlpha=0.62; | |
| // shadow | |
| c.fillStyle='rgba(0,0,0,0.28)'; | |
| c.beginPath();c.ellipse(b.x,y+h-3,w*0.46,h*0.18,0,0,6.28);c.fill(); | |
| // place bounce | |
| if(b.placeT>0){const s=1+b.placeT*0.12;c.translate(b.x,b.y);c.scale(s,s);c.translate(-b.x,-b.y);} | |
| // body | |
| const g=c.createLinearGradient(x,y,x,y+h); | |
| g.addColorStop(0,col.lit);g.addColorStop(0.5,col.base);g.addColorStop(1,col.dark); | |
| c.fillStyle=g;rr(c,x+3,y+4,w-6,h-7,5);c.fill(); | |
| c.strokeStyle=TEAM.outline;c.lineWidth=2;c.stroke(); | |
| // type detail | |
| drawBuildingDetail(b,x,y,w,h,col); | |
| // selected brackets | |
| if(G.selection.indexOf(b)>=0)drawBuildingBrackets(x,y,w,h); | |
| // ready pulse | |
| if(b.readyPulse>0){c.strokeStyle='rgba(92,255,172,'+b.readyPulse+')';c.lineWidth=3; | |
| rr(c,x+1,y+2,w-2,h-3,6);c.stroke();} | |
| c.restore(); | |
| } | |
| function drawBuildingDetail(b,x,y,w,h,col){ | |
| const c=ctx,cx=b.x,cy=b.y,t=G.time; | |
| c.lineWidth=2; | |
| if(b.type==='cc'){ | |
| c.fillStyle=col.dark;rr(c,x+w*0.3,y+8,w*0.4,h*0.3,3);c.fill(); | |
| c.fillStyle=col.accent;c.beginPath();c.arc(cx,y+12,4+Math.sin(t*3)*1,0,6.28);c.fill(); | |
| c.fillStyle='rgba(70,240,240,0.7)';c.fillRect(x+5,y+h-13,w-10,5); // dock lip | |
| c.fillStyle=col.lit;c.fillRect(x+4,y+4,5,5);c.fillRect(x+w-9,y+4,5,5); | |
| } else if(b.type==='power'){ | |
| c.fillStyle=col.dark;c.beginPath();c.arc(x+w*0.32,y+h*0.4,5,0,6.28);c.arc(x+w*0.68,y+h*0.4,5,0,6.28);c.fill(); | |
| const orb=0.4+0.6*G.powerRatio; | |
| c.fillStyle='rgba(255,210,63,'+orb+')';c.beginPath();c.arc(cx,cy+3,6+Math.sin(t*4)*1.5,0,6.28);c.fill(); | |
| } else if(b.type==='refinery'){ | |
| c.fillStyle=col.dark;rr(c,x+6,y+7,w*0.28,h*0.55,3);c.fill(); | |
| c.fillStyle=col.lit;rr(c,x+w*0.45,y+10,w*0.4,h*0.4,3);c.fill(); | |
| const dg=b.glow;c.fillStyle='rgba(70,240,240,'+(0.3+dg*0.6)+')';c.fillRect(x+6,y+h-12,w-12,6); | |
| if(dg>0){c.fillStyle='rgba(70,240,240,'+dg*0.4+')';c.beginPath();c.arc(cx,y+h-9,12*dg,0,6.28);c.fill();} | |
| } else if(b.type==='barracks'){ | |
| c.fillStyle=col.dark;c.beginPath();c.moveTo(x+4,y+h*0.42);c.lineTo(cx,y+6);c.lineTo(x+w-4,y+h*0.42);c.closePath();c.fill(); | |
| c.fillStyle=col.accent;c.fillRect(cx-4,y+h-15,8,11); | |
| // banner | |
| c.fillStyle=col.accent;c.save();c.translate(x+w-7,y+8); | |
| c.beginPath();c.moveTo(0,0);c.lineTo(7+Math.sin(t*3)*1.5,2);c.lineTo(7+Math.sin(t*3)*1.5,9);c.lineTo(0,7);c.closePath();c.fill();c.restore(); | |
| } else if(b.type==='warfactory'){ | |
| // hazard door | |
| c.fillStyle=col.dark;rr(c,x+6,y+h*0.42,w-12,h*0.45,2);c.fill(); | |
| c.fillStyle=col.accent; | |
| for(let i=0;i<4;i++)c.fillRect(x+8+i*((w-16)/4),y+h*0.45,(w-16)/8,h*0.38); | |
| // blink light | |
| c.fillStyle=b.blinkT>0?'#ff5a3c':'#5a2020';c.beginPath();c.arc(x+10,y+10,3,0,6.28);c.fill(); | |
| // smokestack | |
| c.fillStyle=col.lit;c.fillRect(x+w*0.72,y+4,7,h*0.35); | |
| } else if(b.type==='watchtower'){ | |
| c.fillStyle=col.lit;rr(c,x+w*0.2,y-h*0.5,w*0.6,h*1.0,3);c.fill(); | |
| c.strokeStyle=TEAM.outline;c.lineWidth=2;c.stroke(); | |
| // sweeping lens | |
| const a=t*1.5;c.fillStyle='rgba(70,240,240,0.5)'; | |
| c.beginPath();c.moveTo(cx,y);c.arc(cx,y,16,a,a+0.5);c.closePath();c.fill(); | |
| c.fillStyle=col.accent;c.beginPath();c.arc(cx,y,3,0,6.28);c.fill(); | |
| } | |
| } | |
| function drawBuildingBrackets(x,y,w,h){ | |
| const c=ctx,L=8;c.strokeStyle=FX.selValid;c.lineWidth=2.5; | |
| const pts=[[x,y,1,1],[x+w,y,-1,1],[x,y+h,1,-1],[x+w,y+h,-1,-1]]; | |
| for(const p of pts){c.beginPath();c.moveTo(p[0],p[1]+p[3]*L);c.lineTo(p[0],p[1]);c.lineTo(p[0]+p[2]*L,p[1]);c.stroke();} | |
| } | |
| function drawUnit(u,x,y){ | |
| const c=ctx; | |
| c.save(); | |
| const ss=u.spawnT<0.25?clamp(u.spawnT/0.25,0,1):1; | |
| // shadow | |
| c.fillStyle='rgba(0,0,0,0.25)';c.beginPath();c.ellipse(x,y+u.r*0.55,u.r*0.85,u.r*0.4,0,0,6.28);c.fill(); | |
| // selection ring (player only) | |
| if(u.team==='player'&&G.selection.indexOf(u)>=0){ | |
| const sr=u.r+5,sc=u.selT; | |
| c.strokeStyle=FX.selValid;c.lineWidth=2/G.cam.zoom*G.cam.zoom;c.lineWidth=2.2; | |
| c.globalAlpha=0.9;c.beginPath();c.ellipse(x,y+u.r*0.4,sr*sc,sr*0.5*sc,0,0,6.28);c.stroke();c.globalAlpha=1; | |
| } | |
| c.translate(x,y);if(ss<1)c.scale(ss,ss); | |
| if(u.type==='harvester')drawHarvester(u); | |
| else if(u.type==='rifleman')drawRifleman(u); | |
| else if(u.type==='scout')drawScout(u); | |
| else if(u.type==='rocket')drawRocket(u); | |
| else if(u.type==='tank')drawTank(u); | |
| else if(u.type==='critter')drawCritter(u); | |
| c.restore(); | |
| } | |
| function drawHarvester(u){ | |
| const c=ctx,col=UNIT.harvester;c.rotate(u.facing); | |
| const g=c.createLinearGradient(0,-u.r,0,u.r);g.addColorStop(0,col.hullLit);g.addColorStop(1,col.dark); | |
| c.fillStyle=g;rr(c,-u.r,-u.r*0.8,u.r*2,u.r*1.6,3);c.fill(); | |
| c.strokeStyle=TEAM.outline;c.lineWidth=2;c.stroke(); | |
| // cargo bay | |
| const f=u.cargo/u.cargoMax; | |
| c.fillStyle='rgba(0,0,0,0.4)';rr(c,-u.r*0.6,-u.r*0.55,u.r*1.0,u.r*1.1,2);c.fill(); | |
| if(f>0){c.fillStyle=col.cargo;rr(c,-u.r*0.6,-u.r*0.55+u.r*1.1*(1-f),u.r*1.0,u.r*1.1*f,2);c.fill();} | |
| // scoop | |
| c.fillStyle=col.dark;c.fillRect(u.r*0.7,-u.r*0.5,u.r*0.5,u.r); | |
| } | |
| function drawRifleman(u){ | |
| const c=ctx,col=UNIT.rifleman;c.rotate(u.facing); | |
| c.fillStyle=col.dark;c.fillRect(0,-2,u.r+5,4); // barrel | |
| const g=c.createRadialGradient(-2,-2,1,0,0,u.r);g.addColorStop(0,col.lit);g.addColorStop(1,col.body); | |
| c.fillStyle=g;c.beginPath();c.arc(0,0,u.r,0,6.28);c.fill(); | |
| c.strokeStyle=TEAM.outline;c.lineWidth=2;c.stroke(); | |
| c.fillStyle=col.dark;c.beginPath();c.arc(0,0,u.r*0.4,0,6.28);c.fill(); | |
| } | |
| function drawScout(u){ | |
| const c=ctx,col=UNIT.scout;c.rotate(u.facing); | |
| c.fillStyle=col.body; | |
| c.beginPath();c.moveTo(u.r,0);c.lineTo(-u.r*0.7,u.r*0.8);c.lineTo(-u.r*0.3,0);c.lineTo(-u.r*0.7,-u.r*0.8);c.closePath();c.fill(); | |
| c.strokeStyle=TEAM.outline;c.lineWidth=2;c.stroke(); | |
| c.fillStyle=col.lit;c.beginPath();c.arc(0,0,u.r*0.3,0,6.28);c.fill(); | |
| } | |
| function drawRocket(u){ | |
| const c=ctx,col=UNIT.rocket;c.rotate(u.facing); | |
| const g=c.createRadialGradient(-2,-2,1,0,0,u.r);g.addColorStop(0,col.lit);g.addColorStop(1,col.body); | |
| c.fillStyle=g;c.beginPath();c.arc(0,0,u.r,0,6.28);c.fill(); | |
| c.strokeStyle=TEAM.outline;c.lineWidth=2;c.stroke(); | |
| c.fillStyle=col.dark;c.fillRect(0,-u.r*0.7,u.r+6,u.r*0.5); // launcher | |
| } | |
| function drawTank(u){ | |
| const c=ctx,col=UNIT.tank; | |
| c.save();c.rotate(u.facing); | |
| c.fillStyle=col.tread;c.fillRect(-u.r,-u.r,u.r*2,u.r*0.45);c.fillRect(-u.r,u.r*0.55,u.r*2,u.r*0.45); | |
| const g=c.createLinearGradient(0,-u.r,0,u.r);g.addColorStop(0,col.lit);g.addColorStop(1,col.dark); | |
| c.fillStyle=g;rr(c,-u.r*0.85,-u.r*0.65,u.r*1.7,u.r*1.3,3);c.fill(); | |
| c.strokeStyle=TEAM.outline;c.lineWidth=2;c.stroke(); | |
| c.restore(); | |
| // turret (independent aim) | |
| c.save();c.rotate(u.aimFacing||u.facing); | |
| c.fillStyle=col.dark;c.fillRect(0,-2.5,u.r+8,5); | |
| c.fillStyle=col.lit;c.beginPath();c.arc(0,0,u.r*0.55,0,6.28);c.fill(); | |
| c.strokeStyle=TEAM.outline;c.lineWidth=1.5;c.stroke();c.restore(); | |
| } | |
| function drawCritter(u){ | |
| const c=ctx,col=UNIT.critter; | |
| const bob=Math.sin(u.bob)*1.5; | |
| c.fillStyle=col.body;c.beginPath();c.ellipse(0,bob,u.r,u.r*0.85,0,0,6.28);c.fill(); | |
| c.strokeStyle=col.dark;c.lineWidth=1.5;c.stroke(); | |
| c.fillStyle=col.lit;c.beginPath();c.arc(-u.r*0.3,bob-u.r*0.3,u.r*0.25,0,6.28);c.fill(); | |
| // eyes | |
| c.fillStyle='#1a1208';c.beginPath();c.arc(u.r*0.3,bob-1,1.5,0,6.28);c.arc(u.r*0.5,bob-1,1.5,0,6.28);c.fill(); | |
| } | |
| function drawHealthBars(us,blds,alpha){ | |
| const c=ctx; | |
| for(const u of us){ | |
| if(!G.fog.isVisible(u.tileX,u.tileY))continue; | |
| const sel=G.selection.indexOf(u)>=0; | |
| if(!(u.hpFlash>0||sel)||u.type==='critter'&&!(u.hpFlash>0))continue; | |
| if(u.type==='critter'&&u.hpFlash<=0)continue; | |
| const rx=lerp(u.px,u.x,alpha),ry=lerp(u.py,u.y,alpha); | |
| hpBar(rx,ry-u.r-7,u.r*2,u.hp/u.hpMax); | |
| } | |
| for(const b of blds){ | |
| const sel=G.selection.indexOf(b)>=0; | |
| if(!(b.hpFlash>0||sel))continue; | |
| hpBar(b.x,b.ty*TILE-6,b.fw*TILE*0.8,b.hp/b.hpMax); | |
| } | |
| } | |
| function hpBar(x,y,w,frac){ | |
| const c=ctx,iz=1/G.cam.zoom,h=4*Math.min(1.4,iz);w=Math.max(w,18*Math.min(1.4,iz)); | |
| c.fillStyle='rgba(0,0,0,0.6)';c.fillRect(x-w/2-1,y-1,w+2,h+2); | |
| c.fillStyle=frac>0.5?FX.hpHigh:frac>0.25?FX.hpMid:FX.hpLow; | |
| c.fillRect(x-w/2,y,w*clamp(frac,0,1),h); | |
| } | |
| function drawParticles(){ | |
| const c=ctx,L=G.particles.list; | |
| for(const p of L){ | |
| const a=clamp(p.life/p.maxLife,0,1); | |
| c.globalAlpha=p.type===P_SMOKE?a*0.5:a; | |
| c.fillStyle=p.color; | |
| if(p.type===P_CONFETTI){c.save();c.translate(p.x,p.y);c.rotate(p.rot+p.life*5); | |
| c.fillRect(-p.size/2,-p.size/2,p.size,p.size*0.5);c.restore();} | |
| else{c.beginPath();c.arc(p.x,p.y,p.size*(p.type===P_SMOKE?(2-a):1),0,6.28);c.fill();} | |
| } | |
| c.globalAlpha=1; | |
| } | |
| function drawProjectiles(){ | |
| const c=ctx; | |
| for(const p of G.projectiles){ | |
| c.strokeStyle=p.color;c.lineWidth=2.5;c.globalAlpha=0.9; | |
| const d=Math.hypot(p.vx,p.vy)||1; | |
| c.beginPath();c.moveTo(p.x,p.y);c.lineTo(p.x-p.vx/d*7,p.y-p.vy/d*7);c.stroke(); | |
| c.fillStyle=p.color;c.beginPath();c.arc(p.x,p.y,2,0,6.28);c.fill(); | |
| } | |
| c.globalAlpha=1; | |
| } | |
| function drawFloaters(){ | |
| const c=ctx;c.textAlign='center';c.font='700 14px Orbitron, sans-serif'; | |
| for(const f of G.floaters){ | |
| const a=clamp(f.life/f.maxLife,0,1);c.globalAlpha=a; | |
| c.fillStyle='rgba(0,0,0,0.5)';c.fillText(f.text,f.x+1,f.y+1); | |
| c.fillStyle=f.color;c.fillText(f.text,f.x,f.y); | |
| } | |
| c.globalAlpha=1;c.textAlign='left'; | |
| } | |
| function drawDecals(){ | |
| const c=ctx; | |
| for(const d of G.decals){ | |
| if(d.kind==='ping'){const p=d.t/d.life;c.strokeStyle=d.color;c.globalAlpha=1-p;c.lineWidth=2.5; | |
| c.beginPath();c.arc(d.x,d.y,4+p*18,0,6.28);c.stroke(); | |
| c.beginPath();c.arc(d.x,d.y,2+p*9,0,6.28);c.stroke();} | |
| } | |
| c.globalAlpha=1; | |
| } | |
| function drawRally(){ | |
| const c=ctx; | |
| if(G.selKind!=='building')return; | |
| const b=G.selection[0];if(!b||!b.rally)return; | |
| c.strokeStyle=b.rally.gather?'rgba(70,240,240,0.6)':'rgba(120,255,160,0.6)'; | |
| c.lineWidth=2;c.setLineDash([6,6]); | |
| c.beginPath();c.moveTo(b.x,b.y);c.lineTo(b.rally.x,b.rally.y);c.stroke();c.setLineDash([]); | |
| // pennant | |
| const t=G.time;c.fillStyle=b.rally.gather?'#46f0f0':FX.selValid; | |
| c.save();c.translate(b.rally.x,b.rally.y); | |
| c.fillRect(-1,-16,2,16); | |
| c.beginPath();c.moveTo(1,-16);c.lineTo(11+Math.sin(t*4)*2,-13);c.lineTo(1,-9);c.closePath();c.fill(); | |
| c.restore(); | |
| } | |
| function drawGhost(){ | |
| const c=ctx,pl=G.placing; | |
| const tx=(G.input.wx/TILE|0)-((pl.fw-1)>>1),ty=(G.input.wy/TILE|0)-((pl.fh-1)>>1); | |
| const valid=placementValid(tx,ty,pl.fw,pl.fh); | |
| // per-tile cells | |
| for(let y=0;y<pl.fh;y++)for(let x=0;x<pl.fw;x++){ | |
| const cx=tx+x,cy=ty+y; | |
| let ok=inBounds(cx,cy)&&BUILDABLE[G.terrain[tileIdx(cx,cy)]]&&G.occ[tileIdx(cx,cy)]===0&&!G.passGrid[tileIdx(cx,cy)]; | |
| c.fillStyle=ok&&valid?'rgba(93,255,138,0.28)':'rgba(255,93,82,0.30)'; | |
| c.fillRect(cx*TILE+2,cy*TILE+2,TILE-4,TILE-4); | |
| c.strokeStyle=ok&&valid?'rgba(93,255,138,0.7)':'rgba(255,93,82,0.7)'; | |
| c.lineWidth=1.5;c.strokeRect(cx*TILE+2,cy*TILE+2,TILE-4,TILE-4); | |
| } | |
| } | |
| function drawBuildAura(){ | |
| const c=ctx;c.globalAlpha=0.06;c.fillStyle='#5dff8a'; | |
| for(const b of G.buildings){if(b.dead||!b.built)continue; | |
| c.beginPath();c.arc(b.x,b.y,(BUILD_RADIUS+1)*TILE,0,6.28);c.fill();} | |
| c.globalAlpha=1; | |
| } | |
| function drawSelectionBox(){ | |
| const c=ctx;const a=worldToScreen(G.input.boxX0,G.input.boxY0); | |
| const x=Math.min(a.x,G.input.mx),y=Math.min(a.y,G.input.my); | |
| const w=Math.abs(G.input.mx-a.x),h=Math.abs(G.input.my-a.y); | |
| c.fillStyle='rgba(93,255,138,0.10)';c.fillRect(x,y,w,h); | |
| c.strokeStyle=FX.selValid;c.lineWidth=1.5;c.setLineDash([5,4]);c.strokeRect(x,y,w,h);c.setLineDash([]); | |
| } | |
| function drawAttackCursor(){ | |
| const c=ctx;c.strokeStyle=FX.attackPing;c.lineWidth=2; | |
| c.beginPath();c.arc(G.input.mx,G.input.my,12,0,6.28);c.stroke(); | |
| c.beginPath();c.moveTo(G.input.mx-16,G.input.my);c.lineTo(G.input.mx-8,G.input.my); | |
| c.moveTo(G.input.mx+8,G.input.my);c.lineTo(G.input.mx+16,G.input.my); | |
| c.moveTo(G.input.mx,G.input.my-16);c.lineTo(G.input.mx,G.input.my-8); | |
| c.moveTo(G.input.mx,G.input.my+8);c.lineTo(G.input.mx,G.input.my+16);c.stroke(); | |
| } | |
| /* ----- minimap ----- */ | |
| function bakeMinimapBase(){ | |
| minimapBase=document.createElement('canvas');minimapBase.width=MAP_W;minimapBase.height=MAP_H; | |
| const mc=minimapBase.getContext('2d');const img=mc.createImageData(MAP_W,MAP_H); | |
| for(let i=0;i<N_TILES;i++){const col=miniColorRGB(i),o=i<<2;img.data[o]=col[0];img.data[o+1]=col[1];img.data[o+2]=col[2];img.data[o+3]=255;} | |
| mc.putImageData(img,0,0); | |
| } | |
| function miniColorRGB(i){const t=G.terrain[i]; | |
| if(t===T_WATER)return[31,93,104];if(t===T_ROCK)return[74,80,90]; | |
| if(t===T_CRYSTAL)return[57,224,230]; | |
| return G.patchNoise[i]<0.34?[111,90,58]:[96,128,68];} | |
| function miniColor(i){const c=miniColorRGB(i);return'rgb('+c[0]+','+c[1]+','+c[2]+')';} | |
| let mmAccum=0; | |
| function drawMinimap(now){ | |
| const c=mmCtx;c.imageSmoothingEnabled=false; | |
| c.clearRect(0,0,MM,MM); | |
| // base terrain scaled | |
| c.drawImage(minimapBase,0,0,MAP_W,MAP_H,0,0,MM,MM); | |
| // fog overlay | |
| c.imageSmoothingEnabled=true;c.globalAlpha=1; | |
| c.drawImage(G.fog.maskCanvas,0,0,MAP_W,MAP_H,0,0,MM,MM); | |
| c.imageSmoothingEnabled=false; | |
| const s=MM/MAP_W; | |
| // buildings (explored) | |
| for(const b of G.buildings){if(b.dead||!G.fog.explored[tileIdx(b.tileX,b.tileY)])continue; | |
| c.fillStyle=BLD[b.type].accent;c.fillRect((b.tx)*s,(b.ty)*s,Math.max(2,b.fw*s),Math.max(2,b.fh*s));} | |
| // units (visible only) | |
| for(const u of G.units){if(u.dead)continue;if(!G.fog.isVisible(u.tileX,u.tileY))continue; | |
| if(u.type==='critter')continue; | |
| c.fillStyle=u.type==='harvester'?'#f0cd76':'#85adff'; | |
| c.fillRect((u.x/TILE)*s-1,(u.y/TILE)*s-1,2.5,2.5);} | |
| // viewport rect | |
| const cam=G.cam;const vx=cam.x/WORLD_W*MM,vy=cam.y/WORLD_H*MM; | |
| const vw=(cam.vw/cam.zoom)/WORLD_W*MM,vh=(cam.vh/cam.zoom)/WORLD_H*MM; | |
| c.strokeStyle='rgba(255,255,255,0.85)';c.lineWidth=1.5;c.strokeRect(vx,vy,vw,vh); | |
| } | |
| /* ===== S14 HUD / DOM SYNC ======================================== */ | |
| const el={}; | |
| ['credit-val','rate-val','power-fill','power-bar','power-txt','units-val','bldg-val','map-val', | |
| 'clock-val','obj-list','obj-count','sel-empty','sel-portrait','sel-info','sel-multi','sel-name', | |
| 'sel-role','sel-hp','sel-stats','sel-extra','card-label','card-grid','prod-queue','portrait-cv', | |
| 'tooltip','victory-banner','vb-sub'].forEach(id=>el[id]=document.getElementById(id)); | |
| let hudCache={credits:-1,rate:-999,sup:-1,dem:-1,units:-1,bldg:-1,map:-1,clock:'',objSig:'',selSig:'',cardSig:'',queueSig:''}; | |
| function updateHUD(frameDt){ | |
| // credits roll-up | |
| G.creditsDisplay+=(G.credits-G.creditsDisplay)*(1-Math.exp(-12*frameDt)); | |
| const cd=Math.round(G.creditsDisplay); | |
| if(cd!==hudCache.credits){el['credit-val'].textContent=cd;hudCache.credits=cd;} | |
| const rate=Math.round(G.incomeRate); | |
| if(rate!==hudCache.rate){el['rate-val'].textContent=(rate>=0?'+':'')+rate+'/s';hudCache.rate=rate;} | |
| // power | |
| if(G.powerSupply!==hudCache.sup||G.powerDemand!==hudCache.dem){ | |
| hudCache.sup=G.powerSupply;hudCache.dem=G.powerDemand; | |
| const frac=G.powerDemand>0?clamp(G.powerSupply/G.powerDemand,0,1):1; | |
| el['power-fill'].style.transform='scaleX('+(G.powerDemand>0?frac:1)+')'; | |
| el['power-bar'].classList.toggle('low',G.powerDemand>G.powerSupply); | |
| el['power-txt'].innerHTML='<span class="accent">'+G.powerSupply+'</span>/<span class="dim">'+G.powerDemand+'</span>'; | |
| } | |
| // stats | |
| let nu=0;for(const u of G.units)if(!u.dead&&u.team==='player'&&u.type!=='critter')nu++; | |
| if(nu!==hudCache.units){el['units-val'].textContent=nu;hudCache.units=nu;} | |
| let nb=0;for(const b of G.buildings)if(!b.dead&&b.built)nb++; | |
| if(nb!==hudCache.bldg){el['bldg-val'].textContent=nb;hudCache.bldg=nb;} | |
| const mp=Math.round(G.fog.exploredCount/N_TILES*100); | |
| if(mp!==hudCache.map){el['map-val'].textContent=mp+'%';hudCache.map=mp;} | |
| const clk=fmtClock(G.won?G.wonAt:G.sessionSec); | |
| if(clk!==hudCache.clock){el['clock-val'].textContent=clk;hudCache.clock=clk;} | |
| // objectives | |
| const objSig=G.objectives.map(o=>o.done?'1':'0').join('')+G.objDoneCount; | |
| if(objSig!==hudCache.objSig){rebuildObjectives();hudCache.objSig=objSig;} | |
| else updateObjProgress(); | |
| if((''+G.objDoneCount+'/'+G.objectives.length)!==el['obj-count'].textContent) | |
| el['obj-count'].textContent=G.objDoneCount+'/'+G.objectives.length; | |
| // selection + card | |
| updateSelectionPanel(); | |
| updateCommandCard(); | |
| } | |
| function rebuildObjectives(){ | |
| const frag=document.createDocumentFragment(); | |
| for(const o of G.objectives){ | |
| const d=document.createElement('div');d.className='obj'+(o.done?' done':''); | |
| d.innerHTML='<span class="box">'+(o.done?'✔':'')+'</span><span class="txt">'+o.label+'</span>'+ | |
| (o.prog&&!o.done?'<span class="prog" data-obj="'+o.key+'">'+(o.progTxt?o.progTxt():'')+'</span>':''); | |
| frag.appendChild(d); | |
| } | |
| el['obj-list'].replaceChildren(frag); | |
| } | |
| function updateObjProgress(){ | |
| el['obj-list'].querySelectorAll('.prog').forEach(p=>{ | |
| const o=G.objectives.find(x=>x.key===p.dataset.obj);if(o&&o.progTxt)p.textContent=o.progTxt(); | |
| }); | |
| } | |
| function bumpObjCount(){el['obj-count'].classList.add('bump');setTimeout(()=>el['obj-count'].classList.remove('bump'),260);} | |
| function flashCredit(){el['credit-val'].classList.add('flash');setTimeout(()=>el['credit-val'].classList.remove('flash'),120);} | |
| function selSignature(){ | |
| if(G.selKind==='none')return 'none'; | |
| if(G.selKind==='building')return 'b'+G.selection[0].id; | |
| return 'u'+G.selection.length+':'+G.selection.map(u=>u.type).slice(0,8).join(','); | |
| } | |
| function updateSelectionPanel(){ | |
| const sig=selSignature(); | |
| const single=G.selection.length===1?G.selection[0]:null; | |
| if(sig!==hudCache.selSig){ | |
| hudCache.selSig=sig; | |
| el['sel-empty'].style.display=G.selKind==='none'?'block':'none'; | |
| el['sel-multi'].style.display=(G.selKind==='units'&&G.selection.length>1)?'flex':'none'; | |
| const showSingle=G.selection.length===1; | |
| el['sel-portrait'].style.display=showSingle?'block':'none'; | |
| el['sel-info'].style.display=showSingle?'block':'none'; | |
| if(showSingle){ | |
| const e=G.selection[0]; | |
| const def=e.kind==='building'?BLDG_DEFS[e.type]:UNIT_DEFS[e.type]; | |
| el['sel-name'].textContent=def.name; | |
| el['sel-role'].textContent=roleText(e); | |
| drawPortrait(e); | |
| el['sel-stats'].innerHTML=statText(e); | |
| } else if(G.selKind==='units'&&G.selection.length>1){ | |
| const frag=document.createDocumentFragment(); | |
| for(const u of G.selection.slice(0,28)){ | |
| const m=document.createElement('div');m.className='mini-unit'; | |
| m.innerHTML='<span>'+u.type.charAt(0).toUpperCase()+'</span><i></i>'; | |
| m.querySelector('i').style.background=u.hp/u.hpMax>0.5?FX.hpHigh:FX.hpMid; | |
| frag.appendChild(m); | |
| } | |
| el['sel-multi'].replaceChildren(frag); | |
| } | |
| } | |
| // live single fields | |
| if(single){ | |
| el['sel-hp'].style.transform='scaleX('+clamp(single.hp/single.hpMax,0,1)+')'; | |
| el['sel-hp'].style.background=single.hp/single.hpMax>0.5?'linear-gradient(90deg,#3bd66b,#5cffac)': | |
| single.hp/single.hpMax>0.25?'linear-gradient(90deg,#d6a23b,#ffd23f)':'linear-gradient(90deg,#d63b3b,#ff4d42)'; | |
| el['sel-extra'].textContent=extraText(single); | |
| } | |
| } | |
| function roleText(e){ | |
| if(e.kind==='building'){return e.type==='cc'?'HQ · Production · Drop-off': | |
| e.type==='power'?'Power Supply +100':e.type==='refinery'?'Drop-off · Power -40': | |
| e.type==='barracks'?'Infantry Production':e.type==='warfactory'?'Vehicle Production':'Defense · Wide Vision';} | |
| return e.type==='harvester'?'Resource Gathering':e.type==='scout'?'Recon · Fast': | |
| e.type==='tank'?'Heavy Armor':e.atk>0?'Combat Infantry':'Unit'; | |
| } | |
| function statText(e){ | |
| let s='<span>HP <b>'+Math.ceil(e.hp)+'</b>/'+e.hpMax+'</span>'; | |
| if(e.kind==='unit'){ | |
| if(e.atk>0)s+='<span>ATK <b>'+e.atk+'</b></span><span>RNG <b>'+(e.range/TILE).toFixed(1)+'</b></span>'; | |
| s+='<span>SPD <b>'+(e.speed/TILE).toFixed(1)+'</b></span><span>SIGHT <b>'+e.sight+'</b></span>'; | |
| } else { | |
| if(e.power)s+='<span>PWR <b>'+(e.power>0?'+':'')+e.power+'</b></span>'; | |
| s+='<span>SIGHT <b>'+e.sight+'</b></span>'; | |
| } | |
| return s; | |
| } | |
| function extraText(e){ | |
| if(e.kind==='unit'&&e.type==='harvester'){ | |
| return 'Cargo '+Math.round(e.cargo)+'/'+e.cargoMax+(e.harvState?' · '+e.harvState:''); | |
| } | |
| if(e.kind==='building'&&e.queue&&e.queue.length){ | |
| return 'Producing '+UNIT_DEFS[e.queue[0]].name+' · Queue '+e.queue.length+'/'+QUEUE_MAX; | |
| } | |
| return ''; | |
| } | |
| function drawPortrait(e){ | |
| const pc=el['portrait-cv'],c=pc.getContext('2d');c.clearRect(0,0,96,96); | |
| c.save();c.translate(48,52); | |
| if(e.kind==='building'){ | |
| c.scale(2.0,2.0);const col=BLD[e.type]; | |
| c.fillStyle=col.base;rr(c,-16,-16,32,30,4);c.fill();c.strokeStyle=TEAM.outline;c.lineWidth=2;c.stroke(); | |
| c.fillStyle=col.accent;c.fillRect(-6,-12,12,8); | |
| } else { | |
| c.scale(2.6,2.6);const u={...e,facing:-Math.PI/2,aimFacing:-Math.PI/2}; | |
| if(e.type==='harvester')drawHarvester(u);else if(e.type==='rifleman')drawRifleman(u); | |
| else if(e.type==='scout')drawScout(u);else if(e.type==='rocket')drawRocket(u); | |
| else if(e.type==='tank')drawTank(u);else drawCritter(u); | |
| } | |
| c.restore(); | |
| } | |
| const CARD_BUILD=[['power','Q'],['refinery','E'],['barracks','R'],['warfactory','T'],['watchtower','F']]; | |
| const CARD_BARRACKS=[['rifleman','Q'],['scout','E'],['rocket','R']]; | |
| const CARD_WARFACTORY=[['harvester','Q'],['tank','E']]; | |
| function cardContext(){ | |
| if(G.selKind==='building'){ | |
| const b=G.selection[0]; | |
| if(b.type==='cc')return{type:'build-cc',label:'CONSTRUCTION'}; | |
| if(b.type==='barracks')return{type:'train-barracks',label:'BARRACKS',b}; | |
| if(b.type==='warfactory')return{type:'train-warfactory',label:'WAR FACTORY',b}; | |
| return{type:'building-info',label:BLDG_DEFS[b.type].name.toUpperCase(),b}; | |
| } | |
| if(G.selKind==='units'){ | |
| const hasCombat=G.selection.some(u=>u.atk>0); | |
| return{type:hasCombat?'unit-combat':'unit-info',label:'UNITS'}; | |
| } | |
| return{type:'build-cc',label:'CONSTRUCTION'}; | |
| } | |
| function updateCommandCard(){ | |
| const ctxn=cardContext(); | |
| const sig=ctxn.type+(ctxn.b?ctxn.b.id:''); | |
| if(sig!==hudCache.cardSig){ | |
| hudCache.cardSig=sig;el['card-label'].textContent=ctxn.label; | |
| rebuildCard(ctxn); | |
| } | |
| updateCardStates(ctxn); | |
| updateQueue(ctxn); | |
| } | |
| function rebuildCard(ctxn){ | |
| const grid=el['card-grid'];const frag=document.createDocumentFragment(); | |
| if(ctxn.type==='build-cc'){ | |
| for(const[type,hk]of CARD_BUILD)frag.appendChild(buildBtn('build',type,hk,BLDG_DEFS[type])); | |
| } else if(ctxn.type==='train-barracks'){ | |
| for(const[type,hk]of CARD_BARRACKS)frag.appendChild(buildBtn('train',type,hk,UNIT_DEFS[type])); | |
| } else if(ctxn.type==='train-warfactory'){ | |
| for(const[type,hk]of CARD_WARFACTORY)frag.appendChild(buildBtn('train',type,hk,UNIT_DEFS[type])); | |
| } else if(ctxn.type==='unit-combat'){ | |
| const info=document.createElement('div');info.className='info-card';info.style.gridColumn='1/5'; | |
| info.innerHTML='<b>A</b> Attack-move <b>S</b> Stop <b>H</b> Hold<br>'+ | |
| '<b>Ctrl+1-9</b> set group <b>1-9</b> recall<br>Right-click to move / attack critters.'; | |
| frag.appendChild(info); | |
| } else if(ctxn.type==='unit-info'){ | |
| const info=document.createElement('div');info.className='info-card';info.style.gridColumn='1/5'; | |
| info.innerHTML='Right-click a <b>crystal field</b> to harvest.<br>Right-click ground to move.<br><b>S</b> Stop · <b>H</b> Hold.'; | |
| frag.appendChild(info); | |
| } else { | |
| const info=document.createElement('div');info.className='info-card';info.style.gridColumn='1/5'; | |
| info.innerHTML=roleText(ctxn.b)+'.<br>'+(BLDG_DEFS[ctxn.b.type].power?('Power '+(BLDG_DEFS[ctxn.b.type].power>0?'+':'')+BLDG_DEFS[ctxn.b.type].power):''); | |
| frag.appendChild(info); | |
| } | |
| grid.replaceChildren(frag); | |
| } | |
| function buildBtn(act,type,hk,def){ | |
| const b=document.createElement('div');b.className='card-btn';b.dataset.act=act;b.dataset.val=type; | |
| b.dataset.tip=type; | |
| const cv=document.createElement('canvas');cv.width=26;cv.height=26;cv.className='ico'; | |
| drawIcon(cv,act,type); | |
| b.appendChild(cv); | |
| const nm=document.createElement('div');nm.className='nm';nm.textContent=def.name.replace(' Soldier','').replace('Battle ',''); | |
| b.appendChild(nm); | |
| b.insertAdjacentHTML('beforeend','<span class="hk">'+hk+'</span><span class="cost">'+def.cost+'</span><span class="stk"></span>'); | |
| return b; | |
| } | |
| function drawIcon(cv,act,type){ | |
| const c=cv.getContext('2d');c.clearRect(0,0,26,26);c.save();c.translate(13,14); | |
| if(act==='build'){c.scale(0.8,0.8);const col=BLD[type]; | |
| c.fillStyle=col.base;rr(c,-11,-10,22,20,3);c.fill();c.fillStyle=col.accent;c.fillRect(-4,-7,8,5); | |
| c.strokeStyle=TEAM.outline;c.lineWidth=1.5;rr(c,-11,-10,22,20,3);c.stroke();} | |
| else{c.scale(1.1,1.1);const u={...UNIT_DEFS[type],type,r:UNIT_DEFS[type].r*0.7,facing:-Math.PI/2,aimFacing:-Math.PI/2,cargo:60,cargoMax:150}; | |
| if(type==='harvester')drawHarvester(u);else if(type==='rifleman')drawRifleman(u); | |
| else if(type==='scout')drawScout(u);else if(type==='rocket')drawRocket(u);else if(type==='tank')drawTank(u);} | |
| c.restore(); | |
| } | |
| function updateCardStates(ctxn){ | |
| const grid=el['card-grid'];const btns=grid.querySelectorAll('.card-btn'); | |
| if(ctxn.type==='build-cc'){ | |
| btns.forEach(btn=>{ | |
| const type=btn.dataset.val,def=BLDG_DEFS[type],slot=G.buildSlots[type]; | |
| let locked=type==='warfactory'&&!G.hasBarracks; | |
| btn.classList.toggle('locked',locked); | |
| const building=slot&&slot.building,ready=slot&&slot.ready; | |
| btn.classList.toggle('building',!!building); | |
| btn.classList.toggle('ready',!!ready); | |
| if(building)btn.style.setProperty('--p',slot.progress); | |
| btn.classList.toggle('disabled',!locked&&!building&&!ready&&G.credits<def.cost); | |
| const nm=btn.querySelector('.nm');if(ready)nm.textContent='READY'; | |
| else nm.textContent=def.name.replace(' Plant','').replace(' Factory',' Fac.').replace('Watchtower','Tower'); | |
| }); | |
| } else if(ctxn.type.startsWith('train')){ | |
| const b=ctxn.b; | |
| btns.forEach(btn=>{ | |
| const type=btn.dataset.val,def=UNIT_DEFS[type]; | |
| let locked=type==='rocket'&&!G.hasWarFactory; | |
| btn.classList.toggle('locked',locked); | |
| btn.classList.toggle('disabled',!locked&&G.credits<def.cost); | |
| const stk=btn.querySelector('.stk'); | |
| const n=b.queue.filter(q=>q===type).length; | |
| stk.textContent=n>0?n:''; | |
| const lead=b.queue[0]===type; | |
| btn.style.setProperty('--p',lead?b.prodT:0); | |
| btn.classList.toggle('building',lead&&b.prodT>0); | |
| }); | |
| } | |
| } | |
| function updateQueue(ctxn){ | |
| const q=el['prod-queue']; | |
| let queue=null,prog=0; | |
| if(ctxn.b&&ctxn.b.queue){queue=ctxn.b.queue;prog=ctxn.b.prodT;} | |
| const sig=queue?queue.join(','):''; | |
| if(sig!==hudCache.queueSig){ | |
| hudCache.queueSig=sig; | |
| const frag=document.createDocumentFragment(); | |
| if(queue)for(let i=0;i<queue.length;i++){const pip=document.createElement('div'); | |
| pip.className='pip'+(i===0?' active':'');frag.appendChild(pip);} | |
| q.replaceChildren(frag); | |
| } | |
| if(queue&&queue.length){const lead=q.firstChild;if(lead)lead.style.setProperty('--p',prog);} | |
| } | |
| let toastT=0; | |
| function toast(msg,cls){ | |
| const layer=document.getElementById('toast-layer'); | |
| const t=document.createElement('div');t.className='toast'+(cls?' '+cls:'');t.textContent=msg; | |
| layer.appendChild(t); | |
| setTimeout(()=>{t.style.transition='opacity .4s';t.style.opacity='0';setTimeout(()=>t.remove(),400);},1800); | |
| while(layer.children.length>4)layer.firstChild.remove(); | |
| } | |
| function showVictory(){ | |
| const vb=el['victory-banner'];el['vb-sub'].textContent='All objectives complete · '+fmtClock(G.wonAt); | |
| vb.classList.add('show'); | |
| setTimeout(()=>{vb.style.transition='opacity 1s';vb.style.opacity='0'; | |
| setTimeout(()=>{vb.classList.remove('show');vb.style.opacity='';vb.style.transition='';},1000);},5000); | |
| } | |
| /* ----- tooltips ----- */ | |
| let lastTipEl=null; | |
| document.addEventListener('mousemove',e=>{ | |
| const t=e.target.closest('[data-tip]'); | |
| const tip=el['tooltip']; | |
| if(!t){if(lastTipEl){tip.classList.remove('show');lastTipEl=null;}return;} | |
| if(t!==lastTipEl){lastTipEl=t; | |
| const type=t.dataset.tip; | |
| const def=BLDG_DEFS[type]||UNIT_DEFS[type];if(!def){tip.classList.remove('show');return;} | |
| let lock=''; | |
| if(type==='warfactory'&&!G.hasBarracks)lock='Requires Barracks'; | |
| if(type==='rocket'&&!G.hasWarFactory)lock='Requires War Factory'; | |
| tip.innerHTML='<div class="tt-name">'+def.name+' <span class="tt-cost">'+def.cost+'cr</span></div>'+ | |
| '<div class="tt-desc">'+tipDesc(type)+'</div>'+(lock?'<div class="tt-lock">🔒 '+lock+'</div>':''); | |
| tip.classList.add('show'); | |
| } | |
| const tip2=el['tooltip']; | |
| tip2.style.transform='translate('+(e.clientX+14)+'px,'+(e.clientY-10-tip2.offsetHeight)+'px)'; | |
| }); | |
| function tipDesc(type){const m={ | |
| power:'Generates +100 power. Build these to keep production at full speed.', | |
| refinery:'Resource drop-off. Comes with a free Harvester. Build forward, near crystal.', | |
| barracks:'Trains infantry: Rifleman, Scout, Rocket Soldier.', | |
| warfactory:'Builds Harvesters and Battle Tanks. Needs a Barracks first.', | |
| watchtower:'Cheap defense with very wide vision. Great for scouting expansions.', | |
| harvester:'Gathers crystal and ferries it to the nearest drop-off.', | |
| rifleman:'Cheap all-round infantry.', | |
| scout:'Very fast with the widest sight. Reveal the map.', | |
| rocket:'High-damage, long-range infantry. Needs a War Factory.', | |
| tank:'Heavy armor, big punch.', | |
| };return m[type]||'';} | |
| /* ===== S15 MAIN LOOP + BOOT ====================================== */ | |
| let lastT=0,acc=0; | |
| function frame(now){ | |
| requestAnimationFrame(frame); | |
| if(!lastT)lastT=now; | |
| let frameDt=(now-lastT)/1000;lastT=now; | |
| if(frameDt>MAX_FRAME)frameDt=MAX_FRAME; | |
| if(!G.paused)G.sessionSec+=frameDt; | |
| updateCamera(frameDt); | |
| if(!G.paused){ | |
| acc+=frameDt;let steps=0; | |
| while(acc>=SIM_DT&&steps<MAX_CATCHUP){ | |
| for(const u of G.units){u.px=u.x;u.py=u.y;} | |
| fixedUpdate(SIM_DT);acc-=SIM_DT;steps++; | |
| } | |
| if(steps===MAX_CATCHUP)acc=0; | |
| } | |
| const alpha=G.paused?1:clamp(acc/SIM_DT,0,1); | |
| draw(alpha); | |
| mmAccum+=frameDt; | |
| if(mmAccum>0.1||G.cam.moved||G.fog.dirty){mmAccum=0;G.cam.moved=false;drawMinimap(now);} | |
| updateHUD(frameDt); | |
| } | |
| function resize(){ | |
| const dpr=Math.min(devicePixelRatio||1,2); | |
| const w=canvas.clientWidth,h=canvas.clientHeight; | |
| canvas.width=(w*dpr)|0;canvas.height=(h*dpr)|0;DPR=dpr; | |
| G.cam.vw=w;G.cam.vh=h;clampCamTarget();clampCam();G.cam.moved=true; | |
| } | |
| function init(){ | |
| generateTerrain(); | |
| initFog();initFX();initObjectives(); | |
| bakeTerrain(); | |
| resize(); | |
| // pre-place Command Center | |
| const cc=addBuilding(makeBuilding('cc',START_TX,START_TY,true)); | |
| // starting units | |
| const sx=tileCenter(START_TX+1),sy=tileCenter(START_TY+3); | |
| for(let i=0;i<2;i++){const h=addUnit(makeUnit('harvester',sx-20+i*40,sy+10,'player'));h.order='harvest';h.harvState=null;} | |
| const sc=addUnit(makeUnit('scout',sx+30,sy+20,'player')); | |
| recomputePower();recomputeTech(); | |
| // initial fog around base | |
| G.fog.recompute();G.fog.dirty=false; | |
| for(let i=0;i<N_TILES;i++)if(G.fog.visTarget[i])G.fog.visible[i]=255; | |
| // critters | |
| for(let i=0;i<CRITTER_COUNT;i++)spawnCritter(); | |
| // camera on CC | |
| centerCameraOn(cc.x,cc.y); | |
| // events | |
| canvas.addEventListener('mousedown',onMouseDown); | |
| window.addEventListener('mousemove',onMouseMove); | |
| window.addEventListener('mouseup',onMouseUp); | |
| canvas.addEventListener('wheel',onWheel,{passive:false}); | |
| canvas.addEventListener('dblclick',onDblClick); | |
| canvas.addEventListener('contextmenu',e=>e.preventDefault()); | |
| canvas.addEventListener('mouseleave',()=>{G.input.insideCanvas=false;}); | |
| window.addEventListener('keydown',onKeyDown); | |
| window.addEventListener('keyup',onKeyUp); | |
| window.addEventListener('resize',resize); | |
| window.addEventListener('blur',()=>{G.input.keys=Object.create(null);}); | |
| // objectives panel collapse | |
| document.getElementById('obj-head').addEventListener('click',()=>{ | |
| el['obj-list'].classList.toggle('collapsed');}); | |
| document.querySelector('#minimap-panel'); | |
| requestAnimationFrame(frame); | |
| } | |
| window.addEventListener('load',init); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment