# AI 꾸러미 — 아스키 다크 판타지 RPG 화면 (칸 색 글자 · 횃불 빛 · 글자 UI) — ASCII shader / glyph post-process
> 평범하게 그린 어두운 장면(숲길 · 묘지 · 예배당 · 기사 · 해골)을 숨은 캔버스에 칸 하나 = 픽셀 하나로 그리고, 셰이더 한 번으로 칸마다 밝기 → 글자 · 칸 색 → 글자 색으로 바꾼다. 어둠 · 횃불 · 시야 빛이 분위기를 만들고, 이름표 · 체력 바 · 피해 숫자 · 기록 · 상태줄도 같은 글꼴의 글자 층으로 얹는다.  
> 견본: https://ai-techstudio.web.app/#t/i561

이 문서를 코드 도우미(Claude Code · Cursor · ChatGPT 등)에 그대로 주면 돼요. 「## 주문서」 가 할 일, 나머지는 참고 자료예요.

## 주문서

### 만들어 줘: 아스키 다크 판타지 RPG 화면 (칸 색 글자 · 횃불 빛 · 글자 UI) — ASCII shader / glyph post-process

#### 1. 목표
위에서 내려다보는 다크 판타지 RPG (숲길 · 묘지 · 해골 소환)을(를) 아스키 다크 판타지 화면으로 만들어 줘 — 장면을 숨은 캔버스에 칸 단위로 그리고 셰이더로 칸마다 밝기 → 글자, 칸 색 → 글자 색. 어둠 · 횃불 · 시야 빛으로 분위기, 이름표 · 체력 바 · 피해 숫자도 같은 글자 격자로. 분위기는 칸 색 그대로 · 어두운 밤 · 주황 횃불.

#### 2. 핵심 기술 용어
- **ASCII shader / glyph post-process** — 화면을 칸으로 나눠 칸마다 글자 하나 — 글자 모양은 아틀라스에서
- **Glyph atlas** — 글자들을 한 장의 텍스처에 칸칸이 그려 둔 것 — 셰이더가 칸 번호로 꺼내 씀
- **Luminance ramp (character ramp)** — 어두움 → 밝음 순서의 글자 사다리 (" .:o0O@", " ░▒▓█")
- **Exposure curve 1 − e^(−kL)** — 어두운 칸도 글자가 깔리게 밝기를 끌어올리는 곡선 — 밝기는 글자 색으로 따로
- **Light map (multiply) + emissive (additive)** — 주변 어둠 위에 시야 · 횃불 빛을 더한 빛 지도를 곱하고, 불꽃 · 눈빛은 그 뒤에 더하기
- **Text layer (cell-coded UI)** — 칸마다 「글자 번호 + 색」을 담은 작은 텍스처 — UI 도 장면과 같은 글자 격자로

#### 3. 환경
- 플랫폼: three.js r186 (ES 모듈 · TypeScript, `import * as THREE from "three"`), WebGL2, 외부 라이브러리 추가 없이
- 화면: 브라우저 — PC 와 폰(가로 844×390 · 세로 390×844) 모두, 60fps 목표
- 장면은 캔버스 2D(또는 낮은 해상도 렌더 타깃)에 칸 수만큼만 그리고 CanvasTexture(Nearest)로, 글자 층은 DataTexture(RGBA = 글자 번호 · 색)로. 화면 전체 사각형 하나에 ShaderMaterial — gl_FragCoord 로 칸 · 칸 안 위치를 구해 아틀라스에서 글자를 꺼낸다.

#### 4. 조건
- 장면은 칸 수 크기의 숨은 캔버스에 — 화면 크기로 그린 뒤 셰이더에서 평균 내지 않기
- 글자 아틀라스는 처음 한 번만 굽고 고정폭 글꼴, 블록 · 선 글자(█ ━ ─)는 칸을 꽉 채우게 직접 그리기
- 빛은 곱하기(빛 지도) + 더하기(불꽃 · 눈빛) 두 단계 — 어둠 속 빛나는 것이 살아 있어야 한다
- 글자 고르기는 노출 곡선(1 − e^(−kL))으로, 밝기는 글자 색으로 따로 — 어두운 곳이 텅 비지 않게
- UI(이름표 · 체력 · 피해 숫자 · 기록 · 상태줄)도 같은 글자 격자로, 빈칸은 뒤를 가리게
- 움직임은 대본 시각에서 바로 계산 (되감기 · 영상 굽기와 맞게)

