Files
Binding-Fignyaac/index.html
T
Volodia b947fbb7fb initial: dungeon crawler with Bun + TypeScript modular architecture
- 19 TypeScript modules under src/ (constants, entities, room, game, render)
- Canvas 2D rendering with dark fantasy palette
- Room-based navigation, random 7x7 map generation
- Two combat modes: ranged (pistol) and melee (knife)
- Wall collision with door opening support
- Minimap, HP bar, enemy AI
- Bun build pipeline: src/main.ts -> dist/main.js + dist/index.html
2026-06-18 12:55:17 +03:00

703 lines
25 KiB
HTML

<!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>
</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;
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>
</body>
</html>