Initial commit
This commit is contained in:
11
client/README.md
Normal file
11
client/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# chgrid client
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cd client
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open `http://localhost:5173`.
|
||||
84
client/index.html
Normal file
84
client/index.html
Normal file
@@ -0,0 +1,84 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Chat Grid</title>
|
||||
</head>
|
||||
<body>
|
||||
<main class="app">
|
||||
<h1>Chat Grid</h1>
|
||||
<div id="nicknameContainer" class="nickname-row">
|
||||
<label for="preconnectNickname">Nickname</label>
|
||||
<input id="preconnectNickname" type="text" maxlength="32" autocomplete="nickname" />
|
||||
</div>
|
||||
<div class="controls" id="button-container">
|
||||
<button id="connectButton">Connect</button>
|
||||
<button id="settingsButton">Settings</button>
|
||||
<button id="disconnectButton" class="hidden">Disconnect</button>
|
||||
<button id="focusGridButton" class="hidden" aria-controls="gameCanvas">Chat Grid</button>
|
||||
</div>
|
||||
<div id="deviceSummary">
|
||||
<p id="audioInputCurrent" class="hidden"></p>
|
||||
<p id="audioOutputCurrent" class="hidden"></p>
|
||||
</div>
|
||||
<div id="status" role="region" aria-live="polite"></div>
|
||||
<canvas
|
||||
id="gameCanvas"
|
||||
width="600"
|
||||
height="600"
|
||||
tabindex="0"
|
||||
class="hidden"
|
||||
aria-label="Chat Grid, press arrows to move."
|
||||
></canvas>
|
||||
<div id="instructions" class="hidden">
|
||||
<h2>Help</h2>
|
||||
|
||||
<h3>Movement</h3>
|
||||
<p><b>Arrow Keys:</b> Move</p>
|
||||
<p><b>C:</b> Coordinates</p>
|
||||
<p><b>Escape:</b> Disconnect/cancel</p>
|
||||
|
||||
<h3>Users, Nickname, and Chat</h3>
|
||||
<p><b>L:</b> Locate nearest user</p>
|
||||
<p><b>Shift+L:</b> List users</p>
|
||||
<p><b>Shift+U:</b> List connected users</p>
|
||||
<p><b>N:</b> Change nickname</p>
|
||||
<p><b>Apostrophe:</b> Start chat</p>
|
||||
<p><b>Comma / Period:</b> Previous/next chat message</p>
|
||||
<p><b>Less Than / Greater Than:</b> First/last chat message</p>
|
||||
|
||||
<h3>Items</h3>
|
||||
<p><b>I:</b> Locate nearest item</p>
|
||||
<p><b>Shift+I:</b> List items</p>
|
||||
<p><b>A:</b> Add item</p>
|
||||
<p><b>O:</b> Edit item properties</p>
|
||||
<p><b>D:</b> Pick up/drop item</p>
|
||||
<p><b>Shift+D:</b> Delete item</p>
|
||||
<p><b>U:</b> Use item</p>
|
||||
|
||||
<h3>Audio</h3>
|
||||
<p><b>P:</b> Ping server</p>
|
||||
<p><b>M:</b> Mute/unmute</p>
|
||||
<p><b>Shift+M:</b> Toggle stereo/mono output</p>
|
||||
<p><b>E:</b> Cycle voice effect</p>
|
||||
<p><b>Dash or Equals:</b> Lower/raise active effect value</p>
|
||||
</div>
|
||||
|
||||
<small id="appVersion">Another AI experiment with Jage. Version</small>
|
||||
|
||||
<div id="settingsModal" class="hidden" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
|
||||
<div class="modal-content">
|
||||
<h2 id="modalTitle">Audio Settings</h2>
|
||||
<label for="audioInputSelect">Microphone (Input)</label>
|
||||
<select id="audioInputSelect"></select>
|
||||
<label for="audioOutputSelect">Speakers (Output)</label>
|
||||
<select id="audioOutputSelect"></select>
|
||||
<button id="closeSettingsButton">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script src="%BASE_URL%version.js"></script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
3942
client/package-lock.json
generated
Normal file
3942
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
client/package.json
Normal file
24
client/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "chat-grid-client",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint src --ext .ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^8.19.0",
|
||||
"@typescript-eslint/parser": "^8.19.0",
|
||||
"eslint": "^9.17.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.5",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
BIN
client/public/sounds/logon.ogg
Normal file
BIN
client/public/sounds/logon.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/logout.ogg
Normal file
BIN
client/public/sounds/logout.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/notify.ogg
Normal file
BIN
client/public/sounds/notify.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/roll.ogg
Normal file
BIN
client/public/sounds/roll.ogg
Normal file
Binary file not shown.
3
client/public/version.js
Normal file
3
client/public/version.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// Maintainer-controlled web client version.
|
||||
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
|
||||
window.CHGRID_WEB_VERSION = "2026.02.20 R57";
|
||||
535
client/src/audio/audioEngine.ts
Normal file
535
client/src/audio/audioEngine.ts
Normal file
@@ -0,0 +1,535 @@
|
||||
import { HEARING_RADIUS } from '../state/gameState';
|
||||
|
||||
export type SpatialPeerRuntime = {
|
||||
nickname: string;
|
||||
x: number;
|
||||
y: number;
|
||||
gain?: GainNode;
|
||||
panner?: StereoPannerNode;
|
||||
audioElement?: HTMLAudioElement;
|
||||
};
|
||||
|
||||
type SoundSpec = {
|
||||
freq: number;
|
||||
duration: number;
|
||||
type?: OscillatorType;
|
||||
gain?: number;
|
||||
sourcePosition?: { x: number; y: number };
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
type EffectId = 'reverb' | 'echo' | 'flanger' | 'high_pass' | 'low_pass' | 'off';
|
||||
type OutputMode = 'stereo' | 'mono';
|
||||
|
||||
type EffectPreset = { id: EffectId; label: string; defaultValue: number };
|
||||
|
||||
const EFFECT_SEQUENCE: EffectPreset[] = [
|
||||
{ id: 'reverb', label: 'Reverb', defaultValue: 50 },
|
||||
{ id: 'echo', label: 'Echo', defaultValue: 50 },
|
||||
{ id: 'flanger', label: 'Flanger', defaultValue: 50 },
|
||||
{ id: 'high_pass', label: 'High Pass', defaultValue: 50 },
|
||||
{ id: 'low_pass', label: 'Low Pass', defaultValue: 50 },
|
||||
{ id: 'off', label: 'Off', defaultValue: 0 },
|
||||
];
|
||||
|
||||
export class AudioEngine {
|
||||
private audioCtx: AudioContext | null = null;
|
||||
private sfxGainNode: GainNode | null = null;
|
||||
private readonly sampleCache = new Map<string, AudioBuffer>();
|
||||
private readonly sampleLoaders = new Map<string, Promise<AudioBuffer>>();
|
||||
|
||||
private outboundSource: MediaStreamAudioSourceNode | null = null;
|
||||
private outboundInputGain: GainNode | null = null;
|
||||
private outboundDestination: MediaStreamAudioDestinationNode | null = null;
|
||||
private outboundEffectNodes: AudioNode[] = [];
|
||||
private flangerLfo: OscillatorNode | null = null;
|
||||
private flangerLfoGain: GainNode | null = null;
|
||||
private outputMode: OutputMode = 'stereo';
|
||||
private effectIndex = EFFECT_SEQUENCE.findIndex((effect) => effect.id === 'off');
|
||||
private readonly effectValues: Record<EffectId, number> = {
|
||||
reverb: 50,
|
||||
echo: 50,
|
||||
flanger: 50,
|
||||
high_pass: 50,
|
||||
low_pass: 50,
|
||||
off: 0,
|
||||
};
|
||||
|
||||
async ensureContext(): Promise<void> {
|
||||
if (!this.audioCtx) {
|
||||
const Ctor =
|
||||
window.AudioContext ||
|
||||
(window as Window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!Ctor) return;
|
||||
this.audioCtx = new Ctor();
|
||||
this.sfxGainNode = this.audioCtx.createGain();
|
||||
this.sfxGainNode.connect(this.audioCtx.destination);
|
||||
}
|
||||
if (this.audioCtx.state === 'suspended') {
|
||||
await this.audioCtx.resume();
|
||||
}
|
||||
}
|
||||
|
||||
get context(): AudioContext | null {
|
||||
return this.audioCtx;
|
||||
}
|
||||
|
||||
supportsStereoPanner(): boolean {
|
||||
return !!this.audioCtx && typeof this.audioCtx.createStereoPanner === 'function';
|
||||
}
|
||||
|
||||
supportsSinkId(element: HTMLMediaElement): boolean {
|
||||
return (
|
||||
typeof (element as HTMLMediaElement & { setSinkId?: (id: string) => Promise<void> }).setSinkId ===
|
||||
'function'
|
||||
);
|
||||
}
|
||||
|
||||
async configureOutboundStream(inputStream: MediaStream): Promise<MediaStream> {
|
||||
await this.ensureContext();
|
||||
if (!this.audioCtx) {
|
||||
return inputStream;
|
||||
}
|
||||
|
||||
if (this.outboundSource) {
|
||||
this.outboundSource.disconnect();
|
||||
}
|
||||
|
||||
this.outboundSource = this.audioCtx.createMediaStreamSource(inputStream);
|
||||
if (!this.outboundInputGain) {
|
||||
this.outboundInputGain = this.audioCtx.createGain();
|
||||
}
|
||||
if (!this.outboundDestination) {
|
||||
this.outboundDestination = this.audioCtx.createMediaStreamDestination();
|
||||
}
|
||||
|
||||
this.outboundSource.connect(this.outboundInputGain);
|
||||
this.rebuildOutboundEffectGraph();
|
||||
|
||||
return this.outboundDestination.stream;
|
||||
}
|
||||
|
||||
cycleOutboundEffect(): { id: EffectId; label: string } {
|
||||
this.effectIndex = (this.effectIndex + 1) % EFFECT_SEQUENCE.length;
|
||||
this.rebuildOutboundEffectGraph();
|
||||
return EFFECT_SEQUENCE[this.effectIndex];
|
||||
}
|
||||
|
||||
getCurrentEffect(): { id: EffectId; label: string; value: number; defaultValue: number } {
|
||||
const effect = EFFECT_SEQUENCE[this.effectIndex];
|
||||
return {
|
||||
id: effect.id,
|
||||
label: effect.label,
|
||||
value: this.effectValues[effect.id],
|
||||
defaultValue: effect.defaultValue,
|
||||
};
|
||||
}
|
||||
|
||||
adjustCurrentEffectLevel(step: number): { id: EffectId; label: string; value: number; defaultValue: number } | null {
|
||||
const effect = EFFECT_SEQUENCE[this.effectIndex];
|
||||
if (effect.id === 'off') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const next = this.clampLevel(this.effectValues[effect.id] + step);
|
||||
this.effectValues[effect.id] = next;
|
||||
this.rebuildOutboundEffectGraph();
|
||||
|
||||
return {
|
||||
id: effect.id,
|
||||
label: effect.label,
|
||||
value: next,
|
||||
defaultValue: effect.defaultValue,
|
||||
};
|
||||
}
|
||||
|
||||
setEffectLevels(levels: Partial<Record<EffectId, number>>): void {
|
||||
for (const effect of EFFECT_SEQUENCE) {
|
||||
if (effect.id === 'off') continue;
|
||||
const value = levels[effect.id];
|
||||
if (typeof value !== 'number') continue;
|
||||
this.effectValues[effect.id] = this.clampLevel(value);
|
||||
}
|
||||
this.rebuildOutboundEffectGraph();
|
||||
}
|
||||
|
||||
getEffectLevels(): Record<EffectId, number> {
|
||||
return { ...this.effectValues };
|
||||
}
|
||||
|
||||
setOutputMode(mode: OutputMode): void {
|
||||
this.outputMode = mode;
|
||||
}
|
||||
|
||||
toggleOutputMode(): OutputMode {
|
||||
this.outputMode = this.outputMode === 'stereo' ? 'mono' : 'stereo';
|
||||
return this.outputMode;
|
||||
}
|
||||
|
||||
async attachRemoteStream(
|
||||
peer: SpatialPeerRuntime,
|
||||
stream: MediaStream,
|
||||
outputDeviceId: string,
|
||||
): Promise<void> {
|
||||
await this.ensureContext();
|
||||
if (!this.audioCtx) return;
|
||||
|
||||
const audioElement = new Audio();
|
||||
audioElement.srcObject = stream;
|
||||
audioElement.muted = true;
|
||||
|
||||
if (outputDeviceId && this.supportsSinkId(audioElement)) {
|
||||
const sinkTarget = audioElement as HTMLMediaElement & { setSinkId?: (id: string) => Promise<void> };
|
||||
await sinkTarget.setSinkId?.(outputDeviceId);
|
||||
}
|
||||
|
||||
await audioElement.play().catch(() => undefined);
|
||||
document.body.appendChild(audioElement);
|
||||
|
||||
const sourceNode = this.audioCtx.createMediaStreamSource(stream);
|
||||
const gainNode = this.audioCtx.createGain();
|
||||
sourceNode.connect(gainNode);
|
||||
|
||||
let pannerNode: StereoPannerNode | undefined;
|
||||
if (this.supportsStereoPanner()) {
|
||||
pannerNode = this.audioCtx.createStereoPanner();
|
||||
gainNode.connect(pannerNode).connect(this.audioCtx.destination);
|
||||
} else {
|
||||
gainNode.connect(this.audioCtx.destination);
|
||||
}
|
||||
|
||||
peer.audioElement = audioElement;
|
||||
peer.gain = gainNode;
|
||||
peer.panner = pannerNode;
|
||||
}
|
||||
|
||||
updateSpatialAudio(peers: Iterable<SpatialPeerRuntime>, playerPosition: { x: number; y: number }): void {
|
||||
if (!this.audioCtx) return;
|
||||
|
||||
for (const peer of peers) {
|
||||
if (!peer.gain) continue;
|
||||
const dist = Math.hypot(peer.x - playerPosition.x, peer.y - playerPosition.y);
|
||||
let gainValue = 0;
|
||||
let panValue = 0;
|
||||
if (dist < HEARING_RADIUS) {
|
||||
gainValue = Math.pow(1 - dist / HEARING_RADIUS, 2);
|
||||
panValue = Math.sin(((peer.x - playerPosition.x) / HEARING_RADIUS) * (Math.PI / 2));
|
||||
}
|
||||
if (dist < 1.5) gainValue = 1;
|
||||
peer.gain.gain.linearRampToValueAtTime(gainValue, this.audioCtx.currentTime + 0.1);
|
||||
if (peer.panner) {
|
||||
const resolvedPan = this.outputMode === 'mono' ? 0 : Math.max(-1, Math.min(1, panValue));
|
||||
peer.panner.pan.setValueAtTime(resolvedPan, this.audioCtx.currentTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sfxMove(player: { x: number; y: number }): void {
|
||||
void player;
|
||||
this.playSound({ freq: 165, duration: 0.05, type: 'triangle', gain: 0.13 });
|
||||
}
|
||||
|
||||
sfxPeerMove(peer: { x: number; y: number }): void {
|
||||
this.playSound({ freq: 330, duration: 0.05, type: 'triangle', gain: 0.12, sourcePosition: peer });
|
||||
}
|
||||
|
||||
sfxLocate(peer: { x: number; y: number }): void {
|
||||
this.playSound({ freq: 880, duration: 0.2, type: 'sine', gain: 0.5, sourcePosition: peer });
|
||||
}
|
||||
|
||||
sfxUiConfirm(): void {
|
||||
this.playSound({ freq: 880, duration: 0.1, gain: 0.5 });
|
||||
}
|
||||
|
||||
sfxUiCancel(): void {
|
||||
this.playSound({ freq: 440, duration: 0.1, type: 'sawtooth', gain: 0.3 });
|
||||
}
|
||||
|
||||
sfxUiBlip(): void {
|
||||
this.playSound({ freq: 660, duration: 0.05, type: 'triangle', gain: 0.35 });
|
||||
}
|
||||
|
||||
sfxEffectLevel(isDefault: boolean): void {
|
||||
this.playSound({ freq: isDefault ? 659.25 : 440, duration: 0.1, type: 'sine', gain: 0.35 });
|
||||
}
|
||||
|
||||
sfxTileOccupantPing(): void {
|
||||
this.playSound({ freq: 1320, duration: 0.12, type: 'sine', gain: 0.45 });
|
||||
}
|
||||
|
||||
async playSpatialSample(url: string, sourcePosition: { x: number; y: number }, gain = 1): Promise<void> {
|
||||
await this.ensureContext();
|
||||
const { audioCtx, sfxGainNode } = this;
|
||||
if (!audioCtx || !sfxGainNode) return;
|
||||
|
||||
const resolved = this.resolveSpatialMix(sourcePosition, gain);
|
||||
if (!resolved) return;
|
||||
|
||||
try {
|
||||
const buffer = await this.getSampleBuffer(url);
|
||||
const source = audioCtx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
const gainNode = audioCtx.createGain();
|
||||
gainNode.gain.value = resolved.gain;
|
||||
source.connect(gainNode);
|
||||
if (resolved.pan !== undefined && this.supportsStereoPanner() && this.outputMode === 'stereo') {
|
||||
const panner = audioCtx.createStereoPanner();
|
||||
panner.pan.setValueAtTime(resolved.pan, audioCtx.currentTime);
|
||||
gainNode.connect(panner).connect(sfxGainNode);
|
||||
} else {
|
||||
gainNode.connect(sfxGainNode);
|
||||
}
|
||||
source.start();
|
||||
} catch {
|
||||
// Ignore sample decode/load errors.
|
||||
}
|
||||
}
|
||||
|
||||
async playSample(url: string, gain = 1): Promise<void> {
|
||||
await this.ensureContext();
|
||||
const { audioCtx, sfxGainNode } = this;
|
||||
if (!audioCtx || !sfxGainNode) return;
|
||||
if (gain <= 0) return;
|
||||
|
||||
try {
|
||||
const buffer = await this.getSampleBuffer(url);
|
||||
const source = audioCtx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
const gainNode = audioCtx.createGain();
|
||||
gainNode.gain.value = gain;
|
||||
source.connect(gainNode).connect(sfxGainNode);
|
||||
source.start();
|
||||
} catch {
|
||||
// Ignore sample decode/load errors.
|
||||
}
|
||||
}
|
||||
|
||||
cleanupPeerAudio(peer: SpatialPeerRuntime): void {
|
||||
peer.audioElement?.remove();
|
||||
peer.gain?.disconnect();
|
||||
peer.panner?.disconnect();
|
||||
}
|
||||
|
||||
private rebuildOutboundEffectGraph(): void {
|
||||
if (!this.audioCtx || !this.outboundInputGain || !this.outboundDestination) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cleanupEffectNodes();
|
||||
this.outboundInputGain.disconnect();
|
||||
|
||||
const effect = EFFECT_SEQUENCE[this.effectIndex].id;
|
||||
const effectMix = this.effectValues[effect] / 100;
|
||||
|
||||
if (effect === 'off') {
|
||||
this.outboundInputGain.connect(this.outboundDestination);
|
||||
return;
|
||||
}
|
||||
|
||||
if (effect === 'high_pass' || effect === 'low_pass') {
|
||||
const filter = this.audioCtx.createBiquadFilter();
|
||||
filter.type = effect === 'high_pass' ? 'highpass' : 'lowpass';
|
||||
if (effect === 'high_pass') {
|
||||
filter.frequency.value = 120 + effectMix * 7000;
|
||||
} else {
|
||||
filter.frequency.value = 7800 - effectMix * 7600;
|
||||
}
|
||||
filter.Q.value = 0.7 + effectMix * 8;
|
||||
this.outboundInputGain.connect(filter);
|
||||
filter.connect(this.outboundDestination);
|
||||
this.outboundEffectNodes.push(filter);
|
||||
return;
|
||||
}
|
||||
|
||||
if (effect === 'echo') {
|
||||
const delay = this.audioCtx.createDelay(1);
|
||||
delay.delayTime.value = 0.04 + effectMix * 0.76;
|
||||
const feedback = this.audioCtx.createGain();
|
||||
feedback.gain.value = 0.04 + effectMix * 0.88;
|
||||
const wetGain = this.audioCtx.createGain();
|
||||
wetGain.gain.value = 0.08 + effectMix * 0.92;
|
||||
const dryGain = this.audioCtx.createGain();
|
||||
dryGain.gain.value = 1 - effectMix * 0.85;
|
||||
|
||||
this.outboundInputGain.connect(dryGain);
|
||||
dryGain.connect(this.outboundDestination);
|
||||
this.outboundInputGain.connect(delay);
|
||||
delay.connect(wetGain);
|
||||
wetGain.connect(this.outboundDestination);
|
||||
delay.connect(feedback);
|
||||
feedback.connect(delay);
|
||||
|
||||
this.outboundEffectNodes.push(delay, feedback, wetGain, dryGain);
|
||||
return;
|
||||
}
|
||||
|
||||
if (effect === 'reverb') {
|
||||
const convolver = this.audioCtx.createConvolver();
|
||||
convolver.buffer = this.createImpulseResponse(0.4 + effectMix * 4.2, 1 + effectMix * 3.6);
|
||||
const wetGain = this.audioCtx.createGain();
|
||||
wetGain.gain.value = 0.06 + effectMix * 0.94;
|
||||
const dryGain = this.audioCtx.createGain();
|
||||
dryGain.gain.value = 1 - effectMix * 0.8;
|
||||
|
||||
this.outboundInputGain.connect(dryGain);
|
||||
dryGain.connect(this.outboundDestination);
|
||||
this.outboundInputGain.connect(convolver);
|
||||
convolver.connect(wetGain);
|
||||
wetGain.connect(this.outboundDestination);
|
||||
|
||||
this.outboundEffectNodes.push(convolver, wetGain, dryGain);
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = this.audioCtx.createDelay(0.05);
|
||||
delay.delayTime.value = 0.0005 + effectMix * 0.012;
|
||||
const feedback = this.audioCtx.createGain();
|
||||
feedback.gain.value = 0.04 + effectMix * 0.9;
|
||||
const wetGain = this.audioCtx.createGain();
|
||||
wetGain.gain.value = 0.05 + effectMix * 0.95;
|
||||
const dryGain = this.audioCtx.createGain();
|
||||
dryGain.gain.value = 1 - effectMix * 0.82;
|
||||
|
||||
const lfo = this.audioCtx.createOscillator();
|
||||
lfo.type = 'sine';
|
||||
lfo.frequency.value = 0.05 + effectMix * 1.8;
|
||||
const lfoGain = this.audioCtx.createGain();
|
||||
lfoGain.gain.value = 0.0002 + effectMix * 0.015;
|
||||
|
||||
lfo.connect(lfoGain);
|
||||
lfoGain.connect(delay.delayTime);
|
||||
lfo.start();
|
||||
|
||||
this.outboundInputGain.connect(dryGain);
|
||||
dryGain.connect(this.outboundDestination);
|
||||
this.outboundInputGain.connect(delay);
|
||||
delay.connect(wetGain);
|
||||
wetGain.connect(this.outboundDestination);
|
||||
delay.connect(feedback);
|
||||
feedback.connect(delay);
|
||||
|
||||
this.flangerLfo = lfo;
|
||||
this.flangerLfoGain = lfoGain;
|
||||
this.outboundEffectNodes.push(delay, feedback, wetGain, lfoGain, dryGain);
|
||||
}
|
||||
|
||||
private cleanupEffectNodes(): void {
|
||||
for (const node of this.outboundEffectNodes) {
|
||||
node.disconnect();
|
||||
}
|
||||
this.outboundEffectNodes = [];
|
||||
|
||||
if (this.flangerLfo) {
|
||||
this.flangerLfo.stop();
|
||||
this.flangerLfo.disconnect();
|
||||
this.flangerLfo = null;
|
||||
}
|
||||
if (this.flangerLfoGain) {
|
||||
this.flangerLfoGain.disconnect();
|
||||
this.flangerLfoGain = null;
|
||||
}
|
||||
}
|
||||
|
||||
private createImpulseResponse(duration: number, decay: number): AudioBuffer {
|
||||
if (!this.audioCtx) {
|
||||
throw new Error('Audio context not initialized');
|
||||
}
|
||||
const length = Math.floor(this.audioCtx.sampleRate * duration);
|
||||
const impulse = this.audioCtx.createBuffer(2, length, this.audioCtx.sampleRate);
|
||||
for (let channel = 0; channel < impulse.numberOfChannels; channel += 1) {
|
||||
const data = impulse.getChannelData(channel);
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
const noise = Math.random() * 2 - 1;
|
||||
data[i] = noise * Math.pow(1 - i / length, decay);
|
||||
}
|
||||
}
|
||||
return impulse;
|
||||
}
|
||||
|
||||
private clampLevel(value: number): number {
|
||||
const clamped = Math.max(0, Math.min(100, value));
|
||||
return Math.round(clamped / 5) * 5;
|
||||
}
|
||||
|
||||
private playSound(spec: SoundSpec): void {
|
||||
const { audioCtx, sfxGainNode } = this;
|
||||
if (!audioCtx || !sfxGainNode) return;
|
||||
|
||||
const baseGain = spec.gain ?? 1;
|
||||
const resolved = this.resolveSpatialMix(spec.sourcePosition, baseGain);
|
||||
if (!resolved) return;
|
||||
const finalGain = resolved.gain;
|
||||
const panValue = resolved.pan;
|
||||
|
||||
if (finalGain <= 0) return;
|
||||
|
||||
const startTime = audioCtx.currentTime + (spec.delay ?? 0);
|
||||
const oscillator = audioCtx.createOscillator();
|
||||
oscillator.type = spec.type ?? 'sine';
|
||||
oscillator.frequency.setValueAtTime(spec.freq, startTime);
|
||||
|
||||
const gainNode = audioCtx.createGain();
|
||||
gainNode.gain.setValueAtTime(finalGain, startTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.001, startTime + spec.duration);
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
if (panValue !== undefined && this.supportsStereoPanner() && this.outputMode === 'stereo') {
|
||||
const panner = audioCtx.createStereoPanner();
|
||||
panner.pan.setValueAtTime(Math.max(-1, Math.min(1, panValue)), startTime);
|
||||
gainNode.connect(panner).connect(sfxGainNode);
|
||||
} else {
|
||||
gainNode.connect(sfxGainNode);
|
||||
}
|
||||
|
||||
oscillator.start(startTime);
|
||||
oscillator.stop(startTime + spec.duration);
|
||||
}
|
||||
|
||||
private resolveSpatialMix(
|
||||
sourcePosition: { x: number; y: number } | undefined,
|
||||
baseGain: number,
|
||||
): { gain: number; pan?: number } | null {
|
||||
if (!sourcePosition) {
|
||||
return { gain: baseGain };
|
||||
}
|
||||
const distance = Math.hypot(sourcePosition.x, sourcePosition.y);
|
||||
if (distance > HEARING_RADIUS) {
|
||||
return null;
|
||||
}
|
||||
const volumeRatio = Math.max(0, 1 - distance / HEARING_RADIUS);
|
||||
const finalGain = baseGain * Math.pow(volumeRatio, 2);
|
||||
const clampedX = Math.max(-HEARING_RADIUS, Math.min(HEARING_RADIUS, sourcePosition.x));
|
||||
const pan = Math.sin((clampedX / HEARING_RADIUS) * (Math.PI / 2));
|
||||
return { gain: finalGain, pan };
|
||||
}
|
||||
|
||||
private async getSampleBuffer(url: string): Promise<AudioBuffer> {
|
||||
if (!this.audioCtx) {
|
||||
throw new Error('Audio context not initialized');
|
||||
}
|
||||
if (this.sampleCache.has(url)) {
|
||||
return this.sampleCache.get(url)!;
|
||||
}
|
||||
if (!this.sampleLoaders.has(url)) {
|
||||
this.sampleLoaders.set(
|
||||
url,
|
||||
fetch(url)
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error(`Failed to fetch sample: ${url}`);
|
||||
return response.arrayBuffer();
|
||||
})
|
||||
.then((data) => this.audioCtx!.decodeAudioData(data))
|
||||
.then((buffer) => {
|
||||
this.sampleCache.set(url, buffer);
|
||||
this.sampleLoaders.delete(url);
|
||||
return buffer;
|
||||
})
|
||||
.catch((error) => {
|
||||
this.sampleLoaders.delete(url);
|
||||
throw error;
|
||||
}),
|
||||
);
|
||||
}
|
||||
return this.sampleLoaders.get(url)!;
|
||||
}
|
||||
}
|
||||
30
client/src/input/textInput.ts
Normal file
30
client/src/input/textInput.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export function applyTextInput(
|
||||
key: string,
|
||||
currentString: string,
|
||||
cursorPos: number,
|
||||
maxLength: number,
|
||||
): { newString: string; newCursorPos: number } {
|
||||
let newString = currentString;
|
||||
let newCursorPos = cursorPos;
|
||||
const lowerKey = key.toLowerCase();
|
||||
|
||||
if (lowerKey === 'arrowleft') {
|
||||
newCursorPos = Math.max(0, cursorPos - 1);
|
||||
} else if (lowerKey === 'arrowright') {
|
||||
newCursorPos = Math.min(newString.length, cursorPos + 1);
|
||||
} else if (lowerKey === 'backspace') {
|
||||
if (cursorPos > 0) {
|
||||
newString = newString.slice(0, cursorPos - 1) + newString.slice(cursorPos);
|
||||
newCursorPos = cursorPos - 1;
|
||||
}
|
||||
} else if (lowerKey === 'home') {
|
||||
newCursorPos = 0;
|
||||
} else if (lowerKey === 'end') {
|
||||
newCursorPos = newString.length;
|
||||
} else if (key.length === 1 && newString.length < maxLength) {
|
||||
newString = newString.slice(0, cursorPos) + key + newString.slice(cursorPos);
|
||||
newCursorPos = cursorPos + 1;
|
||||
}
|
||||
|
||||
return { newString, newCursorPos };
|
||||
}
|
||||
1779
client/src/main.ts
Normal file
1779
client/src/main.ts
Normal file
File diff suppressed because it is too large
Load Diff
149
client/src/network/protocol.ts
Normal file
149
client/src/network/protocol.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const itemSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.enum(['radio_station', 'dice']),
|
||||
title: z.string(),
|
||||
x: z.number().int(),
|
||||
y: z.number().int(),
|
||||
createdBy: z.string(),
|
||||
createdAt: z.number().int(),
|
||||
updatedAt: z.number().int(),
|
||||
version: z.number().int(),
|
||||
capabilities: z.array(z.string()),
|
||||
useSound: z.string().optional(),
|
||||
params: z.record(z.string(), z.unknown()),
|
||||
carrierId: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const welcomeMessageSchema = z.object({
|
||||
type: z.literal('welcome'),
|
||||
id: z.string(),
|
||||
users: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
nickname: z.string(),
|
||||
x: z.number().int(),
|
||||
y: z.number().int(),
|
||||
}),
|
||||
),
|
||||
items: z.array(itemSchema).optional(),
|
||||
});
|
||||
|
||||
export const signalMessageSchema = z.object({
|
||||
type: z.literal('signal'),
|
||||
senderId: z.string(),
|
||||
senderNickname: z.string().optional(),
|
||||
x: z.number().int().optional(),
|
||||
y: z.number().int().optional(),
|
||||
targetId: z.string().optional(),
|
||||
sdp: z.any().optional(),
|
||||
ice: z.any().optional(),
|
||||
});
|
||||
|
||||
export const updatePositionSchema = z.object({
|
||||
type: z.literal('update_position'),
|
||||
id: z.string(),
|
||||
x: z.number().int(),
|
||||
y: z.number().int(),
|
||||
});
|
||||
|
||||
export const updateNicknameSchema = z.object({
|
||||
type: z.literal('update_nickname'),
|
||||
id: z.string(),
|
||||
nickname: z.string().min(1).max(32),
|
||||
});
|
||||
|
||||
export const userLeftSchema = z.object({
|
||||
type: z.literal('user_left'),
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export const chatMessageSchema = z.object({
|
||||
type: z.literal('chat_message'),
|
||||
message: z.string(),
|
||||
senderId: z.string().optional(),
|
||||
senderNickname: z.string().optional(),
|
||||
system: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const pongSchema = z.object({
|
||||
type: z.literal('pong'),
|
||||
clientSentAt: z.number().int(),
|
||||
});
|
||||
|
||||
export const nicknameResultSchema = z.object({
|
||||
type: z.literal('nickname_result'),
|
||||
accepted: z.boolean(),
|
||||
requestedNickname: z.string(),
|
||||
effectiveNickname: z.string(),
|
||||
reason: z.string().optional(),
|
||||
});
|
||||
|
||||
export const itemUpsertSchema = z.object({
|
||||
type: z.literal('item_upsert'),
|
||||
item: itemSchema,
|
||||
});
|
||||
|
||||
export const itemRemoveSchema = z.object({
|
||||
type: z.literal('item_remove'),
|
||||
itemId: z.string(),
|
||||
});
|
||||
|
||||
export const itemActionResultSchema = z.object({
|
||||
type: z.literal('item_action_result'),
|
||||
ok: z.boolean(),
|
||||
action: z.enum(['add', 'pickup', 'drop', 'delete', 'use', 'update']),
|
||||
message: z.string(),
|
||||
itemId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const itemUseSoundSchema = z.object({
|
||||
type: z.literal('item_use_sound'),
|
||||
itemId: z.string(),
|
||||
sound: z.string(),
|
||||
x: z.number().int(),
|
||||
y: z.number().int(),
|
||||
});
|
||||
|
||||
export const incomingMessageSchema = z.discriminatedUnion('type', [
|
||||
welcomeMessageSchema,
|
||||
signalMessageSchema,
|
||||
updatePositionSchema,
|
||||
updateNicknameSchema,
|
||||
userLeftSchema,
|
||||
chatMessageSchema,
|
||||
pongSchema,
|
||||
nicknameResultSchema,
|
||||
itemUpsertSchema,
|
||||
itemRemoveSchema,
|
||||
itemActionResultSchema,
|
||||
itemUseSoundSchema,
|
||||
]);
|
||||
|
||||
export type IncomingMessage = z.infer<typeof incomingMessageSchema>;
|
||||
|
||||
export type OutgoingMessage =
|
||||
| { type: 'signal'; targetId: string; sdp?: RTCSessionDescriptionInit; ice?: RTCIceCandidateInit }
|
||||
| { type: 'update_position'; x: number; y: number }
|
||||
| { type: 'update_nickname'; nickname: string }
|
||||
| { type: 'chat_message'; message: string }
|
||||
| { type: 'ping'; clientSentAt: number }
|
||||
| { type: 'item_add'; itemType: 'radio_station' | 'dice' }
|
||||
| { type: 'item_pickup'; itemId: string }
|
||||
| { type: 'item_drop'; itemId: string; x: number; y: number }
|
||||
| { type: 'item_delete'; itemId: string }
|
||||
| { type: 'item_use'; itemId: string }
|
||||
| {
|
||||
type: 'item_update';
|
||||
itemId: string;
|
||||
title?: string;
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type RemoteUser = {
|
||||
id: string;
|
||||
nickname: string;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
76
client/src/network/signalingClient.ts
Normal file
76
client/src/network/signalingClient.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { incomingMessageSchema, type IncomingMessage, type OutgoingMessage } from './protocol';
|
||||
|
||||
type MessageHandler = (message: IncomingMessage) => void | Promise<void>;
|
||||
type StatusHandler = (message: string) => void;
|
||||
|
||||
export class SignalingClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private timeoutId: number | null = null;
|
||||
|
||||
constructor(private readonly url: string, private readonly status: StatusHandler) {}
|
||||
|
||||
async connect(onMessage: MessageHandler): Promise<void> {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
|
||||
|
||||
this.ws = new WebSocket(this.url);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (!this.ws) {
|
||||
reject(new Error('WebSocket unavailable'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.timeoutId = window.setTimeout(() => {
|
||||
this.status('Connection timed out.');
|
||||
this.disconnect();
|
||||
reject(new Error('Connection timed out'));
|
||||
}, 10_000);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.clearTimeout();
|
||||
this.status('Connected.');
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
this.clearTimeout();
|
||||
reject(new Error('WebSocket error'));
|
||||
};
|
||||
|
||||
this.ws.onmessage = async (event) => {
|
||||
const parsed = JSON.parse(String(event.data));
|
||||
const validated = incomingMessageSchema.safeParse(parsed);
|
||||
if (!validated.success) return;
|
||||
await onMessage(validated.data);
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.clearTimeout();
|
||||
this.status('Disconnected.');
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
send(payload: OutgoingMessage): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
this.ws.send(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.clearTimeout();
|
||||
if (!this.ws) return;
|
||||
this.ws.onopen = null;
|
||||
this.ws.onmessage = null;
|
||||
this.ws.onclose = null;
|
||||
this.ws.onerror = null;
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
private clearTimeout(): void {
|
||||
if (this.timeoutId !== null) {
|
||||
window.clearTimeout(this.timeoutId);
|
||||
this.timeoutId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
86
client/src/render/canvasRenderer.ts
Normal file
86
client/src/render/canvasRenderer.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { GRID_SIZE, type GameState, type PeerState, type WorldItem } from '../state/gameState';
|
||||
|
||||
export class CanvasRenderer {
|
||||
private readonly ctx: CanvasRenderingContext2D;
|
||||
private readonly squarePixelSize: number;
|
||||
|
||||
constructor(private readonly canvas: HTMLCanvasElement) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('Unable to create 2D context');
|
||||
}
|
||||
this.ctx = ctx;
|
||||
this.squarePixelSize = canvas.width / GRID_SIZE;
|
||||
}
|
||||
|
||||
draw(state: GameState): void {
|
||||
const { ctx } = this;
|
||||
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
ctx.strokeStyle = '#374151';
|
||||
for (let i = 0; i <= GRID_SIZE; i += 1) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(i * this.squarePixelSize, 0);
|
||||
ctx.lineTo(i * this.squarePixelSize, this.canvas.height);
|
||||
ctx.moveTo(0, i * this.squarePixelSize);
|
||||
ctx.lineTo(this.canvas.width, i * this.squarePixelSize);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
for (const peer of state.peers.values()) {
|
||||
this.drawObject(peer, '#f87171', peer.nickname);
|
||||
}
|
||||
for (const item of state.items.values()) {
|
||||
if (item.carrierId) continue;
|
||||
this.drawItem(item);
|
||||
}
|
||||
this.drawObject(state.player, '#34d399', state.player.nickname);
|
||||
|
||||
if (state.mode === 'nickname' || state.mode === 'chat' || state.mode === 'itemPropertyEdit') {
|
||||
const label =
|
||||
state.mode === 'nickname' ? 'New Nickname' : state.mode === 'chat' ? 'Message' : 'Property Value';
|
||||
this.drawTextOverlay(state, label);
|
||||
}
|
||||
}
|
||||
|
||||
private drawObject(obj: Pick<PeerState, 'x' | 'y' | 'nickname'>, color: string, name: string): void {
|
||||
const drawX = obj.x * this.squarePixelSize;
|
||||
const drawY = this.canvas.height - (obj.y * this.squarePixelSize) - this.squarePixelSize;
|
||||
this.ctx.fillStyle = color;
|
||||
this.ctx.fillRect(drawX, drawY, this.squarePixelSize, this.squarePixelSize);
|
||||
this.ctx.fillStyle = 'white';
|
||||
this.ctx.font = '12px Courier New';
|
||||
this.ctx.textAlign = 'center';
|
||||
this.ctx.fillText(name, drawX + this.squarePixelSize / 2, drawY - 5);
|
||||
}
|
||||
|
||||
private drawTextOverlay(state: GameState, label: string): void {
|
||||
const { ctx } = this;
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
||||
ctx.fillRect(0, this.canvas.height / 2 - 30, this.canvas.width, 60);
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.font = '24px Courier New';
|
||||
ctx.textAlign = 'center';
|
||||
|
||||
const text = `${label}: ${state.nicknameInput}`;
|
||||
const textMetrics = ctx.measureText(text);
|
||||
const preCursorText = `${label}: ${state.nicknameInput.substring(0, state.cursorPos)}`;
|
||||
const preCursorWidth = ctx.measureText(preCursorText).width;
|
||||
const textX = this.canvas.width / 2;
|
||||
|
||||
ctx.fillText(text, textX, this.canvas.height / 2);
|
||||
if (state.cursorVisible) {
|
||||
ctx.fillRect(textX - textMetrics.width / 2 + preCursorWidth, this.canvas.height / 2 - 20, 2, 24);
|
||||
}
|
||||
}
|
||||
|
||||
private drawItem(item: WorldItem): void {
|
||||
const drawX = item.x * this.squarePixelSize;
|
||||
const drawY = this.canvas.height - (item.y * this.squarePixelSize) - this.squarePixelSize;
|
||||
this.ctx.fillStyle = item.type === 'radio_station' ? '#fbbf24' : '#60a5fa';
|
||||
this.ctx.fillRect(drawX, drawY, this.squarePixelSize, this.squarePixelSize);
|
||||
this.ctx.fillStyle = '#111827';
|
||||
this.ctx.font = 'bold 12px Courier New';
|
||||
this.ctx.textAlign = 'center';
|
||||
this.ctx.fillText(item.type === 'radio_station' ? 'R' : 'D', drawX + this.squarePixelSize / 2, drawY + 13);
|
||||
}
|
||||
}
|
||||
149
client/src/state/gameState.ts
Normal file
149
client/src/state/gameState.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
export const GRID_SIZE = 40;
|
||||
export const HEARING_RADIUS = 15;
|
||||
export const MOVE_COOLDOWN_MS = 100;
|
||||
|
||||
export type ItemType = 'radio_station' | 'dice';
|
||||
|
||||
export type WorldItem = {
|
||||
id: string;
|
||||
type: ItemType;
|
||||
title: string;
|
||||
x: number;
|
||||
y: number;
|
||||
createdBy: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
version: number;
|
||||
capabilities: string[];
|
||||
useSound?: string;
|
||||
params: Record<string, unknown>;
|
||||
carrierId?: string | null;
|
||||
};
|
||||
|
||||
export type SelectionContext = 'pickup' | 'drop' | 'delete' | 'edit' | 'use' | null;
|
||||
|
||||
export type GameMode =
|
||||
| 'normal'
|
||||
| 'nickname'
|
||||
| 'chat'
|
||||
| 'listUsers'
|
||||
| 'listItems'
|
||||
| 'addItem'
|
||||
| 'selectItem'
|
||||
| 'itemProperties'
|
||||
| 'itemPropertyEdit';
|
||||
|
||||
export type Player = {
|
||||
id: string | null;
|
||||
nickname: string;
|
||||
x: number;
|
||||
y: number;
|
||||
lastMoveTime: number;
|
||||
};
|
||||
|
||||
export type PeerState = {
|
||||
id: string;
|
||||
nickname: string;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type GameState = {
|
||||
running: boolean;
|
||||
mode: GameMode;
|
||||
keysPressed: Record<string, boolean>;
|
||||
nicknameInput: string;
|
||||
cursorPos: number;
|
||||
cursorVisible: boolean;
|
||||
sortedPeerIds: string[];
|
||||
listIndex: number;
|
||||
sortedItemIds: string[];
|
||||
itemListIndex: number;
|
||||
selectedItemIds: string[];
|
||||
selectionContext: SelectionContext;
|
||||
selectedItemIndex: number;
|
||||
selectedItemId: string | null;
|
||||
itemPropertyKeys: string[];
|
||||
itemPropertyIndex: number;
|
||||
editingPropertyKey: string | null;
|
||||
addItemTypeIndex: number;
|
||||
isMuted: boolean;
|
||||
player: Player;
|
||||
peers: Map<string, PeerState>;
|
||||
items: Map<string, WorldItem>;
|
||||
carriedItemId: string | null;
|
||||
};
|
||||
|
||||
export function createInitialState(): GameState {
|
||||
return {
|
||||
running: false,
|
||||
mode: 'normal',
|
||||
keysPressed: {},
|
||||
nicknameInput: '',
|
||||
cursorPos: 0,
|
||||
cursorVisible: true,
|
||||
sortedPeerIds: [],
|
||||
listIndex: 0,
|
||||
sortedItemIds: [],
|
||||
itemListIndex: 0,
|
||||
selectedItemIds: [],
|
||||
selectionContext: null,
|
||||
selectedItemIndex: 0,
|
||||
selectedItemId: null,
|
||||
itemPropertyKeys: [],
|
||||
itemPropertyIndex: 0,
|
||||
editingPropertyKey: null,
|
||||
addItemTypeIndex: 0,
|
||||
isMuted: false,
|
||||
player: {
|
||||
id: null,
|
||||
nickname: 'anon',
|
||||
x: 20,
|
||||
y: 20,
|
||||
lastMoveTime: 0,
|
||||
},
|
||||
peers: new Map(),
|
||||
items: new Map(),
|
||||
carriedItemId: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getNearestPeer(state: GameState): { peerId: string | null; distance: number } {
|
||||
let nearest: string | null = null;
|
||||
let minDist = Infinity;
|
||||
for (const [id, peer] of state.peers.entries()) {
|
||||
const dist = Math.hypot(peer.x - state.player.x, peer.y - state.player.y);
|
||||
if (dist < minDist) {
|
||||
minDist = dist;
|
||||
nearest = id;
|
||||
}
|
||||
}
|
||||
return { peerId: nearest, distance: minDist };
|
||||
}
|
||||
|
||||
export function getDirection(px: number, py: number, tx: number, ty: number): string {
|
||||
const dx = tx - px;
|
||||
const dy = ty - py;
|
||||
if (dx === 0 && dy === 0) return 'here';
|
||||
let vDir = '';
|
||||
let hDir = '';
|
||||
if (dy > 0) vDir = 'north';
|
||||
if (dy < 0) vDir = 'south';
|
||||
if (dx > 0) hDir = 'east';
|
||||
if (dx < 0) hDir = 'west';
|
||||
return `${vDir} ${hDir}`.trim();
|
||||
}
|
||||
|
||||
export function getNearestItem(state: GameState): { itemId: string | null; distance: number } {
|
||||
let nearest: string | null = null;
|
||||
let minDist = Infinity;
|
||||
for (const [id, item] of state.items.entries()) {
|
||||
if (item.carrierId) continue;
|
||||
const dist = Math.hypot(item.x - state.player.x, item.y - state.player.y);
|
||||
if (dist < minDist) {
|
||||
minDist = dist;
|
||||
nearest = id;
|
||||
}
|
||||
}
|
||||
return { itemId: nearest, distance: minDist };
|
||||
}
|
||||
124
client/src/styles.css
Normal file
124
client/src/styles.css
Normal file
@@ -0,0 +1,124 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: radial-gradient(circle at top, #1f2937, #0b1220 50%, #030712);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.app {
|
||||
width: min(860px, 100%);
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#appVersion {
|
||||
display: block;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
#deviceSummary {
|
||||
color: #94a3b8;
|
||||
margin: 0 auto 0.75rem;
|
||||
min-height: 2.4rem;
|
||||
}
|
||||
|
||||
#deviceSummary p {
|
||||
margin: 0.15rem 0;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.nickname-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.nickname-row label {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.nickname-row input {
|
||||
background: #111827;
|
||||
color: #e5e7eb;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
width: min(320px, 70vw);
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
background: #1d4ed8;
|
||||
color: white;
|
||||
border: 1px solid #3b82f6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #1e40af;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background: #475569;
|
||||
border-color: #64748b;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
canvas {
|
||||
background: #111827;
|
||||
border: 2px solid #60a5fa;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 20px rgb(96 165 250 / 35%);
|
||||
}
|
||||
|
||||
#status {
|
||||
height: 2rem;
|
||||
color: #86efac;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
#instructions {
|
||||
color: #94a3b8;
|
||||
text-align: left;
|
||||
margin: 0.75rem auto;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#settingsModal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgb(0 0 0 / 70%);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
min-width: 300px;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
173
client/src/webrtc/peerManager.ts
Normal file
173
client/src/webrtc/peerManager.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { AudioEngine, type SpatialPeerRuntime } from '../audio/audioEngine';
|
||||
import type { RemoteUser } from '../network/protocol';
|
||||
|
||||
export type PeerRuntime = SpatialPeerRuntime & {
|
||||
id: string;
|
||||
pc: RTCPeerConnection;
|
||||
};
|
||||
|
||||
type SendSignal = (targetId: string, payload: { sdp?: RTCSessionDescriptionInit; ice?: RTCIceCandidateInit }) => void;
|
||||
|
||||
type StatusHandler = (message: string) => void;
|
||||
|
||||
export class PeerManager {
|
||||
private readonly peers = new Map<string, PeerRuntime>();
|
||||
private outputDeviceId = '';
|
||||
|
||||
constructor(
|
||||
private readonly audio: AudioEngine,
|
||||
private readonly sendSignal: SendSignal,
|
||||
private readonly getLocalStream: () => MediaStream | null,
|
||||
private readonly status: StatusHandler,
|
||||
) {}
|
||||
|
||||
getPeer(id: string): PeerRuntime | undefined {
|
||||
return this.peers.get(id);
|
||||
}
|
||||
|
||||
getPeers(): Iterable<PeerRuntime> {
|
||||
return this.peers.values();
|
||||
}
|
||||
|
||||
async createOrGetPeer(targetId: string, isInitiator: boolean, userData: Partial<RemoteUser>): Promise<PeerRuntime> {
|
||||
const existing = this.peers.get(targetId);
|
||||
if (existing) return existing;
|
||||
|
||||
const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
|
||||
|
||||
const peer: PeerRuntime = {
|
||||
id: targetId,
|
||||
nickname: userData.nickname ?? 'user...',
|
||||
x: userData.x ?? 20,
|
||||
y: userData.y ?? 20,
|
||||
pc,
|
||||
};
|
||||
|
||||
this.peers.set(targetId, peer);
|
||||
|
||||
const stream = this.getLocalStream();
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((track) => pc.addTrack(track, stream));
|
||||
}
|
||||
|
||||
pc.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
this.sendSignal(targetId, { ice: event.candidate.toJSON() });
|
||||
}
|
||||
};
|
||||
|
||||
pc.ontrack = async (event) => {
|
||||
await this.audio.attachRemoteStream(peer, event.streams[0], this.outputDeviceId);
|
||||
};
|
||||
|
||||
if (isInitiator) {
|
||||
let offer = await pc.createOffer();
|
||||
offer = this.tuneOpus(offer);
|
||||
await pc.setLocalDescription(offer);
|
||||
this.sendSignal(targetId, { sdp: pc.localDescription ?? undefined });
|
||||
}
|
||||
|
||||
return peer;
|
||||
}
|
||||
|
||||
async handleSignal(data: {
|
||||
senderId: string;
|
||||
senderNickname?: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
sdp?: RTCSessionDescriptionInit;
|
||||
ice?: RTCIceCandidateInit;
|
||||
}): Promise<PeerRuntime> {
|
||||
const peer = await this.createOrGetPeer(data.senderId, false, {
|
||||
id: data.senderId,
|
||||
nickname: data.senderNickname,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
});
|
||||
|
||||
if (data.sdp) {
|
||||
await peer.pc.setRemoteDescription(new RTCSessionDescription(data.sdp));
|
||||
if (data.sdp.type === 'offer') {
|
||||
let answer = await peer.pc.createAnswer();
|
||||
answer = this.tuneOpus(answer);
|
||||
await peer.pc.setLocalDescription(answer);
|
||||
this.sendSignal(data.senderId, { sdp: peer.pc.localDescription ?? undefined });
|
||||
}
|
||||
}
|
||||
|
||||
if (data.ice) {
|
||||
await peer.pc.addIceCandidate(new RTCIceCandidate(data.ice)).catch(() => undefined);
|
||||
}
|
||||
|
||||
return peer;
|
||||
}
|
||||
|
||||
async replaceOutgoingTrack(stream: MediaStream): Promise<void> {
|
||||
for (const peer of this.peers.values()) {
|
||||
const sender = peer.pc.getSenders().find((candidate) => candidate.track?.kind === 'audio');
|
||||
const newTrack = stream.getAudioTracks()[0];
|
||||
if (sender && newTrack) {
|
||||
await sender.replaceTrack(newTrack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removePeer(id: string): void {
|
||||
const peer = this.peers.get(id);
|
||||
if (!peer) return;
|
||||
peer.pc.close();
|
||||
this.audio.cleanupPeerAudio(peer);
|
||||
this.peers.delete(id);
|
||||
}
|
||||
|
||||
cleanupAll(): void {
|
||||
for (const id of this.peers.keys()) {
|
||||
this.removePeer(id);
|
||||
}
|
||||
}
|
||||
|
||||
setPeerPosition(id: string, x: number, y: number): void {
|
||||
const peer = this.peers.get(id);
|
||||
if (!peer) return;
|
||||
peer.x = x;
|
||||
peer.y = y;
|
||||
}
|
||||
|
||||
setPeerNickname(id: string, nickname: string): void {
|
||||
const peer = this.peers.get(id);
|
||||
if (!peer) return;
|
||||
peer.nickname = nickname;
|
||||
}
|
||||
|
||||
async setOutputDevice(deviceId: string): Promise<void> {
|
||||
this.outputDeviceId = deviceId;
|
||||
for (const peer of this.peers.values()) {
|
||||
if (!peer.audioElement) continue;
|
||||
const sinkTarget = peer.audioElement as HTMLMediaElement & {
|
||||
setSinkId?: (id: string) => Promise<void>;
|
||||
};
|
||||
await sinkTarget.setSinkId?.(deviceId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private tuneOpus(desc: RTCSessionDescriptionInit): RTCSessionDescriptionInit {
|
||||
if (!desc.sdp) return desc;
|
||||
const lines = desc.sdp.split('\r\n');
|
||||
let opusPayload: string | undefined;
|
||||
for (const line of lines) {
|
||||
if (line.includes('opus/48000')) {
|
||||
const match = line.match(/(\d+) opus\/48000/);
|
||||
if (match) opusPayload = match[1];
|
||||
}
|
||||
}
|
||||
if (opusPayload) {
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
if (lines[index].includes(`a=fmtp:${opusPayload}`)) {
|
||||
lines[index] += ';maxaveragebitrate=128000;stereo=1;sprop-stereo=1;useinbandfec=1;usedtx=0';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...desc, sdp: lines.join('\r\n') };
|
||||
}
|
||||
}
|
||||
14
client/tsconfig.json
Normal file
14
client/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
17
client/vite.config.ts
Normal file
17
client/vite.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
const base = process.env.VITE_BASE_PATH || '/';
|
||||
|
||||
export default defineConfig({
|
||||
base,
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/ws': {
|
||||
target: 'ws://127.0.0.1:8765',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user