arcade.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. (() => {
  2. 'use strict';
  3. const modal = document.querySelector('#arcadeModal');
  4. const canvas = document.querySelector('#arcadeCanvas');
  5. const ctx = canvas.getContext('2d');
  6. const ui = name => document.querySelector('#arcade' + name);
  7. const W = 360, H = 480;
  8. const held = new Set(), pointers = new Map();
  9. let mode, state, paused = false, over = false, raf = 0, last = 0, accumulator = 0, best = 0;
  10. const random = (a,b) => a + Math.random() * (b-a);
  11. const clamp = (v,a,b) => Math.max(a,Math.min(b,v));
  12. function record() {
  13. if (state.score > best) {
  14. best = state.score;
  15. try { localStorage.setItem('zke-arcade-' + mode, String(best)); } catch {}
  16. }
  17. ui('Score').textContent = 'Счёт: ' + state.score;
  18. if (mode === 'jump') ui('Score').textContent += ' · Уровень ' + (1 + Math.floor(difficulty(state.score) * 5));
  19. ui('Best').textContent = 'Рекорд: ' + best;
  20. }
  21. function end(message) {
  22. over = true; record(); held.clear(); pointers.clear();
  23. ui('Status').textContent = message + ' Счёт: ' + state.score;
  24. ui('Pause').disabled = true;
  25. }
  26. function pause(value = !paused) {
  27. if (over || !modal.open) return;
  28. paused = value; held.clear(); pointers.clear();
  29. ui('Pause').textContent = paused ? 'Продолжить' : 'Пауза';
  30. ui('Status').textContent = paused ? 'Пауза' : 'Игра идёт';
  31. last = 0; accumulator = 0; draw();
  32. }
  33. function restart() {
  34. cancelAnimationFrame(raf); paused = false; over = false; held.clear(); pointers.clear();
  35. last = 0; accumulator = 0;
  36. try { best = Number(localStorage.getItem('zke-arcade-' + mode)) || 0; } catch { best = 0; }
  37. ui('Pause').textContent = 'Пауза'; ui('Pause').disabled = false;
  38. ui('Status').textContent = 'Игра идёт';
  39. state = mode === 'snake' ? newSnake() : newJump();
  40. record(); draw(); raf = requestAnimationFrame(frame); canvas.focus();
  41. }
  42. function open(type) {
  43. mode = type;
  44. ui('Title').textContent = type === 'jump' ? 'Прыжок оператора' : 'Змейка';
  45. ui('Help').textContent = type === 'jump'
  46. ? '← → или A/D — движение · W / Пробел — огонь · P — пауза. Края соединены. Зелёные площадки прочные, голубые движутся, рыжие ломаются, белые исчезают. Пружина ↑, ранец J, щит S.'
  47. : 'Стрелки или WASD — поворот · P — пауза. Собирайте еду, избегайте стен и своего хвоста.';
  48. document.querySelectorAll('[data-control]').forEach(button => {
  49. button.hidden = type === 'jump' ? ['up','down'].includes(button.dataset.control) : button.dataset.control === 'shoot';
  50. });
  51. if (!modal.open) modal.showModal();
  52. restart();
  53. }
  54. // All input and animation belong to the open game only.
  55. document.querySelectorAll('[data-arcade]').forEach(button => button.addEventListener('click', () => open(button.dataset.arcade)));
  56. ui('Restart').onclick = restart; ui('Pause').onclick = () => pause();
  57. ui('Close').onclick = () => modal.close();
  58. modal.addEventListener('close', () => { cancelAnimationFrame(raf); held.clear(); pointers.clear(); last = 0; });
  59. window.addEventListener('blur', () => pause(true));
  60. document.addEventListener('visibilitychange', () => { if (document.hidden) pause(true); });
  61. const keys = { ArrowLeft:'left', ArrowRight:'right', ArrowUp:'up', ArrowDown:'down', KeyA:'left', KeyD:'right', KeyW:'up', KeyS:'down', Space:'shoot' };
  62. document.addEventListener('keydown', event => {
  63. if (!modal.open) return;
  64. if (event.code === 'Escape') return;
  65. event.stopImmediatePropagation();
  66. if (event.code === 'KeyP') { event.preventDefault(); if (!event.repeat) pause(); return; }
  67. const control = mode === 'jump' && event.code === 'KeyW' ? 'shoot' : keys[event.code];
  68. if (!control) return;
  69. event.preventDefault();
  70. if (paused || over) return;
  71. held.add(event.code);
  72. if (mode === 'snake') turn(control);
  73. }, true);
  74. document.addEventListener('keyup', event => { if (modal.open) held.delete(event.code); });
  75. document.querySelectorAll('[data-control]').forEach(button => {
  76. button.addEventListener('pointerdown', event => {
  77. if (paused || over) return;
  78. event.preventDefault(); button.setPointerCapture(event.pointerId);
  79. pointers.set(event.pointerId, button.dataset.control);
  80. if (mode === 'snake') turn(button.dataset.control);
  81. });
  82. const release = event => pointers.delete(event.pointerId);
  83. button.addEventListener('pointerup', release);
  84. button.addEventListener('pointercancel', release);
  85. button.addEventListener('lostpointercapture', release);
  86. });
  87. const pressed = key => [...held].some(code => (mode === 'jump' && code === 'KeyW' ? 'shoot' : keys[code]) === key) || [...pointers.values()].includes(key);
  88. function frame(time) {
  89. if (!modal.open) return;
  90. const dt = last ? Math.min((time-last)/1000, .05) : 0; last = time;
  91. if (!paused && !over) {
  92. accumulator += dt;
  93. while (accumulator >= 1/120 && !over) {
  94. mode === 'jump' ? stepJump(1/120) : stepSnake(1/120);
  95. accumulator -= 1/120;
  96. }
  97. record();
  98. }
  99. draw();
  100. raf = requestAnimationFrame(frame);
  101. }
  102. function newSnake() {
  103. const s = { body:[{x:9,y:12},{x:8,y:12},{x:7,y:12}], dir:{x:1,y:0}, queue:[], elapsed:0, score:0 };
  104. food(s); return s;
  105. }
  106. function food(s) {
  107. const free = [];
  108. for(let y=0;y<24;y++) for(let x=0;x<18;x++)
  109. if(!s.body.some(p => p.x===x && p.y===y)) free.push({x,y});
  110. s.food = free[Math.floor(Math.random()*free.length)] || null;
  111. }
  112. function turn(control) {
  113. const dirs = {left:{x:-1,y:0},right:{x:1,y:0},up:{x:0,y:-1},down:{x:0,y:1}};
  114. const next = dirs[control], previous = state.queue.at(-1) || state.dir;
  115. if (!next || state.queue.length >= 2 || (next.x===previous.x && next.y===previous.y) || (next.x===-previous.x && next.y===-previous.y)) return;
  116. state.queue.push(next);
  117. }
  118. function stepSnake(dt) {
  119. const s=state; s.elapsed+=dt;
  120. const interval=Math.max(.065,.17-s.score*.003);
  121. if(s.elapsed<interval) return;
  122. s.elapsed-=interval;
  123. if(s.queue.length) s.dir=s.queue.shift();
  124. const head={x:s.body[0].x+s.dir.x,y:s.body[0].y+s.dir.y};
  125. const eating=s.food && head.x===s.food.x && head.y===s.food.y;
  126. const occupied=eating?s.body:s.body.slice(0,-1);
  127. if(head.x<0||head.x>=18||head.y<0||head.y>=24||occupied.some(p=>p.x===head.x&&p.y===head.y)) return end('Игра окончена.');
  128. s.body.unshift(head);
  129. if(eating) { s.score++; food(s); if(!s.food) end('Всё поле заполнено. Победа!'); }
  130. else s.body.pop();
  131. }
  132. // World Y grows downward; upward progress moves the camera, never the physics.
  133. function newJump() {
  134. const s={player:{x:170,y:390,vy:-520}, platforms:[{x:135,y:430,w:90,type:'solid',item:null}], enemies:[], shots:[], debris:[], time:0, camera:0, top:430, anchor:135, score:0, boost:0, shield:0, cooldown:0, generated:0};
  135. generate(s); return s;
  136. }
  137. // Difficulty rises over 600 points, then stays within normal jump reach.
  138. function difficulty(score) { return clamp(score / 600, 0, 1); }
  139. function generate(s) {
  140. const d = difficulty(s.score);
  141. while(s.top>s.camera-110) {
  142. s.top-=random(50+24*d,68+18*d); s.generated++;
  143. s.anchor=clamp(s.anchor+random(-60-10*d,60+10*d),12,W-88);
  144. const roll=Math.random();
  145. // Permanent landing in every row keeps the main route reachable.
  146. const platform={x:s.anchor,y:s.top,w:86-30*d,type:'solid',item:null};
  147. if(s.generated>5 && roll<.12+.48*d) { platform.type='moving'; platform.base=platform.x; platform.phase=random(0,6); platform.speed=1+1.3*d; }
  148. if(s.generated%8===0) platform.item=['spring','jet','shield'][Math.floor(Math.random()*3)];
  149. s.platforms.push(platform);
  150. if(s.generated>5 && Math.random()<.25+.4*d) s.platforms.push({x:clamp(s.anchor+(s.anchor<180?130:-130),0,300),y:s.top+25,w:56-10*d,type:Math.random()<.5?'break':'vanish',item:null});
  151. const enemyInterval = Math.max(3,8-Math.floor(d*5));
  152. if(s.generated>14 && s.generated%enemyInterval===0) s.enemies.push({x:s.anchor<180?280:55,y:s.top-30,base:s.anchor<180?280:55,phase:random(0,6),r:16,speed:1.5+2*d,range:18+20*d});
  153. }
  154. }
  155. function stepJump(dt) {
  156. const s=state,p=s.player;
  157. s.time += dt;
  158. s.debris.forEach(d => { d.life -= dt; d.x += d.vx*dt; d.y += d.vy*dt; d.vy += d.gravity*dt; d.angle += d.spin*dt; });
  159. s.debris = s.debris.filter(d => d.life > 0);
  160. s.boost=Math.max(0,s.boost-dt); s.shield=Math.max(0,s.shield-dt); s.cooldown-=dt;
  161. const speed=240;
  162. p.x+=((pressed('right')?1:0)-(pressed('left')?1:0))*speed*dt;
  163. if(p.x>W) p.x=-24; if(p.x<-24) p.x=W;
  164. const oldBottom=p.y+30;
  165. p.vy=s.boost>0?-640:p.vy+1450*dt;
  166. p.y+=p.vy*dt;
  167. s.platforms.forEach(a => {
  168. if(a.type==='moving') { a.phase+=dt*(a.speed||1); a.x=clamp(a.base+Math.sin(a.phase)*22,0,W-a.w); }
  169. });
  170. if(p.vy>0) {
  171. const landing=s.platforms.filter(a=>!a.dead&&oldBottom<=a.y&&p.y+30>=a.y&&p.x+24>a.x&&p.x<a.x+a.w).sort((a,b)=>a.y-b.y)[0];
  172. if(landing) {
  173. if(landing.type==='break') shatter(landing);
  174. else {
  175. p.y=landing.y-30; p.vy=-520;
  176. if(landing.type==='vanish') shatter(landing);
  177. if(landing.item==='spring') p.vy=-850;
  178. if(landing.item==='jet') s.boost=2.5;
  179. if(landing.item==='shield') s.shield=7;
  180. if(landing.item!=='spring') landing.item=null;
  181. }
  182. }
  183. }
  184. if(pressed('shoot')&&s.cooldown<=0) { s.shots.push({x:p.x+12,y:p.y}); s.cooldown=.2; }
  185. s.shots.forEach(b=>b.y-=750*dt);
  186. s.enemies.forEach(e=>{
  187. e.phase+=dt*(e.speed||2); e.x=e.base+Math.sin(e.phase)*(e.range||22);
  188. s.shots.forEach(b=>{ if(!b.dead&&!e.dead&&Math.hypot(b.x-e.x,b.y-e.y)<e.r+6) {e.dead=true;b.dead=true;} });
  189. if(!e.dead&&p.x+24>e.x-e.r&&p.x<e.x+e.r&&p.y+30>e.y-e.r&&p.y<e.y+e.r) {
  190. if(s.shield>0||s.boost>0) e.dead=true;
  191. else if(p.vy>0&&oldBottom<=e.y) {e.dead=true;p.vy=-520;}
  192. else end('Столкновение с охранным дроном.');
  193. }
  194. });
  195. s.camera=Math.min(s.camera,p.y-H*.42);
  196. s.score=Math.max(s.score,Math.floor(-s.camera/10));
  197. s.platforms=s.platforms.filter(a=>!a.dead&&a.y<s.camera+H+70);
  198. s.enemies=s.enemies.filter(e=>!e.dead&&e.y<s.camera+H+60);
  199. s.shots=s.shots.filter(b=>!b.dead&&b.y>s.camera-30);
  200. generate(s);
  201. if(p.y>s.camera+H+30) end('Падение. Попробуйте ещё раз!');
  202. }
  203. function shatter(platform) {
  204. if(platform.dead) return;
  205. platform.dead=true;
  206. const ghost=platform.type==='vanish';
  207. for(let i=0;i<8;i++) {
  208. state.debris.push({
  209. x:platform.x+(i+.5)*platform.w/8,y:platform.y+5,
  210. w:platform.w/8-1,h:ghost?5:10,
  211. vx:(i-3.5)*random(12,24),vy:ghost?random(-65,-15):random(-120,-45),
  212. gravity:ghost?30:550,angle:0,spin:random(-5,5),
  213. life:ghost?.65:1.1,color:ghost?'#b2e9ea':'#bd8151',
  214. highlight:ghost?'#ffffff':'#f5c68a'
  215. });
  216. }
  217. }
  218. function rounded(x,y,w,h,r,color) {
  219. ctx.fillStyle=color; ctx.beginPath(); ctx.roundRect(x,y,w,h,r); ctx.fill();
  220. }
  221. function drawOperator(x,y,vy,cooldown) {
  222. // Outlined suit, helmet visor and boots retain the 24 × 30 collision silhouette.
  223. ctx.save(); ctx.translate(x+12,y+15);
  224. ctx.rotate(clamp(((pressed('right')?1:0)-(pressed('left')?1:0))*.09,-.09,.09));
  225. rounded(-12,-3,24,18,5,'#15262a');
  226. rounded(-10,-2,20,15,4,'#e6d5a5');
  227. rounded(-14,0,5,10,2,'#789a83'); rounded(9,0,5,10,2,'#789a83');
  228. rounded(-9,10,7,5,2,'#405a5f'); rounded(2,10,7,5,2,'#405a5f');
  229. rounded(-12,-16,24,20,8,'#15262a');
  230. rounded(-10,-15,20,18,7,'#f2e7c9');
  231. rounded(-8,-12,16,11,4,'#255769');
  232. rounded(-6,-11,12,4,2,'#67bbcd');
  233. rect(-5,-10,4,2,'#d9faff');
  234. rounded(-6,4,12,6,2,'#526c64');
  235. circle(-3,7,1.3,'#c9ff6b'); circle(2,7,1.3,'#ffa967');
  236. rect(-1,-18,3,5,'#b8cbd1');
  237. if(cooldown>.14) { circle(.5,-20,4,'#ffe98a'); circle(.5,-20,2,'#fffbea'); }
  238. ctx.restore();
  239. }
  240. function drawDrone(x,y,time) {
  241. ctx.save(); ctx.translate(x,y);
  242. // Wide rotor arms, armoured shell and one red sensor distinguish enemies.
  243. rect(-25,-6,50,4,'#738395');
  244. [-23,23].forEach(dx=>{
  245. rect(dx-2,-12,4,10,'#a0b0b9');
  246. const span=8+Math.sin(time*50)*4;
  247. rounded(dx-span,-13,span*2,3,1,'#c1d6df');
  248. });
  249. rounded(-17,-12,34,26,9,'#18222f');
  250. rounded(-15,-10,30,22,8,'#718496');
  251. rounded(-12,-9,24,6,3,'#afbec8');
  252. rounded(-12,-3,24,11,5,'#293345');
  253. circle(0,2,7,'#752a46'); circle(0,2,4.5,'#ff526e'); circle(-1,0,1.7,'#ffe4d1');
  254. rect(-10,11,5,4,'#344959'); rect(5,11,5,4,'#344959');
  255. rect(-11,-5,3,2,'#ffcb77'); rect(8,-5,3,2,'#ffcb77');
  256. ctx.restore();
  257. }
  258. function rect(x,y,w,h,color) {ctx.fillStyle=color;ctx.fillRect(x,y,w,h);}
  259. function circle(x,y,r,color) {ctx.fillStyle=color;ctx.beginPath();ctx.arc(x,y,r,0,Math.PI*2);ctx.fill();}
  260. function draw() {
  261. rect(0,0,W,H,'#0d191c');
  262. ctx.strokeStyle='#1a3033';ctx.lineWidth=1;
  263. const grid = mode === 'snake' ? 20 : 24;
  264. const offset=mode==='jump'?((-state.camera)%grid):0;
  265. for(let x=0;x<W;x+=grid){ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,H);ctx.stroke();}
  266. for(let y=offset;y<H;y+=grid){ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(W,y);ctx.stroke();}
  267. if(mode==='snake') drawSnake(); else drawJump();
  268. if(paused||over) {
  269. rect(0,0,W,H,'rgba(4,10,12,.72)');
  270. ctx.fillStyle='#dfffbd';ctx.font='bold 24px monospace';ctx.textAlign='center';
  271. ctx.fillText(paused?'ПАУЗА':'КОНЕЦ ИГРЫ',W/2,H/2-10);
  272. ctx.font='14px monospace';ctx.fillText('Счёт: '+state.score,W/2,H/2+22);ctx.textAlign='left';
  273. }
  274. }
  275. function drawSnake() {
  276. const s=state;
  277. if(s.food) {circle(s.food.x*20+10,s.food.y*20+10,7,'#ff965e');}
  278. s.body.forEach((p,i)=>rect(p.x*20+1,p.y*20+1,18,18,i===0?'#dfff91':'#77b86c'));
  279. const h=s.body[0];rect(h.x*20+5,h.y*20+5,3,3,'#13201c');rect(h.x*20+12,h.y*20+5,3,3,'#13201c');
  280. }
  281. function drawJump() {
  282. const s=state,p=s.player,cy=s.camera;
  283. s.platforms.forEach(a=>{
  284. const y=a.y-cy;
  285. rect(a.x,y,a.w,9,({solid:'#9ce578',moving:'#65c5e6',break:'#c68d62',vanish:'#e0e9df'})[a.type]);
  286. rect(a.x+3,y+9,a.w-6,3,'#38524c');
  287. if(a.type==='break') {ctx.strokeStyle='#302522';ctx.beginPath();ctx.moveTo(a.x+20,y);ctx.lineTo(a.x+29,y+5);ctx.lineTo(a.x+22,y+9);ctx.stroke();}
  288. if(a.item) {rect(a.x+a.w/2-9,y-20,18,18,a.item==='spring'?'#ffe38a':a.item==='jet'?'#ff986e':'#8cdeed');ctx.fillStyle='#152322';ctx.font='bold 14px monospace';ctx.fillText(({spring:'↑',jet:'J',shield:'S'})[a.item],a.x+a.w/2-4,y-6);}
  289. });
  290. s.debris.forEach(d => {
  291. ctx.save(); ctx.globalAlpha = Math.min(1,d.life/.35);
  292. ctx.translate(d.x,d.y-cy); ctx.rotate(d.angle);
  293. rect(-d.w/2,-d.h/2,d.w,d.h,d.color);
  294. rect(-d.w/2,-d.h/2,d.w,2,d.highlight);
  295. ctx.restore();
  296. });
  297. s.enemies.forEach(e=>drawDrone(e.x,e.y-cy,s.time));
  298. s.shots.forEach(b=>rect(b.x-2,b.y-cy,4,12,'#fff2a1'));
  299. const y=p.y-cy;
  300. if(s.shield>0){ctx.strokeStyle='#8cdeed';ctx.lineWidth=2;ctx.beginPath();ctx.arc(p.x+12,y+15,25,0,Math.PI*2);ctx.stroke();}
  301. if(s.boost>0){rect(p.x+5,y+28,14,18+Math.sin(performance.now()/50)*5,'#ff965e');}
  302. drawOperator(p.x,y,p.vy,s.cooldown);
  303. ctx.fillStyle='#95b5aa';ctx.font='11px monospace';
  304. if(s.boost>0)ctx.fillText('РАНЕЦ '+s.boost.toFixed(1),10,20);
  305. else if(s.shield>0)ctx.fillText('ЩИТ '+Math.ceil(s.shield),10,20);
  306. }
  307. })();