# AI 꾸러미 — 피격 흰 번쩍 — Hit flash (white flash)
> 맞은 물체의 재질만 emissive 흰색을 1 → 0 으로 0.1초 빼서 하얗게 번쩍이게 해, 어느 것이 맞았는지 한눈에 보이게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i201

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

## 주문서

### 만들어 줘: 피격 흰 번쩍 — Hit flash (white flash)

#### 1. 목표
잘못 누른 숫자 블록에 피격 흰 번쩍을 넣어 줘 — 맞은 물체만 emissive 흰색이 1 에서 0 으로 0.1초 동안 빠지게. 분위기는 밝은 퍼즐.

#### 2. 핵심 기술 용어
- **Hit flash (white flash)** — 맞은 물체만 순간 하얗게
- **emissive / emissiveIntensity** — 스스로 빛나는 색과 세기 — 빛과 상관없이 밝아짐
- **Per-object material** — 물체마다 따로 둔 재질 — 하나만 번쩍이게

#### 3. 환경
- 플랫폼: three.js r186 (ES 모듈 · TypeScript, `import * as THREE from "three"`), WebGL2, 외부 라이브러리 추가 없이
- 화면: 브라우저 — PC 와 폰(가로 844×390 · 세로 390×844) 모두, 60fps 목표

#### 4. 조건
- 재질은 물체마다 따로 (공유 재질이면 모두가 번쩍인다)
- emissive 는 미리 흰색으로, 세기만 0 ↔ 1 (emissiveMap 같은 맵을 붙였다 뗐다 하면 셰이더가 다시 컴파일된다)
- 번쩍 길이는 0.03 ~ 0.4초 조절 (견본 0.1)
- 켬 / 끔을 나란히 비교 — 끔 쪽은 톡 튀기만

#### 5. 완성 기준 (이게 보이면 성공)
- 블록 셋 중 잘못 누른 블록만 0.1초 하얗게 번쩍하고 튀며 X 표시가 뜬다
- 왼쪽(끔)은 톡 튀기만 해서 어느 것이 맞았는지 놓치기 쉽다 — 차이가 보인다
- 「번쩍 시간」 을 늘리면 더 오래 하얗게 남는다
- 다른 블록은 전혀 밝아지지 않는다

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

## 원리
- 재질에 emissive 흰색(0xffffff)을 미리 넣어 두고 세기를 0 으로 둔다.
- 맞은 순간부터 세기 = clamp(1 − a / 길이, 0, 1) (길이 견본 0.1초) — 빛 방향과 상관없이 통째로 하얘졌다 돌아온다.
- emissiveMap 이 없으면 무늬까지 하얗게 덮이고, 그림(map)을 emissiveMap 에도 넣으면 무늬 모양대로 밝아진다.
- 튀기 · 찌그러짐 · X 표시와 함께 쓰면 「무엇이 · 틀렸다」 가 또렷해진다.

## 핵심 코드 — 물체마다 재질 + emissive 세기 1 → 0
(발췌: demos/demosJuice.ts D201 을 정리)
```ts
// 물체마다 따로 — emissive 는 처음부터 흰색, 세기만 0
const mats = NUM.map((n, i) => new THREE.MeshStandardMaterial({
  map: faceTex(n, COLORS[i]), roughness: 0.42, emissive: 0xffffff, emissiveIntensity: 0,
}));
const cubes = mats.map((m, i) => {
  const c = new THREE.Mesh(roundBoxGeo, m);
  c.position.set((i - 1) * 1.4, 0.45, 0);
  scene.add(c);
  return c;
});

// 맞은 순간부터 a 초 지남, idx = 맞은 블록
const flash = Math.max(0, Math.min(1, 1 - a / dur)); // dur = 0.1초
mats.forEach((m, i) => (m.emissiveIntensity = i === idx ? flash : 0));

// 함께: 톡 튀기 + 찌그러짐
const c = cubes[idx];
const hop = Math.sin(Math.min(1, a / 0.3) * Math.PI) * 0.22;
const sq = 0.14 * Math.exp(-a * 10) * Math.cos(a * 30);
c.position.y = 0.45 + hop;
c.scale.set(1 + sq, 1 - sq, 1 + sq);
```

