From 3e4b83298ce17c575ef2d90e31df61b850da3699 Mon Sep 17 00:00:00 2001 From: Guangcong Luo Date: Sat, 23 Jan 2021 13:17:23 -0800 Subject: [PATCH] Update Battle class API (#1686) The client Battle class API has been pretty old and crusty, so this updates it to be saner. The constructor now takes an options object. Any setting you'd want to initialize with is now a constructor option, instead of needing to call methods after the constructor. (Deprecated settings `roomid` and `joinButtons` still need to be set separately.) The old callback system is removed. It's replaced with a subscription system vaguely resembling `PSStreamModel`. Any callbacks only intended to be used by the warstory generator are removed (anyone who wants to write their own warstory generator should extend `BattleSceneStub` instead). Battles no longer start paused. You can still start them paused by passing `paused: true` as an option. Playback state tracking had a bunch of rearrangement: - `playbackState` no longer exists; state should be directly read from `paused`, `atQueueEnd`, `turn`, and `seeking`. - `turn` is now initialized to `-1`. `-1` now means "we haven't reached `|teampreview|` or `|start|` yet". Reaching those sets turn to `0`. - "Fast forwarding" and "seeking" are now consistently named "seeking". - `seeking` tracks seek state; changes from `fastForward`: - `null` means not seeking (replaces `0`) - `0` means seeking the start (replaces `0.5`) - `Infinity` means seeking the end (replaces `-1`) - `fastForward` deprecated and replaced with `seeking` - `fastForwardTo()` deprecated and replaced with `seekTurn()` - `resultWaiting` is removed (it's unused) - The "activity queue" has been renamed the "step queue", which means some renamed properties: - `activityQueue` to `stepQueue` - `activityStep` to `currentStep` - `nextActivity()` to `nextStep()` - new property: `atQueueEnd` to track if animation has caught up to the end of the step queue (replaces checking `playbackState`) - new property/option: `isReplay` - will automatically set `ended` when reaching the end of a replay (stopping music and showing a message), if the replay was saved before the end of the battle - both replay players (`replay.pokemonshowdown.com` and downloaded files) have been rewritten to use an observer system, instead of the previous manual updating - `reset(true)` has been renamed `resetStep()` --- js/client-battle.js | 71 ++++--- js/client-chat.js | 11 +- js/replay-embed.template.js | 92 ++++----- replays/js/replay.js | 87 ++++---- src/battle-animations.ts | 31 +-- src/battle-log.ts | 4 +- src/battle.ts | 390 +++++++++++++++++------------------- src/panel-battle.tsx | 14 +- test/battle.test.js | 54 +++-- 9 files changed, 360 insertions(+), 394 deletions(-) diff --git a/js/client-battle.js b/js/client-battle.js index 204556f960..7873e90a94 100644 --- a/js/client-battle.js +++ b/js/client-battle.js @@ -8,6 +8,9 @@ maxWidth: 1180, initialize: function (data) { this.me = {}; + this.choice = undefined; + /** are move/switch/team-preview controls currently being shown? */ + this.controlsShown = false; this.battlePaused = false; this.autoTimerActivated = false; @@ -23,12 +26,19 @@ this.$foeHint = this.$el.find('.foehint'); BattleSound.setMute(Dex.prefs('mute')); - this.battle = new Battle(this.$battle, this.$chatFrame, this.id); + this.battle = new Battle({ + id: this.id, + $frame: this.$battle, + $logFrame: this.$chatFrame + }); + this.battle.roomid = this.id; + this.battle.joinButtons = true; this.tooltips = this.battle.scene.tooltips; this.tooltips.listen(this.$controls); - this.battle.roomid = this.id; - this.battle.joinButtons = true; + var self = this; + this.battle.subscribe(function () { self.updateControls(); }); + this.users = {}; this.userCount = {users: 0}; this.$userList = this.$('.userlist'); @@ -41,14 +51,6 @@ this.$chat = this.$chatFrame.find('.inner'); this.$options = this.battle.scene.$options.html('
'); - - var self = this; - this.battle.customCallback = function () { self.updateControls(); }; - this.battle.endCallback = function () { self.updateControls(); }; - this.battle.startCallback = function () { self.updateControls(); }; - this.battle.stagnateCallback = function () { self.updateControls(); }; - - this.battle.play(); }, events: { 'click .replayDownloadButton': 'clickReplayDownloadButton', @@ -108,9 +110,9 @@ }, focus: function (e) { this.tooltips.hideTooltip(); - if (this.battle.playbackState === 3 && !this.battlePaused) { + if (this.battle.paused && !this.battlePaused) { + if (Dex.prefs('noanim')) this.battle.seekTurn(Infinity); this.battle.play(); - if (Dex.prefs('noanim')) this.battle.fastForwardTo(-1); } ConsoleRoom.prototype.focus.call(this, e); }, @@ -125,10 +127,9 @@ log.shift(); app.roomTitleChanged(this); } - if (this.battle.activityQueue.length) return; - this.battle.activityQueue = log; - this.battle.fastForwardTo(-1); - this.battle.play(); + if (this.battle.stepQueue.length) return; + this.battle.stepQueue = log; + this.battle.seekTurn(Infinity, true); if (this.battle.ended) this.battleEnded = true; this.updateLayout(); this.updateControls(); @@ -190,12 +191,12 @@ var args = logLine.substr(10).split('|'); var pokemon = isNaN(Number(args[1])) ? this.battle.getPokemon(args[1]) : this.battle.nearSide.active[args[1]]; var requestData = this.request.active[pokemon ? pokemon.slot : 0]; - delete this.choice; + this.choice = undefined; switch (args[0]) { case 'trapped': requestData.trapped = true; var pokeName = pokemon.side.n === 0 ? BattleLog.escapeHTML(pokemon.name) : "The opposing " + (this.battle.ignoreOpponent || this.battle.ignoreNicks ? pokemon.speciesForme : BattleLog.escapeHTML(pokemon.name)); - this.battle.activityQueue.push('|message|' + pokeName + ' is trapped and cannot switch!'); + this.battle.stepQueue.push('|message|' + pokeName + ' is trapped and cannot switch!'); break; case 'cant': for (var i = 0; i < requestData.moves.length; i++) { @@ -204,20 +205,21 @@ } } args.splice(1, 1, pokemon.getIdent()); - this.battle.activityQueue.push('|' + args.join('|')); + this.battle.stepQueue.push('|' + args.join('|')); break; } } else if (logLine.substr(0, 7) === '|title|') { // eslint-disable-line no-empty } else if (logLine.substr(0, 5) === '|win|' || logLine === '|tie') { this.battleEnded = true; - this.battle.activityQueue.push(logLine); + this.battle.stepQueue.push(logLine); } else if (logLine.substr(0, 6) === '|chat|' || logLine.substr(0, 3) === '|c|' || logLine.substr(0, 4) === '|c:|' || logLine.substr(0, 9) === '|chatmsg|' || logLine.substr(0, 10) === '|inactive|') { this.battle.instantAdd(logLine); } else { - this.battle.activityQueue.push(logLine); + this.battle.stepQueue.push(logLine); } } - this.battle.add('', Dex.prefs('noanim')); + this.battle.add(); + if (Dex.prefs('noanim')) this.battle.seekTurn(Infinity); this.updateControls(); }, toggleMessages: function (user) { @@ -248,18 +250,18 @@ * Battle stuff *********************************************************/ - updateControls: function (force) { + updateControls: function () { if (this.battle.scene.customControls) return; var controlsShown = this.controlsShown; this.controlsShown = false; - if (this.battle.playbackState === 5) { + if (this.battle.seeking !== null) { // battle is seeking this.$controls.html(''); return; - } else if (this.battle.playbackState === 2 || this.battle.playbackState === 3) { + } else if (!this.battle.atQueueEnd) { // battle is playing or paused if (!this.side || this.battleEnded) { @@ -296,7 +298,7 @@ // player this.controlsShown = true; - if (force || !controlsShown || this.choice === undefined || this.choice && this.choice.waiting) { + if (!controlsShown || this.choice === undefined || this.choice && this.choice.waiting) { // don't update controls (and, therefore, side) if `this.choice === null`: causes damage miscalculations this.updateControlsForPlayer(); } else { @@ -325,7 +327,6 @@ // since those early-return. app.topbar.updateTabbar(); }, - controlsShown: false, updateControlsForPlayer: function () { this.callbackWaiting = true; @@ -393,6 +394,7 @@ if (this.battle.mySide.pokemon && !this.battle.mySide.pokemon.length) { // too early, we can't determine `this.choice.count` yet // TODO: send teamPreviewCount in the request object + this.controlsShown = false; return; } if (!this.choice) { @@ -977,18 +979,15 @@ request.requestType = 'wait'; } - var choice = null; - if (choiceText) { - choice = {waiting: true}; - } - this.choice = choice; + this.choice = choiceText ? {waiting: true} : null; this.finalDecision = this.finalDecisionMove = this.finalDecisionSwitch = false; this.request = request; if (request.side) { this.updateSideLocation(request.side); } this.notifyRequest(); - this.updateControls(true); + this.controlsShown = false; + this.updateControls(); }, notifyRequest: function () { var oName = this.battle.farSide.name; @@ -1089,11 +1088,11 @@ }, rewindTurn: function () { if (this.battle.turn) { - this.battle.fastForwardTo(this.battle.turn - 1); + this.battle.seekTurn(this.battle.turn - 1); } }, goToEnd: function () { - this.battle.fastForwardTo(-1); + this.battle.seekTurn(Infinity); }, register: function (userid) { var registered = app.user.get('registered'); diff --git a/js/client-chat.js b/js/client-chat.js index 3c250961ec..730451a12c 100644 --- a/js/client-chat.js +++ b/js/client-chat.js @@ -1041,16 +1041,7 @@ for (var roomid in app.rooms) { var battle = app.rooms[roomid] && app.rooms[roomid].battle; if (!battle) continue; - var turn = battle.turn; - var oldState = battle.playbackState; - if (oldState === 4) turn = -1; - battle.reset(true); - battle.fastForwardTo(turn); - if (oldState !== 3) { - battle.play(); - } else { - battle.pause(); - } + battle.resetToCurrentTurn(); } return false; diff --git a/js/replay-embed.template.js b/js/replay-embed.template.js index cd74504d9a..c63dfd2b07 100644 --- a/js/replay-embed.template.js +++ b/js/replay-embed.template.js @@ -36,13 +36,17 @@ requireScript('https://play.pokemonshowdown.com/js/battle-tooltips.js?a7'); requireScript('https://play.pokemonshowdown.com/js/battle.js?a7'); var Replays = { - init: function (log) { + battle: null, + muted: false, + init: function () { this.$el = $('.wrapper'); if (!this.$el.length) { $('body').append('
'); this.$el = $('.wrapper'); } + var id = $('input[name=replayid]').val() || ''; + var log = ($('script.battle-log-data').text() || '').replace(/\\\//g, '/'); var self = this; this.$el.on('click', '.chooser button', function (e) { @@ -53,18 +57,14 @@ var Replays = { if (action) self[action](); }); - this.battle = new Battle(this.$('.battle'), this.$('.battle-log'), id); - //this.battle.preloadCallback = updateProgress; - this.battle.errorCallback = this.errorCallback.bind(this); - this.battle.resumeButton = this.resume.bind(this); - - this.setlog(log); - }, - setlog: function (log) { - this.battle.setQueue(log.split('\n')); - - this.battle.reset(); - this.$('.battle').append('


'); + this.battle = new Battle({ + id: id, + $frame: this.$('.battle'), + $logFrame: this.$('.battle-log'), + log: log.split('\n'), + isReplay: true, + paused: true, + }); this.$('.replay-controls-2').html('
Speed:
Color scheme:
'); @@ -74,7 +74,8 @@ var Replays = { if (rc2) rc2.innerHTML = rc2.innerHTML; if (window.HTMLAudioElement) $('.soundchooser, .startsoundchooser').show(); - this.reset(); + this.update(); + this.battle.subscribe(function (state) { self.update(state); }); }, "$": function (sel) { return this.$el.find(sel); @@ -115,11 +116,9 @@ var Replays = { break; case 'sound': - var muteTable = { - on: false, // this is kind of backwards: sound[on] === muted[false] - off: true - }; - this.battle.setMute(muteTable[value]); + // remember this is reversed: sound[off] === muted[true] + this.muted = (value === 'off'); + this.battle.setMute(this.muted); this.$('.startsoundchooser').remove(); break; @@ -134,59 +133,52 @@ var Replays = { break; } }, - battle: null, - errorCallback: function () { - var replayid = this.$('input[name=replayid]').val(); - var m = /^([a-z0-9]+)-[a-z0-9]+-[0-9]+$/.exec(replayid); - if (m) { - this.battle.log('
This replay was uploaded from a third-party server (' + BattleLog.escapeHTML(m[1]) + '). It contains errors and cannot be viewed.
Replays uploaded from third-party servers can contain errors if the server is running custom code, or the server operator has otherwise incorrectly configured their server.
', true); - this.battle.pause(); + update: function (state) { + if (state === 'error') { + var m = /^([a-z0-9]+)-[a-z0-9]+-[0-9]+$/.exec(this.battle.id); + if (m) { + this.battle.log('
This replay was uploaded from a third-party server (' + BattleLog.escapeHTML(m[1]) + '). It contains errors.
Replays uploaded from third-party servers can contain errors if the server is running custom code, or the server operator has otherwise incorrectly configured their server.
', true); + } + return; + } + + if (BattleSound.muted && !this.muted) this.changeSetting('sound', 'off'); + + if (this.battle.paused) { + var resetDisabled = !this.battle.started ? ' disabled' : ''; + this.$('.replay-controls').html(' '); + } else { + this.$('.replay-controls').html(' '); } }, pause: function () { - this.$('.replay-controls').html(' '); this.battle.pause(); }, play: function () { - this.$('.battle .playbutton').remove(); - this.$('.replay-controls').html(' '); this.battle.play(); }, - resume: function () { - this.play(); - }, reset: function () { this.battle.reset(); - this.battle.fastForwardTo(0); - this.$('.battle').append('


'); - // this.$('.battle-log').html(''); - this.$('.replay-controls').html(''); }, ff: function () { this.battle.skipTurn(); }, rewind: function () { - if (this.battle.turn) { - this.battle.fastForwardTo(this.battle.turn - 1); - } + this.battle.seekTurn(this.battle.turn - 1); }, ffto: function () { - this.battle.fastForwardTo(prompt('Turn?')); + var turn = prompt('Turn?'); + if (!turn.trim()) return; + if (turn === 'e' || turn === 'end' || turn === 'f' || turn === 'finish') turn = Infinity; + turn = Number(turn); + if (isNaN(turn) || turn < 0) alert("Invalid turn"); + this.battle.seekTurn(turn); }, switchSides: function () { this.battle.switchSides(); }, - start: function () { - this.battle.reset(); - this.battle.play(); - this.$('.replay-controls').html(' '); - }, - startMuted: function () { - this.changeSetting('sound', 'off'); - this.start(); - } }; window.onload = function () { - Replays.init((this.$('script.battle-log-data').text() || '').replace(/\\\//g, '/')); + Replays.init(); }; diff --git a/replays/js/replay.js b/replays/js/replay.js index e08112355d..e4613e2643 100644 --- a/replays/js/replay.js +++ b/replays/js/replay.js @@ -99,11 +99,10 @@ var ReplayPanel = Panels.StaticPanel.extend({ break; case 'sound': - var muteTable = { - on: false, // this is kind of backwards: sound[on] === muted[false] - off: true - }; - this.battle.setMute(muteTable[value]); + // remember this is reversed: sound[off] === muted[true] + this.muted = (value === 'off'); + this.battle.setMute(this.muted); + this.$('.startsoundchooser').remove(); break; case 'speed': @@ -128,26 +127,24 @@ var ReplayPanel = Panels.StaticPanel.extend({ } }, battle: null, - errorCallback: function() { - var replayid = this.$('input[name=replayid]').val(); - var m = /^([a-z0-9]+)-[a-z0-9]+-[0-9]+$/.exec(replayid); - if (m && m[1] !== 'smogtours') { - this.battle.log('
This replay was uploaded from a third-party server (' + BattleLog.escapeHTML(m[1]) + '). It contains errors and cannot be viewed.
Replays uploaded from third-party servers can contain errors if the server is running custom code, or the server operator has otherwise incorrectly configured their server.
', true); - this.battle.pause(); - } - }, + muted: null, updateContent: function() { this.$el.css('overflow-x', 'hidden'); var $battle = this.$('.battle'); if (!$battle.length) return; - this.battle = new Battle($battle, this.$('.battle-log')); - //this.battle.preloadCallback = updateProgress; - // this.battle.errorCallback = this.errorCallback.bind(this); - this.battle.resumeButton = this.resume.bind(this); - this.battle.setQueue((this.$('script.log').text()||'').replace(/\\\//g,'/').split('\n')); - this.battle.reset(); - $battle.append('


'); + var replayid = this.$('input[name=replayid]').val() || ''; + var log = (this.$('script.log').text() || '').replace(/\\\//g,'/'); + + var self = this; + this.battle = new Battle({ + id: replayid, + $frame: $battle, + $logFrame: this.$('.battle-log'), + log: log.split('\n'), + isReplay: true, + paused: true + }) this.$('.urlbox').css('margin-right', 120).before(' Download'); @@ -156,6 +153,27 @@ var ReplayPanel = Panels.StaticPanel.extend({ if (rc2) rc2.innerHTML = rc2.innerHTML; if (window.HTMLAudioElement) this.$('.soundchooser, .startsoundchooser').show(); + + this.battle.subscribe(function (state) { self.update(state); }); + this.update(); + }, + update: function (state) { + if (state === 'error') { + var m = /^([a-z0-9]+)-[a-z0-9]+-[0-9]+$/.exec(this.battle.id); + if (m) { + this.battle.log('
This replay was uploaded from a third-party server (' + BattleLog.escapeHTML(m[1]) + '). It contains errors.
Replays uploaded from third-party servers can contain errors if the server is running custom code, or the server operator has otherwise incorrectly configured their server.
', true); + } + return; + } + + if (BattleSound.muted && !this.muted) this.changeSetting('sound', 'off'); + + if (this.battle.paused) { + var resetDisabled = !this.battle.started ? ' disabled' : ''; + this.$('.replay-controls').html(' '); + } else { + this.$('.replay-controls').html(' '); + } }, clickReplayDownloadButton: function (e) { var filename = (this.battle.tier || 'Battle').replace(/[^A-Za-z0-9]/g, ''); @@ -176,52 +194,35 @@ var ReplayPanel = Panels.StaticPanel.extend({ e.stopPropagation(); }, pause: function() { - this.$('.replay-controls').html(' '); this.battle.pause(); }, play: function() { - this.$('.battle .playbutton').remove(); - this.$('.replay-controls').html(' '); this.battle.play(); }, - resume: function() { - this.play(); - }, reset: function() { this.battle.reset(); - this.$('.battle').append('
'); - // this.$('.battle-log').html(''); - this.$('.replay-controls').html(''); }, ff: function() { this.battle.skipTurn(); }, rewind: function() { - if (this.battle.turn) { - this.battle.fastForwardTo(this.battle.turn - 1); - } + this.battle.seekTurn(this.battle.turn - 1); }, ffto: function() { var turn = prompt('Turn?'); - if (!turn) return; - turn = parseInt(turn); - this.battle.fastForwardTo(turn); + if (!turn.trim()) return; + if (turn === 'e' || turn === 'end' || turn === 'f' || turn === 'finish') turn = Infinity; + turn = Number(turn); + if (isNaN(turn) || turn < 0) alert("Invalid turn"); + this.battle.seekTurn(turn); }, switchSides: function() { this.battle.switchSides(); }, - start: function() { - this.battle.play(); - this.$('.replay-controls').html(' '); - }, remove: function() { this.battle.destroy(); Panels.StaticPanel.prototype.remove.call(this); }, - startMuted: function() { - this.changeSetting('sound', 'off'); - this.start(); - } }); var App = Panels.App.extend({ diff --git a/src/battle-animations.ts b/src/battle-animations.ts index cf6c9abd2d..f528bcc94a 100644 --- a/src/battle-animations.ts +++ b/src/battle-animations.ts @@ -244,10 +244,16 @@ class BattleScene { pause() { this.stopAnimation(); this.updateBgm(); - if (this.battle.resumeButton) { - this.$frame.append('
'); - this.$frame.find('div.playbutton button').click(this.battle.resumeButton); + if (this.battle.turn > 0) { + this.$frame.append('
'); + } else { + this.$frame.append('


'); + this.$frame.find('div.playbutton button[name=play-muted]').click(() => { + this.battle.setMute(true); + this.battle.play(); + }); } + this.$frame.find('div.playbutton button[name=play]').click(() => this.battle.play()); } resume() { this.$frame.find('div.playbutton').remove(); @@ -939,7 +945,7 @@ class BattleScene { } } resetTurn() { - if (!this.battle.turn) { + if (this.battle.turn <= 0) { this.$turn.html(''); return; } @@ -949,7 +955,7 @@ class BattleScene { if (!this.animating) return; const turn = this.battle.turn; - if (!turn) return; + if (turn <= 0) return; const $prevTurn = this.$turn.children(); const $newTurn = $('
Turn ' + turn + '
'); $newTurn.css({ @@ -1439,6 +1445,7 @@ class BattleScene { ///////////////////////////////////////////////////////////////////// setFrameHTML(html: any) { + this.customControls = true; this.$frame.html(html); } setControlsHTML(html: any) { @@ -1538,16 +1545,16 @@ class BattleScene { } updateBgm() { /** - * - not playing in non-battle RoomGames (Playback.Uninitialized) - * - not playing at team preview in replays (Playback.Ready) - * - playing at team preview in games (Playback.Playing) - * - playing during the game (Playback.Playing) + * - not playing in non-battle RoomGames before `|start` (turn -1) + * - not playing at team preview in replays (paused) + * - playing at team preview in games (turn 0) + * - playing during the game (turn 1+) * - not playing while paused - * - playing while waiting for players to choose moves (Playback.Finished) + * - playing while waiting for players to choose moves (atQueueEnd && !ended) * - not playing after the game has ended */ - const nowPlaying = this.battle.playbackState > Playback.Ready && ( - this.battle.started && !this.battle.ended && !this.battle.paused + const nowPlaying = ( + this.battle.turn >= 0 && !this.battle.ended && !this.battle.paused ); if (nowPlaying) { if (!this.bgm) this.rollBgm(); diff --git a/src/battle-log.ts b/src/battle-log.ts index 3b6d3950ee..76ad3f48b9 100644 --- a/src/battle-log.ts +++ b/src/battle-log.ts @@ -983,7 +983,7 @@ class BattleLog { // replay panel replayid = room.fragment; } - battle.fastForwardTo(-1); + battle.seekTurn(Infinity); let buf = '\n'; buf += '\n'; buf += '\n'; @@ -995,7 +995,7 @@ class BattleLog { buf += '\n'; buf += '
\n'; buf += `

${BattleLog.escapeHTML(battle.tier)}
${BattleLog.escapeHTML(battle.p1.name)} vs. ${BattleLog.escapeHTML(battle.p2.name)}

\n`; - buf += '\n'; // lgtm [js/incomplete-sanitization] + buf += '\n'; // lgtm [js/incomplete-sanitization] buf += '
\n'; buf += '
' + battle.scene.log.elem.innerHTML + '
\n'; buf += '\n'; diff --git a/src/battle.ts b/src/battle.ts index b7b3444fe7..bc58cc38c5 100644 --- a/src/battle.ts +++ b/src/battle.ts @@ -649,7 +649,6 @@ class Side { this.rollTrainerSprites(); if (this.foe && this.avatar === this.foe.avatar) this.rollTrainerSprites(); } - if (this.battle.stagnateCallback) this.battle.stagnateCallback(this.battle); } addSideCondition(effect: Effect) { let condition = effect.id; @@ -810,8 +809,6 @@ class Side { } this.battle.scene.animSummon(pokemon, slot); - - if (this.battle.switchCallback) this.battle.switchCallback(this.battle, this); } dragIn(pokemon: Pokemon, slot = pokemon.slot) { let oldpokemon = this.active[slot]; @@ -828,8 +825,6 @@ class Side { pokemon.slot = slot; this.battle.scene.animDragIn(pokemon, slot); - - if (this.battle.dragCallback) this.battle.dragCallback(this.battle, this); } replace(pokemon: Pokemon, slot = pokemon.slot) { let oldpokemon = this.active[slot]; @@ -857,8 +852,6 @@ class Side { this.battle.scene.animUnsummon(oldpokemon, true); } this.battle.scene.animSummon(pokemon, slot, true); - // not sure if we want a different callback - if (this.battle.dragCallback) this.battle.dragCallback(this.battle, this); } switchOut(pokemon: Pokemon, slot = pokemon.slot) { if (pokemon.lastMove !== 'batonpass' && pokemon.lastMove !== 'zbatonpass') { @@ -924,7 +917,6 @@ class Side { pokemon.hp = 0; this.battle.scene.animFaint(pokemon); - if (this.battle.faintCallback) this.battle.faintCallback(this.battle, this); } destroy() { this.clearPokemon(); @@ -933,39 +925,6 @@ class Side { } } -enum Playback { - /** - * Battle is at the end of the queue. `|start` is not in the queue. - * Battle is waiting for `.add()` or `.setQueue()` to add `|start` to - * the queue. Adding other queue entries will happen immediately, - * bringing the state back to Uninitialized. - */ - Uninitialized = 0, - /** - * Battle is at `|start` and hasn't been started yet. - * Battle is paused, waiting for `.play()`. - */ - Ready = 1, - /** - * `.play()` has been called. Battle should be animating - * normally. - */ - Playing = 2, - /** - * `.pause()` has been called. Battle is waiting for `.play()`. - */ - Paused = 3, - /** - * Battle is at the end of the queue. Battle is waiting for - * `.add()` for further battle progress. - */ - Finished = 4, - /** - * Battle is fast forwarding through the queue, with animations off. - */ - Seeking = 5, -} - interface PokemonDetails { details: string; name: string; @@ -1015,38 +974,45 @@ class Battle { sidesSwitched = false; - // activity queue - activityQueue = [] as string[]; + stepQueue: string[]; /** See battle.instantAdd */ - preemptActivityQueue = [] as string[]; + preemptStepQueue: string[] = []; waitForAnimations: true | false | 'simult' = true; - activityStep = 0; - fastForward = 0; - fastForwardWillScroll = false; + /** the index of `stepQueue` currently being animated */ + currentStep = 0; + /** null = not seeking, 0 = seek start, Infinity = seek end, otherwise: seek turn number */ + seeking: number | null = null; - resultWaiting = false; activeMoveIsSpread: string | null = null; - // callback - faintCallback: ((battle: Battle, side: Side) => void) | null = null; - switchCallback: ((battle: Battle, side: Side) => void) | null = null; - dragCallback: ((battle: Battle, side: Side) => void) | null = null; - turnCallback: ((battle: Battle) => void) | null = null; - startCallback: ((battle: Battle) => void) | null = null; - stagnateCallback: ((battle: Battle) => void) | null = null; - endCallback: ((battle: Battle) => void) | null = null; - customCallback: ((battle: Battle, cmd: string, args: string[], kwArgs: KWArgs) => void) | null = null; - errorCallback: ((battle: Battle) => void) | null = null; + subscription: ((state: + 'playing' | 'paused' | 'turn' | 'atqueueend' | 'callback' | 'ended' | 'error' + ) => void) | null; mute = false; messageFadeTime = 300; messageShownTime = 1; + /** for tracking when to accelerate animations in long battles full of double switches */ turnsSinceMoved = 0; - turn = 0; /** - * Has playback gotten to Team Preview or `|start` yet? - * (Affects whether BGM is playing) + * * `-1` = non-battle RoomGames, or hasn't hit Team Preview or `|start` + * * `0` = after Team Preview or `|start` but before `|turn|1` + */ + turn = -1; + /** + * Are we at the end of the queue and waiting for more input? + * + * In addition to at the end of a battle, this is also true if you're + * playing/watching a battle live, and waiting for a player to make a move. + */ + atQueueEnd = false; + /** + * Has the battle ever been played or fast-forwarded? + * + * This is not exactly `turn > 0` because if you start playing a replay, + * then pause before turn 1, `turn` will still be 0, but playback should + * be considered started (for the purposes of displaying "Play" vs "Resume") */ started = false; /** @@ -1054,6 +1020,7 @@ class Battle { * (Affects whether BGM is playing) */ ended = false; + isReplay = false; usesUpkeep = false; weather = '' as ID; pseudoWeather = [] as WeatherState[]; @@ -1068,7 +1035,7 @@ class Battle { sides: [Side, Side] = [null!, null!]; lastMove = ''; - gen = 7; + gen = 8; dex: ModdedDex = Dex; teamPreviewCount = 0; speciesClause = false; @@ -1088,34 +1055,60 @@ class Battle { // options id = ''; + /** used to forward some information to the room in the old client */ roomid = ''; hardcoreMode = false; ignoreNicks = !!Dex.prefs('ignorenicks'); ignoreOpponent = !!Dex.prefs('ignoreopp'); ignoreSpects = !!Dex.prefs('ignorespects'); - debug = false; + debug: boolean; joinButtons = false; /** * The actual pause state. Will only be true if playback is actually * paused, not just waiting for the opponent to make a move. */ - paused = true; - playbackState = Playback.Uninitialized; - - // external - resumeButton: JQuery.EventHandler | null = null; - - constructor($frame: JQuery, $logFrame: JQuery, id = '') { - this.id = id; - - if (!$frame && !$logFrame) { + paused: boolean; + + constructor(options: { + $frame?: JQuery, + $logFrame?: JQuery, + id?: ID, + log?: string[], + paused?: boolean, + isReplay?: boolean, + debug?: boolean, + subscription?: Battle['subscription'], + } = {}) { + this.id = options.id || ''; + + if (options.$frame && options.$logFrame) { + this.scene = new BattleScene(this, options.$frame, options.$logFrame); + } else if (!options.$frame && !options.$logFrame) { this.scene = new BattleSceneStub(); } else { - this.scene = new BattleScene(this, $frame, $logFrame); + throw new Error(`You must specify $frame and $logFrame simultaneously`); } - this.init(); + this.paused = !!options.paused; + this.started = !this.paused; + this.debug = !!options.debug; + this.stepQueue = options.log || []; + this.subscription = options.subscription || null; + + this.p1 = new Side(this, 0); + this.p2 = new Side(this, 1); + this.sides = [this.p1, this.p2]; + this.p2.foe = this.p1; + this.p1.foe = this.p2; + this.nearSide = this.mySide = this.p1; + this.farSide = this.p2; + + this.resetStep(); + } + + subscribe(listener: Battle['subscription']) { + this.subscription = listener; } removePseudoWeather(weather: string) { @@ -1139,22 +1132,18 @@ class Battle { } return false; } - init() { - this.p1 = new Side(this, 0); - this.p2 = new Side(this, 1); - this.sides = [this.p1, this.p2]; - this.p2.foe = this.p1; - this.p1.foe = this.p2; - this.nearSide = this.mySide = this.p1; - this.farSide = this.p2; - this.gen = 7; - this.reset(); + reset() { + this.paused = true; + this.scene.pause(); + this.resetStep(); + this.subscription?.('paused'); } - reset(dontResetSound?: boolean) { + resetStep() { // battle state - this.turn = 0; - this.started = false; + this.turn = -1; + this.started = !this.paused; this.ended = false; + this.atQueueEnd = false; this.weather = '' as ID; this.weatherTimeLeft = 0; this.weatherMinTimeLeft = 0; @@ -1171,16 +1160,9 @@ class Battle { // activity queue state this.activeMoveIsSpread = null; - this.activityStep = 0; - this.fastForwardOff(); - this.resultWaiting = false; - this.paused = true; - if (this.playbackState !== Playback.Seeking) { - this.playbackState = Playback.Uninitialized; - if (!dontResetSound) this.scene.resetBgm(); - } + this.currentStep = 0; this.resetTurnsSinceMoved(); - this.nextActivity(); + this.nextStep(); } destroy() { this.scene.destroy(); @@ -1201,21 +1183,7 @@ class Battle { } resetToCurrentTurn() { - if (this.ended) { - this.reset(true); - this.fastForwardTo(-1); - } else { - let turn = this.turn; - let paused = this.paused; - this.reset(true); - this.paused = paused; - if (turn) this.fastForwardTo(turn); - if (!paused) { - this.play(); - } else { - this.pause(); - } - } + this.seekTurn(this.ended ? Infinity : this.turn, true); } switchSides() { this.setSidesSwitched(!this.sidesSwitched); @@ -1242,15 +1210,16 @@ class Battle { start() { this.log(['start']); this.resetTurnsSinceMoved(); - if (this.startCallback) this.startCallback(this); } winner(winner?: string) { this.log(['win', winner || '']); this.ended = true; + this.subscription?.('ended'); } prematureEnd() { this.log(['message', 'This replay ends here.']); this.ended = true; + this.subscription?.('ended'); } endLastTurn() { if (this.endLastTurnPending) { @@ -1263,28 +1232,25 @@ class Battle { this.scene.updateSidebars(); this.scene.updateWeather(true); } - setTurn(turnNum: string | number) { - turnNum = parseInt(turnNum as string, 10); + setTurn(turnNum: number) { if (turnNum === this.turn + 1) { this.endLastTurnPending = true; } if (this.turn && !this.usesUpkeep) this.updateTurnCounters(); // for compatibility with old replays this.turn = turnNum; + this.started = true; - if (!this.fastForward) this.turnsSinceMoved++; + if (this.seeking === null) this.turnsSinceMoved++; this.scene.incrementTurn(); - if (this.fastForward) { - if (this.turnCallback) this.turnCallback(this); - if (this.fastForward > -1 && turnNum >= this.fastForward) { - this.fastForwardOff(); - if (this.endCallback) this.endCallback(this); + if (this.seeking !== null) { + if (turnNum >= this.seeking) { + this.stopSeeking(); } - return; + } else { + this.subscription?.('turn'); } - - if (this.turnCallback) this.turnCallback(this); } resetTurnsSinceMoved() { this.turnsSinceMoved = 0; @@ -1300,7 +1266,7 @@ class Battle { this.weatherTimeLeft--; if (this.weatherMinTimeLeft !== 0) this.weatherMinTimeLeft--; } - if (!this.fastForward) { + if (this.seeking === null) { this.scene.upkeepWeather(); } return; @@ -1389,7 +1355,7 @@ class Battle { } animateMove(pokemon: Pokemon, move: Move, target: Pokemon | null, kwArgs: KWArgs) { this.activeMoveIsSpread = kwArgs.spread; - if (this.fastForward || kwArgs.still) return; + if (this.seeking !== null || kwArgs.still) return; if (!target) target = pokemon.side.foe.active[0]; if (!target) target = pokemon.side.foe.missedPokemon; @@ -1514,7 +1480,7 @@ class Battle { } if (args[0] === 'detailschange' && nextArgs[0] === '-mega') { if (this.scene.closeMessagebar()) { - this.activityStep--; + this.currentStep--; return; } kwArgs.simult = '.'; @@ -2820,11 +2786,10 @@ class Battle { switch (effect.id) { case 'gravity': - if (!this.fastForward) { - for (const side of this.sides) { - for (const active of side.active) { - if (active) this.scene.runOtherAnim('gravity' as ID, [active]); - } + if (this.seeking !== null) break; + for (const side of this.sides) { + for (const active of side.active) { + if (active) this.scene.runOtherAnim('gravity' as ID, [active]); } } break; @@ -3082,20 +3047,12 @@ class Battle { } as any; } - add(command: string, fastForward?: boolean) { - if (command) this.activityQueue.push(command); + add(command?: string) { + if (command) this.stepQueue.push(command); - if (this.playbackState === Playback.Uninitialized) { - this.nextActivity(); - } else if (this.playbackState === Playback.Finished) { - this.playbackState = this.paused ? Playback.Paused : Playback.Playing; - if (this.paused) return; - this.scene.updateBgm(); - if (fastForward) { - this.fastForwardTo(-1); - } else { - this.nextActivity(); - } + if (this.atQueueEnd && this.currentStep < this.stepQueue.length) { + this.atQueueEnd = false; + this.nextStep(); } } /** @@ -3108,7 +3065,7 @@ class Battle { */ instantAdd(command: string) { this.run(command, true); - this.preemptActivityQueue.push(command); + this.preemptStepQueue.push(command); this.add(command); } runMajor(args: Args, kwArgs: KWArgs, preempt?: boolean) { @@ -3126,7 +3083,7 @@ class Battle { break; } case 'turn': { - this.setTurn(args[1]); + this.setTurn(parseInt(args[1], 10)); this.log(args); break; } @@ -3373,11 +3330,10 @@ class Battle { break; } case 'callback': { - if (this.customCallback) this.customCallback(this, args[1], args.slice(1), kwArgs); + this.subscription?.('callback'); break; } case 'fieldhtml': { - this.playbackState = Playback.Seeking; // force seeking to prevent controls etc this.scene.setFrameHTML(BattleLog.sanitizeHTML(args[1])); break; } @@ -3392,8 +3348,8 @@ class Battle { } run(str: string, preempt?: boolean) { - if (!preempt && this.preemptActivityQueue.length && str === this.preemptActivityQueue[0]) { - this.preemptActivityQueue.shift(); + if (!preempt && this.preemptStepQueue.length && str === this.preemptStepQueue[0]) { + this.preemptStepQueue.shift(); this.scene.preemptCatchup(); return; } @@ -3401,7 +3357,7 @@ class Battle { const {args, kwArgs} = BattleTextParser.parseBattleLine(str); if (this.scene.maybeCloseMessagebar(args, kwArgs)) { - this.activityStep--; + this.currentStep--; this.activeMoveIsSpread = null; return; } @@ -3409,7 +3365,7 @@ class Battle { // parse the next line if it's a minor: runMinor needs it parsed to determine when to merge minors let nextArgs: Args = ['']; let nextKwargs: KWArgs = {}; - const nextLine = this.activityQueue[this.activityStep + 1] || ''; + const nextLine = this.stepQueue[this.currentStep + 1] || ''; if (nextLine.slice(0, 2) === '|-') { ({args: nextArgs, kwArgs: nextKwargs} = BattleTextParser.parseBattleLine(nextLine)); } @@ -3438,16 +3394,15 @@ class Battle { this.log(['error', line]); } } - if (this.errorCallback) this.errorCallback(this); + this.subscription?.('error'); } } if (nextLine.startsWith('|start') || args[0] === 'teampreview') { - this.started = true; - if (this.playbackState === Playback.Uninitialized) { - this.playbackState = Playback.Ready; + if (this.turn === -1) { + this.turn = 0; + this.scene.updateBgm(); } - this.scene.updateBgm(); } } checkActive(poke: Pokemon) { @@ -3460,93 +3415,114 @@ class Battle { pause() { this.paused = true; - this.playbackState = Playback.Paused; this.scene.pause(); + this.subscription?.('paused'); } + /** + * Properties relevant to battle playback, for replay UI implementers: + * - `ended`: has the game ended in a win/loss? + * - `atQueueEnd`: is animation caught up to the end of the battle queue, waiting for more input? + * - `seeking`: are we trying to skip to a specific turn + * - `turn`: what turn are we currently on? `-1` if we haven't started yet, `0` at team preview + * - `paused`: are we playing at all? + */ play() { this.paused = false; - this.playbackState = Playback.Playing; + this.started = true; this.scene.resume(); - this.nextActivity(); + this.nextStep(); + this.subscription?.('playing'); } skipTurn() { - this.fastForwardTo(this.turn + 1); - } - fastForwardTo(time: string | number) { - if (this.fastForward) return; - time = Math.floor(Number(time)); - if (isNaN(time)) return; - if (this.ended && time >= this.turn + 1) return; - - if (time <= this.turn && time !== -1) { - let paused = this.paused; - this.reset(true); - if (paused) this.pause(); - else this.paused = false; - this.fastForwardWillScroll = true; - } - if (!time) { - this.fastForwardOff(); - this.nextActivity(); + this.seekTurn(this.turn + 1); + } + seekTurn(turn: number, forceReset?: boolean) { + if (isNaN(turn)) return; + turn = Math.max(Math.floor(turn), 0); + + if (this.seeking !== null && this.seeking > turn && !forceReset) { + this.seeking = turn; + return; + } + + if (turn === 0) { + this.seeking = null; + this.resetStep(); + this.scene.animationOn(); + if (this.paused) this.subscription?.('paused'); return; } - this.scene.animationOff(); - this.playbackState = Playback.Seeking; - this.fastForward = time; - this.nextActivity(); + + this.seeking = turn; + + if (turn <= this.turn || forceReset) { + this.scene.animationOff(); + this.resetStep(); + } else if (this.atQueueEnd) { + this.scene.animationOn(); + this.seeking = null; + } else { + this.scene.animationOff(); + this.nextStep(); + } } - fastForwardOff() { - this.fastForward = 0; + stopSeeking() { + this.seeking = null; this.scene.animationOn(); - this.playbackState = this.paused ? Playback.Paused : Playback.Playing; + this.subscription?.(this.paused ? 'paused' : 'playing'); } - nextActivity() { - if (this.playbackState === Playback.Ready || this.playbackState === Playback.Paused) { - return; - } + shouldStep() { + if (this.atQueueEnd) return false; + if (this.seeking !== null) return true; + return !(this.paused && this.turn >= 0); + } + nextStep() { + if (!this.shouldStep()) return; this.scene.startAnimations(); let animations = undefined; - while (!animations) { + + do { this.waitForAnimations = true; - if (this.activityStep >= this.activityQueue.length) { - this.fastForwardOff(); - this.playbackState = Playback.Finished; + if (this.currentStep >= this.stepQueue.length) { + this.atQueueEnd = true; + if (!this.ended && this.isReplay) this.prematureEnd(); + this.stopSeeking(); if (this.ended) { this.scene.updateBgm(); } - if (this.endCallback) this.endCallback(this); + this.subscription?.('atqueueend'); return; } - // @ts-ignore property modified in method - if (this.playbackState === Playback.Ready || this.playbackState === Playback.Paused) { - return; - } - this.run(this.activityQueue[this.activityStep]); - this.activityStep++; + + this.run(this.stepQueue[this.currentStep]); + this.currentStep++; if (this.waitForAnimations === true) { animations = this.scene.finishAnimations(); } else if (this.waitForAnimations === 'simult') { this.scene.timeOffset = 0; } - } + } while (!animations && this.shouldStep()); - // @ts-ignore property modified in method - if (this.playbackState === Playback.Ready || this.playbackState === Playback.Paused) { + if (this.paused && this.turn >= 0 && this.seeking === null) { + // initial Play button, team preview + this.scene.pause(); return; } + if (!animations) return; + const interruptionCount = this.scene.interruptionCount; animations.done(() => { if (interruptionCount === this.scene.interruptionCount) { - this.nextActivity(); + this.nextStep(); } }); } setQueue(queue: string[]) { - this.activityQueue = queue; - this.reset(); + this.stepQueue = queue; + this.resetStep(); } setMute(mute: boolean) { diff --git a/src/panel-battle.tsx b/src/panel-battle.tsx index 44a6ad37c8..71ca6f9376 100644 --- a/src/panel-battle.tsx +++ b/src/panel-battle.tsx @@ -123,7 +123,7 @@ class BattleRoom extends ChatRoom { this.receiveLine([`error`, `/ffto - Invalid turn number: ${target}`]); return true; } - this.battle.fastForwardTo(turnNum); + this.battle.seekTurn(turnNum); this.update(null); return true; } case 'switchsides': { @@ -257,18 +257,20 @@ class BattlePanel extends PSRoomPanel { }; componentDidMount() { const $elem = $(this.base!); - const battle = new Battle($elem.find('.battle'), $elem.find('.battle-log')); + const battle = new Battle({ + $frame: $elem.find('.battle'), + $logFrame: $elem.find('.battle-log'), + }); this.props.room.battle = battle; - battle.endCallback = () => this.forceUpdate(); - battle.play(); (battle.scene as BattleScene).tooltips.listen($elem.find('.battle-controls')); super.componentDidMount(); + battle.subscribe(() => this.forceUpdate()); } receiveLine(args: Args) { const room = this.props.room; switch (args[0]) { case 'initdone': - room.battle.fastForwardTo(-1); + room.battle.seekTurn(Infinity); return; case 'request': this.receiveRequest(args[1] ? JSON.parse(args[1]) : null); @@ -316,7 +318,7 @@ class BattlePanel extends PSRoomPanel { if (room.side) { return this.renderPlayerControls(); } - const atEnd = room.battle.playbackState === Playback.Finished; + const atEnd = room.battle.atQueueEnd; return

{atEnd ? diff --git a/test/battle.test.js b/test/battle.test.js index 6b9daebda2..d016e0a397 100644 --- a/test/battle.test.js +++ b/test/battle.test.js @@ -12,33 +12,32 @@ require('../js/battle.js'); describe('Battle', () => { it('should process a bunch of messages properly', () => { - let battle = new Battle(); - battle.debug = true; - - battle.setQueue([ - "|init|battle", - "|title|FOO vs. BAR", - "|j|FOO", - "|j|BAR", - "|request|", - "|player|p1|FOO|169", - "|player|p2|BAR|265", - "|teamsize|p1|6", - "|teamsize|p2|6", - "|gametype|singles", - "|gen|7", - "|tier|[Gen 7] Random Battle", - "|rated|", - "|seed|", - "|rule|Sleep Clause Mod: Limit one foe put to sleep", - "|rule|HP Percentage Mod: HP is shown in percentages", - "|", - "|start", - "|switch|p1a: Leafeon|Leafeon, L83, F|100/100", - "|switch|p2a: Gliscor|Gliscor, L77, F|242/242", - "|turn|1", - ]); - battle.fastForwardTo(-1); + let battle = new Battle({ + debug: true, + log: [ + "|init|battle", + "|title|FOO vs. BAR", + "|j|FOO", + "|j|BAR", + "|request|", + "|player|p1|FOO|169", + "|player|p2|BAR|265", + "|teamsize|p1|6", + "|teamsize|p2|6", + "|gametype|singles", + "|gen|7", + "|tier|[Gen 7] Random Battle", + "|rated|", + "|seed|", + "|rule|Sleep Clause Mod: Limit one foe put to sleep", + "|rule|HP Percentage Mod: HP is shown in percentages", + "|", + "|start", + "|switch|p1a: Leafeon|Leafeon, L83, F|100/100", + "|switch|p2a: Gliscor|Gliscor, L77, F|242/242", + "|turn|1", + ], + }); let p1 = battle.sides[0]; let p2 = battle.sides[1]; @@ -75,7 +74,6 @@ describe('Battle', () => { ]) { battle.add(line); } - battle.fastForwardTo(-1); assert(!p2gliscor.isActive()); let p2kyurem = p2.pokemon[1];