Preact: Improve teambuilder more
Some checks failed
Node.js CI / build (22.x) (push) Has been cancelled

- New export format
  - NOT FINAL, wow did Twitter freak out when they saw this
  - I have not made a decision on whether to keep this new export format

- Stub details form
- Checkbox for old export format
- 510 EV limit
- Show full search results (with windowing)
- Choosing generations rather than specific formats
- Searching/filtering/sorting results
  - Reverse sorting filter columns (move types/categories and
    pokemon types/abilities) is now possible even on oldclient

Bugfixes:
- Gen 1 (no item) species selection
- "Add pokemon" button positioning
- Dark mode results
- Width (to fit the final column of results)
- Highlighted line width
This commit is contained in:
Guangcong Luo 2025-04-26 02:43:59 +00:00
parent 734ce0d71d
commit b5630d3ff2
14 changed files with 748 additions and 205 deletions

15
package-lock.json generated
View File

@ -26,7 +26,7 @@
"eslint": "^9.20.1",
"globals": "^16.0.0",
"mocha": "^6.0.2",
"preact": "^8.3.1",
"preact": "^10.26.5",
"source-map": "^0.7.3",
"typescript": "^5.7.3",
"typescript-eslint": "^8.24.1"
@ -5228,12 +5228,15 @@
}
},
"node_modules/preact": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-8.5.3.tgz",
"integrity": "sha512-O3kKP+1YdgqHOFsZF2a9JVdtqD+RPzCQc3rP+Ualf7V6rmRDchZ9MJbiGTT7LuyqFKZqlHSOyO/oMFmI2lVTsw==",
"version": "10.26.5",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.26.5.tgz",
"integrity": "sha512-fmpDkgfGU6JYux9teDWLhj9mKN55tyepwYbxHgQuIxbWQzgFg5vk7Mrrtfx7xRxq798ynkY4DDDxZr235Kk+4w==",
"dev": true,
"hasInstallScript": true,
"license": "MIT"
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",

View File

@ -33,7 +33,7 @@
"eslint": "^9.20.1",
"globals": "^16.0.0",
"mocha": "^6.0.2",
"preact": "^8.3.1",
"preact": "^10.26.5",
"source-map": "^0.7.3",
"typescript": "^5.7.3",
"typescript-eslint": "^8.24.1"

View File

@ -29,7 +29,7 @@ https://psim.us/dev
<link rel="shortcut icon" href="favicon.ico" id="dynamic-favicon" />
<link rel="stylesheet" href="/style/battle.css?" />
<link rel="stylesheet" href="/style/client2.css?" />
<link rel="stylesheet" href="/style/utilichart.css?" />
<link rel="stylesheet" href="/style/teambuilder.css?" />
<meta name="robots" content="noindex" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<!--[if lte IE 8]><script>
@ -69,8 +69,8 @@ https://psim.us/dev
document.head.appendChild(linkEl);
}
linkStyle("/style/sim-types.css");
linkStyle("/style/teambuilder.css?");
linkStyle("style/battle-search.css");
linkStyle("style/utilichart.css?");
linkStyle("style/battle-search.css?");
linkStyle("/style/font-awesome.css");
</script>
<script nomodule defer src="/js/lib/ps-polyfill.js"></script>
@ -130,6 +130,8 @@ https://psim.us/dev
<script defer src="/data/pokedex-mini.js?"></script>
<script defer src="/data/pokedex-mini-bw.js?"></script>
<script defer src="/data/typechart.js?"></script>
<script defer src="/data/aliases.js?"></script>
<script defer src="/js/lib/d3.v3.min.js"></script>
<script defer src="/js/lib/color-thief.min.js"></script>

View File

