diff --git a/module.json b/module.json index ffdba4b..41bc731 100644 --- a/module.json +++ b/module.json @@ -2,7 +2,7 @@ "id": "scifi-deckplan-generator", "title": "Sci-Fi & Fantasy Map Generator", "description": "Procedurally generates sci-fi deck plans, fantasy dungeons, horror buildings, wilderness encounter maps, and tactical battlemaps as Foundry VTT scenes.", - "version": "0.9.2", + "version": "0.9.3", "compatibility": { "minimum": "13", "verified": "13.350", diff --git a/module/scifi-deckplan-generator.mjs b/module/scifi-deckplan-generator.mjs index dc5b170..e68a226 100644 --- a/module/scifi-deckplan-generator.mjs +++ b/module/scifi-deckplan-generator.mjs @@ -1014,18 +1014,19 @@ async function deleteGeneratedDocuments(scene) { return; } - const { wallIds, drawingIds, tileIds, lightIds, tokenIds, noteIds } = collectGeneratedSceneIds(scene); + const ids = collectGeneratedSceneIds(scene); + const total = ids.wallIds.length + ids.drawingIds.length + ids.tileIds.length + ids.lightIds.length + ids.tokenIds.length + ids.noteIds.length + ids.soundIds.length + ids.regionIds.length; - if (!wallIds.length && !drawingIds.length && !tileIds.length && !lightIds.length && !tokenIds.length && !noteIds.length) { + if (total === 0) { ui.notifications.info("No generated elements found in this scene."); return; } await Dialog.confirm({ title: "Delete Generated Elements", - content: `

Remove ${wallIds.length} walls, ${drawingIds.length} drawings, ${tileIds.length} tiles, ${lightIds.length} lights, ${tokenIds.length} encounter tokens, and ${noteIds.length} generated notes from ${escapeXml(scene.name)}?

`, + content: `

Remove ${ids.wallIds.length} walls, ${ids.drawingIds.length} drawings, ${ids.tileIds.length} tiles, ${ids.lightIds.length} lights, ${ids.tokenIds.length} encounter tokens, ${ids.noteIds.length} generated notes, ${ids.soundIds.length} ambient sounds, and ${ids.regionIds.length} regions from ${escapeXml(scene.name)}?

`, yes: async () => { - await deleteGeneratedDocumentsInternal(scene, { wallIds, drawingIds, tileIds, lightIds, tokenIds, noteIds }); + await deleteGeneratedDocumentsInternal(scene, ids); ui.notifications.info(`Deleted generated elements from ${scene.name}.`); } }); @@ -1094,7 +1095,9 @@ function collectGeneratedSceneIds(scene) { tileIds: scene.tiles.contents.filter((tile) => tile.flags?.[MODULE_ID]?.generatedTile).map((tile) => tile.id), lightIds: scene.lights.contents.filter((light) => light.flags?.[MODULE_ID]?.generatedLight).map((light) => light.id), tokenIds: scene.tokens.contents.filter((token) => token.flags?.[MODULE_ID]?.generatedToken).map((token) => token.id), - noteIds: scene.notes.contents.filter((note) => note.flags?.[MODULE_ID]?.generatedNote).map((note) => note.id) + noteIds: scene.notes.contents.filter((note) => note.flags?.[MODULE_ID]?.generatedNote).map((note) => note.id), + soundIds: scene.sounds.contents.filter((sound) => sound.flags?.[MODULE_ID]?.generatedSound).map((sound) => sound.id), + regionIds: scene.regions?.contents?.filter((region) => region.flags?.[MODULE_ID]?.generatedRegion)?.map((region) => region.id) ?? [] }; } @@ -1105,6 +1108,8 @@ async function deleteGeneratedDocumentsInternal(scene, ids = collectGeneratedSce if (ids.lightIds?.length) await scene.deleteEmbeddedDocuments("AmbientLight", ids.lightIds); if (ids.tokenIds?.length) await scene.deleteEmbeddedDocuments("Token", ids.tokenIds); if (ids.noteIds?.length) await scene.deleteEmbeddedDocuments("Note", ids.noteIds); + if (ids.soundIds?.length) await scene.deleteEmbeddedDocuments("AmbientSound", ids.soundIds); + if (ids.regionIds?.length) await scene.deleteEmbeddedDocuments("Region", ids.regionIds); } function normalizeFormState(raw = {}) { @@ -3197,6 +3202,11 @@ async function populateSceneWithDeck(scene, deckPlan, deck, formState, connector await scene.createEmbeddedDocuments("Tile", [withGeneratedTileFlag(gmOverlayTile)]); } + const lootTiles = buildLootTiles(deck, formState); + if (lootTiles.length) { + await scene.createEmbeddedDocuments("Tile", lootTiles.map((t) => withGeneratedTileFlag(t))); + } + const walls = deck.wallSegments.map((segment) => wallSegmentToDocument(segment, formState.gridSize)); if (walls.length) { await scene.createEmbeddedDocuments("Wall", walls.map((wall) => withGeneratedWallFlag(wall))); @@ -3222,6 +3232,11 @@ async function populateSceneWithDeck(scene, deckPlan, deck, formState, connector } } + const sounds = buildAmbientSounds(deck, formState); + if (sounds.length) { + await scene.createEmbeddedDocuments("AmbientSound", sounds.map((s) => withGeneratedSoundFlag(s))); + } + if (formState.seedEncounters) { const tokens = await buildEncounterTokens(deck, formState); if (tokens.length) { @@ -3238,7 +3253,10 @@ async function populateSceneWithDeck(scene, deckPlan, deck, formState, connector if (formState.placeRegions) { try { - const regions = buildRegionDocuments(deck, formState); + const regions = [ + ...buildRegionDocuments(deck, formState, deckPlan), + ...buildHazardRegions(deck, formState) + ]; if (regions.length) { await scene.createEmbeddedDocuments("Region", regions); } @@ -3262,7 +3280,7 @@ async function populateSceneWithDeck(scene, deckPlan, deck, formState, connector applyGmOverlayVisibility(scene); } -function buildRegionDocuments(deck, formState) { +function buildRegionDocuments(deck, formState, deckPlan = null) { const gs = formState.gridSize; const regions = []; for (const room of deck.rooms) { @@ -3285,6 +3303,64 @@ function buildRegionDocuments(deck, formState) { flags: { [MODULE_ID]: { generatedRegion: true, roomType: room.type } } }); } + + // Multi-Deck Elevator/Ladder/Stairs Teleporters (v13+) + if (deckPlan && deckPlan.decks.length > 1 && deck.deckConnectors?.length) { + for (const connector of deck.deckConnectors) { + const currentDeckIndex = deck.deckIndex; + let targetDeckIndex = null; + if (connector.type === "elevator") { + targetDeckIndex = currentDeckIndex < deckPlan.decks.length - 1 ? currentDeckIndex + 1 : currentDeckIndex - 1; + } else { + const labelUpper = String(connector.label || "").toUpperCase(); + if (labelUpper.includes("UP")) { + targetDeckIndex = currentDeckIndex + 1; + } else if (labelUpper.includes("DOWN")) { + targetDeckIndex = currentDeckIndex - 1; + } else { + targetDeckIndex = currentDeckIndex < deckPlan.decks.length - 1 ? currentDeckIndex + 1 : currentDeckIndex - 1; + } + } + + const targetDeck = deckPlan.decks[targetDeckIndex]; + if (targetDeck && targetDeck.sceneId) { + const targetConnector = targetDeck.deckConnectors?.find((tc) => tc.label === connector.label) ?? targetDeck.deckConnectors?.[0]; + if (targetConnector) { + const cx = connector.x * gs; + const cy = connector.y * gs; + const pts = [ + cx, cy, + cx + gs, cy, + cx + gs, cy + gs, + cx, cy + gs + ]; + + const targetX = Math.round(targetConnector.x * gs + gs / 2); + const targetY = Math.round(targetConnector.y * gs + gs / 2); + + regions.push({ + name: `Transit: ${connector.label}`, + color: "#00e5ff", + visibility: 0, + shapes: [{ type: "polygon", points: pts }], + behaviors: [{ + type: "teleportToken", + name: `Transit to ${targetDeck.deckName}`, + disabled: false, + destination: { + scene: targetDeck.sceneId, + x: targetX, + y: targetY, + elevation: 0 + } + }], + flags: { [MODULE_ID]: { generatedRegion: true, isTransit: true } } + }); + } + } + } + } + return regions; } @@ -5185,6 +5261,20 @@ function withGeneratedNoteFlag(note) { }; } +function withGeneratedSoundFlag(sound) { + return { + ...sound, + flags: { + ...(sound.flags ?? {}), + [MODULE_ID]: { + ...(sound.flags?.[MODULE_ID] ?? {}), + generatedSound: true + } + } + }; +} + + async function buildEncounterTokens(deck, formState) { const preset = resolveEncounterPreset(formState); const packActors = await getEncounterPackActors(formState.encounterPack); @@ -6531,6 +6621,82 @@ function buildMissionBriefContent(deckPlan, formState) { const intro = typeof introSource === "string" ? introSource : introSource?.[formState.theme] ?? "The following location has been mapped from the best data currently available. Treat all positions as approximate until confirmed on the ground."; + + if (chosenTemplate === "sla") { + const seedNum = hashSeed(deckPlan.seed); + const sectorCode = `SEC-${(seedNum % 900 + 100)}-${String.fromCharCode(65 + (seedNum % 26))}${String.fromCharCode(65 + ((seedNum >> 8) % 26))}`; + const threatLevel = { + light: { label: "GREEN", color: "#4cd964", desc: "MINIMAL RESISTANCE / LOW RISK" }, + standard: { label: "YELLOW", color: "#ffcc00", desc: "ACTIVE THREATS / CAUTION REQUIRED" }, + heavy: { label: "RED", color: "#ff3b30", desc: "WAR ZONE / IMMEDIATE DANGER" } + }[formState.encounterDensity] ?? { label: "AMBER", color: "#ff9500", desc: "UNSTABLE ENVIRONMENT" }; + + const payout = (15000 + (seedNum % 35000) + (deckPlan.decks.length * 10000)).toLocaleString(); + const deckLines = deckPlan.decks.map((deck) => ` +
+ ${escapeXml(deck.deckName)}
+ Status: ${deck.rooms.length} Compartments, ${deck.corridors.length} Transit Junctions
+ Threat Profile: ${escapeXml(summarizeDeckThreats(deck))} +
+ `).join(""); + + return ` +
+ +
+
+
SLA INDUSTRIES
+
BLUEPRINT NEWS (BPN) CONTRACT DIVISION
+
+
+ THREAT LEVEL: ${threatLevel.label} +
+
+ + +
+
+ CONTRACT REF: BPN-${sectorCode}-${deckPlan.seed.slice(-4).toUpperCase()} +
+
+ OPERATIONAL ZONE: ${sectorCode} +
+
+ ALLOTMENT REWARD: ${payout}c (Net) +
+
+ SPONSORING DEPT: Dept 7 (Enforcement) +
+
+ + +
+

1. Operational Mandate

+

+ ${escapeXml(intro)} Operatives must secure the designated sectors and neutralize any flagged threats. +

+
+ MANDATORY INSTRUCTION: ${escapeXml(briefSpecialInstruction(formState, chosenTemplate))} +
+
+ + +
+

2. Sector Analysis & High-Risk Zones

+
+ ${deckLines} +
+
+ + +
+ NOTICE TO OPERATIVES: Contract fulfillment requires 100% confirmation of hazard containment and threat elimination. Sponsoring department accepts no responsibility for operational casualty, psychological trauma, or equipment loss. Unauthorized media interviews, recordings, or telemetry leaks to civilian networks are severe breaches of corporate security and subject to immediate asset liquidation. +
// OWN THE CITY. DO THE JOB. //
+
+
+ `.replace(/\n\s+/g, ""); + } + const deckLines = deckPlan.decks.map((deck) => `
  • ${escapeXml(deck.deckName)}: ${deck.rooms.length} rooms, ${deck.corridors.length} corridors, ${escapeXml(summarizeDeckThreats(deck))}
  • `).join(""); return `

    ${escapeXml(deckPlan.shipName)} Mission Brief

    @@ -6673,3 +6839,170 @@ function hexToRgb(color) { } return null; } + +function buildAmbientSounds(deck, formState) { + const gs = formState.gridSize; + const sounds = []; + const theme = formState.theme; + + // 1. Theme-based base atmospheric hum for the whole scene (center-positioned) + const maxRadius = Math.max(deck.columns, deck.rows) * gs; + if (theme === "derelict") { + sounds.push({ + x: Math.round((deck.columns * gs) / 2), + y: Math.round((deck.rows * gs) / 2), + radius: maxRadius, + path: "modules/scifi-deckplan-generator/assets/sounds/derelict-groan.mp3", + repeat: true, + volume: 0.35, + type: "global", + easing: true + }); + } else if (theme === "alien-organic") { + sounds.push({ + x: Math.round((deck.columns * gs) / 2), + y: Math.round((deck.rows * gs) / 2), + radius: maxRadius, + path: "modules/scifi-deckplan-generator/assets/sounds/organic-pulse.mp3", + repeat: true, + volume: 0.4, + type: "global", + easing: true + }); + } else { + sounds.push({ + x: Math.round((deck.columns * gs) / 2), + y: Math.round((deck.rows * gs) / 2), + radius: maxRadius, + path: "modules/scifi-deckplan-generator/assets/sounds/ship-hum.mp3", + repeat: true, + volume: 0.3, + type: "global", + easing: true + }); + } + + // 2. Specific room ambient sounds (reactor cores, engineering, bridge) + for (const room of deck.rooms) { + const rx = Math.round((room.x + room.width / 2) * gs); + const ry = Math.round((room.y + room.height / 2) * gs); + const rRadius = Math.min(room.width, room.height) * gs * 1.5; + + if (room.type === "reactor" || room.type === "engineering") { + sounds.push({ + x: rx, + y: ry, + radius: rRadius, + path: "modules/scifi-deckplan-generator/assets/sounds/reactor-hum.mp3", + repeat: true, + volume: 0.5, + type: "local", + easing: true + }); + } else if (room.type === "bridge" || room.type === "comms") { + sounds.push({ + x: rx, + y: ry, + radius: rRadius, + path: "modules/scifi-deckplan-generator/assets/sounds/static-crackle.mp3", + repeat: true, + volume: 0.35, + type: "local", + easing: true + }); + } + } + + return sounds; +} + +function buildLootTiles(deck, formState) { + const gs = formState.gridSize; + const tiles = []; + + for (const room of deck.rooms) { + // Cargo bays and storage rooms get crates/lockers + if (room.type === "storage" || room.type === "cargo") { + const crateX = Math.round((room.x + 1) * gs); + const crateY = Math.round((room.y + 1) * gs); + tiles.push({ + x: crateX, + y: crateY, + width: gs, + height: gs, + texture: { src: "icons/containers/boxes/box-metal-grey.webp" }, + locked: false, + flags: { + [MODULE_ID]: { + generatedTile: true, + lootType: "crate", + roomName: room.label + } + } + }); + } + // Bridge, Comms, and Research Labs get computer terminals + else if (room.type === "bridge" || room.type === "comms" || room.type === "lab") { + const termX = Math.round((room.x + room.width - 2) * gs); + const termY = Math.round((room.y + 1) * gs); + tiles.push({ + x: termX, + y: termY, + width: gs, + height: gs, + texture: { src: "icons/commodities/tech/sensor-blue.webp" }, + locked: false, + flags: { + [MODULE_ID]: { + generatedTile: true, + lootType: "terminal", + roomName: room.label + } + } + }); + } + } + return tiles; +} + +function buildHazardRegions(deck, formState) { + const gs = formState.gridSize; + const regions = []; + const theme = formState.theme; + + if (!["derelict", "sewer", "derelict-house"].includes(theme)) return []; + + for (const room of deck.rooms) { + if (room.type === "storage" || room.type === "maintenance" || room.type === "reactor") { + const hx = Math.round((room.x + 1) * gs); + const hy = Math.round((room.y + 1) * gs); + const pts = [ + hx, hy, + hx + gs * 2, hy, + hx + gs * 2, hy + gs * 2, + hx, hy + gs * 2 + ]; + + const hazardType = theme === "sewer" ? "Sewage Outflow" : "Acidic Leak"; + const hazardColor = theme === "sewer" ? "#5a4525" : "#00ff33"; // Green glow for acid, brown for sewage + + regions.push({ + name: `Hazard: ${hazardType}`, + color: hazardColor, + visibility: 0, + shapes: [{ type: "polygon", points: pts }], + behaviors: [ + { + type: "adjustDarknessLevel", + name: "Hazard Gloom", + disabled: false, + darkness: 0.3 // v13 adjusts local darkness inside the region! + } + ], + flags: { [MODULE_ID]: { generatedRegion: true, isHazard: true, hazardType } } + }); + } + } + return regions; +} +