/* ============================================================ Wildlife Kingdom — js/input.js Mouse + touch controls · tool painting · placement ghosts · selection picking · camera pan/zoom · hotkeys ============================================================ */ (function(){ 'use strict'; const WK = window.WK; const D = WK.Data; class Input{ constructor(canvas,game,cam,renderer,ui){ this.canvas=canvas; this.game=game; this.cam=cam; this.renderer=renderer; this.ui=ui; this.pointers=new Map(); this.drag=null; // {mode:'pan'|'paint', lastX,lastY,lastTile} this.pinch=null; this.keys=new Set(); this.spaceHeld=false; this.hover=null; // {x,y} this.hoverEdge=null; // {x,y,edge} this._bind(); } /* ---------------- tool application ---------------- */ toolValid(cat,item,x,y){ const g=this.game, w=g.world; if(!w.inBounds(x,y)) return false; const k=w.idx(x,y); switch(cat){ case 'ground':{ const def=D.GROUND[item]; return def.tier<=g.researchTier && w.gid(x,y)!==item && w.occ[k]<0; } case 'paths': return D.PATH[item].tier<=g.researchTier && !D.GROUND[w.gid(x,y)].water && w.occ[k]<0 && w.pid(x,y)!==item; case 'nature':{ const def=D.NATURE[item]; return def.tier<=g.researchTier && w.occ[k]<0 && !D.GROUND[w.gid(x,y)].water; } case 'fences': return true; case 'animals':{ const sp=D.SPECIES[item]; if(sp.tier>g.researchTier) return false; const chk=g.validHabitatAt(x,y); if(chk.error) return false; return chk.region.area>=sp.minArea && g.habitatScoreFor(chk.region.id,item)>=50; } case 'facilities':{ const def=D.BUILDING[item]; if(def.tier>g.researchTier) return false; return w.canPlaceBuilding(x,y,def.w,def.h); } } return false; } edgeFromEvent(sx,sy){ const t=this.cam.screenToTile(sx,sy); if(!this.game.world.inBounds(t.x,t.y)) return null; const p=this.cam.project(t.x,t.y); const lx=(sx-p.x)/WK.HW, ly=(sy-p.y)/WK.HH; // distances to four diamond sides const dSE=1-(lx+ly), dSW=1+(lx-ly), dNW=1+(lx+ly), dNE=1-(lx-ly); const m=Math.min(dSE,dSW,dNW,dNE); const w=this.game.world; if(m===dSE) return {x:t.x,y:t.y,edge:'S'}; if(m===dSW) return w.inBounds(t.x-1,t.y)?{x:t.x-1,y:t.y,edge:'E'}:{x:t.x,y:t.y,edge:'S'}; if(m===dNW) return w.inBounds(t.x-1,t.y)?{x:t.x-1,y:t.y,edge:'E'}:null; return w.inBounds(t.x,t.y-1)?{x:t.x,y:t.y-1,edge:'S'}:null; } applyAt(h){ const ui=this.ui, g=this.game; if(ui.bulldoze){ if(this.hoverEdge&&g.tryBulldozeEdge(this.hoverEdge.x,this.hoverEdge.y,this.hoverEdge.edge)) return; g.tryBulldoze(h.x,h.y); return; } const t=ui.tool; if(!t) return; switch(t.cat){ case 'ground': g.tryGround(h.x,h.y,t.item); break; case 'paths': g.tryPath(h.x,h.y,t.item); break; case 'nature': g.tryNature(h.x,h.y,t.item); break; case 'fences': if(this.hoverEdge) g.tryFence(this.hoverEdge.x,this.hoverEdge.y,this.hoverEdge.edge,t.item); break; case 'animals': g.tryAdopt(t.item,h.x,h.y); break; case 'facilities': if(g.tryBuilding(t.item,h.x,h.y)){ /* tool stays active for combos */ } break; } } applyLine(a,b){ /* Wall-aware drag painting. Iso fence topology: S-edges chain over +x ("\") · E-edges chain over +y ("/"). So a drag paints the SIDE edge of each visited tile → a straight, fully-connected wall hugging the swept line. */ let x=a.x,y=a.y; let guard=80; const w=this.game.world; const fenceMode=(this.ui.tool&&this.ui.tool.cat==='fences')||this.ui.bulldoze; while(guard--&&(x!==b.x||y!==b.y)){ const movedX=b.x!==x; if(b.x>x) x++; else if(b.xy) y++; else y--; if(!w.inBounds(x,y)){ continue; } if(fenceMode){ this.hoverEdge=movedX?{x,y,edge:'S'}:{x,y,edge:'E'}; } this.applyAt({x,y}); } this.hoverEdge=null; } /* ---------------- picking ---------------- */ pick(sx,sy){ const g=this.game, cam=this.cam; let best=null,bd=34*34; const consider=(ent,rad)=>{ const p=cam.project(ent.x,ent.y); const dd=(p.x-sx)**2+(p.y-sy+14)**2; if(dd=0){ const reg=g.world.regions.find(r=>r.id===rid); if(reg&®.openEdges===0&&!reg.hasPath&®.area>=8){ this.ui.showSelection({kind:'habitat',regionId:rid,x:t.x,y:t.y}); return true; } } this.ui.hideSelection(); return false; } /* ---------------- events ---------------- */ _bind(){ const cv=this.canvas; cv.addEventListener('pointerdown',e=>{ WK.AudioSys.ensure(); cv.setPointerCapture(e.pointerId); this.pointers.set(e.pointerId,{x:e.offsetX,y:e.offsetY}); if(this.pointers.size===2){ this._startPinch(); return; } const sx=e.offsetX,sy=e.offsetY; const wantPan = e.button===1||e.button===2||this.spaceHeld|| (e.pointerType==='touch'&&(!this.ui.tool&&!this.ui.bulldoze)); if(wantPan){ this.drag={mode:'pan',lastX:sx,lastY:sy}; cv.style.cursor='grabbing'; return; } if(e.button===2){ this.ui.cancelTool(); return; } const t=this.cam.screenToTile(sx,sy); if(!this.game.world.inBounds(t.x,t.y)) return; this.hoverEdge=this.toolActiveFence()?this.edgeFromEvent(sx,sy):null; if(this.ui.tool||this.ui.bulldoze){ this.drag={mode:'paint',lastTile:t,startTile:t}; this.applyAt(t); }else{ this.pick(sx,sy); } }); cv.addEventListener('pointermove',e=>{ const sx=e.offsetX,sy=e.offsetY; if(this.pointers.has(e.pointerId)) this.pointers.set(e.pointerId,{x:sx,y:sy}); if(this.pinch&&this.pointers.size===2){ this._movePinch(); return; } const t=this.cam.screenToTile(sx,sy); this.hover=t; this.hoverEdge=this.toolActiveFence()?this.edgeFromEvent(sx,sy):null; if(!this.drag) return; if(this.drag.mode==='pan'){ this.cam.panBy(sx-this.drag.lastX,sy-this.drag.lastY); this.drag.lastX=sx; this.drag.lastY=sy; }else{ if(!this.game.world.inBounds(t.x,t.y)) return; if(t.x!==this.drag.lastTile.x||t.y!==this.drag.lastTile.y){ if(e.buttons&2){ } this.applyLine(this.drag.lastTile,t); this.drag.lastTile=t; } } }); const up=e=>{ this.pointers.delete(e.pointerId); if(this.pointers.size<2) this.pinch=null; if(this.drag&&this.pointers.size===0){ this.drag=null; cv.style.cursor=this.ui.tool?'crosshair':'grab'; } }; cv.addEventListener('pointerup',up); cv.addEventListener('pointercancel',up); cv.addEventListener('pointerleave',e=>{ this.hover=null; }); cv.addEventListener('contextmenu',e=>e.preventDefault()); cv.addEventListener('wheel',e=>{ e.preventDefault(); const f=Math.pow(1.0016,-e.deltaY); this.cam.zoomAt(e.offsetX,e.offsetY,f); },{passive:false}); window.addEventListener('keydown',e=>{ if(e.target&&(e.target.tagName==='INPUT')) return; this.keys.add(e.key); if(e.code==='Space'){ this.spaceHeld=true; e.preventDefault(); } const ui=this.ui; switch(e.key){ case 'Escape': ui.cancelTool(); break; case 'x': case 'X': ui.toggleBulldoze(); break; case ' ': ui.togglePause(); e.preventDefault(); break; case '1': ui.setSpeed(1); break; case '2': ui.setSpeed(2); break; case '3': ui.setSpeed(4); break; case '+': case '=': this.cam.zoomAt(this.cam.w/2,this.cam.h/2,1.15); break; case '-': this.cam.zoomAt(this.cam.w/2,this.cam.h/2,0.87); break; } }); window.addEventListener('keyup',e=>{ this.keys.delete(e.key); if(e.code==='Space') this.spaceHeld=false; }); } toolActiveFence(){ return (this.ui.tool&&this.ui.tool.cat==='fences')||(this.ui.bulldoze); } _startPinch(){ const pts=[...this.pointers.values()]; this.pinch={ d:Math.hypot(pts[0].x-pts[1].x,pts[0].y-pts[1].y), mx:(pts[0].x+pts[1].x)/2, my:(pts[0].y+pts[1].y)/2, }; this.drag=null; } _movePinch(){ const pts=[...this.pointers.values()]; if(pts.length<2) return; const d=Math.hypot(pts[0].x-pts[1].x,pts[0].y-pts[1].y); const mx=(pts[0].x+pts[1].x)/2, my=(pts[0].y+pts[1].y)/2; const f=WK.clamp(d/Math.max(20,this.pinch.d),0.5,2); this.cam.zoomAt(mx,my,f); this.cam.panBy(mx-this.pinch.mx,my-this.pinch.my); this.pinch={d,mx,my}; } update(dt){ // keyboard panning const sp=520*dt/this.cam.zoom; let dx=0,dy=0; if(this.keys.has('ArrowLeft')||this.keys.has('a')||this.keys.has('A')) dx-=sp; if(this.keys.has('ArrowRight')||this.keys.has('d')||this.keys.has('D')) dx+=sp; if(this.keys.has('ArrowUp')||this.keys.has('w')||this.keys.has('W')) dy-=sp; if(this.keys.has('ArrowDown')||this.keys.has('s')||this.keys.has('S')) dy+=sp; if(dx||dy) this.cam.panBy(dx,dy); } /* overlay hook for renderer */ overlay(ctx,cam,range){ const ui=this.ui; if(ui.bulldoze){ if(this.hover){ const p=cam.project(this.hover.x,this.hover.y); WK.Sprites.diamondPath(ctx,p.x,p.y,1); ctx.fillStyle='rgba(230,80,70,.25)'; ctx.fill(); ctx.strokeStyle='rgba(230,80,70,.9)'; ctx.lineWidth=2; ctx.stroke(); if(this.hoverEdge){ const h=this.hoverEdge; const p2=cam.project(h.x,h.y); const zz=cam.zoom; let ax,ay,bx,by; if(h.edge==='S'){ ax=p2.x-WK.HW*zz; ay=p2.y; bx=p2.x; by=p2.y+WK.HH*zz; } else{ ax=p2.x; ay=p2.y+WK.HH*zz; bx=p2.x+WK.HW*zz; by=p2.y; } ctx.lineWidth=7; ctx.beginPath(); ctx.moveTo(ax,ay-6); ctx.lineTo(bx,by-6); ctx.stroke(); } } return; } const t=ui.tool; if(!t||!this.hover) return; const h=this.hover; const valid=this.toolValid(t.cat,t.item,h.x,h.y); let g={x:h.x,y:h.y,valid}; if(t.cat==='facilities'){ g.mode='foot'; g.item=t.item; } else if(t.cat==='fences'){ if(this.hoverEdge){ g={...this.hoverEdge,mode:'edge',valid:true}; } else g=null; } else{ g.mode='tile'; if(t.cat==='nature') g.sprite=WK.Sprites.getObject(t.item,0); else if(t.cat==='animals'){ g.sprite=null; // animal ghost: draw mini animal const p=cam.project(h.x,h.y); WK.Sprites.diamondPath(ctx,p.x,p.y,1); ctx.fillStyle=valid?'rgba(140,230,150,.28)':'rgba(230,90,80,.3)'; ctx.fill(); ctx.strokeStyle=valid?'rgba(110,220,120,.85)':'rgba(230,80,70,.9)'; ctx.lineWidth=2; ctx.stroke(); ctx.save(); ctx.translate(p.x,p.y); ctx.globalAlpha=.85; const sc=(WK.ANIMAL_SIZE[t.item]||1)*(valid?1:0.8); ctx.scale(sc,sc); WK.Sprites.drawAnimal(ctx,t.item,{state:'idle',ph:0,t:this.game.timeAbs}); ctx.restore(); return; } } this.renderer.drawGhost(ctx,cam,g); } } WK.Input = Input; })();