Skip to content

Instantly share code, notes, and snippets.

@amchercashin
Last active March 12, 2026 20:28
Show Gist options
  • Select an option

  • Save amchercashin/d404334603d5bbb56e41d2d850081149 to your computer and use it in GitHub Desktop.

Select an option

Save amchercashin/d404334603d5bbb56e41d2d850081149 to your computer and use it in GitHub Desktop.
🍺 Нескучное Пиво — stealth game in Neskuchny Garden, Moscow
<!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