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)
This commit is contained in:
+329
@@ -0,0 +1,329 @@
|
||||
/* ============================================================
|
||||
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&®.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;
|
||||
})();
|
||||
Reference in New Issue
Block a user