Preact: Add battle options and timer (#2395)
Some checks failed
Node.js CI / build (22.x) (push) Has been cancelled

Co-authored-by: Guangcong Luo <guangcongluo@gmail.com>
This commit is contained in:
Aurastic 2025-04-27 06:21:36 +05:30 committed by GitHub
parent 68d04c131a
commit b26709a0a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 320 additions and 50 deletions

View File

@ -159,7 +159,12 @@ export class BattleLog {
let rank = name.charAt(0);
if (battle?.ignoreSpects && ' +'.includes(rank)) return;
if (battle?.ignoreOpponent) {
if ('\u2605\u2606'.includes(rank) && toUserid(name) !== app.user.get('userid')) return;
if (
'\u2605\u2606'.includes(rank) &&
toUserid(name) !== (window.app?.user?.get('userid') || window.PS?.user?.userid)
) {
return;
}
}
const ignoreList = window.app?.ignore || window.PS?.prefs?.ignore;
if (ignoreList?.[toUserid(name)] && ' +^\u2605\u2606'.includes(rank)) return;

View File

@ -3507,7 +3507,7 @@ export class Battle {
return;
} else if (args[1].endsWith(' seconds left.')) {
let hasIndex = args[1].indexOf(' has ');
let userid = window.app?.user?.get('userid');
let userid = window.app?.user?.get('userid') || window.PS?.user.userid;
if (toID(args[1].slice(0, hasIndex)) === userid) {
this.kickingInactive = parseInt(args[1].slice(hasIndex + 5), 10) || true;
}

View File

@ -79,6 +79,13 @@ class PSPrefs extends PSStreamModel<string | null> {
hideinterstice: true,
};
/* Battle preferences */
ignorenicks: boolean | null = null;
ignorespects: boolean | null = null;
ignoreopp: boolean | null = null;
autotimer: boolean | null = null;
rightpanelbattles: boolean | null = null;
/**
* Show "User joined" and "User left" messages. serverid:roomid
* table. Uses 1 and 0 instead of true/false for JSON packing
@ -895,6 +902,13 @@ export class PSRoom extends PSStreamModel<Args | null> implements RoomOptions {
this.send(target);
PS.leave(this.id);
},
'inopener,inparent'(target) {
// do this command in the popup opener
let room = this.getParent();
if (room && PS.isPopup(room)) room = room.getParent();
// will crash if the parent doesn't exist, which is fine
room!.send(target);
},
'maximize'(target) {
const roomid = /[^a-z0-9-]/.test(target) ? toID(target) as any as RoomID : target as RoomID;
const targetRoom = roomid ? PS.rooms[roomid] : this;
@ -1561,6 +1575,10 @@ export const PS = new class extends PSModel {
}
getRoom(elem: HTMLElement | EventTarget | null | undefined, skipClickable?: boolean): PSRoom | null {
let curElem: HTMLElement | null = elem as HTMLElement;
// might be the close button on the roomtab
if ((curElem as HTMLButtonElement)?.name === 'closeRoom' && (curElem as HTMLButtonElement).value) {
return PS.rooms[(curElem as HTMLButtonElement).value] || null;
}
while (curElem) {
if (curElem.id.startsWith('room-')) {
return PS.rooms[curElem.id.slice(5)] || null;
@ -1941,7 +1959,7 @@ export const PS = new class extends PSModel {
options.args = { initialSlash: true };
}
}
if (options.id.startsWith('battle-') && PS.prefs.rightpanelbattles) options.location = 'right';
options.parentRoomid ??= this.getRoom(options.parentElem)?.id;
let preexistingRoom = this.rooms[options.id];
if (preexistingRoom && this.isPopup(preexistingRoom)) {

View File

@ -133,6 +133,7 @@ export class BattleRoom extends ChatRoom {
side: BattleRequestSideInfo | null = null;
request: BattleRequest | null = null;
choices: BattleChoiceBuilder | null = null;
autoTimerActivated: boolean | null = null;
}
class BattleDiv extends preact.Component<{ room: BattleRoom }> {
@ -196,6 +197,64 @@ function PokemonButton(props: {
</button>;
}
class TimerButton extends preact.Component<{ room: BattleRoom }> {
timerInterval: number | null = null;
override componentWillUnmount() {
if (this.timerInterval) {
clearInterval(this.timerInterval);
this.timerInterval = null;
}
}
secondsToTime(seconds: number | true) {
if (seconds === true) return '-:--';
const minutes = Math.floor(seconds / 60);
seconds -= minutes * 60;
return `${minutes}:${(seconds < 10 ? '0' : '')}${seconds}`;
}
render() {
let time = 'Timer';
const room = this.props.room;
if (!this.timerInterval && room.battle.kickingInactive) {
this.timerInterval = setInterval(() => {
if (typeof room.battle.kickingInactive === 'number' && room.battle.kickingInactive > 1) {
room.battle.kickingInactive--;
if (room.battle.graceTimeLeft) room.battle.graceTimeLeft--;
else if (room.battle.totalTimeLeft) room.battle.totalTimeLeft--;
}
this.forceUpdate();
}, 1000);
} else if (this.timerInterval && !room.battle.kickingInactive) {
clearInterval(this.timerInterval);
this.timerInterval = null;
}
let timerTicking = (room.battle.kickingInactive &&
room.request && room.request.requestType !== "wait" && (room.choices && !room.choices.isDone())) ?
' timerbutton-on' : '';
if (room.battle.kickingInactive) {
const secondsLeft = room.battle.kickingInactive;
time = this.secondsToTime(secondsLeft);
if (secondsLeft !== true) {
if (secondsLeft <= 10 && timerTicking) {
timerTicking = ' timerbutton-critical';
}
if (room.battle.totalTimeLeft) {
const totalTime = this.secondsToTime(room.battle.totalTimeLeft);
time += ` | ${totalTime} total`;
}
}
}
return <button
style={{ position: "absolute", right: '10px' }} data-href="battletimer" class={`button${timerTicking}`} role="timer"
>
<i class="fa fa-hourglass-start" aria-hidden></i> {time}
</button>;
}
};
class BattlePanel extends PSRoomPanel<BattleRoom> {
static readonly id = 'battle';
static readonly routes = ['battle-*'];
@ -316,6 +375,11 @@ class BattlePanel extends PSRoomPanel<BattleRoom> {
return;
}
if (PS.prefs.autotimer && !room.battle.kickingInactive && !room.autoTimerActivated) {
this.send('/timer on');
room.autoTimerActivated = true;
}
BattleChoiceBuilder.fixRequest(request, room.battle);
if (request.side) {
@ -569,9 +633,6 @@ class BattlePanel extends PSRoomPanel<BattleRoom> {
if (choices.isDone()) {
return <div class="controls">
<div class="whatdo">
<button name="openTimer" class="button disabled timerbutton">
<i class="fa fa-hourglass-start" aria-hidden></i> Timer
</button>
{this.renderOldChoices(request, choices)}
</div>
<div class="pad">
@ -599,9 +660,6 @@ class BattlePanel extends PSRoomPanel<BattleRoom> {
const moveName = choices.getChosenMove(choices.current, choices.index()).name;
return <div class="controls">
<div class="whatdo">
<button name="openTimer" class="button disabled timerbutton">
<i class="fa fa-hourglass-start" aria-hidden></i> Timer
</button>
{this.renderOldChoices(request, choices)}
{pokemon.name} should use <strong>{moveName}</strong> at where? {}
</div>
@ -617,9 +675,6 @@ class BattlePanel extends PSRoomPanel<BattleRoom> {
return <div class="controls">
<div class="whatdo">
<button name="openTimer" class="button disabled timerbutton">
<i class="fa fa-hourglass-start" aria-hidden></i> Timer
</button>
{this.renderOldChoices(request, choices)}
What will <strong>{pokemon.name}</strong> do?
</div>
@ -674,9 +729,6 @@ class BattlePanel extends PSRoomPanel<BattleRoom> {
const pokemon = request.side.pokemon[choices.index()];
return <div class="controls">
<div class="whatdo">
<button name="openTimer" class="button disabled timerbutton">
<i class="fa fa-hourglass-start" aria-hidden></i> Timer
</button>
{this.renderOldChoices(request, choices)}
What will <strong>{pokemon.name}</strong> do?
</div>
@ -690,9 +742,6 @@ class BattlePanel extends PSRoomPanel<BattleRoom> {
} case 'team': {
return <div class="controls">
<div class="whatdo">
<button name="openTimer" class="button disabled timerbutton">
<i class="fa fa-hourglass-start" aria-hidden></i> Timer
</button>
{choices.alreadySwitchingIn.length > 0 ? (
[<button data-cmd="/cancel" class="button"><i class="fa fa-chevron-left" aria-hidden></i> Back</button>,
" What about the rest of your team? "]
@ -765,7 +814,6 @@ class BattlePanel extends PSRoomPanel<BattleRoom> {
</p>
)}
</div>;
}
handleDownloadReplay = (e: MouseEvent) => {
@ -787,9 +835,14 @@ class BattlePanel extends PSRoomPanel<BattleRoom> {
override render() {
const room = this.props.room;
this.updateLayout();
const id = `room-${room.id}`;
const hardcoreStyle = room.battle?.hardcoreMode ? <style
dangerouslySetInnerHTML={{ __html: `#${id} .battle .turn, #${id} .battle-history { display: none !important; }` }}
></style> : null;
if (room.width < 700) {
return <PSPanelWrapper room={room} focusClick scrollable="hidden">
{hardcoreStyle}
<BattleDiv room={room} />
<ChatLog
class="battle-log hasuserlist" room={room} top={this.battleHeight} noSubscription
@ -800,11 +853,20 @@ class BattlePanel extends PSRoomPanel<BattleRoom> {
</ChatLog>
<ChatTextEntry room={room} onMessage={this.send} onKey={this.onKey} left={0} />
<ChatUserList room={room} top={this.battleHeight} minimized />
<button
data-href="battleoptions" class="button"
style={{ position: 'absolute', right: '75px', top: this.battleHeight }}
>
Battle Options
</button>
{(room.battle && !room.battle.ended && room.request && room.battle.mySide.id === PS.user.userid) &&
<TimerButton room={room} />}
<div class="battle-controls-container"></div>
</PSPanelWrapper>;
}
return <PSPanelWrapper room={room} focusClick scrollable="hidden">
{hardcoreStyle}
<BattleDiv room={room} />
<ChatLog
class="battle-log hasuserlist" room={room} left={640} noSubscription
@ -813,8 +875,16 @@ class BattlePanel extends PSRoomPanel<BattleRoom> {
</ChatLog>
<ChatTextEntry room={room} onMessage={this.send} onKey={this.onKey} left={640} />
<ChatUserList room={room} left={640} minimized />
<button
data-href="battleoptions" class="button"
style={{ position: 'absolute', right: '15px' }}
>
Battle Options
</button>
<div class="battle-controls-container">
<div class="battle-controls" role="complementary" aria-label="Battle Controls" style="top: 370px;">
{(room.battle && !room.battle.ended && room.request && room.battle.mySide.id === PS.user.userid) &&
<TimerButton room={room} />}
{this.renderControls()}
</div>
</div>

View File

@ -503,7 +503,7 @@ class MainMenuPanel extends PSRoomPanel<MainMenuRoom> {
<h3
class={`mini-window-header${notifying}`} draggable onDragStart={this.handleDragStart} onClick={this.handleClickMinimize}
>
<button class="closebutton" data-cmd="/close" aria-label="Close" tabIndex={-1}>
<button class="closebutton" name="closeRoom" value={roomid} aria-label="Close" tabIndex={-1}>
<i class="fa fa-times-circle" aria-hidden></i>
</button>
<button class="maximizebutton" data-cmd="/maximize" tabIndex={-1} aria-label="Maximize">

View File

@ -946,40 +946,22 @@ class BattleForfeitPanel extends PSRoomPanel {
static readonly location = 'semimodal-popup';
static readonly noURL = true;
handleForfeit = (ev: Event) => {
const elem = this.props.room.parentElem;
const roomid = (elem as HTMLInputElement)?.value as RoomID || PS.getRoom(elem)?.id || '' as RoomID;
const room = PS.rooms[roomid] as BattleRoom;
const closeAfter = this.base!.querySelector<HTMLInputElement>('input[name=closeroom]')?.checked;
room.send("/forfeit");
if (closeAfter) PS.leave(room.id);
ev.preventDefault();
this.close();
};
override render() {
const room = this.props.room;
const battleRoom = room.getParent() as BattleRoom;
return <PSPanelWrapper room={room} width={480}><div class="pad">
<form onSubmit={this.handleForfeit}>
<p>Forfeiting makes you lose the battle. Are you sure?</p>
<p>
<label class="checkbox"><input
type="checkbox" name="closeroom" checked={true}
/> Close after forfeiting</label>
</p>
<p>
<button type="submit" class="button"><strong>Forfeit</strong></button> {}
{!battleRoom.battle.rated && <button type="button" data-href="replaceplayer" class="button">
Replace player
</button>} {}
<button type="button" data-cmd="/close" class="button">
Cancel
</button>
</p>
</form>
<p>Forfeiting makes you lose the battle. Are you sure?</p>
<p>
<button data-cmd="/closeand /inopener /closeand /forfeit" class="button"><strong>Forfeit and close</strong></button> {}
<button data-cmd="/closeand /inopener /forfeit" class="button">Just forfeit</button> {}
{!battleRoom.battle.rated && <button type="button" data-href="replaceplayer" class="button">
Replace player
</button>} {}
<button type="button" data-cmd="/close" class="button">
Cancel
</button>
</p>
</div></PSPanelWrapper>;
}
}
@ -1400,6 +1382,181 @@ class LeaveRoomPanel extends PSRoomPanel {
</div></PSPanelWrapper>;
}
}
class BattleOptionsPanel extends PSRoomPanel {
static readonly id = 'battleoptions';
static readonly routes = ['battleoptions'];
static readonly location = 'semimodal-popup';
static readonly noURL = true;
handleHardcoreMode = (ev: Event) => {
const mode = (ev.currentTarget as HTMLInputElement).checked;
const room = this.props.room.getParent() as BattleRoom;
if (!room?.battle) return this.close();
room.battle.setHardcoreMode(mode);
if (mode) {
room.add(`||Hardcore mode ON: Information not available in-game is now hidden.`);
} else {
room.add(`||Hardcore mode OFF: Information not available in-game is now shown.`);
}
room.update(null);
};
handleIgnoreSpectators = (ev: Event | boolean) => {
const value = typeof ev === "object" ?
(ev.currentTarget as HTMLInputElement).checked :
ev;
const room = this.props.room.getParent() as BattleRoom;
if (!room?.battle) return this.close();
room.battle.ignoreSpects = value;
room.add(`||Spectators ${room.battle.ignoreSpects ? '' : 'no longer '}ignored.`);
const chats = document.querySelectorAll<HTMLElement>('.battle-log .chat');
const displaySetting = room.battle.ignoreSpects ? 'none' : '';
for (const chat of chats) {
const small = chat.querySelector('small');
if (!small) continue;
const text = small.innerText;
const isPlayerChat = text.includes('\u2606') || text.includes('\u2605');
if (!isPlayerChat) {
chat.style.display = displaySetting;
}
}
room.battle.scene.log.updateScroll();
};
handleIgnoreOpponent = (ev: Event | boolean) => {
const value = typeof ev === "object" ?
(ev.currentTarget as HTMLInputElement).checked :
ev;
const room = this.props.room.getParent() as BattleRoom;
if (!room?.battle) return this.close();
room.battle.ignoreOpponent = value;
room.battle.resetToCurrentTurn();
};
handleIgnoreNicks = (ev: Event | boolean) => {
const value = typeof ev === "object" ?
(ev.currentTarget as HTMLInputElement).checked :
ev;
const room = this.props.room.getParent() as BattleRoom;
if (!room?.battle) return this.close();
room.battle.ignoreNicks = value;
room.battle.resetToCurrentTurn();
};
handleAllSettings = (ev: Event) => {
const setting = (ev.currentTarget as HTMLInputElement).name;
const value = (ev.currentTarget as HTMLInputElement).checked;
const room = this.props.room.getParent() as BattleRoom;
if (!room?.battle) return this.close();
switch (setting) {
case 'autotimer': {
PS.prefs.set('autotimer', value);
if (value) {
room.send('/timer on');
}
break;
}
case 'ignoreopp': {
PS.prefs.set('ignoreopp', value);
this.handleIgnoreOpponent(value);
break;
}
case 'ignorespects': {
PS.prefs.set('ignorespects', value);
this.handleIgnoreSpectators(value);
break;
}
case 'ignorenicks': {
PS.prefs.set('ignorenicks', value);
this.handleIgnoreNicks(value);
break;
}
case 'rightpanel': {
PS.prefs.set('rightpanelbattles', value);
}
}
};
override render() {
const room = this.props.room;
const battleRoom = this.props.room.getParent() as BattleRoom;
return <PSPanelWrapper room={room} width={380}><div class="pad">
<p><strong>In this battle</strong></p>
<p>
<label class="checkbox">
<input
checked={battleRoom?.battle?.hardcoreMode}
type="checkbox" onChange={this.handleHardcoreMode}
/> Hardcore mode (hide info not shown in-game)
</label>
</p>
<p>
<label class="checkbox">
<input
checked={battleRoom?.battle?.ignoreSpects}
type="checkbox" onChange={this.handleIgnoreSpectators}
/> Ignore spectators
</label>
</p>
<p>
<label class="checkbox">
<input
checked={battleRoom?.battle?.ignoreOpponent}
type="checkbox" onChange={this.handleIgnoreOpponent}
/> Ignore opponent
</label>
</p>
<p>
<label class="checkbox">
<input
checked={battleRoom?.battle?.ignoreNicks}
type="checkbox" onChange={this.handleIgnoreNicks}
/> Ignore nicknames
</label>
</p>
<p><strong>All battles</strong></p>
<p>
<label class="checkbox">
<input
name="ignorenicks" checked={PS.prefs.ignorenicks || false}
type="checkbox" onChange={this.handleAllSettings}
/> Ignore nicknames
</label>
</p>
<p>
<label class="checkbox">
<input
name="ignorespects" checked={PS.prefs.ignorespects || false}
type="checkbox" onChange={this.handleAllSettings}
/> Ignore spectators
</label>
</p>
<p>
<label class="checkbox">
<input
name="ignoreopp" checked={PS.prefs.ignoreopp || false}
type="checkbox" onChange={this.handleAllSettings}
/> Ignore opponent
</label>
</p>
<p>
<label class="checkbox">
<input
name="autotimer" checked={PS.prefs.autotimer || false}
type="checkbox" onChange={this.handleAllSettings}
/> Automatically start timer
</label>
</p>
<p>
<label class="checkbox">
<input
name="rightpanel" checked={PS.prefs.rightpanelbattles || false}
type="checkbox" onChange={this.handleAllSettings}
/> Open new battles on the right side
</label>
</p>
<p><button data-cmd="/close" class="button">Done</button></p>
</div>
</PSPanelWrapper>;
}
}
class PopupRoom extends PSRoom {
returnValue: unknown = this.args?.cancelValue;
@ -1487,6 +1644,24 @@ class RoomTabListPanel extends PSRoomPanel {
</div></PSPanelWrapper>;
}
}
class BattleTimerPanel extends PSRoomPanel {
static readonly id = 'battletimer';
static readonly routes = ['battletimer'];
static readonly location = 'semimodal-popup';
static readonly noURL = true;
override render() {
const room = this.props.room.getParent() as BattleRoom;
return <PSPanelWrapper room={this.props.room}><div class="pad">
{room.battle.kickingInactive ? (
<button class="button" data-cmd="/closeand /inopener /timer stop">Stop Timer</button>
) : (
<button class="button" data-cmd="/closeand /inopener /timer start">Start Timer</button>
)}
</div>
</PSPanelWrapper>;
}
}
PS.addRoomType(
UserPanel,
@ -1504,5 +1679,7 @@ PS.addRoomType(
LeaveRoomPanel,
ChatFormattingPanel,
PopupPanel,
RoomTabListPanel
RoomTabListPanel,
BattleOptionsPanel,
BattleTimerPanel
);