Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ voxel engine from [Fable5-mc](https://github.com/souramoo/Fable5-mc)):
- **Minimap** — a north-up radar in the corner with height-shaded terrain,
your view arrow, and a colored dot per player (clamped to the edge when
they're far) so you can always find people.
- **Screen sharing** — press `P` (or 🖥️) to present: your screen appears on a
slideshow board standing next to your avatar, visible to everyone in voice
range, like presenting at a real meetup.
- **A full voxel sandbox** — infinite procedural terrain with biomes, caves and
ores, a day/night cycle (shared per room), mining and placing with 35 block
types, falling sand, TNT (synchronized explosions!), swimming, sprinting,
Expand Down
65 changes: 65 additions & 0 deletions src/client3d/avatar.js
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,9 @@ class Avatar {
this.bubble = null;
this._chatTimer = 0;

// ---- presentation board (screen share) ----
this.screen = null;

// ---- motion state ----
this.target = new THREE.Vector3();
this.targetYaw = 0;
Expand Down Expand Up @@ -215,6 +218,65 @@ class Avatar {
this.faceMat.needsUpdate = true;
}

/**
* Show a screen share as a slideshow board standing beside the avatar
* (to their right, facing the same way they face — audience in front
* of the presenter sees both them and the slides).
*/
setScreen(videoEl) {
this.clearScreen();
const W = 2.4, H = 1.35; // 16:9-ish board

const tex = new THREE.VideoTexture(videoEl);
tex.colorSpace = THREE.SRGBColorSpace;

const group = new THREE.Group();
const frameMat = new THREE.MeshBasicMaterial({ color: 0x2b2620 });
const centerY = 1.45;

const frame = new THREE.Mesh(new THREE.BoxGeometry(W + 0.14, H + 0.14, 0.07), frameMat);
frame.position.y = centerY;
group.add(frame);

const screenMat = new THREE.MeshBasicMaterial({ map: tex });
const plane = new THREE.Mesh(new THREE.PlaneGeometry(W, H), screenMat);
plane.position.set(0, centerY, -0.04);
plane.rotation.y = Math.PI; // face -Z, same way the avatar faces
group.add(plane);

// easel legs down to the ground
const legH = centerY - H / 2;
for (const side of [-1, 1]) {
const leg = new THREE.Mesh(new THREE.BoxGeometry(0.07, legH + 0.05, 0.07), frameMat);
leg.position.set(side * (W / 2 - 0.1), legH / 2, 0);
group.add(leg);
}

group.position.set(1.9, 0, 0); // beside the avatar (their right)
this.group.add(group);
this.screen = { group, tex, screenMat, frameMat };

// match the real aspect ratio once the stream reports it
videoEl.addEventListener('loadedmetadata', () => {
if (this.screen?.tex !== tex) return;
if (!videoEl.videoWidth || !videoEl.videoHeight) return;
const aspect = videoEl.videoWidth / videoEl.videoHeight;
const sx = Math.min(1.25, Math.max(0.6, aspect / (W / H)));
plane.scale.x = sx;
frame.scale.x = sx;
}, { once: true });
}

clearScreen() {
if (!this.screen) return;
this.group.remove(this.screen.group);
this.screen.tex.dispose();
this.screen.screenMat.dispose();
this.screen.frameMat.dispose();
this.screen.group.traverse((o) => o.geometry?.dispose());
this.screen = null;
}

setChat(text) {
this.clearChat();
this.bubble = chatSprite(text);
Expand Down Expand Up @@ -253,6 +315,7 @@ class Avatar {
dispose() {
this.clearVideo();
this.clearChat();
this.clearScreen();
this.group.parent?.remove(this.group);
}
}
Expand Down Expand Up @@ -286,6 +349,8 @@ export class Avatars {
setVideo(id, videoEl) { this.map.get(id)?.setVideo(videoEl); }
clearVideo(id) { this.map.get(id)?.clearVideo(); }
setChat(id, text) { this.map.get(id)?.setChat(text); }
setScreen(id, videoEl) { this.map.get(id)?.setScreen(videoEl); }
clearScreen(id) { this.map.get(id)?.clearScreen(); }

remove(id) {
const a = this.map.get(id);
Expand Down
2 changes: 2 additions & 0 deletions src/client3d/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
<button id="btn-mic" class="av-button" title="Toggle microphone (M)">🎙️</button>
<button id="btn-cam" class="av-button" title="Toggle camera (V)">📷</button>
<button id="btn-switch-cam" class="av-button" title="Switch camera (C)">🔄</button>
<button id="btn-screen" class="av-button" title="Present your screen (P)">🖥️</button>
</div>
</div>

Expand Down Expand Up @@ -140,6 +141,7 @@
<span>Text chat</span><span>T or ENTER</span>
<span>Mute mic / camera</span><span>M / V</span>
<span>Switch camera</span><span>C</span>
<span>Present screen</span><span>P</span>
<span>Jump / Swim up</span><span>SPACE</span>
<span>Sneak (edge-safe)</span><span>SHIFT</span>
<span>Sprint</span><span>CTRL or double-tap W</span>
Expand Down
43 changes: 43 additions & 0 deletions src/client3d/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,43 @@ class Game {
this.camBtn.addEventListener('click', () => this.toggleCam());
this.switchingCam = false;
document.getElementById('btn-switch-cam').addEventListener('click', () => this.switchCamera());
this.screenBtn = document.getElementById('btn-screen');
this.screenStream = null;
this.screenBtn.addEventListener('click', () => this.toggleScreenShare());
}

/** Present your screen on a board next to your avatar. */
async toggleScreenShare() {
if (this.screenStream) { this.stopScreenShare(); return; }
if (this.state !== 'playing' && this.state !== 'paused') return;
if (!navigator.mediaDevices?.getDisplayMedia) {
this.ui.showToast('Screen sharing is not supported in this browser');
return;
}
try {
const stream = await navigator.mediaDevices.getDisplayMedia({
video: { width: { max: 1280 }, height: { max: 720 }, frameRate: { max: 15 } },
audio: false,
});
this.screenStream = stream;
// the browser's own "stop sharing" bar ends the track
stream.getVideoTracks()[0].addEventListener('ended', () => this.stopScreenShare());
this.rtc?.startScreenShare(stream);
this.screenBtn.classList.add('live');
this.ui.showToast('Presenting — people nearby can see your screen');
} catch (err) {
console.warn('[media] screen share', err);
this.ui.showToast('Screen share cancelled');
}
}

stopScreenShare() {
if (!this.screenStream) return;
for (const t of this.screenStream.getTracks()) t.stop();
this.screenStream = null;
this.rtc?.stopScreenShare();
this.screenBtn.classList.remove('live');
this.ui.showToast('Stopped presenting');
}

/** Cycle to the next video input device (front/back camera, webcams…). */
Expand Down Expand Up @@ -462,7 +499,10 @@ class Game {
this.rtc = new Rtc(this.audio.ctx);
this.rtc.onVideo = (id, video) => this.avatars.setVideo(id, video);
this.rtc.onVideoEnd = (id) => this.avatars.clearVideo(id);
this.rtc.onScreen = (id, video) => this.avatars.setScreen(id, video);
this.rtc.onScreenEnd = (id) => this.avatars.clearScreen(id);
this.rtc.init(this.net.id, this.localStream);
if (this.screenStream) this.rtc.startScreenShare(this.screenStream);
}

sendStateNow() {
Expand Down Expand Up @@ -724,6 +764,9 @@ class Game {
case 'KeyC':
this.switchCamera();
break;
case 'KeyP':
this.toggleScreenShare();
break;
case 'KeyT':
case 'Enter':
e.preventDefault();
Expand Down
115 changes: 111 additions & 4 deletions src/client3d/rtc.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ export class Rtc {
this.calls = new Map(); // socketId -> { call, video, source, panner, since }
this.pending = new Map(); // socketId -> when we started dialing them

// screen sharing: outgoing one-way "screen" calls when presenting,
// and boards we're receiving from presenters in range
this.screenStream = null;
this.screenCalls = new Map(); // socketId -> outgoing screen call
this.remoteScreens = new Map(); // socketId -> { call, video }

this.voiceGain = ctx.createGain();
this.voiceGain.gain.value = 1;
this.voiceGain.connect(ctx.destination);
Expand All @@ -42,8 +48,10 @@ export class Rtc {
this.mediaHolder.style.cssText = 'position:fixed;width:0;height:0;overflow:hidden;';
document.body.appendChild(this.mediaHolder);

this.onVideo = null; // (socketId, videoElement)
this.onVideoEnd = null; // (socketId)
this.onVideo = null; // (socketId, videoElement)
this.onVideoEnd = null; // (socketId)
this.onScreen = null; // (socketId, videoElement) — they started presenting
this.onScreenEnd = null; // (socketId)
}

/** @param {MediaStream|null} stream local cam/mic (null = spectator) */
Expand All @@ -54,6 +62,11 @@ export class Rtc {
config: { iceServers: ICE_SERVERS },
});
this.peer.on('call', (call) => {
if (call.metadata && call.metadata.type === 'screen') {
call.answer(); // one-way: we just watch
this._wireIncomingScreen(call);
return;
}
call.answer(this.myStream);
this._wireCall(call);
});
Expand Down Expand Up @@ -139,6 +152,13 @@ export class Rtc {

hangUp(socketId) {
this.pending.delete(socketId);
// drop any presentation traffic with this peer too
const screenCall = this.screenCalls.get(socketId);
if (screenCall) {
this.screenCalls.delete(socketId);
try { screenCall.close(); } catch { /* already closed */ }
}
this._closeRemoteScreen(socketId);
const entry = this.calls.get(socketId);
if (!entry) return;
this.calls.delete(socketId);
Expand Down Expand Up @@ -170,15 +190,32 @@ export class Rtc {
const seen = new Set();
for (const { id, distance } of others) {
seen.add(id);
if (distance < CALL_DISTANCE) this.call(id);
else if (distance > HANGUP_DISTANCE) this.hangUp(id);
if (distance < CALL_DISTANCE) {
this.call(id);
if (this.screenStream) this._dialScreen(id);
} else if (distance > HANGUP_DISTANCE) {
this.hangUp(id);
}
}
for (const id of [...this.calls.keys()]) {
if (!seen.has(id)) this.hangUp(id);
}
for (const id of [...this.pending.keys()]) {
if (!seen.has(id)) this.pending.delete(id);
}

// presentation boards follow the same range rules as voice
for (const [id, call] of [...this.screenCalls]) {
const o = others.find((m) => m.id === id);
if (!o || o.distance > HANGUP_DISTANCE) {
try { call.close(); } catch { /* already closed */ }
this.screenCalls.delete(id);
}
}
for (const id of [...this.remoteScreens.keys()]) {
const o = others.find((m) => m.id === id);
if (!o || o.distance > HANGUP_DISTANCE) this._closeRemoteScreen(id);
}
}

/** Per-frame: move the WebAudio listener to the camera. */
Expand Down Expand Up @@ -209,6 +246,74 @@ export class Rtc {
}
}

// ----------------------------------------------------------
// Screen sharing ("presenting")
// ----------------------------------------------------------

startScreenShare(stream) {
this.screenStream = stream;
// people already in a voice call see the board immediately;
// anyone walking into range later is dialed by updateProximity
for (const id of this.calls.keys()) this._dialScreen(id);
}

stopScreenShare() {
this.screenStream = null;
for (const call of this.screenCalls.values()) {
try { call.close(); } catch { /* already closed */ }
}
this.screenCalls.clear();
}

_dialScreen(socketId) {
if (!this.peer || !this.screenStream || this.screenCalls.has(socketId)) return;
const call = this.peer.call(PEER_PREFIX + socketId, this.screenStream, {
metadata: { type: 'screen' },
});
if (!call) return;
this.screenCalls.set(socketId, call);
call.on('close', () => this.screenCalls.delete(socketId));
call.on('error', () => this.screenCalls.delete(socketId));
}

_wireIncomingScreen(call) {
const id = this._socketIdOf(call);
this._closeRemoteScreen(id); // a fresh share replaces the old board
const entry = { call, video: null };
this.remoteScreens.set(id, entry);

call.on('stream', (remote) => {
if (this.remoteScreens.get(id) !== entry || entry.video) return;
const video = document.createElement('video');
video.muted = true;
video.autoplay = true;
video.playsInline = true;
video.setAttribute('playsinline', '');
video.srcObject = remote;
this.mediaHolder.appendChild(video);
video.play().catch(() => {});
entry.video = video;
this.onScreen?.(id, video);
});
const end = () => {
if (this.remoteScreens.get(id) === entry) this._closeRemoteScreen(id);
};
call.on('close', end);
call.on('error', end);
}

_closeRemoteScreen(id) {
const entry = this.remoteScreens.get(id);
if (!entry) return;
this.remoteScreens.delete(id);
try { entry.call.close(); } catch { /* already closed */ }
if (entry.video) {
entry.video.srcObject = null;
entry.video.remove();
}
this.onScreenEnd?.(id);
}

/** Swap the outgoing video track on every live call (camera switch). */
replaceVideoTrack(track) {
for (const { call } of this.calls.values()) {
Expand All @@ -223,6 +328,8 @@ export class Rtc {
}

dispose() {
this.stopScreenShare();
for (const id of [...this.remoteScreens.keys()]) this._closeRemoteScreen(id);
for (const id of [...this.calls.keys()]) this.hangUp(id);
this.peer?.destroy();
}
Expand Down
1 change: 1 addition & 0 deletions src/client3d/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,7 @@ html, body {
}
.av-button:hover { background: rgba(80, 80, 110, 0.9); }
.av-button.off { background: rgba(140, 40, 30, 0.9); }
.av-button.live { background: rgba(60, 140, 50, 0.9); }

#room-status {
position: absolute;
Expand Down
Loading