Implement the entities page

This commit is contained in:
KingRainbow44 2023-04-10 00:59:01 -04:00
parent 127d45f21f
commit 16875e85ac
No known key found for this signature in database
GPG Key ID: FC2CB64B00D257BE
13 changed files with 493 additions and 27 deletions

View File

@ -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`

View File

@ -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.
*/

View File

@ -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",

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<HTMLInputElement>): void {
this.setState({ search: event.target.value });
}
/**
* Sets the selected entity.
*
* @param entity The entity.
* @private
*/
private async setSelectedItem(entity: EntityType): Promise<void> {
let data: EntityInfo | null = null; try {
data = await fetchEntityData(entity);
} catch { }
this.setState({
selected: entity,
selectedInfo: data
});
}
render() {
const entities = this.getEntities();
return (
<div className={"EntitiesPage"}>
<div className={"EntitiesPage_Content"}>
<div className={"EntitiesPage_Header"}>
<h1 className={"EntitiesPage_Title"}>Monsters</h1>
<div className={"EntitiesPage_Search"}>
<input
type={"text"}
className={"EntitiesPage_Input"}
placeholder={"Search..."}
onChange={this.onChange.bind(this)}
/>
</div>
</div>
{entities.length > 0 ? (
<VirtualizedGrid
list={entities.filter(entity => this.showEntity(entity))}
itemHeight={64}
itemsPerRow={18}
gap={5}
itemGap={5}
render={(entity) => <MiniCard
key={entity.id} data={entity} icon={entityIcon(entity)}
onClick={() => this.setSelectedItem(entity)}
/>}
/>
) : undefined}
</div>
<div className={"EntitiesPage_Card"}>
<EntityCard
entity={this.state.selected}
info={this.state.selectedInfo}
/>
</div>
</div>
);
}
}
export default EntitiesPage;

View File

@ -21,7 +21,7 @@ class HomePage extends React.Component<any, any> {
<HomeButton name={"Commands"} anchor={"Commands"} />
<HomeButton name={"Characters"} anchor={"Avatars"} />
<HomeButton name={"Items"} anchor={"Items"} />
<HomeButton name={"Entities"} anchor={"Home"} />
<HomeButton name={"Entities"} anchor={"Entities"} />
<HomeButton name={"Scenes"} anchor={"Scenes"} />
</div>

View File

@ -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) => <Item
key={item.id} data={item}
render={(item) => <MiniCard
key={item.id} data={item} icon={itemIcon(item)}
onClick={() => this.setSelectedItem(item)}
/>}
/>

View File

@ -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<IProps, IState> {
return <AvatarsPage />;
case "Items":
return <ItemsPage />;
case "Entities":
return <EntitiesPage />;
case "Scenes":
return <ScenesPage />;
}

View File

@ -50,7 +50,7 @@ class SideBar extends React.Component<{}, IState> {
<SideBarButton name={"Commands"} anchor={"Commands"} />
<SideBarButton name={"Characters"} anchor={"Avatars"} />
<SideBarButton name={"Items"} anchor={"Items"} />
<SideBarButton name={"Entities"} anchor={"Home"} />
<SideBarButton name={"Entities"} anchor={"Entities"} />
<SideBarButton name={"Scenes"} anchor={"Scenes"} />
<SideBarButton name={"Quests"} anchor={"Home"} />
<SideBarButton name={"Achievements"} anchor={"Home"} />

View File

@ -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 <p key={index}>{line}</p>;
});
}
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<IProps, IState> {
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<HTMLInputElement>) {
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<void> {
// TODO: Implement server access.
}
componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>, snapshot?: any) {
if (this.props.entity != prevProps.entity) {
this.setState(defaultState);
}
}
render() {
const { entity, info } = this.props;
const data = info?.data;
return entity ? (
<div className={"ItemCard"}>
<div className={"ItemCard_Content"}>
<div className={"ItemCard_Header"}>
<div className={"ItemCard_Info"}>
<p>{data?.name ?? entity.name}</p>
<p>{data?.type ?? ""}</p>
</div>
{ this.state.icon && <img
className={"ItemCard_Icon"}
alt={entity.name}
src={entityIcon(entity)}
onError={() => this.setState({ icon: false })}
/> }
</div>
<div className={"ItemCard_Description"}>
{toDescription(data?.description)}
</div>
</div>
<div className={"ItemCard_Actions"}>
<div className={"ItemCard_Counter"}>
<div onClick={() => this.addCount(false, false)}
onContextMenu={(e) => {
e.preventDefault();
this.addCount(false, true);
}}
className={"ItemCard_Operation"}>-</div>
<input type={"text"}
value={this.state.count}
className={"ItemCard_Count"}
onChange={this.updateCount.bind(this)}
onBlur={() => {
if (this.state.count == "") {
this.setState({ count: 1 });
}
}}
/>
<div onClick={() => this.addCount(true, false)}
onContextMenu={(e) => {
e.preventDefault();
this.addCount(true, true);
}}
className={"ItemCard_Operation"}>+</div>
</div>
<button
className={"ItemCard_Submit"}
onClick={this.summonAtPlayer.bind(this)}
>Summon</button>
</div>
</div>
) : undefined;
}
}
export default EntityCard;

View File

@ -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<IProps, IState> {
class MiniCard extends React.Component<IProps, IState> {
loading: number | any;
constructor(props: IProps) {
@ -52,27 +53,25 @@ class Item extends React.Component<IProps, IState> {
render() {
return (
<div className={"Item"}
<div className={"MiniCard"}
onClick={this.props.onClick}
>
<div className={"Item_Background"}>
<div className={"MiniCard_Background"}>
{this.state.icon && (
<img
className={"Item_Icon"}
className={"MiniCard_Icon"}
alt={this.props.data.name}
src={itemIcon(this.props.data)}
src={this.props.icon}
onError={this.replaceIcon.bind(this)}
onLoad={() => this.setState({ loaded: true })}
/>
)}
{(!this.state.loaded || !this.state.icon) && <p className={"Item_Label"}>{this.props.data.name}</p>}
{(!this.state.loaded || !this.state.icon) && <p className={"MiniCard_Label"}>{this.props.data.name}</p>}
</div>
<div className={"Item_Info"}></div>
</div>
);
}
}
export default Item;
export default MiniCard;

View File

@ -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<ItemInfo> {
.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<EntityInfo> {
return fetch(`https://api.ambr.top/v2/en/monster/${entity.id}`)
.then((res) => res.json())
.catch(() => {});
}