// LoFinity — world builder. Constructs the full low-poly scene based on the // reference art: sidewalk, vending machine, bench, Asahi Village sign, stone // wall, big tree, dirt path, fences, sunflowers, crop fields, house with blue // roof, clothesline, lamp post, forest, mountains and clouds. import * as THREE from "three"; // --- small helpers ---------------------------------------------------------- const lambert = (color, opts = {}) => new THREE.MeshLambertMaterial({ color, ...opts }); function box(w, h, d, material) { return new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material); } function cyl(rTop, rBottom, h, seg, material) { return new THREE.Mesh(new THREE.CylinderGeometry(rTop, rBottom, h, seg), material); } function ico(r, detail, material) { return new THREE.Mesh(new THREE.IcosahedronGeometry(r, detail), material); } function shadows(object, cast = true, receive = true) { object.traverse((o) => { if (o.isMesh) { o.castShadow = cast; o.receiveShadow = receive; } }); return object; } function canvasTexture(width, height, draw) { const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; draw(canvas.getContext("2d"), width, height); const tex = new THREE.CanvasTexture(canvas); tex.anisotropy = 4; return tex; } const rand = (a, b) => a + Math.random() * (b - a); const pick = (arr) => arr[Math.floor(Math.random() * arr.length)]; // --- palette ---------------------------------------------------------------- const GREENS = [0x4caf3f, 0x46a338, 0x57bd49]; const DARK_GREENS = [0x3c7d36, 0x35702f, 0x44883d]; const WOOD = 0x7a5230; const WOOD_DARK = 0x5d3e23; // --- sidewalk --------------------------------------------------------------- function buildSidewalk() { const g = new THREE.Group(); const slab = box(80, 0.3, 6, lambert(0xc9cdd2)); slab.position.set(0, 0.15, 5); g.add(slab); const curb = box(80, 0.34, 0.35, lambert(0xb0b5bb)); curb.position.set(0, 0.17, 8.1); g.add(curb); const jointMaterial = lambert(0xb4b9bf); for (let x = -36; x <= 36; x += 4) { const joint = box(0.07, 0.31, 6, jointMaterial); joint.position.set(x, 0.155, 5); g.add(joint); } return shadows(g, false, true); } // --- dirt path -------------------------------------------------------------- function buildPath() { // Shape coords: x = world x, y = depth (mapped to -z after rotation) const shape = new THREE.Shape(); shape.moveTo(0.2, -2); shape.lineTo(0.8, 6); shape.lineTo(2.2, 14); shape.lineTo(1.2, 24); shape.lineTo(0.5, 34); shape.lineTo(1.0, 40); shape.lineTo(3.0, 40); shape.lineTo(3.4, 30); shape.lineTo(4.4, 20); shape.lineTo(5.2, 12); shape.lineTo(4.4, 4); shape.lineTo(3.8, -2); shape.closePath(); const path = new THREE.Mesh( new THREE.ShapeGeometry(shape), lambert(0xcfb579) ); path.rotation.x = -Math.PI / 2; path.position.y = 0.02; path.receiveShadow = true; return path; } // --- Asahi Village sign ----------------------------------------------------- function signBoard(w, h, drawText) { const g = new THREE.Group(); const board = box(w, h, 0.12, lambert(0x8a6740)); g.add(board); const frame = box(w + 0.08, h + 0.08, 0.08, lambert(WOOD_DARK)); frame.position.z = -0.03; g.add(frame); const tex = canvasTexture(512, Math.round((512 * h) / w), drawText); const text = new THREE.Mesh( new THREE.PlaneGeometry(w * 0.96, h * 0.96), new THREE.MeshBasicMaterial({ map: tex, transparent: true }) ); text.position.z = 0.065; g.add(text); return g; } function buildSign() { const g = new THREE.Group(); const postMaterial = lambert(WOOD_DARK); for (const x of [-0.85, 0.85]) { const post = box(0.16, 3.1, 0.16, postMaterial); post.position.set(x, 1.55, -0.17); g.add(post); } const cream = "#f5ead0"; const textStyle = (ctx, size) => { ctx.fillStyle = cream; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.font = `bold ${size}px 'Baloo 2', 'Hiragino Sans', sans-serif`; }; const b1 = signBoard(2.3, 0.62, (ctx, w, h) => { textStyle(ctx, 92); ctx.fillText("あさひ村 ☀", w / 2, h / 2 + 4); }); b1.position.y = 2.75; const b2 = signBoard(2.3, 0.5, (ctx, w, h) => { textStyle(ctx, 76); ctx.fillText("Asahi Village", w / 2, h / 2 + 4); }); b2.position.y = 2.05; const b3 = signBoard(2.3, 0.55, (ctx, w, h) => { textStyle(ctx, 84); ctx.fillText("← 2km 先", w / 2, h / 2 + 4); }); b3.position.y = 1.35; g.add(b1, b2, b3); return shadows(g); } function buildFlowerPot() { const g = new THREE.Group(); const pot = cyl(0.34, 0.24, 0.45, 10, lambert(0xb86b4b)); pot.position.y = 0.22; g.add(pot); const foliage = ico(0.42, 1, lambert(pick(GREENS), { flatShading: true })); foliage.position.y = 0.64; foliage.scale.y = 0.8; g.add(foliage); for (let i = 0; i < 7; i++) { const flower = ico(0.075, 0, lambert(pick([0xf2d23c, 0xffffff, 0xf2a83c]))); const a = (i / 7) * Math.PI * 2; flower.position.set(Math.cos(a) * 0.3, 0.78 + rand(-0.06, 0.1), Math.sin(a) * 0.3); g.add(flower); } return shadows(g); } // --- stone wall ------------------------------------------------------------- function buildStoneWall() { const g = new THREE.Group(); const grays = [0x9aa0a6, 0x8a9096, 0xa8aeb4].map((c) => lambert(c, { flatShading: true }) ); for (let row = 0; row < 3; row++) { for (let col = 0; col < 9; col++) { const stone = box(rand(0.7, 1.0), rand(0.42, 0.55), rand(0.5, 0.7), pick(grays)); stone.position.set( col * 0.85 - 3.4 + (row % 2) * 0.4, 0.28 + row * 0.48, rand(-0.06, 0.06) ); stone.rotation.y = rand(-0.08, 0.08); g.add(stone); } } // greenery peeking over the wall for (let i = 0; i < 5; i++) { const shrub = ico(rand(0.5, 0.85), 1, lambert(pick(DARK_GREENS), { flatShading: true })); shrub.position.set(rand(-3.4, 3.8), rand(1.7, 2.1), rand(-0.7, -0.4)); shrub.scale.y = 0.75; g.add(shrub); } return shadows(g); } // --- vending machine -------------------------------------------------------- function buildVendingMachine() { const g = new THREE.Group(); const body = box(2.6, 4.4, 1.9, lambert(0x8ed0f4)); body.position.y = 2.2; g.add(body); const trim = box(2.7, 0.18, 2.0, lambert(0x6db8e0)); trim.position.y = 4.42; g.add(trim); const feetMaterial = lambert(0x33444f); for (const [fx, fz] of [[-1.1, 0.75], [1.1, 0.75], [-1.1, -0.75], [1.1, -0.75]]) { const foot = box(0.22, 0.14, 0.22, feetMaterial); foot.position.set(fx, 0.07, fz); g.add(foot); } // Lit display window with bottle shelves const displayBack = new THREE.Mesh( new THREE.PlaneGeometry(1.78, 2.25), new THREE.MeshBasicMaterial({ color: 0xf4fbff }) ); displayBack.position.set(-0.3, 3.05, 0.955); g.add(displayBack); const bottleGeometry = new THREE.CylinderGeometry(0.075, 0.075, 0.32, 8); const capGeometry = new THREE.CylinderGeometry(0.04, 0.04, 0.07, 8); const shelfMaterial = new THREE.MeshBasicMaterial({ color: 0xdfe9ef }); const rows = [ { y: 3.72, colors: [0x3a8fd9, 0x6db3e8, 0xffffff, 0x4aa3e0, 0xe8a13c, 0x3a8fd9, 0x9fd0f0] }, { y: 3.05, colors: [0xe78a2e, 0xf2b53c, 0xd96f28, 0xf2cf5b, 0xe78a2e, 0xc9892f, 0xf2b53c] }, { y: 2.38, colors: [0x7a4e2a, 0xa06a38, 0xcaa468, 0x5d3a1f, 0xa06a38, 0x7a4e2a, 0xcaa468] }, ]; for (const row of rows) { const shelf = box(1.7, 0.05, 0.05, shelfMaterial); shelf.position.set(-0.3, row.y - 0.2, 0.98); g.add(shelf); row.colors.forEach((color, i) => { const bottle = new THREE.Mesh(bottleGeometry, lambert(color)); const x = -0.3 + (i - 3) * 0.23; bottle.position.set(x, row.y, 0.99); g.add(bottle); const cap = new THREE.Mesh(capGeometry, lambert(0xeeeeee)); cap.position.set(x, row.y + 0.19, 0.99); g.add(cap); }); } // Glass pane over the display const glass = new THREE.Mesh( new THREE.PlaneGeometry(1.82, 2.3), new THREE.MeshLambertMaterial({ color: 0xcfe9ff, transparent: true, opacity: 0.14 }) ); glass.position.set(-0.3, 3.05, 1.1); g.add(glass); // Right control column const panel = box(0.62, 2.25, 0.06, lambert(0x35586e)); panel.position.set(0.86, 3.05, 0.95); g.add(panel); const screen = new THREE.Mesh( new THREE.PlaneGeometry(0.4, 0.22), new THREE.MeshBasicMaterial({ color: 0x1c2f28 }) ); screen.position.set(0.86, 3.75, 0.99); g.add(screen); const coinSlot = box(0.07, 0.2, 0.03, lambert(0x10181d)); coinSlot.position.set(0.86, 3.3, 0.985); g.add(coinSlot); const button = box(0.18, 0.1, 0.04, lambert(0xd9534f)); button.position.set(0.86, 2.95, 0.985); g.add(button); const returnFlap = box(0.26, 0.16, 0.03, lambert(0x10181d)); returnFlap.position.set(0.86, 2.25, 0.985); g.add(returnFlap); // Poster: おいしい水 const posterTex = canvasTexture(256, 224, (ctx, w, h) => { ctx.fillStyle = "#f7fcff"; ctx.fillRect(0, 0, w, h); ctx.strokeStyle = "#7cc4ea"; ctx.lineWidth = 10; ctx.strokeRect(5, 5, w - 10, h - 10); // sky + mountains ctx.fillStyle = "#bfe3ff"; ctx.fillRect(18, 86, w - 36, h - 110); ctx.fillStyle = "#4a7fb5"; ctx.beginPath(); ctx.moveTo(30, h - 26); ctx.lineTo(95, 96); ctx.lineTo(160, h - 26); ctx.closePath(); ctx.fill(); ctx.fillStyle = "#6da3d8"; ctx.beginPath(); ctx.moveTo(120, h - 26); ctx.lineTo(180, 110); ctx.lineTo(236, h - 26); ctx.closePath(); ctx.fill(); ctx.fillStyle = "#2e6da4"; ctx.font = "bold 44px 'Hiragino Sans', sans-serif"; ctx.textAlign = "center"; ctx.fillText("おいしい水", w / 2, 62); }); const poster = new THREE.Mesh( new THREE.PlaneGeometry(1.05, 0.92), new THREE.MeshBasicMaterial({ map: posterTex }) ); poster.position.set(-0.55, 1.25, 0.96); g.add(poster); // Dispenser slot const dispenser = box(1.5, 0.55, 0.1, lambert(0x2e4a5c)); dispenser.position.set(-0.3, 0.52, 0.93); g.add(dispenser); const dispenserInner = box(1.3, 0.38, 0.06, lambert(0x10181d)); dispenserInner.position.set(-0.3, 0.5, 0.97); g.add(dispenserInner); // referenced by main.js to make the machine glow while generating g.userData.screenMaterial = screen.material; g.userData.dispenserMaterial = dispenserInner.material; // Door handle const handle = box(0.1, 1.2, 0.07, lambert(0xd8e6ee)); handle.position.set(0.45, 2.9, 1.0); g.add(handle); shadows(g); // Hover outline: inflated back-face hull for the sides/top, plus a thin // base plate peeking around the footprint — the hull's bottom rim would // otherwise be buried in the sidewalk. const outline = new THREE.Group(); const outlineMaterial = new THREE.MeshBasicMaterial({ color: 0xffd84a, side: THREE.BackSide, }); const hull = box(2.6, 4.4, 1.9, outlineMaterial); hull.scale.setScalar(1.07); hull.position.y = 2.2; hull.castShadow = false; outline.add(hull); // the top trim cap is wider than the body — give it its own hull so the // outline follows the stepped silhouette const capHull = box(2.88, 0.36, 2.18, outlineMaterial); capHull.position.y = 4.42; capHull.castShadow = false; outline.add(capHull); const plate = box(2.92, 0.09, 2.16, new THREE.MeshBasicMaterial({ color: 0xffd84a })); plate.position.y = 0.055; plate.castShadow = false; outline.add(plate); outline.visible = false; g.add(outline); g.userData.outline = outline; return g; } // --- bench ------------------------------------------------------------------ function buildBench() { const g = new THREE.Group(); const blue = lambert(0x4a90c2); const darkBlue = lambert(0x2c5d8a); for (let i = 0; i < 3; i++) { const slat = box(3.8, 0.1, 0.32, blue); slat.position.set(0, 1.0, -0.4 + i * 0.4); g.add(slat); } for (let i = 0; i < 2; i++) { const slat = box(3.8, 0.32, 0.1, blue); slat.position.set(0, 1.5 + i * 0.45, -0.62); g.add(slat); } for (const x of [-1.7, 1.7]) { const leg = box(0.14, 1.0, 0.9, darkBlue); leg.position.set(x, 0.5, -0.05); g.add(leg); const backPost = box(0.14, 1.1, 0.14, darkBlue); backPost.position.set(x, 1.55, -0.6); g.add(backPost); const arm = box(0.5, 0.1, 0.7, darkBlue); arm.position.set(x, 1.35, -0.15); g.add(arm); } return shadows(g); } // --- cassette pile ----------------------------------------------------------- function buildCassette(color) { const g = new THREE.Group(); const shell = box(0.46, 0.075, 0.3, lambert(color)); shell.position.y = 0.0375; g.add(shell); const label = box(0.32, 0.012, 0.2, lambert(0xf4ecd8)); label.position.y = 0.078; g.add(label); const tapeWindow = box(0.16, 0.013, 0.08, lambert(0x1d232f)); tapeWindow.position.y = 0.079; g.add(tapeWindow); return g; } function buildCassettePile() { const g = new THREE.Group(); const colors = [0x2e3440, 0xf2e8d5, 0xd95d4e, 0x7cc4ea, 0xf2c84b, 0x4a3b2c]; // [x, y, z, yaw] — three flat, two crossed on top, one tilted at the summit const placements = [ [0, 0, 0, 0.15], [0.5, 0, 0.05, -0.25], [-0.46, 0, -0.04, 0.4], [0.22, 0.088, 0.02, 1.35], [-0.28, 0.088, -0.03, -1.15], [-0.02, 0.176, 0, 0.55], ]; placements.forEach(([x, y, z, yaw], i) => { const cassette = buildCassette(colors[i % colors.length]); cassette.position.set(x, y, z); cassette.rotation.y = yaw; if (i === placements.length - 1) cassette.rotation.z = 0.07; g.add(cassette); }); shadows(g); // Hover outline: one back-face hull per cassette so the highlight follows // the silhouette of the pile (same trick as the vending machine). const outline = new THREE.Group(); const outlineMaterial = new THREE.MeshBasicMaterial({ color: 0xffd84a, side: THREE.BackSide, }); placements.forEach(([x, y, z, yaw], i) => { const hull = box(0.54, 0.16, 0.38, outlineMaterial); hull.position.set(x, y + 0.04, z); hull.rotation.y = yaw; if (i === placements.length - 1) hull.rotation.z = 0.07; outline.add(hull); }); outline.visible = false; g.add(outline); g.userData.outline = outline; return g; } // --- walkman + vintage headphones --------------------------------------------- function buildWalkman() { const g = new THREE.Group(); const body = box(0.5, 0.12, 0.36, lambert(0xc7cdd4)); body.position.y = 0.06; g.add(body); const tapeWindow = box(0.3, 0.015, 0.2, lambert(0x1d232f)); tapeWindow.position.set(-0.06, 0.125, 0); g.add(tapeWindow); const tapeLabel = box(0.14, 0.012, 0.1, lambert(0xf4ecd8)); tapeLabel.position.set(-0.06, 0.133, 0); g.add(tapeLabel); // transport buttons along the edge, the red one is record [0x3a4254, 0x3a4254, 0xd95d4e].forEach((color, i) => { const button = box(0.05, 0.05, 0.06, lambert(color)); button.position.set(0.19, 0.12, -0.1 + i * 0.1); g.add(button); }); shadows(g); // Hover outline hull (see buildCassettePile) const outline = new THREE.Group(); const hull = box(0.6, 0.22, 0.46, new THREE.MeshBasicMaterial({ color: 0xffd84a, side: THREE.BackSide, })); hull.position.y = 0.075; outline.add(hull); outline.visible = false; g.add(outline); g.userData.outline = outline; return g; } function buildHeadphones() { const g = new THREE.Group(); const band = new THREE.Mesh( new THREE.TorusGeometry(0.2, 0.018, 8, 18, Math.PI), lambert(0x6b7280) ); band.rotation.x = -Math.PI / 2; band.position.y = 0.035; g.add(band); for (const side of [-1, 1]) { const cup = cyl(0.085, 0.085, 0.05, 12, lambert(0x3a4254)); cup.position.set(side * 0.2, 0.03, 0); g.add(cup); const foam = cyl(0.08, 0.08, 0.026, 12, lambert(0xe8893c)); foam.position.set(side * 0.2, 0.068, 0); g.add(foam); } return shadows(g); } // --- game boy (dropped on the sidewalk) ------------------------------------- function buildGameboy() { const g = new THREE.Group(); // classic DMG shell lying flat, face up. local axes: x=width, z=length, y=up const body = box(0.42, 0.1, 0.64, lambert(0xbfc4bd)); body.position.y = 0.05; g.add(body); const lip = box(0.4, 0.02, 0.62, lambert(0xced2cb)); lip.position.y = 0.1; g.add(lip); // screen bezel (upper half, toward -z) + glowing green LCD const bezel = box(0.34, 0.03, 0.28, lambert(0x49544b)); bezel.position.set(0, 0.105, -0.14); g.add(bezel); const screen = new THREE.Mesh( new THREE.BoxGeometry(0.26, 0.034, 0.2), new THREE.MeshBasicMaterial({ color: 0x9bbc0f }) ); screen.position.set(0, 0.108, -0.14); g.add(screen); // d-pad (lower-left): a dark cross const dpadMat = lambert(0x2b2f2b); for (const [w, d] of [[0.045, 0.12], [0.12, 0.045]]) { const arm = box(w, 0.022, d, dpadMat); arm.position.set(-0.11, 0.108, 0.14); g.add(arm); } // A/B buttons (lower-right): dark magenta, set diagonally const btnMat = lambert(0x8c2d52); for (const [bx, bz] of [[0.07, 0.18], [0.155, 0.12]]) { const btn = cyl(0.032, 0.032, 0.024, 12, btnMat); btn.position.set(bx, 0.11, bz); g.add(btn); } // start / select: two little grey pills, angled const pillMat = lambert(0x6b7280); for (const px of [-0.04, 0.05]) { const pill = box(0.07, 0.018, 0.022, pillMat); pill.position.set(px, 0.103, 0.265); pill.rotation.y = -0.4; g.add(pill); } // speaker grille (lower-right corner): a few slits const grilleMat = lambert(0x9aa09a); for (let i = 0; i < 4; i++) { const slit = box(0.012, 0.016, 0.07, grilleMat); slit.position.set(0.1 + i * 0.022, 0.104, 0.27); slit.rotation.y = -0.5; g.add(slit); } shadows(g); // hover outline: a single inflated back-face hull (see buildWalkman) const outline = new THREE.Group(); const hull = box(0.5, 0.2, 0.72, new THREE.MeshBasicMaterial({ color: 0xffd84a, side: THREE.BackSide, })); hull.position.y = 0.07; outline.add(hull); outline.visible = false; g.add(outline); g.userData.outline = outline; return g; } // --- little bird --------------------------------------------------------------- function buildBird() { const g = new THREE.Group(); const cream = lambert(0xf5efe0, { flatShading: true }); const featherMaterial = lambert(0xd9cdb4, { flatShading: true }); const body = ico(0.13, 1, cream); body.scale.set(1, 0.95, 1.2); body.position.y = 0.12; g.add(body); const breast = ico(0.085, 1, lambert(0xe8893c, { flatShading: true })); breast.position.set(0, 0.07, 0.07); g.add(breast); const beak = new THREE.Mesh(new THREE.ConeGeometry(0.025, 0.07, 6), lambert(0xd9a13c)); beak.rotation.x = Math.PI / 2; beak.position.set(0, 0.15, 0.17); g.add(beak); for (const side of [-1, 1]) { const eye = ico(0.018, 0, lambert(0x1d232f)); eye.position.set(side * 0.055, 0.17, 0.115); g.add(eye); const wing = ico(0.07, 1, featherMaterial); wing.scale.set(0.5, 0.7, 1.3); wing.position.set(side * 0.115, 0.12, -0.01); g.add(wing); const leg = cyl(0.012, 0.012, 0.05, 5, lambert(0xb3823c)); leg.position.set(side * 0.04, 0.025, 0.02); g.add(leg); } const tail = new THREE.Group(); const feathers = box(0.07, 0.02, 0.14, featherMaterial); feathers.position.z = -0.07; tail.add(feathers); tail.position.set(0, 0.12, -0.13); tail.rotation.x = -0.35; g.add(tail); g.userData.tail = tail; return shadows(g); } // --- lamp post --------------------------------------------------------------- function buildLamp() { const g = new THREE.Group(); const gray = lambert(0x9aa3ab); const base = cyl(0.22, 0.3, 0.5, 10, gray); base.position.y = 0.25; g.add(base); const pole = cyl(0.1, 0.14, 8, 10, gray); pole.position.y = 4.25; g.add(pole); const arm = cyl(0.08, 0.08, 2.0, 8, gray); arm.position.set(-0.85, 8.5, 0); arm.rotation.z = 1.15; g.add(arm); const head = box(1.0, 0.22, 0.42, gray); head.position.set(-1.85, 8.85, 0); g.add(head); const bulb = new THREE.Mesh( new THREE.PlaneGeometry(0.8, 0.3), new THREE.MeshBasicMaterial({ color: 0xfff7d6 }) ); bulb.rotation.x = Math.PI / 2; bulb.position.set(-1.85, 8.73, 0); g.add(bulb); shadows(g); // Hover outline: inflated back-face hulls tracing the base, pole, arm and // head (same trick as buildVending). Built after shadows() so they never cast. const outline = new THREE.Group(); const outlineMaterial = new THREE.MeshBasicMaterial({ color: 0xffd84a, side: THREE.BackSide, }); const base_ = cyl(0.3, 0.38, 0.6, 10, outlineMaterial); base_.position.y = 0.25; const pole_ = cyl(0.16, 0.2, 8.15, 10, outlineMaterial); pole_.position.y = 4.25; const arm_ = cyl(0.13, 0.13, 2.1, 8, outlineMaterial); arm_.position.set(-0.85, 8.5, 0); arm_.rotation.z = 1.15; const head_ = box(1.16, 0.4, 0.58, outlineMaterial); head_.position.set(-1.85, 8.84, 0); for (const part of [base_, pole_, arm_, head_]) { part.castShadow = false; part.receiveShadow = false; outline.add(part); } outline.visible = false; g.add(outline); g.userData.outline = outline; return g; } // --- trees ------------------------------------------------------------------ function buildBigTree() { const g = new THREE.Group(); const trunk = cyl(0.5, 0.75, 7, 8, lambert(0x8a5a33, { flatShading: true })); trunk.position.y = 3.5; g.add(trunk); const branch = cyl(0.2, 0.32, 3.2, 7, lambert(0x8a5a33, { flatShading: true })); branch.position.set(1.6, 6.4, 0.2); branch.rotation.z = -0.7; g.add(branch); const canopySpec = [ [0, 8.5, 0, 3.2], [2.6, 9.4, 0.5, 2.6], [-2.2, 9.6, -0.4, 2.4], [1.0, 11.0, 0, 2.2], [4.6, 8.6, 0.8, 2.0], ]; const canopy = []; for (const [x, y, z, r] of canopySpec) { const leaves = ico(r, 1, lambert(pick(GREENS), { flatShading: true })); leaves.position.set(x, y, z); leaves.scale.y = 0.85; canopy.push(leaves); g.add(leaves); } g.userData.canopy = canopy; return shadows(g); } function buildBlobTree(scale = 1) { const g = new THREE.Group(); const trunk = cyl(0.25 * scale, 0.4 * scale, 2.6 * scale, 7, lambert(0x7a5230)); trunk.position.y = 1.3 * scale; g.add(trunk); const blobs = 2 + Math.floor(Math.random() * 3); for (let i = 0; i < blobs; i++) { const leaves = ico(rand(1.1, 1.8) * scale, 1, lambert(pick(DARK_GREENS), { flatShading: true })); leaves.position.set( rand(-0.8, 0.8) * scale, (2.8 + i * 1.0) * scale, rand(-0.8, 0.8) * scale ); g.add(leaves); } return shadows(g); } function buildPine(scale = 1) { const g = new THREE.Group(); const trunk = cyl(0.2 * scale, 0.3 * scale, 1.6 * scale, 6, lambert(WOOD)); trunk.position.y = 0.8 * scale; g.add(trunk); for (let i = 0; i < 3; i++) { const cone = new THREE.Mesh( new THREE.ConeGeometry((2.0 - i * 0.45) * scale, 2.2 * scale, 7), lambert(pick(DARK_GREENS), { flatShading: true }) ); cone.position.y = (2.2 + i * 1.3) * scale; g.add(cone); } return shadows(g); } // --- fences ----------------------------------------------------------------- function buildFenceRun(from, to) { const g = new THREE.Group(); const dir = new THREE.Vector2(to.x - from.x, to.z - from.z); const length = dir.length(); const angle = Math.atan2(dir.y, dir.x); const postMaterial = lambert(WOOD); const railMaterial = lambert(0x8a6238); const posts = Math.max(2, Math.round(length / 2.2) + 1); for (let i = 0; i < posts; i++) { const t = i / (posts - 1); const post = box(0.15, 1.1, 0.15, postMaterial); post.position.set(from.x + dir.x * t, 0.55, from.z + dir.y * t); g.add(post); } for (const y of [0.45, 0.85]) { const rail = box(length, 0.09, 0.07, railMaterial); rail.position.set(from.x + dir.x / 2, y, from.z + dir.y / 2); rail.rotation.y = -angle; g.add(rail); } return shadows(g); } // --- sunflowers & flowers ---------------------------------------------------- function buildSunflower() { const g = new THREE.Group(); const h = rand(1.5, 2.1); const stem = cyl(0.04, 0.05, h, 6, lambert(0x4a8c3a)); stem.position.y = h / 2; g.add(stem); const head = new THREE.Group(); const petals = new THREE.Mesh( new THREE.CylinderGeometry(0.3, 0.3, 0.06, 12), lambert(0xf2c83c) ); petals.rotation.x = Math.PI / 2; head.add(petals); const center = new THREE.Mesh( new THREE.CylinderGeometry(0.14, 0.14, 0.08, 10), lambert(0x6e4a2f) ); center.rotation.x = Math.PI / 2; center.position.z = 0.04; head.add(center); head.position.set(0, h, 0.05); head.rotation.x = rand(-0.15, 0.1); g.add(head); const leaf = ico(0.12, 0, lambert(0x4a8c3a)); leaf.scale.set(1.6, 0.5, 1); leaf.position.set(0.12, h * 0.55, 0); g.add(leaf); return shadows(g); } function buildFlowerBush() { const g = new THREE.Group(); const bush = ico(rand(0.35, 0.55), 1, lambert(pick(GREENS), { flatShading: true })); bush.position.y = 0.35; bush.scale.y = 0.8; g.add(bush); for (let i = 0; i < 6; i++) { const flower = ico(0.06, 0, lambert(pick([0x6f8fd8, 0x9fb8f0, 0xffffff]))); const a = Math.random() * Math.PI * 2; flower.position.set(Math.cos(a) * 0.3, rand(0.5, 0.7), Math.sin(a) * 0.3); g.add(flower); } return shadows(g); } // --- crop fields ------------------------------------------------------------ function buildCropField(rows, rowLength) { const g = new THREE.Group(); const rowColors = [0x9fb83e, 0x7da33c, 0xb5c44a]; for (let i = 0; i < rows; i++) { const row = box(1.0, 0.24, rowLength, lambert(rowColors[i % 3])); row.position.set(i * 1.05, 0.12, 0); g.add(row); } return shadows(g, false, true); } // --- house ------------------------------------------------------------------ function buildHouse() { const g = new THREE.Group(); const wallMaterial = lambert(0xf2efe6); const roofMaterial = lambert(0x4a7fb5, { flatShading: true }); const darkWood = lambert(0x4a3b2c); const walls = box(8, 3.4, 6, wallMaterial); walls.position.y = 1.7; g.add(walls); // Gable roof: two slabs + ridge + triangle fills const pitch = 0.52; const slabLength = 4.1; for (const side of [-1, 1]) { const slab = box(9.2, 0.18, slabLength, roofMaterial); slab.rotation.x = side * pitch; slab.position.set( 0, 3.4 + (slabLength / 2) * Math.sin(pitch) - 0.05, side * (slabLength / 2) * Math.cos(pitch) ); g.add(slab); } const ridge = box(9.3, 0.18, 0.5, lambert(0x3e6c9d)); ridge.position.set(0, 3.4 + slabLength * Math.sin(pitch) - 0.05, 0); g.add(ridge); for (const side of [-1, 1]) { const triangle = new THREE.Shape(); triangle.moveTo(-3, 0); triangle.lineTo(3, 0); triangle.lineTo(0, 1.95); triangle.closePath(); const gable = new THREE.Mesh( new THREE.ExtrudeGeometry(triangle, { depth: 0.2, bevelEnabled: false }), wallMaterial ); gable.rotation.y = side * Math.PI / 2; gable.position.set(side * 3.9, 3.4, side * 0.1); g.add(gable); } // Dormer, tucked into the front roof slope const dormer = box(1.1, 0.85, 1.0, wallMaterial); dormer.position.set(-1.8, 3.95, 1.75); g.add(dormer); const dormerRoof = box(1.35, 0.12, 1.15, roofMaterial); dormerRoof.rotation.x = 0.42; dormerRoof.position.set(-1.8, 4.5, 1.75); g.add(dormerRoof); const dormerWindow = new THREE.Mesh( new THREE.PlaneGeometry(0.55, 0.45), new THREE.MeshBasicMaterial({ color: 0xbfe0ea }) ); dormerWindow.position.set(-1.8, 3.98, 2.26); g.add(dormerWindow); // Porch const porchRoof = box(3.2, 0.14, 2.2, roofMaterial); porchRoof.rotation.x = 0.18; porchRoof.position.set(2.0, 2.9, 3.9); g.add(porchRoof); for (const x of [0.7, 3.3]) { const post = box(0.16, 2.6, 0.16, darkWood); post.position.set(x, 1.3, 4.7); g.add(post); } const porchFloor = box(3.4, 0.18, 2.2, lambert(0xb9b3a4)); porchFloor.position.set(2.0, 0.09, 3.9); g.add(porchFloor); const door = box(0.95, 1.9, 0.1, darkWood); door.position.set(2.0, 0.95, 3.03); g.add(door); // Front windows with flower boxes for (const x of [-2.4, -0.5]) { const frame = box(1.15, 1.0, 0.1, darkWood); frame.position.set(x, 1.8, 3.03); g.add(frame); const glassPane = new THREE.Mesh( new THREE.PlaneGeometry(0.95, 0.8), new THREE.MeshBasicMaterial({ color: 0xbfe0ea }) ); glassPane.position.set(x, 1.8, 3.1); g.add(glassPane); const flowerBox = box(1.2, 0.22, 0.24, lambert(0x8a6740)); flowerBox.position.set(x, 1.2, 3.12); g.add(flowerBox); for (let i = 0; i < 4; i++) { const bloom = ico(0.08, 0, lambert(pick([0xf2d23c, 0xe8734a, 0xffffff]))); bloom.position.set(x - 0.45 + i * 0.3, 1.36, 3.16); g.add(bloom); } } return shadows(g); } function buildClothesline() { const g = new THREE.Group(); const poleMaterial = lambert(0x6b6f73); for (const x of [-1.8, 1.8]) { const pole = cyl(0.05, 0.07, 2.6, 6, poleMaterial); pole.position.set(x, 1.3, 0); g.add(pole); const bar = box(0.7, 0.06, 0.06, poleMaterial); bar.position.set(x, 2.55, 0); g.add(bar); } const line = box(3.6, 0.025, 0.025, lambert(0xdddddd)); line.position.y = 2.5; g.add(line); const clothMaterial = lambert(0xf5f5f0); for (const x of [-1.1, 0, 1.1]) { const cloth = box(0.75, rand(0.8, 1.0), 0.05, clothMaterial); cloth.position.set(x, 2.05, 0); cloth.rotation.y = rand(-0.08, 0.08); g.add(cloth); } return shadows(g); } // --- distant scenery --------------------------------------------------------- function buildMountains() { const g = new THREE.Group(); const layers = [ { z: -130, color: 0x5d9457, count: 4, hMin: 26, hMax: 38 }, { z: -155, color: 0x6fa07c, count: 4, hMin: 32, hMax: 46 }, { z: -180, color: 0x7fae9a, count: 3, hMin: 40, hMax: 55 }, ]; for (const layer of layers) { const material = lambert(layer.color, { flatShading: true }); for (let i = 0; i < layer.count; i++) { const h = rand(layer.hMin, layer.hMax); const mountain = new THREE.Mesh(new THREE.ConeGeometry(h * 1.5, h, 5), material); mountain.position.set( -160 + (i + rand(0.1, 0.6)) * (320 / layer.count), h / 2 - 3, layer.z + rand(-12, 12) ); mountain.rotation.y = rand(0, Math.PI); g.add(mountain); } } return g; } function buildClouds() { // Clouds live on light layer 1: they are lit by the sun plus a dedicated // cool hemisphere light (set up in main.js), so they never pick up the // green ground bounce that tints layer-0 objects. const cloudMaterial = new THREE.MeshLambertMaterial({ color: 0xffffff, flatShading: true, emissive: 0xdde9f4, emissiveIntensity: 0.25, }); function addPuff(cloud, r, x, y, z) { const puff = new THREE.Mesh(new THREE.IcosahedronGeometry(r, 1), cloudMaterial); puff.position.set(x, y, z); puff.scale.y = 0.66; puff.layers.set(1); cloud.add(puff); return puff; } function makeCloud(scale) { // Chunky anime cumulus: big heavily-overlapping puffs (fat middle, // flat-ish underside) with a crown of medium puffs nestled on top. const cloud = new THREE.Group(); const length = rand(10, 16); const puffs = Math.max(4, Math.round(length / 2.6)); let maxR = 0; for (let i = 0; i < puffs; i++) { const t = puffs === 1 ? 0.5 : i / (puffs - 1); const envelope = 0.55 + 0.45 * Math.sin(Math.PI * t); const r = (3.2 + 3.2 * envelope) * rand(0.9, 1.1); maxR = Math.max(maxR, r); addPuff(cloud, r, (t - 0.5) * length + rand(-0.5, 0.5), r * 0.5, rand(-1.8, 1.8)); } const crowns = 2 + Math.floor(Math.random() * 2); for (let j = 0; j < crowns; j++) { addPuff( cloud, rand(2.4, 3.6), rand(-length * 0.22, length * 0.22), maxR * rand(0.8, 0.95), rand(-1.2, 1.2) ); } // stay mostly side-on so clouds keep their silhouette from the camera cloud.rotation.y = rand(-0.35, 0.35); cloud.scale.setScalar(scale); return cloud; } const clouds = []; // big hero cumulus, center-back like the reference for (const [x, y, z, s] of [[15, 55, -150, 2.6], [-5, 48, -140, 2.0]]) { const cloud = makeCloud(s); cloud.position.set(x, y, z); cloud.userData.speed = 0.25; clouds.push(cloud); } for (let i = 0; i < 11; i++) { const cloud = makeCloud(rand(1.0, 1.6)); cloud.position.set(rand(-150, 150), rand(34, 80), rand(-170, -50)); cloud.userData.speed = rand(0.4, 1.2); clouds.push(cloud); } // high clouds that frame the title during the sky-gaze intro for (let i = 0; i < 6; i++) { const cloud = makeCloud(rand(1.3, 2.1)); cloud.position.set(rand(-130, 130), rand(85, 130), rand(-200, -90)); cloud.userData.speed = rand(0.3, 0.8); clouds.push(cloud); } return clouds; } // --- assemble --------------------------------------------------------------- export function buildWorld(scene) { // ground const ground = new THREE.Mesh( new THREE.CircleGeometry(340, 48), lambert(0x6fae46) ); ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true; scene.add(ground); scene.add(buildSidewalk()); scene.add(buildPath()); const vending = buildVendingMachine(); vending.position.set(-4.3, 0.3, 4.2); scene.add(vending); const bench = buildBench(); bench.position.set(1.7, 0.3, 4.4); scene.add(bench); // someone left their tape collection on the bench… (clickable as a group) const collection = new THREE.Group(); collection.position.set(-0.4, 1.05, 0); bench.add(collection); const cassettes = buildCassettePile(); cassettes.position.set(-0.6, 0, -0.05); cassettes.rotation.y = -0.2; collection.add(cassettes); // …with their walkman and headphones beside it const walkman = buildWalkman(); walkman.position.set(0.55, 0, 0.02); walkman.rotation.y = 0.35; collection.add(walkman); collection.userData.outlines = [ cassettes.userData.outline, walkman.userData.outline, ]; const headphones = buildHeadphones(); headphones.position.set(0.85, 1.05, -0.04); headphones.rotation.y = -0.6; bench.add(headphones); const cableCurve = new THREE.CatmullRomCurve3([ new THREE.Vector3(0.38, 1.1, 0.1), new THREE.Vector3(0.52, 1.07, 0.22), new THREE.Vector3(0.685, 1.08, 0.07), ]); const cable = new THREE.Mesh( new THREE.TubeGeometry(cableCurve, 16, 0.01, 5), lambert(0x2b3138) ); cable.castShadow = true; bench.add(cable); // a little bird perches on the backrest const bird = buildBird(); bird.position.set(1.15, 2.11, -0.62); bird.rotation.y = -0.5; bird.userData.baseY = bird.position.y; bench.add(bird); const sign = buildSign(); sign.position.set(-8.3, 0.3, 4.2); sign.rotation.y = 0.12; scene.add(sign); const pot = buildFlowerPot(); pot.position.set(-6.8, 0.3, 5.4); scene.add(pot); const lamp = buildLamp(); lamp.position.set(9.6, 0.3, 3.6); scene.add(lamp); // someone dropped their game boy on the sidewalk between the bench and lamp const gameboy = buildGameboy(); gameboy.position.set(6.6, 0.3, 5.7); gameboy.rotation.y = -0.55; scene.add(gameboy); const wall = buildStoneWall(); wall.position.set(-13.5, 0, 3.4); wall.rotation.y = 0.25; scene.add(wall); const bigTree = buildBigTree(); bigTree.position.set(-14.5, 0, -2.5); scene.add(bigTree); // fences along the path and the house garden scene.add(buildFenceRun({ x: 4.6, z: -3 }, { x: 5.8, z: -12 })); scene.add(buildFenceRun({ x: -1.4, z: -5 }, { x: -2.4, z: -14 })); scene.add(buildFenceRun({ x: 6.5, z: -6.8 }, { x: 16.5, z: -6.2 })); // sunflowers behind the garden fence for (let i = 0; i < 8; i++) { const sunflower = buildSunflower(); sunflower.position.set(7.2 + i * 1.2, 0, -7.4 + rand(-0.3, 0.3)); sunflower.rotation.y = rand(-0.4, 0.4); scene.add(sunflower); } for (let i = 0; i < 4; i++) { const bush = buildFlowerBush(); bush.position.set(6.8 + i * 2.6, 0, -6.0 + rand(-0.2, 0.2)); scene.add(bush); } // crop fields const field1 = buildCropField(6, 12); field1.position.set(-10, 0, -20); field1.rotation.y = 0.08; scene.add(field1); const field2 = buildCropField(5, 9); field2.position.set(6.5, 0, -20); field2.rotation.y = -0.06; scene.add(field2); const field3 = buildCropField(8, 11); field3.position.set(-4, 0, -34); scene.add(field3); const house = buildHouse(); house.position.set(12.5, 0, -13); house.rotation.y = -0.22; scene.add(house); const clothesline = buildClothesline(); clothesline.position.set(7.6, 0, -9.5); clothesline.rotation.y = 0.25; scene.add(clothesline); // pocket of trees behind/right of the house for (let i = 0; i < 8; i++) { const tree = Math.random() < 0.5 ? buildBlobTree(rand(1.2, 1.9)) : buildPine(rand(0.9, 1.4)); tree.position.set(rand(15, 32), 0, rand(-28, -15)); scene.add(tree); } // treeline at the horizon for (let i = 0; i < 26; i++) { const tree = Math.random() < 0.6 ? buildBlobTree(rand(1.6, 2.6)) : buildPine(rand(1.3, 2.0)); tree.position.set(rand(-90, 90), 0, rand(-75, -45)); scene.add(tree); } scene.add(buildMountains()); const clouds = buildClouds(); for (const cloud of clouds) scene.add(cloud); return { vending, bench, collection, sign, lamp, gameboy, bigTree, house, clouds, bird }; }