mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-03-09 07:20:45 +00:00
migrate to CSS Modules
This commit is contained in:
parent
c5b43f2e55
commit
d9be5c1eba
51 changed files with 1684 additions and 1630 deletions
36
src/components/CopyCoordinatesButton.module.css
Normal file
36
src/components/CopyCoordinatesButton.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
23
src/components/DebugElements.module.css
Normal file
23
src/components/DebugElements.module.css
Normal 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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
111
src/components/DemoControls.module.css
Normal file
111
src/components/DemoControls.module.css
Normal 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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
9
src/components/FloatingLabel.module.css
Normal file
9
src/components/FloatingLabel.module.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
175
src/components/InspectorControls.module.css
Normal file
175
src/components/InspectorControls.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
55
src/components/KeyboardOverlay.module.css
Normal file
55
src/components/KeyboardOverlay.module.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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">↑</span> Space
|
||||
<div className={styles.Column}>
|
||||
<div className={styles.Row}>
|
||||
<div className={styles.Key} data-pressed={up}>
|
||||
<span className={styles.Arrow}>↑</span> Space
|
||||
</div>
|
||||
</div>
|
||||
<div className="KeyboardOverlay-row">
|
||||
<div className="KeyboardOverlay-key" data-pressed={down}>
|
||||
<span className="KeyboardOverlay-arrow">↓</span> Shift
|
||||
<div className={styles.Row}>
|
||||
<div className={styles.Key} data-pressed={down}>
|
||||
<span className={styles.Arrow}>↓</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}>
|
||||
↑
|
||||
</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}>
|
||||
←
|
||||
</div>
|
||||
<div className="KeyboardOverlay-key" data-pressed={lookDown}>
|
||||
<div className={styles.Key} data-pressed={lookDown}>
|
||||
↓
|
||||
</div>
|
||||
<div className="KeyboardOverlay-key" data-pressed={lookRight}>
|
||||
<div className={styles.Key} data-pressed={lookRight}>
|
||||
→
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
12
src/components/LoadDemoButton.module.css
Normal file
12
src/components/LoadDemoButton.module.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
181
src/components/MissionSelect.module.css
Normal file
181
src/components/MissionSelect.module.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
46
src/components/PlayerNameplate.module.css
Normal file
46
src/components/PlayerNameplate.module.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
22
src/components/TouchControls.module.css
Normal file
22
src/components/TouchControls.module.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue