diff --git a/js/client-battle.js b/js/client-battle.js index 204556f96..7873e90a9 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 3c250961e..730451a12 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 cd74504d9..c63dfd2b0 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 e08112355..e4613e264 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 cf6c9abd2..f528bcc94 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 3b6d3950e..76ad3f48b 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 b7b3444fe..bc58cc38c 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; + paused: boolean; - // external - resumeButton: JQuery.EventHandler | null = null; + constructor(options: { + $frame?: JQuery, + $logFrame?: JQuery, + id?: ID, + log?: string[], + paused?: boolean, + isReplay?: boolean, + debug?: boolean, + subscription?: Battle['subscription'], + } = {}) { + this.id = options.id || ''; - constructor($frame: JQuery, $logFrame: JQuery, id = '') { - this.id = id; - - if (!$frame && !$logFrame) { + 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); + this.seekTurn(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; + seekTurn(turn: number, forceReset?: boolean) { + if (isNaN(turn)) return; + turn = Math.max(Math.floor(turn), 0); - 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(); + if (this.seeking !== null && this.seeking > turn && !forceReset) { + this.seeking = turn; return; } - this.scene.animationOff(); - this.playbackState = Playback.Seeking; - this.fastForward = time; - this.nextActivity(); + + if (turn === 0) { + this.seeking = null; + this.resetStep(); + this.scene.animationOn(); + if (this.paused) this.subscription?.('paused'); + return; + } + + 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 44a6ad37c..71ca6f937 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 6b9daebda..d016e0a39 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];