Commit f15b790f authored by Chunchi Che's avatar Chunchi Che

Merge branch 'dev/dropmenu' into 'main'

optimize: card dropdown menu

See merge request mycard/Neos!242
parents 2e712f62 9d4a6da6
......@@ -19,7 +19,7 @@ const defaultConfig: DefaultsConfig = {
const aiModeConfig: DefaultsConfig = {
...defaultConfig,
defaultDeck: VITE_AI_MODE_DEFAULT_DECK || "Hero",
defaultPlayer: `AiKiller${Math.random().toString(36).slice(2)}}`,
defaultPlayer: `AiKiller-${Math.random().toString(36).slice(2, 6)}}`,
defaultPassword: "AI",
};
......
......@@ -22,6 +22,6 @@ declare global {
color: (
color: string,
backgroundColor?: string
) => (...args: any[]) => void;
) => (...args: Parameters<console.log>) => void;
}
}
......@@ -157,14 +157,15 @@ export default async (move: MsgMove) => {
target.location = to;
// 维护完了之后,开始动画
await eventbus.call(Task.Move, target.uuid);
await eventbus.call(Task.Move, target.uuid, from.zone);
// 如果from或者to是手卡,那么需要刷新除了这张卡之外,这个玩家的所有手卡
if ([from.zone, to.zone].includes(HAND)) {
for (const card of cardStore.at(HAND, target.location.controller)) {
if (card.uuid !== target.uuid) {
await eventbus.call(Task.Move, card.uuid);
}
}
await Promise.all(
cardStore
.at(HAND, target.location.controller)
.filter((c) => c.uuid !== target.uuid)
.map(async (c) => await eventbus.call(Task.Move, c.uuid))
);
}
// 超量素材位置跟随超量怪兽移动
......@@ -177,6 +178,7 @@ export default async (move: MsgMove) => {
overlay.location.zone = to.zone;
overlay.location.controller = to.controller;
overlay.location.sequence = to.sequence;
overlay.location.position = to.position;
await eventbus.call(Task.Move, overlay.uuid);
}
......
......@@ -93,7 +93,7 @@ export interface HintState {
}
export interface PhaseState {
currentPhase: ygopro.StocGameMessage.MsgNewPhase.PhaseType; // TODO 当前的阶段 应该改成enum
currentPhase: ygopro.StocGameMessage.MsgNewPhase.PhaseType;
enableBp: boolean; // 允许进入战斗阶段
enableM2: boolean; // 允许进入M2阶段
enableEp: boolean; // 允许回合结束
......
......@@ -20,39 +20,21 @@ export interface BlockState {
disabled: boolean; // 是否被禁用
}
const genPLaces = (n: number): BlockState[] =>
Array.from({ length: n }).map(() => ({
interactivity: undefined,
disabled: false,
}));
export const placeStore = proxy({
inner: {
[MZONE]: {
me: Array.from({ length: 7 }).map(
() =>
({
interactivity: undefined,
disabled: false,
} as BlockState)
),
op: Array.from({ length: 7 }).map(
() =>
({
interactivity: undefined,
disabled: false,
} as BlockState)
),
me: genPLaces(7),
op: genPLaces(7),
},
[SZONE]: {
me: Array.from({ length: 6 }).map(
() =>
({
interactivity: undefined,
disabled: false,
} as BlockState)
),
op: Array.from({ length: 6 }).map(
() =>
({
interactivity: undefined,
disabled: false,
} as BlockState)
),
me: genPLaces(6),
op: genPLaces(6),
},
},
set(
......
......@@ -41,7 +41,11 @@ body {
margin: 0;
place-items: center;
min-width: 320px;
min-height: 100vh;
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
a {
......
......@@ -14,7 +14,7 @@ import {
SortCardModal,
YesNoModal,
} from "./Message";
import { LifeBar, Mat, Menu, Timer } from "./PlayMat";
import { LifeBar, Mat, Menu } from "./PlayMat";
const NeosDuel = () => {
return (
......@@ -23,7 +23,6 @@ const NeosDuel = () => {
<Alert />
<Menu />
<LifeBar />
<Timer />
<Mat />
<CardModal />
<CardListModal />
......
......@@ -10,9 +10,9 @@ import { useConfig } from "@/config";
import { HandResult, matStore } from "@/stores";
const style = {
borderStyle: "groove",
borderRadius: "8px",
backgroundColor: "#303030",
// borderStyle: "groove",
// borderRadius: "8px",
backgroundColor: "#444",
};
const NeosConfig = useConfig();
......
......@@ -30,7 +30,7 @@ const CheckCardStyle = {
};
const CheckGroupStyle = {
display: "grid",
gridTemplateColumns: "repeat(6, 1fr)",
gridTemplateColumns: "repeat(5, 1fr)",
gap: 10,
};
......
......@@ -13,7 +13,7 @@ section#mat {
top: 2%;
height: 96%;
width: 96%;
transform: translateZ(calc(var(--z) * 1px + 0.1px))
transform: translateZ(calc((var(--z) + var(--sub-z)) * 1px + 0.1px))
rotateY(calc(var(--ry) * 1deg));
transition: 0.2s scale;
cursor: pointer;
......@@ -107,9 +107,12 @@ section#mat {
}
.mat-card.highlight .card-shadow {
--card-shadow-color: #0099ff;
display: block !important;
background: linear-gradient(to right, #0079c6, #009cff) !important;
filter: blur(8px);
background: var(--card-shadow-color) !important;
border-radius: 5px;
box-shadow: 0 0 4px 0 var(--card-shadow-color), 0 0 25px 2px #0099ff87;
transform: translateZ(calc((var(--z)) * 1px + 0.1px));
}
@keyframes focus {
......
This diff is collapsed.
......@@ -5,7 +5,7 @@ import { ygopro } from "@/api";
import { CardType, isMe } from "@/stores";
import { matConfig } from "../../utils";
import { SpringApi } from "./types";
import type { SpringApi } from "./types";
import { asyncStart } from "./utils";
const { BLOCK_WIDTH, BLOCK_HEIGHT_M, BLOCK_HEIGHT_S, COL_GAP, ROW_GAP } =
......
import { ygopro } from "@/api";
import { type CardType, matStore } from "@/stores";
import { SpringApi } from "./types";
import type { SpringApi } from "./types";
import { asyncStart } from "./utils";
/** 发动效果的动画 */
......@@ -13,11 +13,16 @@ export const focus = async (props: { card: CardType; api: SpringApi }) => {
) {
const current = api.current[0].get();
await asyncStart(api)({
y: current.y + (matStore.isMe(card.location.controller) ? -1 : 1) * 200, // TODO: 放到config之中
y: current.y + (matStore.isMe(card.location.controller) ? -1 : 1) * 120, // TODO: 放到config之中
ry: 0,
rz: 0,
});
await asyncStart(api)({ y: current.y, ry: current.ry, rz: current.rz });
await asyncStart(api)({
y: current.y,
ry: current.ry,
rz: current.rz,
z: current.z,
});
} else {
await asyncStart(api)({
focusScale: 1.5,
......
......@@ -4,3 +4,4 @@ export * from "./moveToDeck";
export * from "./moveToGround";
export * from "./moveToHand";
export * from "./moveToOutside";
export * from "./moveToToken";
import { ygopro } from "@/api";
import { type CardType, isMe } from "@/stores";
import { isMe } from "@/stores";
import { matConfig } from "../../utils";
import { SpringApi } from "./types";
import { asyncStart, type MoveFunc } from "./utils";
const {
BLOCK_WIDTH,
......@@ -18,7 +18,7 @@ const {
const { DECK, EXTRA } = ygopro.CardZone;
export const moveToDeck = async (props: { card: CardType; api: SpringApi }) => {
export const moveToDeck: MoveFunc = async (props) => {
const { card, api } = props;
// report
const { location } = card;
......@@ -41,7 +41,8 @@ export const moveToDeck = async (props: { card: CardType; api: SpringApi }) => {
let rz = zone === EXTRA ? DECK_ROTATE_Z.value : -DECK_ROTATE_Z.value;
rz += isMe(controller) ? 0 : 180;
const z = sequence;
api.start({
await asyncStart(api)({
x,
y,
z,
......
import { easings } from "@react-spring/web";
import { ygopro } from "@/api";
import { type CardType, isMe } from "@/stores";
import { isMe } from "@/stores";
import { matConfig } from "../../utils";
import { SpringApi } from "./types";
import { asyncStart } from "./utils";
import { asyncStart, type MoveFunc } from "./utils";
const {
BLOCK_WIDTH,
......@@ -16,13 +15,10 @@ const {
ROW_GAP,
} = matConfig;
const { MZONE, SZONE } = ygopro.CardZone;
const { MZONE, SZONE, TZONE } = ygopro.CardZone;
export const moveToGround = async (props: {
card: CardType;
api: SpringApi;
}) => {
const { card, api } = props;
export const moveToGround: MoveFunc = async (props) => {
const { card, api, fromZone } = props;
const { location } = card;
......@@ -84,26 +80,41 @@ export const moveToGround = async (props: {
let rz = isMe(controller) ? 0 : 180;
rz += defence ? 90 : 0;
const ry = [
ygopro.CardPosition.FACEDOWN,
ygopro.CardPosition.FACEDOWN_ATTACK,
ygopro.CardPosition.FACEDOWN_DEFENSE,
].includes(position ?? 5)
? 180
: 0;
// 动画
if (fromZone === TZONE) {
// 如果是Token,直接先移动到那个位置,然后再放大
api.set({
x,
y,
ry,
rz,
height: 0,
});
} else {
await asyncStart(api)({
x,
y,
height,
z: is_overlay ? 120 : 200,
ry,
rz,
config: {
// mass: 0.5,
easing: easings.easeInOutSine,
},
});
}
await asyncStart(api)({
x,
y,
height,
z: is_overlay ? 120 : 200,
ry: [
ygopro.CardPosition.FACEDOWN,
ygopro.CardPosition.FACEDOWN_ATTACK,
ygopro.CardPosition.FACEDOWN_DEFENSE,
].includes(position ?? 5)
? 180
: 0,
rz,
config: {
// mass: 0.5,
easing: easings.easeInOutSine,
},
});
await asyncStart(api)({
z: 0,
zIndex: is_overlay ? 1 : 3,
config: {
......
import { ygopro } from "@/api";
import { cardStore, type CardType, isMe } from "@/stores";
import { cardStore, isMe } from "@/stores";
import { matConfig } from "../../utils";
import { SpringApi } from "./types";
import { asyncStart, type MoveFunc } from "./utils";
const {
BLOCK_HEIGHT_M,
......@@ -16,7 +16,7 @@ const {
const { HAND } = ygopro.CardZone;
export const moveToHand = async (props: { card: CardType; api: SpringApi }) => {
export const moveToHand: MoveFunc = async (props) => {
const { card, api } = props;
const { sequence, controller } = card.location;
// 手卡会有很复杂的计算...
......@@ -44,14 +44,14 @@ export const moveToHand = async (props: { card: CardType; api: SpringApi }) => {
const negativeX = Math.sin(angle) * r;
const negativeY = Math.cos(angle) * r + HAND_CARD_HEIGHT.value / 2;
const x = hand_circle_center_x + negativeX * (isMe(controller) ? 1 : -1);
const y = hand_circle_center_y - negativeY + 140; // 常量 是手动调的 这里肯定有问题 有空来修
const y = hand_circle_center_y - negativeY + 140; // FIXME: 常量 是手动调的 这里肯定有问题 有空来修
const _rz = (angle * 180) / Math.PI;
api.start({
await asyncStart(api)({
x: isMe(controller) ? x : -x,
y: isMe(controller) ? y : -y,
z: 0,
z: sequence + 5,
rz: isMe(controller) ? _rz : 180 - _rz,
ry: isMe(controller) ? 0 : 180,
height: HAND_CARD_HEIGHT.value,
......
import { ygopro } from "@/api";
import { type CardType, isMe } from "@/stores";
import { isMe } from "@/stores";
import { matConfig } from "../../utils";
import { SpringApi } from "./types";
import { asyncStart, type MoveFunc } from "./utils";
const { BLOCK_WIDTH, BLOCK_HEIGHT_M, BLOCK_HEIGHT_S, COL_GAP, ROW_GAP } =
matConfig;
const { GRAVE } = ygopro.CardZone;
export const moveToOutside = async (props: {
card: CardType;
api: SpringApi;
}) => {
export const moveToOutside: MoveFunc = async (props) => {
const { card, api } = props;
// report
const { zone, controller, position } = card.location;
const { zone, controller, position, sequence } = card.location;
let x = (BLOCK_WIDTH.value + COL_GAP.value) * 3,
y = zone === GRAVE ? BLOCK_HEIGHT_M.value + ROW_GAP.value : 0;
......@@ -23,12 +20,15 @@ export const moveToOutside = async (props: {
x = -x;
y = -y;
}
api.start({
await asyncStart(api)({
x,
y,
z: 0,
height: BLOCK_HEIGHT_S.value,
rz: isMe(controller) ? 0 : 180,
ry: [ygopro.CardPosition.FACEDOWN].includes(position) ? 180 : 0,
subZ: 100,
zIndex: sequence,
});
api.set({ subZ: 0 });
};
import { asyncStart, type MoveFunc } from "./utils";
export const moveToToken: MoveFunc = async (props) => {
const { api } = props;
await asyncStart(api)({
height: 0,
});
};
......@@ -14,6 +14,8 @@ export interface SpringApiProps {
focusDisplay: string;
focusOpacity: number;
// <<< focus
subZ: number; // 0 -> 100,这是为了让卡片移动过程中,稍微上浮一些,避免一些奇怪的遮挡问题
}
export type SpringApi = SpringRef<SpringApiProps>;
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>) => {
return (p: Partial<T> & { config?: SpringConfig }) =>
new Promise((resolve) => {
api.start({
...p,
onRest: resolve,
onResolve: resolve,
});
});
};
export type MoveFunc = (props: {
card: CardType;
api: SpringApi;
fromZone?: ygopro.CardZone;
}) => Promise<void>;
......@@ -3,20 +3,24 @@
display: flex;
top: 0;
left: 0;
height: 100vh; // FIXME: 100% on safari
bottom: 0;
flex-direction: column;
justify-content: space-between;
padding: 20px 35px;
padding: 20px;
margin-left: 10px;
pointer-events: none;
z-index: 100;
--bg-color: #323232;
width: 200px;
}
.life-bar {
width: 160px;
position: relative;
overflow: hidden;
width: 100%;
color: white;
background-color: #323232;
background-color: var(--bg-color);
font-family: var(--theme-font);
border: 1px solid #222;
padding: 1rem;
padding-bottom: 0.6rem;
border-radius: 8px;
......@@ -32,3 +36,43 @@
font-size: 1.8rem;
}
}
.timer-container {
background-color: var(--bg-color);
border-radius: 4px;
padding: 0.4rem 1rem;
font-size: 0.8rem;
font-weight: bold;
font-family: var(--theme-font);
width: fit-content;
color: white;
display: flex;
gap: 8px;
align-items: center;
overflow: hidden;
position: relative;
.timer-text {
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);
}
}
import "./index.scss";
import { Progress } from "antd";
import classNames from "classnames";
import React, { useEffect } from "react";
import React, { useEffect, useState } from "react";
import AnimatedNumbers from "react-animated-numbers";
import { useSnapshot } from "valtio";
import { useEnv } from "@/hook";
import { matStore, playerStore } from "@/stores";
// 三个候选方案
// https://snack.expo.dev/?platform=web
// https://github.com/heyman333/react-animated-numbers
// https://www.npmjs.com/package/react-countup?activeTab=dependents
export const LifeBar: React.FC = () => {
const snap = useSnapshot(matStore.initInfo);
const snapInitInfo = useSnapshot(matStore.initInfo);
const snapPlayer = useSnapshot(playerStore);
const { currentPlayer } = useSnapshot(matStore);
......@@ -21,35 +22,102 @@ export const LifeBar: React.FC = () => {
const [opLife, setOpLife] = React.useState(0);
useEffect(() => {
setMeLife(snap.me.life);
}, [snap.me.life]);
setMeLife(snapInitInfo.me.life);
}, [snapInitInfo.me.life]);
useEffect(() => {
setOpLife(snapInitInfo.op.life);
}, [snapInitInfo.op.life]);
const snapTimeLimit = useSnapshot(matStore.timeLimits);
const [myTimeLimit, setMyTimeLimit] = useState(snapTimeLimit.me);
const [opTimeLimit, setOpTimeLimit] = useState(snapTimeLimit.op);
useEffect(() => {
setMyTimeLimit(snapTimeLimit.me);
}, [snapTimeLimit.me]);
useEffect(() => {
setOpTimeLimit(snapTimeLimit.op);
}, [snapTimeLimit.op]);
useEffect(() => {
setInterval(() => {
setMyTimeLimit((time) => time - 1);
setOpTimeLimit((time) => time - 1);
}, 1000);
}, []);
useEffect(() => {
setOpLife(snap.op.life);
}, [snap.op.life]);
if (useEnv().VITE_IS_AI_MODE) {
// 如果是AI模式
// FIXME: 探索一个优雅的、判断当前是不是AI模式的方法,用户手动输入AI也是AI模式
setMyTimeLimit(240);
setOpTimeLimit(240);
}
}, [currentPlayer]);
return (
<div id="life-bar-container">
<LifeBarItem
active={!matStore.isMe(currentPlayer)}
name={snapPlayer.getOpPlayer().name ?? "?"}
life={opLife}
timeLimit={opTimeLimit}
isMe={false}
/>
<LifeBarItem
active={matStore.isMe(currentPlayer)}
name={snapPlayer.getMePlayer().name ?? "?"}
life={meLife}
timeLimit={myTimeLimit}
isMe={true}
/>
</div>
);
};
const LifeBarItem: React.FC<{
active: boolean;
name: string;
life: number;
timeLimit: number;
isMe: boolean;
}> = ({ active, name, life, timeLimit, isMe }) => {
const mm = Math.floor(timeLimit / 60);
const ss = timeLimit % 60;
const timeText =
timeLimit < 0
? "00:00"
: `${mm < 10 ? "0" + mm : mm}:${ss < 10 ? "0" + ss : ss}`;
return (
<div
style={{
flexDirection: isMe ? "column-reverse" : "column",
overflow: "hidden",
display: "flex",
gap: "0.5rem",
position: "relative",
}}
>
<div
className={classNames("life-bar", {
"life-bar-activated": matStore.isMe(currentPlayer),
"life-bar-activated": active,
})}
>
<div className="name">{snapPlayer.getOpPlayer().name}</div>
<div className="life">
{<AnimatedNumbers animateToNumber={opLife} />}
</div>
<div className="name">{name}</div>
<div className="life">{<AnimatedNumbers animateToNumber={life} />}</div>
</div>
<div
className={classNames("life-bar", {
"life-bar-activated": matStore.isMe(currentPlayer),
})}
>
<div className="name">{snapPlayer.getMePlayer().name}</div>
<div className="life">
<AnimatedNumbers animateToNumber={meLife} />
{active && (
<div className="timer-container">
<Progress
type="circle"
percent={Math.floor((timeLimit / 240) * 100)}
strokeWidth={20}
size={14}
/>
<div className="timer-text">{timeText}</div>
<div className="floodlight floodlight-run" />
</div>
</div>
)}
</div>
);
};
......@@ -10,26 +10,4 @@
padding: 8px;
border-radius: 6px;
overflow: hidden;
.floodlight {
position: absolute;
height: 100%;
width: 40px;
background-color: white;
top: 0;
right: 0;
filter: blur(30px);
transform: skewX(-20deg);
}
.floodlight-run {
animation: floodlight 1s linear infinite;
}
}
@keyframes floodlight {
0% {
right: -80px;
}
100% {
right: calc(100% + 80px);
}
}
......@@ -124,7 +124,6 @@ export const Menu = () => {
>
<Button icon={<CloseCircleFilled />} type="text"></Button>
</DropdownWithTitle>
{/* <div className="floodlight floodlight-run" /> */}
</div>
</>
);
......
#timer-container {
position: fixed;
display: flex;
top: 0;
right: 0;
height: 100vh;
padding: 20px 35px;
flex-direction: column;
pointer-events: none;
}
.timer {
width: 100px;
color: white;
background-color: #323232;
font-family: var(--theme-font);
border: 1px solid #222;
padding: 1rem;
padding-bottom: 0.6rem;
border-radius: 8px;
text-align: center;
display: flex;
flex-direction: column;
font-size: 1.2rem;
}
import "./index.scss";
import React, { useEffect, useState } from "react";
import { useSnapshot } from "valtio";
import { matStore } from "@/stores";
export const Timer: React.FC = () => {
const [time, setTime] = useState(0);
const snap = useSnapshot(matStore);
useEffect(() => {
const interval = setInterval(() => {
if (time > 0) {
setTime((time) => time - 1);
}
}, 1000);
return () => clearInterval(interval);
}, [time]);
useEffect(() => {
setTime(snap.timeLimits.me);
}, [snap.timeLimits.me]);
useEffect(() => {
setTime(snap.timeLimits.op);
}, [snap.timeLimits.op]);
return (
<div id="timer-container">
<div className="timer">{time}</div>
</div>
);
};
export * from "./LifeBar";
export * from "./Mat";
export * from "./Menu";
export * from "./Timer";
......@@ -65,7 +65,7 @@ export const matConfig = {
unit: UNIT.PX,
},
HAND_CARD_HEIGHT: {
value: 140,
value: 130,
unit: UNIT.PX,
},
DECK_OFFSET_X: {
......
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