Commit d84f3d99 authored by Chunchi Che's avatar Chunchi Che

Merge branch 'refactor/store' into 'main'

重构store和动画

See merge request !176
parents 5da35b37 065d7e2a
......@@ -18,12 +18,17 @@
"interface"
],
"simple-import-sort/imports": "warn",
"simple-import-sort/exports": "warn"
"simple-import-sort/exports": "warn",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }
]
},
"settings": {
"import/resolver": {
"node": true,
"typescript": true
}
}
},
"ignorePatterns": ["src/api/ocgcore/idl/ocgcore.ts"]
}
#create by ...
#main
90027012
90027012
90027012
40177746
69815951
69815951
95209656
44665365
44665365
77235086
77235086
77235086
92919429
92919429
92919429
22420202
22420202
22420202
33543890
60037599
60037599
96026108
96026108
96026108
97148796
97148796
97148796
60600126
60600126
94187078
94187078
94187078
22398665
22398665
27383110
27383110
58793369
58793369
84965420
84965420
#extra
73580471
79606837
79606837
79606837
21521304
27552504
1174075
1174075
1174075
73898890
73898890
72336818
41999284
94259633
94259633
!side
neos-protobuf @ 44727f71
Subproject commit ee60c75c921a82e6592d119a21339361d3723258
Subproject commit 44727f7136ec5708c86d6ed09d5bec8b48f13efc
......@@ -11,71 +11,12 @@
"cardsDbUrl":"https://cdn02.moecube.com:444/ygopro-database/zh-CN/cards.cdb",
"stringsUrl":"https://cdn02.moecube.com:444/ygopro-database/zh-CN/strings.conf",
"chainALL": false,
"streamInterval": 200,
"ui":{
"ground":{
"width":9.9,
"height":8
},
"card":{
"transform":{
"x":0.8,
"y":1,
"z":0.05
},
"rotation":{
"x":1.55,
"y":0,
"z":0
},
"reverseRotation":{
"x":1.55,
"y":3.1,
"z":0
},
"defenceRotation":{
"x":1.55,
"y":1.55,
"z":0
},
"handRotation":{
"x":1,
"y":0,
"z":0
},
"handHoverScaling":{
"x":1.2,
"y":1.2,
"z":1
},
"floating":0.02
},
"layout":{
"header":{
"height":80
},
"content":{
"height":800
},
"sider":{
"width":300
},
"footer":{
"height":80
}
},
"status":{
"avatarSize":40,
"meAvatarColor":"#0e63e1",
"opAvatarColor":"#e10e68"
},
"hint":{
"waitingDuration":1.5,
"maxCount": 1
},
"commonDelay": 200,
"moveDelay": 500,
"chainingDelay": 800,
"attackDelay": 500
}
},
"unimplementedWhiteList":[
1,
......
......@@ -11,71 +11,12 @@
"cardsDbUrl":"https://cdn02.moecube.com:444/ygopro-database/zh-CN/cards.cdb",
"stringsUrl":"https://cdn02.moecube.com:444/ygopro-database/zh-CN/strings.conf",
"chainALL": false,
"streamInterval": 200,
"ui":{
"ground":{
"width":9.9,
"height":8
},
"card":{
"transform":{
"x":0.8,
"y":1,
"z":0.05
},
"rotation":{
"x":1.55,
"y":0,
"z":0
},
"reverseRotation":{
"x":1.55,
"y":3.1,
"z":0
},
"defenceRotation":{
"x":1.55,
"y":1.55,
"z":0
},
"handRotation":{
"x":1,
"y":0,
"z":0
},
"handHoverScaling":{
"x":1.2,
"y":1.2,
"z":1
},
"floating":0.02
},
"layout":{
"header":{
"height":80
},
"content":{
"height":800
},
"sider":{
"width":300
},
"footer":{
"height":80
}
},
"status":{
"avatarSize":40,
"meAvatarColor":"#0e63e1",
"opAvatarColor":"#e10e68"
},
"hint":{
"waitingDuration":1.5,
"maxCount": 1
},
"commonDelay": 200,
"moveDelay": 500,
"chainingDelay": 800,
"attackDelay": 500
}
},
"unimplementedWhiteList":[
1,
......
......@@ -11,6 +11,7 @@
"@ant-design/pro-components": "^2.4.4",
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/sortable": "^7.0.2",
"@react-spring/web": "^9.7.2",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
......@@ -22,6 +23,7 @@
"antd": "^5.4.0",
"axios": "^0.27.2",
"classnames": "^2.3.2",
"eventemitter3": "^5.0.1",
"google-protobuf": "^3.21.2",
"lodash-es": "^4.17.21",
"react": "^18.2.0",
......@@ -3093,6 +3095,73 @@
"react-dom": ">=16.9.0"
}
},
"node_modules/@react-spring/animated": {
"version": "9.7.2",
"resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.2.tgz",
"integrity": "sha512-ipvleJ99ipqlnHkz5qhSsgf/ny5aW0ZG8Q+/2Oj9cI7LCc7COdnrSO6V/v8MAX3JOoQNzfz6dye2s5Pt5jGaIA==",
"dependencies": {
"@react-spring/shared": "~9.7.2",
"@react-spring/types": "~9.7.2"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/@react-spring/core": {
"version": "9.7.2",
"resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.7.2.tgz",
"integrity": "sha512-fF512edZT/gKVCA90ZRxfw1DmELeVwiL4OC2J6bMUlNr707C0h4QRoec6DjzG27uLX2MvS1CEatf9KRjwZR9/w==",
"dependencies": {
"@react-spring/animated": "~9.7.2",
"@react-spring/rafz": "~9.7.2",
"@react-spring/shared": "~9.7.2",
"@react-spring/types": "~9.7.2"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-spring/donate"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/@react-spring/rafz": {
"version": "9.7.2",
"resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.7.2.tgz",
"integrity": "sha512-kDWMYDQto3+flkrX3vy6DU/l9pxQ4TVW91DglQEc11iDc7shF4+WVDRJvOVLX+xoMP7zyag1dMvlIgvQ+dvA/A=="
},
"node_modules/@react-spring/shared": {
"version": "9.7.2",
"resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.7.2.tgz",
"integrity": "sha512-6U9qkno+9DxlH5nSltnPs+kU6tYKf0bPLURX2te13aGel8YqgcpFYp5Av8DcN2x3sukinAsmzHUS/FRsdZMMBA==",
"dependencies": {
"@react-spring/rafz": "~9.7.2",
"@react-spring/types": "~9.7.2"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/@react-spring/types": {
"version": "9.7.2",
"resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.7.2.tgz",
"integrity": "sha512-GEflx2Ex/TKVMHq5g5MxQDNNPNhqg+4Db9m7+vGTm8ttZiyga7YQUF24shgRNebKIjahqCuei16SZga8h1pe4g=="
},
"node_modules/@react-spring/web": {
"version": "9.7.2",
"resolved": "https://registry.npmjs.org/@react-spring/web/-/web-9.7.2.tgz",
"integrity": "sha512-7qNc7/5KShu2D05x7o2Ols2nUE7mCKfKLaY2Ix70xPMfTle1sZisoQMBFgV9w/fSLZlHZHV9P0uWJqEXQnbV4Q==",
"dependencies": {
"@react-spring/animated": "~9.7.2",
"@react-spring/core": "~9.7.2",
"@react-spring/shared": "~9.7.2",
"@react-spring/types": "~9.7.2"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/@remix-run/router": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.5.0.tgz",
......@@ -8877,9 +8946,9 @@
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="
},
"node_modules/events": {
"version": "3.3.0",
......@@ -11231,6 +11300,11 @@
"node": ">=0.10.0"
}
},
"node_modules/http-proxy/node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
},
"node_modules/http-signature": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz",
......@@ -30586,6 +30660,56 @@
"rc-util": "^5.29.2"
}
},
"@react-spring/animated": {
"version": "9.7.2",
"resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.2.tgz",
"integrity": "sha512-ipvleJ99ipqlnHkz5qhSsgf/ny5aW0ZG8Q+/2Oj9cI7LCc7COdnrSO6V/v8MAX3JOoQNzfz6dye2s5Pt5jGaIA==",
"requires": {
"@react-spring/shared": "~9.7.2",
"@react-spring/types": "~9.7.2"
}
},
"@react-spring/core": {
"version": "9.7.2",
"resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.7.2.tgz",
"integrity": "sha512-fF512edZT/gKVCA90ZRxfw1DmELeVwiL4OC2J6bMUlNr707C0h4QRoec6DjzG27uLX2MvS1CEatf9KRjwZR9/w==",
"requires": {
"@react-spring/animated": "~9.7.2",
"@react-spring/rafz": "~9.7.2",
"@react-spring/shared": "~9.7.2",
"@react-spring/types": "~9.7.2"
}
},
"@react-spring/rafz": {
"version": "9.7.2",
"resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.7.2.tgz",
"integrity": "sha512-kDWMYDQto3+flkrX3vy6DU/l9pxQ4TVW91DglQEc11iDc7shF4+WVDRJvOVLX+xoMP7zyag1dMvlIgvQ+dvA/A=="
},
"@react-spring/shared": {
"version": "9.7.2",
"resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.7.2.tgz",
"integrity": "sha512-6U9qkno+9DxlH5nSltnPs+kU6tYKf0bPLURX2te13aGel8YqgcpFYp5Av8DcN2x3sukinAsmzHUS/FRsdZMMBA==",
"requires": {
"@react-spring/rafz": "~9.7.2",
"@react-spring/types": "~9.7.2"
}
},
"@react-spring/types": {
"version": "9.7.2",
"resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.7.2.tgz",
"integrity": "sha512-GEflx2Ex/TKVMHq5g5MxQDNNPNhqg+4Db9m7+vGTm8ttZiyga7YQUF24shgRNebKIjahqCuei16SZga8h1pe4g=="
},
"@react-spring/web": {
"version": "9.7.2",
"resolved": "https://registry.npmjs.org/@react-spring/web/-/web-9.7.2.tgz",
"integrity": "sha512-7qNc7/5KShu2D05x7o2Ols2nUE7mCKfKLaY2Ix70xPMfTle1sZisoQMBFgV9w/fSLZlHZHV9P0uWJqEXQnbV4Q==",
"requires": {
"@react-spring/animated": "~9.7.2",
"@react-spring/core": "~9.7.2",
"@react-spring/shared": "~9.7.2",
"@react-spring/types": "~9.7.2"
}
},
"@remix-run/router": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.5.0.tgz",
......@@ -35077,9 +35201,9 @@
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="
},
"eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="
},
"events": {
"version": "3.3.0",
......@@ -36770,6 +36894,13 @@
"eventemitter3": "^4.0.0",
"follow-redirects": "^1.0.0",
"requires-port": "^1.0.0"
},
"dependencies": {
"eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
}
}
},
"http-proxy-agent": {
import axios from "axios";
import sqliteMiddleWare, { sqliteCmd } from "@/middleware/sqlite";
export interface CardMeta {
......@@ -57,6 +55,9 @@ export async function fetchCard(id: number): Promise<CardMeta> {
return res.selectResult ? res.selectResult : { id, data: {}, text: {} };
}
// @ts-ignore
window.fetchCard = fetchCard;
export function getCardStr(meta: CardMeta, idx: number): string | undefined {
switch (idx) {
case 0: {
......
......@@ -24,6 +24,10 @@ export const DeckManager = _objToMap(
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: [] };
}
......
This diff is collapsed.
......@@ -14,20 +14,20 @@ export class BufferReaderExt {
readCardInfo(): ygopro.CardInfo {
const code = this.inner.readUint32();
const controler = this.inner.readUint8();
const controller = this.inner.readUint8();
const location = numberToCardZone(this.inner.readUint8());
const sequence = this.inner.readUint8();
return new ygopro.CardInfo({
code,
controler,
controller,
location,
sequence,
});
}
readCardLocation(): ygopro.CardLocation {
const controler = this.inner.readUint8();
const controller = this.inner.readUint8();
const location = this.inner.readUint8();
const sequence = this.inner.readUint8();
const ss = this.inner.readUint8();
......@@ -35,29 +35,31 @@ export class BufferReaderExt {
if (location & LOCATION_OVERLAY) {
// 超量素材
return new ygopro.CardLocation({
controler,
location: ygopro.CardZone.OVERLAY,
controller,
zone: numberToCardZone(location & ~LOCATION_OVERLAY),
sequence,
is_overlay: true,
overlay_sequence: ss,
});
} else {
return new ygopro.CardLocation({
controler,
location: numberToCardZone(location),
controller,
zone: numberToCardZone(location),
sequence,
is_overlay: false,
position: numberToCardPosition(ss),
});
}
}
readCardShortLocation(): ygopro.CardLocation {
const controler = this.inner.readUint8();
const controller = this.inner.readUint8();
const location = this.inner.readUint8();
const sequence = this.inner.readUint8();
return new ygopro.CardLocation({
controler,
location: numberToCardZone(location),
controller,
zone: numberToCardZone(location),
sequence,
});
}
......
......@@ -17,8 +17,8 @@ export default (data: Uint8Array) => {
const target_location = reader.readCardLocation();
if (
target_location.controler == 0 &&
target_location.location == 0 &&
target_location.controller == 0 &&
target_location.zone == 0 &&
target_location.sequence == 0
) {
// 全零表示直接攻击玩家
......
......@@ -30,7 +30,7 @@ export default (data: Uint8Array) => {
});
for (let i = 0; i < 2; i++) {
const controler = i == 0 ? player : 1 - player;
const controller = i == 0 ? player : 1 - player;
const field = i == 0 ? _field & 0xffff : _field >> 16;
if ((field & 0x7f) != 0) {
......@@ -42,7 +42,7 @@ export default (data: Uint8Array) => {
if ((filter & (1 << sequence)) != 0) {
msg.places.push(
new MsgSelectPlace.SelectAblePlace({
controler,
controller,
zone,
sequence: sequence,
})
......@@ -60,7 +60,7 @@ export default (data: Uint8Array) => {
if ((filter & (1 << sequence)) != 0) {
msg.places.push(
new MsgSelectPlace.SelectAblePlace({
controler,
controller,
zone,
sequence,
})
......@@ -77,7 +77,7 @@ export default (data: Uint8Array) => {
if ((filter & 0x1) != 0) {
msg.places.push(
new MsgSelectPlace.SelectAblePlace({
controler,
controller,
zone,
sequence: 6,
})
......@@ -87,7 +87,7 @@ export default (data: Uint8Array) => {
if ((filter & 0x2) != 0) {
msg.places.push(
new MsgSelectPlace.SelectAblePlace({
controler,
controller,
zone,
sequence: 7,
})
......
......@@ -26,7 +26,7 @@ export default (data: Uint8Array) => {
let offset = 1;
if (dataView.byteLength > 17) {
// data长度大于17,会多传一个大师规则字段
const masterRule = dataView.getUint8(offset); // TODO
const _masterRule = dataView.getUint8(offset); // TODO
offset += 1;
}
......
......@@ -99,9 +99,6 @@ export function cardZoneToNumber(zone: ygopro.CardZone): number {
case ygopro.CardZone.EXTRA: {
return 0x40;
}
case ygopro.CardZone.OVERLAY: {
return 0x80;
}
case ygopro.CardZone.ONFIELD: {
return 0x0c;
}
......@@ -111,6 +108,9 @@ export function cardZoneToNumber(zone: ygopro.CardZone): number {
case ygopro.CardZone.PZONE: {
return 0x200;
}
case ygopro.CardZone.TZONE: {
return 0x300;
}
}
}
......@@ -139,9 +139,6 @@ export function numberToCardZone(
case 0x40: {
return ygopro.CardZone.EXTRA;
}
case 0x80: {
return ygopro.CardZone.OVERLAY;
}
case 0x0c: {
return ygopro.CardZone.ONFIELD;
}
......
......@@ -154,14 +154,14 @@ export function sendSelectIdleCmdResponse(value: number) {
}
export function sendSelectPlaceResponse(value: {
controler: number;
controller: number;
zone: ygopro.CardZone;
sequence: number;
}) {
const response = new ygopro.YgoCtosMsg({
ctos_response: new ygopro.CtosGameMsgResponse({
select_place: new ygopro.CtosGameMsgResponse.SelectPlaceResponse({
player: value.controler,
player: value.controller,
zone: value.zone,
sequence: value.sequence,
}),
......
......@@ -16,7 +16,7 @@ const TYPE_UNION = 0x400; //
const TYPE_DUAL = 0x800; //
const TYPE_TUNER = 0x1000; //
const TYPE_SYNCHRO = 0x2000; //
const TYPE_TOKEN = 0x4000; //
export const TYPE_TOKEN = 0x4000; //
const TYPE_QUICKPLAY = 0x10000; //
const TYPE_CONTINUOUS = 0x20000; //
const TYPE_EQUIP = 0x40000; //
......
/// <reference types="react-scripts" />
/// <reference types="vite/client" />
/// <reference types="eventemitter3" />
interface ImportMetaEnv {
readonly VITE_IS_AI_MODE: boolean;
......@@ -11,9 +12,16 @@ interface ImportMeta {
readonly env: ImportMetaEnv;
}
// // 重新声明useSnapshot,暂时先这么写。原版的会把所有的改成readonly,引发一些棘手的类型报错。
// import "valtio/react";
// declare module "valtio/react" {
// export declare function useSnapshot<T extends object>(proxyObject: T): T;
// export {};
// }
/* eslint @typescript-eslint/no-unused-vars: 0 */
import { EventEmitter } from "eventemitter3";
/* eslint no-var: 0 */
declare global {
var myExtraDeckCodes: number[];
interface Console {
color: (
color: string,
backgroundColor?: string
) => (...args: any[]) => void;
}
}
console.color =
(color: string, backgroundColor?: string) =>
(...args: any[]) => {
console.log(
`%c${args.join(" ")}`,
`color: ${color}; backgroundColor: ${backgroundColor ?? "none"}`
);
};
export {};
import { EventEmitter } from "eventemitter3";
import { v4 as v4uuid } from "uuid";
const eventEmitter = new EventEmitter();
export enum Task {
Move = "move",
Focus = "focus",
}
const getEnd = (task: Task) => `${task}-end`;
/** 在组件之中注册方法 */
const register = (task: Task, fn: (...args: any[]) => Promise<any>) => {
eventEmitter.on(
task,
async ({ taskId, args }: { taskId: string; args: any[] }) => {
await fn(...args);
eventEmitter.emit(getEnd(task), taskId);
}
);
};
/** 在service之中调用组件中的方法 */
const call = (task: Task, ...args: any[]) =>
new Promise<void>((rs) => {
const taskId = v4uuid();
const cb = (respTaskId: string) => {
if (respTaskId === taskId) {
eventEmitter.removeListener(getEnd(task), cb);
rs();
}
};
eventEmitter.emit(task, { taskId, args });
eventEmitter.on(getEnd(task), cb);
});
export const eventbus = {
call,
register,
};
// Some implementation of infrastructure
/* eslint import/export: 0 */
export * from "./console";
export * from "./eventbus";
export * from "./sleep";
export * from "./stream";
......@@ -2,11 +2,12 @@
// 现在我们有这样一个需求:需要保证每次只处理一个消息,在上一个消息处理完后,再进行下一个消息的处理。
//
// 因此封装了一个`WebSocketStream`类,当每次Websocket连接中有消息到达时,往流中添加event,
// 同时执行器会不断地从流中获取event进行处理。
import { sleep } from "./sleep";
const SLEEP_INTERVAL = 200;
import { useConfig } from "@/config";
import { sleep } from "./sleep";
// 同时执行器会不断地从流中获取event进行处理。
export class WebSocketStream {
public ws: WebSocket;
stream: ReadableStream;
......@@ -52,10 +53,10 @@ export class WebSocketStream {
return;
} else {
// websocket not closed, sleep sometime, wait for next message from server
await sleep(SLEEP_INTERVAL);
// websocket not closed, wait some time, and then handle next message from server
return reader.read().then(process);
await sleep(useConfig().streamInterval);
await reader.read().then(process);
}
}
......@@ -66,7 +67,7 @@ export class WebSocketStream {
}
// read some more, and call process function again
return reader.read().then(process);
await reader.read().then(process);
});
}
......
......@@ -31,11 +31,9 @@ const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
root.render(
<React.StrictMode>
<BrowserRouter>
<ConfigProvider theme={{ algorithm: theme.darkAlgorithm }} locale={zhCN}>
<Neos />
</ConfigProvider>
</BrowserRouter>
</React.StrictMode>
<BrowserRouter>
<ConfigProvider theme={{ algorithm: theme.darkAlgorithm }} locale={zhCN}>
<Neos />
</ConfigProvider>
</BrowserRouter>
);
import { ygopro } from "@/api";
import { sleep } from "@/infra";
import { fetchEsHintMeta, matStore } from "@/stores";
import { cardStore, fetchEsHintMeta, matStore } from "@/stores";
export default async (attack: ygopro.StocGameMessage.MsgAttack) => {
fetchEsHintMeta({
......@@ -8,31 +7,32 @@ export default async (attack: ygopro.StocGameMessage.MsgAttack) => {
location: attack.attacker_location,
});
const attacker = matStore
.in(attack.attacker_location.location)
.of(attack.attacker_location.controler)
.at(attack.attacker_location.sequence);
const attacker = cardStore.at(
attack.attacker_location.zone,
attack.attacker_location.controller,
attack.attacker_location.sequence
);
if (attacker) {
if (attack.direct_attack) {
attacker.directAttack = true;
await sleep(500);
// await sleep(500);
attacker.directAttack = false;
} else {
const target = matStore
.in(attack.target_location.location)
.of(attack.target_location.controler)
.at(attack.target_location.sequence);
const target = cardStore.at(
attack.target_location.zone,
attack.target_location.controller,
attack.target_location.sequence
);
if (target) {
attacker.attackTarget = {
sequence: attack.target_location.sequence,
opponent: !matStore.isMe(attack.target_location.controler),
opponent: !matStore.isMe(attack.target_location.controller),
...target,
};
await sleep(500);
// await sleep(500);
attacker.attackTarget = undefined;
}
}
......
import { ygopro } from "@/api";
import { matStore } from "@/stores";
import { cardStore, matStore } from "@/stores";
export default (_chainEnd: ygopro.StocGameMessage.MsgChainEnd) => {
while (true) {
......@@ -8,6 +8,11 @@ export default (_chainEnd: ygopro.StocGameMessage.MsgChainEnd) => {
break;
}
matStore.setChained(chain, undefined);
const target = cardStore.find(chain);
if (target) {
target.chainIndex = undefined;
} else {
console.warn(`<ChainEnd>target from ${chain} is null`);
}
}
};
import { ygopro } from "@/api";
import { matStore } from "@/stores";
import { cardStore, matStore } from "@/stores";
// FIXME: 处理连锁会存在三种结果:
// 1. Solved - 已处理;
......@@ -15,7 +15,12 @@ export default async (chainSolved: ygopro.StocGameMessage.MsgChainSolved) => {
.at(0);
if (location) {
// 设置被连锁状态为空,解除连锁
matStore.setChained(location, undefined);
const target = cardStore.find(location);
if (target) {
target.chainIndex = undefined;
} else {
console.warn(`<ChainSolved>target from ${location} is null`);
}
} else {
console.warn(
`pop from chains return null! solved_index=${chainSolved.solved_index}, len of chains in store=${matStore.chains.length}`
......
import { ygopro } from "@/api";
import { useConfig } from "@/config";
import { sleep } from "@/infra";
import { fetchEsHintMeta, matStore } from "@/stores";
import { eventbus, Task } from "@/infra";
import { cardStore, fetchEsHintMeta, matStore } from "@/stores";
export default async (chaining: ygopro.StocGameMessage.MsgChaining) => {
fetchEsHintMeta({
......@@ -9,15 +8,21 @@ export default async (chaining: ygopro.StocGameMessage.MsgChaining) => {
cardID: chaining.code,
});
await matStore.setChaining(chaining.location, chaining.code, true);
await cardStore.setChaining(chaining.location, chaining.code, true);
await sleep(useConfig().ui.chainingDelay);
const location = chaining.location;
// 恢复成非`chaining`状态
await matStore.setChaining(location, chaining.code, false);
await cardStore.setChaining(location, chaining.code, false);
// 将`location`添加到连锁栈
matStore.chains.push(location);
// 设置被连锁状态
matStore.setChained(location, matStore.chains.length);
const target = cardStore.find(location);
if (target) {
target.chainIndex = matStore.chains.length;
await eventbus.call(Task.Focus, 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 { sleep } from "@/infra";
import { matStore } from "@/stores";
import { cardStore } from "@/stores";
export default async (confirmCards: ygopro.StocGameMessage.MsgConfirmCards) => {
const cards = confirmCards.cards;
console.color("pink")(`confirmCards: ${cards}`);
for (const card of cards) {
const target = matStore
.in(card.location)
.of(card.controler)
.at(card.sequence);
const target = cardStore.at(card.location, card.controller, card.sequence);
if (target) {
// 设置`occupant`
const meta = await fetchCard(card.code);
target.occupant = meta;
target.meta = meta;
// 设置`position`,否则会横放
target.location.position = ygopro.CardPosition.ATTACK;
// 聚焦1s
target.focus = true;
await sleep(1000);
target.focus = false;
await sleep(200);
} else {
console.warn(`card of ${card} is null`);
}
......
import { ygopro } from "@/api";
import { sleep } from "@/infra";
import { fetchEsHintMeta, matStore } from "@/stores";
import { zip } from "@/ui/Duel/utils";
import { fetchCard, ygopro } from "@/api";
import { eventbus, Task } from "@/infra";
import { cardStore, fetchEsHintMeta } from "@/stores";
let cnt = 0;
export default async (draw: ygopro.StocGameMessage.MsgDraw) => {
fetchEsHintMeta({ originMsg: "玩家抽卡时" });
const deckLength = matStore.decks.of(draw.player).length;
const drawLength = draw.cards.length;
const popCards = matStore.decks
.of(draw.player)
.splice(deckLength - drawLength, drawLength);
const data = zip(popCards, draw.cards).map(([pop, hand]) => {
return { uuid: pop.uuid, id: hand };
});
matStore.hands
.of(draw.player)
.add(data, ygopro.CardPosition.FACEUP_ATTACK, true);
// 将卡从卡组移到手牌:设置zone、occupant、sequence
const handsLength = cardStore.at(ygopro.CardZone.HAND, draw.player).length;
const newHands = cardStore
.at(ygopro.CardZone.DECK, draw.player)
.slice(-drawLength);
await sleep(500);
for (const idx in newHands) {
const card = newHands[Number(idx)];
const code = draw.cards[idx];
const meta = await fetchCard(code);
card.code = code;
card.meta = meta;
card.location.zone = ygopro.CardZone.HAND;
card.location.sequence = Number(idx) + handsLength;
}
for (const hand of matStore.hands.of(draw.player)) {
hand.focus = false;
if (cnt++ < 2) {
// FIXME 暂时性的解决方案,头两回抽卡(双方各自初始手卡)先屏蔽掉
// 不然会出现一些问题...
return;
}
// 抽卡动画
await Promise.all(
cardStore
.at(ygopro.CardZone.HAND, draw.player)
.map((card) => eventbus.call(Task.Move, card.uuid))
);
};
......@@ -60,7 +60,14 @@ const ActiveList = [
"select_yes_no",
];
let animation: Promise<unknown> = new Promise<void>((rs) => rs());
export default async function handleGameMsg(pb: ygopro.YgoStocMsg) {
animation = animation.then(() => _handleGameMsg(pb));
// _handleGameMsg(pb);
}
async function _handleGameMsg(pb: ygopro.YgoStocMsg) {
const msg = pb.stoc_game_msg;
if (ActiveList.includes(msg.gameMsg)) {
......@@ -144,12 +151,12 @@ export default async function handleGameMsg(pb: ygopro.YgoStocMsg) {
break;
}
case "pos_change": {
onMsgPosChange(msg.pos_change);
await onMsgPosChange(msg.pos_change);
break;
}
case "select_unselect_card": {
onMsgSelectUnselectCard(msg.select_unselect_card);
await onMsgSelectUnselectCard(msg.select_unselect_card);
break;
}
......
This diff is collapsed.
import { ygopro } from "@/api";
import MsgPosChange = ygopro.StocGameMessage.MsgPosChange;
import { fetchEsHintMeta, matStore } from "@/stores";
export default (posChange: MsgPosChange) => {
const { location, controler, sequence } = posChange.card_info;
import { eventbus, Task } from "@/infra";
import { cardStore, fetchEsHintMeta } from "@/stores";
export default async (posChange: MsgPosChange) => {
const { location, controller, sequence } = posChange.card_info;
switch (location) {
case ygopro.CardZone.MZONE: {
matStore.monsters.of(controler)[sequence].location.position =
posChange.cur_position;
const target = cardStore.at(location, controller, sequence);
if (target) {
target.location.position = posChange.cur_position;
break;
}
case ygopro.CardZone.SZONE: {
matStore.magics.of(controler)[sequence].location.position =
posChange.cur_position;
break;
}
default: {
console.log(`Unhandled zone ${location}`);
}
// TODO: 暂时用`Move`动画,后续可以单独实现一个改变表示形式的动画
await eventbus.call(Task.Move, target.uuid);
} else {
console.warn(`<PosChange>target from ${posChange.card_info} is null`);
}
fetchEsHintMeta({
originMsg: 1600,
});
......
import { v4 as uuidv4 } from "uuid";
import { ygopro } from "@/api";
import { matStore } from "@/stores";
type MsgReloadField = ygopro.StocGameMessage.MsgReloadField;
type ZoneActions = ygopro.StocGameMessage.MsgReloadField.ZoneAction[];
export default (field: MsgReloadField) => {
const _duel_rule = field.duel_rule; // TODO: duel_rule
const gamers = ["me", "op"] as const;
gamers.forEach((gamer) => {
matStore.banishedZones[gamer].length = 0;
matStore.extraDecks[gamer].length = 0;
matStore.graveyards[gamer].length = 0;
matStore.hands[gamer].length = 0;
matStore.monsters[gamer].length = 0;
matStore.magics[gamer].length = 0;
});
const { MZONE, SZONE, HAND, DECK, GRAVE, REMOVED, EXTRA } = ygopro.CardZone;
const zones = [MZONE, SZONE, HAND, DECK, GRAVE, REMOVED, EXTRA] as const;
field.actions.forEach(({ player, zone_actions }) => {
zones.forEach((zone) => {
reloadDuelField(
zone,
zone_actions.filter((item) => item.zone === zone),
player
);
});
});
export default (_field: MsgReloadField) => {
// TODO: 断线重连比较复杂,先留着后面时实现
};
/** 可以理解成reload DuelFieldState */
function reloadDuelField(
cardZone: ygopro.CardZone,
zoneActions: ZoneActions,
controller: number
) {
zoneActions.sort((a, b) => a.sequence - b.sequence);
const cards = zoneActions.map((action) => {
// FIXME: OVERLAY
return {
uuid: uuidv4(), // 因为是重连,所以这里重新申请UUID
location: {
controler: controller,
zone: action.zone,
position: action.position,
},
idleInteractivities: [],
counters: {},
focus: false,
chaining: false,
directAttack: false,
reload: true,
};
});
matStore.in(cardZone).of(controller).length = 0;
matStore
.in(cardZone)
.of(controller)
.push(...cards);
}
import { ygopro } from "@/api";
import {
clearAllIdleInteractivities as clearAllIdleInteractivities,
cardStore,
type Interactivity,
InteractType,
matStore,
......@@ -13,7 +13,9 @@ export default (selectBattleCmd: MsgSelectBattleCmd) => {
const cmds = selectBattleCmd.battle_cmds;
// 先清掉之前的互动性
clearAllIdleInteractivities(player);
cardStore.inner.forEach((card) => {
card.idleInteractivities = [];
});
cmds.forEach((cmd) => {
const interactType = battleTypeToInteracType(cmd.battle_type);
......@@ -30,14 +32,18 @@ export default (selectBattleCmd: MsgSelectBattleCmd) => {
[InteractType.ATTACK]: { directAttackAble: data.direct_attackable },
};
const tmp = map[interactType]; // 添加额外信息
matStore
.in(location)
.of(player)
.addIdleInteractivity(sequence, {
const target = cardStore.at(location, player, sequence);
if (target) {
target.idleInteractivities.push({
...tmp,
interactType,
response: data.response,
});
} else {
console.warn(
`<selectBattleCmd>target from zone=${location}, player=${player}, sequence=${sequence} is null`
);
}
} else {
console.warn(`Undefined InteractType`);
}
......
......@@ -12,8 +12,8 @@ type MsgSelectChain = ygopro.StocGameMessage.MsgSelectChain;
export default (selectChain: MsgSelectChain) => {
const spCount = selectChain.special_count;
const forced = selectChain.forced;
const hint0 = selectChain.hint0;
const hint1 = selectChain.hint1;
const _hint0 = selectChain.hint0;
const _hint1 = selectChain.hint1;
const chains = selectChain.chains;
let handle_flag = 0;
......
import { ygopro } from "@/api";
import { getCardByLocation, messageStore } from "@/stores";
import { cardStore, messageStore } from "@/stores";
type MsgSelectCounter = ygopro.StocGameMessage.MsgSelectCounter;
export default (selectCounter: MsgSelectCounter) => {
......@@ -7,7 +7,7 @@ export default (selectCounter: MsgSelectCounter) => {
messageStore.checkCounterModal.min = selectCounter.min;
messageStore.checkCounterModal.options = selectCounter.options!.map(
({ location, code, counter_count }) => {
const id = getCardByLocation(location)?.occupant?.id;
const id = cardStore.find(location)?.code;
const newCode = code ? code : id || 0;
return {
......
......@@ -19,7 +19,7 @@ export default async (selectEffectYn: MsgSelectEffectYn) => {
) => {
const desc1 = desc.replace(
`[%ls]`,
fetchStrings("!system", cardLocation.location + 1000)
fetchStrings("!system", cardLocation.zone + 1000)
);
const desc2 = desc1.replace(`[%ls]`, cardMeta.text.name || "[?]");
return desc2;
......
import { ygopro } from "@/api";
import {
clearAllIdleInteractivities as clearAllIdleInteractivities,
cardStore,
type Interactivity,
InteractType,
matStore,
......@@ -13,7 +13,9 @@ export default (selectIdleCmd: MsgSelectIdleCmd) => {
const cmds = selectIdleCmd.idle_cmds;
// 先清掉之前的互动性
clearAllIdleInteractivities(player);
cardStore.inner.forEach((card) => {
card.idleInteractivities = [];
});
cmds.forEach((cmd) => {
const interactType = idleTypeToInteractType(cmd.idle_type);
......@@ -29,14 +31,18 @@ export default (selectIdleCmd: MsgSelectIdleCmd) => {
[InteractType.ACTIVATE]: { activateIndex: data.effect_description },
};
const tmp = map[interactType];
matStore
.in(location)
.of(player)
.addIdleInteractivity(sequence, {
const target = cardStore.at(location, player, sequence);
if (target) {
target.idleInteractivities.push({
...tmp,
interactType,
response: data.response,
});
} else {
console.warn(
`target from zone=${location}, controller=${player}, sequence=${sequence} is null`
);
}
} else {
console.warn(`Undefined InteractType`);
}
......
import { ygopro } from "@/api";
import { InteractType, matStore } from "@/stores";
import { InteractType, placeStore } from "@/stores";
type MsgSelectPlace = ygopro.StocGameMessage.MsgSelectPlace;
......@@ -11,27 +11,17 @@ export default (selectPlace: MsgSelectPlace) => {
for (const place of selectPlace.places) {
switch (place.zone) {
case ygopro.CardZone.MZONE: {
matStore.monsters
.of(place.controler)
.setPlaceInteractivityType(
place.sequence,
InteractType.PLACE_SELECTABLE
);
case ygopro.CardZone.MZONE:
case ygopro.CardZone.SZONE:
placeStore.set(place.zone, place.controller, place.sequence, {
interactType: InteractType.PLACE_SELECTABLE,
response: {
controller: place.controller,
zone: place.zone,
sequence: place.sequence,
},
});
break;
}
case ygopro.CardZone.SZONE: {
matStore.magics
.of(place.controler)
.setPlaceInteractivityType(
place.sequence,
InteractType.PLACE_SELECTABLE
);
break;
}
default: {
console.warn(`Unhandled zoneType: ${place.zone}`);
}
}
}
};
......@@ -4,7 +4,7 @@ import { messageStore } from "@/stores";
type MsgSelectPosition = ygopro.StocGameMessage.MsgSelectPosition;
export default (selectPosition: MsgSelectPosition) => {
const player = selectPosition.player;
const _player = selectPosition.player;
const positions = selectPosition.positions;
messageStore.positionModal.positions = positions.map(
......
......@@ -3,7 +3,7 @@ import { fetchCheckCardMeta, messageStore } from "@/stores";
type MsgSelectUnselectCard = ygopro.StocGameMessage.MsgSelectUnselectCard;
export default ({
export default async ({
finishable,
cancelable,
min,
......@@ -16,14 +16,15 @@ export default ({
messageStore.selectCardActions.min = min;
messageStore.selectCardActions.max = max;
messageStore.selectCardActions.single = true;
messageStore.selectCardActions.isValid = true;
messageStore.selectCardActions.isOpen = true;
for (const option of selectableCards) {
fetchCheckCardMeta(option);
await fetchCheckCardMeta(option);
}
for (const option of selectedCards) {
fetchCheckCardMeta(option, true);
await fetchCheckCardMeta(option, true);
}
messageStore.selectCardActions.isValid = true;
messageStore.selectCardActions.isOpen = true;
};
......@@ -4,7 +4,7 @@ import { messageStore } from "@/stores";
type MsgSelectYesNo = ygopro.StocGameMessage.MsgSelectYesNo;
export default async (selectYesNo: MsgSelectYesNo) => {
const player = selectYesNo.player;
const _player = selectYesNo.player;
const effect_description = selectYesNo.effect_description;
messageStore.yesNoModal.msg = await getStrings(effect_description);
......
import { ygopro } from "@/api";
import { matStore } from "@/stores";
import { cardStore } from "@/stores";
type MsgShuffleHand = ygopro.StocGameMessage.MsgShuffleHand;
export default (shuffleHand: MsgShuffleHand) => {
const { hands: codes, player: controller } = shuffleHand;
const indexMap = new Map(codes.map((code, idx) => [code, idx]));
matStore.hands.of(controller).sort((a, b) => {
const indexA = indexMap.get(a.occupant?.id ?? 0) ?? 0;
const indexB = indexMap.get(b.occupant?.id ?? 0) ?? 0;
// 本质上是要将手卡的sequence变成和codes一样的顺序
const hands = cardStore.at(ygopro.CardZone.HAND, controller);
const hash = new Map(codes.map((code) => [code, new Array()]));
codes.forEach((code, sequence) => {
hash.get(code)?.push(sequence);
});
return indexA - indexB;
hands.forEach((hand) => {
const sequences = hash.get(hand.code);
if (sequences !== undefined) {
const sequence = sequences.pop();
if (sequence !== undefined) {
hand.location.sequence = sequence;
hash.set(hand.code, sequences);
} else {
console.warn(
`<ShuffleHand>sequence poped is none, controller=${controller}, code=${hand.code}, sequence=${sequence}`
);
}
} else {
console.warn(
`<ShuffleHand>target from records is null, controller=${controller}, hands=${hands.map(
(hand) => hand.code
)}, codes=${codes}`
);
}
});
};
import { flatten } from "lodash-es";
import { v4 as v4uuid } from "uuid";
import { proxy } from "valtio";
import { subscribeKey } from "valtio/utils";
import { ygopro } from "@/api";
import { playerStore, store } from "@/stores";
import { fetchCard, ygopro } from "@/api";
import { cardStore, CardType, store } from "@/stores";
const { matStore } = store;
const TOKEN_SIZE = 13; // 每人场上最多就只可能有13个token
export default (start: ygopro.StocGameMessage.MsgStart) => {
// 先初始化`matStore`
matStore.selfType = start.playerType;
const opponent =
start.playerType == ygopro.StocGameMessage.MsgStart.PlayerType.FirstStrike
? 1
: 0;
const meName = playerStore.getMePlayer().name;
const opName = playerStore.getOpPlayer().name;
matStore.initInfo.set(0, {
life: start.life1,
name: opponent == 0 ? opName : meName,
deckSize: start.deckSize1,
extraSize: start.extraSize1,
});
matStore.initInfo.set(1, {
life: start.life2,
name: opponent == 1 ? opName : meName,
deckSize: start.deckSize2,
extraSize: start.extraSize2,
});
matStore.monsters.of(0).forEach((x) => (x.location.controler = 0));
matStore.monsters.of(1).forEach((x) => (x.location.controler = 1));
matStore.magics.of(0).forEach((x) => (x.location.controler = 0));
matStore.magics.of(1).forEach((x) => (x.location.controler = 1));
// 再初始化`cardStore`
const cards = flatten(
[
start.deckSize1,
start.extraSize1,
TOKEN_SIZE,
start.deckSize2,
start.extraSize2,
TOKEN_SIZE,
].map((length, i) =>
Array.from({ length }).map((_, sequence) =>
genCard({
uuid: v4uuid(),
code: 0,
location: new ygopro.CardLocation({
controller: i < 3 ? 0 : 1,
zone: [
ygopro.CardZone.DECK,
ygopro.CardZone.EXTRA,
ygopro.CardZone.TZONE,
][i % 3],
sequence,
position: ygopro.CardPosition.FACEDOWN,
}),
originController: i < 3 ? 0 : 1,
counters: {},
idleInteractivities: [],
meta: {
id: 0,
data: {},
text: {},
},
isToken: !((i + 1) % 3),
chaining: false,
directAttack: false,
})
)
)
);
for (let i = 0; i < start.deckSize1; i++) {
matStore.decks.of(0).push({
uuid: v4uuid(),
occupant: {
id: 0,
data: {},
text: {},
},
location: {
controler: 0,
zone: ygopro.CardZone.DECK,
},
focus: false,
chaining: false,
directAttack: false,
counters: {},
idleInteractivities: [],
});
}
for (let i = 0; i < start.deckSize2; i++) {
matStore.decks.of(1).push({
uuid: v4uuid(),
occupant: {
id: 0,
data: {},
text: {},
},
location: {
controler: 1,
zone: ygopro.CardZone.DECK,
},
focus: false,
chaining: false,
directAttack: false,
counters: {},
idleInteractivities: [],
});
}
// 初始化对手的额外卡组
for (let i = 0; i < start.extraSize2; i++) {
matStore.extraDecks.op.push({
uuid: v4uuid(),
occupant: {
id: 0,
data: {},
text: {},
},
location: {
controler: opponent,
zone: ygopro.CardZone.EXTRA,
},
focus: false,
chaining: false,
directAttack: false,
counters: {},
idleInteractivities: [],
});
}
cardStore.inner.push(...cards);
// 设置自己的额外卡组,信息是在waitroom之中拿到的
cardStore
.at(ygopro.CardZone.EXTRA, 1 - opponent)
.forEach((card) => (card.code = myExtraDeckCodes.pop()!));
};
// 在`WaitRoom`页面会设置自己的额外卡组,但那时候拿不到正确的`controller`值,因为不知道自己是先攻还是后手,因此这里需要重新为自己的额外卡组设置`controller`值
matStore
.in(ygopro.CardZone.EXTRA)
.me.forEach((state) => (state.location.controler = 1 - opponent));
// 自动从code推断出occupant
const genCard = (o: CardType) => {
const t = proxy(o);
subscribeKey(t, "code", async (code) => {
const meta = await fetchCard(code ?? 0);
t.meta = meta;
});
return t;
};
import { ygopro } from "@/api";
import { getCardByLocation } from "@/stores";
import { cardStore } from "@/stores";
type MsgUpdateCounter = ygopro.StocGameMessage.MsgUpdateCounter;
export default (updateCounter: MsgUpdateCounter) => {
const { location, count, action_type: counterType } = updateCounter;
const target = getCardByLocation(location); // 不太确定这个后面能不能相应,我不好说
const target = cardStore.find(location); // 不太确定这个后面能不能相应,我不好说
if (target) {
switch (counterType) {
case ygopro.StocGameMessage.MsgUpdateCounter.ActionType.ADD: {
......
import { ygopro } from "@/api";
import MsgUpdateData = ygopro.StocGameMessage.MsgUpdateData;
import { matStore } from "@/stores";
import { cardStore } from "@/stores";
export default (updateData: MsgUpdateData) => {
const { player: controller, zone, actions } = updateData;
if (controller !== undefined && zone !== undefined && actions !== undefined) {
const field = matStore.in(zone).of(controller);
const field = cardStore.at(zone, controller);
actions.forEach((action) => {
const sequence = action.location?.sequence;
if (typeof sequence !== "undefined") {
const target = field[sequence];
if (target && (target.occupant || target.reload)) {
if (target.occupant === undefined) {
target.occupant = { id: action.code!, data: {}, text: {} };
}
const occupant = target.occupant;
const target = field
.filter((card) => card.location.sequence == sequence)
.at(0);
if (target) {
const meta = target.meta;
// 目前只更新以下字段
if (action.code !== undefined && action.code >= 0) {
occupant.id = action.code;
occupant.text.id = action.code;
if (action?.code >= 0) {
meta.id = action.code;
meta.text.id = action.code;
}
if (action.location !== undefined) {
target.location.position = action.location.position;
}
if (action.type_ !== undefined && action.type_ >= 0) {
occupant.data.type = action.type_;
if (action?.type_ >= 0) {
meta.data.type = action.type_;
}
if (action.level !== undefined && action.level >= 0) {
occupant.data.level = action.level;
if (action?.level >= 0) {
meta.data.level = action.level;
}
if (action.attribute !== undefined && action.attribute >= 0) {
occupant.data.attribute = action.attribute;
if (action?.attribute >= 0) {
meta.data.attribute = action.attribute;
}
if (action.race !== undefined && action.race >= 0) {
occupant.data.race = action.race;
if (action?.race >= 0) {
meta.data.race = action.race;
}
if (action.attack !== undefined && action.attack >= 0) {
occupant.data.atk = action.attack;
if (action?.attack >= 0) {
meta.data.atk = action.attack;
}
if (action.defense !== undefined && action.defense >= 0) {
occupant.data.def = action.defense;
if (action?.defense >= 0) {
meta.data.def = action.defense;
}
// TODO: counters
} else {
console.warn(
`<UpdateData>target from zone=${zone}, controller=${controller}, sequence=${sequence} is null`
);
console.info(field);
}
if (target?.reload) {
target.reload = false;
......
import { ygopro } from "@/api";
import {
clearAllIdleInteractivities as clearAllIdleInteractivities,
matStore,
} from "@/stores";
import { cardStore, matStore } from "@/stores";
export default (_wait: ygopro.StocGameMessage.MsgWait) => {
clearAllIdleInteractivities(0);
clearAllIdleInteractivities(1);
for (const card of cardStore.inner) {
card.idleInteractivities = [];
}
matStore.waiting = true;
};
......@@ -19,12 +19,12 @@ export default function handleHsPlayerChange(pb: ygopro.YgoStocMsg) {
case ygopro.StocHsPlayerChange.State.MOVE: {
console.log("Player " + change.pos + " moved to " + change.moved_pos);
let src = change.pos;
let dst = change.moved_pos;
let _src = change.pos;
let _dst = change.moved_pos;
console.log("Currently unsupport Move type of StocHsPlayerChange.");
// todo
// TODO
break;
}
......
......@@ -2,7 +2,7 @@ import { ygopro } from "@/api";
import { joinStore } from "@/stores";
export default function handleJoinGame(pb: ygopro.YgoStocMsg) {
const msg = pb.stoc_join_game;
// todo
const _msg = pb.stoc_join_game;
// TODO
joinStore.value = true;
}
import { proxy } from "valtio";
import { CardMeta, fetchCard, ygopro } from "@/api";
import type { Interactivity } from "./matStore/types";
/**
* 场上某位置的状态
*/
export interface CardType {
uuid: string; // 一张卡的唯一标识
code: number; // 卡号
meta: CardMeta; // 卡片元数据
location: ygopro.CardLocation;
originController: number; // 在卡组构建之中持有这张卡的玩家,方便reloadField的使用
idleInteractivities: Interactivity<number>[]; // IDLE状态下的互动信息
placeInteractivity?: Interactivity<{
controller: number;
zone: ygopro.CardZone;
sequence: number;
}>; // 选择位置状态下的互动信息
counters: { [type: number]: number }; // 指示器
reload?: boolean; // 这个字段会在收到MSG_RELOAD_FIELD的时候设置成true,在收到MSG_UPDATE_DATE的时候设置成false
isToken: boolean; // 是否是token
// 新的字段(从matstore之中搬过来的)
chaining: boolean; // 是否在连锁中
chainIndex?: number /*连锁的序号,如果为空表示不在连锁
TODO: 目前是妥协的设计,因为其实一张卡是可以在同一个连锁链中被连锁多次的,这里为了避免太过复杂只保存最后的连锁序号*/;
directAttack: boolean; // 是否正在直接攻击为玩家
attackTarget?: CardType & { opponent: boolean }; // 攻击目标。(嵌套结构可行么?)
}
class CardStore {
inner: CardType[] = [];
at(zone: ygopro.CardZone, controller: number): CardType[];
at(
zone: ygopro.CardZone,
controller: number,
sequence?: number,
overlay_sequence?: number
): CardType | undefined;
at(
zone: ygopro.CardZone,
controller: number,
sequence?: number,
overlay_sequence?: number
) {
if (sequence !== undefined) {
if (overlay_sequence !== undefined) {
return this.inner
.filter(
(card) =>
card.location.zone === zone &&
card.location.controller === controller &&
card.location.sequence === sequence &&
card.location.is_overlay == true &&
card.location.overlay_sequence == overlay_sequence
)
.at(0);
} else {
return this.inner
.filter(
(card) =>
card.location.zone === zone &&
card.location.controller === controller &&
card.location.sequence === sequence &&
card.location.is_overlay == false
)
.at(0);
}
} else {
return this.inner.filter(
(card) =>
card.location.zone === zone &&
card.location.controller === controller &&
card.location.is_overlay == false
);
}
}
find(location: ygopro.CardLocation): CardType | undefined {
return this.at(location.zone, location.controller, location.sequence);
}
// 获取特定位置下的所有超量素材
findOverlay(
zone: ygopro.CardZone,
controller: number,
sequence: number
): CardType[] {
return this.inner.filter(
(card) =>
card.location.zone == zone &&
card.location.controller == controller &&
card.location.sequence == sequence &&
card.location.is_overlay
);
}
async setChaining(
location: ygopro.CardLocation,
code: number,
isChaining: boolean
): Promise<void> {
const target = this.find(location);
if (target) {
target.chaining = isChaining;
if (isChaining) {
// 目前需要判断`isChaining`为ture才设置meta,因为有些手坑发效果后会move到墓地,
// 运行到这里的时候已经和原来的位置对不上了,这时候不设置meta
const meta = await fetchCard(code);
// 这里不能设置`code`,因为存在一个场景:
// 对方的`魔神仪-曼德拉护肤草`发动效果后,后端会发一次`MSG_SHUFFLE_HAND`,但传给前端的codes全是0,如果这里设置了`code`的话,在后面的`MSG_SHUFFLE_HAND`处理就会有问题。
// target.code = meta.id;
target.meta = meta;
}
if (target.location.zone == ygopro.CardZone.HAND) {
target.location.position = isChaining
? ygopro.CardPosition.FACEUP_ATTACK
: ygopro.CardPosition.FACEDOWN_ATTACK;
}
}
}
}
export const cardStore = proxy(new CardStore());
// @ts-ignore
window.cardStore = cardStore;
export * from "./cardStore";
export * from "./chatStore";
export * from "./joinStore";
export * from "./matStore";
export * from "./messageStore";
export * from "./methods";
export * from "./moraStore";
export * from "./placeStore";
export * from "./playerStore";
import { proxy } from "valtio";
import { devtools } from "valtio/utils";
import { cardStore } from "./cardStore";
import { chatStore } from "./chatStore";
import { joinStore } from "./joinStore";
import { matStore } from "./matStore";
import { messageStore } from "./messageStore";
import { moraStore } from "./moraStore";
import { placeStore } from "./placeStore";
import { playerStore } from "./playerStore";
export const store = proxy({
......@@ -22,6 +27,8 @@ export const store = proxy({
moraStore,
matStore, // 决斗盘
messageStore, // 决斗的信息,包括模态框
cardStore,
placeStore,
});
devtools(store, { name: "valtio store", enabled: true });
import type { ygopro } from "@/api";
import { DESCRIPTION_LIMIT, fetchStrings, getStrings } from "@/api";
import { fetchCard } from "@/api/cards";
import { cardStore } from "@/stores/cardStore";
import { matStore } from "../store";
......@@ -63,12 +64,13 @@ export const fetchEsHintMeta = async ({
}
if (location) {
const fieldMeta = matStore
.in(location.location)
.of(location.controler)
.at(location.sequence);
if (fieldMeta?.occupant?.text.name) {
esHint = esHint.replace("[?]", fieldMeta.occupant.text.name);
const fieldMeta = cardStore.at(
location.zone,
location.controller,
location.sequence
);
if (fieldMeta?.meta.text.name) {
esHint = esHint.replace("[?]", fieldMeta.meta.text.name);
}
}
......
import { fetchCard } from "@/api";
import { matStore } from "@/stores";
export const fetchOverlayMeta = async (
controller: number,
sequence: number,
overlayCodes: number[],
append?: boolean
) => {
const metas = await Promise.all(
overlayCodes.map(async (id) => await fetchCard(id))
);
const target = matStore.monsters.of(controller)[sequence];
if (target && target.occupant) {
if (append) {
target.overlay_materials = (target.overlay_materials || []).concat(metas);
} else {
target.overlay_materials = metas;
}
}
};
import type { ygopro } from "@/api";
import { matStore } from "@/stores";
export const getCardByLocation = (location: ygopro.CardLocation) => {
return matStore.in(location.location).of(location.controler)[
location.sequence
];
};
export * from "./fetchCheckCardMeta";
export * from "./fetchHint";
export * from "./fetchOverlayMeta";
export * from "./getCardByLocation";
import { cloneDeep } from "lodash-es";
import { v4 as v4uuid } from "uuid";
/* eslint valtio/avoid-this-in-proxy: 0 */
import { proxy } from "valtio";
import { ygopro } from "@/api";
import { fetchCard } from "@/api/cards";
import { useConfig } from "@/config";
import type {
CardState,
DuelFieldState as ArrayCardState,
InitInfo,
MatState,
} from "./types";
import { InteractType } from "./types";
import type { InitInfo, MatState } from "./types";
/**
* 根据controller判断是自己还是对方。
......@@ -21,127 +12,11 @@ import { InteractType } from "./types";
const getWhom = (controller: number): "me" | "op" =>
isMe(controller) ? "me" : "op";
/** 卡的列表,提供了一些方便的方法 */
class CardArray extends Array<CardState> implements ArrayCardState {
public __proto__ = CardArray.prototype;
public zone: ygopro.CardZone = ygopro.CardZone.MZONE;
public getController: () => number = () => 1;
private genCard = async (
uuid: string,
controller: number,
id: number,
position?: ygopro.CardPosition,
focus?: boolean,
chainIndex?: number
) => ({
uuid,
occupant: await fetchCard(id),
location: {
controler: controller,
zone: this.zone,
position:
position == undefined ? ygopro.CardPosition.FACEUP_ATTACK : position,
},
focus: focus ?? false,
chaining: false,
chainIndex,
directAttack: false,
counters: {},
idleInteractivities: [],
});
// methods
remove(sequence: number) {
return this.splice(sequence, 1)[0];
}
async insert(
uuid: string,
id: number,
sequence: number,
position?: ygopro.CardPosition,
focus?: boolean,
chainIndex?: number
) {
const card = await this.genCard(
uuid,
this.getController(),
id,
position,
focus,
chainIndex
);
this.splice(sequence, 0, card);
}
async add(
data: { uuid: string; id: number }[],
position?: ygopro.CardPosition,
focus?: boolean
) {
const cards = await Promise.all(
data.map(async ({ uuid, id }) =>
this.genCard(uuid, this.getController(), id, position, focus)
)
);
this.splice(this.length, 0, ...cards);
}
async setOccupant(
sequence: number,
id: number,
position?: ygopro.CardPosition,
focus?: boolean
) {
const meta = await fetchCard(id);
const target = this[sequence];
target.focus = focus ?? false;
target.occupant = meta;
if (position) {
target.location.position = position;
}
}
addIdleInteractivity(
sequence: number,
interactivity: CardState["idleInteractivities"][number]
) {
this[sequence].idleInteractivities.push(interactivity);
}
clearIdleInteractivities() {
this.forEach((card) => (card.idleInteractivities = []));
}
setPlaceInteractivityType(sequence: number, interactType: InteractType) {
this[sequence].placeInteractivity = {
interactType: interactType,
response: {
controler: this.getController(),
zone: this.zone,
sequence,
},
};
}
clearPlaceInteractivity() {
this.forEach((card) => (card.placeInteractivity = undefined));
}
}
const genDuelCardArray = (cardStates: CardState[], zone: ygopro.CardZone) => {
// 为什么不放在构造函数里面,是因为不想改造继承自Array的构造函数
const me = cloneDeep(new CardArray(...cardStates));
me.zone = zone;
me.getController = () => (matStore.selfType === 1 ? 0 : 1);
const op = cloneDeep(new CardArray(...cardStates));
op.zone = zone;
op.getController = () => (matStore.selfType === 1 ? 1 : 0);
const res = proxy({
me,
op,
of: (controller: number) => res[getWhom(controller)],
});
return res;
};
/**
* 根据自己的先后手判断是否是自己
* 原本名字叫judgeSelf
*/
const isMe = (controller: number): boolean => {
export const isMe = (controller: number): boolean => {
switch (matStore.selfType) {
case 1:
// 自己是先攻
......@@ -156,24 +31,6 @@ const isMe = (controller: number): boolean => {
}
};
/**
* 生成一个指定长度的卡片数组
*/
const genBlock = (zone: ygopro.CardZone, n: number) =>
Array(n)
.fill(null)
.map((_) => ({
uuid: v4uuid(), // WARN: 这里其实应该不分配UUID
location: {
zone,
},
focus: false,
chaining: false,
directAttack: false,
idleInteractivities: [],
counters: {},
}));
const initInfo: MatState["initInfo"] = (() => {
const defaultInitInfo = {
masterRule: "UNKNOWN",
......@@ -195,50 +52,11 @@ const initInfo: MatState["initInfo"] = (() => {
});
})();
const hint: MatState["hint"] = proxy({
code: -1,
});
/**
* zone -> matStore
*/
const getZone = (zone: ygopro.CardZone) => {
switch (zone) {
case ygopro.CardZone.MZONE:
return matStore.monsters;
case ygopro.CardZone.SZONE:
return matStore.magics;
case ygopro.CardZone.HAND:
return matStore.hands;
case ygopro.CardZone.DECK:
return matStore.decks;
case ygopro.CardZone.GRAVE:
return matStore.graveyards;
case ygopro.CardZone.REMOVED:
return matStore.banishedZones;
case ygopro.CardZone.EXTRA:
return matStore.extraDecks;
default:
console.error("in error", zone);
return matStore.extraDecks;
}
};
const { SZONE, MZONE, GRAVE, REMOVED, HAND, DECK, EXTRA } = ygopro.CardZone;
/**
* 💡 决斗盘状态仓库,本文件核心,
* 具体介绍可以点进`MatState`去看
*/
export const matStore: MatState = proxy<MatState>({
magics: genDuelCardArray(genBlock(SZONE, 6), SZONE),
monsters: genDuelCardArray(genBlock(MZONE, 7), MZONE),
graveyards: genDuelCardArray([], GRAVE),
banishedZones: genDuelCardArray([], REMOVED),
hands: genDuelCardArray([], HAND),
decks: genDuelCardArray([], DECK),
extraDecks: genDuelCardArray([], EXTRA),
chains: [],
timeLimits: {
......@@ -254,7 +72,7 @@ export const matStore: MatState = proxy<MatState>({
initInfo,
selfType: ygopro.StocTypeChange.SelfType.UNKNOWN,
hint,
hint: { code: -1 },
currentPlayer: -1,
phase: {
currentPhase: ygopro.StocGameMessage.MsgNewPhase.PhaseType.UNKNOWN, // TODO 当前的阶段 应该改成enum
......@@ -266,65 +84,8 @@ export const matStore: MatState = proxy<MatState>({
waiting: false,
unimplemented: 0,
// methods
in: getZone,
isMe,
async setChaining(location, code, isChaining) {
const target = this.in(location.location)
.of(location.controler)
.at(location.sequence);
if (target) {
target.chaining = isChaining;
if (target.occupant && isChaining) {
// 目前需要判断`isChaining`为ture才设置meta,因为有些手坑发效果后会move到墓地,
// 运行到这里的时候已经和原来的位置对不上了,这时候不设置meta
const meta = await fetchCard(code);
target.occupant = meta;
}
if (target.location.zone == ygopro.CardZone.HAND) {
target.location.position = isChaining
? ygopro.CardPosition.FACEUP_ATTACK
: ygopro.CardPosition.FACEDOWN_ATTACK;
}
}
},
setChained(location, chainIndex) {
const target = this.in(location.location)
.of(location.controler)
.at(location.sequence);
if (target) {
target.chainIndex = chainIndex;
} else {
console.warn(`target is null in setChained, location=${location}`);
}
},
setFocus(location, focus) {
const target = this.in(location.location)
.of(location.controler)
.at(location.sequence);
if (target) {
target.focus = focus;
} else {
console.warn(`target is null in setFocus, location=${location}`);
}
},
});
// @ts-ignore 挂到全局,便于调试
window.matStore = matStore;
// 修改原型链,因为valtio的proxy会把原型链改掉。这应该是valtio的一个bug...有空提issue去改
(["me", "op"] as const).forEach((who) => {
(
[
"hands",
"decks",
"extraDecks",
"graveyards",
"banishedZones",
"monsters",
"magics",
] as const
).forEach((zone) => {
matStore[zone][who].__proto__ = CardArray.prototype;
});
});
import type { ygopro } from "@/api";
import type { CardMeta } from "@/api/cards";
// >>> play mat state >>>
......@@ -9,54 +8,6 @@ export interface BothSide<T> {
/** 根据controller返回对应的数组,op或者me */
of: (controller: number) => T;
}
/**
* CardState的顺序index,被称为sequence
*/
export interface DuelFieldState extends Array<CardState> {
/** 移除特定位置的卡片 */
remove: (sequence: number) => CardState;
/** 在指定位置插入卡片 */
insert: (
uuid: string,
id: number,
sequence: number,
position?: ygopro.CardPosition,
focus?: boolean,
chainIndex?: number
) => Promise<void>;
/** 在末尾添加卡片 */
add: (
data: { uuid: string; id: number }[],
position?: ygopro.CardPosition,
focus?: boolean
) => Promise<void>;
/** 设置占据这个位置的卡片信息 */
setOccupant: (
sequence: number,
id: number,
position?: ygopro.CardPosition,
focus?: boolean
) => Promise<void>;
/** 添加 idle 的交互性 */
addIdleInteractivity: (
sequence: number,
interactivity: CardState["idleInteractivities"][number]
) => void;
/** 移除 idle 的交互性 */
clearIdleInteractivities: () => void;
/** 设置 place 的交互种类 */
setPlaceInteractivityType: (
sequence: number,
interactType: InteractType
) => void;
/** 移除 place 的交互性 */
clearPlaceInteractivity: () => void;
// 让原型链不报错
__proto__?: DuelFieldState;
}
type test = DuelFieldState extends (infer S)[] ? S : never;
export interface MatState {
selfType: number;
......@@ -65,20 +16,6 @@ export interface MatState {
set: (controller: number, obj: Partial<InitInfo>) => void;
}; // 双方的初始化信息
hands: BothSide<HandState>; // 双方的手牌
monsters: BothSide<MonsterState>; // 双方的怪兽区状态
magics: BothSide<MagicState>; // 双方的魔法区状态
graveyards: BothSide<GraveyardState>; // 双方的墓地状态
banishedZones: BothSide<BanishedZoneState>; // 双方的除外区状态
decks: BothSide<DeckState>; // 双方的卡组状态
extraDecks: BothSide<ExtraDeckState>; // 双方的额外卡组状态
chains: ygopro.CardLocation[]; // 连锁的卡片位置
timeLimits: BothSide<number> & {
......@@ -97,76 +34,17 @@ export interface MatState {
unimplemented: number; // 未处理的`Message`
// >>> methods >>>
/** 根据zone获取hands/masters/magics... */
in: (zone: ygopro.CardZone) => BothSide<DuelFieldState>;
/** 根据自己的先后手判断是否是自己 */
isMe: (player: number) => boolean;
// 添加连锁中状态
// - 当是手牌以外的卡时,修改code并设置chaining字段;
// - 当是手牌中的卡时,修改code,设置chaining字段,并修改position,参数`isChaining`为true时修改成`FaceUpAttack`,为false时修改成`FaceDownAttack`
setChaining: (
location: ygopro.CardLocation,
code: number,
isChaining: boolean
) => Promise<void>;
// 添加被连锁状态
setChained: (location: ygopro.CardLocation, chainIndex?: number) => void;
// 设置聚焦状态
setFocus: (location: ygopro.CardLocation, focus: boolean) => void;
}
export interface InitInfo {
masterRule?: string;
name: string;
life: number;
deckSize: number;
extraSize: number;
}
/**
* 场上某位置的状态,
* 以后会更名为 BlockState
*/
export interface CardState {
uuid: string; // 一张卡的唯一标识
occupant?: CardMeta; // 占据此位置的卡牌元信息
location: {
controler?: number; // 控制这个位置的玩家,0或1
zone: ygopro.CardZone; // 怪兽区/魔法陷阱区/手牌/卡组/墓地/除外区
position?: ygopro.CardPosition; // 卡片的姿势:攻击还是守备
}; // 位置信息,叫location的原因是为了和ygo对齐
focus: boolean; // 用于实现动画效果,当这个字段为true时,该张卡片会被放大并在屏幕中央展示
chaining: boolean; // 是否在连锁中
chainIndex?: number /*连锁的序号,如果为空表示不在连锁
TODO: 目前是妥协的设计,因为其实一张卡是可以在同一个连锁链中被连锁多次的,这里为了避免太过复杂只保存最后的连锁序号*/;
directAttack: boolean; // 是否正在直接攻击为玩家
attackTarget?: CardState & { sequence: number; opponent: boolean }; // 攻击目标。(嵌套结构可行么?)
idleInteractivities: Interactivity<number>[]; // IDLE状态下的互动信息
placeInteractivity?: Interactivity<{
controler: number;
zone: ygopro.CardZone;
sequence: number;
}>; // 选择位置状态下的互动信息
overlay_materials?: CardMeta[]; // 超量素材, FIXME: 这里需要加上UUID
counters: { [type: number]: number }; // 指示器
reload?: boolean; // 这个字段会在收到MSG_RELOAD_FIELD的时候设置成true,在收到MSG_UPDATE_DATE的时候设置成false
}
export interface BlockState {
// 位置信息
location: {
controller: number;
zone: ygopro.CardZone;
};
// 选择位置状态下的互动信息
placeInteractivity?: Interactivity<{
controler: number;
zone: ygopro.CardZone;
sequence: number;
}>;
}
export interface Interactivity<T> {
interactType: InteractType;
// 如果`interactType`是`ACTIVATE`,这个字段是对应的效果编号
......@@ -196,13 +74,6 @@ export enum InteractType {
ATTACK = 8,
}
export interface HandState extends DuelFieldState {}
export interface MonsterState extends DuelFieldState {}
export interface MagicState extends DuelFieldState {}
export interface GraveyardState extends DuelFieldState {}
export interface BanishedZoneState extends DuelFieldState {}
export interface DeckState extends DuelFieldState {}
export interface ExtraDeckState extends DuelFieldState {}
export interface TimeLimit {
leftTime: number;
}
......
import { matStore } from "@/stores";
export const clearAllIdleInteractivities = (controller: number) => {
matStore.banishedZones.of(controller).clearIdleInteractivities();
matStore.decks.of(controller).clearIdleInteractivities();
matStore.extraDecks.of(controller).clearIdleInteractivities();
matStore.graveyards.of(controller).clearIdleInteractivities();
matStore.hands.of(controller).clearIdleInteractivities();
matStore.magics.of(controller).clearIdleInteractivities();
matStore.monsters.of(controller).clearIdleInteractivities();
};
import { ygopro } from "@/api";
import { matStore } from "@/stores";
/** 清空所有place互动性,也可以删除某一个zone的互动性。zone为空则为清除所有。 */
export const clearAllPlaceInteradtivities = (
controller: number,
zone?: ygopro.CardZone
) => {
if (zone) {
matStore.in(zone).of(controller).clearPlaceInteractivity();
} else {
matStore.banishedZones.of(controller).clearPlaceInteractivity();
matStore.decks.of(controller).clearPlaceInteractivity();
matStore.extraDecks.of(controller).clearPlaceInteractivity();
matStore.graveyards.of(controller).clearPlaceInteractivity();
matStore.hands.of(controller).clearPlaceInteractivity();
matStore.magics.of(controller).clearPlaceInteractivity();
matStore.monsters.of(controller).clearPlaceInteractivity();
}
};
export * from "./clearAllIdleInteractivities";
export * from "./clearAllPlaceInteradtivities";
export * from "./clearSelectActions";
import { ygopro } from "@/api";
import { fetchCard, getCardStr } from "@/api/cards";
import { matStore, messageStore } from "@/stores";
import { cardStore, messageStore } from "@/stores";
export const fetchCheckCardMeta = async (
{
......@@ -21,12 +21,11 @@ export const fetchCheckCardMeta = async (
selected?: boolean,
mustSelect?: boolean
) => {
const controller = location.controler;
const controller = location.controller;
const newID =
code != 0
? code
: matStore.in(location.location).of(controller)[location.sequence]
?.occupant?.id || 0;
: cardStore.at(location.zone, controller, location.sequence)?.code || 0;
const meta = await fetchCard(newID);
const effectDesc = effectDescCode
......
export * from "./fetchCheckCardMeta";
import { proxy } from "valtio";
import { ygopro } from "@/api";
import { matStore } from "@/stores";
import type { Interactivity } from "./matStore/types";
export type PlaceInteractivity =
| Interactivity<{
controller: number;
zone: ygopro.CardZone;
sequence: number;
}>
| undefined;
const { MZONE, SZONE } = ygopro.CardZone;
export const placeStore = proxy({
inner: {
[MZONE]: {
me: Array.from({ length: 7 }).map(() => undefined as PlaceInteractivity),
op: Array.from({ length: 7 }).map(() => undefined as PlaceInteractivity),
},
[SZONE]: {
me: Array.from({ length: 6 }).map(() => undefined as PlaceInteractivity),
op: Array.from({ length: 6 }).map(() => undefined as PlaceInteractivity),
},
},
set(
zone: ygopro.CardZone.MZONE | ygopro.CardZone.SZONE,
controller: number,
sequence: number,
placeInteractivity: PlaceInteractivity
) {
placeStore.inner[zone][matStore.isMe(controller) ? "me" : "op"][sequence] =
placeInteractivity;
},
clearAll() {
(["me", "op"] as const).forEach((who) => {
([MZONE, SZONE] as const).forEach((where) => {
placeStore.inner[where][who] = placeStore.inner[where][who].map(
() => undefined
);
});
});
},
});
/* eslint valtio/avoid-this-in-proxy: 0 */
import { proxy } from "valtio";
import { ygopro } from "@/api";
......
......@@ -2,40 +2,39 @@
// thanks!
@charset "utf-8";
ol, ul {
list-style: none;
ol,
ul {
list-style: none;
}
blockquote, q {
quotes: none;
blockquote,
q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
blockquote:before,
blockquote:after,
q:before,
q:after {
content: "";
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
border-collapse: collapse;
border-spacing: 0;
}
#root {
display: flex;
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://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.1/css/all.min.css"),
"commom", "header", "login-form", "sign-in";
body {
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font: 87.5%/1.5em 'Open Sans', sans-serif;
font: 87.5%/1.5em "Open Sans", sans-serif;
display: flex;
margin: 0;
place-items: center;
......@@ -44,20 +43,20 @@ body {
}
a {
text-decoration: none;
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;
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;
line-height: 1.5em;
}
.clearfix {
......@@ -65,25 +64,28 @@ p {
&:before,
&:after {
content: ' ';
content: " ";
display: table;
}
&:after {
clear: both;
}
}
.container {
margin: 0 auto;
// left: 50%;
// position: fixed;
// top: 50%;
// transform: translate(-50%, -50%);
margin: 0 auto;
width: 100%;
max-width: 300px;
margin-top: 200px;
max-width: 300px;
margin-top: 200px;
}
.g-row {
margin: 0 auto;
width: 100%;
max-width: 1000px;
margin: 0 auto;
width: 100%;
max-width: 1000px;
}
#root {
margin: 0 auto;
text-align: center;
}
:root {
--perspective: 800px;
--scale: 1.35;
--board-rotate-z: 20deg;
--block-width: calc(80px * var(--scale));
--block-height: calc(80px * var(--scale));
--block-column-gap: calc(10px * var(--scale));
--block-row-gap: calc(10px * var(--scale));
--card-w-l-ratio: calc(5.9 / 8.6);
--deck-offset-x: calc(90px * var(--scale));
--deck-offset-y: calc(45px * var(--scale));
--deck-rotate-z: 30deg;
--opponent-deg: 180deg;
--hand-rotate: calc(var(--board-rotate-z) * -1);
--highlight-interval: 800ms;
--highlight-color-x: #393;
--highlight-color-y: #6f6;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
#controller {
position: fixed;
display: flex;
gap: 20px;
bottom: 20px;
right: 20px;
}
#life-bar-container {
position: fixed;
display: flex;
gap: 20px;
top: 20px;
right: 20px;
font-size: 1.5em;
font-weight: 500;
font-family: inherit;
flex-direction: column;
}
#life-bar {
padding: 0.8em 1.6em;
background-color: #A9A9A9;
border-radius: 8px;
text-align: left;
border: 1px solid transparent;
color: black;
opacity: .4;
}
#camera {
perspective: var(--perspective);
transform-style: preserve-3d;
}
#board {
perspective-origin: center center;
transform: translateX(0) translateY(0) translateZ(0)
rotateX(var(--board-rotate-z));
transform-style: preserve-3d;
position: relative;
}
.card {
position: absolute;
left: 0;
top: 0;
--trans-time: 0.3s;
transition: var(--trans-time);
aspect-ratio: var(--card-w-l-ratio);
background-color: skyblue;
background: var(--card-img);
background-size: cover;
height: var(--block-height);
--x-margin-left: calc(
var(--c) * calc(var(--block-width) + var(--block-column-gap))
);
--x-padding: calc(
(var(--block-width) - var(--block-height) * var(--card-w-l-ratio)) / 2
);
--x: calc(var(--x-margin-left) + var(--x-padding));
--y: calc(var(--r) * calc(var(--block-height) + var(--block-row-gap)));
--z: calc(var(--h) * 1px);
transform: translateZ(var(--z)) rotateX(calc(var(--hand-rotate) * var(--vertical))) scale(var(--scale-focus));
translate: var(--x) var(--y);
rotate: calc(var(--opponent-deg) * (1 - var(--vertical)));
transform-style: preserve-3d;
z-index: 10;
animation: glow calc(var(--highlight-interval) * var(--highlight-on)) ease-out infinite alternate;
}
.card-defense {
--height: var(--block-width);
height: calc(var(--height));
--x-margin-left: calc(
var(--c) * calc(var(--block-width) + var(--block-column-gap))
);
--x-padding: calc(
(var(--block-width) - var(--height) * var(--card-w-l-ratio)) / 2
);
--y-margin-top: calc(
var(--r) * calc(var(--block-height) + var(--block-row-gap))
);
--y-padding: calc((var(--block-height) - var(--height)) / 2);
--x: calc(var(--x-margin-left) + var(--x-padding));
--y: calc(var(--y-margin-top) + var(--y-padding));
--z: calc(var(--h) * 1px);
transform: translateZ(var(--z));
translate: var(--x) var(--y);
rotate: calc(90deg + var(--opponent-deg));
animation: glow calc(var(--highlight-interval) * var(--highlight-on)) ease-out infinite alternate;
}
.card::after {
z-index: 9;
/* opacity: var(--shadow); */
content: "";
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: #0000003f;
filter: blur(5px);
transform: translateZ(calc(-1 * var(--z)));
}
.card:hover {
animation: glow-hover var(--highlight-interval) ease-out infinite alternate;
}
.block {
width: var(--block-width);
height: var(--block-height);
background-color: #333;
cursor: pointer;
animation: glow calc(var(--highlight-interval) * var(--highlight-on)) ease-out infinite alternate;
}
.block-extra {
margin-left: calc(var(--block-width) + var(--block-column-gap));
}
.block-row {
display: flex;
gap: var(--block-column-gap);
}
.block-left {
margin-left: calc((var(--block-width) + var(--block-column-gap)) * -1);
}
.block-right {
margin-right: calc((var(--block-width) + var(--block-column-gap)) * -1);
}
#board-bg {
display: flex;
flex-direction: column;
gap: var(--block-row-gap);
transform-style: preserve-3d;
}
@keyframes animation-fly {
0% {
transform: translateZ(1px);
}
50% {
transform: translateZ(80px);
}
100% {
transform: translateZ(1px);
}
}
@keyframes animation-fly-shadow {
0% {
transform: translateZ(0px);
/* background-color: #00000000; */
}
50% {
transform: translateZ(-80px);
/* background-color: #00000063; */
}
100% {
transform: translateZ(0px);
/* background-color: #00000000; */
}
}
.fly {
--trans-time: 0.3s;
animation: animation-fly var(--trans-time);
z-index: 10;
}
.fly::after {
--trans-time: 0.3s;
z-index: 9;
opacity: 1;
content: "";
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: #00000063;
filter: blur(5px);
animation: animation-fly-shadow var(--trans-time) ease-out;
}
@keyframes glow {
0% {
border-color: var(--highlight-color-x);
box-shadow: 0 0 5px rgba(0,255,0,.2), inset 0 0 5px rgba(0,255,0,.1), 0 1px 0 #393;
}
100% {
border-color: var(--highlight-color-y);
box-shadow: 0 0 20px rgba(0,255,0,.6), inset 0 0 10px rgba(0,255,0,.4), 0 1px 0 #6f6;
}
}
@keyframes glow-hover {
0% {
border-color: #CBCC24;
box-shadow: 0 0 5px rgba(255,255,0,.2), inset 0 0 5px rgba(255,255,0,.1), 0 1px 0 #CBCC24;
}
100% {
border-color: #F0F224;
box-shadow: 0 0 20px rgba(255,255,0,.6), inset 0 0 10px rgba(255,255,0,.4), 0 1px 0 #F0F224;
}
}
......@@ -13,12 +13,14 @@ import {
SortCardModal,
YesNoModal,
} from "./Message";
import Mat from "./PlayMat";
import { LifeBar, Mat, Menu } from "./PlayMat";
const NeosDuel = () => {
return (
<>
<Alert />
<Menu />
<LifeBar />
<Mat />
<CardModal />
<CardListModal />
......
......@@ -6,10 +6,7 @@ import { useSnapshot } from "valtio";
import { fetchStrings, sendSelectIdleCmdResponse } from "@/api";
import { useConfig } from "@/config";
import {
clearAllIdleInteractivities as clearAllIdleInteractivities,
messageStore,
} from "@/stores";
import { cardStore, messageStore } from "@/stores";
import {
Attribute2StringCodeMap,
......@@ -77,8 +74,11 @@ export const CardModal = () => {
onClick={() => {
sendSelectIdleCmdResponse(interactive.response);
cardModal.isOpen = false;
clearAllIdleInteractivities(0);
clearAllIdleInteractivities(1);
// 清空互动性
for (const card of cardStore.inner) {
card.idleInteractivities = [];
}
}}
>
{interactive.desc}
......
......@@ -3,10 +3,7 @@ import "@/styles/card-modal.scss";
import React from "react";
import { CardMeta, getCardStr, sendSelectIdleCmdResponse } from "@/api";
import {
clearAllIdleInteractivities as clearAllIdleInteractivities,
messageStore,
} from "@/stores";
import { cardStore, messageStore } from "@/stores";
const { cardModal } = messageStore;
export const EffectButton = (props: {
......@@ -26,8 +23,10 @@ export const EffectButton = (props: {
onClick={() => {
sendSelectIdleCmdResponse(props.effectInteractivies[0].response);
cardModal.isOpen = false;
clearAllIdleInteractivities(0);
clearAllIdleInteractivities(1);
// 清空互动性
for (const card of cardStore.inner) {
card.idleInteractivities = [];
}
}}
>
{props.effectInteractivies[0].desc}
......@@ -48,8 +47,10 @@ export const EffectButton = (props: {
});
}
cardModal.isOpen = false;
clearAllIdleInteractivities(0);
clearAllIdleInteractivities(1);
// 清空互动性
for (const card of cardStore.inner) {
card.idleInteractivities = [];
}
messageStore.optionModal.isOpen = true;
}}
>
......
import { notification } from "antd";
import React, { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useSnapshot } from "valtio";
import { fetchStrings, ygopro } from "@/api";
......@@ -25,7 +24,6 @@ export const HintNotification = () => {
const waiting = snap.waiting;
const result = snap.result;
const navigate = useNavigate();
const [api, contextHolder] = notification.useNotification({
maxCount: NeosConfig.ui.hint.maxCount,
});
......@@ -41,14 +39,18 @@ export const HintNotification = () => {
useEffect(() => {
if (currentPhase) {
const message = fetchStrings(
"!system",
Phase2StringCodeMap.get(currentPhase) ?? 0
);
api.open({
message: fetchStrings(
"!system",
Phase2StringCodeMap.get(currentPhase) ?? 0
),
message,
placement: "topRight",
style: style,
});
console.color("DeepPink")(
`${message}(${matStore.isMe(matStore.currentPlayer) ? "me" : "op"})`
);
}
}, [currentPhase]);
......@@ -75,9 +77,6 @@ export const HintNotification = () => {
message,
placement: "bottom",
style: style,
onClose() {
navigate("/");
},
});
}
}, [result]);
......
......@@ -10,7 +10,6 @@ import {
fetchStrings,
sendSelectMultiResponse,
sendSelectSingleResponse,
ygopro,
} from "@/api";
import { useConfig } from "@/config";
import { clearSelectActions, matStore, messageStore } from "@/stores";
......@@ -60,7 +59,7 @@ export const SelectActionsModal = () => {
? response.length == 1
: response.length >= min && response.length <= max && levelMatched;
const grouped = groupBy(selectables, (option) => option.location?.location!);
const grouped = groupBy(selectables, (option) => option.location?.zone!);
return (
<>
......@@ -152,7 +151,7 @@ export const SelectActionsModal = () => {
style={{
width: 120,
backgroundColor:
option.location?.controler === 0
option.location?.controller === 0
? "white"
: "grey",
}}
......
import { SendOutlined } from "@ant-design/icons";
import { Button, Col, Input, Row } from "antd";
import React, { useState } from "react";
import { sendChat } from "@/api";
export const SendBox = () => {
const [content, setContent] = useState("");
return (
<>
<Row>
<Input.TextArea
placeholder="Message to sent..."
autoSize={{ minRows: 3, maxRows: 4 }}
value={content}
onChange={(e) => {
setContent(e.target.value);
}}
/>
</Row>
<Row>
<Col>
<Button
icon={<SendOutlined />}
onClick={() => {
sendChat(content);
setContent("");
}}
disabled={!content}
/>
</Col>
</Row>
</>
);
};
import { UserOutlined } from "@ant-design/icons";
import { CheckCard } from "@ant-design/pro-components";
import { Avatar } from "antd";
import React from "react";
import { useConfig } from "@/config";
const NeosConfig = useConfig();
const Config = NeosConfig.ui.status;
const avatarSize = 40;
const ME_VALUE = "myself";
const OP_VALUE = "opponent";
import { useSnapshot } from "valtio";
import { matStore } from "@/stores";
export const PlayerStatus = () => {
const meInfo = useSnapshot(matStore.initInfo.me);
const opInfo = useSnapshot(matStore.initInfo.op);
const waiting = useSnapshot(matStore).waiting;
return (
<CheckCard.Group
bordered
style={{ height: `${NeosConfig.ui.layout.header.height}` }}
value={waiting ? OP_VALUE : ME_VALUE}
>
<CheckCard
avatar={
<Avatar
size={avatarSize}
style={{ backgroundColor: Config.opAvatarColor }}
icon={<UserOutlined />}
/>
}
title={OP_VALUE}
description={`Lp: ${opInfo?.life || 0}`}
value={OP_VALUE}
style={{
position: "absolute",
left: `${NeosConfig.ui.layout.sider.width}px`,
}}
/>
<CheckCard
avatar={
<Avatar
size={avatarSize}
style={{ backgroundColor: Config.meAvatarColor }}
icon={<UserOutlined />}
/>
}
title={ME_VALUE}
description={`Lp: ${meInfo?.life || 0}`}
value={ME_VALUE}
style={{
position: "absolute",
right: "0px",
}}
/>
</CheckCard.Group>
);
};
import { MessageOutlined } from "@ant-design/icons";
import { Timeline, TimelineItemProps } from "antd";
import React, { useEffect, useState } from "react";
import { useSnapshot } from "valtio";
import { chatStore } from "@/stores";
export const DuelTimeLine = () => {
const [items, setItems] = useState<TimelineItemProps[]>([]);
const stateChat = chatStore;
const snapChat = useSnapshot(stateChat);
const chat = snapChat.message;
useEffect(() => {
setItems((prev) =>
prev.concat([
{
dot: <MessageOutlined />,
children: chat,
color: "green",
},
])
);
}, [chat]);
return <Timeline items={items} />;
};
......@@ -8,8 +8,5 @@ export * from "./HintNotification";
export * from "./OptionModal";
export * from "./PositionModal";
export * from "./SelectActionsModal";
export * from "./SendBox";
export * from "./SortCardModal";
export * from "./Status";
export * from "./TimeLine";
export * from "./YesNoModal";
section#mat {
.mat-bg {
display: flex;
flex-direction: column;
row-gap: var(--row-gap);
justify-content: center;
align-items: center;
background-color: transparent;
.bg-row {
display: flex;
column-gap: var(--col-gap);
&.opponent {
flex-direction: row-reverse;
}
}
}
.block {
height: var(--block-height-m);
width: var(--block-width);
// background-color: rgba(128, 128, 128, 0.447);
box-shadow: 0 0 0 1px purple;
&.extra {
margin-inline: calc(var(--block-width) / 2 + var(--col-gap) / 2);
}
&.szone {
height: var(--block-height-s);
}
&.highlight {
box-shadow: 0 0 0 1px #00b0ff, 0 0 13px 0px #0077ff,
0 0 11px 0 skyblue inset;
}
}
}
import "./index.scss";
import classnames from "classnames";
import { type FC } from "react";
import { type INTERNAL_Snapshot as Snapshot, useSnapshot } from "valtio";
import { sendSelectPlaceResponse, ygopro } from "@/api";
import { cardStore, type PlaceInteractivity, placeStore } from "@/stores";
const BgExtraRow: FC<{
meSnap: Snapshot<PlaceInteractivity[]>;
opSnap: Snapshot<PlaceInteractivity[]>;
}> = ({ meSnap, opSnap }) => {
return (
<div className={classnames("bg-row")}>
{Array.from({ length: 2 }).map((_, i) => (
<div
key={i}
className={classnames("block", "extra", {
highlight: !!meSnap[i] || !!opSnap[i],
})}
onClick={() => {
onBlockClick(meSnap[i]);
onBlockClick(opSnap[i]);
}}
></div>
))}
</div>
);
};
const BgRow: FC<{
isSzone?: boolean;
opponent?: boolean;
snap: Snapshot<PlaceInteractivity[]>;
}> = ({ isSzone = false, opponent = false, snap }) => (
<div className={classnames("bg-row", { opponent })}>
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className={classnames("block", {
szone: isSzone,
highlight: !!snap[i],
})}
onClick={() => onBlockClick(snap[i])}
></div>
))}
</div>
);
export const Bg: FC = () => {
const snap = useSnapshot(placeStore.inner);
return (
<div className="mat-bg">
<BgRow snap={snap[ygopro.CardZone.SZONE].op} isSzone opponent />
<BgRow snap={snap[ygopro.CardZone.MZONE].op} opponent />
<BgExtraRow
meSnap={snap[ygopro.CardZone.MZONE].me.slice(5, 7)}
opSnap={snap[ygopro.CardZone.MZONE].op.slice(5, 7)}
/>
<BgRow snap={snap[ygopro.CardZone.MZONE].me} />
<BgRow snap={snap[ygopro.CardZone.SZONE].me} isSzone />
</div>
);
};
const onBlockClick = (placeInteractivity: PlaceInteractivity) => {
if (placeInteractivity) {
sendSelectPlaceResponse(placeInteractivity.response);
cardStore.inner.forEach((card) => (card.idleInteractivities = []));
placeStore.clearAll();
}
};
import "@/styles/mat.css";
import classnames from "classnames";
import React, { MouseEventHandler } from "react";
import { sendSelectPlaceResponse } from "@/api";
import {
CardState,
clearAllPlaceInteradtivities,
DuelFieldState,
} from "@/stores";
export const Block: React.FC<{
isExtra?: boolean;
highlight?: boolean;
onClick?: MouseEventHandler;
outerLeft?: boolean;
outerRight?: boolean;
}> = ({
isExtra = false,
highlight = false,
onClick,
outerLeft = false,
outerRight = false,
}) => (
<div
className={classnames("block", {
"block-extra": isExtra,
"block-left": outerLeft,
"block-right": outerRight,
})}
style={
{
"--highlight-on": highlight ? 1 : 0,
} as any
}
onClick={onClick}
/>
);
export function BlockRow<T extends DuelFieldState>(props: {
states: T;
leftState?: CardState;
rightState?: CardState;
}) {
return (
<div className="block-row">
{props.leftState ? (
<Block
highlight={props.leftState.placeInteractivity !== undefined}
onClick={() => {
onBlockClick(props.leftState!);
}}
outerLeft
/>
) : (
<></>
)}
{props.states.map((block, idx) => (
<Block
key={idx}
highlight={block.placeInteractivity !== undefined}
onClick={() => {
onBlockClick(block);
}}
/>
))}
{props.rightState ? (
<Block
highlight={props.rightState.placeInteractivity !== undefined}
onClick={() => {
onBlockClick(props.rightState!);
}}
outerRight
/>
) : (
<></>
)}
</div>
);
}
export const ExtraBlockRow: React.FC<{
meLeft: CardState;
meRight: CardState;
opLeft: CardState;
opRight: CardState;
}> = ({ meLeft, meRight, opLeft, opRight }) => (
<div className="block-row">
<Block
highlight={
meLeft.placeInteractivity !== undefined ||
opLeft.placeInteractivity !== undefined
}
isExtra={true}
onClick={() => {
onBlockClick(meLeft);
onBlockClick(opLeft);
}}
/>
<Block
highlight={
meRight.placeInteractivity !== undefined ||
opRight.placeInteractivity !== undefined
}
isExtra={true}
onClick={() => {
onBlockClick(meRight);
onBlockClick(opRight);
}}
/>
</div>
);
const onBlockClick = (state: CardState) => {
if (state.placeInteractivity) {
sendSelectPlaceResponse(state.placeInteractivity.response);
clearAllPlaceInteradtivities(0);
clearAllPlaceInteradtivities(1);
}
};
import "@/styles/mat.css";
import classnames from "classnames";
import React, { type CSSProperties, MouseEventHandler } from "react";
import { useConfig } from "@/config";
import { Chain } from "./Chain";
const NeosConfig = useConfig();
const ASSETS_BASE =
import.meta.env.BASE_URL == "/"
? NeosConfig.assetsPath
: import.meta.env.BASE_URL + NeosConfig.assetsPath;
const FOCUS_SCALE = 2.5;
const FOCUS_HIGHT = 100;
export const Card: React.FC<{
code: number;
row: number;
col: number;
hight: number;
opponent?: boolean;
defense?: boolean;
facedown?: boolean;
vertical?: boolean;
highlight?: boolean;
focus?: boolean;
fly?: boolean;
chainIdx?: number;
transTime?: number;
onClick?: MouseEventHandler<{}>;
style?: CSSProperties;
}> = ({
code,
row,
col,
hight,
defense = false,
facedown = false,
opponent = false,
vertical = false,
highlight = false,
focus = false,
fly = false,
chainIdx,
transTime = 0.3,
onClick,
style = {},
}) => (
<div
className={classnames("card", {
"card-defense": defense,
fly: fly && !focus,
})}
style={
{
"--h": focus ? FOCUS_HIGHT : hight,
"--r": row,
"--c": col,
"--shadow": hight > 0 ? 1 : 0,
"--opponent-deg": opponent ? "180deg" : "0deg",
"--vertical": vertical ? 1 : 0,
"--trans-time": `${
fly ? NeosConfig.ui.chainingDelay / 1000 : transTime
}s`,
"--highlight-on": highlight ? 1 : 0,
"--scale-focus": focus ? FOCUS_SCALE : 1,
"--card-img": facedown
? `url(${ASSETS_BASE + "/card_back.jpg"})`
: `url(${NeosConfig.cardImgUrl + "/" + code + ".jpg"})`,
...style,
} as any
}
onClick={onClick}
>
{chainIdx ? <Chain chainIdx={chainIdx} /> : <></>}
</div>
);
section#mat {
.mat-card {
position: absolute;
// left: 50%;
// top: 50%;
--card-height: 100px;
height: var(--card-height);
aspect-ratio: var(--card-ratio);
transform-style: preserve-3d;
.card-img-wrap {
transform-style: preserve-3d;
position: relative;
height: 100%;
width: 100%;
transform: translateZ(calc(var(--z) * 1px + 0.1px))
rotateY(calc(var(--ry) * 1deg));
.card-cover,
.card-back {
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
}
.card-cover {
z-index: 1;
transform: translateZ(0.5px);
}
.card-back {
z-index: 0;
transform: translateZ(0px);
}
}
.card-shadow {
// position: absolute;
// left: 0;
// top: 0;
// width: 100%;
// height: 100%;
// background-color: #0000005e;
// filter: blur(2px);
}
}
}
.highlight {
box-shadow: 0 0 10px 2px #5db7ff;
}
import "./index.scss";
import { animated, to, useSpring } from "@react-spring/web";
import classnames from "classnames";
import React, { type CSSProperties, type FC, useEffect, useState } from "react";
import { useSnapshot } from "valtio";
import { ygopro } from "@/api";
import { useConfig } from "@/config";
import { eventbus, Task } from "@/infra";
import { cardStore, CardType, messageStore } from "@/stores";
import { interactTypeToString } from "../../utils";
import {
focus,
moveToDeck,
moveToGround,
moveToHand,
moveToOutside,
} from "./springs";
const NeosConfig = useConfig();
const { HAND, GRAVE, REMOVED, DECK, EXTRA, MZONE, SZONE, TZONE } =
ygopro.CardZone;
export const Card: FC<{ idx: number }> = React.memo(({ idx }) => {
const state = cardStore.inner[idx];
const snap = useSnapshot(state);
const [styles, api] = useSpring(() => ({
x: 0,
y: 0,
z: 0,
rx: 0,
ry: 0,
rz: 0,
zIndex: 0,
height: 0,
}));
const move = async (zone: ygopro.CardZone) => {
switch (zone) {
case MZONE:
case SZONE:
await moveToGround({ card: state, api });
break;
case HAND:
await moveToHand({ card: state, api });
break;
case DECK:
case EXTRA:
await moveToDeck({ card: state, api });
break;
case GRAVE:
case REMOVED:
await moveToOutside({ card: state, api });
break;
case TZONE:
// TODO: 衍生物直接消散
break;
}
};
useEffect(() => {
move(state.location.zone);
}, []);
const [highlight, setHighlight] = useState(false);
// const [shadowOpacity, setShadowOpacity] = useState(0); // TODO: 透明度
// >>> 动画 >>>
/** 动画序列的promise */
let animationQueue: Promise<unknown> = new Promise<void>((rs) => rs());
const addToAnimation = (p: () => Promise<void>) =>
new Promise((rs) => {
animationQueue = animationQueue.then(p).then(rs);
});
eventbus.register(Task.Move, async (uuid: string) => {
if (uuid === state.uuid) {
await addToAnimation(() => move(state.location.zone));
}
});
eventbus.register(Task.Focus, async (uuid: string) => {
if (uuid === state.uuid) {
await addToAnimation(() => focus({ card: state, api }));
}
});
// <<< 动画 <<<
useEffect(() => {
setHighlight(!!snap.idleInteractivities.length);
}, [snap.idleInteractivities]);
return (
<animated.div
className={classnames("mat-card", { highlight })}
style={
{
transform: to(
[styles.x, styles.y, styles.z, styles.rx, styles.ry, styles.rz],
(x, y, z, rx, ry, rz) =>
`translate(${x}px, ${y}px) rotateX(${rx}deg) rotateZ(${rz}deg)`
),
"--z": styles.z,
"--ry": styles.ry,
height: styles.height,
zIndex: styles.zIndex,
} as any as CSSProperties
}
onClick={() => {
if ([MZONE, SZONE, HAND].includes(state.location.zone)) {
onCardClick(state);
} else if ([EXTRA, GRAVE, REMOVED].includes(state.location.zone)) {
onFieldClick(state);
}
}}
>
<div className="card-shadow" />
<div className="card-img-wrap">
<img
className="card-cover"
onError={() => {
console.log("");
}}
src={getCardImgUrl(snap.code)}
/>
<img className="card-back" src={getCardImgUrl(0, true)} />
</div>
</animated.div>
);
});
function getCardImgUrl(code: number, back = false) {
const ASSETS_BASE =
import.meta.env.BASE_URL == "/"
? NeosConfig.assetsPath
: import.meta.env.BASE_URL + NeosConfig.assetsPath;
if (code === 0 || back) {
return ASSETS_BASE + "/card_back.jpg";
}
return NeosConfig.cardImgUrl + "/" + code + ".jpg";
}
const onCardClick = (card: CardType) => {
// 中央弹窗展示选中卡牌信息
messageStore.cardModal.meta = {
id: card.code,
text: card.meta.text,
data: card.meta.data,
};
messageStore.cardModal.interactivies = card.idleInteractivities.map(
(interactivity) => ({
desc: interactTypeToString(interactivity.interactType),
response: interactivity.response,
})
);
messageStore.cardModal.counters = card.counters;
messageStore.cardModal.isOpen = true;
// 侧边栏展示超量素材信息
const overlayMaterials = cardStore.findOverlay(
card.location.zone,
card.location.controller,
card.location.sequence
);
if (overlayMaterials.length > 0) {
messageStore.cardListModal.list =
overlayMaterials.map((overlay) => ({
meta: {
id: overlay.code,
text: overlay.meta.text,
data: overlay.meta.data,
},
interactivies: [],
})) || [];
messageStore.cardListModal.isOpen = true;
}
};
const onFieldClick = (card: CardType) => {
const displayStates = cardStore.at(
card.location.zone,
card.location.controller
);
messageStore.cardListModal.list = displayStates.map((item) => ({
meta: {
id: item.code,
text: item.meta.text,
data: item.meta.data,
},
interactivies: item.idleInteractivities.map((interactivy) => ({
desc: interactTypeToString(interactivy.interactType),
response: interactivy.response,
})),
}));
messageStore.cardListModal.isOpen = true;
};
import { ygopro } from "@/api";
import { type CardType, matStore } from "@/stores";
import { SpringApi } from "./types";
import { asyncStart } from "./utils";
/** 发动效果的动画 */
export const focus = async (props: { card: CardType; api: SpringApi }) => {
const { card, api } = props;
const current = api.current[0].get();
if (card.location.zone === ygopro.CardZone.HAND) {
await asyncStart(api)({
y: current.y + (matStore.isMe(card.location.controller) ? -1 : 1) * 200, // TODO: 放到config之中
rz: 0,
});
await asyncStart(api)({ y: current.y, rz: current.rz });
} else {
await asyncStart(api)({ z: 200 });
await asyncStart(api)({ z: current.z });
}
};
export * from "./focus";
export * from "./moveToDeck";
export * from "./moveToGround";
export * from "./moveToHand";
export * from "./moveToOutside";
import { ygopro } from "@/api";
import { type CardType, isMe } from "@/stores";
import { matConfig } from "../../utils";
import { SpringApi } from "./types";
const {
BLOCK_WIDTH,
BLOCK_HEIGHT_M,
BLOCK_HEIGHT_S,
COL_GAP,
ROW_GAP,
DECK_OFFSET_X,
DECK_OFFSET_Y,
DECK_ROTATE_Z,
DECK_CARD_HEIGHT,
} = matConfig;
const { DECK, EXTRA } = ygopro.CardZone;
export const moveToDeck = async (props: { card: CardType; api: SpringApi }) => {
const { card, api } = props;
// report
const { location } = card;
const { controller, zone, sequence } = location;
const rightX = DECK_OFFSET_X.value + 2 * (BLOCK_WIDTH.value + COL_GAP.value);
const leftX = -rightX;
const bottomY =
DECK_OFFSET_Y.value +
2 * BLOCK_HEIGHT_M.value +
BLOCK_HEIGHT_S.value +
2 * ROW_GAP.value -
BLOCK_HEIGHT_S.value;
const topY = -bottomY;
let x = isMe(controller) ? rightX : leftX;
let y = isMe(controller) ? bottomY : topY;
if (zone === EXTRA) {
x = isMe(controller) ? leftX : rightX;
}
let rz = zone === EXTRA ? DECK_ROTATE_Z.value : -DECK_ROTATE_Z.value;
rz += isMe(controller) ? 0 : 180;
const z = sequence;
api.start({
x,
y,
z,
rz,
ry: isMe(controller) ? (zone === DECK ? 180 : 0) : 180,
zIndex: z,
height: DECK_CARD_HEIGHT.value,
});
};
import { easings } from "@react-spring/web";
import { ygopro } from "@/api";
import { type CardType, isMe } from "@/stores";
import { matConfig } from "../../utils";
import { SpringApi } from "./types";
import { asyncStart } from "./utils";
const {
BLOCK_WIDTH,
BLOCK_HEIGHT_M,
BLOCK_HEIGHT_S,
CARD_RATIO,
COL_GAP,
ROW_GAP,
} = matConfig;
const { MZONE, SZONE } = ygopro.CardZone;
export const moveToGround = async (props: {
card: CardType;
api: SpringApi;
}) => {
const { card, api } = props;
const { location } = card;
const { controller, zone, sequence, position, is_overlay } = location;
// 根据zone计算卡片的宽度
const cardWidth =
zone === SZONE
? BLOCK_HEIGHT_S.value * CARD_RATIO.value
: BLOCK_HEIGHT_M.value * CARD_RATIO.value;
let height = zone === SZONE ? BLOCK_HEIGHT_S.value : BLOCK_HEIGHT_M.value;
// 首先计算 x 和 y
let x = 0,
y = 0;
switch (zone) {
case SZONE: {
if (sequence === 5) {
// 场地魔法
x = -(
3 * (BLOCK_WIDTH.value + COL_GAP.value) -
(BLOCK_WIDTH.value - cardWidth) / 2
);
y = BLOCK_HEIGHT_M.value + ROW_GAP.value;
} else {
x = (sequence - 2) * (BLOCK_WIDTH.value + COL_GAP.value);
y =
2 * (BLOCK_HEIGHT_M.value + ROW_GAP.value) -
(BLOCK_HEIGHT_M.value - BLOCK_HEIGHT_S.value) / 2;
}
break;
}
case MZONE: {
if (sequence > 4) {
// 额外怪兽区
x = (sequence > 5 ? 1 : -1) * (BLOCK_WIDTH.value + COL_GAP.value);
y = 0;
} else {
x = (sequence - 2) * (BLOCK_WIDTH.value + COL_GAP.value);
y = BLOCK_HEIGHT_M.value + ROW_GAP.value;
}
break;
}
}
if (!isMe(controller)) {
x = -x;
y = -y;
}
// 判断是不是防御表示
const defence = [
ygopro.CardPosition.DEFENSE,
ygopro.CardPosition.FACEDOWN_DEFENSE,
ygopro.CardPosition.FACEUP_DEFENSE,
].includes(position ?? 5);
height = defence ? BLOCK_WIDTH.value : height;
let rz = isMe(controller) ? 0 : 180;
rz += defence ? 90 : 0;
// 动画
await asyncStart(api)({
x,
y,
height,
z: is_overlay ? 120 : 200,
ry: [
ygopro.CardPosition.FACEDOWN,
ygopro.CardPosition.FACEDOWN_ATTACK,
ygopro.CardPosition.FACEDOWN_DEFENSE,
].includes(position ?? 5)
? 180
: 0,
rz,
config: {
// mass: 0.5,
easing: easings.easeInOutSine,
},
});
await asyncStart(api)({
z: 0,
zIndex: is_overlay ? 1 : 3,
config: {
easing: easings.easeInOutQuad,
mass: 5,
tension: 300, // 170
friction: 12, // 26
clamp: true,
},
});
};
import { ygopro } from "@/api";
import { cardStore, type CardType, isMe } from "@/stores";
import { matConfig } from "../../utils";
import { SpringApi } from "./types";
const {
BLOCK_HEIGHT_M,
BLOCK_HEIGHT_S,
CARD_RATIO,
ROW_GAP,
HAND_MARGIN_TOP,
HAND_CARD_HEIGHT,
HAND_CIRCLE_CENTER_OFFSET_Y,
} = matConfig;
const { HAND } = ygopro.CardZone;
export const moveToHand = async (props: { card: CardType; api: SpringApi }) => {
const { card, api } = props;
const { sequence, controller } = card.location;
// 手卡会有很复杂的计算...
const hand_circle_center_x = 0;
const hand_circle_center_y =
1 * BLOCK_HEIGHT_M.value +
1 * BLOCK_HEIGHT_S.value +
2 * ROW_GAP.value +
(HAND_MARGIN_TOP.value +
HAND_CARD_HEIGHT.value +
HAND_CIRCLE_CENTER_OFFSET_Y.value);
const hand_card_width = CARD_RATIO.value * HAND_CARD_HEIGHT.value;
const THETA =
2 *
Math.atan(
hand_card_width /
2 /
(HAND_CIRCLE_CENTER_OFFSET_Y.value + HAND_CARD_HEIGHT.value)
) *
0.9;
// 接下来计算每一张手卡
const hands_length = cardStore.at(HAND, controller).length;
const angle = (sequence - (hands_length - 1) / 2) * THETA;
const r = HAND_CIRCLE_CENTER_OFFSET_Y.value + HAND_CARD_HEIGHT.value / 2;
const negativeX = Math.sin(angle) * r;
const negativeY = Math.cos(angle) * r + HAND_CARD_HEIGHT.value / 2;
const x = hand_circle_center_x + negativeX * (isMe(controller) ? 1 : -1);
const y = hand_circle_center_y - negativeY + 130; // 常量 是手动调的 这里肯定有问题 有空来修
const _rz = (angle * 180) / Math.PI;
api.start({
x: isMe(controller) ? x : -x,
y: isMe(controller) ? y : -y,
z: 0,
rz: isMe(controller) ? _rz : 180 - _rz,
ry: isMe(controller) ? 0 : 180,
height: HAND_CARD_HEIGHT.value,
zIndex: sequence,
// rx: -PLANE_ROTATE_X.value,
});
};
import { ygopro } from "@/api";
import { type CardType, isMe } from "@/stores";
import { matConfig } from "../../utils";
import { SpringApi } from "./types";
const { BLOCK_WIDTH, BLOCK_HEIGHT_M, BLOCK_HEIGHT_S, COL_GAP, ROW_GAP } =
matConfig;
const { GRAVE } = ygopro.CardZone;
export const moveToOutside = async (props: {
card: CardType;
api: SpringApi;
}) => {
const { card, api } = props;
// report
const { zone, controller, position } = card.location;
let x = (BLOCK_WIDTH.value + COL_GAP.value) * 3,
y = zone === GRAVE ? BLOCK_HEIGHT_M.value + ROW_GAP.value : 0;
if (!isMe(controller)) {
x = -x;
y = -y;
}
api.start({
x,
y,
z: 0,
height: BLOCK_HEIGHT_S.value,
rz: isMe(controller) ? 0 : 180,
ry: [ygopro.CardPosition.FACEDOWN].includes(position) ? 180 : 0,
});
};
import { type SpringRef } from "@react-spring/web";
export type SpringApi = SpringRef<{
x: number;
y: number;
z: number;
rx: number;
ry: number;
rz: number;
zIndex: number;
height: number;
}>;
import { type SpringConfig, type SpringRef } from "@react-spring/web";
export const asyncStart = <T extends {}>(api: SpringRef<T>) => {
return (p: Partial<T> & { config?: SpringConfig }) =>
new Promise((resolve) => {
api.start({
...p,
onRest: resolve,
});
});
};
import "@/styles/chain.css";
import React from "react";
const CIRCLES_COUNT = 10;
const EASE = 0.2;
const R = 60;
export const Chain: React.FC<{ chainIdx: number }> = (props: {
chainIdx: number;
}) => (
<div
className="circles"
style={
{
"--R": R + "px",
} as any
}
>
{calcXYs(30, CIRCLES_COUNT).map((item, idx) => (
<div
className="circle"
key={idx}
style={
{
"--x": item.X + "px",
"--y": item.Y + "px",
"--ease": (idx * EASE).toString() + "s",
} as any
}
></div>
))}
<div className="font">{props.chainIdx}</div>
</div>
);
// Ref: https://zhuanlan.zhihu.com/p/104226591
/**
* R:大圆半径,2*R = 外部正方形的边长
* counts: 圆的数量
* 返回值:
* [
* [x1,y1],
* [x2,y2],
* ...
* ]
*/
function calcXYs(R: number, counts: number) {
// 当前度数
let deg = 0;
// 单位度数
let pDeg = 360 / counts;
return Array(counts)
.fill(0)
.map((_, i) => {
// 度数以单位度数递增
deg = pDeg * i;
// Math.sin接收的参数以 π 为单位,需要根据360度 = 2π进行转化
const proportion = Math.PI / 180;
// 以外部DIV左下角为原点,计算小圆圆心的横纵坐标
const Y = R + R * Math.sin(proportion * deg);
const X = R + R * Math.cos(proportion * deg);
return { X, Y, deg };
});
}
#life-bar-container {
position: fixed;
display: flex;
gap: 20px;
top: 20px;
right: 20px;
font-size: 1.5em;
font-weight: 500;
font-family: inherit;
flex-direction: column;
}
#life-bar {
padding: 0.8em 1.6em;
background-color: #a9a9a9;
border-radius: 8px;
text-align: left;
border: 1px solid transparent;
color: black;
opacity: 0.4;
}
import "./index.scss";
import React from "react";
import { useSnapshot } from "valtio";
import { matStore, playerStore } from "@/stores";
export const LifeBar: React.FC = () => {
const snap = useSnapshot(matStore.initInfo);
const snapPlayer = useSnapshot(playerStore);
return (
<div id="life-bar-container">
<div id="life-bar">{`${snapPlayer.getMePlayer().name}: ${
snap.me.life
}`}</div>
<div id="life-bar">{`${snapPlayer.getOpPlayer().name}: ${
snap.op.life
}`}</div>
</div>
);
};
This diff is collapsed.
section#mat {
// margin-top: 200px;
// padding-top: 50px; // 先不管 后面调整
position: relative;
#camera {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
// perspective: var(--perspective);
}
#plane {
transform: translateX(0) translateY(0) translateZ(0)
rotateX(var(--plane-rotate-x));
width: fit-content;
perspective: var(--perspective);
}
}
.mat-card-container {
position: absolute;
top: 50%;
left: 50%;
display: flex;
justify-content: center;
align-items: center;
transform-style: preserve-3d;
}
import "./index.scss";
import type { FC, PropsWithChildren } from "react";
import { useSnapshot } from "valtio";
import { cardStore } from "@/stores";
import { Bg } from "../Bg";
import { Card } from "../Card";
import { matConfig, toCssProperties } from "../utils";
// 后面再改名
export const Mat: FC = () => {
const snap = useSnapshot(cardStore.inner);
return (
<section
id="mat"
style={{
width: "100%",
...toCssProperties(matConfig),
}}
>
<Plane>
<Bg />
<CardContainer>
{snap.map((_cardSnap, i) => (
<Card key={i} idx={i} />
))}
</CardContainer>
</Plane>
</section>
);
};
const Plane: FC<PropsWithChildren> = ({ children }) => (
<div id="camera">
<div id="plane">{children}</div>
</div>
);
const CardContainer: FC<PropsWithChildren> = ({ children }) => (
<div className="mat-card-container">{children}</div>
);
#controller {
position: fixed;
display: flex;
gap: 20px;
bottom: 20px;
right: 20px;
z-index: 999;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
import "@/styles/mat.css";
import "./index.scss";
import { Button, Modal } from "antd";
import React, { useState } from "react";
......@@ -11,10 +11,7 @@ import {
sendSurrender,
ygopro,
} from "@/api";
import {
clearAllIdleInteractivities as clearAllIdleInteractivities,
matStore,
} from "@/stores";
import { cardStore, matStore } from "@/stores";
import PhaseType = ygopro.StocGameMessage.MsgNewPhase.PhaseType;
const { phase } = matStore;
......@@ -37,22 +34,25 @@ export const Menu = () => {
? 3
: 7;
const clearAllIdleInteractivities = () => {
for (const card of cardStore.inner) {
card.idleInteractivities = [];
}
};
const onBp = () => {
sendSelectIdleCmdResponse(6);
clearAllIdleInteractivities(0);
clearAllIdleInteractivities(1);
clearAllIdleInteractivities();
phase.enableBp = false;
};
const onM2 = () => {
sendSelectBattleCmdResponse(2);
clearAllIdleInteractivities(0);
clearAllIdleInteractivities(1);
clearAllIdleInteractivities();
phase.enableM2 = false;
};
const onEp = () => {
sendSelectIdleCmdResponse(response);
clearAllIdleInteractivities(0);
clearAllIdleInteractivities(1);
clearAllIdleInteractivities();
phase.enableEp = false;
};
const onSurrender = () => {
......
import { Mat } from "./Mat";
export default Mat;
export * from "./LifeBar";
export * from "./Mat";
export * from "./Menu";
// type CSSValue = [number, string] | number;
export type CSSConfig = Record<string, { value: number; unit: UNIT }>;
/** 转为CSS变量: BOARD_ROTATE_Z -> --board-rotate-z */
export const toCssProperties = (config: CSSConfig) =>
Object.entries(config)
.map(([k, v]) => ({
[`--${k
.split("_")
.map((s) => s.toLowerCase())
.join("-")}`]: `${v.value}${v.unit}`,
}))
.reduce((acc, cur) => ({ ...acc, ...cur }), {});
enum UNIT {
PX = "px",
DEG = "deg",
NONE = "",
}
export const matConfig = {
PERSPECTIVE: {
value: 1500,
unit: UNIT.PX,
},
PLANE_ROTATE_X: {
value: 0,
unit: UNIT.DEG,
},
BLOCK_WIDTH: {
value: 120,
unit: UNIT.PX,
},
BLOCK_HEIGHT_M: {
value: 120,
unit: UNIT.PX,
}, // 主要怪兽区
BLOCK_HEIGHT_S: {
value: 110,
unit: UNIT.PX,
}, // 魔法陷阱区
ROW_GAP: {
value: 10,
unit: UNIT.PX,
},
COL_GAP: {
value: 10,
unit: UNIT.PX,
},
CARD_RATIO: {
value: 5.9 / 8.6,
unit: UNIT.NONE,
},
HAND_MARGIN_TOP: {
value: 0,
unit: UNIT.PX,
},
HAND_CIRCLE_CENTER_OFFSET_Y: {
value: 2000,
unit: UNIT.PX,
},
HAND_CARD_HEIGHT: {
value: 120,
unit: UNIT.PX,
},
DECK_OFFSET_X: {
value: 140,
unit: UNIT.PX,
},
DECK_OFFSET_Y: {
value: 80,
unit: UNIT.PX,
},
DECK_ROTATE_Z: {
value: 30,
unit: UNIT.DEG,
},
DECK_CARD_HEIGHT: {
value: 120,
unit: UNIT.PX,
},
};
export * from "./cssConfig";
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