렌더러는 인터프리터에 등록되는 엔진 함수의 구현체이며, HTML DOM + CSS + WebAudio로 연출을 수행한다. HTML Canvas와 WebGL은 사용하지 않는다. (SVG 및 SVG 필터는 DOM의 일부로 보고 사용한다)
<div id="game-area"> <!-- 호스트 제공 루트 -->
<div class="hg-viewport"> <!-- letterbox, 배경색 black -->
<div class="hg-stage"> <!-- 가상 스테이지. transform: scale()로 맞춤 -->
<div class="hg-plane hg-plane-scene">
<div class="hg-scene hg-scene-back"></div> <!-- 현재 장면 -->
<div class="hg-scene hg-scene-front"></div> <!-- 트랜지션 유입 장면 -->
</div>
<div class="hg-plane hg-plane-layers">
<!-- DrawBustshot/DrawSprite/DrawFilm 대상. layer 번호별로 동적 생성 -->
<div class="hg-layer" data-layer="5"><img …></div>
</div>
<video class="hg-movie" hidden></video>
<div class="hg-window"></div> <!-- 텍스트 창 배경 (ADV/NVL) -->
<div class="hg-message">
<div class="hg-name"></div>
<div class="hg-text"></div>
</div>
<div class="hg-choices" hidden></div> <!-- Select() -->
<div class="hg-effects"></div> <!-- Negative 등 전화면 이펙트 -->
</div>
</div>
<svg class="hg-filter-defs" width="0" height="0"><!-- 마스크 와이프용 필터 --></svg>
</div>.hg-stage는 고정 크기의 가상 스테이지(기준 1280×720,SetScreenAspect반영)로 두고, 뷰포트 크기 변화는transform: scale()하나로 처리한다. 내부 좌표 계산이 리사이즈와 완전히 분리된다.- 스태킹은 plane 순서 +
.hg-layer의z-index(= 스크립트 layer 번호 우선순위)로 결정한다.
스크립트 좌표(엔진 단위)를 StageCoords 모듈 한 곳에서만 CSS 좌표로 변환한다.
M4에서 원본 엔진 소스 대조로 캘리브레이션을 확정했다. 초안의 "800×600 단일 논리 좌표"는 오류였다 — 원본은 높이가 다른 두 개의 좌표 공간을 쓴다.
| 공간 | 대상 함수 | 높이 | 폭 | y축 |
|---|---|---|---|---|
| 씬 공간 | DrawBustshot/MoveBustshot/DrawSprite 등 레이어 좌표 |
480 | 480 × aspect |
아래가 양수 (엔진이 부호 반전) |
| GUI 공간 | SetWindowPos/SetWindowSize/SetGUIPosition/마진·폰트 |
720 | 720 × aspect |
위가 양수 (반전 없음) |
둘의 비는 정확히 1.5다. 두 공간 모두 원점은 화면 중앙이고 x는 오른쪽이 양수다.
근거 (07th-mod/higurashi-assembly, mea-mod):
SceneController.UpdateScreenSize()의num6 = 2f / (scale * 480f). 직교 카메라 (size 1)의 가시 높이가 2이므로 씬 480단위 = 화면 높이다.BlackBars.UpdatePosition()의400f + 480f * AspectRatio / 2f— 폭 800짜리 검은 띠의 안쪽 모서리가480 * aspect / 2에 정확히 놓인다. 즉 씬 폭 = 480 × aspect.Layer.MoveLayer()의y2 = (float)(-y)— 씬 y는 부호가 반전된다. 반면MainUIController.SetWindowPos()는new Vector3(x, y, 0f)로 반전이 없다.- GUI 720은 NGUI UIRoot의 관례값이며, 코퍼스가 교차 검증한다 — 16:9에서 GUI 폭은
720 × 16/9 = 1280이고 init.txt의 NVLWindowSizeX가 1240(좌우 20씩 여백),WindowSizeY가 720(전체 높이)이다. 씬 공간(480)이었다면 성립하지 않는다. 검은 띠 위치도 GUI 단위로 환산하면±640= GUI 폭의 절반으로 맞아떨어진다.
파생 규칙:
- z → 스케일:
scale = 1 - z/400. 원본은z > 0과z < 0을 나눠 쓰지만 (1f - z/400f/1f + z/-400f) 두 식은 대수적으로 같다 (Layer.MoveLayer). SetScreenAspect(s):aspect = 1 / parseFloat(s)."0.5625"→ 16:9.SetGUIPosition(x, y): GUI 패널 전체의 오프셋이며 창 좌표는 그 위에 얹힌다. init.txt의SetGUIPosition(170, 0)+WindowPosX = -170은 서로 상쇄되어 창이 중앙에 온다 — 두 값을 따로 보면 안 된다.- CSS 배치:
left: calc(50% + Xpx)+transform: translate(-50%, -50%)의 중앙 기준 배치로 통일..hg-stage는 GUI 공간(기준 1280×720)을 픽셀로 쓰고, 씬 좌표는StageCoords가 ×1.5 하여 같은 공간으로 올린다.
미확정으로 남은 것: 텍스트 컨테이너의 앵커(피벗). 원본은 TMPro
TextContainer이고 기본값은 Middle이지만, 실제 값은 코드가 아니라 Unity 씬
프리팹에 있어 소스만으로는 정할 수 없다. Middle로 구현해 두었다.
M6에서도 해소되지 않았다 — 이것은 원작 화면과의 목시 대조로만 정해지며,
M6의 검증은 연출 수식(경계 조건·행렬·진폭)이 대상이었다. 같은 성질의 미확정
항목이 M6에서 셋 더 늘었다(§5.3의 EffectCamera culling mask, §5.4의
SceneCameras 부모, MGShader/*의 정확한 계수). 전부 Unity 프리팹/에셋 쪽이다.
원본 엔진은 layer 번호(0~63)의 슬롯에 텍스처를 놓는 모델이다. 렌더러는
LayerManager가 번호 → .hg-layer DOM 요소 매핑을 관리한다.
interface LayerState {
texture: string | null; // 해석 전 논리 이름 ("sprites/si1b_04" 등)
x: number; y: number; z: number;
opacity: number; // 0~1
priority: number; // DrawBustshot의 priority 인자 → z-index
filter?: LayerFilter; // ModSetLayerFilter (grayscale/sepia 등 → CSS filter)
flipped?: boolean;
}| 스크립트 개념 | 구현 |
|---|---|
장면(Scene) — DrawScene, DrawSceneWithMask, ChangeScene, DrawBG |
scene plane의 back/front 2매 스왑. DrawScene류는 완료 시 모든 레이어를 클리어한다(원본 시맨틱 — 04 문서에서 검증) |
버스트샷 — DrawBustshot*, ChangeBustshot, FadeBustshot*, MoveBustshot, ModDrawCharacter |
.hg-layer[data-layer=N]의 <img> 교체/페이드/이동 |
스프라이트 — DrawSprite*, FadeSprite, MoveSprite |
동일 레이어 시스템 사용 (타이틀 로고 등) |
필름 오버레이 — DrawFilm, FadeFilm |
전화면 틴트/비네트 레이어. CSS background-color + mix-blend-mode |
흔들림 — StartShakingOfWindow, StartShakingOfAllObjects |
대상 plane/window에 rAF 기반 랜덤 오프셋 (지속시간·진폭은 인자) |
화면 반전 — Negative |
.hg-stage에 filter: invert(1) 토글 + 지속시간 |
이동/페이드 애니메이션은 CSS transition을 쓰지 않고 rAF 트위너(Tweener)로
구현한다. 이유: (1) 진행 중 상태를 수치로 스냅샷/복원해야 함, (2) 스킵 시 즉시
완료(fast-forward)가 필요함, (3) wait=FALSE 호출 뒤 도착하는 후속 명령과의
경합을 결정적으로 처리해야 함. 모든 트윈은 Tweener에 등록되고
finishAll()(스킵), snapshot()(세이브)을 지원한다.
다수의 그래픽 함수는 마지막 인자로 wait(TRUE/FALSE)를 받는다.
wait=TRUE: 애니메이션 완료 시 resolve되는{ kind: "effect", promise }반환.wait=FALSE: 트윈을 시작만 하고 즉시 반환. 트윈은 백그라운드로 진행.- 같은 레이어에 새 명령이 오면 진행 중 트윈은 즉시 완료 처리 후 새 명령을 적용한다(원본 엔진의 동작 관례).
PlaybackPolicy.effectSpeedup/스킵 모드에서는 트윈 duration을 클램프한다.
front 요소의 opacity를 rAF 트윈. 완료 후 back으로 승격.
마스크 PNG(그레이스케일 램프)의 휘도 순서대로 새 이미지가 드러나는 와이프이다.
코퍼스 최대의 M6 항목이다 — DrawSceneWithMask 472회(마스크 13종),
*WithFiltering 계열 11회.
Scene.SetTransitionMask() / Scene.StartTransition()이 전부다:
sceneMaterial.SetTexture("_Mask", texture);
sceneMaterial.SetFloat("_Fuzzyness", 0.45f); // 하드코딩 상수
iTween.ValueTo(… "from", 0f, "to", 1f, "time", time, // _Range를 0→1로
"onupdate", "UpdateRange" …); // → SetFloat("_Range", r)_Range는 선형으로 쓸린다. iTween의 기본 easeType은 easeOutExpo지만
(iTween.Defaults.easeType), ValueTo는 easetype 인자가 없으면
EaseType.linear를 스스로 채워 넣는다(iTween.cs의 ValueTo 본체).
Layer의 페이드도 같은 ValueTo 경로이므로, M4에서 넣은 선형 트윈이 원본과
맞다는 것이 역으로 검증된다.
MGShader/SceneFadeWithMask는 Unity 에셋이라 저장소에 .shader 파일이 하나도
없다. 다만 파라미터 3개와 경계 조건이 형태를 사실상 강제한다:
Range=0에서 아무것도 보이지 않아야 한다.Range=1에서 휘도 255인 픽셀까지 전부 보여야 한다.Fuzzyness는 경계의 소프트니스이며 유일한 폭 파라미터다.
이 셋을 만족하는 표준형:
alpha = clamp((Range × (1 + Fuzz) − mask) / Fuzz, 0, 1) Fuzz = 0.45
어두운 픽셀부터 드러난다. feFuncA type="linear"가 slope×A + intercept를
[0,1]로 클램프하므로 그대로 한 프리미티브에 들어간다 —
slope = −1/Fuzz, intercept = Range × (1+Fuzz)/Fuzz.
<filter id="…" x="0%" y="0%" width="100%" height="100%"
color-interpolation-filters="sRGB">
<feImage href="…/maskleft.png" result="mask" preserveAspectRatio="none"
x="0%" y="0%" width="100%" height="100%"/>
<feColorMatrix in="mask" type="matrix" result="lum"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .2126 .7152 .0722 0 0"/>
<feComponentTransfer in="lum" result="cut">
<feFuncA type="linear" slope="-2.2222" intercept="{Range * 3.2222}"/>
</feComponentTransfer>
<feComposite in="SourceGraphic" in2="cut" operator="in"/>
</filter>초안은 slope="{S}" intercept="{-S * (1 - t)}", 즉 alpha = S(lum − 1 + t)였다.
- 방향이 반대다 — 밝은 픽셀부터 드러난다.
- t=1에서 휘도 0인 픽셀이 영영 드러나지 않는다 (
alpha = S×0 = 0). 이론적 흠이 아니라 실제로 발화한다: 코퍼스가 28회 쓰는mask1900은 최소 휘도가 0이고,maskaa(14회)는 96%가 휘도 0이다. 그 부분이 영영 전환되지 않는다.
effects.test.ts가 두 경계 조건을 직접 단언한다.
color-interpolation-filters="sRGB"를 고정한다. SVG 기본값은 linearRGB라
브라우저가 디감마를 먹여 램프가 휘어 버린다. 원시값이 맞다는 근거는 마스크
자신에 있다 — 코퍼스의 마스크를 디코드해 보면 maskleft/maskup의 휘도
누적분포가 원시 바이트 공간에서 선형이다(선형으로부터의 최대 편차
0.014~0.05). 작화자가 원시값으로 균일한 램프를 그렸다는 뜻이고, 엔진이 디감마를
먹였다면 와이프가 눈에 띄게 불균일해진다.
| 마스크 | 빈도 | 휘도 범위 | 형태 |
|---|---|---|---|
maskleft / maskright |
116 / 107 | 18 |
수평 램프 |
maskup / maskdown |
91 / 66 | 22 |
수직 램프 |
mask1900 |
28 | 0~255 | — |
maskc |
22 | 22~255 | 중앙이 밝은 방사 |
maskm1 / maskaa |
17 / 14 | 6~236 / 0~160 (96%가 0) | — |
logomask 등 |
소수 | 91~255 | — |
휘도가 0~255를 꽉 채우지 않는다는 것이 요점이다. Fuzzyness가 0.45로 넓은
덕에 위 수식은 이 범위에서도 양 끝이 성립한다.
feImage가 실제로 도는지가 이 방식의 유일한 기술 리스크였다 —
Chrome과 Firefox 양쪽에서 확인했다. 경계 조건도 실물로 단언했다:
| 검사 | 결과 |
|---|---|
range=0 |
화면 전체가 back 그대로 — 아무것도 드러나지 않음 |
range=0.5 (maskleft) |
어두운 쪽(오른쪽)이 드러나고 밝은 쪽(왼쪽)이 남음. 소프트 띠의 위치가 Fuzzyness=0.45의 계산치(x≈267~965)와 일치 |
range=1 (mask1900, 최소 휘도 0) |
화면 전체가 드러남 — 초안 수식이었으면 검은 얼룩이 영영 남았을 자리 |
마스크(1920×1080)와 스테이지(1280×720)가 둘 다 16:9라 preserveAspectRatio="none"
의 늘림이 항등에 가깝다. 다른 종횡비 마스크가 오면 늘어난다 — 원본도 그렇다.
방어 코드가 아니라 실제 경로다. &choicesdialog.txt(도입부 경로)가 쓰는
마스크 "left"는 OGBackgrounds/(Original 아트셋)에만 있고 기본값인 Console
아트셋(CG/)에는 없다. 원본도 같은 자리에서 마스크를 못 찾는다. 따라서
마스크 미해석은 경고를 내지 않는다 — 정상 재생에 경고가 섞이면 경고가
신호이기를 그만둔다.
*WithFiltering 계열의 style 인자는 셰이더로 가지만 본체가 없어 값별 의미를
확정할 수 없다. 읽고 버린다(arity 검증은 유지).
원본 FilmEffector는 EffectCamera에 붙는 전화면 포스트프로세스다
(OnRenderImage → Graphics.Blit(source, dest, SceneMaterial)).
effecttype이 셰이더를 고르고 _Power가 0 → power/256으로 선형 트윈된다.
| effecttype | 셰이더 | _Color |
Ep05 |
|---|---|---|---|
| 0, 1 | MGShader/EffectColorMix |
세운다 | 미사용 |
| 2 | MGShader/DrainColor |
세운다 | DrawFilm 16회 전부 |
| 3 | MGShader/Negative |
안 세운다 | Negative 11회 |
| 10 / 12 | HorizontalBlur2 / GaussianBlur |
안 세운다 | 미사용 |
Ep05의 파라미터 공간은 거의 축퇴돼 있다 — DrawFilm 16회가 전부
effecttype=2, power=256, style=0이고 색은 전부 회색(200,200,200 11회 +
0x88/0xaa/0x77/0x44 각 1회)이다. 즉 화면을 좌우하는 것은 2와 3 둘뿐이다.
Negative는 초안의 주장이 맞았다 — 원본 OperationNegative가
effecttype=3, Color(1,1,1), power=255, style=0을 고정으로 넣어
CreateFilmEffector를 부른다(추측이 아니라 검증됨).
MGShader/*는 Unity 에셋이라 higurashi-assembly에 .shader 파일이 하나도
없다. 그러나 컴파일된 GLSL/Metal이 Data/resources.assets에 텍스트로 박혀
있다(globalgamemanagers에도). LZ4 압축이라 완전 복원은 못 했지만 다음을
확인했다:
- DrainColor 프래그먼트에
* 0.3리터럴이 실재한다. - Rec.709 마커(
0.2126/0.7152)는 파일 전체에 0건이다. - 셰이더가 ShaderForge로 저작됐다(
ShaderForgeMaterialInspector문자열). ShaderForge의 desaturate 노드는 NTSC 가중치(0.3, 0.59, 0.11)을 쓴다.
셋이 같은 방향을 가리키므로 NTSC를 쓴다. 0.59/0.11은 LZ4 백레퍼런스에 먹혀 직접 읽지 못했으므로 여전히 추론이다.
주의: 원본 안에 두 휘도 관례가 공존한다. Filter.Grayscale(55/185/18 =
0.215/0.723/0.070)은 Rec.709 근사인데, 이쪽은 C# CPU 루프이고 DrainColor는
ShaderForge 셰이더라 출처가 다르다. 하나로 통일하지 말 것.
DrainColor : out = lerp(src, luminance(src) × color, power) luminance = NTSC
Negative : out = lerp(src, 1 − src, power) = src × (1 − 2p) + p
ColorMix : out = lerp(src, color, power) = src × (1 − p) + p × color
power = 0이 항등이라는 것은 페이드 구조가 강제한다 — FadeOut이 power를 0으로
몰고 가고, 새 이펙터의 트윈도 0에서 시작한다. 항등이 아니면 모든 페이드의 양 끝에
팝이 보인다.
셋 다 RGB에 대한 선형 연산이라 feColorMatrix 하나에 접힌다 — 오프셋 열이
상수항을 받아 주므로 Negative의 1 − src도 행렬이 된다. Canvas 금지 제약 아래
전화면 포스트프로세스가 성립하는 것이 이 성질 덕이다.
FilmEffector.Prepare()는 effecttype 3에서 flag = false로 두므로
SetColor("_Color", …)가 아예 실행되지 않는다. 즉 Negative가 넘기는
Color(1,1,1)은 셰이더에 도달하지 않는다 — 흰색 틴트로 구현하면 안 된다.
그리고 power = 255는 1.0이 아니라 255/256 = 0.99609375다. Negative는 완전
반전에서 0.4% 모자란다. 눈에 보이진 않지만 원본이 그렇다.
- DrainColor (
DrawFilm(2, 200,200,200, 256, 0, …)— 코퍼스 최빈 형태): 채도가 높은 배경이 회색으로 빠지며 200/255만큼 어두워진다. 행렬 세 행이(0.3, 0.59, 0.11) × 0.784로 같고 행 합이 정확히 0.784다. - Negative: 파란 하늘이 주황으로 완전히 반전된다. 행렬은 대각
1−2p = −0.992, 오프셋p = 0.996(= 255/256). - 필름이 텍스트 창에 닿지 않는다 — 창이
.hg-scene-group밖에 있어 구조적으로 보장된다. 다만 이것이 원본과 같은지는EffectCamera의 culling mask가 프리팹 쪽이라 여전히 미확정이다(아래).
원본에서 둘 다 씬 카메라 스택에 걸리므로(FilmEffector→EffectCamera,
Shaker→SceneCameras), 씬 + 레이어를 감싼 래퍼 하나가 대상이 된다.
텍스트 창은 이 밖에 있다 — 원본에서 창은 별도의 NGUI 카메라가 그리므로
필름도 씬 흔들림도 창에 닿지 않는다.
단 EffectCamera의 culling mask는 Unity 프리팹에 있어 소스로 확정할 수 없다
(§2의 TextContainer 앵커와 같은 부류). 목시 검증 대상이다.
원본 Shaker는 결정적 진동이다. vector는 랜덤성이 아니라 방향 열거이며,
랜덤은 vector=4 한 갈래뿐이다 — 그리고 Ep05는 4를 한 번도 쓰지 않는다.
| vector | 방향 | Ep05 |
|---|---|---|
| 0 | 수평 (±x) | 22회 |
| 1 / 3 | 대각 / 역대각 | 미사용 |
| 2 | 수직 (±y) | 90회 |
| 4 | 랜덤 (x, y 각각 Random.Range(−level, level)) |
미사용 |
- 운동:
srcPos ± intensity를 오가는 왕복, 구간 보간은Sine.EaseInOut(c/2 × (1 − cos(πt)) + b). - 주기:
speed = (256 − speedFactor) × 5 / 1000초 — 초안의 식이 맞았다. 코퍼스 224회 중 222회가speedFactor=248→ 40ms. - 감쇠: 스윙마다
intensity *= 1 − attenuation/100. Ep05는 224회 전부 0이라 감쇠가 발화하지 않는다. - 총 길이:
swing×loop + 2×(loop×0.005) + swing/2. 최빈 형태(248, loop 4)에서 220ms.loopcount=0이면 무한(Ep05 미사용). - 첫 스윙은 반 박자다 — 원본이
timetoswitch = timeperswing/2로 시작하는데 진행도를1 − timetoswitch/timeperswing으로 계산하므로 첫 스윙이 진행도 0.5(사인 이징의 속도 최대점)에서 시작한다. 첫 타격이 툭 튀어나오는 것이 이것이다.
M4에서 찾은 두 좌표 공간(§2)이 여기서 다시 나타난다.
| 함수 | 원본 대상 | 공간 | level=40의 실제 |
|---|---|---|---|
StartShakingOfWindow |
NGUI 메인 패널 | GUI (×1) | 40px |
StartShakingOfAllObjects |
씬 카메라 | 씬 (×1.5) | 60px |
카메라 쪽은 부호가 뒤집힌다 — 카메라가 오른쪽으로 가면 내용은 왼쪽으로
밀린다. y는 Unity(위가 양수) → CSS(아래가 양수)로 한 번 더 뒤집히므로 결과적으로
(x, y) → (−x, +y)다. 즉 같은 인자인데 창과 씬이 반대로 움직인다. 원본이
그렇다 — 코퍼스가 두 함수에 같은 level을 넘기기 때문이다. 12.5Hz에서 위상은
지각되지 않으므로 "전부 흔들린다"로 읽힌다.
SceneCameras가 480 스케일 루트의 자식이라는 것은 소스로 확정할 수 없다
(프리팹). 다만 크기가 강제한다 — 아니라면 level 40이 화면 높이 20배가 된다.
코퍼스는 둘을 쌍으로 부른다 — Window(112회 전부 논블로킹) 직후
AllObjects(111/112 블로킹). 이건 실수가 아니라 관용구다: 원본의 RegisterAction이
액션을 모아 두고 ExecuteActions가 한꺼번에 흘리므로, 논블로킹 Window가 큐에
쌓인 뒤 블로킹 AllObjects가 둘을 같은 프레임에 함께 시작시킨다.
설계의 예측이 실측과 전부 맞았다 (level=40, speedFactor=248, vector=2,
loopcount=4 — 코퍼스 최빈 형태):
| 예측 | 실측 |
|---|---|
| 씬 진폭 = 40 × 1.5 = 60px | ±60px |
| 창 진폭 = 40 × 1 = 40px | ±40px |
| 창과 씬이 반대 부호 | sceneY=+60 ↔ winY=−40 |
| 블로킹 길이 220ms | 229ms |
| 종료 시 원점 복귀 | 0px / 0px |
GADVMode플래그로 모드 결정.ModADVModeSettingLoad/ModNVLModeSettingLoad(+ModNVLADVModeSettingLoad)의 13개 인자 (NameFormat, 창 위치/크기/마진, FontID, 자간, 행간, 폰트 크기)를WindowConfig로 보관하고.hg-window/.hg-message의 스타일에 반영한다.- 창 배경 불투명도는
GWindowOpacity플래그(0~100). DisableWindow/DisplayWindow는 창+메시지의 표시 토글이며, 표시 상태는 스냅샷 대상이다.
OutputLine(name, jpText, altName, altText, mode):
- 언어 플래그(
GLanguage)에 따라 (jp | alt) 텍스트·이름 선택. - 이름은
NameFormat(예:"<size=+4>{0}\n</size>")의{0}에 치환하여 표시. - 본문을 타이프라이터로 출력(§6.3),
mode에 따라 입력 대기 여부 결정.
| mode | 값 | 동작 |
|---|---|---|
Line_Normal |
0 | 타이핑 → 클릭 대기({ kind: "input" } 서스펜션) → 클릭 시 창 클리어 |
Line_Continue |
1 | 이어붙임. 타이핑 완료조차 기다리지 않고 즉시 다음 문장 |
Line_WaitForInput |
2 | 타이핑 → 클릭 대기. 클릭 후 창을 지우지 않고 이어씀(누적) |
Line_ContinueAfterTyping |
3 | 타이핑 완료까지만 대기(effect 서스펜션), 클릭 대기 없음 |
(원본 BurikoTextModes 열거값 확인 완료 — 04-engine-functions.md §2.
GetGlobalFlag(GLinemodeSp)처럼 플래그값이 mode 인자로 직접 전달되는 패턴이
있으므로, mode는 int로 받아 열거로 해석한다)
ClearMessage는 메시지 영역을 비우고 페이지를 넘긴다(백로그에 페이지 확정).
OutputLineAll은 이름 없이 텍스트를 추가한다(개행 삽입 "\n" 관용구 포함).
- 태그 파싱 후 텍스트를 grapheme cluster 단위(
Intl.Segmenter)로 분할,<span class="hg-ch">로 감싸visibility를 rAF로 순차 공개한다. (요소 수 과다 시를 대비해 "공개 경계 span 2분할" 최적화를 대안으로 기록) - 클릭/스킵 시 즉시 전체 공개.
SetValidityOfInput(FALSE)구간은 클릭 무시 (연출용 강제 대기 — 코퍼스에서Wait과 조합해 3,181회 사용). - 보이스 재생 중 여부와 무관하게 타이핑은 진행하되, 오토 모드는 보이스 종료를 기다린다.
글자당 지연(ms) = 25 × (100 − speed) / 100 × 2 × (영문이면 1, 아니면 2)
글자 페이드인 = 150ms (스킵 중 0)
speed는OverrideTextSpeed(≠ −1일 때) → 오토 모드면AutoSpeed→ 아니면TextSpeed(=PlaybackPolicy.textSpeed, 기본 50) 순으로 고른다.- 기본값(50, 비영문)이면 글자당 50ms다.
- 타이핑 대기 시간은 태그를 뺀 본문 글자만 센다. 태그(
<~>) 구간은 원본과 같이 시간을 소비하지 않고 즉시 표시한다. 이미 개행으로 치환된\n도 지연 0으로 둔다 — 원본은\+n두 글자를 이스케이프로 보고 즉시 표시하므로 결과가 같다.
원본 TextController.SetText()는 NameFormat + 본문을 이어 붙인 뒤
CreateText()가 AddText(text, displayimmediate: text2.Length, …)를 부른다.
여기서 text2는 형식이 적용된 이름 전체이고 displayimmediate는 "지연 없이
표시할 글자 수"다. 의도는 "이름 부분은 즉시 표시"였을 것이다.
그런데 AddText()에서 태그 문자는 displayimmediate를 소비하지 않는다
(<~> 구간은 별도 분기로 즉시 표시된다). 그래서 카운터에 NameFormat의 태그
문자 수만큼이 남아 본문으로 새어 나간다. ADV의
NameFormat = "<size=+4>{0}\n</size>"는 태그 문자가 정확히 16자이므로 —
이름 길이와 무관하게(소비량 = 보이는 글자 수) — 매 페이지 첫 줄의 본문 16글자가
타이핑 없이 즉시 표시된다.
이것은 재현하지 않는다. 이름의 보이는 글자만 즉시 표시하고 본문은 첫 글자부터
타이핑한다. 04-engine-functions.md의 SetSpeedOfMessage
정수 나눗셈 퀵은 "연출 의도가 담긴 관측 가능한 동작"이라 재현하지만, 이쪽은
연출 의도가 명백히 반대(이름만 즉시 표시하려던 것)인 순수 결함이고, 재현하면
본편 대부분의 대사 첫 줄이 깨진 채로 나온다. 원작과 다른 화면이 되는 것을 알고
내리는 결정이다.
원본은 OverrideTextSpeed = 50 * (speed / 100)을 C# 정수 나눗셈으로 계산한다
(BurikoScriptFile.OperationSetSpeedOfMessage). 따라서:
| 코퍼스 인자 | 빈도 | speed / 100 |
OverrideTextSpeed |
실효 |
|---|---|---|---|---|
(FALSE, 0) |
83 | — | −1 (해제) | 사용자 설정 |
(TRUE, 16) |
31 | 0 | 0 | 100ms/글자 (최저속) |
(TRUE, 128) |
28 | 1 | 50 | 50ms/글자 (기본과 동일) |
(TRUE, 0/32/64) |
16 | 0 | 0 | 100ms/글자 (최저속) |
인자가 전부 2의 거듭제곱(16/32/64/128)인 것을 보면 스크립트 작성자는 /128
스케일(다른 인자들의 관례 — 04-engine-functions.md §0)을
의도했지만, 엔진의 정수 나눗셈이 100 미만을 전부 0으로 뭉갠다. 결과적으로
연출용 슬로우는 전부 최저속 한 단계뿐이고 128은 기본속으로 되돌리는 용도다.
버그로 보이지만 원작의 관측 가능한 동작이므로 그대로 재현한다 — 고치면
(TRUE, 16) 31곳의 연출 속도가 원작과 달라진다.
코퍼스에서 관찰된 태그만 지원한다: <color=#rrggbb>…</color>,
<size=±N>…</size>(기준 크기 대비 상대 pt), <i>…</i>.
HTML <span>으로 변환하며, 미지의 태그는 경고 후 텍스트에서 제거한다.
페이지(이름, 텍스트, 보이스 참조)를 링 버퍼로 보관. 휠 업/버튼으로 열람, 보이스
재청취 버튼 제공. 백로그는 RendererState에 포함하지 않는다(세이브 용량 대비 가치
낮음 — 로드 시 빈 백로그로 시작). 원본은 Assets.Scripts.Core.History.TextHistory.
초안 정정 (M7) — 링 버퍼 상한은 200이 아니라 100이다
(PushHistory()의 if (lines.Count > 100), 그리고 MaxEntries = 100 상수).
"확정된 페이지"라는 표현도 정정한다 — 백로그 등록은 클릭이 아니라 출력
시점이다. 원본 OperationOutputLine이 RegisterLine()을 SetText()보다
먼저 부르므로, 아직 타이핑 중인 줄도 이미 백로그에 들어 있다.
페이지가 넘어가는(PushHistory()) 자리는 정확히 셋이다.
| 자리 | 근거 |
|---|---|
Line_Normal 출력 직후 |
if (burikoTextModes == Normal) PushHistory(); |
본문이 "\n"으로 시작할 때 |
RegisterLine()의 첫 분기 — 등록 전에 넘긴다 |
ClearMessage |
OperationClearMessage가 ClearText() 뒤에 부른다 |
두 번째가 ADV의 줄 띄우기 관용구다 — 그 자리가 곧 페이지 경계라는 뜻이다.
본문의 개행은 선두뿐 아니라 전부 지운다(Replace("\n", string.Empty)).
태그는 <size>만 걷어낸다(sizeTagRegex) — 백로그는 창과 글자 크기가 다르다.
보이스는 ModPlayVoiceLS가 RegisterVoice()로 쌓아 두면 다음 RegisterLine()이
그 줄에 붙이고 통을 비운다. 한 줄에 여러 묶음이 붙을 수 있다. 재청취(R 키)의
LatestVoice는 3단 폴백이다 — 아직 안 붙은 보이스 → 화면의 줄에 붙은 것 →
백로그를 거슬러 올라가 보이스가 있는 마지막 줄(내레이션이 이어져도 마지막 대사를
들려주기 위한 것).
언어는 표시 중인 쪽만 담는다 — 원본은 RegisterLine(english, japanese, …)로
양쪽을 다 보관하고 백로그 창이 고르지만, 우리 렌더러는 OutputLine 단계에서
이미 GLanguage로 한쪽을 고른다(§6.2). 원본이 양쪽을 든 이유는 재생 중 언어를
바꾸는 L 키인데 §10의 키 배치에 그것이 없다.
WebAudio 기반. AudioContext 1개, 채널마다 GainNode → 마스터 GainNode.
BGM ch0..5 (loop, ogg) ─┐
Voice ch0..7 (one-shot) ├─→ 채널 Gain(= 아래 볼륨식의 결과) → master
SE ch0..7 (one-shot) ─┘
채널 수는 원본 AudioController 생성자가 잡는 레이어 수다(BGM 6 / Voice 8 /
SE 8 / System 2). Ep05 코퍼스가 실제로 쓰는 것은 BGM 0~3, SE 0·1·3·4,
Voice 3~7이며 System 채널은 쓰지 않는다. 원본과 같은 수를 잡아 둔다.
M5에서 원본 대조로 확정했다. 실효 게인은 세 축의 곱이며 [0,1]로 클램프된다.
gain = clamp(0.5 × (G*Volume / 100) × (스크립트 볼륨 / 128), 0, 1)
^^^ ^^^^^^^^^^ ^^^^^^^^^^^^^^^^^
GlobalVolume 사용자 설정 스크립트 인자
근거 (07th-mod/higurashi-assembly, mea-mod):
AudioLayerUnity.Update()의audioSource.volume = volume * subVolume. 여기서volume = AudioController.GetVolumeByType(type) = GlobalVolume × 타입볼륨,subVolume= 오퍼레이션이 넘긴 스크립트 볼륨이다. 매 프레임 재계산된다 — 볼륨 플래그 변경이 즉시 반영되는 것이 이 구조다(별도 구독이 없다).AudioController.GlobalVolume = 0.5f. 전 소스에서 대입이 한 번도 없다 (전수 검색 확인). 즉 상수 0.5의 헤드룸이다.BurikoMemory가VoiceVolume = GVoiceVolume/100,BGMVolume = GBGMVolume/100,SoundVolume = SystemVolume = GSEVolume/100으로 잇는다. SE와 System이 같은 플래그를 공유한다.- 스크립트 인자의
/128f는OperationPlayBGM/OperationPlaySE/OperationMODPlayVoiceLS모두에서 확인된다. - 클램프의 근거는 Unity
AudioSource.volume의 정의역이 [0,1]이라는 것이다.
이 헤드룸이 코퍼스의 볼륨 인자를 설명한다. ModPlayVoiceLS의 볼륨은
128이 아니라 256이 6,064회(전체의 96%)로, /128 하면 2.0 — 1.0을 넘는다.
초안이 "0128 → 0.01.0"이라 적은 것은 이 때문에 틀렸다. 실제로는
0.5 × 0.75 × 2.0 = 0.75로 착지한다. 즉 256은 과증폭이 아니라 GlobalVolume
0.5의 헤드룸을 되찾는 정상값이다. init.txt의 기본값(GVoiceVolume 75,
GBGMVolume/GSEVolume 50)은 엔진 기본값과 같다.
| 계열 | 대표 인자 | 빈도 | 실효 게인 (기본 설정) |
|---|---|---|---|
| 보이스 | 256 | 6,064 | 0.5 × 0.75 × 2.0 = 0.75 |
| 보이스 | 384 / 540 | 217 / 21 | 1.13 / 1.58 → 1.0으로 클램프 |
| BGM | 120 / 56 | 다수 | 0.23 / 0.11 |
| SE | 128 / 56 | 다수 | 0.25 / 0.11 |
볼륨 플래그는 구독하지 않고 게인 계산 시점에 읽는다 — 원본이 매 프레임 재계산하는 것과 같은 결과이면서 구독 배선이 필요 없다.
초안은 PlaySE의 pan을 StereoPannerNode로 구현한다고 했으나, 원본 Unity
포트는 팬을 전혀 적용하지 않는다.
OperationPlaySE()는pan = ReadVariable().IntValue() / 128f로 읽어AudioController.PlaySE(filename, channel, volume, pan)에 넘긴다.- 그런데
PlaySE()는pan을 한 번도 참조하지 않는다. 유일한 소비처였을AudioLayerUnity.UpdatePan(float pan)은 본체가 빈 메서드이고, 애초에 아무도 부르지 않는다.
코퍼스의 pan 인자가 851회 전부 64(= /128 하면 0.5)인 것이 이를 교차
검증한다 — BGI 원본 엔진의 "중앙" 관례값이 그대로 남은 것이고, Unity 포트가
스테레오 정위 자체를 구현하지 않았다. 인자는 읽어서 버린다(arity 검증은 유지).
- BGM:
PlayBGM(ch, name, vol, fadeIn)/ModPlayBGM(+BGMflow 조건) — loop. 채널 상태(곡, 볼륨)는 스냅샷 대상(§9). - SE:
PlaySE(ch, name, vol, pan)— one-shot. 같은 채널이 재생 중이면 먼저 정지시킨다(원본PlaySE가IsPlaying()검사로 그렇게 한다). - 보이스:
ModPlayVoiceLS(ch, characterId, path, vol, enableLipSync)—voice/하위. 립싱크는 v1에서 생략하고 재생만 한다. - 로드는 블로킹이 아니다. 원본은
StartCoroutine(WaitForLoad(...))로 던져 두고 오퍼레이션은 즉시Null을 돌려준다. 따라서 재생 계열은 서스펜션을 반환하면 안 된다 — fetch+decode를 띄워 두고 즉시 반환한다. 로드 완료 전에 같은 채널로 새 명령이 오면 원본이StopCoroutine으로 낡은 로드를 버리는 것과 같이, 세대 토큰으로 낡은 로드의 재생을 무효화한다. - 페이드:
Tweener로 스크립트 볼륨(원본의subVolume)을 굴리고 게인을 매 프레임 재계산한다. 초안은linearRampToValueAtTime이라고 했으나Tweener를 쓴다 — 원본이 iTween으로subVolume을 굴리고Update()가 매 프레임 게인을 다시 계산하는 것과 같은 구조이고, 스킵 시 즉시 완료·스냅샷이라는 §3의 요구가 트윈에 이미 구현돼 있기 때문이다. WebAudio 파라미터 자동화로 하면 스킵 취소와 진행값 관측을 위해 이중 장부를 들게 된다. FadeOutBGM(ch, ms, wait)은 페이드 완료 시 정지까지 한다(원본FadeOut()의 iTweenoncomplete: StopAudio).wait=TRUE면 페이드 시간만큼effect서스펜션. 스킵 중이면 페이드 없이 즉시 정지한다 —OperationFadeOutBGM이IsSkipping을 보고StopBGM으로 분기한다. 코퍼스 최빈 형태가FadeOutBGM(ch, 0, FALSE)(1,056회)이라 0ms 경로가 정상 경로다.WaitToFinishVoicePlaying(ch)[블로킹] — 스킵 중이면 대기 없이 0을 반환, 아니면 남은 재생 시간만큼effect서스펜션 후 1을 반환한다. 원본은 호출 시점에IsVoicePlaying()이 false면 그냥 1을 돌려주는데, 코퍼스의 두 용례가 모두ModPlayVoiceLS→OutputLine→WaitToFinishVoicePlaying순서라 호출 시점엔 로드가 끝나 있다. 로드가 아직 진행 중이면 로드를 기다린 뒤 재생을 기다린다 — 원본에서 이 분기는 로드 타이밍에 걸린 경합이고, 경합을 재현하는 것은 의미가 없다.
AudioController.PlayAudio()는 fadeintime > 0f면 시작 볼륨을 0으로 두는데,
실제 페이드는 fadeintime > 0.05f(초 환산 후)일 때만 건다. 그 사이 구간
(1~50ms)은 볼륨 0으로 재생을 시작한 뒤 아무도 올려 주지 않는다.
Ep05에서는 발화하지 않는다 — PlayBGM/ModPlayBGM 966회 중 fadeIn ≠ 0은
PlayBGM(1, "higurashi", 120, 5000) 단 1회뿐이다. 관측 가능한 원작 동작이고
재현 비용이 0이므로 그대로 옮긴다.
- BGM/SE는
ModAddBGMset/ModAddSEset의 체인을GAltBGM/GAltSE로 고른다. 인덱스가 범위를 벗어나면 원본은 등록 목록의 0번이 아니라 기본 체인 (["BGM"]/["SE"])으로 떨어진다 (MODAudioSet.GetCascade). - 에셋 조회는 대소문자를 무시한다 (§8도 같다). 원본은
WWW("file://" + path)로 파일을 직접 읽고, Windows/macOS의 파일 시스템이 대소문자를 구분하지 않으므로 스크립트의 표기와 디스크의 표기가 달라도 열린다 — 코퍼스가 실제로 여기에 기대고 있다. M5에서 실제 에셋으로 재생하다 세 건이 드러났다:PlayBGM("Iru")↔iru.ogg,PlayBGM("DEADEND")↔deadend.ogg,PlaySE("s_M_Door8k")↔s_m_door8k.ogg. 단 URL은 매니페스트의 실제 표기로 낸다 — 서빙 측(리눅스 호스트, 엄격한 정적 서버)은 대소문자를 구분할 수 있다. 조회만 관대하게 하고 요청은 정확히 한다. - 보이스는 체인이 없다 —
MODAudioSet에voiceCascades가 있지만 채우는 메서드가 존재하지 않아 항상 기본값["voice"]로 떨어진다. 따라서GAltVoice/GAltVoicePriority플래그는 이 브랜치에서 경로 해석에 영향이 없다.voice/고정 루트 + 스크립트가 준 상대경로(ps3/s06/06/…)로 해석한다. - 파일명에
.ogg는 오퍼레이션이 붙인다(StringValue() + ".ogg").
최초 사용자 제스처 전까지 AudioContext는 suspended이며, 이 동안 컨텍스트
시계가 멈춰 있으므로 그 시점에 start()한 소스는 재개 시 처음부터 울린다.
제스처(진행 입력)에서 resume()을 한 번 부른다. 별도의 재생 큐는 두지 않는다 —
멈춘 시계가 큐 역할을 한다. 다만 재개 시점에 밀려 있던 one-shot이 한꺼번에
울리는 것을 막기 위해, suspended 상태에서 시작한 one-shot(SE/보이스)은 버린다
(BGM은 루프이므로 살린다).
HTTP 환경에서는 디렉토리 나열이 불가능하므로, 빌드/서빙 시 생성한 매니페스트
(assets-manifest.json: 상대경로 전체 목록)를 로드해 존재 검사를 O(1)로 한다.
인덱스는 소문자화한 경로 → 실제 표기의 맵이다 — 근거와 실제로 걸린 사례는 §7.4.
- 그래픽:
ModAddArtset이 등록한 폴백 체인(예: Remake =CGAlt:CG)을GArtStyle플래그로 선택. 논리 이름"background/so2"→ 체인 순서대로<dir>/background/so2.png존재 검사 → 첫 히트를 URL로. - 오디오:
ModAddBGMset/ModAddSEset체인(OGBGM:ExtraBGM:BGM등)을GAltBGM/GAltSE플래그로 선택,.ogg해석. (Safari의 ogg/vorbis 미지원을 고려해, 매니페스트 생성 시.m4a트랜스코딩 병행 옵션을 둔다) - 보이스:
voice/고정 루트 + 스크립트가 준 상대경로. - 무비:
ModPlayMovie("mv05")→movies/mv05.ogv(Ogg Theora 확인됨). Chromium/Firefox는 네이티브 재생 가능, Safari 대응은 매니페스트 생성 시 WebM/H.264 트랜스코딩으로 해결한다.<video>로 재생, 종료/스킵 시 resolve되는effect서스펜션 (스킵 입력은 §10.1). - 프리로드:
PreloadBitmap힌트 + "다음 N개 문장의 텍스처/보이스 선읽기" (VM 트레이서로 lookahead) 전략. 이미지 프리로드는<link rel=preload>또는 Image 객체, 오디오는 fetch+decode 캐시.
로드 시 재실행 없이 화면을 즉시 재구성하기 위한 직렬화 상태.
interface RendererState {
version: 2;
scene: { texture: string | null };
layers: Record<number, LayerState>; // 진행 중 트윈은 완료값으로 스냅샷
window: {
visible: boolean; mode: WindowMode;
source: string; // 현재 페이지의 원문 (태그 포함)
prevSource: string; // 원본 prevText — 직전 출력이 시작되기 전
prevAppendNext: boolean; // 원본 prevAppendState — 복원의 분기 조건
config: WindowConfig;
};
audio: { bgm: Array<{ channel: number; name: string; volume: number } | null> };
effects?: { film?: FilmState };
}원본의 대응물은 SceneController.SerializeScene() + SerializeCurrentAudio()이며,
M7에서 그것과 대조해 넷을 정정했다.
① 창은 HTML이 아니라 원문을 담는다. 초안의 textHtml(태그 변환 후)을
source(원문)로 바꾼다. 원본이 그렇게 하고(GetFullText()가 원문을 내고
SetFullText()가 다시 렌더한다), HTML을 담으면 세이브 파일에서 온 문자열을
innerHTML로 되먹이게 되기 때문이다 — 세이브는 localStorage나 파일에서 오므로
신뢰할 수 없다(02-interpreter.md §7.3과 같은 전제). 원문을 평소의 빌더 경로로
다시 태우면 그 창구가 아예 없다. name을 따로 두지 않는 것은 이름이 이미
NameFormat으로 본문 앞에 인라인으로 붙어 있기 때문이다(§6).
② "진행 중 트윈은 완료값" — 원본이 확증했다. Layer.Serialize()가
targetPosition/targetScale/targetAlpha를 쓰고, 필름도
SceneController.filmPower = targetpower로 목표를 들고 있다(현재값은
FilmEffector가 따로 굴린다). 복원도 CreateFilmEffector(…, 0f)로 즉시 목표에
세운다. 이건 이론적 정합이 아니라 실제로 발화한다 — isblocking=FALSE인
버스트샷/필름 뒤에 곧바로 OutputLine이 오면 세이브가 허용되는 클릭 대기
시점에도 트윈이 돌고 있고, 현재값을 담으면 반투명하게 멈춘 스프라이트가
복원된다.
③ 페이드아웃 중인 레이어는 담지 않는다. 원본 SerializeScene의
if (layers[i].FadingOut || !layers[i].IsInUse) → false. 사라지는 중이었으니
복원하면 없는 것이 맞다.
④ 창은 "저장된 줄을 뺀 자리"로 복원한다 — 초안의 appendNext 하나로는 안 된다.
로드는 저장 당시 실행 중이던 문장을 다시 실행하므로(02-interpreter.md §7.2),
그 문장이 이어붙이기(NVL) 중인 OutputLine이면 source(= 원본의 fullText)를
세워 둔 위에 같은 줄이 한 번 더 붙는다. 원본이 fullText와 별도로
prevText·prevAppendState를 적는 이유가 이것이며, 로드 쪽이 그것으로 갈라
prevAppendState가 참이면 fullText가 아니라 **prevText**를 세운다
(BurikoScriptSystem.LoadGame() 407~424행). 그러면 재실행된 줄이 그 위에 붙어
fullText가 된다.
초안이 담던 appendNext는 그 줄 이후의 값이라 이 분기에 쓸 수 없다 —
prevSource/prevAppendNext(그 줄 이전)로 바꾸고 버전을 2로 올렸다. 옛
세이브를 조용히 잘못 복원하느니 거부한다. 슬롯 형식(@higu/app의 SaveStore)도
함께 올려야 한다 — 그러지 않으면 옛 슬롯이 higu.restore()를 통과한 뒤
renderer.restore()가 거부해, VM은 복원됐는데 화면은 아닌 상태로 멈춘다.
restore(state)는 모든 에셋을 로드한 뒤 한 프레임에 DOM/오디오를 재구성한다.- SE·보이스 one-shot, 진행 중 트랜지션·흔들림은 복원하지 않는다(원작도 동일 —
SerializeCurrentAudio가 BGM만 담고SerializeScene이 흔들림을 담지 않는다). - 텍스처·곡은 논리 이름으로 담는다. URL로 담으면 세이브 이후 아트셋
(
GArtStyle)이나 BGM셋이 바뀌었을 때 옛 경로를 되살린다. - BGM 볼륨은 스크립트 인자 스케일로 되돌려 담는다. 내부
subVolume은 이미 /128 된 값이라 그대로 담으면 복원할 때마다 곡이 128배씩 작아진다(§7.1). ModSetLayerFilter의 레이어 필터는 원본이 담지 않는다.SerializeScene에layerFilters가 없다 — 원본에서도 세이브/로드로 사라진다. 우리도 담지 않는다.- 세이브 썸네일: Canvas 금지 제약으로 화면 캡처 대신 현재 장면 텍스처의 축소 이미지 URL을 메타에 저장한다.
| 입력 | 동작 | 원본 대비 |
|---|---|---|
| 클릭 / 휠 다운 / Enter | 타이핑 즉시 완료 → 입력 대기 resolve | 같음 |
| Space | 상동 | 다름 — 원본은 뷰 모드(창 숨김) |
| Ctrl (hold) | 임시 스킵 (skipMode: "all") |
같음 (IsForceSkip) |
| 휠 업 / ← / PageUp | 백로그 열기 | 같음 |
| A | 오토 모드 토글 | 같음 |
| S | 스킵 모드 토글 ("off" ↔ "read") |
같음 |
| R | 마지막 보이스 재청취 | 같음 |
| Q / E | 퀵세이브 / 퀵로드 | 다름 — 원본은 F5/F7 |
| Esc / 우클릭 | 호스트 메뉴 호출 (세이브·로드·설정) | 일부 다름 — 아래 |
SetValidityOfInput(FALSE) / SetValidityOfInterface(FALSE) 구간에서는
해당 입력을 차단한다. EnableJumpingOfReturnIcon은 클릭 대기 아이콘 표시
힌트로만 사용한다.
원본과 일부러 다른 자리가 둘 있다.
- Space — 원본
StateNormal에서 Space는SwitchToViewMode()(창을 숨겨 CG를 보는 모드)이고 진행은 Return/→/PageDown/클릭/휠 다운이다. 여기서는 VN의 관례를 따라 진행에 둔다(M4의 결정). - 퀵세이브 — 원본은 F5/F7이지만 브라우저에서 F5는 새로고침이라
preventDefault에 기대야 하고, 하필 그 새로고침이 M7 DoD("세이브 → 새로고침 → 로드")의 검증 수단이다. Q/E로 옮긴다.
Ctrl 해제는 S로 켜 둔 스킵도 함께 끈다. 원본이 IsSkipping을 무조건
내리기 때문이다(if (IsForceSkip) { IsSkipping = false; IsForceSkip = false; }).
IsForceSkip 깃발의 용도는 대상을 가리는 것이 아니라 해제를 한 번만
발화시키는 것이다 — 원본에서 이 분기는 Ctrl이 안 눌린 매 프레임 돌므로, 깃발이
없으면 IsSkipping이 프레임마다 꺼져 S 스킵이 아예 불가능해진다.
S가 켜는 스킵이 "read"인 것은 원본의 기본값과 같다 — GSkipUnread = 0 →
SkipUnreadMessages = false → 미독에서 멈추는 스킵(§8.1). 설정 UI(M8)가
skipToggleMode를 "all"로 바꾼다. 값의 주인은 호스트이므로 렌더러는 들고
있지 않고 물어본다.
메뉴 호출(Esc/우클릭)은 스킵/오토를 먼저 해제한다 (M8). 원본 StateNormal:
if (Input.GetMouseButtonDown(1)) {
if (IsSkipping && !IsForceSkip) { IsSkipping = false; return false; }
if (IsAuto) { IsAuto = false; return false; }
}
if (Input.GetMouseButtonDown(1) && MessageBoxVisible) { SwitchToRightClickMenu(); }즉 달리는 중의 우클릭은 그것을 끄기만 하고 메뉴를 열지 않는다 — "멈춰"라는
뜻이지 "메뉴"가 아니다. !IsForceSkip 조건은 Ctrl 홀드 중엔 해제할 것이 없다는
뜻이므로(손을 떼면 어차피 풀린다) 그때의 우클릭은 곧바로 메뉴다.
SetValidityOfInterface(FALSE) 구간에서는 열지 않는다.
원본은 여기에 창 숨김(뷰 모드) 관례도 얹지만, 그 진입은 Space이고 이 재구현은 Space를 진행에 뒀다(위) — 뷰 모드는 미구현이다.
무비는 원본에서 별도 상태(StateMovie)이고 GameSystem.Update()는 최상단
상태의 핸들러 하나만 돌린다. 그래서 재생 중에는 위 표가 통째로 죽고, 대신
StateMovie.InputHandler()의 갈래만 산다.
if (Input.GetKeyDown(KeyCode.Escape) || Input.GetKeyDown(KeyCode.Return) ||
Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.RightControl) ||
Input.GetMouseButtonDown(0))
{
RequestLeave();
}| 입력 | 동작 |
|---|---|
| 좌클릭 / Esc / Enter / Ctrl | 스킵 (effect 서스펜션 resolve) |
| 우클릭 | 없음 — 원본에 갈래가 없다 |
| 그 외 전부 | 없음 (진행·백로그·메뉴·토글 모두 죽는다) |
이 두 쪽을 MoviePlayer(스킵 입력을 직접 받는다)와 InputController.suppressed
(자기 입력을 죽인다)가 나눠 맡는다. 원본의 상태 스택에 대응하는 자리다.
스킵은 SetValidityOfInput을 보지 않는다. 원본의 입력 게이트가
if ((CanInput || GameState != GameState.Normal) && (MODIgnoreInputs() || inputHandler == null || !inputHandler()))(GameSystem.cs:992)이라 Normal이 아닌 동안에는 유효성이 아예 걸리지 않는다.
둘은 다른 축이다 — SetValidityOfInput은 스크립트가 거는 연출용 게이트이고,
상태 스택은 입력의 수신자가 바뀌는 것이다. 그래서 유효성을 검사하는
InputController를 거치지 않고 MoviePlayer가 직접 받는다.
클릭은 뷰포트에서 받는다 — 원본은 창 어디를 눌러도 스킵되므로 스테이지
바깥(레터박스)도 포함해야 같은 결과가 된다. <video>(z-index 40)는 창(20/21)·
선택지(30) 위에 있어 자기 위의 클릭도 여기로 버블링시킨다.
예외가 둘 있다.
- 제스처는 통과한다 — 무비를 스킵하는 클릭도 브라우저에는 제스처다. 여기서 새면 소리가 영영 안 나는 창이 생긴다(§7.5와 같은 이유).
- Ctrl 홀드는 억제 중에도 기록한다. 원본은 무비가 끝난 다음 프레임의
GetKey폴링이 곧바로IsSkipping을 켜지만, 이벤트 모델에는 그 폴링이 없고 수식 키는 오토리피트도 없다. 받아 두지 않으면 Ctrl을 쥔 채 무비를 통과한 뒤 스킵이 풀린 채로 남는다.
같은 이유로 재생 시작 시점에 Ctrl을 한 번 묻는다(isSkipHeld). 원본은
GetKey를 매 프레임 폴링하므로 무비가 시작될 때 이미 눌려 있으면 첫 프레임에
빠져나가는데, keydown만 달면 그 갈래가 통째로 빠진다 — 그리고 Ctrl을 누른 채
오프닝을 통과하는 것이 이 갈래의 주된 경로다.
범위 결정("코어 + 최소 GUI")에 따라, 엔진 GUI 화면 함수는 렌더러가 아닌 호스트가 구현한다. 렌더러 패키지는 타입만 제공한다.
interface HostUI {
titleScreen(): Promise<void>; // TitleScreen — 메뉴 선택까지 대기,
// 결과는 LocalWork 관례로 전달
showChapterPreview(): Promise<void>; // ShowChapterPreview
showChapterScreen(): Promise<void>; // ShowChapterScreen
showTips(mode: number): Promise<void>; // ShowTips
showExtras(): Promise<void>; // ShowExtras
languagePrompt(): Promise<void>; // LanguagePrompt — GLanguage 설정
genericCall(name: string, arg: string): Promise<void>; // ModGenericCall
achievement(id: string): void; // GetAchievement — 토스트 등 (no-op 가능)
}각 함수의 결과 전달 관례(LOCALWORK_NO_RESULT, SelectResult 등에 기록)는
04-engine-functions.md의 함수별 항목에 명세한다.
호스트가 미구현 시 전부 no-op 스텁으로 대체 가능해야 한다(헤드리스 완주 요건).
최소 GUI 중 렌더러가 직접 제공하는 것: 선택지(Select), 백로그,
스킵/오토 표시. 세이브/로드 슬롯 UI·설정 화면은 호스트 책임이다.
원본은 GUI 콜백이 실행 위치를 직접 바꾼다 — 타이틀의 CGGallery 버튼은
CallBlock("ViewTipsDisplay")를, 챕터 점프 버튼은 JumpToBlock("Game")을
부른다(ChapterJumpButton.cs:60). 본 재구현은 그러지 않는다. VmControl은
코어 함수 전용이고(engine.ts), 흐름 제어를 호스트로 흘리면 "실행 위치는
데이터"라는 PC 기반 VM의 불변식이 무너진다.
LocalWork만 세우고 돌아가면 결과가 같다. flow:Title이 TitleScreen() 다음에
CallSection("ChapterPreview")를 부르고, ChapterPreview가
LOCALWORK_NO_RESULT == 1이면 JumpSection("Game")으로 가며, Game()은
s_jump로 시작점을 고른다(04-engine-functions.md §6). 즉 타이틀이 s_jump를
세우고 돌아오면 본편의 그 챕터로 이어진다. 그래서 엑스트라(챕터 점프)도
타이틀 안에서 돈다 — 원본이 그 화면에 도달하는 경로가 위의 CallBlock에 기대기
때문이다.
| 화면 | 붙는 곳 | 이유 |
|---|---|---|
| 선택지 | 스테이지 | GUI 공간의 좌표를 갖는 씬의 일부다(§12). 창과 함께 축소돼야 한다 |
| 타이틀·엑스트라 메뉴 | 스테이지 | flow:Title이 그린 배경/로고 위에 얹히는 것이 원본의 모양이다 |
| 백로그·설정·세이브 슬롯 | 뷰포트 | 씬의 일부가 아니라 그 위에 뜨는 UI다. 스테이지에 넣으면 scale()로 함께 줄어 작은 창에서 글씨를 읽을 수 없다 |
원본 ConfigSlider와 BurikoMemory의 설정 반영 블록이 확정해 준다. 값이 사는
곳은 둘로 갈린다 (02-interpreter.md §8):
| 값 | 원본 플래그 | 사는 곳 |
|---|---|---|
| 메시지 속도 | GMessageSpeed |
정책 textSpeed |
| 오토 속도 (타이핑) | GAutoSpeed |
정책 autoSpeed |
| 오토 페이지 넘김 | GAutoAdvSpeed |
정책 autoPageSpeed |
| 미독도 스킵 | GSkipUnread |
호스트 (S의 스킵 종류) |
| 느린 스킵 | GSlowSkip |
정책 slowSkip |
| 오토 중 클릭 진행 | GClickDuringAuto |
정책 clickDuringAuto |
| BGM/SE/보이스 볼륨 | GBGMVolume/GSEVolume/GVoiceVolume |
GlobalFlags |
| 창 불투명도 | GWindowOpacity |
GlobalFlags |
볼륨과 불투명도만 GlobalFlags인 것은 그 넷이 코퍼스가 실제로 읽는 G-플래그
29종에 들어 있기 때문이다(symbols.ts). 나머지는 스크립트가 한 번도 읽지 않으므로
스크립트 상태를 거칠 이유가 없다.
원본 ConfigSlider는 오토 속도를 GAutoAdvSpeed에서 읽고 GAutoSpeed에 쓴다
(:45 vs :94). 두 축이 뒤섞인 자리이며, 본 재구현은 각각의 축에 맞게 둔다.
원본에 있으나 노출하지 않는 것: GUsePrompts, GRightClickMenu,
GCutVoiceOnClick, GUseSystemSound, GArtStyle. 전부 이 재구현이 아직
분기하지 않는 값이라, 내놓으면 움직이지 않는 손잡이가 된다.
배치·타이밍의 근거는 원본 Assets.Scripts.UI.Choice의 ChoiceController.Create()와
ChoiceButton이다. 선택지는 **GUI 공간(720)**에 놓이므로 스테이지에 붙고, y는
StageCoords.guiPoint()가 뒤집는다(§2).
// count <= 8
localPosition = new Vector3(GUIXOffset, -75 * i + 27 * count + 50, 0f);
// count > 8 — 두 열. 홀수 개의 마지막 하나만 가운데.
x = (i == count-1 && count%2==1) ? GUIXOffset : (i%2==0 ? GUIXOffset-300 : GUIXOffset+300);
localPosition = new Vector3(x, -75 * (i/2) + 27 * count - 50, 0f); // i/2는 정수 나눗셈Ep05는 count ≤ 3뿐이라 2열 분기를 밟지 않는다 — 자체 픽스처가 짊어진다.
GUIXOffset = GameSystem.GetGUIOffset() = (4/3 − AspectRatio) × 384이며 16:9에서
−170.67이다. 이 값을 추가 변위로 적용하면 안 된다 — 그렇게 했더니 선택지가
170px 왼쪽으로 밀렸고, 실물 재생에서만 드러났다(M8).
창과 선택지가 같은 패널에 붙기 때문이다. 원본에서
MainUIController.mainuiPanel(MainUIController.cs:427)과 ChoiceController가
버튼의 부모로 삼는 객체(ChoiceController.cs:39)는 같은 태그 PrimaryUIPanel로 찾는
동일 GameObject다. init.txt의 SetGUIPosition(170, 0)이 그 패널을 옮기고, 둘은
각자 그것을 되돌린다:
| 대상 | 패널 | 자기 오프셋 | 합 |
|---|---|---|---|
| 텍스트 창 | +170 | 프리셋 WindowPosX = -170 |
0 (중앙) |
| 선택지 | +170 | GetGUIOffset() = -170.67 |
-0.67 (사실상 중앙) |
정확히 0이 아닌 것은 스크립트가 170을 박아 두고 엔진은 종횡비에서 170.67을 계산하기 때문이다. 원본이 그렇다.
GetGUIOffset()의 호출자는 원본 엔진 전체에서 ChoiceController 하나뿐이다.
&choicesdialog는 선택지 뒤에 3choices(1920×1080)를 깐다. 이 그림이 §2의
TextContainer 앵커와 달리 답을 갖고 있다 — 실측:
| 항목 | 실측 | 원본 수식 |
|---|---|---|
| 띠 중심 x | 949.5 / 1920 → 화면 중앙 -7px | 패널(+170) + 오프셋(-170.67) = -0.67 |
| 띠 셋 중심 y (GUI) | +123.3 / +52.0 / -19.3 | -75*i + 27*3 + 50 = 131 / 56 / -19 |
이미지 1080 → 스테이지 720px(계수 2/3)로 환산한 값이다. 세로는 띠 높이가 47px라 8px 오차가 글자를 띠 안에 두므로 수식이 실측으로 확증된다.
ChoiceButton.fadeTime = 0.25s— 호버 색 전환(흰색 ↔ 빨강(1,0,0))과, 고른 뒤 소멸까지의 대기(LeaveChoices()의AddWait)에 함께 쓰인다. 스크립트가 재개되는 것도 그 대기 뒤다.fadeInTime = fadeTime/2 = 0.125s— 그 전의 클릭은 버린다. 원본 주석이 이유를 적어 뒀다("선택지가 다 뜨기 전에 고르는 것을 막는다").GRyukishiMode는 Ep05 코퍼스에 0회 —GUIXOffset = 0분기와 폰트 0.75배 분기(GRyukishiMode43Aspect)는 죽은 코드다.- 폰트 크기는
ChoiceButton프리팹의 TMPro 설정이라 소스로 확정할 수 없다 (§2의 TextContainer 앵커와 같은 부류).
choice는 input과 함께 세이브 가능 지점이므로(02-interpreter.md §4.1)
"선택지에서 저장하고 선택지에서 로드"가 정상 경로다. 그런데 choice는 진행
입력으로 풀리지 않아 호스트의 forceAdvance()가 닿지 않는다 — 로드가
stop()을 기다리다 영영 끝나지 않는다. 그래서 로드의 펌프는 선택지도 함께
푼다(ChoiceView.destroy() → 대기 중인 선택을 0으로 resolve). 어차피 곧 화면
전체를 복원하므로 그 값은 버려진다. 소멸 페이드는 건너뛴다 — 로드를 250ms
늦출 이유가 없다.