Underground 2 Revived
Play Need for Speed: Underground 2 (2004) in Chrome and Firefox with WebGPU graphics, Web Audio, up to 120 fps widescreen, controller support, free roam and Online races with friends. Fan project, not
翻譯準備中
尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證cnation Slay the Spire (모바일 게임)
翻譯準備中
取自儲存庫 README
正在載入完整 README。 以下為已儲存的摘錄。
원작 Slay the Spire (1편) 의 규칙을 그대로 재현한 모바일 세로형 웹 게임입니다. 스마트폰 브라우저에서 바로 실행되며, 컴퓨터(AI)가 조종하는 몬스터와 1층부터 50층까지 대결합니다. • 카드/UI : HTML + CSS + 인라인 SVG (외부 이미지 파일 0개, 모든 문양은 벡터로 생성) • 캐릭터/몬스터 : three.js 절차적 저폴리 모델 (외부 3D 모델·텍스처 0개) • 효과음 : WebAudio 합성 (외부 음원 파일 0개) • 빌드 도구 불필요 — 순수 ES 모듈, 정적 호스팅만으로 동작 → 브라우저에서 http://localhost:8080 접속 (모바일 뷰로 보면 가장 자연스럽습니다). file:// 로 직접 열면 ES 모듈 CORS 정책 때문에 동작하지 않습니다. 반드시 서버로 여세요. Vercel 배포 정적 사이트라 별도 설정이 필요 없습니다. • Framework Preset : Other • Build Command : 비움 • Output Directory : .檢視完整 README ↗
Version 1.3.11
원작 Slay the Spire (1편) 의 규칙을 그대로 재현한 모바일 세로형 웹 게임입니다. 스마트폰 브라우저에서 바로 실행되며, 컴퓨터(AI)가 조종하는 몬스터와 1층부터 50층까지 대결합니다.
# 정적 서버라면 무엇이든 가능합니다
npx serve . # 또는 npm run dev
python3 -m http.server 8080
→ 브라우저에서 http://localhost:8080 접속 (모바일 뷰로 보면 가장 자연스럽습니다).
file://로 직접 열면 ES 모듈 CORS 정책 때문에 동작하지 않습니다. 반드시 서버로 여세요.
정적 사이트라 별도 설정이 필요 없습니다.
vercel # 프리뷰
vercel --prod # 프로덕션
.vercel.json 에 캐시/보안 헤더가 이미 들어 있습니다.
배포 후 모바일에서 홈 화면에 추가 하면 전체화면 앱처럼 실행됩니다 (manifest.webmanifest 포함).
| 동작 | 조작 |
|---|---|
| 캐릭터 선택 | 새 게임 → 4명 중 선택 → 시작 보너스 선택 |
| 카드 선택 | 카드를 탭 (선택된 카드는 가운데에서 크게 표시) |
| 카드 사용 | 선택된 카드를 한 번 더 탭 하거나 위로 밀기(스와이프) |
| 대상 지정 | 적이 둘 이상이면 빨간 화살표가 뜬 적을 탭 |
| 턴 종료 | 우측 하단 턴 종료 |
| 더미 확인 | 좌/우 하단 뽑을 · 버린 · 소각 버튼 |
| 물약 사용 | 하단 물약 슬롯 탭 |
| 상태이상 설명 | 적/플레이어의 아이콘 탭 |
| 유물 설명 | 상단 유물 아이콘 탭 |
진행 상황은 매 층마다 localStorage 에 자동 저장되며 타이틀의 이어하기 로 복귀합니다.
| 막 | 층 | 보스 |
|---|---|---|
| 1막 | 1 ~ 17층 | 17층 중간 보스 |
| 2막 | 18 ~ 34층 | 34층 중간 보스 |
| 3막 | 35 ~ 50층 | 50층 최종 보스 |
기본값 + 힘(+활력) → 약화 ×0.75 → 취약 ×1.5 → 내림 후 방어도 차감.| 항목 | 수량 | 비고 |
|---|---|---|
| 카드 | 363종 | 아이언클래드 75 · 사일런트 77 · 디펙트 76 · 와쳐 83 · 무색 33 · 상태이상 5 · 저주 14 |
| 캐릭터 | 4종 | 아이언클래드 / 사일런트 / 디펙트 / 와쳐 |
| 몬스터 | 57종 | 1~3막 일반·정예 + 보스 9종 전부 |
| 유물 | 117종 | 시작/일반/고급/희귀/보스 + 캐릭터 전용 |
| 물약 | 30종 | |
| ? 이벤트 | 14종 | |
| 시작 보너스 | 15종 | 혜택 10 + 대가가 따르는 보너스 5 |
| 힘·상태이상 | 115종 |
| 캐릭터 | 체력 | 시작 유물 | 고유 시스템 |
|---|---|---|---|
| 아이언클래드 | 80 | 작열하는 피 | 힘 축적 / 체력 회복 |
| 사일런트 | 70 | 뱀의 반지 | 중독 · 단검(Shiv) · 카드 버리기 연계 |
| 디펙트 | 75 | 금 간 핵 | 구체(Orb) — 번개/냉기/암흑/플라즈마 충전·발동, 집중(Focus) |
| 와쳐 | 72 | 순수한 물 | 자세(Stance) — 분노(×2)/평온/신성(×3), 주문(Mantra), 통찰(Scry) |
index.html 진입점 (importmap + 화면 골격)
css/style.css 모바일 세로형 레이아웃 / 카드 스타일
vendor/three.module.min.js three.js (로컬 동봉 → 오프라인·CDN 차단 환경에서도 동작)
audio/ 배경음악 mp3 15곡 (128kbps · 약 34MB)
js/
├ main.js 게임 컨트롤러 : 화면 전환, 지도, 전투 UI, 보상/상점/이벤트
├ util.js 시드 난수(RNG), DOM 헬퍼
├ audio.js WebAudio 합성 효과음 + 소리 설정 저장
├ bgm.js 배경음악 : 화면별 곡 지정 · 크로스페이드 · 전투곡 랜덤
├ data/
│ ├ carddb.js 카드 클래스 / 레지스트리
│ ├ cards_ironclad.js 아이언클래드 75종
│ ├ cards_colorless.js 무색 + 상태이상 + 저주
│ ├ cards.js 카드 인덱스
│ ├ monsters.js 몬스터 57종 + 조우 테이블
│ ├ relics.js 유물 102종
│ ├ potions.js 물약 30종
│ └ events.js ? 방 이벤트 14종
├ engine/
│ ├ powers.js 힘·상태이상 정의
│ ├ battle.js 전투 엔진 (턴·피해·AI·카드 해결)
│ └ run.js 런 진행 : 지도 생성, 보상, 막 전환, 저장/불러오기
├ ui/
│ ├ icons.js SVG 글리프 35종 + 아이콘 매핑
│ └ cardview.js 카드 렌더러 (HTML/CSS + SVG)
└ three/
└ scene3d.js 절차적 저폴리 캐릭터 · 전투 연출
Slay the Spire 는 Mega Crit 의 저작물입니다.
이 저장소는 원작의 규칙을 학습·연구 목적으로 재구현한 비영리 팬 프로젝트이며,
원작의 이미지·음원·3D 에셋을 일절 포함하지 않고 모든 그래픽과 사운드를 코드로 생성합니다.
포함된 three.js 는 MIT 라이선스입니다 (vendor/THREE-LICENSE.txt).
v1.5.14 를 내보낸 직후 제보가 들어왔다. 엘리트 전투 중에 강제 종료했더니 다시 켰을 때 그 엘리트를 지나 다음 방(보물)에 서 있었다. v1.5.8 에서 고쳤다고 한 바로 그 증상이다.
재현해 보니 범인은 물약이었다.
// js/main.js — usePotion() 끝
async function usePotion(idx, target) {
…
saveRun(run); // ← 전투 중인지 따지지 않는다
}
전투 중에 물약을 쓰거나 버리면 여기서 저장이 돈다. 저장 형식에는 전투가 담기지 않으므로 남는 것은 "그 방에 이미 들어갔다" 는 사실뿐이다. 그대로 앱을 닫으면 그 전투가 통째로 없던 일이 되고 다음 방에서 시작한다 — 보상도 없이.
v1.5.8 이 왜 이걸 놓쳤나. 그때는 저장 지점 두 곳(enterRoom·visibilitychange)만 막았다.
그런데 main.js 에는 저장하는 자리가 열아홉 군데 있었고, 새 저장 지점이 생길 때마다
"전투 중인가" 를 다시 따져야 하는 구조였다. 하나는 반드시 빠진다.
그래서 저장을 한 곳으로 모았다.
/** 저장. 전투 중에는 절대 저장하지 않는다 */
function persist(run = G.run) {
if (G.battle) return; // 전투가 끝나면 endBattle → showRewards 가 올바른 상태로 저장한다
if (run) saveRun(run);
}
main.js 의 saveRun( 열아홉 곳을 전부 persist( 로 바꿨다. 이제 전투를 막는 일은
한 줄에서 한 번만 일어난다. visibilitychange 의 !G.battle 조건도 지웠다 — 두 군데에서
같은 것을 따지면 또 갈라진다.
같은 버그가 두 번 났으므로 검사 도구를 만들었다 (tools/check-save.mjs).
OK persist() 가 있다
OK persist() 가 전투 중 저장을 막는다
OK main.js 는 saveRun 을 직접 부르지 않는다 (전부 persist 를 거친다)
OK visibilitychange 가 방 안에서는 저장하지 않는다
OK enterRoom 이 방에 들어가기 전에 저장한다
가드를 주석 처리하거나 saveRun 을 직접 부르면 바로 FAIL 한다 (둘 다 일부러 망가뜨려 확인했다).
고치기 전/후를 브라우저에서 같은 각본으로 돌린 결과:
[전] 물약 쓴 뒤 : visited 1 → 이어하기 : visited 1 ❌ 엘리트를 지나침
[후] 물약 쓴 뒤 : visited 1 → 이어하기 : visited 0 ✅ 그 방 앞으로 · 쓴 물약도 돌아옴
전투 중에 쓴 물약이 되돌아오는 것은 의도한 동작이다 — 그 전투를 처음부터 다시 치르기 때문이다.
v1.5.8 에서 전투 중 강제 종료를 고쳤는데, 나머지 방은 그대로였다. 점검해 보니 상점·모닥불·? 방·보물 방·보상 화면에서 앱을 닫으면 그 방이 통째로 사라지고 있었다.
| 어디서 닫았나 | 전 | 후 |
|---|---|---|
| 전투 중 | 그 방 앞 지도 (v1.5.8) | 그대로 |
| 보상 화면 | 골드·카드·유물 전부 날아감 | 그 화면 그대로 복원 |
| 상점 | 상점 통째로 날아감 | 재고·할인·산 물건까지 그대로 |
| 모닥불 | 휴식도 강화도 못 하고 날아감 | 그대로 복원 |
| ? 이벤트 | 선택지도 못 보고 날아감 | 같은 이벤트 그대로 |
| 보물 | 유물 날아감 | 그대로 복원 |
| 보스 유물 | 후보가 다시 굴려짐 | 같은 후보 그대로 |
보상 화면이 가장 나빴다. G.battle = null 이 showRewards() 앞에 있어서, 이기고 나서
보상을 고르는 동안에는 "전투 중이 아니다" 로 판정돼 저장이 돌았다. 전투는 이긴 것으로
끝나 있는데 보상은 하나도 못 받는 상태로 저장된 것이다.
단순히 "방 앞으로 되돌리기" 로 고치면 더 큰 구멍이 난다. 상점에 들어갔다가 마음에 드는 물건이 없으면 앱을 껐다 켜서 재고를 다시 굴리면 되기 때문이다. 그래서 방 상태를 통째로 저장하는 쪽으로 갔다.
// js/engine/run.js — 지금 들어가 있는 방
this.room = null; // {kind:'shop'|'rewards'|'event'|'eventResult'|'bossRelic'|'rest', …}
// js/main.js
function beginRoom(room) { G.run.room = room; saveRun(G.run); } // 방에 들어선 순간 확정
function endRoom() { if (G.run) G.run.room = null; } // goMap() 이 늘 불러 준다
function resumeRun() { G.run.room ? <그 방을 다시 연다> : goMap(); }
규칙은 "run.room 이 있으면 이 화면은 그대로 다시 열 수 있다" 이다. 그래서
visibilitychange 는 !G.battle && !G.run.room 일 때만 저장한다덤으로 잡은 것 — 조우 주머니가 리셋되고 있었다. toJSON 에 세 조우 큐가 빠져 있어서
fromJSON 이 복원한 난수로 다시 섞고 있었다.
// 전 — 불러올 때마다 난수가 세 번 움직인다
r.monsterQueue = r.rng.shuffle(ENCOUNTERS[r.act].weak.slice());
그 바람에 ① 난수 위치가 어긋나 v1.5.8 이 약속한 "같은 전투" 가 지켜지지 않았고 ② 주머니가 가득 다시 채워져 이미 치른 조합이 되살아났다 (실측: 약체 4개 남은 것이 6개로 복구). 큐를 저장에 담아 둘 다 고쳤다. 1.5.13 이전 저장은 큐가 없으므로 예전처럼 새로 섞는다.
받은 보상을 다시 못 받게 하는 것도 css 에서 코드로 옮겼다. .reward-row.taken 의
pointer-events: none 은 손가락만 막을 뿐이라, 받은 표시가 저장에 남게 된 이상 기대면 안 된다.
row.disabled = true 를 함께 건다.
실제로 확인한 것 (브라우저):
상점 재고 3번 껐다 켜기 → 세 번 다 같은 재고 ✅ 되감기 막힘
상점에서 카드 구매 후 종료 → 골드·덱·판매 표시 그대로 ✅
전투 중 종료 → 그 방 앞 지도, 다시 들어가니 같은 전투 ✅
보상에서 골드만 받고 종료 → 골드 유지, 남은 보상 그대로 ✅
받은 보상 줄을 억지로 눌러도 두 번 받히지 않음 ✅
v1.5.12 에서 1막에 깔아 둔 몸풀기 경사로를 2막·3막의 첫 전투에도 넣었다. 보스를 막 넘어와 체력이 가득 찬 채로 새 막에 들어서는 자리라, 그 막의 몬스터가 어떤 것들인지 한 판 보고 가라는 뜻이다.
| 1번째 전투 | 2번째 전투 | 그 뒤 | |
|---|---|---|---|
| 1막 | 체력 55% · 피해 50% | 체력 75% · 피해 70% | 정가 |
| 2막 | 체력 50% · 피해 50% | 정가 | 정가 |
| 3막 | 체력 50% · 피해 50% | 정가 | 정가 |
1막만 두 계단인 이유는 그곳이 게임을 처음 배우는 자리이기 때문이다. 2·3막에 들어설 무렵이면 조작은 이미 알고 있으니 한 판이면 충분하다.
조건문 대신 표로 바꿨다. v1.5.12 의 NEW_RUN_TUNING 은 1막 전용이라 막이 늘면
if (this.act === …) 가 따라 늘어난다. 막·전투 번호를 모두 받는 표로 접었다.
// js/engine/run.js
export const ACT_RAMP = {
1: {
1: { hp: 0.55, dmg: 0.5, label: '아주 약함' },
2: { hp: 0.75, dmg: 0.7, label: '약함' },
},
2: { 1: { hp: 0.5, dmg: 0.5, label: '약함' } },
3: { 1: { hp: 0.5, dmg: 0.5, label: '약함' } },
};
…
const tuning = (ACT_RAMP[this.act] || {})[this.monsterCount] || null;
표에 없는 전투는 정가다. 경사로를 늘리고 줄이는 일이 줄 하나 더하고 빼는 일이 되었으므로,
앞으로 보정을 손볼 때는 조건문을 새로 만들지 말고 이 표만 고칠 것 (CLAUDE.md 에도 적었다).
세는 기준인 monsterCount 는 막마다 0 에서 다시 시작하고 엘리트·보스는 세지 않는다.
그래서 엘리트나 보스는 어느 막에서도 보정을 받지 않는다.
실제 판으로 확인한 결과:
2막 1번째 [약함] 뱀 식물 체력 39(정가 75~79) · 공격 7 → 3
3막 1번째 [약함] 가시돌이 체력 26(정가 42~56) · 공격 7 → 3
반발체 체력 17(정가 29~35) · 공격 11 → 5
2막 2번째 [정가] 새 28/29, 공격 1/12/3 그대로
엘리트 보정 없음 · 보스 보정 없음 · 전투 카운터 0
CLAUDE.md 에는 처음부터 이렇게 적혀 있었다.
1막 첫 전투 체력 55%/피해 50%, 두 번째 전투 75%/70%
그런데 코드는 첫 전투 하나만, 그것도 75%/70% 로 깎고 있었다. 두 번째 전투는 보정이 아예 없어 바로 정가였다. 의도했던 두 계단짜리 경사로가 한 계단으로 뭉개져 있었던 셈이다.
| 전 | 후 | |
|---|---|---|
| 1번째 전투 | 체력 75% · 피해 70% | 체력 55% · 피해 50% |
| 2번째 전투 | 보정 없음 (정가) | 체력 75% · 피해 70% |
| 3번째부터 | 정가 | 정가 |
전투 번호 → 배율을 표로 빼서, 경사로를 늘리거나 줄이는 일이 한 줄로 끝나게 했다.
// js/engine/run.js
export const NEW_RUN_TUNING = {
1: { hp: 0.55, dmg: 0.5, label: '아주 약함' },
2: { hp: 0.75, dmg: 0.7, label: '약함' },
};
…
const tuning = this.act === 1 ? NEW_RUN_TUNING[this.monsterCount] || null : null;
tuning 은 몬스터 자료(monsters.js)를 고치는 것이 아니라 그 한 전투에만 걸리는 배율이다.
전투 엔진의 딱 두 군데에서 곱해진다.
// addEnemy — 적을 만들 때 최대 체력에
if (tuning.hp) hp = Math.max(1, Math.round(hp * tuning.hp));
// attackValue — 적이 주는 피해에만 (!src.isPlayer)
if (!src.isPlayer && tuning.dmg) d *= tuning.dmg;
!src.isPlayer 가 핵심이다 — 내가 주는 피해는 그대로라, 체력이 55% 로 깎인 적은
두 배 가까이 빨리 죽는다. 때리는 맛은 살리고 맞는 아픔만 줄이는 쪽이다.
실제 판으로 확인한 결과 (씨앗 11·22·33):
1번째 [아주 약함] 붉은 벌레 체력 6 · 작은 가시 슬라임 7/7, 공격 5 → 2
2번째 [약함] 광신도 체력 37, 공격 6 → 4 · 중간 산성 슬라임 22, 공격 7/10 → 4/7
3번째 [정가] 턱벌레 체력 42, 공격 11 그대로
2·3막, 엘리트, 보스는 보정을 받지 않는다 (엘리트·보스는 monsterCount 도 올리지 않는다).
백과사전 → 용어 에 휘발이라는 항목이 있었다. 두 가지가 잘못돼 있었다.
keywords.js 한 줄이 전부였고,
volatile 플래그도 카드 글의 "휘발" 도 0건이었다.게다가 설명("뽑은 턴이 끝날 때까지 사용하지 않으면 소각됩니다")이 소멸과 사실상 같은 말이라, 읽는 사람에게 "둘이 뭐가 다르지?" 라는 없어도 될 의문만 만들고 있었다. 지웠다 (용어 42개 → 41개).
대신 소각·소멸의 차이를 설명에 못 박았다. 둘은 같은 층위의 말이 아니다.
| 언제 | 무슨 일 | |
|---|---|---|
| 소각 | 카드를 썼을 때 | 버린 더미가 아니라 소각 더미로 간다 → 이번 전투에 다시 못 뽑음 |
| 소멸 | 카드를 안 썼을 때 | 턴 끝에 손에 남아 있으면 → 소각된다 |
소각은 결과("이 카드는 사라진다")이고 소멸은 조건("안 쓰면 소각시킨다")이라, 소멸의 처리 결과가 소각이다. 코드도 그 모양 그대로다.
// 카드를 다 쓰고 난 뒤 — 행선지를 정하는 곳
if (card.exhaust || corrupted) this.exhaustCard(card); // 소각
else this.discardPile.push(card);
// 내 턴이 끝날 때
for (const c of [...this.hand]) {
if (c.ethereal) this.exhaustFromHand(c); // 소멸 → 소각
}
그래서 소각 카드를 쓰지 않고 턴을 넘기면 여느 카드처럼 버려질 뿐이고, 덱을 다시 섞으면 이번 전투에 또 뽑을 수 있다. 반대로 소멸 카드는 안 쓰면 그 자리에서 소각된다. 다만 소멸 카드가 카드 효과로 버려진 경우에는 소각이 아니라 버린 더미로 간다 — 손에 남아 있을 때만 소각된다. 이 세 가지를 용어집 상세 설명에 적어 두었다.
3막 일반 전투에 나오는 덧없는 자(Transient)가 너무 무거웠다. 원작 수치를 그대로 옮겨 둔 탓인데, 이 게임의 3막 일반 전투로는 맞지 않았다.
| 전 | 후 | |
|---|---|---|
| 체력 | 999 | 400 |
| 첫 공격 | 30 | 18 |
| 턴마다 증가 | +10 | +6 |
| 4턴 합계 | 180 | 108 |
진짜 문제는 수치가 아니라 안 보이는 것이었다. 덧없는 자는 다섯 턴을 버티면
스스로 사라지는데, 남은 턴이 s.fade 라는 내부 값에만 있었다. 화면에 보이는 것은
999 체력과 턴마다 커지는 공격뿐이라, 이길 수 없는 싸움에 갇힌 것처럼 보였다.
// 전 — 남은 턴이 화면 어디에도 없다
init: (B, s) => { s.fade = 5; s.atk = 30; },
ai: (B, s) => { s.fade--; if (s.fade <= 0) B.fleeEnemy(s); return 'attack'; },
// 후 — 같은 값을 '덧없음' 상태로 내보낸다
init: (B, s) => { s.fade = 5; s.atk = 18; B.setPower(s, 'fading', 5); },
ai: (B, s) => {
s.fade--;
B.setPower(s, 'fading', s.fade);
if (s.fade <= 0) B.fleeEnemy(s);
return 'attack';
},
s.fade 가 여전히 유일한 기준이고 fading 은 그것을 비추기만 한다 — 두 곳에서
따로 세면 반드시 갈라진다. 전투가 시작되면 이름표 옆에 덧없음 4 가 붙고
적의 턴이 끝날 때마다 줄어든다. 0 이 되면 도망치고 전투가 끝나며, 잡지 못해도
보상은 그대로 받는다.
상태가 118종 → 119종이 되었으므로 js/ui/powerart.js 에 고유한 상태 아이콘
(아래는 남고 위로 갈수록 흩어지는 몸)을, js/data/statehelp.js 에 백과사전 설명을
함께 넣었다. node tools/check-powerart.mjs · check-keywords.mjs 통과.
조금 더 캐주얼하게. 두 가지를 바꿨다.
① 시작 최대 체력 +10 (js/data/characters.js)
| 캐릭터 | 전 | 후 |
|---|---|---|
| 아이언클래드 | 80 | 90 |
| 사일런트 | 70 | 80 |
| 디펙트 | 75 | 85 |
| 와쳐 | 72 | 82 |
1막 초반 조정(첫 전투 체력 55%·피해 50%, 두 번째 75%·70%)은 그대로 두었다. 체력만 올렸으므로 중후반 체감이 가장 크게 달라진다.
② 시작 보너스 : 5개 중 2개 (js/data/neow.js · js/main.js)
예전에는 네 가지 중 하나였다. 이제 다섯을 내주고 둘을 고른다.
// neow.js — 혜택 4 + 대가 1
export function rollNeowOptions(rng) {
const benefits = rng.shuffle(NEOW_BENEFITS.slice()).slice(0, 4);
const drawback = rng.pick(NEOW_DRAWBACKS);
return rng.shuffle([...benefits, drawback]);
}
// main.js
const NEOW_PICKS = 2;
대가가 따르는 것(골드 +250 · 저주 1장 같은 것)은 언제나 한 자리뿐이라, 둘 다 대가를 짊어지는 일은 없다. 한 번 고른 것은 목록에서 빠지고 위쪽에 남아 무엇을 집었는지 보이며, 두 번째까지 고르면 결과 두 줄을 한꺼번에 보여 준다. 카드 제거·강화·변환처럼 중간에 카드를 고르는 보너스도 둘 다 정상 동작한다 (아이언클래드·사일런트·디펙트 세 캐릭터로 끝까지 확인).
전투 도중 앱을 강제로 닫고 다시 열면 그 전투가 통째로 없던 일이 되고 다음 층 지도에서 시작했다. 싸우지도 않았는데 방은 지나간 것으로 처리되고 보상도 사라졌다.
원인 — 두 군데에서 "이미 방에 들어갔다" 는 상태를 저장하고 있었다.
function enterRoom(pos) {
const node = run.enterNode(pos); // 층·방문 표시가 여기서 바뀐다
…startBattle(…)
saveRun(run); // ← 들어간 뒤 상태를 저장
}
addEventListener('visibilitychange', () => {
if (document.hidden && G.run) saveRun(G.run); // ← 앱을 내릴 때 또 저장
});
앱을 강제로 닫으면 visibilitychange 가 먼저 뜨면서 전투 중인 상태가 저장된다.
그런데 저장 형식에는 전투가 담기지 않으므로, 남는 것은 "그 방을 지나왔다" 는 사실뿐이었다.
고친 방법 — 저장 시점을 바꿨다.
enterRoom : 방에 들어가기 전에 저장한다 (들어간 뒤 저장은 없앴다).visibilitychange : 전투 중에는 저장하지 않는다 (!G.battle).그래서 전투 중에 앱이 죽으면 디스크에는 그 방 앞 상태만 남는다. 다시 열면 지도로 돌아오고,
씨앗(rngSeed)도 그 시점 그대로라 같은 전투를 처음부터 치른다.
상점·모닥불처럼 안에서 무언가를 고르는 방은 고를 때마다 saveRun 이 불리므로 그대로다.
보상 화면은 전투가 이미 끝나 G.battle 이 비어 있어 평소처럼 저장된다.
| 전 | 후 | |
|---|---|---|
| 전투 중 강제 종료 → 이어하기 | 다음 층 지도 (전투·보상 사라짐) | 그 전투 앞 지도 (다시 싸울 수 있다) |
| 이기고 보상까지 받은 뒤 | 정상 | 정상 (골드 99 → 163 · 방문 1 확인) |
v1.5.6 으로 보정이 드디어 실제 기기에 닿았고, 그 위에서 관리자 모드 → 화면 여백 조정으로
직접 맞춰 보니 자동 62 + 손 20 = 82px 이 알맞았다. 그래서 그 값을 기본값으로 올렸다.
상단바 82~161 · 물약칸 90~117 · 메뉴 123~155 (아이폰 15 Pro Max 홈 화면 앱)
상태바가 59px 이니 23px 이 숨 쉬는 자리로 남는다.
손조정에는 판 번호가 붙는다. 손조정은 "그때의 자동값에 얼마를 더했나" 이므로, 자동값을 올리면 옛 손조정이 겹쳐 두 배가 된다 (62+20 으로 맞춰 둔 기기가 82+20=102 가 된다).
sts.safeAdjustV가 그것을 막는다 — 자동값을 바꾼 판에서는 옛 손조정을 한 번 지운다. 앞으로 자동값을 또 손보면VER을 올릴 것.
v1.5.5 를 올렸는데 타이틀 버전만 1.5.5 로 바뀌고 전투 화면은 v1.5.4 와 픽셀까지 같았다.
왜 안 먹었나 — v1.5.5 의 계산을 index.html 의 인라인 스크립트에 두었다.
iOS 홈 화면 앱은 시작 문서(index.html)를 캐시에 묶어 두는 일이 있다.
js/*.js 는 새로 받아 오므로 js/version.js 의 1.5.5 는 보이는데,
정작 여백을 심는 인라인 스크립트는 옛 문서라 아예 없었다 — 그래서 아무 일도 일어나지 않았다.
고친 방법 — 계산을 js/safearea.js 로 옮기고 js/main.js 가 맨 먼저 부른다.
index.html 에도 같은 값을 심는 사본이 남아 있지만 그건 깜빡임을 없애려는 덤이고,
본체는 모듈이다. 캐시에 묶인 문서로 접속하는 상황을 테스트로도 재현해 두었다
(index.html 에서 인라인 스크립트를 지운 채 띄워도 62px 이 적용된다).
− 10 / − 4 / + 4 / + 10 으로 위쪽 여백을 손수 맞출 수 있다.
이 기기에만 저장된다(localStorage). 자동값이 안 맞는 기기가 또 나오면 그 자리에서 맞추면 된다.상단바 62~141 · 물약칸 70~97 · 메뉴 103~135 (아이폰 15 Pro Max 홈 화면 앱)
여백 계산을 index.html 에 두지 말 것. 홈 화면 앱에서 그 파일만 옛것으로 남을 수 있다. 고칠 일이 생기면
js/safearea.js를 고친다.
v1.5.2 · v1.5.3 · v1.5.4 세 번 고쳤는데 실제 아이폰에서는 화면이 한 픽셀도 움직이지 않았다. v1.5.3 과 v1.5.4 스크린샷을 픽셀로 겹쳐 보니 위쪽 배치가 완전히 같았다.
왜 안 먹었나 — 세 번 다 env(safe-area-inset-*) 을 바탕으로 바닥값을 계산했다.
그런데 그 기기에서 env(safe-area-inset-bottom) 이 0 으로 온다.
그러면 min(0 × 3, 62px) = 0 이라 바닥값이 통째로 사라진다. 계산식이 아무리 정교해도
0 을 곱한 것과 같았다.
고친 방법 — env 를 아예 쓰지 않는다. index.html 의 인라인 스크립트가 화면 비율로 판단한다.
var sw = Math.min(screen.width, screen.height);
var sh = Math.max(screen.width, screen.height);
if (sh / sw >= 2.1) { // 노치·다이내믹 아일랜드 아이폰
d.style.setProperty('--safe-t', 'max(env(safe-area-inset-top, 0px), 62px)');
d.style.setProperty('--safe-b', 'max(env(safe-area-inset-bottom, 0px), 30px)');
}
| 기기 | 세로비 | 결과 |
|---|---|---|
| 아이폰 X ~ 16 Pro Max (노치·아일랜드) | 2.16 ~ 2.17 | 위 62 · 아래 30 |
| 아이폰 SE · 8 (홈 버튼) | 1.78 | 그대로 |
| 아이패드 | 1.33 | 그대로 |
| 사파리(막대 있음) · 안드로이드 · 컴퓨터 | — | 그대로 (조건 자체가 안 걸린다) |
두 가지를 바꾼 것이 핵심이다.
max).css/style.css 가 캐시에 묶여 있어도 반드시 적용된다.재어 보면 (아이폰 15 Pro Max 홈 화면 앱 기준)
상단바 62~141 · 물약칸 70~97 · 메뉴 103~135 · 손패 691~835 · 턴 종료 847~890
물약칸이 상태바(59) 아래로 완전히 빠지고, 아래쪽도 홈 인디케이터를 피한다.
관리자 모드 → 이 기기 정보의 표시 모드에 iOS 앱 · 노치 보정 켜짐, 기기 화면에
430×932 · 세로비 2.17 이 찍히는지로 확인할 수 있다.
전투 화면에서 물약칸이 다이내믹 아일랜드에 가려 눌리지 않았다. 스크린샷(아이폰 15 Pro Max · 1290×2796)을 픽셀로 재어 보니
| 실제 | 아이폰이 알려 준 값 | |
|---|---|---|
| 위 (상태바) | 59px | 약 30px |
| 아래 (홈 인디케이터) | 34px | 약 22px |
black-translucent 로 설치된 홈 화면 앱은 env(safe-area-inset-*) 을 실제보다 작게 준다.
v1.5.2 의 아래 × 1.7 바닥값도 22 × 1.7 = 37px 밖에 안 되어 여전히 모자랐다.
고친 방법 — iOS 홈 화면 앱에서만 바닥값을 62px 에서 끊어 잡는다.
html.ios-app {
--safe-t: max(env(safe-area-inset-top, 0px),
min(calc(env(safe-area-inset-bottom, 0px) * 3), 62px));
}
| 기기가 알려 주는 값 | 결과 |
|---|---|
| 30 / 22 (값이 작게 오는 아이폰) | min(66, 62) → 62px |
| 59 / 34 (제대로 오는 기기) | min(102, 62) → 62px |
| 20 / 0 (홈 버튼 아이폰) | 20px — 달라지는 것이 없다 |
html.ios-app 은 index.html 의 인라인 스크립트가 iOS + 홈 화면 앱일 때만 붙인다.
브라우저와 안드로이드는 이 규칙에 걸리지 않는다 (안드로이드는 env 를 제대로 주므로 건드릴 이유가 없다).
재어 보면 전투 화면이 이렇게 내려온다 — 물약칸이 상태바(59px) 아래로 완전히 빠진다.
상단바 62~141 · 물약칸 70~97 · 메뉴 103~135 · 전투 무대 141~
관리자 모드 → 이 기기 정보의 표시 모드에 iOS 앱 보정 이 붙는지로 확인할 수 있다.
v1.5.2 로 상단 여백은 고쳤지만 여전히 누르기 어려웠다. 맨 윗줄은 화면 꼭대기라 엄지가 닿기도 멀고, 상태바·다이내믹 아일랜드와 붙어 있어 조금만 위로 빗나가면 iOS 가 가져간다.
☰ 메뉴). 62×32px..relic-row { flex: 1 1 auto; overflow-x: auto } + .menu-btn { flex: 0 0 auto })..tb-row2.empty { display: none } 은 없앴다.메뉴 안 항목도 이름만 보고 알 수 있게 한 줄 설명을 달았다.
| 항목 | 설명 |
|---|---|
| 내 덱 보기 | 지금 가지고 있는 카드 전부 |
| 내 유물 보기 | 가진 유물과 그 효과 |
| 백과사전 | 카드 · 유물 · 물약 · 몬스터 · 용어 사전 |
| 게임 방법 | 규칙과 화면 보는 법 |
| 설정 | 소리 · 백과사전 · 런 포기 |
아이폰 홈 화면 앱에서 오른쪽 위 ☰ 를 눌러도 아무 반응이 없었다. 브라우저(폰·태블릿·컴퓨터)에서는 멀쩡히 열려서 한참 헤맸다.
원인 — 스크린샷을 픽셀로 재어 보니 ☰ 가 화면 위에서 37~65px 자리에 있었다. 다이내믹 아일랜드 기기의 상태바는 59px 이다. 버튼이 상태바 밑에 깔려 있었고, 그 자리의 탭은 iOS 가 가져간다 (상태바 탭 = 맨 위로 스크롤). 그래서 게임까지 오지 않았다.
apple-mobile-web-app-status-bar-style=black-translucent 로 설치된 아이콘에서
env(safe-area-inset-top) 이 실제 상태바보다 작게 오는 것이 화근이었다 (약 30px).
고친 방법 — 홈 화면 앱일 때 위쪽 여백에 바닥값을 둔다.
@media (display-mode: standalone), (display-mode: fullscreen) {
:root { --safe-t: max(env(safe-area-inset-top, 0px),
calc(env(safe-area-inset-bottom, 0px) * 1.7)); }
}
아래 홈 인디케이터가 있는 기기(노치·다이내믹 아일랜드)면 34 × 1.7 ≈ 58px 이 되어
상태바를 넘어선다. 인디케이터가 없는 옛 기기는 아래가 0 이라 이 식도 0 — 아무 영향이 없다.
브라우저에서는 이 미디어 쿼리 자체가 걸리지 않는다.
black-translucent를black으로 바꾸는 길도 있지만 그러지 않았다. 그 값은 홈 화면에 추가할 때 읽혀서 이미 설치한 아이콘은 그대로이고, 새로 설치한 쪽은 iOS 가 비워 주는 자리와 CSS 여백이 겹쳐 위가 두 배로 빈다.
표시 모드 · 안전 여백(env → 적용) · 메뉴 버튼 위치를 적어 둔다.
같은 일이 또 생기면 그 화면 숫자만 보면 된다 (js/viewport.js 의 safeInsets() 가 자로 잰다).1. 백과사전에 긴 설명을 넣었다
백과사전은 "따로 들어가서 자세히 보는 화면" 이므로 자리를 더 쓰더라도 길게 적는다.
| 파일 | 무엇 |
|---|---|
js/data/statehelp.js | 상태 118종 전부의 상세 설명 |
js/data/cardhelp.js | 글만으로는 규칙이 안 보이는 카드 45장의 보충 설명 |
상세 글에는 수치를 다시 적지 않는다. 바로 위에
powers.js의 한 줄이, 카드에는 카드 글이 이미 있다. 여기에 또 적으면 수치를 고칠 때 반드시 갈라진다 (몬스터 도감과 같은 원칙). 상세 글에는 언제 붙고 언제 사라지는지 · 정확히 무엇에 적용되는지 · 흔한 오해만 적는다.
용어집에서 이름이 상태와 같은 낱말(조준·중독·완충 …)은 statehelp.js 것을 자동으로 끌어다 쓴다 —
사전에 또 적지 않는다. 이름이 겹치는 것(자세 분노 vs 상태 분노)만 사전에 직접 적어 둔다.
백과사전에서 카드를 누르면 효과 → 자세히 → 용어(+상세) 순으로 펼쳐진다.
전투 중 길게 누르기는 짧게 그대로 둔다 — 거기서는 빨리 보는 것이 목적이다.
2. 구체 동작 사이에 시간차
이중 시전이 번개를 두 번 치는데 거의 동시에 터져 한 번 친 것처럼 보였다.
엔진의 구체 동작을 async 로 바꾸고 동작마다 pace() 로 사이를 띄웠다 (ORB_GAP).
충전 3개 : 0ms → 400ms → 800ms
이중 시전 : 0ms → 500ms (번개가 두 번 치는 게 보인다)
턴 종료 패시브 : 왼쪽 구체부터 하나씩
channelOne() 을 쓴다 — 사이를 띄우지 않는 같은 동작.startActorTurn · endActorTurn · afterCardPlayed 가 async 가 되었고, 디펙트 카드의 play 도 await 한다.index * 0.22초)는 뺐다. 이제 엔진이 사이를 띄우므로 겹으로 밀리면 안 된다.node tools/sim-cards.mjs 로 캐릭터 4종 × 전 카드 × 강화 전/후 2904회를 돌려 회귀를 확인했다.3. 설정에서 백과사전
게임 중 메뉴 ☰ → 설정 안에서도 백과사전으로 바로 들어갈 수 있다.
전투 중 이름표 옆에 붙는 작은 그림("상태 아이콘")을 118종 모두 새로 그렸다.
고치기 전 실태
| 그림이 아예 없던 상태 | 50종 — POWER_GLYPH 에 매핑이 없어 전부 같은 별표로 떨어졌다 (조준 · 완충 · 집중 · 주문 · 표식 …) |
| 그림이 있던 상태 | 68종. 그나마 25가지 문양을 돌려 썼다 — 금속화 · 고통 감지 불가 · 완충 · 허약 · 바리케이드가 전부 같은 방패, 휘감김 · 압박 · 속박이 전부 같은 사슬 |
새 구조 — js/ui/powerart.js
유물·물약(v1.4.0)과 같은 길을 갔다. 한 아이콘은 [path, 색, 불투명도?] 의 여러 겹이고,
'@a' 자리에 상태 색(버프 노랑 / 디버프 보라)이 들어간다. 나머지 색은 뜻을 나른다 —
불 주황 · 피 빨강 · 독 초록 · 번개 노랑 · 얼음 하늘 · 신성 미색.
path 를 손으로 쓰지 않고 도형 헬퍼로 짠다 (circle · ring · bar · star · arcBand ·
drop · flame · skull · shield · gear · lotus · ekg …). 그래서 118종이 같은 자를 쓰고,
같은 함수를 같은 값으로 부르면 지문이 같아져 검사가 중복을 잡아낼 수 있다.
조준 = 고리 + 십자 눈금 + 가운데 점 (과녁)
중독 = 초록 물방울 + 해골 얼굴
창조적 AI = 전구
각성 전 = Z Z Z
오메가 = Ω
node tools/check-powerart.mjs — 빠진 것 · 남는 것 · 겹치는 모양 · 성하지 않은 path 를 본다.
색만 다르고 모양이 같으면 중복으로 친다. 겹은 4장까지(12px 에서 그 이상은 뭉갠다).POWER_GLYPH 는 없앴다. 이제 powerIcon() 은 POWER_ART 만 본다."문양" 이 아니라 "상태 아이콘" 이라고 부른다. 카드 그림은 문양, 유물·물약은 아이콘, 몸에 붙는 것은 상태 아이콘 — 플레이어가 화면에서 보는 말로 통일했다.
카드에는 적에게 조준을 3 부여합니다 라고만 적혀 있고, 조준이 무엇인지는 게임 어디에도 없었다. 전투 중에 이미 걸린 뒤 유닛을 눌러야만 설명이 나왔다.
js/data/keywords.js)에 조준 · 완충 · 표식 을 더했다 → 카드를 길게 누르면 뜨는 용어 칸에 바로 나온다.용어 / 상태 두 갈래로 나눴다. 상태 쪽은 전투 중 몸에 붙는 118종 전부를 약화 21 · 강화 97 로 나눠 보여 준다.js/engine/powers.js 의 것을 그대로 받아 적는다. 따로 적어 두면 수치를 고칠 때 반드시 둘이 갈라진다 (몬스터 도감과 같은 원칙). 수치는 1 을 넣어 보여 주고, 머리글에 그렇게 적어 두었다.node tools/check-keywords.mjs — 카드 글에 나오는 상태 이름은 모두 사전에 있어야 한다. 이 검사를 먼저 만들었더니 조준 말고도 완충·표식이 같이 걸렸다. 이름만 같고 그 상태가 아닌 것(카드 종류를 뜻하는 저주, 카드 이름인 오메가, 자세인 분노)은 예외로 적어 둔다.상태 목록에는 문양을 붙이지 않았다. 118종 가운데 50종에 아직 고유 문양이 없어 별표만 줄줄이 늘어선다 (전투 중 상태 칩도 같은 상태다 — 따로 할 일).
이 게임은 스마트폰 세로 화면을 겨냥해 만들었다. 태블릿이나 컴퓨터에서 열면 가로가 넓어져 레이아웃이 늘어나 버리므로, 폰에 가까운 비율의 상자 안에만 그리고 양옆을 비운다.
--app-w: min(100vw, calc(100vh * 0.62), 560px);
--app-h: min(100vh, calc(var(--app-w) / 0.40));
| 허용 비율 | 뜻 |
|---|---|
0.62 보다 넓으면 | 양옆을 비운다 (16:9 폰 = 0.5625 → 꽉 참) |
0.40 보다 길쭉하면 | 위아래를 비운다 (21:9 폰 = 0.429 → 꽉 참) |
560px 상한 | 큰 화면에서 폰보다 지나치게 커지지 않게 |
폰에서는 아무것도 달라지지 않는다. 어떤 폰이든 100vw 가 가장 작아서 상자가 화면을 꽉 채운다. 여백은 아이패드 세로(0.75)부터 생긴다.
크기 계산을 js 로 하지 않는다. js 로 재서 CSS 변수에 심으면 첫 프레임에 전체 너비로 한 번 깜빡인다.
min()이면 CSS 만으로 같은 값이 나온다.js/viewport.js는 창 좌표가 필요한 몇 곳(툴팁 가두기·관리자 정보)에 쓸 자만 내준다.
vw/vh 를 전부 갈아 끼웠다 (10곳). 상자가 좁아져도 vw 는 바깥 창 기준이라 따라 줄지 않는다. 데스크톱에서 calc(3.25vw - 0.9px) 는 62px 이 되어 백과사전 카드가 터진다. 상자의 1%를 가리키는 --vw / --vh 를 만들어 바꿨다.
--vw: calc(var(--app-w) / 100);
--desc-fs: calc(var(--vw) * 3.25 - 0.9px); /* 예전 : calc(3.25vw - 0.9px) */
커스텀 속성은
calc()토큰 그대로 남으므로getPropertyValue('--vw')로는 px 를 못 읽는다(NaN). 실제로 적용됐는지는 그걸 쓰는 요소의 계산된 크기로 확인해야 한다 — 회귀 테스트도 그렇게 짰다.
js 쪽 세 곳도 창이 아니라 상자를 보게 고쳤다 — 툴팁 가두기(showTooltip), 손패 폭 기본값, 관리자 정보의 화면 줄(창 크기와 화면 틀을 따로 보여 준다).
여백 표시는 @media (min-aspect-ratio: 62/100), (min-width: 561px), (max-aspect-ratio: 40/100) 로 낸다 — 위 min() 이 무엇에 걸리느냐와 조건이 정확히 같다. 폰에서는 테두리 1px 도 그리지 않는다.
지금까지는 형태 25가지를 돌려 쓰고 색만 달랐다. ring 하나에 유물 14개(닻·염주 팔찌·흑요석 목걸이…), heart 하나에 7개(딸기·베개·생강…)가 매달려 있었고, 8개는 매핑이 없어 기본값 gem 으로 떨어져 있었다. 익숙해지면 이름보다 모양을 먼저 보는데, 겹치면 문양을 두는 의미가 없다 — 카드가 v1.3.9 에서 이미 지킨 기준을 유물·물약만 못 따라가고 있었다.
147개를 하나씩 새로 만들었다. 굽는 비용은 그대로다(아이템 개수가 정하지 형태의 다양성이 정하지 않는다) — 실측 147개 679ms · 0.70MB.
부품 목록을 데이터로 적는다 (js/three/parts3d.js)
anchor: () => [
P.torus(0.19, 0.06, C.iron, { y: 0.74, ...MET }), // 고리
P.cyl(0.08, 0.08, 1.3, C.iron, { y: -0.06, ...MET }), // 기둥
P.box(0.96, 0.13, 0.13, C.iron, { y: 0.4, ...MET }), // 가로대
...[-1, 1].map((s) => P.cone(0.18, 0.4, C.iron, { x: s * 0.52, y: -0.5, rz: s * 34 * R, ...MET })),
],
js/ui/cardart.js)이 [path, 색] 배열인 것과 같은 결이다
parts3d.js는 three.js 를 부르지 않는다. 순수 데이터라야tools/check-icons.mjs가 브라우저 없이 Node 에서 돌아간다. 실제 메시로 바꾸는 일(buildParts)은items3d.js가 맡는다.
색도 뜻에 맞췄다. 예전에는 id 해시로 색상환에 흩뿌렸다. 이제는 얼어붙은 알 = 얼음색 · 녹아내린 알 = 용암색 · 유독성 알 = 독색 처럼 이름이 색으로도 읽힌다. 색은 C 사전에 이름으로 둔다(C.blood · C.frost · C.toxic …, 56종).
물약 30개는 형태만으로는 금방 섞여서 세 가지를 겹쳐 갈랐다 — 병 실루엣(둥근 플라스크·각진 병·호리병·시험관·주머니) × 안에 든 것(불꽃·번개·톱니·가시·날개) × 마개(코르크·금속·왕관).
tools/check-icons.mjs를 반드시 돌릴 것. 빠진 것·겹치는 것·부품 값이 이상한 것을 잡는다. 유물과 물약 사이의 겹침까지 본다. 부품이 3개 미만이면 실루엣이 밋밋해서 그것도 걸린다.
두 번 다시 본 것 — 처음 구워 놓고 보니 20개가 이름과 연결되지 않았다(닻은 흐릿하고, 부적은 T자, 펜촉은 느낌표, 금도금 케이블은 사다리로 보였다). 두 차례 더 손봤다.
P.disc(r, 두께, 색, 옵션) 에서 두께를 빠뜨려 색 문자열이 치수 자리로 들어갔다. 검사 도구가 터지길래 잡았고, 값이 이상하면 터지지 않고 알려 주도록 함께 고쳤다(-sinθ, cosθ) 로 보낸다. 텅스텐 막대의 끝 캡을 (+sinθ, cosθ) 에 두어 막대에서 떨어져 나가 있었다홈 화면 아이콘과 파비콘을 새 일러스트로 바꿨다. 래스터라서 SVG 하나로는 안 되고 쓰임새별로 나눠 넣었다 (icon-192.png · icon-512.png · apple-touch-icon.png 180x180 불투명 풀블리드 · icon-maskable-512.png).
안드로이드 마스크용을 따로 둔 이유 — 새 그림은 모서리가 투명한 데다 제목 글자가 가장자리까지 차서, 그대로 두면 마스크에 잘린다. 그림을 안전 영역(80%) 안으로 줄이고 배경을
#0b0910으로 채운 판을 따로 만들었다.이 작업은 다른 세션에서
main에 먼저 올라갔고js/version.js는 올리지 않았다. v1.4.0 에서 번호와 이력을 맞춰 두었다.
v1.3.15 는 1장 → 2장 → 3장을 display:none 으로 갈아 끼웠다. 장면이 툭툭 바뀌어 읽을 새가 없었다. 세 장을 한 두루마리로 이어 두고 화면이 그 위를 천천히 내려가는 방식으로 바꿨다.
clamp(스크롤 거리 / 14초, 80, 300) 이라 덱이 길든 짧든 걸리는 시간이 비슷하다scrollTop + 화면높이 × 0.72 선을 넘으면). 스크롤과 점등이 따로 놀지 않는다.ed-now 를 position: sticky 로 두어 오른 길을 지나는 동안 계속 따라온다--dim, 1장이 화면을 빠져나가는 동안에 걸쳐서)wheel·touchstart·pointerdown·keydown) 자동 스크롤을 놓아 준다. 칸 켜기와 장막은 계속 따라가므로 위로 올려 다시 봐도, 손으로 내려도 화면이 어긋나지 않는다prefers-reduced-motion 이면 자동으로 내려가지 않고 처음부터 손에 맡긴다프레임마다 조금씩 더하면 안 된다. 처음에
scrollTop += 속도 × dt로 짰더니 소프트웨어 렌더러에서 93px/s 로 맞춘 것이 48px/s 로 나왔다 —dt를 50ms 로 자르고 있는데 실제 프레임 간격이 그보다 길었던 것이다. 느린 기기에서 그만큼 느려진다. 시작 시각을 기준으로 "지금 어디쯤이어야 하는지" 를 계산하도록 고쳤다 (보스에서 쉰 시간은 따로 더해 뺀다).
장마다 padding-bottom 으로 숨 쉴 자리를 준다 (1장 18vh · 2장 12vh). 붙여 두면 천천히 내려가도 1장을 읽는 동안 2장이 이미 화면에 들어와 있어 장면이 겹쳐 보인다.
v1.3.16 에서 관리자 모드에 "자원 2배로 시작" 이 생겨 같은 일을 더 분명하게 할 수 있게 됐다. 시작 보너스 화면 네 번째 선택지 아래 있던 아무 표시 없는 다섯 번째 자리(.choice-secret, 높이 54px)를 걷어냈다.
NEOW_SECRET 자체는 그대로 둔다. 관리자 모드가 쓴다 (adminStartRun).choice-secret CSS 도 함께 지웠다. run.cheat 규칙(전적에 남기지 않음)은 변함없다설정 화면 맨 아래 작은 "관리자 모드" 링크 → 숫자 4자리를 넣으면 열린다. 한 번 풀면 그 실행 동안 다시 묻지 않는다.
| 기능 | 하는 일 |
|---|---|
| 자원 2배로 시작 | 숨겨진 시작 보너스(NEOW_SECRET)를 그대로 얹는다 |
| 1층부터 시작 | 캐릭터만 고르고 바로 |
| 층을 골라 시작 | 1~50 중 하나. 1막 보스·3막·최종 보스 같은 빠른 선택 버튼 6개 |
| 엔딩 화면 보기 | 캐릭터를 고르면 엔딩이 바로 열린다 (엔딩은 캐릭터별 1종, 모두 4종) |
| 이 기기 정보 | 버전 · 계정 · uid · 전적 수 · 이어하기 유무 · 저장소 사용량 · 자료 개수 · 화면 크기 |
여기서 시작한 판은 전부 run.cheat 를 켠다. 테스트로 만든 판이 전적에 섞이면 통계가 망가진다 (v1.3.1 에서 정한 규칙). 화면 맨 위에도 그렇게 적어 둔다.
엔딩 보기는 실제로 오른 판이 아니라서 journal 이 없다 → 그럴듯한 여정을 만들어 채운다. 막마다 전투가 많고 모닥불·상점·보물이 섞이며 17·34·50층은 보스(그 막의 보스 목록에서 무작위). 덱도 그 캐릭터 카드 12장을 더하고 유물 5개를 얹는다 — 시작 덱만 있으면 기록 장이 허전하다.
이 숫자는 자물쇠가 아니라 문고리다. 브라우저에서 돌아가는 코드라 소스를 열면 그대로 보인다. 남이 봐서 곤란한 것(다른 사람의 전적 따위)은 절대 여기에 두지 말 것 — 그건
firestore.rules로 막아야 한다.
pinBox() 는 자릿수를 받도록 고쳤다 (pinBox(4)). CSS 는 --pin-n 변수로 칸 수를 받는다.
지금까지 최종 보스를 잡으면 모달 하나에 통계 다섯 줄이 전부였다. 모달이라는 게 문제의 절반이다 — 모달은 "닫아야 할 것" 으로 읽혀서 오래 볼 마음이 생기지 않는다. 전용 화면(scr-ending)으로 옮기고 세 장(章)이 차례로 쌓이게 했다. 12_victory.mp3 가 약 3분이라 연출 시간은 넉넉하다.
1장 · 정복의 순간 — js/three/ending3d.js 가 빛기둥·먼지·낮은 단을 세우고 그 위에 내 캐릭터를 올려 천천히 돌린다. 캐릭터별 엔딩 문구(js/data/ending.js)가 1.1초 간격으로 한 줄씩 나타난다.
원작(Mega Crit)의 엔딩 텍스트는 가져오지 않았다. 네 캐릭터 문구 모두 이 프로젝트에서 새로 썼다.
2장 · 오른 길 — 1층부터 50층까지 한 칸씩 켜진다. 보스 세 곳(17·34·50층)에서는 1초 멈추고 그 보스 이름을 띄운다. 지켜보게 만드는 장치가 이것이다.
이걸 하려면 층별 기록이 필요한데 없었다.
run.stats는 합계만 세고run.map은 지금 막의 지도만 가진다(막마다 새로 만들어서 1·2막 지도는 이미 사라진다).Run.journal을 새로 만들어enterNode()에서 한 줄씩 쌓고toJSON/fromJSON에 넣었다. 보스 칸에는 어느 보스였는지도 함께 적는다. 1.3.14 이전 저장에는 journal 이 없다 → 없으면 2장을 건너뛰고 1·3장만 보여 준다.
3장 · 기록 — 걸린 시간 · 남은 체력 · 처치 · 엘리트 · 보스 · 골드 타일과, 모은 유물 전부, 최종 덱 전부(카드를 누르면 상세 설명).
건너뛰기는 언제나 있고(두 번째 클리어부터는 지루하면 안 된다) 마지막 장에서는 사라진다. 화면을 탭하면 지금 장을 끝내고 다음으로 넘어간다.
만들며 밟은 함정 둘
#scr-ending { position: relative } 로 덮었더니 화면 높이가 0 이 됐다. .screen 이 이미 position: absolute; inset: 0 이라 relative 로 바꾸면 inset 이 풀린다. 안의 절대 배치 요소가 통째로 사라지고 건너뛰기 버튼이 눌리지 않았다scrollIntoView() 를 썼더니 바깥쪽 스크롤 상자까지 전부 굴려 화면이 통째로 밀렸다. 엔딩 무대(#ending-stage)만 굴리도록 getBoundingClientRect 차이로 직접 계산한다.ed-veil::after 로 3D 무대를 뒤로 물린다. 장막은 화면에 고정이고 글은 스크롤되므로, 안 물리면 글이 인물 위에 그대로 얹힌다행동 설명을 손으로 쓰지 않았다. 몬스터의 run 을 기록용 가짜 전투판에 한 번 돌려 보고, 무슨 일이 일어나는지 받아 적어 문장으로 만든다 (js/data/monsterdex.js).
eff 같은 사본을 두면 언젠가 반드시 둘이 갈라진다run 은 B(전투)와 s(자기 자신)만 만지고 바깥 상태를 건드리지 않아 이렇게 돌려 봐도 안전하다. 가짜 판은 enemyAttack · addPower · addCardToDiscard 등 19개 메서드를 받아 적는다5~7 피해 처럼 폭으로 보여 준다 (벌레의 물기)B.player.hp 를 NaN 으로 두어 체력에 비례하는 피해(육각유령의 분할)를 가려낸다 → "내 체력에 비례한 피해 ×6"B.living().find((e) => e.mid === 'mystic') 처럼 동료를 찾는 코드는, 찾는 이름을 소스에서 읽어 맞춰 준다s.x += n · s.x++ · s.x = (s.x || 30) + 5 자국으로 가려내 배지를 붙인다 (수호자의 쌍둥이 강타, 찌르기의 책, 작업반장, 덧없는 자, 거대한 머리)isSingleton() 으로 판단해 숫자를 빼고 적는다
tools/check-monsterdex.mjs를 반드시 돌릴 것. 가짜 판이 모르는 메서드를 부르는 행동이 새로 들어오면 여기서 걸린다. 행동 168개가 전부 설명으로 바뀌는지, 빈 설명이 생기지 않았는지, 어느 분류에도 안 들어간 몬스터가 없는지 확인한다.
화면 — 막별(1막 25종 / 2막 18종 / 3막 16종) + 소환·분열 3종으로 나눈다. 지도에서 직접 만나지 않는 청동 구체 · 횃불 머리 · 단검이 소환·분열 쪽이다. 등장 위치("1막 약체·강체")는 ENCOUNTERS 에서 자동으로 뽑는다. 엘리트·보스는 테두리 색으로 구분한다.
초상 — monsterPortrait3D() 가 buildEnemy(shape) 를 v1.3.13 의 bakeSized 로 굽는다.
bakeSized(group, w, null) 은 모델 생김새에 맞춰 틀의 세로를 정한다. 납작한 슬라임을 세로로 긴 틀에 담으면 위아래가 빈 채로 굽혀 카드에 헛공간이 생긴다IntersectionObserver 로 보이는 것만 굽는다 (탭 여는 데 1초, 처음에 8종만)캐릭터 초상이 작았던 진짜 이유는 크기가 아니라 비율이었다. bake() 는 정사각형(96×96) 에 바운딩 구(sphere) 를 맞춰 담는다. 캐릭터는 세로로 긴 모양이라 정사각형에 담으면 좌우가 통째로 비고, 그만큼 캐릭터가 작게 그려진다. 그래서 세로로 긴 틀에 다시 굽는 bakeSized(group, w, h) 를 만들었다.
characterPortrait3D() 는 264×396 (2:3) 으로 굽는다. 목록용 characterIcon3D() (96² ) 는 그대로 두었다 — 크게 보여 주는 자리만 해상도를 올린다. 4장뿐이고 한 번 구우면 캐시된다.cx-portrait 를 align-items: stretch 로 카드 높이만큼 늘이고 폭 78 → 110px숨쉬기 연출 — @keyframes cx-breathe 로 4.4초에 걸쳐 scale(1) ↔ scale(1.09). 캐릭터마다 animation-delay 를 0.9초씩 밀어 네 장이 한꺼번에 뛰지 않게 했다. prefers-reduced-motion 에서는 멈춘다.
.cx-portrait의 안쪽 여백(padding: 5px 6px)은 가장 커졌을 때 틀을 넘지 않게 잡은 값이다.object-fit: contain은<img>박스 크기는 그대로 두고 그 안에 여백을 두어 그린다 — 그래서getBoundingClientRect()로는 잘림을 판정할 수 없고, 원본 비율로 그려진 그림을 계산해야 한다.
설정 아이콘 — 기존 gear 글리프는 톱니가 아니라 꼭짓점 10개짜리 별 모양 덩어리였다. 톱니 8개 + 가운데 구멍을 가진 cog 글리프를 새로 만들어 타이틀 우상단 버튼에 물렸다 (22 → 24px).
sweep 0) 으로 그려야 한다. svgIcon() 이 fill-rule 을 지정하지 않아 기본값 nonzero 로 칠해지기 때문이다gear 는 남겨 두었다 — 지금은 오토마톤의 modeShift 의도 아이콘 하나만 쓴다 (유물의 톱니는 SVG 가 아니라 items3d.js 의 3D gear(c) 형태라 무관)지금까지는 이메일 앞부분([email protected] → mmk75)을 그대로 이름으로 썼다. 주소를 그대로 보여 주면 게임 화면에 어울리지 않고, 이메일 전체를 쓰자니 타이틀 칩(.pc-name, 최대 150px)을 넘긴다. 가입할 때 직접 정하게 했다.
authForm)에 이름칸 한 줄 추가 — 이메일 → 이름 → PIN 순서, maxlength 12, 비워 두면 예전처럼 이메일 앞부분promptRename) — 언제든 고칠 수 있다displayName (accounts:update, REST). 기기 안이 아니라 계정에 붙어 있어 기기를 바꿔 다시 로그인해도 같은 이름이 나온다
signUp → setDisplayName 두 번 호출이다. 이름 붙이기가 실패해도 가입 자체는 성립시킨다 (이메일 앞부분으로 떨어질 뿐)accounts:update 를 아예 부르지 않는다cleanName() 으로 앞뒤 공백·연속 공백을 정리하고 12자로 자른다. Firestore 문서에도 name 필드를 같이 올린다displayName 이 비어 있으므로 그대로 이메일 앞부분으로 떨어진다 — 마이그레이션 없음버그 수정 — PIN 을 잊었어요 버튼이 메일을 보낸 뒤 다시 눌리지 않았다. e.currentTarget 은 이벤트 처리가 끝나면 null 이 되는데, await 뒤에서 다시 읽고 있었다(disabled = false 가 예외로 죽어 버튼이 잠긴 채 남았다). 버튼을 먼저 지역 변수에 붙잡아 두도록 고쳤다.
버그 — .name-input CSS 규칙이 v1.3.5 에서 계정 화면 CSS 를 통째로 갈아 끼울 때 함께 사라져 있었다. 이메일 입력칸이 브라우저 기본 모양(높이 21px · 글자 13.3px)으로 나오고 있었다. 규칙을 되살리며 크기도 키웠다 (52.8px · 16px).
font-size를 16px 아래로 내리면 iOS 사파리가 입력칸을 누를 때 화면을 확대한다. 16px 은 그걸 막는 값이라 더 줄이지 말 것.
PIN 입력 (pinBox()) — 폰 잠금화면처럼 네모 6칸.
<input> 을 두지 않았다. 그러면 지우기·붙여넣기·자동완성이 전부 깨진다. 대신 투명한 <input> 하나가 위를 덮어 입력을 받고, 네모는 값에 따라 그려 주기만 한다type="tel" + inputmode="numeric" — 폰에서 숫자판이 확실히 뜬다. 가림(●)은 네모 쪽에서 하므로 type="password" 가 필요 없다측정 : 이메일칸 21px → 52.8px, PIN 네모 54.5×54.5 정사각 6칸. 지우기·6자리 초과 입력 차단·숫자만 입력 모두 확인.
구글 계정이 없는 사람이 많다는 점, 그리고 실기기 테스트에서 로그인이 실패한 점 때문에 재검토.
실패 원인은 구글이 아니었다 — cnation-project.firebaseapp.com 이 낸 "The requested action is invalid." 와 REST 로 확인한 CONFIGURATION_NOT_FOUND 는 콘솔에서 Authentication 을 한 번도 켜지 않아서였다. 어떤 방식이든 똑같이 실패했을 상태.
다만 그 화면이 떴다는 건 iOS 사파리 팝업이 정상적으로 열려 firebaseapp.com 까지 갔다는 뜻이다. v1.3.5 에서 "실기기 확인이 필요하다" 고 남겼던 유일한 미검증 구간은 실제로 통과했다.
그래도 바꾼 이유 — 구글 계정 없는 사용자, 그리고 아래의 실질적 이득.
이메일 + 6자리 PIN (accounts:signUp / signInWithPassword / sendOobCode)
vendor/firebase-auth.js, tools/build-firebase.sh, 라이선스 고지). 팝업이 필요 없어 REST 로 끝난다 — 로그인한 사람도 추가 다운로드 0PIN 이 6자리인 이유 — Firebase 의 비밀번호 최소 길이가 6자다. 4자리를 쓰려면 뒤에 무언가 덧붙여야 하는데, 그러면 Firebase 재설정 페이지에서 사용자가 정한 새 비밀번호와 규칙이 어긋나 PIN 찾기가 깨진다. 6자리면 그대로 쓸 수 있고, 경우의 수도 1만 → 100만으로 늘어난다.
입력 — PIN 칸은 inputmode="numeric" + 숫자만 필터링(글자를 쳐도 숫자만 남는다) + 자간을 벌려 자릿수를 세기 쉽게 했다.
검증 (신원을 모델링한 Auth REST 스텁, 13항목 전부 통과) 가입 · 4자리 거절 · 숫자만 입력 · 전적 업로드 · 저장소를 통째로 비운 새 기기에서 같은 이메일+PIN 으로 전적 복구 · 틀린 PIN · 재설정 메일 · 중복 가입 거절 · 통신 불가 시 게스트 진행 · SDK 요청 0회
콘솔 준비물 — Authentication → Sign-in method → 이메일/비밀번호 사용 설정 (구글은 켤 필요 없음) + Firestore 규칙(firestore.rules)
게임에 익숙해지면 글자보다 문양을 먼저 보게 되는데, 문양이 겹쳐 있으면 문양을 보고 이름을 또 확인해야 해서 문양을 두는 의미가 없었다.
고치기 전 실태 (tools/check-art.mjs 로 측정)
| 계열 | 장수 | 서로 다른 문양 | 겹치는 카드 |
|---|---|---|---|
| 아이언클래드 | 75 | 24종 | 68장 |
| 사일런트 | 77 | 24종 | 68장 |
| 디펙트 | 76 | 18종 | 73장 (lightning 한 문양에 13장) |
| 와쳐 | 83 | 20종 | 77장 (shield 한 문양에 11장) |
| 무색 | 33 | 18종 | 24장 |
| 저주 | 14 | — | 문양 없음 |
한 일
js/ui/cardart.js 신설 — 색을 쓰는 여러 겹 문양 224종. 한 겹은 [path, 색, 불투명도?]'@a' 를 쓰면 그 카드 종류(공격·스킬·파워)의 색이 들어가, 종류별 배경색과 따로 놀지 않는다fire), 얼음은 하늘색(ice), 독은 초록(poison), 저주는 보랏빛 그림자(shadow)cardArtSVG)가 여러 겹·색을 그리도록 확장. 유물·상태이상용 단색 GLYPH 는 그대로 쓴다tools/check-art.mjs — 중복·누락·없는 문양 참조를 검사한다. 앞으로 카드를 추가하면 이걸로 확인할 것문양이 설 자리
27% → 42%, 비율 74:156 — 보이는 그림 칸이 21px → 59px 로 2.5배preserveAspectRatio 를 xMidYMin 으로 바꿔 윗부분이 항상 보이게 했다42px → 50px (액자 69px 은 그대로 — 여백이 충분했다)v1.3.7 에서 세로만 길어져 비율이 어색해졌다는 지적. 폭을 넓히고 글자를 더 키웠다.
10px → 5px, #codex-body 좌우 여백 14px → 7px — 줄인 만큼 카드 폭으로74:124 → 74:132, 그림 영역 30% → 27%clamp(8.4px, 2.5vw, 11.5px) → calc(3.25vw - 0.9px) (카드 폭의 약 13%), 이름 calc(3.7vw - 1px)% 로 바꿨다 — px 로 두면 좁은 화면에서 글상자를 상대적으로 더 많이 먹어, 같은 비율인데도 좁은 폰에서만 설명이 잘렸다 (v1.3.7 의 360px 에서 8장 잘림의 원인)46px → 69px, 그림 28px → 42px (백과사전에서만. 보상·상점·물약 상세는 그대로)측정 (아이언클래드 75장)
| 화면 | v1.3.7 | v1.3.8 | 설명 잘린 카드 |
|---|---|---|---|
| 360px | 76×127 · 9.0px | 83×148 · 10.8px | 8 → 0 |
| 390px | 83×139 · 9.8px | 90×161 · 11.8px | 0 → 0 |
| 430px | 93×156 · 10.8px | 100×179 · 13.1px | 0 → 0 |
비율은 세 화면 모두 1:1.78~1.79 로 일정하다. 안쪽 여백을 % 로 바꾼 덕에 화면 폭이 달라도 같은 모양이 나온다.
백과사전은 카드를 읽는 화면이므로 한 줄에 4장만 두고 키웠다.
.card-grid.cx-cards — 백과사전 전용 클래스. grid-template-columns: repeat(4, 1fr) 로 화면 폭과 무관하게 항상 4열74:106 대신 74:124 로 세로를 늘려 설명 칸을 넓혔다 (어차피 스크롤하는 화면)40% → 30%, 설명 글자 7.6px → clamp(8.4px, 2.5vw, 11.5px), 이름 10px → clamp(11px, 3.15vw, 14px)--nb-lift 는 margin 의 % 가 너비 기준이므로 높이 13% 를 너비 21.8% 로 환산해 반응형으로 바꿨다측정 (아이언클래드 75장 기준)
| 화면 | 이전 | 이후 | 설명 잘린 카드 |
|---|---|---|---|
| 360px | 4열 · 74×106 · 7.6px | 4열 · 76×127 · 9.0px | 15 → 8 |
| 390px | 4열 · 74×106 · 7.6px | 4열 · 83×139 · 9.75px | 15 → 0 |
| 430px | 5열 · 74×106 · 7.6px | 4열 · 93×156 · 10.75px | 15 → 0 |
글자를 키우면 잘림이 늘 것 같지만, 카드를 세로로 늘리고 그림을 줄여 글상자를 키웠기 때문에 모든 폭에서 오히려 줄었다.
.card-grid 는 덱 보기·보상·더미 보기에서도 쓰이므로 건드리지 않았다 — 그쪽 카드는 74×106 · 7.6px 그대로.
v1.3.5 는 첫 실행에 게스트/로그인 선택 모달이 타이틀을 가로막았다. 계정은 타이틀에서 고르는 것으로 바꿨다.
askMode() 자동 호출 제거 — Account.init() 은 화면을 막지 않고 표시만 갱신한다none 인 채로 새 게임 / 이어하기를 눌러도 바로 시작한다 (조용히 게스트로 확정)#btn-profile)을 누르면 언제나 계정 화면 — 거기에 게스트 카드와 구글로 로그인 버튼이 있다#login-hint 한 줄이 보인다 (눌러도 계정 화면으로)askMode() 모달 제거v1.3.4 에서 "로그인 없이 원격 저장은 성립하지 않는다" 고 결론 낸 것에 대한 답. 신원을 기기 밖(구글 계정)에 둔다.
왜 이건 되는가 — v1.3.2 익명 인증은 토큰이 localStorage 에 있어 저장소가 비워지면 신원까지 날아갔다. 구글 계정은 기기 밖에 있으므로, 저장소가 비워져도 다시 로그인하면 같은 uid 가 나오고 전적이 그대로 돌아온다.
| 게스트 | 구글 로그인 | |
|---|---|---|
| 이어하기 (진행 중인 런) | O (로컬) | O (로컬) |
| 전적 · 통계 | X | O (Firestore) |
| 기기 변경 · 저장소 초기화 후 | — | O 복구 |
| SDK 내려받기 | 0 KB | 33 KB (gzip, 누를 때만) |
구조 (js/account.js)
import() — 게스트는 한 바이트도 안 받는다vendor/firebase-auth.js — esbuild 로 트리셰이킹한 자체 번들 112 KB / gzip 33 KB (tools/build-firebase.sh)users/{uid} 문서 하나에 JSON 한 덩어리. localStorage 는 화면을 바로 그리기 위한 사본이고 정답은 Firestoreid 기준으로 합친다 (중복 없이, 끝난 시각 순)걷어낸 것 — v1.3.0 의 로컬 5계정 시스템(js/profile.js). 구글 계정이 곧 플레이어다.
js/legacy.js 가 예전 전적을 모아 두었다가 첫 로그인 때 가져올지 한 번 묻는다 (치트 판은 제외).
Firebase 콘솔 준비물
firestore.rules 참고 (자기 uid 문서만 접근)검증 한계 : 이 개발 환경은 브라우저가
googleapis.com에 나갈 수 없어, 실제 구글 로그인 팝업 왕복은 확인하지 못했다. 그 외 경로(게스트 분기·전적 저장/복구·병합·오프라인·마이그레이션)는 신원을 모델링한 스텁으로 검증했다.
v1.3.2 에서 넣은 Firebase 백업을 도로 걷어냈다. 구조적으로 제구실을 할 수 없었다.
왜 성립하지 않는가
localStorage 가 지워졌을 때다 (사파리 ITP 7일 규칙 등)refreshToken 도 같은 localStorage 에 있다accounts:signUp 이 전혀 다른 uid 를 발급한다backups/{uid} 는 비어 있고, 예전 백업은 영영 고아가 된다재현 (가짜 Firebase 에 신원 발급을 제대로 모델링):
백업 올림 → backups/uid1
ITP 가 지움
새 신원 발급 → uid2
읽으려 한 문서 → backups/uid2 (비어 있음)
결과 → 복구 실패, 빈손으로 시작
v1.3.2 때의 검증이 틀렸다. 가짜 서버가 uid 를 고정값으로 돌려줘 신원이 바뀌는 상황을 모델링하지 못했고, "복구된다" 는 통과는 허상이었다.
제대로 하려면 진짜 로그인(구글 계정·이메일)이 필요하다. 비밀번호 없이 쓴다는 이 게임의 전제와 어긋나므로 넣지 않는다.
대신 기록을 지키는 가장 확실한 방법은 홈 화면에 추가(PWA 설치) 다. iOS 는 설치된 웹앱을 ITP 7일 규칙에서 제외한다. manifest.webmanifest 는 이미 갖춰져 있다.
지운 것 — js/cloud.js (276줄) · firestore.rules · profile.js 의 onProfileChange 훅 · 계정 화면의 백업 상태 줄 · 저장소에 들어가 있던 Firebase 설정
글꼴을 구글 폰트 CDN 에서 받지 않고 저장소 안에 동봉한다. 런타임 외부 의존이 0 이 됐다.
왜
index.html 이 구글 폰트를 차단형 <link> 로 불러, 그 <link> 가 뒤따르는 스크립트 실행까지 붙잡고 있었다vendor/)·배경음악(audio/) 을 이미 로컬로 두고 있던 원칙과도 어긋났다무엇을
fonts/ — woff2 조각 189개 · 1.52 MB, css/fonts.css — @font-face 188+1개 (112 KB / gzip 26 KB)unicode-range 조각을 그대로 옮겨 왔다. 브라우저는 예전처럼 화면에 실제로 쓰인 글자에 해당하는 조각만 받는다 (타이틀 화면 기준 13조각 · 119 KB)css/style.css 가 그 둘만 쓴다 — 500·600 은 한 번도 안 쓰임)cnation Slay the Spire 전용이라 라틴 조각 1개만 담았다 (93개 중)tools/fetch-fonts.py 가 이 꾸러미를 만든다 — 폰트를 새로 올릴 때만 다시 돌리면 된다vercel.json 에 /fonts/(.*) immutable 캐시 헤더 추가 (한 번 받으면 1년간 재요청 없음)fonts/OFL.txt 에 실었다결과 — 글꼴 서버를 완전히 차단한 상태에서 게임 시작까지 8초 초과 → 0.7초, 구글로 나가는 요청 0건
계정과 전적을 Firebase 에 한 벌 더 떠 둔다. 동기화가 아니라 백업이다.
설계 (js/cloud.js)
gstatic)가 막힌 망에서도 돌아간다accounts:signUp) → refreshToken 만 로컬에 보관, idToken 은 메모리에만backups/{uid} 문서 하나에 JSON 한 덩어리. 1 MiB 제한에 닿으면 오래된 기록부터 덜어 낸다dirty 를 남겨 다음 기회에 다시 시도복구 규칙
localStorage 를 비우는 경우)빠뜨릴 수 없는 구조
profile.js 의 commit() / Records.add / Records.clear 한 곳에서만 변경을 알린다 (onProfileChange). 호출부에서 백업 갱신을 잊을 수가 없다Firebase 쪽 준비물 — firestore.rules 참고
CONFIGURATION_NOT_FOUND)uid 문서만 읽고 쓰도록 제한한다apiKey 는 Firebase 웹 앱에서 공개를 전제로 한 식별자다. 실제 보호는 위 보안 규칙이 한다recordRun 이 run.cheat 이면 바로 반환)Records.stats 가 recent 까지 한 번 더 걸러 낸다 (used 기준으로 변경)cheatRuns 집계값을 제거Records.stats 의 includeCheat 옵션도 함께 제거 — 포함할 경로가 더는 없다한 기기를 여러 사람이 쓸 수 있도록 계정 개념을 넣고, 계정마다 전적 통계를 남긴다.
계정 (js/profile.js)
askFirstProfile).profile-chip) 를 누르면 계정 목록 — 전환 · 추가 · 전적 보기 · 삭제activeId 저장)이미 있는 이름입니다.), 이름은 12자까지저장 분리
cnation_sts_save_<계정id> · cnation_sts_runs_<계정id>run.js 에 setSaveKey() / getSaveKey() 추가 — 계정을 바꾸면 이어하기 대상도 함께 바뀐다cnation_sts_save_v1)이 있으면 첫 계정으로 1회 이전 (Profiles.migrateLegacy)전적 (Records)
로컬 우선
localStorage — 서버 없이 완전히 동작한다. 클라우드 백업은 v1.3.2 에서 추가버그 수정
id="btn-settings" 가 겹쳐 있어 이벤트가 전부 타이틀 쪽에 붙고 있었다. 타이틀 쪽을 btn-settings-title 로 분리.rel.big .rel-note)/^\d+\.\d+\.0$/ — 현재 1.1.0 · 1.2.0 두 건이 해당Card.target 게터가 def.target 만 보고 있어 강화로 대상 범위가 바뀌는 카드를 반영하지 못했다. exhaust/ethereal/innate/retain 은 이미 ~U 접미사로 강화판을 지원하고 있었는데 target 만 빠져 있었다def.targetU 지원 추가 후 실명(blind) · 걸어 넘어뜨리기(trip) 에 targetU: ALL 지정play 안의 living() 사용 여부를 대조. 해당 2장 외에 문제 없음을 확인
시체 폭발 · 압점 · 볼링 강타 는 단일 대상 지정이 맞아 그대로 둠 (전체 효과는 지정한 적을 기점으로 발생)target: all 로 이미 올바른 카드 22장, 반대 방향 오류 0건, 물약 0건적 1체 지정 / 적 전체)도 강화 여부에 따라 바뀐다AudioContext 를 suspend 시키는데 복귀 시 resume() 을 호출하지 않았다. 효과음(합성)과 배경음(MediaElementSource)이 같은 컨텍스트를 공유하므로 둘 다 죽었다audio.js 에 wakeAudio() 추가. visibilitychange(보이게 됨) · pageshow · focus 에서 resume() 하고, 실패하면 armGesture() 로 다음 화면 터치에서 재시도onAudioWake() 구독 창구를 만들어, 컨텍스트가 살아난 뒤 bgm.js 가 재생을 다시 시작하고 게인 램프를 다시 건다 (suspend 중 예약된 램프가 날아갈 수 있음)bgm.js 의 visibilitychange 는 이제 멈추는 일만 하고, 되살리는 일은 onAudioWake 가 맡는다 (두 리스너의 실행 순서에 의존하지 않도록 document.hidden 을 직접 읽는다)BGM.prefetch / BGM.prefetchBattle)
02_spire_explore, 지도 → 다음 전투 곡을 미리 받는다prefetchBattle 은 다음 전투 곡을 미리 정해 두고(reserved) 그 곡을 받는다. 실제 전투가 시작되면 정해 둔 곡을 그대로 쓰므로 미리 받은 것이 낭비되지 않는다. "직전 곡 제외" 규칙은 예약 시점에 적용slotFor). 처음엔 전용 프리페치 엘리먼트를 따로 뒀는데, 느린 회선에서 그 엘리먼트가 대역폭을 독점해 오히려 10배 느려졌다(3G 기준 682ms → 6330ms). 같은 엘리먼트를 재사용하도록 바꿔 해결js/bgm.js, audio/)
<audio> 2장을 번갈아 쓰고 효과음과 같은 AudioContext 에 MediaElementSource 로 물려 GainNode 로 페이드. 전곡 디코드(decodeAudioData) 대신 스트리밍이라 메모리를 거의 안 쓴다01 / 지도·보상·보물상자·막 전환 02 / 일반 전투 03_a·04_b·03_c·03_d / 엘리트 05_a·05_b / 모닥불·이벤트 06 / 상점 07 / 1·2·3막 보스 08·09·10 / 게임 오버 11 / 최종 승리 1211_fail(9.9초)·12_victory 는 스팅어라 반복하지 않음visibilitychange 로 백그라운드 전환 시 일시정지·복귀showSettings) — 배경음악 / 배경음 음량 / 효과음 / 효과음 음량. localStorage(cnation_sts_audio_v1) 에 저장되어 새로고침해도 유지.title-gear) — 게임 중에는 메뉴 ☰ → 설정vercel.json 에 /audio/ 영구 캐시 헤더 추가HAND_TWO_ROW = 7 장 이상이면 두 줄로 분배 (renderHand). 뒷줄 floor(n/2) · 앞줄 나머지HAND_TWO_ROW_SCALE = 0.85 로 카드를 줄여 각 줄이 거의 겹치지 않게 → 10장 기준 84% 가시--hand-scale 변수 하나로 width/height/--nb-lift/--desc-fs 를 함께 조절.handbar 높이를 키우지 않고 3D 무대 아래쪽 빈 공간으로 올라감 → 무대 높이 524px 그대로, 리플로우 없음#scr-battle.hand-two-rows 에 --row-lift 를 걸어 .hand-side(더미 버튼) 와 .battle-log 도 같이 올림renderHand() 이 부채꼴 순서대로 c.style.zIndex = 10 + i 를 인라인으로 넣고 있어서, CSS 의 .card.selected { z-index: 60 } 이 항상 밀렸다. 고른 카드보다 오른쪽(인덱스가 큰) 카드가 전부 위로 올라옴c.style.zIndex = isSel ? 80 : 10 + i — 선택 시 인라인 값 자체를 최상단으로. 드래그 중 값도 80 으로 맞추고 CSS 규칙도 80 으로 통일takePotion / showPotionSwap / confirmPotionSwap)
run.addPotion() 이 실패하면 토스트만 띄우고 보상을 못 받았음받지 않기 로 포기 선택openModal(..., { stack: true }) 로 얹어 취소하면 목록으로 복귀 (v1.1.7 모달 스택 활용)potionMult()) 반영.card-namebar 가 position:absolute; top:27% 라 .card-body 의 위쪽 6~17px 를 덮고 있었음 (363장 전부 해당).card-body 가 align-items:center + overflow:hidden 이라, 글이 넘치면 위아래가 함께 잘려 첫 줄부터 사라졌음margin-top: calc(-1 * var(--nb-lift)) (카드 높이의 13% = 기존 top:27% 와 같은 자리) 로 그림 위에 겹쳐 올림 → 보이는 위치는 그대로면서 글상자가 이름표 아래에서 시작.card-desc { max-height: 100%; overflow: hidden; } → 글상자를 넘지 못하므로 항상 첫 줄부터 보이고 뒷부분만 잘림d-long 39자↑ ×0.92 / d-xlong 48자↑ ×0.84). 글자 크기는 --desc-fs 변수로 일원화.card.small 의 임시 대응(padding-top:11px + line-clamp:4) 은 구조가 해결하므로 제거.unit-block) 가 체력바가 아닌 엉뚱한 높이에 표시되던 문제
.unit 기준 position:absolute; left:-8px; top:16px 로 고정 좌표를 쓰고 있었음. .unit 은 [의도] [이름] [체력바] [상태칩] 세로 스택이라 의도 아이콘 유무에 따라 체력바의 높이가 달라져 배지가 이름 옆으로 밀렸고, .unit 의 폭(max-content)과 체력바 폭(인라인 barW)이 달라 가로 위치도 어긋났음.unit-hp-row(position:relative, 폭 = barW) 로 감싸고 배지를 그 안에 넣어 left:-8px; top:50%; translateY(-50%) 로 앵커. 의도·이름·체력바 폭과 무관하게 항상 체력바 왼쪽 끝 · 세로 중앙positionUnits() 의 좌측 클램프에 배지 돌출분(8px) 만큼 여유 추가take(o) 하나로 묶어 정리S3.layoutEnemies() 가 playCardNow() / usePotion() 에서만 호출되고 있었음. 분열·소환은 적 턴에 일어나므로 battleUI.render() → renderBattle() 경로로는 모델이 생기지 않아 screenPos() 가 null → 라벨까지 display:nonerenderBattle() 이 매번 S3.layoutEnemies(B) 를 호출한다. 대신 layoutEnemies 가 살아 있는 적의 uid 목록을 키로 기억해, 구성이 그대로면 즉시 반환 (매 렌더마다 위치를 덮어써 공격 모션 중인 모델이 튕기는 것을 방지)summon) 도 함께 해결statusTooltip / showStatusInfo)
.pw 칩(29×19px)이 작아 개별 탭이 어렵던 문제 → 칩의 개별 클릭 핸들러를 없애고 .unit(이름·체력바·상태칩 묶음) 과 .unit-hit(본체) 이 탭을 받아 전체 상태를 한 창에 표시G.selectedCard 가 있으면 종전대로 대상 지정이 우선 (onTargetTap).intent) 는 계속 다음 행동 설명 — stopPropagation 으로 분리showTooltip(ev, title, text, rows) — 줄 수에 따라 표시 시간 연장(최대 9초), 화면 하단 탭 시 위쪽으로 열림done() 콜백이 호출되지 않아 전투 화면에서 조작 불가 상태가 되던 문제
openModal() 이 #modal-box 를 매번 덮어쓰고 closeModal() 이 #scr-modal 을 그냥 숨겨 버려서, 모달 위에 모달을 띄우면 아래 모달이 복구되지 않았음openModal(builder, { stack: true }) 로 열면 기존 모달의 DOM 노드를 그대로 보관했다가 closeModal() 에서 되돌린다 (snapshotModal / restoreModal). 이벤트 핸들러·스크롤 위치가 유지됨stack: true 로 열고, 나머지 모달은 종전대로 교체 방식 유지showScreen) 시 clearModal() 로 쌓인 모달을 정리.cd-card)는 길게 누르기 대상에서 제외 (무한 중첩 방지).card-desc .kw-* 의 border-bottom)이어하기 아래에 게임 방법 · 백과사전 두 버튼을 가로로 나란히 배치 (.title-menu-row)홈으로 버튼 왼쪽에 제작 : cnation(문명국) 표기 추가 (.history-credit)#scr-codex) — 게임 방법의 "용어집" 을 5개 탭으로 확장. 메뉴(☰)에서도 진입
| 탭 | 내용 |
|---|---|
| 카드 | 363장 — 아이언클래드 75 · 사일런트 77 · 디펙트 76 · 와쳐 83 · 무색 33 · 상태/저주 19. 누르면 상세 설명 |
| 유물 | 117개 — 3D 아이콘 + 설명, 등급별 보기, 캐릭터 전용 표시 |
| 물약 | 30종 — 3D 아이콘 + 효과, 등급 · 전투 전용 표시 |
| 캐릭터 | 4종 — 3D 초상, 시작 체력 / 시작 유물 / 시작 덱, 운영 방식 설명 |
| 용어 | 39개 — 기존 용어집 |
buildPlayer)을 그대로 구워서 사용cubic-bezier(.2,.86,.3,1))이 지나치게 앞쪽에 몰려 첫 0.08초에 거리의 72% 를 이동| 자세 | 색 | 특징 |
|---|---|---|
| 분노 | 붉은빛 | 알갱이 18개가 빠르게 솟고 고리가 빠르게 회전 |
| 평온 | 밝은 파랑 | 알갱이 13개가 천천히 잔잔하게 |
| 신성 | 금빛 | 알갱이 22개 + 머리 위 후광 고리 |
카드 상세 설명 도입을 기점으로 버전을 1.1 계열로 올렸습니다. (기존 1.0.22 → 1.1.0)
renderHand() 가 한 번 더 돌면, 비행 시작 위치(scale 0.16 · 더미 위치)를
최종 위치로 덮어써 버려 애니메이션이 통째로 사라지던 문제. 턴 전환 중에는 렌더가 11~17회 발생flyPending)를 두고, 그동안에는 목표값만 갱신하고 style 은 건드리지 않도록 수정0.16 → 0.54 → 0.85 → 1.0정확도 → 단검이 무엇인지)js/data/keywords.js) — 카드 렌더러의 키워드 색 입히기도 이 사전을 참조하도록 통합
scale 0.16 으로 출발해 커지면서 손패 자리로 날아옴 (0.42s, 장당 0.055s 시차)scale 0.14) 날아가 사라짐, 살짝 회전cardDraw, 버림은 새 cardToss(아래로 떨어지는 바람소리),
소각은 기존 exhaust. 여러 장이면 0.05s 시차로 재생run 에서 실제 효과를 읽어 내 구체적으로 표시 —
내 덱에 현혹 2장 넣음, 자신에게 힘 3, 나에게 약화 2, 새로운 적을 부름, 체력을 나눠 둘로 분열크기 추정치(1.6 + size×1.5) 대신 실제 모델 바운딩 박스 높이로 계산
→ 몬스터마다 자기 머리 바로 위에 붙음.version-bar 규칙이 함께 삭제되어 표시가 사라졌던 문제#scr-history · .rel*)cnation Slay the Spire 한 줄 구성,
화면 폭에 따라 크기가 자동 조절(clamp)js/three/title3d.js) — 전투 씬의 절차적 모델을 그대로 재사용
♥ 66/77 · 🪙 108 · 물약 슬롯 3칸 · 1막 · 3층 · 메뉴js/three/items3d.js)
cnation Slay the Spire 로 변경 — cnation 은 원작 로고를 가리지 않는 차분한 청록? 로 읽힘js/data/changelog.js 로 분리 — 새 버전을 올릴 때 이 파일도 함께 갱신NEOW_SECRET)발동 방법 — 설정 → 관리자 모드(숫자 4자리) → 자원 2배로 시작 을 켠 뒤 판을 시작합니다.
v1.3.16 까지는 시작 보너스 화면의 네 번째 선택지 아래 아무 표시 없는 자리(
.choice-secret, 높이 54px)를 탭하는 방식이었습니다. v1.3.17 에서 그 숨은 버튼을 없앴습니다 — 관리자 모드가 같은 일을 더 분명하게 하고, 모르고 눌러 판을 버리는 일이 없어집니다. 효과 자체(NEOW_SECRET)는 그대로 씁니다.
효과 (NEOW_SECRET in js/data/neow.js)
| 항목 | 내용 |
|---|---|
| 최대 체력 | 2배 (그만큼 회복해서 가득 찬 상태로 시작) |
| 공격 카드 피해 | 2배 — attackValue() 에서 card && run.cheat 일 때만. 가시·중독 같은 카드 외 피해는 그대로 |
| 방어 카드 방어도 | 2배 — gainBlock() 에서 card && !raw && run.cheat 일 때만. 유물·힘으로 얻는 방어도는 그대로 |
| 물약 효과 | 2배 — 신성한 나무껍질 유물과 같은 배율 |
| 골드 | 선택 즉시 가진 골드가 2배, 이후 얻는 골드도 전부 2배 — gainGold() 한 곳에서 처리하므로 전투 보상·상자·이벤트·유물 전부 적용 |
| 유물 | 일반 유물 1개 + 엘리트 유물 1개(relicReward()) |
run.cheat 플래그로 관리하며 저장/불러오기에 함께 보존됩니다.