住人のいるスイッチ
@Lattice_Node
部品 · スライダー
見られた数 4,054コピーされた数 324
<!-- UI by @Lattice_Node — ludion.ai/p/slider-resident (MIT) -->
<div data-ld-part="slider-resident" lang="ja">
<span class="shade" aria-hidden="true"></span>
<canvas class="scene" aria-hidden="true"></canvas>
<input class="range" type="range" min="0" max="100" step="1" value="72" aria-label="音量">
<span class="probe" aria-hidden="true"></span>
</div>
<style>
/* UI by @Lattice_Node — ludion.ai/p/slider-resident (MIT) */
[data-ld-part="slider-resident"] {
/* 固定しないトークン。部屋の色は、この5色の色相に明るさを与えて並べて作る */
--_bg: var(--ld-bg, #e9e5ee);
--_surface: var(--ld-surface, #a8703f);
--_fg: var(--ld-fg, #231a3f);
--_muted: var(--ld-muted, #6a80b4);
--_accent: var(--ld-accent, #ff7b3a);
--_radius: var(--ld-radius, 9999px);
--_font: var(--ld-font, "Hiragino Sans", "Yu Gothic UI", "Noto Sans JP", system-ui, sans-serif);
--_space: var(--ld-space, 8px);
--_shadow: var(--ld-shadow, 0 1px 2px rgb(35 26 63 / .22), 0 6px 14px rgb(35 26 63 / .12));
--_motion: var(--ld-motion, 200ms);
position: relative;
display: block;
box-sizing: border-box;
/* どの枠にも収まる。横長の丸(1 : 3.2)のまわりに、影の余白を含めた比率。
枠の幅の 90% までにして、ナビのような「幅いっぱいの部品」ではなく、ふつうの部品として中央で撮られるようにする */
width: min(100%, 90vw, calc((100vh - 64px) * 2.5714));
aspect-ratio: 2.5714;
font-family: var(--_font);
color: var(--_fg);
}
/* 色を読むための見えない目印 */
[data-ld-part="slider-resident"] .probe {
display: none;
}
/* トークンの影。大きな影は canvas が大きさに合わせて描く */
[data-ld-part="slider-resident"] .shade {
position: absolute;
display: block;
border-radius: var(--_radius);
box-shadow: var(--_shadow);
pointer-events: none;
}
[data-ld-part="slider-resident"] .scene {
position: absolute;
top: 0;
left: 0;
display: block;
width: 100%;
height: 100%;
pointer-events: none;
}
/* 本物のスライダー。絵は canvas が描き、ここは操作だけを受ける。位置と幅は part.js がレンズに合わせる */
[data-ld-part="slider-resident"] .range {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
border: 0;
border-radius: var(--_radius);
background: transparent;
cursor: grab;
touch-action: pan-y;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
appearance: none;
-webkit-appearance: none;
}
[data-ld-part="slider-resident"] .range:active {
cursor: grabbing;
}
[data-ld-part="slider-resident"] .range:focus {
outline: none;
}
[data-ld-part="slider-resident"] .range:focus-visible {
outline: 2px solid var(--_accent);
outline-offset: calc(var(--_space) * .75);
transition: outline-offset var(--_motion) ease;
}
/* 使えるなら、輪は丸全体のまわりに出す。
絵は canvas が全部描くので、入力欄そのものは見えなくする。透明を指定しても、スマホのブラウザによっては
つまみの箱が薄い四角として光る。opacity なら、ブラウザが何を描いても見えない。押す、動かす、キーボードはそのまま効く */
@supports selector(:has(*)) {
[data-ld-part="slider-resident"] .range {
opacity: 0;
}
[data-ld-part="slider-resident"] .range:focus-visible {
outline: none;
}
[data-ld-part="slider-resident"]:has(.range:focus-visible) .shade {
outline: 2px solid var(--_accent);
outline-offset: calc(var(--_space) * .75);
}
}
/* つまみの幅をレンズに合わせると、指の位置とレンズの中心が一致する */
[data-ld-part="slider-resident"] .range::-webkit-slider-runnable-track {
height: 100%;
background: transparent;
border: 0;
}
[data-ld-part="slider-resident"] .range::-moz-range-track {
height: 100%;
background: transparent;
border: 0;
}
[data-ld-part="slider-resident"] .range::-webkit-slider-thumb {
width: var(--thumb, 96px);
height: 100%;
background: transparent;
border: 0;
box-shadow: none;
outline: none;
opacity: 0;
appearance: none;
-webkit-appearance: none;
}
[data-ld-part="slider-resident"] .range::-moz-range-thumb {
width: var(--thumb, 96px);
height: 100%;
background: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
outline: none;
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
[data-ld-part="slider-resident"] .range:focus-visible {
transition: none;
}
}
</style>
<script type="module">
/* UI by @Lattice_Node — ludion.ai/p/slider-resident (MIT) */
// part.js — ESモジュール。rootの中だけを触る
// 住人のいる音量:音量のスライダーのレールの中に、ピクセルの部屋があり、住人が1人暮らしている。
//
// 部屋:低い解像度で、パレットの番号だけで描く。光は段階とディザで表し、中間色を作らない。常に整数倍で写す。
// 外側:磨かれた面、くぼんだガラス、液体のレンズを WebGL で描く。レンズは下の絵を少し拡大する。
// 音:スピーカーから出る波は、レンズのところまで届く。波が触れたものは揺れる。音量が、そのまま部屋の出来事になる。
// 色は JS に書かない。--_bg、--_surface、--_fg、--_muted、--_accent の色相に、決めた明るさを与えて並べて作る。
const CORE_W = 240; // 部屋の中心の幅。窓が広いぶんは、両わきへ広がる
const CORE_H = 72;
const MARGIN = 12; // レンズが窓の外を覗いてもよいよう、世界を少し広く描く
const WORLD_HZ = 30;
const WORLD_SLACK = 0.004; // 画面の更新の時刻は揺れる。この秒数まで足りなくても、次のコマを出す
const BPM = 108;
const PILL_RATIO = 3.2; // 縦 : 横
const READ_TO = 30;
const TAP_TO = 62;
const MAX_FROM = 98;
// 部屋の間取り(世界の座標)
const WALL_X = 206; // 隣の部屋との壁
const LAMP_X = 146;
const LAMP_Y = 21;
const SOFA_X = 122;
const DANCE_X = 174;
const TIDY_X = 176;
const FLOOR_Y = 58; // 足が着く高さ
const SPK_X = 45;
const SPK_Y = 42;
const FRAME_X = 163;
const FRAME_Y = 10;
const FRAME_W = 20;
const FRAME_H = 16;
const BALL_X = 186;
// ---- パレット:色相 × 8段の明るさ
const TONES = [0.17, 0.25, 0.34, 0.44, 0.55, 0.67, 0.79, 0.91];
const NT = TONES.length;
// [トークン, 色相を回す角度, 鮮やかさの倍率]
const RAMPS = [
['fg', 0, 1], // 夜と影
['muted', 0, 1], // 月の光、布
['accent', 0, 0.3], // 漆喰、肌
['surface', 0, 1], // 木
['accent', 0, 1.05], // 灯りと音
['surface', 82, 0.95], // 葉
['bg', 0, 1], // 紙、白いもの
['accent', -72, 1.05], // 色の光 1
['accent', 148, 1], // 色の光 2
['accent', -72, 0.6], // 淡い光 1
['accent', 148, 0.56], // 淡い光 2
['accent', 0, 0.62], // 淡い光 3
['surface', 0, 0.46], // 肌
];
const RK = 0, RU = 1, RW = 2, RS = 3, RA = 4, RG = 5, RC = 6, RP = 7, RT = 8, LP = 9, LT = 10, LA = 11, RN = 12;
// ---- 材質:[名前, 色相, 明るさ, 自ら光るか, 光の場へ出す強さ]
const MATS = [
['NONE', RK, 0, 1, 0],
// 建物の断面
['SLAB', RK, 0, 1, 0],
['SLAB_LT', RK, 1, 1, 0],
['CUT', RK, 2, 1, 0],
['BRICK', RS, 1, 1, 0],
['BRICK_LT', RS, 2, 1, 0],
// 壁と床
['WALL', RW, 6],
['WALL_D', RW, 5],
['WALL_HI', RW, 7],
['FRAME_SH', RW, 3],
['TRIM', RC, 6],
['TRIM_D', RC, 4],
['FLOOR', RS, 4],
['FLOOR_D', RS, 3],
['FLOOR_LT', RS, 5],
['RUG', RP, 4],
['RUG_D', RP, 3],
['RUG_LT', RC, 5],
// 木の家具
['WOOD', RS, 3],
['WOOD_D', RS, 2],
['WOOD_DD', RS, 1],
['WOOD_LT', RS, 4],
['WOOD_HI', RS, 5],
// スピーカー
['BAFFLE', RK, 1],
['CONE', RK, 2],
['CONE_D', RK, 0],
['CONE_HI', RK, 4],
['CAP', RU, 5],
['RING', RU, 3],
['METAL', RU, 4],
['METAL_HI', RC, 5],
['LED_OFF', RK, 2, 1, 0],
['LED_G', RG, 7, 1, 0.5],
['LED_A', RA, 6, 1, 0.6],
['LED_R', RP, 6, 1, 0.7],
// 本
['BK_S', RS, 5],
['BK_U', RU, 5],
['BK_A', RA, 5],
['BK_G', RG, 4],
['BK_P', RP, 4],
['BK_T', RT, 4],
['BK_C', RC, 6],
['BK_K', RK, 4],
['BK_D', RK, 2],
['BK_HI', RC, 7],
// 窓
['SKY0', RK, 0, 1, 0],
['SKY1', RK, 0, 1, 0],
['SKY2', RK, 1, 1, 0.03],
['MOON', RC, 7, 1, 0.6],
['MOON_D', RC, 5, 1, 0.35],
['STAR', RC, 6, 1, 0.3],
['STAR_D', RU, 5, 1, 0.1],
['BLD', RK, 1, 1, 0],
['BLD_F', RK, 2, 1, 0],
['BLD_W', RA, 6, 1, 0.3],
['CURTAIN', RU, 5],
['CURTAIN_D', RU, 3],
['CURTAIN_HI', RU, 6],
// ソファ
['SOFA', RU, 4],
['SOFA_D', RU, 3],
['SOFA_HI', RU, 5],
['PILLOW', RP, 5],
['PILLOW_D', RP, 4],
['BLANKET', RA, 5],
['BLANKET_D', RA, 4],
['BLANKET_HI', RC, 6],
// 灯り
['POLE', RK, 3],
['POLE_HI', RU, 5],
['SHADE', RW, 5],
['SHADE_D', RW, 4],
['SHADE_ON', RA, 6, 1, 0.85],
['SHADE_HOT', RA, 7, 1, 1.15],
['SHADE_RIM', RA, 5, 1, 0.55],
['BULB', RC, 7, 1, 1.3],
// 鏡の玉と光の粒
['BALL', RC, 4],
['BALL_D', RU, 3],
['BALL_HI', RC, 7, 1, 1.1],
['WIRE', RK, 2],
['DOT_P', RP, 7, 1, 0.34],
['DOT_T', RT, 7, 1, 0.34],
['DOT_A', RA, 7, 1, 0.34],
['DOT_C', RC, 7, 1, 0.34],
// 音の波
['WAVE_HI', RA, 7, 1, 0.95],
['WAVE', RA, 6, 1, 0.7],
['WAVE_D', RA, 4, 1, 0.34],
// 葉と鉢
['LEAF', RG, 5],
['LEAF_D', RG, 3],
['LEAF_HI', RG, 6],
['STEM', RG, 4],
['POT', RC, 5],
['POT_HI', RC, 6],
['POT_D', RC, 3],
['SOIL', RK, 1],
// 額の中の絵
['ART_FR', RS, 2],
['ART_FR_HI', RS, 5],
['ART_MAT', RC, 6],
['ART_SKY', RP, 6],
['ART_SKY2', RA, 6],
['ART_SUN', RC, 7],
['ART_M1', RU, 4],
['ART_M2', RU, 2],
['ART_SEA', RT, 4],
// 住人
['HAIR', RK, 2],
['HAIR_HI', RK, 4],
['SKIN', RN, 6],
['SKIN_D', RN, 5],
['EYE', RK, 0],
['CHEEK', RA, 6],
['MOUTH', RA, 3],
['SHIRT', RC, 6],
['SHIRT_D', RC, 4],
['STRIPE', RA, 5],
['PANTS', RU, 3],
['PANTS_D', RU, 2],
['SHOE', RC, 7],
['MOON_SKIN', RU, 5, 1, 0],
['MOON_SKIN_D', RU, 3, 1, 0],
['MOON_HAIR', RK, 1, 1, 0],
['MOON_EYE', RK, 0, 1, 0],
['OUTLINE', RK, 0, 1, 0],
['PHONE', RA, 5],
['PHONE_D', RK, 3],
// 隣の人
['NB_HAIR', RK, 3],
['NB_SKIN', RN, 5],
['NB_SKIN_D', RN, 4],
['NB_SHIRT', RT, 4],
['NB_SHIRT_D', RT, 3],
['NB_STRIPE', RT, 5],
['NB_PANTS', RK, 3],
['NB_PANTS_D', RK, 2],
['NB_SHOE', RU, 5],
// 隣の部屋
['NB_WALL', RU, 4],
['NB_WALL_D', RU, 3],
['NB_TRIM', RC, 4],
['NB_FLOOR', RS, 3],
['NB_BED', RT, 3],
['NB_BED_HI', RT, 4],
['NB_SHEET', RC, 5],
['NB_LAMP', RC, 6, 1, 0.3],
// 音の文字と埃
['DON', RC, 7, 1, 0.5],
['BURST', RC, 7, 1, 0.6],
['DUST', RC, 5],
['SWEAT', RT, 6, 1, 0],
['ZEE', RU, 6, 1, 0.08],
['BUBBLE', RU, 6, 1, 0.06],
['MOTE', RA, 6, 1, 0],
];
const M = Object.fromEntries(MATS.map((row, i) => [row[0], i]));
const CHAR_FROM = M.HAIR;
const CHAR_TO = M.NB_SHOE;
// ---- 文字の絵(カタカナと、印)
const GLYPH_DO = ['..XX....X.X', '..XX...X.X.', '..XX.......', '..XXXX.....', '..XX.XXXX..', '..XX...XXX.', '..XX.....X.', '..XX.......', '..XX.......', '..XX.......', '..XX.......'];
const GLYPH_N = ['...........', 'XX.........', 'XXXX.......', '..XX.....XX', '........XX.', '.......XX..', '......XX...', '....XXX....', 'XXXXX......', 'XXX........', '...........'];
const GLYPH_MARK = ['XX', 'XX', 'XX', 'XX', '..', 'XX'];
const SPARKS = [[-2, -3], [-3, -1], [-3, 1], [-2, 3], [0, -4], [0, 4], [-5, 0]];
const GLYPH_Z = ['XXXXX', '...X.', '..X..', '.X...', 'XXXXX'];
const GLYPH_Z_SMALL = ['XXXX', '..X.', '.X..', 'XXXX'];
// ---- 住人の頭。右向き。下の中央に首が付く
const HEAD_ROWS = [
'....HHHHH...',
'..HHHhhHHH..',
'.HHHhHHHHHH.',
'.HHHHHHHSHHS',
'.HHHHSSSSSSS',
'.HHHSSSS1S1S',
'.HHHSSSS2S2S',
'.HHSSSSCSSSC',
'..HSSSSSS3SS',
'...SSSSSS4S.',
];
const cellsOf = (rows, skip) => {
const list = [];
rows.forEach((row, j) => {
for (let i = 0; i < row.length; i++) if (row[i] !== skip) list.push([i, j, row[i]]);
});
return list;
};
const HEAD_CELLS = cellsOf(HEAD_ROWS, '.');
const SLEEP_FACE = cellsOf(
[
'...HHHHHH...',
'..HHHHHHHH..',
'.HHHHHHHHHH.',
'.HHHSHHSHHH.',
'.HSSSSSSSSH.',
'.HEEESSEEEH.',
'.HSSSSSSSSH.',
'.HCSSSSSSCH.',
'..SSSSMSSS..',
'...SSSSSS...',
],
'.',
);
const G_DO = cellsOf(GLYPH_DO, '.');
const G_N = cellsOf(GLYPH_N, '.');
const G_MARK = cellsOf(GLYPH_MARK, '.');
const G_Z = cellsOf(GLYPH_Z, '.');
const G_Z_SMALL = cellsOf(GLYPH_Z_SMALL, '.');
const clamp = (v, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v));
const mix = (a, b, t) => a + (b - a) * t;
const smoothstep = (a, b, v) => {
const t = clamp((v - a) / (b - a));
return t * t * (3 - 2 * t);
};
const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];
const bayerAt = (px, py) => BAYER[((py & 3) << 2) | (px & 3)];
function seeded(seed) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ---- 色の計算(OKLab)。色の値は書かず、トークンの色相に明るさだけを与える
const toLinear = (c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
const toGamma = (c) => (c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055);
function rgbToOklab([r8, g8, b8]) {
const r = toLinear(r8 / 255);
const g = toLinear(g8 / 255);
const b = toLinear(b8 / 255);
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
return [0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s];
}
function oklabToLinear(L, a, b) {
const l = Math.pow(L + 0.3963377774 * a + 0.2158037573 * b, 3);
const m = Math.pow(L - 0.1055613458 * a - 0.0638541728 * b, 3);
const s = Math.pow(L - 0.0894841775 * a - 1.291485548 * b, 3);
return [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s];
}
function labTone(lab, L) {
// 明るさを決め、色域に入るまで鮮やかさを落とす
let chroma = 1;
for (let i = 0; i < 24; i++) {
const rgb = oklabToLinear(L, lab[1] * chroma, lab[2] * chroma);
if (rgb.every((v) => v >= -0.001 && v <= 1.001)) return rgb.map((v) => Math.round(toGamma(clamp(v)) * 255));
chroma *= 0.9;
}
return oklabToLinear(L, 0, 0).map((v) => Math.round(toGamma(clamp(v)) * 255));
}
// ---- シェーダー
const VERT = `#version 300 es
in vec2 aPos;
void main() { gl_Position = vec4(aPos, 0.0, 1.0); }`;
// 形を測る道具。焼くときと毎コマの両方で使う
const SHAPE_GLSL = `
float rb(vec2 p, vec2 b, float r) {
vec2 q = abs(p) - b + r;
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
}
vec2 rbGrad(vec2 p, vec2 b, float r) {
vec2 q = abs(p) - b + r;
vec2 s = vec2(p.x < 0.0 ? -1.0 : 1.0, p.y < 0.0 ? -1.0 : 1.0);
if (q.x > 0.0 && q.y > 0.0) return s * normalize(q);
return q.x > q.y ? vec2(s.x, 0.0) : vec2(0.0, s.y);
}
float softBox(vec2 p, vec2 c, vec2 h, float s) {
vec2 q = abs(p - c) - h;
float d = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0);
return 1.0 - smoothstep(-s, s, d);
}
// 磨かれた面に映る部屋:上の長い光、下の弱い照り返し、左右の細い光
vec3 studio(vec3 R) {
vec3 c = mix(uEnvLo, uEnvHi * 0.8, smoothstep(-0.75, 0.8, R.y));
vec2 s = R.xy / (1.0 + max(R.z, 0.0));
float key = softBox(s, vec2(-0.06, 0.6), vec2(0.72, 0.1), 0.045);
float low = softBox(s, vec2(0.08, -0.7), vec2(0.82, 0.035), 0.05);
float left = softBox(s, vec2(-0.82, 0.05), vec2(0.03, 0.42), 0.05);
float right = softBox(s, vec2(0.84, 0.12), vec2(0.02, 0.3), 0.05);
c += uEnvHi * (key * 0.95 + low * 0.14 + left * 0.2 + right * 0.1);
return c;
}`;
// 動かない絵を一度だけ焼く。磨かれた面、影、ガラスのくぼみは、部屋の光と絵が変わっても形が変わらない。
// 2枚に分けて焼き、毎コマは読むだけにする。
// 1枚目:動かない色(アルファ込み)と、アルファ
// 2枚目:r が部屋の光の効き、g が部屋の絵の効き
const BAKE_FRAG = `#version 300 es
precision highp float;
uniform vec2 uRes, uPill;
uniform float uBezel, uCorner;
uniform vec3 uAlbedo, uEnvHi, uEnvLo, uInk;
layout(location = 0) out vec4 outBase;
layout(location = 1) out vec4 outWeight;
${SHAPE_GLSL}
float castShadow(vec2 p) {
float h = uPill.y;
float wide = rb(p + vec2(0.0, h * 0.2), uPill - vec2(h * 0.03), uCorner);
float near = rb(p + vec2(0.0, h * 0.045), uPill, uCorner);
float a = 0.2 * (1.0 - smoothstep(-h * 0.24, h * 0.36, wide)) + 0.2 * (1.0 - smoothstep(-h * 0.03, h * 0.09, near));
vec2 toEdge = 0.5 * uRes - abs(p);
float room = min(toEdge.x, toEdge.y) / (h * 0.22);
return a * smoothstep(0.0, 1.0, clamp(room, 0.0, 1.0));
}
void main() {
vec2 p = gl_FragCoord.xy - 0.5 * uRes;
float d = rb(p, uPill, uCorner);
float fallen = castShadow(p);
vec3 base = uInk * fallen;
float alpha = fallen;
float glowWeight = 0.0;
float worldWeight = 0.0;
if (d < 1.0) {
// 磨かれた外の面。外へ丸く落ち、平らな帯があり、窓へ面取りで下りる
vec2 g = rbGrad(p, uPill, uCorner);
float t = clamp(-d / uBezel, 0.0, 1.0);
float a = 0.0;
if (t < 0.52) a = pow(1.0 - t / 0.52, 1.7) * 0.97;
else if (t > 0.8) a = -pow((t - 0.8) / 0.2, 1.25) * 0.78;
vec3 N = normalize(vec3(g * a, sqrt(max(1.0 - a * a, 0.03))));
vec3 R = vec3(2.0 * N.z * N.xy, 2.0 * N.z * N.z - 1.0);
float diffuse = 0.72 + 0.28 * clamp(dot(N, normalize(vec3(-0.2, 0.72, 0.66))), 0.0, 1.0);
float fresnel = 0.045 + 0.955 * pow(1.0 - N.z, 4.0);
vec3 c = uAlbedo * diffuse;
c = mix(c, studio(R), clamp(fresnel * 0.95 + 0.07, 0.0, 1.0));
// 平らな帯には、部屋の窓が細長く映る。上は強く、下は弱い
float plate = smoothstep(0.42, 0.56, t) * (1.0 - smoothstep(0.74, 0.84, t));
vec2 band = vec2(p.x / max(uPill.x, 1.0), t);
float glintTop = softBox(band, vec2(-0.34, 0.63), vec2(0.34, 0.05), 0.035) + 0.55 * softBox(band, vec2(0.42, 0.63), vec2(0.09, 0.045), 0.035);
float glintLow = softBox(band, vec2(0.12, 0.65), vec2(0.5, 0.025), 0.02);
float upper = smoothstep(-0.25, 0.25, p.y / uPill.y);
c += uEnvHi * plate * (glintTop * upper * 0.2 + glintLow * (1.0 - upper) * 0.06);
// 長い面の、ごくゆるい明暗。均一に塗った面に見せない
c *= 1.0 + plate * 0.022 * sin(p.x / max(uPill.x, 1.0) * 3.4 + 0.7);
// 面取りと帯に映る部屋の光の効き。窓のふちの細い暗がりは、光にも面にも掛かる
float inner = smoothstep(0.5, 1.0, t);
float edgeDark = 1.0 - 0.3 * smoothstep(0.93, 1.0, t);
vec3 faceColor = c * edgeDark;
float faceGlow = (inner * inner * 1.25 + 0.2) * edgeDark;
// ガラスのくぼみ:部屋の絵に掛かる暗がりと映り込み
float dw = d + uBezel;
float inWindow = 0.0;
float keep = 0.0;
vec3 over = vec3(0.0);
if (dw < 1.0) {
float win = uPill.y - uBezel;
float ring = exp(max(dw, -win) / (win * 0.055));
float dropped = rb(p - vec2(0.0, win * 0.05), uPill - vec2(uBezel), max(uCorner - uBezel, 1.0));
float overhang = exp(min(dropped, 0.0) / (win * 0.1)) * smoothstep(-win * 0.2, win * 0.6, p.y);
float ink = clamp(ring * 0.5 + overhang * 0.42, 0.0, 0.86);
float sheen = smoothstep(win * 0.22, win * 1.02, p.y + p.x * 0.07);
float sheenA = sheen * sheen * 0.13;
float lowA = smoothstep(-win * 0.55, -win * 1.05, p.y - p.x * 0.05) * 0.03;
keep = (1.0 - ink) * (1.0 - sheenA) * (1.0 - lowA);
over = ((uInk * ink) * (1.0 - sheenA) + uEnvHi * sheenA) * (1.0 - lowA) + uEnvHi * lowA;
inWindow = 1.0 - smoothstep(-0.8, 0.8, dw);
}
float cover = 1.0 - smoothstep(-0.8, 0.8, d);
base = base * (1.0 - cover) + cover * (faceColor * (1.0 - inWindow) + over * inWindow);
glowWeight = cover * (1.0 - inWindow) * faceGlow;
worldWeight = cover * inWindow * keep;
alpha = mix(alpha, 1.0, cover);
}
outBase = vec4(clamp(base, 0.0, 1.0), alpha);
// 光の効きは 1.45 まであるので、半分にして入れる
outWeight = vec4(glowWeight * 0.5, worldWeight, 0.0, 1.0);
}`;
const FRAG = `#version 300 es
precision highp float;
// 指定しないと標本の精度は lowp になる。PC では無視されるが、スマホの GPU では光の場が段になることがある
precision highp sampler2D;
uniform sampler2D uWorld, uGlow, uBase, uWeight;
uniform vec2 uRes, uPill, uOrigin, uWorldSize, uGlowSize, uShift;
uniform vec4 uLens;
uniform float uLensR, uK, uLift, uWobble, uTime;
uniform vec3 uAlbedo, uEnvHi, uEnvLo, uInk, uGround;
out vec4 outColor;
${SHAPE_GLSL}
// なめらかに読む(B スプライン。光の場の格子が見えないように)
vec3 glowAt(vec2 frag) {
vec2 st = vec2(frag.x / uRes.x, 1.0 - frag.y / uRes.y) * uGlowSize - 0.5;
vec2 i = floor(st);
vec2 f = st - i;
vec2 f2 = f * f;
vec2 f3 = f2 * f;
vec2 w0 = (1.0 - 3.0 * f + 3.0 * f2 - f3) / 6.0;
vec2 w1 = (4.0 - 6.0 * f2 + 3.0 * f3) / 6.0;
vec2 w2 = (1.0 + 3.0 * f + 3.0 * f2 - 3.0 * f3) / 6.0;
vec2 w3 = f3 / 6.0;
vec2 g0 = w0 + w1;
vec2 g1 = w2 + w3;
vec2 h0 = (i - 0.5 + w1 / g0 + 0.5) / uGlowSize;
vec2 h1 = (i + 1.5 + w3 / g1 + 0.5) / uGlowSize;
return g0.y * (g0.x * texture(uGlow, vec2(h0.x, h0.y)).rgb + g1.x * texture(uGlow, vec2(h1.x, h0.y)).rgb)
+ g1.y * (g0.x * texture(uGlow, vec2(h0.x, h1.y)).rgb + g1.x * texture(uGlow, vec2(h1.x, h1.y)).rgb);
}
// 大きく写すと、1つのピクセルが小さなタイルに見える。継ぎ目と、ごく浅いふくらみ
vec3 tile(vec3 c, vec2 w, float scale) {
if (scale < 5.0) return c;
vec2 f = fract(w);
vec2 g = min(f, 1.0 - f);
float edge = min(g.x, g.y) * scale;
float seam = 1.0 - 0.1 * (1.0 - smoothstep(0.0, max(1.2, scale * 0.07), edge));
float grade = smoothstep(5.0, 10.0, scale);
float bevel = 1.0 + grade * (0.05 * (0.5 - f.y) + 0.028 * (0.5 - f.x));
return c * seam * bevel;
}
// 整数倍のまま、そのまま読む
vec3 worldExact(vec2 frag) {
vec2 w = vec2(frag.x - uOrigin.x, uOrigin.y - frag.y) / uK - uShift;
ivec2 t = clamp(ivec2(floor(w)), ivec2(0), ivec2(uWorldSize) - 1);
return tile(texelFetch(uWorld, t, 0).rgb, w, uK);
}
// レンズの中:拡大では、ピクセルの縁は立ったまま。圧縮されるところはミップで読み、模様を出さない
vec3 worldBent(vec2 frag, float scale, float foot) {
vec2 w = vec2(frag.x - uOrigin.x, uOrigin.y - frag.y) / uK - uShift;
if (foot > 1.0) return textureLod(uWorld, w / uWorldSize, log2(foot)).rgb;
vec2 r = floor(w + 0.5);
vec2 sharp = r - 0.5 + clamp((w - r) / max(foot, 1.0e-3) + 0.5, 0.0, 1.0);
return tile(textureLod(uWorld, sharp / uWorldSize, 0.0).rgb, w, scale);
}
// 部屋の絵に、ガラスのにじみを重ねる
vec3 throughGlass(vec3 world, vec3 near) {
return 1.0 - (1.0 - world) * (1.0 - clamp(near * 0.78, 0.0, 1.0));
}
// レンズが見る景色。焼いた絵を読み、地の色の上に重ねる
vec3 sceneAt(vec2 frag, float scale, float foot, vec3 near, vec3 faceTint) {
vec2 uv = frag / uRes;
vec4 base = texture(uBase, uv);
vec2 weight = texture(uWeight, uv).rg;
vec3 c = base.rgb + uGround * (1.0 - base.a) + near * faceTint * weight.r * 2.0;
if (weight.g > 0.0) c += throughGlass(worldBent(frag, scale, foot), near) * weight.g;
return c;
}
void main() {
vec2 frag = gl_FragCoord.xy;
vec2 p = frag - 0.5 * uRes;
float h = uPill.y;
vec3 faceTint = uAlbedo * 0.65 + 0.35;
ivec2 at = ivec2(frag);
vec4 base = texelFetch(uBase, at, 0);
vec3 color = base.rgb;
float alpha = base.a;
// ---- レンズの形
vec2 lq = p - uLens.xy;
if (uWobble > 0.001) {
lq += uWobble * h * 0.012 * vec2(sin(lq.y / h * 7.0 + uTime * 11.0), sin(lq.x / h * 6.0 - uTime * 9.0));
}
vec2 he = uLens.zw;
float lr = uLensR;
float ld = rb(lq, he, lr);
// ---- レンズに隠れないところだけ、部屋の光と絵を足す
if (ld > -0.8) {
vec2 weight = texelFetch(uWeight, at, 0).rg;
if (weight.r + weight.g > 0.0) {
vec3 near = glowAt(frag);
color += near * faceTint * weight.r * 2.0;
if (weight.g > 0.0) color += throughGlass(worldExact(frag), near) * weight.g;
}
// ---- レンズの落とす影
float sd = rb(lq + vec2(0.0, h * (0.05 + 0.035 * uLift)), he, lr);
float shA = (1.0 - smoothstep(-h * 0.14, h * (0.2 + 0.1 * uLift), sd)) * 0.36;
color = uInk * shA + color * (1.0 - shA);
alpha = shA + alpha * (1.0 - shA);
}
if (ld < 1.0) {
vec2 gr = rbGrad(lq, he, lr);
float span = min(he.x, he.y);
float rim = 1.0 - clamp(-ld / span, 0.0, 1.0);
vec2 center = 0.5 * uRes;
// 拡大:中ほどは平らに少しだけ大きく。縁に近づくと急に曲がり、外の絵を圧縮して見せる
float mag = 1.24 + 0.03 * uLift;
float bt = clamp((rim - 0.64) / 0.36, 0.0, 1.0);
float band = bt * bt * (3.0 - 2.0 * bt);
float strength = 0.46;
vec2 seenAt = uLens.xy + lq / mag;
vec2 shift = gr * pow(band, 2.2) * span * strength;
float slopeBand = 6.0 * bt * (1.0 - bt) / 0.36;
float foot = (1.0 / mag + 2.2 * pow(max(band, 1.0e-3), 1.2) * slopeBand * strength) / uK;
float scale = uK * mag;
// 光の場はなだらかなので、景色を読む位置で1回だけ読む
vec3 near = glowAt(center + seenAt + shift);
vec3 seen;
if (band > 0.02) {
float ca = 0.075 * band;
seen.r = sceneAt(center + seenAt + shift * (1.0 - ca), scale, foot, near, faceTint).r;
seen.g = sceneAt(center + seenAt + shift, scale, foot, near, faceTint).g;
seen.b = sceneAt(center + seenAt + shift * (1.0 + ca), scale, foot, near, faceTint).b;
} else {
seen = sceneAt(center + seenAt, scale, foot, near, faceTint);
}
// ガラスの形:中ほどはゆるい丸み、縁で急に落ちる
float slope = 0.24 * rim + 0.76 * pow(rim, 6.0);
vec2 tilt = gr * slope;
vec3 N = normalize(vec3(tilt, sqrt(max(1.0 - dot(tilt, tilt), 0.02))));
vec3 R = vec3(2.0 * N.z * N.xy, 2.0 * N.z * N.z - 1.0);
vec2 lp = lq / he;
vec3 lens = seen * 1.04;
// 縁の厚み:明るい線ではなく、ガラスの奥の暗さで形を見せる
lens = mix(lens, uInk, pow(rim, 13.0) * 0.4);
// 下の内側に集まる光
float pool = smoothstep(0.1, 0.96, -lp.y) * smoothstep(1.0, 0.84, rim) * smoothstep(0.38, 0.8, rim);
lens += uEnvHi * pool * 0.22;
// 上の大きな映り込み。丸みに沿って歪むが、縁には付かない
float broad = softBox(lp + vec2(0.0, lp.x * lp.x * 0.16), vec2(-0.04, 0.68), vec2(0.6, 0.14), 0.2) * smoothstep(0.98, 0.76, rim);
lens = mix(lens, uEnvHi, broad * 0.4);
float fresnel = pow(1.0 - N.z, 3.0);
float dirLight = 0.25 + 0.75 * smoothstep(-0.7, 0.85, dot(gr, normalize(vec2(-0.35, 0.94))));
lens = mix(lens, studio(R), clamp(fresnel * dirLight * dirLight * 0.8, 0.0, 0.58));
// 縁の細い光:上と下の2つの弧だけ
float lineW = max(1.1, h * 0.0052);
float hair = 1.0 - smoothstep(0.0, lineW, abs(ld + lineW * 1.15));
float arcA = pow(max(dot(gr, normalize(vec2(-0.5, 0.86))), 0.0), 2.4);
float arcB = pow(max(dot(gr, normalize(vec2(0.58, -0.82))), 0.0), 3.4) * 0.42;
lens = mix(lens, uEnvHi, hair * clamp(arcA + arcB, 0.0, 1.0) * 0.92);
// 部屋の光も、縁に映る
float rimGlow = smoothstep(0.5, 0.97, rim);
if (rimGlow > 0.0) lens += glowAt(frag) * rimGlow * 0.55;
float cover = 1.0 - smoothstep(-0.8, 0.8, ld);
color = mix(color, lens, cover);
alpha = mix(alpha, 1.0, cover);
}
// 大きな階調が縞にならないよう、わずかに散らす
float n = fract(sin(dot(frag, vec2(12.9898, 78.233))) * 43758.5453);
n += fract(sin(dot(frag, vec2(93.989, 67.345))) * 24634.6345) - 1.0;
color += (n * 0.75) / 255.0 * min(alpha * 4.0, 1.0);
outColor = vec4(clamp(color, 0.0, 1.0), alpha);
}`;
function mount(root) {
const cleanups = [];
const on = (target, type, fn, options) => {
target.addEventListener(type, fn, options);
cleanups.push(() => target.removeEventListener(type, fn, options));
};
let canvas = root.querySelector('.scene');
const input = root.querySelector('.range');
const shade = root.querySelector('.shade');
const probe = root.querySelector('.probe');
const motionQuery = matchMedia('(prefers-reduced-motion: reduce)');
const schemeQuery = matchMedia('(prefers-color-scheme: dark)');
let calm = motionQuery.matches;
// 画面に出さない canvas。OffscreenCanvas のない古い Safari(16.3 まで)でも動くようにする
const offscreen = (w, h) => (typeof OffscreenCanvas === 'function' ? new OffscreenCanvas(w, h) : Object.assign(root.ownerDocument.createElement('canvas'), { width: w, height: h }));
// ---- パレットをトークンから作る
const paint = offscreen(1, 1).getContext('2d', { willReadFrequently: true });
const palette = new Array(RAMPS.length * NT);
const palette32 = new Uint32Array(RAMPS.length * NT);
const body = { albedo: [1, 1, 1], hi: [1, 1, 1], lo: [0, 0, 0], ground: [1, 1, 1] };
let cornerPx = 9999;
let motionScale = 1;
const readColor = (style, name) => {
probe.style.color = style.getPropertyValue(`--_${name}`).trim();
paint.clearRect(0, 0, 1, 1);
paint.fillStyle = getComputedStyle(probe).color;
paint.fillRect(0, 0, 1, 1);
const d = paint.getImageData(0, 0, 1, 1).data;
return [d[0], d[1], d[2]];
};
const readTokens = () => {
const style = getComputedStyle(root);
const labs = {};
for (const name of ['bg', 'surface', 'fg', 'muted', 'accent']) labs[name] = rgbToOklab(readColor(style, name));
RAMPS.forEach(([name, turn, amount], ramp) => {
const [, a, b] = labs[name];
const angle = (turn * Math.PI) / 180;
const ca = Math.cos(angle);
const sa = Math.sin(angle);
TONES.forEach((L, step) => {
// 明るさの両端では、鮮やかさを控える
const edge = 1 - 0.42 * Math.pow((L - 0.56) / 0.38, 2);
const lab = [L, (a * ca - b * sa) * amount * edge, (a * sa + b * ca) * amount * edge];
const rgb = labTone(lab, L);
const id = ramp * NT + step;
palette[id] = rgb;
palette32[id] = (255 << 24) | (rgb[2] << 16) | (rgb[1] << 8) | rgb[0];
});
});
// 外の面:地の色から、磨いた陶器の明るさを作る。地が暗ければ、面も暗い
const bgLab = labs.bg;
const bodyL = bgLab[0] > 0.5 ? Math.min(0.975, bgLab[0] + 0.02) : bgLab[0] + 0.16;
const unitOf = (rgb) => rgb.map((v) => v / 255);
body.albedo = unitOf(labTone(bgLab, bodyL));
body.hi = unitOf(labTone(bgLab, Math.min(0.998, bodyL + (bgLab[0] > 0.5 ? 0.12 : 0.34))));
body.lo = unitOf(labTone(bgLab, bodyL * 0.6));
body.ground = unitOf(readColor(style, 'bg'));
// 角丸と動きの速さも、トークンから読む
probe.style.borderTopLeftRadius = style.getPropertyValue('--_radius').trim();
const radius = getComputedStyle(probe).borderTopLeftRadius;
cornerPx = radius.endsWith('%') ? 9999 : parseFloat(radius) || 0;
const motion = style.getPropertyValue('--_motion').trim();
const ms = motion.endsWith('ms') ? parseFloat(motion) : parseFloat(motion) * 1000;
motionScale = ms > 0 ? clamp(200 / ms, 0.4, 2.5) : 2.5;
needBake = true;
};
const unit = (id) => palette[id].map((v) => v / 255);
// ---- 材質の表:光の段ごとに、パレットの番号を引く
const NM = MATS.length;
const litTable = new Uint8Array(NM * 8);
const tintTable = new Uint8Array(NM * 3 * 8);
const glowOf = new Float32Array(NM);
const pal = (ramp, tone) => ramp * NT + clamp(Math.round(tone), 0, NT - 1);
MATS.forEach(([, r, t, self = 0, glow = 0], m) => {
glowOf[m] = glow;
for (let lv = 0; lv < 8; lv++) {
let id;
if (self) id = pal(r, t);
else if (lv === 0) id = pal(RK, t * 0.2);
else if (lv === 1) id = pal(RK, 0.4 + t * 0.36);
else if (lv === 2) id = pal(RU, t * 0.42 - 0.2);
else id = pal(r, t - 6 + lv);
litTable[m * 8 + lv] = id;
const person = m >= CHAR_FROM && m <= CHAR_TO;
[LP, LT, LA].forEach((c, ci) => {
tintTable[(m * 3 + ci) * 8 + lv] = self ? id : person ? pal(r, t - 5 + lv) : pal(c, t * 0.8 + (lv - 4) * 0.62 + 0.3);
});
}
});
// ---- 世界の大きさ(整数倍が決める)
let bw = 1;
let bh = 1;
let ox = 0;
let oy = 0;
let k = 1;
let matBuf = new Uint8Array(1);
let baseBuf = new Uint8Array(1);
let tintBuf = new Uint8Array(1);
let thrBuf = new Float32Array(1);
let pixels = new Uint32Array(1);
let pixelBytes = new Uint8Array(pixels.buffer);
let ceilMap = new Float32Array(1);
let lampMap = new Float32Array(1);
let moonMap = new Float32Array(1);
let nextMap = new Float32Array(1);
let gridOf = new Int32Array(1);
let emitScale = 0;
// ---- 描く道具
const put = (x, y, m) => {
const px = x + ox;
const py = y + oy;
if (px < 0 || py < 0 || px >= bw || py >= bh) return;
matBuf[py * bw + px] = m;
};
const rect = (x, y, w, h, m) => {
for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) put(x + i, y + j, m);
};
const disc = (cx, cy, r, fn) => {
const R = Math.ceil(r);
for (let dy = -R; dy <= R; dy++) {
for (let dx = -R; dx <= R; dx++) {
const d = Math.hypot(dx, dy);
if (d <= r) fn(cx + dx, cy + dy, d, dx, dy);
}
}
};
const glyph = (cells, x, y, m, rim = -1) => {
if (rim >= 0) {
for (const [i, j] of cells) {
put(x + i - 1, y + j, rim);
put(x + i + 1, y + j, rim);
put(x + i, y + j - 1, rim);
put(x + i, y + j + 1, rim);
}
}
for (const [i, j] of cells) put(x + i, y + j, m);
};
// ---- 動かない部屋
const roomAt = (x, y) => {
if (y < 3 || y >= 67) {
if (y === 2 || y === 67) return M.CUT;
return (x - y) % 7 === 0 ? M.SLAB_LT : M.SLAB;
}
if (x >= WALL_X && x < WALL_X + 8) {
if (x === WALL_X || x === WALL_X + 7) return M.CUT;
const row = y >> 1;
if ((y & 1) === 1) return M.BRICK;
return ((x + (row & 1) * 2) & 3) === 0 ? M.BRICK : M.BRICK_LT;
}
if (x >= WALL_X + 8) {
if (y < 49) return (x + 400) % 5 === 0 ? M.NB_WALL_D : M.NB_WALL;
if (y < 52) return M.NB_TRIM;
return M.NB_FLOOR;
}
if (y < 5) return y === 3 ? M.TRIM : M.TRIM_D;
if (y < 49) {
const rowBand = (y >> 3) & 1;
const dot = ((x + 400 + rowBand * 5) % 10 === 0 && y % 8 === 3) || ((x + 400 + rowBand * 5) % 10 === 5 && y % 8 === 7);
return dot ? M.WALL_D : M.WALL;
}
if (y === 49) return M.TRIM;
if (y === 50) return M.TRIM;
if (y === 51) return M.TRIM_D;
// 床の板:奥は細く、手前は太い
const bands = [52, 54, 56, 59, 62, 67];
let band = 0;
while (band < 4 && y >= bands[band + 1]) band++;
if (y === bands[band] && band > 0) return M.FLOOR_D;
const span = [11, 13, 16, 19, 24][band];
const shifted = x + 400 + band * 7;
if (shifted % span === 0) return M.FLOOR_D;
const plank = Math.floor(shifted / span) * 13 + band * 5;
return plank % 5 === 0 ? M.FLOOR_LT : M.FLOOR;
};
const paintRug = () => {
for (let y = 55; y < 65; y++) {
const inset = (64 - y) * 0.8;
const l = Math.round(150 + inset);
const r = Math.round(204 - inset);
for (let x = l; x <= r; x++) {
const edge = x - l < 2 || r - x < 2 || y === 55 || y === 64;
const stripe = ((x + y * 2) >> 2) % 3 === 0;
put(x, y, edge ? M.RUG_LT : stripe ? M.RUG_D : M.RUG);
}
if (y % 2 === 0) {
put(l - 1, y, M.RUG_LT);
put(r + 1, y, M.RUG_LT);
}
}
};
const paintWindow = () => {
const X0 = 100;
const X1 = 138;
const Y0 = 10;
const Y1 = 36;
for (let y = Y0; y < Y1; y++) {
for (let x = X0; x < X1; x++) {
const v = (y - Y0) / (Y1 - Y0);
const d = bayerAt(x + ox, y + oy) / 16;
const m = v + (d - 0.5) * 0.16 < 0.34 ? M.SKY0 : v + (d - 0.5) * 0.16 < 0.68 ? M.SKY1 : M.SKY2;
put(x, y, m);
}
}
// 月
disc(128, 17, 4.4, (x, y, d, dx, dy) => {
const crater = (dx === -1 && dy === -1) || (dx === 2 && dy === 0) || (dx === -2 && dy === 2) || (dx === 1 && dy === 2);
put(x, y, crater || dx + dy > 4 ? M.MOON_D : M.MOON);
});
// 街:奥と手前の2つの層
const r = seeded(4242);
for (let layer = 0; layer < 2; layer++) {
let x = X0 + 1;
while (x < X1 - 1) {
const w = 3 + Math.floor(r() * 6);
const h = (layer === 0 ? 5 : 2) + Math.floor(r() * 6);
rect(x, Y1 - h, w, h, layer === 0 ? M.BLD_F : M.BLD);
x += w + (layer === 0 ? 0 : Math.floor(r() * 2));
}
}
// 枠
rect(X0 - 1, Y0 - 1, X1 - X0 + 2, 1, M.TRIM);
rect(X0 - 1, Y0, 1, Y1 - Y0, M.TRIM);
rect(X1, Y0, 1, Y1 - Y0, M.TRIM_D);
rect(119, Y0, 1, Y1 - Y0, M.TRIM_D);
rect(X0, 23, X1 - X0, 1, M.TRIM_D);
rect(X0 - 2, Y1, X1 - X0 + 4, 2, M.TRIM);
rect(X0 - 2, Y1 + 1, X1 - X0 + 4, 1, M.TRIM_D);
// カーテンレール
rect(94, 7, 50, 1, M.WOOD_DD);
rect(93, 6, 2, 3, M.WOOD);
rect(143, 6, 2, 3, M.WOOD);
};
const paintSofa = () => {
// 床の影
rect(99, 56, 44, 1, M.FLOOR_D);
rect(102, 57, 38, 1, M.FLOOR_D);
// 背もたれ
rect(102, 38, 36, 10, M.SOFA);
rect(103, 37, 34, 1, M.SOFA_HI);
rect(102, 38, 36, 1, M.SOFA_HI);
for (let y = 39; y < 48; y++) {
put(114, y, M.SOFA_D);
put(126, y, M.SOFA_D);
}
// 座面
rect(102, 47, 36, 2, M.SOFA_HI);
rect(102, 49, 36, 5, M.SOFA);
rect(102, 52, 36, 2, M.SOFA_D);
put(114, 47, M.SOFA_D);
put(114, 48, M.SOFA_D);
put(126, 47, M.SOFA_D);
put(126, 48, M.SOFA_D);
// 肘かけ
for (const ax of [97, 137]) {
rect(ax, 43, 6, 11, M.SOFA);
rect(ax + 1, 42, 4, 1, M.SOFA_HI);
rect(ax, 43, 6, 2, M.SOFA_HI);
rect(ax, 51, 6, 3, M.SOFA_D);
}
// 脚
rect(99, 54, 2, 3, M.WOOD_DD);
rect(139, 54, 2, 3, M.WOOD_DD);
// 枕
rect(104, 41, 9, 6, M.PILLOW);
rect(105, 40, 7, 1, M.PILLOW);
rect(105, 47, 7, 1, M.PILLOW_D);
put(104, 41, M.SOFA);
put(112, 41, M.SOFA);
rect(105, 42, 2, 1, M.BK_HI);
};
const paintShelf = () => {
rect(60, 8, 34, 46, M.WOOD_D);
rect(60, 8, 34, 2, M.WOOD_LT);
rect(59, 7, 36, 1, M.WOOD_HI);
rect(60, 8, 2, 46, M.WOOD);
rect(92, 8, 2, 46, M.WOOD);
rect(62, 10, 30, 42, M.WOOD_DD);
for (const y of [18, 29, 40, 51]) {
rect(62, y, 30, 2, M.WOOD_LT);
rect(62, y + 2, 30, 1, M.BAFFLE);
}
rect(60, 54, 34, 2, M.WOOD_DD);
rect(60, 56, 36, 1, M.FLOOR_D);
};
const paintPot = () => {
rect(12, 57, 16, 2, M.FLOOR_D);
for (let y = 50; y < 58; y++) {
const inset = Math.floor((y - 50) / 3);
rect(14 + inset, y, 12 - inset * 2, 1, M.POT);
put(14 + inset, y, M.POT_HI);
put(15 + inset, y, M.POT_HI);
put(25 - inset, y, M.POT_D);
}
rect(13, 48, 14, 2, M.POT_HI);
rect(13, 49, 14, 1, M.POT);
rect(14, 48, 12, 1, M.SOIL);
};
const paintCabinet = () => {
rect(186, 38, 19, 14, M.WOOD);
rect(186, 38, 19, 1, M.WOOD_HI);
rect(187, 40, 8, 10, M.WOOD_D);
rect(196, 40, 8, 10, M.WOOD_D);
rect(187, 40, 8, 1, M.WOOD_LT);
rect(196, 40, 8, 1, M.WOOD_LT);
put(194, 45, M.METAL_HI);
put(196, 45, M.METAL_HI);
rect(187, 52, 2, 3, M.WOOD_DD);
rect(202, 52, 2, 3, M.WOOD_DD);
rect(186, 55, 19, 1, M.FLOOR_D);
// レコードプレーヤー
rect(188, 35, 15, 3, M.BAFFLE);
rect(188, 35, 15, 1, M.CONE_HI);
put(200, 36, M.LED_OFF);
};
const paintDoor = () => {
// 部屋が広く見えるときだけ見える、左の戸
rect(-32, 13, 24, 39, M.WOOD_D);
rect(-30, 15, 20, 37, M.WOOD);
rect(-28, 17, 16, 14, M.WOOD_LT);
rect(-28, 34, 16, 15, M.WOOD_LT);
rect(-27, 18, 14, 12, M.WOOD);
rect(-27, 35, 14, 13, M.WOOD);
rect(-12, 32, 2, 2, M.METAL_HI);
// コート掛け
rect(-4, 18, 1, 38, M.WOOD_DD);
rect(-7, 55, 7, 1, M.WOOD_DD);
rect(-7, 20, 7, 1, M.WOOD_DD);
rect(-7, 21, 3, 14, M.CURTAIN_D);
rect(-7, 21, 1, 14, M.CURTAIN);
};
const paintNextRoom = () => {
// ベッド
rect(222, 45, 40, 9, M.NB_BED);
rect(222, 45, 40, 2, M.NB_BED_HI);
rect(222, 54, 40, 2, M.WOOD_DD);
rect(220, 38, 3, 18, M.WOOD_D);
rect(223, 43, 9, 3, M.NB_SHEET);
// 棚と小さな灯り
rect(250, 18, 16, 2, M.WOOD_D);
rect(252, 13, 3, 5, M.NB_LAMP);
rect(257, 12, 2, 6, M.BK_U);
rect(259, 13, 2, 5, M.BK_G);
// 壁の小さな絵
rect(232, 16, 11, 9, M.NB_WALL_D);
rect(233, 17, 9, 7, M.NB_TRIM);
};
const buildLight = () => {
for (let py = 0; py < bh; py++) {
for (let px = 0; px < bw; px++) {
const x = px - ox;
const y = py - oy;
const i = py * bw + px;
const inRoom = x < WALL_X && y >= 3 && y < 67;
const inNext = x >= WALL_X + 8 && y >= 3 && y < 67;
let c = 0;
let l = 0;
let mn = 0;
let nb = 0;
if (inRoom) {
c = 0.84 + 0.16 * Math.exp(-Math.pow((x - 120) / 130, 2));
if (y < 12) c -= (12 - y) * 0.012;
const dx = x - LAMP_X;
const dy = (y - LAMP_Y) * 1.05;
const d2 = dx * dx + dy * dy;
const fall = 1 / (1 + d2 / (34 * 34));
const cone = Math.abs(dy) / Math.sqrt(d2 + 1);
l = fall * (0.34 + 0.66 * smoothstep(0.08, 0.66, cone)) * 1.3;
mn = 0.1;
// 月の光:床に窓の形で落ち、ソファと窓のまわりも少し明るい
if (y >= 53) {
const shear = (y - 53) * 0.7;
const inPatch = x >= 103 + shear && x <= 135 + shear && Math.abs(x - (119 + shear)) > 1;
if (inPatch) mn += 0.16;
}
if ((y >= 37 && y < 42 && x >= 102 && x < 138) || (y >= 42 && y < 54 && x >= 97 && x < 143)) mn += 0.1;
mn += 0.05 * Math.exp(-Math.pow((x - 119) / 34, 2)) * (y < 53 ? 1 : 0.4);
}
if (inNext) {
const d = Math.hypot(x - 238, y - 24) / 34;
nb = 1 / (1 + d * d);
}
ceilMap[i] = c;
lampMap[i] = l;
moonMap[i] = mn;
nextMap[i] = nb;
}
}
};
// 額の絵は、回して読むので、別に持つ
const art = new Uint8Array(FRAME_W * FRAME_H);
(() => {
for (let v = 0; v < FRAME_H; v++) {
for (let u = 0; u < FRAME_W; u++) {
let m;
const edge = Math.min(u, v, FRAME_W - 1 - u, FRAME_H - 1 - v);
if (edge === 0) m = u === 0 || v === 0 ? M.ART_FR_HI : M.ART_FR;
else if (edge === 1) m = M.ART_FR;
else if (edge === 2) m = M.ART_MAT;
else {
const hill = 9.5 + Math.sin(u * 0.75) * 1.2 - Math.abs(u - 8) * 0.28;
const hillFar = 10.5 - Math.sin(u * 0.5 + 2) * 1.5;
if (Math.hypot(u - 12.5, v - 6.5) < 2) m = M.ART_SUN;
else if (v > 11) m = (u + v) % 3 === 0 ? M.ART_SKY2 : M.ART_SEA;
else if (v > hill + 1) m = M.ART_M2;
else if (v > hillFar) m = M.ART_M1;
else m = v < 6 ? M.ART_SKY : (u + v) % 2 === 0 ? M.ART_SKY2 : M.ART_SKY;
}
art[v * FRAME_W + u] = m;
}
}
})();
// 本棚の本
const books = [];
(() => {
const r = seeded(9091);
const kinds = [M.BK_S, M.BK_U, M.BK_A, M.BK_G, M.BK_P, M.BK_T, M.BK_C, M.BK_K, M.BK_U, M.BK_S];
for (const floorY of [18, 29, 40, 51]) {
let x = 63;
while (x < 90) {
const w = 2 + Math.floor(r() * 3);
if (x + w > 91) break;
const h = 5 + Math.floor(r() * 3.5);
if (r() < 0.14) {
x += 2 + Math.floor(r() * 2);
continue;
}
books.push({ x, w, h, y: floorY, m: kinds[Math.floor(r() * kinds.length)], band: r() < 0.5, id: books.length });
x += w;
}
}
})();
const fallBook = books.filter((b) => b.y === 40).at(-1) ?? books[books.length - 1];
// 葉:[付け根の高さ(鉢から), 向き(ラジアン。0 が右), 長さ, 幅, 茎の長さ]
const LEAVES = [
[1, 3.0, 13, 5, 6],
[2, 0.12, 14, 5.2, 7],
[6, 2.6, 13, 5.4, 7],
[8, 0.42, 14, 5.4, 7],
[12, 2.92, 12, 5, 6],
[14, 0.16, 12, 4.8, 6],
[17, 2.32, 13, 5.2, 6],
[20, 0.72, 13, 5, 6],
[23, 2.8, 10, 4.4, 5],
[25, 0.3, 10, 4.2, 5],
[27, 2.0, 10, 4.4, 4],
[29, 1.1, 10, 4.4, 3],
[30, 1.58, 9, 3.8, 2],
];
const stars = [];
const lights = [];
(() => {
const r = seeded(7711);
for (let i = 0; i < 18; i++) stars.push({ x: 101 + Math.floor(r() * 36), y: 11 + Math.floor(r() * 14), s: r() });
for (let i = 0; i < 26; i++) lights.push({ x: 102 + Math.floor(r() * 17) * 2, y: 25 + Math.floor(r() * 5) * 2, thr: i < 4 ? 0 : 0.04 + r() * 0.9 });
})();
// ---- 音の波と揺れ
const waves = [];
const particles = [];
const dons = [];
const zees = [];
const dots = [];
(() => {
const r = seeded(555);
const list = [M.DOT_P, M.DOT_T, M.DOT_A, M.DOT_C];
for (let i = 0; i < 34; i++) dots.push({ lon: r() * Math.PI * 2, lat: (r() - 0.5) * 1.3, m: list[i % 4], big: r() < 0.3 });
})();
const frame = { a: 0, av: 0, rest: 0 };
const actor = { x: SOFA_X, mode: 'read', next: 'read', goal: SOFA_X, tween: 1, from: null, step: 0, startle: 0, tidy: 0, facing: 1, page: 0, pageT: 0, blink: 0, nextBlink: 2 };
const nb = { mode: 'bed', hit: 0, light: 0, lift: 0 };
const knock = { wait: 1.2, left: 0, gap: 0, side: 0 };
const fallen = { on: false, x: 0, y: 0, vy: 0 };
// ---- 状態
let value = clamp(Number(input.value) || 0, 0, 100);
let x = value / 100; // レンズの位置
let velocity = 0;
let stretch = 0;
let stretchV = 0;
let lift = 0;
let liftTarget = 0;
let wobble = 0;
let beat = 0;
let halfCount = 0;
let kick = 0;
let anim = 0;
let lampNow = 0;
let party = 0;
let ballDrop = 0;
let shakeT = 0;
let shakeBig = 0;
let shiftX = 0;
let shiftY = 0;
let spin = 0;
let zeeAcc = 0;
const seatedMode = (m) => m === 'sleep' || m === 'read' || m === 'tap';
const wantMode = () => {
if (value === 0) return 'sleep';
if (value <= READ_TO) return 'read';
if (value <= TAP_TO) return 'tap';
if (value < MAX_FROM) return 'dance';
return 'wild';
};
const isMax = () => value >= MAX_FROM;
const describe = (v) => {
if (v === 0) return '0、住人は眠っている';
if (v <= READ_TO) return `${v}、住人はヘッドホンで本を読んでいる`;
if (v <= TAP_TO) return `${v}、住人は足でリズムをとっている`;
if (v < MAX_FROM) return `${v}、住人は立ち上がって踊っている`;
return `${v}、部屋が揺れ、壁の額が傾き、隣の部屋から壁をノックされている`;
};
// ---- レンズのいる列(世界の座標)
const lensWorldX = () => {
const center = (shape.W / 2 - shape.ox) / k - ox;
return center + ((x - 0.5) * shape.travel) / k;
};
const waveReach = () => clamp(lensWorldX() - SPK_X + 22, 16, WALL_X - SPK_X - 1);
// 波の先端が、その列にどれだけ触れているか
const waveFront = (wx) => {
let best = 0;
const dist = Math.abs(wx - SPK_X);
for (const w of waves) {
const near = 1 - Math.abs(w.r - dist) / 5;
if (near > 0 && w.p * near > best) best = w.p * near;
}
return best;
};
// ---- 関節の人形。番号:0,1 腰/2,3 胸(腰から)/4,5 首(胸から)/6..9 前の腕(肘、手)/10..13 後ろの腕/14..17 前の脚(膝、足)/18..21 後ろの脚
const poseOf = (hx, hy, cx, cy, nx, ny, af, ab, lf, lb) => Float32Array.of(hx, hy, cx, cy, nx, ny, af[0], af[1], af[2], af[3], ab[0], ab[1], ab[2], ab[3], lf[0], lf[1], lf[2], lf[3], lb[0], lb[1], lb[2], lb[3]);
const attr = { eyes: 0, mouth: 0, phones: 0, book: 0, toeUp: 0, flip: -1 };
const seatLegs = (tapLift = 0) => [
[5, 1, 6, 10 - tapLift],
[4, 1, 3, 10],
];
const poseFor = (mode) => {
const n = Math.floor(beat);
const ph = beat - n;
const c = Math.sin(beat * Math.PI);
attr.eyes = 0;
attr.mouth = 0;
attr.phones = 0;
attr.book = 0;
attr.toeUp = 0;
attr.flip = -1;
switch (mode) {
case 'sleep': {
attr.eyes = 1;
return poseOf(-3, -11, -8, -5, -4, 1, [-3, 4, -1, 6], [-3, 4, -2, 6], [6, -1, 11, 1], [5, 0, 10, 2]);
}
case 'read': {
const bob = !calm && n % 4 === 0 && ph < 0.3 ? 1 : 0;
attr.eyes = actor.blink > 0 ? 2 : 1;
attr.phones = 1;
attr.book = 1;
attr.flip = actor.pageT > 0 ? actor.pageT : -1;
const [lf, lb] = seatLegs();
return poseOf(0, -10, 0, -9, 1, -1 + bob, [3, 6, 7, 7], [2, 6, 6, 8], lf, lb);
}
case 'tap': {
const down = !calm && ph < 0.36;
attr.eyes = actor.blink > 0 ? 1 : 0;
attr.phones = 2;
attr.toeUp = calm ? 1 : ph > 0.5 ? 1 : 0;
const sway = calm ? 0 : c * 0.8;
const fingers = !calm && ph > 0.8 ? -1 : 0;
const [lf, lb] = seatLegs();
return poseOf(0, -10, sway, -9, sway * 0.5, down ? 0 : -1, [2, 5, 5, 8 + fingers], [1, 5, 4, 8], lf, lb);
}
case 'dance': {
const amp = clamp((x * 100 - TAP_TO) / (MAX_FROM - TAP_TO));
const bounce = Math.abs(c);
const dip = (1 - bounce) * (1 + amp);
const hx = c * (1 + 2 * amp);
const liftF = c < 0 ? -c * (2 + 2 * amp) : 0;
const liftB = c > 0 ? c * (2 + 2 * amp) : 0;
const fF = 3 - hx;
const fB = -3 - hx;
const legH = 12 - dip;
attr.eyes = ph < 0.5 ? 2 : 0;
attr.mouth = ph < 0.42 ? 1 : 0;
attr.phones = 2;
return poseOf(
hx,
-12 + dip,
-c * 0.8,
-9,
c * 0.6,
-1 + (ph < 0.3 ? 1 : 0),
[3, 4 - c * (2 + 3 * amp) - amp * 3, 5 + amp, 1 - c * (3 + 5 * amp) - amp * 6],
[-3, 4 + c * (2 + 3 * amp) - amp * 3, -5 - amp, 1 + c * (3 + 5 * amp) - amp * 6],
[fF * 0.5 + 1, legH * 0.5 - liftF * 0.5, fF, legH - liftF],
[fB * 0.5 - 1, legH * 0.5 - liftB * 0.5, fB, legH - liftB],
);
}
case 'wild': {
const j = Math.abs(c);
const jump = j * j * 5;
const tuck = j * 3;
const wag = Math.sin(beat * Math.PI * 2);
attr.eyes = 2;
attr.mouth = 1;
attr.phones = 2;
return poseOf(
c * 2,
-12 - jump,
-c,
-9,
c * 0.8,
ph < 0.4 ? 0 : -1,
[4, -5 + wag, 6 + wag, -12 + wag],
[-4, -5 - wag, -6 + wag, -12 - wag],
[3, 6 - tuck * 0.5, 4, 12 - tuck],
[-3, 6 - tuck * 0.5, -4, 12 - tuck],
);
}
case 'startle': {
attr.eyes = 0;
attr.mouth = 1;
attr.phones = 2;
return poseOf(0, -13, -1, -9, -1, -1, [4, 3, 6, -2], [-4, 3, -6, -2], [2, 7, 2, 13], [-2, 7, -2, 13]);
}
case 'walk': {
const s = Math.sin(actor.step * Math.PI);
attr.phones = 2;
attr.eyes = actor.blink > 0 ? 1 : 0;
return poseOf(0, -12 - Math.abs(s) * 0.8, 0, -9, 0, -1, [s * -2, 5, s * -3, 9], [s * 2, 5, s * 3, 9], [1 + s * 2, 6, s * 4, 12 - Math.max(0, -s) * 2], [-1 - s * 2, 6, -s * 4, 12 - Math.max(0, s) * 2]);
}
case 'tidy': {
attr.phones = 2;
return poseOf(0, -12, 0, -9, 0, -1, [4, -3, 7, -10], [-1, 5, -1, 9], [1, 6, 1, 12], [-1, 6, -1, 12]);
}
default:
return poseOf(0, -12, 0, -9, 0, -1, [1, 5, 1, 10], [-1, 5, -1, 10], [1, 6, 1, 12], [-1, 6, -1, 12]);
}
};
const setMode = (mode) => {
if (mode === actor.mode) return;
actor.from = lastPose ? lastPose.slice() : null;
actor.tween = actor.from ? 0 : 1;
actor.mode = mode;
};
let lastPose = null;
const stepActor = (dt) => {
const want = wantMode();
if (calm) {
actor.mode = want;
actor.x = seatedMode(want) ? SOFA_X : DANCE_X;
actor.tween = 1;
actor.startle = 0;
actor.facing = 1;
if (want !== 'wild') frame.rest = 0;
return;
}
actor.tween = Math.min(1, actor.tween + dt / 0.26);
actor.startle = Math.max(0, actor.startle - dt);
actor.blink = Math.max(0, actor.blink - dt);
actor.nextBlink -= dt;
if (actor.nextBlink <= 0) {
actor.blink = 0.13;
actor.nextBlink = 1.8 + Math.random() * 3.2;
}
actor.page -= dt;
if (actor.page <= 0) {
actor.pageT = 0.42;
actor.page = 4.4 + Math.random() * 3;
}
actor.pageT = Math.max(0, actor.pageT - dt);
if (actor.mode === 'walk') {
actor.next = want;
actor.goal = seatedMode(want) ? SOFA_X : DANCE_X;
if (seatedMode(want) && frame.rest !== 0 && actor.x > SOFA_X + 30) {
actor.goal = TIDY_X;
actor.next = 'tidy';
}
const delta = actor.goal - actor.x;
const speed = 54 + Math.abs(velocity) * 120;
if (Math.abs(delta) > 0.6) {
const move = Math.min(Math.abs(delta), speed * dt);
actor.x += Math.sign(delta) * move;
actor.facing = Math.sign(delta);
actor.step += move * 0.2;
} else {
actor.x = actor.goal;
setMode(actor.next);
if (actor.next === 'tidy') actor.tidy = 0.85;
}
return;
}
if (actor.mode === 'tidy') {
actor.facing = -1;
actor.tidy -= dt;
if (actor.tidy < 0.45) frame.rest = 0;
if (!seatedMode(want)) {
actor.next = want;
setMode(want);
} else if (actor.tidy <= 0) {
actor.goal = SOFA_X;
actor.next = want;
setMode('walk');
}
return;
}
if (actor.mode !== want) {
if (seatedMode(actor.mode) === seatedMode(want)) {
setMode(want);
} else {
actor.goal = seatedMode(want) ? SOFA_X : DANCE_X;
actor.next = want;
setMode('walk');
}
}
if (seatedMode(actor.mode)) actor.facing = 1;
else if (actor.startle > 0) actor.facing = 1;
else if (actor.mode === 'wild') actor.facing = Math.floor(beat / 4) % 2 === 0 ? 1 : -1;
else if (actor.mode === 'dance') actor.facing = Math.floor(beat / 8) % 2 === 0 ? 1 : -1;
};
const knockHit = () => {
nb.hit = 0.14;
shakeBig = 0.14;
wobble = Math.max(wobble, 0.6);
knock.side = (knock.side + 1) % 3;
dons.push({ x: WALL_X - 7, y: [5, 13, 21][knock.side], life: 0.48 });
frame.rest = Math.min(frame.rest + 2.6, 12);
frame.av += (Math.random() < 0.5 ? -1 : 1) * 42;
for (let i = 0; i < 7; i++) {
particles.push({ x: 150 + Math.random() * 56, y: 5, vx: (Math.random() - 0.5) * 8, vy: 10 + Math.random() * 20, life: 1.1 + Math.random() * 0.7, m: M.DUST, g: 40 });
}
for (let i = 0; i < 4; i++) {
particles.push({ x: WALL_X - 1, y: 18 + Math.random() * 24, vx: -14 - Math.random() * 20, vy: Math.random() * 10, life: 0.5, m: M.DUST, g: 50 });
}
if (actor.mode === 'wild' && actor.startle <= 0 && knock.left === 2) actor.startle = 0.62;
if (!fallen.on && Math.random() < 0.8) {
fallen.on = true;
fallen.x = fallBook.x;
fallen.y = fallBook.y - fallBook.h;
fallen.vy = 0;
}
};
// ---- 世界を進める
const stepWorld = (dt) => {
const t = calm ? 0 : dt;
anim += t;
const prevN = Math.floor(beat);
if (!calm) beat += (t * BPM) / 60;
else beat = 0.25;
const n = Math.floor(beat);
const ph = beat - n;
kick = calm ? 0.5 : Math.exp(-ph * 4.2);
const crossedHalf = Math.floor(beat * 2) !== halfCount;
halfCount = Math.floor(beat * 2);
// 音の波を出す
const power = x;
if (!calm) {
if (crossedHalf && value > 0) {
const isDown = halfCount % 2 === 0;
if (isDown || power > 0.45) waves.push({ r: 8, p: power * (isDown ? 1 : 0.62) });
}
const speed = 34 + 46 * power;
for (const w of waves) {
w.r += speed * t;
if (value > 0) w.p = Math.min(w.p, Math.max(power, 0.05));
}
const reach = waveReach();
for (let i = waves.length - 1; i >= 0; i--) {
if (waves[i].r > reach || waves.length > 24) waves.splice(i, 1);
}
}
// 灯りと飾り
if (calm) {
lampNow = value > 0 ? 1 : 0;
party = smoothstep(0.62, 0.72, x);
ballDrop = party;
} else {
lampNow += ((value > 0 ? 1 : 0) - lampNow) * (1 - Math.exp(-t * 10));
party += (smoothstep(0.62, 0.72, x) - party) * (1 - Math.exp(-t * 4));
ballDrop += (party > 0.02 ? 1 - ballDrop : -ballDrop) * (1 - Math.exp(-t * 2.4));
}
spin += t * 1.1;
// 住人
stepActor(t);
// 隣の人
if (x >= 0.86) nb.mode = isMax() ? 'knock' : 'up';
else nb.mode = 'bed';
const wantLight = nb.mode === 'bed' ? 0 : 1;
nb.light = calm ? wantLight : nb.light + (wantLight - nb.light) * (1 - Math.exp(-t * 6));
nb.hit = Math.max(0, nb.hit - t);
// ノック
if (!calm && isMax()) {
if (knock.left > 0) {
knock.gap -= t;
if (knock.gap <= 0) {
knock.left -= 1;
knock.gap = 0.24;
knockHit();
if (knock.left === 0) knock.wait = 2.3 + Math.random() * 0.7;
}
} else {
knock.wait -= t;
if (knock.wait <= 0) {
knock.left = 3;
knock.gap = 0;
}
}
} else if (!calm) {
knock.left = 0;
knock.wait = 1.1;
}
if (calm) {
dons.length = 0;
if (isMax()) dons.push({ x: WALL_X - 7, y: 13, life: 1 });
frame.rest = isMax() ? 9 : 0;
frame.a = frame.rest;
frame.av = 0;
fallen.on = false;
}
// 揺れ
if (!calm) {
shakeT = Math.max(0, shakeT - t);
shakeBig = Math.max(0, shakeBig - t);
if (n !== prevN && isMax()) shakeT = 0.11;
if (shakeBig > 0) {
shiftX = Math.random() < 0.5 ? -2 : 2;
shiftY = Math.random() < 0.5 ? -1 : 1;
} else if (shakeT > 0) {
shiftX = n % 2 === 0 ? 1 : -1;
shiftY = shakeT > 0.06 ? 1 : 0;
} else {
shiftX = 0;
shiftY = 0;
}
} else {
shiftX = 0;
shiftY = 0;
}
// 額:釘を中心に揺れ、傾いたまま止まる
if (!calm) {
if (isMax() && n !== prevN) frame.av += (n % 2 === 0 ? -1 : 1) * 16;
else if (x > 0.8 && n !== prevN) frame.av += (n % 2 === 0 ? -1 : 1) * 3;
const accel = -90 * (frame.a - frame.rest) - 6.5 * frame.av;
frame.av += accel * t;
frame.a += frame.av * t;
}
// 落ちた本は、住人が戻ると棚へ戻る
if (fallen.on) {
fallen.vy += 180 * t;
fallen.y += fallen.vy * t;
if (fallen.y > 54) {
fallen.y = 54;
fallen.vy = 0;
}
if (seatedMode(actor.mode)) fallen.on = false;
}
// 粒
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.life -= t;
p.vy += (p.g ?? 0) * t;
p.x += p.vx * t;
p.y += p.vy * t;
if (p.life <= 0 || p.y > 64) particles.splice(i, 1);
}
if (particles.length > 90) particles.length = 90;
if (!calm && actor.mode === 'wild' && n !== prevN) {
particles.push({ x: actor.x + (Math.random() - 0.5) * 8, y: 28, vx: (Math.random() - 0.5) * 30, vy: -16, life: 0.5, m: M.SWEAT, g: 110 });
}
for (let i = dons.length - 1; i >= 0; i--) {
dons[i].life -= t;
if (dons[i].life <= 0 && !calm) dons.splice(i, 1);
}
// 眠りの Z
if (!calm && actor.mode === 'sleep') {
zeeAcc += t;
if (zeeAcc > 1.7) {
zeeAcc = 0;
zees.push({ x: 120, y: 34, life: 2.6 });
}
}
for (let i = zees.length - 1; i >= 0; i--) {
const z = zees[i];
z.life -= t;
z.y -= t * 5.2;
z.x += Math.sin(z.life * 2.4) * t * 3.6;
if (z.life <= 0 || actor.mode !== 'sleep') zees.splice(i, 1);
}
};
// ---- 動くものを描く
const drawWindowLife = () => {
for (const s of stars) {
const tw = calm ? 1 : Math.sin(anim * (1.1 + s.s * 3.4) + s.s * 40);
if (tw < -0.55) continue;
const here = matBuf[(s.y + oy) * bw + s.x + ox];
if (here === M.SKY0 || here === M.SKY1 || here === M.SKY2) put(s.x, s.y, tw > 0.2 ? M.STAR : M.STAR_D);
}
// 音が大きいほど、向かいの建物の灯りがつく
for (const l of lights) {
const here = matBuf[(l.y + oy) * bw + l.x + ox];
if ((here === M.BLD || here === M.BLD_F) && x >= l.thr) put(l.x, l.y, M.BLD_W);
}
};
const drawCurtain = (x0, w, side) => {
const push = waveFront(x0 + w / 2) * 2.2;
for (let i = 0; i < w; i++) {
const u = i / w;
const fold = Math.sin(u * 12 + (calm ? 0 : Math.sin(anim * 1.3 + i * 0.5) * 0.5 + push));
const hem = 41 + Math.round(Math.sin(i * 1.7) * 0.8);
const swing = (u * (side > 0 ? 1 : 0.4) + 0.1) * push * side;
for (let y = 8; y < hem; y++) {
const off = Math.round(swing * ((y - 8) / 33));
const m = fold > 0.45 ? M.CURTAIN_HI : fold < -0.35 ? M.CURTAIN_D : M.CURTAIN;
put(x0 + i + off, y, y >= hem - 2 ? M.CURTAIN_D : m);
}
}
};
const drawPlant = () => {
const push = waveFront(20);
const baseX = 20;
const baseY = 48;
// 幹
for (let y = baseY; y > baseY - 31; y--) {
const lean = Math.round(Math.sin((baseY - y) * 0.09) * 1.2);
put(baseX + lean, y, M.STEM);
put(baseX + lean + 1, y, M.LEAF_D);
}
LEAVES.forEach(([rise, dir, len, wid, stem], index) => {
const sway = calm ? 0 : Math.sin(anim * 1.15 + index * 1.7) * 0.035 + push * 0.2 * (index % 2 === 0 ? 1 : -1);
const a = dir + sway;
const jx = baseX + Math.round(Math.sin(rise * 0.09) * 1.2);
const jy = baseY - rise;
// 葉柄
const lx = jx + Math.cos(a) * stem;
const ly = jy - Math.sin(a) * stem;
for (let s = 0; s <= stem; s++) {
const u = s / stem;
put(Math.round(mix(jx, lx, u)), Math.round(mix(jy, ly, u)), M.STEM);
}
// 葉:先へ向かって垂れる
const ca = Math.cos(a);
const sa = Math.sin(a);
const R = Math.ceil(len + wid);
for (let dy = -R; dy <= R; dy++) {
for (let dx = -R; dx <= R; dx++) {
const u = dx * ca - dy * sa;
if (u < 0 || u > len) continue;
const sag = (u * u * 0.028) * (ca >= 0 ? 1 : -1);
const v = dx * sa + dy * ca - sag * ca;
const t = u / len;
const half = wid * Math.sin(Math.pow(t, 0.72) * Math.PI);
if (Math.abs(v) > half) continue;
// 切れ込みと葉脈
const notch = Math.abs(v) > half * 0.55 && Math.floor(u) % 4 === 3 && t > 0.15;
if (notch) continue;
let m = v * (ca >= 0 ? -1 : 1) > 0.4 ? M.LEAF_HI : M.LEAF;
if (v * (ca >= 0 ? -1 : 1) < -half * 0.34) m = M.LEAF_D;
if (Math.abs(v) < 0.5) m = M.LEAF_D;
put(Math.round(lx + dx), Math.round(ly + dy), m);
}
}
});
};
const drawSpeaker = () => {
const hop = !calm && x > 0.9 && kick > 0.62 ? -1 : 0;
const X = 32;
const Y = 18 + hop;
// 箱
rect(X, Y, 26, 38, M.WOOD_D);
rect(X, Y, 26, 1, M.WOOD_HI);
rect(X, Y, 2, 38, M.WOOD);
rect(X + 24, Y, 2, 38, M.WOOD_DD);
rect(X + 2, Y + 2, 22, 34, M.BAFFLE);
rect(X + 1, 56, 4, 1, M.WOOD_DD);
rect(X + 21, 56, 4, 1, M.WOOD_DD);
// 上のアンプと、音量の目盛り
rect(X + 1, 11 + hop, 24, 7, M.METAL);
rect(X + 1, 11 + hop, 24, 1, M.METAL_HI);
rect(X + 3, 13 + hop, 15, 2, M.LED_OFF);
const level = clamp(x * (calm ? 1 : 0.72 + 0.34 * kick));
const lit = Math.round(level * 15);
for (let i = 0; i < lit; i += 1) {
const m = i < 8 ? M.LED_G : i < 12 ? M.LED_A : M.LED_R;
if (i % 2 === 0) rect(X + 3 + i, 13 + hop, 1, 2, m);
else rect(X + 3 + i, 13 + hop, 1, 2, M.LED_OFF);
}
disc(X + 21, 14 + hop, 2.2, (px, py) => put(px, py, M.BAFFLE));
const dial = -2.2 + x * 4.4;
put(X + 21 + Math.round(Math.sin(dial) * 2), 14 + hop - Math.round(Math.cos(dial) * 2), M.METAL_HI);
put(X + 3, 16 + hop, value > 0 ? M.LED_A : M.LED_OFF);
// ツイーター
disc(SPK_X, 26 + hop, 4.4, (px, py, d, dx, dy) => {
if (d > 3.3) put(px, py, M.RING);
else if (d > 1.6) put(px, py, dx + dy < 0 ? M.CONE_HI : M.CONE);
else put(px, py, M.CAP);
});
// ウーファー:拍に合わせて、中心が前へ出る
const pump = value > 0 ? kick * x : 0;
const capR = 3 + pump * 2;
const ripple = pump * 3;
disc(SPK_X, SPK_Y + hop, 10.4, (px, py, d, dx, dy) => {
let m;
if (d > 9.3) m = M.RING;
else if (d > 8.2) m = dx + dy < 0 ? M.CONE_HI : M.CONE_D;
else if (d > capR) {
const band = (d + ripple) % 3;
m = band < 1 ? M.CONE_D : dx + dy < -4 ? M.CONE_HI : M.CONE;
} else {
m = dx + dy < 0 ? M.METAL_HI : M.CAP;
}
put(px, py, m);
});
// ねじ
for (const [sx, sy] of [[-10, -10], [10, -10], [-10, 11], [10, 11]]) put(SPK_X + sx, SPK_Y + sy + hop, M.METAL);
};
const drawBooks = () => {
for (const b of books) {
if (fallen.on && b === fallBook) continue;
const push = waveFront(b.x) * (x > 0.75 ? 2.2 : 1);
const hopNow = calm ? 0 : Math.round(push * (0.6 + ((b.id * 7) % 5) * 0.2));
const ty = b.y - b.h - hopNow;
rect(b.x, ty, b.w, b.h, b.m);
rect(b.x, ty, 1, b.h, b.m);
if (b.w > 2) rect(b.x + b.w - 1, ty, 1, b.h, M.BK_D);
if (b.band) rect(b.x, ty + 1, b.w, 1, M.BK_HI);
else if (b.w > 2) put(b.x + 1, ty + 2, M.BK_HI);
}
if (fallen.on) {
const y = Math.round(fallen.y);
rect(fallBook.x, y, fallBook.h, 2, fallBook.m);
rect(fallBook.x, y, fallBook.h, 1, M.BK_HI);
}
};
const drawLamp = () => {
const on = lampNow > 0.5;
const push = waveFront(LAMP_X);
const lean = calm ? 0 : Math.round(push * (x > 0.75 ? 1.4 : 0.4) * (halfCount % 2 ? 1 : -1));
rect(LAMP_X - 4, 55, 9, 2, M.POLE);
rect(LAMP_X - 3, 55, 7, 1, M.POLE_HI);
rect(LAMP_X, 22, 1, 33, M.POLE);
for (let i = 0; i < 11; i++) {
const y = 11 + i;
const half = Math.round(4 + i * 0.32);
for (let dx = -half; dx <= half; dx++) {
const e = Math.abs(dx) / half;
let m;
if (on) {
const hot = i / 11 - e * 0.55 + bayerAt(dx + 8, y) / 40;
m = i === 10 ? M.BULB : hot > 0.44 ? M.SHADE_HOT : hot > 0.04 ? M.SHADE_ON : M.SHADE_RIM;
} else {
m = e > 0.66 ? M.SHADE_D : M.SHADE;
}
put(LAMP_X + dx + lean, y, m);
}
}
};
const drawFrame = () => {
const angle = (frame.a * Math.PI) / 180;
const ca = Math.cos(angle);
const sa = Math.sin(angle);
const reach = FRAME_W + 4;
const artAt = (px, py) => {
const dx = px + 0.5 - FRAME_X;
const dy = py + 0.5 - FRAME_Y;
const iu = Math.floor(ca * dx + sa * dy + FRAME_W / 2);
const iv = Math.floor(-sa * dx + ca * dy - 2);
return iu >= 0 && iu < FRAME_W && iv >= 0 && iv < FRAME_H ? iv * FRAME_W + iu : -1;
};
// 影と、釘から下りる針金
for (let y = FRAME_Y - 1; y < FRAME_Y + FRAME_H + 8; y++) {
for (let px = FRAME_X - reach; px <= FRAME_X + reach; px++) {
if (artAt(px, y) >= 0) continue;
if (artAt(px - 1, y - 1) >= 0) {
put(px, y, M.FRAME_SH);
continue;
}
const dx = px + 0.5 - FRAME_X;
const dy = y + 0.5 - FRAME_Y;
const u = ca * dx + sa * dy;
const v = -sa * dx + ca * dy;
if (v >= 0 && v < 2 && Math.abs(Math.abs(u) - v * 3.2) < 0.7) put(px, y, M.WIRE);
}
}
for (let y = FRAME_Y; y < FRAME_Y + FRAME_H + 8; y++) {
for (let px = FRAME_X - reach; px <= FRAME_X + reach; px++) {
const at = artAt(px, y);
if (at >= 0) put(px, y, art[at]);
}
}
put(FRAME_X, FRAME_Y, M.WOOD_DD);
};
const drawRecord = () => {
const turning = value > 0 && !calm;
rect(190, 33, 11, 2, M.CONE_D);
rect(191, 32, 9, 1, M.CONE_D);
const at = turning ? Math.floor(anim * 16) % 9 : 3;
put(191 + at, 32, M.CONE_HI);
put(190 + ((at + 5) % 11), 33, M.CONE_HI);
rect(195, 33, 2, 1, M.BK_A);
// 針:ノックで跳ねる
const jump = nb.hit > 0 ? 1 : 0;
rect(199, 31 - jump, 4, 1, M.METAL_HI);
put(202, 32, M.METAL);
put(202, 33, M.METAL);
put(199, 32 - jump, M.METAL);
put(200, 36, value > 0 ? M.LED_A : M.LED_OFF);
};
const drawBall = () => {
if (ballDrop < 0.02) return;
const cy = Math.round(mix(0, 15, ballDrop));
const swing = calm ? 0 : Math.round(Math.sin(anim * 1.6) * (isMax() ? 1.6 : 0.6));
const bx = BALL_X + swing;
for (let y = 3; y < cy - 3; y++) put(y < cy - 8 ? BALL_X : bx, y, M.WIRE);
if (cy < 4) return;
disc(bx, cy, 4.3, (px, py, d, dx, dy) => {
const lon = Math.floor(dx * 0.9 + spin * 6);
const lat = dy;
const check = (lon + lat) & 1;
const glint = !calm && Math.sin(lon * 12.9 + lat * 78.2 + Math.floor(spin * 10)) > 0.8;
let m = check ? M.BALL : M.BALL_D;
if (glint || (dx < -1 && dy < -1 && d < 3.2)) m = M.BALL_HI;
put(px, py, m);
});
};
const addCone = (sx, lean, spread, ci) => {
for (let y = 6; y <= 66; y++) {
const dy = y - 5;
const cx = sx + lean * dy;
let half = 1.5 + spread * dy;
// 床に落ちたところで、丸くとじる
if (y > 57) half *= Math.sqrt(Math.max(0, 1 - Math.pow((y - 57) / 9.5, 2)));
const x0 = Math.ceil(cx - half);
const x1 = Math.floor(cx + half);
for (let wx = x0; wx <= x1; wx++) {
if (wx >= WALL_X) continue;
const px = wx + ox;
const py = y + oy;
if (px < 0 || py < 0 || px >= bw || py >= bh) continue;
const th = bayerAt(px, py) / 16;
if (th > party) continue;
const edge = half - Math.abs(wx - cx);
if (edge < 2 && th > edge / 2) continue;
tintBuf[py * bw + px] = ci + 1;
}
}
};
const drawParty = () => {
if (party < 0.02) return;
const t = calm ? 1.2 : anim;
const pump = 1 + kick * 0.14;
const fast = isMax() ? 1.7 : 1;
addCone(90, Math.sin(t * 0.71 * fast) * 0.8, 0.24 * pump, 0);
addCone(172, Math.sin(t * 0.63 * fast + 4) * 0.62, 0.25 * pump, 1);
if (isMax()) addCone(130, Math.sin(t * 0.53 * fast + 2) * 0.72, 0.22 * pump, 2);
for (const fx of [90, 130, 172]) {
rect(fx - 1, 5, 3, 1, M.BAFFLE);
put(fx, 6, M.LED_A);
}
// 鏡の玉の光の粒
if (ballDrop > 0.5) {
for (const d of dots) {
const a = d.lon + (calm ? 0 : spin);
const facing = Math.cos(a);
if (facing < 0.05) continue;
const px = Math.round(BALL_X + Math.sin(a) * 128 * Math.cos(d.lat));
const py = Math.round(28 + Math.sin(d.lat) * 34 + facing * 8);
if (px >= WALL_X - 1 || py < 5 || py > 65) continue;
const here = matBuf[(py + oy) * bw + px + ox];
if (here === M.SKY0 || here === M.SKY1 || here === M.SKY2) continue;
put(px, py, d.m);
if (d.big) {
put(px + 1, py, d.m);
put(px, py + 1, d.m);
put(px + 1, py + 1, d.m);
}
}
}
};
const plotWave = (wx, wy, m) => {
const py = Math.round(wy);
if (py < 4 || py > 66) return;
const px = Math.round(wx);
if (px >= WALL_X) return;
put(px, py, m);
};
const drawWaves = () => {
if (value === 0) return;
const reach = waveReach();
const arc = (r, p, m, drop0) => {
const fade = clamp(r / reach);
const span = 0.44 + p * 0.74;
const step = 0.72 / Math.max(r, 6);
for (let a = -span; a <= span; a += step) {
const wx = SPK_X + Math.cos(a) * r;
const wy = SPK_Y + Math.sin(a) * r * 0.94;
const edge = Math.abs(a) / span;
const drop = drop0 + fade * fade * fade * 0.8 + edge * edge * edge * edge * 0.7;
if (bayerAt(Math.round(wx) + ox, Math.round(wy) + oy) / 16 < drop) continue;
plotWave(wx, wy, m);
}
};
const ring = (r, p) => {
if (r < 4) return;
const fade = clamp(r / reach);
const m = fade < 0.3 ? M.WAVE_HI : fade < 0.72 ? M.WAVE : M.WAVE_D;
// 音量が上がるほど、波面が太くなり、後ろに尾を引く
const thick = 1 + Math.round(p * 2.2);
for (let i = thick - 1; i >= 0; i--) arc(r - i, p, i === 0 ? m : fade < 0.5 ? M.WAVE : M.WAVE_D, i === 0 ? 0 : 0.05 * i);
if (p > 0.34) arc(r - thick - 1, p, M.WAVE_D, 0.32);
if (p > 0.66) arc(r - thick - 3, p, M.WAVE_D, 0.6);
}; if (calm) {
const count = Math.max(1, Math.ceil(x * 9));
for (let i = 0; i < count; i++) ring(10 + ((i + 0.4) / count) * reach * 0.94, x);
return;
}
for (const w of waves) ring(w.r, w.p);
// 背中側へも、少しだけ
if (x > 0.3) {
for (const w of waves) {
if (w.r > 26) continue;
for (let a = -0.5; a <= 0.5; a += 0.75 / Math.max(w.r, 6)) {
const wx = SPK_X - Math.cos(a) * w.r;
const wy = SPK_Y + Math.sin(a) * w.r * 0.9;
if (bayerAt(Math.round(wx) + ox, Math.round(wy) + oy) / 16 < w.r / 26) continue;
plotWave(wx, wy, M.WAVE_D);
}
}
}
};
// ---- 人形:まず小さな下書きに描き、まわりに1画素の縁を付けて写す
const TMP = 80;
const tmp = new Uint8Array(TMP * TMP);
const tput = (tx, ty, m) => {
const ix = Math.round(tx) + 40;
const iy = Math.round(ty) + 66;
if (ix < 0 || iy < 0 || ix >= TMP || iy >= TMP) return;
tmp[iy * TMP + ix] = m;
};
const tblock = (tx, ty, w, h, m) => {
for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) tput(tx + i, ty + j, m);
};
const tline = (x0, y0, x1, y1, w, m) => {
const steps = Math.max(1, Math.ceil(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0))));
const off = (w - 1) >> 1;
for (let s = 0; s <= steps; s++) {
const u = s / steps;
tblock(Math.round(mix(x0, x1, u)) - off, Math.round(mix(y0, y1, u)) - off, w, w, m);
}
};
const RES = { H: M.HAIR, h: M.HAIR_HI, S: M.SKIN, D: M.SKIN_D, E: M.EYE, C: M.CHEEK, O: M.MOUTH, T: M.SHIRT, TD: M.SHIRT_D, TS: M.STRIPE, P: M.PANTS, PD: M.PANTS_D, B: M.SHOE };
const NEXT = { H: M.NB_HAIR, h: M.NB_HAIR, S: M.NB_SKIN, D: M.NB_SKIN_D, E: M.EYE, C: M.NB_SKIN_D, O: M.EYE, T: M.NB_SHIRT, TD: M.NB_SHIRT_D, TS: M.NB_STRIPE, P: M.NB_PANTS, PD: M.NB_PANTS_D, B: M.NB_SHOE };
const headTo = (nx, ny, set, a) => {
const left = nx - 6;
const headTop = ny - 10;
for (const [i, j, ch] of HEAD_CELLS) {
let m = -1;
if (ch === 'H') m = set.H;
else if (ch === 'h') m = set.h;
else if (ch === 'S') m = set.S;
else if (ch === 'C') m = set.C;
else if (ch === '1') m = a.eyes === 0 || a.eyes === 2 ? set.E : set.S;
else if (ch === '2') m = a.eyes === 0 || a.eyes === 1 ? set.E : set.S;
else if (ch === '3') m = a.mouth === 1 ? set.O : set.D;
else if (ch === '4') m = a.mouth === 1 ? set.O : set.S;
if (m >= 0) tput(left + i, headTop + j, m);
}
if (a.phones === 1) {
tblock(left + 3, headTop - 1, 6, 1, M.PHONE_D);
tput(left + 2, headTop, M.PHONE_D);
tput(left + 9, headTop, M.PHONE_D);
tput(left + 1, headTop + 1, M.PHONE_D);
tblock(left + 1, headTop + 2, 1, 2, M.PHONE_D);
tblock(left + 2, headTop + 4, 3, 4, M.PHONE);
tblock(left + 1, headTop + 5, 1, 2, M.PHONE_D);
} else if (a.phones === 2) {
tblock(left + 3, headTop + 10, 7, 1, M.PHONE_D);
tblock(left + 2, headTop + 9, 2, 2, M.PHONE);
tblock(left + 8, headTop + 9, 2, 2, M.PHONE);
}
};
const stampPuppet = (wx, wy, p, f, set, a) => {
tmp.fill(0);
const hipX = p[0];
const hipY = p[1];
const chX = hipX + p[2];
const chY = hipY + p[3];
const shX = chX;
const shY = chY + 1;
const nX = chX + p[4];
const nY = chY + p[5];
const arm = (i, dark) => {
const ex = shX + p[i];
const ey = shY + p[i + 1];
const hx = shX + p[i + 2];
const hy = shY + p[i + 3];
tline(shX, shY, ex, ey, 2, dark ? set.TD : set.T);
tline(ex, ey, hx, hy, 2, dark ? set.D : set.S);
tblock(Math.round(hx) - 1, Math.round(hy) - 1, 2, 2, dark ? set.D : set.S);
};
const leg = (i, dark, toe) => {
const kx = hipX + p[i];
const ky = hipY + p[i + 1];
const fx = hipX + p[i + 2];
const fy = hipY + p[i + 3];
tline(hipX, hipY, kx, ky, 3, dark ? set.PD : set.P);
tline(kx, ky, fx, fy, 3, dark ? set.PD : set.P);
const sx = Math.round(fx) - 1;
const sy = Math.round(fy);
if (toe) {
tblock(sx, sy - 1, 3, 2, set.B);
tblock(sx + 3, sy - 2, 2, 1, set.B);
tput(sx + 2, sy - 2, set.B);
} else {
tblock(sx, sy - 1, 5, 2, set.B);
tput(sx + 1, sy - 2, set.B);
}
};
arm(10, true);
leg(18, true, false);
// 腰と胴。胴は線で描くので、どの角度にも傾く
tblock(Math.round(hipX) - 4, Math.round(hipY) - 1, 8, 3, set.P);
const steps = Math.max(1, Math.ceil(Math.max(Math.abs(chX - hipX), Math.abs(chY - hipY))));
for (let s = 0; s <= steps; s++) {
const u = s / steps;
const cx = Math.round(mix(hipX, chX, u));
const cy = Math.round(mix(hipY - 1, chY + 1, u));
const m = s % 3 === 1 ? set.TS : set.T;
tblock(cx - 4, cy - 1, 8, 2, m);
tblock(cx - 4, cy - 1, 1, 2, set.TD);
}
tput(chX, chY, set.S);
leg(14, false, a.toeUp === 1);
tblock(Math.round(nX) - 1, Math.round(nY) - 1, 3, 2, set.D);
headTo(Math.round(nX), Math.round(nY), set, a);
if (a.book === 1) {
const hx = Math.round(shX + p[8]);
const hy = Math.round(shY + p[9]);
tblock(hx - 3, hy - 5, 8, 6, M.BK_T);
tblock(hx - 2, hy - 6, 6, 1, M.BK_HI);
tblock(hx - 3, hy - 5, 1, 6, M.BK_D);
tblock(hx + 1, hy - 5, 1, 6, M.BK_D);
if (a.flip > 0) {
const fx = Math.round(mix(hx + 4, hx - 3, 1 - a.flip / 0.42));
tblock(fx, hy - 6, 1, 5, M.BK_HI);
}
}
arm(6, false);
// 写す。先に縁、次に中身
const X0 = Math.round(wx);
const Y0 = Math.round(wy);
for (let iy = 0; iy < TMP; iy++) {
for (let ix = 0; ix < TMP; ix++) {
if (!tmp[iy * TMP + ix]) continue;
const sx = ix - 40;
const sy = iy - 66;
if (ix === 0 || !tmp[iy * TMP + ix - 1]) put(X0 + (f > 0 ? sx - 1 : -sx + 1), Y0 + sy, M.OUTLINE);
if (ix === TMP - 1 || !tmp[iy * TMP + ix + 1]) put(X0 + (f > 0 ? sx + 1 : -sx - 1), Y0 + sy, M.OUTLINE);
if (iy === 0 || !tmp[(iy - 1) * TMP + ix]) put(X0 + (f > 0 ? sx : -sx), Y0 + sy - 1, M.OUTLINE);
if (iy === TMP - 1 || !tmp[(iy + 1) * TMP + ix]) put(X0 + (f > 0 ? sx : -sx), Y0 + sy + 1, M.OUTLINE);
}
}
for (let iy = 0; iy < TMP; iy++) {
for (let ix = 0; ix < TMP; ix++) {
const m = tmp[iy * TMP + ix];
if (!m) continue;
put(X0 + (f > 0 ? ix - 40 : -(ix - 40)), Y0 + iy - 66, m);
}
}
};
// 眠っている住人:枕に頭を載せ、毛布の山が息で上下する
const drawSleeper = () => {
const breath = calm ? 0.5 : 0.5 + 0.5 * Math.sin(anim * 1.55);
const rise = Math.round(breath * 1.4);
// 頭:月の光だけが顔を照らす
const fx = 103;
const fy = 36;
for (const [i, j, ch] of SLEEP_FACE) {
let m = M.MOON_SKIN;
if (ch === 'H') m = M.MOON_HAIR;
else if (ch === 'E') m = M.MOON_EYE;
else if (ch === 'C') m = M.MOON_SKIN_D;
else if (ch === 'M') m = M.MOON_SKIN_D;
put(fx + i, fy + j, m);
}
// 毛布:あごまで掛かり、胸のあたりが息で上下する
const X0 = 114;
const X1 = 136;
for (let wx = X0; wx <= X1; wx++) {
const u = (wx - X0) / (X1 - X0);
const hump = Math.sin(Math.min(u * 1.22, 1) * Math.PI) * 5.5 + 2 + (u < 0.45 ? rise : 0) + (u > 0.8 ? 1 : 0);
const ty = Math.round(48 - hump);
put(wx, ty - 1, M.OUTLINE);
for (let y = ty; y <= 49; y++) {
const stripe = (wx + y * 2) % 9 < 3;
put(wx, y, y === ty ? M.BLANKET_HI : stripe ? M.BLANKET_D : M.BLANKET);
}
}
// 垂れた毛布のふち
for (let wx = X0 + 2; wx < X1; wx++) put(wx, 50 + ((wx * 3) % 5 === 0 ? 1 : 0), M.BLANKET_D);
// 鼻ちょうちん:息に合わせてふくらみ、吐ききると消える
const size = calm ? 1.8 : breath > 0.12 ? 0.8 + breath * 1.8 : 0;
if (size > 0) {
const bx = fx + 11 + Math.round(size);
const by = fy + 3 - Math.round(size);
disc(bx, by, size + 0.4, (px, py, d, dx, dy) => {
if (d > size - 0.6) put(px, py, M.BUBBLE);
else if (dx === -1 && dy === -1) put(px, py, M.BUBBLE);
});
}
// Z
if (calm) {
glyph(G_Z, 118, 26, M.ZEE);
glyph(G_Z_SMALL, 113, 20, M.ZEE);
} else {
for (const z of zees) {
const rows = z.life > 1.5 ? G_Z_SMALL : G_Z;
if (z.life < 0.5 && Math.floor(z.life * 12) % 2 === 0) continue;
glyph(rows, Math.round(z.x), Math.round(z.y), M.ZEE);
}
}
};
const drawActor = () => {
const mode = actor.startle > 0 ? 'startle' : actor.mode;
if (mode === 'sleep' && actor.tween >= 1) {
drawSleeper();
lastPose = poseFor('sleep');
return;
}
const target = poseFor(mode);
let pose = target;
if (actor.from && actor.tween < 1) {
const e = actor.tween * actor.tween * (3 - 2 * actor.tween);
pose = new Float32Array(22);
for (let i = 0; i < 22; i++) pose[i] = mix(actor.from[i], target[i], e);
}
lastPose = pose;
// 畳んだ毛布
rect(130, 44, 7, 3, M.BLANKET);
rect(130, 44, 7, 1, M.BLANKET_HI);
stampPuppet(actor.x, FLOOR_Y, pose, actor.facing, RES, attr);
if (mode === 'startle') {
glyph(G_MARK, Math.round(actor.x) + 6, FLOOR_Y - 38, M.DON, M.OUTLINE);
}
};
const drawNeighbor = () => {
const a = { eyes: 0, mouth: 0, phones: 0, book: 0, toeUp: 0, flip: -1 };
if (nb.mode === 'bed') {
// 眠っている
for (let wx = 230; wx < 250; wx++) {
const u = (wx - 230) / 20;
const hump = Math.round(Math.sin(u * Math.PI) * 4 + 2);
for (let y = 46 - hump; y < 50; y++) put(wx, y, y === 46 - hump ? M.NB_BED_HI : M.NB_BED);
}
rect(225, 40, 6, 4, M.NB_HAIR);
rect(227, 42, 4, 2, M.NB_SKIN);
return;
}
if (nb.mode === 'up') {
// 起きて、壁を見ている
a.eyes = 0;
const p = poseOf(0, -12, 0, -9, 0, -1, [3, 4, 5, 2], [2, 5, 5, 3], [1, 6, 1, 12], [-1, 6, -1, 12]);
stampPuppet(226, FLOOR_Y, p, -1, NEXT, a);
return;
}
// 拳で叩く
a.eyes = 1;
const hit = nb.hit > 0.03 ? 1 : 0;
const back = calm ? 0.5 : hit;
const p = poseOf(0, -12, 1 * back, -9, 1 * back, -1, [4, -2, 4 + 3 * back, -5], [-1, 5, -1, 9], [2, 6, 2, 12], [-2, 6, -2, 12]);
stampPuppet(224, FLOOR_Y, p, -1, NEXT, a);
if (hit || calm) {
// 叩いたところの火花
const hitX = WALL_X + 9;
for (const [dx, dy] of SPARKS) put(hitX + dx, 36 + dy, M.BURST);
}
};
const drawDons = () => {
for (const d of dons) {
// 出た瞬間だけ、大きく跳ねる
const jolt = !calm && d.life > 0.4 ? -1 : 0;
glyph(G_DO, d.x, d.y + jolt, M.DON, M.OUTLINE);
glyph(G_N, d.x + 11, d.y + jolt, M.DON, M.OUTLINE);
}
};
const drawMotes = () => {
if (lampNow < 0.5 || calm) return;
for (let i = 0; i < 9; i++) {
const s = i * 37.3;
const wx = LAMP_X - 22 + ((s * 7 + anim * (1.5 + (i % 3))) % 44);
const wy = 26 + ((s * 3 + anim * (0.9 + (i % 2) * 0.7)) % 28);
if (bayerAt(Math.round(wx), Math.round(wy)) > 8) continue;
put(Math.round(wx), Math.round(wy), M.MOTE);
}
};
const buildBase = () => {
for (let py = 0; py < bh; py++) {
for (let px = 0; px < bw; px++) matBuf[py * bw + px] = roomAt(px - ox, py - oy);
}
paintRug();
paintDoor();
paintWindow();
paintShelf();
paintPot();
paintSofa();
paintCabinet();
paintNextRoom();
// 額の跡:傾くと、日に焼けていない壁が覗く
rect(FRAME_X - FRAME_W / 2, FRAME_Y + 2, FRAME_W, FRAME_H, M.WALL_HI);
baseBuf.set(matBuf);
buildLight();
};
// ---- 光を解いて、色にする
let glowRaw = new Float32Array(1);
let glowNearBuf = new Float32Array(1);
let glowFarBuf = new Float32Array(1);
let glowTemp = new Float32Array(1);
const GRID_W = 96;
let GRID_H = 36;
const renderWorld = () => {
matBuf.set(baseBuf);
tintBuf.fill(0);
drawWindowLife();
drawCurtain(95, 9, 1);
drawCurtain(134, 9, -1);
drawPlant();
drawSpeaker();
drawBooks();
drawFrame();
drawRecord();
drawLamp();
drawBall();
drawParty();
drawWaves();
drawNeighbor();
drawMotes();
drawActor();
drawDons();
for (const p of particles) put(Math.round(p.x), Math.round(p.y), p.m);
// 灯りの計算
const warm = smoothstep(0.22, 0.42, x);
const maxNow = isMax() ? 1 : 0;
let amb = mix(0.03, 0.88, warm);
amb = mix(amb, 0.56, party);
amb += maxNow * kick * 0.16;
const lamp = lampNow * mix(0.82, 0.34, warm);
const nextLight = 0.17 + nb.light * 0.46;
const size = bw * bh;
glowRaw.fill(0);
for (let i = 0; i < size; i++) {
const m = matBuf[i];
const L = amb * ceilMap[i] + lamp * lampMap[i] + moonMap[i] + nextLight * nextMap[i];
const lf = L * 7;
let lv = lf | 0;
const frac = lf - lv;
if (frac > 0.42 && (frac >= 0.58 || (frac - 0.42) / 0.16 > thrBuf[i])) lv += 1;
if (lv > 7) lv = 7;
const tint = tintBuf[i];
const id = tint ? tintTable[(m * 3 + tint - 1) * 8 + lv] : litTable[m * 8 + lv];
const c = palette32[id];
pixels[i] = c;
const w = glowOf[m];
if (w > 0) {
const at = gridOf[i];
glowRaw[at] += (c & 255) * w * emitScale;
glowRaw[at + 1] += ((c >>> 8) & 255) * w * emitScale;
glowRaw[at + 2] += ((c >>> 16) & 255) * w * emitScale;
}
}
};
const boxBlur = (src, dst, w, h, r) => {
const size = r * 2 + 1;
for (let y = 0; y < h; y++) {
for (let c = 0; c < 3; c++) {
let sum = 0;
for (let i = -r; i <= r; i++) sum += src[y * w * 4 + clamp(i, 0, w - 1) * 4 + c];
for (let i = 0; i < w; i++) {
dst[y * w * 4 + i * 4 + c] = sum / size;
sum += src[y * w * 4 + clamp(i + r + 1, 0, w - 1) * 4 + c] - src[y * w * 4 + clamp(i - r, 0, w - 1) * 4 + c];
}
}
}
for (let i = 0; i < w; i++) {
for (let c = 0; c < 3; c++) {
let sum = 0;
for (let j = -r; j <= r; j++) sum += dst[clamp(j, 0, h - 1) * w * 4 + i * 4 + c];
for (let j = 0; j < h; j++) {
src[j * w * 4 + i * 4 + c] = sum / size;
sum += dst[clamp(j + r + 1, 0, h - 1) * w * 4 + i * 4 + c] - dst[clamp(j - r, 0, h - 1) * w * 4 + i * 4 + c];
}
}
}
};
// ---- WebGL
let gl = null;
try {
gl = canvas.getContext('webgl2', { alpha: true, premultipliedAlpha: true, antialias: false, depth: false, stencil: false, powerPreference: 'high-performance' });
} catch {
gl = null;
}
let program = null;
const loc = {};
let vao = null;
let quad = null;
let texWorld = null;
let texGlow = null;
let bakeProgram = null;
const bakeLoc = {};
let bakeTarget = null;
let texBase = null;
let texWeight = null;
let needBake = true; // 大きさか色が変わったら、動かない絵を焼き直す
const compile = (type, source) => {
const s = gl.createShader(type);
gl.shaderSource(s, source);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s) || 'shader');
return s;
};
const link = (fragSource) => {
const made = gl.createProgram();
const v = compile(gl.VERTEX_SHADER, VERT);
const f = compile(gl.FRAGMENT_SHADER, fragSource);
gl.attachShader(made, v);
gl.attachShader(made, f);
gl.bindAttribLocation(made, 0, 'aPos');
gl.linkProgram(made);
gl.deleteShader(v);
gl.deleteShader(f);
if (!gl.getProgramParameter(made, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(made) || 'link');
return made;
};
const setupGl = () => {
if (!gl) return false;
try {
program = link(FRAG);
bakeProgram = link(BAKE_FRAG);
} catch (error) {
console.warn('slider-resident: シェーダーを作れないので、2D で描く', error);
return false;
}
gl.useProgram(program);
for (const name of ['uWorld', 'uGlow', 'uBase', 'uWeight', 'uRes', 'uPill', 'uOrigin', 'uWorldSize', 'uGlowSize', 'uShift', 'uLens', 'uLensR', 'uK', 'uLift', 'uWobble', 'uTime', 'uAlbedo', 'uEnvHi', 'uEnvLo', 'uInk', 'uGround']) {
loc[name] = gl.getUniformLocation(program, name);
}
for (const name of ['uRes', 'uPill', 'uBezel', 'uCorner', 'uAlbedo', 'uEnvHi', 'uEnvLo', 'uInk']) {
bakeLoc[name] = gl.getUniformLocation(bakeProgram, name);
}
vao = gl.createVertexArray();
gl.bindVertexArray(vao);
quad = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, quad);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
const makeTex = () => {
const t = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, t);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
return t;
};
texWorld = makeTex();
texGlow = makeTex();
gl.activeTexture(gl.TEXTURE2);
texBase = makeTex();
gl.activeTexture(gl.TEXTURE3);
texWeight = makeTex();
gl.uniform1i(loc.uWorld, 0);
gl.uniform1i(loc.uGlow, 1);
gl.uniform1i(loc.uBase, 2);
gl.uniform1i(loc.uWeight, 3);
gl.disable(gl.BLEND);
// 焼く先。RGBA8 は WebGL2 で必ず描けるが、念のため確かめ、だめなら 2D で描く
for (const [unit, tex] of [[gl.TEXTURE2, texBase], [gl.TEXTURE3, texWeight]]) {
gl.activeTexture(unit);
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
}
bakeTarget = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, bakeTarget);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texBase, 0);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT1, gl.TEXTURE_2D, texWeight, 0);
gl.drawBuffers([gl.COLOR_ATTACHMENT0, gl.COLOR_ATTACHMENT1]);
const complete = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
if (!complete) {
console.warn('slider-resident: 焼く先を作れないので、2D で描く');
return false;
}
needBake = true;
return true;
};
let ready = setupGl();
// 文脈が失われているだけなら、2D へ落とさず戻るのを待つ(スマホでは、タブを離れて戻ると起きる)
let lost = !ready && !!gl && gl.isContextLost();
let ctx2d = null;
let lowCanvas = null;
const useCanvas2d = () => {
// 1つの canvas に2種類の文脈は持てないので、作り直す
const fresh = canvas.cloneNode(false);
canvas.replaceWith(fresh);
canvas = fresh;
ctx2d = canvas.getContext('2d');
};
if (!ready && !lost) useCanvas2d();
// ---- 形と大きさ
const shape = { W: 1, H: 1, dpr: 1, pillW: 1, pillH: 1, bezel: 1, winW: 1, winH: 1, corner: 1, lensHX: 1, lensHY: 1, travel: 1, ox: 0, oy: 0 };
let renderScale = 1;
let fullK = 1; // 解像度を落としていないときの整数倍
// 整数倍を高さで決める。切れるのが 8% までなら大きい方。横は部屋が両わきへ広がる
const pickK = (winH) => {
const whole = Math.max(1, Math.floor(winH / CORE_H));
return ((whole + 1) * CORE_H) / winH <= 1.08 ? whole + 1 : whole;
};
const measure = () => {
const box = root.getBoundingClientRect();
const fullDpr = Math.min(2, devicePixelRatio || 1);
const nextFullK = pickK((Math.round(box.height * fullDpr) / 1.4) * (1 - 0.068 * 2));
if (nextFullK !== fullK) {
fullK = nextFullK;
renderScale = 1;
}
const dpr = fullDpr * renderScale;
const W = Math.max(2, Math.round(box.width * dpr));
const H = Math.max(2, Math.round(box.height * dpr));
const changed = W !== shape.W || H !== shape.H;
shape.W = W;
shape.H = H;
shape.dpr = dpr;
// 丸の大きさ:根の中に、1 : 3.2 で置く
shape.pillH = H / 1.4;
shape.pillW = shape.pillH * PILL_RATIO;
shape.bezel = shape.pillH * 0.068;
shape.winW = shape.pillW - shape.bezel * 2;
shape.winH = shape.pillH - shape.bezel * 2;
// 角丸のトークンは、小さな部品のための数なので、大きさに合わせて伸ばす
const pillCss = shape.pillH / dpr;
shape.corner = Math.min(shape.pillH / 2, cornerPx * dpr * Math.max(1, pillCss / 120));
// レンズ:縦長の丸。少しだけ丸の外へ張り出す。端では、丸の端と同じ中心で止まる
shape.lensHX = shape.pillH * 0.36;
shape.lensHY = shape.pillH * 0.52;
shape.travel = shape.pillW - shape.pillH;
// 画素の格子に合わせる(半端な位置だと、全体がにじむ)
canvas.style.width = `${W / dpr}px`;
canvas.style.height = `${H / dpr}px`;
// DPR が上限の 2 を超える端末では、canvas をブラウザが拡大する。なめらかに拡大すると、ドットがにじむ
const deviceDpr = devicePixelRatio || 1;
const enlarged = deviceDpr > dpr;
canvas.style.imageRendering = enlarged ? 'pixelated' : '';
const grid = enlarged ? deviceDpr : dpr;
const fx = (box.left * grid) % 1;
const fy = (box.top * grid) % 1;
canvas.style.transform = `translate(${-fx / grid}px, ${-fy / grid}px)`;
// 本物のスライダーを、レンズの動く範囲にぴったり重ねる
const thumb = (shape.lensHX * 2) / dpr;
const rangeW = shape.travel / dpr + thumb;
root.style.setProperty('--thumb', `${thumb}px`);
input.style.width = `${rangeW}px`;
input.style.height = `${pillCss}px`;
input.style.left = `${(box.width - rangeW) / 2}px`;
input.style.top = `${(box.height - pillCss) / 2}px`;
input.style.borderRadius = `${shape.corner / dpr}px`;
shade.style.width = `${shape.pillW / dpr}px`;
shade.style.height = `${pillCss}px`;
shade.style.left = `${(box.width - shape.pillW / dpr) / 2}px`;
shade.style.top = `${(box.height - pillCss) / 2}px`;
shade.style.borderRadius = `${shape.corner / dpr}px`;
if (!changed) return false;
canvas.width = W;
canvas.height = H;
// 解像度を落としても、部屋の見え方は変えない(renderScale は整数倍が割り切れる値だけを取る)
k = Math.max(1, Math.round(fullK * renderScale));
const needW = Math.ceil(shape.winW / k) + MARGIN * 2;
const needH = Math.ceil(shape.winH / k) + MARGIN * 2;
bw = needW + (needW & 1);
bh = needH + (needH & 1);
ox = (bw - CORE_W) >> 1;
oy = Math.round((bh - CORE_H) * 0.54);
const size = bw * bh;
matBuf = new Uint8Array(size);
baseBuf = new Uint8Array(size);
tintBuf = new Uint8Array(size);
pixels = new Uint32Array(size);
pixelBytes = new Uint8Array(pixels.buffer);
thrBuf = new Float32Array(size);
ceilMap = new Float32Array(size);
lampMap = new Float32Array(size);
moonMap = new Float32Array(size);
nextMap = new Float32Array(size);
gridOf = new Int32Array(size);
for (let y = 0; y < bh; y++) {
for (let wx = 0; wx < bw; wx++) thrBuf[y * bw + wx] = (bayerAt(wx, y) + 0.5) / 16;
}
// 世界の左上の、画面の画素での位置(必ず整数)
shape.ox = Math.floor(W / 2 - (bw * k) / 2);
shape.oy = Math.floor(H / 2 + (bh * k) / 2);
GRID_H = Math.max(8, Math.round((GRID_W * H) / W));
const cells = GRID_W * GRID_H * 4;
glowRaw = new Float32Array(cells);
glowNearBuf = new Float32Array(cells);
glowFarBuf = new Float32Array(cells);
glowTemp = new Float32Array(cells);
const cellW = W / GRID_W;
const cellH = H / GRID_H;
emitScale = ((k * k) / (cellW * cellH)) * (2.1 / 255);
for (let y = 0; y < bh; y++) {
for (let wx = 0; wx < bw; wx++) {
const sx = shape.ox + (wx + 0.5) * k;
const sy = H - shape.oy + (y + 0.5) * k;
const gx = clamp(Math.floor(sx / cellW), 0, GRID_W - 1);
const gy = clamp(Math.floor(sy / cellH), 0, GRID_H - 1);
gridOf[y * bw + wx] = (gy * GRID_W + gx) * 4;
}
}
buildBase();
if (ready) {
gl.viewport(0, 0, W, H);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texWorld);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, bw, bh, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, texGlow);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, GRID_W, GRID_H, 0, gl.RGBA, gl.FLOAT, null);
for (const [unit, tex] of [[gl.TEXTURE2, texBase], [gl.TEXTURE3, texWeight]]) {
gl.activeTexture(unit);
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, W, H, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
}
needBake = true;
} else {
lowCanvas = offscreen(bw, bh);
}
return true;
};
const uploadWorld = () => {
glowNearBuf.set(glowRaw);
boxBlur(glowNearBuf, glowTemp, GRID_W, GRID_H, 1);
boxBlur(glowNearBuf, glowTemp, GRID_W, GRID_H, 1);
glowFarBuf.set(glowRaw);
boxBlur(glowFarBuf, glowTemp, GRID_W, GRID_H, 4);
boxBlur(glowFarBuf, glowTemp, GRID_W, GRID_H, 4);
for (let i = 0; i < glowNearBuf.length; i++) glowNearBuf[i] += glowFarBuf[i] * 0.6;
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texWorld);
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, bw, bh, gl.RGBA, gl.UNSIGNED_BYTE, pixelBytes);
gl.generateMipmap(gl.TEXTURE_2D);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, texGlow);
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, GRID_W, GRID_H, gl.RGBA, gl.FLOAT, glowNearBuf);
};
// ---- 描く
const lensShape = () => {
const s = clamp(stretch, -0.5, 1.2);
const grow = 1 + 0.03 * lift;
const hx = shape.lensHX * grow * (1 + 0.26 * s);
const hy = shape.lensHY * grow * (1 - 0.09 * s);
return { hx, hy, cx: (x - 0.5) * shape.travel };
};
// 動かない絵を焼く。形か色が変わったときだけ
const bake = () => {
gl.bindFramebuffer(gl.FRAMEBUFFER, bakeTarget);
gl.useProgram(bakeProgram);
gl.uniform2f(bakeLoc.uRes, shape.W, shape.H);
gl.uniform2f(bakeLoc.uPill, shape.pillW / 2, shape.pillH / 2);
gl.uniform1f(bakeLoc.uBezel, shape.bezel);
gl.uniform1f(bakeLoc.uCorner, shape.corner);
gl.uniform3fv(bakeLoc.uAlbedo, body.albedo);
gl.uniform3fv(bakeLoc.uEnvHi, body.hi);
gl.uniform3fv(bakeLoc.uEnvLo, body.lo);
gl.uniform3fv(bakeLoc.uInk, unit(pal(RK, 0)));
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
needBake = false;
};
const drawGl = (time) => {
gl.bindVertexArray(vao);
if (needBake) bake();
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(program);
gl.uniform2f(loc.uRes, shape.W, shape.H);
gl.uniform2f(loc.uPill, shape.pillW / 2, shape.pillH / 2);
gl.uniform2f(loc.uOrigin, shape.ox, shape.oy);
gl.uniform2f(loc.uWorldSize, bw, bh);
gl.uniform2f(loc.uGlowSize, GRID_W, GRID_H);
gl.uniform2f(loc.uShift, shiftX, shiftY);
gl.uniform1f(loc.uK, k);
gl.uniform1f(loc.uLift, lift);
gl.uniform1f(loc.uWobble, calm ? 0 : wobble);
gl.uniform1f(loc.uTime, time % 1000);
const lens = lensShape();
gl.uniform4f(loc.uLens, lens.cx, 0, lens.hx, lens.hy);
gl.uniform1f(loc.uLensR, Math.min(lens.hx, lens.hy, Math.max(shape.corner, lens.hx * 0.5)));
gl.uniform3fv(loc.uAlbedo, body.albedo);
gl.uniform3fv(loc.uEnvHi, body.hi);
gl.uniform3fv(loc.uEnvLo, body.lo);
gl.uniform3fv(loc.uGround, body.ground);
gl.uniform3fv(loc.uInk, unit(pal(RK, 0)));
gl.drawArrays(gl.TRIANGLES, 0, 3);
};
// 0〜1 の色を、canvas の色の文字にする
const cssColor = (c, a = 1) => {
const [r, g, b] = c.map((v) => Math.round(v * 255));
return `rgb(${r} ${g} ${b} / ${a})`;
};
const draw2d = () => {
// WebGL が使えないとき:磨かれた面と窓を描き、レンズは部屋を拡大して見せる。色を塗り重ねて膜にしない
const g = ctx2d;
const low = lowCanvas.getContext('2d');
low.putImageData(new ImageData(new Uint8ClampedArray(pixels.buffer), bw, bh), 0, 0);
g.clearRect(0, 0, shape.W, shape.H);
const px = (shape.W - shape.pillW) / 2;
const py = (shape.H - shape.pillH) / 2;
const ink = unit(pal(RK, 0));
const paintPill = () => {
const face = g.createLinearGradient(0, py, 0, py + shape.pillH);
face.addColorStop(0, cssColor(body.hi));
face.addColorStop(0.35, cssColor(body.albedo));
face.addColorStop(1, cssColor(body.lo));
g.fillStyle = face;
g.beginPath();
g.roundRect(px, py, shape.pillW, shape.pillH, shape.corner);
g.fill();
g.save();
g.beginPath();
g.roundRect(px + shape.bezel, py + shape.bezel, shape.winW, shape.winH, Math.max(shape.corner - shape.bezel, 1));
g.clip();
g.imageSmoothingEnabled = false;
g.drawImage(lowCanvas, shape.ox + shiftX * k, shape.H - shape.oy + shiftY * k, bw * k, bh * k);
g.restore();
};
paintPill();
const lens = lensShape();
const cx = shape.W / 2 + lens.cx;
const cy = shape.H / 2;
const lensPath = () => {
g.beginPath();
g.roundRect(cx - lens.hx, cy - lens.hy, lens.hx * 2, lens.hy * 2, Math.min(lens.hx, lens.hy));
};
// レンズの落とす影
g.save();
g.shadowColor = cssColor(ink, 0.4);
g.shadowBlur = shape.pillH * 0.14;
g.shadowOffsetY = shape.pillH * (0.04 + 0.03 * lift);
g.fillStyle = cssColor(body.ground);
lensPath();
g.fill();
g.restore();
// レンズの中:同じ絵を少し大きくして見せる。外へ張り出した分には地が見える
g.save();
lensPath();
g.clip();
g.fillStyle = cssColor(body.ground);
g.fillRect(cx - lens.hx, cy - lens.hy, lens.hx * 2, lens.hy * 2);
g.translate(cx, cy);
g.scale(1.24, 1.24);
g.translate(-cx, -cy);
paintPill();
g.restore();
// ガラスの縁:上は明るく、下は暗く、細い線だけで形を見せる
const rim = g.createLinearGradient(0, cy - lens.hy, 0, cy + lens.hy);
rim.addColorStop(0, cssColor(body.hi, 0.95));
rim.addColorStop(0.5, cssColor(ink, 0.25));
rim.addColorStop(1, cssColor(ink, 0.5));
g.strokeStyle = rim;
g.lineWidth = Math.max(1, shape.pillH * 0.012);
lensPath();
g.stroke();
};
// ---- 時間を進める
let raf = 0;
let last = 0;
let visible = true;
let pageVisible = document.visibilityState !== 'hidden';
let worldAcc = 1;
let dirty = true;
let worldDirty = true;
let slow = 0;
let good = 0;
let frames = 0;
// 描くのに本当に時間がかかっているときだけ、細かさを落とす。
// コマが少ないだけでは決めない。省電力モードやブラウザの間引きで 30 コマになっている端末では、解像度を下げても速くならず、絵だけが悪くなる。
const OVERLOAD_MS = 12;
const COST_SAMPLES = 5;
const onePixel = new Uint8Array(4);
let costLeft = 0;
let costs = [];
let weighedOnce = false;
const lowerResolution = () => {
// 整数倍が割り切れる値にだけ下げる。部屋の見え方は変わらない
const next = (k - 1) / fullK;
if (k < 2 || next < 0.5) return;
renderScale = next;
measure();
dirty = true;
worldDirty = true;
frames = 0;
};
const adapt = (dt) => {
++frames;
// 重い GPU でも、部屋だけが動く 30 コマなら遅れが見えないことがある。つまみを動かす前に、一度は測っておく
if (frames === 40 && !weighedOnce) {
weighedOnce = true;
costLeft = COST_SAMPLES;
costs = [];
}
if (frames < 40) return;
if (dt > 0.0225) slow++;
else good++;
if (slow + good < 50) return;
const suspect = slow > 30;
slow = 0;
good = 0;
if (suspect && ready) {
costLeft = COST_SAMPLES;
costs = [];
}
};
const weighCost = (startedAt) => {
// readPixels は、ここまでの描画が終わるのを待つ。これで GPU の分も含めた1コマの重さがわかる
gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, onePixel);
costs.push(performance.now() - startedAt);
if (--costLeft > 0) return;
const median = costs.sort((a, b) => a - b)[costs.length >> 1];
if (median > OVERLOAD_MS) lowerResolution();
else frames = -600; // 重くないなら、しばらく測らない
};
const tick = (ms) => {
raf = 0;
if (lost) return;
const t = ms / 1000;
const dt = last ? Math.min(0.05, t - last) : 1 / 60;
last = t;
if (!calm && ready) adapt(dt);
// ---- つまみ:なめらかに追いつく
const target = value / 100;
if (calm) {
if (x !== target) {
x = target;
worldDirty = true;
dirty = true;
}
velocity = 0;
stretch = 0;
stretchV = 0;
lift = liftTarget;
} else {
// 臨界減衰のばねを、式のまま解く。コマが落ちても発散しない
const omega = 24 * motionScale;
const decay = Math.exp(-omega * dt);
const away = x - target;
const carry = (velocity + omega * away) * dt;
x = clamp(target + (away + carry) * decay);
velocity = (velocity - omega * carry) * decay;
if (Math.abs(target - x) < 0.0002 && Math.abs(velocity) < 0.002) {
x = target;
velocity = 0;
}
// レンズの弾み
const goal = clamp(Math.abs(velocity) * 0.5);
const springDt = Math.min(dt, 1 / 40);
stretchV += (190 * (goal - stretch) - 12 * stretchV) * springDt;
stretch += stretchV * springDt;
lift += (liftTarget - lift) * (1 - Math.exp(-dt * 14));
wobble = Math.max(wobble * Math.exp(-dt * 3.4), clamp(Math.abs(velocity) * 0.9));
// レンズが止まっているコマは描き直さない。絵が変わるのは、部屋が動くときだけ
if (x !== target || velocity !== 0 || Math.abs(stretch) > 0.001 || Math.abs(stretchV) > 0.01 || Math.abs(lift - liftTarget) > 0.001 || wobble > 0.02) dirty = true;
}
// ---- 世界は 30 コマで動く。1/30 秒にわずかに届かないだけで次の更新まで待つと、60Hz の画面で 20 コマに落ちる
worldAcc += dt;
const worldStep = calm ? worldDirty : worldAcc >= 1 / WORLD_HZ - WORLD_SLACK;
if (worldStep) {
stepWorld(Math.min(worldAcc, 0.1));
renderWorld();
worldAcc = 0;
worldDirty = false;
if (ready) uploadWorld();
dirty = true;
}
if (dirty) {
const weighing = costLeft > 0 && ready;
const startedAt = weighing ? performance.now() : 0;
if (ready) drawGl(t);
else if (ctx2d) draw2d();
dirty = false;
if (weighing) weighCost(startedAt);
}
// 動きを減らす設定で止まっているときは、次の操作まで描かない
if (visible && pageVisible && !calm) raf = requestAnimationFrame(tick);
};
function wake() {
if (!raf && visible && pageVisible && !lost) {
last = 0;
raf = requestAnimationFrame(tick);
}
}
// ---- 入力は本物のスライダーに任せる
const syncValue = () => {
value = clamp(Math.round(Number(input.value) || 0), 0, 100);
input.setAttribute('aria-valuetext', describe(value));
worldDirty = true;
dirty = true;
wake();
};
on(input, 'input', syncValue);
on(input, 'change', syncValue);
on(input, 'pointerdown', () => {
liftTarget = 1;
dirty = true;
wake();
});
const release = () => {
liftTarget = 0;
dirty = true;
wake();
};
on(input, 'pointerup', release);
on(input, 'pointercancel', release);
on(input, 'lostpointercapture', release);
on(input, 'blur', release);
// ---- 見えていないときは止める
const stopDrawing = () => {
if (raf) cancelAnimationFrame(raf);
raf = 0;
};
const io = new IntersectionObserver((entries) => {
visible = entries.some((e) => e.isIntersecting);
if (visible) {
dirty = true;
worldDirty = true;
wake();
} else stopDrawing();
});
io.observe(root);
on(document, 'visibilitychange', () => {
pageVisible = document.visibilityState !== 'hidden';
if (pageVisible) {
if (lost) waitForRestore();
dirty = true;
worldDirty = true;
wake();
} else stopDrawing();
});
const ro = new ResizeObserver(() => {
measure();
dirty = true;
worldDirty = true;
wake();
});
ro.observe(root);
on(motionQuery, 'change', () => {
calm = motionQuery.matches;
dirty = true;
worldDirty = true;
wake();
});
on(schemeQuery, 'change', () => {
readTokens();
measure();
dirty = true;
worldDirty = true;
wake();
});
// ---- WebGL の文脈が失われたら、戻るのを待って描き直す。戻らなければ 2D で描く
const RESTORE_WAIT_MS = 3000;
let restoreTimer = 0;
const redrawAll = () => {
shape.W = 0; // 大きさが同じでも、画像を作り直す
measure();
dirty = true;
worldDirty = true;
wake();
};
function waitForRestore() {
clearTimeout(restoreTimer);
restoreTimer = setTimeout(() => {
if (!lost || !pageVisible) return;
lost = false;
useCanvas2d();
redrawAll();
}, RESTORE_WAIT_MS);
}
on(canvas, 'webglcontextlost', (event) => {
event.preventDefault();
ready = false;
lost = true;
costLeft = 0;
stopDrawing();
if (pageVisible) waitForRestore();
});
on(canvas, 'webglcontextrestored', () => {
clearTimeout(restoreTimer);
lost = false;
ready = setupGl();
if (!ready) useCanvas2d();
redrawAll();
});
// ---- 組み立て
readTokens();
measure();
if (lost) waitForRestore();
input.setAttribute('aria-valuetext', describe(value));
// はじめの様子:もう少し前から時間が流れていたことにする
actor.mode = wantMode();
actor.x = seatedMode(actor.mode) ? SOFA_X : DANCE_X;
if (x >= 0.86) nb.light = 1;
lampNow = value > 0 ? 1 : 0;
party = smoothstep(0.62, 0.72, x);
ballDrop = party > 0.02 ? 1 : 0;
for (let i = 0; i < 90; i++) stepWorld(1 / 30);
renderWorld();
if (ready) uploadWorld();
wake();
return () => {
stopDrawing();
clearTimeout(restoreTimer);
visible = false;
io.disconnect();
ro.disconnect();
for (const off of cleanups) off();
if (gl && ready) {
gl.deleteProgram(program);
gl.deleteProgram(bakeProgram);
gl.deleteFramebuffer(bakeTarget);
gl.deleteTexture(texBase);
gl.deleteTexture(texWeight);
gl.deleteBuffer(quad);
gl.deleteVertexArray(vao);
gl.deleteTexture(texWorld);
gl.deleteTexture(texGlow);
const lose = gl.getExtension('WEBGL_lose_context');
if (lose) lose.loseContext();
}
};
}
mount(document.querySelector('[data-ld-part="slider-resident"]'));
</script>
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node