Skip to content

Commit ee6ea23

Browse files
committed
Begin hooking up the spectation manager
1 parent 3a33cca commit ee6ea23

File tree

8 files changed

+236
-8
lines changed

8 files changed

+236
-8
lines changed

javascript/entities/player.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { toFloat } from 'base/float.js';
2121
// * Environment
2222
//
2323
// * Interaction
24+
// * Spectating
2425
//
2526
// * Audio
2627
// * Visual
@@ -89,6 +90,11 @@ export class Player extends Supplementable {
8990
static kSpecialActionCarry = 25; // does not work on skin 0 (CJ)
9091
static kSpecialActionPissing = 68;
9192

93+
// Modes of spectating that players can be engaged in.
94+
static kSpectateNormal = 0;
95+
static kSpectateFixed = 1;
96+
static kSpectateSide = 2;
97+
9298
// Constants applicable to the `Player.state` property.
9399
static kStateNone = 0;
94100
static kStateOnFoot = 1;
@@ -426,6 +432,21 @@ export class Player extends Supplementable {
426432
pawnInvoke('GameTextForPlayer', 'isii', this.id, message, time, style);
427433
}
428434

435+
// ---------------------------------------------------------------------------------------------
436+
// Section: Spectating
437+
// ---------------------------------------------------------------------------------------------
438+
439+
spectatePlayer(player, mode = Player.kSpectateNormal) {
440+
pawnInvoke('PlayerSpectateVehicle', 'iii', this.#id_, player.id, mode);
441+
}
442+
443+
spectateVehicle(vehicle, mode = Player.kSpectateNormal) {
444+
pawnInvoke('PlayerSpectateVehicle', 'iii', this.#id_, vehicle.id, mode);
445+
}
446+
447+
get spectating() { /* this is a read-only value on the server */ }
448+
set spectating(value) { pawnInvoke('TogglePlayerSpectating', 'ii', this.#id_, value ? 1 : 0); }
449+
429450
// ---------------------------------------------------------------------------------------------
430451
// Section: Audio
431452
// ---------------------------------------------------------------------------------------------

javascript/entities/test/mock_player.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ export class MockPlayer extends Player {
6363
#lastDialogPromise_ = null;
6464
#lastDialogPromiseResolve_ = null;
6565

66+
#spectating_ = false;
67+
#spectateTarget_ = null;
68+
6669
#streamUrl_ = null;
6770
#soundId_ = null;
6871

@@ -356,6 +359,34 @@ export class MockPlayer extends Player {
356359
// Gets the messages that have been sent to this player.
357360
get messages() { return this.#messages_; }
358361

362+
// ---------------------------------------------------------------------------------------------
363+
// Section: Spectating
364+
// ---------------------------------------------------------------------------------------------
365+
366+
spectatePlayer(player, mode = Player.kSpectateNormal) {
367+
if (!this.#spectating_)
368+
throw new Error('The player must be spectating before picking a target.');
369+
370+
this.#spectateTarget_ = player;
371+
}
372+
373+
spectateVehicle(vehicle, mode = Player.kSpectateNormal) {
374+
if (!this.#spectating_)
375+
throw new Error('The player must be spectating before picking a target.');
376+
377+
this.#spectateTarget_ = vehicle;
378+
}
379+
380+
get spectating() { /* this is a read-only value on the server */ }
381+
set spectating(value) {
382+
this.#spectating_ = !!value;
383+
if (!this.#spectating_)
384+
this.#spectateTarget_ = null;
385+
}
386+
387+
get spectatingForTesting() { return this.#spectating_; }
388+
get spectateTargetForTesting() { return this.#spectateTarget_; }
389+
359390
// ---------------------------------------------------------------------------------------------
360391
// Section: Audio
361392
// ---------------------------------------------------------------------------------------------

javascript/features/settings/setting_list.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

javascript/features/spectate/spectate.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@ export default class Spectate extends Feature {
1616
// including administrative tools and games.
1717
this.markLowLevel();
1818

19+
// Influences behaviour and frequency of the spectation monitor.
20+
const settings = this.defineDependency('settings');
21+
1922
// The spectate manager keeps track of which players are spectating who, and makes sure that
2023
// their intention continues to be preseved despite player state changes.
21-
this.manager_ = new SpectateManager();
24+
this.manager_ = new SpectateManager(settings);
2225
}
2326

2427
// ---------------------------------------------------------------------------------------------

javascript/features/spectate/spectate_group.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,18 @@ export class SpectateGroup {
2828
// Gets the number of players who are part of this group. May be zero.
2929
get size() { return this.#players_.size; }
3030

31+
// Provides access to the players through an iterator.
32+
[Symbol.iterator]() { return this.#players_.values(); }
33+
3134
// Adds the given |player| to the group.
3235
addPlayer(player) {
3336
this.#players_.add(player);
3437
this.#manager_.onGroupUpdated(this);
3538
}
3639

40+
// Returns whether the given |player| is part of this group.
41+
hasPlayer(player) { return this.#players_.has(player); }
42+
3743
// Removes the given |player| from the group.
3844
removePlayer(player) {
3945
this.#players_.delete(player);

javascript/features/spectate/spectate_manager.js

Lines changed: 110 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,25 @@
33
// be found in the LICENSE file.
44

55
import { SpectateGroup } from 'features/spectate/spectate_group.js';
6+
import { SpectateState } from 'features/spectate/spectate_state.js';
67

78
// Responsible for keeping track of which players are spectating which player groups, and maintains
89
// the global player group through which all players can be spectated.
910
export class SpectateManager {
1011
#globalGroup_ = null;
12+
#monitoring_ = false;
13+
#settings_ = null;
14+
#spectating_ = null;
15+
16+
constructor(settings) {
17+
this.#settings_ = settings;
1118

12-
constructor() {
1319
// Initialize the global spectate group, through which all players can be observed.
1420
this.#globalGroup_ = new SpectateGroup(this, SpectateGroup.kSwitchAbandonBehaviour);
1521

22+
// Map keyed by Player, valued by SpectateState instances for active spectators.
23+
this.#spectating_ = new Map();
24+
1625
server.playerManager.addObserver(this, /* replayHistory= */ true);
1726
}
1827

@@ -21,15 +30,100 @@ export class SpectateManager {
2130
// ---------------------------------------------------------------------------------------------
2231

2332
// Called when the |player| should start spectating the given |group|, optionally starting with
24-
// the given |targetPlayer|. All permission and ability checks should've been done already.
25-
spectate(player, group, targetPlayer = null) {
26-
// TODO: Implement this function
33+
// the given |target|. All permission and ability checks should've been done already. Returns
34+
// whether the spectation has started, which would fail iff |target| is spectating too.
35+
spectate(player, group, target = null) {
36+
if (target && !group.hasPlayer(target))
37+
throw new Error(`It's not possible to spectate players not in the SpectateGroup`);
38+
39+
if (!target && !group.size)
40+
throw new Error(`It's not possible to spectate an empty SpectateGroup.`);
41+
42+
// Bail out if the |target| is spectating too. Can't spectate someone who's spectating.
43+
if (this.#spectating_.has(target))
44+
return false;
45+
46+
// If no |target| was given, pick the first player in the |group|. The |player| will be able
47+
// to move back and forth within the group as they please.
48+
if (!target)
49+
target = [ ...group ][0];
50+
51+
// Put the |player| in the same Virtual World and interior as the |target|.
52+
player.virtualWorld = target.virtualWorld;
53+
player.interiorId = target.interiorId;
54+
55+
// If the |player| hasn't been put in spectator mode yet, do this now.
56+
if (!this.#spectating_.has(player))
57+
player.spectating = true;
58+
59+
this.#spectating_.set(player, new SpectateState(group, target));
60+
61+
// Synchronize the environment of the |player|.
62+
this.synchronizeEnvironment(player);
63+
64+
// If the monitor isn't running yet, start it to keep player state updated.
65+
if (!this.#monitoring_)
66+
this.monitor();
67+
68+
return true;
69+
}
70+
71+
// Synchronizes the environment of the |player| with their target, to make sure that they stay
72+
// near each other when spectating. This includes interior and virtual world changes.
73+
synchronizeEnvironment(player) {
74+
const state = this.#spectating_.get(player);
75+
const target = state.target;
76+
77+
// (1) The entity that the |player| is meant to be watching. If they're not, make it so.
78+
const targetEntity = target.vehicle ?? target;
79+
if (targetEntity !== state.targetEntity) {
80+
if (target.vehicle)
81+
player.spectateVehicle(target.vehicle);
82+
else
83+
player.spectatePlayer(target);
84+
}
85+
86+
// (2) Make sure that the |player| and |targetEntity| are in the same environment.
87+
if (player.virtualWorld !== targetEntity.virtualWorld)
88+
player.virtualWorld = targetEntity.virtualWorld;
89+
90+
if (player.interiorId !== targetEntity.interiorId)
91+
player.interiorId = targetEntity.interiorId;
92+
93+
// (3) Maybe have some sort of 3D text label?
2794
}
2895

2996
// Called when the |player| should stop spectating. This function will silently fail if they are
3097
// not currently spectating anyone, as there is no work to do.
3198
stopSpectate(player) {
32-
// TODO: Implement this function
99+
const state = this.#spectating_.get(player);
100+
if (!state)
101+
return; // the |player| is not currently spectating
102+
103+
// Clear out the |player|'s state, they won't be spectating anyone anymore.
104+
this.#spectating_.delete(player);
105+
106+
// Move the player out of spectation mode. This will respawn them.
107+
player.spectating = false;
108+
}
109+
110+
// ---------------------------------------------------------------------------------------------
111+
// Section: spectation monitor
112+
// ---------------------------------------------------------------------------------------------
113+
114+
// Spins while there are players on the server who are spectating others. Will shut down when
115+
// the last player has stopped spectating, moving the system into idle mode.
116+
async monitor() {
117+
await wait(this.#settings_().getValue('playground/spectator_monitor_frequency_ms'));
118+
119+
do {
120+
for (const player of this.#spectating_.keys())
121+
this.synchronizeEnvironment(player);
122+
123+
// Wait for the configured interval before iterating in the next round.
124+
await wait(this.#settings_().getValue('playground/spectator_monitor_frequency_ms'));
125+
126+
} while (this.#monitoring_ && this.#spectating_.size);
33127
}
34128

35129
// ---------------------------------------------------------------------------------------------
@@ -51,9 +145,10 @@ export class SpectateManager {
51145
// Called when the given |player| has disconnected from the server. Removes them from the global
52146
// spectate group, and cleans up any remaining state if they're currently spectating someone.
53147
onPlayerDisconnect(player) {
54-
this.#globalGroup_.removePlayer(player);
148+
if (this.#spectating_.has(player))
149+
this.#spectating_.delete(player);
55150

56-
// TODO: Clean-up state if the |player| is currently spectating anyone.
151+
this.#globalGroup_.removePlayer(player);
57152
}
58153

59154
// ---------------------------------------------------------------------------------------------
@@ -68,5 +163,13 @@ export class SpectateManager {
68163

69164
dispose() {
70165
server.playerManager.removeObserver(this);
166+
167+
// Forcefully stop all current spectators from spectating. This would cause a bug for people
168+
// currently in between rounds in a game, but work fine in all other cases.
169+
for (const player of this.#spectating_.keys())
170+
this.stopSpectate(player);
171+
172+
this.#monitoring_ = false;
173+
this.#settings_ = null;
71174
}
72175
}

javascript/features/spectate/spectate_manager.test.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,60 @@
44

55
describe('SpectateManager', (it, beforeEach) => {
66
let gunther = null;
7+
let lucy = null;
78
let manager = null;
89
let russell = null;
910

1011
beforeEach(() => {
1112
const feature = server.featureManager.loadFeature('spectate');
1213

1314
gunther = server.playerManager.getById(/* Gunther= */ 0);
15+
lucy = server.playerManager.getById(/* Lucy= */ 2);
1416
manager = feature.manager_;
1517
russell = server.playerManager.getById(/* Russell= */ 1);
1618
});
1719

20+
it('should enable players to spectate others despite group changes', async (assert) => {
21+
const settings = server.featureManager.loadFeature('settings');
22+
23+
const frequency = settings.getValue('playground/spectator_monitor_frequency_ms');
24+
const group = manager.getGlobalGroup();
25+
26+
assert.isFalse(gunther.spectatingForTesting);
27+
assert.isFalse(russell.spectatingForTesting);
28+
assert.isFalse(lucy.spectatingForTesting);
29+
30+
// (1) |gunther| should be able to spectate |russell|.
31+
assert.isTrue(manager.spectate(gunther, group, russell));
32+
33+
assert.isTrue(gunther.spectatingForTesting);
34+
assert.isFalse(russell.spectatingForTesting);
35+
36+
assert.strictEqual(gunther.spectateTargetForTesting, russell);
37+
38+
// (2) |lucy| is unable to spectate |gunther|, as they're spectating already.
39+
assert.isFalse(manager.spectate(lucy, group, gunther));
40+
41+
assert.isFalse(lucy.spectatingForTesting);
42+
assert.isNull(lucy.spectateTargetForTesting);
43+
44+
// (3) When |russell| disconnects from the server, |gunther| should move to |lucy|.
45+
46+
// TODO
47+
48+
// (4) When |lucy|'s interior or virtual world changes, this should update for |gunther|.
49+
assert.equal(gunther.virtualWorld, russell.virtualWorld);
50+
assert.equal(gunther.interiorId, russell.interiorId);
51+
52+
russell.virtualWorld = 12;
53+
russell.interiorId = 1;
54+
55+
await server.clock.advance(frequency);
56+
57+
assert.equal(gunther.virtualWorld, russell.virtualWorld);
58+
assert.equal(gunther.interiorId, russell.interiorId);
59+
});
60+
1861
it('should be able to maintain the global spectation group', assert => {
1962
const originalPlayerCount = server.playerManager.count;
2063
const group = manager.getGlobalGroup();
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Copyright 2020 Las Venturas Playground. All rights reserved.
2+
// Use of this source code is governed by the MIT license, a copy of which can
3+
// be found in the LICENSE file.
4+
5+
// Class that describes a player's state while they're spectating.
6+
export class SpectateState {
7+
// The SpectateGroup that the owner of this instance is spectating.
8+
group = null;
9+
10+
// The target within the group that the owner is supposed to be spectating.
11+
target = null;
12+
13+
// The entity (either Player or Vehicle instance) that the owner is actually watching.
14+
targetEntity = null;
15+
16+
constructor(group, target) {
17+
this.group = group;
18+
this.target = target;
19+
}
20+
}

0 commit comments

Comments
 (0)