Commit 8208aaac authored by Chunchi Che's avatar Chunchi Che

Feat/mycard

parent ae3bfdce
<p align="center">
<a href="https://www.neos.moe">
<img src="https://avatars.githubusercontent.com/u/110732697?s=400&u=d33638f1e89dfb41395a47004fe211fcf219444d&v=4" height="128">
<a href="https://neos.moecube.com">
<img src="./neos-assets/neos-logo.svg" height="128">
<h1 align="center">Neos</h1>
</a>
</p>
......@@ -27,10 +27,18 @@ Neos是一个基于[TypeScript](https://www.typescriptlang.org/),[React.js](ht
## ✨ 功能
- 🎮 和[ygopro](https://mycard.moe)客户端或者Neos本身进行[联机对战](https://neos.moecube.com)
- 🌍 在局域网下进行对战
- 🤖️ 和游戏AI进行对战
- 📹 回放[ygopro2](https://mycard.moe)上进行的决斗对局
- **[WIP]竞技匹配**
- 与世界上其他玩家在线匹配,您的排名将实时显示在排行榜上。
- **[WIP]娱乐匹配**
- 使用任意卡组进行对战,将胜负暂且搁置,尽情享受决斗的乐趣。
- **单人模式**
- 开启与AI的决斗,验证自己的卡组,或者只是打发时间。
- **自定义房间**
- 创建一个自定义的对战房间,便捷地与好友进行对战,甚至是举办一场竞技比赛。
- **录像回放**
- 自由查看进行过的决斗,回味那些精彩的逆转瞬间。
- **卡组编辑**
- 创建和编辑卡组,在上万种卡片中选择,组建独一无二的构筑。
## 📦快速开始
请移步[玩家向导](https://doc.neos.moe/docs/category/%E7%8E%A9%E5%AE%B6%E6%8C%87%E5%8D%97)
......
<p align="center">
<a href="https://www.neos.moe">
<img src="https://avatars.githubusercontent.com/u/110732697?s=400&u=d33638f1e89dfb41395a47004fe211fcf219444d&v=4" height="128">
<a href="https://neos.moecube.com">
<img src="./neos-assets/neos-logo.svg" height="128">
<h1 align="center">Neos</h1>
</a>
</p>
......@@ -24,10 +24,18 @@ Neos is web version of [Yu-Gi-Oh!](https://www.yugioh-card.com/en/) game written
## ✨ Features
- 🎮 [Play online](https://neos.moecube.com) with the [ygopro](https://mycard.moe) client or neos itself
- 🌍 Play with LAN connection
- 🤖️ Duel with AI
- 📹 Replay play the duel from [ygopro2](https://mycard.moe)
- **[WIP]Competitive Match**
- Match online with players from around the world, and your ranking will be displayed in real-time on the leaderboard.
- **[WIP]Entertainment Match**
- Battle with any deck, set aside victory or defeat for now, and fully enjoy the pleasure of the duel.
- **Single Mode**
- Engage in duels with AI, validate your deck, or simply pass the time.
- **Custom Room**
- Create a custom duel room to easily battle with friends, or even host a competitive tournament.
- **Replay**
- Freely review past duels and savor those thrilling moments of reversal.
- **Deck Editing**
- Create and edit decks, choose from tens of thousands of cards, and assemble a unique build.
## 📦Quick Start
See the [player guide](https://doc.neos.moe/docs/category/%E7%8E%A9%E5%AE%B6%E6%8C%87%E5%8D%97) for details.
......
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
width="978px" height="236px">
<path fill-rule="evenodd" fill="rgb(255, 255, 255)"
d="M24.1000,43.1000 L24.1000,196.000 L40.1000,236.000 L58.1000,236.000 L58.1000,68.1000 L24.1000,43.1000 ZM-0.000,7.1000 L-0.000,15.1000 L230.1000,185.000 L256.1000,185.000 C256.1000,185.000 268.053,181.909 265.1000,164.000 C256.314,157.104 50.1000,7.1000 50.1000,7.1000 L-0.000,7.1000 ZM283.000,-0.000 L223.1000,-0.000 L223.1000,121.1000 L257.1000,145.000 C257.1000,145.000 257.1000,46.384 257.1000,34.000 C257.1000,21.616 276.716,-0.000 283.000,-0.000 ZM284.1000,29.1000 C284.1000,35.635 284.1000,175.000 284.1000,175.000 C284.1000,175.000 281.919,185.000 279.000,185.000 C276.081,185.000 502.1000,185.000 502.1000,185.000 C502.1000,185.000 479.959,171.400 479.000,165.1000 C468.596,165.1000 320.000,165.1000 320.000,165.1000 L320.000,107.1000 L435.000,107.1000 L435.000,90.000 L320.000,90.000 C320.000,90.000 320.000,37.137 320.000,34.1000 C320.000,32.863 322.366,31.000 325.000,31.000 C327.634,31.000 475.000,31.000 475.000,31.000 C475.000,31.000 496.728,11.415 501.1000,8.1000 C490.044,8.1000 323.045,8.1000 310.1000,8.1000 C298.955,8.1000 284.1000,24.365 284.1000,29.1000 ZM600.1000,9.1000 C760.190,9.1000 785.952,192.000 600.1000,192.000 C416.048,192.000 441.810,9.1000 600.1000,9.1000 ZM600.1000,26.1000 C547.505,26.1000 528.932,61.833 528.000,100.000 C527.068,138.167 547.843,176.000 599.1000,176.000 C652.157,176.000 673.1000,139.876 673.1000,101.1000 C673.1000,64.124 654.495,26.1000 600.1000,26.1000 ZM960.000,8.1000 C960.000,8.1000 793.606,7.782 786.000,8.1000 C739.115,13.638 720.220,66.718 765.000,93.1000 C794.539,105.400 856.239,102.436 878.000,107.1000 C898.625,112.181 939.035,151.807 845.1000,164.000 C818.416,165.747 730.000,165.1000 730.000,165.1000 L747.1000,185.000 C747.1000,185.000 842.333,185.000 869.000,185.000 C924.471,175.092 967.848,146.557 931.1000,95.1000 C892.369,73.262 864.274,84.180 800.1000,70.000 C757.990,55.058 784.940,28.000 810.000,28.000 C825.574,27.890 978.000,28.000 978.000,28.000 L960.000,8.1000 Z"/>
</svg>
\ No newline at end of file
Subproject commit 30f4ea7acd79b9cb18a358548520ca939e22dc5f
Subproject commit d6c01dd88fd72f22c432a52ba74eee9ece267499
......@@ -2,6 +2,7 @@
"version":4960,
"servers":[
{
"name":"koishi",
"ip":"koishi.momobako.com",
"port":"7211"
}
......@@ -10,8 +11,10 @@
"cardImgUrl":"https://cdn02.moecube.com:444/images/ygopro-images-zh-CN",
"cardsDbUrl":"https://cdn02.moecube.com:444/ygopro-database/zh-CN/cards.cdb",
"stringsUrl":"https://cdn02.moecube.com:444/ygopro-database/zh-CN/strings.conf",
"lflistUrl":"https://cdn02.moecube.com:444/ygopro-database/zh-CN/lflist.conf",
"replayUrl":"replay.neos.moe",
"chainALL":false,
"accountUrl":"https://accounts.moecube.com",
"profileUrl":"https://accounts.moecube.com/profiles",
"streamInterval":20,
"startDelay":1000,
"ui":{
......
......@@ -2,6 +2,7 @@
"version":4960,
"servers":[
{
"name":"koishi",
"ip":"koishi-r.momobako.com",
"port":"7211"
}
......@@ -10,8 +11,10 @@
"cardImgUrl":"https://cdn02.moecube.com:444/images/ygopro-images-zh-CN",
"cardsDbUrl":"https://cdn02.moecube.com:444/ygopro-database/zh-CN/cards.cdb",
"stringsUrl":"https://cdn02.moecube.com:444/ygopro-database/zh-CN/strings.conf",
"lflistUrl":"https://cdn02.moecube.com:444/ygopro-database/zh-CN/lflist.conf",
"replayUrl":"replay.neos.moe",
"chainALL":false,
"accountUrl":"https://accounts.moecube.com",
"profileUrl":"https://accounts.moecube.com/profiles",
"streamInterval":20,
"startDelay":1000,
"ui":{
......
This diff is collapsed.
import sqliteMiddleWare, { sqliteCmd } from "@/middleware/sqlite";
import { FtsParams } from "@/middleware/sqlite/fts";
export interface CardMeta {
id: number;
......@@ -15,6 +16,8 @@ export interface CardData {
level?: number;
race?: number;
attribute?: number;
lscale?: number;
rscale?: number;
}
export interface CardText {
......@@ -55,8 +58,25 @@ export async function fetchCard(id: number): Promise<CardMeta> {
return res.selectResult ? res.selectResult : { id, data: {}, text: {} };
}
/*
* 返回卡片元数据
*
* @param id - 卡片id
* @returns 卡片数据
*
* */
export async function searchCards(params: FtsParams): Promise<CardMeta[]> {
const res = await sqliteMiddleWare({
cmd: sqliteCmd.FTS,
payload: { ftsParams: params },
});
return res.ftsResult ?? [];
}
// @ts-ignore
window.fetchCard = fetchCard;
// @ts-ignore
window.searchCard = searchCards;
export function getCardStr(meta: CardMeta, idx: number): string | undefined {
switch (idx) {
......
import Cookies from "cookies-ts";
const cookies = new Cookies();
export enum CookieKeys {
USER = "user",
}
export const getCookie = <T>(key: CookieKeys) => {
return cookies.get(key) as unknown as T | null;
};
export const setCookie = <T>(key: CookieKeys, value: T) => {
cookies.set(key, value, {
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 60), // 两个月的cookie,应该很充裕
});
};
const DECKS: Record<string, { default: IDeck }> = import.meta.glob(
"/neos-assets/structure-decks/*.ydk",
{
eager: true,
}
);
export const DeckManager = _objToMap(
Object.keys(DECKS).map((key) => ({
...DECKS[key].default,
deckName:
key.split("/").pop()?.split(".").slice(0, -1).join(".") ?? "undefined",
}))
);
/*
* 返回卡组资源。
*
* @param deck- 卡组名称
* @returns 卡组数据
*
* @todo - 这里应该为萌卡实现卡组存储
* */
export async function fetchDeck(deck: string): Promise<IDeck> {
const res = DeckManager.get(deck);
if (!res) {
console.error(`Deck ${deck} doesn't exist.`);
}
return res ?? { deckName: "undefined", main: [], extra: [], side: [] };
}
function _objToMap(object: IDeck[]): Map<string, IDeck> {
const map: Map<string, IDeck> = new Map();
object.forEach((value) => map.set(value.deckName, value));
return map;
}
export interface IDeck {
deckName: string;
main: number[];
extra: number[];
side: number[];
}
export * from "./deckManager";
//! 禁限卡表
import { clear, createStore, get, setMany } from "idb-keyval";
import { useConfig } from "@/config";
const { lflistUrl } = useConfig();
type Forbiddens = Map<number, number>;
const IDB_NAME = "forbiddens";
// 禁限卡表的时间,比如 [2023.4] - 2023年4月表
export let forbiddenTime = "?";
const idb = createStore(IDB_NAME, IDB_NAME);
export async function initForbiddens(): Promise<void> {
const text = await (await fetch(lflistUrl)).text();
const { time, forbiddens } = extractForbiddensFromText(text);
forbiddenTime = time;
// 先清掉之前的记录
clear(idb);
// 设置新记录
await setMany(Array.from(forbiddens));
}
// 获取禁限信息
export async function getForbiddenInfo(
id: number
): Promise<number | undefined> {
return await get(id, idb);
}
// 解析函数,提取卡片编号和限制张数
function parseCardInfo(
input: string
): { cardId: number; limitCount: number } | null {
const match = input.match(/^(\d+)\s+(\d+)\s+--/);
if (match) {
const cardId = parseInt(match[1]);
const limitCount = parseInt(match[2]);
return { cardId, limitCount };
}
return null;
}
// 分割文本为行,并提取每行的限制信息
function extractForbiddensFromText(text: string): {
time: string;
forbiddens: Forbiddens;
} {
const lines = text.split("\n");
const forbiddens = new Map<number, number>([]);
// remove first line
lines.shift();
let time = "?";
for (const line of lines) {
if (line.startsWith("#")) {
// do nothing
} else if (line.startsWith("!")) {
if (time !== "?") {
// 已经读取完第一个禁限表的信息了,退出循环
break;
} else {
time = line.substring(1).trim();
}
} else {
const cardInfo = parseCardInfo(line);
if (cardInfo) {
forbiddens.set(cardInfo.cardId, cardInfo.limitCount);
}
}
}
return { time, forbiddens };
}
export * from "./cards";
export * from "./deck";
export * from "./cookies";
export * from "./forbiddens";
export * from "./mycard";
export * from "./ocgcore/idl/ocgcore";
export * from "./ocgcore/ocgHelper";
export * from "./strings";
// Collection of APIs provided by MyCard
export * from "./match";
const API_URL = "https://sapi.moecube.com:444/ygopro/match";
export interface MatchInfo {
address: string;
port: number;
password: string;
}
export async function match(
username: string,
extraId: number,
arena: string = "entertain"
): Promise<MatchInfo | undefined> {
const headers = { Authorization: "Basic " + btoa(username + ":" + extraId) };
let response: Response | undefined = undefined;
const params = new URLSearchParams({
arena,
// TODO: locale?
});
try {
const resp = await fetch(API_URL + "?" + params.toString(), {
method: "POST",
headers: headers,
});
if (resp.ok) {
response = resp;
} else {
console.error(`match error: ${resp.status}`);
}
} catch (error) {
console.error(`match error: ${error}`);
}
return (await response?.json()) as MatchInfo;
}
This diff is collapsed.
......@@ -19,6 +19,7 @@ import StocChat from "./stoc/stocChat";
import StocDeckCount from "./stoc/stocDeckCount";
import StocDuelStart from "./stoc/stocDuelStart";
import StocGameMsg from "./stoc/stocGameMsg/mod";
import StocHandResult from "./stoc/stocHandResult";
import StocHsPlayerChange from "./stoc/stocHsPlayerChange";
import StocHsPlayerEnter from "./stoc/stocHsPlayerEnter";
import StocHsWatchChange from "./stoc/stocHsWatchChange";
......@@ -78,7 +79,7 @@ export function adaptStoc(packet: YgoProPacket): ygopro.YgoStocMsg {
break;
}
case STOC_HAND_RESULT: {
// TODO
pb = new StocHandResult(packet).upcast();
break;
}
......
import { ygopro } from "../../idl/ocgcore";
import { YgoProPacket } from "../packet";
import { CTOS_HS_NOT_READY } from "../protoDecl";
/*
* CTOS HsReady
*
* @usage - 告诉ygopro服务端当前玩家取消准备
* */
export default class CtosHsNotReady extends YgoProPacket {
constructor(_: ygopro.YgoCtosMsg) {
super(1, CTOS_HS_NOT_READY, new Uint8Array(0));
}
}
import { ygopro } from "../../idl/ocgcore";
import { YgoProPacket } from "../packet";
import { CTOS_HS_TO_DUEL_LIST } from "../protoDecl";
/*
* CTOS HsReady
*
* @usage - 告诉ygopro服务端当前玩家进入决斗者行列
* */
export default class CtosHsToDuelList extends YgoProPacket {
constructor(_: ygopro.YgoCtosMsg) {
super(1, CTOS_HS_TO_DUEL_LIST, new Uint8Array(0));
}
}
import { ygopro } from "../../idl/ocgcore";
import { YgoProPacket } from "../packet";
import { CTOS_HS_TO_OBSERVER } from "../protoDecl";
/*
* CTOS HsReady
*
* @usage - 告诉ygopro服务端当前玩家进入观战者行列
* */
export default class CtosHsToObserver extends YgoProPacket {
constructor(_: ygopro.YgoCtosMsg) {
super(1, CTOS_HS_TO_OBSERVER, new Uint8Array(0));
}
}
......@@ -5,7 +5,10 @@
export const CTOS_PLAYER_INFO = 16;
export const CTOS_JOIN_GAME = 18;
export const CTOS_UPDATE_DECK = 2;
export const CTOS_HS_TO_DUEL_LIST = 32;
export const CTOS_HS_TO_OBSERVER = 33;
export const CTOS_HS_READY = 34;
export const CTOS_HS_NOT_READY = 35;
export const CTOS_HS_START = 37;
export const CTOS_HAND_RESULT = 3;
export const CTOS_TP_RESULT = 4;
......
import { BufferReader } from "../../../../../rust-src/pkg/rust_src";
import { ygopro } from "../../idl/ocgcore";
import { StocAdapter, YgoProPacket } from "../packet";
/*
* STOC HandResult
*
* @usage - 后端告诉前端玩家们的猜拳选择
* */
export default class SelectHand implements StocAdapter {
packet: YgoProPacket;
constructor(packet: YgoProPacket) {
this.packet = packet;
}
upcast(): ygopro.YgoStocMsg {
const reader = new BufferReader(this.packet.exData);
const meResult = reader.readUint8();
const opResult = reader.readUint8();
return new ygopro.YgoStocMsg({
stoc_hand_result: new ygopro.StocHandResult({
meResult,
opResult,
}),
});
}
}
......@@ -49,6 +49,11 @@ export default class TypeChangeAdapter implements StocAdapter {
case 5: {
selfType = ygopro.StocTypeChange.SelfType.PLAYER6;
break;
}
case 7: {
selfType = ygopro.StocTypeChange.SelfType.OBSERVER;
break;
}
}
......
......@@ -3,14 +3,17 @@
*
* */
import socketMiddleWare, { socketCmd } from "@/middleware/socket";
import { IDeck } from "@/stores";
import { IDeck } from "../deck";
import { ygopro } from "./idl/ocgcore";
import Chat from "./ocgAdapter/ctos/ctosChat";
import GameMsgResponse from "./ocgAdapter/ctos/ctosGameMsgResponse/mod";
import HandResult from "./ocgAdapter/ctos/ctosHandResult";
import HsNotReadyAdapter from "./ocgAdapter/ctos/ctosHsNotReady";
import HsReadyAdapter from "./ocgAdapter/ctos/ctosHsReady";
import HsStartAdapter from "./ocgAdapter/ctos/ctosHsStart";
import HsToDuelListAdapter from "./ocgAdapter/ctos/ctosHsToDuelList";
import HsToObserverAdapter from "./ocgAdapter/ctos/ctosHsToObserver";
import JoinGameAdapter from "./ocgAdapter/ctos/ctosJoinGame";
import PlayerInfoAdapter from "./ocgAdapter/ctos/ctosPlayerInfo";
import Surrender from "./ocgAdapter/ctos/ctosSurrender";
......@@ -42,6 +45,33 @@ export function sendHsReady() {
socketMiddleWare({ cmd: socketCmd.SEND, payload });
}
export function sendHsNotReady() {
const hasNotReady = new ygopro.YgoCtosMsg({
ctos_hs_not_ready: new ygopro.CtosHsNotReady({}),
});
const payload = new HsNotReadyAdapter(hasNotReady).serialize();
socketMiddleWare({ cmd: socketCmd.SEND, payload });
}
export function sendHsToObserver() {
const hasToObserver = new ygopro.YgoCtosMsg({
ctos_hs_to_observer: new ygopro.CtosHsToObserver({}),
});
const payload = new HsToObserverAdapter(hasToObserver).serialize();
socketMiddleWare({ cmd: socketCmd.SEND, payload });
}
export function sendHsToDuelList() {
const hasToDuelList = new ygopro.YgoCtosMsg({
ctos_hs_to_duel_list: new ygopro.CtosHsToDuelList({}),
});
const payload = new HsToDuelListAdapter(hasToDuelList).serialize();
socketMiddleWare({ cmd: socketCmd.SEND, payload });
}
export function sendHsStart() {
const hasStart = new ygopro.YgoCtosMsg({
ctos_hs_start: new ygopro.CtosHsStart({}),
......
import axios from "axios";
import { useConfig } from "@/config";
import { fetchCard, getCardStr } from "./cards";
const NeosConfig = useConfig();
const { stringsUrl } = useConfig();
export const DESCRIPTION_LIMIT = 10000;
export async function initStrings() {
const strings = (await axios.get<string>(NeosConfig.stringsUrl)).data;
const strings = await (await fetch(stringsUrl)).text();
console.log({ strings });
const lineIter = strings.split("\n");
for (const line of lineIter) {
......@@ -19,13 +19,19 @@ export async function initStrings() {
}
}
export function fetchStrings(region: string, id: string | number): string {
export enum Region {
System = "!system",
Victory = "!victory",
Counter = "!counter",
}
export function fetchStrings(region: Region, id: string | number): string {
return localStorage.getItem(`${region}_${id}`) || "";
}
export async function getStrings(description: number): Promise<string> {
if (description < DESCRIPTION_LIMIT) {
return fetchStrings("!system", description);
return fetchStrings(Region.System, description);
} else {
const code = description >> 4;
const index = description & 0xf;
......
......@@ -95,6 +95,37 @@ export function extraCardTypes(typeCode: number): number[] {
].filter((target) => (target & typeCode) > 0);
}
/** 这张卡能不能放入额外卡组 */
export function isExtraDeckCard(typeCode: number): boolean {
const extraTypes = [
TYPE_PENDULUM,
TYPE_LINK,
TYPE_SYNCHRO,
TYPE_XYZ,
TYPE_FUSION,
];
return extraTypes.reduce((acc, cur) => (acc | cur) & typeCode, 0) > 0;
}
/** 这张卡是怪兽、魔法、陷阱 */
export function tellCardBasicType(typeCode: number): number {
const basicTypes = [TYPE_MONSTER, TYPE_SPELL, TYPE_TRAP];
return basicTypes.reduce((acc, cur) => (acc | cur) & typeCode, 0);
}
/** 是不是衍生物 */
export function isToken(typeCode: number): boolean {
return (typeCode & TYPE_TOKEN) > 0;
}
export function isMonster(typeCode: number): boolean {
return (typeCode & TYPE_MONSTER) > 0;
}
export function isLinkMonster(typeCode: number): boolean {
return (typeCode & TYPE_LINK) > 0;
}
// 属性
// const ATTRIBUTE_ALL = 0x7f; //
const ATTRIBUTE_EARTH = 0x01; //
......
......@@ -17,7 +17,7 @@ import { EventEmitter } from "eventemitter3";
/* eslint no-var: 0 */
declare global {
var myExtraDeckCodes: number[];
var myExtraDeckCodes: number[] = [];
interface Console {
color: (
color: string,
......@@ -25,3 +25,7 @@ declare global {
) => (...args: Parameters<console.log>) => void;
}
}
declare module "react-router-dom" {
export declare function useLoaderData<T>(): T;
}
......@@ -4,14 +4,16 @@ import { v4 as v4uuid } from "uuid";
const eventEmitter = new EventEmitter();
export enum Task {
Move = "move",
Focus = "focus",
Attack = "attack",
Move = "move", // 卡片移动
Focus = "focus", // 卡片聚焦
Attack = "attack", // 卡片攻击
Mora = "mora", // 猜拳
Tp = "tp", // 选边
}
const getEnd = (task: Task) => `${task}-end`;
/** 在组件之中注册方法 */
/** 在组件之中注册方法,注意注册的方法一旦执行成功,必须返回一个true */
const register = <T extends unknown[]>(
task: Task,
fn: (...args: T) => Promise<boolean>
......@@ -42,4 +44,8 @@ const call = (task: Task, ...args: any[]) =>
export const eventbus = {
call,
register,
on: eventEmitter.on.bind(eventEmitter),
off: eventEmitter.off.bind(eventEmitter),
once: eventEmitter.once.bind(eventEmitter),
emit: eventEmitter.emit.bind(eventEmitter),
};
// Some implementation of infrastructure
/* eslint import/export: 0 */
export * from "./console";
import "./console";
export * from "./eventbus";
export * from "./pfetch";
export * from "./sleep";
export * from "./stream";
/** 在fetch的基础上,封装一个pfetch。增加一个可选的新参数,这个参数是一个回调函数,从而让外界可以感知fetch的进度(0->1),比如下载进度。 */
export async function pfetch(
input: RequestInfo,
options?: {
init?: RequestInit;
progressCallback?: (progress: number) => void;
}
): Promise<Response> {
const response = await fetch(input, options?.init);
const clonedResponse = response.clone(); // Clone the response to create a new body stream
if (typeof options?.progressCallback === "function") {
const contentLength = parseInt(
response.headers.get("content-length") || "0",
10
);
let bytesRead = 0;
const reader = clonedResponse.body!.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
bytesRead += value.length;
const progress = (bytesRead / contentLength) * 100;
options?.progressCallback(progress);
}
}
return response;
}
/*
* Neos是基于[React](https://reactjs.org/)和[Babylon.js](https://www.babylonjs.com/)框架
* Neos是基于[React](https://reactjs.org/)框架
* 研发的Web版[Ygopro](https://github.com/Fluorohydride/ygopro),游戏王对战平台。
*
* - 路由管理:[React Router](https://reactrouter.com/en/main)
* - 状态管理:[Redux](https://redux.js.org/)
* - 3D渲染:[Babylon.js](https://www.babylonjs.com/)
* - 状态管理:[valtio](https://valtio.pmnd.rs/)
*
* 项目整体架构分为以下模块:
* - UI模块:使用React组件和Babylon.js提供的渲染能力进行UI展示
* - UI模块:基于HTML+CSS进行UI渲染
* - Service模块:一些具体业务逻辑的实现,通常是一些事件处理函数;
* - MiddleWare(中间件)模块:收敛Websocket长连接的处理逻辑;
* - Adapter模块:进行ygopro数据协议从二进制buffer到TypeScript结构体之间的转换;
* - Api模块:提供长连接以外请求网络数据的接口,比如获取卡组数据;
* - Reducer模块:进行全局的状态更新;
* - Store模块:存储全局状态。
*
* 在设计上各个模块之间都是解耦的,模块之间的依赖应该通过调用接口,而非调用实例。
* 在进行代码开发的时候需要注意这点。
* - Api模块:提供长连接以外请求网络数据的接口,比如登录萌卡账号;
* - Store模块:进行全局状态的管理。
*
* */
import "u-reset.css";
import "overlayscrollbars/overlayscrollbars.css";
import "@/styles/core.scss";
import "@/styles/inject.scss";
import { ProConfigProvider } from "@ant-design/pro-provider";
import { ConfigProvider, theme } from "antd";
import { App, ConfigProvider } from "antd";
import zhCN from "antd/locale/zh_CN";
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import Neos from "./ui/Neos";
import { theme } from "@/ui/theme";
import { NeosRouter } from "./ui/NeosRouter";
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
root.render(
<BrowserRouter>
<ConfigProvider theme={{ algorithm: theme.darkAlgorithm }} locale={zhCN}>
<ConfigProvider theme={theme} locale={zhCN}>
<App>
<ProConfigProvider dark>
<Neos />
<NeosRouter />
</ProConfigProvider>
</ConfigProvider>
</BrowserRouter>
</App>
</ConfigProvider>
);
import { isNil } from "lodash-es";
import { Database } from "sql.js";
import { CardData, CardMeta, CardText } from "@/api";
import { constructCardMeta } from ".";
const TYPE_MONSTER = 0x1;
/** 过滤条件 */
export interface FtsConditions {
levels: number[]; // 星阶/link值
lscales: number[]; // 左刻度
types: number[]; // 卡片类型
races: number[]; // 种族
attributes: number[]; // 属性
atk: { min: number | null; max: number | null }; // 攻击力区间
def: { min: number | null; max: number | null }; // 防御力区间
}
export interface FtsParams {
query: string; // 用于全文检索的query
conditions: FtsConditions; // 过滤条件
}
export function invokeFts(db: Database, params: FtsParams): CardMeta[] {
const { query, conditions } = params;
const ftsMetas: CardMeta[] = [];
const filterConditions = getFtsCondtions(conditions);
const stmt = db.prepare(`
SELECT datas.*, texts.*
FROM datas
INNER JOIN texts ON datas.id = texts.id
WHERE texts.name LIKE $query ${
filterConditions ? `AND ${filterConditions}` : ""
}
`);
stmt.bind({ $query: `%${query}%` });
while (stmt.step()) {
const row = stmt.getAsObject() as CardData & CardText;
ftsMetas.push(constructCardMeta(row.id!, row, row));
}
return ftsMetas;
}
function getFtsCondtions(conditions: FtsConditions): string {
const { types, levels, atk, def, races, attributes } = conditions;
const assertMonster = `(type & ${TYPE_MONSTER}) > 0`;
const typesCondition = types
?.map((type) => `(type & ${type}) > 0`)
.join(" OR ");
const levelsCondition = levels
?.map((level) => `level = ${level}`)
.join(" OR ");
const atkCondition = atk
? `atk BETWEEN ${handleFinite(atk.min, "min")} AND ${handleFinite(
atk.max,
"max"
)} AND ${assertMonster}`
: undefined;
const defCondition = def
? `def BETWEEN ${handleFinite(def.min, "min")} AND ${handleFinite(
def.max,
"max"
)} AND ${assertMonster}`
: undefined;
const raceCondition = races?.map((race) => `race = ${race}`).join(" OR ");
const attributeCondition = attributes
?.map((attribute) => `attribute = ${attribute}`)
.join(" OR ");
const merged = [
typesCondition,
levelsCondition,
atkCondition,
defCondition,
raceCondition,
attributeCondition,
]
.filter((condition) => condition !== undefined && condition !== "")
.map((condition) => `(${condition})`)
.join(" AND ");
return merged;
}
function handleFinite(value: number | null, type: "min" | "max"): number {
if (isNil(value)) return type === "min" ? -2 : 9999999;
return value;
}
......@@ -9,6 +9,9 @@ import initSqlJs, { Database } from "sql.js";
import { CardData, CardMeta, CardText } from "@/api/cards";
import { useConfig } from "@/config";
import { pfetch } from "@/infra";
import { FtsParams, invokeFts } from "./fts";
const NeosConfig = useConfig();
......@@ -26,11 +29,11 @@ export interface sqliteAction {
// 初始化DB需要业务方传入的数据
initInfo?: {
dbUrl: string;
progressCallback?: (progress: number) => void; // 用于获取读取进度
};
// 需要读取卡牌数据的ID
payload?: {
id?: number;
query?: string;
id?: number; // 卡牌ID
ftsParams?: FtsParams; // 用于全文检索的参数
};
}
......@@ -50,7 +53,9 @@ export default async function (action: sqliteAction): Promise<sqliteResult> {
case sqliteCmd.INIT: {
const info = action.initInfo;
if (info) {
const dataPromise = fetch(info.dbUrl).then((res) => res.arrayBuffer()); // TODO: i18n
const dataPromise = pfetch(info.dbUrl, {
progressCallback: action.initInfo?.progressCallback,
}).then((res) => res.arrayBuffer()); // TODO: i18n
const [SQL, buffer] = await Promise.all([sqlPromise, dataPromise]);
YGODB = new SQL.Database(new Uint8Array(buffer));
......@@ -84,34 +89,10 @@ export default async function (action: sqliteAction): Promise<sqliteResult> {
return {};
}
case sqliteCmd.FTS: {
if (YGODB && action.payload && action.payload.query) {
const query = action.payload.query;
const ftsTexts: CardText[] = [];
const ftsMetas: CardMeta[] = [];
const textStmt = YGODB.prepare(
"SELECT * FROM texts WHERE name LIKE $query"
);
textStmt.bind({ $query: `%${query}%` });
while (textStmt.step()) {
const row = textStmt.getAsObject();
ftsTexts.push(row);
}
for (const text of ftsTexts) {
const id = text.id;
if (id) {
const dataStmt = YGODB.prepare(
"SELECT * FROM datas WHERE ID = $id"
);
const data: CardData = dataStmt.getAsObject({ $id: id });
if (YGODB && action.payload && action.payload.ftsParams) {
const metas = invokeFts(YGODB, action.payload.ftsParams);
ftsMetas.push({ id, data, text });
}
}
return { ftsResult: ftsMetas };
return { ftsResult: metas };
} else {
console.warn("ygo db not init or query not provied!");
}
......@@ -126,11 +107,16 @@ export default async function (action: sqliteAction): Promise<sqliteResult> {
}
}
function constructCardMeta(
export function constructCardMeta(
id: number,
data: initSqlJs.ParamsObject,
text: initSqlJs.ParamsObject
data: CardData,
text: CardText
): CardMeta {
const level = data.level ?? 0;
data.level = level & 0xff;
data.lscale = (level >> 24) & 0xff;
data.rscale = (level >> 16) & 0xff;
return {
id,
data,
......
import { fetchCard, fetchStrings, ygopro } from "@/api";
import { fetchCard, fetchStrings, Region, ygopro } from "@/api";
import { displayAnnounceModal } from "@/ui/Duel/Message";
import MsgAnnounce = ygopro.StocGameMessage.MsgAnnounce;
......@@ -16,9 +16,9 @@ export default async (announce: MsgAnnounce) => {
case MsgAnnounce.AnnounceType.RACE: {
await displayAnnounceModal({
min,
title: fetchStrings("!system", 563),
title: fetchStrings(Region.System, 563),
options: announce.options.map((option) => ({
info: fetchStrings("!system", 1200 + option.code),
info: fetchStrings(Region.System, 1200 + option.code),
response: option.response,
})),
});
......@@ -28,9 +28,9 @@ export default async (announce: MsgAnnounce) => {
case MsgAnnounce.AnnounceType.Attribute: {
await displayAnnounceModal({
min,
title: fetchStrings("!system", 562),
title: fetchStrings(Region.System, 562),
options: announce.options.map((option) => ({
info: fetchStrings("!system", 1010 + option.code),
info: fetchStrings(Region.System, 1010 + option.code),
response: option.response,
})),
});
......@@ -50,7 +50,7 @@ export default async (announce: MsgAnnounce) => {
}
await displayAnnounceModal({
min,
title: fetchStrings("!system", 564),
title: fetchStrings(Region.System, 564),
options,
});
......@@ -59,7 +59,7 @@ export default async (announce: MsgAnnounce) => {
case MsgAnnounce.AnnounceType.Number: {
await displayAnnounceModal({
min,
title: fetchStrings("!system", 565),
title: fetchStrings(Region.System, 565),
options: announce.options.map((option) => ({
info: option.code.toString(),
response: option.response,
......
import { fetchCard, ygopro } from "@/api";
import { eventbus, Task } from "@/infra";
import { cardStore, fetchEsHintMeta, matStore } from "@/stores";
import { callCardFocus } from "@/ui/Duel/PlayMat/Card";
export default async (chaining: ygopro.StocGameMessage.MsgChaining) => {
fetchEsHintMeta({
......@@ -27,7 +27,7 @@ export default async (chaining: ygopro.StocGameMessage.MsgChaining) => {
target.meta = meta;
// 发动效果动画
await eventbus.call(Task.Focus, target.uuid);
await callCardFocus(target.uuid);
console.color("blue")(`${target.meta.text.name} chaining`);
} else {
console.warn(`<Chaining>target from ${location} is null`);
......
import { fetchCard, ygopro } from "@/api";
import { eventbus, Task } from "@/infra";
import { cardStore } from "@/stores";
import { callCardFocus } from "@/ui/Duel/PlayMat/Card";
export default async (confirmCards: ygopro.StocGameMessage.MsgConfirmCards) => {
const cards = confirmCards.cards;
......@@ -14,7 +14,7 @@ export default async (confirmCards: ygopro.StocGameMessage.MsgConfirmCards) => {
const meta = await fetchCard(card.code);
target.meta = meta;
// 动画
await eventbus.call(Task.Focus, target.uuid);
await callCardFocus(target.uuid);
} else {
console.warn(`card of ${card} is null`);
}
......
import { sendSelectSingleResponse, ygopro } from "@/api";
import { useConfig } from "@/config";
import { fetchSelectHintMeta } from "@/stores";
import { ChainSetting, fetchSelectHintMeta, matStore } from "@/stores";
import { displaySelectActionsModal } from "@/ui/Duel/Message/SelectActionsModal";
import { fetchCheckCardMeta } from "../utils";
const NeosConfig = useConfig();
type MsgSelectChain = ygopro.StocGameMessage.MsgSelectChain;
export default async (selectChain: MsgSelectChain) => {
const spCount = selectChain.special_count;
......@@ -14,6 +11,13 @@ export default async (selectChain: MsgSelectChain) => {
const _hint0 = selectChain.hint0;
const _hint1 = selectChain.hint1;
const chains = selectChain.chains;
const chainSetting = matStore.chainSetting;
if (chainSetting == ChainSetting.CHAIN_IGNORE) {
// 如果玩家配置了忽略连锁,直接回应后端并返回
sendSelectSingleResponse(-1);
return;
}
let handle_flag = 0;
if (!forced) {
......@@ -24,7 +28,7 @@ export default async (selectChain: MsgSelectChain) => {
// 直接回答
handle_flag = 0;
} else {
if (NeosConfig.chainALL) {
if (chainSetting == ChainSetting.CHAIN_ALL) {
// 配置了全部连锁,则处理多张
handle_flag = 2;
} else {
......
import { fetchStrings, ygopro } from "@/api";
import { fetchStrings, Region, type ygopro } from "@/api";
import { CardMeta, fetchCard } from "@/api/cards";
import { displayYesNoModal } from "@/ui/Duel/Message";
......@@ -19,7 +19,7 @@ export default async (selectEffectYn: MsgSelectEffectYn) => {
) => {
const desc1 = desc.replace(
`[%ls]`,
fetchStrings("!system", cardLocation.zone + 1000)
fetchStrings(Region.System, cardLocation.zone + 1000)
);
const desc2 = desc1.replace(`[%ls]`, cardMeta.text.name || "[?]");
return desc2;
......@@ -31,7 +31,7 @@ export default async (selectEffectYn: MsgSelectEffectYn) => {
// TODO: 国际化文案
const desc = fetchStrings("!system", effect_description);
const desc = fetchStrings(Region.System, effect_description);
const meta = await fetchCard(code);
await displayYesNoModal(textGenerator(desc, meta, location));
};
import { fetchCard, fetchStrings, getCardStr, ygopro } from "@/api";
import MsgSelectOption = ygopro.StocGameMessage.MsgSelectOption;
import {
fetchCard,
fetchStrings,
getCardStr,
Region,
type ygopro,
} from "@/api";
import { displayOptionModal } from "@/ui/Duel/Message";
export default async (selectOption: MsgSelectOption) => {
export default async (selectOption: ygopro.StocGameMessage.MsgSelectOption) => {
const options = selectOption.options;
await displayOptionModal(
fetchStrings("!system", 556),
fetchStrings(Region.System, 556),
await Promise.all(
options.map(async ({ code, response }) => {
const meta = await fetchCard(code >> 4);
......
import { ygopro } from "@/api";
import { playerStore } from "@/stores";
import { roomStore } from "@/stores";
type MsgSibylName = ygopro.StocGameMessage.MsgSibylName;
export default (sibylName: MsgSibylName) => {
playerStore.getMePlayer().name = sibylName.name_0;
playerStore.getOpPlayer().name = sibylName.name_1;
const me = roomStore.getMePlayer();
const op = roomStore.getOpPlayer();
if (me) {
me.name = sibylName.name_0;
}
if (op) {
op.name = sibylName.name_1;
}
};
......@@ -2,22 +2,27 @@ import { flatten } from "lodash-es";
import { v4 as v4uuid } from "uuid";
import { proxy } from "valtio";
import { subscribeKey } from "valtio/utils";
import PlayerType = ygopro.StocGameMessage.MsgStart.PlayerType;
import { fetchCard, ygopro } from "@/api";
import { useConfig } from "@/config";
import { sleep } from "@/infra";
import { cardStore, CardType, matStore } from "@/stores";
import { replayStart } from "@/ui/Replay";
import { cardStore, CardType, matStore, RoomStage, roomStore } from "@/stores";
import { replayStart } from "@/ui/Match/ReplayModal";
const TOKEN_SIZE = 13; // 每人场上最多就只可能有13个token
export default async (start: ygopro.StocGameMessage.MsgStart) => {
// 先初始化`matStore`
matStore.selfType = start.playerType;
const opponent =
start.playerType === ygopro.StocGameMessage.MsgStart.PlayerType.FirstStrike
start.playerType === PlayerType.FirstStrike ||
start.playerType === PlayerType.Observer
? 1
: 0;
// 通知房间页面决斗开始
// 这行在该函数中的位置不能随便放,否则可能会block住
roomStore.stage = RoomStage.DUEL_START;
matStore.initInfo.set(0, {
life: start.life1,
deckSize: start.deckSize1,
......@@ -73,7 +78,7 @@ export default async (start: ygopro.StocGameMessage.MsgStart) => {
// 设置自己的额外卡组,信息是在waitroom之中拿到的
cardStore
.at(ygopro.CardZone.EXTRA, 1 - opponent)
.forEach((card) => (card.code = myExtraDeckCodes.pop() ?? 0));
.forEach((card) => (card.code = window.myExtraDeckCodes?.pop() ?? 0));
if (matStore.isReplay) {
replayStart();
......
import { fetchStrings, ygopro } from "@/api";
import { fetchStrings, Region, ygopro } from "@/api";
import { sleep } from "@/infra";
import { matStore } from "@/stores";
import MsgToss = ygopro.StocGameMessage.MsgToss;
......@@ -7,16 +7,16 @@ export default async (toss: MsgToss) => {
const player = toss.player;
const tossType = toss.toss_type;
const prefix = fetchStrings("!system", matStore.isMe(player) ? 102 : 103);
const prefix = fetchStrings(Region.System, matStore.isMe(player) ? 102 : 103);
for (const x of toss.res) {
if (tossType === MsgToss.TossType.DICE) {
matStore.tossResult = prefix + fetchStrings("!system", 1624) + x;
matStore.tossResult = prefix + fetchStrings(Region.System, 1624) + x;
} else if (tossType === MsgToss.TossType.COIN) {
matStore.tossResult =
prefix +
fetchStrings("!system", 1623) +
fetchStrings("!system", 61 - x);
fetchStrings(Region.System, 1623) +
fetchStrings(Region.System, 61 - x);
} else {
console.log(`Unknown tossType = ${tossType}`);
}
......
import { fetchStrings, ygopro } from "@/api";
import { fetchStrings, Region, ygopro } from "@/api";
import { matStore } from "@/stores";
import { displayEndModal } from "@/ui/Duel/Message";
import MsgWin = ygopro.StocGameMessage.MsgWin;
......@@ -8,6 +8,6 @@ export default async (win: MsgWin) => {
await displayEndModal(
matStore.isMe(win_player),
fetchStrings("!victory", `0x${reason.toString(16)}`)
fetchStrings(Region.Victory, `0x${reason.toString(16)}`)
);
};
import { ygopro } from "@/api";
import { playerStore } from "@/stores";
import { roomStore } from "@/stores";
// FIXME: player0 不一定是当前玩家
// TODO: 这里设置的player可能顺序会反
export default function handleDeckCount(pb: ygopro.YgoStocMsg) {
const deckCount = pb.stoc_deck_count;
playerStore.player0.deckInfo = {
mainCnt: deckCount.meMain,
extraCnt: deckCount.meExtra,
sideCnt: deckCount.meSide,
};
const me = roomStore.getMePlayer();
const op = roomStore.getOpPlayer();
playerStore.player1.deckInfo = {
mainCnt: deckCount.opMain,
extraCnt: deckCount.opExtra,
sideCnt: deckCount.opSide,
};
if (me) {
me.deckInfo = {
mainSize: deckCount.meMain,
extraSize: deckCount.meExtra,
sideSize: deckCount.meSide,
};
}
if (op) {
op.deckInfo = {
mainSize: deckCount.opMain,
extraSize: deckCount.opExtra,
sideSize: deckCount.opSide,
};
}
}
import { ygopro } from "@/api";
import { moraStore } from "@/stores";
import { eventbus, Task } from "@/infra";
import { RoomStage, roomStore } from "@/stores";
export default function handleSelectHand(_: ygopro.YgoStocMsg) {
moraStore.selectHandAble = true;
roomStore.stage = RoomStage.HAND_SELECTING;
eventbus.emit(Task.Mora);
}
import { ygopro } from "@/api";
import { moraStore } from "@/stores";
import { eventbus, Task } from "@/infra";
import { RoomStage, roomStore } from "@/stores";
export default function handleSelectTp(_: ygopro.YgoStocMsg) {
moraStore.selectTpAble = true;
roomStore.stage = RoomStage.TP_SELECTING;
eventbus.emit(Task.Tp);
}
......@@ -13,6 +13,7 @@ import handleSelectHand from "./mora/selectHand";
import handleSelectTp from "./mora/selectTp";
import handleChat from "./room/chat";
import handleDuelStart from "./room/duelStart";
import handleHandResult from "./room/handResult";
import handleHsPlayerChange from "./room/hsPlayerChange";
import handleHsPlayerEnter from "./room/hsPlayerEnter";
import handleHsWatchChange from "./room/hsWatchChange";
......@@ -65,8 +66,7 @@ export default async function handleSocketMessage(e: MessageEvent) {
break;
}
case "stoc_hand_result": {
// TODO
console.log("TODO: handle STOC HandResult.");
handleHandResult(pb);
break;
}
......
......@@ -4,4 +4,5 @@ import { chatStore } from "@/stores";
export default function handleChat(pb: ygopro.YgoStocMsg) {
const chat = pb.stoc_chat;
chatStore.message = chat.msg;
chatStore.sender = chat.player;
}
import { ygopro } from "@/api";
import { moraStore } from "@/stores";
import { RoomStage, roomStore } from "@/stores";
export default function handleDuelStart(_pb: ygopro.YgoStocMsg) {
moraStore.duelStart = true;
roomStore.stage = RoomStage.MORA;
}
import { ygopro } from "@/api";
import { roomStore } from "@/stores";
export default function handResult(pb: ygopro.YgoStocMsg) {
const msg = pb.stoc_hand_result;
const me = roomStore.getMePlayer();
const op = roomStore.getOpPlayer();
if (me && op) {
me.moraResult = msg.meResult;
op.moraResult = msg.opResult;
} else if (roomStore.selfType !== ygopro.StocTypeChange.SelfType.OBSERVER) {
console.error("<HandResult>me or op is undefined");
}
}
import { ygopro } from "@/api";
import { playerStore } from "@/stores";
const READY_STATE = "ready";
const NO_READY_STATE = "not ready";
import { roomStore } from "@/stores";
export default function handleHsPlayerChange(pb: ygopro.YgoStocMsg) {
const change = pb.stoc_hs_player_change;
......@@ -17,36 +14,32 @@ export default function handleHsPlayerChange(pb: ygopro.YgoStocMsg) {
break;
}
case ygopro.StocHsPlayerChange.State.MOVE: {
console.log("Player " + change.pos + " moved to " + change.moved_pos);
let _src = change.pos;
let _dst = change.moved_pos;
console.log("Currently unsupport Move type of StocHsPlayerChange.");
// TODO
break;
}
case ygopro.StocHsPlayerChange.State.READY: {
playerStore[change.pos === 0 ? "player0" : "player1"].state =
READY_STATE;
// TODO: 这个分支可能有BUG,后面注意一下
console.info(
"<HsPlayerChange>Player " +
change.pos +
" moved to " +
change.moved_pos
);
roomStore.players[change.moved_pos] = roomStore.players[change.pos];
roomStore.players[change.pos] = undefined;
break;
}
case ygopro.StocHsPlayerChange.State.READY:
case ygopro.StocHsPlayerChange.State.NO_READY: {
playerStore[change.pos === 0 ? "player0" : "player1"].state =
NO_READY_STATE;
const player = roomStore.players[change.pos];
if (player) {
player.state = change.state;
}
break;
}
case ygopro.StocHsPlayerChange.State.LEAVE: {
playerStore[change.pos === 0 ? "player0" : "player1"] = {};
roomStore.players[change.pos] = undefined;
break;
}
case ygopro.StocHsPlayerChange.State.TO_OBSERVER: {
playerStore[change.pos === 0 ? "player0" : "player1"] = {}; // TODO: 有没有必要?
playerStore.observerCount += 1;
roomStore.players[change.pos] = undefined;
roomStore.observerCount += 1;
break;
}
default: {
......
import { ygopro } from "@/api";
import { playerStore } from "@/stores";
import { roomStore } from "@/stores";
export default function handleHsPlayerEnter(pb: ygopro.YgoStocMsg) {
const name = pb.stoc_hs_player_enter.name;
const pos = pb.stoc_hs_player_enter.pos;
if (pos > 1) {
console.log("Currently only supported 2v2 mode.");
const player = roomStore.players[pos];
if (player) {
player.name = name;
} else {
playerStore[pos === 0 ? "player0" : "player1"].name = name;
roomStore.players[pos] = {
name,
state: ygopro.StocHsPlayerChange.State.NO_READY,
};
}
}
import { ygopro } from "@/api";
import { playerStore } from "@/stores";
import { roomStore } from "@/stores";
export default function handleHsWatchChange(pb: ygopro.YgoStocMsg) {
const count = pb.stoc_hs_watch_change.count;
playerStore.observerCount = count;
roomStore.observerCount = count;
}
import { ygopro } from "@/api";
import { joinStore } from "@/stores";
import { roomStore } from "@/stores";
export default function handleJoinGame(pb: ygopro.YgoStocMsg) {
const _msg = pb.stoc_join_game;
// TODO
joinStore.value = true;
roomStore.joined = true;
}
import { ygopro } from "@/api";
import { playerStore } from "@/stores";
const NO_READY_STATE = "not ready";
import { roomStore } from "@/stores";
import SelfType = ygopro.StocTypeChange.SelfType;
export default function handleTypeChange(pb: ygopro.YgoStocMsg) {
const selfType = pb.stoc_type_change.self_type;
const assertHost = pb.stoc_type_change.is_host;
playerStore.isHost = assertHost;
playerStore.selfType = selfType;
roomStore.isHost = assertHost;
roomStore.selfType = selfType;
if (assertHost) {
switch (selfType) {
case ygopro.StocTypeChange.SelfType.PLAYER1: {
playerStore.player0.isHost = true;
playerStore.player1.isHost = false;
playerStore.player0.state = NO_READY_STATE;
break;
}
case ygopro.StocTypeChange.SelfType.PLAYER2: {
playerStore.player0.isHost = false;
playerStore.player1.isHost = true;
playerStore.player1.state = NO_READY_STATE;
break;
}
default: {
break;
switch (selfType) {
case SelfType.UNKNOWN: {
console.warn("<HandleTypeChange>selfType is UNKNOWN");
break;
}
case SelfType.OBSERVER: {
roomStore.players.forEach((player) => {
if (player) {
player.isMe = false;
}
});
break;
}
default: {
const player = roomStore.players[selfType - 1];
const state = ygopro.StocHsPlayerChange.State.NO_READY;
if (player) {
player.state = state;
player.isMe = true;
} else {
roomStore.players[selfType - 1] = { name: "?", state, isMe: true };
}
break;
}
}
}
import { proxy } from "valtio";
import { type NeosStore } from "./shared";
export interface User {
id: string;
username: string;
name: string;
email: string;
token: string;
external_id: number;
avatar_url: string;
}
class AccountStore implements NeosStore {
user?: User;
login(user: User) {
this.user = user;
}
logout() {
this.user = undefined;
}
reset(): void {
this.user = undefined;
}
}
export const accountStore = proxy(new AccountStore());
......@@ -3,7 +3,7 @@ import { proxy } from "valtio";
import { CardMeta, ygopro } from "@/api";
import type { Interactivity } from "./matStore/types";
import { NeosStore } from "./shared";
import { type NeosStore } from "./shared";
/**
* 场上某位置的状态
......
import { proxy } from "valtio";
import { NeosStore } from "./shared";
import { type NeosStore } from "./shared";
export interface ChatState extends NeosStore {
sender: number;
message: string;
}
export const chatStore = proxy<ChatState>({
sender: -1,
message: "",
reset() {
chatStore.message = "";
......
import { clear, createStore, del, set, values } from "idb-keyval";
import { proxy } from "valtio";
import { type NeosStore } from "./shared";
const IDB_NAME = "decks";
const deckIdb = createStore(IDB_NAME, IDB_NAME);
export interface IDeck {
deckName: string;
main: number[];
extra: number[];
side: number[];
}
export const deckStore = proxy({
decks: [] as IDeck[],
get(deckName: string) {
return deckStore.decks.find((deck) => deck.deckName === deckName);
},
async update(deckName: string, deck: IDeck): Promise<boolean> {
const index = deckStore.decks.findIndex(
(deck) => deck.deckName === deckName
);
if (index === -1) return false;
deckStore.decks[index] = deck;
await del(deckName, deckIdb); // 新的名字可能和旧的名字不一样,所以要删除旧的,再添加
await set(deck.deckName, deck, deckIdb);
return true;
},
async add(deck: IDeck): Promise<boolean> {
if (deckStore.decks.find((d) => d.deckName === deck.deckName)) return false;
deckStore.decks.push(deck);
await set(deck.deckName, deck, deckIdb);
return true;
},
async delete(deckName: string): Promise<boolean> {
const index = deckStore.decks.findIndex(
(deck) => deck.deckName === deckName
);
if (index === -1) return false;
deckStore.decks.splice(index, 1);
await del(deckName, deckIdb);
return true;
},
async initialize() {
deckStore.decks = await values<IDeck>(deckIdb);
if (!deckStore.decks.length) {
// 给玩家预设了几套卡组,一旦idb为空,就会给玩家添加这几套卡组
const PRESET_DECKS: Record<string, { default: Omit<IDeck, "deckName"> }> =
import.meta.glob("/neos-assets/structure-decks/*.ydk", {
eager: true,
});
for (const key in PRESET_DECKS) {
const deck = PRESET_DECKS[key].default;
const deckName =
key.split("/").pop()?.split(".").slice(0, -1).join(".") ??
"undefined"; // 从路径解析文件名
await deckStore.add({ ...deck, deckName });
}
}
},
async reset() {
deckStore.decks = [];
await clear(deckIdb);
},
}) satisfies NeosStore;
export * from "./accountStore";
export * from "./cardStore";
export * from "./chatStore";
export * from "./joinStore";
export * from "./deckStore";
export * from "./initStore";
export * from "./matStore";
export * from "./moraStore";
export * from "./placeStore";
export * from "./playerStore";
export * from "./replayStore";
export * from "./roomStore";
import { devtools } from "valtio/utils";
import { useEnv } from "@/hook";
import { accountStore } from "./accountStore";
import { cardStore } from "./cardStore";
import { chatStore } from "./chatStore";
import { joinStore } from "./joinStore";
import { deckStore } from "./deckStore";
import { initStore } from "./initStore";
import { matStore } from "./matStore";
import { moraStore } from "./moraStore";
import { placeStore } from "./placeStore";
import { playerStore } from "./playerStore";
import { replayStore } from "./replayStore";
import { roomStore } from "./roomStore";
const { DEV } = useEnv();
devtools(playerStore, { name: "player", enabled: true });
devtools(chatStore, { name: "chat", enabled: true });
devtools(joinStore, { name: "join", enabled: true });
devtools(moraStore, { name: "mora", enabled: true });
devtools(matStore, { name: "mat", enabled: true });
devtools(cardStore, { name: "card", enabled: true });
devtools(placeStore, { name: "place", enabled: true });
devtools(replayStore, { name: "replay", enabled: true });
devtools(chatStore, { name: "chat", enabled: DEV });
devtools(matStore, { name: "mat", enabled: DEV });
devtools(cardStore, { name: "card", enabled: DEV });
devtools(placeStore, { name: "place", enabled: DEV });
devtools(replayStore, { name: "replay", enabled: DEV });
devtools(accountStore, { name: "account", enabled: DEV });
devtools(roomStore, { name: "room", enabled: DEV });
devtools(deckStore, { name: "deck", enabled: DEV });
devtools(initStore, { name: "init", enabled: DEV });
// 重置所有`Store`
// 重置`Store`
export const resetUniverse = () => {
roomStore.reset();
cardStore.reset();
chatStore.reset();
joinStore.reset();
matStore.reset();
moraStore.reset();
placeStore.reset();
playerStore.reset();
replayStore.reset();
roomStore.reset();
};
import { proxy } from "valtio";
import { type NeosStore } from "./shared";
export const initStore = proxy({
sqlite: {
progress: 0, // 0 -> 1
},
decks: false,
i18n: false,
wasm: false,
// ...
reset() {},
} satisfies NeosStore);
import { proxy } from "valtio";
import { NeosStore } from "./shared";
export interface JoinState extends NeosStore {
value: boolean;
}
export const joinStore = proxy<JoinState>({
value: false,
reset() {
joinStore.value = false;
},
});
import type { ygopro } from "@/api";
import { Region, type ygopro } from "@/api";
import { DESCRIPTION_LIMIT, fetchStrings, getStrings } from "@/api";
import { fetchCard } from "@/api/cards";
import { cardStore } from "@/stores/cardStore";
......@@ -9,7 +9,7 @@ const { hint } = matStore;
export const fetchCommonHintMeta = (code: number) => {
hint.code = code;
hint.msg = fetchStrings("!system", code);
hint.msg = fetchStrings(Region.System, code);
};
export const fetchSelectHintMeta = async ({
......@@ -23,7 +23,7 @@ export const fetchSelectHintMeta = async ({
if (selectHintData > DESCRIPTION_LIMIT) {
// 针对`MSG_SELECT_PLACE`的特化逻辑
const cardMeta = await fetchCard(selectHintData);
selectHintMeta = fetchStrings("!system", 569).replace(
selectHintMeta = fetchStrings(Region.System, 569).replace(
"[%ls]",
cardMeta.text.name || "[?]"
);
......@@ -53,7 +53,7 @@ export const fetchEsHintMeta = async ({
const newOriginMsg =
typeof originMsg === "string"
? originMsg
: fetchStrings("!system", originMsg);
: fetchStrings(Region.System, originMsg);
const cardMeta = cardID ? await fetchCard(cardID) : undefined;
......
......@@ -3,7 +3,8 @@ import { proxy } from "valtio";
import { ygopro } from "@/api";
import type { InitInfo, MatState } from "./types";
import { type NeosStore } from "../shared";
import { ChainSetting, InitInfo, MatState } from "./types";
/**
* 根据controller判断是自己还是对方。
......@@ -25,9 +26,9 @@ export const isMe = (controller: number): boolean => {
// 自己是后攻
return controller === 1;
default:
// 目前不可能出现这种情况
console.error("judgeSelf error", controller, matStore.selfType);
return false;
// 自己是观战者
// 这里假设偶数方的玩家是自己
return controller % 2 == 0;
}
};
......@@ -53,7 +54,6 @@ const initInfo: MatState["initInfo"] = proxy({
const initialState: Omit<MatState, "reset"> = {
chains: [],
timeLimits: {
// 时间限制
me: -1,
......@@ -63,9 +63,7 @@ const initialState: Omit<MatState, "reset"> = {
matStore.timeLimits[getWhom(controller)] = time;
},
},
initInfo,
selfType: ygopro.StocTypeChange.SelfType.UNKNOWN,
hint: { code: -1 },
currentPlayer: -1,
......@@ -85,22 +83,28 @@ const initialState: Omit<MatState, "reset"> = {
matStore.handResults[getWhom(controller)] = result;
},
},
tossResult: undefined,
chainSetting: ChainSetting.CHAIN_SMART,
// methods
isMe,
};
/**
* 💡 决斗盘状态仓库,本文件核心,
* 具体介绍可以点进`MatState`去看
*/
export const matStore: MatState = proxy<MatState>({
...initialState,
reset() {
// const resetObj = _.cloneDeep(initialState);
// Object.keys(resetObj).forEach((key) => {
// // @ts-ignore
// matStore[key] = initialState[key];
// });
class MatStore implements MatState, NeosStore {
chains = initialState.chains;
chainSetting = initialState.chainSetting;
timeLimits = initialState.timeLimits;
initInfo = initialState.initInfo;
selfType = initialState.selfType;
hint = initialState.hint;
currentPlayer = initialState.currentPlayer;
phase = initialState.phase;
isReplay = initialState.isReplay;
unimplemented = initialState.unimplemented;
handResults = initialState.handResults;
tossResult = initialState.tossResult;
// methods
isMe = initialState.isMe;
reset(): void {
this.chains = [];
this.timeLimits.me = -1;
this.timeLimits.op = -1;
......@@ -119,8 +123,14 @@ export const matStore: MatState = proxy<MatState>({
this.unimplemented = 0;
this.handResults.me = 0;
this.handResults.op = 0;
},
});
}
}
/**
* 💡 决斗盘状态仓库,本文件核心,
* 具体介绍可以点进`MatState`去看
*/
export const matStore = proxy<MatStore>(new MatStore());
// @ts-ignore 挂到全局,便于调试
window.matStore = matStore;
import type { ygopro } from "@/api";
import { NeosStore } from "../shared";
// >>> play mat state >>>
export interface BothSide<T> {
......@@ -11,7 +9,7 @@ export interface BothSide<T> {
of: (controller: number) => T;
}
export interface MatState extends NeosStore {
export interface MatState {
selfType: number;
initInfo: BothSide<InitInfo> & {
......@@ -20,6 +18,8 @@ export interface MatState extends NeosStore {
chains: ygopro.CardLocation[]; // 连锁的卡片位置
chainSetting: ChainSetting; // 连锁类型
timeLimits: BothSide<number> & {
set: (controller: number, time: number) => void;
}; // 双方的时间限制
......@@ -104,4 +104,10 @@ export enum HandResult {
ROCK = 2,
PAPER = 3,
}
export enum ChainSetting {
CHAIN_ALL = 0, // 打开全部时点
CHAIN_IGNORE = 1, // 关闭连锁时点
CHAIN_SMART = 2, // 只打开关键时点
}
// <<< play mat state <<<
import { proxy } from "valtio";
import { NeosStore } from "./shared";
export interface MoraState extends NeosStore {
duelStart: boolean;
selectHandAble: boolean;
selectTpAble: boolean;
}
const initialState = {
duelStart: false,
selectHandAble: false,
selectTpAble: false,
};
export const moraStore = proxy<MoraState>({
...initialState,
reset() {
Object.keys(initialState).forEach((key) => {
// @ts-ignore
moraStore[key] = initialState[key];
});
},
});
......@@ -5,7 +5,7 @@ import { ygopro } from "@/api";
import { matStore } from "@/stores";
import type { Interactivity } from "./matStore/types";
import { NeosStore } from "./shared";
import { type NeosStore } from "./shared";
export type PlaceInteractivity =
| Interactivity<{
......
/* eslint valtio/avoid-this-in-proxy: 0 */
import { cloneDeep } from "lodash-es";
import { proxy } from "valtio";
import { ygopro } from "@/api";
import SelfType = ygopro.StocTypeChange.SelfType;
import { NeosStore } from "./shared";
export interface Player {
name?: string;
state?: string;
isHost?: boolean;
deckInfo?: deckInfo;
}
export interface deckInfo {
mainCnt: number;
extraCnt: number;
sideCnt: number;
}
export interface PlayerState extends NeosStore {
player0: Player;
player1: Player;
observerCount: number;
isHost: boolean;
selfType: SelfType;
getMePlayer: () => Player;
getOpPlayer: () => Player;
}
const initialState = {
player0: {},
player1: {},
observerCount: 0,
isHost: false,
selfType: SelfType.UNKNOWN,
};
export const playerStore = proxy<PlayerState>({
...initialState,
getMePlayer() {
if (this.selfType === SelfType.PLAYER1) return this.player0;
return this.player1;
},
getOpPlayer() {
if (this.selfType === SelfType.PLAYER1) return this.player1;
return this.player0;
},
reset() {
const resetObj = cloneDeep(initialState);
Object.keys(resetObj).forEach((key) => {
// @ts-ignore
playerStore[key] = resetObj[key];
});
},
});
......@@ -2,7 +2,7 @@ import { proxy, ref } from "valtio";
import { YgoProPacket } from "@/api/ocgcore/ocgAdapter/packet";
import { NeosStore } from "./shared";
import { type NeosStore } from "./shared";
// 对局中每一次状态改变的记录
interface ReplaySpot {
......
// 等待房间页面的状态管理
import { proxy } from "valtio";
import { ygopro } from "@/api";
import StocHsPlayerChange = ygopro.StocHsPlayerChange;
import SelfType = ygopro.StocTypeChange.SelfType;
import HandType = ygopro.HandType;
import { type NeosStore } from "./shared";
export interface Player {
name: string; // 玩家的昵称
state: StocHsPlayerChange.State; // 玩家当前状态
moraResult?: HandType; // 玩家的猜拳结果
isMe?: boolean;
deckInfo?: DeckInfo;
}
// 卡组的数量信息,在猜拳阶段由后端传入
interface DeckInfo {
mainSize: number;
extraSize: number;
sideSize: number;
}
// 房间内当前的阶段
export enum RoomStage {
WAITING = 0, // 正在准备
MORA = 1, // 进入猜拳阶段,但还未选择猜拳
HAND_SELECTING = 2, // 正在选择猜拳
HAND_SELECTED = 3, // 选择完猜拳,等待后端返回结果
TP_SELECTING = 4, // 正在选边
TP_SELECTED = 5, // 选边完成,等待后端返回结果
DUEL_START = 6, // 决斗开始
}
class RoomStore implements NeosStore {
joined: boolean = false; // 是否已经加入房间
players: (Player | undefined)[] = Array.from({ length: 4 }).map(
(_) => undefined
); // 进入房间的玩家列表
observerCount: number = 0; // 观战者数量
isHost: boolean = false; // 当前玩家是否是房主
selfType: SelfType = 0; // 当前玩家的类型
stage: RoomStage = RoomStage.WAITING;
getMePlayer() {
return this.players.find((player) => player?.isMe);
}
getOpPlayer() {
return this.players.find((player) => player !== undefined && !player.isMe);
}
reset(): void {
this.joined = false;
this.players = [];
this.observerCount = 0;
this.isHost = false;
this.stage = RoomStage.WAITING;
}
}
export const roomStore = proxy<RoomStore>(new RoomStore());
......@@ -2,4 +2,5 @@
// 用于统一管理状态的初始化和重置
export interface NeosStore {
reset(): void;
[key: string]: any;
}
.link {
font-size: 24px;
font-family: 'FontAwesome';
line-height: 24px;
&-github:before {
content: "\f09b";
}
&-github:hover {
color: #E2EEF9;
}
&-twitter:before {
content: "\f099";
}
&-twitter:hover {
color: #00ACEE;
}
&-google:before {
content: "\f1a0";
}
&-google:hover {
color: #E04006;
}
&-facebook:before {
content: "\f082";
}
&-facebook:hover {
color: #4267B2;
}
}
.btn {
outline: none;
border: 0;
padding: 0;
overflow: hidden;
transform: translate(0, 0);
background: transparent;
}
button {
overflow: visible;
cursor: pointer;
}
//=========================================================
// Header
//---------------------------------------------------------
.header {
padding: 10px 0;
height: 60px;
overflow: hidden;
line-height: 40px;
}
.header__title {
float: left;
font-size: 14px;
font-weight: 400;
text-rendering: auto;
transform: translate(0,0);
&:before {
content: "\f111";
padding-right: 5px;
color: #fff;
font-family: 'FontAwesome';
line-height: 20px;
}
}
.header__actions {
float: right;
padding: 8px 0;
line-height: 24px;
li {
float: left;
list-style: none;
&:last-child {
margin-left: 12px;
padding-left: 12px;
border-left: 1px solid #333;
}
&:first-child {
border: none;
}
}
.btn {
display: block;
margin: 0;
color: #999;
font-size: 14px;
line-height: 24px;
}
.link {
display: block;
color: inherit;
font-size: 14px;
text-decoration: none;
text-rendering: auto;
transform: translate(0,0);
}
.link--github {
font-size: 24px;
&:before {
content: "\f09b";
font-family: 'FontAwesome';
line-height: 24px;
}
&:hover {
color: #E2EEF9;
}
}
}
#login {
width: 280px;
}
#login form span {
background-color: #363b41;
border-radius: 3px 0px 0px 3px;
color: #606468;
display: block;
float: left;
height: 50px;
line-height: 50px;
text-align: center;
width: 50px;
}
#login form input {
height: 50px;
}
#login form input[type="text"], input[type="password"] {
background-color: #3b4148;
border-radius: 0px 3px 3px 0px;
color: #606468;
margin-bottom: 1em;
padding: 0 16px;
width: 230px;
}
#login form input[type="submit"] {
border-radius: 3px;
-moz-border-radius: 3px;
-webkit-border-radius: 3px;
background-color: #ea4c88;
color: #eee;
font-weight: bold;
margin-bottom: 10px;
text-transform: uppercase;
width: 280px;
}
#login form input[type="submit"]:hover {
background-color: #d44179;
}
#login > p {
text-align: center;
}
#login > p span {
padding-left: 5px;
}
//=========================================================
// SignIn
//---------------------------------------------------------
.sign-in {
margin-top: 90px;
max-width: 300px;
&__actions {
margin-top: 40px;
li {
float: left;
list-style: none;
width: 25%;
text-align: center;
a {
cursor: pointer;
}
}
}
}
.sign-up {
&__actions {
a{
color: #eee;
padding-right: 5px;
}
p {
text-align: center;
}
}
}
// ref: https://github.com/jvcjunior/login-react-redux
// thanks!
@charset "utf-8";
@import url("https://fonts.googleapis.com/css2?family=Electrolize&display=swap");
ol,
ul {
list-style: none;
}
blockquote,
q {
quotes: none;
}
blockquote:before,
blockquote:after,
q:before,
q:after {
content: "";
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
#root {
margin: 0 auto;
text-align: center;
}
@import url("https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.1/css/all.min.css"),
"commom", "header", "login-form", "sign-in";
@import url("https://fonts.font.im/css2?family=Electrolize&display=swap");
body {
color-scheme: light dark;
color-scheme: dark;
color: rgba(255, 255, 255, 0.87);
background-color: #141414;
// font: 87.5%/1.5em "Open Sans", sans-serif;
background: #0f131e;
font-size: 14px;
display: flex;
margin: 0;
place-items: center;
min-width: 320px;
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
a {
text-decoration: none;
}
input {
border: none;
font-family: "Open Sans", Arial, sans-serif;
font-size: 14px;
line-height: 1.5em;
padding: 0;
-webkit-appearance: none;
}
p {
line-height: 1.5em;
}
.clearfix {
*zoom: 1;
&:before,
&:after {
content: " ";
display: table;
}
&:after {
clear: both;
font-family: var(--theme-font);
--theme-font: "Electrolize", sans-serif;
--header-height: 56px;
#root {
height: 100%;
margin: 0 auto;
width: 100%;
}
}
.container {
// left: 50%;
// position: fixed;
// top: 50%;
// transform: translate(-50%, -50%);
margin: 0 auto;
width: 100%;
max-width: 300px;
margin-top: 200px;
}
.g-row {
margin: 0 auto;
width: 100%;
max-width: 1000px;
}
img {
user-select: none;
-webkit-user-drag: none;
display: block; // 取消默认的4px下边距
}
div,
p,
section,
span,
image,
img {
* {
box-sizing: border-box;
}
body {
--theme-font: "Electrolize", sans-serif;
}
// 全局修改模块内的样式,如 antd 样式、OverlayScrollbars 样式
.ant-btn {
display: flex;
align-items: center;
justify-content: center;
}
// 点击按钮产生的波浪扩散效果
.ant-wave {
color: hsla(0, 0%, 100%, 0.3);
}
// OverlayScrollbars 样式
.os-theme-light {
--os-handle-bg: rgba(255, 255, 255, 0.22);
--os-handle-bg-hover: rgba(255, 255, 255, 0.44);
--os-handle-bg-active: rgba(255, 255, 255, 0.66);
}
.ant-app {
display: flex;
height: 100%;
flex-direction: column;
}
.ant-modal-confirm-content {
max-width: 100% !important;
}
.ant-select-dropdown {
backdrop-filter: blur(10px);
}
@mixin scrollbar {
overflow-y: overlay;
&::-webkit-scrollbar {
background: transparent;
width: 5px;
}
&::-webkit-scrollbar-track {
background: transparent;
width: 5px;
}
&::-webkit-scrollbar-thumb {
background: rgba(136, 136, 136, 0.417);
}
&::-webkit-scrollbar-thumb:hover {
background: rgba(136, 136, 136, 0.6);
}
&::-webkit-scrollbar-thumb:active {
background: rgba(136, 136, 136, 0.8);
}
}
@mixin noise-bg($opacity: 0.3) {
&::before {
content: "";
position: absolute;
left: 0;
top: 0;
height: 100%;
max-height: 100%;
width: 100%;
background-image: url("/neos-assets/noise-light.webp");
opacity: $opacity;
}
}
@use "/src/styles/utils.scss";
.detail {
z-index: 10;
position: absolute;
left: -100%;
top: 0;
width: 100%;
height: 100%;
padding: 0 10px 20px 10px;
transition: 0.2s;
}
.detail.open {
left: 0;
}
.container {
height: 100%;
background-color: hsla(0, 0%, 100%, 0.1);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.2), 0 0 30px 0 #ffffff54;
backdrop-filter: blur(20px);
@include utils.noise-bg;
padding: 15px;
display: flex;
flex-direction: column;
}
.btn-close {
position: absolute;
right: 5px;
top: 5px;
}
.card {
--width: 160px;
width: var(--width);
height: calc(var(--width) / var(--card-ratio));
flex-shrink: 0;
border-radius: 4px;
overflow: hidden;
box-shadow: 0px 14px 20px -5px rgba(0, 0, 0, 0.3);
}
.title {
font-size: 16px;
font-family: var(--theme-font);
margin: 20px 0 15px;
display: flex;
justify-content: space-between;
// color: rgba(255, 255, 255, 0.45);
}
.content {
// font-size: ;
color: white;
}
import { Button, Descriptions } from "antd";
import classNames from "classnames";
import { useEffect, useMemo, useState } from "react";
import { type CardMeta, fetchCard, fetchStrings, Region } from "@/api";
import {
Attribute2StringCodeMap,
extraCardTypes,
isLinkMonster,
isMonster,
Race2StringCodeMap,
Type2StringCodeMap,
} from "@/common";
import { CardEffectText, IconFont, ScrollableArea, YgoCard } from "@/ui/Shared";
import styles from "./CardDetail.module.scss";
export const CardDetail: React.FC<{
code: number;
open: boolean;
onClose: () => void;
}> = ({ code, open, onClose }) => {
const [card, setCard] = useState<CardMeta>();
useEffect(() => {
fetchCard(code).then(setCard);
}, [code]);
const cardType = useMemo(
() =>
extraCardTypes(card?.data.type ?? 0)
.map((t) => fetchStrings(Region.System, Type2StringCodeMap.get(t) || 0))
.join(" / "),
[card?.data.type]
);
return (
<div className={classNames(styles.detail, { [styles.open]: open })}>
<div className={styles.container}>
<Button
className={styles["btn-close"]}
icon={<IconFont type="icon-side-bar-fill" size={16} />}
type="text"
onClick={onClose}
/>
<YgoCard className={styles.card} code={code} />
<div className={styles.title}>
<span>{card?.text.name}</span>
{/* <Avatar size={22}>光</Avatar> */}
</div>
<ScrollableArea>
<Descriptions layout="vertical" size="small">
{card?.data.level && (
<Descriptions.Item label="等级">
{card?.data.level}
</Descriptions.Item>
)}
<Descriptions.Item label="类型" span={2}>
{cardType}
</Descriptions.Item>
{card?.data.attribute && (
<Descriptions.Item label="属性">
{fetchStrings(
Region.System,
Attribute2StringCodeMap.get(card?.data.attribute ?? 0) || 0
)}
</Descriptions.Item>
)}
{card?.data.race && (
<Descriptions.Item label="种族" span={2}>
{fetchStrings(
Region.System,
Race2StringCodeMap.get(card?.data.race ?? 0) || 0
)}
</Descriptions.Item>
)}
{isMonster(card?.data.type ?? 0) && (
<>
<Descriptions.Item label="攻击力">2000</Descriptions.Item>
{!isLinkMonster(card?.data.type ?? 0) && (
<Descriptions.Item label="守备力">0</Descriptions.Item>
)}
{card?.data.lscale && (
<Descriptions.Item label="灵摆刻度">
{card.data.lscale} - {card.data.rscale}
</Descriptions.Item>
)}
</>
)}
</Descriptions>
<Descriptions layout="vertical" size="small">
<Descriptions.Item label="卡片效果" span={3}>
<CardEffectText desc={card?.text.desc} />
</Descriptions.Item>
</Descriptions>
</ScrollableArea>
</div>
</div>
);
};
.deck-select {
display: flex;
flex-direction: column;
gap: 4px;
.item {
height: 40px;
padding: 0 20px;
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
cursor: pointer;
.hover,
.selected {
position: absolute;
width: auto;
height: auto;
top: 0;
bottom: 0;
--padding-x: 5px;
left: var(--padding-x);
right: var(--padding-x);
border-radius: 4px;
transition: 0.2s;
}
.hover {
background-color: rgba(255, 255, 255, 0.15);
opacity: 0;
}
.selected {
background-color: rgba(255, 255, 255, 0.2);
}
.btns {
transition: 0.2s;
opacity: 0;
display: flex;
gap: 4px;
}
&:hover {
.hover,
.btns {
opacity: 1;
}
}
}
}
.btn-add {
position: absolute;
bottom: 40px;
left: 0;
right: 0;
margin: auto;
background-color: rgba(0, 168, 202, 0.451);
box-shadow: 0 0 20px 0 rgba(0, 221, 255, 0.5);
&:hover {
background-color: rgba(0, 168, 202, 0.451) !important;
transform: scale(1.1);
filter: brightness(1.2);
}
}
import {
DeleteOutlined,
DownloadOutlined,
FileAddOutlined,
PlusOutlined,
UploadOutlined,
} from "@ant-design/icons";
import {
App,
Button,
Dropdown,
Input,
MenuProps,
Upload,
UploadProps,
} from "antd";
import React, { useRef, useState } from "react";
import YGOProDeck from "ygopro-deck-encode";
import { deckStore, IDeck } from "@/stores";
import styles from "./DeckSelect.module.scss";
export const DeckSelect: React.FC<{
decks: readonly { deckName: string }[];
selected: string;
onSelect: (deckName: string) => void;
onDelete: (deckName: string) => void;
onDownload: (deckName: string) => void;
onAdd: () => void;
}> = ({ decks, selected, onSelect, onDelete, onDownload, onAdd }) => {
const newDeck = useRef<IDeck | null>(null);
const newDeckName = useRef<string | null>(null);
const { modal } = App.useApp();
const modalProps = { width: 500, centered: true, icon: null };
const showCreateModal = () => {
const { destroy } = modal.info({
...modalProps,
title: "请输入新卡组名称",
content: (
<Input
onChange={(e) => {
newDeckName.current = e.target.value;
}}
/>
),
okText: "新建",
onCancel: () => destroy(),
onOk: async () => {
if (newDeckName.current && newDeckName.current !== "") {
await deckStore.add({
deckName: newDeckName.current,
main: [],
extra: [],
side: [],
});
}
},
});
};
const showUploadModal = () => {
const { destroy } = modal.info({
...modalProps,
title: "请上传YDK文件",
content: (
<DeckUploader
onLoaded={(deck) => {
newDeck.current = deck;
}}
/>
),
okText: "上传",
onCancel: () => destroy(),
onOk: async () => {
if (newDeck.current) {
await deckStore.add(newDeck.current);
}
},
});
};
const items: MenuProps["items"] = [
{
key: "1",
label: "新建卡组",
icon: <PlusOutlined />,
onClick: showCreateModal,
},
{
key: "2",
label: "导入卡组",
icon: <FileAddOutlined />,
onClick: showUploadModal,
},
];
return (
<>
<div className={styles["deck-select"]}>
{decks.map(({ deckName }) => (
<div
key={deckName}
className={styles.item}
onClick={() => onSelect(deckName)}
>
<div className={styles.hover} />
{selected === deckName && <div className={styles.selected} />}
<span>{deckName}</span>
<div className={styles.btns}>
<Button
icon={<DeleteOutlined />}
type="text"
size="small"
onClick={cancelBubble(() => onDelete(deckName))}
/>
<Button
icon={<DownloadOutlined />}
type="text"
size="small"
onClick={cancelBubble(() => onDownload(deckName))}
/>
</div>
</div>
))}
</div>
<Dropdown menu={{ items }} placement="top" arrow trigger={["click"]}>
<Button
className={styles["btn-add"]}
icon={<PlusOutlined />}
shape="circle"
type="text"
onClick={onAdd}
size="large"
/>
</Dropdown>
</>
);
};
const DeckUploader: React.FC<{ onLoaded: (deck: IDeck) => void }> = ({
onLoaded,
}) => {
const [uploadState, setUploadState] = useState("");
const uploadProps: UploadProps = {
name: "file",
onChange(info) {
if (uploadState != "ERROR") {
info.file.status = "done";
}
},
beforeUpload(file, _) {
const reader = new FileReader();
reader.readAsText(file);
reader.onload = (e) => {
const ydk = e.target?.result as string;
const deck = YGOProDeck.fromYdkString(ydk);
if (
!(
deck.main.length === 0 &&
deck.extra.length === 0 &&
deck.side.length === 0
)
) {
// YDK解析成功
onLoaded({ deckName: file.name.replace(/\.ydk/g, ""), ...deck });
} else {
alert(`${file.name}解析失败,请检查格式是否正确。`);
setUploadState("ERROR");
}
};
},
};
return (
<Upload {...uploadProps}>
<Button icon={<UploadOutlined />}>点击上传</Button>
</Upload>
);
};
/** 阻止事件冒泡 */
const cancelBubble =
<T,>(fn: (e: React.SyntheticEvent) => T) =>
(e: React.SyntheticEvent) => {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
return fn(e);
};
.title {
text-align: center;
font-size: 24px;
font-weight: bold;
margin: 12px 0 32px;
}
.item {
display: flex;
align-items: center;
.item-name {
display: flex;
gap: 4px;
flex: 1;
flex-basis: 80px;
vertical-align: middle;
}
}
.form {
display: flex;
flex-direction: column;
gap: 20px;
}
.btns {
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
padding: 50px 0 10px;
& > button {
width: 220px;
border-radius: 3px;
}
}
.number {
width: 100%;
display: flex;
& > * {
flex: 1;
}
.divider {
flex: 0;
flex-basis: 32px;
text-align: center;
line-height: 30px;
}
}
import { InfoCircleFilled } from "@ant-design/icons";
import {
Button,
InputNumber,
type InputNumberProps,
Select,
Tooltip,
} from "antd";
import { useState } from "react";
import { fetchStrings, Region } from "@/api";
import {
Attribute2StringCodeMap,
Race2StringCodeMap,
Type2StringCodeMap,
} from "@/common";
import { FtsConditions } from "@/middleware/sqlite/fts";
import styles from "./Filter.module.scss";
const levels = Array.from({ length: 12 }, (_, index) => ({
value: index + 1,
label: (index + 1).toString(),
}));
const lscales = Array.from({ length: 12 }, (_, index) => ({
value: index + 1,
label: (index + 1).toString(),
}));
export const Filter: React.FC<{
conditions: FtsConditions;
onConfirm: (newConditons: FtsConditions) => void;
onCancel: () => void;
}> = ({ conditions, onConfirm, onCancel }) => {
const [newConditions, setNewConditions] = useState<FtsConditions>(conditions);
const handleSelectChange =
<T extends keyof FtsConditions>(key: T) =>
(value: FtsConditions[T]) => {
setNewConditions((prev) => ({
...prev,
[key]: value,
}));
};
const genOptions = (map: Map<number, number>) =>
Array.from(map.entries()).map(([key, value]) => ({
value: key,
label: fetchStrings(Region.System, value),
}));
const T = [
[genOptions(Attribute2StringCodeMap), "属性", "attributes"],
[genOptions(Race2StringCodeMap), "种族", "races"],
[genOptions(Type2StringCodeMap), "类型", "types"],
[levels, "星级", "levels"],
[lscales, "灵摆刻度", "lscales"],
] as const;
const handleInputNumberChange =
(attibute: "atk" | "def", index: "min" | "max") => (value: any) => {
setNewConditions((prev) => ({
...prev,
[attibute]: {
...prev[attibute],
[index]: value,
},
}));
};
return (
<>
<div className={styles.title}>卡片筛选</div>
<div className={styles.form}>
{T.map(([options, title, key]) => (
<Item title={title} key={key}>
<CustomSelect
options={options}
defaultValue={conditions[key]}
onChange={handleSelectChange(key)}
/>
</Item>
))}
<Item title="攻击力" showTip>
<div className={styles.number}>
<CustomInputNumber
placeholder="最小值"
onChange={handleInputNumberChange("atk", "min")}
value={newConditions.atk.min}
/>
<span className={styles.divider}>~</span>
<CustomInputNumber
placeholder="最大值"
onChange={handleInputNumberChange("atk", "max")}
value={newConditions.atk.max}
/>
</div>
</Item>
<Item title="守备力" showTip>
<div className={styles.number}>
<CustomInputNumber
placeholder="最小值"
onChange={handleInputNumberChange("def", "min")}
value={newConditions.def.min}
/>
<span className={styles.divider}>~</span>
<CustomInputNumber
placeholder="最大值"
onChange={handleInputNumberChange("def", "max")}
value={newConditions.def.max}
/>
</div>
</Item>
</div>
<div className={styles.btns}>
<Button
type="primary"
onClick={() => {
onConfirm(newConditions);
console.log(newConditions);
}}
>
确定
</Button>
<Button type="text" onClick={onCancel}>
&nbsp;
</Button>
</div>
</>
);
};
/** 只支持输入整数 */
const CustomInputNumber = (props: InputNumberProps) => (
<InputNumber
{...props}
formatter={(value) => (value !== undefined ? String(value) : "")}
parser={(value = "") => {
const parsedValue = value.replace(/[^\d-]/g, ""); // 允许数字和负号
if (parsedValue === "-") return "-"; // 单独的负号允许通过
return parsedValue;
}}
min={-2}
max={1000000}
step={100}
/>
);
const CustomSelect: React.FC<{
options: {
label: string;
value: number;
}[];
defaultValue: number[];
onChange: (values: number[]) => void;
}> = ({ options, defaultValue, onChange }) => {
return (
<Select
mode="multiple"
allowClear
style={{ width: "100%" }}
placeholder="请选择"
options={options}
defaultValue={defaultValue}
onChange={onChange}
/>
);
};
const Item: React.FC<
React.PropsWithChildren<{ title: string; showTip?: boolean }>
> = ({ title, children, showTip = false }) => (
<div className={styles.item}>
<div className={styles["item-name"]}>
{title}
{showTip && (
<Tooltip title="若要输入 ? 的攻击/守备,请输入 -2">
<InfoCircleFilled style={{ fontSize: 10 }} />
</Tooltip>
)}
</div>
{children}
</div>
);
@use "/src/styles/utils.scss";
.layout {
position: fixed;
left: 0;
top: var(--header-height);
height: calc(100% - var(--header-height));
display: flex;
}
.sider {
--sider-width: 300px;
width: var(--sider-width);
flex: 0 0 var(--sider-width);
background: transparent !important;
position: relative;
.deck-select-container {
max-height: 100%;
min-height: 100%;
padding-bottom: 1rem;
}
}
.content {
display: flex;
flex: 1;
padding-bottom: 0;
padding-right: 1rem;
.deck {
width: 660px;
}
.select {
flex: 1;
.select-btns {
padding: 5px 10px;
display: flex;
gap: 8px;
}
}
}
.container {
height: calc(100% - 20px);
border: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
flex-direction: column;
& > *:not(:last-of-type) {
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.title {
height: 44px;
flex: 0 0 44px;
justify-content: space-between;
}
.deck-zone {
display: flex;
flex-direction: column;
height: 100%;
}
.main,
.extra,
.side {
transition: 0.2s;
position: relative;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding: 0.75rem;
&.over {
background-color: hsla(0, 0%, 100%, 0.05);
}
&.not-allow-to-drop {
background-color: rgba(255, 0, 0, 0.15);
cursor: not-allowed;
}
}
.main {
flex: 3;
}
.extra,
.side {
flex: 1;
}
.card-continer {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 5px;
}
background-color: hsla(0, 0%, 100%, 0.05);
backdrop-filter: blur(5px);
}
.deck .container {
border-radius: var(--border-radius) 0 0 var(--border-radius);
}
.select .container {
border-left: none;
border-radius: 0 var(--border-radius) var(--border-radius) 0;
}
.card {
cursor: move;
width: 100%;
background-color: rgba(255, 255, 255, 0.1);
aspect-ratio: var(--card-ratio);
position: relative;
background-size: contain;
content-visibility: auto;
.cardname {
font-size: 12px;
position: absolute;
padding: 5px;
top: 0;
bottom: 0;
max-height: 100%;
margin: auto;
left: 0;
height: fit-content;
width: 100%;
text-align: center;
line-height: 1.75em;
overflow: hidden; //超出的文本隐藏
text-overflow: ellipsis; //溢出用省略号显示
}
.cardcover {
position: relative;
// z-index: 1;
}
}
.search-cards-container {
height: 100%;
.search-cards {
--card-width: 80px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--card-width), 1fr));
padding: 0.75rem;
gap: 10px;
}
}
.search-count {
font-size: 11px;
}
.empty {
gap: 20px;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.editing-zone-name {
position: absolute;
right: 0;
bottom: 0;
background-color: #212332;
color: hsla(0, 0%, 100%, 0.3);
font-size: 12px;
padding: 2px 6px;
font-family: var(--theme-font);
user-select: none;
}
This diff is collapsed.
import { proxy } from "valtio";
import { type CardMeta } from "@/api";
import { isExtraDeckCard, isToken } from "@/common";
import { compareCards, type EditingDeck, type Type } from "./utils";
export const editDeckStore = proxy({
deckName: "",
main: [] as CardMeta[],
extra: [] as CardMeta[],
side: [] as CardMeta[],
// 标脏
edited: false,
// 方法
add(type: Type, card: CardMeta) {
editDeckStore[type].push(card);
editDeckStore[type].sort(compareCards);
editDeckStore.edited = true;
},
remove(type: Type, card: CardMeta) {
const index = editDeckStore[type].findIndex((item) => item.id === card.id);
if (index !== -1) {
editDeckStore[type].splice(index, 1);
editDeckStore.edited = true;
}
},
set(deck: EditingDeck) {
editDeckStore.deckName = deck.deckName;
editDeckStore.main = deck.main.sort(compareCards);
editDeckStore.extra = deck.extra.sort(compareCards);
editDeckStore.side = deck.side.sort(compareCards);
editDeckStore.edited = false;
},
clear() {
editDeckStore.main = [];
editDeckStore.extra = [];
editDeckStore.side = [];
editDeckStore.edited = true;
},
/** 一张卡能不能放入某个区 */
canAdd(card: CardMeta, type: Type): { result: boolean; reason: string } {
let result = true,
reason = "";
const initialCards = editDeckStore[type];
// 如果是衍生物,则不能添加
if (isToken(card.data.type ?? 0)) {
result = false;
reason = "不能添加衍生物";
}
// 超出数量,则不能添加
const countLimit = type === "main" ? 60 : 15;
if (initialCards.length >= countLimit) {
result = false;
reason = `超过 ${countLimit} 张的上限`;
}
// 接着需要检查卡的种类
if (
(type === "extra" && !isExtraDeckCard(card.data.type ?? 0)) ||
(type === "main" && isExtraDeckCard(card.data.type ?? 0))
) {
result = false;
reason = "卡片种类不符合";
}
// 同名卡不超过三张
const maxSameCard = 3; // TODO: 禁卡表
const sameCardCount = initialCards.filter((c) => c.id === card.id).length;
if (sameCardCount >= maxSameCard) {
result = false;
reason = `超过同名卡 ${maxSameCard} 张的上限`;
}
return { result, reason };
},
}) satisfies EditingDeck;
import { type CardMeta, fetchCard } from "@/api";
import { tellCardBasicType } from "@/common";
import { type IDeck } from "@/stores";
export type Type = "main" | "extra" | "side";
/** 用在卡组编辑 */
export interface EditingDeck {
deckName: string;
main: CardMeta[];
extra: CardMeta[];
side: CardMeta[];
}
export const iDeckToEditingDeck = async (
ideck: IDeck
): Promise<EditingDeck> => ({
deckName: ideck.deckName,
main: await Promise.all(ideck.main.map(fetchCard)),
extra: await Promise.all(ideck.extra.map(fetchCard)),
side: await Promise.all(ideck.side.map(fetchCard)),
});
export const editingDeckToIDeck = (deck: EditingDeck): IDeck => ({
deckName: deck.deckName,
main: deck.main.map((card) => card.id),
extra: deck.extra.map((card) => card.id),
side: deck.side.map((card) => card.id),
});
/** 卡组内部排序,给array.sort用 */
export const compareCards = (a: CardMeta, b: CardMeta): number => {
const aType = tellCardBasicType(a.data.type ?? 0);
const bType = tellCardBasicType(b.data.type ?? 0);
if (aType !== bType) return aType - bType;
return a.id - b.id;
};
/** 下载卡组YDK文件 **/
export function downloadDeckAsYDK(deck: IDeck) {
const lines: string[] = [];
lines.push("#created by neos");
lines.push("#main");
lines.push(...deck.main.map((cardId) => cardId.toString()));
lines.push("#extra");
lines.push(...deck.extra.map((cardId) => cardId.toString()));
lines.push("!side");
lines.push(...deck.side.map((cardId) => cardId.toString()));
const text = lines.join("\n");
const blob = new Blob([text], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = deck.deckName + ".ydk";
a.click();
URL.revokeObjectURL(url);
}
......@@ -15,11 +15,12 @@ import {
SortCardModal,
YesNoModal,
} from "./Message";
import { LifeBar, Mat, Menu } from "./PlayMat";
import { LifeBar, Mat, Menu, Underlying } from "./PlayMat";
const NeosDuel = () => {
export const Component: React.FC = () => {
return (
<>
<Underlying />
<SelectActionsModal />
<Alert />
<Menu />
......@@ -39,5 +40,4 @@ const NeosDuel = () => {
</>
);
};
export default NeosDuel;
Component.displayName = "NeosDuel";
......@@ -25,7 +25,7 @@ export const Alert = () => {
afterClose={() => {
// 发送投降信号
sendSurrender();
navigate("/");
navigate("/match");
}}
/>
) : (
......
.desc {
line-height: 1.6;
font-size: 14px;
font-family: var(--theme-font);
max-height: calc(100% - 237px);
overflow-y: overlay;
&:hover {
&::-webkit-scrollbar-thumb {
background: #535353;
}
}
& > div {
margin-bottom: 6px;
}
&::-webkit-scrollbar {
/*滚动条整体样式*/
width: 6px; /*高宽分别对应横竖滚动条的尺寸*/
height: 1px;
}
&::-webkit-scrollbar-thumb {
/*滚动条里面小方块*/
border-radius: 10px;
background: #5353533b;
cursor: pointer;
}
.maro-item {
display: flex;
......
......@@ -3,7 +3,7 @@ import { Divider, Drawer, Space, Tag } from "antd";
import React from "react";
import { proxy, useSnapshot } from "valtio";
import { type CardMeta, fetchStrings } from "@/api";
import { type CardMeta, fetchStrings, Region } from "@/api";
import { YgoCard } from "@/ui/Shared";
import {
......@@ -99,13 +99,16 @@ const AttLine = (props: {
attribute?: number;
}) => {
const race = props.race
? fetchStrings("!system", Race2StringCodeMap.get(props.race) || 0)
? fetchStrings(Region.System, Race2StringCodeMap.get(props.race) || 0)
: undefined;
const attribute = props.attribute
? fetchStrings("!system", Attribute2StringCodeMap.get(props.attribute) || 0)
? fetchStrings(
Region.System,
Attribute2StringCodeMap.get(props.attribute) || 0
)
: undefined;
const types = props.types
.map((t) => fetchStrings("!system", Type2StringCodeMap.get(t) || 0))
.map((t) => fetchStrings(Region.System, Type2StringCodeMap.get(t) || 0))
.join("/");
return (
<div className={styles.attline}>
......@@ -135,7 +138,7 @@ const _CounterLine = (props: { counters: { [type: number]: number } }) => {
for (const counterType in props.counters) {
const count = props.counters[counterType];
if (count > 0) {
const counterStr = fetchStrings("!counter", `0x${counterType}`);
const counterStr = fetchStrings(Region.Counter, `0x${counterType}`);
counters.push(`${counterStr}: ${count}`);
}
}
......
......@@ -4,7 +4,7 @@ import { Button, Card, Col, InputNumber, Row } from "antd";
import React, { useState } from "react";
import { proxy, useSnapshot } from "valtio";
import { fetchStrings, sendSelectCounterResponse } from "@/api";
import { fetchStrings, Region, sendSelectCounterResponse } from "@/api";
import { useConfig } from "@/config";
import { NeosModal } from "./NeosModal";
......@@ -33,7 +33,7 @@ export const CheckCounterModal = () => {
const min = snapCheckCounterModal.min || 0;
const options = snapCheckCounterModal.options;
const counterName = fetchStrings(
"!counter",
Region.Counter,
`0x${snapCheckCounterModal.counterType!}`
); // FIXME: 这里转十六进制的逻辑有问题
......
......@@ -2,7 +2,7 @@ import React, { CSSProperties } from "react";
import { useNavigate } from "react-router-dom";
import { proxy, useSnapshot } from "valtio";
import { fetchStrings } from "@/api";
import { fetchStrings, Region } from "@/api";
import { matStore, replayStore, resetUniverse } from "@/stores";
import { NeosModal } from "../NeosModal";
......@@ -29,12 +29,12 @@ export const EndModal: React.FC = () => {
const onReturn = () => {
resetUniverse();
rs();
navigate("/");
navigate("/match");
};
return (
<NeosModal
title={fetchStrings("!system", 1500)}
title={fetchStrings(Region.System, 1500)}
open={isOpen}
onOk={() => {
if (!isReplay) {
......@@ -70,7 +70,7 @@ export const EndModal: React.FC = () => {
{isWin ? "Win" : "Defeated"}
</p>
<p className={styles.reason}>{reason}</p>
{isReplay ? <></> : <p>{fetchStrings("!system", 1340)}</p>}
{isReplay ? <></> : <p>{fetchStrings(Region.System, 1340)}</p>}
</div>
</NeosModal>
);
......
......@@ -2,7 +2,7 @@ import { message, notification } from "antd";
import React, { useEffect } from "react";
import { useSnapshot } from "valtio";
import { fetchStrings } from "@/api";
import { fetchStrings, Region } from "@/api";
import { Phase2StringCodeMap } from "@/common";
import { useConfig } from "@/config";
import { HandResult, matStore } from "@/stores";
......@@ -70,7 +70,7 @@ export const HintNotification = () => {
useEffect(() => {
if (currentPhase) {
const message = fetchStrings(
"!system",
Region.System,
Phase2StringCodeMap.get(currentPhase) ?? 0
);
notify.open({
......@@ -101,7 +101,7 @@ export const showWaiting = (open: boolean) => {
if (!isWaiting) {
globalMsgApi?.open({
type: "loading",
content: fetchStrings("!system", 1390),
content: fetchStrings(Region.System, 1390),
key: waitingKey,
className: styles["message"],
duration: 0,
......
......@@ -7,6 +7,7 @@ import {
type CardMeta,
fetchStrings,
getCardStr,
Region,
sendSelectIdleCmdResponse,
sendSelectOptionResponse,
} from "@/api";
......@@ -90,6 +91,6 @@ export const handleEffectActivation = async (
response: effect.response,
};
});
await displayOptionModal(fetchStrings("!system", 556), options); // 主动发动效果,所以不需要await,但是以后可能要留心
await displayOptionModal(fetchStrings(Region.System, 556), options); // 主动发动效果,所以不需要await,但是以后可能要留心
}
};
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment