diff --git a/src/handbook/data/README.md b/src/handbook/data/README.md index 9876ad7f5..cb7adf9eb 100644 --- a/src/handbook/data/README.md +++ b/src/handbook/data/README.md @@ -3,6 +3,7 @@ Use Grasscutter's dumpers to generate the data to put here. ## Files Required - `commands.json` +- `entities.csv` - `avatars.csv` - `scenes.csv` - `items.csv` diff --git a/src/handbook/src/backend/data.ts b/src/handbook/src/backend/data.ts index 456bf6220..72238d0b9 100644 --- a/src/handbook/src/backend/data.ts +++ b/src/handbook/src/backend/data.ts @@ -1,10 +1,11 @@ import commands from "@data/commands.json"; +import entities from "@data/entities.csv"; import avatars from "@data/avatars.csv"; import scenes from "@data/scenes.csv"; import items from "@data/items.csv"; import { Quality, ItemType, ItemCategory, SceneType } from "@backend/types"; -import type { Command, Avatar, Item, Scene } from "@backend/types"; +import type { Command, Avatar, Item, Scene, Entity } from "@backend/types"; import { inRange } from "@app/utils"; @@ -72,6 +73,21 @@ export function listCommands(): Command[] { return Object.values(getCommands()); } +/** + * Fetches and casts all entities in the file. + */ +export function getEntities(): Entity[] { + return entities.map((entry) => { + const values = Object.values(entry) as string[]; + const id = parseInt(values[0]); + return { + id, + name: values[1], + internal: values[2] + }; + }); +} + /** * Fetches and casts all avatars in the file. */ diff --git a/src/handbook/src/backend/types.ts b/src/handbook/src/backend/types.ts index 3470f4b98..559a9263f 100644 --- a/src/handbook/src/backend/types.ts +++ b/src/handbook/src/backend/types.ts @@ -1,5 +1,5 @@ export type Page = "Home" | "Commands" | "Avatars" | "Items" - | "Scenes"; + | "Entities" | "Scenes"; export type Days = "Sunday" | "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday"; @@ -31,6 +31,12 @@ export type Item = { icon: string; }; +export type Entity = { + id: number; + name: string; + internal: string; +}; + // Exported from Project Amber. export type ItemInfo = { response: number | 200 | 404; @@ -51,6 +57,23 @@ export type ItemInfo = { }; }; +// Exported from Project Amber. +export type EntityInfo = { + response: number | 200 | 404; + data: { + id: number; + name: string; + type: string; + icon: string; + route: string; + title: string; + specialName: string; + description: string; + entries: any[]; + tips: null; + }; +}; + export enum Target { None = "NONE", Offline = "OFFLINE", diff --git a/src/handbook/src/css/pages/EntitiesPage.scss b/src/handbook/src/css/pages/EntitiesPage.scss new file mode 100644 index 000000000..3da203659 --- /dev/null +++ b/src/handbook/src/css/pages/EntitiesPage.scss @@ -0,0 +1,93 @@ +.EntitiesPage { + display: flex; + height: 100%; + width: 100%; + + flex-direction: row; + justify-content: space-between; + background-color: var(--background-color); + + padding: 24px; +} + +.EntitiesPage_Content { + display: flex; + flex-direction: column; + + width: 80%; +} + +.EntitiesPage_Header { + display: flex; + flex-direction: row; + + gap: 30px; + align-content: center; + + margin-bottom: 30px; +} + +.EntitiesPage_Title { + max-width: 230px; + max-height: 60px; + + font-size: 48px; + font-weight: bold; + text-align: center; + justify-content: center; +} + +.EntitiesPage_Search { + display: flex; + + width: 100%; + height: 100%; + max-width: 465px; + max-height: 60px; + + box-sizing: border-box; + align-items: center; + border-radius: 10px; + + background-color: var(--secondary-color); +} + +.EntitiesPage_Input { + background-color: transparent; + border: none; + + color: var(--text-primary-color); + font-size: 20px; + width: 100%; + padding: 11px; + + &:focus, &:active { + outline: none; + } +} + +.EntitiesPage_Input::placeholder { + color: var(--text-secondary-color); + opacity: 1; +} + +.EntitiesPage_List { + display: grid; + gap: 15px 15px; + + grid-template-columns: repeat(15, 100px); + + margin-bottom: 28px; + overflow-y: scroll; +} + +.EntitiesPage_Card { + display: flex; + + width: 100%; + max-width: 300px; + min-height: 300px; + max-height: 700px; + + align-self: center; +} diff --git a/src/handbook/src/css/widgets/Item.scss b/src/handbook/src/css/widgets/MiniCard.scss similarity index 82% rename from src/handbook/src/css/widgets/Item.scss rename to src/handbook/src/css/widgets/MiniCard.scss index ba8521e95..80c6c6fe6 100644 --- a/src/handbook/src/css/widgets/Item.scss +++ b/src/handbook/src/css/widgets/MiniCard.scss @@ -1,4 +1,4 @@ -.Item { +.MiniCard { display: flex; width: 64px; @@ -8,7 +8,7 @@ justify-content: center; } -.Item_Background { +.MiniCard_Background { display: flex; align-items: center; @@ -19,13 +19,14 @@ background-color: var(--secondary-color); } -.Item_Icon { +.MiniCard_Icon { max-width: 64px; max-height: 64px; object-fit: scale-down; + border-radius: 10px; } -.Item_Label { +.MiniCard_Label { width: 64px; max-height: 64px; text-align: center; @@ -33,7 +34,7 @@ color: var(--text-primary-color); } -.Item_Info { +.MiniCard_Info { position: absolute; display: flex; } diff --git a/src/handbook/src/ui/pages/EntitiesPage.tsx b/src/handbook/src/ui/pages/EntitiesPage.tsx new file mode 100644 index 000000000..ac15f7515 --- /dev/null +++ b/src/handbook/src/ui/pages/EntitiesPage.tsx @@ -0,0 +1,151 @@ +import React, { ChangeEvent } from "react"; + +import MiniCard from "@widgets/MiniCard"; +import VirtualizedGrid from "@components/VirtualizedGrid"; + +import { Entity, ItemCategory } from "@backend/types"; +import type { Entity as EntityType, EntityInfo } from "@backend/types"; +import { getEntities } from "@backend/data"; +import { entityIcon, fetchEntityData } from "@app/utils"; + +import "@css/pages/EntitiesPage.scss"; +import EntityCard from "@widgets/EntityCard"; + +interface IState { + filters: ItemCategory[]; + search: string; + + selected: EntityType | null; + selectedInfo: EntityInfo | null; +} + +class EntitiesPage extends React.Component<{}, IState> { + constructor(props: {}) { + super(props); + + this.state = { + filters: [], + search: "", + + selected: null, + selectedInfo: null + }; + } + + /** + * Should the entity be shown? + * + * @param entity The entity. + * @private + */ + private showEntity(entity: Entity): boolean { + // Check if the entity's name starts with N/A. + if (entity.name.includes("[N/A]")) return false; + + return entity.id > 0; + } + + /** + * Gets the items to render. + * @private + */ + private getEntities(): EntityType[] { + let entities: EntityType[] = []; + + // Add items based on filters. + const filters = this.state.filters; + if (filters.length == 0) { + entities = getEntities(); + } else { + for (const filter of filters) { + // Remove duplicate items. + entities = entities.filter((item, index) => { + return entities.indexOf(item) == index; + }); + } + } + + // Filter out items that don't match the search. + const search = this.state.search.toLowerCase(); + if (search != "") { + entities = entities.filter((item) => { + return item.name.toLowerCase().includes(search); + }); + } + + return entities; + } + + /** + * Invoked when the search input changes. + * + * @param event The event. + * @private + */ + private onChange(event: ChangeEvent): void { + this.setState({ search: event.target.value }); + } + + /** + * Sets the selected entity. + * + * @param entity The entity. + * @private + */ + private async setSelectedItem(entity: EntityType): Promise { + let data: EntityInfo | null = null; try { + data = await fetchEntityData(entity); + } catch { } + + this.setState({ + selected: entity, + selectedInfo: data + }); + } + + render() { + const entities = this.getEntities(); + + return ( +
+
+
+

Monsters

+ +
+ +
+
+ + {entities.length > 0 ? ( + this.showEntity(entity))} + itemHeight={64} + itemsPerRow={18} + gap={5} + itemGap={5} + render={(entity) => this.setSelectedItem(entity)} + />} + /> + ) : undefined} +
+ +
+ +
+
+ ); + } +} + +export default EntitiesPage; diff --git a/src/handbook/src/ui/pages/HomePage.tsx b/src/handbook/src/ui/pages/HomePage.tsx index c90ab526a..d25bfe4d6 100644 --- a/src/handbook/src/ui/pages/HomePage.tsx +++ b/src/handbook/src/ui/pages/HomePage.tsx @@ -21,7 +21,7 @@ class HomePage extends React.Component { - + diff --git a/src/handbook/src/ui/pages/ItemsPage.tsx b/src/handbook/src/ui/pages/ItemsPage.tsx index f8cdd3e47..16b28aadb 100644 --- a/src/handbook/src/ui/pages/ItemsPage.tsx +++ b/src/handbook/src/ui/pages/ItemsPage.tsx @@ -1,13 +1,13 @@ import React, { ChangeEvent } from "react"; -import Item from "@widgets/Item"; +import MiniCard from "@widgets/MiniCard"; import ItemCard from "@widgets/ItemCard"; import VirtualizedGrid from "@components/VirtualizedGrid"; import { ItemCategory } from "@backend/types"; import type { Item as ItemType, ItemInfo } from "@backend/types"; import { getItems, sortedItems } from "@backend/data"; -import { fetchItemData } from "@app/utils"; +import { fetchItemData, itemIcon } from "@app/utils"; import "@css/pages/ItemsPage.scss"; @@ -133,8 +133,8 @@ class ItemsPage extends React.Component<{}, IState> { itemsPerRow={18} gap={5} itemGap={5} - render={(item) => this.setSelectedItem(item)} />} /> diff --git a/src/handbook/src/ui/views/Content.tsx b/src/handbook/src/ui/views/Content.tsx index a30789d5a..eaa4ececa 100644 --- a/src/handbook/src/ui/views/Content.tsx +++ b/src/handbook/src/ui/views/Content.tsx @@ -4,6 +4,7 @@ import HomePage from "@pages/HomePage"; import CommandsPage from "@pages/CommandsPage"; import AvatarsPage from "@pages/AvatarsPage"; import ItemsPage from "@pages/ItemsPage"; +import EntitiesPage from "@pages/EntitiesPage"; import ScenesPage from "@pages/ScenesPage"; import type { Page } from "@backend/types"; @@ -58,6 +59,8 @@ class Content extends React.Component { return ; case "Items": return ; + case "Entities": + return ; case "Scenes": return ; } diff --git a/src/handbook/src/ui/views/SideBar.tsx b/src/handbook/src/ui/views/SideBar.tsx index 712a2a2dd..f1a0b2967 100644 --- a/src/handbook/src/ui/views/SideBar.tsx +++ b/src/handbook/src/ui/views/SideBar.tsx @@ -50,7 +50,7 @@ class SideBar extends React.Component<{}, IState> { - + diff --git a/src/handbook/src/ui/widgets/EntityCard.tsx b/src/handbook/src/ui/widgets/EntityCard.tsx new file mode 100644 index 000000000..34fbc4d9d --- /dev/null +++ b/src/handbook/src/ui/widgets/EntityCard.tsx @@ -0,0 +1,156 @@ +import React from "react"; + +import type { Entity as EntityType, EntityInfo } from "@backend/types"; +import { entityIcon } from "@app/utils"; + +import "@css/widgets/ItemCard.scss"; + +/** + * Converts a description string into a list of paragraphs. + * + * @param description The description to convert. + */ +function toDescription(description: string | undefined): JSX.Element[] { + if (!description) return []; + + return description.split("\\n") + .map((line, index) => { + return

{line}

; + }); +} + +interface IProps { + entity: EntityType | null; + info: EntityInfo | null; +} + +interface IState { + icon: boolean; + count: number | string; +} + +const defaultState = { + icon: true, + count: 1 +}; + +class EntityCard extends React.Component { + constructor(props: IProps) { + super(props); + + this.state = defaultState; + } + + /** + * Updates the count of the item. + * + * @param event The change event. + * @private + */ + private updateCount(event: React.ChangeEvent) { + const value = event.target.value; + if (isNaN(parseInt(value)) && value.length > 1) return; + + this.setState({ count: value }); + } + + /** + * Adds to the count of the entity. + * + * @param positive Is the count being added or subtracted? + * @param multiple Is the count being multiplied by 10? + * @private + */ + private addCount(positive: boolean, multiple: boolean) { + let { count } = this.state; + if (count === "") count = 1; + if (typeof count == "string") + count = parseInt(count); + if (count < 1) count = 1; + + let increment = 1; + if (!positive) increment = -1; + if (multiple) increment *= 10; + + count = Math.max(1, count + increment); + + this.setState({ count }); + } + + /** + * Summons the entity at the connected player's position. + * @private + */ + private async summonAtPlayer(): Promise { + // TODO: Implement server access. + } + + componentDidUpdate(prevProps: Readonly, prevState: Readonly, snapshot?: any) { + if (this.props.entity != prevProps.entity) { + this.setState(defaultState); + } + } + + render() { + const { entity, info } = this.props; + const data = info?.data; + + return entity ? ( +
+
+
+
+

{data?.name ?? entity.name}

+

{data?.type ?? ""}

+
+ + { this.state.icon && {entity.name} this.setState({ icon: false })} + /> } +
+ +
+ {toDescription(data?.description)} +
+
+ +
+
+
this.addCount(false, false)} + onContextMenu={(e) => { + e.preventDefault(); + this.addCount(false, true); + }} + className={"ItemCard_Operation"}>-
+ { + if (this.state.count == "") { + this.setState({ count: 1 }); + } + }} + /> +
this.addCount(true, false)} + onContextMenu={(e) => { + e.preventDefault(); + this.addCount(true, true); + }} + className={"ItemCard_Operation"}>+
+
+ + +
+
+ ) : undefined; + } +} + +export default EntityCard; diff --git a/src/handbook/src/ui/widgets/Item.tsx b/src/handbook/src/ui/widgets/MiniCard.tsx similarity index 74% rename from src/handbook/src/ui/widgets/Item.tsx rename to src/handbook/src/ui/widgets/MiniCard.tsx index 81ebbea05..f364a66f5 100644 --- a/src/handbook/src/ui/widgets/Item.tsx +++ b/src/handbook/src/ui/widgets/MiniCard.tsx @@ -1,12 +1,13 @@ import React from "react"; -import type { Item as ItemData } from "@backend/types"; import { itemIcon } from "@app/utils"; -import "@css/widgets/Item.scss"; +import "@css/widgets/MiniCard.scss"; interface IProps { - data: ItemData; + data: { name: string; }; + icon: string; + onClick?: () => void; } @@ -16,7 +17,7 @@ interface IState { loaded: boolean; } -class Item extends React.Component { +class MiniCard extends React.Component { loading: number | any; constructor(props: IProps) { @@ -52,27 +53,25 @@ class Item extends React.Component { render() { return ( -
-
+
{this.state.icon && ( {this.props.data.name} this.setState({ loaded: true })} /> )} - {(!this.state.loaded || !this.state.icon) &&

{this.props.data.name}

} + {(!this.state.loaded || !this.state.icon) &&

{this.props.data.name}

}
- -
); } } -export default Item; +export default MiniCard; diff --git a/src/handbook/src/utils.ts b/src/handbook/src/utils.ts index 74c46d66c..5ec928217 100644 --- a/src/handbook/src/utils.ts +++ b/src/handbook/src/utils.ts @@ -1,5 +1,5 @@ -import type { Item } from "@backend/types"; -import { ItemInfo, ItemType, Quality } from "@backend/types"; +import type { Entity, Item, EntityInfo, ItemInfo } from "@backend/types"; +import { ItemType, Quality } from "@backend/types"; /** * Fetches the name of the CSS variable for the quality. @@ -56,6 +56,16 @@ export function itemIcon(item: Item): string { } } +/** + * Gets the path to the icon for an entity. + * Uses the Project Amber API to get the icon. + * + * @param entity The entity to get the icon for. Project Amber data required. + */ +export function entityIcon(entity: Entity): string { + return `https://api.ambr.top/assets/UI/monster/UI_MonsterIcon_${entity.internal}.png`; +} + /** * Formats a character's name to fit with the reference name. * Example: Hu Tao -> hu_tao @@ -111,3 +121,16 @@ export async function fetchItemData(item: Item): Promise { .then((res) => res.json()) .catch(() => {}); } + +/** + * Fetches the data for an entity. + * Uses the Project Amber API to get the data. + * + * @route GET https://api.ambr.top/v2/en/monster/{id} + * @param entity The entity to fetch the data for. + */ +export async function fetchEntityData(entity: Entity): Promise { + return fetch(`https://api.ambr.top/v2/en/monster/${entity.id}`) + .then((res) => res.json()) + .catch(() => {}); +}