Add support for watching battles

Playing battles is, of course, a lot harder, but watching them
is at least implemented now.
This commit is contained in:
Guangcong Luo 2019-09-06 02:46:54 -05:00
parent 6d415e2cd7
commit 9e06e72a35
12 changed files with 223 additions and 44 deletions

View File

@ -27,3 +27,4 @@ node_modules/
/js/panel-teambuilder.js
/js/panel-teambuilder-team.js
/js/panel-teamdropdown.js
/js/panel-battle.js

1
.gitignore vendored
View File

@ -38,6 +38,7 @@ package-lock.json
/js/panel-teambuilder.js
/js/panel-teambuilder-team.js
/js/panel-teamdropdown.js
/js/panel-battle.js
/replays/caches/
/replays/replay-config.inc.php

View File

@ -68,6 +68,15 @@
<script defer src="/js/battle-log.js?"></script>
<script defer src="/js/panel-chat.js?"></script>
<script defer src="js/lib/soundmanager2-nodebug-jsmin.js"></script>
<script defer src="js/lib/jquery-2.1.4.min.js"></script>
<script defer src="data/graphics.js"></script>
<script defer src="js/battle-text-parser.js"></script>
<script defer src="data/text.js"></script>
<script defer src="js/battle-tooltips.js"></script>
<script defer src="js/battle.js"></script>
<script defer src="js/panel-battle.js"></script>
<script defer src="/js/battle-dex-data.js?"></script>
<script defer src="/data/pokedex.js?"></script>
<script defer src="/data/moves.js?"></script>

View File

