diff --git a/.gitignore b/.gitignore index 40c89ca..454750b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ cert.cer !package.json !package-lock.json +recording/ diff --git a/README.md b/README.md index aa3e3ad..3fb7b9e 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,54 @@ Software for CSCI-GA.3033-​097 Virtual Reality 2026 Spring. +--- + +## Final Project: ONE MATCH + +* **Video Demo:** [Demo](https://drive.google.com/file/d/1hcZmToe40c4seTaOOcKJAmMABfISPNRJ/view?usp=drive_link) +* **Deck:** [Deck](https://docs.google.com/presentation/d/1cGTfUzx4aNKozdeDvtu0bSSdu9AfzkixCbP0M-sCCFM/edit?usp=sharing) +* **Code:** + * `js/scenes/final_project.js` + * `js/scenes/micro_world.js` + +--- + +## Assignments Demo Videos + +* **Assignment 1: Car** + * **Video Demo:** [Assignment 1 Demo](https://drive.google.com/file/d/1Gta9nbOM10y1SD-sA6ITCxklQPSvHXZj/view?usp=drive_link) + * **Code:** `js/scenes/car.js` + +* **Assignment 2: Car Drive** + * **Video Demo:** [Assignment 2 Demo](https://drive.google.com/file/d/1PRdBgv2hsOrSPU_klBabnfIkiDOFTRDW/view?usp=drive_link) + * **Code:** `js/scenes/carDrive.js` + + +* **Assignment 3: Campfire** + * **Video Demo:** [Assignment 3 Demo](https://drive.google.com/file/d/1zaMHrFpwI21bqsKqNw5JxlqIVy6axHd6/view?usp=drive_link) + * **Code:** `js/scenes/campFire.js` + +* **Assignment 4: Text Party** + * **Video Demo:** [Assignment 4 Demo](https://drive.google.com/file/d/1th_Ud-LDqwSxh8X0xEZjG6v583vUt9uo/view?usp=drive_link) + * **Code:** `js/scenes/textHW.js` + +* **Assignment 5: Spirit Exercise** + * **Video Demo:** [Assignment 5 Demo](https://drive.google.com/file/d/1I7-1yRY8cPxwxCbqNgO94Vv5jAc6i_o-/view?usp=drive_link) + * **Code:** `js/scenes/spirit_exercise.js` + +* **Assignment 6: Headgaze Exercise** + * **Video Demo:** [Assignment 6 Demo](https://drive.google.com/file/d/1wHeAjisn9--nFuDG4DWscihcDw95RE81/view?usp=drive_link) + * **Code:** `js/scenes/headGazeExercise.js` + +* **Extra Exercise: Master** + * **Video Demo:** [Master Exercise Demo](https://drive.google.com/file/d/1FbCLOqYvVAQyVaVW9qjcx53Iw2fukeES/view?usp=drive_link) + * **Code:** `js/scenes/master2.js` + +* **Final Project** + * **Video Demo:** [Final Project Demo](./media/videos/final_demo.mp4) + * **Code:** `js/scenes/final_project.js` + +--- # How to setup the environment install Node.js and npm if you haven't. This project was tested using **Node v18.20.8**; if you run into issues, we recommend switching to this version. diff --git a/js/scenes/campFire.js b/js/scenes/campFire.js new file mode 100644 index 0000000..1f8a688 --- /dev/null +++ b/js/scenes/campFire.js @@ -0,0 +1,70 @@ +/* + This scene is an example of how to use procedural texture + to animate the shape of an object. In this case the object + is a waving flag. The noise function is used to animate + the position of each vertex of the flag geometry. +*/ + +import * as cg from "../render/core/cg.js"; + +function ensureCampfireMesh() { + if (!window.__campfireFlameMeshDefined) { + clay.defineMesh('flame', clay.createGrid(20, 30)); + window.__campfireFlameMeshDefined = true; + } +} + +export const renderCampfire = (model, t, options = {}) => { + ensureCampfireMesh(); + + const pos = options.pos || [0, 0, 0]; + const scale = options.scale ?? 0.3; + const yaw = options.yaw ?? 0; + const lit = options.lit ?? true; + + const root = model.add().move(...pos).turnY(yaw).scale(scale); + + // Grounding elements: a low ember bed and a loose stone ring help the fire + // read as resting on terrain instead of floating over it. + root.add('sphere') + .move(0, -0.03, 0) + .scale(0.75, 0.07, 0.75) + .color(0.14, 0.08, 0.05); + + for (let i = 0; i < 8; i++) { + const angle = i * Math.PI / 4; + root.add('sphere') + .move(Math.cos(angle) * 0.72, -0.01, Math.sin(angle) * 0.72) + .scale(0.14, 0.09, 0.12) + .color(0.34, 0.34, 0.36); + } + + for (let i = 0; i < 6; i++) { + root.add('tubeX') + .color(0.36, 0.24, 0.18) + .move(0, 0.03, 0) + .turnY(i * Math.PI / 3) + .scale(1.3, 0.07, 0.07); + } + + if (!lit) return root; + + for (let i = 0; i < 6; i++) { + const fire = root.add('flame').color(1, 0.45, 0.05); + fire.turnY(i * Math.PI / 3); + fire.setVertices((u, v) => [ + 0.8 * (u - 0.5) * (1 - v), + 2 * v, + 0.3 * v * cg.noise(5 * u, 5 * v - t * 3, t), + ]); + } + + return root; +}; + +export const init = async model => { + model.scale(0.3).move(0,4,0).animate(() => { + while (model.nChildren() > 0) model.remove(0); + renderCampfire(model, model.time, { pos: [0, 0, 0], scale: 1, lit: true }); + }); +} diff --git a/js/scenes/car.js b/js/scenes/car.js new file mode 100644 index 0000000..15812d6 --- /dev/null +++ b/js/scenes/car.js @@ -0,0 +1,56 @@ +/* + Create and animate hierarchical joints. +*/ +let speed = 0.8 + +export const init = async model => { + + model.txtrSrc(1, '../media/textures/tire.png'); + + // CREATE NODES WITH NO SHAPES AS JOINTS FOR ANIMATION. + let carbody = model.add(); + + //wheel center joint + let wheelFLCenter = carbody.add(); + let wheelFRCenter = carbody.add(); + let wheelBLCenter = carbody.add(); + let wheelBRCenter = carbody.add(); + + //wheel joint + let wheelFL = wheelFLCenter.add(); + let wheelFR = wheelFRCenter.add(); + let wheelBL = wheelBLCenter.add(); + let wheelBR = wheelBRCenter.add(); + + // CREATE AND PLACE SHAPES THAT WILL MOVE WITH EACH JOINT. + // carboday + carbody.add('cube').scale(.8,.25,.4).move(0.8,1.5,-1).color(1,0,0); + //car cabin + carbody.add('cube').scale(.5,.2,.35).move(0.8,3.8,-1.2).color(1,1,1);; + + // wheel centers + wheelFRCenter.move(1.2,0,0) + wheelBLCenter.move(0,0,-0.8) + wheelFLCenter.move(1.2,0,-0.8) + + wheelBR.add('torusZ').scale(.18,.18,.2).txtr(1);; + wheelFR.add('torusZ').scale(.18,.18,.2).txtr(1);; + wheelBL.add('torusZ').scale(.18,.18,.2).txtr(1);; + wheelFL.add('torusZ').scale(.18,.18,.2).txtr(1);; + + // ANIMATE THE JOINTS OVER TIME. + model.scale(0.8,0.8,0.8).move(-0.5,1.3,0).animate(() => { + carbody.identity() + .turnY(Math.sin(speed*model.time)*.7+.7); + + wheelFL.identity() + .turnZ(Math.sin(speed*model.time)*.7+.7); + wheelFR.identity() + .turnZ(Math.sin(speed*model.time)*.7+.7); + wheelBL.identity() + .turnZ(Math.sin(speed*model.time)*.7+.7); + wheelBR.identity() + .turnZ(Math.sin(speed*model.time)*.7+.7); + }); +} + diff --git a/js/scenes/carDrive.js b/js/scenes/carDrive.js new file mode 100644 index 0000000..1cdf5b1 --- /dev/null +++ b/js/scenes/carDrive.js @@ -0,0 +1,83 @@ +/* + This is a very simple example of how to use the + inputEvents object. + + When the scene is in XR mode, the x position of + the left controller controls the red component + of the cube's color, and the x position of the + right controller controls the blue component of + the cube's color. +*/ +export const init = async model => { + // See what the inputEvents can do + // console.log(inputEvents); + + model.txtrSrc(1, '../media/textures/tire.png'); + + let speed = 0; + let color = [1,0,0]; + // CREATE NODES WITH NO SHAPES AS JOINTS FOR ANIMATION. + let carbody = model.add(); + + //wheel center joint + let wheelFLCenter = carbody.add(); + let wheelFRCenter = carbody.add(); + let wheelBLCenter = carbody.add(); + let wheelBRCenter = carbody.add(); + + //wheel joint + let wheelFL = wheelFLCenter.add(); + let wheelFR = wheelFRCenter.add(); + let wheelBL = wheelBLCenter.add(); + let wheelBR = wheelBRCenter.add(); + + // CREATE AND PLACE SHAPES THAT WILL MOVE WITH EACH JOINT. + // carboday + let chassis = carbody.add('cube').scale(.8,.25,.4).move(0.8,1.5,-1).color(color); + //car cabin + carbody.add('cube').scale(.5,.2,.35).move(0.8,3.8,-1.2).color(1,1,1);; + + // wheel centers + wheelFRCenter.move(1.2,0,0) + wheelBLCenter.move(0,0,-0.8) + wheelFLCenter.move(1.2,0,-0.8) + + wheelBR.add('torusZ').scale(.18,.18,.2).txtr(1);; + wheelFR.add('torusZ').scale(.18,.18,.2).txtr(1);; + wheelBL.add('torusZ').scale(.18,.18,.2).txtr(1);; + wheelFL.add('torusZ').scale(.18,.18,.2).txtr(1);; + + // USING THE GLOBAL inputEvents OBJECT + + inputEvents.onMove = hand => { + if (isXR()) { + if (hand == 'left'){ + color[0] = inputEvents.pos(hand)[0] * .5 + .5; + color[1] = inputEvents.pos(hand)[2] * .5 + .5; + } + } + } + + model.scale(0.3).move(-0.5,4.5,0).animate(() => { + if (inputEvents.isPressed('right')) { + speed += 0.005; + } else { + speed *= 0.95; + } + + //.identity resets everything + carbody.identity().move(speed * 2, 0, 0); + chassis.color(color); + + wheelFL.identity() + .turnZ(-speed*model.time*.7); + wheelFR.identity() + .turnZ(-speed*model.time*.7); + wheelBL.identity() + .turnZ(-speed*model.time*.7); + wheelBR.identity() + .turnZ(-speed*model.time*.7); + + }); +} + diff --git a/js/scenes/final_old/final_project.js b/js/scenes/final_old/final_project.js new file mode 100644 index 0000000..374522f --- /dev/null +++ b/js/scenes/final_old/final_project.js @@ -0,0 +1,909 @@ +import * as cg from "../../render/core/cg.js"; +import { loadSound, playSoundAtPosition } from "../../util/positional-audio.js"; +import { loadStereoSound, playStereoAudio, stopStereoLoopingAudio } from "../../util/stereo-audio.js"; +import * as act2 from "./micro_world.js"; +import * as global from "../../global.js"; +import { Gltf2Node } from "../../render/nodes/gltf2.js"; +import { ControllerBeam } from "../../render/core/controllerInput.js"; + +// ─── 游戏阶段 ─────────────────────────────────────────── +// "DARK" → 全黑,等待玩家找到火柴 +// "MATCH_HELD" → 火柴在手,等待划火柴手势 +// "LIGHTING" → 点火成功,灯慢慢变亮 +// "LIT" → 第一幕完成,准备进入第二幕 +// "MONSTER_EVENT" → 小怪物出现并抢走火柴盒 +// "QUEST_HUB" → 玩家做选择:跟随或留下 +// "PORTAL_TRANSITION"→ 传送门动画 +// "ACT2" → 进入微观世界 +// "GAME_OVER_STAY" → 玩家选择留下,灯灭结局 + +// ─── FIX #1: 用 window.sharedState 暴露 gamePhase ─────── +// micro_world.js 需要能把游戏推进到 SCENE_3, +// 所以我们把 gamePhase 放进一个全局共享对象,而不是用 let。 +window.sharedState = { gamePhase: "DARK" }; + +// 方便读写的本地别名(每次读/写都通过 window.sharedState.gamePhase) +const getPhase = () => window.sharedState.gamePhase; +const setPhase = (p) => { window.sharedState.gamePhase = p; }; + +// 玩家的选择和倒计时 +let choiceMade = null; // "FOLLOW" 或 "STAY" +let stayTimer = 10; // 如果不跟,10秒后灯灭 + +export const init = async model => { + + // right controller + window.beamR = new ControllerBeam(model, 'right'); + window.rightClick = false; + // Create the buttons ONCE + window.followBtn = model.add('square'); + window.stayBtn = model.add('square'); + + inputEvents.onPress = hand => { + if (hand === 'right') window.rightClick = true; + }; + + // ─── 场景节点 ──────────────────────────────────────── + const matchBox = model.add("cube"); // 火柴盒 + const matchBoxStrip = model.add("square"); // 火柴盒侧面擦火条 + const matchStick = model.add("cube"); // 火柴(从盒中取出) + const matchTip = model.add("sphere"); // 火柴头(红色小球) + const flameNode = model.add("coneY"); // 火苗(点燃后出现) + const floor = model.add("square"); + const wallBack = model.add("square"); + const table = model.add("cube"); + const noteNode = model.add("square"); // 纸条 + const holeNode = model.add("square"); // 墙上的老鼠洞 + + const lampNode = new Gltf2Node({ url: "../../media/models/Chandelier.glb" }); + lampNode.scale = [0.04, 0.04, 0.04]; + lampNode.translation = [0, 1.5, 0]; + global.scene().addNode(lampNode); + + const monsterNode = new Gltf2Node({ url: "../../media/models/cute_slime.glb" }); + monsterNode.scale = [0.2, 0.2, 0.2]; + monsterNode.translation = [0, -1, 0]; // 初始位置(在桌子底下) + monsterNode.rotation = [0, 1, 0, 0]; + global.scene().addNode(monsterNode); + const BASE_NODES = 12; + + // ─── 状态变量 ──────────────────────────────────────── + let igniteBuffer = null; + await loadSound("media/sound/ignite.mp3", buffer => igniteBuffer = buffer); + let bgmBuffer = null; + await loadStereoSound("media/sound/bgm01.mp3", buffer => bgmBuffer = buffer); + if (bgmBuffer) + playStereoAudio(bgmBuffer); + + let lightLevel = 0.0; // 0 = 全黑,1.0 = 全亮 + const ROOM_Y_OFFSET = -0.05; // 整个房间下移 + const ROOM_Z_OFFSET = 0.4; // 整个房间向玩家移动 + + let matchBoxPos = [0.25, 0.9 + ROOM_Y_OFFSET, -0.55 + ROOM_Z_OFFSET]; + const notePos = [0.2, 0.862 + ROOM_Y_OFFSET, -0.55 + ROOM_Z_OFFSET]; + let matchPos = [...matchBoxPos]; + let matchHeld = false; + let matchHeldBy = null; + let matchBoxHeldBy = null; + let matchDir = [1, 0, 0]; + let flameLife = 0.0; + let noteRead = false; + + const holePos = [-0.8, 0.76 + ROOM_Y_OFFSET, -1.49 + ROOM_Z_OFFSET]; + const underTablePos = [0.2, 0.3 + ROOM_Y_OFFSET, -0.6 + ROOM_Z_OFFSET]; + let monsterPos = [...underTablePos]; + let monsterState = "HIDDEN"; + let monsterSnatchTimer = 0.0; + + // 划火柴手势检测 + let prevHandX = null; + let swipeSpeed = 0.0; + const SWIPE_THRESHOLD = 1.8; + + // ─── 数据记录 ──────────────────────────────────────── + const eventLog = []; + const logEvent = (type, hand, pos) => + eventLog.push({ t: model.time, type, hand, pos: [...(pos || [0,0,0])] }); + + // ─── 半径常量 ──────────────────────────────────────── + const GRAB_RADIUS = 0.18; + const NEAR_RADIUS = 0.32; + const STRIKE_RADIUS = 0.14; + const MATCH_LENGTH = 0.09; + const MATCH_TIP_OFFSET = MATCH_LENGTH + 0.005; + const MATCH_THICKNESS = 0.006; + const MATCH_TILT = -Math.PI / 6; + const tipOffset2D = () => ([ + -MATCH_TIP_OFFSET * Math.cos(MATCH_TILT), + -MATCH_TIP_OFFSET * Math.sin(MATCH_TILT), + ]); + + // ─── GAME_OVER_STAY 专用变量 ───────────────────────── + let gameOverTimer = 0.0; // 灯从亮到灭的倒计时累加器 + const GAME_OVER_FADE_DURATION = 4.0; // 灯光渐灭用多少秒 + + model.animate(() => { + // HUD 清理多余动态节点 + while (model.nChildren() > BASE_NODES) + model.remove(BASE_NODES); + + const t = model.time; + const dt = model.deltaTime; + + // 每帧从共享对象读取阶段,方便统一判断 + const gamePhase = getPhase(); + + const leftHand = clientState.finger(clientID, "left", 1); + const rightHand = clientState.finger(clientID, "right", 1); + const leftHandMat = clientState.hand(clientID, "left"); + const rightHandMat = clientState.hand(clientID, "right"); + const pinchLeft = clientState.pinch(clientID, "left", 1); + const pinchRight = clientState.pinch(clientID, "right", 1); + + const hands = [ + { pos: leftHand, side: "left", pinch: pinchLeft }, + { pos: rightHand, side: "right", pinch: pinchRight }, + ]; + + // ══════════════════════════════════════════════════ + // 阶段八:ACT2 — 完全交给 micro_world 处理 + // ══════════════════════════════════════════════════ + if (gamePhase === "ACT2") { + act2.render(model, t, hands); + return; + } + + // ══════════════════════════════════════════════════ + // 让火柴盒可抓取:若手上有盒子则跟随,否则保持原位 + // ══════════════════════════════════════════════════ + if (matchBoxHeldBy) { + if (matchBoxHeldBy === "monster") { + // 位置由怪物逻辑控制 + } else { + const boxPos = matchBoxHeldBy === "left" ? leftHand : rightHand; + const boxPinch = matchBoxHeldBy === "left" ? pinchLeft : pinchRight; + if (Array.isArray(boxPos) && boxPinch) { + matchBoxPos = [boxPos[0], boxPos[1], boxPos[2]]; + } else { + matchBoxHeldBy = null; + } + } + } + + // 保证同一只手不会同时持有火柴与火柴盒 + if (matchHeldBy && matchBoxHeldBy && matchHeldBy === matchBoxHeldBy) + matchBoxHeldBy = null; + + // ══════════════════════════════════════════════════ + // 阶段一:DARK — 找火柴盒 + // ══════════════════════════════════════════════════ + if (gamePhase === "DARK") { + flameNode.identity().scale(0); + matchTip.identity().scale(0); + + let nearMatch = false; + + for (const { pos: hPos, side, pinch } of hands) { + if (!Array.isArray(hPos)) continue; + const dist = cg.distance(hPos, matchBoxPos); + + if (dist < GRAB_RADIUS && pinch) { + matchHeld = true; + matchHeldBy = side; + prevHandX = hPos[0]; + matchPos[0] = hPos[0]; + matchPos[1] = hPos[1]; + matchPos[2] = hPos[2]; + setPhase("MATCH_HELD"); + logEvent("match_grabbed", side, matchBoxPos); + break; + } + if (dist < NEAR_RADIUS) nearMatch = true; + } + + const nearGlow = nearMatch ? (0.15 + 0.1 * Math.sin(10 * t)) : 0.03; + { + const boxMat = matchBoxHeldBy === "left" ? leftHandMat : + matchBoxHeldBy === "right" ? rightHandMat : null; + if (boxMat) + matchBox.identity().setMatrix(boxMat).scale(0.06, 0.03, 0.1) + .color(nearGlow * 2.5, nearGlow * 1.8, nearGlow * 0.6); + else + matchBox.identity().move(matchBoxPos).scale(0.06, 0.03, 0.1) + .color(nearGlow * 2.5, nearGlow * 1.8, nearGlow * 0.6); + } + { + const stripColor = [0.55 * nearGlow, 0.15 * nearGlow, 0.08 * nearGlow]; + const boxMat = matchBoxHeldBy === "left" ? leftHandMat : + matchBoxHeldBy === "right" ? rightHandMat : null; + if (boxMat) + matchBoxStrip.identity().setMatrix(boxMat).move(0.061, 0, 0).turnY(Math.PI / 2) + .scale(0.1, 0.03, 1).color(...stripColor); + else + matchBoxStrip.identity() + .move(matchBoxPos[0] + 0.061, matchBoxPos[1], matchBoxPos[2]) + .turnY(Math.PI / 2).scale(0.1, 0.03, 1).color(...stripColor); + } + matchStick.identity().scale(0); + } + + // ══════════════════════════════════════════════════ + // 阶段二:MATCH_HELD — 检测划火柴手势 + // ══════════════════════════════════════════════════ + if (gamePhase === "MATCH_HELD") { + flameNode.identity().scale(0); + + const hPos = matchHeldBy === "left" ? leftHand : rightHand; + const isPinching = matchHeldBy === "left" ? pinchLeft : pinchRight; + + if (!Array.isArray(hPos) || !isPinching) { + matchHeld = false; + matchHeldBy = null; + setPhase("DARK"); + } else { + matchPos[0] = hPos[0]; + matchPos[1] = hPos[1]; + matchPos[2] = hPos[2]; + const hMat = matchHeldBy === "left" ? leftHandMat : rightHandMat; + if (hMat) { + const d = cg.normalize([hMat[0], hMat[1], hMat[2]]); + if (d) matchDir = d; + } + + if (prevHandX !== null) { + swipeSpeed = Math.abs(hPos[0] - prevHandX) / dt; + } + prevHandX = hPos[0]; + + const nearBox = cg.distance(matchPos, matchBoxPos) < STRIKE_RADIUS; + if (swipeSpeed > SWIPE_THRESHOLD && nearBox) { + setPhase("LIGHTING"); + flameLife = 1.0; + logEvent("match_struck", matchHeldBy, matchPos); + if (igniteBuffer) { + playSoundAtPosition(igniteBuffer, matchPos, 3.0); + setTimeout(() => playSoundAtPosition(igniteBuffer, matchPos), 30); + } + } + } + + if (!matchBoxHeldBy) { + for (const { pos: bPos, side, pinch } of hands) { + if (side === matchHeldBy) continue; + if (!Array.isArray(bPos) || !pinch) continue; + if (cg.distance(bPos, matchBoxPos) < GRAB_RADIUS) { + matchBoxHeldBy = side; + matchBoxPos = [bPos[0], bPos[1], bPos[2]]; + break; + } + } + } + + { + const boxMat = matchBoxHeldBy === "left" ? leftHandMat : + matchBoxHeldBy === "right" ? rightHandMat : null; + if (boxMat) + matchBox.identity().setMatrix(boxMat).scale(0.06, 0.03, 0.1) + .color(0.6, 0.35, 0.1); + else + matchBox.identity().move(matchBoxPos).scale(0.06, 0.03, 0.1) + .color(0.6, 0.35, 0.1); + } + { + const stripR = 0.55 + 0.15 * Math.sin(6 * t); + const stripColor = [stripR, 0.15, 0.08]; + const boxMat = matchBoxHeldBy === "left" ? leftHandMat : + matchBoxHeldBy === "right" ? rightHandMat : null; + if (boxMat) + matchBoxStrip.identity().setMatrix(boxMat).move(0.061, 0, 0).turnY(Math.PI / 2) + .scale(0.1, 0.03, 1).color(...stripColor); + else + matchBoxStrip.identity() + .move(matchBoxPos[0] + 0.061, matchBoxPos[1], matchBoxPos[2]) + .turnY(Math.PI / 2).scale(0.1, 0.03, 1).color(...stripColor); + } + { + const hMat = matchHeldBy === "left" ? leftHandMat : rightHandMat; + if (hMat) + matchStick.identity().setMatrix(hMat).turnZ(MATCH_TILT) + .scale(MATCH_LENGTH, MATCH_THICKNESS, MATCH_THICKNESS) + .color(0.85, 0.75, 0.55); + else + matchStick.identity().move(matchPos).turnZ(MATCH_TILT) + .scale(MATCH_LENGTH, MATCH_THICKNESS, MATCH_THICKNESS) + .color(0.85, 0.75, 0.55); + } + { + const hMat = matchHeldBy === "left" ? leftHandMat : rightHandMat; + if (hMat) + matchTip.identity().setMatrix(hMat).turnZ(MATCH_TILT) + .move(-MATCH_TIP_OFFSET, 0, 0).scale(0.010) + .color(0.8, 0.12, 0.06); + else { + const [ox, oy] = tipOffset2D(); + matchTip.identity() + .move(matchPos[0] + ox, matchPos[1] + oy, matchPos[2]) + .scale(0.010) + .color(0.8, 0.12, 0.06); + } + } + } + + // ══════════════════════════════════════════════════ + // 阶段三:LIGHTING — 灯慢慢变亮 + // ══════════════════════════════════════════════════ + if (gamePhase === "LIGHTING") { + const hPos = matchHeldBy === "left" ? leftHand : rightHand; + const isPinching = matchHeldBy === "left" ? pinchLeft : pinchRight; + const hMat = matchHeldBy === "left" ? leftHandMat : rightHandMat; + if (Array.isArray(hPos) && isPinching) { + matchPos[0] = hPos[0]; + matchPos[1] = hPos[1]; + matchPos[2] = hPos[2]; + if (hMat) { + const d = cg.normalize([hMat[0], hMat[1], hMat[2]]); + if (d) matchDir = d; + } + } else { + matchHeld = false; + matchHeldBy = null; + } + + flameLife -= dt * 0.18; + flameLife = Math.max(flameLife, 0); + lightLevel += dt * 0.22; + + if (lightLevel >= 1.0) { + lightLevel = 1.0; + setPhase("LIT"); + logEvent("room_lit", null, [0,0,0]); + } + + const flameFlicker = flameLife * (0.8 + 0.2 * Math.sin(30 * t)); + if (hMat) + flameNode.identity() + .setMatrix(hMat) + .turnZ(MATCH_TILT + Math.PI / 2) + .move(0, MATCH_TIP_OFFSET * 1.1, 0) + .scale(0.015, 0.04 * flameFlicker, 0.015) + .color(1.0, 0.6 * flameFlicker, 0.05); + else { + const [ox, oy] = tipOffset2D(); + flameNode.identity() + .turnZ(Math.PI / 2) + .move(matchPos[0] + ox, matchPos[1] + oy, matchPos[2]) + .scale(0.015, 0.04 * flameFlicker, 0.015) + .color(1.0, 0.6 * flameFlicker, 0.05); + } + + if (!matchBoxHeldBy) { + for (const { pos: bPos, side, pinch } of hands) { + if (side === matchHeldBy) continue; + if (!Array.isArray(bPos) || !pinch) continue; + if (cg.distance(bPos, matchBoxPos) < GRAB_RADIUS) { + matchBoxHeldBy = side; + matchBoxPos = [bPos[0], bPos[1], bPos[2]]; + break; + } + } + } + + { + const boxMat = matchBoxHeldBy === "left" ? leftHandMat : + matchBoxHeldBy === "right" ? rightHandMat : null; + if (boxMat) + matchBox.identity().setMatrix(boxMat).scale(0.06, 0.03, 0.1) + .color(0.8, 0.45, 0.1); + else + matchBox.identity().move(matchBoxPos).scale(0.06, 0.03, 0.1) + .color(0.8, 0.45, 0.1); + } + { + const stripColor = [0.55, 0.15, 0.08]; + const boxMat = matchBoxHeldBy === "left" ? leftHandMat : + matchBoxHeldBy === "right" ? rightHandMat : null; + if (boxMat) + matchBoxStrip.identity().setMatrix(boxMat).move(0.061, 0, 0).turnY(Math.PI / 2) + .scale(0.1, 0.03, 1).color(...stripColor); + else + matchBoxStrip.identity() + .move(matchBoxPos[0] + 0.061, matchBoxPos[1], matchBoxPos[2]) + .turnY(Math.PI / 2).scale(0.1, 0.03, 1).color(...stripColor); + } + { + const hMatR = matchHeldBy === "left" ? leftHandMat : rightHandMat; + if (hMatR) + matchStick.identity().setMatrix(hMatR).turnZ(MATCH_TILT) + .scale(MATCH_LENGTH, MATCH_THICKNESS, MATCH_THICKNESS) + .color(0.9, 0.8, 0.6); + else + matchStick.identity().move(matchPos).turnZ(MATCH_TILT) + .scale(MATCH_LENGTH, MATCH_THICKNESS, MATCH_THICKNESS) + .color(0.9, 0.8, 0.6); + } + { + const hMatR = matchHeldBy === "left" ? leftHandMat : rightHandMat; + if (hMatR) + matchTip.identity().setMatrix(hMatR).turnZ(MATCH_TILT) + .move(-MATCH_TIP_OFFSET, 0, 0).scale(0.010) + .color(0.9, 0.18, 0.08); + else { + const [ox, oy] = tipOffset2D(); + matchTip.identity() + .move(matchPos[0] + ox, matchPos[1] + oy, matchPos[2]) + .scale(0.010) + .color(0.9, 0.18, 0.08); + } + } + } + + // ══════════════════════════════════════════════════ + // 阶段四:LIT — 第一幕结束 + // ══════════════════════════════════════════════════ + if (gamePhase === "LIT") { + let grabbed = false; + for (const { pos: hPos, side, pinch } of hands) { + if (side === matchBoxHeldBy) continue; + if (!Array.isArray(hPos)) continue; + const dist = cg.distance(hPos, matchPos); + if (dist < GRAB_RADIUS && pinch) { + matchHeld = true; + matchHeldBy = side; + matchPos[0] = hPos[0]; + matchPos[1] = hPos[1]; + matchPos[2] = hPos[2]; + const hMat = side === "left" ? leftHandMat : rightHandMat; + if (hMat) { + const d = cg.normalize([hMat[0], hMat[1], hMat[2]]); + if (d) matchDir = d; + } + grabbed = true; + break; + } + } + if (!grabbed) { + matchHeld = false; + matchHeldBy = null; + } + + if (!matchBoxHeldBy) { + for (const { pos: bPos, side, pinch } of hands) { + if (side === matchHeldBy) continue; + if (!Array.isArray(bPos) || !pinch) continue; + if (cg.distance(bPos, matchBoxPos) < GRAB_RADIUS) { + matchBoxHeldBy = side; + matchBoxPos = [bPos[0], bPos[1], bPos[2]]; + break; + } + } + } + + { + const boxMat = matchBoxHeldBy === "left" ? leftHandMat : + matchBoxHeldBy === "right" ? rightHandMat : null; + if (boxMat) + matchBox.identity().setMatrix(boxMat).scale(0.06, 0.03, 0.1) + .color(0.55 * lightLevel, 0.38 * lightLevel, 0.18 * lightLevel); + else + matchBox.identity().move(matchBoxPos).scale(0.06, 0.03, 0.1) + .color(0.55 * lightLevel, 0.38 * lightLevel, 0.18 * lightLevel); + } + { + const stripColor = [0.55 * lightLevel, 0.15 * lightLevel, 0.08 * lightLevel]; + const boxMat = matchBoxHeldBy === "left" ? leftHandMat : + matchBoxHeldBy === "right" ? rightHandMat : null; + if (boxMat) + matchBoxStrip.identity().setMatrix(boxMat).move(0.061, 0, 0).turnY(Math.PI / 2) + .scale(0.1, 0.03, 1).color(...stripColor); + else + matchBoxStrip.identity() + .move(matchBoxPos[0] + 0.061, matchBoxPos[1], matchBoxPos[2]) + .turnY(Math.PI / 2).scale(0.1, 0.03, 1).color(...stripColor); + } + { + const hMat = matchHeldBy === "left" ? leftHandMat : rightHandMat; + if (hMat && matchHeld) + matchStick.identity().setMatrix(hMat).turnZ(MATCH_TILT) + .scale(MATCH_LENGTH, MATCH_THICKNESS, MATCH_THICKNESS) + .color(0.7 * lightLevel, 0.6 * lightLevel, 0.4 * lightLevel); + else + matchStick.identity().move(matchPos).turnZ(MATCH_TILT) + .scale(MATCH_LENGTH, MATCH_THICKNESS, MATCH_THICKNESS) + .color(0.7 * lightLevel, 0.6 * lightLevel, 0.4 * lightLevel); + } + { + const hMat = matchHeldBy === "left" ? leftHandMat : rightHandMat; + if (hMat && matchHeld) + matchTip.identity().setMatrix(hMat).turnZ(MATCH_TILT) + .move(-MATCH_TIP_OFFSET, 0, 0).scale(0.010) + .color(0.8 * lightLevel, 0.12 * lightLevel, 0.06 * lightLevel); + else { + const [ox, oy] = tipOffset2D(); + matchTip.identity() + .move(matchPos[0] + ox, matchPos[1] + oy, matchPos[2]) + .scale(0.010) + .color(0.8 * lightLevel, 0.12 * lightLevel, 0.06 * lightLevel); + } + } + flameNode.identity().scale(0); + + // ─── FIX #3: 降低低头触发阈值 ──────────────── + // 原始阈值 0.0 + ROOM_Y_OFFSET = -0.05m,玩家几乎不可能触发。 + // 改为 0.9m(站立时头部高度约 1.6m,弯腰低头时约 1.0–1.1m)。 + const headHeight = clientState.head ? clientState.head(clientID)[1] : + (Array.isArray(leftHand) ? leftHand[1] : 1.5); + + if (noteRead && headHeight < 0.9) { + monsterState = "REVEALED"; + setPhase("MONSTER_EVENT"); + logEvent("monster_revealed", null, monsterPos); + } + } + + // ══════════════════════════════════════════════════ + // 阶段五:MONSTER_EVENT — 现身、对视与抢夺 + // ══════════════════════════════════════════════════ + if (gamePhase === "MONSTER_EVENT") { + + if (monsterState === "REVEALED") { + monsterSnatchTimer += dt; + if (monsterSnatchTimer > 1.0) { + monsterState = "JUMP_TO_TABLE"; + monsterSnatchTimer = 0.0; + } + } + else if (monsterState === "JUMP_TO_TABLE") { + const targetPos = [matchBoxPos[0] + 0.15, matchBoxPos[1], matchBoxPos[2]]; + const speed = 2.0; + const dx = targetPos[0] - monsterPos[0]; + const dy = targetPos[1] - monsterPos[1]; + const dz = targetPos[2] - monsterPos[2]; + const dist = Math.sqrt(dx*dx + dy*dy + dz*dz); + + if (dist > 0.05) { + monsterPos[0] += (dx / dist) * speed * dt; + monsterPos[1] += (dy / dist) * speed * dt; + monsterPos[2] += (dz / dist) * speed * dt; + } else { + monsterState = "TAUNTING"; + } + } + else if (monsterState === "TAUNTING") { + monsterSnatchTimer += dt; + if (monsterSnatchTimer > 1.5) { + monsterState = "SNATCHING"; + } + } + else if (monsterState === "SNATCHING") { + const speed = 2.0; + const dx = matchBoxPos[0] - monsterPos[0]; + const dy = matchBoxPos[1] - monsterPos[1]; + const dz = matchBoxPos[2] - monsterPos[2]; + const dist = Math.sqrt(dx*dx + dy*dy + dz*dz); + + if (dist > 0.05) { + monsterPos[0] += (dx / dist) * speed * dt; + monsterPos[1] += (dy / dist) * speed * dt; + monsterPos[2] += (dz / dist) * speed * dt; + } else { + matchBoxHeldBy = "monster"; + monsterState = "ESCAPING"; + } + } + else if (monsterState === "ESCAPING") { + matchBoxPos[0] = monsterPos[0]; + matchBoxPos[1] = monsterPos[1] + 0.05; + matchBoxPos[2] = monsterPos[2]; + + const speed = 2.5; + const dx = holePos[0] - monsterPos[0]; + const dy = holePos[1] - monsterPos[1]; + const dz = holePos[2] - monsterPos[2]; + const dist = Math.sqrt(dx*dx + dy*dy + dz*dz); + + if (dist > 0.2) { + monsterPos[0] += (dx / dist) * speed * dt; + monsterPos[1] += (dy / dist) * speed * dt; + monsterPos[2] += (dz / dist) * speed * dt; + } else { + monsterState = "IDLE_NPC"; + setPhase("QUEST_HUB"); + matchBoxHeldBy = null; + logEvent("entered_quest_hub", null, holePos); + } + } + + if (matchBoxHeldBy === "monster") { + matchBox.identity().move(matchBoxPos).scale(0.06, 0.03, 0.1) + .color(0.55 * lightLevel, 0.38 * lightLevel, 0.18 * lightLevel); + matchBoxStrip.identity() + .move(matchBoxPos[0] + 0.061, matchBoxPos[1], matchBoxPos[2]) + .turnY(Math.PI / 2).scale(0.1, 0.03, 1) + .color(0.55 * lightLevel, 0.15 * lightLevel, 0.08 * lightLevel); + } else { + matchBox.identity().move(matchBoxPos).scale(0.06, 0.03, 0.1) + .color(0.55 * lightLevel, 0.38 * lightLevel, 0.18 * lightLevel); + matchBoxStrip.identity() + .move(matchBoxPos[0] + 0.061, matchBoxPos[1], matchBoxPos[2]) + .turnY(Math.PI / 2).scale(0.1, 0.03, 1) + .color(0.55 * lightLevel, 0.15 * lightLevel, 0.08 * lightLevel); + } + } + + // ══════════════════════════════════════════════════ + // 阶段六:QUEST_HUB — 任务选择 + // ══════════════════════════════════════════════════ + if (gamePhase === "QUEST_HUB") { + // ─── FIX #2: 统一按钮位置 ───────────────────── + // 原来碰撞检测和文字标签各自定义了不同的 uiPos,导致视觉与交互偏移。 + // 现在只定义一套坐标,碰撞检测、按钮几何体、文字标签全部用同一套。 + const uiPos = [0.12, 1.3, -0.6]; + const titlePos = [uiPos[0] - 0.08, uiPos[1] + 0.04, uiPos[2] + 0.01]; + const followBtnPos = [uiPos[0] - 0.15, uiPos[1] - 0.04, uiPos[2]]; + const stayBtnPos = [uiPos[0] + 0.15, uiPos[1] - 0.04, uiPos[2]]; + + // 定位可交互按钮方块(供 hitRect 检测用) + window.followBtn.identity().move(...followBtnPos).scale(0.06, 0.03, 0.01); + window.stayBtn.identity().move(...stayBtnPos).scale(0.06, 0.03, 0.01); + + window.beamR.update(); + + let isPointingFollow = false; + let isPointingStay = false; + let isSelecting = false; + + if (window.beamR.hitRect(window.followBtn.getGlobalMatrix())) { + isPointingFollow = true; + window.followBtn.color(0.8, 0.8, 1); + } else { + window.followBtn.color(0.2, 0.5, 0.2); + } + + if (window.beamR.hitRect(window.stayBtn.getGlobalMatrix())) { + isPointingStay = true; + window.stayBtn.color(1, 0.8, 0.8); + } else { + window.stayBtn.color(0.5, 0.2, 0.2); + } + + // 近身手部判定(手追踪 fallback) + let isNearFollow = false; + let isNearStay = false; + for (const { pos: hPos, pinch } of hands) { + if (!Array.isArray(hPos)) continue; + if (cg.distance(hPos, followBtnPos) < 0.12) { isNearFollow = true; if (pinch) isSelecting = true; } + if (cg.distance(hPos, stayBtnPos) < 0.12) { isNearStay = true; if (pinch) isSelecting = true; } + } + for (const { pinch, pressed } of hands) { + if (pinch || pressed) isSelecting = true; + } + + if (isSelecting) { + if (isPointingFollow || isNearFollow) { + choiceMade = "FOLLOW"; + setPhase("PORTAL_TRANSITION"); + window.portalStartTime = t; + monsterState = "HIDDEN"; + matchHeld = false; + matchHeldBy = null; + matchBoxHeldBy = null; + } else if (isPointingStay || isNearStay) { + choiceMade = "STAY"; + setPhase("GAME_OVER_STAY"); + gameOverTimer = 0.0; + } + } + + // 文字标签对齐按钮坐标 + model.add("cube") + .move(uiPos[0], uiPos[1], uiPos[2] - 0.01) + .scale(0.38, 0.15, 0.005) + .color(1, 1, 1, 0.1); + + model.add(clay.text("Follow the little thief?")) + .move(...titlePos).scale(0.7).color(0, 0, 0); + + model.add(clay.text("[ YES ]")) + .move(...followBtnPos).scale(0.5) + .color(isPointingFollow || isNearFollow ? [0, 0.6, 0] : [0.3, 0.3, 0.3]); + + model.add(clay.text("[ NO ]")) + .move(...stayBtnPos).scale(0.5) + .color(isPointingStay || isNearStay ? [0.8, 0, 0] : [0.3, 0.3, 0.3]); + + } else { + if (window.followBtn) window.followBtn.identity().scale(0); + if (window.stayBtn) window.stayBtn.identity().scale(0); + } + + // ══════════════════════════════════════════════════ + // FIX #4:阶段 GAME_OVER_STAY — 留下时的结局 + // ══════════════════════════════════════════════════ + if (gamePhase === "GAME_OVER_STAY") { + gameOverTimer += dt; + const fadeProgress = Math.min(gameOverTimer / GAME_OVER_FADE_DURATION, 1.0); + + // 灯光渐灭 + lightLevel = 1.0 - fadeProgress; + + // 全屏遮罩:随灯光变暗越来越浓 + if (fadeProgress > 0.1) { + model.add("cube") + .move(0, ROOM_Y_OFFSET, ROOM_Z_OFFSET) + .scale(10) + .color(0, 0, 0); + } + + // 屏幕中央文字 + const alpha = Math.min(fadeProgress * 2, 1.0); + if (alpha > 0.1) { + model.add(clay.text("You chose to stay.")) + .move(-0.25, 1.5, -0.8) + .scale(1.2) + .color(alpha * 0.8, alpha * 0.7, alpha * 0.6); + + if (fadeProgress > 0.6) { + model.add(clay.text("The light fades.")) + .move(-0.18, 1.35, -0.8) + .scale(1.0) + .color(alpha * 0.5, alpha * 0.4, alpha * 0.4); + } + } + } + + // ══════════════════════════════════════════════════ + // 阶段七:PORTAL_TRANSITION — 传送门动画 + // ══════════════════════════════════════════════════ + if (gamePhase === "PORTAL_TRANSITION") { + let pTime = t - window.portalStartTime; + let duration = 5.0; + let progress = pTime / duration; + + let isDarkFlicker = Math.sin(t * 20 * progress) < 0; + if (isDarkFlicker) { + model.add("cube") + .move(0, ROOM_Y_OFFSET, ROOM_Z_OFFSET) + .scale(10) + .color(0, 0, 0); + } + + for (let i = 0; i < 5; i++) { + let spin = t * (1 + i); + let layerScale = 0.15 + Math.sin(t * 2 + i) * 0.05; + if (progress > 0.8) layerScale += (progress - 0.8) * 50; + + model.add("square") + .move(holePos[0], holePos[1], holePos[2] + 0.01 + i * 0.001) + .turnZ(spin) + .scale(layerScale) + .color(0.1, 0.6 + i * 0.1, 1.0); + } + + for (let i = 0; i < 8; i++) { + let pOffset = ((t * 2 + i * 0.5) % 2); + model.add("sphere") + .move(holePos[0] * (1 - pOffset), 1.5, holePos[2] * (1 - pOffset)) + .scale(0.01) + .color(0.5, 0.8, 1); + } + + let shake = (Math.random() - 0.5) * 0.02 * progress; + model.add(clay.text("ACT II : THE MICRO-WORLD")) + .move(shake, 1.5 + shake, -0.8) + .scale(1.5 + progress * 0.05) + .color(0, 0, 0); + + if (pTime > duration) { + setPhase("ACT2"); + act2.resetScene(); // 通知 micro_world 重置状态,避免遗留帧数据 + while (model.nChildren() > 0) model.remove(0); + } + } + + // ══════════════════════════════════════════════════ + // 场景渲染 — 所有颜色乘以 lightLevel + // ══════════════════════════════════════════════════ + const l = lightLevel; + + floor.identity() + .move(0, 0.2 + ROOM_Y_OFFSET, -1.5 + ROOM_Z_OFFSET) + .turnX(-Math.PI / 2).scale(3.0, 3.0, 1) + .color(0.12 * l, 0.09 * l, 0.07 * l); + + wallBack.identity() + .move(0, 1.8 + ROOM_Y_OFFSET, -2 + ROOM_Z_OFFSET) + .scale(3.5, 2, 1) + .color(0.15 * l, 0.11 * l, 0.09 * l); + + table.identity() + .move(0.2, 0.8 + ROOM_Y_OFFSET, -0.6 + ROOM_Z_OFFSET) + .scale(0.5, 0.06, 0.35) + .color(0.28 * l, 0.18 * l, 0.1 * l); + + noteNode.identity() + .move(...notePos) + .turnX(-Math.PI / 2) + .scale(0.08, 0.06, 1) + .color(0.9 * l, 0.85 * l, 0.7 * l); + + if (l > 0.5 && !noteRead) { + for (const { pos: hPos } of hands) { + if (!Array.isArray(hPos)) continue; + if (cg.distance(hPos, notePos) < GRAB_RADIUS) { + noteRead = true; + logEvent("note_read", null, notePos); + break; + } + } + } + + holeNode.identity() + .move(...holePos) + .scale(0.12, 0.15, 1) + .color(0.02 * l, 0.01 * l, 0.01 * l); + + // 渲染小捣蛋鬼 + if (monsterState !== "HIDDEN" && monsterState !== "IDLE_NPC") { + const bounce = 0.06 * Math.abs(Math.sin(15 * t)); + monsterNode.matrix = cg.mMultiply( + cg.mTranslate(monsterPos[0], monsterPos[1] + bounce, monsterPos[2]), + cg.mMultiply( + cg.mRotateY(Math.PI), + cg.mScale(0.2, 0.2, 0.2) + ) + ); + } else { + monsterNode.matrix = cg.mScale(0, 0, 0); + if (gamePhase === "QUEST_HUB") { + matchBox.identity().scale(0); + matchBoxStrip.identity().scale(0); + global.scene().removeNode(lampNode); + } + } + + // Hint 文字 + const hint = + gamePhase === "DARK" ? "FIND SOMETHING IN THE DARK..." : + gamePhase === "MATCH_HELD" ? "STRIKE THE MATCH — SWIPE FAST" : + gamePhase === "LIGHTING" ? "..." : + gamePhase === "LIT" ? (noteRead ? "WHO WROTE THIS...?" : "ACT I COMPLETE") : + gamePhase === "MONSTER_EVENT" ? "HEY! MY MATCHBOX!" : + gamePhase === "QUEST_HUB" ? "TALK TO THE LITTLE THIEF..." : + gamePhase === "GAME_OVER_STAY" ? "" : ""; + + const hintColor = + gamePhase === "DARK" ? [0.4, 0.4, 0.5] : + gamePhase === "MATCH_HELD" ? [0.9, 0.7, 0.3] : + gamePhase === "LIGHTING" ? [1.0, 0.8, 0.4] : + [0.5, 1.0, 0.7]; + + if (hint) { + model.add(clay.text(hint)) + .move(-0.8, 2 + ROOM_Y_OFFSET, -1.8 + ROOM_Z_OFFSET).scale(1.2) + .color(...hintColor); + } + + model.add(clay.text("EVENTS: " + eventLog.length)) + .move(-0.8, 1.8 + ROOM_Y_OFFSET, -1.8 + ROOM_Z_OFFSET).scale(0.85) + .color(0.5, 0.5, 0.7); + + if (l > 0.2) { + model.add(clay.text("You are not alone.")) + .move(notePos[0] - 0.03, notePos[1] + 0.001, notePos[2]) + .turnX(-Math.PI / 2) + .scale(0.4) + .color(0.08 * l, 0.06 * l, 0.04 * l); + + if (noteRead) { + model.add(clay.text("CHECK UNDER THE TABLE.")) + .move(notePos[0] - 0.03, notePos[1] + 0.001, notePos[2] + 0.02) + .turnX(-Math.PI / 2).scale(0.4) + .color(0.6 * l, 0.3 * l, 0.1 * l); + } + } + }); +}; + +export const deinit = () => { + stopStereoLoopingAudio(); +}; diff --git a/js/scenes/final_old/micro_world.js b/js/scenes/final_old/micro_world.js new file mode 100644 index 0000000..d2cef19 --- /dev/null +++ b/js/scenes/final_old/micro_world.js @@ -0,0 +1,217 @@ +import * as cg from "../../render/core/cg.js"; +import * as global from "../../global.js"; +import { Gltf2Node } from "../../render/nodes/gltf2.js"; +import * as customFire from "../campFire.js"; + +let startTime = 0; +let campfireLit = false; +let fireStartTime = 0; +let riftOpened = false; + +// 新增:追踪模型是否已经被物理替换 +let modelsSwapped = false; + +let isInitialized = false; +let monsterNode; + +// 存储节点的数组 +let snowFloorNodes = []; +let greenFloorNodes = []; +let snowTreeNodes = []; +let greenTreeNodes = []; +let plantNodes = []; + +// 预定义位置数据 (x, y, z, rotY, scale) +const floorData = [ + [-3, -4, -6, 0, 3], [0, -4, -6, 0, 3], [3, -4, -6, 0, 3], + [-3, -4, -3, 0, 3], [0, -4, -3, 0, 3], [3, -4, -3, 0, 3], + [-3, -4, 0, 0, 3], [0, -4, 0, 0, 3], [3, -4, 0, 0, 3] +]; + +const treeData = [ + [-4, -1.0, -4, 0.5, 2.5], + [ 3, -1.0, -4.5, -0.3, 3], + [-3.5, -1.0, -1, 1.2, 2.0], + [ 4, -1.0, -1.5, -0.8, 2.2] +]; + +const plantData = [ + [-2.5, -1.0, -3.5, 0.4, 1.5], + [ 2.0, -1.0, -3.0, -0.2, 1.8], + [-1.5, -1.0, -2.0, 1.0, 1.2], + [ 1.5, -1.0, -1.5, -0.5, 1.6], + [-0.5, -1.0, -3.5, 0.1, 2.0] +]; + +export const render = (model, t, hands) => { + if (startTime === 0) startTime = t; + let elapsed = t - startTime; + + // ══════════════════════════════════════════════════ + // 初始化加载 (只将雪地模型加入场景) + // ══════════════════════════════════════════════════ + if (!isInitialized) { + monsterNode = new Gltf2Node({ url: "../../media/models/cute_slime.glb" }); + global.scene().addNode(monsterNode); + + // 初始化地板 + for (let i = 0; i < floorData.length; i++) { + let [x, y, z, rotY, s] = floorData[i]; + let matrix = cg.mMultiply(cg.mTranslate(x, y, z), cg.mMultiply(cg.mRotateY(rotY), cg.mScale(s, s, s))); + + let snowF = new Gltf2Node({ url: "../../media/models/nature/block-snow-large.glb" }); + snowF.matrix = matrix; + global.scene().addNode(snowF); // 只有雪地被加进场景 + snowFloorNodes.push(snowF); + + let greenF = new Gltf2Node({ url: "../../media/models/nature/block-grass-large.glb" }); + greenF.matrix = matrix; // 提前算好矩阵,但不加进场景 + greenFloorNodes.push(greenF); + } + + // 初始化树木 + for (let i = 0; i < treeData.length; i++) { + let [x, y, z, rotY, s] = treeData[i]; + let matrix = cg.mMultiply(cg.mTranslate(x, y, z), cg.mMultiply(cg.mRotateY(rotY), cg.mScale(s, s, s))); + + let snowUrl = i % 2 === 0 ? "../../media/models/nature/tree-snow.glb" : "../../media/models/nature/tree-pine-snow.glb"; + let greenUrl = i % 2 === 0 ? "../../media/models/nature/tree.glb" : "../../media/models/nature/tree-pine.glb"; + + let snowT = new Gltf2Node({ url: snowUrl }); + snowT.matrix = matrix; + global.scene().addNode(snowT); // 只有雪树被加进场景 + snowTreeNodes.push(snowT); + + let greenT = new Gltf2Node({ url: greenUrl }); + greenT.matrix = matrix; + greenTreeNodes.push(greenT); + } + + // 初始化植物 (不加进场景) + for (let i = 0; i < plantData.length; i++) { + let plantUrl = i % 2 === 0 ? "../../media/models/nature/mushrooms.glb" : "../../media/models/nature/flowers.glb"; + let plant = new Gltf2Node({ url: plantUrl }); + plantNodes.push(plant); + } + + isInitialized = true; + } + + while (model.nChildren() > 0) model.remove(0); + + // 巨型火柴盒背景 + model.add("cube").move(-3, 0.5, -5).turnY(0.4).scale(2, 0.8, 3).color(0.8, 0.1, 0.1); + model.add("square").move(-2.8, 0.5, -3.5).turnY(0.4).scale(2, 0.8, 3).color(0.2, 0.2, 0.2); + + let firePos = [0, 0.5, -2]; + let npcPos = [1.5, 1.0 + Math.sin(t * 3) * 0.05, -3]; + + monsterNode.matrix = cg.mMultiply( + cg.mTranslate(npcPos[0], npcPos[1], npcPos[2]), + cg.mMultiply(cg.mRotateY(Math.PI - 0.5), + cg.mScale(1, 1, 1)) + ); + + // ══════════════════════════════════════════════════ + // 交互逻辑 + // ══════════════════════════════════════════════════ + let dialogue = ""; + + if (!campfireLit) { + if (elapsed < 3) dialogue = "YOU FOLLOWED ME..."; + else if (elapsed < 8) dialogue = "I HAD TO STEAL IT. MY WORLD IS FROZEN."; + else dialogue = "I NEED TO LIGHT THE FIRE."; + + model.add("sphere").move(...firePos).scale(0.1).color(0.1, 0.1, 0.1); + + // 点火判定 + for (let i = 0; i < hands.length; i++) { + if (hands[i].pos && cg.distance(hands[i].pos, firePos) < 0.6) { + campfireLit = true; + fireStartTime = t; + } + } + } else { + let timeSinceLit = t - fireStartTime; + + // ─── 直接物理替换节点 (Direct Remove & Add) ─── + if (!modelsSwapped) { + // 1. 删除所有雪地模型 + for (let i = 0; i < snowFloorNodes.length; i++) global.scene().removeNode(snowFloorNodes[i]); + for (let i = 0; i < snowTreeNodes.length; i++) global.scene().removeNode(snowTreeNodes[i]); + + // 2. 将绿地模型和植物加入场景 + for (let i = 0; i < greenFloorNodes.length; i++) global.scene().addNode(greenFloorNodes[i]); + for (let i = 0; i < greenTreeNodes.length; i++) global.scene().addNode(greenTreeNodes[i]); + for (let i = 0; i < plantNodes.length; i++) global.scene().addNode(plantNodes[i]); + + modelsSwapped = true; // 确保只执行一次! + } + + if (timeSinceLit < 6) { + dialogue = "THANK YOU! THE ICE IS MELTING!"; + + let pulse = 0.5 + 0.15 * Math.sin(t * 8); + let fScale = 0.3 + pulse * 0.05; + + model.add("sphere").move(...firePos).scale(fScale, fScale*1.5, fScale).color(1, pulse * 0.8, 0); + + // 给刚加进场景的植物计算生长动画 + let growth = Math.min(1, timeSinceLit / 2.0); + let bouncyGrowth = growth + 0.1 * Math.sin(growth * Math.PI) * (1 - growth); + + for (let i = 0; i < plantNodes.length; i++) { + let [x, y, z, rotY, targetScale] = plantData[i]; + let currentScale = bouncyGrowth * targetScale; + plantNodes[i].matrix = cg.mMultiply( + cg.mTranslate(x, y, z), + cg.mMultiply(cg.mRotateY(rotY), cg.mScale(currentScale, currentScale, currentScale)) + ); + } + } else { + if (!riftOpened) riftOpened = true; + dialogue = "WAIT... THE FIRE IS TOO STRONG! TOUCH THE RIFT!"; + + let alienPulse = 0.5 + 0.5 * Math.sin(t * 15); + model.add("sphere").move(...firePos).scale(0.5 + alienPulse * 0.2).color(0, 1, 0.8 + alienPulse * 0.2); + + for (let i = 0; i < hands.length; i++) { + if (hands[i].pos && cg.distance(hands[i].pos, firePos) < 0.8) { + window.sharedState.gamePhase = "SCENE_3"; + } + } + } + } + + model.add(clay.text(dialogue)) + .move(npcPos[0] - 0.5, npcPos[1] + 1, npcPos[2]) + .scale(3.5) + .color(1, 1, 1); +}; + +export const resetScene = () => { + // 时间,流程 + startTime = 0; + campfireLit = false; + fireStartTime = 0; + riftOpened = false; + + // 状态 + modelsSwapped = false; + isInitialized = false; + + // 清空节点数组 + snowFloorNodes = []; + greenFloorNodes = []; + snowTreeNodes = []; + greenTreeNodes = []; + plantNodes = []; + + // 可选:移除旧 monster + if (monsterNode) { + global.scene().removeNode(monsterNode); + monsterNode = null; + } + + console.log("Scene 2 reset"); +}; diff --git a/js/scenes/final_project.js b/js/scenes/final_project.js new file mode 100644 index 0000000..c551d87 --- /dev/null +++ b/js/scenes/final_project.js @@ -0,0 +1,1032 @@ +import * as cg from "../render/core/cg.js"; +import { loadSound, playSoundAtPosition } from "../util/positional-audio.js"; +import { loadStereoSound, playStereoAudio, stopStereoLoopingAudio } from "../util/stereo-audio.js"; +import * as act2 from "./micro_world.js"; +import * as global from "../global.js"; +import { Gltf2Node } from "../render/nodes/gltf2.js"; +import { ControllerBeam } from "../render/core/controllerInput.js"; + +// ─── 游戏阶段 ─────────────────────────────────────────── +// "INTRO" → 故事序章,逐行展示文字后自动切换 +// "DARK" → 全黑,等待玩家找到火柴 +// "MATCH_HELD" → 火柴在手,等待划火柴手势 +// "LIGHTING" → 点火成功,灯慢慢变亮 +// "LIT" → 第一幕完成,准备进入第二幕 +// "MONSTER_EVENT" → 小怪物出现并抢走火柴盒 +// "QUEST_HUB" → 玩家做选择:跟随或留下 +// "PORTAL_TRANSITION"→ 传送门动画 +// "ACT2" → 进入微观世界 +// "GAME_OVER_STAY" → 玩家选择留下,灯灭结局 + +// ─── FIX #1: 用 window.sharedState 暴露 gamePhase ─────── +// micro_world.js 需要能把游戏推进到 SCENE_3, +// 所以我们把 gamePhase 放进一个全局共享对象,而不是用 let。 +window.sharedState = { gamePhase: "INTRO" }; + +// 方便读写的本地别名(每次读/写都通过 window.sharedState.gamePhase) +const getPhase = () => window.sharedState.gamePhase; +const setPhase = (p) => { window.sharedState.gamePhase = p; }; + +// 玩家的选择和倒计时 +let choiceMade = null; // "FOLLOW" 或 "STAY" +let stayTimer = 10; // 如果不跟,10秒后灯灭 + +export const init = async model => { + // right controller + window.beamR = new ControllerBeam(model, 'right'); + window.rightClick = false; + // Create the buttons ONCE + window.followBtn = model.add('square'); + window.stayBtn = model.add('square'); + + inputEvents.onPress = hand => { + if (hand === 'right') window.rightClick = true; + }; + + // ─── 场景节点 ──────────────────────────────────────── + const matchBox = model.add("cube"); // 火柴盒 + const matchBoxStrip = model.add("square"); // 火柴盒侧面擦火条 + const matchStick = model.add("cube"); // 火柴(从盒中取出) + const matchTip = model.add("sphere"); // 火柴头(红色小球) + const flameNode = model.add("coneY"); // 火苗(点燃后出现) + const floor = model.add("square"); + const wallBack = model.add("square"); + const table = model.add("cube"); + const noteNode = model.add("square"); // 纸条 + const holeNode = model.add("square"); // 墙上的老鼠洞 + + const lampNode = new Gltf2Node({ url: "../../media/models/Chandelier.glb" }); + lampNode.scale = [0.04, 0.04, 0.04]; + lampNode.translation = [0, 1.5, 0]; + global.scene().addNode(lampNode); + + const monsterNode = new Gltf2Node({ url: "../../media/models/cute_slime.glb" }); + monsterNode.scale = [0.2, 0.2, 0.2]; + monsterNode.translation = [0, -1, 0]; // 初始位置(在桌子底下) + monsterNode.rotation = [0, 1, 0, 0]; + global.scene().addNode(monsterNode); + const BASE_NODES = 12; + + // ─── 状态变量 ──────────────────────────────────────── + // Add these with your other state variables + let monsterLine1Buffer = null; + let monsterLine2Buffer, monsterLine3Buffer; + let playedLine1 = false, playedLine2 = false, playedLine3 = false; + let igniteBuffer = null; + await loadSound("media/sound/ignite.mp3", buffer => igniteBuffer = buffer); + let scratchBuffer = null; + await loadSound("media/sound/scratch.mp3", buffer => scratchBuffer = buffer); + let bgmBuffer = null; + await loadStereoSound("media/sound/bgm01.mp3", buffer => bgmBuffer = buffer); + if (bgmBuffer) + playStereoAudio(bgmBuffer); + + await loadSound("media/sound/monster_line1.mp3", buffer => { + console.log("Monster sound 01 loaded!"); // Add this to check console + monsterLine1Buffer = buffer; + }); + await loadSound("media/sound/monster_line2.mp3", b => monsterLine2Buffer = b); + await loadSound("media/sound/monster_line3.mp3", b => monsterLine3Buffer = b); + + + let lightLevel = 0.0; // 0 = 全黑,1.0 = 全亮 + const ROOM_Y_OFFSET = -0.05; // 整个房间下移 + const ROOM_Z_OFFSET = 0.4; // 整个房间向玩家移动 + + let matchBoxPos = [0.25, 0.9 + ROOM_Y_OFFSET, -0.55 + ROOM_Z_OFFSET]; + const notePos = [0.2, 0.862 + ROOM_Y_OFFSET, -0.55 + ROOM_Z_OFFSET]; + let matchPos = [...matchBoxPos]; + let matchHeld = false; + let matchHeldBy = null; + let matchBoxHeldBy = null; + let matchDir = [1, 0, 0]; + let flameLife = 0.0; + let lastScratchTime = 0; + let noteRead = false; + + const holePos = [-0.8, 0.76 + ROOM_Y_OFFSET, -1.49 + ROOM_Z_OFFSET]; + const underTablePos = [0.2, 0.3 + ROOM_Y_OFFSET, -0.6 + ROOM_Z_OFFSET]; + let monsterPos = [holePos[0], holePos[1], holePos[2] + 0.1]; + let monsterState = "HIDDEN"; + let monsterSnatchTimer = 0.0; + + // 划火柴手势检测 + let prevHandX = null; + let swipeSpeed = 0.0; + const SWIPE_THRESHOLD = 1.8; + + // ─── INTRO 专用变量 ────────────────────────────────── + // 序章由一系列时间戳驱动的文字卡片组成,最后一张卡片显示后 + // 玩家捏合(pinch)或等待超时即可跳入真正的游戏。 + let introStartTime = null; // 第一帧时初始化为 t + let introDone = false; // 序章是否已结束 + const INTRO_SKIP_PINCH = true; // 允许 pinch 跳过序章 + + const INTRO_CARDS = [ + [0.0, "A MATCH FACTORY. END OF THE NIGHT SHIFT.", [0.55, 0.50, 0.45], 2.0], + [4.0, "ONE LAST TASK: TEST THE NEW BATCH.", [0.45, 0.40, 0.38], 2.0], + [8.0, "STRIKE A MATCH. MAKE SURE THEY WORK.", [0.70, 0.65, 0.55], 2.0], + [12.0, "THEN YOU CAN GO HOME.", [0.90, 0.80, 0.50], 2.0], + [16.0, "[ PINCH TO BEGIN — or wait ]", [0.35, 0.35, 0.40], 2.0], + ]; + const INTRO_AUTO_END = 40.0; // 没有 pinch 时,(40-16)s 后自动进入游戏 + + // ─── 数据记录 ──────────────────────────────────────── + const eventLog = []; + const logEvent = (type, hand, pos) => + eventLog.push({ t: model.time, type, hand, pos: [...(pos || [0,0,0])] }); + + // ─── 半径常量 ──────────────────────────────────────── + const GRAB_RADIUS = 0.18; + const NEAR_RADIUS = 0.32; + const STRIKE_RADIUS = 0.14; + const MATCH_LENGTH = 0.09; + const MATCH_TIP_OFFSET = MATCH_LENGTH + 0.005; + const MATCH_THICKNESS = 0.006; + const MATCH_TILT = -Math.PI / 6; + const tipOffset2D = () => ([ + -MATCH_TIP_OFFSET * Math.cos(MATCH_TILT), + -MATCH_TIP_OFFSET * Math.sin(MATCH_TILT), + ]); + + // ─── GAME_OVER_STAY 专用变量 ───────────────────────── + let gameOverTimer = 0.0; // 灯从亮到灭的倒计时累加器 + const GAME_OVER_FADE_DURATION = 4.0; // 灯光渐灭用多少秒 + + model.animate(() => { + // HUD 清理多余动态节点 + while (model.nChildren() > BASE_NODES) + model.remove(BASE_NODES); + + const t = model.time; + const dt = model.deltaTime; + + // 每帧从共享对象读取阶段,方便统一判断 + const gamePhase = getPhase(); + + // Global scene nodes are not affected by clearing/hiding `model` + // children, so we explicitly hide the chandelier during the intro. + lampNode.scale = gamePhase === "INTRO" ? [0, 0, 0] : [0.04, 0.04, 0.04]; + + const leftHand = clientState.finger(clientID, "left", 1); + const rightHand = clientState.finger(clientID, "right", 1); + const leftHandMat = clientState.hand(clientID, "left"); + const rightHandMat = clientState.hand(clientID, "right"); + const pinchLeft = clientState.pinch(clientID, "left", 1); + const pinchRight = clientState.pinch(clientID, "right", 1); + + const hands = [ + { pos: leftHand, side: "left", pinch: pinchLeft }, + { pos: rightHand, side: "right", pinch: pinchRight }, + ]; + + // ══════════════════════════════════════════════════ + // 阶段零:INTRO — 故事序章 + // ══════════════════════════════════════════════════ + if (gamePhase === "INTRO") { + // Hide all base nodes so they don't block the view + for (let i = 0; i < BASE_NODES; i++) { + model.child(i).identity().scale(0); + } + + // 首帧时记录序章开始时间 + if (introStartTime === null) introStartTime = t; + const introElapsed = t - introStartTime; + + // 全黑遮罩背景 + model.add("cube") + .move(0, ROOM_Y_OFFSET, ROOM_Z_OFFSET) + .scale(-100) + .color(0, 0, 0); + + // 逐卡渲染:只显示已到时间的卡片,并为最新一张做淡入 + for (let i = 0; i < INTRO_CARDS.length; i++) { + const [startAt, text, _col, sizeScale] = INTRO_CARDS[i]; + if (introElapsed < startAt) break; // 还没到时间 + + // 淡入:前 0.8s 内从 0 升到 1 + const age = introElapsed - startAt; + const alpha = Math.min(age / 0.8, 1.0); + + // 纵向堆叠:最新一张在顶部附近,旧的往下偏移 + const stackOffset = (INTRO_CARDS.length - 1 - i) * 0.14; + const yPos = 1.55 + ROOM_Y_OFFSET - stackOffset; + + model.add(clay.text(text)) + .move(-0.55, yPos, -1.8 + ROOM_Z_OFFSET) + .scale(sizeScale * 1.2) + .color(1, 1, 1, alpha); + } + + // Pinch 跳过 或 自动结束 + let anyPinch = false; + if (INTRO_SKIP_PINCH && introElapsed > 3.0) { + for (const { pinch } of hands) { if (pinch) { anyPinch = true; break; } } + } + + if (anyPinch || introElapsed >= INTRO_AUTO_END) { + introDone = true; + setPhase("DARK"); + } + + // 序章阶段直接 return,不渲染房间 + return; + } + + // ══════════════════════════════════════════════════ + // 阶段八:ACT2 — 完全交给 micro_world 处理 + // ══════════════════════════════════════════════════ + if (gamePhase === "ACT2") { + act2.render(model, t, hands); + return; + } + + // ══════════════════════════════════════════════════ + // 让火柴盒可抓取:若手上有盒子则跟随,否则保持原位 + // ══════════════════════════════════════════════════ + if (matchBoxHeldBy) { + if (matchBoxHeldBy === "monster") { + // 位置由怪物逻辑控制 + } else { + const boxPos = matchBoxHeldBy === "left" ? leftHand : rightHand; + const boxPinch = matchBoxHeldBy === "left" ? pinchLeft : pinchRight; + if (Array.isArray(boxPos) && boxPinch) { + matchBoxPos = [boxPos[0], boxPos[1], boxPos[2]]; + } else { + matchBoxHeldBy = null; + } + } + } + + // 保证同一只手不会同时持有火柴与火柴盒 + if (matchHeldBy && matchBoxHeldBy && matchHeldBy === matchBoxHeldBy) + matchBoxHeldBy = null; + + // ══════════════════════════════════════════════════ + // 阶段一:DARK — 找火柴盒 + // ══════════════════════════════════════════════════ + if (gamePhase === "DARK") { + flameNode.identity().scale(0); + matchTip.identity().scale(0); + + let nearMatch = false; + + for (const { pos: hPos, side, pinch } of hands) { + if (!Array.isArray(hPos)) continue; + const dist = cg.distance(hPos, matchBoxPos); + + if (dist < GRAB_RADIUS && pinch) { + matchHeld = true; + matchHeldBy = side; + prevHandX = hPos[0]; + matchPos[0] = hPos[0]; + matchPos[1] = hPos[1]; + matchPos[2] = hPos[2]; + setPhase("MATCH_HELD"); + logEvent("match_grabbed", side, matchBoxPos); + break; + } + if (dist < NEAR_RADIUS) nearMatch = true; + } + + const nearGlow = nearMatch ? (0.15 + 0.1 * Math.sin(10 * t)) : 0.03; + { + const boxMat = matchBoxHeldBy === "left" ? leftHandMat : + matchBoxHeldBy === "right" ? rightHandMat : null; + if (boxMat) + matchBox.identity().setMatrix(boxMat).scale(0.06, 0.03, 0.1) + .color(nearGlow * 2.5, nearGlow * 1.8, nearGlow * 0.6); + else + matchBox.identity().move(matchBoxPos).scale(0.06, 0.03, 0.1) + .color(nearGlow * 2.5, nearGlow * 1.8, nearGlow * 0.6); + } + { + const stripColor = [0.55 * nearGlow, 0.15 * nearGlow, 0.08 * nearGlow]; + const boxMat = matchBoxHeldBy === "left" ? leftHandMat : + matchBoxHeldBy === "right" ? rightHandMat : null; + if (boxMat) + matchBoxStrip.identity().setMatrix(boxMat).move(0.061, 0, 0).turnY(Math.PI / 2) + .scale(0.1, 0.03, 1).color(...stripColor); + else + matchBoxStrip.identity() + .move(matchBoxPos[0] + 0.061, matchBoxPos[1], matchBoxPos[2]) + .turnY(Math.PI / 2).scale(0.1, 0.03, 1).color(...stripColor); + } + matchStick.identity().scale(0); + } + + // ══════════════════════════════════════════════════ + // 阶段二:MATCH_HELD — 检测划火柴手势 + // ══════════════════════════════════════════════════ + if (gamePhase === "MATCH_HELD") { + flameNode.identity().scale(0); + + const hPos = matchHeldBy === "left" ? leftHand : rightHand; + const isPinching = matchHeldBy === "left" ? pinchLeft : pinchRight; + + if (!Array.isArray(hPos) || !isPinching) { + matchHeld = false; + matchHeldBy = null; + setPhase("DARK"); + } else { + matchPos[0] = hPos[0]; + matchPos[1] = hPos[1]; + matchPos[2] = hPos[2]; + const hMat = matchHeldBy === "left" ? leftHandMat : rightHandMat; + if (hMat) { + const d = cg.normalize([hMat[0], hMat[1], hMat[2]]); + if (d) matchDir = d; + } + + if (prevHandX !== null) { + swipeSpeed = Math.abs(hPos[0] - prevHandX) / dt; + } + prevHandX = hPos[0]; + + const nearBox = cg.distance(matchPos, matchBoxPos) < STRIKE_RADIUS; + if (swipeSpeed > SWIPE_THRESHOLD && nearBox) { + setPhase("LIGHTING"); + flameLife = 1.0; + logEvent("match_struck", matchHeldBy, matchPos); + if (igniteBuffer) { + playSoundAtPosition(igniteBuffer, matchPos, 3.0); + setTimeout(() => playSoundAtPosition(igniteBuffer, matchPos), 30); + } + } else if (swipeSpeed > 0.3 && nearBox && scratchBuffer) { + if (t - lastScratchTime > 0.3) { + playSoundAtPosition(scratchBuffer, matchPos, 3.0); + lastScratchTime = t; + } + } + } + + if (!matchBoxHeldBy) { + for (const { pos: bPos, side, pinch } of hands) { + if (side === matchHeldBy) continue; + if (!Array.isArray(bPos) || !pinch) continue; + if (cg.distance(bPos, matchBoxPos) < GRAB_RADIUS) { + matchBoxHeldBy = side; + matchBoxPos = [bPos[0], bPos[1], bPos[2]]; + break; + } + } + } + + { + const boxMat = matchBoxHeldBy === "left" ? leftHandMat : + matchBoxHeldBy === "right" ? rightHandMat : null; + if (boxMat) + matchBox.identity().setMatrix(boxMat).scale(0.06, 0.03, 0.1) + .color(0.6, 0.35, 0.1); + else + matchBox.identity().move(matchBoxPos).scale(0.06, 0.03, 0.1) + .color(0.6, 0.35, 0.1); + } + { + const stripR = 0.55 + 0.15 * Math.sin(6 * t); + const stripColor = [stripR, 0.15, 0.08]; + const boxMat = matchBoxHeldBy === "left" ? leftHandMat : + matchBoxHeldBy === "right" ? rightHandMat : null; + if (boxMat) + matchBoxStrip.identity().setMatrix(boxMat).move(0.061, 0, 0).turnY(Math.PI / 2) + .scale(0.1, 0.03, 1).color(...stripColor); + else + matchBoxStrip.identity() + .move(matchBoxPos[0] + 0.061, matchBoxPos[1], matchBoxPos[2]) + .turnY(Math.PI / 2).scale(0.1, 0.03, 1).color(...stripColor); + } + { + const hMat = matchHeldBy === "left" ? leftHandMat : rightHandMat; + if (hMat) + matchStick.identity().setMatrix(hMat).turnZ(MATCH_TILT) + .scale(MATCH_LENGTH, MATCH_THICKNESS, MATCH_THICKNESS) + .color(0.85, 0.75, 0.55); + else + matchStick.identity().move(matchPos).turnZ(MATCH_TILT) + .scale(MATCH_LENGTH, MATCH_THICKNESS, MATCH_THICKNESS) + .color(0.85, 0.75, 0.55); + } + { + const hMat = matchHeldBy === "left" ? leftHandMat : rightHandMat; + if (hMat) + matchTip.identity().setMatrix(hMat).turnZ(MATCH_TILT) + .move(-MATCH_TIP_OFFSET, 0, 0).scale(0.010) + .color(0.8, 0.12, 0.06); + else { + const [ox, oy] = tipOffset2D(); + matchTip.identity() + .move(matchPos[0] + ox, matchPos[1] + oy, matchPos[2]) + .scale(0.010) + .color(0.8, 0.12, 0.06); + } + } + } + + // ══════════════════════════════════════════════════ + // 阶段三:LIGHTING — 灯慢慢变亮 + // ══════════════════════════════════════════════════ + if (gamePhase === "LIGHTING") { + const hPos = matchHeldBy === "left" ? leftHand : rightHand; + const isPinching = matchHeldBy === "left" ? pinchLeft : pinchRight; + const hMat = matchHeldBy === "left" ? leftHandMat : rightHandMat; + if (Array.isArray(hPos) && isPinching) { + matchPos[0] = hPos[0]; + matchPos[1] = hPos[1]; + matchPos[2] = hPos[2]; + if (hMat) { + const d = cg.normalize([hMat[0], hMat[1], hMat[2]]); + if (d) matchDir = d; + } + } else { + matchHeld = false; + matchHeldBy = null; + } + + flameLife -= dt * 0.18; + flameLife = Math.max(flameLife, 0); + lightLevel += dt * 0.22; + + if (lightLevel >= 1.0) { + lightLevel = 1.0; + setPhase("LIT"); + logEvent("room_lit", null, [0,0,0]); + } + + const flameFlicker = flameLife * (0.8 + 0.2 * Math.sin(30 * t)); + if (hMat) + flameNode.identity() + .setMatrix(hMat) + .turnZ(MATCH_TILT + Math.PI / 2) + .move(0, MATCH_TIP_OFFSET * 1.1, 0) + .scale(0.015, 0.04 * flameFlicker, 0.015) + .color(1.0, 0.6 * flameFlicker, 0.05); + else { + const [ox, oy] = tipOffset2D(); + flameNode.identity() + .turnZ(Math.PI / 2) + .move(matchPos[0] + ox, matchPos[1] + oy, matchPos[2]) + .scale(0.015, 0.04 * flameFlicker, 0.015) + .color(1.0, 0.6 * flameFlicker, 0.05); + } + + if (!matchBoxHeldBy) { + for (const { pos: bPos, side, pinch } of hands) { + if (side === matchHeldBy) continue; + if (!Array.isArray(bPos) || !pinch) continue; + if (cg.distance(bPos, matchBoxPos) < GRAB_RADIUS) { + matchBoxHeldBy = side; + matchBoxPos = [bPos[0], bPos[1], bPos[2]]; + break; + } + } + } + + { + const boxMat = matchBoxHeldBy === "left" ? leftHandMat : + matchBoxHeldBy === "right" ? rightHandMat : null; + if (boxMat) + matchBox.identity().setMatrix(boxMat).scale(0.06, 0.03, 0.1) + .color(0.8, 0.45, 0.1); + else + matchBox.identity().move(matchBoxPos).scale(0.06, 0.03, 0.1) + .color(0.8, 0.45, 0.1); + } + { + const stripColor = [0.55, 0.15, 0.08]; + const boxMat = matchBoxHeldBy === "left" ? leftHandMat : + matchBoxHeldBy === "right" ? rightHandMat : null; + if (boxMat) + matchBoxStrip.identity().setMatrix(boxMat).move(0.061, 0, 0).turnY(Math.PI / 2) + .scale(0.1, 0.03, 1).color(...stripColor); + else + matchBoxStrip.identity() + .move(matchBoxPos[0] + 0.061, matchBoxPos[1], matchBoxPos[2]) + .turnY(Math.PI / 2).scale(0.1, 0.03, 1).color(...stripColor); + } + { + const hMatR = matchHeldBy === "left" ? leftHandMat : rightHandMat; + if (hMatR) + matchStick.identity().setMatrix(hMatR).turnZ(MATCH_TILT) + .scale(MATCH_LENGTH, MATCH_THICKNESS, MATCH_THICKNESS) + .color(0.9, 0.8, 0.6); + else + matchStick.identity().move(matchPos).turnZ(MATCH_TILT) + .scale(MATCH_LENGTH, MATCH_THICKNESS, MATCH_THICKNESS) + .color(0.9, 0.8, 0.6); + } + { + const hMatR = matchHeldBy === "left" ? leftHandMat : rightHandMat; + if (hMatR) + matchTip.identity().setMatrix(hMatR).turnZ(MATCH_TILT) + .move(-MATCH_TIP_OFFSET, 0, 0).scale(0.010) + .color(0.9, 0.18, 0.08); + else { + const [ox, oy] = tipOffset2D(); + matchTip.identity() + .move(matchPos[0] + ox, matchPos[1] + oy, matchPos[2]) + .scale(0.010) + .color(0.9, 0.18, 0.08); + } + } + } + + // ══════════════════════════════════════════════════ + // 阶段四:LIT — 第一幕结束 + // ══════════════════════════════════════════════════ + if (gamePhase === "LIT") { + let grabbed = false; + for (const { pos: hPos, side, pinch } of hands) { + if (side === matchBoxHeldBy) continue; + if (!Array.isArray(hPos)) continue; + const dist = cg.distance(hPos, matchPos); + if (dist < GRAB_RADIUS && pinch) { + matchHeld = true; + matchHeldBy = side; + matchPos[0] = hPos[0]; + matchPos[1] = hPos[1]; + matchPos[2] = hPos[2]; + const hMat = side === "left" ? leftHandMat : rightHandMat; + if (hMat) { + const d = cg.normalize([hMat[0], hMat[1], hMat[2]]); + if (d) matchDir = d; + } + grabbed = true; + break; + } + } + if (!grabbed) { + matchHeld = false; + matchHeldBy = null; + } + + if (!matchBoxHeldBy) { + for (const { pos: bPos, side, pinch } of hands) { + if (side === matchHeldBy) continue; + if (!Array.isArray(bPos) || !pinch) continue; + if (cg.distance(bPos, matchBoxPos) < GRAB_RADIUS) { + matchBoxHeldBy = side; + matchBoxPos = [bPos[0], bPos[1], bPos[2]]; + break; + } + } + } + + { + const boxMat = matchBoxHeldBy === "left" ? leftHandMat : + matchBoxHeldBy === "right" ? rightHandMat : null; + if (boxMat) + matchBox.identity().setMatrix(boxMat).scale(0.06, 0.03, 0.1) + .color(0.55 * lightLevel, 0.38 * lightLevel, 0.18 * lightLevel); + else + matchBox.identity().move(matchBoxPos).scale(0.06, 0.03, 0.1) + .color(0.55 * lightLevel, 0.38 * lightLevel, 0.18 * lightLevel); + } + { + const stripColor = [0.55 * lightLevel, 0.15 * lightLevel, 0.08 * lightLevel]; + const boxMat = matchBoxHeldBy === "left" ? leftHandMat : + matchBoxHeldBy === "right" ? rightHandMat : null; + if (boxMat) + matchBoxStrip.identity().setMatrix(boxMat).move(0.061, 0, 0).turnY(Math.PI / 2) + .scale(0.1, 0.03, 1).color(...stripColor); + else + matchBoxStrip.identity() + .move(matchBoxPos[0] + 0.061, matchBoxPos[1], matchBoxPos[2]) + .turnY(Math.PI / 2).scale(0.1, 0.03, 1).color(...stripColor); + } + { + const hMat = matchHeldBy === "left" ? leftHandMat : rightHandMat; + if (hMat && matchHeld) + matchStick.identity().setMatrix(hMat).turnZ(MATCH_TILT) + .scale(MATCH_LENGTH, MATCH_THICKNESS, MATCH_THICKNESS) + .color(0.7 * lightLevel, 0.6 * lightLevel, 0.4 * lightLevel); + else + matchStick.identity().move(matchPos).turnZ(MATCH_TILT) + .scale(MATCH_LENGTH, MATCH_THICKNESS, MATCH_THICKNESS) + .color(0.7 * lightLevel, 0.6 * lightLevel, 0.4 * lightLevel); + } + { + const hMat = matchHeldBy === "left" ? leftHandMat : rightHandMat; + if (hMat && matchHeld) + matchTip.identity().setMatrix(hMat).turnZ(MATCH_TILT) + .move(-MATCH_TIP_OFFSET, 0, 0).scale(0.010) + .color(0.8 * lightLevel, 0.12 * lightLevel, 0.06 * lightLevel); + else { + const [ox, oy] = tipOffset2D(); + matchTip.identity() + .move(matchPos[0] + ox, matchPos[1] + oy, matchPos[2]) + .scale(0.010) + .color(0.8 * lightLevel, 0.12 * lightLevel, 0.06 * lightLevel); + } + } + flameNode.identity().scale(0); + + // ─── FIX #3: 降低低头触发阈值 ──────────────── + // 原始阈值 0.0 + ROOM_Y_OFFSET = -0.05m,玩家几乎不可能触发。 + // 改为 0.9m(站立时头部高度约 1.6m,弯腰低头时约 1.0–1.1m)。 + const headHeight = clientState.head ? clientState.head(clientID)[1] : + (Array.isArray(leftHand) ? leftHand[1] : 1.5); + + if (noteRead && headHeight < 0.9) { + monsterState = "REVEALED"; + setPhase("MONSTER_EVENT"); + logEvent("monster_revealed", null, monsterPos); + } + } + + // ══════════════════════════════════════════════════ + // 阶段五:MONSTER_EVENT — 现身、对视与抢夺 + // ══════════════════════════════════════════════════ + if (gamePhase === "MONSTER_EVENT") { + + if (monsterState === "REVEALED") { + monsterSnatchTimer += dt; + if (monsterSnatchTimer > 1.0) { + monsterState = "JUMP_TO_TABLE"; + monsterSnatchTimer = 0.0; + } + } + else if (monsterState === "JUMP_TO_TABLE") { + const targetPos = [matchBoxPos[0] + 0.15, matchBoxPos[1], matchBoxPos[2]]; + const speed = 1.0; + const dx = targetPos[0] - monsterPos[0]; + const dy = targetPos[1] - monsterPos[1]; + const dz = targetPos[2] - monsterPos[2]; + const dist = Math.sqrt(dx*dx + dy*dy + dz*dz); + + if (dist > 0.05) { + monsterPos[0] += (dx / dist) * speed * dt; + monsterPos[1] += (dy / dist) * speed * dt; + monsterPos[2] += (dz / dist) * speed * dt; + } else { + monsterState = "TAUNTING"; + } + } + else if (monsterState === "TAUNTING") { + monsterSnatchTimer += dt; + if (monsterSnatchTimer < 3.0) { + if (!playedLine1 && monsterLine1Buffer) { + playSoundAtPosition(monsterLine1Buffer, monsterPos, 2.0); // 2.0 is volume + playedLine1 = true; + } + model.add(clay.text("I WATCHED THIS FACTORY FOR WEEKS.")) + .move(monsterPos[0] - 0.2, monsterPos[1] + 0.25, monsterPos[2]-0.5) + .scale(1.5).color(1, 1, 1); + } else if (monsterSnatchTimer < 6.0) { + if (!playedLine2 && monsterLine2Buffer) { + playSoundAtPosition(monsterLine2Buffer, monsterPos, 2.0); + playedLine2 = true; + } + model.add(clay.text("YOUR MATCHES ARE THE ONLY WARMTH LEFT.")) + .move(monsterPos[0] - 0.25, monsterPos[1] + 0.25, monsterPos[2]-0.5) + .scale(1.5).color(1, 1, 1); + } else if (monsterSnatchTimer < 9.0) { + if (!playedLine3 && monsterLine3Buffer) { + playSoundAtPosition(monsterLine3Buffer, monsterPos, 2.0); + playedLine3 = true; + } + model.add(clay.text("PLEASE. FOLLOW ME. HELP ME.")) + .move(monsterPos[0] - 0.12, monsterPos[1] + 0.25, monsterPos[2]-0.5) + .scale(1.5).color(1, 1, 1); + } else { + monsterState = "SNATCHING"; + } + } + else if (monsterState === "SNATCHING") { + const speed = 1.5; + const dx = matchBoxPos[0] - monsterPos[0]; + const dy = matchBoxPos[1] - monsterPos[1]; + const dz = matchBoxPos[2] - monsterPos[2]; + const dist = Math.sqrt(dx*dx + dy*dy + dz*dz); + + if (dist > 0.05) { + monsterPos[0] += (dx / dist) * speed * dt; + monsterPos[1] += (dy / dist) * speed * dt; + monsterPos[2] += (dz / dist) * speed * dt; + } else { + matchBoxHeldBy = "monster"; + monsterState = "ESCAPING"; + } + } + else if (monsterState === "ESCAPING") { + matchBoxPos[0] = monsterPos[0]; + matchBoxPos[1] = monsterPos[1] + 0.05; + matchBoxPos[2] = monsterPos[2]; + + const speed = 2.5; + const dx = holePos[0] - monsterPos[0]; + const dy = holePos[1] - monsterPos[1]; + const dz = holePos[2] - monsterPos[2]; + const dist = Math.sqrt(dx*dx + dy*dy + dz*dz); + + if (dist > 0.2) { + monsterPos[0] += (dx / dist) * speed * dt; + monsterPos[1] += (dy / dist) * speed * dt; + monsterPos[2] += (dz / dist) * speed * dt; + } else { + monsterState = "IDLE_NPC"; + setPhase("QUEST_HUB"); + matchBoxHeldBy = null; + logEvent("entered_quest_hub", null, holePos); + } + } + + if (matchBoxHeldBy === "monster") { + matchBox.identity().move(matchBoxPos).scale(0.06, 0.03, 0.1) + .color(0.55 * lightLevel, 0.38 * lightLevel, 0.18 * lightLevel); + matchBoxStrip.identity() + .move(matchBoxPos[0] + 0.061, matchBoxPos[1], matchBoxPos[2]) + .turnY(Math.PI / 2).scale(0.1, 0.03, 1) + .color(0.55 * lightLevel, 0.15 * lightLevel, 0.08 * lightLevel); + } else { + matchBox.identity().move(matchBoxPos).scale(0.06, 0.03, 0.1) + .color(0.55 * lightLevel, 0.38 * lightLevel, 0.18 * lightLevel); + matchBoxStrip.identity() + .move(matchBoxPos[0] + 0.061, matchBoxPos[1], matchBoxPos[2]) + .turnY(Math.PI / 2).scale(0.1, 0.03, 1) + .color(0.55 * lightLevel, 0.15 * lightLevel, 0.08 * lightLevel); + } + } + + // ══════════════════════════════════════════════════ + // 阶段六:QUEST_HUB — 任务选择 + // ══════════════════════════════════════════════════ + if (gamePhase === "QUEST_HUB") { + // ─── FIX #2: 统一按钮位置 ───────────────────── + // 原来碰撞检测和文字标签各自定义了不同的 uiPos,导致视觉与交互偏移。 + // 现在只定义一套坐标,碰撞检测、按钮几何体、文字标签全部用同一套。 + const uiPos = [0.12, 1.3, -0.6]; + const titlePos = [uiPos[0] - 0.08, uiPos[1] + 0.04, uiPos[2] + 0.01]; + const followBtnPos = [uiPos[0] - 0.15, uiPos[1] - 0.04, uiPos[2]]; + const stayBtnPos = [uiPos[0] + 0.15, uiPos[1] - 0.04, uiPos[2]]; + + // 定位可交互按钮方块(供 hitRect 检测用) + window.followBtn.identity().move(...followBtnPos).scale(0.11, 0.03, 0.01); + window.stayBtn.identity().move(...stayBtnPos).scale(0.11, 0.03, 0.01); + + window.beamR.update(); + + let isPointingFollow = false; + let isPointingStay = false; + let isSelecting = false; + + if (window.beamR.hitRect(window.followBtn.getGlobalMatrix())) { + isPointingFollow = true; + window.followBtn.color(0.8, 0.8, 1); + } else { + window.followBtn.color(0.2, 0.5, 0.2); + } + + if (window.beamR.hitRect(window.stayBtn.getGlobalMatrix())) { + isPointingStay = true; + window.stayBtn.color(1, 0.8, 0.8); + } else { + window.stayBtn.color(0.5, 0.2, 0.2); + } + + // 近身手部判定(手追踪 fallback) + let isNearFollow = false; + let isNearStay = false; + for (const { pos: hPos, pinch } of hands) { + if (!Array.isArray(hPos)) continue; + if (cg.distance(hPos, followBtnPos) < 0.12) { isNearFollow = true; if (pinch) isSelecting = true; } + if (cg.distance(hPos, stayBtnPos) < 0.12) { isNearStay = true; if (pinch) isSelecting = true; } + } + for (const { pinch, pressed } of hands) { + if (pinch || pressed) isSelecting = true; + } + + if (isSelecting) { + if (isPointingFollow || isNearFollow) { + choiceMade = "FOLLOW"; + setPhase("PORTAL_TRANSITION"); + window.portalStartTime = t; + monsterState = "HIDDEN"; + matchHeld = false; + matchHeldBy = null; + matchBoxHeldBy = null; + } else if (isPointingStay || isNearStay) { + choiceMade = "STAY"; + setPhase("GAME_OVER_STAY"); + gameOverTimer = 0.0; + } + } + + // 文字标签对齐按钮坐标 + model.add("cube") + .move(uiPos[0], uiPos[1], uiPos[2] - 0.01) + .scale(0.45, 0.15, 0.005) + .color(1, 1, 1, 0.1); + + model.add(clay.text("It came here on purpose.")) + .move(titlePos[0]-0.1, titlePos[1] + 0.03, titlePos[2]).scale(1.2).color(0, 0, 0); + + model.add(clay.text("It needs you.")) + .move(titlePos[0]-0.1, titlePos[1] , titlePos[2]).scale(1.2).color(0, 0, 0); + + model.add(clay.text("[ FOLLOW ]")) + .move(followBtnPos[0] - 0.05, followBtnPos[1], followBtnPos[2]) + .scale(0.8) + .color(isPointingFollow || isNearFollow ? [0, 0.6, 0] : [0.3, 0.3, 0.3]); + + model.add(clay.text("[ FINISH YOUR SHIFT ]")) + .move(stayBtnPos[0] - 0.1, stayBtnPos[1], stayBtnPos[2]) + .scale(0.8) + .color(isPointingStay || isNearStay ? [0.8, 0, 0] : [0.3, 0.3, 0.3]); + + } else { + if (window.followBtn) window.followBtn.identity().scale(0); + if (window.stayBtn) window.stayBtn.identity().scale(0); + } + + // ══════════════════════════════════════════════════ + // FIX #4:阶段 GAME_OVER_STAY — 留下时的结局 + // ══════════════════════════════════════════════════ + if (gamePhase === "GAME_OVER_STAY") { + gameOverTimer += dt; + const fadeProgress = Math.min(gameOverTimer / GAME_OVER_FADE_DURATION, 1.0); + + // 灯光渐灭 + lightLevel = 1.0 - fadeProgress; + + // 全屏遮罩:随灯光变暗越来越浓 + if (fadeProgress > 0.1) { + model.add("cube") + .move(0, ROOM_Y_OFFSET, ROOM_Z_OFFSET) + .scale(10) + .color(0, 0, 0); + } + + // 屏幕中央文字 + const alpha = Math.min(fadeProgress * 2, 1.0); + if (alpha > 0.1) { + model.add(clay.text("You finished your shift.")) + .move(-0.25, 1.5, -0.8) + .scale(1.5) + .color(alpha * 0.8, alpha * 0.7, alpha * 0.6); + + if (fadeProgress > 0.6) { + model.add(clay.text("Somewhere, a world stays frozen.")) + .move(-0.18, 1.35, -0.8) + .scale(1.5) + .color(alpha * 0.5, alpha * 0.4, alpha * 0.4); + } + } + } + + // ══════════════════════════════════════════════════ + // 阶段七:PORTAL_TRANSITION — 传送门动画 + // ══════════════════════════════════════════════════ + if (gamePhase === "PORTAL_TRANSITION") { + let pTime = t - window.portalStartTime; + let duration = 5.0; + let progress = pTime / duration; + + let isDarkFlicker = Math.sin(t * 20 * progress) < 0; + if (isDarkFlicker) { + model.add("cube") + .move(0, ROOM_Y_OFFSET, ROOM_Z_OFFSET) + .scale(10) + .color(0, 0, 0); + } + + for (let i = 0; i < 5; i++) { + let spin = t * (1 + i); + let layerScale = 0.15 + Math.sin(t * 2 + i) * 0.05; + if (progress > 0.8) layerScale += (progress - 0.8) * 50; + + model.add("square") + .move(holePos[0], holePos[1], holePos[2] + 0.01 + i * 0.001) + .turnZ(spin) + .scale(layerScale) + .color(0.1, 0.6 + i * 0.1, 1.0); + } + + for (let i = 0; i < 8; i++) { + let pOffset = ((t * 2 + i * 0.5) % 2); + model.add("sphere") + .move(holePos[0] * (1 - pOffset), 1.5, holePos[2] * (1 - pOffset)) + .scale(0.01) + .color(0.5, 0.8, 1); + } + + let shake = (Math.random() - 0.5) * 0.02 * progress; + model.add(clay.text("ACT II : THE MICRO-WORLD")) + .move(shake-0.5, 1.5 + shake, -0.8) + .scale(2.5 + progress * 0.1) + .color(0, 0, 0); + + if (pTime > duration) { + setPhase("ACT2"); + act2.resetScene(); // 通知 micro_world 重置状态,避免遗留帧数据 + while (model.nChildren() > 0) model.remove(0); + } + } + + // ══════════════════════════════════════════════════ + // 场景渲染 — 所有颜色乘以 lightLevel + // ══════════════════════════════════════════════════ + const l = lightLevel; + + floor.identity() + .move(0, 0.2 + ROOM_Y_OFFSET, -1.5 + ROOM_Z_OFFSET) + .turnX(-Math.PI / 2).scale(3.0, 3.0, 1) + .color(0.12 * l, 0.09 * l, 0.07 * l); + + wallBack.identity() + .move(0, 1.8 + ROOM_Y_OFFSET, -2 + ROOM_Z_OFFSET) + .scale(3.5, 2, 1) + .color(0.15 * l, 0.11 * l, 0.09 * l); + + table.identity() + .move(0.2, 0.8 + ROOM_Y_OFFSET, -0.6 + ROOM_Z_OFFSET) + .scale(0.5, 0.06, 0.35) + .color(0.28 * l, 0.18 * l, 0.1 * l); + + noteNode.identity() + .move(...notePos) + .turnX(-Math.PI / 2) + .scale(0.08, 0.06, 1) + .color(0.9 * l, 0.85 * l, 0.7 * l); + + if (l > 0.5 && !noteRead) { + for (const { pos: hPos } of hands) { + if (!Array.isArray(hPos)) continue; + //if (cg.distance(hPos, notePos) < GRAB_RADIUS) { + noteRead = true; + logEvent("note_read", null, notePos); + break; + //} + } + } + + holeNode.identity() + .move(...holePos) + .scale(0.12, 0.15, 1) + .color(0.02 * l, 0.01 * l, 0.01 * l); + + // 渲染小捣蛋鬼 + if (monsterState !== "HIDDEN" && monsterState !== "IDLE_NPC") { + const bounce = 0.06 * Math.abs(Math.sin(15 * t)); + monsterNode.matrix = cg.mMultiply( + cg.mTranslate(monsterPos[0], monsterPos[1] + bounce, monsterPos[2]), + cg.mMultiply( + cg.mRotateY(Math.PI), + cg.mScale(0.2, 0.2, 0.2) + ) + ); + } else { + monsterNode.matrix = cg.mScale(0, 0, 0); + if (gamePhase === "QUEST_HUB") { + matchBox.identity().scale(0); + matchBoxStrip.identity().scale(0); + global.scene().removeNode(lampNode); + } + } + + // Hint 文字 + const hint = + gamePhase === "INTRO" ? "" : + gamePhase === "DARK" ? "FIND SOMETHING IN THE DARK..." : + gamePhase === "MATCH_HELD" ? "STRIKE THE MATCH. SWIPE FAST" : + gamePhase === "LIGHTING" ? "..." : + gamePhase === "LIT" ? (noteRead ? "WHO WROTE THIS...?" : "ACT I COMPLETE") : + gamePhase === "MONSTER_EVENT" ? "TALK TO THE LITTLE THIEF" : + gamePhase === "QUEST_HUB" ? "MAKE A CHOICE" : + gamePhase === "GAME_OVER_STAY" ? "" : ""; + + const hintColor = + gamePhase === "DARK" ? [0.4, 0.4, 0.5] : + gamePhase === "MATCH_HELD" ? [0.9, 0.7, 0.3] : + gamePhase === "LIGHTING" ? [1.0, 0.8, 0.4] : + [0.5, 1.0, 0.7]; + + if (hint) { + model.add(clay.text(hint)) + .move(-0.8, 2 + ROOM_Y_OFFSET, -1.8 + ROOM_Z_OFFSET).scale(2.0) + .color(...hintColor); + } + + // model.add(clay.text("EVENTS: " + eventLog.length)) + // .move(-0.8, 1.8 + ROOM_Y_OFFSET, -1.8 + ROOM_Z_OFFSET).scale(1.0) + // .color(0.5, 0.5, 0.7); + + if (l > 0.2) { + model.add(clay.text("You are not alone.")) + .move(notePos[0] - 0.03, notePos[1] + 0.001, notePos[2]) + .turnX(-Math.PI / 2) + .scale(0.4) + .color(0.08 * l, 0.06 * l, 0.04 * l); + + if (noteRead) { + model.add(clay.text("")) + .move(notePos[0] - 0.03, notePos[1] + 0.001, notePos[2] + 0.02) + .turnX(-Math.PI / 2).scale(0.4) + .color(0.6 * l, 0.3 * l, 0.1 * l); + } + } + }); +}; + +export const deinit = () => { + stopStereoLoopingAudio(); +}; diff --git a/js/scenes/headGazeExercise.js b/js/scenes/headGazeExercise.js new file mode 100644 index 0000000..71417e1 --- /dev/null +++ b/js/scenes/headGazeExercise.js @@ -0,0 +1,46 @@ +import * as cg from "../render/core/cg.js"; + +// Simple head-gaze focus: look at the cube to fill a ring. + +const DWELL_TIME = 1.0; +const FOCUS_COLOR = [0.2, 0.9, 0.45]; +const IDLE_COLOR = [0.85, 0.85, 0.9]; + +const mixColor = (a, b, t) => [ + a[0] + (b[0] - a[0]) * t, + a[1] + (b[1] - a[1]) * t, + a[2] + (b[2] - a[2]) * t, +]; + +export const init = async model => { + const target = model.add().move(0, 1.6, -1.3); + target.add("cube").scale(0.04); + const progressRing = model.add("ringZ"); + + let dwell = 0; + + model.animate(() => { + const dt = model.deltaTime || 0; + const mm = cg.mMultiply(clay.root().viewMatrix(0), worldCoords); + const m = cg.mMultiply(mm, target.getMatrix()); + const distanceToGaze = m[12]*m[12] + m[13]*m[13]; + const inFront = m[14] < 0; + + if (inFront && distanceToGaze < 0.02) + dwell = Math.min(DWELL_TIME, dwell + dt); + else + dwell = Math.max(0, dwell - dt); + + const t = dwell / DWELL_TIME; + const color = mixColor(IDLE_COLOR, FOCUS_COLOR, t); + const scale = 0.25 + 0.05 * t; + + target.child(0).color(color).identity().scale(scale); + + const ringScale = 0.22 + 0.4 * t; + progressRing.identity() + .move(0, 1.7, -0.9) + .scale(ringScale) + .color(color); + }); +} diff --git a/js/scenes/joints.js b/js/scenes/joints.js index f828488..e36d5b6 100644 --- a/js/scenes/joints.js +++ b/js/scenes/joints.js @@ -3,7 +3,7 @@ */ export const init = async model => { - + // CREATE NODES WITH NO SHAPES AS JOINTS FOR ANIMATION. let shoulder = model.add(); diff --git a/js/scenes/master2.js b/js/scenes/master2.js new file mode 100644 index 0000000..f582c93 --- /dev/null +++ b/js/scenes/master2.js @@ -0,0 +1,112 @@ + +// MASTER-OWNED MULTIPLAYER COMET: +// - CONTROL GOES TO THE MOST RECENT TRIGGER PRESS (master2 pattern). +// - COLOR FOLLOWS LEFT/RIGHT HAND INPUT (multiplayer1 pattern). +// - EVERYONE SEES THE SAME FADING TRAIL. + +window.sharedState = { + time: 0, + pos: [0, 1.5, 0], + color: [1, 0.2, 0.2], + pulse: 0, + controller: {}, + trail: [], +}; + +const TRAIL_COUNT = 16; + +const colorForInput = (hand, id) => { + let hash = 0; + for (let i = 0; i < id.length; i++) + hash = (hash * 31 + id.charCodeAt(i)) % 997; + const n = (hash % 100) / 100; + return hand == 'left' ? [0.2 + 0.5 * n, 1, 0.2] : [0.2, 0.5 + 0.5 * n, 1]; +}; + +export const init = async model => { + let comet = model.add('sphere'); + let halo = model.add('ringY'); + let trail = []; + for (let i = 0; i < TRAIL_COUNT; i++) + trail.push(model.add('sphere')); + + inputEvents.onPress = hand => { + const id = hand + clientID; + sharedState.controller[id] = { + pos: [...inputEvents.pos(hand)], + time: sharedState.time, + color: colorForInput(hand, id), + }; + server.broadcastGlobal('sharedState'); + }; + + inputEvents.onDrag = hand => { + const id = hand + clientID; + if (sharedState.controller[id]) { + sharedState.controller[id].pos = [...inputEvents.pos(hand)]; + server.broadcastGlobal('sharedState'); + } + }; + + inputEvents.onRelease = hand => { + delete sharedState.controller[hand + clientID]; + server.broadcastGlobal('sharedState'); + }; + + model.animate(() => { + sharedState = server.synchronize('sharedState'); + + if (clientID == clients[0]) { + sharedState.time = model.time; + + let newest = null; + let newestTime = -1; + for (let id in sharedState.controller) + if (sharedState.controller[id].time > newestTime) { + newestTime = sharedState.controller[id].time; + newest = sharedState.controller[id]; + } + + if (newest) { + sharedState.pos = [...newest.pos]; + sharedState.color = [...newest.color]; + sharedState.pulse = 1; + sharedState.trail.unshift({ + pos: [...sharedState.pos], + color: [...sharedState.color], + time: sharedState.time, + }); + } else { + sharedState.pos = [ + sharedState.pos[0], + 1.45 + 0.1 * Math.sin(2 * sharedState.time), + sharedState.pos[2], + ]; + sharedState.pulse *= 0.95; + } + + while (sharedState.trail.length > TRAIL_COUNT) + sharedState.trail.pop(); + sharedState.trail = sharedState.trail.filter(p => sharedState.time - p.time < 2.5); + server.broadcastGlobal('sharedState'); + } + + const pulse = 1 + 0.25 * sharedState.pulse * Math.abs(Math.sin(10 * sharedState.time)); + comet.identity().move(sharedState.pos).scale(0.07 * pulse).color(...sharedState.color); + halo.identity().move(sharedState.pos).turnY(2 * sharedState.time).scale(0.11).color(...sharedState.color); + + for (let i = 0; i < TRAIL_COUNT; i++) { + const p = sharedState.trail[i]; + if (!p) { + trail[i].identity().scale(0); + continue; + } + const age = Math.max(0, sharedState.time - p.time); + const fade = Math.max(0, 1 - age / 2.5); + trail[i].identity() + .move(p.pos) + .scale(0.05 * fade) + .color(p.color[0] * fade, p.color[1] * fade, p.color[2] * fade); + } + }); +}; diff --git a/js/scenes/micro_world.js b/js/scenes/micro_world.js new file mode 100644 index 0000000..e9662e4 --- /dev/null +++ b/js/scenes/micro_world.js @@ -0,0 +1,820 @@ +import * as cg from "../render/core/cg.js"; +import * as global from "../global.js"; +import { Gltf2Node } from "../render/nodes/gltf2.js"; +import { buttonState, joyStickState } from "../render/core/controllerInput.js"; +import * as customFire from "./campFire.js"; +import { loadSound, playSoundAtPosition } from "../util/positional-audio.js"; + +// ─── Scene State ──────────────────────────────────────────────────────────── +let startTime = 0; +let campfireLit = false; +let fireStartTime = 0; +let riftOpened = false; +let modelsSwapped = false; +let isInitialized = false; +let monsterNode; +let prevFrameTime = 0; +let virtualPlayerX = 0; +let virtualPlayerZ = 0; +let virtualPlayerYaw = 0; +let snapTurnReady = true; +let mapTaken = false; +let mapOpen = false; +let mapTriggerLatch = false; +let strikeCount = 0; +let lastStrikeTime = 0; +const STRIKES_NEEDED = 3; +const STRIKE_COOLDOWN = 0.5; // seconds between strikes +// State for the matchbox +let matchboxInPocket = false; +let matchboxWorldX = -3; +let matchboxWorldZ = -5; +// voice lines +let monsterLine4Buffer, monsterLine5Buffer,monsterLine6Buffer,monsterLine7Buffer +let played4 = false, played5 = false, played6 = false, played7 = false; +let soundsLoaded = false; + +// ─── Infinite World: Chunk System ─────────────────────────────────────────── +// +// The world is divided into square chunks of CHUNK_SIZE units. +// Each chunk is identified by its integer grid coords (cx, cz). +// We keep a Map from key "cx,cz" → { floorNodes[], treeNodes[], plantNodes[] } +// and load/unload chunks as the player moves through the world. +// +// Player position in Scene 2 is inferred from the VR hand positions (averaged), +// since there is no explicit camera API exposed in this framework. + +const CHUNK_SIZE = 6; // world units per chunk +const RENDER_RADIUS = 2; // chunks visible in each direction (5×5 grid = 25 chunks) +const UNLOAD_RADIUS = 3; // chunks farther than this get unloaded +const STREAM_CENTER_Z_OFFSET = -3; +const MOVE_SPEED = 2.4; +const STICK_DEADZONE = 0.15; +const SNAP_TURN_ANGLE = Math.PI / 6; +const SNAP_TURN_THRESHOLD = 0.65; +const MAP_TAKE_RADIUS = 0.3; +const FIRE_INTERACT_RADIUS = 0.75; +const CAMPFIRE_WORLD_POS = [18, -0.88, -18]; +const MAP_GIFT_WORLD_POS = [0.65, 1.1, -2.55]; +const MAP_BOUNDS = { minX: -6, maxX: 24, minZ: -24, maxZ: 6 }; +const MAP_SIZE = 1.3; + +// Keep the player spawn area clear so Act 2 doesn't begin with terrain +// intersecting the viewer. We only clear the immediate entry zone, not the +// campfire area in front of the player. +function inEntryClearZone(x, z) { + return Math.abs(x) < 1.6 && z > -1.0 && z < 1.5; +} + +function applyDeadzone(v) { + return Math.abs(v) < STICK_DEADZONE ? 0 : v; +} + +function makePlacement(node, x, y, z, rotY, s) { + return { node, x, y, z, rotY, s }; +} + +function rotateXZ(x, z, yaw) { + const c = Math.cos(yaw); + const s = Math.sin(yaw); + return [x * c - z * s, x * s + z * c]; +} + +function transformWorldPoint(x, y, z, offsetX = 0, offsetZ = 0, yaw = 0) { + const [rx, rz] = rotateXZ(x - offsetX, z - offsetZ, -yaw); + return [rx, y, rz]; +} + +function applyPlacement(placement, offsetX = 0, offsetZ = 0, yaw = 0) { + const [x, y, z] = transformWorldPoint(placement.x, placement.y, placement.z, offsetX, offsetZ, yaw); + placement.node.matrix = cg.mMultiply( + cg.mTranslate(x, y, z), + cg.mMultiply(cg.mRotateY(placement.rotY - yaw), cg.mScale(placement.s, placement.s, placement.s)) + ); +} + +function applyChunkWorldOffset(offsetX, offsetZ, yaw) { + for (const chunk of loadedChunks.values()) { + for (const p of chunk.floorNodes) applyPlacement(p, offsetX, offsetZ, yaw); + for (const p of chunk.treeNodes) applyPlacement(p, offsetX, offsetZ, yaw); + for (const p of chunk.stoneNodes) applyPlacement(p, offsetX, offsetZ, yaw); + for (const p of chunk.plantNodes) applyPlacement(p, offsetX, offsetZ, yaw); + } +} + +function offsetPos([x, y, z], offsetX, offsetZ, yaw = 0) { + return transformWorldPoint(x, y, z, offsetX, offsetZ, yaw); +} + +function clamp01(v) { + return Math.max(0, Math.min(1, v)); +} + +function planarDistance(ax, az, bx, bz) { + return Math.hypot(ax - bx, az - bz); +} + +function worldToMapLocal(x, z) { + const u = clamp01((x - MAP_BOUNDS.minX) / (MAP_BOUNDS.maxX - MAP_BOUNDS.minX)); + const v = clamp01((z - MAP_BOUNDS.minZ) / (MAP_BOUNDS.maxZ - MAP_BOUNDS.minZ)); + return [ + (u - 0.5) * MAP_SIZE, + (0.5 - v) * MAP_SIZE, + ]; +} + +function renderMapPanel(model, anchorPos, playerX, playerZ, playerYaw) { + const root = model.add() + .move(...anchorPos) + .turnY(0.4) + .turnX(-0.08) + .scale(0.28); // Keep the global scale for the container + + // 1. Shrink the dark frame width (from 1.95 to 1.3) + root.add("cube").scale(1.3, 1.35, 0.035).color(0.11, 0.10, 0.08); + + // 2. Shrink the paper width (from 1.82 to 1.15) + root.add("square").move(0, 0, 0.02).scale(1.15, 1.22, 1).color(0.88, 0.82, 0.64); + + // 3. Update the grid lines + for (let i = -2; i <= 2; i++) { + const x = i * 0.33; + const y = i * 0.33; + + // Vertical lines (scale X remains thin, Y stays long) + root.add("cube").move(x, 0, 0.03).scale(0.012, 1.12, 0.01).color(0.55, 0.48, 0.34); + + // Horizontal lines (scale X needs to be shorter: from 1.72 to 1.1) + root.add("cube").move(0, y, 0.03).scale(1.1, 0.012, 0.01).color(0.55, 0.48, 0.34); + } + + // 4. Update markers: worldToMapLocal now needs to account for the narrow width + const [rawPlayerMapX, playerMapY] = worldToMapLocal(playerX, playerZ); + const [rawFireMapX, fireMapY] = worldToMapLocal(CAMPFIRE_WORLD_POS[0], CAMPFIRE_WORLD_POS[2]); + const [rawGiftMapX, giftMapY] = worldToMapLocal(MAP_GIFT_WORLD_POS[0], MAP_GIFT_WORLD_POS[2]); + + // Compensate marker X positions for the narrower map (multiply by ~0.6) + const playerMapX = rawPlayerMapX * 0.6; + const fireMapX = rawFireMapX * 0.6; + const giftMapX = rawGiftMapX * 0.6; + + const mapFacing = -playerYaw; + + // Markers (keeping these as standard scales so they don't look like eggs!) + root.add("sphere").move(playerMapX, playerMapY, 0.05).scale(0.06).color(0.14, 0.35, 0.95); + + root.add("sphere").move(fireMapX, fireMapY, 0.05).scale(0.07).color(0.95, 0.32, 0.08); + root.add("sphere").move(giftMapX, giftMapY, 0.05).scale(0.05).color(0.18, 0.7, 0.28); + + // Labels (Reduced X offset for "YOU", "FIRE", "MAP" so they stay near markers) + root.add(clay.text("YOU")).move(playerMapX + 0.08, playerMapY + 0.05, 0.05).scale(0.18).color(0.08, 0.18, 0.55); + root.add(clay.text("FIRE")).move(fireMapX + 0.08, fireMapY + 0.05, 0.05).scale(0.18).color(0.55, 0.18, 0.05); + root.add(clay.text("MAP")).move(giftMapX + 0.08, giftMapY + 0.05, 0.05).scale(0.16).color(0.12, 0.35, 0.12); + + // Compass (Keep centered) + root.add(clay.text("N")).move(0, 0.78, 0.05).scale(0.2).color(0.18, 0.14, 0.1); + root.add("cube").move(0, 0.67, 0.05).scale(0.018, 0.09, 0.01).color(0.18, 0.14, 0.1); + root.add("coneY").move(0, 0.77, 0.05).scale(0.045, 0.08, 0.045).color(0.18, 0.14, 0.1); +} +// Seeded pseudo-random for deterministic generation per chunk +function seededRand(seed) { + let s = seed; + return () => { + s = (s * 1664525 + 1013904223) & 0xffffffff; + return (s >>> 0) / 0xffffffff; + }; +} + +function chunkSeed(cx, cz) { + // Cantor-pair–style unique integer for each (cx, cz) + return Math.abs(cx * 73856093 ^ cz * 19349663); +} + +// Track every loaded chunk +let loadedChunks = new Map(); + +// Which biome a chunk belongs to — used to switch models after campfire +// Chunks generated before the campfire are "snow", after are "green" +// (we just rebuild them on demand if biome changes) +let worldBiome = "snow"; // "snow" | "green" + +// Generate (or regenerate) a single chunk at (cx, cz) and add it to the scene +function buildChunk(cx, cz) { + const rand = seededRand(chunkSeed(cx, cz)); + const originX = cx * CHUNK_SIZE; + const originZ = cz * CHUNK_SIZE; + + const floorNodes = []; + const treeNodes = []; + const stoneNodes = []; + const plantNodes = []; + + // ── Floor tiles (3×3 grid inside the chunk) ── + const tilesPerSide = 2; + const tileSpacing = CHUNK_SIZE / tilesPerSide; + for (let tx = 0; tx < tilesPerSide; tx++) { + for (let tz = 0; tz < tilesPerSide; tz++) { + const x = originX + tx * tileSpacing + tileSpacing * 0.5 - CHUNK_SIZE * 0.5; + const z = originZ + tz * tileSpacing + tileSpacing * 0.5 - CHUNK_SIZE * 0.5; + if (inEntryClearZone(x, z)) continue; + const s = 2.5 + rand() * 0.8; + const rotY = rand() * Math.PI * 2; + + const url = worldBiome === "snow" + ? "../../media/models/nature/block-snow-large.glb" + : "../../media/models/nature/block-grass-large.glb"; + + const node = new Gltf2Node({ url }); + global.scene().addNode(node); + floorNodes.push(makePlacement(node, x, -4, z, rotY, s)); + } + } + + // ── Trees (0–2 per chunk, based on noise) ── + const treeCount = Math.floor(rand() * 3); // 0, 1, or 2 + for (let i = 0; i < treeCount; i++) { + const x = originX + (rand() - 0.5) * CHUNK_SIZE * 0.8; + const z = originZ + (rand() - 0.5) * CHUNK_SIZE * 0.8; + if (inEntryClearZone(x, z)) continue; + const s = 1.8 + rand() * 1.4; + const rotY = rand() * Math.PI * 2; + + const variant = Math.floor(rand() * 2); // 0 = regular tree, 1 = pine + const url = worldBiome === "snow" + ? (variant === 0 ? "../../media/models/nature/tree-snow.glb" : "../../media/models/nature/tree-pine-snow.glb") + : (variant === 0 ? "../../media/models/nature/tree.glb" : "../../media/models/nature/tree-pine.glb"); + + const node = new Gltf2Node({ url }); + global.scene().addNode(node); + treeNodes.push(makePlacement(node, x, -1.0, z, rotY, s)); + } + + // ── Stones (very sparse: 0–1 per chunk) ───────────────────────────────── + const stoneCount = rand() < 0.35 ? 1 : 0; + for (let i = 0; i < stoneCount; i++) { + const x = originX + (rand() - 0.5) * CHUNK_SIZE * 0.85; + const z = originZ + (rand() - 0.5) * CHUNK_SIZE * 0.85; + if (inEntryClearZone(x, z)) continue; + const s = 0.8 + rand() * 0.9; + const rotY = rand() * Math.PI * 2; + + const node = new Gltf2Node({ url: "../../media/models/nature/stones.glb" }); + global.scene().addNode(node); + stoneNodes.push(makePlacement(node, x, -1.05, z, rotY, s)); + } + + // ── Plants / details (0–3 per chunk, only in green biome) ── + if (worldBiome === "green") { + const plantCount = Math.floor(rand() * 4); + for (let i = 0; i < plantCount; i++) { + const x = originX + (rand() - 0.5) * CHUNK_SIZE * 0.9; + const z = originZ + (rand() - 0.5) * CHUNK_SIZE * 0.9; + if (inEntryClearZone(x, z)) continue; + const s = 1.0 + rand() * 1.0; + const rotY = rand() * Math.PI * 2; + + const variant = Math.floor(rand() * 2); + const url = variant === 0 + ? "../../media/models/nature/mushrooms.glb" + : "../../media/models/nature/flowers.glb"; + + const node = new Gltf2Node({ url }); + global.scene().addNode(node); + plantNodes.push(makePlacement(node, x, -1.0, z, rotY, s)); + } + } + + return { floorNodes, treeNodes, stoneNodes, plantNodes }; +} + +// Remove all nodes in a chunk and drop the chunk from the map +function unloadChunk(key) { + const chunk = loadedChunks.get(key); + if (!chunk) return; + for (const p of chunk.floorNodes) global.scene().removeNode(p.node); + for (const p of chunk.treeNodes) global.scene().removeNode(p.node); + for (const p of chunk.stoneNodes) global.scene().removeNode(p.node); + for (const p of chunk.plantNodes) global.scene().removeNode(p.node); + loadedChunks.delete(key); +} + +// Unload every chunk (used on reset) +function unloadAllChunks() { + for (const key of loadedChunks.keys()) unloadChunk(key); +} + +// Given the player's current world-space position, load nearby chunks and +// unload distant ones. Call every frame (cheap: most work is a Map lookup). +function updateChunks(playerX, playerZ) { + const pcx = Math.round(playerX / CHUNK_SIZE); + const pcz = Math.round(playerZ / CHUNK_SIZE); + + // Load chunks within RENDER_RADIUS + for (let dx = -RENDER_RADIUS; dx <= RENDER_RADIUS; dx++) { + for (let dz = -RENDER_RADIUS; dz <= RENDER_RADIUS; dz++) { + const cx = pcx + dx; + const cz = pcz + dz; + const key = `${cx},${cz}`; + if (!loadedChunks.has(key)) { + loadedChunks.set(key, buildChunk(cx, cz)); + } + } + } + + // Unload chunks beyond UNLOAD_RADIUS + for (const key of loadedChunks.keys()) { + const [cx, cz] = key.split(",").map(Number); + if (Math.abs(cx - pcx) > UNLOAD_RADIUS || Math.abs(cz - pcz) > UNLOAD_RADIUS) { + unloadChunk(key); + } + } +} + +// When biome changes (snow → green) we must rebuild all loaded chunks +function rebuildAllChunksForBiome() { + const keys = [...loadedChunks.keys()]; + for (const key of keys) unloadChunk(key); + // Chunks will be reloaded on the next updateChunks() call automatically +} + +// ─── Fixed Scene Objects (campfire area, monster NPC) ─────────────────────── +const firePos = CAMPFIRE_WORLD_POS; + +// ─── Dialogue state ────────────────────────────────────────────────────────── +let plantGrowthStartTime = 0; + +// ─── Matchbox (giant prop) ─────────────────────────────────────────────────── +// The matchbox is the iconic set-piece the slime stole — we render it at the +// back of the scene as a large static prop so it feels meaningful. +function renderMatchboxProp(model) { + model.add("cube") + .move(-3, 0.5, -5).turnY(0.4).scale(2, 0.8, 3) + .color(0.8, 0.1, 0.1); + model.add("square") + .move(-2.8, 0.5, -3.5).turnY(0.4).scale(2, 0.8, 3) + .color(0.2, 0.2, 0.2); +} + +function renderMapHUD(model, triggerJustPressed, getBeamHit) { + // 1. Moved bit right (X: -0.7) and further away (Z: -1.5) + const hudRoot = model.add() + .move(-0.7, 2.1, -1.5) + .turnY(0.45); // Slightly reduced angle since it's closer to the center + + // 2. Invisible Hit Target (Made narrower: X scale changed from 0.09 to 0.07) + const hudHitTarget = hudRoot.add("square") + .scale(0.07, 0.06, 1) + .color(0, 0, 0, 0); + + const isPointingHUD = getBeamHit(hudHitTarget); + + // 3. Visible Button Background (Made narrower: X scale changed from 0.07 to 0.05) + hudRoot.add("square") + .move(0, 0, 0.001) + .scale(0.05, 0.04, 1) + .color( + mapOpen + ? (isPointingHUD ? [1.0, 0.95, 0.70] : [0.98, 0.9, 0.55]) + : (isPointingHUD ? [0.85, 0.78, 0.58] : [0.75, 0.68, 0.48]) + ); + + // 4. Text (Adjusted the X offset from -0.03 to -0.02 to keep it centered in the narrower box) + hudRoot.add(clay.text("MAP")) + .move(-0.02, -0.012, 0.002) + .scale(0.07) + .color(0.15, 0.12, 0.08); + + // Toggle logic + if (triggerJustPressed && isPointingHUD) { + mapOpen = !mapOpen; + } +} +// ─── Export: render ────────────────────────────────────────────────────────── +export const render = (model, t, hands) => { + if (startTime === 0) startTime = t; + const elapsed = t - startTime; + const dt = prevFrameTime === 0 ? 0 : Math.min(t - prevFrameTime, 0.05); + prevFrameTime = t; + + // ── THE GREAT WIPE ────────────────────────────────────────────────────── + const isEnding = campfireLit && (t - fireStartTime) > 20; + + if (isEnding) { + // 1. Clear all "model.add" shapes (campfire, map, etc.) + while (model.nChildren() > 0) model.remove(0); + + // 2. Shrink the Slime to nothing + if (monsterNode) monsterNode.matrix = cg.mScale(0); + + // 3. Shrink all world chunks to nothing + for (const chunk of loadedChunks.values()) { + const hide = (p) => p.node.matrix = cg.mScale(0); + chunk.floorNodes.forEach(hide); + chunk.treeNodes.forEach(hide); + chunk.stoneNodes.forEach(hide); + chunk.plantNodes.forEach(hide); + } + + // 4. Create the Blackout (Inverted cube swallows the camera) + model.add("cube").scale(-20).color(0, 0, 0); + + // 5. Add your Text + model.add(clay.text("TO BE CONTINUED...")) + .move(-1.2, 1.5, -3) + .scale(6.0) + .color(1, 1, 1); + + return; // <--- THIS STOPS THE REST OF THE CODE FROM RUNNING + } + + // 1. QUICK LOADER (if not already loaded) + // 1. Initial Load (Runs once) + if (!soundsLoaded) { + loadSound("../../media/sound/line4.mp3", b => monsterLine4Buffer = b); + loadSound("../../media/sound/line5.mp3", b => monsterLine5Buffer = b); + loadSound("../../media/sound/line6.mp3", b => monsterLine6Buffer = b); + loadSound("../../media/sound/line7.mp3", b => monsterLine7Buffer = b); + soundsLoaded = true; + } + // ── Controller beam — update ONCE per frame ─────────────────────────── + let beamUpdated = false; + const getBeamHit = (target) => { + if (!window.beamR) return false; + if (!beamUpdated) { window.beamR.update(); beamUpdated = true; } + return !!window.beamR.hitRect(target.getGlobalMatrix()); + }; + + // ── Initialise static scene objects (once) ─────────────────────────────── + if (!isInitialized) { + monsterNode = new Gltf2Node({ url: "../../media/models/cute_slime.glb" }); + global.scene().addNode(monsterNode); + isInitialized = true; + } + + // ── Clear dynamic (per-frame) model children ───────────────────────────── + while (model.nChildren() > 0) model.remove(0); + + const leftHandPos = hands.find(h => h.side === "left" && Array.isArray(h.pos))?.pos || null; + + // ── Left-thumbstick locomotion for chunk streaming ──────────────────────── + // We keep a virtual player position for the infinite-world system and move + // it with the left stick. Hand positions are still used for campfire/rift + // interaction, but not for locomotion. + const stickX = applyDeadzone(joyStickState.left.x || 0); + const stickY = applyDeadzone(joyStickState.left.y || 0); + const [moveX, moveZ] = rotateXZ(stickX, stickY, virtualPlayerYaw); + virtualPlayerX += moveX * MOVE_SPEED * dt; + virtualPlayerZ += moveZ * MOVE_SPEED * dt; + const turnX = joyStickState.right.x || 0; + if (snapTurnReady && turnX >= SNAP_TURN_THRESHOLD) { + virtualPlayerYaw -= SNAP_TURN_ANGLE; + snapTurnReady = false; + } + else if (snapTurnReady && turnX <= -SNAP_TURN_THRESHOLD) { + virtualPlayerYaw += SNAP_TURN_ANGLE; + snapTurnReady = false; + } + else if (Math.abs(turnX) < STICK_DEADZONE) { + snapTurnReady = true; + } + const worldOffsetX = virtualPlayerX; + const worldOffsetZ = virtualPlayerZ; + const worldYaw = virtualPlayerYaw; + const playerToFire = planarDistance(virtualPlayerX, virtualPlayerZ, firePos[0], firePos[2]); + + const triggerPressed = !!window.rightClick; + const triggerJustPressed = triggerPressed && !mapTriggerLatch; + mapTriggerLatch = triggerPressed; + if (window.rightClick) window.rightClick = false; + + // ── Dark sky backdrop ───────────────────────────────────────────────────── + // Keep the sky centered on the player so it reads like an enclosing night + // sky rather than world geometry. + model.add("cube") + .scale(-80) + .color(0.015, 0.02, 0.035); + + // ── Infinite chunk streaming ────────────────────────────────────────────── + updateChunks(virtualPlayerX, virtualPlayerZ + STREAM_CENTER_Z_OFFSET); + applyChunkWorldOffset(worldOffsetX, worldOffsetZ, worldYaw); + + // ── NPC monster position (bobs gently) ─────────────────────────────────── + const npcPosWorld = [1.5, 1.0 + Math.sin(t * 3) * 0.05, -3]; + const npcPos = offsetPos(npcPosWorld, worldOffsetX, worldOffsetZ, worldYaw); + monsterNode.matrix = cg.mMultiply( + cg.mTranslate(npcPos[0], npcPos[1], npcPos[2]), + cg.mMultiply(cg.mRotateY(Math.PI - 0.5 - worldYaw), cg.mScale(1, 1, 1)) + ); + + // ── Matchbox prop ───────────────────────────────────────────────────────── + // ========================================== + // 1. WORLD MATCHBOX (If it's on the floor) + // ========================================== + if (!matchboxInPocket) { + const matchboxBasePos = offsetPos([matchboxWorldX, 0.5, matchboxWorldZ], worldOffsetX, worldOffsetZ, worldYaw); + + // Invisible Hit Target (Slightly larger for easy clicking) + const matchboxWorldTarget = model.add("cube") + .move(...matchboxBasePos) + .turnY(0.4 - worldYaw) + .scale(0.35, 0.2, 0.45) + .color(0, 0, 0, 0); + + const isPointingMatchbox = getBeamHit(matchboxWorldTarget); + + model.add("cube") + .move(...matchboxBasePos) + .turnY(0.4 - worldYaw) + .scale(0.13, 0.065, 0.21) + .color(0.8, 0.1, 0.1); + + //striker + model.add("cube") + .move(matchboxBasePos[0], matchboxBasePos[1], matchboxBasePos[2] + 0.01) + .turnY(0.4 - worldYaw) + .scale(0.131, 0.04, 0.18) + .color(0.2, 0.15, 0.1); + + // Floating Text + model.add(clay.text("[ GRAB MATCHBOX ]")) + .move(matchboxBasePos[0], matchboxBasePos[1] + 0.5, matchboxBasePos[2]) + .turnY(-worldYaw) + .scale(3.2) + .color(1,1,1); + + if (triggerJustPressed && isPointingMatchbox) { + matchboxInPocket = true; + } + } + // ========================================== + // 2. HUD POCKET (If you are carrying it) + // ========================================== + if (matchboxInPocket) { + // 1. Move to match Map HUD, just slightly lower on the Y-axis + const hudRoot = model.add() + .move(-0.7, 1.85, -1.5) // Map is at 2.1, this sits right under it + .turnY(0.45); // Matches Map rotation exactly + + // 2. Invisible Hit Target (Matches Map hit target size) + const hudHitTarget = hudRoot.add("square") + .scale(0.07, 0.06, 1) + .color(0, 0, 0, 0); + + const isPointingHUD = getBeamHit(hudHitTarget); + + // 3. Visible Button Background (Matches Map size, keeps Matchbox Red) + hudRoot.add("square") + .move(0, 0, 0.001) + .scale(0.05, 0.04, 1) + .color(isPointingHUD ? [1.0, 0.4, 0.4] : [0.8, 0.1, 0.1]); + + // 4. Text (Matches Map text formatting) + hudRoot.add(clay.text("MATCH")) + .move(-0.025, -0.012, 0.002) + .scale(0.06) + .color(1, 1, 1); + + // 5. Drop Mechanic + if (triggerJustPressed && isPointingHUD) { + matchboxInPocket = false; + + const [dropOffsetX, dropOffsetZ] = rotateXZ(0, -2, virtualPlayerYaw); + matchboxWorldX = virtualPlayerX + dropOffsetX; + matchboxWorldZ = virtualPlayerZ + dropOffsetZ; + } + } + + + const mapGiftPosNow = offsetPos(MAP_GIFT_WORLD_POS, worldOffsetX, worldOffsetZ, worldYaw); + const firePosNow = offsetPos(firePos, worldOffsetX, worldOffsetZ, worldYaw); + + // A flattened dark patch under the fire helps anchor it to the terrain. + model.add("sphere") + .move(firePosNow[0], firePosNow[1] - 0.02, firePosNow[2]) + .scale(0.95, 0.05, 0.95) + .color(0.05, 0.04, 0.04); + + customFire.renderCampfire(model, t, { + pos: [firePosNow[0], firePosNow[1], firePosNow[2]], + scale: 1.35, + yaw: -worldYaw, + lit: campfireLit, + }); + + if (!campfireLit) { + model.add("sphere").move(firePosNow[0], firePosNow[1] + 0.12, firePosNow[2]).scale(0.08).color(0.08, 0.08, 0.08); + model.add(clay.text("[ STRIKE MATCH TO IGNITE ]")) + .move(CAMPFIRE_WORLD_POS[0], CAMPFIRE_WORLD_POS[1] + 1.2, CAMPFIRE_WORLD_POS[2]) + .turnY(-virtualPlayerYaw) // Face the player + .scale(3.0) + .color(1.0, 0.8, 0.2); // Warm yellow/orange text + } + + if (!mapTaken) { + const mapHitTarget = model.add("square") + .move(...mapGiftPosNow) + .turnY(- worldYaw) + .scale(0.24, 0.16, 1) + .color(0, 0, 0, 0); + const isPointingMap = getBeamHit(mapHitTarget); + + model.add("cube") + .move(...mapGiftPosNow) + .turnY(- worldYaw) + .scale(0.22, 0.14, 0.018) + .color(...(isPointingMap ? [0.98, 0.9, 0.68] : [0.86, 0.8, 0.62])); + model.add("square") + .move(mapGiftPosNow[0], mapGiftPosNow[1], mapGiftPosNow[2] + 0.012) + .turnY(- worldYaw) + .scale(0.18, 0.1, 1) + .color(0.72, 0.64, 0.46); + if (triggerJustPressed && isPointingMap) { + mapTaken = true; + mapOpen = true; + } + } + if (mapTaken) { + renderMapHUD(model, triggerJustPressed, getBeamHit); + } + if (mapOpen && mapTaken) { + const mapAnchor = leftHandPos + ? [leftHandPos[0] + 0.12, leftHandPos[1] + 0.04, leftHandPos[2] - 0.22] + : [-0.42, 1.05, -1.0]; + renderMapPanel(model, mapAnchor, virtualPlayerX, virtualPlayerZ, virtualPlayerYaw); + } + + // ── Dialogue & fire logic ───────────────────────────────────────────────── + let dialogue = ""; + + if (!mapTaken) { + // Pre-fire dialogue + if (elapsed < 3) { + dialogue = "YOU CAME!"; + if (!played4 && monsterLine4Buffer) { + playSoundAtPosition(monsterLine4Buffer, npcPos, 3.0); + played4 = true; + } + } + else if (elapsed < 8) { + dialogue = "THIS IS MY HOME. IT'S BEEN FROZEN FOR SO LONG."; + if (!played5 && monsterLine5Buffer) { + playSoundAtPosition(monsterLine5Buffer, npcPos, 3.0); + played5 = true; + } + } + else if (elapsed < 13) { + dialogue = "I DREW YOU A MAP. THE OLD FIRE IS FAR FROM HERE."; + if (!played6 && monsterLine6Buffer) { + playSoundAtPosition(monsterLine6Buffer, npcPos, 3.0); + played6 = true; + } + } + else { + dialogue = "TAKE IT. FIND THE FIRE."; + if (!played7 && monsterLine7Buffer) { + playSoundAtPosition(monsterLine7Buffer, npcPos, 2.0); + played7 = true; + } + } + } + else if (!campfireLit) { + if (playerToFire > 4.0) dialogue = "FOLLOW THE MAP. FIND THE FIRE."; + else dialogue = "YOU FOUND IT. LIGHT THE FIRE."; + + // create an invisible hit target at the campfire + const fireHitTarget = model.add("square") + .move(...firePosNow) + .scale(0.6, 0.6, 1) + .color(0, 0, 0); + + const isAimingAtFire = getBeamHit(fireHitTarget); + + if (!campfireLit) { + if (isAimingAtFire && triggerJustPressed && (t - lastStrikeTime) > STRIKE_COOLDOWN) { + strikeCount++; + lastStrikeTime = t; + // play scratch sound here + if (strikeCount >= STRIKES_NEEDED) { + campfireLit = true; + fireStartTime = t; + // play ignite sound here + } + } + + // show strike progress so player knows what's happening + if (strikeCount > 0 && !campfireLit) { + const progress = "/ ".repeat(strikeCount) + "_ ".repeat(STRIKES_NEEDED - strikeCount); + model.add(clay.text(progress)) + .move(firePosNow[0] - 0.2, firePosNow[1] + 0.5, firePosNow[2]) + .scale(2.0) + .color(1.0, 0.7, 0.2); + } + } + } else { + const timeSinceLit = t - fireStartTime; + + // ── Biome swap (once) ──────────────────────────────────────────────── + if (!modelsSwapped) { + worldBiome = "green"; + rebuildAllChunksForBiome(); + plantGrowthStartTime = t; + modelsSwapped = true; + } + + if (timeSinceLit < 12) { + // Phase A: thawing, fire burning warm + dialogue = "THANK YOU! THE ICE IS MELTING!"; + const pulse = 0.5 + 0.15 * Math.sin(t * 8); + model.add("sphere") + .move(firePosNow[0], firePosNow[1] + 0.35, firePosNow[2]) + .scale(0.22 + pulse * 0.04, 0.18 + pulse * 0.03, 0.22 + pulse * 0.04) + .color(1, 0.42 + pulse * 0.3, 0.06); + + } else { + // Phase B: fire too strong, rift opens + if (!riftOpened) riftOpened = true; + dialogue = ""; + + const alienPulse = 0.5 * Math.sin(t * 15); + model.add("sphere") + .move(firePosNow[0], firePosNow[1] + 0.4, firePosNow[2]) + .scale(0.5 + alienPulse * 0.2) + .color(0, 1, 0.8 + alienPulse * 0.2); + + // Rift touch triggers Scene 3 + for (const h of hands) { + if (h.pos && cg.distance(h.pos, firePosNow) < FIRE_INTERACT_RADIUS + 0.2) { + window.sharedState.gamePhase = "SCENE_3"; + } + } + } + } + + // ── NPC dialogue text ───────────────────────────────────────────────────── + model.add(clay.text(dialogue)) + .move(npcPos[0] - 0.5, npcPos[1] + 1, npcPos[2]) + .scale(3.5) + .color(1, 1, 1); + + // ── Hint: show campfire interaction prompt before it is lit ────────────── + // ── Hint Logic: Only show these if the fire is NOT lit yet ────────────── + if (!campfireLit) { + if (playerToFire < 12.0) { + // 1. If you are close to the fire, this is the most important instruction. + model.add(clay.text("[ STRIKE MATCH TO LIGHT FIRE (RIGHT CLICK 3 TIMES)]")) + .move(firePosNow[0] - 0.8, 1.2, firePosNow[2]) + .turnY(-worldYaw) + .scale(2.5) + .color(1.0, 0.7, 0.2); + + } else if (!mapTaken) { + // 2. If far from fire and map is still on the pedestal, show map prompt. + // (Added a check for elapsed >= 8 so it doesn't show immediately at start) + if (elapsed >= 8) { + model.add(clay.text("[ AIM AND PRESS RIGHT TRIGGER ]")) + .move(mapGiftPosNow[0] - 0.45, mapGiftPosNow[1] + 0.28, mapGiftPosNow[2]) + .turnY(-worldYaw) // Keep it parallel to the map + .scale(1.8) + .color(0.95, 0.85, 0.42); + } + + } else { + // 3. If far from fire and you ALREADY HAVE the map, show how to use the HUD. + model.add(clay.text("[ RIGHT CLICK TO OPEN/CLOSE MAP ]")) + .move(-0.9, 2.05, -1.5) + .turnY(0.45) + .scale(1.5) + .color(0.72, 0.82, 0.95); + } + } + // Once campfireLit is true, all the above hints disappear automatically!s +}; + +// ─── Export: resetScene ────────────────────────────────────────────────────── +export const resetScene = () => { + startTime = 0; + campfireLit = false; + fireStartTime = 0; + riftOpened = false; + modelsSwapped = false; + isInitialized = false; + worldBiome = "snow"; + plantGrowthStartTime = 0; + prevFrameTime = 0; + virtualPlayerX = 0; + virtualPlayerZ = 0; + virtualPlayerYaw = 0; + snapTurnReady = true; + mapTaken = false; + mapOpen = false; + mapTriggerLatch = false; + + unloadAllChunks(); + + if (monsterNode) { + global.scene().removeNode(monsterNode); + monsterNode = null; + } + + console.log("[micro_world] Scene 2 reset — all chunks unloaded"); +}; diff --git a/js/scenes/scene3.js b/js/scenes/scene3.js new file mode 100644 index 0000000..ea3887d --- /dev/null +++ b/js/scenes/scene3.js @@ -0,0 +1,26 @@ +import * as cg from "../render/core/cg.js"; + +export const init = async model => { + // ─── LOAD VISHCHUN (High-Poly Alien Flora) ─── + let alienCore = model.add('gltf', './assets/Vishchun_Core.glb'); + let alienTentacle1 = model.add('gltf', './assets/Vishchun_Flora1.glb'); + let alienTentacle2 = model.add('gltf', './assets/Vishchun_Flora2.glb'); + + let startTime = model.time; + + model.animate(() => { + let t = model.time; + if (window.beamR) window.beamR.update(); + + // The new task: The alien plants are spinning out of control. + // The player must use the laser to hit the core and "cool it down". + let chaosSpeed = 2.0; // Starts fast! + + if (alienCore) alienCore.identity().move(0, 1, -3).turnY(t * chaosSpeed).scale(3, 3, 3); + if (alienTentacle1) alienTentacle1.identity().move(-2, 0, -2).turnZ(Math.sin(t * chaosSpeed)).scale(2, 2, 2); + if (alienTentacle2) alienTentacle2.identity().move(2, 0, -2).turnZ(-Math.sin(t * chaosSpeed)).scale(2, 2, 2); + + let dialogue = "THE ALIEN CORE IS OVERHEATING! COOL IT DOWN!"; + model.add(clay.text(dialogue)).move(0, 3.0, -3).scale(0.015).color(0, 1, 0.8); + }); +} \ No newline at end of file diff --git a/js/scenes/scenes.js b/js/scenes/scenes.js index 13bdbc6..fdbc1ff 100644 --- a/js/scenes/scenes.js +++ b/js/scenes/scenes.js @@ -28,18 +28,26 @@ export default () => { { name: "master1" , path: "./master1.js" , public: true }, { name: "bouncing" , path: "./bouncing.js" , public: true }, { name: "parse1" , path: "./parse1.js" , public: true }, - { name: "beam" , path: "./beam.js" , public: true }, { name: "headGaze" , path: "./headGaze.js" , public: true }, { name: "reading" , path: "./reading.js" , public: true }, { name: "parse2" , path: "./parse2.js" , public: true }, - { name: "aiHelper" , path: "./aiQuery.js" , public: true }, { name: "parse3" , path: "./parse3.js" , public: true }, { name: "arrange" , path: "./arrange.js" , public: true }, { name: "arrange2" , path: "./arrange2.js" , public: true }, { name: "widgets" , path: "./widgets.js" , public: true }, { name: "transfer" , path: "./transfer.js" , public: true }, + { name: "car" , path: "./car.js" , public: true }, + { name: "carDrive" , path: "./carDrive.js" , public: true }, + { name: "campFire" , path: "./campFire.js" , public: true }, + { name: "classUse1" , path: "./classUse1.js" , public: true }, + { name: "classUse2" , path: "./classUse2.js" , public: true }, + { name: "textHW" , path: "./textHW.js" , public: true }, + { name: "master2" , path: "./master2.js" , public: true }, + { name: "headGazeExercise" , path: "./headGazeExercise.js" , public: true }, + { name: "spiritExercise" , path: "./spirit_exercise.js" , public: true }, + { name: "final" , path: "./final_project.js" , public: true } ] }; } diff --git a/js/scenes/spirit_exercise.js b/js/scenes/spirit_exercise.js new file mode 100644 index 0000000..6c8ca4a --- /dev/null +++ b/js/scenes/spirit_exercise.js @@ -0,0 +1,240 @@ +import * as cg from "../render/core/cg.js"; +import { loadSound, playSoundAtPosition } from "../util/positional-audio.js"; + +const TARGET_COUNT = 36; +const TRAIL_COUNT = 24; +const STRIKE_Z = -0.55; +const DESPAWN_Z = -0.15; +const SPAWN_Z = -5.6; + +const lanes = [-1.0, -0.35, 0.35, 1.0]; +const heights = [1.15, 1.5, 1.85]; + +const leftColor = [0.22, 0.9, 1.0]; +const rightColor = [1.0, 0.35, 0.25]; +const neutralColor = [0.95, 0.9, 0.35]; + +let soundBuffer = [], loadSounds = []; +for (let i = 0; i < 6; i++) + loadSounds.push(loadSound("../../media/sound/bounce/" + i + ".wav", buffer => soundBuffer[i] = buffer)); +Promise.all(loadSounds); + +const makeTarget = () => ({ + active: false, + hand: "any", + lane: 0, + level: 1, + spawnTime: 0, + life: 0, + speed: 0, + hitFlash: 0, + pos: [0, 1.5, SPAWN_Z], +}); + +const targetColor = hand => hand == "left" ? leftColor : hand == "right" ? rightColor : neutralColor; + +const choosePattern = beat => { + const phase = beat % 16; + + if (phase >= 8) + return { step: 0.9, count: 1, speed: 1.9, mode: "cross" }; + + return { step: 1.0, count: 1, speed: 1.7, mode: "flow" }; +}; + +export const init = async model => { + let beat = 0; + let nextSpawnTime = 0; + let combo = 0; + let bestCombo = 0; + let score = 0; + let hits = 0; + let misses = 0; + + let targets = []; + let trails = []; + + for (let i = 0; i < TARGET_COUNT; i++) { + targets.push(makeTarget()); + model.add("sphere"); + } + + for (let i = 0; i < TRAIL_COUNT; i++) { + trails.push({ pos: [0, 1.5, -1], life: 0, color: [1, 1, 1] }); + model.add("sphere"); + } + + const leftGuide = model.add("ringZ"); + const rightGuide = model.add("ringZ"); + + const horizon = model.add("square"); + const floor = model.add("square"); + const leftPeak = model.add("coneY"); + const rightPeak = model.add("coneY"); + + let addTrail = (pos, color) => { + trails.unshift({ pos: [...pos], life: 1, color: [...color] }); + trails.pop(); + }; + + let spawnTarget = (spawnAt, patternMode, speed) => { + let activeCount = 0; + for (let i = 0; i < TARGET_COUNT; i++) + if (targets[i].active) + activeCount++; + if (activeCount >= 2) + return; + + for (let i = 0; i < TARGET_COUNT; i++) { + if (targets[i].active) + continue; + + const lane = patternMode == "cross" + ? (beat % 2 == 0 ? 0 : 3) + : 4 * Math.random() >> 0; + + const level = patternMode == "burst" + ? ((beat + i) % 3) + : (3 * Math.random() >> 0); + + let hand = "any"; + if (lane < 2) + hand = "left"; + if (lane > 1) + hand = "right"; + if (patternMode == "flow" && beat % 4 == 3) + hand = "any"; + + targets[i].active = true; + targets[i].hand = hand; + targets[i].lane = lane; + targets[i].level = level; + targets[i].spawnTime = spawnAt; + targets[i].life = 1; + targets[i].speed = speed; + targets[i].hitFlash = 0; + targets[i].pos = [lanes[lane], heights[level], SPAWN_Z]; + return; + } + }; + + let playHit = index => { + if (soundBuffer.length == 0) + return; + playSoundAtPosition(soundBuffer[index % soundBuffer.length], targets[index].pos); + }; + + model.animate(() => { + const t = model.time; + const dt = model.deltaTime; + const bpm = 132 + 16 * Math.sin(0.05 * t); + const beatDuration = 60 / bpm; + + while (t >= nextSpawnTime) { + const pattern = choosePattern(beat); + for (let i = 0; i < pattern.count; i++) + spawnTarget(nextSpawnTime + i * pattern.step, pattern.mode, pattern.speed); + + nextSpawnTime += beatDuration; + beat++; + } + + const leftHand = clientState.finger(clientID, "left", 1); + const rightHand = clientState.finger(clientID, "right", 1); + + if (Array.isArray(leftHand)) + addTrail(leftHand, leftColor); + if (Array.isArray(rightHand)) + addTrail(rightHand, rightColor); + + + for (let i = 0; i < TARGET_COUNT; i++) { + const targetNode = model.child(i); + const target = targets[i]; + + if (!target.active) { + targetNode.identity().scale(0); + continue; + } + + target.pos[2] += target.speed * dt; + target.hitFlash *= 0.86; + + let gotHit = false; + const hitRadius = 0.28; + + if (Array.isArray(leftHand) && target.hand != "right" && cg.distance(leftHand, target.pos) < hitRadius) + gotHit = true; + + if (Array.isArray(rightHand) && target.hand != "left" && cg.distance(rightHand, target.pos) < hitRadius) + gotHit = true; + + if (gotHit) { + target.hitFlash = 1; + target.active = false; + combo++; + hits++; + score += 10 + combo; + bestCombo = Math.max(bestCombo, combo); + playHit(i); + continue; + } + + if (target.pos[2] > DESPAWN_Z) { + target.active = false; + misses++; + combo = 0; + continue; + } + + const c = targetColor(target.hand); + const glow = 0.2 + 0.25 * Math.sin(14 * t + i); + const depthScale = 1.05 + 0.8 * (target.pos[2] - STRIKE_Z) / (SPAWN_Z - STRIKE_Z); + + targetNode.identity() + .move(target.pos) + .scale(0.13 * depthScale) + .color(c[0] + glow, c[1] + glow, c[2] + glow); + } + + for (let i = 0; i < TRAIL_COUNT; i++) { + const n = model.child(TARGET_COUNT + i); + const tr = trails[i]; + tr.life *= 0.9; + + if (tr.life < 0.06) { + n.identity().scale(0); + continue; + } + + n.identity().move(tr.pos).scale(0.04 * tr.life) + .color(tr.color[0] * tr.life, tr.color[1] * tr.life, tr.color[2] * tr.life); + } + + const leftGuidePos = Array.isArray(leftHand) ? leftHand : [-0.45, 1.4, -0.55]; + const rightGuidePos = Array.isArray(rightHand) ? rightHand : [0.45, 1.4, -0.55]; + + leftGuide.identity().move(leftGuidePos).scale(0.1 + 0.03 * Math.sin(9 * t)).color(...leftColor); + rightGuide.identity().move(rightGuidePos).scale(0.1 + 0.03 * Math.sin(9 * t + 1)).color(...rightColor); + + + horizon.identity().move(0, 2.0, -7.5).scale(9.5, 4.8, 1).color(0.06, 0.1, 0.2); + floor.identity().move(0, 0.72, -2.8).turnX(-Math.PI / 2).scale(3.2, 3.2, 1).color(0.04, 0.06, 0.12); + leftPeak.identity().move(-4.8, 0.8, -8.0).scale(1.8, 3.2, 1.8).color(0.08, 0.14, 0.2); + rightPeak.identity().move(4.8, 0.82, -8.1).scale(2.1, 3.4, 2.1).color(0.09, 0.12, 0.22); + + const total = hits + misses; + const accuracy = total > 0 ? Math.floor(100 * hits / total) : 100; + + while (model.nChildren() > TARGET_COUNT + TRAIL_COUNT + 6) + model.remove(TARGET_COUNT + TRAIL_COUNT + 6); + + model.add(clay.text("RHYTHM CARDIO")).move(-1.05, 2.52, -1.85).scale(1.95).color(0.9, 1.0, 1.0); + model.add(clay.text("SCORE " + score)).move(-1.05, 2.30, -1.85).scale(1.2).color(0.85, 0.95, 1.0); + model.add(clay.text("COMBO " + combo + " BEST " + bestCombo)).move(-1.05, 2.13, -1.85).scale(1.08).color(1.0, 0.95, 0.5); + model.add(clay.text("ACCURACY " + accuracy + "%")) + .move(-1.05, 1.96, -1.85).scale(1.08).color(0.5, 1.0, 0.8); + model.add(clay.text("STRIKE IN TIME WITH THE BEAT")) + .move(-1.05, 1.73, -1.85).scale(0.92).color(0.65, 0.85, 1.0); + }); +}; diff --git a/js/scenes/text1.js b/js/scenes/text1.js index 8c06bb9..db5ea63 100644 --- a/js/scenes/text1.js +++ b/js/scenes/text1.js @@ -10,4 +10,3 @@ export const init = async model => { model.add(myText).move(-.1,1.45,0).color(1,1,1).scale(.1); }); } - diff --git a/js/scenes/textHW.js b/js/scenes/textHW.js new file mode 100644 index 0000000..f280885 --- /dev/null +++ b/js/scenes/textHW.js @@ -0,0 +1,61 @@ +export const init = async model => { + let words = [ + "WOW", + "PLAY", + "DANCE", + "BOUNCE", + "SPIN", + "LAUGH", + "JUMP", + "GLOW" + ]; + + let t = 0; + let speed = 0.004; + let zOffset = -2.2; + + let wordPose = (i, tt) => { + let a = 0.75 * tt + (2 * Math.PI * i) / words.length; + let r = 1.9 + 0.08 * Math.sin(1.5 * tt + i); + return { + x: r * Math.cos(a) - 0.23, + y: 2.1 + 0.1 * Math.sin(2 * tt + i) + 0.04, + z: -1.25 + r * Math.sin(a) + zOffset, + }; + }; + + model.animate(() => { + while (model.nChildren()) + model.remove(0); + + let paused = false; + if (typeof inputEvents.isPressed == "function") + paused = inputEvents.isPressed("left") || inputEvents.isPressed("right"); + + if (!paused) + t += speed; + + model.add(clay.text("TEXT PARTY")) + .move(-0.95, 1.18, -0.59+zOffset) + .scale(4.8) + .color(1, 0.2, 0.95); + + for (let i = 0; i < words.length; i++) { + let w = wordPose(i, t); + let red = 0.5 + 0.5 * Math.sin(1.4 * t + i); + let green = 0.5 + 0.5 * Math.sin(1.4 * t + i + 2.1); + let blue = 0.5 + 0.5 * Math.sin(1.4 * t + i + 4.2); + + model.add(clay.text(words[i])) + .move(w.x, w.y, w.z) + .scale(2.2) + .color(paused ? 1 : red, paused ? 0.95 : green, paused ? 0.2 : blue); + } + + let hint = paused ? "PAUSED - RELEASE TRIGGER TO RESUME" : "HOLD LEFT/RIGHT TRIGGER TO PAUSE"; + model.add(clay.text(hint)) + .move(-0.95, 0.5, -0.59+zOffset) + .scale(1.6) + .color(0.2, 1, 0.95); + }); +}; diff --git a/js/util/positional-audio.js b/js/util/positional-audio.js index 5ff6253..d6ed552 100644 --- a/js/util/positional-audio.js +++ b/js/util/positional-audio.js @@ -46,17 +46,24 @@ export async function loadSound(url, bufferSetter) { } -export function playSoundAtPosition(buffer, position) { +export function playSoundAtPosition(buffer, position, volume = 1.0) { audioContext.resume(); const source = audioContext.createBufferSource(); source.buffer = buffer; + + // --- 新增:创建一个音量控制节点 (GainNode) --- + const gainNode = audioContext.createGain(); + gainNode.gain.value = volume; // 根据传入的参数设置音量大小 + resonanceSource.setPosition(position[0], position[1], position[2]); - source.connect(resonanceSource.input); + + // --- 修改连接顺序:音源 -> 音量节点 -> 空间音效节点 --- + source.connect(gainNode); + gainNode.connect(resonanceSource.input); + source.start(0); - console.log('Sound Played'); - + console.log(`Sound Played with volume: ${volume}`); } - // play looping sounds let ongoingSource; diff --git a/js/util/texts.js b/js/util/texts.js index 8eb6ca5..74cf8e5 100644 --- a/js/util/texts.js +++ b/js/util/texts.js @@ -1,4 +1,6 @@ export let texts = [ +`Hello, world! +`, ` import * as cg from "../render/core/cg.js"; import { G3 } from "../util/g3.js"; diff --git a/media/gltf/Chandelier/Chandelier.bin b/media/gltf/Chandelier/Chandelier.bin new file mode 100644 index 0000000..0dacf3a Binary files /dev/null and b/media/gltf/Chandelier/Chandelier.bin differ diff --git a/media/gltf/Chandelier/Chandelier.gltf b/media/gltf/Chandelier/Chandelier.gltf new file mode 100644 index 0000000..ff04b9a --- /dev/null +++ b/media/gltf/Chandelier/Chandelier.gltf @@ -0,0 +1 @@ +{"asset":{"generator":"glTF-Transform v4.2.1","version":"2.0"},"accessors":[{"name":"Chandelier_01_Cube.035-Mesh_0_positions","type":"VEC3","componentType":5126,"count":1913,"max":[3.5627129077911377,10.005268096923828,3.5614700317382812],"min":[-3.5627129077911377,0.6722229719161987,-3.5614700317382812],"bufferView":0,"byteOffset":0},{"name":"Chandelier_01_Cube.035-Mesh_0_normals","type":"VEC3","componentType":5126,"count":1913,"bufferView":0,"byteOffset":12},{"name":"Chandelier_01_Cube.035-Mesh_0_indices","type":"SCALAR","componentType":5123,"count":3288,"bufferView":1,"byteOffset":0},{"name":"Chandelier_01_Cube.035-Mesh_1_indices","type":"SCALAR","componentType":5123,"count":792,"bufferView":1,"byteOffset":6576},{"name":"Chandelier_01_Cube.035-Mesh_2_indices","type":"SCALAR","componentType":5123,"count":672,"bufferView":1,"byteOffset":8160},{"name":"Chandelier_01_Cube.035-Mesh_1_positions","type":"VEC3","componentType":5126,"count":600,"max":[3.649174928665161,3.3673930168151855,3.648792028427124],"min":[-3.649174928665161,0.032280001789331436,-3.648792028427124],"bufferView":2,"byteOffset":0},{"name":"Chandelier_01_Cube.035-Mesh_1_normals","type":"VEC3","componentType":5126,"count":600,"bufferView":2,"byteOffset":12},{"name":"Chandelier_01_Cube.035-Mesh_2_positions","type":"VEC3","componentType":5126,"count":480,"max":[2.829387903213501,4.061237812042236,2.828144073486328],"min":[-2.829387903213501,3.3673930168151855,-2.828144073486328],"bufferView":3,"byteOffset":0},{"name":"Chandelier_01_Cube.035-Mesh_2_normals","type":"VEC3","componentType":5126,"count":480,"bufferView":3,"byteOffset":12}],"bufferViews":[{"buffer":0,"byteOffset":0,"byteLength":45912,"byteStride":24,"target":34962},{"buffer":0,"byteOffset":45912,"byteLength":9504,"target":34963},{"buffer":0,"byteOffset":55416,"byteLength":14400,"byteStride":24,"target":34962},{"buffer":0,"byteOffset":69816,"byteLength":11520,"byteStride":24,"target":34962}],"buffers":[{"name":"Chandelier_01","uri":"Chandelier.bin","byteLength":81336}],"materials":[{"name":"DD9944","pbrMetallicRoughness":{"baseColorFactor":[0.866667,0.6,0.266667,1],"roughnessFactor":0.903921569,"metallicFactor":0}},{"name":"FFFFFF","pbrMetallicRoughness":{"roughnessFactor":0.903921569,"metallicFactor":0}},{"name":"FFEB3B","pbrMetallicRoughness":{"baseColorFactor":[1,0.921569,0.231373,1],"roughnessFactor":0.903921569,"metallicFactor":0}}],"meshes":[{"name":"Chandelier_01_Cube.035-Mesh","primitives":[{"attributes":{"POSITION":0,"NORMAL":1},"mode":4,"material":0,"indices":2},{"attributes":{"POSITION":5,"NORMAL":6},"mode":4,"material":1,"indices":3},{"attributes":{"POSITION":7,"NORMAL":8},"mode":4,"material":2,"indices":4}]}],"nodes":[{"name":"Chandelier_01_Cube.035","mesh":0}],"scenes":[{"nodes":[0]}],"scene":0} \ No newline at end of file diff --git a/media/gltf/cute_slime/license.txt b/media/gltf/cute_slime/license.txt new file mode 100644 index 0000000..da61f9e --- /dev/null +++ b/media/gltf/cute_slime/license.txt @@ -0,0 +1,11 @@ +Model Information: +* title: Cute Slime +* source: https://sketchfab.com/3d-models/cute-slime-cf04b5e4d11f4fcba581dbda21134896 +* author: hugannh3 (https://sketchfab.com/hugannh3) + +Model License: +* license type: CC-BY-4.0 (http://creativecommons.org/licenses/by/4.0/) +* requirements: Author must be credited. Commercial use is allowed. + +If you use this 3D model in your project be sure to copy paste this credit wherever you share it: +This work is based on "Cute Slime" (https://sketchfab.com/3d-models/cute-slime-cf04b5e4d11f4fcba581dbda21134896) by hugannh3 (https://sketchfab.com/hugannh3) licensed under CC-BY-4.0 (http://creativecommons.org/licenses/by/4.0/) \ No newline at end of file diff --git a/media/gltf/cute_slime/scene.bin b/media/gltf/cute_slime/scene.bin new file mode 100644 index 0000000..9eb5fb6 Binary files /dev/null and b/media/gltf/cute_slime/scene.bin differ diff --git a/media/gltf/cute_slime/scene.gltf b/media/gltf/cute_slime/scene.gltf new file mode 100644 index 0000000..f6a588c --- /dev/null +++ b/media/gltf/cute_slime/scene.gltf @@ -0,0 +1,1152 @@ +{ + "accessors": [ + { + "bufferView": 3, + "componentType": 5126, + "count": 1562, + "max": [ + 0.24283985793590546, + 0.4847017824649811, + 0.23476369678974152 + ], + "min": [ + -0.22885391116142273, + 0.003744864370673895, + -0.23503753542900085 + ], + "type": "VEC3" + }, + { + "bufferView": 3, + "byteOffset": 18744, + "componentType": 5126, + "count": 1562, + "max": [ + 0.9996407628059387, + 0.9999397397041321, + 0.999866247177124 + ], + "min": [ + -0.999751627445221, + -1.0, + -0.9998652935028076 + ], + "type": "VEC3" + }, + { + "bufferView": 2, + "componentType": 5126, + "count": 1562, + "max": [ + 0.8638614416122437, + 0.23473824560642242 + ], + "min": [ + 0.8635023832321167, + 0.22292682528495789 + ], + "type": "VEC2" + }, + { + "bufferView": 1, + "componentType": 5125, + "count": 8832, + "type": "SCALAR" + }, + { + "bufferView": 3, + "byteOffset": 37488, + "componentType": 5126, + "count": 507, + "max": [ + 0.15182989835739136, + 0.30778199434280396, + -0.17359691858291626 + ], + "min": [ + -0.13783326745033264, + 0.17687572538852692, + -0.22874267399311066 + ], + "type": "VEC3" + }, + { + "bufferView": 3, + "byteOffset": 43572, + "componentType": 5126, + "count": 507, + "max": [ + 0.9935283660888672, + 0.9962303638458252, + 0.9493014812469482 + ], + "min": [ + -0.9598587155342102, + -0.9269220232963562, + -0.9952096939086914 + ], + "type": "VEC3" + }, + { + "bufferView": 2, + "byteOffset": 12496, + "componentType": 5126, + "count": 507, + "max": [ + 0.7915970087051392, + 0.9446514248847961 + ], + "min": [ + 0.18030130863189697, + 0.06636476516723633 + ], + "type": "VEC2" + }, + { + "bufferView": 1, + "byteOffset": 35328, + "componentType": 5125, + "count": 2700, + "type": "SCALAR" + }, + { + "bufferView": 3, + "byteOffset": 49656, + "componentType": 5126, + "count": 2034, + "max": [ + 0.2478380650281906, + 0.4897014796733856, + 0.2397630214691162 + ], + "min": [ + -0.23385266959667206, + -0.0012551350519061089, + -0.24003686010837555 + ], + "type": "VEC3" + }, + { + "bufferView": 3, + "byteOffset": 74064, + "componentType": 5126, + "count": 2034, + "max": [ + 0.9997515678405762, + 1.0, + 0.9998657703399658 + ], + "min": [ + -0.9996405839920044, + -0.9999387264251709, + -0.999866783618927 + ], + "type": "VEC3" + }, + { + "bufferView": 2, + "byteOffset": 16552, + "componentType": 5126, + "count": 2034, + "max": [ + 0.8638614416122437, + 0.9446514248847961 + ], + "min": [ + 0.18030130863189697, + 0.06636476516723633 + ], + "type": "VEC2" + }, + { + "bufferView": 1, + "byteOffset": 46128, + "componentType": 5125, + "count": 11316, + "type": "SCALAR" + }, + { + "bufferView": 5, + "componentType": 5126, + "count": 3, + "max": [ + 1.0, + 0.0, + 2.384185791015625e-07, + 0.0, + 0.0, + 1.0000001192092896, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0 + ], + "min": [ + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + -2.384185791015625e-07, + 0.0, + 1.0, + 0.0, + -0.006390342488884926, + -0.04414748400449753, + -0.00010652931814547628, + 1.0 + ], + "type": "MAT4" + }, + { + "bufferView": 0, + "componentType": 5123, + "count": 1562, + "type": "VEC4" + }, + { + "bufferView": 4, + "componentType": 5126, + "count": 1562, + "max": [ + 1.0, + 0.4998290538787842, + 0.0, + 0.0 + ], + "min": [ + 0.5001709461212158, + 0.0, + 0.0, + 0.0 + ], + "type": "VEC4" + }, + { + "bufferView": 0, + "byteOffset": 12496, + "componentType": 5123, + "count": 507, + "type": "VEC4" + }, + { + "bufferView": 4, + "byteOffset": 24992, + "componentType": 5126, + "count": 507, + "max": [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + "min": [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + "type": "VEC4" + }, + { + "bufferView": 0, + "byteOffset": 16552, + "componentType": 5123, + "count": 2034, + "type": "VEC4" + }, + { + "bufferView": 4, + "byteOffset": 33104, + "componentType": 5126, + "count": 2034, + "max": [ + 1.0, + 0.4998290538787842, + 0.0, + 0.0 + ], + "min": [ + 0.5001709461212158, + 0.0, + 0.0, + 0.0 + ], + "type": "VEC4" + }, + { + "bufferView": 6, + "componentType": 5126, + "count": 100, + "max": [ + 4.166666507720947 + ], + "min": [ + 0.0416666679084301 + ], + "type": "SCALAR" + }, + { + "bufferView": 7, + "componentType": 5126, + "count": 100, + "max": [ + 0.03999999910593033, + 0.050371427088975906, + 1.0968528307842007e-08 + ], + "min": [ + -0.03999999910593033, + 0.010371426120400429, + 1.0968522090593069e-08 + ], + "type": "VEC3" + }, + { + "bufferView": 6, + "byteOffset": 400, + "componentType": 5126, + "count": 100, + "max": [ + 4.166666507720947 + ], + "min": [ + 0.0416666679084301 + ], + "type": "SCALAR" + }, + { + "bufferView": 8, + "componentType": 5126, + "count": 100, + "max": [ + 0.0, + -2.842170943040401e-14, + 0.0, + 1.0 + ], + "min": [ + 0.0, + -2.879567786474857e-14, + 0.0, + 1.0 + ], + "type": "VEC4" + }, + { + "bufferView": 6, + "byteOffset": 800, + "componentType": 5126, + "count": 100, + "max": [ + 4.166666507720947 + ], + "min": [ + 0.0416666679084301 + ], + "type": "SCALAR" + }, + { + "bufferView": 7, + "byteOffset": 1200, + "componentType": 5126, + "count": 100, + "max": [ + 1.0, + 1.0199998617172241, + 1.0 + ], + "min": [ + 0.949999988079071, + 0.800000011920929, + 0.949999988079071 + ], + "type": "VEC3" + }, + { + "bufferView": 6, + "byteOffset": 1200, + "componentType": 5126, + "count": 30, + "max": [ + 1.375 + ], + "min": [ + 0.0416666679084301 + ], + "type": "SCALAR" + }, + { + "bufferView": 7, + "byteOffset": 2400, + "componentType": 5126, + "count": 30, + "max": [ + 0.0, + 0.9241398572921753, + 0.0 + ], + "min": [ + 0.0, + -0.0007633638451807201, + 0.0 + ], + "type": "VEC3" + }, + { + "bufferView": 6, + "byteOffset": 1320, + "componentType": 5126, + "count": 22, + "max": [ + 1.375 + ], + "min": [ + 0.0416666679084301 + ], + "type": "SCALAR" + }, + { + "bufferView": 7, + "byteOffset": 2760, + "componentType": 5126, + "count": 22, + "max": [ + 1.0, + 1.0805373191833496, + 1.0 + ], + "min": [ + 1.0, + 0.7701960206031799, + 1.0 + ], + "type": "VEC3" + }, + { + "bufferView": 6, + "byteOffset": 1408, + "componentType": 5126, + "count": 33, + "max": [ + 1.375 + ], + "min": [ + 0.0416666679084301 + ], + "type": "SCALAR" + }, + { + "bufferView": 7, + "byteOffset": 3024, + "componentType": 5126, + "count": 33, + "max": [ + 1.5553118392320976e-16, + 0.15035074949264526, + 0.0 + ], + "min": [ + 0.0, + -0.010951504111289978, + -0.002346094697713852 + ], + "type": "VEC3" + }, + { + "bufferView": 6, + "byteOffset": 1540, + "componentType": 5126, + "count": 33, + "max": [ + 1.375 + ], + "min": [ + 0.0416666679084301 + ], + "type": "SCALAR" + }, + { + "bufferView": 8, + "byteOffset": 1600, + "componentType": 5126, + "count": 33, + "max": [ + 0.002479953458532691, + -1.4210768317841384e-14, + 8.155463503246938e-17, + 0.9999969601631165 + ], + "min": [ + 0.0024799529928714037, + -3.538465229374968e-14, + 2.4917085491318498e-17, + 0.9999969601631165 + ], + "type": "VEC4" + }, + { + "bufferView": 6, + "byteOffset": 1672, + "componentType": 5126, + "count": 33, + "max": [ + 1.375 + ], + "min": [ + 0.0416666679084301 + ], + "type": "SCALAR" + }, + { + "bufferView": 7, + "byteOffset": 3420, + "componentType": 5126, + "count": 33, + "max": [ + 1.399999976158142, + 1.3999998569488525, + 1.399999976158142 + ], + "min": [ + 1.0, + 0.4999999403953552, + 1.0 + ], + "type": "VEC3" + }, + { + "bufferView": 6, + "byteOffset": 1804, + "componentType": 5126, + "count": 45, + "max": [ + 1.875 + ], + "min": [ + 0.0416666679084301 + ], + "type": "SCALAR" + }, + { + "bufferView": 7, + "byteOffset": 3816, + "componentType": 5126, + "count": 45, + "max": [ + 9.012035841432335e-15, + 0.0474916435778141, + 0.06838671863079071 + ], + "min": [ + -4.691857796376708e-15, + -0.01939266547560692, + -0.12805521488189697 + ], + "type": "VEC3" + }, + { + "bufferView": 6, + "byteOffset": 1984, + "componentType": 5126, + "count": 45, + "max": [ + 1.875 + ], + "min": [ + 0.0416666679084301 + ], + "type": "SCALAR" + }, + { + "bufferView": 8, + "byteOffset": 2128, + "componentType": 5126, + "count": 45, + "max": [ + 0.08845420926809311, + -2.842170943040401e-14, + 2.777188947019303e-15, + 1.0 + ], + "min": [ + -0.17122069001197815, + -3.5501810502882106e-14, + -5.575671878816106e-15, + 0.9852327108383179 + ], + "type": "VEC4" + }, + { + "bufferView": 6, + "byteOffset": 2164, + "componentType": 5126, + "count": 45, + "max": [ + 1.875 + ], + "min": [ + 0.0416666679084301 + ], + "type": "SCALAR" + }, + { + "bufferView": 7, + "byteOffset": 4356, + "componentType": 5126, + "count": 45, + "max": [ + 1.1714836359024048, + 0.9999999403953552, + 1.1752097606658936 + ], + "min": [ + 1.0, + 0.6982886791229248, + 1.0 + ], + "type": "VEC3" + } + ], + "animations": [ + { + "channels": [ + { + "sampler": 0, + "target": { + "node": 11, + "path": "translation" + } + }, + { + "sampler": 1, + "target": { + "node": 11, + "path": "rotation" + } + }, + { + "sampler": 2, + "target": { + "node": 11, + "path": "scale" + } + } + ], + "name": "Inkchar1_Idle", + "samplers": [ + { + "input": 19, + "interpolation": "LINEAR", + "output": 20 + }, + { + "input": 21, + "interpolation": "LINEAR", + "output": 22 + }, + { + "input": 23, + "interpolation": "LINEAR", + "output": 24 + } + ] + }, + { + "channels": [ + { + "sampler": 0, + "target": { + "node": 10, + "path": "translation" + } + }, + { + "sampler": 1, + "target": { + "node": 10, + "path": "scale" + } + }, + { + "sampler": 2, + "target": { + "node": 11, + "path": "translation" + } + }, + { + "sampler": 3, + "target": { + "node": 11, + "path": "rotation" + } + }, + { + "sampler": 4, + "target": { + "node": 11, + "path": "scale" + } + } + ], + "name": "Inkchar1_Jump", + "samplers": [ + { + "input": 25, + "interpolation": "LINEAR", + "output": 26 + }, + { + "input": 27, + "interpolation": "LINEAR", + "output": 28 + }, + { + "input": 29, + "interpolation": "LINEAR", + "output": 30 + }, + { + "input": 31, + "interpolation": "LINEAR", + "output": 32 + }, + { + "input": 33, + "interpolation": "LINEAR", + "output": 34 + } + ] + }, + { + "channels": [ + { + "sampler": 0, + "target": { + "node": 11, + "path": "translation" + } + }, + { + "sampler": 1, + "target": { + "node": 11, + "path": "rotation" + } + }, + { + "sampler": 2, + "target": { + "node": 11, + "path": "scale" + } + } + ], + "name": "Inkchar1_Run", + "samplers": [ + { + "input": 35, + "interpolation": "LINEAR", + "output": 36 + }, + { + "input": 37, + "interpolation": "LINEAR", + "output": 38 + }, + { + "input": 39, + "interpolation": "LINEAR", + "output": 40 + } + ] + } + ], + "asset": { + "extras": { + "author": "hugannh3 (https://sketchfab.com/hugannh3)", + "license": "CC-BY-4.0 (http://creativecommons.org/licenses/by/4.0/)", + "source": "https://sketchfab.com/3d-models/cute-slime-cf04b5e4d11f4fcba581dbda21134896", + "title": "Cute Slime" + }, + "generator": "Sketchfab-17.8.0", + "version": "2.0" + }, + "bufferViews": [ + { + "buffer": 0, + "byteLength": 32824, + "byteStride": 8, + "name": "shortBufferViews", + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 91392, + "byteOffset": 32824, + "name": "floatBufferViews", + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 32824, + "byteOffset": 124216, + "byteStride": 8, + "name": "floatBufferViews", + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 98472, + "byteOffset": 157040, + "byteStride": 12, + "name": "floatBufferViews", + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 65648, + "byteOffset": 255512, + "byteStride": 16, + "name": "floatBufferViews", + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 192, + "byteOffset": 321160, + "name": "floatBufferViews" + }, + { + "buffer": 0, + "byteLength": 2344, + "byteOffset": 321352, + "name": "floatBufferViews" + }, + { + "buffer": 0, + "byteLength": 4896, + "byteOffset": 323696, + "byteStride": 12, + "name": "floatBufferViews" + }, + { + "buffer": 0, + "byteLength": 2848, + "byteOffset": 328592, + "byteStride": 16, + "name": "floatBufferViews" + } + ], + "buffers": [ + { + "byteLength": 331440, + "uri": "scene.bin" + } + ], + "extensionsUsed": [ + "KHR_materials_unlit" + ], + "images": [ + { + "uri": "textures/Mat_InkChar1_Color_baseColor.png" + }, + { + "uri": "textures/Mat_InkChar1_Eyes_baseColor.png" + } + ], + "materials": [ + { + "doubleSided": true, + "extensions": { + "KHR_materials_unlit": {} + }, + "name": "Mat_InkChar1_Color", + "pbrMetallicRoughness": { + "baseColorTexture": { + "index": 0 + }, + "metallicFactor": 0.0 + } + }, + { + "doubleSided": true, + "extensions": { + "KHR_materials_unlit": {} + }, + "name": "Mat_InkChar1_Eyes", + "pbrMetallicRoughness": { + "baseColorTexture": { + "index": 1 + }, + "metallicFactor": 0.0 + } + }, + { + "extensions": { + "KHR_materials_unlit": {} + }, + "name": "OLLLL", + "pbrMetallicRoughness": { + "baseColorFactor": [ + 0.010984917049932128, + 0.0018502749620731523, + 0.018704943955272956, + 1.0 + ], + "metallicFactor": 0.0 + } + } + ], + "meshes": [ + { + "name": "Object_0", + "primitives": [ + { + "attributes": { + "JOINTS_0": 13, + "NORMAL": 1, + "POSITION": 0, + "TEXCOORD_0": 2, + "WEIGHTS_0": 14 + }, + "indices": 3, + "material": 0, + "mode": 4 + } + ] + }, + { + "name": "Object_1", + "primitives": [ + { + "attributes": { + "JOINTS_0": 15, + "NORMAL": 5, + "POSITION": 4, + "TEXCOORD_0": 6, + "WEIGHTS_0": 16 + }, + "indices": 7, + "material": 1, + "mode": 4 + } + ] + }, + { + "name": "Object_2", + "primitives": [ + { + "attributes": { + "JOINTS_0": 17, + "NORMAL": 9, + "POSITION": 8, + "TEXCOORD_0": 10, + "WEIGHTS_0": 18 + }, + "indices": 11, + "material": 2, + "mode": 4 + } + ] + } + ], + "nodes": [ + { + "children": [ + 1 + ], + "matrix": [ + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.220446049250313e-16, + -1.0, + 0.0, + 0.0, + 1.0, + 2.220446049250313e-16, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0 + ], + "name": "Sketchfab_model" + }, + { + "children": [ + 2 + ], + "name": "root" + }, + { + "children": [ + 3 + ], + "matrix": [ + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.220446049250313e-16, + 1.0, + 0.0, + 0.0, + -1.0, + 2.220446049250313e-16, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0 + ], + "name": "GLTF_SceneRootNode" + }, + { + "children": [ + 4 + ], + "matrix": [ + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.006390342488884926, + 0.0037760469131171703, + 0.00010652779747033492, + 1.0 + ], + "name": "Inkchar1_rig_3" + }, + { + "children": [ + 5, + 7, + 8, + 9, + 6 + ], + "name": "GLTF_created_0" + }, + { + "children": [ + 10 + ], + "name": "GLTF_created_0_rootJoint" + }, + { + "name": "InkChar_1_2" + }, + { + "mesh": 0, + "name": "Object_7", + "skin": 0 + }, + { + "mesh": 1, + "name": "Object_8", + "skin": 0 + }, + { + "mesh": 2, + "name": "Object_9", + "skin": 0 + }, + { + "children": [ + 11 + ], + "name": "spine_1_1", + "rotation": [ + 0.0, + 1.1920928955078125e-07, + 0.0, + 1.0 + ], + "translation": [ + 0.0, + 6.87402490484601e-09, + 0.0 + ] + }, + { + "name": "spine_2_0", + "rotation": [ + 0.0, + -2.842170943040401e-14, + 0.0, + 1.0 + ], + "scale": [ + 1.0, + 0.800000011920929, + 1.0 + ], + "translation": [ + -6.793745846410255e-22, + 0.015469737350940704, + 1.0968525643306748e-08 + ] + } + ], + "samplers": [ + { + "magFilter": 9729, + "minFilter": 9987, + "wrapS": 10497, + "wrapT": 10497 + } + ], + "scene": 0, + "scenes": [ + { + "name": "Sketchfab_Scene", + "nodes": [ + 0 + ] + } + ], + "skins": [ + { + "inverseBindMatrices": 12, + "joints": [ + 5, + 10, + 11 + ], + "skeleton": 5 + } + ], + "textures": [ + { + "sampler": 0, + "source": 0 + }, + { + "sampler": 0, + "source": 1 + } + ] +} diff --git a/media/gltf/cute_slime/textures/Mat_InkChar1_Color_baseColor.png b/media/gltf/cute_slime/textures/Mat_InkChar1_Color_baseColor.png new file mode 100644 index 0000000..377c7fa Binary files /dev/null and b/media/gltf/cute_slime/textures/Mat_InkChar1_Color_baseColor.png differ diff --git a/media/gltf/cute_slime/textures/Mat_InkChar1_Eyes_baseColor.png b/media/gltf/cute_slime/textures/Mat_InkChar1_Eyes_baseColor.png new file mode 100644 index 0000000..061c2ab Binary files /dev/null and b/media/gltf/cute_slime/textures/Mat_InkChar1_Eyes_baseColor.png differ diff --git a/media/models/Chandelier.glb b/media/models/Chandelier.glb new file mode 100644 index 0000000..940f7f6 Binary files /dev/null and b/media/models/Chandelier.glb differ diff --git a/media/models/alien_flora.glb b/media/models/alien_flora.glb new file mode 100644 index 0000000..73f7a28 Binary files /dev/null and b/media/models/alien_flora.glb differ diff --git a/media/models/cute_slime.glb b/media/models/cute_slime.glb new file mode 100644 index 0000000..fd1bac1 Binary files /dev/null and b/media/models/cute_slime.glb differ diff --git a/media/models/nature/Textures/colormap.png b/media/models/nature/Textures/colormap.png new file mode 100644 index 0000000..c149939 Binary files /dev/null and b/media/models/nature/Textures/colormap.png differ diff --git a/media/models/nature/arrow.glb b/media/models/nature/arrow.glb new file mode 100644 index 0000000..d340c47 Binary files /dev/null and b/media/models/nature/arrow.glb differ diff --git a/media/models/nature/arrows.glb b/media/models/nature/arrows.glb new file mode 100644 index 0000000..8d92891 Binary files /dev/null and b/media/models/nature/arrows.glb differ diff --git a/media/models/nature/barrel.glb b/media/models/nature/barrel.glb new file mode 100644 index 0000000..399307d Binary files /dev/null and b/media/models/nature/barrel.glb differ diff --git a/media/models/nature/block-grass-corner-low.glb b/media/models/nature/block-grass-corner-low.glb new file mode 100644 index 0000000..afcc399 Binary files /dev/null and b/media/models/nature/block-grass-corner-low.glb differ diff --git a/media/models/nature/block-grass-corner-overhang-low.glb b/media/models/nature/block-grass-corner-overhang-low.glb new file mode 100644 index 0000000..4c643cb Binary files /dev/null and b/media/models/nature/block-grass-corner-overhang-low.glb differ diff --git a/media/models/nature/block-grass-corner-overhang.glb b/media/models/nature/block-grass-corner-overhang.glb new file mode 100644 index 0000000..0677c4f Binary files /dev/null and b/media/models/nature/block-grass-corner-overhang.glb differ diff --git a/media/models/nature/block-grass-corner.glb b/media/models/nature/block-grass-corner.glb new file mode 100644 index 0000000..cd72b22 Binary files /dev/null and b/media/models/nature/block-grass-corner.glb differ diff --git a/media/models/nature/block-grass-curve-half.glb b/media/models/nature/block-grass-curve-half.glb new file mode 100644 index 0000000..6e5a570 Binary files /dev/null and b/media/models/nature/block-grass-curve-half.glb differ diff --git a/media/models/nature/block-grass-curve-low.glb b/media/models/nature/block-grass-curve-low.glb new file mode 100644 index 0000000..0fdacd0 Binary files /dev/null and b/media/models/nature/block-grass-curve-low.glb differ diff --git a/media/models/nature/block-grass-curve.glb b/media/models/nature/block-grass-curve.glb new file mode 100644 index 0000000..4e63caf Binary files /dev/null and b/media/models/nature/block-grass-curve.glb differ diff --git a/media/models/nature/block-grass-edge.glb b/media/models/nature/block-grass-edge.glb new file mode 100644 index 0000000..5ffd857 Binary files /dev/null and b/media/models/nature/block-grass-edge.glb differ diff --git a/media/models/nature/block-grass-hexagon.glb b/media/models/nature/block-grass-hexagon.glb new file mode 100644 index 0000000..d42dba0 Binary files /dev/null and b/media/models/nature/block-grass-hexagon.glb differ diff --git a/media/models/nature/block-grass-large-slope-narrow.glb b/media/models/nature/block-grass-large-slope-narrow.glb new file mode 100644 index 0000000..1cf774b Binary files /dev/null and b/media/models/nature/block-grass-large-slope-narrow.glb differ diff --git a/media/models/nature/block-grass-large-slope-steep-narrow.glb b/media/models/nature/block-grass-large-slope-steep-narrow.glb new file mode 100644 index 0000000..3b51a86 Binary files /dev/null and b/media/models/nature/block-grass-large-slope-steep-narrow.glb differ diff --git a/media/models/nature/block-grass-large-slope-steep.glb b/media/models/nature/block-grass-large-slope-steep.glb new file mode 100644 index 0000000..82d0d1f Binary files /dev/null and b/media/models/nature/block-grass-large-slope-steep.glb differ diff --git a/media/models/nature/block-grass-large-slope.glb b/media/models/nature/block-grass-large-slope.glb new file mode 100644 index 0000000..466ee9a Binary files /dev/null and b/media/models/nature/block-grass-large-slope.glb differ diff --git a/media/models/nature/block-grass-large-tall.glb b/media/models/nature/block-grass-large-tall.glb new file mode 100644 index 0000000..41f40e0 Binary files /dev/null and b/media/models/nature/block-grass-large-tall.glb differ diff --git a/media/models/nature/block-grass-large.glb b/media/models/nature/block-grass-large.glb new file mode 100644 index 0000000..ec65556 Binary files /dev/null and b/media/models/nature/block-grass-large.glb differ diff --git a/media/models/nature/block-grass-long.glb b/media/models/nature/block-grass-long.glb new file mode 100644 index 0000000..e94248c Binary files /dev/null and b/media/models/nature/block-grass-long.glb differ diff --git a/media/models/nature/block-grass-low-hexagon.glb b/media/models/nature/block-grass-low-hexagon.glb new file mode 100644 index 0000000..14f7d70 Binary files /dev/null and b/media/models/nature/block-grass-low-hexagon.glb differ diff --git a/media/models/nature/block-grass-low-large.glb b/media/models/nature/block-grass-low-large.glb new file mode 100644 index 0000000..82d3f37 Binary files /dev/null and b/media/models/nature/block-grass-low-large.glb differ diff --git a/media/models/nature/block-grass-low-long.glb b/media/models/nature/block-grass-low-long.glb new file mode 100644 index 0000000..ddd49b0 Binary files /dev/null and b/media/models/nature/block-grass-low-long.glb differ diff --git a/media/models/nature/block-grass-low-narrow.glb b/media/models/nature/block-grass-low-narrow.glb new file mode 100644 index 0000000..ad1318d Binary files /dev/null and b/media/models/nature/block-grass-low-narrow.glb differ diff --git a/media/models/nature/block-grass-low.glb b/media/models/nature/block-grass-low.glb new file mode 100644 index 0000000..83cca32 Binary files /dev/null and b/media/models/nature/block-grass-low.glb differ diff --git a/media/models/nature/block-grass-narrow.glb b/media/models/nature/block-grass-narrow.glb new file mode 100644 index 0000000..99958ed Binary files /dev/null and b/media/models/nature/block-grass-narrow.glb differ diff --git a/media/models/nature/block-grass-overhang-corner.glb b/media/models/nature/block-grass-overhang-corner.glb new file mode 100644 index 0000000..6d9cb3a Binary files /dev/null and b/media/models/nature/block-grass-overhang-corner.glb differ diff --git a/media/models/nature/block-grass-overhang-edge.glb b/media/models/nature/block-grass-overhang-edge.glb new file mode 100644 index 0000000..3f85e7e Binary files /dev/null and b/media/models/nature/block-grass-overhang-edge.glb differ diff --git a/media/models/nature/block-grass-overhang-hexagon.glb b/media/models/nature/block-grass-overhang-hexagon.glb new file mode 100644 index 0000000..bf306b4 Binary files /dev/null and b/media/models/nature/block-grass-overhang-hexagon.glb differ diff --git a/media/models/nature/block-grass-overhang-large-slope-narrow.glb b/media/models/nature/block-grass-overhang-large-slope-narrow.glb new file mode 100644 index 0000000..fe7a2ae Binary files /dev/null and b/media/models/nature/block-grass-overhang-large-slope-narrow.glb differ diff --git a/media/models/nature/block-grass-overhang-large-slope-steep-narrow.glb b/media/models/nature/block-grass-overhang-large-slope-steep-narrow.glb new file mode 100644 index 0000000..a8e8914 Binary files /dev/null and b/media/models/nature/block-grass-overhang-large-slope-steep-narrow.glb differ diff --git a/media/models/nature/block-grass-overhang-large-slope-steep.glb b/media/models/nature/block-grass-overhang-large-slope-steep.glb new file mode 100644 index 0000000..98160ca Binary files /dev/null and b/media/models/nature/block-grass-overhang-large-slope-steep.glb differ diff --git a/media/models/nature/block-grass-overhang-large-slope.glb b/media/models/nature/block-grass-overhang-large-slope.glb new file mode 100644 index 0000000..4233ad0 Binary files /dev/null and b/media/models/nature/block-grass-overhang-large-slope.glb differ diff --git a/media/models/nature/block-grass-overhang-large-tall.glb b/media/models/nature/block-grass-overhang-large-tall.glb new file mode 100644 index 0000000..e8547e3 Binary files /dev/null and b/media/models/nature/block-grass-overhang-large-tall.glb differ diff --git a/media/models/nature/block-grass-overhang-large.glb b/media/models/nature/block-grass-overhang-large.glb new file mode 100644 index 0000000..2a521a7 Binary files /dev/null and b/media/models/nature/block-grass-overhang-large.glb differ diff --git a/media/models/nature/block-grass-overhang-long.glb b/media/models/nature/block-grass-overhang-long.glb new file mode 100644 index 0000000..b01c939 Binary files /dev/null and b/media/models/nature/block-grass-overhang-long.glb differ diff --git a/media/models/nature/block-grass-overhang-low-hexagon.glb b/media/models/nature/block-grass-overhang-low-hexagon.glb new file mode 100644 index 0000000..47e966e Binary files /dev/null and b/media/models/nature/block-grass-overhang-low-hexagon.glb differ diff --git a/media/models/nature/block-grass-overhang-low-large.glb b/media/models/nature/block-grass-overhang-low-large.glb new file mode 100644 index 0000000..0ae6215 Binary files /dev/null and b/media/models/nature/block-grass-overhang-low-large.glb differ diff --git a/media/models/nature/block-grass-overhang-low-long.glb b/media/models/nature/block-grass-overhang-low-long.glb new file mode 100644 index 0000000..48145b1 Binary files /dev/null and b/media/models/nature/block-grass-overhang-low-long.glb differ diff --git a/media/models/nature/block-grass-overhang-low-narrow.glb b/media/models/nature/block-grass-overhang-low-narrow.glb new file mode 100644 index 0000000..12cd323 Binary files /dev/null and b/media/models/nature/block-grass-overhang-low-narrow.glb differ diff --git a/media/models/nature/block-grass-overhang-low.glb b/media/models/nature/block-grass-overhang-low.glb new file mode 100644 index 0000000..1aedf0b Binary files /dev/null and b/media/models/nature/block-grass-overhang-low.glb differ diff --git a/media/models/nature/block-grass-overhang-narrow.glb b/media/models/nature/block-grass-overhang-narrow.glb new file mode 100644 index 0000000..d7cf0ed Binary files /dev/null and b/media/models/nature/block-grass-overhang-narrow.glb differ diff --git a/media/models/nature/block-grass.glb b/media/models/nature/block-grass.glb new file mode 100644 index 0000000..dcab7db Binary files /dev/null and b/media/models/nature/block-grass.glb differ diff --git a/media/models/nature/block-moving-blue.glb b/media/models/nature/block-moving-blue.glb new file mode 100644 index 0000000..baa4ace Binary files /dev/null and b/media/models/nature/block-moving-blue.glb differ diff --git a/media/models/nature/block-moving-large.glb b/media/models/nature/block-moving-large.glb new file mode 100644 index 0000000..7de6b91 Binary files /dev/null and b/media/models/nature/block-moving-large.glb differ diff --git a/media/models/nature/block-moving.glb b/media/models/nature/block-moving.glb new file mode 100644 index 0000000..31dfe22 Binary files /dev/null and b/media/models/nature/block-moving.glb differ diff --git a/media/models/nature/block-snow-corner-low.glb b/media/models/nature/block-snow-corner-low.glb new file mode 100644 index 0000000..b03b38b Binary files /dev/null and b/media/models/nature/block-snow-corner-low.glb differ diff --git a/media/models/nature/block-snow-corner-overhang-low.glb b/media/models/nature/block-snow-corner-overhang-low.glb new file mode 100644 index 0000000..3f8cb17 Binary files /dev/null and b/media/models/nature/block-snow-corner-overhang-low.glb differ diff --git a/media/models/nature/block-snow-corner-overhang.glb b/media/models/nature/block-snow-corner-overhang.glb new file mode 100644 index 0000000..c942290 Binary files /dev/null and b/media/models/nature/block-snow-corner-overhang.glb differ diff --git a/media/models/nature/block-snow-corner.glb b/media/models/nature/block-snow-corner.glb new file mode 100644 index 0000000..4573fa0 Binary files /dev/null and b/media/models/nature/block-snow-corner.glb differ diff --git a/media/models/nature/block-snow-curve-half.glb b/media/models/nature/block-snow-curve-half.glb new file mode 100644 index 0000000..592895c Binary files /dev/null and b/media/models/nature/block-snow-curve-half.glb differ diff --git a/media/models/nature/block-snow-curve-low.glb b/media/models/nature/block-snow-curve-low.glb new file mode 100644 index 0000000..f2aabca Binary files /dev/null and b/media/models/nature/block-snow-curve-low.glb differ diff --git a/media/models/nature/block-snow-curve.glb b/media/models/nature/block-snow-curve.glb new file mode 100644 index 0000000..e807910 Binary files /dev/null and b/media/models/nature/block-snow-curve.glb differ diff --git a/media/models/nature/block-snow-edge.glb b/media/models/nature/block-snow-edge.glb new file mode 100644 index 0000000..ec7066c Binary files /dev/null and b/media/models/nature/block-snow-edge.glb differ diff --git a/media/models/nature/block-snow-hexagon.glb b/media/models/nature/block-snow-hexagon.glb new file mode 100644 index 0000000..5970e7d Binary files /dev/null and b/media/models/nature/block-snow-hexagon.glb differ diff --git a/media/models/nature/block-snow-large-slope-narrow.glb b/media/models/nature/block-snow-large-slope-narrow.glb new file mode 100644 index 0000000..9c7732b Binary files /dev/null and b/media/models/nature/block-snow-large-slope-narrow.glb differ diff --git a/media/models/nature/block-snow-large-slope-steep-narrow.glb b/media/models/nature/block-snow-large-slope-steep-narrow.glb new file mode 100644 index 0000000..e612b9e Binary files /dev/null and b/media/models/nature/block-snow-large-slope-steep-narrow.glb differ diff --git a/media/models/nature/block-snow-large-slope-steep.glb b/media/models/nature/block-snow-large-slope-steep.glb new file mode 100644 index 0000000..1d6e0c8 Binary files /dev/null and b/media/models/nature/block-snow-large-slope-steep.glb differ diff --git a/media/models/nature/block-snow-large-slope.glb b/media/models/nature/block-snow-large-slope.glb new file mode 100644 index 0000000..58c8ae3 Binary files /dev/null and b/media/models/nature/block-snow-large-slope.glb differ diff --git a/media/models/nature/block-snow-large-tall.glb b/media/models/nature/block-snow-large-tall.glb new file mode 100644 index 0000000..756f250 Binary files /dev/null and b/media/models/nature/block-snow-large-tall.glb differ diff --git a/media/models/nature/block-snow-large.glb b/media/models/nature/block-snow-large.glb new file mode 100644 index 0000000..aca84a5 Binary files /dev/null and b/media/models/nature/block-snow-large.glb differ diff --git a/media/models/nature/block-snow-long.glb b/media/models/nature/block-snow-long.glb new file mode 100644 index 0000000..d1251b7 Binary files /dev/null and b/media/models/nature/block-snow-long.glb differ diff --git a/media/models/nature/block-snow-low-hexagon.glb b/media/models/nature/block-snow-low-hexagon.glb new file mode 100644 index 0000000..b456a97 Binary files /dev/null and b/media/models/nature/block-snow-low-hexagon.glb differ diff --git a/media/models/nature/block-snow-low-large.glb b/media/models/nature/block-snow-low-large.glb new file mode 100644 index 0000000..e8ca566 Binary files /dev/null and b/media/models/nature/block-snow-low-large.glb differ diff --git a/media/models/nature/block-snow-low-long.glb b/media/models/nature/block-snow-low-long.glb new file mode 100644 index 0000000..6edbf23 Binary files /dev/null and b/media/models/nature/block-snow-low-long.glb differ diff --git a/media/models/nature/block-snow-low-narrow.glb b/media/models/nature/block-snow-low-narrow.glb new file mode 100644 index 0000000..b250556 Binary files /dev/null and b/media/models/nature/block-snow-low-narrow.glb differ diff --git a/media/models/nature/block-snow-low.glb b/media/models/nature/block-snow-low.glb new file mode 100644 index 0000000..b60efc9 Binary files /dev/null and b/media/models/nature/block-snow-low.glb differ diff --git a/media/models/nature/block-snow-narrow.glb b/media/models/nature/block-snow-narrow.glb new file mode 100644 index 0000000..32710e0 Binary files /dev/null and b/media/models/nature/block-snow-narrow.glb differ diff --git a/media/models/nature/block-snow-overhang-corner.glb b/media/models/nature/block-snow-overhang-corner.glb new file mode 100644 index 0000000..65d017c Binary files /dev/null and b/media/models/nature/block-snow-overhang-corner.glb differ diff --git a/media/models/nature/block-snow-overhang-edge.glb b/media/models/nature/block-snow-overhang-edge.glb new file mode 100644 index 0000000..a444495 Binary files /dev/null and b/media/models/nature/block-snow-overhang-edge.glb differ diff --git a/media/models/nature/block-snow-overhang-hexagon.glb b/media/models/nature/block-snow-overhang-hexagon.glb new file mode 100644 index 0000000..c47fcf4 Binary files /dev/null and b/media/models/nature/block-snow-overhang-hexagon.glb differ diff --git a/media/models/nature/block-snow-overhang-large-slope-narrow.glb b/media/models/nature/block-snow-overhang-large-slope-narrow.glb new file mode 100644 index 0000000..a93f7fd Binary files /dev/null and b/media/models/nature/block-snow-overhang-large-slope-narrow.glb differ diff --git a/media/models/nature/block-snow-overhang-large-slope-steep-narrow.glb b/media/models/nature/block-snow-overhang-large-slope-steep-narrow.glb new file mode 100644 index 0000000..73a68bb Binary files /dev/null and b/media/models/nature/block-snow-overhang-large-slope-steep-narrow.glb differ diff --git a/media/models/nature/block-snow-overhang-large-slope-steep.glb b/media/models/nature/block-snow-overhang-large-slope-steep.glb new file mode 100644 index 0000000..170d1a2 Binary files /dev/null and b/media/models/nature/block-snow-overhang-large-slope-steep.glb differ diff --git a/media/models/nature/block-snow-overhang-large-slope.glb b/media/models/nature/block-snow-overhang-large-slope.glb new file mode 100644 index 0000000..22e94ac Binary files /dev/null and b/media/models/nature/block-snow-overhang-large-slope.glb differ diff --git a/media/models/nature/block-snow-overhang-large-tall.glb b/media/models/nature/block-snow-overhang-large-tall.glb new file mode 100644 index 0000000..7bfc214 Binary files /dev/null and b/media/models/nature/block-snow-overhang-large-tall.glb differ diff --git a/media/models/nature/block-snow-overhang-large.glb b/media/models/nature/block-snow-overhang-large.glb new file mode 100644 index 0000000..9d2002f Binary files /dev/null and b/media/models/nature/block-snow-overhang-large.glb differ diff --git a/media/models/nature/block-snow-overhang-long.glb b/media/models/nature/block-snow-overhang-long.glb new file mode 100644 index 0000000..014d3a6 Binary files /dev/null and b/media/models/nature/block-snow-overhang-long.glb differ diff --git a/media/models/nature/block-snow-overhang-low-hexagon.glb b/media/models/nature/block-snow-overhang-low-hexagon.glb new file mode 100644 index 0000000..48dae42 Binary files /dev/null and b/media/models/nature/block-snow-overhang-low-hexagon.glb differ diff --git a/media/models/nature/block-snow-overhang-low-large.glb b/media/models/nature/block-snow-overhang-low-large.glb new file mode 100644 index 0000000..79504cf Binary files /dev/null and b/media/models/nature/block-snow-overhang-low-large.glb differ diff --git a/media/models/nature/block-snow-overhang-low-long.glb b/media/models/nature/block-snow-overhang-low-long.glb new file mode 100644 index 0000000..d9c661c Binary files /dev/null and b/media/models/nature/block-snow-overhang-low-long.glb differ diff --git a/media/models/nature/block-snow-overhang-low-narrow.glb b/media/models/nature/block-snow-overhang-low-narrow.glb new file mode 100644 index 0000000..ed217cc Binary files /dev/null and b/media/models/nature/block-snow-overhang-low-narrow.glb differ diff --git a/media/models/nature/block-snow-overhang-low.glb b/media/models/nature/block-snow-overhang-low.glb new file mode 100644 index 0000000..0466cf0 Binary files /dev/null and b/media/models/nature/block-snow-overhang-low.glb differ diff --git a/media/models/nature/block-snow-overhang-narrow.glb b/media/models/nature/block-snow-overhang-narrow.glb new file mode 100644 index 0000000..bbb55d0 Binary files /dev/null and b/media/models/nature/block-snow-overhang-narrow.glb differ diff --git a/media/models/nature/block-snow.glb b/media/models/nature/block-snow.glb new file mode 100644 index 0000000..c908420 Binary files /dev/null and b/media/models/nature/block-snow.glb differ diff --git a/media/models/nature/bomb.glb b/media/models/nature/bomb.glb new file mode 100644 index 0000000..1885020 Binary files /dev/null and b/media/models/nature/bomb.glb differ diff --git a/media/models/nature/brick.glb b/media/models/nature/brick.glb new file mode 100644 index 0000000..3893776 Binary files /dev/null and b/media/models/nature/brick.glb differ diff --git a/media/models/nature/button-round.glb b/media/models/nature/button-round.glb new file mode 100644 index 0000000..67fd425 Binary files /dev/null and b/media/models/nature/button-round.glb differ diff --git a/media/models/nature/button-square.glb b/media/models/nature/button-square.glb new file mode 100644 index 0000000..21ee77f Binary files /dev/null and b/media/models/nature/button-square.glb differ diff --git a/media/models/nature/character-oobi.glb b/media/models/nature/character-oobi.glb new file mode 100644 index 0000000..9b64352 Binary files /dev/null and b/media/models/nature/character-oobi.glb differ diff --git a/media/models/nature/character-oodi.glb b/media/models/nature/character-oodi.glb new file mode 100644 index 0000000..ca988b7 Binary files /dev/null and b/media/models/nature/character-oodi.glb differ diff --git a/media/models/nature/character-ooli.glb b/media/models/nature/character-ooli.glb new file mode 100644 index 0000000..15648f3 Binary files /dev/null and b/media/models/nature/character-ooli.glb differ diff --git a/media/models/nature/character-oopi.glb b/media/models/nature/character-oopi.glb new file mode 100644 index 0000000..3b7a797 Binary files /dev/null and b/media/models/nature/character-oopi.glb differ diff --git a/media/models/nature/character-oozi.glb b/media/models/nature/character-oozi.glb new file mode 100644 index 0000000..9846808 Binary files /dev/null and b/media/models/nature/character-oozi.glb differ diff --git a/media/models/nature/chest.glb b/media/models/nature/chest.glb new file mode 100644 index 0000000..e9bff3c Binary files /dev/null and b/media/models/nature/chest.glb differ diff --git a/media/models/nature/coin-bronze.glb b/media/models/nature/coin-bronze.glb new file mode 100644 index 0000000..79f5dfc Binary files /dev/null and b/media/models/nature/coin-bronze.glb differ diff --git a/media/models/nature/coin-gold.glb b/media/models/nature/coin-gold.glb new file mode 100644 index 0000000..d84e0e9 Binary files /dev/null and b/media/models/nature/coin-gold.glb differ diff --git a/media/models/nature/coin-silver.glb b/media/models/nature/coin-silver.glb new file mode 100644 index 0000000..5301e1d Binary files /dev/null and b/media/models/nature/coin-silver.glb differ diff --git a/media/models/nature/conveyor-belt.glb b/media/models/nature/conveyor-belt.glb new file mode 100644 index 0000000..ac2842c Binary files /dev/null and b/media/models/nature/conveyor-belt.glb differ diff --git a/media/models/nature/crate-item-strong.glb b/media/models/nature/crate-item-strong.glb new file mode 100644 index 0000000..784a9bf Binary files /dev/null and b/media/models/nature/crate-item-strong.glb differ diff --git a/media/models/nature/crate-item.glb b/media/models/nature/crate-item.glb new file mode 100644 index 0000000..da49102 Binary files /dev/null and b/media/models/nature/crate-item.glb differ diff --git a/media/models/nature/crate-strong.glb b/media/models/nature/crate-strong.glb new file mode 100644 index 0000000..23b137f Binary files /dev/null and b/media/models/nature/crate-strong.glb differ diff --git a/media/models/nature/crate.glb b/media/models/nature/crate.glb new file mode 100644 index 0000000..534ee89 Binary files /dev/null and b/media/models/nature/crate.glb differ diff --git a/media/models/nature/door-large-open.glb b/media/models/nature/door-large-open.glb new file mode 100644 index 0000000..88b3f1b Binary files /dev/null and b/media/models/nature/door-large-open.glb differ diff --git a/media/models/nature/door-open.glb b/media/models/nature/door-open.glb new file mode 100644 index 0000000..378ad57 Binary files /dev/null and b/media/models/nature/door-open.glb differ diff --git a/media/models/nature/door-rotate-large.glb b/media/models/nature/door-rotate-large.glb new file mode 100644 index 0000000..260f730 Binary files /dev/null and b/media/models/nature/door-rotate-large.glb differ diff --git a/media/models/nature/door-rotate.glb b/media/models/nature/door-rotate.glb new file mode 100644 index 0000000..0fa511d Binary files /dev/null and b/media/models/nature/door-rotate.glb differ diff --git a/media/models/nature/fence-broken.glb b/media/models/nature/fence-broken.glb new file mode 100644 index 0000000..f191d01 Binary files /dev/null and b/media/models/nature/fence-broken.glb differ diff --git a/media/models/nature/fence-corner-curved.glb b/media/models/nature/fence-corner-curved.glb new file mode 100644 index 0000000..5ab941b Binary files /dev/null and b/media/models/nature/fence-corner-curved.glb differ diff --git a/media/models/nature/fence-corner.glb b/media/models/nature/fence-corner.glb new file mode 100644 index 0000000..23d5939 Binary files /dev/null and b/media/models/nature/fence-corner.glb differ diff --git a/media/models/nature/fence-low-broken.glb b/media/models/nature/fence-low-broken.glb new file mode 100644 index 0000000..aae86b2 Binary files /dev/null and b/media/models/nature/fence-low-broken.glb differ diff --git a/media/models/nature/fence-low-corner-curved.glb b/media/models/nature/fence-low-corner-curved.glb new file mode 100644 index 0000000..1ae0bd2 Binary files /dev/null and b/media/models/nature/fence-low-corner-curved.glb differ diff --git a/media/models/nature/fence-low-corner.glb b/media/models/nature/fence-low-corner.glb new file mode 100644 index 0000000..587811d Binary files /dev/null and b/media/models/nature/fence-low-corner.glb differ diff --git a/media/models/nature/fence-low-straight.glb b/media/models/nature/fence-low-straight.glb new file mode 100644 index 0000000..f3e1de9 Binary files /dev/null and b/media/models/nature/fence-low-straight.glb differ diff --git a/media/models/nature/fence-rope.glb b/media/models/nature/fence-rope.glb new file mode 100644 index 0000000..0d3e72d Binary files /dev/null and b/media/models/nature/fence-rope.glb differ diff --git a/media/models/nature/fence-straight.glb b/media/models/nature/fence-straight.glb new file mode 100644 index 0000000..75a8463 Binary files /dev/null and b/media/models/nature/fence-straight.glb differ diff --git a/media/models/nature/flag.glb b/media/models/nature/flag.glb new file mode 100644 index 0000000..a9d8e98 Binary files /dev/null and b/media/models/nature/flag.glb differ diff --git a/media/models/nature/flowers-tall.glb b/media/models/nature/flowers-tall.glb new file mode 100644 index 0000000..d8f7675 Binary files /dev/null and b/media/models/nature/flowers-tall.glb differ diff --git a/media/models/nature/flowers.glb b/media/models/nature/flowers.glb new file mode 100644 index 0000000..60da3e9 Binary files /dev/null and b/media/models/nature/flowers.glb differ diff --git a/media/models/nature/grass.glb b/media/models/nature/grass.glb new file mode 100644 index 0000000..ce7a8b8 Binary files /dev/null and b/media/models/nature/grass.glb differ diff --git a/media/models/nature/heart.glb b/media/models/nature/heart.glb new file mode 100644 index 0000000..4a7a9ea Binary files /dev/null and b/media/models/nature/heart.glb differ diff --git a/media/models/nature/hedge-corner.glb b/media/models/nature/hedge-corner.glb new file mode 100644 index 0000000..8a1793e Binary files /dev/null and b/media/models/nature/hedge-corner.glb differ diff --git a/media/models/nature/hedge.glb b/media/models/nature/hedge.glb new file mode 100644 index 0000000..041fb4d Binary files /dev/null and b/media/models/nature/hedge.glb differ diff --git a/media/models/nature/jewel.glb b/media/models/nature/jewel.glb new file mode 100644 index 0000000..64e313e Binary files /dev/null and b/media/models/nature/jewel.glb differ diff --git a/media/models/nature/key.glb b/media/models/nature/key.glb new file mode 100644 index 0000000..774b3ba Binary files /dev/null and b/media/models/nature/key.glb differ diff --git a/media/models/nature/ladder-broken.glb b/media/models/nature/ladder-broken.glb new file mode 100644 index 0000000..3e93fff Binary files /dev/null and b/media/models/nature/ladder-broken.glb differ diff --git a/media/models/nature/ladder-long.glb b/media/models/nature/ladder-long.glb new file mode 100644 index 0000000..670f13f Binary files /dev/null and b/media/models/nature/ladder-long.glb differ diff --git a/media/models/nature/ladder.glb b/media/models/nature/ladder.glb new file mode 100644 index 0000000..0d64d74 Binary files /dev/null and b/media/models/nature/ladder.glb differ diff --git a/media/models/nature/lever.glb b/media/models/nature/lever.glb new file mode 100644 index 0000000..cdce482 Binary files /dev/null and b/media/models/nature/lever.glb differ diff --git a/media/models/nature/lock.glb b/media/models/nature/lock.glb new file mode 100644 index 0000000..24d11fb Binary files /dev/null and b/media/models/nature/lock.glb differ diff --git a/media/models/nature/mushrooms.glb b/media/models/nature/mushrooms.glb new file mode 100644 index 0000000..da00cb7 Binary files /dev/null and b/media/models/nature/mushrooms.glb differ diff --git a/media/models/nature/pipe.glb b/media/models/nature/pipe.glb new file mode 100644 index 0000000..eef6aa6 Binary files /dev/null and b/media/models/nature/pipe.glb differ diff --git a/media/models/nature/plant.glb b/media/models/nature/plant.glb new file mode 100644 index 0000000..8bc6b14 Binary files /dev/null and b/media/models/nature/plant.glb differ diff --git a/media/models/nature/platform-fortified.glb b/media/models/nature/platform-fortified.glb new file mode 100644 index 0000000..69de9f7 Binary files /dev/null and b/media/models/nature/platform-fortified.glb differ diff --git a/media/models/nature/platform-overhang.glb b/media/models/nature/platform-overhang.glb new file mode 100644 index 0000000..87a5fb2 Binary files /dev/null and b/media/models/nature/platform-overhang.glb differ diff --git a/media/models/nature/platform-ramp.glb b/media/models/nature/platform-ramp.glb new file mode 100644 index 0000000..4b42295 Binary files /dev/null and b/media/models/nature/platform-ramp.glb differ diff --git a/media/models/nature/platform.glb b/media/models/nature/platform.glb new file mode 100644 index 0000000..9ceefbb Binary files /dev/null and b/media/models/nature/platform.glb differ diff --git a/media/models/nature/poles.glb b/media/models/nature/poles.glb new file mode 100644 index 0000000..c97231a Binary files /dev/null and b/media/models/nature/poles.glb differ diff --git a/media/models/nature/rocks.glb b/media/models/nature/rocks.glb new file mode 100644 index 0000000..a431b50 Binary files /dev/null and b/media/models/nature/rocks.glb differ diff --git a/media/models/nature/saw.glb b/media/models/nature/saw.glb new file mode 100644 index 0000000..8bcbd7e Binary files /dev/null and b/media/models/nature/saw.glb differ diff --git a/media/models/nature/sign.glb b/media/models/nature/sign.glb new file mode 100644 index 0000000..55552cb Binary files /dev/null and b/media/models/nature/sign.glb differ diff --git a/media/models/nature/spike-block-wide.glb b/media/models/nature/spike-block-wide.glb new file mode 100644 index 0000000..b3b57ee Binary files /dev/null and b/media/models/nature/spike-block-wide.glb differ diff --git a/media/models/nature/spike-block.glb b/media/models/nature/spike-block.glb new file mode 100644 index 0000000..a3d2ebe Binary files /dev/null and b/media/models/nature/spike-block.glb differ diff --git a/media/models/nature/spring.glb b/media/models/nature/spring.glb new file mode 100644 index 0000000..7b4a9e1 Binary files /dev/null and b/media/models/nature/spring.glb differ diff --git a/media/models/nature/star.glb b/media/models/nature/star.glb new file mode 100644 index 0000000..a2a3318 Binary files /dev/null and b/media/models/nature/star.glb differ diff --git a/media/models/nature/stones.glb b/media/models/nature/stones.glb new file mode 100644 index 0000000..cca664d Binary files /dev/null and b/media/models/nature/stones.glb differ diff --git a/media/models/nature/trap-spikes-large.glb b/media/models/nature/trap-spikes-large.glb new file mode 100644 index 0000000..cb23ae1 Binary files /dev/null and b/media/models/nature/trap-spikes-large.glb differ diff --git a/media/models/nature/trap-spikes.glb b/media/models/nature/trap-spikes.glb new file mode 100644 index 0000000..40d5b06 Binary files /dev/null and b/media/models/nature/trap-spikes.glb differ diff --git a/media/models/nature/tree-pine-small.glb b/media/models/nature/tree-pine-small.glb new file mode 100644 index 0000000..4262827 Binary files /dev/null and b/media/models/nature/tree-pine-small.glb differ diff --git a/media/models/nature/tree-pine-snow-small.glb b/media/models/nature/tree-pine-snow-small.glb new file mode 100644 index 0000000..518e7b1 Binary files /dev/null and b/media/models/nature/tree-pine-snow-small.glb differ diff --git a/media/models/nature/tree-pine-snow.glb b/media/models/nature/tree-pine-snow.glb new file mode 100644 index 0000000..97a33a4 Binary files /dev/null and b/media/models/nature/tree-pine-snow.glb differ diff --git a/media/models/nature/tree-pine.glb b/media/models/nature/tree-pine.glb new file mode 100644 index 0000000..8dafc43 Binary files /dev/null and b/media/models/nature/tree-pine.glb differ diff --git a/media/models/nature/tree-snow.glb b/media/models/nature/tree-snow.glb new file mode 100644 index 0000000..88a5c2b Binary files /dev/null and b/media/models/nature/tree-snow.glb differ diff --git a/media/models/nature/tree.glb b/media/models/nature/tree.glb new file mode 100644 index 0000000..7cfa14e Binary files /dev/null and b/media/models/nature/tree.glb differ diff --git a/media/sound/bgm01.mp3 b/media/sound/bgm01.mp3 new file mode 100644 index 0000000..f67696c Binary files /dev/null and b/media/sound/bgm01.mp3 differ diff --git a/media/sound/cabin_bgm.mp3 b/media/sound/cabin_bgm.mp3 new file mode 100644 index 0000000..cae7dc0 Binary files /dev/null and b/media/sound/cabin_bgm.mp3 differ diff --git a/media/sound/ignite.mp3 b/media/sound/ignite.mp3 new file mode 100644 index 0000000..b67d88f Binary files /dev/null and b/media/sound/ignite.mp3 differ diff --git a/media/sound/line4.mp3 b/media/sound/line4.mp3 new file mode 100644 index 0000000..831dd58 Binary files /dev/null and b/media/sound/line4.mp3 differ diff --git a/media/sound/line5.mp3 b/media/sound/line5.mp3 new file mode 100644 index 0000000..d870c91 Binary files /dev/null and b/media/sound/line5.mp3 differ diff --git a/media/sound/line6.mp3 b/media/sound/line6.mp3 new file mode 100644 index 0000000..a14d457 Binary files /dev/null and b/media/sound/line6.mp3 differ diff --git a/media/sound/line7.mp3 b/media/sound/line7.mp3 new file mode 100644 index 0000000..0887a4c Binary files /dev/null and b/media/sound/line7.mp3 differ diff --git a/media/sound/monster_line1.mp3 b/media/sound/monster_line1.mp3 new file mode 100644 index 0000000..96611a4 Binary files /dev/null and b/media/sound/monster_line1.mp3 differ diff --git a/media/sound/monster_line2.mp3 b/media/sound/monster_line2.mp3 new file mode 100644 index 0000000..252b176 Binary files /dev/null and b/media/sound/monster_line2.mp3 differ diff --git a/media/sound/monster_line3.mp3 b/media/sound/monster_line3.mp3 new file mode 100644 index 0000000..e01b74f Binary files /dev/null and b/media/sound/monster_line3.mp3 differ diff --git a/media/sound/scratch.mp3 b/media/sound/scratch.mp3 new file mode 100644 index 0000000..9864b5e Binary files /dev/null and b/media/sound/scratch.mp3 differ diff --git a/media/textures/log.png b/media/textures/log.png new file mode 100644 index 0000000..1e520d8 Binary files /dev/null and b/media/textures/log.png differ diff --git a/media/textures/log1.png b/media/textures/log1.png new file mode 100644 index 0000000..538097d Binary files /dev/null and b/media/textures/log1.png differ diff --git a/media/textures/tire.png b/media/textures/tire.png new file mode 100644 index 0000000..ed9d69b Binary files /dev/null and b/media/textures/tire.png differ diff --git a/media/video/ATTRIBUTION.md b/media/videos/ATTRIBUTION.md similarity index 100% rename from media/video/ATTRIBUTION.md rename to media/videos/ATTRIBUTION.md diff --git a/media/video/bbb-sunflower-540p2-1min.webm b/media/videos/bbb-sunflower-540p2-1min.webm similarity index 100% rename from media/video/bbb-sunflower-540p2-1min.webm rename to media/videos/bbb-sunflower-540p2-1min.webm diff --git a/package.json b/package.json index 3809a5f..2e69cb4 100644 --- a/package.json +++ b/package.json @@ -39,5 +39,8 @@ "devDependencies": { "gltf-import-export": "^1.0.16", "prettier": "2.2.1" + }, + "volta": { + "node": "18.20.8" } } diff --git a/server/main.js b/server/main.js index 0ab6736..e84e5b3 100644 --- a/server/main.js +++ b/server/main.js @@ -1,3 +1,8 @@ +const os = require('os'); +if (!os.tmpDir) { + os.tmpDir = os.tmpdir; +} + var bodyParser = require("body-parser"); var express = require("express"); var formidable = require("formidable");