style cleanup, mobile layout improvements, ForceFieldBare fix

This commit is contained in:
Brian Beck 2026-03-24 23:50:43 -06:00
parent c15bce10f3
commit bb654b3667
98 changed files with 1010 additions and 835 deletions

View file

@ -0,0 +1,160 @@
@layer button {
.Button {
flex: 1 1 auto;
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: auto;
height: auto;
min-width: 28px;
min-height: 32px;
gap: 8px;
margin: 0;
font-family: inherit;
font-size: 18px;
font-weight: 500;
padding: 4px 8px;
border-radius: 5px;
background: rgba(0, 98, 179, 0.8);
border: 1px solid rgba(255, 255, 255, 0.4);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
color: #fff;
cursor: pointer;
transform: translate(0, 0);
transition:
background 0.2s,
border-color 0.2s;
}
.Button svg {
opacity: 0.8;
flex: 0 0 auto;
pointer-events: none;
transition: opacity 0.2s;
}
.Button:disabled {
opacity: 0.6;
box-shadow: inset 0 0 12px 1px rgba(54, 54, 54, 0.5);
color: rgba(255, 255, 255, 0.8);
cursor: default;
}
@media (hover: hover) {
.Button {
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);
background: rgba(3, 82, 147, 0.6);
}
.Button:not(:disabled):hover {
background: rgba(0, 98, 179, 0.8);
border-color: rgba(255, 255, 255, 0.4);
}
.Button svg {
opacity: 0.6;
}
.Button:not(:disabled):hover svg {
opacity: 1;
}
}
.Button:not(:disabled):active,
.Button[aria-expanded="true"] {
background: rgba(0, 98, 179, 0.7);
border-color: rgba(255, 255, 255, 0.3);
transform: translate(0, 1px);
}
.Button[data-active="true"] {
background: rgba(0, 117, 213, 0.9);
border-color: rgba(255, 255, 255, 0.4);
}
.Icon {
}
.ButtonLabel {
font-size: 14px;
}
.ButtonHint {
font-size: 10px;
opacity: 0.7;
}
.ButtonGroup {
width: 100%;
flex: 1 0 auto;
display: flex;
align-items: stretch;
}
.ButtonGroup .Button {
flex: 1 0 0;
flex-direction: column;
gap: 1px;
font-size: 22px;
padding-top: 8px;
padding-bottom: 8px;
background: rgba(27, 79, 135, 0.7);
box-shadow: inset 0 0 10px rgba(4, 40, 71, 0.5);
}
.ButtonGroup .Button svg {
margin-bottom: 3px;
}
.ButtonGroup .Button[data-active="true"] {
background: rgba(0, 101, 203, 0.8);
box-shadow:
inset 0 2px 3px rgba(8, 189, 255, 0.5),
inset 0 -2px 6px rgba(0, 18, 40, 0.5);
}
.ButtonGroup .Button[data-active="true"] svg {
opacity: 1;
}
.ButtonGroup .Button:not(:first-child) {
border-left: 0;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.ButtonGroup .Button:not(:last-child) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.DestructiveActionButton {
composes: Button;
flex: 0 0 auto;
margin: 0 10px 0 0;
font-size: 16px;
min-width: 28px;
min-height: 28px;
padding: 2px;
background: rgba(213, 66, 58, 0.7);
}
@media (hover: hover) {
.DestructiveActionButton:not(:disabled):hover {
background: rgba(229, 70, 59, 0.9);
}
}
@media (pointer: coarse) {
.DestructiveActionButton {
min-width: 36px;
min-height: 36px;
margin-right: 6px;
font-size: 20px;
}
}
}

View file

@ -0,0 +1,23 @@
import { RiLandscapeFill } from "react-icons/ri";
import styles from "./Button.module.css";
export function ChooseMapButton({
isActive = false,
onClick,
}: {
isActive?: boolean;
onClick?: () => void;
}) {
return (
<button
type="button"
className={styles.Button}
onClick={onClick}
data-active={isActive}
>
<RiLandscapeFill />
<span className={styles.ButtonLabel}>Explore</span>
<span className={styles.ButtonHint}>Browse maps</span>
</button>
);
}

