🎮ArcadeLab

Некромант: Готический замок

by NovaWizard24
425 lines15.1 KB
▶ Play
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
<title>Некромант: Готический замок</title>
<style>
*{box-sizing:border-box;touch-action:manipulation}
body{margin:0;padding:10px;background:#100d20;color:#eee;font-family:system-ui,sans-serif}
.game{max-width:850px;margin:auto}
h2{font-size:22px}
.hud{display:flex;justify-content:space-between;gap:6px;flex-wrap:wrap;margin-bottom:8px}
.panel{background:#241a3d;border:1px solid #674b91;border-radius:8px;padding:8px}
canvas{width:100%;height:auto;display:block;aspect-ratio:16/9;background:#111126;border:1px solid #604584;border-radius:8px;touch-action:none;image-rendering:pixelated}
.controls{display:grid;grid-template-columns:100px minmax(0,1fr);gap:10px;align-items:center;margin-top:12px}
button{background:#392254;color:white;border:1px solid #a27ae2;border-radius:9px;padding:10px 5px;min-height:48px;font-weight:bold;font-size:13px}
.pad{width:100px;height:100px;border:2px solid #7356a8;background:#211937;border-radius:50%;position:relative;touch-action:none}
.knob{position:absolute;width:38px;height:38px;left:29px;top:29px;background:#9b7be0;border:2px solid #ddd0ff;border-radius:50%;pointer-events:none}
.actions{display:grid;grid-template-columns:1fr 1fr;gap:7px}
.actions button:last-child{grid-column:1/-1}
small{color:#c8b9e5}
@media(max-width:360px){.controls{grid-template-columns:82px minmax(0,1fr)}.pad{width:82px;height:82px}.knob{left:21px;top:21px}}
</style>
</head>
<body>
<div class="game">
<h2>☠ Некромант: Готический замок</h2>
<div class="hud">
<div class="panel">❤️ <b id="hp">100</b></div>
<div class="panel">🔮 <b id="mana">100</b></div>
<div class="panel">Волна <b id="wave">1/16</b></div>
<div class="panel" id="left">Враги: 5</div>
</div>
<canvas id="game" width="800" height="450"></canvas>
<div class="panel" id="status" style="margin-top:8px">Защити замок!</div>
<div class="controls">
<div class="pad" id="pad"><div class="knob" id="knob"></div></div>
<div class="actions">
<button id="magic">🔮 Магия −8</button>
<button id="summon">💀 Скелеты −70</button>
<button id="bat">🦇 Мышь −20</button>
</div>
</div>
<button id="restart" style="width:100%;margin-top:8px">🔄 Заново</button>
<p><small>WASD/стрелки — движение · Пробел — магия · Q — призыв · E — мышь.</small></p>
</div>
<script>
const $=id=>document.getElementById(id);
const c=$("game"),ctx=c.getContext("2d");
const W=800,H=450;

const CASTLE_IMG = new Image();
// CASTLE_IMG.src = 'castle.png'; 

const ZOMBIE_SPRITE = [
    "  GGGG  ", " GGGGGG ", " GGWGWG ", " GGGGGG ", "  GGGG  ",
    "  BBBB  ", " GBBBBG ", " GBBBBG ", "  BBBB  ", "  B  B  ", "  B  B  ", "  G  G  "
];
const ZOMBIE_COLORS = {'G':'#6a9e5a','B':'#5c3a1e','W':'#ffffff'};

const BOSS_SPRITE = [
    "   GGGGGG   ", "  GGGGGGGG  ", " GGGRGGRGG  ", " GGGGGGGGG  ",
    "  GGGGGG    ", "   GGGG     ", "  DDDDDD    ", " DDDDDDDD   ",
    " DDDDDDDD   ", "  DD  DD    ", "  DD  DD    ", "  GG  GG    "
];
const BOSS_COLORS = {'G':'#3d7a3d','R':'#ff3333','D':'#2d2d2d'};

const SKELETON_SPRITE = [
    "  WWWW  ", " WWWWWW ", " WW WW  ", " WWWWWW ", "  WWWW  ",
    "  WWWW  ", " W WW W ", " W WW W ", "  WWWW  ", "  W  W  ", "  W  W  ", "  W  W  "
];
const SKELETON_COLORS = {'W':'#e0e0e0'};

const PLAYER_SPRITE = [
    "   HHHH   ", "  HHHHHH  ", "  HHHHHH  ", "   FFFF   ", "   FFFF   ",
    "  PPPPPP  ", " PPPPPPPP ", " PPPPPPPP ", " PPPPPPPP ", "  PPPPPP  ",
    "  PP  PP  ", "  PP  PP  "
];
const PLAYER_COLORS = {'H':'#2d2d2d','F':'#e8d8b0','P':'#1a1a2e'};

function drawPixelArt(ctx, x, y, size, colorMap, pixelData) {
    const rows = pixelData.length;
    const cols = pixelData[0].length;
    const px = size / Math.max(rows, cols);
    const offsetX = -cols * px / 2;
    const offsetY = -rows * px / 2;
    for (let r = 0; r < rows; r++) {
        for (let c = 0; c < cols; c++) {
            const char = pixelData[r][c];
            if (char === ' ' || char === '.') continue;
            const color = colorMap[char];
            if (color) {
                ctx.fillStyle = color;
                ctx.fillRect(offsetX + c * px, offsetY + r * px, px + 0.5, px + 0.5);
            }
        }
    }
}

function drawCharacter(spriteData, colorMap, x, y, size, moving, phase=0) {
    ctx.save();
    ctx.translate(x, y);
    if (moving) {
        const t = phase * 10;
        ctx.translate(0, -Math.abs(Math.sin(t)) * 2.5);
        ctx.rotate(Math.sin(t) * 0.055);
    }
    drawPixelArt(ctx, 0, 0, size, colorMap, spriteData);
    ctx.restore();
}

let p,enemies,skeletons,shots,bossArrows,hp,mana,wave,spawned,killed;
let mode,breakTime,spawnTimer,invincible,magicCD,summonCD,batCD,batTime;
let bossShootCD;
let joy={x:0,y:0},pointer=null,keys={},last=0;
let animTime=0;
let playerMoving=false;

const rand=(a,b)=>a+Math.random()*(b-a);
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));
const size=n=>n===1?5:n<=4?9:n<=7?17:n<=10?25:n===11?20:n<=13?26:n<=15?30:1;

function reset(){
 p={x:400,y:320};enemies=[];skeletons=[];shots=[];bossArrows=[];
 hp=100;mana=100;wave=1;spawned=0;killed=0;
 mode="fight";breakTime=0;spawnTimer=0;invincible=0;
 magicCD=summonCD=batCD=batTime=0;
 bossShootCD=0;
 animTime=0;playerMoving=false;
 spawnEnemy();$("status").textContent="Защити замок!";hud();
}
function spawnEnemy(){
 if(wave===16){
  enemies.push({x:rand(180,620),y:280,r:30,hp:700,max:700,speed:25,boss:true});
 }else{
  let side=Math.floor(rand(0,4));
  enemies.push({
   x:side===0?8:side===1?792:rand(0,800),
   y:side===2?270:side===3?355:rand(260,350),
   r:12, hp:26.4, max:26.4, speed:43, boss:false
  });
 }
 spawned++;
}
function hud(){
 $("hp").textContent=Math.ceil(hp);
 $("mana").textContent=Math.floor(mana);
 $("wave").textContent=wave+"/16";
 $("left").textContent=mode==="break"?"Перерыв: "+Math.ceil(breakTime)+"с":
 mode==="fight"?(wave===16?"БОСС":Math.max(0,size(wave)-killed)):"—";
}
function nearest(x,y){
 let best=null,d0=Infinity;
 for(const e of enemies){let d=Math.hypot(e.x-x,e.y-y);if(d<d0){best=e;d0=d}}
 return best;
}
function magic(){
 if(mode!=="fight"||magicCD>0||mana<8)return;
 let e=nearest(p.x,p.y);if(!e)return;
 mana-=8;magicCD=.3;shots.push({x:p.x,y:p.y,e,damage:18});hud();
}
function summon(){
 if(mode==="won"||mode==="lost"||summonCD>0||mana<70)return;
 if(skeletons.length>0)return;
 mana-=70;summonCD=1;
 for(let i=0;i<3;i++){
  let a=i*Math.PI*2/3;
  skeletons.push({x:p.x+Math.cos(a)*15,y:p.y+Math.sin(a)*15,
   dx:Math.cos(a),dy:Math.sin(a),spread:.25,hp:45,max:45,
   damage:12,speed:84,cd:0});
 }
 hud();
}
function bat(){
 if(mode!=="fight"||batCD>0||mana<20)return;
 mana-=20;batCD=5;batTime=4;hud();
}
$("magic").onclick=magic;
$("summon").onclick=summon;
$("bat").onclick=bat;
$("restart").onclick=reset;

const pad=$("pad"),knob=$("knob");
function setJoy(e){
 let r=pad.getBoundingClientRect();
 let x=e.clientX-r.left-r.width/2;
 let y=e.clientY-r.top-r.height/2;
 let d=Math.hypot(x,y),radius=r.width*.25;
 joy=d<4?{x:0,y:0}:{x:x/d*Math.min(1,d/radius),y:y/d*Math.min(1,d/radius)};
 knob.style.transform=`translate(${joy.x*radius}px,${joy.y*radius}px)`;
}
pad.addEventListener("pointerdown",e=>{
 if(pointer!==null)return;
 pointer=e.pointerId;pad.setPointerCapture(pointer);setJoy(e);
});
pad.addEventListener("pointermove",e=>{if(e.pointerId===pointer)setJoy(e)});
function stopJoy(e){
 if(e.pointerId===pointer){pointer=null;joy={x:0,y:0};knob.style.transform=""}
}
pad.addEventListener("pointerup",stopJoy);
pad.addEventListener("pointercancel",stopJoy);
pad.addEventListener("lostpointercapture",stopJoy);

window.addEventListener("keydown",e=>{
 let k=e.key.toLowerCase();keys[k]=true;
 if(["arrowup","arrowdown","arrowleft","arrowright"," "].includes(k))e.preventDefault();
 if(e.repeat)return;
 if(k===" ")magic();if(k==="q")summon();if(k==="e")bat();
});
window.addEventListener("keyup",e=>keys[e.key.toLowerCase()]=false);
window.addEventListener("blur",()=>{
 keys={};pointer=null;joy={x:0,y:0};knob.style.transform="";
});

// === ИЗМЕНЕНО: скелеты всегда активны, даже во время перерыва ===
function updateSkeletons(dt){
 for(const s of skeletons){
  if(s.spread>0){
   let t=Math.min(dt,s.spread);s.x+=s.dx*90*t;s.y+=s.dy*90*t;s.spread-=t;continue;
  }
  // Убрана проверка mode!=="fight" — скелеты двигаются всегда
  s.cd-=dt;let e=nearest(s.x,s.y);if(!e)continue;
  let d=Math.hypot(e.x-s.x,e.y-s.y)||1;
  if(d>23){s.x+=(e.x-s.x)/d*s.speed*dt;s.y+=(e.y-s.y)/d*s.speed*dt}
  else if(s.cd<=0){e.hp-=s.damage;s.cd=.65}
 }
}

function update(dt){
 if(mode==="won"||mode==="lost")return;
 
 mana=Math.min(300,mana+7.5*dt); 
 
 magicCD=Math.max(0,magicCD-dt);
 summonCD=Math.max(0,summonCD-dt);
 batCD=Math.max(0,batCD-dt);
 batTime=Math.max(0,batTime-dt);
 invincible=Math.max(0,invincible-dt);
 bossShootCD=Math.max(0,bossShootCD-dt);

 let x=joy.x,y=joy.y;
 if(keys.a||keys.arrowleft)x--;
 if(keys.d||keys.arrowright)x++;
 if(keys.w||keys.arrowup)y--;
 if(keys.s||keys.arrowdown)y++;
 const d=Math.hypot(x,y);
 playerMoving = d>0.01;
 if(playerMoving){
  p.x=clamp(p.x+x/d*175*dt,15,785);
  p.y=clamp(p.y+y/d*175*dt,250,352);
 }

 if(mode==="break"){
  breakTime-=dt;
  if(breakTime<=0){
   if(wave===15){wave=16;startWave()}
   else{wave++;startWave()}
  }
  updateSkeletons(dt);hud();return;
 }
 if(wave<16&&spawned<size(wave)){
  spawnTimer-=dt;
  if(spawnTimer<=0&&enemies.length<10){spawnEnemy();spawnTimer=.85}
 }
 for(const e of enemies){
  let target=p,dist=Math.hypot(p.x-e.x,p.y-e.y);
  for(const s of skeletons){
   let ds=Math.hypot(s.x-e.x,s.y-e.y);
   if(ds<dist){dist=ds;target=s}
  }
  if(dist>e.r+11){
   e.x+=(target.x-e.x)/dist*e.speed*dt;
   e.y+=(target.y-e.y)/dist*e.speed*dt;
  }else if(target===p&&invincible<=0){
   hp-=e.boss?23:5.5;
   invincible=.65;
   if(hp<=0){hp=0;mode="lost";$("status").textContent="Поражение! Нажми «Заново»."}
  }
  if(e.boss && bossShootCD<=0 && mode==="fight"){
   bossShootCD = 1.8;
   let dx = p.x - e.x, dy = p.y - e.y;
   let dd = Math.hypot(dx, dy) || 1;
   bossArrows.push({x: e.x, y: e.y, dx: dx/dd, dy: dy/dd, damage: 9, life: 3});
  }
 }
 updateSkeletons(dt);
 if(batTime>0){let e=nearest(p.x,p.y);if(e)e.hp-=12*dt}
 for(const s of shots){
  if(!enemies.includes(s.e)){s.dead=true;continue}
  let dx=s.e.x-s.x,dy=s.e.y-s.y,d=Math.hypot(dx,dy)||1,step=440*dt;
  if(d<step+s.e.r){s.e.hp-=s.damage;s.dead=true}
  else{s.x+=dx/d*step;s.y+=dy/d*step}
 }
 shots=shots.filter(s=>!s.dead);
 
 for(const a of bossArrows){
  a.x += a.dx * 240 * dt;
  a.y += a.dy * 240 * dt;
  a.life -= dt;
  if(a.life<=0){a.dead=true;continue}
  if(Math.hypot(a.x-p.x, a.y-p.y) < 15 && invincible<=0){
   hp -= a.damage;
   invincible = 0.5;
   a.dead = true;
   if(hp<=0){hp=0;mode="lost";$("status").textContent="Поражение! Нажми «Заново»."}
  }
 }
 bossArrows = bossArrows.filter(a=>!a.dead);
 
 let before=enemies.length;
 enemies=enemies.filter(e=>e.hp>0);killed+=before-enemies.length;
 if(wave<16&&spawned>=size(wave)&&enemies.length===0){
  mode="break";breakTime=8;$("status").textContent="Перерыв! Следующая волна через 8 секунд.";
 }
 if(wave===16&&enemies.length===0){
  mode="won";$("status").textContent="🏆 Победа! Босс повержен!";
 }
 hud();
}

function startWave(){
 enemies=[];shots=[];spawned=0;killed=0;mode="fight";spawnTimer=0;
 bossArrows=[];bossShootCD=0;
 for(let i=0;i<Math.min(3,size(wave));i++)spawnEnemy();
 $("status").textContent=wave===16?"☠ Финальный босс!":"Волна "+wave+" началась!";
 hud();
}

const stars=Array.from({length:90},()=>({x:rand(0,800),y:rand(0,245),r:rand(.5,1.8)}));

function draw(){
 ctx.fillStyle="#111126";ctx.fillRect(0,0,W,H);
 ctx.fillStyle="#b9a8db";for(const s of stars)ctx.fillRect(s.x,s.y,s.r,s.r);
 
 ctx.fillStyle="#aaa1c7";ctx.beginPath();ctx.arc(625,90,39,0,7);ctx.fill();
 ctx.fillStyle="#111126";ctx.beginPath();ctx.arc(644,75,35,0,7);ctx.fill();
 
 if (CASTLE_IMG.complete && CASTLE_IMG.naturalWidth > 0) {
     const imgRatio = CASTLE_IMG.naturalWidth / CASTLE_IMG.naturalHeight;
     const canvasRatio = W / H;
     let drawW, drawH, drawX, drawY;
     if (imgRatio > canvasRatio) {
         drawH = H; drawW = H * imgRatio;
         drawX = (W - drawW) / 2; drawY = 0;
     } else {
         drawW = W; drawH = W / imgRatio;
         drawX = 0; drawY = (H - drawH) / 2;
     }
     drawY += 60; 
     ctx.drawImage(CASTLE_IMG, drawX, drawY, drawW, drawH);
 } else {
     ctx.fillStyle="#272039";
     for(let i=0;i<8;i++){
      let xx=i*110;ctx.fillRect(xx,205,68,150);
      ctx.beginPath();ctx.moveTo(xx,205);ctx.lineTo(xx+34,158);ctx.lineTo(xx+68,205);ctx.fill();
     }
     ctx.fillStyle="#a9e78b";for(let i=0;i<8;i++)ctx.fillRect(20+i*103,235+i%3*20,7,13);
 }

 ctx.fillStyle="#211a2c";ctx.fillRect(0,360,W,90);
 ctx.fillStyle="#40583d";ctx.fillRect(0,356,W,7);
 for(let i=0;i<70;i++){ctx.fillStyle=i%2?"#718654":"#354a3a";ctx.fillRect(i*12,356+i%4,7,4)}
 for(let i=0;i<13;i++){
  let gx=(i*71+25)%790,gy=382+i%4*12;
  ctx.fillStyle="#454057";ctx.fillRect(gx,gy,15,23);ctx.fillRect(gx+4,gy-5,7,7);
 }
 
 for(const b of shots){
  ctx.fillStyle="#70ffc3";ctx.shadowColor="#70ffc3";ctx.shadowBlur=12;
  ctx.beginPath();ctx.arc(b.x,b.y,6,0,7);ctx.fill();ctx.shadowBlur=0;
 }
 
 for(const a of bossArrows){
  ctx.save();
  ctx.translate(a.x, a.y);
  ctx.rotate(Math.atan2(a.dy, a.dx));
  ctx.fillStyle="#ff5555";
  ctx.shadowColor="#ff0000";ctx.shadowBlur=8;
  ctx.fillRect(-8, -2, 16, 4);
  ctx.beginPath();
  ctx.moveTo(8, -4); ctx.lineTo(14, 0); ctx.lineTo(8, 4);
  ctx.fillStyle="#ffaa00";
  ctx.fill();
  ctx.shadowBlur=0;
  ctx.restore();
 }
 
 for(const s of skeletons){
  drawCharacter(SKELETON_SPRITE, SKELETON_COLORS, s.x, s.y+8, 24, true, animTime+s.x*0.02);
  ctx.fillStyle="#501c32";ctx.fillRect(s.x-12,s.y-14,24,3);
  ctx.fillStyle="#b8f6a1";ctx.fillRect(s.x-12,s.y-14,24*Math.max(0,s.hp/s.max),3);
 }
 
 for(const e of enemies){
  const sprite = e.boss ? BOSS_SPRITE : ZOMBIE_SPRITE;
  const colors = e.boss ? BOSS_COLORS : ZOMBIE_COLORS;
  drawCharacter(sprite, colors, e.x, e.y+8, e.boss?42:25, true, animTime+e.x*0.01);
  ctx.fillStyle="#501c32";ctx.fillRect(e.x-22,e.y-27,44,5);
  ctx.fillStyle=e.boss?"#ff365f":"#9bec68";
  ctx.fillRect(e.x-22,e.y-27,44*Math.max(0,e.hp/e.max),5);
 }
 
 ctx.globalAlpha=invincible>0?.5:1;
 drawCharacter(PLAYER_SPRITE, PLAYER_COLORS, p.x, p.y+10, 31, playerMoving, animTime);
 if(batTime>0)ctx.fillText("🦇",p.x+25,p.y-15);
 ctx.globalAlpha=1;
 
 // === ИЗМЕНЕНО: убрана надпись "ПЕРЕРЫВ", оставлены только победа/поражение ===
 if(mode==="won"||mode==="lost"){
  ctx.fillStyle="#080817dd";ctx.fillRect(0,0,W,H);
  ctx.textAlign="center";ctx.fillStyle=mode==="won"?"#85ffba":"#ff7897";
  ctx.font="bold 30px system-ui";
  ctx.fillText(mode==="won"?"ПОБЕДА!":"ПОРАЖЕНИЕ",W/2,H/2);
 }
}

function frame(now){
 let dt=Math.min(.04,(now-last)/1000||0);last=now;
 animTime+=dt;
 update(dt);draw();requestAnimationFrame(frame);
}
reset();requestAnimationFrame(frame);
</script>
</body>
</html>

Game Source: Некромант: Готический замок

Creator: NovaWizard24

Libraries: none

Complexity: complex (425 lines, 15.1 KB)

The full source code is displayed above on this page.

Remix Instructions

To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: game-novawizard24" to link back to the original. Then publish at arcadelab.ai/publish.