LoFinity / frontend /world.js
eloigil6's picture
Add Game Boy feature and modal interaction. Introduced a Game Boy model in the scene, updated main.js to handle zoom interactions and modal display, and enhanced UI components in ui.js for opening and closing the Game Boy modal. Added corresponding styles in style.css for the modal's appearance and animations. Updated index.html to include a new label for the Game Boy interaction.
576433d
Raw
History Blame Contribute Delete
37.7 kB
// 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 };
}