; ; /* ============================================================ INPUT — touch camera, selection, orders (mobile-first) ============================================================ */ const ptrs=new Map(); let boxMode=false, boxStart=null, pinchD=0, pinchZ=1, tapT=0, pinchA=0, pinchYaw=0; let pinchMY=0, pinchPitch=0.92; let openBld=-1; // building whose menu is open let shake=0; /* CAMERA AUTHORITY. Anything that moves the camera automatically has to yield to the player the moment they touch it. Without that, panning away to scout a landing site or to look at a fight fought the auto-follow every frame and the view was dragged straight back — which reads as the camera, the ship or the units snapping back. `camUser()` is called from every manual camera input and suspends automatic movement for a few seconds afterwards. */ let camUserT=0; function camUser(){ camUserT=4.0; } function zoomSpanMax(){ return typeof spanMaxNow==='function'?spanMaxNow():SPAN_MAX; } function zoomSpanMin(){ return typeof spanMinNow==='function'?spanMinNow():200; } function camAutoAllowed(){ return camUserT<=0; } function camAuthTick(dt){ if(camUserT>0) camUserT-=dt; } /* Tapping the ground while the dropship is still airborne orders it there and keeps the camera with it, so flying the ship never means losing sight of it — but only while the player is not driving the camera themselves. */ function carrierFollow(){ if(!camAutoAllowed()) return; if(carrier.active&&carrier.phase<2){ cam.x+=(carrier.x-cam.x)*0.22; cam.y+=(carrier.y-cam.y)*0.22; // glide, never teleport clampCam(); camUpdateMatrices(); } } function selCount(){ let n=0; for(let i=0;i{ const A=TYPES[utype[a]],B=TYPES[utype[b]]; const ar=(A.air?900:0)+(A.naval?500:0)+(utype[a]===19?700:0)+(A.rng||0)-A.size*3; const br=(B.air?900:0)+(B.naval?500:0)+(utype[b]===19?700:0)+(B.rng||0)-B.size*3; return ar-br||a-b; }); return out; } /* FORMATION TIGHTENING. Spacing is decided per order; if the platoon forms up with the enemy already inside `FORM_TIGHT_R`, the spread shrinks so the whole line reaches the fight at once instead of trickling in single-file. It is a cluster, not a stack: unitSeparation in sim.js still enforces physical separation, so the tighter goals just make the wall denser on contact. */ const FORM_TIGHT_R=170, FORM_TIGHT_MUL=0.72; function formationSpacing(sel){ let sp=31,cx2=0,cy2=0; for(const i of sel){ const T=TYPES[utype[i]]; /* The baked 3D card is rendered at 2.05x logical `size`. Collision radii alone therefore produce legal but visibly interpenetrating formations. This clearance tracks the opaque silhouette while retaining a bounded footprint for mixed or Titan platoons. */ sp=Math.max(sp,Math.max(T.r*2+7,T.size*1.8+8)); cx2+=ux[i];cy2+=uy[i]; } if(sel.length){ cx2/=sel.length; cy2/=sel.length; } let near=false; forUnitsIn(cx2,cy2,FORM_TIGHT_R,j=>{ if(!near&&ualive[j]&&uteam[j]!==0) near=true; }); if(near) sp*=FORM_TIGHT_MUL; return clamp(sp,near?24:31,92); } function formationOffsets(n,form,spacing){ const out=[]; const sp=spacing||31; if(form==='line'){ const perRow=Math.max(2,Math.min(18,Math.ceil(n/2))); for(let k=0;k1){ sampled=[]; for(let k=0;k{ let x=clamp(wx-o[1]*ct-o[0]*st,15,MAP-15),y=clamp(wy-o[1]*st+o[0]*ct,15,MAP-15); if(typeof battlefieldClampPoint==='function'){const p=battlefieldClampPoint(x,y,15);x=p[0];y=p[1];} return {x,y}; }); } function setFormation(n,quiet){ selFormation=((n%FORMS.length)+FORMS.length)%FORMS.length; const F=FORMS[selFormation], b=document.getElementById('formBtn'); if(b){b.querySelector('.em').textContent=F.em;b.querySelector('span:nth-child(2)').textContent=F.nm;} if(!quiet) toast(F.em+' '+F.nm+' armed — drag the hologram and release to place '+selCount()+' units'); } function armFormationOrder(){ if(!selCount()){toast('Select units first');return;} setFormation(selFormation+1,false);armFormation=true; const b=document.getElementById('formBtn');if(b)b.classList.add('on'); sfx('ui'); } /* ATTACK-MOVE vs MOVE. Every order used to be attack-move: `ustate=2`, acquire anything on the way. The simulation has always supported a plain reposition state (`ustate===1`, which suppresses acquisition and drops back to idle on arrival) — it was simply never written by anything. Without it there is no retreat, no repositioning under fire and no walking past a fight, which is the highest frequency tactical decision in the genre. `moveMode` is the player's toggle; double-tap/click on empty ground forces a one-shot retreat without flipping it. `orderMove` honours both. */ let moveMode=0; // 0 = attack-move, 1 = move only function toggleMoveMode(){ moveMode=moveMode?0:1; const b=document.getElementById('moveBtn'); if(b){ b.classList.toggle('on',!!moveMode); const l=b.querySelector('.lbl'); if(l) l.textContent=moveMode?'MOVE':'A-MOVE'; } toast(moveMode?'➤ MOVE — units reposition without stopping to fight' :'⚔ ATTACK-MOVE — units engage what they meet'); sfx('ui'); } function orderMove(wx,wy,patrol,retreat){ const sel=formationMembers(); if(!sel.length) return false; if(typeof battlefieldClampPoint==='function'){const p=battlefieldClampPoint(wx,wy,24);wx=p[0];wy=p[1];} const landGoal=findLand(wx,wy),waterGoal=findWater(wx,wy); const fld=requestField(landGoal[0],landGoal[1],false); const navalFld=waterGoal?requestField(waterGoal[0],waterGoal[1],true):-1; const form=FORMS[selFormation].id; const targets=formationTargets(sel,wx,wy,form); const cohort=patrol?-1:allocMoveCohort(sel,targets,selFormation); /* Retreat/move-only is ustate 1: sim skips acquisition and will not chase a leftover lock. Attack-move stays ustate 2. Double-tap ground passes `retreat` so the A-MOVE toggle is not required to break contact. */ const moveOnly=!patrol&&(retreat||moveMode); for(let k=0;kdestination beam - the traced route above replaces it, and drawing both would show two contradictory paths whenever the field bends around water. */ orderConfirm={x:wx,y:wy,members:sel.slice(),form:selFormation, until:performance.now()+950,noLine:1}; } markStopDisp(false); if(retreat) toast('➤ RETREAT — dropping combat'); uiCommandAck(patrol?'patrol':(retreat?'retreat':'move'),sel.length,wx,wy); updateSelInfo(); return true; } function patrolButtonState(){ const b=document.getElementById('patrolBtn');if(!b)return; b.classList.toggle('on',!!armPatrol); const l=b.querySelector('span:nth-child(2)'); if(l)l.textContent=armPatrol&&patrolDraft&&patrolDraft.pts.length>1?'START':'PATROL'; } function beginPatrolDraft(){ const sel=formationMembers(); if(!sel.length){toast('Select units first');return false;} let x=0,y=0;for(const i of sel){x+=ux[i];y+=uy[i];} patrolDraft={members:sel.map(i=>[i,ugen[i]]),pts:[{x:x/sel.length,y:y/sel.length}],form:selFormation}; armPatrol=true;armFormation=false;orderPreview=null;orderConfirm=null;patrolButtonState(); toast('PATROL PLAN — tap up to 5 waypoints, then tap START');sfx('ui');return true; } function addPatrolWaypoint(wx,wy){ if(!patrolDraft)return; let px=clamp(wx,20,MAP-20),py=clamp(wy,20,MAP-20); if(typeof battlefieldClampPoint==='function'){const q=battlefieldClampPoint(px,py,24);px=q[0];py=q[1];} const p={x:px,y:py}; const last=patrolDraft.pts[patrolDraft.pts.length-1]; if(Math.hypot(p.x-last.x,p.y-last.y)<35){toast('Place the next waypoint farther along the route');return;} if(patrolDraft.pts.length>=6){toast('Route has 5 waypoints — tap START');return;} patrolDraft.pts.push(p);patrolButtonState(); addParticle(3,p.x,p.y,0,0,.55,28,120,255,190); toast('Waypoint '+(patrolDraft.pts.length-1)+' placed — add another or tap START');sfx('confirm'); } function cancelPatrolDraft(quiet){ armPatrol=false;patrolDraft=null;patrolButtonState(); if(!quiet){toast('Patrol plan cancelled');sfx('reject');} } function patrolTargetRows(sel,pts,formId){ const form=FORMS[formId].id,targets=[]; for(let j=0;jualive[e[0]]&&ugen[e[0]]===e[1]&&uteam[e[0]]===0&&uPatrolRoute[e[0]]===ri); if(!live.length){patrolRoutes[ri]=null;return false;} if(force||live.length!==R.members.length){ const sel=live.map(e=>e[0]);R.members=live;R.targets=patrolTargetRows(sel,R.pts,R.form); const step=R.step==null?1:R.step; for(let k=0;k38?.16:lead>150?.50:lead>78?.74:1; } R.legT=(R.legT||0)+dt; const need=live<=6?live:Math.ceil(live*.84); R.waitT=arrived>=need?(R.waitT||0)+dt:0; if(arrived===live||R.waitT>=1.1||(R.legT>22&&arrived>=Math.ceil(live*.6))){ R.step=(step+1)%R.targets.length;R.legT=0;R.waitT=0; const next=R.targets[R.step]; for(const e of R.members){ const i=e[0];if(!ualive[i]||ugen[i]!==e[1]||uPatrolRoute[i]!==ri)continue; const raw=next[uPatrolSlot[i]];if(!raw)continue; const P=domainOrderPoint(i,raw); uPatrolStep[i]=R.step;utx[i]=P.x;uty[i]=P.y;ufield[i]=P.field; } R.pulse=performance.now()+1100; } } } function commitPatrolDraft(){ if(!patrolDraft||patrolDraft.pts.length<2){cancelPatrolDraft(false);return false;} const sel=[],refs=[]; for(const e of patrolDraft.members){ if(ualive[e[0]]&&ugen[e[0]]===e[1]&&uteam[e[0]]===0){sel.push(e[0]);refs.push(e);} } if(!sel.length){cancelPatrolDraft(false);return false;} const pts=patrolDraft.pts.slice(),targets=patrolTargetRows(sel,pts,patrolDraft.form); const ri=patrolRoutes.length; patrolRoutes.push({pts,targets,form:patrolDraft.form,members:refs,step:1,legT:0,waitT:0,gcT:.45,created:performance.now()}); for(let k=0;k1)commitPatrolDraft();else cancelPatrolDraft(false); } /* ---------- control groups (save with long-press, recall with tap) ---------- */ /* Groups store the slot AND its generation. Storing the bare index meant that when a member died and its slot was refilled, the group silently adopted the stranger that replaced it. */ const ctrlGroups=[[],[],[],[]],groupForms=[0,0,0,0]; let activePlatoon=-1; const groupLive=g=>g.filter(e=>ualive[e[0]]&&ugen[e[0]]===e[1]&&uteam[e[0]]===0); function saveGroup(n){ const g=[]; for(let i=0;i=0){ ex=ux[target]; ey=uy[target]; } else { const B=blds[-2-target]; ex=B.x; ey=B.y; } for(let i=0;i=0?ugen[target]:-1; /* Explicit attack is chase, not attack-move. A leftover march flag would keep walking the old ground click while the order said "that unit". */ utx[i]=ex;uty[i]=ey;uhold[i]=0;umarch[i]=0;uPatrolRoute[i]=-1;uMoveCohort[i]=-1; queueClear(i);uGuard[i]=-1;any=true; } if(any){ markStopDisp(false); addParticle(3,ex,ey,0,0,.5,30, 255,90,70); uiCommandAck('attack',selCount(),ex,ey); updateSelInfo(); } return any; } /* ============================================================================ GUARD (escort) + QUEUED WAYPOINTS — both touch-first. ---------------------------------------------------------------------------- Neither order gets a desktop-only affordance. Shift-click exists here only as an accelerator on top of the touch path, never as the way in. WHY THE ORDERS DECK ONLY GAINS ONE BUTTON. #tacRow is five controls wide and #primaryRow already proves six is the ceiling inside the phone dock's minimap-bay padding at 412px (6*46 + 5*3 = 291px of 292px available). A seventh control would push PATROL under the minimap. So the queue planner — which genuinely needs a persistent, stateful control, because it is a mode with a commit — takes the one remaining slot, and GUARD rides the long press, the only touch gesture that can name a FRIENDLY target without also re-selecting it. WHY THE BUTTON IS BUILT HERE AND NOT IN index.html / ui.css. The in-match HUD markup and stylesheet have another owner mid-pass. Creating the control at load and appending it to #tacRow is the same trick main.js's ensureCamControl already uses for OTA shells that predate a button: it inherits the row's deck visibility, safe-area padding and .cbtn styling for free, and the HUD owner can move or restyle it later without touching orders code. ============================================================================ */ function guardLabel(h){ if(h>=0) return TYPES[utype[h]].name; const B=blds[-2-h]; return (B&&BT[B.type]&&BT[B.type].name)||'structure'; } function orderGuard(h,quiet){ if(!guardEntityLive(h,h>=0?ugen[h]:-1)) return false; let n=0; for(let i=0;i=0?ugen[h]:-1; utgt[i]=-1; utgtg[i]=-1; uhold[i]=0; umarch[i]=0; uPatrolRoute[i]=-1; uMoveCohort[i]=-1; ufield[i]=-1; n++; } if(!n) return false; const P=guardEntityPos(h); addParticle(3,P[0],P[1],0,0,.6,Math.max(34,P[2]*2.4), 120,255,190); if(!quiet) toast('⛨ GUARD — '+n+' unit'+(n===1?'':'s')+' escorting '+guardLabel(h)); markStopDisp(false); uiCommandAck('guard',n,P[0],P[1]); updateSelInfo(); return true; } let armQueue=false, queueDraft=null, guardFxT=0; function queueButtonState(){ const b=ensureQueueBtn(); if(!b) return; const n=queueDraft?queueDraft.steps.length:0; b.classList.toggle('on',!!armQueue); const l=b.querySelector('.lbl'); if(l) l.textContent=armQueue?(n?'GO '+n:'TAP…'):'QUEUE'; } function ensureQueueBtn(){ let b=document.getElementById('queueBtn'); if(b) return b; const row=document.getElementById('tacRow'); if(!row) return null; b=document.createElement('button'); b.type='button'; b.id='queueBtn'; b.className='cbtn'; b.setAttribute('aria-label','Chain waypoints for the selected units'); b.innerHTML='QUEUE'; /* Before CLEAR so cancelling the selection stays the last thing in the row. */ row.insertBefore(b,document.getElementById('clearBtn')||null); b.addEventListener('pointerdown',ev=>{ ev.preventDefault(); toggleQueuePlanner(); }); return b; } function beginQueueDraft(){ const sel=formationMembers(); if(!sel.length){ toast('Select units first'); sfx('reject'); return false; } if(armPatrol) cancelPatrolDraft(true); armFormation=false; orderPreview=null; orderConfirm=null; const fb=document.getElementById('formBtn'); if(fb) fb.classList.remove('on'); queueDraft={members:sel.map(i=>[i,ugen[i]]),steps:[],form:selFormation,fx:null,fxT:0}; armQueue=true; queueButtonState(); toast('⇢ ORDER QUEUE — tap ground, an enemy, or one of your own, or drag a path, up to ' +QUEUE_MAX+' orders, then tap GO'); sfx('ui'); return true; } function cancelQueueDraft(quiet){ if(queueDraft) queueFxDrop(queueDraft); armQueue=false; queueDraft=null; queueButtonState(); if(!quiet){ toast('Order queue cancelled'); sfx('reject'); } } /* A queued chain has to stay on the ground BETWEEN taps, and both route renderers only know how to draw `patrolDraft`. Rather than reach into files the HUD and graphics owners hold, the chain is published as an order-FX polyline — src/ui/orderfx.js already draws those as marching chevrons — with a lifetime the planner keeps refreshing. Straight legs are honest here: the player placed the nodes, and each leg's real flow route is only solved when that node becomes the live order. */ function queueFxPush(sx,sy,steps,until){ if(typeof moveFxList==='undefined'||!steps.length) return null; const pts=[{x:sx,y:sy}]; for(const s of steps) pts.push({x:s.x,y:s.y}); let len=0; const arc=[0]; for(let k=1;kMOVE_FX_MAX) moveFxList.shift(); return fx; } function queueFxDrop(D){ if(!D||!D.fx||typeof moveFxList==='undefined') return; const k=moveFxList.indexOf(D.fx); if(k>=0) moveFxList.splice(k,1); D.fx=null; } function queueAddStep(wx,wy,pk,quiet){ const D=queueDraft; if(!D) return; const last=D.steps[D.steps.length-1]; if(last&&last.t===2){ if(!quiet){ toast('GUARD ends the chain — tap GO'); sfx('reject'); } return; } if(D.steps.length>=QUEUE_MAX){ if(!quiet){ toast('Queue is full — tap GO to execute'); sfx('reject'); } return; } let px=clamp(wx,20,MAP-20),py=clamp(wy,20,MAP-20); if(typeof battlefieldClampPoint==='function'){ const q=battlefieldClampPoint(px,py,24); px=q[0]; py=q[1]; } let step,what; const inDraft=h=>{ for(const e of D.members) if(e[0]===h) return true; return false; }; const b=pickBld(wx,wy); if(pk&&pk.enemy>=0){ step={t:1,x:ux[pk.enemy],y:uy[pk.enemy],h:pk.enemy,g:ugen[pk.enemy],mv:0}; what='ATTACK '+TYPES[utype[pk.enemy]].name; } else if(pk&&pk.own>=0&&!inDraft(pk.own)){ step={t:2,x:ux[pk.own],y:uy[pk.own],h:pk.own,g:ugen[pk.own],mv:0}; what='GUARD '+TYPES[utype[pk.own]].name; } else if(b>=0&&blds[b].team!==0){ const B=blds[b]; step={t:1,x:B.x,y:B.y,h:-2-b,g:-1,mv:0}; what='ATTACK '+guardLabel(-2-b); } else if(b>=0&&!quiet){ /* Drag-path samples skip friendly structures: a stroke across your own yard must not turn into a GUARD of the HQ. A deliberate TAP still can. */ const B=blds[b]; step={t:2,x:B.x,y:B.y,h:-2-b,g:-1,mv:0}; what='GUARD '+guardLabel(-2-b); } else { step={t:0,x:px,y:py,h:-1,g:-1,mv:moveMode?1:0}; what=moveMode?'MOVE':'ATTACK-MOVE'; } D.steps.push(step); queueFxDrop(D); D.fxT=0; // polyline changed: rebuild next tick addParticle(3,step.x,step.y,0,0,.55,30, 150,220,255); queueButtonState(); if(quiet) return; toast('⇢ '+D.steps.length+'. '+what+(step.t===2?' — final step':'')+' · add more or tap GO'); sfx('confirm'); } function commitQueueDraft(){ const D=queueDraft; if(!D||!D.steps.length){ cancelQueueDraft(false); return false; } const sel=[]; for(const e of D.members) if(ualive[e[0]]&&ugen[e[0]]===e[1]&&uteam[e[0]]===0) sel.push(e[0]); if(!sel.length){ cancelQueueDraft(false); return false; } let cx=0,cy=0; for(const i of sel){ cx+=ux[i]; cy+=uy[i]; } cx/=sel.length; cy/=sel.length; /* One formation row per ground node, headed along the leg — the same solve patrol legs use — so a queued route arrives in formation at every waypoint instead of piling the whole force onto the pixel that was tapped. */ const form=FORMS[D.form].id, rows=[]; let px=cx,py=cy; for(const s of D.steps){ rows.push(s.t===0?formationTargets(sel,s.x,s.y,form,Math.atan2(s.y-py,s.x-px)):null); px=s.x; py=s.y; } for(let k=0;krows[q] ?{t:0,x:rows[q][k].x,y:rows[q][k].y,h:-1,g:-1,mv:s.mv} :{t:s.t,x:s.x,y:s.y,h:s.h,g:s.g,mv:s.mv}); uhold[i]=0; uPatrolRoute[i]=-1; uMoveCohort[i]=-1; utgt[i]=-1; utgtg[i]=-1; /* Start node one immediately. queueTick would otherwise wait for the unit to "arrive" at whatever its previous order was still pointing at. */ queueNext(i); } const n=sel.length, nodes=D.steps.length, fx0=D.steps[0]; queueFxDrop(D); queueFxPush(cx,cy,D.steps,performance.now()+MOVE_FX_LIFE*1.8); armQueue=false; queueDraft=null; queueButtonState(); toast('⇢ '+n+' unit'+(n===1?'':'s')+' executing a '+nodes+'-step order queue'); uiCommandAck('patrol',n,fx0.x,fx0.y); // "Route uploaded" return true; } function toggleQueuePlanner(){ if(!armQueue){ beginQueueDraft(); return; } if(queueDraft&&queueDraft.steps.length) commitQueueDraft(); else cancelQueueDraft(false); } /* Draft upkeep and the guard read-out. Rides tickPatrolRoutes (below) because sim.js already calls that once per simulation tick and neither of these deserves a second timer. */ function tickOrderPlanning(dt){ if(armQueue&&queueDraft){ const D=queueDraft; let cx=0,cy=0,n=0; for(const e of D.members) if(ualive[e[0]]&&ugen[e[0]]===e[1]){ cx+=ux[e[0]]; cy+=uy[e[0]]; n++; } /* Every drafted unit is gone — including the resetWorld case, where the whole army dies at once. Cancelling here is why the planner needs no hook in main.js's reset. */ if(!n) cancelQueueDraft(true); else { D.fxT-=dt; if(D.fxT<=0){ D.fxT=.5; if(D.fx&&moveFxList.indexOf(D.fx)<0) D.fx=null; // newer orders shifted it out if(!D.fx) D.fx=queueFxPush(cx/n,cy/n,D.steps,performance.now()+6e5); else D.fx.until=performance.now()+6e5; for(const s of D.steps) addParticle(3,s.x,s.y,0,0,.5,26, 150,220,255); } } } guardFxT-=dt; if(guardFxT>0) return; guardFxT=1.4; /* One soft ring per guarded thing, so an escort order stays legible on the ground after its confirmation particle has gone. Deliberately slow and capped: this is a read-out, not a per-unit effect. */ const seen=[]; for(let i=0;i=0||!guardEntityLive(h,uGuardG[i])) continue; seen.push(h); const P=guardEntityPos(h); addParticle(3,P[0],P[1],0,0,.75,Math.max(30,P[2]*2.2), 110,235,190); } } function pickUnit(wx,wy){ const pickR=Math.max(16,orthoSpan*0.012, (typeof mfIconStackOn==='function'&&mfIconStackOn()&&typeof mfIconStackCell==='function') ?mfIconStackCell()*0.55:0); let best=-1,bd=pickR*pickR, bestEnemy=-1,bde=pickR*pickR; forUnitsIn(wx,wy,pickR,j=>{ const d=dist2(wx,wy,ux[j],uy[j]); if(uteam[j]===0){ if(d=0) best=ownSt; const enSt=mfIconStackPick(wx,wy,1); if(enSt>=0&&bestEnemy<0) bestEnemy=enSt; } return {own:best, enemy:bestEnemy}; } function pickBld(wx,wy){ for(let b=0;b0 rejects the page-load false positive (tnow-0<500 during the first half second of a reload). */ const GROUND_DBL_MS=500, GROUND_DBL_PX=80; function groundDoubleTap(sx,sy){ return lastGroundT>0 && performance.now()-lastGroundT=0){ const bi2=armRally; armRally=-1; if(blds[bi2]&&blds[bi2].alive){ let rx=clamp(wx,20,MAP-20),ry=clamp(wy,20,MAP-20); if(typeof battlefieldClampPoint==='function'){const p=battlefieldClampPoint(rx,ry,24);rx=p[0];ry=p[1];} blds[bi2].rally={x:rx,y:ry}; addParticle(3,wx,wy,0,0,.5,30, 120,255,170); toast('⚑ Rally point set — new units will gather there'); sfx('ui'); } return; } // pre-deployment: taps steer the carrier to your chosen landing ground if(carrier.active&&carrier.phase===1){ let cx=clamp(wx,60,MAP-60),cy=clamp(wy,60,MAP-60); if(typeof battlefieldClampPoint==='function'){const p=battlefieldClampPoint(cx,cy,80);cx=p[0];cy=p[1];} carrier.tx=cx; carrier.ty=cy; addParticle(3,carrier.tx,carrier.ty,0,0,.5,32, 120,220,255); uiCommandAck('deploy',1,carrier.tx,carrier.ty); return; } if(carrier.active) return; // still falling — ignore taps const pk=pickUnit(wx,wy); const haveSel=selCount()>0; /* Queue planning owns every map tap while it is armed — including taps on units and structures, which is what makes "chain an attack then an escort" reachable with one thumb. It is checked before selection so a tap on a friendly appends a GUARD step instead of throwing the draft's roster away. */ if(armQueue && queueDraft){ queueAddStep(wx,wy,pk); return; } /* DESKTOP ACCELERATOR, NOT THE WAY IN. Shift-click arms the same planner and appends to it, and releasing Shift commits (see the keyup below). It is a shortcut onto the touch machinery — there is deliberately no order here that a phone cannot reach. */ if(lastTapShift && haveSel && !armPatrol && !placing && beginQueueDraft()){ queueDraft.viaShift=true; queueAddStep(wx,wy,pk); return; } // patrol order (armed from the tactics bar) if(armPatrol && haveSel){ addPatrolWaypoint(wx,wy); return; } if(armPatrol)cancelPatrolDraft(true); // enemy tapped → attack order if(pk.enemy>=0 && haveSel){ lastGroundT=0; orderAttack(pk.enemy); return; } /* WHY: pickUnit's radius at command camera is often larger than the empty ground between selected hulls. A double-tap meant as retreat hit an own unit, reset lastGroundT, and became select-all. If the first tap was a ground order, the second contact at the same screen point is retreat — even when a friendly silhouette is inside the pick disc. Enemy / building taps still win so this cannot steal attack or a factory menu. */ if(haveSel && groundDoubleTap(sx,sy) && pk.enemy<0 && pickBld(wx,wy)<0){ stampGroundTap(sx,sy,true); orderMove(wx,wy,false,true); return; } // own unit tapped → select (double-tap = select all of that type on screen) if(pk.own>=0){ lastGroundT=0; const tnow=performance.now(); if(tnow-lastSelT<430 && utype[pk.own]===lastSelType){ const b2=camBounds(); let n2=0; clearSel(); for(let i=0;i=b2.x0&&ux[i]<=b2.x1&&uy[i]>=b2.y0&&uy[i]<=b2.y1){ usel[i]=1; n2++; } toast('⚔ '+n2+'× '+TYPES[lastSelType].name+' selected — camera locked on'); lastSelT=0; camFollow=pk.own; camFollowT=2.4; // glide in and track it distTarget=clamp(Math.max(orthoSpan*0.5,zoomSpanMin()),zoomSpanMin(),zoomSpanMax()); // close on the unit, into tactical mesh updateSelInfo(); uiCommandAck('select',n2,ux[pk.own],uy[pk.own]); closeMenus(); return; } lastSelT=tnow; lastSelType=utype[pk.own]; if(typeof mfIconStackSkip==='function'&&mfIconStackSkip(pk.own) &&typeof mfIconStackSelect==='function'&&mfIconStackSelect(pk.own)){ uiCommandAck('select',selCount(),ux[pk.own],uy[pk.own]); closeMenus(); return; } clearSel(); usel[pk.own]=1; updateSelInfo(); uiCommandAck('select',1,ux[pk.own],uy[pk.own]); closeMenus(); return; } // building tapped const b=pickBld(wx,wy); if(b>=0){ lastGroundT=0; const B=blds[b]; if(B.team===0){ clearSel(); openBldMenu(b); return; } else if(haveSel){ orderAttack(-2-b); return; } } // ground → move/attack-move; double-tap empty ground = retreat (C&C3 / SupCom2) if(haveSel){ const retreat=groundDoubleTap(sx,sy); stampGroundTap(sx,sy,retreat); orderMove(wx,wy,false,retreat); return; } closeMenus(); } let dragGhost=false, holdTimer=0; /* One threshold for tap vs intel-card hold. A press that lasted 400–520 ms used to do nothing: onTap required <400 while the card armed at 520, so a careful aim in a crowd produced no order and no card. Shorter than this is a tap; reaching it is the hold. Keep 520 so expert long-press feel is unchanged — only the dead zone closes. */ const HOLD_MS=520; /* Thumb contact jitter. 9px `moved` is below a real finger; 220ms is a tap. */ const TAP_JITTER_MS=220, TAP_JITTER_PX=28; cv.addEventListener('pointerdown',e=>{ try{ cv.setPointerCapture(e.pointerId); }catch(_){} const rec={x:e.clientX,y:e.clientY,sx:e.clientX,sy:e.clientY,moved:false,held:false, shift:!!e.shiftKey,t:performance.now()}; ptrs.set(e.pointerId,rec); if(aiming===5&&ptrs.size===1){ const [bwx,bwy]=s2w(e.clientX,e.clientY);setArtBarragePreview(bwx,bwy); clearTimeout(holdTimer);return; } /* Double-tap retreat is decided on pointerdown, not pointerup. WHY the previous path never fired on packed 8901: 1. Formation preview stole the second contact and called orderMove without `retreat`, and never wrote lastGroundT. 2. pickUnit at command camera treated "empty" ground next to the army as a unit tap and reset lastGroundT (select-all, not retreat). 3. A 9px jitter marked the first contact `moved`, so onTap never ran and lastGroundT was never written — the second tap was a single attack-move. The 260ms radio gate is audio-only (audio.js already exempts retreat) and was not the order failure. War Table leftovers are display:none during a match and do not receive canvas pointers. Queue / patrol keep the tap: those modes own the map. */ if(ptrs.size===1 && !placing && !boxMode && running && selCount() && !armQueue && !armPatrol && aiming<0 && groundDoubleTap(e.clientX,e.clientY)){ const [wx,wy]=s2w(e.clientX,e.clientY); const pk=pickUnit(wx,wy); if(pk.enemy<0 && pickBld(wx,wy)<0){ rec.held=true; rec.retreat=1; armFormation=false; orderPreview=null; const fb=document.getElementById('formBtn'); if(fb) fb.classList.remove('on'); stampGroundTap(e.clientX,e.clientY,true); orderMove(wx,wy,false,true); clearTimeout(holdTimer); return; } } if(ptrs.size===1&&armFormation&&!placing&&!boxMode&&running&&selCount()){ const [wx,wy]=s2w(e.clientX,e.clientY); const pp=typeof battlefieldClampPoint==='function'?battlefieldClampPoint(wx,wy,24):[clamp(wx,15,MAP-15),clamp(wy,15,MAP-15)]; orderConfirm=null; orderPreview={pid:e.pointerId,x:pp[0],y:pp[1], members:formationMembers(),form:selFormation}; clearTimeout(holdTimer);return; } // long-press on a unit/building → intel card clearTimeout(holdTimer); if(ptrs.size===1 && !placing && !boxMode && running){ const [hwx,hwy]=s2w(e.clientX,e.clientY); holdTimer=setTimeout(()=>{ const p=ptrs.get(e.pointerId); if(!p||p.moved||p.held||ptrs.size!==1) return; const pk2=pickUnit(hwx,hwy); const ui2=pk2.own>=0?pk2.own:pk2.enemy; const bi2=ui2<0?pickBld(hwx,hwy):-1; /* GUARD rides the long press (see the orders block above for why it gets no dock button). It only fires on a FRIENDLY that is not already part of the selection, so the intel card keeps every other long press: nothing selected, an enemy, neutral ground, or a unit you already hold. "Press something of mine while holding a force" has no other meaning. */ const ownBld=bi2>=0&&blds[bi2]&&blds[bi2].team===0; const ownUnit=pk2.own>=0&&!usel[pk2.own]; if(selCount()&&(ownUnit||ownBld)&&!armQueue&&!armPatrol&&!placing){ p.held=true; if(orderGuard(ownUnit?pk2.own:-2-bi2)){ if(typeof buzz==='function') buzz(12); return; } } if(ui2>=0||bi2>=0){ p.held=true; showUnitCard(ui2,bi2); sfx('ui'); } },HOLD_MS); } if(placing&&ptrs.size===1){ // drag near the ghost moves it; anywhere else pans the camera const [gx2,gy2]=w2s(placing.x,placing.y); dragGhost=Math.hypot(e.clientX-gx2,e.clientY-gy2)<90; } if(ptrs.size===2){ const a=[...ptrs.values()]; pinchD=Math.hypot(a[0].x-a[1].x,a[0].y-a[1].y); pinchZ=orthoSpan; pinchMY=(a[0].y+a[1].y)/2; pinchPitch=pitchTarget; pinchA=Math.atan2(a[1].y-a[0].y,a[1].x-a[0].x); pinchYaw=yawTarget; } if(ptrs.size===1 && boxMode && !placing){ boxStart=[e.clientX,e.clientY]; } }); cv.addEventListener('pointermove',e=>{ const p=ptrs.get(e.pointerId); if(!p) return; if(p.retreat) return; const dx=e.clientX-p.x, dy=e.clientY-p.y; p.x=e.clientX; p.y=e.clientY; if(Math.hypot(e.clientX-p.sx,e.clientY-p.sy)>9) p.moved=true; if(aiming===5&&ptrs.size===1){ const [bwx,bwy]=s2w(e.clientX,e.clientY);setArtBarragePreview(bwx,bwy);return; } if(orderPreview&&orderPreview.pid===e.pointerId){ const [wx,wy]=s2w(e.clientX,e.clientY); const pp=typeof battlefieldClampPoint==='function'?battlefieldClampPoint(wx,wy,24):[clamp(wx,15,MAP-15),clamp(wy,15,MAP-15)]; orderPreview.x=pp[0];orderPreview.y=pp[1];return; } if(armQueue&&queueDraft&&ptrs.size===1&&!placing&&!boxStart){ /* Drag-path while QUEUE is armed. Camera pan is the default one-finger stroke; stealing it here is the point — a queued route that still panned the map could not be drawn with a thumb. Sub-28px motion is still a tap (onTap needs !moved), so a careful waypoint does not become a pan. Samples are ground waypoints only. */ if(Math.hypot(e.clientX-p.sx,e.clientY-p.sy)>28) p.qDrag=true; if(!p.qDrag){ p.moved=false; return; } p.moved=true; const [wx,wy]=s2w(e.clientX,e.clientY); const last=queueDraft.steps[queueDraft.steps.length-1]; if(!last||dist2(wx,wy,last.x,last.y)>=110*110) queueAddStep(wx,wy,null,true); return; } if(ptrs.size===1){ if(placing&&dragGhost){ /* Drag the ghost by ray-picking the ground under the finger, so it tracks the pointer exactly regardless of camera angle or tilt. */ const [gwx,gwy]=s2w(e.clientX,e.clientY); placing.rx=clamp(gwx,40,MAP-40); placing.ry=clamp(gwy,40,MAP-40); snapPlace(); } else if(placing){ const [pw0,ph0]=s2w(e.clientX-dx,e.clientY-dy), [pw1,ph1]=s2w(e.clientX,e.clientY); cam.x-=pw1-pw0; cam.y-=ph1-ph0; clampCam(); camUpdateMatrices(); } else if(boxStart){ const bx=document.getElementById('selbox'); const x0=Math.min(boxStart[0],e.clientX), y0=Math.min(boxStart[1],e.clientY); bx.style.display='block'; bx.style.left=x0+'px'; bx.style.top=y0+'px'; bx.style.width=Math.abs(e.clientX-boxStart[0])+'px'; bx.style.height=Math.abs(e.clientY-boxStart[1])+'px'; } else { /* Ground-anchored pan: the world point under your finger stays under your finger. With a perspective camera a fixed pixels-per-unit pan drifts badly when the ground is far away or steeply tilted. */ const [pw0,ph0]=s2w(e.clientX-dx,e.clientY-dy), [pw1,ph1]=s2w(e.clientX,e.clientY); cam.x-=pw1-pw0; cam.y-=ph1-ph0; camFollow=-1; camUser(); clampCam(); camUpdateMatrices(); } } else if(ptrs.size===2){ const a=[...ptrs.values()]; const d=Math.hypot(a[0].x-a[1].x,a[0].y-a[1].y); if(pinchD>0){ const mx=(a[0].x+a[1].x)/2, my=(a[0].y+a[1].y)/2; const [wx0,wy0]=s2w(mx,my); // pinch = dolly the eye in and out along its own view ray camFollow=-1; camUser(); orthoSpan=clamp(pinchZ*pinchD/Math.max(1,d),zoomSpanMin(),zoomSpanMax()); distTarget=orthoSpan; // twist the same two fingers to orbit — 0.12 rad so a zoom pinch does not spin const ang=Math.atan2(a[1].y-a[0].y,a[1].x-a[0].x); let da=ang-pinchA; while(da>Math.PI)da-=TAU; while(da<-Math.PI)da+=TAU; if(Math.abs(da)>0.12){ yawTarget=pinchYaw-da; camYaw=yawTarget; } // slide both fingers up/down together to raise or drop the eye const dyc=my-pinchMY; if(Math.abs(dyc)>14){ pitchTarget=clamp(pinchPitch+dyc*0.0022,PITCH_MIN,PITCH_MAX); camPitch=pitchTarget; } clampCam(); camUpdateMatrices(); const [wx1,wy1]=s2w(mx,my); cam.x+=wx0-wx1; cam.y+=wy0-wy1; clampCam(); camUpdateMatrices(); } } }); function endPtr(e){ clearTimeout(holdTimer); const p=ptrs.get(e.pointerId); ptrs.delete(e.pointerId); if(!p) return; if(aiming===5&&ptrs.size===0){ if(e.type==='pointercancel')cancelArtilleryBarrageAim(true);else onTap(p.x,p.y); return; } if(orderPreview&&orderPreview.pid===e.pointerId){ const P=orderPreview;orderPreview=null;armFormation=false; const fb=document.getElementById('formBtn');if(fb)fb.classList.remove('on'); if(e.type!=='pointercancel'){ orderMove(P.x,P.y,false); /* Stamp the release as a ground tap so a quick second tap still retreats. Formation preview never went through onTap, which is how an armed FORM button swallowed double-tap retreat. */ stampGroundTap(p.x,p.y,false); /* Keep the exact count-based placement visible briefly after release. On touch screens the finger otherwise covered the only preview frame, making a valid formation order appear to have no visual response. */ orderConfirm={x:P.x,y:P.y,members:P.members.slice(),form:P.form,until:performance.now()+950}; } return; } if(boxStart && ptrs.size===0){ // finish box select const bx=document.getElementById('selbox'); bx.style.display='none'; const boxStartX=boxStart[0], boxStartY=boxStart[1]; boxStart=null; boxMode=false; document.getElementById('boxBtn').classList.remove('on'); const sx0=Math.min(boxStartX,p.x), sx1=Math.max(boxStartX,p.x); const sy0=Math.min(boxStartY,p.y), sy1=Math.max(boxStartY,p.y); if(sx1-sx0>10||sy1-sy0>10){ clearSel(); let n=0; /* Stage 0: walk every live slot and project to screen. At cap this is the army-select cost. Stage 1 should query a spatial hash for the world AABB of the box instead of testing unitHigh. */ for(let i=0;i=sx0&&sp[0]<=sx1&&sp[1]>=sy0&&sp[1]<=sy1){ usel[i]=1; n++; } } if(n) uiCommandAck('select',n); updateSelInfo(); return; } } /* WHY: 9px `moved` is below thumb jitter. The first contact of a double-tap was classified as a pan, lastGroundT was never written, and the second tap could only ever be a single attack-move. A short press that drifted less than a fingertip is still a tap. Real pans are longer strokes. `performance.now()-p.t{ if(!running||placing||boxMode||!selCount()||armQueue||armPatrol||aiming>=0) return; e.preventDefault(); if(!groundDoubleTap(e.clientX,e.clientY)) return; const [wx,wy]=s2w(e.clientX,e.clientY); if(pickUnit(wx,wy).enemy>=0||pickBld(wx,wy)>=0) return; stampGroundTap(e.clientX,e.clientY,true); orderMove(wx,wy,false,true); }); cv.addEventListener('wheel',e=>{ e.preventDefault(); const [wx0,wy0]=s2w(e.clientX,e.clientY); camFollow=-1; camUser(); orthoSpan=clamp(orthoSpan*(e.deltaY<0?0.87:1.15),zoomSpanMin(),zoomSpanMax()); distTarget=orthoSpan; clampCam(); camUpdateMatrices(); const [wx1,wy1]=s2w(e.clientX,e.clientY); cam.x+=wx0-wx1; cam.y+=wy0-wy1; clampCam(); camUpdateMatrices(); },{passive:false}); /* Browser page-zoom (Ctrl+wheel, pinch on HUD, Ctrl+/-) scales the DOM instead of the ortho camera. The canvas handler above only covers #gl; overlays used to let pinch through. Kill page-zoom; game zoom stays on #gl. */ addEventListener('wheel',e=>{ if(e.ctrlKey||e.metaKey) e.preventDefault(); },{passive:false,capture:true}); addEventListener('gesturestart',e=>e.preventDefault(),{passive:false}); addEventListener('gesturechange',e=>e.preventDefault(),{passive:false}); addEventListener('keydown',e=>{ if(!(e.ctrlKey||e.metaKey)) return; const k=e.key, c=e.code; if(k==='+'||k==='-'||k==='='||k==='0'||c==='NumpadAdd'||c==='NumpadSubtract') e.preventDefault(); },true); /* A match can end while a finger is still captured by the battlefield canvas. Android then keeps that pointer sequence alive across the menu transition; the next apparent tap completes the old gesture instead of activating a menu button. Every route back to the front end calls this before rebuilding the attract scene, so input starts from a genuinely idle state. */ function resetInputState(){ clearTimeout(holdTimer); for(const id of ptrs.keys()){ try{ if(cv.hasPointerCapture(id)) cv.releasePointerCapture(id); }catch(e){} } ptrs.clear(); boxMode=false; boxStart=null; pinchD=0; dragGhost=false; armFormation=false; orderPreview=null; orderConfirm=null; if(typeof aiming!=='undefined'&&aiming>=0){ if(aiming===5&&typeof cancelArtilleryBarrageAim==='function')cancelArtilleryBarrageAim(true); else aiming=-1; } if(armPatrol) cancelPatrolDraft(true); if(armQueue) cancelQueueDraft(true); lastGroundT=0; const sb=document.getElementById('selbox'); if(sb) sb.style.display='none'; const bb=document.getElementById('boxBtn'); if(bb) bb.classList.remove('on'); const fb=document.getElementById('formBtn'); if(fb) fb.classList.remove('on'); } addEventListener('keydown',e=>{ if(e.key!=='Tab'||!running||typeof recallGroup!=='function') return; const t=e.target, tag=t&&t.tagName; if(tag==='INPUT'||tag==='TEXTAREA'||(t&&t.isContentEditable)) return; e.preventDefault(); let start=activePlatoon<0?0:activePlatoon+1; for(let k=0;k<4;k++){ const n=(start+k)%4; if(groupLive(ctrlGroups[n]).length){ recallGroup(n,!!e.shiftKey); return; } } }); /* Releasing Shift executes a chain that Shift started, which is the behaviour a desktop RTS player already has in their hands. A chain armed from the QUEUE button is untouched: it waits for GO, because a touch player has no Shift to release. */ addEventListener('keyup',e=>{ if(e.key!=='Shift'||!armQueue||!queueDraft||!queueDraft.viaShift) return; if(queueDraft.steps.length) commitQueueDraft(); else cancelQueueDraft(true); }); /* Build the control now rather than on first use: the orders row is hidden until something is selected, and a button that only appears after its first press cannot be discovered. */ ensureQueueBtn(); queueButtonState(); // ---------- minimap ---------- const mmc=document.getElementById('minimap'); function mmNav(e){ const r=mmc.getBoundingClientRect(); cam.x=(e.clientX-r.left)/r.width*MAP; cam.y=(e.clientY-r.top)/r.height*MAP; camFollow=-1; camUser(); clampCam(); camUpdateMatrices(); } mmc.addEventListener('pointerdown',e=>{ e.stopPropagation(); mmNav(e); mmc.setPointerCapture(e.pointerId); }); mmc.addEventListener('pointermove',e=>{ if(e.buttons) mmNav(e); });