Improve SCUM map playback and vehicle markers

This commit is contained in:
npc0-hue
2026-09-18 15:53:32 +08:00
parent 4009b7260e
commit 156392df1e
7 changed files with 432 additions and 55 deletions
@@ -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, "操作失败。") })); }
@@ -5135,10 +5135,23 @@ export function scumCatalogIconUrl(icon: string): string {
export const scumCatalogByCommand: ReadonlyMap<string, ScumCatalogEntry> = new Map(scumCatalogEntries.map((entry) => [entry.command.toLowerCase(), entry]));
export const scumCatalogByCode: ReadonlyMap<string, ScumCatalogEntry> = new Map(scumCatalogEntries.map((entry) => [entry.code.toLowerCase(), entry]));
function vehicleAliasKeys(code: string): string[] {
const normalized = code.replace(/^#spawnvehicle\s+/i, "").trim();
const withoutVariant = normalized.replace(/_([a-z])$/i, "");
return [...new Set([normalized, withoutVariant, withoutVariant.replace(/_([a-z])$/i, "")].map((value) => value.toLowerCase()))];
// The live server reports spawned vehicles as "<name>_ES" (the collector's VehicleClassSuffix),
// while the catalog keys them as "BP_<name>" / "BPC_<name>". UE object paths add a package prefix.
// Every key below is derived from the same code so both sides meet on the bare vehicle name.
export function vehicleAliasKeys(code: string): string[] {
const normalized = (code.replace(/^#spawnvehicle\s+/i, "").trim().split(".").pop() ?? "").trim();
if (!normalized) return [];
const withoutSuffix = normalized.replace(/_(?:ES|C)$/i, "");
const bare = withoutSuffix.replace(/^(?:BPC_|BP_)/i, "");
const variants = new Set([normalized, withoutSuffix, bare]);
const keys = new Set<string>();
for (const value of variants) {
const base = value.replace(/^(?:bpc_|bp_)/i, "");
keys.add(value.toLowerCase());
keys.add(`bpc_${base}`.toLowerCase());
keys.add(`bp_${base}`.toLowerCase());
}
return [...keys];
}
const vehicleIconAliases: Record<string, string> = {};
@@ -5155,23 +5168,83 @@ export function scumVehicleIconPath(vehicleCode: string): string {
// Colour variants and re-releases ship no icon of their own; they render the icon of their base vehicle.
const scumVehicleFamilyIcons: ReadonlyArray<readonly [string, string]> = [
["bp_dirtbike", "items/vehicles/ico_motorcycle_01_a.webp"],
["bp_cruiser", "items/vehicles/ico_cruiser.webp"],
["bp_bicycle_mountain_bike", "items/vehicles/ico_bicycle_02_a.webp"],
["bpc_bicycle_mountain_bike", "items/vehicles/ico_bicycle_02_a.webp"],
["bpc_bicycle_citybike", "items/vehicles/ico_bicycle_01_a.webp"],
["bpc_motorboat_01", "items/vehicles/ico_motorboat_02.webp"],
["bp_kinglet_scout", "items/vehicles/ico_kinglet_duster_a.webp"],
["dirtbike", "items/vehicles/ico_motorcycle_01_a.webp"],
["sportbike", "items/vehicles/ico_motorcycle_01_a.webp"],
["cruiser", "items/vehicles/ico_cruiser.webp"],
["citybike", "items/vehicles/ico_bicycle_01_a.webp"],
["mountainbike", "items/vehicles/ico_bicycle_02_a.webp"],
["bicycle_mountain_bike", "items/vehicles/ico_bicycle_02_a.webp"],
["bicycle_citybike", "items/vehicles/ico_bicycle_01_a.webp"],
["sidecarbike", "items/vehicles/ico_sidecarbike.webp"],
["motorboat_01", "items/vehicles/ico_motorboat_02.webp"],
["barba", "items/vehicles/motoredboat_a.webp"],
["dinghy", "items/vehicles/ico_motorboat_02.webp"],
["sup", "items/vehicles/ico_sup_vicinity.webp"],
["bigraft", "items/vehicles/ico_improraftbig.webp"],
["smallraft", "items/vehicles/ico_improsmallraft.webp"],
["wheelbarrow_improvised", "items/vehicles/ico_improwheelbarrow.webp"],
["wheelbarrow_metal", "items/vehicles/ico_wheelbarrow.webp"],
["wheelbarrow", "items/vehicles/ico_wheelbarrow.webp"],
["laika", "items/vehicles/ico_laika.webp"],
["rager", "items/vehicles/ico_rager.webp"],
["ris", "items/vehicles/ico_ris.webp"],
["wolfswagen", "items/vehicles/ico_wolfswagen.webp"],
["tractor", "items/vehicles/ico_tractor_01_a.webp"],
["kinglet_scout", "items/vehicles/ico_kinglet_duster_a.webp"],
["kinglet_duster", "items/vehicles/ico_kinglet_duster_a.webp"],
["kinglet_mariner", "items/vehicles/ico_kinglet_mariner.webp"],
];
export function scumVehicleIconUrl(vehicleCode: string): string {
const direct = scumVehicleIconPath(vehicleCode);
if (direct) return scumCatalogIconUrl(direct);
const key = vehicleCode.replace(/^#spawnvehicle\s+/i, "").trim().toLowerCase();
for (const [prefix, icon] of scumVehicleFamilyIcons) { if (key.startsWith(prefix)) return scumCatalogIconUrl(icon); }
function scumVehicleFamilyIcon(vehicleCode: string): string {
const key = (vehicleCode.replace(/^#spawnvehicle\s+/i, "").trim().split(".").pop() ?? "").toLowerCase().replace(/^(bpc_|bp_)/, "");
for (const [prefix, icon] of scumVehicleFamilyIcons) { if (key === prefix || key.startsWith(`${prefix}_`)) return icon; }
return "";
}
export function scumVehicleIconPathFor(vehicleCode: string): string {
const direct = scumVehicleIconPath(vehicleCode);
if (direct) return direct;
return scumVehicleFamilyIcon(vehicleCode);
}
export function scumVehicleIconUrl(vehicleCode: string): string {
const path = scumVehicleIconPathFor(vehicleCode);
return path ? scumCatalogIconUrl(path) : "";
}
// Vehicles whose class ships no icon of its own (Pickup / Quad / SUV / Sportbike and any future
// re-release) still need a marker. These inline glyphs cost no extra request and never 404.
const scumVehicleFallbackGlyphs: ReadonlyArray<readonly [string, string]> = [
["boat", "M3 15h18l-2 5H5l-2-5Zm3 0 3-7h6l3 7M8 11V8h8v3"],
["bike", "M5 18a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm14 0a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM8 15l3-6h4l2 6M11 9h3"],
["motorcycle", "M5 18a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm14 0a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM8 15l3-5h3l2 1 3 4M11 10h4"],
["cart", "M4 6h11l2 8H6L4 6Zm2 12a1.6 1.6 0 1 0 0-3.2A1.6 1.6 0 0 0 6 18Zm10 0a1.6 1.6 0 1 0 0-3.2A1.6 1.6 0 0 0 16 18Z"],
["car", "M4 16v-3l2-5h12l2 5v3M4 16h16M6.5 16a1.5 1.5 0 1 0 0 .01M17.5 16a1.5 1.5 0 1 0 0 .01M4 13h16"],
];
export function scumVehicleFallbackIconUrl(vehicleCode: string): string {
const key = (vehicleCode.replace(/^#spawnvehicle\s+/i, "").trim().split(".").pop() ?? "").toLowerCase();
const family = scumVehicleFamilyIcon(vehicleCode);
const kind = family.includes("boat") || family.includes("raft") || family.includes("sup") || /boat|raft|dinghy|sup|barba/.test(key) ? "boat"
: family.includes("bicycle") ? "bike"
: family.includes("motorcycle") || family.includes("sidecarbike") || /bike|motorcycle/.test(key) ? "motorcycle"
: family.includes("wheelbarrow") || /wheelbarrow/.test(key) ? "cart"
: "car";
const glyph = scumVehicleFallbackGlyphs.find(([name]) => name === kind)?.[1] ?? "";
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="${glyph}"/></svg>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}
export function scumVehicleEntry(vehicleCode: string): ScumCatalogEntry | undefined {
for (const key of vehicleAliasKeys(vehicleCode)) {
const icon = vehicleIconAliases[key];
if (!icon) continue;
const entry = scumCatalogEntries.find((value) => value.kind === "vehicle" && value.icon === icon);
if (entry) return entry;
}
return scumCatalogEntryFor(vehicleCode);
}
export function scumCatalogEntryFor(value: string): ScumCatalogEntry | undefined {
const key = value.trim().toLowerCase();
return scumCatalogByCommand.get(key) ?? scumCatalogByCode.get(key) ?? scumCatalogByCommand.get(`#spawnitem ${key}`) ?? scumCatalogByCommand.get(`#spawnvehicle ${key}`);