Commit a754c2c2 authored by timel's avatar timel

Merge branch 'dev/ui' into 'main'

Dev/ui

See merge request !246
parents 53a9bdf4 0cad7ff4
Pipeline #22709 passed with stages
in 11 minutes and 35 seconds
...@@ -12,12 +12,15 @@ export enum Task { ...@@ -12,12 +12,15 @@ export enum Task {
const getEnd = (task: Task) => `${task}-end`; const getEnd = (task: Task) => `${task}-end`;
/** 在组件之中注册方法 */ /** 在组件之中注册方法 */
const register = (task: Task, fn: (...args: any[]) => Promise<any>) => { const register = <T extends unknown[]>(
task: Task,
fn: (...args: T) => Promise<boolean>
) => {
eventEmitter.on( eventEmitter.on(
task, task,
async ({ taskId, args }: { taskId: string; args: any[] }) => { async ({ taskId, args }: { taskId: string; args: T }) => {
await fn(...args); const result = await fn(...args);
eventEmitter.emit(getEnd(task), taskId); if (result) eventEmitter.emit(getEnd(task), taskId);
} }
); );
}; };
......
import { ygopro } from "@/api"; import { ygopro } from "@/api";
import { eventbus, sleep, Task } from "@/infra";
import { cardStore, fetchEsHintMeta } from "@/stores"; import { cardStore, fetchEsHintMeta } from "@/stores";
import { callCardAttack } from "@/ui/Duel/PlayMat/Card";
export default async (attack: ygopro.StocGameMessage.MsgAttack) => { export default async (attack: ygopro.StocGameMessage.MsgAttack) => {
fetchEsHintMeta({ fetchEsHintMeta({
...@@ -16,18 +16,16 @@ export default async (attack: ygopro.StocGameMessage.MsgAttack) => { ...@@ -16,18 +16,16 @@ export default async (attack: ygopro.StocGameMessage.MsgAttack) => {
if (attacker) { if (attacker) {
if (attack.direct_attack) { if (attack.direct_attack) {
await eventbus.call(Task.Attack, attacker.uuid, true); await callCardAttack(attacker.uuid, {
directAttack: true,
});
} else { } else {
await eventbus.call( await callCardAttack(attacker.uuid, {
Task.Attack, directAttack: false,
attacker.uuid, target: attack.target_location,
false, });
attack.target_location
);
} }
} else { } else {
console.warn(`<Attack>attacker from ${attack.attacker_location} is null`); console.warn(`<Attack>attacker from ${attack.attacker_location} is null`);
} }
await sleep(2000);
}; };
import { fetchCard, ygopro } from "@/api"; import { fetchCard, ygopro } from "@/api";
import { eventbus, sleep, Task } from "@/infra"; import { eventbus, Task } from "@/infra";
import { cardStore, fetchEsHintMeta, matStore } from "@/stores"; import { cardStore, fetchEsHintMeta, matStore } from "@/stores";
export default async (chaining: ygopro.StocGameMessage.MsgChaining) => { export default async (chaining: ygopro.StocGameMessage.MsgChaining) => {
...@@ -29,10 +29,6 @@ export default async (chaining: ygopro.StocGameMessage.MsgChaining) => { ...@@ -29,10 +29,6 @@ export default async (chaining: ygopro.StocGameMessage.MsgChaining) => {
// 发动效果动画 // 发动效果动画
await eventbus.call(Task.Focus, target.uuid); await eventbus.call(Task.Focus, target.uuid);
console.color("blue")(`${target.meta.text.name} chaining`); console.color("blue")(`${target.meta.text.name} chaining`);
// 临时办法,这里延迟800ms
// 长期:需要实现动画序列,一个动画完成后才执行下一个动画
await sleep(800);
} else { } else {
console.warn(`<Chaining>target from ${location} is null`); console.warn(`<Chaining>target from ${location} is null`);
} }
......
import { fetchCard, ygopro } from "@/api"; import { fetchCard, ygopro } from "@/api";
import { eventbus, sleep, Task } from "@/infra"; import { eventbus, Task } from "@/infra";
import { cardStore } from "@/stores"; import { cardStore } from "@/stores";
export default async (confirmCards: ygopro.StocGameMessage.MsgConfirmCards) => { export default async (confirmCards: ygopro.StocGameMessage.MsgConfirmCards) => {
...@@ -15,9 +15,6 @@ export default async (confirmCards: ygopro.StocGameMessage.MsgConfirmCards) => { ...@@ -15,9 +15,6 @@ export default async (confirmCards: ygopro.StocGameMessage.MsgConfirmCards) => {
target.meta = meta; target.meta = meta;
// 动画 // 动画
await eventbus.call(Task.Focus, target.uuid); await eventbus.call(Task.Focus, target.uuid);
// 临时措施,延迟一会,让动画逐个展示
// 长期:需要实现动画序列,一个动画完成后才执行下一个动画
await sleep(500);
} else { } else {
console.warn(`card of ${card} is null`); console.warn(`card of ${card} is null`);
} }
......
import { fetchCard, ygopro } from "@/api"; import { fetchCard, ygopro } from "@/api";
import { eventbus, Task } from "@/infra";
import { cardStore, fetchEsHintMeta } from "@/stores"; import { cardStore, fetchEsHintMeta } from "@/stores";
import { callCardMove } from "@/ui/Duel/PlayMat/Card";
export default async (draw: ygopro.StocGameMessage.MsgDraw) => { export default async (draw: ygopro.StocGameMessage.MsgDraw) => {
fetchEsHintMeta({ originMsg: "玩家抽卡时" }); fetchEsHintMeta({ originMsg: "玩家抽卡时" });
...@@ -27,6 +27,6 @@ export default async (draw: ygopro.StocGameMessage.MsgDraw) => { ...@@ -27,6 +27,6 @@ export default async (draw: ygopro.StocGameMessage.MsgDraw) => {
await Promise.all( await Promise.all(
cardStore cardStore
.at(ygopro.CardZone.HAND, draw.player) .at(ygopro.CardZone.HAND, draw.player)
.map((card) => eventbus.call(Task.Move, card.uuid)) .map((card) => callCardMove(card.uuid))
); );
}; };
import { ygopro } from "@/api"; import { ygopro } from "@/api";
import { sleep } from "@/infra";
import { matStore } from "@/stores"; import { matStore } from "@/stores";
import { showWaiting } from "@/ui/Duel/Message"; import { showWaiting } from "@/ui/Duel/Message";
...@@ -93,7 +92,6 @@ let animation: Promise<unknown> = new Promise<void>((rs) => rs()); ...@@ -93,7 +92,6 @@ let animation: Promise<unknown> = new Promise<void>((rs) => rs());
export default async function handleGameMsg(pb: ygopro.YgoStocMsg) { export default async function handleGameMsg(pb: ygopro.YgoStocMsg) {
animation = animation.then(() => _handleGameMsg(pb)); animation = animation.then(() => _handleGameMsg(pb));
// _handleGameMsg(pb);
} }
async function _handleGameMsg(pb: ygopro.YgoStocMsg) { async function _handleGameMsg(pb: ygopro.YgoStocMsg) {
...@@ -143,8 +141,6 @@ async function _handleGameMsg(pb: ygopro.YgoStocMsg) { ...@@ -143,8 +141,6 @@ async function _handleGameMsg(pb: ygopro.YgoStocMsg) {
} }
case "move": { case "move": {
await onMsgMove(msg.move); await onMsgMove(msg.move);
await sleep(500);
break; break;
} }
case "select_card": { case "select_card": {
...@@ -269,7 +265,6 @@ async function _handleGameMsg(pb: ygopro.YgoStocMsg) { ...@@ -269,7 +265,6 @@ async function _handleGameMsg(pb: ygopro.YgoStocMsg) {
} }
case "chaining": { case "chaining": {
await onMsgChaining(msg.chaining); await onMsgChaining(msg.chaining);
break; break;
} }
case "chain_solved": { case "chain_solved": {
...@@ -324,7 +319,6 @@ async function _handleGameMsg(pb: ygopro.YgoStocMsg) { ...@@ -324,7 +319,6 @@ async function _handleGameMsg(pb: ygopro.YgoStocMsg) {
} }
case "confirm_cards": { case "confirm_cards": {
await onConfirmCards(msg.confirm_cards); await onConfirmCards(msg.confirm_cards);
break; break;
} }
case "become_target": { case "become_target": {
......
import { fetchCard, ygopro } from "@/api"; import { fetchCard, ygopro } from "@/api";
import { eventbus, Task } from "@/infra";
import { cardStore, CardType } from "@/stores"; import { cardStore, CardType } from "@/stores";
import { callCardMove } from "@/ui/Duel/PlayMat/Card";
import { REASON_MATERIAL, TYPE_TOKEN } from "../../common"; import { REASON_MATERIAL, TYPE_TOKEN } from "../../common";
...@@ -114,7 +114,7 @@ export default async (move: MsgMove) => { ...@@ -114,7 +114,7 @@ export default async (move: MsgMove) => {
overlayMaterial.location.zone = to.zone; overlayMaterial.location.zone = to.zone;
overlayMaterial.location.sequence = to.sequence; overlayMaterial.location.sequence = to.sequence;
await eventbus.call(Task.Move, overlayMaterial.uuid); await callCardMove(overlayMaterial.uuid);
} else { } else {
console.warn( console.warn(
`<Move>overlayMaterial from zone=${location.zone}, controller=${location.controller}, sequence=${location.sequence}, overlay_sequence=${location.overlay_sequence} is null` `<Move>overlayMaterial from zone=${location.zone}, controller=${location.controller}, sequence=${location.sequence}, overlay_sequence=${location.overlay_sequence} is null`
...@@ -157,15 +157,16 @@ export default async (move: MsgMove) => { ...@@ -157,15 +157,16 @@ export default async (move: MsgMove) => {
target.location = to; target.location = to;
// 维护完了之后,开始动画 // 维护完了之后,开始动画
await eventbus.call(Task.Move, target.uuid, from.zone); const p = callCardMove(target.uuid, { fromZone: from.zone });
// 如果from或者to是手卡,那么需要刷新除了这张卡之外,这个玩家的所有手卡 // 如果from或者to是手卡,那么需要刷新除了这张卡之外,这个玩家的所有手卡
if ([from.zone, to.zone].includes(HAND)) { if ([from.zone, to.zone].includes(HAND)) {
await Promise.all( const pHands = cardStore
cardStore .at(HAND, target.location.controller)
.at(HAND, target.location.controller) .filter((c) => c.uuid !== target.uuid)
.filter((c) => c.uuid !== target.uuid) .map(async (c) => await callCardMove(c.uuid));
.map(async (c) => await eventbus.call(Task.Move, c.uuid)) await Promise.all([p, ...pHands]);
); } else {
await p;
} }
// 超量素材位置跟随超量怪兽移动 // 超量素材位置跟随超量怪兽移动
...@@ -180,7 +181,7 @@ export default async (move: MsgMove) => { ...@@ -180,7 +181,7 @@ export default async (move: MsgMove) => {
overlay.location.sequence = to.sequence; overlay.location.sequence = to.sequence;
overlay.location.position = to.position; overlay.location.position = to.position;
await eventbus.call(Task.Move, overlay.uuid); await callCardMove(overlay.uuid);
} }
} }
}; };
import { ygopro } from "@/api"; import { ygopro } from "@/api";
import MsgPosChange = ygopro.StocGameMessage.MsgPosChange; import MsgPosChange = ygopro.StocGameMessage.MsgPosChange;
import { eventbus, Task } from "@/infra";
import { cardStore, fetchEsHintMeta } from "@/stores"; import { cardStore, fetchEsHintMeta } from "@/stores";
import { callCardMove } from "@/ui/Duel/PlayMat/Card";
export default async (posChange: MsgPosChange) => { export default async (posChange: MsgPosChange) => {
const { location, controller, sequence } = posChange.card_info; const { location, controller, sequence } = posChange.card_info;
...@@ -10,7 +11,7 @@ export default async (posChange: MsgPosChange) => { ...@@ -10,7 +11,7 @@ export default async (posChange: MsgPosChange) => {
target.location.position = posChange.cur_position; target.location.position = posChange.cur_position;
// TODO: 暂时用`Move`动画,后续可以单独实现一个改变表示形式的动画 // TODO: 暂时用`Move`动画,后续可以单独实现一个改变表示形式的动画
await eventbus.call(Task.Move, target.uuid); await callCardMove(target.uuid);
} else { } else {
console.warn(`<PosChange>target from ${posChange.card_info} is null`); console.warn(`<PosChange>target from ${posChange.card_info} is null`);
} }
......
import { ygopro } from "@/api"; import { ygopro } from "@/api";
import { eventbus, Task } from "@/infra";
import { cardStore } from "@/stores"; import { cardStore } from "@/stores";
import { callCardMove } from "@/ui/Duel/PlayMat/Card";
type MsgShuffleHandExtra = ygopro.StocGameMessage.MsgShuffleHandExtra; type MsgShuffleHandExtra = ygopro.StocGameMessage.MsgShuffleHandExtra;
...@@ -14,27 +14,29 @@ export default async (shuffleHandExtra: MsgShuffleHandExtra) => { ...@@ -14,27 +14,29 @@ export default async (shuffleHandExtra: MsgShuffleHandExtra) => {
hash.get(code)?.push(sequence); hash.get(code)?.push(sequence);
}); });
for (const card of cards) { Promise.all(
const sequences = hash.get(card.code); cards.map(async (card) => {
if (sequences !== undefined) { const sequences = hash.get(card.code);
const sequence = sequences.pop(); if (sequences !== undefined) {
if (sequence !== undefined) { const sequence = sequences.pop();
card.location.sequence = sequence; if (sequence !== undefined) {
hash.set(card.code, sequences); card.location.sequence = sequence;
hash.set(card.code, sequences);
// 触发动画 // 触发动画
await eventbus.call(Task.Move, card.uuid); await callCardMove(card.uuid);
} else {
console.warn(
`<ShuffleHandExtra>sequence poped is none, controller=${controller}, code=${card.code}, sequence=${sequence}`
);
}
} else { } else {
console.warn( console.warn(
`<ShuffleHandExtra>sequence poped is none, controller=${controller}, code=${card.code}, sequence=${sequence}` `<ShuffleHandExtra>target from records is null, controller=${controller}, cards=${cards.map(
(card) => card.code
)}, codes=${codes}`
); );
} }
} else { })
console.warn( );
`<ShuffleHandExtra>target from records is null, controller=${controller}, cards=${cards.map(
(card) => card.code
)}, codes=${codes}`
);
}
}
}; };
import { ygopro } from "@/api"; import { ygopro } from "@/api";
import { eventbus, Task } from "@/infra";
import { cardStore } from "@/stores"; import { cardStore } from "@/stores";
import { callCardMove } from "@/ui/Duel/PlayMat/Card";
import MsgShuffleSetCard = ygopro.StocGameMessage.MsgShuffleSetCard; import MsgShuffleSetCard = ygopro.StocGameMessage.MsgShuffleSetCard;
// 后端传过来的`from_locations`的列表是切洗前场上卡的location,它们在列表里面按照切洗后的顺序排列 // 后端传过来的`from_locations`的列表是切洗前场上卡的location,它们在列表里面按照切洗后的顺序排列
...@@ -18,33 +18,36 @@ export default async (shuffleSetCard: MsgShuffleSetCard) => { ...@@ -18,33 +18,36 @@ export default async (shuffleSetCard: MsgShuffleSetCard) => {
} }
const count = from_locations.length; const count = from_locations.length;
for (let i = 0; i < count; i++) {
const from = from_locations[i];
const target = cardStore.at(from.zone, from.controller, from.sequence);
if (target) {
// 设置code为0,洗切后的code会由`UpdateData`指定
target.code = 0;
target.meta.id = 0;
target.meta.text.id = 0;
} else {
console.warn(`<ShuffleSetCard>target from ${from} is null`);
}
// 处理超量 Promise.all(
const overlay_location = overlay_locations[i]; Array.from({ length: count }).map(async (_, i) => {
if (overlay_location.zone > 0) { const from = from_locations[i];
// 如果没有超量素材,后端会全传0 const target = cardStore.at(from.zone, from.controller, from.sequence);
for (const overlay of cardStore.findOverlay( if (target) {
from.zone, // 设置code为0,洗切后的code会由`UpdateData`指定
from.controller, target.code = 0;
from.sequence target.meta.id = 0;
)) { target.meta.text.id = 0;
// 更新sequence } else {
overlay.location.sequence = overlay_location.sequence; console.warn(`<ShuffleSetCard>target from ${from} is null`);
// 渲染动画
await eventbus.call(Task.Move, overlay.uuid);
// 这里其实有个疑惑,如果超量素材也跟着洗切的话,洗切的意义好像就没有了,感觉算是个k社没想好的设计?
} }
}
} // 处理超量
const overlay_location = overlay_locations[i];
if (overlay_location.zone > 0) {
// 如果没有超量素材,后端会全传0
for (const overlay of cardStore.findOverlay(
from.zone,
from.controller,
from.sequence
)) {
// 更新sequence
overlay.location.sequence = overlay_location.sequence;
// 渲染动画
await callCardMove(overlay.uuid);
// 这里其实有个疑惑,如果超量素材也跟着洗切的话,洗切的意义好像就没有了,感觉算是个k社没想好的设计?
}
}
})
);
}; };
import { ygopro } from "@/api"; import { ygopro } from "@/api";
import { eventbus, Task } from "@/infra";
import { cardStore } from "@/stores"; import { cardStore } from "@/stores";
import { callCardMove } from "@/ui/Duel/PlayMat/Card";
import MsgSwapGraveDeck = ygopro.StocGameMessage.MsgSwapGraveDeck; import MsgSwapGraveDeck = ygopro.StocGameMessage.MsgSwapGraveDeck;
const { DECK, GRAVE } = ygopro.CardZone; const { DECK, GRAVE } = ygopro.CardZone;
...@@ -12,11 +12,11 @@ export default async (swapGraveDeck: MsgSwapGraveDeck) => { ...@@ -12,11 +12,11 @@ export default async (swapGraveDeck: MsgSwapGraveDeck) => {
for (const card of deck) { for (const card of deck) {
card.location.zone = GRAVE; card.location.zone = GRAVE;
await eventbus.call(Task.Move, card.uuid); await callCardMove(card.uuid);
} }
for (const card of grave) { for (const card of grave) {
card.location.zone = DECK; card.location.zone = DECK;
await eventbus.call(Task.Move, card.uuid); await callCardMove(card.uuid);
} }
}; };
import { fetchCard, ygopro } from "@/api"; import { fetchCard, ygopro } from "@/api";
import MsgUpdateData = ygopro.StocGameMessage.MsgUpdateData;
import { eventbus, Task } from "@/infra";
import { cardStore } from "@/stores"; import { cardStore } from "@/stores";
import { callCardMove } from "@/ui/Duel/PlayMat/Card";
import MsgUpdateData = ygopro.StocGameMessage.MsgUpdateData;
export default async (updateData: MsgUpdateData) => { export default async (updateData: MsgUpdateData) => {
const { player: controller, zone, actions } = updateData; const { player: controller, zone, actions } = updateData;
if (controller !== undefined && zone !== undefined && actions !== undefined) { if (controller !== undefined && zone !== undefined && actions !== undefined) {
...@@ -28,7 +27,7 @@ export default async (updateData: MsgUpdateData) => { ...@@ -28,7 +27,7 @@ export default async (updateData: MsgUpdateData) => {
// Currently only update position // Currently only update position
target.location.position = action.location.position; target.location.position = action.location.position;
// animation // animation
await eventbus.call(Task.Move, target.uuid); await callCardMove(target.uuid);
} }
} }
if (action?.type_ >= 0) { if (action?.type_ >= 0) {
......
...@@ -34,24 +34,11 @@ const defaultProps: Omit< ...@@ -34,24 +34,11 @@ const defaultProps: Omit<
const localStore = proxy(defaultProps); const localStore = proxy(defaultProps);
export const SelectActionsModal: React.FC = () => { export const SelectActionsModal: React.FC = () => {
const { const snap = useSnapshot(localStore);
isOpen,
isChain,
min,
max,
single,
selecteds,
selectables,
mustSelects,
cancelable,
finishable,
totalLevels,
overflow,
} = useSnapshot(localStore);
const onSubmit = (options: Snapshot<Option[]>) => { const onSubmit = (options: Snapshot<Option[]>) => {
const values = options.map((option) => option.response!); const values = options.map((option) => option.response!);
if (isChain) { if (localStore.isChain) {
sendSelectSingleResponse(values[0]); sendSelectSingleResponse(values[0]);
} else { } else {
sendSelectMultiResponse(values); sendSelectMultiResponse(values);
...@@ -72,17 +59,7 @@ export const SelectActionsModal: React.FC = () => { ...@@ -72,17 +59,7 @@ export const SelectActionsModal: React.FC = () => {
return ( return (
<SelectCardsModal <SelectCardsModal
{...{ {...{
isOpen, ...snap,
min,
max,
single,
selecteds,
selectables,
mustSelects,
cancelable,
finishable,
totalLevels,
overflow,
onSubmit, onSubmit,
onFinish, onFinish,
onCancel, onCancel,
......
...@@ -67,6 +67,54 @@ section#mat { ...@@ -67,6 +67,54 @@ section#mat {
} }
} }
} }
// 下面应该和moveToOutside、moveToGround对应
.bg-other-blocks {
&.op {
transform: rotate(180deg);
}
position: absolute;
--height: var(--card-height-o);
--width: calc(var(--height) * var(--card-ratio));
--left: calc(
var(--col-gap) * 2 + var(--block-width) * 2.5 +
var(--block-outside-offset-x) + var(--width) / 2
);
--top: calc(
var(--row-gap) + var(--block-height-m) +
(var(--block-height-m) - var(--height)) / 2
);
.block {
position: absolute;
transform: translate(-50%, -50%);
height: var(--height);
width: var(--width);
top: var(--top);
left: var(--left);
}
.field {
left: calc(-1 * var(--left));
}
.banish {
top: calc(var(--top) - var(--row-gap) - var(--height));
}
.deck {
--left: calc(
var(--deck-offset-x) + 2 * (var(--block-width) + var(--col-gap))
);
left: var(--left);
top: calc(
var(--deck-offset-y) + 2 * var(--block-height-m) + 2 * var(--row-gap)
);
transform: translate(-50%, -50%) rotate(calc(-1 * var(--deck-rotate-z)));
height: var(--deck-card-height);
width: calc(var(--deck-card-height) * var(--card-ratio));
&.extra-deck {
left: calc(-1 * var(--left));
transform: translate(-50%, -50%) rotate(var(--deck-rotate-z));
}
}
}
} }
// 被禁用的样式 // 被禁用的样式
...@@ -92,8 +140,19 @@ section#mat { ...@@ -92,8 +140,19 @@ section#mat {
); );
display: none; display: none;
} }
.block.disabled {
.disabled-cross { .disabled-cross.show {
display: block; display: block;
}
section#mat {
.block.glowing {
--card-shadow-color: #13a1ff;
box-shadow: 0 0 3px 3px var(--card-shadow-color), 0 0 25px 2px #0099ff87;
background: var(--card-shadow-color);
border-radius: 2px;
.triangle {
display: none;
}
} }
} }
...@@ -5,12 +5,38 @@ import { type INTERNAL_Snapshot as Snapshot, useSnapshot } from "valtio"; ...@@ -5,12 +5,38 @@ import { type INTERNAL_Snapshot as Snapshot, useSnapshot } from "valtio";
import { sendSelectPlaceResponse, ygopro } from "@/api"; import { sendSelectPlaceResponse, ygopro } from "@/api";
import { import {
BlockState, type BlockState,
cardStore, cardStore,
isMe,
type PlaceInteractivity, type PlaceInteractivity,
placeStore, placeStore,
} from "@/stores"; } from "@/stores";
const BgBlock: React.FC<
React.HTMLProps<HTMLDivElement> & {
disabled?: boolean;
highlight?: boolean;
glowing?: boolean;
}
> = ({
disabled = false,
highlight = false,
glowing = false,
className,
...rest
}) => (
<div
{...rest}
className={classnames("block", className, {
highlight,
glowing,
})}
>
{<DecoTriangles />}
{<DisabledCross disabled={disabled} />}
</div>
);
const BgExtraRow: React.FC<{ const BgExtraRow: React.FC<{
meSnap: Snapshot<BlockState[]>; meSnap: Snapshot<BlockState[]>;
opSnap: Snapshot<BlockState[]>; opSnap: Snapshot<BlockState[]>;
...@@ -18,60 +44,74 @@ const BgExtraRow: React.FC<{ ...@@ -18,60 +44,74 @@ const BgExtraRow: React.FC<{
return ( return (
<div className={classnames("bg-row")}> <div className={classnames("bg-row")}>
{Array.from({ length: 2 }).map((_, i) => ( {Array.from({ length: 2 }).map((_, i) => (
<div <BgBlock
key={i} key={i}
className={classnames("block", "extra", { className="extra"
highlight: !!meSnap[i].interactivity || !!opSnap[i].interactivity,
disabled: meSnap[i].disabled || opSnap[i].disabled,
})}
onClick={() => { onClick={() => {
onBlockClick(meSnap[i].interactivity); onBlockClick(meSnap[i].interactivity);
onBlockClick(opSnap[i].interactivity); onBlockClick(opSnap[i].interactivity);
}} }}
> disabled={meSnap[i].disabled || opSnap[i].disabled}
{<DecoTriangles />} highlight={!!meSnap[i].interactivity || !!opSnap[i].interactivity}
{<DisabledCross />} />
</div>
))} ))}
</div> </div>
); );
}; };
const BgRow: React.FC<{ const BgRow: React.FC<{
isSzone?: boolean; szone?: boolean;
opponent?: boolean; opponent?: boolean;
snap: Snapshot<BlockState[]>; snap: Snapshot<BlockState[]>;
}> = ({ isSzone = false, opponent = false, snap }) => ( }> = ({ szone = false, opponent = false, snap }) => (
<div className={classnames("bg-row", { opponent })}> <div className={classnames("bg-row", { opponent })}>
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
<div <BgBlock
key={i} key={i}
className={classnames("block", { className={classnames({ szone })}
szone: isSzone,
highlight: !!snap[i].interactivity,
disabled: snap[i].disabled,
})}
onClick={() => onBlockClick(snap[i].interactivity)} onClick={() => onBlockClick(snap[i].interactivity)}
> disabled={snap[i].disabled}
{<DecoTriangles />} highlight={!!snap[i].interactivity}
{<DisabledCross />} />
</div>
))} ))}
</div> </div>
); );
const BgOtherBlocks: React.FC<{ me?: boolean }> = ({ me }) => {
useSnapshot(cardStore);
const meController = isMe(0) ? 0 : 1;
const judgeGlowing = (zone: ygopro.CardZone) =>
!!cardStore
.at(zone, meController)
.reduce((sum, c) => (sum += c.idleInteractivities.length), 0);
const glowingExtra = judgeGlowing(ygopro.CardZone.EXTRA);
const glowingGraveyard = judgeGlowing(ygopro.CardZone.GRAVE);
const glowingBanish = judgeGlowing(ygopro.CardZone.REMOVED);
return (
<div className={classnames("bg-other-blocks", { me, op: !me })}>
<BgBlock className="banish" glowing={me && glowingBanish} />
<BgBlock className="graveyard" glowing={me && glowingGraveyard} />
<BgBlock className="field" />
<BgBlock className="deck" />
<BgBlock className="deck extra-deck" glowing={me && glowingExtra} />
</div>
);
};
export const Bg: React.FC = () => { export const Bg: React.FC = () => {
const snap = useSnapshot(placeStore.inner); const snap = useSnapshot(placeStore.inner);
return ( return (
<div className="mat-bg"> <div className="mat-bg">
<BgRow snap={snap[ygopro.CardZone.SZONE].op} isSzone opponent /> <BgRow snap={snap[ygopro.CardZone.SZONE].op} szone opponent />
<BgRow snap={snap[ygopro.CardZone.MZONE].op} opponent /> <BgRow snap={snap[ygopro.CardZone.MZONE].op} opponent />
<BgExtraRow <BgExtraRow
meSnap={snap[ygopro.CardZone.MZONE].me.slice(5, 7)} meSnap={snap[ygopro.CardZone.MZONE].me.slice(5, 7)}
opSnap={snap[ygopro.CardZone.MZONE].op.slice(5, 7)} opSnap={snap[ygopro.CardZone.MZONE].op.slice(5, 7)}
/> />
<BgRow snap={snap[ygopro.CardZone.MZONE].me} /> <BgRow snap={snap[ygopro.CardZone.MZONE].me} />
<BgRow snap={snap[ygopro.CardZone.SZONE].me} isSzone /> <BgRow snap={snap[ygopro.CardZone.SZONE].me} szone />
<BgOtherBlocks me />
<BgOtherBlocks />
</div> </div>
); );
}; };
...@@ -92,4 +132,6 @@ const DecoTriangles: React.FC = () => ( ...@@ -92,4 +132,6 @@ const DecoTriangles: React.FC = () => (
</> </>
); );
const DisabledCross: React.FC = () => <div className="disabled-cross"></div>; const DisabledCross: React.FC<{ disabled: boolean }> = ({ disabled }) => (
<div className={classnames("disabled-cross", { show: disabled })}></div>
);
...@@ -106,7 +106,7 @@ section#mat { ...@@ -106,7 +106,7 @@ section#mat {
} }
} }
.mat-card.highlight .card-shadow { .mat-card.glowing .card-shadow {
--card-shadow-color: #0099ff; --card-shadow-color: #0099ff;
display: block !important; display: block !important;
background: var(--card-shadow-color) !important; background: var(--card-shadow-color) !important;
...@@ -133,3 +133,7 @@ section#mat { ...@@ -133,3 +133,7 @@ section#mat {
} }
text-align: center; text-align: center;
} }
.card-dropdown-disabled {
display: none;
}
...@@ -26,23 +26,20 @@ import { ...@@ -26,23 +26,20 @@ import {
import { interactTypeToString } from "../../utils"; import { interactTypeToString } from "../../utils";
import { import {
attack, attack,
type AttackOptions,
focus, focus,
moveToDeck, move,
moveToGround, type MoveOptions,
moveToHand,
moveToOutside,
moveToToken,
} from "./springs"; } from "./springs";
import type { SpringApiProps } from "./springs/types"; import type { SpringApiProps } from "./springs/types";
const { HAND, GRAVE, REMOVED, DECK, EXTRA, MZONE, SZONE, TZONE } = const { HAND, GRAVE, REMOVED, EXTRA, MZONE, SZONE, TZONE } = ygopro.CardZone;
ygopro.CardZone;
export const Card: React.FC<{ idx: number }> = React.memo(({ idx }) => { export const Card: React.FC<{ idx: number }> = React.memo(({ idx }) => {
const state = cardStore.inner[idx]; const card = cardStore.inner[idx];
const snap = useSnapshot(state); const snap = useSnapshot(card);
const [styles, api] = useSpring( const [styles, api] = useSpring<SpringApiProps>(
() => () =>
({ ({
x: 0, x: 0,
...@@ -57,42 +54,17 @@ export const Card: React.FC<{ idx: number }> = React.memo(({ idx }) => { ...@@ -57,42 +54,17 @@ export const Card: React.FC<{ idx: number }> = React.memo(({ idx }) => {
focusDisplay: "none", focusDisplay: "none",
focusOpacity: 1, focusOpacity: 1,
subZ: 0, subZ: 0,
opacity: 1,
} satisfies SpringApiProps) } satisfies SpringApiProps)
); );
// FIXME: move不应该只根据目的地判断,还要根据先前的位置判断。例子是Token。
const move = async (toZone: ygopro.CardZone, fromZone?: ygopro.CardZone) => {
switch (toZone) {
case MZONE:
case SZONE:
await moveToGround({ card: state, api, fromZone });
break;
case HAND:
await moveToHand({ card: state, api, fromZone });
break;
case DECK:
case EXTRA:
await moveToDeck({ card: state, api, fromZone });
break;
case GRAVE:
case REMOVED:
await moveToOutside({ card: state, api, fromZone });
break;
case TZONE:
// FIXME: 这里应该实现一个衍生物消散的动画,现在暂时让它在动画在展示上回到卡组
await moveToToken({ card: state, api, fromZone });
break;
}
};
// 每张卡都需要移动到初始位置 // 每张卡都需要移动到初始位置
useEffect(() => { useEffect(() => {
move(state.location.zone); addToAnimation(() => move({ card, api }));
}, []); }, []);
const [highlight, setHighlight] = useState(false); const [glowing, setGrowing] = useState(false);
const [classFocus, setClassFocus] = useState(false); const [classFocus, setClassFocus] = useState(false);
// const [shadowOpacity, setShadowOpacity] = useState(0); // TODO: 透明度
// >>> 动画 >>> // >>> 动画 >>>
/** 动画序列的promise */ /** 动画序列的promise */
...@@ -103,40 +75,32 @@ export const Card: React.FC<{ idx: number }> = React.memo(({ idx }) => { ...@@ -103,40 +75,32 @@ export const Card: React.FC<{ idx: number }> = React.memo(({ idx }) => {
animationQueue.current = animationQueue.current.then(p).then(rs); animationQueue.current = animationQueue.current.then(p).then(rs);
}); });
const register = <T extends any[]>(
task: Task,
fn: (...args: T) => Promise<unknown>
) => {
eventbus.register(task, async (uuid, ...rest: T) => {
if (uuid === card.uuid) {
await fn(...rest);
return true;
} else return false;
});
};
useEffect(() => { useEffect(() => {
eventbus.register( register(Task.Move, async (options?: MoveOptions) => {
Task.Move, await addToAnimation(() => move({ card, api, options }));
async (uuid: string, fromZone?: ygopro.CardZone) => { });
if (uuid === state.uuid) {
await addToAnimation(() => move(state.location.zone, fromZone));
}
}
);
eventbus.register(Task.Focus, async (uuid: string) => { register(Task.Focus, async () => {
if (uuid === state.uuid) { setClassFocus(true);
await addToAnimation(async () => { setTimeout(() => setClassFocus(false), 1000); // TODO: 这儿为啥要这么写呢
setClassFocus(true); await focus({ card, api });
setTimeout(() => setClassFocus(false), 1000);
await focus({ card: state, api });
});
}
}); });
eventbus.register( register(Task.Attack, async (options: AttackOptions) => {
Task.Attack, await addToAnimation(() => attack({ card, api, options }));
async ( });
uuid: string,
directAttack: boolean,
target?: ygopro.CardLocation
) => {
if (uuid === state.uuid) {
await addToAnimation(() =>
attack({ card: state, api, target, directAttack })
);
}
}
);
}, []); }, []);
// <<< 动画 <<< // <<< 动画 <<<
...@@ -144,13 +108,19 @@ export const Card: React.FC<{ idx: number }> = React.memo(({ idx }) => { ...@@ -144,13 +108,19 @@ export const Card: React.FC<{ idx: number }> = React.memo(({ idx }) => {
// >>> 效果 >>> // >>> 效果 >>>
const idleInteractivities = snap.idleInteractivities; const idleInteractivities = snap.idleInteractivities;
useEffect(() => { useEffect(() => {
setHighlight(!!idleInteractivities.length); setGrowing(
!!idleInteractivities.length &&
[MZONE, SZONE, HAND, TZONE].includes(card.location.zone)
);
}, [idleInteractivities]); }, [idleInteractivities]);
const [dropdownMenu, setDropdownMenu] = useState({ const [dropdownMenu, setDropdownMenu] = useState({
items: [] as DropdownItem[], items: [] as DropdownItem[],
}); });
// 是否禁用下拉菜单
const [dropdownMenuDisabled, setDropdownMenuDisabled] = useState(false);
// 发动效果 // 发动效果
// 1. 下拉菜单里面选择[召唤 / 特殊召唤 /.../效果发动] // 1. 下拉菜单里面选择[召唤 / 特殊召唤 /.../效果发动]
// 2. 如果是非效果发动,那么直接选择哪张卡(单张卡直接选择那张) // 2. 如果是非效果发动,那么直接选择哪张卡(单张卡直接选择那张)
...@@ -165,6 +135,13 @@ export const Card: React.FC<{ idx: number }> = React.memo(({ idx }) => { ...@@ -165,6 +135,13 @@ export const Card: React.FC<{ idx: number }> = React.memo(({ idx }) => {
map.get(interactType)?.push(card); map.get(interactType)?.push(card);
}); });
}); });
if (!map.size) {
setDropdownMenuDisabled(true);
return;
} else {
setDropdownMenuDisabled(false);
}
const actions = [...map.entries()]; const actions = [...map.entries()];
const nonEffectActions = actions.filter( const nonEffectActions = actions.filter(
([action]) => action !== InteractType.ACTIVATE ([action]) => action !== InteractType.ACTIVATE
...@@ -259,7 +236,7 @@ export const Card: React.FC<{ idx: number }> = React.memo(({ idx }) => { ...@@ -259,7 +236,7 @@ export const Card: React.FC<{ idx: number }> = React.memo(({ idx }) => {
// 中央弹窗展示选中卡牌信息 // 中央弹窗展示选中卡牌信息
// TODO: 同一张卡片,是否重复点击会关闭CardModal? // TODO: 同一张卡片,是否重复点击会关闭CardModal?
displayCardModal(card); displayCardModal(card);
if (card.idleInteractivities.length) handleDropdownMenu([card], false); handleDropdownMenu([card], false);
// 侧边栏展示超量素材信息 // 侧边栏展示超量素材信息
const overlayMaterials = cardStore.findOverlay( const overlayMaterials = cardStore.findOverlay(
...@@ -286,17 +263,17 @@ export const Card: React.FC<{ idx: number }> = React.memo(({ idx }) => { ...@@ -286,17 +263,17 @@ export const Card: React.FC<{ idx: number }> = React.memo(({ idx }) => {
handleDropdownMenu(cards, true); handleDropdownMenu(cards, true);
}; };
if ([MZONE, SZONE, HAND].includes(state.location.zone)) { if ([MZONE, SZONE, HAND].includes(card.location.zone)) {
onCardClick(state); onCardClick(card);
} else if ([EXTRA, GRAVE, REMOVED].includes(state.location.zone)) { } else if ([EXTRA, GRAVE, REMOVED].includes(card.location.zone)) {
onFieldClick(state); onFieldClick(card);
} }
}; };
// <<< 效果 <<< // <<< 效果 <<<
return ( return (
<animated.div <animated.div
className={classnames("mat-card", { highlight })} className={classnames("mat-card", { glowing })}
style={ style={
{ {
transform: to( transform: to(
...@@ -312,6 +289,7 @@ export const Card: React.FC<{ idx: number }> = React.memo(({ idx }) => { ...@@ -312,6 +289,7 @@ export const Card: React.FC<{ idx: number }> = React.memo(({ idx }) => {
"--focus-scale": styles.focusScale, "--focus-scale": styles.focusScale,
"--focus-display": styles.focusDisplay, "--focus-display": styles.focusDisplay,
"--focus-opacity": styles.focusOpacity, "--focus-opacity": styles.focusOpacity,
opacity: styles.opacity,
} as any as CSSProperties } as any as CSSProperties
} }
onClick={onClick} onClick={onClick}
...@@ -321,10 +299,11 @@ export const Card: React.FC<{ idx: number }> = React.memo(({ idx }) => { ...@@ -321,10 +299,11 @@ export const Card: React.FC<{ idx: number }> = React.memo(({ idx }) => {
<Dropdown <Dropdown
menu={dropdownMenu} menu={dropdownMenu}
placement="top" placement="top"
overlayClassName="card-dropdown" overlayClassName={classnames("card-dropdown", {
"card-dropdown-disabled": dropdownMenuDisabled,
})}
arrow arrow
trigger={["click"]} trigger={["click"]}
// disabled={!highlight} // TODO: 这里的disable要考虑到field的情况,比如额外卡组
> >
<div className={classnames("card-img-wrap", { focus: classFocus })}> <div className={classnames("card-img-wrap", { focus: classFocus })}>
<YgoCard <YgoCard
...@@ -375,3 +354,12 @@ const handleEffectActivation = ( ...@@ -375,3 +354,12 @@ const handleEffectActivation = (
}; };
// <<< 下拉菜单 <<< // <<< 下拉菜单 <<<
const call =
<Options,>(task: Task) =>
(uuid: string, options?: Options extends {} ? Options : never) =>
eventbus.call(task, uuid, options);
export const callCardMove = call<MoveOptions>(Task.Move);
export const callCardFocus = call(Task.Focus);
export const callCardAttack = call<AttackOptions>(Task.Attack);
// 暂时先简单实现攻击动画,后面有时间再慢慢优化 // 暂时先简单实现攻击动画,后面有时间再慢慢优化
import { easings } from "@react-spring/web"; import { easings } from "@react-spring/web";
import { ygopro } from "@/api"; import { isMe } from "@/stores";
import { CardType, isMe } from "@/stores"; import { matConfig } from "@/ui/Shared";
import { matConfig } from "../../utils"; import type { AttackFunc } from "./types";
import type { SpringApi } from "./types";
import { asyncStart } from "./utils"; import { asyncStart } from "./utils";
const { BLOCK_WIDTH, BLOCK_HEIGHT_M, BLOCK_HEIGHT_S, COL_GAP, ROW_GAP } = const { BLOCK_WIDTH, BLOCK_HEIGHT_M, BLOCK_HEIGHT_S, COL_GAP, ROW_GAP } =
matConfig; matConfig;
export const attack = async (props: { export const attack: AttackFunc = async (props) => {
card: CardType; const { card, api, options } = props;
api: SpringApi;
directAttack: boolean;
target?: ygopro.CardLocation;
}) => {
const { card, api, directAttack, target } = props;
const current = api.current[0].get(); const current = api.current[0].get();
let x = current.x; let x = current.x;
let y = current.y; let y = current.y;
let rz = current.rz; let rz = current.rz;
if (directAttack) { if (options?.directAttack) {
// 直接攻击 // 直接攻击
y = BLOCK_HEIGHT_M.value + BLOCK_HEIGHT_S.value; y = BLOCK_HEIGHT_M + BLOCK_HEIGHT_S;
if (isMe(card.location.controller)) { if (isMe(card.location.controller)) {
y = -y; y = -y;
} }
} else if (target) { } else if (options?.target) {
// 攻击`target` // 攻击`target`
const { controller, sequence } = target; const { controller, sequence } = options.target;
if (sequence > 4) { if (sequence > 4) {
// 额外怪兽区 // 额外怪兽区
x = (sequence > 5 ? 1 : -1) * (BLOCK_WIDTH.value + COL_GAP.value); x = (sequence > 5 ? 1 : -1) * (BLOCK_WIDTH + COL_GAP);
y = 0; y = 0;
} else { } else {
x = (sequence - 2) * (BLOCK_WIDTH.value + COL_GAP.value); x = (sequence - 2) * (BLOCK_WIDTH + COL_GAP);
y = BLOCK_HEIGHT_M.value + ROW_GAP.value; y = BLOCK_HEIGHT_M + ROW_GAP;
} }
if (!isMe(controller)) { if (!isMe(controller)) {
...@@ -61,7 +55,7 @@ export const attack = async (props: { ...@@ -61,7 +55,7 @@ export const attack = async (props: {
await asyncStart(api)({ await asyncStart(api)({
y: y:
current.y + current.y +
(BLOCK_HEIGHT_M.value / 2) * (isMe(card.location.controller) ? 1 : -1), (BLOCK_HEIGHT_M / 2) * (isMe(card.location.controller) ? 1 : -1),
rz, rz,
}); });
// 加速前冲 // 加速前冲
......
...@@ -11,19 +11,14 @@ export const focus = async (props: { card: CardType; api: SpringApi }) => { ...@@ -11,19 +11,14 @@ export const focus = async (props: { card: CardType; api: SpringApi }) => {
card.location.zone == ygopro.CardZone.HAND || card.location.zone == ygopro.CardZone.HAND ||
card.location.zone == ygopro.CardZone.DECK card.location.zone == ygopro.CardZone.DECK
) { ) {
const current = api.current[0].get(); const current = { ...api.current[0].get() };
await asyncStart(api)({ await asyncStart(api)({
y: current.y + (matStore.isMe(card.location.controller) ? -1 : 1) * 120, // TODO: 放到config之中 y: current.y + (matStore.isMe(card.location.controller) ? -1 : 1) * 120, // TODO: 放到config之中
ry: 0, ry: 0,
rz: 0, // rz: 0,
z: current.z + 50, z: current.z + 50,
}); });
await asyncStart(api)({ await asyncStart(api)(current);
y: current.y,
ry: current.ry,
rz: current.rz,
z: current.z,
});
} else { } else {
await asyncStart(api)({ await asyncStart(api)({
focusScale: 1.5, focusScale: 1.5,
......
export * from "./attack"; export * from "./attack";
export * from "./focus"; export * from "./focus";
export * from "./moveToDeck"; export * from "./move";
export * from "./moveToGround"; export * from "./types";
export * from "./moveToHand"; export * from "./utils";
export * from "./moveToOutside";
export * from "./moveToToken";
import { ygopro } from "@/api";
import { moveToDeck } from "./moveToDeck";
import { moveToGround } from "./moveToGround";
import { moveToHand } from "./moveToHand";
import { moveToOutside } from "./moveToOutside";
import { moveToToken } from "./moveToToken";
import type { MoveFunc } from "./types";
const { HAND, GRAVE, REMOVED, DECK, EXTRA, MZONE, SZONE, TZONE } =
ygopro.CardZone;
export const move: MoveFunc = async (props) => {
const { card } = props;
switch (card.location.zone) {
case MZONE:
case SZONE:
await moveToGround(props);
break;
case HAND:
await moveToHand(props);
break;
case DECK:
case EXTRA:
await moveToDeck(props);
break;
case GRAVE:
case REMOVED:
await moveToOutside(props);
break;
case TZONE:
await moveToToken(props);
break;
}
};
import { ygopro } from "@/api"; import { ygopro } from "@/api";
import { isMe } from "@/stores"; import { isMe } from "@/stores";
import { matConfig } from "@/ui/Shared";
import { matConfig } from "../../utils"; import type { MoveFunc } from "./types";
import { asyncStart, type MoveFunc } from "./utils"; import { asyncStart } from "./utils";
const { const {
BLOCK_WIDTH, BLOCK_WIDTH,
BLOCK_HEIGHT_M, BLOCK_HEIGHT_M,
BLOCK_HEIGHT_S,
COL_GAP, COL_GAP,
ROW_GAP, ROW_GAP,
DECK_OFFSET_X, DECK_OFFSET_X,
...@@ -24,21 +24,16 @@ export const moveToDeck: MoveFunc = async (props) => { ...@@ -24,21 +24,16 @@ export const moveToDeck: MoveFunc = async (props) => {
const { location } = card; const { location } = card;
const { controller, zone, sequence } = location; const { controller, zone, sequence } = location;
const rightX = DECK_OFFSET_X.value + 2 * (BLOCK_WIDTH.value + COL_GAP.value); const rightX = DECK_OFFSET_X + 2 * (BLOCK_WIDTH + COL_GAP);
const leftX = -rightX; const leftX = -rightX;
const bottomY = const bottomY = DECK_OFFSET_Y + 2 * BLOCK_HEIGHT_M + 2 * ROW_GAP;
DECK_OFFSET_Y.value +
2 * BLOCK_HEIGHT_M.value +
BLOCK_HEIGHT_S.value +
2 * ROW_GAP.value -
BLOCK_HEIGHT_S.value;
const topY = -bottomY; const topY = -bottomY;
let x = isMe(controller) ? rightX : leftX; let x = isMe(controller) ? rightX : leftX;
let y = isMe(controller) ? bottomY : topY; let y = isMe(controller) ? bottomY : topY;
if (zone === EXTRA) { if (zone === EXTRA) {
x = isMe(controller) ? leftX : rightX; x = isMe(controller) ? leftX : rightX;
} }
let rz = zone === EXTRA ? DECK_ROTATE_Z.value : -DECK_ROTATE_Z.value; let rz = zone === EXTRA ? DECK_ROTATE_Z : -DECK_ROTATE_Z;
rz += isMe(controller) ? 0 : 180; rz += isMe(controller) ? 0 : 180;
const z = sequence; const z = sequence;
...@@ -49,6 +44,6 @@ export const moveToDeck: MoveFunc = async (props) => { ...@@ -49,6 +44,6 @@ export const moveToDeck: MoveFunc = async (props) => {
rz, rz,
ry: isMe(controller) ? (zone === DECK ? 180 : 0) : 180, ry: isMe(controller) ? (zone === DECK ? 180 : 0) : 180,
zIndex: z, zIndex: z,
height: DECK_CARD_HEIGHT.value, height: DECK_CARD_HEIGHT,
}); });
}; };
...@@ -2,9 +2,10 @@ import { easings } from "@react-spring/web"; ...@@ -2,9 +2,10 @@ import { easings } from "@react-spring/web";
import { ygopro } from "@/api"; import { ygopro } from "@/api";
import { isMe } from "@/stores"; import { isMe } from "@/stores";
import { matConfig } from "@/ui/Shared";
import { matConfig } from "../../utils"; import type { MoveFunc } from "./types";
import { asyncStart, type MoveFunc } from "./utils"; import { asyncStart } from "./utils";
const { const {
BLOCK_WIDTH, BLOCK_WIDTH,
...@@ -13,24 +14,20 @@ const { ...@@ -13,24 +14,20 @@ const {
CARD_RATIO, CARD_RATIO,
COL_GAP, COL_GAP,
ROW_GAP, ROW_GAP,
BLOCK_OUTSIDE_OFFSET_X,
CARD_HEIGHT_O,
} = matConfig; } = matConfig;
const { MZONE, SZONE, TZONE } = ygopro.CardZone; const { MZONE, SZONE, TZONE } = ygopro.CardZone;
export const moveToGround: MoveFunc = async (props) => { export const moveToGround: MoveFunc = async (props) => {
const { card, api, fromZone } = props; const { card, api, options } = props;
const { location } = card; const { location } = card;
const { controller, zone, sequence, position, is_overlay } = location; const { controller, zone, sequence, position, is_overlay } = location;
// 根据zone计算卡片的宽度 let height = zone === SZONE ? BLOCK_HEIGHT_S : BLOCK_HEIGHT_M;
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 // 首先计算 x 和 y
let x = 0, let x = 0,
...@@ -38,28 +35,31 @@ export const moveToGround: MoveFunc = async (props) => { ...@@ -38,28 +35,31 @@ export const moveToGround: MoveFunc = async (props) => {
switch (zone) { switch (zone) {
case SZONE: { case SZONE: {
if (sequence === 5) { if (sequence === 5) {
height = CARD_HEIGHT_O;
// 场地魔法 // 场地魔法
x = -( x = -(
3 * (BLOCK_WIDTH.value + COL_GAP.value) - BLOCK_WIDTH * 2.5 +
(BLOCK_WIDTH.value - cardWidth) / 2 COL_GAP * 2 +
BLOCK_OUTSIDE_OFFSET_X +
CARD_HEIGHT_O * CARD_RATIO * 0.5
); );
y = BLOCK_HEIGHT_M.value + ROW_GAP.value; y = ROW_GAP + BLOCK_HEIGHT_M + (BLOCK_HEIGHT_M - CARD_HEIGHT_O) / 2;
} else { } else {
x = (sequence - 2) * (BLOCK_WIDTH.value + COL_GAP.value); x = (sequence - 2) * (BLOCK_WIDTH + COL_GAP);
y = y =
2 * (BLOCK_HEIGHT_M.value + ROW_GAP.value) - 2 * (BLOCK_HEIGHT_M + ROW_GAP) -
(BLOCK_HEIGHT_M.value - BLOCK_HEIGHT_S.value) / 2; (BLOCK_HEIGHT_M - BLOCK_HEIGHT_S) / 2;
} }
break; break;
} }
case MZONE: { case MZONE: {
if (sequence > 4) { if (sequence > 4) {
// 额外怪兽区 // 额外怪兽区
x = (sequence > 5 ? 1 : -1) * (BLOCK_WIDTH.value + COL_GAP.value); x = (sequence > 5 ? 1 : -1) * (BLOCK_WIDTH + COL_GAP);
y = 0; y = 0;
} else { } else {
x = (sequence - 2) * (BLOCK_WIDTH.value + COL_GAP.value); x = (sequence - 2) * (BLOCK_WIDTH + COL_GAP);
y = BLOCK_HEIGHT_M.value + ROW_GAP.value; y = BLOCK_HEIGHT_M + ROW_GAP;
} }
break; break;
} }
...@@ -76,9 +76,8 @@ export const moveToGround: MoveFunc = async (props) => { ...@@ -76,9 +76,8 @@ export const moveToGround: MoveFunc = async (props) => {
ygopro.CardPosition.FACEDOWN_DEFENSE, ygopro.CardPosition.FACEDOWN_DEFENSE,
ygopro.CardPosition.FACEUP_DEFENSE, ygopro.CardPosition.FACEUP_DEFENSE,
].includes(position ?? 5); ].includes(position ?? 5);
height = defence ? BLOCK_WIDTH.value : height; height = defence ? BLOCK_WIDTH : height;
let rz = isMe(controller) ? 0 : 180; const rz = (isMe(controller) ? 0 : 180) + (defence ? 90 : 0);
rz += defence ? 90 : 0;
const ry = [ const ry = [
ygopro.CardPosition.FACEDOWN, ygopro.CardPosition.FACEDOWN,
...@@ -89,7 +88,8 @@ export const moveToGround: MoveFunc = async (props) => { ...@@ -89,7 +88,8 @@ export const moveToGround: MoveFunc = async (props) => {
: 0; : 0;
// 动画 // 动画
if (fromZone === TZONE) { const isToken = options?.fromZone === TZONE;
if (isToken) {
// 如果是Token,直接先移动到那个位置,然后再放大 // 如果是Token,直接先移动到那个位置,然后再放大
api.set({ api.set({
x, x,
...@@ -107,8 +107,9 @@ export const moveToGround: MoveFunc = async (props) => { ...@@ -107,8 +107,9 @@ export const moveToGround: MoveFunc = async (props) => {
ry, ry,
rz, rz,
config: { config: {
// mass: 0.5, tension: 250,
easing: easings.easeInOutSine, clamp: true,
easing: easings.easeOutSine,
}, },
}); });
} }
...@@ -116,13 +117,12 @@ export const moveToGround: MoveFunc = async (props) => { ...@@ -116,13 +117,12 @@ export const moveToGround: MoveFunc = async (props) => {
await asyncStart(api)({ await asyncStart(api)({
height, height,
z: 0, z: 0,
subZ: isToken ? 100 : 0,
zIndex: is_overlay ? 1 : 3, zIndex: is_overlay ? 1 : 3,
config: { config: {
easing: easings.easeInOutQuad, easing: easings.easeInQuad,
mass: 5,
tension: 300, // 170
friction: 12, // 26
clamp: true, clamp: true,
}, },
}); });
if (isToken) api.set({ subZ: 0 });
}; };
import { ygopro } from "@/api"; import { ygopro } from "@/api";
import { cardStore, isMe } from "@/stores"; import { cardStore, isMe } from "@/stores";
import { matConfig } from "@/ui/Shared";
import { matConfig } from "../../utils"; import type { MoveFunc } from "./types";
import { asyncStart, type MoveFunc } from "./utils"; import { asyncStart } from "./utils";
const { const {
BLOCK_HEIGHT_M, BLOCK_HEIGHT_M,
...@@ -22,27 +23,23 @@ export const moveToHand: MoveFunc = async (props) => { ...@@ -22,27 +23,23 @@ export const moveToHand: MoveFunc = async (props) => {
// 手卡会有很复杂的计算... // 手卡会有很复杂的计算...
const hand_circle_center_x = 0; const hand_circle_center_x = 0;
const hand_circle_center_y = const hand_circle_center_y =
1 * BLOCK_HEIGHT_M.value + BLOCK_HEIGHT_M +
1 * BLOCK_HEIGHT_S.value + BLOCK_HEIGHT_S +
2 * ROW_GAP.value + 2 * ROW_GAP +
(HAND_MARGIN_TOP.value + (HAND_MARGIN_TOP + HAND_CARD_HEIGHT + HAND_CIRCLE_CENTER_OFFSET_Y);
HAND_CARD_HEIGHT.value + const hand_card_width = CARD_RATIO * HAND_CARD_HEIGHT;
HAND_CIRCLE_CENTER_OFFSET_Y.value);
const hand_card_width = CARD_RATIO.value * HAND_CARD_HEIGHT.value;
const THETA = const THETA =
2 * 2 *
Math.atan( Math.atan(
hand_card_width / hand_card_width / 2 / (HAND_CIRCLE_CENTER_OFFSET_Y + HAND_CARD_HEIGHT)
2 /
(HAND_CIRCLE_CENTER_OFFSET_Y.value + HAND_CARD_HEIGHT.value)
) * ) *
0.9; 0.9;
// 接下来计算每一张手卡 // 接下来计算每一张手卡
const hands_length = cardStore.at(HAND, controller).length; const hands_length = cardStore.at(HAND, controller).length;
const angle = (sequence - (hands_length - 1) / 2) * THETA; const angle = (sequence - (hands_length - 1) / 2) * THETA;
const r = HAND_CIRCLE_CENTER_OFFSET_Y.value + HAND_CARD_HEIGHT.value / 2; const r = HAND_CIRCLE_CENTER_OFFSET_Y + HAND_CARD_HEIGHT / 2;
const negativeX = Math.sin(angle) * r; const negativeX = Math.sin(angle) * r;
const negativeY = Math.cos(angle) * r + HAND_CARD_HEIGHT.value / 2; const negativeY = Math.cos(angle) * r + HAND_CARD_HEIGHT / 2;
const x = hand_circle_center_x + negativeX * (isMe(controller) ? 1 : -1); const x = hand_circle_center_x + negativeX * (isMe(controller) ? 1 : -1);
const y = hand_circle_center_y - negativeY + 140; // FIXME: 常量 是手动调的 这里肯定有问题 有空来修 const y = hand_circle_center_y - negativeY + 140; // FIXME: 常量 是手动调的 这里肯定有问题 有空来修
...@@ -54,8 +51,8 @@ export const moveToHand: MoveFunc = async (props) => { ...@@ -54,8 +51,8 @@ export const moveToHand: MoveFunc = async (props) => {
z: sequence + 5, z: sequence + 5,
rz: isMe(controller) ? _rz : 180 - _rz, rz: isMe(controller) ? _rz : 180 - _rz,
ry: isMe(controller) ? 0 : 180, ry: isMe(controller) ? 0 : 180,
height: HAND_CARD_HEIGHT.value, height: HAND_CARD_HEIGHT,
zIndex: sequence, zIndex: sequence,
// rx: -PLANE_ROTATE_X.value, // rx: -PLANE_ROTATE_X,
}); });
}; };
import { ygopro } from "@/api"; import { ygopro } from "@/api";
import { isMe } from "@/stores"; import { isMe } from "@/stores";
import { matConfig } from "@/ui/Shared";
import { matConfig } from "../../utils"; import type { MoveFunc } from "./types";
import { asyncStart, type MoveFunc } from "./utils"; import { asyncStart } from "./utils";
const { BLOCK_WIDTH, BLOCK_HEIGHT_M, BLOCK_HEIGHT_S, COL_GAP, ROW_GAP } = const {
matConfig; BLOCK_WIDTH,
BLOCK_HEIGHT_M,
COL_GAP,
ROW_GAP,
CARD_HEIGHT_O,
BLOCK_OUTSIDE_OFFSET_X,
CARD_RATIO,
} = matConfig;
const { GRAVE } = ygopro.CardZone; const { REMOVED } = ygopro.CardZone;
export const moveToOutside: MoveFunc = async (props) => { export const moveToOutside: MoveFunc = async (props) => {
const { card, api } = props; const { card, api } = props;
// report
const { zone, controller, position, sequence } = card.location; const { zone, controller, position, sequence } = card.location;
let x = (BLOCK_WIDTH.value + COL_GAP.value) * 3, let x =
y = zone === GRAVE ? BLOCK_HEIGHT_M.value + ROW_GAP.value : 0; BLOCK_WIDTH * 2.5 +
COL_GAP * 2 +
BLOCK_OUTSIDE_OFFSET_X +
CARD_HEIGHT_O * CARD_RATIO * 0.5,
y = ROW_GAP + BLOCK_HEIGHT_M + (BLOCK_HEIGHT_M - CARD_HEIGHT_O) / 2;
if (zone === REMOVED) y -= ROW_GAP + CARD_HEIGHT_O;
if (!isMe(controller)) { if (!isMe(controller)) {
x = -x; x = -x;
y = -y; y = -y;
...@@ -24,11 +36,14 @@ export const moveToOutside: MoveFunc = async (props) => { ...@@ -24,11 +36,14 @@ export const moveToOutside: MoveFunc = async (props) => {
x, x,
y, y,
z: 0, z: 0,
height: BLOCK_HEIGHT_S.value, height: CARD_HEIGHT_O,
rz: isMe(controller) ? 0 : 180, rz: isMe(controller) ? 0 : 180,
ry: [ygopro.CardPosition.FACEDOWN].includes(position) ? 180 : 0, ry: [ygopro.CardPosition.FACEDOWN].includes(position) ? 180 : 0,
subZ: 100, subZ: 100,
zIndex: sequence, zIndex: sequence,
config: {
tension: 140,
},
}); });
api.set({ subZ: 0 }); api.set({ subZ: 0 });
}; };
import { asyncStart, type MoveFunc } from "./utils"; import type { MoveFunc } from "./types";
import { asyncStart } from "./utils";
export const moveToToken: MoveFunc = async (props) => { export const moveToToken: MoveFunc = async (props) => {
const { api } = props; const { api } = props;
await asyncStart(api)({ await asyncStart(api)({
height: 0, height: 0,
opacity: 0,
}); });
api.set({ opacity: 1 });
}; };
import { type SpringRef } from "@react-spring/web"; import type { SpringRef } from "@react-spring/web";
import type { ygopro } from "@/api";
import type { CardType } from "@/stores";
export interface SpringApiProps { export interface SpringApiProps {
x: number; x: number;
...@@ -9,6 +12,7 @@ export interface SpringApiProps { ...@@ -9,6 +12,7 @@ export interface SpringApiProps {
rz: number; rz: number;
zIndex: number; zIndex: number;
height: number; height: number;
opacity: number;
// >>> focus // >>> focus
focusScale: number; focusScale: number;
focusDisplay: string; focusDisplay: string;
...@@ -19,3 +23,21 @@ export interface SpringApiProps { ...@@ -19,3 +23,21 @@ export interface SpringApiProps {
} }
export type SpringApi = SpringRef<SpringApiProps>; export type SpringApi = SpringRef<SpringApiProps>;
type OptionsToFunc<Options> = (props: {
card: CardType;
api: SpringApi;
options?: Options;
}) => Promise<void>;
export interface MoveOptions {
fromZone?: ygopro.CardZone;
}
export type MoveFunc = OptionsToFunc<MoveOptions>;
export type AttackOptions =
| {
directAttack: true;
}
| { directAttack: false; target: ygopro.CardLocation };
export type AttackFunc = OptionsToFunc<AttackOptions>;
import { type SpringConfig, type SpringRef } from "@react-spring/web"; import { type SpringConfig, type SpringRef } from "@react-spring/web";
import type { ygopro } from "@/api";
import { type CardType } from "@/stores";
import type { SpringApi } from "./types";
export const asyncStart = <T extends {}>(api: SpringRef<T>) => { export const asyncStart = <T extends {}>(api: SpringRef<T>) => {
return (p: Partial<T> & { config?: SpringConfig }) => return (p: Partial<T> & { config?: SpringConfig }) =>
new Promise((resolve) => { new Promise((resolve) => {
...@@ -14,9 +9,3 @@ export const asyncStart = <T extends {}>(api: SpringRef<T>) => { ...@@ -14,9 +9,3 @@ export const asyncStart = <T extends {}>(api: SpringRef<T>) => {
}); });
}); });
}; };
export type MoveFunc = (props: {
card: CardType;
api: SpringApi;
fromZone?: ygopro.CardZone;
}) => Promise<void>;
...@@ -54,25 +54,3 @@ ...@@ -54,25 +54,3 @@
min-width: 3.25em; min-width: 3.25em;
} }
} }
.floodlight {
position: absolute;
height: 100%;
width: 40px;
background-color: #aaa;
top: 0;
right: 0;
filter: blur(30px);
transform: skewX(-20deg);
}
.floodlight-run {
animation: floodlight 4s linear infinite;
}
@keyframes floodlight {
0% {
right: -80px;
}
100% {
right: calc(100% + 80px);
}
}
...@@ -115,7 +115,6 @@ const LifeBarItem: React.FC<{ ...@@ -115,7 +115,6 @@ const LifeBarItem: React.FC<{
size={14} size={14}
/> />
<div className="timer-text">{timeText}</div> <div className="timer-text">{timeText}</div>
<div className="floodlight floodlight-run" />
</div> </div>
)} )}
</div> </div>
......
// 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}`,
] as [string, string]
)
.reduce((acc, cur) => [...acc, cur], [] as [string, string][]);
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: 130,
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,
},
};
toCssProperties(matConfig).forEach(([k, v]) => {
document.body.style.setProperty(k, v);
});
...@@ -38,7 +38,7 @@ export const YgoCard: React.FC<Props> = (props) => { ...@@ -38,7 +38,7 @@ export const YgoCard: React.FC<Props> = (props) => {
const NeosConfig = useConfig(); const NeosConfig = useConfig();
function getCardImgUrl(code: number, back = false) { export function getCardImgUrl(code: number, back = false) {
const ASSETS_BASE = const ASSETS_BASE =
import.meta.env.BASE_URL === "/" import.meta.env.BASE_URL === "/"
? NeosConfig.assetsPath ? NeosConfig.assetsPath
......
// 此文件目的是在js和CSS之间共享一些变量,并且这些变量是0运行时的。
type CSSConfig = Record<string, [number, UNIT]>;
/** 转为CSS变量: BOARD_ROTATE_Z -> --board-rotate-z */
const toCssProperties = (config: CSSConfig) =>
Object.entries(config)
.map(
([k, v]) =>
[
`--${k
.split("_")
.map((s) => s.toLowerCase())
.join("-")}`,
`${v[0]}${v[1]}`,
] as [string, string]
)
.reduce((acc, cur) => [...acc, cur], [] as [string, string][]);
enum UNIT {
PX = "px",
DEG = "deg",
NONE = "",
}
const matConfigWithUnit = {
PERSPECTIVE: [1500, UNIT.PX],
PLANE_ROTATE_X: [0, UNIT.DEG],
BLOCK_WIDTH: [120, UNIT.PX],
BLOCK_HEIGHT_M: [120, UNIT.PX],
BLOCK_HEIGHT_S: [110, UNIT.PX], // 魔法陷阱区
ROW_GAP: [10, UNIT.PX],
COL_GAP: [10, UNIT.PX],
CARD_RATIO: [5.9 / 8.6, UNIT.NONE],
HAND_MARGIN_TOP: [0, UNIT.PX],
HAND_CIRCLE_CENTER_OFFSET_Y: [2000, UNIT.PX],
HAND_CARD_HEIGHT: [130, UNIT.PX],
DECK_OFFSET_X: [140, UNIT.PX],
DECK_OFFSET_Y: [80, UNIT.PX],
DECK_ROTATE_Z: [30, UNIT.DEG],
DECK_CARD_HEIGHT: [120, UNIT.PX],
CARD_HEIGHT_O: [100, UNIT.PX], // 场地魔法/墓地/除外的卡片高度
BLOCK_OUTSIDE_OFFSET_X: [15, UNIT.PX],
} satisfies CSSConfig;
export const matConfig = Object.keys(matConfigWithUnit).reduce(
(prev, key) => ({
...prev,
// @ts-ignore
[key]: matConfigWithUnit[key][0],
}),
{} as Record<keyof typeof matConfigWithUnit, number>
);
toCssProperties(matConfigWithUnit).forEach(([k, v]) => {
document.body.style.setProperty(k, v);
});
export * from "./css";
export * from "./YgoCard"; export * from "./YgoCard";
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