Skip to content

Commit

Permalink
Begin hooking up the spectation manager
Browse files Browse the repository at this point in the history
  • Loading branch information
RussellLVP committed Aug 5, 2020
1 parent 3a33cca commit ee6ea23
Show file tree
Hide file tree
Showing 8 changed files with 236 additions and 8 deletions.
21 changes: 21 additions & 0 deletions javascript/entities/player.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { toFloat } from 'base/float.js';
// * Environment
//
// * Interaction
// * Spectating
//
// * Audio
// * Visual
Expand Down Expand Up @@ -89,6 +90,11 @@ export class Player extends Supplementable {
static kSpecialActionCarry = 25; // does not work on skin 0 (CJ)
static kSpecialActionPissing = 68;

// Modes of spectating that players can be engaged in.
static kSpectateNormal = 0;
static kSpectateFixed = 1;
static kSpectateSide = 2;

// Constants applicable to the `Player.state` property.
static kStateNone = 0;
static kStateOnFoot = 1;
Expand Down Expand Up @@ -426,6 +432,21 @@ export class Player extends Supplementable {
pawnInvoke('GameTextForPlayer', 'isii', this.id, message, time, style);
}

// ---------------------------------------------------------------------------------------------
// Section: Spectating
// ---------------------------------------------------------------------------------------------

spectatePlayer(player, mode = Player.kSpectateNormal) {
pawnInvoke('PlayerSpectateVehicle', 'iii', this.#id_, player.id, mode);
}

spectateVehicle(vehicle, mode = Player.kSpectateNormal) {
pawnInvoke('PlayerSpectateVehicle', 'iii', this.#id_, vehicle.id, mode);
}

get spectating() { /* this is a read-only value on the server */ }
set spectating(value) { pawnInvoke('TogglePlayerSpectating', 'ii', this.#id_, value ? 1 : 0); }

// ---------------------------------------------------------------------------------------------
// Section: Audio
// ---------------------------------------------------------------------------------------------
Expand Down
31 changes: 31 additions & 0 deletions javascript/entities/test/mock_player.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ export class MockPlayer extends Player {
#lastDialogPromise_ = null;
#lastDialogPromiseResolve_ = null;

#spectating_ = false;
#spectateTarget_ = null;

#streamUrl_ = null;
#soundId_ = null;

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

// ---------------------------------------------------------------------------------------------
// Section: Spectating
// ---------------------------------------------------------------------------------------------

spectatePlayer(player, mode = Player.kSpectateNormal) {
if (!this.#spectating_)
throw new Error('The player must be spectating before picking a target.');

this.#spectateTarget_ = player;
}

spectateVehicle(vehicle, mode = Player.kSpectateNormal) {
if (!this.#spectating_)
throw new Error('The player must be spectating before picking a target.');

this.#spectateTarget_ = vehicle;
}

get spectating() { /* this is a read-only value on the server */ }
set spectating(value) {
this.#spectating_ = !!value;
if (!this.#spectating_)
this.#spectateTarget_ = null;
}

get spectatingForTesting() { return this.#spectating_; }
get spectateTargetForTesting() { return this.#spectateTarget_; }

// ---------------------------------------------------------------------------------------------
// Section: Audio
// ---------------------------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions javascript/features/settings/setting_list.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion javascript/features/spectate/spectate.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@ export default class Spectate extends Feature {
// including administrative tools and games.
this.markLowLevel();

// Influences behaviour and frequency of the spectation monitor.
const settings = this.defineDependency('settings');

// The spectate manager keeps track of which players are spectating who, and makes sure that
// their intention continues to be preseved despite player state changes.
this.manager_ = new SpectateManager();
this.manager_ = new SpectateManager(settings);
}

// ---------------------------------------------------------------------------------------------
Expand Down
6 changes: 6 additions & 0 deletions javascript/features/spectate/spectate_group.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,18 @@ export class SpectateGroup {
// Gets the number of players who are part of this group. May be zero.
get size() { return this.#players_.size; }

// Provides access to the players through an iterator.
[Symbol.iterator]() { return this.#players_.values(); }

// Adds the given |player| to the group.
addPlayer(player) {
this.#players_.add(player);
this.#manager_.onGroupUpdated(this);
}

// Returns whether the given |player| is part of this group.
hasPlayer(player) { return this.#players_.has(player); }

// Removes the given |player| from the group.
removePlayer(player) {
this.#players_.delete(player);
Expand Down
117 changes: 110 additions & 7 deletions javascript/features/spectate/spectate_manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,25 @@
// be found in the LICENSE file.

import { SpectateGroup } from 'features/spectate/spectate_group.js';
import { SpectateState } from 'features/spectate/spectate_state.js';

// Responsible for keeping track of which players are spectating which player groups, and maintains
// the global player group through which all players can be spectated.
export class SpectateManager {
#globalGroup_ = null;
#monitoring_ = false;
#settings_ = null;
#spectating_ = null;

constructor(settings) {
this.#settings_ = settings;

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

// Map keyed by Player, valued by SpectateState instances for active spectators.
this.#spectating_ = new Map();

server.playerManager.addObserver(this, /* replayHistory= */ true);
}

Expand All @@ -21,15 +30,100 @@ export class SpectateManager {
// ---------------------------------------------------------------------------------------------

// Called when the |player| should start spectating the given |group|, optionally starting with
// the given |targetPlayer|. All permission and ability checks should've been done already.
spectate(player, group, targetPlayer = null) {
// TODO: Implement this function
// the given |target|. All permission and ability checks should've been done already. Returns
// whether the spectation has started, which would fail iff |target| is spectating too.
spectate(player, group, target = null) {
if (target && !group.hasPlayer(target))
throw new Error(`It's not possible to spectate players not in the SpectateGroup`);

if (!target && !group.size)
throw new Error(`It's not possible to spectate an empty SpectateGroup.`);

// Bail out if the |target| is spectating too. Can't spectate someone who's spectating.
if (this.#spectating_.has(target))
return false;

// If no |target| was given, pick the first player in the |group|. The |player| will be able
// to move back and forth within the group as they please.
if (!target)
target = [ ...group ][0];

// Put the |player| in the same Virtual World and interior as the |target|.
player.virtualWorld = target.virtualWorld;
player.interiorId = target.interiorId;

// If the |player| hasn't been put in spectator mode yet, do this now.
if (!this.#spectating_.has(player))
player.spectating = true;

this.#spectating_.set(player, new SpectateState(group, target));

// Synchronize the environment of the |player|.
this.synchronizeEnvironment(player);

// If the monitor isn't running yet, start it to keep player state updated.
if (!this.#monitoring_)
this.monitor();

return true;
}

// Synchronizes the environment of the |player| with their target, to make sure that they stay
// near each other when spectating. This includes interior and virtual world changes.
synchronizeEnvironment(player) {
const state = this.#spectating_.get(player);
const target = state.target;

// (1) The entity that the |player| is meant to be watching. If they're not, make it so.
const targetEntity = target.vehicle ?? target;
if (targetEntity !== state.targetEntity) {
if (target.vehicle)
player.spectateVehicle(target.vehicle);
else
player.spectatePlayer(target);
}

// (2) Make sure that the |player| and |targetEntity| are in the same environment.
if (player.virtualWorld !== targetEntity.virtualWorld)
player.virtualWorld = targetEntity.virtualWorld;

if (player.interiorId !== targetEntity.interiorId)
player.interiorId = targetEntity.interiorId;

// (3) Maybe have some sort of 3D text label?
}

// Called when the |player| should stop spectating. This function will silently fail if they are
// not currently spectating anyone, as there is no work to do.
stopSpectate(player) {
// TODO: Implement this function
const state = this.#spectating_.get(player);
if (!state)
return; // the |player| is not currently spectating

// Clear out the |player|'s state, they won't be spectating anyone anymore.
this.#spectating_.delete(player);

// Move the player out of spectation mode. This will respawn them.
player.spectating = false;
}

// ---------------------------------------------------------------------------------------------
// Section: spectation monitor
// ---------------------------------------------------------------------------------------------

// Spins while there are players on the server who are spectating others. Will shut down when
// the last player has stopped spectating, moving the system into idle mode.
async monitor() {
await wait(this.#settings_().getValue('playground/spectator_monitor_frequency_ms'));

do {
for (const player of this.#spectating_.keys())
this.synchronizeEnvironment(player);

// Wait for the configured interval before iterating in the next round.
await wait(this.#settings_().getValue('playground/spectator_monitor_frequency_ms'));

} while (this.#monitoring_ && this.#spectating_.size);
}

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

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

// ---------------------------------------------------------------------------------------------
Expand All @@ -68,5 +163,13 @@ export class SpectateManager {

dispose() {
server.playerManager.removeObserver(this);

// Forcefully stop all current spectators from spectating. This would cause a bug for people
// currently in between rounds in a game, but work fine in all other cases.
for (const player of this.#spectating_.keys())
this.stopSpectate(player);

this.#monitoring_ = false;
this.#settings_ = null;
}
}
43 changes: 43 additions & 0 deletions javascript/features/spectate/spectate_manager.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,60 @@

describe('SpectateManager', (it, beforeEach) => {
let gunther = null;
let lucy = null;
let manager = null;
let russell = null;

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

gunther = server.playerManager.getById(/* Gunther= */ 0);
lucy = server.playerManager.getById(/* Lucy= */ 2);
manager = feature.manager_;
russell = server.playerManager.getById(/* Russell= */ 1);
});

it('should enable players to spectate others despite group changes', async (assert) => {
const settings = server.featureManager.loadFeature('settings');

const frequency = settings.getValue('playground/spectator_monitor_frequency_ms');
const group = manager.getGlobalGroup();

assert.isFalse(gunther.spectatingForTesting);
assert.isFalse(russell.spectatingForTesting);
assert.isFalse(lucy.spectatingForTesting);

// (1) |gunther| should be able to spectate |russell|.
assert.isTrue(manager.spectate(gunther, group, russell));

assert.isTrue(gunther.spectatingForTesting);
assert.isFalse(russell.spectatingForTesting);

assert.strictEqual(gunther.spectateTargetForTesting, russell);

// (2) |lucy| is unable to spectate |gunther|, as they're spectating already.
assert.isFalse(manager.spectate(lucy, group, gunther));

assert.isFalse(lucy.spectatingForTesting);
assert.isNull(lucy.spectateTargetForTesting);

// (3) When |russell| disconnects from the server, |gunther| should move to |lucy|.

// TODO

// (4) When |lucy|'s interior or virtual world changes, this should update for |gunther|.
assert.equal(gunther.virtualWorld, russell.virtualWorld);
assert.equal(gunther.interiorId, russell.interiorId);

russell.virtualWorld = 12;
russell.interiorId = 1;

await server.clock.advance(frequency);

assert.equal(gunther.virtualWorld, russell.virtualWorld);
assert.equal(gunther.interiorId, russell.interiorId);
});

it('should be able to maintain the global spectation group', assert => {
const originalPlayerCount = server.playerManager.count;
const group = manager.getGlobalGroup();
Expand Down
20 changes: 20 additions & 0 deletions javascript/features/spectate/spectate_state.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright 2020 Las Venturas Playground. All rights reserved.
// Use of this source code is governed by the MIT license, a copy of which can
// be found in the LICENSE file.

// Class that describes a player's state while they're spectating.
export class SpectateState {
// The SpectateGroup that the owner of this instance is spectating.
group = null;

// The target within the group that the owner is supposed to be spectating.
target = null;

// The entity (either Player or Vehicle instance) that the owner is actually watching.
targetEntity = null;

constructor(group, target) {
this.group = group;
this.target = target;
}
}

0 comments on commit ee6ea23

Please sign in to comment.