01

Структура файлів проєкту

Кожна симуляція розміщується у власній теці верхнього рівня. Назва теки стає шляхом в URL — пишіть її малими літерами, через дефіс, без пробілів.

my-simulation/
  index.html ← сторінка вашої симуляції
  # необов'язкові доповнення:
  worker.js ← Web Worker для важких обчислень
  shader.vert ← зовнішній вершинний шейдер GLSL
  shader.frag ← зовнішній фрагментний шейдер GLSL

Спільні ресурси (CSS, JS, іконки) розміщуються в shared/ у корені. НЕ копіюйте спільні файли до теки своєї симуляції — посилайтеся на них через відносний шлях.

Посилання на спільні файли

Атрибут data-base на елементах navbar/footer вказує shared/components.js, наскільки високо вгору по дереву шукати:

HTML
<!-- корінь navbar — автоматично заповнюється components.js -->
<div id="navbar-root" data-base="../"></div>

<link rel="stylesheet" href="../shared/theme.css">
<link rel="stylesheet" href="../shared/components.css">

<!-- корінь footer -->
<div id="footer-root" data-base="../"></div>
<script src="../shared/components.js" defer></script>
Глибина: теки симуляцій верхнього рівня використовують data-base="../". Сторінки всередині categories/ чи content/ використовують data-base="../../../".
✓ Контрольна точка — теку створено, посилання на спільні файли коректні
02

Шаблон симуляції

Почніть із цього мінімального каркаса. Він дає вам полотно на весь в'юпорт, Three.js через import map, цикл анімації та повідомлення про запасний варіант WebGL.

HTML
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Моя симуляція — 3D Simulations</title>
  <meta name="description" content="Короткий опис, максимум 160 символів.">
  <link rel="stylesheet" href="../shared/theme.css">
  <link rel="stylesheet" href="../shared/components.css">
  <style>
    body { margin: 0; overflow: hidden; background: #0a0a0f; }
    #canvas-wrap { position: fixed; inset: 0; }
    canvas { display: block; width: 100% !important; height: 100% !important; }
  </style>
  <!-- Import map для Three.js -->
  <script type="importmap">
  { "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.160/build/three.module.js",
    "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160/examples/jsm/" } }
  </script>
</head>
<body>
  <div id="navbar-root" data-base="../"></div>
  <div id="canvas-wrap"></div>
  <div id="footer-root" data-base="../"></div>
  <script src="../shared/components.js" defer></script>

  <script type="module">
  import * as THREE from 'three';
  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

  // ── Налаштування сцени ──────────────────────────────────────
  const canvas = document.createElement('canvas');
  document.getElementById('canvas-wrap').appendChild(canvas);

  const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
  renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
  renderer.setSize(innerWidth, innerHeight);

  const scene  = new THREE.Scene();
  const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 1000);
  camera.position.set(0, 5, 15);

  const controls = new OrbitControls(camera, renderer.domElement);
  controls.enableDamping = true;

  // ── Освітлення ──────────────────────────────────────────────
  scene.add(new THREE.AmbientLight(0xffffff, 0.4));
  const sun = new THREE.DirectionalLight(0xffffff, 1.2);
  sun.position.set(10, 20, 10);
  scene.add(sun);

  // ── Об'єкти вашої симуляції ─────────────────────────────────
  const geo  = new THREE.SphereGeometry(1, 32, 32);
  const mat  = new THREE.MeshStandardMaterial({ color: 0x6366f1 });
  const mesh = new THREE.Mesh(geo, mat);
  scene.add(mesh);

  // ── Зміна розміру ───────────────────────────────────────────
  window.addEventListener('resize', () => {
    camera.aspect = innerWidth / innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(innerWidth, innerHeight);
  });

  // ── Цикл анімації ───────────────────────────────────────────
  const clock = new THREE.Clock();
  (function animate() {
    requestAnimationFrame(animate);
    const t = clock.getElapsedTime();
    mesh.rotation.y = t * 0.5;
    controls.update();
    renderer.render(scene, camera);
  })();
  </script>
</body>
</html>
Порада: тримайте import map угорі <head> — він має йти перед будь-яким тегом скрипта type="module".
✓ Контрольна точка — порожнє полотно показує сферу, що обертається, на 60 fps
03

HUD та панель керування

Усі симуляції в цьому проєкті дотримуються однакового патерну HUD: напівпрозора панель у лівому нижньому (або правому нижньому) кутку, абсолютно позиційована поверх полотна.

Стандартна розмітка HUD

HTML + CSS
<!-- Додайте всередині <body>, після canvas-wrap -->
<div class="hud" id="hud">
  <div class="hud-title">Моя симуляція</div>
  <div class="hud-row">
    <label class="hud-label" for="speed">Швидкість</label>
    <input class="hud-range" id="speed" type="range" min="0.1" max="5" step="0.1" value="1">
  </div>
  <div class="hud-row">
    <label class="hud-label" for="count">Частинки</label>
    <input class="hud-range" id="count" type="range" min="100" max="5000" step="100" value="1000">
  </div>
  <button class="hud-btn" id="btn-reset">Скинути</button>
</div>

