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>
983 lines
32 KiB
HTML
983 lines
32 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: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>
|