feat: simplify SCUM activity management
This commit is contained in:
@@ -92,12 +92,14 @@ type SCUMPlatformSurfaceKey = "players" | "vehicles" | "trajectories" | "vehicle
|
||||
// Squads and members load once per refresh; hovering, selecting and playback issue 0 API calls.
|
||||
// Call budget per gifts load/15s refresh: 5 bounded plugin collections + 1 typed SCUM surface.
|
||||
// Searching, viewing a definition and opening the editor issue 0 additional API calls.
|
||||
// Call budget per activity load/15s refresh: 5 bounded plugin collections and 0 platform SCUM calls.
|
||||
// Expanding an activity, picking a map region, and editing its item list issue 0 additional API calls.
|
||||
const pageCollections: Record<PageKey, PluginCollectionSurfaceKey[]> = {
|
||||
players: ["members", "activityEvents", "giftClaims", "pendingGifts", "giftDeliveries"],
|
||||
squads: ["squads", "members", "flags"],
|
||||
"live-map": ["mapPoints", "mapRegions", "mapSettings", "flags", "squads", "members", "tradeGoods"],
|
||||
gifts: ["gifts", "giftClaims", "pendingGifts", "giftDeliveries", "timedGiftEvents"],
|
||||
workflows: ["events", "eventProduces", "eventRuns", "nativeEventRounds", "tasks", "activityEvents", "tradeGoods", "tradeEvents"]
|
||||
workflows: ["events", "eventProduces", "eventRuns", "mapRegions", "mapSettings"]
|
||||
};
|
||||
|
||||
const pageSCUMTables: Record<PageKey, SCUMPlatformSurfaceKey[]> = {
|
||||
@@ -105,7 +107,7 @@ const pageSCUMTables: Record<PageKey, SCUMPlatformSurfaceKey[]> = {
|
||||
squads: ["players"],
|
||||
"live-map": ["players", "vehicles", "trajectories", "vehicleLocks"],
|
||||
gifts: ["players"],
|
||||
workflows: ["players", "vehicles", "trajectories", "vehicleLocks"]
|
||||
workflows: []
|
||||
};
|
||||
|
||||
export async function loadSCUMSurface(actions: SCUMWorkspaceActions, pageKey: string): Promise<SCUMSurfaceData> {
|
||||
@@ -182,7 +184,9 @@ export async function queueGiftDelivery(actions: SCUMWorkspaceActions, gift: Rec
|
||||
|
||||
export async function saveEventDefinition(actions: SCUMWorkspaceActions, event: RecordMap): Promise<unknown> {
|
||||
const key = requiredKey(event, "id", "活动编号");
|
||||
return requirePluginData(actions).put(scumCollections.events, key, event);
|
||||
const storedEvent = { ...event };
|
||||
if (event.items !== undefined) storedEvent.items = parseGiftItems(event.items);
|
||||
return requirePluginData(actions).put(scumCollections.events, key, storedEvent);
|
||||
}
|
||||
|
||||
export async function deleteEventDefinition(actions: SCUMWorkspaceActions, key: string, produces: RecordMap[] = []): Promise<void> {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
deleteGiftDefinition,
|
||||
deleteEventDefinition,
|
||||
deleteEventProduce,
|
||||
emptySCUMSurfaceData,
|
||||
loadSCUMSurface,
|
||||
parseGiftItems,
|
||||
@@ -10,7 +9,6 @@ import {
|
||||
resetPendingGift,
|
||||
resolveMapBounds,
|
||||
saveEventDefinition,
|
||||
saveEventProduce,
|
||||
saveGiftDefinition,
|
||||
saveMapSettings,
|
||||
startEvent,
|
||||
@@ -30,9 +28,10 @@ type GiftDialogState = { mode: "closed" } | { mode: "view" | "edit"; gift?: Reco
|
||||
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 CatalogPickTarget = "gift-items" | "activity-items";
|
||||
type GiftItemDraft = { catalogCode: string; quantity: string; probability: string; durability: string };
|
||||
type GiftItemDraftField = keyof GiftItemDraft;
|
||||
type ActivityMapPointerEvent = { pointerId?: number; clientX?: number; clientY?: number; currentTarget?: { getBoundingClientRect?: () => { left: number; top: number; width: number; height: number }; setPointerCapture?: (pointerId?: number) => void; releasePointerCapture?: (pointerId?: number) => void }; preventDefault?: () => void; stopPropagation?: () => void };
|
||||
type PlayerPanelKind = "closed" | "gifts" | "items" | "history";
|
||||
type PlayerPanelState = { kind: PlayerPanelKind; playerId: string };
|
||||
type PointerEventLike = { pointerId?: number; clientX?: number; clientY?: number; button?: number; currentTarget?: { setPointerCapture?: (pointerId?: number) => void; releasePointerCapture?: (pointerId?: number) => void }; preventDefault?: () => void; stopPropagation?: () => void };
|
||||
@@ -40,7 +39,6 @@ const scumMapLayerImages = { terrain: new URL("../assets/map/scum-map-terrain-40
|
||||
const scumMapBaseLayers: ReadonlyArray<readonly [ScumMapLayer, string]> = [["terrain", "地形"], ["topo", "等高线"], ["night", "夜间"]];
|
||||
const scumMapVehicleFilters: ReadonlyArray<readonly [MapVehicleFilter, string]> = [["all", "全部载具"], ["land", "陆地载具"], ["water", "水上载具"], ["air", "飞行类载具"], ["other", "其他载具"]];
|
||||
const scumFlightVehicleKeys = new Set(["kinglet_duster", "kinglet_mariner", "kinglet_scout"]);
|
||||
const scumCatalogKinds: ReadonlyArray<readonly [string, string]> = [["all", "全部"], ["item", "物品"], ["vehicle", "载具"], ["zombie", "丧尸"], ["animal", "动物"], ["armed-npc", "NPC"]];
|
||||
const scumGiftCatalogCategories: ReadonlyArray<readonly [string, string]> = [["all", "全部目录"], ["hunting", "狩猎"], ["food", "食物"], ["metabolism", "代谢"], ["medical", "医疗"], ["weapons", "武器"], ["crafting", "制作"], ["gear", "装备"], ["fishing", "钓鱼"], ["building", "建筑"], ["tools", "工具"], ["farming", "农业"], ["misc", "其他"], ["vehicle", "载具"], ["animal", "动物"], ["zombie", "丧尸"], ["armed-npc", "NPC"]];
|
||||
const scumCatalogPickLimit = 48;
|
||||
const scumMapSize = 4096;
|
||||
@@ -58,6 +56,7 @@ const scumMapTrackColors = ["#4ECDC4", "#FF8A6B", "#F7DC6F", "#85C1E9", "#BB8FCE
|
||||
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 };
|
||||
const activityMapDragState = { active: false, pointerId: -1, startX: 0, startY: 0 };
|
||||
|
||||
export type ReactLike = {
|
||||
createElement: (...args: any[]) => any;
|
||||
@@ -96,28 +95,16 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
|
||||
const [squadSearch, setSquadSearch] = usePluginState(react, "");
|
||||
const [selectedSquadId, setSelectedSquadId] = usePluginState(react, "");
|
||||
const [activityStatus, setActivityStatus] = usePluginState(react, "all");
|
||||
const [activityDetailId, setActivityDetailId] = usePluginState(react, "");
|
||||
const [eventId, setEventId] = usePluginState(react, "");
|
||||
const [eventName, setEventName] = usePluginState(react, "");
|
||||
const [eventType, setEventType] = usePluginState(react, "range");
|
||||
const [eventSchedule, setEventSchedule] = usePluginState(react, "");
|
||||
const [eventCommand, setEventCommand] = usePluginState(react, "");
|
||||
const [eventClass, setEventClass] = usePluginState(react, "1");
|
||||
const [eventPlacard, setEventPlacard] = usePluginState(react, "");
|
||||
const [eventPercent, setEventPercent] = usePluginState(react, "100");
|
||||
const [eventDuration, setEventDuration] = usePluginState(react, "1800");
|
||||
const [eventNpc, setEventNpc] = usePluginState(react, "0");
|
||||
const [eventItem, setEventItem] = usePluginState(react, "0");
|
||||
const [eventZombie, setEventZombie] = usePluginState(react, "0");
|
||||
const [eventAnimal, setEventAnimal] = usePluginState(react, "0");
|
||||
const [produceEventId, setProduceEventId] = usePluginState(react, "");
|
||||
const [produceId, setProduceId] = usePluginState(react, "");
|
||||
const [produceTradeGoodsId, setProduceTradeGoodsId] = usePluginState(react, "");
|
||||
const [producePercent, setProducePercent] = usePluginState(react, "100");
|
||||
const [produceValue, setProduceValue] = usePluginState(react, "1");
|
||||
const [produceRadius, setProduceRadius] = usePluginState(react, "0");
|
||||
const [produceX, setProduceX] = usePluginState(react, "0");
|
||||
const [produceY, setProduceY] = usePluginState(react, "0");
|
||||
const [produceZ, setProduceZ] = usePluginState(react, "0");
|
||||
const [eventItems, setEventItems] = usePluginState<GiftItemDraft[]>(react, []);
|
||||
const [eventRegionName, setEventRegionName] = usePluginState(react, "");
|
||||
const [eventRegionRadius, setEventRegionRadius] = usePluginState(react, "5000");
|
||||
const [eventRegionX, setEventRegionX] = usePluginState(react, "");
|
||||
const [eventRegionY, setEventRegionY] = usePluginState(react, "");
|
||||
const [eventRegionZ, setEventRegionZ] = usePluginState(react, "0");
|
||||
const [giftTab, setGiftTab] = usePluginState<GiftTab>(react, "definitions");
|
||||
const [giftSearch, setGiftSearch] = usePluginState(react, "");
|
||||
const [giftStatus, setGiftStatus] = usePluginState(react, "all");
|
||||
@@ -146,8 +133,6 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
|
||||
const mapViewportRef = react.useRef ? react.useRef<HTMLElement | null>(null) : { current: null as HTMLElement | null };
|
||||
const mapPlaybackFrameRef = react.useRef ? react.useRef<MapPlaybackFrame | null>(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 [giftDialog, setGiftDialog] = usePluginState<GiftDialogState>(react, { mode: "closed" });
|
||||
const [mapBaseLayer, setMapBaseLayer] = usePluginState<ScumMapLayer | undefined>(react, undefined);
|
||||
const [mapZonesOverlay, setMapZonesOverlay] = usePluginState<boolean | undefined>(react, undefined);
|
||||
@@ -164,8 +149,8 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
|
||||
if (inFlight || disposed) return;
|
||||
inFlight = true;
|
||||
const currentGeneration = ++generation;
|
||||
if (!input.serverInstanceId || !input.workspaceActions?.scum || !input.workspaceActions?.pluginData) {
|
||||
setState({ status: "error", reason: "插件页面没有绑定服务器、平台 SCUM 数据能力或通用 pluginData 能力。" });
|
||||
if (!input.serverInstanceId || !input.workspaceActions?.pluginData) {
|
||||
setState({ status: "error", reason: "插件页面没有绑定服务器或通用 pluginData 能力。" });
|
||||
inFlight = false;
|
||||
return;
|
||||
}
|
||||
@@ -258,15 +243,14 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
|
||||
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,
|
||||
activityStatus, setActivityStatus, activityDetailId, setActivityDetailId, eventId, setEventId, eventName, setEventName,
|
||||
eventCommand, setEventCommand, eventItems, setEventItems, eventRegionName, setEventRegionName, eventRegionRadius, setEventRegionRadius, eventRegionX, setEventRegionX, eventRegionY, setEventRegionY, eventRegionZ, setEventRegionZ,
|
||||
giftTab, setGiftTab, giftSearch, setGiftSearch, giftStatus, setGiftStatus, giftRecordKey, setGiftRecordKey, giftCode, setGiftCode, giftName, setGiftName, giftItems, setGiftItems, 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, mapPlaybackFrameRef, mapFollowRef, mapTimePreset, setMapTimePreset, mapTimeFrom, setMapTimeFrom, mapTimeTo, setMapTimeTo,
|
||||
mapUsers, setMapUsers, mapTrackKey, setMapTrackKey, mapPlayback, setMapPlayback,
|
||||
eventEditorOpen, setEventEditorOpen, produceEditorOpen, setProduceEditorOpen, giftDialog, setGiftDialog,
|
||||
giftDialog, setGiftDialog,
|
||||
mapBaseLayer, setMapBaseLayer, mapZonesOverlay, setMapZonesOverlay, catalogSearch, setCatalogSearch, catalogKind, setCatalogKind, catalogOpen, setCatalogOpen,
|
||||
setAction, refresh: () => setRefreshSignal((value) => value + 1),
|
||||
mapData, mapSceneContents: null, mapStaticContents: null, mapSelectedTrails: [], mapSelectedLocks: []
|
||||
@@ -302,15 +286,10 @@ type ViewState = {
|
||||
playerSearch: string; setPlayerSearch: StateSetter<string>; playerStatus: string; setPlayerStatus: StateSetter<string>;
|
||||
playerPanel: PlayerPanelState; setPlayerPanel: StateSetter<PlayerPanelState>;
|
||||
squadSearch: string; setSquadSearch: StateSetter<string>; selectedSquadId: string; setSelectedSquadId: StateSetter<string>;
|
||||
activityStatus: string; setActivityStatus: StateSetter<string>; giftTab: GiftTab; setGiftTab: StateSetter<GiftTab>; giftSearch: string; setGiftSearch: StateSetter<string>; giftStatus: string; setGiftStatus: StateSetter<string>; giftRecordKey: string; setGiftRecordKey: StateSetter<string>;
|
||||
activityStatus: string; setActivityStatus: StateSetter<string>; activityDetailId: string; setActivityDetailId: StateSetter<string>; giftTab: GiftTab; setGiftTab: StateSetter<GiftTab>; giftSearch: string; setGiftSearch: StateSetter<string>; giftStatus: string; setGiftStatus: StateSetter<string>; giftRecordKey: string; setGiftRecordKey: StateSetter<string>;
|
||||
eventId: string; setEventId: StateSetter<string>; eventName: string; setEventName: StateSetter<string>;
|
||||
eventType: string; setEventType: StateSetter<string>; eventSchedule: string; setEventSchedule: StateSetter<string>;
|
||||
eventCommand: string; setEventCommand: StateSetter<string>;
|
||||
eventClass: string; setEventClass: StateSetter<string>; eventPlacard: string; setEventPlacard: StateSetter<string>; eventPercent: string; setEventPercent: StateSetter<string>;
|
||||
eventDuration: string; setEventDuration: StateSetter<string>;
|
||||
eventNpc: string; setEventNpc: StateSetter<string>; eventItem: string; setEventItem: StateSetter<string>; eventZombie: string; setEventZombie: StateSetter<string>; eventAnimal: string; setEventAnimal: StateSetter<string>;
|
||||
produceEventId: string; setProduceEventId: StateSetter<string>; produceId: string; setProduceId: StateSetter<string>; produceTradeGoodsId: string; setProduceTradeGoodsId: StateSetter<string>;
|
||||
producePercent: string; setProducePercent: StateSetter<string>; produceValue: string; setProduceValue: StateSetter<string>; produceRadius: string; setProduceRadius: StateSetter<string>; produceX: string; setProduceX: StateSetter<string>; produceY: string; setProduceY: StateSetter<string>; produceZ: string; setProduceZ: StateSetter<string>;
|
||||
eventItems: GiftItemDraft[]; setEventItems: StateSetter<GiftItemDraft[]>; eventRegionName: string; setEventRegionName: StateSetter<string>; eventRegionRadius: string; setEventRegionRadius: StateSetter<string>; eventRegionX: string; setEventRegionX: StateSetter<string>; eventRegionY: string; setEventRegionY: StateSetter<string>; eventRegionZ: string; setEventRegionZ: StateSetter<string>;
|
||||
giftCode: string; setGiftCode: StateSetter<string>; giftName: string; setGiftName: StateSetter<string>; giftItems: GiftItemDraft[]; setGiftItems: StateSetter<GiftItemDraft[]>;
|
||||
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>;
|
||||
@@ -321,7 +300,7 @@ type ViewState = {
|
||||
mapFollowRef: { current: boolean };
|
||||
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>;
|
||||
eventEditorOpen: boolean; setEventEditorOpen: StateSetter<boolean>; produceEditorOpen: boolean; setProduceEditorOpen: StateSetter<boolean>; giftDialog: GiftDialogState; setGiftDialog: StateSetter<GiftDialogState>;
|
||||
giftDialog: GiftDialogState; setGiftDialog: StateSetter<GiftDialogState>;
|
||||
mapBaseLayer: ScumMapLayer | undefined; setMapBaseLayer: StateSetter<ScumMapLayer | undefined>; mapZonesOverlay: boolean | undefined; setMapZonesOverlay: StateSetter<boolean | undefined>;
|
||||
catalogSearch: string; setCatalogSearch: StateSetter<string>; catalogKind: string; setCatalogKind: StateSetter<string>; catalogOpen: boolean; setCatalogOpen: StateSetter<boolean>;
|
||||
};
|
||||
@@ -502,105 +481,165 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData,
|
||||
return view.activityStatus === "all" || status === view.activityStatus;
|
||||
});
|
||||
const statuses = unique(data.events.map((event) => textField(runsByEvent.get(textField(event, "id", "eventId")), "status", "state") || textField(event, "status", "state")).filter(Boolean));
|
||||
const tradeEvents = recentRows(data.tradeEvents, "observedAt", "occurredAt", "createdAt");
|
||||
const openNewActivity = () => {
|
||||
view.setActivityDetailId("__new__"); view.setEventId(""); view.setEventName(""); view.setEventCommand(""); view.setEventItems([]);
|
||||
view.setEventRegionName(""); view.setEventRegionRadius("5000"); view.setEventRegionX(""); view.setEventRegionY(""); view.setEventRegionZ("0");
|
||||
};
|
||||
const openActivity = (event: RecordMap) => {
|
||||
const eventId = textField(event, "id", "eventId");
|
||||
const produce = data.eventProduces.find((row) => textField(row, "eventId", "event") === eventId);
|
||||
const region = activityRegionDraft(event, produce);
|
||||
view.setActivityDetailId(eventId); view.setEventId(eventId); view.setEventName(textField(event, "title", "name")); view.setEventCommand(textField(event, "rconCommand", "command"));
|
||||
view.setEventItems(activityItemDrafts(event, data.eventProduces)); view.setEventRegionName(region.name); view.setEventRegionRadius(region.radius); view.setEventRegionX(region.x); view.setEventRegionY(region.y); view.setEventRegionZ(region.z);
|
||||
};
|
||||
const saveEvent = () => runAction(view.setAction, "正在保存活动定义…", async () => {
|
||||
const id = view.eventId.trim();
|
||||
const name = view.eventName.trim();
|
||||
if (!id || !name) throw new Error("活动编号和名称不能为空。");
|
||||
await saveEventDefinition(actions ?? {}, {
|
||||
id, name, eventType: view.eventClass === "2" ? "fixed" : "range", class: integerInput(view.eventClass, 1), schedule: view.eventSchedule.trim(), corn: view.eventSchedule.trim(),
|
||||
rconCommand: view.eventCommand.trim(), command: view.eventCommand.trim(),
|
||||
placard: view.eventPlacard.trim(), announcement: view.eventPlacard.trim(), percent: integerInput(view.eventPercent, 100), probability: integerInput(view.eventPercent, 100),
|
||||
npc: minimumIntegerInput(view.eventNpc, 0, "NPC 数量"), item: minimumIntegerInput(view.eventItem, 0, "物品数量"), zombie: minimumIntegerInput(view.eventZombie, 0, "僵尸数量"), animal: minimumIntegerInput(view.eventAnimal, 0, "动物数量"),
|
||||
status: "enabled", announce: Boolean(view.eventPlacard.trim()), durationSeconds: minimumIntegerInput(view.eventDuration, 1, "活动持续秒数"), updatedAt: new Date().toISOString()
|
||||
});
|
||||
const title = view.eventName.trim();
|
||||
if (!id || !title) throw new Error("活动编号和活动标题不能为空。");
|
||||
if (!/^[A-Za-z0-9_.:-]{1,96}$/.test(id)) throw new Error("活动编号只能使用字母、数字、点、下划线、短横线或冒号。");
|
||||
const items = parseGiftItems(view.eventItems);
|
||||
if (!items.length) throw new Error("活动至少要包含一项物品或 NPC。");
|
||||
const region = activityRegionValue(view);
|
||||
const previous = data.events.find((event) => textField(event, "id", "eventId") === id);
|
||||
const legacy = previous ? {
|
||||
eventType: textField(previous, "eventType") || "range", class: integerInput(textField(previous, "class"), 1), schedule: textField(previous, "schedule", "corn"), corn: textField(previous, "corn", "schedule"),
|
||||
rconCommand: view.eventCommand.trim() || textField(previous, "rconCommand", "command"), command: view.eventCommand.trim() || textField(previous, "command", "rconCommand"),
|
||||
placard: textField(previous, "placard", "announcement"), announcement: textField(previous, "announcement", "placard"), percent: integerInput(textField(previous, "percent", "probability"), 100), probability: integerInput(textField(previous, "probability", "percent"), 100),
|
||||
npc: activityCount(items, "armed-npc", textField(previous, "npc")), item: activityCount(items, "item", textField(previous, "item")), zombie: activityCount(items, "zombie", textField(previous, "zombie")), animal: activityCount(items, "animal", textField(previous, "animal")),
|
||||
status: textField(previous, "status") || "enabled", announce: boolField(previous, "announce") || Boolean(textField(previous, "placard", "announcement")), durationSeconds: integerInput(textField(previous, "durationSeconds"), 1800)
|
||||
} : { eventType: "range", class: 1, rconCommand: view.eventCommand.trim(), command: view.eventCommand.trim(), percent: 100, probability: 100, npc: activityCount(items, "armed-npc"), item: activityCount(items, "item"), zombie: activityCount(items, "zombie"), animal: activityCount(items, "animal"), status: "enabled", announce: false, durationSeconds: 1800 };
|
||||
await saveEventDefinition(actions ?? {}, { ...legacy, id, name: title, title, region, area: region, items, produces: activityProduceRecords(id, items, region), updatedAt: new Date().toISOString() });
|
||||
view.setActivityDetailId(id);
|
||||
view.refresh();
|
||||
return `活动 ${name} 已保存。`;
|
||||
return `活动 ${title} 已保存。`;
|
||||
});
|
||||
const saveProduce = () => runAction(view.setAction, "正在保存活动生成项…", async () => {
|
||||
const eventId = view.produceEventId.trim() || view.eventId.trim() || textField(data.events[0], "id", "eventId");
|
||||
const tradeGoodsId = view.produceTradeGoodsId.trim();
|
||||
if (!eventId || !tradeGoodsId) throw new Error("活动编号和物品编号不能为空。");
|
||||
await saveEventProduce(actions ?? {}, {
|
||||
id: view.produceId.trim(), eventId, tradeGoodsId, percent: integerInput(view.producePercent, 100), value: integerInput(view.produceValue, 1),
|
||||
r: numberInput(view.produceRadius, 0), x: numberInput(view.produceX, 0), y: numberInput(view.produceY, 0), z: numberInput(view.produceZ, 0), updatedAt: new Date().toISOString()
|
||||
});
|
||||
view.refresh();
|
||||
return "活动生成项已保存。";
|
||||
});
|
||||
const activityHistory = data.activityEvents.filter((event) => Boolean(textField(event, "occurredAt", "createdAt")) && textField(event, "taskKind").toLowerCase() !== "active-task");
|
||||
return e("div", { className: "console-record-list" },
|
||||
statsStrip(e, [["活动定义", data.events.length], ["运行记录", data.eventRuns.length], ["物品列表", data.tradeGoods.length], ["交易记录", data.tradeEvents.length]]),
|
||||
e("div", { className: "overview-two-col" },
|
||||
e("details", { className: "console-module scum-editor", open: view.eventEditorOpen, onToggle: (event: InputEvent) => view.setEventEditorOpen(detailOpen(event)) }, e("summary", null, e("strong", null, "新建或更新活动"), e("span", { className: "page-status" }, view.eventId || view.eventName ? "编辑中" : "点击展开")),
|
||||
labeledField(e, "活动编号", e("input", { value: view.eventId, "aria-label": "活动编号", placeholder: "例如 event_supply_drop", onChange: (event: InputEvent) => view.setEventId(inputValue(event)) })),
|
||||
labeledField(e, "活动名称", e("input", { value: view.eventName, "aria-label": "活动名称", placeholder: "例如 空投补给", onChange: (event: InputEvent) => view.setEventName(inputValue(event)) })),
|
||||
labeledField(e, "生成类型", e("select", { value: view.eventClass, "aria-label": "生成类型", onChange: (event: InputEvent) => view.setEventClass(inputValue(event)) }, e("option", { value: "1" }, "范围生成"), e("option", { value: "2" }, "固定坐标生成"))),
|
||||
labeledField(e, "活动计划", e("input", { value: view.eventSchedule, "aria-label": "活动计划", placeholder: "Cron(留空为手动)", onChange: (event: InputEvent) => view.setEventSchedule(inputValue(event)) })),
|
||||
labeledField(e, "RCON 命令", e("input", { value: view.eventCommand, "aria-label": "RCON 命令", placeholder: "例如 #start_event event_supply_drop", onChange: (event: InputEvent) => view.setEventCommand(inputValue(event)) })),
|
||||
labeledField(e, "活动公告", e("input", { value: view.eventPlacard, "aria-label": "活动公告", placeholder: "活动开始时显示的公告", onChange: (event: InputEvent) => view.setEventPlacard(inputValue(event)) })),
|
||||
labeledField(e, "触发概率 (%)", e("input", { value: view.eventPercent, "aria-label": "活动概率", type: "number", placeholder: "100", onChange: (event: InputEvent) => view.setEventPercent(inputValue(event)) })),
|
||||
labeledField(e, "持续秒数", e("input", { value: view.eventDuration, "aria-label": "活动持续秒数", type: "number", min: 1, placeholder: "1800", onChange: (event: InputEvent) => view.setEventDuration(inputValue(event)) })),
|
||||
e("div", { className: "console-row-actions" },
|
||||
labeledField(e, "NPC 数量", e("input", { value: view.eventNpc, "aria-label": "NPC 数量", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setEventNpc(inputValue(event)) })),
|
||||
labeledField(e, "物品数量", e("input", { value: view.eventItem, "aria-label": "物品数量", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setEventItem(inputValue(event)) })),
|
||||
labeledField(e, "僵尸数量", e("input", { value: view.eventZombie, "aria-label": "僵尸数量", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setEventZombie(inputValue(event)) })),
|
||||
labeledField(e, "动物数量", e("input", { value: view.eventAnimal, "aria-label": "动物数量", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setEventAnimal(inputValue(event)) }))),
|
||||
e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData, onClick: saveEvent }, "保存活动")
|
||||
const renderEditor = (event?: RecordMap) => {
|
||||
const eventId = event ? textField(event, "id", "eventId") : "__new__";
|
||||
const isNew = eventId === "__new__";
|
||||
return e("div", { className: "activity-editor" },
|
||||
labeledField(e, "活动编号", e("input", { value: view.eventId, "aria-label": "活动编号", placeholder: "例如 event_supply_drop", readOnly: !isNew, onChange: (event: InputEvent) => view.setEventId(inputValue(event)) })),
|
||||
labeledField(e, "活动标题", e("input", { value: view.eventName, "aria-label": "活动标题", placeholder: "例如 空投补给", onChange: (event: InputEvent) => view.setEventName(inputValue(event)) })),
|
||||
labeledField(e, "区域名称", e("input", { value: view.eventRegionName, "aria-label": "活动区域名称", placeholder: "例如 北部机场补给区", onChange: (event: InputEvent) => view.setEventRegionName(inputValue(event)) })),
|
||||
e("div", { className: "activity-region-fields" },
|
||||
labeledField(e, "区域半径", e("input", { value: view.eventRegionRadius, "aria-label": "活动区域半径", type: "number", min: "1", step: "1", placeholder: "5000", onChange: (event: InputEvent) => view.setEventRegionRadius(inputValue(event)) })),
|
||||
labeledField(e, "中心 Z", e("input", { value: view.eventRegionZ, "aria-label": "活动区域 Z", type: "number", onChange: (event: InputEvent) => view.setEventRegionZ(inputValue(event)) }))
|
||||
),
|
||||
e("article", { className: "console-module" }, e("h2", null, "状态筛选"),
|
||||
e("select", { value: view.activityStatus, "aria-label": "活动状态", onChange: (event: InputEvent) => view.setActivityStatus(inputValue(event)) }, e("option", { value: "all" }, "全部状态"), statuses.map((status) => e("option", { key: status, value: status }, status))),
|
||||
e("p", { className: "page-status" }, "活动定义属于插件;SCUM 原生 event_round 和 quest/task 只作为运行事实展示。")
|
||||
activityMapPicker(e, data, view),
|
||||
e("div", { className: "activity-region-fields activity-region-coordinates" },
|
||||
labeledField(e, "中心 X", e("input", { value: view.eventRegionX, "aria-label": "活动区域 X", type: "number", onChange: (event: InputEvent) => view.setEventRegionX(inputValue(event)) })),
|
||||
labeledField(e, "中心 Y", e("input", { value: view.eventRegionY, "aria-label": "活动区域 Y", type: "number", onChange: (event: InputEvent) => view.setEventRegionY(inputValue(event)) }))
|
||||
),
|
||||
e("div", { className: "gift-form-wide gift-form-section activity-items-section" }, e("div", { className: "gift-form-section-title" }, e("strong", null, "活动物品 / NPC"), e("span", null, "复用礼包目录;每项配置数量、概率和耐久")), activityItemEditorList(e, view), catalogPicker(e, view, "activity-items")),
|
||||
e("div", { className: "console-row-actions activity-editor-actions" },
|
||||
e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData, onClick: saveEvent }, "保存活动"),
|
||||
event && textField(event, "rconCommand", "command") ? e("button", { type: "button", className: "icon-command", disabled: !actions?.dispatch || !actions?.pluginData, onClick: () => runAction(view.setAction, "正在启动活动…", async () => { await startEvent(actions ?? {}, event, data.eventProduces.filter((produce) => textField(produce, "eventId", "event") === eventId)); view.refresh(); return "活动已进入执行队列。"; }) }, "立即启动") : null,
|
||||
event ? e("button", { type: "button", className: "icon-command danger-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在删除活动…", async () => { await deleteEventDefinition(actions ?? {}, eventId, data.eventProduces); view.setActivityDetailId(""); view.refresh(); return "活动已删除。"; }) }, "删除") : null
|
||||
)
|
||||
);
|
||||
};
|
||||
return e("div", { className: "console-record-list activity-list" },
|
||||
e("div", { className: "resource-filter-bar scum-filter-bar activity-list-toolbar" },
|
||||
e("span", { className: "page-status" }, `${events.length} / ${data.events.length} 个活动`),
|
||||
labeledField(e, "活动状态", e("select", { value: view.activityStatus, "aria-label": "活动状态", onChange: (event: InputEvent) => view.setActivityStatus(inputValue(event)) }, e("option", { value: "all" }, "全部状态"), statuses.map((status) => e("option", { key: status, value: status }, status)))),
|
||||
e("button", { type: "button", className: "primary-command activity-create-command", disabled: !actions?.pluginData, onClick: openNewActivity }, "新建活动")
|
||||
),
|
||||
e("details", { className: "console-module scum-editor", open: view.produceEditorOpen, onToggle: (event: InputEvent) => view.setProduceEditorOpen(detailOpen(event)) }, e("summary", null, e("strong", null, "活动生成项"), e("span", { className: "page-status" }, view.produceEventId || view.produceId ? "编辑中" : "点击展开")),
|
||||
e("div", { className: "console-row-actions" },
|
||||
labeledField(e, "活动编号", e("input", { value: view.produceEventId, "aria-label": "生成项活动编号", placeholder: "默认使用上方活动", onChange: (event: InputEvent) => view.setProduceEventId(inputValue(event)) })),
|
||||
labeledField(e, "生成项编号", e("input", { value: view.produceId, "aria-label": "生成项编号", placeholder: "更新时填写", onChange: (event: InputEvent) => view.setProduceId(inputValue(event)) })),
|
||||
labeledField(e, "物品编号", e("input", { value: view.produceTradeGoodsId, "aria-label": "生成物品编号", placeholder: "TradeGoods ID", onChange: (event: InputEvent) => view.setProduceTradeGoodsId(inputValue(event)) })),
|
||||
labeledField(e, "概率 (%)", e("input", { value: view.producePercent, "aria-label": "生成概率", type: "number", placeholder: "100", onChange: (event: InputEvent) => view.setProducePercent(inputValue(event)) })),
|
||||
labeledField(e, "数量", e("input", { value: view.produceValue, "aria-label": "生成数量", type: "number", placeholder: "1", onChange: (event: InputEvent) => view.setProduceValue(inputValue(event)) }))),
|
||||
catalogPicker(e, view, "produce-item"),
|
||||
e("div", { className: "console-row-actions" },
|
||||
labeledField(e, "半径", e("input", { value: view.produceRadius, "aria-label": "生成半径", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setProduceRadius(inputValue(event)) })),
|
||||
labeledField(e, "X", e("input", { value: view.produceX, "aria-label": "生成 X", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setProduceX(inputValue(event)) })),
|
||||
labeledField(e, "Y", e("input", { value: view.produceY, "aria-label": "生成 Y", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setProduceY(inputValue(event)) })),
|
||||
labeledField(e, "Z", e("input", { value: view.produceZ, "aria-label": "生成 Z", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setProduceZ(inputValue(event)) })),
|
||||
e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData, onClick: saveProduce }, "保存生成项")),
|
||||
e("div", { className: "console-row-list" }, data.eventProduces.length ? data.eventProduces.map((produce, index) => e("div", { key: idOf(produce, `produce-${index}`), className: "console-row" },
|
||||
catalogIconStrip(e, catalogIconsForValue(textField(produce, "tradeGoodsId", "trade_goods_id"))),
|
||||
e("span", null, `${textField(produce, "eventId", "event")} / ${tradeGoodsLabel(data.tradeGoods, textField(produce, "tradeGoodsId", "trade_goods_id"))}`),
|
||||
e("strong", null, `${numField(produce, "percent")}% × ${numField(produce, "value")}`),
|
||||
e("strong", null, `R ${numField(produce, "r")} · ${coords(produce)}`),
|
||||
e("button", { type: "button", className: "icon-command", onClick: () => { view.setProduceEditorOpen(true); view.setProduceEventId(textField(produce, "eventId", "event")); view.setProduceId(textField(produce, "id", "produceId")); view.setProduceTradeGoodsId(textField(produce, "tradeGoodsId", "trade_goods_id")); view.setProducePercent(numField(produce, "percent")); view.setProduceValue(numField(produce, "value")); view.setProduceRadius(numField(produce, "r")); view.setProduceX(numField(produce, "x")); view.setProduceY(numField(produce, "y")); view.setProduceZ(numField(produce, "z")); } }, "编辑"),
|
||||
e("button", { type: "button", className: "icon-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在删除生成项…", async () => { await deleteEventProduce(actions ?? {}, produce); view.refresh(); return "生成项已删除。"; }) }, "删除"))) : e("p", { className: "page-status" }, "暂无活动生成项。"))
|
||||
e("details", { className: "console-module activity-record activity-record-new", open: view.activityDetailId === "__new__", onToggle: (event: InputEvent) => { if (detailOpen(event)) openNewActivity(); else if (view.activityDetailId === "__new__") view.setActivityDetailId(""); } },
|
||||
e("summary", null, e("strong", null, "新建活动"), e("span", { className: "page-status" }, "展开配置活动区域和发放清单")),
|
||||
view.activityDetailId === "__new__" ? renderEditor() : null
|
||||
),
|
||||
events.length ? events.map((event, index) => {
|
||||
const eventId = textField(event, "id", "eventId");
|
||||
const run = runsByEvent.get(eventId);
|
||||
const status = textField(run, "status", "state") || textField(event, "status", "state") || "unknown";
|
||||
return e("article", { key: idOf(event, `event-${index}`), className: "console-record" },
|
||||
e("div", { className: "console-record-head" }, e("strong", null, textField(event, "name", "title") || eventId || "未命名活动"), e("span", { className: `status-pill ${activeStatus(status) ? "status-active" : "status-disabled"}` }, status)),
|
||||
e("div", { className: "console-record-meta" }, e("span", null, `生成 ${Number(field(event, "class")) === 2 ? "固定坐标" : "范围"}`), e("span", null, `计划 ${textField(event, "schedule", "corn") || "手动"}`), e("span", null, `概率 ${numField(event, "percent", "probability")}%`), e("span", null, `NPC/物品/僵尸/动物 ${numField(event, "npc")}/${numField(event, "item")}/${numField(event, "zombie")}/${numField(event, "animal")}`), e("span", null, textField(event, "placard", "announcement") || "无公告")),
|
||||
e("div", { className: "console-row-actions" },
|
||||
e("button", { type: "button", className: "primary-command", disabled: !actions?.dispatch || !actions?.pluginData, onClick: () => runAction(view.setAction, "正在启动活动…", async () => { await startEvent(actions ?? {}, event, data.eventProduces.filter((produce) => textField(produce, "eventId", "event") === eventId)); view.refresh(); return "活动命令已进入执行队列。"; }) }, "立即启动"),
|
||||
e("button", { type: "button", className: "icon-command", onClick: () => { view.setEventEditorOpen(true); view.setEventId(eventId); view.setEventName(textField(event, "name")); view.setEventClass(numField(event, "class") === "--" ? "1" : numField(event, "class")); view.setEventSchedule(textField(event, "schedule", "corn")); view.setEventCommand(textField(event, "rconCommand", "command")); view.setEventPlacard(textField(event, "placard", "announcement")); view.setEventPercent(numField(event, "percent", "probability")); view.setEventDuration(numField(event, "durationSeconds") === "--" ? "1800" : numField(event, "durationSeconds")); view.setEventNpc(numField(event, "npc")); view.setEventItem(numField(event, "item")); view.setEventZombie(numField(event, "zombie")); view.setEventAnimal(numField(event, "animal")); } }, "编辑"),
|
||||
e("button", { type: "button", className: "icon-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在删除活动…", async () => { await deleteEventDefinition(actions ?? {}, eventId, data.eventProduces); view.refresh(); return "活动定义已删除。"; }) }, "删除")
|
||||
)
|
||||
const region = activityRegionDraft(event, data.eventProduces.find((row) => textField(row, "eventId", "event") === eventId));
|
||||
const items = activityItemDrafts(event, data.eventProduces);
|
||||
return e("details", { key: idOf(event, `event-${index}`), className: "console-module activity-record", open: view.activityDetailId === eventId, onToggle: (toggle: InputEvent) => { if (detailOpen(toggle)) openActivity(event); else if (view.activityDetailId === eventId) view.setActivityDetailId(""); } },
|
||||
e("summary", null, e("strong", null, textField(event, "title", "name") || eventId || "未命名活动"), e("span", { className: `status-pill ${activeStatus(status) ? "status-active" : "status-disabled"}` }, status), e("span", { className: "page-status" }, `${region.name || "未命名区域"} · ${items.length} 项物品 / NPC`)),
|
||||
view.activityDetailId === eventId ? renderEditor(event) : null
|
||||
);
|
||||
}) : e("p", { className: "page-status" }, "没有符合状态筛选的真实活动。"),
|
||||
e("div", { className: "overview-two-col" },
|
||||
tablePanel(e, "原生赛事轮次", data.nativeEventRounds, (event) => [textField(event, "eventId") || "event", textField(event, "state") || "unknown", `Kills ${numField(event, "enemyKills")}`, dateField(event, "startTime")]),
|
||||
tablePanel(e, "Quest / Task", data.tasks, (task) => [textField(task, "taskKind") || "task", textField(task, "dataAssetPath") || "unknown", textField(task, "state") || "unknown", textField(task, "userProfileId") || "unknown"])
|
||||
),
|
||||
e("div", { className: "overview-two-col" },
|
||||
itemCatalogPanel(e, data.tradeGoods),
|
||||
tablePanel(e, "最近商人交易", tradeEvents, (event) => [tradeGoodsLabel(data.tradeGoods, textField(event, "itemCode", "code", "tradeGoodsId")), `${tradeActionLabel(event)} × ${numField(event, "quantity", "itemCount")}`, `玩家 ${shortHash(textField(event, "steamId", "playerId"))}`, `价格 ${numField(event, "price", "currencyDelta")}`, dateField(event, "observedAt", "occurredAt", "createdAt")])
|
||||
),
|
||||
tablePanel(e, "最近活动记录", [...data.eventRuns, ...activityHistory], (event) => [textField(event, "eventName", "type", "kind", "activityType") || "event", textField(event, "subjectName", "eventId", "subjectId", "subject") || "unknown", textField(event, "status", "result", "state") || "unknown", dateField(event, "startedAt", "occurredAt", "createdAt")])
|
||||
}) : e("p", { className: "page-status" }, "暂无活动,请先新建活动。")
|
||||
);
|
||||
}
|
||||
|
||||
function activityItemEditorList(e: ReactLike["createElement"], view: ViewState) { return catalogItemEditorList(e, view.eventItems, view.setEventItems, "活动物品"); }
|
||||
|
||||
function activityRegionDraft(event: RecordMap, produce?: RecordMap): { name: string; radius: string; x: string; y: string; z: string } {
|
||||
const nested = field(event, "region", "area", "location");
|
||||
const source = isRecord(nested) ? nested : event;
|
||||
const fallback = produce ? positionOf(produce) : undefined;
|
||||
const value = (keys: string[], fallbackValue = "") => { const direct = textField(source, ...keys); return direct || (fallback ? textField(fallback, ...keys) : fallbackValue); };
|
||||
const name = isRecord(nested) ? value(["name", "label", "regionName", "areaName"]) : value(["regionName", "areaName", "regionLabel"]);
|
||||
return { name, radius: value(["radius", "r", "size"], "5000"), x: value(["x", "locationX"]), y: value(["y", "locationY"]), z: value(["z", "locationZ"], "0") };
|
||||
}
|
||||
|
||||
function activityItemDrafts(event: RecordMap, produces: RecordMap[]): GiftItemDraft[] {
|
||||
const direct = giftItemDrafts(event);
|
||||
if (direct.length) return direct;
|
||||
const eventId = textField(event, "id", "eventId");
|
||||
return produces.filter((produce) => textField(produce, "eventId", "event") === eventId).map((produce) => ({ catalogCode: textField(produce, "catalogCode", "tradeGoodsId", "trade_goods_id"), quantity: numField(produce, "quantity", "value") === "--" ? "1" : numField(produce, "quantity", "value"), probability: numField(produce, "probability", "percent") === "--" ? "100" : numField(produce, "probability", "percent"), durability: giftItemRawDurability(produce) }));
|
||||
}
|
||||
|
||||
function activityRegionValue(view: ViewState): { name: string; radius: number; x: number; y: number; z: number } {
|
||||
const radius = Number(view.eventRegionRadius); const x = Number(view.eventRegionX); const y = Number(view.eventRegionY); const z = Number(view.eventRegionZ);
|
||||
if (!view.eventRegionX.trim() || !view.eventRegionY.trim() || ![radius, x, y, z].every(Number.isFinite) || radius <= 0) throw new Error("请在地图上选择活动区域,并填写有效的区域半径。");
|
||||
return { name: view.eventRegionName.trim(), radius, x, y, z };
|
||||
}
|
||||
|
||||
function activityProduceRecords(eventId: string, items: Array<{ catalogCode: string; quantity: number | string; probability: number | string; durability?: number | string }>, region: { radius: number; x: number; y: number; z: number }): RecordMap[] {
|
||||
return items.map((item, index) => { const quantity = Number(item.quantity); const probability = Number(item.probability); const durability = Number(item.durability); return { id: `produce-${index + 1}`, eventId, tradeGoodsId: item.catalogCode, catalogCode: item.catalogCode, percent: Number.isFinite(probability) ? probability : 100, probability: Number.isFinite(probability) ? probability : 100, value: Number.isSafeInteger(quantity) && quantity > 0 ? quantity : 1, quantity: Number.isSafeInteger(quantity) && quantity > 0 ? quantity : 1, ...(Number.isFinite(durability) ? { durability } : {}), r: region.radius, x: region.x, y: region.y, z: region.z }; });
|
||||
}
|
||||
|
||||
function activityCount(items: Array<{ catalogCode: string; quantity: number | string }>, kind: ScumCatalogEntry["kind"], fallback = "0"): number {
|
||||
const count = items.filter((item) => scumCatalogEntryFor(item.catalogCode)?.kind === kind).reduce((total, item) => total + (Number(item.quantity) || 0), 0);
|
||||
return count || Number(fallback) || 0;
|
||||
}
|
||||
|
||||
function activityMapPicker(e: ReactLike["createElement"], data: SCUMSurfaceData, view: ViewState) {
|
||||
const settings = data.mapSettings.find((value) => textField(value, "_recordKey", "id") === "current") ?? data.mapSettings[0];
|
||||
const bounds = resolveMapBounds(settings);
|
||||
const selected = view.eventRegionX.trim() && view.eventRegionY.trim() && Number.isFinite(Number(view.eventRegionX)) && Number.isFinite(Number(view.eventRegionY)) ? { x: Number(view.eventRegionX), y: Number(view.eventRegionY) } : undefined;
|
||||
const selectedStyle = selected ? activityMapSelectionStyle(selected, Number(view.eventRegionRadius) || 5000, bounds) : undefined;
|
||||
const choose = (event: ActivityMapPointerEvent) => {
|
||||
const point = activityMapWorldPoint(event, bounds);
|
||||
if (!point) return;
|
||||
view.setEventRegionX(String(Math.round(point.x))); view.setEventRegionY(String(Math.round(point.y)));
|
||||
const start = activityMapDragState;
|
||||
if (start.active) {
|
||||
const startPoint = activityMapWorldPoint({ ...event, clientX: start.startX, clientY: start.startY, currentTarget: event.currentTarget }, bounds);
|
||||
if (startPoint) {
|
||||
const radius = Math.round(Math.hypot(point.x - startPoint.x, point.y - startPoint.y));
|
||||
if (radius > 0) view.setEventRegionRadius(String(radius));
|
||||
}
|
||||
}
|
||||
activityMapDragState.active = false;
|
||||
};
|
||||
return e("div", { className: "activity-map-picker" },
|
||||
e("div", { className: "activity-map-picker-head" }, e("strong", null, "活动区域"), e("span", { className: "page-status" }, "点击选择中心;从中心拖动可直接设置半径")),
|
||||
e("div", { className: "activity-map-picker-board", role: "application", "aria-label": "选择活动区域地图", onPointerDown: (event: ActivityMapPointerEvent) => { activityMapDragState.active = true; activityMapDragState.pointerId = event.pointerId ?? -1; activityMapDragState.startX = event.clientX ?? 0; activityMapDragState.startY = event.clientY ?? 0; event.currentTarget?.setPointerCapture?.(event.pointerId); }, onPointerUp: choose, onPointerCancel: () => { activityMapDragState.active = false; } },
|
||||
e("div", { className: "activity-map-picker-scene", style: { backgroundImage: mapBoardStyle(mapLayerSetting(settings), false) } },
|
||||
mapGridOverlay(e),
|
||||
data.mapRegions.map((region, index) => { const position = positionOf(region); return hasCoordinates(position) ? e("button", { key: idOf(region, `map-region-${index}`), type: "button", className: "activity-map-region", style: mapPointStyle(position, bounds), title: textField(region, "name", "label", "id") || "已有地图区域", onPointerDown: (event: ActivityMapPointerEvent) => event.stopPropagation?.(), onClick: (event: ActivityMapPointerEvent) => { event.stopPropagation?.(); view.setEventRegionName(textField(region, "name", "label")); view.setEventRegionX(String(Number(field(position, "x", "locationX")))); view.setEventRegionY(String(Number(field(position, "y", "locationY")))); view.setEventRegionZ(String(Number(field(position, "z", "locationZ") ?? 0))); view.setEventRegionRadius(numField(region, "radius", "r") === "--" ? "5000" : numField(region, "radius", "r")); } }, textField(region, "name", "label") || "区") : null; }),
|
||||
selectedStyle ? e("span", { className: "activity-map-selection", style: selectedStyle, "aria-label": "当前活动区域" }) : null
|
||||
)
|
||||
),
|
||||
e("p", { className: "page-status" }, `当前区域:${view.eventRegionName || "未命名"} · ${view.eventRegionX || "未选择"} / ${view.eventRegionY || "未选择"} · 半径 ${view.eventRegionRadius || "未设置"}`)
|
||||
);
|
||||
}
|
||||
|
||||
function activityMapWorldPoint(event: ActivityMapPointerEvent, bounds: { worldMinX: number; worldMinY: number; worldMaxX: number; worldMaxY: number }): { x: number; y: number } | undefined {
|
||||
const rect = event.currentTarget?.getBoundingClientRect?.();
|
||||
if (!rect || !rect.width || !rect.height || event.clientX === undefined || event.clientY === undefined) return undefined;
|
||||
const horizontal = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
|
||||
const vertical = Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height));
|
||||
return { x: bounds.worldMaxX - horizontal * (bounds.worldMaxX - bounds.worldMinX), y: bounds.worldMaxY - vertical * (bounds.worldMaxY - bounds.worldMinY) };
|
||||
}
|
||||
|
||||
function activityMapSelectionStyle(point: { x: number; y: number }, radius: number, bounds: { worldMinX: number; worldMinY: number; worldMaxX: number; worldMaxY: number }): Record<string, string> {
|
||||
const center = mapPointStyle(point, bounds);
|
||||
const diameter = Math.max(2, Math.min(80, radius * 2 / Math.max(bounds.worldMaxX - bounds.worldMinX, bounds.worldMaxY - bounds.worldMinY) * 100));
|
||||
return { ...center, width: `${diameter}%`, height: `${diameter}%` };
|
||||
}
|
||||
|
||||
function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
|
||||
const actions = input.workspaceActions;
|
||||
const openNewGift = () => {
|
||||
@@ -706,9 +745,11 @@ function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, inpu
|
||||
);
|
||||
}
|
||||
|
||||
function giftItemEditorList(e: ReactLike["createElement"], view: ViewState) {
|
||||
if (!view.giftItems.length) return e("p", { className: "page-status" }, "尚未添加物品,请使用目录选择器加入礼包内容。");
|
||||
return e("div", { className: "gift-item-editor-list" }, view.giftItems.map((item, index) => {
|
||||
function giftItemEditorList(e: ReactLike["createElement"], view: ViewState) { return catalogItemEditorList(e, view.giftItems, view.setGiftItems, "礼包物品"); }
|
||||
|
||||
function catalogItemEditorList(e: ReactLike["createElement"], items: GiftItemDraft[], setItems: StateSetter<GiftItemDraft[]>, labelPrefix: string) {
|
||||
if (!items.length) return e("p", { className: "page-status" }, `尚未添加物品,请使用目录选择器加入${labelPrefix}。`);
|
||||
return e("div", { className: "gift-item-editor-list" }, items.map((item, index) => {
|
||||
const entry = scumCatalogEntryFor(item.catalogCode);
|
||||
const supportsDurability = giftItemSupportsDurability(entry);
|
||||
const itemLabel = entry ? scumCatalogDisplayName(entry) : item.catalogCode || "未命名物品";
|
||||
@@ -718,17 +759,17 @@ function giftItemEditorList(e: ReactLike["createElement"], view: ViewState) {
|
||||
catalogIconStrip(e, catalogIconsForValue(item.catalogCode)),
|
||||
e("div", { className: "gift-item-editor-info" }, e("strong", null, itemLabel), e("span", { className: "provider-id" }, `${category} · ${item.catalogCode || "未配置代码"}`)),
|
||||
e("div", { className: "gift-item-editor-fields" },
|
||||
labeledField(e, "数量", e("input", { type: "number", min: "1", step: "1", value: item.quantity, "aria-label": `礼包物品 ${itemNumber} 数量`, onChange: (event: InputEvent) => updateGiftItemDraft(view, index, "quantity", inputValue(event)) })),
|
||||
labeledField(e, "概率", e("input", { type: "number", min: "0", max: "100", step: "1", value: item.probability, "aria-label": `礼包物品 ${itemNumber} 概率`, onChange: (event: InputEvent) => updateGiftItemDraft(view, index, "probability", inputValue(event)) })),
|
||||
labeledField(e, "耐久", e("input", { type: "number", min: "0", max: "100", step: "1", value: item.durability, disabled: !supportsDurability, placeholder: supportsDurability ? "默认" : "不适用", "aria-label": `礼包物品 ${itemNumber} 耐久`, onChange: (event: InputEvent) => updateGiftItemDraft(view, index, "durability", inputValue(event)) })),
|
||||
e("button", { type: "button", className: "gift-item-editor-remove icon-command", "aria-label": `删除礼包物品 ${itemNumber}`, onClick: () => view.setGiftItems((items) => items.filter((_, itemIndex) => itemIndex !== index)) }, "删除")
|
||||
labeledField(e, "数量", e("input", { type: "number", min: "1", step: "1", value: item.quantity, "aria-label": `${labelPrefix} ${itemNumber} 数量`, onChange: (event: InputEvent) => updateCatalogItemDraft(setItems, index, "quantity", inputValue(event)) })),
|
||||
labeledField(e, "概率", e("input", { type: "number", min: "0", max: "100", step: "1", value: item.probability, "aria-label": `${labelPrefix} ${itemNumber} 概率`, onChange: (event: InputEvent) => updateCatalogItemDraft(setItems, index, "probability", inputValue(event)) })),
|
||||
labeledField(e, "耐久", e("input", { type: "number", min: "0", max: "100", step: "1", value: item.durability, disabled: !supportsDurability, placeholder: supportsDurability ? "默认" : "不适用", "aria-label": `${labelPrefix} ${itemNumber} 耐久`, onChange: (event: InputEvent) => updateCatalogItemDraft(setItems, index, "durability", inputValue(event)) })),
|
||||
e("button", { type: "button", className: "gift-item-editor-remove icon-command", "aria-label": `删除${labelPrefix} ${itemNumber}`, onClick: () => setItems((current) => current.filter((_, itemIndex) => itemIndex !== index)) }, "删除")
|
||||
)
|
||||
);
|
||||
}));
|
||||
}
|
||||
|
||||
function updateGiftItemDraft(view: ViewState, index: number, key: GiftItemDraftField, value: string): void {
|
||||
view.setGiftItems((items) => items.map((item, itemIndex) => itemIndex === index ? { ...item, [key]: value } : item));
|
||||
function updateCatalogItemDraft(setItems: StateSetter<GiftItemDraft[]>, index: number, key: GiftItemDraftField, value: string): void {
|
||||
setItems((items) => items.map((item, itemIndex) => itemIndex === index ? { ...item, [key]: value } : item));
|
||||
}
|
||||
|
||||
function giftDetailsDialog(e: ReactLike["createElement"], gift: RecordMap, view: ViewState, editGift: () => void) {
|
||||
@@ -1387,7 +1428,7 @@ function mapLayerLabel(layer: ScumMapLayer): string { return layer === "topo" ?
|
||||
function catalogMatches(query: string, kind: string, target: CatalogPickTarget): ScumCatalogEntry[] {
|
||||
const search = query.trim().toLowerCase();
|
||||
return scumCatalogEntries.filter((entry) => {
|
||||
const matchesKind = target === "gift-items" ? kind === "all" || scumGiftCatalogFilterKey(entry) === kind : kind === "all" || entry.kind === kind;
|
||||
const matchesKind = kind === "all" || scumGiftCatalogFilterKey(entry) === kind;
|
||||
return matchesKind && (!search || scumCatalogSearchText(entry).includes(search));
|
||||
}).slice(0, scumCatalogPickLimit);
|
||||
}
|
||||
@@ -1414,15 +1455,15 @@ function giftCatalogIcons(gift: RecordMap): string[] {
|
||||
}
|
||||
|
||||
function catalogPicker(e: ReactLike["createElement"], view: ViewState, target: CatalogPickTarget) {
|
||||
const filters = target === "gift-items" ? scumGiftCatalogCategories : scumCatalogKinds;
|
||||
const filters = scumGiftCatalogCategories;
|
||||
const activeFilter = filters.some(([key]) => key === view.catalogKind) ? view.catalogKind : "all";
|
||||
const matches = catalogMatches(view.catalogSearch, activeFilter, target);
|
||||
return e("details", { className: "console-module scum-editor scum-catalog-picker", open: view.catalogOpen, onToggle: (event: InputEvent) => view.setCatalogOpen(detailOpen(event)) },
|
||||
e("summary", null, e("strong", null, target === "gift-items" ? "从 SCUM 目录选择礼包内容" : "从 SCUM 目录选择物品"), e("span", { className: "page-status" }, "图标 / 名称 / 代码")),
|
||||
e("summary", null, e("strong", null, target === "gift-items" ? "从 SCUM 目录选择礼包内容" : "从 SCUM 目录选择活动物品 / NPC"), e("span", { className: "page-status" }, "图标 / 名称 / 代码")),
|
||||
view.catalogOpen ? e("div", { className: "scum-catalog-picker-body" },
|
||||
e("input", { type: "search", value: view.catalogSearch, "aria-label": target === "gift-items" ? "搜索礼包目录" : "搜索生成物品", placeholder: "名称 / 代码,例如 Laika", onChange: (event: InputEvent) => view.setCatalogSearch(inputValue(event)) }),
|
||||
e("div", { className: "scum-catalog-kinds", role: "group", "aria-label": target === "gift-items" ? "礼包目录分类" : "目录类型" }, filters.map(([key, label]) => e("button", { key, type: "button", className: activeFilter === key ? "icon-command is-active" : "icon-command", "aria-pressed": activeFilter === key, onClick: () => view.setCatalogKind(key) }, label))),
|
||||
e("div", { className: "scum-catalog-grid" }, matches.length ? matches.map((entry) => { const icon = scumCatalogIconUrl(entry.icon); const displayName = scumCatalogDisplayName(entry); return e("button", { key: entry.code, type: "button", className: "scum-catalog-chip", title: `${displayName} · ${scumCatalogCategoryLabel(entry)} · ${entry.command}`, onClick: () => target === "gift-items" ? view.setGiftItems((items) => appendCatalogItem(items, entry.code)) : view.setProduceTradeGoodsId(entry.code) }, icon ? e("img", { className: "scum-catalog-icon", src: icon, alt: "" }) : e("span", { className: "scum-catalog-icon scum-catalog-icon-empty" }), e("span", { className: "scum-catalog-meta" }, e("strong", null, displayName), e("span", { className: "provider-id" }, `${scumCatalogCategoryLabel(entry)} · ${entry.code}`))); }) : e("p", { className: "page-status" }, "没有匹配的目录条目。"))
|
||||
e("input", { type: "search", value: view.catalogSearch, "aria-label": target === "gift-items" ? "搜索礼包目录" : "搜索活动物品目录", placeholder: "中文名 / 英文名 / 代码,例如 莱卡", onChange: (event: InputEvent) => view.setCatalogSearch(inputValue(event)) }),
|
||||
e("div", { className: "scum-catalog-kinds", role: "group", "aria-label": target === "gift-items" ? "礼包目录分类" : "活动目录分类" }, filters.map(([key, label]) => e("button", { key, type: "button", className: activeFilter === key ? "icon-command is-active" : "icon-command", "aria-pressed": activeFilter === key, onClick: () => view.setCatalogKind(key) }, label))),
|
||||
e("div", { className: "scum-catalog-grid" }, matches.length ? matches.map((entry) => { const icon = scumCatalogIconUrl(entry.icon); const displayName = scumCatalogDisplayName(entry); return e("button", { key: entry.code, type: "button", className: "scum-catalog-chip", title: `${displayName} · ${scumCatalogCategoryLabel(entry)} · ${entry.command}`, onClick: () => target === "gift-items" ? view.setGiftItems((items) => appendCatalogItem(items, entry.code)) : view.setEventItems((items) => appendCatalogItem(items, entry.code)) }, icon ? e("img", { className: "scum-catalog-icon", src: icon, alt: "" }) : e("span", { className: "scum-catalog-icon scum-catalog-icon-empty" }), e("span", { className: "scum-catalog-meta" }, e("strong", null, displayName), e("span", { className: "provider-id" }, `${scumCatalogCategoryLabel(entry)} · ${entry.code}`))); }) : e("p", { className: "page-status" }, "没有匹配的目录条目。"))
|
||||
) : null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,8 +53,12 @@
|
||||
"required": ["tradeGoodsId", "percent", "value", "r", "x", "y", "z"],
|
||||
"properties": {
|
||||
"tradeGoodsId": { "type": "string", "minLength": 1, "maxLength": 128 },
|
||||
"catalogCode": { "type": "string", "minLength": 1, "maxLength": 128 },
|
||||
"percent": { "type": "integer", "minimum": 0, "maximum": 100 },
|
||||
"probability": { "type": "integer", "minimum": 0, "maximum": 100 },
|
||||
"value": { "type": "integer", "minimum": 1 },
|
||||
"quantity": { "type": "integer", "minimum": 1 },
|
||||
"durability": { "type": "integer", "minimum": 0, "maximum": 100 },
|
||||
"r": { "type": "number", "minimum": 0 },
|
||||
"x": { "type": "number" },
|
||||
"y": { "type": "number" },
|
||||
|
||||
Reference in New Issue
Block a user