feat: add Grok 4.5 benchmark results (#31)
Co-authored-by: DuJiabao <Jiabao.du@Yunzhisun.com>
This commit is contained in:
1 parent
807bdac433
commit
e14d6cd59f
4 files changed
+1804
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user