@ -154,6 +154,7 @@ class BattleLog {
case 'seed': case 'choice': case ':': case 'timer':
case 'J': case 'L': case 'N': case 'spectator': case 'spectatorleave':
case 'initdone':
return;
default:
@ -190,12 +191,17 @@ class BattleLog {
case 'turn':
const h2elem = document.createElement('h2');
h2elem.className = 'battle-history';
let turnMessage = this.battleParser!.parseArgs(args, {}).trim();
if (!turnMessage.startsWith('==') || !turnMessage.endsWith('==')) {
throw new Error("Turn message must be a heading.");
let turnMessage;
if (this.battleParser) {
turnMessage = this.battleParser.parseArgs(args, {}).trim();
if (!turnMessage.startsWith('==') || !turnMessage.endsWith('==')) {
throw new Error("Turn message must be a heading.");
}
turnMessage = turnMessage.slice(2, -2).trim();
this.battleParser.curLineSection = 'break';
} else {
turnMessage = `Turn ${args[1]}`;
}
turnMessage = turnMessage.slice(2, -2).trim();
this.battleParser!.curLineSection = 'break';
h2elem.innerHTML = BattleLog.escapeHTML(turnMessage);
this.addSpacer();
this.addNode(h2elem);

View File

@ -3482,7 +3482,11 @@ class Battle {
else this.paused = false;
this.fastForwardWillScroll = true;
}
if (!time) return;
if (!time) {
this.fastForwardOff();
this.nextActivity();
return;
}
this.scene.animationOff();
this.playbackState = Playback.Seeking;
this.fastForward = time;

View File

@ -11,7 +11,6 @@ class PSConnection {
socket: any = null;
connected = false;
queue = [] as string[];
hostCheckInterval: number | undefined | null;
constructor() {
this.connect();
}
@ -26,7 +25,6 @@ class PSConnection {
PS.connected = true;
for (const msg of this.queue) socket.send(msg);
this.queue = [];
this.hostCheckInterval = setTimeout(() => this.doHostCheck(), 500);
PS.update();
};
socket.onmessage = (e: MessageEvent) => {
@ -34,10 +32,6 @@ class PSConnection {
};
socket.onclose = () => {
console.log('\u2705 (DISCONNECTED)');
if (this.hostCheckInterval !== null) {
clearTimeout(this.hostCheckInterval);
this.hostCheckInterval = null;
}
this.connected = false;
PS.connected = false;
PS.isOffline = true;
@ -48,13 +42,6 @@ class PSConnection {
PS.update();
};
}
doHostCheck() {
if (PS.server.host !== $.trim(PS.server.host)) {
this.socket.close();
} else {
this.hostCheckInterval = setTimeout(() => this.doHostCheck(), 500);
}
}
send(msg: string) {
if (!this.connected) {
this.queue.push(msg);

View File

@ -648,8 +648,10 @@ const PS = new class extends PSModel {
const roomid2 = roomid || 'lobby' as RoomID;
let room = PS.rooms[roomid];
console.log('\u2705 ' + (roomid ? '[' + roomid + '] ' : '') + '%c' + msg, "color: #007700");
let isInit = false;
for (const line of msg.split('\n')) {
if (line.startsWith('|init|')) {
isInit = true;
room = PS.rooms[roomid2];
const type = line.slice(6);
if (!room) {
@ -689,7 +691,7 @@ const PS = new class extends PSModel {
}
if (room) room.receive(line);
}
if (room) room.update(null);
if (room) room.update(isInit ? `|initdone` : null);
}
send(fullMsg: string) {
const pipeIndex = fullMsg.indexOf('|');

148
src/panel-battle.tsx Normal file
View File

@ -0,0 +1,148 @@
/**
* Battle panel
*
* @author Guangcong Luo <guangcongluo@gmail.com>
* @license AGPLv3
*/
class BattleRoom extends ChatRoom {
readonly classType = 'battle';
// @ts-ignore assigned in parent constructor
pmTarget: null;
// @ts-ignore assigned in parent constructor
challengeMenuOpen: false;
// @ts-ignore assigned in parent constructor
challengingFormat: null;
// @ts-ignore assigned in parent constructor
challengedFormat: null;
battle: Battle = null!;
/**
* @return true to prevent line from being sent to server
*/
handleMessage(line: string) {
if (!line.startsWith('/') || line.startsWith('//')) return false;
const spaceIndex = line.indexOf(' ');
const cmd = spaceIndex >= 0 ? line.slice(1, spaceIndex) : line.slice(1);
const target = spaceIndex >= 0 ? line.slice(spaceIndex + 1) : '';
switch (cmd) {
case 'play': {
this.battle.play();
this.update('');
return true;
} case 'pause': {
this.battle.pause();
this.update('');
return true;
} case 'ffto': case 'fastfowardto': {
let turnNum = Number(target);
if (target.charAt(0) === '+' || turnNum < 0) {
turnNum += this.battle.turn;
if (turnNum < 0) turnNum = 0;
} else if (target === 'end') {
turnNum = -1;
}
if (isNaN(turnNum)) {
this.receive(`|error|/ffto - Invalid turn number: ${target}`);
return true;
}
this.battle.fastForwardTo(turnNum);
this.update('');
return true;
} case 'switchsides': {
this.battle.switchSides();
return true;
}}
return super.handleMessage(line);
}
}
class BattleDiv extends preact.Component {
shouldComponentUpdate() {
return false;
}
render() {
return <div class="battle"></div>;
}
}
class BattlePanel extends PSRoomPanel<BattleRoom> {
send = (text: string) => {
this.props.room.send(text);
};
focus() {
this.base!.querySelector('textarea')!.focus();
}
focusIfNoSelection = () => {
const selection = window.getSelection()!;
if (selection.type === 'Range') return;
this.focus();
};
onKey = (e: KeyboardEvent) => {
if (e.keyCode === 33) { // Pg Up key
const chatLog = this.base!.getElementsByClassName('chat-log')[0] as HTMLDivElement;
chatLog.scrollTop = chatLog.scrollTop - chatLog.offsetHeight + 60;
return true;
} else if (e.keyCode === 34) { // Pg Dn key
const chatLog = this.base!.getElementsByClassName('chat-log')[0] as HTMLDivElement;
chatLog.scrollTop = chatLog.scrollTop + chatLog.offsetHeight - 60;
return true;
}
return false;
};
componentDidMount() {
const battle = new Battle($(this.base!).find('.battle'), $(this.base!).find('.battle-log'));
this.props.room.battle = battle;
battle.endCallback = () => this.forceUpdate();
battle.play();
super.componentDidMount();
}
receive(line: string) {
if (line === `|initdone`) {
this.props.room.battle.fastForwardTo(-1);
return;
}
this.props.room.battle.add(line);
}
renderControls() {
const battle = this.props.room.battle;
if (!battle) return null;
const atEnd = battle.playbackState === Playback.Finished;
return <div class="battle-controls" role="complementary" aria-label="Battle Controls" style="top: 370px;">
<p>
{atEnd ?
<button class="button disabled" name="cmd" value="/play"><i class="fa fa-play"></i><br />Play</button>
: battle.paused ?
<button class="button" name="cmd" value="/play"><i class="fa fa-play"></i><br />Play</button>
:
<button class="button" name="cmd" value="/pause"><i class="fa fa-pause"></i><br />Pause</button>
} {}
<button class="button" name="cmd" value="/ffto -1"><i class="fa fa-step-backward"></i><br />Last turn</button>
<button class={"button" + (atEnd ? " disabled" : "")} name="cmd" value="/ffto +1"><i class="fa fa-step-forward"></i><br />Skip turn</button> {}
<button class="button" name="cmd" value="/ffto 0"><i class="fa fa-undo"></i><br />First turn</button>
<button class={"button" + (atEnd ? " disabled" : "")} name="cmd" value="/ffto end"><i class="fa fa-fast-forward"></i><br />Skip to end</button>
</p>
<p>
<button class="button" name="cmd" value="/switchsides"><i class="fa fa-random"></i> Switch sides</button>
</p>
</div>;
}
render() {
const room = this.props.room;
return <PSPanelWrapper room={room}>
<BattleDiv></BattleDiv>
<ChatLog class="battle-log hasuserlist" room={this.props.room} onClick={this.focusIfNoSelection} left={640} noSubscription>
{}
</ChatLog>
<ChatTextEntry room={this.props.room} onMessage={this.send} onKey={this.onKey} left={640} />
<ChatUserList room={this.props.room} left={640} minimized />
{this.renderControls()}
</PSPanelWrapper>;
}
}
PS.roomTypes['battle'] = {
Model: BattleRoom,
Component: BattlePanel,
};
PS.updateRoomTypes();

View File

@ -6,7 +6,7 @@
*/
class ChatRoom extends PSRoom {
readonly classType: string = 'chat';
readonly classType: 'chat' | 'battle' = 'chat';
users: {[userid: string]: string} = {};
userCount = 0;
@ -152,6 +152,7 @@ class ChatRoom extends PSRoom {
class ChatTextEntry extends preact.Component<{
room: PSRoom, onMessage: (msg: string) => void, onKey: (e: KeyboardEvent) => boolean,
left?: number,
}> {
subscription: PSSubscription | null = null;
textbox: HTMLTextAreaElement = null!;
@ -295,7 +296,9 @@ class ChatTextEntry extends preact.Component<{
return true;
}
render() {
return <div class="chat-log-add hasuserlist" onClick={this.focusIfNoSelection}>
return <div
class="chat-log-add hasuserlist" onClick={this.focusIfNoSelection} style={{left: this.props.left || 0}}
>
<form class="chatbox">
<label style={{color: BattleLog.usernameColor(PS.user.userid)}}>{PS.user.name}:</label>
<textarea
@ -376,18 +379,18 @@ class ChatPanel extends PSRoomPanel<ChatRoom> {
</TeamForm>
</div> : null;
return <PSPanelWrapper room={this.props.room}>
return <PSPanelWrapper room={room}>
<div class="tournament-wrapper hasuserlist"></div>
<ChatLog class="chat-log hasuserlist" room={this.props.room} onClick={this.focusIfNoSelection}>
<ChatLog class="chat-log" room={this.props.room} onClick={this.focusIfNoSelection} left={146}>
{challengeTo || challengeFrom && [challengeTo, challengeFrom]}
</ChatLog>
<ChatTextEntry room={this.props.room} onMessage={this.send} onKey={this.onKey} />
<ChatTextEntry room={this.props.room} onMessage={this.send} onKey={this.onKey} left={146} />
<ChatUserList room={this.props.room} />
</PSPanelWrapper>;
}
}
class ChatUserList extends preact.Component<{room: ChatRoom}> {
class ChatUserList extends preact.Component<{room: ChatRoom, left?: number, minimized?: boolean}> {
subscription: PSSubscription | null = null;
componentDidMount() {
this.subscription = this.props.room.subscribe(msg => {
@ -404,7 +407,7 @@ class ChatUserList extends preact.Component<{room: ChatRoom}> {
function colorStyle(userid: ID) {
return {color: BattleLog.usernameColor(userid)};
}
return <ul class="userlist">
return <ul class={'userlist' + (this.props.minimized ? ' userlist-minimized' : '')} style={{left: this.props.left || 0}}>
<li class="userlist-count" style="text-align:center;padding:2px 0"><small>{room.userCount} users</small></li>
{userList.map(([userid, name]) => {
const groupSymbol = name.charAt(0);
@ -442,11 +445,14 @@ class ChatUserList extends preact.Component<{room: ChatRoom}> {
class ChatLog extends preact.Component<{
class: string, room: ChatRoom, onClick?: (e: Event) => void, children?: preact.ComponentChildren,
left?: number, top?: number, noSubscription?: boolean;
}> {
log: BattleLog = null!;
log: BattleLog | null = null;
subscription: PSSubscription | null = null;
componentDidMount() {
this.log = new BattleLog(this.base! as HTMLDivElement);
if (!this.props.noSubscription) {
this.log = new BattleLog(this.base! as HTMLDivElement);
}
this.subscription = this.props.room.subscribe(msg => {
if (!msg) return;
const tokens = PS.lineParse(msg);
@ -470,7 +476,7 @@ class ChatLog extends preact.Component<{
this.props.room.renameUser(tokens[1], tokens[2]);
break;
}
this.log.add(tokens);
if (!this.props.noSubscription) this.log!.add(tokens);
});
this.setControlsJSX(this.props.children);
}
@ -481,25 +487,37 @@ class ChatLog extends preact.Component<{
if (props.class !== this.props.class) {
this.base!.className = props.class;
}
if (props.left !== this.props.left) this.base!.style.left = `${props.left || 0}px`;
if (props.top !== this.props.top) this.base!.style.top = `${props.top || 0}px`;
this.setControlsJSX(props.children);
this.log.updateScroll();
this.updateScroll();
return false;
}
setControlsJSX(jsx: preact.ComponentChildren | undefined) {
const children = this.base!.children;
let controlsElem: HTMLDivElement | undefined = children[children.length - 1] as HTMLDivElement;
if (controlsElem.className !== 'controls') controlsElem = undefined;
let controlsElem = children[children.length - 1] as HTMLDivElement | undefined;
if (controlsElem && controlsElem.className !== 'controls') controlsElem = undefined;
if (!jsx) {
if (!controlsElem) return;
preact.render(null, this.base!, controlsElem);
this.log.updateScroll();
this.updateScroll();
return;
}
preact.render(<div class="controls">{jsx}</div>, this.base!, controlsElem);
this.log.updateScroll();
this.updateScroll();
}
updateScroll() {
if (this.log) {
this.log.updateScroll();
} else if (this.props.room.battle) {
this.log = (this.props.room.battle as Battle).scene.log;
this.log.updateScroll();
}
}
render() {
return <div class={this.props.class} role="log" onClick={this.props.onClick}></div>;
return <div class={this.props.class} role="log" onClick={this.props.onClick} style={{
left: this.props.left || 0, top: this.props.top || 0,
}}></div>;
}
}

View File

@ -77,7 +77,7 @@ class MainMenuRoom extends PSRoom {
continue;
}
room.challengedFormat = json.challengesFrom[targetUserid] || null;
room.challengingFormat = json.challengeTo.to === targetUserid ? json.challengeTo.format : null;
room.challengingFormat = (json.challengeTo || {}).to === targetUserid ? json.challengeTo.format : null;
room.update('');
}
}

View File

@ -1567,7 +1567,6 @@ a.ilink.yours {
text-align: left;
}
.userlist li {
border-bottom: 1px solid #CCCCCC;
height: 19px;
font: 10pt Verdana, sans-serif;
white-space: nowrap;
@ -1669,7 +1668,6 @@ a.ilink.yours {
top: 370px;
left: 0;
width: 640px;
background: #EEF2F5;
}
.battle-chat-toggle {
display: none;
@ -3180,8 +3178,7 @@ a.ilink.yours {
color: #DDD;
}
.dark .ps-room.ps-room-opaque,
.dark .battle-log-add,
.dark .battle-controls {
.dark .battle-log-add {
background: #444444;
color: #DDD;
}
@ -3301,9 +3298,6 @@ a.ilink.yours {
.dark .userlist span {
text-shadow: 1px 1px 0 #222222;
}
.dark .userlist li {
border-bottom-color: #444;
}
.dark .userlist li button:hover {
background: #222222;
}

View File

@ -69,6 +69,15 @@
<script src="js/battle-log.js"></script>
<script src="js/panel-chat.js"></script>
<script src="js/lib/soundmanager2-nodebug-jsmin.js"></script>
<script src="js/lib/jquery-2.1.4.min.js"></script>
<script src="data/graphics.js"></script>
<script src="js/battle-text-parser.js"></script>
<script src="data/text.js"></script>
<script src="js/battle-tooltips.js"></script>
<script src="js/battle.js"></script>
<script src="js/panel-battle.js"></script>
<script src="js/battle-dex-data.js"></script>
<script src="data/pokedex.js"></script>
<script src="data/moves.js"></script>