diff --git a/platform_web/acceptance/fixtures/scum-map.ts b/platform_web/acceptance/fixtures/scum-map.ts new file mode 100644 index 0000000..2eed9c6 --- /dev/null +++ b/platform_web/acceptance/fixtures/scum-map.ts @@ -0,0 +1,48 @@ +import type { ScumSurfaceResponse, ScumUserTrajectoryResponse } from "../../api/types"; +import type { PluginBridgeHostContext, PluginPageContract } from "../../contracts/pluginBridge"; +import type { PluginPageWorkspaceActions } from "../../contracts/pluginPageHost"; + +export const scumMapPage: PluginPageContract = { + key: "live-map", title: "实时地图", path: "/live-map", bundleKey: "scum-server-plugin", bundleVersion: "1.0.3", + bundleIntegritySha256: "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e" +}; + +export const scumMapContext: PluginBridgeHostContext = { + pluginId: "game.scum", routeKey: "live-map", serverInstanceId: "map-test", permissions: ["server.read"], bridgeActions: [], aiPurposes: [], + themeTokens: { colorScheme: "dark", accentColor: "#4ECDC4" }, + pageQuery: { focus: "mira", from: "2026-09-19T00:00:00Z", to: "2026-09-19T00:10:00Z" } +}; + +// Deterministic fixture only: no server, Run channel, database or authenticated API is called. +export function createScumMapFixture(): { surface: ScumSurfaceResponse; actions: PluginPageWorkspaceActions } { + const sample = (steamId: string, displayName: string, minute: number, x: number, y: number, riding = false): ScumUserTrajectoryResponse => ({ + id: `${steamId}-${minute}`, serverInstanceId: "map-test", scumUserId: `user-${steamId}`, subjectType: "player", subjectId: steamId, steamId, displayName, + x, y, z: 0, sampledAt: `2026-09-19T00:0${minute}:00Z`, ...(riding ? { riddenVehicleId: "vehicle-1", gameVehicleId: "car-1" } : {}) + }); + const surface: ScumSurfaceResponse = { + users: { count: 2, items: [ + { id: "user-mira", serverInstanceId: "map-test", steamId: "mira", displayName: "Mira", online: true, x: 0, y: 0, z: 0, riddenVehicleId: "vehicle-1", gameVehicleId: "car-1" }, + { id: "user-nova", serverInstanceId: "map-test", steamId: "nova", displayName: "Nova", online: true, x: 1000, y: 1000, z: 0 } + ] }, + vehicles: { count: 1, items: [{ id: "vehicle-1", vehicleId: "vehicle-1", gameVehicleId: "car-1", serverInstanceId: "map-test", label: "Laika", className: "BPC_Laika_C", exists: true, functional: true, locked: true, x: 40000, y: 40000, z: 0 }] }, + userTrajectories: { count: 6, items: [sample("mira", "Mira", 0, 0, 0), sample("mira", "Mira", 4, -250000, -250000, true), sample("mira", "Mira", 8, -550000, -550000, true), sample("nova", "Nova", 0, 1000, 1000), sample("nova", "Nova", 4, 250000, 250000), sample("nova", "Nova", 8, 600000, 600000)] }, + vehicleTrajectories: { count: 0, items: [] }, + vehicleLocks: { count: 1, items: [{ id: "lock-1", serverInstanceId: "map-test", scumVehicleId: "vehicle-1", gameVehicleId: "car-1", scumUserId: "user-mira", steamId: "mira", lockedAt: "2026-09-19T00:00:00Z" }] } + }; + const collections: Record[]> = { + scum_squads: [{ squadId: "wolves", name: "Wolves" }], + scum_squad_members: [{ squadId: "wolves", steamId: "mira" }, { squadId: "wolves", steamId: "nova" }], + scum_flags: [{ flagId: "flag-1", name: "Wolves Flag", ownerPlayerId: "mira", ownerSquadId: "wolves", x: -40000, y: 40000, z: 0 }] + }; + const actions: PluginPageWorkspaceActions = { + pluginData: { + list: async (collection) => ({ items: (collections[collection] ?? []).map((value, index) => ({ key: `${collection}-${index}`, value })) }), + put: async () => undefined, delete: async () => undefined, transact: async () => undefined + }, + scum: { + surface: async () => surface, users: async () => surface.users, vehicles: async () => surface.vehicles, + userTrajectories: async () => surface.userTrajectories, vehicleTrajectories: async () => surface.vehicleTrajectories, vehicleLocks: async () => surface.vehicleLocks + } + }; + return { surface, actions }; +} diff --git a/platform_web/acceptance/scum-map-preview.html b/platform_web/acceptance/scum-map-preview.html new file mode 100644 index 0000000..7ec1729 --- /dev/null +++ b/platform_web/acceptance/scum-map-preview.html @@ -0,0 +1 @@ +SCUM 地图交互验证
diff --git a/platform_web/acceptance/scum-map-preview.tsx b/platform_web/acceptance/scum-map-preview.tsx new file mode 100644 index 0000000..fc0f630 --- /dev/null +++ b/platform_web/acceptance/scum-map-preview.tsx @@ -0,0 +1,12 @@ +import * as React from "react"; +import { createRoot } from "react-dom/client"; +import { createScumMapFixture, scumMapContext, scumMapPage } from "./fixtures/scum-map"; +import { loadPluginPageBundle } from "../utils/pluginPageBundles"; +import { applyThemePalette } from "../theme/tokens"; +import "../theme/base.css"; + +globalThis.__PLUGIN_PAGE_REACT__ = React; +applyThemePalette("mecha-black"); +const MapPage = await loadPluginPageBundle(scumMapPage); +const { actions } = createScumMapFixture(); +createRoot(document.getElementById("root")!).render(

SCUM 地图交互验证

固定测试数据,用于检查回放、缩放和详情。

); diff --git a/platform_web/theme/base.css b/platform_web/theme/base.css index 23e5b6b..d8921d3 100644 --- a/platform_web/theme/base.css +++ b/platform_web/theme/base.css @@ -460,8 +460,10 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))} .scum-catalog-meta .provider-id{font-size:10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .icon-command.is-active{border-color:var(--accent);color:var(--ink);box-shadow:0 0 0 1px color-mix(in srgb,var(--accent) 46%,transparent)} .scum-workbench{padding:12px;overflow:visible}.scum-workbench>.console-record-list{gap:8px}.scum-filter-bar{margin:0;padding:0 0 10px;border-bottom:1px solid color-mix(in srgb,var(--line) 68%,transparent)}.scum-filter-bar .console-field{flex:0 1 210px}.scum-filter-bar .console-field:first-child{flex:1 1 320px}.scum-filter-bar .page-status{margin-left:auto}.scum-layer-toggle{display:inline-flex;align-items:center;gap:5px;flex:0 0 auto;min-height:28px;padding:0 8px;border:1px solid var(--line);border-radius:999px;background:var(--control-surface);color:var(--ink-soft);font-size:11px;font-weight:760}.scum-filter-bar .scum-layer-toggle{min-height:26px;padding:0 8px;gap:5px;font-size:11px;white-space:nowrap}.scum-filter-bar .scum-layer-toggle input{flex:0 0 auto;width:14px;height:14px;min-width:0;min-height:0;margin:0;accent-color:var(--accent)}.scum-layer-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;flex:0 0 auto}.scum-map-summary{margin-left:auto;white-space:nowrap}.scum-table-wrap{border-color:color-mix(in srgb,var(--line) 72%,transparent);background:linear-gradient(180deg,color-mix(in srgb,var(--surface-solid) 84%,transparent),color-mix(in srgb,var(--surface) 66%,transparent));-webkit-backdrop-filter:blur(8px) saturate(.92);backdrop-filter:blur(8px) saturate(.92);box-shadow:inset 0 1px 0 color-mix(in srgb,var(--crystal-rim) 46%,transparent)}.scum-table-wrap::before,.scum-table-wrap::after{content:none}.scum-user-table{min-width:1080px;table-layout:fixed}.scum-user-table caption{caption-side:top;padding:0 0 8px;text-align:left;color:var(--ink-faint);font-size:12px;font-weight:800}.scum-user-table th,.scum-user-table td{height:38px;padding:6px 10px;border-bottom-color:color-mix(in srgb,var(--line) 64%,transparent);white-space:nowrap;font-size:13px;line-height:1.2}.scum-user-table th{background:color-mix(in srgb,var(--accent-soft) 52%,transparent);color:color-mix(in srgb,var(--ink) 70%,var(--accent-deep))}.scum-user-table tbody tr:hover{background:color-mix(in srgb,var(--accent-soft) 24%,transparent)}.scum-user-table th:nth-child(1),.scum-user-table td:nth-child(1){width:150px}.scum-user-table th:nth-child(2),.scum-user-table td:nth-child(2){width:170px}.scum-user-table th:nth-child(3),.scum-user-table td:nth-child(3){width:76px}.scum-user-table th:nth-child(4),.scum-user-table td:nth-child(4){width:116px}.scum-user-table th:nth-child(5),.scum-user-table td:nth-child(5){width:108px}.scum-user-table th:nth-child(6),.scum-user-table td:nth-child(6){width:108px}.scum-user-table th:nth-child(7),.scum-user-table td:nth-child(7){width:156px}.scum-user-table th:last-child,.scum-user-table td:last-child{width:76px;text-align:right}.scum-user-table td>*{max-width:100%;overflow:hidden;text-overflow:ellipsis}.scum-user-table td.provider-actions-cell{padding-right:8px;overflow:visible}.scum-user-table td.provider-actions-cell>*{overflow:visible}.scum-mono-cell{font-family:var(--font-mono);font-size:12px}.scum-user-table .icon-command{min-height:28px;padding:0 9px}.inline-action-control{position:relative;display:inline-block}.inline-action-control>summary{list-style:none}.inline-action-control>summary::-webkit-details-marker{display:none}.inline-action-control[open]>summary{border-color:var(--accent);color:var(--ink)}.inline-action-control .inline-action-menu{right:0} +.gift-definitions-surface{display:grid;gap:12px;min-width:0}.gift-toolbar{margin:0;padding:0 0 10px;border-bottom:1px solid color-mix(in srgb,var(--line) 68%,transparent)}.gift-toolbar .console-field{flex:0 1 190px}.gift-toolbar .console-field:first-child{flex:1 1 320px}.gift-toolbar .page-status{margin-left:auto}.gift-create-command{width:auto;min-width:112px}.gift-table-wrap{min-width:0}.scum-gift-table{min-width:980px;table-layout:fixed}.scum-gift-table th,.scum-gift-table td{height:54px;padding:9px 10px;border-bottom-color:color-mix(in srgb,var(--line) 64%,transparent);vertical-align:middle;font-size:12.5px}.scum-gift-table th{background:color-mix(in srgb,var(--accent-soft) 52%,transparent);color:color-mix(in srgb,var(--ink) 70%,var(--accent-deep));white-space:nowrap}.scum-gift-table tbody tr:hover{background:color-mix(in srgb,var(--accent-soft) 24%,transparent)}.scum-gift-table th:nth-child(1),.scum-gift-table td:nth-child(1){width:170px}.scum-gift-table th:nth-child(2),.scum-gift-table td:nth-child(2){width:122px}.scum-gift-table th:nth-child(3),.scum-gift-table td:nth-child(3){width:290px}.scum-gift-table th:nth-child(4),.scum-gift-table td:nth-child(4){width:120px}.scum-gift-table th:nth-child(5),.scum-gift-table td:nth-child(5){width:86px}.scum-gift-table th:nth-child(6),.scum-gift-table td:nth-child(6){width:148px}.scum-gift-table th:last-child,.scum-gift-table td:last-child{width:142px;text-align:right}.scum-gift-table td>*{max-width:100%;overflow-wrap:anywhere}.scum-gift-table td.provider-actions-cell{padding-right:8px;overflow:visible}.scum-gift-table td.provider-actions-cell>*{overflow:visible}.scum-gift-code{font-family:var(--font-mono);font-size:11px}.scum-gift-content-cell{display:flex;align-items:center;gap:8px;min-width:0}.scum-gift-content-cell>span:last-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.scum-gift-table-actions{display:inline-flex;align-items:center;justify-content:flex-end;gap:6px}.scum-gift-table-actions .icon-command{min-height:30px;padding:0 9px;white-space:nowrap}.gift-form-wide{grid-column:1/-1}.gift-form-section{display:grid;gap:9px;padding:11px;border:1px solid var(--line);border-radius:8px;background:color-mix(in srgb,var(--surface-solid) 64%,transparent);box-shadow:inset 0 1px 0 var(--crystal-rim)}.gift-form-section-title{display:flex;align-items:baseline;justify-content:space-between;gap:10px;flex-wrap:wrap}.gift-form-section-title strong{color:var(--ink);font-size:13px}.gift-form-section-title span{color:var(--ink-faint);font-size:11px}.gift-dialog-heading{display:grid;gap:3px;min-width:0}.gift-dialog-heading h2{margin:0;color:var(--ink);font-size:18px}.gift-dialog-actions{grid-column:1/-1}.gift-dialog-actions .confirm-primary{min-width:128px}.gift-catalog-reference{margin-top:2px} :root[data-custom-background=true] .scum-workbench,:root[data-custom-background=true] .scum-table-wrap{background:linear-gradient(180deg,rgba(5,10,16,.76),rgba(5,10,16,.64));border-color:color-mix(in srgb,var(--line-strong) 54%,rgba(255,255,255,.18))}:root[data-custom-background=true][data-theme-palette=magical-girl] .scum-workbench,:root[data-custom-background=true][data-theme-palette=magical-girl] .scum-table-wrap{background:linear-gradient(180deg,rgba(18,19,26,.74),rgba(13,15,22,.64));border-color:rgba(250,240,249,.34)} @media (max-width:760px){.scum-filter-bar .page-status{margin-left:0}.scum-user-table{min-width:1040px}} +@media (max-width:760px){.scum-map-bar>.console-field,.scum-map-bar>.scum-map-menu-field,.scum-map-bar>.map-playback-bar{flex:1 1 100%;min-width:0}.map-playback-summary{white-space:normal}} .provider-form input,.provider-form select,.provider-form textarea{width:100%;min-height:38px;border:1px solid var(--line-strong);border-radius:8px;padding:0 10px;background:var(--surface-solid);color:var(--ink);font:inherit} .provider-form textarea{min-height:120px;padding:10px;resize:vertical;font-family:var(--font-mono);font-size:13px} .provider-form input:focus,.provider-form select:focus,.provider-form textarea:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)} @@ -779,29 +781,38 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))} .map-viewport:active{cursor:grabbing} .map-scene{position:absolute;top:50%;left:50%;height:100%;aspect-ratio:1;transform-origin:center;will-change:transform} .map-scene-image{position:absolute;inset:0;background-repeat:no-repeat;background-size:100% 100%;background-position:center} -.map-track-layer{position:absolute;inset:0;z-index:2;width:100%;height:100%;pointer-events:none}.map-grid-overlay{position:absolute;inset:0;z-index:1;pointer-events:none}.map-grid-line{position:absolute;background:color-mix(in srgb,var(--line) 62%,transparent)}.map-grid-line-v{top:0;bottom:0;width:1px}.map-grid-line-h{left:0;right:0;height:1px}.map-grid-label{position:absolute;transform:translate(-50%,-50%);padding:1px 5px;border:1px solid color-mix(in srgb,var(--line) 70%,transparent);border-radius:999px;background:color-mix(in srgb,var(--surface-solid) 72%,transparent);color:var(--ink);font:800 10px/1 var(--font-mono);text-shadow:0 1px 4px rgba(0,0,0,.55)}.map-grid-col-label{top:10px}.map-grid-row-label{left:12px}.map-surface-dot{position:absolute;z-index:3;width:9px;height:9px;padding:0;border:0;border-radius:999px;background:var(--accent);box-shadow:0 0 16px color-mix(in srgb,var(--accent) 80%,transparent);transform:translate(-50%,-50%) scale(var(--map-inverse,1));cursor:pointer}.map-surface-dot img{display:block;width:100%;height:100%;object-fit:contain;filter:drop-shadow(0 0 8px color-mix(in srgb,var(--accent) 76%,transparent))}.map-surface-dot.map-layer-vehicles{width:26px;height:26px;background:color-mix(in srgb,var(--surface-solid) 72%,transparent)}.map-surface-dot.map-layer-flags{background:var(--gold)}.map-surface-dot.map-layer-regions{background:var(--success)}.map-surface-dot-riding{outline:2px solid var(--gold);box-shadow:0 0 0 4px color-mix(in srgb,var(--gold) 24%,transparent),0 0 18px color-mix(in srgb,var(--gold) 80%,transparent)}.map-view-controls{position:absolute;top:10px;right:10px;z-index:6;display:flex;align-items:center;gap:4px;padding:4px;border:1px solid color-mix(in srgb,var(--line) 72%,transparent);border-radius:999px;background:color-mix(in srgb,var(--surface-solid) 82%,transparent);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)} +.map-track-layer{position:absolute;inset:0;z-index:2;width:100%;height:100%;pointer-events:none}.map-grid-overlay{position:absolute;inset:0;z-index:1;pointer-events:none}.map-grid-line{position:absolute;background:color-mix(in srgb,var(--line) 62%,transparent)}.map-grid-line-v{top:0;bottom:0;width:1px}.map-grid-line-h{left:0;right:0;height:1px}.map-grid-label{position:absolute;transform:translate(-50%,-50%);padding:1px 5px;border:1px solid color-mix(in srgb,var(--line) 70%,transparent);border-radius:999px;background:color-mix(in srgb,var(--surface-solid) 72%,transparent);color:var(--ink);font:800 10px/1 var(--font-mono);text-shadow:0 1px 4px rgba(0,0,0,.55)}.map-grid-col-label{top:10px}.map-grid-row-label{left:12px}.map-surface-dot{position:absolute;z-index:3;width:9px;height:9px;padding:0;border:0;border-radius:999px;background:var(--accent);box-shadow:0 0 0 1px var(--surface-solid);transform:translate(-50%,-50%) scale(var(--map-inverse,1));cursor:pointer}.map-surface-dot img{display:block;width:100%;height:100%;object-fit:contain}.map-surface-dot.map-layer-vehicles{width:20px;height:20px;background:color-mix(in srgb,var(--surface-solid) 72%,transparent)}.map-surface-dot.map-layer-flags{background:var(--gold)}.map-surface-dot.map-layer-regions{background:var(--success)}.map-surface-dot-riding{outline:2px solid var(--gold);box-shadow:0 0 0 2px color-mix(in srgb,var(--gold) 24%,transparent)}.map-view-controls{position:absolute;top:10px;right:10px;z-index:6;display:flex;align-items:center;gap:4px;padding:4px;border:1px solid color-mix(in srgb,var(--line) 72%,transparent);border-radius:999px;background:color-mix(in srgb,var(--surface-solid) 82%,transparent);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)} .map-view-controls .icon-command{min-height:24px;padding:0 8px;font-size:11px} .map-zoom-badge{padding:0 6px;color:var(--ink-faint);font:800 10px/1 var(--font-mono)} -.map-track-endpoint{position:absolute;z-index:4;display:grid;place-items:center;width:18px;height:18px;border:1px solid var(--accent);border-radius:999px;background:color-mix(in srgb,var(--surface-solid) 84%,transparent);color:var(--ink);font:800 10px/1 var(--font-mono);transform:translate(-50%,-50%) scale(var(--map-inverse,1));pointer-events:none} -.map-track-endpoint.map-layer-vehicles{border-color:var(--gold)} -.map-ride-marker{position:absolute;z-index:4;display:grid;place-items:center;width:22px;height:22px;border:1px solid color-mix(in srgb,var(--gold) 70%,var(--line));border-radius:999px;background:color-mix(in srgb,var(--surface-solid) 76%,transparent);color:var(--gold);font:800 9px/1 var(--font-mono);transform:translate(-50%,-50%) scale(var(--map-inverse,1));pointer-events:none} -.map-ride-marker img{width:100%;height:100%;object-fit:contain} -.map-track-live{position:absolute;z-index:5;display:flex;align-items:center;gap:4px;transform:translate(-50%,-50%) scale(var(--map-inverse,1));pointer-events:none} +.map-track-live{position:absolute;z-index:5;display:grid;place-items:center;width:16px;height:16px;padding:0;border:0;background:transparent;color:inherit;transform:translate(-50%,-50%) scale(var(--map-inverse,1));pointer-events:auto;cursor:pointer} .map-track-live-dot{width:11px;height:11px;border:2px solid var(--surface-solid);border-radius:999px;background:var(--track-color,var(--gold));box-shadow:0 0 0 2px color-mix(in srgb,var(--track-color,var(--gold)) 42%,transparent)} -.map-track-live.is-playing .map-track-live-dot{width:16px;height:16px} -.map-track-live-icon{width:20px;height:20px;object-fit:contain;filter:drop-shadow(0 0 4px color-mix(in srgb,var(--track-color,var(--gold)) 70%,transparent))} -.map-track-live.is-playing .map-track-live-icon{width:27px;height:27px} -.map-track-live-label{max-width:104px;overflow:hidden;padding:1px 6px;border-radius:999px;background:color-mix(in srgb,var(--surface-solid) 82%,transparent);color:var(--track-color,var(--gold));font-size:10px;white-space:nowrap;text-overflow:ellipsis} -.map-hover-card{position:absolute;z-index:9;display:grid;gap:3px;min-width:168px;max-width:262px;padding:7px 9px;border:1px solid color-mix(in srgb,var(--accent) 55%,var(--line));border-radius:10px;background:color-mix(in srgb,var(--surface-solid) 94%,transparent);box-shadow:0 10px 26px color-mix(in srgb,#000 46%,transparent);transform:translate(-50%,calc(-100% - 12px)) scale(var(--map-inverse,1));transform-origin:50% 100%;pointer-events:none} +.map-track-live.is-active .map-track-live-dot{width:16px;height:16px} +.map-track-live-icon{width:20px;height:20px;object-fit:contain} +.map-track-live.is-active .map-track-live-icon{width:26px;height:26px} +.map-track-live-riding.is-active{width:26px;height:26px} +.map-track-live:focus-visible{outline:2px solid var(--accent);outline-offset:4px} +.map-surface-dot-riding{width:20px;height:20px} +.map-track-live-label{position:absolute;left:calc(100% + 4px);top:50%;transform:translateY(-50%);max-width:104px;overflow:hidden;padding:1px 6px;border-radius:999px;background:color-mix(in srgb,var(--surface-solid) 82%,transparent);color:var(--track-color,var(--gold));font-size:10px;white-space:nowrap;text-overflow:ellipsis} +.map-hover-card{position:absolute;z-index:9;display:grid;gap:3px;min-width:168px;max-width:262px;padding:7px 9px;border:1px solid color-mix(in srgb,var(--accent) 55%,var(--line));border-radius:8px;background:color-mix(in srgb,var(--surface-solid) 94%,transparent);box-shadow:0 10px 26px color-mix(in srgb,#000 46%,transparent);transform:scale(var(--map-inverse,1)) translate(-50%,calc(-100% - 12px));transform-origin:0 0;pointer-events:none} .map-hover-title{color:var(--ink);font-size:11px} .map-hover-row{display:grid;grid-template-columns:56px 1fr;gap:6px;align-items:baseline} .map-hover-row small{color:var(--ink-faint);font-size:10px} .map-hover-row b{color:var(--ink-soft);font-size:11px;font-weight:600;word-break:break-all} .map-track-swatch{display:inline-block;width:10px;height:10px;margin-right:6px;border-radius:999px;vertical-align:middle} -.map-playback-bar{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap} +.map-playback-bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.scum-map-bar>.map-playback-bar{flex:1 1 520px;min-width:320px} +.map-playback-time{color:var(--ink-soft);font-size:12px} +.map-playback-time{white-space:nowrap} +.map-playback-progress{flex:0 1 160px;width:90px;min-width:70px;height:5px;accent-color:var(--accent)} +.map-playback-bar>.primary-command{width:auto;min-width:112px;flex:0 0 auto;white-space:nowrap} +.map-playback-summary{margin-left:0;min-width:0;flex:0 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.map-playback-bar>.scum-map-menu{margin-left:auto} .map-playback-bar .page-status{margin:0} .map-playback-bar .scum-map-menu-body{left:auto;right:0} .scum-map-menu{position:relative;flex:0 0 auto} +.scum-map-bar>.console-field,.scum-map-bar>.scum-map-menu-field{flex:0 1 210px;min-width:150px} +.scum-map-filter-menu{display:block;min-width:0} +.scum-map-menu.scum-map-filter-menu>summary{box-sizing:border-box;width:100%;min-height:36px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .scum-map-menu>summary{display:inline-flex;align-items:center;min-height:26px;padding:0 9px;font-size:11px;list-style:none;cursor:pointer} .scum-map-menu>summary::-webkit-details-marker{display:none} .scum-map-menu[open]>summary{border-color:var(--accent);color:var(--ink)} diff --git a/platform_web/utils/scumMapPlayback.test.tsx b/platform_web/utils/scumMapPlayback.test.tsx new file mode 100644 index 0000000..4dfd15a --- /dev/null +++ b/platform_web/utils/scumMapPlayback.test.tsx @@ -0,0 +1,180 @@ +/** @vitest-environment jsdom */ + +import * as React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createScumMapFixture, scumMapContext, scumMapPage } from "../acceptance/fixtures/scum-map"; +import { loadPluginPageBundle } from "./pluginPageBundles"; + +let root: Root; +let container: HTMLDivElement; +const MapPage = await loadPluginPageBundle(scumMapPage); +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-19T00:10:00Z")); + globalThis.__PLUGIN_PAGE_REACT__ = React; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +async function mount(fixture = createScumMapFixture(), context = scumMapContext) { + const loadSurface = vi.spyOn(fixture.actions.scum!, "surface"); + const list = vi.spyOn(fixture.actions.pluginData!, "list"); + await act(async () => root.render()); + return { ...fixture, loadSurface, list }; +} + +function button(label: string) { + const result = [...container.querySelectorAll("button")].find((node) => node.getAttribute("aria-label") === label || node.textContent === label); + expect(result, `button ${label}`).toBeDefined(); + return result!; +} + +async function click(node: HTMLElement) { await act(async () => node.click()); } +async function tick(ms: number) { await act(async () => vi.advanceTimersByTime(ms)); } +function zoom() { return Number(container.querySelector(".map-zoom-badge")?.textContent?.replace("×", "")); } + +describe("SCUM map through the platform bundle host", () => { + it("starts from the first sample, follows one player, preserves paused markers, and resets for a new selection", async () => { + const { loadSurface, list } = await mount(); + await click(button("播放轨迹回放")); + expect(zoom()).toBe(8); + expect(container.querySelectorAll(".map-track-live")).toHaveLength(1); + expect(container.querySelector(".map-track-live-riding")).toBeNull(); + const initialTransform = container.querySelector(".map-scene")?.getAttribute("style"); + await tick(6000); + expect(container.querySelector(".map-scene")?.getAttribute("style")).not.toBe(initialTransform); + await click(button("暂停轨迹回放")); + const marker = container.querySelector(".map-track-live")!; + expect(marker.classList.contains("is-active")).toBe(true); + await act(async () => marker.focus()); + expect(container.querySelector('[role="tooltip"]')?.textContent).toContain("Mira"); + expect(container.querySelector('[role="tooltip"]')?.textContent).not.toContain("乘坐Laika"); + await click(marker); + expect(container.querySelector(".map-point-strip")?.textContent).toContain("Mira"); + await click(button("全选")); + expect(container.querySelector(".map-playback-time")?.textContent).toContain("选择用户与时间后开始回放"); + await click(button("播放轨迹回放")); + expect(container.querySelectorAll(".map-track-live")).toHaveLength(2); + expect(zoom()).toBe(8); + expect(loadSurface).toHaveBeenCalledTimes(1); + expect(list).toHaveBeenCalledTimes(7); + }); + + it("fits moving users together and changes ride icons at the recorded sample time", async () => { + await mount(); + await click(button("全选")); + await click(button("播放轨迹回放")); + await tick(12000); + expect(zoom()).toBeLessThan(8); + expect(container.querySelector(".map-track-live-riding")).toBeNull(); + await tick(4000); + const riding = container.querySelector(".map-track-live-riding")!; + expect(riding.getAttribute("aria-label")).toContain("Laika"); + expect(riding.querySelector("img")?.getAttribute("src")).toContain("ico_"); + await click(button("暂停轨迹回放")); + await act(async () => riding.focus()); + expect(container.querySelector('[role="tooltip"]')?.textContent).toContain("乘坐Laika"); + const start = container.querySelector('[aria-label="轨迹开始时间"]')!; + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(start, "2026-09-19T00:04"); + start.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect(container.querySelector(".map-playback-time")?.textContent).toContain("选择用户与时间后开始回放"); + const end = container.querySelector('[aria-label="轨迹结束时间"]')!; + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(end, "2026-09-18T00:00"); + end.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect(container.querySelectorAll(".map-track-live")).toHaveLength(0); + expect(container.querySelectorAll(".map-layer-players")).toHaveLength(0); + expect(button("播放轨迹回放").disabled).toBe(true); + }); + + it("shows player, vehicle lock and flag owner details without extra reads", async () => { + const { loadSurface, list } = await mount(); + await act(async () => button("Laika").focus()); + expect(container.querySelector('[role="tooltip"]')?.textContent).toContain("Mira 上锁"); + expect(container.querySelector('[role="tooltip"]')?.textContent).toContain("耐久未同步"); + await act(async () => button("Wolves Flag").focus()); + const tooltip = container.querySelector('[role="tooltip"]')?.textContent; + expect(tooltip).toContain("归属Mira(mira)"); + expect(tooltip).toContain("Wolves(wolves · 2 人)"); + expect(loadSurface).toHaveBeenCalledTimes(1); + expect(list).toHaveBeenCalledTimes(7); + }); + + it("excludes unselected and out-of-window players, including stale retained current positions", async () => { + const fixture = createScumMapFixture(); + fixture.surface.users.items.push({ id: "old", serverInstanceId: "map-test", steamId: "old", displayName: "Old Player", online: false, x: 100, y: 100, lastSeenAt: "2026-09-18T00:00:00Z" }); + await mount(fixture); + const labels = () => [...container.querySelectorAll(".map-track-live-label")].map((node) => node.textContent); + expect(labels()).toEqual(["Mira"]); + expect(container.querySelector('[aria-label="SCUM 地图视图"]')?.textContent).not.toContain("Nova"); + await click(button("清空")); + expect(labels()).toEqual([]); + expect(container.querySelectorAll(".map-layer-players")).toHaveLength(0); + expect(button("播放轨迹回放").disabled).toBe(true); + await click(button("时段内全部")); + expect(labels().sort()).toEqual(["Mira", "Nova"]); + expect(container.querySelector('[aria-label="SCUM 地图视图"]')?.textContent).not.toContain("Old Player"); + }); + + it("draws the travelled path up to the moving marker and keeps the final frame without duplicate endpoints", async () => { + await mount(); + const start = button("播放轨迹回放"); + expect(start.closest("details")).toBeNull(); + await click(start); + await tick(6000); + const polyline = container.querySelector(".map-track-layer polyline")!; + const last = polyline.getAttribute("points")!.split(" ").at(-1)!.split(",").map(Number); + const marker = container.querySelector(".map-track-live")!; + expect(last[0]).toBeCloseTo(parseFloat(marker.style.left) * 40.96, 0); + expect(last[1]).toBeCloseTo(parseFloat(marker.style.top) * 40.96, 0); + expect(polyline.getAttribute("vector-effect")).toBe("non-scaling-stroke"); + await tick(24100); + expect(container.querySelector(".map-playback-time")?.textContent).toContain("回放结束"); + expect(container.querySelectorAll(".map-track-live")).toHaveLength(1); + expect(container.querySelectorAll(".map-track-endpoint")).toHaveLength(0); + expect(container.querySelector("progress")?.value).toBe(1); + }); + + it("reuses marker nodes and geometry during zoom without reparsing the loaded trajectory history", async () => { + const fixture = createScumMapFixture(); + const sample = fixture.surface.userTrajectories.items[0]; + fixture.surface.userTrajectories.items = Array.from({ length: 1000 }, (_, index) => ({ ...sample, id: `sample-${index}`, x: index * 5, sampledAt: new Date(Date.parse("2026-09-19T00:00:00Z") + index * 500).toISOString() })); + await mount(fixture); + const marker = container.querySelector(".map-track-live")!; + await click(marker); + const line = container.querySelector(".map-track-layer polyline"); + const parse = vi.spyOn(Date, "parse"); + await click(button("地图缩小")); + expect(container.querySelector(".map-track-live")).toBe(marker); + expect(container.querySelector(".map-track-layer polyline")).toBe(line); + // Only small control/detail labels may parse times; all 1,000 trajectory rows stay cached. + expect(parse.mock.calls.length).toBeLessThan(30); + expect(zoom()).toBeLessThan(8); + expect(line?.getAttribute("points")?.split(" ")).toHaveLength(1000); + }); + + it("keeps manual zoom during playback and loads only activity in a non-focused time window", async () => { + await mount(createScumMapFixture(), { ...scumMapContext, pageQuery: { from: scumMapContext.pageQuery.from, to: scumMapContext.pageQuery.to } }); + expect(container.querySelectorAll(".map-track-live")).toHaveLength(2); + await click(button("播放轨迹回放")); + await click(button("地图缩小")); + const manualZoom = zoom(); + await tick(1000); + expect(zoom()).toBe(manualZoom); + }); +}); diff --git a/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_bicycle_01_a.webp b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_bicycle_01_a.webp new file mode 100644 index 0000000..2dd8889 Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_bicycle_01_a.webp differ diff --git a/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_bicycle_02_a.webp b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_bicycle_02_a.webp new file mode 100644 index 0000000..d17f19d Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_bicycle_02_a.webp differ diff --git a/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_cruiser.webp b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_cruiser.webp new file mode 100644 index 0000000..0e8e4e3 Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_cruiser.webp differ diff --git a/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_improraftbig.webp b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_improraftbig.webp new file mode 100644 index 0000000..fd86e99 Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_improraftbig.webp differ diff --git a/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_improsmallraft.webp b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_improsmallraft.webp new file mode 100644 index 0000000..5535744 Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_improsmallraft.webp differ diff --git a/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_improwheelbarrow.webp b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_improwheelbarrow.webp new file mode 100644 index 0000000..dd2d4b5 Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_improwheelbarrow.webp differ diff --git a/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_kinglet_duster_a.webp b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_kinglet_duster_a.webp new file mode 100644 index 0000000..5898721 Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_kinglet_duster_a.webp differ diff --git a/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_kinglet_mariner.webp b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_kinglet_mariner.webp new file mode 100644 index 0000000..14b505a Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_kinglet_mariner.webp differ diff --git a/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_laika.webp b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_laika.webp new file mode 100644 index 0000000..40b78e6 Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_laika.webp differ diff --git a/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_motorboat_02.webp b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_motorboat_02.webp new file mode 100644 index 0000000..9f7f889 Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_motorboat_02.webp differ diff --git a/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_motorcycle_01_a.webp b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_motorcycle_01_a.webp new file mode 100644 index 0000000..959a22d Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_motorcycle_01_a.webp differ diff --git a/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_rager.webp b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_rager.webp new file mode 100644 index 0000000..db64a31 Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_rager.webp differ diff --git a/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_ris.webp b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_ris.webp new file mode 100644 index 0000000..9967a46 Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_ris.webp differ diff --git a/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_sidecarbike.webp b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_sidecarbike.webp new file mode 100644 index 0000000..ba07796 Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_sidecarbike.webp differ diff --git a/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_sup_inhands.webp b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_sup_inhands.webp new file mode 100644 index 0000000..f7be0f4 Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_sup_inhands.webp differ diff --git a/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_sup_vicinity.webp b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_sup_vicinity.webp new file mode 100644 index 0000000..1d70924 Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_sup_vicinity.webp differ diff --git a/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_tractor_01_a.webp b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_tractor_01_a.webp new file mode 100644 index 0000000..4ee5961 Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_tractor_01_a.webp differ diff --git a/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_wheelbarrow.webp b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_wheelbarrow.webp new file mode 100644 index 0000000..8a68740 Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_wheelbarrow.webp differ diff --git a/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_wolfswagen.webp b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_wolfswagen.webp new file mode 100644 index 0000000..87e35c7 Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_wolfswagen.webp differ diff --git a/plugins/examples/scum-server-plugin/assets/map/vehicles/motoredboat_a.webp b/plugins/examples/scum-server-plugin/assets/map/vehicles/motoredboat_a.webp new file mode 100644 index 0000000..c9fce7c Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/map/vehicles/motoredboat_a.webp differ diff --git a/plugins/examples/scum-server-plugin/features/contracts.ts b/plugins/examples/scum-server-plugin/features/contracts.ts index c5b77dd..a2451c5 100644 --- a/plugins/examples/scum-server-plugin/features/contracts.ts +++ b/plugins/examples/scum-server-plugin/features/contracts.ts @@ -36,4 +36,17 @@ export type SCUMTrajectoryPoint = { occurredAt: string; subjectId: string; subje export type SCUMTrajectory = { subjectId: string; subjectType: "player" | "vehicle"; points: SCUMTrajectoryPoint[]; provenance: SCUMMigrationProvenance }; export type SCUMTrajectoryCollection = { available: boolean; reason?: string; trajectories: SCUMTrajectory[] }; +export type MapViewState = { zoom: number; x: number; y: number }; +export type MapPlaybackState = { playing: boolean; value: number; speed: number; startedAt: number; startValue: number }; +export type MapTimeWindow = { from: number; to: number; label: string }; +export type MapTrackPoint = { key: string; row: Record; x: number; y: number; time: number; riding: boolean; rideCode: string }; +export type MapTrackSegment = { key: string; mode: "walk" | "ride"; points: MapTrackPoint[] }; +export type MapTrack = { key: string; kind: "player" | "vehicle"; label: string; color: string; points: MapTrackPoint[]; walk: number; ride: number; from: number; to: number; vehicles: string[] }; +export type MapPlaybackFrame = { tracks: MapTrack[]; span: { from: number; to: number } }; +export type MapSceneData = MapPlaybackFrame & { + points: Record[]; settings?: Record; bounds: Record; window: MapTimeWindow; + catalog: Map; users: Array<{ key: string; label: string; count: number }>; playerLabels: Map; + segments: Map; +}; + export type SCUMFeatureWorkspace = { defaultDirectoryKey?: string; directories?: SCUMLogicalDirectory[]; files?: SCUMLogicalFile[]; configFields?: SCUMConfigField[]; map?: { mapId: string; mapVersion: string; precision: number; sampleDistance: number; sampleIntervalSeconds: number; retentionSeconds: number } }; diff --git a/plugins/examples/scum-server-plugin/features/map-vehicle-icons.ts b/plugins/examples/scum-server-plugin/features/map-vehicle-icons.ts new file mode 100644 index 0000000..c3e260d --- /dev/null +++ b/plugins/examples/scum-server-plugin/features/map-vehicle-icons.ts @@ -0,0 +1,29 @@ +import { scumVehicleFallbackIconUrl, scumVehicleIconPathFor } from "./scum-catalog.js"; + +// 48px thumbnails for 20px map markers and 26px playback markers. Catalog originals stay separate. +const icons: Record = { + "items/vehicles/ico_bicycle_01_a.webp": new URL("../assets/map/vehicles/ico_bicycle_01_a.webp", import.meta.url).href, + "items/vehicles/ico_bicycle_02_a.webp": new URL("../assets/map/vehicles/ico_bicycle_02_a.webp", import.meta.url).href, + "items/vehicles/ico_cruiser.webp": new URL("../assets/map/vehicles/ico_cruiser.webp", import.meta.url).href, + "items/vehicles/ico_improraftbig.webp": new URL("../assets/map/vehicles/ico_improraftbig.webp", import.meta.url).href, + "items/vehicles/ico_improsmallraft.webp": new URL("../assets/map/vehicles/ico_improsmallraft.webp", import.meta.url).href, + "items/vehicles/ico_improwheelbarrow.webp": new URL("../assets/map/vehicles/ico_improwheelbarrow.webp", import.meta.url).href, + "items/vehicles/ico_kinglet_duster_a.webp": new URL("../assets/map/vehicles/ico_kinglet_duster_a.webp", import.meta.url).href, + "items/vehicles/ico_kinglet_mariner.webp": new URL("../assets/map/vehicles/ico_kinglet_mariner.webp", import.meta.url).href, + "items/vehicles/ico_laika.webp": new URL("../assets/map/vehicles/ico_laika.webp", import.meta.url).href, + "items/vehicles/ico_motorboat_02.webp": new URL("../assets/map/vehicles/ico_motorboat_02.webp", import.meta.url).href, + "items/vehicles/ico_motorcycle_01_a.webp": new URL("../assets/map/vehicles/ico_motorcycle_01_a.webp", import.meta.url).href, + "items/vehicles/ico_rager.webp": new URL("../assets/map/vehicles/ico_rager.webp", import.meta.url).href, + "items/vehicles/ico_ris.webp": new URL("../assets/map/vehicles/ico_ris.webp", import.meta.url).href, + "items/vehicles/ico_sidecarbike.webp": new URL("../assets/map/vehicles/ico_sidecarbike.webp", import.meta.url).href, + "items/vehicles/ico_sup_inhands.webp": new URL("../assets/map/vehicles/ico_sup_inhands.webp", import.meta.url).href, + "items/vehicles/ico_sup_vicinity.webp": new URL("../assets/map/vehicles/ico_sup_vicinity.webp", import.meta.url).href, + "items/vehicles/ico_tractor_01_a.webp": new URL("../assets/map/vehicles/ico_tractor_01_a.webp", import.meta.url).href, + "items/vehicles/ico_wheelbarrow.webp": new URL("../assets/map/vehicles/ico_wheelbarrow.webp", import.meta.url).href, + "items/vehicles/ico_wolfswagen.webp": new URL("../assets/map/vehicles/ico_wolfswagen.webp", import.meta.url).href, + "items/vehicles/motoredboat_a.webp": new URL("../assets/map/vehicles/motoredboat_a.webp", import.meta.url).href, +}; + +export function scumMapVehicleIconUrl(code: string): string { + return icons[scumVehicleIconPathFor(code)] || scumVehicleFallbackIconUrl(code); +} diff --git a/plugins/examples/scum-server-plugin/features/page-data.ts b/plugins/examples/scum-server-plugin/features/page-data.ts index 286d698..9561d22 100644 --- a/plugins/examples/scum-server-plugin/features/page-data.ts +++ b/plugins/examples/scum-server-plugin/features/page-data.ts @@ -83,10 +83,12 @@ type PageKey = "players" | "squads" | "live-map" | "gifts" | "workflows"; type PluginCollectionSurfaceKey = keyof typeof scumCollections; type SCUMPlatformSurfaceKey = "players" | "vehicles" | "trajectories" | "vehicleLocks"; +// Call budget per live-map load/15s refresh: 7 bounded plugin collections + 1 typed SCUM surface. +// Squads and members load once per refresh; hovering, selecting and playback issue 0 API calls. const pageCollections: Record = { players: ["members", "activityEvents", "giftClaims", "pendingGifts", "giftDeliveries"], squads: ["squads", "members", "flags"], - "live-map": ["mapPoints", "mapRegions", "mapSettings", "flags", "tradeGoods"], + "live-map": ["mapPoints", "mapRegions", "mapSettings", "flags", "squads", "members", "tradeGoods"], gifts: ["gifts", "giftClaims", "pendingGifts", "giftDeliveries", "timedGiftEvents", "tradeGoods"], workflows: ["events", "eventProduces", "eventRuns", "nativeEventRounds", "tasks", "activityEvents", "tradeGoods", "tradeEvents"] }; diff --git a/plugins/examples/scum-server-plugin/features/page.ts b/plugins/examples/scum-server-plugin/features/page.ts index 81f4254..de39fc2 100644 --- a/plugins/examples/scum-server-plugin/features/page.ts +++ b/plugins/examples/scum-server-plugin/features/page.ts @@ -19,55 +19,46 @@ import { type SCUMSurfaceData, type SCUMWorkspaceActions } from "./page-data.js"; -import { scumCatalogEntries, scumCatalogEntryFor, scumCatalogIconUrl, scumVehicleEntry, scumVehicleFallbackIconUrl, scumVehicleIconUrl, type ScumCatalogEntry } from "./scum-catalog.js"; +import { scumCatalogEntries, scumCatalogEntryFor, scumCatalogIconUrl, scumVehicleEntry, type ScumCatalogEntry } from "./scum-catalog.js"; +import { scumMapVehicleIconUrl } from "./map-vehicle-icons.js"; +import type { MapPlaybackFrame, MapPlaybackState, MapSceneData, MapTimeWindow, MapTrack, MapTrackPoint, MapTrackSegment, MapViewState } from "./contracts.js"; type StateSetter = (next: T | ((previous: T) => T)) => void; -type InputEvent = { target?: { value?: string; checked?: boolean; open?: boolean }; stopPropagation?: () => void }; +type InputEvent = { target?: { value?: string; checked?: boolean; open?: boolean }; currentTarget?: { open?: boolean }; stopPropagation?: () => void }; type GiftTab = "definitions" | "claims" | "deliveries" | "timed"; type MapLayer = "players" | "vehicles" | "flags" | "regions" | "other"; +type MapVehicleFilter = "all" | "land" | "water" | "air" | "other"; type ScumMapLayer = "terrain" | "topo" | "night"; type CatalogPickTarget = "gift-items" | "produce-item"; type PlayerPanelKind = "closed" | "gifts" | "items" | "history"; type PlayerPanelState = { kind: PlayerPanelKind; playerId: string }; -type MapViewState = { zoom: number; x: number; y: number }; -type MapPlaybackState = { playing: boolean; value: number; speed: number; startedAt: number; startValue: number }; -type MapTimeWindow = { from: number; to: number; label: string }; -type MapTrackPoint = { key: string; row: RecordMap; x: number; y: number; time: number; riding: boolean; rideCode: string }; -type MapRideSample = { key: string; code: string; time: number; scene: { x: number; y: number } }; -type MapTrackSegment = { key: string; mode: "walk" | "ride"; points: MapTrackPoint[] }; -type MapTrack = { key: string; kind: "player" | "vehicle"; label: string; color: string; points: MapTrackPoint[]; walk: number; ride: number; from: number; to: number; vehicles: string[] }; type PointerEventLike = { pointerId?: number; clientX?: number; clientY?: number; button?: number; currentTarget?: { setPointerCapture?: (pointerId?: number) => void; releasePointerCapture?: (pointerId?: number) => void }; preventDefault?: () => void; stopPropagation?: () => void }; const scumMapLayerImages = { terrain: new URL("../assets/map/scum-map-terrain-4096.webp", import.meta.url).href, topo: new URL("../assets/map/scum-map-topo-4096.webp", import.meta.url).href, night: new URL("../assets/map/scum-map-night-4096.webp", import.meta.url).href, zones: new URL("../assets/map/scum-map-building-zones-4096.png", import.meta.url).href } as const; const scumMapBaseLayers: ReadonlyArray = [["terrain", "地形"], ["topo", "等高线"], ["night", "夜间"]]; +const scumMapVehicleFilters: ReadonlyArray = [["all", "全部载具"], ["land", "陆地载具"], ["water", "水上载具"], ["air", "飞行类载具"], ["other", "其他载具"]]; const scumCatalogKinds: ReadonlyArray = [["all", "全部"], ["item", "物品"], ["vehicle", "载具"], ["zombie", "丧尸"], ["animal", "动物"], ["armed-npc", "NPC"]]; const scumCatalogPickLimit = 48; const scumMapSize = 4096; const scumSurfaceRefreshMs = 15000; const scumMapZoomMin = 1; const scumMapZoomMax = 8; -const scumMapTrackPointLimit = 400; -const scumMapTrackLimit = 24; const scumMapSegmentGapMs = 15 * 60 * 1000; -const scumMapSegmentJump = 52000; const scumMapDefaultView: MapViewState = { zoom: 1, x: 0, y: 0 }; const scumMapPlaybackIdle: MapPlaybackState = { playing: false, value: 1, speed: 1, startedAt: 0, startValue: 1 }; // Playback runs on wall-clock time so the 1x / 2x / 4x switch is instant and timer jitter cannot drift. const scumMapPlaybackDurationMs = 30000; -const scumMapRideWindowMs = 10 * 1000; -const scumMapRideDistance = 1500; const scumMapTimePresets: ReadonlyArray = [["1h", "最近 1 小时"], ["15m", "最近 15 分钟"], ["6h", "最近 6 小时"], ["24h", "最近 24 小时"], ["7d", "最近 7 天"], ["all", "全部时段"], ["custom", "自定义起止"]]; const scumMapPresetMs: Record = { "15m": 15 * 60 * 1000, "1h": 60 * 60 * 1000, "6h": 6 * 60 * 60 * 1000, "24h": 24 * 60 * 60 * 1000, "7d": 7 * 24 * 60 * 60 * 1000 }; const scumMapTrackColors = ["#4ECDC4", "#FF8A6B", "#F7DC6F", "#85C1E9", "#BB8FCE", "#98D8C8", "#FFA07A", "#45B7D1"]; const scumMapTrackColorCache = new Map(); const scumMapDefaultWindowMs = 60 * 60 * 1000; const mapDragState = { active: false, pointerId: -1, startX: 0, startY: 0, originX: 0, originY: 0, moved: false }; -// Latest rendered playback frame; the interval effect reads it to keep the follow camera in sync. -let mapPlaybackFrame: { tracks: MapTrack[]; span: { from: number; to: number } } | null = null; export type ReactLike = { createElement: (...args: any[]) => any; useEffect?: (effect: () => void | (() => void), deps: readonly unknown[]) => void; useRef?: (initial: T) => { current: T }; + useMemo?: (factory: () => T, deps: readonly unknown[]) => T; useState?: (initialState: T | (() => T)) => [T, StateSetter]; }; @@ -123,6 +114,9 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) const [produceY, setProduceY] = usePluginState(react, "0"); const [produceZ, setProduceZ] = usePluginState(react, "0"); const [giftTab, setGiftTab] = usePluginState(react, "definitions"); + const [giftSearch, setGiftSearch] = usePluginState(react, ""); + const [giftStatus, setGiftStatus] = usePluginState(react, "all"); + const [giftRecordKey, setGiftRecordKey] = usePluginState(react, ""); const [giftCode, setGiftCode] = usePluginState(react, ""); const [giftName, setGiftName] = usePluginState(react, ""); const [giftItems, setGiftItems] = usePluginState(react, ""); @@ -135,6 +129,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) const [deliveryGift, setDeliveryGift] = usePluginState(react, ""); const [deliveryPlayer, setDeliveryPlayer] = usePluginState(react, ""); const [mapLayers, setMapLayers] = usePluginState>(react, { players: true, vehicles: true, flags: true, regions: true, other: true }); + const [mapVehicleFilter, setMapVehicleFilter] = usePluginState(react, "all"); const [selectedMapPoint, setSelectedMapPoint] = usePluginState(react, ""); const [hoverMapPoint, setHoverMapPoint] = usePluginState(react, ""); const [mapView, setMapView] = usePluginState(react, scumMapDefaultView); @@ -145,6 +140,8 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) const [mapTrackKey, setMapTrackKey] = usePluginState(react, ""); const [mapPlayback, setMapPlayback] = usePluginState(react, scumMapPlaybackIdle); const mapViewportRef = react.useRef ? react.useRef(null) : { current: null as HTMLElement | null }; + const mapPlaybackFrameRef = react.useRef ? react.useRef(null) : { current: null as MapPlaybackFrame | null }; + const mapFollowRef = react.useRef ? react.useRef(true) : { current: true }; const [eventEditorOpen, setEventEditorOpen] = usePluginState(react, false); const [produceEditorOpen, setProduceEditorOpen] = usePluginState(react, false); const [giftEditorOpen, setGiftEditorOpen] = usePluginState(react, false); @@ -181,28 +178,60 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) if (react.useEffect) react.useEffect(() => { const node = mapViewportRef.current; if (pageKey !== "live-map" || !node || typeof node.addEventListener !== "function") return; + let wheelFrame = 0; + let factor = 1; + let anchorX = 0; + let anchorY = 0; const onWheel = (event: { deltaY: number; clientX: number; clientY: number; preventDefault: () => void }) => { event.preventDefault(); + mapFollowRef.current = false; const rect = node.getBoundingClientRect(); - setMapView((previous) => zoomMapView(previous, event.deltaY < 0 ? 1.16 : 1 / 1.16, event.clientX - rect.left - rect.width / 2, event.clientY - rect.top - rect.height / 2)); + factor *= event.deltaY < 0 ? 1.16 : 1 / 1.16; + anchorX = event.clientX - rect.left - rect.width / 2; + anchorY = event.clientY - rect.top - rect.height / 2; + if (!wheelFrame) wheelFrame = requestAnimationFrame(() => { + const nextFactor = factor; + const x = anchorX; const y = anchorY; + wheelFrame = 0; factor = 1; + setMapView((previous) => zoomMapView(previous, nextFactor, x, y)); + }); }; node.addEventListener("wheel", onWheel, { passive: false }); setMapView((previous) => (previous.zoom === scumMapZoomMin && previous.x === 0 && previous.y === 0 ? defaultMapView(node) : previous)); - return () => { node.removeEventListener("wheel", onWheel); }; + return () => { node.removeEventListener("wheel", onWheel); if (wheelFrame) cancelAnimationFrame(wheelFrame); }; }, [pageKey, state.status, mapViewportRef, setMapView]); if (react.useEffect) react.useEffect(() => { if (!mapPlayback.playing) return; const timer = setInterval(() => { const next = advanceMapPlayback(mapPlayback, Date.now()); setMapPlayback(next); - const camera = mapPlaybackFrame ? playbackFollowView(mapPlaybackFrame.tracks, next, mapPlaybackFrame.span, mapViewportRef.current) : undefined; + const frame = mapPlaybackFrameRef.current; + const camera = frame && mapFollowRef.current ? playbackFollowView(frame.tracks, next, frame.span, mapViewportRef.current) : undefined; if (camera) setMapView(camera); - }, 90); + }, 33); return () => { clearInterval(timer); }; - }, [mapPlayback.playing, mapPlayback.startedAt, mapPlayback.startValue, mapPlayback.speed, setMapPlayback, setMapView, mapViewportRef]); + }, [mapPlayback.playing, mapPlayback.startedAt, mapPlayback.startValue, mapPlayback.speed, setMapPlayback, setMapView, mapViewportRef, mapPlaybackFrameRef]); + if (react.useEffect) react.useEffect(() => { + setMapPlayback(scumMapPlaybackIdle); + setHoverMapPoint(""); + setSelectedMapPoint(""); + }, [input.serverInstanceId, pageKey, mapUsers, mapTimeFrom, mapTimeTo]); + if (react.useEffect) react.useEffect(() => { + if (!giftEditorOpen || typeof document === "undefined") return; + const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") setGiftEditorOpen(false); }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [giftEditorOpen]); if (react.useEffect) react.useEffect(() => { if (pageKey !== "live-map") return; - if (!focusUser) { setMapUsers(undefined); applyMapTimePreset(setMapTimePreset, setMapTimeFrom, setMapTimeTo, "1h"); return; } + if (!focusUser) { + setMapUsers(undefined); + if (pageQuery.from || pageQuery.to) { + const range = mapQueryRange(pageQuery.from, pageQuery.to); + setMapTimePreset("custom"); setMapTimeFrom(localDateTimeValue(range.from)); setMapTimeTo(localDateTimeValue(range.to)); + } else applyMapTimePreset(setMapTimePreset, setMapTimeFrom, setMapTimeTo, "1h"); + return; + } const range = mapQueryRange(pageQuery.from, pageQuery.to); setMapUsers([focusUser]); setMapTimePreset("custom"); @@ -215,37 +244,61 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) if (!node) return; const settings = state.data.mapSettings.find((value) => textField(value, "_recordKey", "id") === "current") ?? state.data.mapSettings[0]; const range = mapQueryRange(pageQuery.from, pageQuery.to); - const points = mapTrackPointList(mapTracks(state.data, resolveMapBounds(settings), { from: range.from, to: range.to, label: "" }, [focusUser], mapVehicleClassIndex(state.data))); - if (points.length) setMapView(fitMapView(points, node, 0.8)); + const tracks = mapTracks(state.data, resolveMapBounds(settings), { from: range.from, to: range.to, label: "" }, [focusUser], mapVehicleClassIndex(state.data)); + const camera = playbackStartView(tracks, node); + if (camera) setMapView(camera); }, [pageKey, focusKey, state.status]); const data = state.status === "ready" ? state.data : emptySCUMSurfaceData; - return e("section", { className: "console-panel scum-workbench", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) }, - action.status !== "idle" ? e("p", { className: "page-status", "data-state": action.status }, action.message) : null, - state.status === "loading" ? e("p", { className: "page-status" }, "正在读取平台 SCUM 用户与载具表…") : null, - state.status === "error" ? e("p", { className: "page-status", "data-state": "error" }, state.reason) : null, - state.status === "ready" ? renderSurfaceBody(e, pageKey, data, input, { + const deriveMap = () => pageKey === "live-map" ? buildMapSceneData(data, mapTimeWindow({ mapTimeFrom, mapTimeTo }), mapUsers, mapVehicleFilter) : null; + const mapData = react.useMemo ? react.useMemo(deriveMap, [pageKey, data, mapTimeFrom, mapTimeTo, mapUsers, mapVehicleFilter]) : deriveMap(); + const view: ViewState = { playerSearch, setPlayerSearch, playerStatus, setPlayerStatus, playerPanel, setPlayerPanel, squadSearch, setSquadSearch, selectedSquadId, setSelectedSquadId, activityStatus, setActivityStatus, eventId, setEventId, eventName, setEventName, eventType, setEventType, eventSchedule, setEventSchedule, eventCommand, setEventCommand, eventClass, setEventClass, eventPlacard, setEventPlacard, eventPercent, setEventPercent, eventDuration, setEventDuration, eventNpc, setEventNpc, eventItem, setEventItem, eventZombie, setEventZombie, eventAnimal, setEventAnimal, produceEventId, setProduceEventId, produceId, setProduceId, produceTradeGoodsId, setProduceTradeGoodsId, producePercent, setProducePercent, produceValue, setProduceValue, produceRadius, setProduceRadius, produceX, setProduceX, produceY, setProduceY, produceZ, setProduceZ, - giftTab, setGiftTab, giftCode, setGiftCode, giftName, setGiftName, giftItems, setGiftItems, giftCommands, setGiftCommands, giftClass, setGiftClass, giftAudience, setGiftAudience, giftNumber, setGiftNumber, giftAchievement, setGiftAchievement, giftAchievementNumber, setGiftAchievementNumber, - deliveryGift, setDeliveryGift, deliveryPlayer, setDeliveryPlayer, mapLayers, setMapLayers, selectedMapPoint, setSelectedMapPoint, + giftTab, setGiftTab, giftSearch, setGiftSearch, giftStatus, setGiftStatus, giftRecordKey, setGiftRecordKey, giftCode, setGiftCode, giftName, setGiftName, giftItems, setGiftItems, giftCommands, setGiftCommands, giftClass, setGiftClass, giftAudience, setGiftAudience, giftNumber, setGiftNumber, giftAchievement, setGiftAchievement, giftAchievementNumber, setGiftAchievementNumber, + deliveryGift, setDeliveryGift, deliveryPlayer, setDeliveryPlayer, mapLayers, setMapLayers, mapVehicleFilter, setMapVehicleFilter, selectedMapPoint, setSelectedMapPoint, hoverMapPoint, setHoverMapPoint, - mapView, setMapView, mapViewportRef, mapTimePreset, setMapTimePreset, mapTimeFrom, setMapTimeFrom, mapTimeTo, setMapTimeTo, + mapView, setMapView, mapViewportRef, mapPlaybackFrameRef, mapFollowRef, mapTimePreset, setMapTimePreset, mapTimeFrom, setMapTimeFrom, mapTimeTo, setMapTimeTo, mapUsers, setMapUsers, mapTrackKey, setMapTrackKey, mapPlayback, setMapPlayback, eventEditorOpen, setEventEditorOpen, produceEditorOpen, setProduceEditorOpen, giftEditorOpen, setGiftEditorOpen, mapBaseLayer, setMapBaseLayer, mapZonesOverlay, setMapZonesOverlay, catalogSearch, setCatalogSearch, catalogKind, setCatalogKind, catalogOpen, setCatalogOpen, - setAction, refresh: () => setRefreshSignal((value) => value + 1) - }) : null + setAction, refresh: () => setRefreshSignal((value) => value + 1), + mapData, mapSceneContents: null, mapStaticContents: null, mapSelectedTrails: [], mapSelectedLocks: [] + }; + const selectedHistory = () => { + const visible = mapData ? mapVisiblePoints(mapData, view) : []; + const track = mapData?.tracks.find((candidate) => `track:${candidate.key}` === selectedMapPoint); + const selected = track?.points[0]?.row ?? visible.find((point, index) => idOf(point, `point-${index}`) === selectedMapPoint) ?? visible[0]; + return { + trails: selected ? trajectoryRecordsForPoint(data.trajectories, selected).sort((left, right) => trajectoryOrder(right) - trajectoryOrder(left)).slice(0, 8) : [], + locks: selected && layerOf(selected) === "vehicles" ? vehicleLockRecordsForPoint(data.vehicleLocks, selected).sort((left, right) => rowTime(right, ["lockedAt", "createdAt"]) - rowTime(left, ["lockedAt", "createdAt"])).slice(0, 5) : [] + }; + }; + const history = react.useMemo ? react.useMemo(selectedHistory, [mapData, mapLayers, selectedMapPoint]) : selectedHistory(); + view.mapSelectedTrails = history.trails; + view.mapSelectedLocks = history.locks; + const renderStaticPoints = () => mapData ? mapVisiblePoints(mapData, view).map((point, index) => mapPointMarker(e, point, index, mapData.bounds, view, data)) : null; + view.mapStaticContents = react.useMemo ? react.useMemo(renderStaticPoints, [mapData, mapLayers]) : renderStaticPoints(); + const renderMapScene = () => mapData ? mapSceneContents(e, data, view, mapData) : null; + // Pan/zoom only changes the parent transform. Keep the geometry and marker elements stable. + view.mapSceneContents = react.useMemo ? react.useMemo(renderMapScene, [mapData, mapLayers, mapPlayback.value, mapPlayback.startedAt, hoverMapPoint]) : renderMapScene(); + return e("section", { className: "console-panel scum-workbench", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) }, + action.status !== "idle" ? e("p", { className: "page-status", "data-state": action.status }, action.message) : null, + state.status === "loading" ? e("p", { className: "page-status" }, "正在读取平台 SCUM 用户与载具表…") : null, + state.status === "error" ? e("p", { className: "page-status", "data-state": "error" }, state.reason) : null, + state.status === "ready" ? renderSurfaceBody(e, pageKey, data, input, view) : null ); } type ViewState = { + mapData: MapSceneData | null; mapSceneContents: unknown; mapStaticContents: unknown; + mapSelectedTrails: RecordMap[]; mapSelectedLocks: RecordMap[]; playerSearch: string; setPlayerSearch: StateSetter; playerStatus: string; setPlayerStatus: StateSetter; playerPanel: PlayerPanelState; setPlayerPanel: StateSetter; squadSearch: string; setSquadSearch: StateSetter; selectedSquadId: string; setSelectedSquadId: StateSetter; - activityStatus: string; setActivityStatus: StateSetter; giftTab: GiftTab; setGiftTab: StateSetter; + activityStatus: string; setActivityStatus: StateSetter; giftTab: GiftTab; setGiftTab: StateSetter; giftSearch: string; setGiftSearch: StateSetter; giftStatus: string; setGiftStatus: StateSetter; giftRecordKey: string; setGiftRecordKey: StateSetter; eventId: string; setEventId: StateSetter; eventName: string; setEventName: StateSetter; eventType: string; setEventType: StateSetter; eventSchedule: string; setEventSchedule: StateSetter; eventCommand: string; setEventCommand: StateSetter; @@ -257,9 +310,11 @@ type ViewState = { giftCode: string; setGiftCode: StateSetter; giftName: string; setGiftName: StateSetter; giftItems: string; setGiftItems: StateSetter; giftCommands: string; setGiftCommands: StateSetter; giftClass: string; setGiftClass: StateSetter; giftAudience: string; setGiftAudience: StateSetter; giftNumber: string; setGiftNumber: StateSetter; giftAchievement: string; setGiftAchievement: StateSetter; giftAchievementNumber: string; setGiftAchievementNumber: StateSetter; deliveryGift: string; setDeliveryGift: StateSetter; deliveryPlayer: string; setDeliveryPlayer: StateSetter; - mapLayers: Record; setMapLayers: StateSetter>; + mapLayers: Record; setMapLayers: StateSetter>; mapVehicleFilter: MapVehicleFilter; setMapVehicleFilter: StateSetter; selectedMapPoint: string; setSelectedMapPoint: StateSetter; hoverMapPoint: string; setHoverMapPoint: StateSetter; setAction: StateSetter; refresh: () => void; mapView: MapViewState; setMapView: StateSetter; mapViewportRef: { current: HTMLElement | null }; + mapPlaybackFrameRef: { current: MapPlaybackFrame | null }; + mapFollowRef: { current: boolean }; mapTimePreset: string; setMapTimePreset: StateSetter; mapTimeFrom: string; setMapTimeFrom: StateSetter; mapTimeTo: string; setMapTimeTo: StateSetter; mapUsers: string[] | undefined; setMapUsers: StateSetter; mapTrackKey: string; setMapTrackKey: StateSetter; mapPlayback: MapPlaybackState; setMapPlayback: StateSetter; eventEditorOpen: boolean; setEventEditorOpen: StateSetter; produceEditorOpen: boolean; setProduceEditorOpen: StateSetter; giftEditorOpen: boolean; setGiftEditorOpen: StateSetter; @@ -544,18 +599,30 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) { const actions = input.workspaceActions; + const openNewGift = () => { + view.setGiftRecordKey(""); view.setGiftCode(""); view.setGiftName(""); view.setGiftItems(""); view.setGiftCommands(""); view.setGiftClass("5"); view.setGiftAudience("all"); view.setGiftNumber("1"); view.setGiftAchievement("0"); view.setGiftAchievementNumber("0"); view.setGiftEditorOpen(true); + }; + const openGiftEditor = (gift: RecordMap) => { + view.setGiftRecordKey(textField(gift, "_recordKey", "code", "id")); view.setGiftCode(textField(gift, "code")); view.setGiftName(textField(gift, "name")); view.setGiftClass(numField(gift, "class")); view.setGiftAudience(textField(gift, "audience") || "all"); view.setGiftNumber(numField(gift, "number")); view.setGiftAchievement(numField(gift, "achievement")); view.setGiftAchievementNumber(numField(gift, "achievementNumber", "achievement_number")); view.setGiftItems(giftItemsInput(gift)); view.setGiftCommands(giftCommandsInput(gift)); view.setGiftEditorOpen(true); + }; const saveGift = () => runAction(view.setAction, "正在保存礼包定义…", async () => { const code = view.giftCode.trim(); const name = view.giftName.trim(); if (!code || !name) throw new Error("礼包编号和名称不能为空。"); + if (!/^[A-Za-z0-9_.-]{1,128}$/.test(code)) throw new Error("礼包编号只能使用字母、数字、点、下划线或短横线。"); const items = parseGiftItems(view.giftItems); const commands = parseGiftCommands(view.giftCommands); if (!items.length && !commands.length) throw new Error("礼包至少需要一项物品或命令。"); + const number = minimumIntegerInput(view.giftNumber, 1, "发放次数"); + const achievement = minimumIntegerInput(view.giftAchievement, 0, "成就类型"); + const achievementNumber = minimumIntegerInput(view.giftAchievementNumber, 0, "成就值"); await saveGiftDefinition(actions ?? {}, { - code, name, class: integerInput(view.giftClass, 5), audience: view.giftAudience, number: integerInput(view.giftNumber, 1), - achievement: integerInput(view.giftAchievement, 0), achievementNumber: integerInput(view.giftAchievementNumber, 0), items, commands, + code, name, class: integerInput(view.giftClass, 5), audience: view.giftAudience, number, + achievement, achievementNumber, items, commands, status: "active", updatedAt: new Date().toISOString() }); + if (view.giftRecordKey && view.giftRecordKey !== code) await deleteGiftDefinition(actions ?? {}, view.giftRecordKey); + view.setGiftEditorOpen(false); view.setGiftRecordKey(""); view.refresh(); return `礼包 ${name} 已保存。`; }); @@ -569,35 +636,51 @@ function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, inpu view.refresh(); return "礼包发放命令已进入执行队列。"; }); + const search = view.giftSearch.trim().toLowerCase(); + const gifts = data.gifts.filter((gift) => { + const status = textField(gift, "status").toLowerCase() || "active"; + const statusFilter = activeStatus(status) ? "active" : "disabled"; + return matchesText(gift, search, "code", "name", "audience", "status") && (view.giftStatus === "all" || statusFilter === view.giftStatus); + }); return e("div", { className: "console-record-list" }, statsStrip(e, [["礼包定义", data.gifts.length], ["领取/待领", data.giftClaims.length + data.pendingGifts.length], ["发放记录", data.giftDeliveries.length], ["物品列表", data.tradeGoods.length]]), e("div", { className: "console-row-actions", role: "tablist", "aria-label": "礼包视图" }, giftTabButton(e, view, "definitions", "礼包定义"), giftTabButton(e, view, "claims", "领取/待领"), giftTabButton(e, view, "deliveries", "发放记录"), giftTabButton(e, view, "timed", "游戏定时记录") ), - view.giftTab === "definitions" ? e("div", { className: "overview-two-col" }, - e("details", { className: "console-module scum-editor", open: view.giftEditorOpen, onToggle: (event: InputEvent) => view.setGiftEditorOpen(detailOpen(event)) }, e("summary", null, e("strong", null, "新建或更新礼包"), e("span", { className: "page-status" }, view.giftCode || view.giftName ? "编辑中" : "点击展开")), - labeledField(e, "礼包编号", e("input", { value: view.giftCode, "aria-label": "礼包编号", placeholder: "例如 welcome_pack", onChange: (event: InputEvent) => view.setGiftCode(inputValue(event)) })), - labeledField(e, "礼包名称", e("input", { value: view.giftName, "aria-label": "礼包名称", placeholder: "例如 新人礼包", onChange: (event: InputEvent) => view.setGiftName(inputValue(event)) })), - labeledField(e, "礼包周期", e("select", { value: view.giftClass, "aria-label": "礼包周期", onChange: (event: InputEvent) => view.setGiftClass(inputValue(event)) }, [["1", "每日"], ["2", "每周"], ["3", "每月"], ["4", "每年"], ["5", "一次"], ["6", "每日五次"]].map(([value, label]) => e("option", { key: value, value }, label)))), - labeledField(e, "适用玩家", e("select", { value: view.giftAudience, "aria-label": "适用玩家", onChange: (event: InputEvent) => view.setGiftAudience(inputValue(event)) }, e("option", { value: "all" }, "全部玩家"), e("option", { value: "pve" }, "PVE 玩家"), e("option", { value: "pvp" }, "PVP 玩家"))), - labeledField(e, "发放次数", e("input", { value: view.giftNumber, "aria-label": "发放次数", type: "number", placeholder: "1", onChange: (event: InputEvent) => view.setGiftNumber(inputValue(event)) })), - e("div", { className: "console-row-actions" }, labeledField(e, "成就类型", e("input", { value: view.giftAchievement, "aria-label": "成就类型", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setGiftAchievement(inputValue(event)) })), labeledField(e, "成就值", e("input", { value: view.giftAchievementNumber, "aria-label": "成就值", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setGiftAchievementNumber(inputValue(event)) }))), - labeledField(e, "礼包物品", e("input", { value: view.giftItems, "aria-label": "礼包物品", placeholder: "SCUM 目录代码:数量", onChange: (event: InputEvent) => view.setGiftItems(inputValue(event)) })), - catalogPicker(e, view, "gift-items"), - labeledField(e, "礼包命令", e("textarea", { value: view.giftCommands, "aria-label": "礼包命令", placeholder: "每行一条命令", onChange: (event: InputEvent) => view.setGiftCommands(inputValue(event)) })), - e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData, onClick: saveGift }, "保存礼包") + view.giftTab === "definitions" ? e("div", { className: "gift-definitions-surface" }, + e("div", { className: "resource-filter-bar scum-filter-bar gift-toolbar" }, + labeledField(e, "搜索礼包", e("input", { type: "search", value: view.giftSearch, "aria-label": "搜索礼包", placeholder: "名称 / 编号 / 适用玩家", onChange: (event: InputEvent) => view.setGiftSearch(inputValue(event)) })), + labeledField(e, "礼包状态", e("select", { value: view.giftStatus, "aria-label": "礼包状态", onChange: (event: InputEvent) => view.setGiftStatus(inputValue(event)) }, e("option", { value: "all" }, "全部状态"), e("option", { value: "active" }, "启用"), e("option", { value: "disabled" }, "停用"))), + e("span", { className: "page-status" }, `${gifts.length} / ${data.gifts.length} 个礼包`), + e("button", { type: "button", className: "primary-command gift-create-command", disabled: !actions?.pluginData, onClick: openNewGift }, "新建礼包") + ), + e("div", { className: "provider-table-wrap scum-table-wrap gift-table-wrap" }, + e("table", { className: "resource-table scum-gift-table" }, + e("thead", null, e("tr", null, ["礼包", "领取规则", "礼包内容", "次数 / 门槛", "状态", "最近更新", "操作"].map((label) => e("th", { key: label, scope: "col" }, label)))), + e("tbody", null, gifts.length ? gifts.map((gift, index) => { + const key = textField(gift, "_recordKey", "code", "id"); + const status = textField(gift, "status").toLowerCase() || "active"; + return e("tr", { key: idOf(gift, `gift-${index}`) }, + e("td", null, e("strong", null, textField(gift, "name") || key), e("span", { className: "provider-id scum-gift-code" }, key)), + e("td", null, e("span", null, giftClassLabel(numField(gift, "class"))), e("span", { className: "provider-id" }, giftAudienceLabel(textField(gift, "audience")))), + e("td", null, e("div", { className: "scum-gift-content-cell" }, catalogIconStrip(e, giftCatalogIcons(gift)), e("span", null, giftItemsSummary(gift))), e("span", { className: "provider-id" }, giftCommandsSummary(gift))), + e("td", null, e("strong", null, numField(gift, "number")), e("span", { className: "provider-id" }, `成就 ${numField(gift, "achievement")} / ${numField(gift, "achievementNumber", "achievement_number")}`)), + e("td", null, e("span", { className: `status-pill ${activeStatus(status) ? "status-active" : "status-disabled"}` }, giftStatusLabel(status))), + e("td", null, e("span", { className: "provider-id" }, dateField(gift, "updatedAt", "createdAt"))), + e("td", { className: "provider-actions-cell" }, e("div", { className: "scum-gift-table-actions" }, + e("button", { type: "button", className: "icon-command", onClick: () => openGiftEditor(gift) }, "编辑"), + e("details", { className: "inline-action-control" }, + e("summary", { className: "icon-command", "aria-label": `打开${textField(gift, "name") || key}更多操作` }, "更多"), + e("div", { className: "inline-action-menu", role: "menu", "aria-label": "礼包操作" }, + e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => { view.setDeliveryGift(key); view.setGiftTab("deliveries"); } }, "选择发放"), + e("button", { type: "button", className: "runtime-action-item danger-command", role: "menuitem", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在删除礼包…", async () => { await deleteGiftDefinition(actions ?? {}, key); view.refresh(); return "礼包定义已删除。"; }) }, "删除") + ) + ) + )) + ); + }) : e("tr", null, e("td", { colSpan: 7 }, e("p", { className: "page-status" }, data.gifts.length ? "没有符合筛选条件的礼包。" : "暂无礼包定义,请先新建一个礼包。")))) + ) ), - e("div", { className: "console-record-list" }, data.gifts.length ? data.gifts.map((gift, index) => { - const key = textField(gift, "_recordKey", "code", "id"); - return e("article", { key: idOf(gift, `gift-${index}`), className: "console-record" }, - e("div", { className: "console-record-head" }, e("strong", null, textField(gift, "name") || key), e("span", { className: "status-pill status-active" }, textField(gift, "status") || "active")), - e("div", { className: "console-record-meta" }, e("span", null, `周期 ${giftClassLabel(numField(gift, "class"))}`), e("span", null, `适用 ${textField(gift, "audience") || "all"}`), e("span", null, `次数 ${numField(gift, "number")}`), e("span", null, `成就 ${numField(gift, "achievement")} / ${numField(gift, "achievementNumber", "achievement_number")}`)), - e("span", { className: "provider-id" }, giftItemsSummary(gift)), - catalogIconStrip(e, giftCatalogIcons(gift)), - e("span", { className: "provider-id" }, giftCommandsSummary(gift)), - e("div", { className: "console-row-actions" }, e("button", { type: "button", className: "icon-command", onClick: () => view.setDeliveryGift(key) }, "选择发放"), e("button", { type: "button", className: "icon-command", onClick: () => { view.setGiftEditorOpen(true); view.setGiftCode(textField(gift, "code")); view.setGiftName(textField(gift, "name")); view.setGiftClass(numField(gift, "class")); view.setGiftAudience(textField(gift, "audience") || "all"); view.setGiftNumber(numField(gift, "number")); view.setGiftAchievement(numField(gift, "achievement")); view.setGiftAchievementNumber(numField(gift, "achievementNumber", "achievement_number")); view.setGiftItems(giftItemsInput(gift)); view.setGiftCommands(giftCommandsInput(gift)); } }, "编辑"), e("button", { type: "button", className: "icon-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在删除礼包…", async () => { await deleteGiftDefinition(actions ?? {}, key); view.refresh(); return "礼包定义已删除。"; }) }, "删除")) - ); - }) : e("p", { className: "page-status" }, "暂无礼包定义。")) ) : null, view.giftTab === "definitions" ? itemCatalogPanel(e, data.tradeGoods) : null, view.giftTab === "claims" ? e("div", { className: "overview-two-col" }, @@ -615,32 +698,78 @@ function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, inpu view.giftTab === "timed" ? e("div", { className: "console-record-list" }, e("p", { className: "page-status" }, "这里是 SCUM 原生 finished_timed_gift_spawner 完成记录,不是插件运营礼包定义。"), tablePanel(e, "游戏原生定时礼包", data.timedGiftEvents, (event) => [textField(event, "userProfileId") || "unknown", textField(event, "mapId") || "unknown", textField(event, "spawnTime") || "unknown", dateField(event, "spawnAt")]) - ) : null + ) : null, + view.giftEditorOpen ? giftEditorDialog(e, view, actions, saveGift) : null ); } +function giftEditorDialog(e: ReactLike["createElement"], view: ViewState, actions: SCUMWorkspaceActions | undefined, saveGift: () => void) { + const editing = Boolean(view.giftRecordKey); + return e("div", { className: "confirm-backdrop management-dialog-backdrop gift-dialog-backdrop", role: "presentation", onClick: () => view.setGiftEditorOpen(false) }, + e("div", { className: "drawer-panel management-dialog-panel management-dialog-wide", role: "dialog", "aria-modal": "true", "aria-label": editing ? "编辑礼包" : "新建礼包", onClick: (event: InputEvent) => event.stopPropagation?.() }, + e("div", { className: "panel-header dialog-panel-header" }, e("div", { className: "gift-dialog-heading" }, e("h2", null, editing ? "编辑礼包" : "新建礼包"), e("span", { className: "page-status" }, editing ? "更新现有礼包定义" : "创建可领取的运营礼包")), e("button", { type: "button", className: "theme-upload drawer-close", "aria-label": "关闭礼包编辑器", onClick: () => view.setGiftEditorOpen(false) }, "关闭")), + e("div", { className: "gift-form dialog-form management-form" }, + e("div", { className: "form-guidance management-form-wide" }, e("strong", null, "先定义规则,再配置内容"), e("span", null, "礼包编号用于数据主键,只支持字母、数字、点、下划线和短横线;礼包至少要包含一项物品或一条命令。")), + labeledField(e, "礼包编号", e("input", { value: view.giftCode, "aria-label": "礼包编号", placeholder: "例如 welcome_pack", onChange: (event: InputEvent) => view.setGiftCode(inputValue(event)) })), + labeledField(e, "礼包名称", e("input", { value: view.giftName, "aria-label": "礼包名称", placeholder: "例如 新人礼包", onChange: (event: InputEvent) => view.setGiftName(inputValue(event)) })), + labeledField(e, "礼包周期", e("select", { value: view.giftClass, "aria-label": "礼包周期", onChange: (event: InputEvent) => view.setGiftClass(inputValue(event)) }, [["1", "每日"], ["2", "每周"], ["3", "每月"], ["4", "每年"], ["5", "一次"], ["6", "每日五次"]].map(([value, label]) => e("option", { key: value, value }, label)))), + labeledField(e, "适用玩家", e("select", { value: view.giftAudience, "aria-label": "适用玩家", onChange: (event: InputEvent) => view.setGiftAudience(inputValue(event)) }, e("option", { value: "all" }, "全部玩家"), e("option", { value: "pve" }, "PVE 玩家"), e("option", { value: "pvp" }, "PVP 玩家"))), + labeledField(e, "发放次数", e("input", { value: view.giftNumber, "aria-label": "发放次数", type: "number", min: "1", placeholder: "1", onChange: (event: InputEvent) => view.setGiftNumber(inputValue(event)) })), + labeledField(e, "成就类型", e("input", { value: view.giftAchievement, "aria-label": "成就类型", type: "number", min: "0", placeholder: "0", onChange: (event: InputEvent) => view.setGiftAchievement(inputValue(event)) })), + labeledField(e, "成就值", e("input", { value: view.giftAchievementNumber, "aria-label": "成就值", type: "number", min: "0", placeholder: "0", onChange: (event: InputEvent) => view.setGiftAchievementNumber(inputValue(event)) })), + e("div", { className: "gift-form-wide gift-form-section" }, e("div", { className: "gift-form-section-title" }, e("strong", null, "礼包物品"), e("span", null, "可用目录选择器快速加入,格式为 目录代码:数量")), labeledField(e, "物品清单", e("input", { value: view.giftItems, "aria-label": "礼包物品", placeholder: "例如 BP_Cash_01:2, Water-Bottle.01:1", onChange: (event: InputEvent) => view.setGiftItems(inputValue(event)) })), catalogPicker(e, view, "gift-items")), + e("div", { className: "gift-form-wide gift-form-section" }, e("div", { className: "gift-form-section-title" }, e("strong", null, "礼包命令"), e("span", null, "每行一条 SCUM RCON 命令,可选")), labeledField(e, "命令清单", e("textarea", { value: view.giftCommands, "aria-label": "礼包命令", placeholder: "例如 #announce Welcome\n每行一条命令", onChange: (event: InputEvent) => view.setGiftCommands(inputValue(event)) }))), + e("div", { className: "confirm-actions gift-dialog-actions" }, e("button", { type: "button", onClick: () => view.setGiftEditorOpen(false) }, "取消"), e("button", { type: "button", className: "confirm-primary", disabled: !actions?.pluginData, onClick: saveGift }, "保存礼包")) + ) + ) + ); +} + +function buildMapSceneData(data: SCUMSurfaceData, window: MapTimeWindow, selectedUsers: string[] | undefined, vehicleFilter: MapVehicleFilter): MapSceneData { + const settings = data.mapSettings.find((value) => textField(value, "_recordKey", "id") === "current") ?? data.mapSettings[0]; + const bounds = resolveMapBounds(settings); + const catalog = mapVehicleClassIndex(data); + const users = mapTrackUsers(data, window); + const allowed = new Set(users.filter((user) => selectedUsers === undefined || selectedUsers.includes(user.key)).map((user) => user.key)); + const points = collectMapPoints(data).filter((point) => (layerOf(point) !== "vehicles" || vehicleFilter === "all" || mapVehicleCategory(point) === vehicleFilter) && (layerOf(point) !== "players" || playerIdentities(point).some((id) => allowed.has(id)) || allowed.has(textField(point, "subjectId")))); + const playerLabels = new Map(data.players.map((player) => [textField(player, "steamId", "gamePlayerId", "id"), textField(player, "displayName", "name", "playerName")])); + const tracks = mapTracks(data, bounds, window, [...allowed], catalog); + return { points, settings, bounds, window, catalog, users, playerLabels, tracks, span: mapPlaybackSpan(window, tracks), segments: new Map(tracks.map((track) => [track.key, mapTrackSegments(track)])) }; +} + +function mapVisiblePoints(scene: MapSceneData, view: ViewState): RecordMap[] { + const trackedUsers = new Set(scene.tracks.map((track) => track.key.slice("player:".length))); + return scene.points.filter((point) => view.mapLayers[layerOf(point)] && !(layerOf(point) === "players" && (playerIdentities(point).some((id) => trackedUsers.has(id)) || trackedUsers.has(textField(point, "subjectId"))))); +} + +function mapSceneContents(e: ReactLike["createElement"], data: SCUMSurfaceData, view: ViewState, scene: MapSceneData) { + const { tracks, span, catalog, bounds, segments } = scene; + const cursor = playbackCursor(span, view.mapPlayback); + const visible = mapVisiblePoints(scene, view); + const hovered = visible.find((point, index) => idOf(point, `point-${index}`) === view.hoverMapPoint); + const hoveredTrack = tracks.find((track) => `track:${track.key}` === view.hoverMapPoint); + const hoveredPosition = hoveredTrack ? mapTrackPositionAt(hoveredTrack, cursor) : undefined; + return [mapGridOverlay(e), + view.mapLayers.players ? e("svg", { key: "tracks", className: "map-track-layer", viewBox: `0 0 ${scumMapSize} ${scumMapSize}`, preserveAspectRatio: "none", "aria-hidden": "true" }, mapTrackShapes(e, tracks, segments, cursor)) : null, + view.mapLayers.players ? mapTrackMarkers(e, tracks, cursor, catalog, Number.isFinite(cursor), view) : null, + view.mapStaticContents, + hovered ? mapHoverCard(e, hovered, data, mapPointStyle(hovered, bounds)) : hoveredPosition ? mapHoverCard(e, mapTrackHoverRow(hoveredPosition.row, data), data, mapSceneStyle(hoveredPosition)) : null + ]; +} + function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) { const actions = input.workspaceActions; - const points = collectMapPoints(data); - const settings = data.mapSettings.find((value) => textField(value, "_recordKey", "id") === "current") ?? data.mapSettings[0]; - const bounds = resolveMapBounds(settings); + const scene = view.mapData!; + const { points, settings, window, users, playerLabels, tracks, span } = scene; const baseLayer = view.mapBaseLayer ?? mapLayerSetting(settings); const zonesOverlay = view.mapZonesOverlay ?? Boolean(settings && boolField(settings, "buildingZones")); - const window = mapTimeWindow(view); - const catalog = mapVehicleClassIndex(data); - const users = mapTrackUsers(data, window); - const playerLabels = new Map(); - for (const player of data.players) { const key = textField(player, "steamId", "gamePlayerId", "id"); const label = textField(player, "displayName", "name", "playerName"); if (key && label) playerLabels.set(key, label); } - const tracks = mapTracks(data, bounds, window, view.mapUsers, catalog); - const span = mapPlaybackSpan(window, tracks); - mapPlaybackFrame = { tracks, span }; + view.mapPlaybackFrameRef.current = { tracks, span }; const cursor = playbackCursor(span, view.mapPlayback); const zoom = view.mapView.zoom; - const visible = points.filter((point) => view.mapLayers[layerOf(point)]); - const hovered = visible.find((point, index) => idOf(point, `point-${index}`) === view.hoverMapPoint); - const selected = visible.find((point, index) => idOf(point, `point-${index}`) === view.selectedMapPoint) ?? visible[0]; - const selectedTrails = selected ? trajectoryRecordsForPoint(data.trajectories, selected).sort((left, right) => trajectoryOrder(right) - trajectoryOrder(left)).slice(0, 8) : []; - const selectedLocks = selected && layerOf(selected) === "vehicles" ? vehicleLockRecordsForPoint(data.vehicleLocks, selected).sort((left, right) => rowTime(right, ["lockedAt", "createdAt"]) - rowTime(left, ["lockedAt", "createdAt"])).slice(0, 5) : []; + const visible = mapVisiblePoints(scene, view); + const selectedTrack = tracks.find((track) => `track:${track.key}` === view.selectedMapPoint); + const selectedPosition = selectedTrack ? mapTrackPositionAt(selectedTrack, cursor) : undefined; + const selected = selectedPosition ? mapTrackHoverRow(selectedPosition.row, data) : visible.find((point, index) => idOf(point, `point-${index}`) === view.selectedMapPoint) ?? visible[0]; return e("div", { className: "console-record-list scum-map-surface" }, e("div", { className: "resource-filter-bar scum-filter-bar scum-map-bar" }, labeledField(e, "快捷", e("select", { value: view.mapTimePreset, "aria-label": "轨迹快捷时间", onChange: (event: InputEvent) => applyMapTimePreset(view.setMapTimePreset, view.setMapTimeFrom, view.setMapTimeTo, inputValue(event)) }, scumMapTimePresets.map(([key, label]) => e("option", { key, value: key }, label)))), @@ -648,24 +777,21 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: labeledField(e, "结束", e("input", { type: "datetime-local", value: view.mapTimeTo, "aria-label": "轨迹结束时间", onChange: (event: InputEvent) => { view.setMapTimePreset("custom"); view.setMapTimeTo(inputValue(event)); } })), mapUserMenu(e, view, users, playerLabels), mapLayerMenu(e, view, zonesOverlay, (next) => { persistMapLayers(view, actions, baseLayer, next); }), + labeledField(e, "载具类型", e("select", { value: view.mapVehicleFilter, "aria-label": "载具类型", onChange: (event: InputEvent) => view.setMapVehicleFilter(inputValue(event) as MapVehicleFilter) }, scumMapVehicleFilters.map(([key, label]) => e("option", { key, value: key }, label)))), labeledField(e, "底图", e("select", { value: baseLayer, "aria-label": "地图底图", onChange: (event: InputEvent) => { const next = mapBaseLayerValue(inputValue(event)); view.setMapBaseLayer(next); persistMapLayers(view, actions, next, zonesOverlay); } }, scumMapBaseLayers.map(([key, label]) => e("option", { key, value: key }, label)))), - e("span", { className: "page-status scum-map-summary" }, `${visible.length} / ${points.length} 个点 · ${tracks.length} 条轨迹 · ${window.label}`) + mapPlaybackBar(e, view, span, tracks, tracks.length > 0, `${visible.length} / ${points.length} 个点 · ${tracks.length} 条轨迹 · ${window.label}`) ), e("div", { className: "map-viewport", "aria-label": "SCUM 地图视图", ref: view.mapViewportRef, onPointerDown: (event: PointerEventLike) => { if (event.button && event.button !== 0) return; mapDragState.active = true; mapDragState.pointerId = event.pointerId ?? -1; mapDragState.startX = event.clientX ?? 0; mapDragState.startY = event.clientY ?? 0; mapDragState.originX = view.mapView.x; mapDragState.originY = view.mapView.y; mapDragState.moved = false; event.currentTarget?.setPointerCapture?.(event.pointerId); }, - onPointerMove: (event: PointerEventLike) => { if (!mapDragState.active || (event.pointerId ?? -1) !== mapDragState.pointerId) return; const deltaX = (event.clientX ?? 0) - mapDragState.startX; const deltaY = (event.clientY ?? 0) - mapDragState.startY; if (Math.abs(deltaX) + Math.abs(deltaY) > 3) mapDragState.moved = true; const originX = mapDragState.originX; const originY = mapDragState.originY; view.setMapView((previous) => ({ ...previous, x: originX + deltaX, y: originY + deltaY })); }, + onPointerMove: (event: PointerEventLike) => { if (!mapDragState.active || (event.pointerId ?? -1) !== mapDragState.pointerId) return; const deltaX = (event.clientX ?? 0) - mapDragState.startX; const deltaY = (event.clientY ?? 0) - mapDragState.startY; if (Math.abs(deltaX) + Math.abs(deltaY) > 3) { mapDragState.moved = true; view.mapFollowRef.current = false; } const originX = mapDragState.originX; const originY = mapDragState.originY; view.setMapView((previous) => ({ ...previous, x: originX + deltaX, y: originY + deltaY })); }, onPointerUp: (event: PointerEventLike) => { if ((event.pointerId ?? -1) !== mapDragState.pointerId) return; mapDragState.active = false; event.currentTarget?.releasePointerCapture?.(event.pointerId); }, onPointerCancel: () => { mapDragState.active = false; }, - onDoubleClick: () => { view.setMapView((previous) => zoomMapView(previous, 1.6, 0, 0)); } }, + onDoubleClick: () => { view.mapFollowRef.current = false; view.setMapView((previous) => zoomMapView(previous, 1.6, 0, 0)); } }, e("div", { className: "map-scene", style: { transform: `translate(-50%, -50%) translate(${view.mapView.x}px, ${view.mapView.y}px) scale(${zoom})`, "--map-inverse": String(1 / zoom) } }, e("div", { className: "map-scene-image", "aria-label": "SCUM 地图图层", style: mapBoardStyle(baseLayer, zonesOverlay) }), - mapGridOverlay(e), - e("svg", { className: "map-track-layer", viewBox: `0 0 ${scumMapSize} ${scumMapSize}`, preserveAspectRatio: "none", "aria-hidden": "true" }, mapTrackShapes(e, tracks, cursor, 18 / zoom)), - mapTrackMarkers(e, tracks, cursor, catalog, view.mapPlayback.playing), - visible.map((point, index) => mapPointMarker(e, point, index, bounds, view, data)), - hovered ? mapHoverCard(e, hovered, data, mapPointStyle(hovered, bounds)) : null + view.mapSceneContents ), - e("div", { className: "map-view-controls" }, + e("div", { className: "map-view-controls", onClick: () => { view.mapFollowRef.current = false; }, onPointerDown: (event: PointerEventLike) => event.stopPropagation?.(), onDoubleClick: (event: PointerEventLike) => event.stopPropagation?.() }, e("button", { type: "button", className: "icon-command", "aria-label": "地图放大", onClick: () => view.setMapView((previous) => zoomMapView(previous, 1.25, 0, 0)) }, "+"), e("button", { type: "button", className: "icon-command", "aria-label": "地图缩小", onClick: () => view.setMapView((previous) => zoomMapView(previous, 0.8, 0, 0)) }, "-"), e("button", { type: "button", className: "icon-command", "aria-label": "地图复位", onClick: () => view.setMapView(defaultMapView(view.mapViewportRef.current)) }, "复位"), @@ -673,27 +799,27 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: e("span", { className: "map-zoom-badge" }, `${zoom.toFixed(2)}×`) ) ), - mapPlaybackBar(e, view, span, tracks, tracks.length > 0), mapTrackLegend(e, view, tracks, playerLabels), - selected ? mapPointStrip(e, selected, selectedTrails, selectedLocks) : null + selected ? mapPointStrip(e, selected, view.mapSelectedTrails, view.mapSelectedLocks, data) : null ); } function mapPointMarker(e: ReactLike["createElement"], point: RecordMap, index: number, bounds: RecordMap, view: ViewState, data: SCUMSurfaceData) { const layer = layerOf(point); - const ride = layer === "players" ? textField(point, "riddenVehicleId", "gameVehicleId") : ""; - const icon = layer === "vehicles" || ride ? vehicleIconFor(point) : ""; + const ride = layer === "players" ? mapRiddenVehicleId(point) : ""; + const riddenVehicle = ride ? vehicleForIdentity(data.vehicles, ride) : undefined; + const icon = layer === "vehicles" ? vehicleIconFor(point) : riddenVehicle ? vehicleIconFor(riddenVehicle) : ride ? vehicleIconByCode(ride) : ""; const key = idOf(point, `point-${index}`); const open = () => view.setHoverMapPoint(key); const close = () => view.setHoverMapPoint((current) => (current === key ? "" : current)); - return e("button", { key, type: "button", className: `map-surface-dot map-layer-${layer}${ride ? " map-surface-dot-riding" : ""}`, title: `${pointTitle(point)} ${coords(point)}${ride ? ` · 乘坐 ${ride}` : ""}`, "aria-label": pointTitle(point), style: mapPointStyle(point, bounds), onPointerEnter: open, onPointerLeave: close, onFocus: open, onBlur: close, onClick: () => { if (!mapDragState.moved) view.setSelectedMapPoint(key); } }, + return e("button", { key, type: "button", className: `map-surface-dot map-layer-${layer}${ride ? " map-surface-dot-riding" : ""}`, title: `${pointTitle(point)} ${coords(point)}${ride ? ` · 乘坐 ${riddenVehicle ? vehicleLabelFor(riddenVehicle) : ride}` : ""}`, "aria-label": pointTitle(point), style: mapPointStyle(point, bounds), onPointerEnter: open, onPointerLeave: close, onFocus: open, onBlur: close, onPointerDown: (event: PointerEventLike) => { mapDragState.moved = false; event.stopPropagation?.(); }, onClick: () => { if (!mapDragState.moved) view.setSelectedMapPoint(key); } }, icon ? e("img", { src: icon, alt: "", loading: "lazy", decoding: "async" }) : "" ); } // Hover card: the map is unreadable without knowing whose dot it is and which vehicle it belongs to. -// Every row is driven by data the platform already returns, so a missing field drops the row instead -// of printing a placeholder. +// Ownership and durability are shown only from reported records; unavailable required facts are +// labelled as unsynchronized. function mapHoverCard(e: ReactLike["createElement"], point: RecordMap, data: SCUMSurfaceData, pointStyle: Record) { const layer = layerOf(point); const rows: Array<[string, string]> = []; @@ -703,19 +829,23 @@ function mapHoverCard(e: ReactLike["createElement"], point: RecordMap, data: SCU push("用户", textField(point, "displayName", "name", "label") || pointTitle(point)); push("SteamID", textField(point, "steamId", "gamePlayerId", "subjectId")); push("队伍", mapSquadLabel(data, textField(point, "squadId", "squadName"))); - push("状态", boolField(point, "online") ? "在线" : "离线"); + const riddenVehicle = vehicleForIdentity(data.vehicles, mapRiddenVehicleId(point)); + push("乘坐", riddenVehicle ? vehicleLabelFor(riddenVehicle) : mapRiddenVehicleId(point)); + push("状态", playerOnline(point) ? "在线" : "离线"); pushDate("最后出现", "lastSeenAt", "lastActivityAt", "updatedAt"); } else if (layer === "vehicles") { push("载具", vehicleLabelFor(point)); push("类名", textField(point, "className", "vehicleClass", "entityClass", "vehicleType")); push("状态", textField(point, "status", "state") || vehicleFunctionalLabel(point) || (boolField(point, "exists", "existsInGame") ? "存在" : "")); - push("耐久", vehicleDurabilityLabel(point)); + push("耐久", vehicleDurabilityLabel(point) || "未同步"); push("车锁", vehicleLockLabel(point, data)); pushDate("最后观测", "vehicleObservedAt", "lastObservedAt", "observedAt", "updatedAt"); } else if (layer === "flags") { push("领地旗", textField(point, "name", "label", "flagId", "id") || "领地旗"); - push("归属", mapOwnerLabel(data, point)); - push("队伍", mapSquadLabel(data, textField(point, "ownerSquadId", "squadId"))); + push("归属", mapOwnerLabel(data, point) || "未同步"); + const squadId = textField(point, "ownerSquadId", "squadId"); + push("附属队伍", squadId ? mapSquadLabel(data, squadId) : "无附属队伍"); + push("状态", textField(point, "status", "state") || "未知"); push("置信度", textField(point, "ownershipConfidence")); } else { push("名称", pointTitle(point)); @@ -723,7 +853,7 @@ function mapHoverCard(e: ReactLike["createElement"], point: RecordMap, data: SCU push("来源", textField(point, "source")); } if (hasCoordinates(point)) push("坐标", coords(point)); - return e("div", { className: `map-hover-card map-hover-${layer}`, role: "tooltip", style: pointStyle }, + return e("div", { key: "hover", className: `map-hover-card map-hover-${layer}`, role: "tooltip", style: pointStyle }, e("strong", { className: "map-hover-title" }, pointTitle(point)), rows.map(([label, value]) => e("span", { key: label, className: "map-hover-row" }, e("small", null, label), e("b", null, value))) ); @@ -732,17 +862,27 @@ function mapHoverCard(e: ReactLike["createElement"], point: RecordMap, data: SCU function mapSquadLabel(data: SCUMSurfaceData, value: string): string { if (!value) return ""; const squad = data.squads.find((row) => textField(row, "squadId", "id", "_recordKey") === value); - return squad ? `${textField(squad, "name", "label") || value}(${value})` : value; + const memberCount = data.members.filter((row) => textField(row, "squadId", "squad_id") === value).length; + const declaredCount = squad ? numField(squad, "memberCount", "member_count") : "--"; + const count = memberCount || (declaredCount === "--" ? 0 : Number(declaredCount)); + if (!squad) return count ? `${value} · ${count} 人` : value; + return `${textField(squad, "name", "label") || value}(${value}${count ? ` · ${count} 人` : ""})`; } function mapOwnerLabel(data: SCUMSurfaceData, point: RecordMap): string { - const steamId = textField(point, "ownerSteamId", "steamId", "ownerProfileId", "ownerId"); - if (!steamId) return ""; - const owner = data.players.find((player) => [textField(player, "steamId"), textField(player, "gamePlayerId"), textField(player, "id")].includes(steamId)); - return owner ? `${textField(owner, "displayName", "name") || steamId}(${steamId})` : steamId; + const ownerId = textField(point, "ownerSteamId", "ownerPlayerId", "steamId", "ownerProfileId", "ownerId"); + if (!ownerId) return ""; + const owner = data.players.find((player) => playerIdentities(player).includes(ownerId)); + return owner ? `${textField(owner, "displayName", "name") || ownerId}(${ownerId})` : ownerId; +} + +function vehicleForIdentity(rows: RecordMap[], identity: string): RecordMap | undefined { + if (!identity) return undefined; + return rows.find((row) => [textField(row, "id"), textField(row, "vehicleId"), textField(row, "gameVehicleId"), textField(row, "entityId")].includes(identity)); } function vehicleLockLabel(point: RecordMap, data: SCUMSurfaceData): string { + if (field(point, "locked", "isLocked") !== undefined && !boolField(point, "locked", "isLocked")) return "未上锁"; const locks = vehicleLockRecordsForPoint(data.vehicleLocks, point).sort((left, right) => rowTime(right, ["lockedAt", "createdAt"]) - rowTime(left, ["lockedAt", "createdAt"])); const latest = locks[0]; if (latest) { @@ -756,6 +896,8 @@ function vehicleLockLabel(point: RecordMap, data: SCUMSurfaceData): string { } function vehicleDurabilityLabel(point: RecordMap): string { + const percent = textField(point, "healthPercent", "durabilityPercent", "conditionPercent"); + if (percent) return `${percent}%`; const value = textField(point, "durability", "vehicleDurability", "health", "vehicleHealth"); return value ? `${value}${textField(point, "durabilityMax", "maxHealth") ? ` / ${textField(point, "durabilityMax", "maxHealth")}` : ""}` : ""; } @@ -768,17 +910,20 @@ function vehicleFunctionalLabel(point: RecordMap): string { function mapUserMenu(e: ReactLike["createElement"], view: ViewState, users: Array<{ key: string; label: string; count: number }>, labels: Map) { const selected = view.mapUsers; const rows = [...users, ...(selected ?? []).filter((key) => !users.some((user) => user.key === key)).map((key) => ({ key, label: labels.get(key) || key, count: 0 }))]; - const summary = selected === undefined ? "全部" : selected.length ? `${selected.length}/${rows.length}` : "未选"; - return e("details", { className: "scum-map-menu", "aria-label": "轨迹用户筛选" }, - e("summary", { className: "icon-command" }, `用户 ${summary}`), - e("div", { className: "scum-map-menu-body" }, - e("div", { className: "console-row-actions" }, - e("button", { type: "button", className: "icon-command", onClick: () => view.setMapUsers(users.map((user) => user.key)) }, "全选"), - e("button", { type: "button", className: "icon-command", onClick: () => view.setMapUsers([]) }, "清空"), - e("button", { type: "button", className: "icon-command", onClick: () => view.setMapUsers(undefined) }, "不限") - ), - e("p", { className: "page-status" }, "列出所选起止时间内有轨迹的用户,颜色与地图轨迹一致。"), - rows.length ? rows.map((user) => e("label", { key: user.key, className: "scum-layer-toggle" }, e("input", { type: "checkbox", checked: selected === undefined || selected.includes(user.key), onChange: (event: InputEvent) => view.setMapUsers(toggleMapUser(selected, user.key, Boolean(event.target?.checked), users)) }), e("span", { className: "map-track-swatch", style: { background: mapTrackColor(user.key) } }), `${user.label} · ${user.count} 点`)) : e("p", { className: "page-status" }, "当前时间范围内没有用户轨迹。") + const summary = selected === undefined ? "时段内全部" : selected.length ? `用户 ${selected.length}/${rows.length}` : "未选"; + return e("div", { className: "console-field scum-map-menu-field" }, + e("span", null, "用户"), + e("details", { className: "scum-map-menu scum-map-filter-menu", "aria-label": "轨迹用户筛选", onMouseLeave: (event: InputEvent) => { if (event.currentTarget) event.currentTarget.open = false; } }, + e("summary", { className: "icon-command" }, summary), + e("div", { className: "scum-map-menu-body" }, + e("div", { className: "console-row-actions" }, + e("button", { type: "button", className: "icon-command", onClick: () => view.setMapUsers(users.map((user) => user.key)) }, "全选"), + e("button", { type: "button", className: "icon-command", onClick: () => view.setMapUsers([]) }, "清空"), + e("button", { type: "button", className: "icon-command", onClick: () => view.setMapUsers(undefined) }, "时段内全部") + ), + e("p", { className: "page-status" }, "仅显示本时段有活动记录的用户;清空选择后地图不显示用户。"), + rows.length ? rows.map((user) => e("label", { key: user.key, className: "scum-layer-toggle" }, e("input", { type: "checkbox", checked: selected === undefined || selected.includes(user.key), onChange: (event: InputEvent) => view.setMapUsers(toggleMapUser(selected, user.key, Boolean(event.target?.checked), users)) }), e("span", { className: "map-track-swatch", style: { background: mapTrackColor(user.key) } }), `${user.label} · ${user.count} 点`)) : e("p", { className: "page-status" }, "当前时间范围内没有用户轨迹。") + ) ) ); } @@ -799,12 +944,15 @@ function mapTrackLegend(e: ReactLike["createElement"], view: ViewState, tracks: function mapLayerMenu(e: ReactLike["createElement"], view: ViewState, zonesOverlay: boolean, onZones: (next: boolean) => void) { const layers = ["players", "vehicles", "flags", "regions", "other"] as MapLayer[]; const enabled = layers.filter((layer) => view.mapLayers[layer]).length; - return e("details", { className: "scum-map-menu", "aria-label": "地图图层筛选" }, - e("summary", { className: "icon-command" }, `图层 ${enabled}/${layers.length}${zonesOverlay ? " + 建筑区" : ""}`), - e("div", { className: "scum-map-menu-body" }, - layers.map((layer) => e("label", { key: layer, className: "scum-layer-toggle", title: layer === "regions" ? "区域来自平台 scum_map_regions 记录,例如安全区或活动区域" : undefined }, e("input", { type: "checkbox", checked: view.mapLayers[layer], onChange: (event: InputEvent) => view.setMapLayers((previous) => ({ ...previous, [layer]: Boolean(event.target?.checked) })) }), layerLabel(layer))), - e("label", { key: "zones", className: "scum-layer-toggle" }, e("input", { type: "checkbox", checked: zonesOverlay, onChange: (event: InputEvent) => onZones(Boolean(event.target?.checked)) }), "建筑区"), - e("p", { className: "page-status" }, "区域 = 平台 scum_map_regions 里的地图区域记录(安全区 / 活动区域),没有写入记录时该图层为空。") + return e("div", { className: "console-field scum-map-menu-field" }, + e("span", null, "图层"), + e("details", { className: "scum-map-menu scum-map-filter-menu", "aria-label": "地图图层筛选", onMouseLeave: (event: InputEvent) => { if (event.currentTarget) event.currentTarget.open = false; } }, + e("summary", { className: "icon-command" }, `${enabled}/${layers.length}${zonesOverlay ? " + 建筑区" : ""}`), + e("div", { className: "scum-map-menu-body" }, + layers.map((layer) => e("label", { key: layer, className: "scum-layer-toggle", title: layer === "regions" ? "区域来自平台 scum_map_regions 记录,例如安全区或活动区域" : undefined }, e("input", { type: "checkbox", checked: view.mapLayers[layer], onChange: (event: InputEvent) => view.setMapLayers((previous) => ({ ...previous, [layer]: Boolean(event.target?.checked) })) }), layerLabel(layer))), + e("label", { key: "zones", className: "scum-layer-toggle" }, e("input", { type: "checkbox", checked: zonesOverlay, onChange: (event: InputEvent) => onZones(Boolean(event.target?.checked)) }), "建筑区"), + e("p", { className: "page-status" }, "区域 = 平台 scum_map_regions 里的地图区域记录(安全区 / 活动区域),没有写入记录时该图层为空。") + ) ) ); } @@ -814,86 +962,94 @@ function persistMapLayers(view: ViewState, actions: SCUMWorkspaceActions | undef runAction(view.setAction, "正在保存地图图层…", async () => { await saveMapSettings(actions, { baseLayer, buildingZones: zones }); view.refresh(); return "地图图层已保存。"; }); } -function mapPlaybackBar(e: ReactLike["createElement"], view: ViewState, span: { from: number; to: number }, tracks: MapTrack[], enabled: boolean) { +function mapPlaybackBar(e: ReactLike["createElement"], view: ViewState, span: { from: number; to: number }, tracks: MapTrack[], enabled: boolean, summary: string) { const cursor = playbackCursor(span, view.mapPlayback); const playing = view.mapPlayback.playing; - const start = () => { - const value = view.mapPlayback.value >= 1 ? 0 : view.mapPlayback.value; + const start = (restart = false) => { + view.mapFollowRef.current = true; + const value = restart || view.mapPlayback.value >= 1 ? 0 : view.mapPlayback.value; const next = rebaseMapPlayback(view.mapPlayback, { playing: true, value }); view.setMapPlayback(next); - if (value === 0) { const camera = playbackStartView(tracks, view.mapViewportRef.current); if (camera) view.setMapView(camera); } + const camera = playbackFollowView(tracks, next, span, view.mapViewportRef.current); + if (camera) view.setMapView(camera); }; return e("div", { className: "map-playback-bar" }, + e("button", { type: "button", className: "primary-command", disabled: !enabled, "aria-label": playing ? "暂停轨迹回放" : "播放轨迹回放", onClick: () => { if (playing) view.setMapPlayback(rebaseMapPlayback(view.mapPlayback, { playing: false })); else start(); } }, playing ? "暂停" : Number.isFinite(cursor) && view.mapPlayback.value < 1 ? "继续回放" : "开始回放"), + e("span", { className: "map-playback-time" }, Number.isFinite(cursor) ? `${view.mapPlayback.value >= 1 ? "回放结束" : playing ? "播放中" : "已暂停"} · ${timeLabel(cursor)}` : "选择用户与时间后开始回放"), + e("span", { className: "page-status scum-map-summary map-playback-summary" }, summary), + e("progress", { className: "map-playback-progress", max: 1, value: Number.isFinite(cursor) ? view.mapPlayback.value : 0, "aria-label": "回放完成比例" }), e("details", { className: "scum-map-menu", "aria-label": "轨迹回放" }, - e("summary", { className: "icon-command" }, playing ? "回放 播放中" : "回放"), + e("summary", { className: "icon-command" }, "回放设置"), e("div", { className: "scum-map-menu-body" }, - e("p", { className: "page-status" }, Number.isFinite(cursor) ? `回放位置 ${timeLabel(cursor)}` : "回放位置 全部"), - e("div", { className: "console-row-actions" }, - e("button", { type: "button", className: playing ? "primary-command" : "icon-command", disabled: !enabled, "aria-label": playing ? "暂停轨迹回放" : "播放轨迹回放", onClick: () => { if (playing) view.setMapPlayback(rebaseMapPlayback(view.mapPlayback, { playing: false })); else start(); } }, playing ? "暂停" : "开始"), - e("button", { type: "button", className: "icon-command", disabled: !view.mapPlayback.playing && view.mapPlayback.value >= 1, onClick: () => view.setMapPlayback(scumMapPlaybackIdle) }, "回到最新") - ), labeledField(e, "速度", e("select", { value: String(view.mapPlayback.speed), "aria-label": "轨迹回放速度", onChange: (event: InputEvent) => view.setMapPlayback((previous) => rebaseMapPlayback(previous, { speed: Number(inputValue(event)) || 1 })) }, [1, 2, 4].map((speed) => e("option", { key: speed, value: String(speed) }, `${speed}x`)))), - e("p", { className: "page-status" }, tracks.filter((track) => track.kind === "player").length === 1 ? "单人回放:镜头跟随并放到最大比例。" : "多人回放:镜头框住全部所选用户。") + e("div", { className: "console-row-actions" }, + e("button", { type: "button", className: "icon-command", disabled: !enabled, onClick: () => start(true) }, "从头播放"), + e("button", { type: "button", className: "icon-command", disabled: view.mapPlayback.startedAt === 0, onClick: () => view.setMapPlayback(scumMapPlaybackIdle) }, "查看完整轨迹")), + e("p", { className: "page-status" }, "1x 用 30 秒回放本段记录;虚线为步行,实线为乘车。") ) ) ); } -function mapTrackShapes(e: ReactLike["createElement"], tracks: MapTrack[], cursor: number, stroke: number) { +function mapTrackShapes(e: ReactLike["createElement"], tracks: MapTrack[], segments: Map, cursor: number) { const shapes: unknown[] = []; - for (const track of tracks) for (const segment of mapTrackSegments(track)) { + for (const track of tracks) for (const segment of segments.get(track.key) ?? []) { const points = segment.points.filter((point) => point.time <= cursor); + const next = segment.points[points.length]; + if (points.length && next && Number.isFinite(cursor)) { + const previous = points[points.length - 1]; + const ratio = (cursor - previous.time) / (next.time - previous.time); + points.push({ ...previous, x: previous.x + (next.x - previous.x) * ratio, y: previous.y + (next.y - previous.y) * ratio }); + } if (points.length < 2) continue; - shapes.push(e("polyline", { key: `${segment.key}:${points.length}`, points: points.map((point) => `${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(" "), fill: "none", stroke: track.color, strokeWidth: segment.mode === "ride" ? stroke * 1.2 : stroke, strokeLinecap: "round", strokeLinejoin: "round", strokeDasharray: segment.mode === "walk" ? `${stroke * 2.4} ${stroke * 1.6}` : undefined, opacity: segment.mode === "ride" ? 0.95 : 0.75 })); + shapes.push(e("polyline", { key: segment.key, points: points.map((point) => `${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(" "), fill: "none", stroke: track.color, strokeWidth: segment.mode === "ride" ? 3 : 2.5, vectorEffect: "non-scaling-stroke", strokeLinecap: "round", strokeLinejoin: "round", strokeDasharray: segment.mode === "walk" ? "6 4" : undefined, opacity: segment.mode === "ride" ? 0.95 : 0.8 })); } return shapes; } -function mapTrackMarkers(e: ReactLike["createElement"], tracks: MapTrack[], cursor: number, catalog: Map, playing: boolean) { - const markers: unknown[] = []; - for (const track of tracks) { - const points = track.points.filter((point) => point.time <= cursor); - if (!points.length) continue; - if (Number.isFinite(cursor)) { - const position = mapTrackPositionAt(track, cursor); - if (position) markers.push(mapLiveMarker(e, track, position, catalog, playing)); - continue; - } - const first = points[0]; - const last = points[points.length - 1]; - markers.push(e("span", { key: `${track.key}:start`, className: `map-track-endpoint map-track-start map-layer-${track.kind === "player" ? "players" : "vehicles"}`, style: mapSceneStyle(first), title: `${track.label} 起点 ${timeLabel(first.time)}` }, "起")); - if (last !== first) markers.push(e("span", { key: `${track.key}:end`, className: "map-track-endpoint map-track-end", style: mapSceneStyle(last), title: `${track.label} 最新 ${timeLabel(last.time)}` }, "终")); - let placed = 0; - for (const segment of mapTrackSegments(track)) { - if (placed >= 4 || segment.mode !== "ride" || segment.points.length < 2) continue; - const visible = segment.points.filter((point) => point.time <= cursor); - if (!visible.length) continue; - const middle = visible[Math.floor(visible.length / 2)]; - const code = middle.rideCode || mapRideCode(middle.row) || track.label; - const icon = trajectoryVehicleIcon(middle.row) || vehicleIconByCode(catalog.get(code) ?? code); - placed += 1; - markers.push(e("span", { key: `${segment.key}:ride`, className: "map-ride-marker", style: mapSceneStyle(middle), title: `${track.label} 乘坐 ${code}` }, icon ? e("img", { src: icon, alt: "", loading: "lazy", decoding: "async" }) : "车")); - } - } - return markers; +function mapTrackMarkers(e: ReactLike["createElement"], tracks: MapTrack[], cursor: number, catalog: Map, active: boolean, view: ViewState) { + // One named marker per user; the same marker becomes a vehicle when the recorded user mounts it. + return tracks.flatMap((track) => { + const point = mapTrackPositionAt(track, cursor); + return point ? [mapLiveMarker(e, track, point, catalog, active, view)] : []; + }); } // The moving marker is what makes playback readable: the selected users stay enlarged, and a ridden // vehicle swaps the dot for the vehicle icon the way a navigation app draws a car instead of a pin. -function mapLiveMarker(e: ReactLike["createElement"], track: MapTrack, point: MapTrackPoint, catalog: Map, playing: boolean) { +function mapLiveMarker(e: ReactLike["createElement"], track: MapTrack, point: MapTrackPoint, catalog: Map, active: boolean, view: ViewState) { const rideCode = point.rideCode; const icon = rideCode ? vehicleIconByCode(catalog.get(rideCode) ?? rideCode) : ""; - const label = rideCode ? `${track.label} · ${scumVehicleEntry(rideCode)?.name || catalog.get(rideCode) || "载具"}` : track.label; - const className = ["map-track-live", playing ? "is-playing" : "", rideCode ? "map-track-live-riding" : "", track.kind === "vehicle" ? "map-track-live-vehicle" : ""].filter(Boolean).join(" "); - return e("span", { key: `${track.key}:live`, className, style: { ...mapSceneStyle(point), "--track-color": track.color }, title: `${label} ${timeLabel(point.time)}` }, + const vehicleCode = catalog.get(rideCode) ?? rideCode; + const label = rideCode ? `${track.label} · ${scumVehicleEntry(vehicleCode)?.name || vehicleCode || "载具"}` : track.label; + const className = ["map-track-live", active ? "is-active" : "", rideCode ? "map-track-live-riding" : "", track.kind === "vehicle" ? "map-track-live-vehicle" : ""].filter(Boolean).join(" "); + const hoverKey = `track:${track.key}`; + const close = () => view.setHoverMapPoint((current) => current === hoverKey ? "" : current); + return e("button", { key: `${track.key}:live`, type: "button", className, style: { ...mapSceneStyle(point), "--track-color": track.color }, title: `${label} ${timeLabel(point.time)}`, "aria-label": label, onPointerEnter: () => view.setHoverMapPoint(hoverKey), onPointerLeave: close, onFocus: () => view.setHoverMapPoint(hoverKey), onBlur: close, onPointerDown: (event: PointerEventLike) => event.stopPropagation?.(), onClick: () => view.setSelectedMapPoint(hoverKey) }, icon ? e("img", { className: "map-track-live-icon", src: icon, alt: "", loading: "lazy", decoding: "async" }) : e("span", { className: "map-track-live-dot" }), e("strong", { className: "map-track-live-label" }, track.label) ); } +function mapTrackHoverRow(row: RecordMap, data: SCUMSurfaceData): RecordMap { + const layer = layerOf(row); + if (layer === "vehicles") { + const identity = textField(row, "gameVehicleId", "vehicleId", "subjectId", "id"); + const vehicle = vehicleForIdentity(data.vehicles, identity); + return vehicle ? { ...vehicle, ...row } : row; + } + if (layer === "players") { + const identity = mapPlayerTrackIdentity(row); + const player = data.players.find((candidate) => playerIdentities(candidate).includes(identity)); + // An absent ride ID on a historical sample must not inherit the player's current vehicle. + return { ...player, ...row, riddenVehicleId: textField(row, "riddenVehicleId"), gameVehicleId: textField(row, "gameVehicleId") }; + } + return row; +} + function mapSceneStyle(point: MapTrackPoint): Record { return { left: `${Math.max(0, Math.min(100, point.x / scumMapSize * 100))}%`, top: `${Math.max(0, Math.min(100, point.y / scumMapSize * 100))}%` }; } -function mapTimeWindow(view: ViewState): MapTimeWindow { +function mapTimeWindow(view: Pick): MapTimeWindow { const from = parseTimeInput(view.mapTimeFrom); const to = parseTimeInput(view.mapTimeTo); if (from === undefined && to === undefined) return { from: -Infinity, to: Infinity, label: "全部时段" }; @@ -928,25 +1084,20 @@ function applyMapTimePreset(setPreset: StateSetter, setFrom: StateSetter function mapTracks(data: SCUMSurfaceData, bounds: RecordMap, window: MapTimeWindow, users: string[] | undefined, catalog: Map): MapTrack[] { const grouped = new Map(); const windowRows = data.trajectories.filter((row) => { const time = trajectoryOrder(row); return time >= window.from && time <= window.to; }); - const rideSamples = mapRideSamples(data, bounds); - const riddenVehicles = new Set(); - if (users !== undefined) for (const row of windowRows) { - if (layerOf(row) === "vehicles" || !users.includes(mapPlayerTrackIdentity(row))) continue; - const vehicle = playerRide(row, rideSamples, bounds)?.key ?? ""; - if (vehicle) riddenVehicles.add(vehicle); - } for (const row of windowRows) { + if (layerOf(row) !== "players") continue; const position = positionOf(row); if (!hasCoordinates(position)) continue; - const kind: "player" | "vehicle" = layerOf(row) === "vehicles" ? "vehicle" : "player"; - const identity = kind === "player" ? mapPlayerTrackIdentity(row) : textField(row, "gameVehicleId", "vehicleId", "subjectId", "id"); + const kind = "player"; + const identity = mapPlayerTrackIdentity(row); if (!identity) continue; - if (kind === "player" ? users !== undefined && !users.includes(identity) : users !== undefined && !riddenVehicles.has(identity)) continue; + if (users !== undefined && !users.includes(identity)) continue; const time = trajectoryOrder(row); const scene = mapScenePoint(row, bounds); - const rideCode = kind === "player" ? playerRideCode(row, rideSamples, bounds) : mapRideCode(row); + // A nearby vehicle is not evidence of riding; only the recorded mount relationship is used. + const rideCode = mapRiddenVehicleId(row); const key = `${kind}:${identity}`; - const track = grouped.get(key) ?? { key, kind, label: (kind === "player" ? textField(row, "displayName", "name") : textField(row, "label", "name", "className")) || identity, color: mapTrackColor(identity), points: [], walk: 0, ride: 0, from: time, to: time, vehicles: [] }; + const track = grouped.get(key) ?? { key, kind, label: textField(row, "displayName", "name") || identity, color: mapTrackColor(identity), points: [], walk: 0, ride: 0, from: time, to: time, vehicles: [] }; track.points.push({ key: `${key}:${track.points.length}`, row, x: scene.x, y: scene.y, time, riding: Boolean(rideCode), rideCode }); if (rideCode && !track.vehicles.includes(rideCode)) track.vehicles.push(rideCode); track.from = Math.min(track.from, time); @@ -956,29 +1107,18 @@ function mapTracks(data: SCUMSurfaceData, bounds: RecordMap, window: MapTimeWind const tracks = [...grouped.values()]; for (const track of tracks) { track.points.sort((left, right) => left.time - right.time); - if (track.points.length > scumMapTrackPointLimit) track.points = track.points.slice(-scumMapTrackPointLimit); + track.from = track.points[0].time; + track.to = track.points[track.points.length - 1].time; track.walk = track.points.filter((point) => !point.riding).length; track.ride = track.points.length - track.walk; track.vehicles = track.vehicles.map((code) => catalog.get(code) ?? code); } - return tracks.sort((left, right) => right.to - left.to).slice(0, scumMapTrackLimit); + // The platform surface already bounds records; preserve every returned selected-user sample. + return tracks.sort((left, right) => right.to - left.to); } function mapPlayerTrackIdentity(row: RecordMap): string { return textField(row, "steamId", "subjectId", "gamePlayerId", "displayName", "id"); } -function mapRiddenVehicleId(row: RecordMap): string { return textField(row, "riddenVehicleId", "gameVehicleId", "vehicleId"); } - -// Explicit ridden_vehicle_id wins; otherwise the vehicle sample from the same poll window is used. -function playerRide(row: RecordMap, samples: MapRideSample[], bounds: RecordMap): MapRideSample | undefined { - const explicit = mapRiddenVehicleId(row); - if (explicit) return { key: explicit, code: mapRideCode(row), time: trajectoryOrder(row), scene: mapScenePoint(row, bounds) }; - return mapRiddenSample(samples, row, bounds); -} - -function playerRideCode(row: RecordMap, samples: MapRideSample[], bounds: RecordMap): string { - const ride = playerRide(row, samples, bounds); - if (!ride) return ""; - return ride.code || "vehicle"; -} +function mapRiddenVehicleId(row: RecordMap): string { return textField(row, "gameVehicleId") || textField(row, "riddenVehicleId") || textField(row, "vehicleId"); } function mapTrackUsers(data: SCUMSurfaceData, window: MapTimeWindow): Array<{ key: string; label: string; count: number }> { const users = new Map(); @@ -992,6 +1132,16 @@ function mapTrackUsers(data: SCUMSurfaceData, window: MapTimeWindow): Array<{ ke current.count += 1; users.set(identity, current); } + for (const player of data.players) { + const identity = textField(player, "steamId", "gamePlayerId", "id"); + if (!identity || users.has(identity)) continue; + const lastSeen = Date.parse(textField(player, "lastActivityAt", "lastSeenAt")); + const login = Date.parse(textField(player, "lastLoginAt")); + const observedInWindow = Number.isFinite(lastSeen) && lastSeen >= window.from && lastSeen <= window.to; + const sessionInWindow = Number.isFinite(login) && Number.isFinite(lastSeen) && login <= window.to && lastSeen >= window.from; + if (!observedInWindow && !sessionInWindow) continue; + users.set(identity, { key: identity, label: textField(player, "displayName", "name") || identity, count: 0 }); + } return [...users.values()].sort((left, right) => right.count - left.count || left.label.localeCompare(right.label)); } @@ -1005,16 +1155,20 @@ function toggleMapUser(selected: string[] | undefined, key: string, checked: boo function mapTrackSegments(track: MapTrack): MapTrackSegment[] { const segments: MapTrackSegment[] = []; let current: MapTrackPoint[] = []; - let mode: "walk" | "ride" = track.kind === "vehicle" ? "ride" : "walk"; - for (const point of track.points) { - const nextMode: "walk" | "ride" = track.kind === "vehicle" || point.riding ? "ride" : "walk"; - const previous = current[current.length - 1]; - const broken = previous ? nextMode !== mode || point.time - previous.time > scumMapSegmentGapMs || Math.hypot(point.x - previous.x, point.y - previous.y) > scumMapSegmentJump : false; - if (broken) { segments.push({ key: `${track.key}:segment-${segments.length}`, mode, points: current }); current = []; } + let mode: "walk" | "ride" = "walk"; + for (let index = 0; index < track.points.length; index += 1) { + const point = track.points[index]; + const previous = track.points[index - 1]; + const nextMode = previous?.riding ? "ride" : "walk"; + const gap = previous && point.time - previous.time > scumMapSegmentGapMs; + if (previous && (gap || nextMode !== mode)) { + if (current.length > 1) segments.push({ key: `${track.key}:segment-${segments.length}`, mode, points: current }); + current = gap ? [] : [previous]; + } mode = nextMode; current.push(point); } - if (current.length) segments.push({ key: `${track.key}:segment-${segments.length}`, mode, points: current }); + if (current.length > 1) segments.push({ key: `${track.key}:segment-${segments.length}`, mode, points: current }); return segments; } @@ -1030,14 +1184,13 @@ function mapVehicleClassIndex(data: SCUMSurfaceData): Map { return index; } -function mapRideCode(row: RecordMap): string { return textField(row, "className", "vehicleClass", "entityClass", "vehicleType", "riddenVehicleId", "gameVehicleId", "vehicleId"); } - // Playback covers the recorded samples, not the whole filter window: a 24h window with two hours of // data would otherwise spend most of the run showing an empty map. function mapPlaybackSpan(window: MapTimeWindow, tracks: MapTrack[]): { from: number; to: number } { - const times = tracks.length ? [Math.min(...tracks.map((track) => track.from)), Math.max(...tracks.map((track) => track.to))] : [0, 0]; - if (times[1] > times[0]) return { from: times[0], to: times[1] }; - return { from: Number.isFinite(window.from) ? window.from : times[0], to: Number.isFinite(window.to) ? window.to : times[1] }; + const players = tracks.filter((track) => track.kind === "player"); + const subjects = players.length ? players : tracks; + if (subjects.length) return { from: Math.min(...subjects.map((track) => track.from)), to: Math.max(...subjects.map((track) => track.to)) }; + return { from: Number.isFinite(window.from) ? window.from : 0, to: Number.isFinite(window.to) ? window.to : 0 }; } export function advanceMapPlayback(previous: MapPlaybackState, now = Date.now()): MapPlaybackState { @@ -1048,65 +1201,33 @@ export function advanceMapPlayback(previous: MapPlaybackState, now = Date.now()) } function rebaseMapPlayback(previous: MapPlaybackState, patch: Partial, now = Date.now()): MapPlaybackState { + if (previous.startedAt === 0 && patch.playing !== true) return { ...previous, ...patch }; const current = advanceMapPlayback(previous, now); return { ...current, ...patch, startedAt: now, startValue: patch.value ?? current.value }; } function playbackCursor(span: { from: number; to: number }, playback: MapPlaybackState): number { - return playback.value >= 1 ? Infinity : span.from + (span.to - span.from) * playback.value; + return playback.startedAt === 0 ? Infinity : span.from + (span.to - span.from) * playback.value; } // A single selected user fills the viewport at the maximum zoom and is followed; several users are // framed together, so two neighbouring players still reach the maximum zoom while two players on // opposite corners fall back to the minimum. -function playbackStartView(tracks: MapTrack[], node: HTMLElement | null): MapViewState | undefined { +export function playbackStartView(tracks: MapTrack[], node: HTMLElement | null): MapViewState | undefined { const players = tracks.filter((track) => track.kind === "player" && track.points.length); if (players.length === 1) return focusMapView(players[0].points[0], node, scumMapZoomMax); - if (players.length > 1) return fitMapView(mapTrackPointList(players), node, 0.8); + if (players.length > 1) return fitMapView(players.map((track) => track.points[0]), node, 0.8); return undefined; } -function playbackFollowView(tracks: MapTrack[], playback: MapPlaybackState, span: { from: number; to: number }, node: HTMLElement | null): MapViewState | undefined { - if (!playback.playing) return undefined; +export function playbackFollowView(tracks: MapTrack[], playback: MapPlaybackState, span: { from: number; to: number }, node: HTMLElement | null): MapViewState | undefined { const players = tracks.filter((track) => track.kind === "player" && track.points.length); - if (players.length !== 1) return undefined; - const position = mapTrackPositionAt(players[0], playbackCursor(span, playback)); - return position ? focusMapView(position, node, scumMapZoomMax) : undefined; -} - -// The player trajectory table only carries ridden_vehicle_id when the collector fills it. Until then -// every player sample is matched against the vehicle samples recorded in the same poll window, so the -// surrounding vehicle still shows up as a ride marker instead of a walk segment. -function mapRideSamples(data: SCUMSurfaceData, bounds: RecordMap): MapRideSample[] { - const samples: MapRideSample[] = []; - for (const row of data.trajectories) { - if (layerOf(row) !== "vehicles") continue; - const position = positionOf(row); - if (!hasCoordinates(position)) continue; - const time = trajectoryOrder(row); - const key = textField(row, "gameVehicleId", "vehicleId", "subjectId", "id"); - if (!key) continue; - samples.push({ key, code: mapRideCode(row), time, scene: mapScenePoint(row, bounds) }); - } - return samples.sort((left, right) => left.time - right.time); -} - -function mapRiddenSample(samples: MapRideSample[], row: RecordMap, bounds: RecordMap): MapRideSample | undefined { - const position = positionOf(row); - if (!hasCoordinates(position)) return undefined; - const time = trajectoryOrder(row); - const scene = mapScenePoint(row, bounds); - let best: MapRideSample | undefined; - let bestDistance = Infinity; - for (const sample of samples) { - const delta = Math.abs(sample.time - time); - if (delta > scumMapRideWindowMs) continue; - const distance = Math.hypot(sample.scene.x - scene.x, sample.scene.y - scene.y); - if (distance > scumMapRideDistance || distance >= bestDistance) continue; - best = sample; - bestDistance = distance; - } - return best; + const positions = players.flatMap((track) => { + const point = mapTrackPositionAt(track, playbackCursor(span, playback)); + return point ? [point] : []; + }); + if (positions.length === 1) return focusMapView(positions[0], node, scumMapZoomMax); + return positions.length ? fitMapView(positions, node, 0.8) : undefined; } function mapTrackPositionAt(track: MapTrack, cursor: number): MapTrackPoint | undefined { @@ -1117,12 +1238,11 @@ function mapTrackPositionAt(track: MapTrack, cursor: number): MapTrackPoint | un for (let index = 1; index < points.length; index += 1) { const previous = points[index - 1]; const next = points[index]; - if (cursor > next.time) continue; + if (cursor >= next.time) continue; const gap = next.time - previous.time; if (gap <= 0 || gap > scumMapSegmentGapMs) return { ...previous, time: cursor }; const ratio = (cursor - previous.time) / gap; - const riding = ratio < 0.5 ? previous.riding : next.riding; - return { key: `${track.key}:live`, row: riding ? next.row : previous.row, x: previous.x + (next.x - previous.x) * ratio, y: previous.y + (next.y - previous.y) * ratio, time: cursor, riding, rideCode: riding ? (next.rideCode || previous.rideCode) : "" }; + return { ...previous, key: `${track.key}:live`, x: previous.x + (next.x - previous.x) * ratio, y: previous.y + (next.y - previous.y) * ratio, time: cursor }; } return points[points.length - 1]; } @@ -1152,9 +1272,9 @@ function fitMapView(points: MapTrackPoint[], node: HTMLElement | null, padding: if (!points.length) return scumMapDefaultView; let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; for (const point of points) { minX = Math.min(minX, point.x); maxX = Math.max(maxX, point.x); minY = Math.min(minY, point.y); maxY = Math.max(maxY, point.y); } - const span = Math.max(48, maxX - minX, maxY - minY); - const zoom = clampMapZoom(scumMapSize * padding / span); const size = Math.max(240, Number(node?.clientHeight) || 640); + const width = Number(node?.clientWidth) || size; + const zoom = clampMapZoom(Math.min(scumMapSize * padding * width / size / Math.max(48, maxX - minX), scumMapSize * padding / Math.max(48, maxY - minY))); return { zoom, x: -((minX + maxX) / 2 / scumMapSize - 0.5) * size * zoom, y: -((minY + maxY) / 2 / scumMapSize - 0.5) * size * zoom }; } @@ -1172,8 +1292,11 @@ function mapTrackColor(identity: string): string { function timeLabel(value: number): string { if (!Number.isFinite(value)) return "不限"; const date = new Date(value); return Number.isNaN(date.getTime()) ? "unknown" : date.toLocaleString(); } function shortTimeLabel(value: number): string { if (!Number.isFinite(value)) return "不限"; const date = new Date(value); if (Number.isNaN(date.getTime())) return "unknown"; const pad = (part: number) => String(part).padStart(2, "0"); return `${date.getMonth() + 1}/${date.getDate()} ${pad(date.getHours())}:${pad(date.getMinutes())}`; } -function mapPointStrip(e: ReactLike["createElement"], point: RecordMap, trails: RecordMap[], locks: RecordMap[]) { +function mapPointStrip(e: ReactLike["createElement"], point: RecordMap, trails: RecordMap[], locks: RecordMap[], data: SCUMSurfaceData) { + const layer = layerOf(point); const icon = layerOf(point) === "vehicles" ? vehicleIconFor(point) : ""; + const ride = mapRiddenVehicleId(point); + const riddenVehicle = ride ? vehicleForIdentity(data.vehicles, ride) : undefined; return e("article", { className: "console-module map-point-strip" }, e("div", { className: "map-point-strip-head" }, e("h2", null, "地图点详情"), @@ -1184,7 +1307,10 @@ function mapPointStrip(e: ReactLike["createElement"], point: RecordMap, trails: e("span", { className: "provider-id" }, `ID ${textField(point, "subjectId", "id", "_recordKey") || "unknown"}`), e("span", { className: "provider-id" }, `来源 ${textField(point, "source") || "平台表"}`), e("span", { className: "provider-id" }, freshness(point)), - layerOf(point) === "vehicles" ? e("span", { className: "provider-id" }, `${textField(point, "className", "vehicleClass", "vehicleType") || "unknown"} · ${textField(point, "status", "state") || vehicleFunctionalLabel(point) || "unknown"} · ${boolField(point, "locked") ? "已上锁" : "未上锁/未知"} · 访问 ${dateField(point, "lastAccessTime", "vehicleObservedAt", "sampledAt")}`) : null), + layer === "players" ? e("span", { className: "provider-id" }, `用户 ${textField(point, "displayName", "name", "label") || pointTitle(point)} · ${playerOnline(point) ? "在线" : "离线"}${ride ? ` · 乘坐 ${riddenVehicle ? vehicleLabelFor(riddenVehicle) : ride}` : ""}`) : null, + layer === "vehicles" ? e("span", { className: "provider-id" }, `${textField(point, "className", "vehicleClass", "vehicleType") || "unknown"} · ${textField(point, "status", "state") || vehicleFunctionalLabel(point) || "unknown"} · ${boolField(point, "locked") ? "已上锁" : "未上锁/未知"} · 访问 ${dateField(point, "lastAccessTime", "vehicleObservedAt", "sampledAt")}`) : null, + layer === "vehicles" ? e("span", { className: "provider-id" }, `载具 ${vehicleLabelFor(point)} · 耐久 ${vehicleDurabilityLabel(point) || "未同步"} · ${vehicleLockLabel(point, data)}`) : null, + layer === "flags" ? e("span", { className: "provider-id" }, `归属 ${mapOwnerLabel(data, point) || "未同步"} · 附属队伍 ${textField(point, "ownerSquadId", "squadId") ? mapSquadLabel(data, textField(point, "ownerSquadId", "squadId")) : "无附属队伍"}`) : null), locks.length ? e("div", { className: "console-row-list" }, locks.map((row, index) => e("div", { key: `selected-lock-${index}`, className: "console-row" }, e("span", null, dateField(row, "lockedAt", "createdAt")), e("strong", null, textField(row, "scumUserId") || "unknown"), e("strong", null, textField(row, "steamId") || "unknown")))) : null, trails.length ? e("div", { className: "console-row-list" }, trails.map((row, index) => e("div", { key: `selected-trail-${index}`, className: "console-row" }, e("span", null, dateField(row, "sampledAt", "observedAt")), e("strong", null, coords(row)), e("strong", null, textField(row, "source") || "平台轨迹表")))) : null ); @@ -1230,11 +1356,6 @@ function giftCatalogIcons(gift: RecordMap): string[] { return Array.isArray(items) ? items.flatMap((item) => isRecord(item) ? catalogIconsForValue(textField(item, "catalogCode", "catalogItemKey", "key", "className")) : []).slice(0, 12) : []; } -function trajectoryVehicleIcon(row: RecordMap): string { - const code = textField(row, "className", "vehicleClass", "entityClass", "vehicleType", "riddenVehicleId", "gameVehicleId", "vehicleId"); - return code ? scumVehicleIconUrl(code) || scumVehicleFallbackIconUrl(code) : ""; -} - function catalogPicker(e: ReactLike["createElement"], view: ViewState, target: CatalogPickTarget) { const matches = catalogMatches(view.catalogSearch, view.catalogKind); return e("details", { className: "console-module scum-editor scum-catalog-picker", open: view.catalogOpen, onToggle: (event: InputEvent) => view.setCatalogOpen(detailOpen(event)) }, @@ -1287,7 +1408,15 @@ function positionOf(row: RecordMap | undefined): RecordMap | undefined { const n function hasCoordinates(row: RecordMap | undefined): row is RecordMap { return Boolean(row) && Number.isFinite(Number(field(row, "x", "locationX"))) && Number.isFinite(Number(field(row, "y", "locationY"))); } function layerOf(point: RecordMap): MapLayer { const value = textField(point, "layer", "subjectType", "type").toLowerCase(); if (value.includes("player") || value.includes("user")) return "players"; if (value.includes("vehicle")) return "vehicles"; if (value.includes("flag")) return "flags"; if (value.includes("region") || value.includes("zone") || value === "base") return "regions"; return "other"; } function layerLabel(layer: MapLayer): string { return layer === "players" ? "用户" : layer === "vehicles" ? "载具" : layer === "flags" ? "旗帜" : layer === "regions" ? "区域" : "其他"; } -function pointTitle(point: RecordMap): string { return textField(point, "name", "label", "subjectName") || textField(point, "subjectType", "type") || textField(point, "subjectId", "id") || "地图点"; } +function mapVehicleCategory(point: RecordMap): MapVehicleFilter { + const entry = scumVehicleEntry(textField(point, "className", "vehicleClass", "entityClass", "vehicleType", "name", "label")); + const value = [textField(point, "vehicleType", "className", "vehicleClass", "entityClass", "name", "label"), entry?.code || "", entry?.name || ""].join(" ").toLowerCase(); + if (/kinglet|duster|mariner|scout|aircraft|airplane|aeroplane|helicopter|plane|flight|飞行/.test(value)) return "air"; + if (/boat|dinghy|raft|motorboat|barba|sup|water|船|水上/.test(value)) return "water"; + if (/bike|bicycle|motorcycle|sidecar|cruiser|laika|rager|ris|wolfswagen|tractor|pickup|suv|quad|sportbike|wheelbarrow|car|land|陆地/.test(value)) return "land"; + return "other"; +} +function pointTitle(point: RecordMap): string { return textField(point, "displayName", "name", "label", "subjectName") || textField(point, "subjectType", "type") || textField(point, "subjectId", "id") || "地图点"; } function mapPointIdentity(point: RecordMap): string { const subject = textField(point, "subjectId", "gamePlayerId", "vehicleId", "flagId", "regionId"); if (subject) return `${layerOf(point)}:${subject}`; const record = textField(point, "id", "_recordKey"); if (record) return `${layerOf(point)}:${record}`; return `${layerOf(point)}:${pointTitle(point).toLowerCase()}:${numField(point, "x", "locationX")}:${numField(point, "y", "locationY")}`; } export function mapScenePoint(point: RecordMap, bounds: RecordMap): { x: number; y: number } { const x = Number(field(point, "x", "locationX") ?? 0); const y = Number(field(point, "y", "locationY") ?? 0); @@ -1306,7 +1435,7 @@ export function mapPointStyle(point: RecordMap, bounds: RecordMap): Record e("span", { key: `v-${value}`, className: "map-grid-line map-grid-line-v", style: { left: `${value}%` } })), breaks.map((value) => e("span", { key: `h-${value}`, className: "map-grid-line map-grid-line-h", style: { top: `${value}%` } })), cols.map((label, index) => e("span", { key: `c-${label}`, className: "map-grid-label map-grid-col-label", style: { left: `${(index + 0.5) * 20}%` } }, label)), rows.map((label, index) => e("span", { key: `r-${label}`, className: "map-grid-label map-grid-row-label", style: { top: `${(index + 0.5) * 20}%` } }, label))); + return e("div", { key: "grid", className: "map-grid-overlay", "aria-hidden": "true" }, breaks.map((value) => e("span", { key: `v-${value}`, className: "map-grid-line map-grid-line-v", style: { left: `${value}%` } })), breaks.map((value) => e("span", { key: `h-${value}`, className: "map-grid-line map-grid-line-h", style: { top: `${value}%` } })), cols.map((label, index) => e("span", { key: `c-${label}`, className: "map-grid-label map-grid-col-label", style: { left: `${(index + 0.5) * 20}%` } }, label)), rows.map((label, index) => e("span", { key: `r-${label}`, className: "map-grid-label map-grid-row-label", style: { top: `${(index + 0.5) * 20}%` } }, label))); } function trajectoryRecordsForPoint(rows: RecordMap[], point: RecordMap): RecordMap[] { const ids = new Set([textField(point, "subjectId"), textField(point, "steamId"), textField(point, "gamePlayerId"), textField(point, "vehicleId"), textField(point, "id")].filter(Boolean)); const layer = layerOf(point); return rows.filter((row) => layerOf(row) === layer && trajectoryIdentity(row).some((identity) => ids.has(identity))); } @@ -1323,15 +1452,13 @@ export function bundledVehicleImageUrl(value: string): string { } function vehicleIconFor(point: RecordMap): string { - const explicit = bundledVehicleImageUrl(textField(point, "imagePath", "image_path")); - if (explicit) return explicit; return vehicleIconByCode(textField(point, "className", "vehicleClass", "entityClass", "vehicleType")); } function vehicleIconByCode(code: string): string { if (!code.trim()) return ""; const normalized = normalizedVehicleClass(code); - return scumVehicleIconUrl(normalized) || scumVehicleFallbackIconUrl(normalized); + return scumMapVehicleIconUrl(normalized); } function vehicleLabelFor(point: RecordMap): string { @@ -1371,6 +1498,8 @@ function giftCommandsSummary(gift: RecordMap): string { const commands = field(g function giftItemsInput(gift: RecordMap): string { const items = field(gift, "items"); return Array.isArray(items) ? items.map((item) => isRecord(item) ? `${textField(item, "catalogCode", "catalogItemKey", "key")}:${numField(item, "quantity")}` : "").filter(Boolean).join(", ") : ""; } function giftCommandsInput(gift: RecordMap): string { const commands = field(gift, "commands"); return Array.isArray(commands) ? commands.map((item) => isRecord(item) ? textField(item, "command", "value") : String(item)).filter(Boolean).join("\n") : ""; } function giftClassLabel(value: string): string { return ({ "1": "每日", "2": "每周", "3": "每月", "4": "每年", "5": "一次", "6": "每日五次" } as Record)[value] ?? value; } +function giftAudienceLabel(value: string): string { return (({ all: "全部玩家", pve: "PVE 玩家", pvp: "PVP 玩家" } as Record)[value] ?? value) || "全部玩家"; } +function giftStatusLabel(value: string): string { return (({ active: "启用", enabled: "启用", running: "启用", scheduled: "启用", queued: "启用", disabled: "停用", inactive: "停用" } as Record)[value] ?? value) || "启用"; } function integerInput(value: string, fallback: number): number { const parsed = Number(value); return Number.isInteger(parsed) ? parsed : fallback; } function minimumIntegerInput(value: string, minimum: number, label: string): number { const parsed = Number(value); if (!Number.isSafeInteger(parsed) || parsed < minimum) throw new Error(`${label}必须是不小于 ${minimum} 的整数。`); return parsed; } function numberInput(value: string, fallback: number): number { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; } diff --git a/plugins/examples/scum-server-plugin/features/scum-catalog.ts b/plugins/examples/scum-server-plugin/features/scum-catalog.ts index 4211a45..6a5563e 100644 --- a/plugins/examples/scum-server-plugin/features/scum-catalog.ts +++ b/plugins/examples/scum-server-plugin/features/scum-catalog.ts @@ -5155,8 +5155,10 @@ export function vehicleAliasKeys(code: string): string[] { } const vehicleIconAliases: Record = {}; +const vehicleEntriesByIcon = new Map(); for (const entry of scumCatalogEntries) { if (entry.kind !== "vehicle" || !entry.icon) continue; + if (!vehicleEntriesByIcon.has(entry.icon)) vehicleEntriesByIcon.set(entry.icon, entry); for (const key of vehicleAliasKeys(entry.code)) vehicleIconAliases[key] = entry.icon; } @@ -5239,7 +5241,7 @@ export function scumVehicleEntry(vehicleCode: string): ScumCatalogEntry | undefi for (const key of vehicleAliasKeys(vehicleCode)) { const icon = vehicleIconAliases[key]; if (!icon) continue; - const entry = scumCatalogEntries.find((value) => value.kind === "vehicle" && value.icon === icon); + const entry = vehicleEntriesByIcon.get(icon); if (entry) return entry; } return scumCatalogEntryFor(vehicleCode); diff --git a/plugins/tests/scum-catalog.test.ts b/plugins/tests/scum-catalog.test.ts index 9c6ea09..864e304 100644 --- a/plugins/tests/scum-catalog.test.ts +++ b/plugins/tests/scum-catalog.test.ts @@ -4,6 +4,7 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { scumCatalogEntries, scumCatalogIconUrl, scumVehicleEntry, scumVehicleFallbackIconUrl, scumVehicleIconUrl } from "../examples/scum-server-plugin/features/scum-catalog.js"; +import { scumMapVehicleIconUrl } from "../examples/scum-server-plugin/features/map-vehicle-icons.js"; const pluginRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../examples/scum-server-plugin"); const catalogSource = readFileSync(join(pluginRoot, "features/scum-catalog.ts"), "utf8"); @@ -35,4 +36,14 @@ describe("scum catalog icons", () => { expect(scumVehicleFallbackIconUrl("Quad_01_D_ES")).toContain("data:image/svg+xml"); expect(decodeURIComponent(scumVehicleFallbackIconUrl("Dinghy_ES"))).toContain(" { + for (const code of ["Rager_ES", "Laika_ES", "BPC_Laika_C", "Dinghy_ES", "Tractor_ES"]) { + const icon = scumMapVehicleIconUrl(code); + expect(icon).toContain("/map/vehicles/"); + expect(existsSync(fileURLToPath(icon))).toBe(true); + expect(readFileSync(fileURLToPath(icon)).length).toBeLessThan(4000); + } + expect(scumVehicleIconUrl("Laika_ES")).toContain("scum-catalog"); + }); }); diff --git a/plugins/tests/scum-feature-module.test.ts b/plugins/tests/scum-feature-module.test.ts index b4df7e1..f524307 100644 --- a/plugins/tests/scum-feature-module.test.ts +++ b/plugins/tests/scum-feature-module.test.ts @@ -5,7 +5,8 @@ import { fileURLToPath } from "node:url"; import { migrateConfigurationRecord, migrateGiftGrantRecord, migratePlayerProfileRecord, migratePlayerRecord, migrateStatePatchRecord, migrateTrajectoryHistoryRecord, migrateTrajectoryRecord, migrationStatus } from "../examples/scum-server-plugin/features/migration.js"; import { createGiftDelivery, deleteGiftDefinition, loadSCUMSurface, parseGiftCommands, parseGiftItems, queueGiftDelivery, resetGiftClaim, resetPendingGift, resolveMapBounds, saveEventProduce, saveGiftDefinition, saveMapSettings, scumCollections, startEvent, type RecordMap, type SCUMSurfaceData, type SCUMWorkspaceActions } from "../examples/scum-server-plugin/features/page-data.js"; -import { advanceMapPlayback, bundledVehicleImageUrl, collectMapPoints, mapPointStyle } from "../examples/scum-server-plugin/features/page.js"; +import { advanceMapPlayback, bundledVehicleImageUrl, collectMapPoints, mapPointStyle, playbackStartView, playbackFollowView } from "../examples/scum-server-plugin/features/page.js"; +import type { MapTrack, MapTrackPoint } from "../examples/scum-server-plugin/features/contracts.js"; import { renderPluginPage } from "../examples/scum-server-plugin/page-bundle/index.js"; import { configurationCatalog, validateConfigPatch, validateStatePatch, validateVehicleSpawn, vehicleSpawnCatalog } from "../examples/scum-server-plugin/features/schemas.js"; import { scumMigrationParityFixtures } from "./fixtures/scum-migration-parity.js"; @@ -124,6 +125,7 @@ describe("SCUM plugin feature module", () => { const list = vi.fn(async (collection: string) => ({ items: [{ key: `${collection}-1`, value: { collection } }], count: 1 })); const dispatch = vi.fn>(async () => ({ status: "queued" })); await expect(loadSCUMSurface({ pluginData: pluginDataActions({ list }), scum: scumActions(), dispatch }, "live-map")).resolves.toMatchObject({ players: expect.any(Array), vehicles: expect.any(Array) }); + expect(list.mock.calls.map(([collection]) => collection)).toEqual([scumCollections.mapPoints, scumCollections.mapRegions, scumCollections.mapSettings, scumCollections.flags, scumCollections.squads, scumCollections.members, scumCollections.tradeGoods]); expect(dispatch).not.toHaveBeenCalled(); }); @@ -272,8 +274,12 @@ describe("SCUM plugin feature module", () => { expect(definitions.texts).toContain("物品列表"); expect(definitions.texts).toContain("Starter Pack"); expect(definitions.texts).toContain("Cargo Drop"); - expect(definitions.buttons.find((button) => button.label === "保存礼包")?.disabled).toBe(false); - expect(definitions.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["礼包周期", "适用玩家", "发放次数", "成就类型", "成就值", "礼包物品", "礼包命令"])); + expect(definitions.texts).toEqual(expect.arrayContaining(["礼包", "领取规则", "礼包内容", "次数 / 门槛", "状态", "最近更新", "操作"])); + expect(definitions.buttons.find((button) => button.label === "新建礼包")?.disabled).toBe(false); + expect(definitions.buttons.find((button) => button.label === "保存礼包")).toBeUndefined(); + expect(definitions.texts).not.toContain("先定义规则,再配置内容"); + expect(definitions.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["搜索礼包", "礼包状态"])); + expect(definitions.inputs.map((input) => input.label)).not.toContain("礼包周期"); const claims = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理", giftTab: "claims" }); expect(claims.texts).toContain("领取记录"); expect(claims.texts).toContain("claimed"); @@ -318,7 +324,7 @@ describe("SCUM plugin feature module", () => { expect(view.nodes).toContain("details:轨迹回放"); expect(view.texts).not.toContain("轨迹列表"); expect(view.elements.map((element) => element.label)).toContain("轨迹颜色图例"); - expect(view.buttons.map((button) => button.label)).toEqual(expect.arrayContaining(["全选", "清空", "不限", "开始", "回到最新"])); + expect(view.buttons.map((button) => button.label)).toEqual(expect.arrayContaining(["全选", "清空", "时段内全部", "开始回放", "从头播放", "查看完整轨迹"])); expect(pageSource).toContain('new URL("../assets/map/scum-map-building-zones-4096.png", import.meta.url).href'); expect(pageSource).toContain("mapBoardStyle(baseLayer, zonesOverlay)"); expect(pageSource).toContain("map-hover-card"); @@ -335,14 +341,14 @@ describe("SCUM plugin feature module", () => { const view = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图", data: riding, pageQuery: mapTestWindow }); expect(view.nodes).toContain("polyline:"); expect(view.texts).toEqual(expect.arrayContaining(["用户 · 4 点 · 步行 2 / 乘车 2"])); - expect(view.elements.filter((element) => element.className.includes("map-ride-marker")).length).toBeGreaterThan(0); - expect(view.elements.filter((element) => element.className.includes("map-track-endpoint")).length).toBeGreaterThan(1); + expect(view.elements.filter((element) => element.className.includes("map-track-live-riding"))).toHaveLength(1); + expect(view.elements.filter((element) => element.className.includes("map-track-endpoint"))).toHaveLength(0); expect(view.texts).toContain("Mira · 4 点"); }); it("keeps the extracted icon catalog closed until an editor asks for it", () => { const gifts = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理" }); - expect(gifts.texts).toContain("从 SCUM 目录选择礼包物品"); + expect(gifts.texts).not.toContain("从 SCUM 目录选择礼包物品"); expect(gifts.inputs.map((input) => input.label)).not.toContain("搜索礼包物品"); const workflows = renderAndCollect({ pageKey: "workflows", pageTitle: "活动管理" }); expect(workflows.texts).toContain("从 SCUM 目录选择物品"); @@ -358,7 +364,7 @@ describe("SCUM plugin feature module", () => { expect(pageSource).toContain("scumMapDefaultWindowMs"); }); - it("keeps only the selected user's tracks and the vehicles that user rode", () => { + it("draws one selected user route with the ridden vehicle on the same marker", () => { const data: SCUMSurfaceData = { ...surfaceData, trajectories: [ { subjectType: "player", subjectId: "76561198000000001", steamId: "76561198000000001", displayName: "Mira", x: 10, y: 20, z: 3, riddenVehicleId: "veh-1", gameVehicleId: "veh-1", sampledAt: "2026-08-10T00:00:01Z" }, { subjectType: "player", subjectId: "76561198000000009", steamId: "76561198000000009", displayName: "Nova", x: 30, y: 40, z: 3, sampledAt: "2026-08-10T00:00:02Z" }, @@ -367,15 +373,16 @@ describe("SCUM plugin feature module", () => { ] }; const view = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图", data, pageQuery: { ...mapTestWindow, focus: "76561198000000001" } }); expect(view.texts).toContain("Mira"); - expect(view.texts).toContain("Laika"); + expect(view.elements.filter((element) => element.className.includes("map-track-live")).map((element) => element.label)).toEqual(expect.arrayContaining(["Mira · Laika"])); expect(view.texts).not.toContain("Nova"); expect(view.texts).not.toContain("WolfsWagen"); const all = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图", data, pageQuery: mapTestWindow }); - expect(all.texts).toEqual(expect.arrayContaining(["Mira", "Nova", "Laika", "WolfsWagen"])); + expect(all.texts).toEqual(expect.arrayContaining(["Mira", "Nova"])); + expect(all.elements.filter((element) => element.className.includes("map-track-chip"))).toHaveLength(2); }); it("loads the vehicle icon for trajectory rows and gift items", () => { - expect(pageSource).toContain("trajectoryVehicleIcon(middle.row)"); + expect(pageSource.includes("scumMapVehicleIconUrl(normalized)")).toBe(true); expect(pageSource).toContain("catalogIconStrip(e, giftCatalogIcons(gift))"); }); @@ -389,7 +396,7 @@ describe("SCUM plugin feature module", () => { const pluginData = pluginDataActions(); await saveMapSettings({ pluginData }, { customMapEnabled: true, centerX: 100000, centerY: 200000, widthKm: 4, heightKm: 2 }); expect(pluginData.put).toHaveBeenCalledWith(scumCollections.mapSettings, "current", expect.objectContaining(bounds)); - expect(pageSource).toContain('textField(point, "imagePath", "image_path")'); + expect(pageSource.includes('bundledVehicleImageUrl(textField(point, "imagePath"')).toBe(false); expect(pageSource).toContain('"className", "vehicleClass", "entityClass", "vehicleType"'); expect(pageSource).not.toContain("visible.slice(0, 240)"); }); @@ -434,6 +441,38 @@ describe("SCUM plugin feature module", () => { const paused = advanceMapPlayback({ playing: false, value: 0.3, speed: 1, startedAt: 0, startValue: 0 }, 900_000); expect(paused.value).toBeCloseTo(0.3, 6); }); + + it("fits playback from selected users' first samples", () => { + const point = (key: string, x: number, y: number, time: number): MapTrackPoint => ({ key, row: {}, x, y, time, riding: false, rideCode: "" }); + const track = (key: string, points: MapTrackPoint[]): MapTrack => ({ key, kind: "player", label: key, color: "#fff", points, walk: points.length, ride: 0, from: points[0].time, to: points[points.length - 1].time, vehicles: [] }); + expect(playbackStartView([track("one", [point("one:1", 300, 500, 1)])], null)?.zoom).toBe(8); + const diverging = [track("a", [point("a:1", 300, 500, 0), point("a:2", 0, 0, 1000)]), track("b", [point("b:1", 520, 680, 0), point("b:2", 4096, 4096, 1000)])]; + expect(playbackStartView(diverging, null)?.zoom).toBe(8); + expect(playbackFollowView(diverging, { playing: true, value: 0.5, speed: 1, startedAt: 0, startValue: 0 }, { from: 0, to: 1000 }, null)?.zoom).toBeLessThan(2); + expect(playbackFollowView(diverging, { playing: false, value: 1, speed: 1, startedAt: 0, startValue: 0 }, { from: 0, to: 1000 }, null)?.zoom).toBe(1); + }); + + it("does not infer riding from a nearby vehicle sample", () => { + const view = renderAndCollect({ pageKey: "live-map", pageQuery: { ...mapTestWindow, focus: "76561198000000001" } }); + expect(view.texts).toContain("用户 · 1 点 · 步行 1 / 乘车 0"); + expect(view.elements.some((element) => element.className.includes("map-ride-marker"))).toBe(false); + expect(view.elements.filter((element) => element.className.includes("map-track-chip"))).toHaveLength(1); + }); + + it("shows flag ownership and attached squad details in the selected point strip", () => { + const flag = { flagId: "flag-1", name: "Wolves Flag", ownerPlayerId: "76561198000000001", squadId: "squad-1", status: "active", layer: "flags", subjectId: "flag-1", x: 100, y: 80, z: 0 }; + const data: SCUMSurfaceData = { ...surfaceData, mapPoints: [flag], mapRegions: [], vehicles: [], vehicleLocks: [], trajectories: [], flags: [] }; + const view = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图", data }); + expect(view.texts).toContain("归属 Mira(76561198000000001) · 附属队伍 Wolves(squad-1 · 1 人)"); + const unattachedFlag = { ...flag, ownerPlayerId: "", squadId: "" }; + const unattached = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图", data: { ...data, mapPoints: [unattachedFlag] } }); + expect(unattached.texts).toContain("归属 未同步 · 附属队伍 无附属队伍"); + }); + + it("shows current vehicle durability and treats an unlocked vehicle as unlocked even with lock history", () => { + const view = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图", data: { ...surfaceData, players: [], mapPoints: [], flags: [], mapRegions: [], trajectories: [], vehicles: [{ ...surfaceData.vehicles[0], locked: false, healthPercent: 72 }], vehicleLocks: surfaceData.vehicleLocks } }); + expect(view.texts).toContain("载具 Laika · 耐久 72% · 未上锁"); + }); }); function pluginDataActions(overrides: Partial<{ list: (collection: string, key?: string) => Promise }> = {}) {