migrate to CSS Modules

This commit is contained in:
Brian Beck 2026-03-01 09:40:17 -08:00
parent c5b43f2e55
commit d9be5c1eba
51 changed files with 1684 additions and 1630 deletions

View file

@ -0,0 +1,36 @@
.Root {
composes: IconButton from "./InspectorControls.module.css";
composes: LabelledButton from "./InspectorControls.module.css";
}
.Root[data-copied="true"] {
background: rgba(0, 117, 213, 0.9);
border-color: rgba(255, 255, 255, 0.4);
}
.ClipboardCheck {
display: none;
opacity: 1;
}
.Root[data-copied="true"] .ClipboardCheck {
display: block;
animation: showClipboardCheck 220ms linear infinite;
}
.Root[data-copied="true"] .MapPin {
display: none;
}
.ButtonLabel {
composes: ButtonLabel from "./InspectorControls.module.css";
}
@keyframes showClipboardCheck {
0% {
opacity: 1;
}
100% {
opacity: 0.2;
}
}

View file

@ -3,6 +3,7 @@ import { FaMapPin } from "react-icons/fa";
import { FaClipboardCheck } from "react-icons/fa6";
import { Camera, Quaternion, Vector3 } from "three";
import { useSettings } from "./SettingsProvider";
import styles from "./CopyCoordinatesButton.module.css";
function encodeViewHash({
position,
@ -55,16 +56,16 @@ export function CopyCoordinatesButton({
return (
<button
type="button"
className="IconButton LabelledButton CopyCoordinatesButton"
className={styles.Root}
aria-label="Copy coordinates URL"
title="Copy coordinates URL"
onClick={handleCopyLink}
data-copied={showCopied ? "true" : "false"}
id="copyCoordinatesButton"
>
<FaMapPin className="MapPin" />
<FaClipboardCheck className="ClipboardCheck" />
<span className="ButtonLabel"> Copy coordinates URL</span>
<FaMapPin className={styles.MapPin} />
<FaClipboardCheck className={styles.ClipboardCheck} />
<span className={styles.ButtonLabel}> Copy coordinates URL</span>
</button>
);
}

View file

@ -0,0 +1,23 @@
.StatsPanel {
left: auto !important;
top: auto !important;
right: 0;
bottom: 0;
}
.AxisLabel {
font-size: 12px;
pointer-events: none;
}
.AxisLabel[data-axis="x"] {
color: rgb(255, 153, 0);
}
.AxisLabel[data-axis="y"] {
color: rgb(153, 255, 0);
}
.AxisLabel[data-axis="z"] {
color: rgb(0, 153, 255);
}

View file

@ -2,6 +2,7 @@ import { Stats, Html } from "@react-three/drei";
import { useDebug } from "./SettingsProvider";
import { useEffect, useRef } from "react";
import { AxesHelper } from "three";
import styles from "./DebugElements.module.css";
export function DebugElements() {
const { debugMode } = useDebug();
@ -17,7 +18,7 @@ export function DebugElements() {
return debugMode ? (
<>
<Stats className="StatsPanel" />
<Stats className={styles.StatsPanel} />
<axesHelper ref={axesRef} args={[70]} renderOrder={999}>
<lineBasicMaterial
depthTest={false}
@ -27,17 +28,17 @@ export function DebugElements() {
/>
</axesHelper>
<Html position={[80, 0, 0]} center>
<span className="AxisLabel" data-axis="y">
<span className={styles.AxisLabel} data-axis="y">
Y
</span>
</Html>
<Html position={[0, 80, 0]} center>
<span className="AxisLabel" data-axis="z">
<span className={styles.AxisLabel} data-axis="z">
Z
</span>
</Html>
<Html position={[0, 0, 80]} center>
<span className="AxisLabel" data-axis="x">
<span className={styles.AxisLabel} data-axis="x">
X
</span>
</Html>

View file

@ -0,0 +1,111 @@
.Root {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.7);
color: #fff;
font-size: 13px;
z-index: 2;
}
.PlayPause {
width: 32px;
height: 32px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 4px;
background: rgba(3, 82, 147, 0.6);
color: #fff;
font-size: 14px;
cursor: pointer;
}
@media (hover: hover) {
.PlayPause:hover {
background: rgba(0, 98, 179, 0.8);
}
}
.Time {
flex-shrink: 0;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.Seek[type="range"] {
flex: 1 1 0;
min-width: 0;
max-width: none;
}
.Speed {
flex-shrink: 0;
padding: 2px 4px;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 3px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 12px;
}
.DiagnosticsPanel {
display: flex;
flex-direction: column;
gap: 3px;
margin-left: 8px;
padding: 4px 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
background: rgba(0, 0, 0, 0.55);
min-width: 320px;
}
.DiagnosticsPanel[data-context-lost="true"] {
border-color: rgba(255, 90, 90, 0.8);
background: rgba(70, 0, 0, 0.45);
}
.DiagnosticsStatus {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
}
.DiagnosticsMetrics {
display: flex;
flex-wrap: wrap;
gap: 4px 10px;
font-size: 11px;
opacity: 0.92;
}
.DiagnosticsFooter {
display: flex;
flex-wrap: wrap;
gap: 4px 8px;
align-items: center;
font-size: 11px;
}
.DiagnosticsFooter button {
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 3px;
background: rgba(3, 82, 147, 0.6);
color: #fff;
padding: 1px 6px;
font-size: 11px;
cursor: pointer;
}
.DiagnosticsFooter button:hover {
background: rgba(0, 98, 179, 0.8);
}

View file

@ -13,6 +13,7 @@ import {
useEngineSelector,
useEngineStoreApi,
} from "../state";
import styles from "./DemoControls.module.css";
const SPEED_OPTIONS = [0.25, 0.5, 1, 2, 4];
@ -89,23 +90,23 @@ export function DemoControls() {
return (
<div
className="DemoControls"
className={styles.Root}
onKeyDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<button
className="DemoControls-playPause"
className={styles.PlayPause}
onClick={isPlaying ? pause : play}
aria-label={isPlaying ? "Pause" : "Play"}
>
{isPlaying ? "\u275A\u275A" : "\u25B6"}
</button>
<span className="DemoControls-time">
<span className={styles.Time}>
{`${formatTime(currentTime)} / ${formatTime(duration)}`}
</span>
<input
className="DemoControls-seek"
className={styles.Seek}
type="range"
min={0}
max={duration}
@ -114,7 +115,7 @@ export function DemoControls() {
onChange={handleSeek}
/>
<select
className="DemoControls-speed"
className={styles.Speed}
value={speed}
onChange={handleSpeedChange}
>
@ -125,13 +126,13 @@ export function DemoControls() {
))}
</select>
<div
className="DemoDiagnosticsPanel"
className={styles.DiagnosticsPanel}
data-context-lost={webglContextLost ? "true" : undefined}
>
<div className="DemoDiagnosticsPanel-status">
<div className={styles.DiagnosticsStatus}>
{webglContextLost ? "WebGL context: LOST" : "WebGL context: ok"}
</div>
<div className="DemoDiagnosticsPanel-metrics">
<div className={styles.DiagnosticsMetrics}>
{latestRendererSample ? (
<>
<span>
@ -153,7 +154,7 @@ export function DemoControls() {
<span>No renderer samples yet</span>
)}
</div>
<div className="DemoDiagnosticsPanel-footer">
<div className={styles.DiagnosticsFooter}>
<span>
samples {rendererSampleCount} events {playbackEventCount}
</span>

View file

@ -0,0 +1,9 @@
.Label {
background: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 11px;
white-space: nowrap;
padding: 1px 3px;
border-radius: 1px;
text-align: center;
}

View file

@ -2,6 +2,7 @@ import { memo, ReactNode, useRef, useState } from "react";
import { Object3D, Vector3 } from "three";
import { useFrame } from "@react-three/fiber";
import { Html } from "@react-three/drei";
import styles from "./FloatingLabel.module.css";
const DEFAULT_POSITION = [0, 0, 0] as [x: number, y: number, z: number];
const _worldPos = new Vector3();
@ -15,7 +16,9 @@ function isBehindCamera(
): boolean {
const e = camera.matrixWorld.elements;
// Dot product of (objectPos - cameraPos) with camera forward (-Z column).
return (wx - e[12]) * -e[8] + (wy - e[13]) * -e[9] + (wz - e[14]) * -e[10] < 0;
return (
(wx - e[12]) * -e[8] + (wy - e[13]) * -e[9] + (wz - e[14]) * -e[10] < 0
);
}
export const FloatingLabel = memo(function FloatingLabel({
@ -39,10 +42,17 @@ export const FloatingLabel = memo(function FloatingLabel({
if (!group) return;
group.getWorldPosition(_worldPos);
const behind = isBehindCamera(camera, _worldPos.x, _worldPos.y, _worldPos.z);
const behind = isBehindCamera(
camera,
_worldPos.x,
_worldPos.y,
_worldPos.z,
);
if (fadeWithDistance) {
const distance = behind ? Infinity : camera.position.distanceTo(_worldPos);
const distance = behind
? Infinity
: camera.position.distanceTo(_worldPos);
const shouldBeVisible = distance < 200;
if (isVisible !== shouldBeVisible) {
@ -69,7 +79,7 @@ export const FloatingLabel = memo(function FloatingLabel({
<group ref={groupRef}>
{isVisible ? (
<Html position={position} center>
<div ref={labelRef} className="StaticShapeLabel" style={{ color }}>
<div ref={labelRef} className={styles.Label} style={{ color }}>
{children}
</div>
</Html>

View file

@ -0,0 +1,175 @@
.Controls {
position: fixed;
top: 0;
left: 0;
background: rgba(0, 0, 0, 0.5);
color: #fff;
padding: 8px 12px 8px 8px;
border-radius: 0 0 4px 0;
font-size: 13px;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
}
.Dropdown {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
}
.Group {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
}
.CheckboxField,
.LabelledButton {
display: flex;
align-items: center;
gap: 6px;
}
.Field {
display: flex;
align-items: center;
gap: 6px;
}
.IconButton {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
margin: 0 0 0 -12px;
font-size: 15px;
padding: 0;
border-top: 1px solid rgba(255, 255, 255, 0.3);
border-left: 1px solid rgba(255, 255, 255, 0.3);
border-right: 1px solid rgba(200, 200, 200, 0.3);
border-bottom: 1px solid rgba(200, 200, 200, 0.3);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
border-radius: 4px;
background: rgba(3, 82, 147, 0.6);
color: #fff;
cursor: pointer;
transform: translate(0, 0);
transition:
background 0.2s,
border-color 0.2s;
}
.IconButton svg {
pointer-events: none;
}
@media (hover: hover) {
.IconButton:hover {
background: rgba(0, 98, 179, 0.8);
border-color: rgba(255, 255, 255, 0.4);
}
}
.IconButton:active,
.IconButton[aria-expanded="true"] {
background: rgba(0, 98, 179, 0.7);
border-color: rgba(255, 255, 255, 0.3);
transform: translate(0, 1px);
}
.IconButton[data-active="true"] {
background: rgba(0, 117, 213, 0.9);
border-color: rgba(255, 255, 255, 0.4);
}
.ButtonLabel {
font-size: 12px;
}
.Toggle {
composes: IconButton;
margin: 0;
}
.MapInfoButton {
composes: IconButton;
composes: LabelledButton;
}
.MissionSelectWrapper {
}
@media (max-width: 1279px) {
.Dropdown[data-open="false"] {
display: none;
}
.Dropdown {
position: absolute;
top: calc(100% + 2px);
left: 2px;
right: 2px;
display: flex;
overflow: auto;
max-height: calc(100dvh - 56px);
flex-direction: column;
align-items: center;
gap: 12px;
background: rgba(0, 0, 0, 0.8);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
padding: 12px;
box-shadow: 0 0 12px rgba(0, 0, 0, 0.4);
}
.Group {
flex-wrap: wrap;
gap: 12px 20px;
}
.LabelledButton {
width: auto;
padding: 0 10px;
}
}
@media (max-width: 639px) {
.Controls {
right: 0;
border-radius: 0;
}
.MissionSelectWrapper {
flex: 1 1 0;
min-width: 0;
}
.MissionSelectWrapper input {
width: 100%;
}
.Toggle {
flex: 0 0 auto;
}
}
@media (min-width: 1280px) {
.Toggle {
display: none;
}
.LabelledButton .ButtonLabel {
display: none;
}
.MapInfoButton {
display: none;
}
}

View file

@ -11,6 +11,7 @@ import { CopyCoordinatesButton } from "./CopyCoordinatesButton";
import { LoadDemoButton } from "./LoadDemoButton";
import { useDemoRecording } from "./DemoProvider";
import { FiInfo, FiSettings } from "react-icons/fi";
import styles from "./InspectorControls.module.css";
export function InspectorControls({
missionName,
@ -79,20 +80,23 @@ export function InspectorControls({
return (
<div
id="controls"
className={styles.Controls}
onKeyDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<MissionSelect
value={missionName}
missionType={missionType}
onChange={onChangeMission}
disabled={isDemoLoaded}
/>
<div className={styles.MissionSelectWrapper}>
<MissionSelect
value={missionName}
missionType={missionType}
onChange={onChangeMission}
disabled={isDemoLoaded}
/>
</div>
<div ref={focusAreaRef}>
<button
ref={buttonRef}
className="IconButton Controls-toggle"
className={styles.Toggle}
onClick={() => {
setSettingsOpen((isOpen) => !isOpen);
}}
@ -103,7 +107,7 @@ export function InspectorControls({
<FiSettings />
</button>
<div
className="Controls-dropdown"
className={styles.Dropdown}
ref={dropdownRef}
id="settingsPanel"
tabIndex={-1}
@ -111,7 +115,7 @@ export function InspectorControls({
onBlur={handleDropdownBlur}
data-open={settingsOpen}
>
<div className="Controls-group">
<div className={styles.Group}>
<CopyCoordinatesButton
cameraRef={cameraRef}
missionName={missionName}
@ -120,16 +124,16 @@ export function InspectorControls({
<LoadDemoButton />
<button
type="button"
className="IconButton LabelledButton MapInfoButton"
className={styles.MapInfoButton}
aria-label="Show map info"
onClick={onOpenMapInfo}
>
<FiInfo />
<span className="ButtonLabel">Show map info</span>
<span className={styles.ButtonLabel}>Show map info</span>
</button>
</div>
<div className="Controls-group">
<div className="CheckboxField">
<div className={styles.Group}>
<div className={styles.CheckboxField}>
<input
id="fogInput"
type="checkbox"
@ -140,7 +144,7 @@ export function InspectorControls({
/>
<label htmlFor="fogInput">Fog?</label>
</div>
<div className="CheckboxField">
<div className={styles.CheckboxField}>
<input
id="audioInput"
type="checkbox"
@ -152,8 +156,8 @@ export function InspectorControls({
<label htmlFor="audioInput">Audio?</label>
</div>
</div>
<div className="Controls-group">
<div className="CheckboxField">
<div className={styles.Group}>
<div className={styles.CheckboxField}>
<input
id="animationInput"
type="checkbox"
@ -164,7 +168,7 @@ export function InspectorControls({
/>
<label htmlFor="animationInput">Animation?</label>
</div>
<div className="CheckboxField">
<div className={styles.CheckboxField}>
<input
id="debugInput"
type="checkbox"
@ -176,8 +180,8 @@ export function InspectorControls({
<label htmlFor="debugInput">Debug?</label>
</div>
</div>
<div className="Controls-group">
<div className="Field">
<div className={styles.Group}>
<div className={styles.Field}>
<label htmlFor="fovInput">FOV</label>
<input
id="fovInput"
@ -191,7 +195,7 @@ export function InspectorControls({
/>
<output htmlFor="fovInput">{fov}</output>
</div>
<div className="Field">
<div className={styles.Field}>
<label htmlFor="speedInput">Speed</label>
<input
id="speedInput"
@ -208,8 +212,8 @@ export function InspectorControls({
</div>
</div>
{isTouch && (
<div className="Controls-group">
<div className="Field">
<div className={styles.Group}>
<div className={styles.Field}>
<label htmlFor="touchModeInput">Joystick:</label>{" "}
<select
id="touchModeInput"

View file

@ -0,0 +1,55 @@
.Root {
position: fixed;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: flex-end;
gap: 10px;
pointer-events: none;
z-index: 1;
}
.Column {
display: flex;
gap: 4px;
flex-direction: column;
justify-content: center;
}
.Row {
display: flex;
gap: 4px;
justify-content: stretch;
}
.Spacer {
width: 32px;
}
.Key {
min-width: 32px;
height: 32px;
display: flex;
flex: 1 0 0;
align-items: center;
justify-content: center;
padding: 0 8px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.5);
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.Key[data-pressed="true"] {
background: rgba(52, 187, 171, 0.6);
border-color: rgba(35, 253, 220, 0.5);
color: #fff;
}
.Arrow {
margin-right: 3px;
}

View file

@ -1,6 +1,7 @@
import { useKeyboardControls } from "@react-three/drei";
import { Controls } from "./ObserverControls";
import { useDemoRecording } from "./DemoProvider";
import styles from "./KeyboardOverlay.module.css";
export function KeyboardOverlay() {
const recording = useDemoRecording();
@ -18,55 +19,55 @@ export function KeyboardOverlay() {
if (recording) return null;
return (
<div className="KeyboardOverlay">
<div className="KeyboardOverlay-column">
<div className="KeyboardOverlay-row">
<div className="KeyboardOverlay-spacer" />
<div className="KeyboardOverlay-key" data-pressed={forward}>
<div className={styles.Root}>
<div className={styles.Column}>
<div className={styles.Row}>
<div className={styles.Spacer} />
<div className={styles.Key} data-pressed={forward}>
W
</div>
<div className="KeyboardOverlay-spacer" />
<div className={styles.Spacer} />
</div>
<div className="KeyboardOverlay-row">
<div className="KeyboardOverlay-key" data-pressed={left}>
<div className={styles.Row}>
<div className={styles.Key} data-pressed={left}>
A
</div>
<div className="KeyboardOverlay-key" data-pressed={backward}>
<div className={styles.Key} data-pressed={backward}>
S
</div>
<div className="KeyboardOverlay-key" data-pressed={right}>
<div className={styles.Key} data-pressed={right}>
D
</div>
</div>
</div>
<div className="KeyboardOverlay-column">
<div className="KeyboardOverlay-row">
<div className="KeyboardOverlay-key" data-pressed={up}>
<span className="KeyboardOverlay-arrow">&uarr;</span> Space
<div className={styles.Column}>
<div className={styles.Row}>
<div className={styles.Key} data-pressed={up}>
<span className={styles.Arrow}>&uarr;</span> Space
</div>
</div>
<div className="KeyboardOverlay-row">
<div className="KeyboardOverlay-key" data-pressed={down}>
<span className="KeyboardOverlay-arrow">&darr;</span> Shift
<div className={styles.Row}>
<div className={styles.Key} data-pressed={down}>
<span className={styles.Arrow}>&darr;</span> Shift
</div>
</div>
</div>
<div className="KeyboardOverlay-column">
<div className="KeyboardOverlay-row">
<div className="KeyboardOverlay-spacer" />
<div className="KeyboardOverlay-key" data-pressed={lookUp}>
<div className={styles.Column}>
<div className={styles.Row}>
<div className={styles.Spacer} />
<div className={styles.Key} data-pressed={lookUp}>
&uarr;
</div>
<div className="KeyboardOverlay-spacer" />
<div className={styles.Spacer} />
</div>
<div className="KeyboardOverlay-row">
<div className="KeyboardOverlay-key" data-pressed={lookLeft}>
<div className={styles.Row}>
<div className={styles.Key} data-pressed={lookLeft}>
&larr;
</div>
<div className="KeyboardOverlay-key" data-pressed={lookDown}>
<div className={styles.Key} data-pressed={lookDown}>
&darr;
</div>
<div className="KeyboardOverlay-key" data-pressed={lookRight}>
<div className={styles.Key} data-pressed={lookRight}>
&rarr;
</div>
</div>

View file

@ -0,0 +1,12 @@
.Root {
composes: IconButton from "./InspectorControls.module.css";
composes: LabelledButton from "./InspectorControls.module.css";
}
.ButtonLabel {
composes: ButtonLabel from "./InspectorControls.module.css";
}
.DemoIcon {
font-size: 19px;
}

View file

@ -2,6 +2,7 @@ import { useCallback, useRef } from "react";
import { MdOndemandVideo } from "react-icons/md";
import { useDemoActions, useDemoRecording } from "./DemoProvider";
import { createDemoStreamingRecording } from "../demo/streaming";
import styles from "./LoadDemoButton.module.css";
export function LoadDemoButton() {
const recording = useDemoRecording();
@ -53,14 +54,14 @@ export function LoadDemoButton() {
/>
<button
type="button"
className="IconButton LabelledButton"
className={styles.Root}
aria-label={recording ? "Unload demo" : "Load demo (.rec)"}
title={recording ? "Unload demo" : "Load demo (.rec)"}
onClick={handleClick}
data-active={recording ? "true" : undefined}
>
<MdOndemandVideo className="DemoIcon" />
<span className="ButtonLabel">
<MdOndemandVideo className={styles.DemoIcon} />
<span className={styles.ButtonLabel}>
{recording ? "Unload demo" : "Demo"}
</span>
</button>

View file

@ -0,0 +1,181 @@
.InputWrapper {
position: relative;
display: flex;
align-items: center;
}
.Shortcut {
position: absolute;
right: 7px;
font-family: system-ui, sans-serif;
font-size: 11px;
padding: 1px 4px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.15);
color: rgba(255, 255, 255, 0.6);
pointer-events: none;
}
.Input[aria-expanded="true"] ~ .Shortcut {
display: none;
}
.Input {
width: 280px;
padding: 6px 36px 6px 8px;
font-size: 14px;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 3px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
outline: none;
user-select: text;
}
.Input[aria-expanded="true"] {
padding-right: 8px;
}
.Input:focus {
border-color: rgba(255, 255, 255, 0.6);
}
.Input::placeholder {
color: transparent;
}
.SelectedValue {
position: absolute;
left: 8px;
right: 36px;
display: flex;
align-items: center;
gap: 6px;
pointer-events: none;
overflow: hidden;
}
.Input[aria-expanded="true"] ~ .SelectedValue {
display: none;
}
.SelectedName {
color: #fff;
font-weight: 600;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 1;
min-width: 0;
}
.SelectedValue > .ItemType {
flex-shrink: 0;
}
.Popover {
z-index: 100;
min-width: 320px;
max-height: var(--popover-available-height, 90vh);
overflow-y: auto;
overscroll-behavior: contain;
background: rgba(20, 20, 20, 0.95);
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 3px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
}
.List {
padding: 4px 0;
}
.List:has(> .Group:first-child) {
padding-top: 0;
}
.Group {
padding-bottom: 4px;
}
.GroupLabel {
position: sticky;
top: 0;
padding: 6px 8px 6px 12px;
font-size: 13px;
font-weight: 600;
color: rgb(198, 202, 202);
background: rgba(58, 69, 72, 0.95);
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
z-index: 1;
}
.Group:not(:last-child) {
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
}
.Item {
display: flex;
flex-direction: column;
gap: 1px;
margin: 4px 4px 0;
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
outline: none;
scroll-margin-top: 32px;
}
.List > .Item:first-child {
margin-top: 0;
}
.Item[data-active-item] {
background: rgba(255, 255, 255, 0.15);
}
.Item[aria-selected="true"] {
background: rgba(100, 150, 255, 0.3);
}
.ItemHeader {
display: flex;
align-items: center;
gap: 6px;
}
.ItemName {
font-size: 14px;
font-weight: 600;
color: #fff;
}
.ItemTypes {
display: flex;
gap: 3px;
}
.ItemType {
font-size: 10px;
font-weight: 600;
padding: 2px 5px;
border-radius: 3px;
background: rgba(255, 157, 0, 0.4);
color: #fff;
}
.ItemType:hover {
background: rgba(255, 157, 0, 0.7);
}
.ItemMissionName {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
}
.NoResults {
padding: 12px 8px;
font-size: 13px;
color: rgba(255, 255, 255, 0.5);
text-align: center;
}

View file

@ -19,6 +19,7 @@ import {
import { matchSorter } from "match-sorter";
import { getMissionInfo, getMissionList, getSourceAndPath } from "../manifest";
import orderBy from "lodash.orderby";
import styles from "./MissionSelect.module.css";
const excludeMissions = new Set([
"SkiFree",
@ -125,16 +126,16 @@ const isMac =
function MissionItemContent({ mission }: { mission: MissionItem }) {
return (
<>
<span className="MissionSelect-itemHeader">
<span className="MissionSelect-itemName">
<span className={styles.ItemHeader}>
<span className={styles.ItemName}>
{mission.displayName || mission.missionName}
</span>
{mission.missionTypes.length > 0 && (
<span className="MissionSelect-itemTypes">
<span className={styles.ItemTypes}>
{mission.missionTypes.map((type) => (
<span
key={type}
className="MissionSelect-itemType"
className={styles.ItemType}
data-mission-type={type}
>
{type}
@ -143,9 +144,7 @@ function MissionItemContent({ mission }: { mission: MissionItem }) {
</span>
)}
</span>
<span className="MissionSelect-itemMissionName">
{mission.missionName}
</span>
<span className={styles.ItemMissionName}>{mission.missionName}</span>
</>
);
}
@ -234,7 +233,7 @@ export function MissionSelect({
<ComboboxItem
key={mission.missionName}
value={mission.missionName}
className="MissionSelect-item"
className={styles.Item}
focusOnHover
onClick={(event) => {
if (event.target && event.target instanceof HTMLElement) {
@ -265,13 +264,13 @@ export function MissionSelect({
return (
<ComboboxProvider store={combobox}>
<div className="MissionSelect-inputWrapper">
<div className={styles.InputWrapper}>
<Combobox
ref={inputRef}
autoSelect
disabled={disabled}
placeholder={displayValue}
className="MissionSelect-input"
className={styles.Input}
onFocus={() => {
try {
document.exitPointerLock();
@ -284,35 +283,29 @@ export function MissionSelect({
}
}}
/>
<div className="MissionSelect-selectedValue">
<span className="MissionSelect-selectedName">{displayValue}</span>
<div className={styles.SelectedValue}>
<span className={styles.SelectedName}>{displayValue}</span>
{missionType && (
<span
className="MissionSelect-itemType"
data-mission-type={missionType}
>
<span className={styles.ItemType} data-mission-type={missionType}>
{missionType}
</span>
)}
</div>
<kbd className="MissionSelect-shortcut">{isMac ? "⌘K" : "^K"}</kbd>
<kbd className={styles.Shortcut}>{isMac ? "⌘K" : "^K"}</kbd>
</div>
<ComboboxPopover
gutter={4}
fitViewport
autoFocusOnHide={false}
className="MissionSelect-popover"
className={styles.Popover}
>
<ComboboxList className="MissionSelect-list">
<ComboboxList className={styles.List}>
{filteredResults.type === "flat"
? filteredResults.missions.map(renderItem)
: filteredResults.groups.map(([groupName, missions]) =>
groupName ? (
<ComboboxGroup
key={groupName}
className="MissionSelect-group"
>
<ComboboxGroupLabel className="MissionSelect-groupLabel">
<ComboboxGroup key={groupName} className={styles.Group}>
<ComboboxGroupLabel className={styles.GroupLabel}>
{groupName}
</ComboboxGroupLabel>
{missions.map(renderItem)}
@ -324,7 +317,7 @@ export function MissionSelect({
),
)}
{noResults && (
<div className="MissionSelect-noResults">No missions found</div>
<div className={styles.NoResults}>No missions found</div>
)}
</ComboboxList>
</ComboboxPopover>

View file

@ -0,0 +1,46 @@
.Root {
pointer-events: none;
display: inline-flex;
flex-direction: column;
align-items: center;
white-space: nowrap;
}
.Top {
composes: Root;
padding-bottom: 20px;
}
.Bottom {
composes: Root;
padding-top: 20px;
}
.IffArrow {
width: 12px;
height: 12px;
image-rendering: pixelated;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.7));
}
.Name {
color: #fff;
font-size: 11px;
text-shadow:
0 1px 3px rgba(0, 0, 0, 0.9),
0 0 1px rgba(0, 0, 0, 0.7);
}
.HealthBar {
width: 60px;
height: 4px;
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.2);
margin: 2px auto 0;
overflow: hidden;
}
.HealthFill {
height: 100%;
background: #2ecc40;
}

View file

@ -7,6 +7,7 @@ import { getKeyframeAtTime } from "../demo/demoPlaybackUtils";
import { textureToUrl } from "../loaders";
import { useStaticShape } from "./GenericShape";
import type { DemoEntity } from "../demo/types";
import styles from "./PlayerNameplate.module.css";
/** Max distance at which nameplates are visible. */
const NAMEPLATE_FADE_DISTANCE = 150;
@ -139,24 +140,21 @@ export function PlayerNameplate({
{isVisible && (
<>
<Html position={[0, iffHeight, 0]} center>
<div ref={iffContainerRef} className="PlayerNameplate PlayerTop">
<div ref={iffContainerRef} className={styles.Top}>
<img
ref={iffImgRef}
className="PlayerNameplate-iffArrow"
className={styles.IffArrow}
src={iffMarkerUrl}
alt=""
/>
</div>
</Html>
<Html position={[0, NAME_HEIGHT, 0]} center>
<div
ref={nameContainerRef}
className="PlayerNameplate PlayerBottom"
>
<div className="PlayerNameplate-name">{displayName}</div>
<div ref={nameContainerRef} className={styles.Bottom}>
<div className={styles.Name}>{displayName}</div>
{hasHealthData && (
<div className="PlayerNameplate-healthBar">
<div ref={fillRef} className="PlayerNameplate-healthFill" />
<div className={styles.HealthBar}>
<div ref={fillRef} className={styles.HealthFill} />
</div>
)}
</div>

View file

@ -0,0 +1,22 @@
.Joystick {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
width: 140px;
height: 140px;
z-index: 1;
}
.Left {
composes: Joystick;
left: 20px;
transform: none;
}
.Right {
composes: Joystick;
left: auto;
right: 20px;
transform: none;
}

View file

@ -3,6 +3,25 @@ import { Euler, Vector3 } from "three";
import { useFrame, useThree } from "@react-three/fiber";
import type nipplejs from "nipplejs";
import { useControls } from "./SettingsProvider";
import styles from "./TouchControls.module.css";
/** Apply styles to nipplejs-generated `.back` and `.front` elements imperatively. */
function applyNippleStyles(zone: HTMLElement) {
const back = zone.querySelector<HTMLElement>(".back");
if (back) {
back.style.background = "rgba(3, 79, 76, 0.6)";
back.style.border = "1px solid rgba(0, 219, 223, 0.5)";
back.style.boxShadow = "inset 0 0 10px rgba(0, 0, 0, 0.7)";
}
const front = zone.querySelector<HTMLElement>(".front");
if (front) {
front.style.background =
"radial-gradient(circle at 50% 50%, rgba(23, 247, 198, 0.9) 0%, rgba(9, 184, 170, 0.95) 100%)";
front.style.border = "2px solid rgba(255, 255, 255, 0.4)";
front.style.boxShadow =
"0 2px 4px rgba(0, 0, 0, 0.5), 0 1px 1px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.15), inset 0 -1px 2px rgba(0, 0, 0, 0.3)";
}
}
const BASE_SPEED = 80;
const LOOK_SENSITIVITY = 0.004;
@ -50,6 +69,8 @@ export function TouchJoystick({
restOpacity: 0.9,
});
applyNippleStyles(zone);
manager.on("move", (_event, data) => {
joystickState.current.angle = data.angle.radian;
joystickState.current.force = Math.min(1, data.force);
@ -86,6 +107,8 @@ export function TouchJoystick({
restOpacity: 0.9,
});
applyNippleStyles(zone);
manager.on("move", (_event, data) => {
lookJoystickState.current.angle = data.angle.radian;
lookJoystickState.current.force = Math.min(1, data.force);
@ -113,13 +136,13 @@ export function TouchJoystick({
<>
<div
ref={joystickZone}
className="TouchJoystick TouchJoystick--left"
className={styles.Left}
onContextMenu={(e) => e.preventDefault()}
onTouchStart={blurActiveElement}
/>
<div
ref={lookJoystickZone}
className="TouchJoystick TouchJoystick--right"
className={styles.Right}
onContextMenu={(e) => e.preventDefault()}
onTouchStart={blurActiveElement}
/>
@ -130,7 +153,7 @@ export function TouchJoystick({
return (
<div
ref={joystickZone}
className="TouchJoystick"
className={styles.Joystick}
onContextMenu={(e) => e.preventDefault()}
onTouchStart={blurActiveElement}
/>
@ -268,9 +291,7 @@ export function TouchCameraMovement({
camera.getWorldDirection(forwardVec.current);
forwardVec.current.normalize();
sideVec.current
.crossVectors(camera.up, forwardVec.current)
.normalize();
sideVec.current.crossVectors(camera.up, forwardVec.current).normalize();
moveVec.current
.set(0, 0, 0)
@ -288,9 +309,7 @@ export function TouchCameraMovement({
const speed = BASE_SPEED * speedMultiplier * 0.5;
camera.getWorldDirection(forwardVec.current);
forwardVec.current.normalize();
moveVec.current
.copy(forwardVec.current)
.multiplyScalar(speed * delta);
moveVec.current.copy(forwardVec.current).multiplyScalar(speed * delta);
camera.position.add(moveVec.current);
if (force >= SINGLE_STICK_DEADZONE) {