Drink Lab
Мини-игра «собери напиток»: стакан в центре, кнопки ингредиентов, жидкость льётся сверху, лёд ПАДАЕТ кубиками и плещет, горячее парит. Слои складываются в порядке налива, как их и строит бариста: новый слой ложится сверху и растёт с первой капли, поэтому цвет в стакане и в струе верен с самого начала, а стык слоёв мягкий, а не наклеенный. Физика настоящая там, где её видно: поверхность — пружина с затуханием, поэтому второй удар складывается с первым; брызги летят по параболе и, упав, добавляют новую волну; кубики льда падают под тяжестью, гасят скорость об поверхность, всплывают и качаются, а в горячем напитке таят и сами остужают его, так что пар слабеет. Стекло преломляет задник и рисуется двумя проходами: одним проходом с двусторонними гранями половины стакана смешиваются в порядке треугольников и по центру видна жёсткая линия. Задание сверяется по порядку добавления, «слить» опустошает стакан, «другой рецепт» меняет задание, а собранный рецепт получает награду — латте-арт на пене.
Установка
npx shadcn@latest add https://motiva.pages.dev/r/drink-lab.jsonзависимости: three@^0.185.1 · @types/three@^0.185.0
Что нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.json.Приехавший рядом
.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"use client";
const css = `.dkl { position: absolute; inset: 0; overflow: hidden;
font-family: var(--ds-font, system-ui, sans-serif);
color: var(--ds-fg, oklch(0.95 0.01 265)); }
.dkl-canvas { display: block; width: 100%; height: 100%; }
/* Панель лежит ПОВЕРХ сцены и не ловит события мимо кнопок: иначе курсор
перестаёт крутить сцену на всей площади панели. */
.dkl-ui { position: absolute; inset: 0; pointer-events: none;
display: grid; grid-template-rows: auto 1fr auto; padding: var(--ds-4, 18px); gap: var(--ds-3, 12px); }
.dkl-task { justify-self: center; text-align: center; display: grid; gap: 4px; }
.dkl-task b { font-size: var(--ds-text-sm, 0.92rem); font-weight: 650; letter-spacing: -0.01em;
text-shadow: 0 1px 10px oklch(0.08 0.01 260 / 0.85); }
.dkl-task span { font-family: var(--ds-font-mono, ui-monospace, monospace);
font-size: var(--ds-text-xs, 0.72rem); letter-spacing: 0.12em; text-transform: uppercase;
color: var(--ds-muted, oklch(0.74 0.02 265)); }
.dkl-task[data-state="done"] b { color: oklch(0.82 0.17 150); }
.dkl-task[data-state="wrong"] b { color: oklch(0.8 0.13 60); }
/* след из налитого: читается как чек на стойке */
.dkl-trace { justify-self: center; align-self: end; display: flex; gap: 6px; flex-wrap: wrap; }
.dkl-chip { font-family: var(--ds-font-mono, ui-monospace, monospace);
font-size: var(--ds-text-xs, 0.7rem); padding: 3px 8px; border-radius: var(--ds-radius-pill, 999px);
background: oklch(0.22 0.02 265 / 0.6); border: 1px solid oklch(0.5 0.02 265 / 0.35); }
.dkl-chip[data-ice="1"] { color: oklch(0.88 0.07 230); border-color: oklch(0.6 0.09 230 / 0.5); }
.dkl-bar { display: flex; gap: var(--ds-2, 8px); flex-wrap: wrap;
justify-content: center; align-items: center; }
.dkl-sep { width: 1px; height: 20px; background: oklch(0.6 0.02 265 / 0.3); }
.dkl-btn { pointer-events: auto; cursor: pointer; position: relative;
padding: 9px 15px; border-radius: var(--ds-radius-pill, 999px);
font: 600 var(--ds-text-sm, 0.86rem) / 1 var(--ds-font, system-ui, sans-serif);
color: var(--ds-fg, oklch(0.95 0.01 265));
background: oklch(0.28 0.02 265 / 0.85);
border: 1px solid oklch(0.62 0.02 265 / 0.45);
backdrop-filter: blur(6px);
transition: background var(--ds-fast, 0.18s), border-color var(--ds-fast, 0.18s),
translate var(--ds-fast, 0.18s); }
.dkl-btn i { display: inline-block; width: 9px; height: 9px; border-radius: 50%;
margin-inline-end: 7px; vertical-align: -1px;
background: var(--tint); box-shadow: 0 0 0 1px oklch(1 0 0 / 0.25) inset; }
.dkl-btn:hover { background: oklch(0.34 0.03 265 / 0.92); border-color: var(--tint, oklch(0.7 0.02 265));
translate: 0 -1px; }
.dkl-btn:active { translate: 0 1px; }
.dkl-btn:disabled { pointer-events: none; opacity: 0.38; }
.dkl-btn:focus-visible { outline: 2px solid var(--ds-accent-ring, oklch(0.72 0.16 300 / 0.5)); outline-offset: 2px; }
.dkl-ghost { font-family: var(--ds-font-mono, ui-monospace, monospace);
font-size: var(--ds-text-xs, 0.72rem); letter-spacing: 0.1em; text-transform: uppercase;
background: oklch(0.2 0.02 265 / 0.55); border-style: dashed; }
@media (prefers-reduced-motion: reduce) {
.dkl-btn { transition: none; translate: none; }
.dkl-btn:hover, .dkl-btn:active { translate: none; }
}
`;
import { useCallback, useEffect, useRef, useState } from "react";
import {
createDrinkLab,
INGREDIENTS,
type DrinkLab as Engine,
type DrinkLabOptions,
type DrinkLabState,
type IngredientId,
} from "@/lib/drink-lab-engine";
export type DrinkLabProps = Omit<DrinkLabOptions, "animate"> & {
/** Рецепты мини-игры. Первый берётся первым, дальше по кругу. */
recipes?: { name: string; steps: IngredientId[] }[];
};
const RECIPES: { name: string; steps: IngredientId[] }[] = [
{ name: "Латте", steps: ["espresso", "milk", "foam"] },
{ name: "Мокко", steps: ["chocolate", "espresso", "milk"] },
{ name: "Карамельный маккиато", steps: ["caramel", "milk", "espresso"] },
{ name: "Айс-латте", steps: ["ice", "milk", "espresso"] },
];
const rgb = (c: [number, number, number]) =>
`rgb(${Math.round(c[0] * 255)} ${Math.round(c[1] * 255)} ${Math.round(c[2] * 255)})`;
/**
* Drink Lab — мини-игра «собери напиток»: стакан в центре, кнопки ингредиентов,
* жидкость льётся сверху и набирается слоями, лёд падает кубиками, горячее парит.
*
* Сцена — `createDrinkLab` из соседнего файла: она держит стекло, слои, струю,
* пар, лёд, брызги и пружину поверхности, а компонент — задание, кнопки и сверку.
* Сцена НЕ играет сама: при старте в стакане уже стоит напиток (`prefill`),
* поэтому в кадре есть пар и живая поверхность, но ничего не происходит без нажатия.
* `prefers-reduced-motion` и `data-shot` рисуют один стоячий кадр.
*/
export function DrinkLab({ recipes = RECIPES, ...options }: DrinkLabProps = {}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const engine = useRef<Engine | null>(null);
const first = useRef(options);
const [state, setState] = useState<DrinkLabState>({ layers: [], level: 0, pouring: false, heat: 0, ice: 0 });
const [task, setTask] = useState(0);
const recipe = recipes[task % recipes.length]!;
// сверка идёт ПО ПОРЯДКУ: слоёный напиток тем и отличается, что порядок важен
const matched = recipe.steps.every((s, i) => state.layers[i] === s) && state.layers.length >= recipe.steps.length;
const wrong = state.layers.some((l, i) => recipe.steps[i] !== undefined && recipe.steps[i] !== l);
const melting = state.ice > 0 && state.heat > 0.15;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const animate = !(
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
const eng = createDrinkLab(canvas, { ...first.current, animate });
eng.onChange(setState);
engine.current = eng;
return () => {
eng.destroy();
engine.current = null;
};
}, []);
const { glass, speed, steam, parallax, refraction, tone, zoom } = options;
useEffect(() => {
engine.current?.set({ glass, speed, steam, parallax, refraction, tone, zoom });
}, [glass, speed, steam, parallax, refraction, tone, zoom]);
// собранный рецепт получает награду: на пене проявляется латте-арт
useEffect(() => {
engine.current?.set({ art: matched ? 1 : 0 });
}, [matched]);
const pour = useCallback((id: IngredientId) => engine.current?.pour(id), []);
/** Слить: стакан пустеет, задание остаётся тем же. */
const drain = useCallback(() => engine.current?.reset(), []);
/** Другой рецепт: стакан пустеет и задание меняется. */
const nextTask = useCallback(() => {
engine.current?.reset();
setTask((t) => t + 1);
}, []);
const hint = matched
? `${recipe.name} готов`
: wrong
? `${recipe.name}: слой не по порядку — слей и начни заново`
: `${recipe.name}: ${recipe.steps.map((s) => INGREDIENTS.find((i) => i.id === s)?.label).join(" → ")}`;
return (
<div className="dkl">
<style href="motiva-drink-lab" precedence="medium">{css}</style>
<canvas ref={canvasRef} className="dkl-canvas" aria-hidden="true" />
<div className="dkl-ui">
<div className="dkl-task" data-state={matched ? "done" : wrong ? "wrong" : "todo"}>
<span>задание</span>
<b>{hint}</b>
</div>
<div className="dkl-trace">
{state.layers.map((l, i) => (
<span className="dkl-chip" key={`${l}-${i}`}>
{INGREDIENTS.find((x) => x.id === l)?.label}
</span>
))}
{state.ice > 0 && (
<span className="dkl-chip" data-ice="1">
лёд ×{state.ice}
{melting ? " · таёт" : ""}
</span>
)}
</div>
<div className="dkl-bar">
{INGREDIENTS.map((ing) => (
<button
key={ing.id}
type="button"
className="dkl-btn"
style={{ ["--tint" as string]: rgb(ing.color) }}
disabled={state.pouring || state.level > 0.985}
onClick={() => pour(ing.id)}
>
<i />
{ing.label}
</button>
))}
<span className="dkl-sep" aria-hidden="true" />
<button type="button" className="dkl-btn dkl-ghost" onClick={drain} disabled={state.level < 0.005}>
слить
</button>
<button type="button" className="dkl-btn dkl-ghost" onClick={nextTask}>
другой рецепт
</button>
</div>
</div>
</div>
);
}
import * as THREE from "three";
/**
* Drink Lab — движок на голом three.js (без React). Стакан в центре, сверху льётся
* жидкость, напиток набирается слоями, лёд ПАДАЕТ кубиками и плещет, горячее парит.
*
* Что здесь считается, а не рисуется:
* • слои — массив границ в шейдере жидкости, поэтому эспрессо под молоком виден
* полосой ровно на своей высоте, а на стыках слои размыты, как в стакане;
* • поверхность — ПРУЖИНА с затуханием: удар струи или кубика даёт ей импульс,
* и она качается и наклоняется, пока не успокоится; это не анимация «по кривой»,
* а честный осциллятор, поэтому второй удар складывается с первым;
* • лёд падает под тяжестью, гасит скорость об поверхность, всплывает и качается;
* в горячем напитке он ТАЕТ и заодно остужает напиток, поэтому пар слабеет;
* • брызги — частицы с тяжестью: вылетают из места удара, летят по параболе
* и, упав, добавляют поверхности новую волну;
* • стекло преломляет задник и рисуется ДВУМЯ проходами (сначала дальние грани,
* потом ближние): одним проходом с двусторонними гранями половины стакана
* смешиваются в порядке треугольников, и по центру видна жёсткая линия.
*
* `createDrinkLab(canvas, options)` → { pour, reset, set, onChange, destroy }.
* Цикл засыпает вне зоны видимости и на скрытой вкладке; `animate: false`
* (reduced-motion / `data-shot`) рисует один стоячий кадр с готовым напитком.
*/
export type IngredientId = "espresso" | "milk" | "foam" | "caramel" | "chocolate" | "ice";
export interface Ingredient {
id: IngredientId;
label: string;
/** Цвет слоя в экранных величинах. */
color: [number, number, number];
/** Сколько слой добавляет к уровню, в долях высоты стакана. */
volume: number;
/** Горячий ингредиент даёт пар. */
hot: boolean;
/** Пенный слой: сверху получает купол и пузыри. */
foamy?: boolean;
/** Лёд не льётся, а падает кубиками. */
solid?: boolean;
}
export const INGREDIENTS: Ingredient[] = [
{ id: "espresso", label: "эспрессо", color: [0.15, 0.065, 0.035], volume: 0.17, hot: true },
{ id: "milk", label: "молоко", color: [0.93, 0.9, 0.84], volume: 0.24, hot: true },
{ id: "foam", label: "пена", color: [0.98, 0.96, 0.91], volume: 0.12, hot: false, foamy: true },
{ id: "caramel", label: "карамель", color: [0.74, 0.47, 0.17], volume: 0.09, hot: false },
{ id: "chocolate", label: "шоколад", color: [0.29, 0.135, 0.075], volume: 0.11, hot: false },
{ id: "ice", label: "лёд", color: [0.74, 0.86, 0.95], volume: 0.05, hot: false, solid: true },
];
export interface DrinkLabOptions {
/** Форма посуды: `latte` · `tumbler` · `espresso`. */
glass?: "latte" | "tumbler" | "espresso";
/** Что уже стоит в стакане при старте. Пустой массив — чистый стакан. */
prefill?: IngredientId[];
/** Скорость налива. */
speed?: number;
/** Густота пара. */
steam?: number;
/** Масштаб сцены: 1 — как задумано, больше — стакан крупнее. */
zoom?: number;
/** Латте-арт на пене: 0 — нет, 1 — нарисован. Ставится, когда рецепт собран. */
art?: number;
/** Отклик сцены на курсор. */
parallax?: number;
/** Преломление в стекле. */
refraction?: number;
/** Тон стола. */
tone?: number;
/** false — один стоячий кадр (reduced-motion, скриншоты). */
animate?: boolean;
}
export interface DrinkLabState {
layers: IngredientId[];
/** Уровень в долях высоты стакана. */
level: number;
/** Идёт ли налив или падение льда. */
pouring: boolean;
/** Жар напитка, 0…1 — по нему видно, будет ли таять лёд. */
heat: number;
/** Сколько кубиков льда в стакане. */
ice: number;
}
export interface DrinkLab {
/** Начать налив (или бросить лёд). false — стакан полон либо занят. */
pour(id: IngredientId): boolean;
reset(): void;
set(next: Partial<DrinkLabOptions>): void;
onChange(cb: (s: DrinkLabState) => void): void;
destroy(): void;
}
const DEFAULTS = {
glass: "latte" as "latte" | "tumbler" | "espresso",
prefill: ["espresso", "milk", "foam"] as IngredientId[],
speed: 1,
steam: 0.9,
zoom: 1,
art: 0,
parallax: 0.6,
refraction: 0.7,
tone: 0.55,
animate: true,
};
type Config = typeof DEFAULTS;
/** Пропорции посуды: радиус верха, радиус низа, высота. */
const SHAPES: Record<string, { rt: number; rb: number; h: number }> = {
latte: { rt: 0.62, rb: 0.5, h: 1.75 },
tumbler: { rt: 0.74, rb: 0.7, h: 1.35 },
espresso: { rt: 0.52, rb: 0.36, h: 0.95 },
};
/** Максимум слоёв: массив границ в шейдере фиксированной длины. */
const MAX_LAYERS = 8;
/** Кубиков льда одновременно. */
const MAX_ICE = 6;
/** Брызг одновременно. */
const MAX_DROPS = 64;
/** Тяжесть в мире сцены (стакан высотой около 1.75). */
const G = 5.4;
const BACK_VERT = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position.xy, 0.0, 1.0);
}
`;
/** Задник: стол и мягкий свет сверху. Он же — то, что преломляет стекло. */
const BACK_FRAG = /* glsl */ `
precision highp float;
uniform vec2 uRes;
uniform float uTone;
varying vec2 vUv;
void main() {
float aspect = uRes.x / uRes.y;
vec2 p = vec2((vUv.x - 0.5) * aspect, vUv.y - 0.5);
float floorY = -0.1;
float onFloor = smoothstep(floorY + 0.012, floorY - 0.012, p.y);
vec3 wallTop = mix(vec3(0.1, 0.11, 0.14), vec3(0.16, 0.14, 0.13), uTone);
vec3 wallBot = mix(vec3(0.04, 0.045, 0.06), vec3(0.07, 0.06, 0.055), uTone);
vec3 wall = mix(wallBot, wallTop, smoothstep(-0.12, 0.55, p.y));
wall += vec3(0.1, 0.1, 0.12) * exp(-dot(p - vec2(0.0, 0.24), p - vec2(0.0, 0.24)) * 5.0);
vec3 deskA = mix(vec3(0.08, 0.08, 0.1), vec3(0.13, 0.1, 0.085), uTone);
vec3 desk = deskA * (1.0 - smoothstep(floorY, floorY - 0.42, p.y) * 0.55);
desk += vec3(0.12, 0.12, 0.14) * exp(-abs(p.y - floorY) * 14.0) * exp(-p.x * p.x * 3.0);
vec3 col = mix(wall, desk, onFloor);
col *= 1.0 - smoothstep(0.35, 1.1, length(p)) * 0.45;
gl_FragColor = vec4(col, 1.0);
}
`;
const SOLID_VERT = /* glsl */ `
varying vec3 vNormal;
varying vec3 vView;
varying vec3 vLocal;
void main() {
vLocal = position;
vNormal = normalize(mat3(modelMatrix) * normal);
vec4 world = modelMatrix * vec4(position, 1.0);
vView = normalize(cameraPosition - world.xyz);
gl_Position = projectionMatrix * viewMatrix * world;
}
`;
/**
* Стекло. Преломление берётся из буфера задника со смещением по нормали, толщина
* стенки читается по френелю: у кромки луч идёт сквозь больше стекла, поэтому там
* и сдвиг сильнее, и кант плотнее. `uFacing` различает проходы: у дальней стенки
* блики слабее, иначе через стакан светят две пары одинаковых полос.
*/
const GLASS_FRAG = /* glsl */ `
precision highp float;
uniform sampler2D uBack;
uniform vec2 uRes;
uniform float uRefraction;
uniform float uFacing;
varying vec3 vNormal;
varying vec3 vView;
varying vec3 vLocal;
void main() {
vec3 n = normalize(vNormal) * uFacing;
vec3 v = normalize(vView);
float facing = max(0.02, dot(n, v));
float fres = pow(1.0 - facing, 3.0);
vec2 suv = gl_FragCoord.xy / uRes;
vec2 off = n.xy * (0.01 + fres * 0.055) * uRefraction;
vec3 back = texture2D(uBack, clamp(suv + off, 0.002, 0.998)).rgb;
vec3 key = normalize(vec3(-0.55, 0.7, 0.45));
float spec = pow(max(0.0, dot(reflect(-key, n), v)), 70.0);
float edgeBand = pow(1.0 - abs(n.z), 7.0) * (0.35 + 0.65 * smoothstep(-0.4, 0.9, vLocal.y));
float near = uFacing > 0.0 ? 1.0 : 0.45; // дальняя стенка глуше
vec3 col = back * (0.84 + fres * 0.08);
col += vec3(0.7, 0.78, 0.9) * fres * 0.3 * near;
col += vec3(1.0) * spec * 0.85 * near;
col += vec3(0.85, 0.9, 1.0) * edgeBand * 0.2 * near;
float a = clamp(0.12 + fres * 0.7 + spec + edgeBand * 0.25, 0.0, 1.0) * (uFacing > 0.0 ? 1.0 : 0.7);
gl_FragColor = vec4(col * a, a);
}
`;
/**
* Жидкость. Цвет берётся из МАССИВА ГРАНИЦ: идём по слоям снизу вверх и подмешиваем
* цвет там, где точка выше границы. На стыках подмешивание размыто — именно это
* читается настоящими слоями, а резкая линия выглядит наклейкой.
*/
const LIQUID_FRAG = /* glsl */ `
precision highp float;
uniform vec3 uColors[${MAX_LAYERS}];
uniform float uTops[${MAX_LAYERS}];
uniform float uBlurs[${MAX_LAYERS}];
uniform int uCount;
uniform vec3 uLight;
varying vec3 vNormal;
varying vec3 vView;
varying vec3 vLocal;
void main() {
// локальная Y единичного цилиндра идёт от -0.5 до +0.5, границы слоёв — в 0…1
float y = clamp(vLocal.y + 0.5, 0.0, 1.0);
vec3 col = uColors[0];
for (int i = 1; i < ${MAX_LAYERS}; i++) {
if (i >= uCount) break;
// Размытие стыка СВОЁ у каждой границы: там, где одна жидкость проходила
// сквозь другую, переход широкий — это и есть след перемешивания.
float b = max(0.004, uBlurs[i - 1]);
col = mix(col, uColors[i], smoothstep(uTops[i - 1] - b, uTops[i - 1] + b, y));
}
vec3 n = normalize(vNormal);
vec3 v = normalize(vView);
float key = max(0.0, dot(n, normalize(uLight)));
float fres = pow(1.0 - max(0.02, dot(n, v)), 3.0);
col *= 0.74 + 0.4 * y;
col *= 0.86 + key * 0.32;
col += col * fres * 0.45;
// самый верх стенки подсвечен: так стенка и диск сходятся без ступеньки
col *= 1.0 + smoothstep(0.94, 1.0, y) * 0.22;
// светлые слои уходили за единицу и становились плоским белым
col = vec3(1.0) - exp(-col * 1.35);
gl_FragColor = vec4(col, 1.0);
}
`;
const SURFACE_VERT = /* glsl */ `
varying vec2 vUv;
varying vec3 vView;
void main() {
vUv = uv;
vec4 world = modelMatrix * vec4(position, 1.0);
vView = normalize(cameraPosition - world.xyz);
gl_Position = projectionMatrix * viewMatrix * world;
}
`;
/**
* Поверхность напитка. Волна — сумма двух колец от последних ударов, амплитуда
* приходит из пружины на процессоре. Пена — КРУГЛЫЕ пузыри со сбитой решёткой:
* порог по клетке даёт квадратную плитку, и поверхность читается пиксель-артом.
*/
const SURFACE_FRAG = /* glsl */ `
precision highp float;
uniform vec3 uColor;
uniform float uRipple;
uniform float uTime;
uniform float uFoam;
uniform float uArt;
uniform vec2 uHit;
uniform float uHitAge;
varying vec2 vUv;
varying vec3 vView;
float hash(vec2 p) { return fract(sin(dot(p, vec2(41.7, 289.1))) * 43758.5453); }
vec2 hash2(vec2 p) { return vec2(hash(p), hash(p + 17.1)); }
void main() {
vec2 p = (vUv - 0.5) * 2.0;
float r = length(p);
// Кромка диска гасится ПРОЗРАЧНОСТЬЮ, а не жёстким отбросом: на приближении
// резаный круг давал пунктирную зубчатую дугу поверх стенки.
float edge = 1.0 - smoothstep(0.984, 1.0, r);
if (edge <= 0.001) discard;
// кольцо бежит ОТ ТОЧКИ УДАРА, а не из центра диска
float rh = length(p - uHit);
float front = uHitAge * 2.6;
float wave = sin(rh * 20.0 - uTime * 8.0) * exp(-abs(rh - front) * 3.4) * uRipple;
wave += sin(r * 13.0 - uTime * 5.0) * exp(-r * 1.6) * uRipple * 0.5;
vec3 n = normalize(vec3(p * wave * 1.8, 1.0));
vec3 col = uColor * (0.84 + wave * 0.45);
if (uFoam > 0.01) {
float b = 0.0;
float rim = 0.0;
for (int i = 0; i < 3; i++) {
float s = 6.0 + float(i) * 5.5;
vec2 q = p * s;
vec2 cell = floor(q);
// центр пузыря сбит внутри клетки: ровная решётка выдаёт себя сразу
vec2 jit = (hash2(cell + float(i) * 31.7) - 0.5) * 0.7;
vec2 loc = fract(q) - 0.5 - jit;
float h = hash(cell + float(i) * 17.3);
float rad = 0.13 + h * 0.26;
float d = length(loc);
b += smoothstep(rad, rad * 0.45, d) * (0.1 - float(i) * 0.02) * (0.45 + h * 0.55);
// светлая кромка пузыря — по ней глаз и узнаёт пену
rim += exp(-abs(d - rad) * 26.0) * (0.09 - float(i) * 0.02);
}
// у стенки пузырей всегда больше: их туда сгоняет кромка
float wall = smoothstep(0.55, 1.0, r);
col = mix(col, vec3(1.0, 0.99, 0.96) * (0.88 + b + wall * 0.06), uFoam);
col += vec3(1.0) * rim * uFoam;
n = normalize(n + vec3(0.0, 0.0, 2.2));
}
/* Латте-арт: вложенные серпы вдоль стебля — так барista и вытягивает розетту.
Рисуется ТОЛЬКО по пене: на чёрном эспрессо рисунка не бывает. */
if (uArt > 0.01 && uFoam > 0.5) {
float art = 0.0;
for (int i = 0; i < 6; i++) {
float t = float(i) / 5.0;
vec2 c = vec2(mix(-0.5, 0.42, t), 0.0);
float w = 0.4 * (1.0 - t * 0.5);
float d = abs(length(p - c) - w);
float half_ = step(p.x, c.x + w * 0.25);
art = max(art, smoothstep(0.055, 0.012, d) * half_);
}
// стебель от края к центру
art = max(art, smoothstep(0.03, 0.008, abs(p.y)) * smoothstep(0.92, 0.25, abs(p.x + 0.1)));
vec3 crema = vec3(0.33, 0.18, 0.1);
col = mix(col, crema, clamp(art * uArt * 0.9, 0.0, 1.0));
// по кромке рисунка — светлая пенка, как на настоящей розетте
n = normalize(n + vec3(p * art * 1.4, 0.0));
}
/* Свет задан в СИСТЕМЕ ДИСКА, а не мира: диск повёрнут на -90° по X, поэтому
мировой «верх» — это его локальная +z. Пока сюда приходил мировой вектор,
косинус считался по чужой оси, пена выходила серой, как бетон. */
vec3 L = normalize(vec3(-0.5, -0.42, 0.78));
float key = max(0.0, dot(n, L));
float spec = pow(max(0.0, dot(reflect(-L, n), normalize(vView))), 40.0);
// Пена рассеивает свет всем объёмом, поэтому у неё СВОЙ подпор яркости:
// на общем множителе она садилась в серый бетон вместо сливок.
col *= 0.72 + key * 0.48 + uFoam * 0.34;
col += vec3(1.0) * spec * 0.5;
/* МЕНИСК вместо тёмной кромки. Тёмное кольцо по краю диска и было той самой
«линией по середине»: диск темнел к краю, а стенка под ним оставалась светлой,
и на стыке получалась жёсткая эллиптическая черта. У настоящей жидкости кромка
у стекла подтянута ВВЕРХ и ловит свет — она светлее, а не темнее. */
float men = smoothstep(0.9, 1.0, r);
col = mix(col, col * 1.22 + vec3(0.05), men * 0.75);
col = vec3(1.0) - exp(-col * 1.75);
gl_FragColor = vec4(col * edge, edge);
}
`;
const STREAM_VERT = /* glsl */ `
uniform float uTime;
varying vec2 vUv;
void main() {
vUv = uv;
vec3 p = position;
p.x += sin(p.y * 7.0 - uTime * 9.0) * 0.02;
p.z += cos(p.y * 6.0 - uTime * 7.5) * 0.011;
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}
`;
const STREAM_FRAG = /* glsl */ `
precision highp float;
uniform vec3 uColor;
uniform float uTime;
varying vec2 vUv;
void main() {
float edge = pow(1.0 - abs(vUv.x - 0.5) * 2.0, 0.6);
float streak = 0.75 + 0.25 * sin(vUv.y * 40.0 - uTime * 26.0);
vec3 col = uColor * (0.7 + 0.5 * edge) * streak;
// блик соразмерен цвету напитка: белая линия делала эспрессо молочным
float lum = dot(uColor, vec3(0.3, 0.59, 0.11));
col += mix(uColor, vec3(1.0), 0.6) * pow(max(0.0, 1.0 - abs(vUv.x - 0.36) * 7.0), 10.0) * (0.2 + lum * 0.8);
gl_FragColor = vec4(col, 1.0);
}
`;
/**
* Пар. Каждая частица живёт свой цикл: всплывает с ускорением, виляет по двум
* частотам сразу (одна частота даёт «змейку», две — живой завиток), расходится
* к верху и гаснет плавной огибающей. Плотность прядей задаётся хешем частицы,
* поэтому струя не выглядит ровным веером.
*/
const STEAM_VERT = /* glsl */ `
attribute float aSeed;
uniform float uTime;
uniform float uHeat;
uniform float uBase;
uniform float uScale;
uniform float uWidth;
varying float vFade;
varying float vSeed;
void main() {
float speed = 0.1 + aSeed * 0.075;
float t = fract(uTime * speed + aSeed * 7.31);
vSeed = aSeed;
// подъём с ускорением: горячий воздух разгоняется, а не едет равномерно
float rise = (t * 0.55 + t * t * 0.85) * (0.9 + aSeed * 0.5);
float curl = sin(t * 5.2 + aSeed * 21.0) * 0.5 + sin(t * 9.7 + aSeed * 11.0) * 0.22;
float spread = 0.1 + t * 0.55;
vec3 p = vec3(
curl * spread * uWidth + (aSeed - 0.5) * 0.16 * uWidth,
uBase + rise,
(cos(t * 4.4 + aSeed * 15.0) * 0.6 + cos(t * 8.1 + aSeed * 5.0) * 0.25) * spread * uWidth
);
// огибающая: появляется у поверхности, гаснет к верху
vFade = smoothstep(0.0, 0.12, t) * (1.0 - smoothstep(0.45, 1.0, t)) * uHeat;
vec4 mv = modelViewMatrix * vec4(p, 1.0);
gl_PointSize = (0.1 + t * 0.85) * uScale / max(0.2, -mv.z);
gl_Position = projectionMatrix * mv;
}
`;
const STEAM_FRAG = /* glsl */ `
precision highp float;
varying float vFade;
varying float vSeed;
void main() {
vec2 d = gl_PointCoord - 0.5;
float r = length(d);
// клочок, а не шарик: мягкая сердцевина и почти нулевой край
// Экспонента на углу спрайта не доходит до нуля (exp(-2) ≈ 0.14), и на плотном
// паре видны КВАДРАТЫ спрайтов. Домножаем на спад к кромке круга.
float a = exp(-r * r * 8.0) * smoothstep(0.5, 0.16, r) * vFade * (0.06 + fract(vSeed * 7.3) * 0.1);
if (a < 0.002) discard;
gl_FragColor = vec4(vec3(0.93, 0.95, 0.98) * a, a);
}
`;
/**
* Лёд. Своя оболочка, а не стекло стакана: стеклянный шейдер почти прозрачен,
* и кубик поверх светлого молока не виден вовсе. Голубая сердцевина, морозные
* грани и своя альфа; `uMelt` подтапливает грани — кубик мутнеет и оплывает.
*/
const ICE_FRAG = /* glsl */ `
precision highp float;
uniform sampler2D uBack;
uniform vec2 uRes;
uniform float uRefraction;
varying vec3 vNormal;
varying vec3 vView;
varying vec3 vLocal;
void main() {
vec3 n = normalize(vNormal);
vec3 v = normalize(vView);
float facing = max(0.04, dot(n, v));
float fres = pow(1.0 - facing, 2.2);
vec2 suv = gl_FragCoord.xy / uRes;
vec3 back = texture2D(uBack, clamp(suv + n.xy * 0.02 * uRefraction, 0.002, 0.998)).rgb;
vec3 core = mix(vec3(0.56, 0.75, 0.95), back, 0.14);
vec3 key = normalize(vec3(-0.5, 0.8, 0.4));
float spec = pow(max(0.0, dot(reflect(-key, n), v)), 50.0);
vec3 col = core * (0.72 + fres * 0.5) + vec3(1.0) * spec * 0.8;
float a = clamp(0.6 + fres * 0.4 + spec, 0.0, 1.0);
gl_FragColor = vec4(col * a, a);
}
`;
/** Брызги: мелкие капли того же цвета, что напиток. */
const DROP_VERT = /* glsl */ `
attribute float aLife;
uniform float uScale;
uniform float uSize;
varying float vFade;
void main() {
vFade = aLife;
vec4 mv = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = uSize * uScale / max(0.2, -mv.z) * (0.6 + aLife * 0.6);
gl_Position = projectionMatrix * mv;
}
`;
const DROP_FRAG = /* glsl */ `
precision highp float;
uniform vec3 uColor;
varying float vFade;
void main() {
vec2 d = gl_PointCoord - 0.5;
float r = length(d);
if (r > 0.5 || vFade <= 0.001) discard;
float shade = 0.75 + 0.45 * (1.0 - r * 2.0);
gl_FragColor = vec4(uColor * shade, 1.0);
}
`;
interface Pour {
ing: Ingredient;
t: number;
/** Слой, который наполняется этим наливом. */
st: { vol: number };
}
interface Cube {
mesh: THREE.Mesh;
pos: THREE.Vector3;
vel: THREE.Vector3;
spin: THREE.Vector3;
size: number;
alive: boolean;
wet: boolean;
}
export function createDrinkLab(canvas: HTMLCanvasElement, options: DrinkLabOptions = {}): DrinkLab {
const cfg: Config = { ...DEFAULTS, ...options };
const animate = cfg.animate;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false, powerPreference: "high-performance" });
const dpr = Math.min(window.devicePixelRatio || 1, 2);
renderer.setPixelRatio(dpr);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 40);
const LIGHT = new THREE.Vector3(-0.5, 0.78, 0.42).normalize();
/* --------------------------------------------------- задник в свой буфер */
const quadGeo = new THREE.PlaneGeometry(2, 2);
const backUniforms = { uRes: { value: new THREE.Vector2(1, 1) }, uTone: { value: cfg.tone } };
const backMat = new THREE.ShaderMaterial({ vertexShader: BACK_VERT, fragmentShader: BACK_FRAG, uniforms: backUniforms });
const backScene = new THREE.Scene();
const backCam = new THREE.Camera();
backScene.add(new THREE.Mesh(quadGeo, backMat));
const backTarget = new THREE.WebGLRenderTarget(1, 1, {
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
depthBuffer: false,
});
// тот же задник кладётся и в основную сцену: иначе стекло преломляет стол,
// которого на экране нет
const backPlate = new THREE.Mesh(
quadGeo,
new THREE.ShaderMaterial({
vertexShader: BACK_VERT,
fragmentShader: BACK_FRAG,
uniforms: backUniforms,
depthWrite: false,
depthTest: false,
}),
);
backPlate.frustumCulled = false;
backPlate.renderOrder = -1;
scene.add(backPlate);
/* ------------------------------------------------------------- посуда */
let shape = SHAPES[cfg.glass]!;
const glassUniforms = () => ({
uBack: { value: backTarget.texture },
uRes: { value: new THREE.Vector2(1, 1) },
uRefraction: { value: cfg.refraction },
});
const resUniform = new THREE.Vector2(1, 1);
const mkGlass = (side: THREE.Side, facing: number) =>
new THREE.ShaderMaterial({
vertexShader: SOLID_VERT,
fragmentShader: GLASS_FRAG,
uniforms: { ...glassUniforms(), uRes: { value: resUniform }, uFacing: { value: facing } },
transparent: true,
premultipliedAlpha: true,
depthWrite: false,
side,
});
// Два прохода: сначала дальние грани, потом ближние. Одним проходом
// с двусторонними гранями половины стакана смешиваются в порядке треугольников,
// и по центру видна жёсткая вертикальная линия.
const glassBackMat = mkGlass(THREE.BackSide, -1);
const glassFrontMat = mkGlass(THREE.FrontSide, 1);
let glassGeo = new THREE.CylinderGeometry(shape.rt, shape.rb, shape.h, 96, 1, true);
const glassFar = new THREE.Mesh(glassGeo, glassBackMat);
const glassNear = new THREE.Mesh(glassGeo, glassFrontMat);
glassFar.renderOrder = 1;
glassNear.renderOrder = 8;
scene.add(glassFar, glassNear);
let bottomGeo = new THREE.CylinderGeometry(shape.rb, shape.rb * 0.94, 0.07, 96);
const bottom = new THREE.Mesh(bottomGeo, glassFrontMat);
bottom.renderOrder = 2;
scene.add(bottom);
const rimGeo = new THREE.TorusGeometry(1, 0.014, 8, 120);
const rimMat = new THREE.MeshBasicMaterial({
color: new THREE.Color("#dbe7ff"),
transparent: true,
opacity: 0.6,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const rimTop = new THREE.Mesh(rimGeo, rimMat);
const rimBase = new THREE.Mesh(rimGeo, rimMat.clone());
(rimBase.material as THREE.MeshBasicMaterial).opacity = 0.28;
rimTop.rotation.x = Math.PI / 2;
rimBase.rotation.x = Math.PI / 2;
rimTop.renderOrder = 9;
rimBase.renderOrder = 9;
scene.add(rimTop, rimBase);
/* ---------------------------------------------------------- жидкость */
const liquidUniforms = {
uColors: { value: Array.from({ length: MAX_LAYERS }, () => new THREE.Vector3(0.1, 0.06, 0.04)) },
uTops: { value: new Float32Array(MAX_LAYERS) },
uBlurs: { value: new Float32Array(MAX_LAYERS) },
uCount: { value: 1 },
uLight: { value: LIGHT },
};
const liquidMat = new THREE.ShaderMaterial({
vertexShader: SOLID_VERT,
fragmentShader: LIQUID_FRAG,
uniforms: liquidUniforms,
});
const liquidGeo = new THREE.CylinderGeometry(1, 1, 1, 96, 1, true);
const liquid = new THREE.Mesh(liquidGeo, liquidMat);
liquid.renderOrder = 3;
scene.add(liquid);
const surfaceUniforms = {
uColor: { value: new THREE.Vector3(0.2, 0.1, 0.05) },
uRipple: { value: 0 },
uTime: { value: 0 },
uFoam: { value: 0 },
uArt: { value: 0 },
uHit: { value: new THREE.Vector2(0, 0) },
uHitAge: { value: 0 },
};
const surfaceMat = new THREE.ShaderMaterial({
vertexShader: SURFACE_VERT,
fragmentShader: SURFACE_FRAG,
uniforms: surfaceUniforms,
transparent: true,
premultipliedAlpha: true,
});
const surfaceGeo = new THREE.PlaneGeometry(2, 2, 1, 1);
const surface = new THREE.Mesh(surfaceGeo, surfaceMat);
surface.renderOrder = 4;
scene.add(surface);
/* ------------------------------------------------------------- струя */
const streamUniforms = { uColor: { value: new THREE.Vector3(1, 1, 1) }, uTime: { value: 0 } };
const streamMat = new THREE.ShaderMaterial({
vertexShader: STREAM_VERT,
fragmentShader: STREAM_FRAG,
uniforms: streamUniforms,
});
const streamGeo = new THREE.CylinderGeometry(0.055, 0.075, 1, 20, 24, true);
const stream = new THREE.Mesh(streamGeo, streamMat);
stream.visible = false;
stream.renderOrder = 6;
scene.add(stream);
/* --------------------------------------------------------------- пар */
const STEAM_N = 150;
const steamPos = new Float32Array(STEAM_N * 3);
const steamSeed = new Float32Array(STEAM_N);
for (let i = 0; i < STEAM_N; i++) steamSeed[i] = (i + 0.5) / STEAM_N;
const steamGeo = new THREE.BufferGeometry();
steamGeo.setAttribute("position", new THREE.BufferAttribute(steamPos, 3));
steamGeo.setAttribute("aSeed", new THREE.BufferAttribute(steamSeed, 1));
const steamMat = new THREE.ShaderMaterial({
vertexShader: STEAM_VERT,
fragmentShader: STEAM_FRAG,
uniforms: {
uTime: { value: 0 },
uHeat: { value: 0 },
uBase: { value: 0 },
uScale: { value: 300 },
uWidth: { value: 1 },
},
transparent: true,
premultipliedAlpha: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
const steam = new THREE.Points(steamGeo, steamMat);
steam.frustumCulled = false;
steam.renderOrder = 10;
scene.add(steam);
/* --------------------------------------------------------------- лёд */
const iceGeo = new THREE.BoxGeometry(1, 0.88, 1);
const iceMat = new THREE.ShaderMaterial({
vertexShader: SOLID_VERT,
fragmentShader: ICE_FRAG,
uniforms: { uBack: { value: backTarget.texture }, uRes: { value: resUniform }, uRefraction: { value: cfg.refraction * 0.6 } },
transparent: true,
premultipliedAlpha: true,
depthWrite: false,
});
const iceEdgeGeo = new THREE.EdgesGeometry(iceGeo);
const iceEdgeMat = new THREE.LineBasicMaterial({
color: new THREE.Color("#eaf4ff"),
transparent: true,
opacity: 0.7,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const cubes: Cube[] = [];
for (let i = 0; i < MAX_ICE; i++) {
const mesh = new THREE.Mesh(iceGeo, iceMat);
mesh.add(new THREE.LineSegments(iceEdgeGeo, iceEdgeMat));
mesh.visible = false;
mesh.renderOrder = 5;
scene.add(mesh);
cubes.push({
mesh,
pos: new THREE.Vector3(),
vel: new THREE.Vector3(),
spin: new THREE.Vector3(),
size: 0,
alive: false,
wet: false,
});
}
/* ------------------------------------------------------------ брызги */
const dropPos = new Float32Array(MAX_DROPS * 3);
const dropLife = new Float32Array(MAX_DROPS);
const dropVel: THREE.Vector3[] = Array.from({ length: MAX_DROPS }, () => new THREE.Vector3());
const dropGeo = new THREE.BufferGeometry();
dropGeo.setAttribute("position", new THREE.BufferAttribute(dropPos, 3));
dropGeo.setAttribute("aLife", new THREE.BufferAttribute(dropLife, 1));
const dropMat = new THREE.ShaderMaterial({
vertexShader: DROP_VERT,
fragmentShader: DROP_FRAG,
uniforms: {
uScale: { value: 300 },
uSize: { value: 0.03 },
uColor: { value: new THREE.Vector3(1, 1, 1) },
},
transparent: true,
depthWrite: false,
});
const drops = new THREE.Points(dropGeo, dropMat);
drops.frustumCulled = false;
drops.renderOrder = 7;
scene.add(drops);
/* --------------------------------------------------------- состояние */
/**
* ДВА разных списка, и это важно.
* `order` — что игрок добавил и в каком порядке (по нему сверяется рецепт,
* и лёд тоже сюда попадает — иначе айс-латте не собрать никогда).
* `strata` — жидкие слои в стакане, снизу вверх.
*
* Слои складываются В ПОРЯДКЕ НАЛИВА, как их и строит бариста. Расслоение
* по плотности со подкрашиванием тут было и убрано по слову владельца:
* настоящего перемешивания это всё равно не даёт (для него нужна симуляция
* жидкости, а не сдвиг границ), а выглядит подделкой.
*/
const order: IngredientId[] = [];
interface Stratum {
col: THREE.Vector3;
vol: number;
/** Размытие ВЕРХНЕЙ границы: мягкий стык, а не наклейка. */
blur: number;
foamy: boolean;
id: IngredientId;
}
const strata: Stratum[] = [];
/** Объём, который занимает лёд: он не слой, но уровень поднимает. */
let iceVol = 0;
let level = 0;
/** Уровень пересчитывается ПО СЛОЯМ, а не накапливается отдельно: иначе
* сумма слоёв и уровень со временем разъезжаются, и границы врут. */
const relevel = () => {
let v = iceVol;
for (const st of strata) v += st.vol;
level = Math.min(1, v);
};
let heat = 0;
/** Пружина поверхности: амплитуда волны и её скорость. */
let rip = 0;
let ripV = 0;
/** Наклон поверхности: тоже пружина, по двум осям. */
const tilt = new THREE.Vector2();
const tiltV = new THREE.Vector2();
let hitAge = 1;
/** Рисунок проявляется плавно: мгновенное появление читается наклейкой. */
let artNow = 0;
/**
* Состояние изменилось внутри физики — интерфейсу надо сказать. Без этого кубик,
* упавший в стакан, не снимал признак «занято»: кнопки оставались выключенными
* навсегда, и игра вставала (айс-латте было не собрать).
*/
let dirty = false;
let active: Pour | null = null;
let listener: ((s: DrinkLabState) => void) | null = null;
const emit = () => {
listener?.({
layers: [...order],
level,
pouring: !!active || cubes.some((c) => c.alive && !c.wet),
heat,
ice: cubes.filter((c) => c.alive).length,
});
};
function applyShape(): void {
shape = SHAPES[cfg.glass]!;
glassGeo.dispose();
glassGeo = new THREE.CylinderGeometry(shape.rt, shape.rb, shape.h, 96, 1, true);
glassFar.geometry = glassGeo;
glassNear.geometry = glassGeo;
bottomGeo.dispose();
bottomGeo = new THREE.CylinderGeometry(shape.rb, shape.rb * 0.94, 0.07, 96);
bottom.geometry = bottomGeo;
bottom.position.y = -shape.h / 2 + 0.035;
rimTop.scale.setScalar(shape.rt);
rimTop.position.y = shape.h / 2;
rimBase.scale.setScalar(shape.rb);
rimBase.position.y = -shape.h / 2 + 0.01;
}
applyShape();
/**
* Новый слой ложится СВЕРХУ и начинает расти с нулевого объёма — с первой же
* капли. Раньше слой добавлялся только в конце налива, и всё время налива
* в стакане был виден цвет предыдущего слоя, а в конце цвет скакал на верный.
* Тот же ингредиент подряд доливается в существующий слой, без шва.
*/
function openStratum(ing: Ingredient): Stratum {
const top = strata[strata.length - 1];
if (top && top.id === ing.id) return top;
const st: Stratum = {
col: new THREE.Vector3(ing.color[0], ing.color[1], ing.color[2]),
vol: 0,
blur: 0.016,
foamy: !!ing.foamy,
id: ing.id,
};
strata.push(st);
return st;
}
/** Границы слоёв, цвета и размытия уезжают в шейдер жидкости. */
function syncLayers(): void {
const cols = liquidUniforms.uColors.value as THREE.Vector3[];
const tops = liquidUniforms.uTops.value as Float32Array;
const blurs = liquidUniforms.uBlurs.value as Float32Array;
const n = Math.min(strata.length, MAX_LAYERS);
let acc = 0;
for (let i = 0; i < n; i++) {
const st = strata[i]!;
cols[i]!.copy(st.col);
acc += st.vol;
tops[i] = level > 0.001 ? Math.min(1, acc / level) : 1;
blurs[i] = st.blur;
}
liquidUniforms.uCount.value = Math.max(1, n);
const top = strata[n - 1];
if (top) {
surfaceUniforms.uColor.value.copy(top.col);
dropMat.uniforms.uColor!.value.copy(top.col);
}
surfaceUniforms.uFoam.value = top?.foamy ? 1 : 0;
}
const surfaceY = () => -shape.h / 2 + Math.max(0.0001, level * shape.h * 0.94) + 0.021;
const surfaceR = () => {
const h = Math.max(0.0001, level * shape.h * 0.94);
return (shape.rb + (shape.rt - shape.rb) * (h / shape.h)) * 0.985;
};
/** Удар по поверхности: пружина получает импульс, летят брызги. */
function splash(x: number, z: number, force: number): void {
const r = surfaceR();
ripV += force * 9;
tiltV.x += (x / Math.max(0.001, r)) * force * 5;
tiltV.y += (z / Math.max(0.001, r)) * force * 5;
surfaceUniforms.uHit.value.set(x / Math.max(0.001, r), z / Math.max(0.001, r));
hitAge = 0;
const n = Math.min(MAX_DROPS, Math.round(4 + force * 26));
let spawned = 0;
for (let i = 0; i < MAX_DROPS && spawned < n; i++) {
if (dropLife[i]! > 0.01) continue;
const a = Math.random() * Math.PI * 2;
const s = 0.35 + Math.random() * 0.85;
dropPos[i * 3] = x;
dropPos[i * 3 + 1] = surfaceY() + 0.01;
dropPos[i * 3 + 2] = z;
dropVel[i]!.set(Math.cos(a) * s * force, (0.9 + Math.random() * 1.5) * force, Math.sin(a) * s * force);
dropLife[i] = 1;
spawned++;
}
}
/** Кубики льда: падают сверху, а не «наливаются». */
function dropIce(count: number): void {
let spawned = 0;
for (const c of cubes) {
if (c.alive || spawned >= count) continue;
const a = Math.random() * Math.PI * 2;
const rr = surfaceR() * 0.45;
c.pos.set(Math.cos(a) * rr, shape.h / 2 + 0.45 + spawned * 0.4, Math.sin(a) * rr);
c.vel.set(0, -0.4, 0);
c.spin.set(Math.random() * 2 - 1, Math.random() * 2 - 1, Math.random() * 2 - 1).multiplyScalar(2.4);
c.size = 0.24 + Math.random() * 0.08;
c.alive = true;
c.wet = false;
c.mesh.visible = true;
spawned++;
}
}
function resize(): void {
const w = canvas.clientWidth || window.innerWidth;
const h = canvas.clientHeight || window.innerHeight;
renderer.setSize(w, h, false);
const bw = Math.max(2, Math.round(w * dpr));
const bh = Math.max(2, Math.round(h * dpr));
backTarget.setSize(bw, bh);
backUniforms.uRes.value.set(bw, bh);
resUniform.set(bw, bh);
steamMat.uniforms.uScale!.value = bh * 0.5;
dropMat.uniforms.uScale!.value = bh * 0.5;
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
resize();
const pointer = { x: 0, y: 0, tx: 0, ty: 0 };
const onPointerMove = (e: PointerEvent) => {
if (!animate) return;
const r = canvas.getBoundingClientRect();
pointer.tx = (e.clientX - r.left) / r.width - 0.5;
pointer.ty = 0.5 - (e.clientY - r.top) / r.height;
};
const onLeave = () => {
pointer.tx = 0;
pointer.ty = 0;
};
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("pointerleave", onLeave);
let raf = 0;
let last = 0;
let time = 0;
/** Один шаг физики: пружина поверхности, брызги, лёд. */
function physics(dt: number): void {
// пружина с затуханием: второй удар складывается с первым, а не отменяет его
ripV += -58 * rip * dt - 6.4 * ripV * dt;
rip += ripV * dt;
tiltV.x += -46 * tilt.x * dt - 5.6 * tiltV.x * dt;
tiltV.y += -46 * tilt.y * dt - 5.6 * tiltV.y * dt;
tilt.x += tiltV.x * dt;
tilt.y += tiltV.y * dt;
hitAge = Math.min(1.4, hitAge + dt);
const sy = surfaceY();
const sr = surfaceR();
/* брызги летят по параболе и, упав, добавляют волну */
for (let i = 0; i < MAX_DROPS; i++) {
if (dropLife[i]! <= 0.01) continue;
const v = dropVel[i]!;
const k = i * 3;
v.y -= G * dt;
const px = dropPos[k]! + v.x * dt;
const py = dropPos[k + 1]! + v.y * dt;
const pz = dropPos[k + 2]! + v.z * dt;
dropPos[k] = px;
dropPos[k + 1] = py;
dropPos[k + 2] = pz;
dropLife[i] = Math.max(0, dropLife[i]! - dt * 0.75);
if (py < sy && Math.hypot(px, pz) < sr) {
dropLife[i] = 0;
ripV += 0.55; // упавшая капля добавляет поверхности новую волну
} else if (py < -shape.h / 2) {
dropLife[i] = 0;
}
}
/* лёд: тяжесть, удар о поверхность, плавание, таяние */
for (const c of cubes) {
if (!c.alive) continue;
const half = c.size * 0.44;
const floatY = level > 0.02 ? sy + half * 0.55 : -shape.h / 2 + half + 0.07;
if (!c.wet) {
c.vel.y -= G * dt;
c.pos.addScaledVector(c.vel, dt);
if (c.pos.y <= floatY) {
c.pos.y = floatY;
const impact = Math.min(1, Math.abs(c.vel.y) * 0.22);
c.vel.y *= -0.28;
c.wet = true;
dirty = true; // кубик долетел: признак «занято» надо снять
if (level > 0.02) splash(c.pos.x, c.pos.z, 0.25 + impact * 0.5);
}
} else {
// плавание: пружина к уровню плюс лёгкое покачивание
c.vel.y += (floatY - c.pos.y) * 26 * dt - c.vel.y * 5.2 * dt;
c.pos.y += c.vel.y * dt;
c.pos.x += Math.sin(time * 0.9 + c.size * 30) * 0.02 * dt;
c.pos.z += Math.cos(time * 0.75 + c.size * 22) * 0.02 * dt;
c.spin.multiplyScalar(1 - dt * 1.6);
}
// ТАЯНИЕ: в горячем напитке кубик оплывает и заодно остужает его
if (c.wet && heat > 0.12) {
c.size -= dt * heat * 0.085;
heat = Math.max(0, heat - dt * 0.12);
if (c.size < 0.07) {
c.alive = false;
c.mesh.visible = false;
iceVol = Math.max(0, iceVol - 0.012); // кубик ушёл, но оставил воду
const base = strata[0];
if (base) base.vol += 0.012;
relevel();
splash(c.pos.x, c.pos.z, 0.12);
syncLayers();
dirty = true;
continue;
}
}
c.mesh.position.copy(c.pos);
c.mesh.scale.setScalar(c.size);
c.mesh.rotation.x += c.spin.x * dt;
c.mesh.rotation.y += c.spin.y * dt;
c.mesh.rotation.z += c.spin.z * dt;
}
}
function frame(now = 0): void {
const dt = animate && last ? Math.min((now - last) / 1000, 0.05) : 0;
last = now;
time += dt;
/* налив */
if (active) {
active.t += dt * 0.55 * cfg.speed;
const want = Math.min(1, active.t);
// объём слоя РАСТЁТ, а уровень считается по слоям — цвет верен с первой капли
active.st.vol = active.ing.volume * want;
relevel();
// струя бьёт непрерывно: пружина получает маленькие толчки каждый кадр
ripV += dt * 26;
if (Math.random() < dt * 9) splash((Math.random() - 0.5) * 0.1, (Math.random() - 0.5) * 0.1, 0.1);
if (active.t >= 1) {
if (active.ing.hot) heat = 1;
active = null;
emit();
}
syncLayers();
}
heat *= Math.exp(-dt * 0.05);
if (dt > 0) physics(dt);
if (dirty) {
dirty = false;
emit();
}
/* геометрия по текущему уровню */
const h = Math.max(0.0001, level * shape.h * 0.94);
const sr = surfaceR();
liquid.visible = level > 0.004;
liquid.scale.set(sr, h, sr);
liquid.position.y = -shape.h / 2 + h / 2 + 0.02;
surface.visible = liquid.visible;
surface.scale.setScalar(sr);
surface.position.y = surfaceY();
// наклон поверхности от пружины: жидкость качается в стакане
surface.rotation.set(-Math.PI / 2 + tilt.y * 0.09, 0, tilt.x * 0.09);
steamMat.uniforms.uBase!.value = surface.position.y + 0.02;
steamMat.uniforms.uWidth!.value = sr * 1.5;
/* струя видна только пока льётся */
if (active) {
const topY = surface.position.y;
const from = shape.h / 2 + 0.62;
const len = Math.max(0.05, from - topY);
stream.visible = true;
stream.position.set(Math.sin(time * 7.0) * 0.012, topY + len / 2, 0);
stream.scale.set(1, len, 1);
streamUniforms.uColor.value.set(active.ing.color[0], active.ing.color[1], active.ing.color[2]);
streamUniforms.uTime.value = time;
} else {
stream.visible = false;
}
artNow += (cfg.art - artNow) * Math.min(1, dt * 2.6);
surfaceUniforms.uArt.value = artNow;
surfaceUniforms.uRipple.value = Math.max(0, Math.min(1.4, rip));
surfaceUniforms.uTime.value = time;
surfaceUniforms.uHitAge.value = hitAge;
steamMat.uniforms.uTime!.value = time;
steamMat.uniforms.uHeat!.value = heat * cfg.steam;
backUniforms.uTone.value = cfg.tone;
glassFrontMat.uniforms.uRefraction!.value = cfg.refraction;
glassBackMat.uniforms.uRefraction!.value = cfg.refraction;
iceMat.uniforms.uRefraction!.value = cfg.refraction * 0.6;
(dropGeo.getAttribute("position") as THREE.BufferAttribute).needsUpdate = true;
(dropGeo.getAttribute("aLife") as THREE.BufferAttribute).needsUpdate = true;
/* параллакс: камера чуть ходит вокруг стакана и смотрит В него */
pointer.x += (pointer.tx - pointer.x) * 0.06;
pointer.y += (pointer.ty - pointer.y) * 0.06;
const ang = pointer.x * cfg.parallax * 0.5;
// «Крупнее» — это ближе камера, а не больше стакан: масштабировать саму
// посуду значило бы ломать физику, где тяжесть и размеры кубиков в мировых единицах.
const r = 7.3 / Math.max(0.4, cfg.zoom);
camera.position.set(Math.sin(ang) * r, 1.35 + pointer.y * cfg.parallax * 0.8, Math.cos(ang) * r);
camera.lookAt(0, 0.12, 0);
renderer.setRenderTarget(backTarget);
renderer.render(backScene, backCam);
renderer.setRenderTarget(null);
renderer.render(scene, camera);
}
const loop = (now: number) => {
frame(now);
raf = requestAnimationFrame(loop);
};
const start = () => {
if (raf || !animate) return;
last = 0;
raf = requestAnimationFrame(loop);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
/**
* Начальная заливка. Сцена НЕ играет сама: она просто стоит с готовым напитком,
* поэтому в кадре есть пар и живая поверхность, а не пустой стакан.
*/
function prefill(): void {
for (const id of cfg.prefill) {
const ing = INGREDIENTS.find((i) => i.id === id);
if (!ing || strata.length >= MAX_LAYERS) continue;
order.push(ing.id);
if (ing.solid) {
iceVol += ing.volume;
relevel();
dropIce(3);
for (const c of cubes) {
if (!c.alive) continue;
c.wet = true;
c.pos.y = surfaceY() + c.size * 0.24;
}
continue;
}
const st = openStratum(ing);
st.vol = ing.volume;
relevel();
if (ing.hot) heat = 1;
}
syncLayers();
emit();
}
prefill();
const ro = new ResizeObserver(() => {
resize();
frame();
});
ro.observe(canvas);
const io = new IntersectionObserver(([e]) => (e!.isIntersecting && !document.hidden ? start() : stop()));
io.observe(canvas);
const onVis = () => (document.hidden ? stop() : start());
document.addEventListener("visibilitychange", onVis);
const onLost = (e: Event) => {
e.preventDefault();
stop();
};
canvas.addEventListener("webglcontextlost", onLost);
frame();
start();
return {
pour(id) {
const busy = !!active || cubes.some((c) => c.alive && !c.wet);
if (busy || level > 0.985) return false;
const ing = INGREDIENTS.find((i) => i.id === id);
if (!ing) return false;
if (ing.solid) {
// лёд НЕ наливают: кубики падают сверху и плещут
dropIce(3);
iceVol += ing.volume;
relevel();
order.push(ing.id);
syncLayers();
emit();
return true;
}
if (strata.length >= MAX_LAYERS) return false;
order.push(ing.id);
active = { ing, t: 0, st: openStratum(ing) };
syncLayers();
syncLayers();
emit();
return true;
},
reset() {
order.length = 0;
strata.length = 0;
iceVol = 0;
level = 0;
heat = 0;
rip = 0;
ripV = 0;
tilt.set(0, 0);
tiltV.set(0, 0);
active = null;
for (const c of cubes) {
c.alive = false;
c.mesh.visible = false;
}
for (let i = 0; i < MAX_DROPS; i++) dropLife[i] = 0;
syncLayers();
emit();
if (!animate) frame();
},
set(next) {
// undefined в пропсах React — «не задано», а не «сбросить»
const patch = Object.fromEntries(Object.entries(next).filter(([, v]) => v !== undefined)) as Partial<DrinkLabOptions>;
const reshape = "glass" in patch && patch.glass !== cfg.glass;
Object.assign(cfg, patch);
if (reshape) applyShape();
if (!animate) frame();
},
onChange(cb) {
listener = cb;
emit();
},
destroy() {
stop();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
canvas.removeEventListener("pointermove", onPointerMove);
canvas.removeEventListener("pointerleave", onLeave);
canvas.removeEventListener("webglcontextlost", onLost);
for (const g of [quadGeo, glassGeo, bottomGeo, liquidGeo, surfaceGeo, streamGeo, steamGeo, iceGeo, rimGeo, iceEdgeGeo, dropGeo]) {
g.dispose();
}
for (const m of [backMat, glassFrontMat, glassBackMat, liquidMat, surfaceMat, streamMat, steamMat, iceMat, dropMat, rimMat, iceEdgeMat]) {
m.dispose();
}
(backPlate.material as THREE.Material).dispose();
(rimBase.material as THREE.Material).dispose();
backTarget.dispose();
renderer.dispose();
renderer.forceContextLoss();
},
};
}