Commit 7f197346 authored by Chunchi Che's avatar Chunchi Che

代码结构优化:解耦DeckZone和DeckCard,复用在组卡页和Side页

parent e23ceeb3
import { fetchCard, fetchStrings, Region, ygopro } from "@/api"; import { fetchCard, fetchStrings, Region, ygopro } from "@/api";
import { displayAnnounceModal } from "@/ui/Duel/Message"; import { displayOptionModal } from "@/ui/Duel/Message";
import MsgAnnounce = ygopro.StocGameMessage.MsgAnnounce; import MsgAnnounce = ygopro.StocGameMessage.MsgAnnounce;
export default async (announce: MsgAnnounce) => { export default async (announce: MsgAnnounce) => {
...@@ -14,26 +14,26 @@ export default async (announce: MsgAnnounce) => { ...@@ -14,26 +14,26 @@ export default async (announce: MsgAnnounce) => {
switch (type_) { switch (type_) {
case MsgAnnounce.AnnounceType.RACE: { case MsgAnnounce.AnnounceType.RACE: {
await displayAnnounceModal({ await displayOptionModal(
min, fetchStrings(Region.System, 563),
title: fetchStrings(Region.System, 563), announce.options.map((option) => ({
options: announce.options.map((option) => ({
info: fetchStrings(Region.System, 1200 + option.code), info: fetchStrings(Region.System, 1200 + option.code),
response: option.response, response: option.response,
})), })),
}); min,
);
break; break;
} }
case MsgAnnounce.AnnounceType.Attribute: { case MsgAnnounce.AnnounceType.Attribute: {
await displayAnnounceModal({ await displayOptionModal(
min, fetchStrings(Region.System, 562),
title: fetchStrings(Region.System, 562), announce.options.map((option) => ({
options: announce.options.map((option) => ({
info: fetchStrings(Region.System, 1010 + option.code), info: fetchStrings(Region.System, 1010 + option.code),
response: option.response, response: option.response,
})), })),
}); min,
);
break; break;
} }
...@@ -48,23 +48,19 @@ export default async (announce: MsgAnnounce) => { ...@@ -48,23 +48,19 @@ export default async (announce: MsgAnnounce) => {
}); });
} }
} }
await displayAnnounceModal({ await displayOptionModal(fetchStrings(Region.System, 564), options, min);
min,
title: fetchStrings(Region.System, 564),
options,
});
break; break;
} }
case MsgAnnounce.AnnounceType.Number: { case MsgAnnounce.AnnounceType.Number: {
await displayAnnounceModal({ await displayOptionModal(
min, fetchStrings(Region.System, 565),
title: fetchStrings(Region.System, 565), announce.options.map((option) => ({
options: announce.options.map((option) => ({
info: option.code.toString(), info: option.code.toString(),
response: option.response, response: option.response,
})), })),
}); min,
);
break; break;
} }
......
...@@ -5,9 +5,13 @@ export default async (mora: ygopro.StocGameMessage.MsgRockPaperScissors) => { ...@@ -5,9 +5,13 @@ export default async (mora: ygopro.StocGameMessage.MsgRockPaperScissors) => {
const _player = mora.player; const _player = mora.player;
// TODO: I18n // TODO: I18n
await displayOptionModal("请选择猜拳", [ await displayOptionModal(
{ msg: "剪刀", response: 1 }, "请选择猜拳",
{ msg: "石头", response: 2 }, [
{ msg: "", response: 3 }, { info: "剪刀", response: 1 },
]); { info: "石头", response: 2 },
{ info: "", response: 3 },
],
1,
);
}; };
...@@ -14,9 +14,10 @@ export default async (selectOption: ygopro.StocGameMessage.MsgSelectOption) => { ...@@ -14,9 +14,10 @@ export default async (selectOption: ygopro.StocGameMessage.MsgSelectOption) => {
await Promise.all( await Promise.all(
options.map(async ({ code, response }) => { options.map(async ({ code, response }) => {
const meta = fetchCard(code >> 4); const meta = fetchCard(code >> 4);
const msg = getCardStr(meta, code & 0xf) || "[?]"; const info = getCardStr(meta, code & 0xf) ?? "[?]";
return { msg, response }; return { info, response };
}), }),
), ),
1,
); );
}; };
...@@ -5,11 +5,9 @@ import { cardStore } from "@/stores/cardStore"; ...@@ -5,11 +5,9 @@ import { cardStore } from "@/stores/cardStore";
import { matStore } from "../store"; import { matStore } from "../store";
const { hint } = matStore;
export const fetchCommonHintMeta = (code: number) => { export const fetchCommonHintMeta = (code: number) => {
hint.code = code; matStore.hint.code = code;
hint.msg = fetchStrings(Region.System, code); matStore.hint.msg = fetchStrings(Region.System, code);
}; };
export const fetchSelectHintMeta = async ({ export const fetchSelectHintMeta = async ({
...@@ -31,13 +29,13 @@ export const fetchSelectHintMeta = async ({ ...@@ -31,13 +29,13 @@ export const fetchSelectHintMeta = async ({
selectHintMeta = await getStrings(selectHintData); selectHintMeta = await getStrings(selectHintData);
} }
hint.code = selectHintData; matStore.hint.code = selectHintData;
if (hint.code > DESCRIPTION_LIMIT) { if (matStore.hint.code > DESCRIPTION_LIMIT) {
// 针对`MSG_SELECT_PLACE`的特化逻辑 // 针对`MSG_SELECT_PLACE`的特化逻辑
hint.msg = selectHintMeta; matStore.hint.msg = selectHintMeta;
} else { } else {
hint.esSelectHint = selectHintMeta; matStore.hint.esSelectHint = selectHintMeta;
hint.esHint = esHint; matStore.hint.esHint = esHint;
} }
}; };
...@@ -74,5 +72,5 @@ export const fetchEsHintMeta = async ({ ...@@ -74,5 +72,5 @@ export const fetchEsHintMeta = async ({
} }
} }
hint.esHint = esHint; matStore.hint.esHint = esHint;
}; };
...@@ -57,33 +57,7 @@ ...@@ -57,33 +57,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
} --card-grid: 10;
.main,
.extra,
.side {
transition: 0.2s;
position: relative;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding: 0.75rem;
&.over {
background-color: hsla(0, 0%, 100%, 0.05);
}
&.not-allow-to-drop {
background-color: rgba(255, 0, 0, 0.15);
cursor: not-allowed;
}
}
.main {
flex: 3;
}
.extra,
.side {
flex: 1;
}
.card-continer {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 5px;
} }
background-color: hsla(0, 0%, 100%, 0.05); background-color: hsla(0, 0%, 100%, 0.05);
backdrop-filter: blur(5px); backdrop-filter: blur(5px);
...@@ -98,45 +72,6 @@ ...@@ -98,45 +72,6 @@
border-radius: 0 var(--border-radius) var(--border-radius) 0; border-radius: 0 var(--border-radius) var(--border-radius) 0;
} }
.card {
cursor: move;
width: 100%;
background-color: rgba(255, 255, 255, 0.1);
aspect-ratio: var(--card-ratio);
position: relative;
background-size: contain;
content-visibility: auto;
transition: 0.1s;
&:hover {
filter: brightness(0.9);
}
.cardname {
font-size: 12px;
position: absolute;
padding: 5px;
top: 0;
bottom: 0;
max-height: 100%;
margin: auto;
left: 0;
height: fit-content;
width: 100%;
text-align: center;
line-height: 1.75em;
overflow: hidden; //超出的文本隐藏
text-overflow: ellipsis; //溢出用省略号显示
}
.cardcover {
position: relative;
}
.cardlimit {
position: absolute;
top: 2px;
left: 2px;
width: 20px;
}
}
.search-cards-container { .search-cards-container {
height: 100%; height: 100%;
.search-cards { .search-cards {
......
...@@ -16,28 +16,28 @@ import { ...@@ -16,28 +16,28 @@ import {
Pagination, Pagination,
Space, Space,
} from "antd"; } from "antd";
import classNames from "classnames";
import { isEqual } from "lodash-es"; import { isEqual } from "lodash-es";
import { type OverlayScrollbarsComponentRef } from "overlayscrollbars-react"; import { type OverlayScrollbarsComponentRef } from "overlayscrollbars-react";
import { memo, useCallback, useEffect, useRef, useState } from "react"; import { memo, useCallback, useEffect, useRef, useState } from "react";
import { DndProvider, useDrag, useDrop } from "react-dnd"; import { DndProvider, useDrop } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend"; import { HTML5Backend } from "react-dnd-html5-backend";
import { LoaderFunction } from "react-router-dom"; import { LoaderFunction } from "react-router-dom";
import { proxy, useSnapshot } from "valtio"; import { proxy, useSnapshot } from "valtio";
import { subscribeKey } from "valtio/utils"; import { subscribeKey } from "valtio/utils";
import { type CardMeta, forbidden, searchCards } from "@/api"; import { type CardMeta, searchCards } from "@/api";
import { isToken } from "@/common"; import { isToken } from "@/common";
import { useConfig } from "@/config";
import { FtsConditions } from "@/middleware/sqlite/fts"; import { FtsConditions } from "@/middleware/sqlite/fts";
import { deckStore, type IDeck, initStore } from "@/stores"; import { deckStore, type IDeck, initStore } from "@/stores";
import { import {
Background, Background,
DeckCard,
DeckZone,
IconFont, IconFont,
Loading, Loading,
ScrollableArea, ScrollableArea,
YgoCard,
} from "@/ui/Shared"; } from "@/ui/Shared";
import { Type } from "@/ui/Shared/DeckZone";
import { CardDetail } from "./CardDetail"; import { CardDetail } from "./CardDetail";
import { DeckSelect } from "./DeckSelect"; import { DeckSelect } from "./DeckSelect";
...@@ -49,11 +49,8 @@ import { ...@@ -49,11 +49,8 @@ import {
downloadDeckAsYDK, downloadDeckAsYDK,
editingDeckToIDeck, editingDeckToIDeck,
iDeckToEditingDeck, iDeckToEditingDeck,
type Type,
} from "./utils"; } from "./utils";
const { assetsPath } = useConfig();
export const loader: LoaderFunction = async () => { export const loader: LoaderFunction = async () => {
// 必须先加载卡组,不然页面会崩溃 // 必须先加载卡组,不然页面会崩溃
if (!initStore.decks) { if (!initStore.decks) {
...@@ -207,7 +204,23 @@ export const DeckEditor: React.FC<{ ...@@ -207,7 +204,23 @@ export const DeckEditor: React.FC<{
</Space> </Space>
<ScrollableArea className={styles["deck-zone"]}> <ScrollableArea className={styles["deck-zone"]}>
{(["main", "extra", "side"] as const).map((type) => ( {(["main", "extra", "side"] as const).map((type) => (
<DeckZone key={type} type={type} /> <DeckZone
key={type}
type={type}
cards={[...snapEditDeck[type]]}
canAdd={editDeckStore.canAdd}
onChange={(card, source, destination) => {
editDeckStore.add(destination, card);
if (source !== "search") {
editDeckStore.remove(source, card);
}
}}
onElementClick={(card) => {
selectedCard.id = card.id;
selectedCard.open = true;
}}
onElementRightClick={(card) => editDeckStore.remove(type, card)}
/>
))} ))}
</ScrollableArea> </ScrollableArea>
</div> </div>
...@@ -394,65 +407,6 @@ const Search: React.FC = () => { ...@@ -394,65 +407,6 @@ const Search: React.FC = () => {
); );
}; };
/** 正在组卡的zone,包括main/extra/side */
const DeckZone: React.FC<{
type: Type;
}> = ({ type }) => {
const { message } = App.useApp();
const cards = useSnapshot(editDeckStore)[type];
const [allowToDrop, setAllowToDrop] = useState(false);
const [{ isOver }, dropRef] = useDrop({
accept: ["Card"], // 指明该区域允许接收的拖放物。可以是单个,也可以是数组
// 里面的值就是useDrag所定义的type
// 当拖拽物在这个拖放区域放下时触发,这个item就是拖拽物的item(拖拽物携带的数据)
drop: ({ value, source }: { value: CardMeta; source: Type | "search" }) => {
if (type === source) return;
const { result, reason } = editDeckStore.canAdd(value, type, source);
if (result) {
editDeckStore.add(type, value);
if (source !== "search") {
editDeckStore.remove(source, value);
}
} else {
message.error(reason);
}
},
hover: ({ value, source }) => {
setAllowToDrop(
type !== source
? editDeckStore.canAdd(value, type, source).result
: true,
);
},
collect: (monitor) => ({
isOver: monitor.isOver(),
}),
});
return (
<div
className={classNames(styles[type], {
[styles.over]: isOver,
[styles["not-allow-to-drop"]]: isOver && !allowToDrop,
})}
ref={dropRef}
>
<div className={styles["card-continer"]}>
{cards.map((card, i) => (
<Card
value={card}
key={card.id + i + type}
source={type}
onRightClick={() => editDeckStore.remove(type, card)}
/>
))}
<div className={styles["editing-zone-name"]}>
{`${type.toUpperCase()}: ${cards.length}`}
</div>
</div>
</div>
);
};
/** 搜索区的搜索结果,使用memo避免重复渲染 */ /** 搜索区的搜索结果,使用memo避免重复渲染 */
const SearchResults: React.FC<{ const SearchResults: React.FC<{
results: CardMeta[]; results: CardMeta[];
...@@ -473,7 +427,15 @@ const SearchResults: React.FC<{ ...@@ -473,7 +427,15 @@ const SearchResults: React.FC<{
<> <>
<div className={styles["search-cards"]}> <div className={styles["search-cards"]}>
{currentData.map((card) => ( {currentData.map((card) => (
<Card value={card} key={card.id} source="search" /> <DeckCard
value={card}
key={card.id}
source="search"
onClick={() => {
selectedCard.id = card.id;
selectedCard.open = true;
}}
/>
))} ))}
</div> </div>
{results.length > itemsPerPage && ( {results.length > itemsPerPage && (
...@@ -496,53 +458,6 @@ const SearchResults: React.FC<{ ...@@ -496,53 +458,6 @@ const SearchResults: React.FC<{
); );
}); });
/** 本组件内使用的单张卡片,增加了文字在图片下方 */
const Card: React.FC<{
value: CardMeta;
source: Type | "search";
onRightClick?: () => void;
}> = memo(({ value, source, onRightClick }) => {
const ref = useRef<HTMLDivElement>(null);
const [{ isDragging }, drag] = useDrag({
type: "Card",
item: { value, source },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
drag(ref);
const [showText, setShowText] = useState(true);
const limitCnt = forbidden.get(value.id);
return (
<div
className={styles.card}
ref={ref}
style={{ opacity: isDragging && source !== "search" ? 0 : 1 }}
onClick={() => {
selectedCard.id = value.id;
selectedCard.open = true;
}}
onContextMenu={(e) => {
e.preventDefault();
onRightClick?.();
}}
>
{showText && <div className={styles.cardname}>{value.text.name}</div>}
<YgoCard
className={styles.cardcover}
code={value.id}
onLoad={() => setShowText(false)}
/>
{limitCnt !== undefined && (
<img
className={styles.cardlimit}
src={`${assetsPath}/Limit0${limitCnt}.png`}
/>
)}
</div>
);
});
const HigherCardDetail: React.FC = () => { const HigherCardDetail: React.FC = () => {
const { id, open } = useSnapshot(selectedCard); const { id, open } = useSnapshot(selectedCard);
return ( return (
......
...@@ -2,8 +2,9 @@ import { proxy } from "valtio"; ...@@ -2,8 +2,9 @@ import { proxy } from "valtio";
import { type CardMeta } from "@/api"; import { type CardMeta } from "@/api";
import { isExtraDeckCard, isToken } from "@/common"; import { isExtraDeckCard, isToken } from "@/common";
import { Type } from "@/ui/Shared/DeckZone";
import { compareCards, type EditingDeck, type Type } from "./utils"; import { compareCards, type EditingDeck } from "./utils";
export const editDeckStore = proxy({ export const editDeckStore = proxy({
deckName: "", deckName: "",
......
...@@ -2,8 +2,6 @@ import { type CardMeta, fetchCard } from "@/api"; ...@@ -2,8 +2,6 @@ import { type CardMeta, fetchCard } from "@/api";
import { tellCardBasicType, tellCardSecondaryType } from "@/common"; import { tellCardBasicType, tellCardSecondaryType } from "@/common";
import { type IDeck } from "@/stores"; import { type IDeck } from "@/stores";
export type Type = "main" | "extra" | "side";
/** 用在卡组编辑 */ /** 用在卡组编辑 */
export interface EditingDeck { export interface EditingDeck {
deckName: string; deckName: string;
......
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useSnapshot } from "valtio";
import { resetUniverse } from "@/stores"; import { SideStage, sideStore } from "@/stores";
import { ChangeSideModal, TpModal } from "../Side";
import { import {
Alert, Alert,
AnnounceModal,
CardListModal, CardListModal,
CardModal, CardModal,
CheckCounterModal, CheckCounterModal,
...@@ -21,12 +21,15 @@ import { ...@@ -21,12 +21,15 @@ import {
import { LifeBar, Mat, Menu, Underlying } from "./PlayMat"; import { LifeBar, Mat, Menu, Underlying } from "./PlayMat";
export const Component: React.FC = () => { export const Component: React.FC = () => {
const { stage } = useSnapshot(sideStore);
const navigate = useNavigate();
useEffect(() => { useEffect(() => {
return () => { if (stage === SideStage.SIDE_CHANGING) {
// Duel组件卸载的时候初始化一些store // 跳转更换Side
resetUniverse(); navigate("/side");
}; }
}, []); }, [stage]);
return ( return (
<> <>
...@@ -44,11 +47,8 @@ export const Component: React.FC = () => { ...@@ -44,11 +47,8 @@ export const Component: React.FC = () => {
<OptionModal /> <OptionModal />
<CheckCounterModal /> <CheckCounterModal />
<SortCardModal /> <SortCardModal />
<AnnounceModal />
<SimpleSelectCardsModal /> <SimpleSelectCardsModal />
<EndModal /> <EndModal />
<ChangeSideModal />
<TpModal />
</> </>
); );
}; };
......
import { CheckCard } from "@ant-design/pro-components";
import { Button } from "antd";
import React, { useState } from "react";
import { proxy, useSnapshot } from "valtio";
import { sendSelectOptionResponse } from "@/api";
import { NeosModal } from "./NeosModal";
interface AnnounceModalProps {
isOpen: boolean;
title?: string;
min: number;
options: {
info: string;
response: number;
}[];
}
const defaultProps = {
isOpen: false,
min: 1,
options: [],
};
const localStore = proxy<AnnounceModalProps>(defaultProps);
export const AnnounceModal = () => {
const { isOpen, title, min, options } = useSnapshot(localStore);
const [selected, setSelected] = useState<number[]>([]);
return (
<NeosModal
title={title}
open={isOpen}
footer={
<Button
disabled={selected.length !== min}
onClick={() => {
let response = selected.reduce((res, current) => res | current, 0); // 多个选择求或
sendSelectOptionResponse(response);
rs();
}}
>
submit
</Button>
}
>
<CheckCard.Group
bordered
multiple
size="small"
onChange={(value: any) => {
setSelected(value);
}}
>
{options.map((option, idx) => (
<CheckCard key={idx} title={option.info} value={option.response} />
))}
</CheckCard.Group>
</NeosModal>
);
};
let rs: (arg?: any) => void = () => {};
export const displayAnnounceModal = async (
args: Omit<AnnounceModalProps, "isOpen">,
) => {
Object.entries(args).forEach(([key, value]) => {
// @ts-ignore
localStore[key] = value;
});
localStore.isOpen = true;
await new Promise<void>((resolve) => (rs = resolve)); // 等待在组件内resolve
localStore.isOpen = false;
localStore.min = 1;
localStore.options = [];
localStore.title = undefined;
};
import { CheckCard } from "@ant-design/pro-components"; import { CheckCard } from "@ant-design/pro-components";
import { Button } from "antd"; import { Button, Segmented } from "antd";
import React, { useState } from "react"; import { chunk } from "lodash-es";
import React, { useEffect, useState } from "react";
import { proxy, useSnapshot } from "valtio"; import { proxy, useSnapshot } from "valtio";
import { import {
...@@ -14,52 +15,116 @@ import { ...@@ -14,52 +15,116 @@ import {
import { NeosModal } from "./NeosModal"; import { NeosModal } from "./NeosModal";
type Options = { msg: string; response: number }[]; type Options = { info: string; response: number }[];
const defaultStore = { const defaultStore = {
title: "", title: "",
isOpen: false, isOpen: false,
min: 1,
options: [] satisfies Options as Options, options: [] satisfies Options as Options,
}; };
const store = proxy(defaultStore); const store = proxy(defaultStore);
// 一页最多4个选项
const MAX_NUM_PER_PAGE = 4;
export const OptionModal = () => { export const OptionModal = () => {
const snap = useSnapshot(store); const snap = useSnapshot(store);
const { title, isOpen, min, options } = snap;
// options可能太多,因此分页展示
const [page, setPage] = useState(0);
const maxPage = Math.ceil(options.length / MAX_NUM_PER_PAGE);
const [selecteds, setSelecteds] = useState<number[][]>([]);
const grouped = chunk(options, MAX_NUM_PER_PAGE);
const { title, isOpen, options } = snap; const onSummit = () => {
const responses = selecteds.flat();
const [selected, setSelected] = useState<number | undefined>(undefined); if (responses.length > 0) {
const response = responses.reduce((res, current) => res | current, 0); // 多个选择求或
const onClick = () => { sendSelectOptionResponse(response);
if (selected !== undefined) {
sendSelectOptionResponse(selected);
rs(); rs();
} }
}; };
useEffect(() => {
setSelecteds(Array.from({ length: maxPage }).map((_) => []));
}, [options]);
return ( return (
<NeosModal <NeosModal
title={title} title={title}
open={isOpen} open={isOpen}
footer={ footer={
<Button disabled={selected === undefined} onClick={onClick}> <Button disabled={selecteds.flat().length !== min} onClick={onSummit}>
确定 确定
</Button> </Button>
} }
> >
<CheckCard.Group bordered size="small" onChange={setSelected as any}> <Selector page={page} maxPage={maxPage} onChange={setPage as any} />
{options.map((option, idx) => ( {grouped.map(
<CheckCard key={idx} title={option.msg} value={option.response} /> (options, i) =>
))} i === page && (
</CheckCard.Group> <CheckCard.Group
key={i}
bordered
multiple
value={selecteds[i]}
style={{
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gap: "10px",
}}
onChange={(values: any) => {
const v = selecteds.map((x, i) => (i === page ? values : x));
setSelecteds(v);
}}
>
{options.map((option, idx) => (
<CheckCard
key={idx}
style={{
width: "200px",
marginInlineEnd: 0,
marginBlockEnd: 0,
}}
title={option.info}
value={option.response}
/>
))}
</CheckCard.Group>
),
)}
</NeosModal> </NeosModal>
); );
}; };
/* 选择区域 */
const Selector: React.FC<{
page: number;
maxPage: number;
onChange: (value: number) => void;
}> = ({ page, maxPage, onChange }) =>
maxPage > 1 ? (
<Segmented
block
options={Array.from({ length: maxPage }).map((_, idx) => idx)}
style={{ margin: "10px 0" }}
value={page}
onChange={onChange as any}
></Segmented>
) : (
<></>
);
let rs: (v?: any) => void = () => {}; let rs: (v?: any) => void = () => {};
export const displayOptionModal = async (title: string, options: Options) => { export const displayOptionModal = async (
title: string,
options: Options,
min: number,
) => {
store.title = title; store.title = title;
store.options = options; store.options = options;
store.min = min;
store.isOpen = true; store.isOpen = true;
await new Promise((resolve) => (rs = resolve)); await new Promise((resolve) => (rs = resolve));
store.isOpen = false; store.isOpen = false;
...@@ -87,10 +152,10 @@ export const handleEffectActivation = async ( ...@@ -87,10 +152,10 @@ export const handleEffectActivation = async (
? getCardStr(meta, effect.effectCode & 0xf) ?? "[:?]" ? getCardStr(meta, effect.effectCode & 0xf) ?? "[:?]"
: "[:?]"; : "[:?]";
return { return {
msg: effectMsg, info: effectMsg,
response: effect.response, response: effect.response,
}; };
}); });
await displayOptionModal(fetchStrings(Region.System, 556), options); // 主动发动效果,所以不需要await,但是以后可能要留心 await displayOptionModal(fetchStrings(Region.System, 556), options, 1); // 主动发动效果,所以不需要await,但是以后可能要留心
} }
}; };
...@@ -46,8 +46,8 @@ export const SelectCardsModal: React.FC<SelectCardsModalProps> = ({ ...@@ -46,8 +46,8 @@ export const SelectCardsModal: React.FC<SelectCardsModalProps> = ({
onCancel, onCancel,
onFinish, onFinish,
}) => { }) => {
// FIXME: handle `selecteds` const grouped = groupBy(selectables, (option) => option.location?.zone!);
const [result, setResult] = useState<Option[]>([]); const [result, setResult] = useState<[ygopro.CardZone, Option[]][]>([]);
const [submitable, setSubmitable] = useState(false); const [submitable, setSubmitable] = useState(false);
const hint = useSnapshot(matStore.hint); const hint = useSnapshot(matStore.hint);
...@@ -56,14 +56,21 @@ export const SelectCardsModal: React.FC<SelectCardsModalProps> = ({ ...@@ -56,14 +56,21 @@ export const SelectCardsModal: React.FC<SelectCardsModalProps> = ({
const minMaxText = min === max ? min : `${min}-${max}`; const minMaxText = min === max ? min : `${min}-${max}`;
// const isMultiple = !single && max > 1; useEffect(() => {
// FIXME: 如果想上面这样鞋会panic,还不是很清楚原因,先放着后面再优化 const initial: [ygopro.CardZone, Option[]][] = grouped.map(([zone, _]) => [
const isMultiple = true; zone,
[] as Option[],
]);
if (initial.length > 0) {
setResult(initial);
}
}, [selectables]);
// 判断是否可以提交 // 判断是否可以提交
useEffect(() => { useEffect(() => {
const flatResult = result.map(([_, v]) => v).flat();
const [sumLevel1, sumLevel2] = (["level1", "level2"] as const).map((key) => const [sumLevel1, sumLevel2] = (["level1", "level2"] as const).map((key) =>
[...mustSelects, ...result] [...mustSelects, ...flatResult]
.map((option) => option[key] || 0) .map((option) => option[key] || 0)
.reduce((sum, current) => sum + current, 0), .reduce((sum, current) => sum + current, 0),
); );
...@@ -72,12 +79,10 @@ export const SelectCardsModal: React.FC<SelectCardsModalProps> = ({ ...@@ -72,12 +79,10 @@ export const SelectCardsModal: React.FC<SelectCardsModalProps> = ({
: sumLevel1 === totalLevels || sumLevel2 === totalLevels; : sumLevel1 === totalLevels || sumLevel2 === totalLevels;
setSubmitable( setSubmitable(
single single
? result.length === 1 ? flatResult.length === 1
: result.length >= min && result.length <= max && levelMatched, : flatResult.length >= min && flatResult.length <= max && levelMatched,
); );
}, [result.length]); }, [result]);
const grouped = groupBy(selectables, (option) => option.location?.zone!);
const zoneOptions = grouped.map((x) => ({ const zoneOptions = grouped.map((x) => ({
value: x[0], value: x[0],
...@@ -126,7 +131,9 @@ export const SelectCardsModal: React.FC<SelectCardsModalProps> = ({ ...@@ -126,7 +131,9 @@ export const SelectCardsModal: React.FC<SelectCardsModalProps> = ({
<Button <Button
type="primary" type="primary"
disabled={!submitable} disabled={!submitable}
onClick={() => onSubmit([...mustSelects, ...result])} onClick={() =>
onSubmit([...mustSelects, ...result.map(([_, v]) => v).flat()])
}
> >
{submitText} {submitText}
</Button> </Button>
...@@ -144,11 +151,18 @@ export const SelectCardsModal: React.FC<SelectCardsModalProps> = ({ ...@@ -144,11 +151,18 @@ export const SelectCardsModal: React.FC<SelectCardsModalProps> = ({
options[0] === selectedZone && ( options[0] === selectedZone && (
<div className={styles["container"]} key={i}> <div className={styles["container"]} key={i}>
<CheckCard.Group <CheckCard.Group
onChange={(res) => { onChange={(res: any) => {
setResult((isMultiple ? res : [res]) as any); const newRes: [ygopro.CardZone, Option[]][] = result.map(
([k, v]) => [k, k === selectedZone ? res : v],
);
setResult(newRes);
}} }}
value={
result.find(([k, _]) => k === selectedZone)?.[1] ??
([] as any)
}
// TODO 考虑如何设置默认值,比如只有一个的,就直接选中 // TODO 考虑如何设置默认值,比如只有一个的,就直接选中
multiple={isMultiple} multiple
className={styles["check-group"]} className={styles["check-group"]}
> >
{options[1].map((card, j) => ( {options[1].map((card, j) => (
......
export * from "./Alert"; export * from "./Alert";
export * from "./AnnounceModal";
export * from "./CardListModal"; export * from "./CardListModal";
export * from "./CardModal"; export * from "./CardModal";
export * from "./CheckCounterModal"; export * from "./CheckCounterModal";
......
...@@ -351,11 +351,11 @@ const handleEffectActivation = ( ...@@ -351,11 +351,11 @@ const handleEffectActivation = (
? getCardStr(meta, effect.effectCode & 0xf) ?? "[:?]" ? getCardStr(meta, effect.effectCode & 0xf) ?? "[:?]"
: "[:?]"; : "[:?]";
return { return {
msg: effectMsg, info: effectMsg,
response: effect.response, response: effect.response,
}; };
}); });
displayOptionModal(fetchStrings(Region.System, 556), options); // 主动发动效果,所以不需要await,但是以后可能要留心 displayOptionModal(fetchStrings(Region.System, 556), options, 1); // 主动发动效果,所以不需要await,但是以后可能要留心
} }
}; };
......
...@@ -64,7 +64,7 @@ export const Component = () => { ...@@ -64,7 +64,7 @@ export const Component = () => {
const logined = Boolean(useSnapshot(accountStore).user); const logined = Boolean(useSnapshot(accountStore).user);
const { pathname } = routerLocation; const { pathname } = routerLocation;
const pathnamesHideHeader = ["/waitroom", "/duel"]; const pathnamesHideHeader = ["/waitroom", "/duel", "/side"];
const callbackUrl = `${location.origin}/match/`; const callbackUrl = `${location.origin}/match/`;
const onLogin = () => location.replace(getSSOSignInUrl(callbackUrl)); const onLogin = () => location.replace(getSSOSignInUrl(callbackUrl));
......
...@@ -6,12 +6,12 @@ import { ...@@ -6,12 +6,12 @@ import {
} from "@ant-design/icons"; } from "@ant-design/icons";
import { App, Button, Space } from "antd"; import { App, Button, Space } from "antd";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { LoaderFunction, useNavigate } from "react-router-dom";
import { useSnapshot } from "valtio"; import { useSnapshot } from "valtio";
import { match } from "@/api"; import { match } from "@/api";
import { useConfig } from "@/config"; import { useConfig } from "@/config";
import { accountStore, deckStore, roomStore } from "@/stores"; import { accountStore, deckStore, resetUniverse, roomStore } from "@/stores";
import { Background, IconFont, Select } from "@/ui/Shared"; import { Background, IconFont, Select } from "@/ui/Shared";
import styles from "./index.module.scss"; import styles from "./index.module.scss";
...@@ -21,6 +21,12 @@ import { connectSrvpro } from "./util"; ...@@ -21,6 +21,12 @@ import { connectSrvpro } from "./util";
const NeosConfig = useConfig(); const NeosConfig = useConfig();
export const loader: LoaderFunction = () => {
// 在加载这个页面之前先重置一些store,清掉上局游戏遗留的数据
resetUniverse();
return null;
};
export const Component: React.FC = () => { export const Component: React.FC = () => {
const { message } = App.useApp(); const { message } = App.useApp();
const serverList = NeosConfig.servers; const serverList = NeosConfig.servers;
......
...@@ -28,6 +28,10 @@ const router = createBrowserRouter([ ...@@ -28,6 +28,10 @@ const router = createBrowserRouter([
path: "/duel", path: "/duel",
lazy: () => import("./Duel/Main"), lazy: () => import("./Duel/Main"),
}, },
{
path: "/side",
lazy: () => import("./Side"),
},
], ],
}, },
]); ]);
......
.card {
cursor: move;
width: 100%;
background-color: rgba(255, 255, 255, 0.1);
aspect-ratio: var(--card-ratio);
position: relative;
background-size: contain;
content-visibility: auto;
transition: 0.1s;
&:hover {
filter: brightness(0.9);
}
.cardname {
font-size: 12px;
position: absolute;
padding: 5px;
top: 0;
bottom: 0;
max-height: 100%;
margin: auto;
left: 0;
height: fit-content;
width: 100%;
text-align: center;
line-height: 1.75em;
overflow: hidden; //超出的文本隐藏
text-overflow: ellipsis; //溢出用省略号显示
}
.cardcover {
position: relative;
}
.cardlimit {
position: absolute;
top: 2px;
left: 2px;
width: 20px;
}
}
import React, { memo, useRef, useState } from "react";
import { useDrag } from "react-dnd";
import { CardMeta, forbidden } from "@/api";
import { useConfig } from "@/config";
import { Type } from "../DeckZone";
import { YgoCard } from "../YgoCard";
import styles from "./index.module.scss";
const { assetsPath } = useConfig();
/** 组卡页和Side页使用的单张卡片,增加了文字和禁限数量 */
export const DeckCard: React.FC<{
value: CardMeta;
source: Type | "search";
onRightClick?: () => void;
onClick?: () => void;
}> = memo(({ value, source, onRightClick, onClick }) => {
const ref = useRef<HTMLDivElement>(null);
const [{ isDragging }, drag] = useDrag({
type: "Card",
item: { value, source },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
drag(ref);
const [showText, setShowText] = useState(true);
const limitCnt = forbidden.get(value.id);
return (
<div
className={styles.card}
ref={ref}
style={{ opacity: isDragging && source !== "search" ? 0 : 1 }}
onClick={onClick}
onContextMenu={(e) => {
e.preventDefault();
onRightClick?.();
}}
>
{showText && <div className={styles.cardname}>{value.text.name}</div>}
<YgoCard
className={styles.cardcover}
code={value.id}
onLoad={() => setShowText(false)}
/>
{limitCnt !== undefined && (
<img
className={styles.cardlimit}
src={`${assetsPath}/Limit0${limitCnt}.png`}
/>
)}
</div>
);
});
.main,
.extra,
.side {
transition: 0.2s;
position: relative;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding: 0.75rem;
&.over {
background-color: hsla(0, 0%, 100%, 0.05);
}
&.not-allow-to-drop {
background-color: rgba(255, 0, 0, 0.15);
cursor: not-allowed;
}
}
.main {
flex: 3;
}
.extra,
.side {
flex: 1;
}
.card-continer {
display: grid;
grid-template-columns: repeat(var(--card-grid), 1fr);
gap: 5px;
}
.editing-zone-name {
position: absolute;
right: 0;
bottom: 0;
background-color: #212332;
color: hsla(0, 0%, 100%, 0.3);
font-size: 12px;
padding: 2px 6px;
font-family: var(--theme-font);
user-select: none;
}
import { App } from "antd";
import classNames from "classnames";
import React, { useState } from "react";
import { useDrop } from "react-dnd";
import { CardMeta } from "@/api";
import { DeckCard } from "../DeckCard";
import styles from "./index.module.scss";
/** 正在组卡的zone,包括main/extra/side
* 该组件内部没有引用任何store,是解耦的*/
export type Type = "main" | "extra" | "side";
export const DeckZone: React.FC<{
type: Type;
cards: CardMeta[];
canAdd: (
card: CardMeta,
type: Type,
source: Type | "search",
) => { result: boolean; reason: string };
onChange: (
card: CardMeta,
source: Type | "search",
destination: Type,
) => void;
onElementClick: (card: CardMeta) => void;
onElementRightClick?: (card: CardMeta) => void;
}> = ({
type,
cards,
canAdd,
onChange,
onElementClick,
onElementRightClick,
}) => {
const { message } = App.useApp();
const [allowToDrop, setAllowToDrop] = useState(false);
const [{ isOver }, dropRef] = useDrop({
accept: ["Card"], // 指明该区域允许接收的拖放物。可以是单个,也可以是数组
// 里面的值就是useDrag所定义的type
// 当拖拽物在这个拖放区域放下时触发,这个item就是拖拽物的item(拖拽物携带的数据)
drop: ({ value, source }: { value: CardMeta; source: Type | "search" }) => {
if (type === source) return;
const { result, reason } = canAdd(value, type, source);
if (result) {
onChange(value, source, type);
} else {
message.error(reason);
}
},
hover: ({ value, source }) => {
setAllowToDrop(
type !== source ? canAdd(value, type, source).result : true,
);
},
collect: (monitor) => ({
isOver: monitor.isOver(),
}),
});
return (
<div
className={classNames(styles[type], {
[styles.over]: isOver,
[styles["not-allow-to-drop"]]: isOver && !allowToDrop,
})}
ref={dropRef}
>
<div className={styles["card-continer"]}>
{cards.map((card, i) => (
<DeckCard
value={card}
key={card.id + i + type}
source={type}
onClick={() => {
onElementClick(card);
}}
onRightClick={() => {
onElementRightClick?.(card);
}}
/>
))}
<div className={styles["editing-zone-name"]}>
{`${type.toUpperCase()}: ${cards.length}`}
</div>
</div>
</div>
);
};
export * from "./Background"; export * from "./Background";
export * from "./CardEffectText"; export * from "./CardEffectText";
export * from "./css"; export * from "./css";
export * from "./DeckCard";
export * from "./DeckZone";
export * from "./IconFont"; export * from "./IconFont";
export * from "./Loading"; export * from "./Loading";
export * from "./Scrollbar"; export * from "./Scrollbar";
......
import { App, Button, Modal } from "antd";
import React, { useEffect } from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { useSnapshot } from "valtio";
import { CardMeta, sendUpdateDeck } from "@/api";
import { roomStore, SideStage, sideStore } from "@/stores";
import { DeckEditor } from "../../BuildDeck";
import { editDeckStore } from "../../BuildDeck/store";
import { iDeckToEditingDeck } from "../../BuildDeck/utils";
import { Background } from "../../Shared";
export const ChangeSideModal: React.FC = () => {
const { message } = App.useApp();
const { deckName, main, extra, side } = useSnapshot(editDeckStore);
const { stage } = useSnapshot(sideStore);
const { errorMsg } = useSnapshot(roomStore);
const cardMeta2Id = (meta: CardMeta) => meta.id;
const handleSummit = () => {
const newDeck = {
deckName: deckName,
main: main.map(cardMeta2Id),
extra: extra.map(cardMeta2Id),
side: side.map(cardMeta2Id),
};
sendUpdateDeck(newDeck);
editDeckStore.edited = false;
};
useEffect(() => {
if (stage === SideStage.SIDE_CHANGED) {
message.info("副卡组更换成功,请耐心等待其他玩家更换卡组");
}
}, [stage]);
useEffect(() => {
if (errorMsg !== undefined && errorMsg !== "") {
message.error(errorMsg);
roomStore.errorMsg = undefined;
}
}, [errorMsg]);
return (
<Modal
title="请选择更换副卡组"
open={
stage === SideStage.SIDE_CHANGING || stage === SideStage.SIDE_CHANGED
}
width={700}
closable={false}
footer={
<Button
disabled={stage > SideStage.SIDE_CHANGING}
onClick={handleSummit}
>
副卡组更换完毕
</Button>
}
>
<DndProvider backend={HTML5Backend}>
<Background />
<DeckEditor
deck={sideStore.deck}
onClear={() => message.error("对局中清空卡组不怕找不回来吗?!")}
onSave={() => message.error("点击右下角按钮确认副卡组更换完毕")}
onReset={async () => {
editDeckStore.set(await iDeckToEditingDeck(sideStore.deck));
}}
/>
</DndProvider>
</Modal>
);
};
...@@ -5,7 +5,7 @@ import { useSnapshot } from "valtio"; ...@@ -5,7 +5,7 @@ import { useSnapshot } from "valtio";
import { sendTpResult } from "@/api"; import { sendTpResult } from "@/api";
import { SideStage, sideStore } from "@/stores"; import { SideStage, sideStore } from "@/stores";
import styles from "./index.module.scss"; import styles from "./TpModal.module.scss";
export const TpModal: React.FC = () => { export const TpModal: React.FC = () => {
const { stage } = useSnapshot(sideStore); const { stage } = useSnapshot(sideStore);
......
.container {
display: flex;
height: 100%;
}
.sider {
width: var(--sider-width);
flex: 0 0 var(--sider-width);
background-color: hsla(0, 0%, 100%, 0.05);
position: relative;
height: 100%;
max-height: 100%;
min-height: 100%;
}
.content {
flex: 1;
padding-bottom: 0;
padding: 1rem;
width: 660px;
}
.deck-container {
width: -webkit-fill-available;
height: calc(100% - 20px);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 10px;
display: flex;
flex-direction: column;
& > *:not(:last-of-type) {
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.title {
height: 44px;
flex: 0 0 44px;
justify-content: space-between;
padding: 1em 2em;
font-size: 16px;
}
.deck-zone {
display: flex;
flex-direction: column;
height: 100%;
--card-grid: 15;
}
background-color: hsla(0, 0%, 100%, 0.05);
backdrop-filter: blur(5px);
}
.detail-container {
--detail-width: 300px;
width: var(--detail-width);
flex: 0 0 var(--detail-width);
position: relative;
}
export * from "./ChangeSideModal"; import { CheckOutlined, UndoOutlined } from "@ant-design/icons";
export * from "./TpModal"; import { App, Button, Space } from "antd";
import React, { useEffect, useState } from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { useNavigate } from "react-router-dom";
import { useSnapshot } from "valtio";
import { CardMeta, fetchCard, sendUpdateDeck } from "@/api";
import { isExtraDeckCard } from "@/common";
import { IDeck, roomStore, SideStage, sideStore } from "@/stores";
import { CardDetail } from "../BuildDeck/CardDetail";
import { Background, DeckZone, ScrollableArea, Type } from "../Shared";
import { Chat } from "../WaitRoom/Chat";
import styles from "./index.module.scss";
import { TpModal } from "./TpModal";
export const Component: React.FC = () => {
const { message } = App.useApp();
const { deck: sideDeck } = sideStore;
const { stage } = useSnapshot(sideStore);
const { errorMsg } = useSnapshot(roomStore);
const initialDeck = JSON.parse(JSON.stringify(sideDeck));
const [deck, setDeck] = useState<IDeck>(initialDeck);
const [selectedCard, setSelectedCard] = useState(0);
const navigate = useNavigate();
const canAdd = (card: CardMeta, type: Type, _source: Type | "search") => {
const cardType = card.data.type ?? 0;
if (
(type === "extra" && !isExtraDeckCard(cardType)) ||
(type === "main" && isExtraDeckCard(cardType))
) {
return { result: false, reason: "卡片种类不符合" };
} else {
return { result: true, reason: "" };
}
};
const onChange = (
card: CardMeta,
source: Type | "search",
destination: Type,
) => {
setDeck((prev) => {
const deck = { ...prev };
if (source !== "search") {
const removeIndex = deck[source].findIndex((id) => id === card.id);
if (removeIndex !== -1) {
deck[source].splice(removeIndex, 1);
}
}
deck[destination].push(card.id);
return deck;
});
};
const onReset = () => {
setDeck(JSON.parse(JSON.stringify(sideDeck)));
message.info("重置成功");
};
const onSummit = () => sendUpdateDeck(deck);
useEffect(() => {
if (stage === SideStage.SIDE_CHANGED) {
message.info("副卡组更换成功,请耐心等待其他玩家更换卡组");
}
if (stage === SideStage.DUEL_START) {
// 决斗开始,跳转
navigate("/duel");
}
}, [stage]);
useEffect(() => {
if (errorMsg !== undefined && errorMsg !== "") {
message.error(errorMsg);
roomStore.errorMsg = undefined;
}
}, [errorMsg]);
return (
<DndProvider backend={HTML5Backend}>
<Background />
<div className={styles.container}>
<div className={styles.sider}>
<Chat />
</div>
<div className={styles.content}>
<div className={styles["deck-container"]}>
<Space className={styles.title}>
<div>请拖动更换副卡组</div>
<Space style={{ marginRight: 6 }}>
<Button
type="text"
size="small"
icon={<UndoOutlined />}
onClick={onReset}
>
重置
</Button>
<Button
type="primary"
size="small"
icon={<CheckOutlined />}
disabled={stage > SideStage.SIDE_CHANGING}
onClick={onSummit}
>
确定
</Button>
</Space>
</Space>
<ScrollableArea className={styles["deck-zone"]}>
{(["main", "extra", "side"] as const).map((type) => (
<DeckZone
key={type}
type={type}
cards={[...deck[type]].map((id) => fetchCard(id))}
canAdd={canAdd}
onChange={onChange}
onElementClick={(card) => setSelectedCard(card.id)}
/>
))}
</ScrollableArea>
</div>
</div>
<div className={styles["detail-container"]}>
<CardDetail code={selectedCard} open={true} onClose={() => {}} />
</div>
</div>
<TpModal />
</DndProvider>
);
};
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