From 440af0df2af9c85b8a13f653ed3e511c8fd22a35 Mon Sep 17 00:00:00 2001 From: plpark Date: Fri, 11 Sep 2026 16:54:33 +0900 Subject: [PATCH] Add tank stage 4 with water and supply crates 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 --- README.md | 22 ++++- site/index.html | 4 +- site/tank.html | 238 ++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 233 insertions(+), 31 deletions(-) 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); }