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..fb45e76 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,40 @@ Software for CSCI-GA.3033-​097 Virtual Reality 2026 Spring. +--- + +## Assignments Demo Videos + +* **Assignment 1: Car** + * **Video Demo:** [Assignment 1 Demo](./media/videos/hw1_car.mp4) + * **Code:** `js/scenes/car.js` + +* **Assignment 2: Car Drive** + * **Video Demo:** [Assignment 2 Demo](./media/videos/hw2_cardrive.mp4) + * **Code:** `js/scenes/carDrive.js` + + +* **Assignment 3: Campfire** + * **Video Demo:** [Assignment 3 Demo](./media/videos/hw3_campfire.mp4) + * **Code:** `js/scenes/campFire.js` + +* **Assignment 4: Text Party** + * **Video Demo:** [Assignment 4 Demo](./media/videos/hw4_textparty.mp4) + * **Code:** `js/scenes/textHW.js` + +* **Assignment 5: Spirit Exercise** + * **Video Demo:** [Assignment 5 Demo](./media/videos/hw5_spirit.mp4) + * **Code:** `js/scenes/spirit_exercise.js` + +* **Assignment 6: Headgaze Exercise** + * **Video Demo:** [Assignment 6 Demo](./media/videos/hw6_headgaze.mp4) + * **Code:** `js/scenes/headGazeExercise.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..867824c --- /dev/null +++ b/js/scenes/campFire.js @@ -0,0 +1,48 @@ +/* + 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"; + +export const init = async model => { + // Define a tall, thin grid for the flame + clay.defineMesh('flame', clay.createGrid(20, 30)); + + // fire layer holder + let fireLayers = []; + for (let i = 0; i < 6; i++){ + let fire = model.add('flame').color(1, .4, 0); // Orange color + fire.angle = i * 30; // angle = 0, 60, 120. + fireLayers.push(fire); + } + + // logs + model.txtrSrc(1, '../media/textures/log1.png'); + let logs =[] + for (let i = 0; i < 6; i++){ + let log =model.add('tubeX').color(0.4,0.3,0.3).txtr(1).scale(1.3,0.07,0.07).move(0,0,0); + log.angle = i * 30 + //log.turnY(log.angle); + logs.push(log); + } + + model.scale(0.3).move(0,4,0).animate(() => { + // Animate the flame by modifying its vertices + fireLayers.forEach(fire => { + fire.identity().turnY(fire.angle); + fire.setVertices((u,v) => { + return [0.8*(u-0.5)*(1-v), + 2*v, + .3 * v * cg.noise(5*u,5*v-model.time*3,model.time) + ]; + }); + }); + + // logs.forEach(log => { + // log.identity().turnY(log.angle); + // }); + }); +} 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_project.js b/js/scenes/final_project.js new file mode 100644 index 0000000..a602aff --- /dev/null +++ b/js/scenes/final_project.js @@ -0,0 +1,891 @@ +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"; + +// ─── 游戏阶段 ─────────────────────────────────────────── +// "DARK" → 全黑,等待玩家找到火柴 +// "MATCH_HELD" → 火柴在手,等待划火柴手势 +// "LIGHTING" → 点火成功,灯慢慢变亮 +// "LIT" → 第一幕完成,准备进入第二幕 +let gamePhase = "DARK"; + +// 玩家的选择和倒计时 +let choiceMade = null; // "FOLLOW" 或 "STAY" +let stayTimer = 10; // 如果不跟,10秒后灯灭 +let portalChoicePos = [0, 1.5, -0.8]; // 选项弹出的位置(玩家正前方) + +export const init = async model => { + + // ─── 场景节点 ──────────────────────────────────────── + 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 lampNode = model.add("sphere"); // 天花板灯泡 + const floor = model.add("square"); + const wallBack = model.add("square"); + const table = model.add("cube"); + const noteNode = model.add("square"); // 纸条 + const monsterNode = model.add("sphere"); // 小怪物的身体 + const maskNode = model.add("square"); // 滑稽的面具 + const holeNode = model.add("square"); // 墙上的老鼠洞 + const BASE_NODES = 13; + + // ─── 状态变量 ──────────────────────────────────────── + 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]; // float it up high //0.86会穿模 + let matchPos = [...matchBoxPos]; // 火柴当前位置 + let matchHeld = false; + let matchHeldBy = null; + let matchBoxHeldBy = null; + let matchDir = [1, 0, 0]; // 火柴朝向(世界坐标) + let flameLife = 0.0; // 火苗强度,点燃后从0涨到1 + 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"; // 状态:HIDDEN -> REVEALED -> SNATCHING -> ESCAPING -> IDLE_NPC + let monsterSnatchTimer = 0.0; + + // 划火柴手势检测 + let prevHandX = null; // 上一帧手的X坐标 + let swipeSpeed = 0.0; // 当前帧手的X方向速度 + 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_HALF = MATCH_LENGTH / 2; + 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), + ]); + + model.animate(() => { + const t = model.time; + const dt = model.deltaTime; + + 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 }, + ]; + + // 让火柴盒可抓取:若手上有盒子则跟随,否则保持原位 + 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]; + gamePhase = "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; + gamePhase = "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; + } + + // 计算X方向速度(划火柴就是横向快速移动) + 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) { + // 划火柴成功! + gamePhase = "LIGHTING"; + flameLife = 1.0; + logEvent("match_struck", matchHeldBy, matchPos); + if (igniteBuffer) { + playSoundAtPosition(igniteBuffer, matchPos,3.0); + // Slightly boost perceived loudness by layering a second hit. + 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; + gamePhase = "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); + } + // 手里的火柴 + if (hMat) + matchStick.identity().setMatrix(hMat).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); + // 火柴头(红色球形) + { + if (hMat) + matchTip.identity().setMatrix(hMat).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); + + // 新增低头 + // 假设你的框架能获取头部的坐标(或者我们可以用手高度来代替) + // 如果没有 clientState.head,你可以判断 leftHand[1] < 1.0 (手伸向了桌底) + const headHeight = clientState.head ? clientState.head(clientID)[1] : + (Array.isArray(leftHand) ? leftHand[1] : 1.5); + + // 在 LIT 阶段,如果玩家读了纸条并且弯下了腰(高度变低) + if (gamePhase === "LIT" && noteRead && headHeight < 0.0 + ROOM_Y_OFFSET) { + monsterState = "REVEALED"; + gamePhase = "MONSTER_EVENT"; + logEvent("monster_revealed", null, monsterPos); + } + } + + // ══════════════════════════════════════════════════ + // 阶段五:MONSTER_EVENT — 现身、对视与抢夺 + // ══════════════════════════════════════════════════ + if (gamePhase === "MONSTER_EVENT") { + + // 1. 现身:在桌底待1秒钟,让玩家低头时能看清它 + if (monsterState === "REVEALED") { + monsterSnatchTimer += dt; + if (monsterSnatchTimer > 1.0) { + monsterState = "JUMP_TO_TABLE"; + monsterSnatchTimer = 0.0; // 重置计时器给后面用 + // TODO: 未来可以在这里播放一声“嘻嘻嘻”的笑声 + } + } + // 2. 跳上桌子:迅速移动到火柴盒旁边 15 厘米处 + 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"; // 到达桌边,进入挑衅状态 + } + } + // 3. 挑衅对视:在桌面上停顿 1.5 秒,让玩家明白它的意图 + else if (monsterState === "TAUNTING") { + monsterSnatchTimer += dt; + // 此时怪物会在火柴盒旁边伴随一点弹跳动画(在下方的渲染逻辑里自带了) + if (monsterSnatchTimer > 1.5) { + monsterState = "SNATCHING"; + } + } + // 4. 抢夺:伸手猛扑向火柴盒 + 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"; + // TODO: 未来可以在这里播放一声“嗖”的抢夺音效 + } + } + // 5. 逃跑:带着火柴盒跑向老鼠洞 + 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 { + // 成功钻入老鼠洞 + console.log("【test】小怪物已经走到洞口啦,准备进入下一阶段"); + monsterState = "IDLE_NPC"; + gamePhase = "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") { + // 设置 UI 坐标在玩家脸前 (水平0, 高1.3, 距离玩家0.6) + let uiPos = [0, 1.3, -0.6]; + let followBtnPos = [uiPos[0] - 0.25, uiPos[1] - 0.05, uiPos[2] + 0.01]; + let stayBtnPos = [uiPos[0] + 0.25, uiPos[1] - 0.05, uiPos[2] + 0.01]; + + let isNearFollow = false; + let isNearStay = false; + let isSelecting = false; + + // 检测手/手柄 + for (let n = 0 ; n < hands.length ; n++) { + let hPos = hands[n].pos; + if (!Array.isArray(hPos)) continue; + + // 检测是否靠近按钮 (范围扩大到 0.4 米,非常轻松) + if (cg.distance(hPos, followBtnPos) < 0.4) isNearFollow = true; + if (cg.distance(hPos, stayBtnPos) < 0.4) isNearStay = true; + + // 检测交互动作:捏合 (Pinch) 或 手柄扳机/A键 (通常映射在 hands[n].pressed 或 pinch) + if (hands[n].pinch || hands[n].pressed) { + isSelecting = true; + } + } + + // 3. 执行选择逻辑 + if (isSelecting) { + if (isNearFollow) { + choiceMade = "FOLLOW"; + // 先进入开门动画阶段 + gamePhase = "PORTAL_TRANSITION"; + window.portalStartTime = t; // 记录开门的时间 + monsterState = "HIDDEN"; + matchHeld = false; + matchHeldBy = null; + matchBoxHeldBy = null; + console.log("选择了: 跟随小怪物"); + } else if (isNearStay) { + choiceMade = "STAY"; + // 可以在这里加一个灯灭的逻辑 + console.log("选择了: 留在原地"); + } + } + } + + // ══════════════════════════════════════════════════ + // 阶段八:选择yes 进入微观世界 + // ══════════════════════════════════════════════════ + if (gamePhase === "ACT2") { + act2.render(model, t, hands); + return + } + + // ══════════════════════════════════════════════════ + // 场景渲染 — 所有颜色乘以 lightLevel + // ══════════════════════════════════════════════════ + const l = lightLevel; // 简写 + + // 天花板灯泡:点亮后发光 + const lampGlow = l > 0.5 ? l + 0.15 * Math.sin(3 * t) : l * 0.3; + lampNode.identity().move(0, 2.3 + ROOM_Y_OFFSET, -1.0 + ROOM_Z_OFFSET).scale(0.08 + lampGlow * 0.04) + .color(lampGlow, lampGlow * 0.95, lampGlow * 0.7); + + // 地板、墙、桌子都乘亮度 + 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 = monsterState === "IDLE_NPC" ? 0.02 * Math.sin(4 * t) : 0.06 * Math.abs(Math.sin(15 * t)); + + // 身体:深蓝色 + monsterNode.identity() + .move(monsterPos[0], monsterPos[1] + bounce, monsterPos[2]) + .scale(0.08) + .color(0.2 * l, 0.25 * l, 0.4 * l); + + // 滑稽面具:亮白色,贴在身体前方朝向玩家 + maskNode.identity() + .move(monsterPos[0], monsterPos[1] + bounce, monsterPos[2] + 0.08) + .scale(0.05, 0.05, 1) + .color(0.9 * l, 0.85 * l, 0.8 * l); + } else { + // --- 强制把隐藏状态下的节点缩放到 0,防止它们变成巨大的白色默认模型 --- + monsterNode.identity().scale(0); + maskNode.identity().scale(0); + + // 当怪物进洞并且阶段变为 QUEST_HUB 时,让火柴盒也彻底消失 + if (gamePhase === "QUEST_HUB") { + matchBox.identity().scale(0); + matchBoxStrip.identity().scale(0); + } + } + + // ══════════════════════════════════════════════════ + // HUD (清屏/清理多余节点) + // ══════════════════════════════════════════════════ + while (model.nChildren() > BASE_NODES) + model.remove(BASE_NODES); + + // QUEST_HUB 的选项文字 + if (gamePhase === "QUEST_HUB") { + let uiPos = [0.12, 1.3, -0.6]; + let titlePos = [uiPos[0] - 0.08, uiPos[1] + 0.04, uiPos[2] + 0.01]; + + // 定义按钮的精确位置 + let followBtnPos = [uiPos[0] - 0.15, uiPos[1] - 0.04, uiPos[2]]; + let stayBtnPos = [uiPos[0] + 0.15, uiPos[1] - 0.04, uiPos[2]]; + + // 补上被我遗漏的距离判定(用于让按钮变色) + let isNearFollow = false; + let isNearStay = false; + for (let n = 0 ; n < hands.length ; n++) { + let hPos = hands[n].pos; + if (!Array.isArray(hPos)) continue; + if (cg.distance(hPos, followBtnPos) < 0.4) isNearFollow = true; + if (cg.distance(hPos, stayBtnPos) < 0.4) isNearStay = true; + } + + // 1. 渲染半透明白色的背景“筐” + 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); // 更轻一点的半透明白色 + + // 2. 渲染文字标题 + model.add(clay.text("Follow the little thief?")) + .move(...titlePos) + .scale(0.6) + .color(0, 0, 0); // 黑色文字 + + // 3. 渲染 YES 按钮 + model.add(clay.text("[ YES ]")) + .move(...followBtnPos) + .scale(0.5) + .color(isNearFollow ? [0, 0.6, 0] : [0.3, 0.3, 0.3]); // 靠近变深绿 + + // 4. 渲染 NO 按钮 + model.add(clay.text("[ NO ]")) + .move(...stayBtnPos) + .scale(0.5) + .color(isNearStay ? [0.8, 0, 0] : [0.3, 0.3, 0.3]); // 靠近变深红 + } + + 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..." : ""; + + 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]; + + model.add(clay.text(hint)) + .move(-0.9, 2.1 + ROOM_Y_OFFSET, -1.8 + ROOM_Z_OFFSET).scale(1.1) + .color(...hintColor); + + model.add(clay.text("EVENTS: " + eventLog.length)) + .move(-0.9, 1.92 + 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); // 更旧更暗的颜色,像铅笔字 + } + } + + // ══════════════════════════════════════════════════ + // 阶段七:处理传送门的视觉过渡 + // ══════════════════════════════════════════════════ + if (gamePhase === "PORTAL_TRANSITION") { + let pTime = t - window.portalStartTime; + let duration = 5.0; + let progress = pTime / duration; + + // 1. 纯黑频闪 (Strobe Effect) + 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); // 删除了 .custom(),直接用纯黑色! + } + + // 2. 吸入感 + let suckingMove = progress * progress * 2; + let currentZ = ROOM_Z_OFFSET + suckingMove; + + // 3. 渲染“多重旋转星云”传送门 + 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); // 删除了 .custom(),用纯色堆叠 + } + + // 4. 粒子吸入效果 + 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); + } + + // 5. 脸前浮现的文字 + 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(0.15 + progress * 0.05) + .color(0.5, 1, 1); + + // 6. 正式切换 + if (pTime > duration) { + gamePhase = "ACT2"; + while (model.nChildren() > 0) model.remove(0); + } + } + }); +}; + +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..bddbbd9 --- /dev/null +++ b/js/scenes/micro_world.js @@ -0,0 +1,73 @@ +import * as cg from "../render/core/cg.js"; + +// 微观世界的全局变量 +let startTime = 0; +let crystalLit = false; // 记录玩家是否点亮了核心 + +export const render = (model, t, hands) => { + if (startTime === 0) startTime = t; + let elapsed = t - startTime; + + // 1. 每帧清理 + while (model.nChildren() > 0) model.remove(0); + + // ══════════════════════════════════════════════════ + // 环境构建 (欺骗视觉的巨大化物体) + // ══════════════════════════════════════════════════ + // 幽暗的青苔地面 + model.add("cube").move(0, -1.5, 0).scale(10, 0.1, 10).color(0.05, 0.15, 0.1); + + // ✨ 核心道具:被偷走的火柴盒(放大了 20 倍,像一栋楼一样掉在旁边!) + model.add("cube").move(-3, -0.5, -4).turnY(0.4).scale(1.5, 0.5, 2.5).color(0.8, 0.1, 0.1); // 红色的火柴盒 + model.add("square").move(-2.8, -0.5, -2.5).turnY(0.4).scale(1.5, 0.5, 2.5).color(0.2, 0.2, 0.2); // 侧面的黑色擦火条 + + // 枯萎/发光的微观水晶(目标物品) + let crystalPos = [2, -0.5, -2.5]; + let crystalColor = crystalLit ? [0.2, 1.0, 0.8] : [0.1, 0.2, 0.2]; // 点亮后变成耀眼的青色 + model.add("tubeZ").move(...crystalPos).turnX(Math.PI/2).scale(0.5, 0.5, 1.5).color(...crystalColor); + + // ══════════════════════════════════════════════════ + // NPC 渲染:小怪物 (The Little Thief) + // ══════════════════════════════════════════════════ + let npcPos = [0, -0.8 + Math.sin(t * 3) * 0.05, -2]; // 让小怪物有呼吸起伏的动画 + + // 怪物的黑色毛球身体 + model.add("sphere").move(...npcPos).scale(0.4).color(0.1, 0.1, 0.1); + // 怪物发光的黄眼睛 + model.add("sphere").move(npcPos[0] - 0.15, npcPos[1] + 0.1, npcPos[2] + 0.3).scale(0.06).color(1, 0.8, 0); + model.add("sphere").move(npcPos[0] + 0.15, npcPos[1] + 0.1, npcPos[2] + 0.3).scale(0.06).color(1, 0.8, 0); + + // ══════════════════════════════════════════════════ + // 核心逻辑:NPC 对话指引与手部交互 + // ══════════════════════════════════════════════════ + let dialogue = ""; + + // 对话系统:根据玩家进入世界的时间,NPC 吐出不同的台词 + if (elapsed < 3) { + dialogue = "YOU FOLLOWED ME..."; + } else if (elapsed < 8) { + dialogue = "I HAD TO STEAL IT. MY WORLD IS DYING."; + } else if (!crystalLit) { + // 指引阶段:告诉玩家怎么做 + dialogue = "TOUCH THE DEAD CRYSTAL ON THE RIGHT!"; + + // 在这里检测玩家的手是否碰到了水晶 + for (let i = 0; i < hands.length; i++) { + let hPos = hands[i].pos; + if (hPos && cg.distance(hPos, [crystalPos[0], crystalPos[1] + 1, crystalPos[2]]) < 0.6) { + crystalLit = true; // 触发!水晶被点亮 + } + } + } else { + // 成功通关阶段 + dialogue = "THANK YOU! THE LIGHT IS RESTORED!"; + // 额外奖励视觉:整个场景稍微亮一点 + model.add("sphere").move(...crystalPos).scale(3).color(0, 1, 1).custom("opacity", 0.2); // 水晶散发光晕 + } + + // 渲染 NPC 浮在头顶的对话框 + model.add(clay.text(dialogue)) + .move(npcPos[0]-0.5, npcPos[1] + 1.2, npcPos[2]) + .scale(2) + .color(1, 1, 1); +}; \ 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/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..9864b5e Binary files /dev/null and b/media/sound/ignite.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/media/videos/hw1_car.mp4 b/media/videos/hw1_car.mp4 new file mode 100644 index 0000000..d037f39 Binary files /dev/null and b/media/videos/hw1_car.mp4 differ diff --git a/media/videos/hw2_cardrive.mp4 b/media/videos/hw2_cardrive.mp4 new file mode 100644 index 0000000..6a29782 Binary files /dev/null and b/media/videos/hw2_cardrive.mp4 differ diff --git a/media/videos/hw3_campfire.mp4 b/media/videos/hw3_campfire.mp4 new file mode 100644 index 0000000..ce79017 Binary files /dev/null and b/media/videos/hw3_campfire.mp4 differ diff --git a/media/videos/orbit.mov b/media/videos/orbit.mov new file mode 100644 index 0000000..2fdd340 Binary files /dev/null and b/media/videos/orbit.mov differ 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");