#### 5. 완성 기준 (이게 보이면 성공)
- 어두운 숲길 · 묘지가 칸마다 색 있는 글자로 보이고, 나무는 초록 · 횃불 둘레는 주황 · 돌은 푸른 회색 글자다
- 횃불이 일렁이며 둘레 글자가 밝아졌다 어두워지고, 「주변 빛」을 내리면 빛 밖 글자가 사라진다
- 해골이 땅에서 솟아오르며 녹색 소환진 빛이 번지고, 이름표 · 체력 바가 글자로 따라다닌다
- 칼을 휘두르면 피해 숫자가 떠오르고 체력 바가 줄며, 쓰러지면 뼛조각이 흩어진다
- 글자 사다리를 기호 · 동그라미 · 블록으로 바꾸면 질감이 달라지고, 「칸 색」을 끄면 초록 터미널이 된다
- 「글자 전 칸 색 보기」를 밀면 왼쪽에 글자로 바꾸기 전 칸 색이 보인다

#### 6. 진행 방식
- 핵심 코드 위주로, 설명은 짧게. 내 프로젝트에 끼워 넣기 쉬운 함수 · 클래스로 나눠 줘.
- 처음 화면에 바로 결과가 보이게, 그리고 켬/끔(또는 전/후) 비교를 할 수 있게 만들어 줘.
- 그림 · 소리 · 모델 파일이 필요하면 코드로 만든 임시 대체물로 먼저 돌아가게 하고, 진짜 파일로 바꿀 자리를 표시해 줘.
- 마지막에 「확인 방법」(무엇을 보면 성공인지)과 「조절할 값」 목록을 짧게 정리해 줘.
- 답변과 코드 주석은 한국어로 해 줘.

## 원리
- 칸 크기를 정하고(세로 6~14px, 가로 = 세로 × 0.6) 화면 = 열 × 줄. 장면은 그 크기의 숨은 캔버스에 세계 좌표 변환으로 그린다 — 큰 그림을 줄여 그리면 칸 평균색이 저절로 나온다.
- 빛: 빛 캔버스를 주변 어둠 색(푸른 기)으로 채우고 시야 · 횃불(일렁이는 반지름) · 소환진 빛을 방사 그라디언트로 더한 뒤 장면에 곱하기. 불꽃 · 해골 눈빛 · 칼빛은 그 다음 더하기로 — 어둠 속에서도 빛난다.
- 셰이더: 칸마다 밝기 L → v = 1 − e^(−kL) 로 끌어올려 글자 사다리 순번을 고르고, 글자 색 = 칸 색을 가장 큰 채널로 나눈 색조 × (0.18 + 0.95v²). 그래서 어두운 땅도 흐린 0 · o 가 깔리고 밝기는 색으로 남는다.
- 글자 층: 칸마다 (글자 번호 + 1, r, g, b) 를 DataTexture 에 쓰고, 셰이더는 값이 있으면 장면 글자 대신 그 글자를 그린다. 빈칸도 「쓴 칸」이라 뒤 장면을 까맣게 가려 이름표가 읽힌다.
- 이름표가 겹치면 옆 · 위 가까운 자리부터 빈 곳을 찾아 비키고, 피해 숫자도 빈 줄을 찾아 위로 — 해골이 몰려도 읽힌다.

