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