refactor: рабочая база на three.js + меню/правила уровней + доки
Полный рефакторинг проекта в стабильную расширяемую базу рогалика. Почему: после разнесения single-file на модули потерялся вызов генерации карты (RoomMap не генерировал комнаты) → игра падала на старте; баг скрывался тем, что Bun-бандлер не проверяет типы. Архитектура: - Логика игры (src/core/) полностью отделена от рендера: без three.js и DOM, тестируется без браузера. - Рендер мира на three.js с ортокамерой (2D-вид); HUD/миникарта — 2D-канвас поверх. - Фиксированный игровой цикл 60 Гц + интерполяция (раньше скорость зависела от частоты кадров). - Seeded-RNG, ввод через абстрактные «намерения» (InputState). Возможности: - Стартовое меню с выбором уровня (Esc → меню). - Конфигуратор уровней: LevelRules + 5 пресетов (размер карты, плотность/сила врагов, HP, фиксированный seed). - Тема внешнего вида (render/theme.ts) — задел под кастомные ассеты. Качество: - 27 юнит-тестов ядра (генерация, симметрия дверей, коллизии, спавн, правила). - Два круга adversarial-ревью; исправлено 6 реальных багов (кнокбэк сквозь стены → софт-лок; незакрываемая сокровищница; фикс-сид после рестарта; перенос ввода между забегами; нет source maps; неточности в доках). - Документация: README, docs/ARCHITECTURE.md, CLAUDE.md, docs/HOWTO.md. - dist/ исключён из гита; bun.lock зафиксирован. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+58
-694
@@ -1,702 +1,66 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Dungeon Crawl — Ranged / Melee</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:#0a0a0a;display:flex;justify-content:center;align-items:center;height:100vh;font-family:monospace;overflow:hidden;user-select:none}
|
||||
canvas{display:block;border:1px solid #222;border-radius:2px;cursor:none}
|
||||
</style>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Dungeon Crawl — three.js</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
background: #0a0a0a;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
font-family: monospace;
|
||||
color: #ccc;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
/* Сцена = два наложенных холста: WebGL-мир снизу, 2D-HUD сверху.
|
||||
width/height: 100% обязательны — иначе canvas (replaced-элемент)
|
||||
показывается в размер своего HiDPI-буфера и вылезает за рамки. */
|
||||
#stage { position: relative; width: 880px; height: 660px; border: 1px solid #222; }
|
||||
#stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#hud { pointer-events: none; }
|
||||
|
||||
/* Стартовое меню — поверх холстов, перекрывает сцену своим фоном. */
|
||||
#menu {
|
||||
position: absolute; inset: 0; z-index: 10;
|
||||
background: #0a0a0fee;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 14px; padding: 24px;
|
||||
}
|
||||
#menu h1 { font-size: 40px; letter-spacing: 4px; color: #c9b27a; }
|
||||
.menu-sub { color: #888; margin-bottom: 6px; }
|
||||
#menu-presets { display: flex; flex-direction: column; gap: 10px; width: 360px; max-width: 90%; }
|
||||
.menu-preset {
|
||||
display: flex; flex-direction: column; gap: 3px;
|
||||
text-align: left; cursor: pointer;
|
||||
background: #15151f; color: #ddd;
|
||||
border: 1px solid #333; border-radius: 4px;
|
||||
padding: 12px 14px; font-family: monospace; font-size: 14px;
|
||||
transition: background .12s, border-color .12s;
|
||||
}
|
||||
.menu-preset:hover { background: #1d1d2c; border-color: #5a78c0; }
|
||||
.menu-preset-name { font-weight: bold; color: #cdd6f4; }
|
||||
.menu-preset-desc { font-size: 11px; color: #888; }
|
||||
.menu-hint { color: #555; font-size: 12px; margin-top: 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="game" width="880" height="660"></canvas>
|
||||
<script>
|
||||
// ============================================================
|
||||
// CONSTANTS
|
||||
// ============================================================
|
||||
const CW=880,CH=660,TILE=44,COLS=15,ROWS=11;
|
||||
const RW=COLS*TILE,RH=ROWS*TILE;
|
||||
const OX=(CW-RW)/2,OY=80;
|
||||
<div id="stage">
|
||||
<canvas id="game" width="880" height="660"></canvas>
|
||||
<canvas id="hud" width="880" height="660"></canvas>
|
||||
|
||||
const T_WALL=0,T_FLOOR=1,T_DOOR=2;
|
||||
const MODE_RANGED=0,MODE_MELEE=1;
|
||||
|
||||
const DIR={
|
||||
up:[0,-1], down:[0,1], left:[-1,0], right:[1,0]
|
||||
};
|
||||
const OPP={up:'bottom',down:'top',left:'right',right:'left'};
|
||||
|
||||
const DOOR={
|
||||
up: { cols:[6,7,8], row:0, cx:7, cy:0 },
|
||||
down: { cols:[6,7,8], row:10, cx:7, cy:10 },
|
||||
left: { col:0, rows:[4,5,6], cx:0, cy:5 },
|
||||
right:{ col:14, rows:[4,5,6], cx:14,cy:5 }
|
||||
};
|
||||
|
||||
let KEYS={},GAME_OVER=false,WON=false;
|
||||
|
||||
// ============================================================
|
||||
// UTILITY
|
||||
// ============================================================
|
||||
function shuffle(a){for(let i=a.length-1;i>0;i--){const j=Math.random()*i|0;[a[i],a[j]]=[a[j],a[i]]}return a}
|
||||
function rand(a,b){return Math.random()*(b-a)+a}
|
||||
function ri(a,b){return Math.floor(rand(a,b+1))}
|
||||
function dist(x1,y1,x2,y2){return Math.hypot(x2-x1,y2-y1)}
|
||||
function overlap(a,b){return a.x<b.x+b.w&&a.x+a.w>b.x&&a.y<b.y+b.h&&a.y+a.h>b.y}
|
||||
|
||||
// ============================================================
|
||||
// ROOM
|
||||
// ============================================================
|
||||
class Room{
|
||||
constructor(c,r,type){
|
||||
this.c=c;this.r=r;this.type=type;
|
||||
this.doors={up:false,down:false,left:false,right:false};
|
||||
this.visited=false;this.cleared=false;
|
||||
this.enemies=[];this.tears=[];
|
||||
this.tiles=[];this.buildTiles();
|
||||
}
|
||||
buildTiles(){
|
||||
for(let r=0;r<ROWS;r++){
|
||||
this.tiles[r]=[];
|
||||
for(let c=0;c<COLS;c++){
|
||||
this.tiles[r][c]=(r===0||r===ROWS-1||c===0||c===COLS-1)?T_WALL:T_FLOOR;
|
||||
}
|
||||
}
|
||||
if(this.cleared)this.placeDoors();
|
||||
}
|
||||
placeDoors(){
|
||||
if(this.doors.up)for(const c of DOOR.up.cols)this.tiles[DOOR.up.row][c]=T_DOOR;
|
||||
if(this.doors.down)for(const c of DOOR.down.cols)this.tiles[DOOR.down.row][c]=T_DOOR;
|
||||
if(this.doors.left)for(const r of DOOR.left.rows)this.tiles[r][DOOR.left.col]=T_DOOR;
|
||||
if(this.doors.right)for(const r of DOOR.right.rows)this.tiles[r][DOOR.right.col]=T_DOOR;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ROOM MAP
|
||||
// ============================================================
|
||||
class RoomMap{
|
||||
constructor(){this.rooms={};this.generate()}
|
||||
key(c,r){return c+','+r}
|
||||
get(c,r){return this.rooms[this.key(c,r)]}
|
||||
has(c,r){return !!this.get(c,r)}
|
||||
add(c,r,t){const o=new Room(c,r,t);this.rooms[this.key(c,r)]=o;return o}
|
||||
|
||||
hasBoss(){return Object.values(this.rooms).some(r=>r.type==='boss')}
|
||||
|
||||
generate(){
|
||||
this.add(0,0,'spawn');
|
||||
let frontier=[[0,0]],count=1,target=8+ri(0,4);
|
||||
const dirs=[['up',0,-1],['down',0,1],['left',-1,0],['right',1,0]];
|
||||
while(frontier.length>0&&count<target){
|
||||
const idx=ri(0,frontier.length-1);
|
||||
const [cr,cc]=frontier[idx];
|
||||
shuffle(dirs);
|
||||
let added=false;
|
||||
for(const [d,dc,dr] of dirs){
|
||||
if(count>=target)break;
|
||||
const nc=cr+dc,nr=cc+dr;
|
||||
if(Math.abs(nc)>3||Math.abs(nr)>3||this.has(nc,nr))continue;
|
||||
let type='normal';
|
||||
if((count===target-1||(Math.random()<0.2&&count>=3))&&!this.hasBoss())type='boss';
|
||||
else if(Math.random()<0.12&&count>=2)type='treasure';
|
||||
this.add(nc,nr,type);
|
||||
this.get(cr,cc).doors[d]=true;
|
||||
this.get(nc,nr).doors[OPP[d]]=true;
|
||||
frontier.push([nc,nr]);count++;added=true;
|
||||
}
|
||||
if(!added)frontier.splice(idx,1);
|
||||
}
|
||||
if(!this.hasBoss()){
|
||||
const cs=Object.values(this.rooms).filter(r=>r.type==='normal');
|
||||
if(cs.length>0)cs[ri(0,cs.length-1)].type='boss';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ENTITIES
|
||||
// ============================================================
|
||||
class Player{
|
||||
constructor(){
|
||||
this.x=0;this.y=0;this.w=26;this.h=26;
|
||||
this.speed=3.2;this.hp=6;this.maxHp=6;
|
||||
this.mode=MODE_RANGED;this.facing='up';this.moveDir='up';
|
||||
this.atkCD=0;this.invTimer=0;this.transCD=0;
|
||||
}
|
||||
get box(){return{x:this.x-this.w/2,y:this.y-this.h/2,w:this.w,h:this.h}}
|
||||
}
|
||||
|
||||
class Enemy{
|
||||
constructor(x,y,type){
|
||||
this.x=x;this.y=y;this.type=type;
|
||||
this.w=type==='boss'?46:type==='fast'?26:32;
|
||||
this.h=this.w;
|
||||
this.hp=type==='boss'?10:type==='fast'?2:3;
|
||||
this.maxHp=this.hp;
|
||||
this.speed=type==='boss'?0.9:type==='fast'?1.9:1.15;
|
||||
this.damage=type==='boss'?2:1;
|
||||
this.knx=0;this.kny=0;this.hitTimer=0;this.atkTimer=0;
|
||||
}
|
||||
get box(){return{x:this.x-this.w/2,y:this.y-this.h/2,w:this.w,h:this.h}}
|
||||
get alive(){return this.hp>0}
|
||||
}
|
||||
|
||||
class Tear{
|
||||
constructor(x,y,dx,dy){
|
||||
this.x=x;this.y=y;this.dx=dx;this.dy=dy;
|
||||
this.r=5;this.speed=7;this.damage=1;this.life=80;
|
||||
}
|
||||
get alive(){return this.life>0}
|
||||
}
|
||||
|
||||
class MeleeSwing{
|
||||
constructor(x,y,dir){
|
||||
this.dir=dir;this.life=10;this.damage=2;this.kb=10;
|
||||
const d=22,s=50;
|
||||
switch(dir){
|
||||
case'up': this.box={x:x-s/2,y:y-d-s,w:s,h:s};break;
|
||||
case'down': this.box={x:x-s/2,y:y+d,w:s,h:s};break;
|
||||
case'left': this.box={x:x-d-s,y:y-s/2,w:s,h:s};break;
|
||||
case'right':this.box={x:x+d,y:y-s/2,w:s,h:s};break;
|
||||
}
|
||||
}
|
||||
get alive(){return this.life>0}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GAME
|
||||
// ============================================================
|
||||
let game=null;
|
||||
|
||||
class Game{
|
||||
constructor(){
|
||||
this.canvas=document.getElementById('game');
|
||||
this.ctx=this.canvas.getContext('2d');
|
||||
this.map=new RoomMap();
|
||||
this.player=new Player();
|
||||
this.cc=0;this.cr=0;this.meleeSwing=null;
|
||||
this.setupInput();
|
||||
this.enterRoom('up');
|
||||
this.update();
|
||||
}
|
||||
get cur(){return this.map.get(this.cc,this.cr)}
|
||||
|
||||
setupInput(){
|
||||
window.addEventListener('keydown',e=>{
|
||||
if((e.key==='Tab'||e.key==='q'||e.key==='Q')&&!GAME_OVER&&!WON){
|
||||
e.preventDefault();this.player.mode=this.player.mode===MODE_RANGED?MODE_MELEE:MODE_RANGED;
|
||||
}
|
||||
if(e.key==='r'||e.key==='R'){if(GAME_OVER||WON)this.restart()}
|
||||
KEYS[e.key]=true;
|
||||
if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(e.key))e.preventDefault();
|
||||
});
|
||||
window.addEventListener('keyup',e=>{KEYS[e.key]=false});
|
||||
window.addEventListener('blur',()=>{KEYS={}});
|
||||
}
|
||||
|
||||
restart(){
|
||||
GAME_OVER=false;WON=false;
|
||||
this.map=new RoomMap();this.player=new Player();
|
||||
this.cc=0;this.cr=0;this.meleeSwing=null;
|
||||
this.enterRoom('up');
|
||||
}
|
||||
|
||||
enterRoom(fromDir){
|
||||
const room=this.cur;
|
||||
room.visited=true;
|
||||
this.entryDir=fromDir;
|
||||
const d=DOOR[fromDir],[ddc,ddr]=DIR[fromDir];
|
||||
|
||||
// place player 1 tile inside from the door center
|
||||
this.player.x=OX+d.cx*TILE+TILE/2-ddc*TILE;
|
||||
this.player.y=OY+d.cy*TILE+TILE/2-ddr*TILE;
|
||||
this.player.facing=fromDir;
|
||||
this.player.invTimer=20;
|
||||
this.player.transCD=15; // prevent re-transition for 15 frames
|
||||
this.meleeSwing=null;
|
||||
|
||||
room.buildTiles();
|
||||
room.enemies=[];
|
||||
room.tears=[];
|
||||
|
||||
if(!room.cleared&&room.type!=='spawn'){
|
||||
this.spawnEnemies(room,fromDir);
|
||||
}else{
|
||||
room.cleared=true;
|
||||
room.buildTiles();
|
||||
}
|
||||
}
|
||||
|
||||
spawnEnemies(room,entryDir){
|
||||
const count=room.type==='boss'?1:room.type==='treasure'?0:2+ri(0,2);
|
||||
for(let i=0;i<count;i++){
|
||||
let tries=0,x,y,ok;
|
||||
const type=room.type==='boss'?'boss':Math.random()<0.3?'fast':'normal';
|
||||
do{
|
||||
x=OX+2*TILE+rand(0,COLS-4)*TILE;
|
||||
y=OY+2*TILE+rand(0,ROWS-4)*TILE;
|
||||
ok=true;
|
||||
const ed=DOOR[entryDir],dx=OX+ed.cx*TILE+TILE/2,dy=OY+ed.cy*TILE+TILE/2;
|
||||
if(dist(x,y,dx,dy)<180)ok=false;
|
||||
for(const e of room.enemies)if(dist(x,y,e.x,e.y)<60)ok=false;
|
||||
if(dist(x,y,this.player.x,this.player.y)<150)ok=false;
|
||||
tries++;
|
||||
}while(!ok&&tries<100);
|
||||
room.enemies.push(new Enemy(x,y,type));
|
||||
}
|
||||
}
|
||||
|
||||
isBlocked(room,col,row){
|
||||
// allow passing through the room boundary at door openings
|
||||
if(row<0&&room.doors.up&&DOOR.up.cols.includes(col))return false;
|
||||
if(row>=ROWS&&room.doors.down&&DOOR.down.cols.includes(col))return false;
|
||||
if(col<0&&room.doors.left&&DOOR.left.rows.includes(row))return false;
|
||||
if(col>=COLS&&room.doors.right&&DOOR.right.rows.includes(row))return false;
|
||||
if(row<0||row>=ROWS||col<0||col>=COLS)return true;
|
||||
return room.tiles[row][col]===T_WALL;
|
||||
}
|
||||
|
||||
collidesWall(ent,room){
|
||||
const l=Math.floor((ent.x-ent.w/2-OX)/TILE);
|
||||
const r=Math.floor((ent.x+ent.w/2-OX)/TILE);
|
||||
const t=Math.floor((ent.y-ent.h/2-OY)/TILE);
|
||||
const b=Math.floor((ent.y+ent.h/2-OY)/TILE);
|
||||
for(let row=t;row<=b;row++)
|
||||
for(let col=l;col<=r;col++)
|
||||
if(this.isBlocked(room,col,row))return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- TRANSITION: player stands on a door tile and moves into it ---
|
||||
checkTransition(){
|
||||
if(GAME_OVER||WON)return;
|
||||
if(this.player.transCD>0)return;
|
||||
const p=this.player,room=this.cur;
|
||||
if(!room.cleared)return;
|
||||
const col=Math.floor((p.x-OX)/TILE),row=Math.floor((p.y-OY)/TILE);
|
||||
|
||||
// top door
|
||||
if(row===0&&room.doors.up&&DOOR.up.cols.includes(col)&&(KEYS['w']||KEYS['W']||KEYS['ArrowUp'])){
|
||||
if(this.map.has(this.cc,this.cr-1)){this.cr--;this.enterRoom('down');return}
|
||||
}
|
||||
// bottom door
|
||||
if(row===ROWS-1&&room.doors.down&&DOOR.down.cols.includes(col)&&(KEYS['s']||KEYS['S']||KEYS['ArrowDown'])){
|
||||
if(this.map.has(this.cc,this.cr+1)){this.cr++;this.enterRoom('up');return}
|
||||
}
|
||||
// left door
|
||||
if(col===0&&room.doors.left&&DOOR.left.rows.includes(row)&&(KEYS['a']||KEYS['A']||KEYS['ArrowLeft'])){
|
||||
if(this.map.has(this.cc-1,this.cr)){this.cc--;this.enterRoom('right');return}
|
||||
}
|
||||
// right door
|
||||
if(col===COLS-1&&room.doors.right&&DOOR.right.rows.includes(row)&&(KEYS['d']||KEYS['D']||KEYS['ArrowRight'])){
|
||||
if(this.map.has(this.cc+1,this.cr)){this.cc++;this.enterRoom('left');return}
|
||||
}
|
||||
}
|
||||
|
||||
update(){
|
||||
if(!GAME_OVER&&!WON)this.tick();
|
||||
this.render();
|
||||
requestAnimationFrame(()=>this.update());
|
||||
}
|
||||
|
||||
tick(){
|
||||
const room=this.cur,p=this.player;
|
||||
|
||||
if(p.invTimer>0)p.invTimer--;
|
||||
if(p.atkCD>0)p.atkCD--;
|
||||
if(p.transCD>0)p.transCD--;
|
||||
|
||||
// --- MOVEMENT ---
|
||||
let mx=0,my=0;
|
||||
if(KEYS['w']||KEYS['W'])my=-1;
|
||||
if(KEYS['s']||KEYS['S'])my=1;
|
||||
if(KEYS['a']||KEYS['A'])mx=-1;
|
||||
if(KEYS['d']||KEYS['D'])mx=1;
|
||||
if(mx!==0||my!==0){
|
||||
const len=Math.hypot(mx,my);mx/=len;my/=len;
|
||||
if(my<0)p.moveDir='up';else if(my>0)p.moveDir='down';
|
||||
if(mx<0)p.moveDir='left';else if(mx>0)p.moveDir='right';
|
||||
const dx=mx*p.speed,dy=my*p.speed;
|
||||
p.x+=dx;if(this.collidesWall(p,room))p.x-=dx;
|
||||
p.y+=dy;if(this.collidesWall(p,room))p.y-=dy;
|
||||
}
|
||||
|
||||
// --- ATTACK ---
|
||||
let ax=0,ay=0;
|
||||
if(KEYS['ArrowUp'])ax=0,ay=-1;
|
||||
else if(KEYS['ArrowDown'])ax=0,ay=1;
|
||||
else if(KEYS['ArrowLeft'])ax=-1,ay=0;
|
||||
else if(KEYS['ArrowRight'])ax=1,ay=0;
|
||||
else if(KEYS[' ']||KEYS['Space']){const[fx,fy]=DIR[p.moveDir];ax=fx;ay=fy}
|
||||
|
||||
if((ax!==0||ay!==0)&&p.atkCD<=0){
|
||||
const len=Math.hypot(ax,ay);ax/=len;ay/=len;
|
||||
const dn=ay<0?'up':ay>0?'down':ax<0?'left':'right';
|
||||
p.facing=dn;p.atkCD=p.mode===MODE_RANGED?10:22;
|
||||
if(p.mode===MODE_RANGED)room.tears.push(new Tear(p.x,p.y,ax,ay));
|
||||
else this.meleeSwing=new MeleeSwing(p.x,p.y,dn);
|
||||
}
|
||||
|
||||
// --- MELEE ---
|
||||
if(this.meleeSwing&&!this.meleeSwing.alive)this.meleeSwing=null;
|
||||
if(this.meleeSwing){
|
||||
this.meleeSwing.life--;
|
||||
for(const e of room.enemies){
|
||||
if(!e.alive||e.hitTimer>0)continue;
|
||||
if(overlap(e.box,this.meleeSwing.box)){
|
||||
e.hp-=this.meleeSwing.damage;
|
||||
e.hitTimer=10;
|
||||
const[dx,dy]=DIR[this.meleeSwing.dir];
|
||||
e.knx=dx*this.meleeSwing.kb;e.kny=dy*this.meleeSwing.kb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- TEARS ---
|
||||
for(const t of room.tears){
|
||||
if(!t.alive)continue;
|
||||
t.x+=t.dx*t.speed;t.y+=t.dy*t.speed;t.life--;
|
||||
const col=Math.floor((t.x-OX)/TILE),row=Math.floor((t.y-OY)/TILE);
|
||||
if(col<0||col>=COLS||row<0||row>=ROWS||t.life<=0){t.life=0;continue}
|
||||
if(room.tiles[row][col]===T_WALL){t.life=0;continue}
|
||||
for(const e of room.enemies){
|
||||
if(!e.alive)continue;
|
||||
if(dist(t.x,t.y,e.x,e.y)<e.w/2+t.r){e.hp-=t.damage;e.hitTimer=8;t.life=0;break}
|
||||
}
|
||||
}
|
||||
room.tears=room.tears.filter(t=>t.alive);
|
||||
|
||||
// --- ENEMY AI ---
|
||||
let aliveCount=0;
|
||||
for(const e of room.enemies){
|
||||
if(!e.alive)continue;
|
||||
aliveCount++;
|
||||
if(e.hitTimer>0)e.hitTimer--;
|
||||
if(Math.abs(e.knx)>0.1||Math.abs(e.kny)>0.1){
|
||||
e.x+=e.knx*3;e.y+=e.kny*3;e.knx*=0.85;e.kny*=0.85;
|
||||
continue;
|
||||
}
|
||||
e.knx=0;e.kny=0;
|
||||
const dx=p.x-e.x,dy=p.y-e.y,d=Math.hypot(dx,dy);
|
||||
if(d>0&&d<500){
|
||||
const s=e.speed,mx=dx/d*s,my=dy/d*s;
|
||||
e.x+=mx;if(this.collidesWall(e,room))e.x-=mx;
|
||||
e.y+=my;if(this.collidesWall(e,room))e.y-=my;
|
||||
}
|
||||
if(e.atkTimer>0)e.atkTimer--;
|
||||
if(dist(e.x,e.y,p.x,p.y)<(e.w+p.w)/2&&p.invTimer<=0&&e.atkTimer<=0){
|
||||
p.hp-=e.damage;p.invTimer=60;e.atkTimer=30;
|
||||
if(p.hp<=0){GAME_OVER=true;return}
|
||||
}
|
||||
}
|
||||
|
||||
// --- ROOM CLEARED ---
|
||||
if(room.enemies.length>0&&aliveCount===0&&!room.cleared){
|
||||
room.cleared=true;room.buildTiles();
|
||||
}
|
||||
|
||||
// --- TRANSITION ---
|
||||
this.checkTransition();
|
||||
|
||||
// --- WIN ---
|
||||
if(!GAME_OVER){
|
||||
const br=Object.values(this.map.rooms).find(r=>r.type==='boss');
|
||||
if(br&&br.cleared)WON=true;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// RENDER
|
||||
// ============================================================
|
||||
render(){
|
||||
const ctx=this.ctx;
|
||||
ctx.fillStyle='#0a0a0f';
|
||||
ctx.fillRect(0,0,CW,CH);
|
||||
this.drawRoom();
|
||||
this.drawEntities();
|
||||
this.drawHUD();
|
||||
this.drawMinimap();
|
||||
if(GAME_OVER)this.drawOverlay('#c33','GAME OVER');
|
||||
else if(WON)this.drawOverlay('#3c3','VICTORY');
|
||||
}
|
||||
|
||||
drawOverlay(c,t){
|
||||
const ctx=this.ctx;
|
||||
ctx.fillStyle='rgba(0,0,0,0.8)';ctx.fillRect(0,0,CW,CH);
|
||||
ctx.fillStyle=c;ctx.font='bold 56px monospace';ctx.textAlign='center';ctx.fillText(t,CW/2,CH/2-20);
|
||||
ctx.fillStyle='#888';ctx.font='18px monospace';ctx.fillText('[R] restart',CW/2,CH/2+40);
|
||||
}
|
||||
|
||||
drawRoom(){
|
||||
const ctx=this.ctx,room=this.cur;
|
||||
// floor
|
||||
for(let r=0;r<ROWS;r++)for(let c=0;c<COLS;c++){
|
||||
const x=OX+c*TILE,y=OY+r*TILE,t=room.tiles[r][c];
|
||||
if(t===T_WALL){
|
||||
ctx.fillStyle='#1a1a24';ctx.fillRect(x,y,TILE,TILE);
|
||||
ctx.fillStyle='#242436';ctx.fillRect(x+2,y+2,TILE-4,TILE-4);
|
||||
ctx.fillStyle='#1e1e2c';ctx.fillRect(x+4,y+4,TILE-8,TILE-8);
|
||||
// brick lines
|
||||
ctx.strokeStyle='#161620';ctx.lineWidth=1;
|
||||
ctx.beginPath();ctx.moveTo(x,y+TILE/2);ctx.lineTo(x+TILE,y+TILE/2);ctx.stroke();
|
||||
ctx.beginPath();ctx.moveTo(x+TILE/2,y);ctx.lineTo(x+TILE/2,y+TILE/2);ctx.stroke();
|
||||
}else if(t===T_DOOR){
|
||||
ctx.fillStyle='#0d0d14';ctx.fillRect(x,y,TILE,TILE);
|
||||
ctx.fillStyle='#2a1e0e';ctx.fillRect(x+6,y+6,TILE-12,TILE-12);
|
||||
ctx.fillStyle='#3a2e14';ctx.fillRect(x+10,y+10,TILE-20,TILE-20);
|
||||
}else{
|
||||
const d=(r+c)%2===0;
|
||||
ctx.fillStyle=d?'#2e2e24':'#353528';
|
||||
ctx.fillRect(x,y,TILE,TILE);
|
||||
}
|
||||
}
|
||||
// wall overlay gradient at edges
|
||||
const grad=ctx.createLinearGradient(OX,OY,OX+RW,OY);
|
||||
ctx.strokeStyle='rgba(0,0,0,0.3)';ctx.lineWidth=2;
|
||||
ctx.strokeRect(OX,OY,RW,RH);
|
||||
}
|
||||
|
||||
drawEntities(){
|
||||
const ctx=this.ctx,room=this.cur,p=this.player;
|
||||
|
||||
// --- ENEMIES ---
|
||||
for(const e of room.enemies){
|
||||
if(!e.alive)continue;
|
||||
const fl=e.hitTimer>0&&e.hitTimer%4<2;
|
||||
ctx.save();
|
||||
// shadow
|
||||
ctx.fillStyle='rgba(0,0,0,0.3)';ctx.beginPath();ctx.ellipse(e.x+2,e.y+e.h/4,e.w/3,4,0,0,Math.PI*2);ctx.fill();
|
||||
|
||||
if(e.type==='boss'){
|
||||
ctx.fillStyle=fl?'#ddd':'#5a0a0a';
|
||||
ctx.beginPath();ctx.arc(e.x,e.y,e.w/2,0,Math.PI*2);ctx.fill();
|
||||
ctx.fillStyle='#4a0808';ctx.beginPath();ctx.arc(e.x-3,e.y-3,e.w/2-4,0,Math.PI*2);ctx.fill();
|
||||
// eyes
|
||||
ctx.fillStyle=fl?'#000':'#ff3333';
|
||||
ctx.beginPath();ctx.arc(e.x-8,e.y-8,5,0,Math.PI*2);ctx.fill();
|
||||
ctx.beginPath();ctx.arc(e.x+8,e.y-8,5,0,Math.PI*2);ctx.fill();
|
||||
ctx.fillStyle='#000';ctx.beginPath();ctx.arc(e.x-8,e.y-8,2.5,0,Math.PI*2);ctx.fill();
|
||||
ctx.beginPath();ctx.arc(e.x+8,e.y-8,2.5,0,Math.PI*2);ctx.fill();
|
||||
// horns
|
||||
ctx.fillStyle=fl?'#bbb':'#3a0505';
|
||||
ctx.beginPath();ctx.moveTo(e.x-16,e.y-e.w/2+4);ctx.lineTo(e.x-8,e.y-e.w/2-16);ctx.lineTo(e.x,e.y-e.w/2+4);ctx.fill();
|
||||
ctx.beginPath();ctx.moveTo(e.x-4,e.y-e.w/2+4);ctx.lineTo(e.x+4,e.y-e.w/2-16);ctx.lineTo(e.x+12,e.y-e.w/2+4);ctx.fill();
|
||||
// HP
|
||||
if(e.hp<e.maxHp){
|
||||
ctx.fillStyle='#222';ctx.fillRect(e.x-22,e.y-e.h/2-14,44,4);
|
||||
ctx.fillStyle='#c33';ctx.fillRect(e.x-22,e.y-e.h/2-14,44*(e.hp/e.maxHp),4);
|
||||
}
|
||||
}else if(e.type==='fast'){
|
||||
ctx.fillStyle=fl?'#ddd':'#992222';
|
||||
ctx.beginPath();ctx.arc(e.x,e.y,e.w/2,0,Math.PI*2);ctx.fill();
|
||||
ctx.fillStyle='#771111';ctx.beginPath();ctx.arc(e.x-1,e.y-1,e.w/2-3,0,Math.PI*2);ctx.fill();
|
||||
ctx.fillStyle='#ff4444';
|
||||
ctx.beginPath();ctx.arc(e.x-5,e.y-4,3,0,Math.PI*2);ctx.fill();
|
||||
ctx.beginPath();ctx.arc(e.x+5,e.y-4,3,0,Math.PI*2);ctx.fill();
|
||||
ctx.fillStyle='#000';ctx.beginPath();ctx.arc(e.x-5,e.y-5,1.5,0,Math.PI*2);ctx.fill();
|
||||
ctx.beginPath();ctx.arc(e.x+5,e.y-5,1.5,0,Math.PI*2);ctx.fill();
|
||||
}else{
|
||||
ctx.fillStyle=fl?'#ccc':'#5a4a2e';
|
||||
ctx.fillRect(e.x-e.w/2,e.y-e.h/2,e.w,e.h);
|
||||
ctx.fillStyle='#4a3a1e';ctx.fillRect(e.x-e.w/2+3,e.y-e.h/2+3,e.w-6,e.h-6);
|
||||
ctx.fillStyle='#332816';ctx.fillRect(e.x-e.w/2+6,e.y-e.h/2+6,e.w-12,e.h-12);
|
||||
ctx.fillStyle='#ffcc66';
|
||||
ctx.fillRect(e.x-7,e.y-5,5,5);ctx.fillRect(e.x+2,e.y-5,5,5);
|
||||
ctx.fillStyle='#000';ctx.fillRect(e.x-6,e.y-4,3,3);ctx.fillRect(e.x+3,e.y-4,3,3);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// --- PLAYER ---
|
||||
ctx.save();
|
||||
const ifl=p.invTimer>0&&p.invTimer%6<3;
|
||||
const pCol=p.mode===MODE_RANGED?'#2a6a9a':'#9a3a2a';
|
||||
ctx.fillStyle=ifl?'#ddd':pCol;
|
||||
// body shape
|
||||
ctx.fillRect(p.x-p.w/2,p.y-p.h/2,p.w,p.h);
|
||||
ctx.fillStyle=ifl?'#ccc':'rgba(0,0,0,0.3)';
|
||||
ctx.fillRect(p.x-p.w/2+3,p.y-p.h/2+3,p.w-6,p.h-6);
|
||||
|
||||
// weapon
|
||||
const[fx,fy]=DIR[p.facing];
|
||||
const wx=p.x+fx*(p.w/2+4),wy=p.y+fy*(p.h/2+4);
|
||||
|
||||
if(p.mode===MODE_RANGED){
|
||||
// PISTOL
|
||||
ctx.strokeStyle=ifl?'#999':'#555';
|
||||
ctx.lineWidth=3;ctx.lineCap='round';
|
||||
// barrel
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(wx,wy);
|
||||
ctx.lineTo(wx+fx*14+fy*2,wy+fy*14+fx*2);
|
||||
ctx.stroke();
|
||||
// body
|
||||
ctx.fillStyle=ifl?'#aaa':'#444';
|
||||
const pw=14,ph=8;
|
||||
ctx.save();
|
||||
const rot=fy!==0?Math.PI/2*(fy<0?-1:1):fx<0?Math.PI:0;
|
||||
ctx.translate(p.x+fx*8,p.y+fy*8);
|
||||
ctx.rect(-pw/2,-ph/2,pw,ph);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
// muzzle flash on attack
|
||||
if(p.atkCD>8&&p.mode===MODE_RANGED){
|
||||
ctx.fillStyle='rgba(255,200,50,0.6)';
|
||||
ctx.beginPath();ctx.arc(wx+fx*16,wy+fy*16,6,0,Math.PI*2);ctx.fill();
|
||||
ctx.fillStyle='rgba(255,255,200,0.4)';
|
||||
ctx.beginPath();ctx.arc(wx+fx*18,wy+fy*18,8,0,Math.PI*2);ctx.fill();
|
||||
}
|
||||
}else{
|
||||
// KNIFE
|
||||
ctx.strokeStyle=ifl?'#bbb':'#ccc';
|
||||
ctx.lineWidth=2;
|
||||
// blade
|
||||
ctx.beginPath();
|
||||
const kx=wx+fx*6,ky=wy+fy*6;
|
||||
ctx.moveTo(kx,ky);
|
||||
ctx.lineTo(kx+fx*16-fy*6,ky+fy*16+fx*6);
|
||||
ctx.lineTo(kx+fx*16+fy*6,ky+fy*16-fx*6);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle=ifl?'#ddd':'#d4d4d4';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle='#999';ctx.stroke();
|
||||
// handle
|
||||
ctx.fillStyle=ifl?'#a99':'#5a3a1a';
|
||||
ctx.fillRect(kx-fx*3-fy*3,ky-fy*3-fx*3,8,8);
|
||||
// guard
|
||||
ctx.fillStyle=ifl?'#bbb':'#888';
|
||||
ctx.fillRect(kx-fx*2-fy*5,ky-fy*2-fx*5,5,12);
|
||||
}
|
||||
|
||||
// eyes
|
||||
ctx.fillStyle='#fff';
|
||||
const ex=p.x+fx*5,ey=p.y+fy*5;
|
||||
ctx.fillRect(ex-5,ey-4,4,5);ctx.fillRect(ex+1,ey-4,4,5);
|
||||
ctx.fillStyle='#111';
|
||||
ctx.fillRect(ex-4+fx,ey-3+fy,2,3);ctx.fillRect(ex+2+fx,ey-3+fy,2,3);
|
||||
ctx.restore();
|
||||
|
||||
// --- TEARS ---
|
||||
for(const t of room.tears){
|
||||
if(!t.alive)continue;
|
||||
ctx.save();
|
||||
ctx.fillStyle='#6699cc';ctx.beginPath();ctx.arc(t.x,t.y,t.r,0,Math.PI*2);ctx.fill();
|
||||
ctx.fillStyle='#99bbee';ctx.beginPath();ctx.arc(t.x-1.5,t.y-1.5,t.r-2,0,Math.PI*2);ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// --- MELEE SWING ---
|
||||
if(this.meleeSwing&&this.meleeSwing.alive){
|
||||
const s=this.meleeSwing,a=s.life/10;
|
||||
ctx.save();
|
||||
ctx.globalAlpha=a*0.35;
|
||||
ctx.fillStyle='#cc8844';ctx.fillRect(s.box.x,s.box.y,s.box.w,s.box.h);
|
||||
ctx.globalAlpha=a;
|
||||
ctx.strokeStyle='#ddbb88';ctx.lineWidth=2;ctx.strokeRect(s.box.x,s.box.y,s.box.w,s.box.h);
|
||||
ctx.globalAlpha=a*0.8;
|
||||
ctx.strokeStyle='#ffcc88';ctx.lineWidth=3;
|
||||
const[dx,dy]=DIR[s.dir];
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(s.box.x+s.box.w/2-dx*18,s.box.y+s.box.h/2-dy*18);
|
||||
ctx.lineTo(s.box.x+s.box.w/2+dx*18,s.box.y+s.box.h/2+dy*18);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
drawHUD(){
|
||||
const ctx=this.ctx,p=this.player;
|
||||
|
||||
// --- HP BAR ---
|
||||
const bx=20,by=20,bw=140,bh=14;
|
||||
ctx.fillStyle='#111';ctx.fillRect(bx,by,bw,bh);
|
||||
ctx.fillStyle='#2a0a0a';ctx.fillRect(bx+2,by+2,bw-4,bh-4);
|
||||
const hpRatio=Math.max(0,p.hp/p.maxHp);
|
||||
const hpCol=hpRatio>0.5?'#993333':hpRatio>0.25?'#994422':'#663322';
|
||||
ctx.fillStyle=hpCol;ctx.fillRect(bx+2,by+2,(bw-4)*hpRatio,bh-4);
|
||||
ctx.strokeStyle='#333';ctx.lineWidth=1;ctx.strokeRect(bx,by,bw,bh);
|
||||
ctx.fillStyle='#bbb';ctx.font='10px monospace';ctx.textAlign='center';
|
||||
ctx.fillText(`HP ${p.hp}/${p.maxHp}`,bx+bw/2,by+bh-3);
|
||||
|
||||
// --- MODE INDICATOR ---
|
||||
const my=CH-46;
|
||||
ctx.textAlign='center';
|
||||
const mText=p.mode===MODE_RANGED?'RANGED':'MELEE';
|
||||
const mCol=p.mode===MODE_RANGED?'#4488cc':'#cc6644';
|
||||
ctx.fillStyle='#0d0d0d';ctx.fillRect(CW/2-95,my-18,190,34);
|
||||
ctx.strokeStyle=mCol;ctx.lineWidth=2;ctx.strokeRect(CW/2-95,my-18,190,34);
|
||||
ctx.fillStyle=mCol;ctx.font='bold 17px monospace';ctx.fillText(`[ ${mText} ]`,CW/2,my+8);
|
||||
ctx.fillStyle='#555';ctx.font='11px monospace';ctx.fillText('[Tab] switch',CW/2,my-26);
|
||||
|
||||
// weapon icon in mode indicator
|
||||
if(p.mode===MODE_RANGED){
|
||||
ctx.strokeStyle='#88bbdd';ctx.lineWidth=2;
|
||||
ctx.beginPath();ctx.moveTo(CW/2-82,my-4);ctx.lineTo(CW/2-72,my-4);ctx.stroke();
|
||||
ctx.fillStyle='#88bbdd';ctx.fillRect(CW/2-82,my-8,10,8);
|
||||
}else{
|
||||
ctx.fillStyle='#ddbb88';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(CW/2-82,my-10);ctx.lineTo(CW/2-74,my-2);ctx.lineTo(CW/2-82,my+4);ctx.fill();
|
||||
}
|
||||
|
||||
// --- ENEMY COUNT ---
|
||||
ctx.textAlign='left';
|
||||
const room=this.cur;
|
||||
const alive=room.enemies.filter(e=>e.alive).length;
|
||||
if(alive>0){
|
||||
ctx.fillStyle='#aa4444';ctx.font='13px monospace';ctx.fillText(`\u25B6 ${alive}`,20,CH-18);
|
||||
}else if(!room.cleared&&room.type!=='spawn'){
|
||||
ctx.fillStyle='#886633';ctx.font='13px monospace';ctx.fillText('Clear the room',20,CH-18);
|
||||
}
|
||||
|
||||
// --- ROOM TYPE ---
|
||||
if(room.visited){
|
||||
ctx.textAlign='right';
|
||||
const tn={spawn:'START',normal:'',treasure:'TREASURE',boss:'BOSS'}[room.type];
|
||||
if(tn){
|
||||
ctx.fillStyle='#555';ctx.font='11px monospace';ctx.fillText(tn,CW-20,OY+RH+30);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drawMinimap(){
|
||||
const ctx=this.ctx;
|
||||
const mx=CW-180,my=12,cell=14,gap=2,cs=cell+gap;
|
||||
|
||||
ctx.fillStyle='rgba(0,0,0,0.75)';ctx.fillRect(mx-8,my-8,cs*7+16,cs*7+16);
|
||||
ctx.strokeStyle='#333';ctx.lineWidth=1;
|
||||
ctx.strokeRect(mx-8,my-8,cs*7+16,cs*7+16);
|
||||
|
||||
for(let r=-3;r<=3;r++)for(let c=-3;c<=3;c++){
|
||||
const room=this.map.get(this.cc+c,this.cr+r);
|
||||
if(!room)continue;
|
||||
const x=mx+(c+3)*cs,y=my+(r+3)*cs;
|
||||
let color='#141414';
|
||||
if(room.visited){
|
||||
const cl={spawn:'#2a5a2a',boss:'#5a1a1a',treasure:'#5a5a1a'}[room.type]||'#555';
|
||||
color=cl;
|
||||
}
|
||||
ctx.fillStyle=color;ctx.fillRect(x,y,cell,cell);
|
||||
if(room.visited){
|
||||
ctx.strokeStyle='rgba(255,255,255,0.12)';ctx.lineWidth=1;
|
||||
if(room.doors.up)ctx.fillRect(x+cs/2-2,y-2,4,3);
|
||||
if(room.doors.down)ctx.fillRect(x+cs/2-2,y+cell-1,4,3);
|
||||
if(room.doors.left)ctx.fillRect(x-2,y+cs/2-2,3,4);
|
||||
if(room.doors.right)ctx.fillRect(x+cell-1,y+cs/2-2,3,4);
|
||||
}
|
||||
if(c===0&&r===0){
|
||||
ctx.strokeStyle='#ddd';ctx.lineWidth=2;ctx.strokeRect(x-1.5,y-1.5,cell+3,cell+3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// START
|
||||
// ============================================================
|
||||
window.addEventListener('load',()=>{game=new Game()});
|
||||
</script>
|
||||
<!-- Стартовое меню. Кнопки уровней генерирует StartMenu из PRESETS. -->
|
||||
<div id="menu">
|
||||
<h1>DUNGEON CRAWL</h1>
|
||||
<p class="menu-sub">Выбери уровень</p>
|
||||
<div id="menu-presets"></div>
|
||||
<p class="menu-hint">WASD — движение · Стрелки — стрельба · Tab — оружие · Esc — меню</p>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="./main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user