## 흔한 실수 · 확인 목록
- [ ] **여러 물체가 재질 하나를 같이 쓰면 다 같이 번쩍인다** — 번쩍일 물체는 재질을 따로 둔다 (견본은 블록마다 MeshStandardMaterial 하나씩).
- [ ] **번쩍일 때 emissiveMap 을 처음 붙이면 첫 번쩍에 멈칫한다** — 맵이 생기면 셰이더 열쇠가 바뀌어 다시 컴파일된다. 처음부터 emissive 흰색 · 세기 0 으로 만들어 두고 세기(uniform)만 바꾼다.
- [ ] **색을 흰색으로 바꾸는 방식(color.set)은 그늘진 면이 덜 하얘진다** — emissive 는 빛과 상관없이 더해지므로 모든 면이 고르게 하얘진다.

## 완성 기준 체크리스트
- [ ] 블록 셋 중 잘못 누른 블록만 0.1초 하얗게 번쩍하고 튀며 X 표시가 뜬다
- [ ] 왼쪽(끔)은 톡 튀기만 해서 어느 것이 맞았는지 놓치기 쉽다 — 차이가 보인다
- [ ] 「번쩍 시간」 을 늘리면 더 오래 하얗게 남는다
- [ ] 다른 블록은 전혀 밝아지지 않는다

