09히어로 씬 — 낮밤 조명과 시차가 함께 도는 완성판
광원 각도 변수를 낮 각도에서 밤 각도로 트랜지션하면 03 의 면 셰이딩 공식이 그대로 다시 계산되어 낮밤이 바뀝니다. 여기에 05 의 포인터 시차를 얹어 씬을 만지면서 조명 토글도 누르는 히어로 배너 하나로 마무리합니다.
쓰임랜딩 히어로, 브랜드 배너
CSS custom propertiestransitionpointermove

<main class="stage">
<div class="dn" id="dn">
<p class="dn__hint">포인터 세로 위치가 낮밤 진행도(t)를 정합니다 — 하늘·창문은 t 로 매 프레임 보간, 해→달은 transition 으로 교체됩니다</p>
<div class="dn__field" role="application" tabindex="0" aria-label="포인터를 위아래로 움직이면 등각 마을이 낮에서 밤으로 바뀌는 장면" id="field">
<div class="dn__sky" id="sky">
<div class="dn__orb dn__orb--sun" id="sun"></div>
<div class="dn__orb dn__orb--moon" id="moon"></div>
</div>
<div class="dn__world" id="world"></div>
</div>
<p class="dn__meta">밤 진행도 <b id="pct">0</b>%</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 낮밤 히어로(09) — 무대는 먹. 하늘색·창문 밝기는 JS 가 낮밤 진행도(t)로 매 프레임 직접
// 보간해 칠한다(transition 없이 매끄럽다). 해→달 교체만 t=0.5 문턱에서 is-night 클래스로
// 진짜 opacity transition 크로스페이드를 튼다.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue-light;
@include stage($stage-ink);
.dn {
display: flex; flex-direction: column; align-items: center; gap: $sp-2;
width: 100%; max-width: 420px;
}
.dn__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.6vw, 12px); line-height: 1.35;
color: $subject-cream; text-align: center;
}
.dn__field {
position: relative;
width: clamp(180px, 42vw, 220px); height: clamp(140px, 34vw, 168px);
display: grid; place-items: center;
border-radius: $r-sm;
overflow: hidden;
outline: none;
}
.dn__field:focus-visible { box-shadow: inset 0 0 0 2px rgba($subject-cream, .6); }
.dn__sky {
position: absolute; inset: 0;
background-color: $subject-blue-light;
border-radius: $r-sm;
}
.dn__orb {
position: absolute; left: 50%; top: 50%;
width: 16px; height: 16px; border-radius: $r-pill;
margin-left: -8px; margin-top: -8px;
transition: opacity $duration $easing;
}
.dn__orb--sun { background: $stage-yellow; box-shadow: 0 0 10px rgba($stage-yellow, .8); opacity: 1; }
.dn__orb--moon { background: $subject-cream; box-shadow: 0 0 8px rgba($subject-cream, .7); opacity: 0; }
.dn__field.is-night .dn__orb--sun { opacity: 0; }
.dn__field.is-night .dn__orb--moon { opacity: 1; }
.dn__world { position: relative; transform-style: preserve-3d; width: 1px; height: 1px; }
.dn__bld {
position: absolute; left: 0; top: 0;
width: 34px; height: 34px;
transform: translate3d(var(--tx), var(--ty), 0) translate(-50%, -50%);
background: $subject-ink;
border: 1px solid rgba($subject-cream, .2);
border-radius: $r-xs;
box-shadow: $shadow-press;
}
.dn__win {
position: absolute; left: 50%; top: 50%;
width: 6px; height: 6px;
transform: translate(-50%, -50%);
background: $stage-yellow;
border-radius: $r-xs;
}
.dn__meta { margin: 0; font-size: clamp(9px, 2.4vw, 11px); color: rgba($subject-cream, .82); }
.dn__meta b { font-weight: 800; color: $stage-yellow; font-variant-numeric: tabular-nums; }
@media (max-width: 340px) {
.dn { gap: $sp-1; }
.dn__field { width: 156px; height: 112px; }
.dn__hint { font-size: 8px; }
.dn__meta { font-size: 7px; }
}
// reduce — 포인터 추적·자동 순환이 멎는다. 0.6(밤 쪽) 진행도로 고정된 정지 장면만 남긴다.
@media (prefers-reduced-motion: reduce) {
.dn.is-still .dn__orb { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.dn {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
max-width: 420px;
}
.dn__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1.35;
color: #fff7e6;
text-align: center;
}
.dn__field {
position: relative;
width: clamp(180px, 42vw, 220px);
height: clamp(140px, 34vw, 168px);
display: grid;
place-items: center;
border-radius: 8px;
overflow: hidden;
outline: none;
}
.dn__field:focus-visible {
box-shadow: inset 0 0 0 2px rgba(255, 247, 230, 0.6);
}
.dn__sky {
position: absolute;
inset: 0;
background-color: #5b8cf8;
border-radius: 8px;
}
.dn__orb {
position: absolute;
left: 50%;
top: 50%;
width: 16px;
height: 16px;
border-radius: 999px;
margin-left: -8px;
margin-top: -8px;
transition: opacity 300ms ease-out;
}
.dn__orb--sun {
background: #ffd23f;
box-shadow: 0 0 10px rgba(255, 210, 63, 0.8);
opacity: 1;
}
.dn__orb--moon {
background: #fff7e6;
box-shadow: 0 0 8px rgba(255, 247, 230, 0.7);
opacity: 0;
}
.dn__field.is-night .dn__orb--sun {
opacity: 0;
}
.dn__field.is-night .dn__orb--moon {
opacity: 1;
}
.dn__world {
position: relative;
transform-style: preserve-3d;
width: 1px;
height: 1px;
}
.dn__bld {
position: absolute;
left: 0;
top: 0;
width: 34px;
height: 34px;
transform: translate3d(var(--tx), var(--ty), 0) translate(-50%, -50%);
background: #17141a;
border: 1px solid rgba(255, 247, 230, 0.2);
border-radius: 4px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.dn__win {
position: absolute;
left: 50%;
top: 50%;
width: 6px;
height: 6px;
transform: translate(-50%, -50%);
background: #ffd23f;
border-radius: 4px;
}
.dn__meta {
margin: 0;
font-size: clamp(9px, 2.4vw, 11px);
color: rgba(255, 247, 230, 0.82);
}
.dn__meta b {
font-weight: 800;
color: #ffd23f;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.dn {
gap: 4px;
}
.dn__field {
width: 156px;
height: 112px;
}
.dn__hint {
font-size: 8px;
}
.dn__meta {
font-size: 7px;
}
}
@media (prefers-reduced-motion: reduce) {
.dn.is-still .dn__orb {
transition: none;
}
}// 낮밤 히어로(09) — 포인터 세로 위치 → 낮밤 진행도 t(0~1). 하늘색·창문 밝기는 t 로
// 매 프레임 직접 보간(JS lerp, transition 없이 매끄럽다). 해↔달 교체만 t=0.5 문턱에서
// 클래스를 토글해 진짜 CSS transition(opacity)으로 크로스페이드한다. 입력이 없으면
// requestAnimationFrame 루프가 자체 경로로 낮밤을 순환시킨다(자동 시연).
(function () {
var root = document.getElementById('dn');
var field = document.getElementById('field');
var sky = document.getElementById('sky');
var world = document.getElementById('world');
var sun = document.getElementById('sun');
var moon = document.getElementById('moon');
var pctEl = document.getElementById('pct');
if (!root || !field || !sky || !world || !sun || !moon || !pctEl) return;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, t) { return a + (b - a) * t; }
function mat4RotateX(deg) {
var r = deg * Math.PI / 180, c = Math.cos(r), s = Math.sin(r);
return [1, 0, 0, 0, 0, c, s, 0, 0, -s, c, 0, 0, 0, 0, 1];
}
function mat4RotateZ(deg) {
var r = deg * Math.PI / 180, c = Math.cos(r), s = Math.sin(r);
return [c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
}
function mat4Mul(a, b) {
var out = new Array(16);
for (var col = 0; col < 4; col++) {
for (var row = 0; row < 4; row++) {
var sum = 0;
for (var k = 0; k < 4; k++) sum += a[k * 4 + row] * b[col * 4 + k];
out[col * 4 + row] = sum;
}
}
return out;
}
function mat4ToCss(m) {
var s = '';
for (var i = 0; i < m.length; i++) s += (i ? ',' : '') + m[i].toFixed(6);
return 'matrix3d(' + s + ')';
}
world.style.transform = mat4ToCss(mat4Mul(mat4RotateX(-35.264), mat4RotateZ(45)));
var mm = window.matchMedia('(max-width: 340px)').matches;
var N = 2;
var TILE_W = mm ? 32 : 40, TILE_H = mm ? 16 : 20;
var wins = [];
for (var row = 0; row < N; row++) {
for (var col = 0; col < N; col++) {
var x = (col - row) * TILE_W / 2;
var y = (col + row) * TILE_H / 2;
var bld = document.createElement('div');
bld.className = 'dn__bld';
bld.style.setProperty('--tx', x.toFixed(2) + 'px');
bld.style.setProperty('--ty', y.toFixed(2) + 'px');
bld.style.zIndex = String(Math.round((col + row) * 10));
var win = document.createElement('div');
win.className = 'dn__win';
bld.appendChild(win);
world.appendChild(bld);
wins.push(win);
}
}
// 낮 하늘색 → 밤 하늘색 (RGB 보간, day.length === night.length)
var DAY = [91, 140, 248]; // $subject-blue-light 근사
var NIGHT = [23, 20, 26]; // $stage-ink
var targetT = 0, curT = 0;
var lastInput = -Infinity;
var IDLE_MS = 1100;
field.addEventListener('pointermove', function (ev) {
var rect = field.getBoundingClientRect();
var ny = (ev.clientY - rect.top) / rect.height;
targetT = clamp(ny, 0, 1);
lastInput = performance.now();
});
field.addEventListener('pointerleave', function () { lastInput = -Infinity; });
var wasNight = false;
function paint(now, t) {
if (now - lastInput > IDLE_MS) targetT = (1 - Math.cos(t * 3)) / 2;
curT += (targetT - curT) * 0.14;
var r = Math.round(lerp(DAY[0], NIGHT[0], curT));
var g = Math.round(lerp(DAY[1], NIGHT[1], curT));
var b = Math.round(lerp(DAY[2], NIGHT[2], curT));
sky.style.backgroundColor = 'rgb(' + r + ',' + g + ',' + b + ')';
for (var i = 0; i < wins.length; i++) {
wins[i].style.opacity = String((0.15 + curT * 0.85).toFixed(2));
}
var isNight = curT > 0.5;
if (isNight !== wasNight) {
field.classList.toggle('is-night', isNight);
wasNight = isNight;
}
var orbY = mm ? 60 : 76;
var ox = (curT - 0.5) * (mm ? 120 : 150);
var oy = -Math.cos(curT * Math.PI) * orbY;
sun.style.transform = 'translate3d(' + ox.toFixed(1) + 'px,' + oy.toFixed(1) + 'px,0)';
moon.style.transform = 'translate3d(' + ox.toFixed(1) + 'px,' + oy.toFixed(1) + 'px,0)';
pctEl.textContent = String(Math.round(curT * 100));
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
targetT = 0.6; curT = 0.6;
paint(0, 0.3);
root.classList.add('is-still');
} else {
var start = performance.now();
(function step(now) {
paint(now, (now - start) / 1000);
requestAnimationFrame(step);
})(start);
}
})();// IsometricHeroDayNight — vanilla 판(run/333/demo/09-isometric-hero-day-night/index.html)
// 의 React 포트. 포인터 세로 위치가 낮밤 진행도(t)를 정한다 — 하늘·창문 색은 t 로 매
// 프레임 RGB 보간하고, 해→달은 t>0.5 에서 클래스 토글로 한 프레임에 컷 교체한다.
import { useEffect, useRef } from "react";
import styles from "./IsometricHeroDayNight.module.scss";
export type IsometricHeroDayNightProps = {
duration?: string;
easing?: string;
color?: string;
};
type Box<T> = { current: T };
const HINT = "포인터 세로 위치가 낮밤 진행도(t)를 정합니다 — 하늘·창문은 t 로 매 프레임 보간, 해→달은 transition 으로 교체됩니다";
const ARIA_FIELD = "포인터를 위아래로 움직이면 등각 마을이 낮에서 밤으로 바뀌는 장면";
const LABEL_NIGHT = "밤 진행도 ";
const SKY_DAY = [110, 200, 255];
const SKY_NIGHT = [23, 20, 26];
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
function mixRgb(a: number[], b: number[], t: number): string {
return (
"rgb(" +
Math.round(lerp(a[0], b[0], t)) +
"," +
Math.round(lerp(a[1], b[1], t)) +
"," +
Math.round(lerp(a[2], b[2], t)) +
")"
);
}
export default function IsometricHeroDayNight({
duration = "600ms",
easing = "ease-out",
color = "#5b8cf8"
}: IsometricHeroDayNightProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const skyRef = useRef<HTMLDivElement | null>(null);
const nightPctRef = useRef<HTMLElement | null>(null);
const stateRef = useRef({ targetT: 0.2, curT: 0.2 }) as Box<{ targetT: number; curT: number }>;
const rafRef = useRef(0) as Box<number>;
useEffect(() => {
const field = fieldRef.current;
const sky = skyRef.current;
const root = rootRef.current;
if (!field || !sky || !root) return;
function onMove(e: PointerEvent): void {
if (!field) return;
const rect = field.getBoundingClientRect();
const ny = (e.clientY - rect.top) / rect.height;
stateRef.current.targetT = ny < 0 ? 0 : ny > 1 ? 1 : ny;
}
field.addEventListener("pointermove", onMove);
function paint(): void {
const s = stateRef.current;
s.curT += (s.targetT - s.curT) * 0.12;
sky!.style.background = mixRgb(SKY_DAY, SKY_NIGHT, s.curT);
const isNight = s.curT > 0.5;
field!.classList.toggle(styles.isNight, isNight);
if (nightPctRef.current) nightPctRef.current.textContent = String(Math.round(s.curT * 100));
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
stateRef.current.curT = 0.5;
stateRef.current.targetT = 0.5;
paint();
root.classList.add(styles.isStill);
} else {
const step = (): void => {
paint();
rafRef.current = requestAnimationFrame(step);
};
rafRef.current = requestAnimationFrame(step);
}
return () => {
cancelAnimationFrame(rafRef.current);
field.removeEventListener("pointermove", onMove);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.hn} ref={rootRef} data-duration={duration} data-easing={easing} data-color={color}>
<p className={styles.hint}>{HINT}</p>
<div className={styles.field} ref={fieldRef} role="img" aria-label={ARIA_FIELD} tabIndex={0}>
<div className={styles.sky} ref={skyRef}></div>
<div className={styles.sun}></div>
<div className={styles.moon}></div>
<div className={styles.town}>
<div className={styles.house}></div>
<div className={styles.house}></div>
<div className={styles.house}></div>
</div>
</div>
<p className={styles.meta}>
<span className={styles.metaLabel}>{LABEL_NIGHT}</span>
<b className={styles.nightVal} ref={nightPctRef}>20</b>%
</p>
</div>
</main>
);
}09 히어로 씬 — 낮밤 조명과 시차가 함께 도는 완성판 — 측정 누적면적 21.418% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 아이소메트릭 디자인 — 진짜 등각투영 행렬로 짓는 3D 다이오라마 씬 9종
이 데모가 속한 글
아이소메트릭 디자인 — 진짜 등각투영 행렬로 짓는 3D 다이오라마 씬 9종큐브 세 면의 명도를 광원 각도로 계산하고, 격자 좌표를 2:1 공식으로 화면 좌표로 바꿔 등각 투영을 짭니다. 깊이값으로 쌓임 순서를 정렬하는 페인터 알고리즘까지 — 방·도시 다이오라마 9종, 코드는 비밀번호 zi 이 글의 데모 전부 보기


