Commit ad0a228f authored by Chunchi Che's avatar Chunchi Che

Merge branch 'optimize/deckeditor' into 'main'

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

See merge request mycard/Neos!290
parents e23ceeb3 7f197346
Pipeline #23306 passed with stages
in 12 minutes and 14 seconds
import { fetchCard, fetchStrings, Region, ygopro } from "@/api";
import { displayAnnounceModal } from "@/ui/Duel/Message";
import { displayOptionModal } from "@/ui/Duel/Message";
import MsgAnnounce = ygopro.StocGameMessage.MsgAnnounce;
export default async (announce: MsgAnnounce) => {
......@@ -14,26 +14,26 @@ export default async (announce: MsgAnnounce) => {
switch (type_) {
case MsgAnnounce.AnnounceType.RACE: {
await displayAnnounceModal({
min,
title: fetchStrings(Region.System, 563),
options: announce.options.map((option) => ({
await displayOptionModal(
fetchStrings(Region.System, 563),
announce.options.map((option) => ({
info: fetchStrings(Region.System, 1200 + option.code),
response: option.response,
})),
});
min,
);
break;
}
case MsgAnnounce.AnnounceType.Attribute: {
await displayAnnounceModal({
min,
title: fetchStrings(Region.System, 562),
options: announce.options.map((option) => ({
await displayOptionModal(
fetchStrings(Region.System, 562),
announce.options.map((option) => ({
info: fetchStrings(Region.System, 1010 + option.code),
response: option.response,
})),
});
min,
);
break;
}
......@@ -48,23 +48,19 @@ export default async (announce: MsgAnnounce) => {
});
}
}
await displayAnnounceModal({
min,
title: fetchStrings(Region.System, 564),
options,
});
await displayOptionModal(fetchStrings(Region.System, 564), options, min);
break;
}
case MsgAnnounce.AnnounceType.Number: {
await displayAnnounceModal({
min,
title: fetchStrings(Region.System, 565),
options: announce.options.map((option) => ({
await displayOptionModal(
fetchStrings(Region.System, 565),
announce.options.map((option) => ({
info: option.code.toString(),
response: option.response,
})),
});
min,
);
break;
}
......
......@@ -5,9 +5,13 @@ export default async (mora: ygopro.StocGameMessage.MsgRockPaperScissors) => {
const _player = mora.player;
// TODO: I18n
await displayOptionModal("请选择猜拳", [
{ msg: "剪刀", response: 1 },
{ msg: "石头", response: 2 },
{ msg: "", response: 3 },
]);
await displayOptionModal(
"请选择猜拳",
[
{ info: "剪刀", response: 1 },
{ info: "石头", response: 2 },
{ info: "", response: 3 },
],
1,
);
};
......@@ -14,9 +14,10 @@ export default async (selectOption: ygopro.StocGameMessage.MsgSelectOption) => {
await Promise.all(
options.map(async ({ code, response }) => {
const meta = fetchCard(code >> 4);
const msg = getCardStr(meta, code & 0xf) || "[?]";
return { msg, response };
const info = getCardStr(meta, code & 0xf) ?? "[?]";
return { info, response };
}),
),
1,
);
};
......@@ -5,11 +5,9 @@ import { cardStore } from "@/stores/cardStore";
import { matStore } from "../store";
const { hint } = matStore;
export const fetchCommonHintMeta = (code: number) => {
hint.code = code;
hint.msg = fetchStrings(Region.System, code);
matStore.hint.code = code;
matStore.hint.msg = fetchStrings(Region.System, code);
};
export const fetchSelectHintMeta = async ({
......@@ -31,13 +29,13 @@ export const fetchSelectHintMeta = async ({
selectHintMeta = await getStrings(selectHintData);
}
hint.code = selectHintData;
if (hint.code > DESCRIPTION_LIMIT) {
matStore.hint.code = selectHintData;
if (matStore.hint.code > DESCRIPTION_LIMIT) {
// 针对`MSG_SELECT_PLACE`的特化逻辑
hint.msg = selectHintMeta;
matStore.hint.msg = selectHintMeta;
} else {
hint.esSelectHint = selectHintMeta;
hint.esHint = esHint;
matStore.hint.esSelectHint = selectHintMeta;
matStore.hint.esHint = esHint;
}
};
......@@ -74,5 +72,5 @@ export const fetchEsHintMeta = async ({
}
}
hint.esHint = esHint;
matStore.hint.esHint = esHint;
};
......@@ -57,33 +57,7 @@
display: flex;
flex-direction: column;
height: 100%;
}
.main,
.extra,
.side {
transition: 0.2s;
position: relative;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding: 0.75rem;
&.over {
background-color: hsla(0, 0%, 100%, 0.05);
}
&.not-allow-to-drop {
background-color: rgba(255, 0, 0, 0.15);
cursor: not-allowed;
}
}
.main {
flex: 3;
}
.extra,
.side {
flex: 1;
}
.card-continer {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 5px;
--card-grid: 10;
}
background-color: hsla(0, 0%, 100%, 0.05);
backdrop-filter: blur(5px);
......@@ -98,45 +72,6 @@
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 {
height: 100%;
.search-cards {
......
......@@ -16,28 +16,28 @@ import {
Pagination,
Space,
} from "antd";
import classNames from "classnames";
import { isEqual } from "lodash-es";
import { type OverlayScrollbarsComponentRef } from "overlayscrollbars-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 { LoaderFunction } from "react-router-dom";
import { proxy, useSnapshot } from "valtio";
import { subscribeKey } from "valtio/utils";
import { type CardMeta, forbidden, searchCards } from "@/api";
import { type CardMeta, searchCards } from "@/api";
import { isToken } from "@/common";
import { useConfig } from "@/config";
import { FtsConditions } from "@/middleware/sqlite/fts";
import { deckStore, type IDeck, initStore } from "@/stores";
import {
Background,
DeckCard,
DeckZone,
IconFont,
Loading,
ScrollableArea,
YgoCard,
} from "@/ui/Shared";
import { Type } from "@/ui/Shared/DeckZone";
import { CardDetail } from "./CardDetail";
import { DeckSelect } from "./DeckSelect";
......@@ -49,11 +49,8 @@ import {
downloadDeckAsYDK,
editingDeckToIDeck,
iDeckToEditingDeck,
type Type,
} from "./utils";
const { assetsPath } = useConfig();
export const loader: LoaderFunction = async () => {
// 必须先加载卡组,不然页面会崩溃
if (!initStore.decks) {
......@@ -207,7 +204,23 @@ export const DeckEditor: React.FC<{
</Space>
<ScrollableArea className={styles["deck-zone"]}>
{(["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>
</div>
......@@ -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避免重复渲染 */
const SearchResults: React.FC<{
results: CardMeta[];
......@@ -473,7 +427,15 @@ const SearchResults: React.FC<{
<>
<div className={styles["search-cards"]}>
{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>
{results.length > itemsPerPage && (
......@@ -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 { id, open } = useSnapshot(selectedCard);
return (
......
......@@ -2,8 +2,9 @@ import { proxy } from "valtio";
import { type CardMeta } from "@/api";
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({
deckName: "",
......
......@@ -2,8 +2,6 @@ import { type CardMeta, fetchCard } from "@/api";
import { tellCardBasicType, tellCardSecondaryType } from "@/common";
import { type IDeck } from "@/stores";
export type Type = "main" | "extra" | "side";
/** 用在卡组编辑 */
export interface EditingDeck {
deckName: string;
......
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 {
Alert,
AnnounceModal,
CardListModal,
CardModal,
CheckCounterModal,
......@@ -21,12 +21,15 @@ import {
import { LifeBar, Mat, Menu, Underlying } from "./PlayMat";
export const Component: React.FC = () => {
const { stage } = useSnapshot(sideStore);
const navigate = useNavigate();
useEffect(() => {
return () => {
// Duel组件卸载的时候初始化一些store
resetUniverse();
};
}, []);
if (stage === SideStage.SIDE_CHANGING) {
// 跳转更换Side
navigate("/side");
}
}, [stage]);
return (
<>
......@@ -44,11 +47,8 @@ export const Component: React.FC = () => {
<OptionModal />
<CheckCounterModal />
<SortCardModal />
<AnnounceModal />
<SimpleSelectCardsModal />
<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 { Button } from "antd";
import React, { useState } from "react";
import { Button, Segmented } from "antd";
import { chunk } from "lodash-es";
import React, { useEffect, useState } from "react";
import { proxy, useSnapshot } from "valtio";
import {
......@@ -14,52 +15,116 @@ import {
import { NeosModal } from "./NeosModal";
type Options = { msg: string; response: number }[];
type Options = { info: string; response: number }[];
const defaultStore = {
title: "",
isOpen: false,
min: 1,
options: [] satisfies Options as Options,
};
const store = proxy(defaultStore);
// 一页最多4个选项
const MAX_NUM_PER_PAGE = 4;
export const OptionModal = () => {
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 [selected, setSelected] = useState<number | undefined>(undefined);
const onClick = () => {
if (selected !== undefined) {
sendSelectOptionResponse(selected);
const onSummit = () => {
const responses = selecteds.flat();
if (responses.length > 0) {
const response = responses.reduce((res, current) => res | current, 0); // 多个选择求或
sendSelectOptionResponse(response);
rs();
}
};
useEffect(() => {
setSelecteds(Array.from({ length: maxPage }).map((_) => []));
}, [options]);
return (
<NeosModal
title={title}
open={isOpen}
footer={
<Button disabled={selected === undefined} onClick={onClick}>
<Button disabled={selecteds.flat().length !== min} onClick={onSummit}>
确定
</Button>
}
>
<CheckCard.Group bordered size="small" onChange={setSelected as any}>
{options.map((option, idx) => (
<CheckCard key={idx} title={option.msg} value={option.response} />
))}
</CheckCard.Group>
<Selector page={page} maxPage={maxPage} onChange={setPage as any} />
{grouped.map(
(options, i) =>
i === page && (
<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>
);
};
/* 选择区域 */
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 = () => {};
export const displayOptionModal = async (title: string, options: Options) => {
export const displayOptionModal = async (
title: string,
options: Options,
min: number,
) => {
store.title = title;
store.options = options;
store.min = min;
store.isOpen = true;
await new Promise((resolve) => (rs = resolve));
store.isOpen = false;
......@@ -87,10 +152,10 @@ export const handleEffectActivation = async (
? getCardStr(meta, effect.effectCode & 0xf) ?? "[:?]"
: "[:?]";
return {
msg: effectMsg,
info: effectMsg,
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> = ({
onCancel,
onFinish,
}) => {
// FIXME: handle `selecteds`
const [result, setResult] = useState<Option[]>([]);
const grouped = groupBy(selectables, (option) => option.location?.zone!);
const [result, setResult] = useState<[ygopro.CardZone, Option[]][]>([]);
const [submitable, setSubmitable] = useState(false);
const hint = useSnapshot(matStore.hint);
......@@ -56,14 +56,21 @@ export const SelectCardsModal: React.FC<SelectCardsModalProps> = ({
const minMaxText = min === max ? min : `${min}-${max}`;
// const isMultiple = !single && max > 1;
// FIXME: 如果想上面这样鞋会panic,还不是很清楚原因,先放着后面再优化
const isMultiple = true;
useEffect(() => {
const initial: [ygopro.CardZone, Option[]][] = grouped.map(([zone, _]) => [
zone,
[] as Option[],
]);
if (initial.length > 0) {
setResult(initial);
}
}, [selectables]);
// 判断是否可以提交
useEffect(() => {
const flatResult = result.map(([_, v]) => v).flat();
const [sumLevel1, sumLevel2] = (["level1", "level2"] as const).map((key) =>
[...mustSelects, ...result]
[...mustSelects, ...flatResult]
.map((option) => option[key] || 0)
.reduce((sum, current) => sum + current, 0),
);
......@@ -72,12 +79,10 @@ export const SelectCardsModal: React.FC<SelectCardsModalProps> = ({
: sumLevel1 === totalLevels || sumLevel2 === totalLevels;
setSubmitable(
single
? result.length === 1
: result.length >= min && result.length <= max && levelMatched,
? flatResult.length === 1
: flatResult.length >= min && flatResult.length <= max && levelMatched,
);
}, [result.length]);
const grouped = groupBy(selectables, (option) => option.location?.zone!);
}, [result]);
const zoneOptions = grouped.map((x) => ({
value: x[0],
......@@ -126,7 +131,9 @@ export const SelectCardsModal: React.FC<SelectCardsModalProps> = ({
<Button
type="primary"
disabled={!submitable}
onClick={() => onSubmit([...mustSelects, ...result])}
onClick={() =>
onSubmit([...mustSelects, ...result.map(([_, v]) => v).flat()])
}
>
{submitText}
</Button>
......@@ -144,11 +151,18 @@ export const SelectCardsModal: React.FC<SelectCardsModalProps> = ({
options[0] === selectedZone && (
<div className={styles["container"]} key={i}>
<CheckCard.Group
onChange={(res) => {
setResult((isMultiple ? res : [res]) as any);
onChange={(res: 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 考虑如何设置默认值,比如只有一个的,就直接选中
multiple={isMultiple}
multiple
className={styles["check-group"]}
>
{options[1].map((card, j) => (
......
export * from "./Alert";
export * from "./AnnounceModal";
export * from "./CardListModal";
export * from "./CardModal";
export * from "./CheckCounterModal";
......
......@@ -351,11 +351,11 @@ const handleEffectActivation = (
? getCardStr(meta, effect.effectCode & 0xf) ?? "[:?]"
: "[:?]";
return {
msg: effectMsg,
info: effectMsg,
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 = () => {
const logined = Boolean(useSnapshot(accountStore).user);
const { pathname } = routerLocation;
const pathnamesHideHeader = ["/waitroom", "/duel"];
const pathnamesHideHeader = ["/waitroom", "/duel", "/side"];
const callbackUrl = `${location.origin}/match/`;
const onLogin = () => location.replace(getSSOSignInUrl(callbackUrl));
......
......@@ -6,12 +6,12 @@ import {
} from "@ant-design/icons";
import { App, Button, Space } from "antd";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { LoaderFunction, useNavigate } from "react-router-dom";
import { useSnapshot } from "valtio";
import { match } from "@/api";
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 styles from "./index.module.scss";
......@@ -21,6 +21,12 @@ import { connectSrvpro } from "./util";
const NeosConfig = useConfig();
export const loader: LoaderFunction = () => {
// 在加载这个页面之前先重置一些store,清掉上局游戏遗留的数据
resetUniverse();
return null;
};
export const Component: React.FC = () => {
const { message } = App.useApp();
const serverList = NeosConfig.servers;
......
......@@ -28,6 +28,10 @@ const router = createBrowserRouter([
path: "/duel",
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 "./CardEffectText";
export * from "./css";
export * from "./DeckCard";
export * from "./DeckZone";
export * from "./IconFont";
export * from "./Loading";
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";
import { sendTpResult } from "@/api";
import { SideStage, sideStore } from "@/stores";
import styles from "./index.module.scss";
import styles from "./TpModal.module.scss";
export const TpModal: React.FC = () => {
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";
export * from "./TpModal";
import { CheckOutlined, UndoOutlined } from "@ant-design/icons";
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