## 이 기술 정보
- id: `i201` · 분류: 이펙트 · 연출 › 손맛 · 주스 · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 값 하나 바꾸기. 재질을 물체마다 두면 그리기 묶음이 그만큼 늘어난다 (수백 개면 인스턴스 색으로).
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i201
- 쓰면 좋을 때: 여러 물체 중 어느 것이 맞았는지 알려 줄 때 / 틀린 입력을 짧게 알려 줄 때
- 쓰지 말 때: 아주 작은 물체 — 번쩍이 안 보인다. 대신 둘레 고리 · 흔들기 / 툰 재질 장면 — MeshToonMaterial 도 emissive 가 있으니 같은 방법으로 (재질만 바꾸지 말 것)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### D201 — `src/demos/demosJuice.ts:2972`
```ts
const D201: Demo = {
  kind: '3d',
  caption: '잘못 누른 블록만 0.1초 하얗게 번쩍 (emissive 1 → 0) — 왼쪽은 톡 튀기만 해서 어느 것이 맞았는지 놓치기 쉽다',
  make() {
    const scene = new THREE.Scene();
    scene.background = skyTex('#26307a', '#0c1030');
    const cam = new THREE.PerspectiveCamera(36, 1.6, 0.1, 50);
    cam.position.set(0, 3.4, 6.6);
    cam.lookAt(0, 0.55, 0);
    scene.add(new THREE.HemisphereLight(0xc8d6ff, 0x2a2050, 1.6));
    const sun = new THREE.DirectionalLight(0xffffff, 2.2);
    sun.position.set(3, 6, 5);
    scene.add(sun);
    const floor = new THREE.Mesh(new THREE.CylinderGeometry(2.6, 2.75, 0.3, 64), new THREE.MeshStandardMaterial({ color: 0x4a57b8, roughness: 0.75 }));
    floor.position.y = -0.15;
    scene.add(floor);
    const floorTop = new THREE.Mesh(new THREE.RingGeometry(2.35, 2.5, 64), new THREE.MeshBasicMaterial({ color: 0x8fa0ff }));
    floorTop.rotation.x = -Math.PI / 2;
    floorTop.position.y = 0.005;
    scene.add(floorTop);
    const geo = roundBox(0.9, 0.9, 0.9, 0.16);
    const NUM = ['3', '8', '5'];
    const mats: THREE.MeshStandardMaterial[] = [];
    const cubes: THREE.Mesh[] = [];
    const shadows: THREE.Mesh[] = [];
    const dot = dotTex();
    for (let i = 0; i < 3; i++) {
      const m = new THREE.MeshStandardMaterial({ map: faceTex(NUM[i]!, BLK[[0, 4, 3][i]!]!), roughness: 0.42, emissive: 0xffffff, emissiveIntensity: 0 });
      mats.push(m);
      const c = new THREE.Mesh(geo, m);
      c.position.set((i - 1) * 1.4, 0.45, 0);
      scene.add(c);
      cubes.push(c);
      const sh = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 1.2), new THREE.MeshBasicMaterial({ map: dot, color: 0x000000, transparent: true, opacity: 0.5, depthWrite: false }));
      sh.rotation.x = -Math.PI / 2;
      sh.position.set(c.position.x, 0.01, 0);
      scene.add(sh);
      shadows.push(sh);
    }
    const xTex = canvasTex(128, 128, (g) => {
      g.beginPath();
      g.arc(64, 64, 52, 0, TAU);
      g.fillStyle = '#ff3b5c';
      g.fill();
      g.strokeStyle = '#fff';
      g.lineWidth = 8;
      g.stroke();
      g.lineWidth = 14;
      g.lineCap = 'round';
      g.beginPath();
      g.moveTo(42, 42);
      g.lineTo(86, 86);
      g.moveTo(86, 42);
      g.lineTo(42, 86);
      g.stroke();
    });
    const xs = new THREE.Sprite(new THREE.SpriteMaterial({ map: xTex, transparent: true, depthTest: false, toneMapped: false }));
    scene.add(xs);
    const hud = new Hud();
    hud.splitLabels();
    let compare = true;
    let dur = 0.1;
    let flash = 0;
    let idx = 0;
    const ORDER = [1, 0, 2, 1, 2, 0];
    return {
      scene,
      camera: cam,
      update(t) {
        const P = 1.0;
        const k = Math.floor(t / P);
        const a = t % P;
        idx = ORDER[k % ORDER.length]!;
        flash = clamp01(1 - a / dur);
        cubes.forEach((c, i) => {
          if (i === idx) {
            const hop = Math.sin(clamp01(a / 0.3) * Math.PI) * 0.22;
            const sq = 0.14 * Math.exp(-a * 10) * Math.cos(a * 30);
            c.position.y = 0.45 + hop;
            c.scale.set(1 + sq, 1 - sq, 1 + sq);
            c.rotation.z = Math.sin(a * 26) * 0.08 * Math.exp(-a * 6);
            shadows[i]!.scale.setScalar(1 - hop);
          } else {
            c.position.y = 0.45;
            c.scale.set(1, 1, 1);
            c.rotation.z = 0;
            shadows[i]!.scale.setScalar(1);
          }
          c.rotation.y = Math.sin(t * 0.8 + i) * 0.12;
        });
        xs.position.set(cubes[idx]!.position.x, 1.35 + a * 0.35, 0.2);
        const xsc = backOut(a / 0.2) * 0.55;
        xs.scale.set(xsc, xsc, 1);
        xs.material.opacity = 1 - smooth(0.45, 0.75, a);
      },
      render(r, w, h) {
        splitRender(r, w, h, scene, cam, compare, (on) => mats.forEach((m, i) => (m.emissiveIntensity = on && i === idx ? flash : 0)), 0.82);
        hud.render(r, w, h, compare, true);
      },
      controls: [tgl('나란히 비교 (끔 | 켬)', compare, (v) => (compare = v)), rng('번쩍 시간 (초)', 0.03, 0.4, 0.01, dur, (v) => (dur = v))],
      dispose() {
        disposeScene(scene);
        hud.dispose();
      },
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [PBR + 클리어코트](https://ai-techstudio.web.app/ai/t/u04.md) `u04`
- 다음에 해 볼 기술: [튀어 오르는 숫자](https://ai-techstudio.web.app/ai/t/i202.md) `i202` · [흔들흔들 거부 (고개 젓기)](https://ai-techstudio.web.app/ai/t/i207.md) `i207`
