Before the doublework change
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import type { FC } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { SpinningModel } from './model3d'
|
||||
|
||||
// The Barricelli Luggage: Nils Aall Barricelli's patented suitcase-commode. Two wooden
|
||||
// beams that taper upward carry three suitcases slung between them as drawers, so the
|
||||
// whole thing reads as a rudimentary chest of drawers built from luggage.
|
||||
|
||||
const WOOD = new THREE.MeshStandardMaterial({ color: 0x6b4a2f, roughness: 0.85, metalness: 0.04, flatShading: true })
|
||||
const BRASS = new THREE.MeshStandardMaterial({ color: 0xc9a24a, roughness: 0.45, metalness: 0.6, flatShading: true })
|
||||
const STRAP = new THREE.MeshStandardMaterial({ color: 0x2f2118, roughness: 0.8, flatShading: true })
|
||||
const LEATHERS = [0x8a5a34, 0x6f4632, 0x9a6b3f].map(color => new THREE.MeshStandardMaterial({ color, roughness: 0.8, metalness: 0.05, flatShading: true }))
|
||||
|
||||
// One suitcase drawer: leather body, a lid seam, two wrap-around straps, brass latches,
|
||||
// corner caps, and a front pull (it is a drawer now, so the handle faces the viewer).
|
||||
function buildSuitcase(width: number, height: number, depth: number, leather: THREE.Material) {
|
||||
const suitcase = new THREE.Group()
|
||||
const body = new THREE.Mesh(new THREE.BoxGeometry(width, height, depth), leather); suitcase.add(body)
|
||||
const seam = new THREE.Mesh(new THREE.BoxGeometry(width + 0.02, 0.04, depth + 0.02), STRAP); suitcase.add(seam)
|
||||
for (const sign of [-1, 1]) {
|
||||
const strap = new THREE.Mesh(new THREE.BoxGeometry(0.07, height + 0.02, depth + 0.04), STRAP)
|
||||
strap.position.x = sign * width * 0.3; suitcase.add(strap)
|
||||
const latch = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.07, 0.03), BRASS)
|
||||
latch.position.set(sign * width * 0.3, 0.02, depth / 2 + 0.01); suitcase.add(latch)
|
||||
}
|
||||
for (const sx of [-1, 1]) for (const sy of [-1, 1]) { // brass corner caps on the front face
|
||||
const cap = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.07, 0.06), BRASS)
|
||||
cap.position.set(sx * (width / 2 - 0.035), sy * (height / 2 - 0.035), depth / 2)
|
||||
suitcase.add(cap)
|
||||
}
|
||||
const pull = new THREE.Mesh(new THREE.BoxGeometry(0.26, 0.05, 0.05), BRASS)
|
||||
pull.position.set(0, -height * 0.16, depth / 2 + 0.05); suitcase.add(pull)
|
||||
for (const sign of [-1, 1]) { const stem = new THREE.Mesh(new THREE.BoxGeometry(0.03, 0.03, 0.05), BRASS); stem.position.set(sign * 0.1, -height * 0.16, depth / 2 + 0.02); suitcase.add(stem) }
|
||||
return suitcase
|
||||
}
|
||||
|
||||
function buildBarricelliLuggage() {
|
||||
const frame = new THREE.Group()
|
||||
const postHeight = 2.1
|
||||
const suitcase = { w: 1.05, h: 0.44, d: 0.62 }
|
||||
const levels = [0.62, 0, -0.62] // three stacked drawers
|
||||
|
||||
for (const sign of [-1, 1]) { // two beams, each tapering upward, leaning slightly inward
|
||||
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.085, 0.12, postHeight, 4), WOOD)
|
||||
post.rotation.y = Math.PI / 4 // square the prism to face front
|
||||
post.rotation.z = -sign * 0.05 // splay the feet, converge the tops
|
||||
post.position.x = sign * 0.66
|
||||
frame.add(post)
|
||||
const foot = new THREE.Mesh(new THREE.BoxGeometry(0.24, 0.09, 0.32), WOOD)
|
||||
foot.position.set(sign * 0.7, -postHeight / 2 - 0.02, 0); frame.add(foot)
|
||||
}
|
||||
// Top cap and a low stretcher tie the beams into a stand.
|
||||
const cap = new THREE.Mesh(new THREE.BoxGeometry(1.44, 0.11, 0.5), WOOD); cap.position.y = postHeight / 2 + 0.02; frame.add(cap)
|
||||
const stretcher = new THREE.Mesh(new THREE.BoxGeometry(1.4, 0.08, 0.18), WOOD); stretcher.position.y = -postHeight / 2 + 0.14; frame.add(stretcher)
|
||||
|
||||
levels.forEach((y, i) => {
|
||||
const drawer = buildSuitcase(suitcase.w, suitcase.h, suitcase.d, LEATHERS[i % LEATHERS.length])
|
||||
drawer.position.y = y; frame.add(drawer)
|
||||
for (const sign of [-1, 1]) { // small wooden brackets mounting each case to the beams
|
||||
const bracket = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.08, 0.3), WOOD)
|
||||
bracket.position.set(sign * (suitcase.w / 2 + 0.05), y, 0); frame.add(bracket)
|
||||
}
|
||||
})
|
||||
|
||||
frame.traverse(obj => { if (obj instanceof THREE.Mesh) obj.castShadow = true })
|
||||
return frame
|
||||
}
|
||||
|
||||
export const BarricelliLuggageMerit: FC<{ label: string; onComplete: () => void }> = ({ label, onComplete }) => (
|
||||
<div className="cutscene-card merit-card">
|
||||
<div className="title-card-inner">
|
||||
<small className="merit-eyebrow">◆ MERIT AWARDED ◆</small>
|
||||
<SpinningModel build={buildBarricelliLuggage} className="merit-stage" cameraPos={[0.9, 0.35, 4.9]} />
|
||||
<h1>{label}</h1>
|
||||
<button className="cutscene-begin" onClick={event => { event.stopPropagation(); onComplete() }}>Accept ▸</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
|
||||
// A self-contained three.js stage that slowly turns a single model and lets the viewer
|
||||
// swipe to spin it faster, the momentum easing back to the idle rate — the same feel as
|
||||
// the inventory rack, reused for one-off showpieces like a merit award.
|
||||
export function SpinningModel({ build, className, cameraPos = [0.6, 0.4, 5], baseSpin = 0.008 }: {
|
||||
build: () => THREE.Group
|
||||
className?: string
|
||||
cameraPos?: [number, number, number]
|
||||
baseSpin?: number
|
||||
}) {
|
||||
const stageRef = useRef<HTMLDivElement>(null)
|
||||
const [camX, camY, camZ] = cameraPos
|
||||
|
||||
useEffect(() => {
|
||||
const stage = stageRef.current
|
||||
if (!stage) return
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
||||
stage.appendChild(renderer.domElement)
|
||||
renderer.domElement.style.cssText = 'position:absolute;inset:0;width:100%;height:100%'
|
||||
|
||||
const scene = new THREE.Scene()
|
||||
const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100)
|
||||
camera.position.set(camX, camY, camZ)
|
||||
camera.lookAt(0, 0, 0)
|
||||
scene.add(new THREE.AmbientLight(0x40483a, 0.9))
|
||||
const key = new THREE.DirectionalLight(0xfff4e0, 1.1); key.position.set(2, 3, 3); scene.add(key)
|
||||
const rim = new THREE.DirectionalLight(0x9fd020, 0.4); rim.position.set(-2, 1, -2); scene.add(rim)
|
||||
const holder = new THREE.Group(); holder.add(build()); scene.add(holder)
|
||||
|
||||
const resize = () => { const w = stage.clientWidth, h = stage.clientHeight; if (w && h) { camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h, false) } }
|
||||
resize(); const ro = new ResizeObserver(resize); ro.observe(stage)
|
||||
|
||||
// Drag to spin (see inventory rack): a swipe rotates 1:1 and hands its speed to `spin`.
|
||||
let spin = baseSpin
|
||||
let dragging = false
|
||||
let lastX = 0
|
||||
const dom = renderer.domElement
|
||||
dom.style.touchAction = 'none'
|
||||
dom.style.cursor = 'grab'
|
||||
const onDown = (event: PointerEvent) => { dragging = true; lastX = event.clientX; dom.style.cursor = 'grabbing'; dom.setPointerCapture(event.pointerId) }
|
||||
const onMove = (event: PointerEvent) => {
|
||||
if (!dragging) return
|
||||
const delta = (event.clientX - lastX) * 0.01
|
||||
lastX = event.clientX
|
||||
holder.rotation.y += delta
|
||||
spin = THREE.MathUtils.clamp(delta, -0.35, 0.35)
|
||||
}
|
||||
const onUp = (event: PointerEvent) => { dragging = false; dom.style.cursor = 'grab'; try { dom.releasePointerCapture(event.pointerId) } catch { /* already released */ } }
|
||||
dom.addEventListener('pointerdown', onDown)
|
||||
dom.addEventListener('pointermove', onMove)
|
||||
dom.addEventListener('pointerup', onUp)
|
||||
dom.addEventListener('pointercancel', onUp)
|
||||
|
||||
let raf = 0
|
||||
const tick = () => {
|
||||
if (!dragging) { holder.rotation.y += spin; spin += (baseSpin - spin) * 0.04 }
|
||||
renderer.render(scene, camera); raf = requestAnimationFrame(tick)
|
||||
}
|
||||
tick()
|
||||
return () => {
|
||||
cancelAnimationFrame(raf); ro.disconnect()
|
||||
dom.removeEventListener('pointerdown', onDown); dom.removeEventListener('pointermove', onMove)
|
||||
dom.removeEventListener('pointerup', onUp); dom.removeEventListener('pointercancel', onUp)
|
||||
renderer.dispose(); dom.remove()
|
||||
scene.traverse(o => { if (o instanceof THREE.Mesh) { o.geometry.dispose(); (o.material as THREE.Material).dispose() } })
|
||||
}
|
||||
}, [build, camX, camY, camZ, baseSpin])
|
||||
|
||||
return <div ref={stageRef} className={className || 'model3d-stage'} />
|
||||
}
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState, type FC } from 'react'
|
||||
import { audio } from './audio'
|
||||
import { BarricelliLuggageMerit } from './merits'
|
||||
|
||||
export type RuntimeUtterance = { id: string; utterer: 'npc' | 'player'; speaker: { name: string; role: string }; poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null; awardsFlag?: string | null }
|
||||
export type RuntimeNode = { id: string; kind: 'cutscene' | 'dialogue' | 'level' | 'merit'; label: string; componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null; musicVolume?: number; awardsFlag?: string | null; utterances?: RuntimeUtterance[]; rootId?: string | null }
|
||||
@@ -51,7 +52,7 @@ export const CUTSCENE_COMPONENT_KEYS = Object.keys(CUTSCENE_REGISTRY)
|
||||
// Merit ceremony components, keyed by a merit node's component_key (e.g. a 3D
|
||||
// award model). The achievement itself is granted server-side on arrival; this is
|
||||
// purely the presentation of receiving it.
|
||||
const MERIT_REGISTRY: Record<string, FC<{ label: string; onComplete: () => void }>> = {}
|
||||
const MERIT_REGISTRY: Record<string, FC<{ label: string; onComplete: () => void }>> = { 'barricelli-luggage': BarricelliLuggageMerit }
|
||||
export const MERIT_COMPONENT_KEYS = Object.keys(MERIT_REGISTRY)
|
||||
|
||||
export function MeritHost({ componentKey, label, awardsFlag, onComplete }: { componentKey: string | null | undefined; label: string; awardsFlag?: string | null; onComplete: () => void }) {
|
||||
|
||||
+1
-1
@@ -260,7 +260,7 @@ function PhoneScreen({ visible, mode, dialed, callee }: { visible: boolean; mode
|
||||
{mode === 'calling' && <><div className="pscr-big">CALLING<span className="pscr-dots" /></div><div className="pscr-num sm">{dialed}</div></>}
|
||||
{mode === 'unknown' && <><div className="pscr-big warn">NUMBER NOT</div><div className="pscr-big warn">IN SERVICE</div><div className="pscr-soft"><span /><span>End ◂</span></div></>}
|
||||
{mode === 'voicemail' && <><div className="pscr-big">VOICEMAIL</div><div className="pscr-callee">{callee}</div><div className="pscr-line">leave a message…</div><div className="pscr-soft"><span /><span>End ◂</span></div></>}
|
||||
{mode === 'connected' && <><div className="pscr-big ok">CONNECTED</div><div className="pscr-callee">{callee}</div><div className="pscr-line">[dialogue plays here]</div><div className="pscr-soft"><span /><span>End ◂</span></div></>}
|
||||
{mode === 'connected' && <><div className="pscr-big ok">CONNECTED</div><div className="pscr-callee">{callee}</div><div className="pscr-line">[connected]</div><div className="pscr-soft"><span /><span>End ◂</span></div></>}
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -628,6 +628,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.merit-card { background: radial-gradient(120% 90% at 50% 35%, #16240f 0%, #0a1408 60%, #04110e 100%); }
|
||||
.merit-card .merit-eyebrow { color: #cdea6a; }
|
||||
.merit-flag { font: 13px IBM Plex Mono; letter-spacing: .12em; color: #d58a46; margin: 0; }
|
||||
.merit-stage { position: relative; width: min(60vw, 460px); height: min(46vh, 380px); cursor: grab; }
|
||||
.title-card-inner { display: grid; justify-items: center; text-align: center; gap: 18px; animation: title-rise 1.1s cubic-bezier(.2,.7,.2,1); }
|
||||
@keyframes title-rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
|
||||
.title-card-inner small { font: 10px IBM Plex Mono; letter-spacing: .3em; color: #7f9a92; text-transform: uppercase; }
|
||||
|
||||
Reference in New Issue
Block a user