## 핵심 코드 — 칸 → 글자 셰이더 (장면 글자 + 글자 층)
(발췌: demos/demosAsciiRpg.ts makeAsciiRpg() 의 fragmentShader 를 정리)
```glsl
uniform sampler2D tScene, tText, tGlyph;   // 칸 색 · 글자 층 · 글자 아틀라스(16 x 7 칸)
uniform vec2 uGrid, uCell, uOff;             // 칸 수 · 칸 크기(px) · 가운데 맞춤
uniform float uRamp[12], uN, uExp;           // 글자 사다리(아틀라스 번호) · 길이 · 노출
void main(){
  vec2 p = gl_FragCoord.xy - uOff;
  vec2 cell = floor(p / uCell), inC = fract(p / uCell);
  vec2 uv = (cell + 0.5) / uGrid;
  vec3 c = texture2D(tScene, uv).rgb;
  vec4 tx = texture2D(tText, uv);
  float gi; vec3 col;
  if (tx.r > 0.0) { gi = floor(tx.r * 255.0 + 0.5) - 1.0; col = tx.gba; }   // UI 글자
  else {
    float L = dot(c, vec3(0.299, 0.587, 0.114));
    float v = clamp(1.0 - exp(-L * uExp), 0.0, 0.999);                   // 어두워도 글자가 깔리게
    int i = int(floor(v * uN));
    for (int j = 0; j < 12; j++) if (j == i) gi = uRamp[j];
    col = c / max(max(c.r, c.g), max(c.b, 1e-3)) * (0.18 + 0.95 * v * v);  // 색조는 칸 색, 밝기는 v
  }
  vec2 g = vec2(mod(gi, 16.0), floor(gi / 16.0));
  float a = texture2D(tGlyph, vec2((g.x + inC.x) / 16.0, 1.0 - (g.y + 1.0 - inC.y) / 7.0)).r;
  gl_FragColor = vec4(vec3(0.008, 0.01, 0.016) + col * a, 1.0);
}
```

## 흔한 실수 · 확인 목록
- [ ] **어두운 곳이 텅 비고 점만 남는다** — 밝기를 그대로 사다리에 대면 어두운 장면은 거의 빈칸 · 점이다. 노출 곡선으로 끌어올려 글자를 고르고, 밝기는 글자 색으로 따로 준다.
- [ ] **이름표 빈칸으로 뒤 장면 글자가 비친다** — 글자 번호 0 을 「비어 있음」으로 쓰면 빈칸(공백 = 0번)도 비어 있음이 된다. 번호 + 1 로 저장해 빈칸도 쓴 칸으로.
- [ ] **해골이 몰리면 이름표 · 피해 숫자가 서로 덮는다** — 먼저 쓴 칸을 기억해 두고 옆 · 위 가까운 빈자리로 비킨다. 피해 숫자는 빈 줄을 찾아 위로.
- [ ] **카드처럼 작은 화면에서 뭉개진다** — 칸이 너무 작으면 글자가 점이 된다. 작은 화면은 줄 수를 줄여(26 줄쯤) 칸을 키우고, 카메라도 가까이.
- [ ] **블록 · 선 글자 사이에 틈이 생긴다** — 글꼴의 █ ━ 는 칸 폭과 다르다. 아틀라스에 직접 사각형으로 칸을 꽉 채워 그린다.

## 완성 기준 체크리스트
- [ ] 어두운 숲길 · 묘지가 칸마다 색 있는 글자로 보이고, 나무는 초록 · 횃불 둘레는 주황 · 돌은 푸른 회색 글자다
- [ ] 횃불이 일렁이며 둘레 글자가 밝아졌다 어두워지고, 「주변 빛」을 내리면 빛 밖 글자가 사라진다
- [ ] 해골이 땅에서 솟아오르며 녹색 소환진 빛이 번지고, 이름표 · 체력 바가 글자로 따라다닌다
- [ ] 칼을 휘두르면 피해 숫자가 떠오르고 체력 바가 줄며, 쓰러지면 뼛조각이 흩어진다
- [ ] 글자 사다리를 기호 · 동그라미 · 블록으로 바꾸면 질감이 달라지고, 「칸 색」을 끄면 초록 터미널이 된다
- [ ] 「글자 전 칸 색 보기」를 밀면 왼쪽에 글자로 바꾸기 전 칸 색이 보인다

