Camo Clash
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 소유) 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; }, }; // ── 공유 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;}' + '#playable-ended{position:fixed;left:50%;top:38%;transform:translate(-50%,-50%);' + 'z-index:99970;display:flex;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'; el.innerHTML = '
' + (won ? '🏆' : '🛑') + '
'; el.lastChild.textContent = String(msg || '').slice(0, 90); // 게임 종료 = 메인(오프닝)으로 — 파라미터 소거 리로드(무결 복귀 캐논, 사용자 확정 2026-07-27). // 라벨 6언어: P._t6(획득 훅 블록) 존재 시 위임, 부재 시 자체 최소 판별(additive). var xb = document.createElement('button'); xb.type = 'button'; 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 _t(ko, ja, en) { var l = String(document.documentElement.lang || '').toLowerCase(); return l.indexOf('ko') === 0 ? ko : (l.indexOf('ja') === 0 ? ja : en); } 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'); tx.textContent = Array.isArray(it.label) ? _t(it.label[0], it.label[1], it.label[2]) : 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]|str, svg?, onSelect }) — 항목 등록(호출 = ⋯ 버튼 지연 생성). add: function (item) { if (!item || typeof item.onSelect !== 'function') return; _items.push(item); _ensure_btn(); }, // labels() — 등록된 항목의 안정 키(영문 라벨). 검증 게이트가 "특정 어포던스 존재/부재"를 확인. 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 금지 캐논 준수, 좌상단 뮤트 버튼 우측 정렬 — 우상단 // 미니맵/우하단 공유·액션 존과 무충돌). CTA 시트 = 일시 표시라 .pmenu 캐논(사운드 메뉴 동형). // 새 탭 진입은 서빙 CSP sandbox 의 allow-popups(-to-escape-sandbox) 전제(content_card 원문 보기 동일). // 새 fill 토큰 없음 — 라이브 셸 채널로 기존 함대에 재배포 없이 전파되는 변경만 사용. (function () { var CTA_URL = 'https://somodus.com/?utm_source=playable&utm_medium=badge&utm_campaign=ingame_cta'; function _t(ko, ja, en) { var l = String(document.documentElement.lang || '').toLowerCase(); return l.indexOf('ko') === 0 ? ko : (l.indexOf('ja') === 0 ? ja : en); } // 상시 배지 폐기 → 오버플로 메뉴(⋯) 항목으로 이동(사용자 요청 2026-07-22: 필수 데이터만 상시 노출). 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 = _t('이 게임은 AI가 만들었어요', 'このゲームはAIが作りました', 'This game was made by AI'); var d = document.createElement('div'); d.style.cssText = 'color:#aeb6c6;'; d.textContent = _t('somodus에서 문장 하나로 나만의 게임과 AI 에이전트를 만들 수 있어요.', 'somodusでは文章ひとつで自分のゲームやAIエージェントが作れます。', 'On somodus, one sentence builds your own game or AI agent.'); var cta = document.createElement('button'); cta.type = 'button'; cta.className = 'pmenu-item'; cta.textContent = _t('나만의 게임 만들기 →', '自分のゲームを作る →', 'Build your own game →'); cta.style.cssText = 'display:flex;align-items:center;justify-content:center;padding:11px 10px;border-radius:11px;' + 'border:1px solid rgba(245,158,11,.5);background:linear-gradient(160deg,rgba(66,46,20,.9),rgba(26,17,8,.94));' + 'color:#ffd98a;font:700 13px/1.2 system-ui,sans-serif;touch-action:manipulation;'; cta.addEventListener('click', function (e) { e.stopPropagation(); try { window.open(CTA_URL, '_blank', 'noopener'); } catch (err) {} _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'], svg: '
', // ⚡ 대체 인라인 SVG(글리프 깨짐 회피) onSelect: _brand_open, }); })(); // ── 배경음악 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'], 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; } // ── WindManager — 전 소비자(풀/꽃잎/구름/샤프트)가 공유하는 단일 바람장 ── var W = { uTime: { value: 0 }, uDir: { value: new THREE_.Vector2(1, 0.35).normalize() }, uStrength: { value: 0.45 }, uPlayer: { value: new THREE_.Vector2(9999, 9999) }, base: 0.45, gustiness: 0.6, speed: 1, }; nature.wind = function (opts) { var o = opts || {}; W.base = clamp(num(o.strength, 0.45), 0, 1.2); W.gustiness = clamp(num(o.gustiness, 0.6), 0, 1); W.speed = clamp(num(o.speed, 1), 0.2, 3); if (o.direction != null) { var wd = P._nv2(o.direction, 1, 0.35); // 객체/배열 수용(L2) if (wd.x * wd.x + wd.z * wd.z < 1e-6) { // L3: 0-벡터 퇴화 가드 try { console.warn('[nature] wind direction is zero — using default'); } catch (e) {} wd = { x: 1, z: 0.35 }; } W.uDir.value.set(wd.x, wd.z).normalize(); } return W; }; var _player_ref = null; nature.set_player = function (obj) { _player_ref = obj || null; }; // ── 자동 풍요 바닥선(auto richness floor) — "무지시 = 볼거리 가득"의 구조 보장 ── // 원리: LLM 로직(부트 동기 실행)이 먼저 호출권을 행사하고, 0.8s 후 셸이 *빈 축만* 기본값으로 // 채운다(생략이 정답 구조의 장면 단위 확장 — 판단을 프롬프트 규율이 아닌 구조로 흡수). // 명시 호출 = 해당 축 자동 해제(0 값 = 명시 미니멀 opt-out). 침묵-성공 정상(자동 스킵 무알림). // 억제: voxel 도시/craft 블록월드(초원 드레싱 부적합), 예산: 잔디 14K·커버 0.9·구름 8 — // 폰 안전권 하단(LLM 커스텀 여지를 남기는 보수 기본값). var _floor = { grass: false, cover: false, clouds: false, aquatic: false, done: false }; var _floor_t = 0; // 발화 시계 = 벽시계(raw 누적) — 게임 시간(elapsed)은 시작 카드 동안 dt=0 정지라 // "클릭하여 시작" 배경(유저 첫인상 프레임)에 바닥선이 영원히 부재하던 클록-도메인 실사고의 봉합. var _amb = null; // {birds:{im,list}, animals:[{h,tx,tz,wait}]} function _floor_apply(elapsed) { _floor.done = true; var made = []; var has_t = !!(P.world && P.world._priv && P.world._priv.has_terrain && P.world._priv.has_terrain()); // 맵-세계 선언(spawn_map closed:true) = voxel 도시/craft 와 같은 세계-소유권 — GLB 맵이 세계의 // 전부이므로 자동 초원/클러터/구름 주입이 "밖의 다른 세계"를 만들던 실사고(camo 맵 모드 2026-07-27) 차단. var _mw = !!(P.world && P.world._priv && P.world._priv.map_world); var blocked = (P.voxel && P.voxel._priv && P.voxel._priv.cities && P.voxel._priv.cities.length > 0) || !!P.craft || _mw; var auto_meadow = false; if (has_t && !blocked) { // 순서-역전 지연 치유 — 지형 이전에 만들어진 고정 패치(y 상수 베이크·원점 국한·원거리 맨땅)를 // 동일 opts 로 스트리밍 재구축(유일-결정 복구 + LOUD). 명시 opt-out(count:0)은 패치 자체가 없어 비대상. if (_grass_fixed.length) { var _gfx = _grass_fixed.splice(0); for (var gf = 0; gf < _gfx.length; gf++) { try { console.warn('[nature] grass_field was built BEFORE world.terrain — rebuilding as a streaming field (call terrain first)'); } catch (e) {} for (var gm = 0; gm < _gfx[gf].ims.length; gm++) { var IM3 = _gfx[gf].ims[gm]; scene.remove(IM3); if (IM3.geometry) IM3.geometry.dispose(); if (IM3.material && IM3.material.dispose) IM3.material.dispose(); var fi3 = _grass_fields.indexOf(IM3); if (fi3 >= 0) _grass_fields.splice(fi3, 1); } nature.grass_field(_gfx[gf].o); } } if (!_floor.cover) { made.push('cover'); nature.ground_cover({ density: 0.9 }); } if (!_floor.grass) { made.push('grass'); nature.grass_field({ count: 22700 }); auto_meadow = true; } // 창 140m 기본에서 1.16잎/㎡(아키타입 26000/150² 와 동일 밀도) — 14000 이면 0.71/㎡ 로 희석돼 보임 } if (!_floor.clouds && scene.fog && !_mw) { made.push('clouds'); nature.clouds({ count: 8 }); } // 맵-세계 = 하늘 장식도 미주입(개구부 밖 가시면 소거 캐논) if (!_floor.aquatic && (P._water_y != null || _rivers.length > 0)) { made.push('aquatic'); nature.aquatic({ density: 0.8 }); } // 앰비언트 생명 — "세계가 나 없이도 산다": 선회 새 2(쿼드 — 스킨 예산 무관) + 무지시 초원에 // 한해 방목 동물 2(베이스 카탈로그 world.spawn — 매니페스트 상시 가용, 목표점 방랑 미니 AI). if (has_t && !blocked) { var bg = new THREE_.PlaneGeometry(1.1, 0.22); var bim2 = new THREE_.InstancedMesh(bg, new THREE_.MeshBasicMaterial({ color: 0xF2F5F7, transparent: true, opacity: 0.9, side: THREE_.DoubleSide }), 2); bim2.frustumCulled = false; scene.add(bim2); _amb = { birds: { im: bim2, list: [{ ph: Math.random() * 6.28, r: 18, h: 11, w: 0.11 }, { ph: Math.random() * 6.28 + 3, r: 26, h: 14, w: 0.08 }], m4: new THREE_.Matrix4(), q: new THREE_.Quaternion(), v: new THREE_.Vector3(), s: new THREE_.Vector3(1, 1, 1), up: new THREE_.Vector3(0, 1, 0) }, animals: [] }; made.push('birds'); if (auto_meadow && P.world.spawn) { var pool = ['deer', 'sheep', 'fox', 'horse', 'cat']; // 카탈로그 creature 패밀리(생성기 자동 배선 — 게임마다 다른 표본) 합류: 방목 동물도 매 게임 다양 var _famc = window['__PLAYABLE_'+'EXTRA_MANIFEST__'] && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].families; if (_famc && Array.isArray(_famc.creature)) pool = pool.concat(_famc.creature); for (var an = 0; an < 2; an++) { var nm2 = pool[Math.floor(Math.random() * pool.length)]; var ax2 = (Math.random() - 0.5) * 44, az2 = 14 + Math.random() * 26; var hd2 = P.world.spawn(nm2, { at: { x: ax2, z: az2 }, collide: false }); _amb.animals.push({ h: hd2, tx: ax2, tz: az2, wait: 2 + Math.random() * 5 }); } made.push('animals'); } } _scape_build(); // 자동 사운드스케이프 — 시각 축과 동일 시점(0.8s)에 장면 감지로 구성 nature._floor_made = made; // 스모크 관측면(비계약) } // 셰이더 주입기(단일 모듈 — variant 별 cache key 필수, replace 침묵 실패 assert) function _inject(material, variant, vertex_decl, vertex_body) { material.onBeforeCompile = function (shader) { shader.uniforms.uWindTime = W.uTime; shader.uniforms.uWindDir = W.uDir; shader.uniforms.uWindStrength = W.uStrength; shader.uniforms.uPlayerPos = W.uPlayer; shader.vertexShader = vertex_decl + '\n' + shader.vertexShader; var before = shader.vertexShader; shader.vertexShader = shader.vertexShader.replace( '#include
', '#include
\n' + vertex_body ); if (shader.vertexShader === before) { try { console.error('[nature] shader anchor not found for ' + variant + ' — wind disabled on this material'); } catch (e) {} if (window.__playable_beacon) window.__playable_beacon('shader_anchor', 'wind anchor missing: ' + variant); // 엔진 버전 드리프트 클래스 — 기기 콘솔에 갇히면 안 됨 } }; material.customProgramCacheKey = function () { return 'playable-nature-' + variant; }; return material; } var _WIND_DECL = 'uniform float uWindTime;\nuniform vec2 uWindDir;\nuniform float uWindStrength;\nuniform vec2 uPlayerPos;'; // ── 풀밭 (히어로) — InstancedMesh 다발(tuft) + 높이 가중 바람 굽힘 + 플레이어 bend ── // 다발 = 테이퍼+굽음 잎 5장 부채 배치(단일 리본 잎은 "다리털" 지각 실사고 — 함수 내 주석). // root 어둡게(fake AO)·tip 밝게. 인스턴스 색 variation. 인스턴스 회전은 미세(±25°) // — 큰 Y회전은 로컬 변위의 월드 방향을 흩뜨려 "전역 바람" 지각을 깨는 함정(리서치 확정). var _grass_fields = []; var _grass_streams = []; // 슬라이딩-윈도우 스트리밍 잔디 상태({im, side, sp, ...}) — 아래 틱이 재배치 var _grass_fixed = []; // 지형-이전 고정 패치 호출 스냅샷({o, ims}) — 바닥선이 스트리밍으로 지연 치유 // ── 자동 사운드스케이프 — 장면 감지 자연음(무지시 기본, 자동 풍요 바닥선 동형): 바람(야외)· // 새(낮)/귀뚜라미(별 뜬 밤 — 판별자 = 별 필드 레벨, mood 이름 정규식 아님)·파도(수면, 해변 근접 // 게인 = 지면-수면 고도차)·개울(강 폴리라인 최근접 거리 게인)·비(날씨 연동). 명시 P.ambience // 호출/stop = 소유권 이관(runtime 이 자동 레이어 전 해체) — LLM 아트 디렉션이 항상 이긴다. var _scape = { done: false, L: {}, poll: 0, star_lv: 0 }; function _scape_build() { if (_scape.done || !P.ambience || !P.ambience._auto) return; _scape.done = true; var Ls = _scape.L; if (scene.fog) Ls.wind = P.ambience._auto('wind', 0.55); // 야외(무드가 fog 소유) = 바람 상시 if (_scape.star_lv > 0.05) Ls.night = P.ambience._auto('night', 0.85); else if (scene.fog) Ls.forest = P.ambience._auto('forest', 0.5); if (P._water_y != null) Ls.waves = P.ambience._auto('ocean', 0.35); if (_rivers.length) Ls.river = P.ambience._auto('river', 0.12); if (_weather && _weather.kind === 'rain') Ls.rain = P.ambience._auto('rain', _weather.kind_raw === 'storm' ? 1.3 : 0.9); } function _scape_weather_sync() { // 날씨 전환 연동(비 시작/그침) — 빌드 이후에만 if (!_scape.done || !P.ambience || !P.ambience._auto) return; if (_scape.L.rain) { _scape.L.rain.stop(); _scape.L.rain = null; } if (_weather && _weather.kind === 'rain') _scape.L.rain = P.ambience._auto('rain', _weather.kind_raw === 'storm' ? 1.3 : 0.9); } // 셀 결정론 해시(0..1) — 같은 월드 셀 = 항상 같은 다발(재방문 팝핑 없음, Math.random 금지 영역) function _cell_h(cx, cz, k) { var h = (cx * 374761393 + cz * 668265263 + k * 1442695041) | 0; h = ((h ^ (h >>> 13)) * 1274126177) | 0; return ((h ^ (h >>> 16)) >>> 0) / 4294967296; } nature.grass_field = function (opts) { var o = opts || {}; _floor.grass = true; // 자동 바닥선 계약: 명시 호출(0 포함) = 자동 주입 해제 if (o.count === 0) return null; // count:0 = 명시 opt-out(미니멀 아트 디렉션) // 스트리밍 여부를 먼저 판정 — area 기본값이 모드에 종속: 추종 창은 셰이더 원거리 페이드 // 종단(70m)까지 커버해야 하므로 기본 140(±70m). 60 이면 창 에지(30m)가 페이드 시작(45m)보다 // 안쪽이라 잔디가 페이드 없이 뚝 끊기는 하드 에지 밴드(30~45m)가 생긴다(시뮬레이션 확증). // 고정 패치(디오라마)는 기존 60 유지. var _stream = (o.follow !== false) && !!(P.world && P.world._priv && P.world._priv.has_terrain && P.world._priv.has_terrain()); var area = clamp(num(o.area, _stream ? 140 : 60), 8, 400); // per_m2(잎/㎡) = 밀도 정본 표현 — 지정 시 count 를 파생(스트리밍 모드에선 area = 추종 윈도우 폭). var count = Math.round(clamp(num(o.per_m2, 0) > 0 ? o.per_m2 * area * area : num(o.count, 20000), 500, 60000)); // 폰 안전권 ≤~30K(42K 가 iPhone DRS 바닥 유발 실사고 — 프롬프트 계약 동일); 상한 60K 는 데스크톱 여유분 var hgt = clamp(num(o.height, 1), 0.2, 3); // 색 기본값 = 활성 무드 ground_ramp(P._pal) — 하늘 ramp 오사용("검은 털" 실사고) 흡수. // mood-먼저 순서 전제 — 위반 시 기본 녹색이 침묵으로 구워지므로 LOUD(이후 mood 는 재색칠 불가). var gr = (P._pal && P._pal.ground_ramp) || null; if (!gr && !o.base && !o.tip) { try { console.warn('[nature] grass_field built before PLAYABLE.mood() — default green baked in (call mood first)'); } catch (e) {} } var base = new THREE_.Color(o.base || (gr && gr[1]) || '#2E6B34'); var tip = new THREE_.Color(o.tip || (gr && gr[3]) || '#8FCB5B'); // ── 다발(tuft) 지오메트리 — "다리털" 실사고 봉합(2026-07-10 사용자 실보고) ── // 구 형태(가는 단일 리본 0.07×1 + 근흑 ×0.35 뿌리)는 ~1잎/㎡ 로 고립 배치되어 밝은 지면 위 // 성긴 검은 털로 지각됐다. 업계 정본(BotW/GoT — 잎은 항상 다발)으로 교체: 테이퍼(밑동 넓고 // 끝 뾰족) + 끝 굽음(pre-curve) 잎 5장을 부채 배치로 한 인스턴스에 묶는다. // count 계약 유지: count = *잎* 예산(프롬프트 ≤60000·폰 ≤30K 그대로) — 인스턴스 = count/5, // 잎당 평균 면적 동급이므로 필레이트 등가(iPhone DRS 안전권 불변). // 다발 변형 3종 — 단일 다발 실루엣의 전면 반복("풀 에셋이 한 개뿐" 실보고)을 지오메트리 // 다양성으로 봉합: 표준(잎5)·장초(잎3 — 길고 굽은 억새)·단밀(잎8 — 낮고 촘촘한 덤불). // 잎 예산 계약 유지: 평균 잎/다발 ≈ 5.3 이라 count→인스턴스 환산·필레이트 등가. var VARIANTS = [ { blades: 5, bh0: 0.55, bhr: 0.45, w0a: 0.10, w0r: 0.06, lean0: 0.10, leanr: 0.22, bend0: 0.10, bendr: 0.18, share: 0.5 }, { blades: 3, bh0: 0.85, bhr: 0.5, w0a: 0.06, w0r: 0.04, lean0: 0.16, leanr: 0.24, bend0: 0.24, bendr: 0.22, share: 0.22 }, { blades: 8, bh0: 0.3, bhr: 0.28, w0a: 0.13, w0r: 0.07, lean0: 0.2, leanr: 0.3, bend0: 0.06, bendr: 0.1, share: 0.28 }, ]; function _make_tuft(V) { var _tp = [], _tt = []; for (var b = 0; b < V.blades; b++) { var byaw = (b / V.blades) * Math.PI * 2 + Math.random() * 1.2; var wx = Math.cos(byaw), wz = Math.sin(byaw); var lx = -wz, lz = wx; var bh = V.bh0 + Math.random() * V.bhr; var w0 = V.w0a + Math.random() * V.w0r; var lean = V.lean0 + Math.random() * V.leanr; var bend = V.bend0 + Math.random() * V.bendr; var bx = wx * Math.random() * 0.13, bz = wz * Math.random() * 0.13; var rows = 4, prevL = null, prevR = null, prevT = 0; for (var ri = 0; ri <= rows; ri++) { var t = ri / rows; var cw = w0 * (1 - t); var off = lean * t + bend * t * t; var cx = bx + lx * off, cy = bh * t, cz = bz + lz * off; var Lx = cx - wx * cw / 2, Lz = cz - wz * cw / 2; var Rx = cx + wx * cw / 2, Rz = cz + wz * cw / 2; if (ri > 0) { if (ri < rows) { _tp.push(prevL[0], prevL[1], prevL[2], prevR[0], prevR[1], prevR[2], Lx, cy, Lz); _tp.push(prevR[0], prevR[1], prevR[2], Rx, cy, Rz, Lx, cy, Lz); _tt.push(prevT, prevT, t, prevT, t, t); } else { _tp.push(prevL[0], prevL[1], prevL[2], prevR[0], prevR[1], prevR[2], cx, cy, cz); _tt.push(prevT, prevT, t); } } prevL = [Lx, cy, Lz]; prevR = [Rx, cy, Rz]; prevT = t; } } var g = new THREE_.BufferGeometry(); g.setAttribute('position', new THREE_.BufferAttribute(new Float32Array(_tp), 3)); g.computeVertexNormals(); return { geo: g, tt: _tt }; } var tufts = Math.max(100, Math.round(count / 5.3)); // 정점 색: 뿌리(base×0.72 — fake AO) → 끝(tip). 구 ×0.35(근흑)는 털 지각의 절반이었다. function _color_tuft(T2) { var bc = new Float32Array(T2.tt.length * 3); var dark = base.clone().multiplyScalar(0.72); var tmp = new THREE_.Color(); for (var vi = 0; vi < T2.tt.length; vi++) { tmp.copy(dark).lerp(tip, Math.pow(clamp(T2.tt[vi], 0, 1), 0.7)); bc[vi * 3] = tmp.r; bc[vi * 3 + 1] = tmp.g; bc[vi * 3 + 2] = tmp.b; } T2.geo.setAttribute('color', new THREE_.BufferAttribute(bc, 3)); } var mat = new THREE_.MeshStandardMaterial({ vertexColors: true, roughness: 0.9, metalness: 0, side: THREE_.DoubleSide }); _inject(mat, 'grass-v2', _WIND_DECL, [ '#ifdef USE_INSTANCING', ' float nwPh = instanceMatrix[3].x * 0.35 + instanceMatrix[3].z * 0.27;', ' float nwW = pow(max(position.y, 0.0), 1.5);', ' float nwSway = (sin(uWindTime * 1.7 + nwPh) * 0.55 + sin(uWindTime * 3.3 + nwPh * 1.7) * 0.25 + sin(uWindTime * 0.53 + nwPh * 0.31) * 0.35) * uWindStrength;', ' transformed.x += uWindDir.x * nwSway * nwW;', ' transformed.z += uWindDir.y * nwSway * nwW;', ' transformed.y -= nwSway * nwSway * nwW * 0.4;', // 원호 보존 — 고무줄 느낌 제거 ' vec2 nwAway = instanceMatrix[3].xz - uPlayerPos;', ' float nwD = length(nwAway);', ' if (nwD < 1.6) {', ' float nwPB = (1.6 - nwD) / 1.6;', ' transformed.xz += normalize(nwAway + vec2(0.0001)) * nwPB * nwPB * 0.55 * nwW;', ' transformed.y -= nwPB * 0.25 * nwW;', ' }', // 원거리 페이드 — 지평선까지 뿌려진 성긴 잎이 하늘 배경 위 "털 점묘"로 보이던 것을 45→70m // 스무스 축소로 제거(원거리 필레이트 절감 겸). cameraPosition = three 내장 유니폼. ' float nwCd = distance(instanceMatrix[3].xz, cameraPosition.xz);', ' transformed *= (1.0 - smoothstep(45.0, 70.0, nwCd));', '#endif', ].join('\n')); var m4 = new THREE_.Matrix4(), q = new THREE_.Quaternion(), v3 = new THREE_.Vector3(), s3 = new THREE_.Vector3(); var col = new THREE_.Color(); var out = []; for (var vI = 0; vI < VARIANTS.length; vI++) { var T2 = _make_tuft(VARIANTS[vI]); _color_tuft(T2); var vn = Math.max(50, Math.round(tufts * VARIANTS[vI].share)); var im = new THREE_.InstancedMesh(T2.geo, mat, vn); im.frustumCulled = false; // ── 배치 2모드 ── // 스트리밍(기본, world 지형 존재 시): 밀도 = count/area²(잎/㎡)를 유지한 채 윈도우가 플레이어를 // 추종 — 구 "원점 고정 유한 패치"는 지형 무한 스트리밍과의 구조 불일치로, 시작점을 벗어나면 // 맨땅이 되던 실보고(2026-07-12)의 근원. 토로이달 격자(창 이동 시 진입 행/열만 재배치)+ // 셀 해시 결정론. 슬롯→격자 매핑은 전단사 순열(i×48271 mod side²) — DRS 강등(count 축소)이 // 한 구역을 통째 비우지 않고 균일 희석되게 한다. // 고정 패치(디오라마 — 지형 부재 or follow:false): 기존 랜덤 패치 유지. if (_stream) { // side = ceil 필수: round 내림이면 side² < vn 인 케이스(예: vn 581 → side 24, 576<581)에서 // 순열이 잉여 슬롯을 기존 셀로 재매핑 → 동일 위치 완전 겹침 다발(z-fight). ceil 은 side² ≥ vn // 보장(잉여 셀은 순열상 균일 산재 미사용 = 무해). var _side = Math.max(4, Math.ceil(Math.sqrt(vn))); var _sp = area / _side; // 격자 간격 = 창폭/side → 밀도 보존 _grass_streams.push({ im: im, side: _side, sp: _sp, vn: vn, hgt: hgt, yfn: (typeof o.y === 'function') ? o.y : ((P.world && P.world.height_at) || null), c0x: 1e9, c0z: 1e9, }); im.count = 0; // 첫 재배치 틱 전 identity 행렬 뭉침 노출 방지 } else { for (var i = 0; i < vn; i++) { var x = (Math.random() - 0.5) * area; var z = (Math.random() - 0.5) * area; var y = typeof o.y === 'function' ? o.y(x, z) : num(o.y, 0); q.setFromAxisAngle(v3.set(0, 1, 0), (Math.random() - 0.5) * 0.9); var sc = (0.7 + Math.random() * 0.5) * hgt; // 실내 컷 — 선언된 바닥면(건물 내부) 위에는 식생이 자라지 않는다(스트리밍 경로와 동형) if (P.world && P.world.floor_covers && P.world.floor_covers(x, z)) sc = 0.0001; var sxz = 0.95 + Math.random() * 0.55; // 다발 폭 variation(높이와 독립 — 커버리지 우선) m4.compose(v3.set(x, y, z), q, s3.set(sxz, sc, sxz)); im.setMatrixAt(i, m4); // 인스턴스 색 variation — 클럼프 근사(위치 기반 저주파 변조) var cl = 0.85 + 0.3 * Math.sin(x * 0.21) * Math.sin(z * 0.17) + (Math.random() - 0.5) * 0.12; im.setColorAt(i, col.setRGB(cl, cl, cl)); } im.instanceMatrix.needsUpdate = true; if (im.instanceColor) im.instanceColor.needsUpdate = true; } scene.add(im); _grass_fields.push(im); out.push(im); } // 순서-역전 스냅샷 — 지형 이전 호출은 고정 패치(y 베이크·원점 국한)가 되므로, 바닥선 시점에 // 지형이 실재하면 동일 opts 로 스트리밍 재구축할 수 있게 보관(follow:false = 명시 고정 → 제외). if (!_stream && o.follow !== false) _grass_fixed.push({ o: o, ims: out.slice() }); return out[0]; // 반환 계약 유지(대표 인스턴스 — 기존 소비자는 단일 im 전제) }; // 스트리밍 잔디 재배치 — 앵커(플레이어→카메라 폴백) 셀 창 이동 시에만 전 슬롯 스캔(정수 연산), // 실제 setMatrixAt 은 셀이 바뀐 슬롯만. 수면 침수 셀 = 스케일 0(강/바다 위 잔디 컷). var _gs_m4 = new THREE_.Matrix4(), _gs_q = new THREE_.Quaternion(), _gs_v = new THREE_.Vector3(), _gs_s = new THREE_.Vector3(), _gs_up = new THREE_.Vector3(0, 1, 0), _gs_col = new THREE_.Color(); function _grass_stream_tick() { if (!_grass_streams.length) return; var pl = P.world && P.world._priv && P.world._priv.player(); var ax = pl && pl.handle ? pl.handle.obj.position.x : (P.three ? P.three.camera.position.x : 0); var az = pl && pl.handle ? pl.handle.obj.position.z : (P.three ? P.three.camera.position.z : 0); for (var gi = 0; gi < _grass_streams.length; gi++) { var G = _grass_streams[gi]; var c0x = Math.floor(ax / G.sp) - (G.side >> 1); var c0z = Math.floor(az / G.sp) - (G.side >> 1); if (c0x === G.c0x && c0z === G.c0z) continue; var first = G.c0x === 1e9; G.c0x = c0x; G.c0z = c0z; var side = G.side, n2 = side * side; var dirty = false; for (var k = 0; k < G.vn; k++) { var kp = (k * 48271) % n2; // 전단사 순열(48271 소수 — side² 와 서로소) var a = (kp / side) | 0, b = kp % side; var cx = c0x + (((a - c0x) % side) + side) % side; var cz = c0z + (((b - c0z) % side) + side) % side; if (!first && G.im.userData['s' + k] === cx * 1e7 + cz) continue; // 셀 불변 슬롯 스킵 G.im.userData['s' + k] = cx * 1e7 + cz; var h1 = _cell_h(cx, cz, 1), h2 = _cell_h(cx, cz, 2), h3 = _cell_h(cx, cz, 3), h4 = _cell_h(cx, cz, 4); var x2 = (cx + 0.15 + h1 * 0.7) * G.sp; var z2 = (cz + 0.15 + h2 * 0.7) * G.sp; var y2 = G.yfn ? G.yfn(x2, z2) : 0; var sc2 = (0.7 + h3 * 0.5) * G.hgt; if (P._water_y != null && y2 < P._water_y - 0.05) sc2 = 0.0001; // 수면 아래 컷 // 실내 컷 — 선언된 바닥면(건물 내부) 위에는 식생이 자라지 않는다. 수면 컷과 동형. if (P.world && P.world.floor_covers && P.world.floor_covers(x2, z2)) sc2 = 0.0001; _gs_q.setFromAxisAngle(_gs_up, (h4 - 0.5) * 0.9); _gs_m4.compose(_gs_v.set(x2, y2, z2), _gs_q, _gs_s.set(0.95 + h2 * 0.55, sc2, 0.95 + h1 * 0.55)); G.im.setMatrixAt(k, _gs_m4); var cl2 = 0.85 + 0.3 * Math.sin(x2 * 0.21) * Math.sin(z2 * 0.17) + (h3 - 0.5) * 0.12; G.im.setColorAt(k, _gs_col.setRGB(cl2, cl2, cl2)); dirty = true; } if (first) G.im.count = G.vn; if (dirty) { G.im.instanceMatrix.needsUpdate = true; if (G.im.instanceColor) G.im.instanceColor.needsUpdate = true; } } } // ── 별 필드 v2 — "쏟아지는 밤하늘"(실보고: 구 350 균일점은 밋밋 + mood 밤 계열엔 별 부재) ── // 4층 Points(밝은 성긴/중간/희미 밀집/은하수 밴드): 층별 고정 크기 + 정점색 색온도(냉백/온백/청)로 // 커스텀 셰이더 없이 다양성 확보, 트윙클은 층별 위상 opacity 진동(포인트 셰이더 앵커 주입의 // 버전 취약성 회피). + 간헐 유성(가산 스트릭). time_of_day 와 mood 밤 계열(three.md)이 공유. var _starfield = null; var _star_tex = null; function _star_sprite_tex() { // 라디얼 글로우 — 하드 사각 점은 3px 에서 "별"로 지각되지 않는다(실측) if (_star_tex) return _star_tex; var c = document.createElement('canvas'); c.width = 32; c.height = 32; var g = c.getContext('2d'); var rg = g.createRadialGradient(16, 16, 0, 16, 16, 16); rg.addColorStop(0, 'rgba(255,255,255,1)'); rg.addColorStop(0.35, 'rgba(255,255,255,0.55)'); rg.addColorStop(1, 'rgba(255,255,255,0)'); g.fillStyle = rg; g.fillRect(0, 0, 32, 32); _star_tex = new THREE_.CanvasTexture(c); return _star_tex; } function _star_layer(n, size, band) { var pos = new Float32Array(n * 3), col = new Float32Array(n * 3); for (var i = 0; i < n; i++) { var vx, vy, vz; if (band) { // 은하수 — 기울인 대원 주변 가우시안 산포 var t = Math.random() * Math.PI * 2; var spread = (Math.random() + Math.random() + Math.random() - 1.5) * 0.16; vx = Math.cos(t); vy = Math.sin(t) * 0.78 + spread; vz = Math.sin(t) * 0.63 + spread * 0.4; var L = Math.sqrt(vx * vx + vy * vy + vz * vz) || 1; vx /= L; vy /= L; vz /= L; if (vy < 0.06) { i--; continue; } // 지평선 아래 제외 } else { var a = Math.random() * Math.PI * 2, e = Math.asin(Math.random()) * 0.92 + 0.06; vx = Math.cos(a) * Math.cos(e); vy = Math.sin(e); vz = Math.sin(a) * Math.cos(e); } pos[i * 3] = vx * 700; pos[i * 3 + 1] = vy * 700; pos[i * 3 + 2] = vz * 700; var r0 = Math.random(), cr, cg, cb; // 색온도: 68% 냉백 · 22% 온백 · 10% 청 if (r0 < 0.68) { cr = 0.92; cg = 0.95; cb = 1.0; } else if (r0 < 0.9) { cr = 1.0; cg = 0.9; cb = 0.76; } else { cr = 0.72; cg = 0.84; cb = 1.0; } var lum = (band ? 0.45 : 0.65) + Math.random() * 0.35; col[i * 3] = cr * lum; col[i * 3 + 1] = cg * lum; col[i * 3 + 2] = cb * lum; } var g2 = new THREE_.BufferGeometry(); g2.setAttribute('position', new THREE_.BufferAttribute(pos, 3)); g2.setAttribute('color', new THREE_.BufferAttribute(col, 3)); // 가산 블렌딩 — ACES 톤매핑 아래 비가산 점은 어두운 하늘에서 뭉개진다(실측: 육안 희미). // fog:false 필수 — scene.fog(far≈95m)가 r=700 돔을 안개색으로 전면 잠식(실측: 별 불가시 근원) var pnt = new THREE_.Points(g2, new THREE_.PointsMaterial({ size: size, sizeAttenuation: false, vertexColors: true, transparent: true, opacity: 0, depthWrite: false, blending: THREE_.AdditiveBlending, map: _star_sprite_tex(), fog: false })); pnt.frustumCulled = false; return pnt; } function _make_starfield() { if (_starfield) return _starfield; var group = new THREE_.Group(); var layers = [ { p: _star_layer(320, 7), base: 1.0, tw: 0.5, rate: 0.9, ph: 0 }, { p: _star_layer(1200, 4.2), base: 0.9, tw: 0.35, rate: 1.5, ph: 2.1 }, { p: _star_layer(3000, 2.6), base: 0.75, tw: 0.25, rate: 2.2, ph: 4.2 }, { p: _star_layer(2200, 2.2, true), base: 0.6, tw: 0.15, rate: 0.6, ph: 1.1 }, // 은하수 ]; for (var li0 = 0; li0 < layers.length; li0++) group.add(layers[li0].p); // 유성 — 스트릭 스프라이트 1개 재사용(14~40초 간격, 0.9초 스윕) var mc = document.createElement('canvas'); mc.width = 128; mc.height = 8; var mg = mc.getContext('2d'); var lg2 = mg.createLinearGradient(0, 0, 128, 0); lg2.addColorStop(0, 'rgba(255,255,255,0)'); lg2.addColorStop(0.75, 'rgba(220,235,255,0.9)'); lg2.addColorStop(1, 'rgba(255,255,255,1)'); mg.fillStyle = lg2; mg.fillRect(0, 0, 128, 8); var meteor = new THREE_.Mesh(new THREE_.PlaneGeometry(46, 2.2), new THREE_.MeshBasicMaterial({ map: new THREE_.CanvasTexture(mc), transparent: true, opacity: 0, blending: THREE_.AdditiveBlending, depthWrite: false, side: THREE_.DoubleSide, fog: false })); meteor.frustumCulled = false; group.add(meteor); group.visible = false; scene.add(group); // 달 — 위상(phase 0..1: 0=신월 1=만월)·색(블러드문 등) 캔버스 스프라이트. fog:false 필수(천체 계약). var moonCv = document.createElement('canvas'); moonCv.width = moonCv.height = 128; function _draw_moon(phase, color) { var g2 = moonCv.getContext('2d'); g2.clearRect(0, 0, 128, 128); var rg = g2.createRadialGradient(64, 64, 30, 64, 64, 64); rg.addColorStop(0, color); rg.addColorStop(0.72, color); rg.addColorStop(1, 'rgba(255,255,255,0)'); g2.fillStyle = rg; g2.beginPath(); g2.arc(64, 64, 60, 0, Math.PI * 2); g2.fill(); if (phase < 0.97) { // 위상 그림자 — phase 1=만월(그림자 이탈)·0=신월(전부 가림). 오프셋 ∝ phase(반전 실측 정정) g2.globalCompositeOperation = 'destination-out'; g2.beginPath(); g2.arc(64 - phase * 122, 64, 58, 0, Math.PI * 2); g2.fill(); g2.globalCompositeOperation = 'source-over'; } } _draw_moon(0.85, 'rgba(240,238,225,0.95)'); var moon = new THREE_.Mesh(new THREE_.PlaneGeometry(56, 56), new THREE_.MeshBasicMaterial({ map: new THREE_.CanvasTexture(moonCv), transparent: true, opacity: 0, depthWrite: false, fog: false, side: THREE_.DoubleSide })); // 달 배치는 첫 *가시* 틱으로 이연 — 생성/mood 호출 시점엔 카메라 리그가 정착 전(기본 -z 응시)이라 // "생성 시점 전방"이 실제 시작 화면의 등 뒤가 되는 함정(스크린샷 실측). tick 에서 1회 배치. moon.visible = false; moon.frustumCulled = false; group.add(moon); var S = { group: group, layers: layers, level: 0, meteor: meteor, moon: moon, draw_moon: _draw_moon, moon_scale: 1, m_t: 99, m_next: 8 + Math.random() * 16, m_from: new THREE_.Vector3(), m_dir: new THREE_.Vector3(), set_level: function (f) { S.level = clamp(num(f, 0), 0, 1); group.visible = S.level > 0.02; }, tick: function (raw, elapsed) { if (!group.visible) return; var cam = P.three.camera; group.position.set(cam.position.x, 0, cam.position.z); // 돔이 카메라를 따라와 어디서든 온하늘 for (var li = 0; li < layers.length; li++) { var L2 = layers[li]; L2.p.material.opacity = S.level * L2.base * (1 - L2.tw * (0.5 + 0.5 * Math.sin(elapsed * L2.rate + L2.ph))); } S.m_t += raw; if (S.m_t > S.m_next) { S.m_t = 0; S.m_next = 14 + Math.random() * 26; var a2 = Math.random() * Math.PI * 2, e2 = 0.55 + Math.random() * 0.5; S.m_from.set(Math.cos(a2) * Math.cos(e2), Math.sin(e2), Math.sin(a2) * Math.cos(e2)).multiplyScalar(620); S.m_dir.set(Math.random() - 0.5, -0.3 - Math.random() * 0.35, Math.random() - 0.5).normalize(); } if (S.m_t < 0.9) { var k = S.m_t / 0.9; meteor.position.copy(S.m_from).addScaledVector(S.m_dir, k * 260); meteor.lookAt(group.position.x + meteor.position.x + S.m_dir.x, meteor.position.y + S.m_dir.y, group.position.z + meteor.position.z + S.m_dir.z); meteor.rotateY(Math.PI / 2); meteor.material.opacity = S.level * Math.sin(Math.PI * k) * 0.85; } else meteor.material.opacity = 0; if (!S.moon_placed && elapsed > 1.2) { // 리그 정착 후(즉시-가시 틱은 아직 기본 카메라 — 실측 함정) 전방 ±40° 상공에 달 고정 S.moon_placed = true; var _mdir = cam.getWorldDirection(new THREE_.Vector3()); // ±12°·고도 20~28° — 모바일 세로(FOV60)와 데스크톱 가로 어느 쪽에서도 시작 프레임 안(전방 dot 0.52=59° 이탈 실측) var _ma = Math.atan2(_mdir.z, _mdir.x) + (Math.random() - 0.5) * 0.42, _me = 0.14 + Math.random() * 0.09; // 고도 8~13° — 3인칭 카메라는 하향 피치(-15°)라 상단 프레임 한계가 +15°(실측): 지평선 위 낮은 큰 달이 유일하게 항상 보이는 대역 moon.position.set(Math.cos(_ma) * Math.cos(_me), Math.sin(_me), Math.sin(_ma) * Math.cos(_me)).multiplyScalar(660); moon.lookAt(group.position.x, 0, group.position.z); moon.visible = true; } moon.material.opacity = S.level * 0.95; }, }; _starfield = S; return S; } // mood 밤 계열 브리지(내부 규약 — three.md P.mood 가 호출; 언더스코어 = 로직 계약 아님) nature._mood_stars = function (level) { _scape.star_lv = level; _make_starfield().set_level(level); }; // 달 커스텀 — phase 0..1(0=신월 1=만월), color(블러드문 '#C4432B' 등), size 배율. 밤 무드/별과 함께 표시. nature.moon = function (opts) { var o = opts || {}; var S = _make_starfield(); S.draw_moon(clamp(num(o.phase, 0.85), 0.05, 1), typeof o.color === 'string' ? o.color : 'rgba(240,238,225,0.95)'); S.moon.material.map.needsUpdate = true; var sc = clamp(num(o.size, 1), 0.4, 3); S.moon.scale.set(sc, sc, 1); return S.moon; }; // ── 오로라 — 하늘 리본(버텍스 웨이브 + 가산, fog:false 천체 계약). 밤·황혼과 조합. ── var _aurora = null; nature.aurora = function (opts) { var o = opts || {}; if (_aurora) { _aurora.group.visible = true; return _aurora.group; } var cols = Array.isArray(o.colors) && o.colors.length ? o.colors : ['#59F0A8', '#4AC8F0', '#B07CF0']; // 커튼 결 텍스처 — 세로 그라디언트(하단·상단 페이드). 단색 밴드는 "산맥 실루엣"으로 오독(실측). var acv = document.createElement('canvas'); acv.width = 256; acv.height = 128; var ag = acv.getContext('2d'); var agr = ag.createLinearGradient(0, 128, 0, 0); agr.addColorStop(0, 'rgba(255,255,255,0)'); agr.addColorStop(0.25, 'rgba(255,255,255,0.9)'); agr.addColorStop(0.6, 'rgba(255,255,255,0.45)'); agr.addColorStop(1, 'rgba(255,255,255,0)'); ag.fillStyle = agr; ag.fillRect(0, 0, 256, 128); var ahz = ag.createLinearGradient(0, 0, 256, 0); // 가로 양끝 페이드 — 리본 절단 경계가 세로 벽으로 보이던 실측 정정 ahz.addColorStop(0, 'rgba(255,255,255,0)'); ahz.addColorStop(0.2, 'rgba(255,255,255,1)'); ahz.addColorStop(0.8, 'rgba(255,255,255,1)'); ahz.addColorStop(1, 'rgba(255,255,255,0)'); ag.globalCompositeOperation = 'destination-in'; ag.fillStyle = ahz; ag.fillRect(0, 0, 256, 128); ag.globalCompositeOperation = 'source-over'; var atex = new THREE_.CanvasTexture(acv); var group = new THREE_.Group(); var ribbons = []; for (var ri = 0; ri < Math.min(cols.length, 3); ri++) { var geo = new THREE_.PlaneGeometry(900, 150 + ri * 40, 72, 1); var mat = new THREE_.MeshBasicMaterial({ color: new THREE_.Color(cols[ri]), map: atex, transparent: true, opacity: 0.0, blending: THREE_.AdditiveBlending, depthWrite: false, side: THREE_.DoubleSide, fog: false, }); var m = new THREE_.Mesh(geo, mat); // 배치는 첫 틱(카메라 리그 정착 후 전방 — 달과 동일 함정 회피) m.frustumCulled = false; group.add(m); ribbons.push({ m: m, base: geo.attributes.position.array.slice(), ph: ri * 2.1, az_off: (ri - 1) * 0.38, target: clamp(num(o.intensity, 0.5), 0.05, 1) * (0.6 - ri * 0.1) }); // 방위 분산 — 동방위 3색 겹침은 가산 백화(실측) } scene.add(group); _aurora = { group: group, ribbons: ribbons, placed: false }; return group; }; // ── 무지개 — 반원 그라디언트 스프라이트(비/폭풍 그침 자동 + 명시 호출). 수명 후 자연 소멸. ── var _rainbow = null; nature.rainbow = function (opts) { var o = opts || {}; if (_rainbow) { _rainbow.t = 0; return _rainbow.mesh; } var cv = document.createElement('canvas'); cv.width = 512; cv.height = 256; var g2 = cv.getContext('2d'); var bands = ['#E5484D', '#F5A524', '#F5D90A', '#46A758', '#3E8DE3', '#6E56CF']; for (var bi = 0; bi < bands.length; bi++) { g2.strokeStyle = bands[bi]; g2.globalAlpha = 0.55; g2.lineWidth = 7; g2.beginPath(); g2.arc(256, 256, 225 - bi * 8, Math.PI, 0); g2.stroke(); } var tex = new THREE_.CanvasTexture(cv); var mesh = new THREE_.Mesh(new THREE_.PlaneGeometry(420, 210), new THREE_.MeshBasicMaterial({ map: tex, transparent: true, opacity: 0, depthWrite: false, fog: false, side: THREE_.DoubleSide })); var cam0 = P.three.camera; var d0 = cam0.getWorldDirection(new THREE_.Vector3()); mesh.position.set(cam0.position.x + d0.x * 380, 95, cam0.position.z + d0.z * 380); // 현재 시선 방향 하늘 mesh.lookAt(cam0.position.x, 40, cam0.position.z); mesh.frustumCulled = false; scene.add(mesh); _rainbow = { mesh: mesh, t: 0, life: clamp(num(o.duration, 18), 5, 60) }; return mesh; }; // ── 강 — 경로 리본 수면(UV 흐름 스크롤 + 지형 스냅). 정적 수면(make.water)의 "흐름" 보완. ── var _rivers = []; // ── 바닥 클러터(ground_cover) — "빽빽한 실감"의 정본: 자갈·들꽃(3색)·버섯·덤불·잔가지를 // world.scatter(청크 스트리밍 InstancedMesh)로 고밀도 산포. 잔디(grass_field)가 잎의 층이라면 // 이것은 사물의 층 — 지면 디테일 밀도가 로우폴리 씬의 실감을 결정한다(황량한 평원 실보고). // 프리미티브+팔레트 유도 색(무에셋 — 플랫폼 미학·즉시 가용). world 패밀리 필요(부재 = LOUD 무동작). function _merge_geos(list) { // 소형 프리미티브 머지(비인덱스 concat — BufferGeometryUtils 비의존) var pos = [], nor = []; for (var i = 0; i < list.length; i++) { var g = list[i].geo.toNonIndexed(); var m = new THREE_.Matrix4().compose( new THREE_.Vector3(list[i].p[0], list[i].p[1], list[i].p[2]), new THREE_.Quaternion().setFromEuler(new THREE_.Euler(list[i].r ? list[i].r[0] : 0, list[i].r ? list[i].r[1] : 0, list[i].r ? list[i].r[2] : 0)), new THREE_.Vector3(list[i].s || 1, list[i].s || 1, list[i].s || 1) ); g.applyMatrix4(m); var pa = g.getAttribute('position').array, na = g.getAttribute('normal').array; for (var j = 0; j < pa.length; j++) { pos.push(pa[j]); nor.push(na[j]); } g.dispose(); } var out = new THREE_.BufferGeometry(); out.setAttribute('position', new THREE_.Float32BufferAttribute(pos, 3)); out.setAttribute('normal', new THREE_.Float32BufferAttribute(nor, 3)); return out; } nature.ground_cover = function (opts) { var o = opts || {}; _floor.cover = true; // 자동 바닥선 계약(0 = opt-out) if (o.density === 0) return null; var world = P.world; if (!world || !world.scatter) { try { console.warn('[nature] ground_cover: world 패밀리 필요(scatter 미존재)'); } catch (e) {} return null; } var d = clamp(num(o.density, 1), 0.2, 2.5); var gr = (P._pal && P._pal.ground_ramp) || ['#2B4A2E', '#3E6B33', '#5B8C3E', '#8FBF5A']; var accent = (P._pal && P._pal.accent) || '#F2A65A'; function mesh(geo, color, flat) { return new THREE_.Mesh(geo, new THREE_.MeshStandardMaterial({ color: new THREE_.Color(color), roughness: 0.9, metalness: 0, flatShading: flat !== false })); } var made = []; // 자갈 — 낮게 깔린 회석(밀도 최고 — 지면 "노이즈"의 기저) var pebble = mesh(new THREE_.DodecahedronGeometry(0.14, 0), new THREE_.Color(gr[0]).lerp(new THREE_.Color('#8a9099'), 0.55)); made.push(world.scatter(pebble, { per_chunk: Math.round(20 * d), scale: [0.5, 1.7] })); // 들꽃 3색 — 줄기+머리 머지, 색만 다른 3풀(scatter 는 재질색 단일 — 색 다양성 = 풀 분리) var fgeo = _merge_geos([ { geo: new THREE_.CylinderGeometry(0.012, 0.018, 0.3, 4), p: [0, 0.15, 0] }, { geo: new THREE_.IcosahedronGeometry(0.06, 0), p: [0, 0.33, 0] }, ]); [accent, '#F5F0FF', '#FFD3E0'].forEach(function (fc, fi) { var fl = mesh(fgeo, fc); made.push(world.scatter(fl, { per_chunk: Math.round((8 - fi * 2) * d), scale: [0.7, 1.5] })); }); // 버섯 — 기둥+갓 var mgeo = _merge_geos([ { geo: new THREE_.CylinderGeometry(0.035, 0.05, 0.16, 6), p: [0, 0.08, 0] }, { geo: new THREE_.SphereGeometry(0.11, 8, 5, 0, Math.PI * 2, 0, Math.PI * 0.55), p: [0, 0.15, 0] }, ]); made.push(world.scatter(mesh(mgeo, '#C4826A'), { per_chunk: Math.round(4 * d), scale: [0.6, 1.6] })); // 덤불 — 뾰족 콘 3발(고사리/떨기) var bgeo = _merge_geos([ { geo: new THREE_.ConeGeometry(0.16, 0.5, 5), p: [0, 0.25, 0], r: [0.25, 0, 0] }, { geo: new THREE_.ConeGeometry(0.13, 0.42, 5), p: [0.14, 0.2, 0.06], r: [-0.1, 0, 0.35] }, { geo: new THREE_.ConeGeometry(0.13, 0.4, 5), p: [-0.12, 0.19, -0.08], r: [0.15, 0, -0.3] }, ]); made.push(world.scatter(mesh(bgeo, gr[2]), { per_chunk: Math.round(7 * d), scale: [0.7, 1.6] })); // 잔가지 — 눕힌 가는 가지(유기물 바닥의 신호) var tgeo = _merge_geos([{ geo: new THREE_.CylinderGeometry(0.03, 0.045, 0.9, 5), p: [0, 0.04, 0], r: [0, 0, Math.PI / 2] }]); made.push(world.scatter(mesh(tgeo, '#5b4230'), { per_chunk: Math.round(3 * d), scale: [0.6, 1.4] })); return made; }; nature.river = function (opts) { var o = opts || {}; var path = Array.isArray(o.path) ? o.path.filter(function (pt) { return Array.isArray(pt) && pt.length >= 2; }) : []; if (path.length < 2) { try { console.warn('[nature] river: path 는 [[x,z],...] 2점 이상이 필요'); } catch (e) {} return null; } var width = clamp(num(o.width, 6), 1.5, 30); // Catmull-Rom 보간 — 굽이치는 강(제어점 사이 부드러운 곡률) var pts = []; for (var i = 0; i < path.length - 1; i++) { var p0 = path[Math.max(0, i - 1)], p1 = path[i], p2 = path[i + 1], p3 = path[Math.min(path.length - 1, i + 2)]; for (var t = 0; t < 8; t++) { var u = t / 8, u2 = u * u, u3 = u2 * u; pts.push([ 0.5 * ((2 * p1[0]) + (-p0[0] + p2[0]) * u + (2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * u2 + (-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * u3), 0.5 * ((2 * p1[1]) + (-p0[1] + p2[1]) * u + (2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * u2 + (-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * u3), ]); } } pts.push(path[path.length - 1].slice(0, 2)); var n = pts.length; // ── 스폰존 우회 보정(구조 흡수) — 경로가 시작 지점(원점) 반경을 관통하면 그 구간을 반경 // 밖으로 밀어내고 재평활. LLM 구도 실수 흡수: 강이 (4,2)를 지나 시작 프레임 절반이 맹물 // + 수중 스폰이 된 실사고(2026-07-12). 지형 spawn-flat(18m 시작 프레이밍 보장)과 동일 사상 — // 시작 지점의 가시성/보행성은 셸이 소유한다. 밀어낸 뒤 이동평균(5)으로 급절곡 봉합. ── var _keep_r = 13 + width / 2; var _pushed = false; for (var sp2 = 0; sp2 < n; sp2++) { var _sd = Math.sqrt(pts[sp2][0] * pts[sp2][0] + pts[sp2][1] * pts[sp2][1]); if (_sd < _keep_r) { _pushed = true; var _sf = _keep_r / Math.max(_sd, 0.001); pts[sp2][0] *= _sf; pts[sp2][1] *= _sf; } } if (_pushed) { try { console.warn('[nature] river path crossed the spawn zone — auto-detoured around the start point (plan water paths >=15m from origin)'); } catch (e) {} var _smx = new Float32Array(n), _smz = new Float32Array(n); for (var _sm = 0; _sm < n; _sm++) { var _acc0 = 0, _acc1 = 0, _cnt = 0; for (var _sw = -2; _sw <= 2; _sw++) { var _si = _sm + _sw; if (_si >= 0 && _si < n) { _acc0 += pts[_si][0]; _acc1 += pts[_si][1]; _cnt++; } } _smx[_sm] = _acc0 / _cnt; _smz[_sm] = _acc1 / _cnt; } for (var _sm2 = 1; _sm2 < n - 1; _sm2++) { pts[_sm2][0] = _smx[_sm2]; pts[_sm2][1] = _smz[_sm2]; } for (var _sp3 = 0; _sp3 < n; _sp3++) { // 평활이 반경 안으로 되당긴 점 재클램프(최종 불변식: 전 점 ≥ keep) var _sd3 = Math.sqrt(pts[_sp3][0] * pts[_sp3][0] + pts[_sp3][1] * pts[_sp3][1]); if (_sd3 < _keep_r) { var _sf3 = _keep_r / Math.max(_sd3, 0.001); pts[_sp3][0] *= _sf3; pts[_sp3][1] *= _sf3; } } } var hy = (P.world && P.world.height_at) ? P.world.height_at : function () { return 0; }; // 물높이 = 지형 스냅 → 이동평균(7) 스무딩만 — 하류 단조 클램프는 임의 방향 경로(오르막 시작)에서 // 강 전체를 시작 저지대 아래로 매몰시키는 역효과(실측 정정). 완만 지형 배치가 사용 계약. var ys = new Float32Array(n); for (var yk = 0; yk < n; yk++) ys[yk] = hy(pts[yk][0], pts[yk][1]); var ys2 = new Float32Array(n); for (var ym = 0; ym < n; ym++) { var acc = 0, cnt = 0; for (var yw = -3; yw <= 3; yw++) { var yi = ym + yw; if (yi >= 0 && yi < n) { acc += ys[yi]; cnt++; } } ys2[ym] = acc / cnt; } // 접선/법선(수평) 사전 계산 — 레이어 리본들이 공유 var nrm = new Float32Array(n * 2); for (var kk = 0; kk < n; kk++) { var nxt0 = pts[Math.min(n - 1, kk + 1)], prv0 = pts[Math.max(0, kk - 1)]; var tx0 = nxt0[0] - prv0[0], tz0 = nxt0[1] - prv0[1]; var tl0 = Math.sqrt(tx0 * tx0 + tz0 * tz0) || 1; nrm[kk * 2] = -tz0 / tl0; nrm[kk * 2 + 1] = tx0 / tl0; } // 리본 빌더 — [c0..c1](−1..1 폭 비율 구간) 스트립. 포말 엣지(좁은 구간 리본)도 동일 경로 재사용. function _ribbon(c0, c1, y_off, v_scale) { var pa = new Float32Array(n * 2 * 3), ua = new Float32Array(n * 2 * 2), ix = []; for (var k = 0; k < n; k++) { var cx = pts[k][0], cz = pts[k][1], nx = nrm[k * 2], nz = nrm[k * 2 + 1]; var y = ys2[k] + y_off; pa[k * 6] = cx + nx * width * c0 / 2; pa[k * 6 + 1] = y; pa[k * 6 + 2] = cz + nz * width * c0 / 2; pa[k * 6 + 3] = cx + nx * width * c1 / 2; pa[k * 6 + 4] = y; pa[k * 6 + 5] = cz + nz * width * c1 / 2; ua[k * 4] = 0; ua[k * 4 + 1] = k * v_scale; ua[k * 4 + 2] = 1; ua[k * 4 + 3] = k * v_scale; if (k < n - 1) ix.push(k * 2, k * 2 + 1, k * 2 + 2, k * 2 + 1, k * 2 + 3, k * 2 + 2); } var g = new THREE_.BufferGeometry(); g.setAttribute('position', new THREE_.BufferAttribute(pa, 3)); g.setAttribute('uv', new THREE_.BufferAttribute(ua, 2)); g.setIndex(ix); g.computeVertexNormals(); return g; } function _canvas_tex(draw) { var cv = document.createElement('canvas'); cv.width = 64; cv.height = 64; draw(cv.getContext('2d')); var tx = new THREE_.CanvasTexture(cv); tx.wrapS = tx.wrapT = THREE_.RepeatWrapping; return tx; } var group = new THREE_.Group(); var water_col = new THREE_.Color(o.color || '#3D7FB8'); // ── L0 젖은 강변(수면보다 넓고 어두운 기저) — 물↔지면 칼 경계("파란 리본" 지각)의 봉합 ── var bank = new THREE_.Mesh(_ribbon(-1.3, 1.3, 0.12, 0.14), new THREE_.MeshStandardMaterial({ color: water_col.clone().multiplyScalar(0.42), transparent: true, opacity: 0.55, roughness: 0.8, side: THREE_.DoubleSide, depthWrite: false, })); group.add(bank); // ── L1 물 본체 — 흐름 줄무늬(스크롤) + 버텍스 리플(틱 — 윤슬의 미세 요동) ── var flow_tex = _canvas_tex(function (g2) { g2.fillStyle = '#FFFFFF'; g2.fillRect(0, 0, 64, 64); g2.fillStyle = 'rgba(228,244,255,0.9)'; for (var st = 0; st < 5; st++) g2.fillRect(5 + st * 13, 6 + (st % 2) * 22, 2.5, 26); }); var body_geo = _ribbon(-1, 1, 0.2, 0.14); var body = new THREE_.Mesh(body_geo, new THREE_.MeshStandardMaterial({ color: water_col, transparent: true, opacity: 0.78, roughness: 0.6, metalness: 0, map: flow_tex, side: THREE_.DoubleSide, // 0.25 는 태양 반사각+grazing Fresnel 에서 블룸 백색 폭주(도로 워시아웃 실사고 동일 클래스) — 광택 감각은 glint 레이어 소유 })); group.add(body); // ── L2 광택 겹 — 가산·다른 스케일/속도의 하이라이트가 본체 위를 미끄러져 "물결 파랄락스" ── var gl_tex = _canvas_tex(function (g2) { g2.clearRect(0, 0, 64, 64); g2.fillStyle = 'rgba(255,255,255,0.3)'; for (var st = 0; st < 7; st++) g2.fillRect((st * 19) % 60, (st * 27) % 52, 1.6, 10 + (st % 3) * 8); }); var glint = new THREE_.Mesh(_ribbon(-0.92, 0.92, 0.26, 0.31), new THREE_.MeshBasicMaterial({ map: gl_tex, transparent: true, opacity: 0.22, blending: THREE_.AdditiveBlending, depthWrite: false, side: THREE_.DoubleSide, // 가산×블룸(임계 1.05) 폭주 실측 — 저강도 유지 })); group.add(glint); // ── L3 포말 엣지 — 강기슭 흰 거품 대시(양안), 본체보다 느린 스크롤 ── var foam_tex = _canvas_tex(function (g2) { g2.clearRect(0, 0, 64, 64); g2.fillStyle = 'rgba(255,255,255,0.85)'; for (var st = 0; st < 6; st++) g2.fillRect(20 + (st % 2) * 10, st * 11, 14, 4.5); }); var foam_mat = new THREE_.MeshBasicMaterial({ map: foam_tex, transparent: true, opacity: 0.55, depthWrite: false, side: THREE_.DoubleSide }); var foamL = new THREE_.Mesh(_ribbon(-1.04, -0.82, 0.24, 0.2), foam_mat); var foamR = new THREE_.Mesh(_ribbon(0.82, 1.04, 0.24, 0.2), foam_mat); group.add(foamL); group.add(foamR); // ── 강돌 + V-웨이크 — 물길에 박힌 바위(rocks:false 로 해제). 흐름을 "가르는" 지물이 유속 // 지각의 앵커이고, 바위 뒤 하류 V-웨이크(반각 ~20°)가 "물이 사물과 상호작용한다"는 최강 // 신호(2026-07-12 리서치 — 강 지각 승리 1순위). 웨이크는 전 바위 병합 1메시(포말 텍스처 // 공유 = 스크롤 동조 자동), 바위 밑 흰 링은 맥동(반경 ±10%). ── var rock_rings = []; if (o.rocks !== false) { var rmat = new THREE_.MeshStandardMaterial({ color: (P._pal && P._pal.ground_ramp) ? new THREE_.Color(P._pal.ground_ramp[0]).lerp(new THREE_.Color('#9aa3ad'), 0.6) : new THREE_.Color('#7d858e'), roughness: 0.95, flatShading: true }); var rgeo = new THREE_.DodecahedronGeometry(1, 0); var rocks = Math.min(24, Math.max(6, Math.round(n / 14))); var wk_pos = [], wk_uv = [], wk_ix = []; var rgeo3 = null; if (P._ring_tex) { rgeo3 = new THREE_.PlaneGeometry(1, 1); rgeo3.rotateX(-Math.PI / 2); } for (var rr = 0; rr < rocks; rr++) { var rk = 4 + Math.floor(Math.random() * (n - 8)); var roff = (Math.random() * 1.4 - 0.7); var rx = pts[rk][0] + nrm[rk * 2] * width * roff / 2, rz = pts[rk][1] + nrm[rk * 2 + 1] * width * roff / 2; var rm = new THREE_.Mesh(rgeo, rmat); var rs = 0.25 + Math.random() * 0.55; rm.scale.set(rs * (0.8 + Math.random() * 0.5), rs * (0.55 + Math.random() * 0.3), rs * (0.8 + Math.random() * 0.5)); rm.position.set(rx, ys2[rk] + 0.05, rz); rm.rotation.set(Math.random() * 0.5, Math.random() * Math.PI, Math.random() * 0.5); group.add(rm); // V-웨이크 2날개 — 하류(경로 순방향) 로 열림. tangent = (nrm.z, -nrm.x) var wtx = nrm[rk * 2 + 1], wtz = -nrm[rk * 2]; var wlen = rs * (2.6 + Math.random() * 1.2), wy2 = ys2[rk] + 0.24; for (var wgi = -1; wgi <= 1; wgi += 2) { // 각 날개: 시작(바위 뒤) → 끝(하류 + 옆으로 벌어짐 tan20° ≈ 0.36) var ex = rx + wtx * wlen + nrm[rk * 2] * wgi * wlen * 0.36; var ez = rz + wtz * wlen + nrm[rk * 2 + 1] * wgi * wlen * 0.36; var px2 = -wtz * 0.09, pz2 = wtx * 0.09; // 날개 두께 방향 var vi = wk_pos.length / 3; wk_pos.push(rx - px2, wy2, rz - pz2, rx + px2, wy2, rz + pz2, ex - px2, wy2, ez - pz2, ex + px2, wy2, ez + pz2); wk_uv.push(0, 0, 1, 0, 0, wlen * 0.5, 1, wlen * 0.5); wk_ix.push(vi, vi + 1, vi + 2, vi + 1, vi + 3, vi + 2); } if (rgeo3 && rock_rings.length < 10) { // 바위 밑둥 흰 링(맥동) — 상위 10개만(드로콜 절제) var rring = new THREE_.Mesh(rgeo3, new THREE_.MeshBasicMaterial({ map: P._ring_tex(), transparent: true, opacity: 0.5, depthWrite: false })); rring.position.set(rx, ys2[rk] + 0.26, rz); rring.scale.setScalar(rs * 2.4); group.add(rring); rock_rings.push({ m: rring, base: rs * 2.4, ph: Math.random() * 6.28 }); } } if (wk_pos.length) { var wk_geo = new THREE_.BufferGeometry(); wk_geo.setAttribute('position', new THREE_.BufferAttribute(new Float32Array(wk_pos), 3)); wk_geo.setAttribute('uv', new THREE_.BufferAttribute(new Float32Array(wk_uv), 2)); wk_geo.setIndex(wk_ix); var wk_mesh = new THREE_.Mesh(wk_geo, new THREE_.MeshBasicMaterial({ map: foam_tex, transparent: true, opacity: 0.5, depthWrite: false, side: THREE_.DoubleSide, })); group.add(wk_mesh); // foam_tex 공유 = 웨이크 대시가 포말 스크롤과 같은 유속으로 흐름(일관성 공짜) } } // ── 잎 표류 — 흐름 방향·속도의 최고 증거물(비용 ≈0 다크호스: 리서치 인터랙션 2순위) ── var leaves = null; if (o.leaves !== false) { var lgeo = new THREE_.PlaneGeometry(0.34, 0.26); lgeo.rotateX(-Math.PI / 2); var lcol = (P._pal && P._pal.ground_ramp) ? new THREE_.Color(P._pal.ground_ramp[1]) : new THREE_.Color('#4A6B33'); var lim = new THREE_.InstancedMesh(lgeo, new THREE_.MeshBasicMaterial({ color: lcol, transparent: true, opacity: 0.85, side: THREE_.DoubleSide }), 6); lim.frustumCulled = false; group.add(lim); var llist = []; for (var lf = 0; lf < 6; lf++) llist.push({ u: Math.random() * (n - 1), lane: (Math.random() * 1.2 - 0.6), rot: Math.random() * 6.28 }); leaves = { im: lim, list: llist, m4: new THREE_.Matrix4(), q: new THREE_.Quaternion(), s: new THREE_.Vector3(1, 1, 1), v: new THREE_.Vector3(), ax: new THREE_.Vector3(0, 1, 0) }; } scene.add(group); var R = { mesh: body, group: group, speed: clamp(num(o.speed, 0.6), 0.05, 4), layers: [ { tex: flow_tex, mul: 1 }, { tex: gl_tex, mul: 1.7 }, // 광택은 본체보다 빠르게 — 표층 유속 파랄락스 { tex: foam_tex, mul: 0.55 }, // 포말은 기슭 마찰로 느리게 ], ripple: { geo: body_geo, base: body_geo.getAttribute('position').array.slice(0), t: 0 }, rock_rings: rock_rings, leaves: leaves, pts: pts, nrm: nrm, ys2: ys2, n: n, width: width, }; _rivers.push(R); return body; }; // ── 폭포 v2 — 업계 정본 해부학(2026-07-12 리서치: RiME/BotW/Taiji 교집합) ── // 낙수 커튼 = 2속(二速) 스트릭 그룹(단일 속도 = 즉시 싸 보이는 실패 모드) + 하단 미스트 // + 착수 splash 링 3개 위상 스태거(확장·페이드 — "낙하 에너지가 물에 전달된다"는 최대 wow 지점) // + churn(작은 흰 점 도약) + 포말 풀 패치(반경 맥동 디스크). 전부 nature 루프 구동, 풀링 고정. var _waterfalls = []; nature.waterfall = function (opts) { var o = opts || {}; var at = o.at || {}; var x = num(at.x, 0), z = num(at.z, 0); var hy2 = (P.world && P.world.height_at) ? P.world.height_at(x, z) : 0; var top = num(o.top_y, hy2 + 18), bottom = num(o.bottom_y, hy2); var width = clamp(num(o.width, 5), 1, 24); // 스트릭 2그룹 — A: 밝고 느린 포말 겹 / B: 희미하고 1.8× 빠른 물 겹(Taiji 2속 구조) var n = 66, nb = 34; var seg = new Float32Array(n * 6), sp = new Float32Array(n * 2); var segB = new Float32Array(nb * 6), spB = new Float32Array(nb * 2); for (var i = 0; i < n; i++) { sp[i * 2] = Math.random(); sp[i * 2 + 1] = 0.5 + Math.random() * 0.45; } for (var ib = 0; ib < nb; ib++) { spB[ib * 2] = Math.random(); spB[ib * 2 + 1] = (0.5 + Math.random() * 0.45) * 1.8; } var geo2 = new THREE_.BufferGeometry(); geo2.setAttribute('position', new THREE_.BufferAttribute(seg, 3)); var lines = new THREE_.LineSegments(geo2, new THREE_.LineBasicMaterial({ color: 0xE8F6FC, transparent: true, opacity: 0.6, depthWrite: false })); lines.frustumCulled = false; scene.add(lines); var geoB = new THREE_.BufferGeometry(); geoB.setAttribute('position', new THREE_.BufferAttribute(segB, 3)); var linesB = new THREE_.LineSegments(geoB, new THREE_.LineBasicMaterial({ color: 0xBFE0F0, transparent: true, opacity: 0.3, depthWrite: false })); linesB.frustumCulled = false; scene.add(linesB); // 상단 립 — 물이 넘어가는 밝은 볼록 밴드(BotW/Taiji 립 판독) var lip = new THREE_.Mesh(new THREE_.BoxGeometry(width, 0.24, 0.5), new THREE_.MeshBasicMaterial({ color: 0xDFF2FA, transparent: true, opacity: 0.5, depthWrite: false })); lip.position.set(x, top + 0.05, z); scene.add(lip); // 미스트 — 하단 확산 점 var mn = 26, mpos = new Float32Array(mn * 3); for (var mi = 0; mi < mn; mi++) { mpos[mi * 3] = x + (Math.random() - 0.5) * width; mpos[mi * 3 + 1] = bottom + Math.random() * 1.2; mpos[mi * 3 + 2] = z + (Math.random() - 0.5) * 2; } var mgeo = new THREE_.BufferGeometry(); mgeo.setAttribute('position', new THREE_.BufferAttribute(mpos, 3)); var mist = new THREE_.Points(mgeo, new THREE_.PointsMaterial({ color: 0xEAF6FF, size: 0.5, transparent: true, opacity: 0.4, depthWrite: false })); mist.frustumCulled = false; scene.add(mist); // 착수 splash 링 3개(위상 120° 스태거) + 포말 풀 패치 — 링 텍스처는 three 공유 브리지 var rings = []; if (P._ring_tex) { var rgeo2 = new THREE_.PlaneGeometry(1, 1); rgeo2.rotateX(-Math.PI / 2); for (var rn2 = 0; rn2 < 3; rn2++) { var rmesh = new THREE_.Mesh(rgeo2, new THREE_.MeshBasicMaterial({ map: P._ring_tex(), transparent: true, opacity: 0, depthWrite: false })); rmesh.name = 'wf_ring'; rmesh.position.set(x + (Math.random() - 0.5) * width * 0.4, bottom + 0.08, z + (Math.random() - 0.5) * 1.2); scene.add(rmesh); rings.push({ m: rmesh, ph: rn2 / 3 }); } } var pool = new THREE_.Mesh(new THREE_.CircleGeometry(width * 0.55, 20), new THREE_.MeshBasicMaterial({ color: 0xE6F4F0, transparent: true, opacity: 0.34, depthWrite: false })); pool.rotation.x = -Math.PI / 2; pool.position.set(x, bottom + 0.05, z); scene.add(pool); // churn — 착수점 도약 흰 점(작은 원이 위로 튀어오름 — Taiji/BotW 2종 세트의 소형 쪽) var cn = 12, cpos = new Float32Array(cn * 3), cst = new Float32Array(cn * 2); // [진행, 속도] for (var ci = 0; ci < cn; ci++) { cst[ci * 2] = Math.random(); cst[ci * 2 + 1] = 0.8 + Math.random() * 0.9; } var cgeo = new THREE_.BufferGeometry(); cgeo.setAttribute('position', new THREE_.BufferAttribute(cpos, 3)); var churn = new THREE_.Points(cgeo, new THREE_.PointsMaterial({ color: 0xF4FBFF, size: 0.3, transparent: true, opacity: 0.8, depthWrite: false })); churn.frustumCulled = false; scene.add(churn); _waterfalls.push({ lines: lines, seg: seg, sp: sp, n: n, linesB: linesB, segB: segB, spB: spB, nb: nb, x: x, z: z, top: top, bottom: bottom, width: width, mist: mist, mpos: mpos, mn: mn, rings: rings, pool: pool, churn: churn, cpos: cpos, cst: cst, cn: cn, t: Math.random() * 3, }); return lines; }; // ═══ 수생 생태(nature.aquatic) — 강·바다의 수생 식물/동물/드레싱 비오메 키트 ═══ // (2026-07-12 수생 에셋 가이드 증류 — §14 비오메 정본을 5키트로: pond/river/coast/reef/kelp. // 전부 절차 생성 + 인스턴싱 + 무드 팔레트 파생 — 카탈로그 다운로드/라이선스 리스크 0.) // 배치 = 해석 지형 수심 밴드 분류(정식): emergent(물가 ±0.4m)·floating(0.2~1.6m)· // submerged(0.6~4m)·beach(물 위 0.05~0.9m)·deep(2.5m+). 지형 부재 시 방사 링 폴백. // 파우나 = 어군(회유)·해파리(맥동, 다크 무드 발광)·개구리(패드 도약)·잠자리·게(스커틀)· // 물새(활공)·물고기 점프(링+효과음 — "세계가 나 없이도 산다" 신호). 전부 nature 루프 구동. var _aquatics = []; nature.aquatic = function (opts) { var o = opts || {}; _floor.aquatic = true; // 자동 바닥선 계약(0 = opt-out) if (o.density === 0) return null; var wy = (P._water_y != null) ? P._water_y : null; var has_riv = _rivers.length > 0; if (wy == null && !has_riv) { try { console.warn('[nature] aquatic: no water surface — call make.water or nature.river first'); } catch (e) {} return null; } var has_t = !!(P.world && P.world._priv && P.world._priv.has_terrain && P.world._priv.has_terrain()); var hy3 = (P.world && P.world.height_at) ? P.world.height_at : function () { return 0; }; var biome = o.biome; if (!biome || biome === 'auto') biome = (wy != null) ? (has_t ? 'coast' : 'pond') : 'river'; if (biome !== 'pond' && biome !== 'river' && biome !== 'coast' && biome !== 'reef' && biome !== 'kelp') { try { console.error('[nature] aquatic: unknown biome "' + biome + '" — enum: pond/river/coast/reef/kelp (auto)'); } catch (e) {} biome = 'coast'; } if (biome === 'river' && !has_riv) biome = (wy != null) ? 'pond' : biome; var at = P._nv2(o.at, 0, 0); var area = clamp(num(o.area, 90), 20, 300); var den = clamp(num(o.density, 1), 0.2, 2.5); var gr = (P._pal && P._pal.ground_ramp) || ['#2B4A2E', '#3E6B33', '#5B8C3E', '#8FBF5A']; var accent = new THREE_.Color((P._pal && P._pal.accent) || '#FFE58A'); var dark = !!(P._pal && (P._pal.name === 'night_neon' || P._pal.name === 'ember' || P._pal.name === 'dusk')); var group = new THREE_.Group(); var A = { group: group, biome: biome, fish: [], jelly: [], frogs: [], dfly: [], crabs: [], birds: [], pads: [], rings: [], jumpf: null, jt: 4 + Math.random() * 8, t: Math.random() * 5 }; // ── 수심 밴드 샘플러 — has_t: 실지형 분류 / 부재: 방사 링 폴백(중심=floating, 가장자리=emergent) ── function band_pts(kind, count, tries) { var out = []; for (var s2 = 0; s2 < (tries || count * 14) && out.length < count; s2++) { var sx3 = at.x + (Math.random() - 0.5) * area, sz3 = at.z + (Math.random() - 0.5) * area; if (has_t && wy != null) { var d3 = wy - hy3(sx3, sz3); // 수심(+ = 물속) if (kind === 'emergent' && (d3 < -0.35 || d3 > 0.45)) continue; if (kind === 'floating' && (d3 < 0.2 || d3 > 1.6)) continue; if (kind === 'submerged' && (d3 < 0.6 || d3 > 4)) continue; if (kind === 'beach' && (d3 > -0.05 || d3 < -0.9)) continue; if (kind === 'deep' && d3 < 2.2) continue; out.push([sx3, sz3, has_t ? hy3(sx3, sz3) : 0]); } else { var rr3 = Math.sqrt(Math.pow(sx3 - at.x, 2) + Math.pow(sz3 - at.z, 2)) / (area / 2); if (kind === 'emergent' && (rr3 < 0.78 || rr3 > 1)) continue; if (kind === 'floating' && rr3 > 0.6) continue; if ((kind === 'submerged' || kind === 'deep') && rr3 > 0.75) continue; if (kind === 'beach') continue; // 지형 없으면 해변 없음 out.push([sx3, sz3, (wy != null ? wy : 0) - 1.2]); } } return out; } function inst(geo, color, list, opts2) { // 배치 목록 → InstancedMesh 1콜 if (!list.length) return null; var o2 = opts2 || {}; var m5 = new THREE_.MeshStandardMaterial({ color: color, roughness: 0.9, metalness: 0, flatShading: true, side: o2.ds ? THREE_.DoubleSide : THREE_.FrontSide, transparent: !!o2.tr, opacity: o2.tr || 1 }); if (o2.wind) _inject(m5, o2.wind, _WIND_DECL, o2.windBody); var im5 = new THREE_.InstancedMesh(geo, m5, list.length); var mm = new THREE_.Matrix4(), qq = new THREE_.Quaternion(), vv = new THREE_.Vector3(), ss = new THREE_.Vector3(), up = new THREE_.Vector3(0, 1, 0); for (var i5 = 0; i5 < list.length; i5++) { var L5 = list[i5]; qq.setFromAxisAngle(up, L5[4] != null ? L5[4] : Math.random() * Math.PI * 2); var sc5 = L5[3] != null ? L5[3] : (0.7 + Math.random() * 0.6); mm.compose(vv.set(L5[0], L5[2], L5[1]), qq, ss.set(sc5, sc5 * (o2.syr || 1), sc5)); im5.setMatrixAt(i5, mm); if (o2.vary) im5.setColorAt(i5, new THREE_.Color(color).offsetHSL(0, 0, (Math.random() - 0.5) * 0.14)); } im5.instanceMatrix.needsUpdate = true; if (im5.instanceColor) im5.instanceColor.needsUpdate = true; group.add(im5); return im5; } var _KELP_BODY = 'float kb = transformed.y * transformed.y * 0.028;\ntransformed.x += (sin(uWindTime * 0.55 + position.y * 0.7) * 0.7 + 0.3) * kb * uWindDir.x * (0.5 + uWindStrength);\ntransformed.z += (cos(uWindTime * 0.5 + position.y * 0.6) * 0.7 + 0.3) * kb * uWindDir.y * (0.5 + uWindStrength);'; var _BLADE_BODY = 'float ab = transformed.y * transformed.y * 0.12;\ntransformed.x += sin(uWindTime * 1.1 + position.x * 3.0) * ab * uWindDir.x * uWindStrength;\ntransformed.z += sin(uWindTime * 1.0 + position.z * 3.0) * ab * uWindDir.y * uWindStrength;'; // ── 식생/드레싱 키트(비오메 게이트) ── var n_e = Math.round(26 * den), n_f = Math.round(16 * den), n_s = Math.round(30 * den), n_d = Math.round(18 * den); // 갈대/부들(emergent) — pond/river/coast. river 는 강기슭(경로 ± 폭) 우선 배치 if (biome === 'pond' || biome === 'river' || biome === 'coast') { var epts = []; if (biome === 'river' && has_riv) { var RV = _rivers[_rivers.length - 1]; for (var re2 = 0; re2 < n_e * 2 && epts.length < n_e * 2; re2++) { var rk2 = 2 + Math.floor(Math.random() * (RV.n - 4)); var side2 = Math.random() < 0.5 ? 1 : -1; var off2 = RV.width * (0.62 + Math.random() * 0.35) * side2 / 2 * 2; var ex2 = RV.pts[rk2][0] + RV.nrm[rk2 * 2] * off2, ez2 = RV.pts[rk2][1] + RV.nrm[rk2 * 2 + 1] * off2; epts.push([ex2, ez2, RV.ys2[rk2] + 0.05]); } } else epts = band_pts('emergent', n_e); var blade = new THREE_.PlaneGeometry(0.16, 1.7, 1, 3); blade.translate(0, 0.85, 0); inst(blade, new THREE_.Color(gr[2]), epts, { ds: true, vary: true, wind: 'aq-blade', windBody: _BLADE_BODY, syr: 1 + Math.random() * 0.3 }); var heads = []; for (var hh2 = 0; hh2 < epts.length; hh2 += 2) heads.push([epts[hh2][0] + 0.1, epts[hh2][1], epts[hh2][2] + 1.45, 0.9]); var headg = new THREE_.CylinderGeometry(0.07, 0.09, 0.5, 5); headg.translate(0, 0.25, 0); inst(headg, new THREE_.Color('#6B4A2E'), heads, {}); // 부들 이삭(고동색 시그니처 — 습지 즉시 판독) var iris = band_pts('emergent', Math.round(n_e * 0.2)); var petg = new THREE_.ConeGeometry(0.12, 0.3, 5); petg.translate(0, 1.15, 0); inst(petg, accent.clone().lerp(new THREE_.Color('#9A7BD0'), 0.4), iris, {}); // 붓꽃 액센트 } // 수련 패드 + 연꽃(floating) — pond/river if ((biome === 'pond' || biome === 'river') && wy != null) { var fpts = band_pts('floating', n_f); var padg = new THREE_.CircleGeometry(0.55, 9, 0.5, 5.6); padg.rotateX(-Math.PI / 2); var padim = inst(padg, new THREE_.Color(gr[3]).lerp(new THREE_.Color(gr[1]), 0.3), fpts.map(function (p6) { return [p6[0], p6[1], wy + 0.03]; }), { vary: true, ds: true }); A.pads = fpts.map(function (p6) { return [p6[0], wy + 0.05, p6[1]]; }); var lot = []; for (var lo2 = 0; lo2 < fpts.length; lo2 += 3) lot.push([fpts[lo2][0] + 0.2, fpts[lo2][1], wy + 0.08, 0.8]); var lotg = new THREE_.ConeGeometry(0.16, 0.26, 6); lotg.translate(0, 0.13, 0); inst(lotg, accent.clone().lerp(new THREE_.Color('#FF9FBE'), 0.5), lot, {}); // 연꽃 var duck = band_pts('floating', Math.round(n_f * 2.2)); var dg = new THREE_.CircleGeometry(0.08, 5); dg.rotateX(-Math.PI / 2); inst(dg, new THREE_.Color(gr[3]), duck.map(function (p7) { return [p7[0], p7[1], wy + 0.02, 0.8 + Math.random()]; }), {}); // 개구리밥 } // 해초/거머리말(submerged) — coast/reef/pond if (biome === 'coast' || biome === 'reef' || biome === 'pond') { var spts = band_pts('submerged', n_s); var sg = new THREE_.PlaneGeometry(0.12, 1.1, 1, 3); sg.translate(0, 0.55, 0); inst(sg, new THREE_.Color(gr[1]).lerp(new THREE_.Color('#2E7D6B'), 0.5), spts, { ds: true, vary: true, wind: 'aq-sea', windBody: _KELP_BODY, tr: 0.9 }); } // 켈프 숲(deep) — kelp 비오메: 기둥 + 수면 캐노피 부유엽 if (biome === 'kelp' && wy != null) { var kpts = band_pts('deep', Math.round(14 * den)); var kh = 6.5; var kg = new THREE_.PlaneGeometry(0.55, kh, 1, 7); kg.translate(0, kh / 2, 0); inst(kg, new THREE_.Color('#3E6B33').lerp(new THREE_.Color('#7A6B2E'), 0.45), kpts, { ds: true, vary: true, wind: 'aq-kelp', windBody: _KELP_BODY, tr: 0.94 }); var can = kpts.map(function (p8) { return [p8[0] + (Math.random() - 0.5), p8[1] + (Math.random() - 0.5), wy + 0.02, 0.8 + Math.random() * 0.7]; }); var cang = new THREE_.CircleGeometry(0.4, 6); cang.rotateX(-Math.PI / 2); inst(cang, new THREE_.Color('#5B6B2E'), can, { vary: true }); } // 산호초 키트(reef) — 가지(스태그혼)·테이블·브레인·부채·해면 (전부 인스턴스 = 콜 5) if (biome === 'reef') { var rpts = band_pts('submerged', Math.round(22 * den)); var c1 = accent.clone(), c2 = accent.clone().offsetHSL(0.12, 0, 0.05), c3 = accent.clone().offsetHSL(-0.14, 0.05, 0); var brg = new THREE_.ConeGeometry(0.07, 1.0, 5); brg.translate(0, 0.5, 0); brg.rotateZ(0.3); var branches = []; for (var br2 = 0; br2 < rpts.length; br2++) { for (var br3 = 0; br3 < 3; br3++) branches.push([rpts[br2][0] + (Math.random() - 0.5) * 0.5, rpts[br2][1] + (Math.random() - 0.5) * 0.5, rpts[br2][2], 0.7 + Math.random() * 0.8, Math.random() * 6.28]); } inst(brg, c1, branches, { vary: true }); // 스태그혼 가지숲 var tbg = new THREE_.CylinderGeometry(0.75, 0.14, 0.5, 7); inst(tbg, c2, band_pts('submerged', Math.round(7 * den)), { vary: true }); // 테이블 산호 var bng = new THREE_.SphereGeometry(0.42, 7, 5); inst(bng, c3, band_pts('submerged', Math.round(8 * den)), { vary: true, syr: 0.72 }); // 브레인 산호 var fng = new THREE_.PlaneGeometry(0.9, 1.1); inst(fng, c2.clone().lerp(new THREE_.Color('#E86FA0'), 0.3), band_pts('submerged', Math.round(6 * den)).map(function (p9) { return [p9[0], p9[1], p9[2] + 0.55]; }), { ds: true, tr: 0.85, wind: 'aq-fan', windBody: _BLADE_BODY }); // 부채 산호 var spg = new THREE_.CylinderGeometry(0.14, 0.2, 0.9, 6); spg.translate(0, 0.45, 0); inst(spg, new THREE_.Color('#C0B26A').lerp(accent, 0.2), band_pts('submerged', Math.round(6 * den)), { vary: true }); // 관해면 } // 조간대 드레싱(coast/reef/kelp) — 성게·말미잘·불가사리·조가비 if (biome === 'coast' || biome === 'reef' || biome === 'kelp') { var dpts = band_pts(biome === 'coast' ? 'emergent' : 'submerged', n_d); var urg = new THREE_.IcosahedronGeometry(0.16, 0); inst(urg, new THREE_.Color('#2A2233'), dpts.slice(0, Math.ceil(n_d * 0.35)), {}); // 성게 var ang = new THREE_.CylinderGeometry(0.16, 0.12, 0.16, 7); ang.translate(0, 0.08, 0); inst(ang, accent.clone().lerp(new THREE_.Color('#7ADFC8'), 0.5), dpts.slice(0, Math.ceil(n_d * 0.3)).map(function (pA) { return [pA[0] + 0.4, pA[1], pA[2]]; }), { vary: true }); // 말미잘 var star = new THREE_.Shape(); for (var st2 = 0; st2 < 10; st2++) { var ra2 = (st2 % 2 === 0) ? 0.2 : 0.085, aa2 = st2 / 10 * Math.PI * 2; if (st2 === 0) star.moveTo(Math.cos(aa2) * ra2, Math.sin(aa2) * ra2); else star.lineTo(Math.cos(aa2) * ra2, Math.sin(aa2) * ra2); } var stg = new THREE_.ShapeGeometry(star); stg.rotateX(-Math.PI / 2); inst(stg, new THREE_.Color('#E8734A'), dpts.slice(0, Math.ceil(n_d * 0.3)).map(function (pB) { return [pB[0] - 0.3, pB[1] + 0.3, pB[2] + 0.03]; }), { vary: true, ds: true }); // 불가사리 if (biome === 'coast') { var shg = new THREE_.ConeGeometry(0.09, 0.12, 6); inst(shg, new THREE_.Color('#E8DCC8'), band_pts('beach', Math.round(n_d * 0.8)), { vary: true }); // 해변 조가비 } } // ── 파우나 ── var fishg = new THREE_.OctahedronGeometry(0.16, 0); fishg.scale(1.7, 0.55, 0.4); function school(count, color, band) { var pts2 = band_pts(band, 1); var cx2 = pts2.length ? pts2[0][0] : at.x, cz2 = pts2.length ? pts2[0][1] : at.z; var im6 = new THREE_.InstancedMesh(fishg, new THREE_.MeshStandardMaterial({ color: color, roughness: 0.7, metalness: 0, flatShading: true }), count); im6.frustumCulled = false; group.add(im6); var lst = []; for (var f6 = 0; f6 < count; f6++) lst.push({ r: 1.5 + Math.random() * 4.5, a: Math.random() * 6.28, w: 0.35 + Math.random() * 0.5, ph: Math.random() * 6.28, dy: -0.7 - Math.random() * 1.6 }); A.fish.push({ im: im6, list: lst, cx: cx2, cz: cz2, m4: new THREE_.Matrix4(), q: new THREE_.Quaternion(), v: new THREE_.Vector3(), s: new THREE_.Vector3(1, 1, 1), up: new THREE_.Vector3(0, 1, 0) }); } if (wy != null && biome !== 'river') { if (biome === 'pond') school(Math.round(14 * den), new THREE_.Color('#D8863A'), 'floating'); // 잉어/금붕어 톤 if (biome === 'coast' || biome === 'kelp') school(Math.round(22 * den), new THREE_.Color('#9FB6C4'), 'submerged'); if (biome === 'reef') { school(Math.round(18 * den), accent.clone(), 'submerged'); school(Math.round(16 * den), accent.clone().offsetHSL(0.3, 0, 0), 'submerged'); } if (biome === 'kelp') school(Math.round(10 * den), new THREE_.Color('#C46A3A'), 'deep'); } // 해파리 — coast/reef (다크 무드 = 발광 → bloom 픽업) if ((biome === 'coast' || biome === 'reef') && wy != null) { var jg = new THREE_.SphereGeometry(0.3, 8, 5, 0, Math.PI * 2, 0, Math.PI * 0.55); var jmat = new THREE_.MeshStandardMaterial({ color: new THREE_.Color('#B9D8E8'), transparent: true, opacity: 0.55, roughness: 0.4, metalness: 0, emissive: dark ? accent.clone() : new THREE_.Color('#000000'), emissiveIntensity: dark ? 1.6 : 0, side: THREE_.DoubleSide }); var jn = Math.round(5 * den); var jim = new THREE_.InstancedMesh(jg, jmat, jn); jim.frustumCulled = false; group.add(jim); var jpts = band_pts('submerged', jn); for (var j6 = 0; j6 < jn; j6++) A.jelly.push({ x: jpts[j6] ? jpts[j6][0] : at.x, z: jpts[j6] ? jpts[j6][1] : at.z, y: wy - 0.8 - Math.random() * 1.4, ph: Math.random() * 6.28, drift: Math.random() * 6.28 }); A.jim = { im: jim, m4: new THREE_.Matrix4(), q: new THREE_.Quaternion(), v: new THREE_.Vector3(), s: new THREE_.Vector3(1, 1, 1) }; } // 개구리(pond) — 패드 위 착좌 + 간헐 도약 if (biome === 'pond' && A.pads.length > 2) { var frg = new THREE_.OctahedronGeometry(0.13, 0); frg.scale(1.1, 0.75, 1); var fim = new THREE_.InstancedMesh(frg, new THREE_.MeshStandardMaterial({ color: new THREE_.Color(gr[2]).lerp(new THREE_.Color('#3E8C3A'), 0.5), roughness: 0.85, flatShading: true }), 3); fim.frustumCulled = false; group.add(fim); for (var fr2 = 0; fr2 < 3; fr2++) { var pd2 = A.pads[Math.floor(Math.random() * A.pads.length)]; A.frogs.push({ x: pd2[0], y: pd2[1], z: pd2[2], from: null, to: null, jt: 0, wait: 3 + Math.random() * 9 }); } A.fim = { im: fim, m4: new THREE_.Matrix4(), q: new THREE_.Quaternion(), v: new THREE_.Vector3(), s: new THREE_.Vector3(1, 1, 1) }; } // 잠자리(pond/river) · 물새 활공(coast/river) if (biome === 'pond' || biome === 'river') { var dgeo = new THREE_.PlaneGeometry(0.5, 0.1); var dim = new THREE_.InstancedMesh(dgeo, new THREE_.MeshBasicMaterial({ color: new THREE_.Color('#7AD8E8'), transparent: true, opacity: 0.85, side: THREE_.DoubleSide }), 3); dim.frustumCulled = false; group.add(dim); for (var df2 = 0; df2 < 3; df2++) A.dfly.push({ cx: at.x + (Math.random() - 0.5) * area * 0.5, cz: at.z + (Math.random() - 0.5) * area * 0.5, ph: Math.random() * 6.28, r: 2 + Math.random() * 3 }); A.dim = { im: dim, m4: new THREE_.Matrix4(), q: new THREE_.Quaternion(), v: new THREE_.Vector3(), s: new THREE_.Vector3(1, 1, 1), up: new THREE_.Vector3(0, 1, 0) }; } if (biome === 'coast' || biome === 'river' || biome === 'kelp') { var bgeo = new THREE_.PlaneGeometry(1.1, 0.22); var bim = new THREE_.InstancedMesh(bgeo, new THREE_.MeshBasicMaterial({ color: 0xF2F5F7, transparent: true, opacity: 0.9, side: THREE_.DoubleSide }), 2); bim.frustumCulled = false; group.add(bim); for (var bd2 = 0; bd2 < 2; bd2++) A.birds.push({ ph: Math.random() * 6.28, r: 14 + Math.random() * 10, h: (wy != null ? wy : 0) + 7 + Math.random() * 4, w: 0.1 + Math.random() * 0.06 }); A.bim = { im: bim, m4: new THREE_.Matrix4(), q: new THREE_.Quaternion(), v: new THREE_.Vector3(), s: new THREE_.Vector3(1, 1, 1), up: new THREE_.Vector3(0, 1, 0) }; } // 게 스커틀(coast 해변) if (biome === 'coast') { var bpts2 = band_pts('beach', 4); if (bpts2.length) { var cgeo2 = new THREE_.OctahedronGeometry(0.12, 0); cgeo2.scale(1.5, 0.5, 1); var cim2 = new THREE_.InstancedMesh(cgeo2, new THREE_.MeshStandardMaterial({ color: new THREE_.Color('#D85A3A'), roughness: 0.85, flatShading: true }), bpts2.length); cim2.frustumCulled = false; group.add(cim2); for (var cb2 = 0; cb2 < bpts2.length; cb2++) A.crabs.push({ x: bpts2[cb2][0], z: bpts2[cb2][1], y: bpts2[cb2][2] + 0.08, dir: Math.random() * 6.28, ph: Math.random() * 6.28 }); A.cim = { im: cim2, m4: new THREE_.Matrix4(), q: new THREE_.Quaternion(), v: new THREE_.Vector3(), s: new THREE_.Vector3(1, 1, 1), up: new THREE_.Vector3(0, 1, 0) }; } } // 물고기 점프 세트(단일 메시 + 링 2 — coast/pond/reef "세계가 살아있다" 신호) if (wy != null && biome !== 'river' && P._ring_tex) { var jfm = new THREE_.Mesh(fishg, new THREE_.MeshStandardMaterial({ color: 0x5A7A8C, roughness: 0.7, flatShading: true })); jfm.visible = false; group.add(jfm); var jr2 = []; var jrg = new THREE_.PlaneGeometry(1, 1); jrg.rotateX(-Math.PI / 2); for (var jr3 = 0; jr3 < 2; jr3++) { var jrm = new THREE_.Mesh(jrg, new THREE_.MeshBasicMaterial({ map: P._ring_tex(), transparent: true, opacity: 0, depthWrite: false })); group.add(jrm); jr2.push(jrm); } A.jumpf = { m: jfm, rings: jr2, st: -1, x: 0, z: 0 }; } scene.add(group); _aquatics.push(A); return { obj: group, biome: biome, remove: function () { if (group.parent) group.parent.remove(group); group.traverse(function (o9) { if (o9.isInstancedMesh) o9.dispose(); }); var ai2 = _aquatics.indexOf(A); if (ai2 >= 0) _aquatics.splice(ai2, 1); }, }; }; // ── 시간대(time of day) — decimal time 키프레임 램프(하늘 그라디언트·태양·안개·별) ── // 키프레임 데이터 = 리서치 확보 램프(외광파 시간대 색: 광원이 내장된 팔레트). var _TOD_KEYS = [ { t: 0.00, h: '#0B1026', z: '#05060F', sun: '#8FA6FF', si: 0.06, se: 30, hemi: ['#1B2340', '#0A0A12'], stars: 1 }, { t: 0.23, h: '#2B2D42', z: '#12172E', sun: '#B0A0E8', si: 0.15, se: 2, hemi: ['#3A3A55', '#141428'], stars: 0.6 }, { t: 0.30, h: '#F67E7D', z: '#3A3A6B', sun: '#FFBF4D', si: 0.85, se: 8, hemi: ['#FFC9B5', '#3A3A55'], stars: 0.05 }, { t: 0.42, h: '#BFE8F5', z: '#4FA8D5', sun: '#FFF0D6', si: 1.2, se: 40, hemi: ['#B1E1FF', '#B97A20'], stars: 0 }, { t: 0.55, h: '#EAF9FF', z: '#57ADD9', sun: '#FFF6E6', si: 1.3, se: 65, hemi: ['#B1E1FF', '#B97A20'], stars: 0 }, { t: 0.72, h: '#FFCE61', z: '#8A5A9B', sun: '#FFB25C', si: 1.05, se: 12, hemi: ['#FFD9A0', '#4A2B4F'], stars: 0 }, { t: 0.80, h: '#EE6C45', z: '#50366F', sun: '#FF8A5C', si: 0.6, se: 3, hemi: ['#E88A6A', '#2B1B3A'], stars: 0.1 }, { t: 0.87, h: '#903A6B', z: '#1F214D', sun: '#C86B8A', si: 0.2, se: -2, hemi: ['#6A4A7A', '#12122A'], stars: 0.5 }, { t: 1.00, h: '#0B1026', z: '#05060F', sun: '#8FA6FF', si: 0.06, se: 30, hemi: ['#1B2340', '#0A0A12'], stars: 1 }, ]; var _TOD_NAMES = { night: 0.05, dawn: 0.3, morning: 0.42, noon: 0.55, golden: 0.72, sunset: 0.8, dusk: 0.87 }; var _tod = null; // {group, sunLight, hemi, sunSprite, stars, tex, canvas, ctx2d, t, cycle_sec} function _hexlerp(c1, c2, k, out) { return out.set(c1).lerp(new THREE_.Color(c2), k); } function _tod_ensure() { if (_tod) return _tod; if (P._mood_group) { scene.remove(P._mood_group); P._mood_group = null; } // 정적 무드와 상호 배타 var c = document.createElement('canvas'); c.width = 2; c.height = 256; var tex = new THREE_.CanvasTexture(c); tex.colorSpace = THREE_.SRGBColorSpace; scene.background = tex; var group = new THREE_.Group(); var hemi = new THREE_.HemisphereLight(0xffffff, 0x222233, 0.7); var sun = new THREE_.DirectionalLight(0xffffff, 1); group.add(hemi); group.add(sun); // 태양 디스크(가산 스프라이트) — 노을 실루엣 프레임의 앵커 var sc = document.createElement('canvas'); sc.width = sc.height = 128; var sg = sc.getContext('2d'); var grad = sg.createRadialGradient(64, 64, 6, 64, 64, 64); grad.addColorStop(0, 'rgba(255,255,255,1)'); grad.addColorStop(0.25, 'rgba(255,240,210,0.9)'); grad.addColorStop(1, 'rgba(255,220,160,0)'); sg.fillStyle = grad; sg.fillRect(0, 0, 128, 128); var sunSprite = new THREE_.Sprite(new THREE_.SpriteMaterial({ map: new THREE_.CanvasTexture(sc), blending: THREE_.AdditiveBlending, depthWrite: false, transparent: true, })); sunSprite.scale.set(120, 120, 1); group.add(sunSprite); // 별 — 공유 별 필드 v2(4층+은하수+유성, _make_starfield) 를 키프레임 레벨로 구동 scene.add(group); _tod = { group: group, hemi: hemi, sun: sun, sunSprite: sunSprite, starfield: _make_starfield(), tex: tex, ctx2d: c.getContext('2d'), t: 0.55, cycle_sec: 0, azim: 0.9 }; return _tod; } // 상호배타의 역방향 집행 — tod 활성 후 P.mood 호출 시 mood 가 이걸 호출해 tod 리그를 해체한다. // (단방향만 있으면 tod→mood 순서에서 라이트 리그 2중첩 + cycle 의 fog 색 매 프레임 분쟁이 실측됨.) P._tod_teardown = function () { if (!_tod) return; scene.remove(_tod.group); if (_starfield) _starfield.set_level(0); // 별 필드는 공유·씬 상주 — 레벨만 끔(mood 가 이후 재설정) _tod = null; }; var _cA = new THREE_.Color(), _cB = new THREE_.Color(), _cC = new THREE_.Color(); function _tod_apply(t) { var S = _tod_ensure(); t = ((t % 1) + 1) % 1; S.t = t; var k0 = _TOD_KEYS[0], k1 = _TOD_KEYS[_TOD_KEYS.length - 1]; for (var i = 0; i < _TOD_KEYS.length - 1; i++) { if (t >= _TOD_KEYS[i].t && t <= _TOD_KEYS[i + 1].t) { k0 = _TOD_KEYS[i]; k1 = _TOD_KEYS[i + 1]; break; } } var span = Math.max(0.0001, k1.t - k0.t); var k = (t - k0.t) / span; // 하늘 그라디언트(지평선→천정) + fog=지평선 var hz = _hexlerp(k0.h, k1.h, k, _cA); var zn = _hexlerp(k0.z, k1.z, k, _cB); var g = S.ctx2d.createLinearGradient(0, 0, 0, 256); g.addColorStop(0, '#' + zn.getHexString()); g.addColorStop(1, '#' + hz.getHexString()); S.ctx2d.fillStyle = g; S.ctx2d.fillRect(0, 0, 2, 256); S.tex.needsUpdate = true; if (!scene.fog) scene.fog = new THREE_.Fog(hz.clone(), 18, 95); scene.fog.color.copy(hz); // 태양(고도·색·강도) — 방향광 + 디스크 위치 동조 var elev = k0.se + (k1.se - k0.se) * k; var er = elev * Math.PI / 180, az = S.azim; var sx = Math.cos(er) * Math.cos(az), sy = Math.sin(er), sz = Math.cos(er) * Math.sin(az); S.sun.position.set(sx * 50, Math.max(sy * 50, -10), sz * 50); if (P._sun_dir) P._sun_dir.value.set(sx, Math.max(sy, 0.05), sz).normalize(); // 물 글리터 광원 동조(공유 태양 방향) _hexlerp(k0.sun, k1.sun, k, _cC); S.sun.color.copy(_cC); S.sun.intensity = Math.max(0, k0.si + (k1.si - k0.si) * k); S.sunSprite.position.set(sx * 650, sy * 650, sz * 650); S.sunSprite.material.color.copy(_cC); S.sunSprite.material.opacity = clamp(S.sun.intensity, 0, 1) * (elev < 20 ? 1 : 0.55); // 반구광 S.hemi.color.copy(_hexlerp(k0.hemi[0], k1.hemi[0], k, _cA)); S.hemi.groundColor.copy(_hexlerp(k0.hemi[1], k1.hemi[1], k, _cB)); S.hemi.intensity = 0.65; // 별 — 공유 필드 레벨(트윙클·유성은 필드 틱이 소유) S.starfield.set_level(clamp(k0.stars + (k1.stars - k0.stars) * k, 0, 1) * 0.95); return S; } // t: 0~1 decimal time 또는 이름(dawn/noon/golden/sunset/dusk/night). 반환: 현재 t. nature.time_of_day = function (t) { var v = typeof t === 'string' ? (_TOD_NAMES[t] != null ? _TOD_NAMES[t] : 0.55) : num(t, 0.55); _tod_apply(v); return v; }; // 자동 순환 — day_seconds 에 하루. 타임랩스 클립("세계가 살아있음"의 압축 장치). nature.cycle = function (opts) { var S = _tod_ensure(); S.cycle_sec = clamp(num(opts && opts.day_seconds, 120), 10, 3600); return S.cycle_sec; }; // ── 구름 — puffy(소프트 스프라이트) | lowpoly(플랫셰이딩 구 블롭), 바람 드리프트 ── var _clouds = []; nature.clouds = function (opts) { var o = opts || {}; _floor.clouds = true; // 자동 바닥선 계약(0 = opt-out) if (o.count === 0) return null; var count = Math.round(clamp(num(o.count, 10), 1, 30)); var style = o.style === 'lowpoly' ? 'lowpoly' : 'puffy'; var height = num(o.height, 45), area = num(o.area, 320); var made = []; if (style === 'puffy') { var c = document.createElement('canvas'); c.width = 256; c.height = 128; var g = c.getContext('2d'); for (var p = 0; p < 6; p++) { var px = 40 + Math.random() * 176, py = 50 + Math.random() * 40, pr = 28 + Math.random() * 30; var rg = g.createRadialGradient(px, py, 2, px, py, pr); rg.addColorStop(0, 'rgba(255,255,255,0.85)'); rg.addColorStop(1, 'rgba(255,255,255,0)'); g.fillStyle = rg; g.fillRect(0, 0, 256, 128); } var ctex = new THREE_.CanvasTexture(c); for (var i = 0; i < count; i++) { var sp = new THREE_.Sprite(new THREE_.SpriteMaterial({ map: ctex, transparent: true, opacity: 0.5 + Math.random() * 0.3, depthWrite: false })); var sc2 = 24 + Math.random() * 40; sp.scale.set(sc2, sc2 * 0.45, 1); sp.position.set((Math.random() - 0.5) * area, height + Math.random() * 18, (Math.random() - 0.5) * area); scene.add(sp); made.push(sp); } } else { var cmat = new THREE_.MeshStandardMaterial({ color: 0xF4F7FB, roughness: 1, metalness: 0, flatShading: true }); for (var j = 0; j < count; j++) { var grp = new THREE_.Group(); var blobs = 3 + Math.floor(Math.random() * 3); for (var b = 0; b < blobs; b++) { var geo = new THREE_.SphereGeometry(3 + Math.random() * 4, 7, 5); var mesh = new THREE_.Mesh(geo, cmat); mesh.position.set(b * 4 - blobs * 2 + Math.random() * 2, (Math.random() - 0.5) * 2, (Math.random() - 0.5) * 3); mesh.scale.y = 0.55; grp.add(mesh); } grp.position.set((Math.random() - 0.5) * area, height + Math.random() * 15, (Math.random() - 0.5) * area); scene.add(grp); made.push(grp); } } for (var m = 0; m < made.length; m++) _clouds.push({ obj: made[m], speed: 0.5 + Math.random() * 1.2, area: area }); return made; }; // ── 날씨 파티클 — 상호 배타 전환(rain/snow/petals/leaves/clear). 카메라 주변 박스 래핑 ── var _weather = null; // {kind, obj, data} var _BOX = { x: 36, y: 22, z: 36 }; function _weather_clear() { if (!_weather) return; scene.remove(_weather.obj); if (_weather.obj.geometry) _weather.obj.geometry.dispose(); if (_weather.splash) { scene.remove(_weather.splash); _weather.splash.geometry.dispose(); } if (_weather.bolt) { scene.remove(_weather.bolt); _weather.bolt.geometry.dispose(); } _weather = null; } nature.weather = function (kind, opts) { var o = opts || {}; var _was = _weather && _weather.kind_raw; // 무지개 자동(비/폭풍 → clear 전환) _weather_clear(); if ((_was === 'rain' || _was === 'storm') && (!kind || kind === 'clear')) nature.rainbow(); if (!kind || kind === 'clear') { _scape_weather_sync(); return null; } // 비 그침 = 빗소리도 그침 var kind_raw = kind; // 확장 kind = 기존 파이프의 프리셋(별도 시스템이 아니라 파라미터 조합 — 축-조합 원칙) if (kind === 'storm') { kind = 'rain'; o.count = Math.round(clamp(num(o.count, 260), 60, 400)); o._storm = true; } else if (kind === 'blizzard') { kind = 'snow'; o.count = num(o.count, 2600); o._slant = 2.6; } else if (kind === 'sandstorm') { kind = 'snow'; o.count = num(o.count, 3000); o._slant = 3.4; o.color = o.color || '#D8B36A'; o._size = 0.2; o._fall = 0.25; } var cam = P.three.camera; if (kind === 'snow') { var n = Math.round(clamp(num(o.count, 1300), 100, 8000)); var pos = new Float32Array(n * 3), vel = new Float32Array(n); for (var i = 0; i < n; i++) { pos[i * 3] = cam.position.x + (Math.random() - 0.5) * _BOX.x; pos[i * 3 + 1] = cam.position.y + Math.random() * _BOX.y; pos[i * 3 + 2] = cam.position.z + (Math.random() - 0.5) * _BOX.z; vel[i] = 0.8 + Math.random() * 0.9; } var geo = new THREE_.BufferGeometry(); geo.setAttribute('position', new THREE_.BufferAttribute(pos, 3)); var pts = new THREE_.Points(geo, new THREE_.PointsMaterial({ color: new THREE_.Color(o.color || '#FFFFFF'), size: num(o._size, 0.14), transparent: true, opacity: 0.9, depthWrite: false })); pts.frustumCulled = false; scene.add(pts); _weather = { kind: 'snow', kind_raw: kind_raw, obj: pts, pos: pos, vel: vel, n: n, slant: num(o._slant, 1), fall: num(o._fall, 1) }; _scape_weather_sync(); // 눈 = 빗소리 정지(눈은 무음이 정답) return pts; } if (kind === 'rain') { // 스트릭 비 v2 — 점 비는 "비처럼 안 보이는" 고전 결함(실보고). 낙하 방향으로 신장한 // 선분(LineSegments) + 카메라 근접 착지 스플래시(짧은 수명 Points). var n = Math.round(clamp(num(o.count, 1500), 100, 5000)); var pos = new Float32Array(n * 3), vel = new Float32Array(n); var seg = new Float32Array(n * 2 * 3); for (var i = 0; i < n; i++) { pos[i * 3] = cam.position.x + (Math.random() - 0.5) * _BOX.x; pos[i * 3 + 1] = cam.position.y + Math.random() * _BOX.y; pos[i * 3 + 2] = cam.position.z + (Math.random() - 0.5) * _BOX.z; vel[i] = 15 + Math.random() * 9; } var geo = new THREE_.BufferGeometry(); geo.setAttribute('position', new THREE_.BufferAttribute(seg, 3)); var lines = new THREE_.LineSegments(geo, new THREE_.LineBasicMaterial({ color: 0x9FC4E8, transparent: true, opacity: 0.42, depthWrite: false })); lines.frustumCulled = false; scene.add(lines); var SPN = 40; var sp_pos = new Float32Array(SPN * 3), sp_life = new Float32Array(SPN); for (var si = 0; si < SPN; si++) sp_pos[si * 3 + 1] = -9999; var sp_geo = new THREE_.BufferGeometry(); sp_geo.setAttribute('position', new THREE_.BufferAttribute(sp_pos, 3)); var splash = new THREE_.Points(sp_geo, new THREE_.PointsMaterial({ color: 0xBFDCF2, size: 0.15, transparent: true, opacity: 0.7, depthWrite: false })); splash.frustumCulled = false; scene.add(splash); _weather = { kind: 'rain', kind_raw: kind_raw, obj: lines, splash: splash, pos: pos, vel: vel, n: n, seg: seg, sp_pos: sp_pos, sp_life: sp_life, sp_i: 0 }; _scape_weather_sync(); if (o._storm) { // 뇌우 — 간헐 번개(분기 볼트 + 화면 섬광 + 흔들림). 볼트는 1개 재사용. var bolt_geo = new THREE_.BufferGeometry(); bolt_geo.setAttribute('position', new THREE_.BufferAttribute(new Float32Array(40 * 6), 3)); var bolt = new THREE_.LineSegments(bolt_geo, new THREE_.LineBasicMaterial({ color: 0xEAF2FF, transparent: true, opacity: 0, blending: THREE_.AdditiveBlending, depthWrite: false, fog: false })); bolt.frustumCulled = false; scene.add(bolt); _weather.storm = { t: 0, next: 2.5 + Math.random() * 5, flash: 0 }; _weather.bolt = bolt; } return lines; } if (kind === 'petals' || kind === 'leaves') { var n2 = Math.round(clamp(num(o.count, 140), 20, 400)); // 꽃잎 = 오각 원판(사각 쿼드는 '종이 조각'으로 지각 — 실보고), 낙엽 = 사각 유지 var quad = kind === 'petals' ? new THREE_.CircleGeometry(0.085, 5) : new THREE_.PlaneGeometry(0.16, 0.12); var pmat = new THREE_.MeshBasicMaterial({ color: new THREE_.Color(o.color || (kind === 'petals' ? '#FFB7C5' : '#D98E3A')), side: THREE_.DoubleSide, transparent: true, opacity: 0.95, depthWrite: false, }); var im = new THREE_.InstancedMesh(quad, pmat, n2); im.frustumCulled = false; var data = []; for (var k = 0; k < n2; k++) { data.push({ x: cam.position.x + (Math.random() - 0.5) * _BOX.x, y: cam.position.y + Math.random() * _BOX.y, z: cam.position.z + (Math.random() - 0.5) * _BOX.z, ph: Math.random() * Math.PI * 2, rs: (Math.random() - 0.5) * 3, fall: 0.5 + Math.random() * 0.6, }); } scene.add(im); _weather = { kind: kind, obj: im, data: data, n: n2, _m4: new THREE_.Matrix4(), _q: new THREE_.Quaternion(), _e: new THREE_.Euler(), _v: new THREE_.Vector3(), _s: new THREE_.Vector3(1, 1, 1) }; return im; } return null; }; // ── 반딧불 — 가산 Points + 정점색 펄스(황혼/밤 무드의 생명 신호) ── var _fireflies = null; nature.fireflies = function (opts) { var o = opts || {}; var n = Math.round(clamp(num(o.count, 40), 5, 120)); var area = num(o.area, 30), yb = num(o.y, 0.4), yh = num(o.h, 2.2); var pos = new Float32Array(n * 3), col = new Float32Array(n * 3), seed = new Float32Array(n); for (var i = 0; i < n; i++) { pos[i * 3] = (Math.random() - 0.5) * area; pos[i * 3 + 1] = yb + Math.random() * yh; pos[i * 3 + 2] = (Math.random() - 0.5) * area; seed[i] = Math.random() * Math.PI * 2; } var geo = new THREE_.BufferGeometry(); geo.setAttribute('position', new THREE_.BufferAttribute(pos, 3)); geo.setAttribute('color', new THREE_.BufferAttribute(col, 3)); var pts = new THREE_.Points(geo, new THREE_.PointsMaterial({ size: 0.16, vertexColors: true, transparent: true, blending: THREE_.AdditiveBlending, depthWrite: false, })); pts.frustumCulled = false; scene.add(pts); var tint = new THREE_.Color(o.color || '#FFE58A'); _fireflies = { obj: pts, pos: pos, col: col, seed: seed, n: n, tint: tint, colAttr: geo.attributes.color, posAttr: geo.attributes.position }; return pts; }; // ── M5 스케일 문법: 부유 모트 — 카메라 추종 랩 박스의 미세 입자(먼지/포자/플랑크톤 읽기). // 미시·실내·수중 스케일 브리프의 최강 단일 신호(포스트 패스 0, DOF 불요) ── var _motes = null; nature.motes = function (opts) { var o = opts || {}; if (_motes) { scene.remove(_motes.obj); _motes = null; } var n = Math.round(clamp(num(o.count, 240), 40, 900)); var R = clamp(num(o.radius, 14), 4, 60); var pos = new Float32Array(n * 3), seed = new Float32Array(n); for (var i = 0; i < n; i++) { pos[i * 3] = (Math.random() - 0.5) * R * 2; pos[i * 3 + 1] = (Math.random() - 0.5) * R * 2; pos[i * 3 + 2] = (Math.random() - 0.5) * R * 2; seed[i] = Math.random() * Math.PI * 2; } var geo = new THREE_.BufferGeometry(); geo.setAttribute('position', new THREE_.BufferAttribute(pos, 3)); var pts = new THREE_.Points(geo, new THREE_.PointsMaterial({ size: clamp(num(o.size, 0.06), 0.01, 0.5), color: new THREE_.Color(o.color || '#fff3dd'), transparent: true, opacity: clamp(num(o.opacity, 0.38), 0.05, 0.9), blending: THREE_.AdditiveBlending, depthWrite: false, sizeAttenuation: true, })); pts.frustumCulled = false; // 카메라 추종 랩 박스 — 낡은 바운즈 컬링이 공백을 만든다 scene.add(pts); _motes = { obj: pts, pos: pos, seed: seed, n: n, R: R, posAttr: geo.attributes.position }; return { obj: pts, remove: function () { if (_motes) { scene.remove(_motes.obj); _motes = null; } } }; }; // ── 신광(sun shafts) — 가산 세로-그라디언트 평면(포스트 패스 0 — 스타일라이즈드 정본 fake) ── var _shafts = []; nature.sun_shafts = function (opts) { var o = opts || {}; var count = Math.round(clamp(num(o.count, 4), 1, 8)); var c = document.createElement('canvas'); c.width = 64; c.height = 256; var g = c.getContext('2d'); var grad = g.createLinearGradient(0, 0, 0, 256); grad.addColorStop(0, 'rgba(255,236,190,0.55)'); grad.addColorStop(1, 'rgba(255,236,190,0)'); g.fillStyle = grad; g.fillRect(0, 0, 64, 256); var tex = new THREE_.CanvasTexture(c); var made = []; for (var i = 0; i < count; i++) { var w = 1.2 + Math.random() * 2.2, h = 16 + Math.random() * 10; var mesh = new THREE_.Mesh( new THREE_.PlaneGeometry(w, h), new THREE_.MeshBasicMaterial({ map: tex, transparent: true, blending: THREE_.AdditiveBlending, depthWrite: false, side: THREE_.DoubleSide, opacity: 0.4 + Math.random() * 0.25 }) ); mesh.position.set((Math.random() - 0.5) * (num(o.area, 26)), h * 0.42, -4 - Math.random() * (num(o.depth, 18))); mesh.rotation.z = 0.28 + Math.random() * 0.14; scene.add(mesh); made.push(mesh); _shafts.push({ obj: mesh, ph: Math.random() * Math.PI * 2, base: mesh.material.opacity }); } return made; }; // ── nature 틱 — 바람 시간·거스트·클라우드 드리프트·날씨·반딧불·샤프트·시간 순환 ── P.loop(function (dt, elapsed, raw) { // 자동 풍요 바닥선 발화(1회) — LLM 로직의 호출권 행사가 끝난 시점(부트+0.8s 벽시계). // 반드시 raw 누적(시작 카드 dt=0 중에도 발화 — 첫인상 프레임이 바닥선의 존재 이유). _floor_t += raw; if (!_floor.done && _floor_t > 0.8) { try { _floor_apply(elapsed); } catch (e) { _floor.done = true; if (P._hook_err) P._hook_err(e, 'nature_floor'); } } // 스트리밍 잔디 추종 — 오류 시 스트림 해제(고정 상태 잔존)하고 LOUD(침묵 반복 크래시 금지) try { _grass_stream_tick(); } catch (e) { _grass_streams.length = 0; if (P._hook_err) P._hook_err(e, 'grass_stream'); } // 사운드스케이프 근접 게인 폴링(0.5s) — 개울은 가까울수록, 파도는 해변(지면-수면 고도차)에서 커진다 _scape.poll += raw; if (_scape.done && _scape.poll > 0.5) { _scape.poll = 0; try { var pl9 = P.world && P.world._priv && P.world._priv.player(); var px9 = pl9 && pl9.handle ? pl9.handle.obj.position.x : (P.three ? P.three.camera.position.x : 0); var pz9 = pl9 && pl9.handle ? pl9.handle.obj.position.z : (P.three ? P.three.camera.position.z : 0); if (_scape.L.river && _rivers.length) { var rd9 = 1e9; for (var rv9 = 0; rv9 < _rivers.length; rv9++) { var PS9 = _rivers[rv9].pts; for (var pi9 = 0; pi9 < PS9.length; pi9 += 2) { // 성김 샘플로 충분(0.5s 주기) var ddx9 = PS9[pi9][0] - px9, ddz9 = PS9[pi9][1] - pz9; var dq9 = ddx9 * ddx9 + ddz9 * ddz9; if (dq9 < rd9) rd9 = dq9; } } _scape.L.river.gain(0.03 + clamp(1 - (Math.sqrt(rd9) - 6) / 30, 0, 1) * 0.55); } if (_scape.L.waves && P._water_y != null && P.world && P.world.height_at) { var sh9 = clamp(1 - (P.world.height_at(px9, pz9) - P._water_y) / 16, 0.15, 1); _scape.L.waves.gain(0.1 + sh9 * 0.5); } } catch (e9) { _scape.poll = -1e9; if (P._hook_err) P._hook_err(e9, 'soundscape'); } // 폴링만 정지(레이어는 베이스 게인으로 지속) + LOUD } if (_amb) { // 앰비언트 생명 틱 — 선회 새 + 방목 동물 목표점 방랑 var AB = _amb.birds; for (var _ab = 0; _ab < AB.list.length; _ab++) { var BD2 = AB.list[_ab]; BD2.ph += raw * BD2.w; AB.q.setFromAxisAngle(AB.up, -BD2.ph); AB.m4.compose(AB.v.set(Math.cos(BD2.ph) * BD2.r, BD2.h + Math.sin(BD2.ph * 2.7) * 0.8, Math.sin(BD2.ph) * BD2.r), AB.q, AB.s.set(1, 1, 0.7 + Math.abs(Math.sin(elapsed * 4 + _ab * 2)) * 0.5)); AB.im.setMatrixAt(_ab, AB.m4); } AB.im.instanceMatrix.needsUpdate = true; for (var _am2 = 0; _am2 < _amb.animals.length; _am2++) { var AN = _amb.animals[_am2]; var ao2 = AN.h && AN.h.obj; if (!ao2) continue; var adx = AN.tx - ao2.position.x, adz = AN.tz - ao2.position.z; var ad2 = Math.sqrt(adx * adx + adz * adz); if (ad2 < 0.4) { AN.wait -= raw; if (AN.h._actions && !AN.h._oneshot) AN.h.play(AN.h._actions.eat && Math.random() < 0.01 ? 'eat' : 'idle'); if (AN.wait <= 0) { AN.tx = ao2.position.x + (Math.random() - 0.5) * 26; AN.tz = ao2.position.z + (Math.random() - 0.5) * 26; AN.wait = 3 + Math.random() * 7; } } else { ao2.position.x += adx / ad2 * raw * 1.3; ao2.position.z += adz / ad2 * raw * 1.3; if (P.world && P.world.height_at) ao2.position.y = P.world.height_at(ao2.position.x, ao2.position.z); ao2.rotation.y = Math.atan2(adx, adz); if (AN.h._actions && !AN.h._oneshot) AN.h.play('walk'); } } } // 거스트: 비정수 주파수 sine 곱(유사 랜덤 돌풍) — 전 소비자 공통 진폭 W.uTime.value += raw * (0.8 + W.speed * 0.6); var gust = 0.5 + 0.5 * Math.sin(elapsed * 0.9) * Math.sin(elapsed * 2.33) * Math.sin(elapsed * 0.41 + 1.7); W.uStrength.value = W.base * (1 - W.gustiness * 0.45 + W.gustiness * 0.9 * gust); if (_player_ref && _player_ref.position) W.uPlayer.value.set(_player_ref.position.x, _player_ref.position.z); // 시간 순환 if (_tod && _tod.cycle_sec > 0) _tod_apply(_tod.t + raw / _tod.cycle_sec); // 별 필드(트윙클·유성·카메라 추종 돔) if (_starfield) _starfield.tick(raw, elapsed); for (var _rv = 0; _rv < _rivers.length; _rv++) { var _R = _rivers[_rv]; if (_R.tex) { _R.tex.offset.y -= _R.speed * raw; continue; } // 구 단층 계약(호환) for (var _rl = 0; _rl < _R.layers.length; _rl++) _R.layers[_rl].tex.offset.y -= _R.speed * _R.layers[_rl].mul * raw; // 버텍스 리플 — 본체 y 미세 요동(윤슬). 정점 수백 개 = CPU 무해, 30fps 스로틀 불요. var _rp = _R.ripple; _rp.t += raw; var _pa2 = _rp.geo.getAttribute('position'); for (var _rv2 = 0; _rv2 < _pa2.count; _rv2++) { _pa2.array[_rv2 * 3 + 1] = _rp.base[_rv2 * 3 + 1] + Math.sin(_rp.t * 2.1 + _rv2 * 0.7) * 0.035; } _pa2.needsUpdate = true; // 바위 링 맥동(반경 ±10%) + 잎 표류(경로 이류 — 유속·방향의 입체 증거) for (var _rr2 = 0; _rr2 < _R.rock_rings.length; _rr2++) { var _RR = _R.rock_rings[_rr2]; _RR.m.scale.setScalar(_RR.base * (1 + Math.sin(_rp.t * 2.4 + _RR.ph) * 0.1)); } if (_R.leaves) { var _LV = _R.leaves; for (var _lf2 = 0; _lf2 < _LV.list.length; _lf2++) { var _L2 = _LV.list[_lf2]; _L2.u += _R.speed * raw * 5.2; if (_L2.u >= _R.n - 1) _L2.u -= (_R.n - 1); _L2.rot += raw * 0.9; var _li0 = _L2.u | 0, _lfr = _L2.u - _li0, _li1 = Math.min(_R.n - 1, _li0 + 1); var _lx = _R.pts[_li0][0] + (_R.pts[_li1][0] - _R.pts[_li0][0]) * _lfr + _R.nrm[_li0 * 2] * _R.width * _L2.lane / 2; var _lz = _R.pts[_li0][1] + (_R.pts[_li1][1] - _R.pts[_li0][1]) * _lfr + _R.nrm[_li0 * 2 + 1] * _R.width * _L2.lane / 2; var _ly = _R.ys2[_li0] + (_R.ys2[_li1] - _R.ys2[_li0]) * _lfr + 0.3; _LV.q.setFromAxisAngle(_LV.ax, _L2.rot); _LV.m4.compose(_LV.v.set(_lx, _ly, _lz), _LV.q, _LV.s); _LV.im.setMatrixAt(_lf2, _LV.m4); } _LV.im.instanceMatrix.needsUpdate = true; } } for (var _wf = 0; _wf < _waterfalls.length; _wf++) { var WF = _waterfalls[_wf]; WF.t += raw; for (var _wi = 0; _wi < WF.n; _wi++) { WF.sp[_wi * 2] += WF.sp[_wi * 2 + 1] * raw * 0.55; if (WF.sp[_wi * 2] > 1) WF.sp[_wi * 2] -= 1; var _wp = WF.sp[_wi * 2]; var _wy = WF.top - (WF.top - WF.bottom) * _wp; var _wxx = WF.x + ((_wi / WF.n) - 0.5) * WF.width; var _wl = 0.9 + _wp * 1.4; // 가속 신장 WF.seg[_wi * 6] = _wxx; WF.seg[_wi * 6 + 1] = _wy; WF.seg[_wi * 6 + 2] = WF.z; WF.seg[_wi * 6 + 3] = _wxx; WF.seg[_wi * 6 + 4] = _wy + _wl; WF.seg[_wi * 6 + 5] = WF.z; } WF.lines.geometry.attributes.position.needsUpdate = true; for (var _wb = 0; _wb < WF.nb; _wb++) { // B그룹 — 1.8× 빠른 희미한 물 겹(2속 커튼) WF.spB[_wb * 2] += WF.spB[_wb * 2 + 1] * raw * 0.55; if (WF.spB[_wb * 2] > 1) WF.spB[_wb * 2] -= 1; var _wpb = WF.spB[_wb * 2]; var _wyb = WF.top - (WF.top - WF.bottom) * _wpb; var _wxb = WF.x + ((_wb / WF.nb) - 0.5) * WF.width * 0.96; var _wlb = 1.4 + _wpb * 2.2; WF.segB[_wb * 6] = _wxb; WF.segB[_wb * 6 + 1] = _wyb; WF.segB[_wb * 6 + 2] = WF.z + 0.12; WF.segB[_wb * 6 + 3] = _wxb; WF.segB[_wb * 6 + 4] = _wyb + _wlb; WF.segB[_wb * 6 + 5] = WF.z + 0.12; } WF.linesB.geometry.attributes.position.needsUpdate = true; for (var _wm = 0; _wm < WF.mn; _wm++) { WF.mpos[_wm * 3 + 1] += raw * 0.5; if (WF.mpos[_wm * 3 + 1] > WF.bottom + 1.6) WF.mpos[_wm * 3 + 1] = WF.bottom; } WF.mist.geometry.attributes.position.needsUpdate = true; // 착수 링 확장·페이드(1.3s 루프, 120° 스태거) + 풀 반경 맥동 ±8% for (var _wr = 0; _wr < WF.rings.length; _wr++) { var _RG = WF.rings[_wr]; var _rt2 = (WF.t / 1.3 + _RG.ph) % 1; var _rs2 = (0.5 + _rt2 * 2.2) * (WF.width / 5); _RG.m.scale.set(_rs2, 1, _rs2); _RG.m.material.opacity = 0.62 * (1 - _rt2); } WF.pool.scale.setScalar(1 + Math.sin(WF.t * 1.9) * 0.08); for (var _wc = 0; _wc < WF.cn; _wc++) { // churn 도약(포물선 — 위로 튀고 떨어짐) WF.cst[_wc * 2] += WF.cst[_wc * 2 + 1] * raw; if (WF.cst[_wc * 2] > 1) WF.cst[_wc * 2] -= 1; var _cp2 = WF.cst[_wc * 2]; WF.cpos[_wc * 3] = WF.x + Math.sin(_wc * 2.4) * WF.width * 0.32; WF.cpos[_wc * 3 + 1] = WF.bottom + 0.1 + 4.2 * _cp2 * (1 - _cp2) * (0.5 + (_wc % 3) * 0.3); WF.cpos[_wc * 3 + 2] = WF.z + Math.cos(_wc * 3.1) * 0.9; } WF.churn.geometry.attributes.position.needsUpdate = true; } // ── 수생 파우나 틱 — 어군 회유·해파리 맥동·개구리 도약·잠자리·물새·게·물고기 점프 ── for (var _aq = 0; _aq < _aquatics.length; _aq++) { var AQ = _aquatics[_aq]; AQ.t += raw; for (var _fs = 0; _fs < AQ.fish.length; _fs++) { // 어군 — 앵커 궤도 + 꼬리 요잉(정어리 회유 축소판) var FS = AQ.fish[_fs]; for (var _fi = 0; _fi < FS.list.length; _fi++) { var F = FS.list[_fi]; F.a += F.w * raw; var fx4 = FS.cx + Math.cos(F.a + F.ph) * F.r, fz4 = FS.cz + Math.sin(F.a + F.ph) * F.r; var fy4 = (P._water_y != null ? P._water_y : 0) + F.dy + Math.sin(AQ.t * 1.3 + F.ph) * 0.15; FS.q.setFromAxisAngle(FS.up, -(F.a + F.ph) - Math.PI / 2 + Math.sin(AQ.t * 6 + F.ph) * 0.22); FS.m4.compose(FS.v.set(fx4, fy4, fz4), FS.q, FS.s); FS.im.setMatrixAt(_fi, FS.m4); } FS.im.instanceMatrix.needsUpdate = true; } if (AQ.jim) { // 해파리 — 수축-추진 맥동 + 저속 표류 for (var _jl = 0; _jl < AQ.jelly.length; _jl++) { var J = AQ.jelly[_jl]; var jp = Math.sin(AQ.t * 1.6 + J.ph); J.x += Math.cos(J.drift) * raw * 0.12; J.z += Math.sin(J.drift) * raw * 0.12; J.y += Math.max(0, jp) * raw * 0.22 - raw * 0.08; if (P._water_y != null) J.y = Math.min(P._water_y - 0.5, Math.max(P._water_y - 3.4, J.y)); AQ.jim.q.identity(); AQ.jim.m4.compose(AQ.jim.v.set(J.x, J.y, J.z), AQ.jim.q, AQ.jim.s.set(1 - jp * 0.18, 1 + jp * 0.3, 1 - jp * 0.18)); AQ.jim.im.setMatrixAt(_jl, AQ.jim.m4); } AQ.jim.im.instanceMatrix.needsUpdate = true; } if (AQ.fim) { // 개구리 — 착좌 + 간헐 패드 도약(포물선) for (var _fg = 0; _fg < AQ.frogs.length; _fg++) { var FR = AQ.frogs[_fg]; if (FR.to) { FR.jt += raw * 1.8; if (FR.jt >= 1) { FR.x = FR.to[0]; FR.y = FR.to[1]; FR.z = FR.to[2]; FR.to = null; FR.wait = 4 + Math.random() * 10; } else { FR.x = FR.from[0] + (FR.to[0] - FR.from[0]) * FR.jt; FR.z = FR.from[2] + (FR.to[2] - FR.from[2]) * FR.jt; FR.y = FR.from[1] + (FR.to[1] - FR.from[1]) * FR.jt + Math.sin(FR.jt * Math.PI) * 0.8; } } else { FR.wait -= raw; if (FR.wait <= 0 && AQ.pads.length > 1) { var np2 = AQ.pads[Math.floor(Math.random() * AQ.pads.length)]; FR.from = [FR.x, FR.y, FR.z]; FR.to = np2; FR.jt = 0; if (P.fx && Math.random() < 0.5) P.fx.sfx('blip', { volume: 0.25 }); } } AQ.fim.q.identity(); AQ.fim.m4.compose(AQ.fim.v.set(FR.x, FR.y + 0.09, FR.z), AQ.fim.q, AQ.fim.s.set(1, 1, 1)); AQ.fim.im.setMatrixAt(_fg, AQ.fim.m4); } AQ.fim.im.instanceMatrix.needsUpdate = true; } if (AQ.dim) { // 잠자리 — 호버 다트(급가감속 원호) for (var _dd = 0; _dd < AQ.dfly.length; _dd++) { var DF = AQ.dfly[_dd]; DF.ph += raw * (1.6 + Math.sin(AQ.t * 0.7 + _dd) * 1.1); var dx4 = DF.cx + Math.cos(DF.ph) * DF.r, dz4 = DF.cz + Math.sin(DF.ph * 1.3) * DF.r * 0.7; var dy4 = (P._water_y != null ? P._water_y : 0) + 0.7 + Math.sin(AQ.t * 2.3 + _dd * 2) * 0.3; AQ.dim.q.setFromAxisAngle(AQ.dim.up, -DF.ph); AQ.dim.m4.compose(AQ.dim.v.set(dx4, dy4, dz4), AQ.dim.q, AQ.dim.s); AQ.dim.im.setMatrixAt(_dd, AQ.dim.m4); } AQ.dim.im.instanceMatrix.needsUpdate = true; } if (AQ.bim) { // 물새 — 대원 활공 + 날개 스케일 플랩 for (var _bd3 = 0; _bd3 < AQ.birds.length; _bd3++) { var BD = AQ.birds[_bd3]; BD.ph += raw * BD.w; var bx4 = Math.cos(BD.ph) * BD.r, bz4 = Math.sin(BD.ph) * BD.r; AQ.bim.q.setFromAxisAngle(AQ.bim.up, -BD.ph); AQ.bim.m4.compose(AQ.bim.v.set(bx4, BD.h + Math.sin(BD.ph * 3) * 0.6, bz4), AQ.bim.q, AQ.bim.s.set(1, 1, 0.7 + Math.abs(Math.sin(AQ.t * 4 + _bd3)) * 0.5)); AQ.bim.im.setMatrixAt(_bd3, AQ.bim.m4); } AQ.bim.im.instanceMatrix.needsUpdate = true; } if (AQ.cim) { // 게 — 옆걸음 스커틀(간헐 정지) for (var _cr = 0; _cr < AQ.crabs.length; _cr++) { var CR = AQ.crabs[_cr]; var go2 = Math.sin(AQ.t * 0.9 + CR.ph) > 0.2; if (go2) { CR.x += Math.cos(CR.dir + Math.PI / 2) * raw * 0.5; CR.z += Math.sin(CR.dir + Math.PI / 2) * raw * 0.5; if (Math.random() < raw * 0.3) CR.dir += (Math.random() - 0.5) * 1.5; } AQ.cim.q.setFromAxisAngle(AQ.cim.up, -CR.dir); AQ.cim.m4.compose(AQ.cim.v.set(CR.x, CR.y, CR.z), AQ.cim.q, AQ.cim.s); AQ.cim.im.setMatrixAt(_cr, AQ.cim.m4); } AQ.cim.im.instanceMatrix.needsUpdate = true; } if (AQ.jumpf) { // 물고기 점프 — 랜덤 타이머, 아크 + 입출수 링(+효과음) var JF = AQ.jumpf; if (JF.st < 0) { AQ.jt -= raw; if (AQ.jt <= 0) { AQ.jt = 8 + Math.random() * 14; var jpts2 = null; for (var _js = 0; _js < AQ.fish.length; _js++) { jpts2 = AQ.fish[_js]; break; } JF.x = (jpts2 ? jpts2.cx : 0) + (Math.random() - 0.5) * 6; JF.z = (jpts2 ? jpts2.cz : 0) + (Math.random() - 0.5) * 6; JF.st = 0; JF.m.visible = true; if (P.fx) P.fx.sfx('blip', { volume: 0.35 }); } } else { JF.st += raw * 1.4; var wy4 = (P._water_y != null ? P._water_y : 0); if (JF.st >= 1) { JF.st = -1; JF.m.visible = false; JF.rings[1].position.set(JF.x + 0.8, wy4 + 0.05, JF.z); JF.rings[1].material.opacity = 0.6; JF.rings[1].scale.setScalar(0.4); } else { JF.m.position.set(JF.x + JF.st * 1.6, wy4 + Math.sin(JF.st * Math.PI) * 1.1, JF.z); JF.m.rotation.z = -JF.st * 2.4 + 1.2; if (JF.st < 0.12 && JF.rings[0].material.opacity < 0.1) { JF.rings[0].position.set(JF.x, wy4 + 0.05, JF.z); JF.rings[0].material.opacity = 0.6; JF.rings[0].scale.setScalar(0.4); } } } for (var _jr4 = 0; _jr4 < 2; _jr4++) { // 링 확장·페이드 var RJ = JF.rings[_jr4]; if (RJ.material.opacity > 0.01) { RJ.material.opacity -= raw * 0.7; RJ.scale.multiplyScalar(1 + raw * 2.2); } } } } if (_aurora && _aurora.group.visible) { var _acam = P.three.camera; _aurora.group.position.set(_acam.position.x, 0, _acam.position.z); // 별 돔과 동일한 카메라 추종 if (!_aurora.placed && elapsed > 1.2) { // 리그 정착 후 전방 상공에 리본 배치(달과 동일 함정) _aurora.placed = true; var _adir = _acam.getWorldDirection(new THREE_.Vector3()); var _aaz = Math.atan2(_adir.z, _adir.x); for (var _ap = 0; _ap < _aurora.ribbons.length; _ap++) { var _am = _aurora.ribbons[_ap].m, _adist = 420 + _ap * 60; var _raz = _aaz + _aurora.ribbons[_ap].az_off; _am.position.set(Math.cos(_raz) * _adist, 150 + _ap * 30, Math.sin(_raz) * _adist); // 하단 가장자리 ~10° 대역(하향-피치 프레임) _am.lookAt(0, _am.position.y, 0); // 수직 커튼(카메라 방위만 마주봄) _aurora.ribbons[_ap].base = _am.geometry.attributes.position.array.slice(); } } if (!_aurora.placed) return; for (var _ar = 0; _ar < _aurora.ribbons.length; _ar++) { var R2 = _aurora.ribbons[_ar]; var arr = R2.m.geometry.attributes.position.array; for (var _av = 0; _av < arr.length; _av += 3) { var bx = R2.base[_av]; arr[_av + 1] = R2.base[_av + 1] + Math.sin(bx * 0.012 + elapsed * 0.7 + R2.ph) * 22 + Math.sin(bx * 0.05 + elapsed * 1.6) * 7; } R2.m.geometry.attributes.position.needsUpdate = true; R2.m.material.opacity = R2.target * (0.75 + 0.25 * Math.sin(elapsed * 0.9 + R2.ph)); } } if (_rainbow) { _rainbow.t += raw; var _rk = _rainbow.t / _rainbow.life; _rainbow.mesh.material.opacity = _rk < 0.15 ? (_rk / 0.15) * 0.75 : _rk > 0.75 ? Math.max(0, (1 - _rk) / 0.25) * 0.75 : 0.75; if (_rk >= 1) { scene.remove(_rainbow.mesh); _rainbow.mesh.geometry.dispose(); _rainbow = null; } } if (_weather && _weather.storm) { var ST = _weather.storm; ST.t += raw; if (ST.t > ST.next) { // 낙뢰 — 볼트 재생성 + 섬광 + 흔들림 ST.t = 0; ST.next = 2.5 + Math.random() * 6; ST.flash = 0.22; var _bcam = P.three.camera; var _bx = _bcam.position.x + (Math.random() - 0.5) * 120; var _bz = _bcam.position.z + (Math.random() - 0.5) * 120; var _by = 160, _cx = _bx, _cz = _bz; var _barr = _weather.bolt.geometry.attributes.position.array; var _gy2 = (P.world && P.world.height_at) ? P.world.height_at(_bx, _bz) : 0; for (var _bs = 0; _bs < 40; _bs++) { var _ny = _by - (_by - _gy2) * (_bs + 1) / 40; var _nx = _cx + (Math.random() - 0.5) * 9; var _nz = _cz + (Math.random() - 0.5) * 9; _barr[_bs * 6] = _cx; _barr[_bs * 6 + 1] = _by - (_by - _gy2) * _bs / 40; _barr[_bs * 6 + 2] = _cz; _barr[_bs * 6 + 3] = _nx; _barr[_bs * 6 + 4] = _ny; _barr[_bs * 6 + 5] = _nz; _cx = _nx; _cz = _nz; } _weather.bolt.geometry.attributes.position.needsUpdate = true; if (P.fx) { P.fx.screen_flash('#DCE9FF', 0.13); P.fx.shake(0.25); } } if (ST.flash > 0) { ST.flash -= raw; _weather.bolt.material.opacity = Math.max(0, ST.flash / 0.22); } else _weather.bolt.material.opacity = 0; } // 구름 드리프트(바람 방향 공유) for (var ci = 0; ci < _clouds.length; ci++) { var C = _clouds[ci]; C.obj.position.x += W.uDir.value.x * C.speed * W.uStrength.value * raw * 2.2; C.obj.position.z += W.uDir.value.y * C.speed * W.uStrength.value * raw * 2.2; if (C.obj.position.x > C.area / 2) C.obj.position.x = -C.area / 2; if (C.obj.position.z > C.area / 2) C.obj.position.z = -C.area / 2; } // 날씨 if (_weather) { var cam = P.three.camera; if (_weather.kind === 'rain' || _weather.kind === 'snow') { var pos = _weather.pos, vel = _weather.vel, snow = _weather.kind === 'snow'; var _wx = W.uDir.value.x * W.uStrength.value, _wz = W.uDir.value.y * W.uStrength.value; for (var i = 0; i < _weather.n; i++) { pos[i * 3 + 1] -= vel[i] * raw * (snow ? (_weather.fall || 1) : 1); var _sl = snow ? (_weather.slant || 1) : 1; pos[i * 3] += _wx * raw * (snow ? 1.6 * _sl : 0.8) + (snow ? Math.sin(elapsed * 1.3 + i) * 0.5 * raw : 0) + (snow && _sl > 1 ? _sl * 1.4 * raw : 0); pos[i * 3 + 2] += _wz * raw * (snow ? 1.6 * _sl : 0.8); if (pos[i * 3 + 1] < cam.position.y - 3) { // 착지 스플래시(비, 카메라 근접 한정) — 재활용 직전 지면 좌표에 짧은 수명 점 if (!snow && _weather.sp_pos) { var _lx = pos[i * 3], _lz = pos[i * 3 + 2]; var _ldx = _lx - cam.position.x, _ldz = _lz - cam.position.z; if (_ldx * _ldx + _ldz * _ldz < 256) { var _gy = (P.world && P.world.height_at) ? P.world.height_at(_lx, _lz) : 0; var _sj = _weather.sp_i = (_weather.sp_i + 1) % 40; _weather.sp_pos[_sj * 3] = _lx; _weather.sp_pos[_sj * 3 + 1] = _gy + 0.04; _weather.sp_pos[_sj * 3 + 2] = _lz; _weather.sp_life[_sj] = 0.22; } } pos[i * 3] = cam.position.x + (Math.random() - 0.5) * _BOX.x; pos[i * 3 + 1] = cam.position.y + _BOX.y * (0.6 + Math.random() * 0.4); pos[i * 3 + 2] = cam.position.z + (Math.random() - 0.5) * _BOX.z; } if (!snow) { // 스트릭: 머리(낙하점) → 꼬리(낙하 반대 방향으로 신장, 속도 비례 길이 + 바람 기울기) var _len = vel[i] * 0.028; var _i6 = i * 6; _weather.seg[_i6] = pos[i * 3]; _weather.seg[_i6 + 1] = pos[i * 3 + 1]; _weather.seg[_i6 + 2] = pos[i * 3 + 2]; _weather.seg[_i6 + 3] = pos[i * 3] - _wx * 0.05 * _len; _weather.seg[_i6 + 4] = pos[i * 3 + 1] + _len; _weather.seg[_i6 + 5] = pos[i * 3 + 2] - _wz * 0.05 * _len; } } _weather.obj.geometry.attributes.position.needsUpdate = true; if (!snow && _weather.splash) { for (var _sk = 0; _sk < 40; _sk++) { if (_weather.sp_life[_sk] > 0) { _weather.sp_life[_sk] -= raw; if (_weather.sp_life[_sk] <= 0) _weather.sp_pos[_sk * 3 + 1] = -9999; } } _weather.splash.geometry.attributes.position.needsUpdate = true; } } else { var d = _weather.data; for (var k2 = 0; k2 < _weather.n; k2++) { var pd = d[k2]; pd.y -= pd.fall * raw; pd.x += (W.uDir.value.x * W.uStrength.value * 1.4 + Math.sin(elapsed * 1.1 + pd.ph) * 0.5) * raw; pd.z += W.uDir.value.y * W.uStrength.value * 1.4 * raw; pd.ph += pd.rs * raw; if (pd.y < cam.position.y - 3) { pd.x = cam.position.x + (Math.random() - 0.5) * _BOX.x; pd.y = cam.position.y + _BOX.y * (0.5 + Math.random() * 0.5); pd.z = cam.position.z + (Math.random() - 0.5) * _BOX.z; } _weather._e.set(pd.ph, pd.ph * 0.7, pd.ph * 0.4); _weather._q.setFromEuler(_weather._e); _weather._m4.compose(_weather._v.set(pd.x, pd.y, pd.z), _weather._q, _weather._s); _weather.obj.setMatrixAt(k2, _weather._m4); } _weather.obj.instanceMatrix.needsUpdate = true; } } // 반딧불 펄스 + 부유 if (_fireflies) { var F = _fireflies; for (var fi = 0; fi < F.n; fi++) { var pulse = Math.max(0, Math.sin(elapsed * 1.7 + F.seed[fi])); pulse = pulse * pulse; F.col[fi * 3] = F.tint.r * pulse; F.col[fi * 3 + 1] = F.tint.g * pulse; F.col[fi * 3 + 2] = F.tint.b * pulse; F.pos[fi * 3] += Math.sin(elapsed * 0.6 + F.seed[fi]) * 0.15 * raw; F.pos[fi * 3 + 1] += Math.cos(elapsed * 0.5 + F.seed[fi] * 2.1) * 0.12 * raw; F.pos[fi * 3 + 2] += Math.cos(elapsed * 0.7 + F.seed[fi]) * 0.15 * raw; } F.colAttr.needsUpdate = true; F.posAttr.needsUpdate = true; } // M5 모트 — 브라운 드리프트 + 카메라 랩(항상 카메라 주변 밀도 유지) if (_motes && P.three) { var MO = _motes, mc = P.three.camera.position; for (var mo = 0; mo < MO.n; mo++) { MO.pos[mo * 3] += Math.sin(elapsed * 0.4 + MO.seed[mo]) * 0.05 * raw; MO.pos[mo * 3 + 1] += Math.cos(elapsed * 0.33 + MO.seed[mo] * 1.7) * 0.035 * raw; MO.pos[mo * 3 + 2] += Math.cos(elapsed * 0.47 + MO.seed[mo]) * 0.05 * raw; for (var ax2 = 0; ax2 < 3; ax2++) { var cc = ax2 === 0 ? mc.x : (ax2 === 1 ? mc.y : mc.z); var dvv = MO.pos[mo * 3 + ax2] - cc; if (dvv > MO.R) MO.pos[mo * 3 + ax2] -= MO.R * 2; else if (dvv < -MO.R) MO.pos[mo * 3 + ax2] += MO.R * 2; } } MO.posAttr.needsUpdate = true; } // 신광 요동 for (var si3 = 0; si3 < _shafts.length; si3++) { var sh = _shafts[si3]; sh.obj.material.opacity = sh.base * (0.75 + 0.25 * Math.sin(elapsed * 0.4 + sh.ph)); } }); // 성능 강등 훅 — 셸 DRS 사다리가 소비(풀 인스턴스 30% 감축 = 무비용 LOD). if (!P._perf_reducers) P._perf_reducers = []; P._perf_reducers.push(function () { var did = null; for (var i = 0; i < _grass_fields.length; i++) { var im = _grass_fields[i]; if (im.count > 4000) { im.count = Math.floor(im.count * 0.7); did = 'grass→' + im.count; } } return did; }); })(); b ? b : v); } function num(v, d) { return typeof v === 'number' && isFinite(v) ? v : d; } function damp_k(lambda, dt) { return 1 - Math.exp(-lambda * dt); } // 프레임률 독립 감쇠 정본 // mulberry32 — 결정론 시딩(청크 재방문 = 동일 재생성, Math.random 금지 영역) function mulberry32(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; }; } // ── 지형 — fBm height field(순수 함수) + 청크 그리드 + 히스테리시스 + 안개 동조 ── var T = null; // {seed, amp, scale, chunk, radius, colors, noise, chunks:Map, pending:[]} function _raw_height(x, z) { var n = T.noise, f = 1.4 / T.scale, a = 1, y = 0; for (var o = 0; o < 4; o++) { y += n.noise(x * f + 31.7, z * f - 17.3) * a; a *= 0.5; f *= 2.05; } if (y > 0.32) y *= 1.3; // 봉우리 과장(정본) return y * T.amp; } // ── 단층 융기 바닥(floor plane) = 지형 필드에 편입 ────────────────────────────── // ⚠️ 이중 표현을 *줄이는* 층이다. 실내 슬랩처럼 단층·평탄한 바닥은 (x,z)→h 로 표현 가능하므로, // 별도 제공자로 빼면 height_at 소비자 전부(77곳: 카메라·몹·소품·마커·AI…)가 슬랩 아래 terrain 을 // 지면으로 오인한다(실보고 "바닥 아래가 보임"의 근인 클래스). 필드에 편입하면 전 소비자가 전환 // 없이 진실을 본다. 다층(obby 플랫폼·build 피스)은 (x,z)→h 로 표현 불가하므로 제공자 층에 남는다. var _floors = []; world.floor_plane = function (f) { if (!f) { _floors.length = 0; return; } _floors.push({ x0: num(f.x0, -1e9), x1: num(f.x1, 1e9), z0: num(f.z0, -1e9), z1: num(f.z1, 1e9), y: num(f.y, 0) }); }; // 선언된 바닥면 = *실내*라는 뜻이기도 하다. 필드가 진실해지자(위 _floor_at) 지형 장식을 지면 높이에 // 놓던 소비자(식생 산포 등)가 그 장식을 실내 바닥 위로 올려버렸다(실보고 e71191f5: 건물 안에 잔디). // 높이만 합성하고 "여긴 실내"라는 사실을 노출하지 않으면 그 클래스가 계속 새므로 질의로 제공한다. world.floor_covers = function (x, z) { for (var fi = 0; fi < _floors.length; fi++) { var f = _floors[fi]; if (x >= f.x0 && x <= f.x1 && z >= f.z0 && z <= f.z1) return true; } return false; }; function _floor_at(x, z, h) { // 선언된 바닥면은 지형 위에 얹힌다(최댓값 합성) for (var fi = 0; fi < _floors.length; fi++) { var f = _floors[fi]; if (x >= f.x0 && x <= f.x1 && z >= f.z0 && z <= f.z1 && f.y > h) h = f.y; } return h; } world.height_at = function (x, z) { var h; if (world._priv && world._priv.craft) h = world._priv.craft.height_at(x, z); // craft 복셀 지형 = 지면의 단일 정본 else if (!T) h = 0; else { h = _raw_height(x, z); if (T.spawn_flat) { var d2 = x * x + z * z; if (d2 < 324) { // 18m 밖 — 고원 영향 0 if (T.h0 == null) T.h0 = _raw_height(0, 0); var d = Math.sqrt(d2); var t = d <= 8 ? 0 : (d - 8) / 10; // 8m 안 완전 평탄, 8~18m smoothstep 블렌드 t = t * t * (3 - 2 * t); h = T.h0 + (h - T.h0) * t; } } } // ⚠️ 바닥면은 *최종* 단계다 — spawn_flat 블렌딩 전에 얹으면 고원 보간이 바닥값을 다시 섞어 // 실내 바닥이 기울어진다(선언된 평탄면은 지형 보간의 대상이 아니라 그 위의 확정 표면). return _floor_at(x, z, h); }; function _chunk_key(i, j) { return i + ',' + j; } function _build_chunk(i, j) { var size = T.chunk, seg = 30; var geo = new THREE_.PlaneGeometry(size, size, seg, seg); geo.rotateX(-Math.PI / 2); var ox = i * size, oz = j * size; var pos = geo.attributes.position; for (var v = 0; v < pos.count; v++) { pos.setY(v, world.height_at(pos.getX(v) + ox, pos.getZ(v) + oz)); } if (P.render_profile === 'realistic') { // Realistic branch: indexed grid + smooth per-vertex ramp LERP + ANALYTIC normals from the // height FUNCTION (central differences cross chunk borders seamlessly — computeVertexNormals // would light-seam every chunk edge because each chunk only sees its own faces). var _colv = new Float32Array(pos.count * 3); var _nrm = new Float32Array(pos.count * 3); var _c1 = new THREE_.Color(), _c2 = new THREE_.Color(), _bands = T.colors; var _eps = 1.2; for (var _vr = 0; _vr < pos.count; _vr++) { var _wx = pos.getX(_vr) + ox, _wz = pos.getZ(_vr) + oz; var _hx = world.height_at(_wx + _eps, _wz) - world.height_at(_wx - _eps, _wz); var _hz = world.height_at(_wx, _wz + _eps) - world.height_at(_wx, _wz - _eps); var _nl = 1 / Math.sqrt(_hx * _hx + 4 * _eps * _eps + _hz * _hz); _nrm[_vr * 3] = -_hx * _nl; _nrm[_vr * 3 + 1] = 2 * _eps * _nl; _nrm[_vr * 3 + 2] = -_hz * _nl; var _tr = clamp((pos.getY(_vr) / Math.max(T.amp, 0.25) + 1) / 2, 0, 0.999); // 정규화 하한 0.25 — near-flat(맵-세계 공허)에서 미세 노이즈가 풀 대비 얼룩으로 증폭되는 것을 차단(구 amp 하한 0.5 게임 = 무변화) var _fi = _tr * (_bands.length - 1), _i0 = Math.floor(_fi); _c1.set(_bands[_i0]); _c2.set(_bands[Math.min(_bands.length - 1, _i0 + 1)]); _c1.lerp(_c2, _fi - _i0); _colv[_vr * 3] = _c1.r; _colv[_vr * 3 + 1] = _c1.g; _colv[_vr * 3 + 2] = _c1.b; } geo.setAttribute('color', new THREE_.BufferAttribute(_colv, 3)); geo.setAttribute('normal', new THREE_.BufferAttribute(_nrm, 3)); var _rmesh = new THREE_.Mesh(geo, T.mat); _rmesh.receiveShadow = true; // shadow receiver (terrain never casts — depth-pass vertex budget) _rmesh.position.set(ox, 0, oz); scene.add(_rmesh); return _rmesh; } geo = geo.toNonIndexed(); // per-face 밴드색(경계 줄무늬 함정 회피 — indexed 금지) var p2 = geo.attributes.position; var col = new Float32Array(p2.count * 3); var tmp = new THREE_.Color(); var bands = T.colors; 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 = clamp((avg / Math.max(T.amp, 0.25) + 1) / 2, 0, 0.999); // 정규화 하한 = _tr 과 동일 캐논 tmp.set(bands[Math.floor(t * bands.length)]); for (var k = 0; k < 3; k++) { col[(f + k) * 3] = tmp.r; col[(f + k) * 3 + 1] = tmp.g; col[(f + k) * 3 + 2] = tmp.b; } } geo.setAttribute('color', new THREE_.BufferAttribute(col, 3)); var mesh = new THREE_.Mesh(geo, T.mat); mesh.receiveShadow = true; // 그림자 리시버(지형은 캐스터 아님 — 깊이 패스 정점 예산) mesh.position.set(ox, 0, oz); scene.add(mesh); return mesh; } // terrain({seed, amplitude, scale, chunk, radius, colors}) → { height_at } — 열린 세계의 지면 함수. world.terrain = function (opts) { if (world._priv && world._priv.craft) { try { console.error('[world] terrain: craft 복셀 월드 활성 — heightfield 지형과 혼용 불가(이중 지형). craft.world 가 지형을 소유한다'); } catch (e) {} return null; } // 지면 소유권 가드(craft 와 동형) — geo 실측 도시는 육지 슬래브+물이 지면을 소유: heightfield // 기복(최소 ±0.5m)이 보행면(0.12m) 위로 솟아 도로/보도를 삼키는 실사고 클래스의 구조 차단. if (world._priv && world._priv.geo_ground) { try { console.error('[world] terrain: geo 실측 도시 활성 — 지면 혼용 불가(기복이 도로를 매몰). geo.city 가 지면을 소유한다'); } catch (e) {} return null; } var o = opts || {}; // 멱등 가드 — 재호출은 구 청크를 dispose 없이 고아화(누수+이중 지형)하던 실틈. 언로드 경로와 // 동일 절차로 구 지형을 회수 후 교체하고 LOUD(controls/fps/attack 싱글턴 가드와 대칭). if (T) { try { console.error('[world] world.terrain called again — replacing the existing terrain (call it ONCE)'); } catch (e) {} T.chunks.forEach(function (mesh, key) { scene.remove(mesh); mesh.geometry.dispose(); _pool_release_chunk(key); }); T.chunks.clear(); T.mat.dispose(); } // 오용 키 LOUD — make.terrain 의 height 어휘(장식 지형)가 여기 오면 침묵 무시되던 혼돈 클래스. if (o.height != null && o.amplitude == null) { try { console.warn('[world] terrain: "height" is make.terrain\'s option — use amplitude'); } catch (e) {} } var prng = mulberry32((num(o.seed, 7) * 2654435761) >>> 0); T = { seed: num(o.seed, 7), // 하한 = ε(0-나눗셈만 차단) — 맵-세계 모드의 "완전 평탄 공허" 선언(amplitude≈0)을 존중해야 한다. // 구 하한 0.5 는 near-flat 선언을 침묵으로 구릉(±0.5m)으로 승격시켜, GLB 맵 바닥 관통·개구부 밖 // 이중 세계 노출의 원인이 됐다(camo 맵 모드 실측 2026-07-27). 일반 게임 기본값(7)은 불변. amp: clamp(num(o.amplitude, 7), 0.0001, 40), scale: clamp(num(o.scale, 130), 30, 600), chunk: clamp(num(o.chunk, 64), 24, 128), radius: clamp(Math.round(num(o.radius, 2)), 1, 3), // 색 기본값 = 활성 무드의 ground_ramp(P._pal) — "생략이 정답" 구조(하늘 ramp 오사용 흡수). // 단 이 구조는 mood-먼저 순서가 전제 — 위반 시 녹색 폴백이 침묵으로 구워지므로 LOUD. colors: (o.colors && o.colors.length >= 2) ? o.colors : ((P._pal && P._pal.ground_ramp) || (function () { try { console.warn('[world] terrain built before PLAYABLE.mood() — default green ramp baked in (call mood first)'); } catch (e) {} return ['#2B4A2E', '#3E6B33', '#5B8C3E', '#8FBF5A']; })()), // 스폰 고원 — 원점 반경(내 8m 완전 평탄 → 18m 블렌드)을 원점 높이로 수렴시켜 어떤 // seed/amplitude 조합에도 시작 프레이밍(플레이어 가시·카메라 비매몰)을 보장. opt-out 가능. spawn_flat: o.spawn_flat !== false, noise: new THREE_.SimplexNoise({ random: prng }), chunks: new Map(), // Shading follows the render profile — flat facets are the stylized signature; the realistic // profile supplies smooth analytic normals per vertex in _build_chunk instead. mat: new THREE_.MeshStandardMaterial({ vertexColors: true, flatShading: P.render_profile !== 'realistic', roughness: 0.92, metalness: 0 }), }; // 안개 = 스트리밍 마스크(무드/시간대가 색을 소유, world 는 거리만 동조). // near 0.25→0.55: 중경(~40m)부터 백화가 시작돼 "먼 풍경이 전부 하얗다" 실보고(2026-07-13) — // 마스크 기능(far 에서 완전 불투명 = 팝인 은폐)은 불변, 세탁 시작만 지연해 원경이 색을 유지한다. var far = T.chunk * (T.radius + 0.6); if (scene.fog) { scene.fog.near = far * 0.55; scene.fog.far = far; } // 초기 링 즉시 빌드 — 스트리밍은 플레이어/카메라 틱 의존이라, 플레이어 없는 씬(시네마틱)에서 // "지형 0청크 + 허공에 뜬 풀" 침묵 실패가 실측됨. 원점 주변은 생성 시점에 확보한다. for (var ci0 = -T.radius; ci0 <= T.radius; ci0++) { for (var cj0 = -T.radius; cj0 <= T.radius; cj0++) { var k0 = _chunk_key(ci0, cj0); T.chunks.set(k0, _build_chunk(ci0, cj0)); _pool_fill_chunk(ci0, cj0, k0); } } return { height_at: world.height_at }; }; function _stream_chunks(px, pz) { if (!T) return; var ci = Math.round(px / T.chunk), cj = Math.round(pz / T.chunk); var R = T.radius, UR = R + 1; // 히스테리시스: 로드 R / 언로드 R+1 (경계 진동 방지) // 언로드 T.chunks.forEach(function (mesh, key) { var p = key.split(','); if (Math.abs(p[0] - ci) > UR || Math.abs(p[1] - cj) > UR) { scene.remove(mesh); mesh.geometry.dispose(); T.chunks.delete(key); _pool_release_chunk(key); } }); // 로드 — 프레임당 1청크(스파이크 방지), 가까운 것부터 var best = null, bestD = 1e9; for (var di = -R; di <= R; di++) { for (var dj = -R; dj <= R; dj++) { var key = _chunk_key(ci + di, cj + dj); if (!T.chunks.has(key)) { var d = di * di + dj * dj; if (d < bestD) { bestD = d; best = [ci + di, cj + dj, key]; } } } } if (best) { T.chunks.set(best[2], _build_chunk(best[0], best[1])); _pool_fill_chunk(best[0], best[1], best[2]); } } // ── 충돌 레지스트리 — 원기둥 push-out + 공간 해시(셀 8m, 근접만 검사) ── // append-only 금지: 수집물 수집·핸들 제거·청크 언로드가 콜라이더를 회수한다(유령 실린더/재방문 적층 차단). var _colliders = new Map(); // cellKey → [{x,z,r}] function _cell(x, z) { return Math.floor(x / 8) + ',' + Math.floor(z / 8); } world.collider = function (x, z, r) { var c = { x: x, z: z, r: clamp(num(r, 0.5), 0.05, 8) }; var key = _cell(x, z); if (!_colliders.has(key)) _colliders.set(key, []); _colliders.get(key).push(c); return c; }; function _remove_collider(c) { if (!c) return; var arr = _colliders.get(_cell(c.x, c.z)); if (!arr) return; var i = arr.indexOf(c); if (i >= 0) arr.splice(i, 1); } // 축정렬 박스 콜라이더(build 벽 등) — y-밴드: 발이 y1 위(램프/바닥 위 보행)면 통과. // 공간 해시 공유(hx/hz ≤ 4 < 셀 8m — 3×3 이웃 스캔 도달 보장, 대형 실린더와 동일 전제). function _collider_box(o) { var c = { box: true, x: num(o.x, 0), z: num(o.z, 0), hx: clamp(num(o.hx, 1), 0.05, 4), hz: clamp(num(o.hz, 1), 0.05, 4), y0: num(o.y0, -1e9), y1: num(o.y1, 1e9) }; var key = _cell(c.x, c.z); if (!_colliders.has(key)) _colliders.set(key, []); _colliders.get(key).push(c); return c; } // 카메라-벽 세그먼트 차폐 — 플레이어→카메라 라인이 box collider(벽) 안을 지나면 true. 3인칭 카메라가 // 밀폐 실내 벽에 갇혀 "화면이 벽색으로 덮이는" 실보고 봉합(지형 회피만으론 실내 벽을 못 피함) — 거리- // 축소 루프가 소비해 벽에 막히면 줌인. 공간 해시(3×3 이웃)라 저비용. var _cam_occl_list = []; // 외부 카메라-차폐 제공자(맵 메시 등 비-collider 지오메트리, 2026-07-24) — 리그의 // 거리-축소 루프가 소비하는 seg-blocked 판정에 합성된다(box collider 만으론 GLB 무대 벽을 못 본다). function _cam_seg_blocked(px, py, pz, cx, cy, cz) { for (var oc = 0; oc < _cam_occl_list.length; oc++) { try { if (_cam_occl_list[oc](px, py, pz, cx, cy, cz)) return true; } catch (e) {} } for (var s = 1; s <= 4; s++) { var t = s / 5, x = px + (cx - px) * t, y = py + (cy - py) * t, z = pz + (cz - pz) * t; var gx = Math.floor(x / 8), gz = Math.floor(z / 8); for (var i = -1; i <= 1; i++) for (var j = -1; j <= 1; j++) { var arr = _colliders.get((gx + i) + ',' + (gz + j)); if (!arr) continue; for (var k = 0; k < arr.length; k++) { var cc = arr[k]; if (cc.box && x >= cc.x - cc.hx && x <= cc.x + cc.hx && z >= cc.z - cc.hz && z <= cc.z + cc.hz && y >= cc.y0 && y <= cc.y1) return true; } } } return false; } // 외부 천장 제공자(spawn_map, 2026-07-24) — 지면 채널의 수직 대칭. 물리의 착지식(p.y <= gy 스냅)은 // *상승 중에도* 걸리므로, y-인지 지면 제공자 단독으로는 "점프 상승 중 발이 슬랩 상단 허용대에 진입 // → 위층을 지면으로 채택 → 슬랩 관통 상향 스냅"의 층-사다리가 구조적으로 열린다(실보고: 천장 밖 // 이탈). 천장 채널이 상승을 슬랩 하면에서 멈추면 그 진입 자체가 불가능해진다(근본 봉합). var _ceil_ext_list = []; function _ceil_ext_all(x, z, y) { // 머리 위 최저 천장면 y 또는 null(개방 하늘/제공자 부재) var best = null; for (var ci = 0; ci < _ceil_ext_list.length; ci++) { var c1 = _ceil_ext_list[ci](x, z, y); if (c1 != null && (best == null || c1 < best)) best = c1; } return best; } var _ground_ext_list = []; // 다중 외부-지면 제공자(build) — 단일 필드(obby)와 max 합성 function _ground_ext_all(x, z, y) { var best = null; if (world._priv && world._priv.ground_ext) { var g0 = world._priv.ground_ext(x, z, y); if (g0 != null) best = g0; } for (var gi = 0; gi < _ground_ext_list.length; gi++) { var g1 = _ground_ext_list[gi](x, z, y); if (g1 != null && (best == null || g1 > best)) best = g1; } return best; } // ── 지면의 단일 정본 질의 ──────────────────────────────────────────────────────── // ⚠️ 지면 표현은 둘이다: terrain 필드(height_at, 2D)와 외부 제공자(_ground_ext_all, y-의존 다층 — // obby 플랫폼·build 피스·실내 슬랩 바닥). 문제는 *합성이 플레이어 물리 루프 안에만* 있었다는 것: // 아키타입이 슬랩을 ground_ext 로 성실히 등록해도 카메라 등 height_at 소비자는 슬랩 아래 terrain 을 // 지면으로 알아, 플레이어는 슬랩 위를 걷는데 카메라는 그 아래로 내려갔다(실보고 "바닥 아래가 보임"). // height_at 자체를 합성하지 않는 이유: (x,z)→h 는 다층 기하를 표현할 수 없다(어느 층인지 미결정). // 따라서 y 를 아는 소비자는 전부 이 질의를 쓴다 — 표현의 이중성은 남기되 *답은 하나*로 만든다. world.ground_at = function (x, z, y) { var g = world.height_at(x, z); var e = _ground_ext_all(x, z, y); return (e != null && e > g) ? e : g; }; function _push_out(pos, radius) { var cx = Math.floor(pos.x / 8), cz = Math.floor(pos.z / 8); for (var i = -1; i <= 1; i++) { for (var j = -1; j <= 1; j++) { var arr = _colliders.get((cx + i) + ',' + (cz + j)); if (!arr) continue; for (var k = 0; k < arr.length; k++) { var c = arr[k]; if (c.box) { // 축정렬 박스 — y-밴드 통과(피스 위 보행) + 최소 침투축 해소 if (pos.y > c.y1 - 0.2 || pos.y < c.y0 - 1.6) continue; var bdx = pos.x - c.x, bdz = pos.z - c.z; var px = c.hx + radius - Math.abs(bdx), pz = c.hz + radius - Math.abs(bdz); if (px > 0 && pz > 0) { if (px < pz) pos.x += (bdx >= 0 ? 1 : -1) * px; else pos.z += (bdz >= 0 ? 1 : -1) * pz; } continue; } var dx = pos.x - c.x, dz = pos.z - c.z; var rr = radius + c.r; var d2 = dx * dx + dz * dz; if (d2 > 0.000001 && d2 < rr * rr) { var d = Math.sqrt(d2), push = (rr - d) / d; pos.x += dx * push; pos.z += dz * push; } } } } } // ── world-pack 스폰 — manifest 1회 로드, GLB 캐시, 비동기를 동기형 핸들로 흡수 ── var _manifest = null, _manifest_pending = []; var _glb_cache = new Map(); // file → {gltf} | {waiters:[]} var _loader = null; var _pack_base = null; function _vendor_base() { if (_pack_base) return _pack_base; var scripts = document.getElementsByTagName('script'); for (var i = 0; i < scripts.length; i++) { var m = /^(.*)\/vendor\/three-playable-[^/]+$/.exec(scripts[i].src || ''); if (m) { _pack_base = m[1] + '/vendor/world-pack/'; return _pack_base; } } _pack_base = '/vendor/world-pack/'; return _pack_base; } // CC-BY/MIT 에셋 크레딧 칩 — 작은 ⓘ 버튼 + 토글 목록(작가·라이선스 링크). 상시 버튼이므로 // backdrop-filter 금지 캐논 준수(불투명 rgba 배경). CC0-only 게임에는 아무것도 만들지 않는다. var _credits_done = false; function _credits_chip(models) { if (_credits_done || !models) return; var rows = []; for (var k in models) { var lic = models[k] && models[k].license; if (lic && lic.type) rows.push({ name: k, author: lic.author || lic.pack || '', type: lic.type, url: lic.url || '' }); } if (!rows.length) return; _credits_done = true; var btn = document.createElement('div'); btn.textContent = 'ⓘ'; btn.setAttribute('style', 'position:fixed;left:8px;bottom:8px;z-index:40;width:20px;height:20px;line-height:20px;text-align:center;border-radius:50%;background:rgba(0,0,0,.45);color:rgba(255,255,255,.75);font:11px/20px sans-serif;cursor:pointer;user-select:none;'); var panel = document.createElement('div'); var html = '
asset credits
'; for (var i = 0; i < rows.length; i++) { var r = rows[i]; html += '
' + r.name.replace(/' + String(r.type).replace(/
'; } panel.innerHTML = html; panel.setAttribute('style', 'display:none;position:fixed;left:8px;bottom:34px;z-index:40;max-width:64vw;max-height:40vh;overflow:auto;background:rgba(0,0,0,.72);color:rgba(255,255,255,.85);font:11px/1.6 sans-serif;padding:8px 10px;border-radius:8px;'); btn.addEventListener('click', function () { panel.style.display = panel.style.display === 'none' ? 'block' : 'none'; }); document.body.appendChild(btn); document.body.appendChild(panel); } function _with_manifest(fn) { if (_manifest) return fn(_manifest); _manifest_pending.push(fn); if (_manifest_pending.length > 1) return; // v2: vendor 는 1y immutable 캐시 — 매니페스트에 모델을 *추가*할 때는 새 파일명으로 버스팅 // (구 게임은 구 파일명을 영구히 계속 보고, 신 셸은 아래 fetch 의 최신 파일명(현재 v3)을 본다. 구 파일 삭제 금지.) // ※ 감사 확정(2026-07-10): manifest.v3.json 은 v2 와 바이트 동일(내용 무변경 버스트 — lock // 정책상 삭제 불가, 실해 없음). 다음 *실제 내용 변경* 은 v4 로 버스트하고 이 주석을 갱신할 것. fetch(_vendor_base() + 'manifest.v3.json') .then(function (r) { return r.json(); }) .then(function (m) { // 확장 에셋 병합 — 생성기가 이 게임 전용으로 인라인 주입한 per-game 매니페스트 // ( = lazy-카탈로그에서 선택·계측된 엔트리, 동일 계약: // file/h/radius/clips/skinned). 이름 충돌 시 기본 팩이 이긴다(검증 깊이 우선). var _ext = window['__PLAYABLE_'+'EXTRA_MANIFEST__']; if (_ext && _ext.models) m.models = Object.assign({}, _ext.models, m.models || {}); // 어트리뷰션 크레딧 칩 — license.type 이 있는 확장 에셋(CC-BY/MIT)이 이 게임에 실재할 때만 // 노출(카탈로그 대다수는 CC0 = type 부재 = 종전과 동일하게 무표기). 표기는 라이선스 의무. try { _credits_chip(_ext && _ext.models); } catch (_) {} _manifest = m; _manifest_pending.forEach(function (f) { try { f(m); } catch (e) { try { console.error('[world] spawn init failed: ' + (e && e.message)); } catch (_) {} } // 침묵 삼킴 금지(LOUD) }); _manifest_pending = []; }) .catch(function (e) { try { console.error('[world] manifest load failed: ' + (e && e.message)); } catch (_) {} if (window.__playable_beacon) window.__playable_beacon('manifest_fail', e && e.message); _manifest_pending = []; }); } function _load_glb(file, cb) { var ent = _glb_cache.get(file); if (ent && ent.gltf) return cb(ent.gltf); if (ent) { ent.waiters.push(cb); return; } ent = { gltf: null, waiters: [cb] }; _glb_cache.set(file, ent); if (!_loader) _loader = new THREE_.GLTFLoader(); // 경로 규약: 기본 팩 = 상대 파일명(world-pack/ 하위), 확장 에셋 = '/'-루트 경로(/vendor-ext/… // — 같은 origin 의 다른 프리픽스). origin 은 vendor base 에서 재유도(도메인 하드코딩 금지). var _url = file.charAt(0) === '/' ? _vendor_base().replace(/\/vendor\/world-pack\/$/, '') + file : _vendor_base() + file; _loader.load(_url, function (gltf) { // ── 재질 정규화(로드당 1회, 클론은 재질 공유라 전 인스턴스 자동 전파) ── // FBX→glTF 변환기(FBX2glTF 등)는 Phong 재질에 metalness 0.2~0.4 를 주입한다. 이 씬의 // 환경은 무채색 RoomEnvironment(0.35)뿐이라 metalness 는 "디퓨즈 (1-m) 손실 + 회색 반사" // = 원색을 어둡고 탁하게만 만든다(2026-07-11 여성 캐릭터 피부 흑화 실사고 — MTL 원본 // Kd 와 GLB baseColor 는 동일했고 왜곡은 전적으로 metalness×무채색 IBL). 로우폴리 // flat-color 에셋의 의도색 = 디퓨즈이므로 make.matte 와 동일 정책(metalness 0)으로 // 정합화. metalnessMap 이 실존하는 재질만 저작 의도로 보고 보존. gltf.scene.traverse(function (n) { if (!n.isMesh || !n.material) return; var mats = Array.isArray(n.material) ? n.material : [n.material]; for (var mi = 0; mi < mats.length; mi++) { if (mats[mi] && mats[mi].isMeshStandardMaterial && !mats[mi].metalnessMap && mats[mi].metalness > 0) mats[mi].metalness = 0; } }); // ── 원치수 1회 실측(파일당) — spawn/hold/scatter 가 클론마다 전체 트리 Box3 를 재측정하던 // 중복 순회의 봉합(클론 = 원본과 동치수라 유일-결정, GTA archetype 의 "치수는 정의가 소유" // 원리). updateMatrixWorld(true) 필수: detached 씬은 KHR_mesh_quantization dequant 노드 // 변환이 미전파된 원시 좌표를 측정한다(2026-07-10 캐릭터 4.8cm 축소 실사고 계약 승계). gltf.scene.updateMatrixWorld(true); var _db = new THREE_.Box3().setFromObject(gltf.scene); gltf._dims = { h: Math.max(0.0001, _db.max.y - _db.min.y), min_y: _db.min.y }; // 셰이더 프리컴파일(가능 시) — 스폰 첫-렌더 프레임의 컴파일 스톨(재질 종류 × 수십 ms 히치)을 // 로드 직후 비동기로 선지불(KHR_parallel_shader_compile). 실패는 무해(기존 지연 컴파일 폴백). try { var _r = P.three && P.three.renderer; if (_r && _r.compileAsync) _r.compileAsync(gltf.scene, cam, scene).catch(function () {}); } catch (e) {} ent.gltf = gltf; ent.waiters.forEach(function (w) { try { w(gltf); } catch (e) {} }); ent.waiters = []; }, undefined, function (e) { try { console.error('[world] GLB load failed ' + file + ': ' + (e && e.message)); } catch (_) {} if (window.__playable_beacon) window.__playable_beacon('glb_fail', file + ': ' + (e && e.message)); _glb_cache.delete(file); // dead-waiter 영구 오염 차단 — 이후 spawn 이 재시도 가능 }); } var _mixers = []; // spawn(name, {at:{x,z}, h?, anim?, collide?}) → 핸들 {obj, play(alias,{fade}), pos, ready, radius} // obj 는 즉시 유효한 Group(placeholder) — GLB 도착 시 내부에 채워짐. play 는 로드 전 호출 시 큐잉. world.spawn = function (name, opts) { var o = opts || {}; var group = new THREE_.Group(); var at = P._nv2(o.at, 0, 0); // 객체/배열 수용(L2) var x = at.x, z = at.z; group.position.set(x, world.height_at(x, z), z); scene.add(group); var handle = { obj: group, ready: false, radius: 0.5, name: name, _mixer: null, _actions: {}, _queued: null, _queued_once: null, _cur: null, _oneshot: null, _held: null, _spec: null, _on_ready: null, play: function (alias, po) { if (!this._mixer) { this._queued = [alias, po]; return; } if (this._oneshot) return; // 원샷(공격/피격) 재생 중 — 로코모션 전환은 종료 후 var act = this._actions[alias]; if (!act || act === this._cur) return; var fade = num(po && po.fade, 0.25); if (this._cur) this._cur.fadeOut(fade); act.reset().fadeIn(fade).play(); this._cur = act; }, // 원샷 클립(공격·피격·사망·이모트) — 정본: reset()+LoopOnce+clampWhenFinished, mixer 'finished' // 는 mixer 단위 이벤트라 e.action 대조 필수(three#11943 무한루프 함정), 종료 후 로코모션은 // 월드 틱이 *현재 속도에 맞는* 클립으로 복귀(항상-idle 복귀 = 한 프레임 튐 함정). play_once: function (alias, po) { var self = this; if (!self._mixer) { // 단일 큐 슬롯 — 덮어쓸 때 이전 항목의 on_done 을 폐기하지 않고 즉시 발화(진행 훅 유실 차단) if (self._queued_once && self._queued_once[1] && self._queued_once[1].on_done) { try { self._queued_once[1].on_done(); } catch (e) {} } self._queued_once = [alias, po]; return; } var act = self._actions[alias]; if (!act) { if (po && po.on_done) { try { po.on_done(); } catch (e) {} } return; } var fade = num(po && po.fade, 0.12); act.setLoop(THREE_.LoopOnce, 1); act.clampWhenFinished = true; act.timeScale = num(po && po.speed, 1); if (self._cur && self._cur !== act) self._cur.fadeOut(fade); act.reset().fadeIn(fade).play(); self._oneshot = act; self._cur = act; var mixer = self._mixer; function on_fin(e) { if (e.action !== act) return; mixer.removeEventListener('finished', on_fin); if (self._oneshot === act) self._oneshot = null; act.fadeOut(0.2); if (self._cur === act) self._cur = null; // 다음 play() 가 새 클립을 fadeIn if (po && po.on_done) { try { po.on_done(); } catch (e2) {} } } mixer.addEventListener('finished', on_fin); return act; }, // 아이템 장착 — manifest 의 hand 본(캐릭터별 검증 명칭)에 아이템 GLB 부착. 무기 든 캐릭터의 // 유일 계약(로직이 본 계층을 뒤지는 코드를 재발명하지 않음). hold: function (item_name, ho) { var self = this; ho = ho || {}; _with_manifest(function (man) { var ispec = man.models && man.models[item_name]; if (!ispec) { try { console.error('[world] hold: unknown item "' + item_name + '"'); } catch (e) {} return; } _load_glb(ispec.file, function (gltf) { var inst = gltf.scene.clone(true); inst.scale.setScalar(num(ho.h, ispec.h || 0.5) / gltf._dims.h); // 원치수 = _load_glb 1회 실측 공유 function attach() { // 주의 2건: ① 장착점 노드(handslot 등)는 skin joints 밖이면 three 가 Bone 아닌 // Object3D 로 로드한다 — isBone 필터 금지. ② GLTFLoader 는 노드명을 sanitize 한다 // ('handslot.r' → 'handslotr', PropertyBinding 경로 문자 제거) — 정규화 비교 필수. function bn(s) { return String(s || '').replace(/[^a-zA-Z0-9_]/g, '').toLowerCase(); } var want = bn(ho.bone || (self._spec && self._spec.hand) || ''); var target = null, loose = null; self.obj.traverse(function (n) { if (target) return; var nn = bn(n.name); if (want && nn === want) { target = n; return; } if (!want && nn && /hand|palm|fist|slot|wrist/.test(nn)) { if (/(r$|right)/.test(nn)) target = n; else if (!loose) loose = n; } }); target = target || loose; if (!target) { // 최종 폴백 — 본 없는 모델(로봇·이형)도 장착은 성립해야 한다(보편 동사 계약): // 몸 전방 오프셋 그룹에 부착(손에 쥔 모양은 아니어도 "장착됨"은 보장). var fg = new THREE_.Group(); fg.position.set(0.28, (num(self._spec && self._spec.h, 1.4)) * 0.55, 0.22); self.obj.add(fg); target = fg; try { console.warn('[world] hold: no hand bone — body-mount fallback'); } catch (e) {} } if (self._held && self._held.parent) self._held.parent.remove(self._held); // 부모(본) 월드 스케일 보정 — 캐릭터 정규화 스케일이 본 하위 아이템에 곱해져 // 원단위 큰 모델(Quaternius 계열)에서 검이 cm 급으로 소멸하는 잠복 결함의 봉합. // 최종 월드 높이 = 목표 h 를 전 모델에서 균일 보장(KayKit 은 ws≈1이라 무회귀). var _ws = new THREE_.Vector3(); target.getWorldScale(_ws); inst.scale.multiplyScalar(1 / Math.max(0.0001, Math.abs(_ws.y))); target.add(inst); self._held = inst; } if (self.ready) attach(); else (self._on_ready = self._on_ready || []).push(attach); }); }); return self; }, pos: group.position, // 명시적 제거 계약 — scene 제거 + 콜라이더/mixer 회수(유령 충돌·mixer 무한 성장 차단) remove: function () { this._removed = true; if (this._collider) { _remove_collider(this._collider); this._collider = null; } if (this._mixer) { var mi = _mixers.indexOf(this._mixer); if (mi >= 0) _mixers.splice(mi, 1); } if (this.obj.parent) this.obj.parent.remove(this.obj); }, }; _with_manifest(function (man) { var spec = man.models && man.models[name]; if (!spec && man.models) { // 유일-결정 정규화 1단(trim/소문자/공백·hyphen→'_') — 근접-오탈자 게이트 밖 잔여(대소문자 // 표기 차)만 흡수. 그 이상(편집거리류)은 오배정 위험이라 금지, 흡수는 LOUD+비콘으로 관측. var _nk = String(name).trim().toLowerCase().replace(/[\s-]+/g, '_'); if (_nk !== name && man.models[_nk]) { try { console.warn('[world] spawn name normalized: "' + name + '" → "' + _nk + '"'); } catch (e) {} if (window.__playable_beacon) window.__playable_beacon('spawn_normalized', String(name).slice(0, 60)); spec = man.models[_nk]; } } if (!spec) { try { console.error('[world] unknown model "' + name + '" — available: ' + Object.keys((man && man.models) || {}).join(', ')); } catch (e) {} if (window.__playable_beacon) window.__playable_beacon('spawn_unknown', String(name).slice(0, 60)); // 생성 게이트(근접-오탈자)가 못 잡은 잔여 — 운영 관측 // 침묵-투명 금지: 미지 모델은 가시적 오작동(마젠타 와이어프레임)으로 — 투명 몹이 // FSM 풀가동으로 플레이어를 때리는 "유령 게임"보다 진단 가능성이 우선. try { var err_mesh = new THREE_.Mesh( new THREE_.BoxGeometry(0.6, 1.4, 0.6), new THREE_.MeshBasicMaterial({ color: 0xff00ff, wireframe: true })); err_mesh.position.y = 0.7; group.add(err_mesh); } catch (e2) {} return; } handle.radius = num(o.radius, spec.radius || 0.5); handle._spec = spec; _load_glb(spec.file, function (gltf) { var inst = spec.skinned ? THREE_.SkeletonUtils.clone(gltf.scene) : gltf.scene.clone(true); // 스케일 정규화 — 원치수는 _load_glb 의 파일당 1회 실측(gltf._dims)을 공유(클론 재측정 // 불요 — 클론 = 원본 동치수. 4.8cm dequant 실사고 계약은 로드 시점 측정이 승계). var _dims = gltf._dims; var target = num(o.h, spec.h || 1); if (o._fit_h && target > o._fit_h) target = o._fit_h; // 과대 에셋 downscale-only 상한(스왑 경로 전용 내부 옵션) var s = target / _dims.h; inst.scale.setScalar(s); inst.position.y = -_dims.min_y * s; // 발바닥 = 지면 inst.traverse(function (_sn) { if (_sn.isMesh) _sn.castShadow = true; }); // 그림자 캐스터 화이트리스트(동적 액터·소품 — 스캐터 인스턴스는 제외) group.add(inst); if (spec.skinned && gltf.animations && gltf.animations.length) { handle._mixer = new THREE_.AnimationMixer(inst); var clips = spec.clips || {}; for (var alias in clips) { var clip = THREE_.AnimationClip.findByName(gltf.animations, clips[alias]); if (clip) handle._actions[alias] = handle._mixer.clipAction(clip); } _mixers.push(handle._mixer); if (handle._queued) { handle.play(handle._queued[0], handle._queued[1]); handle._queued = null; } else if (handle._actions.idle) handle.play('idle'); if (handle._queued_once) { var q1 = handle._queued_once; handle._queued_once = null; handle.play_once(q1[0], q1[1]); } } handle.ready = true; if (handle._on_ready) { handle._on_ready.forEach(function (f) { try { f(); } catch (e) {} }); handle._on_ready = null; } // 소형 prop(h<0.5 — 코인·포션·음식류)은 기본 무충돌: 수집물/드랍의 정본 경로가 // 킬 지점마다 보이지 않는 실린더를 남기는 침묵 열화의 구조 차단. collide:true 로 opt-in. var default_collide = !(spec.cat === 'prop' && num(spec.h, 1) < 0.5); if (!handle._removed && !o._no_collider && (o.collide === true || (o.collide !== false && default_collide))) { handle._collider = world.collider(group.position.x, group.position.z, handle.radius); } if (!handle._removed && !o._no_uni && !o._no_collider) _uni_wire(handle, spec, name, o); // 보편 동사 자동 배선(_no_collider = 몹/교체체 등 내부 스폰 — 제외) }); }); return handle; }; // ── acquire(name, {h}, cb) — 지형·충돌·씬 비결합 순수 에셋 로더(2026-07-24, AR 앵커 소비용) ── // spawn 과 달리 씬에 넣지 않고 호출자가 소유: cb({obj, mixer, actions, play(alias)}). 믹서 tick 은 // 호출자 책임(_mixers 미등록 — world 루프와 이중 update 방지). 미지 이름 = spawn 동일 정책(마젠타+비콘). world.acquire = function (name, o, cb) { o = o || {}; _with_manifest(function (man) { var spec = man.models && man.models[name]; if (!spec && man.models) { var _nk = String(name).trim().toLowerCase().replace(/[\s-]+/g, '_'); if (_nk !== name && man.models[_nk]) spec = man.models[_nk]; } if (!spec) { try { console.error('[world] acquire unknown model "' + name + '"'); } catch (e) {} if (window.__playable_beacon) window.__playable_beacon('spawn_unknown', 'acquire:' + String(name).slice(0, 50)); var g0 = new THREE_.Group(); var err_mesh = new THREE_.Mesh(new THREE_.BoxGeometry(0.5, 0.8, 0.5), new THREE_.MeshBasicMaterial({ color: 0xff00ff, wireframe: true })); err_mesh.position.y = 0.4; g0.add(err_mesh); cb({ obj: g0, mixer: null, actions: {}, play: function () {} }); return; } _load_glb(spec.file, function (gltf) { var inst = spec.skinned ? THREE_.SkeletonUtils.clone(gltf.scene) : gltf.scene.clone(true); var _dims = gltf._dims; var s = num(o.h, spec.h || 1) / _dims.h; var g = new THREE_.Group(); inst.scale.setScalar(s); inst.position.y = -_dims.min_y * s; // 발바닥 = 그룹 원점 g.add(inst); var mixer = null, actions = {}; if (spec.skinned && gltf.animations && gltf.animations.length) { mixer = new THREE_.AnimationMixer(inst); var clips = spec.clips || {}; for (var alias in clips) { var clip = THREE_.AnimationClip.findByName(gltf.animations, clips[alias]); if (clip) actions[alias] = mixer.clipAction(clip); } } var cur = null; function play(alias) { var a2 = actions[alias]; if (!a2 || a2 === cur) return; if (cur) cur.fadeOut(0.18); a2.reset().fadeIn(0.18).play(); cur = a2; } if (actions.idle) play('idle'); cb({ obj: g, mixer: mixer, actions: actions, play: play }); }); }); return null; }; // ── spawn_map(name, {at, y, yaw, ground}) — cat='map' 완성형 맵 스폰(2026-07-24, 맵 라이브러리 1급 소비) ── // spawn 과 계약이 다르다(맵 = 장애물이 아니라 '환경'): 실린더 콜라이더·보편동사 배선 없음, 원치수 // (h = spec.h 실축) 고정 스폰. 핸들이 제공하는 기구 4종이 아키타입 배선의 전부: // ① 다층 지면 — 메시 하향 레이캐스트 y-인지 제공자 자동 등록(ground!==false, 계단·복층 보행. // 본 파일은 ground-ext 게이트 허용 목록 — 다층은 (x,z)→h 로 표현 불가라 제공자가 정본) // ② bounds — GLB 도착 후 실측 AABB({x0..z1,y0,y1}, on_ready 로 통지) // ③ ray_t / blocked — 맵 메시 구간 차폐 판정(발각 LOS·사격 월핵·이동 벽막이 공용) // ④ surface_color_at — 표면색 SSoT: material.color × 텍스처 UV 픽셀 리드백(위장·의태·스포이드. // sRGB 이미지 × 선형 color 곱 = 근사지만, 의태 틴트와 판정이 같은 함수를 쓰므로 자기일관) world.spawn_map = function (name, opts) { var o = opts || {}; // closed = "맵이 세계 그 자체" 선언 — voxel 도시/craft 와 같은 세계-소유권 1급. nature 의 자동 // 바닥 드레싱(무지시 초원·클러터·구름 자동 주입)이 이 표면을 소비해 스킵한다(동기 세팅이라 // ~0.8s 지연 발화하는 자동 드레싱보다 항상 선행). 미선언(closed 없음) = 열린 세계 장식물 문법 유지. if (o.closed) world._priv.map_world = true; var group = new THREE_.Group(); var at = P._nv2(o.at, 0, 0); group.position.set(at.x, num(o.y, 0), at.z); group.rotation.y = num(o.yaw, 0); scene.add(group); var _rc = new THREE_.Raycaster(); var _DOWN = new THREE_.Vector3(0, -1, 0); var _UP = new THREE_.Vector3(0, 1, 0); var _o3 = new THREE_.Vector3(), _d3 = new THREE_.Vector3(); var _g_cache = new Map(); // 지면 질의 메모(0.1m 격자) — 동일 프레임 반복 질의 흡수 var _tex_px = new Map(); // 텍스처 uuid → 64² 다운샘플 캔버스(색 샘플 = 국소 평균, 위장 판정에 적합) var handle = { obj: group, ready: false, name: name, meshes: [], bounds: null, _cbs: [], _g_off: null, on_ready: function (f) { if (this.ready) { try { f(this); } catch (e) {} } else this._cbs.push(f); }, // 구간 차폐: 첫 메시 히트의 거리 t(미히트 = -1). blocked 는 불리언 축약. ray_t: function (ox, oy, oz, dx, dy, dz, maxT) { if (!this.ready) return -1; var l = Math.sqrt(dx * dx + dy * dy + dz * dz) || 1; _o3.set(ox, oy, oz); _d3.set(dx / l, dy / l, dz / l); _rc.set(_o3, _d3); _rc.far = num(maxT, 60); var hits = _rc.intersectObjects(this.meshes, false); return hits.length ? hits[0].distance : -1; }, blocked: function (ax, ay, az, bx, by, bz) { var dx = bx - ax, dy = by - ay, dz = bz - az; var d = Math.sqrt(dx * dx + dy * dy + dz * dz); if (d < 0.0001) return false; var t = this.ray_t(ax, ay, az, dx, dy, dz, d); return t >= 0 && t < d; }, // 표면색: (origin, dir) 레이의 첫 히트에서 material.color × map(uv) 픽셀 → '#rrggbb' | null surface_color_at: function (ox, oy, oz, dx, dy, dz, maxT) { if (!this.ready) return null; var l = Math.sqrt(dx * dx + dy * dy + dz * dz) || 1; _o3.set(ox, oy, oz); _d3.set(dx / l, dy / l, dz / l); _rc.set(_o3, _d3); _rc.far = num(maxT, 8); var hits = _rc.intersectObjects(this.meshes, false); if (!hits.length) return null; var hit = hits[0]; var mat = hit.object.material; if (Array.isArray(mat)) mat = mat[(hit.face && hit.face.materialIndex) || 0] || mat[0]; var r = 1, g = 1, b = 1; if (mat && mat.color) { r = mat.color.r; g = mat.color.g; b = mat.color.b; } var tex = mat && mat.map; if (tex && tex.image && hit.uv) { var ent = _tex_px.get(tex.uuid); if (ent === undefined) { ent = null; try { var cv = document.createElement('canvas'); cv.width = cv.height = 64; var c2 = cv.getContext('2d', { willReadFrequently: true }); c2.drawImage(tex.image, 0, 0, 64, 64); ent = c2; } catch (e) { /* CORS 등 리드백 불가 텍스처 = material.color 단독(캐시로 재시도 차단) */ } _tex_px.set(tex.uuid, ent); } if (ent) { var u = ((hit.uv.x % 1) + 1) % 1, v = ((hit.uv.y % 1) + 1) % 1; var vv = tex.flipY ? (1 - v) : v; // glTF 텍스처 = flipY:false(uv v 원점 상단 그대로) var px = ent.getImageData(Math.min(63, Math.floor(u * 64)), Math.min(63, Math.floor(vv * 64)), 1, 1).data; r *= px[0] / 255; g *= px[1] / 255; b *= px[2] / 255; } } function b2(x2) { var s2 = Math.round(clamp(x2 * 255, 0, 255)).toString(16); return s2.length < 2 ? '0' + s2 : s2; } return '#' + b2(r) + b2(g) + b2(b); }, remove: function () { this._removed = true; if (this._g_off) { this._g_off(); this._g_off = null; } if (this._c_off) { this._c_off(); this._c_off = null; } if (this._cam_off) { this._cam_off(); this._cam_off = null; } if (this.obj.parent) this.obj.parent.remove(this.obj); _g_cache.clear(); _tex_px.clear(); }, }; function _ground_fn(x, z, y) { var b = handle.bounds; if (!b || x < b.x0 || x > b.x1 || z < b.z0 || z > b.z1) return null; // 질의 평면 y 위 0.45m 에서 하향(계단 스텝만 등반 — "발밑-이하 최고면" 캐논(물리 2558) 준수. // 0.6 은 점프 상승 중 위층 슬랩 상단 허용대 진입 = 층-사다리의 한 축이었다. 천장 채널과 2중 봉합) var oy = Math.min((y == null ? b.y1 : y) + 0.45, b.y1 + 0.6); var key = ((x * 10) | 0) + ',' + ((z * 10) | 0) + ',' + ((oy * 5) | 0); var c = _g_cache.get(key); if (c !== undefined) return c; _o3.set(x, oy, z); _rc.set(_o3, _DOWN); _rc.far = oy - b.y0 + 1; var hits = _rc.intersectObjects(handle.meshes, false); var r = hits.length ? hits[0].point.y : null; if (_g_cache.size > 4096) _g_cache.clear(); _g_cache.set(key, r); return r; } function _ceil_fn(x, z, y) { // 머리 위 최저 천장면(상승 클램프 소비) — bounds 밖/지붕 위 = null var b = handle.bounds; if (!b || x < b.x0 || x > b.x1 || z < b.z0 || z > b.z1 || y >= b.y1) return null; _o3.set(x, y + 0.2, z); _rc.set(_o3, _UP); _rc.far = b.y1 - y + 1; var hits = _rc.intersectObjects(handle.meshes, false); if (hits.length) return hits[0].point.y; // closed = 폐쇄 실내 세계 선언(옵트인): 하늘 열린 지점(지붕 구멍·저작 공백)에서도 상승 자체를 // 봉쇄 — 실내 맵의 수직 이탈 경로를 결정론적으로 닫는다(보행·소점프는 불변, 통과 상승만 불가). return o.closed ? y + 1.9 : null; } _with_manifest(function (man) { var spec = man.models && man.models[name]; if (!spec) { try { console.error('[world] spawn_map: unknown map "' + name + '"'); } catch (e) {} if (window.__playable_beacon) window.__playable_beacon('spawn_unknown', 'map:' + String(name).slice(0, 50)); return; // 미지 맵 = 무대 부재 LOUD — 마젠타 박스는 환경 스케일에 부적합(비콘+콘솔로 관측) } _load_glb(spec.file, function (gltf) { if (handle._removed) return; var inst = gltf.scene.clone(true); var _dims = gltf._dims; var s = num(spec.h, 1) / _dims.h; // 원치수 복원(h = 인제스트 실측 실제높이) — 임의 h 리스케일 미지원 inst.scale.setScalar(s); inst.updateMatrixWorld(true); // 필수: detached 측정 전 dequant 노드 변환 전파(load_model 캐논) var pre = new THREE_.Box3().setFromObject(inst); // 재중심화 — 맵 GLB 피벗은 임의(코너/오프셋 저작 다수): "바닥면 중심 = group 원점"이 배치 계약 // (소비자는 bounds 만 신뢰하면 되지만, 카메라 볼륨·스폰이 (0,0) 중심 가정이므로 중심 보장이 정본) inst.position.set(-(pre.min.x + pre.max.x) / 2, -pre.min.y, -(pre.min.z + pre.max.z) / 2); inst.traverse(function (n2) { if (!n2.isMesh) return; n2.castShadow = true; n2.receiveShadow = true; // DoubleSide 정규화 — 실내 저작 GLB 는 노멀 방향이 제각각(지붕=상향 단면 등)이라 단면 // 레이캐스트가 방향 의존으로 실패한다(천장/차폐/지면 전 질의의 신뢰 조건). 렌더 부수효과 // = 외벽이 밖에서도 보임(세트 밖 시점 차단과 정합). var mm9 = n2.material; (Array.isArray(mm9) ? mm9 : [mm9]).forEach(function (m9) { if (m9 && m9.side !== THREE_.DoubleSide) { m9.side = THREE_.DoubleSide; m9.needsUpdate = true; } }); handle.meshes.push(n2); }); group.add(inst); group.updateMatrixWorld(true); var box = new THREE_.Box3().setFromObject(group); handle.bounds = { x0: box.min.x, x1: box.max.x, y0: box.min.y, y1: box.max.y, z0: box.min.z, z1: box.max.z }; // 지배 보행면 앵커(opt-in anchor:'dominant', 2026-07-29 층별 앵커) — 재중심화는 bbox 최저점을 // 바닥으로 앉히므로, 기초/지하 샤프트가 깊은 저작(poolrooms: min.y=-12.8)은 주 보행층이 공중 // 10~20m 에 뜬다(부양 실측). 실측 히스토그램으로 지배 보행면을 찾아 그 면이 배치 y(o.y)에 // 오도록 전체를 하강. 일반 맵은 지배면≈바닥 슬랩 상면이라 델타 수십 cm = 착지 정밀화(무해). if (o.anchor === 'dominant') (function () { var b7 = handle.bounds, hist7 = new Map(), n7 = 0; var rc7 = new THREE_.Raycaster(); rc7.far = (b7.y1 - b7.y0) + 2; var pr7 = mulberry32(0x5eed); for (var s7 = 0; s7 < 90; s7++) { var x7 = b7.x0 + 1 + pr7() * Math.max(0.1, b7.x1 - b7.x0 - 2); var z7 = b7.z0 + 1 + pr7() * Math.max(0.1, b7.z1 - b7.z0 - 2); _o3.set(x7, b7.y1 + 0.5, z7); _d3.set(0, -1, 0); rc7.set(_o3, _d3); var hs7 = rc7.intersectObjects(handle.meshes, false); for (var h7 = 0; h7 < hs7.length; h7++) { // 지붕 제외 = 구조 필터(bbox 상단 1.5m 밴드 전부 제외) — "[0]=지붕" 휴리스틱은 지붕 위 // 디테일(벤트/파이프)이 다중 히트를 만들면 지붕 하면이 지배 버킷이 되어 맵 전체가 // 지붕 높이만큼 침몰한다(station -4.60m 실측). if (hs7[h7].point.y > b7.y1 - 1.5) continue; var yk7 = Math.round(hs7[h7].point.y / 0.4); hist7.set(yk7, (hist7.get(yk7) || 0) + 1); n7++; } } if (n7 >= 12) { var bk7 = null, bc7 = -1; hist7.forEach(function (c7, k7) { if (c7 > bc7) { bc7 = c7; bk7 = k7; } }); var anchorY7 = bk7 * 0.4; var d7 = anchorY7 - num(o.y, 0); if (Math.abs(d7) > 0.05) { group.position.y -= d7; group.updateMatrixWorld(true); var box7 = new THREE_.Box3().setFromObject(group); handle.bounds = { x0: box7.min.x, x1: box7.max.x, y0: box7.min.y, y1: box7.max.y, z0: box7.min.z, z1: box7.max.z }; try { console.warn('[world] spawn_map: dominant-floor anchor shifted ' + (-d7).toFixed(2) + 'm (walk plane → placement y)'); } catch (e) {} } } })(); if (o.ground !== false) handle._g_off = world._priv.ground_ext_add(_ground_fn); // 카메라 붐 차폐 자동 배선 — 리그의 seg-blocked 는 box collider 전용이라 GLB 벽을 못 보고 // 붐이 무대 벽을 관통해 "세트 밖 풍경"이 찍힌다(실보고 2026-07-24). 맵 메시 = 카메라 차폐 정본. handle._cam_off = world._priv.cam_occl_add(function (px, py, pz, cx, cy, cz) { return handle.blocked(px, py, pz, cx, cy, cz); }); if (o.ground !== false) handle._c_off = world._priv.ceil_ext_add(_ceil_fn); // 천장 채널(상승 클램프) — 지면과 세트 // 실외(로트형) 자동 감지(2026-07-29 사용자 확정 "실외여도 괜찮지 않을까") — 실내 계약(지붕 // 불변식·개방면 봉인)은 지붕 커버리지가 낮은 저작(주차장 로트·트레일러 파크)에서 이동 전면 // 기각을 만든다. 40점 상향 레이 히트율 < 0.45 = open_air: 봉인 생략 + 아키타입이 지붕 검사 // 대신 footprint+천장캡 봉쇄로 완화 소비. LOUD 로그로 모드 가시화. (function () { var b6 = handle.bounds, hit6 = 0, n6 = 0; var pr6 = mulberry32(0x0a17); for (var s6 = 0; s6 < 40; s6++) { var x6 = b6.x0 + 1 + pr6() * Math.max(0.1, b6.x1 - b6.x0 - 2); var z6 = b6.z0 + 1 + pr6() * Math.max(0.1, b6.z1 - b6.z0 - 2); var g6 = _ground_fn(x6, z6, b6.y0 + 1.2); if (g6 == null) continue; n6++; _o3.set(x6, g6 + 1.2, z6); _d3.set(0, 1, 0); _rc.set(_o3, _d3); _rc.far = (b6.y1 - b6.y0) + 2; if (_rc.intersectObjects(handle.meshes, false).length) hit6++; } handle.open_air = n6 >= 8 && (hit6 / n6) < 0.45; if (handle.open_air) { try { console.warn('[world] spawn_map: open-air map detected (roof coverage ' + Math.round((hit6 / Math.max(1, n6)) * 100) + '%) — interior invariants relaxed'); } catch (e) {} } })(); handle.ready = true; // 개방면 봉인(opt-in seal) — 모듈러 실내 세트는 정면 1면이 결손인 저작이 흔하다(실보고 // 2026-07-28 "한쪽 벽이 노출"). 4외곽면을 가슴/상체 높이 수평 레이로 커버리지 스캔, 벽 히트 // 비율이 낮은 면만 전고 슬랩으로 닫는다. 색 = 실존 벽면의 실측 재질색(맵과 자기일관). 봉인벽을 // handle.meshes 에 편입해 차폐(LOS/카메라/이동)·표면색 샘플이 실제 벽과 동일 계약으로 동작. if (o.seal && !handle.open_air) (function () { var b8 = handle.bounds, N8 = 9, h8 = b8.y1 - b8.y0; var faces8 = [ { ax: 'z', at: b8.z1, a0: b8.x0, a1: b8.x1, dx: 0, dz: 1 }, { ax: 'z', at: b8.z0, a0: b8.x0, a1: b8.x1, dx: 0, dz: -1 }, { ax: 'x', at: b8.x1, a0: b8.z0, a1: b8.z1, dx: 1, dz: 0 }, { ax: 'x', at: b8.x0, a0: b8.z0, a1: b8.z1, dx: -1, dz: 0 }, ]; var col8 = null, open8 = []; faces8.forEach(function (f8) { var hit8 = 0; for (var i8 = 0; i8 < N8; i8++) { var t8 = (i8 + 0.5) / N8, a8 = f8.a0 + 0.8 + (f8.a1 - f8.a0 - 1.6) * t8; var covered8 = false; [1.2, 2.4].forEach(function (y8) { if (covered8) return; _o3.set(f8.ax === 'z' ? a8 : f8.at - f8.dx * 1.6, b8.y0 + y8, f8.ax === 'z' ? f8.at - f8.dz * 1.6 : a8); _d3.set(f8.dx, 0, f8.dz); _rc.set(_o3, _d3); _rc.far = 2.2; var is8 = _rc.intersectObjects(handle.meshes, false); if (is8.length) { covered8 = true; // 색 = 표면색 SSoT(surface_color_at: material.color × 텍스처 리드백) — 텍스처 재질의 // material.color 는 백색이라 직독하면 봉인벽이 순백 이질면이 된다(실측 2026-07-28) if (!col8) col8 = handle.surface_color_at(_o3.x, _o3.y, _o3.z, f8.dx, 0, f8.dz, 2.2); } }); if (covered8) hit8++; } if (hit8 / N8 < 0.45) open8.push(f8); }); open8.forEach(function (f8) { var span8 = f8.a1 - f8.a0; var w8 = new THREE_.Mesh( f8.ax === 'z' ? new THREE_.BoxGeometry(span8, h8, 0.24) : new THREE_.BoxGeometry(0.24, h8, span8), new THREE_.MeshStandardMaterial({ color: col8 || '#7f6a52', roughness: 0.9 }) ); w8.position.set( f8.ax === 'z' ? (f8.a0 + f8.a1) / 2 : f8.at - f8.dx * 0.12, b8.y0 + h8 / 2, f8.ax === 'z' ? f8.at - f8.dz * 0.12 : (f8.a0 + f8.a1) / 2 ); w8.castShadow = true; w8.receiveShadow = true; scene.add(w8); handle.meshes.push(w8); }); if (open8.length) { try { console.warn('[world] spawn_map: sealed ' + open8.length + ' open face(s)'); } catch (e) {} } })(); var cbs = handle._cbs; handle._cbs = []; cbs.forEach(function (f) { try { f(handle); } catch (e) { try { console.error('[world] spawn_map on_ready failed: ' + (e && e.message)); } catch (_) {} } }); }); }); return handle; }; // ── 플레이어 + 카메라 리그 + 컨트롤 ── var PL = null; // {handle, speed, run, turn, radius, vy, heading, moving, running} var CAMR = null; // {offset, lookat, damping, yaw, pitch, dist, last_input, cur_look} var CTRL = { vec: { x: 0, y: 0 }, force: 0, cam_dx: 0, cam_dy: 0 }; // player({spawn:'knight'|mesh, speed, run_speed, turn_rate, radius, at}) → 상태 객체(pos/heading/moving) // speed → idle/walk/run 클립 자동 크로스페이드(셸 소유 — 로직 무개입). // ── M7 양식화 물리 레버(gravity-lab / bullet-time / magnet-world 브리프의 셸 소유 손잡이) ── world.gravity_scale = function (g) { world._gscale = clamp(num(g, 1), 0.05, 3); return world._gscale; }; // 0.05=달 도약 ↔ 3=중력 프레스 world.time_scale = function (s) { world._tscale = clamp(num(s, 1), 0.1, 2); return world._tscale; }; // world-모션 배율(플레이어 물리+믹서+자석) world.magnet = function (list, opts) { // 반경 내 오브젝트를 플레이어로 견인 — 수집/제거 판정은 로직 몫 var mo = opts || {}; var reg = { list: Array.isArray(list) ? list : [list], radius: clamp(num(mo.radius, 8), 1, 60), strength: clamp(num(mo.strength, 10), 0.5, 40), stopped: false }; (world._magnets = world._magnets || []).push(reg); return { stop: function () { reg.stopped = true; }, set_list: function (l) { reg.list = Array.isArray(l) ? l : [l]; } }; }; world.player = function (opts) { if (world._priv && world._priv.craft) { try { console.error('[world] player: craft.player 활성 — 동시 사용 불가(복셀 물리와 상호배타)'); } catch (e) {} return null; } var o = opts || {}; // 메인 캐릭터 크기 사다리(실보고 2026-07-14 "메인 캐릭터가 너무 큼" — 스왑 경로만 _fit_h 상한이 // 있고 초기 player 는 무상한이던 비대칭 봉합): ①h 미지정 = 카탈로그 실측 h 에 스왑과 동일 // 계약의 과대 상한(인간 노름 1.7×1.6 — LLM 이 거대 생물(공룡 3~5.4m)을 무자각 픽해도 카메라 // 프레임 안) ②명시 h = 의도 주권 존중, 단 구조 밴드(0.3~6m — msfx/충돌/카메라 설계 한계)로만 // 클램프+LOUD ③커스텀 mesh = 하단에서 동일 상한 다운스케일 흡수. var _p_h = o.h != null ? clamp(num(o.h, 1.7), 0.3, 6) : null; if (o.h != null && _p_h !== num(o.h, 1.7)) { try { console.warn('[world] player h ' + o.h + ' clamped to ' + _p_h + ' (structural band 0.3~6m)'); } catch (e) {} } var handle; if (typeof o.spawn === 'string') { handle = world.spawn(o.spawn, { at: o.at, h: _p_h, _fit_h: _p_h != null ? 0 : 1.7 * 1.6, collide: false, _no_collider: true }); } else { var g = new THREE_.Group(); // 문서 계약: spawn 은 팩 이름 *또는* 직접 만든 mesh/Group — Object3D 를 받으면 그대로 채택 var pmesh = o.mesh || ((o.spawn && o.spawn.isObject3D) ? o.spawn : null); if (pmesh) { // 커스텀 mesh 과대 흡수 — LLM 원시 지오메트리 플레이어의 크기는 무검증 표면이었다. // downscale-only(작은 마커류 불간섭), 흡수는 LOUD+비콘 관측. pmesh.updateMatrixWorld(true); var _pb = new THREE_.Box3().setFromObject(pmesh); var _ph = Math.max(0.0001, _pb.max.y - _pb.min.y); var _pcap = _p_h != null ? _p_h : 1.7 * 1.6; if (_ph > _pcap) { pmesh.scale.multiplyScalar(_pcap / _ph); try { console.warn('[world] player mesh ' + _ph.toFixed(1) + 'm downscaled to ' + _pcap.toFixed(2) + 'm (oversize cap)'); } catch (e) {} if (window.__playable_beacon) window.__playable_beacon('player_mesh_downscaled', _ph.toFixed(1)); } pmesh.traverse(function (_sn) { if (_sn.isMesh) _sn.castShadow = true; }); // 커스텀 플레이어도 캐스터 g.add(pmesh); } var at2 = P._nv2(o.at, 0, 0); g.position.set(at2.x, world.height_at(at2.x, at2.z), at2.z); scene.add(g); handle = { obj: g, pos: g.position, play: function () {}, ready: true, radius: 0.45 }; } PL = { handle: handle, speed: clamp(num(o.speed, 4.5), 0.5, 20), run: clamp(num(o.run_speed, 8.5), 1, 30), turn: clamp(num(o.turn_rate, 10), 2, 30), radius: clamp(num(o.radius, 0.45), 0.1, 2), base_h: _p_h != null ? _p_h : Math.min((handle && handle._spec && handle._spec.h) || 1.7, 1.7 * 1.6), // 최초 캐릭터 목표 신장 — 스왑 과대 상한 기준(초기 _fit_h 상한과 동조: 미지정 거대 픽이 base 를 부풀려 스왑 상한까지 승격시키던 연쇄 차단) vy: 0, heading: 0, moving: false, running: false, on_ground: true, jump_v: clamp(num(o.jump, 5.5), 0, 12), glide: o.glide === true, // 공중에서 점프 홀드 = 활공(낙하 감속 — 패러글라이더 캐논) fly: o.fly !== false, // 보편 동사: 점프 홀드 = 상승(자유 비행) 기본 ON — fly:false 로만 해제 move_lock: 0, // combat 이 스윙 커밋먼트(선딜+활성) 동안 이동 잠금에 사용 — 잔여초(게임 dt 로 감소) // 전투(combat 패밀리)용 체력 — 피해 적용·무적 창·사망 연출은 combat 이 소유. hp_max: clamp(num(o.hp, 5), 1, 99), hp: clamp(num(o.hp, 5), 1, 99), invul_until: -9, on_death: typeof o.on_player_death === 'function' ? o.on_player_death : null, }; if (P.nature && P.nature.set_player) P.nature.set_player(handle.obj); // 풀 bend 연동 _msfx_set(typeof o.spawn === 'string' ? o.spawn : ''); // 모션 효과음 프로필(직접 mesh = human 기본) _msfx.px = handle.obj.position.x; _msfx.pz = handle.obj.position.z; // 보편 동사: 캐릭터 교체 버튼 — 생성기 swap 로스터 존재 시 카테고리 메뉴창(사람/동물/몬스터/ // 탈것/군사/사물 → 그리드 즉시 선택, become 계약: 위치/카메라/미션 승계), 부재 시 레거시 순환. // ⚠️ 상시 표준 UI — 구 opt-out(switch_btn:false)은 *흡수*한다(실보고 2026-07-13: LLM 이 산책형 // 게임에서 미학적 선택으로 버튼을 소멸시킴 — 레버 자체가 변동성 클래스라 프롬프트에서도 제거, // 잔존 구 계약 호출은 LOUD+비콘으로 관측만 하고 무시). if (o.switch_btn === false) { try { console.warn('[world] switch_btn:false ignored — character swap is a platform-standard verb'); } catch (e) {} if (window.__playable_beacon) window.__playable_beacon('switch_btn_optout_ignored', '1'); } // ⚠️ 정체성-잠금(identity lock) — 실보고 2026-07-22: 교체가 *기제상 불가능한* 게임(플레이어 몸이 // 게임 규칙의 데이터인 위장/변장류 — become() 이 페인트·색 매칭을 파괴)에서도 버튼이 떠서 눌러도 // 아무 일이 없었다. 위 switch_btn 흡수와 층이 다르다: 저건 LLM 의 *미학적* opt-out(변동성 클래스라 // 무시)이고, 이건 아키타입이 선언하는 *결정론적 기제 사실*이다 — LLM 이 만질 수 없는 셸 계약. var _idlock = o.identity_locked === true; if (_idlock && window.__playable_beacon) window.__playable_beacon('swap_identity_locked', '1'); if (!_idlock) { // 캐릭터 교체 = 오버플로 메뉴(⋯) 항목(사용자 요청 2026-07-22: 필수 데이터만 상시 노출). 구 // 숄더 레일 버튼(playable-switch)은 폐기. 동작은 _swap_action(아래 정의)이 소유. // 로스터(생성기 인라인 swap 계약) 존재 시 = 카테고리 메뉴창(사람/동물/몬스터/탈것/군사/사물 → // 서브 그리드에서 즉시 선택). 부재(구 게임/오프라인) = 레거시 순환 그대로(계약 불변). var _sw_list = null, _sw_i = -1, _sw_cool = 0, _sw_panel = null, _sw_back = null, _sw_cat = 'human'; var _SW_CATS = [ ['human', '🧑', '사람', '人', 'People'], ['animal', '🦊', '동물', '動物', 'Animals'], ['monster', '👾', '몬스터', 'モンスター', 'Monsters'], ['vehicle', '🚗', '탈것', '乗り物', 'Vehicles'], ['military', '🎖', '군사', 'ミリタリー', 'Military'], ['object', '📦', '사물', 'モノ', 'Objects'], ]; function _sw_label(n) { // 표시명 = 팩 접두 제거 + 스네이크 해체 + 두문자화 var s = String(n).replace(/^(civ|wild|mon|cmon|fauna|critter|bird|grave|town|nature|camp|castle|pirate|race|td|fact|golf|blast|item|gear|space|cosmo|city|burb|spooky|food|furn|dun|ruin|aqua|boat|car|mil|res|fbit)_/, ''); s = s.replace(/_/g, ' ').trim(); return s.charAt(0).toUpperCase() + s.slice(1); } var _sw_scrolls = {}; // 카테고리별 스크롤 기억(재오픈 = 이전 위치 복원) var _sw_grid = null; function _sw_close() { if (_sw_grid) { _sw_scrolls[_sw_cat] = _sw_grid.scrollTop; _sw_grid = null; } if (_sw_panel) { try { _sw_panel.remove(); } catch (e) {} _sw_panel = null; } if (_sw_back) { try { _sw_back.remove(); } catch (e) {} _sw_back = null; } } function _sw_pick(name) { var nowt = performance.now(); if (nowt - _sw_cool < 900) return; // 연타 = GLB 로드 폭주 방지 _sw_cool = nowt; _sw_close(); state.become(name); } function _sw_open(sw) { _sw_close(); _sw_back = document.createElement('div'); _sw_back.style.cssText = 'position:fixed;inset:0;z-index:99968;background:rgba(4,6,12,.45);backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px);'; _sw_back.addEventListener('pointerdown', function (e) { e.preventDefault(); e.stopPropagation(); _sw_close(); }); document.body.appendChild(_sw_back); // 콘솔 디자인 언어(PS/Xbox 계열 정본): 딥 글래스 시트 + 헤더(타이틀/닫기) + 아이콘 카테고리 // 레일(액센트 활성) + 카드 그리드 + 진입 애니메이션. 재오픈 = 이전 카테고리·스크롤 복원. _sw_panel = document.createElement('div'); _sw_panel.className = 'pmenu'; _sw_panel.style.cssText = 'position:fixed;right:12px;bottom:calc(330px + env(safe-area-inset-bottom,0px));z-index:99969;' // 숄더 레일 위(272+46) + 'width:min(372px, calc(100vw - 24px));max-height:52vh;display:flex;flex-direction:column;' + 'background:linear-gradient(170deg, rgba(20,26,40,.94), rgba(8,11,19,.96));' + 'border:1px solid rgba(255,255,255,.12);border-radius:18px;' + 'box-shadow:0 22px 60px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.1);' + 'backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);overflow:hidden;'; _sw_panel.addEventListener('pointerdown', function (e) { e.stopPropagation(); }); var head = document.createElement('div'); head.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:12px 14px 4px;flex:0 0 auto;'; var ttl = document.createElement('div'); ttl.textContent = _uni_t('캐릭터 선택', 'キャラクター選択', 'Choose Character'); ttl.style.cssText = 'color:#f4f6fb;font:800 14px/1 system-ui,sans-serif;letter-spacing:.02em;'; var xb = document.createElement('button'); xb.type = 'button'; xb.innerHTML = '
'; xb.style.cssText = 'width:30px;height:30px;border-radius:50%;border:1px solid rgba(255,255,255,.15);' + 'background:rgba(255,255,255,.06);color:#c9cfda;display:flex;align-items:center;justify-content:center;touch-action:manipulation;padding:0;'; xb.addEventListener('pointerdown', function (e) { e.preventDefault(); e.stopPropagation(); _sw_close(); }); head.appendChild(ttl); head.appendChild(xb); var tabs = document.createElement('div'); tabs.style.cssText = 'display:flex;gap:5px;padding:8px 12px 8px;overflow-x:auto;flex:0 0 auto;scrollbar-width:none;'; var grid = document.createElement('div'); // flex:1 1 auto + min-height:0 — flex-column 자식의 min-height:auto 함정 해제(이것 없으면 // grid 가 콘텐츠 높이 이하로 수축 불가 → overflow-y:auto 무발동 → 52vh 초과분 도달 불가). grid.style.cssText = 'display:grid;grid-template-columns:1fr 1fr;gap:7px;padding:2px 12px 14px;overflow-y:auto;overscroll-behavior:contain;flex:1 1 auto;min-height:0;'; _sw_grid = grid; if (!sw[_sw_cat] || !sw[_sw_cat].length) { // 기본 탭이 빈 로스터면 첫 실재 탭으로 for (var _ci = 0; _ci < _SW_CATS.length; _ci++) { if (sw[_SW_CATS[_ci][0]] && sw[_SW_CATS[_ci][0]].length) { _sw_cat = _SW_CATS[_ci][0]; break; } } } function _fill_grid(keep_scroll) { grid.innerHTML = ''; var _cemo = ''; for (var _ce = 0; _ce < _SW_CATS.length; _ce++) if (_SW_CATS[_ce][0] === _sw_cat) _cemo = _SW_CATS[_ce][1]; (sw[_sw_cat] || []).forEach(function (nm) { var b = document.createElement('button'); b.type = 'button'; b.className = 'pmenu-item'; b.innerHTML = '
' + _cemo + '
' + '
' + _sw_label(nm) + '
'; b.style.cssText = 'display:flex;align-items:center;gap:9px;padding:8px 9px;border-radius:12px;' + 'border:1px solid rgba(255,255,255,.1);background:rgba(255,255,255,.045);color:#eef1f6;' + 'font:600 12px/1.25 system-ui,sans-serif;text-align:left;touch-action:manipulation;min-width:0;'; // click(pointerdown 아님) — 팬 제스처 후 브라우저가 click 을 억제하므로 스크롤 시작 // 터치가 선택으로 소비되지 않음. touch-action:manipulation 이라 탭 지연도 없음. b.addEventListener('click', function (e) { e.stopPropagation(); _sw_pick(nm); }); grid.appendChild(b); }); grid.scrollTop = keep_scroll ? (_sw_scrolls[_sw_cat] || 0) : 0; } var paints = []; _SW_CATS.forEach(function (c) { if (!sw[c[0]] || !sw[c[0]].length) return; var t = document.createElement('button'); t.type = 'button'; t.textContent = c[1] + ' ' + _uni_t(c[2], c[3], c[4]); function _paint() { t.style.cssText = 'flex:0 0 auto;padding:8px 12px;border-radius:999px;border:1px solid ' + (_sw_cat === c[0] ? 'rgba(245,158,11,.6)' : 'rgba(255,255,255,.12)') + ';' + 'background:' + (_sw_cat === c[0] ? 'linear-gradient(160deg, rgba(245,158,11,.24), rgba(245,158,11,.1))' : 'rgba(255,255,255,.045)') + ';' + 'color:' + (_sw_cat === c[0] ? '#ffd9a0' : '#e8ecf4') + ';font:700 12px/1 system-ui,sans-serif;touch-action:manipulation;white-space:nowrap;' + 'transition:background .12s ease,border-color .12s ease;'; } _paint(); paints.push(_paint); t.addEventListener('click', function (e) { // click — 탭 레일 가로 팬(overflow-x)과 선택 분리 e.stopPropagation(); if (_sw_grid) _sw_scrolls[_sw_cat] = _sw_grid.scrollTop; // 떠나는 탭 위치 저장 _sw_cat = c[0]; _fill_grid(true); paints.forEach(function (f) { f(); }); }); tabs.appendChild(t); }); _fill_grid(true); // 재오픈 = 이전 카테고리 + 이전 스크롤 복원 _sw_panel.appendChild(head); _sw_panel.appendChild(tabs); _sw_panel.appendChild(grid); document.body.appendChild(_sw_panel); } function _swap_action() { if (_sw_panel) { _sw_close(); return; } // 재탭 = 닫기 var _extsw = window['__PLAYABLE_'+'EXTRA_MANIFEST__'] && window['__PLAYABLE_'+'EXTRA_MANIFEST__'].swap; if (_extsw) { _sw_open(_extsw); return; } var nowt = performance.now(); if (nowt - _sw_cool < 900) return; // 연타 = GLB 로드 폭주 방지 _sw_cool = nowt; _with_manifest(function (man) { if (!_sw_list) { var _ms = man.models || {}; var _chars = [], _crs = [], _props = []; for (var _mk in _ms) { var _mc = _ms[_mk].cat; if (_mc === 'character') _chars.push(_mk); else if (_mc === 'creature') _crs.push(_mk); else if (_mc === 'prop') { var _mh = num(_ms[_mk].h, 0); if (_mh >= 0.4 && _mh <= 2.2) _props.push(_mk); // 플레이어 체급 적정 사물만 } } for (var _pi = _props.length - 1; _pi > 0; _pi--) { // 사물 표본 셔플(게임마다 다른 사물 라인업) var _pj = Math.floor(Math.random() * (_pi + 1)); var _pt = _props[_pi]; _props[_pi] = _props[_pj]; _props[_pj] = _pt; } _sw_list = _chars.concat(_crs, _props.slice(0, 12)); var cur = _sw_list.indexOf(typeof o.spawn === 'string' ? o.spawn : ''); _sw_i = cur >= 0 ? cur : 0; } if (_sw_list.length < 2) return; _sw_i = (_sw_i + 1) % _sw_list.length; state.become(_sw_list[_sw_i]); }); } P.menu.add({ label: ['캐릭터 교체', 'キャラ変更', 'Swap character'], svg: '
', onSelect: _swap_action, }); world._priv.swap_screen = _swap_action; // 상시 교체 버튼(아키타입 소유 UI) 진입점 — 메뉴와 동일 액션 } var state = { obj: handle.obj, pos: handle.obj.position, play: function (a, po) { handle.play(a, po); }, play_once: function (a, po) { if (handle.play_once) handle.play_once(a, po); }, hold: function (item, ho) { if (handle.hold) handle.hold(item, ho); return state; }, jump: function () { // 점프 필 사다리 1/2 — 코요테: 가장자리 이탈 후 0.12s 유예(낙하 전환 직후의 "눌렀는데 // 안 뜀" 불공정 차단 — 플랫포머 정본). vy>0(이미 도약) = 2단 점프가 되므로 제외. if (!PL.on_ground && !(PL._cy != null && PL._cy < 0.12 && PL.vy <= 0.01)) return; PL._cy = 9; // 코요테 소진 PL.vy = PL.jump_v; PL.on_ground = false; // 로코모션 동사(step/splash/jump/land)는 *이동 주체* 소유 — 탑승 중엔 동물 슬롯(모션과 동일 // 계약). 사람 세트(jump_grunt 실녹음 기합)가 말 위에서 발화되던 실보고(2026-07-14)의 봉합: // 동물 세트에 jump 부재 시 zzfx 중립 합성으로 흡수(사다리 불변). _msfx_verb(_mount ? _msfx_mount_slot() : _msfx.smp, 'jump', _MSFX_COM.jump, 1, true); var _jt2 = _mount ? _mount.h : handle; // 탑승 중 점프 모션은 동물 소유 if (_jt2._actions && _jt2._actions.jump) _jt2.play('jump', { fade: 0.1 }); else if (!_mount) { // 점프 클립 부재 모델(기본 adventurer 팩 등) 폴백 — 도약 스쿼시 + 공중 포즈 홀드(믹서 슬로우) if (P.fx && P.fx.squash) P.fx.squash(handle.obj, { amount: 0.2, dur: 0.14 }); if (handle._mixer) { handle._mixer.timeScale = 0.22; PL._air_slow = true; } } }, get heading() { return PL.heading; }, get moving() { return PL.moving; }, get running() { return PL.running; }, get hp() { return PL.hp; }, get hp_max() { return PL.hp_max; }, // 캐릭터 교체 — 위치·방향·HP·카메라·컨트롤 전부 승계(월드 계통이 PL.handle 단일 경유라 // 핸들 교체 = 전 계통 자동 승계). 탑승 중이면 해제, FPS 모드면 본체 숨김 승계. // ⚠️ 파사드 obj/pos 는 생성 시점 바인딩 — 교체 시 재바인딩 필수(stale 참조 함정). become: function (name, bo) { try { var old_h = handle; if (_mount) { // 강제 하차(교체와 탑승의 결합 상태 금지) PL.speed = _mount.speed0; PL.run = _mount.run0; if (_mount.pose_hold && old_h._mixer) old_h._mixer.timeScale = 1; _mount = null; } if (PL._air_slow && old_h._mixer) { old_h._mixer.timeScale = 1; PL._air_slow = false; } // _fit_h: 메뉴 스왑(높이 미지정)만 과대 상한 — 탈것/군사 원치수(≤8m)가 1.7m 카메라 프레임을 // 채우는 결함 봉합. become(name,{h}) 명시 높이는 저작 의도로 존중(무상한). var nh = world.spawn(name, { at: { x: old_h.obj.position.x, z: old_h.obj.position.z }, h: bo && bo.h, _fit_h: (bo && bo.h) ? 0 : (PL.base_h || 1.7) * 1.6, collide: false, _no_collider: true }); nh.obj.position.copy(old_h.obj.position); nh.obj.rotation.y = old_h.obj.rotation.y; handle = nh; PL.handle = nh; state.obj = nh.obj; state.pos = nh.obj.position; if (P.nature && P.nature.set_player) P.nature.set_player(nh.obj); // 풀 bend 재연동(유일 외부 접점) _msfx_set(name); // 모션 효과음 프로필 승계 + 성문 1회(실녹음 울음 우선, 합성 폴백 — 교체 피드백) var _vk = _MSFX_KIT[_msfx.kind]; setTimeout(function () { _msfx_verb(_msfx.smp, 'voice', _vk && _vk.voice, 1, true); }, 260); // 실녹음 프리로드 여유 // 1인칭 = 본체 숨김 승계 — 단 숄더 뷰는 본체 가시가 계약(2026-07-21 실보고: camo 헌터에서 // 캐릭터 교체 시 새 몸체가 무조건 숨어 화면에서 사라짐 — fps=1인칭 가정이 shoulder 신설을 // 못 따라온 이음새; build_fight 도 동일 잠복이었다). if (world._priv.fps && world._priv.fps.view !== 'shoulder') nh.obj.visible = false; if (P.fx) { P.fx.burst({ x: nh.obj.position.x, y: nh.obj.position.y + 1.1, z: nh.obj.position.z }, { count: 22, color: (P._pal && P._pal.accent) || '#FFE58A', speed: 3.2, life: 0.9, gravity: -0.2 }); P.fx.despawn(old_h.obj, { dur: 0.35 }); } else if (old_h.obj.parent) old_h.obj.parent.remove(old_h.obj); } catch (e) { try { console.error('[world] player.become failed: ' + (e && e.message)); } catch (_) {} } return state; }, }; return state; }; // combat/archetype 패밀리 전용 내부 브리지(비공개 — LLM 로직 계약 아님). world._priv = { player: function () { return PL; }, // 탭/클릭 이동 목표(패밀리 설치면 — lane 등): 스틱/키 직접 입력·move_lock 이 항상 즉시 양도 nav_to: function (x, z) { if (PL) PL._nav = { x: num(x, 0), z: num(z, 0) }; }, ext_player: null, // craft 등 외부 플레이어의 전투 어댑터 — combat _pl() 이 우선 소비(world 물리는 내부 PL 만) has_terrain: function () { return !!T; }, // 플레이어 y 소유권 판별자 — 외부 패밀리(voxel 광장 리프트)가 같은 프레임 후행-쓰기로 y 를 // 뺏지 않도록: track 탑승 = 캐리어가 위치 전권(vy=0∧on_ground 시그니처가 지상과 동일해 이 // 표면 없이는 판별 불가), mount 탑승 = 발-공간 y 가 매 프레임 지형 재계산(시트 결합 금지). track_riding: function () { for (var _ti = 0; _ti < _tracks.length; _ti++) if (_tracks[_ti].riding) return true; return false; }, mounted: function () { return !!_mount; }, // 외부 지면 제공자(obby 플랫폼 등) — (x,z,y)→지면 y 또는 null. 지형 height_at 과 max 로 합성 // (플랫폼 = "더 높은 바닥"이라는 등가 번역 — 착지/스냅/이탈이 기존 상태기계를 그대로 통과). ground_ext: null, // 다중-제공자 등록(build 램프/바닥 등) — 단일 필드(ground_ext, obby 소유)와 공존: 물리가 // 양쪽을 max 합성한다. 반환 = 해제 함수. probe = 동일 합성의 조회 전용 노출(고스트 층 결정). ground_ext_add: function (fn) { _ground_ext_list.push(fn); return function () { var i = _ground_ext_list.indexOf(fn); if (i >= 0) _ground_ext_list.splice(i, 1); }; }, ground_ext_probe: function (x, z, y) { return _ground_ext_all(x, z, y); }, ceil_ext_probe: function (x, z, y) { return _ceil_ext_all(x, z, y); }, // 카메라 리그 등 천장 소비자용(맵 게임 외 = null 무동작) cam_seg_blocked: function (px, py, pz, cx, cy, cz) { return _cam_seg_blocked(px, py, pz, cx, cy, cz); }, // 스프링암 충돌(fps 숄더 리그 소비 — collider + cam_occl 합성) cam_face: function (yaw9) { // 스폰 방향 정렬(맵-세계 착지 소비, 2026-07-29) — 벽 앞 스폰이 벽만 보는 // 첫 프레임(카메라 벽 매몰 실보고)을 소거: 3인칭 리그/fps 요 + 플레이어 헤딩을 동일 요로 동기. if (CAMR) { CAMR.yaw = yaw9; } if (world._priv.fps && world._priv.fps.yaw != null) world._priv.fps.yaw = yaw9; if (PL) { PL.heading = yaw9; if (PL.handle && PL.handle.obj) PL.handle.obj.rotation.y = yaw9; } }, cam_occl_add: function (fn) { // 카메라 차폐 제공자 등록(spawn_map 자동 배선) — 반환 = 해제 함수 _cam_occl_list.push(fn); return function () { var i = _cam_occl_list.indexOf(fn); if (i >= 0) _cam_occl_list.splice(i, 1); }; }, vendor_ext_base: function () { return _vendor_base().replace(/\/vendor\/world-pack\/$/, '/vendor-ext/'); }, // 확장 에셋(콘텐츠-해시 텍스처 등) URL 정본 — 도메인 하드코딩 금지 ceil_ext_add: function (fn) { // 천장 제공자 등록(spawn_map 자동 배선) — (x,z,y)→머리 위 천장면 y|null _ceil_ext_list.push(fn); return function () { var i = _ceil_ext_list.indexOf(fn); if (i >= 0) _ceil_ext_list.splice(i, 1); }; }, collider_box: _collider_box, collider_box_remove: _remove_collider, push_out: _push_out, mulberry32: mulberry32, // 패밀리 공용 결정론 PRNG 팩토리(제3의 RNG 재발명 금지) fps: null, // FPS 패밀리가 설치 시 {yaw, pitch} — 이동 기준·카메라 리그 비활성의 단일 스위치 load_model: function (name, h, cb) { // 팩 GLB 를 목표 높이로 정규화한 클론으로 전달(뷰모델용) _with_manifest(function (man) { var spec = man.models && man.models[name]; if (!spec) { try { console.error('[world] load_model: unknown "' + name + '"'); } catch (e) {} return; } _load_glb(spec.file, function (gltf) { var inst = gltf.scene.clone(true); inst.updateMatrixWorld(true); // 필수: detached 측정 전 dequant 노드 변환 전파(spawn 주석 참조) var box = new THREE_.Box3().setFromObject(inst); var rawH = Math.max(0.0001, box.max.y - box.min.y); inst.scale.setScalar((h || spec.h || 1) / rawH); try { cb(inst); } catch (e) {} }); }); }, }; // camera({offset, lookat, damping}) — SimonDev 리그: ideal offset/lookat 로컬→월드 + 지수 감쇠. // 플레이 볼륨 클램프 — 실보고 2026-07-22: 밀폐 세트(방/실내)에서 카메라 구도에 따라 *세트 바깥 공간* // 과 *세트 바닥 아래*가 화면에 들어왔다. 벽 세그먼트 회피(_cam_seg_blocked)는 "벽에 가림"만 풀고 // "볼륨 이탈"은 못 막는다 — 아키타입이 선언한 AABB 안으로 카메라를 하드 클램프해 이탈을 구조적으로 // 불가능하게 한다(선언 없는 야외 게임은 null = 무동작이라 기존 거동 불변). var _cam_bnd = null; world.camera_bounds = function (b) { if (b === undefined) return _cam_bnd; // 인자 없음 = 게터(검증 게이트가 볼륨을 읽어 불변식을 강제) if (!b) { _cam_bnd = null; return; } _cam_bnd = { x0: num(b.x0, -1e9), x1: num(b.x1, 1e9), z0: num(b.z0, -1e9), z1: num(b.z1, 1e9), y0: num(b.y0, -1e9), y1: num(b.y1, 1e9), }; }; // ⚠️ 카메라 리그는 하나가 아니다 — world.camera(3인칭 CAMR)와 world.fps(1인칭·숄더)가 각자 위치를 // 쓴다. 클램프를 리그 안에 인라인으로 두면 나머지 리그가 그대로 새므로(헌터 모드가 그러했다) // 공유 함수로 승격해 *모든* 리그가 같은 볼륨을 소비하게 한다. world.cam_clamp = function (v) { if (!_cam_bnd || !v) return v; v.x = Math.min(Math.max(v.x, _cam_bnd.x0), _cam_bnd.x1); v.y = Math.min(Math.max(v.y, _cam_bnd.y0), _cam_bnd.y1); v.z = Math.min(Math.max(v.z, _cam_bnd.z0), _cam_bnd.z1); return v; }; world.camera = function (opts) { var o = opts || {}; // L2: {x,y,z} 객체·[x,y,z] 배열 모두 수용(실측 사고 지점 — 배열 인덱싱 전제 금지) var off = P._nv3(o.offset, 0, 2.8, -5.5); var lk = P._nv3(o.lookat, 0, 1.4, 2.5); // L3: 퇴화 가드 — 거리 0 오프셋(탑다운 퇴화의 결정론 원인)은 기본값 강제 + LOUD if (Math.abs(off.z) < 0.8) { try { console.error('[world] camera offset degenerate (|z|<0.8 = zero-distance top-down) — using default [0,2.8,-5.5]'); } catch (e) {} off = { x: 0, y: 2.8, z: -5.5 }; } CAMR = { offset: new THREE_.Vector3(off.x, off.y, off.z), lookat: new THREE_.Vector3(lk.x, lk.y, lk.z), damping: clamp(num(o.damping, 3.5), 0.5, 12), yaw: 0, pitch: 0.12, last_input: -99, cur_pos: cam.position.clone(), cur_look: new THREE_.Vector3(), auto_align: o.auto_align !== false, }; return CAMR; }; // controls() — 겐신 정본: 좌측 다이내믹 스틱(이동, force>0.85=달리기) + 우측 드래그(카메라) // + 데스크톱 WASD/화살표 + 위 스와이프/스페이스 점프. 로직 무개입(셸 전체 소유). var _ctrl_installed = false; world.controls = function (opts) { if (_ctrl_installed) return CTRL; _ctrl_installed = true; var o = opts || {}; var canvas = P.three.renderer.domElement; // ── 시작 카드 — 미션 + 조작법 안내(실보고: "첫 시작에 미션과 컨트롤키 사용법이 반드시 나와야"). // 셸이 설치한 조작 체계는 셸만 정확히 알므로 카드도 셸 소유(LLM 규율 비의존). 첫 입력(오디오 // 언락과 동일 제스처)에 페이드아웃. mission 텍스트는 호출자 소유 — world.controls({ mission }). (function () { var _touch = ('ontouchstart' in window) || ((navigator.maxTouchPoints || 0) > 0); var _lang = String(((typeof navigator !== 'undefined' && (navigator.language || navigator.userLanguage)) || document.documentElement.lang || 'en')).slice(0, 2); // 언어=브라우저 우선(2026-07-29) // P._run_always(아키타입 선언) = 기본 속도가 곧 달리기 — Shift 안내 자체를 제거(2026-07-24) var _sk = P._run_always ? '' : ' (Shift 달리기)', _sj = P._run_always ? '' : ' (Shift 走る)', _se = P._run_always ? '' : ' (Shift to run)'; var _TXT = { ko: _touch ? ['왼쪽 화면 드래그 — 이동', '오른쪽 화면 드래그 — 시점', '점프 버튼 — 점프', '탭하여 시작'] : ['WASD · 화살표 — 이동' + _sk, '화면 드래그 — 시점', 'Space — 점프', '클릭하여 시작'], ja: _touch ? ['左ドラッグ — 移動', '右ドラッグ — 視点', 'ボタン — ジャンプ', 'タップで開始'] : ['WASD・矢印 — 移動' + _sj, 'ドラッグ — 視点', 'Space — ジャンプ', 'クリックで開始'], en: _touch ? ['Drag left side — move', 'Drag right side — look', 'Button — jump', 'Tap to start'] : ['WASD · Arrows — move' + _se, 'Drag — look', 'Space — jump', 'Click to start'], }; var t = _TXT[_lang] || _TXT.en; if (Array.isArray(o.lines) && o.lines.length) t = o.lines.slice(0, 4).concat(t.slice(o.lines.length)); // craft 등 외부 조작 체계의 카드 문구 오버라이드 var mission = typeof o.mission === 'string' && o.mission.trim() ? o.mission.trim().slice(0, 90) : ''; var card = document.createElement('div'); card.style.cssText = 'position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);z-index:99960;' + 'pointer-events:none;background:rgba(10,13,22,.82);border:1px solid rgba(255,229,138,.35);' + 'border-radius:16px;padding:18px 26px;color:#f5f7fa;font:500 14px/1.9 system-ui,sans-serif;' + 'text-align:center;max-width:82vw;transition:opacity .45s;'; function _line(txt, css) { var d = document.createElement('div'); if (css) d.style.cssText = css; d.textContent = txt; card.appendChild(d); } if (mission) _line(mission, 'font-weight:800;font-size:16px;color:#FFE58A;margin-bottom:8px'); _line(t[0]); _line(t[1]); if (!_touch) _line(t[2]); // 데스크톱 = 키보드 안내 유지. 터치는 아래 버튼 범례가 대신한다. // 버튼 범례 — 고정 문구가 아니라 P.pad 레지스트리(설치된 실제 버튼)에서 생성한다. 카드는 // 버튼 설치보다 *먼저* 만들어지므로(그리고 fps/combat 은 더 늦게 설치된다) 소멸 전까지 매 // 프레임 재구성하되, 서명이 바뀔 때만 DOM 을 다시 그린다. var _lg = document.createElement('div'); _lg.style.cssText = 'display:flex;flex-direction:column;gap:7px;margin-top:10px;align-items:flex-start;'; card.appendChild(_lg); _line('▶ ' + t[3], 'margin-top:10px;font-weight:700;color:#FFE58A;animation:playable-card-pulse 1.4s ease-in-out infinite'); var st = document.createElement('style'); st.textContent = '@keyframes playable-card-pulse{0%,100%{opacity:.55}50%{opacity:1}}'; card.appendChild(st); document.body.appendChild(card); var _dismissed = false; function _dismiss() { if (_dismissed) return; _dismissed = true; card.style.opacity = '0'; setTimeout(function () { try { card.remove(); } catch (e) {} }, 500); window.removeEventListener('pointerdown', _dismiss); window.removeEventListener('keydown', _dismiss); } window.addEventListener('pointerdown', _dismiss); window.addEventListener('keydown', _dismiss); var _lg_sig = ''; (function _lg_tick() { if (_dismissed) return; requestAnimationFrame(_lg_tick); var es = (P.pad && P.pad.legend) ? P.pad.legend() : []; var sig = es.map(function (e) { return String(e.label); }).join('|'); if (sig === _lg_sig) return; _lg_sig = sig; _lg.innerHTML = ''; for (var li = 0; li < es.length; li++) { var row = document.createElement('div'); row.style.cssText = 'display:flex;align-items:center;gap:10px;'; var ic = document.createElement('span'); // 버튼과 같은 캡 질감 — 범례와 실제 버튼이 한눈에 같은 물건으로 읽혀야 매핑이 성립한다 ic.style.cssText = 'display:inline-flex;width:34px;height:34px;flex:0 0 34px;' + 'align-items:center;justify-content:center;border-radius:50%;color:' + (es[li].glyphc || '#f0f3f8') + ';' + 'border:2px solid ' + es[li].ring + ';box-shadow:inset 0 1px 0 rgba(255,255,255,.18);' + 'background:radial-gradient(circle at 50% 30%, #333c50 0%, #191e2a 56%, #0a0d14 100%);'; ic.innerHTML = es[li].glyph; var tx = document.createElement('span'); var L = es[li].label; tx.textContent = Array.isArray(L) ? (_lang === 'ko' ? L[0] : (_lang === 'ja' ? L[1] : L[2])) : String(L); tx.style.cssText = 'font-size:13px;'; row.appendChild(ic); row.appendChild(tx); _lg.appendChild(row); } })(); // ── 상시 미션 칩(셸 소유) — 시작 카드는 첫 입력에 소멸하므로 미션/시간의 유일 표면이 // "1회 노출"이던 공백(실보고 2026-07-13: 목표 상태·미션 시간 UI 소멸)의 구조 봉합. // 미션 텍스트 + 경과 mm:ss 를 상단 중앙 필로 상시 고정(플레이 시작 = 첫 입력부터 계시). // ⚠️ backdrop-filter 금지(상시 요소 — sw 렌더 프레임 붕괴 실측 계약). pointer-events:none. if (mission) { var chip = document.createElement('div'); chip.id = 'playable-mission'; // ⚠️ 상단 밴드 겹침 봉합(실보고 2026-07-22): 좌 코너(뮤트+브랜드)·중앙 칩·우 코너(미니맵)가 // 좌표를 각자 주장해 좁은 화면에서 겹쳤다 — 하단 버튼을 P.pad 로 고치기 전과 동일한 병(아키타입 // HUD 주석의 "뮤트 회피/칩 회피"가 그 증거). 중앙정렬(translateX)+78vw 는 코너를 무시하므로 // 폐기하고, 칩을 *코너-사이 밴드*로 구조적으로 가둔다: 좌우 코너의 실제 rect 를 측정해 그 사이만 // 차지하고 넘치면 말줄임. 코너는 fixed·edge-고정이라 측정 1회로 족하나, 미니맵/공유가 칩보다 // 늦게 생성될 수 있어 초기 ~1.5s 재측정 후 정지. chip.style.cssText = 'position:fixed;left:14px;right:14px;' + 'top:calc(10px + env(safe-area-inset-top,0px));z-index:99940;pointer-events:none;' + 'display:flex;align-items:center;gap:8px;' + 'background:rgba(10,13,22,.78);border:1px solid rgba(255,255,255,.14);border-radius:999px;' + 'padding:7px 14px;color:#f5f7fa;font:600 12px/1.35 system-ui,sans-serif;' + 'box-shadow:0 4px 14px rgba(0,0,0,.35);'; var chip_txt = document.createElement('span'); chip_txt.textContent = mission; chip_txt.style.cssText = 'flex:1;min-width:0;color:#FFE58A;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'; var chip_t = document.createElement('span'); chip_t.textContent = '0:00'; chip_t.style.cssText = 'flex:none;color:rgba(245,247,250,.85);font-variant-numeric:tabular-nums;'; chip.appendChild(chip_txt); chip.appendChild(chip_t); document.body.appendChild(chip); // 코너-사이 밴드로 가두기 — 좌: 뮤트/브랜드의 오른쪽 끝, 우: 미니맵/공유 중 가장 왼쪽. 값이 // 바뀔 때만 style 을 써서 강제 리플로우 최소화. 미니맵/공유가 늦게 붙는 경우를 위해 초기 재측정. var _cb_l = -1, _cb_r = -1; function _chip_bound() { var lc = 14, le = document.getElementById('playable-menu') || document.getElementById('playable-mute'); if (le) lc = Math.round(le.getBoundingClientRect().right) + 8; var rc = 14, W = window.innerWidth; ['playable-minimap'].forEach(function (id) { var e = document.getElementById(id); if (e && e.style.display !== 'none') { var b = e.getBoundingClientRect(); if (b.width > 0) rc = Math.max(rc, Math.round(W - b.left) + 8); } }); if (lc !== _cb_l) { _cb_l = lc; chip.style.left = lc + 'px'; } if (rc !== _cb_r) { _cb_r = rc; chip.style.right = rc + 'px'; } } _chip_bound(); var _cb_n = 0; (function _cb_tick() { if (_cb_n++ > 90) return; requestAnimationFrame(_cb_tick); _chip_bound(); })(); // ~1.5s window.addEventListener('resize', _chip_bound); var _mt0 = 0; function _mt_start() { if (_mt0) return; _mt0 = performance.now(); window.removeEventListener('pointerdown', _mt_start); window.removeEventListener('keydown', _mt_start); setInterval(function () { var s = Math.floor((performance.now() - _mt0) / 1000); chip_t.textContent = Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); }, 1000); } window.addEventListener('pointerdown', _mt_start); window.addEventListener('keydown', _mt_start); } })(); var DEAD = 0.12, STICK_R = 56; // 스틱 UI(DOM) var base = document.createElement('div'); base.id = 'playable-stick-base'; base.style.cssText = 'position:fixed;width:112px;height:112px;border-radius:50%;border:2px solid rgba(255,255,255,.25);background:rgba(255,255,255,.06);pointer-events:none;display:none;z-index:99950;'; var knob = document.createElement('div'); knob.style.cssText = 'position:absolute;left:50%;top:50%;width:48px;height:48px;margin:-24px 0 0 -24px;border-radius:50%;background:rgba(255,255,255,.35);'; base.appendChild(knob); document.body.appendChild(base); var stick_id = null, stick_ox = 0, stick_oy = 0, cam_id = null, cam_px = 0, cam_py = 0; canvas.addEventListener('pointerdown', function (e) { if (e.clientX < P.vw * 0.5 && stick_id === null) { stick_id = e.pointerId; stick_ox = e.clientX; stick_oy = e.clientY; base.style.display = 'block'; base.style.left = (stick_ox - 56) + 'px'; base.style.top = (stick_oy - 56) + 'px'; if (P.claim_pointer) P.claim_pointer(e.pointerId); // 스틱이 소비한 포인터 — 스와이프 채널 제외 } else if (cam_id === null && e.pointerId !== stick_id) { cam_id = e.pointerId; cam_px = e.clientX; cam_py = e.clientY; if (P.claim_pointer) P.claim_pointer(e.pointerId); // 카메라 드래그 포인터 — 스와이프 채널 제외 } }); canvas.addEventListener('pointermove', function (e) { if (e.pointerId === stick_id) { var dx = e.clientX - stick_ox, dy = e.clientY - stick_oy; var d = Math.sqrt(dx * dx + dy * dy); var cl = Math.min(d, STICK_R); var nx = d > 0.001 ? dx / d : 0, ny = d > 0.001 ? dy / d : 0; knob.style.transform = 'translate(' + (nx * cl) + 'px,' + (ny * cl) + 'px)'; CTRL.force = cl / STICK_R; if (CTRL.force < DEAD) { CTRL.vec.x = 0; CTRL.vec.y = 0; CTRL.force = 0; } else { CTRL.vec.x = nx; CTRL.vec.y = ny; } } else if (e.pointerId === cam_id) { CTRL.cam_dx += (e.clientX - cam_px) * 0.005; CTRL.cam_dy += (e.clientY - cam_py) * 0.005; cam_px = e.clientX; cam_py = e.clientY; if (CAMR) CAMR.last_input = _wt; } }); function up(e) { if (e.pointerId === stick_id) { stick_id = null; CTRL.vec.x = 0; CTRL.vec.y = 0; CTRL.force = 0; knob.style.transform = ''; base.style.display = 'none'; } if (e.pointerId === cam_id) cam_id = null; } canvas.addEventListener('pointerup', up); canvas.addEventListener('pointercancel', up); // 점프 채널 분리 — 스와이프-업은 스틱 해제/카메라 드래그와 같은 포인터를 공유해 상시 오발했고 // (전진 후 정지·피치 드래그 해제마다 점프), 키보드 'up'(W/↑)은 전진 이동과 겹친다(걷기=깡충). // 정본 = 전용 버튼(모바일, 겐신 동형) + Space(데스크톱). function _do_jump() { if (world._priv.ext_jump) return world._priv.ext_jump(); // craft 등 외부 플레이어 소유자 위임 // 경로 탑승 중 점프 = 하차(하차 후 통상 점프로 이어짐) for (var _tj = 0; _tj < _tracks.length; _tj++) if (_tracks[_tj].riding) { _tracks[_tj].api.unboard(); break; } if (!PL) return; if (!PL.on_ground) { // 점프 필 사다리 2/2 — 코요테(이탈 후 0.12s) 불통과 시 입력 버퍼(착지 시 물리 틱이 소비) if (!(PL._cy != null && PL._cy < 0.12 && PL.vy <= 0.01)) { PL._jbuf = 0.13; return; } } PL._cy = 9; // 코요테 소진(공중 재점프 차단) PL.vy = PL.jump_v; PL.on_ground = false; // 로코모션 동사 = 이동 주체 소유(상단 state.jump 와 동일 계약 — 사람 기합이 말 위에서 나던 봉합) _msfx_verb(_mount ? _msfx_mount_slot() : _msfx.smp, 'jump', _MSFX_COM.jump, 1, true); var _jt = _mount ? _mount.h : PL.handle; // 탑승 중 점프 모션은 동물 소유(Gallop_Jump 계열) if (_jt._actions && _jt._actions.jump) _jt.play('jump', { fade: 0.1 }); else if (!_mount) { // 점프 클립 부재 폴백 — PL.jump 와 동일 계약(도약 스쿼시 + 공중 포즈 홀드) if (P.fx && P.fx.squash) P.fx.squash(PL.handle.obj, { amount: 0.2, dur: 0.14 }); if (PL.handle._mixer) { PL.handle._mixer.timeScale = 0.22; PL._air_slow = true; } } } if (PL) PL._jump_fn = _do_jump; // 착지 프레임의 버퍼 소비 경로(물리 틱이 호출) — craft 경로는 world 플레이어 부재(상호배타)라 가드 필수 // 보편 동사: 공격/부수기 — 상시 버튼(모바일) + F 키(데스크톱). 로직 무관 항상 제공. if (o.smash !== false) { P.on_key(function (k) { if (k === 'f' || k === 'F') { if (world._priv.smash) world._priv.smash(); } }); var sb = document.createElement('button'); sb.id = 'playable-smash'; sb.type = 'button'; sb.innerHTML = '
'; P.pad.face(sb, 'primary', { label: ['부수기', '壊す', 'Smash'] }); // 좌표·글리프는 레지스트리 전담 sb.addEventListener('pointerdown', function (e) { e.preventDefault(); e.stopPropagation(); if (world._priv.smash) world._priv.smash(); }); document.body.appendChild(sb); } if (o.jump !== false) { P.on_key(function (k, e) { if (k === ' ') { _do_jump(); if (e && e.preventDefault) e.preventDefault(); } }); CTRL.jump_held = false; // 외부 플레이어(craft)용 홀드 신호 — PL 물리는 P.keys/_jb_held 를 계속 사용 var jb = document.createElement('button'); jb.id = 'playable-jump'; jb.type = 'button'; jb.innerHTML = '
'; P.pad.face(jb, 'jump', { label: ['점프', 'ジャンプ', 'Jump'] }); jb.addEventListener('pointerdown', function (e) { e.preventDefault(); e.stopPropagation(); if (PL) PL._jb_held = true; CTRL.jump_held = true; _do_jump(); }); ['pointerup', 'pointercancel', 'pointerleave'].forEach(function (ev) { jb.addEventListener(ev, function () { if (PL) PL._jb_held = false; CTRL.jump_held = false; }); }); document.body.appendChild(jb); } return CTRL; }; // ── 프리미티브 스캐터(청크-결정론 풀) — 종류당 전역 InstancedMesh 1개, .count 갱신 = 무비용 ── var _pools = []; // {im, per_chunk, max, slots:Map(chunkKey→[start,count]), free:[], y_off, min_h, max_h, scale} var _slot_next = 0; // L1 지터드-그리드 슬롯 커서(풀 생성 순 누적 — 100 초과분은 폴백 경로) function _slot_blocked(x, z, r, from_grid) { // L2 — collider 공간 해시(8m 셀) 3×3 근접 거부 var cx = Math.floor(x / 8), cz = Math.floor(z / 8); for (var i = -1; i <= 1; i++) { for (var j = -1; j <= 1; j++) { var arr = _colliders.get((cx + i) + ',' + (cz + j)); if (!arr) continue; for (var k = 0; k < arr.length; k++) { var c = arr[k]; if (from_grid && c._grid) continue; // 그리드 네이티브 간 분리는 L1 소유 — 상호 오거부(밀도 반토막 실측) 차단 var dx = x - c.x, dz = z - c.z, rr = c.r + r; if (dx * dx + dz * dz < rr * rr) return true; } } } return false; } function _pool_fill_chunk(ci, cj, key) { if (!T) return; for (var p = 0; p < _pools.length; p++) { var pool = _pools[p]; if (pool.slots.has(key)) continue; if (pool.free.length < pool.per_chunk) { // 이론상 불가(상주 최대 기반 상한) — 도달 = 불변식 위반, 침묵 금지 if (!pool._starved) { pool._starved = true; try { console.warn('[world] scatter: slot pool exhausted — a chunk streamed in EMPTY (invariant breach, raise cap)'); } catch (e) {} } continue; } // ── 배치 = 계층형 흡수 사다리(업계 정본: 식생 배치 blue-noise 계열 — 지터드-그리드 근사) ── // (연혁: 시드가 (지형시드,청크)만 → 전 풀 동일 좌표열 완전 적층 실사고 2026-07-13 → 소금 // 도입 → 소금도 독립 난수열 간 확률 충돌은 잔존 → 아래 L1/L2 로 구조 봉합.) // L1 예방: 청크 공유 지터드-그리드(10×10 결정론 순열) — 풀별 slot_base 구간 분리로 서로 다른 // 풀이 항상 서로 다른 서브셀 = 교차-풀 포개짐의 표현 불가능화(소금만으로는 독립 난수열 간 // 확률 충돌이 잔존 — 시뮬레이션 실측 8풀 25청크당 11쌍). 인접 서브셀 지터 후 최소 간격 // ≈ 0.3×서브셀(청크 64m 기준 ~1.9m). 좌표는 슬롯-결정론(srng — min_h 스킵이 시퀀스를 // 오염하지 않아 재방문 동일 재생성 계약 유지). // L2 흡수: 그리드 밖 충돌원(링 식재/POI/로직 spawn 콜라이더·슬롯 초과 폴백)은 collider // 공간 해시 근접 거부(밀도 1 감소는 무해, 관통 겹침은 유해 — 거부가 정답). // L3 게이트: playable_scatter_smoke 가 교차-풀 최소거리를 상시 단정(pre-commit 스위트). var perm = null; function _slot_of(idx) { if (!perm) { perm = new Array(100); for (var pi = 0; pi < 100; pi++) perm[pi] = pi; var spr = mulberry32((((T.seed * 73856093) ^ (ci * 19349663) ^ (cj * 83492791)) ^ 0x51ed270b) >>> 0); for (var pj = 99; pj > 0; pj--) { var pk = (spr() * (pj + 1)) | 0; var pt = perm[pj]; perm[pj] = perm[pk]; perm[pk] = pt; } } return perm[idx % 100]; } var chash = ((T.seed * 73856093) ^ (ci * 19349663) ^ (cj * 83492791)) >>> 0; var used = [], cols = []; for (var n = 0; n < pool.per_chunk; n++) { var srng = mulberry32((chash ^ Math.imul(pool.salt, 2654435761) ^ Math.imul(n + 1, 0x9E3779B9)) >>> 0); var gidx = pool.slot_base + n, wx, wz; if (gidx < 100) { // L1 그리드 경로 var s = _slot_of(gidx); var cs = T.chunk / 10; wx = ci * T.chunk - T.chunk / 2 + ((s % 10) + 0.5) * cs + (srng() - 0.5) * cs * 0.7; wz = cj * T.chunk - T.chunk / 2 + (((s / 10) | 0) + 0.5) * cs + (srng() - 0.5) * cs * 0.7; } else { // 슬롯 초과 폴백 — 자유 지터(L2 가 흡수) wx = ci * T.chunk + (srng() - 0.5) * T.chunk; wz = cj * T.chunk + (srng() - 0.5) * T.chunk; } if (world.floor_covers(wx, wz)) continue; // 실내 컷 — 선언된 바닥면(건물 안)에는 지형 장식을 두지 않는다 var h = world.height_at(wx, wz); var hn = (h / Math.max(T.amp, 0.25) + 1) / 2; // 정규화 하한 = 색 밴드와 동일 캐논(near-flat 에서 min_h/max_h 필터가 노이즈 추첨이 되는 것 차단) if (hn < pool.min_h || hn > pool.max_h) continue; var sc = pool.scale_r[0] + srng() * (pool.scale_r[1] - pool.scale_r[0]); // [min,max] 범위 변주 if (_slot_blocked(wx, wz, Math.max(pool.collide_r * sc, 0.5), gidx < 100)) continue; // L2 거부(그리드 후보는 비그리드 충돌원만 검사) var slot = pool.free.pop(); if (slot == null) break; pool._q.setFromAxisAngle(pool._up, srng() * Math.PI * 2); pool._m4.compose(pool._v.set(wx, h + pool.y_off, wz), pool._q, pool._s.set(sc, sc, sc)); for (var pq = 0; pq < pool.ims.length; pq++) { // 다부품 = 동일 배치 × 부품 로컬 오프셋 var PT = pool.ims[pq]; if (PT.off) { pool._m4b.multiplyMatrices(pool._m4, PT.off); PT.im.setMatrixAt(slot, pool._m4b); } else PT.im.setMatrixAt(slot, pool._m4); } if (slot + 1 > pool.hi) pool.hi = slot + 1; // L0 하이워터 used.push(slot); if (pool.collide_r > 0) { var _pc = world.collider(wx, wz, pool.collide_r * sc); _pc._grid = gidx < 100; cols.push(_pc); } } pool.slots.set(key, used); if (cols.length) pool.cols.set(key, cols); // 청크 언로드 시 회수(재방문 적층 차단) pool.dirty = true; } } function _pool_release_chunk(key) { for (var p = 0; p < _pools.length; p++) { var pool = _pools[p]; var used = pool.slots.get(key); if (!used) continue; for (var i = 0; i < used.length; i++) { pool._m4.makeScale(0, 0, 0); // 슬롯 숨김(0 스케일) 후 재사용 풀로 for (var pq2 = 0; pq2 < pool.ims.length; pq2++) pool.ims[pq2].im.setMatrixAt(used[i], pool._m4); pool.free.push(used[i]); } pool.slots.delete(key); var cs = pool.cols.get(key); if (cs) { for (var c2 = 0; c2 < cs.length; c2++) _remove_collider(cs[c2]); pool.cols.delete(key); } pool.dirty = true; } } // scatter(sampleMesh, {per_chunk, scale, y_offset, min_h, max_h, collide_r}) — 지형 청크에 결정론 배치. // ── 패밀리/이름 스캐터 — world.scatter('tree'|'rock'|'flower'|'bush'|'mushroom'|<모델명>, opts). // 생성기가 게임마다 카탈로그에서 범주별 다양 표본(families)을 인라인 배선하고, 셸이 그 표본을 // *다종 혼합* 스트리밍 스캐터로 소비한다 — LLM 은 범주 토큰 하나만 쓰면 종 다양성은 구조가 // 소유(프리미티브 단일종 매스 레이어의 "전부 똑같은 나무" 실보고 봉합). per_chunk 는 총량으로 // 해석해 종 수로 분할. 스킨드는 인스턴싱 불가라 제외. GLB 도착 순서대로 슬롯이 비동기 합류 // (콘텐츠-해시 소형 파일 — 셀 채움은 pool 등록 시 즉시). 부재 시: opts.fallback(셸 내부 — // 아키타입 프리미티브)이 있으면 그것으로, 없으면 LOUD 1회 후 무배치(침묵-실패 금지). function _scatter_named(token, opts) { var o = opts || {}; var H = { token: token, slots: [] }; // 매니페스트 fetch 실패 시 _with_manifest 콜백은 드롭된다(기존 계약) — 구 프리미티브 매스 // 레이어는 매니페스트 무의존이었으므로, fallback 이 있으면 9s 안전망으로 무빈땅 보장을 승계. // ⚠️ 단일-소유권 sentinel(_fb_fired): 타이머와 매니페스트 콜백 중 *먼저 배치한 쪽이 소유*. // 구 가드(if(_fb_t))는 "타이머 없음"과 "이미 발화"를 구분 못해, 매니페스트가 9s 를 넘겨 // *성공*하면 폴백 층 + 실제 층이 이중 배치되던 확정 결함(2026-07-13 감사)의 구조 봉합 — // 발화 후 매니페스트 경로는 no-op(hold-last-LOD 정본: 늦은 고품질로 교체 churn 하지 않고 // 저하 상태를 일관 유지). 흡수는 LOUD+비콘으로 관측. var _fb_t = null, _fb_fired = false; if (o.fallback) { _fb_t = setTimeout(function () { _fb_t = null; _fb_fired = true; var fo = Object.assign({}, o); delete fo.fallback; H.slots.push(world.scatter(o.fallback, fo)); }, 9000); } _with_manifest(function (m) { if (_fb_t) { clearTimeout(_fb_t); _fb_t = null; } if (_fb_fired) { try { console.warn('[world] scatter("' + token + '"): manifest arrived after 9s fallback — keeping fallback layer (no double-place)'); } catch (e) {} if (window.__playable_beacon) window.__playable_beacon('scatter_fallback_late', String(token).slice(0, 60)); return; } var _ext2 = window['__PLAYABLE_'+'EXTRA_MANIFEST__']; var fams = (_ext2 && _ext2.families) || {}; // 바이옴 재지정(opts.biome — closed enum) — 미배선 바이옴은 LOUD 후 게임 기본 팔레트로 흡수(혼돈 방지) if (o.biome) { var _fbio = _ext2 && _ext2.families_biome; if (_fbio && _fbio[o.biome]) fams = _fbio[o.biome]; else { try { console.warn('[world] scatter biome "' + o.biome + '" not wired — using game default palette'); } catch (e) {} // 생성기 derive 게이팅(로직 리터럴 참조 바이옴만 발행)의 잔여 실패율 계측 — 흡수는 관측 가능해야 한다 if (window.__playable_beacon) window.__playable_beacon('scatter_biome_unwired', String(o.biome).slice(0, 30)); } } var names = (fams[token] || (m.models && m.models[token] ? [token] : [])).filter(function (n) { var sp = m.models && m.models[n]; return sp && !sp.skinned; }); if (!names.length) { if (o.fallback) { var fo = Object.assign({}, o); delete fo.fallback; H.slots.push(world.scatter(o.fallback, fo)); return; } try { console.warn('[world] scatter("' + token + '"): no wired family/model for this game — nothing scattered'); } catch (e) {} if (window.__playable_beacon) window.__playable_beacon('scatter_unwired', String(token).slice(0, 60)); // spawn_unknown 의 scatter 대칭 — 흡수 계측 return; } var per_each = Math.max(1, Math.round(clamp(num(o.per_chunk, 14), 1, 60) / names.length)); names.forEach(function (n) { var sp = m.models[n]; _load_glb(sp.file, function (gltf) { var inst = gltf.scene.clone(true); var s2 = num(sp.h, 1) / gltf._dims.h; // 원치수 = _load_glb 1회 실측 공유(4.8cm 계약 승계) inst.scale.setScalar(s2); inst.position.y = -gltf._dims.min_y * s2; var wrap = new THREE_.Group(); wrap.add(inst); wrap.updateMatrixWorld(true); var so = Object.assign({}, o, { per_chunk: per_each }); delete so.fallback; H.slots.push(world.scatter(wrap, so)); }); }); }); return H; } world.scatter = function (sample, opts) { if (typeof sample === 'string') return _scatter_named(sample, opts); // 다부품 샘플(Group — 줄기+수관 등) 구조 흡수: 부품별 InstancedMesh 를 만들되 배치 계산은 // 슬롯당 1회 공유(부품 로컬 오프셋 합성 → 정렬 보존). 구 코드는 sample.geometry 부재 시 // 침묵 null — 아키타입 "빈 들판 방지" 나무 스캐터가 0그루로 무배치되던 실사고(2026-07-12)의 // 진범. 침묵-실패 금지: 메시 전무 샘플만 LOUD 후 null. var parts = []; if (sample && sample.geometry) parts.push({ geo: sample.geometry, mat: sample.material, off: null }); else if (sample && sample.isObject3D) { sample.updateMatrixWorld(true); var _had_skinned = false; sample.traverse(function (c) { if (c.isSkinnedMesh) { _had_skinned = true; return; } // 인스턴싱은 본 미적용 → T-포즈 정적 떼 복제(동물 GLB 오용 프레이밍) if (c.geometry && c.material) parts.push({ geo: c.geometry, mat: c.material, off: c.matrixWorld.clone() }); }); if (_had_skinned) { try { console.warn('[world] scatter: skinned mesh in sample skipped — scatter is for static props; use world.spawn for animals'); } catch (e) {} } } if (!parts.length) { try { console.warn('[world] scatter: sample has no mesh geometry — skipped'); } catch (e) {} return null; } var o = opts || {}; // 오용 키 LOUD — make.scatter 의 count/area 어휘가 여기 오면 셸이 침묵 무시하던 혼돈 클래스 // (아키타입 작성자도 min_h/max_h 를 미터로 넘긴 실증). 경고 후 정상 진행(기본값). if (o.count != null && o.per_chunk == null) { try { console.warn('[world] scatter: "count" is make.scatter\'s option — use per_chunk (density per chunk); falling back to default'); } catch (e) {} } if ((o.min_h != null && (o.min_h < 0 || o.min_h > 1)) || (o.max_h != null && (o.max_h < 0 || o.max_h > 1))) { try { console.warn('[world] scatter: min_h/max_h are 0..1 normalized altitude (not meters) — clamping'); } catch (e) {} } var per = Math.round(clamp(num(o.per_chunk, 14), 1, 60)); // 슬롯 상한 = 상주 청크 이론 최대 × per. 언로드는 |d| > R+1 에서만 일어나므로 상주 청크는 // 최대 (2(R+1)+1)² — 구 상수 30 은 R=2 정지 상태만 가정해 대각 연속 이동(꼬리 히스테리시스 // 잔존 ~35청크)이나 radius 3 에서 free 고갈 → 전방 새 청크가 조용히 빈 채 스트리밍되는 // 방향성 맨땅(시뮬레이션 확증). T 이전 호출은 R=3 보수 상한. var _rr = (T ? T.radius : 3) + 1; var max = per * (2 * _rr + 1) * (2 * _rr + 1); var zero = new THREE_.Matrix4().makeScale(0, 0, 0); var ims = []; for (var pt = 0; pt < parts.length; pt++) { var im = new THREE_.InstancedMesh(parts[pt].geo, parts[pt].mat, max); im.frustumCulled = false; for (var i = 0; i < max; i++) im.setMatrixAt(i, zero); // ⚠️ L0 — 미할당 슬롯 렌더 원천 차단: count 를 하이워터로만 올린다(플러시에서 pool.hi 동조). // 제로필만으로는 불충분 실증(2026-07-13): r185 에서 미할당 슬롯이 identity(원점·스케일1)로 // 렌더돼 *전 종의 잔여 슬롯이 스폰 지점에 적층* — "괴상한 덩어리" 실사고의 제1 진범 // (구 프리미티브 1종 시절엔 원점의 나무 1그루로 위장돼 잠복). 제로필은 방어층으로 존치. im.count = 0; scene.add(im); ims.push({ im: im, off: parts[pt].off }); } var pool = { ims: ims, im: ims[0].im, per_chunk: per, max: max, slots: new Map(), cols: new Map(), free: [], hi: 0, // 할당 하이워터(count 상한 — 해제는 0-스케일이 숨기므로 단조 증가만) salt: _pools.length + 1, // 풀 식별 소금(슬롯별 srng 시드 + 폴백 경로) slot_base: _slot_next, // L1 지터드-그리드 풀별 슬롯 구간(생성 순 누적 — 스크립트 고정이라 결정론) y_off: num(o.y_offset, 0), min_h: clamp(num(o.min_h, 0), 0, 1), max_h: clamp(num(o.max_h, 1), 0, 1), scale_r: P._nrange(o.scale, 0.7, 1.4).map(function (v) { return clamp(v, 0.05, 20); }), collide_r: clamp(num(o.collide_r, 0), 0, 5), dirty: true, _m4: new THREE_.Matrix4(), _m4b: new THREE_.Matrix4(), _q: new THREE_.Quaternion(), _v: new THREE_.Vector3(), _s: new THREE_.Vector3(), _up: new THREE_.Vector3(0, 1, 0), }; for (var f = max - 1; f >= 0; f--) pool.free.push(f); _pools.push(pool); _slot_next += per; // L1 슬롯 구간 확정(다음 풀은 이어서) if (T) T.chunks.forEach(function (_, key) { var pp = key.split(','); _pool_fill_chunk(+pp[0], +pp[1], key); }); return pool.im; // 반환 계약 유지(대표 im) }; // ── 어포던스 — 근접 상호작용 · 수집물 자석 · 화면밖 마커 ── var _interactables = []; // {obj, radius, label, cb, el} var _btn = null; function _ensure_btn() { if (_btn) return _btn; _btn = document.createElement('button'); _btn.id = 'playable-interact'; _btn.type = 'button'; // 문맥 프롬프트 = 숄더 레일의 라벨 pill(레지스트리 배치 — 페이스 다이아몬드와 구조적 무충돌) P.pad.rail(_btn, { pill: true, extra: 'display:none;background:rgba(245,158,11,.92);color:#181205;border-color:rgba(255,255,255,.28);' }); return _btn; } // ── 보편 상호작용 계층(universal verbs) — 모든 게임에서 항상 가능한 기본 동사들 ── // 부수기(smash)·타기(ride)·장착(equip)을 셸이 스폰 시점에 자동 배선한다. 로직 저작 여부와 // 무관하게 성립하는 "세계의 기본 물리 감각" — 로직은 opt-out(breakable/rideable/equippable:false) // 또는 자체 상호작용으로 덮어쓸 수 있다(자동 등록은 반경이 좁아 로직 등록이 우선 노출됨). var _smashables = []; // {h, hp} var _uni_lang = (function () { var l = String(((typeof navigator !== 'undefined' && (navigator.language || navigator.userLanguage)) || document.documentElement.lang || 'en')).toLowerCase(); return l.indexOf('ko') === 0 ? 0 : (l.indexOf('ja') === 0 ? 1 : 2); })(); function _uni_t(ko, ja, en) { return _uni_lang === 0 ? ko : (_uni_lang === 1 ? ja : en); } var _EQUIP_BASE = ['sword_1h', 'sword_2h', 'axe', 'dagger', 'staff', 'wand', 'bow', 'crossbow', 'shield_round', 'shield_square', 'axe_tool', 'shovel', 'hammer', 'torch']; function _uni_wire(handle, spec, name, o) { if (world._priv && world._priv.craft) return; // craft 모드 = 블록-중심 동사(mount/hold 는 world PL 전제 — PL 부재 시 무동작/소실 버튼이 된다) var tags = (spec && spec.tags) || []; var has = function (t) { return tags.indexOf(t) >= 0; }; var equippable = (spec.cat === 'prop') && (has('weapon') || has('tool') || _EQUIP_BASE.indexOf(name) >= 0); var rideable = (spec.cat === 'creature' && num(spec.h, 0) >= 0.9) || has('ride') || has('mount'); if (equippable && o.equippable !== false) { world.interactable(handle, { radius: 2.2, label: '🗡 ' + _uni_t('장착', '装備', 'Equip'), on_interact: function () { handle.remove(); if (PL && PL.handle.hold) PL.handle.hold(name); if (P.fx && P.fx.pickup) P.fx.pickup({ x: handle.obj.position.x, y: handle.obj.position.y + 0.6, z: handle.obj.position.z }); } }); return; // 장착품은 파괴 대상에서 제외(검을 부수는 모순 차단) } if (rideable && o.rideable !== false) { var _float = has('water') || has('sea') || has('boat') || has('ship') || has('sail'); var _rapi = null; var it = world.interactable(handle, { radius: 2.4, label: '🐎 ' + _uni_t('타기', '乗る', 'Ride'), on_interact: function (self) { if (_rapi && _rapi.mounted) { _rapi.dismount(); _rapi = null; self.label = '🐎 ' + _uni_t('타기', '乗る', 'Ride'); return; } _rapi = world.mount(handle, { float: _float }); if (_rapi) self.label = '⤓ ' + _uni_t('내리기', '降りる', 'Dismount'); } }); } if ((spec.cat === 'prop' || spec.cat === 'nature') && !equippable && o.breakable !== false) { _smashables.push({ h: handle, hp: 3, name: name }); } if (spec.cat === 'creature' && o.breakable !== false) { _smashables.push({ h: handle, hp: 2, name: name, creature: true }); // 동물 = 피격 리액션 + 쓰러짐(die 클립) 후 소멸 } } // ── 모션 효과음(msfx) — 전 게임 자동 포함: 캐릭터 신원(cat·이름 토큰·실측 h)에 맞는 발소리/ // 도약·착지/스윙·명중/엔진 험을 셸이 스스로 발화(zzfx 합성 = 다운로드·라이선스 0). LLM 지시 // 불요(자동 풍요 바닥선과 동형 철학) — opt-out 은 P.sfx.motion({ enabled:false }) / volume. // 마스터 음소거는 AudioContext suspend 라 자동 편입. 케이던스는 실이동 거리 기반(탈것 속도 // 배율·아날로그 force 감쇠를 자동 흡수), 스텝마다 피치 지터(기계적 반복감 제거 — 업계 정본). var _msfx = { on: true, vol: 1, kind: 'human', h: 1.7, name: '', spec_read: true, dist: 0, cool: 0, px: 0, pz: 0, eng: null, smp: {} }; var _MSFX_KIT = { human: { stride: 0.78, step: [.3, .15, 88, , .006, .022, 4, 1.2] }, hoof: { stride: 0.95, step: [.42, .12, 190, , .004, .028, 1, 2.9, -24], voice: [.35, .2, 150, .03, .08, .14, 4, .7, , , , , .07, 1.2] }, paw: { stride: 0.7, step: [.2, .2, 70, , .005, .018, 4, .9], voice: [.35, .15, 520, .01, .05, .1, 1, 1.3, , , 90, .04] }, wing: { stride: 1.05, step: [.26, .2, 130, .008, .025, .05, 4, .5], voice: [.35, .1, 880, .01, .04, .07, 1, 1.6, , , 170, .03] }, frog: { stride: 0.85, step: [.3, .15, 150, , .01, .05, 1, 1.8, 14], voice: [.45, .1, 130, .02, .09, .12, 2, .9, , , -25, .09] }, vehicle: { stride: 0, engine: true, voice: [.5, , 620, .01, .06, .12, 1, 1.4, , , 60, .05] }, // voice = 짧은 경적 object: { stride: 0.8, step: [.24, .12, 120, , .008, .03, 4, 1.9] }, }; var _MSFX_COM = { // kind 공통 동사 jump: [.4, , 250, .015, .035, .09, 1, 1.7, 52], land: [.5, .1, 80, , .01, .06, 4, 2], splash: [.38, .25, 820, .01, .03, .1, 4, .4, -32], swing: [.32, .2, 460, .008, .02, .055, 4, .35, -38], hit: [.6, .1, 150, , .02, .09, 4, 2.1], hurt: [.45, .12, 230, .005, .03, .1, 4, 1.4, -18], // 피격 성문(L2 합성 — 실녹음 세트가 L1) death: [.55, .1, 170, .01, .09, .26, 1, 1.1, -9, , -55, .07], // 하강 사망음 }; function _msfx_kind_of(name, spec) { var n = String(name || '').toLowerCase(); // 이름 토큰 우선(차량은 팩에서 prop) — 물리량/토큰 판별 원칙(태그 단독 판별 금지: 실수목 실사고와 동형) if (/car|truck|jeep|kart|tractor|bus|van|ambulance|firetruck|police|taxi|race|tank_|_tank|jet|plane|heli|boat_motor|submarine|carrier|destroyer/.test(n)) return 'vehicle'; var cat = spec && spec.cat; if (cat === 'creature') { if (/horse|deer|elk|moose|cow|bull|goat|sheep|donkey|zebra|unicorn|camel|pig|boar|llama|alpaca|giraffe/.test(n)) return 'hoof'; if (/bird|crow|chicken|duck|goose|penguin|owl|eagle|parrot|flamingo|seagull|hen|rooster/.test(n)) return 'wing'; if (/frog|toad/.test(n)) return 'frog'; return 'paw'; } if (cat === 'prop') return 'object'; return 'human'; } function _msfx_set(name) { _msfx.name = String(name || ''); _msfx.spec_read = false; // spec(비동기 매니페스트)은 틱에서 지연 확정 — 이름 토큰으로 우선 분류 _msfx.kind = _msfx_kind_of(_msfx.name, _manifest && _manifest.models && _manifest.models[_msfx.name]); _msfx.h = 1.7; _msfx.dist = 0; _msfx.smp = {}; if (_msfx.name) _msfx_resolve(_msfx.name, _manifest && _manifest.models && _manifest.models[_msfx.name], _msfx.smp); } function _msfx_play(par, volm, jit) { if (!_msfx.on || !P.sfx || !par) return; var a = par.slice(); a[0] = (a[0] || 0.3) * _msfx.vol * (volm || 1); if (jit && a[2]) a[2] = a[2] * (0.92 + Math.random() * 0.16); P.sfx(a); } function _msfx_engine(on, spd) { // 지속 험 = 오실레이터 노드(zzfx 반복 재생은 이음새가 튄다) var ctx = window.__zzfx_ctx; if (!ctx) return; if (!on) { if (_msfx.eng) { var E0 = _msfx.eng; _msfx.eng = null; try { E0.g.gain.setTargetAtTime(0, ctx.currentTime, 0.1); setTimeout(function () { try { E0.o1.stop(); E0.o2.stop(); } catch (e) {} }, 600); } catch (e) {} } return; } if (!_msfx.eng) { try { var o1 = ctx.createOscillator(), o2 = ctx.createOscillator(), g = ctx.createGain(), f = ctx.createBiquadFilter(); o1.type = 'sawtooth'; o2.type = 'square'; f.type = 'lowpass'; f.frequency.value = 380; g.gain.value = 0; o1.connect(f); o2.connect(f); f.connect(g); g.connect(ctx.destination); o1.start(); o2.start(); _msfx.eng = { o1: o1, o2: o2, g: g }; } catch (e) { _msfx.eng = null; return; } } var rpm = 44 + spd * 92; try { // 게인은 절대값(직결 노드는 zzfxV 미경유) — 저음량 험, 뮤트는 ctx suspend 가 흡수 _msfx.eng.o1.frequency.setTargetAtTime(rpm, ctx.currentTime, 0.08); _msfx.eng.o2.frequency.setTargetAtTime(rpm * 1.98, ctx.currentTime, 0.08); _msfx.eng.g.gain.setTargetAtTime((0.022 + spd * 0.05) * _msfx.vol, ctx.currentTime, 0.1); } catch (e) {} } if (P.sfx) { P.sfx.motion = function (mo) { // 공개 계약: 무음 아트 디렉션 opt-out / 볼륨 mo = mo || {}; if (mo.enabled != null) _msfx.on = !!mo.enabled; if (mo.volume != null) _msfx.vol = clamp(num(mo.volume, 1), 0, 2); if (!_msfx.on) _msfx_engine(false, 0); }; } // ── 실녹음 SFX 매니페스트 — 캐릭터마다 개별 매칭된 진짜 효과음 세트(L1). 로드 전/실패/매핑 // 부재는 위 zzfx 합성 프리셋(L2)이 그대로 받는다(흡수 사다리 — 무음 회귀·기능 회귀 없음). // sets: {세트명:{urls:[...]}}, model_map: {모델명:{step|splash|jump|land|swing|hit|voice|hurt|death:세트명}}, // class_rules: [{tokens?:정규식, cat?:카테고리, tag?:태그, map:{...}}] — 확장 에셋 자동 커버. var _sfx_mf = null, _sfx_mf_pending = [], _sfx_mf_tried = false; function _with_sfx_manifest(fn) { if (_sfx_mf) return fn(_sfx_mf); _sfx_mf_pending.push(fn); if (_sfx_mf_tried) return; _sfx_mf_tried = true; var base = _vendor_base().replace(/\/vendor\/world-pack\/$/, '/vendor-ext/'); fetch(base + 'sfx.manifest.v1.json') .then(function (r) { if (!r.ok) throw new Error('http ' + r.status); return r.json(); }) .then(function (m) { _sfx_mf = m && m.sets ? m : { sets: {}, model_map: {}, class_rules: [] }; var pd = _sfx_mf_pending; _sfx_mf_pending = []; pd.forEach(function (f) { try { f(_sfx_mf); } catch (e) {} }); }) .catch(function (e) { // 매니페스트 부재 = 전면 L2 폴백(현행 합성음) — LOUD 1회 후 무해 try { console.warn('[sfx] manifest load failed — synth fallback for all motion sfx: ' + (e && e.message)); } catch (_) {} _sfx_mf_pending = []; }); } function _msfx_resolve(name, spec, into) { // into: 대상 슬롯 객체({} — _msfx.smp 또는 mount 캐시 엔트리). 해석+프리로드는 비동기(매니페스트 도착 시). into.ready = false; _with_sfx_manifest(function (mf) { var map = (mf.model_map && mf.model_map[name]) || null; if (!map && mf.class_rules) { var nm = String(name || '').toLowerCase(); var tags = (spec && spec.tags) || []; for (var ri = 0; ri < mf.class_rules.length; ri++) { var R = mf.class_rules[ri]; if (R.tokens && !(new RegExp(R.tokens)).test(nm)) continue; if (R.cat && (!spec || spec.cat !== R.cat)) continue; if (R.tag && tags.indexOf(R.tag) < 0) continue; map = R.map; break; } } if (!map) return; // 매핑 부재 = L2 유지 var _origin = _vendor_base().replace(/\/vendor\/world-pack\/$/, ''); // GLB 확장 에셋과 동일 절대화 규약('/'-루트 = origin + path) var out = {}; for (var vb in map) { var st = mf.sets && mf.sets[map[vb]]; if (st && st.urls && st.urls.length) { var abs = st.urls.map(function (u) { return u.charAt(0) === '/' ? _origin + u : u; }); out[vb] = { u: abs, i: (Math.random() * abs.length) | 0 }; for (var ui = 0; ui < abs.length; ui++) P.sfx._bank_load(abs[ui]); // 백그라운드 프리로드 } } into.v = out; into.ready = true; }); } var _msfx_slot_cache = new Map(); // 모델명 → resolve 슬롯(소형 — 탑승물/몹/파괴 대상 공용, capped 프루닝) function _msfx_slot_for(name, spec) { if (!name) return null; var sl = _msfx_slot_cache.get(name); if (!sl) { if (_msfx_slot_cache.size > 64) _msfx_slot_cache.clear(); // 오디오 버퍼는 bank 별도 캐시라 재해석 무해 sl = {}; _msfx_slot_cache.set(name, sl); _msfx_resolve(name, spec, sl); } return sl; } function _msfx_mount_slot() { // 탑승물 실녹음 세트(이름 키 소형 캐시) if (!_mount || !_mount.h || !_mount.h.name) return null; return _msfx_slot_for(_mount.h.name, _mount.h._spec); } function _msfx_verb(slot, verb, fallback_params, volm, jit) { // L1: 해당 캐릭터의 실녹음 세트(디코드 완료분만) — 테이크 라운드로빈. L2: zzfx 합성. if (!_msfx.on) return; if (verb === 'jump' && P._no_jump_voice) { _msfx_play(fallback_params, volm, jit); return; } // 점프=효과음만(사람 목소리 제거 — 아키타입 선언) var S = slot && slot.ready && slot.v && slot.v[verb]; if (S) { var buf = P.sfx._bank_get(S.u[S.i % S.u.length]); if (buf) { S.i++; if (P.sfx._bank_play(buf, 0.9 * _msfx.vol * (volm || 1))) return; } } _msfx_play(fallback_params, volm, jit); } // combat/fps 등 별도 IIFE 셸이 몹·대상 신원별 실녹음 동사(hurt/death/hit …)를 발화하는 공식 표면. // slot_for = 이름 키 캐시 해석(비동기 프리로드), verb = L1 실녹음→L2 zzfx 흡수 사다리 그대로. world._priv.msfx = { slot_for: _msfx_slot_for, verb: _msfx_verb, com: _MSFX_COM, player_slot: function () { return _msfx.smp; }, }; // 부수기 실행 — 반경 내 최근접 파괴가능 대상(수집물/상호작용/탑승 동물은 제외) function _do_smash() { if (!PL) return; var pp = PL.handle.obj.position; // 공격 모션은 항상(대상 유무 무관 — 헛스윙도 동사다) if (PL.handle._actions && PL.handle._actions.attack && PL.handle.play_once && !_mount) PL.handle.play_once('attack', { fade: 0.08 }); _msfx_verb(_msfx.smp, 'swing', _MSFX_COM.swing, 1, true); var best = null, bd = 2.6; for (var si = _smashables.length - 1; si >= 0; si--) { var sm = _smashables[si]; if (!sm.h.obj.parent || sm.h._removed) { _smashables.splice(si, 1); continue; } if (_mount && _mount.h === sm.h) continue; var d = sm.h.obj.position.distanceTo(pp); if (d < bd) { var ok = true, tobj = sm.h.obj; for (var ci3 = 0; ci3 < _collectibles.length; ci3++) if (_collectibles[ci3].obj === tobj) { ok = false; break; } if (ok) { bd = d; best = sm; } } } // 몹(combat 소유) 우선 — 넉백/HP바/사망은 combat 의 hurt 경로가 정본 if (world._priv_combat) { var _cms = world._priv_combat.mobs(); for (var cmi = 0; cmi < _cms.length; cmi++) { var cm = _cms[cmi]; if (cm.state === 'dead') continue; var cmd = cm.handle.obj.position.distanceTo(pp); if (cmd < bd) { world._priv_combat.hurt(cm, 1, pp, { intensity: 0.5 }); return; } } } if (!best) return; var bp = best.h.obj.position; best.hp -= 1; // hit 는 대상 재질/신원 세트 우선(나무상자=목재 파열, 동물=육질 — class_rules 자동 커버), 부재 시 플레이어 프로필 var _tsl = best.h.name ? _msfx_slot_for(best.h.name, best.h._spec) : null; _msfx_verb(_tsl && _tsl.ready && _tsl.v && _tsl.v.hit ? _tsl : _msfx.smp, 'hit', _MSFX_COM.hit, 1, true); if (best.creature && best.hp > 0) _msfx_verb(_tsl, 'hurt', _MSFX_COM.hurt, 0.85, true); // 피격 성문(임팩트+보컬 병행 = 장르 정본) if (P.fx) { if (P.fx.impact) P.fx.impact({ x: bp.x, y: bp.y + 0.6, z: bp.z }); if (P.fx.squash) P.fx.squash(best.h.obj, { amount: 0.18, dur: 0.12 }); } if (best.creature && best.h.play_once && best.h._actions && best.h._actions.hit && best.hp > 0) best.h.play_once('hit', { fade: 0.08 }); if (best.hp <= 0) { var bi2 = _smashables.indexOf(best); if (bi2 >= 0) _smashables.splice(bi2, 1); if (best.creature) _msfx_verb(_tsl, 'death', _MSFX_COM.death, 1, true); // 사망 성문(실녹음 우선) if (best.creature && best.h.play_once && best.h._actions && best.h._actions.die) { // 쓰러짐 연출 후 소멸 — 즉시 remove 는 "맞자마자 증발"(사망 모션 실보고 계약) var _bh = best.h; _bh.play_once('die', { fade: 0.08 }); setTimeout(function () { if (P.fx && P.fx.despawn) P.fx.despawn(_bh.obj, { dur: 0.4 }); setTimeout(function () { _bh.remove(); }, 450); }, 900); } else { if (P.fx && P.fx.explode) P.fx.explode({ x: bp.x, y: bp.y + 0.5, z: bp.z }); best.h.remove(); } } } world._priv.smash = _do_smash; // 스모크/로직 프로그래매틱 접근 world._priv._smashables = _smashables; world.interactable = function (obj, opts) { var o = opts || {}; var it = { obj: obj && obj.obj ? obj.obj : obj, radius: clamp(num(o.radius, 2.6), 0.5, 10), label: String(o.label || 'Interact'), cb: typeof o.on_interact === 'function' ? o.on_interact : null, active: false, }; _interactables.push(it); _ensure_btn(); return { remove: function () { var i = _interactables.indexOf(it); if (i >= 0) _interactables.splice(i, 1); } }; }; var _collectibles = []; // {obj, magnet, collect, cb, taken} world.collectible = function (obj, opts) { var o = opts || {}; var c = { h: (obj && obj.obj) ? obj : null, // spawn 핸들이면 보관 — 수집 시 콜라이더 회수용 obj: obj && obj.obj ? obj.obj : obj, magnet: clamp(num(o.magnet, 4), 0.5, 12), collect: clamp(num(o.collect, 0.8), 0.2, 3), cb: typeof o.on_collect === 'function' ? o.on_collect : null, vel: 0, taken: false, spin: o.spin !== false, }; _collectibles.push(c); return c; }; var _markers = []; // {target, el, ar, margin} world.marker = function (target, opts) { var o = opts || {}; var el = document.createElement('div'); el.className = 'playable-marker'; // UI 존 스모크 관측면(공유 버튼 등 고정 존과의 교차 단정) el.style.cssText = 'position:fixed;z-index:99940;pointer-events:none;display:none;' + 'color:#FFE58A;font:700 13px system-ui,sans-serif;text-shadow:0 1px 4px rgba(0,0,0,.6);' + 'white-space:nowrap;transform:translate(-50%,-50%);'; var ar = document.createElement('span'); ar.style.cssText = 'display:inline-block;'; ar.textContent = o.icon || '➤'; el.appendChild(ar); el.appendChild(document.createTextNode(o.label ? ' ' + o.label : '')); document.body.appendChild(el); var mk = { target: target && target.obj ? target.obj : target, el: el, ar: ar, margin: 48 }; _markers.push(mk); var _txt = el.childNodes[1]; // label text node (created above — empty string when no label) return { remove: function () { el.remove(); var i = _markers.indexOf(mk); if (i >= 0) _markers.splice(i, 1); }, // Live label update (service counters etc.) — text-node write only: no re-layout of the marker // structure, and the projection loop keeps owning position/visibility untouched. set_label: function (t) { _txt.nodeValue = t ? ' ' + String(t) : ''; }, }; }; // ── 경로-추종 캐리어(track) — 기차·롤러코스터·케이블카·짚라인의 단일 시스템 ── // 캐리어(빈 그룹)가 Catmull-Rom 경로를 순환/왕복 주행. 로직은 캐리어에 모델을 attach 하고 // board() 로 플레이어를 태운다(탑승 중 이동 물리 오버라이드, 점프 = 하차). var _tracks = []; var _trk_p = null, _trk_n = null; // 틱 스크래치(three 로드 후 지연 생성) world.track = function (opts) { var o = opts || {}; var path = Array.isArray(o.path) ? o.path.filter(function (pt) { return Array.isArray(pt) && pt.length >= 2; }) : []; if (path.length < 2) { try { console.warn('[world] track: path 는 [[x,z],...] 2점 이상이 필요'); } catch (e) {} return null; } // 경로 샘플(강과 동일 Catmull-Rom) + y = 지형 스냅 + y_off(케이블카 = 큰 y_off). // loop = 기하 폐합(마지막→첫 세그먼트 포함, 인덱스 랩) — 미폐합 순환은 랩마다 텔레포트가 된다. var closed = o.loop !== false; var _pk = function (j) { if (closed) return path[((j % path.length) + path.length) % path.length]; return path[Math.max(0, Math.min(path.length - 1, j))]; }; var pts = []; for (var i = 0; i < (closed ? path.length : path.length - 1); i++) { var p0 = _pk(i - 1), p1 = _pk(i), p2 = _pk(i + 1), p3 = _pk(i + 2); for (var t = 0; t < 8; t++) { var u = t / 8, u2 = u * u, u3 = u2 * u; pts.push([ 0.5 * ((2 * p1[0]) + (-p0[0] + p2[0]) * u + (2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * u2 + (-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * u3), 0.5 * ((2 * p1[1]) + (-p0[1] + p2[1]) * u + (2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * u2 + (-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * u3), ]); } } pts.push((closed ? path[0] : path[path.length - 1]).slice(0, 2)); // 폐합 시 종점 = 시작점(cum 이 폐합 거리 포함) var y_off = num(o.y_off, 0.3); var hy = world.height_at; var P3 = pts.map(function (q) { return new THREE_.Vector3(q[0], (typeof o.y === 'number' ? o.y : hy(q[0], q[1])) + y_off, q[1]); }); // 스무딩(지형 출렁임 완화 — 강 교훈) for (var sm = 0; sm < 2; sm++) for (var si = 1; si < P3.length - 1; si++) P3[si].y = (P3[si - 1].y + P3[si].y + P3[si + 1].y) / 3; var cum = [0]; for (var ci = 1; ci < P3.length; ci++) cum.push(cum[ci - 1] + P3[ci].distanceTo(P3[ci - 1])); var total = cum[cum.length - 1]; var carrier = new THREE_.Group(); scene.add(carrier); var T = { pts: P3, cum: cum, total: total, d: 0, dir: 1, speed: clamp(num(o.speed, 6), 0.5, 40), loop: closed, carrier: carrier, riding: false, seat: 0.35 }; function _at(d, out) { // 거리 → 위치(이분 탐색 불요 규모 — 선형). out 재사용 = 프레임당 할당 0 out = out || new THREE_.Vector3(); if (d <= 0) return out.copy(T.pts[0]); if (d >= total) return out.copy(T.pts[T.pts.length - 1]); for (var k = 1; k < cum.length; k++) { if (cum[k] >= d) { var f = (d - cum[k - 1]) / Math.max(0.0001, cum[k] - cum[k - 1]); return out.lerpVectors(T.pts[k - 1], T.pts[k], f); } } return out.copy(T.pts[T.pts.length - 1]); } T._at = _at; _tracks.push(T); var api = { obj: carrier, board: function (bo) { if (!PL) return api; bo = bo || {}; for (var bi = 0; bi < _tracks.length; bi++) _tracks[bi].riding = false; // 단일-탑승(다중 board = 마지막 승자 경합 차단) if (_mount) { // 탑승 결합 상태 금지 — become 과 동일 계약(강제 하차) PL.speed = _mount.speed0; PL.run = _mount.run0; if (_mount.pose_hold && PL.handle._mixer) PL.handle._mixer.timeScale = 1; _mount = null; } if (typeof bo.seat_y === 'number') T.seat = clamp(bo.seat_y, 0, 20); else if (T.carrier.children.length) { // 부착 차량 실측 좌석(mount 의 bbox×0.74 와 동일 계약) — 대형 차량 매몰 방지 var tb = new THREE_.Box3().setFromObject(T.carrier); T.seat = (tb.max.y > tb.min.y) ? Math.max(0.35, (tb.max.y - T.carrier.position.y) * 0.74) : 0.35; } else T.seat = 0.35; T.riding = true; return api; }, unboard: function () { T.riding = false; if (PL) { // y 유지 = 고공 트랙(케이블카·짚라인)에서 자연 낙하, 지상 트랙은 다음 틱 착지 var mp2 = PL.handle.obj.position; mp2.x += 1.2; mp2.y = Math.max(mp2.y, world.height_at(mp2.x, mp2.z)); PL.on_ground = false; } return api; }, get riding() { return T.riding; }, set speed(v) { T.speed = clamp(num(v, T.speed), 0.5, 40); }, get speed() { return T.speed; }, }; T.api = api; return api; }; // ── 이미지 아이템 — 재호스팅된 이미지 URL 을 액자 빌보드로 스폰(뉴스/콘텐츠 아이템의 정본) ── // ⚠️ 반드시 rehost_external_image 를 거친 somodus.com/i/{key} URL 만 — 외부 직링크는 무CORS 로 // WebGL 텍스처가 침묵 실패한다. 로드 전/실패 시 = 제목 텍스트 패널로 우아 강등(장식성 콘텐츠 — // 마젠타 박스 아님) + console.warn LOUD. 반환 핸들은 spawn 과 동형({obj, play})이라 // world.collectible/interactable/marker 와 그대로 조합 가능. 매 틱 Y-빌보딩은 셸 소유. var _img_boards = []; world.spawn_image = function (url, opts) { var o = opts || {}; var h = clamp(num(o.h, 1.2), 0.4, 4); var at = o.at || {}; var x = num(at.x, 0), z = num(at.z, 0); var title = typeof o.title === 'string' ? o.title.slice(0, 60) : ''; var group = new THREE_.Group(); var FR = 0.06; // 액자 테두리 두께(비율) // 자리표시 텍스처 — 로드 전/실패 공용(다크 매트 + 제목 텍스트) function _panel_tex(txt) { var c = document.createElement('canvas'); c.width = 512; c.height = 384; var g = c.getContext('2d'); g.fillStyle = '#141a24'; g.fillRect(0, 0, 512, 384); if (txt) { g.fillStyle = '#FFE58A'; g.font = '700 34px system-ui, sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle'; var words = txt.split(' '), lines = [], cur = ''; for (var wi = 0; wi < words.length; wi++) { var t2 = cur ? cur + ' ' + words[wi] : words[wi]; if (g.measureText(t2).width > 440 && cur) { lines.push(cur); cur = words[wi]; } else cur = t2; } if (cur) lines.push(cur); for (var li = 0; li < Math.min(lines.length, 4); li++) g.fillText(lines[li], 256, 192 + (li - (Math.min(lines.length, 4) - 1) / 2) * 44); } var tex = new THREE_.CanvasTexture(c); tex.colorSpace = THREE_.SRGBColorSpace; return tex; } var aspect = 4 / 3; var inner_mat = new THREE_.MeshBasicMaterial({ map: _panel_tex(title), side: THREE_.DoubleSide, toneMapped: false }); var inner = new THREE_.Mesh(new THREE_.PlaneGeometry(1, 1), inner_mat); // frame:false = 액자 없는 스탠디(빌보드 캐릭터 용도 — custom_character billboard 경로의 소비 계약) var frame = null; if (o.frame !== false) { frame = new THREE_.Mesh(new THREE_.PlaneGeometry(1, 1), P.make.matte('#E8DCC0')); frame.material.side = THREE_.DoubleSide; frame.position.z = -0.004; // 테두리 판(살짝 뒤) — 얇은 3D 프레임보다 값싸고 뒤에서도 보임 group.add(frame); } group.add(inner); function _fit() { var w = h * aspect; inner.scale.set(w, h, 1); if (frame) frame.scale.set(w * (1 + FR * 2 / aspect), h * (1 + FR * 2), 1); group.position.set(x, world.height_at(x, z) + h * 0.62 + 0.25, z); } _fit(); scene.add(group); if (typeof url === 'string' && /^(https?:\/\/|\/)/.test(url)) { // 상대 /i/{key} 도 유효(동일 오리진 서빙) // 비-플랫폼 origin 사전 경고 — 외부 직링크는 무CORS 로 텍스처가 침묵 실패하는 최다 원인. // (rehost_external_image 경유 URL = 동일 오리진 /i/{key}) 시도는 계속한다(ACAO 있는 호스트도 존재). var _offsite = false; try { _offsite = url.charAt(0) !== '/' && new URL(url).origin !== location.origin; } catch (e) {} if (_offsite) { try { console.warn('[world] spawn_image: off-platform image URL (likely no CORS — pass a rehosted somodus.com/i/… URL): ' + url.slice(0, 120)); } catch (e) {} } var loader = new THREE_.TextureLoader(); loader.setCrossOrigin('anonymous'); loader.load(url, function (tex) { tex.colorSpace = THREE_.SRGBColorSpace; if (tex.image && tex.image.width) aspect = clamp(tex.image.width / tex.image.height, 0.5, 2.2); inner_mat.map = tex; inner_mat.needsUpdate = true; _fit(); }, undefined, function () { try { console.warn('[world] spawn_image texture load failed (rehosted URL? expired 7d?): ' + String(url).slice(0, 160)); } catch (e) {} // 운영 관측 — 실사용 실패율은 비콘 깔때기로만 보인다(콘솔은 모바일에서 불가시) if (window.__playable_beacon) window.__playable_beacon('image_load_failed', (_offsite ? 'offsite:' : '') + String(url).slice(0, 100)); }); } else if (url) { try { console.warn('[world] spawn_image: url must be http(s) or /-rooted — got ' + String(url).slice(0, 80)); } catch (e) {} } var handle = { obj: group, ready: true, radius: h * 0.6, play: function () {}, play_once: function (a, po) { if (po && po.on_done) { try { po.on_done(); } catch (e) {} } } }; _img_boards.push(group); return handle; }; // ── 동물 탑승 — world.mount(handle) / 반환 { dismount } ── // 구조: 리페어런팅 없이 매 틱 정렬(동물 = 플레이어 발밑, 플레이어 = 안장 높이 리프트) — // 컨트롤러·충돌·지형 스냅은 기존 플레이어 물리를 그대로 재사용(이동 코드 불가침 landmine 회피). // 클립 라우팅: 보행(idle/walk/run)·점프는 동물이, 라이더는 sit 클립(부재 시 포즈 홀드). // 동물의 충돌 실린더는 탑승 시 회수(자기 충돌로 밀려나는 함정), 하차 시에도 재생성 안 함(장식 계약). var _mount = null; world.mount = function (h, opts) { if (!PL || _mount || !h || !h.obj) return null; var o = opts || {}; if (h._collider) { _remove_collider(h._collider); h._collider = null; } var bb = new THREE_.Box3().setFromObject(h.obj); var seat = clamp(num(o.seat_y, Math.max(0.35, (bb.max.y - h.obj.position.y) * 0.55)), 0.2, 3.5); // 0.74→0.55: bbox 탑은 머리 높이 — 등(안장점)은 그 ~55%(양 탑승 부유 실보고) _mount = { h: h, seat: seat, speed0: PL.speed, run0: PL.run, pose_hold: false, float: o.float === true }; PL.speed = PL.speed * clamp(num(o.speed_mult, 1.6), 1, 4); PL.run = PL.run * clamp(num(o.run_mult, 1.85), 1, 4); if (PL.handle._actions && PL.handle._actions.sit) PL.handle.play('sit', { fade: 0.15 }); else if (PL.handle._mixer) { PL.handle._mixer.timeScale = 0.12; _mount.pose_hold = true; } // sit 부재 = 포즈 홀드 var api = { dismount: function () { if (!_mount) return; PL.speed = _mount.speed0; PL.run = _mount.run0; if (_mount.pose_hold && PL.handle._mixer) PL.handle._mixer.timeScale = 1; // 동물은 현 위치에 잔류, 플레이어는 옆으로 반보 + 지면 스냅 var mp = PL.handle.obj.position; mp.x += 1.1; mp.y = world.height_at(mp.x, mp.z); _mount = null; }, get mounted() { return !!_mount; }, }; return api; }; // ── 미니맵 — 플레이어 중심 지형 음영 + 마커/수집물 점 + 시선 화살표(원형 글래스, 우상단) ── // 지형층(offscreen)은 height_at 격자 샘플이라 비싸다 — 이동 임계+최소 간격으로만 재샘플하고, // 동적층(점·화살표)만 매 프레임 합성. 좌표계 = 월드 축 고정(화면 x=월드 x, 화면 y=월드 z). var _mmap = null; world.minimap = function (opts) { if (_mmap) return _mmap.api; var o = opts || {}; // 크기 상한 = UI 존 레지스트리 예약 기하(초과 = 톱바 인셋 계약 붕괴), 생성 = 존 점유 통지 var _mm_max = (P._ui && P._ui.Z.minimap) ? P._ui.Z.minimap.w - 2 : 178; var size = clamp(num(o.size, 116), 84, Math.min(180, _mm_max)), range = clamp(num(o.range, 70), 30, 2600); // 상한 2600: 실측 도시(geo, 수 km extent)의 지구 단위 지도 — 소형 월드 기본(70)은 불변 if (P._ui) P._ui.claim('minimap'); var wrap = document.createElement('div'); wrap.id = 'playable-minimap'; wrap.style.cssText = 'position:fixed;right:14px;top:calc(14px + env(safe-area-inset-top,0px));z-index:30;' + 'width:' + size + 'px;height:' + size + 'px;border-radius:50%;overflow:hidden;' + 'border:2px solid rgba(255,229,138,.4);box-shadow:0 4px 16px rgba(0,0,0,.35);' + 'pointer-events:none;background:rgba(10,13,22,.5);'; var cv = document.createElement('canvas'); var R = size; // 내부 해상도 2x cv.width = cv.height = R * 2; cv.style.cssText = 'width:100%;height:100%;display:block;'; wrap.appendChild(cv); document.body.appendChild(wrap); var g = cv.getContext('2d'); var terr = document.createElement('canvas'); terr.width = terr.height = R * 2; var tg = terr.getContext('2d'); var ramp = (P._pal && P._pal.ground_ramp) || ['#2B4A2E', '#3E6B33', '#5B8C3E', '#8FBF5A']; var N = 36, cell = (R * 2) / N; var last_tx = 1e9, last_tz = 1e9, last_ts = -1e9; var zone_r = null; // 존 링(BR 스톰 등) — set_zone 으로 갱신, null = 비표시 function _redraw_terrain(px, pz) { // 표면 공급자 우선(geo 실측 도시 등) — 높이 램프는 평탄 지면에서 단색 원(물/뭍/도로 무구분) // 이 되므로, 소유 패밀리가 map_color_at(x,z)→색 을 등록하면 그 분류색으로 지형층을 그린다. var prov = world._priv && world._priv.map_color_at; if (typeof prov === 'function') { for (var pj = 0; pj < N; pj++) { for (var pi2 = 0; pi2 < N; pi2++) { tg.fillStyle = prov(px + (pi2 / (N - 1) - 0.5) * 2 * range, pz + (pj / (N - 1) - 0.5) * 2 * range) || '#333'; tg.globalAlpha = 0.9; tg.fillRect(pi2 * cell, pj * cell, cell + 1, cell + 1); } } tg.globalAlpha = 1; last_tx = px; last_tz = pz; return; } var hs = new Float32Array(N * N), hmin = Infinity, hmax = -Infinity, i, j; for (j = 0; j < N; j++) { for (i = 0; i < N; i++) { var h = world.height_at(px + (i / (N - 1) - 0.5) * 2 * range, pz + (j / (N - 1) - 0.5) * 2 * range) || 0; hs[j * N + i] = h; if (h < hmin) hmin = h; if (h > hmax) hmax = h; } } var span = Math.max(0.5, hmax - hmin); for (j = 0; j < N; j++) { for (i = 0; i < N; i++) { var f = (hs[j * N + i] - hmin) / span * (ramp.length - 1); tg.fillStyle = ramp[Math.round(clamp(f, 0, ramp.length - 1))]; tg.globalAlpha = 0.9; tg.fillRect(i * cell, j * cell, cell + 1, cell + 1); } } tg.globalAlpha = 1; last_tx = px; last_tz = pz; } _mmap = { draw: function (px, pz, yaw, now) { if ((Math.abs(px - last_tx) > range * 0.16 || Math.abs(pz - last_tz) > range * 0.16) && now - last_ts > 1200) { _redraw_terrain(px, pz); last_ts = now; } if (last_ts < 0) { _redraw_terrain(px, pz); last_ts = now; } g.clearRect(0, 0, R * 2, R * 2); // 지형층 — 마지막 샘플 중심과의 오프셋만큼 평행이동(재샘플 사이 연속 스크롤) var sc = R / range; // px per meter g.drawImage(terr, (last_tx - px) * sc, (last_tz - pz) * sc); // 마커(POI) — 앰버 큰 점 var mi; for (mi = 0; mi < _markers.length; mi++) { var mt = _markers[mi].target; if (!mt) continue; var mx = (mt.position.x - px) * sc, mz = (mt.position.z - pz) * sc; var md = Math.sqrt(mx * mx + mz * mz); if (md > R - 8) { mx *= (R - 8) / md; mz *= (R - 8) / md; } // 범위 밖 = 가장자리 클램프 g.fillStyle = '#FFE58A'; g.strokeStyle = 'rgba(0,0,0,.55)'; g.lineWidth = 2; g.beginPath(); g.arc(R + mx, R + mz, 5, 0, Math.PI * 2); g.fill(); g.stroke(); } // 수집물 — 작은 점(범위 내만, 상한 40) g.fillStyle = 'rgba(155,231,255,.9)'; var drawn = 0; for (mi = 0; mi < _collectibles.length && drawn < 40; mi++) { var co = _collectibles[mi].obj; if (!co) continue; var cx = (co.position.x - px) * sc, cz = (co.position.z - pz) * sc; if (cx * cx + cz * cz > (R - 6) * (R - 6)) continue; g.beginPath(); g.arc(R + cx, R + cz, 2.5, 0, Math.PI * 2); g.fill(); drawn++; } // 전투 유닛 블립(팀 색 — combat 설치 시 자동, 설정 0): ally=청·그 외=적. 봇 챔피언은 // 큰 점+백테. 구조물(타워/코어 외부-타겟 프록시)은 사각 블립 + 가장자리 클램프(목표 방향 // 시인 — MOBA 미니맵 정본). 상한 24(레인 캡 구조상 충분·가독). var _cbm = world._priv_combat; if (_cbm && _cbm.mobs) { var _ums = _cbm.mobs(), _ud = 0; for (mi = 0; mi < _ums.length && _ud < 24; mi++) { var _um = _ums[mi]; if (_um.state === 'dead') continue; var _ux = (_um.handle.obj.position.x - px) * sc, _uz = (_um.handle.obj.position.z - pz) * sc; if (_ux * _ux + _uz * _uz > (R - 6) * (R - 6)) continue; g.fillStyle = _um.team === 'ally' ? '#4aa8ff' : '#ff5240'; g.beginPath(); g.arc(R + _ux, R + _uz, _um._is_bot_champ ? 4.5 : 3, 0, Math.PI * 2); g.fill(); if (_um._is_bot_champ) { g.strokeStyle = 'rgba(255,255,255,.8)'; g.lineWidth = 1.5; g.stroke(); } _ud++; } if (_cbm.targets) { var _uts = _cbm.targets(); for (mi = 0; mi < _uts.length; mi++) { var _ut = _uts[mi]; if (!_ut.alive || !_ut.alive()) continue; var _tx2 = (_ut.pos.x - px) * sc, _tz2 = (_ut.pos.z - pz) * sc; var _td2 = Math.sqrt(_tx2 * _tx2 + _tz2 * _tz2); if (_td2 > R - 7) { _tx2 *= (R - 7) / _td2; _tz2 *= (R - 7) / _td2; } g.fillStyle = _ut.team === 'ally' ? '#4aa8ff' : '#ff5240'; g.strokeStyle = 'rgba(0,0,0,.55)'; g.lineWidth = 2; g.fillRect(R + _tx2 - 3.5, R + _tz2 - 3.5, 7, 7); g.strokeRect(R + _tx2 - 3.5, R + _tz2 - 3.5, 7, 7); } } } // 존 링 — 월드 원점 중심(BR 스톰 수축 원의 시인성 정본, 캔버스가 자동 클립) if (zone_r != null) { g.strokeStyle = '#d24aff'; g.lineWidth = 3; g.globalAlpha = 0.9; g.beginPath(); g.arc(R - px * sc, R - pz * sc, zone_r * sc, 0, Math.PI * 2); g.stroke(); g.globalAlpha = 1; } // 플레이어 — 중심 삼각 화살표(시선 = 카메라 yaw, 나침반 마커와 동일 정본) g.save(); g.translate(R, R); g.rotate(Math.atan2(yaw.z, yaw.x)); // 화면 x=월드 x·화면 y=월드 z 이므로 방향 각도 그대로 g.fillStyle = '#fff'; g.strokeStyle = 'rgba(0,0,0,.5)'; g.lineWidth = 2; g.beginPath(); g.moveTo(8, 0); g.lineTo(-5, 5.5); g.lineTo(-5, -5.5); g.closePath(); g.fill(); g.stroke(); g.restore(); }, api: { remove: function () { wrap.remove(); _mmap = null; }, set_range: function (r) { range = clamp(num(r, range), 30, 2600); last_ts = -1e9; }, // 재샘플 강제 set_zone: function (r) { zone_r = r == null ? null : Math.max(1, num(r, 0)); }, }, }; return _mmap.api; }; // ── world 틱 — 이동→충돌→카메라(순서 고정: 지터 함정 봉합) + 스트리밍 + 어포던스 ── var _wt = 0, _degen_frames = 0; var _fwd = new THREE_.Vector3(), _right = new THREE_.Vector3(), _mv = new THREE_.Vector3(); var _ideal = new THREE_.Vector3(), _ilook = new THREE_.Vector3(), _proj = new THREE_.Vector3(); var _mm_dir = new THREE_.Vector3(); // 미니맵 전용 — _proj 는 마커 루프가 투영으로 재사용(공유 시 방향 오염) P.loop(function (dt, elapsed, raw) { // M7 양식화 물리 레버 — world-모션 시간 배율(이 루프 소비 전체: 플레이어 물리·믹서·자석). // fx/cine 는 자체 루프(별도 시간) — 로직 자체 타이머는 로직 소유(문서화된 경계). if (world._tscale !== undefined && world._tscale !== 1) dt = dt * world._tscale; _wt = elapsed; for (var mi = 0; mi < _mixers.length; mi++) _mixers[mi].update(dt); // 게임 시간(히트스톱 동조) // M7 자석 — 등록 리스트의 오브젝트를 반경 내에서 플레이어로 견인(수집 판정은 로직 소유) if (world._magnets && world._magnets.length && PL) { var _mp = PL.handle.obj.position; for (var _gi = 0; _gi < world._magnets.length; _gi++) { var _M = world._magnets[_gi]; if (_M.stopped) continue; for (var _gj = 0; _gj < _M.list.length; _gj++) { var _ob = _M.list[_gj]; if (!_ob || !_ob.position || _ob.parent === null) continue; var _ddx = _mp.x - _ob.position.x, _ddy = (_mp.y + 0.8) - _ob.position.y, _ddz = _mp.z - _ob.position.z; var _dd = Math.sqrt(_ddx * _ddx + _ddy * _ddy + _ddz * _ddz); if (_dd > _M.radius || _dd < 0.02) continue; var _pull = Math.min(0.9, _M.strength * (1 - _dd / _M.radius) * dt / _dd); // 프레임당 90% 캡(관통/진동 방지) _ob.position.x += _ddx * _pull; _ob.position.y += _ddy * _pull; _ob.position.z += _ddz * _pull; } } } // L4 최후 방어선 — 틱 진입 시점의 카메라(=직전 프레임에 실제 렌더된 상태)가 "플레이어 밀착 + // 수직 하방" 퇴화로 지속되면 기본 리그로 자가 복구. 리그 갱신 *이후* 검사하면 리그가 이미 // 교정한 상태만 보게 되어 로직발 퇴화를 놓친다(실측) — 반드시 진입부에서 관측. if (PL && !world._priv.fps && dt > 0) { var _dcp = cam.position.distanceTo(PL.handle.obj.position); var _down = cam.getWorldDirection(_proj).y < -0.94; // 하방 피치 > ~70° _degen_frames = (_dcp < 1.2 && _down) ? _degen_frames + 1 : 0; if (_degen_frames >= 60) { _degen_frames = 0; try { console.error('[world] degenerate top-down camera persisted — self-healing to default rig'); } catch (e) {} if (!CAMR) world.camera({}); else { CAMR.offset.set(0, 2.8, -5.5); CAMR.lookat.set(0, 1.4, 2.5); } CAMR.cur_pos.copy(PL.handle.obj.position).add(new THREE_.Vector3(0, 3.2, -5.5)); cam.position.copy(CAMR.cur_pos); } } // 입력 벡터(스틱 ∪ 키보드) — 카메라-상대 var ix = CTRL.vec.x, iy = CTRL.vec.y, force = CTRL.force; if (P.keys.size) { var kx = (P.keys.has('ArrowRight') || P.keys.has('d') || P.keys.has('D') ? 1 : 0) - (P.keys.has('ArrowLeft') || P.keys.has('a') || P.keys.has('A') ? 1 : 0); var ky = (P.keys.has('ArrowDown') || P.keys.has('s') || P.keys.has('S') ? 1 : 0) - (P.keys.has('ArrowUp') || P.keys.has('w') || P.keys.has('W') ? 1 : 0); if (kx !== 0 || ky !== 0) { var kl = Math.sqrt(kx * kx + ky * ky); ix = kx / kl; iy = ky / kl; force = (P.keys.has('Shift') ? 1 : 0.6); } } // 내비 목표(탭/클릭 이동 — _priv.nav_to 설치 패밀리 전용): 직접 입력이 항상 우선(즉시 양도), // 도달(0.35m)·입력 동결(move_lock)에 자동 소멸. 이동 코드는 카메라-상대(_fwd/_right)이므로 // 월드 방향을 동일 yaw 기저로 ix/iy 에 역산 합류 — 이동/충돌/클립 파이프라인 전체 재사용. if (PL && PL._nav) { if (force > 0.01 || PL.move_lock > 0) PL._nav = null; else { var _nx = PL._nav.x - PL.handle.obj.position.x, _nz = PL._nav.z - PL.handle.obj.position.z; var _nd = Math.sqrt(_nx * _nx + _nz * _nz); if (_nd < 0.35) PL._nav = null; else { var _nyaw = world._priv.fps ? world._priv.fps.yaw : (CAMR ? CAMR.yaw : 0); var _nfx = Math.sin(_nyaw), _nfz = Math.cos(_nyaw); ix = -((_nx / _nd) * _nfz - (_nz / _nd) * _nfx); iy = -((_nx / _nd) * _nfx + (_nz / _nd) * _nfz); force = _nd > 2.2 ? 1 : 0.7; // 원거리 = 달리기, 근접 = 감속 도착 } } } if (PL && dt > 0) { var p = PL.handle.obj.position; if (PL.move_lock > 0) PL.move_lock -= dt; // 게임시간 감소(히트스톱 동조 — 절대시각 비교는 클록 불일치 함정) PL.moving = force > 0.01 && !(PL.move_lock > 0); PL.running = PL.moving && force > 0.85; if (PL.moving) { var yaw = world._priv.fps ? world._priv.fps.yaw : (CAMR ? CAMR.yaw : 0); _fwd.set(Math.sin(yaw), 0, Math.cos(yaw)); _right.set(_fwd.z, 0, -_fwd.x); // 스크린-오른쪽 증명(lookAt 행렬): xaxis = cross(up, zaxis), zaxis = -dir. // dir = fwd = (sin yaw, 0, cos yaw) → xaxis = (-fwd.z, 0, fwd.x). // _right = (fwd.z, 0, -fwd.x) = -xaxis(스크린-왼쪽 벡터)이므로 오른쪽 입력(ix>0)의 // 계수는 -ix 가 정답. ⚠️ 이 부호를 '직관'으로 다시 뒤집지 말 것 — 2026-07-10 에 +ix 로 // "수리"했다가 실반전을 도입한 실사고(검증 프로브가 같은 오유도를 공유한 순환 검증). // 행동 스위트가 카메라 행렬(matrixWorld 0열) 기준으로 이 부호를 상시 검증한다. _mv.copy(_fwd).multiplyScalar(-iy).addScaledVector(_right, -ix); if (_mv.lengthSq() > 0.0001) { _mv.normalize(); var spd = PL.running ? PL.run : PL.speed * (0.4 + force * 0.6); p.x += _mv.x * spd * dt; p.z += _mv.z * spd * dt; var want = Math.atan2(_mv.x, _mv.z); var dh = want - PL.heading; while (dh > Math.PI) dh -= Math.PI * 2; while (dh < -Math.PI) dh += Math.PI * 2; PL.heading += dh * damp_k(PL.turn, dt); PL.handle.obj.rotation.y = PL.heading; } } _push_out(p, PL.radius); if (_mount) p.y -= _mount.seat; // 물리(중력·착지 판정)는 발-공간에서 — 안장 리프트는 틱 말미에 재적용(누적/착지 오염 차단) // 지면/중력 var gy = world.height_at(p.x, p.z); if (_mount && _mount.float && P._water_y != null) gy = Math.max(gy, P._water_y); // 수상 탈것 — 수면 주행(지형이 수면 위로 솟으면 상륙) { // 외부 바닥(obby 플랫폼·build 피스) — 발밑-이하 최고면만 지면으로 승격(단일+다중 합성) var _eg = _ground_ext_all(p.x, p.z, p.y); if (_eg != null && _eg > gy) gy = _eg; // ── 지면 진실성 자기검사(생산자 측 불변식) ─────────────────────────────────── // 실보고 2026-07-22: 아키타입이 융기 바닥을 세우고도 높이 필드에 선언하지 않으면, 필드를 // 믿는 소비자 전부(카메라·몹·소품·마커·AI)가 보행면 아래를 지면으로 안다. 소비처는 70곳이라 // 지점별 테스트가 불가능하므로, *생산자 의무*를 셸이 스스로 상시 검사한다 — 골든이 없는 // 게임·앞으로 생성될 모든 게임까지 자기 위반을 신고하므로 커버리지가 아키타입 수와 무관하다. // 오검출 차단: 접지 상태 + 다층 제공자 부재(_eg == null) 일 때만 판정한다(다층은 필드가 // 답할 수 없는 정당한 불일치). 스폰 부트스트랩 2초 유예 + 1회 래치(비콘 폭주 방지). if (!PL._gtruth && PL.on_ground && _eg == null && (PL._gt_t = (PL._gt_t || 0) + dt) > 2) { if (Math.abs(world.height_at(p.x, p.z) - p.y) > 0.6) { PL._gtruth = 1; try { console.error('[world] ground field drift — 보행면이 height_at 과 어긋난다(융기 바닥 미선언 의심): world.floor_plane({x0,x1,z0,z1,y}) 로 지형 필드에 편입할 것'); } catch (e) {} if (window.__playable_beacon) window.__playable_beacon('ground_field_drift', String(Math.round((p.y - world.height_at(p.x, p.z)) * 100) / 100)); } } } if (!PL.on_ground) { PL._cy = (PL._cy || 0) + dt; // 코요테 시계(이탈 후 경과) if (PL._jbuf > 0) PL._jbuf -= dt; // 점프 선입력 버퍼 감쇠 var _held = (P.keys && P.keys.has(' ')) || PL._jb_held; // P.keys = Set(대괄호 접근은 상시 undefined 함정) if (PL.fly && _held) PL.vy = Math.min(PL.vy + 26 * dt, 6.5); // 홀드 = 상승(자유 비행) else PL.vy -= 14 * (world._gscale === undefined ? 1 : world._gscale) * dt; // M7 gravity_scale if (PL.glide && !PL.fly && _held && PL.vy < -1.7) PL.vy = -1.7; // 활공 = 종단 낙하 -1.7m/s var _pvy = PL.vy; // 착지음 무게 산정용(대입 소거 전 낙하속도 캡처) p.y += PL.vy * dt; if (PL.vy > 0 && _ceil_ext_list.length) { // 천장 채널 — 상승은 머리 위 슬랩 하면에서 끝난다(제공자 없는 게임 = 완전 무영향) var _ce = _ceil_ext_all(p.x, p.z, p.y); var _cap = _ce != null ? _ce - 1.75 : null; // 1.75 = 머리+지면 프로브(+0.6) 여유 — 프로브 원점이 슬랩 위로 못 넘어가 위층 오채택(층-사다리)도 함께 소멸 if (_cap != null && _cap > gy && p.y > _cap) { p.y = _cap; PL.vy = 0; } } if (p.y <= gy) { p.y = gy; PL.vy = 0; PL.on_ground = true; PL._cy = 0; if (PL._air_slow) { if (PL.handle._mixer) PL.handle._mixer.timeScale = 1; PL._air_slow = false; } if (P.fx && P.fx.squash) P.fx.squash((_mount ? _mount.h : PL.handle).obj, { amount: 0.16, dur: 0.15 }); // 착지 임팩트(클립 유무 무관) if (_pvy < -3.2) _msfx_verb(_mount ? _msfx_mount_slot() : _msfx.smp, 'land', _MSFX_COM.land, Math.min(1.6, 0.5 - _pvy * 0.09), true); // 경사 미세 낙차는 무음. 착지도 이동 주체 소유(탑승 = 동물 슬롯) if (PL._jbuf > 0 && PL._jump_fn) { PL._jbuf = 0; PL._jump_fn(); } // 버퍼 점프(착지 프레임 즉발, 착지 효과 후행 — _air_slow 리셋이 공중 포즈를 되돌리는 순서 함정) } } else if (p.y - gy > 0.65) { PL.on_ground = false; } // 가장자리 이탈 = 낙하 전환(즉시 하향 스냅은 플랫폼/절벽에서 순간이동 — 스텝-오프 정본) else p.y = gy; // 모션 효과음 스텝 — 실이동 거리 케이던스(속도 배율 자동 흡수). 텔레포트 급간(>3m)은 걸음 아님. if (_msfx.on) { if (!_msfx.spec_read && PL.handle && PL.handle._spec) { // 매니페스트 지연 확정(cat·실측 h 보강) _msfx.spec_read = true; _msfx.kind = _msfx_kind_of(_msfx.name || PL.handle.name, PL.handle._spec); _msfx.h = clamp(num(PL.handle._spec.h, 1.7), 0.3, 6); _msfx.smp = {}; _msfx_resolve(_msfx.name || PL.handle.name, PL.handle._spec, _msfx.smp); // 실녹음 세트 재해석(tags/cat 확정분) } var _mk2 = _mount ? _msfx_kind_of(_mount.h && _mount.h.name, _mount.h && _mount.h._spec) : _msfx.kind; if (_mk2 === 'human' && _mount) _mk2 = 'hoof'; // 탑승물 신원 미상 = 4족 기본(탑승 가능 = 대부분 동물) var K2 = _MSFX_KIT[_mk2] || _MSFX_KIT.human; var _dx3 = p.x - _msfx.px, _dz3 = p.z - _msfx.pz; _msfx.px = p.x; _msfx.pz = p.z; var _hd3 = Math.sqrt(_dx3 * _dx3 + _dz3 * _dz3); if (_hd3 > 3) _hd3 = 0; if (K2.engine) { _msfx_engine(true, clamp(dt > 0 ? (_hd3 / dt) / Math.max(1, PL.run) : 0, 0, 1)); } else { if (_msfx.eng) _msfx_engine(false, 0); if (PL.on_ground && PL.moving && _hd3 > 0) { _msfx.dist += _hd3; var _st3 = (K2.stride || 0.8) * clamp(_mount ? 1.5 : _msfx.h / 1.7, 0.65, 2.2) * (PL.running ? 1.3 : 1); // 케이던스 시간 상한 — 거리 기반 발화는 LLM 이 고른 아케이드 속도(실보고: 4.9/8.7m/s → // 초당 6~8회 발화)가 보행 애니 사이클(~2~3보/초)과 유리돼 "발소리가 더 빠름"이 된다. // 보폭(저속 정확성)은 유지하고 최소 간격만 강제(run 0.30s≈3.3보/s·walk 0.42s≈2.4보/s // — 애니 발구름 주기의 실측 상한): 속도 레버가 오디오-비주얼 동조를 깰 수 없는 구조. _msfx.cool = Math.max(0, (_msfx.cool || 0) - dt); if (_msfx.dist >= _st3 && _msfx.cool <= 0) { _msfx.dist = 0; _msfx.cool = PL.running ? 0.30 : 0.42; var _wet3 = P._water_y != null && gy < P._water_y - 0.1; var _slot9 = _mount ? _msfx_mount_slot() : _msfx.smp; _msfx_verb(_slot9, _wet3 ? 'splash' : 'step', _wet3 ? _MSFX_COM.splash : K2.step, PL.running ? 1.25 : 1, true); } } else _msfx.dist = 0; } } // 탑승 정렬 — 동물 = 지면(점프 아크 동조), 플레이어 = 안장 리프트, 방향 동기 if (_mount) { var mo = _mount.h.obj; mo.position.set(p.x, p.y, p.z); mo.rotation.y = PL.handle.obj.rotation.y; p.y += _mount.seat; } // 클립 자동 전환(셸 소유): 속도 → idle/walk/run 크로스페이드. 탑승 중엔 동물이 보행 담당. var A = PL.handle._actions; var _loco = _mount ? _mount.h : PL.handle; var LA = _loco._actions; if (LA && PL.on_ground) { if (!PL.moving && LA.idle) _loco.play('idle'); else if (PL.moving && PL.running && LA.run) _loco.play('run'); else if (PL.moving && LA.walk) _loco.play('walk'); } if (_mount && A && A.sit) PL.handle.play('sit'); // 라이더 자세 상시 유지(play 는 동일 액션 dedupe) _stream_chunks(p.x, p.z); } if (!PL && T && dt > 0) _stream_chunks(cam.position.x, cam.position.z); // 무플레이어 씬 = 카메라 기준 스트리밍 // 경로-추종 캐리어 주행 + 탑승 오버라이드 — 반드시 물리 뒤·카메라 리그 *앞*(카메라가 물리-스냅 // 위치를 lookAt 하면 고공 트랙 탑승 중 바닥을 보는 1프레임 괴리 — 렌더-최종 순서 원칙과 동일) for (var _tk = 0; _tk < _tracks.length; _tk++) { var TK = _tracks[_tk]; if (dt > 0) { TK.d += TK.speed * TK.dir * dt; if (TK.loop) { if (TK.d > TK.total) TK.d -= TK.total; if (TK.d < 0) TK.d += TK.total; } else { if (TK.d > TK.total) { TK.d = TK.total; TK.dir = -1; } if (TK.d < 0) { TK.d = 0; TK.dir = 1; } } } if (!_trk_p) { _trk_p = new THREE_.Vector3(); _trk_n = new THREE_.Vector3(); } var _tp = TK._at(TK.d, _trk_p), _tn = TK._at(TK.loop ? (TK.d + 0.6 * TK.dir + TK.total) % TK.total : Math.max(0, Math.min(TK.total, TK.d + 0.6 * TK.dir)), _trk_n); TK.carrier.position.copy(_tp); if (_tn.distanceToSquared(_tp) > 0.0001) TK.carrier.lookAt(_tn.x, _tn.y, _tn.z); // full 3D = 경사 피치(언덕·코스터 정합) if (TK.riding && PL) { var _pp3 = PL.handle.obj.position; _pp3.set(_tp.x, _tp.y + TK.seat, _tp.z); PL.vy = 0; PL.on_ground = true; // heading = 진행 벡터에서 직접(carrier.rotation.y 복사는 피치 동반 시 Euler XYZ 분해 // 앰비규어티로 yaw 90°+ 에서 x/z 에 π 가 실려 오답 — quaternion 은 무결, Euler 읽기만 함정) if (_tn.distanceToSquared(_tp) > 0.0001) PL.handle.obj.rotation.y = Math.atan2(_tn.x - _tp.x, _tn.z - _tp.z); } } // 카메라 리그(캐릭터 이동 후, 같은 프레임 — 순서 함정 봉합) if (CAMR && PL && !world._priv.fps) { CAMR.yaw -= CTRL.cam_dx; CAMR.pitch = clamp(CAMR.pitch + CTRL.cam_dy, -0.35, 1.1); CTRL.cam_dx = 0; CTRL.cam_dy = 0; if (CAMR.auto_align && PL.moving && (_wt - CAMR.last_input) > 4) { // 카메라는 +forward(=(sin yaw, cos yaw))를 바라보므로 "플레이어 뒤" = yaw → heading. // +π 는 카메라가 플레이어 얼굴을 보게 만들어 이동방향(카메라-상대)이 뒤집히고, // yaw↔heading 이 서로를 π 간격으로 영원히 추격 → 제자리 원운동(실사고: 4초+ 전진 시 스핀). var target_yaw = PL.heading; var dy2 = target_yaw - CAMR.yaw; while (dy2 > Math.PI) dy2 -= Math.PI * 2; while (dy2 < -Math.PI) dy2 += Math.PI * 2; CAMR.yaw += dy2 * damp_k(0.6, raw); // soft auto-align(정본 λ≈0.5) } var pp = PL.handle.obj.position; // 구면 오빗 정본: forward = (sin yaw, 0, cos yaw). 카메라 = 플레이어 뒤(dist·cos pitch) // + 위(base + dist·sin pitch). 시선 = 플레이어 앞(look_z·forward) — 둘 다 yaw 동조(오빗 일관). var fyx = Math.sin(CAMR.yaw), fyz = Math.cos(CAMR.yaw); var cp = Math.cos(CAMR.pitch), sp = Math.sin(CAMR.pitch); var dist0 = Math.abs(CAMR.offset.z); // 지형 회피 정본: 뒤 언덕에 막히면 위로 올리지(내려다보기 유발) 않고 *거리를 줄여* 줌인. var facs = [1, 0.68, 0.45, 0.28], placed = false; for (var fi2 = 0; fi2 < facs.length; fi2++) { var dd = dist0 * facs[fi2]; _ideal.set(pp.x - fyx * dd * cp, pp.y + CAMR.offset.y * facs[fi2] + dd * sp, pp.z - fyz * dd * cp); // 지면 = 정본 합성 질의(터레인 ∪ 외부 제공자). 구 height_at 단독은 실내 슬랩 아래 terrain 을 // 지면으로 오인해 카메라가 보행면 밑으로 내려갔다(플레이어 물리와 지면 인식이 어긋난 모순). // 지면 프로브 y = *플레이어* 기준(pp.y) — 카메라 자기 y 로 조회하면 다층에서 카메라 xz 아래의 // *다른 층*(메자닌 지붕)을 지면으로 오인해 그 위로 캐리어된다(2026-07-24 밀집 A/B 계측으로 확정). if (_ideal.y >= world.ground_at(_ideal.x, _ideal.z, pp.y + 1.1) + 0.45 && !_cam_seg_blocked(pp.x, pp.y + 1.1, pp.z, _ideal.x, _ideal.y, _ideal.z)) { placed = true; break; } } // 최후: *상향 전용* 클램프 — 대입식은 다층(메자닌)에서 카메라 xz 가 데크 밖일 때 1층 지면으로 // 하향 텔레포트시켰다(실보고 2026-07-24 "2층인데 1층 시점"). 의도(주석 '상향')대로 max 로. if (!placed) _ideal.y = Math.max(_ideal.y, world.ground_at(_ideal.x, _ideal.z, pp.y + 1.1) + 0.5); world.cam_clamp(_ideal); // 플레이 볼륨 하드 클램프(박스라 lerp 결과도 볼륨 내부가 보장된다) _ilook.set(pp.x + fyx * CAMR.lookat.z, pp.y + CAMR.lookat.y, pp.z + fyz * CAMR.lookat.z); var k = damp_k(CAMR.damping, raw); CAMR.cur_pos.lerp(_ideal, k); CAMR.cur_look.lerp(_ilook, k); cam.position.copy(CAMR.cur_pos); cam.lookAt(CAMR.cur_look); } // 풀 dirty flush for (var pi = 0; pi < _pools.length; pi++) { if (_pools[pi].dirty) { for (var pq3 = 0; pq3 < _pools[pi].ims.length; pq3++) { var _im3 = _pools[pi].ims[pq3].im; _im3.count = _pools[pi].hi; _im3.instanceMatrix.needsUpdate = true; } _pools[pi].dirty = false; } } // 근접 상호작용 if (PL && _interactables.length) { var nearest = null, nd = 1e9; var ppos = PL.handle.obj.position; for (var ii = 0; ii < _interactables.length; ii++) { var it = _interactables[ii]; if (!it.obj) continue; var dd = ppos.distanceTo(it.obj.position); if (dd < it.radius && dd < nd) { nd = dd; nearest = it; } } var btn = _ensure_btn(); if (nearest) { btn.textContent = nearest.label; btn.style.display = 'flex'; // .pbtn = flex 중앙정렬(block 이면 라벨이 좌측으로 흐른다) btn.onclick = function () { if (nearest.cb) { try { nearest.cb(nearest); } catch (e) { try { console.error('[playable] on_interact hook error: ' + (e && e.message)); } catch (_) {} } } }; } else btn.style.display = 'none'; } // 수집물 자석(가속 끌림 — lerp 아닌 가속: "빨려드는" 체감 정본) + 회전 if (PL) { var pp2 = PL.handle.obj.position; for (var ci2 = _collectibles.length - 1; ci2 >= 0; ci2--) { var col = _collectibles[ci2]; if (col.taken || !col.obj) { _collectibles.splice(ci2, 1); continue; } if (col.spin) col.obj.rotation.y += dt * 2.2; var dc = col.obj.position.distanceTo(pp2); if (dc < col.collect) { col.taken = true; if (P.fx) P.fx.pickup({ x: col.obj.position.x, y: col.obj.position.y, z: col.obj.position.z }); if (col.obj.parent) col.obj.parent.remove(col.obj); if (col.h && col.h._collider) { _remove_collider(col.h._collider); col.h._collider = null; } // 유령 실린더 회수 if (col.cb) { try { col.cb(col); } catch (e) { try { console.error('[playable] on_collect hook error: ' + (e && e.message)); } catch (_) {} } } _collectibles.splice(ci2, 1); } else if (dc < col.magnet) { col.vel += 26 * dt; var step = Math.min(col.vel * dt, dc); col.obj.position.lerp(new THREE_.Vector3(pp2.x, pp2.y + 0.6, pp2.z), step / dc); } } } // ── 마커 — 온스크린: 대상 머리 위 라벨(거리 페이드) / 오프스크린: *수평 나침반* 가장자리 // 화살표(방향 회전). 구 구현(스크린 투영 좌표의 가장자리 클램프)은 고도차·카메라-뒤 반전이 // 방향을 왜곡하고 온스크린이면 아예 숨겨 "가이드 표시가 부정확" 실보고의 근원이었다 — // 나침반(대상-카메라 수평각 − 카메라 yaw)은 항상 유일 방향이고, 화살표 자체를 그 각도로 // 회전시켜 방향 정보를 명시한다. var _mk_dir = cam.getWorldDirection(_proj); var _mk_yaw = Math.atan2(_mk_dir.x, _mk_dir.z); for (var mi2 = 0; mi2 < _markers.length; mi2++) { var mk = _markers[mi2]; if (!mk.target) { mk.el.style.display = 'none'; continue; } _proj.set(mk.target.position.x, mk.target.position.y + 1.6, mk.target.position.z).project(cam); var onscreen = _proj.z < 1 && _proj.x > -1 && _proj.x < 1 && _proj.y > -1 && _proj.y < 1; mk.el.style.display = 'block'; if (onscreen) { // 대상 위 라벨 — 가까울수록 선명(원거리 라벨 잔치 방지), 화살표는 아래를 가리킴 var _dref = PL ? PL.handle.obj.position : cam.position; var _mdist = mk.target.position.distanceTo(_dref); mk.el.style.opacity = String(clamp(1.15 - _mdist / 45, 0.3, 1)); mk.ar.style.transform = 'rotate(90deg)'; mk.el.style.left = ((_proj.x * 0.5 + 0.5) * P.vw) + 'px'; mk.el.style.top = ((-_proj.y * 0.5 + 0.5) * P.vh) + 'px'; } else { var _ddx = mk.target.position.x - cam.position.x; var _ddz = mk.target.position.z - cam.position.z; var _rel = Math.atan2(_ddx, _ddz) - _mk_yaw; // 0 = 카메라 전방 var _dirx = Math.sin(_rel), _diry = -Math.cos(_rel); // 화면: 전방 = 위 var mgn = mk.margin; var _sc = Math.min( (P.vw / 2 - mgn) / Math.max(0.0001, Math.abs(_dirx)), (P.vh / 2 - mgn) / Math.max(0.0001, Math.abs(_diry)) ); mk.el.style.opacity = '1'; mk.ar.style.transform = 'rotate(' + Math.round(Math.atan2(_diry, _dirx) * 180 / Math.PI) + 'deg)'; var _mx = P.vw / 2 + _dirx * _sc, _my = P.vh / 2 + _diry * _sc; // UI 존 회피 — 에지 클램프가 공유 버튼/미니맵 사각형에 앉으면 세로로 밀어냄(겹침 실보고 봉합) if (P._ui && P._ui.excl_rects) { var _exr = P._ui.excl_rects(P.vw, P.vh); for (var _er = 0; _er < _exr.length; _er++) { var _R3 = _exr[_er]; if (_mx > _R3.x0 && _mx < _R3.x1 && _my > _R3.y0 && _my < _R3.y1) { _my = (_R3.y0 > P.vh / 2) ? (_R3.y0 - 10) : (_R3.y1 + 10); } } } mk.el.style.left = _mx + 'px'; mk.el.style.top = _my + 'px'; } } // 이미지 액자 Y-빌보딩 — 씬에서 제거된 보드는 목록에서 회수 for (var ib = _img_boards.length - 1; ib >= 0; ib--) { var bg = _img_boards[ib]; if (!bg.parent) { _img_boards.splice(ib, 1); continue; } bg.rotation.y = Math.atan2(cam.position.x - bg.position.x, cam.position.z - bg.position.z); } // ── 미니맵 합성(설치 시) — 기준점 = 플레이어(없으면 카메라), 시선 = 카메라 방향(나침반 정본) ── if (_mmap) { var _mmp = PL ? PL.handle.obj.position : cam.position; _mmap.draw(_mmp.x, _mmp.z, cam.getWorldDirection(_mm_dir), performance.now()); } }); })(); b ? b : v); } function num(v, d) { return typeof v === 'number' && isFinite(v) ? v : d; } function damp_k(l, dt) { return 1 - Math.exp(-l * dt); } function _pl() { return (world._priv && (world._priv.ext_player || world._priv.player())) || null; } // ext_player = craft 등 외부 플레이어 소유자의 전투 어댑터(위치/hp/무적창 호환 계약) function _cmsfx(handle, verb, volm) { // 신원별 실녹음 동사(hurt/death/hit) — world msfx 표면 경유(L1 실녹음→L2 합성 사다리 그대로) var M = world._priv && world._priv.msfx; if (!M) return; var sl = handle && handle.name ? M.slot_for(handle.name, handle._spec) : null; M.verb(sl, verb, M.com[verb], volm || 1, true); } var _wt = 0; // 게임 시간 누적(히트스톱 동조 — dt 합산) var _mobs = []; var _tokens = 0, _TOKEN_MAX = 2; // 동시 공격 몹 수 상한(무쌍/Arkham 정본 — 공정성) // ── 단일 피해 경로: 전방 호 판정(구 오버랩 + dot, |Δy|≤2m 수직 허용 — Genshin 근접 정본) ── function _in_arc(sx, sy, sz, heading, range, arc_deg, tx, ty, tz, tr) { if (Math.abs(ty - sy) > 2) return false; var dx = tx - sx, dz = tz - sz; var d = Math.sqrt(dx * dx + dz * dz); if (d - (tr || 0) > range) return false; if (d < 0.001) return true; return ((dx * Math.sin(heading) + dz * Math.cos(heading)) / d) >= Math.cos(arc_deg * Math.PI / 360); } // ── 피격 플래시 — emissive 흰색 override 80ms(three.js 정본). 함정 2건 흡수: // 공유 머티리얼은 최초 피격 시 per-인스턴스 clone, emissive 부재(Basic)면 color 폴백. ── function _flash_mats(root) { var mats = []; root.traverse(function (n) { if (!n.isMesh || !n.material) return; var arr = Array.isArray(n.material) ? n.material : [n.material]; for (var i = 0; i < arr.length; i++) { if (!arr[i].userData.__combat_clone) { var c = arr[i].clone(); c.userData.__combat_clone = true; if (Array.isArray(n.material)) n.material[i] = c; else n.material = c; arr[i] = c; } mats.push(arr[i]); } }); return mats; } function _flash_white(ent, dur) { if (!ent.mats) ent.mats = _flash_mats(ent.handle.obj); for (var i = 0; i < ent.mats.length; i++) { var m = ent.mats[i]; if (m.emissive) { if (m.userData.__base_emi == null) { m.userData.__base_emi = m.emissive.getHex(); m.userData.__base_int = m.emissiveIntensity; } m.emissive.setHex(0xffffff); m.emissiveIntensity = 0.85; } } ent.flash_until = _wt + (dur || 0.08); } function _flash_restore(ent) { if (!ent.mats) return; for (var i = 0; i < ent.mats.length; i++) { var m = ent.mats[i]; if (m.emissive && m.userData.__base_emi != null) { m.emissive.setHex(m.userData.__base_emi); m.emissiveIntensity = m.userData.__base_int; } } } function _telegraph(ent, t01) { // windup 동안 붉은 발광 램프업(예고 = 공정성) — 종료 시 restore if (!ent.mats) ent.mats = _flash_mats(ent.handle.obj); for (var i = 0; i < ent.mats.length; i++) { var m = ent.mats[i]; if (m.emissive) { if (m.userData.__base_emi == null) { m.userData.__base_emi = m.emissive.getHex(); m.userData.__base_int = m.emissiveIntensity; } m.emissive.setHex(0xff3a24); m.emissiveIntensity = 0.7 * clamp(t01, 0, 1); } } } function _chest(obj, h) { return { x: obj.position.x, y: obj.position.y + (h || 1) * 0.6, z: obj.position.z }; } // ── HUD — 스크린 투영 단일 오버레이 캔버스(per-enemy world-space UI 는 실측 프레임 손실 클래스) ── // 백킹 = CSS × dpr(캡 2) — 1:1 CSS px 백킹은 레티나에서 HP바/하트가 1/dpr² 밀도로 뭉개지던 실틈. // 좌표 기저 = P.vw/P.vh(렌더러와 동일) — innerWidth 혼용은 뷰포트 분기 시 투영 오정렬 클래스. var _hud = null, _hud_g = null, _hud_dpr = 1; function _ensure_hud() { if (_hud) return; _hud = document.createElement('canvas'); _hud.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:99930;'; document.body.appendChild(_hud); _hud_g = _hud.getContext('2d'); function fit() { _hud_dpr = Math.min(2, window.devicePixelRatio || 1); _hud.width = Math.round(P.vw * _hud_dpr); _hud.height = Math.round(P.vh * _hud_dpr); } fit(); P.on_resize(fit); } var _proj_v = null; function _draw_hud() { if (!_hud_g) return; _hud_g.setTransform(1, 0, 0, 1, 0, 0); _hud_g.clearRect(0, 0, _hud.width, _hud.height); _hud_g.setTransform(_hud_dpr, 0, 0, _hud_dpr, 0, 0); // 이하 전부 CSS px 좌표(P.vw 기저) if (!_proj_v) _proj_v = new THREE_.Vector3(); // 몹 HP바 — 첫 피격 시 표시 → 4초 무전투 페이드(관례) for (var i = 0; i < _mobs.length; i++) { var m = _mobs[i]; if (m.state === 'dead' || !m.hp_seen) continue; var age = _wt - m.combat_t; if (age > 4) { m.hp_seen = false; continue; } _proj_v.set(m.handle.obj.position.x, m.handle.obj.position.y + m.h * 1.15, m.handle.obj.position.z).project(cam); if (_proj_v.z > 1) continue; var sx = (_proj_v.x * 0.5 + 0.5) * P.vw, sy = (-_proj_v.y * 0.5 + 0.5) * P.vh; var a = age > 3 ? (4 - age) : 1; _hud_g.globalAlpha = a; _hud_g.fillStyle = 'rgba(10,12,20,0.75)'; _hud_g.fillRect(sx - 23, sy - 5, 46, 7); _hud_g.fillStyle = m.hp / m.hp_max > 0.4 ? '#f59e0b' : '#ef4444'; _hud_g.fillRect(sx - 21, sy - 3, 42 * clamp(m.hp / m.hp_max, 0, 1), 3); _hud_g.globalAlpha = 1; } // 플레이어 하트(전투 설치 시) — Bedrock 정본: 핫바 바로 위 중앙. 종전 상단 좌측(14,34)은 // 뮤트 버튼·아키타입 HUD·미션 칩 밀집 지대라 상시 겹침(실측 회귀 2026-07-14) — 하단 중앙은 // 핫바 외 점유가 없고(공유 버튼은 우측 존) 게임 장르 불문 안전. 무적 창 동안 점멸. var PL = _pl(); if (PL && (_ATK || _mobs.length)) { var blink = _wt < PL.invul_until && (Math.floor(_wt * 10) % 2 === 0); var _hb = document.getElementById('playable-hotbar'); var hy = _hb ? _hb.getBoundingClientRect().top - 10 : P.vh - 88; var hx = (P.vw - PL.hp_max * 24) / 2 + 4; _hud_g.font = '20px system-ui,sans-serif'; for (var hh = 0; hh < PL.hp_max; hh++) { _hud_g.globalAlpha = blink ? 0.35 : (hh < PL.hp ? 1 : 0.22); _hud_g.fillStyle = hh < PL.hp ? '#ef4444' : '#5a606e'; _hud_g.fillText('♥', hx + hh * 24, hy); } _hud_g.globalAlpha = 1; } } // ── 몹 사망 — 즉시 타겟/판정 제외(시체 판정 오염 금지) → die 클립 → 스케일 페이드 → 제거 ── function _mob_die(m) { if (m.state === 'dead') return; if (m.state === 'windup' || m.state === 'attack') _tokens = Math.max(0, _tokens - 1); m.state = 'dead'; m.hp = 0; _flash_restore(m); _cmsfx(m.handle, 'death'); // 사망 성문 — 몹 신원(모델명/cat/태그) 매칭 실녹음 우선 var fell = false; function fall() { if (fell) return; fell = true; if (P.fx) P.fx.despawn(m.handle.obj, { dur: 0.45 }); setTimeout(function () { if (m.handle.remove) m.handle.remove(); // scene + mixer 회수(웨이브 게임 mixer 무한 성장 차단) else if (m.handle.obj.parent) m.handle.obj.parent.remove(m.handle.obj); var _di = _mobs.indexOf(m); // 사체 엔트리 회수 — _mobs 무한 성장(분리/타겟 루프 선형 비용) 차단(선제 감사) if (_di >= 0) _mobs.splice(_di, 1); }, 600); } if (m.handle._actions && m.handle._actions.die) { m.handle.play_once('die', { on_done: fall }); setTimeout(fall, 2600); // die 클립 finished 유실 대비 안전망 } else fall(); if (m.on_death) { try { m.on_death(m.api); } catch (e) { P._hook_err(e, 'on_death'); } } } // ── 몹 피격 — 단일 진입점(플레이어 공격·mob.hurt() 모두 여기) ── function _mob_hurt(m, dmg, from, opt) { if (m.state === 'dead') return; dmg = Math.max(0, num(dmg, 1)); m.hp -= dmg; m.hp_seen = true; m.combat_t = _wt; var c = _chest(m.handle.obj, m.h); var inten = clamp(num(opt && opt.intensity, 0.45), 0, 1); if (P.fx) { P.fx.impact({ x: c.x, y: c.y, z: c.z }, { intensity: inten }); P.fx.float_text({ x: c.x, y: c.y + m.h * 0.4, z: c.z }, String(dmg), (opt && opt.finisher) ? { color: '#ffd23e', size: 1.5 } : { color: '#ffffff' }); } if (m.hp > 0 && _wt >= (m.hurt_sfx_until || 0)) { // 피격 성문 — 콤보 연타 기관총화 방지 스로틀 m.hurt_sfx_until = _wt + 0.25; _cmsfx(m.handle, 'hurt', 0.85); } _flash_white(m, 0.08); // 넉백 — 고정 속도·짧은 거리(경 0.6m / 막타 1.6m), 공격자→피격자 수평 방향 if (from) { var dx = m.handle.obj.position.x - from.x, dz = m.handle.obj.position.z - from.z; var d = Math.sqrt(dx * dx + dz * dz) || 1; var kd = (opt && opt.finisher) ? 1.6 : 0.6; m.kb_x = (dx / d) * kd; m.kb_z = (dz / d) * kd; m.kb_t = 0.18; } // 스턴 + 재스턴 저항(스턴락 방지 — 군중전 전제) if (_wt >= m.restun_until) { m.stun_until = _wt + ((opt && opt.finisher) ? 0.5 : 0.3); m.restun_until = m.stun_until + 0.5; if (m.state === 'windup' || m.state === 'attack') { _tokens = Math.max(0, _tokens - 1); m.state = 'chase'; } if (m.hp > 0 && m.handle._actions && m.handle._actions.hit) m.handle.play_once('hit', { fade: 0.06 }); } if (m.on_hurt) { try { m.on_hurt(m.api, dmg); } catch (e) { P._hook_err(e, 'on_hurt'); } } if (m.hp <= 0) _mob_die(m); } // ── 플레이어 피격 — 무적 창 0.8s + 화면 플래시 + 하트. 기본 사망 = 스폰점 리스폰(캐주얼 정본) ── var _respawn = null; function _player_hurt(dmg, from) { var PL = _pl(); if (!PL || _wt < PL.invul_until || PL.hp <= 0) return; PL.hp -= Math.max(0, num(dmg, 1)); PL.invul_until = _wt + 0.8; if (P.fx) { P.fx.screen_flash('#ff4a3a', 0.1); P.fx.shake(0.4); P.fx.sfx('hit'); } var _pm = world._priv && world._priv.msfx; // 플레이어 피격/사망 성문 — 플레이어 프로필 슬롯(무적창 0.8s 가 자연 스로틀) if (_pm) _pm.verb(_pm.player_slot(), PL.hp <= 0 ? 'death' : 'hurt', _pm.com[PL.hp <= 0 ? 'death' : 'hurt'], 1, true); if (PL.hp <= 0) { if (PL.on_death) { try { PL.on_death(); } catch (e) { P._hook_err(e, 'on_player_death'); } } else { // 기본 리스폰 — 첫 프레임에 기록한 스폰점, 1.2s 후 부활 + 무적 2s. // 위치 복귀는 어댑터 respawn_at 훅이 있으면 그쪽이 정본(craft 등 외부 물리 소유자는 // 매 틱 handle.obj.position 을 자기 좌표로 덮어써 직접 대입이 무효 — "하트만 리셋" 실사고). var p = PL.handle.obj.position; if (P.fx) P.fx.screen_flash('#0a0c14', 0.9); if (PL.handle._actions && PL.handle._actions.die) PL.handle.play_once('die'); // 사망 오버레이(언어 중립) — 죽음이 "일어난 일"로 읽히게 하는 최소 정본 피드백 var _dov = document.createElement('div'); _dov.style.cssText = 'position:fixed;inset:0;z-index:99980;background:rgba(20,6,8,.78);' + 'display:flex;align-items:center;justify-content:center;font-size:72px;pointer-events:none;' + 'opacity:0;transition:opacity .25s;'; _dov.textContent = '\uD83D\uDC80'; document.body.appendChild(_dov); requestAnimationFrame(function () { _dov.style.opacity = '1'; }); setTimeout(function () { if (PL.respawn_at) PL.respawn_at(_respawn ? _respawn.x : null, _respawn ? _respawn.z : null); else if (_respawn) { p.x = _respawn.x; p.z = _respawn.z; p.y = world.height_at(p.x, p.z); } PL.hp = PL.hp_max; PL.invul_until = _wt + 2; _dov.style.opacity = '0'; setTimeout(function () { _dov.remove(); }, 350); }, 1200); } } } // 환경 피해 주입 채널(craft 낙하 데미지 등) — 무적창·플래시·사망/리스폰 사다리를 몹 피격과 // 공유하는 단일 진입점(별도 hp 직조작 = 사망 사다리 우회 회귀의 근원이라 채널로 봉인). if (world._priv) world._priv.player_hurt = function (d) { _player_hurt(num(d, 1), null); }; // ═══ world.mob(model, opts) — 선언적 몹(리서치 수렴 기본값) ═══ // { hp:3, speed:0.8(플레이어 run 대비), aggro:10, attack_range:1.8, leash:18, damage:1, // cooldown:2.0, windup:0.6, behavior:'melee'|'passive'|'flee', at:{x,z}, h?, on_hurt, on_death } // 반환 핸들: { obj, pos, hp, hurt(n), state, remove() } — FSM/리액션/HP바는 전부 셸 소유. world.mob = function (model, opts) { var o = opts || {}; var handle = world.spawn(model, { at: o.at, h: o.h, collide: false, _no_collider: true }); if (!handle) return null; var m = { handle: handle, // h/radius 는 게터 — manifest(_spec)는 비동기로 채워지므로 생성 시 1회 캡처하면 초기 스폰 // 몹 전원이 기본값(1/0.5)에 영구 고정되는 race 가 실측됨(HP바 공중부양·판정 왜곡·스폰 // 시점별 비결정). 매 조회 시 현재 _spec 을 본다. get h() { return num(o.h, (handle._spec && handle._spec.h) || 1); }, get radius() { return num(o.radius, (handle._spec && handle._spec.radius) || 0.5); }, hp_max: clamp(Math.round(num(o.hp, 3)), 1, 99), hp: clamp(Math.round(num(o.hp, 3)), 1, 99), speed_mul: clamp(num(o.speed, 0.8), 0.1, 1.5), y_off: clamp(num(o.y_offset, 0), 0, 30), // 비행체(dragon)용 지면 오프셋 — 지면 클램프에 합산 aggro: clamp(num(o.aggro, 10), 1, 60), atk_range: clamp(num(o.attack_range, o.behavior === 'ranged' ? 11 : 1.8), 0.5, 30), leash: clamp(num(o.leash, 18), 4, 120), damage: Math.max(0, num(o.damage, 1)), cooldown: clamp(num(o.cooldown, 2.0), 0.4, 10), windup: clamp(num(o.windup, 0.6), 0.35, 3), // 텔레그래프 최소 0.35s 하한(공정성) behavior: (o.behavior === 'passive' || o.behavior === 'flee' || o.behavior === 'ranged') ? o.behavior : 'melee', state: 'idle', spawn_x: P._nv2(o.at, 0, 0).x, spawn_z: P._nv2(o.at, 0, 0).z, // 객체/배열 수용(L2) wander_t: _wt + 1 + Math.random() * 3, wx: 0, wz: 0, heading: Math.random() * Math.PI * 2, stun_until: -9, restun_until: -9, next_attack: 0, windup_end: 0, strafe_t: 0, sx: 0, sz: 0, aim_x: 0, aim_z: 0, shots: 0, kb_x: 0, kb_z: 0, kb_t: 0, mats: null, flash_until: 0, hp_seen: false, combat_t: -9, on_hurt: typeof o.on_hurt === 'function' ? o.on_hurt : null, on_death: typeof o.on_death === 'function' ? o.on_death : null, // 팀 교전(additive — lane/BR 공유 기반): null = 구계약(타겟 = 플레이어 고정). // 라벨 의미론: 'ally' 만 특수(플레이어 진영 — 플레이어를 타겟하지 않고 플레이어 공격에서도 // 제외). 그 외 임의 문자열 = 자유 라벨(FFA — 라벨 부등 = 상호 적대, BR 봇 상호 실교전용). team: (typeof o.team === 'string' && o.team.trim()) ? o.team.trim().slice(0, 24) : null, march: (o.march && typeof o.march.x === 'number' && typeof o.march.z === 'number') ? { x: o.march.x, z: o.march.z } : null, // 무타겟 시 행진 목적지(레인 정본) taunt_until: -9, _tgt: null, _tgt_t: -9, _tok: false, }; if (o.behavior && o.behavior !== 'melee' && o.behavior !== 'passive' && o.behavior !== 'flee' && o.behavior !== 'ranged') { try { console.error('[combat] unknown behavior "' + o.behavior + '" — using melee (valid: melee|passive|flee|ranged)'); } catch (e) {} if (window.__playable_beacon) window.__playable_beacon('enum_violation', 'mob behavior: ' + o.behavior); // 생성 결함 — 게이트가 못 잡는 closed-set 위반의 유일 관측 채널 } // melee 몹인데 attack 클립이 없는 모델(pig/sheep/fish 등) — 기계적으론 동작하나 무모션 타격이 // 되므로 LOUD 경고(카탈로그의 전투용 모델을 쓰라는 신호). (function () { function warn_clips() { if (m.behavior === 'melee' && handle._mixer && !(handle._actions || {}).attack) { try { console.warn('[combat] mob model has no attack clip — telegraph plays without animation'); } catch (e) {} } } if (handle.ready) warn_clips(); else (handle._on_ready = handle._on_ready || []).push(warn_clips); })(); m.api = { obj: handle.obj, pos: handle.obj.position, get hp() { return m.hp; }, get state() { return m.state; }, get team() { return m.team; }, hurt: function (n, opt2) { _mob_hurt(m, num(n, 1), null, opt2); }, taunt: function (sec) { m.taunt_until = _wt + clamp(num(sec, 3), 0.5, 10); m._tgt_t = -9; }, // 어그로 강제(enemy 팀 → 플레이어; 재획득 스로틀 즉시 무효화) set_march: function (to) { m.march = (to && typeof to.x === 'number') ? { x: to.x, z: num(to.z, 0) } : null; }, remove: function () { if ((m.state === 'windup' || m.state === 'attack') && m._tok) _tokens = Math.max(0, _tokens - 1); // 토큰 회수(누수 2회=전 몹 공격 불능 실측; 팀 교전 윈드업은 미소비라 _tok 게이트) m.state = 'dead'; var i = _mobs.indexOf(m); if (i >= 0) _mobs.splice(i, 1); if (handle.remove) handle.remove(); // scene + mixer 회수 else if (handle.obj.parent) handle.obj.parent.remove(handle.obj); }, }; _mobs.push(m); _ensure_hud(); return m.api; }; // ═══ world.attack(opts?) — 플레이어 공격(전 기본값 = 정본 수치, 인자 0개 호출 가능) ═══ // { range:2.0, arc:110, combo:3, damage:1, on_hit(mob_api, info) } → { trigger() } // 셸 소유: 공격 버튼 UI + J/X 키, 입력 버퍼 133ms, 3타 체인(막타 1.5x), 정규화-시간 판정 창 // (40~60%, per-swing 중복 제거), lunge + 타겟 자동 회전(score = 거리 0.7 + 정면 0.3). var _ATK = null; world.attack = function (opts) { if (_ATK) return _ATK.api; if (world._priv && world._priv.fps) { // FPS 와 상호배제(프롬프트 install ONE 의 결정론 집행) try { console.error('[combat] world.attack ignored — FPS mode owns firing (install ONE)'); } catch (e) {} return { trigger: function () {} }; } var o = opts || {}; _ATK = { range: clamp(num(o.range, 2.0), 0.8, 30), arc: clamp(num(o.arc, 110), 30, 360), combo: clamp(Math.round(num(o.combo, 3)), 1, 5), damage: Math.max(0, num(o.damage, 1)), on_hit: typeof o.on_hit === 'function' ? o.on_hit : null, chain: 0, chain_until: -9, buffer_until: -9, swinging: false, t0: 0, dur: 0, win_a: 0, win_b: 0, landed: [], lunge_x: 0, lunge_z: 0, lunge_left: 0, }; // 공격 버튼(모바일 우하단 — interact 버튼 위) + 키보드 J/X var btn = document.createElement('button'); btn.id = 'playable-attack'; btn.type = 'button'; // 인라인 SVG(교차 검) — 이모지는 기기 폰트별 렌더 비일관(⤒→"不" 실보고와 동근) btn.innerHTML = '
'; // 주 동사 승계: world 부수기의 south 슬롯을 반납받아 전투 공격이 점유(좌표 하드코딩 금지) P.pad.release(document.getElementById('playable-smash')); P.pad.face(btn, 'primary', { label: ['공격', '攻撃', 'Attack'] }); // 색은 슬롯 소유(캡 광택 규약) btn.addEventListener('pointerdown', function (e) { e.preventDefault(); e.stopPropagation(); _atk_press(); }); document.body.appendChild(btn); // 검 버튼 이중 노출 봉합 — world 의 부수기(⚔)와 같은 자리에 겹치던 잠복 결함: 전투 설치 시 // 공격 버튼이 주 동사를 승계하고 부수기 버튼은 숨긴다(F 키 부수기는 유지 — 소품 파괴 경로 보존). var _wsb = document.getElementById('playable-smash'); if (_wsb) _wsb.style.display = 'none'; P.on_key(function (k) { if ((k === 'j' || k === 'J' || k === 'x' || k === 'X') && !(world._priv && world._priv.fps)) _atk_press(); }); // fps 설치 후엔 키 발동도 양보 _ensure_hud(); _ATK.api = { trigger: _atk_press }; return _ATK.api; }; function _best_target(PL) { // 타겟 스코어 = 거리 0.7 + 정면 0.3(Genshin 재현 실측 근사) — 조회 반경은 공격 리치보다 넉넉히 var px = PL.handle.obj.position.x, pz = PL.handle.obj.position.z; var R = Math.max(_ATK.range * 2.2, 5), best = null, bs = -1; var fx2 = Math.sin(PL.heading), fz2 = Math.cos(PL.heading); for (var i = 0; i < _mobs.length; i++) { var m = _mobs[i]; if (m.state === 'dead' || m.team === 'ally') continue; // 아군 유닛 = 플레이어 타겟 제외 var dx = m.handle.obj.position.x - px, dz = m.handle.obj.position.z - pz; var d = Math.sqrt(dx * dx + dz * dz); if (d > R) continue; var facing = d > 0.001 ? (dx * fx2 + dz * fz2) / d : 1; var s = (1 - d / R) * 0.7 + (facing * 0.5 + 0.5) * 0.3; if (s > bs) { bs = s; best = m; } } return best; } function _atk_press() { var PL = _pl(); if (!_ATK || !PL || PL.hp <= 0) return; if (_ATK.swinging) { _ATK.buffer_until = _wt + 0.35; return; } // 버퍼 — 창 열리면 즉시 소비 _atk_start(PL); } function _atk_start(PL) { if (_wt > _ATK.chain_until) _ATK.chain = 0; var finisher = _ATK.chain === _ATK.combo - 1; // 타겟 자동 회전 + lunge(공격자→타겟, 최소 간격에서 정지) var tgt = _best_target(PL); if (tgt) { var dx = tgt.handle.obj.position.x - PL.handle.obj.position.x; var dz = tgt.handle.obj.position.z - PL.handle.obj.position.z; PL.heading = Math.atan2(dx, dz); PL.handle.obj.rotation.y = PL.heading; var d = Math.sqrt(dx * dx + dz * dz); var stop = PL.radius + tgt.radius + 0.25; var lunge = clamp(d - stop, 0, finisher ? 1.1 : 0.55); if (d > 0.001) { _ATK.lunge_x = (dx / d) * lunge; _ATK.lunge_z = (dz / d) * lunge; _ATK.lunge_left = lunge; } } else { _ATK.lunge_left = 0; } // 클립 선택 — attack/attack2/attack3 중 체인 순, 없으면 가상 스윙 타임라인(0.45s) var A = PL.handle._actions || {}; var names = ['attack', 'attack2', 'attack3'].filter(function (n) { return !!A[n]; }); var clip_alias = names.length ? names[Math.min(_ATK.chain, names.length - 1)] : null; var dur = 0.45, speed = 1; if (clip_alias) { var raw_dur = A[clip_alias].getClip().duration; speed = clamp(raw_dur / 0.75, 0.7, 2.2); // 체감 스윙 ~0.75s 로 정규화(팩별 클립 길이 편차 흡수) dur = raw_dur / speed; PL.handle.play_once(clip_alias, { fade: 0.08, speed: speed }); } _ATK.swinging = true; _ATK.t0 = _wt; _ATK.dur = dur; _ATK.win_a = _wt + dur * (finisher ? 0.35 : 0.40); _ATK.win_b = _wt + dur * (finisher ? 0.65 : 0.60); _ATK.landed.length = 0; _ATK.finisher = finisher; PL.move_lock = dur * 0.7; // 선딜+활성 이동 잠금(커밋먼트), 후딜 후반은 자유 — 잔여초(월드 틱이 게임 dt 로 감소) if (P.fx) P.fx.sfx('blip', { volume: 0.5 }); } function _atk_tick(PL, dt) { if (!_ATK || !_ATK.swinging) return; var t = _wt; // lunge — 활성 창까지 잔여 거리 소진(고정 짧은 전진) if (_ATK.lunge_left > 0 && t < _ATK.win_b) { var step = Math.min(_ATK.lunge_left, (dt / Math.max(0.001, _ATK.dur * 0.6)) * Math.sqrt(_ATK.lunge_x * _ATK.lunge_x + _ATK.lunge_z * _ATK.lunge_z)); var L = Math.sqrt(_ATK.lunge_x * _ATK.lunge_x + _ATK.lunge_z * _ATK.lunge_z) || 1; PL.handle.obj.position.x += (_ATK.lunge_x / L) * step; PL.handle.obj.position.z += (_ATK.lunge_z / L) * step; _ATK.lunge_left -= step; } // 판정 창 — arc 내 전원(≤5체 캡), per-swing 1회 if (t >= _ATK.win_a && t <= _ATK.win_b) { var p = PL.handle.obj.position; var hits = 0; for (var i = 0; i < _mobs.length && hits < 5; i++) { var m = _mobs[i]; if (m.state === 'dead' || m.team === 'ally' || _ATK.landed.indexOf(m) >= 0) continue; // 아군 오사 차단 if (_in_arc(p.x, p.y, p.z, PL.heading, _ATK.range, _ATK.arc, m.handle.obj.position.x, m.handle.obj.position.y, m.handle.obj.position.z, m.radius)) { _ATK.landed.push(m); hits++; var dmg = _ATK.finisher ? Math.max(1, Math.round(_ATK.damage * 1.5)) : _ATK.damage; _mob_hurt(m, dmg, p, { intensity: _ATK.finisher ? 0.85 : 0.35 + 0.12 * _ATK.chain, finisher: _ATK.finisher }); if (_ATK.on_hit) { try { _ATK.on_hit(m.api, { damage: dmg, combo: _ATK.chain + 1, finisher: _ATK.finisher }); } catch (e) { P._hook_err(e, 'on_hit'); } } } } } if (t >= _ATK.t0 + _ATK.dur) { _ATK.swinging = false; _ATK.chain = (_ATK.chain + 1) % _ATK.combo; _ATK.chain_until = t + 0.9; // 체인 유지 창 — 경과 시 1타로 리셋 if (_ATK.buffer_until > t) { _ATK.buffer_until = -9; _atk_start(PL); } } } // ── 타겟 프록시(팀 교전 additive) — 구계약(team=null) = 플레이어 프록시 고정 ── // 프록시 계약: {pos, radius, h, is_player, alive(), hurt(d, from)} — 몹/플레이어/외부(타워)가 동형. var _ext_targets = []; // lane 등의 고정 유닛(타워/코어) 등록면 function _player_proxy(PL) { return { pos: PL.handle.obj.position, radius: PL.radius, h: 1.7, is_player: true, alive: function () { return PL.hp > 0; }, hurt: function (d, from) { _player_hurt(d, from); } }; } function _mob_proxy(t) { return { pos: t.handle.obj.position, radius: t.radius, h: t.h, is_player: false, alive: function () { return t.state !== 'dead'; }, hurt: function (d, from) { _mob_hurt(t, d, from, { intensity: 0.3 }); } }; } function _target_for(m, PL) { if (!m.team) return (PL && PL.hp > 0) ? _player_proxy(PL) : null; // 구계약 불변 if (m.team !== 'ally' && PL && PL.hp > 0 && _wt < m.taunt_until) return _player_proxy(PL); // 어그로 표("치면 웨이브 전체가 문다") — 비-ally 라벨 전체 if (m._tgt && _wt < m._tgt_t + 0.5 && m._tgt.alive()) return m._tgt; // 0.5s 재획득 스로틀 var mp = m.handle.obj.position; var best = null, bd = Infinity; for (var i = 0; i < _mobs.length; i++) { var t = _mobs[i]; if (t === m || t.state === 'dead' || !t.team || t.team === m.team) continue; var dx = t.handle.obj.position.x - mp.x, dz = t.handle.obj.position.z - mp.z; var d2 = dx * dx + dz * dz; if (d2 < bd) { bd = d2; best = _mob_proxy(t); } } for (var j = 0; j < _ext_targets.length; j++) { var et = _ext_targets[j]; if (et.team === m.team || !et.alive()) continue; var ex = et.pos.x - mp.x, ez = et.pos.z - mp.z; var ed2 = ex * ex + ez * ez; if (ed2 < bd) { bd = ed2; best = et; } } if (m.team !== 'ally' && PL && PL.hp > 0) { // 비-ally 라벨 전체가 플레이어를 후보에 포함(FFA 동형) var pdx = PL.handle.obj.position.x - mp.x, pdz = PL.handle.obj.position.z - mp.z; if (pdx * pdx + pdz * pdz < bd) best = _player_proxy(PL); } m._tgt = best; m._tgt_t = _wt; return best; } // ── 몹 FSM 틱 ── function _mob_tick(m, PL, dt) { var obj = m.handle.obj, pos = obj.position; if (m.state === 'dead') return; if (m.flash_until && _wt >= m.flash_until) { m.flash_until = 0; _flash_restore(m); } // 넉백(고정 속도 소진) if (m.kb_t > 0) { var f = Math.min(dt, m.kb_t) / 0.18; pos.x += m.kb_x * f; pos.z += m.kb_z * f; m.kb_t -= dt; } // 접지 = 지형 ∪ 확장-지면(슬랩 바닥·램프 — ground_ext 제공자) 중 최대. 지형-단독 클램프는 // 슬랩 위 몹 전원이 바닥에 매몰되는 클래스였다(2026-07-21 안뜰/던전/호러 바닥 신설로 표면화). var _gy = world.height_at(pos.x, pos.z); if (world._priv && world._priv.ground_ext_probe) { // 관용 +1.7: 지형에서 스폰된 몹이 낮은 슬랩 바닥(≤~1.5m) 위로 부트스트랩되게 하되, // 고상 바닥(build 엘리베이티드 ≥3m) 아래의 몹은 끌어올리지 않는다. var _ge = world._priv.ground_ext_probe(pos.x, pos.z, pos.y + 1.7); if (_ge != null && _ge > _gy) _gy = _ge; } pos.y = _gy + m.y_off; // y_off = 비행체 지면 오프셋 if (_wt < m.stun_until) return; // 스턴 — AI 정지(넉백/중력만) var T = _target_for(m, PL); // 타겟 프록시(구계약 = 플레이어 고정) if (!T) { // 무타겟: 구계약 몹 = 종전대로 정지, 팀 몹 = 행진 목적지로 전진(레인 정본) 또는 대기 if (m.team && m.march) { var A0 = m.handle._actions || {}; (function () { var mdx = m.march.x - pos.x, mdz = m.march.z - pos.z; var md = Math.sqrt(mdx * mdx + mdz * mdz); if (md < 0.6) { if (!m.handle._oneshot && A0.idle) m.handle.play('idle'); return; } pos.x += (mdx / md) * 1.6 * m.speed_mul * dt; pos.z += (mdz / md) * 1.6 * m.speed_mul * dt; var want0 = Math.atan2(mdx, mdz), dh0 = want0 - m.heading; while (dh0 > Math.PI) dh0 -= Math.PI * 2; while (dh0 < -Math.PI) dh0 += Math.PI * 2; m.heading += dh0 * damp_k(8, dt); obj.rotation.y = m.heading; if (!m.handle._oneshot && A0.walk) m.handle.play('walk'); })(); } return; } var pp = T.pos; var dx = pp.x - pos.x, dz = pp.z - pos.z; var pd = Math.sqrt(dx * dx + dz * dz); var sdx = pos.x - m.spawn_x, sdz = pos.z - m.spawn_z; var leash_d = Math.sqrt(sdx * sdx + sdz * sdz); var A = m.handle._actions || {}; var spd_walk = 1.6 * m.speed_mul, spd_run = (PL.run || 8.5) * m.speed_mul; function walk_to(tx, tz, spd, clip) { var mdx = tx - pos.x, mdz = tz - pos.z; var md = Math.sqrt(mdx * mdx + mdz * mdz); if (md < 0.05) return true; pos.x += (mdx / md) * spd * dt; pos.z += (mdz / md) * spd * dt; var want = Math.atan2(mdx, mdz), dh = want - m.heading; while (dh > Math.PI) dh -= Math.PI * 2; while (dh < -Math.PI) dh += Math.PI * 2; m.heading += dh * damp_k(8, dt); obj.rotation.y = m.heading; if (!m.handle._oneshot && A[clip]) m.handle.play(clip); return false; } switch (m.state) { case 'idle': if (!m.handle._oneshot && A.idle) m.handle.play('idle'); if ((m.behavior === 'melee' || m.behavior === 'ranged') && pd < m.aggro) { m.state = 'chase'; break; } if (m.behavior === 'flee' && pd < m.aggro) { m.state = 'flee'; break; } if (m.team && m.march) { // 행진(레인 정본) — 배회 대신 목적지 전진, spawn 갱신(leash 무해화) if (!walk_to(m.march.x, m.march.z, spd_walk * 1.9, 'walk')) { m.spawn_x = pos.x; m.spawn_z = pos.z; } break; } if (_wt > m.wander_t) { m.wx = m.spawn_x + (Math.random() - 0.5) * 12; m.wz = m.spawn_z + (Math.random() - 0.5) * 12; m.state = 'wander'; } break; case 'wander': if ((m.behavior === 'melee' || m.behavior === 'ranged') && pd < m.aggro) { m.state = 'chase'; break; } if (m.behavior === 'flee' && pd < m.aggro) { m.state = 'flee'; break; } if (walk_to(m.wx, m.wz, spd_walk, 'walk')) { m.state = 'idle'; m.wander_t = _wt + 2 + Math.random() * 3; } break; case 'chase': if (leash_d > m.leash) { m.state = 'return'; break; } if (pd > m.aggro * 1.6) { m.state = 'return'; break; } // 이탈 해제(영원 추격 금지) if (m.behavior === 'ranged') { // 환형 유지: 사거리의 55~90% 밴드 밖이면 접근/이탈, 밴드 안에서는 주기적 측면 재배치(strafe) var band_in = m.atk_range * 0.55, band_out = m.atk_range * 0.9; if (pd > band_out) { walk_to(pp.x, pp.z, spd_run, A.run ? 'run' : 'walk'); } else if (pd < band_in) { walk_to(pos.x - dx, pos.z - dz, spd_walk * 2, A.run ? 'run' : 'walk'); } else { if (_wt > m.strafe_t) { var sa = Math.atan2(dx, dz) + Math.PI + (Math.random() - 0.5) * 1.6; m.sx = pp.x + Math.sin(sa) * pd; m.sz = pp.z + Math.cos(sa) * pd; m.strafe_t = _wt + 1.5 + Math.random() * 1.5; // 재선정 스로틀(정본) } walk_to(m.sx, m.sz, spd_walk * 1.5, 'walk'); if (_wt >= m.next_attack && (!T.is_player || _tokens < _TOKEN_MAX)) { if (!_proj_los(pos.x, pos.y + m.h * 0.8, pos.z, pp.x, pp.y + 1.05, pp.z)) { m.strafe_t = _wt; // 사선이 지형에 막힘 — 쏘지 않고 즉시 재배치(능선 너머 무의미 사격 차단) } else { if (T.is_player) { _tokens++; m._tok = true; } else m._tok = false; // 토큰 = 대-플레이어 동시 공격 수 제한 전용 m.state = 'windup'; m.windup_end = _wt + m.windup; m.aim_x = pp.x; m.aim_z = pp.z; // 묵은 조준 좌표(발사 시점이 아닌 지금) if (A.attack) m.handle.play_once('attack', { fade: 0.08, speed: Math.max(0.6, (A.attack.getClip().duration || 1) / (m.windup + 0.35)) }); } } } break; } if (pd <= m.atk_range) { if (_wt >= m.next_attack && (!T.is_player || _tokens < _TOKEN_MAX)) { if (T.is_player) { _tokens++; m._tok = true; } else m._tok = false; m.state = 'windup'; m.windup_end = _wt + m.windup; if (A.attack) m.handle.play_once('attack', { fade: 0.08, speed: Math.max(0.6, (A.attack.getClip().duration || 1) / (m.windup + 0.35)) }); } else if (!m.handle._oneshot && A.idle) m.handle.play('idle'); // 토큰 대기 — 포위 유지 } else { // 추격 속도는 클립 존재와 무관(run 클립 없는 모델이 걷는 플레이어도 못 쫓는 함정 봉합) — // 클립만 있는 것으로 폴백(walk 애니에 run 속도). walk_to(pp.x, pp.z, spd_run, A.run ? 'run' : 'walk'); } break; case 'windup': { // 텔레그래프 — 발광 램프업 + 타겟 응시(회피 판단 시간 = 공정성) var t01 = 1 - (m.windup_end - _wt) / m.windup; _telegraph(m, t01); var want2 = Math.atan2(dx, dz), dh2 = want2 - m.heading; while (dh2 > Math.PI) dh2 -= Math.PI * 2; while (dh2 < -Math.PI) dh2 += Math.PI * 2; m.heading += dh2 * damp_k(4, dt); obj.rotation.y = m.heading; if (_wt >= m.windup_end) { _flash_restore(m); if (m._tok) { _tokens = Math.max(0, _tokens - 1); m._tok = false; } m.next_attack = _wt + m.cooldown; m.state = 'chase'; if (m.behavior === 'ranged') { _fire_projectile(m, T); } // 착탄(근접) — 단일 arc 판정 경로(타겟 프록시 — 플레이어/유닛/타워 동형) else if (_in_arc(pos.x, pos.y, pos.z, m.heading, m.atk_range * 1.15, 100, pp.x, pp.y, pp.z, T.radius)) { T.hurt(m.damage, pos); } } break; } case 'flee': if (pd > m.aggro * 1.8) { m.state = 'idle'; break; } walk_to(pos.x - dx, pos.z - dz, spd_run * 0.9, A.run ? 'run' : 'walk'); break; case 'return': if (m.behavior === 'melee' && pd < m.aggro * 0.7) { m.state = 'chase'; break; } // 귀환 중 재조우 if (walk_to(m.spawn_x, m.spawn_z, spd_walk * 2, A.run ? 'run' : 'walk')) { m.state = 'idle'; m.hp = m.hp_max; m.hp_seen = false; // 귀환 완료 = 회복(leash 정본) } break; } // 몹-타겟/몹-몹 최소 간격(원기둥 push-out — 정적 콜라이더 미등록: 몹은 이동체) if (pd > 0.001 && pd < m.radius + T.radius) { var push = (m.radius + PL.radius - pd) / pd; pos.x -= dx * push; pos.z -= dz * push; } if (world._priv) world._priv.push_out(pos, m.radius); } // ── 적 투사체 풀 — 정본: 적의 히트스캔은 회피 불가라 캐주얼 금기 → 느린 dodgeable 투사체 + // 묵은 조준(windup 시작 시점 좌표) + 첫 발 고의 빗맞춤. 속도 기본 8m/s(플레이어 run 8.5 — 회피 가능). var _projs = [], _proj_pool = []; function _fire_projectile(m, T) { var pr = _proj_pool.pop(); if (!pr) { if (_projs.length >= 16) return; // 풀 상한 var mesh = new THREE_.Mesh(new THREE_.SphereGeometry(0.16, 8, 8), new THREE_.MeshBasicMaterial({ color: 0xff6a3a })); pr = { mesh: mesh }; scene.add(mesh); } // 발사 고도 = h·0.8(가슴보다 높게) — 구릉 지형에서 저고도 스침 궤적이 지면 판정으로 전량 // 소멸하는 함정 실측(pmin 0.83m 에서 puff) → 발사/목표 고도 상향 + 지면 판정 소여유. var src = { x: m.handle.obj.position.x, y: m.handle.obj.position.y + m.h * 0.8, z: m.handle.obj.position.z }; // 묵은 조준: windup 시작 시 저장한 (aim_x, aim_z) — 이동 중이면 자연 빗맞음. 첫 발은 측면 1.2m 오프셋. var tx = m.aim_x, tz = m.aim_z; if (m.shots === 0) { var a0 = Math.random() * Math.PI * 2; tx += Math.cos(a0) * 1.2; tz += Math.sin(a0) * 1.2; } m.shots++; var ty = T.pos.y + (T.is_player ? 1.05 : Math.max(0.6, T.h * 0.6)); var dx = tx - src.x, dy = ty - src.y, dz = tz - src.z; var d = Math.sqrt(dx * dx + dy * dy + dz * dz) || 1; var spd = clamp(num(m.proj_speed, 8), 3, 20); pr.vx = dx / d * spd; pr.vy = dy / d * spd; pr.vz = dz / d * spd; pr.mesh.position.set(src.x, src.y, src.z); pr.mesh.visible = true; pr.life = 4; pr.dmg = m.damage; pr.tgt = T.is_player ? null : T; // 유닛/타워 타겟 — null = 구계약(플레이어 판정) _projs.push(pr); if (P.fx) P.fx.sfx('shoot', { volume: 0.6 }); } function _proj_tick(PL, dt) { for (var i = _projs.length - 1; i >= 0; i--) { var pr = _projs[i]; pr.life -= dt; pr.mesh.position.x += pr.vx * dt; pr.mesh.position.y += pr.vy * dt; pr.mesh.position.z += pr.vz * dt; var hit_ground = pr.mesh.position.y < world.height_at(pr.mesh.position.x, pr.mesh.position.z) - 0.08; var hit_pl = false; if (pr.tgt) { // 유닛/타워 타겟(팀 교전) — 사멸 시 자연 소멸 비행 if (pr.tgt.alive()) { var tp2 = pr.tgt.pos; var hx2 = pr.mesh.position.x - tp2.x, hy2 = pr.mesh.position.y - (tp2.y + Math.max(0.5, pr.tgt.h * 0.55)), hz2 = pr.mesh.position.z - tp2.z; if ((hx2 * hx2 + hy2 * hy2 + hz2 * hz2) < (pr.tgt.radius + 0.45) * (pr.tgt.radius + 0.45)) { hit_pl = true; pr.tgt.hurt(pr.dmg, pr.mesh.position); } } } else { var pp = PL ? PL.handle.obj.position : null; if (pp) { var hx = pr.mesh.position.x - pp.x, hy = pr.mesh.position.y - (pp.y + 0.9), hz = pr.mesh.position.z - pp.z; hit_pl = (hx * hx + hy * hy + hz * hz) < (PL.radius + 0.45) * (PL.radius + 0.45); } if (hit_pl) _player_hurt(pr.dmg, pr.mesh.position); } if (hit_pl || hit_ground || pr.life <= 0) { if (P.fx && (hit_pl || hit_ground)) P.fx.burst({ x: pr.mesh.position.x, y: pr.mesh.position.y, z: pr.mesh.position.z }, { count: 6, color: '#ff8a5a', speed: 3, life: 0.3 }); pr.mesh.visible = false; _projs.splice(i, 1); _proj_pool.push(pr); } } } // 사선 LOS — 발사 경로 8점 지형 샘플(+0.25 여유). 막히면 쏘지 않고 재배치(정본: annulus 재선정도 LOS 조건부) function _proj_los(sx, sy, sz, txx, tyy, tzz) { var dx = txx - sx, dy = tyy - sy, dz = tzz - sz; for (var i = 1; i < 8; i++) { var t = i / 8; if (sy + dy * t < world.height_at(sx + dx * t, sz + dz * t) + 0.25) return false; } return true; } // FPS 패밀리 브리지(비공개) — 몹 목록·단일 피해 경로 재사용(리액션 스택 공유) world._priv_combat = { mobs: function () { return _mobs; }, hurt: function (m, dmg, from, opt) { _mob_hurt(m, dmg, from, opt); }, // 외부 고정 유닛(타워/코어 — lane 소유) 타겟 등록: 프록시 계약 {team, pos, radius, h, alive(), hurt(d, from)} register_targetable: function (t) { _ext_targets.push(t); return function () { var i = _ext_targets.indexOf(t); if (i >= 0) _ext_targets.splice(i, 1); }; }, targets: function () { return _ext_targets; }, // 미니맵 구조물 블립 소비면(조회 전용) }; function _mob_sep() { // 몹-몹 겹침 해소(살아있는 몹 ≤10 전제 — O(n²) 무해) for (var i = 0; i < _mobs.length; i++) { var a = _mobs[i]; if (a.state === 'dead') continue; for (var j = i + 1; j < _mobs.length; j++) { var b = _mobs[j]; if (b.state === 'dead') continue; var dx = b.handle.obj.position.x - a.handle.obj.position.x; var dz = b.handle.obj.position.z - a.handle.obj.position.z; var rr = a.radius + b.radius; var d2 = dx * dx + dz * dz; if (d2 > 0.000001 && d2 < rr * rr) { var d = Math.sqrt(d2), push = (rr - d) / d * 0.5; a.handle.obj.position.x -= dx * push; a.handle.obj.position.z -= dz * push; b.handle.obj.position.x += dx * push; b.handle.obj.position.z += dz * push; } } } } // ── combat 틱 — 게임 시간(dt: 히트스톱 동조), HUD 는 매 프레임 ── P.loop(function (dt) { _wt += dt; var PL = _pl(); if (PL && !_respawn) _respawn = { x: PL.handle.obj.position.x, z: PL.handle.obj.position.z }; if (dt > 0 && PL) { _atk_tick(PL, dt); for (var i = 0; i < _mobs.length; i++) _mob_tick(_mobs[i], PL, dt); _mob_sep(); _proj_tick(PL, dt); } _draw_hud(); }); })(); b ? b : v); } function num(v, d) { return typeof v === 'number' && isFinite(v) ? v : d; } function _pl() { return world._priv ? world._priv.player() : null; } function _mobs() { return world._priv_combat ? world._priv_combat.mobs() : []; } var F = null; // 설치 상태 var _dir = new THREE_.Vector3(), _right = new THREE_.Vector3(), _up = new THREE_.Vector3(0, 1, 0); // ── 트레이서 풀(additive Line — 탄환당 라이트 금지 정본) ── var _tracers = [], _tracer_pool = []; function _tracer(from, to) { var t = _tracer_pool.pop(); if (!t) { if (_tracers.length >= 8) return; var g = new THREE_.BufferGeometry(); g.setAttribute('position', new THREE_.BufferAttribute(new Float32Array(6), 3)); var m = new THREE_.LineBasicMaterial({ color: 0xffe0a0, transparent: true, opacity: 0.9, blending: THREE_.AdditiveBlending, depthWrite: false }); t = { line: new THREE_.Line(g, m) }; scene.add(t.line); } var a = t.line.geometry.attributes.position.array; a[0] = from.x; a[1] = from.y; a[2] = from.z; a[3] = to.x; a[4] = to.y; a[5] = to.z; t.line.geometry.attributes.position.needsUpdate = true; t.line.visible = true; t.life = 0.07; _tracers.push(t); } // ── 지형 LOS — 레이 경로 8점 높이 샘플(해석적 지형이라 저비용) ── function _terrain_blocks(ox, oy, oz, dx, dy, dz, dist) { for (var i = 1; i <= 8; i++) { var tt = dist * i / 8; if (oy + dy * tt < world.height_at(ox + dx * tt, oz + dz * tt)) return tt; } return -1; } // ── 히트 판정 — 몹 = 가슴 중심 구(반경 max(radius, h·0.45)·1.3 magnetism), 레이-구 해석 판정 ── function _ray_mob(ox, oy, oz, dx, dy, dz, range) { var best = null, bestT = range; var mobs = _mobs(); for (var i = 0; i < mobs.length; i++) { var m = mobs[i]; if (m.state === 'dead' || m.team === 'ally') continue; // 아군 유닛 오사 차단(combat 근접 경로와 대칭) var p = m.handle.obj.position; var cxx = p.x - ox, cy = (p.y + m.h * 0.55) - oy, cz = p.z - oz; var tca = cxx * dx + cy * dy + cz * dz; if (tca < 0 || tca > bestT) continue; var r = Math.max(m.radius, m.h * 0.45) * 1.3; var d2 = cxx * cxx + cy * cy + cz * cz - tca * tca; if (d2 <= r * r) { best = m; bestT = tca; } } return best ? { mob: best, t: bestT } : null; } // world.fps(opts) — 1인칭 모드 설치(원라인 계약). 기본값 전부 정본 수치. // { weapon:'crossbow'|팩아이템|null, fire_rate:2.5(발/초), damage:1, range:40, spread:1.5(도), // auto:true, eye:1.55, hfov:90, on_fire(), on_hit(mob, info) } → { trigger(), get yaw() } world.fps = function (opts) { if (F) return F.api; var o = opts || {}; F = { yaw: 0, pitch: 0, recoil: 0, cool: 0, bob_t: 0, rate: clamp(num(o.fire_rate, 2.5), 0.5, 12), dmg: Math.max(0, num(o.damage, 1)), range: clamp(num(o.range, 40), 5, 120), spread: clamp(num(o.spread, 1.5), 0, 8) * Math.PI / 180, auto: o.auto !== false, view: o.view === 'shoulder' ? 'shoulder' : 'fp', // 'shoulder' = 3인칭 숄더캠(본체 가시·뷰모델 생략 — build_fight 정본) eye: clamp(num(o.eye, 1.55), 0.6, 3), hfov: clamp(num(o.hfov, 90), 60, 110), on_fire: typeof o.on_fire === 'function' ? o.on_fire : null, tracer: o.tracer !== false, // false = 기본 트레이서(연노랑 라인) 억제 — 커스텀 발사 VFX 소비자용 fire_sfx: o.fire_sfx !== false, // false = 기본 합성 발사음 억제 — 실녹음 발사음 소비자용 on_hit: typeof o.on_hit === 'function' ? o.on_hit : null, gun: null, gun_scene: null, gun_cam: null, locked: false, }; world._priv.fps = F; // world 가 카메라 리그를 끄고 이동 yaw 를 이걸로 읽음 var PL0 = _pl(); if (PL0) { if (F.view !== 'shoulder') { PL0.handle.obj.visible = false; F._hid = true; } F.yaw = PL0.heading; } // 1인칭 = 본체 숨김(숄더 = 가시) // world.attack 과 상호배제(설치 순서 무관) — 동시 설치 = 이중 발동·버튼 좌표 겹침 함정 var _ab = document.getElementById('playable-attack'); if (_ab) { _ab.style.display = 'none'; try { console.error('[fps] world.attack button hidden — FPS owns firing (install ONE)'); } catch (e) {} } cam.rotation.order = 'YXZ'; // hFOV-lock — three 의 fov 는 수직: 세로 화면에서 수평 ~39° 터널시야 → 수평 고정 보정(정본) function fit_fov() { var aspect = P.vw / Math.max(1, P.vh); var v = 2 * Math.atan(Math.tan(F.hfov * Math.PI / 360) / aspect) * 180 / Math.PI; cam.fov = clamp(v, 45, 110); cam.updateProjectionMatrix(); if (F.gun_cam) { F.gun_cam.aspect = aspect; F.gun_cam.updateProjectionMatrix(); } } fit_fov(); P.on_resize(fit_fov); // 크로스헤어 + 히트마커(DOM) — 기본 = 중앙 점(캐주얼), opts.crosshair:'sniper' = 대형 십자 // 저격 레티클(수직/수평 4바 + 중앙 도트 + 외곽 링, 백색+흑색 윤곽 = 밝은 표면 위 가독 보장) var ch = document.createElement('div'); ch.id = 'playable-crosshair'; if (o.crosshair === 'sniper') { ch.style.cssText = 'position:fixed;left:50%;top:50%;width:56px;height:56px;margin:-28px 0 0 -28px;pointer-events:none;z-index:99940;'; var _xh = ''; var _bar = 'position:absolute;background:rgba(255,255,255,.95);box-shadow:0 0 0 1px rgba(0,0,0,.55);border-radius:2px;'; _xh += '
'; _xh += '
'; _xh += '
'; _xh += '
'; _xh += '
'; _xh += '
'; ch.innerHTML = _xh; } else { ch.style.cssText = 'position:fixed;left:50%;top:50%;width:6px;height:6px;margin:-3px 0 0 -3px;border-radius:50%;' + 'background:rgba(255,255,255,.9);box-shadow:0 0 0 2px rgba(0,0,0,.25);pointer-events:none;z-index:99940;'; } document.body.appendChild(ch); var hm = document.createElement('div'); hm.textContent = '✕'; hm.style.cssText = 'position:fixed;left:50%;top:50%;transform:translate(-50%,-50%) scale(1);color:#ffd23e;' + 'font:700 26px system-ui;opacity:0;pointer-events:none;z-index:99941;transition:opacity .12s,transform .12s;'; document.body.appendChild(hm); F._hitmark = function () { hm.style.opacity = '1'; hm.style.transform = 'translate(-50%,-50%) scale(1.25)'; setTimeout(function () { hm.style.opacity = '0'; hm.style.transform = 'translate(-50%,-50%) scale(0.9)'; }, 90); }; // 뷰모델 — 2-scene 오버레이(무기 팩 재사용). 오버레이 씬은 자체 라이트 필수(정본 gotcha). F.gun_scene = new THREE_.Scene(); F.gun_cam = new THREE_.PerspectiveCamera(50, P.vw / Math.max(1, P.vh), 0.01, 10); F.gun_scene.add(new THREE_.HemisphereLight(0xffffff, 0x444455, 1.1)); var gl2 = new THREE_.DirectionalLight(0xfff2dd, 0.8); gl2.position.set(1, 2, 1); F.gun_scene.add(gl2); if (o.weapon !== null && F.view !== 'shoulder') { // 숄더 = 뷰모델 생략(손 무기는 hold 동사 소유) world._priv.load_model(String(o.weapon || 'crossbow'), 0.55, function (inst) { var g = new THREE_.Group(); inst.rotation.x = -Math.PI / 2; // 팩 아이템은 Y-축 상향 정규화 — 전방(−Z)으로 눕힘 g.add(inst); g.position.set(0.24, -0.22, -0.5); g.rotation.y = 0.06; F.gun_scene.add(g); F.gun = g; }); } // 렌더 루프 소비 훅 — 월드 렌더 → clearDepth → 무기(three 셸이 호출) P._fps_overlay = function () { renderer.clearDepth(); var ac = renderer.autoClear; renderer.autoClear = false; renderer.render(F.gun_scene, F.gun_cam); renderer.autoClear = ac; }; // ── 데스크톱 마우스 정본(2026-07-24 전면 재설계 — MDN Pointer Lock + three.js PointerLockControls 문법) ── // 리서치 확정 캐논(스펙): 락 중 모든 마우스 이벤트(move/down/up/click/wheel)는 *락 대상 요소*가 // 단독 수신한다. 종전 결함의 뿌리 = body 에 락을 걸어 캔버스가 이벤트 흐름에서 영구 제외된 것. // ① 락 대상 = canvas(PointerLockControls 동형) ② 첫 클릭 = 락 취득 전용(오발 없음) // ③ 락 중 mousedown(button 0) = 발사 홀드 — 좌표는 무의미하므로 사격은 중심 레이(기존 계약) // ④ ESC = 일시정지 트리거(pointerlockchange 감지) → 비차단 재개 배지(UI 는 그대로 조작 가능) // ⑤ 재락은 브라우저 쿨다운(~1.3s) 실패를 관용 — pointerlockerror 후 재클릭 대기(정본 UX) // ⑥ unadjustedMovement(OS 가속 무시 = FPS 정본) 시도 → NotSupportedError 폴백(Chrome/Edge 만 지원) // ⑦ 락 거부 환경(CSP 등) = 드래그-룩 폴백 유지, 발사는 ◉/J/X(커서가 살아 있으므로 성립) var canvas = renderer.domElement; function _plock_request() { try { var pr = canvas.requestPointerLock({ unadjustedMovement: true }); if (pr && pr.catch) pr.catch(function (err) { if (err && err.name === 'NotSupportedError') { try { canvas.requestPointerLock(); } catch (e2) {} } }); } catch (e) { try { canvas.requestPointerLock(); } catch (e2) {} } // 구형(비-옵션 시그니처) 브라우저 폴백 } var _resume = null; // ESC 후 "죽은 커서" 무설명 방치 금지 — 비차단 재개 배지(중앙 필만 클릭 소비) function _resume_show() { if (_resume) return; if (document.body.getAttribute('data-pend') === '1') return; // 게임 종료 상태 = 재개 배지 억제(종료 = 조준 모드 OFF 확정) _resume = document.createElement('div'); _resume.id = 'playable-aim-resume'; _resume.setAttribute('data-mgmt-ui', '1'); _resume.style.cssText = 'position:fixed;inset:0;z-index:99968;display:flex;align-items:center;justify-content:center;pointer-events:none;'; var pill = document.createElement('button'); pill.type = 'button'; pill.setAttribute('data-mgmt-ui', '1'); var _rl = String(((typeof navigator !== 'undefined' && (navigator.language || navigator.userLanguage)) || document.documentElement.lang || 'en')).toLowerCase(); // 언어 정본 = 브라우저 우선(2026-07-29 정책 전환·world 셸 동형) pill.textContent = '🖱 ' + (_rl.indexOf('ja') === 0 ? 'クリックで照準再開' : _rl.indexOf('ko') === 0 ? '클릭하여 조준 재개' : 'Click to resume aim'); pill.style.cssText = 'pointer-events:auto;padding:12px 20px;border-radius:999px;border:2px solid rgba(150,205,255,.45);' + 'background:rgba(16,22,34,.88);color:#eaf2ff;font:700 15px system-ui;cursor:pointer;box-shadow:0 6px 20px rgba(0,0,0,.5);'; pill.addEventListener('pointerdown', function (e) { e.stopPropagation(); _plock_request(); }); // 쿨다운 실패 = 배지 유지 → 재클릭 _resume.appendChild(pill); document.body.appendChild(_resume); } function _resume_hide() { if (_resume) { try { _resume.remove(); } catch (e) {} _resume = null; } } var _aimhud = null; // 조준 모드 상태 배지(사용자 확정 UX — 락 상태의 가시 표식 = 혼돈 최소화의 정본) function _aimhud_show() { if (_aimhud) return; var _al = String(((typeof navigator !== 'undefined' && (navigator.language || navigator.userLanguage)) || document.documentElement.lang || 'en')).toLowerCase(); _aimhud = document.createElement('div'); _aimhud.id = 'playable-aimhud'; // 종료 배너가 id 로 즉시 제거(락 해제 비동기 레이스의 겹침 봉합) _aimhud.setAttribute('data-mgmt-ui', '1'); _aimhud.textContent = '🎯 ' + (_al.indexOf('ja') === 0 ? '照準モード ON · ESC で解除' : _al.indexOf('ko') === 0 ? '조준 모드 ON · ESC 키로 해제' : 'AIM MODE ON · press ESC to exit'); _aimhud.style.cssText = 'position:fixed;left:50%;top:calc(58px + env(safe-area-inset-top,0px));transform:translateX(-50%);' + 'z-index:99965;padding:8px 16px;border-radius:999px;pointer-events:none;white-space:nowrap;' + 'background:rgba(16,22,34,.82);border:1.5px solid rgba(94,240,141,.55);color:#8effc2;' + 'font:700 13px system-ui;box-shadow:0 4px 14px rgba(0,0,0,.45);'; document.body.appendChild(_aimhud); } function _aimhud_hide() { if (_aimhud) { try { _aimhud.remove(); } catch (e) {} _aimhud = null; } } canvas.addEventListener('pointerdown', function (e) { if (e.pointerType !== 'mouse') return; if (document.body.getAttribute('data-pend') === '1') return; // 게임 종료 후 = 조준 락 재점화 금지(배지 부활 차단 — 실보고 2026-07-27) if (!document.pointerLockElement) { _plock_request(); return; } // 첫 클릭 = 락 전용 if (!F.auto && e.button === 0) F.hold_fire = true; // 락 중 = 캔버스가 전 이벤트 수신(스펙) — 좌클릭 발사 }); canvas.addEventListener('pointerup', function (e) { if (e.pointerType === 'mouse') F.hold_fire = false; }); canvas.addEventListener('contextmenu', function (e) { if (document.pointerLockElement) e.preventDefault(); }); // 락 중 우클릭 = 메뉴 차단(오조작 소음 제거) document.addEventListener('pointerlockchange', function () { F.locked = document.pointerLockElement === canvas; F.hold_fire = false; // 락 경계에서 홀드 청산(경계 이벤트 유실 대비 — 연사 고착 구조 차단) if (F.locked) { F._had_lock = true; _resume_hide(); _aimhud_show(); } else { _aimhud_hide(); if (F._had_lock) _resume_show(); } // ESC 이탈(첫 락 이후에만) = 재개 배지 }); document.addEventListener('pointerlockerror', function () { F.locked = false; }); // 거부/쿨다운 — 드래그-룩 폴백 유지 document.addEventListener('mousemove', function (e) { if (F.locked) { F.yaw -= e.movementX * 0.002; // 0.002 = PointerLockControls 표준 감도 F.pitch = clamp(F.pitch - e.movementY * 0.002, -1.45, 1.45); } }); window.addEventListener('blur', function () { F.hold_fire = false; }); // 수동 발사(J/X 키 + auto:false 시 버튼) P.on_key(function (k) { if (k === 'j' || k === 'J' || k === 'x' || k === 'X') F.want_fire = true; }); if (!F.auto) { var fb = document.createElement('button'); fb.id = 'playable-fire'; fb.type = 'button'; fb.textContent = '◉'; // 주 동사 승계: world 부수기의 south 슬롯을 반납받아 수동 사격이 점유(좌표 하드코딩 금지) P.pad.release(document.getElementById('playable-smash')); P.pad.face(fb, 'primary', { label: ['발사', '射撃', 'Fire'] }); // 색은 슬롯 소유(캡 광택 규약) fb.addEventListener('pointerdown', function (e) { e.preventDefault(); e.stopPropagation(); F.hold_fire = true; }); fb.addEventListener('pointerup', function () { F.hold_fire = false; }); fb.addEventListener('pointercancel', function () { F.hold_fire = false; }); document.body.appendChild(fb); _hide_smash(); // 수동 ◉ 가 주 동사 슬롯(우하단 최대)을 승계 — 같은 좌표의 world 부수기(⚔)와 // 겹침 차단(world.attack 승계 규율과 대칭). 생성 순서 무관화: 설치 시 + 첫 틱 재확인(루프). } // auto 발사는 몹-결합(크로스헤어 온 타겟 = _ray_mob) — 몹이 영원히 0인 소비자는 모바일 발사 // 수단 자체가 부재한 잠복 클래스(2026-07-21 camo 실보고: 위장체는 몹이 아님 → 무발사). // 8s 후에도 몹 0·발사 0이면 LOUD(관측 전용 — 행동 불변). 비-몹 타겟은 auto:false 로 설치. if (F.auto) setTimeout(function () { try { if (F && F.auto && _mobs().length === 0 && !F._fired_once) { console.error('[fps] auto-fire has NO targets (mob registry empty) — mobile players cannot fire at all; install with auto:false (dedicated fire button) for non-mob targets'); if (window.__playable_beacon) window.__playable_beacon('fps_auto_no_targets', ''); } } catch (e) {} }, 8000); F.api = { trigger: function () { F.want_fire = true; }, get yaw() { return F.yaw; }, // 루팅→무기 스왑(BR-lite 계약) — 발사 파라미터 핫스왑 + 선택적 뷰모델 교체. // 유효 필드만 갱신(부분 지정 안전) — 레어도 티어 업이 이 verb 하나로 배관된다. set_weapon: function (w) { w = w || {}; if (w.fire_rate != null) F.rate = clamp(num(w.fire_rate, F.rate), 0.5, 12); if (w.damage != null) F.dmg = Math.max(0, num(w.damage, F.dmg)); if (w.range != null) F.range = clamp(num(w.range, F.range), 5, 120); if (w.spread != null) F.spread = clamp(num(w.spread, F.spread * 180 / Math.PI), 0, 8) * Math.PI / 180; if (typeof w.tint === 'string') { F.tint = w.tint; _tint_gun(); } // 티어 색 발광(레어도 체감 — 모델 스왑 후에도 유지) if (typeof w.weapon === 'string') { world._priv.load_model(w.weapon, 0.55, function (inst) { if (F.gun) F.gun_scene.remove(F.gun); var g = new THREE_.Group(); inst.rotation.x = -Math.PI / 2; // 팩 아이템 Y-축 상향 정규화 — 전방(−Z)으로 눕힘(설치 시와 동일 계약) g.add(inst); g.position.set(0.24, -0.22, -0.5); g.rotation.y = 0.06; F.gun_scene.add(g); F.gun = g; _tint_gun(); }); } }, }; return F.api; }; // 수동 발사 모드의 부수기(⚔) 버튼 숨김 — ◉ 와 좌표가 겹친다(우하단 주 동사 슬롯은 하나). function _hide_smash() { var s = document.getElementById('playable-smash'); if (s) s.style.display = 'none'; } // 뷰모델 틴트 — 티어 색 발광. 재질은 최초 1회 클론(팩 공유 재질 오염 방지) function _tint_gun() { if (!F.gun || !F.tint) return; F.gun.traverse(function (m) { if (!m.isMesh || !m.material) return; if (!m.material._fps_tinted) { m.material = m.material.clone(); m.material._fps_tinted = true; } if (m.material.emissive) { m.material.emissive.set(F.tint); m.material.emissiveIntensity = 0.35; } }); } function _fire(PL) { F._fired_once = true; // auto-무표적 LOUD 가드의 관측 신호 F.cool = 1 / F.rate; F.recoil = Math.min(0.05, F.recoil + 0.014); // pitch 킥(additive spring — 정본) // 조준 방향 + 각도 콘 스프레드(거리 무관 — 정본) cam.getWorldDirection(_dir); if (F.spread > 0) { var a = Math.random() * Math.PI * 2, r = Math.random() * F.spread; _right.crossVectors(_dir, _up).normalize(); var upv = new THREE_.Vector3().crossVectors(_right, _dir); _dir.addScaledVector(_right, Math.cos(a) * Math.sin(r)).addScaledVector(upv, Math.sin(a) * Math.sin(r)).normalize(); } var ox = cam.position.x, oy = cam.position.y, oz = cam.position.z; var hit = _ray_mob(ox, oy, oz, _dir.x, _dir.y, _dir.z, F.range); var tg = _terrain_blocks(ox, oy, oz, _dir.x, _dir.y, _dir.z, hit ? hit.t : F.range); if (hit && tg > 0 && tg < hit.t) hit = null; // 지형이 먼저 가림 var end_t = hit ? hit.t : (tg > 0 ? tg : F.range); // 총구(카메라 우하단 근사) → 착탄점 트레이서 _right.crossVectors(_dir, _up).normalize(); var mx = ox + _dir.x * 0.5 + _right.x * 0.14 - 0.1; var my = oy + _dir.y * 0.5 - 0.12; var mz = oz + _dir.z * 0.5 + _right.z * 0.14; if (F.tracer) _tracer({ x: mx, y: my, z: mz }, { x: ox + _dir.x * end_t, y: oy + _dir.y * end_t, z: oz + _dir.z * end_t }); // 사격 정보 스냅샷 — on_fire 소비자(커스텀 발사 VFX: camo 페인트 블라스트 등)에게 전달(additive: // 인자 무시하는 기존 소비자 전부 무영향). 총구/방향/착탄거리 = 이 함수의 실계산치 그대로. var _shot_si = { mx: mx, my: my, mz: mz, ox: ox, oy: oy, oz: oz, dx: _dir.x, dy: _dir.y, dz: _dir.z, end_t: end_t, hit: !!hit }; // o* = 카메라 원점(크로스헤어 정밀 정합용 — 총구 근사 m* 는 중심 레이에서 평행이동돼 있다) if (F.fire_sfx && P.fx) P.fx.sfx('shoot', { volume: 0.7 }); if (F.gun) F.gun.position.z = -0.44; // 뷰모델 킥백(틱에서 스프링 복귀) if (hit) { world._priv_combat.hurt(hit.mob, F.dmg, null, { intensity: 0.3 }); F._hitmark(); if (F.on_hit) { var api = null; try { api = hit.mob.api; F.on_hit(api, { damage: F.dmg }); } catch (e) { P._hook_err(e, 'on_hit'); } } } else if (tg > 0 && P.fx) { P.fx.burst({ x: ox + _dir.x * tg, y: oy + _dir.y * tg, z: oz + _dir.z * tg }, { count: 5, color: '#c8b89a', speed: 2.5, life: 0.25 }); } if (F.on_fire) { try { F.on_fire(_shot_si); } catch (e) { P._hook_err(e, 'on_fire'); } } } P.loop(function (dt, elapsed, raw) { // 트레이서 수명(연출 = 벽시계) for (var ti = _tracers.length - 1; ti >= 0; ti--) { _tracers[ti].life -= raw; if (_tracers[ti].life <= 0) { _tracers[ti].line.visible = false; _tracer_pool.push(_tracers.splice(ti, 1)[0]); } } if (!F) return; if (!F.auto && !F._smash_checked) { F._smash_checked = true; _hide_smash(); } // 생성 순서 무관화 재확인 var PL = _pl(); // 룩 입력 소비(우측 드래그 축적치) — world 카메라 리그가 꺼져 있으므로 여기가 유일 소비자. // controls() 는 멱등(설치 후 동일 CTRL 반환)이라 그 반환 객체를 공유 참조로 쓴다. if (F._ctrl == null && P.world && P.world.controls) F._ctrl = P.world.controls(); if (F._ctrl) { F.yaw -= F._ctrl.cam_dx; F.pitch = clamp(F.pitch - F._ctrl.cam_dy, -1.45, 1.45); F._ctrl.cam_dx = 0; F._ctrl.cam_dy = 0; } F.recoil *= Math.exp(-8 * raw); // 리코일 스프링 복귀 // 카메라 = 눈 위치 + YXZ euler (three 공식 정본. world 이동 yaw 규약과 정합: cam yaw = yaw+π) if (PL && !F._hid && F.view !== 'shoulder') { PL.handle.obj.visible = false; F._hid = true; F.yaw = PL.heading; } // fps 를 player 보다 먼저 호출해도 본체 숨김(순서 무관화·숄더 제외) if (PL) { var p = PL.handle.obj.position; F.bob_t += (PL.moving ? raw * (PL.running ? 11 : 7.5) : 0); if (F.view === 'shoulder') { // 숄더 붐 — 시선축 평행 배치(오버-숄더 정본) + **스프링암 충돌**(업계 정본: Unreal SpringArm // 의 probe 문법 — 머리→이상 붐 위치 세그먼트가 벽/천장/맵 메시에 막히면 붐 길이를 축소해 // 카메라를 차폐물 앞으로 당긴다. 실보고 '점프 시 천장 너머 노출'의 근본 봉합: 점프 제한이 // 아니라 카메라 충돌이 정답 — 3인칭 리그(facs 루프)와 동일 원리를 숄더 리그에 이식). var _fx = Math.sin(F.yaw), _fz = Math.cos(F.yaw); var _rx = Math.cos(F.yaw), _rz = -Math.sin(F.yaw); var _hx = p.x, _hy = p.y + 1.55, _hz = p.z; // 머리(붐 피벗 — 차폐 세그 원점은 실제 몸) // 수직 카메라 랙(업계 정본: 플랫포머/TPS 공통 — 점프 아크를 카메라가 추종하면 구도가 출렁인다. // 실보고 2026-07-25 "점프할 때 카메라 구도가 바뀜"): 붐 높이 기준을 접지 시에만 빠르게 추종하고 // 공중에서는 동결 — 캐릭터가 프레임 안에서 상승하고 구도는 안 뛸 때 그대로. 착지 전환은 지수 // 평활(~0.1s)이라 낙하 착지·계단도 스냅 없음. 1인칭(else 분기)은 눈 그 자체라 비적용. var _ong = PL.on_ground !== false; // 필드 미보유(undefined) = 접지 취급 → 항상 추종(캐리어 안전) if (F.cam_gy == null) F.cam_gy = p.y; F.cam_gy += ((_ong ? p.y : F.cam_gy) - F.cam_gy) * Math.min(1, raw * 10); var _bfac = [1, 0.72, 0.5, 0.32, 0.2]; var _bx, _by, _bz, _placed = false; for (var _bi = 0; _bi < _bfac.length; _bi++) { var _bd = 2.8 * _bfac[_bi]; _bx = p.x - _fx * _bd + _rx * 0.55 * _bfac[_bi]; _bz = p.z - _fz * _bd + _rz * 0.55 * _bfac[_bi]; _by = F.cam_gy + 1.55 + (0.35 - Math.sin(F.pitch) * 1.6) * _bfac[_bi]; if (!world._priv || !world._priv.cam_seg_blocked || !world._priv.cam_seg_blocked(_hx, _hy, _hz, _bx, _by, _bz)) { _placed = true; break; } } // 지면·천장 하한/상한 — 두 질의 모두 힌트를 *플레이어 층*(_gfloor+1.2)으로 고정한다. // 이전의 _by(클램프 전 이상 붐 높이) 힌트는 이상 붐이 천장을 넘는 순간 y-인지 지면 제공자가 // 지붕 *상면*을 바닥으로 채택하게 만들어(_tmin>_tmax) Math.max 가 모순을 위로 해소 — 카메라가 // 지붕 위에 올라섰다(그리드 로밍 프로브 실측: 위반 캠 y = 슬랩 상면+0.35 전 표본 일치). var _gfloor = world.ground_at ? world.ground_at(p.x, p.z, p.y + 0.2) : world.height_at(p.x, p.z); var _bg = world.ground_at ? world.ground_at(_bx, _bz, _gfloor + 1.2) : world.height_at(_bx, _bz); var _tmin = (_bg == null ? _gfloor : _bg) + 0.35; var _tmax = (world._priv && world._priv.ceil_ext_probe) ? world._priv.ceil_ext_probe(_bx, _bz, _gfloor + 1.2) : null; var _byc = Math.max(_by, _tmin); if (_tmax != null && _byc > _tmax - 0.3) _byc = _tmax - 0.3; // 모순(초저 소핏)은 천장 우선 — 시각 누출 > 바닥 스침 cam.position.set(_bx, _byc, _bz); } else { cam.position.set(p.x, p.y + F.eye + Math.sin(F.bob_t) * 0.035, p.z); } if (world.cam_clamp) world.cam_clamp(cam.position); // 밀폐 세트 볼륨 클램프(3인칭 리그와 동일 계약) PL.heading = F.yaw; // 본체 heading 동기(풀 bend·잔존 시스템 정합) } cam.rotation.set(F.pitch + F.recoil, F.yaw + Math.PI, 0, 'YXZ'); // 뷰모델 애니(킥백 복귀 + 미세 bob) if (F.gun) { F.gun.position.z += (-0.5 - F.gun.position.z) * Math.min(1, raw * 10); F.gun.position.y = -0.22 + Math.sin(F.bob_t) * 0.008; } if (dt <= 0) return; // 히트스톱 동결 F.cool -= dt; // 발사 판정: auto = 크로스헤어 온 타겟(스프레드 무시 중심 레이) / 수동 = 버튼·키·trigger var want = F.want_fire || F.hold_fire; if (F.auto && !want) { cam.getWorldDirection(_dir); var t0 = _ray_mob(cam.position.x, cam.position.y, cam.position.z, _dir.x, _dir.y, _dir.z, F.range); if (t0) { var tb = _terrain_blocks(cam.position.x, cam.position.y, cam.position.z, _dir.x, _dir.y, _dir.z, t0.t); want = tb < 0; // 가림 없을 때만 auto-fire } } F.want_fire = false; if (want && F.cool <= 0 && (!PL || PL.hp > 0)) _fire(PL); // 무플레이어(터렛/사격장)도 발사 가능 — 침묵 불발 함정 봉합 }); })(); 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; } }; })();