Last active
March 12, 2026 20:28
-
-
Save amchercashin/d404334603d5bbb56e41d2d850081149 to your computer and use it in GitHub Desktop.
🍺 Нескучное Пиво — stealth game in Neskuchny Garden, Moscow
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="ru"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"> | |
| <meta name="apple-mobile-web-app-capable" content="yes"> | |
| <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> | |
| <title>Нескучное Пиво | Нескучный Сад</title> | |
| <style> | |
| *{margin:0;padding:0;box-sizing:border-box} | |
| html,body{height:100%;overflow:hidden;overscroll-behavior:none;-webkit-overflow-scrolling:none} | |
| body{background:#0f0f1a;display:flex;justify-content:center;align-items:center;height:100dvh;font-family:system-ui,-apple-system,sans-serif; | |
| -webkit-touch-callout:none;-webkit-user-select:none;user-select:none; | |
| padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)} | |
| canvas{max-width:100vw;max-height:100dvh;touch-action:none;-webkit-tap-highlight-color:transparent} | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="c"></canvas> | |
| <script> | |
| const C=document.getElementById('c'),X=C.getContext('2d'); | |
| const WORLD_W=1400,WORLD_H=900; | |
| const isMobile=/iPhone|iPad|iPod|Android/i.test(navigator.userAgent)||('ontouchstart'in window&&navigator.maxTouchPoints>1); | |
| // ===== CANVAS SIZING ===== | |
| const cam={x:200,y:80,zoom:1,active:false}; | |
| function isPortrait(){return window.innerHeight>window.innerWidth*1.15} | |
| function resizeCanvas(){ | |
| const w=window.innerWidth,h=window.innerHeight; | |
| if(isPortrait()){ | |
| C.width=700; | |
| C.height=Math.round(700*h/w); | |
| }else{ | |
| C.width=WORLD_W; | |
| C.height=WORLD_H; | |
| } | |
| } | |
| window.addEventListener('resize',resizeCanvas); | |
| window.addEventListener('orientationchange',()=>setTimeout(resizeCanvas,100)); | |
| resizeCanvas(); | |
| // ===== Seeded RNG ===== | |
| let _s=12345; | |
| function rng(){_s=(_s*16807)%2147483647;return(_s-1)/2147483646} | |
| // ===== MAP DATA ===== | |
| const PATHS=[ | |
| [[1220,30],[1220,870]], | |
| [[680,60],[675,220],[680,400],[690,600],[685,800],[680,870]], | |
| [[120,60],[150,200],[200,350],[180,500],[250,650],[350,800],[500,860]], | |
| [[150,200],[400,180],[680,180],[1000,180],[1220,180]], | |
| [[200,420],[450,400],[680,400],[1000,400],[1220,400]], | |
| [[250,650],[500,640],[690,640],[1000,650],[1220,650]], | |
| [[500,130],[620,100],[730,130],[730,250],[620,280],[500,250],[500,130]], | |
| [[400,560],[550,540],[620,610],[500,660],[400,620],[400,560]], | |
| [[200,350],[400,350],[530,340]], | |
| [[180,500],[400,500],[530,490]], | |
| [[680,800],[900,850],[1220,860]], | |
| [[120,60],[680,60]], | |
| [[500,860],[680,870]] | |
| ]; | |
| const LANDMARKS=[ | |
| {x:615,y:190,name:'Зелёный театр',icon:'🎭'}, | |
| {x:500,y:600,name:'Александринский дворец',icon:'🏛️'}, | |
| {x:350,y:400,name:'Минеральный источник',icon:'⛲'}, | |
| {x:400,y:35,name:'← Парк Горького',icon:''}, | |
| {x:80,y:860,name:'Андреевский мост →',icon:'🌉'}, | |
| ]; | |
| const BEER_POS=[ | |
| {x:260,y:130},{x:1180,y:250},{x:620,y:160},{x:350,y:395}, | |
| {x:860,y:300},{x:130,y:520},{x:680,y:400},{x:520,y:590}, | |
| {x:1180,y:700},{x:400,y:800} | |
| ]; | |
| const BUSH_POS=[ | |
| {x:300,y:155},{x:810,y:165},{x:1100,y:210},{x:555,y:310}, | |
| {x:410,y:350},{x:260,y:425},{x:910,y:405},{x:140,y:460}, | |
| {x:595,y:475},{x:1100,y:415},{x:360,y:555},{x:750,y:595}, | |
| {x:1100,y:645},{x:210,y:710},{x:510,y:745},{x:900,y:780}, | |
| {x:800,y:105},{x:455,y:255} | |
| ]; | |
| const POLICE_ROUTES=[ | |
| {pts:[[1220,100],[1220,400],[1220,700],[1220,860]],speed:95,ping:true}, | |
| {pts:[[680,120],[680,400],[680,650],[680,800]],speed:85,ping:true}, | |
| {pts:[[300,180],[680,180],[1000,180],[1220,180]],speed:105,ping:true}, | |
| {pts:[[400,400],[680,400],[1000,400],[1000,650],[680,650],[400,650]],speed:90,ping:false} | |
| ]; | |
| // ===== GENERATE TREES ===== | |
| function distToSeg(px,py,x1,y1,x2,y2){ | |
| const dx=x2-x1,dy=y2-y1,l2=dx*dx+dy*dy; | |
| if(l2===0)return Math.hypot(px-x1,py-y1); | |
| let t=((px-x1)*dx+(py-y1)*dy)/l2; | |
| t=Math.max(0,Math.min(1,t)); | |
| return Math.hypot(px-(x1+t*dx),py-(y1+t*dy)); | |
| } | |
| function distToAnyPath(px,py){ | |
| let m=Infinity; | |
| for(const p of PATHS)for(let i=0;i<p.length-1;i++){ | |
| const d=distToSeg(px,py,p[i][0],p[i][1],p[i+1][0],p[i+1][1]); | |
| if(d<m)m=d; | |
| } | |
| return m; | |
| } | |
| function nearAny(px,py,arr,minD){ | |
| for(const a of arr)if(Math.hypot(px-a.x,py-a.y)<minD)return true; | |
| return false; | |
| } | |
| const TREES=[]; | |
| for(let i=0;i<120;i++){ | |
| let x,y,ok=false; | |
| for(let t=0;t<50;t++){ | |
| x=rng()*1150+30;y=rng()*840+30; | |
| if(x>1250)continue; | |
| if(distToAnyPath(x,y)<35)continue; | |
| if(nearAny(x,y,BEER_POS,45))continue; | |
| if(nearAny(x,y,BUSH_POS,40))continue; | |
| if(nearAny(x,y,TREES,28))continue; | |
| if(nearAny(x,y,LANDMARKS,50))continue; | |
| ok=true;break; | |
| } | |
| if(ok)TREES.push({x,y,r:10+rng()*8,sway:rng()*Math.PI*2}); | |
| } | |
| // ===== GAME STATE ===== | |
| let gameState='menu'; | |
| let gameTime=0, messageText='', messageTimer=0; | |
| const keys={}; | |
| const player={x:200,y:80,speed:190,lives:3,beers:0,hidden:false,caught:false,angle:0,wobble:0}; | |
| let beers=[], police=[]; | |
| function resetGame(){ | |
| player.x=200;player.y=80;player.lives=3;player.beers=0;player.hidden=false;player.caught=false;player.wobble=0; | |
| cam.x=200;cam.y=80; | |
| gameTime=0;messageText='';messageTimer=0; | |
| beers=BEER_POS.map(b=>({x:b.x,y:b.y,collected:false,sparkleOff:rng()*Math.PI*2})); | |
| police=POLICE_ROUTES.map(r=>({ | |
| route:r.pts,speed:r.speed,ping:r.ping, | |
| x:r.pts[0][0],y:r.pts[0][1], | |
| wpIdx:0,dir:1, | |
| state:'patrol',alertTimer:0,chaseTimer:0, | |
| detRadius:130,angle:0,speech:'',speechTimer:0 | |
| })); | |
| } | |
| // ===== INPUT: Keyboard ===== | |
| window.addEventListener('keydown',e=>{ | |
| keys[e.code]=true; | |
| if(e.code==='Space'&&gameState==='menu'){gameState='playing';resetGame()} | |
| if(e.code==='Space'&&(gameState==='gameover'||gameState==='win')){gameState='menu'} | |
| if(e.code==='KeyR'&&gameState==='caught'){respawnPlayer()} | |
| const gameKeys=['KeyW','KeyA','KeyS','KeyD','ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space','KeyR']; | |
| if(gameKeys.includes(e.code))e.preventDefault(); | |
| }); | |
| window.addEventListener('keyup',e=>{keys[e.code]=false}); | |
| function respawnPlayer(){ | |
| gameState='playing';player.caught=false;player.x=200;player.y=80; | |
| cam.x=200;cam.y=80; | |
| police.forEach(p=>{p.state='patrol';p.x=p.route[0][0];p.y=p.route[0][1];p.wpIdx=0;p.dir=1}); | |
| } | |
| // ===== INPUT: Touch ===== | |
| const joy={active:false,id:-1,sx:0,sy:0,cx:0,cy:0,dx:0,dy:0}; | |
| function touchToCanvas(t){ | |
| const r=C.getBoundingClientRect(); | |
| return{x:(t.clientX-r.left)*(C.width/r.width),y:(t.clientY-r.top)*(C.height/r.height)}; | |
| } | |
| C.addEventListener('touchstart',e=>{ | |
| e.preventDefault(); | |
| const t=e.changedTouches[0]; | |
| const p=touchToCanvas(t); | |
| if(gameState==='menu'){gameState='playing';resetGame();return} | |
| if(gameState==='gameover'||gameState==='win'){gameState='menu';return} | |
| if(gameState==='caught'){respawnPlayer();return} | |
| if(!joy.active){ | |
| joy.active=true;joy.id=t.identifier; | |
| joy.sx=p.x;joy.sy=p.y;joy.cx=p.x;joy.cy=p.y;joy.dx=0;joy.dy=0; | |
| } | |
| },{passive:false}); | |
| C.addEventListener('touchmove',e=>{ | |
| e.preventDefault(); | |
| for(const t of e.changedTouches){ | |
| if(t.identifier===joy.id&&joy.active){ | |
| const p=touchToCanvas(t); | |
| joy.cx=p.x;joy.cy=p.y; | |
| const ox=p.x-joy.sx, oy=p.y-joy.sy; | |
| const d=Math.hypot(ox,oy); | |
| const maxR=60; | |
| if(d>5){ | |
| const c=Math.min(d,maxR); | |
| joy.dx=(ox/d)*(c/maxR); | |
| joy.dy=(oy/d)*(c/maxR); | |
| }else{joy.dx=0;joy.dy=0} | |
| } | |
| } | |
| },{passive:false}); | |
| function endTouch(e){ | |
| e.preventDefault(); | |
| for(const t of e.changedTouches){ | |
| if(t.identifier===joy.id){ | |
| joy.active=false;joy.id=-1;joy.dx=0;joy.dy=0; | |
| } | |
| } | |
| } | |
| C.addEventListener('touchend',endTouch,{passive:false}); | |
| C.addEventListener('touchcancel',endTouch,{passive:false}); | |
| C.addEventListener('contextmenu',e=>e.preventDefault()); | |
| // ===== COLLISION ===== | |
| function canMove(x,y){ | |
| if(x<12||y<12||x>1388||y>888)return false; | |
| const riverEdge=1260+Math.sin(y/50)*12; | |
| if(x>riverEdge)return false; | |
| for(const t of TREES){ | |
| if(Math.hypot(x-t.x,y-t.y)<t.r+8)return false; | |
| } | |
| return true; | |
| } | |
| function isInBush(x,y){ | |
| for(const b of BUSH_POS){ | |
| if(Math.hypot(x-b.x,y-b.y)<28)return true; | |
| } | |
| return false; | |
| } | |
| // ===== UPDATE ===== | |
| function update(dt){ | |
| if(gameState!=='playing')return; | |
| gameTime+=dt; | |
| if(messageTimer>0)messageTimer-=dt; | |
| // Player movement | |
| let dx=0,dy=0; | |
| if(keys['KeyW']||keys['ArrowUp'])dy=-1; | |
| if(keys['KeyS']||keys['ArrowDown'])dy=1; | |
| if(keys['KeyA']||keys['ArrowLeft'])dx=-1; | |
| if(keys['KeyD']||keys['ArrowRight'])dx=1; | |
| if(dx&&dy){dx*=0.707;dy*=0.707} | |
| if(joy.active){dx=joy.dx;dy=joy.dy} | |
| const wobbleAmt=player.beers*0.4; | |
| if(dx||dy){ | |
| dx+=(Math.sin(gameTime*5))*wobbleAmt*0.15; | |
| dy+=(Math.cos(gameTime*4.3))*wobbleAmt*0.15; | |
| } | |
| const spd=player.speed*(player.hidden?0.5:1)*dt; | |
| const nx=player.x+dx*spd, ny=player.y+dy*spd; | |
| if(canMove(nx,player.y))player.x=nx; | |
| if(canMove(player.x,ny))player.y=ny; | |
| if(dx||dy)player.angle=Math.atan2(dy,dx); | |
| player.hidden=isInBush(player.x,player.y); | |
| player.wobble=player.beers; | |
| // Camera follow (smooth) | |
| const lerpSpd=Math.min(1,5*dt); | |
| cam.x+=(player.x-cam.x)*lerpSpd; | |
| cam.y+=(player.y-cam.y)*lerpSpd; | |
| // Collect beers | |
| const collectR=isMobile?30:22; | |
| for(const b of beers){ | |
| if(!b.collected&&Math.hypot(player.x-b.x,player.y-b.y)<collectR){ | |
| b.collected=true; | |
| player.beers++; | |
| const msgs=['Ааа, холодненькое! 🍺','Отличный глоток!','Ещё одно! 🍻','*буль-буль-буль*','Освежает!','Пенное! 🍺','Хмельное!','За здоровье!','Ух! 🍻','Последнее?!']; | |
| showMessage(msgs[player.beers-1]||'Пиво!'); | |
| if(player.beers>=beers.length){gameState='win'} | |
| } | |
| } | |
| // Police AI | |
| for(const p of police){ | |
| const pdx=player.x-p.x, pdy=player.y-p.y; | |
| const pdist=Math.hypot(pdx,pdy); | |
| if(p.state==='patrol'){ | |
| const wp=p.route[p.wpIdx]; | |
| const wx=wp[0]-p.x, wy=wp[1]-p.y, wd=Math.hypot(wx,wy); | |
| if(wd<8){ | |
| if(p.ping){ | |
| if(p.wpIdx>=p.route.length-1)p.dir=-1; | |
| if(p.wpIdx<=0)p.dir=1; | |
| p.wpIdx+=p.dir; | |
| }else{ | |
| p.wpIdx=(p.wpIdx+1)%p.route.length; | |
| } | |
| }else{ | |
| p.x+=wx/wd*p.speed*dt; | |
| p.y+=wy/wd*p.speed*dt; | |
| p.angle=Math.atan2(wy,wx); | |
| } | |
| if(!player.hidden&&pdist<p.detRadius){ | |
| p.state='alert';p.alertTimer=0.8; | |
| p.speech='❓';p.speechTimer=1.2; | |
| } | |
| }else if(p.state==='alert'){ | |
| p.alertTimer-=dt; | |
| if(player.hidden||pdist>p.detRadius*1.5){p.state='patrol';p.speech='';continue} | |
| if(p.alertTimer<=0){ | |
| p.state='chase';p.chaseTimer=0; | |
| p.speech='❗ Стой!';p.speechTimer=1.5; | |
| showMessage('⚠️ БЕГИ! Полиция!'); | |
| } | |
| }else if(p.state==='chase'){ | |
| p.chaseTimer+=dt; | |
| if(pdist>5){ | |
| const cspd=p.speed*1.4*dt; | |
| const cnx=p.x+pdx/pdist*cspd, cny=p.y+pdy/pdist*cspd; | |
| if(canMove(cnx,cny)){p.x=cnx;p.y=cny} | |
| else if(canMove(cnx,p.y)){p.x=cnx} | |
| else if(canMove(p.x,cny)){p.y=cny} | |
| p.angle=Math.atan2(pdy,pdx); | |
| } | |
| if(pdist<18){ | |
| player.lives--; | |
| if(player.lives<=0){gameState='gameover'} | |
| else{gameState='caught';player.caught=true} | |
| p.speech='Попался! 🚔';p.speechTimer=2; | |
| continue; | |
| } | |
| if(player.hidden){ | |
| p.state='searching';p.alertTimer=2.5; | |
| p.speech='Где он?..';p.speechTimer=2; | |
| } | |
| if(pdist>350||p.chaseTimer>8){ | |
| p.state='patrol';p.speech='Показалось..';p.speechTimer=2; | |
| } | |
| }else if(p.state==='searching'){ | |
| p.alertTimer-=dt; | |
| const sx=p.x+Math.sin(gameTime*3)*30*dt; | |
| const sy=p.y+Math.cos(gameTime*2.5)*30*dt; | |
| if(canMove(sx,sy)){p.x=sx;p.y=sy} | |
| if(p.alertTimer<=0){p.state='patrol';p.speech=''} | |
| if(!player.hidden&&pdist<p.detRadius*0.8){ | |
| p.state='chase';p.chaseTimer=0; | |
| p.speech='Вот ты где!';p.speechTimer=1.5; | |
| } | |
| } | |
| if(p.speechTimer>0)p.speechTimer-=dt; | |
| if(p.speechTimer<=0)p.speech=''; | |
| } | |
| } | |
| function showMessage(text){messageText=text;messageTimer=2} | |
| // ===== RENDER ===== | |
| function render(){ | |
| const t=performance.now()/1000; | |
| const portrait=isPortrait(); | |
| const cw=C.width,ch=C.height,cx=cw/2,cy=ch/2; | |
| // Setup camera for portrait | |
| cam.active=portrait&&(gameState==='playing'||gameState==='caught'); | |
| if(cam.active){ | |
| cam.zoom=2.0; | |
| const vw=cw/cam.zoom, vh=ch/cam.zoom; | |
| // Clamp camera to world bounds | |
| cam.x=Math.max(vw/2,Math.min(WORLD_W-vw/2,cam.x)); | |
| cam.y=Math.max(vh/2,Math.min(WORLD_H-vh/2,cam.y)); | |
| } | |
| // Clear | |
| X.fillStyle='#0f0f1a';X.fillRect(0,0,cw,ch); | |
| // ===== WORLD (with camera transform) ===== | |
| X.save(); | |
| if(cam.active){ | |
| X.translate(cx,cy); | |
| X.scale(cam.zoom,cam.zoom); | |
| X.translate(-cam.x,-cam.y); | |
| } | |
| // Background | |
| X.fillStyle='#3a6b30';X.fillRect(0,0,WORLD_W,WORLD_H); | |
| // Grass texture | |
| X.globalAlpha=0.15; | |
| for(let i=0;i<200;i++){ | |
| const gx=(i*97+37)%WORLD_W,gy=(i*131+53)%WORLD_H; | |
| X.fillStyle=i%2?'#4a8040':'#2d5a1e'; | |
| X.fillRect(gx,gy,3,3); | |
| } | |
| X.globalAlpha=1; | |
| // River | |
| X.beginPath();X.moveTo(WORLD_W,0); | |
| for(let y=0;y<=WORLD_H;y+=3){ | |
| const rx=1265+Math.sin(y/50+t*0.8)*12+Math.sin(y/120+t*0.3)*8; | |
| X.lineTo(rx,y); | |
| } | |
| X.lineTo(WORLD_W,WORLD_H);X.closePath(); | |
| const rg=X.createLinearGradient(1250,0,WORLD_W,0); | |
| rg.addColorStop(0,'#2d6a9f');rg.addColorStop(0.3,'#3a7db8');rg.addColorStop(1,'#1e5580'); | |
| X.fillStyle=rg;X.fill(); | |
| // River ripples | |
| X.strokeStyle='rgba(255,255,255,0.12)';X.lineWidth=1; | |
| for(let i=0;i<8;i++){ | |
| const ry=((t*30+i*120)%960)-30; | |
| const rx=1300+Math.sin(i)*20; | |
| X.beginPath();X.moveTo(rx-15,ry);X.quadraticCurveTo(rx,ry-5,rx+15,ry);X.stroke(); | |
| } | |
| // Embankment | |
| X.strokeStyle='#8a7a60';X.lineWidth=4;X.beginPath(); | |
| for(let y=0;y<=WORLD_H;y+=3){ | |
| const rx=1255+Math.sin(y/50+t*0.8)*12+Math.sin(y/120+t*0.3)*8; | |
| y===0?X.moveTo(rx-8,y):X.lineTo(rx-8,y); | |
| } | |
| X.stroke(); | |
| // Paths | |
| for(const path of PATHS){ | |
| X.beginPath();X.moveTo(path[0][0],path[0][1]); | |
| for(let i=1;i<path.length;i++)X.lineTo(path[i][0],path[i][1]); | |
| X.strokeStyle='#9a8050';X.lineWidth=24;X.lineCap='round';X.lineJoin='round';X.stroke(); | |
| X.beginPath();X.moveTo(path[0][0],path[0][1]); | |
| for(let i=1;i<path.length;i++)X.lineTo(path[i][0],path[i][1]); | |
| X.strokeStyle='#c4a97d';X.lineWidth=18;X.stroke(); | |
| } | |
| // Green Theater | |
| X.fillStyle='#5a5a6a';X.strokeStyle='#3a3a4a';X.lineWidth=2; | |
| X.beginPath();X.arc(615,190,55,Math.PI,0);X.fill();X.stroke(); | |
| X.fillStyle='#4a4a5a';X.fillRect(560,185,110,25);X.strokeRect(560,185,110,25); | |
| // Palace | |
| X.fillStyle='#6a6050';X.strokeStyle='#4a4030'; | |
| X.fillRect(460,575,80,55);X.strokeRect(460,575,80,55); | |
| X.fillStyle='#7a7060';X.fillRect(470,555,60,20);X.strokeRect(470,555,60,20); | |
| for(let i=0;i<4;i++){X.fillStyle='#8a8070';X.fillRect(468+i*20,575,4,30)} | |
| // Mineral spring | |
| X.fillStyle='#5ab0d0';X.beginPath();X.arc(350,400,10,0,Math.PI*2);X.fill(); | |
| X.strokeStyle='#3a90b0';X.lineWidth=2;X.stroke(); | |
| X.fillStyle='rgba(90,176,208,0.3)';X.beginPath();X.arc(350,400,16,0,Math.PI*2);X.fill(); | |
| // Tree shadows | |
| X.fillStyle='rgba(0,0,0,0.2)'; | |
| for(const tr of TREES){ | |
| X.beginPath();X.ellipse(tr.x+5,tr.y+5,tr.r,tr.r*0.6,0,0,Math.PI*2);X.fill(); | |
| } | |
| // Bushes | |
| for(const b of BUSH_POS){ | |
| const hl=player&&Math.hypot(player.x-b.x,player.y-b.y)<28; | |
| X.fillStyle=hl?'rgba(70,170,50,0.85)':'rgba(55,140,35,0.8)'; | |
| for(let i=0;i<5;i++){ | |
| const bx=b.x+Math.cos(i*1.26)*14,by=b.y+Math.sin(i*1.26)*10; | |
| X.beginPath();X.arc(bx,by,12+Math.sin(t+i)*1.5,0,Math.PI*2);X.fill(); | |
| } | |
| if(hl){ | |
| X.strokeStyle='rgba(100,255,80,0.4)';X.lineWidth=2; | |
| X.beginPath();X.arc(b.x,b.y,30,0,Math.PI*2);X.stroke(); | |
| } | |
| } | |
| // Trees | |
| for(const tr of TREES){ | |
| const sw=Math.sin(t*0.8+tr.sway)*2; | |
| X.fillStyle='#2a5a1a'; | |
| X.beginPath();X.arc(tr.x+sw,tr.y,tr.r,0,Math.PI*2);X.fill(); | |
| X.fillStyle='#357a25'; | |
| X.beginPath();X.arc(tr.x+sw-2,tr.y-2,tr.r*0.7,0,Math.PI*2);X.fill(); | |
| } | |
| // Beers | |
| for(const b of beers){ | |
| if(b.collected)continue; | |
| const glow=0.5+Math.sin((t+b.sparkleOff)*3)*0.3; | |
| X.fillStyle=`rgba(255,200,50,${glow*0.2})`; | |
| X.beginPath();X.arc(b.x,b.y,18,0,Math.PI*2);X.fill(); | |
| X.fillStyle='#d4920a';X.fillRect(b.x-6,b.y-5,12,13); | |
| X.fillStyle='#fff8e0'; | |
| X.beginPath();X.ellipse(b.x,b.y-5,7,4,0,0,Math.PI*2);X.fill(); | |
| X.strokeStyle='#d4920a';X.lineWidth=2; | |
| X.beginPath();X.arc(b.x+9,b.y+1,5,Math.PI*1.5,Math.PI*0.5);X.stroke(); | |
| } | |
| // Police | |
| for(const p of police){ | |
| if(p.state==='patrol'){ | |
| X.fillStyle='rgba(255,100,100,0.06)'; | |
| X.beginPath();X.arc(p.x,p.y,p.detRadius,0,Math.PI*2);X.fill(); | |
| }else if(p.state==='chase'||p.state==='alert'){ | |
| const pulse=0.1+Math.sin(t*6)*0.05; | |
| X.fillStyle=`rgba(255,50,50,${pulse})`; | |
| X.beginPath();X.arc(p.x,p.y,p.detRadius*(p.state==='chase'?1.2:1),0,Math.PI*2);X.fill(); | |
| }else if(p.state==='searching'){ | |
| X.fillStyle='rgba(255,200,50,0.08)'; | |
| X.beginPath();X.arc(p.x,p.y,p.detRadius,0,Math.PI*2);X.fill(); | |
| } | |
| X.fillStyle='#2050a0'; | |
| X.beginPath();X.arc(p.x,p.y,12,0,Math.PI*2);X.fill(); | |
| X.fillStyle='#1a3a80';X.fillRect(p.x-10,p.y-16,20,8); | |
| X.fillStyle='#ffd700'; | |
| X.beginPath();X.arc(p.x,p.y-2,3,0,Math.PI*2);X.fill(); | |
| if(p.state==='chase'){ | |
| X.fillStyle='rgba(255,255,150,0.15)';X.beginPath(); | |
| X.moveTo(p.x,p.y);X.arc(p.x,p.y,100,p.angle-0.4,p.angle+0.4); | |
| X.closePath();X.fill(); | |
| } | |
| if(p.speech&&p.speechTimer>0){ | |
| X.font='bold 13px system-ui'; | |
| const tw=X.measureText(p.speech).width+12; | |
| X.fillStyle='rgba(255,255,255,0.9)'; | |
| roundRect(p.x-tw/2,p.y-38,tw,22,6);X.fill(); | |
| X.fillStyle='#333';X.textAlign='center';X.textBaseline='middle'; | |
| X.fillText(p.speech,p.x,p.y-27); | |
| } | |
| } | |
| // Player | |
| if(gameState==='playing'||gameState==='caught'){ | |
| const wb=player.wobble; | |
| const px=player.x+Math.sin(t*4)*wb*1.2; | |
| const py=player.y+Math.cos(t*3.3)*wb*0.8; | |
| X.globalAlpha=player.hidden?0.45:1; | |
| X.fillStyle='rgba(0,0,0,0.25)'; | |
| X.beginPath();X.ellipse(px+2,py+4,10,6,0,0,Math.PI*2);X.fill(); | |
| X.fillStyle='#e8a030'; | |
| X.beginPath();X.arc(px,py,10,0,Math.PI*2);X.fill(); | |
| X.fillStyle='#f5c060'; | |
| X.beginPath();X.arc(px,py-1,7,0,Math.PI*2);X.fill(); | |
| X.fillStyle='#333'; | |
| X.beginPath();X.arc(px-3,py-3,1.5,0,Math.PI*2);X.fill(); | |
| X.beginPath();X.arc(px+3,py-3,1.5,0,Math.PI*2);X.fill(); | |
| const anyChasing=police.some(p=>p.state==='chase'); | |
| if(anyChasing){ | |
| X.beginPath();X.arc(px,py+1,4,0,Math.PI);X.strokeStyle='#333';X.lineWidth=1.5;X.stroke(); | |
| }else if(player.beers>5){ | |
| X.beginPath();X.arc(px,py+2,3,0.2,Math.PI-0.2);X.strokeStyle='#333';X.lineWidth=1.5;X.stroke(); | |
| }else{ | |
| X.beginPath();X.arc(px,py+2,3,0.1,Math.PI-0.1);X.strokeStyle='#333';X.lineWidth=1;X.stroke(); | |
| } | |
| X.fillStyle='#cc3030'; | |
| X.beginPath();X.ellipse(px,py-8,9,4,0,0,Math.PI*2);X.fill(); | |
| X.fillRect(px-8,py-10,16,4); | |
| if(player.hidden){ | |
| X.globalAlpha=0.7;X.font='10px system-ui';X.fillStyle='#aaffaa'; | |
| X.textAlign='center';X.fillText('🫣 спрятался',px,py-20); | |
| } | |
| X.globalAlpha=1; | |
| } | |
| // Landmark labels | |
| X.font='bold 11px system-ui';X.textAlign='center';X.textBaseline='middle'; | |
| for(const l of LANDMARKS){ | |
| X.fillStyle='rgba(0,0,0,0.5)'; | |
| const tw=X.measureText(l.name).width+10; | |
| roundRect(l.x-tw/2,l.y+18,tw,18,4);X.fill(); | |
| X.fillStyle='#fff'; | |
| X.fillText((l.icon?l.icon+' ':'')+l.name,l.x,l.y+27); | |
| } | |
| // River label | |
| X.save();X.translate(1350,450);X.rotate(-Math.PI/2); | |
| X.font='bold 16px system-ui';X.fillStyle='rgba(255,255,255,0.3)'; | |
| X.textAlign='center';X.fillText('М О С К В А - Р Е К А',0,0); | |
| X.restore(); | |
| X.restore(); // END camera transform | |
| // ===== SCREEN-SPACE UI ===== | |
| if(gameState==='playing'){ | |
| // HUD bar | |
| X.fillStyle='rgba(0,0,0,0.6)'; | |
| roundRect(10,10,cw-20,44,8);X.fill(); | |
| X.font='bold 16px system-ui';X.textAlign='left';X.textBaseline='middle'; | |
| X.fillStyle='#ff6060'; | |
| let livesStr='';for(let i=0;i<player.lives;i++)livesStr+='❤️ '; | |
| X.fillText(livesStr,22,32); | |
| X.fillStyle='#ffd060';X.textAlign='center'; | |
| X.fillText(`🍺 ${player.beers} / ${beers.length}`,cx,32); | |
| X.fillStyle='#aaa';X.textAlign='right'; | |
| const min=Math.floor(gameTime/60),sec=Math.floor(gameTime%60); | |
| X.fillText(`⏱ ${min}:${sec.toString().padStart(2,'0')}`,cw-22,32); | |
| if(player.beers>0&&!portrait){ | |
| X.textAlign='left';X.fillStyle='#ffa040'; | |
| X.font='12px system-ui'; | |
| X.fillText('Опьянение: '+'🟡'.repeat(Math.min(player.beers,10)),200,32); | |
| } | |
| // Hidden indicator | |
| if(player.hidden){ | |
| X.fillStyle='rgba(0,180,0,0.7)'; | |
| roundRect(cx-120,58,240,28,6);X.fill(); | |
| X.fillStyle='#fff';X.font='bold 14px system-ui';X.textAlign='center'; | |
| X.fillText('🌿 Ты в укрытии!',cx,72); | |
| } | |
| // Message | |
| if(messageTimer>0){ | |
| X.globalAlpha=Math.min(1,messageTimer); | |
| X.fillStyle='rgba(0,0,0,0.75)'; | |
| X.font='bold 20px system-ui';X.textAlign='center'; | |
| const mw=X.measureText(messageText).width+30; | |
| roundRect(cx-mw/2,ch-70,mw,40,8);X.fill(); | |
| X.fillStyle='#ffd060'; | |
| X.fillText(messageText,cx,ch-50); | |
| X.globalAlpha=1; | |
| } | |
| // Beer compass arrow (screen space) | |
| const nearest=beers.filter(b=>!b.collected).sort((a,b)=> | |
| Math.hypot(a.x-player.x,a.y-player.y)-Math.hypot(b.x-player.x,b.y-player.y) | |
| )[0]; | |
| if(nearest){ | |
| const nd=Math.hypot(nearest.x-player.x,nearest.y-player.y); | |
| if(nd>(cam.active?100:200)){ | |
| const na=Math.atan2(nearest.y-player.y,nearest.x-player.x); | |
| const edgeR=Math.min(cx,cy)-60; | |
| const ax=cx+Math.cos(na)*edgeR, ay=cy+Math.sin(na)*edgeR; | |
| // Clamp to screen | |
| const cax=Math.max(30,Math.min(cw-30,ax)); | |
| const cay=Math.max(70,Math.min(ch-80,ay)); | |
| X.globalAlpha=0.5;X.fillStyle='#ffd060';X.font='20px system-ui';X.textAlign='center'; | |
| X.fillText('🍺',cax,cay); | |
| // Distance | |
| X.font='10px system-ui';X.fillStyle='#ffd060'; | |
| X.fillText(Math.round(nd)+'м',cax,cay+14); | |
| X.globalAlpha=1; | |
| } | |
| } | |
| // Compass | |
| X.fillStyle='rgba(0,0,0,0.4)'; | |
| X.beginPath();X.arc(50,80,18,0,Math.PI*2);X.fill(); | |
| X.fillStyle='#e44';X.font='bold 14px system-ui';X.textAlign='center';X.textBaseline='middle'; | |
| X.fillText('N',50,78); | |
| X.strokeStyle='#e44';X.lineWidth=2; | |
| X.beginPath();X.moveTo(50,68);X.lineTo(47,75);X.lineTo(53,75);X.closePath();X.stroke(); | |
| // ===== MINI-MAP (portrait only) ===== | |
| if(cam.active){ | |
| const mmw=130, mmh=Math.round(130*WORLD_H/WORLD_W); | |
| const mmx=cw-mmw-12, mmy=62; | |
| // Background | |
| X.fillStyle='rgba(0,0,0,0.55)'; | |
| roundRect(mmx-5,mmy-5,mmw+10,mmh+10,6);X.fill(); | |
| X.fillStyle='#2a5a22';X.fillRect(mmx,mmy,mmw,mmh); | |
| // River | |
| X.fillStyle='#2565a0'; | |
| X.fillRect(mmx+mmw*0.9,mmy,mmw*0.1,mmh); | |
| // Paths (simplified) | |
| X.strokeStyle='rgba(196,169,125,0.5)';X.lineWidth=1;X.lineCap='round'; | |
| for(const path of PATHS){ | |
| X.beginPath(); | |
| X.moveTo(mmx+path[0][0]/WORLD_W*mmw,mmy+path[0][1]/WORLD_H*mmh); | |
| for(let i=1;i<path.length;i++) | |
| X.lineTo(mmx+path[i][0]/WORLD_W*mmw,mmy+path[i][1]/WORLD_H*mmh); | |
| X.stroke(); | |
| } | |
| // Beers | |
| for(const b of beers){ | |
| if(b.collected)continue; | |
| X.fillStyle='#ffd060'; | |
| X.fillRect(mmx+b.x/WORLD_W*mmw-1.5,mmy+b.y/WORLD_H*mmh-1.5,3,3); | |
| } | |
| // Police | |
| X.fillStyle='#4466ff'; | |
| for(const p of police){ | |
| X.fillRect(mmx+p.x/WORLD_W*mmw-2,mmy+p.y/WORLD_H*mmh-2,4,4); | |
| } | |
| // Player | |
| X.fillStyle='#ff4444'; | |
| X.beginPath(); | |
| X.arc(mmx+player.x/WORLD_W*mmw,mmy+player.y/WORLD_H*mmh,3,0,Math.PI*2); | |
| X.fill(); | |
| // Viewport rect | |
| const vw=cw/cam.zoom/WORLD_W*mmw; | |
| const vh=ch/cam.zoom/WORLD_H*mmh; | |
| const vx=mmx+(cam.x-cw/cam.zoom/2)/WORLD_W*mmw; | |
| const vy=mmy+(cam.y-ch/cam.zoom/2)/WORLD_H*mmh; | |
| X.strokeStyle='rgba(255,255,255,0.5)';X.lineWidth=1; | |
| X.strokeRect(vx,vy,vw,vh); | |
| } | |
| } | |
| // Virtual joystick | |
| if(joy.active&&gameState==='playing'){ | |
| X.strokeStyle='rgba(255,255,255,0.2)';X.lineWidth=3; | |
| X.beginPath();X.arc(joy.sx,joy.sy,60,0,Math.PI*2);X.stroke(); | |
| X.fillStyle='rgba(255,255,255,0.06)'; | |
| X.beginPath();X.arc(joy.sx,joy.sy,60,0,Math.PI*2);X.fill(); | |
| const knobX=joy.sx+joy.dx*60, knobY=joy.sy+joy.dy*60; | |
| X.fillStyle='rgba(255,255,255,0.35)'; | |
| X.beginPath();X.arc(knobX,knobY,24,0,Math.PI*2);X.fill(); | |
| X.strokeStyle='rgba(255,255,255,0.5)';X.lineWidth=2; | |
| X.beginPath();X.arc(knobX,knobY,24,0,Math.PI*2);X.stroke(); | |
| } | |
| // ===== OVERLAYS ===== | |
| // Menu | |
| if(gameState==='menu'){ | |
| X.fillStyle='rgba(0,0,0,0.7)';X.fillRect(0,0,cw,ch); | |
| X.textAlign='center';X.textBaseline='middle'; | |
| X.fillStyle='#ffd060';X.font='bold 48px system-ui'; | |
| X.fillText('🍺 НЕСКУЧНОЕ ПИВО 🍺',cx,cy-180); | |
| X.fillStyle='#ccc';X.font='20px system-ui'; | |
| X.fillText('Приключение в Нескучном Саду',cx,cy-125); | |
| X.fillStyle='#aaa';X.font='16px system-ui'; | |
| if(isMobile){ | |
| X.fillText('Управление — виртуальный джойстик',cx,cy-30); | |
| }else{ | |
| X.fillText('WASD / Стрелки — движение',cx,cy-30); | |
| } | |
| X.fillText('Прячься в кустах 🌿 от полиции 👮',cx,cy+5); | |
| X.fillText('Собери все 10 пив 🍺 и не попадись!',cx,cy+40); | |
| X.fillText('С каждым пивом управление веселее! 🥴',cx,cy+75); | |
| if(isMobile){ | |
| X.fillStyle='#ffd060'; | |
| roundRect(cx-170,cy+130,340,65,14);X.fill(); | |
| X.fillStyle='#1a1a2e';X.font='bold 28px system-ui'; | |
| X.fillText('НАЖМИ — ИГРАТЬ',cx,cy+163); | |
| }else{ | |
| X.fillStyle='#ffd060';X.font='bold 26px system-ui'; | |
| if(Math.sin(t*4)>0)X.fillText('[ ПРОБЕЛ — НАЧАТЬ ]',cx,cy+160); | |
| } | |
| X.fillStyle='#666';X.font='13px system-ui'; | |
| X.fillText('на основе реальной карты Нескучного сада, Москва',cx,ch-30); | |
| } | |
| // Caught | |
| if(gameState==='caught'){ | |
| X.fillStyle='rgba(255,0,0,0.15)';X.fillRect(0,0,cw,ch); | |
| X.fillStyle='rgba(0,0,0,0.6)'; | |
| roundRect(cx-250,cy-100,500,200,12);X.fill(); | |
| X.textAlign='center';X.textBaseline='middle'; | |
| X.fillStyle='#ff6060';X.font='bold 34px system-ui'; | |
| X.fillText('👮 ПОПАЛСЯ!',cx,cy-50); | |
| X.fillStyle='#ccc';X.font='18px system-ui'; | |
| X.fillText(`Осталось жизней: ${'❤️'.repeat(player.lives)}`,cx,cy); | |
| if(isMobile){ | |
| X.fillStyle='#ffd060'; | |
| roundRect(cx-160,cy+30,320,55,12);X.fill(); | |
| X.fillStyle='#1a1a2e';X.font='bold 20px system-ui'; | |
| X.fillText('НАЖМИ — ПРОДОЛЖИТЬ',cx,cy+57); | |
| }else{ | |
| X.fillStyle='#ffd060';X.font='bold 20px system-ui'; | |
| X.fillText('[ R — продолжить ]',cx,cy+57); | |
| } | |
| } | |
| // Game over | |
| if(gameState==='gameover'){ | |
| X.fillStyle='rgba(0,0,0,0.8)';X.fillRect(0,0,cw,ch); | |
| X.textAlign='center';X.textBaseline='middle'; | |
| X.fillStyle='#ff4040';X.font='bold 44px system-ui'; | |
| X.fillText('🚔 ЗАДЕРЖАН! 🚔',cx,cy-120); | |
| X.fillStyle='#ccc';X.font='20px system-ui'; | |
| X.fillText(`Собрано пива: ${player.beers} из ${beers.length}`,cx,cy-50); | |
| const min=Math.floor(gameTime/60),sec=Math.floor(gameTime%60); | |
| X.fillText(`Время: ${min}:${sec.toString().padStart(2,'0')}`,cx,cy-15); | |
| X.fillStyle='#ffa040';X.font='16px system-ui'; | |
| X.fillText('Штраф за распитие — от 500 до 1500₽ 😅',cx,cy+40); | |
| if(isMobile){ | |
| X.fillStyle='#ffd060'; | |
| roundRect(cx-160,cy+75,320,55,12);X.fill(); | |
| X.fillStyle='#1a1a2e';X.font='bold 20px system-ui'; | |
| X.fillText('НАЖМИ — В МЕНЮ',cx,cy+102); | |
| }else{ | |
| X.fillStyle='#ffd060';X.font='bold 22px system-ui'; | |
| if(Math.sin(t*4)>0)X.fillText('[ ПРОБЕЛ — в меню ]',cx,cy+102); | |
| } | |
| } | |
| // Win | |
| if(gameState==='win'){ | |
| X.fillStyle='rgba(0,0,0,0.75)';X.fillRect(0,0,cw,ch); | |
| X.textAlign='center';X.textBaseline='middle'; | |
| X.fillStyle='#ffd060';X.font='bold 44px system-ui'; | |
| X.fillText('🎉 ВСЁ ПИВО СОБРАНО! 🎉',cx,cy-140); | |
| const min=Math.floor(gameTime/60),sec=Math.floor(gameTime%60); | |
| X.fillStyle='#ccc';X.font='20px system-ui'; | |
| X.fillText(`Время: ${min}:${sec.toString().padStart(2,'0')}`,cx,cy-75); | |
| let rank='',rc=''; | |
| if(gameTime<90){rank='🏆 Мастер Пивовар!';rc='#ffd700'} | |
| else if(gameTime<150){rank='🥈 Опытный Любитель';rc='#c0c0c0'} | |
| else if(gameTime<240){rank='🥉 Начинающий Выпивоха';rc='#cd7f32'} | |
| else{rank='🍼 Молочный Турист';rc='#aaa'} | |
| X.fillStyle=rc;X.font='bold 26px system-ui'; | |
| X.fillText(rank,cx,cy-25); | |
| X.fillStyle='#aaa';X.font='15px system-ui'; | |
| X.fillText('Распитие в парках — это нехорошо! 😉',cx,cy+30); | |
| if(isMobile){ | |
| X.fillStyle='#ffd060'; | |
| roundRect(cx-160,cy+65,320,55,12);X.fill(); | |
| X.fillStyle='#1a1a2e';X.font='bold 20px system-ui'; | |
| X.fillText('НАЖМИ — В МЕНЮ',cx,cy+92); | |
| }else{ | |
| X.fillStyle='#ffd060';X.font='bold 22px system-ui'; | |
| if(Math.sin(t*4)>0)X.fillText('[ ПРОБЕЛ — в меню ]',cx,cy+92); | |
| } | |
| } | |
| } | |
| function roundRect(x,y,w,h,r){ | |
| X.beginPath();X.moveTo(x+r,y);X.lineTo(x+w-r,y);X.quadraticCurveTo(x+w,y,x+w,y+r); | |
| X.lineTo(x+w,y+h-r);X.quadraticCurveTo(x+w,y+h,x+w-r,y+h); | |
| X.lineTo(x+r,y+h);X.quadraticCurveTo(x,y+h,x,y+h-r); | |
| X.lineTo(x,y+r);X.quadraticCurveTo(x,y,x+r,y);X.closePath(); | |
| } | |
| // ===== GAME LOOP ===== | |
| let lastTime=0; | |
| function loop(now){ | |
| const dt=Math.min((now-lastTime)/1000,0.05); | |
| lastTime=now; | |
| update(dt); | |
| render(); | |
| requestAnimationFrame(loop); | |
| } | |
| resetGame(); | |
| requestAnimationFrame(now=>{lastTime=now;loop(now)}); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment