Motiva
Мои коллекции
← каталог

Drink Lab

Мини-игра «собери напиток»: стакан в центре, кнопки ингредиентов, жидкость льётся сверху, лёд ПАДАЕТ кубиками и плещет, горячее парит. Слои складываются в порядке налива, как их и строит бариста: новый слой ложится сверху и растёт с первой капли, поэтому цвет в стакане и в струе верен с самого начала, а стык слоёв мягкий, а не наклеенный. Физика настоящая там, где её видно: поверхность — пружина с затуханием, поэтому второй удар складывается с первым; брызги летят по параболе и, упав, добавляют новую волну; кубики льда падают под тяжестью, гасят скорость об поверхность, всплывают и качаются, а в горячем напитке таят и сами остужают его, так что пар слабеет. Стекло преломляет задник и рисуется двумя проходами: одним проходом с двусторонними гранями половины стакана смешиваются в порядке треугольников и по центру видна жёсткая линия. Задание сверяется по порядку добавления, «слить» опустошает стакан, «другой рецепт» меняет задание, а собранный рецепт получает награду — латте-арт на пене.

threeclickmouse-moveautoadvanced~164 КБreduced-motion ✓
↗
🎛 Настройки — крути и копируй

Установка

npx shadcn@latest add https://motiva.pages.dev/r/drink-lab.json

зависимости: three@^0.185.1 · @types/three@^0.185.0

Что нужно в проекте до установки
  1. Файл components.json в корне

    Из него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:

    "aliases": { "components": "@/components", "lib": "@/lib" }

    Нет файла — установка не начнётся вовсе.

  2. Алиас @ объявлен дважды

    В корневом tsconfig.json и в конфиге сборщика. В Vite корневой tsconfig часто содержит только references — этого мало.

    "paths": { "@/*": ["./src/*"] }        // tsconfig.json
    resolve.alias = { "@": "/src" }        // vite.config.ts

    Без paths CLI молча создаёт папку с именем @.

  3. Токены подключены один раз на приложение

    Они приезжают вместе с записью — достаточно импортировать их в точке входа:

    import "@/styles/motiva-tokens.css";

    Без них блок потеряет цвета, ритм и тему.

  4. Своя типографика не спорит с блоком

    Наследуемые свойства протекают внутрь: text-align: center на обёртке отцентрует содержимое секции. То же касается font-familyи line-height.

  5. Проект без TypeScript? всё равно нужен tsconfig

    CLI грузит tsconfig.json безусловно, а при "tsx": false идёт за jsconfig.json и падает так же. Минимум — файл с pathsи "tsx": true в components.json.

    Приехавший рядом .tsx можно не трогать — вам нужен .vanilla.js.

Экспорт

↓ Скачать .zip

Код

"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>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); заказ владельца 14.09.2026 — «мини-конструктор напитков, стакан по центру, магнитные кнопки, сверху течёт жидкость, если молоко сбить то пар, 3D и параллакс»v1.0.0