Files
crossword/site/tank.html
T
plparkandClaude Opus 5 a9bd956c99 Add tank stage 6: mirrors and a raid on the enemy base
Five stages of holding the line; this one lets you attack. An enemy
eagle sits at the top behind steel, and a player shell that reaches it
ends the stage at once for 1000 - enemy shells cannot touch it, so the
stage cannot clear itself.

Reaching it needs the mirrors. A shell entering one turns 90 degrees,
capped at five turns and stepped one cell past the mirror so it cannot
re-enter the same one. The eagle's only opening is a bullet-only shaft
at column 8, walled in steel and fed by a "/" mirror below it, so only
a shot travelling right along row 6 banks up into it; shooting left or
from underneath deflects away. Mirrors block tanks and cannot be broken,
and they block the enemies' line of sight so they do not waste shots.

The clear/win paths were one block inside afterKill; they are now
clearStage(bonus, note) so the snipe and the last kill share them.

Checked headlessly: the snipe lands and pays 1000, the shaft saw 0 tank
samples against 639 bullet samples, 6 idle runs left the enemy eagle
standing, and closing r11's centre brought the base through 6 of 10
idle runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 17:35:09 +09:00

1392 lines
46 KiB
HTML

<!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:13px 13px 14px;
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:11px; letter-spacing:.12em}
.row{
display:flex; align-items:center; justify-content:space-between;
font-size:13px; padding:5px 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; flex-wrap:wrap; gap:3px; align-items:center;
justify-content:flex-end; max-width:152px;
}
.pip{width:9px;height:9px;border-radius:2px;background:#dcc76d;box-shadow:inset 0 -2px 0 rgba(0,0,0,.28)}
.pip.e{background:#b9c2cc}
.pip.off{background:#463728; box-shadow:none}
.keys{margin-top:11px; font-size:12px; line-height:1.72; 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:10px;font-size:10.5px;color:#8e7a5f;line-height:1.62}
.note code{color:#c0a87f;background:#2f251c;padding:0 3px;border-radius:3px}
.spec{
margin-top:10px;padding-top:9px;border-top:1px dashed #3d2f22;
font-size:10.5px;line-height:1.62;color:#9fd6b4;
}
.spec:empty{display:none}
.spec b{
display:block;font-size:10px;letter-spacing:.22em;color:#6f8f79;
font-weight:900;margin-bottom:4px;
}
button{
margin-top:10px; 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; flex-wrap:wrap; gap:5px; margin-top:10px}
.btns button{
flex:0 0 calc(33.333% - 4px); min-width:0; margin-top:0;
padding:6px 0; font-size:11px;
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 id="stagekeys"></div>
</div>
<div class="note">
갈색 벽돌은 부서지고 회색 강철은 부서지지 않습니다. 기지가 파괴되거나
탱크 3대를 모두 잃으면 게임 오버. 아래 버튼·숫자키·<code>#3</code> 주소로
각 단계에 바로 진입.
</div>
<div class="spec" id="spec"></div>
<div class="btns" id="stagebtns"></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, WATER=5, FOREST=6;
var MIRROR_A=7, MIRROR_B=8, EBASE=9, ERUIN=10; /* 반사판 / 적 본진 */
var CH_MA="/", CH_MB=String.fromCharCode(92);
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},
commander:{speed:76, hp:3, body:"#c2708e", dark:"#7a3b52", light:"#f0a8c0", fire:0.70, bspd:235,
pHunt:0.65, pRush:0.20, worth:300, crown:true}
};
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."
]
},
{
name:"STAGE 3 · 강철 회랑",
maxOnField:4,
enemies:["hunter","heavy","scout","hunter","heavy","guard","hunter","heavy"],
map:[
".................",
".SSSSS.S.S.SSSSS.",
".S.....B.B.....S.",
".S.BBBBB.BBBBB.S.",
"...B.........B...",
".BBB.SSBSBSS.BBB.",
".B...S.....S...B.",
".B.B.S.BBB.S.B.B.",
".B.B...B.B...B.B.",
".B.BBBBB.BBBBB.B.",
".B.....B.B.....B.",
".BSSS...B...SSSB.",
"...B.B.BBB.B.B...",
".BBB...BBB...BBB.",
"...B...BEB...B..."
]
},
{
name:"STAGE 4 · 참호",
maxOnField:4,
enemies:["hunter","heavy","scout","guard","hunter","heavy","scout","hunter","heavy","guard"],
items:["freeze","shield"],
itemEvery:14,
specials:"물길(탱크 통행 불가, 포탄은 통과) · 보급 상자(시간 정지 / 기지 강화)",
map:[
".................",
".BBBBB.....BBBBB.",
".B...B..S..B...B.",
".B.S.B.....B.S.B.",
".BBB.BWW.WWB.BBB.",
"...B...B.B...B...",
".BB.BBWW.WWBB.BB.",
"SS.....B.B.....SS",
"..B.BWW...WWB.B..",
".BBB.B.B.B.B.BBB.",
".B..B.......B..B.",
".B.SBB..B..BBS.B.",
".B...B.BBB.B...B.",
".B.B...BBB...B.B.",
".SS....BEB....SS."
]
},
{
name:"STAGE 5 · 최후 방어선",
maxOnField:5,
enemies:["hunter","heavy","scout","hunter","heavy","guard","hunter","heavy","scout","hunter","heavy","commander"],
items:["freeze","shield","power"],
itemEvery:12,
specials:"숲(탱크·포탄 통과, 은폐) · 화력 강화 상자 · 동시 5대 · 지휘 탱크",
map:[
".................",
".BB.BB..S..BB.BB.",
".B...B..B..B...B.",
".B.BBB.B.B.BBB.B.",
"..B.FFF...FFF.B..",
".BB.BSB...BSB.BB.",
".B..B..BBB..B..B.",
"SS..B..B.B..B..SS",
".B..B..BBB..B..B.",
".BB.BSB...BSB.BB.",
"..B.FFF...FFF.B..",
".B.BBB.B.B.BBB.B.",
".BFFFB.BBB.BFFFB.",
".BB.B..BBB..B.BB.",
".S.....BEB.....S."
]
},
{
name:"STAGE 6 · 거울 요새",
maxOnField:4,
enemies:["hunter","heavy","scout","hunter","heavy","guard","hunter","heavy","scout","hunter","heavy","commander"],
items:["freeze","shield","power"],
itemEvery:13,
specials:"반사판(포탄 90° 꺾임) · 적 본진 습격(부수면 즉시 승리)",
map:[
".................",
".BB....SSS....BB.",
".B...B.SXS.B...B.",
".B.BBB.S.S.BBB.B.",
"...B...S.S...B...",
".BB.BB.S.S.BB.BB.",
"...B..../..B...B.",
"SS..B..BBB..B..SS",
".B.\\B.......B/.B.",
".BBB.B.B.B.B.BBB.",
"..B./.......\\.B..",
".B.BBB.BBB.BBB.B.",
".B...B.BBB.B...B.",
".BB.B..BBB..B.BB.",
".S.....BEB.....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); },
item:function(){ tone("sine",900,1400,0.10,0.05); tone("sine",1400,1900,0.10,0.04,0.09); },
pickup:function(){ var n=[784,1047,1319],i; for(i=0;i<3;i++) tone("square",n[i],n[i],0.11,0.09,i*0.07); },
splash:function(){ noise(0.16,0.13,900,240,"bandpass"); },
mirror:function(){ tone("triangle",1800,2600,0.07,0.07); tone("sine",2600,1400,0.09,0.04,0.03); },
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 elSpec=document.getElementById("spec");
(function(){ /* 단계 키 안내 */
var t="",i;
for(i=1;i<=STAGES.length;i++) t+="<kbd>"+i+"</kbd>";
document.getElementById("stagekeys").innerHTML=t+" 단계 선택";
})();
var grid, player, enemies, bullets, fx, spawns, enemyQueue;
var state, msg, msg2, msg3, score, kills, livesLeft, shake, flashT, elapsed;
var stage=1, totalEnemies=4, maxOnField=3;
var items, itemT, freezeT, shieldT, shieldCells, powerT;
function curStage(){ return STAGES[stage-1]; }
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:ch==="W"?WATER:ch==="F"?FOREST:
ch===CH_MA?MIRROR_A:ch===CH_MB?MIRROR_B:ch==="X"?EBASE: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=[];
items=[]; itemT=(st.itemEvery||0); freezeT=0; shieldT=0; shieldCells=null; powerT=0;
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="화면을 클릭한 뒤 방향키 · 스페이스";
msg3=st.specials?("특수 요소 — "+st.specials):null;
syncHud(); syncUrl();
}
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;
var sp=curStage().specials;
elSpec.innerHTML=sp?("<b>특수 요소</b>"+sp):"";
}
/* ===================== 지형 유틸 ===================== */
function isSolid(t){ return t===BRICK||t===STEEL||t===BASE; }
/* 물길: 탱크는 막고 포탄은 넘어간다. rectBlocked 는 탱크 이동에만 쓰인다 */
function blocksTank(t){
return isSolid(t)||t===WATER||t===MIRROR_A||t===MIRROR_B||t===EBASE;
}
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(blocksTank(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,
worth:o.worth||100, crown:!!o.crown, maxHp:o.hp||1,
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,worth:k.worth,crown:k.crown});
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 boost=(t.isPlayer && powerT>0);
var limit=t.isPlayer?(boost?3: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*(boost?1.35:1), owner:t, player:t.isPlayer, gone:false
});
if(t.isPlayer) sfx.fire(); else sfx.efire();
return true;
}
/* ===================== 보급 상자 ===================== */
var SHELL=[[7,13],[8,13],[9,13],[7,14],[9,14]]; /* 기지 외곽 */
var ITEM_INFO={
freeze:{label:"시간 정지", note:"적 3초 정지", col:"#8fdcff", glyph:"II"},
shield:{label:"기지 강화", note:"외곽 강철 10초", col:"#cfd8e2", glyph:"[]"},
power: {label:"화력 강화", note:"연사·탄속 10초", col:"#ffd27a", glyph:"^^"}
};
function spawnItem(){
var kinds=curStage().items;
if(!kinds||!kinds.length) return;
var tries, c, r;
for(tries=0;tries<120;tries++){
c=Math.floor(Math.random()*COLS); r=Math.floor(Math.random()*ROWS);
if(grid[r][c]!==EMPTY) continue;
if(Math.abs(c-BASE_C)<3 && r>ROWS-4) continue; /* 기지 앞은 비운다 */
if(r<2) continue; /* 적 출현 지점 피하기 */
var x=c*CELL+OFF, y=r*CELL+OFF;
if(tankHits(null,x,y)) continue;
items.push({x:x,y:y,kind:kinds[Math.floor(Math.random()*kinds.length)],t:0});
sfx.item();
return;
}
}
function raiseShield(){
shieldCells=[];
for(var i=0;i<SHELL.length;i++){
var c=SHELL[i][0], r=SHELL[i][1];
shieldCells.push([c,r]);
grid[r][c]=STEEL;
}
}
function dropShield(){
if(!shieldCells) return;
for(var i=0;i<shieldCells.length;i++){
var c=shieldCells[i][0], r=shieldCells[i][1];
if(grid[r][c]===STEEL) grid[r][c]=BRICK; /* 강철이 벽돌로 되돌아온다 */
}
shieldCells=null;
}
function takeItem(it){
if(it.kind==="freeze"){ freezeT=3.0; }
else if(it.kind==="shield"){ raiseShield(); shieldT=10.0; }
else if(it.kind==="power"){ powerT=10.0; }
score+=50;
sfx.pickup();
fx.push({x:it.x+SIZE/2,y:it.y+SIZE/2,t:0.4,max:0.4,r:34,ring:true});
syncHud();
}
/* ===================== 적 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;
var t=grid[r][c];
if(t===STEEL||t===MIRROR_A||t===MIRROR_B||t===EBASE) 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===MIRROR_A||t===MIRROR_B){
if((b.bounces||0)>=5){ spark(cx,cy); b.gone=true; break; } /* 무한 반사 방지 */
b.bounces=(b.bounces||0)+1;
var nd;
if(t===MIRROR_A) nd=[-b.dy,-b.dx]; /* "/" */
else nd=[ b.dy, b.dx]; /* "\\" */
b.dx=nd[0]; b.dy=nd[1];
/* 칸 중심에 맞춘 뒤 다음 칸으로 한 칸 밀어 같은 반사판을 두 번 타지 않게 한다 */
b.x=c*CELL+CELL/2+b.dx*(CELL/2+1)-3;
b.y=r*CELL+CELL/2+b.dy*(CELL/2+1)-3;
spark(cx,cy); sfx.mirror();
continue;
}
if(t===EBASE){
if(b.player){
grid[r][c]=ERUIN;
boom(c*CELL+CELL/2,r*CELL+CELL/2,true);
b.gone=true;
clearStage(1000,"적 본진 격파 +1000");
}else{
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+=e.worth;
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 clearStage(bonus,note){
if(state!=="play") return;
score+=bonus;
if(stage<STAGES.length){
state="clear";
msg="STAGE "+stage+" CLEAR";
msg2=(note?note+" · ":"")+"Space / Enter 로 "+(stage+1)+"단계 진입";
var nx=STAGES[stage];
msg3=nx&&nx.specials?("다음 단계 특수 요소 — "+nx.specials):null;
}else{
state="win";
msg="ALL CLEAR!";
msg2=(note?note+" · ":"")+"모든 단계 돌파 · R 키로 재도전";
msg3=null;
}
sfx.win(); syncHud();
}
function afterKill(){
syncHud();
if(enemyQueue.length===0 && enemies.length===0) clearStage(500,null);
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 키로 다시 시작"; msg3=null;
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 키로 계속"; msg3=null; }
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;
});
(function(){ /* 단계 버튼은 STAGES 를 보고 만든다 */
var box=document.getElementById("stagebtns");
for(var i=1;i<=STAGES.length;i++){
(function(n){
var b=document.createElement("button");
b.textContent=n+"단계";
b.title=STAGES[n-1].name;
b.addEventListener("click",function(){
audio(); startStage(n,false); state="play"; msg=null;
});
box.appendChild(b);
})(i);
}
})();
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 drawWaterCell(c,r){
var x=c*CELL, y=r*CELL;
ctx.fillStyle="#16384a";
ctx.fillRect(x,y,CELL,CELL);
ctx.fillStyle="#1d4a61";
ctx.fillRect(x+1,y+1,CELL-2,CELL-2);
/* 잔물결 — 시간에 따라 흐른다 */
var t=waveT;
for(var i=0;i<3;i++){
var yy=y+6+i*9+Math.sin(t*1.6+i*1.1+c*0.7)*1.6;
ctx.fillStyle=(i===1)?"rgba(160,225,245,.30)":"rgba(120,195,220,.20)";
var off=((t*9+i*13+r*5)%CELL);
ctx.fillRect(x+off-CELL,yy,CELL*0.55,2);
ctx.fillRect(x+off,yy,CELL*0.55,2);
}
ctx.strokeStyle="rgba(8,26,36,.55)"; ctx.lineWidth=1;
ctx.strokeRect(x+0.5,y+0.5,CELL-1,CELL-1);
}
function drawForestCell(c,r){
var x=c*CELL, y=r*CELL;
/* 바닥 잔풀 */
ctx.fillStyle="#17240f";
ctx.fillRect(x,y,CELL,CELL);
/* 잎 덩어리 — 탱크를 덮어 은폐한다 */
var i, pts=[[7,8],[18,6],[25,14],[12,17],[4,22],[21,25],[14,27],[28,22]];
for(i=0;i<pts.length;i++){
var px=x+pts[i][0], py=y+pts[i][1];
var sw=(i%3===0)?9:7;
ctx.fillStyle=(i%2)?"#2f5a1e":"#3c7026";
ctx.beginPath(); ctx.arc(px,py,sw*0.62,0,Math.PI*2); ctx.fill();
ctx.fillStyle="rgba(150,205,110,.22)";
ctx.beginPath(); ctx.arc(px-1.4,py-1.8,sw*0.28,0,Math.PI*2); ctx.fill();
}
}
function drawForest(){
for(var r=0;r<ROWS;r++) for(var c=0;c<COLS;c++)
if(grid[r][c]===FOREST) drawForestCell(c,r);
}
function drawMirrorCell(c,r,kind){
var x=c*CELL, y=r*CELL;
ctx.fillStyle="#2b3138";
ctx.fillRect(x,y,CELL,CELL);
ctx.fillStyle="#3c444d";
ctx.fillRect(x+2,y+2,CELL-4,CELL-4);
/* 거울면 */
ctx.save();
ctx.lineCap="round";
var a=(kind===MIRROR_A)?[x+5,y+CELL-5,x+CELL-5,y+5]:[x+5,y+5,x+CELL-5,y+CELL-5];
ctx.strokeStyle="rgba(0,0,0,.55)"; ctx.lineWidth=6;
ctx.beginPath(); ctx.moveTo(a[0],a[1]); ctx.lineTo(a[2],a[3]); ctx.stroke();
ctx.strokeStyle="#dff2ff"; ctx.lineWidth=3.2;
ctx.beginPath(); ctx.moveTo(a[0],a[1]); ctx.lineTo(a[2],a[3]); ctx.stroke();
ctx.strokeStyle="rgba(120,210,255,.85)"; ctx.lineWidth=1.2;
ctx.beginPath(); ctx.moveTo(a[0],a[1]); ctx.lineTo(a[2],a[3]); ctx.stroke();
ctx.restore();
ctx.strokeStyle="rgba(10,14,18,.6)"; ctx.lineWidth=1;
ctx.strokeRect(x+0.5,y+0.5,CELL-1,CELL-1);
}
function drawEnemyBase(c,r,ruin){
var x=c*CELL, y=r*CELL;
ctx.fillStyle="#2a1a1e";
ctx.fillRect(x,y,CELL,CELL);
if(ruin){
ctx.fillStyle="#5a3a42";
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="#e3a8bc"; /* 적 독수리 */
ctx.beginPath();
ctx.moveTo(x+CELL/2,y+CELL-5);
ctx.lineTo(x+CELL-5,y+CELL-13);
ctx.lineTo(x+CELL-9,y+CELL-13);
ctx.lineTo(x+CELL-5,y+5);
ctx.lineTo(x+5,y+5);
ctx.lineTo(x+9,y+CELL-13);
ctx.lineTo(x+5,y+CELL-13);
ctx.closePath(); ctx.fill();
ctx.fillStyle="#a5566e";
ctx.fillRect(x+CELL/2-2,y+6,4,CELL-15);
ctx.fillStyle="#7e3b50";
ctx.fillRect(x+6,y+4,CELL-12,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);
else if(t===WATER) drawWaterCell(c,r);
else if(t===MIRROR_A||t===MIRROR_B) drawMirrorCell(c,r,t);
else if(t===EBASE) drawEnemyBase(c,r,false);
else if(t===ERUIN) drawEnemyBase(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();
if(t.crown){ /* 지휘 탱크 표식 */
ctx.fillStyle="#ffd86a";
ctx.fillRect(-5,-s/2+6,10,2);
ctx.fillRect(-5,-s/2+6,2,4);
ctx.fillRect(-1,-s/2+6,2,4);
ctx.fillRect(3,-s/2+6,2,4);
}
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 drawItem(it){
var info=ITEM_INFO[it.kind]||ITEM_INFO.freeze;
var bob=Math.sin(it.t*4)*1.8;
var x=it.x, y=it.y+bob, s=SIZE;
ctx.save();
ctx.globalAlpha=0.35+0.25*Math.sin(it.t*5);
ctx.strokeStyle=info.col; ctx.lineWidth=2;
ctx.strokeRect(x-2,y-2,s+4,s+4);
ctx.globalAlpha=1;
ctx.fillStyle="#2b2318";
ctx.fillRect(x+2,y+2,s-4,s-4);
ctx.fillStyle=info.col;
ctx.fillRect(x+2,y+2,s-4,3);
ctx.strokeStyle=info.col; ctx.lineWidth=1.6;
ctx.strokeRect(x+4.5,y+4.5,s-9,s-9);
ctx.fillStyle=info.col;
ctx.font="bold 11px 'Segoe UI',sans-serif";
ctx.textAlign="center";
ctx.fillText(info.glyph,x+s/2,y+s/2+5);
ctx.textAlign="left";
ctx.restore();
}
function drawBadges(){
var list=[],i;
if(freezeT>0) list.push(["시간 정지",freezeT,"#8fdcff"]);
if(shieldT>0) list.push(["기지 강화",shieldT,"#cfd8e2"]);
if(powerT>0) list.push(["화력 강화",powerT,"#ffd27a"]);
for(i=0;i<list.length;i++){
var y=6+i*22;
ctx.fillStyle="rgba(12,9,6,.72)";
ctx.fillRect(6,y,104,18);
ctx.fillStyle=list[i][2];
ctx.fillRect(6,y,3,18);
ctx.font="bold 11px 'Segoe UI','Malgun Gothic',sans-serif";
ctx.fillText(list[i][0]+" "+list[i][1].toFixed(1)+"s",14,y+13);
}
}
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;
var bh=msg3?150:124;
ctx.fillStyle="rgba(12,9,6,.74)";
ctx.fillRect(0,H/2-bh/2,W,bh);
ctx.strokeStyle="rgba(232,178,92,.35)";
ctx.lineWidth=1;
ctx.beginPath(); ctx.moveTo(0,H/2-bh/2-0.5); ctx.lineTo(W,H/2-bh/2-0.5);
ctx.moveTo(0,H/2+bh/2+0.5); ctx.lineTo(W,H/2+bh/2+0.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+28);
if(msg3){
ctx.fillStyle="#9fd6b4";
ctx.font="12.5px 'Segoe UI','Malgun Gothic',sans-serif";
ctx.fillText(msg3,W/2,H/2+52);
}
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<items.length;i++) drawItem(items[i]);
for(i=0;i<bullets.length;i++) drawBullet(bullets[i]);
drawForest(); /* 숲은 탱크 위에 덮인다 */
drawFx();
drawBadges();
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, waveT=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;
waveT+=dt;
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;
/* 보급 상자와 효과 시간 */
if(curStage().itemEvery){
itemT-=dt;
if(itemT<=0){
if(items.length===0) spawnItem();
itemT=curStage().itemEvery;
}
}
if(freezeT>0) freezeT=Math.max(0,freezeT-dt);
if(powerT>0) powerT=Math.max(0,powerT-dt);
if(shieldT>0){
shieldT=Math.max(0,shieldT-dt);
if(shieldT===0) dropShield();
}
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 ii=items.length-1;ii>=0;ii--){
items[ii].t+=dt;
if(boxesHit(player.x,player.y,items[ii].x,items[ii].y)){
takeItem(items[ii]); items.splice(ii,1);
}
}
}
for(var e=0;e<enemies.length;e++){
if(enemies[e].hitFlash>0) enemies[e].hitFlash-=dt;
if(freezeT<=0) updateEnemy(enemies[e],dt); /* 시간 정지 중에는 멈춘다 */
}
updateBullets(dt);
}
render();
requestAnimationFrame(frame);
}
/* URL 로 단계 직접 진입: tank.html#3 또는 tank.html?stage=3 */
function stageFromUrl(){
var h=String(location.hash||"").replace(/^#\/?/,"");
var m=h.match(/^(?:stage)?([0-9]+)$/);
if(!m){
var q=String(location.search||"").match(/[?&]stage=([0-9]+)/);
if(q) m=[0,q[1]];
}
if(!m) return 0;
var n=parseInt(m[1],10);
return (n>=1 && n<=STAGES.length) ? n : 0;
}
function syncUrl(){
/* 셸의 iframe 안에서는 주소의 주인이 셸이다. 단독으로 열렸을 때만 동기화한다 */
try{ if(window.top===window) history.replaceState(null,"","#"+stage); }catch(e){}
}
/* 셸이 iframe 의 조각만 바꿀 때는 문서가 다시 읽히지 않으므로 hashchange 로 받는다 */
window.addEventListener("hashchange",function(){
startStage(stageFromUrl()||1,false);
});
startStage(stageFromUrl()||1,false);
requestAnimationFrame(frame);
})();
</script>
</body>
</html>