Add tank stage 3 and per-stage entry links

Two stages were not much of a ladder. Add STAGE 3 · 강철 회랑: 31 steel
cells the shells cannot pass, a double brick cap over the base and two
narrow funnels into it, 8 enemies with 4 on the field.

Getting to a later stage meant clearing the earlier ones. Read the stage
from the URL (#3 or ?stage=3) at load and on hashchange, so the shell's
nav chips and a plain link both land where they say. The stage buttons
and key hints are built from STAGES, so a new stage needs no UI edit.

Simulated 10 idle and 10 random-bot runs per stage: stage 3 leaves the
base standing in 6 of 10 idle runs, and its 144 reachable cells keep the
player out of a sealed pocket - an earlier layout had only 8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 16:38:20 +09:00
co-authored by Claude Opus 5
parent f771a6b3e5
commit f208619ac4
3 changed files with 112 additions and 21 deletions
+15 -4
View File
@@ -19,16 +19,27 @@
기본으로 열리는 탭은 한국 문학이다. 각 페이지는 단독으로 열어도 동작한다. 셸(`/`)의 탭은 해시로 상태를 남기므로 기본으로 열리는 탭은 한국 문학이다. 각 페이지는 단독으로 열어도 동작한다. 셸(`/`)의 탭은 해시로 상태를 남기므로
`/#korean`, `/#russian`, `/#british`, `/#french`, `/#german`, `/#japanese`, `/#tank` 로 바로 들어갈 수 있고, 탭을 다시 눌러도 풀던 내용은 유지된다. `/#korean`, `/#russian`, `/#british`, `/#french`, `/#german`, `/#japanese`, `/#tank` 로 바로 들어갈 수 있고, 탭을 다시 눌러도 풀던 내용은 유지된다.
휴게실 탱크게임의 단계별 진입은 `/#tank1`, `/#tank2`, `/#tank3` 이다.
## 휴게실 ## 휴게실
| 탭 | 경로 | 내용 | | 탭 | 경로 | 단계 | 적 | 특징 |
|---|---|---| |---|---|---|---|---|
| 탱크게임 | `/tank.html` | 2단계 구성 — 1단계 요새(적 4대) / 2단계 미로(적 6대). 방향키 이동, 스페이스 발사 | | 탱크게임 | `/tank.html` | 1단계 요새 | 4대 (동시 3) | 벽돌 밀도 25%, 넓은 기동 공간 |
| ↳ | `/tank.html#2` | 2단계 미로 | 6대 (동시 4) | 밀도 43%, 1칸 폭 통로가 얽힌 미로 |
| ↳ | `/tank.html#3` | 3단계 강철 회랑 | 8대 (동시 4) | 강철 31칸, 기지 외곽 2중 + 좁은 진입로 |
방향키 이동, 스페이스 발사. 한 단계를 돌파하면 다음 단계로 이어지고, 점수와 남은 탱크를
그대로 가져간다. **단계를 거치지 않고 바로 들어가는 방법**은 셋이다.
- 왼쪽 탭의 `휴게실 > 탱크게임` 아래 단계 칩 (셸 주소로는 `/#tank2`, `/#tank3`)
- 게임 화면 오른쪽 아래의 단계 버튼, 또는 `1` ~ `3` 키
- 주소 직접 입력 — `/tank.html#3`, `/tank.html?stage=3` (범위를 벗어난 값은 1단계)
낱말풀이와 공유하는 자산이 없는 단독 페이지다. 캔버스와 WebAudio 만 쓰므로 자산 해시 낱말풀이와 공유하는 자산이 없는 단독 페이지다. 캔버스와 WebAudio 만 쓰므로 자산 해시
스탬프(아래 캐시 항목)의 영향도 받지 않는다. iframe 안에서는 **화면을 한 번 클릭해 스탬프(아래 캐시 항목)의 영향도 받지 않는다. iframe 안에서는 **화면을 한 번 클릭해
포커스를 준 뒤** 키를 눌러야 한다. 포커스를 준 뒤** 키를 눌러야 한다. 셸이 iframe 의 조각(`#2`)만 바꿀 때는 문서가 다시
읽히지 않으므로, 게임 쪽에서 `hashchange` 를 받아 단계를 바꾼다.
## 구성 ## 구성
+23 -1
View File
@@ -54,6 +54,20 @@ body{
.nav .label.rest{ .nav .label.rest{
margin-top:14px;padding-top:15px;border-top:1px solid var(--line); margin-top:14px;padding-top:15px;border-top:1px solid var(--line);
} }
.nav .stagejump{
display:flex;flex-wrap:wrap;gap:6px;padding:9px 17px 11px;
border-bottom:1px solid #221E19;
}
.nav .stagejump a{
flex:0 0 calc(33.333% - 4px);display:block;padding:6px 0;border:1px solid var(--line);
border-radius:4px;text-align:center;text-decoration:none;
font-size:11px;font-weight:700;letter-spacing:.02em;
color:var(--slate);background:#1A1712;
}
.nav .stagejump a:hover{color:var(--amber);background:#221E19}
.nav .stagejump a[aria-current="page"]{
background:var(--vermilion);border-color:var(--amber);color:#fff;
}
.nav a{ .nav a{
display:block;text-decoration:none;color:inherit; display:block;text-decoration:none;color:inherit;
padding:12px 18px 12px 17px;border-left:3px solid transparent; padding:12px 18px 12px 17px;border-left:3px solid transparent;
@@ -156,6 +170,11 @@ body{
<span class="kr">탱크게임</span> <span class="kr">탱크게임</span>
<span class="og">Tank Battle</span> <span class="og">Tank Battle</span>
</a> </a>
<div class="stagejump">
<a href="tank.html#1" target="stage" data-key="tank1" title="1단계 요새">1단계</a>
<a href="tank.html#2" target="stage" data-key="tank2" title="2단계 미로">2단계</a>
<a href="tank.html#3" target="stage" data-key="tank3" title="3단계 강철 회랑">3단계</a>
</div>
</div> </div>
<div class="rail-foot"> <div class="rail-foot">
@@ -177,7 +196,10 @@ var PAGES = {
french: {file:'french.html', name:'프랑스 문학'}, french: {file:'french.html', name:'프랑스 문학'},
german: {file:'german.html', name:'독일 문학'}, german: {file:'german.html', name:'독일 문학'},
japanese:{file:'japanese.html', name:'일본 문학'}, japanese:{file:'japanese.html', name:'일본 문학'},
tank: {file:'tank.html', name:'휴게실 · 탱크게임'} 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단계'}
}; };
var DEFAULT = 'korean'; var DEFAULT = 'korean';
+73 -15
View File
@@ -62,6 +62,7 @@
font-family:inherit; font-family:inherit;
} }
.note{margin-top:12px;font-size:11px;color:#8e7a5f;line-height:1.7} .note{margin-top:12px;font-size:11px;color:#8e7a5f;line-height:1.7}
.note code{color:#c0a87f;background:#2f251c;padding:0 3px;border-radius:3px}
button{ button{
margin-top:14px; width:100%; cursor:pointer; margin-top:14px; width:100%; cursor:pointer;
background:linear-gradient(#9a6a35,#7a5026); background:linear-gradient(#9a6a35,#7a5026);
@@ -70,8 +71,11 @@
} }
button:hover{filter:brightness(1.1)} button:hover{filter:brightness(1.1)}
button:active{transform:translateY(1px); border-bottom-width:2px} button:active{transform:translateY(1px); border-bottom-width:2px}
.btns{display:flex; gap:8px} .btns{display:flex; flex-wrap:wrap; gap:6px}
.btns button{background:linear-gradient(#5c4a34,#463725); border-color:#3a2c1c} .btns button{
flex:0 0 calc(33.333% - 4px); margin-top:8px; padding:7px 0; font-size:12px;
background:linear-gradient(#5c4a34,#463725); border-color:#3a2c1c;
}
</style> </style>
</head> </head>
<body> <body>
@@ -89,17 +93,15 @@
<div><kbd>←</kbd><kbd>→</kbd><kbd>↑</kbd><kbd>↓</kbd> 이동</div> <div><kbd>←</kbd><kbd>→</kbd><kbd>↑</kbd><kbd>↓</kbd> 이동</div>
<div><kbd>Space</kbd> 발사</div> <div><kbd>Space</kbd> 발사</div>
<div><kbd>P</kbd> 일시정지 <kbd>R</kbd> 다시시작</div> <div><kbd>P</kbd> 일시정지 <kbd>R</kbd> 다시시작</div>
<div><kbd>1</kbd><kbd>2</kbd> 단계 직접 선택</div> <div id="stagekeys"></div>
</div> </div>
<div class="note"> <div class="note">
갈색 벽돌은 포탄에 부서지고, 회색 강철은 부서지지 않습니다. 갈색 벽돌은 포탄에 부서지고, 회색 강철은 부서지지 않습니다.
기지가 파괴되거나 탱크 3대를 모두 잃으면 게임 오버. 기지가 파괴되거나 탱크 3대를 모두 잃으면 게임 오버.
<br>1단계 적 4대 → 돌파 시 2단계(미로, 적 6대) 진입. <br>돌파하면 다음 단계로 넘어가고, 아래 버튼이나 <code>#2</code>·<code>#3</code>
</div> 같은 주소로 각 단계에 바로 들어갈 수도 있습니다.
<div class="btns">
<button id="b1">1단계</button>
<button id="b2">2단계</button>
</div> </div>
<div class="btns" id="stagebtns"></div>
<button id="btn">현재 단계 다시 시작 (R)</button> <button id="btn">현재 단계 다시 시작 (R)</button>
</aside> </aside>
</div> </div>
@@ -170,6 +172,28 @@ var STAGES=[
"...B...BBB...B...", "...B...BBB...B...",
".S.B...BEB...B.S." ".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..."
]
} }
]; ];
@@ -235,6 +259,11 @@ var cv=document.getElementById("cv"), ctx=cv.getContext("2d");
var elE=document.getElementById("epips"), elP=document.getElementById("ppips"); var elE=document.getElementById("epips"), elP=document.getElementById("ppips");
var elS=document.getElementById("score"), elK=document.getElementById("kills"); var elS=document.getElementById("score"), elK=document.getElementById("kills");
var elStage=document.getElementById("stage"); var elStage=document.getElementById("stage");
(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 grid, player, enemies, bullets, fx, spawns, enemyQueue;
var state, msg, msg2, score, kills, livesLeft, shake, flashT, elapsed; var state, msg, msg2, score, kills, livesLeft, shake, flashT, elapsed;
@@ -278,7 +307,7 @@ function startStage(n,keep){
spawnPlayer(0.4); spawnPlayer(0.4);
fillEnemySpawns(0.9); fillEnemySpawns(0.9);
state="ready"; msg=st.name; msg2="화면을 클릭한 뒤 방향키 · 스페이스"; state="ready"; msg=st.name; msg2="화면을 클릭한 뒤 방향키 · 스페이스";
syncHud(); syncHud(); syncUrl();
} }
function reset(){ startStage(1,false); } function reset(){ startStage(1,false); }
@@ -705,12 +734,20 @@ window.addEventListener("blur",function(){ held.length=0; });
document.getElementById("btn").addEventListener("click",function(){ document.getElementById("btn").addEventListener("click",function(){
audio(); startStage(state==="win"?1:stage,false); state="play"; msg=null; audio(); startStage(state==="win"?1:stage,false); state="play"; msg=null;
}); });
document.getElementById("b1").addEventListener("click",function(){ (function(){ /* 단계 버튼은 STAGES 를 보고 만든다 */
audio(); startStage(1,false); state="play"; msg=null; var box=document.getElementById("stagebtns");
}); for(var i=1;i<=STAGES.length;i++){
document.getElementById("b2").addEventListener("click",function(){ (function(n){
audio(); startStage(2,false); state="play"; msg=null; 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(){ cv.addEventListener("mousedown",function(){
audio(); audio();
try{ window.focus(); cv.focus(); }catch(e){} /* iframe 안에서도 키 입력을 받도록 */ try{ window.focus(); cv.focus(); }catch(e){} /* iframe 안에서도 키 입력을 받도록 */
@@ -974,7 +1011,28 @@ function frame(ts){
requestAnimationFrame(frame); requestAnimationFrame(frame);
} }
reset(); /* 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); requestAnimationFrame(frame);
})(); })();
</script> </script>