Commit ef561c5a authored by Chunchi Che's avatar Chunchi Che

Merge branch 'feat/history' into 'main'

Feat/history

See merge request mycard/Neos!408
parents 56493c78 76029364
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512 512" enable-background="new 0 0 512 512" xml:space="preserve">
<g>
<g>
<polygon fill="#6E83B7" points="502,256 302,106 302,186 146,186 146,326 302,326 302,406 "/>
</g>
<g>
<rect x="78" y="186" fill="#6E83B7" width="40" height="140"/>
</g>
<g>
<rect x="10" y="186" fill="#6E83B7" width="40" height="140"/>
</g>
</g>
</svg>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
<g id="SVGRepo_iconCarrier"> <path opacity="0.5" d="M17 9.00195C19.175 9.01406 20.3529 9.11051 21.1213 9.8789C22 10.7576 22 12.1718 22 15.0002V16.0002C22 18.8286 22 20.2429 21.1213 21.1215C20.2426 22.0002 18.8284 22.0002 16 22.0002H8C5.17157 22.0002 3.75736 22.0002 2.87868 21.1215C2 20.2429 2 18.8286 2 16.0002L2 15.0002C2 12.1718 2 10.7576 2.87868 9.87889C3.64706 9.11051 4.82497 9.01406 7 9.00195" stroke="#d9d9d9" stroke-width="1.5" stroke-linecap="round"/> <path d="M12 2L12 15M12 15L9 11.5M12 15L15 11.5" stroke="#d9d9d9" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> </g>
</svg>
\ No newline at end of file
neos-protobuf @ 46f932a8
Subproject commit 702feb507a3e5cd31fdc8ee7d9e4b80e68f89923
Subproject commit 46f932a8ee77e72af15141612453709cdcfb6892
/**
* Generated by the protoc-gen-ts. DO NOT EDIT!
* compiler version: 3.21.5
* compiler version: 4.24.4
* source: idl/ocgcore.proto
* git: https://github.com/thesayyn/protoc-gen-ts */
import * as pb_1 from "google-protobuf";
......@@ -14732,7 +14732,7 @@ export namespace ygopro {
if (this.overlay_cards != null) {
data.overlay_cards = this.overlay_cards;
}
if (this.counters.size > 0) {
if (this.counters != null) {
data.counters = (Object.fromEntries)(this.counters);
}
if (this.owner != null) {
......@@ -15507,23 +15507,70 @@ export namespace ygopro {
}
export class MsgSet extends pb_1.Message {
#one_of_decls: number[][] = [];
constructor(data?: any[] | {}) {
constructor(data?: any[] | {
code?: number;
location?: CardLocation;
}) {
super();
pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], this.#one_of_decls);
if (!Array.isArray(data) && typeof data == "object") { }
if (!Array.isArray(data) && typeof data == "object") {
if ("code" in data && data.code != undefined) {
this.code = data.code;
}
if ("location" in data && data.location != undefined) {
this.location = data.location;
}
}
}
get code() {
return pb_1.Message.getFieldWithDefault(this, 1, 0) as number;
}
set code(value: number) {
pb_1.Message.setField(this, 1, value);
}
get location() {
return pb_1.Message.getWrapperField(this, CardLocation, 2) as CardLocation;
}
static fromObject(data: {}): MsgSet {
set location(value: CardLocation) {
pb_1.Message.setWrapperField(this, 2, value);
}
get has_location() {
return pb_1.Message.getField(this, 2) != null;
}
static fromObject(data: {
code?: number;
location?: ReturnType<typeof CardLocation.prototype.toObject>;
}): MsgSet {
const message = new MsgSet({});
if (data.code != null) {
message.code = data.code;
}
if (data.location != null) {
message.location = CardLocation.fromObject(data.location);
}
return message;
}
toObject() {
const data: {} = {};
const data: {
code?: number;
location?: ReturnType<typeof CardLocation.prototype.toObject>;
} = {};
if (this.code != null) {
data.code = this.code;
}
if (this.location != null) {
data.location = this.location.toObject();
}
return data;
}
serialize(): Uint8Array;
serialize(w: pb_1.BinaryWriter): void;
serialize(w?: pb_1.BinaryWriter): Uint8Array | void {
const writer = w || new pb_1.BinaryWriter();
if (this.code != 0)
writer.writeInt32(1, this.code);
if (this.has_location)
writer.writeMessage(2, this.location, () => this.location.serialize(writer));
if (!w)
return writer.getResultBuffer();
}
......@@ -15533,6 +15580,12 @@ export namespace ygopro {
if (reader.isEndGroup())
break;
switch (reader.getFieldNumber()) {
case 1:
message.code = reader.readInt32();
break;
case 2:
reader.readMessage(message.location, () => message.location = CardLocation.deserialize(reader));
break;
default: reader.skipField();
}
}
......
......@@ -52,7 +52,16 @@
},
"54": {
"protoType": "set",
"fields": []
"fields": [
{
"fieldName": "code",
"fieldType": "uint32"
},
{
"fieldName": "location",
"fieldType": "CardLocation"
}
]
},
"55": {
"protoType": "swap",
......
......@@ -52,7 +52,7 @@ export enum Region {
}
export function fetchStrings(region: Region, id: string | number): string {
return localStorage.getItem(`${region}_${id}`) ?? "";
return localStorage.getItem(`${region}_${id}`) ?? "?";
}
export function getStrings(description: number): string {
......
......@@ -2,6 +2,7 @@ import { WebSocketStream } from "@/infra";
import {
cardStore,
chatStore,
historyStore,
matStore,
placeStore,
roomStore,
......@@ -22,6 +23,7 @@ export function initUIContainer(conn: WebSocketStream) {
roomStore,
chatStore,
sideStore,
historyStore,
});
const container = new Container(context, conn);
......
......@@ -3,6 +3,7 @@
import {
CardStore,
ChatStore,
HistoryStore,
MatStore,
PlaceStore,
RoomStore,
......@@ -16,6 +17,7 @@ interface ContextInitInfo {
roomStore?: RoomStore;
chatStore?: ChatStore;
sideStore?: SideStore;
historyStore?: HistoryStore;
}
export class Context {
......@@ -25,17 +27,26 @@ export class Context {
public roomStore: RoomStore;
public chatStore: ChatStore;
public sideStore: SideStore;
public historyStore: HistoryStore;
constructor();
constructor(initInfo: ContextInitInfo);
constructor(initInfo?: ContextInitInfo) {
const { matStore, cardStore, placeStore, roomStore, chatStore, sideStore } =
initInfo ?? {};
const {
matStore,
cardStore,
placeStore,
roomStore,
chatStore,
sideStore,
historyStore,
} = initInfo ?? {};
this.matStore = matStore ?? new MatStore();
this.cardStore = cardStore ?? new CardStore();
this.placeStore = placeStore ?? new PlaceStore();
this.roomStore = roomStore ?? new RoomStore();
this.chatStore = chatStore ?? new ChatStore();
this.sideStore = sideStore ?? new SideStore();
this.historyStore = historyStore ?? new HistoryStore();
}
}
......@@ -15,6 +15,8 @@ export default (
if (target) {
console.info(`${target.meta.text.name} become target`);
target.targeted = true;
context.historyStore.putTargeted(context, target.code, location);
} else {
console.warn(`<BecomeTarget>target from ${location} is null`);
}
......
......@@ -44,6 +44,8 @@ export default async (
target.meta = meta;
}
context.historyStore.putEffect(context, meta.id, location);
// 发动效果动画
await callCardFocus(target.uuid);
console.color("blue")(`${target.meta.text.name} chaining`);
......
......@@ -63,6 +63,8 @@ export default async (
target.meta = { id: 0, data: {}, text: {} };
}
}
context.historyStore.putConfirmed(context, meta.id, target.location);
} else {
console.warn(`card of ${card} is null`);
}
......
......@@ -8,9 +8,17 @@ export default (
flipSummoning: ygopro.StocGameMessage.MsgFlipSummoning,
) => {
// playEffect(AudioActionType.SOUND_FILP);
const context = container.context;
fetchEsHintMeta({
context: container.context,
context: context,
originMsg: "「[?]」反转召唤宣言时",
cardID: flipSummoning.code,
});
context.historyStore.putFlipSummon(
context,
flipSummoning.code,
flipSummoning.location,
);
};
......@@ -175,6 +175,12 @@ export default async (container: Container, move: MsgMove) => {
target.targeted = false;
}
if (
(to.zone !== MZONE && to.zone !== SZONE) ||
(to.zone === MZONE && reason !== 0)
)
context.historyStore.putMove(context, code, from, to.zone);
// 维护完了之后,开始播放音效和动画
if (to.zone === REMOVED) {
......
......@@ -3,6 +3,8 @@ import { Container } from "@/container";
import { fetchEsHintMeta } from "./util";
export default (container: Container, _set: ygopro.StocGameMessage.MsgSet) => {
fetchEsHintMeta({ context: container.context, originMsg: 1601 });
export default (container: Container, set: ygopro.StocGameMessage.MsgSet) => {
const context = container.context;
context.historyStore.putSet(context, set.code, set.location);
fetchEsHintMeta({ context: context, originMsg: 1601 });
};
......@@ -13,9 +13,17 @@ export default (
// } else {
// playEffect(AudioActionType.SOUND_SPECIAL_SUMMON);
// }
const context = container.context;
fetchEsHintMeta({
context: container.context,
context: context,
originMsg: "「[?]」特殊召唤宣言时",
cardID: spSummoning.code,
});
context.historyStore.putSpSummon(
context,
spSummoning.code,
spSummoning.location,
);
};
......@@ -11,9 +11,13 @@ export default (
* 因此这里先注释掉,等解决掉上述问题后再加上召唤的音效。
* */
// playEffect(AudioActionType.SOUND_SUMMON);
const context = container.context;
fetchEsHintMeta({
context: container.context,
context: context,
originMsg: "「[?]」通常召唤宣言时",
cardID: summoning.code,
});
context.historyStore.putSummon(context, summoning.code, summoning.location);
};
import { proxy } from "valtio";
import { ygopro } from "@/api";
import { Context } from "@/container";
import { NeosStore } from "./shared";
export enum HistoryOp {
MOVE = 1,
EFFECT = 2,
TARGETED = 3,
CONFIRMED = 4,
ATTACK = 5, // TODO
SUMMON = 6,
SP_SUMMON = 7,
FLIP_SUMMON = 8,
SET = 9,
}
export interface History {
card: number;
opponent: boolean;
currentLocation?: ygopro.CardLocation;
operation: HistoryOp;
target?: ygopro.CardZone;
}
export class HistoryStore implements NeosStore {
historys: History[] = [];
putMove(
context: Context,
card: number,
from: ygopro.CardLocation,
to: ygopro.CardZone,
) {
// TODO: Refinement
this.historys.push({
card,
opponent: !context.matStore.isMe(from.controller),
currentLocation: from,
operation: HistoryOp.MOVE,
target: to,
});
}
putEffect(context: Context, card: number, location: ygopro.CardLocation) {
this.historys.push({
card,
opponent: !context.matStore.isMe(location.controller),
currentLocation: location,
operation: HistoryOp.EFFECT,
});
}
putTargeted(context: Context, card: number, location: ygopro.CardLocation) {
this.historys.push({
card,
opponent: !context.matStore.isMe(location.controller),
currentLocation: location,
operation: HistoryOp.TARGETED,
});
}
putConfirmed(context: Context, card: number, location: ygopro.CardLocation) {
this.historys.push({
card,
opponent: !context.matStore.isMe(location.controller),
currentLocation: location,
operation: HistoryOp.CONFIRMED,
});
}
putSummon(context: Context, card: number, location: ygopro.CardLocation) {
this.historys.push({
card,
opponent: !context.matStore.isMe(location.controller),
operation: HistoryOp.SUMMON,
});
}
putSpSummon(context: Context, card: number, location: ygopro.CardLocation) {
this.historys.push({
card,
opponent: !context.matStore.isMe(location.controller),
operation: HistoryOp.SP_SUMMON,
});
}
putFlipSummon(context: Context, card: number, location: ygopro.CardLocation) {
this.historys.push({
card,
opponent: !context.matStore.isMe(location.controller),
operation: HistoryOp.FLIP_SUMMON,
});
}
putSet(context: Context, card: number, location: ygopro.CardLocation) {
this.historys.push({
card,
opponent: !context.matStore.isMe(location.controller),
operation: HistoryOp.SET,
target: location.zone,
});
}
reset(): void {
this.historys = [];
}
}
export const historyStore = proxy(new HistoryStore());
......@@ -2,6 +2,7 @@ export * from "./accountStore";
export * from "./cardStore";
export * from "./chatStore";
export * from "./deckStore";
export * from "./historyStore";
export * from "./initStore";
export * from "./matStore";
export * from "./placeStore";
......@@ -17,6 +18,7 @@ import { accountStore } from "./accountStore";
import { cardStore } from "./cardStore";
import { chatStore } from "./chatStore";
import { deckStore } from "./deckStore";
import { historyStore } from "./historyStore";
import { initStore } from "./initStore";
import { matStore } from "./matStore";
import { placeStore } from "./placeStore";
......@@ -47,6 +49,7 @@ export const resetUniverse = () => {
replayStore.reset();
roomStore.reset();
sideStore.reset();
historyStore.reset();
};
// 重置决斗相关的`Store`
......@@ -54,4 +57,5 @@ export const resetDuel = () => {
cardStore.reset();
matStore.reset();
placeStore.reset();
historyStore.reset();
};
......@@ -8,7 +8,9 @@ import { AudioActionType, changeScene } from "@/infra/audio";
import { matStore, SideStage, sideStore } from "@/stores";
import {
ActionHistory,
Alert,
AnnounceModal,
CardListModal,
CardModal,
CheckCounterModal,
......@@ -21,10 +23,7 @@ import {
SortCardModal,
YesNoModal,
} from "./Message";
import { AnnounceModal } from "./Message/AnnounceModal";
import { LifeBar, Mat, Menu, Underlying } from "./PlayMat";
import { ChatBox } from "./PlayMat/ChatBox";
import { HandChain } from "./PlayMat/HandChain";
import { ChatBox, HandChain, LifeBar, Mat, Menu, Underlying } from "./PlayMat";
export const loader: LoaderFunction = async () => {
// 更新场景
......@@ -88,6 +87,7 @@ export const Component: React.FC = () => {
<EndModal />
<ChatBox />
<HandChain />
<ActionHistory />
</>
);
};
......
.root {
:global(.ant-drawer-content-wrapper) {
box-shadow: none;
}
:global(.ant-drawer-content) {
width: 85%;
display: flex;
flex-direction: column;
background: #0b0a0a96;
}
}
.drawer {
width: 100%;
right: 10%;
--height: 40rem;
top: calc((100% - var(--height)) / 2);
height: var(--height) !important;
position: relative;
border-radius: 0.375rem;
:global(.ant-drawer-header) {
padding: 1rem 0;
:global(.ant-drawer-header-title) {
flex-direction: row-reverse;
padding-left: 1.5rem;
}
}
}
.container {
position: relative;
height: 100%;
.timeline {
display: flex;
flex-direction: column;
margin-top: 1rem;
gap: 0.5rem;
.history {
display: flex;
font-family: var(--theme-font);
font-size: 1rem;
.card-container {
display: flex;
flex-direction: row;
align-items: flex-start;
.card {
width: 4rem;
height: auto;
}
.location {
margin-top: 3rem;
margin-left: 1rem;
color: #8484ff;
}
}
.op-container {
display: flex;
flex-direction: column;
margin: 0 0.8rem;
.op-text {
display: flex;
flex: 1;
align-items: flex-end;
justify-content: center;
margin-bottom: 0.2rem;
color: #48faf0;
}
.op-icon {
display: flex;
flex: 1;
width: 3rem;
height: auto;
}
}
.target {
margin-top: 3rem;
color: #f16b3f;
}
}
}
}
import { RightOutlined } from "@ant-design/icons";
import { Drawer } from "antd";
import React from "react";
import { proxy, useSnapshot } from "valtio";
import { fetchStrings, Region, ygopro } from "@/api";
import { useConfig } from "@/config";
import { History, HistoryOp, historyStore } from "@/stores";
import { ScrollableArea, YgoCard } from "@/ui/Shared";
import styles from "./index.module.scss";
const { assetsPath } = useConfig();
const defaultStore = {
isOpen: false,
};
const store = proxy(defaultStore);
export const ActionHistory: React.FC = () => {
const { isOpen } = useSnapshot(store);
const { historys } = useSnapshot(historyStore);
return (
<Drawer
open={isOpen}
placement="right"
rootClassName={styles.root}
className={styles.drawer}
mask={false}
closeIcon={<RightOutlined />}
onClose={() => (store.isOpen = false)}
title="操作历史" // TODO: I18N
>
<ScrollableArea className={styles.container} maxHeight="var(--height)">
<div className={styles.timeline}>
{historys.map((history, idx) => (
<HistoryItem key={idx} {...(history as History)} />
))}
</div>
</ScrollableArea>
</Drawer>
);
};
const HistoryItem: React.FC<History> = ({
card,
currentLocation,
operation,
target,
}) => (
<div className={styles.history}>
<div className={styles["card-container"]}>
<YgoCard className={styles.card} code={card} />
{currentLocation && (
<div className={styles.location}>{`${zone2Text(
currentLocation.zone,
)}`}</div>
)}
</div>
<div className={styles["op-container"]}>
<div className={styles["op-text"]}>{Op2Text(operation)}</div>
{operation === HistoryOp.MOVE ? (
<img src={`${assetsPath}/arrow.svg`} className={styles["op-icon"]} />
) : operation === HistoryOp.EFFECT ? (
<img src={`${assetsPath}/effect.png`} className={styles["op-icon"]} />
) : operation === HistoryOp.TARGETED ? (
<img src={`${assetsPath}/targeted.png`} className={styles["op-icon"]} />
) : operation === HistoryOp.CONFIRMED ? (
<img
src={`${assetsPath}/confirmed.png`}
className={styles["op-icon"]}
/>
) : operation === HistoryOp.ATTACK ? (
<img src={`${assetsPath}/attack.png`} className={styles["op-icon"]} />
) : operation === HistoryOp.SET ? (
<img src={`${assetsPath}/set.png`} className={styles["op-icon"]} />
) : (
<img src={`${assetsPath}/summon.png`} className={styles["op-icon"]} />
)}
</div>
{target && <div className={styles.target}>{`${zone2Text(target)}`}</div>}
</div>
);
function zone2Text(zone: ygopro.CardZone): string {
return fetchStrings(Region.System, zone + 1000);
}
// TODO: I18N
function Op2Text(op: HistoryOp): string {
switch (op) {
case HistoryOp.MOVE:
return "移动";
case HistoryOp.EFFECT:
return fetchStrings(Region.System, 1150);
case HistoryOp.TARGETED:
return "被取对象";
case HistoryOp.CONFIRMED:
return "展示";
case HistoryOp.ATTACK:
return fetchStrings(Region.System, 1157);
case HistoryOp.SUMMON:
return fetchStrings(Region.System, 1151);
case HistoryOp.SP_SUMMON:
return fetchStrings(Region.System, 1152);
case HistoryOp.FLIP_SUMMON:
return fetchStrings(Region.System, 1154);
case HistoryOp.SET:
return fetchStrings(Region.System, 1153);
}
}
export const displayActionHistory = () => (store.isOpen = true);
export * from "./ActionHistory";
export * from "./Alert";
export * from "./AnnounceModal";
export * from "./CardListModal";
export * from "./CardModal";
export * from "./CheckCounterModal";
......
......@@ -2,6 +2,7 @@ import {
ArrowRightOutlined,
CheckOutlined,
CloseCircleFilled,
FileSearchOutlined,
MessageFilled,
RobotFilled,
RobotOutlined,
......@@ -37,6 +38,7 @@ import { useTranslation } from "react-i18next";
import { getUIContainer } from "@/container/compat";
import { displayActionHistory } from "../../Message";
import { clearAllIdleInteractivities, clearSelectInfo } from "../../utils";
import { openChatBox } from "../ChatBox";
......@@ -349,6 +351,13 @@ export const Menu = () => {
type="text"
></Button>
</DropdownWithTitle>
<Tooltip title={i18n("History")}>
<Button
icon={<FileSearchOutlined />}
onClick={displayActionHistory}
type="text"
/>
</Tooltip>
<Tooltip title="AI">
<Button
icon={enableKuriboh ? <RobotFilled /> : <RobotOutlined />}
......
export * from "./ChatBox";
export * from "./HandChain";
export * from "./LifeBar";
export * from "./Mat";
export * from "./Menu";
......
......@@ -184,6 +184,7 @@
},
"Menu": {
"DoYouSurrunder": "是否投降?",
"History": "操作历史",
"Cancel": "取消",
"Confirm": "确定",
"SelectPhase": "请选择要进入的阶段",
......
......@@ -182,6 +182,7 @@
},
"Menu": {
"DoYouSurrunder": "Do you surrender?",
"History": "Operation History",
"Cancel": "Cancel",
"Confirm": "Confirm",
"SelectPhase": "Please select the phase",
......
......@@ -54,7 +54,7 @@ export const YgoCard: React.FC<Props> = (props) => {
{/* {cardName} */}
{targeted ? (
<div className={styles.targeted}>
<img src={`${assetsPath}/targeted.svg`} />
<img src={`${assetsPath}/targeted.png`} />
</div>
) : (
<></>
......
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