Add a 휴게실 tank game tab under the literature list

The crossword shell only carried literature tabs. Add a second nav
section, 휴게실, holding a standalone canvas tank game: two stages
(fortress with 4 enemies, maze with 6), arrow keys to move, space to
fire, WebAudio for the shots and explosions.

The page shares nothing with the crossword engine, so the Dockerfile's
asset-hash stamp leaves it alone. Inside the shell's iframe a keydown
only lands once the frame has focus, so the canvas takes a tabindex and
the click handler focuses it; the start overlay says to click first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 16:01:04 +09:00
co-authored by Claude Opus 5
parent 9cb8915173
commit f771a6b3e5
3 changed files with 1006 additions and 2 deletions
+11 -1
View File
@@ -51,6 +51,9 @@ body{
font-size:10px;font-weight:900;letter-spacing:.28em;color:var(--slate);
padding:0 20px 10px;
}
.nav .label.rest{
margin-top:14px;padding-top:15px;border-top:1px solid var(--line);
}
.nav a{
display:block;text-decoration:none;color:inherit;
padding:12px 18px 12px 17px;border-left:3px solid transparent;
@@ -147,6 +150,12 @@ body{
<span class="kr">일본<span class="suffix"> 문학</span></span>
<span class="og">日本文学</span>
</a>
<div class="label rest">휴게실</div>
<a href="tank.html" target="stage" data-key="tank">
<span class="kr">탱크게임</span>
<span class="og">Tank Battle</span>
</a>
</div>
<div class="rail-foot">
@@ -167,7 +176,8 @@ var PAGES = {
british: {file:'british.html', name:'영국 문학'},
french: {file:'french.html', name:'프랑스 문학'},
german: {file:'german.html', name:'독일 문학'},
japanese:{file:'japanese.html', name:'일본 문학'}
japanese:{file:'japanese.html', name:'일본 문학'},
tank: {file:'tank.html', name:'휴게실 · 탱크게임'}
};
var DEFAULT = 'korean';
+982
View File
@@ -0,0 +1,982 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>탱크 대전 · TANK BATTLE</title>
<style>
:root{
--bg:#17130f;
--panel:#241d16;
--panel2:#2f251c;
--line:#4a3928;
--cream:#f2e3c8;
--dim:#b09a7d;
--accent:#e8b25c;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{
background:radial-gradient(1200px 600px at 50% -10%, #2a2118 0%, var(--bg) 60%);
color:var(--cream);
font-family:"Segoe UI","Malgun Gothic",system-ui,sans-serif;
display:flex; align-items:center; justify-content:center;
padding:16px;
}
.wrap{display:flex; gap:16px; align-items:flex-start; flex-wrap:wrap; justify-content:center}
.stage{
position:relative; padding:10px;
background:var(--panel);
border:2px solid var(--line);
border-radius:10px;
box-shadow:0 18px 50px rgba(0,0,0,.55), inset 0 0 0 1px rgba(255,255,255,.03);
}
canvas{display:block; border-radius:4px; background:#111}
canvas:focus{outline:none}
.hud{
width:216px; min-width:200px;
background:var(--panel);
border:2px solid var(--line);
border-radius:10px;
padding:14px 14px 16px;
box-shadow:0 18px 50px rgba(0,0,0,.45);
}
.hud h1{margin:0 0 2px; font-size:19px; letter-spacing:.06em; color:var(--accent)}
.hud .sub{font-size:11px; color:var(--dim); margin-bottom:14px; letter-spacing:.12em}
.row{
display:flex; align-items:center; justify-content:space-between;
font-size:13px; padding:7px 0; border-bottom:1px dashed #3d2f22;
}
.row:last-of-type{border-bottom:0}
.row b{font-size:16px; color:#fff; font-variant-numeric:tabular-nums}
.pips{display:flex; gap:4px; align-items:center}
.pip{width:12px;height:12px;border-radius:2px;background:#dcc76d;box-shadow:inset 0 -3px 0 rgba(0,0,0,.28)}
.pip.e{background:#b9c2cc}
.pip.off{background:#463728; box-shadow:none}
.keys{margin-top:14px; font-size:12px; line-height:1.85; color:#cbb79a}
kbd{
display:inline-block; min-width:20px; text-align:center;
background:var(--panel2); border:1px solid var(--line);
border-bottom-width:3px; border-radius:5px;
padding:1px 6px; margin-right:5px; font-size:11px; color:#f5ead4;
font-family:inherit;
}
.note{margin-top:12px;font-size:11px;color:#8e7a5f;line-height:1.7}
button{
margin-top:14px; width:100%; cursor:pointer;
background:linear-gradient(#9a6a35,#7a5026);
color:#fff6e6; border:1px solid #5d3a1c; border-bottom-width:3px;
border-radius:7px; padding:8px; font-size:13px; font-family:inherit; letter-spacing:.04em;
}
button:hover{filter:brightness(1.1)}
button:active{transform:translateY(1px); border-bottom-width:2px}
.btns{display:flex; gap:8px}
.btns button{background:linear-gradient(#5c4a34,#463725); border-color:#3a2c1c}
</style>
</head>
<body>
<div class="wrap">
<div class="stage"><canvas id="cv" width="544" height="480" tabindex="0"></canvas></div>
<aside class="hud">
<h1>TANK BATTLE</h1>
<div class="sub">탱크 대전</div>
<div class="row"><span>단계</span><b id="stage">1 / 2</b></div>
<div class="row"><span>남은 적</span><span class="pips" id="epips"></span></div>
<div class="row"><span>아군 탱크</span><span class="pips" id="ppips"></span></div>
<div class="row"><span>점수</span><b id="score">0</b></div>
<div class="row"><span>격파</span><b id="kills">0</b></div>
<div class="keys">
<div><kbd>←</kbd><kbd>→</kbd><kbd>↑</kbd><kbd>↓</kbd> 이동</div>
<div><kbd>Space</kbd> 발사</div>
<div><kbd>P</kbd> 일시정지 <kbd>R</kbd> 다시시작</div>
<div><kbd>1</kbd><kbd>2</kbd> 단계 직접 선택</div>
</div>
<div class="note">
갈색 벽돌은 포탄에 부서지고, 회색 강철은 부서지지 않습니다.
기지가 파괴되거나 탱크 3대를 모두 잃으면 게임 오버.
<br>1단계 적 4대 → 돌파 시 2단계(미로, 적 6대) 진입.
</div>
<div class="btns">
<button id="b1">1단계</button>
<button id="b2">2단계</button>
</div>
<button id="btn">현재 단계 다시 시작 (R)</button>
</aside>
</div>
<script>
(function(){
"use strict";
/* ===================== 상수 ===================== */
var COLS=17, ROWS=15, CELL=32;
var W=COLS*CELL, H=ROWS*CELL;
var SIZE=28, OFF=(CELL-SIZE)/2;
var EMPTY=0, BRICK=1, STEEL=2, BASE=3, RUIN=4;
var BASE_C=8, BASE_R=14;
var PLAYER_SPAWN={c:5,r:13};
var ENEMY_SPAWNS=[{c:0,r:0},{c:8,r:0},{c:16,r:0}];
var PLAYER_TANKS=3; /* 아군 탱크 3대 (사망 시 2대 더 출격) */
/* pHunt: 플레이어 추격 확률, pRush: 기지 돌격 확률, 나머지는 배회 */
var KIND={
scout: {speed:66, hp:1, body:"#b9c2cc", dark:"#6f7884", light:"#e4eaf0", fire:1.10, bspd:190, pHunt:0.35, pRush:0.10},
heavy: {speed:46, hp:2, body:"#9d8f7a", dark:"#5f5648", light:"#cbbda6", fire:1.35, bspd:165, pHunt:0.20, pRush:0.18},
hunter:{speed:86, hp:1, body:"#c98a6a", dark:"#7d4f38", light:"#efb797", fire:0.85, bspd:215, pHunt:0.70, pRush:0.05},
guard: {speed:58, hp:1, body:"#a7b58e", dark:"#5f6b4c", light:"#d5e0bd", fire:1.00, bspd:195, pHunt:0.15, pRush:0.08}
};
var STAGES=[
{
name:"STAGE 1 · 요새",
maxOnField:3,
enemies:["scout","heavy","hunter","guard"],
map:[
".................",
"..BB.........BB..",
"..BB..BBBBB..BB..",
"..BB..B...B..BB..",
"......B...B......",
".SS...B...B...SS.",
"......BBBBB......",
"..BB.........BB..",
"..BB..S...S..BB..",
"..BB.........BB..",
".....BB...BB.....",
".SS..BB...BB..SS.",
".................",
".......BBB.......",
".......BEB......."
]
},
{
name:"STAGE 2 · 미로",
maxOnField:4,
enemies:["scout","hunter","heavy","guard","hunter","heavy"],
map:[
"...BB.......BB...",
".S.BB.BBBBB.BB.S.",
".B....B...B....B.",
".BBBB.B.S.B.BBBB.",
"....B.B.B.B.B....",
"BBB.B...B...B.BBB",
"..B.BBB.B.BBB.B..",
"..B...B...B...B..",
"BBBB.BBB.BBB.BBBB",
".....B.....B.....",
".BBBBB.BBB.BBBBB.",
".B.....BSB.....B.",
".B.BBB.....BBB.B.",
"...B...BBB...B...",
".S.B...BEB...B.S."
]
}
];
/* ===================== 사운드 (WebAudio) ===================== */
var AC=window.AudioContext||window.webkitAudioContext;
var ac=null, noiseBuf=null, master=null;
function audio(){
if(!AC) return null;
if(!ac){
ac=new AC();
master=ac.createGain();
master.gain.value=0.5;
master.connect(ac.destination);
var n=Math.floor(ac.sampleRate*1.0);
noiseBuf=ac.createBuffer(1,n,ac.sampleRate);
var d=noiseBuf.getChannelData(0);
for(var i=0;i<n;i++) d[i]=Math.random()*2-1;
}
if(ac.state==="suspended") ac.resume();
return ac;
}
function noise(dur,vol,f0,f1,type){
var a=audio(); if(!a) return;
var t=a.currentTime;
var s=a.createBufferSource(); s.buffer=noiseBuf; s.loop=true;
var flt=a.createBiquadFilter(); flt.type=type||"lowpass";
flt.frequency.setValueAtTime(f0,t);
flt.frequency.exponentialRampToValueAtTime(Math.max(60,f1),t+dur);
var g=a.createGain();
g.gain.setValueAtTime(vol,t);
g.gain.exponentialRampToValueAtTime(0.0008,t+dur);
s.connect(flt); flt.connect(g); g.connect(master);
s.start(t); s.stop(t+dur+0.02);
}
function tone(type,f0,f1,dur,vol,delay){
var a=audio(); if(!a) return;
var t=a.currentTime+(delay||0);
var o=a.createOscillator(), g=a.createGain();
o.type=type;
o.frequency.setValueAtTime(f0,t);
o.frequency.exponentialRampToValueAtTime(Math.max(30,f1),t+dur);
g.gain.setValueAtTime(0.0001,t);
g.gain.linearRampToValueAtTime(vol,t+0.008);
g.gain.exponentialRampToValueAtTime(0.0008,t+dur);
o.connect(g); g.connect(master);
o.start(t); o.stop(t+dur+0.02);
}
var sfx={
fire:function(){ tone("square",760,150,0.13,0.15); noise(0.09,0.15,2600,500); },
efire:function(){ tone("sawtooth",420,120,0.12,0.09); noise(0.08,0.09,1600,400); },
brick:function(){ noise(0.11,0.22,1500,320,"bandpass"); tone("triangle",300,120,0.07,0.05); },
steel:function(){ noise(0.06,0.13,5200,2200,"highpass"); tone("square",1500,900,0.05,0.05); },
boom:function(){ noise(0.42,0.46,1200,90); tone("sine",180,40,0.38,0.17); },
bigboom:function(){ noise(0.85,0.58,900,50); tone("sine",140,28,0.8,0.24); tone("square",90,30,0.5,0.10,0.05); },
spawn:function(){ tone("triangle",300,900,0.18,0.06); tone("triangle",500,1200,0.14,0.05,0.1); },
win:function(){ var n=[523,659,784,1047],i; for(i=0;i<4;i++) tone("square",n[i],n[i],0.16,0.11,i*0.14); },
lose:function(){ var n=[392,330,262,196],i; for(i=0;i<4;i++) tone("sawtooth",n[i],n[i]*0.95,0.26,0.12,i*0.18); }
};
/* ===================== 상태 ===================== */
var cv=document.getElementById("cv"), ctx=cv.getContext("2d");
var elE=document.getElementById("epips"), elP=document.getElementById("ppips");
var elS=document.getElementById("score"), elK=document.getElementById("kills");
var elStage=document.getElementById("stage");
var grid, player, enemies, bullets, fx, spawns, enemyQueue;
var state, msg, msg2, score, kills, livesLeft, shake, flashT, elapsed;
var stage=1, totalEnemies=4, maxOnField=3;
function pendingEnemies(){
var n=0;
for(var i=0;i<spawns.length;i++) if(!spawns[i].player) n++;
return n;
}
function buildGrid(src){
var g=[],r,c;
for(r=0;r<ROWS;r++){
var line=(src[r]||"");
while(line.length<COLS) line+=".";
line=line.slice(0,COLS);
var row=[];
for(c=0;c<COLS;c++){
var ch=line.charAt(c);
row.push(ch==="B"?BRICK:ch==="S"?STEEL:ch==="E"?BASE:EMPTY);
}
g.push(row);
}
return g;
}
/* keep: 이전 단계의 점수·남은 탱크를 이어받는다 (단계 돌파로 진입할 때) */
function startStage(n,keep){
stage=Math.max(1,Math.min(STAGES.length,n));
var st=STAGES[stage-1];
grid=buildGrid(st.map);
enemies=[]; bullets=[]; fx=[]; spawns=[];
if(!keep){ score=0; kills=0; livesLeft=PLAYER_TANKS-1; }
shake=0; flashT=0; elapsed=0;
maxOnField=st.maxOnField;
enemyQueue=[];
for(var i=0;i<st.enemies.length;i++) enemyQueue.push(KIND[st.enemies[i]]);
totalEnemies=enemyQueue.length;
player=null;
spawnPlayer(0.4);
fillEnemySpawns(0.9);
state="ready"; msg=st.name; msg2="화면을 클릭한 뒤 방향키 · 스페이스";
syncHud();
}
function reset(){ startStage(1,false); }
function syncHud(){
var remain=enemyQueue.length+enemies.length;
var html="",i;
for(i=0;i<totalEnemies;i++) html+='<span class="pip e'+(i<remain?"":" off")+'"></span>';
elE.innerHTML=html;
var pl=livesLeft+((player||spawns.some(function(s){return s.player;}))?1:0);
html="";
for(i=0;i<PLAYER_TANKS;i++) html+='<span class="pip'+(i<pl?"":" off")+'"></span>';
elP.innerHTML=html;
elS.textContent=score;
elK.textContent=kills;
elStage.textContent=stage+" / "+STAGES.length;
}
/* ===================== 지형 유틸 ===================== */
function isSolid(t){ return t===BRICK||t===STEEL||t===BASE; }
function rectBlocked(x,y,w,h){
var c1=Math.floor(x/CELL), c2=Math.floor((x+w-1)/CELL);
var r1=Math.floor(y/CELL), r2=Math.floor((y+h-1)/CELL);
for(var r=r1;r<=r2;r++){
for(var c=c1;c<=c2;c++){
if(c<0||r<0||c>=COLS||r>=ROWS) return true;
if(isSolid(grid[r][c])) return true;
}
}
return false;
}
function snap(v){ return Math.round((v-OFF)/CELL)*CELL+OFF; }
function boxesHit(ax,ay,bx,by){
return ax<bx+SIZE && ax+SIZE>bx && ay<by+SIZE && ay+SIZE>by;
}
function allTanks(){
var out=enemies.slice();
if(player) out.push(player);
return out;
}
function tankHits(self,x,y){
var list=allTanks();
for(var i=0;i<list.length;i++){
if(list[i]===self) continue;
if(boxesHit(x,y,list[i].x,list[i].y)) return true;
}
return false;
}
/* ===================== 탱크 ===================== */
function makeTank(o){
return {
x:o.x, y:o.y, dx:o.dx||0, dy:(o.dy===undefined?-1:o.dy),
speed:o.speed, hp:o.hp||1, isPlayer:!!o.isPlayer,
body:o.body, dark:o.dark, light:o.light,
bspd:o.bspd||220, fireCd:o.fire||1, fireT:0,
pHunt:o.pHunt||0, pRush:o.pRush||0, mode:"wander", thinkT:0,
shield:o.shield||0, blockT:0, hitFlash:0, tread:0, kind:o.kind||""
};
}
function spawnPlayer(delay){
spawns.push({x:PLAYER_SPAWN.c*CELL+OFF, y:PLAYER_SPAWN.r*CELL+OFF,
t:delay, max:Math.max(delay,0.6), player:true});
}
function fillEnemySpawns(base){
var slot=0;
while(enemyQueue.length>pendingEnemies()
&& enemies.length+pendingEnemies()<maxOnField
&& pendingEnemies()<ENEMY_SPAWNS.length){ /* 출현 지점 중복 방지 */
addEnemySpawn(base+slot*1.4);
slot++;
}
}
function addEnemySpawn(delay){
var used={},i;
for(i=0;i<spawns.length;i++) if(!spawns[i].player) used[spawns[i].si]=1;
var cand=[];
for(i=0;i<ENEMY_SPAWNS.length;i++) if(!used[i]) cand.push(i);
if(!cand.length) cand=[0,1,2];
var si=cand[Math.floor(Math.random()*cand.length)];
var p=ENEMY_SPAWNS[si];
spawns.push({x:p.c*CELL+OFF, y:p.r*CELL+OFF, t:delay, max:Math.max(delay,0.9), si:si, player:false});
sfx.spawn();
}
function hatch(s){
if(s.player){
player=makeTank({x:s.x,y:s.y,dx:0,dy:-1,speed:118,isPlayer:true,
body:"#dcc76d",dark:"#8a7527",light:"#f7eeb4",bspd:305,fire:0.3,shield:2.6});
}else{
var k=enemyQueue.shift();
if(!k) return;
var e=makeTank({x:s.x,y:s.y,dx:0,dy:1,speed:k.speed,hp:k.hp,
body:k.body,dark:k.dark,light:k.light,bspd:k.bspd,fire:k.fire,
pHunt:k.pHunt,pRush:k.pRush,kind:k.name});
rollMode(e);
e.thinkT=0.2+Math.random()*0.4;
e.fireT=0.6+Math.random()*0.9;
enemies.push(e);
}
fx.push({x:s.x+SIZE/2,y:s.y+SIZE/2,t:0.3,max:0.3,r:26,ring:true});
}
/* 이동: 진행축의 수직 방향을 격자에 정렬해 좁은 통로 통과를 쉽게 만든다 */
function tryMove(t,dt){
if(!t.dx && !t.dy) return true;
var nx=t.x, ny=t.y;
if(t.dx!==0){
var sy=snap(t.y);
if(sy!==t.y && !rectBlocked(t.x,sy,SIZE,SIZE) && !tankHits(t,t.x,sy)) ny=sy;
}else{
var sx=snap(t.x);
if(sx!==t.x && !rectBlocked(sx,t.y,SIZE,SIZE) && !tankHits(t,sx,t.y)) nx=sx;
}
var step=t.speed*dt;
var tx=nx+t.dx*step, ty=ny+t.dy*step;
if(rectBlocked(tx,ty,SIZE,SIZE) || tankHits(t,tx,ty)){
t.x=nx; t.y=ny;
return false;
}
t.x=tx; t.y=ty; t.tread+=step;
return true;
}
function canStep(t,d){
var x=t.x, y=t.y;
if(d[0]!==0) y=snap(y); else x=snap(x);
var probe=Math.max(7,t.speed*0.14);
var tx=x+d[0]*probe, ty=y+d[1]*probe;
return !rectBlocked(tx,ty,SIZE,SIZE) && !tankHits(t,tx,ty);
}
function fire(t){
var limit=t.isPlayer?2:1, mine=0, i;
for(i=0;i<bullets.length;i++) if(bullets[i].owner===t && !bullets[i].gone) mine++;
if(mine>=limit) return false;
var cx=t.x+SIZE/2, cy=t.y+SIZE/2;
bullets.push({
x:cx+t.dx*(SIZE/2+2)-3, y:cy+t.dy*(SIZE/2+2)-3,
dx:t.dx, dy:t.dy, spd:t.bspd, owner:t, player:t.isPlayer, gone:false
});
if(t.isPlayer) sfx.fire(); else sfx.efire();
return true;
}
/* ===================== 적 AI ===================== */
var DIRS=[[0,-1],[0,1],[-1,0],[1,0]];
function baseCenter(){ return {x:BASE_C*CELL+CELL/2, y:BASE_R*CELL+CELL/2}; }
/* 강철만 시야를 막는다 (벽돌은 쏴서 뚫을 수 있으므로 사격 허용) */
function losOpen(x1,y1,x2,y2){
var steps=Math.ceil(Math.sqrt((x2-x1)*(x2-x1)+(y2-y1)*(y2-y1))/10);
for(var i=1;i<steps;i++){
var k=i/steps, x=x1+(x2-x1)*k, y=y1+(y2-y1)*k;
var c=Math.floor(x/CELL), r=Math.floor(y/CELL);
if(c<0||r<0||c>=COLS||r>=ROWS) return false;
if(grid[r][c]===STEEL) return false;
}
return true;
}
/* 행동 모드를 주기적으로 다시 굴려 기지 직행을 막고 자연스럽게 배회시킨다.
기지 돌격은 초반 15초간 금지하고 40초까지 서서히 풀린다 */
function rollMode(e){
var ramp=Math.max(0,Math.min(1,(elapsed-15)/25));
var rush=e.pRush*ramp;
var r=Math.random();
if(r<rush) e.mode="rush";
else if(r<rush+e.pHunt) e.mode="hunt";
else e.mode="wander";
}
function chooseDir(e){
var tgt=null;
if(e.mode==="hunt" && player) tgt={x:player.x+SIZE/2, y:player.y+SIZE/2};
else if(e.mode==="rush") tgt=baseCenter();
var ex=e.x+SIZE/2, ey=e.y+SIZE/2;
var dx=tgt?tgt.x-ex:0, dy=tgt?tgt.y-ey:0;
var pull=(e.mode==="rush")?2.4:3.0;
var opts=[], total=0, i, d, w;
for(i=0;i<DIRS.length;i++){
d=DIRS[i];
if(!canStep(e,d)) continue;
w=1.0+Math.random()*1.4; /* 무작위성 */
if(tgt){
if(d[0]!==0 && Math.abs(dx)>10 && (dx>0?1:-1)===d[0]) w+=pull;
if(d[1]!==0 && Math.abs(dy)>10 && (dy>0?1:-1)===d[1]) w+=pull;
if(Math.abs(dx)>Math.abs(dy) ? d[0]!==0 : d[1]!==0) w+=0.5; /* 주 이동축 선호 */
}
if(d[0]===e.dx && d[1]===e.dy) w+=1.3; /* 직진 관성 */
if(d[0]===-e.dx && d[1]===-e.dy) w*=0.25; /* 급반전 억제 */
opts.push({d:d,w:w}); total+=w;
}
if(!opts.length) return [-e.dx,-e.dy];
var pick=Math.random()*total;
for(i=0;i<opts.length;i++){
pick-=opts[i].w;
if(pick<=0) return opts[i].d;
}
return opts[opts.length-1].d;
}
function aimedAt(e,tx,ty){
var ex=e.x+SIZE/2, ey=e.y+SIZE/2;
if(e.dx!==0){
if(Math.abs(ty-ey)>13) return false;
if((tx-ex>0?1:-1)!==e.dx || Math.abs(tx-ex)<4) return false;
}else if(e.dy!==0){
if(Math.abs(tx-ex)>13) return false;
if((ty-ey>0?1:-1)!==e.dy || Math.abs(ty-ey)<4) return false;
}else return false;
return losOpen(ex,ey,tx,ty);
}
function updateEnemy(e,dt){
e.thinkT-=dt;
var moved=tryMove(e,dt);
if(!moved){
e.blockT+=dt;
if(e.blockT>0.10){
var nd=chooseDir(e);
e.dx=nd[0]; e.dy=nd[1];
e.blockT=0; e.thinkT=0.6+Math.random()*1.2;
/* 벽에 막히면 앞을 뚫기도 한다 */
if(Math.random()<0.4) e.fireT=Math.min(e.fireT,0.05);
}
}else{
e.blockT=0;
if(e.thinkT<=0){
e.thinkT=0.9+Math.random()*1.8;
rollMode(e);
var d=chooseDir(e);
e.dx=d[0]; e.dy=d[1];
}
}
e.fireT-=dt;
if(e.fireT<=0){
var b=baseCenter(), shoot=false;
var onBase=aimedAt(e,b.x,b.y);
if(player && aimedAt(e,player.x+SIZE/2,player.y+SIZE/2)) shoot=true;
else if(e.mode==="rush" && onBase) shoot=true; /* 기지 조준은 돌격 모드에서만 */
else if(!onBase && Math.random()<0.11) shoot=true; /* 이따금 견제 사격 */
if(shoot && fire(e)) e.fireT=e.fireCd*(0.75+Math.random()*0.6);
else e.fireT=0.18;
}
}
/* ===================== 이펙트 ===================== */
function boom(x,y,big){
var n=big?28:15, i;
for(i=0;i<n;i++){
var a=Math.random()*Math.PI*2, sp=(big?100:64)*(0.3+Math.random());
fx.push({x:x,y:y,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp,
r:(big?7:5)*(0.5+Math.random()), t:(big?0.7:0.45)*(0.6+Math.random()*0.6),
col:Math.random()<0.45?"#ffd27a":(Math.random()<0.5?"#ff9a3c":"#ff5f2e")});
}
fx.push({x:x,y:y,t:0.3,max:0.3,r:big?42:26,ring:true});
shake=Math.max(shake,big?9:5);
if(big) sfx.bigboom(); else sfx.boom();
}
function spark(x,y){
for(var i=0;i<6;i++){
var a=Math.random()*Math.PI*2, sp=70*(0.3+Math.random());
fx.push({x:x,y:y,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp,r:2.6,t:0.22,col:"#ffeab0"});
}
}
function debris(x,y){
for(var i=0;i<9;i++){
var a=Math.random()*Math.PI*2, sp=85*(0.3+Math.random());
fx.push({x:x,y:y,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp,r:3,t:0.35,
col:Math.random()<0.5?"#a9713c":"#7d5127"});
}
}
/* ===================== 탄환 ===================== */
function updateBullets(dt){
var i,j;
for(i=0;i<bullets.length;i++){
var b=bullets[i];
if(b.gone) continue;
var total=b.spd*dt;
var steps=Math.max(1,Math.ceil(total/5));
for(var s=0;s<steps;s++){
b.x+=b.dx*total/steps;
b.y+=b.dy*total/steps;
var cx=b.x+3, cy=b.y+3;
if(cx<0||cy<0||cx>=W||cy>=H){
spark(Math.max(2,Math.min(W-2,cx)),Math.max(2,Math.min(H-2,cy)));
b.gone=true; break;
}
var c=Math.floor(cx/CELL), r=Math.floor(cy/CELL), t=grid[r][c];
if(t===BRICK){
grid[r][c]=EMPTY;
debris(c*CELL+CELL/2,r*CELL+CELL/2);
sfx.brick(); b.gone=true; break;
}
if(t===STEEL){ spark(cx,cy); sfx.steel(); b.gone=true; break; }
if(t===BASE){
if(b.player){ /* 아군 포탄은 기지를 부수지 못한다 */
spark(cx,cy); sfx.steel();
}else{
grid[r][c]=RUIN;
boom(c*CELL+CELL/2,r*CELL+CELL/2,true);
gameOver("기지가 파괴되었다");
}
b.gone=true; break;
}
/* 탄 대 탄 */
for(j=0;j<bullets.length;j++){
var o=bullets[j];
if(o===b||o.gone||o.player===b.player) continue;
if(Math.abs(o.x-b.x)<8 && Math.abs(o.y-b.y)<8){
spark(cx,cy); o.gone=true; b.gone=true; break;
}
}
if(b.gone) break;
/* 탄 대 탱크 */
if(b.player){
for(j=enemies.length-1;j>=0;j--){
var e=enemies[j];
if(cx>e.x&&cx<e.x+SIZE&&cy>e.y&&cy<e.y+SIZE){
e.hp--;
if(e.hp<=0){
boom(e.x+SIZE/2,e.y+SIZE/2,false);
enemies.splice(j,1);
kills++; score+=100;
afterKill();
}else{
spark(cx,cy); e.hitFlash=0.18;
}
b.gone=true; break;
}
}
}else if(player){
if(cx>player.x&&cx<player.x+SIZE&&cy>player.y&&cy<player.y+SIZE){
if(player.shield>0) spark(cx,cy);
else killPlayer();
b.gone=true; break;
}
}
if(b.gone) break;
}
}
var keep=[];
for(i=0;i<bullets.length;i++) if(!bullets[i].gone) keep.push(bullets[i]);
bullets=keep;
}
function afterKill(){
syncHud();
if(enemyQueue.length===0 && enemies.length===0){
if(state==="play"){
score+=500;
if(stage<STAGES.length){
state="clear";
msg="STAGE "+stage+" CLEAR";
msg2="Space / Enter 로 "+(stage+1)+"단계 진입";
}else{
state="win";
msg="ALL CLEAR!"; msg2="모든 단계 돌파 · R 키로 재도전";
}
sfx.win(); syncHud();
}
}else{
fillEnemySpawns(1.2);
}
}
function killPlayer(){
boom(player.x+SIZE/2,player.y+SIZE/2,true);
player=null;
flashT=0.25;
if(livesLeft>0){
livesLeft--;
spawnPlayer(1.4);
}else{
gameOver("아군 탱크 전멸");
}
syncHud();
}
function gameOver(reason){
if(state==="over"||state==="win") return;
state="over";
msg="GAME OVER"; msg2=reason+" · R 키로 다시 시작";
sfx.lose();
}
/* ===================== 입력 ===================== */
var held=[];
var keymap={ArrowUp:"0,-1",ArrowDown:"0,1",ArrowLeft:"-1,0",ArrowRight:"1,0",
w:"0,-1",s:"0,1",a:"-1,0",d:"1,0",W:"0,-1",S:"0,1",A:"-1,0",D:"1,0"};
window.addEventListener("keydown",function(ev){
var k=ev.key;
if(k===" "||k==="Spacebar"||k.indexOf("Arrow")===0) ev.preventDefault();
audio();
/* 단계 직접 선택 */
if(k>="1" && k<=String(STAGES.length)){
startStage(parseInt(k,10),false); state="play"; msg=null; return;
}
if(k==="r"||k==="R"){
startStage(state==="win"?1:stage,false); state="play"; msg=null; return;
}
/* 단계 돌파 후 다음 단계로 */
if(state==="clear" && (k===" "||k==="Spacebar"||k==="Enter")){
startStage(stage+1,true); state="play"; msg=null; return;
}
if(k==="p"||k==="P"){
if(state==="play"){ state="pause"; msg="PAUSED"; msg2="P 키로 계속"; }
else if(state==="pause"){ state="play"; msg=null; }
return;
}
if(state==="ready"){ state="play"; msg=null; }
if(keymap[k]){
var d=keymap[k];
var i=held.indexOf(d);
if(i>=0) held.splice(i,1);
held.push(d);
}
if((k===" "||k==="Spacebar") && !ev.repeat && player && state==="play") fire(player);
});
window.addEventListener("keyup",function(ev){
if(keymap[ev.key]){
var i=held.indexOf(keymap[ev.key]);
if(i>=0) held.splice(i,1);
}
});
window.addEventListener("blur",function(){ held.length=0; });
document.getElementById("btn").addEventListener("click",function(){
audio(); startStage(state==="win"?1:stage,false); state="play"; msg=null;
});
document.getElementById("b1").addEventListener("click",function(){
audio(); startStage(1,false); state="play"; msg=null;
});
document.getElementById("b2").addEventListener("click",function(){
audio(); startStage(2,false); state="play"; msg=null;
});
cv.addEventListener("mousedown",function(){
audio();
try{ window.focus(); cv.focus(); }catch(e){} /* iframe 안에서도 키 입력을 받도록 */
if(state==="ready"){ state="play"; msg=null; }
else if(state==="clear"){ startStage(stage+1,true); state="play"; msg=null; }
});
/* ===================== 그리기 ===================== */
function drawBrickCell(c,r){
var x=c*CELL, y=r*CELL;
ctx.fillStyle="#6b4526";
ctx.fillRect(x,y,CELL,CELL);
var h=CELL/4, bw=CELL/2, i, j;
for(i=0;i<4;i++){
var off=(i%2)?0:-bw/2;
for(j=-1;j<3;j++){
var bx=x+off+j*bw;
var x0=Math.max(x,bx), x1=Math.min(x+CELL,bx+bw-1.4);
if(x1-x0<=0.5) continue;
var by=y+i*h;
ctx.fillStyle=(i%2)?"#a9713c":"#9c6733";
ctx.fillRect(x0,by+0.8,x1-x0,h-1.6);
ctx.fillStyle="rgba(255,224,180,.20)";
ctx.fillRect(x0,by+0.8,x1-x0,1.1);
ctx.fillStyle="rgba(60,30,10,.22)";
ctx.fillRect(x0,by+h-1.9,x1-x0,1.1);
}
}
ctx.strokeStyle="rgba(40,20,6,.32)";
ctx.lineWidth=1;
ctx.strokeRect(x+0.5,y+0.5,CELL-1,CELL-1);
}
function drawSteelCell(c,r){
var x=c*CELL, y=r*CELL, h=CELL/2, i, j;
ctx.fillStyle="#5d646d";
ctx.fillRect(x,y,CELL,CELL);
for(i=0;i<2;i++) for(j=0;j<2;j++){
var bx=x+j*h, by=y+i*h;
ctx.fillStyle="#8f99a4";
ctx.fillRect(bx+1.5,by+1.5,h-3,h-3);
ctx.fillStyle="#cfd8e2";
ctx.fillRect(bx+1.5,by+1.5,h-3,2.4);
ctx.fillRect(bx+1.5,by+1.5,2.4,h-3);
ctx.fillStyle="rgba(20,26,32,.45)";
ctx.fillRect(bx+1.5,by+h-3.9,h-3,2.4);
ctx.fillRect(bx+h-3.9,by+1.5,2.4,h-3);
}
}
function drawBase(c,r,ruin){
var x=c*CELL, y=r*CELL;
ctx.fillStyle="#2a221a";
ctx.fillRect(x,y,CELL,CELL);
if(ruin){
ctx.fillStyle="#57483a";
ctx.beginPath();
ctx.moveTo(x+4,y+CELL-4); ctx.lineTo(x+CELL/2-3,y+16);
ctx.lineTo(x+CELL/2+5,y+20); ctx.lineTo(x+CELL-4,y+CELL-4);
ctx.closePath(); ctx.fill();
return;
}
ctx.fillStyle="#e9d9a8";
ctx.beginPath();
ctx.moveTo(x+CELL/2,y+5);
ctx.lineTo(x+CELL-5,y+13);
ctx.lineTo(x+CELL-9,y+13);
ctx.lineTo(x+CELL-5,y+CELL-5);
ctx.lineTo(x+5,y+CELL-5);
ctx.lineTo(x+9,y+13);
ctx.lineTo(x+5,y+13);
ctx.closePath(); ctx.fill();
ctx.fillStyle="#b99a56";
ctx.fillRect(x+CELL/2-2,y+9,4,CELL-15);
ctx.fillStyle="#8c6f3a";
ctx.fillRect(x+6,y+CELL-7,CELL-12,3);
}
function drawMap(){
for(var r=0;r<ROWS;r++) for(var c=0;c<COLS;c++){
var t=grid[r][c];
if(t===BRICK) drawBrickCell(c,r);
else if(t===STEEL) drawSteelCell(c,r);
else if(t===BASE) drawBase(c,r,false);
else if(t===RUIN) drawBase(c,r,true);
}
}
function drawTank(t){
var cx=t.x+SIZE/2, cy=t.y+SIZE/2, s=SIZE;
var ang = t.dy<0?0 : t.dy>0?Math.PI : t.dx<0?-Math.PI/2 : Math.PI/2;
ctx.save();
ctx.translate(cx,cy);
ctx.rotate(ang);
ctx.fillStyle="rgba(0,0,0,.33)";
ctx.fillRect(-s/2+2,-s/2+3,s,s);
ctx.fillStyle=t.dark;
ctx.fillRect(-s/2,-s/2,7,s);
ctx.fillRect(s/2-7,-s/2,7,s);
ctx.fillStyle="rgba(0,0,0,.40)";
for(var i=0;i<5;i++){
var yy=-s/2+(i*6+(t.tread*0.55)%6);
ctx.fillRect(-s/2,yy,7,2);
ctx.fillRect(s/2-7,yy,7,2);
}
ctx.fillStyle=(t.hitFlash>0)?"#ffffff":t.body;
ctx.fillRect(-s/2+6,-s/2+4,s-12,s-8);
ctx.fillStyle="rgba(255,255,255,.22)";
ctx.fillRect(-s/2+6,-s/2+4,s-12,2.5);
ctx.fillStyle=t.light;
ctx.beginPath(); ctx.arc(0,1,5.6,0,Math.PI*2); ctx.fill();
ctx.fillStyle=t.dark;
ctx.beginPath(); ctx.arc(0,1,2.4,0,Math.PI*2); ctx.fill();
ctx.fillStyle=t.dark;
ctx.fillRect(-2.2,-s/2-4,4.4,s/2+5);
ctx.fillStyle=t.light;
ctx.fillRect(-2.2,-s/2-4,4.4,2);
ctx.restore();
if(t.shield>0){
var p=t.shield*12;
ctx.save();
ctx.globalAlpha=0.45+0.4*Math.sin(p*2);
ctx.strokeStyle="#8fdcff"; ctx.lineWidth=2;
ctx.beginPath(); ctx.arc(cx,cy,SIZE*0.72,0,Math.PI*2); ctx.stroke();
ctx.globalAlpha=0.28;
ctx.strokeStyle="#d8f4ff";
ctx.beginPath(); ctx.arc(cx,cy,SIZE*0.55+Math.sin(p*3)*2,0,Math.PI*2); ctx.stroke();
ctx.restore();
ctx.globalAlpha=1;
}
}
function drawSpawn(s){
var p=1-Math.max(0,Math.min(1,s.t/s.max));
ctx.save();
ctx.translate(s.x+SIZE/2,s.y+SIZE/2);
ctx.rotate(p*Math.PI*3);
var R=6+p*11;
ctx.strokeStyle=s.player?"#ffe98a":"#9fe0ff";
ctx.lineWidth=2.4;
ctx.beginPath();
for(var i=0;i<=8;i++){
var a=i*Math.PI/4, rr=(i%2)?R*0.45:R;
if(i===0) ctx.moveTo(Math.cos(a)*rr,Math.sin(a)*rr);
else ctx.lineTo(Math.cos(a)*rr,Math.sin(a)*rr);
}
ctx.closePath(); ctx.stroke();
ctx.restore();
}
function drawBullet(b){
ctx.fillStyle="rgba(255,170,80,.30)";
ctx.fillRect(b.x-b.dx*6,b.y-b.dy*6,6,6);
ctx.fillStyle=b.player?"#fff6d0":"#ffcf9c";
ctx.fillRect(b.x,b.y,6,6);
}
function drawFx(){
for(var i=0;i<fx.length;i++){
var f=fx[i];
if(f.ring){
var p=1-f.t/f.max;
ctx.globalAlpha=Math.max(0,1-p);
ctx.strokeStyle="#ffd08a";
ctx.lineWidth=3*(1-p)+0.6;
ctx.beginPath(); ctx.arc(f.x,f.y,f.r*p+4,0,Math.PI*2); ctx.stroke();
}else{
ctx.globalAlpha=Math.max(0,Math.min(1,f.t*3));
ctx.fillStyle=f.col;
ctx.fillRect(f.x-f.r/2,f.y-f.r/2,f.r,f.r);
}
}
ctx.globalAlpha=1;
}
function drawOverlay(){
if(!msg) return;
ctx.fillStyle="rgba(12,9,6,.74)";
ctx.fillRect(0,H/2-62,W,124);
ctx.strokeStyle="rgba(232,178,92,.35)";
ctx.lineWidth=1;
ctx.beginPath(); ctx.moveTo(0,H/2-62.5); ctx.lineTo(W,H/2-62.5);
ctx.moveTo(0,H/2+61.5); ctx.lineTo(W,H/2+61.5); ctx.stroke();
ctx.textAlign="center";
ctx.fillStyle="#e8b25c";
ctx.font="bold 40px 'Segoe UI',sans-serif";
ctx.fillText(msg,W/2,H/2-4);
ctx.fillStyle="#e9dcc2";
ctx.font="15px 'Segoe UI','Malgun Gothic',sans-serif";
ctx.fillText(msg2||"",W/2,H/2+30);
ctx.textAlign="left";
}
function render(){
ctx.setTransform(1,0,0,1,0,0);
ctx.clearRect(0,0,W,H);
ctx.save();
if(shake>0) ctx.translate((Math.random()-0.5)*shake,(Math.random()-0.5)*shake);
ctx.fillStyle="#121110";
ctx.fillRect(-12,-12,W+24,H+24);
ctx.strokeStyle="rgba(255,255,255,.022)";
ctx.lineWidth=1;
var i;
for(i=1;i<COLS;i++){ ctx.beginPath(); ctx.moveTo(i*CELL+0.5,0); ctx.lineTo(i*CELL+0.5,H); ctx.stroke(); }
for(i=1;i<ROWS;i++){ ctx.beginPath(); ctx.moveTo(0,i*CELL+0.5); ctx.lineTo(W,i*CELL+0.5); ctx.stroke(); }
drawMap();
for(i=0;i<spawns.length;i++) drawSpawn(spawns[i]);
for(i=0;i<enemies.length;i++) drawTank(enemies[i]);
if(player) drawTank(player);
for(i=0;i<bullets.length;i++) drawBullet(bullets[i]);
drawFx();
ctx.restore();
if(flashT>0){
ctx.fillStyle="rgba(255,190,120,"+(flashT*1.3).toFixed(3)+")";
ctx.fillRect(0,0,W,H);
}
drawOverlay();
}
/* ===================== 루프 ===================== */
var last=0;
function frame(ts){
var dt=(ts-last)/1000;
last=ts;
if(!isFinite(dt)||dt<0) dt=0;
if(dt>0.05) dt=0.05;
if(shake>0) shake=Math.max(0,shake-dt*28);
if(flashT>0) flashT=Math.max(0,flashT-dt*2.2);
for(var i=fx.length-1;i>=0;i--){
var f=fx[i];
f.t-=dt;
if(f.vx!==undefined){ f.x+=f.vx*dt; f.y+=f.vy*dt; f.vx*=0.92; f.vy*=0.92; }
if(f.t<=0) fx.splice(i,1);
}
if(state==="play"){
elapsed+=dt;
for(var s=spawns.length-1;s>=0;s--){
spawns[s].t-=dt;
if(spawns[s].t<=0){
var sp=spawns[s];
spawns.splice(s,1);
hatch(sp);
syncHud();
}
}
if(player){
if(player.shield>0) player.shield=Math.max(0,player.shield-dt);
if(held.length){
var d=held[held.length-1].split(",");
player.dx=parseInt(d[0],10);
player.dy=parseInt(d[1],10);
tryMove(player,dt);
}
}
for(var e=0;e<enemies.length;e++){
if(enemies[e].hitFlash>0) enemies[e].hitFlash-=dt;
updateEnemy(enemies[e],dt);
}
updateBullets(dt);
}
render();
requestAnimationFrame(frame);
}
reset();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>