Files
deepseek c06786518c Wildlife Kingdom — original browser zoo-management sim
- Procedural canvas art: 15 species × 4 poses, 15 buildings, modular terrain, UI icons
- Habitat enclosure detection, biome suitability, staff/guest AI pathfinding
- Economy: tickets, shops, wages, star rating, research tiers, 15 missions
- Day/night cycle, particles, WebAudio soundtrack & synth SFX
- Mouse + touch controls, responsive UI, localStorage autosave
- Verified: 31/31 logic tests (test/smoke.js) · 12/12 render audits (test/qa.html)
2026-08-23 07:01:21 +00:00

330 lines
12 KiB
JavaScript

/* ============================================================
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.x<x) x--;
else if(b.y>y) 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<bd*(rad||1)){ best=ent; bd=dd; }
};
bd=34*34; for(const a of g.animals) consider(a,(a.size||1)*1.3);
bd=26*26; for(const st of g.staff) consider(st,1);
bd=22*22; for(const gu of g.guests) consider(gu,1);
if(best){ this.ui.showSelection(best); return true; }
const t=cam.screenToTile(sx,sy);
const b=g.world.buildingAt(t.x,t.y);
if(b){ this.ui.showSelection(b); return true; }
// habitat summary
g.world.ensureRegions();
const rid=g.world.regionOf(t.x,t.y);
if(rid>=0){
const reg=g.world.regions.find(r=>r.id===rid);
if(reg&&reg.openEdges===0&&!reg.hasPath&&reg.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;
})();