| import * as THREE from "three"; | |
| import { RoundedBoxGeometry } from "three/addons/geometries/RoundedBoxGeometry.js"; | |
| import { TextureFactory } from "../rendering/TextureFactory.js"; | |
| export class SovereignInterior { | |
| constructor({ scene, renderer, physics, store, bus, commands, animations, audio }) { | |
| this.scene = scene; | |
| this.renderer = renderer; | |
| this.physics = physics; | |
| this.store = store; | |
| this.bus = bus; | |
| this.commands = commands; | |
| this.animations = animations; | |
| this.audio = audio; | |
| this.interactables = []; | |
| this.textureFactory = new TextureFactory(renderer.renderer); | |
| this.materials = this.textureFactory.materials(); | |
| this.clock = new THREE.Clock(); | |
| this.doorOpen = false; | |
| this.lampState = null; | |
| this.buildArchitecture(); | |
| this.buildLighting(); | |
| this.buildArt(); | |
| this.buildCouch(); | |
| this.buildTables(); | |
| this.buildDiningChairs(); | |
| this.buildFloorLamp(); | |
| this.buildDocument(); | |
| this.buildTerminalAndDoor(); | |
| this.physics.addTrigger({ id: "trigger.exit", position: [3.9, 1.1, 5.45], size: [1.35, 2.2, 0.7] }); | |
| this.unsubscribe = [ | |
| bus.on("STATE_CHANGED", ({ state, event }) => this.applyState(state, event)), | |
| bus.on("STATE_HYDRATED", ({ state }) => this.applyState(state, { type: "STATE_HYDRATED" })), | |
| bus.on("TRIGGER_ENTERED", ({ id }) => { | |
| if (id === "trigger.exit" && this.store.state.world.entities["door.exit"].open) { | |
| this.commands.execute("MISSION_COMPLETE"); | |
| } | |
| }), | |
| ]; | |
| this.applyState(store.state, { type: "BOOT" }); | |
| } | |
| piece(parent, geometry, material, position, rotation = [0, 0, 0], shadows = true) { | |
| const mesh = new THREE.Mesh(geometry, material); | |
| mesh.position.set(...position); | |
| mesh.rotation.set(...rotation); | |
| mesh.castShadow = shadows; | |
| mesh.receiveShadow = true; | |
| parent.add(mesh); | |
| return mesh; | |
| } | |
| box(parent, size, material, position, rotationY = 0, shadows = true) { | |
| return this.piece(parent, new THREE.BoxGeometry(...size), material, position, [0, rotationY, 0], shadows); | |
| } | |
| round(parent, size, radius, material, position, rotation = [0, 0, 0]) { | |
| return this.piece(parent, new RoundedBoxGeometry(...size, 5, radius), material, position, rotation); | |
| } | |
| cylinder(parent, top, bottom, height, material, position, segments = 32) { | |
| return this.piece(parent, new THREE.CylinderGeometry(top, bottom, height, segments), material, position); | |
| } | |
| register(object, metadata) { | |
| object.name = metadata.name; | |
| object.traverse(child => { | |
| if (child.isMesh) child.userData.entityId = metadata.id; | |
| }); | |
| this.interactables.push({ object, ...metadata }); | |
| return object; | |
| } | |
| buildArchitecture() { | |
| const M = this.materials; | |
| const floor = this.box(this.scene, [12, 0.18, 10], M.floor, [0, -0.09, 0]); | |
| floor.name = "Wood floor"; | |
| this.physics.addFixedBox({ position: [0, -0.09, 0], size: [12, 0.18, 10], friction: 0.9 }); | |
| const walls = [ | |
| { position: [0, 2.5, -5], size: [12, 5, 0.18] }, | |
| { position: [-6, 2.5, 0], size: [0.18, 5, 10] }, | |
| { position: [6, 2.5, 0], size: [0.18, 5, 10] }, | |
| { position: [-1.45, 2.5, 5], size: [9.1, 5, 0.18] }, | |
| { position: [5.35, 2.5, 5], size: [1.3, 5, 0.18] }, | |
| { position: [3.9, 3.85, 5], size: [1.6, 2.3, 0.18] }, | |
| ]; | |
| for (const wall of walls) { | |
| this.box(this.scene, wall.size, M.wall, wall.position, 0, false); | |
| this.physics.addFixedBox(wall); | |
| } | |
| const ceiling = this.box(this.scene, [12, 0.12, 10], M.wall, [0, 5.02, 0], 0, false); | |
| ceiling.name = "Ceiling"; | |
| this.physics.addFixedBox({ position: [0, 5.02, 0], size: [12, 0.12, 10] }); | |
| this.box(this.scene, [11.8, 0.18, 0.12], M.dark, [0, 0.16, -4.84]); | |
| this.box(this.scene, [0.12, 0.18, 9.7], M.dark, [-5.84, 0.16, 0]); | |
| this.box(this.scene, [0.12, 0.18, 9.7], M.dark, [5.84, 0.16, 0]); | |
| this.box(this.scene, [9, 0.18, 0.12], M.dark, [-1.5, 0.16, 4.84]); | |
| this.box(this.scene, [1.2, 0.18, 0.12], M.dark, [5.35, 0.16, 4.84]); | |
| this.box(this.scene, [5.2, 0.06, 3.25], M.rug, [-2.1, 0.08, -1.35]); | |
| } | |
| buildLighting() { | |
| this.scene.add(new THREE.AmbientLight(0xfff6e8, 1.25)); | |
| this.mainLight = new THREE.PointLight(0xffd19a, 105, 16, 2); | |
| this.mainLight.position.set(0, 4.35, 0.65); | |
| this.mainLight.castShadow = true; | |
| const mapSize = this.store.state.settings.shadows === "high" ? 2048 : 1024; | |
| this.mainLight.shadow.mapSize.set(mapSize, mapSize); | |
| this.mainLight.shadow.camera.near = 0.2; | |
| this.mainLight.shadow.camera.far = 15; | |
| this.mainLight.shadow.bias = -0.0004; | |
| this.mainLight.shadow.normalBias = 0.025; | |
| this.scene.add(this.mainLight); | |
| const pendant = this.cylinder(this.scene, 0.045, 0.045, 0.5, this.materials.dark, [0, 4.76, 0.65], 16); | |
| pendant.castShadow = false; | |
| const bulbMaterial = new THREE.MeshStandardMaterial({ color: 0xffd7a0, emissive: 0xffa94c, emissiveIntensity: 2 }); | |
| const bulb = this.piece(this.scene, new THREE.SphereGeometry(0.24, 24, 16), bulbMaterial, [0, 4.43, 0.65]); | |
| bulb.castShadow = false; | |
| } | |
| buildArt() { | |
| const group = new THREE.Group(); | |
| group.position.set(-2.15, 3.42, -4.77); | |
| this.scene.add(group); | |
| this.box(group, [3.2, 1.78, 0.09], this.materials.dark, [0, 0, 0]); | |
| this.box(group, [2.94, 1.52, 0.06], this.materials.art, [0, 0, 0.09]); | |
| this.register(group, { | |
| id: "artwork.seal", | |
| name: "Sealed Artwork", | |
| interactionType: "inspect", | |
| interactionDistance: 2.8, | |
| prompt: state => state.world.entities["artwork.seal"].inspected ? "Review sealed artwork" : "Inspect sealed artwork", | |
| onInteract: () => this.commands.execute("INSPECT_ARTWORK"), | |
| }); | |
| } | |
| buildCouch() { | |
| const M = this.materials; | |
| const couch = new THREE.Group(); | |
| couch.position.set(-2.2, 0, -3.72); | |
| this.scene.add(couch); | |
| this.round(couch, [4.7, 0.52, 1.45], 0.14, M.sofa, [0, 0.48, 0]); | |
| this.round(couch, [4.68, 0.72, 0.4], 0.14, M.sofa, [0, 1.35, -0.58], [-0.09, 0, 0]); | |
| this.round(couch, [0.5, 1.05, 1.5], 0.16, M.sofa, [-2.18, 0.82, 0.02]); | |
| this.round(couch, [0.5, 1.05, 1.5], 0.16, M.sofa, [2.18, 0.82, 0.02]); | |
| for (const x of [-1.43, 0, 1.43]) { | |
| this.round(couch, [1.32, 0.28, 1.17], 0.11, M.sofa, [x, 0.82, 0.15]); | |
| this.round(couch, [1.3, 1.03, 0.27], 0.12, M.sofa, [x, 1.43, -0.47], [-0.08, 0, 0]); | |
| } | |
| for (const x of [-1.9, 1.9]) { | |
| for (const z of [-0.45, 0.45]) this.cylinder(couch, 0.08, 0.1, 0.28, M.dark, [x, 0.13, z], 12); | |
| } | |
| this.round(couch, [0.72, 0.72, 0.18], 0.1, M.chair, [-1.55, 1.46, -0.24], [0, 0, -0.15]); | |
| this.round(couch, [0.72, 0.72, 0.18], 0.1, M.chair, [1.5, 1.45, -0.22], [0, 0, 0.12]); | |
| this.physics.addFixedBox({ position: [-2.2, 0.62, -3.72], size: [4.7, 1.24, 1.45] }); | |
| this.register(couch, { | |
| id: "couch.main", | |
| name: "Couch", | |
| interactionType: "sit", | |
| interactionDistance: 2.5, | |
| prompt: () => "Sit on couch", | |
| onInteract: () => this.commands.execute("USE_COUCH", { | |
| anchor: { | |
| position: [-2.2, 0.79, -3.12], | |
| standPosition: [0.65, 0.79, -2.7], | |
| yaw: Math.PI, | |
| pitch: 0, | |
| }, | |
| }), | |
| }); | |
| } | |
| buildTables() { | |
| const M = this.materials; | |
| const coffee = new THREE.Group(); | |
| coffee.position.set(-2.1, 0, -1.28); | |
| this.scene.add(coffee); | |
| this.cylinder(coffee, 1.18, 1.18, 0.16, M.wood, [0, 0.72, 0], 48); | |
| for (const x of [-0.65, 0.65]) { | |
| for (const z of [-0.42, 0.42]) this.cylinder(coffee, 0.09, 0.12, 0.67, M.dark, [x, 0.35, z], 12); | |
| } | |
| this.physics.addFixedBox({ position: [-2.1, 0.72, -1.28], size: [2.35, 0.16, 2.35] }); | |
| const dining = new THREE.Group(); | |
| dining.position.set(3.45, 0, -1.62); | |
| this.scene.add(dining); | |
| this.box(dining, [2.55, 0.18, 1.45], M.wood, [0, 1.42, 0]); | |
| for (const x of [-1, 1]) { | |
| for (const z of [-0.51, 0.51]) this.box(dining, [0.12, 1.35, 0.12], M.dark, [x, 0.7, z]); | |
| } | |
| this.physics.addFixedBox({ position: [3.45, 1.42, -1.62], size: [2.55, 0.18, 1.45] }); | |
| } | |
| buildDiningChairs() { | |
| this.createChair([2.75, 0.02, -0.12], Math.PI, "chair.dining.01"); | |
| this.createChair([4.15, 0.02, -3.1], 0, "chair.dining.02"); | |
| } | |
| createChair(position, rotationY, id) { | |
| const chair = new THREE.Group(); | |
| this.round(chair, [1.02, 0.2, 0.96], 0.08, this.materials.chair, [0, 0.88, 0]); | |
| this.round(chair, [1.02, 0.95, 0.2], 0.08, this.materials.chair, [0, 1.45, 0.42], [-0.08, 0, 0]); | |
| for (const x of [-0.38, 0.38]) { | |
| for (const z of [-0.34, 0.34]) this.box(chair, [0.1, 0.82, 0.1], this.materials.dark, [x, 0.43, z]); | |
| } | |
| chair.rotation.y = rotationY; | |
| this.scene.add(chair); | |
| const body = this.physics.addDynamicChair(position, chair); | |
| body.setRotation({ x: 0, y: Math.sin(rotationY / 2), z: 0, w: Math.cos(rotationY / 2) }, true); | |
| this.register(chair, { | |
| id, | |
| name: "Dining Chair", | |
| interactionType: "push", | |
| interactionDistance: 2.2, | |
| prompt: () => "Move chair", | |
| onInteract: ({ direction }) => this.commands.execute("PUSH_CHAIR", { | |
| body, | |
| impulse: { x: direction.x * 2.8, y: 0.18, z: direction.z * 2.8 }, | |
| }), | |
| }); | |
| } | |
| buildFloorLamp() { | |
| const lamp = new THREE.Group(); | |
| lamp.position.set(-5.03, 0, -3.75); | |
| this.scene.add(lamp); | |
| this.cylinder(lamp, 0.38, 0.46, 0.12, this.materials.brass, [0, 0.08, 0]); | |
| this.cylinder(lamp, 0.045, 0.045, 2.75, this.materials.brass, [0, 1.47, 0], 16); | |
| this.shadeMaterial = new THREE.MeshPhysicalMaterial({ | |
| color: 0xe8c994, | |
| roughness: 0.6, | |
| side: THREE.DoubleSide, | |
| transparent: true, | |
| opacity: 0.88, | |
| emissive: 0xffb35c, | |
| emissiveIntensity: 0.13, | |
| }); | |
| this.piece(lamp, new THREE.CylinderGeometry(0.35, 0.62, 0.72, 40, 1, true), this.shadeMaterial, [0, 2.76, 0]); | |
| const glow = new THREE.MeshStandardMaterial({ color: 0xffd49a, emissive: 0xffa43a, emissiveIntensity: 3 }); | |
| this.piece(lamp, new THREE.SphereGeometry(0.1, 20, 12), glow, [0, 2.68, 0]); | |
| this.floorLight = new THREE.PointLight(0xffb66e, 38, 6, 2); | |
| this.floorLight.position.set(-5.03, 2.7, -3.75); | |
| this.scene.add(this.floorLight); | |
| this.physics.addFixedBox({ position: [-5.03, 1.4, -3.75], size: [0.75, 2.8, 0.75] }); | |
| this.register(lamp, { | |
| id: "lamp.floor", | |
| name: "Floor Lamp", | |
| interactionType: "toggle", | |
| interactionDistance: 2.5, | |
| prompt: state => state.world.entities["lamp.floor"].on ? "Turn lamp off" : "Turn lamp on", | |
| onInteract: () => this.commands.execute("TOGGLE_LAMP"), | |
| }); | |
| } | |
| buildDocument() { | |
| const document = new THREE.Group(); | |
| document.position.set(-2.1, 0.84, -1.28); | |
| document.rotation.y = -0.2; | |
| this.scene.add(document); | |
| this.box(document, [0.72, 0.035, 0.92], this.materials.paper, [0, 0, 0]); | |
| this.cylinder(document, 0.09, 0.09, 0.018, this.materials.brass, [0.22, 0.03, 0.27], 24); | |
| this.documentObject = document; | |
| this.register(document, { | |
| id: "document.covenant", | |
| name: "Covenant Fragment", | |
| interactionType: "collect", | |
| interactionDistance: 2.25, | |
| prompt: () => "Collect covenant document", | |
| onInteract: () => this.commands.execute("COLLECT_DOCUMENT"), | |
| }); | |
| } | |
| buildTerminalAndDoor() { | |
| const terminal = new THREE.Group(); | |
| terminal.position.set(2.58, 1.42, 4.78); | |
| terminal.rotation.y = Math.PI; | |
| this.scene.add(terminal); | |
| this.box(terminal, [0.74, 0.92, 0.18], this.materials.dark, [0, 0, 0]); | |
| this.terminalScreen = this.box(terminal, [0.58, 0.46, 0.04], this.materials.screen, [0, 0.12, 0.11]); | |
| this.box(terminal, [0.48, 0.08, 0.07], this.materials.brass, [0, -0.32, 0.12]); | |
| this.register(terminal, { | |
| id: "terminal.sovereign", | |
| name: "Sovereign Terminal", | |
| interactionType: "activate", | |
| interactionDistance: 2.45, | |
| enabled: state => state.world.entities["document.covenant"].verified, | |
| prompt: state => state.world.entities["terminal.sovereign"].activated ? "Trust chain accepted" : "Activate sovereign terminal", | |
| disabledPrompt: () => "Terminal awaits verified evidence", | |
| onInteract: () => this.commands.execute("ACTIVATE_TERMINAL"), | |
| }); | |
| this.doorPivot = new THREE.Group(); | |
| this.doorPivot.position.set(3.1, 0, 4.84); | |
| this.scene.add(this.doorPivot); | |
| const panel = this.box(this.doorPivot, [1.55, 2.68, 0.12], this.materials.door, [0.775, 1.34, 0]); | |
| this.box(this.doorPivot, [0.09, 0.09, 0.16], this.materials.brass, [1.38, 1.35, -0.1]); | |
| this.register(this.doorPivot, { | |
| id: "door.exit", | |
| name: "Exit Door", | |
| interactionType: "door", | |
| interactionDistance: 2.2, | |
| enabled: state => !state.world.entities["door.exit"].locked && !state.world.entities["door.exit"].open, | |
| prompt: () => "Open exit door", | |
| disabledPrompt: state => state.world.entities["door.exit"].open ? "Exit is open" : "Door requires verified covenant", | |
| onInteract: () => { | |
| if (!this.store.state.world.entities["door.exit"].open) { | |
| this.store.dispatch("DOOR_OPENED", { automatic: false }, { entityId: "door.exit" }); | |
| } | |
| }, | |
| }); | |
| this.doorCollider = this.physics.addFixedBox({ position: [3.875, 1.34, 4.84], size: [1.55, 2.68, 0.18] }); | |
| } | |
| applyState(state, event) { | |
| const entities = state.world.entities; | |
| const lampOn = entities["lamp.floor"].on; | |
| if (this.floorLight && this.lampState !== lampOn) { | |
| this.lampState = lampOn; | |
| const start = this.floorLight.intensity; | |
| const target = lampOn ? 38 : 0; | |
| this.animations.to({ | |
| duration: 0.28, | |
| update: progress => { | |
| this.floorLight.intensity = THREE.MathUtils.lerp(start, target, progress); | |
| this.shadeMaterial.emissiveIntensity = THREE.MathUtils.lerp(lampOn ? 0.02 : 0.13, lampOn ? 0.13 : 0.02, progress); | |
| }, | |
| }); | |
| this.audio.setLamp(lampOn); | |
| } | |
| if (this.documentObject) this.documentObject.visible = entities["document.covenant"].present; | |
| if (this.terminalScreen) { | |
| this.terminalScreen.material.emissive.setHex(entities["terminal.sovereign"].activated ? 0x5ee6ba : 0x214f45); | |
| this.terminalScreen.material.emissiveIntensity = entities["terminal.sovereign"].activated ? 2.2 : 1.2; | |
| } | |
| if (entities["door.exit"].open && !this.doorOpen) this.openDoor(event.type === "STATE_HYDRATED"); | |
| if (!entities["door.exit"].open && this.doorOpen) this.closeDoor(); | |
| } | |
| openDoor(instant = false) { | |
| this.doorOpen = true; | |
| this.physics.removeCollider(this.doorCollider); | |
| if (instant) { | |
| this.doorPivot.rotation.y = Math.PI / 2; | |
| return; | |
| } | |
| const start = this.doorPivot.rotation.y; | |
| this.cancelDoorAnimation?.(); | |
| this.cancelDoorAnimation = this.animations.to({ | |
| duration: this.store.state.settings.reducedMotion ? 0.01 : 1.05, | |
| ease: "outCubic", | |
| update: progress => { | |
| this.doorPivot.rotation.y = THREE.MathUtils.lerp(start, Math.PI / 2, progress); | |
| }, | |
| complete: () => this.audio.playTone(520, 0.12, 0.035), | |
| }); | |
| } | |
| closeDoor() { | |
| this.cancelDoorAnimation?.(); | |
| this.cancelDoorAnimation = null; | |
| this.doorOpen = false; | |
| this.doorPivot.rotation.y = 0; | |
| this.doorCollider = this.physics.addFixedBox({ position: [3.875, 1.34, 4.84], size: [1.55, 2.68, 0.18] }); | |
| } | |
| setShadowQuality(quality) { | |
| this.mainLight.castShadow = quality !== "off"; | |
| const size = quality === "high" ? 2048 : 1024; | |
| this.mainLight.shadow.mapSize.set(size, size); | |
| this.mainLight.shadow.map?.dispose(); | |
| this.mainLight.shadow.map = null; | |
| } | |
| update() { | |
| const time = this.clock.getElapsedTime(); | |
| if (this.store.state.world.entities["lamp.floor"].on) this.floorLight.intensity = 38 + Math.sin(time * 5.2) * 0.35; | |
| this.mainLight.intensity = 105 + Math.sin(time * 0.9) * 1.2; | |
| } | |
| dispose() { | |
| this.unsubscribe.forEach(dispose => dispose()); | |
| this.textureFactory.dispose(); | |
| } | |
| } | |