<style>
  .hud {
    position: fixed; bottom: 1.5rem; left: 1.5rem; z-index: 10;
    background: rgba(10,10,15,0.85); backdrop-filter: blur(12px);
    border: 1px solid rgba(255,255,255,0.08); border-radius: 12px;
    padding: 1rem 1.25rem; min-width: 220px; color: #e2e8f0;
  }
  .hud-title  { font-size: 0.7rem; font-weight: 700; text-transform: uppercase;
                letter-spacing: 0.1em; color: #6366f1; margin-bottom: 0.85rem; }
  .hud-row    { display: flex; flex-direction: column; gap: 0.25rem; margin-bottom: 0.6rem; }
  .hud-label  { font-size: 0.72rem; color: #94a3b8; }
  .hud-range  { width: 100%; accent-color: #6366f1; }
  .hud-btn    { width: 100%; margin-top: 0.4rem; padding: 0.4rem;
                border: 1px solid rgba(255,255,255,0.12); border-radius: 6px;
                background: rgba(255,255,255,0.05); color: #e2e8f0;
                font-size: 0.78rem; cursor: pointer; transition: background 0.15s; }
  .hud-btn:hover { background: rgba(255,255,255,0.1); }
</style>

Підключення елементів керування до симуляції

JavaScript
// У вашому модульному скрипті, після налаштування сцени:
const speedSlider = document.getElementById('speed');
const resetBtn    = document.getElementById('btn-reset');

let simSpeed = 1;
speedSlider.addEventListener('input', e => simSpeed = parseFloat(e.target.value));
resetBtn.addEventListener('click', resetSimulation);

function resetSimulation() {
  // повторно ініціалізуйте частинки / геометрію тут
}

// Використовуйте simSpeed у циклі анімації:
function animate() {
  requestAnimationFrame(animate);
  const dt = clock.getDelta() * simSpeed;
  updateParticles(dt);
  renderer.render(scene, camera);
}
Гаряча клавіша: натисніть H, щоб перемкнути видимість HUD. Додайте document.addEventListener('keydown', e => { if (e.key === 'h') hud.classList.toggle('hidden'); })
✓ Контрольна точка — HUD відображається з робочими повзунками та кнопкою скидання
04

Мобільні пристрої та адаптивність

Усі симуляції мають працювати на сенсорних екранах. Три ключові напрямки: сенсорне керування орбітою, розмір HUD та масштабування продуктивності.

OrbitControls, зручні для сенсора

OrbitControls обробляє сенсорні події «з коробки» — обертання одним пальцем, масштабування щипком двома пальцями, панорамування двома пальцями. Додатковий код не потрібен.

Адаптивний HUD

CSS
/* Згортаємо HUD на дуже малих екранах */
@media (max-width: 480px) {
  .hud {
    left: 0.75rem; right: 0.75rem; bottom: 0.75rem;
    min-width: unset;
  }
  /* Більші сенсорні цілі */
  .hud-range  { height: 24px; }
  .hud-btn    { padding: 0.65rem; font-size: 0.85rem; }
}

Масштабування продуктивності

JavaScript
// Визначаємо мобільний пристрій і зменшуємо кількість частинок / роздільність
const isMobile = /Mobi|Android/i.test(navigator.userAgent);
const PARTICLE_COUNT = isMobile ? 2000 : 10000;
renderer.setPixelRatio(isMobile ? 1 : Math.min(devicePixelRatio, 2));

// Адаптивна якість: якщо FPS падає нижче 30, зменшуємо кількість частинок удвічі
let frameCount = 0, lastTime = performance.now();
function checkFPS() {
  frameCount++;
  const now = performance.now();
  if (now - lastTime >= 2000) {
    const fps = frameCount / 2;
    if (fps < 30 && particleCount > 500) {
      particleCount = Math.floor(particleCount * 0.7);
      rebuildParticles();
    }
    frameCount = 0; lastTime = now;
  }
}
Завжди тестуйте на реальному мобільному пристрої — емуляція в Chrome DevTools не точно відображає продуктивність GPU чи затримку сенсора.
✓ Контрольна точка — симуляція плавно працює на телефоні середнього класу
05

Контрольний список якості

Перш ніж надсилати pull request або додавати свою симуляцію до каталогу, перевірте кожен пункт нижче:

HTML та метадані

  • Унікальний <title> у форматі Назва — 3D Simulations
  • <meta name="description"> — 120–160 символів, звичайний текст
  • <link rel="canonical"> з повним абсолютним URL
  • lang="en" (або lang="uk" для української версії)
  • Коректні og:title, og:description, og:type

Продуктивність

  • Стабільні 60 fps на пристрої середнього класу (тестуйте з обмеженням CPU 4× у Chrome DevTools)
  • Немає витоків пам'яті — geometry.dispose() та material.dispose() викликано під час очищення
  • Pixel ratio обмежено до 2 (Math.min(devicePixelRatio, 2))
  • Текстури мають розміри, що є степенем двійки (64, 128, 256 … 2048)

Доступність

  • Усі інтерактивні елементи керування мають видиму рамку фокуса (:focus-visible)
  • Підписи HUD використовують <label for="…"> або aria-label
  • Полотно має aria-label з описом симуляції
  • Кнопка паузи доступна без навігації мишею

Кросбраузерність і мобільні пристрої

  • Протестовано в Chrome, Firefox, Safari (WebKit)
  • Сенсорні події працюють в iOS Safari (щипок, обертання)
  • Коректне повідомлення-замінник, якщо контекст WebGL недоступний
  • HUD зручний при ширині в'юпорта 375px

Додавання до каталогу

Коли ваша симуляція пройде контрольний список, додайте її до shared/data/simulations.json (якщо він існує) і додайте картку в index.html, що вказує на нову теку.

Порада: відкрийте PR із текою симуляції, скриншотом preview/my-sim.jpg (1200×630) та описом в один абзац. Контрольний список перевірки запуститься автоматично.
✓ Усі перевірки пройдено — можна надсилати!

Що далі?

Продовжуйте розвивати навички за допомогою цих ресурсів