// ==UserScript== // @name Visual Novel Engine V4 // @namespace http://tampermonkey.net/ // @version 4.0 // @description Crack용 비주얼 노벨 UI의 로컬 확장 기반 버전입니다. 보호 카트리지(VNC2)와 압축 카트리지를 읽습니다. 원래 엔진과 같이 켜지 마세요. // @author agetion(c3ak), local modifications // @match *://crack.wrtn.ai/* // @connect crack-api.wrtn.ai // @connect raw.githubusercontent.com // @grant GM_addStyle // @grant GM_xmlhttpRequest // @run-at document-idle // @updateURL https://raw.githubusercontent.com/c3ak/Visual_Novel_Engine_for_Crack/main/VN_Engine.user.js // @downloadURL https://raw.githubusercontent.com/c3ak/Visual_Novel_Engine_for_Crack/main/VN_Engine.user.js // ==/UserScript== (function() { 'use strict'; // --- 상수 정의 --- const DOM_IDS = { CONTAINER: 'vn-engine-container', BACKGROUND: 'vn-background-overlay', EVENT_CG: 'vn-event-cg-overlay', HOTSPOT_LAYER: 'vn-hotspot-layer', IMAGE_VIEWER: 'vn-image-viewer', IMAGE_VIEWER_IMG: 'vn-image-viewer-img', IMAGE_VIEWER_CAPTION: 'vn-image-viewer-caption', IMAGE_VIEWER_MISSING: 'vn-image-viewer-missing', IMAGE_VIEWER_CLOSE: 'vn-image-viewer-close', CHAR_CONTAINER: 'vn-character-container', STATUS_WINDOW: 'vn-status-window', DIALOGUE_BOX: 'vn-dialogue-box', CHAR_NAME: 'vn-character-name', DIALOGUE_TEXT: 'vn-dialogue-text', BACK_BUTTON: 'vn-back-button', SETTINGS_MODAL: 'vn-settings-modal', START_BUTTON: 'vn-start-button', DEMO_BUTTON: 'vn-demo-button', SETTINGS_BUTTON: 'vn-settings-button', INPUT_BUTTON: 'vn-input-button', INPUT_MODAL: 'vn-input-modal', LOG_BUTTON: 'vn-log-button', LOG_MODAL: 'vn-log-modal', LOADING_INDICATOR: 'vn-loading-indicator', MENU_BUTTON: 'vn-menu-button', MENU_MODAL: 'vn-menu-modal', MENU_TABS: 'vn-menu-tabs', MENU_BODY: 'vn-menu-body', MENU_CLOSE: 'vn-menu-close', MENU_SETTINGS_BUTTON: 'vn-menu-settings-button', MENU_EXIT_BUTTON: 'vn-menu-exit-button', MENU_PAUSE_BUTTON: 'vn-menu-pause-button', RESUME_BUTTON: 'vn-resume-button', PAUSE_EXIT_BUTTON: 'vn-pause-exit-button', AI_LOG_MODAL: 'vn-ai-log-modal', AI_LOG_BODY: 'vn-ai-log-body', AI_LOG_CLOSE: 'vn-ai-log-close', AI_LOG_CLEAR: 'vn-ai-log-clear', STAGE_OVERLAY: 'vn-stage-overlay', MODULE_OVERLAY: 'vn-module-overlay', SERVICE_HOST: 'vn-service-host', HOME_LAYER: 'vn-home-layer', HOME_FRAME_HOST: 'vn-home-frame-host', END_TALK_BUTTON: 'vn-end-talk-button', SCREEN_VEIL: 'vn-screen-veil', SCRIPT_CHOICES: 'vn-script-choices' }; // 가로로 든 폰처럼 높이가 낮은 화면. 모바일 배치는 이 조건 하나로 켜진다. const COMPACT_SCREEN_QUERY = '(max-height: 500px)'; const ANIMATION_TYPES = { 'shake-vertical': '세로 흔들기', 'shake-horizontal': '가로 흔들기', 'flash': '반짝이기', 'bounce': '통통 튀기', 'vibrate': '진동하기', 'fall-left': '왼쪽으로 털썩(넘어짐)' }; // --- 오디오 관리자 --- const AudioManager = { currentAudio: null, currentUrl: null, rules: [], loadRules(rules) { this.rules = rules || []; }, // [신규] 볼륨 즉시 업데이트 함수 updateVolume() { if (this.currentAudio && !this.currentAudio.paused) { this.currentAudio.volume = SettingsManager.settings.globalVolume; } }, checkAndPlay(imageUrl) { if (!imageUrl || imageUrl === 'off') return; const filename = imageUrl.substring(imageUrl.lastIndexOf('/') + 1); const match = this.rules.find(rule => filename.includes(rule.trigger)); if (match) { // 규칙에 "loop": false 를 적으면 한 번만 튼다 (기본은 반복) this.play(match.audioUrl, { loop: match.loop !== false }); } }, // 주소, 또는 카트리지 안의 소리 파일 경로("assets/audio/x.mp3")를 재생할 수 있는 주소로 바꾼다. resolveSource(source) { const value = String(source === undefined || source === null ? '' : source).trim(); if (!value) return null; if (/^(?:https?:\/\/|\/\/|blob:|data:audio\/)/i.test(value)) return value; if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return null; // javascript: 같은 주소는 막는다 const url = typeof CartridgeRuntime !== 'undefined' ? CartridgeRuntime.getAssetUrl(value.replace(/^\.?\//, '')) : null; if (!url) console.warn(`VN Engine: 카트리지에서 소리 파일을 찾을 수 없습니다 — ${value}`); return url; }, // options.loop 이 false 면 한 번만 튼다. 같은 곡이 울리는 중이면 처음부터 다시 틀지 않는다. play(source, options = {}) { const url = this.resolveSource(source); if (!url) return false; if (this.currentUrl === url && this.currentAudio && !this.currentAudio.paused) return true; if (this.currentAudio) { this.fadeOutAndStop(this.currentAudio); } this.currentUrl = url; const newAudio = new Audio(url); newAudio.loop = options.loop !== false; newAudio.volume = 0; // 재생이 시작되기 전에도 '지금 트는 곡' 으로 본다 (대본의 ) this.startingAudio = newAudio; const playPromise = newAudio.play(); if (playPromise !== undefined) { playPromise.then(() => { if (this.startingAudio === newAudio) this.startingAudio = null; this.fadeIn(newAudio, SettingsManager.settings.globalVolume); this.currentAudio = newAudio; }).catch(error => { if (this.startingAudio === newAudio) this.startingAudio = null; console.warn("VN Engine: BGM 재생 실패", error); }); } else if (this.startingAudio === newAudio) { this.startingAudio = null; this.currentAudio = newAudio; } return true; }, // 대본의 : 지금 울리는 곡이 끝날 때까지 기다린다. // 반복하는 곡이거나 아무것도 울리지 않으면 바로 넘어간다. 다른 곡으로 바뀌거나 꺼져도 더 기다리지 않는다. // VN 일시정지로 멈춘 동안은 끝나지 않았으므로 계속 기다린다. whenSilent() { const audio = this.startingAudio || this.currentAudio; if (!audio || audio.loop || audio.ended) return Promise.resolve(); return new Promise(resolve => { let timer = null; const done = () => { clearInterval(timer); audio.removeEventListener('ended', done); resolve(); }; audio.addEventListener('ended', done); timer = setInterval(() => { if (audio.ended || (audio !== this.currentAudio && audio !== this.startingAudio)) done(); }, 250); }); }, fadeIn(audio, targetVol) { let vol = 0; const timer = setInterval(() => { const maxVol = SettingsManager.settings.globalVolume; if (!audio || audio.paused) { clearInterval(timer); return; } vol += 0.05; if (vol >= maxVol) { vol = maxVol; audio.volume = vol; clearInterval(timer); } else { audio.volume = vol; } }, 100); }, fadeOutAndStop(audio) { if (!audio) return; let vol = audio.volume; const timer = setInterval(() => { vol -= 0.05; if (vol <= 0) { vol = 0; audio.volume = 0; audio.pause(); audio.currentTime = 0; clearInterval(timer); } else { audio.volume = vol; } }, 100); }, // VN 일시정지: 곡을 끄지 않고 멈췄다가 돌아오면 그 자리에서 잇는다 pause() { if (this.currentAudio && !this.currentAudio.paused) { this.currentAudio.pause(); this.pausedByEngine = true; } }, resume() { if (this.pausedByEngine && this.currentAudio) { this.currentAudio.play().catch(error => console.warn('VN Engine: BGM 재개 실패', error)); } this.pausedByEngine = false; }, // ★ [신규 추가] 모든 오디오 정지 함수 stopAll() { this.pausedByEngine = false; if (this.currentAudio) { this.fadeOutAndStop(this.currentAudio); // 서서히 꺼짐 this.currentAudio = null; this.currentUrl = null; } } }; // [신규] URL에서 식별 ID 추출 (작품 ID 우선) function getCurrentTargetId() { const path = window.location.pathname; // 1순위: 작품(Story) ID (예: /stories/xxxx/episodes/...) const storyMatch = path.match(/\/stories\/([a-f0-9]{24})/); if (storyMatch) return { id: storyMatch[1], type: 'story', name: '작품' }; // 2순위: 일반 채팅(Chat) ID const chatMatch = path.match(/\/chats\/([a-f0-9]{24})/); if (chatMatch) return { id: chatMatch[1], type: 'chat', name: '채팅방' }; const cMatch = path.match(/\/c\/([a-f0-9]{24})/); if (cMatch) return { id: cMatch[1], type: 'chat', name: '채팅방' }; return null; } // URL에서 Story ID를 추출하는 헬퍼 함수 const getStoryId = () => { // /stories/ 와 /episodes/ 사이의 ID 추출 const match = window.location.pathname.match(/\/stories\/([a-f0-9]+)/); // 만약 없으면 채팅방 ID라도 가져오도록 시도 (범용성) if (!match) { const chatMatch = window.location.pathname.match(/\/chats\/([a-f0-9]+)/); return chatMatch ? chatMatch[1] : 'unknown_id'; } return match[1]; }; // --- 카트리지 파일 형식 --- const CARTRIDGE_FORMAT = 'vn-engine-cartridge'; const CARTRIDGE_FORMAT_VERSION = 1; const CARTRIDGE_EXTENSION = '.vncartridge'; // 카트리지 화면 안에 함께 넣어주는 도우미. 제작자가 postMessage 배관을 직접 짜지 않아도 된다. const CARTRIDGE_HELPER_SOURCE = ` window.VNCartridge = (function () { 'use strict'; var context = window.VNCartridgeContext || {}; var runtime = context.runtime || {}; var shared = runtime.shared || {}; var status = runtime.status || {}; var ai = runtime.ai || { generating: false, reason: 'idle', lastArrivedAt: 0 }; var images = context.images || {}; var home = runtime.home || { active: false }; // 지금 채팅방. 주소 끝의 ID 이고(작품 채팅이면 에피소드 ID), 채팅방을 옮기면 엔진이 새 값을 알린다 (3.35.0) var session = runtime.session || { id: null }; // VN 이 켜져 있는지. 켜고 끌 때마다 엔진이 알린다 (3.37.0) var vn = runtime.vn || { active: false, opening: false }; var listeners = { shared: [], event: [], status: [], ai: [], home: [], hotspot: [], script: [], session: [], vn: [] }; var pending = {}; var scripts = {}; var sequence = 0; function send(type, body) { sequence += 1; var message = { source: 'vn-cartridge', type: type, requestId: type + '-' + Date.now() + '-' + sequence }; for (var key in body) if (Object.prototype.hasOwnProperty.call(body, key)) message[key] = body[key]; window.parent.postMessage(message, '*'); return message.requestId; } function fire(channel, a, b) { listeners[channel].forEach(function (fn) { try { fn(a, b); } catch (error) { console.error('VNCartridge: ' + channel + ' 처리 중 오류', error); } }); } window.addEventListener('message', function (event) { var message = event.data; if (!message || message.source !== 'vn-engine') return; if (message.type === 'shared-state:changed' || message.type === 'shared-state:saved' || message.type === 'shared-state:value') { shared = message.state || {}; fire('shared', shared, message.from || null); } else if (message.type === 'module-event') { fire('event', message.event || {}, message.from || null); } else if (message.type === 'status:update') { status = message.status || {}; fire('status', status); } else if (message.type === 'ai-activity') { ai = message.activity || ai; fire('ai', ai); } else if (message.type === 'home:state') { home = message.home || home; fire('home', home); } else if (message.type === 'hotspot:press') { // 배경 위 자리를 눌렀다. 무엇을 할지는 카트리지가 정한다. { id, background, label } fire('hotspot', message.hotspot || {}); } else if (message.type === 'home:talk-result') { var settle = pending[message.requestId]; if (settle) { delete pending[message.requestId]; settle({ ok: message.ok === true, reason: message.reason || '' }); } } else if (message.type === 'vn:state') { vn = message.vn || { active: false, opening: false }; fire('vn', vn); } else if (message.type === 'chat:sent') { var settleChat = pending[message.requestId]; if (settleChat) { delete pending[message.requestId]; settleChat({ ok: message.ok === true, reason: message.reason || '' }); } } else if (message.type === 'session:changed') { // 채팅방을 옮겼다. 공유 서랍도 그 범위의 것으로 바뀐다. session = message.session || { id: null }; if (message.state) { shared = message.state; fire('shared', shared, null); } fire('session', session); } else if (message.type === 'script:ended') { // 부탁한 대본이 끝났다. finished = 마지막 줄을 넘겼다 / interrupted = 다른 응답이 끼어들었다 // stopped = VN 을 껐다 / refused = 틀 수 없었다 (VN 이 꺼져 있음 · 빈 대본) var ended = { id: String(message.scriptId || ''), state: String(message.state || 'finished'), reason: message.reason || '' }; var resolveScript = scripts[ended.id]; if (resolveScript) { delete scripts[ended.id]; resolveScript({ ok: ended.state === 'finished', state: ended.state, reason: ended.reason }); } fire('script', ended); } else if (message.type === 'choice:chosen') { var settleChoice = pending[message.requestId]; if (settleChoice) { delete pending[message.requestId]; settleChoice({ ok: message.ok === true, index: typeof message.index === 'number' ? message.index : -1, text: typeof message.text === 'string' ? message.text : '', reason: message.reason || '' }); } } }); return { context: context, get status() { return status; }, get ai() { return ai; }, // 방 화면 모듈일 때만 쓴다. active 가 false 면 대화 화면 뒤에 배경으로 깔려 있는 중이다. get home() { return home; }, // { id }: 지금 채팅방 ID. 주소에서 못 읽으면 null. 채팅방마다 따로 기억할 것은 이 ID 를 열쇠로 쓴다 (3.35.0) get session() { return session; }, // { active, opening }: VN 이 켜져 있는지, 켤 때 오프닝을 틀었는지 (3.37.0) get vn() { return vn; }, // 크랙에 대화를 보낸다. 플레이어가 대화 입력으로 보낸 것과 같고, tellAi 로 넣어 둔 알림도 함께 붙는다 (3.37.0). // { ok, reason }. reason: inactive(VN 꺼짐) · empty · too-long · busy(다른 글을 보내는 중) · failed(입력창·전송 버튼을 못 찾음) sendChat: function (message) { return new Promise(function (resolve) { var id = send('chat:send', { text: String(message === undefined || message === null ? '' : message) }); pending[id] = resolve; }); }, // manifest 모듈의 images 에 적은 그림. 이름 → data: 주소 images: images, loadImage: function (name) { return new Promise(function (resolve, reject) { var source = images[name]; if (!source) { reject(new Error('VNCartridge: 그림이 없습니다 — ' + name)); return; } var image = new Image(); image.onload = function () { resolve(image); }; image.onerror = function () { reject(new Error('VNCartridge: 그림을 읽지 못했습니다 — ' + name)); }; image.src = source; }); }, // 방 화면 모듈에서 manifest home.characters 의 인물과 대화를 시작한다. choiceId 를 빼면 첫 talk 선택지. talkTo: function (characterId, choiceId) { return new Promise(function (resolve) { var id = send('home:talk', { characterId: String(characterId || ''), choiceId: choiceId ? String(choiceId) : '' }); pending[id] = resolve; setTimeout(function () { if (!pending[id]) return; delete pending[id]; resolve({ ok: false, reason: 'timeout' }); }, 3000); }); }, shared: { get all() { return shared; }, get: function (key) { return shared[key]; }, patch: function (patch) { send('shared-state:patch', { patch: patch }); }, refresh: function () { send('shared-state:read', {}); } }, state: { get all() { return runtime.state || {}; }, replace: function (state) { send('module-state:replace', { state: state }); } }, emit: function (type, payload) { send('module-event:emit', { event: { type: type, payload: payload } }); }, // options.id 를 주면 그 이름으로 넣고, options.replace 가 true 면 같은 이름을 덮어쓴다. tellAi: function (title, text, options) { var opts = options || {}; send('ai-context:enqueue', { event: { id: opts.id ? String(opts.id) : String(Date.now()) + '-' + Math.random().toString(36).slice(2), title: title, text: text, createdAt: Date.now(), replace: opts.replace === true } }); }, // 넣어 둔 알림을 이름으로 걷어낸다. 같은 카트리지 안이면 다른 모듈이 넣은 것도 걷는다. withdrawAi: function (id) { send('ai-context:withdraw', { id: String(id) }); }, close: function () { send('view:close', {}); }, // 엔진만 할 수 있는 일을 부탁한다. 모두 이 카트리지의 모듈과 그림에만 작동한다. // 그 화면 모듈을 연다. 메뉴에서 숨겨 둔 화면도 열린다. // options.fullscreen: 창 머리줄 없이 화면 전체에 띄운다 (3.39.0). 닫기는 모듈의 close() 나 Esc openView: function (moduleId, options) { send('view:open', { moduleId: String(moduleId || ''), fullscreen: Boolean(options && options.fullscreen === true) }); }, // 그림 창을 띄운다. 핫스팟 image 와 같은 창이고, 이름은 ![이름] 으로 부르는 그림 이름이다. showImage: function (name, label) { send('image:show', { name: String(name || ''), label: label === undefined || label === null ? '' : String(label) }); }, // 음악을 튼다. 카트리지 안의 소리 파일 경로(assets/audio/…)나 주소. { loop: false } 면 한 번만. playMusic: function (source, options) { var opts = options || {}; send('music:play', { src: String(source || ''), loop: opts.loop !== false }); }, stopMusic: function () { send('music:stop', {}); }, // VN 화면에서 대본을 튼다. 오프닝과 같은 문법이고, 대화 기록에도 AI 에게도 가지 않는다. // 줄 배열이나 글 하나. 끝나면 { ok: true, state: 'finished' } (마지막 줄을 넘겼을 때). // 다른 응답이 끼어들거나 VN 을 끄면 ok: false 와 그 까닭(state). 열려 있던 모듈 창은 엔진이 닫는다. playScript: function (content, options) { var opts = options || {}; var body = Array.isArray(content) ? content.map(function (line) { return String(line === undefined || line === null ? '' : line); }).join(String.fromCharCode(10)) : String(content || ''); sequence += 1; var scriptId = opts.id ? String(opts.id) : 'script-' + Date.now() + '-' + sequence; return new Promise(function (resolve) { scripts[scriptId] = resolve; send('script:play', { content: body, scriptId: scriptId }); }); }, // VN 화면에 선택지를 띄운다. [{ text, input }] 또는 글 배열 → { ok, index, text }. // input: true 인 보기를 누르면 글을 적는 칸이 열리고, 적은 글이 text 로 온다. choose: function (options, settings) { var opts = settings || {}; var list = (Array.isArray(options) ? options : []).map(function (option) { if (typeof option === 'string') return { text: option }; var item = option || {}; return { text: String(item.text || ''), input: item.input === true, placeholder: item.placeholder ? String(item.placeholder) : '' }; }); return new Promise(function (resolve) { var id = send('choice:show', { options: list, placeholder: opts.placeholder ? String(opts.placeholder) : '' }); pending[id] = resolve; }); }, // 메뉴에 그 화면을 보이거나 숨긴다. 엔진이 이야기(채팅)마다 기억한다. menu: { show: function (moduleId) { send('menu:set', { moduleId: String(moduleId || ''), visible: true }); }, hide: function (moduleId) { send('menu:set', { moduleId: String(moduleId || ''), visible: false }); } }, // keepOpen: true 를 주면 창을 닫지 않고 보낸다. 이동 연출처럼 보낸 뒤에도 // 화면이 계속 돌아야 할 때 쓴다. 다 끝나면 모듈이 close() 로 직접 닫는다. commitChat: function (options) { send('chat:commit', { options: (options && typeof options === 'object') ? options : {} }); }, on: function (channel, handler) { if (!listeners[channel] || typeof handler !== 'function') return function () {}; listeners[channel].push(handler); return function () { var index = listeners[channel].indexOf(handler); if (index > -1) listeners[channel].splice(index, 1); }; } }; })(); `; // assets.images 에 적은 값이 외부 주소인지 패키지 안 경로인지 가린다. const EXTERNAL_IMAGE_SOURCE = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i; // 이미지로 쓸 수 있는 주소만 허용한다. javascript: 같은 것은 막는다. const ALLOWED_IMAGE_SOURCE = /^(?:https?:\/\/|\/\/|data:image\/)/i; // 모듈의 images 에 넣을 수 있는 그림 파일 const MODULE_IMAGE_PATTERN = /\.(png|jpe?g|gif|webp|avif|bmp|svg)$/i; const ENGINE_VERSION = '4.0.0'; // 보이는 모듈 화면(무대·메뉴 창·방 화면)의 격리 조건. 출처는 계속 없고(allow-same-origin 없음), // 1인칭 조작을 위해 마우스 고정(requestPointerLock)만 더 허락한다 — 누른 뒤에만 걸리고 Esc 로 풀린다 (3.36.0) const MODULE_FRAME_SANDBOX = 'allow-scripts allow-pointer-lock'; // 파일 이름이 이 접두사로 시작하면 폴더와 무관하게 배경으로 봅니다. (bg_격납고.png) const BACKGROUND_NAME_PREFIX = 'bg_'; // 이름 뒤쪽의 번호를 떼어 같은 인물을 하나로 묶습니다. (mira01 → mira) // 외부 URL 방식과 카트리지 내장 이미지가 같은 규칙을 쓰도록 여기 한 곳에만 둡니다. // 경로나 주소에서 카트리지 호출 이름을 뽑는다. // /c/x1 -> x1 // assets/c/x1.webp -> x1 // https://cdn/g/35.png?v=2 -> 35 function assetNameFromReference(value) { const text = String(value === undefined || value === null ? '' : value).split(/[?#]/)[0].trim(); if (!text) return ''; const lastSegment = text.slice(text.lastIndexOf('/') + 1); return lastSegment.replace(/\.[^.]+$/, '').trim(); } // 핫스팟을 얼마나 드러낼지. 숨김은 테두리도 손 모양 커서도 없다. const HOTSPOT_REVEALS = ['always', 'hover', 'hidden']; // 방 화면 선택지가 할 수 있는 일. talk = 대화 화면으로 넘어가기, message = 짧은 알림 const HOME_ACTIONS = ['talk', 'message']; // '대화하기' 가 AI 문맥 대기열에 넣는 알림의 이름. 보내지 않고 방으로 돌아오면 이 이름으로 걷는다 const HOME_TALK_NOTE_ID = 'home-talk'; // 오류 문구를 액자 문서(srcdoc)에 넣을 때 쓴다 function escapeHtml(value) { return String(value === undefined || value === null ? '' : value) .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } function characterIdFromBaseName(baseName) { const value = String(baseName || ''); const match = value.match(/^([a-zA-Z_]+[a-zA-Z])([0-9_].*)?$/) || value.match(/^([a-zA-Z]+)([0-9_].*)?$/); return (match && match[1]) ? match[1] : value; } // 의존성 없이 읽을 수 있도록 v1 패키지는 ZIP의 STORE 방식(무압축)을 사용합니다. const ZipStore = { crcTable: null, getCrcTable() { if (this.crcTable) return this.crcTable; this.crcTable = Array.from({ length: 256 }, (_, index) => { let value = index; for (let bit = 0; bit < 8; bit++) value = (value & 1) ? (0xedb88320 ^ (value >>> 1)) : (value >>> 1); return value >>> 0; }); return this.crcTable; }, crc32(bytes) { const table = this.getCrcTable(); let crc = 0xffffffff; for (const byte of bytes) crc = table[(crc ^ byte) & 0xff] ^ (crc >>> 8); return (crc ^ 0xffffffff) >>> 0; }, normalizePath(rawPath) { const normalized = String(rawPath || '').replace(/\\/g, '/'); const segments = normalized.split('/'); if (!normalized || normalized.startsWith('/') || /^[a-zA-Z]:/.test(normalized) || segments.includes('..')) { throw new Error(`안전하지 않은 카트리지 경로입니다: ${rawPath}`); } return normalized; }, // 압축(deflate) 항목을 풀어야 해서 기다리는 함수다 (보호판 엔진). async read(input) { const bytes = input instanceof Uint8Array ? input : new Uint8Array(input); const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); if (bytes.byteLength < 22) throw new Error('ZIP 카트리지의 크기가 너무 작습니다.'); let eocdOffset = -1; const searchStart = Math.max(0, bytes.byteLength - 65557); for (let offset = bytes.byteLength - 22; offset >= searchStart; offset--) { if (view.getUint32(offset, true) === 0x06054b50) { eocdOffset = offset; break; } } if (eocdOffset < 0) throw new Error('ZIP 중앙 디렉터리를 찾을 수 없습니다.'); const entryCount = view.getUint16(eocdOffset + 10, true); const centralSize = view.getUint32(eocdOffset + 12, true); const centralOffset = view.getUint32(eocdOffset + 16, true); if (centralOffset + centralSize > bytes.byteLength) throw new Error('ZIP 중앙 디렉터리가 손상되었습니다.'); const decoder = new TextDecoder('utf-8'); const files = new Map(); let offset = centralOffset; for (let index = 0; index < entryCount; index++) { if (offset + 46 > bytes.byteLength || view.getUint32(offset, true) !== 0x02014b50) { throw new Error('ZIP 파일 항목이 손상되었습니다.'); } const method = view.getUint16(offset + 10, true); const expectedCrc = view.getUint32(offset + 16, true); const compressedSize = view.getUint32(offset + 20, true); const uncompressedSize = view.getUint32(offset + 24, true); const nameLength = view.getUint16(offset + 28, true); const extraLength = view.getUint16(offset + 30, true); const commentLength = view.getUint16(offset + 32, true); const localOffset = view.getUint32(offset + 42, true); const nameEnd = offset + 46 + nameLength; if (nameEnd > bytes.byteLength) throw new Error('ZIP 파일명이 손상되었습니다.'); const path = this.normalizePath(decoder.decode(bytes.subarray(offset + 46, nameEnd))); offset = nameEnd + extraLength + commentLength; if (path.endsWith('/')) continue; if (method !== 0 && method !== 8) throw new Error(`지원하지 않는 ZIP 압축 방식입니다: ${path}`); if (method === 0 && compressedSize !== uncompressedSize) throw new Error(`ZIP 항목 크기가 일치하지 않습니다: ${path}`); if (localOffset + 30 > bytes.byteLength || view.getUint32(localOffset, true) !== 0x04034b50) { throw new Error(`ZIP 로컬 항목이 손상되었습니다: ${path}`); } const localNameLength = view.getUint16(localOffset + 26, true); const localExtraLength = view.getUint16(localOffset + 28, true); const dataStart = localOffset + 30 + localNameLength + localExtraLength; const dataEnd = dataStart + compressedSize; if (dataEnd > bytes.byteLength) throw new Error(`ZIP 항목 데이터가 손상되었습니다: ${path}`); let data = bytes.slice(dataStart, dataEnd); if (method === 8) { try { data = await CartridgeProtect.inflateRaw(data); } catch (error) { throw new Error(`ZIP 항목 압축을 풀 수 없습니다: ${path}`); } if (data.length !== uncompressedSize) throw new Error(`ZIP 항목 크기가 일치하지 않습니다: ${path}`); } if (this.crc32(data) !== expectedCrc) throw new Error(`ZIP 항목 검증에 실패했습니다: ${path}`); if (files.has(path)) throw new Error(`중복된 카트리지 파일입니다: ${path}`); files.set(path, data); } return files; }, create(inputEntries) { const encoder = new TextEncoder(); const entries = Array.from(inputEntries instanceof Map ? inputEntries.entries() : Object.entries(inputEntries)); return this.writeEntries(entries.map(([rawPath, rawData]) => { const data = rawData instanceof Uint8Array ? rawData : encoder.encode(String(rawData)); return { path: rawPath, data, stored: data, method: 0 }; })); }, // 그림·소리처럼 이미 압축된 형식은 그대로 두고, 나머지는 줄어들 때만 deflate 로 넣는다. async createCompressed(inputEntries) { const encoder = new TextEncoder(); const entries = Array.from(inputEntries instanceof Map ? inputEntries.entries() : Object.entries(inputEntries)); const prepared = []; for (const [rawPath, rawData] of entries) { const data = rawData instanceof Uint8Array ? rawData : encoder.encode(String(rawData)); let stored = data; let method = 0; if (CartridgeProtect.shouldCompress(rawPath)) { const packed = await CartridgeProtect.deflateRaw(data); if (packed.length < data.length) { stored = packed; method = 8; } } prepared.push({ path: rawPath, data, stored, method }); } return this.writeEntries(prepared); }, writeEntries(prepared) { const encoder = new TextEncoder(); const localChunks = []; const centralChunks = []; let localOffset = 0; prepared.forEach(({ path: rawPath, data, stored, method }) => { const path = this.normalizePath(rawPath); const nameBytes = encoder.encode(path); const crc = this.crc32(data); const local = new Uint8Array(30 + nameBytes.length + stored.length); const localView = new DataView(local.buffer); localView.setUint32(0, 0x04034b50, true); localView.setUint16(4, 20, true); localView.setUint16(6, 0x0800, true); localView.setUint16(8, method, true); localView.setUint32(14, crc, true); localView.setUint32(18, stored.length, true); localView.setUint32(22, data.length, true); localView.setUint16(26, nameBytes.length, true); local.set(nameBytes, 30); local.set(stored, 30 + nameBytes.length); const central = new Uint8Array(46 + nameBytes.length); const centralView = new DataView(central.buffer); centralView.setUint32(0, 0x02014b50, true); centralView.setUint16(4, 20, true); centralView.setUint16(6, 20, true); centralView.setUint16(8, 0x0800, true); centralView.setUint16(10, method, true); centralView.setUint32(16, crc, true); centralView.setUint32(20, stored.length, true); centralView.setUint32(24, data.length, true); centralView.setUint16(28, nameBytes.length, true); centralView.setUint32(42, localOffset, true); central.set(nameBytes, 46); localChunks.push(local); centralChunks.push(central); localOffset += local.length; }); const centralSize = centralChunks.reduce((sum, chunk) => sum + chunk.length, 0); const eocd = new Uint8Array(22); const eocdView = new DataView(eocd.buffer); eocdView.setUint32(0, 0x06054b50, true); eocdView.setUint16(8, prepared.length, true); eocdView.setUint16(10, prepared.length, true); eocdView.setUint32(12, centralSize, true); eocdView.setUint32(16, localOffset, true); const allChunks = [...localChunks, ...centralChunks, eocd]; const totalSize = allChunks.reduce((sum, chunk) => sum + chunk.length, 0); const output = new Uint8Array(totalSize); let writeOffset = 0; allChunks.forEach(chunk => { output.set(chunk, writeOffset); writeOffset += chunk.length; }); return output; } }; // --- 보호 카트리지 (VNC2) --- // 남이 압축 프로그램이나 메모장으로 카트리지 내용을 뜯어보지 못하게 항목마다 따로 암호화한 형식이다. // tools/cartridge_protect.py 와 열쇠 만드는 법·형식이 같아야 한다. 형식 설명은 그 파일 머리에 있다. // 열쇠 재료가 이 엔진 안에 있으니 작정한 사람까지 막지는 못한다 — 쉽게 열어 보는 것만 막는다. const CartridgeProtect = { MAGIC: [0x56, 0x4e, 0x43, 0x32], CONTAINER_VERSION: 1, HEADER_SIZE: 40, STORED_PATTERN: /\.(png|jpe?g|webp|gif|avif|mp3|ogg|oga|opus|m4a|aac|mp4|webm|woff2?|zip|vncartridge)$/i, SEED_MASK: 0xA7, SEED_PARTS: [ [134, 211, 59, 84, 152, 114, 106, 254, 140, 207, 249, 198, 228, 75, 80, 169], [94, 232, 50, 107, 23, 69, 44, 57, 163, 235, 40, 76, 249, 254, 177, 135] ], masterSecrets: new Map(), isProtected(bytes) { return bytes.length >= this.HEADER_SIZE && this.MAGIC.every((value, index) => bytes[index] === value); }, shouldCompress(path) { return !this.STORED_PATTERN.test(String(path)); }, async pipeBytes(bytes, transform) { const stream = new Blob([bytes]).stream().pipeThrough(transform); return new Uint8Array(await new Response(stream).arrayBuffer()); }, inflateRaw(bytes) { return this.pipeBytes(bytes, new DecompressionStream('deflate-raw')); }, deflateRaw(bytes) { return this.pipeBytes(bytes, new CompressionStream('deflate-raw')); }, async getMasterSecret(generation) { if (this.masterSecrets.has(generation)) return this.masterSecrets.get(generation); if (generation !== 1) throw new Error(`이 카트리지는 더 새 엔진이 필요합니다 (보호 열쇠 ${generation}세대).`); const encoder = new TextEncoder(); const label = encoder.encode('vn-engine/cartridge-protect/g1'); const first = this.SEED_PARTS[0].map(value => value ^ this.SEED_MASK); const second = this.SEED_PARTS[1].slice().reverse().map(value => value ^ this.SEED_MASK); const material = new Uint8Array(label.length + 32); material.set(label, 0); material.set(first, label.length); material.set(second, label.length + 16); const secret = new Uint8Array(await crypto.subtle.digest('SHA-256', material)); this.masterSecrets.set(generation, secret); return secret; }, async deriveKey(salt, generation, usages) { const baseKey = await crypto.subtle.importKey('raw', await this.getMasterSecret(generation), 'HKDF', false, ['deriveKey']); return crypto.subtle.deriveKey( { name: 'HKDF', hash: 'SHA-256', salt, info: new TextEncoder().encode('vnc2-key') }, baseKey, { name: 'AES-GCM', length: 256 }, false, usages ); }, toBase64(bytes) { let binary = ''; for (let offset = 0; offset < bytes.length; offset += 0x8000) { binary += String.fromCharCode.apply(null, bytes.subarray(offset, offset + 0x8000)); } return btoa(binary); }, fromBase64(text) { const binary = atob(String(text)); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index); return bytes; }, async open(input) { const bytes = input instanceof Uint8Array ? input : new Uint8Array(input); if (!this.isProtected(bytes)) throw new Error('보호 카트리지(VNC2)가 아닙니다.'); const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const containerVersion = bytes[4]; if (containerVersion !== this.CONTAINER_VERSION) { throw new Error(`이 카트리지는 더 새 엔진이 필요합니다 (보호 형식 ${containerVersion}).`); } const generation = bytes[5]; const salt = bytes.slice(8, 24); const indexIv = bytes.slice(24, 36); const indexLength = view.getUint32(36, true); const dataStart = this.HEADER_SIZE + indexLength; if (dataStart > bytes.length) throw new Error('보호 카트리지가 손상되었습니다.'); const key = await this.deriveKey(salt, generation, ['decrypt']); let index; try { const plain = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: indexIv, additionalData: bytes.slice(0, this.HEADER_SIZE) }, key, bytes.slice(this.HEADER_SIZE, dataStart) ); index = JSON.parse(new TextDecoder('utf-8').decode(plain)); } catch (error) { throw new Error('보호 카트리지를 열 수 없습니다. 파일이 손상되었거나 다른 엔진용입니다.'); } if (!index || !Array.isArray(index.entries)) throw new Error('보호 카트리지 목차가 올바르지 않습니다.'); const encoder = new TextEncoder(); const files = new Map(); for (const entry of index.entries) { const path = ZipStore.normalizePath(entry.p); const start = dataStart + Number(entry.o); const end = start + Number(entry.n); if (!Number.isFinite(start) || end > bytes.length || end < start) throw new Error(`보호 카트리지 항목이 손상되었습니다: ${path}`); let data; try { data = new Uint8Array(await crypto.subtle.decrypt( { name: 'AES-GCM', iv: this.fromBase64(entry.iv), additionalData: encoder.encode(entry.p) }, key, bytes.slice(start, end) )); if (entry.m === 8) data = await this.inflateRaw(data); } catch (error) { throw new Error(`보호 카트리지 항목을 풀 수 없습니다: ${path}`); } if (data.length !== entry.s) throw new Error(`보호 카트리지 항목 크기가 맞지 않습니다: ${path}`); if (files.has(path)) throw new Error(`중복된 카트리지 파일입니다: ${path}`); files.set(path, data); } return files; }, // 스튜디오에서 보호해서 내보낼 때 쓴다. 주석 지우기는 하지 않는다 (Python 빌더만 한다). async seal(inputEntries) { const encoder = new TextEncoder(); const entries = Array.from(inputEntries instanceof Map ? inputEntries.entries() : Object.entries(inputEntries)); const salt = crypto.getRandomValues(new Uint8Array(16)); const key = await this.deriveKey(salt, 1, ['encrypt']); const index = []; const chunks = []; let offset = 0; for (const [rawPath, rawData] of entries) { const path = ZipStore.normalizePath(rawPath); const data = rawData instanceof Uint8Array ? rawData : encoder.encode(String(rawData)); let payload = data; let method = 0; if (this.shouldCompress(path)) { const packed = await this.deflateRaw(data); if (packed.length < data.length) { payload = packed; method = 8; } } const iv = crypto.getRandomValues(new Uint8Array(12)); const encrypted = new Uint8Array(await crypto.subtle.encrypt( { name: 'AES-GCM', iv, additionalData: encoder.encode(path) }, key, payload )); index.push({ p: path, o: offset, n: encrypted.length, s: data.length, m: method, iv: this.toBase64(iv) }); chunks.push(encrypted); offset += encrypted.length; } const indexBytes = encoder.encode(JSON.stringify({ v: 1, entries: index })); const header = new Uint8Array(this.HEADER_SIZE); const headerView = new DataView(header.buffer); header.set(this.MAGIC, 0); header[4] = this.CONTAINER_VERSION; header[5] = 1; header.set(salt, 8); const indexIv = crypto.getRandomValues(new Uint8Array(12)); header.set(indexIv, 24); headerView.setUint32(36, indexBytes.length + 16, true); const encryptedIndex = new Uint8Array(await crypto.subtle.encrypt( { name: 'AES-GCM', iv: indexIv, additionalData: header }, key, indexBytes )); const output = new Uint8Array(this.HEADER_SIZE + encryptedIndex.length + offset); output.set(header, 0); output.set(encryptedIndex, this.HEADER_SIZE); let writeOffset = this.HEADER_SIZE + encryptedIndex.length; chunks.forEach(chunk => { output.set(chunk, writeOffset); writeOffset += chunk.length; }); return output; } }; const CartridgeFormat = { isPlainObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }, sanitizeId(value) { return String(value || 'manual').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'manual'; }, compareVersions(left, right) { const toParts = value => String(value).split('.').map(part => Number.parseInt(part, 10) || 0); const leftParts = toParts(left); const rightParts = toParts(right); const length = Math.max(leftParts.length, rightParts.length); for (let index = 0; index < length; index++) { const difference = (leftParts[index] || 0) - (rightParts[index] || 0); if (difference !== 0) return difference; } return 0; }, fromPackageManifest(packageManifest) { if (!this.isPlainObject(packageManifest)) throw new Error('manifest.json은 객체여야 합니다.'); const moduleList = Array.isArray(packageManifest.modules) ? packageManifest.modules : []; const modules = {}; moduleList.forEach(moduleDefinition => { if (!this.isPlainObject(moduleDefinition) || !String(moduleDefinition.id || '').trim()) { throw new Error('모든 카트리지 모듈에는 id가 필요합니다.'); } const moduleId = String(moduleDefinition.id).trim(); if (modules[moduleId]) throw new Error(`중복된 모듈 ID입니다: ${moduleId}`); modules[moduleId] = { ...moduleDefinition, id: undefined, enabled: moduleDefinition.enabled !== false }; delete modules[moduleId].id; }); return this.normalize({ format: packageManifest.format, formatVersion: packageManifest.formatVersion, manifest: { id: packageManifest.id, name: packageManifest.name, version: packageManifest.version, engineVersion: packageManifest.engineVersion, author: packageManifest.author, description: packageManifest.description, storyIds: packageManifest.storyIds, coverUrl: packageManifest.coverUrl }, theme: packageManifest.theme, skin: packageManifest.skin, presentation: packageManifest.presentation || {}, modules, assets: packageManifest.assets || {}, hotspots: packageManifest.hotspots, home: packageManifest.home, initialState: {} }); }, toPackageManifest(cartridge) { const normalized = this.normalize(cartridge); return { format: normalized.format, formatVersion: normalized.formatVersion, ...normalized.manifest, presentation: normalized.presentation, modules: Object.entries(normalized.modules).map(([id, definition]) => ({ id, ...definition })), assets: normalized.assets, ...(normalized.hotspots ? { hotspots: normalized.hotspots } : {}), ...(normalized.home ? { home: normalized.home } : {}), theme: normalized.theme, skin: normalized.skin }; }, normalize(raw, fallback = {}) { if (!this.isPlainObject(raw)) throw new Error('카트리지 최상위 데이터는 객체여야 합니다.'); if (raw.format !== CARTRIDGE_FORMAT) throw new Error(`지원하지 않는 카트리지 형식입니다: ${raw.format || '형식 없음'}`); if (Number(raw.formatVersion) !== CARTRIDGE_FORMAT_VERSION) { throw new Error(`지원하지 않는 카트리지 버전입니다: ${raw.formatVersion || '버전 없음'}`); } const manifest = this.isPlainObject(raw.manifest) ? raw.manifest : {}; const id = String(manifest.id || '').trim(); const name = String(manifest.name || fallback.name || '').trim(); if (!id) throw new Error('카트리지 manifest.id가 필요합니다.'); if (!name) throw new Error('카트리지 manifest.name이 필요합니다.'); const requiredEngineVersion = String(manifest.engineVersion || ENGINE_VERSION); if (this.compareVersions(ENGINE_VERSION, requiredEngineVersion) < 0) { throw new Error(`이 카트리지는 VN Engine ${requiredEngineVersion} 이상이 필요합니다.`); } const storyIds = Array.isArray(manifest.storyIds) ? manifest.storyIds.map(value => String(value).trim()).filter(Boolean) : []; const presentation = this.isPlainObject(raw.presentation) ? raw.presentation : {}; const modules = this.isPlainObject(raw.modules) ? raw.modules : {}; Object.entries(modules).forEach(([moduleId, moduleDefinition]) => { if (!this.isPlainObject(moduleDefinition)) throw new Error(`modules.${moduleId}는 객체여야 합니다.`); if (moduleDefinition.enabled !== undefined && typeof moduleDefinition.enabled !== 'boolean') { throw new Error(`modules.${moduleId}.enabled는 true 또는 false여야 합니다.`); } }); return { format: CARTRIDGE_FORMAT, formatVersion: CARTRIDGE_FORMAT_VERSION, manifest: { id, name, version: String(manifest.version || '1.0.0'), engineVersion: requiredEngineVersion, author: String(manifest.author || ''), description: String(manifest.description || ''), storyIds, coverUrl: String(manifest.coverUrl || fallback.coverUrl || '') }, presentation: { animations: Array.isArray(presentation.animations) ? presentation.animations : [], bgm: Array.isArray(presentation.bgm) ? presentation.bgm : [], opening: Array.isArray(presentation.opening) || typeof presentation.opening === 'string' ? presentation.opening : [], // 이야기 도중 조건이 맞으면 끼워 넣는 장면 (SceneDirector). 없으면 칸을 만들지 않는다. ...(Array.isArray(presentation.scenes) && presentation.scenes.length > 0 ? { scenes: presentation.scenes } : {}) }, modules, assets: this.isPlainObject(raw.assets) ? raw.assets : {}, // 배경 위의 누를 수 있는 자리. 여기서 빠뜨리면 실행기(buildHotspotIndex)가 볼 수 없다. hotspots: this.isPlainObject(raw.hotspots) ? raw.hotspots : null, // 대화하지 않을 때 머무는 방 화면. 실행기(buildHomeScene)가 읽는다. home: this.isPlainObject(raw.home) ? raw.home : null, // theme 은 manifest 안의 객체이거나 패키지 안 JSON 파일 경로 문자열이다. theme: typeof raw.theme === 'string' || this.isPlainObject(raw.theme) ? raw.theme : null, // skin 은 패키지 안 CSS 파일 경로 하나 또는 여러 개다. skin: typeof raw.skin === 'string' || Array.isArray(raw.skin) ? raw.skin : null, initialState: this.isPlainObject(raw.initialState) ? raw.initialState : {} }; }, fromLegacy(raw, fallback = {}) { const isAnimationArray = Array.isArray(raw); const meta = !isAnimationArray && this.isPlainObject(raw?.meta) ? raw.meta : {}; const settings = !isAnimationArray && this.isPlainObject(raw?.settings) ? raw.settings : {}; const storyId = String(meta.storyId || fallback.storyId || 'manual'); const name = String(fallback.name || meta.name || `Legacy ${storyId}`); return this.normalize({ format: CARTRIDGE_FORMAT, formatVersion: CARTRIDGE_FORMAT_VERSION, manifest: { id: `legacy.${this.sanitizeId(storyId)}`, name, version: String(meta.version || '1.0.0'), engineVersion: ENGINE_VERSION, storyIds: storyId === 'manual' ? [] : [storyId], coverUrl: String(meta.coverUrl || fallback.coverUrl || '') }, presentation: { animations: isAnimationArray ? raw : (settings.animations || raw.customAnimations || []), bgm: isAnimationArray ? [] : (settings.bgm || raw.customBgmRules || []), opening: isAnimationArray ? [] : (raw.opening || []) }, modules: {}, assets: {}, initialState: {} }, fallback); }, parse(text, fileName = '') { let raw; try { raw = JSON.parse(text); } catch (error) { throw new Error('카트리지 JSON 형식이 올바르지 않습니다.'); } const fallbackName = fileName.replace(/\.(vncartridge|json)$/i, '') || '가져온 카트리지'; if (this.isPlainObject(raw) && raw.format === CARTRIDGE_FORMAT) { return this.normalize(raw, { name: fallbackName }); } if (Array.isArray(raw) || (this.isPlainObject(raw) && raw.settings && raw.meta)) { return this.fromLegacy(raw, { name: fallbackName }); } throw new Error('VN Engine 카트리지 또는 기존 설정 파일이 아닙니다.'); }, createTemplate({ name, storyId, coverUrl, animations, bgm, opening }) { const normalizedStoryId = String(storyId || 'manual'); return this.normalize({ format: CARTRIDGE_FORMAT, formatVersion: CARTRIDGE_FORMAT_VERSION, manifest: { id: `cartridge.${this.sanitizeId(normalizedStoryId)}.${Date.now()}`, name: name || '새 카트리지', version: '1.0.0', engineVersion: ENGINE_VERSION, storyIds: normalizedStoryId === 'manual' ? [] : [normalizedStoryId], coverUrl: coverUrl || '' }, presentation: { animations: animations || [], bgm: bgm || [], opening: opening || [] }, modules: {}, assets: {}, initialState: {} }); } }; const CartridgePackageReader = { // 압축을 풀고 보호 카트리지를 여는 데 기다려야 해서 기다리는 함수다 (보호판 엔진). // packageType 은 'zip' / 'protected' / 'legacy-json'. 'protected' 도 패키지라서 라이브러리에 그대로 저장된다. async readArrayBuffer(input, fileName = '') { const bytes = input instanceof Uint8Array ? input : new Uint8Array(input); const isZip = bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04; const isProtected = CartridgeProtect.isProtected(bytes); if (!isZip && !isProtected) { if (fileName.toLowerCase().endsWith(CARTRIDGE_EXTENSION)) { throw new Error(`${CARTRIDGE_EXTENSION} 파일은 ZIP 패키지나 보호 카트리지여야 합니다.`); } const cartridge = CartridgeFormat.parse(new TextDecoder('utf-8').decode(bytes), fileName); return { cartridge, files: new Map(), bytes, packageType: 'legacy-json' }; } const files = isProtected ? await CartridgeProtect.open(bytes) : await ZipStore.read(bytes); const manifestBytes = files.get('manifest.json'); if (!manifestBytes) throw new Error('카트리지 루트에 manifest.json이 필요합니다.'); let manifest; try { manifest = JSON.parse(new TextDecoder('utf-8').decode(manifestBytes)); } catch (error) { throw new Error('manifest.json 형식이 올바르지 않습니다.'); } const cartridge = CartridgeFormat.fromPackageManifest(manifest); Object.entries(cartridge.modules).forEach(([moduleId, moduleDefinition]) => { if (moduleDefinition.entry && !files.has(moduleDefinition.entry)) { throw new Error(`모듈 파일을 찾을 수 없습니다: ${moduleId} → ${moduleDefinition.entry}`); } [['styles', '스타일'], ['scripts', '스크립트']].forEach(([fieldName, label]) => { const resourcePaths = moduleDefinition[fieldName]; if (resourcePaths === undefined) return; if (!Array.isArray(resourcePaths) || resourcePaths.some(resourcePath => typeof resourcePath !== 'string' || !resourcePath.trim())) { throw new Error(`modules.${moduleId}.${fieldName}는 파일 경로 배열이어야 합니다.`); } resourcePaths.forEach(resourcePath => { if (!files.has(resourcePath)) throw new Error(`모듈 ${label} 파일을 찾을 수 없습니다: ${moduleId} → ${resourcePath}`); }); }); if (moduleDefinition.dataFiles !== undefined) { if (!CartridgeFormat.isPlainObject(moduleDefinition.dataFiles)) { throw new Error(`modules.${moduleId}.dataFiles는 이름과 파일 경로의 객체여야 합니다.`); } Object.entries(moduleDefinition.dataFiles).forEach(([dataName, dataPath]) => { if (!dataName.trim() || typeof dataPath !== 'string' || !dataPath.trim()) { throw new Error(`modules.${moduleId}.dataFiles 항목이 올바르지 않습니다.`); } if (!files.has(dataPath)) { throw new Error(`모듈 데이터 파일을 찾을 수 없습니다: ${moduleId}.${dataName} → ${dataPath}`); } if (dataPath.toLowerCase().endsWith('.json')) { try { JSON.parse(new TextDecoder('utf-8').decode(files.get(dataPath))); } catch (error) { throw new Error(`모듈 JSON 데이터 형식이 올바르지 않습니다: ${dataPath}`); } } }); } // 모듈 화면 안에서 쓸 그림. 화면은 격리된 액자라 엔진이 data: 주소로 바꿔 넘긴다. if (moduleDefinition.images !== undefined) { if (!CartridgeFormat.isPlainObject(moduleDefinition.images)) { throw new Error(`modules.${moduleId}.images는 이름과 그림 파일 경로의 객체여야 합니다.`); } Object.entries(moduleDefinition.images).forEach(([imageName, imagePath]) => { if (!imageName.trim() || typeof imagePath !== 'string' || !imagePath.trim()) { throw new Error(`modules.${moduleId}.images 항목이 올바르지 않습니다.`); } if (!MODULE_IMAGE_PATTERN.test(imagePath)) { throw new Error(`모듈 그림은 이미지 파일이어야 합니다: ${moduleId}.${imageName} → ${imagePath}`); } if (!files.has(imagePath)) { throw new Error(`모듈 그림 파일을 찾을 수 없습니다: ${moduleId}.${imageName} → ${imagePath}`); } }); } }); return { cartridge, files, bytes, packageType: isProtected ? 'protected' : 'zip' }; } }; const CartridgePackageStore = { databaseName: 'vnEngineCartridgePackages', storeName: 'packages', open() { return new Promise((resolve, reject) => { const request = indexedDB.open(this.databaseName, 1); request.onupgradeneeded = () => { if (!request.result.objectStoreNames.contains(this.storeName)) { request.result.createObjectStore(this.storeName, { keyPath: 'id' }); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error || new Error('카트리지 저장소를 열 수 없습니다.')); }); }, async put(cartridgeId, bytes, fileName) { const database = await this.open(); return new Promise((resolve, reject) => { const transaction = database.transaction(this.storeName, 'readwrite'); transaction.objectStore(this.storeName).put({ id: cartridgeId, fileName, blob: new Blob([bytes], { type: 'application/vnd.vn-engine.cartridge+zip' }), installedAt: new Date().toISOString() }); transaction.oncomplete = () => { database.close(); resolve(); }; transaction.onerror = () => { database.close(); reject(transaction.error || new Error('카트리지를 저장할 수 없습니다.')); }; }); }, async get(cartridgeId) { const database = await this.open(); return new Promise((resolve, reject) => { const transaction = database.transaction(this.storeName, 'readonly'); const request = transaction.objectStore(this.storeName).get(cartridgeId); request.onsuccess = () => { database.close(); resolve(request.result || null); }; request.onerror = () => { database.close(); reject(request.error || new Error('카트리지를 불러올 수 없습니다.')); }; }); }, async delete(cartridgeId) { const database = await this.open(); return new Promise((resolve, reject) => { const transaction = database.transaction(this.storeName, 'readwrite'); transaction.objectStore(this.storeName).delete(cartridgeId); transaction.oncomplete = () => { database.close(); resolve(); }; transaction.onerror = () => { database.close(); reject(transaction.error || new Error('카트리지를 삭제할 수 없습니다.')); }; }); } }; // --- 카트리지 공유 서랍 --- // 모듈별 저장소와 달리 카트리지 하나에 서랍 하나다. 모듈들이 같은 값을 보고 쓴다. // 통째로 덮어쓰면 서로 지우게 되므로, 최상위 항목 단위로만 바꾼다(값이 null 이면 삭제). const CartridgeSharedStateStore = { storagePrefix: 'vnEngineSharedState', getScopeId() { return getCurrentTargetId()?.id || 'manual'; }, getKey(cartridgeId) { return `${this.storagePrefix}:${this.getScopeId()}:${cartridgeId}`; }, normalize(state) { if (!state || typeof state !== 'object' || Array.isArray(state)) return {}; const serialized = JSON.stringify(state); if (serialized.length > 250000) throw new Error('공유 상태가 저장 한도를 초과했습니다.'); return JSON.parse(serialized); }, load(cartridgeId) { try { const raw = localStorage.getItem(this.getKey(cartridgeId)); return raw ? this.normalize(JSON.parse(raw)) : {}; } catch (error) { console.warn('VN Engine: 공유 상태를 불러오지 못했습니다.', error); return {}; } }, patch(cartridgeId, patch) { const incoming = this.normalize(patch); const next = this.load(cartridgeId); Object.entries(incoming).forEach(([key, value]) => { if (value === null) delete next[key]; else next[key] = value; }); const normalized = this.normalize(next); localStorage.setItem(this.getKey(cartridgeId), JSON.stringify(normalized)); return normalized; }, clear(cartridgeId) { localStorage.removeItem(this.getKey(cartridgeId)); } }; // 메뉴에 어떤 화면 모듈을 보일지. 모듈이 VNCartridge.menu.show/hide 로 바꾼 값을 이야기(채팅)마다 기억한다. // 적힌 값이 없으면 manifest 의 "menu" 를 따른다 (false 면 숨김, 없으면 보임). const CartridgeMenuStore = { storagePrefix: 'vnEngineMenuState', getKey(cartridgeId) { return `${this.storagePrefix}:${getCurrentTargetId()?.id || 'manual'}:${cartridgeId}`; }, load(cartridgeId) { try { const data = JSON.parse(localStorage.getItem(this.getKey(cartridgeId)) || '{}'); return data && typeof data === 'object' && !Array.isArray(data) ? data : {}; } catch (error) { return {}; } }, set(cartridgeId, moduleId, visible) { const data = this.load(cartridgeId); data[moduleId] = visible === true; localStorage.setItem(this.getKey(cartridgeId), JSON.stringify(data)); return data; }, clear(cartridgeId) { localStorage.removeItem(this.getKey(cartridgeId)); } }; const CartridgeModuleStateStore = { storagePrefix: 'vnEngineModuleState', getScopeId() { return getCurrentTargetId()?.id || 'manual'; }, getKey(cartridgeId, moduleId) { return `${this.storagePrefix}:${this.getScopeId()}:${cartridgeId}:${moduleId}`; }, normalize(state) { if (!state || typeof state !== 'object' || Array.isArray(state)) return {}; const serialized = JSON.stringify(state); if (serialized.length > 250000) throw new Error('모듈 상태가 저장 한도를 초과했습니다.'); return JSON.parse(serialized); }, load(cartridgeId, moduleId) { try { const raw = localStorage.getItem(this.getKey(cartridgeId, moduleId)); return raw ? this.normalize(JSON.parse(raw)) : {}; } catch (error) { console.warn('VN Engine: 모듈 상태를 불러오지 못했습니다.', error); return {}; } }, save(cartridgeId, moduleId, state) { const normalized = this.normalize(state); localStorage.setItem(this.getKey(cartridgeId, moduleId), JSON.stringify(normalized)); return normalized; } }; const AiMessageContextQueue = { storagePrefix: 'vnEngineAiMessageContext', maximumItems: 50, maximumTextLength: 16000, getKey() { return `${this.storagePrefix}:${getCurrentTargetId()?.id || 'manual'}`; }, load() { try { const parsed = JSON.parse(localStorage.getItem(this.getKey()) || '[]'); return Array.isArray(parsed) ? parsed.filter(item => item && typeof item.id === 'string' && typeof item.text === 'string') : []; } catch (error) { console.warn('VN Engine: AI 문맥 대기열을 불러오지 못했습니다.', error); return []; } }, save(items) { const normalized = Array.isArray(items) ? items.slice(-this.maximumItems) : []; localStorage.setItem(this.getKey(), JSON.stringify(normalized)); return normalized; }, enqueue(cartridgeId, moduleId, event) { if (!event || typeof event !== 'object') throw new Error('AI 문맥 이벤트가 올바르지 않습니다.'); const text = String(event.text || '').trim(); if (!text) throw new Error('AI에 전달할 문맥이 비어 있습니다.'); if (text.length > this.maximumTextLength) throw new Error('AI 문맥이 허용 길이를 초과했습니다.'); const localId = String(event.id || `${Date.now()}-${Math.random().toString(36).slice(2)}`).slice(0, 160); const id = `${cartridgeId}:${moduleId}:${localId}`; const items = this.load(); const existing = items.find(item => item.id === id); if (existing) { // 진행 상황처럼 계속 바뀌는 알림은 같은 자리를 덮어써서 마지막 것 하나만 남긴다. if (event.replace !== true) return existing; existing.title = String(event.title || existing.title).slice(0, 120); existing.text = text; existing.createdAt = Number.isFinite(event.createdAt) ? event.createdAt : Date.now(); this.save(items); return existing; } const item = { id, cartridgeId, moduleId, title: String(event.title || moduleId).slice(0, 120), text, createdAt: Number.isFinite(event.createdAt) ? event.createdAt : Date.now() }; items.push(item); this.save(items); return item; }, compose(userMessage) { const items = this.load(); const contextText = items.map(item => item.text).join('\n\n'); return { finalMessage: contextText ? `${userMessage}\n\n${contextText}` : userMessage, items }; }, // 카트리지가 넣어 둔 알림을 이름으로 걷어낸다. 지도가 넣은 것을 상시 모듈이 걷을 수도 있게 // 모듈은 가리지 않고, 같은 카트리지의 같은 이름이면 걷는다. withdraw(cartridgeId, localId) { const name = String(localId === undefined || localId === null ? '' : localId).trim(); if (!cartridgeId || !name) return 0; const suffix = `:${name}`; const items = this.load(); const kept = items.filter(item => !(item.cartridgeId === cartridgeId && item.id.endsWith(suffix))); if (kept.length !== items.length) this.save(kept); return items.length - kept.length; }, consume(itemIds) { const consumed = new Set(Array.isArray(itemIds) ? itemIds : []); if (consumed.size === 0) return this.load(); return this.save(this.load().filter(item => !consumed.has(item.id))); }, clear() { this.save([]); } }; const AiTransmissionLogStore = { storagePrefix: 'vnEngineAiTransmissionLog', maximumItems: 40, getKey() { return `${this.storagePrefix}:${getCurrentTargetId()?.id || 'manual'}`; }, load() { try { const parsed = JSON.parse(localStorage.getItem(this.getKey()) || '[]'); return Array.isArray(parsed) ? parsed : []; } catch (error) { console.warn('VN Engine: AI 전송 로그를 불러오지 못했습니다.', error); return []; } }, record(entry) { const items = this.load(); items.push({ id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, timestamp: Date.now(), status: entry.status === 'sent' ? 'sent' : 'failed', userMessage: String(entry.userMessage || '').slice(0, 50000), finalMessage: String(entry.finalMessage || '').slice(0, 100000), contextTitles: Array.isArray(entry.contextTitles) ? entry.contextTitles.map(title => String(title).slice(0, 120)) : [], error: String(entry.error || '').slice(0, 500) }); localStorage.setItem(this.getKey(), JSON.stringify(items.slice(-this.maximumItems))); }, clear() { localStorage.setItem(this.getKey(), '[]'); } }; // --- UI 테마 관리자 --- // 카트리지가 준 값으로 generateStyles 의 :root 토큰만 덮어쓴다. // 선택자는 받지 않으므로 카트리지가 화면 구조를 건드릴 수는 없다. const THEME_TOKENS = [ 'font', 'accent', 'panelBg', 'panelText', 'panelBorder', 'backdrop', 'radius', 'divider', 'sidebarBg', 'mutedText', 'tabText', 'tabHoverBg', 'accentBg', 'accentText', 'actionBg', 'actionBorder', 'actionText', 'dangerBg', 'dangerBorder', 'dangerText', 'dialogueBg', 'dialogueBorder', 'dialogueText', 'dialogueRadius', 'nameBg', 'nameBorder', 'nameText', 'narrationText', 'statusBg', 'statusBorder', 'statusText', 'statusAccent', 'menuButtonBg', 'menuButtonBgHover', 'menuButtonText', 'moduleBg', 'moduleBackdrop', 'moduleBorder', 'frameBg', 'primary', 'primaryHover', 'controlBg' ]; // 색, 길이, 글꼴 이름에 필요한 문자만 허용한다. 세미콜론과 중괄호가 없으므로 값 밖으로 나갈 수 없다. const THEME_VALUE_ALLOWED = /^[#a-zA-Z0-9\s,.()%'"\/+-]{1,120}$/; const THEME_VALUE_FORBIDDEN = /url\(|@import|expression|javascript:|image-set/i; const ThemeManager = { styleId: 'vn-cartridge-theme', activeTheme: null, toCssVariable(key) { return `--vn-${key.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`)}`; }, sanitize(theme) { const result = {}; if (!theme || typeof theme !== 'object' || Array.isArray(theme)) return result; Object.entries(theme).forEach(([key, rawValue]) => { if (!THEME_TOKENS.includes(key)) { console.warn(`VN Engine: 알 수 없는 테마 항목을 건너뜁니다 — ${key}`); return; } const value = String(rawValue).trim(); if (!THEME_VALUE_ALLOWED.test(value) || THEME_VALUE_FORBIDDEN.test(value)) { console.warn(`VN Engine: 사용할 수 없는 테마 값을 건너뜁니다 — ${key}`); return; } result[key] = value; }); return result; }, apply(theme) { const sanitized = this.sanitize(theme); this.activeTheme = Object.keys(sanitized).length > 0 ? sanitized : null; this.render(); }, clear() { this.activeTheme = null; this.render(); }, isEnabled() { const settings = (typeof SettingsManager !== 'undefined' && SettingsManager.settings) || {}; return settings.useCartridgeTheme !== false; }, render() { // 문서가 없는 환경(테스트 등)에서는 조용히 넘어간다. if (typeof document === 'undefined') return; const existing = document.getElementById(this.styleId); const entries = this.isEnabled() && this.activeTheme ? Object.entries(this.activeTheme) : []; if (entries.length === 0) { existing?.remove(); return; } // html:root 는 :root 보다 우선순위가 높아, 기본 토큰이 나중에 다시 주입돼도 테마가 유지된다. const declarations = entries.map(([key, value]) => `${this.toCssVariable(key)}:${value};`).join(''); const style = existing || document.createElement('style'); style.id = this.styleId; style.textContent = `html:root{${declarations}}`; if (!existing) document.head.appendChild(style); } }; // --- 카트리지 스킨 관리자 --- // 카트리지가 준 CSS 를 엔진 UI 안으로만 범위를 좁혀서 주입한다. // 선택자 앞에 엔진 루트를 강제로 붙이므로 Crack 페이지 본체에는 닿지 않는다. const SKIN_ROOTS = ['#vn-engine-container', '#vn-module-overlay', '.vn-control-panel']; const SKIN_MAX_LENGTH = 120000; const SKIN_FORBIDDEN = /@import|@charset|@namespace|expression\s*\(|behavior\s*:|-moz-binding|javascript:|<\/?script/i; const SkinManager = { styleId: 'vn-cartridge-skin', activeCss: null, splitSelectors(prelude) { const parts = []; let current = ''; let depth = 0; let quote = null; for (const character of prelude) { if (quote) { current += character; if (character === quote) quote = null; continue; } if (character === '"' || character === "'") { quote = character; current += character; continue; } if (character === '(' || character === '[') depth += 1; if (character === ')' || character === ']') depth -= 1; if (character === ',' && depth === 0) { parts.push(current); current = ''; continue; } current += character; } parts.push(current); return parts.map(part => part.trim()).filter(Boolean); }, scopeSelector(selector) { // :root, html, body 처럼 엔진 밖을 노리는 선택자는 버린다. if (/^(:root|html|body)\b/i.test(selector)) { console.warn(`VN Engine: 스킨에서 범위를 벗어난 선택자를 건너뜁니다 — ${selector}`); return ''; } return SKIN_ROOTS.map(root => `${root} ${selector}`).join(', '); }, scope(css) { let result = ''; let cursor = 0; while (cursor < css.length) { const open = css.indexOf('{', cursor); if (open < 0) break; const prelude = css.slice(cursor, open).trim(); let depth = 1; let index = open + 1; while (index < css.length && depth > 0) { if (css[index] === '{') depth += 1; else if (css[index] === '}') depth -= 1; index += 1; } const body = css.slice(open + 1, index - 1); cursor = index; if (!prelude) continue; if (prelude.startsWith('@')) { const name = prelude.slice(1).split(/[\s({]/)[0].toLowerCase(); if (name === 'media' || name === 'supports' || name === 'layer') { const inner = this.scope(body); if (inner) result += `${prelude}{${inner}}`; } else if (name.endsWith('keyframes')) { // 키프레임 안은 선택자가 아니라 진행률이므로 그대로 둔다. result += `${prelude}{${body}}`; } else if (name === 'font-face') { // 카트리지에 담은 글꼴 (엔진 3.34.0). url() 은 위에서 data: 만 남겼으므로 바깥 주소를 부르지 않는다. result += `@font-face{${body}}`; } else { console.warn(`VN Engine: 스킨에서 지원하지 않는 규칙을 건너뜁니다 — ${prelude}`); } continue; } const selectors = this.splitSelectors(prelude).map(selector => this.scopeSelector(selector)).filter(Boolean); if (selectors.length === 0) continue; result += `${selectors.join(', ')}{${body}}`; } return result; }, prepare(rawCss) { const text = String(rawCss || ''); if (!text.trim()) return ''; if (text.length > SKIN_MAX_LENGTH) { console.warn('VN Engine: 스킨 CSS 가 너무 큽니다. 적용하지 않습니다.'); return ''; } let css = text.replace(/\/\*[\s\S]*?\*\//g, ''); if (SKIN_FORBIDDEN.test(css)) { console.warn('VN Engine: 스킨 CSS 에 허용되지 않는 구문이 있어 적용하지 않습니다.'); return ''; } // 외부 주소를 가리키는 url() 은 막는다. 패키지에 담긴 data: 는 허용한다. css = css.replace(/url\(\s*(['"]?)([^)'"]*)\1\s*\)/gi, (match, quote, target) => /^data:/i.test(String(target).trim()) ? match : 'none'); // !important 를 지워, 아래 보호 규칙이 항상 우선하도록 한다. css = css.replace(/!\s*important/gi, ''); return this.scope(css); }, // 카트리지가 무슨 짓을 해도 메뉴와 닫기 버튼만은 남겨, 카트리지를 뺄 길을 보장한다. // 스킨의 !important 는 모두 제거했으므로, 여기의 !important 가 특이도와 무관하게 이긴다. // 보장 훅만 쓰므로 이 함수는 DOM_IDS 같은 바깥 값에 기대지 않는다. guardCss() { const visible = 'visibility:visible!important;opacity:1!important;pointer-events:auto!important;'; return [ `[data-vn-part="menu-button"]{display:inline-block!important;${visible}}`, `[data-vn-part="menu-action"]{display:block!important;${visible}}`, `[data-vn-part="menu-actions"]{display:grid!important;${visible}}`, `[data-vn-part="menu-close"],[data-vn-part="module-close"]{display:inline-block!important;${visible}}`, `.vn-control-panel.vn-paused .vn-pause-only,.vn-drawer-toggle{display:inline-block!important;${visible}}`, // 좁은 화면에서 엔진이 쓰는 가로 배치를 보장 규칙이 덮지 않도록 같이 맞춘다. '@media (max-width: 720px){[data-vn-part="menu-actions"]{display:flex!important;}}', '@media (max-height: 500px){[data-vn-part="menu-actions"]{display:flex!important;}}' ].join(''); }, apply(css) { this.activeCss = this.prepare(css) || null; this.render(); }, clear() { this.activeCss = null; this.render(); }, render() { // 문서가 없는 환경(테스트 등)에서는 조용히 넘어간다. if (typeof document === 'undefined') return; const existing = document.getElementById(this.styleId); const settings = (typeof SettingsManager !== 'undefined' && SettingsManager.settings) || {}; const enabled = settings.useCartridgeTheme !== false; if (!enabled || !this.activeCss) { existing?.remove(); return; } const style = existing || document.createElement('style'); style.id = this.styleId; style.textContent = `${this.activeCss}\n${this.guardCss()}`; if (!existing) document.head.appendChild(style); } }; const CartridgeRuntime = { activeCartridge: null, packageFiles: new Map(), frameBindings: new WeakMap(), // WeakMap 은 순회가 안 되어 전체 통지를 못 한다. 살아 있는 액자 목록을 따로 들고 간다. liveFrames: [], imageAssets: new Map(), declaredImages: new Map(), speakerCharacterIds: new Map(), hotspots: new Map(), hotspotReveal: 'always', home: null, objectUrls: new Map(), dataUrls: new Map(), activate(cartridge, packageFiles = new Map()) { if (this.activeCartridge) this.deactivate(); this.activeCartridge = cartridge; this.packageFiles = packageFiles || new Map(); ThemeManager.apply(this.resolveTheme(cartridge)); SkinManager.apply(this.resolveSkin(cartridge)); this.buildImageAssetIndex(); this.buildDeclaredImageIndex(); this.buildSpeakerIndex(); this.buildHotspotIndex(); this.buildHomeScene(); const enabledModules = Object.entries(cartridge.modules || {}) .filter(([, definition]) => definition.enabled === true) .map(([moduleId]) => moduleId); try { ServiceHost.start(); } catch (error) { console.error('VN Engine: 상시 모듈 시작 실패', error); } window.dispatchEvent(new CustomEvent('vn-engine:cartridge-activated', { detail: { cartridgeId: cartridge.manifest.id, enabledModules } })); console.log(`VN Engine: cartridge activated (${cartridge.manifest.id})`, enabledModules); }, deactivate() { if (!this.activeCartridge) return; const cartridgeId = this.activeCartridge.manifest.id; this.activeCartridge = null; this.packageFiles = new Map(); ThemeManager.clear(); SkinManager.clear(); try { ServiceHost.stop(); } catch (error) { console.error('VN Engine: 상시 모듈 정리 실패', error); } this.releaseObjectUrls(); this.imageAssets = new Map(); this.declaredImages = new Map(); this.speakerCharacterIds = new Map(); this.hotspots = new Map(); this.hotspotReveal = 'always'; this.home = null; // 카트리지가 내려가면 그 카트리지의 액자는 명부에서 뺀다. this.liveFrames = this.liveFrames.filter(entry => entry.cartridgeId !== cartridgeId); window.dispatchEvent(new CustomEvent('vn-engine:cartridge-deactivated', { detail: { cartridgeId } })); }, // --- 카트리지 내장 이미지 --- // 매칭 키는 "확장자를 뗀 파일 이름" 그대로입니다. (mira01.png → mira01) // AI가 ![mira01] 이라고 쓰면 그 파일을 화면에 띄웁니다. 폴더 위치는 이름에 영향을 주지 않습니다. normalizeAssetName(value) { return String(value === undefined || value === null ? '' : value).trim().toLowerCase(); }, // 배경/캐릭터 판정은 기존 URL 방식과 같은 신호를 카트리지 경로에 적용한 것입니다. // - 파일 이름이 bg_ 로 시작 → 배경 // - 경로가 backgroundPattern(기본 /g/)을 포함 → 배경 // - 경로가 characterPattern(기본 /c/)을 포함 → 캐릭터 // - 아무 신호도 없으면 캐릭터 // 배경 신호가 하나라도 있으면 배경입니다. 설정이 바뀌면 바로 반영되도록 꺼낼 때 판정합니다. assetKindForPath(path) { const normalized = `/${String(path).replace(/^\/+/, '')}`; const settings = (typeof SettingsManager !== 'undefined' && SettingsManager.settings) || {}; const backgroundPattern = settings.backgroundPattern || '/g/'; const characterPattern = settings.characterPattern || '/c/'; const fileName = normalized.slice(normalized.lastIndexOf('/') + 1); if (fileName.toLowerCase().startsWith(BACKGROUND_NAME_PREFIX)) return 'background'; if (backgroundPattern && normalized.includes(backgroundPattern)) return 'background'; if (characterPattern && normalized.includes(characterPattern)) return 'character'; return 'character'; }, mimeTypeForPath(path) { const value = String(path); const extension = value.slice(value.lastIndexOf('.') + 1).toLowerCase(); return { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif', bmp: 'image/bmp', svg: 'image/svg+xml', // 카트리지 안의 소리 파일 (BGM 규칙 · VNCartridge.playMusic) mp3: 'audio/mpeg', ogg: 'audio/ogg', opus: 'audio/ogg', wav: 'audio/wav', m4a: 'audio/mp4', aac: 'audio/aac' }[extension] || 'application/octet-stream'; }, buildImageAssetIndex() { this.releaseObjectUrls(); this.imageAssets = new Map(); const imagePattern = /\.(png|jpe?g|gif|webp|avif|bmp|svg)$/i; // 모듈 화면 전용 그림(타일·스프라이트)은 ![이름] 으로 부르는 그림이 아니다 const moduleImagePaths = new Set(); Object.values(this.activeCartridge?.modules || {}).forEach(definition => { if (CartridgeFormat.isPlainObject(definition?.images)) Object.values(definition.images).forEach(path => moduleImagePaths.add(path)); }); const imagePaths = [...this.packageFiles.keys()] .filter(path => imagePattern.test(path) && !moduleImagePaths.has(path)) .sort(); imagePaths.forEach(path => { const fileName = path.slice(path.lastIndexOf('/') + 1); const baseName = fileName.replace(/\.[^.]+$/, ''); const key = this.normalizeAssetName(baseName); if (!key) return; if (this.imageAssets.has(key)) { console.warn(`VN Engine: 이미지 이름이 중복되어 무시합니다 (${path}). 먼저 등록됨: ${this.imageAssets.get(key).path}`); return; } this.imageAssets.set(key, { name: baseName, path, characterId: characterIdFromBaseName(baseName) }); }); return this.imageAssets; }, // manifest 의 assets.images 를 읽는다. // "이름": "https://..." → 외부 주소 // "이름": "assets/g/bg.webp" → 패키지 안 파일 // "이름": { url|path, kind, characterId } → 판정을 직접 지정 // 이 표는 파일 색인보다 먼저 보므로, 같은 이름이면 표가 이긴다. buildDeclaredImageIndex() { this.declaredImages = new Map(); const images = this.activeCartridge?.assets?.images; if (!images || typeof images !== 'object' || Array.isArray(images)) return this.declaredImages; Object.entries(images).forEach(([rawName, definition]) => { const name = String(rawName).trim(); const key = this.normalizeAssetName(name); if (!key) return; const isObject = definition && typeof definition === 'object' && !Array.isArray(definition); const rawSource = typeof definition === 'string' ? definition : (isObject ? (definition.url || definition.path) : null); const source = String(rawSource || '').trim(); if (!source) { console.warn(`VN Engine: 이미지 선언에 주소나 경로가 없습니다 — ${name}`); return; } const external = EXTERNAL_IMAGE_SOURCE.test(source); if (external && !ALLOWED_IMAGE_SOURCE.test(source)) { console.warn(`VN Engine: 이미지에 쓸 수 없는 주소라 건너뜁니다 — ${name}`); return; } const declaredKind = isObject ? definition.kind : null; const declaredCharacterId = isObject && typeof definition.characterId === 'string' ? definition.characterId.trim() : ''; this.declaredImages.set(key, { name, source, external, kind: declaredKind === 'background' || declaredKind === 'character' ? declaredKind : null, characterId: declaredCharacterId || null }); }); return this.declaredImages; }, // manifest 의 hotspots 를 읽는다. 배경 그림 위에서 누를 수 있는 영역이다. // "hotspots": { // "reveal": "hidden", ← 전체 기본값 (always | hover | hidden) // "areas": { // "bg_teahouse": [{ "id": "menu-board", "rect": [0.06, 0.12, 0.2, 0.36], "image": "menu_board" }] // } // } // rect 는 배경 그림 기준 [가로위치, 세로위치, 너비, 높이] 를 0~1 비율로 적는다. buildHotspotIndex() { this.hotspots = new Map(); this.hotspotReveal = 'always'; const config = this.activeCartridge?.hotspots || this.activeCartridge?.manifest?.hotspots; if (!config || typeof config !== 'object' || Array.isArray(config)) return this.hotspots; if (HOTSPOT_REVEALS.includes(config.reveal)) this.hotspotReveal = config.reveal; const areas = config.areas && typeof config.areas === 'object' ? config.areas : {}; Object.entries(areas).forEach(([rawName, list]) => { const key = this.normalizeAssetName(rawName); if (!key || !Array.isArray(list)) return; const parsed = list.map(item => this.readHotspot(item)).filter(Boolean); if (parsed.length > 0) this.hotspots.set(key, parsed); else console.warn(`VN Engine: 쓸 수 있는 핫스팟이 없습니다 — ${rawName}`); }); return this.hotspots; }, // [가로위치, 세로위치, 너비, 높이] (배경 그림 기준 0~1 비율). 핫스팟과 방 화면이 같이 쓴다. readRect(value) { const rect = Array.isArray(value) ? value.map(Number) : []; if (rect.length !== 4 || rect.some(number => !Number.isFinite(number))) return null; const [x, y, width, height] = rect; if (width <= 0 || height <= 0) return null; return { x, y, width, height }; }, readHotspot(item) { if (!item || typeof item !== 'object') return null; const rect = this.readRect(item.rect); if (!rect) return null; const tell = item.tellAi && typeof item.tellAi === 'object' ? item.tellAi : null; const tellText = tell ? String(tell.text || '').trim() : ''; return { id: String(item.id || item.image || '').trim().slice(0, 80) || 'hotspot', rect, image: item.image ? String(item.image).trim() : '', label: item.label ? String(item.label).trim().slice(0, 120) : '', // 하나만 다르게 드러내고 싶으면 여기에 적는다. 없으면 카트리지 기본값을 따른다. reveal: HOTSPOT_REVEALS.includes(item.reveal) ? item.reveal : null, tellAi: tellText ? { title: String(tell.title || '').trim().slice(0, 120), text: tellText } : null }; }, hotspotsFor(name) { const key = this.normalizeAssetName(name); return (key && this.hotspots.get(key)) || []; }, // manifest 의 home 을 읽는다. 대화하지 않을 때 머무는 방 화면이다. // "home": { // "background": "bg_room", ← 방 배경 그림 이름 // "endLabel": "대화 종료", ← 대사창에 붙는 돌아가기 버튼 글자 (생략 가능) // "characters": [{ // "id": "friend", "name": "친구", // "image": "home_roommate", ← 방 화면에 세워 둘 그림 // "rect": [0.3, 0.3, 0.14, 0.6], ← 배경 그림 기준 0~1 비율 (핫스팟과 같다) // "standing": "roommate01", ← 대화할 때 띄울 스탠딩 // "choices": [ // { "label": "대화하기", "action": "talk", "line": "...", "tellAi": "..." }, // { "label": "선물하기", "action": "message", "text": "아직 준비 중입니다." } // ] // }] // } // line·tellAi·text 안의 {name} 은 캐릭터 이름으로 바뀐다. // // 그림 대신 모듈을 방 화면으로 쓸 수도 있다. 걸어 다니는 맵처럼 직접 그리는 화면이다. // "home": { "module": "town-map", "characters": [{ "id": "neighbor", "name": "이웃", "choices": [...] }] } // 이때 background 와 캐릭터의 rect 는 없어도 된다. 모듈이 VNCartridge.talkTo(id) 로 대화를 건다. buildHomeScene() { this.home = null; const config = this.activeCartridge?.home; if (!config || typeof config !== 'object' || Array.isArray(config)) return null; const background = String(config.background || '').trim(); const module = this.readHomeModule(config.module); if (!background && !module) { console.warn('VN Engine: 방 화면(home)에 background 도 module 도 없어 쓰지 않습니다.'); return null; } const list = Array.isArray(config.characters) ? config.characters : []; this.home = { background, module, endLabel: String(config.endLabel || '').trim().slice(0, 20) || '대화 종료', characters: list.map((item, index) => this.readHomeCharacter(item, index, Boolean(module))).filter(Boolean) }; return this.home; }, // 방 화면으로 띄울 모듈 ID. 켜져 있는 view 모듈이어야 하고, 전체 화면 무대(stage)는 안 된다. readHomeModule(value) { if (value === undefined || value === null || value === '') return null; const id = String(value).trim(); const definition = this.getModule(id); if (!definition || definition.enabled !== true || definition.type !== 'view' || !definition.entry) { console.warn(`VN Engine: 방 화면 모듈을 찾을 수 없어 쓰지 않습니다 — ${id} (켜져 있는 view 모듈이어야 합니다)`); return null; } if (definition.config && definition.config.stage === true) { console.warn(`VN Engine: 무대(stage) 모듈은 방 화면으로 쓸 수 없습니다 — ${id}`); return null; } return id; }, readHomeCharacter(item, index, rectOptional = false) { if (!item || typeof item !== 'object') return null; // 모듈 방 화면에서는 모듈이 인물을 그리므로 rect 가 없어도 된다 (있으면 그림 위에 세운다) const rect = item.rect === undefined && rectOptional ? null : this.readRect(item.rect); if (!rect && (item.rect !== undefined || !rectOptional)) { console.warn(`VN Engine: 방 화면 캐릭터 ${index + 1}번의 rect 가 올바르지 않아 ${rectOptional ? '세우지 않습니다' : '뺍니다'}.`); } if (!rect && !rectOptional) return null; const id = String(item.id || '').trim().slice(0, 80) || `character-${index + 1}`; const choices = (Array.isArray(item.choices) ? item.choices : []).map((choice, order) => { const label = choice && typeof choice === 'object' ? String(choice.label || '').trim().slice(0, 40) : ''; if (!label || !HOME_ACTIONS.includes(choice.action)) { console.warn(`VN Engine: ${id} 의 선택지 ${order + 1}번은 label 이나 action(${HOME_ACTIONS.join(' | ')})이 없어 뺍니다.`); return null; } return { id: String(choice.id || '').trim().slice(0, 80) || `choice-${order + 1}`, label, action: choice.action, line: String(choice.line || '').trim().slice(0, 500), tellAi: String(choice.tellAi || '').trim().slice(0, 2000), text: String(choice.text || '').trim().slice(0, 200) }; }).filter(Boolean); return { id, name: String(item.name || '').trim().slice(0, 40) || id, image: String(item.image || '').trim(), standing: String(item.standing || '').trim(), rect, choices }; }, // 이미지 표에서 인물로 판정되는 항목은 그 이름을 화자 이름으로도 쓴다. // "미라": { "path": "assets/c/mira01.webp", "characterId": "mira" } // → 대사 "**미라** | ..." 가 나오면 화면의 mira 를 강조한다. // 인물 판정과 ID 는 resolveImageAsset 과 같은 규칙을 따른다. buildSpeakerIndex() { this.speakerCharacterIds = new Map(); this.declaredImages.forEach((declared, key) => { const kind = declared.kind || this.assetKindForPath(declared.source); if (kind !== 'character') return; const idSource = /^data:/i.test(declared.source) ? declared.name : this.baseNameForSource(declared.source); const characterId = declared.characterId || characterIdFromBaseName(idSource); if (characterId) this.speakerCharacterIds.set(key, characterId); }); return this.speakerCharacterIds; }, // 화자 이름이 이미지 표에 있으면 그 인물 ID, 없으면 null. resolveSpeakerCharacterId(name) { const key = this.normalizeAssetName(name); return key ? (this.speakerCharacterIds.get(key) || null) : null; }, // 주소나 경로에서 확장자와 질의 문자열을 떼고 이름만 남긴다. baseNameForSource(source) { const value = String(source).split(/[?#]/)[0]; const fileName = value.slice(value.lastIndexOf('/') + 1); return fileName.replace(/\.[^.]+$/, '') || value; }, getAssetUrl(path) { if (this.objectUrls.has(path)) return this.objectUrls.get(path); const bytes = this.packageFiles.get(path); if (!bytes) return null; try { const url = URL.createObjectURL(new Blob([bytes], { type: this.mimeTypeForPath(path) })); this.objectUrls.set(path, url); return url; } catch (error) { console.error('VN Engine: 이미지 자산 URL 생성 실패', path, error); return null; } }, // ![이름] 의 이름을 카트리지 이미지 파일 이름과 대조합니다. 없으면 null. resolveImageAsset(name) { const key = this.normalizeAssetName(name); if (!key) return null; const declared = this.declaredImages?.get(key); if (declared) { const url = declared.external ? declared.source : this.getAssetUrl(declared.source); if (url) { // data: 주소에는 파일 이름이 없으므로 선언한 이름으로 인물을 묶는다. const idSource = /^data:/i.test(declared.source) ? declared.name : this.baseNameForSource(declared.source); return { name: declared.name, path: declared.source, url, kind: declared.kind || this.assetKindForPath(declared.source), characterId: declared.characterId || characterIdFromBaseName(idSource) }; } // 선언은 있는데 파일이 없으면 아래 파일 색인으로 넘어간다. console.warn(`VN Engine: 선언한 이미지를 찾을 수 없습니다 — ${declared.name} → ${declared.source}`); } const asset = this.imageAssets.get(key); if (!asset) return null; const url = this.getAssetUrl(asset.path); return url ? { ...asset, kind: this.assetKindForPath(asset.path), url } : null; }, releaseObjectUrls() { this.objectUrls.forEach(url => { try { URL.revokeObjectURL(url); } catch (error) { /* 무시 */ } }); this.objectUrls = new Map(); this.dataUrls = new Map(); }, // theme 은 manifest 안의 객체이거나 패키지 안의 JSON 파일 경로일 수 있다. resolveTheme(cartridge) { const theme = cartridge?.theme; if (!theme) return null; if (typeof theme !== 'string') return theme; const text = this.getTextFile(theme); if (text === null) { console.warn(`VN Engine: 테마 파일을 찾을 수 없습니다 — ${theme}`); return null; } try { return JSON.parse(text); } catch (error) { console.warn(`VN Engine: 테마 파일 형식이 올바르지 않습니다 — ${theme}`); return null; } }, // skin 은 패키지 안의 CSS 파일 경로다. 여러 개면 순서대로 이어 붙인다. resolveSkin(cartridge) { const skin = cartridge?.skin; if (!skin) return ''; const paths = Array.isArray(skin) ? skin : [skin]; return paths.map(path => { const text = this.getTextFile(String(path)); if (text === null) { console.warn(`VN Engine: 스킨 파일을 찾을 수 없습니다 — ${path}`); return ''; } return text; }).filter(Boolean).join('\n'); }, getModule(moduleId) { return this.activeCartridge?.modules?.[moduleId] || null; }, // 상시 모듈은 메뉴에 나오지 않는다. 카트리지가 켜지는 순간 함께 뜬다. getServiceModules() { if (!this.activeCartridge) return []; return Object.entries(this.activeCartridge.modules || {}) .filter(([, definition]) => definition.enabled === true && definition.type === 'service' && definition.entry) .map(([id, definition]) => ({ id, ...definition })); }, getViewModules() { if (!this.activeCartridge) return []; return Object.entries(this.activeCartridge.modules || {}) .filter(([, definition]) => definition.enabled === true && definition.type === 'view' && definition.entry) // config.stage 가 true 인 모듈은 전체 화면 무대로 띄우므로 메뉴 탭에는 넣지 않는다 .filter(([, definition]) => !(definition.config && definition.config.stage === true)) // 방 화면으로 쓰는 모듈도 메뉴 탭이 아니다 ('대화 종료' 로 돌아간다) .filter(([id]) => id !== this.home?.module) // 메뉴에서 숨긴 화면은 빠진다 (그 화면의 명령 토큰도 듣지 않는다) .filter(([id, definition]) => this.isInMenu(id, definition)) .map(([id, definition]) => ({ id, ...definition })); }, // 메뉴에 보일지. 모듈이 menu.show/hide 로 정한 값이 있으면 그것, 없으면 manifest 의 "menu" (false 면 숨김). isInMenu(moduleId, definition) { const cartridgeId = this.activeCartridge?.manifest?.id; const saved = cartridgeId ? CartridgeMenuStore.load(cartridgeId)[moduleId] : undefined; if (typeof saved === 'boolean') return saved; return definition?.menu !== false; }, // VNCartridge.menu.show/hide 명령. 이 카트리지의 화면 모듈만 바꿀 수 있다. setMenuVisible(moduleId, visible) { const cartridgeId = this.activeCartridge?.manifest?.id; const definition = this.getModule(moduleId); if (!cartridgeId || !definition || definition.type !== 'view') return false; CartridgeMenuStore.set(cartridgeId, moduleId, visible === true); return true; }, // VNCartridge.openView 명령이 여는 화면 모듈. 메뉴에서 숨겨 둔 화면도 연다. getOpenableModule(moduleId) { const definition = this.getModule(moduleId); if (!definition || definition.enabled !== true || definition.type !== 'view' || !definition.entry) return null; return { id: moduleId, ...definition }; }, // VN 시작과 동시에 전체 화면으로 띄울 모듈. manifest 의 config.stage 로 표시한다. getStageModule() { if (!this.activeCartridge) return null; const found = Object.entries(this.activeCartridge.modules || {}) .find(([, definition]) => definition.enabled === true && definition.type === 'view' && definition.entry && definition.config && definition.config.stage === true); return found ? { id: found[0], ...found[1] } : null; }, getTextFile(path) { const bytes = this.packageFiles.get(path); return bytes ? new TextDecoder('utf-8').decode(bytes) : null; }, getModuleData(moduleDefinition) { const result = {}; const dataFiles = moduleDefinition?.dataFiles; if (!dataFiles || typeof dataFiles !== 'object' || Array.isArray(dataFiles)) return result; Object.entries(dataFiles).forEach(([dataName, dataPath]) => { const text = this.getTextFile(dataPath); if (text === null) throw new Error(`카트리지 데이터 파일을 읽을 수 없습니다: ${dataPath}`); if (String(dataPath).toLowerCase().endsWith('.json')) { try { result[dataName] = JSON.parse(text); } catch (error) { throw new Error(`카트리지 JSON 데이터 형식이 올바르지 않습니다: ${dataPath}`); } } else { result[dataName] = text; } }); return result; }, // 모듈 화면은 allow-same-origin 이 없는 액자라 엔진의 blob: 주소를 못 쓴다. data: 주소로 바꿔 넘긴다. getDataUrl(path) { if (this.dataUrls.has(path)) return this.dataUrls.get(path); const bytes = this.packageFiles.get(path); if (!bytes) return null; let binary = ''; for (let offset = 0; offset < bytes.length; offset += 0x8000) { binary += String.fromCharCode.apply(null, bytes.subarray(offset, offset + 0x8000)); } const url = `data:${this.mimeTypeForPath(path)};base64,${btoa(binary)}`; this.dataUrls.set(path, url); return url; }, getModuleImages(moduleDefinition) { const result = {}; const images = moduleDefinition?.images; if (!CartridgeFormat.isPlainObject(images)) return result; let totalBytes = 0; Object.entries(images).forEach(([imageName, imagePath]) => { const url = this.getDataUrl(imagePath); if (!url) { console.warn(`VN Engine: 모듈 그림을 찾을 수 없습니다 — ${moduleDefinition.id}.${imageName} → ${imagePath}`); return; } totalBytes += this.packageFiles.get(imagePath).length; result[imageName] = url; }); // 화면 문서에 통째로 들어가므로 너무 크면 여는 데 오래 걸린다 if (totalBytes > 4 * 1024 * 1024) { console.warn(`VN Engine: 모듈 그림이 ${(totalBytes / 1048576).toFixed(1)}MB 입니다. 화면이 늦게 열릴 수 있습니다 — ${moduleDefinition.id}`); } return result; }, getRuntimeContext(moduleDefinition) { const cartridgeId = this.activeCartridge?.manifest?.id; if (!cartridgeId) return { state: {}, status: {} }; const status = typeof StatusInfoManager !== 'undefined' ? StatusInfoManager.getRuntimeData() : {}; // 창을 여는 순간 AI 가 이미 응답을 쓰고 있을 수 있다. 그 상태로 시작하게 한다. const ai = typeof AiActivityMonitor !== 'undefined' ? AiActivityMonitor.snapshot() : { generating: false, reason: 'idle', lastArrivedAt: 0 }; const runtime = { state: CartridgeModuleStateStore.load(cartridgeId, moduleDefinition.id), shared: CartridgeSharedStateStore.load(cartridgeId), status, ai }; // 방 화면 모듈은 들어오는 순간에 만들어지므로 처음에는 조작할 수 있는 상태다 if (moduleDefinition.id && moduleDefinition.id === this.home?.module) runtime.home = { active: true }; runtime.session = this.currentSession(); runtime.vn = { active: typeof isEngineActive !== 'undefined' ? isEngineActive === true : false, opening: false }; return runtime; }, // 지금 채팅방 { id }. 주소 끝의 24자리 ID (작품 채팅이면 에피소드, 아니면 채팅방). 없으면 null. currentSession() { return { id: (typeof getCurrentSessionId === 'function' && getCurrentSessionId()) || null }; }, // 주소(채팅방)가 바뀌었다. 열린 액자마다 새 채팅방과 그 범위의 공유 서랍을 알린다 (엔진 3.35.0). // 상시 모듈은 채팅방을 옮겨도 그대로 살아 있으므로 이 알림으로 따라간다. announceSession() { const cartridgeId = this.activeCartridge?.manifest?.id; if (!cartridgeId) return 0; return this.broadcast(cartridgeId, { type: 'session:changed', session: this.currentSession(), state: CartridgeSharedStateStore.load(cartridgeId) }); }, // VN 을 켜거나 껐다. 열린 액자(상시 모듈 포함)에 알린다 (엔진 3.37.0). // opening: 켜면서 오프닝을 틀었거나 오프닝 고르는 창을 띄웠으면 true. announceVN(active, opening = false) { const cartridgeId = this.activeCartridge?.manifest?.id; if (!cartridgeId) return 0; return this.broadcast(cartridgeId, { type: 'vn:state', vn: { active: active === true, opening: opening === true } }); }, registerFrame(frame, moduleDefinition) { if (!frame?.contentWindow || !this.activeCartridge) return; const binding = { cartridgeId: this.activeCartridge.manifest.id, moduleId: moduleDefinition.id }; this.frameBindings.set(frame.contentWindow, binding); this.liveFrames = this.liveFrames.filter(entry => entry.frame !== frame); this.liveFrames.push({ frame, ...binding }); }, // graceMs: 액자를 치우는 순간(pagehide)에 보낸 마지막 저장이 늦게 도착해도 받을 수 있게 잠시 기다렸다 지운다 unregisterFrame(frame, graceMs = 0) { const target = frame?.contentWindow; if (target) { if (graceMs > 0) setTimeout(() => this.frameBindings.delete(target), graceMs); else this.frameBindings.delete(target); } this.liveFrames = this.liveFrames.filter(entry => entry.frame !== frame); }, // 닫힌 액자는 걸러내고 살아 있는 것만 돌려준다. activeFrames() { this.liveFrames = this.liveFrames.filter(entry => entry.frame?.isConnected && entry.frame.contentWindow); return this.liveFrames; }, // 같은 카트리지의 열린 액자들에 알린다. exceptWindow 는 보낸 당사자를 뜻한다. broadcast(cartridgeId, payload, exceptWindow = null) { let delivered = 0; this.activeFrames().forEach(entry => { if (entry.cartridgeId !== cartridgeId) return; const target = entry.frame.contentWindow; if (!target || target === exceptWindow) return; try { target.postMessage({ source: 'vn-engine', ...payload, moduleId: entry.moduleId }, '*'); delivered += 1; } catch (error) { console.warn('VN Engine: 모듈에 통지하지 못했습니다.', error); } }); return delivered; }, handleMessage(event) { const binding = this.frameBindings.get(event.source); const message = event.data; if (!binding || !message || message.source !== 'vn-cartridge') return; if (message.type === 'module-state:replace') { try { const state = CartridgeModuleStateStore.save(binding.cartridgeId, binding.moduleId, message.state); event.source?.postMessage({ source: 'vn-engine', type: 'module-state:saved', requestId: message.requestId || null, state }, '*'); } catch (error) { console.error('VN Engine: 모듈 상태 저장 실패', error); event.source?.postMessage({ source: 'vn-engine', type: 'module-state:error', requestId: message.requestId || null, message: error.message }, '*'); } return; } if (message.type === 'shared-state:patch') { try { const state = CartridgeSharedStateStore.patch(binding.cartridgeId, message.patch); event.source?.postMessage({ source: 'vn-engine', type: 'shared-state:saved', requestId: message.requestId || null, state }, '*'); // 같은 카트리지의 다른 창에도 바뀐 값을 알린다. this.broadcast(binding.cartridgeId, { type: 'shared-state:changed', state, from: binding.moduleId }, event.source); } catch (error) { console.error('VN Engine: 공유 상태 저장 실패', error); event.source?.postMessage({ source: 'vn-engine', type: 'shared-state:error', requestId: message.requestId || null, message: error.message }, '*'); } return; } if (message.type === 'module-event:emit') { const moduleEvent = message.event; if (!moduleEvent || typeof moduleEvent !== 'object' || !String(moduleEvent.type || '').trim()) { event.source?.postMessage({ source: 'vn-engine', type: 'module-event:error', requestId: message.requestId || null, message: '사건에는 type 이 필요합니다.' }, '*'); return; } let payload = null; try { // 글자로 옮길 수 있는 값만 건넨다. 함수나 순환 참조는 여기서 걸린다. payload = moduleEvent.payload === undefined ? null : JSON.parse(JSON.stringify(moduleEvent.payload)); } catch (error) { event.source?.postMessage({ source: 'vn-engine', type: 'module-event:error', requestId: message.requestId || null, message: '사건 내용을 전달할 수 없습니다.' }, '*'); return; } const delivered = this.broadcast(binding.cartridgeId, { type: 'module-event', event: { type: String(moduleEvent.type).trim(), payload }, from: binding.moduleId }, event.source); event.source?.postMessage({ source: 'vn-engine', type: 'module-event:sent', requestId: message.requestId || null, delivered }, '*'); return; } if (message.type === 'shared-state:read') { event.source?.postMessage({ source: 'vn-engine', type: 'shared-state:value', requestId: message.requestId || null, state: CartridgeSharedStateStore.load(binding.cartridgeId) }, '*'); return; } if (message.type === 'stage:close') { StageOverlay.close(); return; } // 방 화면 모듈이 인물에게 말을 건다. 방 화면이 떠 있을 때 그 모듈만 걸 수 있다. if (message.type === 'home:talk') { const result = HomeScene.talkFromModule(binding.moduleId, message.characterId, message.choiceId); event.source?.postMessage({ source: 'vn-engine', type: 'home:talk-result', requestId: message.requestId || null, ...result }, '*'); return; } // 카트리지가 VN 화면에 대본을 틀어 달라고 한다. 오프닝과 같은 재생기로 튼다 (엔진 3.34.0). // 끝나면 StageManager 가 같은 카트리지의 모든 창에 script:ended 를 알린다. if (message.type === 'script:play') { const scriptId = String(message.scriptId || ''); const refuse = reason => event.source?.postMessage({ source: 'vn-engine', type: 'script:ended', scriptId, state: 'refused', reason }, '*'); if (binding.cartridgeId !== this.activeCartridge?.manifest?.id) { refuse('cartridge'); return; } if (typeof isEngineActive !== 'undefined' && !isEngineActive) { refuse('inactive'); return; } const content = String(message.content || ''); if (!content.trim()) { refuse('empty'); return; } // 지도처럼 열어 둔 창은 닫는다. 「\항해」 로 보류해 둔 글은 그대로 두었다가 카트리지가 commitChat 할 때 보낸다. if (ModuleOverlay.isOpen()) ModuleOverlay.close({ keepPending: true }); StageManager.start(content, { cartridgeScript: { id: scriptId, cartridgeId: binding.cartridgeId } }); return; } // VN 화면에 선택지를 띄운다. 고른 것은 부탁한 창에만 돌려준다 (엔진 3.34.0). if (message.type === 'choice:show') { const reply = result => event.source?.postMessage({ source: 'vn-engine', type: 'choice:chosen', requestId: message.requestId || null, ...result }, '*'); if (binding.cartridgeId !== this.activeCartridge?.manifest?.id) { reply({ ok: false, reason: 'cartridge' }); return; } if (typeof isEngineActive !== 'undefined' && !isEngineActive) { reply({ ok: false, reason: 'inactive' }); return; } if (!UIManager.showScriptChoices(message.options, { placeholder: message.placeholder }, reply)) reply({ ok: false, reason: 'empty' }); return; } // 카트리지가 크랙에 대화를 보내 달라고 한다. 엔진의 대화 입력과 같은 길로 보낸다 (엔진 3.37.0). // 명령 토큰(\항해 등)은 보지 않는다. 실패해도 경고창을 띄우지 않고 까닭을 돌려준다. if (message.type === 'chat:send') { const reply = (ok, reason = '') => event.source?.postMessage({ source: 'vn-engine', type: 'chat:sent', requestId: message.requestId || null, ok, reason }, '*'); if (binding.cartridgeId !== this.activeCartridge?.manifest?.id) { reply(false, 'cartridge'); return; } if (typeof isEngineActive !== 'undefined' && !isEngineActive) { reply(false, 'inactive'); return; } const body = String(message.text || '').trim(); if (!body) { reply(false, 'empty'); return; } if (body.length > 16000) { reply(false, 'too-long'); return; } if (UIManager.isSendingMessage) { reply(false, 'busy'); return; } Promise.resolve(UIManager.sendMessage({ fromPending: true, message: body, silent: true })) .then(sent => reply(sent === true, sent === true ? '' : 'failed'), () => reply(false, 'failed')); return; } if (message.type === 'chat:commit') { const sent = UIManager.commitPendingChat(message.options); event.source?.postMessage({ source: 'vn-engine', type: 'chat:committed', requestId: message.requestId || null, sent }, '*'); return; } if (message.type === 'view:close') { ModuleOverlay.close(); return; } // 카트리지가 엔진에 부탁하는 일 (openView · menu.show/hide · showImage · playMusic/stopMusic). // 지금 켜진 카트리지의 모듈만 부탁할 수 있고, 그 카트리지의 화면 · 그림 · 소리 파일에만 작동한다. const replyTypes = { 'view:open': 'view:opened', 'menu:set': 'menu:updated', 'image:show': 'image:shown', 'music:play': 'music:played', 'music:stop': 'music:stopped' }; if (replyTypes[message.type]) { const reply = (type, ok) => event.source?.postMessage({ source: 'vn-engine', type, requestId: message.requestId || null, ok }, '*'); if (binding.cartridgeId !== this.activeCartridge?.manifest?.id) { reply(replyTypes[message.type], false); return; } if (message.type === 'music:play') { reply('music:played', AudioManager.play(String(message.src || ''), { loop: message.loop !== false })); return; } if (message.type === 'music:stop') { AudioManager.stopAll(); reply('music:stopped', true); return; } if (message.type === 'view:open') { const definition = this.getOpenableModule(String(message.moduleId || '')); if (definition) ModuleOverlay.open(definition, { fullscreen: message.fullscreen === true }); else console.warn(`VN Engine: 열 수 있는 화면 모듈이 아닙니다 — ${message.moduleId}`); reply('view:opened', Boolean(definition)); } else if (message.type === 'menu:set') { const ok = this.setMenuVisible(String(message.moduleId || ''), message.visible === true); if (!ok) console.warn(`VN Engine: 메뉴에 둘 수 있는 화면 모듈이 아닙니다 — ${message.moduleId}`); reply('menu:updated', ok); } else { const ok = UIManager.showCartridgeImage(String(message.name || ''), String(message.label || '')); reply('image:shown', ok); } return; } if (message.type === 'ai-context:withdraw') { const removed = AiMessageContextQueue.withdraw(binding.cartridgeId, message.id); event.source?.postMessage({ source: 'vn-engine', type: 'ai-context:withdrawn', requestId: message.requestId || null, removed }, '*'); return; } if (message.type === 'ai-context:enqueue') { try { const item = AiMessageContextQueue.enqueue(binding.cartridgeId, binding.moduleId, message.event); event.source?.postMessage({ source: 'vn-engine', type: 'ai-context:queued', requestId: message.requestId || null, itemId: item.id }, '*'); } catch (error) { console.error('VN Engine: AI 문맥 등록 실패', error); event.source?.postMessage({ source: 'vn-engine', type: 'ai-context:error', requestId: message.requestId || null, message: error.message }, '*'); } } }, createViewDocument(moduleDefinition) { const html = this.getTextFile(moduleDefinition.entry); if (!html) return '
모듈 화면 파일을 읽을 수 없습니다.
'; const context = { cartridge: this.activeCartridge ? { id: this.activeCartridge.manifest.id, name: this.activeCartridge.manifest.name, version: this.activeCartridge.manifest.version } : null, module: { id: moduleDefinition.id, title: moduleDefinition.title || moduleDefinition.id, version: moduleDefinition.version, config: moduleDefinition.config || {} }, data: this.getModuleData(moduleDefinition), images: this.getModuleImages(moduleDefinition), runtime: this.getRuntimeContext(moduleDefinition) }; const serialized = JSON.stringify(context) .replace(/window.VNCartridgeContext=${serialized};<\/script>` + `