Stages so far only varied the walls. Give stage 4 rules of its own. Water blocks tanks but not shells, so the centre of the map is crossed only at the fords while both sides can still shoot across it. It is one predicate - rectBlocked is used for tank movement alone, so adding WATER there leaves bullets untouched. Supply crates appear on a timer and grant a 3s enemy freeze or 10s of steel around the base; the shovel effect puts brick back when it lapses, so broken walls are repaired. Active effects show a countdown badge. Stages carry a specials line: shown on the start overlay and in the HUD on entry, and previewed on the preceding stage's clear screen. Checked headlessly: over 247k sampled tank positions none sat on water while 7591 bullet frames did, crates were taken 8 times in 8 runs, and stage 4 leaves the base standing in 7 of 10 idle runs with 98 cells the player can reach without firing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1225 lines
40 KiB
HTML
1225 lines
40 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; 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: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; gap:4px; margin-top:10px}
|
|
.btns button{
|
|
flex:1 1 0; 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;
|
|
|
|
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."
|
|
]
|
|
},
|
|
{
|
|
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."
|
|
]
|
|
}
|
|
];
|
|
|
|
/* ===================== 사운드 (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"); },
|
|
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: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; }
|
|
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,
|
|
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 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;
|
|
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)+"단계 진입";
|
|
var nx=STAGES[stage];
|
|
msg3=nx&&nx.specials?("다음 단계 특수 요소 — "+nx.specials):null;
|
|
}else{
|
|
state="win";
|
|
msg="ALL CLEAR!"; msg2="모든 단계 돌파 · R 키로 재도전"; msg3=null;
|
|
}
|
|
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 키로 다시 시작"; 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 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);
|
|
}
|
|
}
|
|
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 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]);
|
|
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>
|