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); }