Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added public/images/aoe4-maps/ascension.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/aoe4-maps/fangs.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/aoe4-maps/snake-river.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/aoe4-maps/west-lake.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
136 changes: 132 additions & 4 deletions src/components/PresetEditor/PresetEditorCivSelection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,49 @@ interface Props extends WithTranslation {
onPresetDraftOptionsChange: (value: DraftOption[]) => ISetEditorDraftOptions,
}

class PresetEditorCivSelection extends React.Component<Props, object> {
type SortOrder = 'default' | 'az' | 'za';

interface State {
searchQuery: string;
sortOrder: SortOrder;
deselectedTags: string[];
tagDropdownOpen: boolean;
}

class PresetEditorCivSelection extends React.Component<Props, State> {

private static readonly SORT_STORAGE_KEY = 'presetEditor.sortOrder';
private dropdownRef = React.createRef<HTMLDivElement>();

constructor(props: Props) {
super(props);
const stored = localStorage.getItem(PresetEditorCivSelection.SORT_STORAGE_KEY) as SortOrder | null;
this.state = {searchQuery: '', sortOrder: stored ?? 'default', deselectedTags: [], tagDropdownOpen: false};
this.handleOutsideClick = this.handleOutsideClick.bind(this);
}

componentDidMount() {
document.addEventListener('mousedown', this.handleOutsideClick);
}

componentWillUnmount() {
document.removeEventListener('mousedown', this.handleOutsideClick);
}

componentDidUpdate(prevProps: Props, prevState: State) {
if (prevProps.availableOptions !== this.props.availableOptions) {
this.setState({searchQuery: '', deselectedTags: [], tagDropdownOpen: false});
}
if (prevState.sortOrder !== this.state.sortOrder) {
localStorage.setItem(PresetEditorCivSelection.SORT_STORAGE_KEY, this.state.sortOrder);
}
}

private handleOutsideClick(e: MouseEvent) {
if (this.state.tagDropdownOpen && this.dropdownRef.current && !this.dropdownRef.current.contains(e.target as Node)) {
this.setState({tagDropdownOpen: false});
}
}

public render() {
if (this.props.preset === null || this.props.preset === undefined) {
Expand All @@ -24,15 +66,101 @@ class PresetEditorCivSelection extends React.Component<Props, object> {

const presetOptions = this.props.preset.options;

const civs = this.props.availableOptions.map((value: DraftOption, index: number) =>
const allTags = [...new Set(this.props.availableOptions.reduce<string[]>((acc, opt) => acc.concat(opt.tags), []))].sort();

const query = this.state.searchQuery.toLowerCase();
let filtered = [...this.props.availableOptions];

if (query) {
filtered = filtered.filter(opt => opt.name.toLowerCase().includes(query));
}
if (this.state.deselectedTags.length > 0) {
filtered = filtered.filter(opt =>
opt.tags.length === 0 || opt.tags.some(tag => !this.state.deselectedTags.includes(tag))
);
}

if (this.state.sortOrder === 'az') {
filtered.sort((a, b) => a.name.localeCompare(b.name));
} else if (this.state.sortOrder === 'za') {
filtered.sort((a, b) => b.name.localeCompare(a.name));
}

const civs = filtered.map((value: DraftOption, index: number) =>
<PresetOptionCheckbox presetOptions={presetOptions} value={value}
key={index}
disabled={false}
onPresetDraftOptionsChange={this.props.onPresetDraftOptionsChange}/>);

return (
<div className="is-flex" style={{flexDirection: 'row', flexWrap: 'wrap'}}>
{civs}
<div>
<div className="is-flex mb-2" style={{gap: '0.5rem', alignItems: 'center'}}>
<div className="control has-icons-right" style={{maxWidth: '20rem'}}>
<input
className="input is-small"
type="text"
placeholder={this.props.t('presetEditor.searchOptions', 'Search options…')}
value={this.state.searchQuery}
onChange={e => this.setState({searchQuery: e.target.value})}
/>
{this.state.searchQuery && (
<span className="icon is-right"
style={{pointerEvents: 'all', cursor: 'pointer', color: 'white'}}
onClick={() => this.setState({searchQuery: ''})}>
<i>×</i>
</span>
)}
</div>
<div className="select is-small">
<select value={this.state.sortOrder}
onChange={e => this.setState({sortOrder: e.target.value as SortOrder})}>
<option value="default">{this.props.t('presetEditor.sortDefault', 'Default')}</option>
<option value="az">A → Z</option>
<option value="za">Z → A</option>
</select>
</div>
{allTags.length > 0 && (
<div ref={this.dropdownRef}
className={`dropdown${this.state.tagDropdownOpen ? ' is-active' : ''}`}>
<div className="dropdown-trigger">
<button className="button is-small"
onClick={() => this.setState({tagDropdownOpen: !this.state.tagDropdownOpen})}>
<span>{this.props.t('presetEditor.filterByTag', 'Filter by tag')}</span>
{this.state.deselectedTags.length > 0 && (
<span className="tag is-warning is-small ml-1">
{allTags.length - this.state.deselectedTags.length}/{allTags.length}
</span>
)}
</button>
</div>
<div className="dropdown-menu">
<div className="dropdown-content">
{allTags.map(tag => {
const checked = !this.state.deselectedTags.includes(tag);
return (
<label key={tag} className="dropdown-item" style={{cursor: 'pointer'}}>
<input
type="checkbox"
checked={checked}
onChange={() => {
const next = checked
? [...this.state.deselectedTags, tag]
: this.state.deselectedTags.filter(t => t !== tag);
this.setState({deselectedTags: next});
}}
/>
{' '}{tag}
</label>
);
})}
</div>
</div>
</div>
)}
</div>
<div className="is-flex" style={{flexDirection: 'row', flexWrap: 'wrap'}}>
{civs}
</div>
</div>
);
}
Expand Down
35 changes: 32 additions & 3 deletions src/models/Aoe4Civilisation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,40 @@ enum Name {
MACEDONIAN_DYNASTY = "aoe4.MacedonianDynasty",
SENGOKU_DAIMYO = "aoe4.SengokuDaimyo",
TUGHLAQ_DYNASTY = "aoe4.TughlaqDynasty",
JIN_DYNASTY = "aoe4.JinDynasty",
JIN_DYNASTY = "aoe4.JinDynasty",
}

class Aoe4Civilisation extends DraftOption {

// Add tags here as you go — any civ not listed defaults to no tags.
// MUST stay before the static civ instances (initialization order).
public static readonly TAGS: Partial<Record<Name, string[]>> = {
// [Name.ENGLISH]: ['Beginner Friendly', 'Defensive'],
[Name.RUS]: ['Release'],
[Name.HOLY_ROMAN_EMPIRE]: ['Release'],
[Name.CHINESE]: ['Release'],
[Name.ENGLISH]: ['Release'],
[Name.DELHI_SULTANATE]: ['Release'],
[Name.MONGOLS]: ['Release'],
[Name.ABBASID_DYNASTY]: ['Release'],
[Name.FRENCH]: ['Release'],
[Name.OTTOMANS]: ['Anniversary Edition'],
[Name.MALIANS]: ['Anniversary Edition'],
[Name.BYZANTINES]: ['The Sultans Ascend'],
[Name.JAPANESE]: ['The Sultans Ascend'],
[Name.AYYUBIDS]: ['The Sultans Ascend'],
[Name.ZHUXILEGACY]: ['The Sultans Ascend'],
[Name.JEANNEDARC]: ['The Sultans Ascend'],
[Name.ORDEROFTHEDRAGON]: ['The Sultans Ascend'],
[Name.HOUSE_OF_LANCASTER]: ['Knights of Cross and Rose'],
[Name.KNIGHTS_TEMPLAR]: ['Knights of Cross and Rose'],
[Name.GOLDEN_HORDE]: ['Dynasties of the East'],
[Name.MACEDONIAN_DYNASTY]: ['Dynasties of the East'],
[Name.SENGOKU_DAIMYO]: ['Dynasties of the East'],
[Name.TUGHLAQ_DYNASTY]: ['Dynasties of the East'],
[Name.JIN_DYNASTY]: ['Yue Fei\'s Legacy'],
};

public static readonly RUS: Aoe4Civilisation = new Aoe4Civilisation(Name.RUS);
public static readonly HOLY_ROMAN_EMPIRE: Aoe4Civilisation = new Aoe4Civilisation(Name.HOLY_ROMAN_EMPIRE);
public static readonly CHINESE: Aoe4Civilisation = new Aoe4Civilisation(Name.CHINESE);
Expand Down Expand Up @@ -84,7 +113,7 @@ class Aoe4Civilisation extends DraftOption {
public static readonly ALL_ACTIVE = Aoe4Civilisation.ALL

private constructor(name: Name) {
super(name, name, Aoe4Civilisation.defaultImageUrlsForCivilisation(name));
super(name, name, Aoe4Civilisation.defaultImageUrlsForCivilisation(name), 'civs.', 'default', Aoe4Civilisation?.TAGS[name] ?? []);
}

public static defaultImageUrlsForCivilisation(name: string) {
Expand All @@ -99,4 +128,4 @@ class Aoe4Civilisation extends DraftOption {
}

export default Aoe4Civilisation;
export {Name};
export { Name };
4 changes: 4 additions & 0 deletions src/models/Aoe4Map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class Aoe4Map extends DraftOption {
new Aoe4Map("Altai"),
new Aoe4Map("Ancient Spires"),
new Aoe4Map("Archipelago"),
new Aoe4Map("Ascension"),
new Aoe4Map("Atacama"),
new Aoe4Map("Atoll"),
new Aoe4Map("Baldland"),
Expand Down Expand Up @@ -42,6 +43,7 @@ class Aoe4Map extends DraftOption {
new Aoe4Map("Enlightened Horizon"),
new Aoe4Map("Eroded"),
new Aoe4Map("Excavation"),
new Aoe4Map("Fangs"),
new Aoe4Map("Firefly Fjords"),
new Aoe4Map("Flankwoods"),
new Aoe4Map("Floodplain"),
Expand Down Expand Up @@ -116,6 +118,7 @@ class Aoe4Map extends DraftOption {
new Aoe4Map("Silk Road"),
new Aoe4Map("Sixfold Crossing"),
new Aoe4Map("Skargard"),
new Aoe4Map("Snake River"),
new Aoe4Map("Socotra"),
new Aoe4Map("Sol and Luna"),
new Aoe4Map("Sunkenlands"),
Expand All @@ -131,6 +134,7 @@ class Aoe4Map extends DraftOption {
new Aoe4Map("Water Drake"),
new Aoe4Map("Waterholes"),
new Aoe4Map("Waterlanes"),
new Aoe4Map("West Lake"),
new Aoe4Map("Wetlands"),
new Aoe4Map("Wilderness"),
new Aoe4Map("Wolf Hill"),
Expand Down
6 changes: 4 additions & 2 deletions src/models/DraftOption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,16 @@ class DraftOption {
public readonly imageUrls: ImageUrls;
public readonly i18nPrefix: string;
public readonly category: string;
public readonly tags: string[];


constructor(id: string, name: string = id, imageUrls: ImageUrls = DraftOption.defaultImageUrlsForCivilisation(id), i18nPrefix = 'civs.', category = 'default') {
constructor(id: string, name: string = id, imageUrls: ImageUrls = DraftOption.defaultImageUrlsForCivilisation(id), i18nPrefix = 'civs.', category = 'default', tags: string[] = []) {
this.id = id;
this.name = name;
this.imageUrls = imageUrls;
this.i18nPrefix = i18nPrefix;
this.category = category;
this.tags = tags;
}

public static defaultImageUrlsForCivilisation(name: string): ImageUrls {
Expand Down Expand Up @@ -74,7 +76,7 @@ class DraftOption {
Assert.isImageUrlsOrUndefined(draftOption.imageUrls);
Assert.isOptionalString(draftOption.i18nPrefix);
Assert.isOptionalString(draftOption.category);
retval.push(new DraftOption(draftOption.id, draftOption.name, draftOption.imageUrls, draftOption.i18nPrefix, draftOption.category));
retval.push(new DraftOption(draftOption.id, draftOption.name, draftOption.imageUrls, draftOption.i18nPrefix, draftOption.category, draftOption.tags ?? []));
}
return retval;
}
Expand Down