View file

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

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 buttonStyles from "./Button.module.css";
import styles from "./CopyCoordinatesButton.module.css";
function encodeViewHash({
@ -58,7 +59,7 @@ export function CopyCoordinatesButton({
return (
<button
type="button"
className={styles.Root}
className={styles.Button}
aria-label="Link to coordinates"
title="Copy the current coordinates to URL"
onClick={handleCopyLink}
@ -66,9 +67,9 @@ export function CopyCoordinatesButton({
data-copied={showCopied ? "true" : "false"}
id="copyCoordinatesButton"
>
<FaMapPin className={styles.MapPin} />
<FaClipboardCheck className={styles.ClipboardCheck} />
<span className={styles.ButtonLabel}> Link to coordinates</span>
<FaMapPin className={styles.PinIcon} />
<FaClipboardCheck className={styles.ClipboardIcon} />
<span className={buttonStyles.ButtonLabel}> Link to coordinates</span>
</button>
);
}

View file

@ -2,30 +2,39 @@
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
padding: 8px 10px;
font-size: 13px;
z-index: 2;
}
@media (pointer: coarse) {
.Root {
padding: 6px 8px;
}
}
.PlayPause {
width: 32px;
height: 32px;
flex-shrink: 0;
composes: Button from "./Button.module.css";
width: 36px;
height: 36px;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.3);
font-size: 12px;
/* border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 4px;
background: rgba(3, 82, 147, 0.6);
color: #fff;
font-size: 11px;
cursor: pointer;
cursor: pointer; */
}
@media (hover: hover) {
.PlayPause:hover {
background: rgba(0, 98, 179, 0.8);
@media (pointer: coarse) {
.PlayPause {
width: 40px;
height: 40px;
font-size: 14px;
}
}
@ -35,6 +44,18 @@
white-space: nowrap;
}
@media (max-width: 599px) {
.Time {
font-size: 12px;
}
}
@media (max-width: 399px) {
.Time {
font-size: 11px;
}
}
.Seek[type="range"] {
flex: 1 0 20px;
width: 20px;
@ -53,6 +74,14 @@
font-size: 13px;
}
@media (pointer: coarse) {
.Speed,
.CameraMode {
font-size: 14px;
padding: 8px;
}
}
.Field {
display: flex;
align-items: center;

View file

@ -0,0 +1,24 @@
.Button {
composes: DestructiveActionButton from "./Button.module.css";
gap: 6px;
padding: 4px 10px 4px 6px;
margin: 0 10px 0 auto;
font-size: 20px;
}
.ButtonLabel {
composes: ButtonLabel from "./Button.module.css";
font-size: 13px;
font-weight: 500;
}
@media (max-width: 799px) {
.Button {
padding-right: 6px;
margin: 0 10px 0 0;
}
.Button .ButtonLabel {
display: none;
}
}

View file

@ -0,0 +1,16 @@
import { cameraTourStore } from "../state/cameraTourStore";
import { HiMiniArrowLeftEndOnRectangle } from "react-icons/hi2";
import styles from "./ExitTourButton.module.css";
export function ExitTourButton() {
return (
<button
type="button"
className={styles.Button}
onClick={() => cameraTourStore.getState().cancel()}
>
<HiMiniArrowLeftEndOnRectangle />
<span className={styles.ButtonLabel}>Exit tour</span>
</button>
);
}

View file

@ -2,6 +2,8 @@
position: relative;
max-width: calc(100dvw - 40px);
max-height: calc(100dvh - 40px);
min-width: 120px;
min-height: 120px;
background: rgba(20, 37, 38, 0.8);
border: 1px solid rgba(65, 131, 139, 0.6);
border-radius: 4px;

View file

@ -0,0 +1,17 @@
import { LoadingIndicator } from "./LoadingIndicator";
import styles from "./GameDialog.module.css";
export function GameDialogSpinner({ onClose }: { onClose?: () => void }) {
return (
<div
className={styles.Overlay}
onClick={(event) => {
onClose?.();
}}
>
<div className={styles.Dialog}>
<LoadingIndicator isLoading />
</div>
</div>
);
}

View file

@ -425,9 +425,12 @@ export function InputConsumer() {
for (const frame of frames) {
dYaw += frame.deltaYaw;
dPitch += frame.deltaPitch;
x = frame.x; // latest wins
y = frame.y;
z = frame.z;
// Use the value with the largest magnitude across all producers.
// This prevents a keyboard-only frame (x=0) from overwriting
// joystick movement from another producer in the same frame.
if (Math.abs(frame.x) > Math.abs(x)) x = frame.x;
if (Math.abs(frame.y) > Math.abs(y)) y = frame.y;
if (Math.abs(frame.z) > Math.abs(z)) z = frame.z;
frameDelta += frame.delta;
for (let i = 0; i < frame.triggers.length; i++) {
if (frame.triggers[i]) frameTriggers[i] = true;

View file

@ -13,50 +13,6 @@
gap: 0;
}
.ButtonGroup {
width: 100%;
flex: 1 0 auto;
display: flex;
align-items: stretch;
}
.ButtonGroup .IconButton {
flex: 1 0 0;
flex-direction: column;
gap: 1px;
font-size: 22px;
padding-top: 8px;
padding-bottom: 8px;
background: rgba(27, 79, 135, 0.7);
box-shadow: inset 0 0 10px rgba(4, 40, 71, 0.5);
}
.ButtonGroup .IconButton svg {
margin-bottom: 3px;
}
.ButtonGroup .IconButton[data-active="true"] {
background: rgba(0, 101, 203, 0.8);
box-shadow:
inset 0 2px 3px rgba(8, 189, 255, 0.5),
inset 0 -2px 6px rgba(0, 18, 40, 0.5);
}
.ButtonGroup .IconButton[data-active="true"] svg {
opacity: 1;
}
.ButtonGroup .IconButton:not(:first-child) {
border-left: 0;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.ButtonGroup .IconButton:not(:last-child) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.Group {
display: flex;
flex-wrap: wrap;
@ -65,12 +21,6 @@
gap: 20px;
}
.LabelledButton {
display: flex;
align-items: center;
gap: 6px;
}
.CheckboxField {
display: grid;
grid-template-columns: auto 1fr;
@ -161,100 +111,9 @@
grid-row: 2;
}
.IconButton {
flex: 1 1 auto;
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: auto;
height: auto;
min-width: 28px;
min-height: 32px;
gap: 8px;
margin: 0;
font-family: inherit;
font-size: 18px;
font-weight: 500;
padding: 4px 8px;
border-radius: 5px;
background: rgba(0, 98, 179, 0.8);
border: 1px solid rgba(255, 255, 255, 0.4);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
color: #fff;
cursor: pointer;
transform: translate(0, 0);
transition:
background 0.2s,
border-color 0.2s;
}
.IconButton svg {
opacity: 0.8;
flex: 0 0 auto;
pointer-events: none;
transition: opacity 0.2s;
}
.IconButton:disabled {
opacity: 0.6;
box-shadow: inset 0 0 12px 1px rgba(54, 54, 54, 0.5);
color: rgba(255, 255, 255, 0.8);
cursor: default;
}
@media (hover: hover) {
.IconButton {
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);
background: rgba(3, 82, 147, 0.6);
}
.IconButton:not(:disabled):hover {
background: rgba(0, 98, 179, 0.8);
border-color: rgba(255, 255, 255, 0.4);
}
.IconButton svg {
opacity: 0.6;
}
.IconButton:not(:disabled):hover svg {
opacity: 1;
}
}
.IconButton:not(:disabled):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: 14px;
}
.ButtonHint {
font-size: 10px;
opacity: 0.7;
}
.Toggle {
composes: IconButton;
margin: 0;
}
.MapInfoButton {
composes: IconButton;
composes: LabelledButton;
.MapInfoButton,
.ShowScoresButton {
composes: Button from "./Button.module.css";
}
.ForceRenderButton {
@ -269,6 +128,19 @@
cursor: pointer;
}
.CloseSidebarButton {
composes: Button from "./Button.module.css";
margin: 12px;
padding-top: 8px;
padding-bottom: 8px;
}
@media (min-width: 900px) {
.CloseSidebarButton {
display: none;
}
}
@media (pointer: coarse) {
.Tools {
padding-top: 12px;

View file

@ -1,7 +1,5 @@
import { useEffect, useState, useRef, RefObject, memo } from "react";
import { RiLandscapeFill } from "react-icons/ri";
import { FaRotateRight } from "react-icons/fa6";
import { LuClipboardList, LuUsers } from "react-icons/lu";
import { Camera } from "three";
import {
useControls,
@ -20,6 +18,10 @@ import { useRecording } from "./RecordingProvider";
import { useDataSource, useMissionName } from "../state/gameEntityStore";
import { useLiveSelector } from "../state/liveConnectionStore";
import { hasMission } from "../manifest";
import { ChooseMapButton } from "./ChooseMapButton";
import { MapInfoButton } from "./MapInfoButton";
import { ShowScoresButton } from "./ShowScoresButton";
import buttonStyles from "./Button.module.css";
import styles from "./InspectorControls.module.css";
const DEFAULT_PANELS = ["controls", "preferences", "audio", "timeline"];
@ -35,6 +37,7 @@ export const InspectorControls = memo(function InspectorControls({
choosingMap,
cameraRef,
invalidateRef,
onClose,
}: {
missionName: string;
missionType?: string;
@ -46,6 +49,7 @@ export const InspectorControls = memo(function InspectorControls({
choosingMap?: boolean;
cameraRef: RefObject<Camera | null>;
invalidateRef: RefObject<(() => void) | null>;
onClose: () => void;
}) {
const isTouch = useTouchDevice();
const dataSource = useDataSource();
@ -130,19 +134,13 @@ export const InspectorControls = memo(function InspectorControls({
data-open={settingsOpen}
>
<div className={styles.Tools}>
<div className={styles.ButtonGroup}>
<button
type="button"
className={styles.IconButton}
data-active={
(dataSource === "map" && !recording) || choosingMap
<div className={buttonStyles.ButtonGroup}>
<ChooseMapButton
isActive={
(dataSource === "map" && !recording) || (choosingMap ?? false)
}
onClick={onChooseMap}
>
<RiLandscapeFill />
<span className={styles.ButtonLabel}>Explore</span>
<span className={styles.ButtonHint}>Browse maps</span>
</button>
/>
<LoadDemoButton
isActive={!choosingMap && recording?.source === "demo"}
choosingMap={choosingMap}
@ -161,26 +159,9 @@ export const InspectorControls = memo(function InspectorControls({
cameraRef={cameraRef}
disabled={!missionInManifest}
/>
<button
type="button"
className={styles.MapInfoButton}
aria-label="Show map info"
onClick={onOpenMapInfo}
disabled={!missionInManifest}
>
<LuClipboardList />
<span className={styles.ButtonLabel}>Show map info</span>
</button>
<MapInfoButton missionName={missionName} onClick={onOpenMapInfo} />
{onOpenScoreScreen && (
<button
type="button"
className={styles.MapInfoButton}
aria-label="Show scores"
onClick={onOpenScoreScreen}
>
<LuUsers />
<span className={styles.ButtonLabel}>Show scores</span>
</button>
<ShowScoresButton onClick={onOpenScoreScreen} />
)}
</div>
<div className={styles.Accordions}>
@ -476,6 +457,9 @@ export const InspectorControls = memo(function InspectorControls({
</Accordion>
</AccordionGroup>
</div>
<button className={styles.CloseSidebarButton} onClick={onClose}>
<span className={buttonStyles.ButtonLabel}>Close</span>
</button>
</div>
</div>
</div>

View file

@ -1,23 +1,15 @@
.Root {
composes: IconButton from "./InspectorControls.module.css";
composes: LabelledButton from "./InspectorControls.module.css";
.JoinServerButton {
composes: Button from "./Button.module.css";
}
/* Text label ("Connect", "Connecting...") follows standard breakpoint rules. */
.TextLabel {
composes: ButtonLabel from "./InspectorControls.module.css";
.Icon {
composes: Icon from "./Button.module.css";
}
/* Ping label is always visible regardless of breakpoint. */
.PingLabel {
composes: ButtonLabel from "./InspectorControls.module.css";
display: flex !important;
margin-right: 2px;
}
.LiveIcon {
/* font-size: 15px; */
}
.Pulsing {
.JoinServerButton[data-connected="true"] .Icon {
animation: blink 1.2s ease-out infinite;
}
@keyframes blink {
0% {
opacity: 1;
@ -26,7 +18,3 @@
opacity: 0.25;
}
}
.ButtonHint {
composes: ButtonHint from "./InspectorControls.module.css";
}

View file

@ -1,6 +1,7 @@
import { BsFillLightningChargeFill } from "react-icons/bs";
import { cameraTourStore } from "../state/cameraTourStore";
import { useLiveSelector } from "../state/liveConnectionStore";
import buttonStyles from "./Button.module.css";
import styles from "./JoinServerButton.module.css";
export function JoinServerButton({
@ -22,9 +23,10 @@ export function JoinServerButton({
return (
<button
type="button"
className={styles.Root}
className={styles.JoinServerButton}
aria-label={isLive ? "Connected  click to disconnect" : "Join server"}
title={isLive ? "Connected  click to disconnect" : "Join server"}
data-connected={isLive}
onClick={() => {
cameraTourStore.getState().cancel();
if (isLive) {
@ -35,12 +37,10 @@ export function JoinServerButton({
}}
data-active={isActive}
>
<BsFillLightningChargeFill
className={`${styles.LiveIcon} ${isLive ? styles.Pulsing : ""}`}
/>
<BsFillLightningChargeFill className={styles.Icon} />
<>
<span className={styles.TextLabel}>Live</span>
<span className={styles.ButtonHint}>
<span className={buttonStyles.ButtonLabel}>Live</span>
<span className={buttonStyles.ButtonHint}>
{isConnecting ? "Connecting…" : isLive ? "Connected" : "Join a game"}
</span>
</>

View file

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

View file

@ -5,7 +5,7 @@ import { cameraTourStore } from "../state/cameraTourStore";
import { demoTimelineStore } from "../state/demoTimelineStore";
import { liveConnectionStore } from "../state/liveConnectionStore";
import { usePlaybackActions, useRecording } from "./RecordingProvider";
import styles from "./LoadDemoButton.module.css";
import styles from "./Button.module.css";
const log = createLogger("LoadDemoButton");
@ -113,7 +113,7 @@ export function LoadDemoButton({
/>
<button
type="button"
className={styles.Root}
className={styles.Button}
aria-label={isDemoLoaded ? "Unload demo" : "Load demo (.rec)"}
title={isDemoLoaded ? "Unload demo" : "Load demo (.rec)"}
onClick={handleClick}

View file

@ -46,6 +46,11 @@
font-variant-numeric: tabular-nums;
}
.LoadingIndicator[data-indeterminate="true"] .Progress,
.LoadingIndicator[data-indeterminate="true"] .ProgressText {
display: none;
}
@keyframes spin {
to {
transform: rotate(360deg);

View file

@ -2,21 +2,24 @@ import styles from "./LoadingIndicator.module.css";
export function LoadingIndicator({
isLoading,
progress,
progress = null,
}: {
isLoading: boolean;
progress: number;
progress?: number | null;
}) {
const percent = (progress ?? 0) * 100;
return (
<div className={styles.LoadingIndicator} data-complete={!isLoading}>
<div
className={styles.LoadingIndicator}
data-complete={!isLoading}
data-indeterminate={progress == null}
>
<div className={styles.Spinner} />
<div className={styles.Progress}>
<div
className={styles.ProgressBar}
style={{ width: `${progress * 100}%` }}
/>
<div className={styles.ProgressBar} style={{ width: `${percent}%` }} />
</div>
<div className={styles.ProgressText}>{Math.round(progress * 100)}%</div>
<div className={styles.ProgressText}>{Math.round(percent)}%</div>
</div>
);
}

View file

@ -0,0 +1,35 @@
import { LuClipboardList } from "react-icons/lu";
import { hasMission } from "../manifest";
import { useDataSource, useMissionName } from "../state/gameEntityStore";
import styles from "./Button.module.css";
export function MapInfoButton({
missionName,
onClick,
}: {
missionName: string;
onClick: () => void;
}) {
const dataSource = useDataSource();
const storeMissionName = useMissionName();
const hasStreamData = dataSource === "demo" || dataSource === "live";
// When streaming, the URL query param may not reflect the actual map.
// Use the store's mission name (from the server) for the manifest check.
const effectiveMissionName = hasStreamData ? storeMissionName : missionName;
const missionInManifest = effectiveMissionName
? hasMission(effectiveMissionName)
: false;
return (
<button
type="button"
className={styles.Button}
aria-label="Show map info"
onClick={onClick}
disabled={!missionInManifest}
>
<LuClipboardList />
<span className={styles.ButtonLabel}>Show map info</span>
</button>
);
}

View file

@ -26,25 +26,6 @@
view-transition-class: layout;
}
.CancelButton {
padding: 4px 6px;
font-size: 12px;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 3px;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
white-space: nowrap;
z-index: 1;
}
@media (hover: hover) {
.CancelButton:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
}
.Sidebar {
position: relative;
grid-area: sidebar;
@ -87,67 +68,10 @@
z-index: 1;
}
.ToggleSidebarButton {
flex: 0 0 auto;
display: grid;
place-content: center;
min-width: 30px;
min-height: 30px;
padding: 2px;
margin: 0 0 0 8px;
font-size: 24px;
border-radius: 4px;
background: transparent;
color: #fff;
border: 0;
cursor: pointer;
opacity: 0.6;
}
.ToggleSidebarButton:active,
.ToggleSidebarButton[data-active="true"] {
opacity: 1;
transform: translateY(1px);
}
.ToggleSidebarButton[data-orientation="top"] {
display: none;
min-height: 48px;
min-width: 48px;
margin: 0;
}
@media (hover: hover) {
.ToggleSidebarButton:not(:disabled):hover {
opacity: 1;
}
}
.ToggleSidebarButton svg {
pointer-events: none;
}
.Backdrop {
display: none;
}
.CloseSidebarButton {
composes: IconButton from "./InspectorControls.module.css";
composes: LabelledButton from "./InspectorControls.module.css";
width: calc(100% - 24px);
margin: 12px;
padding-top: 8px;
padding-bottom: 8px;
}
.ButtonLabel {
composes: ButtonLabel from "./InspectorControls.module.css";
}
.CloseSidebarButton .ButtonLabel {
font-size: 15px;
}
@media (max-width: 899px) {
.Frame {
display: grid;
@ -175,14 +99,6 @@
justify-content: space-between;
}
.ToggleSidebarButton[data-orientation="left"] {
display: none;
}
.ToggleSidebarButton[data-orientation="top"] {
display: grid;
}
.Backdrop {
display: block;
position: fixed;
@ -193,44 +109,4 @@
background: rgba(0, 0, 0, 0.7);
z-index: 2;
}
.CancelButton {
display: none;
}
}
@media (min-width: 900px) {
.CloseSidebarButton {
display: none;
}
}
@media (pointer: coarse) {
.ToggleSidebarButton {
font-size: 28px;
}
}
.ExitTourButton {
composes: ActionButton from "./StreamingMissionInfo.module.css";
gap: 6px;
padding: 4px 10px 4px 6px;
margin: 0 10px 0 auto;
font-size: 20px;
}
.ExitTourButton .ButtonLabel {
font-size: 13px;
font-weight: 500;
}
@media (max-width: 799px) {
.ExitTourButton {
padding-right: 6px;
margin: 0 10px 0 0;
}
.ExitTourButton .ButtonLabel {
display: none;
}
}

View file

@ -42,15 +42,11 @@ import {
useMissionType,
} from "../state/gameEntityStore";
import { getMissionInfo } from "../manifest";
import {
LuPanelLeftClose,
LuPanelLeftOpen,
LuPanelTopClose,
LuPanelTopOpen,
} from "react-icons/lu";
import { cameraTourStore, useCameraTour } from "../state/cameraTourStore";
import { useTouchDevice } from "./useTouchDevice";
import { HiMiniArrowLeftEndOnRectangle } from "react-icons/hi2";
import { GameDialogSpinner } from "./GameDialogSpinner";
import { ToggleSidebarButton } from "./ToggleSidebarButton";
import { ExitTourButton } from "./ExitTourButton";
import styles from "./MapInspector.module.css";
function ViewTransition({ children }: { children: ReactNode }) {
@ -233,30 +229,20 @@ export function MapInspector() {
<main className={styles.Frame}>
<RecordingProvider>
<header className={styles.Toolbar}>
<button
type="button"
className={styles.ToggleSidebarButton}
data-orientation="top"
aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"}
title={sidebarOpen ? "Close sidebar" : "Open sidebar"}
onClick={(event) => {
<ToggleSidebarButton
orientation="top"
isOpen={sidebarOpen}
onClick={() => {
setSidebarOpen((open) => !open);
}}
>
{sidebarOpen ? <LuPanelTopClose /> : <LuPanelTopOpen />}
</button>
<button
type="button"
className={styles.ToggleSidebarButton}
data-orientation="left"
aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"}
title={sidebarOpen ? "Close sidebar" : "Open sidebar"}
onClick={(event) => {
/>
<ToggleSidebarButton
orientation="left"
isOpen={sidebarOpen}
onClick={() => {
setSidebarOpen((open) => !open);
}}
>
{sidebarOpen ? <LuPanelLeftClose /> : <LuPanelLeftOpen />}
</button>
/>
<Activity mode={hasStreamData && !choosingMap ? "visible" : "hidden"}>
<StreamingMissionInfo />
</Activity>
@ -268,28 +254,8 @@ export function MapInspector() {
autoFocus={choosingMap}
onCancel={handleCancelChoosingMap}
/>
{choosingMap && (
<button
type="button"
className={styles.CancelButton}
onClick={() => {
setChoosingMap(false);
}}
>
Cancel
</button>
)}
</Activity>
{isTourActive && (
<button
type="button"
className={styles.ExitTourButton}
onClick={() => cameraTourStore.getState().cancel()}
>
<HiMiniArrowLeftEndOnRectangle />
<span className={styles.ButtonLabel}>Exit tour</span>
</button>
)}
{isTourActive && <ExitTourButton />}
</header>
{sidebarOpen ? <div className={styles.Backdrop} /> : null}
<Activity mode={sidebarOpen ? "visible" : "hidden"}>
@ -309,15 +275,10 @@ export function MapInspector() {
}
onChooseMap={handleChooseMap}
onCancelChoosingMap={handleCancelChoosingMap}
/>
<button
className={styles.CloseSidebarButton}
onClick={(event) => {
onClose={() => {
setSidebarOpen(false);
}}
>
<span className={styles.ButtonLabel}>Close</span>
</button>
/>
</div>
</Activity>
<InputProvider>
@ -360,7 +321,11 @@ export function MapInspector() {
</footer>
{mapInfoOpen ? (
<ViewTransition>
<Suspense>
<Suspense
fallback={
<GameDialogSpinner onClose={() => setMapInfoOpen(false)} />
}
>
<MapInfoDialog
onClose={() => setMapInfoOpen(false)}
missionName={missionName}
@ -371,14 +336,24 @@ export function MapInspector() {
) : null}
{serverBrowserOpen ? (
<ViewTransition>
<Suspense>
<Suspense
fallback={
<GameDialogSpinner
onClose={() => setServerBrowserOpen(false)}
/>
}
>
<ServerBrowser onClose={() => setServerBrowserOpen(false)} />
</Suspense>
</ViewTransition>
) : null}
{scoreScreenOpen ? (
<ViewTransition>
<Suspense>
<Suspense
fallback={
<GameDialogSpinner onClose={() => setScoreScreenOpen(false)} />
}
>
<ScoreScreen onClose={() => setScoreScreenOpen(false)} />
</Suspense>
</ViewTransition>

View file

@ -82,8 +82,9 @@
.Popover {
z-index: 100;
min-width: 320px;
min-width: 360px;
max-height: var(--popover-available-height, 90vh);
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
background: rgba(20, 20, 20, 0.95);
@ -92,6 +93,20 @@
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
}
@media (max-width: 499px) {
.PopoverWrapper {
transform: translate3d(0, 44px, 0) !important;
left: 8px !important;
right: 8px !important;
width: auto !important;
max-width: none !important;
}
.Popover {
min-width: 0;
}
}
.List {
padding: 4px 0;
}
@ -203,12 +218,19 @@
display: grid;
place-content: center;
background: transparent;
padding: 4px 16px 4px 4px;
margin: 0 0 0 -8px;
padding: 6px;
border: 0;
font-size: 24px;
color: rgba(255, 255, 255, 0.5);
}
@media (pointer: fine) {
.CloseButton {
visibility: hidden;
}
}
.CloseButton[data-open="false"] {
visibility: hidden;
}
@ -224,9 +246,3 @@
width: 100%;
}
}
@media (min-width: 900px) {
.CloseButton {
display: none;
}
}

View file

@ -180,6 +180,11 @@ export function MissionSelect({
const combobox = useComboboxStore({
resetValueOnHide: true,
selectedValue: value,
setOpen: (open) => {
if (!open) {
onCancel?.();
}
},
setSelectedValue: (newValue) => {
if (newValue) {
let newMissionType = missionTypeRef.current;
@ -325,11 +330,27 @@ export function MissionSelect({
)}
<kbd className={styles.Shortcut}>{isMac ? "⌘K" : "^K"}</kbd>
</div>
<button
type="button"
className={styles.CloseButton}
data-open={isOpen}
onClick={() => {
combobox.hide();
onCancel?.();
}}
>
<IoMdCloseCircle />
</button>
<ComboboxPopover
gutter={4}
fitViewport
sameWidth
fixed
autoFocusOnHide={false}
className={styles.Popover}
wrapperProps={{
className: styles.PopoverWrapper,
}}
onKeyDown={(event) => {
if (!event.metaKey) {
event.stopPropagation();
@ -358,17 +379,6 @@ export function MissionSelect({
)}
</ComboboxList>
</ComboboxPopover>
<button
type="button"
className={styles.CloseButton}
data-open={isOpen}
onClick={() => {
combobox.hide();
onCancel?.();
}}
>
<IoMdCloseCircle />
</button>
</ComboboxProvider>
);
}

View file

@ -0,0 +1,16 @@
import { LuUsers } from "react-icons/lu";
import styles from "./Button.module.css";
export function ShowScoresButton({ onClick }: { onClick: () => void }) {
return (
<button
type="button"
className={styles.Button}
aria-label="Show scores"
onClick={onClick}
>
<LuUsers />
<span className={styles.ButtonLabel}>Show scores</span>
</button>
);
}

View file

@ -54,21 +54,9 @@
color: #eceae7;
}
.ActionButton {
composes: IconButton from "./InspectorControls.module.css";
flex: 0 0 auto;
margin: 0 10px 0 0;
font-size: 16px;
min-width: 28px;
min-height: 28px;
padding: 2px;
background: rgba(213, 66, 58, 0.7);
}
@media (hover: hover) {
.ActionButton:not(:disabled):hover {
background: rgba(229, 70, 59, 0.9);
}
.EjectButton,
.DisconnectButton {
composes: DestructiveActionButton from "./Button.module.css";
}
.EjectIcon {

View file

@ -110,7 +110,7 @@ export function StreamingMissionInfo() {
{dataSource === "demo" ? (
<button
type="button"
className={styles.ActionButton}
className={styles.EjectButton}
title="Unload demo"
aria-label="Unload demo"
onClick={handleEject}
@ -121,7 +121,7 @@ export function StreamingMissionInfo() {
) : isLive ? (
<button
type="button"
className={styles.ActionButton}
className={styles.DisconnectButton}
title="Disconnect"
aria-label="Disconnect"
onClick={handleDisconnect}

View file

@ -0,0 +1,55 @@
.Button {
flex: 0 0 auto;
display: grid;
place-content: center;
min-width: 30px;
min-height: 30px;
padding: 2px;
margin: 0 0 0 8px;
font-size: 24px;
border-radius: 4px;
background: transparent;
color: #fff;
border: 0;
cursor: pointer;
opacity: 0.6;
}
.Button:active,
.Button[data-active="true"] {
opacity: 1;
transform: translateY(1px);
}
.Button[data-orientation="top"] {
display: none;
min-height: 48px;
min-width: 48px;
margin: 0;
}
@media (hover: hover) {
.Button:not(:disabled):hover {
opacity: 1;
}
}
.Button svg {
pointer-events: none;
}
@media (max-width: 899px) {
.Button[data-orientation="left"] {
display: none;
}
.Button[data-orientation="top"] {
display: grid;
}
}
@media (pointer: coarse) {
.Button {
font-size: 28px;
}
}

View file

@ -0,0 +1,40 @@
import {
LuPanelLeftClose,
LuPanelLeftOpen,
LuPanelTopClose,
LuPanelTopOpen,
} from "react-icons/lu";
import styles from "./ToggleSidebarButton.module.css";
export function ToggleSidebarButton({
isOpen,
orientation,
onClick,
}: {
isOpen: boolean;
orientation: "left" | "top";
onClick: () => void;
}) {
return (
<button
type="button"
className={styles.Button}
data-orientation={orientation}
aria-label={isOpen ? "Close sidebar" : "Open sidebar"}
title={isOpen ? "Close sidebar" : "Open sidebar"}
onClick={onClick}
>
{orientation === "top" ? (
isOpen ? (
<LuPanelTopClose />
) : (
<LuPanelTopOpen />
)
) : isOpen ? (
<LuPanelLeftClose />
) : (
<LuPanelLeftOpen />
)}
</button>
);
}

View file

@ -1,3 +1,5 @@
@layer button;
:root {
--monospace-font:
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono",

View file

@ -126,6 +126,17 @@ export interface MutableEntity {
audioMinLoopGap?: number;
audioMaxLoopGap?: number;
sceneData?: SceneObject;
/** Force field visual data extracted from ForceFieldBareData datablock. */
forceFieldData?: {
textures: string[];
color: [number, number, number];
baseTranslucency: number;
dimensions: [number, number, number];
framesPerSec: number;
scrollSpeed: number;
umapping: number;
vmapping: number;
};
}
export type RuntimeControlObject = {
@ -949,6 +960,37 @@ export abstract class StreamEngine implements StreamingPlayback {
entity.maintainEmitterId = trailEmitterId;
}
}
// Force field visual data from ForceFieldBareData datablock.
if (entity.className === "ForceFieldBare" && blockData) {
const color1 = blockData.color1 as
| { r: number; g: number; b: number }
| undefined;
const textures: string[] = [];
for (let i = 0; i < 5; i++) {
const tex = blockData[`texture${i}`] as string | undefined;
if (tex) textures.push(tex);
}
// Use scale from ghost data as box dimensions (same as mission bridge).
const scale = data.scale as
| { x: number; y: number; z: number }
| undefined;
entity.forceFieldData = {
textures,
color: color1
? [color1.r, color1.g, color1.b]
: [1, 1, 1],
baseTranslucency:
(blockData.baseTranslucency as number) ?? 1,
dimensions: scale
? [scale.y, scale.z, scale.x]
: [1, 1, 1],
framesPerSec: (blockData.framesPerSec as number) ?? 1,
scrollSpeed: (blockData.scrollSpeed as number) ?? 0,
umapping: (blockData.umapping as number) ?? 1,
vmapping: (blockData.vmapping as number) ?? 1,
};
}
}
// Weapon images (Player)
@ -2212,6 +2254,7 @@ export abstract class StreamEngine implements StreamingPlayback {
audioMinLoopGap: entity.audioMinLoopGap,
audioMaxLoopGap: entity.audioMaxLoopGap,
sceneData: entity.sceneData,
forceFieldData: entity.forceFieldData,
});
}
return entities;

View file

@ -158,6 +158,19 @@ export function streamEntityToGameEntity(
return {
...positionedBase(entity, spawnTime),
renderType: "ForceFieldBare",
forceFieldData: entity.forceFieldData
? {
textures: entity.forceFieldData.textures,
color: entity.forceFieldData.color,
baseTranslucency: entity.forceFieldData.baseTranslucency,
numFrames: entity.forceFieldData.textures.length,
framesPerSec: entity.forceFieldData.framesPerSec,
scrollSpeed: entity.forceFieldData.scrollSpeed,
umapping: entity.forceFieldData.umapping,
vmapping: entity.forceFieldData.vmapping,
dimensions: entity.forceFieldData.dimensions,
}
: undefined,
} satisfies ForceFieldBareEntity;
}

View file

@ -155,6 +155,17 @@ export interface StreamEntity {
audioMaxLoopGap?: number;
/** Scene infrastructure data (terrain, interior, sky, etc.). */
sceneData?: SceneObject;
/** Force field visual data from ForceFieldBareData datablock. */
forceFieldData?: {
textures: string[];
color: [number, number, number];
baseTranslucency: number;
dimensions: [number, number, number];
framesPerSec: number;
scrollSpeed: number;
umapping: number;
vmapping: number;
};
}
export interface StreamCamera {