Jurassic GO
somodus.com
= 3 || !navigator.sendBeacon) return; sent++; var sc = document.querySelector('script[src*="three-playable"]'); var origin = sc ? new URL(sc.src).origin : location.origin; var meta = document.querySelector('meta[name="playable-build"]'); navigator.sendBeacon(origin + '/g-log', JSON.stringify({ k: location.pathname.slice(0, 60), t: String(kind).slice(0, 24), m: String(msg).slice(0, 300), b: (meta && meta.content) || '', ua: navigator.userAgent.slice(0, 140), })); } catch (e) {} }; })(); // ── 1) ZzFX v1.3.2 (MIT © 2019 Frank Force — https://github.com/KilledByAPixel/ZzFX) ── // 원본 그대로(수정 0). AudioContext 생성이 미지원 환경에서 던져도 런타임 나머지가 살도록 try 격리. try { let // ZzFXMicro - Zuper Zmall Zound Zynth - v1.3.2 by Frank Force zzfxV=.3, // volume zzfxX=new AudioContext, // audio context zzfx= // play sound (p=1,k=.05,b=220,e=0,r=0,t=.1,q=0,D=1,u=0,y=0,v=0,z=0,l=0,E=0,A=0,F=0,c=0,w=1,m=0,B=0 ,N=0)=>{let M=Math,d=2*M.PI,R=44100,G=u*=500*d/R/R,C=b*=(1-k+2*k*M.random(k=[]))*d/R, g=0,H=0,a=0,n=1,I=0,J=0,f=0,h=N<0?-1:1,x=d*h*N*2/R,L=M.cos(x),Z=M.sin,K=Z(x)/4,O=1+K, X=-2*L/O,Y=(1-K)/O,P=(1+h*L)/2/O,Q=-(h+L)/O,S=P,T=0,U=0,V=0,W=0;e=R*e+9;m*=R;r*=R;t*= R;c*=R;y*=500*d/R**3;A*=d/R;v*=d/R;z*=R;l=R*l|0;p*=zzfxV;for(h=e+m+r+t+c|0;a
a?0:(a
z&&(b+=v,C+=v,n=0),!l||++I%l ||(b=C,u=G,n=n||1);X=zzfxX,p=X.createBuffer(1,h,R);p.getChannelData(0).set(k);b=X. createBufferSource();b.buffer=p;b.connect(X.destination);b.start()}; window.__zzfx = zzfx; window.__zzfx_ctx = zzfxX; } catch (e) { window.__zzfx = null; window.__zzfx_ctx = null; } // ── 2) PLAYABLE 네임스페이스 ── (function () { var P = {}; window.PLAYABLE = P; // LLM 로직 훅(입력/리사이즈/게임 콜백) 오류는 게임을 죽이지 않되 침묵도 금지 — 상한부 LOUD. // 전 셸 공용 깔때기(ctx = 훅 이름) — 훅 오류는 게임을 안 죽여 전역 error 비콘에 안 잡히므로 // 첫 발생만 비콘 박제(매 프레임 재발해도 비콘 상한 3회를 여기서 소진하지 않게). var _hook_err_n = 0; P._hook_err = function (e, ctx) { if (_hook_err_n >= 5) return; _hook_err_n++; var m = (ctx ? ctx + ': ' : '') + (e && (e.stack || e.message || e)); try { console.error('[playable] logic hook error: ' + m); } catch (_) {} if (_hook_err_n === 1 && window.__playable_beacon) window.__playable_beacon('hook_error', m); }; // ── 인자 형태 정규화 계층 (L1) — LLM-대면 옵션의 형태 다양성(객체/배열/스칼라/불리언)을 // 진입 시 단일 의미로 흡수(normalize-at-entry, Postel). 실측 사고: world.camera 에 {x,y,z} 객체를 // 넘기자 배열 인덱싱 → Vector3(undefined…) 가 ES6 기본 인자로 (0,0,0) 침묵 치환 → 탑다운 퇴화 카메라. // 이 계층이 있는 한 어느 형태 추론이든 정답이 된다(전 셸 파일 공용 — 파싱 중복·드리프트 금지). function _fin(v, d) { return typeof v === 'number' && isFinite(v) ? v : d; } P._nv3 = function (v, dx, dy, dz) { // → {x,y,z} — 객체/배열/스칼라/null 수용 if (v == null) return { x: dx, y: dy, z: dz }; if (typeof v === 'number') return { x: _fin(v, dx), y: _fin(v, dy), z: _fin(v, dz) }; if (Array.isArray(v)) return { x: _fin(v[0], dx), y: _fin(v[1], dy), z: _fin(v[2], dz) }; return { x: _fin(v.x, dx), y: _fin(v.y, dy), z: _fin(v.z, dz) }; }; P._nv2 = function (v, dx, dz) { // → {x,z} — at/direction 류 (배열 [x,z] 는 [0]→x, [1]→z) if (v == null) return { x: dx, z: dz }; if (Array.isArray(v)) return { x: _fin(v[0], dx), z: _fin(v[1], dz) }; return { x: _fin(v.x, dx), z: _fin(v.z != null ? v.z : v.y, dz) }; // {x,y} 2D 관례도 흡수 }; P._nrange = function (v, dmin, dmax) { // → [min,max] — 스칼라/배열/{min,max} 수용 if (v == null) return [dmin, dmax]; if (typeof v === 'number' && isFinite(v)) return [v, v]; if (Array.isArray(v)) { var a = _fin(v[0], dmin), b = _fin(v[1], a); return [Math.min(a, b), Math.max(a, b)]; } var mn = _fin(v.min, dmin), mx = _fin(v.max, dmax); return [Math.min(mn, mx), Math.max(mn, mx)]; }; // 셸 chrome 공용 스타일(토스트·공유 버튼) — Dawn Signal(밤 캔버스 + 앰버 액센트). (function () { var s = document.createElement('style'); s.textContent = '#playable-toast{position:fixed;left:50%;bottom:calc(24px + env(safe-area-inset-bottom,0px));transform:translateX(-50%) translateY(8px);' + 'background:#1c2130;color:#f5f7fa;border:1px solid rgba(245,158,11,.35);border-radius:999px;padding:10px 18px;' + 'font:600 13px/1 system-ui,sans-serif;opacity:0;transition:opacity .25s,transform .25s;pointer-events:none;z-index:99990;}' + '#playable-toast.show{opacity:1;transform:translateX(-50%) translateY(0);}' + // (기록 공유는 상시 버튼 폐기 → 오버플로 메뉴 항목으로 이동. #playable-share 규칙 제거.) '#playable-toast{}'; document.head.appendChild(s); })(); // storage — sandbox opaque origin 에서 localStorage 는 SecurityError. 페이지 세션 한정 in-memory 대체. // (Claude Artifacts window.storage 선례. 리로드 간 영속 없음 — 게임 로직은 이 계약만 사용.) var _mem = new Map(); P.storage = { get: function (k) { return _mem.has(k) ? _mem.get(k) : null; }, set: function (k, v) { _mem.set(k, v); }, remove: function (k) { _mem.delete(k); }, }; // 오디오 — ZzFX 파라미터 배열 1개를 받는 안전 래퍼. 실패는 무음(게임을 절대 깨지 않음). P.sfx = function (params) { try { if (window.__zzfx && Array.isArray(params)) window.__zzfx.apply(null, params); } catch (e) {} }; // ── 실녹음 SFX 뱅크 로더 — msfx(world.md)의 L1 원천. fetch→decodeAudioData 캐시(url 키), // 동시 로드 ≤4, 실패 = LOUD 1회 후 null(소비자는 zzfx 합성 L2 로 폴백 — 무음 회귀 없음). // decode 는 unlock/suspend 와 무관하게 가능. 포맷은 mp3/m4a/wav(전 브라우저 decodeAudioData // 지원 교집합 — ogg vorbis 는 iOS Safari 미지원이라 뱅크에 두지 않는 것이 인제스트 계약). var _bank = new Map(); // url → {buf: AudioBuffer|null, failed?: true} var _bank_inflight = 0, _bank_q = []; function _bank_drain() { var ctx = _actx(); if (!ctx) return; while (_bank_inflight < 4 && _bank_q.length) { (function (url) { _bank_inflight++; fetch(url) .then(function (r) { if (!r.ok) throw new Error('http ' + r.status); return r.arrayBuffer(); }) .then(function (ab) { return ctx.decodeAudioData(ab); }) .then(function (buf) { var e2 = _bank.get(url); if (e2) e2.buf = buf; }) .catch(function (e) { var e3 = _bank.get(url); if (e3) e3.failed = true; try { console.warn('[sfx] bank load failed (synth fallback stays): …' + String(url).slice(-44) + ' — ' + (e && e.message)); } catch (_) {} }) .then(function () { _bank_inflight--; _bank_drain(); }); })(_bank_q.shift()); } } P.sfx._bank_load = function (url) { // 프리로드(멱등) — 기등록 URL 도 드레인 킥(ctx 지연 생성 시 큐 정체 봉합) if (!url) return; if (!_bank.has(url)) { _bank.set(url, { buf: null }); _bank_q.push(url); } _bank_drain(); }; P.sfx._bank_get = function (url) { var e = _bank.get(url); return (e && e.buf) || null; }; P.sfx._bank_play = function (buf, vol, opts) { // 디코드 완료 버퍼 재생 — 성공 시 true(폴백 억제 신호). // opts.{offset,duration} = 구간 슬라이스(다발 수록 원본에서 단발만 재생 — 짧은 릴리즈로 클릭 소거) try { var ctx = _actx(); if (!ctx || !buf) return false; var src = ctx.createBufferSource(); src.buffer = buf; var g = ctx.createGain(); var v = Math.max(0.0001, vol == null ? 1 : vol); g.gain.value = v; src.connect(g); g.connect(ctx.destination); var off = (opts && opts.offset) || 0, dur = opts && opts.duration; if (opts && opts.rate) src.playbackRate.value = opts.rate; // 미세 변주(0.96~1.04) — 반복 발사의 기계감 소거 if (dur) { g.gain.setValueAtTime(v, ctx.currentTime); g.gain.setTargetAtTime(0.0001, ctx.currentTime + Math.max(0.05, dur - 0.09), 0.035); src.start(0, off, dur); } else src.start(); return true; } catch (e) { return false; } }; // ── SFX 라이브러리 — 게임 로직이 이름으로 재생하는 범용 실효과음(코인·문·폭발·UI 등). // 배선은 생성기 인라인 맵( = {이름: 절대 URL} — 서버 카탈로그 출처, LLM 은 // 이름 선택만)이 정본. 미배선 이름 = LOUD 1회 + false(게임은 절대 깨지 않음). 클립은 부팅 즉시 // 백그라운드 프리로드(decode 는 unlock 무관) — 최초 호출 시 미디코드면 3s 내 도착분 지연 재생. var _lib_warned = {}; P.sfx.lib_has = function (name) { return !!((window['__PLAYABLE_'+'SFX_LIB__'] || {})[name]); }; // 배선 확인(무경고) — fx 오버레이 소비 P.sfx.lib = function (name, opts) { var map = window['__PLAYABLE_'+'SFX_LIB__'] || {}; var u = map[name]; if (!u) { if (!_lib_warned[name]) { _lib_warned[name] = 1; try { console.warn('[sfx] lib clip not wired for this game: ' + name); } catch (e) {} if (window.__playable_beacon) window.__playable_beacon('sfx_lib_unwired', String(name).slice(0, 60)); // 흡수 계측(spawn_unknown 대칭) } return false; } var vol = opts && opts.volume != null ? opts.volume : 1; var buf = P.sfx._bank_get(u); if (buf) return P.sfx._bank_play(buf, vol, opts); // opts(rate/offset/duration) 전달 — 종 배속 무시 결함 봉합(2026-07-25) P.sfx._bank_load(u); var tries = 0; var t = setInterval(function () { var b = P.sfx._bank_get(u); if (b) { clearInterval(t); P.sfx._bank_play(b, vol, opts); } // 지연 경로도 opts(rate) 승계 else if (++tries > 20) clearInterval(t); // 3s 포기 — 로드 실패는 bank 가 이미 LOUD }, 150); return true; }; (function () { // 인라인 맵 전 클립 프리로드 — 게임 이벤트 시점엔 대체로 디코드 완료 try { var m = window['__PLAYABLE_'+'SFX_LIB__']; if (m) for (var k in m) P.sfx._bank_load(m[k]); } catch (e) {} })(); // 첫 사용자 제스처에서 AudioContext resume — iOS 는 touchstart 를 unlock 제스처로 인정하지 않으므로 // pointerdown(=touchend 계열 합성 이전에도 유효한 최신 표준)+keydown 을 once 로 듣는다. // unlock 훅: 제스처 이후에만 가능한 오디오 작업(음악 fetch·앰비언스 그래프 기동)의 단일 대기 지점. var _unlock_fns = []; P._unlocked = false; P._on_unlock = function (fn) { if (typeof fn !== 'function') return; if (P._unlocked) { try { fn(); } catch (e) {} return; } _unlock_fns.push(fn); }; (function () { function unlock() { P._unlocked = true; // resume() 프라미스 거절 흡수 — iOS "Failed to start the audio device" 가 unhandledrejection // 으로 새어 오류 비콘 예산(3회)을 소모한 실측(benign: 다음 제스처에서 재시도됨). try { var _rp = !P._muted && window.__zzfx_ctx && window.__zzfx_ctx.state === 'suspended' ? window.__zzfx_ctx.resume() : null; if (_rp && _rp.catch) _rp.catch(function () {}); } catch (e) {} var fns = _unlock_fns.slice(); _unlock_fns.length = 0; for (var i = 0; i < fns.length; i++) { try { fns[i](); } catch (e) {} } window.removeEventListener('pointerdown', unlock, true); window.removeEventListener('keydown', unlock, true); } window.addEventListener('pointerdown', unlock, true); window.addEventListener('keydown', unlock, true); })(); // 탭 백그라운드 = 오디오 일시정지(rAF 는 서므로 게임은 멈추는데 소리만 계속 나는 결함 차단) document.addEventListener('visibilitychange', function () { try { var ctx = window.__zzfx_ctx; if (!ctx) return; if (document.hidden) { if (ctx.state === 'running') ctx.suspend(); } else if (P._unlocked && !P._muted && ctx.state === 'suspended') { var _rp2 = ctx.resume(); if (_rp2 && _rp2.catch) _rp2.catch(function () {}); } } catch (e) {} }); // ── 마스터 음소거(전 게임 보편 UI) — music·ambience·sfx 를 컨텍스트 suspend 한 점에서 침묵 ── // iOS 무음 스위치 우회(playback 세션)로 "끌 방법이 볼륨뿐"이던 실사용 공백의 봉합. 상태는 // 세션 한정(P.storage 계약 — sandbox opaque origin 은 localStorage 불가). 리로드 = 소리 복귀. P._muted = false; P._mute_svg = function (muted) { // 스피커 아이콘 2태(인라인 SVG — 이모지 폰트 비일관 봉합) var body = muted ? '
' : '
'; return '
' + '
' + body + '
'; }; P.mute = function (on) { P._muted = on === undefined ? !P._muted : !!on; var ctx = window.__zzfx_ctx; try { if (P._muted) { if (ctx && ctx.state === 'running') ctx.suspend(); } else if (P._unlocked && ctx && ctx.state === 'suspended') { var _mrp = ctx.resume(); if (_mrp && _mrp.catch) _mrp.catch(function () {}); } } catch (e) {} var mb = document.getElementById('playable-mute'); if (mb) mb.innerHTML = P._mute_svg(P._muted); return P._muted; }; // ── UI 존 레지스트리 — 셸 HUD 고정 슬롯의 단일 정본(겹침의 표현 불가능화, 2026-07-12 실보고: // 미니맵이 아키타입 타이머 칩을 덮고 POI 마커가 공유 버튼 뒤로 숨던 세로폰 겹침 클래스). // 각 요소는 예약 존을 점유(claim)하고, 상호 회피가 필요한 소비자(톱바 우측 인셋·마커 클램프)는 // 점유 상태를 구독/조회한다. 존 기하 = 상수 px(safe-area 는 각 CSS 의 env() 가 가산 — 회피 // 사각형은 보수 여유 44px 로 근사). 신규 고정 UI 를 만들 때는 반드시 여기 존을 등록할 것. P._ui = { Z: { // { l|r, t|b, w, h } — 좌상 기준 px top_left: { l: 14, t: 14, w: 44, h: 44 }, // 뮤트(runtime 소유) brand: { l: 110, t: 14, w: 126, h: 40 }, // '나도 만들기' 훅 칩(runtime 소유 — ⋯ 버튼 우측 열, 2026-07-27) minimap: { r: 14, t: 14, w: 122, h: 122 }, // 미니맵(world 소유 — 예약 최대 기하) share: { r: 16, b: 16, w: 150, h: 52 }, // 공유 버튼(runtime 소유) actions: { r: 16, b: 96, w: 210, h: 130 }, // 상호작용/공격/점프/스왑 버튼 열(world 소유) }, occ: {}, _subs: [], claim: function (name) { this.occ[name] = 1; for (var i = 0; i < this._subs.length; i++) { try { this._subs[i](name); } catch (e) {} } }, on_claim: function (fn) { this._subs.push(fn); if (this.occ.minimap) { try { fn('minimap'); } catch (e) {} } }, // 오프스크린 앵커 요소(POI 마커 등)가 피해야 할 화면 사각형 목록(px) excl_rects: function (vw, vh) { var out = []; var Zs = this.Z.share, Za = this.Z.actions; out.push({ x0: vw - Zs.r - Zs.w - 10, y0: vh - (Za.b + Za.h + 44), x1: vw, y1: vh }); // 우하단(공유+액션열) if (this.occ.minimap) { var Zm = this.Z.minimap; out.push({ x0: vw - Zm.r - Zm.w - 10, y0: 0, x1: vw, y1: Zm.t + Zm.h + 44 }); // 우상단(미니맵) } return out; }, }; // ── 6언어 리졸버(내부 API, LLM 비노출) — 플랫폼 언어 캐논과 동형(pipeline_executor 의 // pick_telegram_lang 정규화: ko/ja/es/zh-hant(tw·hk 포함)/zh-hans/en 폴백). 획득 접점(훅)과 메뉴 // 라벨이 소비 — 사용자 결정 2026-07-27 '다국어 지원 필수'. 셸 공용 _t(ko,ja,en) 3언어 관례보다 // 넓은 표면이 필요할 때 이것을 쓴다. m = {ko,ja,en,es,hans,hant} (누락 키 = en 폴백). P._t6 = function (m) { var c = String(((typeof navigator !== 'undefined' && (navigator.language || navigator.userLanguage)) || document.documentElement.lang || 'en')).toLowerCase(), k; // 언어=브라우저 우선(2026-07-29 사용자 확정: 기본 영어+브라우저 다국어) if (c.indexOf('ko') === 0) k = 'ko'; else if (c.indexOf('ja') === 0) k = 'ja'; else if (c.indexOf('es') === 0) k = 'es'; else if (c.indexOf('zh-hant') === 0 || c === 'zh-tw' || c === 'zh-hk') k = 'hant'; else if (c.indexOf('zh') === 0) k = 'hans'; else k = 'en'; return m[k] != null ? m[k] : m.en; }; // ── 공유 UI 스타일시트 — 콘솔 디자인 언어(PS/Xbox 계열 정본: 다크 글래스 + 블러 + 단일 액센트 + // 프레스 스케일 피드백 + 일관 아이콘 스트로크). 전 셸의 버튼/메뉴가 클래스로 공유(중복 인라인 제거). (function () { var st = document.createElement('style'); st.id = 'playable-ui-css'; // ⚠️ 상시 버튼에 backdrop-filter 금지 — WebGL 캔버스 위 매 프레임 블러 합성이 저사양/소프트웨어 // 렌더에서 프레임을 붕괴시킨다(water 스모크 44틱→2틱 실측 회귀). 블러는 일시 표시 메뉴(.pmenu)만. // 페이지 전역 더블탭 줌 봉인(iOS 실기기 실보고 2026-07-25) — bind_pointer 의 touch-action:none 은 // "묶인 스테이지 요소"만 덮어, video/body/HUD 여백 더블탭이 Safari 기본 줌을 발동시켰다. // iOS 13+ 표준 정답 = html/body touch-action:manipulation(더블탭 줌만 제거 — 패널 스크롤·핀치(접근성) 유지, // 조상 제한이 전 하위 요소로 전파되어 표면 누락이 구조적으로 불가). 뷰포트 메타·dblclick preventDefault 는 // Safari 가 무시/비일관(조사 확정)이라 채택하지 않는다. st.textContent = 'html,body{touch-action:manipulation;}' + '.pbtn{position:fixed;display:flex;align-items:center;justify-content:center;border-radius:50%;color:#eef2f8;' + 'border:1.5px solid rgba(255,255,255,.16);background:linear-gradient(160deg,rgba(44,52,68,.88),rgba(13,17,27,.92));' + 'box-shadow:0 10px 26px rgba(0,0,0,.45),inset 0 1px 0 rgba(255,255,255,.16);' + 'touch-action:manipulation;padding:0;line-height:1;z-index:99960;transition:transform .12s ease,border-color .15s ease,box-shadow .15s ease;}' + '.pbtn:active{transform:scale(.9);border-color:rgba(245,158,11,.6);}' + '.pbtn--primary{border-color:rgba(245,158,11,.5);background:linear-gradient(160deg,rgba(66,46,20,.9),rgba(26,17,8,.94));' + 'box-shadow:0 10px 28px rgba(245,158,11,.16),0 10px 26px rgba(0,0,0,.45),inset 0 1px 0 rgba(255,255,255,.18);}' // 콘솔 페이스 버튼 4색 링 — 범용 게임패드 배열 규약(하단 녹/우 적/좌 청/상 황)만 차용해 // "보는 즉시 콘솔 컨트롤러"로 읽히게 한다(상표 글리프 미사용). 배정은 P.pad 레지스트리 전담. + '.pbtn--a{border-color:rgba(74,222,128,.6);box-shadow:0 10px 28px rgba(74,222,128,.15),0 10px 26px rgba(0,0,0,.45),inset 0 1px 0 rgba(255,255,255,.18);}' + '.pbtn--b{border-color:rgba(248,113,113,.55);box-shadow:0 10px 26px rgba(248,113,113,.12),0 10px 24px rgba(0,0,0,.42),inset 0 1px 0 rgba(255,255,255,.16);}' + '.pbtn--x{border-color:rgba(96,165,250,.55);box-shadow:0 10px 26px rgba(96,165,250,.12),0 10px 24px rgba(0,0,0,.42),inset 0 1px 0 rgba(255,255,255,.16);}' + '.pbtn--y{border-color:rgba(250,204,21,.55);box-shadow:0 10px 26px rgba(250,204,21,.12),0 10px 24px rgba(0,0,0,.42),inset 0 1px 0 rgba(255,255,255,.16);}' + '.pbtn--rail{border-radius:14px;}' // ── 콘솔 버튼 캡(광택) ───────────────────────────────────────────────────── // 실물 게임패드 버튼의 사출 성형 캡을 재현: 어두운 돔 그라디언트 + 상단 스페큘러 하이라이트 // + 주기적 시닌(sheen) 스윕. 시선을 끌어 "눌러볼 수 있게" 하는 것이 목적(사용자 요청). // ⚠️ 애니메이션은 transform/opacity 만 쓴다 — left/width 애니메이션은 레이아웃을 유발하고, // backdrop-filter 는 상시 요소 금지 캐논(WebGL 캔버스 위 매 프레임 블러 = 프레임 붕괴)이다. // transform 은 컴포지터 전용이라 리페인트가 없다. + '.pbtn--pad{overflow:hidden;border-width:2px;' + 'background:radial-gradient(circle at 50% 30%, #333c50 0%, #191e2a 56%, #0a0d14 100%);}' + '.pbtn--pad svg{position:relative;z-index:2;filter:drop-shadow(0 0 5px currentColor);}' + '.pbtn--pad::before{content:"";position:absolute;left:13%;top:7%;width:74%;height:40%;border-radius:50%;' + 'background:linear-gradient(180deg,rgba(255,255,255,.32),rgba(255,255,255,0));pointer-events:none;z-index:1;}' + '.pbtn--pad::after{content:"";position:absolute;top:-60%;left:0;width:55%;height:220%;pointer-events:none;z-index:3;' + 'background:linear-gradient(90deg,rgba(255,255,255,0),rgba(255,255,255,.4),rgba(255,255,255,0));' + 'transform:translateX(-190%) rotate(18deg);animation:pbtn-sheen 3.8s ease-in-out infinite;}' + '@keyframes pbtn-sheen{0%,70%{transform:translateX(-190%) rotate(18deg);}92%,100%{transform:translateX(320%) rotate(18deg);}}' // 슬롯별 시닌 시차 — 전 버튼이 동시에 번쩍이면 산만하다(순차 = 패드가 살아있는 느낌) + '.pbtn--b::after{animation-delay:.45s;}.pbtn--x::after{animation-delay:.9s;}.pbtn--y::after{animation-delay:1.35s;}' // 글리프 색 = 슬롯 색(캡은 어둡고 기호만 발광 — 첨부 참조 이미지의 판독 구조) + '.pbtn--a{color:#5ef08d;}.pbtn--b{color:#ff8a8a;}.pbtn--x{color:#7db4ff;}.pbtn--y{color:#ffd93d;}' + '.pbtn--pad:active{transform:scale(.92);box-shadow:inset 0 3px 10px rgba(0,0,0,.6),0 3px 10px rgba(0,0,0,.4);}' // 종료 상태(data-pend) — 실보고 2026-07-22: 잡혀서 끝나도 표식이 일시 토스트뿐이라 HUD 가 멈춘 // 채 남아 "게임이 멈춘 것"처럼 보였다. 조작 계열을 시각적으로 비활성화하고 상시 배너를 띄운다. // 공유/음소거(.pbtn 이되 pbtn--pad 아님)는 살려둬야 종료 후 공유가 가능하다. + 'body[data-pend="1"] #playable-archetype-hud{opacity:.42;filter:grayscale(.75);}' + 'body[data-pend="1"] .pbtn--pad,body[data-pend="1"] #playable-pad-housing,body[data-pend="1"] #playable-stick-base' + '{opacity:.28;pointer-events:none;filter:grayscale(1);}' + 'body[data-pend="1"] #playable-crosshair{display:none;}' // 종료 후 미션 칩 숨김 — 종료 배너(+획득 CTA)와 같은 상단 밴드를 다투는데, 끝난 게임의 목표/ // 타이머는 죽은 정보다(미션 없는 프리셋의 no_mission 스모크는 존재 검사라 display 무영향). + 'body[data-pend="1"] #playable-mission{display:none;}' + '#playable-ended{position:fixed;left:50%;top:38%;transform:translate(-50%,-50%);' + 'z-index:99970;display:flex;flex-direction:column;align-items:center;gap:9px;padding:10px 18px;border-radius:14px;max-width:calc(100vw - 32px);' + 'font:700 15px/1.35 system-ui,sans-serif;color:#f4f7fb;text-align:center;' + 'background:linear-gradient(160deg,rgba(24,29,44,.95),rgba(11,14,22,.96));border:1px solid rgba(255,255,255,.16);' + 'box-shadow:0 14px 40px rgba(0,0,0,.5);animation:pmenu-in .2s ease-out;}' + '@keyframes pmenu-in{from{opacity:0;transform:translateY(14px) scale(.98);}to{opacity:1;transform:none;}}' + '.pmenu{animation:pmenu-in .18s ease-out;}' + '.pmenu-item{transition:transform .1s ease,border-color .12s ease,background .12s ease;}' + '.pmenu-item:active{transform:scale(.96);border-color:rgba(245,158,11,.55);}'; if (document.head) document.head.appendChild(st); // 더블탭 줌 2층 v2(iOS 실기기 잔존 실보고 2026-07-25 ×2) — touch-action:manipulation 이 무시되는 // iOS 사례를 결정론 차단: 330ms 창 안의 두 번째 탭은 touchend 기본동작(더블탭 줌)을 무조건 취소. // v1은 버튼류를 제외했는데(연타 클릭 보존) 순위창 닫기 버튼 더블탭에서 줌이 잔존 → v2 = 전 요소 // 적용 + 인터랙티브 요소는 억제된 네이티브 click 을 합성 click 으로 복원(연타 동작 보존 — 발사 // 버튼 스팸 포함). 게임 표면 동사는 포인터 이벤트 기반이라 원래 무손상. var _dtz_last = 0; document.addEventListener('touchend', function (e) { var now9 = Date.now(); var dbl9 = now9 - _dtz_last < 330; _dtz_last = now9; if (!dbl9 || !e.cancelable || e.touches.length > 0) return; e.preventDefault(); var t9 = e.target; var inter9 = t9 && t9.closest && t9.closest('button,a,input,select,textarea,label'); if (inter9) { try { inter9.click(); } catch (e9) {} } // 네이티브 click 억제분 복원(정확히 1회) }, { capture: true, passive: false }); })(); // ── 콘솔 패드 레이아웃 정본(P.pad) ────────────────────────────────────────────── // 실보고(2026-07-22): 버튼 좌표가 6개 셸 파일에 각자 하드코딩되어 smash(world)/attack(combat)/ // fire(fps)가 *동일 좌표*(right:24/bottom:156)를 주장 → 동시 활성 시 겹침이 구조적으로 보장되고, // 나머지 버튼도 파일별 임의 좌표라 배치가 산만했다. 좌표를 개별 파일에서 몰수하고 *역할 선언*만 // 받는 단일 레지스트리로 대체 — 한 슬롯의 이중 점유가 표현 불가능해진다(구조로 불변식 보장). // 배치 언어 = 콘솔 게임패드 정본: 우측 페이스 다이아몬드(4색 링) + 상단 숄더 레일(L/R) + 좌측 스틱. (function () { // 중심 right:88/bottom:158, 반경 66. 각 슬롯 박스가 서로 비교차임을 산술 검산한 값이다(간격을 // 좁히면 south-east 모서리가 접촉한다 — 값 변경 시 반드시 재검산할 것). // 페이스 글리프 = *슬롯* 소유다(실제 컨트롤러와 동형: 도형은 버튼 위치의 정체성이고, 그것이 // 무슨 동작인지는 시작 범례가 매핑한다). ⚠️ 도형 세트는 소니 PlayStation 의 4종{삼각형·원· // 크로스·정사각형}과 한 요소도 겹치지 않게 골랐다 — WIPO 사례연구상 그 기호들은 등록상표이고, // 소니는 근사 디자인(O2 광고: 정사각형·T·역삼각형·원)에도 제소한 전례가 있다. somodus 는 게임을 // 생성·판매하므로 등록 분류와 정면 중복이라 위험이 더 크다. 다이아몬드 배치와 4색 링은 범용 // 게임패드 관습이라 그대로 쓴다. function _g(inner) { return '
' + inner + '
'; } var SHAPE = { south: _g('
'), // 육각형 east: _g('
'), // 셰브런 north: _g('
'), // 4각 별 west: _g('
'), // 오각형 }; // ring = 범례가 소비하는 링 색(위 .pbtn--* CSS 와 같은 값의 단일 출처 — 범례와 버튼의 색이 // 어긋나면 "어느 버튼 설명인지" 매핑이 깨지므로 문자열을 두 곳에 적지 않는다). var SLOTS = { south: { cx: 88, cy: 92, size: 66, cls: 'pbtn--a', ring: 'rgba(74,222,128,.6)', glyphc: '#5ef08d' }, // 주 동사(공격/발사/부수기) east: { cx: 32, cy: 160, size: 56, cls: 'pbtn--b', ring: 'rgba(248,113,113,.55)', glyphc: '#ff8a8a' }, // 점프 north: { cx: 88, cy: 224, size: 54, cls: 'pbtn--y', ring: 'rgba(250,204,21,.55)', glyphc: '#ffd93d' }, // 예비(4번째 동사) west: { cx: 144, cy: 160, size: 54, cls: 'pbtn--x', ring: 'rgba(96,165,250,.55)', glyphc: '#7db4ff' }, // 보조 }; var ORDER = ['south', 'east', 'north', 'west']; var ROLE = { primary: 'south', jump: 'east', interact: 'north', secondary: 'west' }; var taken = {}, rail = null, house = null; // 범례 원장 — 시작 카드가 "설치된 실제 버튼"을 읽어 설명을 만든다. 레지스트리가 유일 정본이라 // 설명이 실제 버튼과 어긋나는 일이 구조적으로 불가능하다(고정 문구는 늘 드리프트한다). var _reg = []; function _housing() { // 하우징 플레이트 = 다이아몬드 뒤 반투명 라디얼(컨트롤러 실루엣 환기) if (house) return; // ⚠️ blur 금지 캐논 준수(상시 표시 요소) house = document.createElement('div'); house.id = 'playable-pad-housing'; house.style.cssText = 'position:fixed;right:-2px;bottom:calc(66px + env(safe-area-inset-bottom,0px));' + 'width:180px;height:180px;border-radius:50%;z-index:99958;pointer-events:none;' + 'background:radial-gradient(circle at 50% 50%, rgba(18,22,34,.44), rgba(10,13,20,.17) 62%, rgba(10,13,20,0) 74%);'; document.body.appendChild(house); } P.pad = { // 역할 선언 → 슬롯 배정. 선점 시 ORDER 순 다음 빈 슬롯으로 결정론 스필(임의 좌표 불가). // 반환 = 배정된 슬롯명(4슬롯 만원이면 null → 호출부가 rail 로 강등). face: function (el, role, opts) { opts = opts || {}; var want = ROLE[role] || 'west', slot = null; if (!taken[want]) slot = want; else for (var i = 0; i < ORDER.length; i++) { if (!taken[ORDER[i]]) { slot = ORDER[i]; break; } } if (!slot) return null; taken[slot] = el; var s = SLOTS[slot], size = opts.size || s.size; el.innerHTML = SHAPE[slot]; // 글리프 = 슬롯 소유(호출부의 아이콘을 승계하지 않는다) _reg.push({ el: el, glyph: SHAPE[slot], label: opts.label || null, ring: s.ring, glyphc: s.glyphc, face: true }); el.className = 'pbtn pbtn--pad ' + s.cls + (opts.cls ? ' ' + opts.cls : ''); el.style.cssText = 'right:' + (s.cx - size / 2) + 'px;bottom:calc(' + (s.cy - size / 2) + 'px + env(safe-area-inset-bottom,0px));width:' + size + 'px;height:' + size + 'px;' + (opts.extra || ''); _housing(); return slot; }, // 유틸 동사(캐릭터 교체/비행/레시피/스킬/건설) = 숄더 레일 — 페이스 다이아몬드와 구조적 무충돌. rail: function (el, opts) { opts = opts || {}; _rail(); var size = opts.size || 46; // 레일 버튼은 의미 아이콘을 유지한다(실제 패드의 L/R 처럼 도형 정체성이 없는 유틸 계열) if (opts.label) _reg.push({ el: el, glyph: el.innerHTML, label: opts.label, face: false }); el.className = 'pbtn pbtn--pad pbtn--rail' + (opts.cls ? ' ' + opts.cls : ''); // pill = 라벨을 가진 문맥 프롬프트(상호작용 등) — 폭 자동, 레일 flex 가 배치를 전담한다. el.style.cssText = 'position:relative;right:auto;bottom:auto;left:auto;top:auto;pointer-events:auto;' + (opts.pill ? 'width:auto;height:44px;padding:0 18px;border-radius:14px;font:700 15px/1 system-ui,sans-serif;' : 'width:' + size + 'px;height:' + size + 'px;') + (opts.extra || ''); rail.appendChild(el); return rail; }, // 슬롯 반납 — 주 동사 승계(전투/사격이 world 부수기를 대체) 시 선행자를 비워야 후행자가 south 를 // 받는다. 반납 없이 face() 하면 후행자가 점프 슬롯으로 밀려 배치가 무너진다. release: function (el) { for (var k in taken) { if (taken[k] === el) delete taken[k]; } for (var r = _reg.length - 1; r >= 0; r--) { if (_reg[r].el === el) _reg.splice(r, 1); } // 범례에서도 제거(승계된 구 동사) }, // 설치된 버튼의 범례 — {glyph, label} 목록. label 은 [ko,ja,en] 삼중이거나 문자열. // 화면에서 사라진(숨겨진) 버튼은 제외한다(승계로 죽은 동사를 설명하지 않는다). legend: function () { var out = []; for (var i = 0; i < _reg.length; i++) { var r = _reg[i]; if (!r.label || !r.el || !r.el.isConnected) continue; var st = getComputedStyle(r.el); if (st.display === 'none' || st.visibility === 'hidden') continue; out.push({ glyph: r.glyph, label: r.label, ring: r.ring || 'rgba(255,255,255,.25)', glyphc: r.glyphc || '#f0f3f8' }); } return out; }, // 클러스터 존(레인 스킬 열·건설 팔레트 등 다중 버튼 패널) = 숄더 레일 위 밴드. 좌표를 직접 쓰면 // 주 동사 버튼을 피복하는 실결함이 반복됐다(lane R 버튼 불능) — 밴드를 레지스트리가 소유한다. zone: function (el, opts) { opts = opts || {}; el.style.cssText = 'position:fixed;right:16px;bottom:calc(330px + env(safe-area-inset-bottom,0px));' + 'z-index:99952;display:flex;flex-direction:column;align-items:flex-end;gap:8px;' + (opts.extra || ''); return el; }, rail_el: function () { return _rail(); }, // 클러스터(스킬/건설 패널)가 직접 담을 컨테이너 }; // 종료 상태 선언(공용) — 아키타입의 _end 가 호출한다. 상시 배너 + 조작/HUD 시각 비활성화로 // "끝났다"가 화면에서 자명해진다(일시 토스트만으론 사라진 뒤 멈춘 화면과 구분되지 않았다). P.game_over = function (msg, won) { try { if (document.exitPointerLock) document.exitPointerLock(); } catch (e) {} // 종료 = 조준 모드 OFF(커서 복귀 — 사용자 확정 UX) try { document.body.setAttribute('data-pend', '1'); } catch (e) {} // 조준 UI 즉시 소거(실보고 2026-07-27: 락 해제는 비동기 — pointerlockchange 대기 중 배너와 겹침) — // id 계약으로 배지/재개 오버레이를 동프레임 제거(겹침이 표현 불가능). try { var _ah = document.getElementById('playable-aimhud'); if (_ah) _ah.remove(); } catch (e) {} try { var _ar = document.getElementById('playable-aim-resume'); if (_ar) _ar.remove(); } catch (e) {} var old = document.getElementById('playable-ended'); if (old) { try { old.remove(); } catch (e) {} } var el = document.createElement('div'); el.id = 'playable-ended'; var row = document.createElement('div'); row.style.cssText = 'display:flex;align-items:center;justify-content:center;gap:9px;'; row.innerHTML = '
' + (won ? '🏆' : '🛑') + '
'; row.lastChild.textContent = String(msg || '').slice(0, 90); el.appendChild(row); // (종료 배너의 획득 CTA 부착은 2026-07-27 사용자 확정으로 제거 — 배너 = [게임 종료] 단일 액션, // 획득 CTA 는 오프닝 최하단으로 이동(camo _mode_select). P._cta_btn 자체는 시트/배지가 계속 소비.) // 게임 종료 = 메인(오프닝)으로 — 파라미터 소거 리로드(무결 복귀 캐논, 사용자 확정 2026-07-27) var xb = document.createElement('button'); xb.type = 'button'; // 라벨 6언어: P._t6(획득 훅 블록) 존재 시 위임, 부재 시 자체 최소 판별(additive). var _xl = { ko: '게임 종료', ja: 'ゲーム終了', en: 'Exit game', es: 'Salir del juego', hans: '结束游戏', hant: '結束遊戲' }; if (P._t6) xb.textContent = P._t6(_xl); else { var _lg = String(document.documentElement.lang || 'en').toLowerCase(); xb.textContent = _lg.indexOf('ko') === 0 ? _xl.ko : _lg.indexOf('ja') === 0 ? _xl.ja : _lg.indexOf('es') === 0 ? _xl.es : (_lg.indexOf('zh') === 0 ? (_lg.indexOf('hant') >= 0 || _lg.indexOf('tw') >= 0 || _lg.indexOf('hk') >= 0 ? _xl.hant : _xl.hans) : _xl.en); } xb.style.cssText = 'display:block;align-self:stretch;padding:9px 10px;border-radius:11px;border:1px solid rgba(255,255,255,.22);' + 'background:rgba(255,255,255,.06);color:#cbd2dc;font:700 12.5px/1.2 system-ui,sans-serif;touch-action:manipulation;cursor:pointer;'; xb.addEventListener('click', function (e) { e.stopPropagation(); try { location.href = location.pathname; } catch (err) { location.reload(); } }); el.appendChild(xb); el.style.borderColor = won ? 'rgba(250,204,21,.45)' : 'rgba(248,113,113,.45)'; document.body.appendChild(el); }; function _rail() { // 숄더 레일 컨테이너 확보(지연 생성 — 유틸 버튼이 없는 게임은 DOM 무증가) if (!rail) { rail = document.createElement('div'); rail.id = 'playable-pad-rail'; // wrap-reverse = 좁은 화면에서 넘치지 않고 위로 접힌다(넘침/겹침이 표현 불가능). rail.style.cssText = 'position:fixed;right:16px;bottom:calc(272px + env(safe-area-inset-bottom,0px));' + 'z-index:99959;display:flex;flex-direction:row-reverse;flex-wrap:wrap-reverse;align-items:flex-end;' + 'justify-content:flex-start;max-width:min(320px, calc(100vw - 28px));gap:9px;pointer-events:none;'; document.body.appendChild(rail); } return rail; } })(); (function () { var mb = document.createElement('button'); mb.id = 'playable-mute'; mb.type = 'button'; mb.innerHTML = P._mute_svg(false); // 인라인 SVG — 이모지 글리프의 기기별 렌더 비일관 봉합 mb.className = 'pbtn'; mb.style.cssText = 'left:14px;top:calc(14px + env(safe-area-inset-top,0px));width:40px;height:40px;'; // 탭 = 사운드 메뉴(배경음악 끄기·바꾸기 + 전체 음소거) — 2026-07-17 사용자 요청. // 즉시-음소거였던 구 동작은 메뉴 항목으로 이동(음악만/전체를 분리 제어). function _snd_t(ko, ja, en) { var l = String(((typeof navigator !== 'undefined' && (navigator.language || navigator.userLanguage)) || document.documentElement.lang || 'en')).toLowerCase(); return l.indexOf('ko') === 0 ? ko : (l.indexOf('ja') === 0 ? ja : en); } var _snd_back = null; function _snd_close() { if (_snd_back && _snd_back.parentNode) _snd_back.parentNode.removeChild(_snd_back); _snd_back = null; } function _snd_open() { _snd_close(); _snd_back = document.createElement('div'); _snd_back.style.cssText = 'position:fixed;inset:0;z-index:99964;background:rgba(4,6,12,.3);'; _snd_back.addEventListener('pointerdown', function (e) { e.preventDefault(); e.stopPropagation(); _snd_close(); }); var panel = document.createElement('div'); panel.className = 'pmenu'; // 일시 표시 메뉴만 blur 허용(공유 스타일 캐논) panel.style.cssText = 'position:fixed;left:14px;top:calc(62px + env(safe-area-inset-top,0px));z-index:99965;' + 'display:flex;flex-direction:column;gap:6px;padding:10px;width:214px;' + 'background:linear-gradient(170deg, rgba(20,26,40,.94), rgba(8,11,19,.96));' + 'border:1px solid rgba(255,255,255,.12);border-radius:14px;' + 'box-shadow:0 18px 48px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.1);' + 'backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);'; panel.addEventListener('pointerdown', function (e) { e.stopPropagation(); }); function _item(icon, label, fn) { var b = document.createElement('button'); b.type = 'button'; b.className = 'pmenu-item'; b.innerHTML = '
' + icon + '
' + '
' + label + '
'; b.style.cssText = 'display:flex;align-items:center;gap:9px;padding:9px 10px;border-radius:11px;' + 'border:1px solid rgba(255,255,255,.1);background:rgba(255,255,255,.045);color:#eef1f6;' + 'font:600 12.5px/1.2 system-ui,sans-serif;text-align:left;touch-action:manipulation;'; b.addEventListener('click', function (e) { e.stopPropagation(); fn(); }); panel.appendChild(b); } _item('🎵', P.music.playing() ? _snd_t('배경음악 끄기', 'BGMをオフ', 'Music off') : _snd_t('배경음악 켜기', 'BGMをオン', 'Music on'), function () { P.music.toggle(); _snd_close(); }); _item('🔁', _snd_t('배경음악 바꾸기', 'BGMを変える', 'Change music'), function () { P.music.next(); _snd_close(); try { P.toast(_snd_t('배경음악을 바꿨어요 🎶', 'BGMを変えました 🎶', 'Music changed 🎶')); } catch (e) {} }); _item('🔇', P._muted ? _snd_t('전체 소리 켜기', 'すべての音をオン', 'Unmute all') : _snd_t('전체 소리 끄기', 'すべての音をオフ', 'Mute all'), function () { P.mute(); _snd_close(); }); _snd_back.appendChild(panel); document.body.appendChild(_snd_back); } mb.addEventListener('pointerdown', function (e) { e.preventDefault(); e.stopPropagation(); if (_snd_back) _snd_close(); else _snd_open(); }); if (document.body) document.body.appendChild(mb); else document.addEventListener('DOMContentLoaded', function () { document.body.appendChild(mb); }); })(); // ── 오버플로 메뉴(P.menu) — 비필수 어포던스(브랜드 소개·기록 공유·캐릭터 교체)를 ⋯ 하나로 접어 // 화면엔 필수 데이터만 남긴다(사용자 요청 2026-07-22). 뮤트(빠른 토글)는 밖에 유지. P.pad 와 // 동형: 소비자는 항목을 *등록*만 하고 배치/열림은 레지스트리가 소유한다. 항목이 하나도 없으면 // ⋯ 버튼 자체를 만들지 않는다(DOM 무증가). backdrop-filter 는 일시 표시 패널(.pmenu)만. (function () { var _items = [], _back = null, _btn = null; function _close() { if (_back && _back.parentNode) _back.parentNode.removeChild(_back); _back = null; } function _open() { _close(); _back = document.createElement('div'); _back.style.cssText = 'position:fixed;inset:0;z-index:99964;background:rgba(4,6,12,.3);'; _back.addEventListener('pointerdown', function (e) { e.preventDefault(); e.stopPropagation(); _close(); }); var panel = document.createElement('div'); panel.className = 'pmenu'; panel.style.cssText = 'position:fixed;left:14px;top:calc(62px + env(safe-area-inset-top,0px));z-index:99965;' + 'display:flex;flex-direction:column;gap:6px;padding:10px;width:min(258px, calc(100vw - 28px));' + 'background:linear-gradient(170deg, rgba(20,26,40,.94), rgba(8,11,19,.96));' + 'border:1px solid rgba(255,255,255,.12);border-radius:14px;' + 'box-shadow:0 18px 48px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.1);' + 'backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);'; panel.addEventListener('pointerdown', function (e) { e.stopPropagation(); }); _items.forEach(function (it) { var row = document.createElement('button'); row.type = 'button'; row.className = 'pmenu-item'; row.style.cssText = 'display:flex;align-items:center;gap:11px;padding:12px;border-radius:11px;' + 'border:1px solid rgba(255,255,255,.1);background:rgba(255,255,255,.04);color:#eef1f6;' + 'font:600 13.5px/1.2 system-ui,sans-serif;text-align:left;cursor:pointer;'; var ic = document.createElement('span'); ic.style.cssText = 'flex:0 0 20px;display:inline-flex;align-items:center;justify-content:center;color:#f6d9a0;'; ic.innerHTML = it.svg || ''; var tx = document.createElement('span'); // 라벨 배열 = [ko,ja,en,es?,zh-hans?,zh-hant?] — 4번째 이후는 옵션(구 3항 배열은 en 폴백, 하위호환). tx.textContent = Array.isArray(it.label) ? P._t6({ ko: it.label[0], ja: it.label[1], en: it.label[2], es: it.label[3], hans: it.label[4], hant: it.label[5] }) : String(it.label || ''); row.appendChild(ic); row.appendChild(tx); row.addEventListener('pointerdown', function (e) { e.preventDefault(); e.stopPropagation(); _close(); try { it.onSelect(); } catch (x) {} }); panel.appendChild(row); }); _back.appendChild(panel); (document.body || document.documentElement).appendChild(_back); } function _ensure_btn() { if (_btn) return; _btn = document.createElement('button'); _btn.id = 'playable-menu'; _btn.type = 'button'; _btn.className = 'pbtn'; _btn.style.cssText = 'left:62px;top:calc(14px + env(safe-area-inset-top,0px));width:40px;height:40px;'; _btn.innerHTML = '
'; _btn.addEventListener('pointerdown', function (e) { e.preventDefault(); e.stopPropagation(); if (_back) _close(); else _open(); }); (document.body || document.documentElement).appendChild(_btn); } P.menu = { // add({ label:[ko,ja,en,es?,zh-hans?,zh-hant?]|str, svg?, onSelect }) — 항목 등록(호출 = ⋯ 버튼 지연 생성). add: function (item) { if (!item || typeof item.onSelect !== 'function') return; _items.push(item); _ensure_btn(); }, // labels() — 등록된 항목의 안정 키(영문 라벨 = 인덱스 2, 6언어 확장과 무관하게 불변). 검증 게이트가 "특정 어포던스 존재/부재"를 확인. labels: function () { return _items.map(function (it) { return Array.isArray(it.label) ? it.label[2] : String(it.label || ''); }); }, }; })(); // ── 'Made with somodus' 배지 + 나만의 게임 CTA — 산출물 자체가 획득 접점이 되는 접합부. // 상시 훅 칩 = .pbtn 계열(backdrop-filter 금지 캐논 준수, 좌상단 ⋯ 버튼 우측 열 — 미션 칩은 // world._chip_bound 가 #playable-brand 실측 rect 를 좌측 경계로 회피, 우상단 미니맵/우하단 // 공유·액션 존과 무충돌). CTA 시트 = 일시 표시라 .pmenu 캐논(사운드 메뉴 동형). // 새 탭 진입은 서빙 CSP sandbox 의 allow-popups(-to-escape-sandbox) 전제(content_card 원문 보기 동일). // 새 fill 토큰 없음 — 라이브 셸 채널로 기존 함대에 재배포 없이 전파되는 변경만 사용. // CTA 3표면(문안/URL/스타일 소유 = 이 블록 단일): ①상시 훅 칩(탭=시트 — 오탭이 외부 이탈로 // 직결되지 않는 1단 완충) ②종료 배너 CTA(P.game_over 가 P._cta_btn 지연 호출 — 충동 정점 직행) // ③⋯ 메뉴 'somodus 소개'. utm_content 로 표면별 전환 계측 분리. (function () { // 상시 배지는 2026-07-22 '필수 데이터만 상시 노출'로 폐기됐다가 2026-07-27 사용자 결정 // (플레이어→제작자 훅 전면 배치)으로 재도입 — 폐기 사유였던 미션 칩 겹침은 수동 회피가 아니라 // 실측 경계(world._chip_bound) + 게이트(camohideseek smoke 상단 밴드 목록)로 구조 봉합. // 착지 승격(2026-07-27) — 스토어 설치 워커의 생성물은 playable_generate 가 head 에 자기 스토어 // id 를 주입(window.__SMDS_STORE_ID__). 존재+형식 유효 시 훅 착지를 그 에이전트의 스토어 상세로 // (bare-id → 엣지 301 이 로컬라이즈된 슬러그 URL 로 정규화 = 6언어 랜딩 상속), 아니면 범용 홈. function _cta_url(src) { var sid = (typeof window.__SMDS_STORE_ID__ === 'string' && /^[a-f0-9]{24}$/.test(window.__SMDS_STORE_ID__)) ? window.__SMDS_STORE_ID__ : ''; return (sid ? 'https://somodus.com/store/agent/' + sid : 'https://somodus.com/') + '?utm_source=playable&utm_medium=badge&utm_campaign=ingame_cta&utm_content=' + src; } // 획득 접점 카피 = 6언어 필수(P._t6 — 사용자 결정 2026-07-27). 셸 공용 3언어 _t 를 쓰지 않는다. // 획득 CTA 버튼 팩토리(내부 API — 종료 배너/시트가 공용, LLM 비노출) — src = utm_content 표면 키. P._cta_btn = function (src) { if (!document.getElementById('playable-cta-anim')) { // 훅 애니메이션 1회 주입(샤인 스윕 + 글로우 맥동) var _cs = document.createElement('style'); _cs.id = 'playable-cta-anim'; _cs.textContent = '@keyframes pctaSweep{0%{transform:translateX(-150%) skewX(-18deg)}55%,100%{transform:translateX(320%) skewX(-18deg)}}' + '@keyframes pctaGlow{0%,100%{box-shadow:0 8px 22px rgba(255,150,40,.35),inset 0 2px 0 rgba(255,255,255,.5),inset 0 -3px 0 rgba(0,0,0,.22)}50%{box-shadow:0 10px 30px rgba(255,180,60,.6),inset 0 2px 0 rgba(255,255,255,.6),inset 0 -3px 0 rgba(0,0,0,.22)}}'; document.head.appendChild(_cs); } var b = document.createElement('button'); b.type = 'button'; // 훅 카피(2026-07-27 사용자 확정 리서치): 효용(나만의 게임) + 무노력 약속(말 한마디) + 즉시성 — 6언어 필수 var _hook = P._t6({ ko: '나만의 게임, 말 한마디면 완성', ja: '自分のゲーム、ひとことで完成', en: 'Your own game — one sentence away', es: 'Tu propio juego con una sola frase', hans: '一句话,做出你自己的游戏', hant: '一句話,做出你自己的遊戲' }); b.style.cssText = 'position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;gap:7px;padding:12px 14px;border-radius:13px;' + 'border:0;background:linear-gradient(180deg,#ffe98a 0%,#ffb52e 55%,#ff8a3d 100%);color:#3a2405;' + 'font:800 13.5px/1.3 system-ui,sans-serif;word-break:keep-all;text-align:center;touch-action:manipulation;align-self:stretch;cursor:pointer;' + 'animation:pctaGlow 2.6s ease-in-out infinite;'; b.innerHTML = '
' + '
' + '
✨
→
'; b.children[2].textContent = _hook; b.addEventListener('click', function (e) { e.stopPropagation(); try { window.open(_cta_url(src), '_blank', 'noopener'); } catch (err) {} }); return b; }; var _brand_back = null; function _brand_close() { if (_brand_back && _brand_back.parentNode) _brand_back.parentNode.removeChild(_brand_back); _brand_back = null; } function _brand_open() { _brand_close(); _brand_back = document.createElement('div'); _brand_back.style.cssText = 'position:fixed;inset:0;z-index:99964;background:rgba(4,6,12,.3);'; _brand_back.addEventListener('pointerdown', function (e) { e.preventDefault(); e.stopPropagation(); _brand_close(); }); var panel = document.createElement('div'); panel.className = 'pmenu'; panel.style.cssText = 'position:fixed;left:14px;top:calc(62px + env(safe-area-inset-top,0px));z-index:99965;' + 'display:flex;flex-direction:column;gap:8px;padding:14px;width:238px;' + 'background:linear-gradient(170deg, rgba(20,26,40,.94), rgba(8,11,19,.96));' + 'border:1px solid rgba(255,255,255,.12);border-radius:14px;' + 'box-shadow:0 18px 48px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.1);' + 'backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);color:#eef1f6;' + 'font:500 12.5px/1.6 system-ui,sans-serif;'; panel.addEventListener('pointerdown', function (e) { e.stopPropagation(); }); var h = document.createElement('div'); h.style.cssText = 'font:700 13.5px/1.4 system-ui,sans-serif;'; h.textContent = P._t6({ ko: '이 게임은 AI가 만들었어요', ja: 'このゲームはAIが作りました', en: 'This game was made by AI', es: 'Este juego fue creado por IA', hans: '这个游戏由 AI 制作', hant: '這個遊戲由 AI 製作' }); var d = document.createElement('div'); d.style.cssText = 'color:#aeb6c6;'; d.textContent = P._t6({ ko: 'somodus에서 문장 하나로 나만의 게임과 AI 에이전트를 만들 수 있어요.', ja: 'somodusでは文章ひとつで自分のゲームやAIエージェントが作れます。', en: 'On somodus, one sentence builds your own game or AI agent.', es: 'En somodus, una sola frase crea tu propio juego o agente de IA.', hans: '在 somodus,一句话就能打造你自己的游戏和 AI 智能体。', hant: '在 somodus,一句話就能打造你自己的遊戲和 AI 代理。' }); var cta = P._cta_btn('sheet'); cta.addEventListener('click', function () { _brand_close(); }); panel.appendChild(h); panel.appendChild(d); panel.appendChild(cta); _brand_back.appendChild(panel); document.body.appendChild(_brand_back); } P.menu.add({ label: ['somodus 소개', 'somodus について', 'About somodus', 'Acerca de somodus', '关于 somodus', '關於 somodus'], svg: '
', // ⚡ 대체 인라인 SVG(글리프 깨짐 회피) onSelect: _brand_open, }); // 상시 훅 칩 #playable-brand — P._ui.Z.brand 존 점유. 탭 = 시트 토글(사운드 메뉴 동형 pointerdown). var badge = document.createElement('button'); badge.id = 'playable-brand'; badge.type = 'button'; badge.className = 'pbtn pbtn--rail'; badge.style.cssText = 'left:110px;top:calc(14px + env(safe-area-inset-top,0px));height:40px;padding:0 12px;gap:6px;' + 'font:700 12px/1 system-ui,sans-serif;color:#ffd98a;border-color:rgba(245,158,11,.45);'; badge.innerHTML = '
'; badge.lastChild.textContent = P._t6({ ko: '나도 만들기', ja: '自分も作る', en: 'Make yours', es: 'Crea el tuyo', hans: '我也要做', hant: '我也要做' }); badge.addEventListener('pointerdown', function (e) { e.preventDefault(); e.stopPropagation(); if (_brand_back) _brand_close(); else _brand_open(); }); if (P._ui) P._ui.claim('brand'); (document.body || document.documentElement).appendChild(badge); })(); // ── 배경음악 P.music(mood) — 큐레이션 CC0 루프(플랫폼 CDN audio-pack)의 유일 재생 경로 ── // 정본 근거(실측 리서치): // ·
는 루프 이음새 갭(~80ms, 디코더 리셋) → AudioBufferSourceNode(loop+loopStart/End)만 심리스. // · iOS 무음 스위치는 Web Audio 를 ringer 채널로 음소거 → 무음
루프로 세션을 playback // 카테고리로 전환(unmute 패턴). 무음 소스는 런타임 합성 WAV(외부 파일/base64 블롭 없음). // · 디코드 PCM 상주 메모리: 스테레오 2분 = 42MB(iOS 크래시 실증) → 팩이 모노·90초 이하로 큐레이션되고 // 여기서 상주 버퍼 1개 계약을 집행(트랙 교체 시 이전 소스/버퍼 참조 즉시 해제). // · 로딩은 첫 제스처 이후에만(초기 로드 예산 보호 — preload 금지). 실패 = 조용한 열화(게임 불중단). // mood 는 팩 manifest 의 closed enum: calm_exploration | adventure | battle | cozy | mystery | // night | triumph | chiptune_action var _AUDIO_PACK_BASE = 'https://somodus.com/vendor/audio-pack/'; // 조립 최종 fill 에서 실 CDN base 로 치환(잔존 불가) function _actx() { return window.__zzfx_ctx || null; } var _music = { want: null, vol: 0.5, manifest: null, src: null, gain: null, mood: null, loading: false }; function _music_manifest(cb) { if (_music.manifest) return cb(_music.manifest); fetch(_AUDIO_PACK_BASE + 'audio_manifest.v5.json') // 불변 캐시 하 변이 금지 — 내용 변경 시 반드시 새 파일명(vendor.lock 게이트). v5 = lofi_2(Chiptune DnB 오분류·빠른 템포)를 chiptune_action 으로 이적(2026-07-25 캄-오프닝 실보고). 다음 실변경은 v6 로. .then(function (r) { return r.json(); }) .then(function (m) { _music.manifest = m; cb(m); }) .catch(function () { cb(null); }); } // iOS 무음 스위치 우회 — 합성 무음 WAV 를
루프로 재생(첫 제스처 시 1회, 음악 사용 게임만). var _silent_el = null; function _ios_playback_session() { if (_silent_el) return; try { if (navigator.audioSession) { navigator.audioSession.type = 'playback'; _silent_el = true; return; } // Safari 17+ var rate = 8000, n = (rate * 0.05) | 0, buf = new ArrayBuffer(44 + n * 2), v = new DataView(buf); function wstr(o, s) { for (var i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)); } wstr(0, 'RIFF'); v.setUint32(4, 36 + n * 2, true); wstr(8, 'WAVE'); wstr(12, 'fmt '); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true); v.setUint32(24, rate, true); v.setUint32(28, rate * 2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true); wstr(36, 'data'); v.setUint32(40, n * 2, true); var bytes = new Uint8Array(buf), b64 = ''; for (var i = 0; i < bytes.length; i++) b64 += String.fromCharCode(bytes[i]); var el = document.createElement('audio'); el.src = 'data:audio/wav;base64,' + btoa(b64); el.loop = true; var pr = el.play(); if (pr && pr.catch) pr.catch(function () {}); _silent_el = el; } catch (e) {} } function _music_stop_src(fade) { var ctx = _actx(); if (!_music.src) return; var src = _music.src, g = _music.gain; _music.src = null; _music.gain = null; _music.mood = null; // 상주 버퍼 1개 계약 — 참조 즉시 해제 try { if (ctx && g) { g.gain.setTargetAtTime(0.0001, ctx.currentTime, Math.max(0.05, (fade || 0.4) / 3)); setTimeout(function () { try { src.stop(); src.disconnect(); g.disconnect(); } catch (e) {} }, ((fade || 0.4) * 1000 + 150) | 0); } else { src.stop(); } } catch (e) {} } function _music_start(mood, pick) { // pick = 명시 트랙(music.next 트랙 순환 전용) — 부재 시 무드 내 랜덤 var ctx = _actx(); if (!ctx || _music.loading) return; _music.loading = true; _music_manifest(function (man) { if (!man) { _music_fail_next(mood); return; } // 네트워크 실패 = 조용한 열화(게임 불중단) var list = man.moods && man.moods[mood]; if (!list || !list.length) { // 결정론 저작 오류(closed enum 위반)는 네트워크 실패와 채널 분리 — LOUD 진단 try { console.error('[music] unknown mood "' + mood + '" — available: ' + Object.keys(man.moods || {}).join(', ')); } catch (e) {} _music_fail_next(mood); return; } var t = pick || list[(Math.random() * list.length) | 0]; fetch(_AUDIO_PACK_BASE + t.file) .then(function (r) { return r.arrayBuffer(); }) .then(function (ab) { // 콜백형 decodeAudioData — 프라미스형 미지원 구형 Safari 포함 전 브라우저 호환. ctx.decodeAudioData(ab, function (buffer) { _music.loading = false; if (_music.want !== mood) { if (_music.want) _music_start(_music.want); return; } // 로딩 중 교체/중지 요청 존중 _music_stop_src(0.3); var src = ctx.createBufferSource(); src.buffer = buffer; src.loop = true; if (t.loop_start != null) src.loopStart = t.loop_start; if (t.loop_end != null) src.loopEnd = t.loop_end; var g = ctx.createGain(); g.gain.setValueAtTime(0.0001, ctx.currentTime); g.gain.setTargetAtTime(_music.vol, ctx.currentTime, 0.35); // 페이드-인(급습 방지) src.connect(g); g.connect(ctx.destination); src.start(); _music.src = src; _music.gain = g; _music.mood = mood; _music.file = t.file; }, function () { _music_fail_next(mood); }); }) .catch(function () { _music_fail_next(mood); }); }); } // 실패 시 대기 중인 다음 요청 재발송 — "a 로딩 실패 후 b 를 아무도 시작하지 않는" 영구 무음 차단 function _music_fail_next(mood) { _music.loading = false; if (_music.want && _music.want !== mood) _music_start(_music.want); } P.music = function (mood, opts) { try { mood = String(mood || ''); if (!mood) return; if (opts && typeof opts.volume === 'number') _music.vol = Math.max(0, Math.min(1, opts.volume)); if (_music.mood === mood && _music.src) { _music.want = mood; return; } // 재생 중 재호출 — want 도 갱신(로딩 중 교체 역전 차단) _music.want = mood; P._on_unlock(function () { _ios_playback_session(); if (_music.want && _music.mood !== _music.want) _music_start(_music.want); }); } catch (e) {} }; P.music.stop = function () { _music.want = null; _music_stop_src(0.6); }; P.music.volume = function (v) { _music.vol = Math.max(0, Math.min(1, +v || 0)); var ctx = _actx(); if (ctx && _music.gain) { try { _music.gain.gain.setTargetAtTime(_music.vol, ctx.currentTime, 0.1); } catch (e) {} } }; // ── 배경음악 트랙 순환/토글(사운드 메뉴 소비 — 2026-07-17 사용자 요청) ── // next(): 현재 무드의 다른 트랙 → 안정(calm) 패밀리 순으로 순환. 심신-안정 트랙이 순환의 // 대부분을 차지하도록 풀을 구성(무드 정체성 유지: 현재 무드 트랙이 항상 선두). var _MUSIC_CALM_POOL = ['lofi', 'calm_exploration', 'cozy', 'night', 'happy']; P.music.next = function () { _music_manifest(function (man) { if (!man || !man.moods) return; var order = [_music.mood || _music.want || 'lofi'].concat(_MUSIC_CALM_POOL); var pool = [], seen = {}; for (var i = 0; i < order.length; i++) { var lst = man.moods[order[i]] || []; for (var k = 0; k < lst.length; k++) { if (seen[lst[k].file]) continue; seen[lst[k].file] = 1; pool.push({ mood: order[i], t: lst[k] }); } } if (!pool.length) return; var at = -1; for (var j = 0; j < pool.length; j++) if (pool[j].t.file === _music.file) at = j; var nxt = pool[(at + 1) % pool.length]; _music.want = nxt.mood; P._on_unlock(function () { _ios_playback_session(); _music_start(nxt.mood, nxt.t); }); }); }; // toggle(): 배경음악만 끄기/켜기(전체 음소거 P.mute 와 분리 채널). 끈 무드를 기억해 복귀, // 음악 없던 게임에서 켜면 안정 기본(lofi)으로 시작. P.music.toggle = function () { if (_music.want || _music.src) { _music._resume = _music.mood || _music.want; P.music.stop(); return false; } var back = _music._resume; _music._resume = null; P.music(back || 'lofi'); return true; }; P.music.playing = function () { return !!(_music.want || _music.src); }; // fx 덕킹 훅 — 고강도 임팩트 순간 음악을 잠깐 낮춰 타격 SFX 를 앞세운다(다운 30ms/복귀 ~0.5s 정본). P._music_duck = function (intensity) { var ctx = _actx(); if (!ctx || !_music.gain) return; var i = Math.max(0, Math.min(1, +intensity || 0.5)); try { _music.gain.gain.setTargetAtTime(_music.vol * (1 - 0.55 * i), ctx.currentTime, 0.03); _music.gain.gain.setTargetAtTime(_music.vol, ctx.currentTime + 0.12, 0.25); } catch (e) {} }; // ── 프로시저럴 앰비언스 P.ambience(name) — 파일 0바이트 합성(루프 이음새 자체가 없음) ── // 정본 레시피: 루핑 노이즈 베드(Paul Kellett 핑크노이즈 근사/브라운) → BiquadFilter → 저주파 LFO 변조 // + 랜덤 원샷 레이어(새·귀뚜라미 처프, 물방울 — oscillator 주파수 스윕 스케줄러). // name: forest | wind | rain | night | ocean | river | cave. 명시 호출 = 동시 1개(교체 시 해체). // + 자동 사운드스케이프 레이어(P.ambience._auto — nature 가 장면 감지로 구동, 다층 동시): // 레이어별 독립 {gain, nodes, timers} 컨텍스트(L). 명시 P.ambience()/stop() = 소유권 이관 // (자동 전 레이어 해체 + 이후 자동 비활성). 빌드 유틸은 전부 L 을 첫 인자로 받는다. var _amb = { nodes: [], timers: [], gain: null, name: null, user: false }; var _amb_autos = []; // 자동 레이어 핸들 목록(소유권 이관 시 일괄 해체) function _noise_buffer(kind) { var ctx = _actx(); if (!ctx) return null; var len = (ctx.sampleRate * 3) | 0, buf = ctx.createBuffer(1, len, ctx.sampleRate), d = buf.getChannelData(0); var b0 = 0, b1 = 0, b2 = 0, b3 = 0, b4 = 0, b5 = 0, b6 = 0, last = 0, i, w; for (i = 0; i < len; i++) { w = Math.random() * 2 - 1; if (kind === 'brown') { last = (last + 0.02 * w) / 1.02; d[i] = last * 3.5; } else { // pink — Paul Kellett 필터 근사(업계 정본 구현) b0 = 0.99886 * b0 + w * 0.0555179; b1 = 0.99332 * b1 + w * 0.0750759; b2 = 0.969 * b2 + w * 0.153852; b3 = 0.8665 * b3 + w * 0.3104856; b4 = 0.55 * b4 + w * 0.5329522; b5 = -0.7616 * b5 - w * 0.016898; d[i] = (b0 + b1 + b2 + b3 + b4 + b5 + b6 + w * 0.5362) * 0.11; b6 = w * 0.115926; } } return buf; } function _amb_bed(L, kind, cutoff, level, lfo_hz, lfo_depth) { var ctx = _actx(), buf = _noise_buffer(kind); if (!ctx || !buf || !L.gain) return; var src = ctx.createBufferSource(); src.buffer = buf; src.loop = true; var filt = ctx.createBiquadFilter(); filt.type = 'lowpass'; filt.frequency.value = cutoff; var g = ctx.createGain(); g.gain.value = 0.0001; g.gain.setTargetAtTime(level, ctx.currentTime, 0.8); // 느린 페이드-인 src.connect(filt); filt.connect(g); g.connect(L.gain); if (lfo_hz) { // 컷오프 저주파 흔들림 = "살아있는" 바람의 핵심(정적 노이즈와의 지각 차이) var lfo = ctx.createOscillator(), lg = ctx.createGain(); lfo.frequency.value = lfo_hz; lg.gain.value = cutoff * (lfo_depth || 0.3); lfo.connect(lg); lg.connect(filt.frequency); lfo.start(); L.nodes.push(lfo, lg); } src.start(); L.nodes.push(src, filt, g); } function _amb_chirp(L, f0, f1, dur, vol) { var ctx = _actx(); if (!ctx || !L.gain || L.dead) return; // stop 직후 지연 처프 차단 var o = ctx.createOscillator(), g = ctx.createGain(), t = ctx.currentTime; o.frequency.setValueAtTime(f0, t); o.frequency.exponentialRampToValueAtTime(Math.max(40, f1), t + dur); g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(vol, t + dur * 0.3); g.gain.exponentialRampToValueAtTime(0.0001, t + dur); o.connect(g); g.connect(L.gain); o.start(t); o.stop(t + dur + 0.02); } function _amb_schedule(L, min_s, max_s, fire) { var slot = L.timers.length; // 스케줄러 체인당 고정 슬롯 1개(재예약 시 덮어쓰기 — stop 이 일괄 해제) L.timers.push(0); function next() { L.timers[slot] = setTimeout(function () { try { fire(); } catch (e) {} next(); }, (min_s + Math.random() * (max_s - min_s)) * 1000); } next(); } var _AMB_PRESETS = { wind: function (L) { _amb_bed(L, 'pink', 480, 0.16, 0.13, 0.5); }, forest: function (L) { _amb_bed(L, 'pink', 650, 0.09, 0.18, 0.4); _amb_schedule(L, 2.5, 7, function () { // 새 처프 2~3회 묶음 var n = 2 + (Math.random() * 2 | 0), base = 2300 + Math.random() * 700; for (var i = 0; i < n; i++) { (function (i) { setTimeout(function () { _amb_chirp(L, base + Math.random() * 300, base * 0.8, 0.09, 0.05); }, i * 130); })(i); } }); }, rain: function (L) { _amb_bed(L, 'pink', 3800, 0.13, 0.09, 0.15); }, night: function (L) { _amb_bed(L, 'pink', 320, 0.05, 0.1, 0.3); _amb_schedule(L, 0.6, 1.6, function () { // 귀뚜라미 — 규칙성 있는 고주파 펄스 트레인 for (var i = 0; i < 3; i++) { (function (i) { setTimeout(function () { _amb_chirp(L, 4300, 4100, 0.045, 0.028); }, i * 70); })(i); } }); }, ocean: function (L) { _amb_bed(L, 'brown', 420, 0.22, 0.07, 0.8); }, river: function (L) { // 개울 졸졸 — 밝은 브라운 베드 + 상승 버블 처프(불규칙 고빈도) _amb_bed(L, 'brown', 1500, 0.13, 0.32, 0.45); _amb_schedule(L, 0.15, 0.55, function () { _amb_chirp(L, 500 + Math.random() * 500, 850 + Math.random() * 700, 0.05 + Math.random() * 0.04, 0.016); }); }, crowd: function (L) { // 경기장 관중 — 브라운노이즈 웅성 베드 + 저주기 스웰(스포츠 정본) _amb_bed(L, 'brown', 900, 0.14, 0.05, 0.5); _amb_bed(L, 'pink', 2200, 0.05, 0.09, 0.3); }, cave: function (L) { _amb_bed(L, 'brown', 160, 0.17, 0.05, 0.4); _amb_schedule(L, 3, 9, function () { _amb_chirp(L, 900, 340, 0.35, 0.06); }); // 낙수 }, }; function _amb_layer_stop(L) { L.dead = true; for (var i = 0; i < L.timers.length; i++) clearTimeout(L.timers[i]); L.timers.length = 0; for (var j = 0; j < L.nodes.length; j++) { try { if (L.nodes[j].stop) L.nodes[j].stop(); } catch (e) {} try { L.nodes[j].disconnect(); } catch (e) {} } L.nodes.length = 0; if (L !== _amb && L.gain) { try { L.gain.disconnect(); } catch (e) {} L.gain = null; } } function _amb_autos_stop() { for (var i = 0; i < _amb_autos.length; i++) _amb_layer_stop(_amb_autos[i]._L); _amb_autos.length = 0; } P.ambience = function (name) { try { name = String(name || ''); if (!_AMB_PRESETS[name]) { try { console.error('[ambience] unknown preset "' + name + '" — valid: ' + Object.keys(_AMB_PRESETS).join(', ')); } catch (e) {} return; } _amb.user = true; // 명시 호출 = 사운드스케이프 소유권 이관(자동 레이어 전 해체 + 이후 자동 비활성) _amb_autos_stop(); if (_amb.name === name) return; P.ambience.stop(); _amb.name = name; P.ambience.current = name; // 현재 프리셋 관측면 — 상황 전환(craft 동굴 등)의 복귀 재료 P._on_unlock(function () { var ctx = _actx(); if (!ctx || _amb.name !== name) return; if (!_amb.gain) { _amb.gain = ctx.createGain(); _amb.gain.gain.value = 1; _amb.gain.connect(ctx.destination); } _amb.dead = false; _AMB_PRESETS[name](_amb); }); } catch (e) {} }; P.ambience.stop = function () { _amb.user = true; // 명시 정지 = 무음 의도 — 자동도 함께 침묵 _amb_autos_stop(); _amb.name = null; _amb.dead = true; // 지연 처프 가드(dead 는 다음 명시 시작에서 해제) for (var i = 0; i < _amb.timers.length; i++) clearTimeout(_amb.timers[i]); _amb.timers.length = 0; for (var j = 0; j < _amb.nodes.length; j++) { try { if (_amb.nodes[j].stop) _amb.nodes[j].stop(); } catch (e) {} try { _amb.nodes[j].disconnect(); } catch (e) {} } _amb.nodes.length = 0; }; // 자동 사운드스케이프 레이어(내부 API — nature 의 장면 감지가 소비, LLM 비노출). // 반환 핸들: gain(v)=목표 게인 스무스 추종, stop(). 명시 P.ambience 호출 후엔 null(소유권 이관). P.ambience._auto = function (name, gain0) { try { if (_amb.user || !_AMB_PRESETS[name]) return null; var L = { nodes: [], timers: [], gain: null, dead: false }; var H = { _L: L, gain: function (v) { var ctx = _actx(); if (L.gain && ctx) { try { L.gain.gain.setTargetAtTime(Math.max(0.0001, v), ctx.currentTime, 0.4); } catch (e) {} } }, stop: function () { _amb_layer_stop(L); var xi = _amb_autos.indexOf(H); if (xi >= 0) _amb_autos.splice(xi, 1); } }; _amb_autos.push(H); P._on_unlock(function () { var ctx = _actx(); if (!ctx || L.dead || _amb.user) return; L.gain = ctx.createGain(); L.gain.gain.value = Math.max(0.0001, typeof gain0 === 'number' && isFinite(gain0) ? gain0 : 1); L.gain.connect(ctx.destination); _AMB_PRESETS[name](L); }); return H; } catch (e) { return null; } }; // 토스트 var _toast_el = null, _toast_t = 0; P.toast = function (msg) { try { if (!_toast_el) { _toast_el = document.createElement('div'); _toast_el.id = 'playable-toast'; document.body.appendChild(_toast_el); } _toast_el.textContent = String(msg); _toast_el.classList.add('show'); clearTimeout(_toast_t); _toast_t = setTimeout(function () { _toast_el.classList.remove('show'); }, 1800); } catch (e) {} }; // 공유 — 스포일러-프리 텍스트 아티팩트(이모지 격자/스코어) 복사가 1순위, Web Share 가능 시 시트. P.share = function (text) { var t = String(text == null ? '' : text); var url = location.href; var payload = t ? (t + '\n' + url) : url; function copy() { var done = function () { P.toast('Copied!'); }; try { if (navigator.clipboard && navigator.clipboard.writeText) { return navigator.clipboard.writeText(payload).then(done, function () { legacy(); }); } } catch (e) {} legacy(); function legacy() { try { var ta = document.createElement('textarea'); ta.value = payload; ta.setAttribute('style', 'position:fixed;opacity:0;'); document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); done(); } catch (e) { P.toast(payload.slice(0, 80)); } } } try { if (navigator.share) { return navigator.share({ text: t, url: url }).catch(function () { copy(); }); } } catch (e) {} copy(); return Promise.resolve(); }; // 기록 공유 — 상시 플로팅 버튼 폐기(P.pad 다이아몬드와 겹침) → 오버플로 메뉴(⋯) 항목으로 이동 // (사용자 요청 2026-07-22). 게임 로직은 get_text(현재 결과의 공유 텍스트)만 공급. 한 게임에서 여러 번 // 호출되면 최신 get_text 로 갱신(중복 항목 방지). var _share_get = null, _share_registered = false; P.mount_share = function (get_text, label, svg) { // svg = 메뉴 아이콘 오버라이드(옵션, additive — 예: 친구 공유 아이콘) try { _share_get = get_text; if (_share_registered) return null; _share_registered = true; P.menu.add({ label: label ? String(label) : ['기록 공유', '記録を共有', 'Share result', 'Compartir resultado', '分享成绩', '分享成績'], svg: svg || '
', onSelect: function () { var t = ''; try { t = typeof _share_get === 'function' ? _share_get() : String(_share_get || ''); } catch (e) {} P.share(t); }, }); return null; } catch (e) { return null; } }; // 콘텐츠 카드 — 수집/발견 순간에 실데이터(뉴스·사실·기록)를 보여주는 셸 소유 오버레이. // 게임 로직은 데이터만 공급(표시 UI 는 셸 계약 — LLM 재량 UI 의 품질 편차 흡수). // image 는 DOM
(CORS 불요 — 단, 재호스팅 URL 권장: 외부 직링크는 핫링크/만료 파손), // url 은 "원문 보기" 새 탭(window.open — 서빙 CSP 의 allow-popups 가 전제). 배경 탭 = 닫기. P.content_card = function (opts) { try { var o = opts || {}; var lang = String(((typeof navigator !== 'undefined' && (navigator.language || navigator.userLanguage)) || document.documentElement.lang || 'en')).slice(0, 2); var TXT = { ko: { open: '원문 보기', close: '닫기' }, ja: { open: '元記事を見る', close: '閉じる' }, en: { open: 'Read more', close: 'Close' }, }; var t = TXT[lang] || TXT.en; var old = document.getElementById('playable-content-card'); if (old) old.remove(); var ov = document.createElement('div'); ov.id = 'playable-content-card'; ov.style.cssText = 'position:fixed;inset:0;z-index:99982;display:flex;align-items:center;' + 'justify-content:center;background:rgba(6,8,14,.66);padding:20px;'; var card = document.createElement('div'); card.style.cssText = 'max-width:min(88vw,440px);max-height:78vh;overflow-y:auto;' + 'background:rgba(13,17,27,.96);border:1px solid rgba(255,229,138,.35);border-radius:16px;' + 'padding:0 0 16px;color:#f5f7fa;font:500 14px/1.7 system-ui,sans-serif;' + 'box-shadow:0 12px 40px rgba(0,0,0,.5);'; if (typeof o.image === 'string' && /^(https?:\/\/|\/)/.test(o.image)) { // 상대 /i/{key} 허용(동일 오리진) var img = document.createElement('img'); img.src = o.image; img.style.cssText = 'display:block;width:100%;max-height:38vh;object-fit:cover;' + 'border-radius:15px 15px 0 0;'; img.onerror = function () { img.remove(); }; card.appendChild(img); } var pad = document.createElement('div'); pad.style.cssText = 'padding:16px 20px 0;'; function _clip(v, n) { v = String(v || ''); return v.length > n ? v.slice(0, n - 1) + '…' : v; } // 침묵 절단 금지 — 잘림을 가시화 function _el(tag, txt, css) { if (!txt) return; var d = document.createElement(tag); d.textContent = String(txt); if (css) d.style.cssText = css; pad.appendChild(d); } _el('div', _clip(o.title, 120), 'font-weight:800;font-size:17px;line-height:1.4;margin-bottom:8px;'); _el('div', _clip(o.body, 600), 'color:rgba(245,247,250,.85);'); _el('div', _clip(o.source, 60), 'margin-top:10px;font-size:12px;color:rgba(245,247,250,.5);'); card.appendChild(pad); var row = document.createElement('div'); row.style.cssText = 'display:flex;gap:10px;justify-content:flex-end;padding:14px 20px 0;'; function _btn(label, primary, fn) { var b = document.createElement('button'); b.type = 'button'; b.textContent = label; b.style.cssText = 'padding:10px 18px;border-radius:999px;font:700 13px system-ui,sans-serif;cursor:pointer;' + (primary ? 'background:#F59E0B;border:none;color:#1a1206;' : 'background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.22);color:#f5f7fa;'); b.onclick = fn; row.appendChild(b); } if (typeof o.url === 'string' && /^https?:\/\//.test(o.url)) { _btn(t.open, true, function () { try { window.open(o.url, '_blank', 'noopener'); } catch (e) {} }); } _btn(t.close, false, function () { ov.remove(); }); card.appendChild(row); ov.appendChild(card); ov.addEventListener('click', function (e) { if (e.target === ov) ov.remove(); }); // 백드롭도 click 시점(동일 고스트클릭 봉합) document.body.appendChild(ov); return ov; } catch (e) { try { console.error('[playable] content_card error: ' + (e && e.message)); } catch (_) {} return null; } }; // 완료 순간 공유 카드 — 캡처 프레임(캔버스) + 기록 텍스트를 한 장의 이미지로 합성해 오버레이로 // 제시하고 Web Share(파일)/텍스트 공유로 내보낸다. CSP sandbox 에 allow-downloads 가 없어 // a[download] 는 불가 — 저장은 이미지 길게 누르기(모바일)/우클릭(데스크톱) 안내가 정본. // 사용: PLAYABLE.three.capture().then(function (c) { PLAYABLE.share_shot(c, { title, lines, share_text }); }) P.share_shot = function (src, opts) { try { try { if (document.exitPointerLock) document.exitPointerLock(); } catch (e0) {} // 공유 카드 = DOM 조작 국면 — 조준 락 무조건 해제(커서 없이는 공유/닫기 클릭 불가, 사용자 확정 UX) var o = opts || {}; if (!src || !src.width) { P.share(String(o.share_text || '')); return null; } var lang = String(((typeof navigator !== 'undefined' && (navigator.language || navigator.userLanguage)) || document.documentElement.lang || 'en')).slice(0, 2); var TXT = { ko: { save: '이미지를 길게 눌러 저장하세요', save_desk: '이미지를 우클릭해 저장하세요', share: '친구에게 공유', close: '닫기' }, ja: { save: '画像を長押しして保存', save_desk: '画像を右クリックして保存', share: '友達にシェア', close: '閉じる' }, en: { save: 'Long-press the image to save', save_desk: 'Right-click the image to save', share: 'Share with friends', close: 'Close' }, }; var t = TXT[lang] || TXT.en; var touch = ('ontouchstart' in window) || ((navigator.maxTouchPoints || 0) > 0); // ── 카드 합성: 프레임 원본 위 하단 밴드(그라디언트) + 제목/기록 라인 + 워터마크 ── var W = src.width, H = src.height; var card = document.createElement('canvas'); card.width = W; card.height = H; var g = card.getContext('2d'); g.drawImage(src, 0, 0); var bh = Math.round(H * 0.24); var grd = g.createLinearGradient(0, H - bh, 0, H); grd.addColorStop(0, 'rgba(8,11,18,0)'); grd.addColorStop(0.45, 'rgba(8,11,18,.72)'); grd.addColorStop(1, 'rgba(8,11,18,.92)'); g.fillStyle = grd; g.fillRect(0, H - bh, W, bh); var fs = Math.max(14, Math.round(W * 0.052)); var pad = Math.round(W * 0.055); var y = H - bh + Math.round(bh * 0.34); g.textBaseline = 'middle'; g.shadowColor = 'rgba(0,0,0,.5)'; g.shadowBlur = Math.round(fs * 0.4); g.fillStyle = '#f5f7fa'; g.font = '800 ' + fs + 'px system-ui, sans-serif'; g.fillText(String(o.title || document.title || '').slice(0, 40), pad, y); var lines = Array.isArray(o.lines) ? o.lines.slice(0, 2) : []; g.fillStyle = '#FFE58A'; g.font = '700 ' + Math.round(fs * 0.86) + 'px system-ui, sans-serif'; for (var li = 0; li < lines.length; li++) { y += Math.round(fs * 1.35); g.fillText(String(lines[li]).slice(0, 60), pad, y); } g.shadowBlur = 0; g.fillStyle = 'rgba(245,247,250,.55)'; g.font = '600 ' + Math.round(fs * 0.62) + 'px system-ui, sans-serif'; g.textAlign = 'right'; g.fillText('somodus.com', W - pad, H - Math.round(bh * 0.16)); g.textAlign = 'left'; // ── 오버레이 시트 ── var old = document.getElementById('playable-share-shot'); if (old) old.remove(); var ov = document.createElement('div'); ov.id = 'playable-share-shot'; ov.style.cssText = 'position:fixed;inset:0;z-index:99985;display:flex;flex-direction:column;' + 'align-items:center;justify-content:center;gap:14px;background:rgba(6,8,14,.78);padding:20px;'; var img = document.createElement('img'); img.src = card.toDataURL('image/png'); img.style.cssText = 'max-width:min(86vw,480px);max-height:62vh;border-radius:14px;' + 'border:1px solid rgba(255,229,138,.35);box-shadow:0 12px 40px rgba(0,0,0,.5);'; ov.appendChild(img); var hint = document.createElement('div'); hint.textContent = touch ? t.save : t.save_desk; hint.style.cssText = 'color:rgba(245,247,250,.75);font:500 13px system-ui,sans-serif;'; ov.appendChild(hint); var row = document.createElement('div'); row.style.cssText = 'display:flex;gap:10px;'; function _mkbtn(target, label, primary, fn, extra) { var b = document.createElement('button'); b.type = 'button'; b.textContent = label; b.style.cssText = 'padding:11px 20px;border-radius:999px;font:700 14px system-ui,sans-serif;cursor:pointer;' + (primary ? 'background:#F59E0B;border:none;color:#1a1206;' : 'background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.22);color:#f5f7fa;') + (extra || ''); b.onclick = fn; target.appendChild(b); return b; } function _btn(label, primary, fn) { return _mkbtn(row, label, primary, fn); } // 게임 소유 연속-플레이 액션(additive 계약: {label, sub?, icon?, style?, primary?, on(ov)} ≤3). // icon/sub 보유 = 프리미엄 렌더(풀폭 스택 + 상단 시린 + 글로시 샤인 스윕 애니메이션 — 공유/닫기 // 필 위의 주 CTA 위계, 사용자 확정 2026-07-25 "보는것만으로 설레게"). 구형(라벨만) = 필 폴백. var acts = Array.isArray(o.actions) ? o.actions.slice(0, 3) : []; if (acts.length) { if (!document.getElementById('playable-shot-anim')) { var _ast = document.createElement('style'); _ast.id = 'playable-shot-anim'; _ast.textContent = '@keyframes pshotSweep{0%{transform:translateX(-140%) skewX(-18deg)}55%,100%{transform:translateX(340%) skewX(-18deg)}}' + '@keyframes pshotRise{0%{opacity:0;transform:translateY(16px)}100%{opacity:1;transform:translateY(0)}}'; document.head.appendChild(_ast); } var arow = document.createElement('div'); arow.style.cssText = 'display:flex;flex-direction:column;gap:10px;align-items:stretch;width:min(86vw,380px);'; for (var ai = 0; ai < acts.length; ai++) (function (a, i9) { if (!a.icon && !a.sub) { _mkbtn(arow, String(a.label || '').slice(0, 28), !!a.primary, function () { try { if (a.on) a.on(ov); } catch (e9) {} }, a.style ? String(a.style) : '') ; return; } var b = document.createElement('button'); b.type = 'button'; b.style.cssText = 'position:relative;overflow:hidden;display:flex;align-items:center;gap:13px;width:100%;padding:13px 18px;' + 'border:0;border-radius:18px;cursor:pointer;text-align:left;touch-action:manipulation;-webkit-tap-highlight-color:transparent;' + 'animation:pshotRise .5s cubic-bezier(.2,.8,.2,1) ' + (0.08 + i9 * 0.09) + 's both;' + (a.style ? String(a.style) : 'background:#F59E0B;color:#1a1206;'); b.innerHTML = '
' + '
' + (a.icon ? '
' + a.icon + '
' : '') + '
' + '
' + String(a.label || '').slice(0, 28) + '
' + (a.sub ? '
' + String(a.sub).slice(0, 20) + '
' : '') + '
' + '
›
'; b.onclick = function () { try { if (a.on) a.on(ov); } catch (e9) {} }; arow.appendChild(b); })(acts[ai], ai); ov.appendChild(arow); } var share_text = String(o.share_text || o.title || '').slice(0, 160); _btn(t.share, true, function () { card.toBlob(function (blob) { try { if (blob && navigator.canShare && navigator.share) { var f = new File([blob], 'somodus-play.png', { type: 'image/png' }); if (navigator.canShare({ files: [f] })) { // 이미지 *단독* 공유 — files+text+url 혼합은 iOS(특히 텔레그램 인앱 브라우저)가 // URL 을 우선해 "App Store 의 앱이 필요한 링크" 경고로 빠지는 실사고. 제목·기록· // somodus.com 은 카드 이미지에 이미 박혀 있어 정보 손실 없음(사진 공유와 동일 UX). navigator.share({ files: [f] }).catch(function () {}); return; } } } catch (e) {} P.share(share_text); // 파일 공유 미지원 환경 = 텍스트 공유 정본으로 }, 'image/png'); }); _btn(t.close, false, function () { ov.remove(); }); ov.appendChild(row); // ── 최하단 획득 CTA(사용자 확정 2026-07-28 "공유 카드에도") — 공유 카드는 전 게임 공통의 종결 // 획득 접점이라 렌더러가 단일 소유(아키타입별 중복 금지). 카피 = 오프닝 CTA 정본과 동일 문안, // 6언어(P._t6 존재 시 위임, 부재 시 자체 최소 판별 — game_over 종료 버튼과 동일 additive 계약). (function () { var _cm = { ko: '코딩 없이, 내가 원하는 게임 만들기', ja: 'コーディングなしで、思いどおりのゲームを作る', en: 'Build the game you want — no coding', es: 'Crea el juego que quieras — sin programar', hans: '不用写代码,做出你想要的游戏', hant: '不用寫程式,做出你想要的遊戲' }; var _ct; if (P._t6) _ct = P._t6(_cm); else { var _cl = String(((typeof navigator !== 'undefined' && (navigator.language || navigator.userLanguage)) || document.documentElement.lang || 'en')).toLowerCase(); _ct = _cl.indexOf('ko') === 0 ? _cm.ko : _cl.indexOf('ja') === 0 ? _cm.ja : _cl.indexOf('es') === 0 ? _cm.es : (_cl.indexOf('zh') === 0 ? (_cl.indexOf('hant') >= 0 || _cl.indexOf('tw') >= 0 || _cl.indexOf('hk') >= 0 ? _cm.hant : _cm.hans) : _cm.en); } if (!document.getElementById('playable-shot-anim')) { // 스윕 키프레임 — actions 미보유 카드(구형 게임)에서도 CTA 는 항상 쓰므로 자체 보장 var _cst = document.createElement('style'); _cst.id = 'playable-shot-anim'; _cst.textContent = '@keyframes pshotSweep{0%{transform:translateX(-140%) skewX(-18deg)}55%,100%{transform:translateX(340%) skewX(-18deg)}}' + '@keyframes pshotRise{0%{opacity:0;transform:translateY(16px)}100%{opacity:1;transform:translateY(0)}}'; document.head.appendChild(_cst); } var cb = document.createElement('button'); cb.type = 'button'; cb.style.cssText = 'position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;gap:8px;width:min(86vw,380px);padding:11px 14px;margin-top:2px;' + 'border:0;border-radius:15px;cursor:pointer;touch-action:manipulation;-webkit-tap-highlight-color:transparent;word-break:keep-all;' + 'background:linear-gradient(180deg,#8fe8ff 0%,#3fb6d8 55%,#1f7ea6 100%);color:#062a38;' + 'box-shadow:0 10px 26px rgba(63,182,216,.38),inset 0 2px 0 rgba(255,255,255,.5),inset 0 -3px 0 rgba(0,0,0,.2);' + 'animation:pshotRise .5s cubic-bezier(.2,.8,.2,1) .35s both;'; cb.innerHTML = '
' + '
' + '
✨
→
'; cb.children[3].textContent = _ct; // [글로스, 스윕, ✨, 라벨, →] — 라벨 = 4번째(카피는 textContent 주입 = 마크업 이스케이프 불요) cb.onclick = function () { try { window.open('https://somodus.com/?utm_source=playable&utm_medium=share_card&utm_campaign=ingame_cta&utm_content=end_card', '_blank', 'noopener'); } catch (e9) {} }; ov.appendChild(cb); })(); document.body.appendChild(ov); return ov; } catch (e) { try { console.error('[playable] share_shot error: ' + (e && e.message)); } catch (_) {} return null; } }; // 문자 행렬 스프라이트 — rows: ["..XX..", ...], palette: {"X":"#f59e0b", ...}. '.'/' ' = 투명. // 반환: 캔버스(scale 배 확대, 픽셀 크리스프). 도트 그래픽의 유일 승인 에셋 경로(base64 금지). P.sprite = function (rows, palette, scale) { scale = Math.max(1, Math.floor(scale || 1)); var h = rows.length, w = 0, y, x; for (y = 0; y < h; y++) w = Math.max(w, rows[y].length); var c = document.createElement('canvas'); c.width = w * scale; c.height = h * scale; var g = c.getContext('2d'); g.imageSmoothingEnabled = false; for (y = 0; y < h; y++) { for (x = 0; x < rows[y].length; x++) { var ch = rows[y][x]; if (ch === '.' || ch === ' ') continue; var col = palette && palette[ch]; if (!col) continue; g.fillStyle = col; g.fillRect(x * scale, y * scale, scale, scale); } } return c; }; // 게임 루프 — fn(dt초, 경과초, raw_dt초). dt 는 0.05 로 clamp(백그라운드 복귀 대점프 차단). // dt = 게임 시간(fx 히트스톱 중 0 — juice 계층이 시간을 소유), raw_dt = 벽시계(DRS 등 계측용). // elapsed 는 raw 누적(연출 동결이 워밍업/계측 타이머를 왜곡하지 않게). stop 함수 반환. // // 구조(감사 봉합): 콜백마다 독립 rAF 체인을 돌리던 구조(three 게임 8개+, 낭비·순서 비계약)를 // 단일 rAF 드라이버 + 등록 순서 실행으로 통합. 렌더는 별도 최종 페이즈(P._loop_render — 셸 // 내부 전용) — 같은 프레임의 모든 시뮬레이션(월드/전투/카메라/로직) *이후* 에 그려, 렌더 루프가 // 먼저 등록되어 항상 직전 프레임 상태를 그리던 상시 1프레임 표시 지연(FPS 조준 지연 체감)을 // 제거한다. world 의 L4 퇴화 방어선("틱 진입 카메라 = 직전 렌더 상태" 관측)은 렌더-최종에서도 // 성립한다: 틱 진입 시점의 카메라는 여전히 직전 프레임 렌더에 쓰인 상태다. // 크래시 의미론은 종전과 동일: 던진 콜백만 정지(overlay+비콘), 나머지는 계속. var _loop_sim = [], _loop_render = [], _loop_started = false, _loop_last = 0; function _loop_run(list, raw, frozen) { var n = list.length; // 프레임 중 등록된 콜백은 다음 프레임부터(종전 rAF 의미론 유지) for (var i = 0; i < n; i++) { var en = list[i]; if (!en.on) continue; en.elapsed += raw; try { en.fn(frozen ? 0 : raw, en.elapsed, raw); } catch (e) { en.on = false; window.__playable_error_overlay((e && (e.stack || e.message)) || String(e)); // 루프 크래시는 overlay 직접 호출이라 전역 error 리스너 비콘을 타지 않는다 — 여기서 박제. if (window.__playable_beacon) window.__playable_beacon('loop_crash', (e && e.message) || String(e)); } } } function _loop_frame(ts) { requestAnimationFrame(_loop_frame); if (!_loop_last) { _loop_last = ts; return; } var raw = Math.min(0.05, (ts - _loop_last) / 1000); _loop_last = ts; // _modal_freeze = 셸 모달(아바타 페인트 등)이 열린 동안 게임 시간 동결(dt=0) — hitstop 과 동일 // 의미론의 장기 버전. 칠하는 동안 술래/타이머가 진행되면 모달이 곧 페널티가 된다(실보고 2026-07-23). var frozen = !!(P.fx && P.fx._frozen && P.fx._frozen()) || !!P._modal_freeze; _loop_run(_loop_sim, raw, frozen); _loop_run(_loop_render, raw, frozen); } function _loop_add(list, fn) { var en = { fn: fn, elapsed: 0, on: true }; list.push(en); if (!_loop_started) { _loop_started = true; requestAnimationFrame(_loop_frame); } return function stop() { en.on = false; }; } P.loop = function (fn) { return _loop_add(_loop_sim, fn); }; P._loop_render = function (fn) { return _loop_add(_loop_render, fn); }; // 셸 내부 전용(three 렌더 패스) // 뷰포트 — 기저 = *layout viewport*(innerWidth/H). 캔버스 CSS(fixed inset:0)가 layout 기준이므로 // 버퍼/투영도 같은 기저여야 한다. 구현이 visualViewport 값을 쓰던 시절, 핀치줌·키보드에서 두 // 뷰포트가 갈라져 "버퍼 축소 + aspect 왜곡 + 저해상" 클래스가 잠복했다(주소창 수축은 layout // resize 로도 발화하므로 visualViewport *값*은 불필요 — 수축 타이밍 보강용 리스너만 유지). P.vw = window.innerWidth; P.vh = window.innerHeight; var _resize_fns = []; P.on_resize = function (fn) { _resize_fns.push(fn); }; function _resized() { P.vw = window.innerWidth; P.vh = window.innerHeight; for (var i = 0; i < _resize_fns.length; i++) { try { _resize_fns[i](P.vw, P.vh); } catch (e) { P._hook_err(e); } } } window.addEventListener('resize', _resized); if (window.visualViewport) window.visualViewport.addEventListener('resize', _resized); P._emit_resize = _resized; // 키 입력 — 눌림 상태 Set + 콜백. (모바일 주 타깃이지만 데스크톱 공유 유입도 즉시 플레이 가능해야.) P.keys = new Set(); var _keydown_fns = [], _keyup_fns = []; P.on_key = function (down_fn, up_fn) { if (down_fn) _keydown_fns.push(down_fn); if (up_fn) _keyup_fns.push(up_fn); }; // 폼 입력 가드(2026-07-25 실보고: 방 이름/비밀번호 타이핑이 WASD 이동·Space 점프로 샜다) — // 텍스트 입력 요소가 이벤트 타깃이면 게임 키 채널 전체 차단(업계 정본: 게임은 폼 포커스 중 입력 무시). function _typing_target(e) { var t = e.target; if (!t) return false; var tg = t.tagName; return tg === 'INPUT' || tg === 'TEXTAREA' || tg === 'SELECT' || t.isContentEditable === true; } window.addEventListener('keydown', function (e) { if (_typing_target(e)) return; // P.keys 에도 미기입 — 타이핑 중 이동 잔류 차단 P.keys.add(e.key); for (var i = 0; i < _keydown_fns.length; i++) { try { _keydown_fns[i](e.key, e); } catch (err) { P._hook_err(err); } } }); window.addEventListener('keyup', function (e) { P.keys.delete(e.key); // 삭제는 무조건(포커스 이탈 시 잔류 키 방지) if (_typing_target(e)) return; for (var i = 0; i < _keyup_fns.length; i++) { try { _keyup_fns[i](e.key, e); } catch (err) { P._hook_err(err); } } }); // ── 방향 입력 단일 채널 — 좌우 반전 실수 클래스의 구조 흡수 ── // 로직이 스와이프 감지 수학·키 매핑을 재발명하면 부호 실수(좌우 반전)가 실측 재발한다. // 셸이 화살표/WASD/스와이프를 시맨틱 방향('left'|'right'|'up'|'down')으로 정규화해 단일 채널로 // 전달하고, 방향→X부호 매핑은 DIR_X 상수 데이터로 소유한다(사용 시 반전이 구조적으로 불가). // 불변식: 'left' = 화면 왼쪽 = 논리/월드 X 감소. (three 기본 카메라에서 world +X = 화면 오른쪽.) P.DIR_X = { left: -1, right: 1, up: 0, down: 0 }; var _dir_fns = []; P.on_dir = function (fn) { if (typeof fn === 'function') _dir_fns.push(fn); }; function _emit_dir(dir, e) { for (var i = 0; i < _dir_fns.length; i++) { try { _dir_fns[i](dir, e); } catch (err) { P._hook_err(err); } } } var _KEY_DIR = { ArrowLeft: 'left', ArrowRight: 'right', ArrowUp: 'up', ArrowDown: 'down', a: 'left', d: 'right', w: 'up', s: 'down', A: 'left', D: 'right', W: 'up', S: 'down', }; // on_dir 리스너가 등록된 게임에서만 개입 — 미사용 게임(dom 스크롤 페이지 등)의 화살표 기본 // 동작(스크롤)을 보존한다. window.addEventListener('keydown', function (e) { if (_typing_target(e)) return; // 폼 입력 가드(위 키 채널과 동일 캐논 — WASD/화살표가 입력창 타이핑에서 새지 않게) var dir = _KEY_DIR[e.key]; if (dir && _dir_fns.length) { _emit_dir(dir, e); e.preventDefault(); } }); // 스와이프: 셸 포인터 배관(bind_pointer)에 훅 — 변위 ≥24px 의 지배축으로 분류(탭은 방향 아님). var _SWIPE_MIN_PX = 24; // 포인터 클레임 — 스틱/카메라 드래그 등 다른 셸 계층이 소비한 포인터를 스와이프로 재해석하면 // 오발(전진 후 정지 = 스와이프-업)이 실측 재발한다. 소비자는 down 시점에 claim_pointer 로 선언. P._claimed_pointers = new Set(); P.claim_pointer = function (id) { P._claimed_pointers.add(id); }; P._swipe_track = (function () { var starts = new Map(); // pointerId → {x,y} — 단일 슬롯은 멀티터치에서 시작점이 덮여 쓰레기 방향 방출 return { down: function (e) { starts.set(e.pointerId, { x: e.clientX, y: e.clientY }); }, up: function (e) { var st = starts.get(e.pointerId); starts.delete(e.pointerId); var claimed = P._claimed_pointers.has(e.pointerId); if (claimed) { // 삭제는 지연 — 같은 pointerup 의 후속 리스너(sport 등)도 클레임을 판독해야 함 (function (id) { setTimeout(function () { P._claimed_pointers.delete(id); }, 0); })(e.pointerId); } if (!st || claimed) return; var dx = e.clientX - st.x, dy = e.clientY - st.y; if (Math.abs(dx) < _SWIPE_MIN_PX && Math.abs(dy) < _SWIPE_MIN_PX) return; _emit_dir(Math.abs(dx) >= Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : (dy > 0 ? 'down' : 'up'), e); }, }; })(); // 포인터 입력 — 셸 스테이지 요소에 touch-action:none + non-passive preventDefault 배관을 걸고 // (Chrome 56+/iOS 11.3+ 에서 터치 리스너 기본 passive — 이걸 모르는 로직 코드가 스크롤 버그의 주범), // down/move/up 을 단일 포인터로 정규화해 전달. 좌표는 스테이지 픽셀 기준(캔버스 프리셋은 셸이 // 논리 좌표로 재매핑해 P.pointer_map 을 덮어쓴다). P.pointer_map = function (x, y) { return { x: x, y: y }; }; P.bind_pointer = function (el, handlers) { handlers = handlers || {}; el.style.touchAction = 'none'; var active = false; function pos(e) { var r = el.getBoundingClientRect(); return P.pointer_map(e.clientX - r.left, e.clientY - r.top, r); } el.addEventListener('pointerdown', function (e) { active = true; try { el.setPointerCapture(e.pointerId); } catch (err) {} P._swipe_track.down(e); // 방향 채널(on_dir) 스와이프 분류 — 셸 소유 if (handlers.down) { try { handlers.down(pos(e), e); } catch (err) { P._hook_err(err); } } e.preventDefault(); }); el.addEventListener('pointermove', function (e) { if (handlers.move) { try { handlers.move(pos(e), e, active); } catch (err) { P._hook_err(err); } } }); function up(e) { if (!active) return; active = false; P._swipe_track.up(e); if (handlers.up) { try { handlers.up(pos(e), e); } catch (err) { P._hook_err(err); } } } el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); // iOS 구형 대비: 터치 스크롤/더블탭 줌 잔여 차단(non-passive). el.addEventListener('touchmove', function (e) { e.preventDefault(); }, { passive: false }); }; // ══ P.leaderboard — 게임키별 공용 리더보드(2026-07-21, 사용자 확정) ══ // 계약: 같은 게임 링크(/g/{key})를 여는 모두가 같은 보드에서 경쟁(바이럴 강화). 저장·판정은 // 전부 에지 DO(somodus-worker /g-lb/{key}) — 코어 무관, 게임별 격리. 클라이언트는 표시·제출만. // 우아한 부재(forward-only): game_key(=URL의 /g/{key}) 없거나(스모크·직접 실행) 엔드포인트 // 404 면 조용히 비활성 — 절대 크래시 없음. 닉네임 = 자유 닉네임 140자(코드포인트, 다국어·이모지 허용 // — 2026-07-25 사용자 확정; 구 3-이니셜 캐논 대체). XSS 봉인은 문자군 제한이 아니라 렌더 규율이 소유 // (textContent 전용 + DO 측 제어문자 소거) — 표시층은 단일행 ellipsis 로 레이아웃 불변. var _lb = { key: null, metric: '', unit: '', max: 1e12, hb: true, min_play_s: 3, t0: 0, submitted: false, initials: null, base: null }; (function () { try { var m = String(location.pathname || '').match(/\/g\/([a-f0-9]{8,64})/); if (m) { _lb.key = m[1]; _lb.base = location.origin + '/g-lb/' + m[1]; } } catch (e) {} })(); function _lb_initials_key() { return 'somodus_lb_initials'; } function _lb_get_initials() { try { return (P.storage && P.storage.get(_lb_initials_key())) || _lb.initials; } catch (e) { return _lb.initials; } } function _lb_set_initials(v) { _lb.initials = v; try { if (P.storage) P.storage.set(_lb_initials_key(), v); } catch (e) {} } function _lb_ask_initials(cb) { var prior = _lb_get_initials(); if (prior) { cb(prior); return; } var lang = String(((typeof navigator !== 'undefined' && (navigator.language || navigator.userLanguage)) || document.documentElement.lang || 'en')).slice(0, 2); var T = { ko: { t: '닉네임 (최대 140자)', s: '순위 등록', c: '취소' }, ja: { t: 'ニックネーム(最大140文字)', s: 'ランキング登録', c: 'キャンセル' }, en: { t: 'Nickname (up to 140 chars)', s: 'Join the ranking', c: 'Cancel' } }[lang] || { t: 'Nickname (up to 140 chars)', s: 'Join the ranking', c: 'Cancel' }; var ov = document.createElement('div'); ov.setAttribute('data-mgmt-ui', '1'); ov.style.cssText = 'position:fixed;inset:0;z-index:99991;display:flex;align-items:center;justify-content:center;background:rgba(8,10,16,.72)'; ov.innerHTML = '
' + '
' + T.t + '
' // 폰트 16px 고정 — iOS 는 16px 미만 입력 포커스 시 자동 확대(더블탭 줌 봉인과 별개 경로) + '
' + '
' + '
' + T.c + '
' + '
' + T.s + '
'; document.body.appendChild(ov); var inp = ov.querySelector('input'); // input 중 값 강제 치환 금지 — 한글/일어 IME 조합 중 value 재대입은 조합을 파괴한다(textarea clamp 캐논 동형). // 길이는 maxlength 속성, 위생(제어문자·공백 정규화·140 코드포인트)은 제출 시 1회. setTimeout(function () { try { inp.focus(); } catch (e) {} }, 30); ov.querySelector('[data-x]').addEventListener('click', function (e) { e.preventDefault(); ov.remove(); }); // click 시점 제거 — pointerdown 제거는 릴리즈 click 이 하부 요소에 착탄(고스트클릭, 2026-07-25 실보고) ov.querySelector('[data-ok]').addEventListener('click', function (e) { // click 시점(고스트클릭 봉합) e.preventDefault(); var v = String(inp.value || '').replace(/[\u0000-\u001f\u007f\u200b-\u200f\u2028\u2029\ufeff]/g, '').replace(/\s+/g, ' ').trim(); v = Array.from(v).slice(0, 140).join(''); if (!v) return; _lb_set_initials(v); ov.remove(); cb(v); }); } P.leaderboard = { // enable({ metric, unit, max, higher_better, min_play_s }) — 아키타입이 완주 배관 전에 1회 호출. enable: function (o) { o = o || {}; _lb.metric = String(o.metric || '').slice(0, 24); _lb.unit = String(o.unit || '').slice(0, 8); _lb.max = typeof o.max === 'number' && isFinite(o.max) ? o.max : 1e12; _lb.hb = o.higher_better !== false; _lb.min_play_s = typeof o.min_play_s === 'number' ? Math.max(0, o.min_play_s) : 3; _lb.t0 = (performance && performance.now) ? performance.now() : Date.now(); _lb.submitted = false; // 오버플로 메뉴에 "전체 리더보드 열기" 항목 — 활성 게임에서 종료 전에도 언제든 순위 확인 // (사용자 요청 2026-07-22). show()(인자 없음)는 GET 으로 전체 top-N 을 새로 받아 렌더한다. if (!_lb.menu_added && P.menu) { _lb.menu_added = true; P.menu.add({ label: ['게임 순위', 'ゲームランキング', 'Game Ranking'], svg: '
', onSelect: function () { P.leaderboard.show(); }, }); } return P.leaderboard; }, active: function () { return !!_lb.key; }, // submit(score) — 완주 시. 1차 방어(비용상향): min-play 게이트 + 아키타입 상한 clamp(2차는 DO). submit: function (score) { if (!_lb.key || _lb.submitted) return; var s = Number(score); if (!isFinite(s)) return; var elapsed = (((performance && performance.now) ? performance.now() : Date.now()) - _lb.t0) / 1000; if (elapsed < _lb.min_play_s) return; // 로드 직후 즉시 점수 = 명백한 조작, 조용히 무시 s = Math.max(0, Math.min(_lb.max, Math.floor(s))); _lb.submitted = true; _lb_ask_initials(function (ini) { try { fetch(_lb.base + '?hb=' + (_lb.hb ? 1 : 0), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ initials: ini, score: s }), }).then(function (r) { return r.json(); }).then(function (res) { if (res && res.ok) P.leaderboard.show(res); }).catch(function () {}); // 엔드포인트 부재/네트워크 실패 = 조용한 열화 } catch (e) {} }); }, // show(preloaded?) — top-N 오버레이(textContent 만 — XSS 불가). 미리 받은 결과 있으면 재fetch 생략. show: function (pre) { if (!_lb.key) return; function render(data, my_rank) { var rows = (data && data.rows) || []; var lang = String(((typeof navigator !== 'undefined' && (navigator.language || navigator.userLanguage)) || document.documentElement.lang || 'en')).slice(0, 2); var T = { ko: { t: '🏆 게임 순위', close: '닫기', empty: '아직 기록이 없어요 — 1등이 되어보세요!' }, ja: { t: '🏆 ゲームランキング', close: '閉じる', empty: 'まだ記録がありません — 1位を狙え!' }, en: { t: '🏆 Game Ranking', close: 'Close', empty: 'No scores yet — be the first!' } }[lang] || { t: '🏆 Game Ranking', close: 'Close', empty: 'No scores yet — be the first!' }; var old = document.getElementById('playable-leaderboard'); if (old) old.remove(); var ov = document.createElement('div'); ov.id = 'playable-leaderboard'; ov.setAttribute('data-mgmt-ui', '1'); ov.style.cssText = 'position:fixed;inset:0;z-index:99990;display:flex;align-items:center;justify-content:center;background:rgba(8,10,16,.74)'; var card = document.createElement('div'); card.style.cssText = 'width:min(84vw,340px);max-height:74vh;overflow:auto;background:#161b26;border:1px solid rgba(255,255,255,.16);border-radius:16px;padding:18px;color:#fff;font-family:system-ui'; var h = document.createElement('div'); h.textContent = T.t; h.style.cssText = 'font:800 18px system-ui;margin-bottom:12px;text-align:center'; card.appendChild(h); if (!rows.length) { var em = document.createElement('div'); em.textContent = T.empty; em.style.cssText = 'color:#9aa3b0;text-align:center;padding:14px 0'; card.appendChild(em); } rows.forEach(function (r, i) { var mine = (i + 1) === my_rank; var row = document.createElement('div'); row.style.cssText = 'display:flex;align-items:center;gap:10px;padding:7px 10px;border-radius:9px;margin-top:5px;font:700 14px system-ui;background:' + (mine ? 'rgba(245,158,11,.22)' : 'rgba(255,255,255,.04)'); var rk = document.createElement('span'); rk.textContent = '#' + (i + 1); rk.style.cssText = 'width:34px;color:' + (i < 3 ? '#ffd23e' : '#8a93a1'); var nm = document.createElement('span'); nm.textContent = String(r.i || '').slice(0, 280); nm.style.cssText = 'flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'; // textContent = XSS 불가 · 단일행 ellipsis(140cp 닉네임도 행 불변) var sc = document.createElement('span'); sc.textContent = Number(r.s || 0).toLocaleString() + (_lb.unit ? _lb.unit : ''); sc.style.cssText = 'color:#e8ecf4;font-variant-numeric:tabular-nums'; row.appendChild(rk); row.appendChild(nm); row.appendChild(sc); // 행 탭 = 전체 닉네임 펼침/접힘 토글(2026-07-25 사용자 확정 — 140자 닉네임이 ellipsis 로 잘리는 행) row.style.cursor = 'pointer'; row.addEventListener('pointerdown', function (e) { e.preventDefault(); var open9 = nm.style.whiteSpace !== 'nowrap'; nm.style.whiteSpace = open9 ? 'nowrap' : 'normal'; nm.style.overflow = open9 ? 'hidden' : 'visible'; nm.style.wordBreak = open9 ? '' : 'break-word'; // 공백 없는 초장문/URL 도 카드 폭 안에서 개행 }); card.appendChild(row); }); var cb = document.createElement('button'); cb.textContent = T.close; cb.style.cssText = 'margin-top:14px;width:100%;padding:11px;border-radius:10px;border:0;background:#2e3646;color:#e8ecf4;font:700 14px system-ui'; cb.addEventListener('click', function (e) { e.preventDefault(); ov.remove(); }); // click 시점 제거(고스트클릭 봉합 — 오프닝 순위창 닫기 = 게임 시작 실보고) card.appendChild(cb); ov.appendChild(card); ov.addEventListener('click', function (e) { if (e.target === ov) ov.remove(); }); // 백드롭도 click 시점(동일 고스트클릭 봉합) document.body.appendChild(ov); } if (pre && pre.rows) { render(pre, pre.rank); return; } try { fetch(_lb.base + '?hb=' + (_lb.hb ? 1 : 0)).then(function (r) { return r.json(); }).then(function (data) { render(data, -1); }).catch(function () {}); } catch (e) {} }, }; })(); // three 프리셋 부트스트랩 — 렌더러/씬/카메라/조명 환경/리사이즈/렌더 루프는 셸 소유. // (LLM three.js 실패 모드의 최대 지점 = 버전 드리프트한 부트스트랩 재발명 — 구조적으로 차단.) (function () { var P = window.PLAYABLE; var loading = document.getElementById('playable-loading'); if (typeof THREE === 'undefined') { window.__playable_error_overlay('3D engine failed to load. Please reload.'); return; } var canvas = document.getElementById('playable-canvas'); // WebGL 컨텍스트 생성 실패(구형 webview·GPU 차단 환경)는 vendor 내부 throw 라 cross-origin 마스킹 // ("Script error.")으로 뭉개진다 — 명시 try/catch 로 사람이 읽는 메시지를 fail loud. var renderer; try { // alpha only under the sensor flag (arcam camera composite needs a transparent backbuffer; // generator injects window['__PLAYABLE_'+'SENSOR__']=1 iff a sensor family is selected — unconditional // alpha would tax every game with a compositing blend for nothing). renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true, alpha: !!window['__PLAYABLE_'+'SENSOR__'] }); } catch (e) { window.__playable_error_overlay('3D (WebGL) is unavailable in this browser. Try opening the link in your default browser.' + ((e && e.message) ? '\n\n(' + String(e.message).slice(0, 160) + ')' : '')); return; } // ── 노출/색 관리는 셸이 강제 소유 (L1 예방 — 백색 포화 블로우아웃 구조적 차단) ── // ACES 필믹 톤매핑: HDR 값(고강도 emissive·IBL·블룸 합산)을 부드럽게 롤오프 — 톤매핑 부재가 // "화면 전체가 하얗게 타는" 실측 실패의 근본 원인. 로직은 renderer 설정을 만지지 않는다(게이트 차단). renderer.toneMapping = THREE.ACESFilmicToneMapping; // 기준 노출 — ACES 는 미드톤을 ~35% 누른다(sRGB 감각으로 저작된 팔레트가 일괄 저휘도로 깔리던 // "전 무드 공통 어두움"의 기저 원인, 2026-07-11 변인 계측: 지면 94→108). AE v2(포화-비율)의 // 중립 복귀 목표와 반드시 같은 상수여야 한다(하드코딩 분산 = 드리프트). var _EXP_BASE = 1.2; renderer.toneMappingExposure = _EXP_BASE; var _pr_max = Math.min(2, window.devicePixelRatio || 1); // 인앱 브라우저 서멀 방어 캡 var _pr = _pr_max; renderer.setPixelRatio(_pr); // ── 실시간 그림자 — 셸 소유 디폴트-온(2026-07-17 정책 전환: 구 전면 금지는 실측 없는 휴리스틱, // A14(2020)급 예산 ~1.5-2.5ms/프레임). 사양 봉인: 방향광 키 1개 + PCFShadowMap(PCFSoft = // 모바일 부적합, three#15577) + 1024 맵 + 앵커(플레이어∨카메라) 추종 타이트 프러스텀(광원 // 고정축 텍셀 스냅 = 쉬머 봉인). 캐스터 = 화이트리스트(world.spawn 모델·구조물 등 동적 // 액터만 — 인스턴스 식생/지형은 깊이 패스 정점 예산에서 구조 제외, 지형/데칼은 리시버만). // 감당 못 하는 기기는 DRS 사다리 0단이 자동 반납 → blob 그림자 복귀(= 종전 상태): 어떤 // 기기에서도 현행보다 나빠질 수 없는 비대칭. 로직의 renderer.shadowMap 접근은 종전대로 // 게이트 거부(셸 소유 불변 — 이 전환은 "금지 해제"가 아니라 "셸 능력화"다). renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFShadowMap; var _SHDW = { on: true, light: null, blobs: [], half: 36, texel: 72 / 1024 }; var _shdw_dir = new THREE.Vector3(-6, 9, 4).normalize(); // 키라이트 고정축(전 무드 공통 — 스냅 기저의 전제) var _shdw_right = new THREE.Vector3().crossVectors(_shdw_dir, new THREE.Vector3(0, 1, 0)).normalize(); var _shdw_up = new THREE.Vector3().crossVectors(_shdw_right, _shdw_dir).normalize(); var _shdw_v = new THREE.Vector3(); function _shadow_attach(light) { // mood 키라이트 재생성마다 재부착(구 라이트는 그룹째 제거) if (!_SHDW.on) return; light.castShadow = true; light.shadow.mapSize.set(1024, 1024); var sc = light.shadow.camera; sc.left = -_SHDW.half; sc.right = _SHDW.half; sc.top = _SHDW.half; sc.bottom = -_SHDW.half; sc.near = 1; sc.far = 160; light.shadow.bias = -0.0003; light.shadow.normalBias = 0.08; // flat-shaded 로우폴리 아크네 방어(1024 맵 텍셀 ~7cm 동계) _SHDW.light = light; } function _shadow_off() { // DRS 0단 전용 — blob(가짜 그림자)이 접지감을 승계, 다운-온리 if (!_SHDW.on) return false; _SHDW.on = false; renderer.shadowMap.enabled = false; if (_SHDW.light) _SHDW.light.castShadow = false; // 라이트 상태 해시 변경 = 재질 자동 리컴파일 for (var _sb = 0; _sb < _SHDW.blobs.length; _sb++) { try { _SHDW.blobs[_sb].visible = true; } catch (e) {} } return true; } function _shadow_set_axis(dir) { // Realistic-profile sun re-base — the axis stays FIXED between mood changes, so the texel-snap // shimmer seal's invariant ("axis never rotates per frame") is preserved; only the basis moves. _shdw_dir = dir.clone().normalize(); _shdw_right = new THREE.Vector3().crossVectors(_shdw_dir, new THREE.Vector3(0, 1, 0)).normalize(); _shdw_up = new THREE.Vector3().crossVectors(_shdw_right, _shdw_dir).normalize(); } P._shadow = { attach: _shadow_attach, state: _SHDW }; // 패밀리 브리지(비계약 — nature 태양 리그 승계용) var scene = new THREE.Scene(); scene.background = new THREE.Color(0x0b0e14); var camera = new THREE.PerspectiveCamera(60, 1, 0.1, 2000); camera.position.set(0, 2, 6); // 무자산 고품질 기본 조명 환경 — RoomEnvironment IBL(텍스처 0). 로직은 조명 재발명 불요 // (추가 라이트·배경(Sky)·fog 는 로직이 자유 구성 가능). // ⚠️ environmentIntensity 0.35 (실측 수리): RoomEnvironment 는 의도적으로 밝은 스튜디오(point // light 900)라 기본 강도 1 에서는 지면류가 grazing angle Fresnel(스치는 시야각에서 반사율→1)로 // 스튜디오를 거울 반사해 albedo 와 무관하게 선형 radiance ≥1 → 화면 워시아웃 + 전면 블룸 소스가 // 된다(도로가 하얗게 타는 실측 회귀의 주범). 0.35 는 다크/네온 베이스 무드의 실증값 — 밝은 주간 // 무드는 로직이 라이트(Directional/Hemisphere)+Sky 를 추가해 만든다. try { var pmrem = new THREE.PMREMGenerator(renderer); scene.environment = pmrem.fromScene(new THREE.RoomEnvironment(), 0.04).texture; scene.environmentIntensity = 0.35; } catch (e) { /* 환경맵 실패 시에도 씬은 렌더 — 로직 라이트로 열화 */ } function fit() { var w = P.vw, h = P.vh; renderer.setPixelRatio(_pr); renderer.setSize(w, h, false); camera.aspect = w / h; camera.updateProjectionMatrix(); if (P.three && P.three.composer) { // EffectComposer 는 생성 시점 pixelRatio 를 캐싱한다(r185 소스 확정) — 동기화 없이는 DRS 의 // pr 강등이 씬 라스터(컴포저 RT = 지배 비용)를 못 줄이고 표시만 다운샘플하는 "화질 순손실 + // 성능 무효과" 단계가 된다(강등해도 fps 회복 없음 → 사다리 계속 진행 실틈). if (P.three.composer.setPixelRatio) P.three.composer.setPixelRatio(_pr); if (P.three.composer.setSize) P.three.composer.setSize(w, h); } } P.on_resize(fit); P.three = { scene: scene, camera: camera, renderer: renderer, composer: null }; // 대기 스케일 계약 — 대형-씬 패밀리(실측 도시 등)가 무드 기본 안개(소형 절차 씬 기준, far≈95)를 // 씬 스케일로 재척도하는 유일 정본(재발명 금지 — space 의 fog=null 처리와 함께 2개의 공인 경로). // 반환 = 복원 함수(패밀리 remove 경로에서 호출 — 절차 폴백이 소형 기본값으로 복귀). P.three.fog_scale = function (o) { var fog = scene.fog; if (!fog) return function () {}; var far = (o && typeof o.far === 'number') ? o.far : 1250; var near = (o && typeof o.near === 'number') ? o.near : 160; // 카메라 far 연동 — 안개 far 가 카메라 far(기본 2000)에 눌리면 원경이 안개 대신 클립으로 // 잘린다(대형 씬에서 '전체가 안 보임'). far×1.3 여유로 승급, 복원 함수가 원복. var prev_cam = null; if (far * 1.3 > camera.far) { prev_cam = camera.far; camera.far = Math.ceil(far * 1.3); camera.updateProjectionMatrix(); } if (fog.near !== undefined) { var prev = { near: fog.near, far: fog.far }; fog.near = Math.max(fog.near, near); fog.far = Math.max(fog.far, far); return function () { fog.near = prev.near; fog.far = prev.far; if (prev_cam !== null) { camera.far = prev_cam; camera.updateProjectionMatrix(); } }; } if (fog.density !== undefined) { var pd = fog.density; fog.density = Math.min(fog.density, 2.15 / far); return function () { fog.density = pd; if (prev_cam !== null) { camera.far = prev_cam; camera.updateProjectionMatrix(); } }; } return function () {}; }; fit(); // 공유 태양 방향(단위 벡터, 표면→태양) — 물 글리터의 광원 정본. mood 키라이트 위치와 동일 기본값, // nature.time_of_day 가 태양을 움직이면 갱신(가드 소비 — 소비자: make.water 셰이더). P._sun_dir = { value: new THREE.Vector3(-6, 9, 4).normalize() }; // ═══ Render profile — the fidelity axis (stylized | realistic), an ASSEMBLY-TIME token ═══ // Why a token and not a logic API: rendering ownership was confiscated from logic after the // 2026-07-10 washout incidents, so the fidelity decision must arrive from upstream (the generation // schema) and be applied by the only owner allowed to touch the renderer — this shell. An unfilled // token (old generator bundle serving a new live shell) compares false → stylized, silently safe. // Realistic = the SAME authored mood/palette rendered physically: the vendor bundle's dormant // THREE.Sky (atmospheric scattering) replaces the gradient sky, a PMREM of that real sky replaces // the studio RoomEnvironment IBL, matte/terrain default to smooth shading, and water turns glossy // enough to mirror the sky. Assets stay 100% procedural (honest capability — no photo textures). var _RPROF = ('stylized' === 'realistic') ? 'realistic' : 'stylized'; var _rp = { sky: null, env_tex: null, env_scene: null, rt: null, buf: null, cam: null, last_sun: null, prof: null, max_lum: null }; var _bloom_pass = null, _bloom_thr_auth = 1; // realistic bloom floor state — set by enable_bloom, consumed by _rp_bloom_floor if (_RPROF === 'realistic') { try { if (typeof THREE.Sky !== 'function') throw new Error('THREE.Sky missing from vendor bundle'); _rp.sky = new THREE.Sky(); _rp.sky.name = 'rp_sky'; // smoke observation surface (non-contract) _rp.sky.scale.setScalar(1750); // inside camera far (2000); Sky's shader ignores fog by design _rp.sky.material.uniforms.turbidity.value = 2.5; _rp.sky.material.uniforms.rayleigh.value = 1.2; _rp.sky.material.uniforms.mieCoefficient.value = 0.005; _rp.sky.material.uniforms.mieDirectionalG.value = 0.8; _rp.sky.material.uniforms.sunPosition.value.copy(P._sun_dir.value); scene.add(_rp.sky); _rp.env_scene = new THREE.Scene(); try { console.info('[three] render profile: realistic (physical sky rig)'); } catch (e0) {} } catch (e) { // Graceful profile degradation — realistic is additive fidelity, never a breakage class. _RPROF = 'stylized'; _rp.sky = null; try { console.error('[three] realistic profile unavailable (' + (e && e.message) + ') — stylized fallback'); } catch (e1) {} if (window.__playable_beacon) window.__playable_beacon('render_profile_degraded', (e && e.message) || 'sky init failed'); } } P.render_profile = _RPROF; // read-only surface — logic may branch on it; assigning it does nothing P._rp = _rp; // smoke observation bridge (non-contract, underscore = vocabulary-gate exempt) // ── Measured-sky pipeline: ONE 16×16 linear readback per mood/sun change powers BOTH the fog // color and the IBL dome. ⚠️ Why NOT pmrem.fromScene(sky): THREE.Sky's fragment shader evaluates // pow(negative, 1.5) on the below-horizon hemisphere — undefined GPU behavior that SwiftShader // (and some mobile GPUs) resolve to NaN, and ONE NaN texel poisons the whole CubeUV mip chain → // every standard material renders black (measured 2026-07-18: frame mean 0 with fromScene, healthy // with environment=null; mie=0 did not save it). The dome below is built from MEASURED above-horizon // rows only, encoded as bounded uint8 sRGB — NaN is structurally unrepresentable in the IBL input. // This also answers the documented "Sky↔fog matching problem" by measurement (readback cadence // class shared with the auto-exposure meter — mood changes / large sun moves only, never per-frame). function _rp_sky_measure() { if (!_rp.sky) return; try { if (!_rp.rt) { _rp.rt = new THREE.WebGLRenderTarget(16, 16, { type: THREE.FloatType, minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter }); // readback-only; float32+LINEAR is illegal on iPhone GPUs (texpolicy canon) _rp.buf = new Float32Array(16 * 16 * 4); _rp.cam = new THREE.PerspectiveCamera(100, 1, 0.1, 4000); } var sd = P._sun_dir.value; // Sun-perpendicular azimuth (avoids the sun-disk hot spot), pitched 40° up with a 100° vfov — // a single render spans elevations ≈ [-10°, +90°] (horizon through zenith in one readback). var ax = -sd.z, az = sd.x; var al = Math.sqrt(ax * ax + az * az) || 1; ax /= al; az /= al; var pitch = 40 * Math.PI / 180; _rp.cam.position.set(0, 0, 0); _rp.cam.lookAt(ax * Math.cos(pitch), Math.sin(pitch), az * Math.cos(pitch)); _rp.env_scene.add(_rp.sky); // temporary re-parent (single-parent rule) — restored right below renderer.setRenderTarget(_rp.rt); renderer.render(_rp.env_scene, _rp.cam); renderer.setRenderTarget(null); scene.add(_rp.sky); renderer.readRenderTargetPixels(_rp.rt, 0, 0, 16, 16, _rp.buf); var t50 = Math.tan(50 * Math.PI / 180); var prof = []; // ascending [{e(levation°), r, g, b}] — linear, clamped finite for (var row = 0; row < 16; row++) { var yn = (row + 0.5) / 16 * 2 - 1; var e = 40 + Math.atan(yn * t50) * 180 / Math.PI; var r = 0, g = 0, b = 0; for (var cx = 6; cx < 10; cx++) { var o4 = (row * 16 + cx) * 4; r += _rp.buf[o4]; g += _rp.buf[o4 + 1]; b += _rp.buf[o4 + 2]; } r /= 4; g /= 4; b /= 4; if (!isFinite(r + g + b)) continue; // drop non-finite rows — they never enter the dome prof.push({ e: e, r: Math.min(r, 1.4), g: Math.min(g, 1.4), b: Math.min(b, 1.4) }); } if (!prof.length) return; _rp.prof = prof; // Peak measured sky-band luma — the realistic bloom floor's input (see _rp_bloom_floor). var pl_max = 0; for (var pj = 0; pj < prof.length; pj++) { var pl2 = 0.2126 * prof[pj].r + 0.7152 * prof[pj].g + 0.0722 * prof[pj].b; if (pl2 > pl_max) pl_max = pl2; } _rp.max_lum = pl_max; _rp_bloom_floor(); // Fog color = the measured band just above the horizon (~4°) — normalized into display range, // then LUMA-CAPPED. The bare max-channel normalization pushed any bright horizon to linear 1.0, // and linear-1.0 fog × the small-scene fog far (95-115m) rendered every mid/far surface as // near-white (2026-07-18 mirror-lake washout: the whole vista collapsed into the fog color). // 0.62 linear ≈ 0.73 displayed through ACES@1.2 — still a bright daylight haze, but distant // geometry keeps hue and stays separable from the sky. Dim horizons (dawn/dusk) pass untouched. var fr = prof[0]; for (var pi = 0; pi < prof.length; pi++) { if (Math.abs(prof[pi].e - 4) < Math.abs(fr.e - 4)) fr = prof[pi]; } var fm = Math.max(fr.r, fr.g, fr.b, 1); var fcr = fr.r / fm, fcg = fr.g / fm, fcb = fr.b / fm; var fll = 0.2126 * fcr + 0.7152 * fcg + 0.0722 * fcb; if (fll > 0.62) { var fsc = 0.62 / fll; fcr *= fsc; fcg *= fsc; fcb *= fsc; } if (scene.fog) scene.fog.color.setRGB(fcr, fcg, fcb); _rp.last_sun = sd.clone(); } catch (e) { try { scene.add(_rp.sky); } catch (e1) {} // Float RT unsupported — sky visual stays, IBL/fog keep their previous values (never breaks). try { console.warn('[three] realistic sky measure failed (' + (e && e.message) + ') — keeping previous fog/IBL'); } catch (e2) {} } } // Rebuild the scene IBL as a small equirect gradient dome from the measured profile. The 0.35 // intensity seal above guards the bright-studio RoomEnvironment's grazing-Fresnel washout; this // measured real-sky dome is physically plausible ambient, so it sits at 0.75 under the same // ACES + auto-exposure safety nets. Bounded uint8 input → the renderer's internal equirect→CubeUV // conversion cannot produce NaN. function _rp_env_rebuild() { if (!_rp.sky || !_rp.prof) return; try { var W = 8, H = 32; var data = new Uint8Array(W * H * 4); var prof = _rp.prof; var s2 = function (c) { return Math.round(Math.pow(Math.min(Math.max(c, 0), 1), 1 / 2.2) * 255); }; // linear→sRGB approx for (var row = 0; row < H; row++) { var e = ((row + 0.5) / H) * 180 - 90; // row 0 = nadir, top row = zenith (equirect v-axis) var lo = prof[0], hi = prof[prof.length - 1], cr, cg, cb; if (e <= lo.e) { cr = lo.r * 0.32; cg = lo.g * 0.32; cb = lo.b * 0.32; } // below horizon = ground-bounce tint else if (e >= hi.e) { cr = hi.r; cg = hi.g; cb = hi.b; } else { cr = lo.r; cg = lo.g; cb = lo.b; for (var k = 0; k < prof.length - 1; k++) { if (e >= prof[k].e && e <= prof[k + 1].e) { var t = (e - prof[k].e) / ((prof[k + 1].e - prof[k].e) || 1); cr = prof[k].r + (prof[k + 1].r - prof[k].r) * t; cg = prof[k].g + (prof[k + 1].g - prof[k].g) * t; cb = prof[k].b + (prof[k + 1].b - prof[k].b) * t; break; } } } for (var cx2 = 0; cx2 < W; cx2++) { var o = (row * W + cx2) * 4; data[o] = s2(cr); data[o + 1] = s2(cg); data[o + 2] = s2(cb); data[o + 3] = 255; } } var tex = new THREE.DataTexture(data, W, H, THREE.RGBAFormat, THREE.UnsignedByteType); tex.mapping = THREE.EquirectangularReflectionMapping; tex.colorSpace = THREE.SRGBColorSpace; tex.magFilter = THREE.LinearFilter; tex.minFilter = THREE.LinearFilter; tex.needsUpdate = true; if (_rp.env_tex) { try { _rp.env_tex.dispose(); } catch (e0) {} } _rp.env_tex = tex; scene.environment = tex; scene.environmentIntensity = 0.75; } catch (e) { /* IBL keeps its previous value — degrade is visual-only */ } } // Realistic bloom floor — UnrealBloomPass's high-pass passes the FULL texel color once its luma // crosses the threshold (not just the excess), so a physical sky sitting slightly above an authored // threshold (e.g. 1.05 vs a ~1.1-1.4 noon sky band) turns the ENTIRE dome into a bloom source: a // full-screen additive veil over sky AND midground (2026-07-18 mirror-lake washout, second axis). // The floor lifts the threshold just above the MEASURED sky-band peak, so the dome never seeds // bloom while genuinely hot sources (sun disk, emissives, specular peaks) still do. Re-applied on // every sky measure (mood change / large sun move) and on enable_bloom itself; stylized untouched. function _rp_bloom_floor() { if (_RPROF !== 'realistic' || !_bloom_pass || _rp.max_lum == null) return; var bt = Math.max(_bloom_thr_auth, Math.min(1.6, _rp.max_lum + 0.12)); _bloom_pass.threshold = bt; if (_bloom_pass.highPassUniforms && _bloom_pass.highPassUniforms.luminosityThreshold) { _bloom_pass.highPassUniforms.luminosityThreshold.value = bt; // constructor-only copy in three — set both } } if (_RPROF === 'realistic') { _rp_sky_measure(); _rp_env_rebuild(); } // sky fog+IBL from frame 0 // ═══ 무드 프리셋 — "데이터가 곧 아트 디렉션" (v0/shadcn 토큰 구속 전략의 게임판) ═══ // 각 프리셋 = [skyStops(지평선→천정 hex 램프), hemiSky, hemiGround, keyColor, keyIntensity, accent, ground]. // 그라디언트 하늘(CanvasTexture) + fog 색=지평선 스탑 자동 일치(무비용 종합 1위 기법) + 웜/쿨 // hemisphere 페어 + key 라이트 리그. LLM 은 hex 를 발명하지 않고 프리셋 선택 + 역할 배정만 한다. // d[7] = ground_ramp — 지면 전용 4-스톱(어두움→밝음). ⚠️ 축 원칙: 시간대(하늘·조명)와 식생 // 기저색은 독립 축이다 — 초원류 무드(sunset/dawn/noon)의 지면은 초록을 유지하고 시간대는 // 하늘 ramp·키라이트 색온도로만 표현한다("노을 = 사막색 땅" 결합이 green-field-at-golden-hour // 를 표현 불가능하게 만들던 실사고). 비-식생 지면(사막·설원)은 brief 가 명시할 때 로직이 // terrain colors 로 오버라이드. // 계약 공백 봉합(2026-07-10 실사고): 팔레트에 지면용 램프가 없어 유일한 4색 배열인 하늘 // ramp 를 terrain/grass 에 먹이는 오사용이 LLM·아키타입 양쪽에서 구조적으로 유도됐다 // ("파란 산악 + 검은 털 풀"의 근본 원인). 하늘은 ramp, 지면은 ground_ramp — 도메인 분리. var _MOODS = { sunset: [['#EE6C45', '#C0417A', '#6B4C86', '#4A3F7A'], '#FFB187', '#4A2B4F', '#FFD9A0', 1.1, '#FFE58A', '#2B2136', ['#354426', '#4F6530', '#6E8A3C', '#97A94E']], // 상부 = 밝은 보라(프레임 40% 지배 실측). ground_ramp = 노을빛 받은 초록(시간대≠식생 축 — 구 gold 램프는 '노을이면 사막' 강제, 실보고 근원) dawn: [['#F67E7D', '#C06C84', '#7A6890', '#463F63'], '#FFC9B5', '#3A3A55', '#FFE3D0', 0.9, '#FFB997', '#33334E', ['#2F3E2C', '#47583B', '#64784A', '#8A9C63']], // sunset 동계 상부 리프트 + 새벽 안개빛 초록 지면(시간대≠식생 축) noon: [['#EAF9FF', '#BFE8F5', '#7EC8E3', '#4FA8D5'], '#B1E1FF', '#B97A20', '#FFF4D6', 1.3, '#FF8A5C', '#7A9B6D', ['#3E6B33', '#55863C', '#6FA24A', '#8FBF5A']], dusk: [['#E94560', '#903A6B', '#16213E', '#0F1830'], '#C86B8A', '#141B33', '#FF9FB0', 0.8, '#E94560', '#1C2440', ['#2C2B3A', '#3D3F45', '#4F5651', '#66705E']], night_neon: [['#1B2340', '#12172E', '#0B1026', '#05060F'], '#3A4A8A', '#0A0A12', '#8FD0FF', 0.55, '#00E5FF', '#10131F', ['#101820', '#16222C', '#1F2E38', '#2A3D45']], ember: [['#7B241C', '#3D1308', '#25100B', '#120808'], '#E25822', '#1A0C0C', '#FFC288', 0.75, '#FF7A1A', '#241012', ['#241210', '#3A1D15', '#54291B', '#733A22']], ice: [['#5BC0BE', '#3A506B', '#1C2541', '#0B132B'], '#CFF7F5', '#1C2541', '#EAFDFF', 0.9, '#7CF7EF', '#22304A', ['#5E7387', '#7E93A6', '#A5BBC9', '#D4E6EF']], candy: [['#FF7BAC', '#B65FBF', '#7551A8', '#2A174E'], '#FFC0D9', '#4A2B66', '#FFF0F6', 1.0, '#FFD166', '#3A2258', ['#5E4468', '#7C5A80', '#9E7796', '#C09AAC']], storm: [['#8FA3B8', '#66798E', '#485A6E', '#33414F'], '#9FB4C8', '#2E3A46', '#D9E4EE', 0.85, '#7CC0F0', '#2C3844', ['#2F3E2C', '#44543A', '#5C6E48', '#7A8A5C']], // 폭풍 하늘 — weather('storm') 과 페어(식생 축 = 초록 유지) }; P._mood_group = null; // nature.time_of_day 와 상호 배타 — nature 가 제거할 수 있게 P 에 노출 P.mood = function (name) { if (P._tod_teardown) P._tod_teardown(); // nature.time_of_day 리그 해체(양방향 상호배타) // 'night' alias — music/ambience/time_of_day 에는 night 가 실재해 "mood 도 night" 추론이 // 구조적으로 유도된다(4중 이름 충돌). 의도가 유일-결정이므로 alias 로 흡수. if (name === 'night') name = 'night_neon'; // closed enum 위반 = LOUD(music/ambience 와 대칭) — 침묵 night_neon 강등은 "밤 아님" 브리프를 조용히 배반한다. if (name && !_MOODS[name]) { try { console.error('[three] unknown mood "' + name + '" — enum: ' + Object.keys(_MOODS).join('/') + '; falling back to night_neon'); } catch (e) {} } var d = _MOODS[name] || _MOODS.night_neon; // 밤 계열 무드 = 별 자동(실보고 "별 쏟아지는 밤하늘" — 구현이 time_of_day 에만 별이 있고 // 정적 mood 밤은 별 0 이던 갭). nature 셸의 공유 별 필드 브리지(레벨만 지시). // ※ 조건부 주입 정합: 밤 무드 문자열이 로직에 있으면 NATURE 셸이 포함되도록 마커 등록됨(slots.mjs). if (P.nature && P.nature._mood_stars) { var _mk2 = _MOODS[name] ? name : 'night_neon'; P.nature._mood_stars(_mk2 === 'night_neon' ? 0.95 : _mk2 === 'ember' ? 0.3 : _mk2 === 'dusk' ? 0.5 : 0); } var stops = d[0]; // 그라디언트 하늘: 세로 캔버스 → scene.background(스크린 정렬) — Sky↔fog 매칭 난제의 구조적 우회. var c = document.createElement('canvas'); c.width = 2; c.height = 1024; // 256 스텝은 대화면에서 밴딩 가시(실보고) var g = c.getContext('2d'); var grad = g.createLinearGradient(0, 0, 0, 1024); // 스탑 분포: 균등 → 지평선 치중. 균등 분포는 어두운 천정색이 하늘의 상부 2/3 를 지배해 // 세로폰(하늘이 화면 대부분)에서 sunset 류가 "밤"으로 오인되던 실보고의 근원 — 천정색은 // 상부 30%로 압축하고 지평선 밝은 색 구간을 넓힌다(색 데이터 불변, 분포만). var _sp = [0, 0.3, 0.6, 1]; for (var i = 0; i < stops.length; i++) grad.addColorStop(_sp[i] != null ? _sp[i] : i / (stops.length - 1), stops[stops.length - 1 - i]); g.fillStyle = grad; g.fillRect(0, 0, 2, 1024); var tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.SRGBColorSpace; scene.background = tex; // fog 색 = 지평선 스탑(대기원근 정본). 거리 소유권은 분리 — world.terrain 이 스트리밍 마스크로 // near/far 를 재설정하는데, mood 재호출이 fog 를 재생성하면 그 거리를 파괴해 먼 청크 팝인을 // 유발하던 순서-의존 결함. 기존 fog 가 있으면 색만 갱신한다. if (scene.fog) scene.fog.color.set(stops[0]); else scene.fog = new THREE.Fog(new THREE.Color(stops[0]), 18, 95); if (P._mood_group) scene.remove(P._mood_group); P._mood_group = new THREE.Group(); P._mood_group.add(new THREE.HemisphereLight(new THREE.Color(d[1]), new THREE.Color(d[2]), 0.7)); var key = new THREE.DirectionalLight(new THREE.Color(d[3]), d[4]); key.position.set(-6, 9, 4); _shadow_attach(key); // 그림자 키라이트 승계 — target 도 그룹 합류(추종 프러스텀의 행렬 갱신 전제) P._mood_group.add(key.target); P._mood_group.add(key); scene.add(P._mood_group); if (_RPROF === 'realistic' && _rp.sky) { // Physical sun per mood — mood stays the "when / which palette" axis; realistic changes HOW // that same choice is lit: [sun elevation°, azimuth°, turbidity, rayleigh, mieCoefficient]. var _RPS = { noon: [58, 35, 2.2, 1.1, 0.004], sunset: [5, 250, 5.5, 2.6, 0.008], dawn: [4, 100, 4.5, 2.8, 0.007], dusk: [-3, 255, 6, 3, 0.008], night_neon: [-14, 0, 2, 2, 0.003], ember: [3, 230, 9, 3.5, 0.01], ice: [22, 40, 1.4, 1.6, 0.003], candy: [11, 210, 4, 2.2, 0.006], storm: [32, 60, 10, 4, 0.012], }; var _rs = _RPS[_MOODS[name] ? name : 'night_neon'] || _RPS.noon; var _el = _rs[0] * Math.PI / 180, _az = _rs[1] * Math.PI / 180; var _sunv = new THREE.Vector3(Math.cos(_el) * Math.sin(_az), Math.sin(_el), Math.cos(_el) * Math.cos(_az)); P._sun_dir.value.copy(_sunv); // single sun source of truth — water glitter + sky follow share it _rp.sky.material.uniforms.turbidity.value = _rs[2]; _rp.sky.material.uniforms.rayleigh.value = _rs[3]; _rp.sky.material.uniforms.mieCoefficient.value = _rs[4]; _rp.sky.material.uniforms.sunPosition.value.copy(_sunv); // Key light follows the physical sun; the shadow axis is re-based statically per mood (texel // snapping stays exact — the axis is fixed BETWEEN mood changes, same invariant as before). // Below ~12° elevation the shadow frustum degenerates (grazing light), so shadows keep a // clamped axis while the VISUAL sun (sky/glitter) is free to sit at the horizon. var _sh = _sunv.clone(); if (_sh.y < 0.21) { _sh.y = 0.21; _sh.normalize(); } key.position.copy(_sh).multiplyScalar(11.5); _shadow_set_axis(_sh); _rp_sky_measure(); _rp_env_rebuild(); } // 팔레트 반환 — 로직의 모든 색은 이 객체에서만(팔레트 외 색 금지 계약의 데이터 원천). var pal = { name: name, ramp: stops.slice(), horizon: stops[0], zenith: stops[stops.length - 1], sky: d[1], accent: d[5], ground: d[6], key: d[3], ground_ramp: (d[7] || [d[6], d[6], d[6], d[6]]).slice(), }; P._pal = pal; // 지면 소비자(world.terrain/nature.grass_field)의 색 기본값 원천 — 생략이 정답이 되게 return pal; }; P.moods = Object.keys(_MOODS); // ═══ 환경 팩토리 (PLAYABLE.make) — 함정-흡수 절차 생성기 ═══ var _env_anims = []; P.loop(function (dt, elapsed, raw) { for (var i = 0; i < _env_anims.length; i++) { try { _env_anims[i](raw, elapsed); } catch (e) {} } }); function _clampn(v, a, b, d) { v = typeof v === 'number' && isFinite(v) ? v : d; return v < a ? a : (v > b ? b : v); } function num0(v) { return typeof v === 'number' && isFinite(v) ? v : 0; } // 공유 링 텍스처(소프트 라디얼 링 — 리플/착수/스플래시 계열이 공용) — 1회 생성 캐시 var _ring_cache = null; function _ring_tex() { if (_ring_cache) return _ring_cache; var c = document.createElement('canvas'); c.width = c.height = 128; var g = c.getContext('2d'); g.strokeStyle = 'rgba(255,255,255,0.9)'; g.lineWidth = 10; g.beginPath(); g.arc(64, 64, 46, 0, Math.PI * 2); g.stroke(); g.strokeStyle = 'rgba(255,255,255,0.35)'; g.lineWidth = 22; g.beginPath(); g.arc(64, 64, 44, 0, Math.PI * 2); g.stroke(); _ring_cache = new THREE.CanvasTexture(c); return _ring_cache; } P._ring_tex = _ring_tex; // 패밀리 공유(nature 폭포 착수 링 등 — P._pal 과 동형의 내부 브리지) P.make = { // 매트 재질 — metalness=0 봉인(envMap 정책과 정합·grazing 거울화/검은 재질 사고 차단). // Shading default follows the render profile (stylized = faceted low-poly, realistic = smooth); // an explicit `flat` option always wins in both profiles. matte: function (color, opts) { var o = opts || {}; return new THREE.MeshStandardMaterial({ color: new THREE.Color(color || '#888888'), roughness: _clampn(o.roughness, 0.3, 1, 0.85), metalness: 0, flatShading: o.flat != null ? o.flat !== false : _RPROF !== 'realistic', }); }, // 발광 재질 — bloom(threshold 1.0)을 실제로 발화시키는 유일 정본 경로(HDR 초과 emissive). glow: function (color, intensity) { return new THREE.MeshStandardMaterial({ color: new THREE.Color(color || '#ffffff'), emissive: new THREE.Color(color || '#ffffff'), emissiveIntensity: _clampn(intensity, 0.5, 3, 2), roughness: 0.4, metalness: 0, }); }, // fBm 로우폴리 지형 — indexed vertex-color 줄무늬 함정을 toNonIndexed 로 내부 흡수(실증 회귀 클래스). // 장식 전용(height_at 없음) — 걷는 지형은 world.terrain 이 유일 정본(프롬프트 계약과 동일). terrain: function (opts) { var o = opts || {}; var size = _clampn(o.size, 40, 600, 220), seg = Math.round(_clampn(o.seg, 16, 128, 80)); var height = _clampn(o.height, 0.5, 60, 10); // 기본색 = 무드 ground_ramp(도메인 분리 계약) — 구 기본값(보라/마젠타 하늘형 램프)은 // ground_ramp 계약이 봉합한 "파란 산악" 클래스 그 자체였다(mood 미호출 시에만 녹색 폴백). // 폴백은 침묵 금지 — 이후 mood() 를 불러도 이미 구운 메시는 재색칠되지 않는 순서-의존. if (!(o.colors && o.colors.length >= 2) && !P._pal) { try { console.warn('[three] make.terrain built before PLAYABLE.mood() — default green ramp baked in (call mood first)'); } catch (e) {} } var colors = (o.colors && o.colors.length >= 2) ? o.colors : ((P._pal && P._pal.ground_ramp) || ['#2B4A2E', '#3E6B33', '#5B8C3E', '#8FBF5A']); var geo = new THREE.PlaneGeometry(size, size, seg, seg); geo.rotateX(-Math.PI / 2); var noise = new THREE.ImprovedNoise(); var pos = geo.attributes.position; var cell = size / seg; for (var i = 0; i < pos.count; i++) { var x = pos.getX(i), z = pos.getZ(i); var y = 0, amp = 1, freq = 1.6 / size; for (var oct = 0; oct < 4; oct++) { y += noise.noise(x * freq, z * freq, 7.7) * amp; amp *= 0.5; freq *= 2.1; } if (y > 0.35) y *= 1.35; // 봉우리 과장(정본) if (o.island) { // 섬/디오라마 — 반경 falloff(가장자리로 갈수록 급락 → 물에 잠기는 섬 실루엣) var _ir = Math.sqrt(x * x + z * z) / (size * 0.5); y = y * Math.max(0, 1 - Math.pow(_ir, 3)) - Math.pow(Math.max(0, _ir - 0.72), 2) * 9; } pos.setY(i, y * height); if (_RPROF !== 'realistic') { // vertex jitter is the low-poly facet signature — smooth wants the regular grid pos.setX(i, x + (Math.random() - 0.5) * cell * 0.45); pos.setZ(i, z + (Math.random() - 0.5) * cell * 0.45); } } if (_RPROF === 'realistic') { // Realistic branch: indexed grid + per-vertex ramp LERP + smooth normals — the per-face // banding below is the stylized signature, not a fidelity ceiling. var _pv = geo.attributes.position; var _colv = new Float32Array(_pv.count * 3); var _c1 = new THREE.Color(), _c2 = new THREE.Color(); for (var _vi = 0; _vi < _pv.count; _vi++) { var _tt = Math.min(0.999, Math.max(0, (_pv.getY(_vi) / height + 1) / 2)); var _fi = _tt * (colors.length - 1), _i0 = Math.floor(_fi); _c1.set(colors[_i0]); _c2.set(colors[Math.min(colors.length - 1, _i0 + 1)]); _c1.lerp(_c2, _fi - _i0); _colv[_vi * 3] = _c1.r; _colv[_vi * 3 + 1] = _c1.g; _colv[_vi * 3 + 2] = _c1.b; } geo.setAttribute('color', new THREE.BufferAttribute(_colv, 3)); geo.computeVertexNormals(); var _rmesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.95, metalness: 0 })); _rmesh.receiveShadow = true; _rmesh.position.y = typeof o.y === 'number' ? o.y : 0; scene.add(_rmesh); return _rmesh; } geo = geo.toNonIndexed(); // per-face 밴드색은 non-indexed 필수(경계 줄무늬 방지) var p2 = geo.attributes.position; var col = new Float32Array(p2.count * 3); var tmp = new THREE.Color(); for (var f = 0; f < p2.count; f += 3) { var avg = (p2.getY(f) + p2.getY(f + 1) + p2.getY(f + 2)) / 3; var t = Math.min(0.999, Math.max(0, (avg / height + 1) / 2)); tmp.set(colors[Math.floor(t * colors.length)]); for (var v = 0; v < 3; v++) { col[(f + v) * 3] = tmp.r; col[(f + v) * 3 + 1] = tmp.g; col[(f + v) * 3 + 2] = tmp.b; } } geo.setAttribute('color', new THREE.BufferAttribute(col, 3)); var mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ vertexColors: true, flatShading: true, roughness: 0.9, metalness: 0 })); mesh.receiveShadow = true; // 그림자 리시버(장식 지형 — 캐스터 아님) mesh.position.y = typeof o.y === 'number' ? o.y : 0; scene.add(mesh); return mesh; }, // ═══ 물 v2 — 업계 정본 계층 사다리(2026-07-12 리서치 3축 교차 확정: WW/SoT/BotW/Slime Rancher // + r185 청크 원문 검증 + 모바일 TBR 캐논). 전 레이어를 단일 셰이더 1 draw call 로 흡수 // (투명 오버드로우 = 모바일 최대 킬러 — 가산 별도 레이어 금지, 글리터는 클램프 emissive 항). // L0 GPU 컴파운드 사인 파도 — begin/beginnormal_vertex 주입 + 해석 도함수 노멀(구 v1 의 // 매 프레임 CPU computeVertexNormals 완전 삭제 — three#9574 랙 클래스). // L1 수심 색 — world.height_at(해석 지형)를 빌드 1회 DataTexture 로 베이크 = depth texture // 의 픽셀 정밀 해안선을 TBR resolve 비용 0 으로(ARM/Qualcomm depth-prepass 금지 가이드 정합). // "깊이가 색을 결정한다"(전 명작 만장일치 초석). 지형 없으면 균일 심해로 우아 강등. // L2 해안 포말 — WW 이중 룩업(흰 대시 + 오프셋 다크 그림자) + depth-밴드 링(해안 평행 자동) // + swash 왕복(위상 노이즈 — 해변 밀물/썰물 서사). 포말은 항상 움직인다(정적 레이스 금지). // L3 태양 글리터 — Water.js 검증 Blinn 항(pow 90)·기여 클램프 ≤1.15(bloom threshold 1.0 // 경계 통제 — additive×ACES 블로우아웃 전과 방어). 노이즈 노멀 워블로 "점들의 길" 분산. // L4 프레넬 수평선 — grazing 에서 fog 색(=지평선) 수렴("게임 물" 위화감 봉합, 비용 dot+pow). // L5 SoT 파고 청록 — 파고(y)로 투과색 근사(가짜 SSS — "피크가 밝아지는" SoT 미학의 저가 이식). // L6 도섭/주행 리플 링 — 풀링 인스턴스(렌더타깃 금지 컨센서스), 플레이어/수상 탈것 자동 배선. // 수중(L7)은 렌더 루프의 수중 모드가 소유(갓레이·기포 — 이 파일 하단). water: function (opts) { var o = opts || {}; var size = _clampn(o.size, 20, 600, 220), seg = Math.round(_clampn(o.seg, 16, 96, 56)); var wy = typeof o.y === 'number' ? o.y : 0; var amp = _clampn(o.wave, 0.05, 3, 0.35); // 색 정본: blue-green 패밀리 기본(도메인 규칙) — deep 지정(구 color 하위호환) 시 shallow 자동 파생 var deep = new THREE.Color(o.deep || o.color || '#1F5F96'); var shallow = o.shallow ? new THREE.Color(o.shallow) : deep.clone().lerp(new THREE.Color('#54C2B0'), 0.55); var sss = deep.clone().lerp(new THREE.Color('#6FE3D0'), 0.6); var foamc = new THREE.Color('#EAF6F2'); // 순백 아닌 오프화이트(리서치: 하늘빛 섞인 포말) // ── L1 수심 베이크 — 지형 존재 시에만(부재 = 균일 심해 → 포말/swash 자동 무장해제) ── var has_t = !!(P.world && P.world._priv && P.world._priv.has_terrain && P.world._priv.has_terrain()); var DN = 128, dmax = 7; var ddata = new Uint8Array(DN * DN); if (has_t) { for (var dj = 0; dj < DN; dj++) { for (var di = 0; di < DN; di++) { var dx0 = (di / (DN - 1) - 0.5) * size, dz0 = (dj / (DN - 1) - 0.5) * size; var dh = wy - P.world.height_at(dx0 + num0(o.x), dz0 + num0(o.z)); ddata[dj * DN + di] = Math.max(0, Math.min(255, Math.round((dh / dmax) * 255))); } } } else { for (var df = 0; df < DN * DN; df++) ddata[df] = 217; // 0.85 — 균일 심해 if (P.world) { try { console.warn('[three] make.water built before world.terrain — flat deep water (no shoreline foam); call world.terrain first for beaches'); } catch (e) {} } } // 수중 스폰 LOUD — 수면이 시작 지점 지면보다 높으면 첫 프레임이 물속(구도 실수 클래스) if (has_t && wy > P.world.height_at(0, 0) + 0.4) { try { console.warn('[three] make.water level ' + wy + ' is ABOVE the spawn ground — the start point is underwater (lower y, or use an island terrain)'); } catch (e) {} } var dtex = new THREE.DataTexture(ddata, DN, DN, THREE.RedFormat, THREE.UnsignedByteType); dtex.magFilter = dtex.minFilter = THREE.LinearFilter; dtex.needsUpdate = true; // ── 포말/노이즈 팩 텍스처(1장 3채널 다용도): R=포말 대시 타일, G=저주파 노이즈(swash 위상· // 링 절단), B=이속 노이즈(글리터 워블) — 텍스처 탭 수 최소화(모바일 FS 예산) ── var fc = document.createElement('canvas'); fc.width = fc.height = 128; var fg = fc.getContext('2d'); fg.fillStyle = '#000000'; fg.fillRect(0, 0, 128, 128); fg.strokeStyle = '#FF0000'; fg.lineWidth = 3; fg.lineCap = 'round'; // R: WW풍 호(arc) 대시 for (var fa = 0; fa < 26; fa++) { var ax = Math.random() * 128, ay = Math.random() * 128, ar = 4 + Math.random() * 9; var a0 = Math.random() * Math.PI * 2; fg.beginPath(); fg.arc(ax, ay, ar, a0, a0 + 1.1 + Math.random() * 1.6); fg.stroke(); fg.beginPath(); fg.arc(ax - 128, ay, ar, a0, a0 + 2); fg.stroke(); // 타일 이음 fg.beginPath(); fg.arc(ax, ay - 128, ar, a0, a0 + 2); fg.stroke(); } var fimg = fg.getImageData(0, 0, 128, 128); for (var fp = 0; fp < 128 * 128; fp++) { // G/B: 상이 주파수 value 노이즈(캔버스 밖 수학 — 결정성 불요) var fx2 = fp % 128, fy2 = (fp / 128) | 0; fimg.data[fp * 4 + 1] = 128 + 127 * Math.sin(fx2 * 0.11 + Math.sin(fy2 * 0.13) * 2.7) * Math.sin(fy2 * 0.07 + 1.3); fimg.data[fp * 4 + 2] = 128 + 127 * Math.sin(fx2 * 0.23 + 4.1 + Math.sin(fy2 * 0.19) * 2.2) * Math.sin(fy2 * 0.17 + 0.6); fimg.data[fp * 4 + 3] = 255; } fg.putImageData(fimg, 0, 0); var ftex = new THREE.CanvasTexture(fc); ftex.wrapS = ftex.wrapT = THREE.RepeatWrapping; // ── 지오메트리 + 재질(표준 재질 + onBeforeCompile — fog/ACES/라이팅 통합 무료, r185 앵커 검증) ── var geo = new THREE.PlaneGeometry(size, size, seg, seg); geo.rotateX(-Math.PI / 2); var uT = { value: 0 }; var mat = new THREE.MeshStandardMaterial({ // Realistic profile: low roughness makes the standard env-map term mirror the physical-sky // PMREM off the wave normals — the sky-reflecting ocean look, still 1 draw call, no Reflector. color: 0xffffff, transparent: true, roughness: _RPROF === 'realistic' ? 0.12 : 0.5, metalness: 0, depthWrite: false, side: THREE.DoubleSide, // 수중 뒷면 = 밝은 물결 천장(아래서 본 수면) }); var swash_on = (o.swash !== false && has_t) ? 1 : 0; var glint_k = _clampn(o.glint, 0, 2, 1); mat.onBeforeCompile = function (sh) { sh.uniforms.uWT = uT; sh.uniforms.uWAmp = { value: amp }; sh.uniforms.uWDepth = { value: dtex }; sh.uniforms.uWFoam = { value: ftex }; sh.uniforms.uWInv = { value: 1 / size }; sh.uniforms.uWShallow = { value: shallow }; sh.uniforms.uWDeep = { value: deep }; sh.uniforms.uWSss = { value: sss }; sh.uniforms.uWFoamC = { value: foamc }; sh.uniforms.uWSun = P._sun_dir; sh.uniforms.uWGlint = { value: glint_k }; sh.uniforms.uWSwash = { value: swash_on }; sh.uniforms.uWAlpha = { value: _clampn(o.opacity, 0.3, 1, 0.86) }; var vdecl = 'uniform float uWT;\nuniform float uWAmp;\nvarying vec3 vWWPos;\nvarying vec3 vWWN;\nvarying float vWH;\n' + 'float ww_h(vec2 p, float t){ return sin(p.x*0.16 + t*1.15) + sin(p.y*0.13 + t*0.87) + 0.6*sin((p.x+p.y)*0.27 + t*1.6) + 0.4*sin(p.x*0.05 - t*0.42); }\n'; sh.vertexShader = vdecl + sh.vertexShader; var vb = sh.vertexShader; sh.vertexShader = sh.vertexShader.replace('#include
', '#include
\n' + 'float ww_e = 0.9;\n' + 'float ww_h0 = ww_h(position.xz, uWT);\n' + 'float ww_hx = ww_h(position.xz + vec2(ww_e, 0.0), uWT);\n' + 'float ww_hz = ww_h(position.xz + vec2(0.0, ww_e), uWT);\n' + 'objectNormal = normalize(vec3((ww_h0 - ww_hx) * uWAmp * 0.34 / ww_e, 1.0, (ww_h0 - ww_hz) * uWAmp * 0.34 / ww_e));\n'); sh.vertexShader = sh.vertexShader.replace('#include
', '#include
\n' + 'transformed.y += ww_h0 * uWAmp * 0.34;\n' + 'vWH = ww_h0 * 0.33;\n' + 'vWWN = normalize((modelMatrix * vec4(objectNormal, 0.0)).xyz);\n' + 'vWWPos = (modelMatrix * vec4(transformed, 1.0)).xyz;\n'); var fdecl = 'uniform float uWT;\nuniform sampler2D uWDepth;\nuniform sampler2D uWFoam;\nuniform float uWInv;\n' + 'uniform vec3 uWShallow;\nuniform vec3 uWDeep;\nuniform vec3 uWSss;\nuniform vec3 uWFoamC;\n' + 'uniform vec3 uWSun;\nuniform float uWGlint;\nuniform float uWSwash;\nuniform float uWAlpha;\n' + 'varying vec3 vWWPos;\nvarying vec3 vWWN;\nvarying float vWH;\n'; sh.fragmentShader = fdecl + sh.fragmentShader; var fb = sh.fragmentShader; sh.fragmentShader = sh.fragmentShader.replace('#include
', '#include
\n' + 'vec2 ww_luv = vWWPos.xz * uWInv + 0.5;\n' + 'float ww_d = texture2D(uWDepth, ww_luv).r;\n' + 'vec4 ww_nz = texture2D(uWFoam, vWWPos.xz * 0.045 + vec2(uWT * 0.013, -uWT * 0.009));\n' // L1 수심 색(3-스톱: 얕은 청록 → 본색 → 심해) + L5 파고 청록(SoT 가짜 SSS) + 'vec3 ww_col = mix(uWShallow, uWDeep, smoothstep(0.04, 0.62, ww_d));\n' + 'ww_col = mix(ww_col, uWSss, clamp(vWH, 0.0, 1.0) * 0.38);\n' // L4 프레넬 수평선 — grazing 에서 fog(지평선) 색 수렴 + 'vec3 ww_V = normalize(cameraPosition - vWWPos);\n' + 'float ww_fr = pow(1.0 - clamp(dot(ww_V, vWWN), 0.0, 1.0), 3.0);\n' + '#ifdef USE_FOG\n ww_col = mix(ww_col, fogColor, ww_fr * 0.5);\n#endif\n' // L2 포말 — 해안 엣지(맥동) + depth-밴드 링 2위상 + swash 왕복. WW 이중 룩업(흰+다크 오프셋) + 'float ww_dash = texture2D(uWFoam, vWWPos.xz * 0.11 + vec2(uWT * 0.02, uWT * 0.014)).r;\n' + 'float ww_dashD = texture2D(uWFoam, vWWPos.xz * 0.11 + vec2(uWT * 0.02 + 0.035, uWT * 0.014 + 0.03)).r;\n' + 'float ww_edge = (1.0 - smoothstep(0.0, 0.085, ww_d)) * (0.72 + 0.28 * sin(uWT * 1.7 + ww_nz.g * 9.0));\n' + 'float ww_ring = smoothstep(0.72, 0.98, fract(ww_d * 5.5 - uWT * 0.14 + ww_nz.g * 0.35)) * (1.0 - smoothstep(0.1, 0.42, ww_d));\n' + 'float ww_sw = 0.0;\n' + 'if (uWSwash > 0.5) {\n' + ' float ww_r1 = (0.5 + 0.5 * cos(uWT * 0.8 + ww_nz.g * 6.28)) * 0.14;\n' + ' float ww_r2 = (0.5 + 0.5 * cos(uWT * 0.8 + 2.4 + ww_nz.b * 6.28)) * 0.11;\n' + ' ww_sw = (1.0 - smoothstep(0.0, 0.035, abs(ww_d - ww_r1))) * 0.85 + (1.0 - smoothstep(0.0, 0.028, abs(ww_d - ww_r2))) * 0.6;\n' + ' ww_sw *= 1.0 - smoothstep(0.14, 0.22, ww_d);\n' + '}\n' + 'float ww_crest = smoothstep(0.55, 0.95, vWH) * smoothstep(0.4, 0.9, ww_dash) * 0.5;\n' + 'float ww_foam = clamp((ww_edge + ww_ring * 0.8 + ww_sw) * smoothstep(0.25, 0.65, ww_dash) + ww_crest, 0.0, 1.0);\n' + 'ww_col = mix(ww_col, uWDeep * 0.55, clamp(ww_dashD - ww_dash, 0.0, 1.0) * ww_foam * 0.7);\n' + 'ww_col = mix(ww_col, uWFoamC, ww_foam);\n' + 'if (!gl_FrontFacing) { ww_col = mix(ww_col, uWShallow, 0.45); }\n' // 수중 천장 = 밝은 물결 + 'diffuseColor.rgb = ww_col;\n' + 'diffuseColor.a = clamp(mix(uWAlpha * 0.72, uWAlpha, smoothstep(0.05, 0.5, ww_d)) + ww_foam * 0.5 + (gl_FrontFacing ? 0.0 : 0.1), 0.0, 0.96);\n'); sh.fragmentShader = sh.fragmentShader.replace('#include
', '#include
\n' // L3 태양 글리터 — 노이즈 워블 노멀 + 좁은 Blinn(pow 90), 기여 클램프(블로우아웃 3중 방어 1호) + 'vec3 ww_gn = normalize(vWWN + vec3(ww_nz.b - 0.5, 0.0, ww_nz.g - 0.5) * 0.42);\n' + 'float ww_sp = pow(max(dot(ww_V, reflect(-normalize(uWSun), ww_gn)), 0.0), 90.0);\n' + 'float ww_sunh = clamp(normalize(uWSun).y * 1.6, 0.15, 1.0);\n' // 저고도 감쇠(일몰 블로우아웃 커브) + 'totalEmissiveRadiance += min(ww_sp * 1.6 * uWGlint * ww_sunh, 1.15) * vec3(1.0, 0.93, 0.78);\n'); if (sh.vertexShader === vb || sh.fragmentShader === fb) { try { console.error('[three] water shader anchor not found — plain water fallback'); } catch (e) {} if (window.__playable_beacon) window.__playable_beacon('shader_anchor', 'water anchor missing'); } }; mat.customProgramCacheKey = function () { return 'playable-water-v2'; }; var mesh = new THREE.Mesh(geo, mat); mesh.renderOrder = -2; // 투명 중 최선두(지형 뒤·파티클/식생 앞) — 소트 팝핑 봉합 mesh.position.set(num0(o.x), wy, num0(o.z)); scene.add(mesh); if (P._water_y == null || wy > P._water_y) P._water_y = wy; // 수중 모드 감지 수면(최고 수위) // ── L6 도섭/주행 리플 링 — 인스턴스 풀 8, 플레이어/수상 탈것 자동 배선(계약 API 없음) ── var rgeo = new THREE.PlaneGeometry(1, 1); rgeo.rotateX(-Math.PI / 2); var rim = new THREE.InstancedMesh(rgeo, new THREE.MeshBasicMaterial({ map: _ring_tex(), transparent: true, depthWrite: false, opacity: 1, }), 8); rim.name = 'water_ripples'; rim.frustumCulled = false; rim.renderOrder = -1; scene.add(rim); var rings = []; // {x,z,age,life,s0,s1} for (var rz = 0; rz < 8; rz++) rings.push(null); var _rm4 = new THREE.Matrix4(); var _last_ring = 0; P.make._water_rings = rings; // 스모크 관측면(비계약) _env_anims.push(function (raw, elapsed) { uT.value += raw; // 링 스폰 — 물 안에서 이동 중인 플레이어(도섭) / float 탑승(주행 웨이크) var pl = P.world && P.world._priv && P.world._priv.player(); if (pl && pl.handle) { var pp = pl.handle.obj.position; var inw = Math.abs(pp.x - mesh.position.x) < size / 2 && Math.abs(pp.z - mesh.position.z) < size / 2 && pp.y < wy + 0.6 && pp.y > wy - 2.5; var mounted = !!(P.world._priv.mounted && P.world._priv.mounted()); if (inw && (pl.moving || mounted) && elapsed - _last_ring > (mounted ? 0.22 : 0.34)) { _last_ring = elapsed; for (var ri = 0; ri < 8; ri++) { if (!rings[ri]) { rings[ri] = { x: pp.x, z: pp.z, age: 0, life: 0.9, s0: 0.5, s1: mounted ? 2.6 : 1.7 }; break; } } } } var any = false; for (var rj = 0; rj < 8; rj++) { var rg = rings[rj]; if (!rg) { _rm4.makeScale(0.0001, 0.0001, 0.0001); rim.setMatrixAt(rj, _rm4); continue; } rg.age += raw; if (rg.age >= rg.life) { rings[rj] = null; _rm4.makeScale(0.0001, 0.0001, 0.0001); rim.setMatrixAt(rj, _rm4); continue; } any = true; var rt = rg.age / rg.life; var rsc = rg.s0 + (rg.s1 - rg.s0) * rt; _rm4.makeScale(rsc, 1, rsc); _rm4.setPosition(rg.x, wy + 0.04, rg.z); rim.setMatrixAt(rj, _rm4); } rim.material.opacity = 0.5; // per-instance 알파 불가 — 링 텍스처 자체가 소프트 페이드, 수명 스케일이 소멸 서사 rim.instanceMatrix.needsUpdate = true; rim.visible = any; }); return mesh; }, // 인스턴스 스캐터 — jittered grid + 랜덤 Y회전/스케일(수백 그루=드로콜 1). scatter: function (sample, opts) { if (!sample || !sample.geometry) return null; var o = opts || {}; var count = Math.round(_clampn(o.count, 1, 400, 120)); var area = _clampn(o.area, 10, 600, 180); var im = new THREE.InstancedMesh(sample.geometry, sample.material, count); var m4 = new THREE.Matrix4(), q = new THREE.Quaternion(), s = new THREE.Vector3(), pv = new THREE.Vector3(); var side = Math.ceil(Math.sqrt(count)), cell2 = area / side, n = 0; for (var gx = 0; gx < side && n < count; gx++) { for (var gz = 0; gz < side && n < count; gz++, n++) { var x = (gx + 0.5) * cell2 - area / 2 + (Math.random() - 0.5) * cell2 * 0.8; var z = (gz + 0.5) * cell2 - area / 2 + (Math.random() - 0.5) * cell2 * 0.8; var y = typeof o.y === 'function' ? o.y(x, z) : (typeof o.y === 'number' ? o.y : 0); var sc = 0.6 + Math.random() * 0.8; q.setFromAxisAngle(pv.set(0, 1, 0), Math.random() * Math.PI * 2); m4.compose(pv.set(x, y, z), q, s.set(sc, sc, sc)); im.setMatrixAt(n, m4); } } im.instanceMatrix.needsUpdate = true; scene.add(im); return im; }, // blob 그림자 — radial CanvasTexture(가짜 그림자 정본, 실시간 그림자 금지 정책의 접지감 대체). blob_shadow: function (target, opts) { var o = opts || {}; var r = _clampn(o.radius, 0.2, 10, 0.8); var c = document.createElement('canvas'); c.width = c.height = 128; var g = c.getContext('2d'); var grad = g.createRadialGradient(64, 64, 8, 64, 64, 64); grad.addColorStop(0, 'rgba(0,0,0,0.4)'); grad.addColorStop(1, 'rgba(0,0,0,0)'); g.fillStyle = grad; g.fillRect(0, 0, 128, 128); var mesh = new THREE.Mesh( new THREE.PlaneGeometry(r * 2, r * 2), new THREE.MeshBasicMaterial({ map: new THREE.CanvasTexture(c), transparent: true, depthWrite: false }) ); mesh.rotation.x = -Math.PI / 2; mesh.renderOrder = 1; var gy = typeof o.y === 'number' ? o.y : 0.02; mesh.position.y = gy; // 실시간 그림자 활성 중엔 잠복(실그림자+블롭 이중 그림자 방지) — DRS 반납 시 자동 복귀. // 로직은 종전대로 blob_shadow 를 계속 호출한다(저사양 강등 기기의 접지감 폴백이 이것). mesh.visible = !_SHDW.on; _SHDW.blobs.push(mesh); scene.add(mesh); if (target && target.position) { _env_anims.push(function () { mesh.position.x = target.position.x; mesh.position.z = target.position.z; var h = Math.max(0, target.position.y - gy); mesh.material.opacity = Math.max(0.12, 0.55 - h * 0.12); // 부상 시 감쇠(접지감) }); } return mesh; }, }; // ── 포스트프로세싱은 셸이 구성 소유 (L2 예방 — 로직의 컴포저 직접 구성은 게이트가 거부) ── // PLAYABLE.enable_bloom(opts?): 클램프된 파라미터 + 하프해상도 블룸 + OutputPass(색공간/톤매핑 종결) // 순서를 결정론으로 보장. 무클램프 strength/threshold(=0) 조합이 블로우아웃·프레임 저하의 양대 원인. P.enable_bloom = function (opts) { try { // 멱등 가드 — 재호출은 구 컴포저(MSAA RT 포함)를 dispose 없이 고아화하던 실틈(terrain 동형). if (P.three.composer) { try { console.warn('[three] enable_bloom called again — rebuilding post chain (call it once)'); } catch (e0) {} if (P.three.composer.dispose) P.three.composer.dispose(); P.three.composer = null; } var o = opts && typeof opts === 'object' ? opts : {}; var strength = Math.min(0.8, Math.max(0, typeof o.strength === 'number' ? o.strength : 0.45)); var radius = Math.min(1, Math.max(0, typeof o.radius === 'number' ? o.radius : 0.4)); // threshold 는 톤매핑 *이전 선형 HDR* 휘도로 판정된다(LuminosityHighPass) — 1 미만이면 "잘 // 밝혀진 diffuse 면"까지 블룸 소스가 되어 5-mip additive 베일이 화면을 덮는다(실측 워시아웃). // 업계 정본 = 1.0(pmndrs 기본값도 1.0): HDR 초과(emissive>1·스페큘러 피크)만 발광. clamp [0.9,1.5]. var threshold = Math.min(1.5, Math.max(0.9, typeof o.threshold === 'number' ? o.threshold : 1.0)); // 블룸 활성 시 pixelRatio 상한 — "네이티브 대비 비율"로 표현(절대값 1.5 고정은 dpr3 아이폰에서 // 물리 해상도 50% 렌더를 영구 고정하던 흐림 실사고의 근본 결함: dpr2 에선 75%라 잠복). // dpr3→2.0(67%)·dpr2→1.5(75%, 현행 유지)·dpr1→1.0. 서멀 캡(_pr_max) 내. 약한 기기가 캡을 // 못 버티면 DRS 1단계(>1.5→1.5)가 ~2초 내 측정-강등(EMA>28ms × 순 60프레임) — 정적 과방어를 측정 안전망이 대체. var _bloom_cap = Math.min(_pr_max, Math.max(1.5, (window.devicePixelRatio || 1) * 0.67)); if (_pr > _bloom_cap) { _pr = _bloom_cap; fit(); } // MSAA RT 명시 — antialias:true 는 default framebuffer 전용이라 컴포저 기본 RT(HalfFloat, // samples 0)로는 블룸 켠 모든 게임이 AA 를 통째로 잃는다(풀 실루엣 지글거림 실사고). // WebGL2 MSAA(타일 GPU 저비용), clone 이 samples 를 보존함은 r185 소스 확정. var composer = new THREE.EffectComposer(renderer, new THREE.WebGLRenderTarget( Math.max(1, Math.round(P.vw * _pr)), Math.max(1, Math.round(P.vh * _pr)), { type: THREE.HalfFloatType, samples: 4 })); composer.addPass(new THREE.RenderPass(scene, camera)); var bloom_pass = new THREE.UnrealBloomPass( new THREE.Vector2(Math.max(1, P.vw >> 1), Math.max(1, P.vh >> 1)), strength, radius, threshold); composer.addPass(bloom_pass); _bloom_pass = bloom_pass; _bloom_thr_auth = threshold; _rp_bloom_floor(); // realistic: lift the threshold above the measured sky band (dome ≠ bloom source) composer.addPass(new THREE.OutputPass()); // 톤매핑+sRGB 종결 — 누락 시 포스트 체인이 톤매핑을 건너뜀 // "저렴한 시네마틱" 표준 잔여 2패스 — grain·vignette 는 sRGB(OutputPass 뒤)가 정본 순서. // FilmPass(r157+)는 grain 전용(intensity 0.15~0.35 대역), Vignette 는 초점 유도+가장자리 정돈. try { // grain 은 근접-네이티브 전제 미학 — 유효 해상도 비율(_pr/dpr) < 0.7 이면 업스케일 블러 위 // 굵은 노이즈로 증폭되는 "흐림+노이즈" 복합 체감(iPhone dpr3 실사고). DRS 강등 사다리에만 // 있던 이 가드를 base 상태로 승격 — 저해상 기기는 처음부터 grain 미탑재. // (vignette 는 해상도 무관 미학이라 항상 유지. 사다리의 grain-off 단계는 null 가드로 안전.) // 0.95 = 사실상 네이티브 전용 — 구 0.7 은 dpr2 캡(1.5/2.0=0.75)이 통과해 "75% 업스케일 // 소프트 + 그 위 grain" 복합(맥 화질 실보고의 근원)이 정확히 가드 취지 반대로 새고 있었다. if (_pr / (window.devicePixelRatio || 1) >= 0.95) { _film_pass = new THREE.FilmPass(0.12); composer.addPass(_film_pass); } _vignette_pass = new THREE.ShaderPass(THREE.VignetteShader); // 계측 실증(2026-07-11): darkness 1.2 = 프레임 평균 -20pt(지면 94→113 회복) — 업계 통상 // 0.3~0.6 의 2배로 "어두운 액자"였다. 0.5/1.15 = 초점 유도만 남기는 subtle 대역. _vignette_pass.uniforms.offset.value = 1.15; _vignette_pass.uniforms.darkness.value = 0.5; composer.addPass(_vignette_pass); } catch (e2) { _film_pass = _vignette_pass = null; /* 구 vendor 번들(심볼 부재) — bloom 만으로 열화 */ } composer.setSize(P.vw, P.vh); P.three.composer = composer; return composer; } catch (e) { // 블룸은 순수 장식 — 실패 시 직접 렌더로 열화(게임은 절대 깨지 않음). 단 기기-특이 GPU 실패 // 클래스라 비콘 박제(콘솔은 기기에 갇힌다). try { console.error('[playable] enable_bloom failed: ' + (e && e.message)); } catch (_) {} if (window.__playable_beacon) window.__playable_beacon('bloom_fail', (e && e.message) || String(e)); P.three.composer = null; return null; } }; // ── M5 스케일 문법 완결: DOF (opt-in — 미시/디오라마/히어로 클로즈업의 얕은 심도 읽기) ── // PLAYABLE.enable_dof(opts?): BokehPass(1f 번들+) 를 RenderPass 직후에 삽입. 컴포저 부재 시 // 최소 체인(Render→Bokeh→Output) 자체 구성. 구 번들(심볼 부재)=LOUD no-op(전방-전용 열화). // aperture 는 three 예제 대역(1e-4 급)을 클램프로 강제 — 무클램프 대구경은 전면 블러 실사고 클래스. P.enable_dof = function (opts) { try { if (typeof THREE.BokehPass !== 'function') { try { console.warn('[playable] enable_dof: bundle has no BokehPass (pre-1f) — no-op'); } catch (e0) {} return null; } var o = opts && typeof opts === 'object' ? opts : {}; var focus = Math.min(200, Math.max(1.5, typeof o.focus === 'number' ? o.focus : 18)); var aperture = Math.min(0.0005, Math.max(0.00002, typeof o.aperture === 'number' ? o.aperture : 0.00012)); var maxblur = Math.min(0.012, Math.max(0.001, typeof o.maxblur === 'number' ? o.maxblur : 0.006)); var pass = new THREE.BokehPass(scene, camera, { focus: focus, aperture: aperture, maxblur: maxblur }); if (!P.three.composer) { var composer2 = new THREE.EffectComposer(renderer, new THREE.WebGLRenderTarget( Math.max(1, Math.round(P.vw * _pr)), Math.max(1, Math.round(P.vh * _pr)), { type: THREE.HalfFloatType, samples: 4 })); // MSAA RT 캐논(enable_bloom 동형 — AA 상실 방지) composer2.addPass(new THREE.RenderPass(scene, camera)); composer2.addPass(pass); composer2.addPass(new THREE.OutputPass()); composer2.setSize(P.vw, P.vh); P.three.composer = composer2; } else { P.three.composer.insertPass(pass, 1); // RenderPass 직후·블룸 이전(HDR 공간 심도 → 발광 → 톤매핑 종결) } _dof_pass = pass; return { set_focus: function (d) { if (_dof_pass) _dof_pass.uniforms.focus.value = Math.min(200, Math.max(1.5, +d || focus)); }, remove: function () { if (_dof_pass && P.three.composer) { P.three.composer.removePass(_dof_pass); _dof_pass = null; } }, }; } catch (e) { try { console.error('[playable] enable_dof failed: ' + (e && e.message)); } catch (_) {} if (window.__playable_beacon) window.__playable_beacon('dof_fail', (e && e.message) || String(e)); return null; } }; var _dof_pass = null; // ── 동적 해상도 스케일링 (L4 런타임 안전망 — *어떤 원인이든* 지속 프레임 저하를 화질로 흡수) ── // 프레임타임 EMA 가 ~28ms(≈36fps 미만)를 60프레임 연속 유지하면 사다리 강등: // 그림자 off(blob 복귀) → pixelRatio 1.5 → 1.25 → 1.0 → (최종) 블룸 off. 다운-온리(진동 방지). // 0단 = 그림자: 가장 비싼 순수-장식이자 반납 시 시각 하한이 "종전 상태"로 정확히 복귀하는 유일 단. var _ema = 16, _slow_frames = 0; var _film_pass = null, _vignette_pass = null; // 품질 저하 이벤트 비콘 — DRS 강등/노출 클램프가 console 에만 갇혀 "왜 흐리냐/어둡냐"가 재현 불가 // 미스터리가 되던 관측성 공백 봉합. 세션당 1회(오류 비콘의 3회 예산을 품질 이벤트가 먹지 않게). var _q_beaconed = false; function _quality_beacon(msg) { if (_q_beaconed) return; _q_beaconed = true; if (window.__playable_beacon) window.__playable_beacon('quality_degrade', msg); } function _degrade_step() { if (_shadow_off()) return 'shadow off (blob fallback)'; if (_pr > 1.5) { _pr = 1.5; fit(); return 'pixelRatio→1.5'; } // grain 은 고해상 전제 미학 — 저해상 업스케일과 결합하면 굵은 노이즈로 증폭돼 "화질 저하 + // 노이즈" 복합 체감을 만든다(iPhone 실사고). 해상도를 더 내리기 전에 먼저 몰수한다. if (_film_pass && _film_pass.enabled) { _film_pass.enabled = false; if (_vignette_pass) _vignette_pass.enabled = false; return 'grain/vignette off (low-res guard)'; } if (_pr > 1.25) { _pr = 1.25; fit(); return 'pixelRatio→1.25'; } // 등록된 감량기(nature 풀 인스턴스 count 등 — 무비용 LOD) 우선 소진 if (P._perf_reducers && P._perf_reducers.length) { for (var ri = 0; ri < P._perf_reducers.length; ri++) { var did = null; try { did = P._perf_reducers[ri](); } catch (e) {} if (did) return did; } } if (_pr > 1.0) { _pr = 1.0; fit(); return 'pixelRatio→1.0'; } if (P.three.composer) { P.three.composer = null; return 'bloom off'; } return null; } // ── 자동 노출 — 워시아웃 안전망 v2 (포화-비율 계측 + 양방향 중립 수렴) ── // 판정 지표 = "평균 밝기"가 아니라 **포화(near-white) 픽셀 비율** — 워시아웃의 정의 그 자체 // (카메라 특허의 "포화 픽셀 비율 유지" + UE 히스토그램 하이라이트-우선 percentile 동계). // ⚠️ 구 구현의 2중 결함(2026-07-11 실기기 exposure→0.50 실사고 — sunset 들판이 밤으로 렌더): // ①단위 불일치 — RT 렌더는 톤맵 전 *선형*값인데 임계 0.22 는 표시-감각 수치. 건강한 황금 // 들판의 선형 평균은 0.44+ 가 정상(ACES 가 표시에서 압축) → 정상 씬을 워시아웃으로 오판, // 첫 측정에서 0.22/0.44 = 바닥 0.5 직행. // ②하향-단방향 + 2초 반복 = 시선-의존 래칫 — 밝은 프레이밍을 한 번 지나가면 영구 감광. // 업계 정본(UE/Unity eye-adaptation)은 예외 없이 *양방향* 적응 + EV 상·하한이다. // v2 구조: 노출은 [0.6, _EXP_BASE] 안에서 "현재 프레임 포화-비율"에만 반응해 양방향 수렴 — // 상태 누적이 없어 래칫이 표현 불가능(원인 소멸 = 자동 중립 복귀). 1.0 초과 증폭은 하지 // 않는다(grey-should-be-grey 전면 AE 는 밤/새벽 무드의 authored 어둠을 파괴 — 채택 기각). // 계측 = FloatType RT(선형 HDR 그대로 — 8bit RT 는 1.0 에서 포화돼 과노출 *정도*를 읽지 못해 // 피드백이 다시 파손된다). float 렌더 미지원 기기 = catch → AE 통째 비활성(저작 노출 1.0 // 유지가 안전 기본값 — 안전망은 게임을 절대 깨지 않음). var _ae_rt = null, _ae_buf = null; function _auto_exposure_meter() { try { if (!_ae_rt) { _ae_rt = new THREE.WebGLRenderTarget(32, 32, { type: THREE.FloatType, minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter }); // readback-only; float32+LINEAR is illegal on iPhone GPUs (texpolicy canon) _ae_buf = new Float32Array(32 * 32 * 4); } renderer.setRenderTarget(_ae_rt); renderer.render(scene, camera); renderer.setRenderTarget(null); renderer.readRenderTargetPixels(_ae_rt, 0, 0, 32, 32, _ae_buf); var exp = renderer.toneMappingExposure; var hot = 0; for (var i = 0; i < _ae_buf.length; i += 4) { var lum = 0.2126 * _ae_buf[i] + 0.7152 * _ae_buf[i + 1] + 0.0722 * _ae_buf[i + 2]; if (lum * exp > 1.5) hot++; // 표시 근사 ACES(1.5)≈0.90 — "거의 백색" 픽셀 } var frac = hot / (32 * 32); // 데드밴드 [0.12, 0.30] — 사이면 무반응(경계 씬 진동 차단). 초과 = 워시아웃 → 감광 스텝, // 미만 && 감광 상태 = 원인 소멸 → 중립(1.0) 복원 스텝. 스텝형 수렴(×0.8 / ×1.25, ~2초 주기). if (frac > 0.30) { var down = Math.max(0.6, exp * 0.8); if (down < exp - 0.01) { renderer.toneMappingExposure = down; try { console.info('[playable] washout clamp → ' + down.toFixed(2) + ' (hot ' + Math.round(frac * 100) + '%)'); } catch (_) {} _quality_beacon('exposure→' + down.toFixed(2)); } } else if (frac < 0.12 && exp < _EXP_BASE) { renderer.toneMappingExposure = Math.min(_EXP_BASE, exp * 1.25); } } catch (e) { /* float RT 미지원 등 = AE 비활성 (안전망은 게임을 절대 깨지 않음) */ } } // 렌더 패스 — 로직이 전역 playable_update(dt, elapsed) 를 정의하면 매 프레임 호출. // 포스트 체인(P.three.composer)은 PLAYABLE.enable_bloom 이 설정한 것을 사용. // P._loop_render(렌더-최종 페이즈): 같은 프레임의 전 시뮬레이션(world/combat/fps/카메라 리그) // *이후* 에 그린다 — P.loop 등록 순서상 렌더가 먼저 돌아 항상 직전 프레임을 그리던 상시 // 1프레임 표시 지연(FPS 조준 지연 체감)의 구조 봉합. playable_update 는 이 패스 선두 = // 시뮬레이션 결과를 본 뒤 렌더 직전에 개입하는 로직 훅. var _ae_t = 1.0; // 자동 노출 계측 누적 시계(벽시계 — 프레임 카운트는 저FPS 헤드리스에서 미발화하던 검증 사각) // 프레임 캡처 — preserveDrawingBuffer:false 라 render 직후 같은 태스크에서만 픽셀이 유효. // 큐에 쌓고 렌더 패스 말미에 복사본 캔버스로 resolve(공유 카드/스크린샷 저장의 유일 캡처 경로). var _cap_q = []; P.three.capture = function () { return new Promise(function (res) { _cap_q.push(res); }); }; P._loop_render(function (dt, elapsed, raw) { // dt = 게임 시간(fx.hitstop 중 0 — 로직 동결), raw = 벽시계(계측 전용). if (typeof window.playable_update === 'function') window.playable_update(dt, elapsed); // 수중 모드 — 카메라가 등록 수면 아래로 잠기면 포그 스왑 + 청색 오버레이(복귀 시 원복) // + L7 수중 강화(2026-07-12 리서치): 가짜 갓레이(세로 광선 빌보드 — 볼류메트릭 금지 정본) // + 상승 기포(wobble·수면 직전 페이드 — '팝' 소멸이 최다 지적 실수) + marine snow 표류. if (P._water_y != null) { var _uw = camera.position.y < P._water_y - 0.12; if (_uw !== P._underwater) { P._underwater = _uw; if (_uw) { if (scene.fog) { P._fog_save = { c: scene.fog.color.getHex(), n: scene.fog.near, f: scene.fog.far }; scene.fog.color.set('#1E5E86'); scene.fog.near = 2; scene.fog.far = 34; } if (!P._uw_ov) { P._uw_ov = document.createElement('div'); P._uw_ov.style.cssText = 'position:fixed;inset:0;z-index:15;pointer-events:none;background:rgba(24,84,128,.28);'; document.body.appendChild(P._uw_ov); } P._uw_ov.style.display = 'block'; if (!P._uw_fx) { // 지연 1회 생성(수중 미사용 게임 비용 0) var _ug = new THREE.Group(); _ug.name = 'uw_fx'; var _rc = document.createElement('canvas'); _rc.width = 64; _rc.height = 256; var _rg = _rc.getContext('2d'); var _rgr = _rg.createLinearGradient(0, 0, 0, 256); _rgr.addColorStop(0, 'rgba(210,240,255,0.55)'); _rgr.addColorStop(1, 'rgba(210,240,255,0)'); _rg.fillStyle = _rgr; for (var _rb = 0; _rb < 5; _rb++) _rg.fillRect(4 + _rb * 13, 0, 4 + (_rb % 2) * 3, 256); var _rtex = new THREE.CanvasTexture(_rc); var _rmat = new THREE.MeshBasicMaterial({ map: _rtex, transparent: true, opacity: 0.16, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide }); for (var _rp2 = 0; _rp2 < 4; _rp2++) { var _rpl = new THREE.Mesh(new THREE.PlaneGeometry(7, 14), _rmat); _rpl.rotation.y = _rp2 * 0.9 + 0.4; _rpl.position.set(Math.sin(_rp2 * 1.7) * 6, 0, Math.cos(_rp2 * 2.3) * 6); _ug.add(_rpl); } var _bn = 48, _bpos = new Float32Array(_bn * 3), _bsp = new Float32Array(_bn); for (var _bi = 0; _bi < _bn; _bi++) { _bpos[_bi * 3] = (Math.random() - 0.5) * 16; _bpos[_bi * 3 + 1] = -Math.random() * 8; _bpos[_bi * 3 + 2] = (Math.random() - 0.5) * 16; _bsp[_bi] = (_bi % 3 === 0) ? 0.5 + Math.random() * 0.7 : 0.05 + Math.random() * 0.08; // 1/3 기포(상승), 2/3 marine snow(표류) } var _bgeo = new THREE.BufferGeometry(); _bgeo.setAttribute('position', new THREE.BufferAttribute(_bpos, 3)); var _bpts = new THREE.Points(_bgeo, new THREE.PointsMaterial({ color: 0xD8EEFA, size: 0.14, transparent: true, opacity: 0.55, depthWrite: false })); _bpts.frustumCulled = false; _ug.add(_bpts); scene.add(_ug); P._uw_fx = { g: _ug, pts: _bpts, pos: _bpos, sp: _bsp, n: _bn }; } P._uw_fx.g.visible = true; } else { if (scene.fog && P._fog_save) { scene.fog.color.setHex(P._fog_save.c); scene.fog.near = P._fog_save.n; scene.fog.far = P._fog_save.f; } if (P._uw_ov) P._uw_ov.style.display = 'none'; if (P._uw_fx) P._uw_fx.g.visible = false; } } if (P._underwater && P._uw_fx) { // 갓레이/기포 카메라 추종 + 상승/표류(할당 0) var _UF = P._uw_fx; _UF.g.position.set(camera.position.x, P._water_y - 6, camera.position.z); _UF.g.rotation.y += raw * 0.02; for (var _bu = 0; _bu < _UF.n; _bu++) { var _by = _UF.pos[_bu * 3 + 1] + _UF.sp[_bu] * raw * (_bu % 3 === 0 ? 1 : 0.3); if (_by > 5.6) _by = -8; // 수면(그룹 로컬 +6) 직전 리셋 — 도달 전 소멸('팝' 방지) _UF.pos[_bu * 3 + 1] = _by; if (_bu % 3 === 0) _UF.pos[_bu * 3] += Math.sin(elapsed * 2.1 + _bu) * raw * 0.35; // 기포 wobble } _UF.pts.geometry.attributes.position.needsUpdate = true; } } // 그림자 추종 프러스텀 — 앵커(플레이어∨카메라)를 광원 고정축 평면에 투영해 텍셀 스냅(축이 // 회전하지 않으므로 스냅이 정확 — 서브텍셀 평행이동 쉬머의 구조 봉인). if (_SHDW.on && _SHDW.light) { var _sp2 = (P.world && P.world._priv && P.world._priv.player()); var _sa = _sp2 ? _sp2.handle.obj.position : camera.position; var _ar = Math.round(_sa.dot(_shdw_right) / _SHDW.texel) * _SHDW.texel; var _au = Math.round(_sa.dot(_shdw_up) / _SHDW.texel) * _SHDW.texel; _shdw_v.copy(_shdw_right).multiplyScalar(_ar).addScaledVector(_shdw_up, _au).addScaledVector(_shdw_dir, _sa.dot(_shdw_dir)); _SHDW.light.target.position.copy(_shdw_v); _SHDW.light.position.copy(_shdw_v).addScaledVector(_shdw_dir, 60); _SHDW.light.target.updateMatrixWorld(); } // 카메라 셰이크 = 렌더-시점 오버레이(가산→렌더→원복) — 카메라 소유자(월드 리그/OrbitControls/ // 로직 lookAt)가 회전을 매 프레임 재구축해도 무효화될 수 없는 유일한 적용 지점. var _shk = P.fx && P.fx._cam_shake ? P.fx._cam_shake() : null; if (_shk) { camera.rotation.x += _shk.x; camera.rotation.y += _shk.y; camera.rotation.z += _shk.z; } // 모달 렌더 오버라이드(아바타 페인트 화면 등) — *같은 renderer/context* 를 빌려 자기 씬을 그린다 // (2차 WebGL 컨텍스트 생성은 iOS 컨텍스트 상한 위험 → 재사용이 정본). 게임 루프는 계속 돌되 화면만 대체. if (P._modal_render) { try { P._modal_render(renderer); } catch (e) {} } else if (P.three.composer) P.three.composer.render(); else renderer.render(scene, camera); if (P._fps_overlay) { try { P._fps_overlay(); } catch (e) {} } // FPS 뷰모델(clearDepth 오버레이 — 포스트 체인 제외) if (_cap_q.length) { var _cqs = _cap_q; _cap_q = []; try { var _cc = document.createElement('canvas'); _cc.width = renderer.domElement.width; _cc.height = renderer.domElement.height; _cc.getContext('2d').drawImage(renderer.domElement, 0, 0); for (var _cqi = 0; _cqi < _cqs.length; _cqi++) _cqs[_cqi](_cc); } catch (e) { for (var _cqe = 0; _cqe < _cqs.length; _cqe++) _cqs[_cqe](null); } } if (_shk) { camera.rotation.x -= _shk.x; camera.rotation.y -= _shk.y; camera.rotation.z -= _shk.z; } // DRS 감시 — raw(벽시계) EMA(히트스톱의 dt=0 이 계측을 오염하지 않게). 첫 3초는 워밍업 제외. if (elapsed > 3) { _ema = _ema * 0.9 + (raw * 1000) * 0.1; if (_ema > 28) { _slow_frames++; } else if (_slow_frames > 0) { _slow_frames--; } if (_slow_frames >= 60) { var applied = _degrade_step(); _slow_frames = 0; _ema = 16; if (applied) { try { console.info('[playable] sustained low fps — degraded: ' + applied); } catch (_) {} _quality_beacon('drs:' + applied); } } } // 자동 노출 클램프 — 2초 후 첫 측정, 이후 ~2초 주기(120프레임). // 카메라 피드 활성(P._arcam_feed — arcam 투명 합성 중)에는 계측 정지: 가시 프레임의 지배 // 성분(카메라 피드)이 GL 파이프라인 밖이라 GL 포화-비율이 표시 이미지를 대표하지 못한다 // (투명 배경 위 오버레이만 계측 = 무의미 → 저작 노출 유지). 피드 없는 강등 모드(gyro/touch // 3D 배경)는 일반 씬이므로 AE 안전망 유지. _ae_t += raw; if (elapsed > 2 && _ae_t >= 2 && !P._arcam_feed) { _ae_t = 0; _auto_exposure_meter(); } // Realistic profile: the sky follows the shared sun every frame (uniform copy — nature.time_of_day // animates P._sun_dir and the physical sky tracks it for free). Fog re-syncs to the measured sky // horizon only on large sun moves, piggybacking the AE ~2s cadence; while time_of_day is active // its painterly ramps own the fog color, so the readback stands down. if (_rp.sky) { _rp.sky.material.uniforms.sunPosition.value.copy(P._sun_dir.value); if (!P._tod_teardown && _rp.last_sun && _ae_t === 0 && _rp.last_sun.dot(P._sun_dir.value) < 0.9997) { _rp_sky_measure(); _rp_env_rebuild(); } } }); // 포인터 입력(논리 좌표 = 스테이지 px; NDC 는 P.pointer_ndc 로 변환 제공). P.pointer_ndc = function (p) { return { x: (p.x / P.vw) * 2 - 1, y: -(p.y / P.vh) * 2 + 1 }; }; P.bind_pointer(canvas, { down: function (p, e) { if (window.playable_pointer_down) window.playable_pointer_down(p, e); }, move: function (p, e, active) { if (window.playable_pointer_move) window.playable_pointer_move(p, e, active); }, up: function (p, e) { if (window.playable_pointer_up) window.playable_pointer_up(p, e); }, }); if (loading && loading.parentNode) loading.parentNode.removeChild(loading); })(); b ? b : v); } function num(v, d) { return typeof v === 'number' && isFinite(v) ? v : d; } // ── easing 8종 (Penner 계열 — GSAP/anime 기본값 수렴점 = outQuad) ── var _c1 = 1.70158; var EASE = { linear: function (t) { return t; }, inQuad: function (t) { return t * t; }, outQuad: function (t) { return t * (2 - t); }, inOutSine: function (t) { return 0.5 - 0.5 * Math.cos(Math.PI * t); }, outCubic: function (t) { return 1 - Math.pow(1 - t, 3); }, outBack: function (t) { var u = t - 1; return 1 + (_c1 + 1) * u * u * u + _c1 * u * u; }, inBack: function (t) { return (_c1 + 1) * t * t * t - _c1 * t * t; }, outElastic: function (t) { if (t === 0 || t === 1) return t; return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * (2 * Math.PI / 3)) + 1; }, }; fx.ease = EASE; // ── 프레임률 독립 감쇠 — per-frame lerp(a,b,0.1) 오류 클래스의 정본 대체 ── fx.damp = function (current, target, lambda, dt) { return current + (target - current) * (1 - Math.exp(-num(lambda, 8) * num(dt, 0.016))); }; // ── 시간(히트스톱) — gameDt 만 동결(P.loop 이 _frozen 훅 소비), fx 자체 연출은 raw 로 진행 ── var _freeze = 0; fx._frozen = function () { return _freeze > 0; }; fx.hitstop = function (dur) { _freeze = Math.max(_freeze, clamp(num(dur, 0.08), 0, 0.3)); // max-대체(스택 금지) }; // ── 트윈 — 숫자 프로퍼티 전용(중첩은 하위 객체를 obj 로: mesh.position 등) ── var _tweens = []; fx.tween = function (obj, props, opts) { if (!obj || !props) return; var o = opts || {}; var t = { obj: obj, keys: [], t: 0, dur: clamp(num(o.dur, 0.3), 0.01, 10), ease: EASE[o.ease] || EASE.outQuad, delay: clamp(num(o.delay, 0), 0, 10), onDone: typeof o.onDone === 'function' ? o.onDone : null, }; for (var k in props) { if (typeof obj[k] === 'number' && typeof props[k] === 'number') { t.keys.push([k, obj[k], props[k]]); } } if (t.keys.length) _tweens.push(t); return t; }; // ── 카메라/화면 셰이크 — trauma 모델(shake=trauma², 3D=회전만·2D/dom=CSS 이동+회전) ── var _trauma = 0, _shake_t = 0; var _cam_off = { x: 0, y: 0, z: 0 }; fx.shake = function (trauma) { _trauma = Math.max(_trauma, clamp(num(trauma, 0.3), 0, 1)); }; fx._cam_shake = function () { return _cam_off; }; // 렌더 루프 전용(3D 셰이크의 유일 소비자) fx.set_shake_scale = function (s) { _shake_scale = clamp(num(s, 1), 0, 1); }; // 접근성 훅 var _shake_scale = 1; function _noise(t, seed) { // 저비용 유사-Perlin(사인 합성) — 채널별 시드 return Math.sin(t * 19.1 + seed) * 0.55 + Math.sin(t * 47.7 + seed * 2.3) * 0.3 + Math.sin(t * 89.3 + seed * 4.7) * 0.15; } function _apply_shake(raw) { _shake_t += raw; var s = _trauma * _trauma * _shake_scale; // 지각은 지수적 if (IS3D) { // 여기서 오프셋만 계산 — cam.rotation 에 직접 가산하지 않는다. 카메라 소유자(world 리그· // OrbitControls)의 매 프레임 lookAt 이 회전을 재구축해 가산분을 항상 소거하기 때문(실측 무효화). // 적용은 렌더 루프가 렌더 직전 가산→렌더→원복으로 소비(fx._cam_shake) — 누구도 지울 수 없는 계층. if (s > 0.0001) { _cam_off.x = _noise(_shake_t, 1) * 0.045 * s; _cam_off.y = _noise(_shake_t, 2) * 0.045 * s; _cam_off.z = _noise(_shake_t, 3) * 0.06 * s; } else { _cam_off.x = _cam_off.y = _cam_off.z = 0; } } else { var el = IS2D ? P.canvas : P.root; var ov = _overlay_canvas; if (el) { var tr = ''; if (s > 0.0001) { var mx = (IS2D ? 12 : 8), ma = 0.03; tr = 'translate(' + (_noise(_shake_t, 1) * mx * s).toFixed(1) + 'px,' + (_noise(_shake_t, 2) * mx * s).toFixed(1) + 'px) rotate(' + (_noise(_shake_t, 3) * ma * s).toFixed(4) + 'rad)'; } el.style.transform = tr; if (ov) ov.style.transform = tr; } } _trauma = Math.max(0, _trauma - raw * 0.9); // 선형 감쇠 } // ── 사운드 — ZzFX 프리셋 패밀리 + 피치 규율(±5% 랜덤, 콤보 반음 상승) 내장 ── // 파라미터 배열 = 큐레이션 데이터(파생 불가) — index 0=volume, 2=frequency. var _SFX = { hit: [, , 528, .01, , .48, , .6, -11.6, , , , .32, 4.2], explosion: [1.2, , 333, .01, 0, .9, 4, 1.9, , , , , , .5, , .6], pickup: [, , 925, .04, .3, .6, 1, .3, , 6.27, -184, .09, .17], powerup: [, , 80, .3, .4, .7, 2, .1, -0.73, 3.42, -430, .09, .17, , , , .19], jump: [, , 150, .05, , .05, , 1.3, , , , , , 3], shoot: [, , 90, , .01, .03, 4, , , , , , , 9, 50, .2, , .2, .01], blip: [.6, , 1675, , .06, .24, 1, 1.82, , , 837, .06], }; var _combo = {}; // name → {n, until} // 실클립 오버레이(2026-07-25 고품질 캐논): 생성기가 fx_
_N 별칭을 배선하면 신스 대신 실녹음 재생 // (콤보 반음 상승·피치 변주는 rate 로 승계). 미배선(구세대/오프라인) = 신스 폴백 — forward-only. var _FX_LIB_N = { hit: 3, explosion: 4, pickup: 3, powerup: 2, shoot: 3, blip: 3 }; fx.sfx = function (name, opts) { var base = _SFX[name]; if (!base) return; var o = opts || {}; var params = base.slice(); var pitch = 1 + (Math.random() * 2 - 1) * clamp(num(o.pitchVar, 0.05), 0, 0.2); if (o.combo) { var now = performance.now(); var c = _combo[name]; if (c && now < c.until) c.n = Math.min(c.n + 1, 12); else c = { n: 0 }; c.until = now + 1200; _combo[name] = c; pitch *= Math.pow(1.06, c.n); // 반음(×1.06) 상승 } var libn = _FX_LIB_N[name]; if (libn && P.sfx.lib_has && P.sfx.lib_has('fx_' + name + '_1')) { var vi = 1 + ((Math.random() * libn) | 0); if (!P.sfx.lib_has('fx_' + name + '_' + vi)) vi = 1; // 변주 결번 관용 P.sfx.lib('fx_' + name + '_' + vi, { volume: clamp(num(o.volume, 1), 0, 2), rate: clamp(pitch, 0.5, 1.8) }); return; } if (typeof params[2] === 'number') params[2] *= pitch; params[0] = (typeof params[0] === 'number' ? params[0] : 1) * clamp(num(o.volume, 1), 0, 2); P.sfx(params); }; // ── 파티클 풀 (3D=Points 단일 풀 / 2D=오버레이 캔버스 사각) — burst·trail 공용 ── var _POOL = 224; var _parts = []; // {alive, x,y,z, vx,vy,vz, life, life0, size, r,g,b, gravity} for (var i = 0; i < _POOL; i++) _parts.push({ alive: false }); var _free_scan = 0; function _spawn_part(x, y, z, vx, vy, vz, life, size, col, gravity) { for (var n = 0; n < _POOL; n++) { var p = _parts[(_free_scan + n) % _POOL]; if (!p.alive) { _free_scan = (_free_scan + n + 1) % _POOL; p.alive = true; p.x = x; p.y = y; p.z = z; p.vx = vx; p.vy = vy; p.vz = vz; p.life = p.life0 = life; p.size = size; p.gravity = gravity; p.r = col.r; p.g = col.g; p.b = col.b; return; } } } function _col(css) { var c = { r: 1, g: 1, b: 1 }; try { if (IS3D) { var t = new THREE.Color(css); c.r = t.r; c.g = t.g; c.b = t.b; } else { var m = /^#?([0-9a-f]{6})$/i.exec(String(css).replace('#', '')); if (m) { var v = parseInt(m[1], 16); c.r = ((v >> 16) & 255) / 255; c.g = ((v >> 8) & 255) / 255; c.b = (v & 255) / 255; } } } catch (e) {} return c; } // 3D 풀 렌더 오브젝트(lazy) var _points = null, _pos_attr = null, _col_attr = null; function _ensure_points() { if (_points || !IS3D) return; var g = new THREE.BufferGeometry(); var pos = new Float32Array(_POOL * 3), col = new Float32Array(_POOL * 3); _pos_attr = new THREE.BufferAttribute(pos, 3); _pos_attr.setUsage(THREE.DynamicDrawUsage); _col_attr = new THREE.BufferAttribute(col, 3); _col_attr.setUsage(THREE.DynamicDrawUsage); g.setAttribute('position', _pos_attr); g.setAttribute('color', _col_attr); var m = new THREE.PointsMaterial({ size: 0.18, vertexColors: true, transparent: true, opacity: 0.95, blending: THREE.AdditiveBlending, depthWrite: false, sizeAttenuation: true }); _points = new THREE.Points(g, m); _points.frustumCulled = false; P.three.scene.add(_points); } // 2D 오버레이 캔버스(파티클/플로팅텍스트/플래시 — 게임 캔버스와 분리, 로직 드로잉 불간섭) var _overlay_canvas = null, _octx = null; function _ensure_overlay() { if (_overlay_canvas || !IS2D) return; _overlay_canvas = document.createElement('canvas'); _overlay_canvas.id = 'playable-fx-overlay'; _overlay_canvas.style.cssText = 'position:absolute;pointer-events:none;image-rendering:pixelated;'; P.canvas.parentNode.appendChild(_overlay_canvas); _octx = _overlay_canvas.getContext('2d'); function sync() { var r = P.canvas.getBoundingClientRect(); var pr = P.canvas.parentNode.getBoundingClientRect(); _overlay_canvas.style.left = (r.left - pr.left) + 'px'; _overlay_canvas.style.top = (r.top - pr.top) + 'px'; _overlay_canvas.style.width = r.width + 'px'; _overlay_canvas.style.height = r.height + 'px'; // 백킹 해상도 = 게임 캔버스와 동일 정책: pixel 경로는 논리 해상도(정수 스케일·pixelated), // 벡터 경로는 CSS×dpr(캡 2) — 구현이 항상 논리 해상도였던 탓에 벡터 게임 위 파티클/텍스트만 // 저해상으로 뭉개지는 계층 불일치가 있었다(canvas2d fit 의 dpr 정책과 동형). if (P.view && P.view.pixel === false) { var _odpr = Math.min(2, window.devicePixelRatio || 1); _overlay_canvas.width = Math.round(P.view.w * P.view.scale * _odpr); _overlay_canvas.height = Math.round(P.view.h * P.view.scale * _odpr); _octx.setTransform(P.view.scale * _odpr, 0, 0, P.view.scale * _odpr, 0, 0); // 이하 논리 좌표 유지 _octx.imageSmoothingEnabled = true; _overlay_canvas.style.imageRendering = 'auto'; } else { _overlay_canvas.width = P.view.w; _overlay_canvas.height = P.view.h; _octx.setTransform(1, 0, 0, 1, 0, 0); _octx.imageSmoothingEnabled = false; _overlay_canvas.style.imageRendering = 'pixelated'; } } P.on_resize(sync); sync(); fx._sync_overlay = sync; } // ── burst — one-shot 파편 ── fx.burst = function (pos, opts) { pos = P._nv3 ? P._nv3(pos, 0, 0, 0) : pos; // 객체/배열 수용(L2) var o = opts || {}; var count = clamp(Math.round(num(o.count, 20)), 1, 80); var col = _col(o.color || '#ffffff'); var speed = num(o.speed, IS3D ? 6 : 120); var life = clamp(num(o.life, 0.5), 0.05, 3); var size = num(o.size, IS3D ? 0.16 : 3); var spread = num(o.spread, Math.PI * 2); var gravity = num(o.gravity, 0); var px = pos && num(pos.x, 0), py = pos && num(pos.y, 0), pz = pos ? num(pos.z, 0) : 0; if (IS3D) _ensure_points(); else if (IS2D) _ensure_overlay(); else return; for (var n = 0; n < count; n++) { var a = (Math.random() - 0.5) * spread, b = (Math.random() - 0.5) * spread; var sp = speed * (0.4 + Math.random() * 0.8); var vx, vy, vz = 0; if (IS3D) { vx = Math.sin(a) * Math.cos(b) * sp; vy = Math.abs(Math.cos(a)) * sp * 0.8; vz = Math.sin(b) * sp; } else { vx = Math.cos(a) * sp; vy = Math.sin(a) * sp; } _spawn_part(px, py, pz, vx, vy, vz, life * (0.6 + Math.random() * 0.6), size * (0.6 + Math.random() * 0.8), col, gravity); } if (o.sound) fx.sfx(typeof o.sound === 'string' ? o.sound : 'hit'); }; // ── trail — 대상 위치에 파편을 지속 방출(파티클 트레일 — 링버퍼 리본의 저위험 대체) ── var _trails = []; fx.trail = function (target, opts) { var o = opts || {}; var t = { target: target, on: true, acc: 0, rate: clamp(num(o.rate, 40), 5, 90), color: o.color || '#8fd0ff', life: clamp(num(o.life, 0.4), 0.05, 2), size: num(o.size, IS3D ? 0.12 : 2.5), }; _trails.push(t); return { stop: function () { t.on = false; } }; }; // ── flash — 3D: 대상 재질 흰 점멸(자동 복원) / 2D·dom: 화면 플래시로 열화 ── var _flashes = []; fx.flash = function (target, opts) { var o = opts || {}; var dur = clamp(num(o.dur, 0.06), 0.02, 0.5); if (IS3D && target && target.material && target.material.emissive) { var m = target.material; // emissive base 는 userData 공유 슬롯이 단일 진실(combat 의 피격/텔레그래프와 소유권 공유) — // 서로의 "일시 상태"를 base 로 박제해 영구 발광하는 이중-스냅샷 충돌을 차단. if (m.userData.__base_emi == null) { m.userData.__base_emi = m.emissive.getHex(); m.userData.__base_int = m.emissiveIntensity; } _flashes.push({ m: m, t: dur }); m.emissive.set(o.color || '#ffffff'); m.emissiveIntensity = 2; } else fx.screen_flash(o.color, dur); }; var _screen_flash_el = null; fx.screen_flash = function (color, dur) { try { if (!_screen_flash_el) { _screen_flash_el = document.createElement('div'); _screen_flash_el.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:99970;opacity:0;transition:opacity .05s;'; document.body.appendChild(_screen_flash_el); } _screen_flash_el.style.background = color || '#ffffff'; _screen_flash_el.style.opacity = '0.55'; var d = clamp(num(dur, 0.08), 0.03, 0.5) * 1000; setTimeout(function () { _screen_flash_el.style.transition = 'opacity ' + Math.round(d) + 'ms'; _screen_flash_el.style.opacity = '0'; }, 30); } catch (e) {} }; // ── squash & stretch — 부피 보존(1/√s), 종료 시 원 scale 강제 리셋(드리프트 차단). 3D 전용 ── fx.squash = function (target, opts) { if (!IS3D || !target || !target.scale) return; var o = opts || {}; var amount = clamp(num(o.amount, 0.25), 0.02, 0.5); var dur = clamp(num(o.dur, 0.18), 0.05, 1); var axis = o.axis === 'x' || o.axis === 'z' ? o.axis : 'y'; var s0 = { x: target.scale.x, y: target.scale.y, z: target.scale.z }; var sq = 1 - amount, st = 1 / Math.sqrt(sq); target.scale[axis] = s0[axis] * sq; var others = ['x', 'y', 'z'].filter(function (k) { return k !== axis; }); others.forEach(function (k) { target.scale[k] = s0[k] * st; }); var back = {}; back[axis] = s0[axis]; others.forEach(function (k) { back[k] = s0[k]; }); fx.tween(target.scale, back, { dur: dur, ease: 'outBack', onDone: function () { target.scale.set(s0.x, s0.y, s0.z); } }); }; // ── floating text — 3D: Sprite 빌보드 / 2D: 오버레이 / dom: 중앙 상승 div ── var _floats = []; fx.float_text = function (pos, text, opts) { pos = P._nv3 ? P._nv3(pos, 0, 0, 0) : pos; var o = opts || {}; var dur = clamp(num(o.dur, 0.8), 0.2, 3); if (IS3D) { // 백킹 512×192(구 256×96 은 근접 시 글리프 업스케일 블러) + sRGB colorSpace(색 텍스트가 // 선형으로 오해석되던 감마 오차 — 패밀리 텍스처 규약과 정합). var c = document.createElement('canvas'); c.width = 512; c.height = 192; var g = c.getContext('2d'); g.font = '700 104px system-ui, sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle'; g.lineWidth = 20; g.strokeStyle = 'rgba(0,0,0,0.55)'; g.strokeText(String(text), 256, 96); g.fillStyle = o.color || '#ffffff'; g.fillText(String(text), 256, 96); var tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.SRGBColorSpace; var sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false })); var sc = num(o.size, 1.1); sp.scale.set(sc, sc * 0.375, 1); sp.position.set(num(pos && pos.x, 0), num(pos && pos.y, 1), num(pos && pos.z, 0)); P.three.scene.add(sp); _floats.push({ kind: 3, sp: sp, tex: tex, t: 0, dur: dur, rise: num(o.rise, 1.2) }); } else if (IS2D) { _ensure_overlay(); _floats.push({ kind: 2, x: num(pos && pos.x, P.view.w / 2), y: num(pos && pos.y, P.view.h / 2), text: String(text), color: o.color || '#ffffff', t: 0, dur: dur, rise: num(o.rise, 26) }); } else { try { var d = document.createElement('div'); d.textContent = String(text); d.style.cssText = 'position:fixed;left:50%;top:38%;transform:translateX(-50%);z-index:99960;pointer-events:none;font:800 22px system-ui,sans-serif;color:' + (o.color || '#f5f7fa') + ';text-shadow:0 2px 8px rgba(0,0,0,.5);transition:all ' + dur + 's ease-out;opacity:1;'; document.body.appendChild(d); requestAnimationFrame(function () { d.style.top = '30%'; d.style.opacity = '0'; }); setTimeout(function () { d.remove(); }, dur * 1000 + 100); } catch (e) {} } }; // ── emissive 펄스(3D) — 핸들 반환 ── var _pulses = []; fx.pulse = function (target, opts) { if (!IS3D || !target || !target.material || !target.material.emissive) return { stop: function () {} }; var o = opts || {}; var m0 = target.material; if (m0.userData.__base_emi == null) { m0.userData.__base_emi = m0.emissive.getHex(); m0.userData.__base_int = m0.emissiveIntensity; } // emissive base 공유 슬롯(단일 소유) var p = { m: m0, base: m0.userData.__base_int, amount: clamp(num(o.amount, 0.5), 0, 2), rate: clamp(num(o.rate, 2), 0.2, 10), t: 0, on: true }; _pulses.push(p); return { stop: function () { p.on = false; p.m.emissiveIntensity = p.m.userData.__base_int != null ? p.m.userData.__base_int : p.base; } }; }; // ── 복합 동사 — intensity(0~1) 하나가 히트스톱·trauma·파편 수를 동시 스케일 ── fx.impact = function (pos, opts) { pos = P._nv3 ? P._nv3(pos, 0, 0, 0) : pos; var o = opts || {}; var i = clamp(num(o.intensity, 0.5), 0, 1); if (o.target) { fx.flash(o.target); if (IS3D) fx.squash(o.target, { amount: 0.15 + 0.2 * i }); } fx.burst(pos, { count: Math.round(8 + 22 * i), color: o.color || '#ffffff', speed: (IS3D ? 4 : 90) * (0.7 + i), life: 0.35 + 0.3 * i }); fx.shake(0.15 + 0.35 * i); fx.hitstop(0.02 + 0.13 * i); if (o.sound !== false) fx.sfx('hit'); if (P._music_duck) P._music_duck(i); // 배경음악 덕킹 — 타격 순간 SFX 를 앞세움(런타임 훅) }; fx.explode = function (pos, opts) { pos = P._nv3 ? P._nv3(pos, 0, 0, 0) : pos; var o = opts || {}; var i = clamp(num(o.intensity, 0.7), 0, 1); fx.burst(pos, { count: Math.round(24 + 46 * i), color: o.color || '#ffb35c', speed: (IS3D ? 7 : 150) * (0.7 + i), life: 0.5 + 0.4 * i, gravity: IS3D ? -4 : 160 }); fx.screen_flash(o.color || '#ffd9a0', 0.07); fx.shake(0.35 + 0.4 * i); fx.hitstop(0.04 + 0.1 * i); if (o.sound !== false) fx.sfx('explosion'); if (P._music_duck) P._music_duck(i); // 배경음악 덕킹(런타임 훅) }; fx.pickup = function (pos, opts) { pos = P._nv3 ? P._nv3(pos, 0, 0, 0) : pos; var o = opts || {}; fx.burst(pos, { count: 10, color: o.color || '#ffe58a', speed: IS3D ? 3 : 70, life: 0.35 }); if (o.text != null) fx.float_text(pos, o.text, { color: o.color || '#ffe58a' }); if (o.sound !== false) fx.sfx('pickup', { combo: true }); }; fx.spawn = function (target, opts) { if (!IS3D || !target || !target.scale) return; var s = { x: target.scale.x, y: target.scale.y, z: target.scale.z }; target.scale.set(0.001, 0.001, 0.001); fx.tween(target.scale, { x: s.x, y: s.y, z: s.z }, { dur: num(opts && opts.dur, 0.25), ease: 'outBack' }); }; fx.despawn = function (target, opts) { if (!IS3D || !target || !target.scale) { return; } fx.tween(target.scale, { x: 0.001, y: 0.001, z: 0.001 }, { dur: num(opts && opts.dur, 0.2), ease: 'inBack', onDone: function () { if (target.parent) target.parent.remove(target); }, }); }; fx.score = function (pos, amount, opts) { var o = opts || {}; fx.float_text(pos, (num(amount, 0) >= 0 ? '+' : '') + amount, { color: o.color || '#ffe58a' }); if (o.sound !== false) fx.sfx('blip', { combo: true }); }; // ── fx 프레임 — 자체 P.loop(로직 루프와 독립, raw dt 로 구동: 히트스톱 중에도 연출은 진행) ── P.loop(function (dt, elapsed, raw) { if (_freeze > 0) _freeze = Math.max(0, _freeze - raw); // tweens for (var i = _tweens.length - 1; i >= 0; i--) { var tw = _tweens[i]; if (tw.delay > 0) { tw.delay -= raw; continue; } tw.t += raw; var k = Math.min(1, tw.t / tw.dur), e = tw.ease(k); for (var j = 0; j < tw.keys.length; j++) { var kk = tw.keys[j]; tw.obj[kk[0]] = kk[1] + (kk[2] - kk[1]) * e; } if (k >= 1) { _tweens.splice(i, 1); if (tw.onDone) { try { tw.onDone(); } catch (e2) {} } } } // flashes(3D 재질 복원) for (var f = _flashes.length - 1; f >= 0; f--) { _flashes[f].t -= raw; if (_flashes[f].t <= 0) { var fl = _flashes[f]; if (fl.m.userData.__base_emi != null) { fl.m.emissive.setHex(fl.m.userData.__base_emi); fl.m.emissiveIntensity = fl.m.userData.__base_int; } _flashes.splice(f, 1); } } // pulses for (var pu = _pulses.length - 1; pu >= 0; pu--) { var pp = _pulses[pu]; if (!pp.on) { _pulses.splice(pu, 1); continue; } pp.t += raw; pp.m.emissiveIntensity = pp.base + Math.max(0, Math.sin(pp.t * Math.PI * 2 * pp.rate)) * pp.amount; } // trails → 파편 방출 for (var tr = _trails.length - 1; tr >= 0; tr--) { var t = _trails[tr]; if (!t.on || !t.target) { _trails.splice(tr, 1); continue; } t.acc += raw * t.rate; var src = IS3D ? (t.target.position || t.target) : t.target; while (t.acc >= 1) { t.acc -= 1; _spawn_part(num(src.x, 0), num(src.y, 0), IS3D ? num(src.z, 0) : 0, 0, 0, 0, t.life, t.size, _col(t.color), 0); } } // particles var any = false; for (var pi = 0; pi < _POOL; pi++) { var p = _parts[pi]; if (!p.alive) continue; p.life -= raw; if (p.life <= 0) { p.alive = false; continue; } any = true; p.x += p.vx * raw; p.y += p.vy * raw; p.z += p.vz * raw; p.vy += (IS3D ? (p.gravity || 0) : (p.gravity || 0)) * raw; } // 3D 파티클/플로팅 갱신 if (IS3D) { if (_points) { var pa = _pos_attr.array, ca = _col_attr.array, w = 0; for (var qi = 0; qi < _POOL; qi++) { var q = _parts[qi]; if (!q.alive) continue; var fade = q.life / q.life0; pa[w * 3] = q.x; pa[w * 3 + 1] = q.y; pa[w * 3 + 2] = q.z; ca[w * 3] = q.r * fade; ca[w * 3 + 1] = q.g * fade; ca[w * 3 + 2] = q.b * fade; w++; } _points.geometry.setDrawRange(0, w); _pos_attr.needsUpdate = true; _col_attr.needsUpdate = true; _points.visible = w > 0; } for (var fi = _floats.length - 1; fi >= 0; fi--) { var ft = _floats[fi]; ft.t += raw; var fk = Math.min(1, ft.t / ft.dur); ft.sp.position.y += ft.rise * raw / ft.dur; ft.sp.material.opacity = 1 - EASE.inQuad(fk); if (fk >= 1) { P.three.scene.remove(ft.sp); ft.tex.dispose(); ft.sp.material.dispose(); _floats.splice(fi, 1); } } } else if (IS2D && (_octx || any || _floats.length)) { _ensure_overlay(); if (_octx) { _octx.clearRect(0, 0, _overlay_canvas.width, _overlay_canvas.height); for (var ri = 0; ri < _POOL; ri++) { var rp = _parts[ri]; if (!rp.alive) continue; var rf = rp.life / rp.life0; _octx.globalAlpha = rf; _octx.fillStyle = 'rgb(' + Math.round(rp.r * 255) + ',' + Math.round(rp.g * 255) + ',' + Math.round(rp.b * 255) + ')'; var sz = Math.max(1, Math.round(rp.size * rf)); _octx.fillRect(Math.round(rp.x - sz / 2), Math.round(rp.y - sz / 2), sz, sz); } _octx.globalAlpha = 1; for (var gi = _floats.length - 1; gi >= 0; gi--) { var gt = _floats[gi]; gt.t += raw; var gk = Math.min(1, gt.t / gt.dur); _octx.globalAlpha = 1 - EASE.inQuad(gk); _octx.font = '700 12px system-ui, sans-serif'; _octx.textAlign = 'center'; _octx.fillStyle = gt.color; _octx.fillText(gt.text, Math.round(gt.x), Math.round(gt.y - gt.rise * gk)); if (gk >= 1) _floats.splice(gi, 1); } _octx.globalAlpha = 1; } } _apply_shake(raw); }); })(); b ? b : v); } function num(v, d) { return typeof v === 'number' && isFinite(v) ? v : d; } var _cbs = {}; A.on = function (ev, fn) { if (typeof fn === 'function') (_cbs[ev] = _cbs[ev] || []).push(fn); }; function _emit(ev, ctx) { var hs = _cbs[ev] || []; for (var i = 0; i < hs.length; i++) { try { hs[i](ctx); } catch (e) { if (P._hook_err) P._hook_err(e, 'arcam:' + ev); } } } // ── mode state ── var _mode = { camera: false, gyro: false, label: 'touch' }; var _started = false; var _active = true; // phase gate — explore/encounter games park arcam between encounters var _EYE = 1.55; // fixed eye height — the camera NEVER translates (rotation-only canon) A.mode = function () { return { camera: _mode.camera, gyro: _mode.gyro, label: _mode.label }; }; function _relabel() { _mode.label = _mode.camera ? (_mode.gyro ? 'ar' : 'cam') : (_mode.gyro ? 'gyro' : 'touch'); } // ── sensor state (DeviceOrientationControls math — MIT, three.js r133 / three-stdlib canon) ── var _dev = null, _dev_seen = false, _rebased = false; var _euler = new THREE_.Euler(); var _q0 = new THREE_.Quaternion(); var _q1 = new THREE_.Quaternion(-Math.sqrt(0.5), 0, 0, Math.sqrt(0.5)); var _zee = new THREE_.Vector3(0, 0, 1); var _up = new THREE_.Vector3(0, 1, 0); var _target_q = new THREE_.Quaternion(); var _yaw_q = new THREE_.Quaternion(); var _final_q = new THREE_.Quaternion(); var _fwd = new THREE_.Vector3(); var _yaw_off = 0; // relative-yaw rebase: forward at start ⇒ world -Z var _drag = { yaw: 0, pitch: 0 }; // touch look (full control in touch/cam, yaw-assist over gyro) function _screen_angle() { var a = (screen.orientation && typeof screen.orientation.angle === 'number') ? screen.orientation.angle : (typeof window.orientation === 'number' ? window.orientation : 0); return a * DEG; } function _dev_quat(out, d) { _euler.set((d.beta || 0) * DEG, (d.alpha || 0) * DEG, -(d.gamma || 0) * DEG, 'YXZ'); out.setFromEuler(_euler); out.multiply(_q1); out.multiply(_q0.setFromAxisAngle(_zee, -_screen_angle())); return out; } function _yaw_of(q) { // horizontal angle of q's forward vs world -Z (positive = left, three convention) _fwd.set(0, 0, -1).applyQuaternion(q); return Math.atan2(-_fwd.x, -_fwd.z); } function _rebase() { if (!_dev) return; _yaw_off = -_yaw_of(_dev_quat(_target_q, _dev)); _drag.yaw = 0; _rebased = true; } A.recenter = function () { if (_mode.gyro && _dev) _rebase(); else { _drag.yaw = 0; _drag.pitch = 0; } return null; }; function _on_dev(e) { if (e.alpha == null && e.beta == null && e.gamma == null) return; // sensorless one-shot null event _dev = { alpha: e.alpha, beta: e.beta, gamma: e.gamma }; if (!_dev_seen) { _dev_seen = true; if (!_rebased) _rebase(); } } // ── drag look — always live (gyro yaw-assist / full look when gyro absent). Grab-the-world // convention (content follows the finger). Claimed pointers (game UI) are never consumed. ── var _pd = null; window.addEventListener('pointerdown', function (e) { if (e.isPrimary === false) return; _pd = { id: e.pointerId, x: e.clientX, y: e.clientY }; }); window.addEventListener('pointermove', function (e) { if (!_pd || e.pointerId !== _pd.id || !_started) return; if (P._claimed_pointers && P._claimed_pointers.has(e.pointerId)) { _pd = null; return; } var s = 2.4 / Math.max(1, P.vh); // full-height drag ≈ 137° _drag.yaw += (e.clientX - _pd.x) * s; if (!_mode.gyro) _drag.pitch = clamp(_drag.pitch + (e.clientY - _pd.y) * s, -1.45, 1.45); _pd.x = e.clientX; _pd.y = e.clientY; }); window.addEventListener('pointerup', function (e) { if (_pd && e.pointerId === _pd.id) _pd = null; }); window.addEventListener('pointercancel', function (e) { if (_pd && e.pointerId === _pd.id) _pd = null; }); // ── camera feed (DOM video under transparent canvas) ── var _video = null, _stream = null; function _stop_stream() { if (_stream) { try { var ts = _stream.getTracks(); for (var i = 0; i < ts.length; i++) ts[i].stop(); } catch (e) {} } _stream = null; } function _acquire_cam() { return navigator.mediaDevices.getUserMedia({ audio: false, video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } }, }).then(function (stream) { _stream = stream; if (!_video) { _video = document.createElement('video'); _video.setAttribute('playsinline', ''); _video.muted = true; _video.autoplay = true; _video.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;object-fit:cover;z-index:0;background:#000;'; var cv = renderer.domElement; cv.parentNode.insertBefore(_video, cv); // canvas composites above the feed — z-index is INERT on position:static (the preset canvas // is an unpositioned block, so the fixed z0 video painted OVER the whole 3D layer: every // anchor invisible on every device while the feed was live; real-iPhone measured 2026-07-23). // position:relative with no offsets = identical layout, z-index now applies. if (!cv.style.position) cv.style.position = 'relative'; if (!cv.style.zIndex) cv.style.zIndex = '1'; } _video.style.display = ''; _video.srcObject = stream; var pl = _video.play(); if (pl && pl.catch) pl.catch(function () {}); var tr = stream.getVideoTracks()[0]; if (tr) tr.addEventListener('ended', function () { _degrade_cam(); }); return true; }); } // Transparent composite switch — requires renderer alpha:true ( contract in the // three preset bootstrap). Saved background restores on camera loss (degraded modes keep a 3D bg). var _saved_bg; var _bg_saved = false; var _sky_hidden = false; // realistic 프로파일의 Sky '메시'(rp_sky) — background 널링만으론 피드가 가려진다 var _feed_fill = null, _saved_envi; // 피드 조명 보상(realistic 한정) — 반구 필 + 환경광 부스트 저장/복원 function _bg_clear() { if (!_bg_saved) { _saved_bg = scene.background; _bg_saved = true; } scene.background = null; renderer.setClearColor(0x000000, 0); // ⚠️치명 결함 봉합(2026-07-28 실기기 "카메라 켰는데 화면 안 나옴"): realistic 렌더 프로파일은 // 하늘을 scene.background 가 아닌 THREE.Sky 돔 메시(name='rp_sky', three.md 계약)로 그린다 — // 메시는 배경 널링과 무관하게 불투명 지오메트리로 렌더되어 카메라 피드 전체를 가림. // 피드 활성 동안 돔을 숨기고 해제 시 복원(이름 계약 기반 — arcam 이 프로파일 내부를 모름). var _sky9 = scene.getObjectByName && scene.getObjectByName('rp_sky'); if (_sky9 && _sky9.visible) { _sky9.visible = false; _sky_hidden = true; } // 피드 조명 보상(2026-07-28 실보고 "공룡이 어둡다"): realistic 리그는 하늘 돔 밝기 전제의 노출 // 균형이라, 돔 소등 + 실세계 카메라 밝기 대비에서 크리처가 저노출로 읽힌다. AR 업계 관행 // (가상 오브젝트 = 중립 명부 보상)대로 피드 동안 반구 필 + 환경광 부스트 — realistic 마커 // (rp_sky 존재) 게이트라 stylized 합성은 종전 그대로(회귀 0). if (_sky9 && !_feed_fill) { _feed_fill = new THREE.HemisphereLight(0xffffff, 0x93a3b8, 0.9); _feed_fill.name = 'arcam_feed_fill'; scene.add(_feed_fill); _saved_envi = scene.environmentIntensity; if (typeof _saved_envi === 'number') scene.environmentIntensity = Math.max(_saved_envi, 1.25); } P._arcam_feed = true; // AE meter stands down while the visible frame is feed-dominated (three.md contract) } function _bg_restore() { if (_bg_saved) { scene.background = _saved_bg; _bg_saved = false; } renderer.setClearColor(0x000000, 1); if (_sky_hidden) { var _sky8 = scene.getObjectByName && scene.getObjectByName('rp_sky'); if (_sky8) _sky8.visible = true; _sky_hidden = false; } if (_feed_fill) { try { scene.remove(_feed_fill); } catch (e9) {} _feed_fill = null; if (typeof _saved_envi === 'number') scene.environmentIntensity = _saved_envi; _saved_envi = undefined; } P._arcam_feed = false; } function _degrade_cam() { // single explicit-notice degrade path (never silent) if (!_mode.camera) return; _mode.camera = false; _relabel(); _stop_stream(); if (_video) _video.style.display = 'none'; _bg_restore(); P.toast(_copy.cam_lost); _emit('mode', A.mode()); } // iOS suspends capture on tab-hide/lock — stop tracks on hidden (also clears the green camera // dot), re-acquire fresh on visible (WebKit one-active-stream rule: never a second getUserMedia // while one lives). document.addEventListener('visibilitychange', function () { if (!_mode.camera || !_active) return; if (document.visibilityState === 'hidden') { _stop_stream(); } else { _acquire_cam().catch(function () { _degrade_cam(); }); } }); // Phase gate for explore↔encounter games (geowalk pairing): OFF parks arcam — camera stream // stopped (clears the OS camera indicator between encounters), feed hidden, background restored, // and the per-frame camera drive yields (the archetype/logic may rig the camera freely). ON // re-acquires and resumes. Anchors/indicators stay as-is (hide them by removing/`obj.visible`). A.set_active = function (on) { on = on !== false; if (!_started || on === _active) { _active = on; return null; } _active = on; if (_mode.camera) { if (on) { if (_video) _video.style.display = ''; _bg_clear(); _acquire_cam().catch(function () { _degrade_cam(); }); } else { _stop_stream(); if (_video) _video.style.display = 'none'; _bg_restore(); } } return null; }; // ── start card (pre-permission priming — benefit line + privacy + safety, single Start tap) ── var _copy = { title: 'Camera AR', body: 'This game uses your camera and motion sensors.', privacy: 'Camera video stays on your device — never recorded or uploaded.', safety: 'Stay aware of your surroundings while playing.', start: 'Start', no_cam: 'Playing without camera view.', no_gyro: 'Drag to look around.', cam_lost: 'Camera stopped — continuing without camera view.', }; var _start_pending = false; // re-entrancy seal — a caller polling start() while the card is up must not stack overlays A.start = function (opts) { if (_started || _start_pending) return null; _start_pending = true; opts = opts || {}; for (var k in _copy) { if (typeof opts[k] === 'string' && opts[k]) _copy[k] = opts[k]; } var wants_cam = opts.camera !== false; var mobile = !!(window.matchMedia && matchMedia('(pointer: coarse)').matches); // 프리미엄 카드(2026-07-24 사용자 확정) — 발광 카메라 엠블럼 + 그라디언트 타이틀 + 골드 캡슐 CTA var ov = document.createElement('div'); ov.style.cssText = 'position:fixed;inset:0;z-index:999;display:flex;align-items:center;justify-content:center;background:linear-gradient(180deg,rgba(8,10,16,.94),rgba(10,14,24,.97));backdrop-filter:blur(6px);font-family:system-ui,sans-serif;'; var card = document.createElement('div'); card.style.cssText = 'max-width:340px;width:90%;padding:28px 24px 24px;border-radius:22px;background:linear-gradient(180deg,#1a2234,#131a2a);border:1px solid rgba(124,240,255,.18);box-shadow:0 24px 60px rgba(0,0,0,.55),inset 0 1px 0 rgba(255,255,255,.06);color:#e8ecf4;text-align:center;'; var em = document.createElement('div'); em.style.cssText = 'width:66px;height:66px;margin:0 auto 14px;filter:drop-shadow(0 6px 16px rgba(124,240,255,.35));'; em.innerHTML = '
'; var h = document.createElement('div'); h.style.cssText = 'font-size:22px;font-weight:900;margin-bottom:10px;background:linear-gradient(135deg,#9dff6a,#7cf0ff);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;'; h.textContent = _copy.title; var b = document.createElement('div'); b.style.cssText = 'font-size:15px;line-height:1.6;opacity:.92;margin-bottom:12px;'; b.textContent = mobile && wants_cam ? _copy.body : ''; var pv = document.createElement('div'); pv.style.cssText = 'font-size:11.5px;line-height:1.5;opacity:.5;margin-bottom:5px;'; pv.textContent = mobile && wants_cam ? _copy.privacy : ''; var sf = document.createElement('div'); sf.style.cssText = 'font-size:11.5px;line-height:1.5;opacity:.5;margin-bottom:18px;'; sf.textContent = mobile ? _copy.safety : ''; var btn = document.createElement('button'); btn.style.cssText = 'display:block;width:100%;font-size:17px;font-weight:900;letter-spacing:.3px;padding:15px 16px;border:0;border-radius:999px;color:#3a2405;cursor:pointer;text-shadow:0 1px 0 rgba(255,255,255,.45);background:linear-gradient(180deg,#ffe98a 0%,#ffb52e 45%,#ff8a3d 100%);box-shadow:0 10px 26px rgba(255,150,40,.4),inset 0 2px 0 rgba(255,255,255,.6),inset 0 -3px 0 rgba(140,60,0,.3);'; btn.textContent = _copy.start; card.appendChild(em); card.appendChild(h); card.appendChild(b); card.appendChild(pv); card.appendChild(sf); card.appendChild(btn); ov.appendChild(card); document.body.appendChild(ov); btn.addEventListener('click', function () { // (1) Motion permission — FIRST synchronous call inside the gesture (awaiting anything before // it would void the transient activation on iOS). var motion_p; if (typeof DeviceOrientationEvent !== 'undefined' && typeof DeviceOrientationEvent.requestPermission === 'function') { try { motion_p = DeviceOrientationEvent.requestPermission(); } catch (e) { motion_p = Promise.resolve('denied'); } } else { motion_p = Promise.resolve(mobile ? 'granted' : 'denied'); // Android: listener-gated, no prompt; desktop: no sensor } // (2) Camera — mobile only (a desktop webcam gives no AR value; skipping avoids a pointless prompt) var cam_p = (wants_cam && mobile && navigator.mediaDevices && navigator.mediaDevices.getUserMedia) ? _acquire_cam().then(function () { return true; }, function () { return false; }) : Promise.resolve(false); motion_p = motion_p.then(function (r) { return r === 'granted'; }, function () { return false; }); Promise.all([motion_p, cam_p]).then(function (rr) { _mode.gyro = rr[0]; _mode.camera = rr[1]; _relabel(); if (_mode.gyro) window.addEventListener('deviceorientation', _on_dev); if (_mode.camera) _bg_clear(); try { ov.remove(); } catch (e) {} _started = true; camera.position.set(0, _EYE, 0); if (wants_cam && mobile && !_mode.camera) P.toast(_copy.no_cam); if (mobile && !_mode.gyro) P.toast(_copy.no_gyro); // Permission granted but no events (sensorless device / broken vendor stack) — the exact // variance class that got DeviceOrientationControls removed from three core: watchdog to touch. if (_mode.gyro) { setTimeout(function () { if (!_dev_seen) { _mode.gyro = false; _relabel(); P.toast(_copy.no_gyro); _emit('mode', A.mode()); } }, 1500); } _emit('ready', A.mode()); }); }); return null; }; // ── anchors — objects glued to real-world directions (spherical around the viewer; the camera // never translates, so fixed world yaw ⇒ the magic-window illusion). Degrees for the LLM surface. ── var _anchors = []; var _proj = new THREE_.Vector3(); var _dir = new THREE_.Vector3(); var _inv_q = new THREE_.Quaternion(); function _place(h) { var cy = Math.cos(h._pitch), x = -Math.sin(h._yaw) * cy, z = -Math.cos(h._yaw) * cy; h.obj.position.set(x * h._dist, _EYE + Math.sin(h._pitch) * h._dist, z * h._dist); } A.anchor = function (opts) { opts = opts || {}; var obj = (opts.obj && opts.obj.isObject3D) ? opts.obj : null; if (!obj) { // default marker — small glowing orb (logic normally passes its own P.make mesh/group) obj = new THREE_.Mesh(new THREE_.IcosahedronGeometry(0.22, 1), P.make && P.make.glow ? P.make.glow(opts.color || '#7cf0ff') : new THREE_.MeshBasicMaterial({ color: opts.color || '#7cf0ff' })); } var h = { obj: obj, _yaw: num(opts.yaw, 0) * DEG, _pitch: clamp(num(opts.pitch, 0), -80, 80) * DEG, _dist: clamp(num(opts.dist, 3), 0.8, 40), _aim: 180, _arrow: null, }; if (opts.indicator !== false) { var ar = document.createElement('div'); ar.textContent = '➤'; ar.style.cssText = 'position:fixed;z-index:5;font-size:26px;color:' + (opts.color || '#7cf0ff') + ';text-shadow:0 0 8px rgba(0,0,0,.7);pointer-events:none;display:none;transform-origin:50% 50%;'; document.body.appendChild(ar); h._arrow = ar; } h.set = function (o) { o = o || {}; if (o.yaw != null) h._yaw = num(o.yaw, 0) * DEG; if (o.pitch != null) h._pitch = clamp(num(o.pitch, 0), -80, 80) * DEG; if (o.dist != null) h._dist = clamp(num(o.dist, 3), 0.8, 40); _place(h); return h; }; h.aim_deg = function () { return h._aim; }; h.remove = function () { var ix = _anchors.indexOf(h); if (ix >= 0) _anchors.splice(ix, 1); try { scene.remove(h.obj); } catch (e) {} if (h._arrow) { try { h._arrow.remove(); } catch (e) {} h._arrow = null; } return null; }; _place(h); scene.add(h.obj); _anchors.push(h); return h; }; A.aimed = function (h, tol) { return !!(h && typeof h.aim_deg === 'function' && h.aim_deg() <= num(tol, 8)); }; A.look = function () { // current view direction, degrees (yaw positive = left, pitch positive = up) _fwd.set(0, 0, -1).applyQuaternion(camera.quaternion); return { yaw: Math.atan2(-_fwd.x, -_fwd.z) / DEG, pitch: Math.asin(clamp(_fwd.y, -1, 1)) / DEG }; }; function _update_anchors() { _inv_q.copy(camera.quaternion).invert(); var margin = 34, cx = P.vw / 2, cy2 = P.vh / 2; for (var i = 0; i < _anchors.length; i++) { var h = _anchors[i]; _dir.copy(h.obj.position).sub(camera.position); var len = _dir.length() || 1; // aim angle vs camera forward (degrees) — the aim/capture primitive _fwd.set(0, 0, -1).applyQuaternion(camera.quaternion); var cosv = clamp(_dir.dot(_fwd) / len, -1, 1); h._aim = Math.acos(cosv) / DEG; if (!h._arrow) continue; var onscreen = false; if (cosv > 0) { _proj.copy(h.obj.position).project(camera); onscreen = _proj.x > -1 && _proj.x < 1 && _proj.y > -1 && _proj.y < 1; } if (onscreen) { h._arrow.style.display = 'none'; continue; } // off-screen edge arrow — camera-space lateral direction → margin-rect intersection _dir.applyQuaternion(_inv_q); var ax = _dir.x, ay = _dir.y; var n = Math.sqrt(ax * ax + ay * ay) || 1; ax /= n; ay /= n; var hw = cx - margin, hh = cy2 - margin; var t = Math.min(hw / Math.max(Math.abs(ax), 1e-4), hh / Math.max(Math.abs(ay), 1e-4)); var sx = cx + ax * t, sy = cy2 - ay * t; var rot = Math.atan2(-ay, ax); h._arrow.style.display = ''; h._arrow.style.left = (sx - 13) + 'px'; h._arrow.style.top = (sy - 13) + 'px'; h._arrow.style.transform = 'rotate(' + rot + 'rad)'; } } // ── per-frame camera drive — smoothing in quaternion space, frame-rate-independent gain ── var _tmp_e = new THREE_.Euler(); P.loop(function (dt) { if (!_started || !_active) return; var kk = 1 - Math.exp(-(num(dt, 0.016)) * 14); if (_mode.gyro && _dev) { _dev_quat(_target_q, _dev); _yaw_q.setFromAxisAngle(_up, _yaw_off + _drag.yaw); _final_q.copy(_yaw_q).multiply(_target_q); } else { // grab-the-world convention (panorama canon): drag right → look left, drag down → look up _tmp_e.set(_drag.pitch, _drag.yaw, 0, 'YXZ'); _final_q.setFromEuler(_tmp_e); } camera.quaternion.slerp(_final_q, kk); _update_anchors(); }); })(); b ? b : v); } function num(v, d) { return typeof v === 'number' && isFinite(v) ? v : d; } var DEG = Math.PI / 180; var _cbs = {}; G.on = function (ev, fn) { if (typeof fn === 'function') (_cbs[ev] = _cbs[ev] || []).push(fn); }; function _emit(ev, ctx) { var hs = _cbs[ev] || []; for (var i = 0; i < hs.length; i++) { try { hs[i](ctx); } catch (e) { if (P._hook_err) P._hook_err(e, 'geowalk:' + ev); } } } // ── state ── var _mode = 'virtual'; // 'gps' | 'virtual' var _started = false; var _origin = null; // {lat, lng, coslat} — session anchor (game-space 0,0) var _pos = { x: 0, z: 0 }; // game-space meters (x east, z south — screen-map convention) var _last_fix = null; // {lat, lng, t} var _walked = 0; // accrued walking meters (gated) var _speed = 0; // km/h (last accepted segment) var _watch_id = null; G.mode = function () { return _mode; }; G.pos = function () { return { x: _pos.x, z: _pos.z }; }; G.walked_m = function () { return _walked; }; G.speed_kmh = function () { return _speed; }; // ── local planar projection (equirectangular around the origin fix) ── var _M_LAT = 110540, _M_LNG = 111320; function _project(lat, lng) { return { x: (lng - _origin.lng) * _M_LNG * _origin.coslat, z: -(lat - _origin.lat) * _M_LAT }; } G.dist_m = function (a, b) { var p = b || _pos, q = a || _pos; var dx = (q.x || 0) - (p.x || 0), dz = (q.z || 0) - (p.z || 0); return Math.sqrt(dx * dx + dz * dz); }; G.bearing_deg = function (t) { // 0 = north, clockwise (radar/compass convention) var dx = (t && t.x || 0) - _pos.x, dz = (t && t.z || 0) - _pos.z; return ((Math.atan2(dx, -dz) / DEG) + 360) % 360; }; // ── fix filter ladder ── var ACC_MAX = 60, TELEPORT_MS = 40, SPEED_LOCK_KMH = 12; function _on_fix(p) { var c = p.coords; if (!c || !isFinite(c.latitude)) return; if (num(c.accuracy, 9999) > ACC_MAX) return; // accuracy gate var now = p.timestamp || Date.now(); if (!_origin) { _origin = { lat: c.latitude, lng: c.longitude, coslat: Math.cos(c.latitude * DEG) }; } var g = _project(c.latitude, c.longitude); if (_last_fix) { var dt = Math.max(0.5, (now - _last_fix.t) / 1000); var d = Math.sqrt((g.x - _pos.x) * (g.x - _pos.x) + (g.z - _pos.z) * (g.z - _pos.z)); var mps = d / dt; if (mps > TELEPORT_MS) { // multipath teleport — re-seat, no accrual _pos.x = g.x; _pos.z = g.z; _last_fix = { lat: c.latitude, lng: c.longitude, t: now }; return; } _speed = mps * 3.6; // light low-pass (heavy smoothing lags approach detection — 0.45 keeps sub-fix latency) _pos.x += (g.x - _pos.x) * 0.45; _pos.z += (g.z - _pos.z) * 0.45; if (_speed <= SPEED_LOCK_KMH) _walked += Math.min(d, 45); // speed gate: vehicles don't hatch anything } else { _pos.x = g.x; _pos.z = g.z; } _last_fix = { lat: c.latitude, lng: c.longitude, t: now }; _gps_heading = (typeof c.heading === 'number' && isFinite(c.heading) && _speed > 1.5) ? c.heading : _gps_heading; _emit('fix', { x: _pos.x, z: _pos.z, accuracy: c.accuracy, speed_kmh: _speed }); } // ── heading ladder (radar orientation) ── var _gps_heading = null, _compass = null; function _on_abs(e) { // Android chromium — absolute yaw, no prompt if (e.alpha == null) return; _compass = (360 - e.alpha) % 360; } function _on_ios_dev(e) { // iOS — only fires if motion permission was granted elsewhere (arcam) // 정확도 게이트 <50 → <180 완화(2026-07-25 실보고): 미보정 나침반이 50~180 을 오래 유지해 // heading 이 영구 null → AR 내비 화살표 전체 소멸. 오차 수십°라도 좌/우 곡선 유도에는 유효하므로 // 수용하고, 무효 신호(-1)만 거부한다. if (typeof e.webkitCompassHeading === 'number' && isFinite(e.webkitCompassHeading) && (e.webkitCompassAccuracy == null || (e.webkitCompassAccuracy >= 0 && e.webkitCompassAccuracy < 180))) { _compass = e.webkitCompassHeading; } } G.heading_deg = function () { return _compass != null ? _compass : _gps_heading; }; // null = unknown (radar goes north-up) // ── wake lock (screen-off = GPS stops — load-bearing for a walking game) ── var _wl = null; function _wake_acquire() { if (!navigator.wakeLock || !navigator.wakeLock.request) return; navigator.wakeLock.request('screen').then(function (s) { _wl = s; s.addEventListener('release', function () { _wl = null; }); }, function () { P.toast(_copy.keep_on); }); } document.addEventListener('visibilitychange', function () { if (_started && _mode === 'gps' && document.visibilityState === 'visible' && !_wl) _wake_acquire(); }); // ── deterministic spawn cells (place × hour ⇒ same spawns for every player, zero network) ── function _cell_hash(ix, iy, t, salt) { var h = 2166136261 >>> 0; var vs = [ix, iy, t, salt]; for (var i = 0; i < 4; i++) { h = Math.imul(h ^ (vs[i] >>> 0), 16777619) >>> 0; } h ^= h >>> 13; h = Math.imul(h, 0x5bd1e995) >>> 0; h ^= h >>> 15; return h >>> 0; } function _mulberry(a) { return function () { a |= 0; a = (a + 0x6D2B79F5) | 0; var t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } // cells({radius_m?, grid_m?, bucket_min?, salt?, density?}) → [{id, x, z, r, fresh_s}] // r ∈ [0,1) = the cell's rarity roll (game maps it to species/tiers); fresh_s = seconds left in // the time bucket (despawn countdown). Virtual mode uses the same math on the virtual position // (a fixed pseudo-origin), so the loop and its smoke are identical in both modes. G.cells = function (opts) { opts = opts || {}; var grid = clamp(num(opts.grid_m, 120), 40, 600); var radius = clamp(num(opts.radius_m, 300), grid, 2000); var bucket_ms = clamp(num(opts.bucket_min, 60), 5, 1440) * 60000; var salt = num(opts.salt, 7); var density = clamp(num(opts.density, 0.55), 0.05, 1); var t = Math.floor(Date.now() / bucket_ms); var fresh_s = Math.floor((bucket_ms - (Date.now() % bucket_ms)) / 1000); // anchor the grid on ABSOLUTE coordinates when GPS (world-stable cells), on the virtual plane otherwise var ax = _pos.x, az = _pos.z; if (_mode === 'gps' && _origin) { ax += _origin.lng * _M_LNG * _origin.coslat; az += -_origin.lat * _M_LAT; } var out = []; var n = Math.ceil(radius / grid); var cx = Math.floor(ax / grid), cz = Math.floor(az / grid); for (var ix = cx - n; ix <= cx + n; ix++) { for (var iz = cz - n; iz <= cz + n; iz++) { var rng = _mulberry(_cell_hash(ix & 0xffffff, iz & 0xffffff, t & 0xffffff, salt)); if (rng() >= density) continue; var wx = (ix + 0.15 + rng() * 0.7) * grid, wz = (iz + 0.15 + rng() * 0.7) * grid; var gx = wx - (ax - _pos.x), gz = wz - (az - _pos.z); // back to game space var ddx = gx - _pos.x, ddz = gz - _pos.z; if (ddx * ddx + ddz * ddz > radius * radius) continue; out.push({ id: ix + '_' + iz + '_' + t, x: gx, z: gz, r: rng(), fresh_s: fresh_s }); } } return out; }; // ── radar HUD (shell-owned canvas — bearings/distances of game targets around the player) ── var _radar = null; G.radar = function (opts) { opts = opts || {}; if (_radar) { try { _radar.el.remove(); } catch (e) {} _radar = null; } var size = 128, range = clamp(num(opts.range_m, 250), 20, 2000); var cv = document.createElement('canvas'); cv.width = size * 2; cv.height = size * 2; cv.style.cssText = 'position:fixed;right:12px;bottom:12px;width:' + size + 'px;height:' + size + 'px;z-index:6;pointer-events:none;opacity:.88;'; document.body.appendChild(cv); var ctx = cv.getContext('2d'); var r = { el: cv, set_range: function (m) { range = clamp(num(m, range), 20, 2000); }, remove: function () { try { cv.remove(); } catch (e) {} if (_radar === r) _radar = null; }, _draw: function () { var tg = (typeof opts.targets === 'function') ? (opts.targets() || []) : []; var S = size; // draw in 2x backing, 1x units via scale ctx.setTransform(2, 0, 0, 2, 0, 0); ctx.clearRect(0, 0, S, S); var c = S / 2; ctx.beginPath(); ctx.arc(c, c, c - 2, 0, 6.2832); ctx.fillStyle = 'rgba(10,16,24,.55)'; ctx.fill(); ctx.strokeStyle = 'rgba(124,240,255,.5)'; ctx.lineWidth = 1.5; ctx.stroke(); ctx.beginPath(); ctx.arc(c, c, (c - 2) / 2, 0, 6.2832); ctx.strokeStyle = 'rgba(124,240,255,.18)'; ctx.stroke(); var hd = G.heading_deg(); // heading-up when known, north-up otherwise var rot = (hd != null ? -hd : 0) * DEG; // north tick ctx.save(); ctx.translate(c, c); ctx.rotate(rot); ctx.fillStyle = 'rgba(255,255,255,.55)'; ctx.font = '9px system-ui'; ctx.textAlign = 'center'; ctx.fillText('N', 0, -c + 12); for (var i = 0; i < tg.length; i++) { var b = G.bearing_deg(tg[i]) * DEG, d = G.dist_m(tg[i]); var rr = Math.min(1, d / range) * (c - 8); ctx.beginPath(); ctx.arc(Math.sin(b) * rr, -Math.cos(b) * rr, d <= range ? 3.5 : 2.5, 0, 6.2832); ctx.fillStyle = tg[i].color || '#7cf0ff'; ctx.fill(); } ctx.restore(); // player dot + heading wedge (screen-up = travel direction in heading-up mode) ctx.beginPath(); ctx.arc(c, c, 3, 0, 6.2832); ctx.fillStyle = '#fff'; ctx.fill(); }, }; _radar = r; return r; }; // ── virtual walk (denied / desktop / insecure) — shell thumbstick + arrow keys, identical loop ── var _stick_vec = { x: 0, z: 0 }; function _virtual_ui() { var base = document.createElement('div'); base.style.cssText = 'position:fixed;left:18px;bottom:18px;width:96px;height:96px;border-radius:50%;' + 'background:rgba(20,26,38,.5);border:1.5px solid rgba(124,240,255,.4);z-index:6;touch-action:none;'; var nub = document.createElement('div'); nub.style.cssText = 'position:absolute;left:33px;top:33px;width:30px;height:30px;border-radius:50%;background:rgba(124,240,255,.75);'; base.appendChild(nub); document.body.appendChild(base); var pid = null; base.addEventListener('pointerdown', function (e) { pid = e.pointerId; if (P.claim_pointer) P.claim_pointer(pid); base.setPointerCapture(pid); }); base.addEventListener('pointermove', function (e) { if (e.pointerId !== pid) return; var rb = base.getBoundingClientRect(); var dx = clamp((e.clientX - rb.left - 48) / 40, -1, 1), dz = clamp((e.clientY - rb.top - 48) / 40, -1, 1); _stick_vec.x = dx; _stick_vec.z = dz; nub.style.left = (33 + dx * 26) + 'px'; nub.style.top = (33 + dz * 26) + 'px'; }); var end = function (e) { if (e.pointerId !== pid) return; pid = null; _stick_vec.x = 0; _stick_vec.z = 0; nub.style.left = '33px'; nub.style.top = '33px'; }; base.addEventListener('pointerup', end); base.addEventListener('pointercancel', end); } var VIRT_MPS = 2.6; // brisk gameplay walk P.loop(function (dt) { if (!_started) return; if (_mode === 'virtual') { var kx = (P.keys && (P.keys.has('ArrowRight') || P.keys.has('d') || P.keys.has('D')) ? 1 : 0) - (P.keys && (P.keys.has('ArrowLeft') || P.keys.has('a') || P.keys.has('A')) ? 1 : 0); var kz = (P.keys && (P.keys.has('ArrowDown') || P.keys.has('s') || P.keys.has('S')) ? 1 : 0) - (P.keys && (P.keys.has('ArrowUp') || P.keys.has('w') || P.keys.has('W')) ? 1 : 0); var vx = clamp(_stick_vec.x + kx, -1, 1), vz = clamp(_stick_vec.z + kz, -1, 1); var l = Math.sqrt(vx * vx + vz * vz); if (l > 0.15) { var sp = VIRT_MPS * Math.min(1, l); var step = sp * num(dt, 0.016); _pos.x += (vx / l) * step; _pos.z += (vz / l) * step; _walked += step; _speed = sp * 3.6; _gps_heading = ((Math.atan2(vx, -vz) / DEG) + 360) % 360; } else { _speed = 0; } } if (_radar) _radar._draw(); }); // ── start card (location priming — contextual: geowalk asks ONLY for location; camera/motion // belong to arcam's card at the encounter moment) ── var _copy = { title: 'Walk & Play', body: 'This game uses your location while you play — walk around in the real world to explore.', privacy: 'Your location stays on your device — never uploaded or shared.', safety: 'Stay aware of your surroundings. Do not trespass, and never play while driving or cycling.', // mode buttons — EQUAL-WEIGHT indoor/outdoor choice (the old underline opt-out buried the // indoor path and users read it as an error fallback, not a mode; measured confusion 2026-07-23) start: '🚶 Outdoors · walk & play (GPS)', virtual_label: '🏠 Indoors · play right here (no GPS)', no_gps: 'Playing in virtual-walk mode — use the stick or arrow keys to move.', keep_on: 'Keep your screen on while walking — the game pauses when it locks.', }; G.start = function (opts) { if (_started) return null; opts = opts || {}; for (var k in _copy) { if (typeof opts[k] === 'string' && opts[k]) _copy[k] = opts[k]; } var has_geo = !!(navigator.geolocation && window.isSecureContext !== false); var mobile = !!(window.matchMedia && matchMedia('(pointer: coarse)').matches); function _go_virtual() { _mode = 'virtual'; if (opts.virtual_ui !== false) _virtual_ui(); // 소비자(예: 실내 카메라-헌트)가 스틱이 불필요하면 억제 _started = true; P.toast(_copy.no_gps); _emit('ready', { mode: _mode }); } // ── 풀-디자인 오프닝(2026-07-24 사용자 확정 "최고 퀄리티 오프닝") — 히어로 그라디언트 무대 + // 부유 입자 + 그라디언트 타이틀 + 사용법 스텝(opts.howto) + 모드 버튼 위계(opts.primary). // 기본값은 기존 소비자 무변화(gps 우선·howto 없음) — 소비 아키타입이 옵션으로 조형한다. var showcase9 = opts.stage === 'showcase'; // 카드 뒤 3D 히어로 무대 노출(중앙 투명 스크림) — 소비 아키타입이 무대를 소유 if (showcase9) document.body.setAttribute('data-gw-opening', '1'); // 오프닝 동안 게임 HUD(.pbtn) 완전 숨김 — 스크림 투과 노이즈 봉합 var ov = document.createElement('div'); ov.style.cssText = 'position:fixed;inset:0;z-index:999;overflow:auto;display:flex;align-items:center;justify-content:center;font-family:system-ui,sans-serif;' + (showcase9 ? 'background:linear-gradient(180deg,rgba(8,10,16,.86) 0%,rgba(8,10,16,.3) 26%,rgba(8,10,16,.06) 46%,rgba(8,10,16,.14) 60%,rgba(9,11,17,.9) 78%,rgba(10,12,18,.97) 100%);' : 'background:radial-gradient(120% 80% at 50% -10%,#1d3a2f 0%,rgba(18,26,40,0) 55%),radial-gradient(120% 90% at 50% 115%,#14263e 0%,rgba(10,13,20,0) 60%),linear-gradient(180deg,#0b0f18 0%,#0e1524 100%);'); (function () { // 부유 트윙클 입자(설렘 신호 — CSS 전용, 1회 키프레임 주입) var st9 = document.createElement('style'); st9.textContent = '@keyframes gw-float{0%,100%{transform:translateY(0)}50%{transform:translateY(-12px)}}@keyframes gw-tw{0%,100%{opacity:.15}50%{opacity:.8}}@keyframes gw-shine{0%,55%{left:-60%}90%,100%{left:120%}}' + '@keyframes gw-lbpulse{0%,100%{box-shadow:0 8px 20px rgba(255,170,40,.4),0 0 0 0 rgba(255,190,60,.55),inset 0 2px 3px rgba(255,255,255,.55),inset 0 -3px 5px rgba(120,60,0,.45)}55%{box-shadow:0 8px 24px rgba(255,170,40,.55),0 0 0 11px rgba(255,190,60,0),inset 0 2px 3px rgba(255,255,255,.55),inset 0 -3px 5px rgba(120,60,0,.45)}}' + '@keyframes gw-lbbob{0%,88%,100%{transform:rotate(0)}91%{transform:rotate(-9deg)}94%{transform:rotate(8deg)}97%{transform:rotate(-4deg)}}' + '@keyframes gw-lbspark{0%,100%{opacity:0;transform:scale(.4)}50%{opacity:.95;transform:scale(1)}}' + '[data-gw-opening] .pbtn{visibility:hidden !important}'; document.head.appendChild(st9); for (var pi9 = 0; pi9 < 14; pi9++) { var dt9 = document.createElement('div'); dt9.style.cssText = 'position:absolute;width:4px;height:4px;border-radius:50%;background:' + (pi9 % 3 ? '#7cf0ff' : '#ffd24a') + ';pointer-events:none;' + 'left:' + (4 + Math.random() * 92).toFixed(0) + '%;top:' + (4 + Math.random() * 92).toFixed(0) + '%;opacity:.3;' + 'animation:gw-tw ' + (2 + Math.random() * 3).toFixed(1) + 's ease-in-out ' + (Math.random() * 2).toFixed(1) + 's infinite;'; ov.appendChild(dt9); } })(); function _dismiss9() { try { ov.remove(); } catch (e9) {} document.body.removeAttribute('data-gw-opening'); } var card = document.createElement('div'); card.style.cssText = showcase9 ? 'position:relative;width:100%;height:100%;display:flex;flex-direction:column;align-items:center;color:#e8ecf4;text-align:center;padding:5vh 0 12px;' : 'position:relative;max-width:362px;width:92%;padding:28px 20px 22px;color:#e8ecf4;text-align:center;'; var sheet9 = card; // 비-쇼케이스 = 카드 자체가 컨테이너 function line(txt, css) { var d = document.createElement('div'); d.style.cssText = css; d.textContent = txt; sheet9.appendChild(d); return d; } function _logo9(txt) { // 게임 로고 타이포(스택 워드 + 골드 그라디언트 + 네이비 스트로크 + 층상 드롭섀도 — 명작 타이틀 문법) var lw = document.createElement('div'); lw.style.cssText = 'line-height:1.02;margin-bottom:10px;animation:gw-float 3.4s ease-in-out infinite;'; var words9 = String(txt).split(' '); var fs9 = txt.length > 14 ? 34 : (txt.length > 8 ? 42 : 50); for (var wi9 = 0; wi9 < words9.length; wi9++) { var wd9 = document.createElement('div'); wd9.style.cssText = 'white-space:nowrap;'; for (var li9 = 0; li9 < words9[wi9].length; li9++) { var sp9 = document.createElement('span'); sp9.textContent = words9[wi9][li9]; sp9.style.cssText = 'display:inline-block;font-weight:900;font-size:' + fs9 + 'px;letter-spacing:-1px;' + 'background:linear-gradient(180deg,#fff3b0 5%,#ffd24a 45%,#ff9a3d 92%);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;' + '-webkit-text-stroke:1.5px rgba(27,42,94,.92);' + 'filter:drop-shadow(0 3px 0 rgba(27,42,94,.95)) drop-shadow(0 10px 18px rgba(0,0,0,.55));' + 'transform:rotate(' + (((li9 % 3) - 1) * 2.4).toFixed(1) + 'deg) translateY(' + ((li9 % 2) ? -2 : 1) + 'px);'; wd9.appendChild(sp9); } lw.appendChild(wd9); } sheet9.appendChild(lw); } if (showcase9) { // 상단 로고 블록 → (중앙 = 3D 무대 투과) → 하단 시트 var top9 = document.createElement('div'); top9.style.cssText = 'flex:0 0 auto;width:92%;max-width:380px;'; sheet9 = top9; _logo9(_copy.title); if (has_geo && opts.body) line(_copy.body, 'font-size:13.5px;line-height:1.5;opacity:.92;text-shadow:0 1px 6px rgba(0,0,0,.8);'); card.appendChild(top9); var mid9 = document.createElement('div'); mid9.style.cssText = 'flex:1 1 auto;min-height:120px;'; card.appendChild(mid9); var bot9 = document.createElement('div'); bot9.style.cssText = 'flex:0 0 auto;width:92%;max-width:380px;'; card.appendChild(bot9); sheet9 = bot9; } else { if (opts.hero) line(String(opts.hero).slice(0, 12), 'font-size:54px;line-height:1;margin-bottom:10px;filter:drop-shadow(0 8px 20px rgba(124,240,255,.4));animation:gw-float 3.2s ease-in-out infinite;'); line(_copy.title, 'font-size:30px;font-weight:900;letter-spacing:-.5px;margin-bottom:10px;background:linear-gradient(135deg,#9dff6a,#7cf0ff 55%,#c9a2ff);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;'); if (has_geo) line(_copy.body, 'font-size:14.5px;line-height:1.55;opacity:.88;margin-bottom:16px;'); } if (Array.isArray(opts.howto) && opts.howto.length) { // 사용법 스텝(아이콘 칩 + 한 줄 설명 — 직관 문법) var hw9 = document.createElement('div'); hw9.style.cssText = 'text-align:left;background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.09);border-radius:16px;padding:10px 13px;margin-bottom:18px;backdrop-filter:blur(6px);'; for (var hi9 = 0; hi9 < opts.howto.length && hi9 < 4; hi9++) { var row9 = document.createElement('div'); row9.style.cssText = 'display:flex;align-items:center;gap:11px;padding:7px 0;font-size:13.5px;line-height:1.45;color:#dfe7f2;'; var ic9 = document.createElement('div'); ic9.style.cssText = 'flex:0 0 34px;height:34px;border-radius:10px;background:rgba(124,240,255,.14);display:flex;align-items:center;justify-content:center;font-size:18px;'; ic9.textContent = String(opts.howto[hi9].i || '•'); var tx9 = document.createElement('div'); tx9.textContent = String(opts.howto[hi9].t || ''); row9.appendChild(ic9); row9.appendChild(tx9); hw9.appendChild(row9); } sheet9.appendChild(hw9); } var BTN_BIG = showcase9 // 쇼케이스 = 게임급 골드 캡슐 CTA(유리 하이라이트·3D 인셋·샤인 스윕) ? 'position:relative;overflow:hidden;display:block;width:100%;font-size:18px;font-weight:900;letter-spacing:.3px;padding:17px 16px;border:0;border-radius:999px;color:#3a2405;cursor:pointer;text-shadow:0 1px 0 rgba(255,255,255,.45);background:linear-gradient(180deg,#ffe98a 0%,#ffb52e 45%,#ff8a3d 100%);box-shadow:0 10px 26px rgba(255,150,40,.45),inset 0 2px 0 rgba(255,255,255,.65),inset 0 -3px 0 rgba(140,60,0,.35);' : 'display:block;width:100%;font-size:16.5px;font-weight:800;padding:16px 16px;border:0;border-radius:14px;color:#fff;cursor:pointer;background:linear-gradient(135deg,#4f7cff,#7c5cff);box-shadow:0 8px 24px rgba(79,124,255,.4);'; // 쇼케이스 보조 버튼(야외 GPS) — 유리 필이 "존재감 0"이라는 실보고(2026-07-28) → 에메랄드 글로시 // 캡슐로 승격(주 CTA 골드와 위계는 유지하되 동급의 게임 버튼 질감: 스페큘러+3D 인셋+샤인 스윕). var BTN_SMALL = showcase9 ? 'position:relative;overflow:hidden;display:block;width:100%;font-size:15.5px;font-weight:800;letter-spacing:.2px;padding:14px 14px;border:0;border-radius:999px;color:#06331f;cursor:pointer;margin-top:10px;text-shadow:0 1px 0 rgba(255,255,255,.35);' + 'background:linear-gradient(180deg,#8ff3c0 0%,#3ecf8a 52%,#1d9c62 100%);' + 'box-shadow:0 10px 24px rgba(46,190,125,.42),inset 0 2px 0 rgba(255,255,255,.55),inset 0 -3px 0 rgba(0,70,40,.35);' : 'display:block;width:100%;font-size:13.5px;font-weight:700;padding:11px 14px;border:1px solid rgba(191,234,244,.25);border-radius:12px;background:rgba(124,240,255,.1);color:#bfeaf4;cursor:pointer;margin-top:10px;'; function _shine9(b9) { // CTA 샤인 스윕(프레스 유도 모션) var sh9 = document.createElement('span'); sh9.style.cssText = 'position:absolute;top:0;left:-60%;width:45%;height:100%;background:linear-gradient(100deg,rgba(255,255,255,0),rgba(255,255,255,.55),rgba(255,255,255,0));animation:gw-shine 2.6s ease-in-out infinite;pointer-events:none;'; b9.appendChild(sh9); } var prim_virtual = opts.primary === 'virtual' && has_geo && mobile; // 위계 옵션 — 실내를 상단 대형으로 var btn = document.createElement('button'); // GPS(야외) 경로 버튼 — 제스처 스코프 유지 btn.textContent = has_geo && mobile ? _copy.start : _copy.virtual_label; var alt = null; if (has_geo && mobile) { alt = document.createElement('button'); alt.textContent = _copy.virtual_label; alt.addEventListener('click', function () { _dismiss9(); _go_virtual(); }); if (prim_virtual) { // 실내 = 상단 대형 / 야외 = 하단 소형(에메랄드 글로시 — 샤인 동반) alt.style.cssText = BTN_BIG; if (showcase9) _shine9(alt); btn.style.cssText = BTN_SMALL; if (showcase9) _shine9(btn); sheet9.appendChild(alt); sheet9.appendChild(btn); } else { btn.style.cssText = BTN_BIG; if (showcase9) _shine9(btn); alt.style.cssText = BTN_SMALL; if (showcase9) _shine9(alt); sheet9.appendChild(btn); sheet9.appendChild(alt); } } else { btn.style.cssText = BTN_BIG; sheet9.appendChild(btn); } if (showcase9 && typeof opts.on_share === 'function') { // 친구-공유 글로시 버튼(퍼플 돔 + 스페큘러 + 샤인) var shr9 = document.createElement('button'); shr9.setAttribute('data-gw-share', '1'); shr9.setAttribute('aria-label', String(opts.share_label || 'Share')); shr9.style.cssText = 'display:flex;align-items:center;justify-content:center;gap:10px;width:100%;margin-top:12px;padding:9px 14px;border:0;background:transparent;cursor:pointer;color:#dfe7f2;font-size:14px;font-weight:700;font-family:inherit;'; var orb9 = document.createElement('span'); orb9.style.cssText = 'position:relative;overflow:hidden;flex:0 0 46px;height:46px;border-radius:50%;display:flex;align-items:center;justify-content:center;' + 'background:radial-gradient(circle at 32% 26%,#b18cff 0%,#7c5cff 48%,#4f3dd8 100%);' + 'box-shadow:0 8px 20px rgba(124,92,255,.45),inset 0 2px 3px rgba(255,255,255,.55),inset 0 -3px 5px rgba(30,10,90,.45);'; orb9.innerHTML = '
' + '
'; _shine9(orb9); shr9.appendChild(orb9); var slb9 = document.createElement('span'); slb9.textContent = String(opts.share_label || 'Share'); shr9.appendChild(slb9); shr9.addEventListener('click', function () { try { opts.on_share(); } catch (e9) {} }); sheet9.appendChild(shr9); } if (showcase9 && typeof opts.on_ranking === 'function') { // 게임 순위 글로시 버튼(2026-07-25 사용자 확정 — // "호기심 자극" 모션 스택: 골드 트로피 돔 + 스페큘러 + 샤인 스윕 + 맥동 글로우 링 + 간헐 트로피 위글 + 스파클 트윙클) var lbr9 = document.createElement('button'); lbr9.setAttribute('data-gw-ranking', '1'); lbr9.setAttribute('aria-label', String(opts.ranking_label || 'Ranking')); lbr9.style.cssText = 'display:flex;align-items:center;justify-content:center;gap:10px;width:100%;margin-top:6px;padding:9px 14px;border:0;background:transparent;cursor:pointer;color:#ffe6b0;font-size:14px;font-weight:700;font-family:inherit;'; var tro9 = document.createElement('span'); tro9.style.cssText = 'position:relative;overflow:visible;flex:0 0 46px;height:46px;border-radius:50%;display:flex;align-items:center;justify-content:center;' + 'background:radial-gradient(circle at 32% 26%,#ffe98a 0%,#ffb52e 52%,#c9720e 100%);' + 'animation:gw-lbpulse 2.2s ease-in-out infinite;'; tro9.innerHTML = '
' + '
✦
' + '
✦
'; var tri9 = document.createElement('span'); // 내부 클립 레이어(스페큘러+샤인 스윕) — 맥동 글로우 링은 tro9 가 소유 tri9.style.cssText = 'position:absolute;inset:0;border-radius:50%;overflow:hidden;pointer-events:none;'; tri9.innerHTML = '
'; _shine9(tri9); tro9.appendChild(tri9); lbr9.appendChild(tro9); var llb9 = document.createElement('span'); llb9.textContent = String(opts.ranking_label || 'Ranking'); lbr9.appendChild(llb9); lbr9.addEventListener('click', function () { try { opts.on_ranking(); } catch (e9) {} }); sheet9.appendChild(lbr9); } if (has_geo) line(_copy.privacy, 'font-size:11.5px;line-height:1.5;opacity:.5;margin-top:16px;'); line(_copy.safety, 'font-size:11.5px;line-height:1.5;opacity:.5;margin-top:6px;'); (function () { // 최하단 획득 CTA(2026-07-27 사용자 확정) — "코딩 없이, 내가 원하는 게임 만들기" // camo 오프닝 v2 노코드 CTA 정본 미러(틸 글로시·스페큘러·샤인 스윕·3언어·utm) — 문구/디자인 동일 계보. var _l9 = String(((typeof navigator !== 'undefined' && (navigator.language || navigator.userLanguage)) || document.documentElement.lang || 'en')).slice(0, 2); var _cta_t = _l9 === 'ko' ? '코딩 없이, 내가 원하는 게임 만들기' : (_l9 === 'ja' ? 'コーディングなしで、思いどおりのゲームを作る' : 'Build the game you want — no coding'); var mk9 = document.createElement('button'); mk9.type = 'button'; mk9.setAttribute('data-gw-makecta', '1'); mk9.style.cssText = 'position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;gap:8px;width:100%;margin-top:14px;padding:12px 14px;' + 'border:0;border-radius:16px;cursor:pointer;touch-action:manipulation;-webkit-tap-highlight-color:transparent;word-break:keep-all;' + 'background:linear-gradient(180deg,#8fe8ff 0%,#3fb6d8 55%,#1f7ea6 100%);color:#062a38;' + 'box-shadow:0 12px 28px rgba(63,182,216,.4),inset 0 2px 0 rgba(255,255,255,.5),inset 0 -3px 0 rgba(0,0,0,.2);'; mk9.innerHTML = '
' + '
' + '
✨
' + '
' + '
→
'; mk9.querySelector('[data-gw-makecta-t]').textContent = _cta_t; // textContent 주입(이스케이프 안전) mk9.addEventListener('pointerdown', function (ev) { ev.preventDefault(); ev.stopPropagation(); try { window.open('https://somodus.com/?utm_source=playable&utm_medium=opening&utm_campaign=ingame_cta&utm_content=geowalk_opening', '_blank', 'noopener'); } catch (e9) {} }); sheet9.appendChild(mk9); })(); ov.appendChild(card); document.body.appendChild(ov); btn.addEventListener('click', function () { _dismiss9(); if (!(has_geo && mobile)) { _go_virtual(); return; } _wake_acquire(); // inside the gesture — iOS may refuse outside one // heading feeds (Android absolute needs no prompt; iOS listener only yields if arcam granted motion) try { window.addEventListener('deviceorientationabsolute', _on_abs); } catch (e) {} try { window.addEventListener('deviceorientation', _on_ios_dev); } catch (e) {} var settled = false; _watch_id = navigator.geolocation.watchPosition(function (p) { if (!settled) { settled = true; _mode = 'gps'; _started = true; _emit('ready', { mode: _mode }); } _on_fix(p); }, function (err) { if (settled) return; settled = true; try { navigator.geolocation.clearWatch(_watch_id); } catch (e) {} _go_virtual(); // explicit-notice degrade (toast inside) }, { enableHighAccuracy: true, maximumAge: 2000, timeout: 15000 }); // some browsers neither fix nor error for a long time (prompt ignored) — watchdog to virtual setTimeout(function () { if (!settled) { settled = true; try { navigator.geolocation.clearWatch(_watch_id); } catch (e) {} _go_virtual(); } }, 20000); }); return null; }; })(); b ? b : v); } function pick(v, list, d) { return list.indexOf(v) >= 0 ? v : d; } function warn(m) { try { console.warn('[archetype] ' + m); } catch (e) {} } // i18n — 셸 고정문구 3언어(runtime TXT[lang] 캐논). config 문구는 LLM 이 사용자 언어로 채운다. // 언어 = 브라우저 우선(사용자 확정 2026-07-29 "기본 영어 + 브라우저 언어 다국어") — 공유 링크를 연 // 친구의 브라우저 언어를 따르고, 미지원 언어는 영어. 생성 언어(document lang)는 최후 폴백. var _at_bcp = String(((typeof navigator !== 'undefined' && (navigator.language || navigator.userLanguage)) || document.documentElement.lang || 'en')).toLowerCase(); var _at_lang = _at_bcp.indexOf('ko') === 0 ? 0 : (_at_bcp.indexOf('ja') === 0 ? 1 : 2); function _at_t(ko, ja, en) { return _at_lang === 0 ? ko : (_at_lang === 1 ? ja : en); } var _at_lang6 = (function () { // 6언어 키(맵 라벨 등 라벨 데이터 해석) — 동일 브라우저-우선 정책 if (_at_bcp.indexOf('ko') === 0) return 'ko'; if (_at_bcp.indexOf('ja') === 0) return 'ja'; if (_at_bcp.indexOf('es') === 0) return 'es'; if (_at_bcp.indexOf('zh') === 0) return (_at_bcp.indexOf('tw') >= 0 || _at_bcp.indexOf('hk') >= 0 || _at_bcp.indexOf('hant') >= 0) ? 'hant' : 'hans'; return 'en'; })(); // ── 공유 맵-품질 프리미티브(저폴리 실내 리치니스 SOTA — 전 아키타입 재사용) ── // 전부 무비용 프리미티브·실광원 0. camo 개편에서 추출, 모든 스테이지가 맞는 것만 소비. // _q_blob: 가짜 접지 그림자(저폴리 부유감 봉합 — 범용). _q_ceil_light: emissive 천장 픽스처+바닥 광-풀 // (실내·야간). _q_baseboard: 걸레받이(실내 벽 마감 #1 신호). function _q_blob(cx, cz, fy, r) { var m = new THREE.Mesh(new THREE.CircleGeometry(r, 16), new THREE.MeshBasicMaterial({ color: 0x0a0d12, transparent: true, opacity: 0.3, depthWrite: false })); m.rotation.x = -Math.PI / 2; m.position.set(cx, fy + 0.06, cz); scene.add(m); } function _q_ceil_light(cx, cz, fy, ceilY, hex) { var pan = new THREE.Mesh(new THREE.BoxGeometry(1.8, 0.12, 1.8), P.make.glow(hex || '#ffe6b0', 0.5)); pan.position.set(cx, ceilY, cz); scene.add(pan); var pool = new THREE.Mesh(new THREE.CircleGeometry(3.2, 18), new THREE.MeshBasicMaterial({ color: new THREE.Color(hex || '#ffe6b0'), transparent: true, opacity: 0.035, depthWrite: false })); pool.rotation.x = -Math.PI / 2; pool.position.set(cx, fy + 0.07, cz); scene.add(pool); } function _q_baseboard(cx, cz, hx, hz, fy, hex) { var base = new THREE.Mesh(new THREE.BoxGeometry(hx * 2 + 0.14, 0.24, hz * 2 + 0.14), P.make.matte(hex || '#23272e', { roughness: 0.9 })); base.position.set(cx, fy + 0.12, cz); scene.add(base); } // _q_shade: 면에 vertex-color 그라디언트/AO를 구워 flat 단색의 밋밋함을 봉합(텍스처 없이 톤 깊이 부여 — // 저폴리 flat-shading 친화·런타임 무비용). mode 'y' = 세로(하단 어둡게, 벽) · 'xz' = 가장자리(코너 AO, 바닥). // vertex color 는 머티리얼 색에 곱해지므로 원색(은신 기능색 포함) 보존, 톤만 변조. function _q_shade(mesh, dark, mode) { var g = mesh && mesh.geometry; if (!g || !g.attributes || !g.attributes.position) return; var pos = g.attributes.position, n = pos.count, ym = Infinity, yM = -Infinity, em = 0.0001, i; for (i = 0; i < n; i++) { var y = pos.getY(i); if (y < ym) ym = y; if (y > yM) yM = y; var e = Math.max(Math.abs(pos.getX(i)), Math.abs(pos.getZ(i))); if (e > em) em = e; } var col = new Float32Array(n * 3); for (i = 0; i < n; i++) { var v; if (mode === 'xz') { var d = Math.max(Math.abs(pos.getX(i)), Math.abs(pos.getZ(i))) / em; v = dark + (1 - dark) * (1 - d * d); } // 중앙 밝고 가장자리 어둡게 else { var t = (pos.getY(i) - ym) / ((yM - ym) || 1); v = dark + (1 - dark) * t; } // 하단 어둡고 상단 밝게 col[i * 3] = v; col[i * 3 + 1] = v; col[i * 3 + 2] = v; } g.setAttribute('color', new THREE.BufferAttribute(col, 3)); if (mesh.material && !mesh.material.vertexColors) { mesh.material = mesh.material.clone(); mesh.material.vertexColors = true; } } // ── 테마팩 스테이지 프롭(공유 — dungeon/horror/race 카탈로그 배선 캠페인) ── // 카탈로그 GLB(it={n,h,r})를 (x,z)·바닥 fy 에 native 스케일로 스폰(+선택 콜라이더). world.spawn 기본 // height_at 접지를 슬랩 fy 로 덮어씀. 미주입 시 아키타입은 이 호출을 건너뛰고 프리미티브 스테이지 유지 // (zero-asset 불변식). window.__stageprops 카운터 = 골든 관측용. function _spawn_stage_prop(it, x, z, fy, opt) { opt = opt || {}; var th = clamp(num(it && it.h, 1), 0.3, num(opt.cap_h, 2.8)); // native 크기, 상한(천장 관통 방지) var hh = P.world.spawn(it.n, { at: { x: x, z: z }, h: th, _no_collider: true, _no_uni: true }); if (hh && fy != null) hh.obj.position.y = fy; // fy=null → world.spawn 의 height_at 접지 유지(지형 기반 아키타입) if (opt.collide !== false) { var scale = th / Math.max(0.05, num(it && it.h, 1)); var cr = clamp(num(it && it.r, 0.4) * scale, 0.22, num(opt.cap_r, 1.2)); var _by = (fy != null ? fy : P.world.height_at(x, z)); P.world._priv.collider_box({ x: x, z: z, hx: cr, hz: cr, y0: _by - 3, y1: _by + th }); } var _br = clamp(num(it && it.r, 0.4) * (th / Math.max(0.05, num(it && it.h, 1))), 0.28, 1.5); _q_blob(x, z, (fy != null ? fy : P.world.height_at(x, z)), _br); // 범용 접지 그림자(부유감 봉합) try { window.__stageprops = (window.__stageprops || 0) + 1; } catch (e) {} return hh; } // ── POI 조형 3종(셸 소유 — 검증된 실루엣) ── function _poi_mesh(kind, pal) { var g = new THREE_.Group(); var gr = pal.ground_ramp || [pal.ground, pal.ground, pal.ground, pal.ground]; var accent = P.make.glow(pal.accent, 1.15), soft = P.make.matte(gr[3]), stone = P.make.matte(gr[0]); // 1.8→1.15: 원거리 arch 토러스가 블룸 헤일로로 번져 "하늘에 뜬 일식 고리" 아티팩트(실측 줌 확정) — 임계(1.05) 살짝 위 = 은은한 반짝임만 if (kind === 'arch') { var ring = new THREE_.Mesh(new THREE_.TorusGeometry(2.2, 0.09, 10, 40), accent); ring.position.y = 2.5; g.add(ring); [-1.6, 1.6].forEach(function (x) { var p = new THREE_.Mesh(new THREE_.CylinderGeometry(0.12, 0.18, 2.5, 8), soft); p.position.set(x, 1.15, 0); g.add(p); }); g._pulses = [P.fx.pulse(ring, { amount: 0.1, rate: 1.1 })]; } else if (kind === 'stones') { for (var i = 0; i < 8; i++) { var a = i * 0.785; var s = new THREE_.Mesh(new THREE_.DodecahedronGeometry(0.34, 0), i % 3 ? stone : soft); s.position.set(Math.cos(a) * 1.9, 0.24, Math.sin(a) * 1.9); s.scale.y = 0.6 + 0.2 * (i % 2); g.add(s); } var c = new THREE_.Mesh(new THREE_.SphereGeometry(0.4, 16, 12), accent); c.position.y = 0.8; g.add(c); g._pulses = [P.fx.pulse(c, { amount: 0.12, rate: 1.5 })]; } else { // tree(기본) var tr = new THREE_.Mesh(new THREE_.CylinderGeometry(0.3, 0.42, 3, 8), P.make.matte(gr[0])); tr.position.y = 1.5; g.add(tr); var c1 = new THREE_.Mesh(new THREE_.SphereGeometry(1.9, 12, 9), P.make.matte(gr[2])); c1.position.y = 3.6; c1.scale.set(1, 0.75, 1); g.add(c1); var c2 = new THREE_.Mesh(new THREE_.SphereGeometry(1.2, 10, 8), soft); c2.position.set(0.9, 4.1, 0.3); g.add(c2); } return g; } // ═══ 아키타입 #1: meadow_explorer — 오픈월드 수집 탐험(완비·골든-테스트 대상) ═══ var _ARCH = {}; _ARCH.meadow_explorer = function (cfg) { var c = cfg || {}; // ── Tier B config 검증(전 필드 기본값·클램프·enum — 어떤 입력에도 동작 보장) ── var mood_req = c.mood === 'night' ? 'night_neon' : c.mood; // P.mood 의 'night' alias 와 정합(사전 검증이 alias 를 우회하지 않게) // "항상 낮부터 시작" 정책(실보고: 어두운 시작 화면 = 밤 오인) — 어두운 무드는 아키타입 *시작* // 무드로 쓰지 않는다(훅에서 진행 중 전환은 자유). sunset 으로 강등해 브리프 감성은 보존. if (mood_req === 'night_neon' || mood_req === 'ember' || mood_req === 'dusk') { warn('dark mood "' + mood_req + '" demoted to sunset — archetype starts in daylight (switch later via hooks if needed)'); mood_req = 'sunset'; } var mood_name = pick(mood_req, P.moods || [], 'noon'); if (mood_req && mood_name !== mood_req) warn('unknown mood "' + mood_req + '" → ' + mood_name); var pal = P.mood(mood_name); P.music(pick(c.music, ['calm_exploration', 'adventure', 'cozy', 'mystery', 'night', 'chiptune_action', 'battle', 'triumph', 'space', 'spooky', 'lofi', 'sad', 'happy'], 'lofi')); P.ambience(pick(c.ambience, ['forest', 'wind', 'rain', 'night', 'ocean', 'cave', 'crowd'], 'forest')); P.enable_bloom({ strength: 0.55, radius: 0.5, threshold: 1.05 }); var world = P.world, nature = P.nature, fx = P.fx; nature.wind({ strength: clamp(num(c.wind, 0.6), 0, 1.2), gustiness: 0.5, direction: { x: 0.8, z: 0.3 } }); var gr = pal.ground_ramp || [pal.ground, pal.ground, pal.ground, pal.ground]; // scale 130(파장) — 완만한 구릉(경사 ∝ amplitude/scale). 색 생략 = 무드 ground_ramp 기본값. var terrain = world.terrain({ seed: (num(c.seed, 7) | 0), amplitude: clamp(num(c.amplitude, 4), 1, 7), scale: 130, chunk: 32, radius: 3, }); var CHARS = ['farmer', 'adventurer', 'villager', 'knight', 'rogue', 'worker', 'king', 'mage', 'barbarian', 'striker', 'robot']; // 확장 카탈로그 캐릭터 허용 — 이번 생성에 배선된 인라인 매니페스트(character 카테고리)의 이름이면 // 베이스 목록 밖이어도 플레이어 자격(21종+ 다양성의 아키타입 경로 개방). 그 외 미지 이름은 기본 강등. var _extm = (typeof window !== 'undefined' && window['__PLAYABLE_'+'EXTRA_MANIFEST__'] && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].models) || {}; var _psel = typeof c.player === 'string' && (_extm[c.player] && _extm[c.player].cat === 'character') ? c.player : pick(c.player, CHARS, 'adventurer'); var player = world.player({ spawn: _psel, at: { x: 0, z: 0 }, speed: 4, run_speed: 7.2 }); world.camera(); world.controls({ mission: c.hint ? String(c.hint) : '' }); // hint = 미션 문장 → 시작 카드에 승격(토스트보다 오래·조작법과 함께) world.minimap(); // 미니맵 — POI 마커·수집물·시선 표시(길찾기 실보고의 상시 보조 수단) // 미션 타이머 — 시작 = 첫 입력(시작 카드 해제와 동일 제스처 = 실플레이 개시 시점의 정본) var t0 = null, t_final = null; function _t_begin() { if (t0 === null) t0 = performance.now(); window.removeEventListener('pointerdown', _t_begin); window.removeEventListener('keydown', _t_begin); } window.addEventListener('pointerdown', _t_begin); window.addEventListener('keydown', _t_begin); function _t_fmt(ms) { var sec = Math.max(0, ms) / 1000; var m = Math.floor(sec / 60); var r = sec - m * 60; return m + ':' + (r < 10 ? '0' : '') + r.toFixed(1); } nature.grass_field({ area: 150, count: 26000, base: gr[1], tip: gr[3], height: 0.55, y: terrain.height_at }); // 26K = 모바일 fillrate 안전권(42K 실사고). height 0.55 = 정강이~무릎(0.7 은 허리께 "갈대" 지각 실보고) nature.clouds({ count: 14, style: 'puffy' }); nature.ground_cover({ density: clamp(num(c.cover, 1), 0.2, 2.5) }); // 바닥 클러터(자갈·들꽃·버섯·덤불) — 황량한 평원 실보고 봉합 var WEATHERS = ['clear', 'petals', 'leaves', 'rain', 'snow']; var wsel = pick(c.weather, WEATHERS, 'petals'); if (wsel !== 'clear') nature.weather(wsel, { count: 70 }); // 색은 날씨 기본값이 소유 — accent 덮어쓰기가 분홍 꽃잎을 '흰 종이조각'으로 만들던 정체성 상실(실보고) // 스캐터(항상 존재 — 빈 들판 방지). 매스 나무 = 카탈로그 패밀리('tree' — 생성기가 게임마다 // 다양 표본 자동 배선, world.md _scatter_named 가 다종 혼합 스트리밍) — 프리미티브 단일종이 // 전 맵을 채워 "모든 나무가 똑같다" 실보고(2026-07-13)의 구조 봉합. 프리미티브는 패밀리 // 부재(오프라인/스모크/인덱스 미발행) 시의 L2 폴백으로 강등(무빈땅 보장 불변). var tree = new THREE_.Group(); var t1 = new THREE_.Mesh(new THREE_.CylinderGeometry(0.22, 0.3, 1.6, 7), P.make.matte(gr[0])); t1.position.y = 0.8; tree.add(t1); var t2 = new THREE_.Mesh(new THREE_.ConeGeometry(1.1, 2.1, 9), P.make.matte(gr[2])); t2.position.y = 2.2; tree.add(t2); world.scatter('tree', { per_chunk: 9, scale: [0.8, 1.3], collide_r: 0.7, fallback: tree }); // min_h/max_h 는 0..1 정규화 대역 — 구 코드가 미터(-3/6)로 넘기던 실오용 제거(기본 = 전 대역) // per_chunk 9 = 시야 원뿔(~90°, 45m)에 ~4그루(5 였을 땐 ~2그루 = 원경 맨땅 지각) — 총량 해석, // 종 수로 분할되므로 밀도 불변. GLB 키트 링 식재(아래)는 근경 랜드마크 역할로 존치. // 실수목 근경 식재 — 판별자 = 물리량(h, 인제스트 엔진 실측 표시높이) ∩ 이름-토큰 배제. // ⚠️ 태그 단독 판별 금지: 인제스트가 팩-공통 태그를 전 파일에 부착해 꽃무리·덤불에도 'tree' 가 // 붙는다(실사고 2026-07-12: 자동 14그루 중 진짜 나무 5 — 나머지가 30cm 꽃무리로 대체돼 "황량"). // h 는 실측이라 유일-결정: 나무 ≥2.2m, 데코 ≤1.4m. 배치 = 근경 가중 2밴드(60% 14~55m + // 40% 55~105m) — 구 균일 16~111m 광역 산포는 πr² 희석으로 체감 밀도 0 이 되던 실틈. function _kit_h(n) { return num(_extm[n] && _extm[n].h, 0); } var _kit_trees = Object.keys(_extm).filter(function (n) { var t = _extm[n].tags || []; return (t.indexOf('tree') >= 0 || t.indexOf('nature') >= 0) && _kit_h(n) >= 2.2 && !/flower|bush|grass|rock/.test(n); }); var _kit_deco = Object.keys(_extm).filter(function (n) { var t = _extm[n].tags || []; return (t.indexOf('rock') >= 0 || t.indexOf('flower') >= 0 || t.indexOf('bush') >= 0 || t.indexOf('nature') >= 0) && _kit_h(n) > 0 && _kit_h(n) <= 1.4; }); if (_kit_trees.length) { var _tprng = world._priv.mulberry32(((num(c.seed, 7) * 48271) ^ 0x9E3779B9) >>> 0); for (var _ti = 0; _ti < 18; _ti++) { var _ta = _tprng() * Math.PI * 2; var _tr = (_ti % 5 < 3) ? (14 + _tprng() * 41) : (55 + _tprng() * 50); // 근경 가중 2밴드 world.spawn(_kit_trees[_ti % _kit_trees.length], { at: { x: Math.cos(_ta) * _tr, z: Math.sin(_ta) * _tr } }); } for (var _di = 0; _di < 12 && _kit_deco.length; _di++) { var _da = _tprng() * Math.PI * 2, _dr = 8 + _tprng() * 55; // 데코 근경 위주(첫 프레임 채움) world.spawn(_kit_deco[_di % _kit_deco.length], { at: { x: Math.cos(_da) * _dr, z: Math.sin(_da) * _dr }, collide: false }); } } // ── POI(발견 지점) — config 배열, ≤5, 좌표 클램프 ── var pois = Array.isArray(c.pois) ? c.pois.slice(0, 5) : []; if (!pois.length) pois = [{ label: '고요한 쉼터', kind: 'stones', x: 40, z: 30 }, { label: '빛의 아치', kind: 'arch', x: -45, z: 25 }, { label: '큰 나무', kind: 'tree', x: 20, z: -50 }]; var discovered = 0; var poi_items = pois.map(function (p, i) { var x = clamp(num(p && p.x, 30 + i * 15), -120, 120), z = clamp(num(p && p.z, 30 - i * 25), -120, 120); var kind = pick(p && p.kind, ['arch', 'stones', 'tree'], 'tree'); var label = String((p && p.label) || 'POI ' + (i + 1)).slice(0, 24); var g = _poi_mesh(kind, pal); g.position.set(x, world.height_at(x, z), z); scene.add(g); fx.spawn(g); return { label: label, index: i, seen: false, x: x, z: z, g: g, mk: world.marker(g, { label: label }) }; }); // 근접 판정 — POI 당 rAF 루프 1개씩(5 POI = 5루프) 돌리던 낭비를 단일 루프로 통합. var _t_tick = 0; P.loop(function (dt) { if (dt <= 0) return; if (t0 !== null && t_final === null && (++_t_tick % 6) === 0) _hud_time(performance.now() - t0); for (var pi = 0; pi < poi_items.length; pi++) { var it = poi_items[pi]; if (it.seen) continue; var dx = player.pos.x - it.x, dz = player.pos.z - it.z; if (dx * dx + dz * dz < 36) { it.seen = true; discovered++; it.mk.remove(); // 미션 달성물은 반드시 소멸(실보고) — 완료된 랜드마크가 계속 서서 반짝이면 "아직 할 일" // 로 오독된다. pulse 정지 후 축소 소멸(fx.despawn 이 씬 제거까지 수행). if (it.g._pulses) { for (var _pi = 0; _pi < it.g._pulses.length; _pi++) { try { it.g._pulses[_pi].stop(); } catch (e) {} } } fx.despawn(it.g, { dur: 0.5 }); var fp = { x: it.x, y: world.height_at(it.x, it.z) + 2, z: it.z }; fx.burst(fp, { count: 26, color: pal.accent, speed: 3, life: 1.2, gravity: -0.15 }); fx.float_text(fp, it.label, { color: pal.accent }); fx.sfx('powerup'); A._emit('discover', { label: it.label, index: it.index, discovered: discovered, total: pois.length }); _check_complete(); } } }); // ── 수집물 — config {model, count, label} ── var PROPS = ['gem', 'coin', 'potion_red', 'potion_blue', 'potion_green', 'apple', 'mushroom', 'gold', 'key']; var col = c.collect || {}; var total = clamp(Math.round(num(col.count, 10)), 3, 24); var citem = pick(col.model, PROPS, 'gem'); var clabel = String(col.label || '반짝임').slice(0, 16); var collected = 0; var prng = world._priv.mulberry32((num(c.seed, 7) * 92821) >>> 0); // world 와 동일 PRNG 정본(제3 RNG 재발명 금지) for (var ci = 0; ci < total; ci++) { (function () { var ang = prng() * Math.PI * 2, r = 12 + prng() * 85; var x = Math.cos(ang) * r, z = Math.sin(ang) * r; var h = world.spawn(citem, { at: { x: x, z: z }, h: 0.55, collide: false }); world.collectible(h, { magnet: 6.5, on_collect: function () { collected++; _hud(); A._emit('collect', { count: collected, total: total, x: x, z: z }); _check_complete(); } }); })(); } // ── 동물(분위기) — config 배열 ≤5 ── var ANIMALS = ['deer', 'sheep', 'fox', 'bird', 'stag', 'horse', 'dog', 'cat', 'wolf', 'pig']; (Array.isArray(c.animals) ? c.animals.slice(0, 5) : [{ model: 'deer', x: 25, z: 18 }, { model: 'sheep', x: -18, z: 30 }]).forEach(function (an) { var m = pick(an && an.model, ANIMALS, 'deer'); var h = world.spawn(m, { at: { x: clamp(num(an && an.x, 20), -110, 110), z: clamp(num(an && an.z, 20), -110, 110) }, collide: false }); h.play('idle'); }); // ── HUD + 완결 루프(항상 존재 — 누락 불가) ── var ui = document.createElement('div'); ui.id = 'playable-archetype-hud'; ui.style.cssText = 'position:fixed;left:64px;top:calc(52px + env(safe-area-inset-top,0px));z-index:20;color:#fff;font-family:system-ui,sans-serif;pointer-events:none;text-shadow:0 2px 8px rgba(0,0,0,.45);'; // top 52 = 중앙 미션 칩(top:10 h≈36) 아래 — 좁은 화면 제목 겹침 실측 봉합 // left 64 = 뮤트 버튼(좌상단 40px+마진) 회피 — 제목 가림 실보고 // UI 존 계약 — 미니맵 점유 시 우측 인셋(칩 행이 미니맵 밑으로 흐르던 세로폰 겹침 실보고 봉합). // right 지정 = 컨테이너 폭 고정 → inline-block 칩들이 자동 줄바꿈(겹침의 표현 불가능화). if (P._ui) P._ui.on_claim(function (nm) { if (nm === 'minimap') ui.style.right = (P._ui.Z.minimap.r + P._ui.Z.minimap.w + 12) + 'px'; }); ui.innerHTML = '
' + '
' + '
⏱ 0:00.0
'; ui.children[0].textContent = String(c.title || '들판 탐험').slice(0, 40); document.body.appendChild(ui); function _hud() { ui.children[1].textContent = clabel + ' ' + collected + ' / ' + total + (pois.length ? ' · 발견 ' + discovered + ' / ' + pois.length : ''); } function _hud_time(ms) { ui.children[2].textContent = '⏱ ' + _t_fmt(ms); } _hud(); var complete = false; function _check_complete() { if (complete || collected < total || discovered < pois.length) return; complete = true; if (t0 !== null) { t_final = performance.now() - t0; _hud_time(t_final); } P.music('triumph'); fx.screen_flash(pal.key); fx.burst({ x: player.pos.x, y: player.pos.y + 2.2, z: player.pos.z }, { count: 40, color: pal.accent, speed: 5, life: 1.4, gravity: -0.4 }); P.toast(String(c.complete_text || '오늘의 산책을 마음에 담았어요.').slice(0, 60)); A._emit('complete', { collected: collected, discovered: discovered, time_ms: t_final }); // 완료 순간 기록 카드 — 축포가 화면에 실린 프레임을 캡처(900ms 후 = 플래시 잦아들고 버스트 절정) var _rec = t_final != null ? _t_fmt(t_final) : null; setTimeout(function () { P.three.capture().then(function (shot) { P.share_shot(shot, { title: String(c.title || '들판 탐험').slice(0, 40), lines: [(_rec ? '⏱ ' + _rec + ' · ' : '') + clabel + ' ' + collected + '/' + total], share_text: String(c.share_text || '평화로운 들판에서 ' + clabel + ' ' + collected + '개를 모았어요 🌿').slice(0, 120) + (_rec ? ' ⏱ ' + _rec : ''), }); }); }, 900); } P.mount_share(function () { return String(c.share_text || '평화로운 들판에서 ' + clabel + ' ' + collected + '개를 모았어요 🌿').slice(0, 120); }, String(c.share_label || '공유').slice(0, 12)); // (hint 는 시작 카드로 승격 — 중복 토스트 제거) A._installed = 'meadow_explorer'; return { player: player, get collected() { return collected; }, get discovered() { return discovered; }, total: total }; }; // ═══ 아키타입 #2: craft_sandbox — 복셀 채굴·건축 서바이벌(완비·골든-테스트 대상) ═══ // craft 패밀리 완전체: 복셀 월드 + FPV + 핫바 + 채굴 미션 + 몹 + 완료 공유. meadow 와 동일 // Tier B 계약(전 필드 기본값·클램프·enum — 어떤 입력에도 동작하는 게임). _ARCH.craft_sandbox = function (cfg) { var c = cfg || {}; if (!P.craft) { warn('craft family missing — falling back to meadow_explorer'); return _ARCH.meadow_explorer(cfg); } var mood_req = c.mood === 'night' ? 'night_neon' : c.mood; if (mood_req === 'night_neon' || mood_req === 'ember' || mood_req === 'dusk') { warn('dark mood "' + mood_req + '" demoted to sunset — archetype starts in daylight'); mood_req = 'sunset'; } var mood_name = pick(mood_req, P.moods || [], 'noon'); var pal = P.mood(mood_name); P.music(pick(c.music, ['calm_exploration', 'adventure', 'cozy', 'mystery', 'night', 'chiptune_action', 'battle', 'triumph', 'space', 'spooky', 'lofi', 'sad', 'happy'], 'lofi')); P.ambience(pick(c.ambience, ['forest', 'wind', 'rain', 'night', 'ocean', 'cave', 'crowd'], 'wind')); var craft = P.craft, world = P.world, fx = P.fx; var theme = pick(c.theme, ['meadow', 'desert', 'snow', 'island'], 'meadow'); craft.world({ seed: (num(c.seed, 7) | 0), size: pick(c.size, ['small', 'medium', 'large'], 'medium'), theme: theme, amplitude: clamp(num(c.amplitude, 6), 2, 10), caves: c.caves !== false, trees: clamp(num(c.trees, 1), 0, 3), mobs: c.mobs !== false, // 서바이벌 기본 — combat 패밀리는 아키타입 deps 가 동반 주입 day_cycle: c.day_cycle, // 기본 on(낮 90s/밤 45s) — 검증은 craft.world 소유(Tier B) }); var HOTS = ['grass', 'dirt', 'stone', 'sand', 'wood', 'leaves', 'plank', 'glass', 'ore', 'snow']; var hot = (Array.isArray(c.hotbar) ? c.hotbar : ['dirt', 'stone', 'wood', 'plank', 'glass', 'leaves']) .map(String).filter(function (n) { return HOTS.indexOf(n) >= 0; }).slice(0, 6); if (hot.length < 3) hot = ['dirt', 'stone', 'wood']; craft.hotbar(hot, { counts: c.counts === 'infinite' ? 'infinite' : (Array.isArray(c.counts) ? c.counts : undefined) }); if (c.counts !== 'infinite' && c.recipes !== false) craft.recipes(Array.isArray(c.recipes) ? c.recipes : undefined); // 1탭 제작(counts 경제 전용, 기본 wood→plank·sand→glass) // ── mine 'none' 센티널 — 순수-창작 계약(미션 카운터·타이머·완주 표면이 구조적으로 부재). // 게임 장기는 브리프가 요청한 곳에만 부착(SIM-FORM 원칙) — 창작 브리프의 정본 형태. ── var mine_none = c.mine === 'none'; var player = craft.player({ at: { x: 0, z: 0 }, mission: (c.hint && !mine_none) ? String(c.hint) : '', buttons: c.buttons, camera: c.camera, aim: c.aim }); // buttons/camera/aim 검증은 craft.player 소유(Tier B — 기본 3인칭·crosshair) if (mine_none && c.hint) P.toast(String(c.hint).slice(0, 60)); // 순수 창작은 목표 칩 대신 힌트만 토스트로 world.minimap(); // ── 채굴 미션 — config mine:{block, count, label} (기본: 광석 5) | 'none' = 미션 장기 부재 ── if (!mine_none && !c.mine) warn('legacy default mine mission injected (ore 5) — pass mine:"none" for a pure-creative build scene'); var mi = (!mine_none && c.mine) || {}; var mblock = pick(mi.block, ['ore', 'wood', 'stone', 'leaves', 'sand', 'snow'], 'ore'); var mtotal = clamp(Math.round(num(mi.count, 5)), 2, 30); var mlabel = String(mi.label || (mblock === 'ore' ? '광석' : mblock)).slice(0, 16); var mined = 0; // 타이머(첫 입력 시작 — meadow 정본과 동일) var t0 = null, t_final = null; function _t_begin() { if (t0 === null) t0 = performance.now(); window.removeEventListener('pointerdown', _t_begin); window.removeEventListener('keydown', _t_begin); } window.addEventListener('pointerdown', _t_begin); window.addEventListener('keydown', _t_begin); function _t_fmt(ms) { var sec = Math.max(0, ms) / 1000; var m = Math.floor(sec / 60); var r = sec - m * 60; return m + ':' + (r < 10 ? '0' : '') + r.toFixed(1); } // HUD(제목 + 미션 카운터 + 타이머) — 뮤트 버튼(좌상단 40px)과 겹치지 않게 우측 배치 var ui = document.createElement('div'); ui.id = 'playable-archetype-hud'; ui.style.cssText = 'position:fixed;left:64px;top:calc(52px + env(safe-area-inset-top,0px));z-index:20;color:#fff;font-family:system-ui,sans-serif;pointer-events:none;text-shadow:0 2px 8px rgba(0,0,0,.45);'; // top 52 = 중앙 미션 칩(top:10 h≈36) 아래 — 좁은 화면 제목 겹침 실측 봉합 if (P._ui) P._ui.on_claim(function (nm) { if (nm === 'minimap') ui.style.right = (P._ui.Z.minimap.r + P._ui.Z.minimap.w + 12) + 'px'; }); // UI 존 계약(위 HUD 와 동일) ui.innerHTML = '
' + '
' + '
⏱ 0:00.0
'; ui.children[0].textContent = String(c.title || '블록 세계').slice(0, 40); document.body.appendChild(ui); if (mine_none) { ui.children[1].style.display = 'none'; ui.children[2].style.display = 'none'; } // 순수 창작 = 제목만 function _hud() { if (!mine_none) ui.children[1].textContent = '⛏ ' + mlabel + ' ' + mined + ' / ' + mtotal; } _hud(); P.loop(function () { if (!mine_none && t0 !== null && t_final === null) ui.children[2].textContent = '⏱ ' + _t_fmt(performance.now() - t0); }); var complete = false; craft.on_break(function (x, y, z, name) { A._emit('break', { x: x, y: y, z: z, name: name, mined: mined, total: mtotal }); if (mine_none || complete || name !== mblock) return; mined++; _hud(); A._emit('mine', { count: mined, total: mtotal, name: name }); if (mined < mtotal) return; complete = true; if (t0 !== null) t_final = performance.now() - t0; P.music('triumph'); fx.screen_flash(pal.key); P.toast(String(c.complete_text || mlabel + ' ' + mtotal + '개를 모두 캤어요! 이제 자유롭게 지어 보세요.').slice(0, 60)); A._emit('complete', { mined: mined, time_ms: t_final }); var _rec = t_final != null ? _t_fmt(t_final) : null; setTimeout(function () { P.three.capture().then(function (shot) { P.share_shot(shot, { title: String(c.title || '블록 세계').slice(0, 40), lines: [(_rec ? '⏱ ' + _rec + ' · ' : '') + '⛏ ' + mlabel + ' ' + mined + '/' + mtotal], share_text: String(c.share_text || '블록 세계에서 ' + mlabel + ' ' + mined + '개를 캤어요 ⛏').slice(0, 120), }); }); }, 900); }); craft.on_place(function (x, y, z, name) { A._emit('place', { x: x, y: y, z: z, name: name }); }); P.mount_share(function () { return String(c.share_text || (mine_none ? '블록 세계를 자유롭게 짓고 있어요 🧱' : '블록 세계에서 ' + mlabel + ' ' + mined + '개를 캤어요 ⛏')).slice(0, 120); }, String(c.share_label || '공유').slice(0, 12)); A._installed = 'craft_sandbox'; return { player: player, get mined() { return mined; }, total: mtotal, craft: craft }; }; // ── obby 공용 재료(아키타입 #3/#4) — 아바타 선정·타이머·HUD 칩 골격 ── function _obby_avatar(c) { // 생성기 매니페스트 .obby(블로키 아바타 로스터) 우선 — 미발행 시 기본 카탈로그 폴백 var _obm = (typeof window !== 'undefined' && window['__PLAYABLE_'+'EXTRA_MANIFEST__'] && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].obby) || null; var _extm = (typeof window !== 'undefined' && window['__PLAYABLE_'+'EXTRA_MANIFEST__'] && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].models) || {}; if (typeof c.player === 'string' && _extm[c.player]) return c.player; // config 명시(베이스 목록) > 로스터 기본 — 명시 선택이 로스터에 침묵 무시되던 실결함(선제 감사). var _BASE = ['farmer', 'adventurer', 'villager', 'knight', 'rogue', 'worker', 'king', 'mage', 'barbarian', 'striker', 'robot']; if (typeof c.player === 'string' && _BASE.indexOf(c.player) >= 0) return c.player; if (_obm && _obm.player && _obm.player.length) return _obm.player[0]; return pick(c.player, _BASE, 'adventurer'); } function _fmt_t(ms) { var sec = Math.max(0, ms) / 1000; var m = Math.floor(sec / 60); var r = sec - m * 60; return m + ':' + (r < 10 ? '0' : '') + r.toFixed(1); } function _arch_hud(title) { var ui = document.createElement('div'); ui.id = 'playable-archetype-hud'; ui.style.cssText = 'position:fixed;left:64px;top:calc(52px + env(safe-area-inset-top,0px));z-index:20;color:#fff;font-family:system-ui,sans-serif;pointer-events:none;text-shadow:0 2px 8px rgba(0,0,0,.45);'; // meadow HUD 와 동일 존 계약 if (P._ui) P._ui.on_claim(function (nm) { if (nm === 'minimap') ui.style.right = (P._ui.Z.minimap.r + P._ui.Z.minimap.w + 12) + 'px'; }); ui.innerHTML = '
' + '
' + '
⏱ 0:00.0
'; ui.children[0].textContent = String(title).slice(0, 40); document.body.appendChild(ui); return ui; } // ═══ 아키타입 #3: obby_course — 장애물 코스 파쿠르(완비·골든-테스트 대상) ═══ // obby 패밀리 부품(이동/소실/벨트 플랫폼·스피너·체크포인트·낙사)의 시드-결정론 코스 조립. // 갭 문법 = 우리 점프 물리(jump_v 5.5·중력 14 → 정점 1.08m·체공 0.79s·달리기 7.2m/s) 기준 // 모서리-간격: easy 1.6~2.4 / normal 2.2~3.2 / hard 2.8~4.0m(상승 +0.2~0.5m 포함 안전역). // 사망 = 무패널티 즉시 리스폰(옵비 장르 관습 — 데스 스크린 금지, obby.respawn 이 소유). _ARCH.obby_course = function (cfg) { var c = cfg || {}; if (!P.obby) { warn('obby family missing — falling back to meadow_explorer'); return _ARCH.meadow_explorer(cfg); } var mood_req = c.mood === 'night' ? 'night_neon' : c.mood; if (mood_req === 'night_neon' || mood_req === 'ember' || mood_req === 'dusk') { warn('dark mood "' + mood_req + '" demoted to sunset — archetype starts in daylight'); mood_req = 'sunset'; } var mood_name = pick(mood_req, P.moods || [], 'noon'); var pal = P.mood(mood_name); P.music(pick(c.music, ['calm_exploration', 'adventure', 'cozy', 'mystery', 'night', 'chiptune_action', 'battle', 'triumph', 'space', 'spooky', 'lofi', 'sad', 'happy'], 'lofi')); P.ambience(pick(c.ambience, ['forest', 'wind', 'rain', 'night', 'ocean', 'cave', 'crowd'], 'wind')); P.enable_bloom({ strength: 0.55, radius: 0.5, threshold: 1.05 }); var world = P.world, obby = P.obby, fx = P.fx, nature = P.nature; var seed = (num(c.seed, 11) | 0); nature.wind({ strength: 0.5, gustiness: 0.4, direction: { x: 0.8, z: 0.3 } }); var gr = pal.ground_ramp || [pal.ground, pal.ground, pal.ground, pal.ground]; var terrain = world.terrain({ seed: seed, amplitude: 2.2, scale: 130, chunk: 32, radius: 3 }); // 배경 초원(추락 착지면 = 낙사 판정면) nature.grass_field({ area: 150, count: 18000, base: gr[1], tip: gr[3], height: 0.5, y: terrain.height_at }); nature.clouds({ count: 12, style: 'puffy' }); var player = world.player({ spawn: _obby_avatar(c), at: { x: 0, z: 0 }, speed: 4.4, run_speed: 7.2, fly: false }); // fly 기본 ON = 파쿠르 무력화 — 반드시 해제 world.camera(); world.controls({ mission: c.hint ? String(c.hint) : '' }); // ── 코스 생성(시드-결정론) — 스테이지당 도전 유형 순환 + 말미 체크포인트 ── var stages = clamp(Math.round(num(c.stages, 6)), 3, 10); var diff = pick(c.difficulty, ['easy', 'normal', 'hard'], 'normal'); var GAP = { easy: [1.6, 2.4], normal: [2.2, 3.2], hard: [2.8, 4.0] }[diff]; var prng = world._priv.mulberry32(((seed * 62819) ^ 0x5F3759DF) >>> 0); var accent = pal.accent; var PLATE = ['#f2f0ea', '#cfd8e3', '#e7d9c3']; // 플라스틱 3톤(디테일은 무드 accent 가 소유) var cx = 0, cz = 0, cy = world.height_at(0, 0) + 0.05, heading = prng() * Math.PI * 2; obby.platform({ at: { x: 0, y: cy, z: 0 }, size: { x: 4, z: 4, y: 0.5 }, color: accent }); // 시작 패드(스폰 착지면) function _step(gapMul, rise) { heading += (prng() - 0.5) * 1.1; var gap = (GAP[0] + prng() * (GAP[1] - GAP[0])) * gapMul; var half_from = 1.0, half_to = 1.0; // 모서리-간격 → 중심-간격 환산(양변 half 합산) var d = gap + half_from + half_to; cx += Math.cos(heading) * d; cz += Math.sin(heading) * d; cy += rise; return { x: cx, y: cy, z: cz }; } var TYPES = ['hop', 'move', 'blink', 'belt', 'spin']; for (var st = 0; st < stages; st++) { var type = TYPES[st % TYPES.length]; var n = 3 + Math.floor(prng() * 3); // 스테이지당 3~5 부품 for (var i = 0; i < n; i++) { // 각 브랜치가 자기 _step 을 소유(공용 선행 _step 은 belt/spin 재보정과 겹쳐 "플랫폼 없는 // 2배 갭" = 불가능 점프를 만든다 — 커서 전진은 부품 1개당 정확히 1회) if (type === 'hop') { obby.platform({ at: _step(1, 0.2 + prng() * 0.25), size: { x: 2, z: 2 }, color: PLATE[(st + i) % 3] }); } else if (type === 'move') { var at = _step(1, 0.2 + prng() * 0.25); var ext = 2.2 + prng() * 1.6; var side = prng() < 0.5 ? 1 : -1; // 진행 직교 왕복(경로 리듬) obby.platform({ at: at, size: { x: 2.2, z: 2.2 }, color: PLATE[(st + i) % 3], offset: prng() * 3, move: { to: { x: at.x + Math.cos(heading + side * Math.PI / 2) * ext, y: at.y, z: at.z + Math.sin(heading + side * Math.PI / 2) * ext }, period: 2.6 + prng() * 1.4 }, }); } else if (type === 'blink') { obby.platform({ at: _step(1, 0.2 + prng() * 0.25), size: { x: 2, z: 2 }, color: PLATE[(st + i) % 3], offset: i * 0.9, blink: { on: 1.8, off: 1.0 } }); } else if (type === 'belt') { var blen = 5 + prng() * 3; var bat = _step(0.4, 0.1); // 벨트는 갭 짧게(진입 안전) bat = { x: bat.x + Math.cos(heading) * blen / 2, y: bat.y, z: bat.z + Math.sin(heading) * blen / 2 }; obby.platform({ at: bat, size: { x: Math.abs(Math.cos(heading)) > 0.7 ? blen : 2.4, z: Math.abs(Math.cos(heading)) > 0.7 ? 2.4 : blen }, color: '#8f97a3', belt: { dx: -Math.cos(heading), dz: -Math.sin(heading), speed: 1.6 + (diff === 'hard' ? 0.9 : 0) }, // 역방향 벨트 = 달려서 거슬러 오르기 }); // 커서 = 벨트 끝 모서리 - 1.0(다음 _step 의 half_from 가정치) — 가상 중심 보정으로 갭 산식 정합 유지 cx = bat.x + Math.cos(heading) * (blen / 2 - 1.0); cz = bat.z + Math.sin(heading) * (blen / 2 - 1.0); cy = bat.y; } else { // spin — 대형 회전 디스크 + 중앙 스피너 암 var sat = _step(0.8, 0.2); obby.platform({ at: sat, size: { x: 5, z: 5 }, color: PLATE[(st + i) % 3], spin: { speed: (prng() < 0.5 ? 1 : -1) * 0.12 } }); obby.spinner({ at: { x: sat.x, y: sat.y + 0.45, z: sat.z }, r: 2.3, speed: 0.3 + (diff === 'hard' ? 0.15 : 0), arms: 1 }); i++; // 디스크는 2부품 몫(폭 5) } } // 스테이지 말미 = 휴식 플랫폼 + 체크포인트(불공정 반복 차단의 정본 리듬) var rest = _step(0.9, 0.2); obby.platform({ at: rest, size: { x: 3.4, z: 3.4, y: 0.5 }, color: accent }); obby.checkpoint({ at: { x: rest.x, y: rest.y, z: rest.z }, r: 1.3 }); } var fin = _step(0.9, 0.2); obby.platform({ at: fin, size: { x: 4, z: 4, y: 0.5 }, color: '#ffd24a' }); obby.void_ground(true); // 지형 착지 = 코스 이탈(시작 패드 포함 전 구간 플랫폼 위) // ── HUD + 완결 루프 ── var ui = _arch_hud(c.title || '점프 코스'); var stage_now = 0, deaths = 0, complete = false; var t0 = null, t_final = null; function _t_begin() { if (t0 === null) t0 = performance.now(); window.removeEventListener('pointerdown', _t_begin); window.removeEventListener('keydown', _t_begin); } window.addEventListener('pointerdown', _t_begin); window.addEventListener('keydown', _t_begin); function _hud() { ui.children[1].textContent = '🏁 ' + stage_now + ' / ' + stages + (deaths ? ' · 💥 ' + deaths : ''); } _hud(); P.loop(function () { if (t0 !== null && t_final === null) ui.children[2].textContent = '⏱ ' + _fmt_t(performance.now() - t0); }); obby.on_stage(function (idx) { stage_now = Math.max(stage_now, idx + 1); _hud(); P.toast('체크포인트! ' + stage_now + ' / ' + stages); A._emit('stage', { stage: stage_now, total: stages }); }); obby.on_death(function (n) { deaths = n; _hud(); A._emit('death', { deaths: n }); }); obby.finish({ at: { x: fin.x, y: fin.y, z: fin.z }, r: 1.8, on_finish: function () { if (complete) return; complete = true; if (t0 !== null) t_final = performance.now() - t0; P.music('triumph'); fx.screen_flash(pal.key); fx.burst({ x: fin.x, y: fin.y + 2.2, z: fin.z }, { count: 40, color: accent, speed: 5, life: 1.4, gravity: -0.4 }); var _rec = t_final != null ? _fmt_t(t_final) : null; P.toast(String(c.complete_text || '코스 완주! 🏁').slice(0, 60)); A._emit('complete', { deaths: deaths, time_ms: t_final }); setTimeout(function () { P.three.capture().then(function (shot) { P.share_shot(shot, { title: String(c.title || '점프 코스').slice(0, 40), lines: [(_rec ? '⏱ ' + _rec + ' · ' : '') + '💥 ' + deaths + '번 넘어지고 완주'], share_text: String(c.share_text || '점프 코스를 완주했어요 🏁').slice(0, 120) + (_rec ? ' ⏱ ' + _rec : ''), }); }); }, 900); }, }); P.mount_share(function () { return String(c.share_text || (complete ? '점프 코스를 완주했어요 🏁' : '점프 코스에 도전 중 — ' + stage_now + '/' + stages + ' 🏃')).slice(0, 120); }, String(c.share_label || '공유').slice(0, 12)); A._installed = 'obby_course'; return { player: player, get stage() { return stage_now; }, get deaths() { return deaths; }, stages: stages }; }; // ═══ 아키타입 #4: tycoon_loop — 드로퍼→컨베이어→정산→구매 사다리(완비·골든-테스트 대상) ═══ // 타이쿤 최소 완전체 정본: 드롭 2s 간격 → 벨트 컨베이어 → 네온 용광로 자동 정산 → 빌보드 // 구매 패드(비용 ×1.8 계단, 15~40s 간격 페이싱) → 피날레 구매 = 승리. _ARCH.tycoon_loop = function (cfg) { var c = cfg || {}; if (!P.obby) { warn('obby family missing — falling back to meadow_explorer'); return _ARCH.meadow_explorer(cfg); } var mood_req = c.mood === 'night' ? 'night_neon' : c.mood; if (mood_req === 'night_neon' || mood_req === 'ember' || mood_req === 'dusk') { warn('dark mood "' + mood_req + '" demoted to sunset — archetype starts in daylight'); mood_req = 'sunset'; } var mood_name = pick(mood_req, P.moods || [], 'noon'); var pal = P.mood(mood_name); P.music(pick(c.music, ['calm_exploration', 'adventure', 'cozy', 'mystery', 'night', 'chiptune_action', 'battle', 'triumph', 'space', 'spooky', 'lofi', 'sad', 'happy'], 'lofi')); P.ambience(pick(c.ambience, ['forest', 'wind', 'rain', 'night', 'ocean', 'cave', 'crowd'], 'wind')); P.enable_bloom({ strength: 0.55, radius: 0.5, threshold: 1.05 }); var world = P.world, obby = P.obby, fx = P.fx, nature = P.nature; var seed = (num(c.seed, 5) | 0); var gr = pal.ground_ramp || [pal.ground, pal.ground, pal.ground, pal.ground]; var terrain = world.terrain({ seed: seed, amplitude: 1.6, scale: 150, chunk: 32, radius: 3 }); // 완만 평지(공장 부지) nature.grass_field({ area: 150, count: 16000, base: gr[1], tip: gr[3], height: 0.45, y: terrain.height_at }); nature.clouds({ count: 10, style: 'puffy' }); var player = world.player({ spawn: _obby_avatar(c), at: { x: 0, z: 9 }, speed: 4.4, run_speed: 7.2, fly: false }); // z:9 = 마지막 패드(0,6)와 스폰 중첩 해소·비행 = 패드 사다리 우회라 차단 world.camera(); world.controls({ mission: c.hint ? String(c.hint) : '' }); world.minimap(); // 패드 = world.marker 등록이라 지도에 자동 표기 // ── 공장 부지(평탄 슬래브 위 — 지형 요철 흡수) ── var by = world.height_at(0, 0) + 0.3; obby.platform({ at: { x: 0, y: by, z: -4 }, size: { x: 22, z: 16, y: 0.6 }, color: '#d8d5cd' }); // 드로퍼 타워(좌) → 벨트(중) → 용광로(우) var BELT_LEN = 9, BELT_Y = by + 0.5; obby.platform({ at: { x: 0, y: BELT_Y, z: -6 }, size: { x: BELT_LEN, z: 1.6, y: 0.35 }, color: '#8f97a3', belt: { dx: 1, dz: 0, speed: 2.2 } }); var tower = new THREE_.Group(); var tmat = P.make.matte('#b9c0ca'); var tp = new THREE_.Mesh(new THREE_.BoxGeometry(1.4, 3.2, 1.4), tmat); tp.position.y = 1.6; tower.add(tp); var spout = new THREE_.Mesh(new THREE_.BoxGeometry(0.9, 0.5, 0.9), P.make.matte('#7d8590')); spout.position.set(0.8, 2.4, 0); tower.add(spout); tower.position.set(-BELT_LEN / 2 - 0.8, by, -6); scene.add(tower); var fmat = P.make.matte('#3a2f3f', { roughness: 0.35 }); fmat.emissive = new THREE_.Color(pal.accent); fmat.emissiveIntensity = 0.8; // 네온 용광로(정산 지점의 시각 앵커) var furnace = new THREE_.Mesh(new THREE_.BoxGeometry(2, 2.4, 2), fmat); furnace.position.set(BELT_LEN / 2 + 1.2, by + 1.2, -6); scene.add(furnace); // ── 경제(시뮬레이션은 아키타입 소유 — obby 부품은 무대만) ── var label = String(c.currency_label || '코인').slice(0, 8); var cash = 0, earned = 0, complete = false; var drop_iv = 2.0, drop_val = clamp(Math.round(num(c.drop_value, 2)), 1, 50); // 정본: 2s 간격 시작 var _drops = [], _drop_t = 0; var _cube_geo = new THREE_.BoxGeometry(0.55, 0.55, 0.55); var TIER_COLORS = ['#e8a13a', '#3ddc84', '#4aa8ff', '#c26bff', '#ffd24a']; // 구매 단계별 광물 티어 var TIER_MATS = TIER_COLORS.map(function (cl) { return P.make.matte(cl, { roughness: 0.35 }); }); // 재질 5종 선생성 — 드롭마다 신규 할당(세션 내내 누수) 봉합 var tier = 0; P.loop(function (dt) { dt = Math.min(num(dt, 0.016), 0.1); if (complete) return; _drop_t += dt; if (_drop_t >= drop_iv) { _drop_t = 0; var cube = new THREE_.Mesh(_cube_geo, TIER_MATS[Math.min(tier, TIER_COLORS.length - 1)]); cube.position.set(-BELT_LEN / 2 + 0.3, BELT_Y + 0.35, -6); scene.add(cube); _drops.push(cube); } for (var i = _drops.length - 1; i >= 0; i--) { var d = _drops[i]; d.position.x += 2.2 * dt; // 벨트와 동일 상수(시각-물리 정합) d.rotation.z -= 2.4 * dt; if (d.position.x >= BELT_LEN / 2 + 0.5) { // 용광로 정산 scene.remove(d); _drops.splice(i, 1); cash += drop_val; earned += drop_val; _hud(); fx.float_text({ x: furnace.position.x, y: furnace.position.y + 1.6, z: furnace.position.z }, '+' + drop_val, { color: pal.accent }); fx.sfx('pickup', { volume: 0.4 }); A._emit('earn', { cash: cash, earned: earned }); } } }); // ── 구매 패드 사다리(빌보드 = world.marker) — 밟기 + 잔액 충족 = 즉시 구매 ── var defs = Array.isArray(c.pads) ? c.pads.slice(0, 12) : null; if (!defs || !defs.length) defs = [{ label: '더 빠른 드로퍼' }, { label: '광물 가치 ↑' }, { label: '2배 드로퍼' }, { label: '고급 광물' }, { label: '초고속 벨트' }, { label: '황금 광물' }, { label: '전설의 조각상' }]; var base_cost = clamp(Math.round(num(c.base_cost, 20)), 5, 500); var pads = defs.map(function (p, i) { var cost = Math.round(base_cost * Math.pow(1.8, i)); // 정본 계단(×1.5~2.5 대역 중앙) var ang = -Math.PI / 2 + (i / Math.max(1, defs.length - 1)) * Math.PI; // 부지 앞 반원 배치 var px = Math.cos(ang) * 8, pz = 2 + Math.sin(ang) * 4; var g = new THREE_.Group(); var pmat = P.make.matte('#3ddc84', { roughness: 0.5 }); pmat.transparent = true; pmat.opacity = 0.92; var padm = new THREE_.Mesh(new THREE_.CylinderGeometry(1.1, 1.1, 0.18, 18), pmat); padm.position.y = 0.09; g.add(padm); g.position.set(px, world.height_at(px, pz) + 0.32, pz); scene.add(g); var lbl = String((p && p.label) || '업그레이드 ' + (i + 1)).slice(0, 20); return { g: g, mat: pmat, x: px, z: pz, cost: cost, label: lbl, bought: false, idx: i, mk: world.marker(g, { label: lbl + ' — ' + cost + ' ' + label }) }; }); function _buy(pd) { pd.bought = true; cash -= pd.cost; tier = Math.min(tier + 1, TIER_COLORS.length - 1); // 효과 사다리(유일-결정 순환): 짝수 구매 = 드롭 간격 ×0.72, 홀수 = 가치 ×1.7 if (pd.idx % 2 === 0) drop_iv = Math.max(0.5, drop_iv * 0.72); else drop_val = Math.round(drop_val * 1.7); pd.mk.remove(); fx.despawn(pd.g, { dur: 0.4 }); fx.sfx('powerup'); fx.burst({ x: pd.x, y: world.height_at(pd.x, pd.z) + 1.2, z: pd.z }, { count: 22, color: pal.accent, speed: 3.5, life: 1.1, gravity: -0.2 }); P.toast(pd.label + ' 구매!'); _hud(); A._emit('buy', { label: pd.label, index: pd.idx, cash: cash }); if (pd.idx === pads.length - 1) _win(); } P.loop(function () { if (complete) return; for (var i = 0; i < pads.length; i++) { var pd = pads[i]; if (pd.bought) continue; if (i > 0 && !pads[i - 1].bought) break; // 순차 해금(계단의 페이싱 보존) var dx = player.pos.x - pd.x, dz = player.pos.z - pd.z; if (dx * dx + dz * dz < 1.7 && cash >= pd.cost) _buy(pd); pd.mat.opacity = cash >= pd.cost ? 0.95 : 0.4; // 구매 가능 시각 신호 break; // 활성 패드는 항상 1개(최전선) } }); // ── HUD + 완결 ── var ui = _arch_hud(c.title || '나만의 공장'); var t0 = null, t_final = null; function _t_begin() { if (t0 === null) t0 = performance.now(); window.removeEventListener('pointerdown', _t_begin); window.removeEventListener('keydown', _t_begin); } window.addEventListener('pointerdown', _t_begin); window.addEventListener('keydown', _t_begin); function _hud() { ui.children[1].textContent = '💰 ' + cash + ' ' + label; } _hud(); P.loop(function () { if (t0 !== null && t_final === null) ui.children[2].textContent = '⏱ ' + _fmt_t(performance.now() - t0); }); function _win() { if (complete) return; complete = true; if (t0 !== null) t_final = performance.now() - t0; // 피날레 조형(황금 조각상) — 승리의 시각 앵커 var smat = P.make.matte('#ffd24a', { roughness: 0.3 }); smat.emissive = new THREE_.Color('#b8860b'); smat.emissiveIntensity = 0.5; var statue = new THREE_.Group(); var sb = new THREE_.Mesh(new THREE_.CylinderGeometry(1.2, 1.5, 0.7, 16), smat); sb.position.y = 0.35; statue.add(sb); var ss = new THREE_.Mesh(new THREE_.ConeGeometry(0.8, 2.4, 12), smat); ss.position.y = 1.9; statue.add(ss); statue.position.set(0, world.height_at(0, -1) + 0.6, -1); scene.add(statue); fx.spawn(statue); P.music('triumph'); fx.screen_flash(pal.key); fx.burst({ x: 0, y: world.height_at(0, -1) + 3, z: -1 }, { count: 44, color: '#ffd24a', speed: 5, life: 1.5, gravity: -0.4 }); var _rec = t_final != null ? _fmt_t(t_final) : null; P.toast(String(c.complete_text || '공장 완성! 🏭').slice(0, 60)); A._emit('complete', { earned: earned, time_ms: t_final }); setTimeout(function () { P.three.capture().then(function (shot) { P.share_shot(shot, { title: String(c.title || '나만의 공장').slice(0, 40), lines: [(_rec ? '⏱ ' + _rec + ' · ' : '') + '💰 총 ' + earned + ' ' + label + ' 생산'], share_text: String(c.share_text || '나만의 공장을 완성했어요 🏭 총 ' + earned + ' ' + label).slice(0, 120), }); }); }, 900); } P.mount_share(function () { return String(c.share_text || (complete ? '나만의 공장을 완성했어요 🏭' : '공장 확장 중 — 💰 ' + cash + ' ' + label)).slice(0, 120); }, String(c.share_label || '공유').slice(0, 12)); A._installed = 'tycoon_loop'; return { player: player, get cash() { return cash; }, get earned() { return earned; }, pads: pads.length }; }; // ── Shared management-camera rig (service_tycoon + plot_tycoon — file-local single implementation, // two consumers). Root-cause fix of the 2026-07-20 field report ("camera frozen — no pan/zoom; // the lot reads as an unimplemented void on portrait phones"): in the management-sim genre the // camera IS an interaction surface, so a fixed viewpoint was a genre-grammar violation, not a // tuning issue. The rig keeps the fixed isometric ANGLE (genre identity) and owns: 1-finger // drag-pan (bounded to the stage), pinch zoom + desktop wheel zoom, aspect-aware auto-framing // (portrait phones start tighter instead of showing a barren wide lot), smooth damping, and // tap-vs-drag discrimination (short still tap → on_tap for tile-select games; anything else = // camera gesture). UI elements opt out via
or [data-mgmt-ui]. ── function _mgmt_camera(o) { var cam = P.three.camera; var D = { x: o.dir.x, y: o.dir.y, z: o.dir.z }; var dl = Math.sqrt(D.x * D.x + D.y * D.y + D.z * D.z) || 1; D.x /= dl; D.y /= dl; D.z /= dl; var FOV2 = Math.tan(((cam && cam.fov) || 60) * Math.PI / 360); function fit_dist() { // portrait bias: narrow aspect = pull back up to +40% so the stage width still reads var aspect = Math.max(0.35, (window.innerWidth || 390) / Math.max(1, window.innerHeight || 700)); return clamp(o.base_dist * clamp(1.5 / aspect, 1, 1.4), 14, 90); } var base_fit = fit_dist(); var MINZ = 0.5, MAXZ = 1.75; var st = { tx: o.center.x, tz: o.center.z, dist: base_fit, yaw: 0, gx: o.center.x, gz: o.center.z, gdist: base_fit, gyaw: 0 }; // Fog rescale (§5.6 scale contract): the mood default fog (far≈95, small-scene basis) sits INSIDE // this rig's viewing distances — without rescaling, most of the stage lives in the fog band and the // world reads as a white haze (2026-07-20 field report #2). One sanctioned path: P.three.fog_scale. if (P.three.fog_scale) { var _far = (base_fit * MAXZ + Math.max(o.extent_x, o.extent_z)) * 1.25; P.three.fog_scale({ near: _far * 0.45, far: _far }); } if (P.on_resize) P.on_resize(function () { var f = fit_dist(), r = st.gdist / base_fit; base_fit = f; st.gdist = clamp(f * r, f * MINZ, f * MAXZ); }); // yaw-rotated camera offset + ground-plane pan basis (recomputed per use — yaw is a live axis) function _basis() { var cy = Math.cos(st.yaw), sy = Math.sin(st.yaw); var bx = D.x * cy + D.z * sy, bz = -D.x * sy + D.z * cy; // horizontal offset dir rotated about Y var fx = -bx, fz = -bz; var fl = Math.sqrt(fx * fx + fz * fz) || 1; fx /= fl; fz /= fl; return { bx: bx, bz: bz, fx: fx, fz: fz, rx: -fz, rz: fx }; } var pts = new Map(), moved = 0, tap_t0 = 0, pinch0 = 0, pinch_dist0 = 0, pinch_ang0 = 0, pinch_yaw0 = 0, rot_drag = false; function _ui_hit(ev) { var t = ev.target; return t && t.closest && t.closest('button,[data-mgmt-ui]'); } window.addEventListener('pointerdown', function (ev) { if (_ui_hit(ev)) return; if (ev.button === 2) { rot_drag = true; return; } // desktop: right-drag = rotate pts.set(ev.pointerId, { x: ev.clientX, y: ev.clientY }); if (pts.size === 1) { moved = 0; tap_t0 = performance.now(); } if (pts.size === 2) { var a = []; pts.forEach(function (p) { a.push(p); }); pinch0 = Math.hypot(a[0].x - a[1].x, a[0].y - a[1].y); pinch_dist0 = st.gdist; pinch_ang0 = Math.atan2(a[1].y - a[0].y, a[1].x - a[0].x); // two-finger twist = rotate pinch_yaw0 = st.gyaw; } }); window.addEventListener('contextmenu', function (ev) { if (!_ui_hit(ev)) ev.preventDefault(); }); window.addEventListener('pointermove', function (ev) { if (rot_drag) { st.gyaw += ev.movementX != null ? ev.movementX * 0.006 : 0; return; } var p = pts.get(ev.pointerId); if (!p) return; var dx = ev.clientX - p.x, dy = ev.clientY - p.y; p.x = ev.clientX; p.y = ev.clientY; if (pts.size >= 2) { var a = []; pts.forEach(function (q) { a.push(q); }); var d = Math.hypot(a[0].x - a[1].x, a[0].y - a[1].y); if (pinch0 > 0) { st.gdist = clamp(pinch_dist0 * pinch0 / Math.max(24, d), base_fit * MINZ, base_fit * MAXZ); st.gyaw = pinch_yaw0 + (Math.atan2(a[1].y - a[0].y, a[1].x - a[0].x) - pinch_ang0); // twist rotate } moved += 99; // a pinch is never a tap return; } moved += Math.abs(dx) + Math.abs(dy); var b = _basis(); var wpp = (2 * FOV2 * st.gdist) / Math.max(1, window.innerHeight || 700); // world units per screen px at the target plane st.gx += (-dx * b.rx + dy * b.fx) * wpp; st.gz += (-dx * b.rz + dy * b.fz) * wpp; st.gx = clamp(st.gx, o.center.x - o.extent_x * 0.65, o.center.x + o.extent_x * 0.65); st.gz = clamp(st.gz, o.center.z - o.extent_z * 0.75, o.center.z + o.extent_z * 0.75); }); function _end(ev) { if (ev.button === 2) { rot_drag = false; return; } if (!pts.has(ev.pointerId)) return; pts.delete(ev.pointerId); if (pts.size < 2) pinch0 = 0; if (pts.size === 0 && moved < 9 && performance.now() - tap_t0 < 350 && o.on_tap && !_ui_hit(ev)) o.on_tap(ev); } window.addEventListener('pointerup', _end); window.addEventListener('pointercancel', _end); window.addEventListener('wheel', function (ev) { st.gdist = clamp(st.gdist * (1 + ev.deltaY * 0.0012), base_fit * MINZ, base_fit * MAXZ); }, { passive: true }); P.loop(function (dt) { if (dt <= 0) return; var k = Math.min(1, dt * 9); st.tx += (st.gx - st.tx) * k; st.tz += (st.gz - st.tz) * k; st.dist += (st.gdist - st.dist) * k; st.yaw += (st.gyaw - st.yaw) * k; var b = _basis(); cam.position.set(st.tx + b.bx * st.dist, o.y + D.y * st.dist, st.tz + b.bz * st.dist); cam.lookAt(st.tx, o.y + (o.look_lift != null ? o.look_lift : 0.5), st.tz); }); return { state: st, set_center: function (x, z) { st.gx = x; st.gz = z; } }; } // ── Shared lot-floor differentiation (surface canon §5.5 applied to management stages): a monolithic // slab renders as a flat untextured void ("the ground looks unimplemented" — field report). One // canon: per-tile tone-jittered thin tiles over a darker base (the 4cm seams read as expansion // joints for free) + a few soft stain patches. ── function _mgmt_floor(prng, x0, x1, z0, z1, y, tile, tones) { var g = new THREE_.Group(); var mats = tones.map(function (c) { return P.make.matte(c, { roughness: 0.93 }); }); for (var tx = x0; tx < x1 - 0.01; tx += tile) { for (var tz = z0; tz < z1 - 0.01; tz += tile) { var w = Math.min(tile, x1 - tx), d = Math.min(tile, z1 - tz); var m = new THREE_.Mesh(new THREE_.BoxGeometry(w - 0.08, 0.06, d - 0.08), mats[Math.floor(prng() * mats.length)]); m.position.set(tx + w / 2, y + 0.03, tz + d / 2); g.add(m); } } var stain = new THREE_.MeshBasicMaterial({ color: '#8f8b82', transparent: true, opacity: 0.16, depthWrite: false }); for (var si = 0; si < 5; si++) { var sm = new THREE_.Mesh(new THREE_.CircleGeometry(0.7 + prng() * 1.3, 12), stain); sm.rotation.x = -Math.PI / 2; sm.position.set(x0 + prng() * (x1 - x0), y + 0.075, z0 + prng() * (z1 - z0)); g.add(sm); } scene.add(g); return g; } // ── Shared management-stage scenery ring (field report #2: "no scenery in the distance — the world // feels empty"): rejection-sampled trees/bushes/rocks over an area-uniform annulus, avoiding the // stage rects (scatter has no exclusion zones — the manual-ring canon). Pairs with the rig's fog // rescale: the pushed-out fog makes this mid/far ring actually visible. ── function _mgmt_scenery(prng, gr, avoid, r0, r1, n_tree, n_bush, n_rock) { var _extm = (typeof window !== 'undefined' && window['__PLAYABLE_'+'EXTRA_MANIFEST__'] && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].models) || {}; var kit = Object.keys(_extm).filter(function (n) { var t = _extm[n].tags || []; return (t.indexOf('tree') >= 0 || t.indexOf('nature') >= 0) && num(_extm[n].h, 0) >= 2.2 && !/flower|bush|grass|rock/.test(n); }); function blocked(x, z) { for (var i = 0; i < avoid.length; i++) { var a = avoid[i]; if (x > a.x0 && x < a.x1 && z > a.z0 && z < a.z1) return true; } return false; } function pos() { for (var tries = 0; tries < 12; tries++) { var a = prng() * Math.PI * 2; var r = Math.sqrt(r0 * r0 + prng() * (r1 * r1 - r0 * r0)); // area-uniform annulus (no center clumping) var x = Math.cos(a) * r, z = Math.sin(a) * r; if (!blocked(x, z)) return { x: x, z: z }; } return null; } var trunk = P.make.matte(gr[0]), crown = P.make.matte(gr[2]), crown2 = P.make.matte(gr[3]); var rockm = P.make.matte('#9aa2ab', { roughness: 0.95 }); for (var ti = 0; ti < n_tree; ti++) { var p = pos(); if (!p) continue; if (kit.length) { P.world.spawn(kit[ti % kit.length], { at: p, collide: false }); continue; } var tg = new THREE_.Group(); var s = 0.8 + prng() * 0.9; var tt = new THREE_.Mesh(new THREE_.CylinderGeometry(0.2 * s, 0.28 * s, 1.5 * s, 7), trunk); tt.position.y = 0.75 * s; tg.add(tt); var tc = new THREE_.Mesh(new THREE_.SphereGeometry(1.15 * s, 10, 8), prng() < 0.5 ? crown : crown2); tc.position.y = 2.2 * s; tg.add(tc); tg.position.set(p.x, P.world.height_at(p.x, p.z), p.z); scene.add(tg); } for (var bi = 0; bi < n_bush; bi++) { var pb = pos(); if (!pb) continue; var bsh = new THREE_.Mesh(new THREE_.SphereGeometry(0.45 + prng() * 0.5, 9, 7), prng() < 0.5 ? crown : crown2); bsh.position.set(pb.x, P.world.height_at(pb.x, pb.z) + 0.3, pb.z); bsh.scale.y = 0.72; scene.add(bsh); } for (var ri = 0; ri < n_rock; ri++) { var pr = pos(); if (!pr) continue; var rk = new THREE_.Mesh(new THREE_.DodecahedronGeometry(0.3 + prng() * 0.45, 0), rockm); rk.position.set(pr.x, P.world.height_at(pr.x, pr.z) + 0.2, pr.z); rk.scale.y = 0.7; scene.add(rk); } } // ═══ Archetype #4b: service_tycoon — roadside service-station management sim (complete · golden-tested) ═══ // Identity: a mobile-tycoon-grammar MANAGEMENT sim (fixed 3/4 high-angle camera, NO avatar): autonomous // customer cars pull off the road into the lot, park at a free pump, fill up (live liters·revenue counter // above the car via marker.set_label), pay and drive off. The player manages: per-fuel stock, restock // orders (a delivery truck really drives in), reputation stars, daily quests on a day cycle, a periodic // customer-request card (choose the right fuel), and an upgrade ladder whose FINAL purchase (the grand // canopy) is the victory. Camera = fixed high-angle rig owned per-frame by this archetype (scenic observe // precedent — world.camera's follow rig needs an avatar; the |z|<0.8 degenerate band is avoided by design). // All station structures are shell-owned procedural meshes (catalog-independent — lane turret precedent); // vehicles prefer the generator roster (.service: cars/truck/building GLBs) // with primitive low-poly fallbacks (works with zero assets — smoke/offline invariant). _ARCH.service_tycoon = function (cfg) { var c = cfg || {}; // ── Tier B config validation (defaults·clamps·enums — a working game under ANY input) ── var mood_req = c.mood === 'night' ? 'night_neon' : c.mood; if (mood_req === 'night_neon' || mood_req === 'ember' || mood_req === 'dusk') { warn('dark mood "' + mood_req + '" demoted to sunset — archetype starts in daylight'); mood_req = 'sunset'; } var mood_name = pick(mood_req, P.moods || [], 'noon'); if (mood_req && mood_name !== mood_req) warn('unknown mood "' + mood_req + '" → ' + mood_name); var pal = P.mood(mood_name); P.music(pick(c.music, ['calm_exploration', 'adventure', 'cozy', 'mystery', 'night', 'chiptune_action', 'battle', 'triumph', 'space', 'spooky', 'lofi', 'sad', 'happy'], 'lofi')); P.ambience(pick(c.ambience, ['forest', 'wind', 'rain', 'night', 'ocean', 'cave', 'crowd'], 'wind')); P.enable_bloom({ strength: 0.5, radius: 0.5, threshold: 1.05 }); var world = P.world, nature = P.nature, fx = P.fx; var seed = (num(c.seed, 23) | 0); var prng = null; // created after world.terrain (same PRNG canon as the other archetypes) var gr = pal.ground_ramp || [pal.ground, pal.ground, pal.ground, pal.ground]; var terrain = world.terrain({ seed: seed, amplitude: 1, scale: 240, chunk: 32, radius: 3 }); // near-flat basis prng = world._priv.mulberry32(((seed * 52711) ^ 0x9E3779B9) >>> 0); nature.wind({ strength: 0.4, gustiness: 0.4, direction: { x: 0.8, z: 0.3 } }); nature.grass_field({ area: 150, count: 14000, base: gr[1], tip: gr[3], height: 0.4, y: terrain.height_at }); // 0.4 < pad lift 0.55 — blades never pierce the slabs nature.clouds({ count: 10, style: 'puffy' }); // ── Fixed layout frame (road south, lot flush north of it) — pad top absorbs terrain bumps ── var ROAD_Z = -14, ROAD_HW = 3.6, ROAD_X = 92; var LOT = { x0: -17, x1: 17, z0: ROAD_Z + ROAD_HW, z1: 14 }; var hmax = -Infinity; for (var hx = -ROAD_X; hx <= ROAD_X; hx += 12) { for (var hz = ROAD_Z - ROAD_HW - 2; hz <= LOT.z1 + 2; hz += 5) { var hh = world.height_at(hx, hz); if (hh > hmax) hmax = hh; } } var Y = hmax + 0.55; // shared drive surface (road + lot are flush) var asphalt = P.make.matte('#3f444c', { roughness: 0.95 }); var concrete = P.make.matte('#d8d5cd', { roughness: 0.92 }); var white = P.make.matte('#eef0f2', { roughness: 0.7 }); var dark = P.make.matte('#2e333b', { roughness: 0.85 }); var red = P.make.matte('#c4382d', { roughness: 0.6 }); var slab = function (mat, x0, x1, z0, z1, top, th) { var m = new THREE_.Mesh(new THREE_.BoxGeometry(x1 - x0, th || 0.8, z1 - z0), mat); m.position.set((x0 + x1) / 2, (top || Y) - (th || 0.8) / 2, (z0 + z1) / 2); scene.add(m); return m; }; slab(asphalt, -ROAD_X, ROAD_X, ROAD_Z - ROAD_HW, ROAD_Z + ROAD_HW, Y, 2.4); // road (thick — no floating edge over terrain dips) slab(P.make.matte('#c6c2b9', { roughness: 0.94 }), LOT.x0, LOT.x1, LOT.z0, LOT.z1, Y, 2.4); // lot base (darker — tile seams read as joints) _mgmt_floor(prng, LOT.x0, LOT.x1, LOT.z0, LOT.z1, Y, 4, ['#d6d2c9', '#dcd8d0', '#e2dfd7']); // §5.5 surface differentiation for (var dx = -ROAD_X + 4; dx < ROAD_X - 2; dx += 7) slab(white, dx, dx + 3, ROAD_Z - 0.14, ROAD_Z + 0.14, Y + 0.012, 0.05); // center dashes slab(white, -ROAD_X, ROAD_X, ROAD_Z - ROAD_HW + 0.15, ROAD_Z - ROAD_HW + 0.4, Y + 0.012, 0.05); // shoulder lines slab(white, -ROAD_X, LOT.x0, ROAD_Z + ROAD_HW - 0.4, ROAD_Z + ROAD_HW - 0.15, Y + 0.012, 0.05); slab(white, LOT.x1, ROAD_X, ROAD_Z + ROAD_HW - 0.4, ROAD_Z + ROAD_HW - 0.15, Y + 0.012, 0.05); // ── Shell-owned station meshes (procedural — catalog-independent) ── var _extm = (typeof window !== 'undefined' && window['__PLAYABLE_'+'EXTRA_MANIFEST__'] && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].models) || {}; var _svc = (typeof window !== 'undefined' && window['__PLAYABLE_'+'EXTRA_MANIFEST__'] && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].service) || null; function box(g, mat, w, h, d, x, y, z) { var b = new THREE_.Mesh(new THREE_.BoxGeometry(w, h, d), mat); b.position.set(x, y, z); g.add(b); return b; } var screen_glow = P.make.glow('#bfe3ff', 1.1); function _mk_pump() { var g = new THREE_.Group(); box(g, concrete, 2.6, 0.22, 1.2, 0, 0.11, 0); // island base box(g, red, 0.62, 1.14, 0.44, 0, 0.79, 0); // dispenser body box(g, dark, 0.62, 0.16, 0.44, 0, 1.42, 0); // cap box(g, screen_glow, 0.4, 0.3, 0.05, 0, 1.05, 0.23); // screen box(g, dark, 0.1, 0.34, 0.12, 0.38, 0.9, 0); // nozzle holster box(g, P.make.matte('#e9b41f'), 0.16, 0.5, 0.16, -1.05, 0.47, 0); // guard bollard return g; } function _mk_tank() { var g = new THREE_.Group(); var sph = P.make.matte('#eceff2', { roughness: 0.55 }); [-1.15, 1.15].forEach(function (ox) { var s = new THREE_.Mesh(new THREE_.SphereGeometry(0.95, 14, 10), sph); s.position.set(ox, 1.5, 0); g.add(s); for (var li = 0; li < 4; li++) { var la = li * Math.PI / 2 + Math.PI / 4; var leg = new THREE_.Mesh(new THREE_.CylinderGeometry(0.06, 0.06, 1.1, 6), dark); leg.position.set(ox + Math.cos(la) * 0.55, 0.55, Math.sin(la) * 0.55); g.add(leg); } box(g, dark, 0.12, 0.3, 0.12, ox, 2.55, 0); // top valve }); return g; } function _mk_sign(fuel_defs) { var g = new THREE_.Group(); box(g, dark, 0.9, 3.4, 0.5, 0, 1.7, 0); // monolith pole box(g, white, 2.0, 1.5, 0.24, 0, 3.9, 0); // panel var STRIP = ['#3ddc84', '#e8a13a', '#4aa8ff']; for (var si = 0; si < Math.min(3, fuel_defs.length); si++) { box(g, P.make.matte(STRIP[si]), 1.6, 0.26, 0.06, 0, 4.36 - si * 0.42, 0.14); } var top = new THREE_.Mesh(new THREE_.BoxGeometry(2.0, 0.18, 0.26), P.make.glow(pal.accent, 1.15)); top.position.y = 4.75; g.add(top); g._pulses = [fx.pulse(top, { amount: 0.08, rate: 1.1 })]; return g; } function _mk_lamp() { var g = new THREE_.Group(); var pole = new THREE_.Mesh(new THREE_.CylinderGeometry(0.07, 0.1, 4.4, 8), P.make.matte('#9aa2ab')); pole.position.y = 2.2; g.add(pole); box(g, P.make.matte('#9aa2ab'), 1.4, 0.08, 0.08, 0.65, 4.35, 0); var head = new THREE_.Mesh(new THREE_.BoxGeometry(0.5, 0.12, 0.22), P.make.glow('#ffe6b0', 1.2)); head.position.set(1.25, 4.3, 0); g.add(head); return g; } function _mk_building() { var g = new THREE_.Group(); box(g, dark, 6.4, 2.3, 5.2, 0, 1.15, 0); // ground floor (shop) box(g, P.make.matte('#e2e6ea', { roughness: 0.85 }), 6.7, 2.0, 5.5, 0, 3.3, 0); // floor 2 box(g, P.make.matte('#d7dce1', { roughness: 0.85 }), 6.7, 2.0, 5.5, 0, 5.3, 0); // floor 3 box(g, dark, 6.9, 0.4, 5.7, 0, 6.5, 0); // parapet var win_glass = P.make.matte('#a9c9e8', { roughness: 0.35 }); // non-emissive — glow windows bloomed into headlight blobs (visual check) box(g, win_glass, 1.1, 1.3, 0.06, -1.4, 1.0, -2.63); // shop door glass (faces the pumps, -z) box(g, red, 2.6, 0.16, 0.9, -1.4, 1.95, -3.0); // awning for (var wf = 0; wf < 2; wf++) for (var wi = -2; wi <= 2; wi += 2) { box(g, win_glass, 0.8, 0.7, 0.06, wi, 3.25 + wf * 2, -2.79); } return g; } function _mk_canopy(x0, x1, z) { var g = new THREE_.Group(); [x0, x1].forEach(function (px) { var p = new THREE_.Mesh(new THREE_.CylinderGeometry(0.14, 0.16, 4.6, 8), P.make.matte('#c9ced4')); p.position.set(px, 2.3, z); g.add(p); }); box(g, white, x1 - x0 + 6, 0.3, 5.4, (x0 + x1) / 2, 4.75, z); box(g, red, x1 - x0 + 6.04, 0.34, 0.5, (x0 + x1) / 2, 4.75, z + 2.5); box(g, red, x1 - x0 + 6.04, 0.34, 0.5, (x0 + x1) / 2, 4.75, z - 2.5); return g; } // ── i18n fuel/upgrade defaults ── var cur = String(c.currency_label || _at_t('₩', '¥', '$')).slice(0, 8); var FUEL_DEF = [ { label: _at_t('휘발유', 'レギュラー', 'Petrol'), price: 8, stock: 120 }, { label: _at_t('경유', '軽油', 'Diesel'), price: 7, stock: 100 }, { label: 'LPG', price: 5, stock: 90 }, ]; var fuels = (Array.isArray(c.fuels) ? c.fuels.slice(0, 3) : FUEL_DEF).map(function (f, i) { var d = FUEL_DEF[i] || FUEL_DEF[0]; var st = clamp(Math.round(num(f && f.stock, d.stock)), 0, 999); return { label: String((f && f.label) || d.label).slice(0, 10), price: clamp(Math.round(num(f && f.price, d.price)), 1, 999), stock: st, cap: Math.max(st, 200), }; }); while (fuels.length < 2) fuels.push({ label: FUEL_DEF[fuels.length].label, price: FUEL_DEF[fuels.length].price, stock: 100, cap: 200 }); // ── Placement ── var PUMP_X = [-9, -3, 3, 9], PUMP_Z = -1.5, PARK_Z = -3.4; var pumps = PUMP_X.map(function (px, i) { var g = _mk_pump(); g.scale.setScalar(1.25); // read clearly at the management-camera distance g.position.set(px, Y, PUMP_Z); g.visible = i < 2; // 2 pumps open at start — the ladder reveals the rest scene.add(g); return { g: g, x: px, open: i < 2, busy: false }; }); var bld = null; if (_svc && _svc.building && _extm[_svc.building]) bld = world.spawn(_svc.building, { at: { x: 12.5, z: 9.5 }, h: 7, collide: false }); if (!bld) { var bg = _mk_building(); bg.position.set(12.5, Y, 9.5); scene.add(bg); } else bld.obj.position.y = Y; var tank = _mk_tank(); tank.position.set(-13, Y, 9); scene.add(tank); var sign = _mk_sign(fuels); sign.position.set(-15.2, Y, -7.8); sign.rotation.y = 2.1; scene.add(sign); // panel faces the management camera // Lot dressing — bollards at the entrance, a trash bin by the shop, hedge bushes on the north edge var yellowm = P.make.matte('#e9b41f'); [[-13.5, LOT.z0 + 0.7], [13.5, LOT.z0 + 0.7]].forEach(function (bp) { var bl = new THREE_.Mesh(new THREE_.CylinderGeometry(0.12, 0.14, 0.8, 8), yellowm); bl.position.set(bp[0], Y + 0.4, bp[1]); scene.add(bl); }); var bin = new THREE_.Mesh(new THREE_.CylinderGeometry(0.3, 0.26, 0.7, 10), P.make.matte('#4c8b45')); bin.position.set(8.6, Y + 0.35, 7.5); scene.add(bin); for (var bi2 = 0; bi2 < 5; bi2++) { var bsh = new THREE_.Mesh(new THREE_.SphereGeometry(0.55 + (bi2 % 2) * 0.2, 9, 7), P.make.matte(gr[2])); bsh.position.set(-12 + bi2 * 5.6, Y + 0.32, LOT.z1 - 0.7); bsh.scale.y = 0.75; scene.add(bsh); } [[-32, ROAD_Z + ROAD_HW + 0.7, 1], [32, ROAD_Z + ROAD_HW + 0.7, 1], [-15.6, 5, 1], [15.9, -6.5, -1]].forEach(function (L) { var lg = _mk_lamp(); lg.position.set(L[0], Y, L[1]); lg.rotation.y = L[2] > 0 ? Math.PI / 2 : -Math.PI / 2; // arm toward the pavement scene.add(lg); }); // Perimeter greens — manual ring placement (world.scatter has no exclusion zones: a scattered tree // piercing the lot slab would break the stage; rejection-sample outside the pads instead). function _kit_h(n) { return num(_extm[n] && _extm[n].h, 0); } var _kit_trees = Object.keys(_extm).filter(function (n) { var t = _extm[n].tags || []; return (t.indexOf('tree') >= 0 || t.indexOf('nature') >= 0) && _kit_h(n) >= 2.2 && !/flower|bush|grass|rock/.test(n); }); _mgmt_scenery(prng, gr, [ { x0: -ROAD_X - 4, x1: ROAD_X + 4, z0: ROAD_Z - ROAD_HW - 1.6, z1: ROAD_Z + ROAD_HW + 1.6 }, { x0: LOT.x0 - 2, x1: LOT.x1 + 2, z0: LOT.z0 - 2, z1: LOT.z1 + 2 }, ], 20, 150, 36, 18, 10); // near+far scenery ring (fog is rig-rescaled, so the distance is actually visible) // ── Vehicles (roster GLB preferred, primitive fallback) ── var CAR_COLORS = ['#e8734a', '#3dbb7f', '#4a8fd4', '#e0c04a', '#c95f5f', '#7f8fa6']; var car_glass = P.make.matte('#a9c9e8', { roughness: 0.35 }); var _car_ci = 0; // deterministic color rotation — prng draws clustered on same-frame spawns function _mk_car_mesh(color) { var g = new THREE_.Group(); var body = P.make.matte(color, { roughness: 0.55 }); box(g, body, 1.15, 0.42, 2.5, 0, 0.5, 0); box(g, body, 1.02, 0.36, 1.25, 0, 0.86, -0.12); box(g, car_glass, 0.96, 0.26, 0.08, 0, 0.88, 0.55); // windshield (matte — glow bloomed at distance) box(g, dark, 1.17, 0.14, 0.34, 0, 0.34, 1.12); // bumpers box(g, dark, 1.17, 0.14, 0.34, 0, 0.34, -1.12); [[0.52, 0.78], [-0.52, 0.78], [0.52, -0.78], [-0.52, -0.78]].forEach(function (w) { var wh = new THREE_.Mesh(new THREE_.CylinderGeometry(0.24, 0.24, 0.16, 10), dark); wh.rotation.z = Math.PI / 2; wh.position.set(w[0], 0.24, w[1]); g.add(wh); }); return g; } function _mk_truck_mesh() { var g = new THREE_.Group(); box(g, white, 1.25, 0.9, 1.2, 0, 0.85, 1.5); // cab box(g, screen_glow, 1.1, 0.34, 0.08, 0, 1.05, 2.08); box(g, P.make.matte('#3dbb7f', { roughness: 0.6 }), 1.35, 1.35, 2.6, 0, 1.05, -0.4); // cargo [[0.6, 1.5], [-0.6, 1.5], [0.6, -1.0], [-0.6, -1.0]].forEach(function (w) { var wh = new THREE_.Mesh(new THREE_.CylinderGeometry(0.3, 0.3, 0.2, 10), dark); wh.rotation.z = Math.PI / 2; wh.position.set(w[0], 0.3, w[1]); g.add(wh); }); return g; } function _new_vehicle(kind) { var names = _svc && Array.isArray(kind === 'truck' ? (_svc.truck ? [_svc.truck] : null) : _svc.cars) ? (kind === 'truck' ? [_svc.truck] : _svc.cars) : null; var name = names && names.length ? names[Math.floor(prng() * names.length)] : null; if (name && _extm[name]) { var h = world.spawn(name, { at: { x: -200, z: ROAD_Z }, h: kind === 'truck' ? 2.4 : 1.4, collide: false, _no_uni: true }); return { obj: h.obj, kill: function () { h.remove(); } }; } var g = kind === 'truck' ? _mk_truck_mesh() : _mk_car_mesh(CAR_COLORS[_car_ci++ % CAR_COLORS.length]); g.position.set(-200, Y, ROAD_Z); scene.add(g); return { obj: g, kill: function () { scene.remove(g); } }; } // Ambient traffic — cars loop the road on both lanes (pure dressing; customers are separate) var traffic_n = Math.round(clamp(num(c.traffic, 4), 0, 8)); var _amb = []; for (var ai = 0; ai < traffic_n; ai++) { var av = _new_vehicle('car'); var lane = ai % 2 === 0 ? ROAD_Z - 1.7 : ROAD_Z + 1.7; _amb.push({ v: av, z: lane, dirx: ai % 2 === 0 ? 1 : -1, x: -ROAD_X + prng() * 2 * ROAD_X, spd: 8 + prng() * 3.5 }); } // ── Economy state ── var cash = clamp(Math.round(num(c.cash, 300)), 0, 99999); var earned = 0, served = 0, served_today = 0, missed = 0; var rep = 3.0, day = 1, day_t = 0; var day_s = clamp(num(c.day_s, 75), 40, 240); var quest_n = clamp(Math.round(num(c.quest_n, 8)), 3, 30); var fill_rate = 6, arrive_iv = 6.5, complete = false; // Default ladder labels FOLLOW the physical build cycle below (pump → tanks → shop annex → …): // a label whose words don't match its construction was the 2026-07-20 field report #3 ("건설을 // 눌러도 시각적으로 안 지어짐") — the visual contract is the cycle, labels must ride it. var UPG_DEF = [ { label: _at_t('3번 주유기', '3番ポンプ', 'Pump No.3'), kind: 'pump' }, { label: _at_t('저장 탱크 증설', '貯蔵タンク増設', 'Extra Tanks'), kind: 'tank' }, { label: _at_t('미니 스토어', 'ミニストア', 'Mini Store'), kind: 'annex' }, { label: _at_t('4번 주유기', '4番ポンプ', 'Pump No.4'), kind: 'pump' }, { label: _at_t('그랜드 캐노피', 'グランドキャノピー', 'Grand Canopy'), kind: 'pump' }, ]; // TYPED ladder (never-again contract L1): each step carries an explicit construction `kind` // (closed enum — the label is BOUND to what gets built, so a label↔construction mismatch is // unrepresentable). Absent/unknown kind falls back to the fixed cycle (still always builds). var UPG_KIND_CYCLE = ['pump', 'tank', 'annex']; var upg_steps = (Array.isArray(c.upgrades) && c.upgrades.length ? c.upgrades.slice(0, 8) : UPG_DEF) .map(function (u, i) { return { label: String((u && u.label) || u || UPG_DEF[i] || 'Upgrade ' + (i + 1)).slice(0, 20), kind: pick(u && u.kind, UPG_KIND_CYCLE, UPG_KIND_CYCLE[i % 3]), }; }); if (upg_steps.length < 2) upg_steps = UPG_DEF.map(function (l, i) { return { label: l, kind: UPG_KIND_CYCLE[i % 3] }; }); var upg_labels = upg_steps.map(function (s) { return s.label; }); var base_cost = clamp(Math.round(num(c.base_cost, 250)), 50, 2000); var upg_idx = 0; var built = []; // physical constructions per buy (smoke latch + hooks material) var TANK_SLOTS = [[-13, 5.2], [-9.2, 8.6], [-9.2, 5.2], [-13, 12.6]]; var _tank_extra = 0; var ANNEX_SLOTS = [[7.6, 9.5], [4.3, 9.5], [1.1, 9.5]]; var _annex_n = 0; function _upg_cost() { return Math.round(base_cost * Math.pow(2, upg_idx)); } // ── HUD (chip row + gauges) ── var ui = document.createElement('div'); ui.id = 'playable-archetype-hud'; ui.style.cssText = 'position:fixed;left:64px;right:12px;top:calc(50px + env(safe-area-inset-top,0px));z-index:20;color:#fff;font-family:system-ui,sans-serif;pointer-events:none;display:flex;flex-wrap:wrap;gap:6px;align-items:center;text-shadow:0 1px 6px rgba(0,0,0,.5);'; // top 50 = below the mute/attribution badge row (overlap seen in the visual check) function _chip(html) { var d = document.createElement('div'); d.style.cssText = 'display:inline-flex;align-items:center;gap:5px;padding:5px 10px;border-radius:999px;background:rgba(16,22,30,.55);font-weight:700;font-size:13px;'; d.innerHTML = html; ui.appendChild(d); return d; } var chip_title = _chip(''); chip_title.style.fontSize = '15px'; chip_title.style.fontWeight = '800'; chip_title.textContent = String(c.title || _at_t('주유소 타이쿤', 'ガソリンスタンド経営', 'Fuel Station Tycoon')).slice(0, 40); var chip_day = _chip(''), chip_cash = _chip(''); var fuel_chips = fuels.map(function (f) { var d = _chip('
'); return { root: d, name: d.children[0], bar: d.children[1].firstChild, val: d.children[2] }; }); var chip_rep = _chip(''), chip_quest = _chip(''); document.body.appendChild(ui); function _hud() { chip_day.textContent = '📅 ' + _at_t('일차 ', 'Day ', 'Day ') + day; chip_cash.textContent = '💰 ' + cash + ' ' + cur; for (var fi = 0; fi < fuels.length; fi++) { var f = fuels[fi]; fuel_chips[fi].name.textContent = f.label; fuel_chips[fi].val.textContent = Math.round(f.stock) + 'L'; // stock depletes in float increments — display integers var r = f.cap ? f.stock / f.cap : 0; fuel_chips[fi].bar.style.width = Math.round(clamp(r, 0, 1) * 100) + '%'; fuel_chips[fi].bar.style.background = r < 0.2 ? '#e8734a' : '#3ddc84'; } chip_rep.textContent = '★ ' + (Math.round(rep * 10) / 10).toFixed(1); chip_quest.textContent = '🎯 ' + served_today + ' / ' + quest_n; } _hud(); if (c.hint) P.toast(String(c.hint).slice(0, 60)); // ── Action bar (order / build) + panels ── var act = document.createElement('div'); act.dataset.mgmtUi = '1'; // camera rig ignores gestures starting on management UI act.style.cssText = 'position:fixed;left:50%;transform:translateX(-50%);bottom:calc(14px + env(safe-area-inset-bottom,0px));z-index:22;display:flex;gap:10px;'; function _abtn(txt) { var b = document.createElement('button'); b.textContent = txt; b.style.cssText = 'padding:10px 16px;border-radius:14px;border:1px solid rgba(255,255,255,.28);background:rgba(20,26,34,.78);color:#fff;font:700 14px system-ui;touch-action:manipulation;cursor:pointer;'; b.addEventListener('pointerdown', function (ev) { ev.stopPropagation(); }); act.appendChild(b); return b; } var btn_order = _abtn('🛢 ' + _at_t('연료 주문', '燃料注文', 'Order Fuel')); var btn_build = _abtn('🏗 ' + _at_t('건설', '建設', 'Build')); document.body.appendChild(act); var panel = document.createElement('div'); panel.dataset.mgmtUi = '1'; panel.style.cssText = 'position:fixed;left:50%;transform:translateX(-50%);bottom:calc(64px + env(safe-area-inset-bottom,0px));z-index:23;display:none;gap:8px;padding:10px;border-radius:14px;background:rgba(14,18,26,.88);'; document.body.appendChild(panel); var ORDER_L = 80; var pending_delivery = null; // { fi, amount, truck, t, phase } function _close_panel() { panel.style.display = 'none'; panel.innerHTML = ''; } function _order_cost(fi) { return Math.round(ORDER_L * fuels[fi].price * 0.6); } btn_order.addEventListener('click', function () { if (panel.style.display === 'flex' && panel._mode === 'order') return _close_panel(); panel.innerHTML = ''; panel._mode = 'order'; panel.style.display = 'flex'; fuels.forEach(function (f, fi) { var b = document.createElement('button'); b.style.cssText = 'padding:9px 12px;border-radius:11px;border:1px solid rgba(255,255,255,.22);background:rgba(30,40,52,.9);color:#fff;font:700 13px system-ui;cursor:pointer;'; b.textContent = f.label + ' +' + ORDER_L + 'L · ' + _order_cost(fi) + ' ' + cur; b.addEventListener('click', function () { if (pending_delivery) return P.toast(_at_t('배송 트럭이 이미 오는 중이에요', '配送トラックはもう向かっています', 'A delivery truck is already on its way')); if (cash < _order_cost(fi)) return P.toast(_at_t('잔액이 부족해요', '残高が足りません', 'Not enough cash')); cash -= _order_cost(fi); var tv = _new_vehicle('truck'); pending_delivery = { fi: fi, amount: ORDER_L, truck: tv, x: -ROAD_X, phase: 'in', t: 0 }; _close_panel(); _hud(); fx.sfx('pickup', { volume: 0.5 }); A._emit('order', { fuel: fuels[fi].label, index: fi, amount: ORDER_L, cash: cash }); }); panel.appendChild(b); }); }); btn_build.addEventListener('click', function () { if (panel.style.display === 'flex' && panel._mode === 'build') return _close_panel(); panel.innerHTML = ''; panel._mode = 'build'; panel.style.display = 'flex'; var b = document.createElement('button'); b.style.cssText = 'padding:9px 14px;border-radius:11px;border:1px solid rgba(255,255,255,.22);background:rgba(30,40,52,.9);color:#fff;font:700 13px system-ui;cursor:pointer;'; if (upg_idx >= upg_labels.length) b.textContent = _at_t('모든 확장 완료!', '拡張はすべて完了!', 'Everything is built!'); else b.textContent = '🏗 ' + upg_labels[upg_idx] + ' · ' + _upg_cost() + ' ' + cur; b.addEventListener('click', function () { _buy_next(); }); panel.appendChild(b); }); function _buy_next() { if (complete || upg_idx >= upg_labels.length) return; var cost = _upg_cost(); if (cash < cost) return P.toast(_at_t('잔액이 부족해요', '残高が足りません', 'Not enough cash')); cash -= cost; var label = upg_labels[upg_idx]; var final_step = upg_idx === upg_labels.length - 1; // EVERY step CONSTRUCTS something physical (field report #3 seal: the build button promises // construction — a numbers-only step reads as "nothing happened"). Deterministic effect+build // cycle: pump reveal → storage-tank cluster (capacity) → shop annex (service speed) → repeat; // the FINAL step raises the grand canopy. All constructions land in _priv.built (smoke latch). function _bfx(g) { fx.spawn(g); var bp = new THREE_.Vector3(); g.getWorldPosition ? g.getWorldPosition(bp) : bp.copy(g.position); fx.burst({ x: bp.x, y: Y + 2.4, z: bp.z }, { count: 20, color: pal.accent, speed: 3, life: 1.1, gravity: -0.2 }); } function _build_tank() { for (var fi2 = 0; fi2 < fuels.length; fi2++) { fuels[fi2].cap = Math.round(fuels[fi2].cap * 1.5); fuels[fi2].stock = Math.min(fuels[fi2].cap, fuels[fi2].stock + 60); } var slot = TANK_SLOTS[_tank_extra++]; if (!slot) { // slots exhausted — GROW an existing tank (the ledger contract is unconditional) var lastT = null; for (var bi2 = built.length - 1; bi2 >= 0; bi2--) if (built[bi2].kind === 'tank') { lastT = built[bi2].g; break; } lastT = lastT || tank; lastT.scale.multiplyScalar(1.09); _bfx(lastT); built.push({ kind: 'tank', g: lastT }); return; } var tg2 = _mk_tank(); tg2.scale.setScalar(0.88); tg2.position.set(slot[0], Y, slot[1]); scene.add(tg2); _bfx(tg2); built.push({ kind: 'tank', g: tg2 }); } function _build_annex() { fill_rate *= 1.3; arrive_iv *= 0.86; var slot = ANNEX_SLOTS[_annex_n++]; if (!slot) { // slots exhausted — GROW the last annex (unconditional ledger contract) var lastA = null; for (var bi3 = built.length - 1; bi3 >= 0; bi3--) if (built[bi3].kind === 'annex') { lastA = built[bi3].g; break; } if (lastA) { lastA.scale.multiplyScalar(1.08); _bfx(lastA); built.push({ kind: 'annex', g: lastA }); } else { _build_tank(); } // no annex yet to grow — a tank build still honors the contract return; } var ag = new THREE_.Group(); box(ag, P.make.matte('#e6e2d8', { roughness: 0.85 }), 3.0, 2.1, 4.0, 0, 1.05, 0); box(ag, dark, 3.1, 0.3, 4.1, 0, 2.25, 0); box(ag, P.make.matte('#a9c9e8', { roughness: 0.35 }), 0.06, 1.1, 2.4, -1.53, 0.95, 0); // storefront glass (faces the lot, -x) box(ag, red, 0.7, 0.16, 2.8, -1.7, 1.75, 0); // awning strip var sgn = new THREE_.Mesh(new THREE_.BoxGeometry(0.1, 0.5, 1.6), P.make.glow(pal.accent, 1.12)); sgn.position.set(-1.58, 2.0, 0); ag.add(sgn); ag.position.set(slot[0], Y, slot[1]); scene.add(ag); _bfx(ag); built.push({ kind: 'annex', g: ag }); } var _b0 = built.length; // never-again contract L2: every purchase MUST grow the construction ledger if (final_step) { var cg = _mk_canopy(PUMP_X[0], PUMP_X[PUMP_X.length - 1], PUMP_Z); cg.position.y = Y; scene.add(cg); _bfx(cg); built.push({ kind: 'canopy', g: cg }); } else { var kind = upg_steps[upg_idx] ? upg_steps[upg_idx].kind : UPG_KIND_CYCLE[upg_idx % 3]; var closed = null; for (var pi = 0; pi < pumps.length; pi++) if (!pumps[pi].open) { closed = pumps[pi]; break; } if (kind === 'pump' && closed) { closed.open = true; closed.g.visible = true; _bfx(closed.g); built.push({ kind: 'pump', g: closed.g }); } else if (kind === 'tank' || (kind === 'pump' && !closed)) { _build_tank(); } else { _build_annex(); } } if (built.length === _b0) { // structurally unreachable (every branch above builds, incl. slot-exhausted growth fallbacks) — // if a future edit reintroduces a numbers-only step, this self-reports from production. warn('tycoon buy "' + label + '" constructed nothing — the every-buy-constructs contract is broken'); if (window.__playable_beacon) window.__playable_beacon('tycoon_buy_no_build', label); } upg_idx++; _close_panel(); _hud(); fx.sfx('powerup'); P.toast('🏗 ' + label + ' ' + _at_t('완공!', '完成!', 'built!')); A._emit('buy', { label: label, index: upg_idx - 1, cash: cash }); if (final_step) _win(); } // ── Customer cars (state machine: in → fill → out) ── var _cust = []; function _free_pump() { for (var i = 0; i < pumps.length; i++) if (pumps[i].open && !pumps[i].busy) return pumps[i]; return null; } function _route_in(pump, from_x) { return [ [from_x, ROAD_Z - 1.7], [pump.x - 9, ROAD_Z - 1.7], [pump.x - 5.5, PARK_Z - 2.2], [pump.x, PARK_Z], ]; } function _route_out(pump) { return [[pump.x + 5.5, PARK_Z - 2.2], [pump.x + 10, ROAD_Z - 1.7], [ROAD_X + 6, ROAD_Z - 1.7]]; } function spawn_customer(fuel_idx, liters, near) { var fi = typeof fuel_idx === 'number' ? clamp(Math.round(fuel_idx), 0, fuels.length - 1) : (function () { var r = prng(); return r < 0.5 ? 0 : (r < 0.82 && fuels.length > 1 ? 1 : fuels.length - 1); })(); var L = clamp(Math.round(num(liters, 14 + prng() * 28)), 5, 99); var pump = _free_pump(); if (!pump && !near && prng() < 0.5) return null; // half the overflow just drives past silently — a full forecourt must not death-spiral the stars if (!pump || fuels[fi].stock < L) { // drive-by loss — LOUD in-world, reputation slips missed++; rep = Math.max(0.5, rep - 0.08); fx.float_text({ x: LOT.x0 + 2, y: Y + 2.2, z: LOT.z0 + 1 }, !pump ? _at_t('만차! 💢', '満車! 💢', 'Full! 💢') : _at_t(fuels[fi].label + ' 품절 💢', fuels[fi].label + '切れ 💢', 'No ' + fuels[fi].label + ' 💢'), { color: '#ff9a8a' }); _hud(); A._emit('miss', { reason: !pump ? 'full' : 'stock', fuel: fuels[fi].label, reputation: rep }); return null; } pump.busy = true; var v = _new_vehicle('car'); var route = _route_in(pump, near ? -34 : -ROAD_X - 4); v.obj.position.set(route[0][0], Y, route[0][1]); var cst = { v: v, pump: pump, fi: fi, liters: L, poured: 0, state: 'in', wp: route, wi: 0, mk: world.marker(v.obj, { icon: '⛽', label: '' }), }; _cust.push(cst); return cst; } function _step_route(cst, spd, dt) { var tgt = cst.wp[cst.wi]; if (!tgt) return true; var o = cst.v.obj.position; var dx = tgt[0] - o.x, dz = tgt[1] - o.z; var d = Math.sqrt(dx * dx + dz * dz); if (d < 0.25) { cst.wi++; return cst.wi >= cst.wp.length; } var mv = Math.min(d, spd * dt); o.x += dx / d * mv; o.z += dz / d * mv; o.y = Y; cst.v.obj.rotation.y = Math.atan2(dx, dz); return false; } // ── Customer request card (bottom sheet — pick the right fuel) ── var card = document.createElement('div'); card.dataset.mgmtUi = '1'; card.style.cssText = 'position:fixed;left:50%;transform:translateX(-50%);bottom:calc(112px + env(safe-area-inset-bottom,0px));z-index:23;display:none;padding:12px 14px;border-radius:16px;background:rgba(14,18,26,.9);color:#fff;font-family:system-ui;min-width:240px;box-shadow:0 8px 28px rgba(0,0,0,.4);'; document.body.appendChild(card); var req = null, req_timer = 0; function _open_request() { if (req || complete || !_free_pump()) return; // Pick only among fuels that can actually serve the request — a random pick against an empty tank // silently produced no card (dead request window; caught by the golden smoke). var cands = []; for (var ki = 0; ki < fuels.length; ki++) if (fuels[ki].stock >= 20) cands.push(ki); if (!cands.length) return; var fi = cands[Math.floor(prng() * cands.length)]; var L = Math.max(10, Math.min(26 + Math.round(prng() * 26), Math.floor(fuels[fi].stock * 0.8))); req = { fi: fi, liters: L, bonus: 1.5 }; card.innerHTML = '
🙋 ' + _at_t('손님 요청', 'お客さんのリクエスト', 'Customer request') + ' — ' + fuels[fi].label + ' ' + L + 'L · ' + Math.round(L * fuels[fi].price * req.bonus) + ' ' + cur + '
'; var row = card.children[1]; fuels.forEach(function (f, bi) { var b = document.createElement('button'); b.style.cssText = 'flex:1;padding:8px 10px;border-radius:10px;border:1px solid rgba(255,255,255,.22);background:rgba(30,40,52,.95);color:#fff;font:700 13px system-ui;cursor:pointer;'; b.textContent = f.label; b.addEventListener('click', function () { if (!req) return; // card already timed out this frame — no stale read var ok = bi === req.fi; if (ok) { var vip = spawn_customer(req.fi, req.liters, true); if (vip) { vip.bonus = req.bonus; P.toast(_at_t('VIP 손님이 들어와요! ⭐', 'VIPのお客さんが来ます! ⭐', 'VIP customer coming in! ⭐')); } } else { rep = Math.max(0.5, rep - 0.05); P.toast(_at_t('앗, 다른 연료였어요…', 'あっ、違う燃料でした…', 'Oops, wrong fuel…')); _hud(); } A._emit('request', { ok: ok, fuel: fuels[req.fi].label, liters: req.liters }); req = null; card.style.display = 'none'; }); row.appendChild(b); }); card.style.display = 'block'; fx.sfx('pickup', { volume: 0.4 }); req_timer = 0; } // ── Management camera rig (pan/zoom/aspect framing — shared _mgmt_camera owns the camera) ── _mgmt_camera({ center: { x: 0.4, z: 0 }, y: Y, dir: { x: 13.5, y: 20.5, z: -23 }, base_dist: 33.6, extent_x: 46, extent_z: 44, look_lift: 0.5, }); // ── Single game loop (traffic · customers · delivery · day cycle) ── var arrive_t = 3.5, req_gap = 18 + prng() * 10, _label_t = 0; var _priv_fast = false; P.loop(function (dt) { if (dt <= 0) return; dt = Math.min(dt, 0.1); var spd_mul = _priv_fast ? 4 : 1; // ambient traffic for (var aa = 0; aa < _amb.length; aa++) { var am = _amb[aa]; am.x += am.dirx * am.spd * dt; if (am.x > ROAD_X + 6) am.x = -ROAD_X - 6; if (am.x < -ROAD_X - 6) am.x = ROAD_X + 6; am.v.obj.position.set(am.x, Y, am.z); am.v.obj.rotation.y = am.dirx > 0 ? Math.PI / 2 : -Math.PI / 2; } // customer arrivals — pace by reputation (better stars = busier forecourt) if (!complete) { arrive_t -= dt * spd_mul; if (arrive_t <= 0) { arrive_t = clamp(arrive_iv - rep * 0.8, 2.2, 12) * (0.75 + prng() * 0.5); spawn_customer(); } req_gap -= dt; if (req_gap <= 0) { req_gap = 30 + prng() * 14; _open_request(); } if (req) { req_timer += dt; if (req_timer > 10) { req = null; card.style.display = 'none'; } } } // customers _label_t += dt; var relabel = _label_t > 0.15; if (relabel) _label_t = 0; for (var ci = _cust.length - 1; ci >= 0; ci--) { var cst = _cust[ci]; if (cst.state === 'in') { if (_step_route(cst, (cst.wi < 2 ? 9 : 4.5) * spd_mul, dt)) { cst.state = 'fill'; cst.v.obj.rotation.y = Math.PI / 2; // parked heading +x beside the pump fx.sfx('pickup', { volume: 0.25 }); } } else if (cst.state === 'fill') { var pour = Math.min(fill_rate * spd_mul * dt, cst.liters - cst.poured, fuels[cst.fi].stock); cst.poured += pour; fuels[cst.fi].stock = Math.max(0, fuels[cst.fi].stock - pour); if (relabel) { cst.mk.set_label(cst.poured.toFixed(1) + 'L · ' + Math.round(cst.poured * fuels[cst.fi].price * (cst.bonus || 1)) + ' ' + cur); _hud(); } if (cst.poured >= cst.liters - 0.01 || (fuels[cst.fi].stock <= 0 && pour <= 0)) { var revenue = Math.round(cst.poured * fuels[cst.fi].price * (cst.bonus || 1)); cash += revenue; earned += revenue; served++; served_today++; rep = Math.min(5, rep + (cst.bonus ? 0.1 : 0.04)); cst.mk.remove(); cst.mk = null; cst.pump.busy = false; cst.state = 'out'; cst.wp = _route_out(cst.pump); cst.wi = 0; fx.float_text({ x: cst.v.obj.position.x, y: Y + 2.3, z: cst.v.obj.position.z }, '+' + revenue + ' ' + cur, { color: pal.accent }); fx.sfx('powerup', { volume: 0.45 }); _hud(); A._emit('serve', { fuel: fuels[cst.fi].label, liters: Math.round(cst.poured), revenue: revenue, cash: cash, served: served }); } } else if (cst.state === 'out') { if (_step_route(cst, (cst.wi < 1 ? 4.5 : 9) * spd_mul, dt)) { if (cst.mk) cst.mk.remove(); cst.v.kill(); _cust.splice(ci, 1); } } } // delivery truck if (pending_delivery) { var pd = pending_delivery, tobj = pd.truck.obj; if (pd.phase === 'in') { tobj.position.y = Y; pd.x += 11 * spd_mul * dt; tobj.position.set(pd.x, Y, ROAD_Z - 1.7); tobj.rotation.y = Math.PI / 2; if (pd.x > -26) { pd.phase = 'park'; pd.wp = [[-19, ROAD_Z - 1.7], [-14.5, 2], [-13, 6]]; pd.wi = 0; } } else if (pd.phase === 'park') { var tg2 = pd.wp[pd.wi], tp = tobj.position; var tdx = tg2[0] - tp.x, tdz = tg2[1] - tp.z, td = Math.sqrt(tdx * tdx + tdz * tdz); if (td < 0.3) { pd.wi++; if (pd.wi >= pd.wp.length) { pd.phase = 'drop'; pd.t = 0; } } else { var tmv = Math.min(td, 5 * spd_mul * dt); tp.x += tdx / td * tmv; tp.z += tdz / td * tmv; tobj.rotation.y = Math.atan2(tdx, tdz); } } else if (pd.phase === 'drop') { pd.t += dt * spd_mul; if (pd.t > 2.2) { fuels[pd.fi].stock = Math.min(fuels[pd.fi].cap, fuels[pd.fi].stock + pd.amount); _hud(); fx.float_text({ x: -13, y: Y + 3.4, z: 6 }, '+' + pd.amount + 'L', { color: '#3ddc84' }); fx.sfx('pickup', { volume: 0.5 }); P.toast('🛢 ' + fuels[pd.fi].label + ' +' + pd.amount + 'L'); A._emit('delivery', { fuel: fuels[pd.fi].label, index: pd.fi, amount: pd.amount }); pd.phase = 'leave'; pd.wp = [[-14.5, 2], [-19, ROAD_Z - 1.7], [ROAD_X + 8, ROAD_Z - 1.7]]; pd.wi = 0; } } else if (pd.phase === 'leave') { var tg3 = pd.wp[pd.wi], tp3 = tobj.position; var t3x = tg3[0] - tp3.x, t3z = tg3[1] - tp3.z, t3d = Math.sqrt(t3x * t3x + t3z * t3z); if (t3d < 0.3) { pd.wi++; if (pd.wi >= pd.wp.length) { pd.truck.kill(); pending_delivery = null; } } else { var t3m = Math.min(t3d, 8 * spd_mul * dt); tp3.x += t3x / t3d * t3m; tp3.z += t3z / t3d * t3m; tobj.rotation.y = Math.atan2(t3x, t3z); } } } // day cycle + daily quest if (!complete) { day_t += dt * spd_mul; if (day_t >= day_s) { day_t = 0; var met = served_today >= quest_n; if (met) { var bonus = 60 + day * 25; cash += bonus; earned += bonus; P.toast('🎯 ' + _at_t('일일 목표 달성! +', 'デイリー目標達成! +', 'Daily goal met! +') + bonus + ' ' + cur); fx.sfx('powerup'); } A._emit('day', { day: day, quest_met: met, served_today: served_today }); day++; served_today = 0; _hud(); } } }); // ── Victory (final upgrade = the grand canopy) + share ── function _win() { if (complete) return; complete = true; if (req) { req = null; card.style.display = 'none'; } P.music('triumph'); fx.screen_flash(pal.key); fx.burst({ x: 0, y: Y + 6, z: PUMP_Z }, { count: 44, color: '#ffd24a', speed: 5, life: 1.5, gravity: -0.4 }); P.toast(String(c.complete_text || _at_t('주유소 완공! 최고의 주유소예요 ⛽', 'スタンド完成! 最高の店です ⛽', 'Station complete! Best stop in town ⛽')).slice(0, 60)); A._emit('complete', { earned: earned, served: served, day: day }); setTimeout(function () { P.three.capture().then(function (shot) { P.share_shot(shot, { title: String(c.title || _at_t('주유소 타이쿤', 'ガソリンスタンド経営', 'Fuel Station Tycoon')).slice(0, 40), lines: ['⛽ ' + _at_t('손님 ', '客 ', 'Served ') + served + ' · 💰 ' + earned + ' ' + cur + ' · 📅 ' + day], share_text: String(c.share_text || _at_t('내 주유소를 완성했어요 ⛽ 손님 ' + served + '명, 총 ' + earned + ' ' + cur, 'ガソリンスタンドを完成させました ⛽', 'I built the best fuel station ⛽ ' + served + ' customers served')).slice(0, 120), }); }); }, 900); } P.mount_share(function () { return String(c.share_text || _at_t('주유소 운영 중 ⛽ 손님 ' + served + '명 · 💰 ' + cash + ' ' + cur, 'ガソリンスタンド経営中 ⛽ 客' + served + '人', 'Running my fuel station ⛽ ' + served + ' served · 💰 ' + cash)).slice(0, 120); }, String(c.share_label || _at_t('공유', '共有', 'Share')).slice(0, 12)); A._installed = 'service_tycoon'; return { get cash() { return cash; }, get earned() { return earned; }, get served() { return served; }, get reputation() { return rep; }, get day() { return day; }, get complete() { return complete; }, fuels: fuels, upgrades: upg_labels.length, _priv: { spawn_customer: spawn_customer, buy_next: _buy_next, open_request: _open_request, pumps: pumps, customers: _cust, built: built, set fast(v) { _priv_fast = !!v; }, get fast() { return _priv_fast; }, }, }; }; // ═══ Archetype #4c: plot_tycoon — grid-placement lot builder (complete · golden-tested) ═══ // Identity: the PLACEMENT half of the tycoon pair (service_tycoon = serving loop, this = building // loop): a fixed 3/4 management camera over a buildable venue lot. The player taps a grid tile, // picks a building from the bottom build bar, and places it; buildings tick passive income, visitor // peg-people stream in from the entrance and pay on arrival; earnings unlock the ladder and the // final LANDMARK build is the victory. Theme (market street / food court / fairground / farmstead) // comes entirely from config labels + the closed silhouette vocabulary — meshes are shell-owned // procedural (catalog-independent), visitors prefer the generator roster // (.people: walk-clip characters) with a peg-person fallback. _ARCH.plot_tycoon = function (cfg) { var c = cfg || {}; var mood_req = c.mood === 'night' ? 'night_neon' : c.mood; if (mood_req === 'night_neon' || mood_req === 'ember' || mood_req === 'dusk') { warn('dark mood "' + mood_req + '" demoted to sunset — archetype starts in daylight'); mood_req = 'sunset'; } var mood_name = pick(mood_req, P.moods || [], 'noon'); if (mood_req && mood_name !== mood_req) warn('unknown mood "' + mood_req + '" → ' + mood_name); var pal = P.mood(mood_name); P.music(pick(c.music, ['calm_exploration', 'adventure', 'cozy', 'mystery', 'night', 'chiptune_action', 'battle', 'triumph', 'space', 'spooky', 'lofi', 'sad', 'happy'], 'lofi')); P.ambience(pick(c.ambience, ['forest', 'wind', 'rain', 'night', 'ocean', 'cave', 'crowd'], 'crowd')); P.enable_bloom({ strength: 0.5, radius: 0.5, threshold: 1.05 }); var world = P.world, nature = P.nature, fx = P.fx; var seed = (num(c.seed, 29) | 0); var prng = null; var gr = pal.ground_ramp || [pal.ground, pal.ground, pal.ground, pal.ground]; var terrain = world.terrain({ seed: seed, amplitude: 1, scale: 240, chunk: 32, radius: 3 }); prng = world._priv.mulberry32(((seed * 40093) ^ 0x9E3779B9) >>> 0); nature.wind({ strength: 0.4, gustiness: 0.4, direction: { x: 0.8, z: 0.3 } }); nature.grass_field({ area: 150, count: 14000, base: gr[1], tip: gr[3], height: 0.4, y: terrain.height_at }); nature.clouds({ count: 10, style: 'puffy' }); // ── Grid frame (tile 4.5m; entrance walkway at the south edge) ── var COLS = clamp(Math.round(num(c.grid && c.grid[0], 6)), 4, 8); var ROWS = clamp(Math.round(num(c.grid && c.grid[1], 4)), 3, 6); var TILE = 4.5; var W = COLS * TILE, D = ROWS * TILE; var hmax = -Infinity; for (var hx = -W; hx <= W; hx += 6) for (var hz = -D - 10; hz <= D; hz += 5) { var hh = world.height_at(hx, hz); if (hh > hmax) hmax = hh; } var Y = hmax + 0.55; var concrete = P.make.matte('#ddd9d0', { roughness: 0.92 }); var dark = P.make.matte('#2e333b', { roughness: 0.85 }); var line = P.make.matte('#c2bcb0', { roughness: 0.9 }); var slabm = new THREE_.Mesh(new THREE_.BoxGeometry(W + 3, 2.4, D + 3), P.make.matte('#c9c5bc', { roughness: 0.94 })); slabm.position.set(0, Y - 1.2, 0); scene.add(slabm); var walk = new THREE_.Mesh(new THREE_.BoxGeometry(TILE * 0.9, 2.4, 9), concrete); walk.position.set(0, Y - 1.21, D / 2 + 4.4); // entrance walkway (south) scene.add(walk); _mgmt_floor(prng, -W / 2, W / 2, -D / 2, D / 2, Y, TILE, ['#dad6cd', '#e0dcd4', '#e6e3db']); // §5.5 surface differentiation for (var gi = 0; gi <= COLS; gi++) { // grid lines (above the floor tiles) var gl = new THREE_.Mesh(new THREE_.BoxGeometry(0.09, 0.04, D), line); gl.position.set(-W / 2 + gi * TILE, Y + 0.085, 0); scene.add(gl); } for (var gj = 0; gj <= ROWS; gj++) { var gl2 = new THREE_.Mesh(new THREE_.BoxGeometry(W, 0.04, 0.09), line); gl2.position.set(0, Y + 0.085, -D / 2 + gj * TILE); scene.add(gl2); } function tile_center(i, j) { return { x: -W / 2 + (i + 0.5) * TILE, z: -D / 2 + (j + 0.5) * TILE }; } // Perimeter greens — rejection-sampled outside the slab (scatter has no exclusion zones) var _extm = (typeof window !== 'undefined' && window['__PLAYABLE_'+'EXTRA_MANIFEST__'] && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].models) || {}; function _kit_h(n) { return num(_extm[n] && _extm[n].h, 0); } var _kit_trees = Object.keys(_extm).filter(function (n) { var t = _extm[n].tags || []; return (t.indexOf('tree') >= 0 || t.indexOf('nature') >= 0) && _kit_h(n) >= 2.2 && !/flower|bush|grass|rock/.test(n); }); _mgmt_scenery(prng, gr, [ { x0: -W / 2 - 4, x1: W / 2 + 4, z0: -D / 2 - 4, z1: D / 2 + 12 }, ], 16, 140, 32, 16, 8); // near+far scenery ring (fog is rig-rescaled) // ── Building silhouettes (closed vocabulary — shell-owned meshes) ── var cur = String(c.currency_label || _at_t('₩', '¥', '$')).slice(0, 8); function box(g, mat, w, h, d, x, y, z) { var b = new THREE_.Mesh(new THREE_.BoxGeometry(w, h, d), mat); b.position.set(x, y, z); g.add(b); return b; } var red = P.make.matte('#c4382d', { roughness: 0.6 }); var white = P.make.matte('#eef0f2', { roughness: 0.7 }); var wood = P.make.matte('#8a5c38', { roughness: 0.85 }); var glass = P.make.matte('#a9c9e8', { roughness: 0.35 }); var KINDS = { stall: function () { // market stall — counter + striped canopy var g = new THREE_.Group(); box(g, wood, 2.6, 0.9, 1.4, 0, 0.45, 0.4); [-1.15, 1.15].forEach(function (px) { box(g, wood, 0.14, 2.2, 0.14, px, 1.1, -0.5); }); box(g, red, 3.0, 0.12, 2.0, 0, 2.25, 0); box(g, white, 3.02, 0.13, 0.5, 0, 2.24, 0.78); return g; }, shop: function () { // small store — box + awning + door var g = new THREE_.Group(); box(g, P.make.matte('#e2e6ea', { roughness: 0.85 }), 3.2, 2.4, 2.6, 0, 1.2, 0); box(g, dark, 3.3, 0.35, 2.7, 0, 2.55, 0); box(g, glass, 1.0, 1.2, 0.06, -0.7, 0.85, 1.31); box(g, red, 2.0, 0.12, 0.8, 0, 1.9, 1.5); return g; }, cafe: function () { // cafe — counter hut + parasol table var g = new THREE_.Group(); box(g, wood, 2.0, 1.9, 1.6, -0.7, 0.95, -0.3); box(g, red, 2.2, 0.3, 1.8, -0.7, 2.0, -0.3); var pole = new THREE_.Mesh(new THREE_.CylinderGeometry(0.04, 0.04, 2.1, 6), white); pole.position.set(1.1, 1.05, 0.7); g.add(pole); var um = new THREE_.Mesh(new THREE_.ConeGeometry(1.0, 0.5, 8), P.make.matte('#e0c04a')); um.position.set(1.1, 2.2, 0.7); g.add(um); var tb = new THREE_.Mesh(new THREE_.CylinderGeometry(0.5, 0.5, 0.08, 10), white); tb.position.set(1.1, 0.8, 0.7); g.add(tb); return g; }, garden: function () { // pocket garden — planter + bushes var g = new THREE_.Group(); box(g, wood, 3.0, 0.4, 3.0, 0, 0.2, 0); box(g, P.make.matte('#5f9e57'), 2.7, 0.2, 2.7, 0, 0.44, 0); [[-0.8, -0.6], [0.7, 0.5], [0, -0.2]].forEach(function (p, i) { var b = new THREE_.Mesh(new THREE_.SphereGeometry(0.5 + i * 0.12, 9, 7), P.make.matte(gr[2])); b.position.set(p[0], 0.8, p[1]); b.scale.y = 0.8; g.add(b); }); return g; }, fountain: function () { // fountain — twin basin + glow water var g = new THREE_.Group(); var b1 = new THREE_.Mesh(new THREE_.CylinderGeometry(1.5, 1.6, 0.5, 12), white); b1.position.y = 0.25; g.add(b1); var wm = new THREE_.Mesh(new THREE_.CylinderGeometry(1.35, 1.35, 0.1, 12), P.make.glow('#9fd8ff', 1.08)); wm.position.y = 0.52; g.add(wm); var b2 = new THREE_.Mesh(new THREE_.CylinderGeometry(0.55, 0.65, 0.5, 10), white); b2.position.y = 0.85; g.add(b2); var wm2 = new THREE_.Mesh(new THREE_.CylinderGeometry(0.45, 0.45, 0.1, 10), P.make.glow('#9fd8ff', 1.1)); wm2.position.y = 1.12; g.add(wm2); g._pulses = [fx.pulse(wm2, { amount: 0.08, rate: 1.4 })]; return g; }, }; function _mk_landmark(kind) { // finale — big glow-tipped anchor var g = new THREE_.Group(); var stone = P.make.matte('#cfc6b8'); var accent = P.make.glow(pal.accent, 1.2); if (kind === 'statue') { box(g, stone, 2.6, 1.4, 2.6, 0, 0.7, 0); box(g, P.make.matte('#6fae9d'), 1.0, 2.4, 0.8, 0, 2.6, 0); var orb = new THREE_.Mesh(new THREE_.SphereGeometry(0.35, 10, 8), accent); orb.position.set(0.7, 4.1, 0); g.add(orb); g._pulses = [fx.pulse(orb, { amount: 0.1, rate: 1.2 })]; } else if (kind === 'arch') { [-1.6, 1.6].forEach(function (px) { box(g, stone, 0.7, 3.6, 0.7, px, 1.8, 0); }); box(g, stone, 4.4, 0.9, 0.8, 0, 3.9, 0); var key = new THREE_.Mesh(new THREE_.SphereGeometry(0.3, 8, 6), accent); key.position.y = 3.4; g.add(key); g._pulses = [fx.pulse(key, { amount: 0.1, rate: 1.2 })]; } else { // tower (default) box(g, stone, 2.4, 3.6, 2.4, 0, 1.8, 0); box(g, stone, 1.6, 2.6, 1.6, 0, 4.7, 0); var sp = new THREE_.Mesh(new THREE_.CylinderGeometry(0.1, 0.24, 2.4, 8), accent); sp.position.y = 7.1; g.add(sp); g._pulses = [fx.pulse(sp, { amount: 0.08, rate: 1.1 })]; } return g; } // ── Build ladder (Tier B config) ── var BDEF = [ { label: _at_t('노점', '屋台', 'Stall'), kind: 'stall' }, { label: _at_t('상점', 'ショップ', 'Shop'), kind: 'shop' }, { label: _at_t('카페', 'カフェ', 'Cafe'), kind: 'cafe' }, { label: _at_t('정원', 'ガーデン', 'Garden'), kind: 'garden' }, { label: _at_t('분수', '噴水', 'Fountain'), kind: 'fountain' }, ]; var defs = (Array.isArray(c.buildings) && c.buildings.length ? c.buildings.slice(0, 5) : BDEF).map(function (b, i) { var d = BDEF[i] || BDEF[0]; return { label: String((b && b.label) || d.label).slice(0, 20), kind: pick(b && b.kind, ['stall', 'shop', 'cafe', 'garden', 'fountain'], d.kind) }; }); var base_cost = clamp(Math.round(num(c.base_cost, 120)), 50, 2000); defs.forEach(function (d, i) { d.cost = Math.round(base_cost * Math.pow(1.9, i)); d.income = Math.max(4, Math.round(d.cost * 0.09)); // payback ≈ 11 ticks — the pacing canon d.unlocked = i === 0; }); var lmk = { label: String((c.landmark && c.landmark.label) || _at_t('그랜드 타워', 'グランドタワー', 'Grand Tower')).slice(0, 20), kind: pick(c.landmark && c.landmark.kind, ['tower', 'statue', 'arch'], 'tower'), cost: Math.round(base_cost * Math.pow(1.9, defs.length) * 1.4), }; var cash = clamp(Math.round(num(c.cash, 200)), 0, 99999); var earned = 0, placed = 0, visitors_served = 0, complete = false; // ── Tiles + placement ── var tiles = []; for (var tj = 0; tj < ROWS; tj++) for (var ti2 = 0; ti2 < COLS; ti2++) tiles.push({ i: ti2, j: tj, b: null, g: null }); function tile_at(i, j) { return tiles[j * COLS + i]; } var sel_ring = new THREE_.Mesh(new THREE_.TorusGeometry(TILE * 0.42, 0.09, 8, 24), P.make.glow(pal.accent, 1.2)); sel_ring.position.y = Y + 0.1; sel_ring.rotation.x = -Math.PI / 2; sel_ring.visible = false; scene.add(sel_ring); var sel = null; function _place(t, di) { var d = defs[di]; if (!d || t.b || complete) return false; if (!d.unlocked) { P.toast(_at_t('아직 잠겨 있어요', 'まだロック中', 'Still locked')); return false; } if (cash < d.cost) { P.toast(_at_t('잔액이 부족해요', '残高が足りません', 'Not enough cash')); return false; } cash -= d.cost; var g = KINDS[d.kind](); var ct = tile_center(t.i, t.j); g.position.set(ct.x, Y, ct.z); g.rotation.y = (prng() - 0.5) * 0.4; scene.add(g); fx.spawn(g); t.b = { def: d, di: di, income_t: 2 + prng() * 2 }; t.g = g; placed++; // unlock ladder: each placement unlocks the next tier once for (var ui = 0; ui < defs.length; ui++) { if (!defs[ui].unlocked && placed >= ui) { defs[ui].unlocked = true; if (ui > 0) A._emit('unlock', { label: defs[ui].label, index: ui }); break; } } fx.sfx('powerup', { volume: 0.5 }); _hud(); _close_bar(); A._emit('place', { label: d.label, kind: d.kind, index: di, i: t.i, j: t.j, cash: cash, placed: placed }); return true; } function _place_landmark(t) { if (t.b || complete) return false; if (placed < defs.length) { P.toast(_at_t('먼저 가게를 더 지어보세요', 'まず店を増やそう', 'Build more shops first')); return false; } if (cash < lmk.cost) { P.toast(_at_t('잔액이 부족해요', '残高が足りません', 'Not enough cash')); return false; } cash -= lmk.cost; var g = _mk_landmark(lmk.kind); var ct = tile_center(t.i, t.j); g.position.set(ct.x, Y, ct.z); scene.add(g); fx.spawn(g); t.b = { def: { label: lmk.label, income: 0 }, di: -1 }; t.g = g; placed++; _hud(); _close_bar(); _win(); return true; } // ── HUD chips ── var ui = document.createElement('div'); ui.id = 'playable-archetype-hud'; ui.style.cssText = 'position:fixed;left:64px;right:12px;top:calc(50px + env(safe-area-inset-top,0px));z-index:20;color:#fff;font-family:system-ui,sans-serif;pointer-events:none;display:flex;flex-wrap:wrap;gap:6px;align-items:center;text-shadow:0 1px 6px rgba(0,0,0,.5);'; function _chip(sz) { var d = document.createElement('div'); d.style.cssText = 'display:inline-flex;align-items:center;gap:5px;padding:5px 10px;border-radius:999px;background:rgba(16,22,30,.55);font-weight:700;font-size:' + (sz || 13) + 'px;'; ui.appendChild(d); return d; } var chip_title = _chip(15); chip_title.style.fontWeight = '800'; chip_title.textContent = String(c.title || _at_t('나의 플라자', 'マイプラザ', 'My Plaza')).slice(0, 40); var chip_cash = _chip(), chip_placed = _chip(), chip_next = _chip(); document.body.appendChild(ui); function _hud() { chip_cash.textContent = '💰 ' + cash + ' ' + cur; chip_placed.textContent = '🏪 ' + placed + ' / ' + tiles.length; var nxt = null; for (var ni = 0; ni < defs.length; ni++) if (!defs[ni].unlocked) { nxt = defs[ni]; break; } chip_next.textContent = nxt ? '🔓 ' + nxt.label : '🏆 ' + lmk.label + ' · ' + lmk.cost + ' ' + cur; } _hud(); if (c.hint) P.toast(String(c.hint).slice(0, 60)); // ── Build bar (bottom sheet — opens on empty-tile tap) ── var bar = document.createElement('div'); bar.dataset.mgmtUi = '1'; bar.style.cssText = 'position:fixed;left:50%;transform:translateX(-50%);bottom:calc(16px + env(safe-area-inset-bottom,0px));z-index:23;display:none;gap:8px;padding:10px;border-radius:14px;background:rgba(14,18,26,.9);flex-wrap:wrap;justify-content:center;max-width:94vw;'; bar.addEventListener('pointerdown', function (ev) { ev.stopPropagation(); }); document.body.appendChild(bar); function _close_bar() { bar.style.display = 'none'; sel = null; sel_ring.visible = false; } function _open_bar(t) { bar.innerHTML = ''; defs.forEach(function (d, di) { var b = document.createElement('button'); var locked = !d.unlocked; b.style.cssText = 'padding:9px 12px;border-radius:11px;border:1px solid rgba(255,255,255,.22);background:' + (locked ? 'rgba(40,44,52,.7)' : 'rgba(30,40,52,.95)') + ';color:' + (locked ? '#8a93a1' : '#fff') + ';font:700 13px system-ui;cursor:pointer;'; b.textContent = (locked ? '🔒 ' : '') + d.label + ' · ' + d.cost + ' ' + cur; b.addEventListener('click', function () { _place(t, di); }); bar.appendChild(b); }); if (placed >= defs.length) { var lb = document.createElement('button'); lb.style.cssText = 'padding:9px 12px;border-radius:11px;border:1px solid rgba(255,220,120,.5);background:rgba(72,58,20,.92);color:#ffe58a;font:800 13px system-ui;cursor:pointer;'; lb.textContent = '🏆 ' + lmk.label + ' · ' + lmk.cost + ' ' + cur; lb.addEventListener('click', function () { _place_landmark(t); }); bar.appendChild(lb); } bar.style.display = 'flex'; } // Tile select — fed by the management rig's tap channel (drag/pinch = camera, still tap = select) var _ray = new THREE_.Raycaster(), _ndc = new THREE_.Vector2(); var cam = P.three.camera; function _tile_tap(ev) { if (complete) return; _ndc.set((ev.clientX / window.innerWidth) * 2 - 1, -(ev.clientY / window.innerHeight) * 2 + 1); _ray.setFromCamera(_ndc, cam); var hit = _ray.intersectObject(slabm, false)[0]; if (!hit) { _close_bar(); return; } var i = Math.floor((hit.point.x + W / 2) / TILE), j = Math.floor((hit.point.z + D / 2) / TILE); if (i < 0 || i >= COLS || j < 0 || j >= ROWS) { _close_bar(); return; } var t = tile_at(i, j); if (t.b) { P.toast(t.b.def.label + (t.b.def.income ? ' · +' + t.b.def.income + ' ' + cur : '')); _close_bar(); return; } sel = t; var ct = tile_center(i, j); sel_ring.position.set(ct.x, Y + 0.1, ct.z); sel_ring.visible = true; _open_bar(t); fx.sfx('pickup', { volume: 0.25 }); } // ── Management camera rig (pan/zoom/aspect framing + tap channel) ── var _cd = Math.max(W, D + 8); _mgmt_camera({ center: { x: 0, z: 1.5 }, y: Y, dir: { x: 0.62, y: 0.92, z: -1.05 }, base_dist: _cd * 1.53, extent_x: W + 14, extent_z: D + 18, look_lift: 0.4, on_tap: _tile_tap, }); // ── Visitors (peg-people fallback / roster characters) ── var _people = (typeof window !== 'undefined' && window['__PLAYABLE_'+'EXTRA_MANIFEST__'] && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].people) || null; var PEG_COLORS = ['#e8734a', '#3dbb7f', '#4a8fd4', '#e0c04a', '#c95f5f', '#9a6fd4']; var _peg_ci = 0; function _mk_peg() { var g = new THREE_.Group(); var col = PEG_COLORS[_peg_ci++ % PEG_COLORS.length]; var body = new THREE_.Mesh(new THREE_.CylinderGeometry(0.22, 0.3, 0.85, 10), P.make.matte(col, { roughness: 0.6 })); body.position.y = 0.55; g.add(body); var head = new THREE_.Mesh(new THREE_.SphereGeometry(0.21, 10, 8), P.make.matte('#f2d1b3')); head.position.y = 1.2; g.add(head); return g; } function _new_visitor() { var name = _people && _people.length ? _people[Math.floor(prng() * _people.length)] : null; if (name && _extm[name]) { var h = world.spawn(name, { at: { x: 0, z: -200 }, h: 1.55, collide: false, _no_uni: true }); h.play('walk'); return { obj: h.obj, kill: function () { h.remove(); }, play: function (a) { h.play(a); } }; } var g = _mk_peg(); g.position.set(0, Y, -200); scene.add(g); return { obj: g, kill: function () { scene.remove(g); }, play: function () {} }; } var _vis = []; function _spawn_visitor() { var cands = tiles.filter(function (t) { return t.b && t.b.def.income; }); if (!cands.length) return; var tgt = cands[Math.floor(prng() * cands.length)]; var ct = tile_center(tgt.i, tgt.j); var v = _new_visitor(); v.obj.position.set((prng() - 0.5) * 2, Y, D / 2 + 8); _vis.push({ v: v, tgt: tgt, wp: [[(prng() - 0.5) * 2, D / 2 + 1.5], [ct.x, ct.z + TILE * 0.42]], wi: 0, state: 'in', t: 0, bob: prng() * 6 }); } // ── Single loop (camera · income ticks · visitors) ── var arrive_t = 4, cam_t = 0; var _priv_fast = false; P.loop(function (dt) { if (dt <= 0) return; dt = Math.min(dt, 0.1); var mul = _priv_fast ? 5 : 1; cam_t += dt; // visitor bob phase (the camera itself is rig-owned) if (!complete) { // passive income per building for (var bi = 0; bi < tiles.length; bi++) { var t = tiles[bi]; if (!t.b || !t.b.def.income) continue; t.b.income_t -= dt * mul; if (t.b.income_t <= 0) { t.b.income_t = 4.2 + prng() * 1.6; cash += t.b.def.income; earned += t.b.def.income; var ct2 = tile_center(t.i, t.j); fx.float_text({ x: ct2.x, y: Y + 2.6, z: ct2.z }, '+' + t.b.def.income, { color: pal.accent }); _hud(); A._emit('income', { label: t.b.def.label, amount: t.b.def.income, cash: cash }); } } // visitor arrivals scale with how built-out the lot is arrive_t -= dt * mul; if (arrive_t <= 0) { arrive_t = clamp(6.5 - placed * 0.55, 1.6, 8); _spawn_visitor(); } } for (var vi = _vis.length - 1; vi >= 0; vi--) { var rec = _vis[vi]; var o = rec.v.obj.position; if (rec.state === 'in' || rec.state === 'out') { var tp = rec.wp[rec.wi]; if (!tp) { rec.state = rec.state === 'in' ? 'pay' : 'gone'; continue; } var dx = tp[0] - o.x, dz = tp[1] - o.z; var dd = Math.sqrt(dx * dx + dz * dz); if (dd < 0.2) { rec.wi++; continue; } var mv = Math.min(dd, 2.6 * mul * dt); o.x += dx / dd * mv; o.z += dz / dd * mv; o.y = Y + Math.abs(Math.sin((cam_t + rec.bob) * 9)) * 0.05; // peg hop (GLB walk clips override visually) rec.v.obj.rotation.y = Math.atan2(dx, dz); } else if (rec.state === 'pay') { rec.t += dt * mul; if (rec.t > 1.1) { var pay = Math.max(2, Math.round(rec.tgt.b ? rec.tgt.b.def.income * 0.6 : 2)); cash += pay; earned += pay; visitors_served++; fx.float_text({ x: o.x, y: Y + 2.1, z: o.z }, '+' + pay + ' ' + cur, { color: '#ffe58a' }); fx.sfx('pickup', { volume: 0.3 }); _hud(); A._emit('visitor', { paid: pay, served: visitors_served, cash: cash }); rec.state = 'out'; rec.wp = [[(prng() - 0.5) * 2, D / 2 + 1.5], [(prng() - 0.5) * 2, D / 2 + 9]]; rec.wi = 0; } } else { // gone rec.v.kill(); _vis.splice(vi, 1); } } }); // ── Victory + share ── function _win() { if (complete) return; complete = true; _close_bar(); P.music('triumph'); fx.screen_flash(pal.key); fx.burst({ x: 0, y: Y + 6, z: 0 }, { count: 44, color: '#ffd24a', speed: 5, life: 1.5, gravity: -0.4 }); P.toast(String(c.complete_text || _at_t(lmk.label + ' 완공! 최고의 플라자예요 🏆', lmk.label + '完成! 🏆', lmk.label + ' built! Best plaza in town 🏆')).slice(0, 60)); A._emit('complete', { earned: earned, placed: placed, visitors: visitors_served }); setTimeout(function () { P.three.capture().then(function (shot) { P.share_shot(shot, { title: String(c.title || _at_t('나의 플라자', 'マイプラザ', 'My Plaza')).slice(0, 40), lines: ['🏪 ' + placed + ' · 👥 ' + visitors_served + ' · 💰 ' + earned + ' ' + cur], share_text: String(c.share_text || _at_t('나만의 플라자를 완성했어요 🏪 방문객 ' + visitors_served + '명', 'マイプラザ完成 🏪', 'I built my plaza 🏪 ' + visitors_served + ' visitors served')).slice(0, 120), }); }); }, 900); } P.mount_share(function () { return String(c.share_text || _at_t('플라자 확장 중 🏪 ' + placed + '개 배치 · 💰 ' + cash + ' ' + cur, 'プラザ拡張中 🏪', 'Growing my plaza 🏪 ' + placed + ' built · 💰 ' + cash)).slice(0, 120); }, String(c.share_label || _at_t('공유', '共有', 'Share')).slice(0, 12)); A._installed = 'plot_tycoon'; return { get cash() { return cash; }, get earned() { return earned; }, get placed() { return placed; }, get visitors() { return visitors_served; }, get complete() { return complete; }, grid: [COLS, ROWS], buildings: defs.length, _priv: { place: function (i, j, di) { return _place(tile_at(clamp(i | 0, 0, COLS - 1), clamp(j | 0, 0, ROWS - 1)), clamp(di | 0, 0, defs.length - 1)); }, place_landmark: function (i, j) { return _place_landmark(tile_at(clamp(i | 0, 0, COLS - 1), clamp(j | 0, 0, ROWS - 1))); }, tiles: tiles, defs: defs, spawn_visitor: _spawn_visitor, visitors: _vis, set fast(v) { _priv_fast = !!v; }, get fast() { return _priv_fast; }, }, }; }; // ═══ 아키타입 #5: battle_royale — BR-lite(수축 스톰+루팅 티어+봇전, 완비·골든-테스트 대상) ═══ // 정본(리서치 확정): "BR vs 봇"은 위장이 아니라 정본(포트나이트 OG 로비 88-92%가 봇 실측). // 존 3페이즈+파이널(대기 45/30/20s), 봇 상호 교전 = 페이크 처리(확률 타이머+킬피드+원거리 총성 — // 실연산 불요), 루팅 = 레어도 5색 티어 업(데미지 +9%/티어 — WoW 장르 관습 SAFE). // IP 경계: "Victory Royale" 문구·라마·실에셋만 금지 — 존 색은 자홍 변주, 승리 문구 자체 명명. _ARCH.battle_royale = function (cfg) { var c = cfg || {}; if (!P.world || !P.world.fps) { warn('fps family missing — falling back to meadow_explorer'); return _ARCH.meadow_explorer(cfg); } var mood_req = c.mood === 'night' ? 'night_neon' : c.mood; if (mood_req === 'night_neon' || mood_req === 'ember' || mood_req === 'dusk') { warn('dark mood "' + mood_req + '" demoted to sunset — archetype starts in daylight'); mood_req = 'sunset'; } var mood_name = pick(mood_req, P.moods || [], 'noon'); var pal = P.mood(mood_name); P.music(pick(c.music, ['calm_exploration', 'adventure', 'cozy', 'mystery', 'night', 'chiptune_action', 'battle', 'triumph', 'space', 'spooky', 'lofi', 'sad', 'happy'], 'lofi')); P.ambience(pick(c.ambience, ['forest', 'wind', 'rain', 'night', 'ocean', 'cave', 'crowd'], 'wind')); P.enable_bloom({ strength: 0.55, radius: 0.5, threshold: 1.05 }); var world = P.world, fx = P.fx, nature = P.nature; var seed = (num(c.seed, 9) | 0); nature.wind({ strength: 0.5, gustiness: 0.4, direction: { x: 0.8, z: 0.3 } }); var gr = pal.ground_ramp || [pal.ground, pal.ground, pal.ground, pal.ground]; var terrain = world.terrain({ seed: seed, amplitude: clamp(num(c.amplitude, 4), 1, 7), scale: 130, chunk: 32, radius: 3 }); nature.grass_field({ area: 150, count: 20000, base: gr[1], tip: gr[3], height: 0.5, y: terrain.height_at }); nature.clouds({ count: 12, style: 'puffy' }); nature.ground_cover({ density: 1 }); var tree = new THREE_.Group(); var t1 = new THREE_.Mesh(new THREE_.CylinderGeometry(0.22, 0.3, 1.6, 7), P.make.matte(gr[0])); t1.position.y = 0.8; tree.add(t1); var t2 = new THREE_.Mesh(new THREE_.ConeGeometry(1.1, 2.1, 9), P.make.matte(gr[2])); t2.position.y = 2.2; tree.add(t2); world.scatter('tree', { per_chunk: 7, scale: [0.8, 1.3], collide_r: 0.7, fallback: tree }); // ── POI 구조물 3개소(전장 랜드마크 — 루팅 밀집과 결부) ── var prng_poi = world._priv.mulberry32((seed * 26339) >>> 0); var _pois = []; for (var poi = 0; poi < 3; poi++) { (function () { var pang = prng_poi() * Math.PI * 2, prr = 25 + prng_poi() * 50; var px5 = Math.cos(pang) * prr, pz5 = Math.sin(pang) * prr; var py5 = world.height_at(px5, pz5); var st5 = new THREE_.Group(); var wallm = P.make.matte(gr[0], { roughness: 0.9 }); var w1 = new THREE_.Mesh(new THREE_.BoxGeometry(6, 2.4, 0.5), wallm); w1.position.set(0, 1.2, -3); st5.add(w1); var w2 = new THREE_.Mesh(new THREE_.BoxGeometry(0.5, 2.4, 6), wallm); w2.position.set(-3, 1.2, 0); st5.add(w2); var tw5 = new THREE_.Mesh(new THREE_.BoxGeometry(2, 5, 2), P.make.matte(gr[1], { roughness: 0.85 })); tw5.position.set(2, 2.5, 2); st5.add(tw5); st5.position.set(px5, py5, pz5); scene.add(st5); world.marker(tw5, { label: '보급 거점' }); _pois.push({ x: px5, z: pz5 }); })(); } var ended = false, placement = null, kills = 0; var player = world.player({ spawn: 'adventurer', at: { x: 0, z: 0 }, speed: 4.4, run_speed: 7.6, fly: false, glide: true, hp: 5, on_player_death: function () { if (!ended) _end(false); }, }); world.controls({ mission: c.hint ? String(c.hint) : '' }); // fps 는 controls 를 루프에서 지연 획득 — 미션 문구는 선설치가 정본 var R0 = clamp(num(c.zone_r, 105), 60, 140); // 실 테마 프롭(브리프-추론 배선 — 우주/중세/해적 등 브리프 팩, 미주입 시 없음). 지형 접지, 커버 // 오브젝트로 배치(링, 스폰 원점·POI 근접 회피). prng 독립 스트림(다운스트림 desync 방지). var _BR_PROPS = (typeof window !== 'undefined' && window['__PLAYABLE_'+'EXTRA_MANIFEST__'] && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].stageprops && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].stageprops.items) || null; if (_BR_PROPS && _BR_PROPS.length) { var _bpp = world._priv.mulberry32(((seed * 13337) ^ 0x51ED2701) >>> 0), _bn = 18; for (var _bi = 0; _bi < _bn; _bi++) { var _brd = 22 + _bpp() * (R0 * 0.6 - 22), _ba = (_bi / _bn) * 6.283 + _bpp() * 0.5; var _bx = Math.cos(_ba) * _brd, _bz = Math.sin(_ba) * _brd, _bskip = false; for (var _pi = 0; _pi < _pois.length; _pi++) { var _pdx = _bx - _pois[_pi].x, _pdz = _bz - _pois[_pi].z; if (_pdx * _pdx + _pdz * _pdz < 49) { _bskip = true; break; } } if (!_bskip) _spawn_stage_prop(_BR_PROPS[_bi % _BR_PROPS.length], _bx, _bz, null, { collide: true, cap_h: 3.0 }); } } var mm = world.minimap({ range: Math.round(R0 * 1.15) }); // 존 전체가 지도 안에 — 스톰 가독성 정본 // 스카이-드롭 인트로 — 고고도 강하(점프 홀드 = 활공), 착지 전엔 스톰 카운트다운 정지 var _dropped = false; (function () { var PLd = world._priv.player(); if (PLd) { PLd.handle.obj.position.y += 45; PLd.vy = 0; PLd.on_ground = false; } P.toast('🪂 전장으로 강하!'); })(); var gun = world.fps({ weapon: c.weapon == null ? undefined : c.weapon, fire_rate: 4.5, damage: 1, range: 45, spread: 2.4, on_hit: function (api) { // 킬 귀속 재료 — 봇 상호 실교전 도입으로 'YOU' 단정이 깨짐(최근 4.5s 내 플레이어 타격 = 플레이어 킬) for (var hi2 = 0; hi2 < bots.length; hi2++) if (bots[hi2].h === api) { bots[hi2].hit_t = performance.now(); break; } }, }); // ── 봇(실전 = combat 원거리 FSM, 상호 교전 = 페이크) ── var NAMES = ['Ace', 'Bolt', 'Coco', 'Dash', 'Echo', 'Fizz', 'Gogo', 'Hilo', 'Iris', 'Juno', 'Kilo', 'Lumo', 'Mint', 'Nova']; var diff = pick(c.difficulty, ['easy', 'normal', 'hard'], 'normal'); var BOT = { easy: { hp: 2, dmg: 1, aggro: 18 }, normal: { hp: 3, dmg: 1, aggro: 24 }, hard: { hp: 4, dmg: 1, aggro: 30 } }[diff]; var nbots = clamp(Math.round(num(c.bots, 10)), 5, 14); var prng = world._priv.mulberry32(((seed * 40503) ^ 0x9E3779B9) >>> 0); var CHARS = ['knight', 'rogue', 'barbarian', 'worker', 'robot', 'striker']; var bots = []; var alive = nbots + 1; for (var bi = 0; bi < nbots; bi++) { (function (idx) { var ang = prng() * Math.PI * 2, rr = 30 + prng() * 60; var name = NAMES[idx % NAMES.length]; var h = world.mob(CHARS[idx % CHARS.length], { at: { x: Math.cos(ang) * rr, z: Math.sin(ang) * rr }, // FFA 개별 팀 라벨 = 근접 봇끼리 실교전(combat 타겟 프록시 — 라벨 부등 = 상호 적대). // 행진 = 스폰↔중심 중점(반수렴): 전원 중앙 집결은 동시 액터 예산(~8) 초과 — 밀도만 올린다. // 원거리 교전은 종전대로 페이크 페이싱이 소유(하이브리드 정본). team: 'br_' + idx, march: { x: Math.cos(ang) * rr * 0.5, z: Math.sin(ang) * rr * 0.5 }, behavior: 'ranged', hp: BOT.hp, damage: BOT.dmg, aggro: BOT.aggro, leash: 120, speed: 0.75, on_death: function () { if (ended) return; var rec = bots[idx]; if (!rec || rec.gone) return; rec.gone = true; alive--; if (rec.hit_t && performance.now() - rec.hit_t < 4500) { kills++; _feed('YOU', name); } // 플레이어 킬(최근 타격 귀속) else { // 봇 상호 실교전 전사 — 최근접 생존 봇을 처치자로 표기 var killer = null, kd = Infinity; for (var k2 = 0; k2 < bots.length; k2++) { var b2 = bots[k2]; if (b2 === rec || b2.gone || !b2.h) continue; var kdx = b2.h.pos.x - rec.h.pos.x, kdz = b2.h.pos.z - rec.h.pos.z; var kd2 = kdx * kdx + kdz * kdz; if (kd2 < kd) { kd = kd2; killer = b2; } } _feed(killer ? killer.name : 'Storm', name); } _hud(); _check_win(); }, }); bots.push({ h: h, name: name, gone: false, hit_t: 0 }); })(bi); } // ── 스톰 존(수축 원형 벽 — 자홍 변주, 존 밖 = 주기 피해) — R0 는 미니맵 배선 지점에서 선언 ── var zr = R0, zphase = 0, ztimer = clamp(num(c.first_wait, 45), 10, 90), zshrinking = false; // 페이즈 정본: 대기 45/30/20s → 수축 35/28/22s → 반경 ×0.55/0.42/0.3 → 파이널 40s 수축 → 0 var ZP = [ { wait: 45, dur: 35, to: 0.55, tick: 2.0 }, { wait: 30, dur: 28, to: 0.42, tick: 1.2 }, { wait: 20, dur: 22, to: 0.3, tick: 0.7 }, { wait: 12, dur: 40, to: 0.02, tick: 0.45 }, ]; ZP[0].wait = ztimer; var zmat = new THREE_.MeshBasicMaterial({ color: '#d24aff', transparent: true, opacity: 0.16, side: 2, depthWrite: false, fog: false }); // fog.far(~115m) < 존 반경 — 안개 무시로 원거리 백화 봉합 var zwall = new THREE_.Mesh(new THREE_.CylinderGeometry(1, 1, 46, 48, 1, true), zmat); zwall.position.y = 20; zwall.scale.set(zr, 1, zr); scene.add(zwall); var _zfrom = zr, _zt = 0, _hurt_acc = 0, _bhurt_acc = 0; // ── 루팅(보급 광석: 무기 티어 업 + 회복) — 레어도 5색 사다리 ── var TIERS = [ { name: '커먼', color: '#9da3ab', dmg: 1, rate: 4.5, spread: 2.4 }, { name: '언커먼', color: '#3ddc84', dmg: 1.1, rate: 4.8, spread: 2.1 }, { name: '레어', color: '#4aa8ff', dmg: 1.2, rate: 5.1, spread: 1.9 }, { name: '에픽', color: '#c26bff', dmg: 1.32, rate: 5.4, spread: 1.7 }, { name: '레전더리', color: '#ffd24a', dmg: 1.45, rate: 5.8, spread: 1.5 }, ]; var tier = 0; function _apply_tier() { var t = TIERS[tier]; if (gun && gun.set_weapon) gun.set_weapon({ fire_rate: t.rate, damage: t.dmg, spread: t.spread, tint: t.color }); // 티어 색 = 뷰모델 발광(체감 피드백) _hud(); } var prng2 = world._priv.mulberry32((seed * 15731) >>> 0); for (var li = 0; li < 7; li++) { (function (li2) { var ang = prng2() * Math.PI * 2, rr = 15 + prng2() * 70; var x = Math.cos(ang) * rr, z = Math.sin(ang) * rr; if (li2 < _pois.length) { // 선두 3개 = POI 밀집(구조물 = 루팅 거점) x = _pois[li2].x + (prng2() - 0.5) * 6; z = _pois[li2].z + (prng2() - 0.5) * 6; } var h = world.spawn('gold', { at: { x: x, z: z }, h: 0.5, collide: false }); world.collectible(h, { magnet: 5, on_collect: function () { if (tier < TIERS.length - 1) { tier++; _apply_tier(); P.toast('무기 티어 업 — ' + TIERS[tier].name + '!'); fx.sfx('powerup'); } A._emit('loot', { tier: tier }); } }); })(li); } for (var hi = 0; hi < 3; hi++) { (function () { var ang = prng2() * Math.PI * 2, rr = 15 + prng2() * 65; var h = world.spawn('potion_red', { at: { x: Math.cos(ang) * rr, z: Math.sin(ang) * rr }, h: 0.45, collide: false }); world.collectible(h, { magnet: 5, on_collect: function () { var PL2 = world._priv.player(); if (PL2) PL2.hp = Math.min(PL2.hp_max, PL2.hp + 1); fx.sfx('pickup'); P.toast('+1 ❤'); } }); })(); } // ── 킬피드(우상단, 최근 3줄) ── var feed = document.createElement('div'); feed.style.cssText = 'position:fixed;right:12px;top:calc(96px + env(safe-area-inset-top,0px));z-index:20;color:#fff;font:600 12px system-ui;text-align:right;pointer-events:none;text-shadow:0 1px 6px rgba(0,0,0,.6);'; document.body.appendChild(feed); var _feed_lines = []; function _feed(a, b) { _feed_lines.push(a + ' ⚔ ' + b); if (_feed_lines.length > 3) _feed_lines.shift(); feed.innerHTML = _feed_lines.map(function (l) { return '
' + l + '
'; }).join(''); } // ── 페이크 봇전(확률 타이머 — alive 를 기대 곡선으로 수렴, 실봇은 최후 2기까지 보존) ── var match_t = 0, _fake_t = 0; var TOTAL_S = 0; for (var zi = 0; zi < ZP.length; zi++) TOTAL_S += ZP[zi].wait + ZP[zi].dur; function _real_alive() { var n = 0; for (var i2 = 0; i2 < bots.length; i2++) if (!bots[i2].gone) n++; return n; } function _fake_kill() { var pool = NAMES.filter(function (n2) { for (var i3 = 0; i3 < bots.length; i3++) if (!bots[i3].gone && bots[i3].name === n2) return false; return true; }); var a2 = pool[Math.floor(prng2() * pool.length)] || 'Zed'; var b2 = NAMES[Math.floor(prng2() * NAMES.length)]; if (a2 === b2) b2 = b2 + '2'; _feed(a2, b2); fx.sfx('shoot', { volume: 0.18 }); alive--; // 실봇 정리(원거리 우선) — 성능 예산 보존, 단 최후 2기는 실봇 유지(파이널 교전 보장) if (_real_alive() > 2 && prng2() < 0.5) { var far = null, fard = -1; var pp2 = world._priv.player().handle.obj.position; for (var i4 = 0; i4 < bots.length; i4++) { if (bots[i4].gone) continue; var dd2 = (bots[i4].h.pos.x - pp2.x) * (bots[i4].h.pos.x - pp2.x) + (bots[i4].h.pos.z - pp2.z) * (bots[i4].h.pos.z - pp2.z); if (dd2 > fard) { fard = dd2; far = bots[i4]; } } if (far) { far.gone = true; far.h.remove(); } } _hud(); _check_win(); } // ── HUD(생존/킬/티어/존 타이머) ── var ui = _arch_hud(c.title || '최후의 1인'); function _hud() { ui.children[1].textContent = '👥 ' + alive + ' · ⚔ ' + kills + ' · ' + TIERS[tier].name; ui.children[1].style.borderLeft = '3px solid ' + TIERS[tier].color; } _hud(); function _ztext(s) { ui.children[2].textContent = s; } // ── 매치 루프(존 수축·존 피해·페이크 페이싱) ── P.loop(function (dt) { if (ended || dt <= 0) return; dt = Math.min(dt, 0.1); match_t += dt; // 스카이-드롭 게이트 — 착지 전엔 스톰 카운트다운만 정지(봇/HUD/페이싱은 계속) if (!_dropped) { var _pld = world._priv.player(); if (match_t > 0.5 && _pld && _pld.on_ground) _dropped = true; else _ztext('🪂 강하 중'); } // 존 상태기계 if (zphase < ZP.length) { var Z = ZP[zphase]; if (!zshrinking) { if (_dropped) ztimer -= dt; if (_dropped) _ztext('⛈ ' + Math.max(0, Math.ceil(ztimer)) + 's'); if (ztimer <= 0) { zshrinking = true; _zfrom = zr; _zt = 0; P.toast('스톰이 수축합니다!'); } } else { _zt += dt; var f = Math.min(1, _zt / Z.dur); zr = _zfrom + (R0 * Z.to - _zfrom) * f; _ztext('⛈ 수축 중'); if (f >= 1) { zphase++; zshrinking = false; ztimer = zphase < ZP.length ? ZP[zphase].wait : 0; } } zwall.scale.set(Math.max(1, zr), 1, Math.max(1, zr)); if (mm && mm.set_zone) mm.set_zone(zr); // 미니맵 존 링 동기 } else _ztext('⛈ 최종 존'); // 존 밖 피해(주기 = 페이즈 tick) var pp3 = world._priv.player().handle.obj.position; var pd = Math.sqrt(pp3.x * pp3.x + pp3.z * pp3.z); if (pd > zr) { _hurt_acc += dt; var tick = ZP[Math.min(zphase, ZP.length - 1)].tick; if (_hurt_acc >= tick) { _hurt_acc = 0; if (world._priv.player_hurt) world._priv.player_hurt(1); } } else _hurt_acc = 0; // 존 밖 봇 피해(플레이어와 동일 주기 — 킬피드 'Storm' 귀속은 기존 on_death 가 처리) _bhurt_acc += dt; var btick = ZP[Math.min(zphase, ZP.length - 1)].tick; if (_bhurt_acc >= btick) { _bhurt_acc = 0; for (var zb = 0; zb < bots.length; zb++) { var brec = bots[zb]; if (brec.gone || !brec.h || !brec.h.hurt) continue; if (Math.sqrt(brec.h.pos.x * brec.h.pos.x + brec.h.pos.z * brec.h.pos.z) > zr) brec.h.hurt(1); } } // 페이크 봇전 페이싱 — 기대 생존 곡선(잔여시간 거듭제곱)으로 수렴 _fake_t += dt; if (_fake_t > 3) { _fake_t = 0; var frac = Math.max(0, 1 - match_t / TOTAL_S); var expected = 1 + Math.round(nbots * Math.pow(frac, 1.25)); if (alive > expected + 1 && alive > 3) _fake_kill(); } }); // ── 승패 ── function _check_win() { if (!ended && alive <= 1) _end(true); } function _end(won) { if (ended) return; ended = true; placement = won ? 1 : alive; if (won) { P.music('triumph'); fx.screen_flash(pal.key); P.toast(String(c.complete_text || '최후의 1인! 🏆').slice(0, 60)); } else { P.toast('#' + placement + ' 로 탈락 — 다시 도전!'); } A._emit('complete', { won: won, placement: placement, kills: kills }); P.leaderboard.submit(kills); setTimeout(function () { P.three.capture().then(function (shot) { P.share_shot(shot, { title: String(c.title || '최후의 1인').slice(0, 40), lines: ['#' + placement + ' · ⚔ ' + kills + ' 킬'], share_text: String(c.share_text || ('배틀 로열에서 #' + placement + ' · ' + kills + '킬 ⚔')).slice(0, 120), }); }); }, 900); } P.mount_share(function () { return String(c.share_text || ('배틀 로열 ' + (placement ? '#' + placement : '생존 중') + ' · ⚔ ' + kills + '킬')).slice(0, 120); }, String(c.share_label || '공유').slice(0, 12)); P.leaderboard.enable({ metric: 'kills', unit: '', max: 14, higher_better: true, min_play_s: 6 }); A._installed = 'battle_royale'; return { player: player, get alive() { return alive; }, get kills() { return kills; }, get tier() { return tier; }, get zone_r() { return zr; }, get ended() { return ended; }, _priv: { fake_kill: _fake_kill, bots: bots } }; }; // ═══ 아키타입 #6: cozy_island — 코지 라이프(낚시·채집·주민 요청, 완비·골든-테스트 대상) ═══ // 정본(리서치 확정): 세션 목표 = 주민 요청 3건 이행(채집이 요청의 재료 공급원인 이중 루프 — // "동물이 나를 알아봐 준다"가 AC 정서 코어). 코지 원칙: 실패 상태 0·시간 압박 0·풍요·골든아워. // 30% 확률 60s 소나기 = 희귀어 2×(고지 토스트). 데코는 5분판 비필수(A Short Hike 선례). _ARCH.cozy_island = function (cfg) { var c = cfg || {}; if (!P.cozy) { warn('cozy family missing — falling back to meadow_explorer'); return _ARCH.meadow_explorer(cfg); } var mood_req = c.mood === 'night' ? 'night_neon' : c.mood; if (mood_req === 'night_neon' || mood_req === 'ember' || mood_req === 'dusk') { warn('dark mood "' + mood_req + '" demoted to sunset — archetype starts in daylight'); mood_req = 'sunset'; } var mood_name = pick(mood_req, P.moods || [], 'sunset'); // 골든아워 기본(코지 정본) var pal = P.mood(mood_name); P.music(pick(c.music, ['calm_exploration', 'adventure', 'cozy', 'mystery', 'night', 'chiptune_action', 'battle', 'triumph', 'space', 'spooky', 'lofi', 'sad', 'happy'], 'lofi')); P.ambience(pick(c.ambience, ['forest', 'wind', 'rain', 'night', 'ocean', 'cave', 'crowd'], 'forest')); P.enable_bloom({ strength: 0.55, radius: 0.5, threshold: 1.05 }); var world = P.world, cozy = P.cozy, fx = P.fx, nature = P.nature; var seed = (num(c.seed, 13) | 0); nature.wind({ strength: 0.5, gustiness: 0.4, direction: { x: 0.8, z: 0.3 } }); var gr = pal.ground_ramp || [pal.ground, pal.ground, pal.ground, pal.ground]; var terrain = world.terrain({ seed: seed, amplitude: clamp(num(c.amplitude, 3), 1, 6), scale: 130, chunk: 32, radius: 3 }); nature.grass_field({ area: 150, count: 24000, base: gr[1], tip: gr[3], height: 0.55, y: terrain.height_at }); nature.clouds({ count: 14, style: 'puffy' }); nature.ground_cover({ density: 1.2 }); nature.fireflies({ count: 24 }); // 코지 앰비언스 스택(기확보 완제품) // 연못(낚시터) — make.water 계층 사다리 재사용. 위치 = 반경 22m 표본 12각 중 지형 최저점 // (물 평면은 고정 y — 지형이 y 아래로 꺼진 곳만 수면이 드러나므로 국소 저지가 연못의 정답) var prng = world._priv.mulberry32(((seed * 74747) ^ 0x5F3759DF) >>> 0); var pond = { x: 22, z: 0, r: 9 }; var _pmin = Infinity; for (var pi = 0; pi < 12; pi++) { var pa2 = (pi / 12) * Math.PI * 2; var px2 = Math.cos(pa2) * 22, pz2 = Math.sin(pa2) * 22; var ph2 = world.height_at(px2, pz2); if (ph2 < _pmin) { _pmin = ph2; pond.x = px2; pond.z = pz2; } } P.make.water({ x: pond.x, z: pond.z, size: pond.r * 2, y: _pmin + 0.3 }); // 실 아늑 데코(furn_/fbit_ 가구·식물 배선, 미주입 시 없음). 섬에 산포(연못+2·스폰 원점 회피), // 지형 접지. prng 독립 스트림(단일 공유 prng desync 방지). var _CZ_PROPS = (typeof window !== 'undefined' && window['__PLAYABLE_'+'EXTRA_MANIFEST__'] && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].stageprops && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].stageprops.items) || null; if (_CZ_PROPS && _CZ_PROPS.length) { var _cpp = world._priv.mulberry32(((seed * 20873) ^ 0x27D4EB2F) >>> 0), _cn = 16; for (var _czi = 0; _czi < _cn; _czi++) { var _crd = 6 + _cpp() * 18, _cza = _cpp() * 6.283; var _cx = Math.cos(_cza) * _crd, _cz = Math.sin(_cza) * _crd, _cpx = _cx - pond.x, _cpz = _cz - pond.z; if (_cpx * _cpx + _cpz * _cpz < (pond.r + 2) * (pond.r + 2)) continue; // 연못 회피 if (_cx * _cx + _cz * _cz < 16) continue; // 스폰 원점 회피 _spawn_stage_prop(_CZ_PROPS[_czi % _CZ_PROPS.length], _cx, _cz, null, { collide: true, cap_h: 2.4 }); } } var player = world.player({ spawn: _obby_avatar(c), at: { x: 0, z: 0 }, speed: 4, run_speed: 6.8 }); world.controls({ mission: c.hint ? String(c.hint) : '' }); world.minimap(); // ── 낚시(1순위 동사) — 스팟 3개(연못 가장자리) ── cozy.fishing({ spots: [ { x: pond.x + pond.r * 0.7, z: pond.z }, { x: pond.x - pond.r * 0.6, z: pond.z + pond.r * 0.5 }, { x: pond.x, z: pond.z - pond.r * 0.7 }, ], fish: Array.isArray(c.fish) ? c.fish.slice(0, 8).map(function (f) { return { name: String((f && f.name) || '물고기').slice(0, 14), tier: pick(f && f.tier, ['C', 'U', 'R'], 'C'), price: clamp(Math.round(num(f && f.price, 400)), 50, 20000), pun: f && f.pun ? String(f.pun).slice(0, 40) : null }; }) : undefined, rain_fish: c.rain_fish, label: String(c.fishing_label || '낚시').slice(0, 8), }); // ── 과일나무 3그루 + 화석 스팟 3개 ── var _extm2 = (typeof window !== 'undefined' && window['__PLAYABLE_'+'EXTRA_MANIFEST__'] && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].models) || {}; var _kit_trees2 = Object.keys(_extm2).filter(function (n) { var t = _extm2[n].tags || []; return (t.indexOf('tree') >= 0 || t.indexOf('nature') >= 0) && num(_extm2[n].h, 0) >= 2.2 && !/flower|bush|grass|rock/.test(n); }); for (var ti = 0; ti < 3; ti++) { var ta = prng() * Math.PI * 2, tr = 10 + prng() * 18; var tx = Math.cos(ta) * tr, tz = Math.sin(ta) * tr; for (var tj = 0; tj < 8 && (tx - pond.x) * (tx - pond.x) + (tz - pond.z) * (tz - pond.z) < (pond.r + 1.5) * (pond.r + 1.5); tj++) { ta = prng() * Math.PI * 2; tr = 10 + prng() * 18; // 연못 원판 내 착지 = 수중 나무 — 재추첨(상한 8, 결정론 prng) tx = Math.cos(ta) * tr; tz = Math.sin(ta) * tr; } var th; if (_kit_trees2.length) th = world.spawn(_kit_trees2[ti % _kit_trees2.length], { at: { x: tx, z: tz } }); else { // 프리미티브 폴백(무빈땅 보장 불변) var tg = new THREE_.Group(); var tt = new THREE_.Mesh(new THREE_.CylinderGeometry(0.22, 0.3, 1.8, 7), P.make.matte(gr[0])); tt.position.y = 0.9; tg.add(tt); var tc = new THREE_.Mesh(new THREE_.SphereGeometry(1.3, 10, 8), P.make.matte(gr[2])); tc.position.y = 2.6; tg.add(tc); tg.position.set(tx, world.height_at(tx, tz), tz); scene.add(tg); th = { obj: tg }; } cozy.shake_tree(th, { fruit_model: pick(c.fruit, ['apple', 'mushroom', 'gem', 'coin'], 'apple'), label: String(c.shake_label || '흔들기').slice(0, 8) }); } for (var di = 0; di < 3; di++) { var da = prng() * Math.PI * 2, dr = 8 + prng() * 20; var dx6 = Math.cos(da) * dr, dz6 = Math.sin(da) * dr; for (var dj = 0; dj < 8 && (dx6 - pond.x) * (dx6 - pond.x) + (dz6 - pond.z) * (dz6 - pond.z) < (pond.r + 1.5) * (pond.r + 1.5); dj++) { da = prng() * Math.PI * 2; dr = 8 + prng() * 20; // 수중 화석 스팟 재추첨(상한 8) dx6 = Math.cos(da) * dr; dz6 = Math.sin(da) * dr; } cozy.dig_spot({ x: dx6, z: dz6 }); } // ── 주민(요청 루프 = 세션 목표) — 동물 우선(프롬프트 계약 "animal villagers" 정합) ── var VILLAGER_MODELS = ['dog', 'cat', 'fox', 'deer', 'villager', 'farmer']; var _VBASE = ['dog', 'cat', 'fox', 'deer', 'horse', 'pig', 'sheep', 'wolf', 'villager', 'farmer', 'adventurer', 'knight', 'rogue', 'worker', 'king', 'mage', 'barbarian', 'striker', 'robot']; var vconf = (Array.isArray(c.villagers) ? c.villagers.slice(0, 3) : [ { name: '모카', personality: 'peppy' }, { name: '토리', personality: 'lazy' }, { name: '밤이', personality: 'cranky' }, ]); var req_total = 0, req_done = 0, friends = 0; var villagers = vconf.map(function (vc, vi) { var ang = (vi / Math.max(1, vconf.length)) * Math.PI * 2 + 0.7; var reqs = Array.isArray(vc && vc.requests) ? vc.requests : [{ kind: ['fruit', 'fish', 'fossil'][vi % 3], n: vi === 2 ? 1 : 2 }]; req_total += Math.min(5, reqs.length); return cozy.villager({ model: (vc && typeof vc.model === 'string' && (_extm2[vc.model] || _VBASE.indexOf(vc.model) >= 0)) ? vc.model : VILLAGER_MODELS[vi % VILLAGER_MODELS.length], // 베이스명 명시도 존중(_obby_avatar 침묵-무시 결함과 동형 봉합) name: (vc && vc.name) || '주민' + (vi + 1), personality: vc && vc.personality, at: { x: Math.cos(ang) * 9, z: Math.sin(ang) * 9 }, requests: reqs, thanks_line: vc && vc.thanks_line, }); }); // ── 소나기 이벤트(30% 확률, 60s — 희귀어 2× 고지) ── var _rain_done = false, _rain_t = 20 + prng() * 60; if (prng() < 0.3) { P.loop(function (dt) { if (_rain_done || dt <= 0) return; _rain_t -= dt; if (_rain_t <= 0) { _rain_done = true; nature.weather('rain', { count: 80 }); cozy.set_rain(true); P.toast(_at_t('소나기가 내려요 — 희귀한 물고기가 나올지도! 🌧', 'にわか雨だ — レアな魚が釣れるかも!🌧', 'Rain shower — rare fish may bite! 🌧')); setTimeout(function () { nature.weather('clear'); cozy.set_rain(false); P.toast(_at_t('비가 갰어요 ☀', '雨があがった ☀', 'The rain cleared ☀')); }, 60000); } }); } // ── HUD + 완결(요청 전부 이행 = 세션 목표 — 시간 압박 없음) ── var ui = _arch_hud(c.title || '아늑한 섬'); var t0 = null, t_final = null; function _t_begin() { if (t0 === null) t0 = performance.now(); window.removeEventListener('pointerdown', _t_begin); window.removeEventListener('keydown', _t_begin); } window.addEventListener('pointerdown', _t_begin); window.addEventListener('keydown', _t_begin); function _hud() { var pk = cozy.pocket(); ui.children[1].textContent = '💌 ' + req_done + ' / ' + req_total + ' · 🪙 ' + pk.coins; } _hud(); P.loop(function () { if (t0 !== null && t_final === null) ui.children[2].textContent = '⏱ ' + _fmt_t(performance.now() - t0); }); cozy.on('pocket', _hud); var complete = false; cozy.on('request_done', function (ctx) { req_done++; _hud(); A._emit('request', { done: req_done, total: req_total, villager: ctx.villager }); }); cozy.on('friend', function (ctx) { friends++; A._emit('friend', ctx); if (!complete && friends >= villagers.length) { complete = true; if (t0 !== null) t_final = performance.now() - t0; P.music('triumph'); fx.screen_flash(pal.key); P.toast(String(c.complete_text || '모두와 절친이 됐어요! 🏡').slice(0, 60)); A._emit('complete', { friends: friends, coins: cozy.pocket().coins, time_ms: t_final }); var _rec = t_final != null ? _fmt_t(t_final) : null; setTimeout(function () { P.three.capture().then(function (shot) { P.share_shot(shot, { title: String(c.title || '아늑한 섬').slice(0, 40), lines: [(_rec ? '⏱ ' + _rec + ' · ' : '') + '❤ 절친 ' + friends + '명 · 🪙 ' + cozy.pocket().coins], share_text: String(c.share_text || '아늑한 섬에서 모두와 절친이 됐어요 🏡').slice(0, 120), }); }); }, 900); } }); cozy.on('catch', function (f) { A._emit('catch', f); }); P.mount_share(function () { return String(c.share_text || ('아늑한 섬에서 요청 ' + req_done + '건을 들어줬어요 🌿')).slice(0, 120); }, String(c.share_label || '공유').slice(0, 12)); A._installed = 'cozy_island'; return { player: player, villagers: villagers, get requests_done() { return req_done; }, get friends() { return friends; }, pocket: cozy.pocket }; }; // ═══ 아키타입 #7: lane_battle — 단일 레인 1v1 vs 봇(MOBA-lite, 완비·골든-테스트 대상) ═══ // 정본(리서치 확정): 성립 형태 = 단일 레인 1v1 vs 봇만(모바일 액터 예산). 웨이브 20s(2+2, // 3번째마다 대포)·타워 램프/보호/요새화·QWER-lite(궁 3렙)·데스 타이머 3+2×렙·어그로 표· // 승리 = 타워→코어, 5:00 서든데스 = 구조물 HP% 판정. 카메라 = 고정 하이앵글(57° — 퇴화 가드 통과역). _ARCH.lane_battle = function (cfg) { var c = cfg || {}; if (!P.lane) { warn('lane family missing — falling back to meadow_explorer'); return _ARCH.meadow_explorer(cfg); } var mood_req = c.mood === 'night' ? 'night_neon' : c.mood; if (mood_req === 'night_neon' || mood_req === 'ember' || mood_req === 'dusk') { warn('dark mood "' + mood_req + '" demoted to sunset — archetype starts in daylight'); mood_req = 'sunset'; } var mood_name = pick(mood_req, P.moods || [], 'noon'); var pal = P.mood(mood_name); P.music(pick(c.music, ['calm_exploration', 'adventure', 'cozy', 'mystery', 'night', 'chiptune_action', 'battle', 'triumph', 'space', 'spooky', 'lofi', 'sad', 'happy'], 'lofi')); P.ambience(pick(c.ambience, ['forest', 'wind', 'rain', 'night', 'ocean', 'cave', 'crowd'], 'wind')); P.enable_bloom({ strength: 0.55, radius: 0.5, threshold: 1.05 }); var world = P.world, lane = P.lane, fx = P.fx, nature = P.nature; var seed = (num(c.seed, 17) | 0); var gr = pal.ground_ramp || [pal.ground, pal.ground, pal.ground, pal.ground]; var terrain = world.terrain({ seed: seed, amplitude: 1.2, scale: 160, chunk: 32, radius: 3 }); // 준평탄(레인 가독성) nature.grass_field({ area: 150, count: 16000, base: gr[1], tip: gr[3], height: 0.45, y: terrain.height_at }); nature.clouds({ count: 10, style: 'puffy' }); var mp = lane.map({ length: clamp(num(c.length, 56), 40, 80), width: 8 }); // 실 테마 프롭(브리프-추론 배선, 미주입 시 없음). 레인(z축·폭8) 바깥 양측 플랭크 = 미니언/챔프 // 경로 무간섭(collide:false). lane_battle 은 자체 prng 부재 → 독립 스트림 신설. var _LN_PROPS = (typeof window !== 'undefined' && window['__PLAYABLE_'+'EXTRA_MANIFEST__'] && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].stageprops && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].stageprops.items) || null; if (_LN_PROPS && _LN_PROPS.length) { var _lpp = world._priv.mulberry32(((seed * 30119) ^ 0x1B873593) >>> 0); var _llen = clamp(num(c.length, 56), 40, 80), _lnn = 12; for (var _lni = 0; _lni < _lnn; _lni++) { var _lz = -_llen / 2 + (_lni + 0.5) / _lnn * _llen; var _lx = (5.5 + _lpp() * 4) * (_lni % 2 === 0 ? -1 : 1); // 좌우 교대, 레인 밖 _spawn_stage_prop(_LN_PROPS[_lni % _LN_PROPS.length], _lx, _lz, null, { collide: false, cap_h: 3.2 }); } } var diff = pick(c.difficulty, ['easy', 'normal', 'hard'], 'normal'); var BOT = { easy: { hp: 8, dmg: 0.8, cd: 3.0 }, normal: { hp: 10, dmg: 1, cd: 2.4 }, hard: { hp: 13, dmg: 1.3, cd: 1.9 } }[diff]; // 구조물(진영별 타워 1 + 코어 1) var t_ally = lane.tower({ team: 'ally' }); var t_enemy = lane.tower({ team: 'enemy' }); var c_ally = lane.core({ team: 'ally' }); var c_enemy = lane.core({ team: 'enemy' }); // 웨이브(양 진영 동주기 — 미니언 모델은 config 로 테마) var wm = c.minions || {}; lane.wave({ team: 'ally', models: { melee: wm.ally_melee, ranged: wm.ally_ranged, cannon: wm.cannon } }); lane.wave({ team: 'enemy', models: { melee: wm.enemy_melee, ranged: wm.enemy_ranged, cannon: wm.cannon } }); // 챔피언 + 봇 var _extm3 = (typeof window !== 'undefined' && window['__PLAYABLE_'+'EXTRA_MANIFEST__'] && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].models) || {}; var CH_MODELS = ['knight', 'mage', 'rogue', 'barbarian', 'striker', 'king']; var champ = lane.champion({ model: (typeof c.player === 'string' && _extm3[c.player]) ? c.player : pick(c.player, CH_MODELS, 'knight'), }); var bot = lane.bot({ model: pick(c.bot_model, CH_MODELS, 'mage'), hp: BOT.hp, damage: BOT.dmg, cooldown: BOT.cd }); // 고정 하이앵글 카메라(57° — |z|<0.8 퇴화 가드·수직 자가치유 임계 모두 통과) world.camera({ offset: { x: 0, y: 11, z: -7 }, lookat: { x: 0, y: 0.5, z: 2 }, damping: 5 }); world.controls({ mission: c.hint ? String(c.hint) : '' }); world.minimap({ range: Math.max(46, Math.round(mp.len * 0.62)) }); // 유닛/구조물 팀 블립은 셸 자동 // ── HUD(KDA·CS·골드·레벨) + 팀 스코어보드 + 매치 타이머·서든데스 ── var ui = _arch_hud(c.title || '외길 결전'); // 팀 스코어보드(청 킬 · 잔여 타이머 · 적 킬 — 미션 칩 아래 상단 중앙 고정) var sb = document.createElement('div'); sb.id = 'playable-lane-scoreboard'; sb.style.cssText = 'position:fixed;left:50%;transform:translateX(-50%);top:calc(46px + env(safe-area-inset-top,0px));z-index:99930;display:flex;align-items:center;gap:8px;pointer-events:none;font:800 13px system-ui;'; sb.innerHTML = '
🔵 0
' + '
5:00
' + '
🔴 0
'; document.body.appendChild(sb); var SUDDEN_S = clamp(num(c.sudden_death_s, 300), 120, 600); // 5:00 정본 var t0 = null, ended = false, won = null; function _t_begin() { if (t0 === null) t0 = performance.now(); window.removeEventListener('pointerdown', _t_begin); window.removeEventListener('keydown', _t_begin); } window.addEventListener('pointerdown', _t_begin); window.addEventListener('keydown', _t_begin); function _hud() { ui.children[1].textContent = '⚔ ' + champ.kills + '/' + champ.deaths + ' · CS ' + champ.cs + ' · 🪙 ' + champ.gold + ' · Lv' + champ.level; sb.children[0].textContent = '🔵 ' + champ.kills; sb.children[2].textContent = '🔴 ' + champ.deaths; } _hud(); P.loop(function () { if (t0 !== null && !ended) { var el = (performance.now() - t0) / 1000; var left = Math.max(0, SUDDEN_S - el); var _tstr = Math.floor(left / 60) + ':' + ('0' + Math.floor(left % 60)).slice(-2); ui.children[2].textContent = '⏱ ' + _tstr; sb.children[1].textContent = _tstr; if (left <= 0) _sudden_death(); } }); lane.on('gold', _hud); lane.on('cs', _hud); lane.on('level', function (ctx) { _hud(); A._emit('level', ctx); }); lane.on('bot_death', function (ctx) { _hud(); P.toast('적 챔피언 처치! ⚔'); A._emit('kill', ctx); }); lane.on('death', function (ctx) { _hud(); A._emit('death', ctx); }); lane.on('tower_down', function (ctx) { P.toast(ctx.team === 'enemy' ? '적 타워 파괴! 코어를 노리세요' : '아군 타워가 무너졌어요!'); if (P.fx) P.fx.sfx('explosion', { volume: 0.6 }); A._emit('tower', ctx); }); lane.on('core_down', function (ctx) { _end(ctx.team === 'enemy'); }); function _struct_hpf(team) { // 서든데스 판정 = 구조물 HP% 합 var tw = team === 'ally' ? t_ally : t_enemy; var co = team === 'ally' ? c_ally : c_enemy; return (tw.alive ? tw.hp / tw._st.hp_max : 0) + (co.alive ? co.hp / co._st.hp_max : 0); } function _sudden_death() { if (ended) return; _end(_struct_hpf('ally') >= _struct_hpf('enemy')); // 동률 = 방어 성공 취급(플레이어 우세) } function _end(win) { if (ended) return; ended = true; won = !!win; if (won) { P.music('triumph'); fx.screen_flash(pal.key); P.toast(String(c.complete_text || '승리! 코어 파괴 🏆').slice(0, 60)); } else { P.toast('패배… 다시 도전!'); } if (P.lane) P.lane._halted = true; // 종료 후 웨이브 영구 스폰 차단(사체 누적 — 선제 감사) A._emit('complete', { won: won, kills: champ.kills, deaths: champ.deaths, cs: champ.cs, level: champ.level }); setTimeout(function () { P.three.capture().then(function (shot) { P.share_shot(shot, { title: String(c.title || '외길 결전').slice(0, 40), lines: [(won ? '🏆 승리' : '패배') + ' · ⚔ ' + champ.kills + '/' + champ.deaths + ' · CS ' + champ.cs + ' · Lv' + champ.level], share_text: String(c.share_text || ('외길 결전 ' + (won ? '승리! 🏆' : '분투…') + ' ⚔ ' + champ.kills + '킬')).slice(0, 120), }); }); }, 900); } P.mount_share(function () { return String(c.share_text || ('외길 결전 ' + (won == null ? '교전 중' : (won ? '승리! 🏆' : '분투')) + ' — ⚔ ' + champ.kills + '/' + champ.deaths)).slice(0, 120); }, String(c.share_label || '공유').slice(0, 12)); A._installed = 'lane_battle'; return { champion: champ, bot: bot, get ended() { return ended; }, get won() { return won; }, towers: { ally: t_ally, enemy: t_enemy }, cores: { ally: c_ally, enemy: c_enemy } }; }; // ═══ 아키타입 #8: build_fight — 건축 배틀 1v1 vs 봇(퀵빌드 듀얼, 완비·골든-테스트 대상) ═══ // 정본(리서치 확정): 1v1.LOL 모바일 번역 — 전투↔건축 모드 토글 + 피스 전용 버튼 + // "조준하는 곳 = 배치되는 곳". 3인칭 숄더캠(fps view:'shoulder') + 봇 = combat enemy 팀 // (봇은 건축하지 않음 — v1 정직 스코프: hp 로 보상). 채집 = 나무 스윙당 자재(+2). // 봇이 벽을 조준·파괴(combat 외부 타겟 경로)해 "엄폐물이 깎이는" 판타지가 성립. _ARCH.build_fight = function (cfg) { var c = cfg || {}; if (!P.build || !P.world || !P.world.fps) { warn('build/fps family missing — falling back to meadow_explorer'); return _ARCH.meadow_explorer(cfg); } var mood_req = c.mood === 'night' ? 'night_neon' : c.mood; if (mood_req === 'night_neon' || mood_req === 'ember' || mood_req === 'dusk') { warn('dark mood "' + mood_req + '" demoted to sunset — archetype starts in daylight'); mood_req = 'sunset'; } var mood_name = pick(mood_req, P.moods || [], 'noon'); var pal = P.mood(mood_name); P.music(pick(c.music, ['calm_exploration', 'adventure', 'cozy', 'mystery', 'night', 'chiptune_action', 'battle', 'triumph', 'space', 'spooky', 'lofi', 'sad', 'happy'], 'lofi')); P.ambience(pick(c.ambience, ['forest', 'wind', 'rain', 'night', 'ocean', 'cave', 'crowd'], 'wind')); P.enable_bloom({ strength: 0.55, radius: 0.5, threshold: 1.05 }); var world = P.world, build = P.build, fx = P.fx, nature = P.nature; var seed = (num(c.seed, 21) | 0); var gr = pal.ground_ramp || [pal.ground, pal.ground, pal.ground, pal.ground]; var terrain = world.terrain({ seed: seed, amplitude: 1.5, scale: 150, chunk: 32, radius: 3 }); // 준평탄(건축 가독성) nature.grass_field({ area: 150, count: 16000, base: gr[1], tip: gr[3], height: 0.45, y: terrain.height_at }); nature.clouds({ count: 10, style: 'puffy' }); var ended = false, won = null; var player = world.player({ spawn: _obby_avatar(c), at: { x: -14, z: 0 }, speed: 4.6, run_speed: 7.6, fly: false, hp: clamp(Math.round(num(c.hp, 6)), 3, 20), on_player_death: function () { if (!ended) _end(false); }, }); world.controls({ mission: c.hint ? String(c.hint) : '' }); var gun = world.fps({ view: 'shoulder', weapon: null, fire_rate: clamp(num(c.fire_rate, 3.5), 1, 8), damage: 1, range: 40, spread: 2.2 }); // ── 건축(퀵빌드) + 채집 ── var bapi = build.enable({ materials: clamp(Math.round(num(c.materials, 12)), 0, 200), hp: 6 }); var prng = world._priv.mulberry32(((seed * 31337) ^ 0x9E3779B9) >>> 0); var _extm4 = (typeof window !== 'undefined' && window['__PLAYABLE_'+'EXTRA_MANIFEST__'] && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].models) || {}; var _kit_trees4 = Object.keys(_extm4).filter(function (n) { var t = _extm4[n].tags || []; return (t.indexOf('tree') >= 0 || t.indexOf('nature') >= 0) && num(_extm4[n].h, 0) >= 2.2 && !/flower|bush|grass|rock/.test(n); }); for (var ti = 0; ti < 6; ti++) { // 수확 나무 6그루(아레나 주변 링) var ta = (ti / 6) * Math.PI * 2 + prng() * 0.5, tr = 18 + prng() * 10; var tx = Math.cos(ta) * tr, tz = Math.sin(ta) * tr; var th; if (_kit_trees4.length) th = world.spawn(_kit_trees4[ti % _kit_trees4.length], { at: { x: tx, z: tz } }); else { var tg = new THREE_.Group(); var tt = new THREE_.Mesh(new THREE_.CylinderGeometry(0.24, 0.34, 2.0, 7), P.make.matte(gr[0])); tt.position.y = 1.0; tg.add(tt); var tc = new THREE_.Mesh(new THREE_.ConeGeometry(1.2, 2.4, 9), P.make.matte(gr[2])); tc.position.y = 3.0; tg.add(tc); tg.position.set(tx, world.height_at(tx, tz), tz); scene.add(tg); th = { obj: tg }; } build.harvestable(th, { yield: 2 }); } // 실 테마 프롭(브리프-추론 배선, 미주입 시 없음). ±14 결투박스·x축 시야선(|z|<6) 밖 링 배치, // 지형 접지. prng 독립 스트림(하베스트-트리 draw 보존). collide:false(결투 무간섭). var _BF_PROPS = (typeof window !== 'undefined' && window['__PLAYABLE_'+'EXTRA_MANIFEST__'] && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].stageprops && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].stageprops.items) || null; if (_BF_PROPS && _BF_PROPS.length) { var _fpp = world._priv.mulberry32(((seed * 48611) ^ 0x85EBCA6B) >>> 0), _fnn = 12; for (var _fni = 0; _fni < _fnn; _fni++) { var _frd = 16 + _fpp() * 9, _fa = _fpp() * 6.283; var _fx = Math.cos(_fa) * _frd, _fz = Math.sin(_fa) * _frd; if (Math.abs(_fz) < 6) continue; // x축 결투 회랑·시야선 회피 _spawn_stage_prop(_BF_PROPS[_fni % _BF_PROPS.length], _fx, _fz, null, { collide: false, cap_h: 3.0 }); } } // ── 봇(1v1 상대 — enemy 팀: 플레이어·아군 벽(외부 타겟)을 조준) ── var diff = pick(c.difficulty, ['easy', 'normal', 'hard'], 'normal'); var BOT = { easy: { hp: 8, dmg: 1, cd: 3.2, aggro: 26 }, normal: { hp: 12, dmg: 1, cd: 2.6, aggro: 32 }, hard: { hp: 16, dmg: 1, cd: 2.0, aggro: 40 } }[diff]; var bot_name = String(c.bot_name || 'Rival').slice(0, 12); var bot = world.mob(pick(c.bot_model, ['knight', 'rogue', 'barbarian', 'mage', 'striker'], 'rogue'), { at: { x: 14, z: 0 }, team: 'enemy', behavior: 'ranged', hp: BOT.hp, damage: BOT.dmg, aggro: BOT.aggro, attack_range: 14, leash: 120, speed: 0.7, cooldown: BOT.cd, windup: 0.6, on_death: function () { if (!ended) _end(true); }, }); // ── HUD(자재·상대 HP·타이머) + 완결 ── var ui = _arch_hud(c.title || '건축 배틀'); var t0 = null, t_final = null; function _t_begin() { if (t0 === null) t0 = performance.now(); window.removeEventListener('pointerdown', _t_begin); window.removeEventListener('keydown', _t_begin); } window.addEventListener('pointerdown', _t_begin); window.addEventListener('keydown', _t_begin); function _hud() { var bh = bot && bot.state !== 'dead' ? Math.max(0, bot.hp) : 0; ui.children[1].textContent = '🪵 ' + bapi.materials + ' · ' + bot_name + ' ❤' + bh; } _hud(); build.on('materials', _hud); build.on('place', _hud); P.loop(function () { if (t0 !== null && t_final === null) ui.children[2].textContent = '⏱ ' + _fmt_t(performance.now() - t0); if (!ended && (++_hud._n || (_hud._n = 1)) % 12 === 0) _hud(); // 상대 HP 주기 갱신(피격은 combat 소유 — 폴링) }); function _end(win) { if (ended) return; ended = true; won = !!win; if (t0 !== null) t_final = performance.now() - t0; if (won) { P.music('triumph'); fx.screen_flash(pal.key); P.toast(String(c.complete_text || bot_name + ' 격파! 🏆').slice(0, 60)); } else { P.toast('패배… 벽으로 방어하며 다시 도전!'); } A._emit('complete', { won: won, materials: bapi.materials, pieces: bapi.pieces, time_ms: t_final }); var _rec = t_final != null ? _fmt_t(t_final) : null; setTimeout(function () { P.three.capture().then(function (shot) { P.share_shot(shot, { title: String(c.title || '건축 배틀').slice(0, 40), lines: [(won ? '🏆 승리' : '패배') + (_rec ? ' · ⏱ ' + _rec : '') + ' · 🧱 ' + bapi.pieces + '피스'], share_text: String(c.share_text || ('건축 배틀 ' + (won ? '승리! 🏆' : '분투…') + ' 🧱')).slice(0, 120), }); }); }, 900); } P.mount_share(function () { return String(c.share_text || ('건축 배틀 ' + (won == null ? '교전 중 🧱' : (won ? '승리! 🏆' : '분투')))).slice(0, 120); }, String(c.share_label || '공유').slice(0, 12)); A._installed = 'build_fight'; return { player: player, bot: bot, build: bapi, get ended() { return ended; }, get won() { return won; } }; }; // ═══ 아키타입 #9: voxel_city — 복셀 도시 탐험 시뮬레이터(완비·골든-테스트 대상) ═══ // 정체성 = "게임"이 아니라 살아있는 도시를 걷고/날며 체험하는 시뮬레이터: V.city 맨해튼 문법 // 도시 + 랜드마크 실루엣(닫힌 어휘 — 셸이 조형·배치 소유, LLM 은 이름/종류만) + 비행 모드 토글 // + 자동 주야 사이클(도시 불빛 점등이 하이라이트) + 무압박 발견 목표(타이머 없음). // 2026-07-15 실사고의 구조 봉합: "복셀 도시" 브리프의 유일-정답 거처(월드 정체성이 도시인 아키타입). _ARCH.voxel_city = function (cfg) { var c = cfg || {}; if (!P.voxel || !P.world) { warn('voxel/world family missing — falling back to meadow_explorer'); return _ARCH.meadow_explorer(cfg); } // ── Tier B config 검증(전 필드 기본값·클램프·enum) ── var mood_req = c.mood === 'night' ? 'night_neon' : c.mood; if (mood_req === 'night_neon' || mood_req === 'ember' || mood_req === 'dusk') { warn('dark mood "' + mood_req + '" demoted to noon — voxel_city starts in daylight (the day-night cycle reaches night by itself)'); mood_req = 'noon'; } var mood_name = pick(mood_req, P.moods || [], 'noon'); if (mood_req && mood_name !== mood_req) warn('unknown mood "' + mood_req + '" → ' + mood_name); var pal = P.mood(mood_name); P.music(pick(c.music, ['calm_exploration', 'adventure', 'cozy', 'mystery', 'night', 'chiptune_action', 'battle', 'triumph', 'space', 'spooky', 'lofi', 'sad', 'happy'], 'lofi')); P.ambience(pick(c.ambience, ['forest', 'wind', 'rain', 'night', 'ocean', 'cave', 'crowd'], 'crowd')); P.enable_bloom({ strength: 0.55, radius: 0.5, threshold: 1.05 }); var world = P.world, nature = P.nature, fx = P.fx, V = P.voxel; var seed = (num(c.seed, 11) | 0); var size = typeof c.size === 'string' ? ({ small: 140, medium: 190, large: 250 })[c.size] || 190 : clamp(num(c.size, 190), 100, 320); var half = size / 2; // ⚠️ 지형은 절차 모드 전용(_build_procedural 에서 생성) — 실측(geo) 모드는 지형 미생성. // 근거(실사고): terrain 진폭 최소 클램프 0.5m > geo 보행면 0.12m 라 기복이 도로/보도를 삼킴 // ('초록 바닥' 결함). geo 의 지면 = 육지 슬래브+물(시각) + voxel 틱 보행면 클램프(물리) — // world.height_at 은 무지형 시 0 을 반환하므로 플레이어 물리는 안전. nature.wind({ strength: 0.35, gustiness: 0.4, direction: { x: 0.7, z: 0.4 } }); nature.clouds({ count: 10, style: 'puffy', height: 70 }); // ── 랜드마크 재료(닫힌 실루엣 어휘 — 조형·배치 = 셸 소유; config 는 {label,kind}만) ── // 'none' 센티널 = 랜드마크-프리 도시 장면(발견 카운터·목표 칩·완주 표면이 구조적으로 부재). // 게임 장기는 브리프가 요청한 곳에만 부착(SIM-FORM 원칙) — 도시 장면-생성 브리프의 정본 형태. ── var LMK_KINDS = ['tower', 'bridge', 'statue', 'park', 'arch', 'dome']; var lmk_none = c.landmarks === 'none'; var lmks_in = !lmk_none && Array.isArray(c.landmarks) ? c.landmarks.slice(0, 6) : []; if (!lmks_in.length && !lmk_none) { warn('legacy default landmarks injected (3) — pass landmarks:"none" for a landmark-free city scene'); lmks_in = [{ label: '중앙 타워', kind: 'tower' }, { label: '기념 아치', kind: 'arch' }, { label: '큰 광장', kind: 'park' }]; } // ── 도시 2모드: 실측(geo — 카탈로그 히트, 데이터가 형태의 정본) | 절차(V.city — 정직 폴백) ── // GEO 이중 키: config.city_id ∧ 생성기 주입 config 의 id 일치 ∧ geo 패밀리 존재. var GEO = typeof c.city_id === 'string' && window['__PLAYABLE_'+'GEOCITY__'] && P.geo && window['__PLAYABLE_'+'GEOCITY__'].id === String(c.city_id).trim() ? window['__PLAYABLE_'+'GEOCITY__'] : null; var city = null, lmk_reach = 110; // 발견 반경²(절차 10.5m — 실측은 실건물 footprint 스케일로 확대) function _build_procedural() { world.terrain({ seed: seed, amplitude: 1, scale: 280, chunk: 32, radius: 3 }); // 평탄 기조(도시 기저) var prng = world._priv.mulberry32(((seed * 74747) ^ 0x51ED2701) >>> 0); var ring_r = Math.min(size * 0.33, half - 16); var a0 = prng() * Math.PI * 2; var lmk_pts = lmks_in.map(function (L, i) { var a = a0 + (i / lmks_in.length) * Math.PI * 2; return { label: String((L && L.label) || '명소 ' + (i + 1)).slice(0, 24), kind: pick(L && L.kind, LMK_KINDS, LMK_KINDS[i % LMK_KINDS.length]), x: Math.cos(a) * ring_r, z: Math.sin(a) * ring_r, angle: a, }; }); city = V.city({ seed: seed, size: size, skyline: pick(c.skyline, ['downtown', 'midtown', 'uniform'], 'downtown'), density: clamp(num(c.density, 0.85), 0.3, 1), floors: Array.isArray(c.floors) ? c.floors : [3, 26], park_ratio: clamp(num(c.park_ratio, 0.08), 0, 0.3), traffic: Math.round(clamp(num(c.traffic, 8), 0, 20)), lit: 0.55, // 주야 사이클 전제 — 밤 도달 시 도시가 살아있게 상시 점등 비율을 높인다 reserve: lmk_pts.map(function (q) { return { x: q.x, z: q.z, r: 10 }; }), }); _place_landmarks(lmk_pts); } // ── 랜드마크 실루엣 조형(복셀 독법 — 박스 적층 + 발광 액센트 1점) ── var stone = P.make.matte(0xcfc6b8), deckm = P.make.matte(0x8a8f99), patina = P.make.matte(0x6fae9d); var accent = P.make.glow(pal.accent, 1.3); function _lmk_mesh(kind) { var g = new THREE_.Group(); function box(m, w, h, d, x, y, z) { var b = new THREE_.Mesh(new THREE_.BoxGeometry(w, h, d), m); b.position.set(x, y, z); g.add(b); return b; } var glow_ref = null; if (kind === 'tower') { box(stone, 8, 13, 8, 0, 6.5, 0); box(stone, 5.6, 11, 5.6, 0, 18.5, 0); box(stone, 3.4, 9, 3.4, 0, 28.5, 0); var sp = new THREE_.Mesh(new THREE_.CylinderGeometry(0.16, 0.3, 7, 6), accent); sp.position.y = 36.5; g.add(sp); glow_ref = sp; } else if (kind === 'bridge') { box(deckm, 18, 0.7, 3.2, 0, 5.6, 0); box(stone, 1.3, 11, 1.3, -5.2, 5.5, 0); box(stone, 1.3, 11, 1.3, 5.2, 5.5, 0); for (var cb = 0; cb < 4; cb++) { var cx = [-7.6, -2.6, 2.6, 7.6][cb]; var cable = box(deckm, 0.14, 5.4, 0.14, cx, 8.3, 0); cable.rotation.z = (cx < 0 ? -1 : 1) * 0.5; } var bl = new THREE_.Mesh(new THREE_.SphereGeometry(0.34, 8, 6), accent); bl.position.set(-5.2, 11.2, 0); g.add(bl); glow_ref = bl; var br = bl.clone(); br.position.x = 5.2; g.add(br); } else if (kind === 'statue') { box(stone, 4.2, 4.5, 4.2, 0, 2.25, 0); box(patina, 1.8, 3.8, 1.4, 0, 6.4, 0); box(patina, 1.1, 1.1, 1.1, 0, 8.9, 0); var arm = box(patina, 0.5, 2.8, 0.5, 1.2, 8.6, 0); arm.rotation.z = -0.35; var torch = new THREE_.Mesh(new THREE_.SphereGeometry(0.42, 8, 6), accent); torch.position.set(1.95, 10.2, 0); g.add(torch); glow_ref = torch; } else if (kind === 'park') { box(P.make.matte(0x5f9e57), 15, 0.14, 15, 0, 0.07, 0); box(P.make.matte(0x3d7db0), 5.5, 0.1, 4, 3, 0.16, 2.6); // 연못 for (var pt = 0; pt < 4; pt++) { var px = [-4.6, -4.2, 1.2, 5.2][pt], pz = [-4.2, 3.6, -5, 4.8][pt]; box(P.make.matte(0x6a4a30), 0.5, 1.6, 0.5, px, 0.8, pz); box(P.make.matte(0x4c8b45), 2.2, 1.5, 2.2, px, 2.3, pz); box(P.make.matte(0x5aa04f), 1.4, 1, 1.4, px, 3.4, pz); } var lamp = new THREE_.Mesh(new THREE_.SphereGeometry(0.3, 8, 6), accent); lamp.position.set(0, 3.2, 0); g.add(lamp); box(stone, 0.24, 3, 0.24, 0, 1.5, 0); glow_ref = lamp; } else if (kind === 'dome') { box(stone, 9, 3.4, 9, 0, 1.7, 0); var drum = new THREE_.Mesh(new THREE_.CylinderGeometry(3.1, 3.4, 2.6, 12), stone); drum.position.y = 4.7; g.add(drum); var dome = new THREE_.Mesh(new THREE_.SphereGeometry(3.1, 14, 10, 0, Math.PI * 2, 0, Math.PI / 2), patina); dome.position.y = 6; g.add(dome); var fin = new THREE_.Mesh(new THREE_.SphereGeometry(0.32, 8, 6), accent); fin.position.y = 9.5; g.add(fin); glow_ref = fin; } else { // arch(기본) box(stone, 2.2, 7.5, 2.2, -3.6, 3.75, 0); box(stone, 2.2, 7.5, 2.2, 3.6, 3.75, 0); box(stone, 9.4, 2.6, 2.4, 0, 8.8, 0); box(stone, 7.6, 1, 1.8, 0, 10.6, 0); var key = new THREE_.Mesh(new THREE_.SphereGeometry(0.3, 8, 6), accent); key.position.set(0, 7.4, 0); g.add(key); glow_ref = key; } if (glow_ref) g._pulses = [fx.pulse(glow_ref, { amount: 0.1, rate: 1.2 })]; return g; } var discovered = 0; var lmk_items = []; function _place_landmarks(pts) { lmk_items = pts.map(function (q, i) { var g; if (q.kind === 'building') { // 실측 모드 전용 — 실건물 자체가 기념물(마커 앵커만, 조형 없음) g = new THREE_.Group(); g.position.set(q.x, city.walk + 40, q.z); } else { g = _lmk_mesh(q.kind); g.position.set(q.x, city.walk, q.z); g.rotation.y = q.angle != null ? -q.angle + Math.PI / 2 : Math.atan2(-q.x, -q.z); // 도심 지향 } scene.add(g); return { label: q.label, kind: q.kind, index: i, seen: false, x: q.x, z: q.z, g: g, mk: world.marker(g, { label: q.label }) }; }); if (ui) _hud(); // ui 는 var 호이스팅 — 절차 경로(동기)에선 미생성이라 스킵, 초기 _hud() 가 커버 } if (GEO) { city = P.geo.city({ lit: 0.55 }); if (!city) { GEO = null; _build_procedural(); } else { lmk_reach = 3600; // 실측 스케일 — 실건물 footprint 가 중심 접근을 막으므로 60m 반경 city.ready(function (ok) { if (!ok) { // 타일 실패 = 절차 강등(LOUD/비콘은 geo 셸 소유) — 플레이어는 절차 도시 중심으로 try { city.remove(); } catch (e) {} GEO = null; lmk_reach = 110; _build_procedural(); if (plref) { plref.handle.obj.position.x = 2; plref.handle.obj.position.z = 0; } return; } if (!lmk_none) { // 랜드마크-프리 장면은 실측 모드에서도 발견 표면(마커·카운터) 미장착 _place_landmarks(city.pois.map(function (p2, i2) { // config landmarks = 순서 대응 재라벨만 var lb = lmks_in[i2] && lmks_in[i2].label ? String(lmks_in[i2].label).slice(0, 24) : p2.label; return { label: lb, kind: p2.kind, x: p2.x, z: p2.z }; })); } fly_alt = clamp(num(city.tallest.h, 90) + 24, 40, 340); // 실측 스카이라인 상공(1WTC ~430m) }); } } else { _build_procedural(); } // ── 플레이어(실측 spawn | 절차 도로 스캔) + 카메라 + 컨트롤 + 미니맵 ── var sx = 2, sz = 0; if (GEO && city.spawn) { sx = city.spawn.x; sz = city.spawn.z; } else { for (var r2 = 2; r2 < half; r2 += 0.5) { if (city.road_at(r2, 0)) { sx = r2; break; } } } var CHARS = ['farmer', 'adventurer', 'villager', 'knight', 'rogue', 'worker', 'king', 'mage', 'barbarian', 'striker', 'robot']; var _extm = (typeof window !== 'undefined' && window['__PLAYABLE_'+'EXTRA_MANIFEST__'] && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].models) || {}; var _psel = typeof c.player === 'string' && (_extm[c.player] && _extm[c.player].cat === 'character') ? c.player : pick(c.player, CHARS, 'adventurer'); var player = world.player({ spawn: _psel, at: { x: sx, z: sz }, speed: 4.2, run_speed: 7.6 }); // 랜드마크-프리 장면은 mission 을 비운다(셸 미션 칩 = 목표 문구+경과 타이머 = 게임 표면). world.camera(); world.controls({ mission: (c.hint && !lmk_none) ? String(c.hint) : '' }); if (lmk_none && c.hint) P.toast(String(c.hint).slice(0, 60)); // 힌트는 토스트로 합류 world.minimap(GEO ? { range: 2400 } : {}); // 실측 도시 = 섬 전역 지도(소형 기본 70m 는 무기능) // ── 주야 사이클(시뮬레이터 하이라이트 — 도시 불빛 점등 아크). cycle:false = 상시 주간 ── nature.time_of_day('noon'); if (c.cycle !== false) { var day_s = clamp(num(c.cycle && c.cycle.day_s, 180), 60, 600); nature.cycle({ day_seconds: day_s }); } // ── 비행 모드(FLY) — 플레이어 리그 그대로, 고도만 셸이 소유(조이스틱 = 수평 이동) ── var flying = false; var plref = world._priv.player(); var fly_alt = clamp(city.tallest.h + 14, 30, 90); var _walk_speed = plref ? plref.speed : 4.2, _walk_run = plref ? plref.run : 7.6; V._priv.fly_check = function () { return flying; }; function _set_fly(on) { flying = !!on; if (fly_btn) { fly_btn.style.background = flying ? 'rgba(233,178,86,.92)' : 'rgba(20,26,34,.72)'; fly_btn.style.color = flying ? '#1c1710' : '#fff'; } if (plref) { if (flying) { plref.speed = _walk_speed * 2.8; plref.run = _walk_run * 2.4; } else { plref.speed = _walk_speed; plref.run = _walk_run; } } P.toast(flying ? '✈ 비행 모드 — 도시 위를 날아요' : '🚶 도보 모드'); A._emit('fly', { on: flying }); } var fly_btn = null; if (c.fly !== false) { fly_btn = document.createElement('button'); fly_btn.id = 'playable-voxelcity-fly'; fly_btn.textContent = '✈'; // 유틸 동사 = 숄더 레일(좌표 하드코딩 금지 — 화면 중앙 우측에 홀로 떠 배치가 산만했다) P.pad.rail(fly_btn, { size: 50, extra: 'font-size:24px;cursor:pointer;', label: ['비행 전환', '飛行切替', 'Toggle flight'] }); fly_btn.addEventListener('pointerdown', function (ev) { ev.stopPropagation(); }); // 카메라 드래그 캡처 차단 fly_btn.addEventListener('click', function () { _set_fly(!flying); }); } // ── HUD(시뮬레이터 — 타이머 없음) + 발견 루프 ── var ui = _arch_hud(c.title || '복셀 시티'); ui.children[2].style.display = 'none'; // 무압박 시뮬레이터 — 타이머 칩 제거 if (lmk_none) ui.children[1].style.display = 'none'; // 랜드마크-프리 장면 = 제목만 function _hud() { if (!lmk_none) ui.children[1].textContent = '📍 명소 ' + discovered + ' / ' + lmk_items.length; } _hud(); var complete = false; function _check_complete() { if (complete || !lmk_items.length || discovered < lmk_items.length) return; complete = true; P.music('triumph'); fx.screen_flash(pal.accent); P.toast(String(c.complete_text || '도시의 명소를 모두 발견했어요! 🏙').slice(0, 60)); A._emit('complete', { discovered: discovered, total: lmk_items.length }); setTimeout(function () { P.three.capture().then(function (shot) { P.share_shot(shot, { title: String(c.title || '복셀 시티').slice(0, 40), lines: ['📍 명소 ' + discovered + ' / ' + lmk_items.length + ' 발견'], share_text: String(c.share_text || '복셀 도시의 명소를 전부 찾았어요 🏙✨').slice(0, 120), }); }); }, 900); } P.loop(function (dt) { if (dt <= 0) return; if (flying && plref) { var pp = plref.handle.obj.position; pp.y += (city.walk + fly_alt - pp.y) * Math.min(1, dt * 2.2); plref.vy = 0; plref.on_ground = true; } for (var li = 0; li < lmk_items.length; li++) { var it = lmk_items[li]; if (it.seen) continue; var dx = player.pos.x - it.x, dz = player.pos.z - it.z; if (dx * dx + dz * dz < lmk_reach) { // 절차 10.5m / 실측 60m(발견 반경²) it.seen = true; discovered++; it.mk.remove(); // 라벨 소거 = "발견됨" 신호 — 랜드마크 자체는 도시 정체성이므로 존치 var fp = { x: it.x, y: city.walk + 6, z: it.z }; fx.burst(fp, { count: 24, color: pal.accent, speed: 3, life: 1.1, gravity: -0.12 }); fx.float_text(fp, it.label, { color: pal.accent }); fx.sfx('powerup'); _hud(); A._emit('discover', { label: it.label, kind: it.kind, index: it.index, discovered: discovered, total: lmk_items.length }); _check_complete(); } } }); P.mount_share(function () { return String(c.share_text || (lmk_none ? '복셀 도시를 거닐고 있어요 🏙' : '복셀 도시를 산책 중 — 명소 ' + discovered + '/' + lmk_items.length + ' 🏙')).slice(0, 120); }, String(c.share_label || '공유').slice(0, 12)); A._installed = 'voxel_city'; return { player: player, get city() { return city; }, get landmarks() { return lmk_items; }, set_fly: _set_fly, get geo() { return !!GEO; }, get flying() { return flying; }, get discovered() { return discovered; }, get complete() { return complete; }, }; }; // ═══ 아키타입 #10: scenic_explorer — 무압박 자연 풍경 탐방 시뮬레이터(완비·골든-테스트 대상) ═══ // 존재 이유(2026-07-17 lake 2연속 게이트 반려 실사고의 구조 봉합): 순수 감상 장면 브리프는 // 게임 이벤트가 없어 자유-로직 경로에서 fx 게이트와 결정론 충돌한다 — 장면 클래스의 정본 착지점을 // 아키타입 계층에 신설(셸이 ambient fx 를 소유 = 게이트 면제가 구조적으로 정당). voxel_city 와 // 동형의 "시뮬레이터, 도전 게임 아님" 계약: 실패 상태·타이머 없음, 뷰포인트 발견은 무압박, // 완주 후에도 세계는 계속 살아있다. // 2026-07-18 확장 — 자연-과정 시뮬레이션 흡수(2축, 둘 다 Tier B enum): presence 'observe' = // 아바타·조이스틱·뷰포인트 없는 순수 관찰(셸 소유 드리프트 카메라), process 'fill' = 수체가 // 분지 바닥에서 최종 수위선까지 차오름("호수가 생성되는 물리 시뮬레이터" 브리프의 정본 응답 — // 독립 템플릿 기각(b8afd53f 선례 유지), in-family 흡수가 정답). // 2026-07-18 확장 2 — 시뮬레이터-우선 캐스케이드("호수 만들어줘"가 발견 0/3 게임으로 착지한 // 실사고 봉합): viewpoints:'none' 센티널 = 순수 산책(아바타로 걷되 발견 카운터·목표 칩·경과 // 타이머·완주 표면이 구조적으로 부재). 게임 의도가 명시되지 않은 장면 브리프의 walk 형태 정본. _ARCH.scenic_explorer = function (cfg) { var c = cfg || {}; // ── Tier B config 검증(전 필드 기본값·클램프·enum — 어떤 입력에도 동작 보장) ── var mood_req = c.mood === 'night' ? 'night_neon' : c.mood; if (mood_req === 'night_neon' || mood_req === 'ember' || mood_req === 'dusk') { warn('dark mood "' + mood_req + '" demoted to sunset — archetype starts in daylight (switch later via hooks if needed)'); mood_req = 'sunset'; } var mood_name = pick(mood_req, P.moods || [], 'noon'); if (mood_req && mood_name !== mood_req) warn('unknown mood "' + mood_req + '" → ' + mood_name); var pal = P.mood(mood_name); P.music(pick(c.music, ['calm_exploration', 'adventure', 'cozy', 'mystery', 'night', 'chiptune_action', 'battle', 'triumph', 'space', 'spooky', 'lofi', 'sad', 'happy'], 'lofi')); // ambience 는 명시 config 시에만 — 셸 자동 사운드스케이프(물가 파도·개울 스웰·새)가 장면 // 사실성의 정본이고, 명시 호출은 그 전체의 소유권을 가져간다(principles 계약). if (c.ambience) P.ambience(pick(c.ambience, ['forest', 'wind', 'rain', 'night', 'ocean', 'cave', 'crowd'], 'forest')); P.enable_bloom({ strength: 0.5, radius: 0.5, threshold: 1.05 }); var world = P.world, nature = P.nature, fx = P.fx; nature.wind({ strength: clamp(num(c.wind, 0.55), 0, 1.2), gustiness: 0.45, direction: { x: 0.8, z: 0.3 } }); var WATERS = ['lake', 'sea', 'river', 'waterfall', 'none']; var wkind = pick(c.water, WATERS, 'lake'); // ── presence 축 — 'walk'(아바타 산책) | 'observe'(아바타 없음: 셸 소유 시네마틱 드리프트 카메라). // observe 는 "지켜보는" 장면 브리프(자연 과정 물리-시뮬레이터 류)의 정본 착지점 — 2026-07-18 // 실사고(호수 형성 시뮬레이터 요청이 캐릭터+퀘스트 게임으로 착지) 봉합: 게임 표면(캐릭터· // 조이스틱·뷰포인트 HUD)이 구조적으로 제거된다. ── var presence = pick(c.presence, ['walk', 'observe'], 'walk'); // ── viewpoints 'none' 센티널 — 순수 산책 계약(walk + 발견/목표/완주 표면 없음). 배열이 아닌 // 문자열 'none' 만 인정(오타·미지 값은 기존 기본 뷰포인트 경로로 안전 폴백). ── var vp_none = presence === 'walk' && c.viewpoints === 'none'; // ── process 축 — 'none' | 'fill'(수체가 분지 바닥의 실개천에서 최종 수위선까지 차오른다: // "호수가 생성되는 과정" 브리프의 정본 응답). 분지형 수체(lake/sea) 전용. ── var process_kind = pick(c.process, ['none', 'fill'], 'none'); if (process_kind === 'fill' && wkind !== 'lake' && wkind !== 'sea') { warn('process "fill" needs a basin water body (lake/sea) — ignored for "' + wkind + '"'); process_kind = 'none'; } var process_dur = clamp(num(c.process_duration, 60), 20, 180); // river 는 완만 지형 필수(급경사가 리본을 삼킨다 — three 프롬프트 계약), lake 는 분지가 필요해 // 기복 하한을 둔다(평지의 수면 = 가시 호수 없음). var amp = clamp(num(c.amplitude, 4), 1, 7); if (wkind === 'river' || wkind === 'waterfall') amp = Math.min(amp, 2.5); if (wkind === 'lake') amp = Math.max(amp, 2.5); var seed = (num(c.seed, 11) | 0); var terrain = world.terrain({ seed: seed, amplitude: amp, scale: 140, chunk: 32, radius: 3 }); var gr = pal.ground_ramp || [pal.ground, pal.ground, pal.ground, pal.ground]; // ── 물 중심축(장면의 주인공) — 스폰 건조 불변식(y < h0)이 항상 우선 ── var h0 = world.height_at(0, 0); var water_y = null, focal = null; // focal = 물가 초점(앰비언트 스파클·수생 배치 참조점) var water_mesh = null, fill_state = null; if (wkind === 'lake' || wkind === 'sea') { var hmin = Infinity, hx = 0, hz = 0; for (var si = 0; si < 64; si++) { var sa = si / 64 * Math.PI * 2, sr = 18 + (si % 8) * 9; var sx = Math.cos(sa) * sr, sz = Math.sin(sa) * sr; var sh = world.height_at(sx, sz); if (sh < hmin) { hmin = sh; hx = sx; hz = sz; } } // 수면 = 스폰과 최저점 사이 60% 지점(분지 침수 보장), 상한 = 스폰 -0.55m(건조 불변식). water_y = Math.min(h0 - 0.55, hmin + (h0 - hmin) * (wkind === 'sea' ? 0.75 : 0.6)); // 수심 베이크·P._water_y 는 최종 수위 기준(make.water 생성 시점 y) — fill 은 메시 y 만 낮춰 // 시작한다(차오르는 동안 해안 포말이 잠복하는 것은 의도된 우아 강등: 수위 정착 후 복귀). water_mesh = P.make.water({ size: wkind === 'sea' ? 560 : 300, y: water_y, wave: wkind === 'sea' ? 0.5 : 0.22 }); if (process_kind === 'fill' && water_mesh) { fill_state = { t: 0, y0: hmin + (water_y - hmin) * 0.06, aq: false }; // 분지 바닥 실개천에서 시작 water_mesh.position.y = fill_state.y0; } else { nature.aquatic({ biome: wkind === 'sea' ? 'coast' : 'pond' }); // fill 은 수위 정착(≥85%) 후 합류 } focal = { x: hx, z: hz, y: water_y }; } else if (wkind === 'river' || wkind === 'waterfall') { var rprng = world._priv.mulberry32((seed * 62141) >>> 0); var zo = 24 + rprng() * 14; // 스폰존 규칙: 경로는 원점에서 ≥15m var path = []; for (var pi2 = 0; pi2 < 5; pi2++) { path.push([-100 + pi2 * 50, zo + (rprng() - 0.5) * 22]); } nature.river({ path: path, width: 4.5 }); if (wkind === 'waterfall') { var wf = path[0]; nature.waterfall({ at: { x: wf[0], z: wf[1] }, width: 5.5 }); } nature.aquatic({ biome: 'river' }); focal = { x: path[2][0], z: path[2][1], y: world.height_at(path[2][0], path[2][1]) + 0.4 }; } // ── 식생(장면의 살) — 스트리밍 초지 + 바닥 클러터 + 수목 산포 ── nature.grass_field({ area: 150, count: 24000, base: gr[1], tip: gr[3], height: 0.5, y: terrain.height_at }); nature.clouds({ count: 12, style: 'puffy' }); nature.ground_cover({ density: clamp(num(c.vegetation, 1.4), 0.2, 2.5) }); var WEATHERS = ['clear', 'petals', 'leaves', 'rain', 'snow']; var wsel = pick(c.weather, WEATHERS, 'clear'); // 사실적 대낮 기본 = 맑음(꽃잎 기본은 meadow 의 것) if (wsel !== 'clear') nature.weather(wsel, { count: 70 }); var tree = new THREE_.Group(); var t1 = new THREE_.Mesh(new THREE_.CylinderGeometry(0.22, 0.3, 1.6, 7), P.make.matte(gr[0])); t1.position.y = 0.8; tree.add(t1); var t2 = new THREE_.Mesh(new THREE_.ConeGeometry(1.1, 2.1, 9), P.make.matte(gr[2])); t2.position.y = 2.2; tree.add(t2); world.scatter('tree', { per_chunk: 10, scale: [0.9, 1.5], collide_r: 0.7, fallback: tree }); // 숲 밀도(meadow 9 대비 +1) // 실수목 근경 식재 — meadow 와 동일 판별 캐논(실측 h ∩ 이름-토큰 배제; 태그 단독 판별 금지). var _extm = (typeof window !== 'undefined' && window['__PLAYABLE_'+'EXTRA_MANIFEST__'] && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].models) || {}; function _kit_h(n) { return num(_extm[n] && _extm[n].h, 0); } var _kit_trees = Object.keys(_extm).filter(function (n) { var t = _extm[n].tags || []; return (t.indexOf('tree') >= 0 || t.indexOf('nature') >= 0) && _kit_h(n) >= 2.2 && !/flower|bush|grass|rock/.test(n); }); if (_kit_trees.length) { var _tprng = world._priv.mulberry32(((seed * 48271) ^ 0x9E3779B9) >>> 0); for (var _ti = 0; _ti < 16; _ti++) { var _ta = _tprng() * Math.PI * 2; var _tr = (_ti % 5 < 3) ? (14 + _tprng() * 41) : (55 + _tprng() * 50); // 근경 가중 2밴드(meadow 캐논) var _tx = Math.cos(_ta) * _tr, _tz = Math.sin(_ta) * _tr; if (water_y != null && world.height_at(_tx, _tz) < water_y + 0.3) continue; // 수중 식재 방지 world.spawn(_kit_trees[_ti % _kit_trees.length], { at: { x: _tx, z: _tz } }); } } // ── 플레이어(산책 속도) + 카메라 + 조작 + 미니맵 — walk 전용. observe 는 아바타·조이스틱· // 미니맵 없이 셸 소유 드리프트 카메라(아래 단일 루프)가 장면을 운반한다. ── var player = null; if (presence === 'walk') { var CHARS = ['farmer', 'adventurer', 'villager', 'knight', 'rogue', 'worker', 'king', 'mage', 'barbarian', 'striker', 'robot']; var _psel = typeof c.player === 'string' && (_extm[c.player] && _extm[c.player].cat === 'character') ? c.player : pick(c.player, CHARS, 'adventurer'); player = world.player({ spawn: _psel, at: { x: 0, z: 0 }, speed: 3.6, run_speed: 6.5 }); // 순수 산책(vp_none)은 mission 을 비운다 — 셸 미션 칩(상시 목표 문구 + 경과 타이머)이 // 게임 표면이므로 구조적으로 미장착. 힌트는 아래 토스트 경로로 합류. world.camera(); world.controls({ mission: (c.hint && !vp_none) ? String(c.hint) : '' }); world.minimap(); } if ((presence !== 'walk' || vp_none) && c.hint) { P.toast(String(c.hint).slice(0, 60)); // observe·순수 산책은 목표 칩 대신 힌트만 토스트로 } // ── 동물(분위기) ── var ANIMALS = ['deer', 'sheep', 'fox', 'bird', 'stag', 'horse', 'dog', 'cat', 'wolf', 'pig', 'fish']; (Array.isArray(c.creatures) ? c.creatures.slice(0, 5) : [{ model: 'deer', x: 22, z: 14 }, { model: 'bird', x: -14, z: 26 }, { model: 'fox', x: 30, z: -24 }]).forEach(function (an) { var m = pick(an && an.model, ANIMALS, 'deer'); var h = world.spawn(m, { at: { x: clamp(num(an && an.x, 20), -110, 110), z: clamp(num(an && an.z, 20), -110, 110) }, collide: false }); h.play('idle'); }); // ── 뷰포인트(무압박 발견) — POI 조형 공유(_poi_mesh), 타이머 없음. observe 는 발견 주체 // (아바타)가 없으므로, 순수 산책(vp_none)은 발견이 게임 표면이므로, 각각 뷰포인트/발견 // 표면 전체를 구조적으로 생략(순수 관찰·순수 산책 계약). ── var vps = presence === 'walk' && !vp_none && Array.isArray(c.viewpoints) ? c.viewpoints.slice(0, 5) : []; if (!vps.length && presence === 'walk' && !vp_none) { warn('legacy default viewpoints injected (3) — pass viewpoints:"none" for a pure stroll'); vps = [ { label: _at_t('고요한 물가', '静かな水辺', 'Quiet Waterside'), kind: 'stones', x: focal ? clamp(focal.x, -110, 110) : 38, z: focal ? clamp(focal.z, -110, 110) : 28 }, { label: _at_t('바람의 언덕', '風の丘', 'Windy Hill'), kind: 'arch', x: -46, z: 24 }, { label: _at_t('오래된 나무', '古い木', 'The Old Tree'), kind: 'tree', x: 20, z: -52 }, ]; } var discovered = 0, complete = false; var vp_items = vps.map(function (p, i) { var x = clamp(num(p && p.x, 30 + i * 15), -120, 120), z = clamp(num(p && p.z, 30 - i * 25), -120, 120); var kind = pick(p && p.kind, ['arch', 'stones', 'tree'], 'stones'); var label = String((p && p.label) || 'View ' + (i + 1)).slice(0, 24); var g = _poi_mesh(kind, pal); g.position.set(x, world.height_at(x, z), z); scene.add(g); fx.spawn(g); return { label: label, index: i, seen: false, x: x, z: z, g: g, mk: world.marker(g, { label: label }) }; }); // ── HUD(최소 — 제목 + 발견 칩; 타이머·압박 요소 없음. observe·순수 산책은 칩 없이 제목만) ── var ui = document.createElement('div'); ui.id = 'playable-archetype-hud'; ui.style.cssText = 'position:fixed;left:64px;top:calc(52px + env(safe-area-inset-top,0px));z-index:20;color:#fff;font-family:system-ui,sans-serif;pointer-events:none;text-shadow:0 2px 8px rgba(0,0,0,.45);'; if (P._ui) P._ui.on_claim(function (nm) { if (nm === 'minimap') ui.style.right = (P._ui.Z.minimap.r + P._ui.Z.minimap.w + 12) + 'px'; }); ui.innerHTML = '
' + (presence === 'walk' && vps.length ? '
' : ''); ui.children[0].textContent = String(c.title || _at_t('고요한 산책', '静かな散歩', 'A Quiet Walk')).slice(0, 40); document.body.appendChild(ui); function _hud() { if (ui.children[1]) ui.children[1].textContent = _at_t('발견', '発見', 'Found') + ' ' + discovered + ' / ' + vp_items.length; } _hud(); // ── 단일 루프: 근접 발견 + 앰비언트 스파클 + fill 수위 + observe 드리프트 카메라 // (프레임은 결코 정지하지 않는다 원칙의 셸 소유 집행) ── var _spark_t = 0, _spark_i = 0, _obs_t = 0; var _cam = P.three.camera, _obs_r0 = wkind === 'sea' ? 62 : 46; // observe 궤도 중심 — 초점을 ±55 로 클램프해 카메라가 초기 지형 링(±112m)을 벗어나지 않게 // 한다(플레이어 없는 씬은 스트리밍 틱이 없어 링 밖 = 맨땅). var _obs_c = focal ? { x: clamp(focal.x, -55, 55), z: clamp(focal.z, -55, 55) } : { x: 0, z: 0 }; P.loop(function (dt) { if (dt <= 0) return; if (fill_state && water_mesh) { fill_state.t += dt; var fk = Math.min(1, fill_state.t / process_dur); var fe = fk * fk * (3 - 2 * fk); // smoothstep — 차오름의 서사(초반 실개천·후반 완만 정착) water_mesh.position.y = fill_state.y0 + (water_y - fill_state.y0) * fe; if (!fill_state.aq && fk >= 0.85) { // 수생 생태는 수위 정착 무렵 합류(공중 부유 릴리패드 방지) fill_state.aq = true; nature.aquatic({ biome: wkind === 'sea' ? 'coast' : 'pond' }); } if (fk >= 1) { water_mesh.position.y = water_y; if (focal) fx.burst({ x: focal.x, y: water_y + 1.2, z: focal.z }, { count: 18, color: pal.accent, speed: 1.4, life: 1.6, gravity: -0.05 }); A._emit('process', { kind: 'fill', duration: process_dur }); fill_state = null; } } if (presence === 'observe') { _obs_t += dt * 0.045; // 한 바퀴 ~140s — 관조의 속도(플레이어 이동 대체) var orr = _obs_r0 + Math.sin(_obs_t * 2.3) * 6; var ox2 = _obs_c.x + Math.cos(_obs_t) * orr, oz2 = _obs_c.z + Math.sin(_obs_t) * orr; var wl = water_mesh ? water_mesh.position.y : water_y; var oy2 = Math.max(world.height_at(ox2, oz2) + 7, (wl != null ? wl : h0) + 5.5) + Math.sin(_obs_t * 3.1) * 1.2; _cam.position.set(ox2, oy2, oz2); _cam.lookAt(_obs_c.x, (wl != null ? wl : world.height_at(_obs_c.x, _obs_c.z)) + 1.2, _obs_c.z); } _spark_t += dt; if (_spark_t > 4) { // ~4s 주기의 은은한 입자 — 초점(물가·뷰포인트)을 순환 _spark_t = 0; var tgt = (_spark_i++ % 2 === 0 && focal) ? focal : (vp_items.length ? vp_items[_spark_i % vp_items.length] : focal); if (tgt) { var ty = tgt.y != null ? tgt.y : world.height_at(tgt.x, tgt.z); fx.burst({ x: tgt.x, y: ty + 1.4, z: tgt.z }, { count: 5, color: pal.accent, speed: 0.7, life: 1.7, gravity: 0.02 }); } } for (var vi = 0; player && vi < vp_items.length; vi++) { var it = vp_items[vi]; if (it.seen) continue; var dx = player.pos.x - it.x, dz = player.pos.z - it.z; if (dx * dx + dz * dz < 36) { it.seen = true; discovered++; it.mk.remove(); if (it.g._pulses) { for (var _pi = 0; _pi < it.g._pulses.length; _pi++) { try { it.g._pulses[_pi].stop(); } catch (e) {} } } fx.despawn(it.g, { dur: 0.5 }); var fp = { x: it.x, y: world.height_at(it.x, it.z) + 2, z: it.z }; fx.burst(fp, { count: 22, color: pal.accent, speed: 2.6, life: 1.2, gravity: -0.12 }); fx.float_text(fp, it.label, { color: pal.accent }); fx.sfx('powerup'); _hud(); A._emit('discover', { label: it.label, index: it.index, discovered: discovered, total: vp_items.length }); _check_complete(); } } }); // ── 완주(전 뷰포인트 발견) — 차분한 축하 + 기록 카드; 음악 전환 없음(고요 정체성 유지), // 세계는 계속 살아있다(시뮬레이터 계약 — voxel_city 동형) ── function _check_complete() { if (complete || discovered < vp_items.length) return; complete = true; fx.burst({ x: player.pos.x, y: player.pos.y + 2.2, z: player.pos.z }, { count: 34, color: pal.accent, speed: 4, life: 1.4, gravity: -0.3 }); P.toast(String(c.complete_text || _at_t('모든 풍경을 눈에 담았어요.', 'すべての景色を心に刻みました。', 'You took in every view.')).slice(0, 60)); A._emit('complete', { discovered: discovered, total: vp_items.length }); setTimeout(function () { P.three.capture().then(function (shot) { P.share_shot(shot, { title: String(c.title || _at_t('고요한 산책', '静かな散歩', 'A Quiet Walk')).slice(0, 40), lines: ['🏞 ' + _at_t('발견', '発見', 'Found') + ' ' + discovered + ' / ' + vp_items.length], share_text: String(c.share_text || _at_t('고요한 풍경 속을 거닐었어요 🏞', '静かな風景の中を歩きました 🏞', 'A quiet walk through beautiful scenery 🏞')).slice(0, 120), }); }); }, 900); } P.mount_share(function () { return String(c.share_text || _at_t('고요한 풍경 속을 거닐었어요 🏞', '静かな風景の中を歩きました 🏞', 'A quiet walk through beautiful scenery 🏞')).slice(0, 120); }, String(c.share_label || _at_t('공유', '共有', 'Share')).slice(0, 12)); A._installed = 'scenic_explorer'; return { player: player, // observe 모드에선 null(아바타 없음 계약) get discovered() { return discovered; }, total: vp_items.length, get complete() { return complete; }, get water_y() { return water_y; }, get water_level() { return water_mesh ? water_mesh.position.y : water_y; }, // fill 진행 관측면 }; }; // ═══ 아키타입 #13: camo_hideseek — 위장 숨바꼭질(헌터/하이더 2모드, 완비·골든-테스트 대상) ═══ // 정본(리서치 확정): Meccha Chameleon 류 "몸에 색을 칠해 배경에 의태하는 숨바꼭질"의 싱글플레이 // 정직 번역(멀티 넷코드는 플랫폼 계약 밖 — 봇 번역, BR-lite 선례와 동일 공개 원칙). // · 무대 = 축정렬 유채색 스크린 8면(위장 놀이는 "판별 가능한 색의 무대"가 정체성 — 기능색). // · hunter 모드: 벽색으로 의태한 마네킹 위장체를 제한시간 내 전원 발견(fps 사격 = 발견 동사). // 판정은 공유 순수 함수 _cam_fire_ray(스모크가 동일 구현을 소비 — 매퍼 규율) + 벽 AABB 차폐. // · hider 모드: 스포이드 탭(벽 표면 색 채집)으로 자기 몸 3부위를 칠하고 AI 헌터의 순찰을 버틴다. // 발각 = 결정론 휴리스틱(색-거리·이동·근접·정면성 — _cam_rate 순수 함수가 단일 소유). // · 위장체/플레이어 몸 = 아키타입 소유 흰 마네킹 프리미티브(GLB 텍스처 틴트는 색 정밀도가 // 보장 안 됨 — 색 일치가 코어 메커니즘이므로 재질 색을 데이터로 소유한다. 눈은 미도색 = 원작 // 의 "눈만은 하얗다" 발견 단서 재현). fps 몹 레이는 위치+반경 해석 판정이라 시각체는 자유. _ARCH.camo_hideseek = function (cfg) { P._run_always = true; // 상시 달리기 — 시작 카드(Shift 안내) 생성 *전*에 선언해야 카드에서 소거된다(순서 결함 실보고 2026-07-24) P._no_jump_voice = true; // 점프 = 효과음만(사람 목소리 제거 실보고) var c = cfg || {}; // ── Tier B config 검증(전 필드 기본값·클램프·enum — 어떤 입력에도 동작 보장) ── var mood_req = c.mood === 'night' ? 'night_neon' : c.mood; if (mood_req === 'night_neon' || mood_req === 'ember' || mood_req === 'dusk') { warn('dark mood "' + mood_req + '" demoted to sunset — archetype starts in daylight'); mood_req = 'sunset'; } var mood_name = pick(mood_req, P.moods || [], 'noon'); if (mood_req && mood_name !== mood_req) warn('unknown mood "' + mood_req + '" → ' + mood_name); var pal = P.mood(mood_name); P.music(pick(c.music, ['calm_exploration', 'adventure', 'cozy', 'mystery', 'night', 'chiptune_action', 'battle', 'triumph', 'space', 'spooky', 'lofi', 'sad', 'happy'], 'lofi')); // 잔잔한 시작이 정본(실보고 4 — 숨바꼭질의 관찰 정서) P.ambience(pick(c.ambience, ['forest', 'wind', 'rain', 'night', 'ocean', 'cave', 'crowd'], 'wind')); P.enable_bloom({ strength: 0.5, radius: 0.5, threshold: 1.05 }); var world = P.world, nature = P.nature, fx = P.fx; var seed = (num(c.seed, 17) | 0); // ── 실험: 완성형 맵 스테이지 선언(파싱은 지형 생성보다 앞 — 맵 모드는 기저 세계 자체를 바꾼다) ── // c.map_stage = 생성기가 카탈로그에서 해석·주입한 {name,w,d,h}(정직성 게이트가 문자열→객체 치환/제거). // 맵-세계 모드 캐논(실보고 2026-07-24 "이중 구조가 근본 문제"): 맵 = 세계 그 자체. 기저 지형은 // 엔진의 바닥 프리미티브라 소거 불가하므로 *비활성 공허*로 강등 — 완전 평탄 + 초원/구름/원경 // 미생성(밖이 "다른 세계"로 보이는 이중 구조의 가시면 소거), 도달은 실내 폐루프가 차단(아래). var MS = (c.map_stage && typeof c.map_stage === 'object' && c.map_stage.name && num(c.map_stage.w, 0) > 0) ? c.map_stage : null; // 방 단위 맵 선택(2026-07-27): c.map_roster = 생성기 해석 [{name,w,d,h}] — ?camo_map= 파라미터가 // 로스터 내 항목이면 그 맵으로 스테이지 오버라이드(방 생성/입장의 맵 전환 = 파라미터 리로드 문법). var _MAP_ROSTER = Array.isArray(c.map_roster) ? c.map_roster.filter(function (r0) { return r0 && r0.name && num(r0.w, 0) > 0; }) : []; if (MS && !_MAP_ROSTER.some(function (r0) { return r0.name === MS.name; })) _MAP_ROSTER.unshift(MS); // 기본 스테이지도 로스터 1급 try { var _mq = new URLSearchParams(location.search).get('camo_map') || ''; if (/^[a-z0-9_]{1,24}$/.test(_mq)) { var _mhit = _MAP_ROSTER.filter(function (r0) { return r0.name === _mq; })[0]; if (_mhit) MS = _mhit; } } catch (e) {} var terrain = world.terrain({ seed: seed, amplitude: MS ? 0.0001 : 1, scale: 240, chunk: 32, radius: 3 }); // near-flat 기반(안뜰 밖) · 맵-세계 = 완전 평탄 공허 var prng = world._priv.mulberry32(((seed * 31337) ^ 0x9E3779B9) >>> 0); var gr = pal.ground_ramp || [pal.ground, pal.ground, pal.ground, pal.ground]; nature.wind({ strength: 0.4, gustiness: 0.4, direction: { x: 0.8, z: 0.3 } }); if (!MS) { // 맵-세계 모드 = 외부 세계 드레싱 자체를 생성하지 않는다(맵이 세계의 전부) nature.grass_field({ area: 150, count: 12000, base: gr[1], tip: gr[3], height: 0.4, y: terrain.height_at }); nature.clouds({ count: 10, style: 'puffy' }); _mgmt_scenery(prng, gr, [{ x0: -25, x1: 25, z0: -19, z1: 19 }], 30, 62, 12, 8, 5); // 담장 너머 원경 실루엣 } // ── hideshare: 비동기 고스트 은신 공유(2026-07-24 사용자 확정 — "친구에게 URL 도전장") ── // 하이더 생존 → 은신 기록(위치+3부위색+포즈) 저장 → ?hide={id} 도전장 URL 발급. 방문자는 // 헌터로 그 기록(+최근 풀)을 헌트 — 원작(사람이 사람을 찾는다)의 비동기 복원. 저장소 = // 리더보드와 같은 게임키 DO(/g-hide/{key}, additive-only). 우아한 부재 캐논(P.leaderboard // 동형): 게임키 없음·네트워크 실패 = 조용한 기능 비활성(게임은 항상 완전). var _hs_key = (function () { try { var m2 = location.pathname.match(/^\/g\/([a-f0-9]{8,64})$/); if (m2) return m2[1]; } catch (e) {} if (typeof c.hide_key === 'string' && /^[a-f0-9]{8,64}$/.test(c.hide_key)) return c.hide_key; // dev/프리뷰 노브(오리진 밖 테스트) return null; })(); var _hs_base = (typeof c.hide_base === 'string' && /^https:\/\//.test(c.hide_base)) ? c.hide_base.replace(/\/$/, '') : ''; // 기본 = 동일 오리진(프리뷰만 절대 URL 노브) var _hs_ch = null; try { _hs_ch = new URLSearchParams(location.search).get('hide') || null; } catch (e) {} if (_hs_ch && !/^[a-z0-9]{4,16}$/.test(_hs_ch)) _hs_ch = null; // 연속 플레이 재시작 파라미터(공유 카드 팀 버튼 → location.reload 무결 복귀 문법, creature_quest 캐논) var _replay_role = null, _boot_new = false, _boot_join = null; try { var _bq = new URLSearchParams(location.search); _replay_role = _bq.get('camo_role') || null; _boot_new = _bq.get('camo_new') === '1'; // 맵 선택 후 새 방 직행(방 패널 재경유 없음) var _bj = _bq.get('camo_join') || ''; if (/^[a-z0-9]{6,16}$/.test(_bj)) _boot_join = _bj; } catch (e) {} if (_replay_role !== 'hider' && _replay_role !== 'hunter') _replay_role = null; var _hs_ai = false; // 술래 방 선택에서 'AI 게임방' — 고스트 로드 생략, AI 배치 강제 var _hs_join = null, _hs_join_row = null; // 하이더 참가 모드(2026-07-25) — 기존 방 id/행(방명 유지·참가자 닉네임만 입력) var _hs_pin = null; // 잠금 방 입장 PIN(개설/참가/헌트/기록 공용 — 검증 성공 시에만 세팅) try { // 잠금 방 맵-리로드 간 PIN 핸드오프(URL 노출 금지 — 세션 스토리지 1회성). 선언 *뒤* 실행(초기화 덮어쓰기 함정) var _hp0 = sessionStorage.getItem('camo_pin'); if (_hp0 && /^[0-9]{4}$/.test(_hp0)) _hs_pin = _hp0; sessionStorage.removeItem('camo_pin'); } catch (e) {} function _hs_esc(t9) { // 자유 텍스트 이름(140자·이모지) 렌더 전 필수 이스케이프 — innerHTML 주입의 유일 관문 return String(t9 == null ? '' : t9).replace(/[&<>"']/g, function (c9) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c9]; }); } function _hs_name(row9) { // 표시 이름: 자유 이름(nm) 우선, 레거시 = "{이니셜}의 게임방" if (row9 && row9.nm) return String(row9.nm); var i9 = String((row9 && row9.i) || '???'); return _at_t(i9 + '의 게임방', i9 + 'のルーム', i9 + "'s room"); } function _hs_ini_of(nm9) { // 레거시 initials 파생(영숫자 3자, 없으면 YOU) — 서버 필수 필드 호환 return ((String(nm9 || '').match(/[A-Za-z0-9]/g) || []).slice(0, 3).join('').toUpperCase()) || 'YOU'; } function _hs_chip(row9) { // 아바타 칩 = 이름 첫 1~2 문자(이모지 안전 — 코드포인트 단위) var src9 = (row9 && row9.nm) ? row9.nm : String((row9 && row9.i) || '?'); return Array.from(String(src9)).slice(0, 2).join(''); } function _hs_pin_modal(row9, cb9) { P._modal_freeze = true; // PIN 입력 중 게임 시간·입력 동결(방 모달과 동일 캐논) var done9 = false; var pv9 = document.createElement('div'); pv9.setAttribute('data-mgmt-ui', '1'); pv9.style.cssText = 'position:fixed;inset:0;z-index:99979;display:flex;align-items:center;justify-content:center;background:rgba(6,9,16,.82);backdrop-filter:blur(5px);font-family:system-ui,sans-serif;'; var pc9 = document.createElement('div'); pc9.style.cssText = 'width:min(88vw,300px);border-radius:20px;padding:20px 18px 16px;background:linear-gradient(180deg,rgba(18,26,38,.97),rgba(11,16,26,.98));border:1px solid rgba(255,210,62,.35);box-shadow:0 26px 60px rgba(0,0,0,.6);color:#e8ecf4;text-align:center;'; pc9.innerHTML = '
🔒 ' + _at_t('비밀번호 입력', 'パスワード入力', 'Enter Password') + '
' + '
' + _at_t('이 방은 잠겨 있어요 — 4자리 숫자', 'この部屋はロック中 — 数字4桁', 'This room is locked — 4 digits') + '
'; var pi9 = document.createElement('input'); pi9.type = 'tel'; pi9.inputMode = 'numeric'; pi9.maxLength = 4; pi9.autocomplete = 'off'; pi9.style.cssText = 'width:130px;padding:11px 0;border-radius:12px;border:2px solid rgba(255,210,62,.5);background:rgba(0,0,0,.3);color:#ffd23e;font:900 24px/1 ui-monospace,monospace;letter-spacing:.4em;text-align:center;outline:none;'; pi9.addEventListener('input', function () { pi9.value = pi9.value.replace(/[^0-9]/g, '').slice(0, 4); }); pc9.appendChild(pi9); var pm9 = document.createElement('div'); pm9.style.cssText = 'min-height:17px;font:700 12px system-ui;color:#ff8a8a;margin-top:8px;'; var pb9 = document.createElement('button'); pb9.type = 'button'; pb9.setAttribute('data-mgmt-ui', '1'); pb9.textContent = _at_t('입장', '入る', 'Enter'); pb9.style.cssText = 'display:block;width:100%;margin-top:10px;padding:12px;border:0;border-radius:999px;cursor:pointer;font:900 15px system-ui;background:linear-gradient(180deg,#ffe98a,#ff9a3d);color:#3a2405;box-shadow:0 8px 20px rgba(255,150,40,.4);touch-action:manipulation;'; pb9.addEventListener('pointerdown', function (ev9) { ev9.preventDefault(); ev9.stopPropagation(); var pin9 = pi9.value; if (!/^[0-9]{4}$/.test(pin9)) { pm9.textContent = _at_t('4자리 숫자를 입력하세요', '数字4桁を入力', 'Enter 4 digits'); return; } pb9.style.opacity = '0.6'; fetch(_hs_url('?id=' + row9.id + '&pin=' + pin9)).then(function (r) { return r.json(); }).catch(function () { return null; }) .then(function (res9) { pb9.style.opacity = '1'; if (res9 && res9.row) { done9 = true; P._modal_freeze = false; try { pv9.remove(); } catch (e9) {} cb9(pin9, res9.row); } else pm9.textContent = _at_t('비밀번호가 달라요', 'パスワードが違う', 'Wrong password'); }); }); var px9 = document.createElement('button'); px9.type = 'button'; px9.setAttribute('data-mgmt-ui', '1'); px9.textContent = _at_t('취소', 'キャンセル', 'Cancel'); px9.style.cssText = 'display:block;margin:8px auto 0;padding:7px 14px;border:0;border-radius:999px;background:transparent;color:rgba(232,236,244,.6);font:700 12.5px system-ui;cursor:pointer;'; px9.addEventListener('pointerdown', function (ev9) { ev9.preventDefault(); ev9.stopPropagation(); done9 = true; P._modal_freeze = false; try { pv9.remove(); } catch (e9) {} cb9(null, null); }); pc9.appendChild(pm9); pc9.appendChild(pb9); pc9.appendChild(px9); pv9.appendChild(pc9); document.body.appendChild(pv9); try { pi9.focus(); } catch (e9) {} } // ── 우상단 프리미엄 사이드 스택(사용자 확정 2026-07-25): [‹ 처음으로] + 술래 전용 [캐릭터 교체] // [친구 초대] — 글로시 시린 + 샤인 스윕 + 스태거 등장(공유 카드 CTA 문법 미러, 상시 가시). ── function _camo_side_ui(r9) { if (document.getElementById('camo-side-ui')) return; if (!document.getElementById('playable-shot-anim')) { var st9 = document.createElement('style'); st9.id = 'playable-shot-anim'; st9.textContent = '@keyframes pshotSweep{0%{transform:translateX(-140%) skewX(-18deg)}55%,100%{transform:translateX(340%) skewX(-18deg)}}' + '@keyframes pshotRise{0%{opacity:0;transform:translateY(16px)}100%{opacity:1;transform:translateY(0)}}'; document.head.appendChild(st9); } var col9 = document.createElement('div'); col9.id = 'camo-side-ui'; col9.setAttribute('data-mgmt-ui', '1'); col9.style.cssText = 'position:fixed;left:10px;top:calc(70px + env(safe-area-inset-top,0px));z-index:41;display:flex;flex-direction:column;gap:9px;align-items:flex-start;'; // 좌상단(사운드/메뉴 칩 아래) — HUD 는 우상단으로 이양(겹침 소거, 사용자 확정 2026-07-25) function _sbtn(icon9, label9, grad9, ink9, glow9, delay9, on9) { var b9 = document.createElement('button'); b9.type = 'button'; b9.setAttribute('data-mgmt-ui', '1'); b9.style.cssText = 'position:relative;overflow:hidden;display:flex;align-items:center;gap:7px;padding:9px 13px;' + 'border:0;border-radius:999px;cursor:pointer;touch-action:manipulation;-webkit-tap-highlight-color:transparent;' + 'background:' + grad9 + ';color:' + ink9 + ';box-shadow:0 8px 20px ' + glow9 + ',inset 0 2px 0 rgba(255,255,255,.45),inset 0 -2px 0 rgba(0,0,0,.2);' + 'animation:pshotRise .5s cubic-bezier(.2,.8,.2,1) ' + delay9 + ' both;'; b9.innerHTML = '
' + '
' + '
' + icon9 + '
' + '
' + label9 + '
'; b9.addEventListener('pointerdown', function (ev9) { ev9.preventDefault(); ev9.stopPropagation(); on9(); }); col9.appendChild(b9); return b9; } if (_hs_key) _sbtn( // 뒤로가기 = 오프닝 복귀(파라미터 소거 리로드 — 무결 복귀 캐논) '
', _at_t('처음으로', 'はじめに', 'Home'), 'linear-gradient(180deg,rgba(46,56,76,.96),rgba(26,33,48,.96))', '#dbe4f2', 'rgba(0,0,0,.35)', '.05s', function () { try { location.href = location.pathname; } catch (e9) { location.reload(); } }); if (r9 === 'hunter') { _sbtn( '
', _at_t('캐릭터 교체', 'キャラ変更', 'Swap'), 'linear-gradient(180deg,#d9b8ff 0%,#a06bf0 55%,#7440c9 100%)', '#2a1050', 'rgba(160,107,240,.4)', '.13s', function () { if (world._priv.swap_screen) world._priv.swap_screen(); }); _sbtn( '
', _at_t('친구 초대', '友だち招待', 'Invite'), 'linear-gradient(180deg,#ffe98a 0%,#ffb52e 55%,#ff8a3d 100%)', '#3a2405', 'rgba(255,150,40,.4)', '.21s', function () { P.share(String(c.share_text || _at_t('위장 숨바꼭질에서 나를 찾아봐! 🦎', 'カモフラかくれんぼで僕を探してみて! 🦎', 'Come find me in camo hide & seek! 🦎')).slice(0, 120)); }); } document.body.appendChild(col9); } function _replay_url(r) { try { var u = new URL(location.href); u.searchParams.delete('hide'); // 같은 도전장 재수행은 무의미 — 풀-랜덤/AI 로 새 판 u.searchParams.set('camo_role', r); u.hash = ''; return u.toString(); } catch (e) { return location.pathname + '?camo_role=' + r; } } function _hs_url(q) { return _hs_base + '/g-hide/' + _hs_key + (q || ''); } var role = pick(c.role, ['hunter', 'hider'], 'hider'); // 기본=하이더(숨바꼭질 직관 = 내가 숨는다 + 붓 아바타 정합, Planner 프롬프트와 동기 2026-07-22) if (_hs_ch && _hs_key) role = 'hunter'; // 도전장 방문자 = 찾는 사람(config role 보다 우선 — 링크의 의미가 곧 모드) var diff = pick(c.difficulty, ['easy', 'normal', 'hard'], 'normal'); // imperfect = 의태 불완전도 — 실보고 2 봉합: 구값(0.14~0.26)은 "한눈에 보이는" 수준이라 게임 // 성립 불가. 원작 문법 = 거의 완벽한 색 일치, 발견 단서는 눈·실루엣·미세 편차(하향 확정). var DIFF = { easy: { imperfect: 0.12, whistle: 7, detect: 0.55 }, normal: { imperfect: 0.05, whistle: 10, detect: 0.8 }, hard: { imperfect: 0.02, whistle: 14, detect: 1.1 } }[diff]; var n_hiders = clamp(Math.round(num(c.hiders, 6)), 4, 10); var round_s = clamp(num(c.round_s, 150), 60, 300); var hide_s = clamp(num(c.hide_s, 25), 10, 60); var seek_s = clamp(num(c.seek_s, 75), 30, 180); // ── 색 유틸(코어 메커니즘 = 색 데이터 소유) ── function _rgb(hex) { var n = parseInt(hex.slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } function _hex(r3) { function b2(v) { var s = Math.round(clamp(v, 0, 255)).toString(16); return s.length < 2 ? '0' + s : s; } return '#' + b2(r3[0]) + b2(r3[1]) + b2(r3[2]); } function _mix(a, b, t) { var A = _rgb(a), B = _rgb(b); return _hex([A[0] + (B[0] - A[0]) * t, A[1] + (B[1] - A[1]) * t, A[2] + (B[2] - A[2]) * t]); } function _cdist(a, b) { // 정규화 색-거리 0(동일)..1(최대) — 발각 휴리스틱의 단일 척도 var A = _rgb(a), B = _rgb(b); return Math.sqrt((A[0] - B[0]) * (A[0] - B[0]) + (A[1] - B[1]) * (A[1] - B[1]) + (A[2] - B[2]) * (A[2] - B[2])) / 441.7; } // ── 무대: 밀폐 컬러 안뜰(실보고 3 봉합 — 원작 맵 리서치 확정: 공식 7맵 전부 밀폐형 컬러 // 실내/구조물. 핵심 문법 = "모든 시선이 유채색 표면에서 끝난다". 초원+독립 스크린은 원거리 // 시선이 잔디에서 끝나 위장체가 들판에 노출됨 → 외곽 4벽(도색 세그먼트) + 내부 파티션 + // 컬러 바닥 패치의 안뜰로 재설계. 축정렬 = AABB 차폐/충돌 정본 유지) ── var SCREEN_HUES = ['#d95763', '#e0a13c', '#7fb069', '#4a9fd8', '#9b6bd4', '#3f9f8a', '#c76b8e', '#c9b26b']; var walls = [], _sampleables = []; // 무대 재설계(실보고 4 봉합 — 원작 맵 리서치: 공식 7맵 전부 "어수선한 다실 실내 + 흉내낼 소품 // 사이 은신". 단일 개방 안뜰은 시선이 길고 위장체가 평면 벽에 노출 → ① 다실 파티션 그리드 // ② 컬러 소품 클러터(기둥/상자더미/화단 — '기둥이 실루엣을 끊는다'는 원작 문법) ③ 위장체를 // 벽+소품에 분산. 소품은 walls[] 호환 오클루더라 차폐·배치·순찰·스포이드에 자동 참여). // ── 맵 스테이지 무대 치수(MS 는 위(지형 생성 앞)에서 선언) — 설정 시 절차 무대(벽·파티션·복층· // 지하·가구)를 전부 생략하고 GLB 맵이 무대가 된다. walls[] 가 비므로 색 의미론(스포이드·의태 // 기준·LOS·위장체 배치)은 spawn_map 핸들의 메시 레이캐스트 + 표면색 리드백으로 대체(각 지점 분기). ── var ROOM_W = MS ? clamp(num(MS.w, 20), 6, 80) : 50, ROOM_D = MS ? clamp(num(MS.d, 16), 6, 80) : 40, WH2 = 3.4; // WH2 = 파티션 높이(1층 방 스케일 유지) var WH_P = MS ? Math.max(2.2, num(MS.h, 4)) : 6.2; // 둘레벽 높이 — 2층 데크(파티션 위)까지 밀폐(실보고 2026-07-23 층 구조). 맵 모드 = 맵 실측 높이 var _extra_occl = []; // 층 구조 등 비-벽 차폐체(발각/사격 LOS 공용) — walls[] 의미론(색·은신면) 불침범 var GC = 4, GR = 3, CW = ROOM_W / GC, CD = ROOM_D / GR; // 방 격자 4×3(아담한 12방 — 밀집·다코너), 셀 폭/깊이 var DOORW = 4.6; // 문 구멍 폭(공유 — 파티션·가구회피). 넓게 = 통행 원활(밀집 회귀 봉합) var SPAWN_XZ = { x: 0, z: Math.min(4, ROOM_D / 2 - 1.5) }; // 하이더 스폰(가구가 여길 침범하면 캐릭터와 겹침 — 클리어). 소형 맵 = 내부 클램프 var _rhmax = -Infinity; for (var rhx = -ROOM_W / 2; rhx <= ROOM_W / 2; rhx += 6) for (var rhz = -ROOM_D / 2; rhz <= ROOM_D / 2; rhz += 6) _rhmax = Math.max(_rhmax, world.height_at(rhx, rhz)); var Y_R = _rhmax + (MS ? 0.6 : 3.75); // 승상(+3.2) = 지하실이 지형 위에 놓인다(ground_at=max(terrain,ext) 합성상 // 지형 아래 바닥은 표현 불가 — 무대 전체를 들어올리는 것이 유일한 구조 해법. 실보고 2026-07-24 지하실) // 맵 모드 = 지하실 없음: 지형 요철 위 소승상만(맵 바닥이 max 합성에서 항상 이기는 최소 높이) var B1Y = Y_R - 3.1; // 지하 바닥 var BM = { x0: -ROOM_W / 2 + 0.35, x1: -5.0, z0: -ROOM_D / 2 + 0.35, z1: -ROOM_D / 2 + ROOM_D / 3 - 0.4 }; // 지하 footprint(좌-후방) var SH = { x0: -11.9, x1: -5.0, z0: -9.0, z1: -7.3 }; // 지하 계단 개구부(+x 로 오름) // 맵 모드 무대 본체 — 원치수 GLB 스폰(지면 제공자·bounds·차폐 레이·표면색은 핸들이 소유). // closed = 폐쇄 실내 세계 선언: 지붕 구멍·저작 공백 위에서도 상승 불가(수직 이탈 결정론 봉합) var _MAPH = MS ? world.spawn_map(MS.name, { y: Y_R, closed: true, seal: true, anchor: 'dominant' }) : null; // anchor = 지배 보행면 하강(깊은 기초/지하 샤프트 저작의 부양 봉합 — poolrooms류) // seal = 결손 외벽 자동 봉인(정면 개방형 세트 실보고 2026-07-28) if (MS) scene.add(new THREE_.AmbientLight(0xffffff, 0.5)); // 맵-세계 실내 조도 하한 — 원저작 조명이 없는 저알베도 GLB(폐창고 등)의 시인성 보강 // 무대 입장 커버 — 맵 GLB 는 비동기 도착이라 첫 수 초간 "세트 밖 지형 위에 선 모습"이 노출된다 // (실보고 2026-07-24). 도착·착지 전까지 화면을 덮어 외부 노출을 0프레임으로. 20s 안전 타임아웃 // (네트워크 실패 시 영구 커버 = 게임 사망보다 열화 노출이 낫다 — LOUD 후 해제). if (MS) (function () { var cv9 = document.createElement('div'); cv9.style.cssText = 'position:fixed;inset:0;z-index:44;background:#0d1017;display:flex;align-items:center;justify-content:center;color:#9aa3b8;font:600 15px system-ui;transition:opacity .5s;'; cv9.textContent = _at_t('무대 입장 중…', '舞台に入場中…', 'Entering the stage…'); document.body.appendChild(cv9); var off9 = function () { if (!cv9.parentNode) return; cv9.style.opacity = '0'; setTimeout(function () { try { cv9.remove(); } catch (e) {} }, 550); }; _MAPH.on_ready(function () { setTimeout(off9, 400); }); // 착지 스냅 + 첫 프레임 뒤 페이드 setTimeout(function () { if (cv9.parentNode) { try { console.warn('[camo] map stage load timeout — cover released'); } catch (e) {} off9(); } }, 20000); })(); // 맵 모드 이동 구속 정본(양 모드 공유) — true = 이번 프레임 이동 기각(호출측이 직전 위치 복원). // ① footprint 하드 클램프 ② 3고도(발목 0.35·허리 0.75·가슴 1.25) × 몸 반경 0.3 연장 레이 — // 단일 가슴 레이는 창 개구부 대역·낮은 가구(부스 소파/테이블)를 놓쳐 몸이 파묻혔다(실보고) // ③ "지붕 아래" 불변식 — 실내 맵의 플레이 공간 = 상향 레이가 천장에 닿는 곳 전부, 그 외 전부 밖. // 문/개구부로 몇 레이가 다 빠져도 세트 밖(하늘 열림)으론 구조적으로 못 나간다. (밀폐 실내 전제 // — 야외 안뜰형 맵은 이 실험 플래그의 지원 범위 밖) function _map_move_deny(px0, pz0, pp) { var b9 = _MAPH.bounds; pp.x = clamp(pp.x, b9.x0 + 0.5, b9.x1 - 0.5); pp.z = clamp(pp.z, b9.z0 + 0.5, b9.z1 - 0.5); var mdx = pp.x - px0, mdz = pp.z - pz0, ml = Math.sqrt(mdx * mdx + mdz * mdz); if (ml < 0.0005) return false; var ex9 = pp.x + mdx / ml * 0.3, ez9 = pp.z + mdz / ml * 0.3; // 계단 스텝-리베이스(실보고 2026-07-28 "계단을 걸어서 못 오름"): 목표 지점의 보행면이 한 스텝 // (≤0.55m) 위/아래면 레이 기준면을 그 보행면으로 옮긴다 — 디딤판 리저(단높이 ~0.25m)가 발목 // 레이(0.35)에 벽으로 오인되던 것을 소거. 진짜 벽/가구는 리베이스 후에도 전 높이를 막으므로 불변. var gb9 = pp.y; var gn9 = world._priv.ground_ext_probe(ex9, ez9, pp.y + 0.9); if (gn9 != null && Math.abs(gn9 - pp.y) <= 0.55) gb9 = gn9; var H9 = [0.35, 0.75, 1.25]; for (var hi9 = 0; hi9 < 3; hi9++) if (_MAPH.blocked(px0, gb9 + H9[hi9], pz0, ex9, gb9 + H9[hi9], ez9)) return true; return false; } // 실내 합법-위치 폐루프 — "지붕 아래 = 실내"가 매 프레임의 상태 불변식. XZ-델타 거부만으로는 // *수직으로 이미 도달한* 밖(지붕 위 등)이 잔존한다(실보고: 지붕 위 정지). 이탈 감지 시 마지막 // 합법 전좌표(xyz)로 복원 + 수직속도 0 — 어떤 경로로 새어도 1프레임 내 자기교정. var _legal9 = { x: 0, z: 0, y: 0, ok: false }; function _map_interior_enforce(pp) { if (_MAPH.open_air) return; // 실외 로트 맵 — 지붕 불변식 비적용(footprint 클램프 + closed 천장캡이 봉쇄) if (_MAPH.ray_t(pp.x, pp.y + 1.0, pp.z, 0, 1, 0, 120) >= 0) { _legal9.x = pp.x; _legal9.y = pp.y; _legal9.z = pp.z; _legal9.ok = true; return; } if (!_legal9.ok) return; pp.x = _legal9.x; pp.y = _legal9.y; pp.z = _legal9.z; var PL9 = world._priv.player(); if (PL9 && PL9.vy > 0) PL9.vy = 0; } var rc_of = function (c, r) { return { x: -ROOM_W / 2 + (c + 0.5) * CW, z: -ROOM_D / 2 + (r + 0.5) * CD }; }; // 방별 바닥 톤 팔레트(따뜻한 우드/타일/카펫 — 무채-근접, 리서치: 바닥은 능동 팔레트 멤버). 러그로 존 앵커. var FLOOR_TONES = ['#6b5d4f', '#5a5147', '#4e5a52', '#57505e', '#664f4f', '#4f5866', '#5e5647', '#514f4a', '#48504e', '#5c5348', '#4d4a52', '#5a4f4a']; var RUG_HUES = ['#b5654e', '#4f7d8c', '#7a6ba8', '#c79a3e', '#5f8a5a', '#a85a6e', '#4a7a70', '#c07850']; // ── 표면 패턴 텍스처(맵 퀄리티 — 실보고 2026-07-23, Meccha 벽지/타일 문법): 공유 그레이스케일-근접 // 캔버스 3장(벽지 다이아 격자·바닥 체커 타일·러그 직조)을 material.color(색상) × map(패턴) 곱으로 // 소비 — 텍스처 3장/드로우콜 무증가, 벽 정체색(위장 색-매칭·스포이드 SSoT)은 material.color 에 // 그대로 남는다(패턴은 ±수% 명암 레이어일 뿐). UV 는 면별 월드-미터 리맵(_uv_world) → 벽 크기와 // 무관하게 패턴 밀도 일정. ── function _pat_tex(size, draw) { var cv = document.createElement('canvas'); cv.width = cv.height = size; var c2 = cv.getContext('2d'); c2.fillStyle = '#f2f2f2'; c2.fillRect(0, 0, size, size); draw(c2, size); var tex = new THREE_.CanvasTexture(cv); tex.wrapS = tex.wrapT = THREE_.RepeatWrapping; if (THREE_.SRGBColorSpace) tex.colorSpace = THREE_.SRGBColorSpace; tex.anisotropy = 4; return tex; } var _TEX_WALL = _pat_tex(128, function (c2, sz) { // 벽지 — 다이아 격자 + 교점 도트(원작 문법) c2.strokeStyle = 'rgba(0,0,0,0.075)'; c2.lineWidth = 2; c2.beginPath(); c2.moveTo(0, sz / 2); c2.lineTo(sz / 2, 0); c2.lineTo(sz, sz / 2); c2.lineTo(sz / 2, sz); c2.closePath(); c2.moveTo(-sz / 2, sz / 2); c2.lineTo(0, 0); c2.moveTo(sz, 0); c2.lineTo(sz * 1.5, sz / 2); c2.moveTo(0, sz); c2.lineTo(-sz / 2, sz * 1.5); c2.moveTo(sz, sz); c2.lineTo(sz * 1.5, sz * 1.5); c2.stroke(); c2.fillStyle = 'rgba(255,255,255,0.5)'; [[0, 0], [sz, 0], [0, sz], [sz, sz], [sz / 2, sz / 2]].forEach(function (pt) { c2.beginPath(); c2.arc(pt[0], pt[1], 3, 0, 6.2832); c2.fill(); }); c2.fillStyle = 'rgba(0,0,0,0.04)'; // 미세 결(플랫 단색 밋밋 봉합) for (var gi2 = 0; gi2 < 90; gi2++) c2.fillRect((gi2 * 37) % sz, (gi2 * 53) % sz, 1.5, 1.5); }); var _TEX_FLOOR = _pat_tex(128, function (c2, sz) { // 바닥 — 체커 타일 + 줄눈(Meccha 체커 문법) c2.fillStyle = 'rgba(0,0,0,0.10)'; c2.fillRect(0, 0, sz / 2, sz / 2); c2.fillRect(sz / 2, sz / 2, sz / 2, sz / 2); c2.strokeStyle = 'rgba(0,0,0,0.13)'; c2.lineWidth = 2; c2.strokeRect(0, 0, sz / 2, sz / 2); c2.strokeRect(sz / 2, 0, sz / 2, sz / 2); c2.strokeRect(0, sz / 2, sz / 2, sz / 2); c2.strokeRect(sz / 2, sz / 2, sz / 2, sz / 2); c2.fillStyle = 'rgba(255,255,255,0.10)'; // 타일 하이라이트 모서리 c2.fillRect(2, 2, sz / 2 - 4, 3); c2.fillRect(sz / 2 + 2, sz / 2 + 2, sz / 2 - 4, 3); }); var _TEX_RUG = _pat_tex(64, function (c2, sz) { // 러그 — 직조 크로스해치 c2.strokeStyle = 'rgba(0,0,0,0.09)'; c2.lineWidth = 1.5; for (var li3 = 0; li3 <= sz; li3 += 6) { c2.beginPath(); c2.moveTo(li3, 0); c2.lineTo(li3, sz); c2.stroke(); c2.beginPath(); c2.moveTo(0, li3); c2.lineTo(sz, li3); c2.stroke(); } c2.strokeStyle = 'rgba(255,255,255,0.14)'; c2.lineWidth = 3; c2.strokeRect(1.5, 1.5, sz - 3, sz - 3); // 러그 테두리 결 }); // 박스 지오메트리 UV → 면별 월드-미터 스케일(scale m = 패턴 1회 반복 길이). 면 순서 = ±x ±y ±z. function _uv_world(geo, w2, h2, d2, m) { var spans = [[d2, h2], [d2, h2], [w2, d2], [w2, d2], [w2, h2], [w2, h2]]; var uvA = geo.attributes.uv, idx = geo.index; for (var gi3 = 0; gi3 < geo.groups.length; gi3++) { var gr2 = geo.groups[gi3], sp = spans[gi3], seen2 = {}; for (var ii2 = gr2.start; ii2 < gr2.start + gr2.count; ii2++) { var vi2 = idx.getX(ii2); if (seen2[vi2]) continue; seen2[vi2] = 1; uvA.setXY(vi2, uvA.getX(vi2) * sp[0] / m, uvA.getY(vi2) * sp[1] / m); } } uvA.needsUpdate = true; } // 바닥/천장 — 방별 톤 타일 + 걸레받이 그리드 밑판. 천장 = 실내감(하늘 새는 시선 차단) + emissive 조명(아래). if (!MS) (function () { // 절차 무대 전용(맵 모드 = GLB 가 바닥/천장 소유) // 스커트 = 지하(BM) 구역을 비운 2분할(승상된 무대 하부 마감 + 지하 공간 확보) function _skirt(x0, x1, z0, z1) { var sk = new THREE_.Mesh(new THREE_.BoxGeometry(x1 - x0, 4.2, z1 - z0), P.make.matte('#33383f', { roughness: 0.96 })); sk.position.set((x0 + x1) / 2, Y_R - 2.1, (z0 + z1) / 2); scene.add(sk); } _skirt(-ROOM_W / 2 - 0.8, ROOM_W / 2 + 0.8, BM.z1, ROOM_D / 2 + 0.8); _skirt(BM.x1, ROOM_W / 2 + 0.8, -ROOM_D / 2 - 0.8, BM.z1); _skirt(-ROOM_W / 2 - 0.8, BM.x0, -ROOM_D / 2 - 0.8, BM.z1); // 서측 얇은 띠(지하 외벽 마감) var ceil = new THREE_.Mesh(new THREE_.BoxGeometry(ROOM_W + 1.6, 0.3, ROOM_D + 1.6), P.make.matte('#3a4048', { roughness: 0.95 })); ceil.position.set(0, Y_R + WH_P + 0.15, 0); scene.add(ceil); function _tile_box(x0, x1, z0, z1, hex) { if (x1 - x0 < 0.25 || z1 - z0 < 0.25) return; var tb = new THREE_.Mesh(new THREE_.BoxGeometry(x1 - x0, 0.08, z1 - z0, 6, 1, 6), P.make.matte(hex, { roughness: 0.94 })); tb.material.map = _TEX_FLOOR; tb.material.needsUpdate = true; _uv_world(tb.geometry, x1 - x0, 0.08, z1 - z0, 1.6); tb.position.set((x0 + x1) / 2, Y_R + 0.04, (z0 + z1) / 2); _q_shade(tb, 0.66, 'xz'); scene.add(tb); } for (var r = 0; r < GR; r++) for (var c = 0; c < GC; c++) { // 방별 바닥 타일(개별 톤) var rc = rc_of(c, r), ti = r * GC + c; var thex = FLOOR_TONES[ti % FLOOR_TONES.length]; var tx0 = rc.x - (CW - 0.3) / 2, tx1 = rc.x + (CW - 0.3) / 2, tz0 = rc.z - (CD - 0.3) / 2, tz1 = rc.z + (CD - 0.3) / 2; if (r === 0 && c === 1) { // 지하 계단 개구부 방 — 타일 4분할(SH 구멍) _tile_box(tx0, SH.x0, tz0, tz1, thex); _tile_box(SH.x1, tx1, tz0, tz1, thex); _tile_box(SH.x0, SH.x1, tz0, SH.z0, thex); _tile_box(SH.x0, SH.x1, SH.z1, tz1, thex); } else { _tile_box(tx0, tx1, tz0, tz1, thex); } var tile = null; if (false) { tile = null; } // (구 단일 타일 경로 대체) var rug = new THREE_.Mesh(new THREE_.BoxGeometry(CW * 0.42, 0.05, CD * 0.42), P.make.matte(RUG_HUES[ti % RUG_HUES.length], { roughness: 0.9 })); // 러그 = 클러스터 앵커·온기 rug.material.map = _TEX_RUG; rug.material.needsUpdate = true; // 직조 결 _uv_world(rug.geometry, CW * 0.42, 0.05, CD * 0.42, 0.55); rug.position.set(rc.x + (prng() - 0.5) * 1.5, Y_R + 0.09, rc.z + (prng() - 0.5) * 1.5); rug.rotation.y = (prng() - 0.5) * 0.3; scene.add(rug); } })(); var _hue_i = 0; // 통합 오클루더 — 벽·기둥·상자·화단 전부 이 하나로(walls[] 계약: aabb 차폐 + nx/nz 은신면 + hl // 측면폭 + off0 표면-두께). face = 은신 면 방향('+x'|'-x'|'+z'|'-z'). function _add_box(cx, cz, hx, hz, h, color, kind, face, fy) { var BY = fy == null ? Y_R : fy; // 배치 층(지하 B1Y 지원) var mesh = new THREE_.Mesh(new THREE_.BoxGeometry(hx * 2, h, hz * 2, 1, 5, 1), P.make.matte(color, { roughness: 0.85 })); if (kind === 'perim' || kind === 'part') { // 벽지 패턴(색조 × 다이아 격자) — 소품은 제외(실루엣 대비) mesh.material.map = _TEX_WALL; mesh.material.needsUpdate = true; _uv_world(mesh.geometry, hx * 2, h, hz * 2, 1.15); } mesh.position.set(cx, BY + h / 2 - 0.05, cz); _q_shade(mesh, kind === 'perim' || kind === 'part' ? 0.8 : 0.7, 'y'); // 벽 세로 AO(하단 어둡게). 은신 벽은 미묘(0.8)로 색-매치 영향 최소·소품은 더 강하게(0.7) scene.add(mesh); world._priv.collider_box({ x: cx, z: cz, hx: Math.min(4, hx + 0.15), hz: Math.min(4, hz + 0.15), y0: BY - 3, y1: BY + h }); var axis = (face === '+x' || face === '-x') ? 'x' : 'z'; var nx = face === '+x' ? 1 : face === '-x' ? -1 : 0; var nz = face === '+z' ? 1 : face === '-z' ? -1 : 0; var w = { x: cx, z: cz, y: BY, axis: axis, color: color, mesh: mesh, kind: kind, nx: nx, nz: nz, hl: (axis === 'x' ? hz : hx), off0: (axis === 'x' ? hx : hz), solid: h >= 2.5, // 전신 차폐물(벽·기둥)만 시선/사격을 막는다. 낮은 소품(상자·화단)은 넘겨본다. aabb: { x0: cx - hx, x1: cx + hx, y0: BY - 0.4, y1: BY + h, z0: cz - hz, z1: cz + hz } }; walls.push(w); _sampleables.push(mesh); return w; } // 얇은 벽 — 내부-법선 자동(중심 방향) function _add_wall(wx, wz, axis, len, kind) { var color = SCREEN_HUES[_hue_i++ % SCREEN_HUES.length]; var face; if (axis === 'x') face = (wx === 0 ? '+x' : (wx > 0 ? '-x' : '+x')); else face = (wz === 0 ? '+z' : (wz > 0 ? '-z' : '+z')); return _add_box(wx, wz, axis === 'x' ? 0.3 : len / 2, axis === 'x' ? len / 2 : 0.3, kind === 'perim' ? WH_P : WH2, color, kind, face); } // 실 CC0 가구(카탈로그 furn_/fbit_)의 목표 월드 높이 — 카탈로그 native h 는 킷 단위라 실치수와 무관, // 이름-키워드로 상식적 높이를 부여(비율 정합). 책장류 ≥1.5 = 전신 차폐(solid). function _furn_h(n) { n = String(n); if (/bookcase|shelf|wardrobe|cabinet|closet|dresser/.test(n)) return 1.7; if (/lamp/.test(n)) return /floor|standing/.test(n) ? 1.5 : 0.55; // 플로어 램프만 키큼, 테이블 램프는 낮음 if (/plant/.test(n)) return 1.0; return 0.9; // sofa·bed·table·desk·bench·chair·stool·sidetable } // 실 가구 배치 — _add_box 와 동일 계약(collider_box 플레이어 충돌 + walls[] occluder)이되 컬러 박스 // 대신 GLB 스폰. 카탈로그 메타(it.h/it.r)로 근사 발자국을 동기 산출 → occluder 즉시 등록(비동기 GLB // 는 ready 에 시각만 채움). color = 방 배경색(SCREEN_HUE)이라 여기 은신한 위장체는 배경에 매치된다. function _add_furniture(cx, cz, it, color, face, fy) { var FY = fy == null ? Y_R : fy; // 배치 층(2층 데크 지원) var fh = _furn_h(it.n); var scale = fh / Math.max(0.05, it.h || 0.5); var fr = clamp((it.r || 0.3) * scale + 0.12, 0.22, 2.2); // 스케일 발자국 반경(정사각 근사) var hx = fr, hz = fr; var nx0 = face === '+x' ? 1 : face === '-x' ? -1 : 0, nz0 = face === '+z' ? 1 : face === '-z' ? -1 : 0; var hh = world.spawn(it.n, { at: { x: cx, z: cz }, h: fh, _no_collider: true, _no_uni: true }); if (hh) { hh.obj.position.y = FY; hh.obj.rotation.y = Math.atan2(nx0, nz0) + (prng() - 0.5) * 0.28; } // 층 착지 + 방 안쪽 향(가구 정면)·미세 yaw(격자감 제거) world._priv.collider_box({ x: cx, z: cz, hx: Math.min(4, hx + 0.15), hz: Math.min(4, hz + 0.15), y0: FY - 3, y1: FY + fh }); var axis = (face === '+x' || face === '-x') ? 'x' : 'z'; var nx = face === '+x' ? 1 : face === '-x' ? -1 : 0; var nz = face === '+z' ? 1 : face === '-z' ? -1 : 0; var w = { x: cx, z: cz, y: FY, axis: axis, color: color, mesh: null, kind: 'furn', nx: nx, nz: nz, hl: (axis === 'x' ? hz : hx), off0: (axis === 'x' ? hx : hz), solid: fh >= 1.5, aabb: { x0: cx - hx, x1: cx + hx, y0: FY - 0.4, y1: FY + fh, z0: cz - hz, z1: cz + hz } }; walls.push(w); _blob(cx, cz, fr + 0.18, FY); // 가짜 접지 그림자(부유감 제거) return w; } // 블롭 접지 그림자(가짜 — 무비용 무광 원판, 바닥 바로 위). 저폴리 부유감의 주 원인을 봉합. function _blob(cx, cz, r, fy) { var m = new THREE_.Mesh(new THREE_.CircleGeometry(r, 16), new THREE_.MeshBasicMaterial({ color: 0x0a0d12, transparent: true, opacity: 0.3, depthWrite: false })); m.rotation.x = -Math.PI / 2; m.position.set(cx, (fy == null ? Y_R : fy) + 0.06, cz); scene.add(m); } // 천장 emissive 조명 픽스처(실광원 없이 발광 — 모바일 안전) + 아래 바닥 광-풀(캐스트 광 착시). function _ceil_light(cx, cz, fy) { var base_y = fy == null ? Y_R : fy; var pan = new THREE_.Mesh(new THREE_.BoxGeometry(1.8, 0.12, 1.8), P.make.glow('#ffe6b0', 0.5)); pan.position.set(cx, base_y + WH2 - 0.12, cz); scene.add(pan); var rod = new THREE_.Mesh(new THREE_.BoxGeometry(0.07, Math.max(0.2, (Y_R + WH_P) - (base_y + WH2) + 0.1), 0.07), P.make.matte('#23272e', { roughness: 0.8 })); rod.position.set(cx, ((base_y + WH2) + (Y_R + WH_P)) / 2, cz); scene.add(rod); // 펜던트 로드(천장 연결) var pool = new THREE_.Mesh(new THREE_.CircleGeometry(3.2, 18), new THREE_.MeshBasicMaterial({ color: 0xffe6b0, transparent: true, opacity: 0.035, depthWrite: false })); pool.rotation.x = -Math.PI / 2; pool.position.set(cx, base_y + 0.07, cz); scene.add(pool); } // 벽 걸레받이(baseboard) — 얇은 박스 1개/벽, 무비용. 리서치: 그레이박스→'마감된' 실내의 #1 신호 // (벽-바닥 이음새 은폐). 크라운은 draw call 절감차 생략(천장 emissive 조명이 상단 공허를 이미 해소). function _wall_trim(w) { var isx = w.axis === 'x', bw = isx ? (w.off0 * 2 + 0.14) : (w.hl * 2), bd = isx ? (w.hl * 2) : (w.off0 * 2 + 0.14); var base = new THREE_.Mesh(new THREE_.BoxGeometry(bw, 0.24, bd), P.make.matte('#23272e', { roughness: 0.9 })); base.position.set(w.x, (w.y == null ? Y_R : w.y) + 0.12, w.z); scene.add(base); } // ── 외곽 4벽(밀폐) — ≤7m 세그먼트 도색 ── if (!MS) (function () { var segs_x = 7, seg_lx = ROOM_W / segs_x; for (var i = 0; i < segs_x; i++) { var cx3 = -ROOM_W / 2 + seg_lx * (i + 0.5); _add_wall(cx3, -ROOM_D / 2, 'z', seg_lx, 'perim'); _add_wall(cx3, ROOM_D / 2, 'z', seg_lx, 'perim'); } var segs_z = 5, seg_lz = ROOM_D / segs_z; for (var j = 0; j < segs_z; j++) { var cz3 = -ROOM_D / 2 + seg_lz * (j + 0.5); _add_wall(-ROOM_W / 2, cz3, 'x', seg_lz, 'perim'); _add_wall(ROOM_W / 2, cz3, 'x', seg_lz, 'perim'); } })(); // ── 내부 파티션 그리드(다실 구조, 4×3) — 인접 방마다 문-구멍(셀 경계별 = 완전 연결·미로 아님). // 벽 다수 = 코너·시선 분절·은신 컬러면 증가(원작 "어수선한 다실" 문법). ── if (!MS) (function () { var DOOR = DOORW; function seg(axis, fixed, a0, a1) { // (a0,a1) 구간에 중앙 문-구멍, 양측 벽 세그먼트 var mid = (a0 + a1) / 2, parts = [[a0, mid - DOOR / 2], [mid + DOOR / 2, a1]]; for (var s = 0; s < parts.length; s++) { var lo = parts[s][0], hi = parts[s][1], len = hi - lo; if (len < 1.2) continue; var c = (lo + hi) / 2; if (axis === 'x') _add_wall(fixed, c, 'x', len, 'part'); else _add_wall(c, fixed, 'z', len, 'part'); } } for (var cc = 1; cc < GC; cc++) for (var r = 0; r < GR; r++) // 열 경계 세로벽(행 셀별 문) seg('x', -ROOM_W / 2 + cc * CW, -ROOM_D / 2 + r * CD, -ROOM_D / 2 + (r + 1) * CD); for (var rr = 1; rr < GR; rr++) for (var c = 0; c < GC; c++) // 행 경계 가로벽(열 셀별 문) seg('z', -ROOM_D / 2 + rr * CD, -ROOM_W / 2 + c * CW, -ROOM_W / 2 + (c + 1) * CW); })(); // ── 방 소품(원작 문법: 흉내낼 오브젝트가 실루엣을 끊고 은신면을 늘린다) ── // 정본: 카탈로그의 실 CC0 가구(Kenney furniture-kit · KayKit Furniture Bits)를 룸 격자에 배치. 생성기가 // .camo.items 로 결정론 선택·주입(archetype 소비 계약). 미주입(골든·인덱스 // 미발행) 시 = 컬러 프리미티브 폴백(zero-asset 불변식). 가구 occluder color = 방 배경색이라 여기 은신한 // 위장체는 배경에 매치돼 "가구 사이에 배경색으로 등장"한다(실보고 요청). var FACES = ['+x', '-x', '+z', '-z']; var _CAMO_FURN = (typeof window !== 'undefined' && window['__PLAYABLE_'+'EXTRA_MANIFEST__'] && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].camo && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].camo.items) || null; // 소품 클러터(작은 프리미티브 — 밀도·표면드레싱, 무충돌 순수 데코 + 블롭 그림자). 리서치: 밀도·다양성이 // '디자인된' 방의 핵심. 상자/책더미/화병 실루엣 혼합. function _clutter(cx, cz, hue) { // (맵 모드 미호출 — 배치 IIFE 가 통째로 게이트) var t = prng(), w2, h2, d2; if (t < 0.45) { w2 = 0.5; h2 = 0.5; d2 = 0.5; } else if (t < 0.75) { w2 = 0.78; h2 = 0.3; d2 = 0.5; } else { w2 = 0.3; h2 = 0.82; d2 = 0.3; } var m = new THREE_.Mesh(new THREE_.BoxGeometry(w2, h2, d2), P.make.matte(_mix(hue, '#e8e2d4', 0.18), { roughness: 0.85 })); m.position.set(cx, Y_R + h2 / 2 + 0.02, cz); m.rotation.y = prng() * 0.7; scene.add(m); _sampleables.push(m); _blob(cx, cz, Math.max(w2, d2) * 0.72); } if (!MS) (function () { var fi = 0, hw = CW / 2 - 1.4, hd = CD / 2 - 1.4; // 문 밖 코너 편향 오프셋(가구가 문·통행로를 막지 않게 — 밀집 회귀 봉합). half = 방 반폭/깊이. function _coff(half) { var m = DOORW / 2 + 1.0; return (prng() < 0.5 ? -1 : 1) * (m + prng() * Math.max(0.3, half - m)); } function _fars(x, z) { var dx = x - SPAWN_XZ.x, dz = z - SPAWN_XZ.z; return dx * dx + dz * dz > 13.0; } // 스폰 3.6m 밖 = 캐릭터 겹침 방지 var slot = [ // 벽밀착 슬롯(면=방 안쪽 법선) — 문 중앙 회피 코너 배치(밀도 상향: 6슬롯) function (rx, rz) { return { x: rx - hw, z: rz + _coff(hd), face: '+x' }; }, function (rx, rz) { return { x: rx + hw, z: rz + _coff(hd), face: '-x' }; }, function (rx, rz) { return { x: rx + _coff(hw), z: rz - hd, face: '+z' }; }, function (rx, rz) { return { x: rx + _coff(hw), z: rz + hd, face: '-z' }; }, function (rx, rz) { return { x: rx - hw, z: rz + _coff(hd), face: '+x' }; }, function (rx, rz) { return { x: rx + _coff(hw), z: rz + hd, face: '-z' }; }, ]; for (var r = 0; r < GR; r++) for (var c = 0; c < GC; c++) { var rc = rc_of(c, r), rx = rc.x, rz = rc.z; if (_CAMO_FURN && _CAMO_FURN.length) { // ── 실 가구 경로: 벽밀착 + 중앙 히어로 + 클러터 (문·스폰 회피) ── var ord = [0, 1, 2, 3, 4, 5]; for (var oi = 5; oi > 0; oi--) { var oj = Math.floor(prng() * (oi + 1)), ot = ord[oi]; ord[oi] = ord[oj]; ord[oj] = ot; } for (var wsi = 0; wsi < 6; wsi++) { var ws = slot[ord[wsi]](rx, rz); if (_fars(ws.x, ws.z)) _add_furniture(ws.x, ws.z, _CAMO_FURN[fi++ % _CAMO_FURN.length], SCREEN_HUES[Math.floor(prng() * SCREEN_HUES.length)], ws.face); } var hx2 = rx + (prng() - 0.5) * 1.6, hz2 = rz + (prng() - 0.5) * 1.6; if (_fars(hx2, hz2)) _add_furniture(hx2, hz2, _CAMO_FURN[fi++ % _CAMO_FURN.length], SCREEN_HUES[Math.floor(prng() * SCREEN_HUES.length)], FACES[Math.floor(prng() * 4)]); var hx3 = rx + (prng() - 0.5) * 5.2, hz3 = rz + (prng() < 0.5 ? -1 : 1) * (2.0 + prng() * 1.6); // 2번째 히어로(밀도 상향 — 문/스폰 회피 오프셋) if (_fars(hx3, hz3)) _add_furniture(hx3, hz3, _CAMO_FURN[fi++ % _CAMO_FURN.length], SCREEN_HUES[Math.floor(prng() * SCREEN_HUES.length)], FACES[Math.floor(prng() * 4)]); var hx4 = rx + (prng() < 0.5 ? -1 : 1) * (2.6 + prng() * 1.8), hz4 = rz + (prng() - 0.5) * 5.2; // 3번째 히어로(숨바꼭질=밀집이 은신) if (_fars(hx4, hz4)) _add_furniture(hx4, hz4, _CAMO_FURN[fi++ % _CAMO_FURN.length], SCREEN_HUES[Math.floor(prng() * SCREEN_HUES.length)], FACES[Math.floor(prng() * 4)]); // 산포 스캐터(밀도 실보고) — 문/스폰 회피 + 벽 2.9m 이내 금지(벽-은신 시야 통로 보존: 헌터가 // 벽의 위장체를 쏘는 코어 루프가 가구에 막히면 안 된다 — 골든 real_input 이 강제) for (var sc2 = 0; sc2 < 10; sc2++) { var sx5 = rx + (prng() - 0.5) * (CW - 6.0), sz5 = rz + (prng() - 0.5) * (CD - 6.0); if (_fars(sx5, sz5)) _add_furniture(sx5, sz5, _CAMO_FURN[fi++ % _CAMO_FURN.length], SCREEN_HUES[Math.floor(prng() * SCREEN_HUES.length)], FACES[Math.floor(prng() * 4)]); } var nclut = 9 + Math.floor(prng() * 3); for (var ci2 = 0; ci2 < nclut; ci2++) { var clx = rx + (prng() - 0.5) * (CW - 3), clz = rz + (prng() - 0.5) * (CD - 3); if (_fars(clx, clz)) _clutter(clx, clz, SCREEN_HUES[Math.floor(prng() * SCREEN_HUES.length)]); } } else { // ── 프리미티브 폴백(가구형 컬러 박스) + 클러터 ── var nprop = 3 + Math.floor(prng() * 2); for (var pk = 0; pk < nprop; pk++) { var px2 = rx + (prng() - 0.5) * (CW - 3), pz2 = rz + (prng() - 0.5) * (CD - 3); var hue = SCREEN_HUES[Math.floor(prng() * SCREEN_HUES.length)], face = FACES[Math.floor(prng() * 4)], t2 = prng(); if (t2 < 0.28) { _add_box(px2, pz2, 0.45, 0.45, WH2, hue, 'prop', face); } else if (t2 < 0.55) { var caw = 0.55 + prng() * 0.35; _add_box(px2, pz2, face === '+x' || face === '-x' ? 0.38 : caw, face === '+x' || face === '-x' ? caw : 0.38, 2.4, hue, 'prop', face); } else if (t2 < 0.78) { _add_box(px2, pz2, 1.1, 0.6, 1.0, hue, 'prop', face); } else { _add_box(px2, pz2, 0.7, 0.7, 1.4, hue, 'prop', face); } _clutter(px2 + (prng() - 0.5) * 2, pz2 + (prng() - 0.5) * 2, hue); } } } })(); for (var _wi = 0; _wi < walls.length; _wi++) { if (walls[_wi].kind === 'perim' || walls[_wi].kind === 'part') _wall_trim(walls[_wi]); } // 벽 트림 전수(걸레받이+크라운) // ── 2층 메자닌 + 계단(실보고 2026-07-23 층 구조) — 우측-후방 첫 행 위 데크(파티션 3.4 위 3.5m, // 둘레벽 6.2 승고로 밀폐 유지). 보행 = ground_ext(y-의존 다층 1급 계약, build 패밀리 동형) — // 승급 관용 ≤1.7(아래-스폰 승급/위-착지 양쪽 캐논). 스폰(0,4)·문 통행·헌터 순찰(1층) 불침범. if (!MS) (function () { var F2Y = Y_R + 3.5; var MZ = { x0: ROOM_W / 2 - CW * 1.32, x1: ROOM_W / 2 - 0.35, z0: -ROOM_D / 2 + 0.35, z1: -ROOM_D / 2 + CD - 0.4 }; var ST = { x0: MZ.x0 - 6.9, x1: MZ.x0, z0: -9.0, z1: -7.3 }; // 데크 좌측면 붙임 계단(+x 로 오름) // 데크 슬랩(바닥 체커 텍스처 + 월드 UV) var deck = new THREE_.Mesh(new THREE_.BoxGeometry(MZ.x1 - MZ.x0, 0.14, MZ.z1 - MZ.z0), P.make.matte('#5a5147', { roughness: 0.94 })); deck.material.map = _TEX_FLOOR; deck.material.needsUpdate = true; _uv_world(deck.geometry, MZ.x1 - MZ.x0, 0.14, MZ.z1 - MZ.z0, 1.6); deck.position.set((MZ.x0 + MZ.x1) / 2, F2Y - 0.07, (MZ.z0 + MZ.z1) / 2); _q_shade(deck, 0.7, 'xz'); scene.add(deck); // LOS 차폐(발각/사격) — walls[] 의미론 불침범 전용 리스트 _extra_occl.push({ solid: true, aabb: { x0: MZ.x0, x1: MZ.x1, y0: F2Y - 0.14, y1: F2Y, z0: MZ.z0, z1: MZ.z1 } }); // 보행 제공자 — 데크 + 계단 램프(y-게이트 승급 관용 1.7) world._priv.ground_ext_add(function (x, z, y) { // noqa: ground-ext — 데크 아래 1층 보행(스폰·문·헌터 순찰) 유지가 필요한 진성 다층이라 (x,z)→h 필드 표현 불가; 카메라 하향 소비자는 5647670c 상향 전용 클램프로 봉합됨 if (x >= MZ.x0 && x <= MZ.x1 && z >= MZ.z0 && z <= MZ.z1 && y > F2Y - 1.7) return F2Y; if (x >= ST.x0 && x <= ST.x1 && z >= ST.z0 && z <= ST.z1) { var t = clamp((x - ST.x0) / (ST.x1 - ST.x0), 0, 1); var h2 = Y_R + (F2Y - Y_R) * t; if (y > h2 - 1.7) return h2; } return -1e9; }); // 계단 시각 = 12단 스텝 박스(어두운 우드 톤) var NS = 12, run = (ST.x1 - ST.x0) / NS, sw2 = ST.z1 - ST.z0; for (var si = 0; si < NS; si++) { var sh2 = (F2Y - Y_R) * (si + 1) / NS; var st2 = new THREE_.Mesh(new THREE_.BoxGeometry(run, sh2, sw2), P.make.matte('#4a4038', { roughness: 0.9 })); st2.position.set(ST.x0 + run * (si + 0.5), Y_R + sh2 / 2, (ST.z0 + ST.z1) / 2); scene.add(st2); } _extra_occl.push({ solid: true, aabb: { x0: ST.x0, x1: ST.x1, y0: Y_R, y1: F2Y, z0: ST.z0, z1: ST.z1 } }); // 난간(시각 — 데크 전면/좌측 + 계단 측면 포스트) function _rail(x0, z0, x1, z1) { var len = Math.max(Math.abs(x1 - x0), Math.abs(z1 - z0)), isx = Math.abs(x1 - x0) > Math.abs(z1 - z0); var bar = new THREE_.Mesh(new THREE_.BoxGeometry(isx ? len : 0.07, 0.07, isx ? 0.07 : len), P.make.matte('#2b2f38', { roughness: 0.7 })); bar.position.set((x0 + x1) / 2, F2Y + 0.95, (z0 + z1) / 2); scene.add(bar); var np2 = Math.max(2, Math.round(len / 1.6)); for (var pi3 = 0; pi3 <= np2; pi3++) { var tx = x0 + (x1 - x0) * (pi3 / np2), tz = z0 + (z1 - z0) * (pi3 / np2); var post = new THREE_.Mesh(new THREE_.BoxGeometry(0.07, 0.95, 0.07), P.make.matte('#2b2f38', { roughness: 0.7 })); post.position.set(tx, F2Y + 0.48, tz); scene.add(post); } } _rail(MZ.x0 + 0.1, MZ.z1 - 0.08, MZ.x1 - 0.1, MZ.z1 - 0.08); // 전면(방 쪽) _rail(MZ.x0 + 0.08, MZ.z0 + 0.1, MZ.x0 + 0.08, ST.z0 - 0.3); // 좌측(계단 개구부 전까지) // 데크 지지 기둥(1층 은신 소품 겸) _add_box(MZ.x0 + 0.5, MZ.z1 - 0.8, 0.24, 0.24, 3.36, SCREEN_HUES[_hue_i++ % SCREEN_HUES.length], 'prop', '+x'); _add_box((MZ.x0 + MZ.x1) / 2, MZ.z1 - 0.8, 0.24, 0.24, 3.36, SCREEN_HUES[_hue_i++ % SCREEN_HUES.length], 'prop', '+z'); // 2층 가구(실 로스터 있을 때) + 러그 if (_CAMO_FURN && _CAMO_FURN.length) { var fpos = [[MZ.x0 + 2.6, MZ.z0 + 2.2, '+z'], [(MZ.x0 + MZ.x1) / 2 + 1.5, MZ.z0 + 1.6, '+z'], [MZ.x1 - 2.4, (MZ.z0 + MZ.z1) / 2, '-x'], [MZ.x0 + 3.4, MZ.z1 - 2.6, '-z'], [MZ.x1 - 2.2, MZ.z0 + 2.4, '+z'], [(MZ.x0 + MZ.x1) / 2 - 2.8, (MZ.z0 + MZ.z1) / 2 + 2.2, '-z']]; for (var df = 0; df < fpos.length; df++) _add_furniture(fpos[df][0], fpos[df][1], _CAMO_FURN[(df + 7) % _CAMO_FURN.length], SCREEN_HUES[Math.floor(prng() * SCREEN_HUES.length)], fpos[df][2], F2Y); var drg = new THREE_.Mesh(new THREE_.BoxGeometry(4.4, 0.05, 3.4), P.make.matte(RUG_HUES[2], { roughness: 0.9 })); drg.material.map = _TEX_RUG; drg.material.needsUpdate = true; _uv_world(drg.geometry, 4.4, 0.05, 3.4, 0.55); drg.position.set((MZ.x0 + MZ.x1) / 2, F2Y + 0.03, (MZ.z0 + MZ.z1) / 2); scene.add(drg); } })(); // ── 지하실(실보고 2026-07-24) — BM footprint, 계단(SH) 하강. 보행 제공자는 상단(floor_plane 분해부) // 에 배선. 벽 = walls[] 1급(색-매치 은신면 + 벽지), 바닥 체커, 자체 조명(어둑한 지하 무드). ── if (!MS) (function () { // 바닥/천장(개구부 컷) var bfl = new THREE_.Mesh(new THREE_.BoxGeometry(BM.x1 - BM.x0, 0.14, BM.z1 - BM.z0), P.make.matte('#514f4a', { roughness: 0.95 })); bfl.material.map = _TEX_FLOOR; bfl.material.needsUpdate = true; _uv_world(bfl.geometry, BM.x1 - BM.x0, 0.14, BM.z1 - BM.z0, 1.6); bfl.position.set((BM.x0 + BM.x1) / 2, B1Y - 0.07, (BM.z0 + BM.z1) / 2); scene.add(bfl); function _bceil(x0, x1, z0, z1) { if (x1 - x0 < 0.3 || z1 - z0 < 0.3) return; var bc = new THREE_.Mesh(new THREE_.BoxGeometry(x1 - x0, 0.2, z1 - z0), P.make.matte('#2c3038', { roughness: 0.96 })); bc.position.set((x0 + x1) / 2, Y_R - 0.14, (z0 + z1) / 2); scene.add(bc); } _bceil(BM.x0, BM.x1, BM.z0, SH.z0); _bceil(BM.x0, SH.x0, SH.z0, BM.z1); _bceil(BM.x0, BM.x1, SH.z1, BM.z1); // 내부 벽 4면(색-매치 은신면 + 벽지 + 걸레받이) — 지하 격리 차폐는 스커트/천장이 이미 담당 var BW = 2.85; _add_box((BM.x0 + BM.x1) / 2, BM.z0 + 0.25, (BM.x1 - BM.x0) / 2 - 0.1, 0.25, BW, SCREEN_HUES[_hue_i++ % SCREEN_HUES.length], 'part', '+z', B1Y); _add_box((BM.x0 + BM.x1) / 2, BM.z1 - 0.25, (BM.x1 - BM.x0) / 2 - 0.1, 0.25, BW, SCREEN_HUES[_hue_i++ % SCREEN_HUES.length], 'part', '-z', B1Y); _add_box(BM.x0 + 0.25, (BM.z0 + BM.z1) / 2, 0.25, (BM.z1 - BM.z0) / 2 - 0.1, BW, SCREEN_HUES[_hue_i++ % SCREEN_HUES.length], 'part', '+x', B1Y); _add_box(BM.x1 - 0.25, (BM.z0 + BM.z1) / 2, 0.25, (BM.z1 - BM.z0) / 2 - 0.1, BW, SCREEN_HUES[_hue_i++ % SCREEN_HUES.length], 'part', '-x', B1Y); // 계단(12단 하강 시각) + 1층 개구부 난간 var NSB = 12, runb = (SH.x1 - SH.x0) / NSB, swb = SH.z1 - SH.z0; for (var sb = 0; sb < NSB; sb++) { var hb = (Y_R - B1Y) * (sb + 1) / NSB; var stb = new THREE_.Mesh(new THREE_.BoxGeometry(runb, hb, swb), P.make.matte('#4a4038', { roughness: 0.9 })); stb.position.set(SH.x0 + runb * (sb + 0.5), B1Y + hb / 2, (SH.z0 + SH.z1) / 2); scene.add(stb); } function _srail(x0, z0, x1, z1) { var isx = Math.abs(x1 - x0) > Math.abs(z1 - z0), len = Math.max(Math.abs(x1 - x0), Math.abs(z1 - z0)); var bar = new THREE_.Mesh(new THREE_.BoxGeometry(isx ? len : 0.07, 0.07, isx ? 0.07 : len), P.make.matte('#2b2f38', { roughness: 0.7 })); bar.position.set((x0 + x1) / 2, Y_R + 0.92, (z0 + z1) / 2); scene.add(bar); var npb = Math.max(2, Math.round(len / 1.5)); for (var pb2 = 0; pb2 <= npb; pb2++) { var px4 = x0 + (x1 - x0) * (pb2 / npb), pz4 = z0 + (z1 - z0) * (pb2 / npb); var po = new THREE_.Mesh(new THREE_.BoxGeometry(0.07, 0.92, 0.07), P.make.matte('#2b2f38', { roughness: 0.7 })); po.position.set(px4, Y_R + 0.46, pz4); scene.add(po); } } _srail(SH.x0 - 0.1, SH.z0 - 0.12, SH.x1 - 0.6, SH.z0 - 0.12); // 개구부 북측 _srail(SH.x0 - 0.1, SH.z1 + 0.12, SH.x1 - 0.6, SH.z1 + 0.12); // 남측 _srail(SH.x0 - 0.12, SH.z0 - 0.12, SH.x0 - 0.12, SH.z1 + 0.12); // 하단측 if (_CAMO_FURN && _CAMO_FURN.length) { var bpos = [[BM.x0 + 2.4, BM.z0 + 2.2, '+z'], [(BM.x0 + BM.x1) / 2, BM.z0 + 1.8, '+z'], [BM.x1 - 2.6, BM.z1 - 2.4, '-z'], [BM.x0 + 2.6, BM.z1 - 2.2, '-z'], [(BM.x0 + BM.x1) / 2 - 3.5, (BM.z0 + BM.z1) / 2, '+x']]; for (var bf2 = 0; bf2 < bpos.length; bf2++) _add_furniture(bpos[bf2][0], bpos[bf2][1], _CAMO_FURN[(bf2 + 13) % _CAMO_FURN.length], SCREEN_HUES[Math.floor(prng() * SCREEN_HUES.length)], bpos[bf2][2], B1Y); var brg = new THREE_.Mesh(new THREE_.BoxGeometry(4.2, 0.05, 3.2), P.make.matte(RUG_HUES[5], { roughness: 0.9 })); brg.material.map = _TEX_RUG; brg.material.needsUpdate = true; _uv_world(brg.geometry, 4.2, 0.05, 3.2, 0.55); brg.position.set((BM.x0 + BM.x1) / 2, B1Y + 0.03, (BM.z0 + BM.z1) / 2); scene.add(brg); } })(); // 실내 바닥 = 지형 필드에 편입(단층 평탄면이라 (x,z)→h 로 표현 가능). 구 ground_ext 제공자는 // 플레이어 물리만 소비해 카메라·몹·소품 등 나머지 height_at 소비자가 슬랩 아래로 샜다. // 지하(BM) 구역은 floor_plane(단층 x,z→h)에서 제외 — 그 위 1층/지하 보행은 y-게이트 제공자 2단. // (맵 모드 = 절차 바닥/지하 부재 — 보행면은 spawn_map 의 메시 레이캐스트 제공자가 단독 소유) if (!MS) world.floor_plane({ x0: -ROOM_W / 2 - 0.4, x1: ROOM_W / 2 + 0.4, z0: BM.z1, z1: ROOM_D / 2 + 0.4, y: Y_R }); if (!MS) world.floor_plane({ x0: BM.x1, x1: ROOM_W / 2 + 0.4, z0: -ROOM_D / 2 - 0.4, z1: BM.z1, y: Y_R }); if (!MS) world._priv.ground_ext_add(function (x, z, y) { // noqa: ground-ext — 지하실은 같은 (x,z) 위에 1층 바닥과 지하 바닥이 공존하는 진성 다층이라 (x,z)→h 필드(floor_plane) 표현 불가; 1층은 위 floor_plane 2장이 담당, 카메라는 상향 전용 클램프(5647670c)로 봉합됨 var inBM = x >= BM.x0 && x <= BM.x1 && z >= BM.z0 && z <= BM.z1; if (!inBM) return -1e9; var inSH = x >= SH.x0 && x <= SH.x1 && z >= SH.z0 && z <= SH.z1; if (inSH) { // 하강 계단 램프(+x 로 오름) var t = clamp((x - SH.x0) / (SH.x1 - SH.x0), 0, 1); var h2 = B1Y + (Y_R - B1Y) * t; if (y > h2 - 1.7) return h2; return -1e9; } if (y > Y_R - 1.7) return Y_R; // 지하 위 1층 바닥(개구부 제외) return B1Y; // 지하 바닥 }); // ── 지하실 내부(바닥/천장/벽/계단/조명/가구는 가구 로스터 블록에서 — _TEX/_add_box 정의 이후) ── // 오클루더 앞 은신 지점(위장체 배치·헌터 순찰 공유) — off0(표면 두께) + off 만큼 면 밖으로. function _wall_inner(w, lateral, off) { var lat = clamp(lateral, -(w.hl - 0.4), w.hl - 0.4); var d = (w.off0 || 0.3) + off; if (w.axis === 'x') return { x: w.x + w.nx * d, z: w.z + lat }; return { x: w.x + lat, z: w.z + w.nz * d }; } // 레이 ↔ 벽 AABB 차폐(slab method) — 사격 판정의 월핵 차단 function _ray_walls(ox, oy, oz, dx, dy, dz, maxT, solid_only) { if (_MAPH) { // 맵 모드 — 메시 레이캐스트가 차폐 정본(walls[] 는 비어 있다. 맵 지오메트리 전체 = solid) if (!_MAPH.ready) return -1; return _MAPH.ray_t(ox, oy, oz, dx, dy, dz, maxT); } var lists = [walls, _extra_occl]; for (var li9 = 0; li9 < 2; li9++) for (var i = 0; i < lists[li9].length; i++) { if (solid_only && !lists[li9][i].solid) continue; // 낮은 소품은 시선/사격을 막지 않음(넘겨봄) var b = lists[li9][i].aabb, t0 = 0, t1 = maxT, ok = true; var od = [[ox, dx, b.x0, b.x1], [oy, dy, b.y0, b.y1], [oz, dz, b.z0, b.z1]]; for (var k = 0; k < 3; k++) { var o2 = od[k][0], d2 = od[k][1], lo = od[k][2], hi = od[k][3]; if (Math.abs(d2) < 1e-8) { if (o2 < lo || o2 > hi) { ok = false; break; } continue; } var ta = (lo - o2) / d2, tb = (hi - o2) / d2; if (ta > tb) { var tt = ta; ta = tb; tb = tt; } if (ta > t0) t0 = ta; if (tb < t1) t1 = tb; if (t0 > t1) { ok = false; break; } } if (ok && t0 < maxT && t1 > 0) return t0; } return -1; } // ── 마네킹(아키타입 소유 — 3부위 도색 계약: 0=몸통 1=머리 2=다리, 눈은 미도색 단서) ── function _mk_manne(base_hex) { var g = new THREE_.Group(); function part(geo, x, y, z) { var m = new THREE_.Mesh(geo, P.make.matte(base_hex, { roughness: 0.75 })); m.position.set(x, y, z); m.userData._camo = 1; g.add(m); return m; } var legL = part(new THREE_.BoxGeometry(0.17, 0.56, 0.2), -0.12, 0.28, 0); var legR = part(new THREE_.BoxGeometry(0.17, 0.56, 0.2), 0.12, 0.28, 0); var torso = part(new THREE_.BoxGeometry(0.52, 0.62, 0.3), 0, 0.9, 0); var armL = part(new THREE_.BoxGeometry(0.13, 0.5, 0.16), -0.35, 0.95, 0); var armR = part(new THREE_.BoxGeometry(0.13, 0.5, 0.16), 0.35, 0.95, 0); var head = part(new THREE_.SphereGeometry(0.22, 14, 11), 0, 1.44, 0); [-0.085, 0.085].forEach(function (ex) { // 눈(흰자+동공) — 어떤 도색에도 남는 발견 단서 var e = new THREE_.Mesh(new THREE_.SphereGeometry(0.045, 8, 7), P.make.matte('#f4f4f2', { roughness: 0.4 })); e.position.set(ex, 1.5, 0.185); e.userData._camo = 1; g.add(e); var p = new THREE_.Mesh(new THREE_.SphereGeometry(0.02, 6, 5), P.make.matte('#20242c', { roughness: 0.4 })); p.position.set(ex, 1.5, 0.222); p.userData._camo = 1; g.add(p); }); var slots = [[torso, armL, armR], [head], [legL, legR]]; var colors = [base_hex, base_hex, base_hex]; return { g: g, colors: colors, legs: [legL, legR], torso: torso, set_part: function (slot, hex) { var s = clamp(Math.round(num(slot, 0)), 0, 2); colors[s] = hex; for (var i = 0; i < slots[s].length; i++) slots[s][i].material.color.set(hex); }, }; } var ended = false, phase = 'hide'; // 실제 값은 _start_role 이 확정(모드 선택 지연 가능 구조) var _seek_t0 = null; // 라운드 시작 시각 — 공유 카드 "찾는 데 걸린 시간" 각인용(사용자 확정 2026-07-25) var found = 0, score = 0, detect = 0; var ui = _arch_hud(String(c.title || _at_t('위장 숨바꼭질', 'カモフラかくれんぼ', 'Camo Hide & Seek')).slice(0, 40)); // HUD = 우상단 상태 존(좌상단 = 컨트롤 스택과 분리 — 겹침 소거, 사용자 확정 2026-07-25) ui.style.left = 'auto'; ui.style.right = '12px'; ui.style.textAlign = 'right'; ui.style.top = 'calc(60px + env(safe-area-inset-top,0px))'; var _hs_room_name = null; // 현재 방 이름 — 확정 시 HUD 타이틀 = 방 이름(사용자 확정: "위장 숨바꼭질" 대신) function _hud_room(nm0) { if (!nm0) return; _hs_room_name = String(nm0); ui.children[0].textContent = Array.from(_hs_room_name).slice(0, 22).join(''); } function _fmt_t(s) { return Math.floor(s / 60) + ':' + ('0' + Math.floor(s % 60)).slice(-2); } var player, chams = [], hunter = null, _priv_api = {}; var man = null, PLh = null, _role_started = false; // 분기-소유 자원의 impl-스코프 승격(구 var 호이스팅 계약 — // _start_role 지연 구조로 스코프가 좁아지며 _hud/_end 의 참조가 끊긴 실보고 'man is not defined' 봉합) var _hs_pure = false; // hideshare '숨기' 선택 = 순수 은신 제작 모드(사용자 확정 2026-07-24): AI 술래·타이머·발각 미발동, 상시 저장 버튼이 목표 var _timer = 0; // ── 역할 시작(지연 가능) — hideshare 활성 게임은 시작 카드에서 숨기/찾기를 고른다(2026-07-24 // 사용자 확정 UX). 비활성·도전장·골든 경로는 아래 디스패처가 동기 호출 = 종전 부팅과 동일. function _start_role(r) { role = r; _role_started = true; _seek_t0 = performance.now(); phase = role === 'hunter' ? 'seek' : 'hide'; _timer = role === 'hunter' ? round_s : hide_s; if (role === 'hunter') P.leaderboard.enable({ metric: 'found', unit: '', max: 10, higher_better: true, min_play_s: 5 }); else if (!_hs_pure) P.leaderboard.enable({ metric: 'score', unit: 'pt', max: 100000, higher_better: true, min_play_s: 5 }); // 하이더 = 생존 점수 보드(순수 숨기 모드는 점수 자체가 없음) if (role === 'hunter') { // ════ 헌터 모드: 위장체 전원 발견 ════ // 카메라/발사 계약(2026-07-21 실보고 봉합): ① view 'shoulder' — 본체 가시(1인칭의 "내 캐릭터가 // 안 보인다" 실보고) + 뷰모델 생략(무기 스케일 사고 클래스가 구조적으로 소멸) ② auto:false — // fps 의 auto 발사는 몹-결합(크로스헤어 온 타겟)인데 위장체는 몹이 아니라 모바일 발사 수단이 // 부재했다(실보고 "공격 버튼 무반응"의 근인). 전용 ◉ 버튼 + J/X = 조준 지목 사격(원작 문법). player = world.player({ spawn: 'adventurer', at: { x: 0, z: 0 }, speed: 6.8, run_speed: 6.8 }); // 상시 빠름 var PLh2 = world._priv.player(); PLh2.handle.obj.position.y = Y_R + 0.2; // 스폰 = 안뜰 바닥(지형-스폰 이중공간 실보고 봉합) PLh2.handle.hold('blast_blaster_c', { h: 0.62 }); // 페인트 블라스터 장착 — 물감 스트림의 시작점 앵커(AI 헌터와 동일 기물) world.camera_bounds({ // 밀폐 세트 = 숄더캠도 볼륨 밖으로 못 나간다(세트 밖·바닥 아래 노출 차단) x0: -ROOM_W / 2 + 1.2, x1: ROOM_W / 2 - 1.2, z0: -ROOM_D / 2 + 1.2, z1: ROOM_D / 2 - 1.2, y0: (MS ? Y_R + 0.45 : B1Y + 0.55), // 바닥=지하(하강 추종)·천장=둘레벽(메자닌 시점) — 층 구조의 볼륨 y1: Y_R + WH_P - 0.35, // 맵 모드도 실측 천장까지 — 천장 관통 시점은 카메라 메시 차폐(cam_occl)가 구조적으로 차단(구 0.55 캡은 메자닌에서 카메라를 가구에 박는 임시처방이라 철거) }); // 무대 승상(+3.2) 후 부팅 첫 프레임 카메라(지형 높이)가 볼륨 아래 — 1회 스냅(이탈 0프레임 계약) if (P.three.camera.position.y < Y_R + 0.75) P.three.camera.position.y = Y_R + 1.4; if (MS && _MAPH) _MAPH.on_ready(function (h9) { // 실내 바닥 착지 — y-인지 제공자는 질의 평면(현재 y) 기준이라 초기 y(지형)가 실내 바닥 아래면 기초부를 지면으로 잡는다(실보고: 머리만 바닥 위 돌출). 1층 눈높이 평면(Y_R+1.2)에서 재스냅 — bounds.y1 기준이면 지붕을 보행면으로 오채택한다. var _po = world._priv.player().handle.obj.position; // 바닥-우선 후보 스캔(실보고 2026-07-29 "핑크 벽 클로즈업" — 스폰점 보행면이 장롱/책상 상단 // (Y_R+1.7 관용 안)이면 가구 위 스냅 + 카메라 벽 매몰): 스폰 주변 후보 링에서 걷어 올린 보행면 // 중 *가장 낮은* 것(=바닥)을 채택. 전 후보가 가구 위뿐이면 그 최저값(기존 동작의 상위호환). var _best9 = null, _bx9 = _po.x, _bz9 = _po.z; // 후보 = 동심 링(반경 사다리 — 대형 맵의 무바닥 존(거대 풀·홀)이 근접 링을 통째로 삼키는 실측 // 대응). 바닥 밴드(Y_R±) 최저 보행면 채택, 첫 바닥-밴드 히트 링에서 조기 종료(원점 선호). // 조기 종료는 *바닥 등급*(Y_R+0.8 미만) 히트에서만 — 가구-상단 밴드(≤Y_R+1.7)로 멈추면 // 원점의 장롱 위가 다시 채택된다(house 회귀 실측). 바닥 미발견 시 전 링의 최저 보행면 폴백. var _radii9 = [0, 1.2, 2.4, 5, 9, 16, 28], _floor9 = false; for (var _ri9 = 0; _ri9 < _radii9.length && !_floor9; _ri9++) { var _r9 = _radii9[_ri9]; for (var _ai9 = 0; _ai9 < (_r9 === 0 ? 1 : 8); _ai9++) { var _th9 = (_ai9 / 8) * Math.PI * 2; var _cx9 = _po.x + Math.cos(_th9) * _r9, _cz9 = _po.z + Math.sin(_th9) * _r9; var _g9 = world._priv.ground_ext_probe(_cx9, _cz9, Y_R + 1.2); if (_g9 != null && _g9 > -1e8 && _g9 < Y_R + 1.7 && (_best9 == null || _g9 < _best9)) { _best9 = _g9; _bx9 = _cx9; _bz9 = _cz9; if (_g9 < Y_R + 0.8) _floor9 = true; } } } if (_best9 != null) { _po.x = _bx9; _po.z = _bz9; _po.y = _best9 + 0.25; } // 스폰 방향 = 8방위 중 최장 자유거리(가슴 높이 ray_t) — "맵 중심 지향"은 중앙 조형물이 있는 // 맵에서 첫 프레임이 조형물 클로즈업이 되는 실측(vaporhall 다비드상). 트인 방향이 정본. if (world._priv.cam_face) { var _bd9 = null, _bl9 = -1; for (var _di9 = 0; _di9 < 8; _di9++) { var _dt9 = (_di9 / 8) * Math.PI * 2; var _dl9 = h9.ray_t(_po.x, (_best9 != null ? _best9 : _po.y) + 1.3, _po.z, Math.sin(_dt9), 0, Math.cos(_dt9), 60); if (_dl9 < 0) continue; // 무히트 = 개구부 밖 공허 방향(문구멍) — 실내 최장 시야만 후보(실측: station 문 밖 응시) if (_dl9 > _bl9) { _bl9 = _dl9; _bd9 = _dt9; } } if (_bd9 != null) world._priv.cam_face(_bd9); } }); world.controls({ mission: String(c.hint || _at_t('위장한 카멜레온을 조준하고 클릭·◉ 버튼으로 쏘세요! 눈이 단서예요', '擬態したカメレオンを狙ってクリック・◉ボタンで撃て!目が手がかり', 'Aim at the camouflaged chameleons — click or tap ◉ to shoot! The eyes give them away')).slice(0, 80) }); var _st9 = document.createElement('style'); // 종료 후에도 조준·사격 유지(사용자 확정 — 샌드박스 사격 문법) _st9.textContent = 'body[data-pend="1"] #playable-crosshair{display:block !important;}'; document.head.appendChild(_st9); world.fps({ view: 'shoulder', weapon: null, auto: false, fire_rate: 2.4, damage: 1, range: 50, spread: 0, crosshair: 'sniper', tracer: false, fire_sfx: false, // 십자 레티클 · 기본 트레이서/합성음 억제(물감 스트림 + 실녹음 샷건이 정본) on_fire: function (si) { _last_fire_t = null; _sg_fire(); // 실녹음 샷건(랜덤 5발 변주) var fi = _cam_fire_ray(null); if (si) { // 종점 접촉 정합(실보고 "끝이 반드시 조준선에 닿게") — fps 는 몹/지형만 알아 // 벽·마네킹 조준 시 스트림이 관통해 원거리에서 끝났다. 벽 레이 + 명중 거리로 클램프. var ct9 = si.end_t; var wt9 = _ray_walls(si.ox, si.oy, si.oz, si.dx, si.dy, si.dz, ct9, false); // 카메라 원점 레이 = 크로스헤어 중심 정본 if (wt9 >= 0 && wt9 < ct9) ct9 = wt9; if (_last_fire_t != null && _last_fire_t < ct9 + 0.6) ct9 = _last_fire_t; si = { ox: si.ox, oy: si.oy, oz: si.oz, mx: si.mx, my: si.my, mz: si.mz, dx: si.dx, dy: si.dy, dz: si.dz, end_t: Math.max(0.6, ct9), hit: si.hit }; _mk_beam(si); _paint_hitfx(si, fi); } A._emit('shot', { found: fi >= 0 }); }, }); // 위장체 배치(실보고 2 봉합) — 벽 세그먼트 무작위 배정, 의태색 = 벽색과 사실상 동일 // (imperfect 미세 혼합·부위 편차 제거·동일 재질값·벽에 납작 밀착) — 발견 단서 = 눈·실루엣. // 데크(MZ)/지하(BM) 하부·내부 벽 제외 — 숄더캠 조준선이 층 슬랩 차폐와 구조적으로 충돌하는 // 자리(카메라는 데크를 넘고 레이는 막힘 — 2026-07-24 밀집 A/B 계측). 개활 안뜰 벽만 배치. function _in_rect(x, z, R2) { return x >= R2.x0 && x <= R2.x1 && z >= R2.z0 && z <= R2.z1; } // hideshare 고스트 배치 — 도전장(?hide=) 방문 시 AI 배치 대신 저장된 실제 은신 기록을 세운다. // 위치 재검은 기하 소유자인 셸의 몫(에지는 수치 위생만): bounds 클램프 + (맵 모드) 보행면 // probe + 실내 불변식. 기각/0건 = 기본 배치 폴백(게임은 항상 완전). function _hs_place_ghost(rec) { var gx = clamp(num(rec.x, 0), -ROOM_W / 2 + 0.5, ROOM_W / 2 - 0.5); var gz = clamp(num(rec.z, 0), -ROOM_D / 2 + 0.5, ROOM_D / 2 - 0.5); var gy = Y_R; if (MS && _MAPH && _MAPH.ready) { var g9 = world._priv.ground_ext_probe(gx, gz, num(rec.y, Y_R) + 0.9); if (g9 == null || g9 < Y_R - 1) return false; if (_MAPH.ray_t(gx, g9 + 1.2, gz, 0, 1, 0, 120) < 0) return false; // 지붕 불변식(이동 캐논과 동일) gy = g9; } var gman = null; if (rec.skin && P.avatar && P.avatar.spawn_from) { // 포즈·색칠 원본 재현(스킨 동봉 기록) try { gman = P.avatar.spawn_from(rec.skin); } catch (e9) { gman = null; } } if (!gman) { // 구기록/스킨 부재 = 3색 마네킹 폴백(additive 하위호환) gman = _mk_manne('#eceae4'); var gcs = rec.c || []; for (var s9 = 0; s9 < 3; s9++) if (/^#[0-9a-f]{6}$/i.test(String(gcs[s9] || ''))) gman.set_part(s9, String(gcs[s9])); } gman.g.position.set(gx, gy, gz); gman.g.rotation.y = num(rec.ry, 0); scene.add(gman.g); chams.push({ x: gx, y: gy, z: gz, nx: Math.sin(num(rec.ry, 0)), nz: Math.cos(num(rec.ry, 0)), solid: true, man: gman, found: false, breath: prng() * 6.28, reveal_t: -1, ghost_i: Array.from(String(rec.nm || rec.i || '')).slice(0, 14).join('') }); return true; } function _hs_load_ghosts() { function _go(rows) { var placed = 0; for (var r9 = 0; r9 < rows.length && placed < 8; r9++) if (rows[r9] && _hs_place_ghost(rows[r9])) placed++; // 캡 8 = 방장+참가 7 if (!placed) { _place_default(); return; } // 기록 소실/전기각 = 기본 게임 폴백(LOUD-lite) n_hiders = placed; _hud(); P.toast('🎯 ' + _at_t('친구가 남긴 은신을 찾아라! (' + placed + '명)', '友達が残した隠れ場所を探せ! (' + placed + '匹)', 'Find the hides friends left! (' + placed + ')')); } var _pHead = _hs_ch // 게임방/도전장 = 지목 기록 우선, 무파라미터 "찾기" = 최근 풀만(구세대 호환 풀-랜덤 모드) ? fetch(_hs_url('?id=' + _hs_ch + (_hs_pin ? '&pin=' + _hs_pin : ''))).then(function (r) { return r.json(); }).catch(function () { return null; }) : Promise.resolve(null); _pHead.then(function (one) { if (one && one.row && one.row.map && (!MS || one.row.map !== MS.name) && _MAP_ROSTER.some(function (r0) { return r0.name === one.row.map; })) { try { if (_hs_pin) sessionStorage.setItem('camo_pin', _hs_pin); } catch (e9) {} location.href = location.pathname + '?camo_map=' + one.row.map + '&hide=' + _hs_ch; // 방의 맵으로 정합 리로드 return []; } if (one && one.locked && !one.row) { // 잠금 방 도전장 직링크 — PIN 입력 후 재시도(취소 = AI 폴백) return new Promise(function (res9) { _hs_pin_modal({ id: _hs_ch }, function (pin9, row9) { if (pin9 && row9) { _hs_pin = pin9; res9([row9].concat(row9.members || [])); } else res9([]); }); }); } var head = (one && one.row) ? [one.row] : []; // 게임방 의미론(2026-07-25): 방을 지목했으면 그 방 주인 1명만 헌트(방 = 그 하이더의 공간). // 풀 합류는 무파라미터 구세대 경로 전용 유지(additive 호환). if (_hs_ch && head.length) { _hud_room(_hs_name(head[0])); return [head[0]].concat(head[0].members || []); } // 방 = 방장+참가 하이더 전원 · HUD 타이틀 = 방 이름 return fetch(_hs_url('')).then(function (r) { return r.json(); }).catch(function () { return { rows: [] }; }) .then(function (pool) { var rest = ((pool && pool.rows) || []).filter(function (r2) { return !head.length || r2.id !== head[0].id; }); return head.concat(rest); }); }).catch(function () { return []; }).then(function (rows) { if (MS && _MAPH && !_MAPH.ready) _MAPH.on_ready(function () { _go(rows); }); else _go(rows); }); } function _place_default() { if (!MS) { var _MZ2 = { x0: ROOM_W / 2 - CW * 1.32 - 1.2, x1: ROOM_W / 2, z0: -ROOM_D / 2, z1: -ROOM_D / 2 + CD + 1.2 }; var _BM2 = { x0: -ROOM_W / 2, x1: -5.0 + 1.2, z0: -ROOM_D / 2, z1: -ROOM_D / 2 + ROOM_D / 3 + 1.2 }; var _wall_pick = walls.filter(function (w9) { return w9.y === Y_R && !_in_rect(w9.x, w9.z, _MZ2) && !_in_rect(w9.x, w9.z, _BM2); }); if (!_wall_pick.length) _wall_pick = walls.slice(); for (var wp = _wall_pick.length - 1; wp > 0; wp--) { var wq = Math.floor(prng() * (wp + 1)); var wt = _wall_pick[wp]; _wall_pick[wp] = _wall_pick[wq]; _wall_pick[wq] = wt; } for (var ci = 0; ci < n_hiders; ci++) { (function (ci2) { var w = _wall_pick[ci2 % _wall_pick.length]; var lateral = clamp((prng() - 0.5) * 2 * (w.hl - 0.9), -(w.hl - 0.9), w.hl - 0.9); var p = _wall_inner(w, lateral, 0.5); var off_hue = SCREEN_HUES[(ci2 + 3) % SCREEN_HUES.length]; var tint = _mix(w.color, off_hue, DIFF.imperfect * (0.5 + prng() * 0.5)); var man = _mk_manne(tint); man.g.position.set(p.x, w.y, p.z); man.g.rotation.y = Math.atan2(w.nx, w.nz); // 벽을 등지고 실내를 본다(눈 단서 = 정면) man.g.scale.set(0.94 + prng() * 0.1, 0.94 + prng() * 0.1, 0.5); // 벽면 밀착 플랫 실루엣(원작 문법) scene.add(man.g); chams.push({ x: p.x, y: w.y, z: p.z, nx: w.nx, nz: w.nz, solid: !!w.solid, man: man, found: false, breath: prng() * 6.28, reveal_t: -1 }); })(ci); } } else _MAPH.on_ready(function (h9) { // 맵 모드 위장체 배치 — 벽색 데이터가 없으므로 배치·의태색 모두 표면 레이캐스트: 보행 가능 // 지점(다층 포함 무작위 y-평면 probe)에서 8방 최근접 표면을 찾아 그 표면색으로 의태·밀착. var b9 = h9.bounds, tries9 = 0; while (chams.length < n_hiders && tries9++ < n_hiders * 60) { var mx = b9.x0 + 0.8 + prng() * (b9.x1 - b9.x0 - 1.6); var mz = b9.z0 + 0.8 + prng() * (b9.z1 - b9.z0 - 1.6); var qy = b9.y0 + 0.5 + prng() * Math.max(0.5, b9.y1 - b9.y0 - 1.0); // 무작위 층 평면(복층 맵 분산) var gy = world._priv.ground_ext_probe(mx, mz, qy); if (gy == null || gy < b9.y0 - 0.5 || gy > b9.y1 - 1.2) continue; // 보행면 없음/지붕 위 제외 if (!h9.open_air && h9.ray_t(mx, gy + 1.2, mz, 0, 1, 0, 80) < 0) continue; // 지붕 아래(실내)만 — 실외 로트는 전 보행면 허용 var near9 = false; // 위장체 간 최소 간격(과밀 = 한 발에 다수 노출) for (var ck = 0; ck < chams.length; ck++) { var cdx = chams[ck].x - mx, cdz = chams[ck].z - mz; if (cdx * cdx + cdz * cdz < 2.6) { near9 = true; break; } } if (near9) continue; var best9 = null; for (var da = 0; da < 8; da++) { var an9 = da * Math.PI / 4, ddx9 = Math.cos(an9), ddz9 = Math.sin(an9); var t9 = h9.ray_t(mx, gy + 0.9, mz, ddx9, 0, ddz9, 1.6); if (t9 >= 0 && (!best9 || t9 < best9.t)) best9 = { t: t9, dx: ddx9, dz: ddz9 }; } if (!best9 || best9.t < 0.35) continue; // 표면 내부/과밀착 지점 제외 var shex = h9.surface_color_at(mx, gy + 0.9, mz, best9.dx, 0, best9.dz, 2.0) || '#8a8a8a'; var mpx = mx + best9.dx * (best9.t - 0.3), mpz = mz + best9.dz * (best9.t - 0.3); var off_hue9 = SCREEN_HUES[(chams.length + 3) % SCREEN_HUES.length]; var man9 = _mk_manne(_mix(shex, off_hue9, DIFF.imperfect * (0.5 + prng() * 0.5))); man9.g.position.set(mpx, gy, mpz); man9.g.rotation.y = Math.atan2(-best9.dx, -best9.dz); // 표면을 등지고 실내를 본다 man9.g.scale.set(0.94 + prng() * 0.1, 0.94 + prng() * 0.1, 0.5); scene.add(man9.g); chams.push({ x: mpx, y: gy, z: mpz, nx: -best9.dx, nz: -best9.dz, solid: true, man: man9, found: false, breath: prng() * 6.28, reveal_t: -1 }); } if (chams.length < n_hiders) { // 수용 실패분 = 목표 하향(침묵 미달성 게임 금지 — LOUD) try { console.warn('[camo] map cham placement short: ' + chams.length + '/' + n_hiders); } catch (e) {} n_hiders = Math.max(1, chams.length); _hud(); } }); } if (_hs_key && !_hs_ai) _hs_load_ghosts(); else _place_default(); // 게임방 헌트(0건=기본 폴백) · AI 게임방/비활성 = AI 배치 // 공유 순수 발견 판정 — on_fire 와 스모크가 동일 구현 소비(매퍼 규율). ray=null 이면 카메라 레이. // (주의: 블록-레벨 함수 선언은 선언문 실행 시점에 바인딩 — _priv_api 배선은 선언 뒤에서 한다) var _dirv = new THREE_.Vector3(), _last_fire_t = null; // _last_fire_t = 직전 사격의 마네킹 명중 거리(스트림 종점 정합 소비) function _cam_fire_ray(ray) { if (ended || phase !== 'seek') return -1; var ox, oy, oz, dx, dy, dz; if (ray) { ox = ray.ox; oy = ray.oy; oz = ray.oz; dx = ray.dx; dy = ray.dy; dz = ray.dz; } else { var cam2 = P.three.camera; cam2.getWorldDirection(_dirv); ox = cam2.position.x; oy = cam2.position.y; oz = cam2.position.z; dx = _dirv.x; dy = _dirv.y; dz = _dirv.z; } // 전신 캡슐 판정: 몸통 세로축을 다리·몸통·머리 3점으로 표본화 → 조준선이 몸 어디에 닿아도 발견. // (단일 mid-body 구는 표적이 너무 작아 머리·다리 조준이 빗나가는 정합성 버그의 근인이었음) var HITS = [0.42, 0.9, 1.42], HR2 = 0.6 * 0.6; var best = -1, bestT = 50; for (var i = 0; i < chams.length; i++) { var ch = chams[i]; if (ch.found) continue; for (var s = 0; s < HITS.length; s++) { var cx = ch.x - ox, cy = (ch.y + HITS[s]) - oy, cz = ch.z - oz; var tca = cx * dx + cy * dy + cz * dz; if (tca < 0 || tca > bestT) continue; var d2 = cx * cx + cy * cy + cz * cz - tca * tca; if (d2 <= HR2) { best = i; bestT = tca; break; } } } if (best < 0) return -1; var wt = _ray_walls(ox, oy, oz, dx, dy, dz, bestT, true); // 고형(벽·기둥)만 차폐 — 낮은 소품 너머는 발견 가능 if (wt >= 0 && wt < bestT) return -1; // 벽이 먼저 가림(월핵 차단) _last_fire_t = bestT; _find(best); return best; } function _find(i) { var ch = chams[i]; ch.found = true; found++; ch.reveal_t = 0; ch.man.g.traverse(function (mm2) { // 아바타 몸 = poseRoot/피벗 중첩 — 1층 children 순회는 무발화(traverse 정본) if (mm2.material && mm2.material.emissive) { mm2.material.emissive.set(pal.accent); mm2.material.emissiveIntensity = 0.4; } }); fx.burst({ x: ch.x, y: ch.y + 1.2, z: ch.z }, { count: 20, color: pal.accent, speed: 2.8, life: 1.1, gravity: -0.15 }); fx.float_text({ x: ch.x, y: ch.y + 2, z: ch.z }, _at_t('찾았다!', '見つけた!', 'Found!') + (ch.ghost_i ? ' · ' + ch.ghost_i : ''), { color: pal.accent }); // 고스트 = 남긴 친구 이니셜 공개 fx.sfx('powerup'); _hud(); A._emit('found', { index: i, found: found, total: n_hiders }); if (found >= n_hiders) _end(true); } // ── 페인트 사격 VFX v3(원작 스크린샷 재현 — 사용자 제공 레퍼런스 2026-07-24) ── // ① 무지개 빔: 길이를 따라 HSL 무지개가 흐르는(스크롤) *정점색* 실린더 + 백색 코어 심지 — // 그라데이션은 텍스처가 아니라 정점색이 정본(즉발 풀렝스·짧은 수명 → 연사 = 연속 물줄기). // ② 착탄 번짐: Kenney Splat Pack(CC0) 백색 실루엣 8종 아틀라스(vendor-ext 콘텐츠-해시)를 // 무지개 틴트 데칼로 — 명중 마네킹에는 attach(숨쉬기 추종) 되어 물감이 쌓이고, 빗나간 // 표면엔 대형+위성 얼룩이 팝-번져 오래 남는다. ③ 명중 시 마네킹 3부위가 페인트 범벅. var _PFX_URL = world._priv.vendor_ext_base() + 'd028095bc033283d0b003180c6fef2195d85ea80b32f77a60df6f73b4e2d98fd.png'; // 실녹음 샷건 v2 — The Free Firearm Sound Library(CC0·96kHz/24bit 근접 스튜디오 수록, // Mossberg 500 + Benelli Nova 펌프 샷건 단발) — 필드 녹음(갈라짐 실보고)의 상위 대체. var _SG_URLS = [ world._priv.vendor_ext_base() + 'ac7208fc9a9dff8c1455a1a05f55bd240410aec2117dea28e72430eb7c4846df.m4a', world._priv.vendor_ext_base() + '8dbe9a05ce9fd694de0a6bb121732c4dc1a70ab4a48b7ef060c7d71c9dac7942.m4a', ]; for (var _sgp = 0; _sgp < _SG_URLS.length; _sgp++) P.sfx._bank_load(_SG_URLS[_sgp]); // 부팅 프리로드 // 슬라이스 테이블 — 원본 수록은 파일당 2발 + **선행 무음 0.69~1.70s**(발사-소리 싱크 어긋남의 // 근인, 파형 실측 2026-07-25). offset = 온셋 20ms 앞(어택 보존), duration = 실측 테일 + 여유. var _SG_CUTS = [ { u: 0, off: 1.676, dur: 1.1 }, { u: 0, off: 4.940, dur: 1.1 }, { u: 1, off: 0.675, dur: 1.9 }, { u: 1, off: 3.687, dur: 1.9 }, ]; function _sg_fire() { // 근접 스튜디오 단발(4슬라이스 랜덤 + 미세 재생속도 변주 = 실총 변주 문법) if (P._muted) return; var c9 = _SG_CUTS[Math.floor(prng() * _SG_CUTS.length)]; var b9 = P.sfx._bank_get(_SG_URLS[c9.u]); var ok9 = !!(b9 && P.sfx._bank_play(b9, 0.9, { offset: c9.off, duration: c9.dur, rate: 0.96 + prng() * 0.08 })); if (!ok9) fx.sfx('shoot', { volume: 0.6 }); // 미디코드 = 합성 폴백(무음 회귀 금지) } var _PFX_TEX = null; try { _PFX_TEX = new THREE_.TextureLoader().load(_PFX_URL); if (THREE_.SRGBColorSpace) _PFX_TEX.colorSpace = THREE_.SRGBColorSpace; } catch (e) {} var _RAINBOW = ['#ff3355', '#ff8a2b', '#ffd23e', '#4ade80', '#4a9fd8', '#b46bd4']; var _beams = [], _decals = [], _rb_i = 0; var _BEAM_GEO = new THREE_.CylinderGeometry(0.08, 0.045, 1, 10, 24, true); // 노즐(하단 -Y)→착탄(+Y) 테이퍼 — 반경은 지오메트리에 베이크(스케일은 길이만) var _MUZ = new THREE_.Vector3(); var _QUAD_GEO = new THREE_.PlaneGeometry(1, 1); var _YUP = new THREE_.Vector3(0, 1, 0), _DIRV3 = new THREE_.Vector3(), _HSL = new THREE_.Color(); function _gun_tip() { // 시작점 = 캐릭터 가슴 앵커(실보고: 총구 좌표는 장착물 원점·포즈에 따라 // 몸과 간극 발생) — 가슴에서 출발하면 반대쪽 끝이 반드시 몸통과 연결된다(총 위를 지나며 자연스럽게 읽힘) var pp0 = player.pos; var yw = (PLh2 && PLh2.handle) ? PLh2.handle.obj.rotation.y : 0; _MUZ.set(pp0.x + Math.sin(yw) * 0.28, pp0.y + 1.02, pp0.z + Math.cos(yw) * 0.28); return _MUZ; } function _mk_beam(si) { if (_beams.length > 5) return; // 정합 계약(실보고 2026-07-24): 종점 = 카메라 레이 착탄점(크로스헤어와 정확히 일치), // 시작점 = 캐릭터의 총구 — 표준 TPS 문법(카메라 총구 근사는 허공 발사로 보였다). var ex0 = si.ox + si.dx * si.end_t, ey0 = si.oy + si.dy * si.end_t, ez0 = si.oz + si.dz * si.end_t; // 종점 = *카메라* 레이 점 — 화면 중심 도트에 정확히 투영(총구 근사 원점은 평행이동 오차) var gp = _gun_tip(); var bx = ex0 - gp.x, by = ey0 - gp.y, bz = ez0 - gp.z; var bl = Math.sqrt(bx * bx + by * by + bz * bz) || 1; var g = _BEAM_GEO.clone(); g.setAttribute('color', new THREE_.BufferAttribute(new Float32Array(g.attributes.position.count * 3), 3)); // 물감 = 비발광(실보고 "빛이 눈 아픔" — additive/백색 코어가 블룸을 때리던 근인 제거). var m = new THREE_.Mesh(g, new THREE_.MeshBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.97, depthWrite: false, side: THREE_.DoubleSide })); m.position.set(gp.x + bx / 2, gp.y + by / 2, gp.z + bz / 2); _DIRV3.set(bx / bl, by / bl, bz / bl); m.quaternion.setFromUnitVectors(_YUP, _DIRV3); m.scale.set(1, bl, 1); scene.add(m); _beams.push({ m: m, g: g, t: 0, life: 0.24, ph: prng() * 6 }); } function _beam_paint(B) { // 정점색 무지개 기입(스크롤 위상) — 24 높이분할 × 링 정점 var pos = B.g.attributes.position, col = B.g.attributes.color; for (var vi = 0; vi < pos.count; vi++) { var ty = pos.getY(vi) + 0.5; // 0..1(길이 방향) _HSL.setHSL(((ty * 2.4) + B.ph) % 1, 0.85, 0.55); // 물감 톤(네온 아님) col.setXYZ(vi, _HSL.r, _HSL.g, _HSL.b); } col.needsUpdate = true; } function _splat_tex(ix) { // 3×3 아틀라스 서브렉트(0..7 스플랫, 8 소프트 원) — 클론 = 이미지 공유 if (!_PFX_TEX) return null; var t = _PFX_TEX.clone(); t.needsUpdate = true; t.repeat.set(1 / 3, 1 / 3); t.offset.set((ix % 3) / 3, (2 - Math.floor(ix / 3)) / 3); return t; } function _mk_decal(parent, x, y, z, nx, ny, nz, hex, s1, life, delay, ix) { var t = _splat_tex(ix == null ? Math.floor(prng() * 8) : ix); if (!t || _decals.length > 90) return; var m = new THREE_.Mesh(_QUAD_GEO, new THREE_.MeshBasicMaterial({ map: t, color: hex, transparent: true, depthWrite: false, side: THREE_.DoubleSide })); m.position.set(x + nx * 0.05, y + ny * 0.05, z + nz * 0.05); m.lookAt(x + nx, y + ny, z + nz); m.rotateZ(prng() * 6.283); m.scale.setScalar(0.02); m.visible = false; scene.add(m); if (parent) parent.attach(m); // 월드 변환 보존 부착 — 마네킹 숨쉬기/리빌 점프를 따라간다 _decals.push({ m: m, t: -(delay || 0), life: life, s1: s1 }); } function _paint_hitfx(si, hit_i) { var ex = si.ox + si.dx * si.end_t, ey = si.oy + si.dy * si.end_t, ez = si.oz + si.dz * si.end_t; // 스플랫 = 크로스헤어 중심 레이 착탄점 var rl = Math.sqrt(si.dx * si.dx + si.dz * si.dz) || 1; var r1x = si.dz / rl, r1z = -si.dx / rl; _mk_decal(null, ex, ey, ez, -si.dx, -si.dy, -si.dz, '#ffffff', 0.5, 0.28, 0, 8); // 착탄 글로우 플래시(소프트 원) if (hit_i >= 0 && chams[hit_i]) { // 명중 — 마네킹 위에 무지개 물감이 순차로 번져 쌓인다 var ch = chams[hit_i]; for (var i5 = 0; i5 < 5; i5++) { var ox5 = (prng() - 0.5) * 0.5, oy5 = 0.4 + prng() * 0.9, oz5 = (prng() - 0.5) * 0.2; _mk_decal(ch.man.g, ch.x + r1x * ox5, ch.y + oy5, ch.z + r1z * ox5 + oz5, -si.dx, -si.dy, -si.dz, _RAINBOW[_rb_i++ % _RAINBOW.length], 0.32 + prng() * 0.22, 2.8, i5 * 0.055); } ch.man.set_part(0, _RAINBOW[_rb_i % 6]); ch.man.set_part(1, _RAINBOW[(_rb_i + 2) % 6]); ch.man.set_part(2, _RAINBOW[(_rb_i + 4) % 6]); // 페인트 범벅(레퍼런스 돔) } else { // 빗나감 — 표면에 대형 + 위성 얼룩(오래 남는 물감 자국) var hue5 = _RAINBOW[_rb_i++ % _RAINBOW.length]; _mk_decal(null, ex, ey, ez, -si.dx, -si.dy, -si.dz, hue5, 0.55 + prng() * 0.3, 3.2, 0); for (var s5 = 0; s5 < 2; s5++) { var a5 = prng() * 6.283, rr5 = 0.3 + prng() * 0.3; _mk_decal(null, ex + Math.cos(a5) * r1x * rr5, ey + Math.sin(a5) * rr5 * 0.8, ez + Math.cos(a5) * r1z * rr5, -si.dx, -si.dy, -si.dz, _RAINBOW[_rb_i++ % _RAINBOW.length], 0.16 + prng() * 0.14, 2.4, 0.06 + s5 * 0.06); } } fx.shake(0.1); } function _pfx_tick(dt) { for (var b6 = _beams.length - 1; b6 >= 0; b6--) { var B = _beams[b6]; B.t += dt; B.ph += dt * 4.2; if (B.t >= B.life) { scene.remove(B.m); B.g.dispose(); B.m.material.dispose(); _beams.splice(b6, 1); continue; } _beam_paint(B); var kb = B.t / B.life, fb = 1 - Math.max(0, (kb - 0.5) / 0.5); B.m.material.opacity = 0.97 * fb; } for (var d6 = _decals.length - 1; d6 >= 0; d6--) { var D = _decals[d6]; D.t += dt; if (D.t < 0) continue; // 순차 번짐 딜레이 if (D.t >= D.life) { if (D.m.parent) D.m.parent.remove(D.m); D.m.material.map.dispose(); D.m.material.dispose(); _decals.splice(d6, 1); continue; } D.m.visible = true; var kd = D.t / D.life; var ed = 1 - Math.pow(1 - Math.min(1, kd * 3), 3); // 빠른 팝-번짐 var ws = D.s1 * ed; D.m.scale.set(ws, ws, ws); D.m.material.opacity = 0.96 * (1 - Math.max(0, (kd - 0.62) / 0.38)); } } P.loop(function (dt) { if (dt > 0) _pfx_tick(dt); }); // 독립 등록 — 메인 루프의 ended 게이트 밖: // 종료 후에도 빔 스크롤/데칼 번짐이 살아 있고(동결 실보고 봉합), 샌드박스 사격 연출이 유지된다 _priv_api.fire_ray = _cam_fire_ray; // 휘파람(원작 단서 재현 — 거리 비례 음량, 위치 비공개) var _whistle_t = DIFF.whistle * 0.6, _whistle_hinted = false; var _mv_lx = 0, _mv_lz = 0; // 맵 모드 벽막이용 직전 위치(콜라이더 부재 대체 — 세그먼트 차폐 시 복원) P.loop(function (dt) { if (ended || dt <= 0) return; dt = Math.min(dt, 0.1); if (MS && _MAPH && _MAPH.ready) { // 맵 모드 이동 구속(하이더 루프와 동일 정본 헬퍼) + 실내 폐루프 var pp0 = player.pos; if (_map_move_deny(_mv_lx, _mv_lz, pp0)) { pp0.x = _mv_lx; pp0.z = _mv_lz; } _map_interior_enforce(pp0); _mv_lx = pp0.x; _mv_lz = pp0.z; } _timer -= dt; ui.children[2].textContent = '⏱ ' + _fmt_t(Math.max(0, _timer)); if (_timer <= 0) { _end(false); return; } _whistle_t -= dt; if (_whistle_t <= 0) { _whistle_t = DIFF.whistle; var pp = player.pos, near = -1, nd = Infinity; for (var i = 0; i < chams.length; i++) { if (chams[i].found) continue; var dd = (chams[i].x - pp.x) * (chams[i].x - pp.x) + (chams[i].z - pp.z) * (chams[i].z - pp.z); if (dd < nd) { nd = dd; near = i; } } if (near >= 0) { fx.sfx('blip', { volume: clamp(1 - Math.sqrt(nd) / 42, 0.12, 0.85) }); A._emit('whistle', { distance: Math.sqrt(nd) }); if (!_whistle_hinted) { _whistle_hinted = true; P.toast(_at_t('🎵 휘파람 소리 — 가까울수록 크게 들려요', '🎵 口笛の音 — 近いほど大きく聞こえる', '🎵 A whistle — louder means closer')); } } } for (var bi = 0; bi < chams.length; bi++) { // 숨쉬기 미세 동요 + 발견 점프 var ch = chams[bi]; ch.breath += dt * 1.7; ch.man.torso.scale.y = 1 + Math.sin(ch.breath) * 0.02; if (ch.reveal_t >= 0 && ch.reveal_t < 0.8) { ch.reveal_t += dt; ch.man.g.position.y = ch.y + Math.abs(Math.sin(ch.reveal_t * 7.85)) * 0.5 * (1 - ch.reveal_t); } } }); } else { // ════ 하이더 모드: 스포이드 도색 → AI 헌터의 수색을 버틴다 ════ // identity_locked: 하이더의 외형은 마네킹(man)이 소유하고 그 색이 곧 게임 규칙이다 — 캐릭터를 // 바꿔도 마네킹이 덮어 *시각적으로 아무 일도 일어나지 않는다*. 눌러도 무효인 버튼은 띄우지 않는다. player = world.player({ spawn: 'adventurer', at: { x: SPAWN_XZ.x, z: SPAWN_XZ.z }, speed: 6.8, run_speed: 6.8, identity_locked: true, jump: false }); // 상시 빠름 · 점프 회수(원작 문법 — Space = 스포이드 전용, 층 이동은 전부 계단) world._priv.player().handle.obj.position.y = Y_R + 0.2; // 스폰 = 안뜰 바닥 world.camera(); // 밀폐 세트 = 카메라 플레이 볼륨 클램프(세트 밖·바닥 아래 노출 차단) world.camera_bounds({ x0: -ROOM_W / 2 + 1.2, x1: ROOM_W / 2 - 1.2, z0: -ROOM_D / 2 + 1.2, z1: ROOM_D / 2 - 1.2, y0: (MS ? Y_R + 0.45 : B1Y + 0.55), // 바닥=지하(하강 추종)·천장=둘레벽(메자닌 시점) — 층 구조의 볼륨 y1: Y_R + WH_P - 0.35, // 맵 모드도 실측 천장까지 — 천장 관통 시점은 카메라 메시 차폐(cam_occl)가 구조적으로 차단(구 0.55 캡은 메자닌에서 카메라를 가구에 박는 임시처방이라 철거) }); // 무대 승상(+3.2) 후 부팅 첫 프레임 카메라(지형 높이)가 볼륨 아래 — 1회 스냅(이탈 0프레임 계약) if (P.three.camera.position.y < Y_R + 0.75) P.three.camera.position.y = Y_R + 1.4; if (MS && _MAPH) _MAPH.on_ready(function (h9) { // 실내 바닥 착지 — y-인지 제공자는 질의 평면(현재 y) 기준이라 초기 y(지형)가 실내 바닥 아래면 기초부를 지면으로 잡는다(실보고: 머리만 바닥 위 돌출). 1층 눈높이 평면(Y_R+1.2)에서 재스냅 — bounds.y1 기준이면 지붕을 보행면으로 오채택한다. var _po = world._priv.player().handle.obj.position; // 바닥-우선 후보 스캔(실보고 2026-07-29 "핑크 벽 클로즈업" — 스폰점 보행면이 장롱/책상 상단 // (Y_R+1.7 관용 안)이면 가구 위 스냅 + 카메라 벽 매몰): 스폰 주변 후보 링에서 걷어 올린 보행면 // 중 *가장 낮은* 것(=바닥)을 채택. 전 후보가 가구 위뿐이면 그 최저값(기존 동작의 상위호환). var _best9 = null, _bx9 = _po.x, _bz9 = _po.z; // 후보 = 동심 링(반경 사다리 — 대형 맵의 무바닥 존(거대 풀·홀)이 근접 링을 통째로 삼키는 실측 // 대응). 바닥 밴드(Y_R±) 최저 보행면 채택, 첫 바닥-밴드 히트 링에서 조기 종료(원점 선호). // 조기 종료는 *바닥 등급*(Y_R+0.8 미만) 히트에서만 — 가구-상단 밴드(≤Y_R+1.7)로 멈추면 // 원점의 장롱 위가 다시 채택된다(house 회귀 실측). 바닥 미발견 시 전 링의 최저 보행면 폴백. var _radii9 = [0, 1.2, 2.4, 5, 9, 16, 28], _floor9 = false; for (var _ri9 = 0; _ri9 < _radii9.length && !_floor9; _ri9++) { var _r9 = _radii9[_ri9]; for (var _ai9 = 0; _ai9 < (_r9 === 0 ? 1 : 8); _ai9++) { var _th9 = (_ai9 / 8) * Math.PI * 2; var _cx9 = _po.x + Math.cos(_th9) * _r9, _cz9 = _po.z + Math.sin(_th9) * _r9; var _g9 = world._priv.ground_ext_probe(_cx9, _cz9, Y_R + 1.2); if (_g9 != null && _g9 > -1e8 && _g9 < Y_R + 1.7 && (_best9 == null || _g9 < _best9)) { _best9 = _g9; _bx9 = _cx9; _bz9 = _cz9; if (_g9 < Y_R + 0.8) _floor9 = true; } } } if (_best9 != null) { _po.x = _bx9; _po.z = _bz9; _po.y = _best9 + 0.25; } // 스폰 방향 = 8방위 중 최장 자유거리(가슴 높이 ray_t) — "맵 중심 지향"은 중앙 조형물이 있는 // 맵에서 첫 프레임이 조형물 클로즈업이 되는 실측(vaporhall 다비드상). 트인 방향이 정본. if (world._priv.cam_face) { var _bd9 = null, _bl9 = -1; for (var _di9 = 0; _di9 < 8; _di9++) { var _dt9 = (_di9 / 8) * Math.PI * 2; var _dl9 = h9.ray_t(_po.x, (_best9 != null ? _best9 : _po.y) + 1.3, _po.z, Math.sin(_dt9), 0, Math.cos(_dt9), 60); if (_dl9 < 0) continue; // 무히트 = 개구부 밖 공허 방향(문구멍) — 실내 최장 시야만 후보(실측: station 문 밖 응시) if (_dl9 > _bl9) { _bl9 = _dl9; _bd9 = _dt9; } } if (_bd9 != null) world._priv.cam_face(_bd9); } }); world.controls({ smash: false, mission: String(c.hint || (_hs_pure // 하이더 = 공격 동사 무의미(색칠은 탭) — ⬡ 부수기 버튼 미장착(실보고 2026-07-25) ? _at_t('포즈·색칠로 나를 꾸미고, 숨을 곳을 정했으면 숨기 완료!', 'ポーズ&ペイントで自分を飾って、隠れ場所を決めたらかくれ完了!', 'Style yourself with Pose & Paint, pick a spot, then Hide Complete!') : _at_t('포즈·색칠로 배경과 비슷하게 꾸미고, 벽 앞에 숨어 끝까지 버티세요!', 'ポーズ&ペイントで背景に似せて、壁の前に隠れて生き延びろ!', 'Blend in with Pose & Paint, hide by a wall, and survive!'))).slice(0, 90) }); PLh = world._priv.player(); // 몸 = 붓으로 칠한 아바타(P.avatar.spawn_body 어댑터 — man.g/colors/set_part 계약 그대로라 검출· // 도색 코드 무수정). 저장 스킨이 곧 시작 위장색(Meccha Chameleon 수준: 내가 칠한 아바타로 플레이). // 아바타 부재(dom/canvas2d·미로드) = 레거시 마네킹 폴백(계약 불변). man = (P.avatar && P.avatar.spawn_body) ? P.avatar.spawn_body() : _mk_manne('#eceae4'); if (_hs_join) { // 참가 모드 — 기존 은신자(방장+멤버) 고스트를 그대로 보여주며 내 자리를 고른다 fetch(_hs_url('?id=' + _hs_join + (_hs_pin ? '&pin=' + _hs_pin : ''))).then(function (r) { return r.json(); }).catch(function () { return null; }) .then(function (one) { if (!(one && one.row)) return; if (_hs_join_row && !_hs_join_row.i) _hs_join_row.i = _hs_name(one.row); // 리로드 직행 참가 — 방명 지연 충전 _hud_room(_hs_name(one.row)); var all9 = [one.row].concat(one.row.members || []); function _show9() { all9.forEach(function (rec) { var gx = clamp(num(rec.x, 0), -ROOM_W / 2 + 0.5, ROOM_W / 2 - 0.5); var gz = clamp(num(rec.z, 0), -ROOM_D / 2 + 0.5, ROOM_D / 2 - 0.5); var gy = Y_R; if (MS && _MAPH && _MAPH.ready) { var g9 = world._priv.ground_ext_probe(gx, gz, num(rec.y, Y_R) + 0.9); if (g9 == null || g9 < Y_R - 1) return; gy = g9; } var gman = null; if (rec.skin && P.avatar && P.avatar.spawn_from) { try { gman = P.avatar.spawn_from(rec.skin); } catch (e9) { gman = null; } } if (!gman) { gman = _mk_manne('#eceae4'); var gcs = rec.c || []; for (var s9 = 0; s9 < 3; s9++) if (/^#[0-9a-f]{6}$/i.test(String(gcs[s9] || ''))) gman.set_part(s9, String(gcs[s9])); } gman.g.position.set(gx, gy, gz); gman.g.rotation.y = num(rec.ry, 0); scene.add(gman.g); world.marker(gman.g, { label: '🙈 ' + Array.from(String(rec.nm || rec.i || '')).slice(0, 14).join('') }); // 이름표 = 함께 숨는 실감 }); P.toast('🙈 ' + _at_t('이 방에 ' + all9.length + '명이 숨어 있어요 — 나도 자리를 골라 숨어요!', 'この部屋に' + all9.length + '人かくれてる — 僕も場所を選ぼう!', all9.length + ' already hiding here — pick your spot!')); } if (MS && _MAPH && !_MAPH.ready) _MAPH.on_ready(_show9); else _show9(); }); } function _adopt_handle(hd) { // 캐릭터 스왑(플랫폼 표준 동사) 승계 — 새 핸들에도 마네킹 계약 유지 hd.obj.add(man.g); function _hide2() { hd.obj.traverse(function (m) { if (m.isMesh && !m.userData._camo) m.visible = false; }); } if (hd.ready) _hide2(); else (hd._on_ready = hd._on_ready || []).push(_hide2); _hide2(); } var _cur_handle = PLh.handle; _adopt_handle(_cur_handle); if (!_hs_pure && P.avatar && P.avatar.pose_button) P.avatar.pose_button(); // 인게임 포즈 휠 — 순수 숨기 모드는 중앙 프리미엄 독이 포즈·색칠을 소유(중복 마운트 금지) if (P.avatar && P.avatar.add_menu) P.avatar.add_menu(); // "내 캐릭터 꾸미기" 메뉴 = 하이더 전용(술래 메뉴 정리 — 사용자 확정 2026-07-25) var paint_slot = 0, _last_px = SPAWN_XZ.x, _last_pz = SPAWN_XZ.z, _walk_t = 0, _speed = 0; function _paint_part(hex) { // 탭 샘플러와 스모크가 공유하는 도색 단일 경로 man.set_part(paint_slot, hex); paint_slot = (paint_slot + 1) % 3; fx.sfx('pickup'); fx.burst({ x: player.pos.x, y: player.pos.y + 1.2, z: player.pos.z }, { count: 8, color: hex, speed: 1.6, life: 0.5 }); A._emit('painted', { color: hex }); _hud(); // hide 페이즈 루프는 HUD 갱신 전에 return — 도색 칩은 여기서 즉시 반영 } // (인게임 채집-도포(탭 샘플러·스페이스 스포이드)는 전면 폐기 — 사용자 확정 2026-07-25 // '혼돈 유발': 브러시 페인터(포즈·색칠)가 유일한 도색 경로. _paint_part 는 골든 검출 // 배터리(_priv.paint_part) 전용 내부 함수로 존치.) // 도발 버튼(원작 휘파람 — 점수 보상 ↔ 발각 가속 리스크) var taunt_until = -9; var wb = document.createElement('button'); wb.type = 'button'; wb.setAttribute('data-mgmt-ui', '1'); wb.textContent = '🎵 ' + _at_t('도발', '挑発', 'Taunt'); wb.style.cssText = 'position:fixed;left:20px;bottom:calc(160px + env(safe-area-inset-bottom,0px));z-index:30;display:none;' + 'padding:12px 16px;border-radius:999px;border:2px solid rgba(245,158,11,.5);background:rgba(28,33,48,.78);color:#ffd23e;font:700 14px system-ui;touch-action:manipulation;'; wb.addEventListener('pointerdown', function (ev) { ev.preventDefault(); ev.stopPropagation(); if (ended || phase !== 'seek') return; taunt_until = performance.now() + 4000; score += 150; fx.sfx('blip', { volume: 0.8 }); A._emit('whistle', { score: score }); _hud(); }); document.body.appendChild(wb); // ── hideshare 순수 숨기 모드: 상시 저장 버튼(눈에 띄는 주 액션 — 저장이 곧 목표) ── // 언제든 현재 자리·색으로 저장(재탭 = 새 기록). 성공 시 도전장 복사 + 셀프 테스트 노출. // ── 순수 숨기 모드 프리미엄 독(사용자 확정 2026-07-25): 중앙 [포즈·색칠]+[숨기 완료!] 글로시 // CTA(샤인 스윕) — 이모지 필 폐기. 숨기 완료 = 닉네임(3자) 입력 모달 → 게임방 개설. ── function _hs_anim_css() { // 공유 카드 렌더러와 동일 키프레임(id 공유 — 중복 주입 무해 가드) if (document.getElementById('playable-shot-anim')) return; var st9 = document.createElement('style'); st9.id = 'playable-shot-anim'; st9.textContent = '@keyframes pshotSweep{0%{transform:translateX(-140%) skewX(-18deg)}55%,100%{transform:translateX(340%) skewX(-18deg)}}' + '@keyframes pshotRise{0%{opacity:0;transform:translateY(16px)}100%{opacity:1;transform:translateY(0)}}'; document.head.appendChild(st9); } function _hs_cta(icon, label, sub, grad, ink, glow, delay) { // 프리미엄 CTA 팩토리(시린+스윕+스태거) var b = document.createElement('button'); b.type = 'button'; b.setAttribute('data-mgmt-ui', '1'); b.style.cssText = 'position:relative;overflow:hidden;display:flex;align-items:center;gap:10px;padding:12px 18px;' + 'border:0;border-radius:17px;cursor:pointer;text-align:left;touch-action:manipulation;-webkit-tap-highlight-color:transparent;' + 'background:' + grad + ';color:' + ink + ';box-shadow:0 10px 26px ' + glow + ',inset 0 2px 0 rgba(255,255,255,.5),inset 0 -3px 0 rgba(0,0,0,.22);' + 'animation:pshotRise .5s cubic-bezier(.2,.8,.2,1) ' + delay + ' both;'; b.innerHTML = '
' + '
' + '
' + icon + '
' + '
' + '
' + label + '
' + (sub ? '
' + sub + '
' : '') + '
'; return b; } function _hs_room_modal() { // 방 이름 → 게임방 개설/참가(리더보드 닉네임 스토어 공유) P._modal_freeze = true; // 모달 중 게임 시간·입력 동결(타이핑=이동 실보고 — 키 채널 가드와 2중 봉합) var mv = document.createElement('div'); mv.setAttribute('data-mgmt-ui', '1'); mv.style.cssText = 'position:fixed;inset:0;z-index:99978;display:flex;align-items:center;justify-content:center;background:rgba(6,9,16,.82);backdrop-filter:blur(5px);font-family:system-ui,sans-serif;'; // 시작 카드(99960) 위·공유 카드(99985) 아래 function _mv_close() { P._modal_freeze = false; try { mv.remove(); } catch (e9) {} } mv.addEventListener('pointerdown', function (ev) { if (ev.target === mv) _mv_close(); }); var cd = document.createElement('div'); cd.style.cssText = 'width:min(90vw,340px);border-radius:22px;padding:22px 20px 18px;background:linear-gradient(180deg,rgba(18,26,38,.97),rgba(11,16,26,.98));' + 'border:1px solid rgba(125,232,138,.3);box-shadow:0 26px 60px rgba(0,0,0,.6),inset 0 1px 0 rgba(255,255,255,.06);color:#e8ecf4;text-align:center;animation:pshotRise .4s cubic-bezier(.2,.8,.2,1) both;'; var _jr = _hs_join ? _hs_join_row : null; // 참가 모드 = 방명(방장) 유지, 참가자 이름만 입력 var _jn = _jr ? _hs_esc(Array.from(String(_jr.i || '')).slice(0, 24).join('')) : ''; cd.innerHTML = '
' + (_jr ? _at_t("'" + _jn + "'에 숨기", "'" + _jn + "'にかくれる", "Hide in '" + _jn + "'") : _at_t('게임방 만들기', 'ゲームルームを作る', 'Create Your Room')) + '
' + '
' + (_jr ? _at_t('내 이름을 정하면 이 방의 숨은 멤버로 추가돼요. (이모지 OK · 140자)', '名前を決めると、この部屋のかくれメンバーに追加されるよ。(絵文字OK・140字)', 'Pick your name — you\'ll join this room as a hidden member. (emoji OK · 140)') : _at_t('방 이름을 정하면 친구들이 내 방에 들어와 나를 찾아요. (이모지 OK · 140자)', 'ルーム名を決めると、友達が君の部屋で君を探すよ。(絵文字OK・140字)', 'Name your room — friends will enter and hunt you. (emoji OK · 140)')) + '
'; var inp = document.createElement('input'); inp.type = 'text'; inp.maxLength = 140; inp.spellcheck = false; inp.autocomplete = 'off'; inp.placeholder = _jr ? _at_t('내 이름', '名前', 'Your name') : _at_t('방 이름 (예: 우리집 비밀기지 🏠)', 'ルーム名 (例: ひみつきち 🏠)', 'Room name (e.g. Secret Base 🏠)'); try { inp.value = (P.storage && P.storage.get('somodus_lb_initials')) || ''; } catch (e) {} inp.style.cssText = 'width:100%;box-sizing:border-box;padding:12px 14px;margin-bottom:10px;border-radius:14px;border:2px solid rgba(125,232,138,.45);background:rgba(0,0,0,.3);' + 'color:#b8ffc2;font:800 16px/1.3 system-ui;text-align:center;outline:none;'; cd.appendChild(inp); // + 비밀번호 추가(개설 전용, 기본 = 공개) — 토글 시 4자리 PIN 입력 노출 var pinInp = null; if (!_jr) { var pinTg = document.createElement('button'); pinTg.type = 'button'; pinTg.setAttribute('data-mgmt-ui', '1'); pinTg.innerHTML = '🔒 ' + _at_t('+ 비밀번호 추가', '+ パスワード追加', '+ Add password'); pinTg.style.cssText = 'display:block;margin:0 auto 10px;padding:8px 14px;border:1.5px dashed rgba(255,210,62,.45);border-radius:999px;background:transparent;color:#ffd23e;font:700 12.5px system-ui;cursor:pointer;touch-action:manipulation;'; var pinWrap = document.createElement('div'); pinWrap.style.cssText = 'display:none;margin-bottom:10px;'; pinInp = document.createElement('input'); pinInp.type = 'tel'; pinInp.inputMode = 'numeric'; pinInp.maxLength = 4; pinInp.autocomplete = 'off'; pinInp.placeholder = '0000'; pinInp.style.cssText = 'width:130px;padding:10px 0;border-radius:12px;border:2px solid rgba(255,210,62,.5);background:rgba(0,0,0,.3);color:#ffd23e;font:900 22px/1 ui-monospace,monospace;letter-spacing:.4em;text-align:center;outline:none;'; pinInp.addEventListener('input', function () { pinInp.value = pinInp.value.replace(/[^0-9]/g, '').slice(0, 4); }); var pinNote = document.createElement('div'); pinNote.style.cssText = 'font:600 11.5px system-ui;opacity:.65;margin-top:5px;'; pinNote.textContent = _at_t('비밀번호를 아는 친구만 입장할 수 있어요', 'パスワードを知る友達だけ入れるよ', 'Only friends with the password can enter'); pinWrap.appendChild(pinInp); pinWrap.appendChild(pinNote); pinTg.addEventListener('pointerdown', function (ev9) { ev9.preventDefault(); ev9.stopPropagation(); var on9 = pinWrap.style.display === 'none'; pinWrap.style.display = on9 ? 'block' : 'none'; pinTg.innerHTML = on9 ? '🔓 ' + _at_t('공개 방으로 하기', '公開ルームにする', 'Make it public') : '🔒 ' + _at_t('+ 비밀번호 추가', '+ パスワード追加', '+ Add password'); if (!on9 && pinInp) pinInp.value = ''; }); cd.appendChild(pinTg); cd.appendChild(pinWrap); } var go2 = _hs_cta( '
', _hs_join ? _at_t('이 방에 숨기', 'この部屋にかくれる', 'Hide Here') : _at_t('게임방 개설', 'ルーム開設', 'Open My Room'), null, 'linear-gradient(180deg,#8bf095 0%,#3fbf6c 55%,#1f8f52 100%)', '#06250f', 'rgba(47,174,98,.45)', '0s'); go2.style.width = '100%'; go2.style.justifyContent = 'center'; var msg = document.createElement('div'); msg.style.cssText = 'min-height:18px;font:700 12.5px system-ui;color:#ffcf6a;margin-top:10px;'; go2.addEventListener('pointerdown', function (ev) { ev.preventDefault(); ev.stopPropagation(); var nmv = inp.value.replace(/\s+/g, ' ').trim(); if (!nmv) { msg.textContent = _jr ? _at_t('이름을 입력하세요', '名前を入力', 'Enter your name') : _at_t('방 이름을 입력하세요', 'ルーム名を入力', 'Enter a room name'); return; } var _pinv = (pinInp && pinInp.value) ? pinInp.value : ''; if (pinInp && pinInp.parentElement && pinInp.parentElement.style.display !== 'none' && !/^[0-9]{4}$/.test(_pinv)) { msg.textContent = _at_t('비밀번호는 4자리 숫자예요', 'パスワードは数字4桁', 'Password must be 4 digits'); return; } var ini = _hs_ini_of(nmv); try { if (P.storage) P.storage.set('somodus_lb_initials', nmv); } catch (e) {} go2.style.opacity = '0.6'; go2.style.pointerEvents = 'none'; var _payload = { initials: ini, name: nmv, x: player.pos.x, y: player.pos.y, z: player.pos.z, ry: (PLh.handle.obj.rotation.y || 0), colors: (man.colors || []).slice(0, 3), pose: (man && man.get_pose) ? man.get_pose() : 'stand' }; var _skn = (P.avatar && P.avatar.get_live_skin) ? P.avatar.get_live_skin() : null; // 포즈·색칠 원본 동봉(실보고 2026-07-27: 술래에게 3색 마네킹만 보이던 결함의 근본 봉합) if (_skn) _payload.skin = _skn; if (MS && MS.name) _payload.map = MS.name; // 방의 맵(방 단위 맵 선택 — 입장자는 이 맵으로 리로드 정합) if (/^[0-9]{4}$/.test(_pinv)) _payload.pin = _pinv; if (_hs_join) { _payload.join_for = _hs_join; if (_hs_pin) _payload.pin = _hs_pin; } fetch(_hs_url(''), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(_payload) }) .then(function (r) { return r.json(); }) .then(function (res) { if (!res || !res.ok || !(_hs_join || res.id)) throw new Error((res && res.why) || 'save'); var _rid = _hs_join || res.id; A._emit('hide_saved', { id: _rid, joined: !!_hs_join, hiders: res.hiders || 1 }); _hud_room(_hs_join ? (_hs_join_row && _hs_join_row.i) : nmv); // HUD 타이틀 = 방 이름 fx.sfx('powerup'); fx.burst({ x: player.pos.x, y: player.pos.y + 1.2, z: player.pos.z }, { count: 14, color: '#6de0a4', speed: 2.2, life: 0.8 }); var link = location.origin + location.pathname + '?hide=' + _rid; cd.innerHTML = '
' + (_hs_join ? '✅ ' + _at_t('숨기 완료! 이 방에 ' + (res.hiders || 2) + '명 숨었어요', 'かくれ完了! この部屋に' + (res.hiders || 2) + '人かくれたよ', 'Hidden! ' + (res.hiders || 2) + ' now hiding here') : '✅ ' + _at_t("'" + _hs_esc(Array.from(nmv).slice(0, 20).join('')) + "' 개설 완료!" + (_payload.pin ? ' 🔒' : ''), "'" + _hs_esc(Array.from(nmv).slice(0, 20).join('')) + "' 開設完了!" + (_payload.pin ? ' 🔒' : ''), "'" + _hs_esc(Array.from(nmv).slice(0, 20).join('')) + "' is live!" + (_payload.pin ? ' 🔒' : ''))) + '
' + '
' + (_hs_join ? _at_t('술래는 이 방의 숨은 전원을 찾아야 해요. 링크로 친구를 더 불러보세요!', 'シーカーはこの部屋の全員を探すことになる。リンクで友達をもっと呼ぼう!', 'Seekers must find everyone hiding here. Invite more friends with the link!') : (_payload.pin ? _at_t('링크와 비밀번호를 함께 알려줘야 친구가 입장할 수 있어요.', 'リンクとパスワードを一緒に伝えてね。', 'Share both the link and the password so friends can enter.') : _at_t('링크를 보내면 친구가 술래로 입장해요. 이동해서 새 방을 또 만들 수도 있어요.', 'リンクを送ると友達がシーカーとして入場。移動して新しい部屋も作れる。', 'Send the link and friends join as seekers. Move & host another room anytime.'))) + '
'; var cpb = _hs_cta( '
', _at_t('친구 초대하기', '友だちを招待する', 'Invite Friends'), null, 'linear-gradient(180deg,#ffe98a 0%,#ffb52e 55%,#ff8a3d 100%)', '#3a2405', 'rgba(255,150,40,.4)', '0s'); cpb.style.width = '100%'; cpb.style.justifyContent = 'center'; cpb.style.marginBottom = '9px'; cpb.addEventListener('pointerdown', function (e5) { e5.preventDefault(); e5.stopPropagation(); var txt5 = _at_t('내 게임방에서 나를 찾아봐! 🦎 ', '僕のルームで僕を探してみて! 🦎 ', 'Come find me in my room! 🦎 '); var ok5 = function () { P.toast('✅ ' + _at_t('링크 복사됨 — 친구에게 보내세요!', 'リンクをコピー — 友達へ!', 'Link copied — send it!')); }; try { if (navigator.share) { navigator.share({ text: txt5, url: link }).catch(function () {}); return; } // 네이티브 공유 시트(모바일 정본) } catch (e6) {} if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(txt5 + link).then(ok5, function () { P.toast(link); }); else P.toast(link); }); cd.appendChild(cpb); var tb3 = document.createElement('button'); tb3.type = 'button'; tb3.setAttribute('data-mgmt-ui', '1'); tb3.textContent = '🔎 ' + _at_t('술래 모드로 플레이해보기', 'シーカーでプレイしてみる', 'Play it as a Seeker'); tb3.style.cssText = 'display:block;width:100%;padding:11px;border-radius:999px;border:2px solid rgba(150,205,255,.5);background:rgba(20,28,40,.9);color:#9fc8ff;font:700 13.5px system-ui;cursor:pointer;touch-action:manipulation;margin-bottom:4px;'; tb3.addEventListener('pointerdown', function (e6) { e6.preventDefault(); e6.stopPropagation(); try { location.href = link; } catch (e7) { try { location.search = '?hide=' + _rid; } catch (e8) {} } }); cd.appendChild(tb3); var cl3 = document.createElement('button'); cl3.type = 'button'; cl3.setAttribute('data-mgmt-ui', '1'); cl3.textContent = _at_t('닫기', '閉じる', 'Close'); cl3.style.cssText = 'display:block;margin:6px auto 0;padding:8px 16px;border:0;border-radius:999px;background:transparent;color:rgba(232,236,244,.6);font:700 13px system-ui;cursor:pointer;'; cl3.addEventListener('pointerdown', function (e9) { e9.preventDefault(); e9.stopPropagation(); _mv_close(); }); cd.appendChild(cl3); }) .catch(function () { go2.style.opacity = '1'; go2.style.pointerEvents = 'auto'; msg.textContent = '⚠️ ' + _at_t('개설 실패 — 잠시 후 다시 시도', '開設失敗 — 後でもう一度', 'Failed — try again soon'); }); }); cd.appendChild(go2); cd.appendChild(msg); mv.appendChild(cd); document.body.appendChild(mv); try { inp.focus(); } catch (e) {} } function _hs_mount_save() { ui.children[1].style.display = 'none'; // 순수 숨기 = 검출/점수 무의미(술래 없음) — 방 이름 + 시간만(사용자 확정 2026-07-25) ui.children[2].textContent = '⏱ 0:00'; setInterval(function () { // 경과 시간(개설 준비 시간 감각 — 카운트다운 아님) if (_seek_t0 == null) return; var el9 = Math.max(0, Math.round((performance.now() - _seek_t0) / 1000)); ui.children[2].textContent = '⏱ ' + Math.floor(el9 / 60) + ':' + ('0' + (el9 % 60)).slice(-2); }, 1000); _hs_anim_css(); var dock = document.createElement('div'); dock.setAttribute('data-mgmt-ui', '1'); dock.style.cssText = 'position:fixed;left:50%;transform:translateX(-50%);bottom:calc(88px + env(safe-area-inset-bottom,0px));z-index:40;' + 'display:flex;gap:10px;align-items:stretch;justify-content:center;max-width:94vw;flex-wrap:wrap;'; var pp = _hs_cta( '
', _at_t('포즈·색칠', 'ポーズ&ペイント', 'Pose & Paint'), 'POSE & PAINT', 'linear-gradient(180deg,#d9b8ff 0%,#a06bf0 55%,#7440c9 100%)', '#2a1050', 'rgba(160,107,240,.45)', '.08s'); pp.addEventListener('pointerdown', function (ev) { ev.preventDefault(); ev.stopPropagation(); if (!(P.avatar && P.avatar.paint_screen)) return; dock.style.display = 'none'; // 페인트 화면은 탭-통과 — 뒤에 독이 비쳐 보이는 겹침 차단 P.avatar.paint_screen(function () { dock.style.display = 'flex'; }); }); var sv = _hs_cta( '
', _at_t('숨기 완료!', 'かくれ完了!', 'Hide Complete!'), _at_t('내 게임방 개설', 'マイルーム開設', 'CREATE MY ROOM'), 'linear-gradient(180deg,#8bf095 0%,#3fbf6c 55%,#1f8f52 100%)', '#06250f', 'rgba(47,174,98,.45)', '.16s'); sv.addEventListener('pointerdown', function (ev) { ev.preventDefault(); ev.stopPropagation(); _hs_room_modal(); }); dock.appendChild(pp); dock.appendChild(sv); document.body.appendChild(dock); } if (_hs_pure) _hs_mount_save(); // 배경판 판정 — 플레이어와 가장 가까운 벽(3m 이내)의 색이 의태 기준 var _bd_cache = { t: -9, x: 0, z: 0, v: null }; // 맵 모드 표면 샘플 스로틀(8방 레이 × 매 프레임 = 과비용) function _backdrop() { if (MS) { // 맵 모드 — 최근접 표면의 리드백 색이 의태 기준({color} 동형 반환 = _cam_rate 무수정 소비) if (!_MAPH || !_MAPH.ready) return null; var pm = player.pos, nw9 = performance.now(); var mdx = pm.x - _bd_cache.x, mdz = pm.z - _bd_cache.z; if (nw9 - _bd_cache.t < 250 && mdx * mdx + mdz * mdz < 0.12) return _bd_cache.v; var ba = -1, bt9 = 3.0; for (var a9 = 0; a9 < 8; a9++) { var an8 = a9 * Math.PI / 4; var t8 = _MAPH.ray_t(pm.x, pm.y + 1.0, pm.z, Math.cos(an8), 0, Math.sin(an8), bt9); if (t8 >= 0 && t8 < bt9) { bt9 = t8; ba = an8; } } var v9 = null; if (ba >= 0) { var hx8 = _MAPH.surface_color_at(pm.x, pm.y + 1.0, pm.z, Math.cos(ba), 0, Math.sin(ba), 3.2); if (hx8) v9 = { color: hx8 }; } _bd_cache.t = nw9; _bd_cache.x = pm.x; _bd_cache.z = pm.z; _bd_cache.v = v9; return v9; } var pp = player.pos, best = null, bd = 3.0; for (var i = 0; i < walls.length; i++) { var b = walls[i].aabb; var ddx = Math.max(b.x0 - pp.x, 0, pp.x - b.x1), ddz = Math.max(b.z0 - pp.z, 0, pp.z - b.z1); var d = Math.sqrt(ddx * ddx + ddz * ddz); if (d < bd) { bd = d; best = walls[i]; } } return best; } // 발각 속도 순수 함수(루프와 스모크가 공유 — 단일 소유) — 색-거리·이동·근접·도발의 결정론 합성. // blocked = 벽 LOS 차폐(루프가 _ray_walls 로 계산해 전달) — 벽 뒤 은신은 구조적으로 안전. function _cam_rate(dist, facing, moving, blocked) { if (dist > 16 || facing < 0.35 || blocked) return -0.25; // 시야 밖/차폐 = 감쇠 var w = _backdrop(); var camo = 1; if (w) camo = (_cdist(man.colors[0], w.color) + _cdist(man.colors[1], w.color) + _cdist(man.colors[2], w.color)) / 3; var r = DIFF.detect * (0.15 + camo * 1.15) * (moving ? 1.7 : 1) * (0.35 + (1 - dist / 16)); if (performance.now() < taunt_until) r *= 2; return r; } _priv_api.paint_part = _paint_part; _priv_api.rate = _cam_rate; _priv_api.backdrop = _backdrop; _priv_api.skip_hide = function () { if (phase === 'hide') { _timer = 0.01; } }; // AI 헌터(아키타입 소유 워커 — 몹 FSM 불간섭: 순찰 = 결정론 웨이포인트) var hunter_h = null, hp_i = 0, hp_wait = 0, _hgy0 = 0, hunter_pos = { x: 0, z: -ROOM_D / 2 + 4 }; // 홀 안(먼 벽 근처) — 밀폐 안뜰 밖 스폰 금지. _hgy0 = 직전 보행면(벽막이 레이 높이 기준) var patrol = MS ? [] : walls.filter(function (_, i) { return i % 2 === 0; }).map(function (w, i) { return _wall_inner(w, (i % 2 ? -1.6 : 1.6), 2.4); }); if (MS) { // 맵 모드 — 벽 파생 순찰점이 없으므로 bounds 내 3×3 그리드(도달 불가점은 라우터가 우회/포기) for (var pgx = -1; pgx <= 1; pgx++) for (var pgz = -1; pgz <= 1; pgz++) patrol.push({ x: pgx * (ROOM_W / 2 - 2.2), z: pgz * (ROOM_D / 2 - 2.2) }); _MAPH.on_ready(function (h9) { // 순찰점 실내 검증 — 지붕 없는 점(세트 외곽/개구부 밖)은 제거(이동 불변식과 동일 캐논) var vp9 = patrol.filter(function (p9) { return h9.ray_t(p9.x, Y_R + 1.2, p9.z, 0, 1, 0, 80) >= 0; }); if (!vp9.length) vp9 = [{ x: 0, z: 0 }]; patrol.length = 0; Array.prototype.push.apply(patrol, vp9); }); } else patrol.push({ x: 0, z: 0 }); // ── 문 경유 라우터 ───────────────────────────────────────────────────────── // 실보고 e71191f5: 헌터가 벽을 그대로 통과했다. 근인 = 웨이포인트로 *직선* 이동(벽 검사 없음) // 인데 무대는 4×3 파티션으로 나뉘어 있어 직선은 필연적으로 벽을 지난다(collide:false 라 // 콜라이더도 없다). 파티션은 셀 경계마다 중앙에 문-구멍이 있으므로 — 그래서 인접 셀은 전부 // 연결되어 있다 — 경로를 "같은 셀이면 직행, 다른 셀이면 경계 문부터"로 제한하면 벽 관통이 // 기하학적으로 불가능해진다(별도 충돌 처리 없이 구조로 봉합). function _cell_i(x) { return clamp(Math.floor((x + ROOM_W / 2) / CW), 0, GC - 1); } function _cell_j(z) { return clamp(Math.floor((z + ROOM_D / 2) / CD), 0, GR - 1); } function _next_hop(fx, fz, to) { if (MS) { // 실험 맵 모드 — 그리드 문 라우터가 성립하지 않는다(방 격자 부재): 세그먼트 차폐 시 // 중앙 경유 우회(개활 중심 가정), 중앙 경로도 막히면 제자리(다음 웨이포인트로 자연 전환) if (_MAPH && _MAPH.ready && _MAPH.blocked(fx, Y_R + 1.0, fz, to.x, Y_R + 1.0, to.z)) { if (!_MAPH.blocked(fx, Y_R + 1.0, fz, fx * 0.5, Y_R + 1.0, fz * 0.5)) return { x: fx * 0.5, z: fz * 0.5 }; hp_i++; // 완전 차단 = 도달 불가 웨이포인트 포기(정지 교착 방지 — 추격 중엔 인덱스 무소비라 무해) return { x: fx, z: fz }; } return to; } var ai = _cell_i(fx), aj = _cell_j(fz), bi = _cell_i(to.x), bj = _cell_j(to.z); if (ai === bi && aj === bj) return to; // 같은 방 = 직행 if (ai !== bi) { // 열 경계 문(그 행 셀의 중앙) var dir = bi > ai ? 1 : -1, bx = -ROOM_W / 2 + (dir > 0 ? ai + 1 : ai) * CW; return { x: bx + dir * 0.8, z: -ROOM_D / 2 + (aj + 0.5) * CD }; // 0.8 = 경계 너머로 확정 통과 } var dj = bj > aj ? 1 : -1, bz = -ROOM_D / 2 + (dj > 0 ? aj + 1 : aj) * CD; return { x: -ROOM_W / 2 + (ai + 0.5) * CW, z: bz + dj * 0.8 }; } function _spawn_hunter() { hunter_h = world.spawn('worker', { at: { x: hunter_pos.x, z: hunter_pos.z }, collide: false }); // 무검 캐릭터(knight=칼 실보고) // 페인트건 장착 — ⚠️ load_model 은 월드팩 전용(카탈로그명 불인식 → 이전 장착 실패의 근인). // 카탈로그 스폰 후 손 위치로 재부모화(비동기 ready 시 시각 재배치). var _gunh = world.spawn('blast_blaster_c', { at: { x: hunter_pos.x, z: hunter_pos.z }, h: 0.62, _no_collider: true, _no_uni: true }); if (_gunh && _gunh.obj) { hunter_h.obj.add(_gunh.obj); _gunh.obj.position.set(0.36, 1.02, 0.3); _gunh.obj.rotation.set(0, 0.1, 0); hunter_h._gun = _gunh.obj; } if (hunter_h && hunter_h.play) hunter_h.play('walk'); hunter = hunter_h; _priv_api.hunter = hunter_h; wb.style.display = 'block'; P.toast(_at_t('👁 헌터 도착 — 숨을 죽이세요!', '👁 ハンター到着 — 息をひそめろ!', '👁 The hunter is here — hold still!')); fx.sfx('hit', { volume: 0.5 }); } var _spotted_warned = false; P.loop(function (dt) { if (ended || dt <= 0) return; dt = Math.min(dt, 0.1); if (PLh.handle !== _cur_handle) { _cur_handle = PLh.handle; _adopt_handle(_cur_handle); } // 스왑 감시(become 은 핸들을 교체한다) // 걷기 다리 스윙(마네킹 = 애니메이션 클립 없음 — 절차 스윙이 정본) var pp = player.pos; if (MS && _MAPH && _MAPH.ready) { // 맵 모드 이동 구속(정본 헬퍼) + 실내 합법-위치 폐루프 if (_map_move_deny(_last_px, _last_pz, pp)) { pp.x = _last_px; pp.z = _last_pz; } _map_interior_enforce(pp); } _speed = Math.sqrt((pp.x - _last_px) * (pp.x - _last_px) + (pp.z - _last_pz) * (pp.z - _last_pz)) / Math.max(dt, 0.001); _last_px = pp.x; _last_pz = pp.z; if (_speed > 0.4) { _walk_t += dt * clamp(_speed * 2.2, 4, 14); man.legs[0].rotation.x = Math.sin(_walk_t) * 0.62; man.legs[1].rotation.x = -Math.sin(_walk_t) * 0.62; } else { man.legs[0].rotation.x *= 0.8; man.legs[1].rotation.x *= 0.8; } if (_hs_pure) return; // 순수 숨기 모드 — 타이머·술래·발각 전부 미발동(도색/이동/저장만. 사용자 확정 UX) _timer -= dt; if (phase === 'hide') { ui.children[2].textContent = '🎨 ' + _fmt_t(Math.max(0, _timer)); if (_timer <= 0) { phase = 'seek'; _timer = seek_s; _spawn_hunter(); } return; } ui.children[2].textContent = '⏱ ' + _fmt_t(Math.max(0, _timer)); score += dt * 10; if (_timer <= 0) { _end(true); return; } // 헌터 순찰(웨이포인트 왕복) + 발각 적분 if (hunter_h) { // 추격 상태 — 실보고 e71191f5: 98% 까지 차올라도 헌터가 순찰을 계속해 "발견이 아무 일도 // 일으키지 않았다". 발각이 진행되면 순찰을 버리고 플레이어를 향해 다가온다(가독성 = 위협). var _chasing = detect >= 0.55; var goal = _chasing ? { x: pp.x, z: pp.z } : patrol[hp_i % patrol.length]; var hop = _next_hop(hunter_pos.x, hunter_pos.z, goal); // 벽 관통 금지: 문 경유 var hdx = hop.x - hunter_pos.x, hdz = hop.z - hunter_pos.z; var hd = Math.sqrt(hdx * hdx + hdz * hdz); var gdx = goal.x - hunter_pos.x, gdz = goal.z - hunter_pos.z; var gd = Math.sqrt(gdx * gdx + gdz * gdz); if (!_chasing && hp_wait > 0) { hp_wait -= dt; } else if (!_chasing && gd < 0.6) { hp_i++; hp_wait = 1.1; } else if (hd > 0.001) { var _hspd = _chasing ? 3.2 : 2.3; // 추격은 빠르게(다가옴이 체감되어야 한다) var _hnx = hunter_pos.x + hdx / hd * _hspd * dt; var _hnz = hunter_pos.z + hdz / hd * _hspd * dt; // 맵 모드 — 술래도 플레이어와 동일한 세그먼트 벽막이(가슴 높이): 카운터/가구/벽 관통 실보고 소거 if (!(MS && _MAPH && _MAPH.ready && _MAPH.blocked(hunter_pos.x, _hgy0 + 1.0, hunter_pos.z, _hnx, _hgy0 + 1.0, _hnz))) { hunter_pos.x = _hnx; hunter_pos.z = _hnz; } else hp_i++; // 막힘 = 현 웨이포인트 포기(교착 방지 — 추격 중 인덱스는 무소비) hunter_h.obj.rotation.y = Math.atan2(hdx, hdz); } if (MS && _MAPH && _MAPH.ready) { // AI 헌터도 세트 footprint 밖 이탈 금지(플레이어와 동일 볼륨) var _hb = _MAPH.bounds; hunter_pos.x = clamp(hunter_pos.x, _hb.x0 + 0.5, _hb.x1 - 0.5); hunter_pos.z = clamp(hunter_pos.z, _hb.z0 + 0.5, _hb.z1 - 0.5); } var _hgy = Y_R; // 슬랩 바닥(Y_R) — height_at(지형)이면 바닥 아래로 가라앉아 안 보임 if (MS && _MAPH && _MAPH.ready) { // 맵 모드 — 1층 보행면 probe(계단·단차 추종) var _hg9 = world._priv.ground_ext_probe(hunter_pos.x, hunter_pos.z, Y_R + 1.2); if (_hg9 != null && _hg9 > -1e8) _hgy = _hg9; } hunter_h.obj.position.set(hunter_pos.x, _hgy, hunter_pos.z); _hgy0 = _hgy; var pdx = pp.x - hunter_pos.x, pdz = pp.z - hunter_pos.z; var pd = Math.sqrt(pdx * pdx + pdz * pdz); var hh = hunter_h.obj.rotation.y; var facing = pd > 0.001 ? (pdx * Math.sin(hh) + pdz * Math.cos(hh)) / pd : 1; var hy = _hgy + 1.4; // 눈높이 = 보행면 기준(구현: height_at(지형)+1.4 는 승상 무대에서 벽 AABB // y-구간(Y_R-0.4~) 아래를 지나 LOS 차폐가 무발동 — 벽 뒤 은신이 성립하지 않던 잠복 결함 봉합) var los_blocked = pd > 0.001 && _ray_walls(hunter_pos.x, hy, hunter_pos.z, pdx / pd, 0, pdz / pd, pd) >= 0; detect = clamp(detect + _cam_rate(pd, facing, _speed > 0.6, los_blocked) * dt, 0, 1); if (detect >= 0.6 && !_spotted_warned) { _spotted_warned = true; P.toast(_at_t('👁 들킬 것 같아요!', '👁 見つかりそう!', '👁 You are about to be spotted!')); A._emit('spotted', { detect: detect }); } if (detect < 0.45) _spotted_warned = false; if (detect >= 1 && (facing < 0.55 || los_blocked)) detect = 0.985; // 시선 하드 게이트(실보고 // 2026-07-24 "엉뚱한 데를 보는데 끝남") — 발사는 실제로 바라보며 시야가 열린 순간에만. if (detect >= 1) { // 포획 = 페인트 총(Meccha 문법 — 실보고 2026-07-24): 헌터가 페인트탄을 // 쏘고, 명중한 몸이 현란한 다색 페인트로 뒤덮인다 — '맞아서 죽었다'가 몸에 남는 시각 판정. hunter_h.obj.rotation.y = Math.atan2(pdx, pdz); try { hunter_h.play('attack'); } catch (e) {} var _pb = new THREE_.Mesh(new THREE_.SphereGeometry(0.16, 10, 8), P.make.glow('#ff4ad2', 0.9)); var _hp0 = hunter_h.obj.position; _pb.position.set(_hp0.x + Math.sin(hunter_h.obj.rotation.y) * 0.55, _hp0.y + 1.1, _hp0.z + Math.cos(hunter_h.obj.rotation.y) * 0.55); scene.add(_pb); // 총구 발사 fx.tween(_pb.position, { x: pp.x, y: pp.y + 1.1, z: pp.z }, { dur: 0.16, ease: 'linear' }); setTimeout(function () { try { scene.remove(_pb); } catch (e) {} if (P.avatar && P.avatar.splatter) P.avatar.splatter(64); // 전신이 확실히 뒤덮이는 다색 스플래터 ['#e23b3b', '#ffd23e', '#4ade80', '#4f7cff'].forEach(function (bc, bi) { fx.burst({ x: pp.x, y: pp.y + 0.9 + bi * 0.25, z: pp.z }, { count: 12, color: bc, speed: 4.5, life: 0.9 }); }); fx.sfx('hit'); fx.shake(0.55); fx.hitstop(0.14); }, 170); setTimeout(function () { _end(false); }, 620); // 스플래터가 읽힌 뒤 종료 return; } } _hud(); }); } _hud(); // 역할 확정 시점의 초기 HUD(분기 자원(man 등) 준비 후 — 선택 지연 구조의 렌더 시작점) _camo_side_ui(r); // 우상단 사이드 스택(처음으로 + 술래: 교체/초대) } // ── _start_role 끝 ── // ── HUD(발견/의태 상태 칩) — man/PLh 는 impl-스코프 승격분(role 확정 전 호출은 no-op 가드) ── function _hud() { if (!_role_started) return; // 모드 선택 대기 중 — 분기 자원 미준비 if (role === 'hunter') { ui.children[1].textContent = '🔎 ' + found + ' / ' + n_hiders; } else { ui.children[1].innerHTML = '🎨
●
●
●
' + ' 👁 ' + Math.round(detect * 100) + '% · ' + Math.round(score) + _at_t('점', '点', 'pt'); } } _hud(); // ── 종결(공유) ── // 종료 축하 팡파레(사용자 확정 2026-07-25: 공유 카드 국면 = BGM 제거, 파티 팡파레 단발만) — // SFX 카탈로그 sounds_fanfare 계열(콘텐츠-해시 불변 키, _SG_URLS 와 동일 문법). impl 스코프 // 필수: _end 는 _start_role 바깥이라 역할 스코프 정의는 참조 불가(호이스팅-계약 파괴 부류). var _FAN_URLS = [ world._priv.vendor_ext_base() + 'sfx/35057df1e1058ca6638b4bdf1f1725d9ec432a7d4e0b49fa85b37ce0f3819f4c.mp3', world._priv.vendor_ext_base() + 'sfx/ece54cfc868daeaf27aa1558097da625977f1efee06cef6904ae275a34a54952.mp3', ]; for (var _fnp = 0; _fnp < _FAN_URLS.length; _fnp++) P.sfx._bank_load(_FAN_URLS[_fnp]); function _fanfare() { if (P._muted) return; var bf = P.sfx._bank_get(_FAN_URLS[Math.floor(prng() * _FAN_URLS.length)]); if (!(bf && P.sfx._bank_play(bf, 0.85))) fx.sfx('powerup'); // 미디코드 = 합성 폴백(무음 회귀 금지) } function _end(won) { if (ended) return; ended = true; var msg; if (role === 'hunter') { msg = won ? String(c.complete_text || _at_t('전원 발견! 🦎', '全員発見! 🦎', 'All found! 🦎')).slice(0, 60) : _at_t('시간 초과 — ' + (n_hiders - found) + '명이 숨어 있었어요', '時間切れ — ' + (n_hiders - found) + '匹が隠れていた', 'Time up — ' + (n_hiders - found) + ' stayed hidden'); if (!won) { // 미발견 위장체 공개(정직한 리빌) for (var i = 0; i < chams.length; i++) { if (chams[i].found) continue; world.marker(chams[i].man.g, { label: _at_t('여기 있었어요', 'ここにいた', 'Was here') }); fx.burst({ x: chams[i].x, y: chams[i].y + 1.4, z: chams[i].z }, { count: 10, color: '#d24aff', speed: 1.8, life: 1 }); } } } else { msg = won ? String(c.complete_text || _at_t('끝까지 버텼다! 🦎 ' + Math.round(score) + '점', '生き延びた! 🦎 ' + Math.round(score) + '点', 'You survived! 🦎 ' + Math.round(score) + 'pt')).slice(0, 60) : _at_t('발각! 헌터에게 잡혔어요', '発見された! ハンターに捕まった', 'Spotted! The hunter got you'); if (!won) { fx.screen_flash('#d24aff'); fx.sfx('shoot', { volume: 0.6 }); } } P.music.stop(); // 종료 국면 = BGM 무조건 소거(공유 카드에 음악이 깔리던 실보고 — 사용자 확정 2026-07-25) if (won) { _fanfare(); fx.screen_flash(pal.key); } P.toast(msg); P.game_over(msg, won); // 상시 종료 배너 + 조작/HUD 비활성화(타이머가 멈춘 채 남던 실보고 봉합) A._emit('complete', { won: won, role: role, found: found, total: role === 'hunter' ? n_hiders : 0, score: Math.round(score) }); // 리더보드 제출 — 두 역할 모두(실보고 e71191f5: 하이더는 점수가 있는데도 리더보드가 없었다). // 게임 key 는 역할이 고정(c.role)이라 hunter/hider 지표가 한 보드에 섞이지 않는다. P.leaderboard.submit(role === 'hunter' ? found : Math.round(score)); // 게임방 참여 기록(2026-07-25) — 방 지목 헌트만(풀-랜덤/AI 는 방 개념 없음). 완주 1회: // plays 는 승패 무관 +1(참여자수), 클리어 초는 승리 시에만 방별 기록(top10)에 등재. if (role === 'hunter' && _hs_key && _hs_ch) { var _pn = ''; try { _pn = (P.storage && P.storage.get('somodus_lb_initials')) || ''; } catch (e9) {} var _pi = _hs_ini_of(_pn); var _ps = (won && _seek_t0 != null) ? Math.max(1, Math.round((performance.now() - _seek_t0) / 1000)) : null; fetch(_hs_url(''), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ play_for: _hs_ch, initials: _pi, name: _pn, s: _ps, pin: _hs_pin || undefined }) }) .then(function (r) { return r.json(); }) .then(function (pr) { if (pr && pr.ok && won && pr.best && pr.best.s === _ps && pr.best.i === _pi) P.toast('🏆 ' + _at_t('이 방 신기록!', 'この部屋の新記録!', 'New room record!')); }).catch(function () {}); } // (hideshare 저장 UI 는 순수 숨기 모드(_hs_pure)의 상시 버튼이 소유 — 사용자 확정 2026-07-24: // "생존 시에만 저장" 게이트 폐기. 이 종결부는 AI 술래 게임(비-hideshare) 전용 경로가 됐다.) setTimeout(function () { P.three.capture().then(function (shot) { var _elapsed = _seek_t0 != null ? Math.max(0, Math.round((performance.now() - _seek_t0) / 1000)) : null; var _mmss = _elapsed == null ? null : (Math.floor(_elapsed / 60) + ':' + ('0' + (_elapsed % 60)).slice(-2)); P.share_shot(shot, { title: String(c.title || _at_t('위장 숨바꼭질', 'カモフラかくれんぼ', 'Camo Hide & Seek')).slice(0, 40), // 술래 카드 = 발견 수 + 걸린 시간 각인(사용자 확정 2026-07-25 — 스크린샷 자체에 기록이 남는다) lines: [role === 'hunter' ? '🔎 ' + found + ' / ' + n_hiders + (_mmss ? ' · ⏱ ' + _mmss : '') : '🦎 ' + Math.round(score) + 'pt'], share_text: String(c.share_text || _at_t('위장 숨바꼭질에서 한 판! 🦎', 'カモフラかくれんぼで一勝負! 🦎', 'A round of camo hide & seek! 🦎')).slice(0, 120), actions: [ // 최상단 = 순위 보기(사용자 확정 2026-07-25) → 팀 재시작 2종(글로시 스윕은 runtime 렌더러 소유) { label: _at_t('순위 보기', 'ランキングを見る', 'View Rankings'), sub: 'RANKING', icon: '
', style: 'background:linear-gradient(180deg,#a8e4ff 0%,#5cb2e8 55%,#2e6fb0 100%);color:#0a2540;box-shadow:0 12px 30px rgba(74,159,216,.42),inset 0 2px 0 rgba(255,255,255,.55),inset 0 -3px 0 rgba(0,0,0,.2);', on: function () { if (P.leaderboard.active()) P.leaderboard.show(); else P.toast('🏆 ' + _at_t('아직 순위가 없어요 — 첫 기록의 주인공이 되어보세요!', 'まだランキングがありません — 最初の記録を作ろう!', 'No rankings yet — set the first record!')); } }, { label: _at_t('숨는 팀으로 시작', 'ハイダーチームでスタート', 'Start as Hider Team'), sub: 'HIDER TEAM', icon: '
', style: 'background:linear-gradient(180deg,#8bf095 0%,#3fbf6c 55%,#1f8f52 100%);color:#06250f;box-shadow:0 12px 30px rgba(47,174,98,.45),inset 0 2px 0 rgba(255,255,255,.5),inset 0 -3px 0 rgba(0,0,0,.22);', on: function () { location.href = _replay_url('hider'); } }, { label: _at_t('술래로 시작', 'シーカーでスタート', 'Start as Seeker'), sub: 'SEEKER TEAM', icon: '
', style: 'background:linear-gradient(180deg,#ffe98a 0%,#ffb52e 55%,#ff8a3d 100%);color:#3a2405;box-shadow:0 12px 30px rgba(255,150,40,.42),inset 0 2px 0 rgba(255,255,255,.55),inset 0 -3px 0 rgba(0,0,0,.2);', on: function () { location.href = _replay_url('hunter'); } }, ], }); }); }, 900); } P.mount_share(function () { return String(c.share_text || _at_t('위장 숨바꼭질 🦎', 'カモフラかくれんぼ 🦎', 'Camo hide & seek 🦎')).slice(0, 120); }, String(c.share_label || _at_t('친구 초대', '友だち招待', 'Invite')).slice(0, 12)); // ── 부팅 디스패처 — 도전장 = 즉시 헌터 · hideshare 활성 = 모드 선택 카드 · 그 외 = config 역할 동기 시작 ── // ── 풀-디자인 오프닝(사용자 확정 2026-07-25) — 실사 키아트 히어로 + 팀 선택(원작 Meccha // Chameleon 팀 명칭: Hider Team/Seeker Team) → 팀별 브리핑 카드(Steam 서술 문법: 동사 선행 // 3스텝, 혼동 소거) → 시작. 첫 화면 = 히어로+버튼만(설명 0 — 사용자 확정). // 키아트 = 인게임 실촬영(bar 무대 헤드리스 캡처, vendor-ext 콘텐츠-해시) — c.opening_hero 로 대체 가능. function _mode_select(auto_role) { // auto_role = 재시작 직행(팀 버튼 생략, 히어로 위 방 선택 패널) var _HERO = String(c.opening_hero || (world._priv.vendor_ext_base() + 'c49864f020f2d95364728ddd506f3dcb2b12b35bb1d73a7ff45cf58416400eb6.jpg')); var ov = document.createElement('div'); ov.setAttribute('data-mgmt-ui', '1'); ov.style.cssText = 'position:fixed;inset:0;z-index:46;overflow:hidden;background:#070a12;font-family:system-ui,sans-serif;'; var st = document.createElement('style'); st.textContent = '@keyframes camoKen{0%{transform:scale(1) translateY(0)}100%{transform:scale(1.09) translateY(-1.6%)}}' + '@keyframes camoRise{0%{opacity:0;transform:translateY(26px)}100%{opacity:1;transform:translateY(0)}}' + '@keyframes camoGlow{0%,100%{text-shadow:0 0 22px rgba(125,232,138,.35),0 4px 18px rgba(0,0,0,.85)}50%{text-shadow:0 0 34px rgba(125,232,138,.6),0 4px 18px rgba(0,0,0,.85)}}' + '@keyframes pshotSweep{0%{transform:translateX(-140%) skewX(-18deg)}55%,100%{transform:translateX(340%) skewX(-18deg)}}' + '@keyframes camoTitle{0%{background-position:0% 50%}100%{background-position:300% 50%}}' + '@keyframes camoFloat{0%,100%{transform:translateY(0)}50%{transform:translateY(-6px)}}' + '@keyframes camoTGlow{0%,100%{filter:drop-shadow(0 6px 22px rgba(57,208,162,.35)) drop-shadow(0 2px 8px rgba(0,0,0,.8))}50%{filter:drop-shadow(0 6px 34px rgba(124,240,255,.55)) drop-shadow(0 2px 8px rgba(0,0,0,.8))}}' + '.camo-press{transition:transform .12s cubic-bezier(.2,.8,.2,1)}.camo-press:active{transform:scale(.96)}'; ov.appendChild(st); var hero = document.createElement('div'); hero.style.cssText = 'position:absolute;inset:-3%;background:url("' + _HERO + '") center 38%/cover no-repeat;animation:camoKen 22s ease-in-out infinite alternate;'; ov.appendChild(hero); var scrim = document.createElement('div'); scrim.style.cssText = 'position:absolute;inset:0;background:' + 'radial-gradient(120% 90% at 50% 30%,rgba(6,9,16,0) 40%,rgba(6,9,16,.55) 100%),' + 'linear-gradient(180deg,rgba(6,9,16,.5) 0%,rgba(6,9,16,0) 26%,rgba(6,9,16,0) 46%,rgba(6,9,16,.88) 78%,rgba(5,8,14,.97) 100%);'; ov.appendChild(scrim); var col = document.createElement('div'); col.style.cssText = 'position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;gap:12px;padding:0 20px calc(30px + env(safe-area-inset-bottom));'; ov.appendChild(col); // 타이틀 로고타이프(사용자 확정 2026-07-27: 게임명 = 영어 전용 표기, 멀티 애니메이션) — // 흐르는 그라디언트(camoTitle) + 글로우 맥동(camoTGlow) + 플로트(camoFloat) + 등장(camoRise) var ttw = document.createElement('div'); ttw.style.cssText = 'animation:camoRise .8s cubic-bezier(.2,.8,.2,1) both;margin-bottom:8px;'; var tt = document.createElement('div'); tt.textContent = String(c.title || 'Camo Clash').slice(0, 24); tt.style.cssText = 'font-weight:900;font-size:clamp(40px,11.5vw,66px);letter-spacing:-.03em;line-height:1.05;text-align:center;' + 'font-style:italic;' + 'background:linear-gradient(100deg,#b6ff7a 0%,#39d0a2 25%,#7cf0ff 50%,#ffd76a 75%,#b6ff7a 100%);background-size:300% 100%;' + '-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;' + 'animation:camoTitle 5s linear infinite,camoTGlow 3.2s ease-in-out infinite,camoFloat 4.5s ease-in-out infinite;'; ttw.appendChild(tt); col.appendChild(ttw); var teamRow = document.createElement('div'); // 좌우 카드 배치(사용자 확정 2026-07-27) teamRow.style.cssText = 'display:flex;gap:12px;width:min(92vw,400px);align-items:stretch;'; col.appendChild(teamRow); function mk(r, en, sub, grad, glow, ink, svg, delay) { var b = document.createElement('button'); b.type = 'button'; b.setAttribute('data-mgmt-ui', '1'); b.className = 'camo-press'; b.style.cssText = 'position:relative;overflow:hidden;flex:1;display:flex;flex-direction:column;align-items:center;gap:8px;padding:18px 10px 15px;' + 'border:0;border-radius:22px;cursor:pointer;text-align:center;touch-action:manipulation;-webkit-tap-highlight-color:transparent;' + 'background:' + grad + ';color:' + ink + ';box-shadow:0 14px 34px ' + glow + ',inset 0 2px 0 rgba(255,255,255,.5),inset 0 -3px 0 rgba(0,0,0,.22);' + 'animation:camoRise .7s cubic-bezier(.2,.8,.2,1) ' + delay + ' both;'; b.innerHTML = '
' + '
' + '
' + svg + '
' + '
' + en + '
' + (sub ? '
' + sub + '
' : ''); b.addEventListener('pointerdown', function (ev) { ev.preventDefault(); ev.stopPropagation(); _briefing(r); }); teamRow.appendChild(b); } // 원작 팀 명칭 준거(사용자 확정): EN 팀명 대문자 + 로컬라이즈 병기(EN 로케일은 중복 생략) mk('hider', 'HIDER TEAM', _at_t('숨는 팀', 'ハイダーチーム', ''), 'linear-gradient(180deg,#8bf095 0%,#3fbf6c 55%,#1f8f52 100%)', 'rgba(47,174,98,.45)', '#06250f', '
', '.12s'); mk('hunter', 'SEEKER TEAM', _at_t('술래 팀', 'シーカーチーム', ''), 'linear-gradient(180deg,#ffe98a 0%,#ffb52e 55%,#ff8a3d 100%)', 'rgba(255,150,40,.42)', '#3a2405', '
', '.22s'); // [친구 초대하기] — 바이럴 관절(사용자 확정 2026-07-27): 팀 카드 아래 풀폭 글로시(바이올렛) var inv = document.createElement('button'); inv.type = 'button'; inv.setAttribute('data-mgmt-ui', '1'); inv.className = 'camo-press'; inv.style.cssText = 'position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;gap:9px;width:min(92vw,400px);padding:13px 16px;' + 'border:0;border-radius:18px;cursor:pointer;touch-action:manipulation;-webkit-tap-highlight-color:transparent;' + 'background:linear-gradient(180deg,#d9b8ff 0%,#a06bf0 55%,#7440c9 100%);color:#2a1050;' + 'box-shadow:0 12px 28px rgba(160,107,240,.42),inset 0 2px 0 rgba(255,255,255,.5),inset 0 -3px 0 rgba(0,0,0,.22);' + 'animation:camoRise .7s cubic-bezier(.2,.8,.2,1) .32s both;'; inv.innerHTML = '
' + '
' + '
' + '
' + _at_t('친구 초대하기', '友だちを招待する', 'Invite Friends') + '
'; inv.addEventListener('pointerdown', function (ev) { ev.preventDefault(); ev.stopPropagation(); P.share(String(c.share_text || _at_t('같이 숨바꼭질하자! 🦎', 'いっしょにかくれんぼしよう! 🦎', 'Come play hide & seek with me! 🦎')).slice(0, 120)); }); col.appendChild(inv); // 최하단 획득 CTA(사용자 확정 2026-07-27) — "코딩 없이 내가 원하는 게임 만들기"(3언어, 틸 글로시) var mkcta = document.createElement('button'); mkcta.type = 'button'; mkcta.setAttribute('data-mgmt-ui', '1'); mkcta.className = 'camo-press'; mkcta.style.cssText = 'position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;gap:8px;width:min(92vw,400px);padding:12px 14px;' + 'border:0;border-radius:16px;cursor:pointer;touch-action:manipulation;-webkit-tap-highlight-color:transparent;word-break:keep-all;' + 'background:linear-gradient(180deg,#8fe8ff 0%,#3fb6d8 55%,#1f7ea6 100%);color:#062a38;' + 'box-shadow:0 12px 28px rgba(63,182,216,.4),inset 0 2px 0 rgba(255,255,255,.5),inset 0 -3px 0 rgba(0,0,0,.2);' + 'animation:camoRise .7s cubic-bezier(.2,.8,.2,1) .42s both;'; mkcta.innerHTML = '
' + '
' + '
✨
' + '
' + _at_t('코딩 없이, 내가 원하는 게임 만들기', 'コーディングなしで、思いどおりのゲームを作る', 'Build the game you want — no coding') + '
' + '
→
'; mkcta.addEventListener('pointerdown', function (ev) { ev.preventDefault(); ev.stopPropagation(); try { window.open('https://somodus.com/?utm_source=playable&utm_medium=opening&utm_campaign=ingame_cta&utm_content=camo_opening', '_blank', 'noopener'); } catch (e9) {} }); col.appendChild(mkcta); // ── 팀 브리핑(2화면) — Steam 캡슐 서술 문법: 상황 1줄 없이 곧장 "무엇을 하는가" 3스텝, 동사 선행 ── function _briefing(r) { var hider = r === 'hider'; col.style.display = 'none'; var bp = document.createElement('div'); bp.style.cssText = 'position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;padding:0 18px calc(26px + env(safe-area-inset-bottom));'; var card = document.createElement('div'); card.style.cssText = 'width:min(92vw,380px);border-radius:24px;padding:20px 20px 18px;background:linear-gradient(180deg,rgba(16,22,34,.94),rgba(10,14,24,.96));' + 'border:1px solid ' + (hider ? 'rgba(125,232,138,.3)' : 'rgba(255,181,46,.3)') + ';box-shadow:0 26px 60px rgba(0,0,0,.6),inset 0 1px 0 rgba(255,255,255,.06);color:#e8ecf4;' + 'animation:camoRise .5s cubic-bezier(.2,.8,.2,1) both;'; var chip = document.createElement('div'); chip.style.cssText = 'display:inline-flex;align-items:center;gap:8px;padding:7px 14px;border-radius:999px;margin-bottom:14px;font-weight:900;font-size:14px;letter-spacing:.05em;' + (hider ? 'background:linear-gradient(180deg,#8bf095,#2fae62);color:#06250f;' : 'background:linear-gradient(180deg,#ffe98a,#ff9a3d);color:#3a2405;'); chip.textContent = (hider ? 'HIDER TEAM' : 'SEEKER TEAM') + (_at_t(' · ' + (hider ? '숨는 팀' : '술래 팀'), ' · ' + (hider ? 'ハイダーチーム' : 'シーカーチーム'), '')); card.appendChild(chip); var steps = hider ? [_at_t('배경과 구분이 안 될 때까지 몸 색을 칠하세요.', '背景と見分けがつかなくなるまで体を塗ろう。', 'Paint your body until it blends into the background.'), _at_t('들키지 않을 자리를 골라 그 자리에 숨으세요.', '見つからない場所を選んでそこに隠れよう。', 'Pick a spot where no one will look, and hide there.'), _at_t('저장을 누르면 친구에게 보낼 도전장이 만들어져요.', '保存すると友達への挑戦状ができあがる。', 'Tap Save to create a challenge link for your friends.')] : [_at_t('이 방 어딘가, 배경과 똑같이 칠한 카멜레온들이 숨어 있어요.', 'この部屋のどこかに、背景そっくりに塗ったカメレオンが隠れている。', 'Somewhere in this room, chameleons are painted to match the walls.'), _at_t('수상한 곳은 페인트건으로 쏘아 확인하세요 — 눈이 단서예요.', '怪しい場所はペイントガンで撃って確かめよう — 目が手がかり。', 'Shoot suspicious spots with your paint gun — the eyes give them away.'), _at_t('시간이 끝나기 전에 전원을 찾아내면 승리합니다.', '時間切れになる前に全員見つければ勝ち。', 'Find them all before time runs out to win.')]; for (var si = 0; si < steps.length; si++) { var rowd = document.createElement('div'); rowd.style.cssText = 'display:flex;gap:12px;align-items:flex-start;margin-bottom:11px;'; rowd.innerHTML = '
' + (si + 1) + '
' + '
' + steps[si] + '
'; card.appendChild(rowd); } var go = document.createElement('button'); go.type = 'button'; go.setAttribute('data-mgmt-ui', '1'); go.textContent = _at_t('게임 시작', 'ゲームスタート', 'Start Game'); go.style.cssText = 'display:block;width:100%;margin-top:6px;padding:14px;border:0;border-radius:999px;cursor:pointer;font:900 16.5px system-ui;letter-spacing:.03em;' + (hider ? 'background:linear-gradient(180deg,#8bf095 0%,#3fbf6c 55%,#1f8f52 100%);color:#06250f;box-shadow:0 10px 26px rgba(47,174,98,.4)' : 'background:linear-gradient(180deg,#ffe98a 0%,#ffb52e 55%,#ff8a3d 100%);color:#3a2405;box-shadow:0 10px 26px rgba(255,150,40,.4)') + ',inset 0 2px 0 rgba(255,255,255,.55),inset 0 -3px 0 rgba(0,0,0,.2);touch-action:manipulation;-webkit-tap-highlight-color:transparent;'; go.addEventListener('pointerdown', function (ev) { ev.preventDefault(); ev.stopPropagation(); if (_hs_key && !_hs_ch) { try { bp.remove(); } catch (e) {} _room_select(hider ? 'hider' : 'hunter'); return; } // 양 팀 모두 게임방 선택 경유(하이더 = 새 방/기존 방 참가) try { ov.remove(); } catch (e) {} if (hider) _hs_pure = true; // 선택형 숨기 = 순수 은신 제작(술래 없음) _start_role(r); }); card.appendChild(go); var back = document.createElement('button'); back.type = 'button'; back.setAttribute('data-mgmt-ui', '1'); back.textContent = _at_t('‹ 팀 다시 고르기', '‹ チームを選び直す', '‹ Choose another team'); back.style.cssText = 'display:block;margin:10px auto 0;padding:8px 14px;border:0;border-radius:999px;background:transparent;color:rgba(232,236,244,.6);font:700 13px system-ui;cursor:pointer;touch-action:manipulation;-webkit-tap-highlight-color:transparent;'; back.addEventListener('pointerdown', function (ev) { ev.preventDefault(); ev.stopPropagation(); try { bp.remove(); } catch (e) {} col.style.display = 'flex'; }); card.appendChild(back); bp.appendChild(card); ov.appendChild(bp); } // ── 게임방 선택(술래) — 개설된 방 목록(방장 닉네임·참여자수·최고기록) + AI 게임방 ── function _map_select() { // 새 방의 맵 고르기(2026-07-27) — 카드 그리드, 선택 = 그 맵으로 새 방 직행 var mp = document.createElement('div'); mp.style.cssText = 'position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;padding:0 18px calc(26px + env(safe-area-inset-bottom));'; var card = document.createElement('div'); card.style.cssText = 'position:relative;width:min(92vw,380px);max-height:72vh;overflow-y:auto;border-radius:24px;padding:18px 16px 14px;' + 'background:linear-gradient(180deg,rgba(16,22,34,.95),rgba(10,14,24,.97));border:1px solid rgba(125,232,138,.3);' + 'box-shadow:0 26px 60px rgba(0,0,0,.6),inset 0 1px 0 rgba(255,255,255,.06);color:#e8ecf4;animation:camoRise .5s cubic-bezier(.2,.8,.2,1) both;'; card.innerHTML = '
' + _at_t('맵 선택', 'マップを選ぶ', 'Pick a Map') + '
' + '
' + _at_t('어떤 무대에 숨을까요?', 'どのステージにかくれる?', 'Where will you hide?') + '
'; var mb = document.createElement('button'); mb.type = 'button'; mb.setAttribute('data-mgmt-ui', '1'); mb.innerHTML = '
'; mb.style.cssText = 'position:absolute;left:10px;top:10px;width:34px;height:34px;border-radius:12px;border:1px solid rgba(255,255,255,.18);background:rgba(255,255,255,.07);color:#dbe4f2;display:flex;align-items:center;justify-content:center;cursor:pointer;touch-action:manipulation;z-index:2;'; mb.addEventListener('pointerdown', function (ev) { ev.preventDefault(); ev.stopPropagation(); try { mp.remove(); } catch (e) {} }); card.appendChild(mb); var grid = document.createElement('div'); grid.style.cssText = 'display:grid;grid-template-columns:1fr 1fr;gap:10px;'; _MAP_ROSTER.forEach(function (r0, i0) { var cur = MS && MS.name === r0.name; var b0 = document.createElement('button'); b0.type = 'button'; b0.setAttribute('data-mgmt-ui', '1'); b0.className = 'camo-press'; b0.style.cssText = 'position:relative;overflow:hidden;display:flex;flex-direction:column;align-items:center;gap:4px;padding:15px 8px 12px;' + 'border:0;border-radius:16px;cursor:pointer;touch-action:manipulation;-webkit-tap-highlight-color:transparent;' + (cur ? 'background:linear-gradient(180deg,#8bf095 0%,#3fbf6c 55%,#1f8f52 100%);color:#06250f;box-shadow:0 10px 24px rgba(47,174,98,.4),inset 0 2px 0 rgba(255,255,255,.5),inset 0 -3px 0 rgba(0,0,0,.2);' : 'background:rgba(255,255,255,.06);border:1px solid rgba(150,205,255,.25);color:#e8ecf4;box-shadow:inset 0 1px 0 rgba(255,255,255,.07);') + 'animation:camoRise .5s cubic-bezier(.2,.8,.2,1) ' + (0.05 + i0 * 0.05) + 's both;'; b0.innerHTML = (cur ? '
' : '') + '
🗺
' + '
' + _hs_esc(_map_label(r0.name)) + '
' + '
' + Math.round(num(r0.w, 0)) + '×' + Math.round(num(r0.d, 0)) + 'm' + (cur ? ' · ' + _at_t('현재', '現在', 'now') : '') + '
'; b0.addEventListener('pointerdown', function (ev) { ev.preventDefault(); ev.stopPropagation(); if (cur) { _hs_join = null; _hs_pure = true; try { ov.remove(); } catch (e) {} _start_role('hider'); } else location.href = location.pathname + '?camo_map=' + r0.name + '&camo_new=1'; // 스테이지 = 부팅 구축 — 리로드 문법 }); grid.appendChild(b0); }); card.appendChild(grid); mp.appendChild(card); ov.appendChild(mp); } function _map_label(mn9) { // 표시 전용 — 인덱스 6언어 라벨(로스터 통과분) 우선, 부재 시 토큰 프리티파이 var _le9 = _MAP_ROSTER.filter(function (r0) { return r0.name === mn9; })[0]; var _lb9 = _le9 && _le9.label; if (_lb9 && typeof _lb9 === 'object') { var _v9 = _lb9[_at_lang6] || _lb9.en; // 브라우저 언어 → 영어 기본(셸 언어 정책과 단일) if (_v9) return String(_v9); } return String(mn9 || '').replace(/^map_/, '').replace(/_v\d+$/, '').replace(/_/g, ' ').toUpperCase(); } function _room_select(forRole) { var asHider = forRole === 'hider'; function _rmss(s9) { s9 = Math.max(0, Math.round(s9)); return Math.floor(s9 / 60) + ':' + ('0' + (s9 % 60)).slice(-2); } var rp = document.createElement('div'); rp.style.cssText = 'position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;padding:0 18px calc(24px + env(safe-area-inset-bottom));'; var card = document.createElement('div'); card.style.cssText = 'position:relative;width:min(92vw,380px);max-height:72vh;overflow-y:auto;border-radius:24px;padding:18px 16px 14px 16px;' + 'background:linear-gradient(180deg,rgba(16,22,34,.95),rgba(10,14,24,.97));border:1px solid rgba(255,181,46,.3);' + 'box-shadow:0 26px 60px rgba(0,0,0,.6),inset 0 1px 0 rgba(255,255,255,.06);color:#e8ecf4;animation:camoRise .5s cubic-bezier(.2,.8,.2,1) both;'; var backTL = document.createElement('button'); // 창 좌상단 뒤로가기(사용자 확정 2026-07-25) backTL.type = 'button'; backTL.setAttribute('data-mgmt-ui', '1'); backTL.innerHTML = '
'; backTL.style.cssText = 'position:absolute;left:10px;top:10px;width:34px;height:34px;border-radius:12px;border:1px solid rgba(255,255,255,.18);background:rgba(255,255,255,.07);color:#dbe4f2;display:flex;align-items:center;justify-content:center;cursor:pointer;touch-action:manipulation;-webkit-tap-highlight-color:transparent;z-index:2;'; backTL.addEventListener('pointerdown', function (ev) { ev.preventDefault(); ev.stopPropagation(); try { rp.remove(); } catch (e) {} col.style.display = 'flex'; // 팀 선택 화면 복귀(재시작 직행이어도 동일 — 팀부터 다시) }); card.innerHTML = '
' + (asHider ? _at_t('어디에 숨을까요?', 'どこにかくれる?', 'Where to Hide?') : _at_t('게임방 선택', 'ルームを選ぶ', 'Pick a Room')) + '
' + '
' + (asHider ? _at_t('새 방을 만들거나, 친구 방에 추가로 숨을 수 있어요', '新しい部屋を作るか、友達の部屋に追加でかくれよう', 'Host a new room, or hide together in a friend\'s room') : _at_t('친구들이 숨어 기다리는 방이에요', '友達が隠れて待っている部屋だよ', 'Friends are hiding inside, waiting')) + '
'; card.appendChild(backTL); // innerHTML 대입 *이후* 부착 — 리스너 보존 var _list = document.createElement('div'); // 방 목록 전용 컨테이너 function _room_btn(inner, border, fn, opts9) { opts9 = opts9 || {}; var b9 = document.createElement('button'); b9.type = 'button'; b9.setAttribute('data-mgmt-ui', '1'); b9.style.cssText = 'position:relative;overflow:hidden;display:flex;align-items:center;gap:12px;width:100%;padding:11px 13px;margin-bottom:9px;' + 'border-radius:16px;cursor:pointer;text-align:left;touch-action:manipulation;-webkit-tap-highlight-color:transparent;' + (opts9.grad ? 'border:0;background:' + opts9.grad + ';color:' + (opts9.ink || '#06250f') + ';box-shadow:0 10px 24px ' + (opts9.glow || 'rgba(47,174,98,.4)') + ',inset 0 2px 0 rgba(255,255,255,.5),inset 0 -3px 0 rgba(0,0,0,.2);' : 'border:1px solid ' + border + ';background:rgba(255,255,255,.05);color:#e8ecf4;box-shadow:inset 0 1px 0 rgba(255,255,255,.07);'); b9.innerHTML = (opts9.grad ? '
' + '
' : '') + inner + (opts9.trail || '
›
'); b9.addEventListener('pointerdown', function (ev) { ev.preventDefault(); ev.stopPropagation(); fn(); }); _list.appendChild(b9); return b9; } // 하이더 행 트레일 = "+ 나도 숨기" 글로시 필(샤인 스윕 — 사용자 확정 2026-07-25 "누르고 싶게") var _JOIN_PILL = '
' + '
' + '
+ ' + _at_t('나도 추가로 숨기', '僕も追加でかくれる', 'Hide here too') + '
'; if (asHider) _room_btn( '
' + '
' + '
' + _at_t('새 게임방 만들기', '新しいルームを作る', 'Host a New Room') + '
' + '
' + _at_t('내가 방장이 되어 숨어요', '自分がルーム主になってかくれる', 'Be the host and hide first') + '
', null, function () { if (_MAP_ROSTER.length > 1) { _map_select(); return; } // 맵 선택 경유(2026-07-27 사용자 확정) _hs_join = null; _hs_pure = true; try { ov.remove(); } catch (e) {} _start_role('hider'); }, { grad: 'linear-gradient(180deg,#8bf095 0%,#3fbf6c 55%,#1f8f52 100%)', ink: '#06250f', glow: 'rgba(47,174,98,.45)', trail: '
›
' }); // (술래 AI 게임방 버튼 제거 — 사용자 확정. AI 배치 = 폴백만 잔존) // 고정 프레임 + 스켈레톤(사용자 확정 2026-07-29 "절대 아코디언 금지") — 목록 영역은 첫 페인트부터 // 고정 높이의 기본 틀로 서고, 로딩 결과는 그 틀 *안을* 채운다(내부 스크롤). 높이 변화 = 0. _list.style.cssText = 'height:min(42vh,330px);overflow-y:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;'; if (!document.getElementById('camo-skel-anim')) { var _sk = document.createElement('style'); _sk.id = 'camo-skel-anim'; _sk.textContent = '@keyframes camoSkel{0%,100%{opacity:.35}50%{opacity:.7}}'; document.head.appendChild(_sk); } var _skels = []; for (var _si9 = 0; _si9 < 3; _si9++) { var sk9 = document.createElement('div'); sk9.style.cssText = 'height:62px;border-radius:16px;margin-bottom:9px;border:1px solid rgba(255,255,255,.07);' + 'background:linear-gradient(90deg,rgba(255,255,255,.05),rgba(255,255,255,.09),rgba(255,255,255,.05));' + 'animation:camoSkel 1.4s ease-in-out ' + (_si9 * 0.18) + 's infinite;'; _list.appendChild(sk9); _skels.push(sk9); } var ld = null; fetch(_hs_url('')).then(function (r) { return r.json(); }).catch(function () { return null; }).then(function (res) { _skels.forEach(function (s9) { try { s9.remove(); } catch (e) {} }); var rows = (res && res.rows) || []; if (!rows.length) { var em = document.createElement('div'); em.style.cssText = 'font:600 13px system-ui;opacity:.6;padding:6px 0 4px;text-align:center;'; em.textContent = _at_t('아직 개설된 방이 없어요 — 첫 방의 주인이 되어보세요!', 'まだ部屋がないよ — 最初のルーム主になろう!', 'No rooms yet — be the first host!'); _list.appendChild(em); return; } for (var r8 = 0; r8 < Math.min(rows.length, 12); r8++) (function (row) { if (!row || !row.id) return; var best = (row.recs && row.recs[0]) || null; var hiders = row.locked ? (Number(row.hiders) || 1) : 1 + ((row.members && row.members.length) || 0); var full = asHider && hiders >= 8; function _enter() { // 공개 = 즉시 · 잠금 = PIN 모달 검증 후(성공 행으로 참가 문맥 갱신) // 방의 맵 ≠ 현재 스테이지 → 그 맵으로 파라미터 리로드(스테이지는 부팅 시 구축 — 리로드 문법) var _rm = String(row.map || ''); if (_rm && (!MS || _rm !== MS.name) && _MAP_ROSTER.some(function (r0) { return r0.name === _rm; })) { try { if (_hs_pin) sessionStorage.setItem('camo_pin', _hs_pin); } catch (e) {} location.href = location.pathname + '?camo_map=' + _rm + (asHider ? '&camo_join=' + row.id : '&hide=' + row.id); return; } _hud_room(_hs_name(row)); // HUD 타이틀 = 방 이름 if (asHider) { _hs_join = row.id; _hs_join_row = { id: row.id, i: _hs_name(row) }; _hs_pure = true; } else _hs_ch = row.id; try { ov.remove(); } catch (e) {} _start_role(asHider ? 'hider' : 'hunter'); } var b8 = _room_btn( '
' + _hs_esc(_hs_chip(row)) + '
' + '
' + (row.locked ? '🔒 ' : '') + _hs_esc(_hs_name(row)) + '
' + '
' + (row.map ? '🗺 ' + _hs_esc(_map_label(row.map)) + ' · ' : '') + '🙈 ' + _at_t('숨은 ', 'かくれ ', 'hiding ') + hiders + ' · 👥 ' + _at_t('참여 ', '参加 ', 'plays ') + (Number(row.plays) || 0) + (best ? ' · 🏆 ' + _rmss(best.s) + ' ' + _hs_esc(Array.from(String(best.nm || best.i || '')).slice(0, 10).join('')) : '') + (full ? ' · ' + _at_t('만원', '満員', 'FULL') : '') + '
', asHider ? 'rgba(125,232,138,.28)' : 'rgba(150,205,255,.25)', function () { if (full) return; if (row.locked) _hs_pin_modal(row, function (pin8, row8) { if (pin8) { _hs_pin = pin8; _enter(); } }); else { _hs_pin = null; _enter(); } }, (asHider && !full) ? { trail: _JOIN_PILL } : null); if (full) { b8.style.opacity = '0.55'; b8.style.cursor = 'default'; } })(rows[r8]); }); card.appendChild(_list); // 방 목록 컨테이너 부착(백링크 제거 편집에서 소실 — 복원) rp.appendChild(card); ov.appendChild(rp); } document.body.appendChild(ov); if (auto_role === 'hider' || auto_role === 'hunter') { col.style.display = 'none'; _room_select(auto_role); } } if (_boot_new && _hs_key) { _hs_pure = true; _start_role('hider'); } // 맵 선택 후 새 방 직행(2026-07-27) else if (_boot_join && _hs_key) { _hs_join = _boot_join; _hs_join_row = { id: _boot_join, i: '' }; _hs_pure = true; _start_role('hider'); } // 맵 리로드 후 참가 직행(방명은 고스트 fetch 가 채움) else if (_replay_role && _hs_key) _mode_select(_replay_role); // 재시작 = 반드시 게임방 선택 경유(사용자 확정 2026-07-25 — 히어로 위 방 패널 직행) else if (_replay_role) _start_role(_replay_role); // 비-hideshare = 종전 직행(방 개념 없음) else if (_hs_ch && _hs_key) _start_role('hunter'); else if (_hs_key) _mode_select(); else _start_role(role); _priv_api.chams = chams; _priv_api.walls = walls; _priv_api.ray_walls = _ray_walls; // ("내 캐릭터 꾸미기" 메뉴 진입점은 하이더 역할 시작부로 이동 — 술래 메뉴 정리, 사용자 확정 2026-07-25) A._installed = 'camo_hideseek'; return { player: player, role: role, get phase() { return phase; }, get found() { return found; }, total: n_hiders, get detect() { return detect; }, get score() { return Math.round(score); }, get ended() { return ended; }, _priv: _priv_api, }; }; // ═══ 아키타입 #14: runner_dash — 3레인 엔드리스 러너(완비·골든-테스트 대상) ═══ // 정본(리서치 확정): 하이퍼캐주얼 1위 문법(레인 대시)의 결정론 구현. 입력 = 셸 소유 방향 채널 // P.on_dir(스와이프+화살표 통합) — 좌/우 레인 전환, 위 점프(아래 = 급강하). 트랙 = 지형 위 재활용 // 세그먼트 슬랩(무한 전진), 장애물/코인 = 세그먼트 시드-결정론 패턴, 속도 램프, 충돌 = 종료 + // 기록 카드. 플레이어 운동학은 아키타입이 전유(월드 물리 후행-덮어쓰기 — mgmt 카메라 선례): // 조이스틱/컨트롤 미설치(러너는 방향 채널이 유일 동사 — 시작 게이트도 자체 소유). _ARCH.runner_dash = function (cfg) { var c = cfg || {}; // ── Tier B config 검증(전 필드 기본값·클램프·enum — 어떤 입력에도 동작 보장) ── var mood_req = c.mood === 'night' ? 'night_neon' : c.mood; if (mood_req === 'night_neon' || mood_req === 'ember' || mood_req === 'dusk') { warn('dark mood "' + mood_req + '" demoted to sunset — archetype starts in daylight'); mood_req = 'sunset'; } var mood_name = pick(mood_req, P.moods || [], 'noon'); if (mood_req && mood_name !== mood_req) warn('unknown mood "' + mood_req + '" → ' + mood_name); var pal = P.mood(mood_name); P.music(pick(c.music, ['calm_exploration', 'adventure', 'cozy', 'mystery', 'night', 'chiptune_action', 'battle', 'triumph', 'space', 'spooky', 'lofi', 'sad', 'happy'], 'lofi')); P.ambience(pick(c.ambience, ['forest', 'wind', 'rain', 'night', 'ocean', 'cave', 'crowd'], 'wind')); P.enable_bloom({ strength: 0.5, radius: 0.5, threshold: 1.05 }); var world = P.world, nature = P.nature, fx = P.fx; var seed = (num(c.seed, 13) | 0); var terrain = world.terrain({ seed: seed, amplitude: 1.2, scale: 220, chunk: 32, radius: 3 }); var prng = world._priv.mulberry32(((seed * 69061) ^ 0x9E3779B9) >>> 0); var gr = pal.ground_ramp || [pal.ground, pal.ground, pal.ground, pal.ground]; nature.wind({ strength: 0.5, gustiness: 0.4, direction: { x: 0.6, z: 0.4 } }); nature.grass_field({ area: 150, count: 14000, base: gr[1], tip: gr[3], height: 0.4, y: terrain.height_at }); nature.clouds({ count: 10, style: 'puffy' }); var LANE_W = clamp(num(c.lane_width, 2.2), 1.6, 3); var SPEED0 = clamp(num(c.speed, 6.5), 3, 10); var SPEED_MAX = clamp(num(c.speed_max, 15), SPEED0, 24); var RAMP = clamp(num(c.ramp, 0.09), 0.01, 0.5); // m/s 가속(초당) var COIN_V = clamp(Math.round(num(c.coin_value, 1)), 1, 100); var JUMP_V = 6.4, GRAV = 15; var SEG = 16, NSEG = 15; // 세그먼트 길이·재활용 풀 var lane_x = function (l) { return (l - 1) * LANE_W; }; // ── 트랙 세그먼트(재활용 풀) — 노면 슬랩 + 세그먼트별 시드 장애물/코인 ── var road_mat = P.make.matte('#4a4f58', { roughness: 0.95 }); var edge_mat = P.make.matte('#e8e5da', { roughness: 0.7 }); var hurdle_mat = P.make.matte('#c4382d', { roughness: 0.6 }); var block_mat = P.make.matte(_hex_mix(gr[0], '#20242c', 0.25), { roughness: 0.85 }); function _hex_mix(a, b, t) { // 소형 로컬(러너 전용 — camo 유틸과 독립) function rgb(h) { var n = parseInt(h.slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } var A = rgb(a), B = rgb(b); function b2(v) { var s = Math.round(v).toString(16); return s.length < 2 ? '0' + s : s; } return '#' + b2(A[0] + (B[0] - A[0]) * t) + b2(A[1] + (B[1] - A[1]) * t) + b2(A[2] + (B[2] - A[2]) * t); } var coin_mat = P.make.glow('#ffd24a', 1.3); var W_ROAD = LANE_W * 3 + 1.6; var segs = []; function seg_y(i) { // 세그먼트 노면 높이 — 양끝·양측 hmax + 0.55 (슬랩 캐논) var h = -Infinity; for (var zz = 0; zz <= 1; zz++) for (var xx = -1; xx <= 1; xx++) { h = Math.max(h, world.height_at(xx * LANE_W * 1.5, (i + zz) * SEG)); } return h + 0.55; } function seg_prng(i) { return world._priv.mulberry32(((seed ^ (i * 2654435761)) >>> 0) || 1); } function build_seg(rec, i) { rec.i = i; var y = seg_y(i); rec.y = y; rec.grp.position.set(0, 0, 0); // 노면·연석 위치 갱신 rec.road.position.set(0, y - 0.6, i * SEG + SEG / 2); rec.eL.position.set(-W_ROAD / 2 + 0.15, y + 0.03, i * SEG + SEG / 2); rec.eR.position.set(W_ROAD / 2 - 0.15, y + 0.03, i * SEG + SEG / 2); // 기존 장애물/코인 회수 for (var k = 0; k < rec.props.length; k++) rec.grp.remove(rec.props[k]); rec.props = []; rec.obs = []; rec.coins = []; if (i < 3) return; // 출발 구간은 클린 var pr = seg_prng(i); var kind = pr() < 0.28 ? 'coins' : (pr() < 0.55 ? 'hurdle' : 'blocks'); if (kind === 'hurdle') { // 낮은 허들 1개(점프로 통과) — 레인 무작위 var hl = Math.floor(pr() * 3), hz = i * SEG + 4 + pr() * (SEG - 8); var hm = new THREE_.Mesh(new THREE_.BoxGeometry(LANE_W * 0.92, 0.75, 0.5), hurdle_mat); hm.position.set(lane_x(hl), y + 0.375, hz); rec.grp.add(hm); rec.props.push(hm); rec.obs.push({ lane: hl, z: hz, jumpable: true }); } else if (kind === 'blocks') { // 2레인 봉쇄(레인 전환으로 통과) — 열린 레인 1개 var open = Math.floor(pr() * 3), bz = i * SEG + 5 + pr() * (SEG - 10); for (var l = 0; l < 3; l++) { if (l === open) continue; var bm = new THREE_.Mesh(new THREE_.BoxGeometry(LANE_W * 0.94, 2.2, 0.7), block_mat); bm.position.set(lane_x(l), y + 1.1, bz); rec.grp.add(bm); rec.props.push(bm); rec.obs.push({ lane: l, z: bz, jumpable: false }); } } else { // 코인 열(한 레인 5개) var cl = Math.floor(pr() * 3), cz0 = i * SEG + 3; for (var ci = 0; ci < 5; ci++) { var cm = new THREE_.Mesh(new THREE_.CylinderGeometry(0.32, 0.32, 0.08, 14), coin_mat); cm.rotation.x = Math.PI / 2; cm.position.set(lane_x(cl), y + 1.0, cz0 + ci * 2.2); rec.grp.add(cm); rec.props.push(cm); rec.coins.push({ lane: cl, z: cz0 + ci * 2.2, mesh: cm, got: false }); } } } for (var si = 0; si < NSEG; si++) { var grp = new THREE_.Group(); var road = new THREE_.Mesh(new THREE_.BoxGeometry(W_ROAD, 1.2, SEG), road_mat); var eL = new THREE_.Mesh(new THREE_.BoxGeometry(0.3, 0.06, SEG), edge_mat); var eR = new THREE_.Mesh(new THREE_.BoxGeometry(0.3, 0.06, SEG), edge_mat); grp.add(road); grp.add(eL); grp.add(eR); scene.add(grp); var rec = { grp: grp, road: road, eL: eL, eR: eR, props: [], obs: [], coins: [], i: -1, y: 0 }; build_seg(rec, si); segs.push(rec); } function seg_at(z) { return segs[((Math.floor(z / SEG) % NSEG) + NSEG) % NSEG]; } // ── 플레이어(아바타 + 전유 운동학) ── var _extm = (typeof window !== 'undefined' && window['__PLAYABLE_'+'EXTRA_MANIFEST__'] && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].models) || {}; var CHARS = ['adventurer', 'rogue', 'striker', 'robot', 'knight']; var _psel = typeof c.player === 'string' && (_extm[c.player] && _extm[c.player].cat === 'character') ? c.player : pick(c.player, CHARS, 'adventurer'); var player = world.player({ spawn: _psel, at: { x: 0, z: 0 }, speed: 0.001 }); // 이동은 아키타입 전유 world.camera(); var PLh = world._priv.player(); var lane = 1, py = seg_y(0), vy = 0, grounded = true, dist = 0, coins = 0, speed = SPEED0; var started = false, ended = false, score_best = 0; // ── 시작 게이트 + HUD ── var ui = _arch_hud(String(c.title || _at_t('무한 질주', '無限ダッシュ', 'Endless Dash')).slice(0, 40)); function _hud() { ui.children[1].textContent = '🏃 ' + Math.floor(dist) + 'm · 🪙 ' + coins; ui.children[2].textContent = '⚡ x' + (speed / SPEED0).toFixed(1); } _hud(); var gate = document.createElement('div'); gate.setAttribute('data-mgmt-ui', '1'); gate.style.cssText = 'position:fixed;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:10px;background:rgba(8,10,16,.42);color:#fff;font:700 16px system-ui;text-align:center;'; gate.innerHTML = '
🏃
' + String(c.hint || _at_t('← → 레인 전환 · ↑ 점프
탭하여 출발!', '←→レーン移動・↑ジャンプ
タップでスタート!', 'Swipe ← → to switch lanes · ↑ to jump
Tap to start!')).slice(0, 120) + '
'; document.body.appendChild(gate); function _start() { if (started || ended) return; started = true; gate.remove(); fx.sfx('powerup'); A._emit('start', {}); } gate.addEventListener('pointerdown', function (ev) { ev.preventDefault(); _start(); }); // ── 입력(셸 방향 채널 — 스와이프+화살표 통합) ── function _dir(d) { if (ended) return; if (!started) { _start(); return; } if (d === 'left') lane = Math.max(0, lane - 1); else if (d === 'right') lane = Math.min(2, lane + 1); else if (d === 'up') { if (grounded) { vy = JUMP_V; grounded = false; fx.sfx('jump'); } } else if (d === 'down') { if (!grounded) vy = -10; } // 급강하 } P.on_dir(_dir); // ── 종료(충돌) ── function _crash() { if (ended || !started) return; ended = true; fx.screen_flash('#d24a4a'); fx.shake(0.5); fx.sfx('explosion', { volume: 0.5 }); if (PLh.handle.play) PLh.handle.play('idle'); P.toast(String(c.complete_text || _at_t('기록 ' + Math.floor(dist) + 'm! 🏃', '記録 ' + Math.floor(dist) + 'm! 🏃', 'Distance ' + Math.floor(dist) + 'm! 🏃')).slice(0, 60)); A._emit('crash', { distance: Math.floor(dist), coins: coins }); A._emit('complete', { distance: Math.floor(dist), coins: coins, speed: speed }); P.leaderboard.submit(Math.floor(dist)); // 재도전 버튼(프로즌 페이지 안전 = reload) var rb = document.createElement('button'); rb.setAttribute('data-mgmt-ui', '1'); rb.textContent = '↻ ' + _at_t('다시 달리기', 'もう一度', 'Run again'); rb.style.cssText = 'position:fixed;left:50%;transform:translateX(-50%);bottom:calc(120px + env(safe-area-inset-bottom,0px));z-index:70;padding:13px 22px;border-radius:999px;border:2px solid rgba(245,158,11,.6);background:rgba(28,33,48,.9);color:#ffd23e;font:800 15px system-ui;'; rb.addEventListener('pointerdown', function () { try { location.reload(); } catch (e) {} }); document.body.appendChild(rb); setTimeout(function () { P.three.capture().then(function (shot) { P.share_shot(shot, { title: String(c.title || _at_t('무한 질주', '無限ダッシュ', 'Endless Dash')).slice(0, 40), lines: ['🏃 ' + Math.floor(dist) + 'm · 🪙 ' + coins], share_text: String(c.share_text || _at_t(Math.floor(dist) + 'm 질주 기록! 🏃', Math.floor(dist) + 'm 走った! 🏃', 'I dashed ' + Math.floor(dist) + 'm! 🏃')).slice(0, 120), }); }); }, 900); } // ── 러너 루프(전진·레인·점프·세그 재활용·충돌·코인) ── P.loop(function (dt) { if (dt <= 0 || ended) return; dt = Math.min(dt, 0.1); var pp = PLh.handle.obj.position; if (!started) { // 대기 화면 — 제자리 러닝 연출 PLh.moving = true; PLh.running = false; pp.x += (lane_x(lane) - pp.x) * Math.min(1, dt * 10); return; } speed = Math.min(SPEED_MAX, speed + RAMP * dt); dist += speed * dt; pp.z += speed * dt; pp.x += (lane_x(lane) - pp.x) * Math.min(1, dt * 12); // 전유 수직 운동학(월드 물리 후행-덮어쓰기) var s = seg_at(pp.z); var gy = s.y; vy -= GRAV * dt; py += vy * dt; if (py <= gy) { py = gy; vy = 0; grounded = true; } else grounded = false; pp.y = py; PLh.heading = 0; PLh.moving = true; PLh.running = speed > SPEED0 * 1.3; PLh.on_ground = grounded; PLh.vy = 0; // 세그먼트 재활용(전방 유지) var front = Math.floor(pp.z / SEG); for (var i2 = 0; i2 < NSEG; i2++) { var rec = segs[i2]; if (rec.i < front - 2) build_seg(rec, rec.i + NSEG); } // 충돌·코인 for (var i3 = 0; i3 < NSEG; i3++) { var r3 = segs[i3]; if (Math.abs(r3.i * SEG - pp.z) > SEG * 2) continue; for (var o = 0; o < r3.obs.length; o++) { var ob = r3.obs[o]; if (ob.lane !== lane) continue; if (Math.abs(pp.z - ob.z) > 0.65) continue; if (ob.jumpable && py - r3.y > 0.85) continue; // 공중 통과 _crash(); return; } for (var c3 = 0; c3 < r3.coins.length; c3++) { var cn = r3.coins[c3]; if (cn.got || cn.lane !== lane) continue; if (Math.abs(pp.z - cn.z) < 1.0 && py - r3.y < 1.6) { cn.got = true; cn.mesh.visible = false; coins += COIN_V; fx.sfx('pickup', { volume: 0.5 }); A._emit('coin', { coins: coins }); } } } // 코인 스핀 연출 for (var i4 = 0; i4 < NSEG; i4++) { for (var c4 = 0; c4 < segs[i4].coins.length; c4++) { if (!segs[i4].coins[c4].got) segs[i4].coins[c4].mesh.rotation.z += dt * 3; } } _hud(); }); P.mount_share(function () { return String(c.share_text || _at_t('무한 질주 ' + Math.floor(dist) + 'm 🏃', '無限ダッシュ ' + Math.floor(dist) + 'm 🏃', 'Endless dash ' + Math.floor(dist) + 'm 🏃')).slice(0, 120); }, String(c.share_label || _at_t('공유', '共有', 'Share')).slice(0, 12)); P.leaderboard.enable({ metric: 'distance', unit: 'm', max: 100000, higher_better: true, min_play_s: 4 }); A._installed = 'runner_dash'; return { player: player, get distance() { return Math.floor(dist); }, get coins() { return coins; }, get speed() { return speed; }, get lane() { return lane; }, get started() { return started; }, get ended() { return ended; }, _priv: { segs: segs, seg_at: seg_at, dir: _dir, start: _start, crash: _crash, get grounded() { return grounded; }, get py() { return py; } }, }; }; // ═══ 아키타입 #15: daily_merge — 2048류 3D 머지 보드 퍼즐(완비·골든-테스트 대상) ═══ // 정본: 모바일 최대 수요 장르(퍼즐)의 첫 아키타입. three 프리셋 3D 보드로 구현 — canvas2d // 아키타입화는 프리셋 템플릿·게이트 3면 수술이 필요해 별도 과제(구조 맵 기록). 입력 = P.on_dir // (스와이프+화살표), 규칙 = 표준 2048 슬라이드-머지(순수 함수 — 골든이 산술까지 단정), // 스폰 = 시드 결정론. 카메라 = 고정 부감(아바타·조이스틱 없음), deps = three 코어만. _ARCH.daily_merge = function (cfg) { var c = cfg || {}; var mood_name = pick(c.mood === 'night' ? 'night_neon' : c.mood, P.moods || [], 'noon'); var pal = P.mood(mood_name); P.music(pick(c.music, ['calm_exploration', 'adventure', 'cozy', 'mystery', 'night', 'chiptune_action', 'battle', 'triumph', 'space', 'spooky', 'lofi', 'sad', 'happy'], 'lofi')); if (c.ambience) P.ambience(pick(c.ambience, ['forest', 'wind', 'rain', 'night', 'ocean', 'cave', 'crowd'], 'wind')); P.enable_bloom({ strength: 0.45, radius: 0.5, threshold: 1.05 }); var fx = P.fx; var seed = (num(c.seed, 3) | 0); var prng = (function () { var s = ((seed * 88793) ^ 0x9E3779B9) >>> 0; return function () { s = (s + 0x6D2B79F5) >>> 0; var t = Math.imul(s ^ (s >>> 15), 1 | s); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; })(); var N = clamp(Math.round(num(c.grid, 4)), 3, 6); var TARGETS = [256, 512, 1024, 2048, 4096]; var target = TARGETS.indexOf(num(c.target, 2048)) >= 0 ? num(c.target, 2048) : 2048; var CELL = 1.6, GAP = 0.2, PITCH = CELL + GAP; var half = (N - 1) * PITCH / 2; // ── 보드 무대(고정 부감 카메라 — 아바타 없음) ── var cam = P.three.camera; function _fit() { var aspect = Math.max(0.4, (window.innerWidth || 390) / Math.max(1, window.innerHeight || 700)); var d = (N * PITCH) * (aspect < 1 ? 1.45 / aspect * 0.72 : 1.05); cam.position.set(0, d * 1.15, d * 0.78); cam.lookAt(0, 0, -0.2); } _fit(); P.on_resize(_fit); var base = new THREE_.Mesh(new THREE_.BoxGeometry(N * PITCH + 0.7, 0.5, N * PITCH + 0.7), P.make.matte('#2c3140', { roughness: 0.9 })); base.position.y = -0.45; scene.add(base); var slot_mat = P.make.matte('#3a4152', { roughness: 0.85 }); for (var gi = 0; gi < N * N; gi++) { var sm = new THREE_.Mesh(new THREE_.BoxGeometry(CELL, 0.14, CELL), slot_mat); sm.position.set((gi % N) * PITCH - half, -0.12, Math.floor(gi / N) * PITCH - half); scene.add(sm); } // 값 → 색 램프(11계단) + 숫자 CanvasTexture 캐시 var HUES = ['#8f9aa8', '#a8b7c4', '#e8b84a', '#e89a3c', '#e0742f', '#d95763', '#c94a8a', '#9b6bd4', '#4a9fd8', '#3f9f8a', '#ffd24a']; var _lab_cache = {}; function tile_mat(v) { if (_lab_cache[v]) return _lab_cache[v]; var idx = Math.min(HUES.length - 1, Math.round(Math.log(v) / Math.LN2) - 1); var cv = document.createElement('canvas'); cv.width = cv.height = 128; var cx = cv.getContext('2d'); cx.fillStyle = HUES[idx]; cx.fillRect(0, 0, 128, 128); cx.fillStyle = v <= 4 ? '#2c3140' : '#ffffff'; cx.font = '700 ' + (String(v).length > 3 ? 40 : 52) + 'px system-ui'; cx.textAlign = 'center'; cx.textBaseline = 'middle'; cx.fillText(String(v), 64, 68); var tex = new THREE_.CanvasTexture(cv); var mats = []; var side = P.make.matte(HUES[idx], { roughness: 0.55 }); for (var f = 0; f < 6; f++) mats.push(f === 2 ? new THREE_.MeshStandardMaterial({ map: tex, roughness: 0.5 }) : side); _lab_cache[v] = mats; return mats; } // ── 순수 보드 규칙(2048 슬라이드-머지 — 골든이 직접 단정하는 단일 소유 함수) ── var board = []; // N*N 배열: 0 | 값 for (var bi = 0; bi < N * N; bi++) board.push(0); function _slide_line(line) { // 한 줄을 왼쪽-병합 — {line, gained, moved} var vals = line.filter(function (v) { return v > 0; }); var out = [], gained = 0; for (var i = 0; i < vals.length; i++) { if (i + 1 < vals.length && vals[i] === vals[i + 1]) { out.push(vals[i] * 2); gained += vals[i] * 2; i++; } else out.push(vals[i]); } while (out.length < line.length) out.push(0); var moved = false; for (var j = 0; j < line.length; j++) if (out[j] !== line[j]) moved = true; return { line: out, gained: gained, moved: moved }; } function _apply_dir(b, d) { // 방향 슬라이드 — {board, gained, moved} (비변이) var nb = b.slice(), gained = 0, moved = false; for (var r = 0; r < N; r++) { var line = []; for (var k = 0; k < N; k++) { var x = d === 'left' ? k : d === 'right' ? N - 1 - k : r; var z = d === 'left' || d === 'right' ? r : (d === 'up' ? k : N - 1 - k); line.push(nb[z * N + x]); } var res = _slide_line(line); gained += res.gained; if (res.moved) moved = true; for (var k2 = 0; k2 < N; k2++) { var x2 = d === 'left' ? k2 : d === 'right' ? N - 1 - k2 : r; var z2 = d === 'left' || d === 'right' ? r : (d === 'up' ? k2 : N - 1 - k2); nb[z2 * N + x2] = res.line[k2]; } } return { board: nb, gained: gained, moved: moved }; } function _can_move(b) { for (var i = 0; i < N * N; i++) { if (b[i] === 0) return true; var x = i % N, z = Math.floor(i / N); if (x + 1 < N && b[i] === b[i + 1]) return true; if (z + 1 < N && b[i] === b[i + N]) return true; } return false; } function _spawn(b) { var empties = []; for (var i = 0; i < b.length; i++) if (b[i] === 0) empties.push(i); if (!empties.length) return b; b[empties[Math.floor(prng() * empties.length)]] = prng() < 0.9 ? 2 : 4; return b; } // ── 렌더 동기화(값별 메시 재생성 — N≤6 = 최대 36타일, 매 수 재생성 무부담) ── var tile_grp = new THREE_.Group(); scene.add(tile_grp); function _render_board() { while (tile_grp.children.length) tile_grp.remove(tile_grp.children[0]); for (var i = 0; i < board.length; i++) { if (!board[i]) continue; var m = new THREE_.Mesh(new THREE_.BoxGeometry(CELL, 0.62, CELL), tile_mat(board[i])); m.position.set((i % N) * PITCH - half, 0.31, Math.floor(i / N) * PITCH - half); tile_grp.add(m); } } var score = 0, best_tile = 0, ended = false, won = false, lock = false; var ui = _arch_hud(String(c.title || _at_t('데일리 머지', 'デイリーマージ', 'Daily Merge')).slice(0, 40)); function _hud() { ui.children[1].textContent = '⭐ ' + score; ui.children[2].textContent = '🎯 ' + target; } function _max_tile() { var mx = 0; for (var i = 0; i < board.length; i++) mx = Math.max(mx, board[i]); return mx; } _spawn(board); _spawn(board); _render_board(); _hud(); if (c.hint) P.toast(String(c.hint).slice(0, 60)); function _dir(d) { if (ended || lock) return; var res = _apply_dir(board, d); if (!res.moved) return; lock = true; board = res.board; score += res.gained; _spawn(board); _render_board(); best_tile = _max_tile(); if (res.gained > 0) fx.sfx('pickup', { volume: 0.4 }); A._emit('move', { dir: d, score: score, gained: res.gained }); if (res.gained >= 128) A._emit('merge', { value: res.gained }); _hud(); setTimeout(function () { lock = false; }, 90); if (!won && best_tile >= target) { won = true; fx.screen_flash(pal.accent); fx.sfx('powerup'); P.toast(String(c.complete_text || _at_t(target + ' 달성! 🎉 계속 가보세요', target + '達成! 🎉 まだ続く', target + ' reached! 🎉 Keep going')).slice(0, 60)); A._emit('win', { score: score, tile: best_tile }); setTimeout(_share, 900); } if (!_can_move(board)) { ended = true; P.toast(_at_t('더 이상 이동 불가 — ' + score + '점!', 'これ以上動けない — ' + score + '点!', 'No more moves — ' + score + ' points!')); A._emit('complete', { score: score, tile: best_tile, won: won }); P.leaderboard.submit(score); var rb = document.createElement('button'); rb.setAttribute('data-mgmt-ui', '1'); rb.textContent = '↻ ' + _at_t('새 판', 'もう一局', 'New board'); rb.style.cssText = 'position:fixed;left:50%;transform:translateX(-50%);bottom:calc(120px + env(safe-area-inset-bottom,0px));z-index:70;padding:13px 22px;border-radius:999px;border:2px solid rgba(245,158,11,.6);background:rgba(28,33,48,.9);color:#ffd23e;font:800 15px system-ui;'; rb.addEventListener('pointerdown', function () { try { location.reload(); } catch (e) {} }); document.body.appendChild(rb); if (!won) setTimeout(_share, 900); } } P.on_dir(_dir); function _share() { P.three.capture().then(function (shot) { P.share_shot(shot, { title: String(c.title || _at_t('데일리 머지', 'デイリーマージ', 'Daily Merge')).slice(0, 40), lines: ['⭐ ' + score + ' · 🧩 ' + best_tile], share_text: String(c.share_text || _at_t('머지 퍼즐 ' + score + '점 · 최고 타일 ' + best_tile + ' 🧩', 'マージパズル ' + score + '点 · 最高 ' + best_tile + ' 🧩', 'Merge puzzle ' + score + ' pts · best tile ' + best_tile + ' 🧩')).slice(0, 120), }); }); } P.mount_share(function () { return String(c.share_text || _at_t('머지 퍼즐 ' + score + '점 🧩', 'マージパズル ' + score + '点 🧩', 'Merge puzzle ' + score + ' pts 🧩')).slice(0, 120); }, String(c.share_label || _at_t('공유', '共有', 'Share')).slice(0, 12)); P.leaderboard.enable({ metric: 'score', unit: '', max: 100000000, higher_better: true, min_play_s: 5 }); A._installed = 'daily_merge'; return { get score() { return score; }, get best_tile() { return best_tile; }, get ended() { return ended; }, get won() { return won; }, grid: N, target: target, _priv: { get board() { return board.slice(); }, set_board: function (b) { if (Array.isArray(b) && b.length === N * N) { board = b.slice(); _render_board(); } }, apply_dir: _apply_dir, can_move: _can_move, dir: _dir, }, }; }; // ═══ 아키타입 #16: dungeon_crawl — 미니 던전 RPG(완비·골든-테스트 대상) ═══ // 정본(리서치 확정): 미션-먼저 공간 생성(Dormans/Spelunky 크리티컬-패스) — 방 사슬은 시드 // 결정론으로 start→…→boss 도달을 구성적으로 보장(막힌 던전이 표현 불가). 전투 = combat 패밀리 // 재사용(world.mob 근접 FSM + world.attack 3타 체인·공격 버튼), 열쇠 K개 → 보스문 개방 → 보스 // 처치 = 승리. 사망 = 재도전 버튼(무한 리스폰 없음 — 던전 런의 긴장 정본). _ARCH.dungeon_crawl = function (cfg) { var c = cfg || {}; var mood_req = c.mood === 'night' ? 'night_neon' : c.mood; if (mood_req === 'night_neon' || mood_req === 'ember' || mood_req === 'dusk') { warn('dark mood "' + mood_req + '" demoted to sunset — archetype starts in daylight (torches carry the dungeon feel)'); mood_req = 'sunset'; } var mood_name = pick(mood_req, P.moods || [], 'sunset'); var pal = P.mood(mood_name); P.music(pick(c.music, ['calm_exploration', 'adventure', 'cozy', 'mystery', 'night', 'chiptune_action', 'battle', 'triumph', 'space', 'spooky', 'lofi', 'sad', 'happy'], 'lofi')); P.ambience(pick(c.ambience, ['forest', 'wind', 'rain', 'night', 'ocean', 'cave', 'crowd'], 'cave')); P.enable_bloom({ strength: 0.55, radius: 0.5, threshold: 1.05 }); var world = P.world, fx = P.fx; var seed = (num(c.seed, 21) | 0); var terrain = world.terrain({ seed: seed, amplitude: 1, scale: 240, chunk: 32, radius: 3 }); var prng = world._priv.mulberry32(((seed * 50331) ^ 0x9E3779B9) >>> 0); var gr = pal.ground_ramp || [pal.ground, pal.ground, pal.ground, pal.ground]; var NROOM = clamp(Math.round(num(c.rooms, 5)), 4, 8); var diff = pick(c.difficulty, ['easy', 'normal', 'hard'], 'normal'); var MOB = { easy: { hp: 2, dmg: 1, n: 2 }, normal: { hp: 3, dmg: 1, n: 3 }, hard: { hp: 4, dmg: 2, n: 3 } }[diff]; var KEYS_NEED = clamp(Math.round(num(c.keys, 2)), 1, 4); // ── 방 사슬 생성(크리티컬 패스 — 좌표는 시드 결정론, +z 전진 + 횡 지터) ── var wall_mat = P.make.matte('#4a4454', { roughness: 0.92 }); wall_mat.vertexColors = true; // _q_shade AO(공유 머티리얼 — clone 불요) var wall_top = P.make.matte('#5c5468', { roughness: 0.9 }); var floor_mat = P.make.matte('#6b6274', { roughness: 0.95 }); floor_mat.vertexColors = true; var rooms = []; var zc = 0, xc = 0, hmax = -Infinity; for (var ri = 0; ri < NROOM; ri++) { var w = 11 + Math.floor(prng() * 4) * 2, d = 11 + Math.floor(prng() * 4) * 2; rooms.push({ x: xc, z: zc, w: w, d: d, boss: ri === NROOM - 1 }); for (var hx = -w; hx <= w; hx += 4) for (var hz = -d; hz <= d; hz += 4) hmax = Math.max(hmax, world.height_at(xc + hx / 2, zc + hz / 2)); zc += d / 2 + 8 + 6; // 다음 방 중심(복도 8m) xc += (prng() - 0.5) * 10; zc += (ri < NROOM - 1 ? rooms[ri].d / 2 : 0) * 0; // (전진은 위 한 줄이 소유 — 가독 유지) } // 문 x 사슬(크리티컬-패스 보장의 핵심) — 방 i↔i+1 의 북문·남문·복도 중심이 하나의 door_x 를 // 공유해야 경로가 벽에 막히지 않는다(양쪽 방의 문-가능 창으로 이중 클램프). var door_x = []; for (var di = 0; di < NROOM - 1; di++) { var A2 = rooms[di], B2 = rooms[di + 1]; var dx0 = (A2.x + B2.x) / 2; dx0 = clamp(dx0, A2.x - A2.w / 2 + 3, A2.x + A2.w / 2 - 3); dx0 = clamp(dx0, B2.x - B2.w / 2 + 3, B2.x + B2.w / 2 - 3); door_x.push(dx0); } var Y = hmax + 0.55; var _floor_rects = []; // 보행면 등록(ground_ext) — 지형-단독 접지의 슬랩 매몰 클래스 봉합 function slab(mat, x, z, w2, d2, y, th) { var m = new THREE_.Mesh(new THREE_.BoxGeometry(w2, th, d2, 6, 1, 6), mat); m.position.set(x, y - th / 2, z); if (mat === floor_mat) _q_shade(m, 0.66, 'xz'); // 바닥 가장자리 AO scene.add(m); _floor_rects.push({ x: x, z: z, hw: w2 / 2 + 0.3, hd: d2 / 2 + 0.3 }); // 방/복도 바닥 = 지형 필드에 편입(단층 평탄면). 구 ground_ext 제공자는 플레이어 물리만 // 소비해 카메라·몹·소품 등 나머지 height_at 소비자가 바닥 아래로 샜다. world.floor_plane({ x0: x - w2 / 2 - 0.3, x1: x + w2 / 2 + 0.3, z0: z - d2 / 2 - 0.3, z1: z + d2 / 2 + 0.3, y: Y }); return m; } function wall(x, z, w2, d2, h2) { var m = new THREE_.Mesh(new THREE_.BoxGeometry(w2, h2, d2, 1, 5, 1), wall_mat); m.position.set(x, Y + h2 / 2, z); _q_shade(m, 0.6, 'y'); // 벽 세로 AO(하단 어둡게 — 단색 밋밋 봉합) scene.add(m); _q_baseboard(x, z, w2 / 2, d2 / 2, Y, '#332e3d'); // 걸레받이(석조 트림 — 마감감) var cap = new THREE_.Mesh(new THREE_.BoxGeometry(w2 + 0.12, 0.2, d2 + 0.12), wall_top); cap.position.set(x, Y + h2 + 0.1, z); scene.add(cap); var col = world._priv.collider_box({ x: x, z: z, hx: Math.min(4, w2 / 2 + 0.1), hz: Math.min(4, d2 / 2 + 0.1), y0: Y - 3, y1: Y + h2 }); // y0 -3: 언더슬립 차단 return { mesh: m, cap: cap, col: col }; } var DOOR = 3.2, WH = 3; var torch_pts = []; for (var r2 = 0; r2 < rooms.length; r2++) { var rm = rooms[r2]; slab(floor_mat, rm.x, rm.z, rm.w + 2, rm.d + 2, Y, 1.4); var hw = rm.w / 2, hd = rm.d / 2; // 남/북 벽(문 구멍 = 사슬 방향) — 세그먼트 4m 이하(collider_box hx≤4 계약) function wall_run(x0, x1, z, gap_c) { // gap_c = 문 중심 x | null var segs2 = []; if (gap_c == null) segs2.push([x0, x1]); else { segs2.push([x0, gap_c - DOOR / 2]); segs2.push([gap_c + DOOR / 2, x1]); } for (var s3 = 0; s3 < segs2.length; s3++) { var a = segs2[s3][0], b = segs2[s3][1]; for (var x = a; x < b - 0.05; x += 4) { var wlen = Math.min(4, b - x); wall(x + wlen / 2, z, wlen, 0.8, WH); } } } var prev = rooms[r2 - 1], next = rooms[r2 + 1]; wall_run(rm.x - hw, rm.x + hw, rm.z - hd, prev ? door_x[r2 - 1] : null); wall_run(rm.x - hw, rm.x + hw, rm.z + hd, next ? door_x[r2] : null); // 동/서 벽(통짜) for (var z4 = rm.z - hd; z4 < rm.z + hd - 0.05; z4 += 4) { var wl2 = Math.min(4, rm.z + hd - z4); wall(rm.x - hw, z4 + wl2 / 2, 0.8, wl2, WH); wall(rm.x + hw, z4 + wl2 / 2, 0.8, wl2, WH); } torch_pts.push({ x: rm.x - hw + 1, z: rm.z - hd + 1 }, { x: rm.x + hw - 1, z: rm.z + hd - 1 }); // 복도(다음 방으로) — 바닥 + 측벽 if (next) { var cx = door_x[r2]; var z0 = rm.z + hd, z1 = next.z - next.d / 2; slab(floor_mat, cx, (z0 + z1) / 2, DOOR + 2.4, z1 - z0 + 2, Y, 1.4); for (var cz = z0; cz < z1 - 0.05; cz += 4) { var cl = Math.min(4, z1 - cz); wall(cx - DOOR / 2 - 0.5, cz + cl / 2, 0.8, cl, WH); wall(cx + DOOR / 2 + 0.5, cz + cl / 2, 0.8, cl, WH); } } } // 횃불(글로우 기둥 — 던전 무드) for (var ti = 0; ti < torch_pts.length; ti++) { var tp2 = torch_pts[ti]; var tm = new THREE_.Mesh(new THREE_.CylinderGeometry(0.09, 0.12, 1.2, 6), P.make.matte('#5a4632')); tm.position.set(tp2.x, Y + 0.6, tp2.z); scene.add(tm); var fl = new THREE_.Mesh(new THREE_.SphereGeometry(0.16, 8, 7), P.make.glow('#ffb347', 1.6)); fl.position.set(tp2.x, Y + 1.3, tp2.z); scene.add(fl); fx.pulse(fl, { amount: 0.25, rate: 2.2 }); } // ── 실 던전 프롭(테마팩 배선 — 카탈로그 dun_ 200종). 미주입 시 프롭 없이 프리미티브 던전 유지. ── // 동/서 측벽 근처에만 배치(남/북 벽의 문 임계경로·중앙 이동로 회피). 콜라이더로 실 장애물화. var _DUN_PROPS = (typeof window !== 'undefined' && window['__PLAYABLE_'+'EXTRA_MANIFEST__'] && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].stageprops && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].stageprops.items) || null; if (_DUN_PROPS && _DUN_PROPS.length) { var _dpi = 0; for (var rp = 0; rp < rooms.length; rp++) { var rmp = rooms[rp], hwp = rmp.w / 2, hdp = rmp.d / 2; var nprop = 3 + Math.floor(prng() * 3); // 방당 3~5 for (var pk3 = 0; pk3 < nprop; pk3++) { var west = prng() < 0.5; var ppx = rmp.x + (west ? -(hwp - 1.4) : (hwp - 1.4)); var ppz = rmp.z + (prng() - 0.5) * (rmp.d - 3.5); _spawn_stage_prop(_DUN_PROPS[_dpi++ % _DUN_PROPS.length], ppx, ppz, Y, { cap_h: 2.8 }); } } } // ── 플레이어 + 근접 전투(패밀리 재사용) ── var _extm = (typeof window !== 'undefined' && window['__PLAYABLE_'+'EXTRA_MANIFEST__'] && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].models) || {}; var CHARS = ['knight', 'rogue', 'barbarian', 'adventurer', 'mage', 'striker']; var _psel = typeof c.player === 'string' && (_extm[c.player] && _extm[c.player].cat === 'character') ? c.player : pick(c.player, CHARS, 'knight'); var player = world.player({ spawn: _psel, at: { x: rooms[0].x, z: rooms[0].z }, speed: 4.2, run_speed: 7, hp: 5, on_player_death: function () { _defeat(); } }); world._priv.player().handle.obj.position.y = Y + 0.2; // 스폰 = 던전 바닥(이중공간 클래스 봉합) world.camera(); world.controls({ mission: String(c.hint || _at_t('열쇠 ' + KEYS_NEED + '개를 모아 보스 방을 열어라!', '鍵を' + KEYS_NEED + '本集めてボス部屋を開けろ!', 'Collect ' + KEYS_NEED + ' keys and open the boss door!')).slice(0, 80) }); world.minimap({ range: 60 }); var kills = 0, keys = 0, ended = false; var atk = world.attack({ range: 2.2, combo: 3, damage: 1, on_hit: function () {}, }); // ── 몹 배치(방별) + 열쇠 드랍 ── var MOBS = ['goblin_cw', 'rogue', 'barbarian', 'worker']; var key_rooms = []; while (key_rooms.length < KEYS_NEED) { // 중간 방들 중 열쇠 방(시드 결정론, 중복 없이) var kr = 1 + Math.floor(prng() * (NROOM - 2)); if (key_rooms.indexOf(kr) < 0) key_rooms.push(kr); } var mobs_alive = 0; function _drop_key(x, z) { var kh = world.spawn('key', { at: { x: x, z: z }, h: 0.45, collide: false }); world.collectible(kh, { magnet: 4, on_collect: function () { keys++; fx.sfx('pickup'); P.toast('🔑 ' + keys + ' / ' + KEYS_NEED); A._emit('key', { keys: keys, need: KEYS_NEED }); _hud(); _try_open(); } }); } for (var mr = 1; mr < NROOM - 1; mr++) { (function (mri) { var rm2 = rooms[mri]; var nmob = MOB.n + (mri === NROOM - 2 ? 1 : 0); for (var mi = 0; mi < nmob; mi++) { (function (last) { var mx = rm2.x + (prng() - 0.5) * (rm2.w - 4), mz = rm2.z + (prng() - 0.5) * (rm2.d - 4); mobs_alive++; world.mob(MOBS[Math.floor(prng() * MOBS.length)], { at: { x: mx, z: mz }, behavior: 'melee', hp: MOB.hp, damage: MOB.dmg, aggro: 9, leash: 14, speed: 0.7, on_death: function () { mobs_alive--; kills++; fx.sfx('hit'); A._emit('kill', { kills: kills }); _hud(); if (last && key_rooms.indexOf(mri) >= 0) _drop_key(mx, mz); // 방 마지막 처치 몹이 열쇠 드랍 }, }); })(mi === nmob - 1); } })(mr); } // ── 보스문(열쇠 게이트) + 보스 ── var boss_room = rooms[NROOM - 1]; var gate_x = door_x[NROOM - 2]; var door_z = boss_room.z - boss_room.d / 2; var gate_mesh = new THREE_.Mesh(new THREE_.BoxGeometry(DOOR, WH, 0.7), P.make.glow('#8a5cff', 1.2)); gate_mesh.position.set(gate_x, Y + WH / 2, door_z); scene.add(gate_mesh); var gate_col = world._priv.collider_box({ x: gate_x, z: door_z, hx: DOOR / 2, hz: 0.5, y0: Y - 1, y1: Y + WH }); var gate_open = false; function _try_open() { if (gate_open || keys < KEYS_NEED) return; gate_open = true; scene.remove(gate_mesh); world._priv.collider_box_remove(gate_col); fx.burst({ x: gate_x, y: Y + 1.6, z: door_z }, { count: 24, color: '#8a5cff', speed: 3, life: 1.1 }); fx.sfx('powerup'); P.toast(_at_t('보스 방이 열렸다!', 'ボス部屋が開いた!', 'The boss room is open!')); A._emit('gate', {}); } var boss_h = null; boss_h = world.mob(pick(c.boss_model, ['barbarian', 'knight', 'dragon'], 'barbarian'), { at: { x: boss_room.x, z: boss_room.z }, behavior: 'melee', hp: MOB.hp * 4 + 4, damage: MOB.dmg + 1, aggro: 11, leash: Math.max(6, boss_room.w / 2), speed: 0.65, h: 2.6, on_death: function () { _victory(); }, }); // ── HUD·종결 ── var ui = _arch_hud(String(c.title || _at_t('던전 크롤', 'ダンジョンクロール', 'Dungeon Crawl')).slice(0, 40)); function _hud() { ui.children[1].textContent = '🔑 ' + keys + '/' + KEYS_NEED + ' · ⚔ ' + kills; ui.children[2].textContent = gate_open ? _at_t('👹 보스!', '👹 ボス!', '👹 Boss!') : _at_t('🚪 잠김', '🚪 封印', '🚪 Sealed'); } _hud(); function _retry_btn() { var rb = document.createElement('button'); rb.setAttribute('data-mgmt-ui', '1'); rb.textContent = '↻ ' + _at_t('다시 도전', 'もう一度', 'Try again'); rb.style.cssText = 'position:fixed;left:50%;transform:translateX(-50%);bottom:calc(120px + env(safe-area-inset-bottom,0px));z-index:70;padding:13px 22px;border-radius:999px;border:2px solid rgba(245,158,11,.6);background:rgba(28,33,48,.9);color:#ffd23e;font:800 15px system-ui;'; rb.addEventListener('pointerdown', function () { try { location.reload(); } catch (e) {} }); document.body.appendChild(rb); } function _victory() { if (ended) return; ended = true; P.music('triumph'); fx.screen_flash(pal.key); P.toast(String(c.complete_text || _at_t('던전 클리어! 👑', 'ダンジョンクリア! 👑', 'Dungeon cleared! 👑')).slice(0, 60)); A._emit('complete', { won: true, kills: kills, keys: keys }); P.leaderboard.submit(kills); setTimeout(function () { P.three.capture().then(function (shot) { P.share_shot(shot, { title: String(c.title || _at_t('던전 크롤', 'ダンジョンクロール', 'Dungeon Crawl')).slice(0, 40), lines: ['👑 ' + _at_t('클리어', 'クリア', 'Cleared') + ' · ⚔ ' + kills], share_text: String(c.share_text || _at_t('던전 보스 격파! 👑 ⚔' + kills, 'ダンジョンボス撃破! 👑 ⚔' + kills, 'Dungeon boss down! 👑 ⚔' + kills)).slice(0, 120), }); }); }, 900); } function _defeat() { if (ended) return; ended = true; P.toast(_at_t('쓰러졌다… 다시 도전!', '倒れた… もう一度!', 'You fell… try again!')); A._emit('complete', { won: false, kills: kills, keys: keys }); _retry_btn(); } P.mount_share(function () { return String(c.share_text || _at_t('던전 크롤 ⚔' + kills + ' · 🔑' + keys, 'ダンジョン ⚔' + kills + ' · 🔑' + keys, 'Dungeon crawl ⚔' + kills + ' · 🔑' + keys)).slice(0, 120); }, String(c.share_label || _at_t('공유', '共有', 'Share')).slice(0, 12)); P.leaderboard.enable({ metric: 'kills', unit: '', max: 200, higher_better: true, min_play_s: 8 }); A._installed = 'dungeon_crawl'; return { player: player, get kills() { return kills; }, get keys() { return keys; }, keys_need: KEYS_NEED, get gate_open() { return gate_open; }, get ended() { return ended; }, rooms: NROOM, _priv: { rooms: rooms, boss: function () { return boss_h; }, try_open: _try_open, drop_key: _drop_key, Y: Y, attack: atk }, }; }; // ═══ 아키타입 #17: race_circuit — 아케이드 서킷 레이싱(완비·골든-테스트 대상) ═══ // 정본: 하이퍼캐주얼 레이싱 문법 = 자동 가속 + 스티어만(◀ ▶ 홀드 버튼·화살표 홀드). 트랙 = // 파라메트릭 폐루프(직선 2 + 반원 2 라운드-렉트, 시드 크기 변주)를 슬랩 조각으로 깔고, AI // 라이벌은 경로-파람 추종(러버밴딩). 체크포인트 4 → 랩 → 완주 = 순위 + 기록 카드. 오프트랙 = // 잔디 감속(사망 없음 — 캐주얼 계약). 차량 운동학은 아키타입 전유. _ARCH.race_circuit = function (cfg) { var c = cfg || {}; var mood_req = c.mood === 'night' ? 'night_neon' : c.mood; if (mood_req === 'night_neon' || mood_req === 'ember' || mood_req === 'dusk') { warn('dark mood "' + mood_req + '" demoted to sunset — archetype starts in daylight'); mood_req = 'sunset'; } var mood_name = pick(mood_req, P.moods || [], 'noon'); var pal = P.mood(mood_name); P.music(pick(c.music, ['calm_exploration', 'adventure', 'cozy', 'mystery', 'night', 'chiptune_action', 'battle', 'triumph', 'space', 'spooky', 'lofi', 'sad', 'happy'], 'lofi')); P.ambience(pick(c.ambience, ['forest', 'wind', 'rain', 'night', 'ocean', 'cave', 'crowd'], 'wind')); P.enable_bloom({ strength: 0.5, radius: 0.5, threshold: 1.05 }); var world = P.world, nature = P.nature, fx = P.fx; var seed = (num(c.seed, 31) | 0); var terrain = world.terrain({ seed: seed, amplitude: 1, scale: 240, chunk: 32, radius: 3 }); var prng = world._priv.mulberry32(((seed * 74747) ^ 0x9E3779B9) >>> 0); var gr = pal.ground_ramp || [pal.ground, pal.ground, pal.ground, pal.ground]; nature.wind({ strength: 0.4, gustiness: 0.4, direction: { x: 0.7, z: 0.4 } }); nature.grass_field({ area: 150, count: 16000, base: gr[1], tip: gr[3], height: 0.4, y: terrain.height_at }); nature.clouds({ count: 10, style: 'puffy' }); var LAPS = clamp(Math.round(num(c.laps, 3)), 1, 5); var NRIV = clamp(Math.round(num(c.rivals, 3)), 0, 5); var diff = pick(c.difficulty, ['easy', 'normal', 'hard'], 'normal'); var RIV_SPD = { easy: 0.82, normal: 0.9, hard: 0.97 }[diff]; // 플레이어 최고속 대비 var VMAX = clamp(num(c.speed_max, 26), 14, 40); var TRACK_W = 10; var SX = clamp(num(c.track_size, 1), 0.7, 1.5); // ── 파라메트릭 폐루프(라운드-렉트: 직선 L 2개 + 반원 R 2개) — t ∈ [0, TOTAL) ── var L = 70 * SX, R = 26 * SX; var TOTAL = 2 * L + 2 * Math.PI * R; // 폐루프 파라메트릭 정확식 — {x, z, hx, hz}(단위 접선). 서직선 북행 → 북반원 → 동직선 남행 → 남반원. function P_at(t) { t = ((t % TOTAL) + TOTAL) % TOTAL; if (t < L) return { x: -R, z: t - L / 2, hx: 0, hz: 1 }; t -= L; if (t < Math.PI * R) { var a = t / R; return { x: 0 - R * Math.cos(a), z: L / 2 + R * Math.sin(a), hx: Math.sin(a), hz: Math.cos(a) }; } t -= Math.PI * R; if (t < L) return { x: R, z: L / 2 - t, hx: 0, hz: -1 }; t -= L; var a2 = t / R; return { x: 0 + R * Math.cos(a2), z: -L / 2 - R * Math.sin(a2), hx: -Math.sin(a2), hz: -Math.cos(a2) }; } // 트랙 높이(경로 전체 hmax — 슬랩 캐논) var hmax = -Infinity; for (var st = 0; st < TOTAL; st += 6) { var pp0 = P_at(st); hmax = Math.max(hmax, world.height_at(pp0.x, pp0.z)); } var Y = hmax + 0.55; // 노면 슬랩(4m 조각) + 커브 연석 var road_mat = P.make.matte('#454a54', { roughness: 0.95 }); var curb_a = P.make.matte('#d94a3f', { roughness: 0.7 }); var curb_b = P.make.matte('#efece2', { roughness: 0.7 }); var kseg = 0; for (var t2 = 0; t2 < TOTAL; t2 += 4) { var p2 = P_at(t2 + 2); var m2 = new THREE_.Mesh(new THREE_.BoxGeometry(TRACK_W, 1.2, 4.6), road_mat); m2.position.set(p2.x, Y - 0.6, p2.z); m2.rotation.y = Math.atan2(p2.hx, p2.hz); scene.add(m2); var cb = new THREE_.Mesh(new THREE_.BoxGeometry(0.8, 0.1, 4.6), (kseg++ % 2) ? curb_a : curb_b); cb.position.set(p2.x - p2.hz * (TRACK_W / 2 + 0.4), Y + 0.05, p2.z + p2.hx * (TRACK_W / 2 + 0.4)); cb.rotation.y = m2.rotation.y; scene.add(cb); var cb2 = new THREE_.Mesh(new THREE_.BoxGeometry(0.8, 0.1, 4.6), (kseg % 2) ? curb_a : curb_b); cb2.position.set(p2.x + p2.hz * (TRACK_W / 2 + 0.4), Y + 0.05, p2.z - p2.hx * (TRACK_W / 2 + 0.4)); cb2.rotation.y = m2.rotation.y; scene.add(cb2); } // 출발 게이트(스타트/피니시 라인) var sl = new THREE_.Mesh(new THREE_.BoxGeometry(TRACK_W, 0.06, 1.2), P.make.matte('#f2f2f2', { roughness: 0.5 })); var p_start = P_at(0); sl.position.set(p_start.x, Y + 0.04, p_start.z); scene.add(sl); // ── 실 레이싱 프롭(테마팩 배선 — 카탈로그 race_ 76종: 배리어·배너·타이어스택). 미주입 시 없음. ── // 트랙 바깥(커브 너머)에만 배치 = 레이싱 라인 무간섭(collide:false, 순수 시각 드레싱). var _RACE_PROPS = (typeof window !== 'undefined' && window['__PLAYABLE_'+'EXTRA_MANIFEST__'] && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].stageprops && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].stageprops.items) || null; if (_RACE_PROPS && _RACE_PROPS.length) { var _rpi = 0, _roff = TRACK_W / 2 + 1.8; for (var _rt = 0; _rt < TOTAL; _rt += 22) { var _rp = P_at(_rt); _spawn_stage_prop(_RACE_PROPS[_rpi++ % _RACE_PROPS.length], _rp.x - _rp.hz * _roff, _rp.z + _rp.hx * _roff, Y, { collide: false, cap_h: 3.0 }); _spawn_stage_prop(_RACE_PROPS[_rpi++ % _RACE_PROPS.length], _rp.x + _rp.hz * _roff, _rp.z - _rp.hx * _roff, Y, { collide: false, cap_h: 3.0 }); } } // ── 차량(카탈로그 GLB 우선·프리미티브 폴백) ── var _extm = (typeof window !== 'undefined' && window['__PLAYABLE_'+'EXTRA_MANIFEST__'] && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].models) || {}; function _car_mesh(hex) { var g = new THREE_.Group(); var body = new THREE_.Mesh(new THREE_.BoxGeometry(1.5, 0.55, 2.9), P.make.matte(hex, { roughness: 0.5 })); body.position.y = 0.55; g.add(body); var top = new THREE_.Mesh(new THREE_.BoxGeometry(1.1, 0.42, 1.3), P.make.matte(_mix2(hex, '#ffffff', 0.25), { roughness: 0.4 })); top.position.set(0, 0.95, -0.1); g.add(top); var wm = P.make.matte('#22252c', { roughness: 0.9 }); [[-0.75, 0.95], [0.75, 0.95], [-0.75, -0.95], [0.75, -0.95]].forEach(function (w) { var wh = new THREE_.Mesh(new THREE_.CylinderGeometry(0.32, 0.32, 0.3, 12), wm); wh.rotation.z = Math.PI / 2; wh.position.set(w[0], 0.32, w[1]); g.add(wh); }); return g; } function _mix2(a, b, t) { function rgb(h) { var n = parseInt(h.slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } var A = rgb(a), B = rgb(b); function b2(v) { var s = Math.round(v).toString(16); return s.length < 2 ? '0' + s : s; } return '#' + b2(A[0] + (B[0] - A[0]) * t) + b2(A[1] + (B[1] - A[1]) * t) + b2(A[2] + (B[2] - A[2]) * t); } function _spawn_car(name_cfg, hex, at) { var g = new THREE_.Group(); var used_glb = false; if (typeof name_cfg === 'string' && _extm[name_cfg] && /car|truck|van|racer|kart|vehicle/i.test(name_cfg)) { var h2 = world.spawn(name_cfg, { at: { x: at.x, z: at.z }, collide: false }); if (h2 && h2.obj) { g = h2.obj; used_glb = true; } } if (!used_glb) { g = _car_mesh(hex); scene.add(g); } g.position.set(at.x, Y, at.z); return g; } // ── 상태(플레이어 + 라이벌) ── var CAR_HUES = ['#3f8fd8', '#d95763', '#7fb069', '#e0a13c', '#9b6bd4', '#3f9f8a']; var pt = 4, pspeed = 0, psteer = 0, pheading = Math.atan2(P_at(4).hx, P_at(4).hz); var pcar = _spawn_car(c.car, CAR_HUES[0], P_at(4)); var px = P_at(4).x, pz = P_at(4).z; var rivals = []; for (var rv = 0; rv < NRIV; rv++) { var rt0 = 10 + rv * 5; rivals.push({ t: rt0, mesh: _spawn_car(null, CAR_HUES[(rv + 1) % CAR_HUES.length], P_at(rt0)), lap: 0, spd: VMAX * RIV_SPD * (0.96 + prng() * 0.08) }); } var lap = 0, cp = 0, NCP = 4, ended = false, started = false, rank = 1; var steer_hold = 0; // -1 좌 · 0 · +1 우 // ── 입력: 홀드 스티어 버튼(◀ ▶) + 화살표 홀드 ── function _mk_steer(txt, side, val) { var b = document.createElement('button'); b.setAttribute('data-mgmt-ui', '1'); b.id = 'playable-race-' + (val < 0 ? 'left' : 'right'); b.textContent = txt; b.style.cssText = 'position:fixed;' + side + ':18px;bottom:calc(96px + env(safe-area-inset-bottom,0px));z-index:60;width:76px;height:76px;border-radius:50%;border:2px solid rgba(245,158,11,.5);background:rgba(28,33,48,.75);color:#ffd23e;font:800 28px system-ui;touch-action:manipulation;'; b.addEventListener('pointerdown', function (e) { e.preventDefault(); e.stopPropagation(); steer_hold = val; if (!started) _start(); }); ['pointerup', 'pointercancel', 'pointerleave'].forEach(function (ev) { b.addEventListener(ev, function () { if (steer_hold === val) steer_hold = 0; }); }); document.body.appendChild(b); return b; } _mk_steer('◀', 'left', -1); _mk_steer('▶', 'right', 1); window.addEventListener('keydown', function (e) { if (e.key === 'ArrowLeft') { steer_hold = -1; if (!started) _start(); e.preventDefault(); } else if (e.key === 'ArrowRight') { steer_hold = 1; if (!started) _start(); e.preventDefault(); } else if (e.key === 'ArrowUp' && !started) _start(); }); window.addEventListener('keyup', function (e) { if (e.key === 'ArrowLeft' && steer_hold === -1) steer_hold = 0; if (e.key === 'ArrowRight' && steer_hold === 1) steer_hold = 0; }); // ── HUD·시작·종결 ── var ui = _arch_hud(String(c.title || _at_t('서킷 레이스', 'サーキットレース', 'Circuit Race')).slice(0, 40)); function _hud() { ui.children[1].textContent = '🏁 ' + Math.min(lap + 1, LAPS) + '/' + LAPS + ' · ' + rank + _at_t('위', '位', (rank === 1 ? 'st' : rank === 2 ? 'nd' : rank === 3 ? 'rd' : 'th')); ui.children[2].textContent = '🚗 ' + Math.round(pspeed * 3.6) + 'km/h'; } _hud(); var gate2 = document.createElement('div'); gate2.setAttribute('data-mgmt-ui', '1'); gate2.style.cssText = 'position:fixed;inset:0;z-index:59;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:10px;background:rgba(8,10,16,.42);color:#fff;font:700 16px system-ui;text-align:center;'; gate2.innerHTML = '
🏁
' + String(c.hint || _at_t('◀ ▶ 홀드로 조향 · 가속은 자동
탭하여 출발!', '◀▶長押しでハンドル・加速は自動
タップでスタート!', 'Hold ◀ ▶ to steer · auto-throttle
Tap to start!')).slice(0, 120) + '
'; document.body.appendChild(gate2); gate2.addEventListener('pointerdown', function (ev) { ev.preventDefault(); _start(); }); function _start() { if (started || ended) return; started = true; gate2.remove(); fx.sfx('powerup'); A._emit('start', {}); } function _finish() { if (ended) return; ended = true; var won = rank === 1; if (won) { P.music('triumph'); fx.screen_flash(pal.key); } P.toast(String(c.complete_text || _at_t(rank + '위로 완주! 🏁', rank + '位でゴール! 🏁', 'Finished ' + rank + '! 🏁')).slice(0, 60)); A._emit('complete', { rank: rank, laps: LAPS, won: won }); P.leaderboard.submit(rank); setTimeout(function () { P.three.capture().then(function (shot) { P.share_shot(shot, { title: String(c.title || _at_t('서킷 레이스', 'サーキットレース', 'Circuit Race')).slice(0, 40), lines: ['🏁 ' + lap + '/' + LAPS + ' · ' + rank + _at_t('위', '位', '')], share_text: String(c.share_text || _at_t('서킷 ' + rank + '위 완주! 🏁', 'サーキット' + rank + '位! 🏁', 'Circuit finish: ' + rank + '! 🏁')).slice(0, 120), }); }); }, 900); } // ── 레이스 루프(플레이어 아케이드 운동학 + 라이벌 파람 추종 + 랩/순위) ── var _cam = P.three.camera; function _nearest_t(x, z, t_hint) { // 경로-파람 근사(힌트 주변 지역 탐색 — 전역 스캔 불요) var best = t_hint, bd = Infinity; for (var dtq = -8; dtq <= 8; dtq += 1) { var tq = t_hint + dtq; var q = P_at(tq); var dd = (q.x - x) * (q.x - x) + (q.z - z) * (q.z - z); if (dd < bd) { bd = dd; best = tq; } } return { t: ((best % TOTAL) + TOTAL) % TOTAL, d: Math.sqrt(bd) }; } var _pt_hint = 4, cps_hit = []; P.loop(function (dt) { if (dt <= 0 || ended) return; dt = Math.min(dt, 0.1); if (started) { // 조향·전진(자동 가속) pheading += steer_hold * 1.9 * dt * (0.55 + 0.45 * Math.min(1, pspeed / (VMAX * 0.5))); var near = _nearest_t(px, pz, _pt_hint); _pt_hint = near.t; var off = near.d > TRACK_W / 2 + 0.6; var vtarget = off ? VMAX * 0.35 : VMAX; pspeed += (vtarget - pspeed) * Math.min(1, dt * (pspeed < vtarget ? 0.9 : 2.2)); px += Math.sin(pheading) * pspeed * dt; pz += Math.cos(pheading) * pspeed * dt; // 랩/체크포인트(경로-파람 통과 판정) var cpi = Math.floor(near.t / (TOTAL / NCP)); if (cps_hit.indexOf(cpi) < 0 && near.d < TRACK_W) { // 순서 강제: 다음 기대 cp 만 인정(역주행 랩 카운트 차단) if (cpi === cps_hit.length % NCP) { cps_hit.push(cpi); if (cpi > 0) A._emit('checkpoint', { cp: cpi, lap: lap + 1 }); } if (cps_hit.length === NCP) { cps_hit = []; lap++; fx.sfx('powerup', { volume: 0.5 }); A._emit('lap', { lap: lap, laps: LAPS }); if (lap >= LAPS) { _finish(); return; } } } } pcar.position.set(px, Y, pz); pcar.rotation.y = pheading; // 라이벌(파람 추종 + 간단 러버밴딩) var ahead = 0; for (var rvi = 0; rvi < rivals.length; rvi++) { var rvr = rivals[rvi]; if (started) { var rb2 = 1 + clamp(((lap * TOTAL + _pt_hint) - (rvr.lap * TOTAL + rvr.t)) / TOTAL * 0.12, -0.08, 0.08); rvr.t += rvr.spd * rb2 * dt; if (rvr.t >= TOTAL) { rvr.t -= TOTAL; rvr.lap++; } } var rp = P_at(rvr.t); rvr.mesh.position.set(rp.x + Math.cos(rvi) * 1.6, Y, rp.z + Math.sin(rvi) * 1.2); rvr.mesh.rotation.y = Math.atan2(rp.hx, rp.hz); if (rvr.lap * TOTAL + rvr.t > lap * TOTAL + _pt_hint) ahead++; } rank = 1 + ahead; // 추격 카메라(전유 — 아바타 없음) var camd = 9.5, camh = 4.6; _cam.position.set(px - Math.sin(pheading) * camd, Y + camh, pz - Math.cos(pheading) * camd); _cam.lookAt(px, Y + 1.1, pz); _hud(); }); P.mount_share(function () { return String(c.share_text || _at_t('서킷 레이스 ' + rank + '위 🏁', 'サーキット' + rank + '位 🏁', 'Circuit race: rank ' + rank + ' 🏁')).slice(0, 120); }, String(c.share_label || _at_t('공유', '共有', 'Share')).slice(0, 12)); P.leaderboard.enable({ metric: 'rank', unit: '위', max: 6, higher_better: false, min_play_s: 6 }); A._installed = 'race_circuit'; return { get lap() { return lap; }, laps: LAPS, get rank() { return rank; }, get speed() { return pspeed; }, get started() { return started; }, get ended() { return ended; }, _priv: { P_at: P_at, TOTAL: TOTAL, get t() { return _pt_hint; }, rivals: rivals, start: _start, set_pos: function (x, z, hd) { px = x; pz = z; if (hd != null) pheading = hd; }, get heading() { return pheading; }, get pos() { return { x: px, z: pz }; }, Y: Y, }, }; }; // ═══ 아키타입 #18: horror_escape — 호러 방탈출(완비·골든-테스트 대상) ═══ // 정본: Roblox 모바일 수요 상위 장르(호러·이스케이프)의 결정론 구현. ⚠️ 주간-시작 정책의 명시 // 예외 — 어둠은 이 장르의 정체성(가독성은 플레이어 손전등 스팟 + 비상등·출구 사인 글로우가 // 소유). 루프: 시설의 수색 지점(캐비닛)을 interact 로 뒤져 퓨즈 N개 확보 → 출구 전원 복구 → // 탈출 = 승리. 스토커 1기가 배회(근접 시 추격 — 잡히면 정직한 실패 + 재도전). 점프스케어 = // 근접 스팅어(sfx+플래시+셰이크, 결정론 쿨다운). _ARCH.horror_escape = function (cfg) { var c = cfg || {}; // 어둠 예외(장르 정체성) — 단, 미지 무드는 밤으로 흡수 var mood_name = pick(c.mood === 'night' ? 'night_neon' : c.mood, P.moods || [], 'night_neon'); var pal = P.mood(mood_name); P.music(pick(c.music, ['calm_exploration', 'adventure', 'cozy', 'mystery', 'night', 'chiptune_action', 'battle', 'triumph', 'space', 'spooky', 'lofi', 'sad', 'happy'], 'lofi')); P.ambience(pick(c.ambience, ['forest', 'wind', 'rain', 'night', 'ocean', 'cave', 'crowd'], 'night')); P.enable_bloom({ strength: 0.7, radius: 0.55, threshold: 0.95 }); var world = P.world, fx = P.fx; var seed = (num(c.seed, 66) | 0); var terrain = world.terrain({ seed: seed, amplitude: 1, scale: 240, chunk: 32, radius: 3 }); var prng = world._priv.mulberry32(((seed * 92821) ^ 0x9E3779B9) >>> 0); var FUSES = clamp(Math.round(num(c.fuses, 3)), 2, 5); var NCAB = clamp(Math.round(num(c.cabinets, 8)), FUSES + 2, 14); // ── 시설(격자 방 3×2 + 내부 통로 — 던전과 다른 정체성: 한 채의 어두운 건물) ── var W_B = 46, D_B = 34; // 건물 외곽 var hmax = -Infinity; for (var hx = -W_B / 2; hx <= W_B / 2; hx += 6) for (var hz = -D_B / 2; hz <= D_B / 2; hz += 6) hmax = Math.max(hmax, world.height_at(hx, hz)); var Y = hmax + 0.55; var wall_mat = P.make.matte('#2b2e38', { roughness: 0.95 }); wall_mat.vertexColors = true; // _q_shade AO(공유 머티리얼) var floor_mat = P.make.matte('#3a3d47', { roughness: 0.95 }); floor_mat.vertexColors = true; (function () { var fl = new THREE_.Mesh(new THREE_.BoxGeometry(W_B + 2, 1.4, D_B + 2, 8, 1, 8), floor_mat); fl.position.set(0, Y - 0.7, 0); _q_shade(fl, 0.6, 'xz'); scene.add(fl); // 바닥 가장자리 AO var ceil = new THREE_.Mesh(new THREE_.BoxGeometry(W_B + 2, 0.4, D_B + 2), wall_mat); ceil.position.set(0, Y + 3.4, 0); scene.add(ceil); // 시설 바닥 = 지형 필드에 편입(단층 평탄면). 구 ground_ext 제공자는 플레이어 물리만 소비해 // 카메라·몹·소품 등 나머지 height_at 소비자가 바닥 아래로 샜다. world.floor_plane({ x0: -W_B / 2 - 0.5, x1: W_B / 2 + 0.5, z0: -D_B / 2 - 0.5, z1: D_B / 2 + 0.5, y: Y }); })(); var WH = 3.2; function hwall(x, z, w2, d2, gap_axis, gap_c) { // gap = 문 구멍(폭 2.6) var segs2 = []; if (gap_c == null) segs2.push(gap_axis === 'x' ? [x - w2 / 2, x + w2 / 2] : [z - d2 / 2, z + d2 / 2]); else { var a0 = gap_axis === 'x' ? x - w2 / 2 : z - d2 / 2, a1 = gap_axis === 'x' ? x + w2 / 2 : z + d2 / 2; segs2.push([a0, gap_c - 1.3]); segs2.push([gap_c + 1.3, a1]); } for (var s3 = 0; s3 < segs2.length; s3++) { var a = segs2[s3][0], b = segs2[s3][1]; for (var q = a; q < b - 0.05; q += 4) { var len = Math.min(4, b - q); var m = new THREE_.Mesh(gap_axis === 'x' ? new THREE_.BoxGeometry(len, WH, 0.6, 1, 5, 1) : new THREE_.BoxGeometry(0.6, WH, len, 1, 5, 1), wall_mat); m.position.set(gap_axis === 'x' ? q + len / 2 : x, Y + WH / 2, gap_axis === 'x' ? z : q + len / 2); _q_shade(m, 0.58, 'y'); // 벽 세로 AO(하단 어둡게 — 무드에 깊이) scene.add(m); _q_baseboard(m.position.x, m.position.z, gap_axis === 'x' ? len / 2 : 0.3, gap_axis === 'x' ? 0.3 : len / 2, Y, '#1c1f26'); // 걸레받이(어두운 톤 — 무드 유지) world._priv.collider_box({ x: m.position.x, z: m.position.z, hx: gap_axis === 'x' ? len / 2 + 0.1 : 0.4, hz: gap_axis === 'x' ? 0.4 : len / 2 + 0.1, y0: Y - 3, y1: Y + WH }); // y0 -3: 언더슬립 차단 } } } // 외벽(남쪽 중앙 = 출구문 구멍) + 내부 십자 벽(문 구멍 4) hwall(0, -D_B / 2, W_B, 0, 'x', 0); // 남(출구) hwall(0, D_B / 2, W_B, 0, 'x', null); // 북 hwall(-W_B / 2, 0, 0, D_B, 'z', null); // 서 hwall(W_B / 2, 0, 0, D_B, 'z', null); // 동 hwall(0, 0, W_B, 0, 'x', -W_B / 4); // 중앙 가로벽(서쪽 문) hwall(-W_B / 6, D_B / 4, 0, D_B / 2, 'z', D_B / 4); // 북측 세로벽(문) hwall(W_B / 6, -D_B / 4, 0, D_B / 2, 'z', -D_B / 4); // 남측 세로벽(문) // 비상등(붉은 글로우 — 최소 가독 앵커) + 출구 사인 var EMER = [[-W_B / 3, -D_B / 3], [W_B / 3, D_B / 3], [-W_B / 3, D_B / 3], [W_B / 3, -D_B / 3], [0, 0]]; for (var ei = 0; ei < EMER.length; ei++) { var em = new THREE_.Mesh(new THREE_.SphereGeometry(0.13, 8, 7), P.make.glow('#d94a3f', 1.4)); em.position.set(EMER[ei][0], Y + 2.9, EMER[ei][1]); scene.add(em); fx.pulse(em, { amount: 0.3, rate: 0.9 }); } var exit_sign = new THREE_.Mesh(new THREE_.BoxGeometry(1.6, 0.5, 0.12), P.make.glow('#3ddc84', 1.5)); exit_sign.position.set(0, Y + 2.9, -D_B / 2 + 0.5); scene.add(exit_sign); // ── 실 공포 프롭(테마팩 배선 — 카탈로그 spooky_/grave_ 154종). 미주입 시 프롭 없이 시설 유지. ── // 동·서·북 3벽 둘레에만 배치(남측 출구·중앙 이동로·추격 회피). 콜라이더로 실 장애물화(긴장감). var _HOR_PROPS = (typeof window !== 'undefined' && window['__PLAYABLE_'+'EXTRA_MANIFEST__'] && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].stageprops && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].stageprops.items) || null; if (_HOR_PROPS && _HOR_PROPS.length) { var _hpi = 0, _hw2 = W_B / 2 - 2.2, _hd2 = D_B / 2 - 2.2, _han = []; for (var ht = 0; ht < 4; ht++) { var _hf = (ht + 0.5) / 4; _han.push([-_hw2, -_hd2 + _hf * 2 * _hd2]); // 서벽 _han.push([_hw2, -_hd2 + _hf * 2 * _hd2]); // 동벽 _han.push([-_hw2 + _hf * 2 * _hw2, _hd2]); // 북벽 } for (var _hq = 0; _hq < _han.length; _hq++) { _spawn_stage_prop(_HOR_PROPS[_hpi++ % _HOR_PROPS.length], _han[_hq][0] + (prng() - 0.5) * 2, _han[_hq][1] + (prng() - 0.5) * 2, Y, { cap_h: 2.6 }); } } // ── 플레이어 + 손전등(장르 가독성의 소유자) ── var _extm = (typeof window !== 'undefined' && window['__PLAYABLE_'+'EXTRA_MANIFEST__'] && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].models) || {}; var CHARS = ['adventurer', 'rogue', 'villager', 'worker']; var _psel = typeof c.player === 'string' && (_extm[c.player] && _extm[c.player].cat === 'character') ? c.player : pick(c.player, CHARS, 'adventurer'); var player = world.player({ spawn: _psel, at: { x: W_B / 3, z: D_B / 3 }, speed: 3.8, run_speed: 6.2, hp: 2, on_player_death: function () { _caught(); } }); world._priv.player().handle.obj.position.y = Y + 0.2; // 스폰 = 시설 바닥(이중공간 클래스 봉합) world.camera(); world.controls({ mission: String(c.hint || _at_t('퓨즈 ' + FUSES + '개를 찾아 전원을 복구하고 탈출하라!', 'ヒューズを' + FUSES + '本見つけて脱出しろ!', 'Find ' + FUSES + ' fuses, restore power, escape!')).slice(0, 80) }); var flash = new THREE_.SpotLight(0xfff2d8, 2.2, 26, 0.42, 0.5, 1.2); var cam = P.three.camera; scene.add(flash); scene.add(flash.target); // ── 수색 지점(캐비닛) — interact 로 개봉, K개에 퓨즈(시드 결정론) ── var cab_mat = P.make.matte('#4a4030', { roughness: 0.85 }); var CABS = []; var fuse_slots = []; while (fuse_slots.length < FUSES) { var fi = Math.floor(prng() * NCAB); if (fuse_slots.indexOf(fi) < 0) fuse_slots.push(fi); } var fuses = 0, ended = false, powered = false; for (var cbi = 0; cbi < NCAB; cbi++) { (function (idx) { var cx = (prng() - 0.5) * (W_B - 6), cz = (prng() - 0.5) * (D_B - 6); var g = new THREE_.Group(); var body = new THREE_.Mesh(new THREE_.BoxGeometry(1.1, 1.9, 0.7), cab_mat); body.position.y = 0.95; g.add(body); var door = new THREE_.Mesh(new THREE_.BoxGeometry(0.95, 1.7, 0.08), P.make.matte('#5c5040', { roughness: 0.7 })); door.position.set(0, 0.95, 0.38); g.add(door); g.position.set(cx, Y, cz); g.rotation.y = prng() * Math.PI * 2; scene.add(g); world.collider(cx, cz, 0.8); var opened = false; var it = world.interactable(g, { radius: 2.6, label: _at_t('뒤지기', '調べる', 'Search'), on_interact: function () { if (opened || ended) return; opened = true; door.rotation.y = -1.9; // 문 개방 연출 door.position.x = -0.55; var has = fuse_slots.indexOf(idx) >= 0; if (has) { fuses++; fx.sfx('powerup', { volume: 0.6 }); fx.burst({ x: cx, y: Y + 1.4, z: cz }, { count: 12, color: '#ffd24a', speed: 2, life: 0.8 }); P.toast('🔋 ' + fuses + ' / ' + FUSES); A._emit('fuse', { fuses: fuses, need: FUSES }); _hud(); if (fuses >= FUSES) _power_on(); } else { fx.sfx('blip', { volume: 0.4 }); P.toast(_at_t('비어 있다…', '空っぽだ…', 'Empty…')); } A._emit('search', { found: has }); it.remove(); }, }); CABS.push({ x: cx, z: cz, idx: idx, has: fuse_slots.indexOf(idx) >= 0 }); })(cbi); } // ── 출구문(전원 게이트) ── var exit_door = new THREE_.Mesh(new THREE_.BoxGeometry(2.6, WH, 0.5), P.make.matte('#6b1f1f', { roughness: 0.6 })); exit_door.position.set(0, Y + WH / 2, -D_B / 2); scene.add(exit_door); var exit_col = world._priv.collider_box({ x: 0, z: -D_B / 2, hx: 1.3, hz: 0.4, y0: Y - 1, y1: Y + WH }); var door_it = null; function _power_on() { if (powered) return; powered = true; fx.screen_flash('#3ddc84'); fx.sfx('powerup'); P.toast(_at_t('⚡ 전원 복구 — 출구가 열린다!', '⚡ 電源復旧 — 出口が開く!', '⚡ Power restored — the exit unlocks!')); A._emit('power', {}); door_it = world.interactable(exit_door, { radius: 3, label: _at_t('탈출!', '脱出!', 'Escape!'), on_interact: function () { _escape(); }, }); } function _escape() { if (ended || !powered) return; ended = true; scene.remove(exit_door); world._priv.collider_box_remove(exit_col); P.music('triumph'); fx.screen_flash('#3ddc84'); P.toast(String(c.complete_text || _at_t('탈출 성공! 🌙', '脱出成功! 🌙', 'You escaped! 🌙')).slice(0, 60)); A._emit('complete', { won: true, fuses: fuses, searches: NCAB }); setTimeout(function () { P.three.capture().then(function (shot) { P.share_shot(shot, { title: String(c.title || _at_t('심야 탈출', '深夜の脱出', 'Midnight Escape')).slice(0, 40), lines: ['🌙 ' + _at_t('탈출 성공', '脱出成功', 'Escaped') + ' · 🔋 ' + fuses], share_text: String(c.share_text || _at_t('심야의 시설에서 탈출했다! 🌙', '深夜の施設から脱出した! 🌙', 'Escaped the midnight facility! 🌙')).slice(0, 120), }); }); }, 900); } function _caught() { if (ended) return; ended = true; fx.screen_flash('#d24a4a'); fx.sfx('explosion', { volume: 0.4 }); P.toast(_at_t('잡혔다… 다시 도전!', '捕まった… もう一度!', 'Caught… try again!')); A._emit('complete', { won: false, fuses: fuses }); var rb = document.createElement('button'); rb.setAttribute('data-mgmt-ui', '1'); rb.textContent = '↻ ' + _at_t('다시 도전', 'もう一度', 'Try again'); rb.style.cssText = 'position:fixed;left:50%;transform:translateX(-50%);bottom:calc(120px + env(safe-area-inset-bottom,0px));z-index:70;padding:13px 22px;border-radius:999px;border:2px solid rgba(245,158,11,.6);background:rgba(28,33,48,.9);color:#ffd23e;font:800 15px system-ui;'; rb.addEventListener('pointerdown', function () { try { location.reload(); } catch (e) {} }); document.body.appendChild(rb); } // ── 스토커(느린 배회 → 근접 추격) + 점프스케어 스팅어 ── var stalker = world.mob(pick(c.stalker_model, ['rogue', 'barbarian', 'robot'], 'rogue'), { at: { x: -W_B / 3, z: -D_B / 3 }, behavior: 'melee', hp: 99, damage: 1, aggro: clamp(num(c.stalker_aggro, 7), 4, 12), leash: 200, speed: 0.5, cooldown: 3, }); var _sting_cd = 0; var ui = _arch_hud(String(c.title || _at_t('심야 탈출', '深夜の脱出', 'Midnight Escape')).slice(0, 40)); function _hud() { ui.children[1].textContent = '🔋 ' + fuses + '/' + FUSES; ui.children[2].textContent = powered ? _at_t('🚪 열림!', '🚪 開!', '🚪 Open!') : _at_t('🚪 잠김', '🚪 封鎖', '🚪 Locked'); } _hud(); P.loop(function (dt) { if (dt <= 0) return; dt = Math.min(dt, 0.1); // 손전등 = 카메라 추종(시선 방향 26m 콘) flash.position.copy(cam.position); var dv = new THREE_.Vector3(); cam.getWorldDirection(dv); flash.target.position.set(cam.position.x + dv.x * 12, cam.position.y + dv.y * 12, cam.position.z + dv.z * 12); if (ended) return; // 점프스케어 스팅어(근접 결정론 쿨다운 12s) _sting_cd -= dt; if (stalker && _sting_cd <= 0) { var pp = player.pos; var dx = stalker.pos.x - pp.x, dz = stalker.pos.z - pp.z; var d = Math.sqrt(dx * dx + dz * dz); if (d < 6.5) { _sting_cd = 12; fx.sfx('explosion', { volume: 0.25 }); fx.shake(0.45); fx.screen_flash('#1a0b0b'); A._emit('scare', { distance: d }); } } }); P.mount_share(function () { return String(c.share_text || _at_t('심야 탈출 🔋' + fuses + '/' + FUSES, '深夜の脱出 🔋' + fuses + '/' + FUSES, 'Midnight escape 🔋' + fuses + '/' + FUSES)).slice(0, 120); }, String(c.share_label || _at_t('공유', '共有', 'Share')).slice(0, 12)); A._installed = 'horror_escape'; return { player: player, get fuses() { return fuses; }, fuses_need: FUSES, get powered() { return powered; }, get ended() { return ended; }, cabinets: NCAB, _priv: { cabs: CABS, stalker: stalker, power_on: _power_on, escape: _escape, Y: Y }, }; }; // ═══ 아키타입 #19: creature_quest — 위치기반 크리처 수집 (geowalk 탐색 → arcam 조우) ═══ // Canon (2026-07-21 GO-genre research): explore phase = real-world walking (geowalk cells = // deterministic serverless spawns, radar, walk HUD) → encounter phase = camera-AR aim-hold // capture (arcam anchor at a spawn offset yaw — the GO-basic-mode spawn-ahead grammar). // INDOOR mode (2026-07-23): the shell start card offers an equal-weight indoor/outdoor choice; // indoor (= geowalk 'virtual' ready, incl. GPS-denied/desktop degrades) is a walk-free 360° // camera hunt — K creatures anchored around the player in yaw slots, revealed by turning the // camera, captured by the same aim-hold scan, auto-respawning until the book completes. // Capture verb = AIM-HOLD SCAN by design, never a thrown ball-item: the throw-to-capture shape // is the exact claim surface of the Nintendo capture patents (JP7545191 family, live litigation // 2026) and of the red/white ball trade dress — the scan verb is the Jurassic-World-Alive-class // safe grammar. IP note: species are procedural primitive creatures with config-themed names — // never Pokémon-universe names/designs (composer-side IP note enforces the vocabulary). // Collection persists via localStorage (play-origin serving contract; opaque-origin contexts // degrade to session-memory gracefully). Session honesty: distance is screen-on session // distance (geowalk canon) — no background progress is promised anywhere. _ARCH.creature_quest = function (cfg) { var c = cfg || {}; var arcam = P.arcam, geowalk = P.geowalk, fx = P.fx; if (!arcam || !geowalk) { warn('creature_quest requires arcam+geowalk shells'); return null; } var mood_name = pick(c.mood, P.moods || [], 'noon'); P.mood(mood_name); P.music('lofi'); // 상시 lofi(2026-07-25 사용자 확정 '잔잔 유지 — 근본 해결') — 교전 무드 전환 폐지, c.music 의도적 미소비 P.ambience(pick(c.ambience, ['forest', 'wind', 'rain', 'night', 'ocean', 'cave', 'crowd'], 'forest')); // ── species config (Tier B: 전 필드 기본값·클램프 — 어떤 입력에도 동작) ── var TIERS = ['common', 'rare', 'epic']; var species = []; var sp_in = Array.isArray(c.species) ? c.species.slice(0, 10) : []; for (var si = 0; si < sp_in.length; si++) { var s0 = sp_in[si] || {}; species.push({ name: String(s0.name || 'Creature ' + (si + 1)).slice(0, 24), color: /^#[0-9a-fA-F]{6}$/.test(String(s0.color || '')) ? s0.color : '#7cd97c', tier: pick(s0.tier, TIERS, 'common'), model: typeof s0.model === 'string' && s0.model ? String(s0.model).slice(0, 48) : null, // 카탈로그 실물 캐릭터(선택 — 부재/실패 시 절차 폴백) pts: typeof s0.pts === 'number' && isFinite(s0.pts) ? clamp(Math.round(s0.pts), 1, 200) : null, // 종 점수(선택 — 미지정 = 티어 결정론) }); } if (species.length < 2) { species = [ { name: _at_t('이끼뭉치', 'コケだま', 'Mosspuff'), color: '#7cd97c', tier: 'common' }, { name: _at_t('조약돌리', 'コロイシ', 'Pebblit'), color: '#b9a98c', tier: 'common' }, { name: _at_t('반딧불이', 'ホタルン', 'Glimmerbug'), color: '#ffd24a', tier: 'common' }, { name: _at_t('이슬방울', 'シズクン', 'Dewdrop'), color: '#6fc3ff', tier: 'rare' }, { name: _at_t('구름송이', 'クモワタ', 'Cloudlet'), color: '#e8e8f4', tier: 'rare' }, { name: _at_t('별조각', 'ホシカケ', 'Starshard'), color: '#c98cff', tier: 'epic' }, ]; } // ── 자동 로스터 확장(≥50 등장 계약, 2026-07-24) — 생성기 hunt 표본으로 도감 자동 증원 ── // 이름 = 결정론 음절 합성(ko/ja/en) · 티어 = 65/30/5 · 색 = 고정 팔레트. 저작 종이 항상 도감 앞줄. var ROSTER = clamp(Math.round(num(c.roster, 50)), Math.max(2, species.length), 60); (function () { var hunt = (window['__PLAYABLE_'+'EXTRA_MANIFEST__'] && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].hunt && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].hunt.names) || []; if (!hunt.length) return; var used = {}; for (var ui = 0; ui < species.length; ui++) if (species[ui].model) used[species[ui].model] = 1; var s5 = ((((num(c.seed, 7) | 0)) ^ 0x9e3779b9) >>> 0) || 7; function rng() { s5 = (s5 * 1664525 + 1013904223) >>> 0; return s5 / 4294967296; } var SYL = _at_lang === 0 ? ['모', '포', '루', '치', '베', '도', '라', '미', '보', '쿠', '피', '노', '가', '토', '무', '리'] : (_at_lang === 1 ? ['モ', 'ポ', 'ル', 'チ', 'ベ', 'ド', 'ラ', 'ミ', 'ボ', 'ク', 'ピ', 'ノ', 'ガ', 'ト', 'ム', 'リ'] : ['Mo', 'Po', 'Lu', 'Chi', 'Be', 'Do', 'Ra', 'Mi', 'Bo', 'Ku', 'Pi', 'No', 'Ga', 'To', 'Mu', 'Ri']); var SUF = _at_lang === 0 ? ['몬', '링', '콩', '비', '츠', '푸'] : (_at_lang === 1 ? ['モン', 'リン', 'コン', 'ビ', 'ツ', 'プ'] : ['mon', 'ling', 'kong', 'bee', 'tz', 'pu']); var PAL = ['#8fd9a8', '#f2a65a', '#7cc95e', '#ffd447', '#7a6bd6', '#6fc3ff', '#ff8a7c', '#c98cff', '#8ce8d8', '#f0b0d0', '#b8e07c', '#e8c890']; var names_used = {}; for (var s6 = 0; s6 < species.length; s6++) names_used[species[s6].name] = 1; // 종 로어(2026-07-25 스토리텔링) — 생성기가 매칭 픽에 동봉한 실명·역사/특징/성격/주의([ko,ja,en]). // 로어 보유 종 = 실명(변종 수식어 포함, 동명 충돌은 로마숫자 dedup), 미보유 종 = 기존 음절 합성. var hunt_lore = (window['__PLAYABLE_'+'EXTRA_MANIFEST__'] && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].hunt && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].hunt.lore) || {}; var ROMAN9 = [' Ⅱ', ' Ⅲ', ' Ⅳ', ' Ⅴ', ' Ⅵ']; for (var hi = 0; hi < hunt.length && species.length < ROSTER; hi++) { var mdl = hunt[hi]; if (used[mdl]) continue; used[mdl] = 1; var lr9 = hunt_lore[mdl] || null; var nm5 = '', guard = 0; if (lr9 && lr9.name) { nm5 = String(lr9.name[_at_lang] || lr9.name[2] || ''); for (var rn9 = 0; names_used[nm5] && rn9 < ROMAN9.length; rn9++) nm5 = String(lr9.name[_at_lang] || lr9.name[2]) + ROMAN9[rn9]; } if (!nm5 || names_used[nm5]) { do { nm5 = SYL[(rng() * SYL.length) | 0] + SYL[(rng() * SYL.length) | 0] + SUF[(rng() * SUF.length) | 0]; } while (names_used[nm5] && ++guard < 40); } if (names_used[nm5]) continue; names_used[nm5] = 1; var rr6 = rng(); species.push({ name: nm5, model: mdl, lore: lr9, color: PAL[(rng() * PAL.length) | 0], tier: rr6 < 0.05 ? 'epic' : (rr6 < 0.35 ? 'rare' : 'common'), }); } })(); if (!species.some(function (s) { return s.tier === 'common'; })) species[0].tier = 'common'; // ── 종 점수/기질(2026-07-24 사용자 확정) — 점수 높은 종 = 이동 매우 빠름 + 자주 도망 ── // pts: common 8~14 · rare 20~30 · epic 50~70(결정론, config pts 오버라이드 허용). agil 0..1 = 속도/도주 축. (function () { var s7 = (((num(c.seed, 7) | 0)) ^ 0x51ab) >>> 0 || 3; function r7() { s7 = (s7 * 1664525 + 1013904223) >>> 0; return s7 / 4294967296; } for (var pi2 = 0; pi2 < species.length; pi2++) { var spp = species[pi2]; var t7 = spp.tier === 'epic' ? 2 : (spp.tier === 'rare' ? 1 : 0); if (spp.pts == null) spp.pts = [8, 20, 50][t7] + Math.round(r7() * [6, 10, 20][t7]); spp.agil = clamp((spp.pts - 8) / 62, 0, 1); } })(); // ── 로어 전수 보장(2026-07-25 사용자 확정 "모든 몬스터는 반드시 스토리 보유") ── // 큐레이션(생성기 hunt.lore) 없는 종 = 결정론 합성 로어. 유래/성격은 세계관 목격담 톤 풀에서 // 이름 해시로 선택, 특징/주의는 실제 게임플레이 성질(tier·agil)에서 유도 — 스토리가 곧 공략 힌트라 // 어떤 종(저작·합성·미래 에셋)에도 정직하게 성립. 구조적으로 로어 부재 종이 존재 불가. (function () { var LH9 = [ ['이 일대에서 오래전부터 목격담이 이어져 온 수수께끼의 존재다.', 'この一帯で昔から目撃談が続く謎の存在。', 'A mystery sighted in these parts for as long as anyone remembers.'], ['탐험가들의 기록 속에 이름만 전해지던 희귀한 존재다.', '探検家の記録に名前だけが残る希少な存在。', 'Known only by name in old explorers’ journals.'], ['별이 밝은 밤에만 모습을 드러낸다는 소문이 전해진다.', '星の明るい夜にだけ姿を見せるという噂がある。', 'Rumor says it appears only on star-bright nights.'], ['깊은 숲의 수호자라 불리며 지역 전설로 전해져 왔다.', '深い森の守り手と呼ばれ、土地の伝説として語り継がれてきた。', 'Local legend calls it the keeper of the deep woods.'], ['어느 날 갑자기 나타나 연구자들을 놀라게 한 신종이다.', 'ある日突然現れ、研究者たちを驚かせた新種。', 'A new species that appeared overnight and stunned researchers.'], ]; var LP9 = [ ['호기심이 많아 낯선 것을 그냥 지나치지 못한다.', '好奇心旺盛で、見慣れないものを素通りできない。', 'Too curious to ever walk past something new.'], ['겁이 많지만 무리 앞에서는 용감한 척을 한다.', '臆病だが、仲間の前では勇敢なふりをする。', 'Timid at heart — brave only in front of its herd.'], ['장난기가 넘쳐 사냥꾼을 놀리듯 움직인다.', 'いたずら好きで、ハンターをからかうように動く。', 'A prankster that seems to toy with hunters.'], ['조용한 성격으로, 먼저 건드리지 않으면 온순하다.', '物静かで、こちらから触れなければ温厚。', 'Quiet and gentle — unless you start it.'], ['자기 영역에 대한 자부심이 강해 침입자를 오래 기억한다.', '縄張りへの誇りが強く、侵入者を長く覚えている。', 'Fiercely territorial — it remembers every trespasser.'], ]; function lh9(nm) { var h9 = 0; for (var ci9 = 0; ci9 < nm.length; ci9++) h9 = (h9 * 131 + nm.charCodeAt(ci9)) >>> 0; return h9; } for (var li9 = 0; li9 < species.length; li9++) { var sl9 = species[li9]; if (sl9.lore) continue; var hs9 = lh9(String(sl9.name)); var f9 = sl9.tier === 'epic' ? ['극히 드물게 나타나는 전설급 개체 — 마주치는 것 자체가 행운이다.', '極めて稀に現れる伝説級の個体 — 出会えること自体が幸運。', 'A legendary-class specimen — meeting one is luck itself.'] : (sl9.tier === 'rare' ? ['보통 개체보다 한층 영리하고 경계심이 강한 희귀종이다.', '通常の個体より賢く、警戒心の強い希少種。', 'A rare breed — smarter and warier than the common kind.'] : ['이 지역 생태계에 폭넓게 적응한 친근한 종이다.', 'この地域の生態系に幅広く適応した親しみやすい種。', 'A friendly species well-adapted to this region.']); var w9l = sl9.agil >= 0.66 ? ['매우 빠르고 자주 도망친다 — 발견 즉시 연사하고, 빙결 아이템이 특효다.', '非常に速く、よく逃げる — 見つけたら即連射。氷結アイテムが特効。', 'Blindingly fast and quick to flee — open fire on sight; freeze items work wonders.'] : (sl9.agil >= 0.33 ? ['경계심이 강해 오래 머물지 않는다. 조준을 놓치지 마라.', '警戒心が強く長居しない。狙いを外すな。', 'Wary and restless — do not let your aim slip.'] : ['움직임은 느긋하지만 방심은 금물 — 돌진 한 방이 묵직하다.', '動きはゆったりだが油断禁物 — 突進の一撃が重い。', 'Slow-moving but never harmless — its charge hits like a truck.']); sl9.lore = { h: LH9[hs9 % LH9.length], f: f9, p: LP9[(hs9 >>> 3) % LP9.length], w: w9l }; // >>>(무부호) — >>는 2^31 초과 해시에서 음수 인덱스(undefined p) 실측 } })(); var by_tier = { common: [], rare: [], epic: [] }; for (var bt = 0; bt < species.length; bt++) by_tier[species[bt].tier].push(bt); if (!by_tier.rare.length) by_tier.rare = by_tier.common; if (!by_tier.epic.length) by_tier.epic = by_tier.rare; var GRID = clamp(num(c.grid_m, 110), 40, 400); var RADIUS = clamp(num(c.spawn_radius_m, 320), GRID, 1200); var DENSITY = clamp(num(c.density, 0.55), 0.1, 1); var BUCKET = clamp(num(c.bucket_min, 45), 10, 240); var ENC_M = clamp(num(c.encounter_m, 45), 40, 120); // ≥40m — 도심 GPS 오차보다 커야 "도달 미인정" 클래스가 안 생긴다 var SEED = (num(c.seed, 7) | 0); // ── 도감 영속(localStorage — play 오리진 계약; opaque origin 은 세션-메모리로 관용 강등) ── var _dex_key = 'cq_dex_' + ((location.pathname.split('/').pop() || 'local').slice(0, 40)); var dex = {}; try { dex = JSON.parse(localStorage.getItem(_dex_key) || '{}') || {}; } catch (e) { dex = {}; } function dex_save() { try { localStorage.setItem(_dex_key, JSON.stringify(dex)); } catch (e) {} } function dex_unique() { var n = 0; for (var i = 0; i < species.length; i++) if (dex[species[i].name]) n++; return n; } function dex_total() { var n = 0; for (var k in dex) n += dex[k] | 0; return n; } // ── 탐색 씬(월드 패밀리 없음 — 소형 자족 지도풍 스테이지) ── var T3 = THREE; var scene = P.three.scene, camera = P.three.camera; var ex = new T3.Group(); scene.add(ex); var ground = new T3.Mesh(new T3.CircleGeometry(RADIUS * 1.15, 48), P.make.matte('#2a4636', { roughness: 1 })); // 어둑한 지도 = "게임 꺼짐" 오인 실보고(2026-07-24) — 명도 상향 ground.rotation.x = -Math.PI / 2; ex.add(ground); (function _rings() { // 극좌표 그리드 — "레이더 지도" 문법 var pts = [], rr, a; for (rr = GRID; rr <= RADIUS; rr += GRID) { for (a = 0; a < 64; a++) { pts.push(new T3.Vector3(Math.cos(a / 64 * 6.2832) * rr, 0.02, Math.sin(a / 64 * 6.2832) * rr)); pts.push(new T3.Vector3(Math.cos((a + 1) / 64 * 6.2832) * rr, 0.02, Math.sin((a + 1) / 64 * 6.2832) * rr)); } } var g = new T3.BufferGeometry().setFromPoints(pts); ex.add(new T3.LineSegments(g, new T3.LineBasicMaterial({ color: 0x5a8f78, transparent: true, opacity: 0.75 }))); // 레이더 링 명도 상향(지도 가독) })(); var pin = new T3.Group(); // 플레이어 핀 var pin_body = new T3.Mesh(new T3.ConeGeometry(0.9, 2.2, 12), P.make.glow('#7cf0ff', 0.9)); pin_body.rotation.x = Math.PI; pin_body.position.y = 1.4; pin.add(pin_body); ex.add(pin); // ── 크리처 메시(절차 원시도형 — IP 복제 구조적 불가) ── function creature_mesh(sp, big) { var g = new T3.Group(); var s = big ? 1 : 0.62; var body = new T3.Mesh(new T3.IcosahedronGeometry(0.55 * s, 1), P.make.matte(sp.color, { roughness: 0.55 })); body.scale.y = 0.85; g.add(body); var ew = 0.16 * s; for (var e2 = -1; e2 <= 1; e2 += 2) { var eye = new T3.Mesh(new T3.SphereGeometry(ew, 8, 8), P.make.matte('#ffffff', { roughness: 0.3 })); eye.position.set(e2 * 0.22 * s, 0.14 * s, 0.42 * s); g.add(eye); var pu = new T3.Mesh(new T3.SphereGeometry(ew * 0.45, 6, 6), P.make.matte('#181c22', { roughness: 0.4 })); pu.position.set(e2 * 0.22 * s, 0.14 * s, 0.55 * s); g.add(pu); } if (sp.tier !== 'common') { var ring = new T3.Mesh(new T3.TorusGeometry(0.75 * s, 0.045 * s, 8, 24), P.make.glow(sp.color, sp.tier === 'epic' ? 1.6 : 1.0)); ring.rotation.x = Math.PI / 2; ring.position.y = -0.1 * s; g.add(ring); } if (sp.tier === 'epic') { var ant = new T3.Mesh(new T3.SphereGeometry(0.09 * s, 6, 6), P.make.glow('#ffffff', 1.4)); ant.position.set(0, 0.75 * s, 0); g.add(ant); } return g; } // ── 자족 실물 로더 — creature_quest 는 world 셸 무포함(arcam 카메라 소유 상호배제 슬롯 캐논)이라 // 생성기가 인라인한 (선택분) + 번들 GLTFLoader 를 직접 소비한다. // 재질 정규화(metalness→0)·1회 실측 치수 공유는 world 로더와 동일 정책 미러. var _cq_glb = {}; // file → {gltf}|{waiters:[cb]} function cq_vendor_root() { // vendor origin 재유도(도메인 하드코딩 금지) — three 번들 script src 기반 try { var sc = document.querySelector('script[src*="three-playable"]'); return sc ? sc.src.replace(/\/vendor\/[^/]*$/, '') : ''; } catch (e) { return ''; } } function cq_acquire(name, hh, cb) { var man = window['__PLAYABLE_'+'EXTRA_MANIFEST__']; var spec = man && man.models && man.models[name]; if (!spec || !spec.file) { warn('creature model "' + name + '" not in inline manifest — procedural fallback'); return; } function build(gltf) { var dims = gltf._cq_dims; if (!dims) { var bx = new T3.Box3().setFromObject(gltf.scene); dims = gltf._cq_dims = { h: Math.max(0.01, bx.max.y - bx.min.y), min_y: bx.min.y, cx: (bx.min.x + bx.max.x) / 2, cz: (bx.min.z + bx.max.z) / 2, dmax: Math.max(0.01, bx.max.x - bx.min.x, bx.max.z - bx.min.z) }; } var inst = spec.skinned && T3.SkeletonUtils ? T3.SkeletonUtils.clone(gltf.scene) : gltf.scene.clone(true); // 스케일 = 높이 정규화 + 수평 최대치수 캡 3.4×hh(2026-07-25 실보고 "카메라에 꽉 참" 봉합): // 높이 단독 정규화는 장폭형(디모르포돈 폭 9.2×h·프테라노돈 5.2×h·수룡 5.5~6.1×h)과 // 초저높이 바인드포즈를 폭주 스케일시킨다(재중심화 전에는 원점 이탈로 빗겨나 은폐되던 결함). // 지상 공룡(dz≤3.5×h)은 캡에 실질 비영향 — 폭주 클래스만 실측 비율로 축소. var s = Math.min(hh / dims.h, 3.4 * hh / dims.dmax); var g2 = new T3.Group(); inst.scale.setScalar(s); // 3축 완전 재중심화(2026-07-25 실보고 "익룡 조준 영역 어긋남" — 전수 실측: 54종 중 12종이 // 지오메트리 중심을 원점에서 0.35~11.8×몸높이 이탈(anky_real 11.8x·pisto 2.8x·dimorpho 2.1x· // triceratops 계열 0.65x). 구 로더는 Y(min_y)만 보정해 보이는 몸체가 조준 앵커에서 수평 이탈). inst.position.set(-dims.cx * s, -(dims.min_y + dims.h / 2) * s, -dims.cz * s); // 몸 중심 = 앵커 원점(실 스케일 높이 기준 — hh 가정식은 캡 축소 시 수직 이탈) g2.add(inst); var mixer = null, actions = {}; if (spec.skinned && gltf.animations && gltf.animations.length) { mixer = new T3.AnimationMixer(inst); var clips = spec.clips || {}; for (var al in clips) { var cl2 = T3.AnimationClip.findByName(gltf.animations, clips[al]); if (cl2) actions[al] = mixer.clipAction(cl2); } } var cur2 = null; function play(alias) { var a3 = actions[alias]; if (!a3 || a3 === cur2) return; if (cur2) cur2.fadeOut(0.18); a3.reset().fadeIn(0.18).play(); cur2 = a3; } if (actions.idle) play('idle'); else if (actions.swim) play('swim'); // 수생 히어로 관용(클립 별칭 부재 시 T-포즈 정지 방지) else if (actions.walk) play('walk'); cb({ obj: g2, mixer: mixer, actions: actions, play: play }); } var ent = _cq_glb[spec.file]; if (ent && ent.gltf) return build(ent.gltf); if (ent) { ent.waiters.push(build); return; } ent = _cq_glb[spec.file] = { gltf: null, waiters: [build] }; new T3.GLTFLoader().load(cq_vendor_root() + spec.file, function (gltf) { gltf.scene.traverse(function (n) { // metalness 정규화(world 정책 미러 — 무채색 IBL 흑화 봉합) if (!n.isMesh || !n.material) return; var ms = Array.isArray(n.material) ? n.material : [n.material]; for (var mi2 = 0; mi2 < ms.length; mi2++) { if (ms[mi2] && ms[mi2].isMeshStandardMaterial && !ms[mi2].metalnessMap && ms[mi2].metalness > 0) ms[mi2].metalness = 0; } }); ent.gltf = gltf; var ws = ent.waiters; ent.waiters = []; for (var wi = 0; wi < ws.length; wi++) { try { ws[wi](gltf); } catch (e) {} } }, undefined, function (e) { warn('creature model load failed "' + name + '" — procedural fallback: ' + (e && e.message)); delete _cq_glb[spec.file]; }); } // ── 크리처 보이스(2026-07-25 사용자 확정 "종마다 고유한 소리") — 실녹음 뱅크 클립 × 종 해시 배속. // 같은 클립도 종마다 다른 배속(0.75~1.34, epic 은 저음역)이라 목소리가 고유하고, 공격은 동일 클립의 // 고속 거친 변주(개체성 유지). 구세대(풀 미배선) = P.sfx.lib 우아한 부재로 무해. // 패밀리별 보이스 풀(2026-07-25 실보고 "공룡 소리가 아님" 봉합): 공룡/대형몬스터=깊은 포효 전용 // 저음역, 야생 포유류=그런트/하울, 수생=최저음 포효, 절차 크리처만 monster 계열. 배속은 좁은 // 저음 창(칩멍크화 차단 — 공격 변주도 ×1.08 상한). var _VOICE_FAM = { // 공룡 4아과(2026-07-25 상세감사): 육식=티렉스 콜, 초식=저음 벨로우, 익룡=스크리치, 수룡=최저음 벨로우 dino_carn: { call: ['trex_call_1', 'trex_call_2', 'trex_call_3', 'trex_call_4', 'trex_call_5', 'trex_call_6', 'trex_call_7', 'trex_call_8', 'roar_04', 'roar_05'], base: 0.8, span: 0.3 }, dino_herb: { call: ['deep_moan_1', 'deep_moan_2', 'deep_moan_3', 'deep_moan_4', 'deep_moan_5', 'dino_raspy_1'], base: 0.72, span: 0.28 }, ptero: { call: ['ptero_screech_1', 'ptero_screech_2', 'ptero_screech_3', 'ptero_screech_4'], base: 0.85, span: 0.3 }, marine: { call: ['deep_moan_1', 'deep_moan_2', 'deep_moan_3', 'deep_moan_4', 'deep_moan_5'], base: 0.55, span: 0.15 }, dino: { call: ['roar_01', 'roar_02', 'roar_03', 'roar_04', 'roar_05', 'roar_06', 'creature_roar_01', 'creature_roar_02'], base: 0.68, span: 0.3 }, wild: { call: ['grunt_01', 'grunt_02', 'grunt_03', 'grunt_04', 'grunt_05', 'howl', 'beast_snarl_1', 'beast_snarl_2', 'animal_noise_1'], base: 0.85, span: 0.35 }, aqua: { call: ['roar_02', 'roar_04', 'creature_roar_01'], base: 0.58, span: 0.18 }, procedural: { call: ['monster_01', 'monster_02', 'monster_03', 'monster_04', 'monster_05', 'monster_06', 'monster_07', 'monster_08', 'beast_snarl_3'], base: 0.85, span: 0.45 }, }; var _VOICE_MATCH = [ // 종 모델명 → 실제 그 동물의 실녹음(가용 시 최우선 — 2026-07-25 동물 소리 확보) [/bear/, ['bear_growl_1', 'bear_growl_2']], [/crow|raven/, ['crow_caw_1']], [/frog/, ['frog_croak_1', 'frog_croak_2']], [/dog|shiba|husky/, ['dog_grunt_1']], [/cat_|kitten|_cat/, ['cat_meow_1']], [/vulture|eagle|hawk|condor/, ['vulture_call_1']], [/camel/, ['camel_groan_1', 'camel_groan_2']], [/yak|bison|buffalo/, ['yak_groan_1']], [/peacock/, ['peacock_scream_1']], ]; function sp_voice(sp) { var nm9 = String(sp.model || sp.name || 'x'); for (var mi9 = 0; mi9 < _VOICE_MATCH.length; mi9++) { // 실제 동물 일치 = 자연 피치 좁은 창 if (_VOICE_MATCH[mi9][0].test(nm9)) { var mp9 = _VOICE_MATCH[mi9][1]; var mh9 = 0; for (var mv9 = 0; mv9 < nm9.length; mv9++) mh9 = (mh9 * 31 + nm9.charCodeAt(mv9)) >>> 0; return { call: mp9[mh9 % mp9.length], rate: 0.92 + (mh9 % 18) / 100, hurt: (mh9 % 2) ? 'creature_hurt_01' : 'creature_hurt_02' }; } } var fam9 = !sp.model ? 'procedural' : (/pteranodon|dimorphodon|quetzal|ptero/.test(nm9) ? 'ptero' : (/mosasaur|pistosaur|plesiosaur|elasmosaur|pliosaur/.test(nm9) ? 'marine' : (/tricera|stego|apato|brachio|argentino|parasaur|ankylo|pachy|diplo|iguanodon/.test(nm9) ? 'dino_herb' : (/^dino_/.test(nm9) ? 'dino_carn' : (/^(mon_big_|myth_)/.test(nm9) ? 'dino' : (/^aqua_/.test(nm9) ? 'aqua' : (/^(wild_|fauna_|critter_|styani_|dbunny_|qfarm_|bird_)/.test(nm9) ? 'wild' : 'procedural'))))))); var pool9 = _VOICE_FAM[fam9]; var h9 = 0; for (var vi9 = 0; vi9 < nm9.length; vi9++) h9 = (h9 * 31 + nm9.charCodeAt(vi9)) >>> 0; var rate9 = pool9.base + ((h9 >>> 4) % 100) / 100 * pool9.span; if (sp.tier === 'epic') rate9 *= 0.88; else if (sp.tier === 'rare') rate9 *= 0.95; // 상위 티어 = 더 저음(체급 문법) return { call: pool9.call[h9 % pool9.call.length], rate: rate9, hurt: (h9 % 2) ? 'creature_hurt_01' : 'creature_hurt_02' }; } function voice(sp, verb, vol) { var v9 = sp_voice(sp); if (verb === 'attack') P.sfx.lib(v9.call, { volume: 0.8, rate: Math.min(1.2, v9.rate * 1.08) }); // 거친 변주는 볼륨으로(배속 완화) else if (verb === 'die') P.sfx.lib('creature_die_01', { volume: 0.7, rate: v9.rate }); else if (verb === 'hurt') P.sfx.lib(v9.hurt, { volume: 0.5, rate: v9.rate }); else P.sfx.lib(v9.call, { volume: vol == null ? 0.6 : vol, rate: v9.rate }); } // ── 크리처 실물(카탈로그 GLB 우선 = 프로덕션 품질·리깅 애니) + 절차 폴백(오프라인/미지 이름 견고) ── var _cq_shadow_tex = null; function _shadow_blob(rr) { // 접지 소프트 그림자(방사형 캔버스) — AR "공중부양" 인상 봉합(실감 캐논) if (!_cq_shadow_tex) { var cs = document.createElement('canvas'); cs.width = cs.height = 64; var cc = cs.getContext('2d'); var gg = cc.createRadialGradient(32, 32, 0, 32, 32, 32); gg.addColorStop(0, 'rgba(0,0,0,0.5)'); gg.addColorStop(0.7, 'rgba(0,0,0,0.22)'); gg.addColorStop(1, 'rgba(0,0,0,0)'); cc.fillStyle = gg; cc.fillRect(0, 0, 64, 64); _cq_shadow_tex = new T3.CanvasTexture(cs); } var m9 = new T3.Mesh(new T3.CircleGeometry(rr, 24), new T3.MeshBasicMaterial({ map: _cq_shadow_tex, transparent: true, depthWrite: false })); m9.rotation.x = -Math.PI / 2; return m9; } function creature_obj(sp, big) { var g = new T3.Group(); g._cq = { play: function () {}, mixer: null }; if (sp.model) { // 로딩 플레이스홀더 = 소환(물질화) 이펙트(2026-07-25 실보고 봉합): GLB 도착 전 절차 크리처 // ("외계인" 정20면체+눈알)가 순간 노출됐다가 교체되던 깜빡임의 근본 원인 = 즉시-폴백 패턴. // 로딩 상태를 '소환 중' 글로우로 정직 표현하고, 7초 미도착(오프라인/결손)만 절차 폴백(견고성 캐논 유지). var ph = new T3.Group(); var orb0 = _spr(sp.color, 0.85, 0.7); ph.add(orb0); var wk0 = _spr('#ffffff', 0.32, 0.85); ph.add(wk0); var rg0 = new T3.Mesh(new T3.TorusGeometry(0.5, 0.035, 8, 26), P.make.glow(sp.color, 1.3)); rg0.rotation.x = Math.PI / 2; rg0.position.y = -0.45; ph.add(rg0); g.add(ph); var fb0 = setTimeout(function () { if (g._cq_loaded) return; try { g.remove(ph); } catch (e) {} g.add(creature_mesh(sp, big)); // 정직 강등 — 오프라인에서도 게임은 성립 }, 7000); var hh = big ? (0.85 + TIERS.indexOf(sp.tier) * 0.25) : 0.55; cq_acquire(sp.model, hh, function (h2) { g._cq_loaded = true; clearTimeout(fb0); try { g.remove(ph); } catch (e) {} g.add(h2.obj); g._cq = h2; var wp0 = new T3.Vector3(); // 물질화 버스트 — 소환 연출의 완결 g.getWorldPosition(wp0); fx.burst(wp0, { color: sp.color, count: 12 }); var shd = _shadow_blob(hh * 0.5); // 접지 그림자 — 실물 도착 시점에만(높이 확정 후) shd.position.y = -hh / 2 + 0.015; g.add(shd); if (sp.tier !== 'common') { // 희귀도 오라는 실물에도 유지(티어 시각 문법) var ring2 = new T3.Mesh(new T3.TorusGeometry(0.55, 0.04, 8, 24), P.make.glow(sp.color, sp.tier === 'epic' ? 1.6 : 1.0)); ring2.rotation.x = Math.PI / 2; ring2.position.y = -hh / 2 + 0.04; g.add(ring2); } }); } else { g.add(creature_mesh(sp, big)); // 모델 미지정 = 절차 크리처(저작 종 계약 유지) } return g; } // ── HUD(도감 버튼·진행 바) — 버튼 포인터는 claim(카메라 드래그와 비간섭) ── // 표준 콘솔 버튼 소비(2026-07-24 사용자 확정 "playable 표준 버튼 복제") — 런타임 전역 // .pbtn/.pbtn--pad 클래스가 돔 그라디언트·스페큘러·시닌 스윕·프레스 스케일·touch-action 을 // 전부 소유(중복 인라인 제거 = 드리프트 원천 소멸). 인라인은 위치/크기/z 만(아키타입 스택 질서). // slot: 'pbtn--a'(녹=주 액션)/'pbtn--b'(적=이탈)/'pbtn--x'(청=정보). 텍스트 버튼 = rail(둥근 사각). function _btn(txt, css, slot) { var d = document.createElement('button'); d.textContent = txt; d.className = 'pbtn pbtn--pad ' + (slot || 'pbtn--x') + (txt ? ' pbtn--rail' : ''); d.style.cssText = 'z-index:7;font:700 14px system-ui;' + (txt ? 'padding:10px 14px;' : '') + css; d.addEventListener('pointerdown', function (ev) { if (P.claim_pointer) P.claim_pointer(ev.pointerId); }); document.body.appendChild(d); return d; } // ── '잡은 몬스터들' 버튼(2026-07-25 사용자 확정 풀 리디자인) — 공룡 라인아트 + 포획 수 배지 + // 글로시(스페큘러·샤인 스윕·글로우 맥동) + 포획 진행 메달 링/젬(브론즈→실버→골드→플래티넘→마스터). // 콘솔 _btn 베이스(포인터 claim·프레스 피드백) 위에 커스텀 레이어 — 표준 진화 상속 유지. (function () { var st6 = document.createElement('style'); st6.textContent = '@keyframes cq-dexshine{0%,55%{left:-60%}90%,100%{left:120%}}' + '@keyframes cq-dexspin{to{transform:rotate(360deg)}}' + '@keyframes cq-dexglow{0%,100%{opacity:.55;transform:scale(1)}50%{opacity:1;transform:scale(1.12)}}'; document.head.appendChild(st6); })(); var hud = _btn('', 'left:12px;top:60px;width:58px;height:58px;', 'pbtn--x'); hud.setAttribute('data-cq-dex', '1'); hud.setAttribute('aria-label', _at_t('잡은 몬스터들', 'つかまえたモンスター', 'Caught Monsters')); hud.style.overflow = 'visible'; hud.innerHTML = // 글로우 맥동(메달 색 연동) — 버튼 뒤 광륜 '
' // 메달 링(마스크 도넛) — 티어 메탈릭 conic 그라디언트, 저속 회전 = 금속 광택 순환 + '
' // 글로시 내부(클립) — 유리 스페큘러 + 샤인 스윕 + '
' + '
' + '
' + '
' // 공룡 라인아트(사우로포드 실루엣 — 긴 목 = 즉독성) — 시안 발광 스트로크 + '
' + '
' + '
' + '
' + '
' + '
' // 포획 수 배지(골드 캡슐) + '
0
' // 메달 젬(방패 클립) — 티어 도달 시 표시 + '
'; var hud_glow = hud.querySelector('[data-dxg]'); var hud_ring = hud.querySelector('[data-dxr]'); var hud_count = hud.querySelector('[data-dxc]'); var hud_gem = hud.querySelector('[data-dxm]'); // 메달 티어(포획 종 비율) — 로스터 크기와 무관하게 성립하는 비율 임계(브론즈 10%→마스터 100%) var MEDAL9 = [ { r: 0.10, ring: 'conic-gradient(from 210deg,#5e3a17,#e8a86a,#8a5a2b,#f5c894,#5e3a17)', glow: 'rgba(222,158,96,.55)', gem: 'linear-gradient(135deg,#f5c894,#c07f3e 55%,#6e4218)' }, { r: 0.30, ring: 'conic-gradient(from 210deg,#6f7880,#f2f7fc,#9aa3ad,#ffffff,#6f7880)', glow: 'rgba(225,235,245,.55)', gem: 'linear-gradient(135deg,#ffffff,#bcc5cf 55%,#7e8790)' }, { r: 0.55, ring: 'conic-gradient(from 210deg,#8a6400,#ffe27a,#d4a017,#fff3b0,#8a6400)', glow: 'rgba(255,215,90,.6)', gem: 'linear-gradient(135deg,#fff3b0,#f5c518 55%,#9a7000)' }, { r: 0.80, ring: 'conic-gradient(from 210deg,#3f7f8f,#eafcff,#8fd4de,#ffffff,#3f7f8f)', glow: 'rgba(170,235,245,.65)', gem: 'linear-gradient(135deg,#ffffff,#9fdbe6 55%,#4f93a6)' }, { r: 1.00, ring: 'conic-gradient(from 0deg,#8b5cf6,#ff8ae2,#4fc3ff,#8bffdf,#ffe98a,#8b5cf6)', glow: 'rgba(185,140,255,.75)', gem: 'linear-gradient(135deg,#e2ccff,#8b5cf6 55%,#4d2b9e)' }, ]; function medal_tier() { var q9 = dex_unique() / Math.max(1, species.length); var t9m = -1; for (var mi9 = 0; mi9 < MEDAL9.length; mi9++) if (q9 >= MEDAL9[mi9].r) t9m = mi9; return t9m; } hud.style.display = 'none'; // 오프닝 동안 숨김 — geo ready 에서 표출 var back_btn = _btn('', 'left:12px;top:138px;width:46px;height:46px;', 'pbtn--b'); // 메인 복귀(오프닝) — 표준 콘솔 글로시 · 138 = 도감 버튼(58px+메달 젬) 아래 층 back_btn.setAttribute('aria-label', _at_t('메인 화면으로', 'メイン画面へ', 'Back to main')); back_btn.innerHTML = '
'; back_btn.addEventListener('click', function () { location.reload(); }); // 단일 HTML 재부트 = 무결 복귀 경로(도감·최고기록은 localStorage 영속) var share_btn9 = _btn('', 'left:12px;top:192px;width:46px;height:46px;', 'pbtn--x'); // 플레이 중 친구-공유(오프닝 버튼과 동일 정체성 — 퍼플 돔) share_btn9.setAttribute('aria-label', _at_t('친구 초대', '友だちを招待', 'Invite friends')); share_btn9.style.background = 'radial-gradient(circle at 32% 26%,#b18cff 0%,#7c5cff 48%,#4f3dd8 100%)'; share_btn9.style.boxShadow = '0 8px 20px rgba(124,92,255,.45), inset 0 2px 3px rgba(255,255,255,.55), inset 0 -3px 5px rgba(30,10,90,.45)'; share_btn9.innerHTML = '
'; var inv_ov = null; // 친구 초대 카드(2026-07-25) — 점수 경쟁 안내 → CTA 가 공유 시트를 연다(프리미엄 카드 문법 미러) function invite_card() { if (inv_ov) return; inv_ov = document.createElement('div'); inv_ov.style.cssText = 'position:fixed;inset:0;z-index:80;display:flex;align-items:center;justify-content:center;background:linear-gradient(180deg,rgba(8,10,16,.9),rgba(10,14,24,.94));backdrop-filter:blur(6px);font-family:system-ui,sans-serif;'; inv_ov.addEventListener('pointerdown', function (ev) { if (ev.target === inv_ov) { inv_ov.remove(); inv_ov = null; } }); // 바깥 탭 = 닫기 var cd9 = document.createElement('div'); cd9.style.cssText = 'max-width:330px;width:88%;padding:26px 22px 22px;border-radius:22px;background:linear-gradient(180deg,#1a2234,#131a2a);border:1px solid rgba(177,140,255,.25);box-shadow:0 24px 60px rgba(0,0,0,.55),inset 0 1px 0 rgba(255,255,255,.06);color:#e8ecf4;text-align:center;'; cd9.innerHTML = '
' + '
' + '
'; var tt9 = document.createElement('div'); tt9.style.cssText = 'font-size:21px;font-weight:900;margin-bottom:10px;background:linear-gradient(135deg,#b18cff,#7cf0ff);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;'; tt9.textContent = share_label9; var bd9 = document.createElement('div'); bd9.style.cssText = 'font-size:15px;line-height:1.6;opacity:.92;margin-bottom:18px;'; bd9.textContent = _at_t('친구를 초대해서 누가 더 잘하는지 점수 경쟁을 해보세요!', '友だちを招待して、どっちが上手かスコアで勝負しよう!', 'Invite a friend and battle for the top score!'); var go9 = document.createElement('button'); go9.style.cssText = 'display:block;width:100%;font-size:16.5px;font-weight:900;letter-spacing:.3px;padding:14px;border:0;border-radius:999px;color:#3a2405;cursor:pointer;text-shadow:0 1px 0 rgba(255,255,255,.45);background:linear-gradient(180deg,#ffe98a 0%,#ffb52e 45%,#ff8a3d 100%);box-shadow:0 10px 26px rgba(255,150,40,.4),inset 0 2px 0 rgba(255,255,255,.6),inset 0 -3px 0 rgba(140,60,0,.3);'; go9.textContent = _at_t('초대 링크 보내기', '招待リンクを送る', 'Send invite link'); go9.addEventListener('click', function () { P.share(share_text9()); try { inv_ov.remove(); } catch (e9) {} inv_ov = null; }); cd9.appendChild(tt9); cd9.appendChild(bd9); cd9.appendChild(go9); inv_ov.appendChild(cd9); document.body.appendChild(inv_ov); } share_btn9.addEventListener('click', invite_card); // 게임 내 = 초대 카드 경유(대결 문맥 안내) // 좌상단 첫 줄은 셸 사운드 칩 예약 — 겹침 구조 차단(실기기 실보고) var prog = document.createElement('div'); prog.style.cssText = 'position:fixed;left:50%;bottom:24%;transform:translateX(-50%);width:180px;height:10px;border-radius:5px;background:rgba(6,20,24,.55);border:1px solid rgba(94,240,226,.4);z-index:7;display:none;overflow:hidden;box-shadow:0 0 10px rgba(94,240,226,.25);'; // 24% — 하단 중앙 레이더 다이얼(18~134px)과 상호 침범 없는 층 var prog_fill = document.createElement('div'); prog_fill.style.cssText = 'height:100%;width:0%;background:linear-gradient(90deg,#3fd9cb,#8bfff2);border-radius:5px;box-shadow:0 0 8px rgba(94,240,226,.8);'; prog.appendChild(prog_fill); document.body.appendChild(prog); var flee_btn = _btn(_at_t('도망가기', 'にげる', 'Run away'), 'right:12px;top:146px;display:none;', 'pbtn--b'); // 우상단 스택: 라이프(14)→레이더(46..140)→도주(146) // ── 조준 레티클(AR 국면 공통) — 홀로 스캐너 문법(2026-07-25 사용자 확정 컨셉): 시안 서클 + // 4방위 틱 + 코어 도트. 조준 확정 시 종 색으로 발광 전환(색 의미론 유지). var HOLO = '#5ef0e2'; // creature_quest 홀로-HUD 기준색(스캐너 시안) — 명판/브래킷/레이더/태그 공유 var ret = document.createElement('div'); ret.setAttribute('data-cq-ret', '1'); // 골든 계약 앵커(스타일 리터럴 매칭 폐지 — data-cq-radar/nav 와 동일 문법) ret.style.cssText = 'position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);z-index:6;pointer-events:none;display:none;width:34px;height:34px;border:2px solid rgba(94,240,226,.9);border-radius:50%;box-shadow:0 0 10px rgba(94,240,226,.45);transition:border-color .12s,box-shadow .12s;'; var ret_dot = document.createElement('div'); ret_dot.style.cssText = 'position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:5px;height:5px;border-radius:50%;background:' + HOLO + ';box-shadow:0 0 6px ' + HOLO + ';'; ret.appendChild(ret_dot); for (var rt9 = 0; rt9 < 4; rt9++) { // 4방위 틱(상·하·좌·우) — 참조 컨셉의 십자 레티클 var tk9 = document.createElement('div'); tk9.style.cssText = 'position:absolute;background:' + HOLO + ';opacity:.9;' + (rt9 < 2 ? 'left:50%;width:2px;height:7px;margin-left:-1px;' + (rt9 ? 'bottom:-11px;' : 'top:-11px;') : 'top:50%;height:2px;width:7px;margin-top:-1px;' + (rt9 === 3 ? 'right:-11px;' : 'left:-11px;')); ret.appendChild(tk9); } document.body.appendChild(ret); var ret_name = document.createElement('div'); ret_name.style.cssText = 'position:fixed;left:50%;top:50%;transform:translate(-50%,26px);z-index:6;pointer-events:none;display:none;color:#fff;font-family:system-ui;font-size:13px;font-weight:700;text-shadow:0 1px 4px rgba(0,0,0,.8);white-space:nowrap;'; document.body.appendChild(ret_name); function ret_aim(sp, label9) { // sp=null → 대기(홀로 시안), sp → 조준 확정(종 색). 이름 라벨은 // label9(아이템) 전용 — 종명은 상단 명판 스트립이 단독 소유(레티클 밑 이중 표기가 크리처를 // 가리던 실보고 2026-07-25 봉합, 포켓몬 GO 상단 라벨 문법). if (sp) { ret.style.borderColor = sp.color; ret.style.boxShadow = '0 0 12px ' + sp.color; if (label9) { ret_name.textContent = sp.name; ret_name.style.color = sp.color; ret_name.style.display = ''; } else ret_name.style.display = 'none'; } else { ret.style.borderColor = 'rgba(94,240,226,.9)'; ret.style.boxShadow = '0 0 10px rgba(94,240,226,.45)'; ret_name.style.display = 'none'; } } // 세로 폰의 가로 반각(°) — "전방 스폰"은 반드시 이 안에 들어와야 부팅 즉시 보인다 // (실측 2026-07-23: 390×700 포트레이트 가로 반각 ≈18°, 종전 ±22~30° 지터/오프셋은 정면조차 화면 밖) function fov_half_h() { var a = camera.aspect || ((window.innerWidth || 390) / (window.innerHeight || 700)); return Math.atan(Math.tan((camera.fov || 60) * Math.PI / 360) * a) * 57.29578; } function ahead_jitter() { return Math.max(4, Math.min(22, fov_half_h() - 8)); } function hud_sync() { // 포획 수 배지 + 메달 티어(링/글로우/젬) 동기화 — 티어 전환 시에만 스타일 갱신 hud_count.textContent = String(dex_unique()); var mt9 = medal_tier(); if (hud._mt === mt9) return; hud._mt = mt9; if (mt9 < 0) { hud_ring.style.background = 'conic-gradient(from 210deg,rgba(94,240,226,.15),rgba(94,240,226,.7),rgba(94,240,226,.15),rgba(94,240,226,.55),rgba(94,240,226,.15))'; hud_glow.style.background = 'radial-gradient(circle,rgba(94,240,226,.4),rgba(94,240,226,0) 70%)'; hud_gem.style.display = 'none'; } else { var md9 = MEDAL9[mt9]; hud_ring.style.background = md9.ring; hud_ring.style.animationDuration = mt9 >= 4 ? '3.2s' : '9s'; // 마스터 = 무지개 링 고속 순환 hud_glow.style.background = 'radial-gradient(circle,' + md9.glow + ',rgba(0,0,0,0) 70%)'; hud_gem.style.display = ''; hud_gem.style.background = md9.gem; } } // ══ 홀로-HUD(2026-07-25 사용자 확정 "AR 스캐너 컨셉") — 종명 명판·뷰파인더 브래킷 ══ // 참조 문법: 좌상 명판(종명 대문자 톤 + 등급 서브라인 + 실거리 m), 타깃 크리처를 감싸는 // 4코너 스캔 브래킷(3D 위치 → 스크린 투영, 거리 반비례 크기, lerp 스무딩). // 명판 = 상단 중앙 컴팩트 스트립(2026-07-25 실보고 "명판이 몬스터를 가림" — 포켓몬 GO 인카운터 // 라벨/MH Now 상단 스트립 문법: 크리처는 화면 중앙 주인공, 정보는 상단 한 줄로 비켜난다). var plate = document.createElement('div'); plate.setAttribute('data-cq-plate', '1'); plate.style.cssText = 'position:fixed;left:50%;top:52px;transform:translateX(-50%);z-index:7;display:none;pointer-events:none;max-width:88vw;' + 'align-items:baseline;gap:9px;padding:6px 15px;background:linear-gradient(135deg,rgba(6,20,24,.72),rgba(6,20,24,.45));backdrop-filter:blur(5px);' + 'border:1px solid rgba(94,240,226,.4);border-radius:999px;white-space:nowrap;' + 'box-shadow:0 0 16px rgba(94,240,226,.18), inset 0 0 10px rgba(94,240,226,.06);'; plate.innerHTML = '
' + '
' + '
'; // 스토리 라인은 플레이 화면에서 제거(2026-07-25 사용자 확정 "플레이 중 화면 복잡") — 로어는 // '잡은 몬스터들' 상세 모달 전용 표면으로 일원화(데이터/생성 배선은 유지). document.body.appendChild(plate); var plate_name = plate.querySelector('[data-cq-plate-name]'); var plate_sub = plate.querySelector('[data-cq-plate-sub]'); var plate_dist = plate.querySelector('[data-cq-plate-dist]'); function tier_tag(tier) { // 등급 서브라인 — 홀로 배지 톤(현지화, Steam식 명확성) return tier === 'epic' ? _at_t('전설 개체', '伝説クラス', 'LEGENDARY CLASS') : tier === 'rare' ? _at_t('희귀 개체', 'レアクラス', 'RARE CLASS') : _at_t('일반 개체', 'ノーマル', 'COMMON'); } function plate_tick(rec) { // rec=null → 숨김(타깃 미확보) — 매 프레임 호출 안전(문자열 변경만) if (!rec || !rec.anchor || !rec.anchor.obj) { plate.style.display = 'none'; return; } var sp7 = species[rec.sp_i]; plate.style.display = 'flex'; if (plate._sp !== sp7) { plate._sp = sp7; plate_name.textContent = sp7.name; plate_sub.textContent = tier_tag(sp7.tier) + ' · ⭐' + sp7.pts; plate.style.borderLeftColor = sp7.tier === 'common' ? HOLO : sp7.color; } var dd7 = camera.position.distanceTo(rec.anchor.obj.position); plate_dist.textContent = (dd7 < 9.95 ? dd7.toFixed(1) : Math.round(dd7)) + ' m'; } var brk = document.createElement('div'); // 뷰파인더 4코너 브래킷 — 타깃 프레이밍 brk.setAttribute('data-cq-brackets', '1'); brk.style.cssText = 'position:fixed;left:0;top:0;z-index:5;display:none;pointer-events:none;will-change:transform,width,height;'; (function () { var cs9 = ['left:0;top:0;border-width:2.5px 0 0 2.5px;border-radius:6px 0 0 0;', 'right:0;top:0;border-width:2.5px 2.5px 0 0;border-radius:0 6px 0 0;', 'left:0;bottom:0;border-width:0 0 2.5px 2.5px;border-radius:0 0 0 6px;', 'right:0;bottom:0;border-width:0 2.5px 2.5px 0;border-radius:0 0 6px 0;']; for (var bi9 = 0; bi9 < 4; bi9++) { var cn9 = document.createElement('div'); cn9.style.cssText = 'position:absolute;width:22%;height:22%;max-width:34px;max-height:34px;border-style:solid;border-color:' + HOLO + ';' + 'filter:drop-shadow(0 0 6px rgba(94,240,226,.7));' + cs9[bi9]; brk.appendChild(cn9); } })(); document.body.appendChild(brk); var _brk_s = null; // lerp 상태 {x,y,w,h} var _v9 = null; function brackets_tick(rec) { // 3D 앵커 → 스크린 사각 투영(거리 반비례 크기) — rec=null/후방 = 숨김 var obj9b = rec && rec.anchor && rec.anchor.obj; if (!obj9b) { brk.style.display = 'none'; _brk_s = null; return; } _v9 = _v9 || new T3.Vector3(); obj9b.getWorldPosition(_v9); var cz9 = _v9.clone().applyMatrix4(camera.matrixWorldInverse).z; if (cz9 > -0.3) { brk.style.display = 'none'; _brk_s = null; return; } // 카메라 후방/과근접 — 투영 불능 var d9b = camera.position.distanceTo(_v9); _v9.project(camera); var W9 = window.innerWidth, H9 = window.innerHeight; var sx9 = (_v9.x * 0.5 + 0.5) * W9, sy9 = (-_v9.y * 0.5 + 0.5) * H9; // 공칭 실물 높이 ~1.9m의 화면 투영 높이(수직 fov 기반) — 프레이밍은 양식이므로 근사면 충분 var hh9 = H9 * (1.9 / (2 * d9b * Math.tan((camera.fov || 60) * Math.PI / 360))); hh9 = Math.max(84, Math.min(Math.min(W9, H9) * 0.72, hh9)); var ww9 = hh9 * 0.94; if (!_brk_s) _brk_s = { x: sx9, y: sy9, w: ww9, h: hh9 }; else { // 프레임 lerp — 돌진/봅에도 프레임이 차분히 따라붙는다(스캐너 질감) _brk_s.x += (sx9 - _brk_s.x) * 0.30; _brk_s.y += (sy9 - _brk_s.y) * 0.30; _brk_s.w += (ww9 - _brk_s.w) * 0.22; _brk_s.h += (hh9 - _brk_s.h) * 0.22; } brk.style.display = ''; brk.style.width = _brk_s.w + 'px'; brk.style.height = _brk_s.h + 'px'; brk.style.transform = 'translate(' + (_brk_s.x - _brk_s.w / 2) + 'px,' + (_brk_s.y - _brk_s.h / 2) + 'px)'; } function holo_hide() { plate.style.display = 'none'; brk.style.display = 'none'; _brk_s = null; plate._sp = null; } // ══ v2 전투/생동감 시스템(2026-07-24) — 대시 이동(순간이동 금지)·돌진-회피-라이프·펄스 사격·AR 레이더·포획 축하 ══ // ── 라이프(❤) — 돌진 피격 시 차감, 회피(조준각 이탈) = 무피해; 0 = 짧은 기절 후 회복(캐주얼 캐논) ── var LIVES_MAX = clamp(Math.round(num(c.lives, 3)), 1, 5); var lives = LIVES_MAX, down_until = 0; var run_pts = 0, run_t = 0; // 런(라이프 소진까지) 기록 — 총점 = 생존 시간(초) + Σ종별 포획 점수(2026-07-24 확정) function run_score() { return Math.floor(run_t) + run_pts; } function diff_mult() { return 1 / (1 + Math.min(2.2, run_t / 75)); } // 생존 시간 비례 난이도 — 돌진 빈도 최대 ~3.2배 // 단일 리터럴(배선 게이트 계약: enable({ metric: 'score' 리터럴이 셸 본문에 실재해야 함) + 사망 후 재호출 = once-guard 리셋 function lb_enable() { return P.leaderboard.enable({ metric: 'score', unit: '', max: 1000000, higher_better: true, min_play_s: 10 }); } // 상단 중앙 ⏱ 플레이 시간 + ⭐ 현재 점수(사망 시 0 리셋 — 리더보드 반영 값 그대로) var timer_el = document.createElement('div'); timer_el.setAttribute('data-cq-timer', '1'); timer_el.style.cssText = 'display:none;position:fixed;left:50%;top:14px;transform:translateX(-50%);z-index:7;font:800 15px system-ui;color:#eaf2ff;text-shadow:0 1px 4px rgba(0,0,0,.75);pointer-events:none;background:linear-gradient(135deg,rgba(6,20,24,.66),rgba(6,20,24,.42));border:1px solid rgba(94,240,226,.35);border-radius:12px;padding:6px 12px;backdrop-filter:blur(4px);box-shadow:0 0 12px rgba(94,240,226,.15);letter-spacing:.4px;'; document.body.appendChild(timer_el); var timer_acc = 9; function timer_sync() { var s8 = Math.floor(run_t); timer_el.textContent = '⏱ ' + Math.floor(s8 / 60) + ':' + ('0' + (s8 % 60)).slice(-2) + ' · ⭐ ' + run_score(); } timer_sync(); var lives_el = document.createElement('div'); lives_el.setAttribute('data-cq-lives', '1'); lives_el.style.cssText = 'position:fixed;right:12px;top:14px;z-index:7;font-size:17px;letter-spacing:1px;display:none;font-family:system-ui;text-shadow:0 1px 4px rgba(0,0,0,.7);pointer-events:none;'; document.body.appendChild(lives_el); function lives_sync() { var s5 = ''; for (var li5 = 0; li5 < LIVES_MAX; li5++) s5 += li5 < lives ? '❤️' : '🖤'; lives_el.textContent = s5; } lives_sync(); // ── 화면 균열(피격) — 임팩트 중심 방사 균열 + 백플래시 + 셰이크 ── var crack = document.createElement('canvas'); crack.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;z-index:40;pointer-events:none;display:none;opacity:1;transition:opacity .55s;'; document.body.appendChild(crack); function crack_show() { var W4 = crack.width = window.innerWidth, H4 = crack.height = window.innerHeight; var g4 = crack.getContext('2d'); g4.clearRect(0, 0, W4, H4); g4.fillStyle = 'rgba(255,255,255,.28)'; g4.fillRect(0, 0, W4, H4); var cx = W4 / 2, cy = H4 * 0.46; for (var ck = 0; ck < 13; ck++) { var a4 = (ck / 13) * 6.2832 + Math.random() * 0.5; var len = (0.25 + Math.random() * 0.55) * Math.min(W4, H4); g4.strokeStyle = 'rgba(255,255,255,' + (0.55 + Math.random() * 0.4) + ')'; g4.lineWidth = 1 + Math.random() * 2.2; g4.beginPath(); g4.moveTo(cx, cy); var px4 = cx, py4 = cy, seg = 4 + (Math.random() * 3 | 0); for (var sg = 1; sg <= seg; sg++) { var rr4 = (len * sg) / seg; var ja = a4 + (Math.random() - 0.5) * 0.5; px4 = cx + Math.cos(ja) * rr4; py4 = cy + Math.sin(ja) * rr4; g4.lineTo(px4, py4); } g4.stroke(); if (Math.random() < 0.5) { // 분기 균열 g4.beginPath(); g4.moveTo(px4, py4); g4.lineTo(px4 + (Math.random() - 0.5) * 70, py4 + (Math.random() - 0.5) * 70); g4.stroke(); } } crack.style.display = ''; crack.style.opacity = '1'; setTimeout(function () { crack.style.opacity = '0'; }, 240); setTimeout(function () { crack.style.display = 'none'; }, 900); } var down_ov = document.createElement('div'); down_ov.style.cssText = 'position:fixed;inset:0;z-index:41;display:none;align-items:center;justify-content:center;background:rgba(8,6,10,.72);color:#fff;font-family:system-ui;font-size:20px;font-weight:800;text-align:center;'; document.body.appendChild(down_ov); function life_hit() { lives--; lives_sync(); fx.shake(0.85); try { if (navigator.vibrate) navigator.vibrate(140); } catch (e) {} // Android — iOS Safari 미지원 fx.sfx('explosion', { volume: 0.8 }); crack_show(); if (lives <= 0) { down_until = Date.now() + 2600; var final_s = run_score(); down_ov.textContent = _at_t('😵 쓰러졌다! 최종 점수 ' + final_s + ' — 기록을 등록하세요', '😵 ダウン!スコア ' + final_s + ' — きろくとうろく', '😵 Down! Final score ' + final_s + ' — submit your record'); down_ov.style.display = 'flex'; P.leaderboard.submit(final_s); // 등록창(이니셜)은 사망 시에만(2026-07-24 확정) — 제출 = 런 총점 dex = {}; dex_save(); // 로그라이트 캐논: 라이프 소진 = 도감·런 기록 전체 리셋 run_pts = 0; run_t = 0; win_done = false; timer_sync(); setTimeout(function () { down_ov.style.display = 'none'; lives = LIVES_MAX; lives_sync(); hud_sync(); lb_enable(); // once-guard 리셋 — 다음 런도 사망 시 제출 가능 P.toast(_at_t('기록이 초기화됐어요 — 다시 도전!', 'きろくリセット — さいちょうせん!', 'Records reset — try again!')); }, 2600); } } // ── 포획 축하 — 카메라 기준 폭죽 연사 + 컨페티 낙하 ── (function () { var st5 = document.createElement('style'); st5.textContent = '@keyframes cqconf{to{transform:translateY(114vh) rotate(720deg)}}' + '@keyframes cqconf2{30%{transform:translate(16px,30vh) rotate(240deg)}60%{transform:translate(-12px,62vh) rotate(480deg)}to{transform:translate(10px,114vh) rotate(780deg)}}' + '@keyframes cqring{to{transform:translate(-50%,-50%) scale(10);opacity:0}}'; document.head.appendChild(st5); })(); function celebrate(sp, pos) { try { if (navigator.vibrate) navigator.vibrate([70, 40, 110]); } catch (e) {} // Android — iOS Safari 는 Vibration API 자체 미지원(웹 한계) var fl = document.createElement('div'); // 풀스크린 플래시 fl.style.cssText = 'position:fixed;inset:0;z-index:41;pointer-events:none;background:radial-gradient(circle,rgba(255,255,255,.9),rgba(255,255,255,0) 72%);opacity:1;transition:opacity .5s;'; document.body.appendChild(fl); setTimeout(function () { fl.style.opacity = '0'; }, 70); setTimeout(function () { try { fl.remove(); } catch (e) {} }, 700); var rg = document.createElement('div'); // 확산 링(종 색) rg.style.cssText = 'position:fixed;left:50%;top:46%;z-index:41;pointer-events:none;width:64px;height:64px;border-radius:50%;' + 'border:4px solid ' + sp.color + ';box-shadow:0 0 24px ' + sp.color + ';transform:translate(-50%,-50%);opacity:.95;animation:cqring .8s ease-out forwards;'; document.body.appendChild(rg); setTimeout(function () { try { rg.remove(); } catch (e) {} }, 950); if (pos) fx.burst(pos, { color: sp.color, count: 54 }); for (var ci = 1; ci <= 6; ci++) (function (k) { // 폭죽 6연사 — 화면 가득 setTimeout(function () { var p2 = new T3.Vector3((Math.random() * 2 - 1) * 2.1, (Math.random() * 2 - 1) * 1.6, -(1.4 + Math.random() * 2.4)); p2.applyQuaternion(camera.quaternion).add(camera.position); fx.burst(p2, { color: k % 2 ? sp.color : ['#ffd447', '#7cf0ff', '#ff7cba'][k % 3], count: 30 }); fx.sfx('explosion', { volume: 0.26 }); }, k * 120); })(ci); fx.shake(0.55); // 화면 흔들림 대폭 var confc = ['#ffd447', '#7cf0ff', '#ff7cba', sp.color, '#a4f47c', '#ffffff']; for (var fi = 0; fi < 56; fi++) { // 컨페티 56 — 크기/모양/낙하 곡선 혼합 var cf = document.createElement('div'); var cw = 6 + Math.random() * 9; cf.style.cssText = 'position:fixed;z-index:42;pointer-events:none;width:' + cw + 'px;height:' + (cw * (0.7 + Math.random() * 0.9)) + 'px;' + 'border-radius:' + (fi % 3 ? '2px' : '50%') + ';left:' + (Math.random() * 100) + 'vw;top:-5vh;background:' + confc[fi % confc.length] + ';' + 'animation:' + (fi % 2 ? 'cqconf' : 'cqconf2') + ' ' + (1.2 + Math.random() * 1.5) + 's ' + (Math.random() * 0.5) + 's ease-in forwards;'; document.body.appendChild(cf); (function (el5) { setTimeout(function () { try { el5.remove(); } catch (e) {} }, 3800); })(cf); } } // ── AR 감지 레이더(요-상대 극좌표) — 회전 스윕선·스윕 통과 시 블립 펄스·거리 비례 반경 ── // 2026-07-25 홀로-HUD 컨셉: 하단 중앙 컴퍼스 다이얼(참조 이미지 문법 — 중심 플레이어 화살표 + 방위 눈금 링). var rad2 = document.createElement('canvas'); rad2.width = rad2.height = 120; rad2.setAttribute('data-cq-radar', '1'); rad2.style.cssText = 'position:fixed;left:50%;bottom:18px;transform:translateX(-50%);width:116px;height:116px;z-index:6;pointer-events:none;display:none;filter:drop-shadow(0 0 12px rgba(94,240,226,.3));'; document.body.appendChild(rad2); var sweep_a = 0; function radar2_draw(t, recs) { sweep_a = (sweep_a + t * 2.4) % 6.2832; var g3 = rad2.getContext('2d'); g3.clearRect(0, 0, 120, 120); g3.beginPath(); g3.arc(60, 60, 56, 0, 6.2832); g3.fillStyle = 'rgba(8,14,22,.6)'; g3.fill(); g3.strokeStyle = 'rgba(124,240,255,.4)'; g3.lineWidth = 1.5; g3.stroke(); for (var rg = 1; rg <= 2; rg++) { g3.beginPath(); g3.arc(60, 60, 56 * rg / 3, 0, 6.2832); g3.strokeStyle = 'rgba(124,240,255,.16)'; g3.stroke(); } g3.beginPath(); g3.moveTo(60, 8); g3.lineTo(60, 112); g3.moveTo(8, 60); g3.lineTo(112, 60); g3.strokeStyle = 'rgba(124,240,255,.1)'; g3.stroke(); var grd = g3.createConicGradient ? g3.createConicGradient(sweep_a - 1.5708, 60, 60) : null; // 스윕 잔광 부채꼴 if (grd) { grd.addColorStop(0, 'rgba(124,240,255,0)'); grd.addColorStop(0.86, 'rgba(124,240,255,0)'); grd.addColorStop(1, 'rgba(124,240,255,.3)'); g3.beginPath(); g3.moveTo(60, 60); g3.arc(60, 60, 55, 0, 6.2832); g3.closePath(); g3.fillStyle = grd; g3.fill(); } g3.beginPath(); g3.moveTo(60, 60); // 스윕 선 g3.lineTo(60 + Math.sin(sweep_a) * 55, 60 - Math.cos(sweep_a) * 55); g3.strokeStyle = 'rgba(124,240,255,.85)'; g3.lineWidth = 1.6; g3.stroke(); var look_y = (arcam.look() || {}).yaw || 0; for (var bi = 0; bi < recs.length; bi++) { var rb = recs[bi]; if (!rb) continue; var relr = ((rb.yaw - look_y) * 0.017453); var rr5 = Math.min(1, (rb.dist_now || rb.dist || 3) / 6) * 46; var bx = 60 - Math.sin(relr) * rr5, by = 60 - Math.cos(relr) * rr5; var sd = Math.abs((((relr - sweep_a) % 6.2832) + 6.2832) % 6.2832); rb._glow = Math.max((sd < 0.35 || sd > 5.93) ? 1 : 0, (rb._glow || 0) * 0.94); var col = species[rb.sp_i].color; g3.beginPath(); g3.arc(bx, by, 3 + 3.5 * rb._glow, 0, 6.2832); g3.fillStyle = col; g3.globalAlpha = 0.55 + 0.45 * rb._glow; g3.fill(); g3.globalAlpha = 1; } for (var tk5 = 0; tk5 < 24; tk5++) { // 외곽 방위 눈금 링(컴퍼스 다이얼 문법) — 12시 = 시선 방향 var ta5 = tk5 * 0.2618; var tl5 = tk5 % 6 === 0 ? 5 : 2.5; g3.beginPath(); g3.moveTo(60 + Math.sin(ta5) * (56 - tl5), 60 - Math.cos(ta5) * (56 - tl5)); g3.lineTo(60 + Math.sin(ta5) * 56, 60 - Math.cos(ta5) * 56); g3.strokeStyle = 'rgba(124,240,255,' + (tk5 % 6 === 0 ? '.6' : '.28') + ')'; g3.lineWidth = 1.4; g3.stroke(); } g3.beginPath(); // 플레이어(중심) — 컨셉 다이얼의 상향 화살촉(시선 = 12시 고정) g3.moveTo(60, 51); g3.lineTo(66.5, 66); g3.lineTo(60, 61.5); g3.lineTo(53.5, 66); g3.closePath(); g3.fillStyle = '#bffef4'; g3.shadowColor = '#5ef0e2'; g3.shadowBlur = 8; g3.fill(); g3.shadowBlur = 0; } // ── 펄스 사격(능동 전투 동사 — 스캔 문법 내 사격: 볼-투척 아님) ── var fire_btn = _btn('', 'right:16px;bottom:15%;width:68px;height:68px;display:none;', 'pbtn--a'); // 주 액션 = 녹색 링(콘솔 규약) fire_btn.setAttribute('data-cq-fire', '1'); fire_btn.setAttribute('aria-label', _at_t('펄스 발사', 'パルス発射', 'Fire pulse')); // 공격 아이콘 — 표준 패드 글리프 언어(굵은 라운드 아웃라인 단일 형상, currentColor 발광 — 참조 버튼 동형, 2026-07-24 확정) fire_btn.innerHTML = '
' + '
' + '
'; // ── 펄스 VFX v2(2026-07-24 사용자 요청 "멋지고 화려한 공격") — 머즐 플래시 → 곡선 궤적 에너지 볼트 // (코어 맥동 + 백색 심지 + 나선 궤도 입자 2 + 잔상 트레일) → 명중 충격파 링 2중 + 2톤 스파크 + 셰이크. // 전부 additive 무텍스처 프리미티브(depthWrite:false — 피드 합성/입자 겹침 안전), 수명 짧아 상시 부하 미미. var pulse_cd = 0, pulses = []; // {grp, core, wick, orb1, orb2, from, ctrl, to, t, dur, gt, oa, rec} var pulse_fx = []; // 단명 이펙트 {mesh, t, life, s0, s1, o0, bb(빌보드)} function _pfx_mat(color, opacity) { return new T3.MeshBasicMaterial({ color: color, transparent: true, opacity: opacity, blending: T3.AdditiveBlending, depthWrite: false }); } // ── 소프트 스프라이트 글로우(2026-07-24 실감 캐논): 하드엣지 지오메트리(콘/구)가 "폴리곤 도형"으로 // 읽히는 실보고 봉합 — 방사형 그라디언트 캔버스 텍스처 스프라이트가 화염/에너지/섬광의 유일 문법. var _spr_texes = {}; function _spr_tex(color) { if (_spr_texes[color]) return _spr_texes[color]; var cv9 = document.createElement('canvas'); cv9.width = cv9.height = 64; var cx9 = cv9.getContext('2d'); var gr9 = cx9.createRadialGradient(32, 32, 0, 32, 32, 32); gr9.addColorStop(0, '#ffffff'); gr9.addColorStop(0.28, color); gr9.addColorStop(1, 'rgba(0,0,0,0)'); cx9.fillStyle = gr9; cx9.fillRect(0, 0, 64, 64); _spr_texes[color] = new T3.CanvasTexture(cv9); return _spr_texes[color]; } function _spr(color, size, opacity) { var sp = new T3.Sprite(new T3.SpriteMaterial({ map: _spr_tex(color), transparent: true, opacity: opacity, blending: T3.AdditiveBlending, depthWrite: false })); sp.scale.set(size, size, 1); return sp; } function _pfx(mesh, life, s0, s1, o0, bb, dy) { mesh.scale.setScalar(s0); scene.add(mesh); pulse_fx.push({ mesh: mesh, t: 0, life: life, s0: s0, s1: s1, o0: o0, bb: bb, dy: dy || 0 }); } function _pfx_ring(pos, color, life, s1) { // 카메라 정면 충격파 링 var r6 = new T3.Mesh(new T3.RingGeometry(0.16, 0.24, 28), _pfx_mat(color, 0.9)); r6.position.copy(pos); _pfx(r6, life, 0.2, s1, 0.9, true); } // ── 탄환 팔레트 + 아이템 버프 5종(2026-07-24 사용자 로스터 확정) ── // 일반탄 = 매 발 랜덤 팔레트. 필드 아이템(동시 1개)이 테마 프롭으로 AR 공간에 주기 등장 → 쏘면 획득. // 버프는 시간제(초) — 상단 카운트다운 배너가 활성 아이템을 상시 표시: // ⚡ 공속 UP(연사 가속) / 🌀 연타(1회 발사=14발 무지개 스웜) / ❄ 빙결(맞춤 결정 셸 정지) // 🔥 즉시 연소(화염 궤적 + 한 방 소멸) / 💣 범위 폭발(직격 불요 스플래시). var PULSE_COLS = ['#7cf0ff', '#ff8ae2', '#ffd24a', '#9dff6a', '#c9a2ff', '#ff9d7c']; var buff = null; // {kind, until(ms)} var item = null, next_item = 0; // 필드 아이템(동시 1개) — {kind, anchor, t, tw} var ITEM_DEFS = { // model = 카탈로그 실물 픽업 프롭(생성기 매니페스트 상시 동봉) — 🔥는 스프라이트 실화염 rapid: { icon: '⚡', color: '#ffd24a', dur: 10, model: 'cosmo_pickup_thunder', mh: 0.5, name: _at_t('공격속도 UP', 'こうげきそくどUP', 'Rapid Fire') }, multi: { icon: '🌀', color: '#c9a2ff', dur: 8, model: 'cosmo_pickup_bullets', mh: 0.5, name: _at_t('연타 사격', 'れんだシュート', 'Volley Storm') }, ice: { icon: '❄️', color: '#8ad8ff', dur: 10, model: 'item_snowflake', mh: 0.45, name: _at_t('얼음 구슬', 'こおりだま', 'Frost Orb') }, fire: { icon: '🔥', color: '#ff8a3d', dur: 7, model: null, mh: 0, name: _at_t('불꽃 구슬', 'ほのおだま', 'Blaze Orb') }, bomb: { icon: '💣', color: '#ff6a6a', dur: 8, model: 'adv_smokebomb', mh: 0.45, name: _at_t('폭탄', 'ばくだん', 'Boom Bomb') }, }; var ITEM_KINDS = ['rapid', 'multi', 'ice', 'fire', 'bomb']; var buff_bar = document.createElement('div'); // 상단 활성 배너 — "아이템 — N초" 카운트다운 buff_bar.style.cssText = 'position:fixed;left:50%;top:58px;transform:translateX(-50%);z-index:8;display:none;pointer-events:none;font:800 14px system-ui;color:#fff;padding:8px 16px;border-radius:999px;backdrop-filter:blur(6px);background:rgba(18,24,36,.72);white-space:nowrap;letter-spacing:.2px;text-shadow:0 1px 3px rgba(0,0,0,.6);'; document.body.appendChild(buff_bar); (function () { // 배너 맥동 키프레임(1회 주입) var s9 = document.createElement('style'); s9.textContent = '@keyframes cq-buffpulse{0%,100%{transform:translateX(-50%) scale(1)}50%{transform:translateX(-50%) scale(1.06)}}'; document.head.appendChild(s9); })(); buff_bar.style.animation = 'cq-buffpulse 1.1s ease-in-out infinite'; function buff_sync(now) { if (!buff) { buff_bar.style.display = 'none'; return; } var d9 = ITEM_DEFS[buff.kind]; var left = Math.max(0, Math.ceil((buff.until - (now || Date.now())) / 1000)); buff_bar.style.display = ''; buff_bar.style.border = '1.5px solid ' + d9.color; buff_bar.style.boxShadow = '0 0 16px ' + d9.color + ', inset 0 0 8px rgba(255,255,255,.12)'; buff_bar.textContent = d9.icon + ' ' + d9.name + ' — ' + left + _at_t('초', '秒', 's'); } var buff_acc = 0; function tick_buff(t, now) { if (!buff) return; if (now >= buff.until) { buff = null; buff_sync(); P.toast(_at_t('아이템 효과 종료', 'アイテムこうか終了', 'Power-up expired')); return; } buff_acc += t; if (buff_acc > 0.25) { buff_acc = 0; buff_sync(now); } } function pulse_style() { // 발사 시점 스냅샷 — 비행 중 버프 변경과 무간섭 var bk = buff && buff.kind; if (bk === 'fire') return { core: '#ff9a3d', glow: 2.3, wick: '#ffe9b0', o1: '#ff6a2a', o2: '#ffd24a', trail: '#ff8a3d', ring: '#ff9a3d', burst: '#ffd24a', fl: '#ffe9b0', dmg: 0, burn: true, shake: 0.2, kind: 'fire', tr_iv: 0.008, cd: 420 }; if (bk === 'ice') return { core: '#8ad8ff', glow: 2.0, wick: '#ffffff', o1: '#bfe8ff', o2: '#e8fbff', trail: '#9adfff', ring: '#bfe8ff', burst: '#ffffff', fl: '#eaffff', dmg: 0.45, shake: 0.14, kind: 'ice', tr_iv: 0.014, cd: 420 }; if (bk === 'bomb') return { core: '#2a2d36', glow: 0.7, wick: '#ff6a6a', o1: '#ffd24a', o2: '#ff6a6a', trail: '#ffb03d', ring: '#ff9a3d', burst: '#ffd24a', fl: '#fff1d8', dmg: 0.55, bomb: true, shake: 0.3, kind: 'bomb', tr_iv: 0.02, cd: 700, arc: 0.8 }; var base = PULSE_COLS[(Math.random() * PULSE_COLS.length) | 0]; var stn = { core: base, glow: 2.0, wick: '#ffffff', o1: base, o2: '#ffffff', trail: base, ring: base, burst: '#ffffff', fl: '#eaffff', dmg: 0.45, shake: 0.14, kind: bk || null, tr_iv: 0.014, cd: 420 }; if (bk === 'rapid') { stn.cd = 160; stn.core = '#ffd24a'; stn.trail = '#ffd24a'; stn.ring = '#ffd24a'; stn.o1 = '#fff3c0'; stn.glow = 2.2; } if (bk === 'multi') { stn.cd = 560; stn.volley = 14; stn.dmg = 0.1; stn.tr_iv = 0.03; stn.no_dart = true; } return stn; } function item_mesh(kind) { // 실물 프롭(카탈로그 GLB) + 소프트 글로우 후광 — 🔥만 스프라이트 실화염 이미터 var g = new T3.Group(); var d0 = ITEM_DEFS[kind]; var halo = new T3.Mesh(new T3.TorusGeometry(0.28, 0.02, 8, 26), _pfx_mat(d0.color, 0.8)); // 받침 헤일로 halo.rotation.x = Math.PI / 2; halo.position.y = -0.24; g.add(halo); g.add(_spr(d0.color, 0.9, 0.45)); // 배후 소프트 글로우 후광(스프라이트 — 폴리곤 실루엣 없음) if (kind === 'fire') { g._fire_emit = true; // tick_items 실화염 스프라이트 이미터 소유(상승·플리커 파티클) var wk = _spr('#ffd24a', 0.34, 0.95); wk.position.y = -0.06; g.add(wk); } else { var ph9 = new T3.Mesh(new T3.OctahedronGeometry(0.16, 0), P.make.glow(d0.color, 1.4)); // 프롭 도착 전 자리(매니페스트 부재 구세대 유지 형태) g.add(ph9); cq_acquire(d0.model, d0.mh, function (h9) { try { g.remove(ph9); } catch (e) {} g.add(h9.obj); }); } return g; } function item_spawn() { var kind = ITEM_KINDS[(Math.random() * ITEM_KINDS.length) | 0]; var anchor = arcam.anchor({ yaw: (Math.random() * 2 - 1) * 55, pitch: 4 + Math.random() * 8, dist: 1.9 + Math.random() * 0.9, obj: item_mesh(kind), color: ITEM_DEFS[kind].color, }); item = { kind: kind, anchor: anchor, t: 0, tw: 0 }; P.toast(ITEM_DEFS[kind].icon + ' ' + ITEM_DEFS[kind].name + ' — ' + _at_t('발견! 쏘면 획득해요', 'はっけん!うって入手', 'spotted — shoot it to grab it!')); fx.sfx('blip', { volume: 0.5 }); } function item_clear() { if (item) { try { item.anchor.remove(); } catch (e) {} item = null; } } function item_collect(kind) { buff = { kind: kind, until: Date.now() + ITEM_DEFS[kind].dur * 1000 }; buff_sync(); next_item = Date.now() + 16000 + Math.random() * 9000; var msg9 = { rapid: _at_t('공격속도 대폭 UP!', 'こうげきそくど大UP!', 'Fire rate way up!'), multi: _at_t('한 번에 수십발 연타!', '一気にれんだ!', 'Dozens of bolts per shot!'), ice: _at_t('맞은 몬스터는 얼어붙어요', 'あてるとこおる', 'Hits freeze monsters solid!'), fire: _at_t('한 방에 불타올라 소멸!', '一発で燃えつきる!', 'One hit burns them down!'), bomb: _at_t('폭발 범위 피해!', 'ばくはつ範囲ダメージ!', 'Explosive splash damage!'), }[kind]; P.toast(ITEM_DEFS[kind].icon + ' ' + ITEM_DEFS[kind].name + ' — ' + msg9); fx.sfx('powerup', { volume: 0.75 }); fx.shake(0.08); } function tick_items(t, now) { if (!item) { if (!next_item) next_item = now + 8000 + Math.random() * 6000; if (now >= next_item && arcam_ready) item_spawn(); return; } item.t += t; var io = item.anchor && item.anchor.obj; if (io) { // 프롭 생동 — 스핀·봅·트윙클 + 🔥 실화염 이미터(상승 소프트 스프라이트) io.rotation.y += t * 1.8; io.position.y += Math.sin(now / 300) * 0.002; if (io._fire_emit) { item.fe = (item.fe || 0) + t; if (item.fe > 0.045) { item.fe = 0; var fs = _spr(Math.random() < 0.55 ? '#ff8a3d' : '#ffd24a', 0.3, 0.85); fs.position.copy(io.position); fs.position.x += (Math.random() - 0.5) * 0.14; fs.position.y -= 0.12; fs.position.z += (Math.random() - 0.5) * 0.14; _pfx(fs, 0.38 + Math.random() * 0.14, 0.26 + Math.random() * 0.1, 0.07, 0.85, false, 0.65); // 상승하며 조여드는 불꽃 } } item.tw += t; if (item.tw > 1.4) { item.tw = 0; fx.burst(io.position, { color: ITEM_DEFS[item.kind].color, count: 5 }); } } if (item.t > 14) { item_clear(); next_item = now + 10000 + Math.random() * 8000; } // 만료 — 다음 예약 } // ── 빙결(얼음탄 명중) — 결정 셸에 갇혀 완전 정지, 해동 시 파편 비산 ── function rec_freeze(rec, ms) { var obj = rec.anchor && rec.anchor.obj; if (!obj) return; rec.frozen_until = Date.now() + ms; rec.dash = null; if (rec.charge) { rec.charge = null; rec.dist_now = rec.dist; rec.anchor.set({ dist: rec.dist }); } // 돌진 중 빙결 = 돌진 취소·복귀 if (rec.escape_at) rec.escape_at += ms; // 빙결 시간만큼 도주/재돌진/배회 연기 if (rec.next_charge) rec.next_charge += ms; if (rec.next_move) rec.next_move += ms; if (!rec.ice_shell) { // 바운딩박스 맞춤 결정 인케이스먼트 — 어떤 체구의 몬스터든 얼음이 몸을 제대로 덮는다 var bb = new T3.Box3().setFromObject(obj); var sz = bb.getSize(new T3.Vector3()); var cen = bb.getCenter(new T3.Vector3()); obj.worldToLocal(cen); var rad = Math.max(sz.x, sz.z, 0.6) * 0.7; var shell = new T3.Group(); var blk = new T3.Mesh(new T3.IcosahedronGeometry(1, 1), new T3.MeshBasicMaterial({ color: '#bfe8ff', transparent: true, opacity: 0.42, depthWrite: false })); blk.scale.set(rad, Math.max(sz.y * 0.65, rad * 0.9), rad); shell.add(blk); rec.ice_mat = blk.material; // tick_rec 반짝임 대상 for (var si = 0; si < 4; si++) { // 표면 돌출 크리스탈 스파이크 var sp9 = new T3.Mesh(new T3.OctahedronGeometry(rad * 0.22, 0), new T3.MeshBasicMaterial({ color: '#e8fbff', transparent: true, opacity: 0.6, depthWrite: false })); var a9 = si * 1.57 + 0.6; sp9.position.set(Math.cos(a9) * rad * 0.8, (si % 2 ? 0.3 : -0.15) * sz.y * 0.5, Math.sin(a9) * rad * 0.8); sp9.scale.y = 1.8; sp9.rotation.set(0.4 * (si % 2 ? 1 : -1), a9, 0.3); shell.add(sp9); } shell.position.copy(cen); obj.add(shell); rec.ice_shell = shell; } fx.burst(obj.position, { color: '#bfe8ff', count: 16 }); _pfx_ring(obj.position, '#8ad8ff', 0.22, 3.4); fx.sfx('blip', { volume: 0.7 }); } function rec_thaw(rec) { var obj = rec.anchor && rec.anchor.obj; rec.frozen_until = 0; if (rec.ice_shell) { try { obj.remove(rec.ice_shell); } catch (e) {} rec.ice_shell = null; rec.ice_mat = null; } if (obj) { // 셸 파열 — 얼음 파편 비산 fx.burst(obj.position, { color: '#bfe8ff', count: 22 }); _pfx_ring(obj.position, '#bfe8ff', 0.25, 4.5); fx.sfx('hit', { volume: 0.4 }); } } // 실총 샷건 발사음(FFSL CC0 — camo `_sg_fire` 계약 미러, 2026-07-25 사용자 확정): Mossberg 500· // Benelli Nova 근접 스튜디오 녹음. 원본이 파일당 2발+선행 무음 수록이라 반드시 슬라이스 재생 // (통짜 재생 = 발사-소리 싱크 어긋남 실측). vendor-ext 콘텐츠-해시 불변 키 — 어느 게임 참조든 안전. var _SG_URLS = [ cq_vendor_root() + '/vendor-ext/ac7208fc9a9dff8c1455a1a05f55bd240410aec2117dea28e72430eb7c4846df.m4a', cq_vendor_root() + '/vendor-ext/8dbe9a05ce9fd694de0a6bb121732c4dc1a70ab4a48b7ef060c7d71c9dac7942.m4a', ]; for (var _sgp = 0; _sgp < _SG_URLS.length; _sgp++) P.sfx._bank_load(_SG_URLS[_sgp]); // 부팅 프리로드 var _SG_CUTS = [ // offset = 온셋 20ms 앞(어택 보존), duration = 실측 테일 + 여유(camo 파형 실측 승계) { u: 0, off: 1.676, dur: 1.1 }, { u: 0, off: 4.940, dur: 1.1 }, { u: 1, off: 0.675, dur: 1.9 }, { u: 1, off: 3.687, dur: 1.9 }, ]; function _sg_fire() { // 근접 스튜디오 단발(4슬라이스 랜덤 + 미세 재생속도 변주 = 실총 변주 문법) if (P._muted) return; var c9 = _SG_CUTS[(Math.random() * _SG_CUTS.length) | 0]; var b9 = P.sfx._bank_get(_SG_URLS[c9.u]); var ok9 = !!(b9 && P.sfx._bank_play(b9, 0.9, { offset: c9.off, duration: c9.dur, rate: 0.96 + Math.random() * 0.08 })); if (!ok9) fx.sfx('shoot', { volume: 0.6 }); // 미디코드 = 합성 폴백(무음 회귀 금지) } function fire_pulse(get_focus) { if (_pause9 && _pause9.on) return; // 열람 일시정지 중 발사 차단(발사 버튼 z 가 도감/초대 위라 눌림 가능) var nowp = Date.now(); if (nowp < pulse_cd || nowp < down_until) return; var st0 = pulse_style(); // 발사 시점 스타일 스냅샷 — 쿨다운도 스타일 소유(⚡=160ms, 💣=700ms) pulse_cd = nowp + st0.cd; _sg_fire(); // 실총 샷건 슬라이스(FFSL) — 합성 'shoot' 대체 fx.shake(0.06); // 발사 반동(미세) var from = new T3.Vector3(0.12, -0.32, -0.5).applyQuaternion(camera.quaternion).add(camera.position); var tgt_it = (item && arcam.aimed(item.anchor, 12)) ? item : null; // 아이템 조준 = 획득 사격(크리처보다 우선) var rec5 = tgt_it ? null : get_focus(); var to0; if (tgt_it && tgt_it.anchor.obj) to0 = tgt_it.anchor.obj.position.clone(); else if (rec5 && rec5.anchor && rec5.anchor.obj) to0 = rec5.anchor.obj.position.clone(); else to0 = new T3.Vector3(0, 0, -(st0.bomb ? 4 : 6)).applyQuaternion(camera.quaternion).add(camera.position); // 머즐 플래시(총구 개화 — 소프트 스프라이트) var mz = _spr(st0.wick, 1, 0.9); mz.position.copy(from); _pfx(mz, 0.09, 0.15, 0.75, 0.9); var volley = st0.volley || 1; for (var vi = 0; vi < volley; vi++) { var st = st0; if (vi > 0) { // 🌀 연타 = 발마다 새 팔레트(무지개 스웜) — 전투 필드는 첫 발 스냅샷 승계 st = pulse_style(); st.dmg = st0.dmg; st.volley = st0.volley; st.no_dart = true; st.tr_iv = st0.tr_iv; } // 곡선 제어점 — 측면+상방 보우(발사마다 좌/우 랜덤, 💣은 높은 로브 아크) var jto = volley > 1 ? to0.clone().add(new T3.Vector3((Math.random() - 0.5) * 0.5, (Math.random() - 0.5) * 0.4, (Math.random() - 0.5) * 0.3)) : to0; var side = new T3.Vector3(1, 0, 0).applyQuaternion(camera.quaternion) .multiplyScalar((Math.random() < 0.5 ? -1 : 1) * (0.35 + Math.random() * (volley > 1 ? 0.55 : 0.3))); var ctrl = from.clone().lerp(jto, 0.5).add(side).add(new T3.Vector3(0, 0.22 + (st0.arc || 0), 0)); // 볼트 본체: 소프트 스프라이트 2겹(코어+백심) — 폴리곤 실루엣 없는 플라즈마 구(+단발만 궤도 입자 2) var grp = new T3.Group(); var base9 = volley > 1 ? 0.3 : 0.46; var core = _spr(st.core, base9, 0.95); var wick = _spr('#ffffff', base9 * 0.4, 0.9); grp.add(core); grp.add(wick); var orb1 = null, orb2 = null; if (volley === 1) { orb1 = _spr(st.o1, 0.14, 0.9); orb2 = _spr(st.o2, 0.14, 0.9); grp.add(orb1); grp.add(orb2); } grp.position.copy(from); grp.visible = vi === 0; scene.add(grp); pulses.push({ grp: grp, core: core, base: base9, orb1: orb1, orb2: orb2, from: from, ctrl: ctrl, to: jto, t: -vi * 0.035, dur: 0.26, gt: 0, oa: Math.random() * 6.28, rec: rec5, it: tgt_it, st: st }); } } function tick_pulses(t) { for (var fi2 = pulse_fx.length - 1; fi2 >= 0; fi2--) { // 단명 이펙트(트레일/링/플래시/화염) var e6 = pulse_fx[fi2]; e6.t += t; var kf2 = Math.min(1, e6.t / e6.life); e6.mesh.scale.setScalar(e6.s0 + (e6.s1 - e6.s0) * kf2); e6.mesh.material.opacity = e6.o0 * (1 - kf2); if (e6.dy) e6.mesh.position.y += e6.dy * t; // 화염 상승 드리프트 if (e6.bb) e6.mesh.lookAt(camera.position); if (kf2 >= 1) { try { scene.remove(e6.mesh); } catch (e) {} pulse_fx.splice(fi2, 1); } } for (var pi = pulses.length - 1; pi >= 0; pi--) { var pu = pulses[pi]; pu.t += t; if (pu.t < 0) { pu.grp.visible = false; continue; } // 🌀 볼리 스태거 대기 pu.grp.visible = true; var k5 = Math.min(1, pu.t / pu.dur); var iv = 1 - k5; // 2차 베지어 궤적 pu.grp.position.set( iv * iv * pu.from.x + 2 * k5 * iv * pu.ctrl.x + k5 * k5 * pu.to.x, iv * iv * pu.from.y + 2 * k5 * iv * pu.ctrl.y + k5 * k5 * pu.to.y, iv * iv * pu.from.z + 2 * k5 * iv * pu.ctrl.z + k5 * k5 * pu.to.z); pu.core.scale.setScalar(pu.base * (1 + 0.22 * Math.sin(pu.t * 65))); // 코어 맥동(스프라이트 = 스케일이 크기) if (pu.orb1) { // 나선 궤도(진행할수록 조임 — 볼리 경량 볼트는 미보유) pu.oa += t * 34; var orr = 0.13 * (1 - k5 * 0.55); pu.orb1.position.set(Math.cos(pu.oa) * orr, Math.sin(pu.oa) * orr, 0); pu.orb2.position.set(Math.cos(pu.oa + 3.14) * orr, Math.sin(pu.oa + 3.14) * orr, 0); } pu.gt += t; // 잔상 트레일 — 🔥는 궤적 그대로 불길이 남는다(콘 화염 + 성장·상승 잔광) if (pu.gt > pu.st.tr_iv) { pu.gt = 0; if (pu.st.burn) { // 궤적 그대로 타오르는 불길 — 상승 소프트 화염 var fg = _spr(Math.random() < 0.5 ? '#ff8a3d' : '#ffd24a', 0.3, 0.85); fg.position.copy(pu.grp.position); _pfx(fg, 0.45, 0.2, 0.5, 0.85, false, 0.55); } else { var gh = _spr(pu.st.trail, 0.26, 0.6); gh.position.copy(pu.grp.position); _pfx(gh, 0.2, 0.26, 0.07, 0.6); } } if (k5 >= 1) { try { scene.remove(pu.grp); } catch (e) {} pulses.splice(pi, 1); var st9 = pu.st; if (st9.bomb) { // 💣 범위 폭발 — 직격 불요, 반경 내 전원 피해(빗나가도 폭발) _pfx_ring(pu.to, '#ff9a3d', 0.35, 10); _pfx_ring(pu.to, '#ffd24a', 0.22, 6); _pfx_ring(pu.to, '#ffffff', 0.16, 3.5); var fb = _spr('#ffd24a', 1, 0.95); // 화구(소프트 스프라이트 — 폭발 개화) fb.position.copy(pu.to); _pfx(fb, 0.25, 0.5, 3.2, 0.95); var fb2 = _spr('#ff8a3d', 1, 0.85); fb2.position.copy(pu.to); _pfx(fb2, 0.35, 0.8, 4.2, 0.85); for (var bo = 0; bo < 8; bo++) { // 폭염 파편 — 사방 상승 화염 var bf = _spr(bo % 2 ? '#ff8a3d' : '#ffd24a', 0.4, 0.9); bf.position.copy(pu.to); bf.position.x += (Math.random() - 0.5) * 0.9; bf.position.y += Math.random() * 0.5 - 0.1; bf.position.z += (Math.random() - 0.5) * 0.9; _pfx(bf, 0.4 + Math.random() * 0.25, 0.35, 0.9, 0.9, false, 0.8 + Math.random() * 0.7); } fx.burst(pu.to, { color: '#ff9a3d', count: 30 }); fx.burst(pu.to, { color: '#ffd24a', count: 16 }); fx.shake(0.3); fx.sfx('explosion', { volume: 0.8 }); var lst9 = (phase === 'indoor' && ind) ? ind.list : (enc ? [enc] : []); for (var bi = 0; bi < lst9.length; bi++) { var br = lst9[bi]; if (br.hold != null && br.anchor && br.anchor.obj && br.anchor.obj.position.distanceTo(pu.to) < 3.5) { br.hold += st9.dmg; rec_hurt(br); } } if (pu.it && pu.it === item) { var kb = pu.it.kind; item_clear(); item_collect(kb); } // 폭발이 획득도 겸함 } else if (pu.it && pu.it === item) { // 아이템 명중 = 획득(만료로 이미 사라졌으면 무시) var kind9 = pu.it.kind; fx.burst(pu.to, { color: ITEM_DEFS[kind9].color, count: 18 }); _pfx_ring(pu.to, ITEM_DEFS[kind9].color, 0.25, 4); item_clear(); item_collect(kind9); } else if (pu.rec && pu.rec.hold != null) { // 명중 = 에너지 감소(유일한 대미지 경로 — 조준 단독은 무대미지) + 반동 대시 rec_hurt(pu.rec); // 피격 리액션(hit 클립 원샷 + 움찔 스쿼시) if (st9.burn) { // 🔥 즉시 연소 — hold 임계 도달로 기존 포획 이음에 합류(이중 award 구조적 불가) + 실화염 블레이즈 pu.rec.hold = Math.max(pu.rec.hold, pu.rec.need); for (var fi9 = 0; fi9 < 10; fi9++) { // 상승 소프트 화염 다발 — 몸을 감싸고 타오른다 var fs9 = _spr(['#ff6a2a', '#ff8a3d', '#ffd24a'][fi9 % 3], 0.4, 0.9); fs9.position.copy(pu.to); fs9.position.x += (Math.random() - 0.5) * 0.5; fs9.position.y += Math.random() * 0.5 - 0.2; fs9.position.z += (Math.random() - 0.5) * 0.5; _pfx(fs9, 0.5 + Math.random() * 0.35, 0.3 + Math.random() * 0.25, 0.85, 0.9, false, 0.9 + Math.random() * 0.6); } fx.burst(pu.to, { color: '#ff8a3d', count: 26 }); fx.burst(pu.to, { color: '#ffd24a', count: 14 }); fx.shake(0.22); fx.sfx('explosion', { volume: 0.6 }); } else { pu.rec.hold += st9.dmg; if (st9.kind === 'ice') rec_freeze(pu.rec, 3200); // 얼음탄 = 빙결(정지 — 반동 대시도 없음) else if (!st9.no_dart && pu.rec.darts_left > 0 && !(pu.rec.frozen_until > Date.now())) { pu.rec.darts_left--; start_dash(pu.rec, ((pu.rec.darts_left % 2) ? 1 : -1) * (26 + pu.rec.darts_left * 8)); } _pfx_ring(pu.to, st9.ring, 0.3, 6.5); // 충격파 링 2중(탄색 대형 + 백색 소형 속발) _pfx_ring(pu.to, '#ffffff', 0.18, 3.2); var fl6 = _spr(st9.fl, 1, 0.95); // 임팩트 코어 섬광(소프트 스프라이트) fl6.position.copy(pu.to); _pfx(fl6, 0.12, 0.5, 1.9, 0.95); fx.burst(pu.to, { color: st9.ring, count: st9.volley > 1 ? 8 : 18 }); fx.burst(pu.to, { color: st9.burst, count: st9.volley > 1 ? 4 : 10 }); fx.shake(st9.volley > 1 ? 0.05 : st9.shake); fx.sfx('hit', { volume: st9.volley > 1 ? 0.25 : 0.55, combo: !(st9.volley > 1) }); } } else { // 빗나감 — 소형 피즐(소프트 스프라이트) var fz = _spr(st9.trail, 1, 0.7); fz.position.copy(pu.to); _pfx(fz, 0.14, 0.2, 0.55, 0.7); } } } } // ── 피격 리액션 + 소멸 연출(2026-07-24 사용자 요청 "아파하는 액션") ── // 펄스 명중 = hit 클립 원샷 + 움찔 스쿼시(모델·절차 공통), 포획 = die 클립 재생 후 소멸. var dying = []; // {anchor, cq, t} — 포획 후 die 연출 소유(제거는 여기서) function rec_hurt(rec) { rec.flinch = 0.3; // 움찔 스쿼시 창(초) — tick_rec 이 스케일 펄스로 소비 voice(species[rec.sp_i], 'hurt'); // 피격 신음 — 종 고유 배속 if (rec.escape_at) rec.escape_at = Math.max(rec.escape_at, Date.now() + 6000); // 교전 중 도주 유예(명중마다 +6s) var cqh = rec.anchor && rec.anchor.obj && rec.anchor.obj._cq; if (cqh && cqh.actions && cqh.actions.hit) { cqh.play('hit'); clearTimeout(rec._hit_t); rec._hit_t = setTimeout(function () { try { if (!rec.charge && !rec.dash) cqh.play('idle'); } catch (e) {} }, 560); } } function start_die(rec) { // die 클립 보유 시 소멸 연출로 이관(true = 앵커 제거를 dying 이 소유) var cq = rec.anchor && rec.anchor.obj && rec.anchor.obj._cq; if (!(cq && cq.actions && cq.actions.die)) return false; clearTimeout(rec._hit_t); cq.play('die'); dying.push({ anchor: rec.anchor, cq: cq, t: 0 }); return true; } function tick_dying(t) { for (var di = dying.length - 1; di >= 0; di--) { var dd = dying[di]; dd.t += t; if (dd.cq.mixer) dd.cq.mixer.update(t); if (dd.t > 0.5 && dd.anchor.obj) dd.anchor.obj.scale.setScalar(Math.max(0.01, 1 - (dd.t - 0.5) / 0.35)); // 클립 후반 페이드-스케일 if (dd.t > 0.85) { try { dd.anchor.remove(); } catch (e) {} dying.splice(di, 1); } } } // ── 크리처 생동 틱(공유): 대시 트윈(순간이동 금지)·유휴 배회·돌진-회피 판정·믹서 구동·카메라 응시 ── function rec_init_motion(rec, tier_i) { rec.agil = species[rec.sp_i].agil || 0; // 고득점 종 기질 — 빠른 이동·잦은 배회·조기 도주 rec.dist = rec.anchor._dist || 3; rec.dist_now = rec.dist; rec.next_move = Date.now() + (2500 + Math.random() * 3500) * (1 - 0.55 * rec.agil); rec.escape_at = Date.now() + 26000 - rec.agil * 16000 + Math.random() * 4000; // 도주 시각(고득점=빠름) rec.next_charge = Date.now() + Math.max(2600, (9000 + Math.random() * 6000 - tier_i * 1500) * diff_mult()); // 생존 시간 비례 가속 return rec; } function start_dash(rec, delta_yaw) { if (rec.charge) return; rec.dash = { from: rec.yaw, to: rec.yaw + delta_yaw * (1 + 0.6 * (rec.agil || 0)), t: 0, dur: (0.42 + Math.random() * 0.15) * (1 - 0.45 * (rec.agil || 0)) }; // 고득점 종 = 매우 빠른 대시 var cq5 = rec.anchor && rec.anchor.obj && rec.anchor.obj._cq; if (cq5) cq5.play(cq5.actions && cq5.actions.run ? 'run' : 'walk'); fx.sfx('jump', { volume: 0.35 }); } function tick_rec(rec, t, now) { var obj = rec.anchor && rec.anchor.obj; if (!obj) return; if (rec.frozen_until && now < rec.frozen_until) { // 빙결 — 믹서·배회·돌진·도주·응시 전부 동결, 셸 반짝임만 if (rec.ice_mat) rec.ice_mat.opacity = 0.34 + 0.12 * Math.sin(now / 110); return; } if (rec.ice_shell) rec_thaw(rec); // 시간 경과 해동 — 파편 비산 var cq = obj._cq; if (cq && cq.mixer) cq.mixer.update(t); obj.lookAt(camera.position.x, obj.position.y, camera.position.z); // 실물 모델 정면축 = 카메라 응시 if (rec.flinch > 0) { // 피격 움찔 — 스쿼시 펄스(사인 반주기) rec.flinch -= t; var kf = Math.max(0, rec.flinch / 0.3); obj.scale.setScalar(1 - 0.2 * Math.sin(kf * Math.PI)); if (rec.flinch <= 0) obj.scale.setScalar(1); } if (!rec.charge && !rec.escaped && rec.escape_at && now >= rec.escape_at && rec !== cur_focus) rec.escaped = true; // 도주 발동 — 조준 락온 중엔 불가(소비 = 국면 브랜치) if (rec.dash) { // 화면상 고속 이동(순간이동 대체 — 생동/현실감) rec.dash.t += t; var k6 = Math.min(1, rec.dash.t / rec.dash.dur); var e6 = 1 - Math.pow(1 - k6, 3); rec.yaw = rec.dash.from + (rec.dash.to - rec.dash.from) * e6; rec.anchor.set({ yaw: rec.yaw }); if (k6 >= 1) { rec.dash = null; if (cq) cq.play('idle'); } } else if (!rec.charge && now >= rec.next_move) { // 유휴 배회 미니 대시 rec.next_move = now + (3200 + Math.random() * 3800) * (1 - 0.55 * (rec.agil || 0)); start_dash(rec, (Math.random() < 0.5 ? -1 : 1) * (9 + Math.random() * 12)); } if (rec.charge) { // 돌진 — 카메라를 향해 파고든다: 시점 이탈(회피)만이 무피해 var ch = rec.charge; ch.t += t; if (!ch.back) { var k7 = Math.min(1, ch.t / 0.85); rec.dist_now = rec.dist + (0.55 - rec.dist) * (k7 * k7); rec.anchor.set({ dist: rec.dist_now }); if (k7 >= 1) { ch.back = true; ch.t = 0; if (rec.anchor.aim_deg() < 30 && now >= down_until) life_hit(); // 정면 유지 = 피격 / 카메라 돌림·회피 = 무피해 else { P.toast(_at_t('휙! 회피!', 'ヒュッ!回避!', 'Whoosh! Dodged!')); fx.sfx('jump', { volume: 0.5 }); } } } else { var k8 = Math.min(1, ch.t / 0.5); rec.dist_now = 0.55 + (rec.dist - 0.55) * k8; rec.anchor.set({ dist: rec.dist_now }); if (k8 >= 1) { rec.charge = null; rec.next_charge = now + Math.max(2600, (8000 + Math.random() * 7000) * diff_mult()); // 난이도 가속(생존 시간 비례) if (cq) cq.play('idle'); } } } else if (now >= rec.next_charge && now >= down_until) { rec.charge = { t: 0, back: false }; var cq7 = obj._cq; if (cq7) cq7.play(cq7.actions && cq7.actions.attack ? 'attack' : (cq7.actions && cq7.actions.run ? 'run' : 'walk')); P.toast('⚡ ' + species[rec.sp_i].name + ' ' + _at_t('돌진! 피하세요!', 'とっしん!よけて!', 'is charging — dodge!')); voice(species[rec.sp_i], 'attack'); // 공격 포효 = 돌진 텔레그래프(아케이드 상승음 제거 — 공룡 정체성) } } // '잡은 몬스터들' 패널(2026-07-25 사용자 확정 — 舊 '도감' 개칭 + 풀 리디자인): // 전 종 실명 상시 노출(미포획 = 비활성 톤, '???' 폐지) · 포획 종 = ? 상세 버튼 → 360° 회전 뷰어 모달. var panel = null; var sp_modal_close = null; function species_modal(sp) { // 포획 종 상세 — 전용 소형 렌더러 턴테이블(모달 수명 한정, 닫기 시 dispose) if (sp_modal_close) sp_modal_close(); var ov9 = document.createElement('div'); ov9.style.cssText = 'position:fixed;inset:0;z-index:70;background:rgba(3,6,10,.78);backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;'; var cd9 = document.createElement('div'); cd9.style.cssText = 'width:min(88vw,380px);max-height:86vh;overflow:auto;background:linear-gradient(165deg,#131a26,#0b0f18);border:1px solid rgba(94,240,226,.28);border-radius:20px;padding:16px 18px;color:#e8ecf4;font-family:system-ui;box-shadow:0 24px 64px rgba(0,0,0,.6),0 0 28px rgba(94,240,226,.12);'; ov9.appendChild(cd9); var vw9 = document.createElement('canvas'); vw9.style.cssText = 'display:block;width:100%;height:225px;border-radius:14px;background:radial-gradient(circle at 50% 84%,rgba(94,240,226,.18),rgba(0,0,0,0) 62%);'; cd9.appendChild(vw9); var rd9 = null, raf9 = 0; try { rd9 = new T3.WebGLRenderer({ canvas: vw9, antialias: true, alpha: true }); rd9.setPixelRatio(Math.min(2, window.devicePixelRatio || 1)); var sc9 = new T3.Scene(); var cm9 = new T3.PerspectiveCamera(38, 1, 0.1, 50); var obj0 = creature_obj(sp, true); var grp0 = new T3.Group(); grp0.add(obj0); grp0.position.y = 0.3; sc9.add(grp0); sc9.add(new T3.AmbientLight('#8fa3b8', 1.15)); var kl9 = new T3.DirectionalLight('#ffd9a8', 1.6); kl9.position.set(2.5, 3.4, 2.2); sc9.add(kl9); var rl9 = new T3.DirectionalLight('#7cf0ff', 1.9); rl9.position.set(-2.6, 2.4, -3.0); sc9.add(rl9); cm9.position.set(0, 0.62, 2.9); cm9.lookAt(0, 0.34, 0); var pt9 = performance.now(); (function loop9() { raf9 = requestAnimationFrame(loop9); var n9 = performance.now(), dt9 = Math.min(0.06, (n9 - pt9) / 1000); pt9 = n9; grp0.rotation.y += dt9 * 0.85; // 360° 턴테이블 try { if (obj0._cq && obj0._cq.play && obj0._cq.mixer && !obj0._cq._idle9) { obj0._cq._idle9 = 1; obj0._cq.play('idle'); } if (obj0._cq && obj0._cq.mixer) obj0._cq.mixer.update(dt9); } catch (e9) {} var w9 = vw9.clientWidth || 300; if (Math.abs(vw9.width - w9 * rd9.getPixelRatio()) > 1) { rd9.setSize(w9, 225, false); cm9.aspect = w9 / 225; cm9.updateProjectionMatrix(); } rd9.render(sc9, cm9); })(); } catch (e9) { vw9.style.display = 'none'; } // WebGL 컨텍스트 고갈 등 — 텍스트 상세만으로 정직 강등 var nm9 = document.createElement('div'); nm9.textContent = sp.name; nm9.style.cssText = 'margin-top:10px;font:900 22px/1.15 system-ui;color:#fff;text-shadow:0 0 14px ' + sp.color + ';'; cd9.appendChild(nm9); var mt9 = document.createElement('div'); mt9.textContent = tier_tag(sp.tier) + ' · ⭐' + sp.pts + ' · ' + _at_t('포획', 'ゲット', 'Caught') + ' ×' + (dex[sp.name] | 0); mt9.style.cssText = 'margin-top:4px;font:700 11.5px system-ui;color:' + (sp.tier === 'common' ? HOLO : sp.color) + ';letter-spacing:1.6px;'; cd9.appendChild(mt9); if (sp.lore) { // 로어 4절(유래/특징/성격/주의) — 로어 없는 종(저작/합성명)은 뷰어+기본 정보만 var LB9 = [ ['📜', _at_t('유래', '由来', 'Origin'), sp.lore.h], ['🔍', _at_t('특징', '特徴', 'Traits'), sp.lore.f], ['💭', _at_t('성격', '性格', 'Nature'), sp.lore.p], ['⚠️', _at_t('주의', '注意', 'Caution'), sp.lore.w], ]; for (var lb9 = 0; lb9 < LB9.length; lb9++) { var sec9 = document.createElement('div'); sec9.style.cssText = 'margin-top:10px;padding:9px 11px;border-radius:11px;background:rgba(255,255,255,.045);border-left:2.5px solid ' + (lb9 === 3 ? 'rgba(255,180,60,.65)' : 'rgba(94,240,226,.45)') + ';'; var st9b = document.createElement('div'); st9b.textContent = LB9[lb9][0] + ' ' + LB9[lb9][1]; st9b.style.cssText = 'font:800 11px system-ui;letter-spacing:2px;color:' + (lb9 === 3 ? '#ffd28a' : HOLO) + ';margin-bottom:4px;'; var sb9 = document.createElement('div'); sb9.textContent = String((LB9[lb9][2] || [])[_at_lang] || (LB9[lb9][2] || [])[2] || ''); sb9.style.cssText = 'font:500 13px/1.55 system-ui;color:#dbe6ee;'; sec9.appendChild(st9b); sec9.appendChild(sb9); cd9.appendChild(sec9); } } var xb9 = document.createElement('button'); xb9.textContent = _at_t('닫기', 'とじる', 'Close'); xb9.style.cssText = 'margin-top:14px;width:100%;padding:11px;border-radius:12px;border:1px solid rgba(94,240,226,.35);background:rgba(94,240,226,.12);color:#bffef4;font:800 14px system-ui;'; cd9.appendChild(xb9); function close9() { if (raf9) cancelAnimationFrame(raf9); try { if (rd9) rd9.dispose(); } catch (e9) {} try { ov9.remove(); } catch (e9) {} sp_modal_close = null; } sp_modal_close = close9; xb9.addEventListener('click', function (e9) { e9.preventDefault(); close9(); }); // click 시점(고스트클릭 캐논) ov9.addEventListener('click', function (e9) { if (e9.target === ov9) close9(); }); document.body.appendChild(ov9); } hud.addEventListener('click', function () { if (panel) { panel.remove(); panel = null; return; } panel = document.createElement('div'); // 백드롭 — 바깥 탭 = 닫기(2026-07-24 실보고 · click 시점 = 고스트클릭 캐논) panel.style.cssText = 'position:fixed;inset:0;z-index:60;background:rgba(4,6,10,.55);backdrop-filter:blur(3px);'; panel.addEventListener('click', function (ev) { if (ev.target === panel) { panel.remove(); panel = null; } }); var card9 = document.createElement('div'); card9.style.cssText = 'position:absolute;inset:10% 6%;background:linear-gradient(170deg,rgba(16,22,34,.97),rgba(9,12,20,.97));border:1px solid rgba(94,240,226,.22);border-radius:18px;padding:18px;color:#e8ecf4;font-family:system-ui;overflow:auto;box-shadow:0 20px 60px rgba(0,0,0,.55);'; panel.appendChild(card9); var h = document.createElement('div'); h.textContent = _at_t('잡은 몬스터들', 'つかまえたモンスター', 'Caught Monsters'); h.style.cssText = 'font-size:19px;font-weight:900;letter-spacing:.3px;text-shadow:0 0 12px rgba(94,240,226,.4);'; card9.appendChild(h); var hc9 = document.createElement('div'); hc9.textContent = dex_unique() + ' / ' + species.length; hc9.style.cssText = 'margin-top:3px;font:800 12px system-ui;color:' + HOLO + ';letter-spacing:2px;'; card9.appendChild(hc9); var pb9 = document.createElement('div'); // 수집 진행 바 pb9.style.cssText = 'margin:10px 0 6px;height:6px;border-radius:3px;background:rgba(255,255,255,.08);overflow:hidden;'; var pf9 = document.createElement('div'); pf9.style.cssText = 'height:100%;width:' + Math.round(dex_unique() / Math.max(1, species.length) * 100) + '%;background:linear-gradient(90deg,#3fd9cb,#8bfff2);box-shadow:0 0 8px rgba(94,240,226,.7);border-radius:3px;'; pb9.appendChild(pf9); card9.appendChild(pb9); // 포획 종 최상위 정렬(2026-07-25 사용자 확정) — 잡은 몬스터 전부가 항상 목록 맨 위(원 로스터 순서 유지 안정 정렬) var sp_sorted = species.slice().sort(function (a9, b9) { return ((dex[b9.name] | 0) > 0 ? 1 : 0) - ((dex[a9.name] | 0) > 0 ? 1 : 0); }); sp_sorted.forEach(function (sp) { var got = dex[sp.name] | 0; var row = document.createElement('div'); row.style.cssText = 'display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:12px;margin-top:6px;' + 'background:' + (got ? 'linear-gradient(90deg,rgba(255,255,255,.07),rgba(255,255,255,.02))' : 'rgba(255,255,255,.018)') + ';' + 'border-left:3px solid ' + (got ? sp.color : 'rgba(255,255,255,.1)') + ';' + (got ? 'cursor:pointer;' : ''); var nm = document.createElement('span'); nm.textContent = sp.name + (sp.tier !== 'common' ? (sp.tier === 'epic' ? ' ★★' : ' ★') : ''); nm.style.cssText = 'flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:700 14px system-ui;' + (got ? 'color:#f2f7fb;' : 'color:rgba(255,255,255,.3);'); // 미포획 = 실명 그대로, 비활성 톤(??? 폐지) var ct = document.createElement('span'); ct.textContent = got ? '×' + got : '—'; ct.style.cssText = got ? 'font:800 12px system-ui;color:#ffd23e;background:rgba(255,210,62,.12);border-radius:999px;padding:3px 9px;' : 'font:700 12px system-ui;color:rgba(255,255,255,.25);'; row.appendChild(nm); row.appendChild(ct); if (got) { // 포획 종 = 행 전체 탭으로 상세 진입(2026-07-25 확정) + 돋보기 어포던스 칩 var qb = document.createElement('button'); qb.setAttribute('aria-label', sp.name); qb.style.cssText = 'flex:0 0 28px;height:28px;border-radius:50%;border:0;cursor:pointer;display:flex;align-items:center;justify-content:center;' + 'background:radial-gradient(circle at 32% 26%,#bffef4 0%,#5ef0e2 55%,#1e9c8e 100%);' + 'box-shadow:0 4px 12px rgba(94,240,226,.4),inset 0 1.5px 2px rgba(255,255,255,.6),inset 0 -2px 4px rgba(0,60,55,.4);'; qb.innerHTML = '
'; // 돋보기 row.appendChild(qb); row.addEventListener('click', function (e9) { e9.preventDefault(); species_modal(sp); }); // 행 전체 탭(버튼 포함 — 단일 진입) } card9.appendChild(row); }); var cl = document.createElement('button'); cl.textContent = _at_t('닫기', 'とじる', 'Close'); cl.style.cssText = 'margin-top:14px;width:100%;border:0;border-radius:12px;padding:11px;background:#2e3646;color:#e8ecf4;font-weight:800;'; cl.addEventListener('click', function () { if (panel) { panel.remove(); panel = null; } }); card9.appendChild(cl); document.body.appendChild(panel); }); // ── 스폰 동기화(결정론 셀 → 씬 메시) ── var spawns = {}; // id → {sp_i, mesh, x, z} var cooled = {}; // id → until_ms (포획/도주 쿨다운) function sp_index(r) { var tier = r >= 0.97 ? 'epic' : (r >= 0.85 ? 'rare' : 'common'); var list = by_tier[tier]; return list[Math.floor(((r * 7919) % 1) * list.length) % list.length]; } function sync_cells() { var cells = geowalk.cells({ radius_m: RADIUS, grid_m: GRID, bucket_min: BUCKET, density: DENSITY, salt: SEED }) || []; var live = {}; var now = Date.now(); for (var i = 0; i < cells.length && i < 40; i++) { var cc = cells[i]; if (cooled[cc.id] && cooled[cc.id] > now) continue; live[cc.id] = true; if (!spawns[cc.id]) { var idx = sp_index(cc.r); var m = creature_mesh(species[idx], false); m.position.set(cc.x, 0.75, cc.z); ex.add(m); spawns[cc.id] = { sp_i: idx, mesh: m, x: cc.x, z: cc.z, ph: (cc.r * 6.28) }; } } for (var id in spawns) { if (!live[id]) { try { ex.remove(spawns[id].mesh); } catch (e) {} delete spawns[id]; } } } var radar = geowalk.radar({ range_m: RADIUS, targets: function () { var out = []; for (var id in spawns) out.push({ x: spawns[id].x, z: spawns[id].z, color: species[spawns[id].sp_i].color }); return out; }, }); // ── 조우(encounter) — arcam aim-hold 포획 ── var phase = 'explore'; // 'explore' | 'encounter' | 'indoor' var enc = null; // {id, sp_i, anchor, hold, need, darts_left, next_dart, t} var arcam_ready = false; var win_done = false; var cur_focus = null; // 현재 조준 중인 rec(펄스 사격 타깃) // pointerdown 즉발(연타 지연 0) + click 병행(합성 클릭 호환) — 쿨다운이 이중 발화를 흡수. // preventDefault = iOS 더블탭 줌 제스처 인식 차단 2중 봉합(touch-action 과 벨트+서스펜더). var _fire_ev = function (ev) { if (ev && ev.cancelable && ev.preventDefault) ev.preventDefault(); fire_pulse(function () { return cur_focus; }); }; fire_btn.addEventListener('pointerdown', _fire_ev); fire_btn.addEventListener('click', _fire_ev); // 화면 탭 = 발사(2026-07-24 사용자 확정) — 버튼 밖 어디를 탭해도 펄스. // 탭 판정(<320ms·이동<12px)으로 드래그 조준과 구분, 대상은 게임 표면(canvas/video/body)만 // (도감 패널·리더보드 모달·HUD 버튼 등 DOM 위 탭은 발사 아님 — 오발 구조적 차단). var _tap9 = null; window.addEventListener('pointerdown', function (ev) { var tg = ev.target; if (!(tg === document.body || (tg && (tg.tagName === 'CANVAS' || tg.tagName === 'VIDEO')))) { _tap9 = null; return; } _tap9 = { x: ev.clientX, y: ev.clientY, t: Date.now(), id: ev.pointerId }; }, true); window.addEventListener('pointerup', function (ev) { if (!_tap9 || ev.pointerId !== _tap9.id) return; var dx9 = ev.clientX - _tap9.x, dy9 = ev.clientY - _tap9.y; var ok9 = (Date.now() - _tap9.t) < 320 && (dx9 * dx9 + dy9 * dy9) < 144; _tap9 = null; if (!ok9 || panel) return; if (phase === 'encounter' || phase === 'indoor') fire_pulse(function () { return cur_focus; }); }, true); function enc_begin(id) { var rec = spawns[id]; if (!rec) { phase = 'explore'; arcam.set_active(false); return; } // 카드 대기 중 버킷 롤오버로 소멸 — 탐색 복귀(phase 고착·카메라 점유 방지) phase = 'encounter'; var sp = species[rec.sp_i]; var tier_i = TIERS.indexOf(sp.tier); ex.visible = false; if (radar && radar.el) radar.el.style.display = 'none'; flee_btn.style.display = ''; camera.position.set(0, 1.55, 0); // 결정론 전방 오프셋(GO 기본모드 spawn-ahead 문법) — 가로 반각 내로 정규화(부팅 즉시 가시 보장) var yaw0 = (((rec.x * 13 + rec.z * 7) % 60) - 30) / 30 * ahead_jitter(); var anchor = arcam.anchor({ yaw: yaw0, pitch: 2, dist: 3.2 + tier_i * 0.6, obj: creature_obj(sp, true), color: sp.color, }); enc = rec_init_motion({ id: id, sp_i: rec.sp_i, anchor: anchor, hold: 0, need: 1.3 + tier_i * 0.5, darts_left: 1 + tier_i, next_dart: (1.3 + tier_i * 0.5) / (2 + tier_i), t: 0, yaw: yaw0, }, tier_i); prog.style.display = ''; prog_fill.style.width = '0%'; ret.style.display = ''; ret_aim(null); lives_el.style.display = ''; rad2.style.display = ''; fire_btn.style.display = ''; next_item = Date.now() + 7000 + Math.random() * 6000; // 조우당 아이템 등장 예약(과거 스탬프 즉시-스폰 방지) nav_hide(); // 조우 중 내비 화살표 숨김(조준 표면과 겹침 방지) P.toast(sp.name + (sp.tier !== 'common' ? ' ★' : '') + ' — ' + _at_t('조준을 유지해 스캔하세요!', 'ねらいを合わせてスキャン!', 'Hold your aim to scan it!')); A._emit('encounter', { species: sp.name, tier: sp.tier }); fx.sfx('blip', { volume: 0.6 }); voice(sp, 'call'); // 등장 포효 — 종 고유 } // ── AR 내비게이션(야외 탐색, 2026-07-24 사용자 확정 "지도 대신 카메라+거대 화살표+남은 거리") ── // 야외 ready 직후 arcam 카드를 올리고, 카메라 국면(ar/cam)으로 승인되면 탐색 국면도 카메라 위에서 // 진행: 최근접 크리처 방위(bearing−heading)로 회전하는 거대 화살표 + 종명·남은 거리 칩. // 카메라 거부/데스크톱/나침반 부재는 기존 지도-탐색(레거시 경로)으로 구조적 강등 — 침묵 결손 없음. var CAM_PRIVACY = _at_t('카메라 영상은 휴대폰 안에서만 처리돼요 — 절대 녹화·전송되지 않아요.', 'カメラ映像は端末内だけで処理され、録画・送信は一切ありません。', 'Camera video stays on your device — never recorded or uploaded.'); var CAM_SAFETY = _at_t('주변을 살피며 안전하게 플레이하세요.', '周りに気をつけて遊んでね。', 'Stay aware of your surroundings while playing.'); var explore_ar = false; var nav_card_pending = false; var _nav_ref9 = null; // 헤딩 절대 앵커 {hd, cy} — 나침반/GPS 진행방향 사망 구간을 arcam yaw 적분으로 이어붙임 var nav_wrap = document.createElement('div'); // 대형 안내 문구 + 종명·잔여거리 칩(화살표 의미를 말로 명시) nav_wrap.setAttribute('data-cq-nav', '1'); nav_wrap.style.cssText = 'position:fixed;left:50%;top:17%;transform:translateX(-50%);z-index:6;display:none;pointer-events:none;text-align:center;width:92%;'; nav_wrap.innerHTML = '
' // 홀로 헥사 태그(2026-07-25 컨셉 "NEST SITE 120 m" 문법) — 육각 클립 글래스 + 시안 글로우 + '
' + '
'; document.body.appendChild(nav_wrap); var nav_msg = nav_wrap.querySelector('[data-cq-nav-msg]'); var nav_dist = nav_wrap.querySelector('[data-cq-nav-dist]'); // 증강현실 지면 셰브런 트레일(차량 AR 내비 문법) — 베벨 압출 입체 화살촉 행렬이 목표 방위로 원근 // 후퇴. 시안 홀로 발광 파동이 트레일을 따라 앞으로 흐른다(2026-07-25 사용자 확정 "AR 스캐너 컨셉" — // 07-24 무지개 파동을 컨셉 통일색으로 대체, 참조 이미지의 시안 셰브런 문법). // 재질 = 조명 셰이딩(Standard, emissive 자발광) + NormalBlending 고농도 — additive 백화 없이 또렷. var NAV_N = 6; var nav3 = new T3.Group(); nav3.visible = false; (function () { var shp = new T3.Shape(); shp.moveTo(0, 0.52); shp.lineTo(0.62, -0.18); shp.lineTo(0.62, -0.52); shp.lineTo(0, 0.16); shp.lineTo(-0.62, -0.52); shp.lineTo(-0.62, -0.18); shp.closePath(); var geo9 = new T3.ExtrudeGeometry(shp, { depth: 0.22, bevelEnabled: true, bevelThickness: 0.06, bevelSize: 0.05, bevelSegments: 2 }); // 볼륨 압출 + 베벨(입체 하이라이트) for (var ci = 0; ci < NAV_N; ci++) { var mm9 = new T3.Mesh(geo9, new T3.MeshStandardMaterial({ color: '#43e8dc', emissive: '#0e7d74', roughness: 0.32, metalness: 0, transparent: true, opacity: 0.95, depthWrite: false })); mm9.renderOrder = NAV_N - ci; // 근접 우선 합성(반투명 정렬) mm9.rotation.x = -1.22; // 지면에 눕히되 약간 세워 가시성(참조 이미지의 기립 셰브런 문법) mm9.position.set(0, 0, -(2.4 + ci * 1.05)); var sc9 = 0.55 - ci * 0.045; // 실측 조정(2026-07-24 폰 스크린샷: 1.4 스케일 = 화면 압도) — 폭 ~1/4 화면 mm9.scale.setScalar(Math.max(0.28, sc9)); nav3.add(mm9); } scene.add(nav3); })(); function nav_hide() { nav_wrap.style.display = 'none'; nav3.visible = false; } function nav_tick(tt) { // 최근접 스폰 유도 — 정렬(±28°) 시 녹색 직진 신호 var best = null, bd = 1e9; for (var id9 in spawns) { var dd = geowalk.dist_m({ x: spawns[id9].x, z: spawns[id9].z }); if (dd < bd) { bd = dd; best = spawns[id9]; } } if (!best) { nav_hide(); return; } var sp9 = species[best.sp_i]; var hd = geowalk.heading_deg(); var br = geowalk.bearing_deg({ x: best.x, z: best.z }); nav_wrap.style.display = ''; // ── 헤딩 사다리 v2(2026-07-25 실보고 "화살표 사라짐" 봉합 — 실측: 미보정 나침반 → hd 영구 null → // 구 강등 분기가 트레일 전체를 숨김). ① 절대 소스(나침반/GPS 진행방향)가 있으면 그대로, // ② 없으면 최근 절대 앵커 + arcam 자이로 yaw 적분(Δheading = -Δyaw — yaw+=좌CCW, heading+=우CW)으로 // 실시간 유지, ③ 앵커조차 없으면 hd=null 로 두되 트레일은 직진형으로 상시 표시(아래). var cy9 = arcam.look().yaw; if (hd != null) { _nav_ref9 = { hd: hd, cy: cy9 }; } else if (_nav_ref9) { hd = ((_nav_ref9.hd + (_nav_ref9.cy - cy9)) % 360 + 360) % 360; } { // 상시 가시 곡선 트레일(2026-07-24 사용자 확정 "화살표는 항상 보이고 좌/우로 휘게" + 07-25 "필수"): // 트레일은 항상 카메라 정면에서 시작하고, 목표 방위와의 편차(rel_s)만큼 좌/우로 굽는 아크로 // 방향을 알린다. 헤딩 미상이어도 숨기지 않는다 — 직진 트레일 + 정직 안내(걸으면 GPS 진행방향이 // 잡혀 몇 걸음 안에 곡선 유도로 자동 승격). var hd_known = hd != null; var rel_u = hd_known ? ((br - hd) % 360 + 360) % 360 : 0; var rel_s = rel_u > 180 ? rel_u - 360 : rel_u; // (−180,180] — +우/−좌 var on9 = Math.abs(rel_s) < 28; nav_msg.textContent = !hd_known ? '🚶 ' + _at_t('화살표를 따라 걸으면 방향이 잡혀요', 'あるいて進むと方向がわかるよ', 'Start walking — the arrows will lock on') : on9 ? '✨ ' + _at_t('그대로 직진하세요!', 'そのまま直進!', 'Keep going straight!') : (rel_s > 0 ? '↱ ' + _at_t('오른쪽으로 돌면서 화살표를 따라가세요', '右にまがって矢印をたどってね', 'Turn right and follow the arrows') : '↰ ' + _at_t('왼쪽으로 돌면서 화살표를 따라가세요', '左にまがって矢印をたどってね', 'Turn left and follow the arrows')); nav_dist.style.color = (hd_known && on9) ? '#c6ff9e' : '#eaffff'; // 정렬 = 그린 시프트(헥사 태그는 무테 — 텍스트 톤으로 신호) // 그룹은 카메라 수평 전방에 정렬(트레일 시작점 = 항상 시야 정면) var fw9 = new T3.Vector3(0, 0, -1).applyQuaternion(camera.quaternion); fw9.y = 0; if (fw9.lengthSq() < 1e-6) fw9.set(0, 0, -1); fw9.normalize(); nav3.visible = true; nav3.position.set(camera.position.x, camera.position.y - 1.5, camera.position.z); nav3.rotation.y = Math.atan2(-fw9.x, -fw9.z); var turn9 = Math.max(-145, Math.min(145, rel_s)) * Math.PI / 180; // 총 굽힘각(뒤=강한 U형) var px9 = 0, pz9 = -2.4; // 로컬 아크 스테핑(−z = 정면, +x = 우측) — 시작점을 밀어 근접 과대 방지 var tw9 = (tt || 0); for (var ni = 0; ni < NAV_N; ni++) { var mn = nav3.children[ni]; var th9 = turn9 * (ni + 0.5) / NAV_N; // 누적 선회각 — 체인이 점진적으로 목표 방위로 굽는다 px9 += Math.sin(th9) * 1.05; pz9 -= Math.cos(th9) * 1.05; mn.position.set(px9, 0, pz9); mn.rotation.order = 'YXZ'; mn.rotation.y = -th9; // 접선 방향으로 회두(굽는 흐름이 읽힌다) mn.rotation.x = -1.22; // 시안 홀로 발광 파동(정렬 시 가속) — 휘도 펄스가 트레일을 따라 전방으로 흐른다 var pw9 = 0.5 + 0.5 * Math.sin(tw9 * (on9 ? 4.6 : 2.8) - ni * 1.05); mn.material.color.setHSL(0.492, 0.95, 0.5 + 0.22 * pw9); mn.material.emissive.setHSL(0.492, 1, 0.16 + 0.3 * pw9); // 자발광 — 그늘/역광에서도 진하게 mn.material.opacity = 0.78 + 0.2 * pw9; } } nav_dist.textContent = sp9.name + ' · ' + Math.max(1, Math.round(bd)) + 'm'; } function enc_end(cool_ms) { if (!enc) return; try { enc.anchor.remove(); } catch (e) {} cooled[enc.id] = Date.now() + (cool_ms || 60000); if (spawns[enc.id]) { try { ex.remove(spawns[enc.id].mesh); } catch (e) {} delete spawns[enc.id]; } enc = null; phase = 'explore'; item_clear(); // 탐색 복귀 = AR 아이템 철수(잔류 앵커 방지) if (!explore_ar) arcam.set_active(false); // AR 내비 탐색은 카메라 유지(꺼짐 오인 방지) ex.visible = !explore_ar; if (radar && radar.el) radar.el.style.display = ''; flee_btn.style.display = 'none'; prog.style.display = 'none'; ret.style.display = 'none'; ret_name.style.display = 'none'; lives_el.style.display = 'none'; rad2.style.display = 'none'; fire_btn.style.display = 'none'; holo_hide(); // 명판·브래킷 철수(탐색 국면 잔류 방지) hud_sync(); } flee_btn.addEventListener('click', function () { if (phase === 'encounter') { A._emit('flee', {}); enc_end(45000); } }); function award(sp, pos) { // 포획 보상 공통부(야외 조우·실내 헌트 공유 — 도감/보드/훅/완성 판정 단일화) dex[sp.name] = (dex[sp.name] | 0) + 1; dex_save(); fx.sfx('pickup', { volume: 0.85 }); celebrate(sp, pos); // 폭죽 연사 + 컨페티(포획 축하 — 화면 전체 연출) P.toast('✨ ' + sp.name + ' +' + sp.pts + '⭐'); // 종별 점수 가시화 run_pts += sp.pts; // 제출은 사망 시 1회(등록창 = 사망 시에만) — run_score() 로 합산 timer_sync(); hud_sync(); // 실내 포획 경로의 도감 카운터 고착 봉합(HUD 갱신이 야외 루프에만 있던 실보고 2026-07-24) A._emit('catch', { species: sp.name, tier: sp.tier, pts: sp.pts, score: run_score(), total: dex_total(), unique: dex_unique() }); if (!win_done && dex_unique() >= species.length) { win_done = true; fx.sfx('powerup', { volume: 1 }); fx.score(_at_t('몬스터 컬렉션 완성!', 'モンスターコンプリート!', 'Collection complete!')); } } function enc_capture() { voice(species[enc.sp_i], 'die'); // 사망(포획 소멸) 보이스 if (enc.ice_shell) rec_thaw(enc); // 빙결 상태 포획 = 셸 파열 연출 후 소멸 award(species[enc.sp_i], enc.anchor && enc.anchor.obj ? enc.anchor.obj.position : null); if (start_die(enc)) enc.anchor = { remove: function () {} }; // die 연출(dying)이 앵커 제거를 소유 enc_end(90000); // 탐색 복귀 명시 — 지도 화면 전환을 "게임 꺼짐"으로 오인하는 실보고 봉합(연속 안내) P.toast(_at_t('📡 탐색 모드 — 레이더로 다음 몬스터를 찾아요!', '📡 たんさくモード — レーダーで次を探そう!', '📡 Explore mode — track the next one on the radar!')); } function enc_request(id) { // 첫 조우만 arcam 카드(문맥 분리 권한 — 삼중 프롬프트 금지 캐논) phase = 'encounter'; // 모달 대기 — 탐색 루프의 매 심-프레임 재발화 차단(카드 적층 실측 결함 봉합) if (arcam_ready) { arcam.set_active(true); enc_begin(id); return; } var pend_id = id; arcam.on('ready', function () { if (arcam_ready) return; arcam_ready = true; enc_begin(pend_id); }); arcam.start({ privacy: CAM_PRIVACY, safety: CAM_SAFETY, start: _at_t('카메라 켜기', 'カメラをオンにする', 'Turn on camera'), title: String(c.encounter_title || _at_t('크리처 발견!', 'クリーチャー発見!', 'Creature spotted!')).slice(0, 40), body: String(c.encounter_body || _at_t('카메라를 켜면 눈앞에 크리처가 나타나요. 가운데 조준원을 맞추고 화면을 탭해서 잡으세요.', 'カメラをオンにすると目の前にクリーチャーが現れます。照準を合わせて画面をタップ!', 'Turn on your camera and the creature appears in front of you. Line up the reticle and tap to catch it.')).slice(0, 120), }); } // ── 실내 모드('virtual' ready) — 걷기 없는 360° 카메라 헌트 ── // 좁은 방에서도 성립하는 GO류 실내 문법: 이동 요구 0, 플레이어 주위 yaw 슬롯(360°/K)에 // 크리처를 동시 앵커 → 카메라를 돌리면 발견 → 동일한 aim-hold 스캔 포획 → 포획/도주 슬롯은 // 잠시 후 자동 리스폰. 도감·리더보드·완성 축하·훅('encounter'/'catch')은 야외 경로와 동일 계약. // GPS 거부·데스크톱 강등도 이 경로로 수렴(arcam이 ar→cam→gyro→touch 자체 강등). var IND_K = 3; // 동시 등장 수 — 회전 발견이 성립하는 최소 밀도(슬롯 중첩 없음) var ind = null; // { list: [{slot, sp_i, anchor, hold, need, darts_left, next_dart, yaw}], resp: [{slot, t}] } function ind_spawn(slot) { var idx = sp_index(Math.random()); var sp = species[idx]; var tier_i = TIERS.indexOf(sp.tier); // 슬롯 0 = 정면 보장(가로 반각 내 지터 — 회전/드래그를 못 해도 최소 1마리는 부팅 즉시 보인다) var j0 = ahead_jitter(); var yaw = slot === 0 ? (Math.random() * 2 - 1) * j0 : slot * (360 / IND_K) + Math.random() * 44 - 22; var anchor = arcam.anchor({ yaw: yaw, pitch: 1 + Math.random() * 7, dist: 2.1 + Math.random() * 1.4 + tier_i * 0.5, obj: creature_obj(sp, true), color: sp.color, }); A._emit('encounter', { species: sp.name, tier: sp.tier }); voice(sp, 'call', 0.45); // 등장 포효(실내 다중 스폰 = 소볼륨) return rec_init_motion({ slot: slot, sp_i: idx, anchor: anchor, hold: 0, need: 1.3 + tier_i * 0.5, darts_left: 1 + tier_i, next_dart: (1.3 + tier_i * 0.5) / (2 + tier_i), yaw: yaw }, tier_i); } function indoor_begin() { phase = 'indoor'; ex.visible = false; if (radar && radar.el) radar.el.style.display = 'none'; camera.position.set(0, 1.55, 0); ret.style.display = ''; ret_aim(null); lives_el.style.display = ''; rad2.style.display = ''; fire_btn.style.display = ''; hud_sync(); function ind_boot() { ind = { list: [], resp: [] }; for (var i = 0; i < IND_K; i++) ind.list.push(ind_spawn(i)); fx.sfx('blip', { volume: 0.5 }); } if (arcam_ready) { arcam.set_active(true); ind_boot(); return; } arcam.on('ready', function () { if (arcam_ready) return; arcam_ready = true; ind_boot(); }); arcam.start({ // 첫 1회 카메라 카드 — 문맥 분리 권한 캐논(조우 카드와 동일 소스) privacy: CAM_PRIVACY, safety: CAM_SAFETY, start: _at_t('카메라 켜기', 'カメラをオンにする', 'Turn on camera'), title: _at_t('실내 사냥 시작', 'おうちハント開始', 'Indoor hunt').slice(0, 40), body: _at_t('카메라를 켜고 제자리에서 천천히 둘러보세요. 사방에 크리처가 나타나요. 조준원을 맞추고 화면을 탭하면 잡혀요.', 'カメラをオンにして、その場でゆっくり見回してください。照準を合わせてタップでゲット!', 'Turn on your camera and slowly look around — creatures appear all around you. Aim the reticle and tap to catch.').slice(0, 120), }); } // ── UI 열람 일시정지(2026-07-25 사용자 확정) — 잡은 몬스터들/종 상세/게임 순위/친구 초대 창이 // 떠 있는 동안 게임 시뮬레이션 정지, 닫으면 재개. 렌더는 유지(정지 화면), 절대시각 필드는 // 정지 시간만큼 시프트해 버프/아이템 예약/쿨다운/돌진 예정이 정지 중 흘러가지 않는다. var _pause9 = { on: false, t0: 0 }; function _pause_shift9(d9) { next_item += d9; down_until += d9; pulse_cd += d9; if (buff) buff.until += d9; for (var ck9 in cooled) cooled[ck9] += d9; function sh9(r9) { if (!r9) return; if (r9.frozen_until) r9.frozen_until += d9; if (r9.next_charge) r9.next_charge += d9; } sh9(enc); if (ind) { for (var si9 = 0; si9 < ind.list.length; si9++) sh9(ind.list[si9]); for (var sj9 = 0; sj9 < ind.resp.length; sj9++) ind.resp[sj9].t += d9; } } function _pause_sync9() { // 매 프레임 폴링 — 열람 표면 4종의 존재가 곧 정지 신호(개별 훅 배선 불요 = 누락 불가) var open9 = !!(panel || sp_modal_close || inv_ov || document.getElementById('playable-leaderboard')); if (open9 === _pause9.on) return; if (open9) { _pause9.on = true; _pause9.t0 = Date.now(); } else { _pause9.on = false; _pause_shift9(Date.now() - _pause9.t0); } } // ── 통합 루프 ── var cell_t = 0, hud_t = 0; P.loop(function (dt) { var t = num(dt, 0.016); _pause_sync9(); if (_pause9.on) return; // 열람 중 = 시뮬레이션 정지(렌더는 셸이 계속 — 정지 화면 유지) tick_dying(t); // 포획 소멸 연출 — 국면 무관 구동(조우 종료 후에도 클립 완주) if (show9) { // 오프닝 쇼케이스 — 턴테이블 + 믹서 + 카메라 미세 스웨이 show9.t += t; show9.obj.rotation.y = Math.sin(show9.t * 0.4) * 0.42; // 중앙 정면 기준 스웨이(우편향 실보고 조정) var cqs = show9.obj._cq; if (cqs && cqs.mixer) cqs.mixer.update(t); camera.position.x = Math.sin(show9.t * 0.3) * 0.12; camera.lookAt(0, 1.95, -4.6); } if (phase === 'explore') { if (!geo_ready) return; // 위치 카드 응답 전 — 탐색/조우 정지(카드 겹침 방지) run_t += t; // 야외 걷기도 플레이 시간(점수 축) timer_acc += t; if (timer_acc > 0.5) { timer_acc = 0; timer_sync(); } cell_t += t; if (cell_t > 2.5) { cell_t = 0; sync_cells(); } hud_t += t; if (hud_t > 1) { hud_t = 0; hud_sync(); } var pp = geowalk.pos() || { x: 0, z: 0 }; if (explore_ar) { // AR 내비 — 카메라 위 셰브런 트레일 유도(지도 세계는 숨김, 카메라는 arcam 소유) if (ex.visible) ex.visible = false; nav_tick(run_t); } else { pin.position.set(pp.x, 0, pp.z); // 지도풍 추적 카메라(3/4 부감) — arcam 미기동/파킹 상태에서만 아키타입이 카메라를 소유 camera.position.set(pp.x, 30, pp.z + 19); camera.lookAt(pp.x, 0, pp.z); } var now2 = Date.now(); for (var id in spawns) { var s2 = spawns[id]; s2.mesh.position.y = 0.75 + Math.sin(now2 / 500 + s2.ph) * 0.14; s2.mesh.rotation.y += t * 0.6; if (!nav_card_pending && geowalk.dist_m({ x: s2.x, z: s2.z }) < ENC_M) { nav_hide(); enc_request(id); break; } } } else if (enc) { enc.t += t; var nowE = Date.now(); if (nowE >= down_until) run_t += t; // 생존 시간(난이도 축) timer_acc += t; if (timer_acc > 0.5) { timer_acc = 0; timer_sync(); } var a = enc.anchor; if (a && a.obj && !enc.charge && !enc.dash && !(enc.frozen_until > nowE)) a.obj.position.y += Math.sin(nowE / 400) * 0.0018; // 빙결 중 부유 봅 정지(굳는다) tick_rec(enc, t, nowE); tick_items(t, nowE); tick_buff(t, nowE); if (enc.escaped) { // 고득점 종 도주 — 포획 실패로 탐색 복귀 P.toast('💨 ' + species[enc.sp_i].name + ' ' + _at_t('이(가) 도망쳤다!', 'ににげられた!', 'escaped!')); fx.sfx('jump', { volume: 0.5 }); A._emit('flee', {}); enc_end(30000); return; } tick_pulses(t); radar2_draw(t, [enc]); plate_tick(enc); // 홀로 명판(종명·등급·실거리) — 조우 중 상시(타깃 락 문법) brackets_tick(enc); // 뷰파인더 브래킷 — 조우 타깃 프레이밍 var aimedE = arcam.aimed(a, 12); cur_focus = aimedE ? enc : null; var itA = item && arcam.aimed(item.anchor, 12); // 아이템 조준 표시(레티클 이름/색 재사용) if (itA) ret_aim({ name: ITEM_DEFS[item.kind].icon + ' ' + ITEM_DEFS[item.kind].name, color: ITEM_DEFS[item.kind].color }, true); else ret_aim(aimedE ? species[enc.sp_i] : null); // 대미지는 펄스 명중만(조준 유지 수동 충전 폐지 — 능동 사격이 게임의 코어 동사) prog_fill.style.width = Math.min(100, (enc.hold / enc.need) * 100).toFixed(1) + '%'; if (enc.hold >= enc.need) { enc_capture(); return; } if (enc.t > 45) { P.toast(_at_t('크리처가 도망갔어요…', 'クリーチャーに逃げられた…', 'It fled…')); A._emit('flee', {}); enc_end(45000); } } else if (phase === 'indoor' && ind) { var now3 = Date.now(); if (now3 >= down_until) run_t += t; // 생존 시간(난이도 축) timer_acc += t; if (timer_acc > 0.5) { timer_acc = 0; timer_sync(); } for (var ri = ind.resp.length - 1; ri >= 0; ri--) { // 슬롯 리스폰(포획 후 잠시 비움 — 즉시 재등장의 밭갈이감 방지) if (ind.resp[ri].t <= now3) { ind.list.push(ind_spawn(ind.resp[ri].slot)); ind.resp.splice(ri, 1); } } var focus = null; for (var li = 0; li < ind.list.length; li++) { var rc = ind.list[li]; if (rc.anchor && rc.anchor.obj && !rc.charge && !rc.dash && !(rc.frozen_until > now3)) rc.anchor.obj.position.y += Math.sin(now3 / 400 + rc.slot * 2.1) * 0.0018; // 빙결 중 봅 정지 tick_rec(rc, t, now3); if (!focus && arcam.aimed(rc.anchor, 12)) focus = rc; } tick_items(t, now3); tick_buff(t, now3); for (var le = ind.list.length - 1; le >= 0; le--) { // 고득점 종 도주 스윕(빈 슬롯은 리스폰 예약) var re2 = ind.list[le]; if (!re2.escaped) continue; if (re2.anchor && re2.anchor.obj) fx.burst(re2.anchor.obj.position, { color: '#ffffff', count: 10 }); try { re2.anchor.remove(); } catch (e) {} ind.list.splice(le, 1); ind.resp.push({ slot: re2.slot, t: now3 + 2600 }); fx.sfx('jump', { volume: 0.4 }); if (focus === re2) focus = null; } tick_pulses(t); radar2_draw(t, ind.list); plate_tick(focus); // 실내 = 조준 확보 개체만 명판(다중 스폰 — 식별한 놈을 스캔하는 문법) brackets_tick(focus); cur_focus = focus; ret_aim(focus ? species[focus.sp_i] : null); prog.style.display = focus ? '' : 'none'; if (focus) prog_fill.style.width = Math.min(100, (focus.hold / focus.need) * 100).toFixed(1) + '%'; for (var lk = ind.list.length - 1; lk >= 0; lk--) { // 펄스 누적 대미지 임계 도달 개체 포획(대미지는 펄스가 유일) var rk = ind.list[lk]; if (rk.hold < rk.need) continue; var sp4 = species[rk.sp_i]; voice(sp4, 'die'); // 사망(포획 소멸) 보이스 var pos4 = rk.anchor && rk.anchor.obj ? rk.anchor.obj.position.clone() : null; if (!start_die(rk)) { try { rk.anchor.remove(); } catch (e) {} } // die 클립 보유 시 소멸 연출로 이관 ind.list.splice(lk, 1); ind.resp.push({ slot: rk.slot, t: now3 + 3500 }); prog.style.display = 'none'; award(sp4, pos4); } } }); // ── 부트(geowalk 카드 — 위치 권한만; 카메라/모션은 첫 조우 시 arcam 카드) ── // ── 오프닝 쇼케이스(2026-07-24 "명작 오프닝") — 마퀴 크리처 실물 3D가 로고 카드 뒤에 살아있는 무대. // 에픽(실물 모델) 종 우선 선택 → 림라이트+키라이트+지면 안개 글로우+턴테이블·idle 클립. // 게임 테마가 바뀌면 무대도 자동 변주(자산 기반 — 정적 사진 배경 불요). 모드 선택 즉시 해체. var show9 = null; (function () { // 마퀴 우선(2026-07-25 사용자 확정 "인트로 = 테마 플래그십 최고퀄") — 생성기가 테마 로스터에서 // 결정론 선정한 hunt.marquee(dino→트렉스, _real 우선·tris 최대)가 있으면 무조건 그 종. var mq9 = (window['__PLAYABLE_'+'EXTRA_MANIFEST__'] && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].hunt && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].hunt.marquee) || null; var pick9 = null; if (mq9) for (var sm9 = 0; sm9 < species.length; sm9++) if (species[sm9].model === mq9) { pick9 = species[sm9]; break; } if (!pick9) for (var si9 = 0; si9 < species.length; si9++) if (species[si9].model && species[si9].tier === 'epic') { pick9 = species[si9]; break; } if (!pick9) for (var sj9 = 0; sj9 < species.length; sj9++) if (species[sj9].model) { pick9 = species[sj9]; break; } if (!pick9) pick9 = species[0]; var grp9 = new T3.Group(); var obj9 = creature_obj(pick9, true); obj9.position.set(0, 1.95, -4.6); // 로고-시트 사이 빈 하늘 영역으로 상향(버튼 가림 실보고 2026-07-28) obj9.scale.setScalar(2.1); // 히어로 존재감 확대 grp9.add(obj9); var gr9 = new T3.Mesh(new T3.CircleGeometry(4.4, 40), new T3.MeshBasicMaterial({ color: '#0c1420', transparent: true, opacity: 0.85 })); // 히어로 확대에 맞춘 무대 확장 gr9.rotation.x = -Math.PI / 2; gr9.position.set(0, 0.02, -4.6); grp9.add(gr9); var rim9 = new T3.DirectionalLight('#7cf0ff', 2.2); // 후상방 시안 림 — 실루엣 분리 rim9.position.set(-2.5, 4.5, -7.5); grp9.add(rim9); var key9 = new T3.DirectionalLight('#ffd9a8', 1.4); // 전측방 웜 키 key9.position.set(2.5, 3.2, 1.5); grp9.add(key9); for (var fi0 = 0; fi0 < 8; fi0++) { // 지면 안개 글로우(무대 깊이감) var fg0 = _spr(fi0 % 2 ? '#7cf0ff' : '#9dff6a', 1.6 + Math.random() * 1.4, 0.12); fg0.position.set((Math.random() - 0.5) * 5, 0.3 + Math.random() * 0.5, -3.2 - Math.random() * 2.5); grp9.add(fg0); } scene.add(grp9); ex.visible = false; // 쇼케이스 동안 지도 세계 숨김(모드 확정 시 재결정) camera.position.set(0, 1.35, 0); camera.lookAt(0, 1.95, -4.6); show9 = { grp: grp9, obj: obj9, t: 0 }; })(); function showcase_end() { if (!show9) return; try { scene.remove(show9.grp); } catch (e) {} show9 = null; ex.visible = true; // 이후 indoor_begin/nav_boot 이 국면별로 재결정 } var geo_ready = false; // 조우는 geowalk ready 이후에만 — 카드 응답 전 근접 스폰이 arcam 카드를 겹쳐 올리는 순서 결함 봉합 geowalk.on('ready', function (m) { geo_ready = true; showcase_end(); // 오프닝 무대 해체(모드 확정) hud.style.display = ''; timer_el.style.display = ''; if (m && m.mode === 'virtual') { indoor_begin(); return; } // 실내 선택·GPS 거부·데스크톱 → 걷기 없는 카메라 헌트 sync_cells(); hud_sync(); // AR 내비 카드(2026-07-24) — 야외 = 카메라가 1급 탐색 표면. 카메라 국면(ar/cam) 승인 시에만 // explore_ar 전환; gyro/touch 강등이면 기존 지도-탐색 유지(빈 배경 위 화살표 방지). nav_card_pending = true; // 카드 응답 전 조우 발화 차단(arcam 카드 중첩 방지) function nav_boot() { nav_card_pending = false; var am9 = arcam.mode(); if (am9 && (am9.label === 'ar' || am9.label === 'cam')) { arcam.set_active(true); explore_ar = true; ex.visible = false; } } if (arcam_ready) { nav_boot(); return; } arcam.on('ready', function () { if (arcam_ready) return; arcam_ready = true; nav_boot(); }); arcam.start({ privacy: CAM_PRIVACY, safety: CAM_SAFETY, start: _at_t('카메라 켜기', 'カメラをオンにする', 'Turn on camera'), title: _at_t('AR 길안내', 'ARナビ', 'AR Navigation').slice(0, 40), body: _at_t('카메라를 켜면 화면의 화살표가 가장 가까운 크리처까지 안내해요. 화살표를 따라 걸어가세요.', 'カメラをオンにすると矢印が一番近いクリーチャーまで案内します。矢印に沿って歩きましょう。', 'Turn on your camera and on-screen arrows will guide you to the nearest creature. Just follow them.').slice(0, 120), }); }); function share_text9() { // 공유 본문 단일 소스(메뉴 칩 + 오프닝 버튼 공용) — start 호출 전 정의(호이스팅 라벨 결손 봉합) return String(c.share_text || (_at_t('나랑 점수 대결하자! 내가 잡은 몬스터 ', 'スコア対決しよう!つかまえたモンスター ', 'Score battle! Monsters caught: ') + dex_unique() + '/' + species.length + ' 🐾')).slice(0, 120); } var share_label9 = c.share_label ? String(c.share_label).slice(0, 12) : _at_t('친구 초대', '友だちを招待', 'Invite friends'); geowalk.start({ // 풀-디자인 오프닝(2026-07-24): 히어로+사용법 스텝+실내 우선 위계+전문 현지화 title: String(c.title_text || _at_t('크리처 퀘스트', 'クリーチャークエスト', 'Creature Quest')).slice(0, 40), privacy: String(c.walk_privacy || _at_t('위치 정보는 휴대폰 안에서만 사용돼요 — 절대 전송·저장되지 않아요.', '位置情報は端末内だけで使われ、送信・保存は一切ありません。', 'Your location stays on your device — never uploaded or shared.')).slice(0, 140), safety: String(c.walk_safety || _at_t('주변을 살피며 안전하게! 운전·자전거 중에는 플레이 금지예요.', '周りに気をつけて安全に!運転・自転車中はプレイ禁止。', 'Stay aware of your surroundings. Never play while driving or cycling.')).slice(0, 140), stage: 'showcase', // 카드 뒤 3D 히어로 무대(로고+시트 사이 투과) primary: 'virtual', // 실내 = 상단 대형(어디서든 즉시 시작), 야외 GPS = 하단 소형(2026-07-24 확정) start: String(c.outdoor_label || _at_t('야외에서 걸으며 잡기 (GPS)', 'やがいを歩いてつかまえる (GPS)', 'Play outdoors · GPS walk')).slice(0, 40), virtual_label: String(c.indoor_label || _at_t('지금 여기서 바로 시작', 'いまここですぐスタート', 'Play here right now')).slice(0, 40), no_gps: _at_t('실내 모드 — 카메라로 주변을 둘러보면 크리처가 나타나요!', 'おうちモード — カメラで見わたすとクリーチャーが!', 'Indoor mode — look around with your camera to find creatures!'), virtual_ui: false, // 실내 헌트는 이동 요구 0 — 가상 산책 스틱 억제 on_share: function () { P.share(share_text9()); }, // 오프닝 친구-공유 버튼(글로시 돔) — 셸 P.share 재사용 share_label: share_label9, // 오프닝 게임 순위 버튼(2026-07-25 사용자 확정) — 골드 트로피 돔 모션 스택. 게임키 없으면(스모크·직접 실행) // show() 가 조용한 no-op 이라 버튼 자체를 내리지 않는다(우아한 부재 캐논 합치). on_ranking: P.leaderboard.active() ? function () { P.leaderboard.show(); } : null, ranking_label: _at_t('게임 순위 보기', 'ランキングを見る', 'View rankings'), }); P.mount_share(share_text9, share_label9, // 친구-공유 의미 기본(2026-07-24 확정) '
'); // 친구(두 사람) 아이콘 lb_enable(); // 총점(생존 시간 + 종별 포획 점수) — 제출은 사망 시에만 A._installed = 'creature_quest'; return { get unique() { return dex_unique(); }, get total() { return dex_total(); }, get phase() { return phase; }, species: species.map(function (s) { return s.name; }), _priv: { sync_cells: sync_cells, enc_begin: enc_begin, enc_end: enc_end, spawns: spawns, dex: dex, enc_state: function () { return enc ? { yaw: enc.yaw, hold: enc.hold, need: enc.need, darts_left: enc.darts_left, frozen: !!(enc.frozen_until > Date.now()) } : null; }, ind_state: function () { return ind ? ind.list.map(function (r) { return { yaw: r.yaw, hold: r.hold, need: r.need, darts_left: r.darts_left, frozen: !!(r.frozen_until > Date.now()) }; }) : null; }, item_grant: item_collect, buff_state: function () { return buff ? { kind: buff.kind, until: buff.until } : null; }, pulse_count: function () { return pulses.length; }, item_state: function () { return item ? { kind: item.kind } : null; }, // 로어 전수 보장 계약(2026-07-25 "모든 몬스터는 스토리 보유") — 골든이 매 조립마다 검증 lore_all: function () { return species.every(function (s9) { return s9.lore && s9.lore.h && s9.lore.f && s9.lore.p && s9.lore.w; }); }, // AR 내비 3D 트레일 실상태(2026-07-25 실보고 "화살표 사라짐" — DOM 골든이 못 보는 3D 가시성 계측면) nav_state: function () { var m0 = nav3.children[0] && nav3.children[0].material; return { ar: explore_ar, vis: nav3.visible, hd: geowalk.heading_deg(), op: m0 ? +m0.opacity.toFixed(2) : null, el: m0 ? +m0.emissive.getHSL({}).l.toFixed(2) : null, msg: nav_msg.textContent.slice(0, 30) }; }, }, }; }; A._run = function (name, config) { var impl = _ARCH[name]; if (!impl) { warn('unknown archetype "' + name + '" — available: ' + Object.keys(_ARCH).join(', ')); if (window.__playable_beacon) window.__playable_beacon('archetype_unknown', String(name)); impl = _ARCH.meadow_explorer; // 폴백도 동작하는 게임(침묵 백지 금지) } try { return impl(config || {}); } catch (e) { // 아키타입은 "항상 동작" 계약 — 내부 예외는 오버레이 + 비콘으로 LOUD if (window.__playable_error_overlay) window.__playable_error_overlay('archetype failed: ' + (e && e.message)); if (window.__playable_beacon) window.__playable_beacon('archetype_fail', e && e.message); return null; } }; })();