根本原因: 專案缺少說明文件;bundle 為單一 910KB 主檔改版快取效率差,且每次開頁 與連續操作都會重複呼叫 Google Sheets API。 影響: 主 chunk 降至 216KB(react/antd 拆出可長期快取,jsrsasign 僅非安全來源 才動態載入);平台清單 sessionStorage 快取 5 分鐘、平台分頁記憶體快取 60 秒;機台圖片懶載入;InputNumber 改用 suffix 消除棄用警告。 修法: - vite.config.ts 加入 manualChunks 拆分 react / antd+dayjs - GoogleSheetService 加入兩層快取 - Image 元件預設 loading="lazy" - SDGame 三個 InputNumber 的 addonAfter 改為 suffix - 新增 README.md(功能、開發、技術備註、部署) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
362 lines
11 KiB
TypeScript
362 lines
11 KiB
TypeScript
import MainControl from "@/Common/MainControl/MainControl";
|
|
import CSMessage from "@/Common/Message/CSMessage";
|
|
import { CoroutineV2 } from "@/Engine/CatanEngine/CoroutineV2/CoroutineV2";
|
|
import { INetResponse } from "@/Engine/CatanEngine/NetManagerV2/Core/INetResponse";
|
|
import { NetConnector } from "@/Engine/CatanEngine/NetManagerV2/NetConnector";
|
|
import { NetManagerSD } from "@/Engine/CatanEngine/NetManagerV2/NetManagerSD";
|
|
import CSSettingsSDV3 from "@/FormTableSD/CSSettingsSDV3";
|
|
import { SlotsetTableRow } from "@/FormTableSD/Tables/SlotsetTable";
|
|
import { gameObj, useGameItems } from "@/context/GameItemsContext";
|
|
import { SDAccountLoginRequest } from "@/define/Request/AccountRequest";
|
|
import { SlotInRequest } from "@/define/Request/SlotRequest";
|
|
import GameManager from "@/modules/GameManager";
|
|
import WheelSelect from "@/components/WheelSelect";
|
|
import { Button, InputNumber, Switch } from "antd";
|
|
import dayjs from "dayjs";
|
|
import React, { useEffect, useState } from "react";
|
|
|
|
const SDGame = (props: ISDGame) => {
|
|
const { gameUrl, onClickSlotOut } = props;
|
|
const { gameData } = useGameItems();
|
|
const { nowSlotId } = gameData;
|
|
const [isOK, setIsOK] = useState<boolean>(false);
|
|
const [isSpin, setIsSpin] = useState<boolean>(false);
|
|
const [betOptions, setBetOptions] = useState<number[]>([]);
|
|
const [bet, setBet] = useState<number>(0);
|
|
const [delay, setDelay] = useState<number>(0.1);
|
|
const [isRatioStop, setIsRatioStop] = useState<boolean>(false);
|
|
const [ratioStop, setRatioStop] = useState<number>(200);
|
|
const [isCountStop, setIsCountStop] = useState<boolean>(false);
|
|
const [countStop, setCountStop] = useState<number>(200);
|
|
const [log, setLog] = useState<string[]>([]);
|
|
let conn: NetConnector = undefined;
|
|
|
|
function* onLoad(): IterableIterator<any> {
|
|
reset();
|
|
const url: URL = new URL(gameUrl);
|
|
const queryParameters: URLSearchParams = new URLSearchParams(url.search);
|
|
const token: string = queryParameters.get("token");
|
|
const slotid: number = +queryParameters.get("slotid");
|
|
let host: string = queryParameters.get("host");
|
|
const port: number = 9005;
|
|
|
|
// NetConnector 對沒帶協定的 host 會用「目前頁面」的協定判斷 ws/wss,
|
|
// bot 頁面是 http 會誤判成 ws://;改依遊戲網址本身的協定,https 就強制 wss
|
|
if (url.protocol === "https:" && !/^https?:\/\//.test(host)) {
|
|
host = `https://${host}`;
|
|
}
|
|
|
|
GameManager.SlotData.SlotId = slotid;
|
|
GameManager.SlotData.IsRatioStop = isRatioStop;
|
|
GameManager.SlotData.RatioStop = ratioStop;
|
|
GameManager.SlotData.IsCountStop = isCountStop;
|
|
GameManager.SlotData.CountStop = countStop;
|
|
conn = new NetConnector(host, port);
|
|
conn.OnDataReceived.AddCallback(onNetDataReceived);
|
|
conn.OnDisconnected.AddCallback(onNetDisconnected);
|
|
loadJSON(slotid);
|
|
setSlotClass();
|
|
NetManagerSD.Initialize(conn);
|
|
console.log("[SDsocket] connecting...");
|
|
AddLog(`連線 SD socket: ${host}:${port}`);
|
|
yield NetManagerSD.ConnectAsync();
|
|
AddLog("SD socket 已連線");
|
|
yield* login(token);
|
|
yield* slotIn();
|
|
}
|
|
|
|
function onClose(): void {
|
|
CoroutineV2.StopCoroutinesBy("Spin");
|
|
try {
|
|
if (conn && conn.IsConnected) {
|
|
conn.Disconnect();
|
|
}
|
|
} catch (e) {
|
|
//
|
|
}
|
|
reset();
|
|
}
|
|
|
|
function reset(): void {
|
|
GameManager.SlotData.SlotId = undefined;
|
|
GameManager.SlotData.SlotClass = undefined;
|
|
GameManager.SlotData.IsSpin = undefined;
|
|
GameManager.SlotData.NowBet = undefined;
|
|
GameManager.SlotData.IsRatioStop = undefined;
|
|
GameManager.SlotData.RatioStop = undefined;
|
|
GameManager.SlotData.IsCountStop = undefined;
|
|
GameManager.SlotData.CountStop = undefined;
|
|
}
|
|
|
|
function* login(token: string): IterableIterator<any> {
|
|
const req = new SDAccountLoginRequest(token);
|
|
yield req.SendAsync(true);
|
|
const resp = req.Result;
|
|
if (!resp.IsValid) {
|
|
CSMessage.NetError(resp.Method, resp.Status, "SD Account Login Fail");
|
|
AddLog(`SD 登入失敗 [${resp.Method}] status:${resp.Status}`);
|
|
return;
|
|
}
|
|
AddLog("SD 登入成功");
|
|
}
|
|
|
|
function* slotIn(): IterableIterator<any> {
|
|
let req: SlotInRequest = new SlotInRequest(GameManager.SlotData.SlotId);
|
|
yield req.SendAsync(true);
|
|
let resp: INetResponse<JSON> = req.Result;
|
|
if (resp.IsValid) {
|
|
const br: number[] = resp.Data["br"];
|
|
const db: number = resp.Data["db"];
|
|
setBetOptions(br);
|
|
setBet(br[db]);
|
|
GameManager.SlotData.NowBet = br[db];
|
|
setIsOK(true);
|
|
AddLog(`機台就緒 押注選項: [${br.join(", ")}]`);
|
|
} else {
|
|
AddLog(`SlotIn 失敗 [${resp.Method}] status:${resp.Status}`);
|
|
}
|
|
}
|
|
|
|
async function loadJSON(slotid: number) {
|
|
let slotset: SlotsetTableRow = CSSettingsSDV3.Slotset[slotid];
|
|
let formName: string[] = slotset.FormName;
|
|
let parallel: Promise<void>[] = [];
|
|
for (let i: number = 0; i < formName.length; i++) {
|
|
await MainControl.DownloadFormSetting(formName[i]);
|
|
}
|
|
// set Form
|
|
await Promise.all(parallel);
|
|
}
|
|
|
|
async function setSlotClass() {
|
|
let slot: any;
|
|
const slotGroup: typeof import("../../define/Game/Base/Slot") = await import(/* @vite-ignore */`../../define/Game/Base/Slot`);
|
|
try {
|
|
slot = slotGroup[`Slot${GameManager.SlotData.SlotId}`];
|
|
} catch (error) {
|
|
//
|
|
}
|
|
if (!slot) {
|
|
slot = slotGroup.SlotBase;
|
|
}
|
|
GameManager.SlotData.SlotClass = new slot(GameManager.SlotData.SlotId, AddLog);
|
|
}
|
|
|
|
async function OnClickSpin() {
|
|
GameManager.SlotData.IsSpin = true;
|
|
setIsSpin(true);
|
|
CoroutineV2.Single(spin()).Start("Spin");
|
|
}
|
|
|
|
function OnClickStop() {
|
|
GameManager.SlotData.IsSpin = false;
|
|
setIsSpin(false);
|
|
}
|
|
|
|
function* spin(): IterableIterator<any> {
|
|
const { player } = gameObj;
|
|
const { m: money } = player;
|
|
if (money < GameManager.SlotData.NowBet) {
|
|
noMoney();
|
|
OnClickStop();
|
|
return;
|
|
}
|
|
if (isCountStop) {
|
|
if (GameManager.SlotData.CountStop <= 0) {
|
|
OnClickStop();
|
|
return;
|
|
}
|
|
GameManager.SlotData.CountStop--;
|
|
setCountStop((v) => v - 1);
|
|
}
|
|
yield* GameManager.SlotData.SlotClass.Spin(bet, OnClickStop);
|
|
yield CoroutineV2.WaitTime(delay);
|
|
if (GameManager.SlotData.IsSpin) {
|
|
yield* spin();
|
|
} else {
|
|
GameManager.SlotData.IsSpin = false;
|
|
setIsSpin(false);
|
|
}
|
|
}
|
|
|
|
function noMoney(): void {
|
|
const { player } = gameObj;
|
|
const { m: money } = player;
|
|
GameManager.SlotData.IsSpin = false;
|
|
AddLog(`金額不足: ${money}`);
|
|
}
|
|
|
|
function AddLog(s: string) {
|
|
const todayTimeStr: string = dayjs().format("YYYY/MM/DD HH:mm:ss");
|
|
setLog((v) => {
|
|
const next: string[] = [`${todayTimeStr} ${s}`, ...v];
|
|
return next.length > 100 ? next.slice(0, 100) : next;
|
|
});
|
|
}
|
|
|
|
/**RPC回傳.若協定錯誤斷線.原因也會在這裡收到 */
|
|
function onNetDataReceived(resp: INetResponse<any>) {
|
|
// MainControl.DataReceivedEvent.DispatchCallback([MainControl.DataType.ServerData, resp]);
|
|
}
|
|
|
|
/**只要連線中斷不管主被動都會走到這裡 */
|
|
function onNetDisconnected() {
|
|
console.warn("[socket] Disconnected");
|
|
conn.OnDataReceived.RemoveAllCallbacks();
|
|
// MainControl.DataReceivedEvent.DispatchCallback([MainControl.DataType.NetDisconnected]);
|
|
}
|
|
|
|
useEffect(() => {
|
|
CoroutineV2.Single(onLoad()).Start();
|
|
|
|
return onClose;
|
|
}, []);
|
|
|
|
return (
|
|
<div style={layoutStyle} className="lp-rise">
|
|
{/* 左側:控制面板 */}
|
|
<div className="lp-panel lp-corners" style={controlPanelStyle}>
|
|
<div style={{ marginBottom: 18 }}>
|
|
<div className="lp-kicker" style={{ marginBottom: 6 }}>SD Machine</div>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
|
<h2 className="lp-title" style={{ fontSize: 24 }}>SLOT <span className="lp-mono" style={{ color: "var(--lp-amber)" }}>#{nowSlotId}</span></h2>
|
|
<span className={`lp-dot ${isSpin ? "lp-dot--red" : isOK ? "" : "lp-dot--amber"}`} />
|
|
<span className="lp-label">{isSpin ? "spinning" : isOK ? "ready" : "loading"}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{isOK && <>
|
|
<div style={rowStyle}>
|
|
<span className="lp-label" style={rowLabelStyle}>押注</span>
|
|
<WheelSelect
|
|
wrapperStyle={{ flex: 1 }}
|
|
style={{ width: "100%" }}
|
|
value={bet}
|
|
disabled={isSpin}
|
|
options={betOptions.map((v: number) => ({ value: v, label: v.toLocaleString() }))}
|
|
onChange={(v: number) => { setBet(v); GameManager.SlotData.NowBet = v; }}
|
|
/>
|
|
</div>
|
|
|
|
<div style={rowStyle}>
|
|
<span className="lp-label" style={rowLabelStyle}>延遲</span>
|
|
<InputNumber
|
|
style={{ flex: 1 }}
|
|
min={0}
|
|
step={0.1}
|
|
value={delay}
|
|
onChange={(v: number) => setDelay(+v || 0)}
|
|
suffix="秒"
|
|
/>
|
|
</div>
|
|
|
|
<div style={rowStyle}>
|
|
<span className="lp-label" style={rowLabelStyle}>倍率停</span>
|
|
<Switch
|
|
checked={isRatioStop}
|
|
onChange={(checked: boolean) => { setIsRatioStop(checked); GameManager.SlotData.IsRatioStop = checked; }}
|
|
/>
|
|
{isRatioStop &&
|
|
<InputNumber
|
|
style={{ flex: 1 }}
|
|
min={0}
|
|
value={ratioStop}
|
|
onChange={(v: number) => { const n = +v || 0; setRatioStop(n); GameManager.SlotData.RatioStop = n; }}
|
|
suffix="倍"
|
|
/>
|
|
}
|
|
</div>
|
|
|
|
<div style={rowStyle}>
|
|
<span className="lp-label" style={rowLabelStyle}>轉數停</span>
|
|
<Switch
|
|
checked={isCountStop}
|
|
onChange={(checked: boolean) => { setIsCountStop(checked); GameManager.SlotData.IsCountStop = checked; }}
|
|
/>
|
|
{isCountStop &&
|
|
<InputNumber
|
|
style={{ flex: 1 }}
|
|
min={0}
|
|
value={countStop}
|
|
onChange={(v: number) => { const n = +v || 0; setCountStop(n); GameManager.SlotData.CountStop = n; }}
|
|
suffix="轉"
|
|
/>
|
|
}
|
|
</div>
|
|
|
|
<div style={{ marginTop: "auto", paddingTop: 20 }}>
|
|
{isSpin
|
|
? <button className="lp-spin-btn lp-spin-btn--stop" onClick={OnClickStop}>STOP</button>
|
|
: <button className="lp-spin-btn" onClick={OnClickSpin}>SPIN</button>
|
|
}
|
|
<Button danger block onClick={onClickSlotOut} style={{ marginTop: 12 }}>
|
|
離開機台
|
|
</Button>
|
|
</div>
|
|
</>}
|
|
|
|
{!isOK &&
|
|
<div style={{ marginTop: "auto" }}>
|
|
<Button danger block onClick={onClickSlotOut}>離開機台</Button>
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
{/* 右側:Log 終端 */}
|
|
<div style={logPanelStyle}>
|
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 8 }}>
|
|
<span className="lp-kicker">Log</span>
|
|
<span className="lp-label">{log.length} 筆</span>
|
|
</div>
|
|
<div className="lp-log">
|
|
{log.length === 0 && <p className="lp-cursor" style={{ color: "var(--lp-text-dim)" }}>等待紀錄</p>}
|
|
{log.map((s: string, index: number) => <p key={index}>{s}</p>)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default SDGame;
|
|
|
|
interface ISDGame {
|
|
gameUrl: string;
|
|
onClickSlotOut: () => void;
|
|
}
|
|
|
|
const layoutStyle: React.CSSProperties = {
|
|
flex: 1,
|
|
minHeight: 0,
|
|
display: "flex",
|
|
gap: 16,
|
|
alignItems: "stretch",
|
|
};
|
|
|
|
const controlPanelStyle: React.CSSProperties = {
|
|
width: 340,
|
|
flexShrink: 0,
|
|
padding: "24px 24px 20px",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
overflowY: "auto",
|
|
};
|
|
|
|
const rowStyle: React.CSSProperties = {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 12,
|
|
marginBottom: 16,
|
|
};
|
|
|
|
const rowLabelStyle: React.CSSProperties = {
|
|
width: 52,
|
|
flexShrink: 0,
|
|
};
|
|
|
|
const logPanelStyle: React.CSSProperties = {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
};
|