## 이 기술 정보
- id: `i561` · 분류: 그래픽 · 셰이더 › 후처리 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 숨은 캔버스는 칸 수(예: 150 × 50)만큼이라 그리기 · 올리기가 가볍고, 셰이더는 화면 전체 한 번. 폰에서도 60fps. 글자 아틀라스는 처음 한 번만 굽는다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i561
- 쓰면 좋을 때: 다크 판타지 · 로그라이크 · 공포처럼 「보이지 않는 것」이 분위기인 게임 / 그림 자원 없이 독특한 그림체가 필요할 때 (도형만 그려도 글자가 질감이 됨) / 해킹 · 터미널 · 레트로 연출
- 쓰지 말 때: 작은 글씨 · 세밀한 아이템 그림을 읽어야 하는 화면 — 칸 하나가 픽셀 하나라 뭉개진다 / 밝고 알록달록한 어린이 게임 — 어둠과 빛이 없으면 글자 질감만 남아 지저분하다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeAsciiRpg — `src/demos/demosAsciiRpg.ts:443`
```ts
function makeAsciiRpg(T: typeof THREE): Scene3D {
  const world = bakeWorld();
  const sc = document.createElement('canvas');
  const g = sc.getContext('2d')!;
  const lc = document.createElement('canvas');
  const lg = lc.getContext('2d')!;
  const sceneTex = new T.CanvasTexture(sc);
  sceneTex.minFilter = sceneTex.magFilter = T.NearestFilter;
  sceneTex.generateMipmaps = false;
  let textData = new Uint8Array(4);
  let textTex = new T.DataTexture(textData, 1, 1);
  const glyphs = atlas(T);
  const o = { s: 1, cell: 0, ramp: 1, color: true, ambient: 0.3, lights: true, ui: true, split: 0, exp: 9 };
  const uni = {
    tScene: { value: sceneTex as THREE.Texture },
    tText: { value: textTex as THREE.Texture },
    tGlyph: { value: glyphs as THREE.Texture },
    uGrid: { value: new T.Vector2(1, 1) },
    uCell: { value: new T.Vector2(6, 10) },
    uOff: { value: new T.Vector2() },
    uRamp: { value: new Array<number>(12).fill(0) },
    uN: { value: 1 },
    uColor: { value: 1 },
    uSplit: { value: 0 },
    uExp: { value: 9 },
    uRes: { value: new T.Vector2(1, 1) },
  };
  const mat = new T.ShaderMaterial({
    uniforms: uni,
    vertexShader: 'void main(){ gl_Position = vec4(position.xy, 0.0, 1.0); }',
    fragmentShader: /* glsl */ `
      uniform sampler2D tScene, tText, tGlyph;
      uniform vec2 uGrid, uCell, uOff, uRes;
      uniform float uRamp[12];
      uniform float uN, uColor, uSplit, uExp;
      void main(){
        vec2 p = gl_FragCoord.xy - uOff;
        vec2 cell = floor(p / uCell);
        vec2 inC = fract(p / uCell);
        if (cell.x < 0.0 || cell.y < 0.0 || cell.x >= uGrid.x || cell.y >= uGrid.y) { gl_FragColor = vec4(0.008, 0.01, 0.016, 1.0); return; }
        vec2 uv = (cell + 0.5) / uGrid;
        vec3 c = texture2D(tScene, uv).rgb;
        // 비교 — 왼쪽은 글자로 바꾸기 전 칸 색
        if (gl_FragCoord.x < uSplit * uRes.x) { gl_FragColor = vec4(c, 1.0); return; }
        vec4 tx = texture2D(tText, uv);
        float gi;
        vec3 col;
        if (tx.r > 0.0) {
          gi = floor(tx.r * 255.0 + 0.5) - 1.0;
          col = tx.gba;
        } else {
          float L = dot(c, vec3(0.299, 0.587, 0.114));
          // 노출 곡선 — 어두운 칸도 글자가 촘촘하게 (영상처럼 0 · o 가 깔림), 밝기는 색으로
          float v = clamp(1.0 - exp(-L * uExp), 0.0, 0.999);
          int i = int(floor(v * uN));
          gi = 0.0;
          for (int j = 0; j < 12; j++) if (j == i) gi = uRamp[j];
          float m = max(max(c.r, c.g), max(c.b, 1e-3));
          float b = 0.18 + 0.95 * v * v;
          col = uColor > 0.5 ? clamp(c / m * b, 0.0, 1.0) : vec3(0.36, 1.0, 0.52) * b;
        }
        vec2 g = vec2(mod(gi, ${ACOLS}.0), floor(gi / ${ACOLS}.0));
        float a = texture2D(tGlyph, vec2((g.x + inC.x) / ${ACOLS}.0, 1.0 - (g.y + 1.0 - inC.y) / ${AROWS}.0)).r;
        gl_FragColor = vec4(vec3(0.008, 0.01, 0.016) + col * a, 1.0);
      }`,
    depthTest: false,
    depthWrite: false,
  });
  const scene = new T.Scene();
  const quad = new T.Mesh(new T.PlaneGeometry(2, 2), mat);
  quad.frustumCulled = false;
  scene.add(quad);
  const cam = new T.OrthographicCamera(-1, 1, 1, -1, 0, 1);
  let clock = 6;
  let cols = 0;
  let rows = 0;
  const buf = new T.Vector2();

  const resize = (C: number, R: number): void => {
    cols = C;
    rows = R;
    sc.width = lc.width = C;
    sc.height = lc.height = R;
    textTex.dispose();
    textData = new Uint8Array(C * R * 4);
    textTex = new T.DataTexture(textData, C, R);
    textTex.minFilter = textTex.magFilter = T.NearestFilter;
    uni.tText.value = textTex;
    uni.uGrid.value.set(C, R);
  };
  /** 글자 층에 쓰기 — row 는 위에서부터 */
  const put = (row: number, col: number, str: string, rgb: [number, number, number]): void => {
    if (row < 0 || row >= rows) return;
    const r = rows - 1 - row;
    for (let i = 0; i < str.length; i++) {
      const cc = col + i;
      if (cc < 0 || cc >= cols) continue;
      const k = (r * cols + cc) * 4;
      textData[k] = gIndex(str[i]!) + 1; // 0 = 비어 있음, 빈칸도 「쓴 칸」이라 뒤 장면을 가린다
      textData[k + 1] = rgb[0];
      textData[k + 2] = rgb[1];
      textData[k + 3] = rgb[2];
    }
  };
  const hex = (h: string): [number, number, number] => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];

  const frame = (): void => {
    const p = clock % CYCLE;
    const pl = player(p);
    const ppm = Math.max(2.6, rows / 19); // 칸 / m — 작은 화면에선 더 가까이
    const camX = pl.x * 0.6;
    const camY = pl.y - 1.6;
    const toCell = (x: number, y: number): [number, number] => [cols / 2 + (x - camX) * ppm, rows / 2 + (y - camY) * ppm];
    const W = (gg: G): void => gg.setTransform(ppm, 0, 0, ppm, cols / 2 - camX * ppm, rows / 2 - camY * ppm);
    // 1) 장면
    g.setTransform(1, 0, 0, 1, 0, 0);
    g.globalCompositeOperation = 'source-over';
    g.imageSmoothingEnabled = true;
    g.imageSmoothingQuality = 'high';
    g.fillStyle = '#000';
    g.fillRect(0, 0, cols, rows);
    W(g);
    g.drawImage(world, WX0, WY0, WW, WH);
    const skels = SPAWN.map((_, k) => skeleton(k, p));
    // 소환진 (바닥)
    SPAWN.forEach((s) => {
      const f = seg(p, s.at - 0.6, s.at) * (1 - seg(p, s.at + 1.3, s.at + 2.2));
      if (f <= 0) return;
      g.strokeStyle = `rgba(120,255,160,${(0.8 * f).toFixed(2)})`;
      g.lineWidth = 0.12;
      g.beginPath();
      g.ellipse(s.x, s.y, 1.2, 0.45, 0, 0, Math.PI * 2);
      g.stroke();
    });
    // 쓰러진 뼈 + 배우들 (발 y 순서로)
    DIE.forEach((d, k) => {
      if (p >= d) drawBones(g, skels[k]!.x, skels[k]!.y, p - d, k);
    });
    const actors: { y: number; draw: () => void }[] = [{ y: pl.y, draw: () => drawKnight(g, pl) }];
    skels.forEach((s, k) => {
      if (s.alive) actors.push({ y: s.y, draw: () => drawSkel(g, s, SPAWN[k]!.warrior, k) });
    });
    actors.sort((a, b) => a.y - b.y).forEach((a) => a.draw());
    // 2) 빛 — 주변 어둠 위에 시야 · 횃불 · 소환진 빛을 더해 곱하기
    lg.setTransform(1, 0, 0, 1, 0, 0);
    lg.globalCompositeOperation = 'source-over';
    const amb = o.ambient * 255;
    lg.fillStyle = `rgb(${amb * 0.85},${amb * 0.95},${amb * 1.25})`;
    lg.fillRect(0, 0, cols, rows);
    const fl = (i: number): number => 0.85 + 0.1 * Math.sin(clock * 11 + i * 3.1) + 0.06 * Math.sin(clock * 23.7 + i);
    if (o.lights) {
      W(lg);
      lg.globalCompositeOperation = 'lighter';
      const glow = (x: number, y: number, r: number, c: string): void => {
        const gr = lg.createRadialGradient(x, y, 0, x, y, r);
        gr.addColorStop(0, c);
        gr.addColorStop(1, 'rgba(0,0,0,0)');
        lg.fillStyle = gr;
        lg.fillRect(x - r, y - r, r * 2, r * 2);
      };
      glow(pl.x, pl.y - 1, 7, 'rgba(190,185,170,.95)');
      TORCHES.forEach(([x, y], i) => glow(x, y - 1.5, 5.8 * fl(i), 'rgba(255,135,45,.95)'));
      SPAWN.forEach((s) => {
        const f = seg(p, s.at - 0.6, s.at) * (1 - seg(p, s.at + 1.3, s.at + 2.4));
        if (f > 0) glow(s.x, s.y - 0.5, 3.2, `rgba(90,255,140,${(0.9 * f).toFixed(2)})`);
      });
      glow(0, -92, 3, 'rgba(120,90,200,.35)');
    }
    g.setTransform(1, 0, 0, 1, 0, 0);
    g.globalCompositeOperation = 'multiply';
    g.drawImage(lc, 0, 0);
    // 3) 스스로 빛나는 것 — 불꽃 · 눈빛 · 칼빛
    g.globalCompositeOperation = 'lighter';
    W(g);
    if (o.lights)
      TORCHES.forEach(([x, y], i) => {
        const f = fl(i);
        g.fillStyle = 'rgba(255,120,30,.9)';
        g.beginPath();
        g.ellipse(x, y - 1.62, 0.26 * f, 0.4 * f, 0, 0, Math.PI * 2);
        g.fill();
        g.fillStyle = 'rgba(255,220,120,.9)';
        g.beginPath();
        g.ellipse(x, y - 1.58, 0.12, 0.2 * f, 0, 0, Math.PI * 2);
        g.fill();
      });
    skels.forEach((s) => {
      if (!s.alive || s.rise < 0.6) return;
      g.fillStyle = 'rgba(255,60,40,.95)';
      g.fillRect(s.x - 0.13 + s.face * 0.04, s.y - 1.74, 0.09, 0.08);
      g.fillRect(s.x + 0.04 + s.face * 0.04, s.y - 1.74, 0.09, 0.08);
    });
    if (pl.swing > 0 && pl.swing < 1) {
      g.strokeStyle = `rgba(230,240,255,${(0.9 * (1 - pl.swing)).toFixed(2)})`;
      g.lineWidth = 0.18;
      g.beginPath();
      g.arc(pl.x + pl.face * 0.4, pl.y - 1.1, 1.1, pl.face > 0 ? -2.1 : Math.PI + 2.1, pl.face > 0 ? lerp(-2.1, 0.5, pl.swing) : Math.PI + 2.1 - lerp(0, 2.6, pl.swing), pl.face < 0);
      g.stroke();
    }
    // 끝 — 예배당으로 들어가며 어두워짐, 시작은 밝아짐
    const fade = Math.max(seg(p, 28.4, 29.6), 1 - seg(p, 0, 0.8));
    if (fade > 0) {
      g.setTransform(1, 0, 0, 1, 0, 0);
      g.globalCompositeOperation = 'source-over';
      g.fillStyle = `rgba(0,0,0,${fade.toFixed(2)})`;
      g.fillRect(0, 0, cols, rows);
    }
    sceneTex.needsUpdate = true;

    // 4) 글자 층 — 이름표 · 체력 · 피해 숫자 · 기록 · 상태줄
    textData.fill(0);
    if (o.ui && fade < 0.6) {
      const bar = (row: number, cx: number, w: number, f: number, full: [number, number, number]): void => {
        const n = Math.round(clamp01(f) * w);
        const c0 = Math.round(cx - w / 2);
        put(row, c0, '━'.repeat(n), full);
        put(row, c0 + n, '─'.repeat(w - n), [70, 74, 84]);
      };
      /** 그 줄의 그 칸들이 비었나 (위에서부터 row) */
      const free = (row: number, c0: number, n: number): boolean => {
        if (row < 0 || row >= rows) return true;
        const r = rows - 1 - row;
        for (let c = Math.max(0, c0); c < Math.min(cols, c0 + n); c++) if (textData[(r * cols + c) * 4]) return false;
        return true;
      };
      const label = (x: number, y: number, name: string, hp: number, col: [number, number, number], bc: [number, number, number]): void => {
        const [cx0, cy] = toCell(x, y - 2.25);
        let cx = cx0;
        const w = Math.max(name.length, 10) + 2;
        // 앞서 쓴 이름표와 겹치면 옆 · 위로 비킴 (몸에서 너무 멀어지지 않게 가까운 자리부터)
        let row = Math.round(cy);
        for (const [dr, dc] of [[0, 0], [0, -0.65], [0, 0.65], [-2, 0], [-2, -0.65], [-2, 0.65], [-4, 0]] as const) {
          const r0 = Math.round(cy) + dr;
          const c0 = Math.round(cx + dc * w - w / 2);
          if (free(r0 - 1, c0, w) && free(r0, c0, w)) {
            row = r0;
            cx += dc * w;
            break;
          }
        }
        put(row - 1, Math.round(cx - name.length / 2) - 1, ' ' + name + ' ', col);
        if (hp >= 0) bar(row, cx, Math.max(6, Math.min(10, name.length)), hp, bc);
      };
      label(pl.x, pl.y, 'Rowan', -1, [235, 238, 245], [255, 255, 255]);
      skels.forEach((s, k) => {
        if (s.alive && s.rise > 0.5) label(s.x, s.y, SPAWN[k]!.name, s.hp, s.flash ? [255, 255, 255] : [228, 222, 205], [240, 240, 240]);
      });
      /** 피해 숫자 — 이름표를 덮지 않게 빈 줄을 찾아 위로 */
      const num = (row: number, c0: number, tx: string, rgb: [number, number, number]): void => {
        let r = row;
        while (r > 0 && !free(r, c0 - 1, tx.length + 2)) r--;
        put(r, c0, tx, rgb);
      };
      // 피해 숫자 — 위로 떠오르며 사라짐
      for (const h of HITS)
        if (p >= h.t && p < h.t + 0.9) {
          const s = skels[h.k]!;
          const [cx, cy] = toCell(s.x, s.y - 2.3 - (p - h.t) * 1.6);
          const tx = String(h.dmg);
          num(Math.round(cy) - 2, Math.round(cx - tx.length / 2), tx, [255, 214, 90]);
        }
      for (const h of OUCH)
        if (p >= h.t && p < h.t + 0.9) {
          const [cx, cy] = toCell(pl.x, pl.y - 2.3 - (p - h.t) * 1.6);
          const tx = '-' + h.dmg;
          num(Math.round(cy) - 2, Math.round(cx - tx.length / 2), tx, [255, 96, 80]);
        }
      // 기록 (최근 3줄)
      if (cols >= 44) {
        const seen = LOG.filter(([t]) => t <= p).slice(-3);
        seen.forEach(([, s, c], i) => {
          const dim = i === seen.length - 1 ? 1 : 0.55;
          const [r, gg, b] = hex(c);
          put(1 + i, 2, s.slice(0, cols - 4), [r * dim, gg * dim, b * dim]);
        });
      }
      // 상태줄
      const kills = DIE.filter((d) => p >= d).length;
      const hpw = cols >= 60 ? 10 : 6;
      const left = cols >= 60 ? 'Rowan  Lv 12  HP ' : 'HP ';
      const right = `  ${Math.max(0, pl.hp)}/100` + (cols >= 50 ? `   Kills ${kills}/4` : '');
      put(rows - 2, 2, left, [200, 205, 215]);
      bar(rows - 2, 2 + left.length + hpw / 2, hpw, pl.hp / 100, [214, 70, 60]);
      put(rows - 2, 2 + left.length + hpw, right, [200, 205, 215]);
    }
    textTex.needsUpdate = true;
  };

  return {
    scene,
    camera: cam,
    tone: T.NoToneMapping,
    update(_t, dt) {
      clock += Math.min(dt, 0.1) * o.s;
    },
    render(renderer, w, h) {
      renderer.getDrawingBufferSize(buf);
      const pr = buf.x / Math.max(1, w);
      // 칸 높이 — 자동이면 큰 화면은 줄 50 개쯤, 카드처럼 작은 화면(CSS 높이 330 미만)은 26 줄쯤이라 덜 뭉개지게.
      // 견본 틀은 렌더러 픽셀 비율을 1 로 두고 화면 크기 × devicePixelRatio 로 그리므로 CSS 크기는 devicePixelRatio 로 나눠 잰다
      const dpr = Math.max(1, window.devicePixelRatio || 1) * pr;
      const cssH = h / dpr;
      const ch = (o.cell > 0 ? o.cell : cssH < 330 ? cssH / 26 : Math.max(6, Math.min(14, cssH / 50))) * dpr;
      const cw = ch * 0.6;
      const C = Math.max(8, Math.floor(buf.x / cw));
      const R = Math.max(6, Math.floor(buf.y / ch));
      if (C !== cols || R !== rows) resize(C, R);
      uni.uCell.value.set(cw, ch);
      uni.uOff.value.set((buf.x - C * cw) / 2, (buf.y - R * ch) / 2);
      uni.uRes.value.set(buf.x, buf.y);
      const ramp = RAMPS[o.ramp]!;
      uni.uN.value = ramp.length;
      for (let i = 0; i < 12; i++) uni.uRamp.value[i] = gIndex(ramp[Math.min(i, ramp.length - 1)]!);
      uni.uColor.value = o.color ? 1 : 0;
      uni.uSplit.value = o.split;
      uni.uExp.value = o.exp;
      frame();
      renderer.render(scene, cam);
    },
    controls: [
      { type: 'range', label: '칸 높이 px (0 = 자동)', min: 0, max: 18, step: 1, value: 0, on: (v) => (o.cell = v) },
      { type: 'range', label: '글자 사다리 (0 기호 · 1 동그라미 · 2 블록)', min: 0, max: 2, step: 1, value: 1, on: (v) => (o.ramp = v) },
      { type: 'toggle', label: '칸 색 그대로 (끄면 초록 터미널)', value: true, on: (v) => (o.color = v) },
      { type: 'range', label: '주변 빛 (어둠)', min: 0.04, max: 0.7, step: 0.01, value: 0.3, on: (v) => (o.ambient = v) },
      { type: 'range', label: '글자 노출 (어두운 칸도 글자로)', min: 2, max: 24, step: 0.5, value: 9, on: (v) => (o.exp = v) },
      { type: 'toggle', label: '횃불 · 시야 · 소환진 빛', value: true, on: (v) => (o.lights = v) },
      { type: 'toggle', label: '이름표 · 체력 · 기록도 글자로', value: true, on: (v) => (o.ui = v) },
      { type: 'range', label: '글자 전 칸 색 보기 (왼쪽부터)', min: 0, max: 1, step: 0.01, value: 0, on: (v) => (o.split = v) },
      speedCtl(o),
    ],
    dispose() {
      sceneTex.dispose();
      textTex.dispose();
      glyphs.dispose();
      mat.dispose();
      quad.geometry.dispose();
    },
  };
}
```

### i561 견본 항목 — `src/demos/demosAsciiRpg.ts:784`
```ts
  i561: { kind: '3d', caption: '숨은 캔버스에 그린 어두운 묘지 장면을 칸마다 글자로 — 칸 색 · 횃불 빛 · 땅에서 솟는 해골 · 이름표 · 체력도 글자', make: (T) => makeAsciiRpg(T) }
```

## 관련 기술
- 먼저 알면 좋은 기술: [아스키 렌더](https://ai-techstudio.web.app/ai/t/i238.md) `i238`
- 다음에 해 볼 기술: [영상으로 굽기 (헤드리스 브라우저 → mp4)](https://ai-techstudio.web.app/ai/t/i82.md) `i82` · [감독 투어 + 프레임 단위 녹화](https://ai-techstudio.web.app/ai/t/i89.md) `i89`
- 참고 문서: [Wikipedia — ASCII art](https://en.wikipedia.org/wiki/ASCII_art) · [MDN — CanvasRenderingContext2D.globalCompositeOperation (multiply · lighter)](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation)
