Improve SCUM map playback and vehicle markers
This commit is contained in:
@@ -19,7 +19,7 @@ import {
|
||||
type SCUMSurfaceData,
|
||||
type SCUMWorkspaceActions
|
||||
} from "./page-data.js";
|
||||
import { scumCatalogEntries, scumCatalogEntryFor, scumCatalogIconUrl, scumVehicleIconUrl, type ScumCatalogEntry } from "./scum-catalog.js";
|
||||
import { scumCatalogEntries, scumCatalogEntryFor, scumCatalogIconUrl, scumVehicleEntry, scumVehicleFallbackIconUrl, scumVehicleIconUrl, type ScumCatalogEntry } from "./scum-catalog.js";
|
||||
|
||||
type StateSetter<T> = (next: T | ((previous: T) => T)) => void;
|
||||
type InputEvent = { target?: { value?: string; checked?: boolean; open?: boolean }; stopPropagation?: () => void };
|
||||
@@ -30,9 +30,10 @@ 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 };
|
||||
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 };
|
||||
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 };
|
||||
@@ -49,13 +50,19 @@ 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 };
|
||||
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<readonly [string, string]> = [["1h", "最近 1 小时"], ["15m", "最近 15 分钟"], ["6h", "最近 6 小时"], ["24h", "最近 24 小时"], ["7d", "最近 7 天"], ["all", "全部时段"], ["custom", "自定义起止"]];
|
||||
const scumMapPresetMs: Record<string, number> = { "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<string, string>();
|
||||
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;
|
||||
@@ -129,6 +136,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
|
||||
const [deliveryPlayer, setDeliveryPlayer] = usePluginState(react, "");
|
||||
const [mapLayers, setMapLayers] = usePluginState<Record<MapLayer, boolean>>(react, { players: true, vehicles: true, flags: true, regions: true, other: true });
|
||||
const [selectedMapPoint, setSelectedMapPoint] = usePluginState(react, "");
|
||||
const [hoverMapPoint, setHoverMapPoint] = usePluginState(react, "");
|
||||
const [mapView, setMapView] = usePluginState<MapViewState>(react, scumMapDefaultView);
|
||||
const [mapTimePreset, setMapTimePreset] = usePluginState(react, pageQuery.from || pageQuery.to ? "custom" : "1h");
|
||||
const [mapTimeFrom, setMapTimeFrom] = usePluginState(react, localDateTimeValue(defaultMapRange.from));
|
||||
@@ -184,9 +192,14 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
|
||||
}, [pageKey, state.status, mapViewportRef, setMapView]);
|
||||
if (react.useEffect) react.useEffect(() => {
|
||||
if (!mapPlayback.playing) return;
|
||||
const timer = setInterval(() => setMapPlayback((previous) => advanceMapPlayback(previous)), 90);
|
||||
const timer = setInterval(() => {
|
||||
const next = advanceMapPlayback(mapPlayback, Date.now());
|
||||
setMapPlayback(next);
|
||||
const camera = mapPlaybackFrame ? playbackFollowView(mapPlaybackFrame.tracks, next, mapPlaybackFrame.span, mapViewportRef.current) : undefined;
|
||||
if (camera) setMapView(camera);
|
||||
}, 90);
|
||||
return () => { clearInterval(timer); };
|
||||
}, [mapPlayback.playing, setMapPlayback]);
|
||||
}, [mapPlayback.playing, mapPlayback.startedAt, mapPlayback.startValue, mapPlayback.speed, setMapPlayback, setMapView, mapViewportRef]);
|
||||
if (react.useEffect) react.useEffect(() => {
|
||||
if (pageKey !== "live-map") return;
|
||||
if (!focusUser) { setMapUsers(undefined); applyMapTimePreset(setMapTimePreset, setMapTimeFrom, setMapTimeTo, "1h"); return; }
|
||||
@@ -218,6 +231,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
|
||||
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,
|
||||
hoverMapPoint, setHoverMapPoint,
|
||||
mapView, setMapView, mapViewportRef, mapTimePreset, setMapTimePreset, mapTimeFrom, setMapTimeFrom, mapTimeTo, setMapTimeTo,
|
||||
mapUsers, setMapUsers, mapTrackKey, setMapTrackKey, mapPlayback, setMapPlayback,
|
||||
eventEditorOpen, setEventEditorOpen, produceEditorOpen, setProduceEditorOpen, giftEditorOpen, setGiftEditorOpen,
|
||||
@@ -244,7 +258,7 @@ type ViewState = {
|
||||
giftClass: string; setGiftClass: StateSetter<string>; giftAudience: string; setGiftAudience: StateSetter<string>; giftNumber: string; setGiftNumber: StateSetter<string>; giftAchievement: string; setGiftAchievement: StateSetter<string>; giftAchievementNumber: string; setGiftAchievementNumber: StateSetter<string>;
|
||||
deliveryGift: string; setDeliveryGift: StateSetter<string>; deliveryPlayer: string; setDeliveryPlayer: StateSetter<string>;
|
||||
mapLayers: Record<MapLayer, boolean>; setMapLayers: StateSetter<Record<MapLayer, boolean>>;
|
||||
selectedMapPoint: string; setSelectedMapPoint: StateSetter<string>; setAction: StateSetter<ActionState>; refresh: () => void;
|
||||
selectedMapPoint: string; setSelectedMapPoint: StateSetter<string>; hoverMapPoint: string; setHoverMapPoint: StateSetter<string>; setAction: StateSetter<ActionState>; refresh: () => void;
|
||||
mapView: MapViewState; setMapView: StateSetter<MapViewState>; mapViewportRef: { current: HTMLElement | null };
|
||||
mapTimePreset: string; setMapTimePreset: StateSetter<string>; mapTimeFrom: string; setMapTimeFrom: StateSetter<string>; mapTimeTo: string; setMapTimeTo: StateSetter<string>;
|
||||
mapUsers: string[] | undefined; setMapUsers: StateSetter<string[] | undefined>; mapTrackKey: string; setMapTrackKey: StateSetter<string>; mapPlayback: MapPlaybackState; setMapPlayback: StateSetter<MapPlaybackState>;
|
||||
@@ -619,9 +633,11 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input:
|
||||
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);
|
||||
const cursor = view.mapPlayback.value >= 1 ? Infinity : span.from + (span.to - span.from) * view.mapPlayback.value;
|
||||
mapPlaybackFrame = { 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) : [];
|
||||
@@ -645,8 +661,9 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input:
|
||||
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),
|
||||
visible.map((point, index) => mapPointMarker(e, point, index, bounds, view))
|
||||
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
|
||||
),
|
||||
e("div", { className: "map-view-controls" },
|
||||
e("button", { type: "button", className: "icon-command", "aria-label": "地图放大", onClick: () => view.setMapView((previous) => zoomMapView(previous, 1.25, 0, 0)) }, "+"),
|
||||
@@ -656,17 +673,91 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input:
|
||||
e("span", { className: "map-zoom-badge" }, `${zoom.toFixed(2)}×`)
|
||||
)
|
||||
),
|
||||
mapPlaybackBar(e, view, span, tracks.length > 0),
|
||||
mapPlaybackBar(e, view, span, tracks, tracks.length > 0),
|
||||
mapTrackLegend(e, view, tracks, playerLabels),
|
||||
selected ? mapPointStrip(e, selected, selectedTrails, selectedLocks) : null
|
||||
);
|
||||
}
|
||||
|
||||
function mapPointMarker(e: ReactLike["createElement"], point: RecordMap, index: number, bounds: RecordMap, view: ViewState) {
|
||||
const ride = layerOf(point) === "players" ? textField(point, "riddenVehicleId", "gameVehicleId") : "";
|
||||
const icon = vehicleIconFor(point);
|
||||
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 key = idOf(point, `point-${index}`);
|
||||
return e("button", { key, type: "button", className: `map-surface-dot map-layer-${layerOf(point)}${ride ? " map-surface-dot-riding" : ""}`, title: `${pointTitle(point)} ${coords(point)}${ride ? ` · 乘坐 ${ride}` : ""}`, "aria-label": pointTitle(point), style: mapPointStyle(point, bounds), onClick: () => { if (!mapDragState.moved) view.setSelectedMapPoint(key); } }, icon ? e("img", { src: icon, alt: "" }) : "");
|
||||
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); } },
|
||||
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.
|
||||
function mapHoverCard(e: ReactLike["createElement"], point: RecordMap, data: SCUMSurfaceData, pointStyle: Record<string, string>) {
|
||||
const layer = layerOf(point);
|
||||
const rows: Array<[string, string]> = [];
|
||||
const push = (label: string, value: string) => { if (value) rows.push([label, value]); };
|
||||
const pushDate = (label: string, ...keys: string[]) => { if (textField(point, ...keys)) push(label, dateField(point, ...keys)); };
|
||||
if (layer === "players") {
|
||||
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") ? "在线" : "离线");
|
||||
pushDate("最后出现", "lastSeenAt", "lastActivityAt", "updatedAt");
|
||||
} else if (layer === "vehicles") {
|
||||
push("载具", vehicleLabelFor(point));
|
||||
push("类名", textField(point, "className", "vehicleClass", "entityClass", "vehicleType"));
|
||||
push("状态", textField(point, "status", "state") || (boolField(point, "exists", "existsInGame") ? "存在" : ""));
|
||||
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("置信度", textField(point, "ownershipConfidence"));
|
||||
} else {
|
||||
push("名称", pointTitle(point));
|
||||
push("类型", textField(point, "type", "subjectType", "layer"));
|
||||
push("来源", textField(point, "source"));
|
||||
}
|
||||
if (hasCoordinates(point)) push("坐标", coords(point));
|
||||
return e("div", { 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)))
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function vehicleLockLabel(point: RecordMap, data: SCUMSurfaceData): string {
|
||||
const locks = vehicleLockRecordsForPoint(data.vehicleLocks, point).sort((left, right) => rowTime(right, ["lockedAt", "createdAt"]) - rowTime(left, ["lockedAt", "createdAt"]));
|
||||
const latest = locks[0];
|
||||
if (latest) {
|
||||
const steamId = textField(latest, "steamId", "scumUserId");
|
||||
const owner = data.players.find((player) => [textField(player, "steamId"), textField(player, "gamePlayerId"), textField(player, "id"), textField(player, "scumUserId")].includes(steamId));
|
||||
const who = owner ? textField(owner, "displayName", "name") || steamId : steamId;
|
||||
return `${who || "未知玩家"} 上锁 · ${dateField(latest, "lockedAt", "createdAt")}`;
|
||||
}
|
||||
if (boolField(point, "locked")) return "已上锁(未记录上锁人)";
|
||||
return textField(point, "lockState") || "未上锁";
|
||||
}
|
||||
|
||||
function vehicleDurabilityLabel(point: RecordMap): string {
|
||||
const value = textField(point, "durability", "vehicleDurability", "health", "vehicleHealth");
|
||||
return value ? `${value}${textField(point, "durabilityMax", "maxHealth") ? ` / ${textField(point, "durabilityMax", "maxHealth")}` : ""}` : "";
|
||||
}
|
||||
|
||||
function mapUserMenu(e: ReactLike["createElement"], view: ViewState, users: Array<{ key: string; label: string; count: number }>, labels: Map<string, string>) {
|
||||
@@ -718,18 +809,26 @@ 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 }, enabled: boolean) {
|
||||
const cursor = view.mapPlayback.value >= 1 ? Infinity : span.from + (span.to - span.from) * view.mapPlayback.value;
|
||||
function mapPlaybackBar(e: ReactLike["createElement"], view: ViewState, span: { from: number; to: number }, tracks: MapTrack[], enabled: boolean) {
|
||||
const cursor = playbackCursor(span, view.mapPlayback);
|
||||
const playing = view.mapPlayback.playing;
|
||||
const start = () => {
|
||||
const value = 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); }
|
||||
};
|
||||
return e("div", { className: "map-playback-bar" },
|
||||
e("details", { className: "scum-map-menu", "aria-label": "轨迹回放" },
|
||||
e("summary", { className: "icon-command" }, view.mapPlayback.playing ? "回放 播放中" : "回放"),
|
||||
e("summary", { className: "icon-command" }, playing ? "回放 播放中" : "回放"),
|
||||
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: view.mapPlayback.playing ? "primary-command" : "icon-command", disabled: !enabled, "aria-label": view.mapPlayback.playing ? "暂停轨迹回放" : "播放轨迹回放", onClick: () => view.setMapPlayback((previous) => ({ ...previous, playing: !previous.playing, value: !previous.playing && previous.value >= 1 ? 0 : previous.value })) }, view.mapPlayback.playing ? "暂停" : "回放"),
|
||||
e("button", { type: "button", className: "icon-command", disabled: !enabled, onClick: () => view.setMapPlayback(scumMapPlaybackIdle) }, "回到最新")
|
||||
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) => ({ ...previous, speed: Number(inputValue(event)) || 1 })) }, [1, 2, 4].map((speed) => e("option", { key: speed, value: String(speed) }, `${speed}x`))))
|
||||
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 ? "单人回放:镜头跟随并放到最大比例。" : "多人回放:镜头框住全部所选用户。")
|
||||
)
|
||||
)
|
||||
);
|
||||
@@ -745,11 +844,16 @@ function mapTrackShapes(e: ReactLike["createElement"], tracks: MapTrack[], curso
|
||||
return shapes;
|
||||
}
|
||||
|
||||
function mapTrackMarkers(e: ReactLike["createElement"], tracks: MapTrack[], cursor: number, catalog: Map<string, string>) {
|
||||
function mapTrackMarkers(e: ReactLike["createElement"], tracks: MapTrack[], cursor: number, catalog: Map<string, string>, 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)}` }, "起"));
|
||||
@@ -760,15 +864,28 @@ function mapTrackMarkers(e: ReactLike["createElement"], tracks: MapTrack[], curs
|
||||
const visible = segment.points.filter((point) => point.time <= cursor);
|
||||
if (!visible.length) continue;
|
||||
const middle = visible[Math.floor(visible.length / 2)];
|
||||
const code = mapRideCode(middle.row) || track.label;
|
||||
const icon = trajectoryVehicleIcon(middle.row) || scumVehicleIconUrl(catalog.get(code) ?? code);
|
||||
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: "" }) : "车"));
|
||||
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;
|
||||
}
|
||||
|
||||
// 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<string, string>, playing: boolean) {
|
||||
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)}` },
|
||||
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 mapSceneStyle(point: MapTrackPoint): Record<string, string> { 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 {
|
||||
@@ -806,10 +923,11 @@ function applyMapTimePreset(setPreset: StateSetter<string>, setFrom: StateSetter
|
||||
function mapTracks(data: SCUMSurfaceData, bounds: RecordMap, window: MapTimeWindow, users: string[] | undefined, catalog: Map<string, string>): MapTrack[] {
|
||||
const grouped = new Map<string, MapTrack>();
|
||||
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<string>();
|
||||
if (users !== undefined) for (const row of windowRows) {
|
||||
if (layerOf(row) === "vehicles" || !users.includes(mapPlayerTrackIdentity(row))) continue;
|
||||
const vehicle = mapRiddenVehicleId(row);
|
||||
const vehicle = playerRide(row, rideSamples, bounds)?.key ?? "";
|
||||
if (vehicle) riddenVehicles.add(vehicle);
|
||||
}
|
||||
for (const row of windowRows) {
|
||||
@@ -821,10 +939,10 @@ function mapTracks(data: SCUMSurfaceData, bounds: RecordMap, window: MapTimeWind
|
||||
if (kind === "player" ? users !== undefined && !users.includes(identity) : users !== undefined && !riddenVehicles.has(identity)) continue;
|
||||
const time = trajectoryOrder(row);
|
||||
const scene = mapScenePoint(row, bounds);
|
||||
const rideCode = mapRideCode(row);
|
||||
const rideCode = kind === "player" ? playerRideCode(row, rideSamples, bounds) : mapRideCode(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: [] };
|
||||
track.points.push({ key: `${key}:${track.points.length}`, row, x: scene.x, y: scene.y, time, riding: Boolean(rideCode) });
|
||||
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);
|
||||
track.to = Math.max(track.to, time);
|
||||
@@ -844,6 +962,19 @@ function mapTracks(data: SCUMSurfaceData, bounds: RecordMap, window: MapTimeWind
|
||||
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 mapTrackUsers(data: SCUMSurfaceData, window: MapTimeWindow): Array<{ key: string; label: string; count: number }> {
|
||||
const users = new Map<string, { key: string; label: string; count: number }>();
|
||||
for (const row of data.trajectories) {
|
||||
@@ -896,16 +1027,101 @@ function mapVehicleClassIndex(data: SCUMSurfaceData): Map<string, string> {
|
||||
|
||||
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] };
|
||||
}
|
||||
|
||||
function advanceMapPlayback(previous: MapPlaybackState): MapPlaybackState {
|
||||
const value = previous.value + 0.012 * (previous.speed || 1);
|
||||
export function advanceMapPlayback(previous: MapPlaybackState, now = Date.now()): MapPlaybackState {
|
||||
if (!previous.playing) return previous;
|
||||
const elapsed = Math.max(0, now - previous.startedAt);
|
||||
const value = previous.startValue + elapsed / scumMapPlaybackDurationMs * (previous.speed || 1);
|
||||
return value >= 1 ? { ...previous, playing: false, value: 1 } : { ...previous, value };
|
||||
}
|
||||
|
||||
function rebaseMapPlayback(previous: MapPlaybackState, patch: Partial<MapPlaybackState>, now = Date.now()): MapPlaybackState {
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function playbackFollowView(tracks: MapTrack[], playback: MapPlaybackState, span: { from: number; to: number }, node: HTMLElement | null): MapViewState | undefined {
|
||||
if (!playback.playing) return 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;
|
||||
}
|
||||
|
||||
function mapTrackPositionAt(track: MapTrack, cursor: number): MapTrackPoint | undefined {
|
||||
const points = track.points;
|
||||
if (!points.length) return undefined;
|
||||
if (!Number.isFinite(cursor)) return points[points.length - 1];
|
||||
if (cursor < points[0].time) return undefined;
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
const previous = points[index - 1];
|
||||
const next = points[index];
|
||||
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 points[points.length - 1];
|
||||
}
|
||||
|
||||
function zoomMapView(previous: MapViewState, factor: number, anchorX: number, anchorY: number): MapViewState {
|
||||
const zoom = clampMapZoom(previous.zoom * factor);
|
||||
if (zoom === previous.zoom) return previous;
|
||||
@@ -921,6 +1137,12 @@ function defaultMapView(node: HTMLElement | null): MapViewState {
|
||||
return width > height && height > 0 ? { zoom: clampMapZoom(width / height), x: 0, y: 0 } : scumMapDefaultView;
|
||||
}
|
||||
|
||||
function focusMapView(point: MapTrackPoint, node: HTMLElement | null, zoom: number): MapViewState {
|
||||
const size = Math.max(240, Number(node?.clientHeight) || 640);
|
||||
const clamped = clampMapZoom(zoom);
|
||||
return { zoom: clamped, x: -(point.x / scumMapSize - 0.5) * size * clamped, y: -(point.y / scumMapSize - 0.5) * size * clamped };
|
||||
}
|
||||
|
||||
function fitMapView(points: MapTrackPoint[], node: HTMLElement | null, padding: number): MapViewState {
|
||||
if (!points.length) return scumMapDefaultView;
|
||||
let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity;
|
||||
@@ -1005,7 +1227,7 @@ function giftCatalogIcons(gift: RecordMap): string[] {
|
||||
|
||||
function trajectoryVehicleIcon(row: RecordMap): string {
|
||||
const code = textField(row, "className", "vehicleClass", "entityClass", "vehicleType", "riddenVehicleId", "gameVehicleId", "vehicleId");
|
||||
return code ? scumVehicleIconUrl(code) : "";
|
||||
return code ? scumVehicleIconUrl(code) || scumVehicleFallbackIconUrl(code) : "";
|
||||
}
|
||||
|
||||
function catalogPicker(e: ReactLike["createElement"], view: ViewState, target: CatalogPickTarget) {
|
||||
@@ -1086,9 +1308,35 @@ function trajectoryRecordsForPoint(rows: RecordMap[], point: RecordMap): RecordM
|
||||
function vehicleLockRecordsForPoint(rows: RecordMap[], point: RecordMap): RecordMap[] { const ids = new Set([textField(point, "id"), textField(point, "scumVehicleId"), textField(point, "vehicleId"), textField(point, "gameVehicleId"), textField(point, "entityId"), textField(point, "subjectId")].filter(Boolean)); return rows.filter((row) => [textField(row, "scumVehicleId"), textField(row, "vehicleId"), textField(row, "gameVehicleId")].some((identity) => ids.has(identity))); }
|
||||
function trajectoryIdentity(row: RecordMap): string[] { return [textField(row, "subjectId"), textField(row, "steamId"), textField(row, "gamePlayerId"), textField(row, "vehicleId"), textField(row, "id")].filter(Boolean); }
|
||||
function trajectoryOrder(row: RecordMap): number { const stamp = Date.parse(textField(row, "sampledAt", "observedAt", "createdAt")); return Number.isNaN(stamp) ? 0 : stamp; }
|
||||
function vehicleIconFor(point: RecordMap): string { const explicit = textField(point, "imagePath", "image_path"); if (explicit) return explicit.startsWith("/") ? explicit : `/${explicit}`; return scumVehicleIconUrl(normalizedVehicleClass(textField(point, "className", "vehicleClass", "entityClass", "vehicleType"))); }
|
||||
// The machine-side collector reports its own dump paths (for example "/original/Rager_ES.webp");
|
||||
// those files are not part of the plugin bundle, so only URLs the bundle really serves may reach an <img>.
|
||||
export function bundledVehicleImageUrl(value: string): string {
|
||||
const url = value.trim();
|
||||
if (!url || /^https?:\/\//i.test(url)) return "";
|
||||
if (/^(?:data|blob):/i.test(url)) return url;
|
||||
return /^\/(?:@fs|assets|src|node_modules)\//.test(url) ? url : "";
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function vehicleLabelFor(point: RecordMap): string {
|
||||
const code = textField(point, "className", "vehicleClass", "entityClass", "vehicleType", "riddenVehicleId", "gameVehicleId");
|
||||
return scumVehicleEntry(code)?.name || textField(point, "displayName", "label", "name") || code || "未知载具";
|
||||
}
|
||||
function vehicleClassKey(value: string): string { return normalizedVehicleClass(value.replace(/^#spawnvehicle\s+/i, "")); }
|
||||
function normalizedVehicleClass(value: string): string { return value.replace(/_C$/i, "").split(".").pop()?.trim() ?? value.trim(); }
|
||||
// Live classes arrive as "<name>_ES" (spawner suffix) or "<name>_C" (blueprint suffix); the catalog
|
||||
// keys the bare name, so every lookup path strips both before comparing.
|
||||
function normalizedVehicleClass(value: string): string { return (value.split(".").pop() ?? value).replace(/_(?:ES|C)$/i, "").trim(); }
|
||||
function shortHash(value: string): string { return value ? `${value.slice(0, 10)}…${value.slice(-6)}` : ""; }
|
||||
|
||||
function runAction(setAction: StateSetter<ActionState>, pending: string, task: () => Promise<string>) { setAction({ status: "pending", message: pending }); void task().then((message) => setAction({ status: "ok", message })).catch((error) => setAction({ status: "error", message: errorMessage(error, "操作失败。") })); }
|
||||
|
||||
Reference in New Issue
Block a user