@ -675,11 +675,11 @@ abstract class BattleTypedSearch<T extends SearchType> {
}
getResults(filters?: SearchFilter[] | null, sortCol?: string | null, reverseSort?: boolean): SearchRow[] {
if (sortCol === 'type') {
return [this.sortRow!, ...BattleTypeSearch.prototype.getDefaultResults.call(this)];
return [this.sortRow!, ...BattleTypeSearch.prototype.getDefaultResults.call(this, reverseSort)];
} else if (sortCol === 'category') {
return [this.sortRow!, ...BattleCategorySearch.prototype.getDefaultResults.call(this)];
return [this.sortRow!, ...BattleCategorySearch.prototype.getDefaultResults.call(this, reverseSort)];
} else if (sortCol === 'ability') {
return [this.sortRow!, ...BattleAbilitySearch.prototype.getDefaultResults.call(this)];
return [this.sortRow!, ...BattleAbilitySearch.prototype.getDefaultResults.call(this, reverseSort)];
}
if (!this.baseResults) {
@ -1189,14 +1189,15 @@ class BattleAbilitySearch extends BattleTypedSearch<'ability'> {
getTable() {
return BattleAbilities;
}
getDefaultResults(): SearchRow[] {
getDefaultResults(reverseSort?: boolean): SearchRow[] {
const results: SearchRow[] = [];
for (let id in BattleAbilities) {
results.push(['ability', id as ID]);
}
if (reverseSort) results.reverse();
return results;
}
getBaseResults() {
getBaseResults(): SearchRow[] {
if (!this.species) return this.getDefaultResults();
const format = this.format;
const isHackmons = (format.includes('hackmons') || format.endsWith('bh'));
@ -1884,12 +1885,14 @@ class BattleCategorySearch extends BattleTypedSearch<'category'> {
getTable() {
return { physical: 1, special: 1, status: 1 };
}
getDefaultResults(): SearchRow[] {
return [
getDefaultResults(reverseSort?: boolean): SearchRow[] {
const results: SearchRow[] = [
['category', 'physical' as ID],
['category', 'special' as ID],
['category', 'status' as ID],
];
if (reverseSort) results.reverse();
return results;
}
getBaseResults() {
return this.getDefaultResults();
@ -1906,11 +1909,12 @@ class BattleTypeSearch extends BattleTypedSearch<'type'> {
getTable() {
return window.BattleTypeChart;
}
getDefaultResults(): SearchRow[] {
getDefaultResults(reverseSort?: boolean): SearchRow[] {
const results: SearchRow[] = [];
for (let id in window.BattleTypeChart) {
results.push(['type', id as ID]);
}
if (reverseSort) results.reverse();
return results;
}
getBaseResults() {

View File

@ -1109,7 +1109,7 @@ export class BattleLog {
} else if (name.startsWith(`[Gen ${Dex.gen} `)) {
name = '[' + name.slice(`[Gen ${Dex.gen} `.length);
}
return name;
return name || `[Gen ${Dex.gen}]`;
}
static escapeHTML(str: string | number, jsEscapeToo?: boolean) {

View File

@ -11,7 +11,9 @@ import preact from "../js/lib/preact";
import { Dex, type ID } from "./battle-dex";
import type { DexSearch, SearchRow } from "./battle-dex-search";
export class PSSearchResults extends preact.Component<{ search: DexSearch }> {
export class PSSearchResults extends preact.Component<{
search: DexSearch, searchInitial?: ID | null, windowing?: number | null, firstRow?: SearchRow,
}> {
readonly URL_ROOT = `//${Config.routes.dex}/`;
renderPokemonSortRow() {
@ -211,7 +213,7 @@ export class PSSearchResults extends preact.Component<{ search: DexSearch }> {
<span class="col typecol">
<img
src={`${Dex.resourcePrefix}sprites/types/${move.type}.png`}
src={`${Dex.resourcePrefix}sprites/types/${encodeURIComponent(move.type)}.png`}
alt={move.type} height="14" width="32" class="pixelated"
/>
<img
@ -242,7 +244,10 @@ export class PSSearchResults extends preact.Component<{ search: DexSearch }> {
<span class="col namecol">{this.renderName(name, matchStart, matchEnd)}</span>
<span class="col typecol">
<img src={`${Dex.resourcePrefix}sprites/types/${name}.png`} alt={name} height="14" width="32" class="pixelated" />
<img
src={`${Dex.resourcePrefix}sprites/types/${encodeURIComponent(name)}.png`}
alt={name} height="14" width="32" class="pixelated"
/>
</span>
{errorMessage}
@ -373,24 +378,32 @@ export class PSSearchResults extends preact.Component<{ search: DexSearch }> {
}
return <li>Error: not found</li>;
}
renderFilters() {
const search = this.props.search;
return search.filters && <p>
Filters: {}
{search.filters.map(([type, name]) =>
<button class="filter" data-filter={`${type}:${name}`}>
{name} <i class="fa fa-times-circle" aria-hidden></i>
</button>
)}
{!search.query && <small style="color: #888">(backspace = delete filter)</small>}
</p>;
}
render() {
const search = this.props.search;
return <ul class="dexlist">
{search.filters && <p>
Filters: {}
{search.filters.map(([type, name]) =>
<button class="filter" value={`${type}:${name}`}>
${name} <i class="fa fa-times-circle" aria-hidden></i>
</button>
)}
{!search.query && <small style="color: #888">(backspace = delete filter)</small>}
</p>}
{
// TODO: implement windowing
// for now, just show first twenty results
search.results?.slice(0, 20).map(result => this.renderRow(result))
}
let results = search.results;
let searchInitial: SearchRow | null = null;
if (this.props.searchInitial && search.typedSearch) {
searchInitial = [search.typedSearch.searchType, this.props.searchInitial];
}
if (this.props.windowing) results = results?.slice(0, this.props.windowing) || null;
return <ul class="dexlist" style={`min-height: ${(1 + (search.results?.length || 1)) * 33}px;`}>
{this.renderFilters()}
{searchInitial && this.renderRow(searchInitial)}
{results?.map(result => this.renderRow(result))}
</ul>;
}
}

View File

@ -1537,6 +1537,12 @@ export const PS = new class extends PSModel {
width: 570,
maxWidth: 640,
};
case 'team':
return {
minWidth: 660,
width: 660,
maxWidth: 660,
};
case 'battle':
return {
minWidth: 320,

View File

@ -23,6 +23,8 @@ class TeamRoom extends PSRoom {
team!: Team;
gen = Dex.gen;
dex: ModdedDex = Dex;
search = new DexSearch();
searchInitial: ID | null = null;
constructor(options: RoomOptions) {
super(options);
const team = PS.teams.byKey[this.id.slice(5)] || null;
@ -135,9 +137,9 @@ class TeamRoom extends PSRoom {
}
if (natureOverride) {
val *= natureOverride;
} else if (BattleNatures[set.nature as "Serious"]?.plus === stat) {
} else if (BattleNatures[set.nature!]?.plus === stat) {
val *= 1.1;
} else if (BattleNatures[set.nature as "Serious"]?.minus === stat) {
} else if (BattleNatures[set.nature!]?.minus === stat) {
val *= 0.9;
}
if (!supportsEVs) {
@ -166,31 +168,44 @@ class TeamTextbox extends preact.Component<{ team: Team, room: TeamRoom }> {
sets: Dex.PokemonSet[] = [];
textbox: HTMLTextAreaElement = null!;
heightTester: HTMLTextAreaElement = null!;
compat = false;
/** we changed the set but are delaying updates until the selection form is closed */
setDirty = false;
windowing = true;
selection: {
setIndex: number,
offsetY: number | null,
type: SelectionType | null,
typeIndex: number,
lineRange: [number, number] | null,
active: boolean,
} | null = null;
search = new DexSearch();
getYAt(index: number, value: string) {
innerFocus: {
offsetY: number | null,
setIndex: number,
type: SelectionType,
/** i.e. which move is this */
typeIndex: number,
range: [number, number],
/** if you edit, you'll change the range end, so it needs to be updated with this in mind */
rangeEndChar: string,
} | null = null;
getYAt(index: number, fullLine?: boolean) {
if (index < 0) return 10;
this.heightTester.value = value.slice(0, index);
const newValue = this.textbox.value.slice(0, index);
this.heightTester.value = fullLine && !newValue.endsWith('\n') ? newValue + '\n' : newValue;
return this.heightTester.scrollHeight;
}
input = () => this.update();
keyUp = () => this.update(true);
input = () => this.updateText();
keyUp = () => this.updateText(true);
click = (ev: MouseEvent | KeyboardEvent) => {
if (ev.altKey || ev.ctrlKey || ev.metaKey) return;
const oldRange = this.selection?.lineRange;
this.update(true, true);
this.updateText(true, true);
if (this.selection) {
// this shouldn't actually update anything, so the reference comparison is enough
if (this.selection.lineRange === oldRange) return;
if (this.textbox.selectionStart === this.textbox.selectionEnd) {
const range = this.getSelectionTypeRange();
if (range) this.textbox.setSelectionRange(range.start, range.end);
if (range) this.textbox.setSelectionRange(range[0], range[1]);
}
}
};
@ -203,38 +218,72 @@ class TeamTextbox extends preact.Component<{ team: Team, room: TeamRoom }> {
}
break;
case 9: // tab
if (!this.selection?.active) {
this.click(ev);
if (!this.innerFocus) {
if (
this.textbox.selectionStart === this.textbox.value.length &&
(this.textbox.value.endsWith('\n\n') || !this.textbox.value)
) {
this.addPokemon();
} else {
this.click(ev);
}
ev.stopImmediatePropagation();
ev.preventDefault();
}
break;
case 80: // p
if (ev.metaKey) {
PS.alert(PSTeambuilder.exportTeam(this.sets, this.props.room.dex));
ev.stopImmediatePropagation();
ev.preventDefault();
break;
}
}
};
closeMenu = () => {
if (this.selection?.active) {
this.selection.active = false;
this.forceUpdate();
if (this.innerFocus) {
this.innerFocus = null;
if (this.setDirty) {
this.updateText();
} else {
this.forceUpdate();
}
this.textbox.focus();
return true;
}
return false;
};
update = (cursorOnly?: boolean, autoSelect?: boolean) => {
updateText = (noTextChange?: boolean, autoSelect?: boolean | SelectionType) => {
const textbox = this.textbox;
const value = textbox.value;
const selectionStart = textbox.selectionStart || 0;
const selectionEnd = textbox.selectionEnd || 0;
if (this.selection?.lineRange) {
const [start, end] = this.selection.lineRange;
if (this.innerFocus) {
if (!noTextChange) {
let lineEnd = this.textbox.value.indexOf('\n', this.innerFocus.range[0]);
if (lineEnd < 0) lineEnd = this.textbox.value.length;
const line = this.textbox.value.slice(this.innerFocus.range[0], lineEnd);
if (this.innerFocus.rangeEndChar) {
const index = line.indexOf(this.innerFocus.rangeEndChar);
if (index >= 0) lineEnd = this.innerFocus.range[0] + index;
}
this.innerFocus.range[1] = lineEnd;
}
const [start, end] = this.innerFocus.range;
if (selectionStart >= start && selectionStart <= end && selectionEnd >= start && selectionEnd <= end) {
if (autoSelect && !this.selection.active) {
this.selection.active = true;
this.forceUpdate();
if (!noTextChange) {
this.updateSearch();
this.setDirty = true;
}
return;
}
this.innerFocus = null;
}
if (this.setDirty) {
this.setDirty = false;
noTextChange = false;
}
this.heightTester.style.width = `${textbox.offsetWidth}px`;
@ -243,7 +292,7 @@ class TeamTextbox extends preact.Component<{ team: Team, room: TeamRoom }> {
/** for the set we're currently parsing */
let setIndex: number | null = null;
let nextSetIndex = 0;
if (!cursorOnly) this.setInfo = [];
if (!noTextChange) this.setInfo = [];
this.selection = null;
while (index < value.length) {
@ -257,12 +306,12 @@ class TeamTextbox extends preact.Component<{ team: Team, room: TeamRoom }> {
continue;
}
if (setIndex === null && index && !cursorOnly) {
this.setInfo[this.setInfo.length - 1].bottomY = this.getYAt(index - 1, value);
if (setIndex === null && index && !noTextChange && this.setInfo.length) {
this.setInfo[this.setInfo.length - 1].bottomY = this.getYAt(index - 1);
}
if (setIndex === null) {
if (!cursorOnly) {
if (!noTextChange) {
const atIndex = line.indexOf('@');
let species = atIndex >= 0 ? line.slice(0, atIndex).trim() : line.trim();
if (species.endsWith(' (M)') || species.endsWith(' (F)')) {
@ -297,7 +346,8 @@ class TeamTextbox extends preact.Component<{ team: Team, room: TeamRoom }> {
type = 'move';
} else if (
!lcLine || lcLine.startsWith('level:') || lcLine.startsWith('gender:') ||
lcLine.startsWith('shiny:')
(lcLine + ':').startsWith('shiny:') || (lcLine + ':').startsWith('gigantamax:') ||
lcLine.startsWith('tera type:') || lcLine.startsWith('dynamax level:')
) {
type = 'details';
} else if (
@ -308,29 +358,31 @@ class TeamTextbox extends preact.Component<{ team: Team, room: TeamRoom }> {
} else {
type = 'pokemon';
const atIndex = line.indexOf('@');
if (atIndex >= 0 && selectionStart > index + atIndex) {
type = 'item';
start = index + atIndex + 1;
} else {
end = index + atIndex;
if (atIndex >= 0) {
if (selectionStart > index + atIndex) {
type = 'item';
start = index + atIndex + 1;
} else {
end = index + atIndex;
if (line.charAt(atIndex - 1) === ']' || line.charAt(atIndex - 2) === ']') {
type = 'ability';
}
}
}
}
const offsetY = this.getYAt(index - 1, value);
if (typeof autoSelect === 'string') autoSelect = autoSelect === type;
this.selection = {
setIndex, offsetY, type, lineRange: [start, end], active: !!autoSelect,
setIndex, type, lineRange: [start, end], typeIndex: 0,
};
const searchType = type !== 'details' && type !== 'stats' ? type : '';
this.search.setType(searchType, this.props.team.format, this.sets[setIndex]);
this.search.find('');
window.search = this.search;
if (autoSelect) this.engageFocus();
}
index = nlIndex + 1;
}
if (!cursorOnly) {
if (!noTextChange) {
const end = value.endsWith('\n\n') ? value.length - 1 : value.length;
const bottomY = this.getYAt(end, value);
const bottomY = this.getYAt(end, true);
if (this.setInfo.length) {
this.setInfo[this.setInfo.length - 1].bottomY = bottomY;
}
@ -340,24 +392,134 @@ class TeamTextbox extends preact.Component<{ team: Team, room: TeamRoom }> {
}
this.forceUpdate();
};
engageFocus(focus?: this['innerFocus']) {
if (this.innerFocus) return;
const { room } = this.props;
if (!focus) {
if (!this.selection?.type) return;
const range = this.getSelectionTypeRange();
if (!range) return;
const { type, setIndex } = this.selection;
let rangeEndChar = this.textbox.value.charAt(range[1]);
if (rangeEndChar === ' ') rangeEndChar += this.textbox.value.charAt(range[1] + 1);
focus = {
offsetY: this.getYAt(range[0]),
setIndex,
type,
typeIndex: this.selection.typeIndex,
range,
rangeEndChar,
};
}
this.innerFocus = focus;
if (focus.type === 'details' || focus.type === 'stats') {
this.forceUpdate();
return;
}
room.search.setType(focus.type, room.team.format, this.sets[focus.setIndex]);
this.textbox.setSelectionRange(focus.range[0], focus.range[1]);
let value = this.textbox.value.slice(focus.range[0], focus.range[1]);
room.searchInitial = null;
switch (focus.type) {
case 'pokemon':
if (room.dex.species.get(value).exists) {
room.searchInitial = toID(value);
value = '';
}
break;
case 'item':
if (toID(value) === 'noitem') value = '';
if (room.dex.items.get(value).exists) {
room.searchInitial = toID(value);
value = '';
}
break;
case 'ability':
if (toID(value) === 'selectability') value = '';
if (toID(value) === 'noability') value = '';
if (room.dex.abilities.get(value).exists) {
room.searchInitial = toID(value);
value = '';
}
break;
case 'move':
if (room.dex.moves.get(value).exists) {
room.searchInitial = toID(value);
value = '';
}
break;
}
room.search.find(value);
this.windowing = true;
this.forceUpdate();
}
updateSearch() {
if (!this.innerFocus) return;
const { range } = this.innerFocus;
const { room } = this.props;
const value = this.textbox.value.slice(range[0], range[1]);
room.search.find(value);
this.windowing = true;
this.forceUpdate();
}
handleClick = (ev: Event) => {
const { room } = this.props;
let target = ev.target as HTMLElement | null;
while (target && target.className !== 'dexlist') {
if (target.tagName === 'A') {
const entry = target.getAttribute('data-entry');
if (entry) {
const [type, name] = entry.split('|');
this.changeSet(type as SelectionType, name);
if (room.search.addFilter([type, name])) {
room.search.find('');
if (room.search.query) {
this.changeSet(this.selection!.type!, '');
} else {
this.forceUpdate();
}
} else {
this.changeSet(type as SelectionType, name);
}
ev.preventDefault();
ev.stopImmediatePropagation();
break;
}
}
if (target.tagName === 'BUTTON') {
const filter = target.getAttribute('data-filter');
if (filter) {
room.search.removeFilter(filter.split(':') as any);
room.search.find('');
this.forceUpdate();
ev.preventDefault();
ev.stopPropagation();
break;
}
// sort
const sort = target.getAttribute('data-sort');
if (sort) {
room.search.toggleSort(sort);
room.search.find('');
this.forceUpdate();
ev.preventDefault();
ev.stopPropagation();
break;
}
}
target = target.parentElement;
}
};
getSelectionTypeRange() {
getSelectionTypeRange(): [number, number] | null {
const selection = this.selection;
if (!selection?.lineRange) return null;
@ -395,7 +557,7 @@ class TeamTextbox extends preact.Component<{ team: Team, room: TeamRoom }> {
}
}
return { start, end };
return [start, end];
}
case 'item': {
// let atIndex = lcLine.lastIndexOf('@');
@ -404,17 +566,28 @@ class TeamTextbox extends preact.Component<{ team: Team, room: TeamRoom }> {
// if (lcLine.charAt(atIndex + 1) === ' ') atIndex++;
// return { start: start + atIndex + 1, end };
if (lcLine.startsWith(' ')) start++;
return { start, end };
return [start, end];
}
case 'ability': {
if (lcLine.startsWith('[')) {
start++;
if (lcLine.endsWith(' ')) {
end--;
lcLine = lcLine.slice(0, -1);
}
if (lcLine.endsWith(']')) {
end--;
}
return [start, end];
}
if (!lcLine.startsWith('ability:')) return null;
start += lcLine.startsWith('ability: ') ? 9 : 8;
return { start, end };
return [start, end];
}
case 'move': {
if (!lcLine.startsWith('-')) return null;
start += lcLine.startsWith('- ') ? 2 : 1;
return { start, end };
return [start, end];
}
}
return null;
@ -425,50 +598,67 @@ class TeamTextbox extends preact.Component<{ team: Team, room: TeamRoom }> {
const range = this.getSelectionTypeRange();
if (range) {
this.replace(name, range.start, range.end);
this.update(false, true);
this.replace(name, range[0], range[1]);
this.updateText(false, true);
return;
}
switch (type) {
case 'pokemon': {
const species = this.props.room.dex.species.get(name);
const abilities = Object.values(species.abilities);
this.sets[selection.setIndex] ||= {
ability: abilities.length === 1 ? abilities[0] : undefined,
species: '',
moves: [],
};
this.sets[selection.setIndex].species = name;
this.replaceSet(selection.setIndex);
this.update(false, true);
this.updateText(false, true);
break;
}
case 'ability': {
this.sets[selection.setIndex].ability = name;
this.replaceSet(selection.setIndex);
this.update(false, true);
this.updateText(false, true);
break;
}
}
}
getSetRange(index: number) {
const start = this.setInfo[index]?.index ?? this.textbox.value.length;
const end = this.setInfo[index + 1]?.index ?? this.textbox.value.length;
return [start, end];
}
changeCompat = (ev: Event) => {
const checkbox = ev.currentTarget as HTMLInputElement;
this.compat = checkbox.checked;
this.sets = PSTeambuilder.importTeam(this.textbox.value);
this.textbox.value = PSTeambuilder.exportTeam(this.sets, this.props.room.dex, this.compat);
this.updateText();
};
replaceSet(index: number) {
const { room } = this.props;
const { team } = room;
if (!team) return;
let newText = PSTeambuilder.exportSet(this.sets[index], room.dex);
const start = this.setInfo[index]?.index || this.textbox.value.length;
const end = this.setInfo[index + 1]?.index || this.textbox.value.length;
if (start === this.textbox.value.length && !this.textbox.value.endsWith('\n\n')) {
let newText = PSTeambuilder.exportSet(this.sets[index], room.dex, this.compat);
const [start, end] = this.getSetRange(index);
if (start && start === this.textbox.value.length && !this.textbox.value.endsWith('\n\n')) {
newText = (this.textbox.value.endsWith('\n') ? '\n' : '\n\n') + newText;
}
this.replace(newText, start, end, start + newText.length);
// we won't do a full update but we do need to update where the end is,
// for future updates
if (this.setInfo[index + 1]) {
this.setInfo[index + 1].index = start + newText.length;
if (!this.setInfo[index]) {
this.updateText();
} else {
if (this.setInfo[index + 1]) {
this.setInfo[index + 1].index = start + newText.length;
}
// others don't need to be updated;
// TODO: a full update next time we focus the textbox
} else if (!this.setInfo[index]) {
this.update();
// we'll do a full update next time we focus the textbox
this.setDirty = true;
}
}
replace(text: string, start: number, end: number, selectionStart = start, selectionEnd = start + text.length) {
@ -490,9 +680,9 @@ class TeamTextbox extends preact.Component<{ team: Team, room: TeamRoom }> {
this.heightTester = this.base!.getElementsByClassName('heighttester')[0] as HTMLTextAreaElement;
this.sets = PSTeambuilder.unpackTeam(this.props.team.packedTeam);
const exportedTeam = PSTeambuilder.exportTeam(this.sets, this.props.room.dex);
const exportedTeam = PSTeambuilder.exportTeam(this.sets, this.props.room.dex, this.compat);
this.textbox.value = exportedTeam;
this.update();
this.updateText();
}
override componentWillUnmount() {
this.textbox = null!;
@ -501,32 +691,50 @@ class TeamTextbox extends preact.Component<{ team: Team, room: TeamRoom }> {
clickDetails = (ev: Event) => {
const target = ev.currentTarget as HTMLButtonElement;
const i = parseInt(target.value || '0');
if (this.selection?.active && this.selection?.type === target.name) {
this.selection.active = false;
if (this.innerFocus?.type === target.name) {
this.innerFocus = null;
this.forceUpdate();
return;
}
this.selection = {
this.innerFocus = {
offsetY: null,
setIndex: i,
offsetY: this.setInfo[i].bottomY,
type: target.name as SelectionType,
lineRange: null,
active: true,
typeIndex: 0,
range: [0, 0],
rangeEndChar: '',
};
this.forceUpdate();
};
addPokemon = () => {
this.selection = {
if (!this.textbox.value.endsWith('\n\n')) {
this.textbox.value += this.textbox.value.endsWith('\n') ? '\n' : '\n\n';
}
const end = this.textbox.value.length;
this.textbox.setSelectionRange(end, end);
this.textbox.focus();
this.engageFocus({
offsetY: this.getYAt(end, true),
setIndex: this.setInfo.length,
offsetY: null,
type: 'pokemon',
lineRange: null,
active: true,
};
this.search.setType('pokemon', this.props.team.format);
this.search.find('');
typeIndex: 0,
range: [end, end],
rangeEndChar: '@',
});
};
scrollResults = (ev: Event) => {
this.windowing = false;
if (PS.leftPanelWidth !== null) {
(ev.currentTarget as any).scrollIntoViewIfNeeded?.();
}
this.forceUpdate();
};
windowResults() {
if (this.windowing) {
return Math.ceil(window.innerHeight / 33);
}
return null;
}
renderDetails(set: Dex.PokemonSet, i: number) {
const { room } = this.props;
const { team } = room;
@ -542,8 +750,11 @@ class TeamTextbox extends preact.Component<{ team: Team, room: TeamRoom }> {
return <button class="textbox setdetails" name="details" value={i} onClick={this.clickDetails}>
<span class="detailcell"><label>Level</label>{set.level || 100}</span>
<span class="detailcell"><label>Gender</label>{gender}</span>
<span class="detailcell"><label>Shiny</label>{set.shiny ? 'Yes' : 'No'}</span>
{room.gen < 9 && <span class="detailcell"><label>Gender</label>{gender}</span>}
{room.gen === 9 && <span class="detailcell">
<label>Tera</label>{set.teraType || species.forceTeraType || species.types[0]}
</span>}
</button>;
}
@ -610,12 +821,13 @@ class TeamTextbox extends preact.Component<{ team: Team, room: TeamRoom }> {
onInput={this.input} onClick={this.click} onKeyUp={this.keyUp} onKeyDown={this.keyDown}
/>
<textarea
class="textbox teamtextbox heighttester" style="visibility:hidden" tabIndex={-1} aria-hidden={true}
class="textbox teamtextbox heighttester" style="visibility:hidden;left:-15px" tabIndex={-1} aria-hidden={true}
/>
<div class="teamoverlays">
{this.setInfo.slice(0, -1).map(info =>
<hr style={`top:${info.bottomY - 18}px`} />
)}
{this.setInfo.length < 6 && !!this.setInfo.length && <hr style={`top:${this.bottomY() - 18}px`} />}
{this.setInfo.map((info, i) => {
if (!info.species) return null;
const set = this.sets[i];
@ -642,32 +854,36 @@ class TeamTextbox extends preact.Component<{ team: Team, room: TeamRoom }> {
{this.renderDetails(set, i)}
</div>];
})}
{this.setInfo.length < 6 && [
!!this.setInfo.length && <hr style={`top:${this.bottomY() - 18}px`} />,
{this.setInfo.length < 6 && !(this.innerFocus && this.innerFocus.setIndex >= this.setInfo.length) && (
<div style={`top:${this.bottomY() - 3}px ;left:105px;position:absolute`}>
<button class="button" onClick={this.addPokemon}>
<i class="fa fa-plus" aria-hidden></i> Add Pok&eacute;mon
</button>
</div>,
]}
{this.selection?.active && this.selection.offsetY !== null && (
<div class="teaminnertextbox" style={{ top: this.selection.offsetY - 1 }}></div>
</div>
)}
{this.innerFocus?.offsetY != null && (
<div
class={`teaminnertextbox teaminnertextbox-${this.innerFocus.type}`} style={{ top: this.innerFocus.offsetY - 21 }}
></div>
)}
</div>
{this.selection?.active && (
<p>
<label class="checkbox"><input type="checkbox" name="compat" onChange={this.changeCompat} /> Old export format</label>
</p>
{this.innerFocus && (
<div
class="searchresults" style={{ top: (this.setInfo[this.selection.setIndex]?.bottomY ?? this.bottomY() + 50) - 12 }}
onClick={this.handleClick}
class="searchresults" style={{ top: (this.setInfo[this.innerFocus.setIndex]?.bottomY ?? this.bottomY() + 50) - 12 }}
onClick={this.handleClick} onScroll={this.scrollResults}
>
<button class="button closesearch" onClick={this.closeMenu}>
<i class="fa fa-times" aria-hidden></i> Close
<kbd>Esc</kbd> <i class="fa fa-times" aria-hidden></i> Close
</button>
{this.selection.type === 'stats' ? (
<StatForm room={this.props.room} set={this.sets[this.selection.setIndex]} onChange={this.handleSetChange} />
) : this.selection.type === 'details' ? (
<p>Insert details form here</p>
{this.innerFocus.type === 'stats' ? (
<StatForm room={this.props.room} set={this.sets[this.innerFocus.setIndex]} onChange={this.handleSetChange} />
) : this.innerFocus.type === 'details' ? (
<DetailsForm room={this.props.room} set={this.sets[this.innerFocus.setIndex]} onChange={this.handleSetChange} />
) : (
<PSSearchResults search={this.search} />
<PSSearchResults search={room.search} searchInitial={room.searchInitial} windowing={this.windowResults()} />
)}
</div>
)}
@ -892,6 +1108,7 @@ class StatForm extends preact.Component<{
set.evs = guess.evs;
this.plus = guess.plusStat || null;
this.minus = guess.minusStat || null;
this.updateNatureFromPlusMinus();
this.props.onChange();
};
handleOptimize = () => {
@ -1012,17 +1229,22 @@ class StatForm extends preact.Component<{
const target = ev.currentTarget as HTMLInputElement;
const { set } = this.props;
const statID = target.name.split('-')[1] as Dex.StatName;
const value = Math.abs(Number(target.value));
if (statID === 'hp') {
PS.alert("Natures cannot raise or lower HP.");
return;
}
let value = Math.abs(parseInt(target.value));
if (target.value.includes('+')) {
if (statID === 'hp') {
PS.alert("Natures cannot raise or lower HP.");
return;
}
this.plus = statID;
} else if (this.plus === statID) {
this.plus = null;
}
if (target.value.includes('-')) {
if (statID === 'hp') {
PS.alert("Natures cannot raise or lower HP.");
return;
}
this.minus = statID;
} else if (this.minus === statID) {
this.minus = null;
@ -1033,6 +1255,18 @@ class StatForm extends preact.Component<{
set.evs ||= {};
set.evs[statID] = value;
}
if (target.type === 'range') {
// enforce limit
const maxEv = this.maxEVs();
if (maxEv < 6 * 252) {
let totalEv = 0;
for (const curEv of Object.values(set.evs || {})) totalEv += curEv;
if (totalEv > maxEv && totalEv - value <= maxEv) {
set.evs![statID] = maxEv - (totalEv - value) - (maxEv % 4);
}
}
}
this.updateNatureFromPlusMinus();
this.props.onChange();
};
@ -1095,6 +1329,12 @@ class StatForm extends preact.Component<{
set.ivs = { hp, atk, def, spa, spd, spe };
this.props.onChange();
};
maxEVs() {
const { room } = this.props;
const team = room.team;
const useEVs = !team.format.includes('letsgo');
return useEVs ? 510 : Infinity;
}
override render() {
const { room, set } = this.props;
const team = room.team;
@ -1126,7 +1366,20 @@ class StatForm extends preact.Component<{
statID, statNames[statID], room.getStat(statID, set),
] as const);
return <div style="font-size:10pt">
let remaining = null;
const maxEv = this.maxEVs();
if (maxEv < 6 * 252) {
let totalEv = 0;
for (const ev of Object.values(set.evs || {})) totalEv += ev;
if (totalEv <= maxEv) {
remaining = (totalEv > (maxEv - 2) ? 0 : (maxEv - 2) - totalEv);
} else {
remaining = maxEv - totalEv;
}
remaining ||= null;
}
return <div style="font-size:10pt" role="dialog" aria-label="Stats">
<div class="resultheader"><h3>EVs, IVs, and Nature</h3></div>
<div class="pad">
{this.renderSpreadGuesser()}
@ -1146,7 +1399,7 @@ class StatForm extends preact.Component<{
<td class="setstatbar">{this.renderStatbar(stat, statID)}</td>
<td><input
name={`ev-${statID}`} placeholder={`${defaultEV || ''}`}
type="text" inputMode="numeric" class="textbox default-placeholder" size={5}
type="text" inputMode="numeric" class="textbox default-placeholder" style="width:40px"
onInput={this.changeEV} onChange={this.changeEV}
/></td>
<td><input
@ -1155,14 +1408,14 @@ class StatForm extends preact.Component<{
onInput={this.changeEV} onChange={this.changeEV}
/></td>
<td><input
name={`iv-${statID}`} min={0} max={useIVs ? 31 : 15} placeholder={useIVs ? '31' : '15'}
name={`iv-${statID}`} min={0} max={useIVs ? 31 : 15} placeholder={useIVs ? '31' : '15'} style="width:40px"
type="number" class="textbox default-placeholder" onInput={this.changeIV} onChange={this.changeIV}
/></td>
<td style="text-align:right"><strong>{stat}</strong></td>
</tr>)}
<tr>
<td colSpan={3} style="text-align:right">Remaining:</td>
<td style="text-align:center">0</td>
<td colSpan={3} style="text-align:right">{remaining !== null ? 'Remaining:' : ''}</td>
<td style="text-align:center">{remaining && remaining < 0 ? <b class="message-error">{remaining}</b> : remaining}</td>
<td colSpan={3} style="text-align:right">{this.renderIVMenu()}</td>
</tr>
</table>
@ -1185,4 +1438,152 @@ class StatForm extends preact.Component<{
}
}
class DetailsForm extends preact.Component<{
room: TeamRoom,
set: Dex.PokemonSet,
onChange: () => void,
}> {
update(init?: boolean) {
const { set } = this.props;
const skipID = !init ? this.base!.querySelector<HTMLInputElement>('input:focus')?.name : undefined;
const nickname = this.base!.querySelector<HTMLInputElement>('input[name="nickname"]');
if (nickname && skipID !== 'nickname') nickname.value = set.name || '';
}
override componentDidMount(): void {
this.update(true);
}
override componentDidUpdate(): void {
this.update();
}
changeNickname = (ev: Event) => {
const target = ev.currentTarget as HTMLInputElement;
const { set } = this.props;
if (target.value) {
set.name = target.value.trim();
} else {
delete set.name;
}
this.props.onChange();
};
changeTera = (ev: Event) => {
const target = ev.currentTarget as HTMLInputElement;
const { set } = this.props;
const species = this.props.room.dex.species.get(set.species);
if (!target.value || target.value === (species.forceTeraType || species.types[0])) {
delete set.teraType;
} else {
set.teraType = target.value.trim();
}
this.props.onChange();
};
render() {
const { room, set } = this.props;
const species = room.dex.species.get(set.species);
return <div style="font-size:10pt" role="dialog" aria-label="Details">
<div class="resultheader"><h3>Details</h3></div>
<div class="pad">
<p><label class="label">Nickname: <input
name="nickname" class="textbox default-placeholder" placeholder={species.baseSpecies}
onInput={this.changeNickname} onChange={this.changeNickname}
/></label></p>
<p>[insert the rest of the details pane]</p>
{/*
buf += '<div class="formrow"><label class="formlabel">Level:</label><div><input type="number" min="1" max="100" step="1" name="level" value="' + (typeof set.level === 'number' ? set.level : 100) + '" class="textbox inputform numform" /></div></div>';
if (this.curTeam.gen > 1) {
buf += '<div class="formrow"><label class="formlabel">Gender:</label><div>';
if (species.gender && !isHackmons) {
var genderTable = { 'M': "Male", 'F': "Female", 'N': "Genderless" };
buf += genderTable[species.gender];
} else {
buf += '<label class="checkbox inline"><input type="radio" name="gender" value="M"' + (set.gender === 'M' ? ' checked' : '') + ' /> Male</label> ';
buf += '<label class="checkbox inline"><input type="radio" name="gender" value="F"' + (set.gender === 'F' ? ' checked' : '') + ' /> Female</label> ';
if (!isHackmons) {
buf += '<label class="checkbox inline"><input type="radio" name="gender" value="N"' + (!set.gender ? ' checked' : '') + ' /> Random</label>';
} else {
buf += '<label class="checkbox inline"><input type="radio" name="gender" value="N"' + (set.gender === 'N' ? ' checked' : '') + ' /> Genderless</label>';
}
}
buf += '</div></div>';
if (isLetsGo) {
buf += '<div class="formrow"><label class="formlabel">Happiness:</label><div><input type="number" name="happiness" value="70" class="textbox inputform numform" /></div></div>';
} else {
if (this.curTeam.gen < 8 || isNatDex)
buf += '<div class="formrow"><label class="formlabel">Happiness:</label><div><input type="number" min="0" max="255" step="1" name="happiness" value="' + (typeof set.happiness === 'number' ? set.happiness : 255) + '" class="textbox inputform numform" /></div></div>';
}
buf += '<div class="formrow"><label class="formlabel">Shiny:</label><div>';
buf += '<label class="checkbox inline"><input type="radio" name="shiny" value="yes"' + (set.shiny ? ' checked' : '') + ' /> Yes</label> ';
buf += '<label class="checkbox inline"><input type="radio" name="shiny" value="no"' + (!set.shiny ? ' checked' : '') + ' /> No</label>';
buf += '</div></div>';
if (this.curTeam.gen === 8 && !isBDSP) {
if (!species.cannotDynamax) {
buf += '<div class="formrow"><label class="formlabel">Dmax Level:</label><div><input type="number" min="0" max="10" step="1" name="dynamaxlevel" value="' + (typeof set.dynamaxLevel === 'number' ? set.dynamaxLevel : 10) + '" class="textbox inputform numform" /></div></div>';
}
if (species.canGigantamax || species.forme === 'Gmax') {
buf += '<div class="formrow"><label class="formlabel">Gigantamax:</label><div>';
if (species.forme === 'Gmax') {
buf += 'Yes';
} else {
buf += '<label class="checkbox inline"><input type="radio" name="gigantamax" value="yes"' + (set.gigantamax ? ' checked' : '') + ' /> Yes</label> ';
buf += '<label class="checkbox inline"><input type="radio" name="gigantamax" value="no"' + (!set.gigantamax ? ' checked' : '') + ' /> No</label>';
}
buf += '</div></div>';
}
}
}
if (this.curTeam.gen > 2) {
buf += '<div class="formrow" style="display:none"><label class="formlabel">Pokeball:</label><div><select name="pokeball" class="button">';
buf += '<option value=""' + (!set.pokeball ? ' selected="selected"' : '') + '></option>'; // unset
var balls = this.curTeam.dex.getPokeballs();
for (var i = 0; i < balls.length; i++) {
buf += '<option value="' + balls[i] + '"' + (set.pokeball === balls[i] ? ' selected="selected"' : '') + '>' + balls[i] + '</option>';
}
buf += '</select></div></div>';
}
if (!isLetsGo && (this.curTeam.gen === 7 || isNatDex || (isBDSP && species.baseSpecies === 'Unown'))) {
buf += '<div class="formrow"><label class="formlabel" title="Hidden Power Type">Hidden Power:</label><div><select name="hptype" class="button">';
buf += '<option value=""' + (!set.hpType ? ' selected="selected"' : '') + '>(automatic type)</option>'; // unset
var types = Dex.types.all();
for (var i = 0; i < types.length; i++) {
if (types[i].HPivs) {
buf += '<option value="' + types[i].name + '"' + (set.hpType === types[i].name ? ' selected="selected"' : '') + '>' + types[i].name + '</option>';
}
}
buf += '</select></div></div>';
}
*/}
{room.gen === 9 && <p>
<label class="label" title="Tera Type">
Tera Type: {}
{species.forceTeraType ? (
<select name="teratype" class="button cur" disabled><option>{species.forceTeraType}</option></select>
) : (
<select name="teratype" class="button" onChange={this.changeTera}>
{Dex.types.all().map(type => (
<option value={type.name} selected={(set.teraType || species.types[0]) === type.name}>
{type.name}
</option>
))}
</select>
)}
</label>
</p>}
{species.cosmeticFormes && <p>
<button class="button">
Change sprite
</button>
</p>}
</div>
</div>;
}
}
PS.addRoomType(TeamPanel);

View File

@ -8,10 +8,10 @@
import { PS, PSRoom, type Team } from "./client-main";
import { PSPanelWrapper, PSRoomPanel } from "./panels";
import { TeamBox, TeamFolder } from "./panel-teamdropdown";
import { PSUtils, type ID } from "./battle-dex";
import { Dex, PSUtils, type ID } from "./battle-dex";
class TeambuilderRoom extends PSRoom {
readonly DEFAULT_FORMAT = 'gen8' as ID;
readonly DEFAULT_FORMAT = `gen${Dex.gen}` as ID;
/**
* - `""` - all

View File

@ -95,12 +95,13 @@ export class PSTeambuilder {
if (
set.pokeball || (set.hpType && toID(set.hpType) !== hasHP) || set.gigantamax ||
(set.dynamaxLevel !== undefined && set.dynamaxLevel !== 10)
(set.dynamaxLevel !== undefined && set.dynamaxLevel !== 10) || set.teraType
) {
buf += `,${set.hpType || ''}`;
buf += `,${toID(set.pokeball)}`;
buf += `,${set.gigantamax ? 'G' : ''}`;
buf += `,${set.dynamaxLevel !== undefined && set.dynamaxLevel !== 10 ? set.dynamaxLevel : ''}`;
buf += `,${set.teraType || ''}`;
}
}
@ -187,12 +188,13 @@ export class PSTeambuilder {
// happiness
if (parts[11]) {
let misc = parts[11].split(',', 4);
const misc = parts[11].split(',', 6);
set.happiness = (misc[0] ? Number(misc[0]) : undefined);
set.hpType = misc[1];
set.pokeball = misc[2];
set.gigantamax = !!misc[3];
set.dynamaxLevel = (misc[4] ? Number(misc[4]) : 10);
set.dynamaxLevel = (misc[4] ? Number(misc[4]) : undefined);
set.teraType = misc[5];
}
}
@ -213,16 +215,16 @@ export class PSTeambuilder {
}
if (set.gender === 'M') text += ` (M)`;
if (set.gender === 'F') text += ` (F)`;
if (set.item) {
if (compat && set.item) {
text += ` @ ${set.item}`;
} else if (!compat && dex.gen > 1) {
text += ` @ (No Item)`;
}
text += `\n`;
if (set.ability && set.ability !== 'No Ability') {
if ((set.item || set.ability || dex.gen >= 2) && !compat) {
if (set.ability || dex.gen >= 3) text += `[${set.ability || '(select ability)'}]`;
if (set.item || dex.gen >= 2) text += ` @ ${set.item || "(no item)"}`;
text += `\n`;
} else if (set.ability && set.ability !== 'No Ability') {
text += `Ability: ${set.ability}\n`;
} else if (!compat && dex.gen > 2) {
text += `Ability: (No Ability)\n`;
}
if (!compat) {
@ -246,7 +248,7 @@ export class PSTeambuilder {
if (set.evs || set.nature) {
const nature = BattleNatures[set.nature as 'Serious'];
for (const stat of Dex.statNames) {
const plusMinus = nature?.plus === stat ? '+' : nature?.minus === stat ? '-' : '';
const plusMinus = compat ? '' : nature?.plus === stat ? '+' : nature?.minus === stat ? '-' : '';
const ev = set.evs?.[stat] || '';
if (ev === '' && !plusMinus) continue;
text += first ? `EVs: ` : ` / `;
@ -255,6 +257,7 @@ export class PSTeambuilder {
}
}
if (!first) {
if (set.nature && !compat) text += ` (${set.nature})`;
text += `\n`;
}
if (set.nature && compat) {
@ -293,7 +296,10 @@ export class PSTeambuilder {
text += `Dynamax Level: ${set.dynamaxLevel}\n`;
}
if (set.gigantamax) {
text += `Gigantamax: Yes\n`;
text += compat ? `Gigantamax: Yes\n` : `Gigantamax\n`;
}
if (set.teraType) {
text += `Tera Type: ${set.teraType}\n`;
}
if (set.moves && compat) {
@ -301,7 +307,7 @@ export class PSTeambuilder {
if (move.startsWith('Hidden Power ')) {
const hpType = move.slice(13);
move = move.slice(0, 13);
move = `${move}[${hpType}]`;
move = compat ? `${move}[${hpType}]` : `${move}${hpType}`;
}
if (move) {
text += `- ${move}\n`;
@ -312,11 +318,11 @@ export class PSTeambuilder {
text += `\n`;
return text;
}
static exportTeam(sets: Dex.PokemonSet[], dex?: ModdedDex) {
static exportTeam(sets: Dex.PokemonSet[], dex?: ModdedDex, compat?: boolean) {
let text = '';
for (const set of sets) {
// core
text += PSTeambuilder.exportSet(set, dex);
text += PSTeambuilder.exportSet(set, dex, compat);
}
return text;
}
@ -331,9 +337,11 @@ export class PSTeambuilder {
return [buffer.slice(0, delimIndex), buffer.slice(delimIndex + delimiter.length)];
}
static parseExportedTeamLine(line: string, isFirstLine: boolean, set: Dex.PokemonSet) {
if (isFirstLine) {
if (isFirstLine || line.startsWith('[')) {
let item;
[line, item] = line.split(' @ ');
[line, item] = line.split('@');
line = line.trim();
item = item?.trim();
if (item) {
set.item = item;
if (toID(set.item) === 'noitem') set.item = '';
@ -346,68 +354,75 @@ export class PSTeambuilder {
set.gender = 'F';
line = line.slice(0, -4);
}
let parenIndex = line.lastIndexOf(' (');
if (line.endsWith(')') && parenIndex !== -1) {
set.species = Dex.species.get(line.slice(parenIndex + 2, -1)).name;
set.name = line.slice(0, parenIndex);
} else {
set.species = Dex.species.get(line).name;
set.name = '';
if (line.startsWith('[') && line.endsWith(']')) {
// the ending `]` is necessary to establish this as ability
// (rather than nickname starting with `[`)
set.ability = line.slice(1, -1);
if (toID(set.ability) === 'selectability') {
set.ability = '';
}
} else if (line) {
const parenIndex = line.lastIndexOf(' (');
if (line.endsWith(')') && parenIndex !== -1) {
set.species = Dex.species.get(line.slice(parenIndex + 2, -1)).name;
set.name = line.slice(0, parenIndex);
} else {
set.species = Dex.species.get(line).name;
set.name = '';
}
}
} else if (line.startsWith('Trait: ')) {
line = line.slice(7);
set.ability = line;
set.ability = line.slice(7);
} else if (line.startsWith('Ability: ')) {
line = line.slice(9);
set.ability = line;
set.ability = line.slice(9);
} else if (line.startsWith('Item: ')) {
set.item = line.slice(6);
} else if (line.startsWith('Nickname: ')) {
set.name = line.slice(10);
} else if (line.startsWith('Species: ')) {
set.species = line.slice(9);
} else if (line === 'Shiny: Yes' || line === 'Shiny') {
set.shiny = true;
} else if (line.startsWith('Level: ')) {
line = line.slice(7);
set.level = +line;
set.level = +line.slice(7);
} else if (line.startsWith('Happiness: ')) {
line = line.slice(11);
set.happiness = +line;
set.happiness = +line.slice(11);
} else if (line.startsWith('Pokeball: ')) {
line = line.slice(10);
set.pokeball = line;
set.pokeball = line.slice(10);
} else if (line.startsWith('Hidden Power: ')) {
line = line.slice(14);
set.hpType = line;
set.hpType = line.slice(14);
} else if (line.startsWith('Dynamax Level: ')) {
line = line.substr(15);
set.dynamaxLevel = +line;
} else if (line === 'Gigantamax: Yes') {
set.dynamaxLevel = +line.slice(15);
} else if (line === 'Gigantamax: Yes' || line === 'Gigantamax') {
set.gigantamax = true;
} else if (line.startsWith('Tera Type: ')) {
set.teraType = line.slice(11);
} else if (line.startsWith('EVs: ')) {
line = line.slice(5);
let evLines = line.split('/');
const evLines = line.slice(5).split('(')[0].split('/');
set.evs = { hp: 0, atk: 0, def: 0, spa: 0, spd: 0, spe: 0 };
let plus = '', minus = '';
for (let evLine of evLines) {
evLine = evLine.trim();
let spaceIndex = evLine.indexOf(' ');
const spaceIndex = evLine.indexOf(' ');
if (spaceIndex === -1) continue;
let statid = BattleStatIDs[evLine.slice(spaceIndex + 1)];
const statid = BattleStatIDs[evLine.slice(spaceIndex + 1)];
if (!statid) continue;
if (evLine.charAt(spaceIndex - 1) === '+') plus = statid;
if (evLine.charAt(spaceIndex - 1) === '-') minus = statid;
let statval = parseInt(evLine.slice(0, spaceIndex), 10);
set.evs[statid] = statval;
set.evs[statid] = parseInt(evLine.slice(0, spaceIndex), 10) || 0;
}
const nature = this.getNature(plus as StatNameExceptHP, minus as StatNameExceptHP);
if (nature !== 'Serious') {
set.nature = nature as Dex.NatureName;
}
} else if (line.startsWith('IVs: ')) {
line = line.slice(5);
let ivLines = line.split(' / ');
const ivLines = line.slice(5).split(' / ');
set.ivs = { hp: 31, atk: 31, def: 31, spa: 31, spd: 31, spe: 31 };
for (let ivLine of ivLines) {
ivLine = ivLine.trim();
let spaceIndex = ivLine.indexOf(' ');
const spaceIndex = ivLine.indexOf(' ');
if (spaceIndex === -1) continue;
let statid = BattleStatIDs[ivLine.slice(spaceIndex + 1)];
const statid = BattleStatIDs[ivLine.slice(spaceIndex + 1)];
if (!statid) continue;
let statval = parseInt(ivLine.slice(0, spaceIndex), 10);
if (isNaN(statval)) statval = 31;
@ -417,20 +432,15 @@ export class PSTeambuilder {
let natureIndex = line.indexOf(' Nature');
if (natureIndex === -1) natureIndex = line.indexOf(' nature');
if (natureIndex === -1) return;
line = line.substr(0, natureIndex);
line = line.slice(0, natureIndex);
if (line !== 'undefined') set.nature = line as Dex.NatureName;
} else if (line.startsWith('-') || line.startsWith('~')) {
} else if (line.startsWith('-') || line.startsWith('~') || line.startsWith('Move:')) {
if (line.startsWith('Move:')) line = line.slice(4);
line = line.slice(line.charAt(1) === ' ' ? 2 : 1);
if (line.startsWith('Hidden Power [')) {
const hpType = line.slice(14, -1) as Dex.TypeName;
line = 'Hidden Power ' + hpType;
if (!set.ivs && Dex.types.isName(hpType)) {
set.ivs = { hp: 31, atk: 31, def: 31, spa: 31, spd: 31, spe: 31 };
const hpIVs = Dex.types.get(hpType).HPivs || {};
for (let stat in hpIVs) {
set.ivs[stat as Dex.StatName] = hpIVs[stat as Dex.StatName]!;
}
}
set.hpType = hpType;
}
if (line === 'Frustration' && set.happiness === undefined) {
set.happiness = 0;
@ -839,6 +849,18 @@ class FormatDropdownPanel extends PSRoomPanel {
this.search = (ev.currentTarget as HTMLInputElement).value;
this.forceUpdate();
};
toggleGen = (ev: Event) => {
const target = ev.currentTarget as HTMLButtonElement;
this.gen = this.gen === target.value ? '' : target.value;
this.forceUpdate();
};
override componentWillUnmount(): void {
const { room } = this.props;
super.componentWillUnmount();
if (this.gen && room.parentElem?.getAttribute('data-selecttype') === 'teambuilder') {
this.chooseParentValue(this.gen);
}
}
override render() {
const room = this.props.room;
if (!room.parentElem) {
@ -856,11 +878,21 @@ class FormatDropdownPanel extends PSRoomPanel {
break;
}
}
const curGen = (gen: string) => this.gen === gen ? ' cur' : '';
const searchBar = <div style="margin-bottom: 0.5em">
<input
type="search" name="search" placeholder="Search formats" class="textbox autofocus"
onInput={this.updateSearch} onChange={this.updateSearch}
/>
/> {}
<button onClick={this.toggleGen} value="gen9" class={`button button-first${curGen('gen9')}`}>Gen 9</button>
<button onClick={this.toggleGen} value="gen8" class={`button button-middle${curGen('gen8')}`}>8</button>
<button onClick={this.toggleGen} value="gen7" class={`button button-middle${curGen('gen7')}`}>7</button>
<button onClick={this.toggleGen} value="gen6" class={`button button-middle${curGen('gen6')}`}>6</button>
<button onClick={this.toggleGen} value="gen5" class={`button button-middle${curGen('gen5')}`}>5</button>
<button onClick={this.toggleGen} value="gen4" class={`button button-middle${curGen('gen4')}`}>4</button>
<button onClick={this.toggleGen} value="gen3" class={`button button-middle${curGen('gen3')}`}>3</button>
<button onClick={this.toggleGen} value="gen2" class={`button button-middle${curGen('gen2')}`}>2</button>
<button onClick={this.toggleGen} value="gen1" class={`button button-last${curGen('gen1')}`}>1</button>
</div>;
if (!formatsLoaded) {
return <PSPanelWrapper room={room}><div class="pad">
@ -895,6 +927,8 @@ class FormatDropdownPanel extends PSRoomPanel {
if (searchID && !toID(format.name).includes(searchID)) {
continue;
}
if (this.gen && !format.id.startsWith(this.gen)) continue;
if (format.column !== curColumnNum) {
if (curColumn.length) {
curColumn = [];
@ -911,7 +945,7 @@ class FormatDropdownPanel extends PSRoomPanel {
curColumn.push(format);
}
const width = columns.length * 225 + 30;
const width = Math.max(columns.length, 2.1) * 225 + 30;
const noResults = curColumn.length === 0;
return <PSPanelWrapper room={room} width={width}><div class="pad">

View File

@ -285,8 +285,12 @@ export function PSPanelWrapper(props: {
}
export class PSView extends preact.Component {
static readonly isIOS = [
'iPad Simulator', 'iPhone Simulator', 'iPod Simulator', 'iPad', 'iPhone', 'iPod',
].includes(navigator.platform);
static readonly isChrome = navigator.userAgent.includes(' Chrome/');
static readonly isSafari = !this.isChrome && navigator.userAgent.includes(' Safari/');
static readonly isFirefox = navigator.userAgent.includes(' Firefox/');
static readonly isMac = navigator.platform?.startsWith('Mac');
static textboxFocused = false;
static setTextboxFocused(focused: boolean) {
@ -341,7 +345,7 @@ export class PSView extends preact.Component {
super();
PS.subscribe(() => this.forceUpdate());
if (PSView.isSafari) {
if (PSView.isIOS) {
// I don't want to prevent users from being able to zoom, but iOS Safari
// auto-zooms when focusing textboxes (unless the font size is 16px),
// and this apparently fixes it while still allowing zooming.
@ -561,7 +565,7 @@ export class PSView extends preact.Component {
}
static scrollToRoom() {
if (document.documentElement.scrollWidth > document.documentElement.clientWidth && window.scrollX === 0) {
if (PSView.isSafari && PS.leftPanelWidth === null) {
if ((PSView.isIOS || PSView.isFirefox) && PS.leftPanelWidth === null) {
// Safari bug: `scrollBy` doesn't actually work when scroll snap is enabled
// note: interferes with the `PSMain.textboxFocused` workaround for a Chrome bug
document.documentElement.classList.remove('scroll-snap-enabled');

View File

@ -388,9 +388,11 @@ select.button {
}
.label strong {
font-size: 11pt;
display: block;
}
.label .textbox {
.label .textbox,
.label .button,
.label strong {
margin-top: 2px;
display: block;
}
.textbox {

View File

@ -54,6 +54,7 @@
.dexlist .result {
height: 32px;
padding: 1px 0 0 0;
contain: strict;
}
.result p,
.resultheader p,
@ -352,3 +353,64 @@
.dexlist .ppsortcol {
width: 23px;
}
.dexlist .result a {
border-radius: 4px;
cursor: pointer;
}
.dexlist .result a.cur {
border-color: #555555;
background: rgba(248, 248, 248, 0.4);
}
.dexlist .result a:hover,
.dexlist .result a.hover {
border-color: #D8D8D8;
background: #F8F8F8;
}
.dexlist .result a.cur:hover,
.dexlist .result a.cur.hover {
border-color: #555555;
background: #F8F8F8;
}
/* dark mode */
.dark .dexlist h3, .dark .dexentry h3, .dark .resultheader h3 {
background: #636363;
color: #F1F1F1;
border: 1px solid #A9A9A9;
text-shadow: 1px 1px 0 rgb(40, 43, 45);
box-shadow: inset 0px 1px 0 rgb(49, 49, 49);
border-right: none;
}
.dark .dexlist .result a.hover,
.dark .dexlist .result a:hover {
border-color: #777777;
background: rgba(100, 100, 100, 0.5);
color: #FFFFFF;
}
.dark .dexlist .result a.cur {
border-color: #BBBBBB;
background: rgba(100, 100, 100, 0.2);
}
.dark .dexlist .result a.cur:hover {
border-color: #BBBBBB;
background: rgba(100, 100, 100, 0.4);
color: #FFFFFF;
}
.dark .dexlist .namecol,
.dark .dexlist .pokemonnamecol,
.dark .dexlist .movenamecol {
color: #DDD;
}
.dark .dexlist .col {
color: #DDD;
}
.dark .dexlist .cur .col {
color: #FFF;
}
.dark .dexlist a:hover .col {
color: #FFF;
}

View File

@ -276,6 +276,15 @@
box-shadow: inset 0px 1px 2px #D2D2D2, 0px 0px 5px #66AADD;
background: transparent;
}
.teaminnertextbox-stats, .teaminnertextbox-pokemon {
width: 420px;
}
.teaminnertextbox-item, .teaminnertextbox-ability {
width: 270px;
}
.teaminnertextbox-move {
width: 220px;
}
.searchresults {
background: #f2f2f2;
@ -285,9 +294,11 @@
position: absolute;
top: 300px;
right: -13px;
width: 624px;
left: -14px;
width: 640px;
min-height: 150px;
overflow: auto;
max-height: 80vh;
}
.dark .searchresults {
background: #282828;
@ -297,6 +308,7 @@
position: absolute;
top: 1px;
right: 1px;
z-index: 1;
}
.teameditor {