Commit f8678c9d authored by Chunchi Che's avatar Chunchi Che

Merge branch 'feat/pages' into 'feat/mycard'

Feat/pages

See merge request !253
parents f7a5a096 0910b407
Pipeline #23058 passed with stages
in 13 minutes and 58 seconds
This source diff could not be displayed because it is too large. You can view the blob instead.
neos-protobuf @ d6c01dd8
Subproject commit 30f4ea7acd79b9cb18a358548520ca939e22dc5f
Subproject commit d6c01dd88fd72f22c432a52ba74eee9ece267499
......@@ -2,6 +2,7 @@
"version":4960,
"servers":[
{
"name":"koishi",
"ip":"koishi.momobako.com",
"port":"7211"
}
......@@ -10,6 +11,7 @@
"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",
"accountUrl":"https://accounts.moecube.com",
"profileUrl":"https://accounts.moecube.com/profiles",
......
......@@ -2,6 +2,7 @@
"version":4960,
"servers":[
{
"name":"koishi",
"ip":"koishi-r.momobako.com",
"port":"7211"
}
......@@ -10,6 +11,7 @@
"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",
"accountUrl":"https://accounts.moecube.com",
"profileUrl":"https://accounts.moecube.com/profiles",
......
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) {
......
......@@ -11,5 +11,7 @@ export const getCookie = <T>(key: CookieKeys) => {
};
export const setCookie = <T>(key: CookieKeys, value: T) => {
cookies.set(key, value);
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";
......
import axios from "axios";
const API_URL = "https://sapi.moecube.com:444/ygopro/match";
export interface MatchInfo {
......@@ -14,19 +12,26 @@ export async function match(
arena: string = "entertain"
): Promise<MatchInfo | undefined> {
const headers = { Authorization: "Basic " + btoa(username + ":" + extraId) };
const response = await axios
.post(API_URL, undefined, {
headers: headers,
params: {
arena,
// TODO: locale?
},
})
.catch((error) => {
console.error(`match error: ${error}`);
let response: Response | undefined = undefined;
const params = new URLSearchParams({
arena,
// TODO: locale?
});
return undefined;
try {
const resp = await fetch(API_URL + "?" + params.toString(), {
method: "POST",
headers: headers,
});
return response ? response.data : undefined;
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,
......
......@@ -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;
}
......@@ -14,12 +14,19 @@
* - 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 { theme } from "@/ui/theme";
import { NeosRouter } from "./ui/NeosRouter";
const root = ReactDOM.createRoot(
......@@ -27,9 +34,11 @@ const root = ReactDOM.createRoot(
);
root.render(
<ConfigProvider theme={{ algorithm: theme.darkAlgorithm }} locale={zhCN}>
<ProConfigProvider dark>
<NeosRouter />
</ProConfigProvider>
<ConfigProvider theme={theme} locale={zhCN}>
<App>
<ProConfigProvider dark>
<NeosRouter />
</ProConfigProvider>
</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,12 +29,11 @@ export interface sqliteAction {
// 初始化DB需要业务方传入的数据
initInfo?: {
dbUrl: string;
progressCallback?: (progress: number) => void; // 用于获取读取进度
};
// 需要读取卡牌数据的ID
payload?: {
id?: number; // 卡牌ID
query?: string; // 用于全文检索的query
type?: number; // 通过`type`过滤
ftsParams?: FtsParams; // 用于全文检索的参数
};
}
......@@ -51,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));
......@@ -85,44 +89,10 @@ export default async function (action: sqliteAction): Promise<sqliteResult> {
return {};
}
case sqliteCmd.FTS: {
if (YGODB && action.payload && action.payload.query) {
// TODO: 这里应该可以优化为联表查询
const query = action.payload.query;
const type = action.payload.type;
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);
}
if (YGODB && action.payload && action.payload.ftsParams) {
const metas = invokeFts(YGODB, action.payload.ftsParams);
for (const text of ftsTexts) {
const id = text.id;
if (id && type !== undefined) {
const sql = "SELECT * FROM datas WHERE ID = $id AND type = $type";
const dataStmt = YGODB.prepare(sql);
const data: CardData = dataStmt.getAsObject({
$id: id,
$type: type,
});
ftsMetas.push({ id, data, text });
} else if (id) {
const sql = "SELECT * FROM datas WHERE ID = $id";
const dataStmt = YGODB.prepare(sql);
const data: CardData = dataStmt.getAsObject({ $id: id });
ftsMetas.push({ id, data, text });
}
}
return { ftsResult: ftsMetas };
return { ftsResult: metas };
} else {
console.warn("ygo db not init or query not provied!");
}
......@@ -137,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 { 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 { NeosStore } from "./shared";
import { type NeosStore } from "./shared";
export interface User {
id: string;
......
......@@ -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(accountStore, { name: "account", 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,6 +3,7 @@ import { proxy } from "valtio";
import { ygopro } from "@/api";
import { type NeosStore } from "../shared";
import { ChainSetting, InitInfo, MatState } from "./types";
/**
......@@ -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,8 +54,6 @@ const initInfo: MatState["initInfo"] = proxy({
const initialState: Omit<MatState, "reset"> = {
chains: [],
chainSetting: ChainSetting.CHAIN_SMART,
timeLimits: {
// 时间限制
me: -1,
......@@ -64,9 +63,7 @@ const initialState: Omit<MatState, "reset"> = {
matStore.timeLimits[getWhom(controller)] = time;
},
},
initInfo,
selfType: ygopro.StocTypeChange.SelfType.UNKNOWN,
hint: { code: -1 },
currentPlayer: -1,
......@@ -86,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;
......@@ -120,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> & {
......
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;
width: 100%;
}
@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: #0f131e;
// font: 87.5%/1.5em "Open Sans", sans-serif;
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,
nav {
* {
box-sizing: border-box;
}
body {
--theme-font: "Electrolize", sans-serif;
--nav-height: 48px;
}
// 全局修改模块内的样式,如 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;
}
}
export const Component = () => <>build</>;
Component.displayName = "Build";
@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, ...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;
};
......@@ -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";
.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("/home");
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,但是以后可能要留心
}
};
......@@ -3,7 +3,7 @@ import { Button, Card, Segmented, Space, Tooltip } from "antd";
import { useEffect, useState } from "react";
import { INTERNAL_Snapshot as Snapshot, useSnapshot } from "valtio";
import type { CardMeta, ygopro } from "@/api";
import { type CardMeta, Region, type ygopro } from "@/api";
import { fetchStrings } from "@/api";
import { CardType, matStore } from "@/stores";
import { YgoCard } from "@/ui/Shared";
......@@ -81,7 +81,7 @@ export const SelectCardsModal: React.FC<SelectCardsModalProps> = ({
const zoneOptions = grouped.map((x) => ({
value: x[0],
label: fetchStrings("!system", x[0] + 1000),
label: fetchStrings(Region.System, x[0] + 1000),
}));
const [selectedZone, setSelectedZone] = useState(zoneOptions[0]?.value);
......@@ -91,7 +91,7 @@ export const SelectCardsModal: React.FC<SelectCardsModalProps> = ({
}, [selectables]);
const [submitText, finishText, cancelText] = [1211, 1296, 1295].map((n) =>
fetchStrings("!system", n)
fetchStrings(Region.System, n)
);
return (
......@@ -172,7 +172,7 @@ export const SelectCardsModal: React.FC<SelectCardsModalProps> = ({
<p>
<span>
{/* TODO: 这里的字体可以调整下 */}
{selecteds.length > 0 ? fetchStrings("!system", 212) : ""}
{selecteds.length > 0 ? fetchStrings(Region.System, 212) : ""}
</span>
</p>
<div className={styles["check-group"]}>
......
......@@ -17,7 +17,7 @@
.block {
height: var(--block-height-m);
width: var(--block-width);
background: radial-gradient(#ffffff00, #151212);
background-color: #ffffff1c;
position: relative;
&.extra {
margin-inline: calc(var(--block-width) / 2 + var(--col-gap) / 2);
......@@ -26,43 +26,44 @@
height: var(--block-height-s);
}
&.highlight {
background: #102639;
background: #ffffff35;
cursor: pointer;
.triangle {
--color: #006eff;
transform: scale(1.5);
}
&:hover {
opacity: 0.7;
.triangle {
transform: scale(1.2);
}
}
}
.triangle {
width: 0;
height: 0;
--color: #333;
border-width: 4px;
border-style: solid;
display: none;
--color: red;
position: absolute;
transition: 0.3s;
transform: scale(1.2);
.triangle-atom {
width: 20px;
height: 5px;
background-color: red;
position: absolute;
&:last-of-type {
transform: rotate(90deg);
transform-origin: 2.5px 2.5px;
}
}
&:nth-of-type(1) {
border-color: var(--color) transparent transparent var(--color);
left: 0;
}
&:nth-of-type(2) {
border-color: var(--color) var(--color) transparent transparent;
right: 0;
}
&:nth-of-type(3) {
border-color: transparent var(--color) var(--color) transparent;
transform: rotate(90deg);
right: 0;
bottom: 0;
}
&:nth-of-type(4) {
border-color: transparent transparent var(--color) var(--color);
bottom: 0;
}
// &:nth-of-type(3) {
// transform: rotate(180deg);
// right: 0;
// bottom: 0;
// }
// &:nth-of-type(4) {
// transform: rotate(270deg);
// bottom: 0;
// }
}
}
......
......@@ -129,8 +129,11 @@ const onBlockClick = (placeInteractivity: PlaceInteractivity) => {
const DecoTriangles: React.FC = () => (
<>
{Array.from({ length: 4 }).map((_, i) => (
<div className={styles.triangle} key={i} />
{Array.from({ length: 2 }).map((_, i) => (
<div className={styles.triangle} key={i}>
<div className={styles["triangle-atom"]} />
<div className={styles["triangle-atom"]} />
</div>
))}
</>
);
......
......@@ -4,7 +4,7 @@ import classnames from "classnames";
import React, { type CSSProperties, useEffect, useRef, useState } from "react";
import { useSnapshot } from "valtio";
import type { CardMeta } from "@/api";
import { type CardMeta, Region } from "@/api";
import {
fetchStrings,
getCardStr,
......@@ -353,7 +353,7 @@ const handleEffectActivation = (
response: effect.response,
};
});
displayOptionModal(fetchStrings("!system", 556), options); // 主动发动效果,所以不需要await,但是以后可能要留心
displayOptionModal(fetchStrings(Region.System, 556), options); // 主动发动效果,所以不需要await,但是以后可能要留心
}
};
......
......@@ -24,7 +24,7 @@
padding: 1rem;
padding-bottom: 0.6rem;
border-radius: 8px;
text-align: left;
// text-align: left;
display: flex;
flex-direction: column;
gap: 8px;
......
......@@ -5,7 +5,7 @@ import AnimatedNumbers from "react-animated-numbers";
import { useSnapshot } from "valtio";
import { useEnv } from "@/hook";
import { matStore, playerStore } from "@/stores";
import { matStore, roomStore } from "@/stores";
import styles from "./index.module.scss";
// 三个候选方案
......@@ -15,7 +15,7 @@ import styles from "./index.module.scss";
export const LifeBar: React.FC = () => {
const snapInitInfo = useSnapshot(matStore.initInfo);
const snapPlayer = useSnapshot(playerStore);
const snapPlayer = useSnapshot(roomStore);
const { currentPlayer } = useSnapshot(matStore);
const [meLife, setMeLife] = React.useState(0);
......@@ -59,14 +59,14 @@ export const LifeBar: React.FC = () => {
<div className={styles.container}>
<LifeBarItem
active={!matStore.isMe(currentPlayer)}
name={snapPlayer.getOpPlayer().name ?? "?"}
name={snapPlayer.getOpPlayer()?.name ?? "?"}
life={opLife}
timeLimit={opTimeLimit}
isMe={false}
/>
<LifeBarItem
active={matStore.isMe(currentPlayer)}
name={snapPlayer.getMePlayer().name ?? "?"}
name={snapPlayer.getMePlayer()?.name ?? "?"}
life={meLife}
timeLimit={myTimeLimit}
isMe={true}
......
......@@ -5,8 +5,11 @@ body {
}
section.mat {
position: relative;
width: "100%";
position: absolute;
width: 100%;
left: 0;
top: 0;
height: 100%;
.camera {
height: 100%;
display: flex;
......
@use "/src/styles/utils.scss";
.background {
position: fixed;
left: 0;
top: 0;
height: 100%;
max-height: 100%;
overflow: hidden;
width: 100%;
background-color: #010514;
z-index: -1;
display: flex;
justify-content: center;
@include utils.noise-bg;
}
.inner {
transform: translateY(50%);
width: 60vw;
height: 100vh;
background: radial-gradient(#00814f, #1c0161);
background-size: contain;
background-repeat: repeat;
filter: blur(502px);
transition: 0.5s;
}
.opponent .inner {
background: radial-gradient(#810000, #54005f98);
transform: translateY(-50%);
filter: blur(602px);
opacity: 0.8;
}
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