feat: add Grok 4.5 benchmark results (#31)

Co-authored-by: DuJiabao <Jiabao.du@Yunzhisun.com>
This commit is contained in:
mintcolorandDuJiabao authored and GitHub committed 2026-07-09 20:23:47 +01:00
1 parent 807bdac433
commit e14d6cd59f
4 files changed
+1804

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
{
"label": "Grok 4.5",
"vendor": "xAI",
"harness": "Grok Build TUI",
"effort": "High",
"artifactDir": "grok-4-5/grok-build-tui-high",
"order": 59,
"runs": {
"mythos-craft": {
"note": {
"zh": "在本地通过 Grok Build(TUI)以 Grok 4.5(High,最高可选档位)一次生成,未修改。含 Perlin 噪声地形、WASD 第一人称、多种方块破坏/放置、水晶塔/符文石阵/龙骨遗迹/天空遗迹,按 G 传送地标,附小地图与快捷栏。",
"en": "One-shot by Grok 4.5 (High, highest available effort) in the Grok Build TUI, unmodified. Perlin-noise terrain, WASD first-person, multi-block break/place, Crystal Spire / Rune Circle / Dragon Relic / Sky Ruins, G-key landmark teleport, minimap and hotbar."
},
"contributor": "Mintcolour"
},
"pelican-cycling": {
"note": {
"zh": "在本地通过 Grok Build(TUI)以 Grok 4.5(High,最高可选档位)一次生成,未修改。海边骑行鹈鹕 SVG:渐变天空、太阳云朵、海浪沙滩、棕榈树、海鸥、自行车细节与鹈鹕喙囊羽翼踩踏姿态。",
"en": "One-shot by Grok 4.5 (High, highest available effort) in the Grok Build TUI, unmodified. Coastal pelican-on-bicycle SVG: gradient sky, sun and clouds, waves and beach, palms, gulls, detailed bicycle, and pelican beak/pouch/wing pedaling pose."
},
"contributor": "Mintcolour"
},
"skeleton-watch": {
"note": {
"zh": "在本地通过 Grok Build(TUI)以 Grok 4.5(High,最高可选档位)一次生成,未修改。Three.js 镂空机械表:齿轮系、摆轮游丝、擒纵、红宝石轴承、发光指针刻度、爆炸/组装视图、零件悬停标签与自动旋转。",
"en": "One-shot by Grok 4.5 (High, highest available effort) in the Grok Build TUI, unmodified. Three.js skeleton watch: gear train, balance/hairspring, escapement, ruby bearings, luminous hands and markers, exploded/assembled views, part hover labels and auto-rotate."
},
"contributor": "Mintcolour"
}
}
}
@@ -0,0 +1,760 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mythos Craft</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0c12; font-family: "Segoe UI", system-ui, sans-serif; color: #e8e6df; }
canvas#c { display: block; width: 100%; height: 100%; cursor: none; }
#hud {
position: fixed; inset: 0; pointer-events: none; z-index: 5;
}
#cross {
position: absolute; left: 50%; top: 50%; width: 18px; height: 18px; transform: translate(-50%, -50%);
}
#cross::before, #cross::after {
content: ""; position: absolute; background: rgba(255,255,255,0.85);
box-shadow: 0 0 0 1px rgba(0,0,0,0.45);
}
#cross::before { left: 7px; top: 0; width: 2px; height: 18px; }
#cross::after { left: 0; top: 7px; width: 18px; height: 2px; }
#hotbar {
position: absolute; left: 50%; bottom: 22px; transform: translateX(-50%);
display: flex; gap: 6px; padding: 8px 10px; background: rgba(8,10,16,0.55);
border: 1px solid rgba(255,255,255,0.12); border-radius: 10px; backdrop-filter: blur(8px);
}
.slot {
width: 44px; height: 44px; border: 2px solid rgba(255,255,255,0.18); border-radius: 6px;
display: flex; align-items: center; justify-content: center; position: relative;
background: rgba(255,255,255,0.04);
}
.slot.active { border-color: #f5d76e; box-shadow: 0 0 0 1px #f5d76e inset, 0 0 12px rgba(245,215,110,0.25); }
.slot canvas { width: 32px; height: 32px; image-rendering: pixelated; }
.slot .key {
position: absolute; top: 2px; left: 4px; font-size: 9px; color: rgba(255,255,255,0.55); font-family: ui-monospace, monospace;
}
#info {
position: absolute; left: 16px; top: 14px; font-size: 12px; line-height: 1.55;
text-shadow: 0 1px 2px rgba(0,0,0,0.8); color: rgba(232,230,223,0.9);
}
#info b { color: #f5d76e; font-weight: 600; }
#minimap {
position: absolute; right: 16px; top: 14px; width: 128px; height: 128px;
border: 1px solid rgba(255,255,255,0.2); border-radius: 8px; background: rgba(8,12,20,0.7);
image-rendering: pixelated; box-shadow: 0 8px 24px rgba(0,0,0,0.35);
}
#overlay {
position: fixed; inset: 0; z-index: 20; display: flex; align-items: center; justify-content: center;
background: radial-gradient(ellipse at center, rgba(18,22,36,0.92), rgba(4,6,12,0.96));
}
#overlay.hidden { display: none; }
.panel {
width: min(520px, 92vw); padding: 28px 30px 24px; border-radius: 16px;
background: linear-gradient(160deg, rgba(28,32,48,0.95), rgba(12,14,22,0.98));
border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 24px 60px rgba(0,0,0,0.45);
}
.panel h1 {
font-size: 28px; letter-spacing: 0.04em; margin-bottom: 6px;
background: linear-gradient(90deg, #f5d76e, #e8b86d, #c9a0ff);
-webkit-background-clip: text; background-clip: text; color: transparent;
}
.panel p { font-size: 13.5px; line-height: 1.65; color: rgba(232,230,223,0.82); margin: 10px 0; }
.panel ul { margin: 10px 0 16px 18px; font-size: 13px; line-height: 1.7; color: rgba(232,230,223,0.78); }
.panel kbd {
display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 11px;
background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.15); font-family: ui-monospace, monospace;
}
.panel button {
pointer-events: auto; width: 100%; margin-top: 8px; padding: 12px 16px; border: 0; border-radius: 10px;
background: linear-gradient(135deg, #f5d76e, #d4a017); color: #1a1408; font-weight: 700; font-size: 14px;
cursor: pointer; letter-spacing: 0.03em;
}
.panel button:hover { filter: brightness(1.06); }
#hint {
position: absolute; left: 50%; bottom: 88px; transform: translateX(-50%);
font-size: 12px; color: rgba(255,255,255,0.55); text-shadow: 0 1px 2px #000; opacity: 0; transition: opacity .4s;
}
#hint.show { opacity: 1; }
</style>
</head>
<body>
<div id="overlay">
<div class="panel">
<h1>MYTHOS CRAFT</h1>
<p>A voxel world of mythic ruins, crystal towers and forgotten rings — generated as a single HTML file.</p>
<ul>
<li><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · mouse look</li>
<li><kbd>LMB</kbd> break · <kbd>RMB</kbd> place · <kbd>1-8</kbd> / scroll hotbar</li>
<li><kbd>G</kbd> teleport between mythic landmarks</li>
<li><kbd>Esc</kbd> release pointer</li>
</ul>
<button id="start">Click to enter the world</button>
</div>
</div>
<div id="hud">
<div id="cross"></div>
<div id="info"></div>
<canvas id="minimap" width="128" height="128"></canvas>
<div id="hotbar"></div>
<div id="hint">Press G to visit mythic sites</div>
</div>
<canvas id="c"></canvas>
<script type="importmap">
{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js"}}
</script>
<script type="module">
import * as THREE from 'three';
// ---------- Perlin noise ----------
class Perlin {
constructor(seed = 1337) {
this.p = new Uint8Array(512);
const perm = new Uint8Array(256);
for (let i = 0; i < 256; i++) perm[i] = i;
let s = seed >>> 0;
for (let i = 255; i > 0; i--) {
s = (s * 1664525 + 1013904223) >>> 0;
const j = s % (i + 1);
const t = perm[i]; perm[i] = perm[j]; perm[j] = t;
}
for (let i = 0; i < 512; i++) this.p[i] = perm[i & 255];
}
fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
lerp(a, b, t) { return a + t * (b - a); }
grad(h, x, y, z) {
const u = (h & 15);
const a = u < 8 ? x : y;
const b = u < 4 ? y : (u === 12 || u === 14 ? x : z);
return ((u & 1) ? -a : a) + ((u & 2) ? -b : b);
}
noise(x, y, z = 0) {
const X = Math.floor(x) & 255, Y = Math.floor(y) & 255, Z = Math.floor(z) & 255;
x -= Math.floor(x); y -= Math.floor(y); z -= Math.floor(z);
const u = this.fade(x), v = this.fade(y), w = this.fade(z);
const p = this.p;
const A = p[X] + Y, AA = p[A] + Z, AB = p[A + 1] + Z;
const B = p[X + 1] + Y, BA = p[B] + Z, BB = p[B + 1] + Z;
return this.lerp(
this.lerp(
this.lerp(this.grad(p[AA], x, y, z), this.grad(p[BA], x - 1, y, z), u),
this.lerp(this.grad(p[AB], x, y - 1, z), this.grad(p[BB], x - 1, y - 1, z), u), v),
this.lerp(
this.lerp(this.grad(p[AA + 1], x, y, z - 1), this.grad(p[BA + 1], x - 1, y, z - 1), u),
this.lerp(this.grad(p[AB + 1], x, y - 1, z - 1), this.grad(p[BB + 1], x - 1, y - 1, z - 1), u), v), w);
}
fbm(x, y, oct = 4) {
let v = 0, a = 0.5, f = 1;
for (let i = 0; i < oct; i++) { v += a * this.noise(x * f, y * f); a *= 0.5; f *= 2; }
return v;
}
}
// ---------- Block types ----------
const AIR = 0;
const BLOCKS = {
1: { name: 'Grass', color: [0x5a, 0x9e, 0x3a], side: [0x6b, 0x4e, 0x2e], bottom: [0x5c, 0x3d, 0x22] },
2: { name: 'Dirt', color: [0x6b, 0x4e, 0x2e] },
3: { name: 'Stone', color: [0x7a, 0x7c, 0x82] },
4: { name: 'Sand', color: [0xd6, 0xc4, 0x8a] },
5: { name: 'Wood', color: [0x6e, 0x4a, 0x28], side: [0x5a, 0x3c, 0x1e] },
6: { name: 'Leaves', color: [0x3d, 0x8a, 0x45], alpha: true },
7: { name: 'Crystal', color: [0x7a, 0xd4, 0xff], emissive: 0x2266aa },
8: { name: 'Obsidian', color: [0x1a, 0x12, 0x28] },
9: { name: 'Gold', color: [0xe8, 0xc0, 0x3a] },
10: { name: 'Rune', color: [0x6a, 0x4c, 0x9e], emissive: 0x4422aa },
11: { name: 'Snow', color: [0xee, 0xf2, 0xf6] },
12: { name: 'Water', color: [0x2a, 0x6a, 0xc8], alpha: true },
};
const PLACEABLE = [1, 2, 3, 4, 5, 7, 8, 9, 10];
// Procedural atlas (16x16 tiles, 4x4 grid of types we care about)
function makeAtlas() {
const tile = 16, cols = 8, rows = 4;
const c = document.createElement('canvas');
c.width = tile * cols; c.height = tile * rows;
const ctx = c.getContext('2d');
const drawTile = (id, col, row, face) => {
const b = BLOCKS[id]; if (!b) return;
const base = face === 'side' && b.side ? b.side : face === 'bottom' && b.bottom ? b.bottom : b.color;
const x0 = col * tile, y0 = row * tile;
for (let y = 0; y < tile; y++) for (let x = 0; x < tile; x++) {
const n = ((x * 13 + y * 7 + id * 31) % 17) - 8;
let r = Math.max(0, Math.min(255, base[0] + n));
let g = Math.max(0, Math.min(255, base[1] + n));
let bl = Math.max(0, Math.min(255, base[2] + n));
// subtle pixel noise / highlights
if ((x + y) % 5 === 0) { r = Math.min(255, r + 10); g = Math.min(255, g + 10); bl = Math.min(255, bl + 8); }
if (id === 1 && face === 'top' && y < 4) { // grass fringe
r = 0x4a + (n & 7); g = 0x8e + (n & 7); bl = 0x32;
}
if (id === 7 && (x === 7 || y === 7)) { r = 0xcc; g = 0xee; bl = 0xff; }
if (id === 10 && ((x + y) % 4 === 0)) { r = 0xb0; g = 0x80; bl = 0xff; }
ctx.fillStyle = `rgb(${r},${g},${bl})`;
ctx.fillRect(x0 + x, y0 + y, 1, 1);
}
};
// layout: id maps to col; row 0 top, 1 side, 2 bottom
for (let id = 1; id <= 12; id++) {
const col = (id - 1) % cols;
drawTile(id, col, 0, 'top');
drawTile(id, col, 1, 'side');
drawTile(id, col, 2, 'bottom');
}
const tex = new THREE.CanvasTexture(c);
tex.magFilter = THREE.NearestFilter;
tex.minFilter = THREE.NearestFilter;
tex.colorSpace = THREE.SRGBColorSpace;
tex.flipY = false;
return { tex, tile, cols, rows };
}
function faceUV(id, face) {
const col = (id - 1) % 8;
const row = face === 'top' ? 0 : face === 'bottom' ? 2 : 1;
const u0 = col / 8, v0 = row / 4, u1 = (col + 1) / 8, v1 = (row + 1) / 4;
return [u0, v0, u1, v0, u1, v1, u0, v1];
}
// ---------- World ----------
const CHUNK = 16;
const VIEW = 4; // chunks radius
const WORLD_H = 64;
const SEA = 12;
const noise = new Perlin(0x4d797468); // 'Myth' seed-ish hex-safe
const world = new Map(); // "x,y,z" -> id
const chunkMeshes = new Map();
const key = (x, y, z) => `${x},${y},${z}`;
const getB = (x, y, z) => {
if (y < 0 || y >= WORLD_H) return AIR;
const k = key(x, y, z);
if (world.has(k)) return world.get(k);
return AIR;
};
const setB = (x, y, z, id) => {
if (y < 0 || y >= WORLD_H) return;
const k = key(x, y, z);
if (id === AIR) world.delete(k); else world.set(k, id);
};
function heightAt(x, z) {
const c = noise.fbm(x * 0.018, z * 0.018, 5);
const m = noise.fbm(x * 0.006 + 40, z * 0.006 + 40, 3);
let h = 14 + c * 10 + m * 8;
const d = noise.noise(x * 0.003, z * 0.003);
if (d < -0.25) h = Math.min(h, SEA - 1 + (d + 0.25) * 8); // basins
if (m > 0.35) h += (m - 0.35) * 28; // mountains
return Math.floor(Math.max(3, Math.min(WORLD_H - 8, h)));
}
function biomeAt(x, z) {
const t = noise.fbm(x * 0.004 + 100, z * 0.004 + 100, 3);
const m = noise.fbm(x * 0.004 + 200, z * 0.004 + 200, 3);
if (t < -0.25) return 'desert';
if (t > 0.35 && m > 0.1) return 'snow';
if (m < -0.2) return 'forest';
return 'plains';
}
function genChunk(cx, cz) {
const ox = cx * CHUNK, oz = cz * CHUNK;
for (let lz = 0; lz < CHUNK; lz++) for (let lx = 0; lx < CHUNK; lx++) {
const x = ox + lx, z = oz + lz;
const h = heightAt(x, z);
const biome = biomeAt(x, z);
for (let y = 0; y <= h; y++) {
let id = 3; // stone
if (y === h) {
if (h < SEA) id = 4;
else if (biome === 'desert') id = 4;
else if (biome === 'snow') id = 11;
else id = 1;
} else if (y >= h - 3) {
id = biome === 'desert' ? 4 : 2;
} else if (y < 5 && noise.noise(x * 0.1, y * 0.1, z * 0.1) > 0.35) {
id = 9; // rare gold veins
}
setB(x, y, z, id);
}
// water fill
for (let y = h + 1; y <= SEA; y++) setB(x, y, z, 12);
// trees
if (h > SEA + 1 && biome === 'forest' && ((x * 374761 + z * 668265) >>> 0) % 37 === 0) {
const th = 4 + ((x * z) >>> 0) % 3;
for (let i = 1; i <= th; i++) setB(x, h + i, z, 5);
for (let dy = -1; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) for (let dz = -2; dz <= 2; dz++) {
if (dx * dx + dz * dz + dy * dy < 7) {
const ly = h + th + dy;
if (getB(x + dx, ly, z + dz) === AIR) setB(x + dx, ly, z + dz, 6);
}
}
}
}
}
// Mythic structures
const LANDMARKS = [];
function placeBox(x0, y0, z0, w, h, d, id, hollow = false) {
for (let y = 0; y < h; y++) for (let z = 0; z < d; z++) for (let x = 0; x < w; x++) {
const edge = x === 0 || x === w - 1 || y === 0 || y === h - 1 || z === 0 || z === d - 1;
if (!hollow || edge) setB(x0 + x, y0 + y, z0 + z, id);
}
}
function buildCrystalTower(cx, cz) {
const h = heightAt(cx, cz);
const baseY = Math.max(h + 1, SEA + 1);
placeBox(cx - 3, baseY, cz - 3, 7, 2, 7, 8);
for (let i = 0; i < 18; i++) {
const s = Math.max(1, 5 - Math.floor(i / 4));
placeBox(cx - Math.floor(s / 2), baseY + 2 + i, cz - Math.floor(s / 2), s, 1, s, 7);
}
setB(cx, baseY + 21, cz, 10);
setB(cx, baseY + 22, cz, 7);
LANDMARKS.push({ name: 'Crystal Spire', x: cx + 0.5, y: baseY + 3, z: cz + 0.5, color: '#7ad4ff' });
}
function buildStoneCircle(cx, cz) {
const h = heightAt(cx, cz);
const baseY = Math.max(h + 1, SEA + 1);
const R = 8;
for (let i = 0; i < 12; i++) {
const a = (i / 12) * Math.PI * 2;
const px = Math.round(cx + Math.cos(a) * R);
const pz = Math.round(cz + Math.sin(a) * R);
const ph = 4 + (i % 3);
for (let y = 0; y < ph; y++) setB(px, baseY + y, pz, 3);
setB(px, baseY + ph, pz, 10);
}
// central altar
placeBox(cx - 1, baseY, cz - 1, 3, 1, 3, 8);
setB(cx, baseY + 1, cz, 9);
LANDMARKS.push({ name: 'Rune Circle', x: cx + 0.5, y: baseY + 2, z: cz + 0.5, color: '#b080ff' });
}
function buildDragonBones(cx, cz) {
const h = heightAt(cx, cz);
const baseY = Math.max(h + 1, SEA + 1);
// spine
for (let i = 0; i < 16; i++) {
setB(cx + i, baseY + 2 + Math.floor(Math.sin(i * 0.5) * 2), cz, 11);
if (i % 2 === 0) {
setB(cx + i, baseY + 3 + Math.floor(Math.sin(i * 0.5) * 2), cz + 2, 11);
setB(cx + i, baseY + 3 + Math.floor(Math.sin(i * 0.5) * 2), cz - 2, 11);
}
}
// skull
placeBox(cx + 16, baseY + 2, cz - 2, 4, 3, 5, 11, true);
setB(cx + 19, baseY + 3, cz - 1, 8);
setB(cx + 19, baseY + 3, cz + 1, 8);
LANDMARKS.push({ name: 'Dragon Relic', x: cx + 8.5, y: baseY + 4, z: cz + 0.5, color: '#eef2f6' });
}
function buildSkyRuins(cx, cz) {
const h = heightAt(cx, cz);
const baseY = Math.max(h + 12, SEA + 14);
// floating platforms
placeBox(cx - 5, baseY, cz - 5, 11, 1, 11, 3);
placeBox(cx - 3, baseY + 1, cz - 3, 7, 5, 7, 8, true);
for (let i = 0; i < 4; i++) {
const a = (i / 4) * Math.PI * 2;
const px = Math.round(cx + Math.cos(a) * 8);
const pz = Math.round(cz + Math.sin(a) * 8);
placeBox(px - 1, baseY - 2, pz - 1, 3, 1, 3, 10);
for (let y = baseY - 1; y <= baseY + 4; y++) setB(px, y, pz, 7);
}
setB(cx, baseY + 6, cz, 9);
LANDMARKS.push({ name: 'Sky Ruins', x: cx + 0.5, y: baseY + 2, z: cz + 0.5, color: '#f5d76e' });
}
function ensureStructures() {
// fixed mythic sites near origin in different directions
buildCrystalTower(24, 18);
buildStoneCircle(-30, 22);
buildDragonBones(12, -40);
buildSkyRuins(-18, -16);
}
// ---------- Meshing ----------
const atlas = makeAtlas();
const faceNormals = [
{ d: [1, 0, 0], verts: [[1,0,0],[1,1,0],[1,1,1],[1,0,1]], face: 'side' },
{ d: [-1, 0, 0], verts: [[0,0,1],[0,1,1],[0,1,0],[0,0,0]], face: 'side' },
{ d: [0, 1, 0], verts: [[0,1,1],[1,1,1],[1,1,0],[0,1,0]], face: 'top' },
{ d: [0, -1, 0], verts: [[0,0,0],[1,0,0],[1,0,1],[0,0,1]], face: 'bottom' },
{ d: [0, 0, 1], verts: [[1,0,1],[1,1,1],[0,1,1],[0,0,1]], face: 'side' },
{ d: [0, 0, -1], verts: [[0,0,0],[0,1,0],[1,1,0],[1,0,0]], face: 'side' },
];
function opaque(id) { return id !== AIR && id !== 6 && id !== 12; }
function buildChunkMesh(cx, cz) {
const positions = [], normals = [], uvs = [], colors = [];
const indices = [];
let vi = 0;
const ox = cx * CHUNK, oz = cz * CHUNK;
const pushFace = (x, y, z, fn, id) => {
const uv = faceUV(id, fn.face);
const b = BLOCKS[id];
const tint = id === 12 ? [0.55, 0.7, 1] : id === 6 ? [0.75, 1, 0.75] : [1, 1, 1];
for (let i = 0; i < 4; i++) {
const v = fn.verts[i];
positions.push(x + v[0], y + v[1], z + v[2]);
normals.push(fn.d[0], fn.d[1], fn.d[2]);
uvs.push(uv[i * 2], uv[i * 2 + 1]);
colors.push(tint[0], tint[1], tint[2]);
}
indices.push(vi, vi + 1, vi + 2, vi, vi + 2, vi + 3);
vi += 4;
};
for (let lz = 0; lz < CHUNK; lz++) for (let lx = 0; lx < CHUNK; lx++) for (let y = 0; y < WORLD_H; y++) {
const x = ox + lx, z = oz + lz;
const id = getB(x, y, z);
if (id === AIR) continue;
for (const fn of faceNormals) {
const nx = x + fn.d[0], ny = y + fn.d[1], nz = z + fn.d[2];
const nid = getB(nx, ny, nz);
const show = !opaque(nid) && nid !== id;
if (show) pushFace(x, y, z, fn, id);
}
}
if (!positions.length) return null;
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geo.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
geo.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
geo.setIndex(indices);
geo.computeBoundingSphere();
const mat = new THREE.MeshLambertMaterial({
map: atlas.tex, vertexColors: true, alphaTest: 0.4, side: THREE.FrontSide,
});
return new THREE.Mesh(geo, mat);
}
// ---------- Scene ----------
const canvas = document.getElementById('c');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setClearColor(0x87b5e8);
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x9ec5e8, 0.012);
const camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.1, 256);
const sun = new THREE.DirectionalLight(0xfff2d6, 1.15);
sun.position.set(40, 80, 20);
scene.add(sun);
scene.add(new THREE.AmbientLight(0x6a7a9a, 0.55));
const hemi = new THREE.HemisphereLight(0x9ec5e8, 0x3a4a2a, 0.35);
scene.add(hemi);
// sky dome
const skyGeo = new THREE.SphereGeometry(200, 24, 16);
const skyMat = new THREE.ShaderMaterial({
side: THREE.BackSide,
depthWrite: false,
uniforms: {},
vertexShader: `varying vec3 vP; void main(){ vP=position; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }`,
fragmentShader: `varying vec3 vP; void main(){
float h = normalize(vP).y * 0.5 + 0.5;
vec3 top = vec3(0.35,0.55,0.92); vec3 mid = vec3(0.62,0.78,0.95); vec3 bot = vec3(0.85,0.72,0.55);
vec3 col = mix(bot, mid, smoothstep(0.0,0.45,h));
col = mix(col, top, smoothstep(0.4,1.0,h));
gl_FragColor = vec4(col,1.0);
}`,
});
const sky = new THREE.Mesh(skyGeo, skyMat);
scene.add(sky);
// ---------- Player ----------
const player = {
x: 0.5, y: 30, z: 0.5,
vx: 0, vy: 0, vz: 0,
yaw: 0, pitch: 0,
onGround: false,
selected: 0,
landmarkIdx: 0,
};
const keys = {};
const SPEED = 5.5, JUMP = 8.2, GRAV = 22;
function collideAABB(px, py, pz) {
const r = 0.3, h = 1.7;
const minX = Math.floor(px - r), maxX = Math.floor(px + r);
const minY = Math.floor(py), maxY = Math.floor(py + h - 0.01);
const minZ = Math.floor(pz - r), maxZ = Math.floor(pz + r);
for (let y = minY; y <= maxY; y++) for (let z = minZ; z <= maxZ; z++) for (let x = minX; x <= maxX; x++) {
const id = getB(x, y, z);
if (opaque(id)) return true;
}
return false;
}
function movePlayer(dt) {
const forward = (keys['KeyW'] ? 1 : 0) - (keys['KeyS'] ? 1 : 0);
const strafe = (keys['KeyD'] ? 1 : 0) - (keys['KeyA'] ? 1 : 0);
const sin = Math.sin(player.yaw), cos = Math.cos(player.yaw);
// Three.js camera faces -Z at yaw=0; forward along look direction
let dx = (-strafe * cos + forward * -sin);
let dz = (-strafe * sin + forward * cos);
const len = Math.hypot(dx, dz) || 1;
dx = (dx / len) * SPEED;
dz = (dz / len) * SPEED;
if (keys['Space'] && player.onGround) { player.vy = JUMP; player.onGround = false; }
player.vy -= GRAV * dt;
// axis separation
let nx = player.x + dx * dt;
if (!collideAABB(nx, player.y, player.z)) player.x = nx;
let nz = player.z + dz * dt;
if (!collideAABB(player.x, player.y, nz)) player.z = nz;
let ny = player.y + player.vy * dt;
if (!collideAABB(player.x, ny, player.z)) {
player.y = ny; player.onGround = false;
} else {
if (player.vy < 0) player.onGround = true;
player.vy = 0;
}
if (player.y < -20) { player.y = heightAt(Math.floor(player.x), Math.floor(player.z)) + 3; player.vy = 0; }
camera.position.set(player.x, player.y + 1.55, player.z);
camera.rotation.order = 'YXZ';
camera.rotation.y = player.yaw;
camera.rotation.x = player.pitch;
sky.position.copy(camera.position);
}
// ---------- Chunk streaming ----------
function chunkKey(cx, cz) { return `${cx},${cz}`; }
function updateChunks() {
const pcx = Math.floor(player.x / CHUNK);
const pcz = Math.floor(player.z / CHUNK);
const needed = new Set();
for (let dz = -VIEW; dz <= VIEW; dz++) for (let dx = -VIEW; dx <= VIEW; dx++) {
if (dx * dx + dz * dz > VIEW * VIEW) continue;
const cx = pcx + dx, cz = pcz + dz;
const k = chunkKey(cx, cz);
needed.add(k);
if (!chunkMeshes.has(k)) {
genChunk(cx, cz);
const mesh = buildChunkMesh(cx, cz);
if (mesh) { scene.add(mesh); chunkMeshes.set(k, mesh); }
else chunkMeshes.set(k, null);
}
}
for (const [k, mesh] of chunkMeshes) {
if (!needed.has(k)) {
if (mesh) { scene.remove(mesh); mesh.geometry.dispose(); mesh.material.dispose(); }
chunkMeshes.delete(k);
}
}
}
function rebuildAround(x, z) {
const cx = Math.floor(x / CHUNK), cz = Math.floor(z / CHUNK);
for (let dz = -1; dz <= 1; dz++) for (let dx = -1; dx <= 1; dx++) {
const k = chunkKey(cx + dx, cz + dz);
const old = chunkMeshes.get(k);
if (old) { scene.remove(old); old.geometry.dispose(); old.material.dispose(); }
const mesh = buildChunkMesh(cx + dx, cz + dz);
if (mesh) { scene.add(mesh); chunkMeshes.set(k, mesh); }
else chunkMeshes.set(k, null);
}
}
// ---------- Raycast ----------
function raycast(maxDist = 6) {
const origin = camera.position.clone();
const dir = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion).normalize();
let x = origin.x, y = origin.y, z = origin.z;
let lastAir = null;
const step = 0.05;
for (let t = 0; t < maxDist; t += step) {
x = origin.x + dir.x * t;
y = origin.y + dir.y * t;
z = origin.z + dir.z * t;
const bx = Math.floor(x), by = Math.floor(y), bz = Math.floor(z);
const id = getB(bx, by, bz);
if (opaque(id) || (id !== AIR && id !== 12)) {
return { hit: { x: bx, y: by, z: bz, id }, prev: lastAir };
}
lastAir = { x: bx, y: by, z: bz };
}
return null;
}
// ---------- UI ----------
const hotbarEl = document.getElementById('hotbar');
const infoEl = document.getElementById('info');
const minimap = document.getElementById('minimap');
const mctx = minimap.getContext('2d');
const hintEl = document.getElementById('hint');
function buildHotbar() {
hotbarEl.innerHTML = '';
PLACEABLE.forEach((id, i) => {
const slot = document.createElement('div');
slot.className = 'slot' + (i === player.selected ? ' active' : '');
const key = document.createElement('span'); key.className = 'key'; key.textContent = String(i + 1);
const ic = document.createElement('canvas'); ic.width = 16; ic.height = 16;
const ictx = ic.getContext('2d');
const b = BLOCKS[id].color;
ictx.fillStyle = `rgb(${b[0]},${b[1]},${b[2]})`;
ictx.fillRect(0, 0, 16, 16);
ictx.fillStyle = 'rgba(255,255,255,0.25)'; ictx.fillRect(0, 0, 16, 4);
slot.appendChild(key); slot.appendChild(ic);
hotbarEl.appendChild(slot);
});
}
function drawMinimap() {
const s = 128, scale = 1;
mctx.fillStyle = '#0c121c'; mctx.fillRect(0, 0, s, s);
const px = Math.floor(player.x), pz = Math.floor(player.z);
for (let z = 0; z < s; z++) for (let x = 0; x < s; x++) {
const wx = px + (x - s / 2) * scale, wz = pz + (z - s / 2) * scale;
const h = heightAt(wx, wz);
const biome = biomeAt(wx, wz);
let col = '#5a9e3a';
if (h <= SEA) col = '#2a6ac8';
else if (biome === 'desert') col = '#d6c48a';
else if (biome === 'snow') col = '#e8eef4';
else if (biome === 'forest') col = '#2f6a34';
mctx.fillStyle = col;
mctx.fillRect(x, z, 1, 1);
}
for (const lm of LANDMARKS) {
const mx = (lm.x - px) / scale + s / 2;
const mz = (lm.z - pz) / scale + s / 2;
if (mx < 0 || mx > s || mz < 0 || mz > s) continue;
mctx.fillStyle = lm.color;
mctx.beginPath(); mctx.arc(mx, mz, 3, 0, Math.PI * 2); mctx.fill();
}
mctx.fillStyle = '#fff';
mctx.beginPath(); mctx.arc(s / 2, s / 2, 2.5, 0, Math.PI * 2); mctx.fill();
// facing
mctx.strokeStyle = '#f5d76e'; mctx.beginPath();
mctx.moveTo(s / 2, s / 2);
mctx.lineTo(s / 2 - Math.sin(player.yaw) * 8, s / 2 + Math.cos(player.yaw) * 8);
mctx.stroke();
}
function updateInfo() {
const b = PLACEABLE[player.selected];
infoEl.innerHTML = `<b>Mythos Craft</b><br/>XYZ ${player.x.toFixed(1)} ${player.y.toFixed(1)} ${player.z.toFixed(1)}<br/>Holding: ${BLOCKS[b].name}<br/>Landmarks: ${LANDMARKS.length} · G to visit`;
}
// ---------- Input ----------
const overlay = document.getElementById('overlay');
document.getElementById('start').addEventListener('click', () => {
overlay.classList.add('hidden');
canvas.requestPointerLock();
hintEl.classList.add('show');
setTimeout(() => hintEl.classList.remove('show'), 4000);
});
document.addEventListener('pointerlockchange', () => {
if (document.pointerLockElement !== canvas) overlay.classList.remove('hidden');
});
document.addEventListener('mousemove', (e) => {
if (document.pointerLockElement !== canvas) return;
player.yaw -= e.movementX * 0.0025;
player.pitch -= e.movementY * 0.0025;
player.pitch = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, player.pitch));
});
window.addEventListener('keydown', (e) => {
keys[e.code] = true;
if (e.code >= 'Digit1' && e.code <= 'Digit9') {
const i = parseInt(e.code.slice(5), 10) - 1;
if (i < PLACEABLE.length) { player.selected = i; buildHotbar(); }
}
if (e.code === 'KeyG' && LANDMARKS.length) {
player.landmarkIdx = (player.landmarkIdx) % LANDMARKS.length;
const lm = LANDMARKS[player.landmarkIdx];
player.x = lm.x; player.z = lm.z; player.y = lm.y; player.vy = 0;
player.landmarkIdx = (player.landmarkIdx + 1) % LANDMARKS.length;
hintEl.textContent = `Teleported to ${lm.name}`;
hintEl.classList.add('show');
setTimeout(() => hintEl.classList.remove('show'), 2000);
}
if (e.code === 'Escape') document.exitPointerLock();
});
window.addEventListener('keyup', (e) => { keys[e.code] = false; });
window.addEventListener('wheel', (e) => {
if (document.pointerLockElement !== canvas) return;
player.selected = (player.selected + (e.deltaY > 0 ? 1 : -1) + PLACEABLE.length) % PLACEABLE.length;
buildHotbar();
});
canvas.addEventListener('mousedown', (e) => {
if (document.pointerLockElement !== canvas) { canvas.requestPointerLock(); return; }
const hit = raycast();
if (!hit) return;
if (e.button === 0 && hit.hit) {
setB(hit.hit.x, hit.hit.y, hit.hit.z, AIR);
rebuildAround(hit.hit.x, hit.hit.z);
} else if (e.button === 2 && hit.prev) {
const id = PLACEABLE[player.selected];
// don't place inside player
const px = Math.floor(player.x), py = Math.floor(player.y), pz = Math.floor(player.z);
const inPlayer = hit.prev.x === px && hit.prev.z === pz && (hit.prev.y === py || hit.prev.y === py + 1);
if (!inPlayer && getB(hit.prev.x, hit.prev.y, hit.prev.z) === AIR) {
setB(hit.prev.x, hit.prev.y, hit.prev.z, id);
rebuildAround(hit.prev.x, hit.prev.z);
}
}
});
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
window.addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
// ---------- Boot ----------
// Pre-gen around spawn and place structures first
for (let cz = -VIEW - 1; cz <= VIEW + 1; cz++) for (let cx = -VIEW - 1; cx <= VIEW + 1; cx++) genChunk(cx, cz);
ensureStructures();
// re-gen structure chunks already in world map — rebuild meshes
for (let cz = -VIEW; cz <= VIEW; cz++) for (let cx = -VIEW; cx <= VIEW; cx++) {
const k = chunkKey(cx, cz);
const mesh = buildChunkMesh(cx, cz);
if (mesh) { scene.add(mesh); chunkMeshes.set(k, mesh); }
}
// spawn on surface near origin
player.y = heightAt(0, 0) + 3;
player.x = 0.5; player.z = 0.5;
buildHotbar();
let last = performance.now();
let mapTick = 0;
function loop(now) {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
if (document.pointerLockElement === canvas) movePlayer(dt);
else {
camera.position.set(player.x, player.y + 1.55, player.z);
camera.rotation.order = 'YXZ';
camera.rotation.y = player.yaw;
camera.rotation.x = player.pitch;
}
updateChunks();
updateInfo();
if ((mapTick++ % 15) === 0) drawMinimap();
// gentle sun cycle tint
const t = now * 0.00005;
sun.position.set(Math.cos(t) * 60, 50 + Math.sin(t) * 20, Math.sin(t) * 60);
renderer.render(scene, camera);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
</body>
</html>
@@ -0,0 +1,334 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 600" width="900" height="600" role="img" aria-label="A lively pelican riding a bicycle by the sea">
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#6eb6e8"/>
<stop offset="45%" stop-color="#a8d4f0"/>
<stop offset="78%" stop-color="#f2c9a0"/>
<stop offset="100%" stop-color="#f7e2c0"/>
</linearGradient>
<linearGradient id="sea" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#3a8fc4"/>
<stop offset="40%" stop-color="#2a6fa0"/>
<stop offset="100%" stop-color="#1e4f78"/>
</linearGradient>
<linearGradient id="sand" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#efd7a8"/>
<stop offset="100%" stop-color="#d9b87a"/>
</linearGradient>
<linearGradient id="pelBody" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#f4f1ea"/>
<stop offset="55%" stop-color="#e6e0d4"/>
<stop offset="100%" stop-color="#cfc6b4"/>
</linearGradient>
<linearGradient id="pelWing" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#f7f4ee"/>
<stop offset="70%" stop-color="#d2cbb8"/>
<stop offset="100%" stop-color="#8a7f6c"/>
</linearGradient>
<linearGradient id="beak" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#f0a24a"/>
<stop offset="50%" stop-color="#e8892e"/>
<stop offset="100%" stop-color="#c96a1a"/>
</linearGradient>
<linearGradient id="pouch" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#f2b36a"/>
<stop offset="100%" stop-color="#d67a3a"/>
</linearGradient>
<radialGradient id="sunGlow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#fff6c8" stop-opacity="1"/>
<stop offset="40%" stop-color="#ffd36a" stop-opacity="0.85"/>
<stop offset="100%" stop-color="#ffb03a" stop-opacity="0"/>
</radialGradient>
<filter id="soft" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="1.2"/>
</filter>
<filter id="shadow">
<feDropShadow dx="2" dy="4" stdDeviation="3" flood-color="#000" flood-opacity="0.22"/>
</filter>
</defs>
<!-- Sky -->
<rect width="900" height="600" fill="url(#sky)"/>
<!-- Sun -->
<circle cx="760" cy="110" r="70" fill="url(#sunGlow)"/>
<circle cx="760" cy="110" r="28" fill="#fff1a8"/>
<circle cx="760" cy="110" r="22" fill="#ffe07a"/>
<!-- Distant clouds -->
<g fill="#fff" opacity="0.88">
<ellipse cx="140" cy="90" rx="48" ry="18"/>
<ellipse cx="170" cy="82" rx="34" ry="16"/>
<ellipse cx="110" cy="86" rx="28" ry="12"/>
<ellipse cx="420" cy="70" rx="56" ry="16"/>
<ellipse cx="455" cy="62" rx="30" ry="14"/>
<ellipse cx="390" cy="66" rx="26" ry="11"/>
<ellipse cx="600" cy="100" rx="40" ry="13"/>
<ellipse cx="625" cy="94" rx="24" ry="11"/>
</g>
<!-- Sea -->
<rect y="310" width="900" height="160" fill="url(#sea)"/>
<!-- Waves -->
<g fill="none" stroke="#7ec4e8" stroke-width="2" opacity="0.55">
<path d="M0 330 Q40 320 80 330 T160 330 T240 330 T320 330 T400 330 T480 330 T560 330 T640 330 T720 330 T800 330 T880 330"/>
<path d="M0 350 Q50 338 100 350 T200 350 T300 350 T400 350 T500 350 T600 350 T700 350 T800 350 T900 350" opacity="0.7"/>
<path d="M0 372 Q45 362 90 372 T180 372 T270 372 T360 372 T450 372 T540 372 T630 372 T720 372 T810 372 T900 372"/>
</g>
<g fill="#a8dff5" opacity="0.35">
<ellipse cx="120" cy="340" rx="40" ry="5"/>
<ellipse cx="320" cy="355" rx="50" ry="4"/>
<ellipse cx="540" cy="338" rx="35" ry="4"/>
<ellipse cx="720" cy="360" rx="45" ry="5"/>
</g>
<!-- Horizon haze -->
<rect y="300" width="900" height="18" fill="#fff" opacity="0.15"/>
<!-- Beach -->
<path d="M0 450 C120 430 220 460 340 445 C460 430 560 470 700 448 C780 438 850 455 900 450 L900 600 L0 600 Z" fill="url(#sand)"/>
<!-- Sand texture -->
<g fill="#c9a66a" opacity="0.25">
<circle cx="60" cy="520" r="1.5"/><circle cx="90" cy="540" r="1"/><circle cx="130" cy="500" r="1.2"/>
<circle cx="200" cy="560" r="1"/><circle cx="250" cy="510" r="1.4"/><circle cx="310" cy="545" r="1"/>
<circle cx="400" cy="530" r="1.2"/><circle cx="480" cy="560" r="1"/><circle cx="550" cy="505" r="1.5"/>
<circle cx="620" cy="540" r="1"/><circle cx="700" cy="520" r="1.3"/><circle cx="780" cy="555" r="1"/>
<circle cx="840" cy="510" r="1.2"/><circle cx="160" cy="575" r="1"/><circle cx="360" cy="580" r="1.1"/>
</g>
<!-- Palm tree left -->
<g filter="url(#shadow)">
<path d="M95 470 C92 420 88 380 100 320" fill="none" stroke="#6b4423" stroke-width="14" stroke-linecap="round"/>
<path d="M95 470 C98 420 108 380 118 325" fill="none" stroke="#8a5a30" stroke-width="6" stroke-linecap="round" opacity="0.7"/>
<g fill="#2f8a3a">
<path d="M100 320 Q40 300 20 270 Q70 300 100 320"/>
<path d="M100 320 Q50 340 15 350 Q65 335 100 320"/>
<path d="M100 320 Q150 280 200 265 Q145 300 100 320"/>
<path d="M100 320 Q160 330 210 355 Q150 335 100 320"/>
<path d="M100 320 Q90 260 70 230 Q95 270 100 320"/>
<path d="M100 320 Q130 255 160 235 Q125 275 100 320"/>
</g>
<g fill="#24702e" opacity="0.85">
<path d="M100 325 Q55 310 35 290 Q75 310 100 325"/>
<path d="M100 325 Q145 305 175 290 Q140 315 100 325"/>
</g>
</g>
<!-- Coconuts -->
<g fill="#6b3f1f">
<circle cx="95" cy="328" r="6"/><circle cx="108" cy="332" r="5.5"/><circle cx="100" cy="340" r="5"/>
</g>
<!-- Distant palm right -->
<g opacity="0.85">
<path d="M820 460 C818 420 822 390 815 360" fill="none" stroke="#6b4423" stroke-width="10" stroke-linecap="round"/>
<g fill="#2f8a3a">
<path d="M815 360 Q770 345 755 320 Q785 345 815 360"/>
<path d="M815 360 Q860 340 885 325 Q850 350 815 360"/>
<path d="M815 360 Q800 320 790 300 Q810 330 815 360"/>
<path d="M815 360 Q845 325 860 305 Q835 335 815 360"/>
</g>
</g>
<!-- Seagulls -->
<g fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" opacity="0.9">
<path d="M300 120 Q315 110 330 120"/>
<path d="M330 120 Q345 110 360 122"/>
<path d="M500 95 Q512 86 524 95"/>
<path d="M524 95 Q536 86 548 96"/>
<path d="M650 140 Q660 132 670 140"/>
<path d="M670 140 Q680 132 690 141"/>
</g>
<!-- Foam line -->
<path d="M0 448 C80 438 160 458 250 445 C340 432 420 460 520 448 C620 436 720 458 820 446 C860 442 890 450 900 448" fill="none" stroke="#fff" stroke-width="4" opacity="0.55"/>
<!-- Shells & starfish -->
<g>
<path d="M150 530 Q158 518 168 530 Q158 538 150 530" fill="#e8c4a0" stroke="#c9a07a" stroke-width="1"/>
<path d="M720 545 Q728 534 738 545 Q728 552 720 545" fill="#f0d0b8" stroke="#d0a888" stroke-width="1"/>
<!-- starfish -->
<path d="M200 560 L205 548 L210 560 L222 562 L212 570 L215 582 L205 575 L195 582 L198 570 L188 562 Z" fill="#e07050"/>
<path d="M650 570 L654 560 L658 570 L668 572 L660 578 L662 588 L654 582 L646 588 L648 578 L640 572 Z" fill="#e88840"/>
<circle cx="280" cy="575" r="4" fill="#dcc0a0" stroke="#b89878" stroke-width="0.8"/>
<circle cx="560" cy="555" r="3.5" fill="#e8d0b0"/>
</g>
<!-- Bicycle + Pelican group -->
<g transform="translate(280 250)" filter="url(#shadow)">
<!-- Ground contact shadow -->
<ellipse cx="200" cy="268" rx="120" ry="10" fill="#000" opacity="0.12"/>
<!-- Bicycle frame -->
<g fill="none" stroke="#2a2e36" stroke-width="5" stroke-linejoin="round" stroke-linecap="round">
<!-- Rear triangle -->
<path d="M70 210 L150 210 L115 145 Z"/>
<!-- Front boom / top tube / down tube -->
<path d="M150 210 L230 145"/>
<!-- Seat tube extension already in triangle; head tube -->
<path d="M230 145 L250 210"/>
<!-- Seat post -->
<path d="M115 145 L115 120"/>
<!-- Handlebar stem -->
<path d="M230 145 L235 118"/>
<!-- Handlebars -->
<path d="M210 118 L260 118" stroke-width="4"/>
<path d="M210 118 Q205 130 208 142" stroke-width="3.5"/>
<path d="M260 118 Q265 130 262 142" stroke-width="3.5"/>
<!-- Fork -->
<path d="M250 210 L235 145" stroke-width="4"/>
</g>
<!-- Frame paint accents -->
<g fill="none" stroke="#c43c2a" stroke-width="3" stroke-linejoin="round" opacity="0.9">
<path d="M78 208 L145 208 L118 152"/>
<path d="M155 208 L222 152"/>
</g>
<!-- Seat -->
<ellipse cx="115" cy="116" rx="18" ry="7" fill="#2a2e36"/>
<ellipse cx="110" cy="114" rx="10" ry="4" fill="#444a54"/>
<!-- Chainring & chain -->
<circle cx="150" cy="210" r="16" fill="none" stroke="#4a505a" stroke-width="3"/>
<circle cx="150" cy="210" r="10" fill="none" stroke="#6a707a" stroke-width="2"/>
<g stroke="#5a606a" stroke-width="1.2">
<line x1="150" y1="194" x2="150" y2="226"/>
<line x1="134" y1="210" x2="166" y2="210"/>
<line x1="138" y1="198" x2="162" y2="222"/>
<line x1="162" y1="198" x2="138" y2="222"/>
</g>
<!-- Chain -->
<path d="M150 226 Q110 235 70 226" fill="none" stroke="#5a6068" stroke-width="2" stroke-dasharray="4 3"/>
<path d="M150 194 Q110 185 70 194" fill="none" stroke="#5a6068" stroke-width="2" stroke-dasharray="4 3"/>
<!-- Pedals / cranks -->
<g stroke="#2a2e36" stroke-width="3.5" stroke-linecap="round">
<line x1="150" y1="210" x2="175" y2="235"/>
<line x1="150" y1="210" x2="125" y2="185"/>
</g>
<rect x="168" y="232" width="16" height="6" rx="2" fill="#3a3e46" transform="rotate(25 176 235)"/>
<rect x="112" y="180" width="16" height="6" rx="2" fill="#3a3e46" transform="rotate(25 120 183)"/>
<!-- Rear wheel -->
<g transform="translate(70 210)">
<circle r="48" fill="none" stroke="#1a1e24" stroke-width="6"/>
<circle r="42" fill="none" stroke="#c8ced6" stroke-width="2.5"/>
<circle r="8" fill="#2a2e36" stroke="#6a7078" stroke-width="2"/>
<g stroke="#6a727c" stroke-width="1.2" opacity="0.9">
<line x1="0" y1="-42" x2="0" y2="42"/>
<line x1="-42" y1="0" x2="42" y2="0"/>
<line x1="-30" y1="-30" x2="30" y2="30"/>
<line x1="30" y1="-30" x2="-30" y2="30"/>
<line x1="-15" y1="-39" x2="15" y2="39"/>
<line x1="15" y1="-39" x2="-15" y2="39"/>
<line x1="-39" y1="-15" x2="39" y2="15"/>
<line x1="39" y1="-15" x2="-39" y2="15"/>
</g>
<!-- tire tread hint -->
<circle r="48" fill="none" stroke="#3a3e46" stroke-width="1.5" stroke-dasharray="3 8" opacity="0.5"/>
</g>
<!-- Front wheel -->
<g transform="translate(250 210)">
<circle r="48" fill="none" stroke="#1a1e24" stroke-width="6"/>
<circle r="42" fill="none" stroke="#c8ced6" stroke-width="2.5"/>
<circle r="8" fill="#2a2e36" stroke="#6a7078" stroke-width="2"/>
<g stroke="#6a727c" stroke-width="1.2" opacity="0.9">
<line x1="0" y1="-42" x2="0" y2="42"/>
<line x1="-42" y1="0" x2="42" y2="0"/>
<line x1="-30" y1="-30" x2="30" y2="30"/>
<line x1="30" y1="-30" x2="-30" y2="30"/>
<line x1="-15" y1="-39" x2="15" y2="39"/>
<line x1="15" y1="-39" x2="-15" y2="39"/>
<line x1="-39" y1="-15" x2="39" y2="15"/>
<line x1="39" y1="-15" x2="-39" y2="15"/>
</g>
<circle r="48" fill="none" stroke="#3a3e46" stroke-width="1.5" stroke-dasharray="3 8" opacity="0.5"/>
</g>
<!-- Pelican body -->
<g>
<!-- Tail feathers -->
<g fill="#c8c0ae">
<path d="M55 150 Q30 145 18 160 Q35 155 55 158 Z"/>
<path d="M55 158 Q28 162 22 178 Q40 168 58 165 Z"/>
<path d="M58 155 Q40 175 48 190 Q60 170 62 160 Z"/>
</g>
<!-- Body -->
<ellipse cx="110" cy="145" rx="58" ry="36" fill="url(#pelBody)"/>
<ellipse cx="120" cy="140" rx="40" ry="24" fill="#faf8f3" opacity="0.55"/>
<!-- Far wing (behind) -->
<path d="M90 130 Q70 90 95 70 Q120 85 125 120 Q110 125 90 130 Z" fill="#d8d2c4" opacity="0.9"/>
<!-- Near wing raised in pedaling pose -->
<path d="M100 135 Q130 80 175 75 Q165 110 145 130 Q125 140 100 135 Z" fill="url(#pelWing)"/>
<path d="M110 132 Q140 95 165 90" fill="none" stroke="#a89f8c" stroke-width="1.5" opacity="0.7"/>
<path d="M115 138 Q145 105 168 100" fill="none" stroke="#a89f8c" stroke-width="1.2" opacity="0.55"/>
<!-- primary feathers tip -->
<path d="M160 78 Q180 72 188 88 Q170 90 160 85 Z" fill="#6a6256"/>
<path d="M155 85 Q175 82 182 98 Q162 95 155 90 Z" fill="#7a7266"/>
<!-- Neck -->
<path d="M155 140 C190 125 205 95 195 70 C188 55 170 55 160 68" fill="url(#pelBody)"/>
<path d="M162 138 C185 125 198 100 190 75" fill="none" stroke="#d2cbb8" stroke-width="8" stroke-linecap="round" opacity="0.5"/>
<!-- Head -->
<ellipse cx="168" cy="62" rx="22" ry="18" fill="url(#pelBody)"/>
<ellipse cx="172" cy="58" rx="14" ry="11" fill="#faf8f3" opacity="0.5"/>
<!-- Eye -->
<circle cx="178" cy="58" r="5.5" fill="#1a1e24"/>
<circle cx="180" cy="56.5" r="2" fill="#fff"/>
<circle cx="176.5" cy="59" r="1.2" fill="#4a90c8" opacity="0.7"/>
<!-- eye ring -->
<circle cx="178" cy="58" r="6.5" fill="none" stroke="#c4a060" stroke-width="1.2"/>
<!-- Long beak -->
<path d="M185 60 L270 72 L185 78 Z" fill="url(#beak)"/>
<path d="M185 60 L270 72 L250 70 L185 68 Z" fill="#f5b85a" opacity="0.5"/>
<!-- upper beak ridge -->
<path d="M188 62 L265 71" fill="none" stroke="#b86a20" stroke-width="1.2" opacity="0.6"/>
<!-- gular pouch -->
<path d="M188 78 Q220 105 265 78 Q220 88 188 78 Z" fill="url(#pouch)"/>
<path d="M195 82 Q220 96 252 84" fill="none" stroke="#c07030" stroke-width="1" opacity="0.45"/>
<!-- Legs pedaling -->
<!-- Front leg (to forward pedal) -->
<path d="M125 170 L155 200 L175 235" fill="none" stroke="#d8c8a0" stroke-width="7" stroke-linecap="round"/>
<path d="M125 170 L155 200 L175 235" fill="none" stroke="#c4b088" stroke-width="3" stroke-linecap="round" opacity="0.5"/>
<!-- webbed foot on pedal -->
<ellipse cx="176" cy="238" rx="10" ry="5" fill="#e8c878" transform="rotate(20 176 238)"/>
<g fill="#d4b060" opacity="0.8">
<ellipse cx="170" cy="236" rx="3" ry="5" transform="rotate(-10 170 236)"/>
<ellipse cx="182" cy="240" rx="3" ry="5" transform="rotate(35 182 240)"/>
</g>
<!-- Rear leg (to back pedal) -->
<path d="M95 175 L110 200 L125 185" fill="none" stroke="#c8b890" stroke-width="7" stroke-linecap="round"/>
<ellipse cx="122" cy="183" rx="9" ry="4.5" fill="#e8c878" transform="rotate(-30 122 183)"/>
<!-- Chest fluff detail -->
<path d="M80 145 Q100 155 130 150" fill="none" stroke="#d0c8b8" stroke-width="1.5" opacity="0.6"/>
<path d="M85 155 Q110 162 135 155" fill="none" stroke="#d0c8b8" stroke-width="1.2" opacity="0.45"/>
</g>
<!-- Wing-hand on handlebar suggestion -->
<g>
<path d="M145 125 Q180 110 220 115" fill="none" stroke="#d2cbb8" stroke-width="5" stroke-linecap="round" opacity="0.85"/>
<ellipse cx="228" cy="116" rx="8" ry="5" fill="#e8e0d0" transform="rotate(-10 228 116)"/>
</g>
</g>
<!-- Footprints in sand -->
<g fill="#c9a66a" opacity="0.35">
<ellipse cx="220" cy="510" rx="8" ry="3" transform="rotate(-15 220 510)"/>
<ellipse cx="245" cy="518" rx="8" ry="3" transform="rotate(-10 245 518)"/>
<ellipse cx="268" cy="525" rx="7" ry="2.5" transform="rotate(-5 268 525)"/>
</g>
<!-- Subtle water sparkles -->
<g fill="#fff" opacity="0.4">
<circle cx="100" cy="380" r="1.5"/><circle cx="250" cy="395" r="1"/><circle cx="400" cy="375" r="1.2"/>
<circle cx="580" cy="390" r="1"/><circle cx="750" cy="380" r="1.4"/><circle cx="330" cy="410" r="1"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,679 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Skeleton Watch</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #05060a; font-family: "Segoe UI", system-ui, sans-serif; color: #e8e6df; }
canvas { display: block; width: 100%; height: 100%; }
#ui {
position: fixed; inset: 0; pointer-events: none; z-index: 10;
}
.panel {
pointer-events: auto; position: absolute; top: 20px; left: 20px;
width: min(300px, 86vw); padding: 16px 18px;
background: linear-gradient(160deg, rgba(22,24,32,0.92), rgba(10,12,18,0.95));
border: 1px solid rgba(255,255,255,0.1); border-radius: 14px;
box-shadow: 0 16px 40px rgba(0,0,0,0.45); backdrop-filter: blur(10px);
}
.panel h1 {
font-size: 15px; letter-spacing: 0.18em; text-transform: uppercase;
color: #d4af37; margin-bottom: 4px; font-weight: 600;
}
.panel .sub { font-size: 11px; color: rgba(232,230,223,0.55); margin-bottom: 12px; }
.row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
button {
pointer-events: auto; border: 1px solid rgba(212,175,55,0.35); background: rgba(212,175,55,0.08);
color: #e8e6df; padding: 7px 12px; border-radius: 8px; font-size: 12px; cursor: pointer;
transition: background .15s, border-color .15s;
}
button:hover, button.active {
background: rgba(212,175,55,0.22); border-color: #d4af37;
}
#info {
font-size: 12px; line-height: 1.55; color: rgba(232,230,223,0.78);
min-height: 72px; padding-top: 6px; border-top: 1px solid rgba(255,255,255,0.08);
}
#info b { color: #d4af37; }
#hint {
position: absolute; bottom: 18px; left: 50%; transform: translateX(-50%);
font-size: 12px; color: rgba(255,255,255,0.4); letter-spacing: 0.04em;
}
#label {
position: absolute; pointer-events: none; padding: 4px 10px; border-radius: 6px;
background: rgba(8,10,16,0.85); border: 1px solid rgba(212,175,55,0.4);
font-size: 11px; color: #f0e6c8; display: none; white-space: nowrap;
transform: translate(-50%, -130%);
}
</style>
</head>
<body>
<div id="ui">
<div class="panel">
<h1>Skeleton Watch</h1>
<div class="sub">Interactive 3D movement · drag to orbit</div>
<div class="row">
<button id="btn-explode" class="active">Exploded</button>
<button id="btn-assembled">Assembled</button>
<button id="btn-auto">Auto-rotate</button>
</div>
<div class="row">
<button id="btn-reset">Reset view</button>
</div>
<div id="info">Hover a part for details. Click to pin a selection.</div>
</div>
<div id="hint">Drag · scroll zoom · click parts</div>
<div id="label"></div>
</div>
<script type="importmap">
{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js"}}
</script>
<script type="module">
import * as THREE from 'three';
const canvas = document.createElement('canvas');
document.body.appendChild(canvas);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x07080d);
scene.fog = new THREE.FogExp2(0x07080d, 0.018);
const camera = new THREE.PerspectiveCamera(38, innerWidth / innerHeight, 0.1, 100);
camera.position.set(4.2, 2.4, 5.2);
// Manual orbit
const orbit = { target: new THREE.Vector3(0, 0, 0), spherical: new THREE.Spherical(7.2, 1.15, 0.55), dragging: false, auto: true };
function updateCamera() {
const p = new THREE.Vector3().setFromSpherical(orbit.spherical).add(orbit.target);
camera.position.copy(p);
camera.lookAt(orbit.target);
}
updateCamera();
// Lights
const amb = new THREE.AmbientLight(0x2a3040, 0.45);
scene.add(amb);
const key = new THREE.DirectionalLight(0xfff2dc, 2.2);
key.position.set(5, 8, 4);
key.castShadow = true;
key.shadow.mapSize.set(2048, 2048);
key.shadow.camera.near = 1; key.shadow.camera.far = 30;
key.shadow.camera.left = key.shadow.camera.bottom = -6;
key.shadow.camera.right = key.shadow.camera.top = 6;
scene.add(key);
const fill = new THREE.DirectionalLight(0x88aaff, 0.55);
fill.position.set(-4, 2, -3);
scene.add(fill);
const rim = new THREE.DirectionalLight(0xffcc88, 0.4);
rim.position.set(-2, 4, 6);
scene.add(rim);
const hemi = new THREE.HemisphereLight(0x606880, 0x1a1510, 0.35);
scene.add(hemi);
// Floor reflection plane
const floor = new THREE.Mesh(
new THREE.CircleGeometry(4.5, 64),
new THREE.MeshStandardMaterial({ color: 0x0c0e14, metalness: 0.85, roughness: 0.25, envMapIntensity: 0.5 })
);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -1.35;
floor.receiveShadow = true;
scene.add(floor);
// Soft ground glow
const glow = new THREE.Mesh(
new THREE.RingGeometry(0.6, 2.8, 64),
new THREE.MeshBasicMaterial({ color: 0x2a2210, transparent: true, opacity: 0.35, side: THREE.DoubleSide })
);
glow.rotation.x = -Math.PI / 2;
glow.position.y = -1.34;
scene.add(glow);
// Materials
const matSteel = new THREE.MeshPhysicalMaterial({
color: 0xb8c0c8, metalness: 0.95, roughness: 0.22, clearcoat: 0.4, clearcoatRoughness: 0.2,
});
const matPolished = new THREE.MeshPhysicalMaterial({
color: 0xd8dee6, metalness: 1.0, roughness: 0.12, clearcoat: 0.6, clearcoatRoughness: 0.1,
});
const matGold = new THREE.MeshPhysicalMaterial({
color: 0xd4af37, metalness: 1.0, roughness: 0.18, clearcoat: 0.35,
});
const matRose = new THREE.MeshPhysicalMaterial({
color: 0xb76e4d, metalness: 1.0, roughness: 0.2,
});
const matBrass = new THREE.MeshPhysicalMaterial({
color: 0xc4a35a, metalness: 0.95, roughness: 0.25,
});
const matRuby = new THREE.MeshPhysicalMaterial({
color: 0xa01830, metalness: 0.15, roughness: 0.12, transmission: 0.35, thickness: 0.4,
transparent: true, opacity: 0.92, emissive: 0x300010, emissiveIntensity: 0.15,
});
const matCrystal = new THREE.MeshPhysicalMaterial({
color: 0xaaccff, metalness: 0.05, roughness: 0.05, transmission: 0.92, thickness: 0.6,
transparent: true, opacity: 0.35, ior: 1.45, clearcoat: 1,
});
const matLume = new THREE.MeshStandardMaterial({
color: 0xc8f0a0, emissive: 0x88cc44, emissiveIntensity: 0.85, roughness: 0.4, metalness: 0.1,
});
const matBlue = new THREE.MeshPhysicalMaterial({
color: 0x1a3a6a, metalness: 0.7, roughness: 0.25, emissive: 0x0a1a30, emissiveIntensity: 0.2,
});
const matBlued = new THREE.MeshPhysicalMaterial({
color: 0x1e3a5f, metalness: 0.9, roughness: 0.2,
});
const matDark = new THREE.MeshPhysicalMaterial({
color: 0x2a2e36, metalness: 0.85, roughness: 0.35,
});
const watch = new THREE.Group();
scene.add(watch);
const parts = []; // { mesh, name, desc, home, explode }
const pickables = [];
function addPart(mesh, name, desc, explodeOffset) {
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.userData = { name, desc };
const home = mesh.position.clone();
const explode = home.clone().add(explodeOffset || new THREE.Vector3(0, 0, 0));
watch.add(mesh);
parts.push({ mesh, name, desc, home, explode });
pickables.push(mesh);
return mesh;
}
function gear({ teeth = 16, radius = 0.4, thickness = 0.06, toothDepth = 0.06, material = matBrass, hub = 0.12 }) {
const g = new THREE.Group();
const body = new THREE.Mesh(new THREE.CylinderGeometry(radius - toothDepth * 0.4, radius - toothDepth * 0.4, thickness, 48), material);
body.rotation.x = Math.PI / 2;
g.add(body);
const toothW = (Math.PI * 2 * radius) / teeth * 0.45;
for (let i = 0; i < teeth; i++) {
const a = (i / teeth) * Math.PI * 2;
const t = new THREE.Mesh(
new THREE.BoxGeometry(toothDepth, toothW, thickness * 0.95),
material
);
t.position.set(Math.cos(a) * (radius - toothDepth * 0.5), Math.sin(a) * (radius - toothDepth * 0.5), 0);
t.rotation.z = a;
g.add(t);
}
const hubM = new THREE.Mesh(new THREE.CylinderGeometry(hub, hub, thickness + 0.02, 24), matPolished);
hubM.rotation.x = Math.PI / 2;
g.add(hubM);
// spoke holes illusion
for (let i = 0; i < 4; i++) {
const a = (i / 4) * Math.PI * 2 + 0.4;
const hole = new THREE.Mesh(
new THREE.CylinderGeometry(radius * 0.12, radius * 0.12, thickness + 0.04, 12),
matDark
);
hole.rotation.x = Math.PI / 2;
hole.position.set(Math.cos(a) * radius * 0.45, Math.sin(a) * radius * 0.45, 0);
g.add(hole);
}
return g;
}
function rubyBearing(r = 0.045) {
return new THREE.Mesh(new THREE.CylinderGeometry(r, r, 0.035, 20), matRuby);
}
function bridge(w, h, d, material = matSteel) {
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material);
return m;
}
// --- Case ---
const caseOuter = new THREE.Mesh(
new THREE.CylinderGeometry(1.55, 1.55, 0.42, 64),
matPolished
);
caseOuter.rotation.x = Math.PI / 2;
addPart(caseOuter, 'Case', 'Polished stainless steel case mid-section.', new THREE.Vector3(0, 0, -1.8));
const caseBezel = new THREE.Mesh(
new THREE.TorusGeometry(1.52, 0.08, 16, 64),
matGold
);
addPart(caseBezel, 'Bezel', '18k gold-tone bezel ring.', new THREE.Vector3(0, 0, -1.4));
// Lugs
for (let i = 0; i < 4; i++) {
const side = i < 2 ? 1 : -1;
const y = i % 2 === 0 ? 0.55 : -0.55;
const lug = new THREE.Mesh(new THREE.BoxGeometry(0.28, 0.18, 0.45), matPolished);
lug.position.set(side * 1.55, y, 0);
lug.rotation.z = side * 0.15;
addPart(lug, 'Lug', 'Case lug for strap attachment.', new THREE.Vector3(side * 1.2, y * 0.3, -0.6));
}
// Crystal
const crystal = new THREE.Mesh(new THREE.CylinderGeometry(1.38, 1.38, 0.08, 64), matCrystal);
crystal.rotation.x = Math.PI / 2;
crystal.position.z = 0.28;
addPart(crystal, 'Sapphire Crystal', 'Domed sapphire crystal with AR coating.', new THREE.Vector3(0, 0, 1.6));
// Case back ring
const backRing = new THREE.Mesh(new THREE.TorusGeometry(1.35, 0.06, 12, 48), matSteel);
backRing.position.z = -0.28;
addPart(backRing, 'Case Back', 'Exhibition case back ring.', new THREE.Vector3(0, 0, -2.2));
// --- Main plate ---
const mainPlate = new THREE.Mesh(new THREE.CylinderGeometry(1.25, 1.25, 0.08, 64), matRose);
mainPlate.rotation.x = Math.PI / 2;
mainPlate.position.z = -0.12;
addPart(mainPlate, 'Main Plate', 'Rose-gold main plate supporting the movement.', new THREE.Vector3(0, -1.1, 0));
// Decorative perlage dots on plate
for (let i = 0; i < 18; i++) {
const a = (i / 18) * Math.PI * 2;
const d = new THREE.Mesh(new THREE.CircleGeometry(0.12, 16), new THREE.MeshStandardMaterial({ color: 0xa05a40, metalness: 0.8, roughness: 0.3 }));
d.position.set(Math.cos(a) * 0.85, Math.sin(a) * 0.85, -0.078);
watch.add(d);
}
// --- Gear train ---
const gearTrain = [
{ teeth: 12, r: 0.22, pos: [0.35, 0.15, 0.02], mat: matBrass, name: 'Center Wheel', desc: 'Drives the minute hand — one revolution per hour.', exp: [0.6, 0.9, 0.5], spin: 1 },
{ teeth: 18, r: 0.32, pos: [0.0, -0.35, 0.05], mat: matBrass, name: 'Third Wheel', desc: 'Intermediate wheel in the going train.', exp: [-0.3, -1.0, 0.7], spin: -1.6 },
{ teeth: 14, r: 0.24, pos: [-0.4, 0.05, 0.08], mat: matGold, name: 'Fourth Wheel', desc: 'Seconds wheel — typically one rev per minute.', exp: [-1.0, 0.4, 0.9], spin: 3.2 },
{ teeth: 22, r: 0.42, pos: [0.15, 0.55, -0.02], mat: matBrass, name: 'Barrel Wheel', desc: 'Mainspring barrel delivering stored energy.', exp: [0.4, 1.3, 0.3], spin: 0.25 },
];
const spinning = [];
for (const g of gearTrain) {
const mesh = gear({ teeth: g.teeth, radius: g.r, thickness: 0.07, material: g.mat, hub: 0.08 });
mesh.position.set(...g.pos);
addPart(mesh, g.name, g.desc, new THREE.Vector3(...g.exp));
spinning.push({ mesh, speed: g.spin });
// jewel under hub
const jewel = rubyBearing(0.04);
jewel.rotation.x = Math.PI / 2;
jewel.position.set(g.pos[0], g.pos[1], g.pos[2] - 0.05);
addPart(jewel, 'Ruby Bearing', 'Synthetic ruby jewel reducing friction at the pivot.', new THREE.Vector3(...g.exp).add(new THREE.Vector3(0, 0, -0.15)));
}
// Escape wheel
const escape = gear({ teeth: 15, radius: 0.18, thickness: 0.05, toothDepth: 0.05, material: matSteel, hub: 0.05 });
escape.position.set(-0.55, -0.35, 0.12);
addPart(escape, 'Escape Wheel', 'Impulse wheel of the Swiss lever escapement.', new THREE.Vector3(-1.3, -0.8, 1.0));
spinning.push({ mesh: escape, speed: 6 });
// Pallet fork
const pallet = new THREE.Group();
const lever = new THREE.Mesh(new THREE.BoxGeometry(0.28, 0.05, 0.04), matSteel);
pallet.add(lever);
const arm1 = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.03, 0.03), matSteel);
arm1.position.set(0.12, 0.06, 0); arm1.rotation.z = 0.5; pallet.add(arm1);
const arm2 = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.03, 0.03), matSteel);
arm2.position.set(0.12, -0.06, 0); arm2.rotation.z = -0.5; pallet.add(arm2);
const stone1 = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.03, 0.03), matRuby);
stone1.position.set(0.18, 0.08, 0); pallet.add(stone1);
const stone2 = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.03, 0.03), matRuby);
stone2.position.set(0.18, -0.08, 0); pallet.add(stone2);
pallet.position.set(-0.72, -0.2, 0.14);
addPart(pallet, 'Pallet Fork', 'Swiss lever with ruby pallet stones.', new THREE.Vector3(-1.5, -0.4, 1.2));
// Balance wheel + hairspring
const balanceGroup = new THREE.Group();
const balanceRim = new THREE.Mesh(new THREE.TorusGeometry(0.28, 0.035, 12, 48), matBlued);
balanceGroup.add(balanceRim);
const balanceSpoke1 = new THREE.Mesh(new THREE.BoxGeometry(0.52, 0.03, 0.025), matBlued);
balanceGroup.add(balanceSpoke1);
const balanceSpoke2 = new THREE.Mesh(new THREE.BoxGeometry(0.03, 0.52, 0.025), matBlued);
balanceGroup.add(balanceSpoke2);
// timing screws
for (let i = 0; i < 8; i++) {
const a = (i / 8) * Math.PI * 2;
const sc = new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.02, 0.05, 8), matGold);
sc.rotation.x = Math.PI / 2;
sc.position.set(Math.cos(a) * 0.28, Math.sin(a) * 0.28, 0);
balanceGroup.add(sc);
}
// hairspring
const springPts = [];
for (let i = 0; i < 80; i++) {
const t = i / 80;
const ang = t * Math.PI * 6;
const rad = 0.04 + t * 0.2;
springPts.push(new THREE.Vector3(Math.cos(ang) * rad, Math.sin(ang) * rad, 0.02));
}
const springGeo = new THREE.TubeGeometry(new THREE.CatmullRomCurve3(springPts), 120, 0.008, 6, false);
const hairspring = new THREE.Mesh(springGeo, matBlued);
balanceGroup.add(hairspring);
// balance staff
const staff = new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.02, 0.2, 12), matPolished);
staff.rotation.x = Math.PI / 2;
balanceGroup.add(staff);
balanceGroup.position.set(-0.35, 0.45, 0.18);
addPart(balanceGroup, 'Balance Wheel', 'Blued balance with Breguet-style hairspring — the timekeeping oscillator.', new THREE.Vector3(-0.8, 1.2, 1.4));
spinning.push({ mesh: balanceGroup, speed: 8, oscillate: true });
// Mainspring barrel interior hint
const barrel = new THREE.Mesh(new THREE.CylinderGeometry(0.38, 0.38, 0.12, 32), matGold);
barrel.rotation.x = Math.PI / 2;
barrel.position.set(0.15, 0.55, -0.08);
addPart(barrel, 'Mainspring Barrel', 'Houses the coiled mainspring energy source.', new THREE.Vector3(0.5, 1.5, -0.2));
// Bridges
const bridge1 = bridge(1.1, 0.22, 0.05, matSteel);
bridge1.position.set(0.2, -0.2, 0.16);
bridge1.rotation.z = 0.4;
addPart(bridge1, 'Train Bridge', 'Steel bridge securing the gear train pivots.', new THREE.Vector3(0.3, -1.4, 0.8));
const bridge2 = bridge(0.7, 0.18, 0.05, matSteel);
bridge2.position.set(-0.4, 0.35, 0.2);
bridge2.rotation.z = -0.5;
addPart(bridge2, 'Balance Cock', 'Cock bridge holding the balance upper jewel.', new THREE.Vector3(-1.1, 1.0, 1.1));
const bridge3 = bridge(0.9, 0.16, 0.045, matPolished);
bridge3.position.set(0.0, 0.55, 0.1);
addPart(bridge3, 'Barrel Bridge', 'Bridge over the mainspring barrel.', new THREE.Vector3(0.2, 1.6, 0.5));
// More jewels on bridges
for (const [x, y, z] of [[0.35, 0.15, 0.18], [0.0, -0.35, 0.18], [-0.4, 0.05, 0.2], [-0.35, 0.45, 0.28]]) {
const j = rubyBearing(0.035);
j.rotation.x = Math.PI / 2;
j.position.set(x, y, z);
addPart(j, 'Ruby Jewel', 'Oil-retaining ruby bearing.', new THREE.Vector3(x * 1.5, y * 1.5, z + 1.0));
}
// Crown
const crown = new THREE.Group();
const crownBody = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.12, 0.16, 20), matGold);
crownBody.rotation.z = Math.PI / 2;
crown.add(crownBody);
const crownGrip = new THREE.Mesh(new THREE.CylinderGeometry(0.14, 0.14, 0.08, 20), matGold);
crownGrip.rotation.z = Math.PI / 2;
crownGrip.position.x = 0.1;
crown.add(crownGrip);
// flutes
for (let i = 0; i < 12; i++) {
const a = (i / 12) * Math.PI * 2;
const f = new THREE.Mesh(new THREE.BoxGeometry(0.02, 0.08, 0.03), matRose);
f.position.set(0.1, Math.cos(a) * 0.13, Math.sin(a) * 0.13);
crown.add(f);
}
crown.position.set(1.7, 0, 0);
addPart(crown, 'Crown', 'Winding and setting crown.', new THREE.Vector3(2.4, 0, 0.2));
// Stem
const stem = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.03, 0.5, 12), matSteel);
stem.rotation.z = Math.PI / 2;
stem.position.set(1.35, 0, 0);
addPart(stem, 'Winding Stem', 'Connects crown to keyless works.', new THREE.Vector3(2.0, 0, 0.4));
// Dial ring + markers (skeleton so partial dial)
const dialRing = new THREE.Mesh(new THREE.TorusGeometry(1.2, 0.04, 12, 64), matDark);
dialRing.position.z = 0.22;
addPart(dialRing, 'Chapter Ring', 'Minute track chapter ring.', new THREE.Vector3(0, 0, 1.3));
// Hour markers
for (let i = 0; i < 12; i++) {
const a = (i / 12) * Math.PI * 2 - Math.PI / 2;
const isMain = i % 3 === 0;
const m = new THREE.Mesh(
new THREE.BoxGeometry(isMain ? 0.08 : 0.04, isMain ? 0.18 : 0.1, 0.03),
matLume
);
m.position.set(Math.cos(a) * 1.15, Math.sin(a) * 1.15, 0.24);
m.rotation.z = a + Math.PI / 2;
addPart(m, 'Luminous Marker', 'Super-LumiNova hour marker.', new THREE.Vector3(Math.cos(a) * 0.5, Math.sin(a) * 0.5, 1.5));
}
// Hands
const handPivot = new THREE.Group();
handPivot.position.z = 0.26;
const hourHand = new THREE.Group();
const hh = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.55, 0.03), matGold);
hh.position.y = 0.25;
hourHand.add(hh);
const hhLume = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.35, 0.02), matLume);
hhLume.position.y = 0.28; hhLume.position.z = 0.02;
hourHand.add(hhLume);
handPivot.add(hourHand);
const minuteHand = new THREE.Group();
const mh = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.85, 0.025), matGold);
mh.position.y = 0.4;
minuteHand.add(mh);
const mhLume = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.55, 0.018), matLume);
mhLume.position.y = 0.42; mhLume.position.z = 0.02;
minuteHand.add(mhLume);
handPivot.add(minuteHand);
const secondHand = new THREE.Group();
const sh = new THREE.Mesh(new THREE.BoxGeometry(0.02, 1.0, 0.015), matRose);
sh.position.y = 0.35;
secondHand.add(sh);
const shTip = new THREE.Mesh(new THREE.CircleGeometry(0.04, 12), matRose);
shTip.position.y = 0.9; shTip.position.z = 0.01;
secondHand.add(shTip);
const shCounter = new THREE.Mesh(new THREE.BoxGeometry(0.02, 0.2, 0.015), matRose);
shCounter.position.y = -0.12;
secondHand.add(shCounter);
handPivot.add(secondHand);
const handHub = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 0.05, 16), matPolished);
handHub.rotation.x = Math.PI / 2;
handPivot.add(handHub);
addPart(handPivot, 'Hands', 'Luminous hour, minute and seconds hands.', new THREE.Vector3(0, 0, 1.9));
spinning.push({ mesh: hourHand, speed: 0.1 });
spinning.push({ mesh: minuteHand, speed: 1.2 });
spinning.push({ mesh: secondHand, speed: 12 });
// Strap hints (short)
for (const side of [-1, 1]) {
const strap = new THREE.Mesh(
new THREE.BoxGeometry(0.7, 0.55, 0.08),
new THREE.MeshStandardMaterial({ color: 0x1a1210, roughness: 0.7, metalness: 0.1 })
);
strap.position.set(0, side * 1.95, -0.05);
addPart(strap, 'Leather Strap', 'Hand-stitched alligator-pattern strap end.', new THREE.Vector3(0, side * 1.0, -0.8));
}
// Explode state
let explodeAmount = 1; // 0 assembled, 1 exploded
let explodeTarget = 1;
function applyExplode(t) {
for (const p of parts) {
p.mesh.position.lerpVectors(p.home, p.explode, t);
}
}
// Interaction
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
let selected = null;
const labelEl = document.getElementById('label');
const infoEl = document.getElementById('info');
function setInfo(name, desc) {
infoEl.innerHTML = `<b>${name}</b><br/>${desc}`;
}
function highlight(mesh, on) {
mesh.traverse?.((c) => {
if (c.isMesh && c.material && c.material.emissive) {
if (!c.userData._em) c.userData._em = c.material.emissive.getHex();
if (!c.userData._ei) c.userData._ei = c.material.emissiveIntensity || 0;
if (on) {
c.material.emissive.setHex(0x443308);
c.material.emissiveIntensity = 0.35;
} else {
c.material.emissive.setHex(c.userData._em);
c.material.emissiveIntensity = c.userData._ei;
}
}
});
if (mesh.isMesh && mesh.material && mesh.material.emissive) {
if (!mesh.userData._em) mesh.userData._em = mesh.material.emissive.getHex();
if (!mesh.userData._ei) mesh.userData._ei = mesh.material.emissiveIntensity || 0;
if (on) {
mesh.material.emissive.setHex(0x443308);
mesh.material.emissiveIntensity = 0.45;
} else {
mesh.material.emissive.setHex(mesh.userData._em);
mesh.material.emissiveIntensity = mesh.userData._ei;
}
}
}
function pick(clientX, clientY) {
mouse.x = (clientX / innerWidth) * 2 - 1;
mouse.y = -(clientY / innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const hits = raycaster.intersectObjects(pickables, true);
if (!hits.length) return null;
let obj = hits[0].object;
while (obj.parent && !obj.userData?.name && obj.parent !== watch) obj = obj.parent;
// find part with matching mesh root
for (const p of parts) {
if (p.mesh === obj || p.mesh.children?.includes?.(hits[0].object) || isDescendant(p.mesh, hits[0].object)) {
return { part: p, point: hits[0].point };
}
}
if (obj.userData?.name) {
return { part: { mesh: obj, name: obj.userData.name, desc: obj.userData.desc }, point: hits[0].point };
}
return null;
}
function isDescendant(root, node) {
let n = node;
while (n) { if (n === root) return true; n = n.parent; }
return false;
}
let hover = null;
window.addEventListener('pointermove', (e) => {
if (orbit.dragging) return;
const res = pick(e.clientX, e.clientY);
if (hover && hover !== selected) highlight(hover.mesh, false);
if (res) {
hover = res.part;
if (hover !== selected) highlight(hover.mesh, true);
labelEl.style.display = 'block';
labelEl.textContent = hover.name;
labelEl.style.left = e.clientX + 'px';
labelEl.style.top = e.clientY + 'px';
} else {
if (hover && hover !== selected) highlight(hover.mesh, false);
hover = null;
if (!selected) labelEl.style.display = 'none';
}
});
window.addEventListener('pointerdown', (e) => {
if (e.button === 0 && e.target.tagName !== 'BUTTON') {
orbit.dragging = true;
orbit.auto = false;
document.getElementById('btn-auto').classList.remove('active');
}
});
window.addEventListener('pointerup', (e) => {
if (orbit.dragging) {
orbit.dragging = false;
// click select if little movement — handled separately
}
});
window.addEventListener('click', (e) => {
if (e.target.tagName === 'BUTTON') return;
const res = pick(e.clientX, e.clientY);
if (selected) highlight(selected.mesh, false);
if (res) {
selected = res.part;
highlight(selected.mesh, true);
setInfo(selected.name, selected.desc);
labelEl.style.display = 'block';
labelEl.textContent = selected.name;
} else {
selected = null;
setInfo('Skeleton Watch', 'Hover a part for details. Click to pin a selection.');
labelEl.style.display = 'none';
}
});
window.addEventListener('pointermove', (e) => {
if (!orbit.dragging) return;
orbit.spherical.theta -= e.movementX * 0.005;
orbit.spherical.phi -= e.movementY * 0.005;
orbit.spherical.phi = Math.max(0.2, Math.min(Math.PI - 0.2, orbit.spherical.phi));
updateCamera();
});
window.addEventListener('wheel', (e) => {
orbit.spherical.radius *= e.deltaY > 0 ? 1.08 : 0.92;
orbit.spherical.radius = Math.max(3.5, Math.min(14, orbit.spherical.radius));
updateCamera();
}, { passive: true });
document.getElementById('btn-explode').addEventListener('click', () => {
explodeTarget = 1;
document.getElementById('btn-explode').classList.add('active');
document.getElementById('btn-assembled').classList.remove('active');
});
document.getElementById('btn-assembled').addEventListener('click', () => {
explodeTarget = 0;
document.getElementById('btn-assembled').classList.add('active');
document.getElementById('btn-explode').classList.remove('active');
});
document.getElementById('btn-auto').addEventListener('click', (e) => {
orbit.auto = !orbit.auto;
e.currentTarget.classList.toggle('active', orbit.auto);
});
document.getElementById('btn-reset').addEventListener('click', () => {
orbit.spherical.set(7.2, 1.15, 0.55);
updateCamera();
});
window.addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
// Animate
applyExplode(1);
let last = performance.now();
function loop(now) {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
explodeAmount += (explodeTarget - explodeAmount) * Math.min(1, dt * 3);
applyExplode(explodeAmount);
if (orbit.auto) {
orbit.spherical.theta += dt * 0.25;
updateCamera();
}
const t = now * 0.001;
for (const s of spinning) {
if (s.oscillate) {
s.mesh.rotation.z = Math.sin(t * s.speed) * 0.7;
} else {
s.mesh.rotation.z -= s.speed * dt * 0.4;
}
}
// subtle lume pulse
matLume.emissiveIntensity = 0.7 + Math.sin(t * 1.5) * 0.2;
renderer.render(scene, camera);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
</body>
</html>