diff --git a/README.md b/README.md
index ec55b7f..465f6fa 100644
--- a/README.md
+++ b/README.md
@@ -28,13 +28,29 @@
| 탱크게임 | `/tank.html` | 1단계 요새 | 4대 (동시 3) | 벽돌 밀도 25%, 넓은 기동 공간 |
| ↳ | `/tank.html#2` | 2단계 미로 | 6대 (동시 4) | 밀도 43%, 1칸 폭 통로가 얽힌 미로 |
| ↳ | `/tank.html#3` | 3단계 강철 회랑 | 8대 (동시 4) | 강철 31칸, 기지 외곽 2중 + 좁은 진입로 |
+| ↳ | `/tank.html#4` | 4단계 참호 | 10대 (동시 4) | **물길** + **보급 상자**(시간 정지 / 기지 강화) |
방향키 이동, 스페이스 발사. 한 단계를 돌파하면 다음 단계로 이어지고, 점수와 남은 탱크를
그대로 가져간다. **단계를 거치지 않고 바로 들어가는 방법**은 셋이다.
-- 왼쪽 탭의 `휴게실 > 탱크게임` 아래 단계 칩 (셸 주소로는 `/#tank2`, `/#tank3`)
-- 게임 화면 오른쪽 아래의 단계 버튼, 또는 `1` ~ `3` 키
-- 주소 직접 입력 — `/tank.html#3`, `/tank.html?stage=3` (범위를 벗어난 값은 1단계)
+- 왼쪽 탭의 `휴게실 > 탱크게임` 아래 단계 칩 (셸 주소로는 `/#tank2` … `/#tank4`)
+- 게임 화면 오른쪽 아래의 단계 버튼, 또는 `1` ~ `4` 키
+- 주소 직접 입력 — `/tank.html#4`, `/tank.html?stage=4` (범위를 벗어난 값은 1단계)
+
+## 특수 요소
+
+3단계까지는 지형만 달라지지만 4단계부터는 규칙이 하나씩 붙는다. 단계에 들어갈 때
+시작 화면 세 번째 줄과 오른쪽 `특수 요소` 칸에 그 단계에 걸린 규칙이 나오고, 앞
+단계를 돌파한 화면에서는 다음 단계의 규칙을 미리 알려 준다.
+
+| 요소 | 동작 | 쓰인 단계 |
+|---|---|---|
+| 물길 `W` | 탱크는 건널 수 없고 포탄은 넘어간다. 건너려면 도선(ford)으로 돌아야 한다 | 4 |
+| 보급 상자 | 일정 주기로 빈 칸에 하나 놓인다. 밟으면 효과 + 50점 | 4 |
+| ↳ 시간 정지 | 적 전원이 3초간 멈춘다 (포탄은 계속 난다) | 4 |
+| ↳ 기지 강화 | 기지 외곽 5칸이 10초간 강철이 된다. 끝나면 벽돌로 돌아오며 부서졌던 칸도 메워진다 | 4 |
+
+효과가 걸린 동안에는 화면 왼쪽 위에 남은 시간이 배지로 표시된다.
낱말풀이와 공유하는 자산이 없는 단독 페이지다. 캔버스와 WebAudio 만 쓰므로 자산 해시
스탬프(아래 캐시 항목)의 영향도 받지 않는다. iframe 안에서는 **화면을 한 번 클릭해
diff --git a/site/index.html b/site/index.html
index 98292f2..6144957 100644
--- a/site/index.html
+++ b/site/index.html
@@ -174,6 +174,7 @@ body{
1단계2단계3단계
+ 4단계
@@ -199,7 +200,8 @@ var PAGES = {
tank: {file:'tank.html', name:'휴게실 · 탱크게임'},
tank1: {file:'tank.html#1', name:'휴게실 · 탱크게임 1단계'},
tank2: {file:'tank.html#2', name:'휴게실 · 탱크게임 2단계'},
- tank3: {file:'tank.html#3', name:'휴게실 · 탱크게임 3단계'}
+ tank3: {file:'tank.html#3', name:'휴게실 · 탱크게임 3단계'},
+ tank4: {file:'tank.html#4', name:'휴게실 · 탱크게임 4단계'}
};
var DEFAULT = 'korean';
diff --git a/site/tank.html b/site/tank.html
index 13697df..aaa9615 100644
--- a/site/tank.html
+++ b/site/tank.html
@@ -38,14 +38,14 @@
background:var(--panel);
border:2px solid var(--line);
border-radius:10px;
- padding:14px 14px 16px;
+ 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:14px; letter-spacing:.12em}
+ .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:7px 0; border-bottom:1px dashed #3d2f22;
+ 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}
@@ -53,7 +53,7 @@
.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}
+ .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);
@@ -61,19 +61,28 @@
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}
+ .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:14px; width:100%; cursor:pointer;
+ 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:6px}
+ .btns{display:flex; gap:4px; margin-top:10px}
.btns button{
- flex:0 0 calc(33.333% - 4px); margin-top:8px; padding:7px 0; font-size:12px;
+ flex:1 1 0; min-width:0; margin-top:0; padding:6px 0; font-size:11px;
background:linear-gradient(#5c4a34,#463725); border-color:#3a2c1c;
}
@@ -96,11 +105,11 @@
- 갈색 벽돌은 포탄에 부서지고, 회색 강철은 부서지지 않습니다.
- 기지가 파괴되거나 탱크 3대를 모두 잃으면 게임 오버.
- 돌파하면 다음 단계로 넘어가고, 아래 버튼이나 #2·#3
- 같은 주소로 각 단계에 바로 들어갈 수도 있습니다.
+ 갈색 벽돌은 부서지고 회색 강철은 부서지지 않습니다. 기지가 파괴되거나
+ 탱크 3대를 모두 잃으면 게임 오버. 아래 버튼·숫자키·#3 주소로
+ 각 단계에 바로 진입.
+
@@ -113,7 +122,7 @@
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 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};
@@ -194,6 +203,31 @@ var STAGES=[
".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."
+ ]
}
];
@@ -250,6 +284,9 @@ var sfx={
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); }
};
@@ -259,6 +296,7 @@ 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+=""+i+"";
@@ -266,8 +304,10 @@ var elStage=document.getElementById("stage");
})();
var grid, player, enemies, bullets, fx, spawns, enemyQueue;
-var state, msg, msg2, score, kills, livesLeft, shake, flashT, elapsed;
+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;
@@ -284,7 +324,7 @@ function buildGrid(src){
var row=[];
for(c=0;c특수 요소"+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(isSolid(grid[r][c])) return true;
+ if(blocksTank(grid[r][c])) return true;
}
}
return false;
@@ -438,18 +484,68 @@ function canStep(t,d){
}
function fire(t){
- var limit=t.isPlayer?2:1, mine=0, i;
+ var boost=(t.isPlayer && powerT>0);
+ var limit=t.isPlayer?(boost?3:2):1, mine=0, i;
for(i=0;i=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
+ 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;i0) list.push(["시간 정지",freezeT,"#8fdcff"]);
+ if(shieldT>0) list.push(["기지 강화",shieldT,"#cfd8e2"]);
+ if(powerT>0) list.push(["화력 강화",powerT,"#ffd27a"]);
+ for(i=0;i0){
@@ -963,13 +1125,14 @@ function render(){
}
/* ===================== 루프 ===================== */
-var last=0;
+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);
@@ -982,6 +1145,21 @@ function frame(ts){
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){
@@ -999,10 +1177,16 @@ function frame(ts){
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;e0) enemies[e].hitFlash-=dt;
- updateEnemy(enemies[e],dt);
+ if(freezeT<=0) updateEnemy(enemies[e],dt); /* 시간 정지 중에는 멈춘다 */
}
updateBullets(dt);
}