|
|
|
|
@@ -4,83 +4,215 @@ import type { SignSnapshot } from '@/journey/types';
|
|
|
|
|
import type { SceneResourceBag } from '@/scene/resources';
|
|
|
|
|
|
|
|
|
|
const routeLabels: Readonly<Record<SectionId, string>> = {
|
|
|
|
|
start: '00 / START',
|
|
|
|
|
profile: '01 / PROFILE',
|
|
|
|
|
experience: '02 / EXPERIENCE',
|
|
|
|
|
infrastructure: '03 / INFRASTRUCTURE',
|
|
|
|
|
'selected-builds': '04 / SELECTED BUILDS',
|
|
|
|
|
'additional-work': '05 / ADDITIONAL WORK',
|
|
|
|
|
toolkit: '06 / TOOLKIT',
|
|
|
|
|
contact: '07 / DESTINATION',
|
|
|
|
|
start: 'Start',
|
|
|
|
|
profile: 'Profile',
|
|
|
|
|
experience: 'Experience',
|
|
|
|
|
infrastructure: 'Infrastructure',
|
|
|
|
|
'selected-builds': 'Selected builds',
|
|
|
|
|
'additional-work': 'Additional work',
|
|
|
|
|
toolkit: 'Toolkit',
|
|
|
|
|
contact: 'Contact',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export interface SignWorld {
|
|
|
|
|
group: SignGroup;
|
|
|
|
|
setViewportAspect(aspect: number): void;
|
|
|
|
|
update(sign: SignSnapshot | null): void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface SignGroup {
|
|
|
|
|
visible: boolean;
|
|
|
|
|
position: { x: number; y: number; z: number; set(x: number, y: number, z: number): void };
|
|
|
|
|
rotation: { y: number };
|
|
|
|
|
scale: { setScalar(scale: number): void };
|
|
|
|
|
add(...objects: unknown[]): void;
|
|
|
|
|
getObjectByName(name: string): unknown;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function routeLabelFor(id: SectionId): string {
|
|
|
|
|
return routeLabels[id];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function createSign(resources: SceneResourceBag) {
|
|
|
|
|
export function createSign(resources: SceneResourceBag): SignWorld {
|
|
|
|
|
const group = new THREE.Group();
|
|
|
|
|
group.name = 'roadside-sign';
|
|
|
|
|
group.visible = false;
|
|
|
|
|
const geometry = resources.own('geometry', new THREE.PlaneGeometry(8.4, 2.2));
|
|
|
|
|
|
|
|
|
|
const backplateMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
|
|
|
|
color: 0x07101a,
|
|
|
|
|
roughness: 0.7,
|
|
|
|
|
metalness: 0.42,
|
|
|
|
|
transparent: true,
|
|
|
|
|
opacity: 0,
|
|
|
|
|
}));
|
|
|
|
|
const frameMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
|
|
|
|
color: 0x2d6f79,
|
|
|
|
|
emissive: 0x43cfdd,
|
|
|
|
|
emissiveIntensity: 0.68,
|
|
|
|
|
roughness: 0.38,
|
|
|
|
|
metalness: 0.58,
|
|
|
|
|
transparent: true,
|
|
|
|
|
opacity: 0,
|
|
|
|
|
}));
|
|
|
|
|
const postMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
|
|
|
|
color: 0x172b35,
|
|
|
|
|
roughness: 0.64,
|
|
|
|
|
metalness: 0.62,
|
|
|
|
|
transparent: true,
|
|
|
|
|
opacity: 0,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const backplate = new THREE.Mesh(
|
|
|
|
|
resources.own('geometry', new THREE.BoxGeometry(8.2, 2.35, 0.24)),
|
|
|
|
|
backplateMaterial,
|
|
|
|
|
);
|
|
|
|
|
backplate.name = 'sign-backplate';
|
|
|
|
|
group.add(backplate);
|
|
|
|
|
|
|
|
|
|
const canvas = document.createElement('canvas');
|
|
|
|
|
canvas.width = 768;
|
|
|
|
|
canvas.height = 192;
|
|
|
|
|
canvas.width = 1_024;
|
|
|
|
|
canvas.height = 256;
|
|
|
|
|
const context = canvas.getContext('2d');
|
|
|
|
|
const texture = resources.own('texture', new THREE.CanvasTexture(canvas));
|
|
|
|
|
texture.colorSpace = THREE.SRGBColorSpace;
|
|
|
|
|
const material = resources.own('material', new THREE.MeshBasicMaterial({
|
|
|
|
|
texture.generateMipmaps = false;
|
|
|
|
|
texture.minFilter = THREE.LinearFilter;
|
|
|
|
|
const faceMaterial = resources.own('material', new THREE.MeshBasicMaterial({
|
|
|
|
|
color: 0xffffff,
|
|
|
|
|
transparent: true,
|
|
|
|
|
opacity: 0.96,
|
|
|
|
|
opacity: 0,
|
|
|
|
|
side: THREE.DoubleSide,
|
|
|
|
|
map: texture,
|
|
|
|
|
toneMapped: true,
|
|
|
|
|
}));
|
|
|
|
|
const panel = new THREE.Mesh(geometry, material);
|
|
|
|
|
group.add(panel);
|
|
|
|
|
const face = new THREE.Mesh(
|
|
|
|
|
resources.own('geometry', new THREE.PlaneGeometry(7.72, 1.87)),
|
|
|
|
|
faceMaterial,
|
|
|
|
|
);
|
|
|
|
|
face.name = 'sign-face';
|
|
|
|
|
face.position.z = 0.132;
|
|
|
|
|
face.userData.textureUpdateCount = 0;
|
|
|
|
|
group.add(face);
|
|
|
|
|
|
|
|
|
|
const thrusterGeometry = resources.own('geometry', new THREE.ConeGeometry(0.18, 0.72, 8));
|
|
|
|
|
const thrusterMaterial = resources.own('material', new THREE.MeshBasicMaterial({ color: 0x44e8f5 }));
|
|
|
|
|
for (const x of [-3.5, 3.5]) {
|
|
|
|
|
const thruster = new THREE.Mesh(thrusterGeometry, thrusterMaterial);
|
|
|
|
|
thruster.rotation.x = Math.PI / 2;
|
|
|
|
|
thruster.position.set(x, -1.22, 0.08);
|
|
|
|
|
group.add(thruster);
|
|
|
|
|
const frame = new THREE.Group();
|
|
|
|
|
frame.name = 'sign-frame';
|
|
|
|
|
const horizontalGeometry = resources.own('geometry', new THREE.BoxGeometry(8.42, 0.12, 0.32));
|
|
|
|
|
const verticalGeometry = resources.own('geometry', new THREE.BoxGeometry(0.12, 2.35, 0.32));
|
|
|
|
|
for (const y of [-1.175, 1.175]) {
|
|
|
|
|
const rail = new THREE.Mesh(horizontalGeometry, frameMaterial);
|
|
|
|
|
rail.position.y = y;
|
|
|
|
|
frame.add(rail);
|
|
|
|
|
}
|
|
|
|
|
for (const x of [-4.15, 4.15]) {
|
|
|
|
|
const rail = new THREE.Mesh(verticalGeometry, frameMaterial);
|
|
|
|
|
rail.position.x = x;
|
|
|
|
|
frame.add(rail);
|
|
|
|
|
}
|
|
|
|
|
group.add(frame);
|
|
|
|
|
|
|
|
|
|
const postGeometry = resources.own('geometry', new THREE.BoxGeometry(0.17, 3.5, 0.17));
|
|
|
|
|
for (const [x, side] of [[-2.9, 'left'], [2.9, 'right']] as const) {
|
|
|
|
|
const post = new THREE.Mesh(postGeometry, postMaterial);
|
|
|
|
|
post.name = `sign-post-${side}`;
|
|
|
|
|
post.position.set(x, -2.86, -0.08);
|
|
|
|
|
group.add(post);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fadeMaterials = [backplateMaterial, frameMaterial, postMaterial, faceMaterial];
|
|
|
|
|
let target: SectionId | null = null;
|
|
|
|
|
let narrowViewport = false;
|
|
|
|
|
|
|
|
|
|
const updateTexture = (id: SectionId): void => {
|
|
|
|
|
if (target === id) return;
|
|
|
|
|
target = id;
|
|
|
|
|
if (!context) return;
|
|
|
|
|
context.fillStyle = '#07111b';
|
|
|
|
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
|
context.strokeStyle = '#35ddeb';
|
|
|
|
|
context.lineWidth = 7;
|
|
|
|
|
context.strokeRect(8, 8, canvas.width - 16, canvas.height - 16);
|
|
|
|
|
context.fillStyle = '#e8fbff';
|
|
|
|
|
context.font = '600 46px ui-monospace, monospace';
|
|
|
|
|
context.textAlign = 'center';
|
|
|
|
|
context.textBaseline = 'middle';
|
|
|
|
|
context.fillText(routeLabelFor(id), canvas.width / 2, canvas.height / 2);
|
|
|
|
|
face.userData.textureUpdateCount += 1;
|
|
|
|
|
if (context) drawLabel(context, routeLabelFor(id), canvas.width, canvas.height);
|
|
|
|
|
texture.needsUpdate = true;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const applyOpacity = (opacity: number): void => {
|
|
|
|
|
for (const material of fadeMaterials) material.opacity = opacity;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
group,
|
|
|
|
|
update(sign: SignSnapshot | null) {
|
|
|
|
|
group.visible = sign !== null && sign.opacity > 0;
|
|
|
|
|
if (!sign) return;
|
|
|
|
|
setViewportAspect(aspect) {
|
|
|
|
|
narrowViewport = !Number.isFinite(aspect) || aspect < 0.85;
|
|
|
|
|
},
|
|
|
|
|
update(sign) {
|
|
|
|
|
if (!sign) {
|
|
|
|
|
group.visible = false;
|
|
|
|
|
applyOpacity(0);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateTexture(sign.targetId);
|
|
|
|
|
material.opacity = sign.opacity;
|
|
|
|
|
const phaseOffset = sign.phase === 'anticipate'
|
|
|
|
|
? -30
|
|
|
|
|
: sign.phase === 'approach'
|
|
|
|
|
? -26 + sign.progress * 21
|
|
|
|
|
: sign.phase === 'dwell'
|
|
|
|
|
? -5
|
|
|
|
|
: -5 + sign.progress * 18;
|
|
|
|
|
group.position.set(7.2, 4.1, phaseOffset);
|
|
|
|
|
group.rotation.y = -0.12;
|
|
|
|
|
const opacity = clamp01(sign.opacity);
|
|
|
|
|
applyOpacity(opacity);
|
|
|
|
|
group.visible = opacity > 0;
|
|
|
|
|
const progress = smoothstep(clamp01(sign.progress));
|
|
|
|
|
const dwell = narrowViewport
|
|
|
|
|
? { x: 3.1, y: 3.25, z: -20, scale: 0.68 }
|
|
|
|
|
: { x: 8.25, y: 3.1, z: -8.5, scale: 1 };
|
|
|
|
|
const horizon = narrowViewport
|
|
|
|
|
? { x: 4.2, y: 4.1, z: -88, scale: 0.2 }
|
|
|
|
|
: { x: 12.4, y: 5.3, z: -92, scale: 0.24 };
|
|
|
|
|
|
|
|
|
|
if (sign.phase === 'anticipate') {
|
|
|
|
|
group.position.set(horizon.x, horizon.y, horizon.z);
|
|
|
|
|
group.scale.setScalar(horizon.scale);
|
|
|
|
|
} else if (sign.phase === 'approach') {
|
|
|
|
|
group.position.set(
|
|
|
|
|
THREE.MathUtils.lerp(horizon.x, dwell.x, progress),
|
|
|
|
|
THREE.MathUtils.lerp(horizon.y, dwell.y, progress),
|
|
|
|
|
THREE.MathUtils.lerp(horizon.z, dwell.z, progress),
|
|
|
|
|
);
|
|
|
|
|
group.scale.setScalar(THREE.MathUtils.lerp(horizon.scale, dwell.scale, progress));
|
|
|
|
|
} else if (sign.phase === 'dwell') {
|
|
|
|
|
group.position.set(dwell.x, dwell.y, dwell.z);
|
|
|
|
|
group.scale.setScalar(dwell.scale);
|
|
|
|
|
} else {
|
|
|
|
|
group.position.set(
|
|
|
|
|
dwell.x + progress * (narrowViewport ? 1.4 : 3.2),
|
|
|
|
|
dwell.y + progress * 0.7,
|
|
|
|
|
THREE.MathUtils.lerp(dwell.z, 17, progress),
|
|
|
|
|
);
|
|
|
|
|
group.scale.setScalar(dwell.scale * THREE.MathUtils.lerp(1, 1.08, progress));
|
|
|
|
|
}
|
|
|
|
|
group.rotation.y = narrowViewport ? -0.08 : -0.16;
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function drawLabel(
|
|
|
|
|
context: CanvasRenderingContext2D,
|
|
|
|
|
label: string,
|
|
|
|
|
width: number,
|
|
|
|
|
height: number,
|
|
|
|
|
): void {
|
|
|
|
|
context.clearRect(0, 0, width, height);
|
|
|
|
|
context.fillStyle = '#07111b';
|
|
|
|
|
context.strokeStyle = '#56d4df';
|
|
|
|
|
context.lineWidth = 4;
|
|
|
|
|
context.beginPath();
|
|
|
|
|
if (typeof context.roundRect === 'function') {
|
|
|
|
|
context.roundRect(14, 14, width - 28, height - 28, 12);
|
|
|
|
|
} else {
|
|
|
|
|
context.rect(14, 14, width - 28, height - 28);
|
|
|
|
|
}
|
|
|
|
|
context.fill();
|
|
|
|
|
context.stroke();
|
|
|
|
|
context.fillStyle = '#eef7f8';
|
|
|
|
|
context.font = '600 58px "Geist Mono Variable", ui-monospace, monospace';
|
|
|
|
|
context.textAlign = 'center';
|
|
|
|
|
context.textBaseline = 'middle';
|
|
|
|
|
context.fillText(label, width / 2, height / 2 + 2, width - 88);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clamp01(value: number): number {
|
|
|
|
|
return Number.isFinite(value) ? THREE.MathUtils.clamp(value, 0, 1) : 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function smoothstep(value: number): number {
|
|
|
|
|
return value * value * (3 - 2 * value);
|
|
|
|
|
}
|
|
|
|
|
|