diff --git a/Assets/Art/Materials/Creatures/M_IceCrystalGolem.mat b/Assets/Art/Materials/Creatures/M_IceCrystalGolem.mat index e2fdd1908..78300ab56 100644 --- a/Assets/Art/Materials/Creatures/M_IceCrystalGolem.mat +++ b/Assets/Art/Materials/Creatures/M_IceCrystalGolem.mat @@ -25,6 +25,7 @@ Material: m_Parent: {fileID: 0} m_ModifiedSerializedProperties: 0 m_ValidKeywords: + - _EMISSION - _METALLICSPECGLOSSMAP - _NORMALMAP m_InvalidKeywords: [] diff --git a/Assets/Art/Materials/ManafallShoreCaustics.mat b/Assets/Art/Materials/ManafallShoreCaustics.mat index 8edfeb692..1a06b8736 100644 --- a/Assets/Art/Materials/ManafallShoreCaustics.mat +++ b/Assets/Art/Materials/ManafallShoreCaustics.mat @@ -24,7 +24,10 @@ Material: serializedVersion: 3 m_TexEnvs: [] m_Ints: [] - m_Floats: [] + m_Floats: + - _CausticRev: 6 + - _CausticScale: 4.6 + - _CausticSpeed: 1.05 m_Colors: [] m_BuildTextureStacks: [] m_AllowLocking: 1 diff --git a/Assets/Art/Materials/Manafall_Water_Surface.mat b/Assets/Art/Materials/Manafall_Water_Surface.mat index 8cb6e45b1..da5023421 100644 --- a/Assets/Art/Materials/Manafall_Water_Surface.mat +++ b/Assets/Art/Materials/Manafall_Water_Surface.mat @@ -46,8 +46,8 @@ Material: - _Ambient: 0.18 - _BeachContactFoamBoost: 1.25 - _BodyDiffuseFlatten: 0 - - _CausticScale: 0.82 - - _CausticStrength: 0.34 + - _CausticScale: 1.05 + - _CausticStrength: 0.52 - _CoastalShimmerStrength: 0.78 - _CrestFoamEdgeSharpness: 1.55 - _CrestFoamHeightThreshold: 0.42 diff --git a/Assets/Data/Creatures/NightlingLootTable.asset b/Assets/Data/Creatures/NightlingLootTable.asset index 16befd795..0e019ee63 100644 --- a/Assets/Data/Creatures/NightlingLootTable.asset +++ b/Assets/Data/Creatures/NightlingLootTable.asset @@ -37,6 +37,10 @@ MonoBehaviour: weight: 3 minAmount: 1 maxAmount: 1 + signatureRareItem: {fileID: 11400000, guid: b7e4c91a2d8f4e06a5c3d9f01e82b4a7, type: 2} + signatureRareChance: 0.1 + signatureRareMinAmount: 1 + signatureRareMaxAmount: 1 itemDatabase: {fileID: 11400000, guid: e8f7a6b5c4d3928170615240abcdef01, type: 2} includeAllNonRareItems: 1 genericNonRareWeight: 1 diff --git a/Assets/Editor/.manafall_player_chat_setup_done b/Assets/Editor/.manafall_player_chat_setup_done index a9fedd8f0..da98b2e08 100644 --- a/Assets/Editor/.manafall_player_chat_setup_done +++ b/Assets/Editor/.manafall_player_chat_setup_done @@ -1,2 +1,2 @@ -2026-10-15T16:57:07.8254215-05:00 +2026-10-19T20:46:43.5656378-05:00 OK diff --git a/Assets/Editor/WildlifeSpawnTableSetupEditor.cs b/Assets/Editor/WildlifeSpawnTableSetupEditor.cs index d6176b8ab..7b9aa0085 100644 --- a/Assets/Editor/WildlifeSpawnTableSetupEditor.cs +++ b/Assets/Editor/WildlifeSpawnTableSetupEditor.cs @@ -169,8 +169,9 @@ public static class WildlifeSpawnTableSetupEditor WildlifeBiomeMask.None, 0f, 0, 0, 1, 1, 0f, true, true, 300f, 600f), Row("cave_worm", "Cave Worm", "Cave Worm.prefab", WildlifeSpawnMode.OpenWorld, WildlifeSpawnRole.Predator, WildlifeBiomeMask.Mountain | WildlifeBiomeMask.Forest, 0.05f, 2, 2, 1, 1, 0f, false, true, 720f, 1400f), + // ~3× rarer than prior 0.18/3: weight 0.06, single alive, longer respawn. Row("nightling", "Nightling", "Nightling.prefab", WildlifeSpawnMode.OpenWorld, WildlifeSpawnRole.Predator, - WildlifeBiomeMask.Forest | WildlifeBiomeMask.Swamp, 0.18f, 3, 1, 1, 1, 0f, false, true, 480f, 900f), + WildlifeBiomeMask.Forest | WildlifeBiomeMask.Swamp, 0.06f, 1, 1, 1, 1, 0f, false, true, 1440f, 2700f), // Visual removed (Capcom Pteranodon). Weight 0 until a replacement mesh is wired. Row("vikra", "Vikra", "Vikra.prefab", WildlifeSpawnMode.OpenWorld, WildlifeSpawnRole.Predator, WildlifeBiomeMask.Mountain | WildlifeBiomeMask.Forest | WildlifeBiomeMask.Grassland, 0f, 0, 1, 1, 1, 0f, true, true, 480f, 900f), diff --git a/Assets/GameObjects/Prefabs/Player.prefab b/Assets/GameObjects/Prefabs/Player.prefab index 532408812..7baf68a57 100644 --- a/Assets/GameObjects/Prefabs/Player.prefab +++ b/Assets/GameObjects/Prefabs/Player.prefab @@ -2303,6 +2303,20 @@ MonoBehaviour: bobSmoothTime: 0.38 downhillBobCadenceScale: 0.5 downhillBobRockScale: 0.38 + walkBobRate: 1.8 + walkBobVerticalAmp: 0.022 + walkBobSideAmp: 0.012 + walkBobRollDegrees: 0.55 + runBobRate: 1.4 + runBobVerticalAmp: 0.05 + runBobSideAmp: 0.02 + runBobRollDegrees: 0.85 + crouchBobRate: 1.1 + crouchBobVerticalAmp: 0.009 + crouchBobSideAmp: 0.006 + crouchBobRollDegrees: 0.25 + bobEaseTime: 0.16 + bobGaitBlendSpeed: 5 idleMoveThreshold: 0.06 idleBreathFreq: 0.33 idlePitchDegrees: 0.45 @@ -2585,7 +2599,7 @@ MonoBehaviour: lowVitalsRecoveryHoldSeconds: 0.12 lowVitalsRecoveryDecayPerSecond: 5 lowVitalsAudioFadeOutPerSecond: 8 - rowingDrainPerSecond: 0.675 + rowingDrainPerSecond: 0.135 --- !u!114 &-8877665544332211993 MonoBehaviour: m_ObjectHideFlags: 0 @@ -2621,6 +2635,8 @@ MonoBehaviour: inWaterGravityMultiplier: 0.035 buoyancyUpSpeed: 2.4 swimPaddleUpSpeed: 5.5 + swimPaddleDownSpeed: 4.8 + idleSinkSpeed: 1.35 swimSpeedSubmersionExponent: 1.28 swimSpeedMinSubmergedFraction: 0.07 buoyancySubmersionExponent: 1.15 @@ -2679,6 +2695,7 @@ MonoBehaviour: swimStrokePitchAtMaxSpeed: {x: 1.04, y: 1.22} allowOfflineDevControl: 1 logAquaticMovementDebug: 0 + logUnderwaterCameraDebug: 1 --- !u!114 &-8877665544332211991 MonoBehaviour: m_ObjectHideFlags: 0 @@ -2691,8 +2708,8 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: b4e7f2a91c3d46508e9f1a2b3c4d5e6f, type: 3} m_Name: m_EditorClassIdentifier: Assembly-CSharp::WaterRippleEmitter - radius: 6 - strength: 0.52 + radius: 4.5 + strength: 0.92 worldOffset: {x: 0, y: 0, z: 0} aquaticController: {fileID: -8877665544332211993} belowSeaSurfaceMaxDepth: 4 @@ -2703,16 +2720,16 @@ MonoBehaviour: highPriorityWake: 1 localWakeOffsets: [] enableVelocityWake: 1 - minSpeedForWake: 0.2 - speedForFullWake: 6 - minStrengthFromSpeed: 0.22 - maxStrengthFromSpeed: 1.15 - wakeStampSpacing: 2 - maxWakeStamps: 5 - wakeStampStrengthMul: 0.78 - wakeRadiusMul: 0.88 + minSpeedForWake: 0.45 + speedForFullWake: 2.4 + minStrengthFromSpeed: 0.62 + maxStrengthFromSpeed: 1.4 + wakeStampSpacing: 1.45 + maxWakeStamps: 8 + wakeStampStrengthMul: 1.05 + wakeRadiusMul: 1.55 wakeProfile: {fileID: 0} - kelvinSpreadAngleDegrees: 22 + kelvinSpreadAngleDegrees: 19 useKelvinWakeStamps: 1 surfaceSkimStrength: 0.35 fullSubmersionDepth: 1.2 @@ -4483,9 +4500,6 @@ MonoBehaviour: syncDirection: 0 syncMode: 0 syncInterval: 0 - syncClassId: 255 - syncHasClass: 0 - syncAwaitingWorldEntry: 1 --- !u!114 &166600001122334671 MonoBehaviour: m_ObjectHideFlags: 0 @@ -4501,7 +4515,6 @@ MonoBehaviour: syncDirection: 0 syncMode: 0 syncInterval: 0 - loadoutPublished: 0 --- !u!114 &166600001122334801 MonoBehaviour: m_ObjectHideFlags: 0 diff --git a/Assets/Jinxish/Drag & Drop Inventory & Hotbar Framework/ScriptableObjects/Items/Brewer's Cask.asset b/Assets/Jinxish/Drag & Drop Inventory & Hotbar Framework/ScriptableObjects/Items/Brewer's Cask.asset index 035e4addb..095baea20 100644 --- a/Assets/Jinxish/Drag & Drop Inventory & Hotbar Framework/ScriptableObjects/Items/Brewer's Cask.asset +++ b/Assets/Jinxish/Drag & Drop Inventory & Hotbar Framework/ScriptableObjects/Items/Brewer's Cask.asset @@ -12,13 +12,13 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: de710cc37b178ac41bdc90fbef7a5c3b, type: 3} m_Name: Brewer's Cask m_EditorClassIdentifier: Assembly-CSharp::InventoryFramework.Item - id: 20896 + id: 23006 itemName: Brewers Cask description: A placeable brewing station. icon: {fileID: 0} maxStack: 1 model: {fileID: 5447498083774230809, guid: 2b56106bf121367478c84400844373a5, type: 3} - carryWeight: 25 + carryWeight: -1 isIllegalItem: 0 itemType: 0 consumable: {fileID: 0} diff --git a/Assets/Jinxish/Drag & Drop Inventory & Hotbar Framework/Scripts/Player.cs b/Assets/Jinxish/Drag & Drop Inventory & Hotbar Framework/Scripts/Player.cs index f4f9a227d..7eef553e3 100644 --- a/Assets/Jinxish/Drag & Drop Inventory & Hotbar Framework/Scripts/Player.cs +++ b/Assets/Jinxish/Drag & Drop Inventory & Hotbar Framework/Scripts/Player.cs @@ -72,6 +72,19 @@ public class Player : NetworkBehaviour return; } + // List view is a sibling of the bag — if the bag already closed, Esc/Tab must still clear it. + if (!inventoryOpen && (cancelPressed || inventoryPressed) + && InventoryFramework.InventoryListView.AnyChromeBlockingGameplay()) + { + InventoryFramework.InventoryListView.DismissOrphanChrome(); + CloseOpenChestHudIfAny(); + ItemTooltip.DismissAll(); + InputStateManager.RefreshFromOpenPanels(); + if (!InputStateManager.IsUIMode) + InputStateManager.SetGameplayMode(); + return; + } + // Inventory opens bag (blocked while a hotbar instrument is equipped — E is a lute note key). if (inventoryPressed && !instrumentBlocksUse) { @@ -119,6 +132,12 @@ public class Player : NetworkBehaviour private void SetInventoryOpen(bool open) { + if (open) + { + // List mode: hide the slot grid before the bag activates (no old-grid flash). + InventoryFramework.InventoryListView.PrepareBagOpenVisuals(); + } + if (inventory != null) inventory.SetActive(open); @@ -135,6 +154,11 @@ public class Player : NetworkBehaviour return; } + // ChestHud / list chrome are siblings of the bag under the Inventory canvas — closing + // the bag alone used to leave loot or an empty list stuck on screen with no Esc path. + CloseOpenChestHudIfAny(); + InventoryFramework.InventoryListView.DismissOrphanChrome(); + ItemTooltip.DismissAll(); InputStateManager.RefreshFromOpenPanels(); // Inventory tabs LateUpdate may still hide Skills/Crafting overlays after this; @@ -144,6 +168,14 @@ public class Player : NetworkBehaviour FindFirstObjectByType()?.RefreshTimeOfDayHudForPause(); } + private void CloseOpenChestHudIfAny() + { + ChestHudController chestHud = GetComponentInChildren(true); + // Always dismiss — Close is safe when already shut, and catches orphan panels + // after a corpse despawn cleared boundChest while the loot panel stayed up. + chestHud?.Close(); + } + private static void CloseOpenMarketIfAny() { MarketHudController market = MarketHudController.FindOpenLocal(); diff --git a/Assets/Resources/ManafallAeroVolumetricFog.mat b/Assets/Resources/ManafallAeroVolumetricFog.mat index 1fa04a8f5..8bebc4963 100644 --- a/Assets/Resources/ManafallAeroVolumetricFog.mat +++ b/Assets/Resources/ManafallAeroVolumetricFog.mat @@ -209,7 +209,7 @@ Material: - _Main_Light_Self_Shadow_Remap_Max: 0.8 - _Main_Light_Self_Shadow_Remap_Min: 0.2 - _Main_Light_Self_Shadow_Steps: 4 - - _Max_Distance: 140.15604 + - _Max_Distance: 160 - _Metallic: 0 - _OUTPUT: 0 - _OcclusionStrength: 1 @@ -242,7 +242,7 @@ Material: - _BaseColor: {r: 1, g: 1, b: 1, a: 1} - _Bottom_Colour: {r: 1, g: 1, b: 1, a: 1} - _Color: {r: 1, g: 1, b: 1, a: 1} - - _Colour: {r: 0.7507201, g: 0.81142175, b: 0.86014926, a: 1} + - _Colour: {r: 0.78, g: 0.84, b: 0.9, a: 1} - _EmissionColor: {r: 0, g: 0, b: 0, a: 1} - _Height_Gradient_Bottom_Colour_Tint: {r: 1, g: 1, b: 1, a: 1} - _Height_Gradient_Colour_Bottom: {r: 1, g: 1, b: 1, a: 1} diff --git a/Assets/Resources/ManafallWaterQuality_Laptop.asset b/Assets/Resources/ManafallWaterQuality_Laptop.asset index fdbcf0388..ecfaf680c 100644 --- a/Assets/Resources/ManafallWaterQuality_Laptop.asset +++ b/Assets/Resources/ManafallWaterQuality_Laptop.asset @@ -18,11 +18,11 @@ MonoBehaviour: rippleFollowCameraCenter: 1 rippleFarBlurPass: 1 rippleNormalOnlyFarWake: 1 - rippleDecayPerFrame: 0.955 + rippleDecayPerFrame: 0.972 objectWakeStampBudgetFraction: 0.72 - maxEmitterWakeStamps: 7 - rippleFieldHeightScale: 0.58 - rippleFieldNormalScale: 0.5 + maxEmitterWakeStamps: 8 + rippleFieldHeightScale: 0.85 + rippleFieldNormalScale: 0.7 refractFullQuality: 1 refractionChromaticPx: 1.6 refractionBlur: 2.1 diff --git a/Assets/Resources/ManafallWaterQuality_PC.asset b/Assets/Resources/ManafallWaterQuality_PC.asset index 49806a8cd..825992d85 100644 --- a/Assets/Resources/ManafallWaterQuality_PC.asset +++ b/Assets/Resources/ManafallWaterQuality_PC.asset @@ -18,11 +18,11 @@ MonoBehaviour: rippleFollowCameraCenter: 1 rippleFarBlurPass: 1 rippleNormalOnlyFarWake: 1 - rippleDecayPerFrame: 0.955 + rippleDecayPerFrame: 0.972 objectWakeStampBudgetFraction: 0.72 - maxEmitterWakeStamps: 7 - rippleFieldHeightScale: 0.72 - rippleFieldNormalScale: 0.62 + maxEmitterWakeStamps: 10 + rippleFieldHeightScale: 0.95 + rippleFieldNormalScale: 0.78 refractFullQuality: 1 refractionChromaticPx: 2.4 refractionBlur: 2.1 diff --git a/Assets/Resources/WildlifeSpawnTable.asset b/Assets/Resources/WildlifeSpawnTable.asset index ccd5a2d5f..13cb10ac3 100644 --- a/Assets/Resources/WildlifeSpawnTable.asset +++ b/Assets/Resources/WildlifeSpawnTable.asset @@ -101,16 +101,16 @@ MonoBehaviour: spawnMode: 0 role: 2 biomes: 130 - weight: 0.18 - maxAliveGlobal: 3 + weight: 0.06 + maxAliveGlobal: 1 maxAlivePerCell: 1 minPackSize: 1 maxPackSize: 1 packSpreadMeters: 0 spawnAtDay: 0 spawnAtNight: 1 - respawnMinSeconds: 480 - respawnMaxSeconds: 900 + respawnMinSeconds: 1440 + respawnMaxSeconds: 2700 allowWaterSpawn: 0 minWaterDepthMeters: 0 - spawnId: vikra diff --git a/Assets/ScriptableObjects/Combat/Shovel_Basic.asset b/Assets/ScriptableObjects/Combat/Shovel_Basic.asset index 914dd9634..074aa30ca 100644 --- a/Assets/ScriptableObjects/Combat/Shovel_Basic.asset +++ b/Assets/ScriptableObjects/Combat/Shovel_Basic.asset @@ -14,8 +14,9 @@ MonoBehaviour: m_EditorClassIdentifier: maxRange: 4 editRadius: 0.45 - cooldownSeconds: 0.4 - maxEditsPerSecond: 8 + digDurationSeconds: 2.8 + staminaDrainPerSecond: 4 + maxEditsPerSecond: 2 addSolid: 0 materialId: 1 digPower: 1 diff --git a/Assets/ScriptableObjects/Items/Dusk-Fee Token.asset b/Assets/ScriptableObjects/Items/Dusk-Fee Token.asset new file mode 100644 index 000000000..fa5d8f9cb --- /dev/null +++ b/Assets/ScriptableObjects/Items/Dusk-Fee Token.asset @@ -0,0 +1,36 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: de710cc37b178ac41bdc90fbef7a5c3b, type: 3} + m_Name: Dusk-Fee Token + m_EditorClassIdentifier: + id: 522 + itemName: Dusk-Fee Token + description: A dull disc the Nightling court leaves when a dusk-fee is collected. + Still warm after sundown. Nightling-only find. + icon: {fileID: 21300000, guid: 879ef87f9d5c4778a9ce85ff05e32c8b, type: 3} + maxStack: 5 + model: {fileID: 0} + carryWeight: 0.08 + isIllegalItem: 0 + itemType: 0 + consumable: {fileID: 0} + meleeDefinition: {fileID: 0} + bowDefinition: {fileID: 0} + shovelDefinition: {fileID: 0} + hoeDefinition: {fileID: 0} + farmingSeedDefinition: {fileID: 0} + isWateringTool: 0 + placeableDefinition: {fileID: 0} + instrument: {fileID: 0} + noteDefinition: {fileID: 0} + readableBook: {fileID: 0} + sealclashCard: {fileID: 0} diff --git a/Assets/ScriptableObjects/Items/Dusk-Fee Token.asset.meta b/Assets/ScriptableObjects/Items/Dusk-Fee Token.asset.meta new file mode 100644 index 000000000..429fe6bc7 --- /dev/null +++ b/Assets/ScriptableObjects/Items/Dusk-Fee Token.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b7e4c91a2d8f4e06a5c3d9f01e82b4a7 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ScriptableObjects/Items/ManafallItemDatabase.asset b/Assets/ScriptableObjects/Items/ManafallItemDatabase.asset index 3322dcd22..70fcfbeac 100644 --- a/Assets/ScriptableObjects/Items/ManafallItemDatabase.asset +++ b/Assets/ScriptableObjects/Items/ManafallItemDatabase.asset @@ -40,6 +40,7 @@ MonoBehaviour: - {fileID: 11400000, guid: f1a2b3c4d5e647889900112233445568, type: 2} - {fileID: 11400000, guid: aabbcc11dd22ee334455667788990a01, type: 2} - {fileID: 11400000, guid: aabbcc11dd22ee334455667788990b01, type: 2} + - {fileID: 11400000, guid: b7e4c91a2d8f4e06a5c3d9f01e82b4a7, type: 2} - {fileID: 11400000, guid: 6ac28aefdecdd5845a2932e6f80c8749, type: 2} - {fileID: 11400000, guid: 5b5ea7d34b869fe46aaff494d99fd570, type: 2} - {fileID: 11400000, guid: a0bc3a264faacdc43ad80c400be18bc9, type: 2} diff --git a/Assets/Scripts/Alchemy/AlchemyCatalog.cs b/Assets/Scripts/Alchemy/AlchemyCatalog.cs index 9d200df6b..0828f85e6 100644 --- a/Assets/Scripts/Alchemy/AlchemyCatalog.cs +++ b/Assets/Scripts/Alchemy/AlchemyCatalog.cs @@ -147,6 +147,21 @@ public static class AlchemyCatalog new("grave_moss", "Grave Moss", false, E("Resist Disease", "Detect Undead", "Drain Personality", "Fortify Willpower"), P(AlchemyProperty.Death, AlchemyProperty.Spirit, AlchemyProperty.Stability)), new("azure_coral", "Azure Coral", false, E("Water Breathing", "Resist Shock", "Reflect", "Slow Movement"), P(AlchemyProperty.Water, AlchemyProperty.Shock, AlchemyProperty.Stability)), new("goldcap_fungus", "Goldcap Fungus", false, E("Restore Health", "Restore Fatigue", "Fortify Luck", "Cure Disease"), P(AlchemyProperty.Life, AlchemyProperty.Stability, AlchemyProperty.Earth)), + + // Disease cure signature herbs (mild = common, severe = Risky/rare). + new("rawcap_bitterleaf", "Rawcap Bitterleaf", false, E("Cure Disease", "Restore Health", "Drain Fatigue", "Thirst"), P(AlchemyProperty.Life, AlchemyProperty.Earth, AlchemyProperty.Stability)), + new("coughbane_reed", "Coughbane Reed", false, E("Cure Disease", "Restore Fatigue", "Water Breathing", "Calm"), P(AlchemyProperty.Life, AlchemyProperty.Water, AlchemyProperty.Air)), + new("frogitch_salve_herb", "Frogitch Salve Herb", false, E("Cure Disease", "Poison Resistance", "Restore Health", "Calm"), P(AlchemyProperty.Life, AlchemyProperty.Water, AlchemyProperty.Stability)), + new("feverbreak_root", "Feverbreak Root", false, E("Cure Disease", "Fire Resistance", "Restore Health", "Warmth"), P(AlchemyProperty.Life, AlchemyProperty.Fire, AlchemyProperty.Stability)), + new("mirefoot_scrubmoss", "Mirefoot Scrubmoss", false, E("Cure Disease", "Water Walking", "Speed", "Restore Fatigue"), P(AlchemyProperty.Life, AlchemyProperty.Water, AlchemyProperty.Earth)), + new("blackwater_char", "Blackwater Char", false, E("Cure Disease", "Restore Health", "Thirst", "Fortify Endurance"), P(AlchemyProperty.Life, AlchemyProperty.Water, AlchemyProperty.Earth)), + new("leechbane_thorn", "Leechbane Thorn", false, E("Cure Disease", "Restore Health", "Fortify Attack", "Drain Personality"), P(AlchemyProperty.Life, AlchemyProperty.Fire, AlchemyProperty.Corruption)), + new("ague_ember_seed", "Ague Ember Seed", false, E("Cure Disease", "Warmth", "Resist Frost", "Restore Magicka"), P(AlchemyProperty.Fire, AlchemyProperty.Life, AlchemyProperty.Frost)), + new("rotveil_spore", "Rotveil Spore", true, E("Cure Disease", "Poison Resistance", "Drain Health", "Detect Undead"), P(AlchemyProperty.Death, AlchemyProperty.Life, AlchemyProperty.Corruption), "Spores cling to the lungs if mishandled."), + new("poxiron_flake", "Poxiron Flake", true, E("Cure Disease", "Light Aura", "Fire Resistance", "Drain Health"), P(AlchemyProperty.Fire, AlchemyProperty.Life, AlchemyProperty.Arcane), "Flakes sting the skin like hot iron."), + new("gravedamp_lichen", "Gravedamp Lichen", true, E("Cure Disease", "Detect Undead", "Drain Personality", "Restore Magicka"), P(AlchemyProperty.Death, AlchemyProperty.Spirit, AlchemyProperty.Life), "Smells of wet tombs."), + new("hollow_lungwort", "Hollow Lungwort", true, E("Cure Disease", "Restore Fatigue", "Water Breathing", "Drain Stamina"), P(AlchemyProperty.Air, AlchemyProperty.Life, AlchemyProperty.Water), "The leaf whistled when broken — empty air inside."), + new("aetherwing_membrane", "Aetherwing Membrane", true, E("True Levitation", "Feather", "Reflect Magic", "Slow"), P(AlchemyProperty.Air, AlchemyProperty.Arcane, AlchemyProperty.Spirit), "The brew strains against gravity and wants to drift upward."), // Vikra death loot — hollow skybone steadies mobility brews and hardens stone draughts. new("vikra_bone_shard", "Vikra Bone Shard", false, E("Feather", "Fortify Endurance", "Slowfall", "Weakness to Blunt"), P(AlchemyProperty.Air, AlchemyProperty.Earth, AlchemyProperty.Weight)), diff --git a/Assets/Scripts/Alchemy/AlchemyPotionRuntime.cs b/Assets/Scripts/Alchemy/AlchemyPotionRuntime.cs index c6b4ca65e..4ff37ef7f 100644 --- a/Assets/Scripts/Alchemy/AlchemyPotionRuntime.cs +++ b/Assets/Scripts/Alchemy/AlchemyPotionRuntime.cs @@ -135,14 +135,8 @@ public static class AlchemyPotionRuntime ApplyImmediate(potion.Secondary, Mathf.Max(1, potion.PotencyTier - 4), health, stamina); ApplyImmediate(potion.Tertiary, Mathf.Max(1, potion.PotencyTier - 8), health, stamina); - if (HasFamily(potion, AlchemyPotionFamily.Healing) || HasFamily(potion, AlchemyPotionFamily.VenomWard)) - { - PlayerMushroomSicknessRuntime sickness = - player.GetComponent() - ?? player.GetComponentInParent() - ?? player.GetComponentInChildren(true); - sickness?.TryCureFromPotionOrHealer(); - } + // Mundane diseases require their matching signature cure potion (or a paid healer). + // Generic Healing / VenomWard no longer auto-clear disease. if ((potion.Flags & AlchemyBrewFlags.Volatile) != 0 && health != null && potion.InstabilityTier >= 10) { @@ -247,15 +241,6 @@ public static class AlchemyPotionRuntime ApplyImmediateScaled(potion.Secondary, Mathf.Max(1, potion.PotencyTier - 4), clampedScale, health, stamina, applyHealthAuthority); ApplyImmediateScaled(potion.Tertiary, Mathf.Max(1, potion.PotencyTier - 8), clampedScale, health, stamina, applyHealthAuthority); - if (HasFamily(potion, AlchemyPotionFamily.Healing) || HasFamily(potion, AlchemyPotionFamily.VenomWard)) - { - PlayerMushroomSicknessRuntime sickness = - player.GetComponent() - ?? player.GetComponentInParent() - ?? player.GetComponentInChildren(true); - sickness?.TryCureFromPotionOrHealer(); - } - AlchemyDecodedPotion scaledPotion = Scale(potion, clampedScale); if (scaledPotion.Primary != AlchemyPotionFamily.None || scaledPotion.Secondary != AlchemyPotionFamily.None || diff --git a/Assets/Scripts/Alchemy/AlchemySignatureBrewCatalog.cs b/Assets/Scripts/Alchemy/AlchemySignatureBrewCatalog.cs index 7c3056ead..b8a85ec2b 100644 --- a/Assets/Scripts/Alchemy/AlchemySignatureBrewCatalog.cs +++ b/Assets/Scripts/Alchemy/AlchemySignatureBrewCatalog.cs @@ -4,12 +4,12 @@ using System.Text; using UnityEngine; /// -/// Fifty specialty drinkable potions matched by ingredient signatures (parallel to hostile catalog). -/// Encoded via . +/// Specialty drinkable potions matched by ingredient signatures (parallel to hostile catalog), +/// including disease cures 52–63. Encoded via . /// public static class AlchemySignatureBrewCatalog { - public const int RecipeCount = 50; + public const int RecipeCount = 63; public readonly struct Recipe { @@ -353,6 +353,68 @@ public static class AlchemySignatureBrewCatalog AlchemySignatureEffectKind.Clearwake, "Clears underwater murk — stronger brews see farther through the depths.", AlchemyPotionFamily.Tide, AlchemyPotionFamily.Spirit), + + // Disease cures (52–63) — one potion per DiseaseId + new(52, "Rawcap Purge", "rawcap_bitterleaf", + new[] { "ash_fern", "marsh_salt", "goldcap_fungus" }, 1, + AlchemySignatureEffectKind.CureRawcapGut, + "Cures Rawcap Gut from eating raw mushrooms.", + AlchemyPotionFamily.Healing, AlchemyPotionFamily.VenomWard), + new(53, "Marshclear Cordial", "coughbane_reed", + new[] { "marsh_salt", "ash_fern", "silver_reed" }, 1, + AlchemySignatureEffectKind.CureMarshCough, + "Cures Marsh Cough.", + AlchemyPotionFamily.Healing, AlchemyPotionFamily.VenomWard), + new(54, "Itchban Salve-Draught", "frogitch_salve_herb", + new[] { "marsh_salt", "mire_bloom", "ash_fern" }, 1, + AlchemySignatureEffectKind.CureFrogitch, + "Cures Frogitch.", + AlchemyPotionFamily.Healing, AlchemyPotionFamily.VenomWard), + new(55, "Woundfever Breaker", "feverbreak_root", + new[] { "ash_fern", "bitterroot", "goldcap_fungus" }, 2, + AlchemySignatureEffectKind.CureWoundFever, + "Cures Wound Fever.", + AlchemyPotionFamily.Healing, AlchemyPotionFamily.VenomWard), + new(56, "Mirefoot Wash", "mirefoot_scrubmoss", + new[] { "marsh_salt", "hollowroot", "mire_bloom" }, 2, + AlchemySignatureEffectKind.CureMirefoot, + "Cures Mirefoot.", + AlchemyPotionFamily.Healing, AlchemyPotionFamily.VenomWard), + new(57, "Blackwater Binder", "blackwater_char", + new[] { "marsh_salt", "goldcap_fungus", "deep_moss" }, 2, + AlchemySignatureEffectKind.CureBlackwaterBelly, + "Cures Blackwater Belly.", + AlchemyPotionFamily.Healing, AlchemyPotionFamily.VenomWard), + new(58, "Leechblood Draw", "leechbane_thorn", + new[] { "ash_fern", "redthorn_berry", "bitterroot" }, 2, + AlchemySignatureEffectKind.CureLeechblood, + "Cures Leechblood.", + AlchemyPotionFamily.Healing, AlchemyPotionFamily.VenomWard), + new(59, "Bonechill Warmth", "ague_ember_seed", + new[] { "ember_moss", "snowbell_flower", "ash_fern" }, 2, + AlchemySignatureEffectKind.CureBoneChillAgue, + "Cures Bone Chill Ague.", + AlchemyPotionFamily.Healing, AlchemyPotionFamily.VenomWard), + new(60, "Rotveil Antitoxin", "rotveil_spore", + new[] { "grave_moss", "mire_bloom", "marsh_salt" }, 3, + AlchemySignatureEffectKind.CureSwampRot, + "Cures Swamp Rot. Rare reagents.", + AlchemyPotionFamily.Healing, AlchemyPotionFamily.VenomWard), + new(61, "Poxiron Tincture", "poxiron_flake", + new[] { "sunscale_dust", "ash_fern", "goldcap_fungus" }, 3, + AlchemySignatureEffectKind.CureRedscalePox, + "Cures Redscale Pox. Rare reagents.", + AlchemyPotionFamily.Healing, AlchemyPotionFamily.VenomWard), + new(62, "Gravedamp Rite", "gravedamp_lichen", + new[] { "grave_moss", "ghost_fern", "cracked_soul_dust" }, 3, + AlchemySignatureEffectKind.CureGraveDamp, + "Cures Grave Damp. Rare reagents.", + AlchemyPotionFamily.Healing, AlchemyPotionFamily.Spirit), + new(63, "Hollowlung Breath", "hollow_lungwort", + new[] { "sky_reed", "moonwater_lily", "ash_fern" }, 3, + AlchemySignatureEffectKind.CureHollowLung, + "Cures Hollow Lung. Rare reagents.", + AlchemyPotionFamily.Healing, AlchemyPotionFamily.VenomWard), }; private static readonly Dictionary ById = BuildById(); @@ -523,6 +585,9 @@ public static class AlchemySignatureBrewCatalog if (potion.SignatureRecipeId <= 0 || !TryGetRecipe(potion.SignatureRecipeId, out Recipe recipe)) return string.Empty; + if (DiseaseCatalog.DiseaseForCureEffect(recipe.Effect) != DiseaseId.None) + return $"{recipe.DisplayName}: {recipe.EffectSummary}"; + float durationSeconds = Mathf.Lerp(18f, 120f, Mathf.Clamp01(potion.DurationTier / 31f)); if (recipe.Effect == AlchemySignatureEffectKind.Featherfall) durationSeconds = Mathf.Lerp(25f, 180f, Mathf.Clamp01(potion.DurationTier / 31f)); diff --git a/Assets/Scripts/Alchemy/AlchemySignatureEffectKind.cs b/Assets/Scripts/Alchemy/AlchemySignatureEffectKind.cs index b8214fad6..996377e24 100644 --- a/Assets/Scripts/Alchemy/AlchemySignatureEffectKind.cs +++ b/Assets/Scripts/Alchemy/AlchemySignatureEffectKind.cs @@ -69,4 +69,18 @@ public enum AlchemySignatureEffectKind : byte /// Underwater murk reduction — stronger brew = clearer water. Clearwake = 51, + + // Disease cures (signature recipes 52–63) + CureRawcapGut = 52, + CureMarshCough = 53, + CureFrogitch = 54, + CureWoundFever = 55, + CureMirefoot = 56, + CureBlackwaterBelly = 57, + CureLeechblood = 58, + CureBoneChillAgue = 59, + CureSwampRot = 60, + CureRedscalePox = 61, + CureGraveDamp = 62, + CureHollowLung = 63, } diff --git a/Assets/Scripts/Alchemy/AlchemySignatureRuntime.cs b/Assets/Scripts/Alchemy/AlchemySignatureRuntime.cs index 668b2fd17..cf23d699b 100644 --- a/Assets/Scripts/Alchemy/AlchemySignatureRuntime.cs +++ b/Assets/Scripts/Alchemy/AlchemySignatureRuntime.cs @@ -79,6 +79,29 @@ public static class AlchemySignatureRuntime case AlchemySignatureEffectKind.FailuresShelfSurprise: ApplyFailuresShelf(player, health, stamina, status, potency01, durationSeconds); return; + case AlchemySignatureEffectKind.CureRawcapGut: + case AlchemySignatureEffectKind.CureMarshCough: + case AlchemySignatureEffectKind.CureFrogitch: + case AlchemySignatureEffectKind.CureWoundFever: + case AlchemySignatureEffectKind.CureMirefoot: + case AlchemySignatureEffectKind.CureBlackwaterBelly: + case AlchemySignatureEffectKind.CureLeechblood: + case AlchemySignatureEffectKind.CureBoneChillAgue: + case AlchemySignatureEffectKind.CureSwampRot: + case AlchemySignatureEffectKind.CureRedscalePox: + case AlchemySignatureEffectKind.CureGraveDamp: + case AlchemySignatureEffectKind.CureHollowLung: + { + DiseaseId cureId = DiseaseCatalog.DiseaseForCureEffect(recipe.Effect); + PlayerDiseaseRuntime diseases = PlayerDiseaseRuntime.EnsureOn(player); + if (diseases != null && cureId != DiseaseId.None && diseases.ServerTryCure(cureId)) + { + if (health != null) + health.Heal(Mathf.Lerp(2f, 8f, potency01)); + } + + return; + } } status?.ApplySignatureEffect(recipe.Effect, potency01, durationSeconds); diff --git a/Assets/Scripts/Bandit/BanditLootTable.cs b/Assets/Scripts/Bandit/BanditLootTable.cs index 7e352c123..787102ee1 100644 --- a/Assets/Scripts/Bandit/BanditLootTable.cs +++ b/Assets/Scripts/Bandit/BanditLootTable.cs @@ -85,7 +85,7 @@ public sealed class BanditLootTable : ScriptableObject if (bonusChance > 0f && Random.value <= bonusChance) { - List picks = RollBonusItems(ctx.pickedIds, commonW, rareW, minRolls, maxRolls); + List picks = RollBonusItems(ctx.pickedIds, commonW, rareW, minRolls, maxRolls, ctx.quality01); for (int i = 0; i < picks.Count; i++) { Item item = picks[i]; @@ -117,15 +117,13 @@ public sealed class BanditLootTable : ScriptableObject float commonW, float rareW, int minRolls, - int maxRolls) + int maxRolls, + float creatureQuality01 = 0f) { var results = new List(2); ItemDatabase db = ResolveItemDatabase(); if (db?.items == null || db.items.Count == 0) return results; - if (commonW <= 0f && rareW <= 0f) - return results; - MarketCatalog catalog = MarketCatalog.Resolve(null); var candidates = new List<(Item item, float weight)>(db.items.Count); for (int i = 0; i < db.items.Count; i++) @@ -138,9 +136,11 @@ public sealed class BanditLootTable : ScriptableObject if (item.HasMeleeDefinition || item.HasBowDefinition) continue; - bool rare = ItemLootRarity.IsRare(item, catalog); - float weight = rare ? rareW : commonW; - if (weight <= 0f) + if (ItemLootRarity.ResolveTier(item) > MarketItemRarity.Tier.Rare) + continue; + + float weight = ItemLootRarity.ResolveLootWeight(item, creatureQuality01); + if (weight <= 0.001f) continue; candidates.Add((item, weight)); diff --git a/Assets/Scripts/BiomeClimateSampler.cs b/Assets/Scripts/BiomeClimateSampler.cs index 5d9ff7d00..60044bdd9 100644 --- a/Assets/Scripts/BiomeClimateSampler.cs +++ b/Assets/Scripts/BiomeClimateSampler.cs @@ -147,13 +147,157 @@ public static class BiomeClimateSampler float outer = localRadius + edge * 0.35f; float inner = Mathf.Max(0f, localRadius - edge); - float membership = 1f - Mathf.SmoothStep(inner, outer, dist); + // GLSL-style edge falloff — Mathf.SmoothStep is a lerp and zeros the disc center. + float membership = 1f - smoothstep(inner, outer, dist); if (membership <= 0.001f) return 0f; return Mathf.Clamp01(membership * strength); } + /// + /// 0–1 membership on the separate void island. Missing YAML still places one opposite the volcano. + /// + public static float SampleVoidDomain01(int worldX, int worldZ, int seed, BiomeSettings settings) + { + if (settings == null) + settings = DefaultSettings; + + EnsureVoidSettingsDefaults(settings); + ResolveVoidLayout(settings, out Vector2 voidCenter, out float radius, out float strength); + if (strength <= 0.001f || radius <= 1f) + return 0f; + + float edge = Mathf.Clamp(settings.voidDomainEdgeMeters <= 0.001f ? 36f : settings.voidDomainEdgeMeters, 10f, 80f); + float dx = worldX - voidCenter.x; + float dz = worldZ - voidCenter.y; + float dist = Mathf.Sqrt(dx * dx + dz * dz); + float outer = radius + edge * 0.35f; + float inner = Mathf.Max(0f, radius - edge); + // GLSL-style edge falloff — Mathf.SmoothStep is a lerp and zeros the disc center. + float membership = 1f - smoothstep(inner, outer, dist); + if (membership <= 0.001f) + return 0f; + + // Only block paint that would still sit on the main landmass. The void center itself + // must never be zeroed — a stale / oversized islandRadius used to reject the pad. + float mainDx = worldX - s_islandCenter.x; + float mainDz = worldZ - s_islandCenter.y; + float mainDist = Mathf.Sqrt(mainDx * mainDx + mainDz * mainDz); + if (dist > radius * 0.35f && mainDist <= s_islandRadius * 0.98f) + return 0f; + + return Mathf.Clamp01(membership * strength); + } + + /// + /// Gravity scale at a world XZ: 1 outside the void, + /// in the core. Uses a wider soft edge than biome paint so gravity eases across the rim. + /// + public static float SampleGravityMultiplier(float worldX, float worldZ, int seed, BiomeSettings settings) + { + if (settings == null) + settings = DefaultSettings; + + EnsureVoidSettingsDefaults(settings); + float voidCore = Mathf.Clamp(settings.voidGravityMultiplier, 0.05f, 1f); + if (settings.voidDomainStrength <= 0.001f || Mathf.Abs(voidCore - 1f) < 0.001f) + return 1f; + + ResolveVoidLayout(settings, out Vector2 voidCenter, out float radius, out float strength); + if (strength <= 0.001f || radius <= 1f) + return 1f; + + float edge = Mathf.Clamp(settings.voidDomainEdgeMeters <= 0.001f ? 36f : settings.voidDomainEdgeMeters, 10f, 80f); + float widen = Mathf.Clamp(settings.voidGravityBlendWiden, 1f, 2.5f); + float softEdge = edge * widen; + float dx = worldX - voidCenter.x; + float dz = worldZ - voidCenter.y; + float dist = Mathf.Sqrt(dx * dx + dz * dz); + float outer = radius + softEdge * 0.55f; + float inner = Mathf.Max(0f, radius - softEdge * 0.35f); + float membership = 1f - smoothstep(inner, outer, dist); + if (membership <= 0.001f) + return 1f; + + float mainDx = worldX - s_islandCenter.x; + float mainDz = worldZ - s_islandCenter.y; + float mainDist = Mathf.Sqrt(mainDx * mainDx + mainDz * mainDz); + if (dist > radius * 0.35f && mainDist <= s_islandRadius * 0.98f) + return 1f; + + float void01 = Mathf.Clamp01(membership * strength); + return Mathf.Lerp(1f, voidCore, void01); + } + + public static void ResolveVoidLayout(BiomeSettings settings, out Vector2 centerXZ, out float radiusMeters) + { + EnsureVoidSettingsDefaults(settings); + ResolveVoidLayout(settings, out centerXZ, out radiusMeters, out _); + } + + /// + /// Profile assets authored before void fields leave strength/radius at 0 — restore defaults + /// the same way volcano settings are forced ready. + /// + public static void EnsureVoidSettingsDefaults(BiomeSettings settings) + { + if (settings == null) + return; + + if (settings.voidDomainStrength <= 0.001f) + settings.voidDomainStrength = 1f; + if (settings.voidDomainRadiusMeters < 40f || settings.voidDomainRadiusMeters > 280f) + settings.voidDomainRadiusMeters = 160f; + if (settings.voidIslandGapMeters < 80f) + settings.voidIslandGapMeters = 140f; + if (settings.voidDomainEdgeMeters < 10f || settings.voidDomainEdgeMeters > 80f) + settings.voidDomainEdgeMeters = 36f; + if (settings.voidIslandBearingDegrees <= 0.001f) + { + float volcanoBearing = settings.volcanoIslandBearingDegrees > 0.001f + ? settings.volcanoIslandBearingDegrees + : 90f; + settings.voidIslandBearingDegrees = Mathf.Repeat(volcanoBearing + 180f, 360f); + if (settings.voidIslandBearingDegrees <= 0.001f) + settings.voidIslandBearingDegrees = 270f; + } + if (settings.voidPlateauAboveSea01 < 0.02f || settings.voidPlateauAboveSea01 > 0.14f) + settings.voidPlateauAboveSea01 = 0.055f; + if (settings.voidPeakLift01 < 0.18f || settings.voidPeakLift01 > 0.55f) + settings.voidPeakLift01 = 0.44f; + if (settings.voidHeightExaggeration < 1.05f || settings.voidHeightExaggeration > 4.5f) + settings.voidHeightExaggeration = 3.15f; + if (settings.voidGravityMultiplier < 0.05f || settings.voidGravityMultiplier > 1f) + settings.voidGravityMultiplier = 0.3333333f; + if (settings.voidGravityBlendWiden < 1f || settings.voidGravityBlendWiden > 2.5f) + settings.voidGravityBlendWiden = 2.1f; + } + + private static void ResolveVoidLayout(BiomeSettings settings, out Vector2 centerXZ, out float radiusMeters, out float strength) + { + if (settings == null) + settings = DefaultSettings; + + EnsureVoidSettingsDefaults(settings); + strength = Mathf.Clamp01(settings.voidDomainStrength); + radiusMeters = Mathf.Clamp(settings.voidDomainRadiusMeters, 40f, 280f); + float gap = ResolveVoidGap(settings); + float bearingDeg = settings.voidIslandBearingDegrees; + if (bearingDeg <= 0.001f) + bearingDeg = 270f; + float bearing = bearingDeg * Mathf.Deg2Rad; + float centerDist = Mathf.Max(1f, s_islandRadius) + gap + radiusMeters; + centerXZ = s_islandCenter + new Vector2(Mathf.Cos(bearing), Mathf.Sin(bearing)) * centerDist; + } + + private static float ResolveVoidGap(BiomeSettings settings) + { + if (settings == null || settings.voidIslandGapMeters <= 0.001f) + return 140f; + return Mathf.Clamp(settings.voidIslandGapMeters, 80f, 400f); + } + public static void Sample(int worldX, int worldZ, int seed, BiomeSettings settings, out float moisture01, out float effectiveTemperature01) { if (settings == null) diff --git a/Assets/Scripts/BiomeGravity.cs b/Assets/Scripts/BiomeGravity.cs new file mode 100644 index 000000000..7bdfc4bc4 --- /dev/null +++ b/Assets/Scripts/BiomeGravity.cs @@ -0,0 +1,39 @@ +using UnityEngine; + +/// +/// Spatial gravity from the void biome. Core void is ~1/3 normal; borders blend gradually. +/// Character motors call ; Rigidbodies are corrected by +/// . +/// +public static class BiomeGravity +{ + private static readonly BiomeSettings FallbackSettings = new BiomeSettings(); + + public static float SampleMultiplier(Vector3 worldPosition) + { + WorldGenerator world = ManafallWorldBinding.FindWorldGenerator(); + BiomeSettings settings = world != null ? world.BiomeSettings : FallbackSettings; + int seed = world != null ? world.TerrainSeed : 0; + return BiomeClimateSampler.SampleGravityMultiplier(worldPosition.x, worldPosition.z, seed, settings); + } + + public static float SampleMultiplier(float worldX, float worldZ) + { + WorldGenerator world = ManafallWorldBinding.FindWorldGenerator(); + BiomeSettings settings = world != null ? world.BiomeSettings : FallbackSettings; + int seed = world != null ? world.TerrainSeed : 0; + return BiomeClimateSampler.SampleGravityMultiplier(worldX, worldZ, seed, settings); + } + + /// Scales a character-motor gravity accel (usually negative) by the local biome field. + public static float Scale(float gravityAccel, Vector3 worldPosition) + { + return gravityAccel * SampleMultiplier(worldPosition); + } + + /// World gravity acceleration at a point (same direction as ). + public static Vector3 GetAcceleration(Vector3 worldPosition) + { + return Physics.gravity * SampleMultiplier(worldPosition); + } +} diff --git a/Assets/Scripts/BiomeGravity.cs.meta b/Assets/Scripts/BiomeGravity.cs.meta new file mode 100644 index 000000000..29dd8eca2 --- /dev/null +++ b/Assets/Scripts/BiomeGravity.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e6b9a7447cd74130b2e87fa94e976a68 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/BiomeGravityWorldDriver.cs b/Assets/Scripts/BiomeGravityWorldDriver.cs new file mode 100644 index 000000000..76e58ebf4 --- /dev/null +++ b/Assets/Scripts/BiomeGravityWorldDriver.cs @@ -0,0 +1,119 @@ +using UnityEngine; + +/// +/// Applies biome gravity to every active Rigidbody the same way (not player-only). +/// Unity still integrates ; this adds the residual so net +/// acceleration equals . +/// +[DefaultExecutionOrder(-40)] +[DisallowMultipleComponent] +public sealed class BiomeGravityWorldDriver : MonoBehaviour +{ + public static BiomeGravityWorldDriver Instance { get; private set; } + + [SerializeField] private float refreshIntervalSeconds = 0.35f; + [SerializeField] private int maxBodiesPerRefresh = 256; + + private readonly System.Collections.Generic.List bodies = + new System.Collections.Generic.List(256); + private float nextRefreshAt; + private int refreshCursor; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void ResetStatics() => Instance = null; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] + private static void EnsureExists() + { + if (Instance != null) + return; + + var go = new GameObject("BiomeGravityWorldDriver"); + DontDestroyOnLoad(go); + go.AddComponent(); + } + + private void Awake() + { + if (Instance != null && Instance != this) + { + Destroy(this); + return; + } + + Instance = this; + } + + private void OnDestroy() + { + if (Instance == this) + Instance = null; + } + + private void FixedUpdate() + { + if (Time.unscaledTime >= nextRefreshAt) + { + nextRefreshAt = Time.unscaledTime + Mathf.Max(0.15f, refreshIntervalSeconds); + RefreshBodyCache(); + } + + for (int i = bodies.Count - 1; i >= 0; i--) + { + Rigidbody rb = bodies[i]; + if (rb == null) + { + bodies.RemoveAt(i); + continue; + } + + if (!rb.gameObject.activeInHierarchy || !rb.useGravity || rb.isKinematic) + continue; + + float mul = BiomeGravity.SampleMultiplier(rb.worldCenterOfMass); + if (Mathf.Abs(mul - 1f) < 0.001f) + continue; + + // Unity already applied Physics.gravity; add residual so net = g * mul. + rb.AddForce(Physics.gravity * (mul - 1f), ForceMode.Acceleration); + } + } + + private void RefreshBodyCache() + { + Rigidbody[] found = FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); + if (found == null || found.Length == 0) + { + bodies.Clear(); + refreshCursor = 0; + return; + } + + int cap = Mathf.Max(32, maxBodiesPerRefresh); + if (found.Length <= cap) + { + bodies.Clear(); + for (int i = 0; i < found.Length; i++) + { + Rigidbody rb = found[i]; + if (rb != null && rb.useGravity && !rb.isKinematic) + bodies.Add(rb); + } + + refreshCursor = 0; + return; + } + + // Rotate through the world so large sessions stay bounded. + bodies.Clear(); + int start = refreshCursor % found.Length; + for (int n = 0; n < cap; n++) + { + Rigidbody rb = found[(start + n) % found.Length]; + if (rb != null && rb.useGravity && !rb.isKinematic) + bodies.Add(rb); + } + + refreshCursor = (start + cap) % found.Length; + } +} diff --git a/Assets/Scripts/BiomeGravityWorldDriver.cs.meta b/Assets/Scripts/BiomeGravityWorldDriver.cs.meta new file mode 100644 index 000000000..61e9509ac --- /dev/null +++ b/Assets/Scripts/BiomeGravityWorldDriver.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9cedc44cb0334a3594d326bd6aa776f6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: -40 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/BiomePalette.cs b/Assets/Scripts/BiomePalette.cs index 9fb7af487..6b70eea20 100644 --- a/Assets/Scripts/BiomePalette.cs +++ b/Assets/Scripts/BiomePalette.cs @@ -28,6 +28,8 @@ public static class BiomePalette return new Color(0.45f, 0.55f, 0.38f); case BiomeType.Swamp: return new Color(0.18f, 0.28f, 0.14f); + case BiomeType.Void: + return new Color(0.10f, 0.07f, 0.14f); default: // Never hand out the magenta debug sentinel for ground: the terrain shader multiplies // this tint into the detail albedo, so one out-of-range biome value painted a whole @@ -58,6 +60,18 @@ public static class BiomePalette return rock; } + /// Dark stone / slate for the void island (vertex paint — not grass tint). + public static Color GetVoidRockColor(float height01, float seaLevel) + { + float lift = Mathf.InverseLerp(seaLevel, 1f, Mathf.Clamp01(height01)); + Color abyssal = new Color(0.08f, 0.07f, 0.11f); + Color slate = new Color(0.18f, 0.16f, 0.22f); + Color paleCliff = new Color(0.28f, 0.26f, 0.32f); + Color rock = Color.Lerp(abyssal, slate, Mathf.SmoothStep(0.08f, 0.55f, lift)); + rock = Color.Lerp(rock, paleCliff, Mathf.SmoothStep(0.45f, 0.92f, lift) * 0.55f); + return rock; + } + /// Rust-style top-down map colors (biome + depth), not mesh tints. public static Color GetMapColor(BiomeType biome, float height01, float seaLevel, float beachBand) { diff --git a/Assets/Scripts/BiomeSettings.cs b/Assets/Scripts/BiomeSettings.cs index 6901a21e5..dde098015 100644 --- a/Assets/Scripts/BiomeSettings.cs +++ b/Assets/Scripts/BiomeSettings.cs @@ -163,6 +163,28 @@ public class BiomeSettings [Tooltip("Jagged ridge / buttress strength on the slopes.")] [Range(0f, 1f)] public float volcanoJaggedStrength = 0.9f; + [Header("Void Island (separate landmass)")] + [Tooltip("0 disables the void island. Missing YAML (0 radius) still places one.")] + [Range(0f, 1f)] public float voidDomainStrength = 1f; + [Tooltip("Degrees from +X toward +Z. Opposite the volcano so the two islands do not meet.")] + [Range(0f, 360f)] public float voidIslandBearingDegrees = 270f; + [Tooltip("Open water between main-island shore and void-island shore (meters).")] + [Min(40f)] public float voidIslandGapMeters = 140f; + [Tooltip("Void island land radius (meters).")] + [Min(40f)] public float voidDomainRadiusMeters = 160f; + [Tooltip("Soft blend width at the void rim (meters).")] + [Min(10f)] public float voidDomainEdgeMeters = 36f; + [Tooltip("Height01 of the low walkable rock shelf above sea level.")] + [Range(0.02f, 0.14f)] public float voidPlateauAboveSea01 = 0.055f; + [Tooltip("Height01 lift for sky plateaus / tall void towers above sea.")] + [Range(0.18f, 0.55f)] public float voidPeakLift01 = 0.44f; + [Tooltip("World-Y exaggeration for void peaks (same idea as the volcano island).")] + [Range(1f, 4.5f)] public float voidHeightExaggeration = 3.15f; + [Tooltip("Gravity multiplier deep inside the void (1 = normal, 0.333 = one third).")] + [Range(0.05f, 1f)] public float voidGravityMultiplier = 0.3333333f; + [Tooltip("Widens the gravity soft-edge past the void paint rim (1 = tight, 2.5 = broad ramp).")] + [Range(1f, 2.5f)] public float voidGravityBlendWiden = 2.1f; + [Header("High Country")] public float alpineLandLiftThreshold = 0.68f; public float alpineSlopeThreshold = 0.42f; diff --git a/Assets/Scripts/BiomeSystem.cs b/Assets/Scripts/BiomeSystem.cs index 3e8a685b0..c08803458 100644 --- a/Assets/Scripts/BiomeSystem.cs +++ b/Assets/Scripts/BiomeSystem.cs @@ -11,7 +11,8 @@ public enum BiomeType Snow, Tundra, Swamp, - Volcano + Volcano, + Void } public static class BiomeSystem diff --git a/Assets/Scripts/Books/LoreBookLootUtility.cs b/Assets/Scripts/Books/LoreBookLootUtility.cs index bf61c3758..cc3b802bf 100644 --- a/Assets/Scripts/Books/LoreBookLootUtility.cs +++ b/Assets/Scripts/Books/LoreBookLootUtility.cs @@ -157,20 +157,14 @@ public static class LoreBookLootUtility if (item == null) return 0f; - ReadableBook book = item.ReadableBook; - float w = book != null ? book.ResolvedSpawnWeight : 1f; - LoreBookRarity rarity = book != null ? book.loreRarity : LoreBookRarity.Common; - switch (rarity) - { - case LoreBookRarity.Common: w *= 1.35f; break; - case LoreBookRarity.Uncommon: w *= 1.1f; break; - case LoreBookRarity.Rare: w *= 0.65f; break; - case LoreBookRarity.Epic: w *= 0.28f; break; - case LoreBookRarity.Legendary: w *= 0.1f; break; - case LoreBookRarity.Mythic: w *= 0.03f; break; - } + // Tier rate from MarketItemRarity (ProfileForSkillBook); spawn weight is a relative mul. + float tierWeight = MarketItemRarity.LootWeightFor(item); + if (tierWeight <= 0.001f) + return 0f; - return Mathf.Max(0.01f, w); + ReadableBook book = item.ReadableBook; + float spawnMul = book != null ? Mathf.Max(0.01f, book.ResolvedSpawnWeight) : 1f; + return tierWeight * spawnMul; } public static float WeightForItem(Item item, LoreBookStockBias bias = LoreBookStockBias.General) diff --git a/Assets/Scripts/CardGame/SealclashCardLootUtility.cs b/Assets/Scripts/CardGame/SealclashCardLootUtility.cs index 259c85fe8..d9efa880c 100644 --- a/Assets/Scripts/CardGame/SealclashCardLootUtility.cs +++ b/Assets/Scripts/CardGame/SealclashCardLootUtility.cs @@ -154,9 +154,24 @@ public static class SealclashCardLootUtility { if (!SealclashCardItemUtility.IsSealclashCardItem(item)) return 0f; - return SealclashCardItemUtility.CatalogWeightForRarity( - SealclashCardItemUtility.ResolveRarityName(item), - pool); + + // Canonical rate from MarketItemRarity tier (ProfileForItem CatalogWeight). + float weight = MarketItemRarity.LootWeightFor(item); + if (weight <= 0.001f) + return 0f; + + // Orian caravan slightly boosts Rare+ relative to village shelves. + if (pool == SealclashCardItemUtility.ShopCardPool.OrianCaravan) + { + string rarity = SealclashCardItemUtility.ResolveRarityName(item); + float village = SealclashCardItemUtility.CatalogWeightForRarity( + rarity, SealclashCardItemUtility.ShopCardPool.VillageGeneral); + float orian = SealclashCardItemUtility.CatalogWeightForRarity(rarity, pool); + if (village > 0.001f) + weight *= orian / village; + } + + return weight; } public static int CountCardsInChestSlots(SyncChestSlots slots) diff --git a/Assets/Scripts/Chest/ChestHudController.cs b/Assets/Scripts/Chest/ChestHudController.cs index 2742fdad0..552c903fc 100644 --- a/Assets/Scripts/Chest/ChestHudController.cs +++ b/Assets/Scripts/Chest/ChestHudController.cs @@ -44,6 +44,9 @@ namespace InventoryFramework public bool IsOpen => panelRoot != null && panelRoot.activeSelf && boundChest != null; + /// True when the loot panel GameObject is active (even if the bound chest was destroyed). + public bool IsPanelActive => panelRoot != null && panelRoot.activeSelf; + public ChestInventoryUI ChestInventory => chestInventoryUI; /// Persistent 2D source under this HUD object — survives PlayOneShot (temporary Destroy sources often silence). @@ -51,9 +54,16 @@ namespace InventoryFramework private void Update() { - if (!IsOpen) + // Panel can stay active after a corpse despawn clears boundChest — still allow dismiss. + if (panelRoot == null || !panelRoot.activeSelf) return; + if (boundChest == null) + { + Close(); + return; + } + // Same F that opened the chest must not close it on this frame. if (ignoreCloseKeysThisFrame) { @@ -112,10 +122,21 @@ namespace InventoryFramework private void LateUpdate() { - if (panelRoot == null || !panelRoot.activeSelf || boundChest == null || boundPlayer == null) + if (panelRoot == null || !panelRoot.activeSelf) + return; + + // Body despawned / unbound while the loot panel was still up. + if (boundChest == null) + { + Close(); + return; + } + + if (boundPlayer == null) return; - // Slightly inside interact range so you cannot stand out of interact range with UI open. + // Slightly inside interact range. Corpses override IsPlayerInRange for ragdoll / + // disabled colliders so this does not false-close under a dead body. float stayRange = Mathf.Max(0.5f, Mathf.Min(closeDistance, boundChest.InteractRange * 0.92f)); if (!boundChest.IsPlayerInRange(boundPlayer.position, stayRange)) Close(); @@ -166,6 +187,8 @@ namespace InventoryFramework if (panelRoot != null) { UIDraggablePanel.EnsureOn(panelRoot); + // List mode: hide the slot grid before the first paint (no old-grid flash). + InventoryListView.PrepareChestOpenVisuals(); panelRoot.SetActive(true); PlaceInRightColumn(panelRoot.transform as RectTransform); TryApplyParchmentPanel(); @@ -244,6 +267,8 @@ namespace InventoryFramework boundPlayer = null; suppressOpenCloseSounds = false; fHeldLastFrame = IsInteractHeld(); + ignoreCloseKeysThisFrame = false; + ignoreExchangeKeysThisFrame = false; if (chestInventoryUI != null) chestInventoryUI.Bind(null); diff --git a/Assets/Scripts/Chunk.cs b/Assets/Scripts/Chunk.cs index c407006e9..056778198 100644 --- a/Assets/Scripts/Chunk.cs +++ b/Assets/Scripts/Chunk.cs @@ -1590,7 +1590,9 @@ public class Chunk : MonoBehaviour if (biomeMap == null) return; - int max = (int)BiomeType.Volcano; + // Must include Void (added after Volcano). Capping at Volcano turned every Void cell + // into Grassland — flat green disks on the offshore void pad. + int max = (int)BiomeType.Void; int firstBad = -1; int repaired = 0; int sideX = biomeMap.GetLength(0); @@ -1811,7 +1813,8 @@ public class Chunk : MonoBehaviour && inland01 >= 0.28f && biomeMap[x, z] != BiomeType.Forest && biomeMap[x, z] != BiomeType.Desert - && biomeMap[x, z] != BiomeType.Volcano) + && biomeMap[x, z] != BiomeType.Volcano + && biomeMap[x, z] != BiomeType.Void) { float bankTop = seaLevel + Mathf.Max( beachBand * 2.5f, @@ -1827,6 +1830,11 @@ public class Chunk : MonoBehaviour biomeMap[x, z] = BiomeType.Swamp; } } + + float void01 = BiomeClimateSampler.SampleVoidDomain01(worldX, worldZ, seed, bio); + // Paint rock even on the low shelves between towers (not only high plateaus). + if (void01 > 0.08f && height >= seaLevel - 0.02f) + biomeMap[x, z] = BiomeType.Void; } private void SampleClimate(int worldX, int worldZ, BiomeSettings bio, out float moisture01, out float effectiveTemperature01) @@ -3107,6 +3115,13 @@ public class Chunk : MonoBehaviour return WorldTerrainHeightModifiers.Apply(volcanoH, worldX, worldZ); } + float void01Early = BiomeClimateSampler.SampleVoidDomain01(worldX, worldZ, seed, bioEarly); + if (void01Early > 0.02f) + { + float voidH = BuildVoidIslandHeight01(worldX, worldZ, void01Early, bioEarly); + return WorldTerrainHeightModifiers.Apply(voidH, worldX, worldZ); + } + float sx = worldX; float sz = worldZ; NoiseUtility.DomainWarpPosition(ref sx, ref sz, interiorWarpScale, interiorWarpStrength, seed + 2929); @@ -3434,6 +3449,7 @@ public class Chunk : MonoBehaviour } height01 = ApplyVolcanoCore(height01, worldX, worldZ); + height01 = ApplyVoidCore(height01, worldX, worldZ); if (t_captureTail) { t_tailPre = height01; @@ -3458,6 +3474,145 @@ public class Chunk : MonoBehaviour return BuildVolcanoIslandHeight01(worldX, worldZ, volcano01, bio); } + /// Offshore void mass — rock shelves, chasms, and sky plateaus. + private float ApplyVoidCore(float height01, int worldX, int worldZ) + { + BiomeSettings bio = ResolveBiomeSettings(); + float void01 = BiomeClimateSampler.SampleVoidDomain01(worldX, worldZ, seed, bio); + if (void01 <= 0.02f) + return height01; + return BuildVoidIslandHeight01(worldX, worldZ, void01, bio); + } + + private float BuildVoidIslandHeight01(int worldX, int worldZ, float void01, BiomeSettings bio) + { + BiomeClimateSampler.ResolveVoidLayout(bio, out Vector2 voidCenter, out float radius); + radius = Mathf.Max(40f, radius); + + float dx = worldX - voidCenter.x; + float dz = worldZ - voidCenter.y; + float dist = Mathf.Sqrt(dx * dx + dz * dz); + float radial01 = Mathf.Clamp01(dist / radius); + + float shelf = bio != null ? Mathf.Clamp(bio.voidPlateauAboveSea01, 0.02f, 0.14f) : 0.055f; + float peakLift = bio != null ? Mathf.Clamp(bio.voidPeakLift01, 0.18f, 0.55f) : 0.42f; + + // Voronoi pads: each cell is a shelf, a sky tower, a mid mesa, or open air/chasm. + const float cellMeters = 42f; + float cellX = (worldX - voidCenter.x) / cellMeters; + float cellZ = (worldZ - voidCenter.y) / cellMeters; + int ix = Mathf.FloorToInt(cellX); + int iz = Mathf.FloorToInt(cellZ); + float bestD = 8f; + float secondD = 8f; + int bestI = 0; + int bestJ = 0; + for (int j = -1; j <= 1; j++) + { + for (int i = -1; i <= 1; i++) + { + int cx = ix + i; + int cz = iz + j; + float ox = Hash01(cx, cz, seed + 9011); + float oz = Hash01(cx, cz, seed + 9029); + float px = cx + ox; + float pz = cz + oz; + float ddx = cellX - px; + float ddz = cellZ - pz; + float d = Mathf.Sqrt(ddx * ddx + ddz * ddz); + if (d < bestD) + { + secondD = bestD; + bestD = d; + bestI = cx; + bestJ = cz; + } + else if (d < secondD) + { + secondD = d; + } + } + } + + float edge = Mathf.Max(0.001f, secondD - bestD); + // GLSL edge falloff — Mathf.SmoothStep is a from→to lerp and crushed every pad to a flat shelf. + float padMask = 1f - EdgeSmooth(0.16f, 0.58f, bestD); + float cliff = EdgeSmooth(0.03f, 0.2f, edge); + float cellRoll = Hash01(bestI, bestJ, seed + 9043); + + // 0 chasm / air gap, 1 low rock shelf, 2 mid floating mesa, 3 sky plateau. + int kind; + if (cellRoll < 0.2f) + kind = 0; + else if (cellRoll < 0.42f) + kind = 1; + else if (cellRoll < 0.7f) + kind = 2; + else + kind = 3; + + // Keep a walkable rock pad near the authored spawn seat (domain center). + if (radial01 < 0.2f && kind == 0) + kind = 1; + + float rockNoise = NoiseUtility.RidgedMultifractal( + worldX, worldZ, 0.028f, seed + 88107, 4, 0.52f, 2.2f); + float rubble = NoiseUtility.FractalNoise( + worldX, worldZ, 0.055f, seed + 88203, 3, 0.5f, 2.05f); + + float shore = seaLevel - 0.035f; + float target = shore; + if (kind == 0) + { + // Deep cut between pads — reads as open air / void under floating rock. + float abyss = seaLevel - Mathf.Lerp(0.06f, 0.18f, rockNoise); + target = Mathf.Lerp(abyss, seaLevel - 0.02f, padMask * 0.25f); + } + else if (kind == 1) + { + float h = seaLevel + shelf * Mathf.Lerp(0.7f, 1.35f, rubble); + h += (rockNoise - 0.5f) * 0.045f; + target = Mathf.Lerp(shore, h, padMask); + } + else if (kind == 2) + { + // Mid mesa with sheer sides — floating-island silhouette from the water. + float top = seaLevel + Mathf.Lerp(shelf * 2.2f, peakLift * 0.72f, Hash01(bestI, bestJ, seed + 9061)); + top += (rockNoise - 0.45f) * 0.05f; + float stem = 1f - EdgeSmooth(0.1f, 0.4f, bestD); // wider top, thinner approach + float mesa = Mathf.Lerp(shore, top, padMask * stem); + target = Mathf.Lerp(mesa, top, cliff * padMask * 0.9f); + } + else + { + // Sky plateaus that climb hard — flat crowns, cliff skirts. + float crown = seaLevel + peakLift * Mathf.Lerp(0.85f, 1.2f, Hash01(bestI, bestJ, seed + 9077)); + float skirt = seaLevel + shelf * 0.9f; + float tower = Mathf.Lerp(skirt, crown, Mathf.Pow(Mathf.Max(padMask * cliff, 1e-3f), 0.55f)); + tower = Mathf.Max(tower, Mathf.Lerp(shore, crown * 0.95f, padMask * padMask)); + tower += (rockNoise - 0.5f) * 0.06f; + target = tower; + } + + // Rim softens into the sea so the void disc still reads as one offshore mass. + float rim = EdgeSmooth(0.08f, 0.78f, void01) * (1f - EdgeSmooth(0.82f, 1.02f, radial01)); + float shaped = Mathf.Lerp(shore, target, rim); + if (void01 > 0.55f && kind != 0) + shaped = Mathf.Max(shaped, seaLevel + shelf * 0.55f); + return shaped; + } + + private static float Hash01(int x, int z, int salt) + { + unchecked + { + int h = x * 374761393 + z * 668265263 + salt; + h = (h ^ (h >> 13)) * 1274126177; + h ^= h >> 16; + return (h & 0x7fffffff) / (float)int.MaxValue; + } + } + /// /// Standalone volcano island cone — dry land above sea, independent of main-island falloff. /// @@ -4255,6 +4410,19 @@ public class Chunk : MonoBehaviour return t * t * (3f - 2f * t); } + /// + /// GLSL-style smoothstep on a value between edges. Do not use : + /// that API lerps from→to with a 0–1 t, which zeros void/volcano rim masks. + /// + private static float EdgeSmooth(float edge0, float edge1, float x) + { + float denom = edge1 - edge0; + if (Mathf.Abs(denom) < 1e-5f) + return x >= edge1 ? 1f : 0f; + float t = Mathf.Clamp01((x - edge0) / denom); + return t * t * (3f - 2f * t); + } + private static int FloorDiv(int value, int divisor) { if (value >= 0) @@ -4554,6 +4722,20 @@ public class Chunk : MonoBehaviour return ComputeLandHeight(height01, cold01, worldX, worldZ); } + float void01 = BiomeClimateSampler.SampleVoidDomain01(worldX, worldZ, seed, volcanoBio); + if (void01 > 0.02f) + { + // Always keep the authored void silhouette. Lifting every cell to sea+ε + // erased chasms and turned floating pads into one flat shelf. + height01 = ApplyVoidCore(height01, worldX, worldZ); + if (height01 >= seaLevel) + return ComputeLandHeight(height01, cold01, worldX, worldZ); + + float seaY = seaLevel * heightScale; + float depth01 = Mathf.Clamp01(seaLevel - height01); + return seaY - depth01 * heightScale * Mathf.Lerp(1.15f, 2.1f, depth01 / 0.18f); + } + // Safety: arid interior cells never use deep ocean seafloor carving (sheer desert cliffs). // Gate must stay stricter than the coastal shelf band — a low inland check was raising // arid shoreline mesh above water and painting the rim as desert. @@ -4636,7 +4818,7 @@ public class Chunk : MonoBehaviour float volcanoEx = Mathf.Clamp(bio.volcanoHeightExaggeration, 1f, 4.5f); if (volcanoEx > 1.001f && volcano01 > 0.02f) { - float core = Mathf.SmoothStep(0.05f, 0.55f, volcano01); + float core = EdgeSmooth(0.05f, 0.55f, volcano01); float ex = Mathf.Lerp(1f, volcanoEx, core); float seaY = seaLevel * heightScale; float above = y - seaY; @@ -4644,6 +4826,19 @@ public class Chunk : MonoBehaviour y = seaY + above * ex; } + // Void sky plateaus — stretch the height01 towers into readable cliffs. + float void01 = BiomeClimateSampler.SampleVoidDomain01(worldX, worldZ, seed, bio); + float voidEx = Mathf.Clamp(bio.voidHeightExaggeration, 1f, 4.5f); + if (voidEx > 1.001f && void01 > 0.02f) + { + float core = EdgeSmooth(0.08f, 0.55f, void01); + float ex = Mathf.Lerp(1f, voidEx, core); + float seaY = seaLevel * heightScale; + float above = y - seaY; + if (above > 0.05f) + y = seaY + above * ex; + } + return y; } @@ -4710,6 +4905,9 @@ public class Chunk : MonoBehaviour if (biome == BiomeType.Volcano) return BiomePalette.GetVolcanoRockColor(height01, seaLevel); + if (biome == BiomeType.Void) + return BiomePalette.GetVoidRockColor(height01, seaLevel); + return BiomePalette.GetTint(biome); } @@ -4745,6 +4943,10 @@ public class Chunk : MonoBehaviour if (volcanoTint01 > 0.12f && biomeH >= seaLevel + beachBand * 0.35f) biome = BiomeType.Volcano; + float voidTint01 = BiomeClimateSampler.SampleVoidDomain01(worldX, worldZ, seed, bio); + if (voidTint01 > 0.08f && biomeH >= seaLevel - 0.02f) + biome = BiomeType.Void; + if (WorldTerrainHeightModifiers.ForcesDryBiomeAt(worldX, worldZ)) { if (biome == BiomeType.Ocean || biome == BiomeType.Beach) diff --git a/Assets/Scripts/Combat/PlayerDeathPresentation.cs b/Assets/Scripts/Combat/PlayerDeathPresentation.cs index 953695bdc..134ff80e8 100644 --- a/Assets/Scripts/Combat/PlayerDeathPresentation.cs +++ b/Assets/Scripts/Combat/PlayerDeathPresentation.cs @@ -543,10 +543,12 @@ public sealed class PlayerDeathPresentation : NetworkBehaviour deathToSpawnScreenRoutine = null; } + bool abortedWorldEntry = false; if (ownerWorldEntryRoutine != null) { StopCoroutine(ownerWorldEntryRoutine); ownerWorldEntryRoutine = null; + abortedWorldEntry = true; } localOssuaryHint = false; @@ -556,6 +558,14 @@ public sealed class PlayerDeathPresentation : NetworkBehaviour deathFocusTransform = null; deathCarryVelocity = Vector3.zero; + // Death respawn used to call ForceEnd after ServerNotifyOwnerWorldEntry, which + // stopped ReleaseWorldEntryWhenGroundReady and left spawnBlackScrim up forever. + if (abortedWorldEntry) + { + ClearWorldEntryCover(); + fpsController?.RestoreLocalPresentation(); + } + if (characterController != null) characterController.enabled = true; diff --git a/Assets/Scripts/Combat/PlayerHealth.cs b/Assets/Scripts/Combat/PlayerHealth.cs index e12d1c5dc..23d7c65b5 100644 --- a/Assets/Scripts/Combat/PlayerHealth.cs +++ b/Assets/Scripts/Combat/PlayerHealth.cs @@ -370,6 +370,10 @@ public class PlayerHealth : NetworkBehaviour alchemyStatus ??= GetComponent() ?? GetComponentInParent(); if (alchemyStatus != null) damageDelta *= alchemyStatus.ColdDamageMultiplier; + PlayerDiseaseRuntime diseases = GetComponent() + ?? GetComponentInParent(); + if (diseases != null) + damageDelta *= diseases.GetColdDamageMultiplier(); if (!NetworkSessionActive) { @@ -528,6 +532,11 @@ public class PlayerHealth : NetworkBehaviour if (spell != null) finalDamage *= GetAlterationSpellDamageMultiplier(); + PlayerDiseaseRuntime diseases = GetComponent() + ?? GetComponentInParent(); + if (diseases != null) + finalDamage *= diseases.GetIncomingDamageMultiplier(); + if (equipmentManager != null) equipmentManager.ApplyDamageToEquippedItems(finalDamage); @@ -571,6 +580,13 @@ public class PlayerHealth : NetworkBehaviour if (finalDamage > 0.01f && (!NetworkSessionActive || isLocalPlayer)) CombatMusicSignals.NotifyActivity(); + // Creature melee that lands (hurt reaction path, not spells/effect ticks) may seed disease. + if (finalDamage > 0.01f && triggerHurtReaction && spell == null && syncedHealth > 0f) + { + DiseaseAmbientDriver.EnsureOn(gameObject); + DiseaseInfectionService.ServerTryInfectFromCreatureHit(gameObject, damageSourcePosition); + } + if (syncedHealth <= 0f) { PlayerVitalsDebug.LogFatalOutcome(gameObject, "TakeDamage", hpBefore, currentHealth, @@ -1039,7 +1055,12 @@ public class PlayerHealth : NetworkBehaviour private float ApplyMedicineHealingBonus(float amount) { - return amount * GetMedicineHealingMultiplier(); + float mul = GetMedicineHealingMultiplier(); + PlayerDiseaseRuntime diseases = GetComponent() + ?? GetComponentInParent(); + if (diseases != null) + mul *= diseases.GetIncomingHealMultiplier(); + return amount * mul; } private float GetFortitudeDamageMultiplier() @@ -1066,6 +1087,16 @@ public class PlayerHealth : NetworkBehaviour return practiceProgress != null ? practiceProgress.ClientAlterationSpellDamageMultiplier : 1f; } + private float GetEffectiveMaxHealth() + { + float cap = maxHealth; + PlayerDiseaseRuntime diseases = GetComponent() + ?? GetComponentInParent(); + if (diseases != null) + cap *= diseases.GetMaxHealthMultiplier(); + return Mathf.Max(1f, cap); + } + private void ApplyHealLocal(float amount, string debugSource = "Heal") { if (NetworkSessionActive && !isServer) @@ -1073,7 +1104,7 @@ public class PlayerHealth : NetworkBehaviour float before = syncedHealth; syncedHealth += amount; - syncedHealth = Mathf.Clamp(syncedHealth, 0f, maxHealth); + syncedHealth = Mathf.Clamp(syncedHealth, 0f, GetEffectiveMaxHealth()); currentHealth = syncedHealth; PlayerVitalsDebug.LogHeal(gameObject, debugSource, amount, before, currentHealth); diff --git a/Assets/Scripts/Combat/PlayerMeleeCombat.cs b/Assets/Scripts/Combat/PlayerMeleeCombat.cs index 9e9d1907a..de70ba2dd 100644 --- a/Assets/Scripts/Combat/PlayerMeleeCombat.cs +++ b/Assets/Scripts/Combat/PlayerMeleeCombat.cs @@ -1021,6 +1021,12 @@ public class PlayerMeleeCombat : NetworkBehaviour damage = VampireCombatIntegration.ModifyOutgoingMeleeDamage(attackerRoot, damage); damage = WerewolfCombatIntegration.ModifyOutgoingMeleeDamage(attackerRoot, damage); + PlayerDiseaseRuntime diseases = attackerRoot != null + ? attackerRoot.GetComponent() ?? attackerRoot.GetComponentInParent() + : null; + if (diseases != null) + damage *= diseases.GetStrengthMultiplier(); + if (profile != null && profile.usePracticeSkillModifiers && NetworkServer.active && attackerRoot != null) { PlayerPracticeProgress pr = attackerRoot.GetComponent(); diff --git a/Assets/Scripts/Combat/PlayerUnarmedHandCombat.cs b/Assets/Scripts/Combat/PlayerUnarmedHandCombat.cs index 3c86c01eb..c616ecce7 100644 --- a/Assets/Scripts/Combat/PlayerUnarmedHandCombat.cs +++ b/Assets/Scripts/Combat/PlayerUnarmedHandCombat.cs @@ -892,6 +892,12 @@ public sealed class PlayerUnarmedHandCombat : NetworkBehaviour : null; damage *= alchemyStatus != null ? alchemyStatus.OutgoingMeleeDamageMultiplier : 1f; + PlayerDiseaseRuntime diseases = attackerRoot != null + ? attackerRoot.GetComponent() ?? attackerRoot.GetComponentInParent() + : null; + if (diseases != null) + damage *= diseases.GetStrengthMultiplier(); + PlayerHealth playerHealth = hit.GetComponentInParent(); if (playerHealth != null && playerHealth.gameObject != attackerRoot) { diff --git a/Assets/Scripts/Consumables/PlayerMushroomSicknessRuntime.cs b/Assets/Scripts/Consumables/PlayerMushroomSicknessRuntime.cs index 24c82eb6d..d3686de91 100644 --- a/Assets/Scripts/Consumables/PlayerMushroomSicknessRuntime.cs +++ b/Assets/Scripts/Consumables/PlayerMushroomSicknessRuntime.cs @@ -2,31 +2,22 @@ using Mirror; using UnityEngine; /// -/// Raw forage mushrooms (not stew) stack toward food sickness. -/// After a random 3–9 eats, the player turns sick: pale-green health bar and slow HP drain -/// until the bout ends, a healing/antivenin potion cures it, or a church healer clears it. +/// Compatibility shim: raw forage mushrooms still count toward illness, but the bout is +/// on . /// [DisallowMultipleComponent] public sealed class PlayerMushroomSicknessRuntime : MonoBehaviour { private const float SessionIdleResetSeconds = 480f; - private const float TickIntervalSeconds = 0.5f; - private const float DamagePerSecond = 0.85f; - private const float MinSickDurationSeconds = 95f; - private const float MaxSickDurationSeconds = 150f; private int rawEatenThisSession; private int sicknessThreshold = -1; - private bool sick; - private float sickExpiresAt; - private float nextTickAt; private float lastRawEatAt = -999f; - private bool hudSick; - private PlayerHealth health; private ActiveHotbarConsume hotbarConsume; - public bool IsSick => sick && Time.time < sickExpiresAt; + public bool IsSick => + PlayerDiseaseRuntime.EnsureOn(gameObject)?.Has(DiseaseId.RawcapGut) == true; public static PlayerMushroomSicknessRuntime EnsureOn(GameObject player) { @@ -34,7 +25,16 @@ public sealed class PlayerMushroomSicknessRuntime : MonoBehaviour return null; PlayerMushroomSicknessRuntime existing = player.GetComponent(); - return existing != null ? existing : player.AddComponent(); + if (existing != null) + { + PlayerDiseaseRuntime.EnsureOn(player); + DiseaseAmbientDriver.EnsureOn(player); + return existing; + } + + PlayerDiseaseRuntime.EnsureOn(player); + DiseaseAmbientDriver.EnsureOn(player); + return player.AddComponent(); } /// Owner / offline path after a raw mushroom is eaten. @@ -51,7 +51,7 @@ public sealed class PlayerMushroomSicknessRuntime : MonoBehaviour hotbarConsume?.CmdRegisterRawMushroomEaten(); } - /// Server / offline: count toward the random 3–9 threshold and maybe start sickness. + /// Server / offline: count toward the random 3–9 threshold and maybe start Rawcap Gut. public void ServerRegisterRawMushroomEaten() { if (!HasDamageAuthority()) @@ -66,83 +66,33 @@ public sealed class PlayerMushroomSicknessRuntime : MonoBehaviour rawEatenThisSession++; if (!IsSick && rawEatenThisSession >= sicknessThreshold) - BeginSickness(); + { + rawEatenThisSession = 0; + sicknessThreshold = -1; + DiseaseInfectionService.ServerTryInfectRawcapGut(gameObject); + } } + /// Legacy healer/potion hook — cures Rawcap Gut only. public bool TryCureFromPotionOrHealer() { - if (!IsSick && !sick) - return false; - - ClearSickness(notifyHud: true); - return true; + PlayerDiseaseRuntime diseases = PlayerDiseaseRuntime.EnsureOn(gameObject); + return diseases != null && diseases.ServerTryCure(DiseaseId.RawcapGut); } public void ClearForRespawn() { - ClearSickness(notifyHud: true); rawEatenThisSession = 0; sicknessThreshold = -1; lastRawEatAt = -999f; + PlayerDiseaseRuntime.EnsureOn(gameObject)?.ClearForRespawn(); } private void Awake() { - health = GetComponent(); hotbarConsume = GetComponent(); - } - - private void Update() - { - if (!HasDamageAuthority()) - return; - - MaybeResetIdleSession(); - - if (!sick) - return; - - if (Time.time >= sickExpiresAt) - { - ClearSickness(notifyHud: true); - return; - } - - if (Time.time < nextTickAt) - return; - - nextTickAt = Time.time + TickIntervalSeconds; - health ??= GetComponent(); - if (health == null || health.currentHealth <= 0.5f) - return; - - health.TakeDamageFromEffect(DamagePerSecond * TickIntervalSeconds, transform.position); - } - - private void OnDisable() - { - if (hudSick) - ApplyHudTint(false); - } - - private void BeginSickness() - { - sick = true; - float duration = Random.Range(MinSickDurationSeconds, MaxSickDurationSeconds); - sickExpiresAt = Time.time + duration; - nextTickAt = Time.time; - rawEatenThisSession = 0; - sicknessThreshold = -1; - ApplyHudTint(true); - } - - private void ClearSickness(bool notifyHud) - { - sick = false; - sickExpiresAt = 0f; - nextTickAt = 0f; - if (notifyHud) - ApplyHudTint(false); + PlayerDiseaseRuntime.EnsureOn(gameObject); + DiseaseAmbientDriver.EnsureOn(gameObject); } private void MaybeResetIdleSession() @@ -157,19 +107,6 @@ public sealed class PlayerMushroomSicknessRuntime : MonoBehaviour sicknessThreshold = -1; } - private void ApplyHudTint(bool enable) - { - hudSick = enable; - hotbarConsume ??= GetComponent(); - if (hotbarConsume != null) - { - hotbarConsume.NotifyMushroomSicknessHud(enable); - return; - } - - PlayerStatusRuntimeHud.EnsureOn(gameObject)?.SetHealthBarSickTint(enable); - } - private bool HasDamageAuthority() { return NetworkServer.active diff --git a/Assets/Scripts/Creatures/Chicken/ChickenLootTable.cs b/Assets/Scripts/Creatures/Chicken/ChickenLootTable.cs index 10129e479..1cba65eea 100644 --- a/Assets/Scripts/Creatures/Chicken/ChickenLootTable.cs +++ b/Assets/Scripts/Creatures/Chicken/ChickenLootTable.cs @@ -68,7 +68,7 @@ public sealed class ChickenLootTable : ScriptableObject if (rareBonusPool != null && rareBonusPool.Length > 0 && Random.value <= chance) { - RareEntry chosen = PickRareEntry(rareBonusPool); + RareEntry chosen = PickRareEntry(rareBonusPool, ctx.quality01); if (chosen.item != null) { int lo = Mathf.Min(chosen.minAmount, chosen.maxAmount); @@ -88,13 +88,18 @@ public sealed class ChickenLootTable : ScriptableObject return spawnedAny; } - private static RareEntry PickRareEntry(RareEntry[] pool) + private static RareEntry PickRareEntry(RareEntry[] pool, float creatureQuality01) { float total = 0f; for (int i = 0; i < pool.Length; i++) { - if (pool[i].item != null && pool[i].weight > 0f) - total += pool[i].weight; + RareEntry row = pool[i]; + if (row.item == null) + continue; + float relativeMul = row.weight > 0.001f ? row.weight : 1f; + float w = ItemLootRarity.ResolveLootWeight(row.item, creatureQuality01, relativeMul); + if (w > 0.001f) + total += w; } if (total <= 0f) @@ -102,18 +107,24 @@ public sealed class ChickenLootTable : ScriptableObject float roll = Random.Range(0f, total); float cumulative = 0f; + RareEntry last = default; for (int i = 0; i < pool.Length; i++) { RareEntry row = pool[i]; - if (row.item == null || row.weight <= 0f) + if (row.item == null) + continue; + float relativeMul = row.weight > 0.001f ? row.weight : 1f; + float w = ItemLootRarity.ResolveLootWeight(row.item, creatureQuality01, relativeMul); + if (w <= 0.001f) continue; - cumulative += row.weight; + last = row; + cumulative += w; if (roll <= cumulative) return row; } - return pool[pool.Length - 1]; + return last; } private static Vector3 FibonacciDiskOffset(int index, float spacing) diff --git a/Assets/Scripts/Creatures/Cow/CowLootTable.cs b/Assets/Scripts/Creatures/Cow/CowLootTable.cs index 1f1dfa866..9af46143b 100644 --- a/Assets/Scripts/Creatures/Cow/CowLootTable.cs +++ b/Assets/Scripts/Creatures/Cow/CowLootTable.cs @@ -60,7 +60,7 @@ public sealed class CowLootTable : ScriptableObject if (chance > 0f && Random.value <= chance) { - Item bonus = RollBonusItem(); + Item bonus = RollBonusItem(ctx.quality01); if (bonus != null) { int lo = Mathf.Min(genericMinAmount, genericMaxAmount); @@ -127,18 +127,19 @@ public sealed class CowLootTable : ScriptableObject } } - private Item RollBonusItem() + private Item RollBonusItem(float creatureQuality01) { - List candidates = BuildBonusCandidates(); + List<(Item item, float weight)> candidates = BuildBonusCandidates(creatureQuality01); if (candidates.Count == 0) return null; - return candidates[Random.Range(0, candidates.Count)]; + int index = TierLootRoll.PickIndex(candidates); + return index >= 0 ? candidates[index].item : null; } - private List BuildBonusCandidates() + private List<(Item item, float weight)> BuildBonusCandidates(float creatureQuality01) { - var candidates = new List(64); + var candidates = new List<(Item item, float weight)>(64); var seenIds = new HashSet(); if (cowHide != null) @@ -162,9 +163,15 @@ public sealed class CowLootTable : ScriptableObject if (!ItemLootRarity.CanDropFromCreatureBonusLoot(item, catalog)) continue; + if (ItemLootRarity.ResolveTier(item) > MarketItemRarity.Tier.Rare) + continue; + + float weight = ItemLootRarity.ResolveLootWeight(item, creatureQuality01); + if (weight <= 0.001f) + continue; seenIds.Add(item.id); - candidates.Add(item); + candidates.Add((item, weight)); } return candidates; diff --git a/Assets/Scripts/Creatures/CreatureCorpseLootChest.cs b/Assets/Scripts/Creatures/CreatureCorpseLootChest.cs index 009573dc4..db449a0eb 100644 --- a/Assets/Scripts/Creatures/CreatureCorpseLootChest.cs +++ b/Assets/Scripts/Creatures/CreatureCorpseLootChest.cs @@ -11,6 +11,7 @@ using UnityEngine; public sealed class CreatureCorpseLootChest : NetworkChest { public const int DefaultLootCapacity = 12; + private const string BodyLookVolumeName = "CorpseLootLookVolume"; [SerializeField] private int lootCapacity = DefaultLootCapacity; [SerializeField] private float emptyDespawnSeconds = 8f; @@ -241,10 +242,164 @@ public sealed class CreatureCorpseLootChest : NetworkChest public Vector3 ResolveBodyLootWorldPosition() { + if (TryGetBodyBounds(out Bounds bounds)) + return bounds.center; + + Transform hurtbox = transform.Find("Hurtbox"); + if (hurtbox != null) + return hurtbox.position; + Collider col = GetComponentInChildren(); - if (col != null) + if (col != null && col.enabled) return col.bounds.center; return transform.position + Vector3.up * 0.5f; } + + private void LateUpdate() + { + if (IsCorpseLootable()) + UpdateBodyLookVolume(); + else + DestroyBodyLookVolume(); + } + + /// + /// Death disables CharacterController / many body colliders, so aim rays often miss. + /// A generous trigger volume covering the corpse keeps "search" prompts reliable. + /// + private void UpdateBodyLookVolume() + { + Transform volumeTf = transform.Find(BodyLookVolumeName); + GameObject volumeGo; + CapsuleCollider capsule; + if (volumeTf == null) + { + volumeGo = new GameObject(BodyLookVolumeName); + volumeGo.transform.SetParent(transform, false); + volumeGo.layer = gameObject.layer; + capsule = volumeGo.AddComponent(); + capsule.isTrigger = true; + capsule.direction = 1; + } + else + { + volumeGo = volumeTf.gameObject; + capsule = volumeGo.GetComponent(); + if (capsule == null) + capsule = volumeGo.AddComponent(); + capsule.isTrigger = true; + } + + Vector3 center = ResolveBodyLootWorldPosition(); + float radius = 1.15f; + float height = 2.2f; + if (TryGetBodyBounds(out Bounds bounds)) + { + center = bounds.center; + radius = Mathf.Clamp(Mathf.Max(bounds.extents.x, bounds.extents.z) + 0.55f, 1.1f, 3.8f); + height = Mathf.Clamp(bounds.size.y + 1.1f, 1.6f, 5.5f); + } + + volumeGo.transform.position = center; + volumeGo.transform.rotation = Quaternion.identity; + capsule.isTrigger = true; + capsule.radius = radius; + capsule.height = Mathf.Max(height, radius * 2f + 0.1f); + capsule.center = Vector3.zero; + capsule.enabled = true; + } + + private void DestroyBodyLookVolume() + { + Transform volumeTf = transform.Find(BodyLookVolumeName); + if (volumeTf != null) + Destroy(volumeTf.gameObject); + } + + private bool TryGetBodyBounds(out Bounds bounds) + { + bounds = default; + bool any = false; + + Renderer[] renderers = GetComponentsInChildren(true); + for (int i = 0; i < renderers.Length; i++) + { + Renderer renderer = renderers[i]; + if (renderer == null || !renderer.enabled) + continue; + if (renderer is ParticleSystemRenderer) + continue; + + if (!any) + { + bounds = renderer.bounds; + any = true; + } + else + { + bounds.Encapsulate(renderer.bounds); + } + } + + if (any) + return true; + + Collider[] colliders = GetComponentsInChildren(true); + for (int i = 0; i < colliders.Length; i++) + { + Collider col = colliders[i]; + if (col == null || !col.enabled) + continue; + if (col.transform.name == BodyLookVolumeName) + continue; + + if (!any) + { + bounds = col.bounds; + any = true; + } + else + { + bounds.Encapsulate(col.bounds); + } + } + + return any; + } + + /// + /// Death disables body colliders. Base NetworkChest range then always fails and the loot + /// UI either never stays open or distance-closes every frame — use the body point instead. + /// + public override bool IsPlayerInInteractRange(Vector3 playerPosition) + { + if (!IsCorpseLootable()) + return false; + + return IsBodyInLootRange(playerPosition, InteractRange); + } + + public override bool IsPlayerInRange(Vector3 playerPosition, float range) + { + if (!IsCorpseLootable()) + return false; + + return IsBodyInLootRange(playerPosition, range); + } + + public override bool ServerIsPlayerInRange(Vector3 playerPosition) => + IsCorpseLootable() && IsPlayerInInteractRange(playerPosition); + + private bool IsBodyInLootRange(Vector3 playerPosition, float range) + { + range = Mathf.Clamp(Mathf.Max(0.5f, range), 0.5f, 8f); + Vector3 body = ResolveBodyLootWorldPosition(); + Vector3 delta = playerPosition - body; + float planar = new Vector2(delta.x, delta.z).magnitude; + if (planar > range) + return false; + + return Mathf.Abs(delta.y) <= range + 1.5f; + } } diff --git a/Assets/Scripts/Creatures/Druclad.meta b/Assets/Scripts/Creatures/Druclad.meta new file mode 100644 index 000000000..c50891d47 --- /dev/null +++ b/Assets/Scripts/Creatures/Druclad.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: eb3b8f72db8d4bfb92ade07fb7e90457 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Creatures/Druclad/DrucladAnimCatalog.cs b/Assets/Scripts/Creatures/Druclad/DrucladAnimCatalog.cs new file mode 100644 index 000000000..7bd2f7998 --- /dev/null +++ b/Assets/Scripts/Creatures/Druclad/DrucladAnimCatalog.cs @@ -0,0 +1,220 @@ +using UnityEngine; + +/// +/// Mixamo Humanoid clip filenames + strike timings for Druclad (void fire boss). +/// Clips live under Assets/ThirdParty/DownloadedAssets/Druclad/Clips/ (preferred) +/// or fall back to shared Mixamo FBXs already in the project (Copy From Druclad Avatar). +/// +public static class DrucladAnimCatalog +{ + public const string ClipsFolder = "Assets/ThirdParty/DownloadedAssets/Druclad/Clips"; + public const string RiggedModelPath = + "Assets/ThirdParty/DownloadedAssets/Druclad/Druclad_MixamoRigged.fbx"; + public const string AnimatorPath = "Assets/Animation/Druclad.controller"; + + // --- Locomotion / reaction --- + public const string IdlePreferred = "Monster Idle.fbx"; + public const string IdleFallback = + "Assets/ThirdParty/DownloadedAssets/animated-skeleton-casts-spell-with-hands/Standing 2H Magic Attack 04@Zombie Idle.fbx"; + + public const string WalkPreferred = "Zombie Walk.fbx"; + public const string WalkFallback = + "Assets/ThirdParty/DownloadedAssets/animated-skeleton-casts-spell-with-hands/Standing 2H Magic Attack 04@Walking.fbx"; + + public const string RunPreferred = "Run.fbx"; + public const string RunFallback = + "Assets/ThirdParty/DownloadedAssets/samurai-character/source/Untitled@Running (1).fbx"; + + public const string RoarPreferred = "Roaring.fbx"; + public const string RoarFallback = + "Assets/ThirdParty/DownloadedAssets/animated-skeleton-casts-spell-with-hands/Standing 2H Magic Attack 04@Zombie Attack.fbx"; + + public const string HitPreferred = "Impact.fbx"; + public const string HitFallback = + "Assets/ThirdParty/DownloadedAssets/animated-skeleton-casts-spell-with-hands/Standing 2H Magic Attack 04@Zombie Reaction Hit.fbx"; + + public const string DeathPreferred = "Dying Backwards.fbx"; + public const string DeathFallback = + "Assets/ThirdParty/DownloadedAssets/animated-skeleton-casts-spell-with-hands/Standing 2H Magic Attack 04@Dying Backwards.fbx"; + + // --- Attacks --- + public const string SwipeLPreferred = "Punching.fbx"; + public const string SwipeLFallback = + "Assets/ThirdParty/DownloadedAssets/animated-skeleton-casts-spell-with-hands/Standing 2H Magic Attack 04@Zombie Punching.fbx"; + + public const string SwipeRPreferred = "Hook.fbx"; + public const string SwipeRFallback = + "Assets/ThirdParty/DownloadedAssets/animated-skeleton-casts-spell-with-hands/Standing 2H Magic Attack 04@Zombie Attack (1).fbx"; + + public const string OverheadPreferred = "Axe Overhead.fbx"; + public const string OverheadFallback = + "Assets/ThirdParty/DownloadedAssets/animated-skeleton-casts-spell-with-hands/Standing 2H Magic Attack 04@Zombie Attack.fbx"; + + public const string StompPreferred = "Jump Attack.fbx"; + public const string StompFallback = + "Assets/ThirdParty/DownloadedAssets/animated-skeleton-casts-spell-with-hands/Standing 2H Magic Attack 04@Mutant Jumping.fbx"; + + public const string BreathWindupPreferred = "Spell Casting.fbx"; + public const string BreathWindupFallback = + "Assets/ThirdParty/DownloadedAssets/animated-skeleton-casts-spell-with-hands/Standing 2H Magic Attack 04@Zombie Attack.fbx"; + + public const string HurlPreferred = "Frisbee Throw.fbx"; + public const string HurlFallback = + "Assets/ThirdParty/DownloadedAssets/samurai-character/source/Untitled@Frisbee Throw (1).fbx"; + + public const string ClapPreferred = "Spell Casting Sky.fbx"; + public const string ClapFallback = + "Assets/ThirdParty/DownloadedAssets/animated-skeleton-casts-spell-with-hands/Standing 2H Magic Attack 04@Mutant Jumping.fbx"; + + public const string SpeedParam = "Speed"; + public const string DeadBool = "Dead"; + public const string HurtTrigger = "Hurt"; + public const string DieTrigger = "Die"; + public const string RoarTrigger = "Roar"; + public const string IdleState = "Idle"; + public const string LocomotionState = "Locomotion"; + public const string DieState = "Die"; + + public enum AttackId : byte + { + None = 0, + AshSwipeL = 1, + AshSwipeR = 2, + MagmaStomp = 3, + CinderBolt = 4, + InfernoBreath = 5, + TwinBolts = 6, + EruptionStep = 7, + MeteorRain = 8, + WorldbreakerSlam = 9, + BreathSwipeString = 10, + } + + public readonly struct AttackDef + { + public readonly AttackId Id; + public readonly string StateName; + public readonly string TriggerName; + public readonly string PreferredFileName; + public readonly string FallbackAssetPath; + public readonly float StrikeNormalized; + public readonly float RecoveryNormalized; + public readonly float ApproximateLength; + public readonly float Cooldown; + public readonly byte MinPhase; + public readonly string Notes; + + public AttackDef( + AttackId id, + string stateName, + string triggerName, + string preferredFileName, + string fallbackAssetPath, + float strikeNormalized, + float recoveryNormalized, + float approximateLength, + float cooldown, + byte minPhase, + string notes) + { + Id = id; + StateName = stateName; + TriggerName = triggerName; + PreferredFileName = preferredFileName; + FallbackAssetPath = fallbackAssetPath; + StrikeNormalized = strikeNormalized; + RecoveryNormalized = recoveryNormalized; + ApproximateLength = approximateLength; + Cooldown = cooldown; + MinPhase = minPhase; + Notes = notes; + } + + public float HitDelaySeconds => ApproximateLength * Mathf.Clamp01(StrikeNormalized); + public float LockSeconds => ApproximateLength * Mathf.Clamp01(RecoveryNormalized); + } + + public static readonly AttackDef[] Attacks = + { + new AttackDef( + AttackId.AshSwipeL, "AshSwipeL", "AshSwipeL", + SwipeLPreferred, SwipeLFallback, + strikeNormalized: 0.38f, recoveryNormalized: 0.90f, + approximateLength: 1.15f, cooldown: 1.35f, minPhase: 1, + notes: "Left fist melee cone + light burn."), + new AttackDef( + AttackId.AshSwipeR, "AshSwipeR", "AshSwipeR", + SwipeRPreferred, SwipeRFallback, + strikeNormalized: 0.42f, recoveryNormalized: 0.90f, + approximateLength: 1.20f, cooldown: 1.35f, minPhase: 1, + notes: "Right fist melee cone + light burn."), + new AttackDef( + AttackId.MagmaStomp, "MagmaStomp", "MagmaStomp", + StompPreferred, StompFallback, + strikeNormalized: 0.55f, recoveryNormalized: 0.92f, + approximateLength: 1.55f, cooldown: 3.2f, minPhase: 1, + notes: "Foot plant circle AoE + fire splash."), + new AttackDef( + AttackId.CinderBolt, "CinderBolt", "CinderBolt", + HurlPreferred, HurlFallback, + strikeNormalized: 0.42f, recoveryNormalized: 0.92f, + approximateLength: 1.35f, cooldown: 2.4f, minPhase: 1, + notes: "Single firebolt release."), + new AttackDef( + AttackId.InfernoBreath, "InfernoBreath", "InfernoBreath", + BreathWindupPreferred, BreathWindupFallback, + strikeNormalized: 0.28f, recoveryNormalized: 1.0f, + approximateLength: 2.4f, cooldown: 6.5f, minPhase: 2, + notes: "Windup then sustained cone channel."), + new AttackDef( + AttackId.TwinBolts, "TwinBolts", "TwinBolts", + HurlPreferred, HurlFallback, + strikeNormalized: 0.42f, recoveryNormalized: 0.92f, + approximateLength: 1.40f, cooldown: 3.1f, minPhase: 2, + notes: "Two firebolts with slight spread."), + new AttackDef( + AttackId.EruptionStep, "EruptionStep", "EruptionStep", + StompPreferred, StompFallback, + strikeNormalized: 0.48f, recoveryNormalized: 0.94f, + approximateLength: 1.65f, cooldown: 5.5f, minPhase: 2, + notes: "Dash-stomp then delayed magma puddle."), + new AttackDef( + AttackId.MeteorRain, "MeteorRain", "MeteorRain", + ClapPreferred, ClapFallback, + strikeNormalized: 0.52f, recoveryNormalized: 0.96f, + approximateLength: 2.1f, cooldown: 11f, minPhase: 3, + notes: "Sky clap — rain director spawns delayed meteors."), + new AttackDef( + AttackId.WorldbreakerSlam, "WorldbreakerSlam", "WorldbreakerSlam", + OverheadPreferred, OverheadFallback, + strikeNormalized: 0.58f, recoveryNormalized: 0.98f, + approximateLength: 2.35f, cooldown: 8.5f, minPhase: 3, + notes: "Huge overhead smash; long punish window."), + new AttackDef( + AttackId.BreathSwipeString, "BreathSwipeString", "BreathSwipeString", + BreathWindupPreferred, BreathWindupFallback, + strikeNormalized: 0.30f, recoveryNormalized: 1.0f, + approximateLength: 2.8f, cooldown: 7.5f, minPhase: 3, + notes: "Short breath then immediate swipe."), + }; + + public static bool TryGet(AttackId id, out AttackDef def) + { + for (int i = 0; i < Attacks.Length; i++) + { + if (Attacks[i].Id == id) + { + def = Attacks[i]; + return true; + } + } + + def = default; + return false; + } + + public static AttackDef GetOrDefault(AttackId id) + { + return TryGet(id, out AttackDef def) ? def : Attacks[0]; + } +} diff --git a/Assets/Scripts/Creatures/Druclad/DrucladAnimCatalog.cs.meta b/Assets/Scripts/Creatures/Druclad/DrucladAnimCatalog.cs.meta new file mode 100644 index 000000000..3c640a3dd --- /dev/null +++ b/Assets/Scripts/Creatures/Druclad/DrucladAnimCatalog.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a36243af7fba4a1aa5c900fe73629b83 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Creatures/Druclad/DrucladBodyFireVfx.cs b/Assets/Scripts/Creatures/Druclad/DrucladBodyFireVfx.cs new file mode 100644 index 000000000..d11722c2d --- /dev/null +++ b/Assets/Scripts/Creatures/Druclad/DrucladBodyFireVfx.cs @@ -0,0 +1,96 @@ +using UnityEngine; + +/// +/// Boss-scale body fire driver (Hellhound-style). Prefers Hovl hellhound aura loop, +/// otherwise keeps embedded Vefects layers and scales/tints with health + flares. +/// +[DisallowMultipleComponent] +public sealed class DrucladBodyFireVfx : MonoBehaviour +{ + [SerializeField] private Light glowLight; + [SerializeField] private float minScale = 0.55f; + [SerializeField] private float maxScale = 1.35f; + [SerializeField] private float minLightIntensity = 1.4f; + [SerializeField] private float maxLightIntensity = 6.5f; + [SerializeField] private float enrageFlareFireMultiplier = 1.85f; + [SerializeField] private float enrageFlareLightMultiplier = 1.7f; + [SerializeField] private float enrageFlareRangeBoost = 3.2f; + [SerializeField] private Color healthyTint = new Color(1.4f, 0.68f, 0.16f, 1f); + [SerializeField] private Color woundedTint = new Color(1.05f, 0.3f, 0.05f, 1f); + [SerializeField] private Color dyingTint = new Color(0.48f, 0.12f, 0.04f, 1f); + + private GameObject hovlBodyFire; + private bool usingHovlBodyFire; + + public void ApplyPresentation(float healthRatio, float flareStrength, bool enrageFlare = false) + { + healthRatio = Mathf.Clamp01(healthRatio); + flareStrength = Mathf.Clamp01(flareStrength); + + float flareMul = 1f + flareStrength * 0.95f; + if (enrageFlare) + flareMul = Mathf.Max(flareMul, enrageFlareFireMultiplier); + + float scale = Mathf.Lerp(minScale, maxScale, healthRatio) * flareMul; + transform.localScale = Vector3.one * scale; + + if (glowLight == null) + return; + + Color tint = healthRatio > 0.55f + ? Color.Lerp(woundedTint, healthyTint, (healthRatio - 0.55f) / 0.45f) + : Color.Lerp(dyingTint, woundedTint, healthRatio / 0.55f); + + glowLight.color = tint; + float intensity = Mathf.Lerp(minLightIntensity, maxLightIntensity, healthRatio) * flareMul; + float range = 5.5f + healthRatio * 4.5f; + if (enrageFlare) + { + intensity *= enrageFlareLightMultiplier; + range += enrageFlareRangeBoost; + } + + glowLight.intensity = intensity; + glowLight.range = range; + } + + private void Awake() + { + if (glowLight == null) + glowLight = GetComponentInChildren(true); + TryAttachHovlBodyFire(); + } + + private void TryAttachHovlBodyFire() + { + if (usingHovlBodyFire) + return; + + // Reuse hellhound aura pack — same fire look the player already knows. + hovlBodyFire = CreatureHovlBridge.TryAttachLoop( + CreatureHovlId.Hellhound, + CreatureHovlPhase.Aura, + transform, + 0.95f); + if (hovlBodyFire == null) + return; + + usingHovlBodyFire = true; + DisableLegacyFireParticles(); + } + + private void DisableLegacyFireParticles() + { + ParticleSystem[] systems = GetComponentsInChildren(true); + for (int i = 0; i < systems.Length; i++) + { + ParticleSystem ps = systems[i]; + if (ps == null || (hovlBodyFire != null && ps.transform.IsChildOf(hovlBodyFire.transform))) + continue; + + ParticleSystemRenderer renderer = ps.GetComponent(); + if (renderer != null) + renderer.enabled = false; + } + } +} diff --git a/Assets/Scripts/Creatures/Druclad/DrucladBodyFireVfx.cs.meta b/Assets/Scripts/Creatures/Druclad/DrucladBodyFireVfx.cs.meta new file mode 100644 index 000000000..4b00d8fb0 --- /dev/null +++ b/Assets/Scripts/Creatures/Druclad/DrucladBodyFireVfx.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0d9075eb420946188bc3dec73f43e7b8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Creatures/Druclad/DrucladBrain.cs b/Assets/Scripts/Creatures/Druclad/DrucladBrain.cs new file mode 100644 index 000000000..b478e027d --- /dev/null +++ b/Assets/Scripts/Creatures/Druclad/DrucladBrain.cs @@ -0,0 +1,263 @@ +using UnityEngine; + +/// +/// Phased combat brain for Druclad: close-in / reposition / hold / attack / channel / rain. +/// Weights by phase, distance band, and breath-cone occupancy. +/// +public sealed class DrucladBrain +{ + public enum Intent : byte + { + Hold = 0, + CloseIn = 1, + Reposition = 2, + Attack = 3, + ChannelBreath = 4, + CallRain = 5, + Flee = 6, + Awaken = 7, + Dead = 8, + } + + public struct TickInput + { + public Vector3 selfPosition; + public Vector3 selfForward; + public float now; + public float deltaTime; + public Vector3? targetPosition; + public float targetDistance; + public bool hasTarget; + public bool isDead; + public bool isMoveLocked; + public bool canAttack; + public bool playerOnVoid; + public bool awakening; + public byte phase; + public bool playerInBreathCone; + public bool playerBehind; + public float softEnrage01; + public System.Func isAttackReady; + } + + public struct TickOutput + { + public Intent intent; + public Vector3 desiredFacing; + public Vector3 desiredMove; + public float desiredSpeed; + public DrucladAnimCatalog.AttackId attackId; + public bool triggerAttack; + } + + private readonly float meleeRange; + private readonly float midRange; + private readonly float farRange; + private readonly float flankBias; + private int strafeSign = 1; + private float nextStrafeFlip; + private DrucladAnimCatalog.AttackId lastAttack = DrucladAnimCatalog.AttackId.None; + private float lastRainAt = -999f; + + public DrucladBrain(float meleeRange, float midRange, float farRange, float flankBias) + { + this.meleeRange = Mathf.Max(1.5f, meleeRange); + this.midRange = Mathf.Max(this.meleeRange + 0.5f, midRange); + this.farRange = Mathf.Max(this.midRange + 1f, farRange); + this.flankBias = Mathf.Clamp01(flankBias); + } + + public void Initialize(float now) + { + strafeSign = Random.value > 0.5f ? 1 : -1; + nextStrafeFlip = now + Random.Range(1.2f, 2.4f); + lastAttack = DrucladAnimCatalog.AttackId.None; + lastRainAt = -999f; + } + + public void NotifyAttackCommitted(DrucladAnimCatalog.AttackId id, float now) + { + lastAttack = id; + if (id == DrucladAnimCatalog.AttackId.MeteorRain) + lastRainAt = now; + } + + public TickOutput Tick(in TickInput input) + { + TickOutput output = default; + output.desiredFacing = input.selfForward.sqrMagnitude > 0.001f + ? Flatten(input.selfForward).normalized + : Vector3.forward; + output.desiredMove = Vector3.zero; + output.desiredSpeed = 0f; + output.attackId = DrucladAnimCatalog.AttackId.None; + + if (input.isDead) + { + output.intent = Intent.Dead; + return output; + } + + if (input.awakening) + { + output.intent = Intent.Awaken; + return output; + } + + if (!input.playerOnVoid) + { + output.intent = Intent.Flee; + return output; + } + + if (!input.hasTarget || !input.targetPosition.HasValue) + { + output.intent = Intent.Hold; + return output; + } + + Vector3 toTarget = Flatten(input.targetPosition.Value - input.selfPosition); + if (toTarget.sqrMagnitude < 0.001f) + toTarget = output.desiredFacing; + Vector3 direct = toTarget.normalized; + Vector3 right = Vector3.Cross(Vector3.up, direct).normalized; + output.desiredFacing = direct; + + if (input.isMoveLocked) + { + output.intent = Intent.Hold; + return output; + } + + if (input.now >= nextStrafeFlip) + { + strafeSign *= -1; + nextStrafeFlip = input.now + Random.Range(1.1f, 2.6f); + } + + float dist = input.targetDistance; + bool melee = dist <= meleeRange; + bool mid = dist > meleeRange && dist <= midRange; + bool far = dist > midRange; + + if (input.canAttack) + { + DrucladAnimCatalog.AttackId pick = ChooseAttack(in input, melee, mid, far); + if (pick != DrucladAnimCatalog.AttackId.None) + { + output.triggerAttack = true; + output.attackId = pick; + if (pick == DrucladAnimCatalog.AttackId.InfernoBreath + || pick == DrucladAnimCatalog.AttackId.BreathSwipeString) + { + output.intent = Intent.ChannelBreath; + } + else if (pick == DrucladAnimCatalog.AttackId.MeteorRain) + { + output.intent = Intent.CallRain; + } + else + { + output.intent = Intent.Attack; + } + + return output; + } + } + + if (melee) + { + // Slight back-step / circle so breath and stomp stay readable. + output.intent = Intent.Reposition; + Vector3 circle = (-direct * 0.35f + right * (strafeSign * flankBias)).normalized; + output.desiredMove = circle; + output.desiredSpeed = 0.55f; + return output; + } + + if (mid) + { + output.intent = Intent.Reposition; + Vector3 circle = (direct * (1f - flankBias * 0.55f) + right * (strafeSign * flankBias)).normalized; + output.desiredMove = circle; + output.desiredSpeed = 0.85f; + return output; + } + + output.intent = Intent.CloseIn; + output.desiredMove = direct; + output.desiredSpeed = Mathf.Lerp(0.75f, 1f, Mathf.InverseLerp(midRange, farRange, dist)); + return output; + } + + private DrucladAnimCatalog.AttackId ChooseAttack(in TickInput input, bool melee, bool mid, bool far) + { + byte phase = (byte)Mathf.Clamp(input.phase, 1, 3); + float enrage = Mathf.Clamp01(input.softEnrage01); + bool Ready(DrucladAnimCatalog.AttackId id) + { + if (input.isAttackReady != null && !input.isAttackReady(id)) + return false; + if (!DrucladAnimCatalog.TryGet(id, out DrucladAnimCatalog.AttackDef def)) + return false; + return def.MinPhase <= phase; + } + + // Never back-to-back rain. + float rainGap = Mathf.Lerp(11f, 7.5f, enrage); + bool rainOk = input.now - lastRainAt >= rainGap; + + if (phase >= 3 && rainOk && (far || mid) && Ready(DrucladAnimCatalog.AttackId.MeteorRain) + && lastAttack != DrucladAnimCatalog.AttackId.MeteorRain + && Random.value < 0.38f + enrage * 0.2f) + return DrucladAnimCatalog.AttackId.MeteorRain; + + if (phase >= 3 && melee && Ready(DrucladAnimCatalog.AttackId.WorldbreakerSlam) + && Random.value < 0.28f) + return DrucladAnimCatalog.AttackId.WorldbreakerSlam; + + if (phase >= 3 && mid && Ready(DrucladAnimCatalog.AttackId.BreathSwipeString) + && Random.value < 0.32f) + return DrucladAnimCatalog.AttackId.BreathSwipeString; + + if (phase >= 2 && (mid || input.playerInBreathCone) && Ready(DrucladAnimCatalog.AttackId.InfernoBreath) + && Random.value < (input.playerInBreathCone ? 0.55f : 0.28f)) + return DrucladAnimCatalog.AttackId.InfernoBreath; + + if (phase >= 2 && mid && Ready(DrucladAnimCatalog.AttackId.EruptionStep) + && Random.value < 0.30f) + return DrucladAnimCatalog.AttackId.EruptionStep; + + if (phase >= 2 && (mid || far) && Ready(DrucladAnimCatalog.AttackId.TwinBolts) + && Random.value < 0.34f + enrage * 0.15f) + return DrucladAnimCatalog.AttackId.TwinBolts; + + if (melee && Ready(DrucladAnimCatalog.AttackId.MagmaStomp) && Random.value < 0.36f) + return DrucladAnimCatalog.AttackId.MagmaStomp; + + if ((mid || far) && Ready(DrucladAnimCatalog.AttackId.CinderBolt) && Random.value < 0.42f) + return DrucladAnimCatalog.AttackId.CinderBolt; + + if (melee || mid) + { + bool left = Random.value > 0.5f; + DrucladAnimCatalog.AttackId swipe = left + ? DrucladAnimCatalog.AttackId.AshSwipeL + : DrucladAnimCatalog.AttackId.AshSwipeR; + if (Ready(swipe)) + return swipe; + swipe = left + ? DrucladAnimCatalog.AttackId.AshSwipeR + : DrucladAnimCatalog.AttackId.AshSwipeL; + if (Ready(swipe)) + return swipe; + } + + if (Ready(DrucladAnimCatalog.AttackId.CinderBolt)) + return DrucladAnimCatalog.AttackId.CinderBolt; + + return DrucladAnimCatalog.AttackId.None; + } + + private static Vector3 Flatten(Vector3 v) => new Vector3(v.x, 0f, v.z); +} diff --git a/Assets/Scripts/Creatures/Druclad/DrucladBrain.cs.meta b/Assets/Scripts/Creatures/Druclad/DrucladBrain.cs.meta new file mode 100644 index 000000000..9d30ab058 --- /dev/null +++ b/Assets/Scripts/Creatures/Druclad/DrucladBrain.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e9dc1e8eb5644a0c99ce61a287b34cf7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Creatures/Druclad/DrucladController.cs b/Assets/Scripts/Creatures/Druclad/DrucladController.cs new file mode 100644 index 000000000..aa9378798 --- /dev/null +++ b/Assets/Scripts/Creatures/Druclad/DrucladController.cs @@ -0,0 +1,1082 @@ +using System.Collections; +using System.Collections.Generic; +using InventoryFramework; +using Manafall.Creatures; +using Mirror; +using UnityEngine; + +/// +/// Server-authoritative void-island fire boss. Mixamo Humanoid motor + phased attack kit +/// with animation-synced hit frames (TrollController patterns + KillChallenger-style timings). +/// +[DisallowMultipleComponent] +[RequireComponent(typeof(CharacterController))] +[RequireComponent(typeof(NetworkIdentity))] +[RequireComponent(typeof(Health))] +[RequireComponent(typeof(DrucladFirePresentation))] +[DefaultExecutionOrder(-100)] +public sealed class DrucladController : NetworkBehaviour +{ + private const string PlayerTag = "Player"; + + public enum EncounterPhase : byte + { + Dormant = 0, + Awaken = 1, + Phase1 = 2, + Phase2 = 3, + Phase3 = 4, + Fleeing = 5, + Dead = 6, + } + + [Header("References")] + [SerializeField] private CharacterController characterController; + [SerializeField] private Health health; + [SerializeField] private Animator animator; + [SerializeField] private RuntimeAnimatorController defaultRuntimeAnimatorController; + [SerializeField] private Transform breathMuzzle; + [SerializeField] private Transform castHand; + [SerializeField] private DrucladFireBreathVolume breathVolume; + [SerializeField] private DrucladFirePresentation firePresentation; + [SerializeField] private AudioSource bodyAudio; + [SerializeField] private GameObject lootBagPrefab; + [SerializeField] private DrucladLootTable deathLootTable; + + [Header("Motor")] + [SerializeField] private float gravity = -24f; + [SerializeField] private float walkSpeed = 3.6f; + [SerializeField] private float chaseSpeed = 5.8f; + [SerializeField] private float phase2SpeedMul = 1.12f; + [SerializeField] private float phase3SpeedMul = 1.22f; + [SerializeField] private float turnSpeed = 6.2f; + [SerializeField] private float attackTurnSpeed = 12f; + + [Header("Combat ranges")] + [SerializeField] private float meleeRange = 5.4f; + [SerializeField] private float midRange = 12f; + [SerializeField] private float farRange = 28f; + [SerializeField] private float acquireRange = 64f; + [SerializeField] private float loseTargetRange = 78f; + [SerializeField] private float attackAngleDegrees = 78f; + [SerializeField] private LayerMask meleeHitMask = ~0; + + [Header("Damage")] + [SerializeField] private float swipeDamage = 38f; + [SerializeField] private float stompDamage = 52f; + [SerializeField] private float stompRadius = 6.2f; + [SerializeField] private float boltDamage = 28f; + [SerializeField] private float twinBoltDamage = 22f; + [SerializeField] private float breathDamageMul = 1f; + [SerializeField] private float worldbreakerDamage = 78f; + [SerializeField] private float worldbreakerRadius = 11f; + [SerializeField] private float meteorDamage = 46f; + [SerializeField] private float softEnrageAfterSeconds = 150f; + + [Header("Encounter")] + [SerializeField] private float awakenImmuneSeconds = 1.65f; + [SerializeField] private float fleeGraceSeconds = 4.5f; + [SerializeField] private float voidOccupancyThreshold = 0.18f; + [SerializeField] private float maxHealthBoss = 4200f; + + [Header("Animation")] + [SerializeField] private string speedParam = DrucladAnimCatalog.SpeedParam; + [SerializeField] private string deadBool = DrucladAnimCatalog.DeadBool; + + [SyncVar(hook = nameof(OnDeadChanged))] + private bool isDead; + + [SyncVar] + private float syncedLocomotionSpeed; + + [SyncVar] + private byte syncedPhase; + + [SyncVar(hook = nameof(OnHealthRatioChanged))] + private float syncedHealthRatio = 1f; + + public bool IsDead => isDead; + public EncounterPhase CurrentPhase => (EncounterPhase)syncedPhase; + public byte CombatPhaseByte + { + get + { + return CurrentPhase switch + { + EncounterPhase.Phase1 => 1, + EncounterPhase.Phase2 => 2, + EncounterPhase.Phase3 => 3, + EncounterPhase.Awaken => 1, + _ => 0, + }; + } + } + + private Transform chaseTarget; + private float verticalVelocity; + private float moveLockUntil; + private float immuneUntil; + private float nextRetargetTime; + private float encounterStartedAt = -1f; + private float leftVoidSince = -1f; + private int activeAttackToken; + private Coroutine attackRoutine; + private Coroutine deathRoutine; + private DrucladBrain brain; + private WorldGenerator cachedWorld; + private NetworkAnimator networkAnimator; + private readonly Dictionary attackReadyAt = + new Dictionary(16); + private readonly Collider[] meleeBuffer = new Collider[20]; + private readonly int[] triggerHashes = new int[32]; + private readonly bool[] triggerPresent = new bool[32]; + private int speedHash; + private int deadHash; + private int hurtHash; + private int dieHash; + private int roarHash; + private bool hasSpeed; + private bool hasDead; + private bool hasHurt; + private bool hasDie; + private bool hasRoar; + private bool offlineAiArmed; + private bool awakenPlayed; + private bool phase2Roared; + private bool phase3Roared; + private static bool NetworkSessionActive => NetworkServer.active || NetworkClient.active; + private bool HasAiAuthority => isServer || offlineAiArmed || !NetworkSessionActive; + + private void Awake() + { + characterController ??= GetComponent(); + health ??= GetComponent(); + animator ??= GetComponentInChildren(true); + networkAnimator = GetComponent(); + bodyAudio ??= GetComponent() ?? GetComponentInChildren(true); + if (breathVolume == null) + breathVolume = GetComponent() + ?? gameObject.AddComponent(); + if (firePresentation == null) + firePresentation = GetComponent() + ?? gameObject.AddComponent(); + BootstrapAnimator(); + EnsureCharacterController(); + BindFirePresentation(); + } + + private void OnEnable() + { + BindHealth(); + BootstrapAnimator(); + BindFirePresentation(); + PushHealthRatioToClients(force: true); + } + + private void OnDisable() + { + UnbindHealth(); + breathVolume?.StopChannel(); + } + + public override void OnStartServer() + { + base.OnStartServer(); + offlineAiArmed = false; + ServerBoot(); + } + + public override void OnStartClient() + { + base.OnStartClient(); + BootstrapAnimator(); + BindFirePresentation(); + firePresentation?.SetHealthRatio(syncedHealthRatio); + if (!NetworkServer.active && !NetworkClient.active) + offlineAiArmed = true; + } + + private void Start() + { + if (!NetworkSessionActive) + { + offlineAiArmed = true; + ServerBoot(); + } + } + + private void ServerBoot() + { + if (health != null) + { + health.DestroyOnDeath = false; + if (health.MaxHealth < maxHealthBoss * 0.5f) + health.ConfigureMaxHealth(maxHealthBoss, healToFull: true); + } + + brain ??= new DrucladBrain(meleeRange, midRange, farRange, flankBias: 0.62f); + brain.Initialize(Time.time); + breathVolume?.Bind(this, ResolveBreathMuzzleTransform()); + BindFirePresentation(); + PushHealthRatioToClients(force: true); + syncedPhase = (byte)EncounterPhase.Awaken; + immuneUntil = Time.time + awakenImmuneSeconds; + encounterStartedAt = Time.time; + awakenPlayed = false; + phase2Roared = false; + phase3Roared = false; + attackReadyAt.Clear(); + } + + private void BindFirePresentation() + { + if (firePresentation == null) + return; + + Transform anchor = transform; + if (animator != null) + { + Transform hips = animator.GetBoneTransform(HumanBodyBones.Hips) + ?? animator.GetBoneTransform(HumanBodyBones.Spine); + if (hips != null) + anchor = hips; + } + + firePresentation.Bind(anchor, bodyAudio); + firePresentation.SetHealthRatio(syncedHealthRatio); + } + + private void Update() + { + if (!HasAiAuthority || isDead) + return; + + float dt = Time.deltaTime; + ApplyGravity(dt); + TickEncounter(dt); + if (Time.frameCount % 15 == 0) + PushHealthRatioToClients(); + if (isDead || CurrentPhase == EncounterPhase.Fleeing || CurrentPhase == EncounterPhase.Dormant) + return; + + TickCombat(dt); + } + + private void TickEncounter(float dt) + { + bool onVoid = IsPlayerOnVoid(out Transform nearest); + if (nearest != null && (chaseTarget == null || !IsLivingPlayerTarget(chaseTarget))) + chaseTarget = nearest; + + if (CurrentPhase == EncounterPhase.Awaken) + { + if (!awakenPlayed) + { + awakenPlayed = true; + SetAnimTrigger(DrucladAnimCatalog.RoarTrigger, roarHash, hasRoar); + firePresentation?.TriggerRoarFlare(); + DrucladFireFx.PlayAwakenCrack(GetGroundPoint()); + moveLockUntil = Mathf.Max(moveLockUntil, immuneUntil); + } + + if (Time.time >= immuneUntil) + syncedPhase = (byte)EncounterPhase.Phase1; + return; + } + + if (!onVoid) + { + if (leftVoidSince < 0f) + leftVoidSince = Time.time; + if (Time.time - leftVoidSince >= fleeGraceSeconds) + { + BeginFleeDespawn(); + return; + } + } + else + { + leftVoidSince = -1f; + } + + RefreshPhaseFromHealth(); + } + + private void RefreshPhaseFromHealth() + { + if (health == null || CurrentPhase < EncounterPhase.Phase1 || CurrentPhase > EncounterPhase.Phase3) + return; + + float ratio = health.MaxHealth > 0.01f ? health.CurrentHealth / health.MaxHealth : 1f; + if (ratio <= 0.33f && CurrentPhase != EncounterPhase.Phase3) + { + syncedPhase = (byte)EncounterPhase.Phase3; + if (!phase3Roared) + { + phase3Roared = true; + SetAnimTrigger(DrucladAnimCatalog.RoarTrigger, roarHash, hasRoar); + firePresentation?.TriggerPhaseEnrageFlare(); + moveLockUntil = Time.time + 1.1f; + } + } + else if (ratio <= 0.66f && CurrentPhase == EncounterPhase.Phase1) + { + syncedPhase = (byte)EncounterPhase.Phase2; + if (!phase2Roared) + { + phase2Roared = true; + SetAnimTrigger(DrucladAnimCatalog.RoarTrigger, roarHash, hasRoar); + firePresentation?.TriggerPhaseEnrageFlare(); + moveLockUntil = Time.time + 0.95f; + } + } + } + + private void TickCombat(float dt) + { + if (Time.time >= nextRetargetTime) + { + nextRetargetTime = Time.time + 0.35f; + TryAcquireTarget(); + } + + bool hasTarget = IsLivingPlayerTarget(chaseTarget); + float dist = hasTarget ? PlanarDistance(transform.position, chaseTarget.position) : 999f; + if (hasTarget && dist > loseTargetRange) + { + chaseTarget = null; + hasTarget = false; + } + + float softEnrage = 0f; + if (encounterStartedAt > 0f && softEnrageAfterSeconds > 1f) + softEnrage = Mathf.Clamp01((Time.time - encounterStartedAt) / softEnrageAfterSeconds); + + DrucladBrain.TickInput input = new DrucladBrain.TickInput + { + selfPosition = transform.position, + selfForward = transform.forward, + now = Time.time, + deltaTime = dt, + targetPosition = hasTarget ? chaseTarget.position : (Vector3?)null, + targetDistance = dist, + hasTarget = hasTarget, + isDead = isDead, + isMoveLocked = Time.time < moveLockUntil, + canAttack = Time.time >= moveLockUntil && CurrentPhase >= EncounterPhase.Phase1, + playerOnVoid = IsPointOnVoid(transform.position), + awakening = CurrentPhase == EncounterPhase.Awaken, + phase = CombatPhaseByte, + playerInBreathCone = hasTarget && breathVolume != null + && breathVolume.IsPointInCone(chaseTarget.position + Vector3.up), + playerBehind = hasTarget && Vector3.Dot(Flatten(chaseTarget.position - transform.position), Flatten(transform.forward)) < -0.15f, + softEnrage01 = softEnrage, + isAttackReady = IsAttackReady, + }; + + DrucladBrain.TickOutput output = brain.Tick(in input); + + Vector3 desiredFacing = Flatten(output.desiredFacing); + if (desiredFacing.sqrMagnitude > 0.0001f) + { + float turnRate = Time.time < moveLockUntil ? attackTurnSpeed : turnSpeed; + transform.rotation = Quaternion.Slerp( + transform.rotation, + Quaternion.LookRotation(desiredFacing.normalized, Vector3.up), + turnRate * dt); + } + + if (output.triggerAttack && hasTarget && output.attackId != DrucladAnimCatalog.AttackId.None) + { + BeginAttack(output.attackId); + syncedLocomotionSpeed = 0f; + return; + } + + if (Time.time < moveLockUntil || output.intent == DrucladBrain.Intent.Hold) + { + syncedLocomotionSpeed = 0f; + ApplyAnimatorLocomotion(0f); + return; + } + + Vector3 desiredMove = Flatten(output.desiredMove); + float moveIntent = Mathf.Clamp01(output.desiredSpeed); + if (desiredMove.sqrMagnitude < 0.0001f || moveIntent <= 0.01f) + { + syncedLocomotionSpeed = 0f; + ApplyAnimatorLocomotion(0f); + return; + } + + float speedMul = CurrentPhase switch + { + EncounterPhase.Phase2 => phase2SpeedMul, + EncounterPhase.Phase3 => phase3SpeedMul, + _ => 1f, + }; + float baseSpeed = hasTarget && dist > meleeRange * 0.9f ? chaseSpeed : walkSpeed; + float speed = Mathf.Lerp(walkSpeed * 0.55f, baseSpeed, moveIntent) * speedMul; + syncedLocomotionSpeed = speed; + MovePlanar(desiredMove.normalized * speed, dt); + ApplyAnimatorLocomotion(speed); + } + + private bool IsAttackReady(DrucladAnimCatalog.AttackId id) + { + if (!DrucladAnimCatalog.TryGet(id, out _)) + return false; + if (!attackReadyAt.TryGetValue(id, out float ready)) + return true; + float soft = encounterStartedAt > 0f + ? Mathf.Clamp01((Time.time - encounterStartedAt) / Mathf.Max(1f, softEnrageAfterSeconds)) + : 0f; + // Soft enrage shrinks remaining wait on bolts. + if (id is DrucladAnimCatalog.AttackId.CinderBolt or DrucladAnimCatalog.AttackId.TwinBolts) + ready -= soft * 1.2f; + return Time.time >= ready; + } + + private void BeginAttack(DrucladAnimCatalog.AttackId id) + { + if (!DrucladAnimCatalog.TryGet(id, out DrucladAnimCatalog.AttackDef def)) + return; + + activeAttackToken++; + if (attackRoutine != null) + StopCoroutine(attackRoutine); + brain?.NotifyAttackCommitted(id, Time.time); + float cd = def.Cooldown; + if (encounterStartedAt > 0f) + { + float soft = Mathf.Clamp01((Time.time - encounterStartedAt) / Mathf.Max(1f, softEnrageAfterSeconds)); + if (id is DrucladAnimCatalog.AttackId.CinderBolt or DrucladAnimCatalog.AttackId.TwinBolts) + cd *= Mathf.Lerp(1f, 0.62f, soft); + if (id == DrucladAnimCatalog.AttackId.MeteorRain) + cd *= Mathf.Lerp(1f, 0.72f, soft); + } + + attackReadyAt[id] = Time.time + cd; + firePresentation?.TriggerAttackFlare(); + attackRoutine = StartCoroutine(ServerAttackRoutine(id, def, activeAttackToken)); + } + + private IEnumerator ServerAttackRoutine( + DrucladAnimCatalog.AttackId id, + DrucladAnimCatalog.AttackDef def, + int token) + { + moveLockUntil = Time.time + def.LockSeconds; + SetAnimTrigger(def.TriggerName, ResolveTriggerHash(def.TriggerName), true); + + float hitDelay = def.HitDelaySeconds; + if (hitDelay > 0.02f) + yield return new WaitForSeconds(hitDelay); + + if (token != activeAttackToken || isDead) + yield break; + + ServerApplyAttackHit(id); + + if (id == DrucladAnimCatalog.AttackId.InfernoBreath) + { + firePresentation?.TriggerAttackFlare(); + breathVolume?.BeginChannel(1.85f, breathDamageMul); + yield return new WaitForSeconds(1.85f); + breathVolume?.StopChannel(); + } + else if (id == DrucladAnimCatalog.AttackId.BreathSwipeString) + { + firePresentation?.TriggerAttackFlare(); + breathVolume?.BeginChannel(0.85f, breathDamageMul * 0.85f); + yield return new WaitForSeconds(0.85f); + breathVolume?.StopChannel(); + if (token == activeAttackToken && !isDead) + { + firePresentation?.TriggerAttackFlare(); + ServerApplyAttackHit(DrucladAnimCatalog.AttackId.AshSwipeR); + } + } + + attackRoutine = null; + } + + private void ServerApplyAttackHit(DrucladAnimCatalog.AttackId id) + { + Vector3 origin = transform.position + Vector3.up * (characterController != null ? characterController.height * 0.42f : 2.4f); + Vector3 fwd = Flatten(transform.forward); + if (fwd.sqrMagnitude < 0.001f) + fwd = Vector3.forward; + + switch (id) + { + case DrucladAnimCatalog.AttackId.AshSwipeL: + case DrucladAnimCatalog.AttackId.AshSwipeR: + ApplyMeleeCone(origin, fwd, meleeRange, swipeDamage, 8f, 2.5f, applyBurn: true); + RpcPlayTelegraph(0, origin + fwd * 1.2f); + break; + + case DrucladAnimCatalog.AttackId.MagmaStomp: + { + Vector3 ground = GetGroundPoint(); + ApplyShockwave(ground, stompRadius, stompDamage, 16f, 8f); + DrucladFireFx.PlayGroundSlam(ground, 1.55f); + RpcPlayTelegraph(1, ground); + break; + } + + case DrucladAnimCatalog.AttackId.CinderBolt: + SpawnBolt(spreadYaw: 0f, twinBoltDamage: false); + break; + + case DrucladAnimCatalog.AttackId.TwinBolts: + SpawnBolt(spreadYaw: -9f, twinBoltDamage: true); + SpawnBolt(spreadYaw: 9f, twinBoltDamage: true); + break; + + case DrucladAnimCatalog.AttackId.EruptionStep: + { + if (chaseTarget != null) + { + Vector3 to = Flatten(chaseTarget.position - transform.position); + if (to.sqrMagnitude > 0.01f) + MovePlanar(to.normalized * Mathf.Min(6.5f, to.magnitude * 0.55f), 0.2f); + } + + Vector3 stomp = GetGroundPoint(); + ApplyShockwave(stomp, stompRadius * 0.85f, stompDamage * 0.75f, 12f, 5f); + DrucladFireFx.PlayGroundSlam(stomp, 1.35f); + StartCoroutine(DelayedPuddle(stomp, 0.55f)); + break; + } + + case DrucladAnimCatalog.AttackId.MeteorRain: + { + Vector3 focus = chaseTarget != null ? chaseTarget.position : transform.position + transform.forward * 8f; + DrucladMeteorRainDirector.Play( + this, + focus, + GetGroundPoint(), + meteorCount: Random.Range(4, 7), + ringRadius: 7.5f, + safeRadiusNearBoss: 3.4f, + damage: meteorDamage, + firstDelay: 0.45f, + stagger: 0.28f); + RpcPlayTelegraph(2, focus); + break; + } + + case DrucladAnimCatalog.AttackId.WorldbreakerSlam: + { + Vector3 ground = GetGroundPoint(); + ApplyShockwave(ground, worldbreakerRadius, worldbreakerDamage, 28f, 18f); + DrucladFireFx.PlayGroundSlam(ground, 2.2f); + RpcPlayTelegraph(1, ground); + break; + } + } + } + + private IEnumerator DelayedPuddle(Vector3 point, float delay) + { + yield return new WaitForSeconds(delay); + if (isDead) + yield break; + DrucladMagmaPuddle.Spawn(point, radius: 2.4f, lifetime: 4.2f, damagePerTick: 11f); + } + + private void SpawnBolt(float spreadYaw, bool twinBoltDamage) + { + Vector3 origin = ResolveCastOrigin(); + Vector3 aim = chaseTarget != null + ? (chaseTarget.position + Vector3.up * 1.1f - origin) + : transform.forward; + if (aim.sqrMagnitude < 0.01f) + aim = transform.forward; + aim = Quaternion.Euler(0f, spreadYaw, 0f) * aim.normalized; + + float dmg = twinBoltDamage ? this.twinBoltDamage : boltDamage; + uint netId = netIdentity != null ? netIdentity.netId : 0u; + DrucladFireBoltProjectile.Spawn( + origin, + aim, + chaseTarget, + speed: 22f, + damage: dmg, + lifeSeconds: 3.2f, + homing: 0.55f, + knockPlanar: 7f, + knockVertical: 2.5f, + ownerNetId: netId, + authoritative: true); + + if (isServer) + RpcSpawnBoltVisual(origin, aim, twinBoltDamage); + } + + [ClientRpc] + private void RpcSpawnBoltVisual(Vector3 origin, Vector3 direction, bool twin) + { + if (isServer) + return; + DrucladFireBoltProjectile.Spawn( + origin, + direction, + null, + speed: 22f, + damage: 0f, + lifeSeconds: 3.2f, + homing: 0f, + knockPlanar: 0f, + knockVertical: 0f, + ownerNetId: 0u, + authoritative: false); + } + + [ClientRpc] + private void RpcPlayTelegraph(byte kind, Vector3 point) + { + switch (kind) + { + case 0: + DrucladFireFx.PlayCastBurst(point, transform.forward, 0.7f); + break; + case 1: + DrucladFireFx.PlayGroundSlam(point, 1.2f); + break; + case 2: + SpellFocusVefectsBridge.PlaySmolderingGroundRing(point, Vector3.up, 2.2f, 0.8f, 1f); + break; + } + } + + private void ApplyMeleeCone( + Vector3 origin, + Vector3 forward, + float range, + float damage, + float knockPlanar, + float knockVertical, + bool applyBurn) + { + int count = Physics.OverlapSphereNonAlloc( + origin + forward * (range * 0.45f), + range * 0.55f, + meleeBuffer, + meleeHitMask, + QueryTriggerInteraction.Collide); + + var hitPlayers = new HashSet(); + for (int i = 0; i < count; i++) + { + Collider col = meleeBuffer[i]; + if (col == null || PlayerInteractionSphere.ShouldIgnoreEnemyCombatHit(col)) + continue; + + PlayerHealth player = col.GetComponentInParent(); + if (player == null || player.currentHealth <= 0.5f) + continue; + + if (!hitPlayers.Add(player.GetInstanceID())) + continue; + + Vector3 toPlayer = Flatten(col.transform.position - origin); + if (toPlayer.sqrMagnitude < 0.01f) + toPlayer = forward; + toPlayer.Normalize(); + if (Vector3.Angle(forward, toPlayer) > attackAngleDegrees * 0.5f) + continue; + + Vector3 knock = toPlayer * knockPlanar + Vector3.up * knockVertical; + player.TakeDamage(damage, origin, knock); + if (applyBurn) + SpellEffectCatalog.ApplyToPlayer(player, SpellEffectId.Burning, 2.8f); + } + } + + private void ApplyShockwave(Vector3 center, float radius, float damage, float knockPlanar, float knockLift) + { + int count = Physics.OverlapSphereNonAlloc(center, radius, meleeBuffer, meleeHitMask, QueryTriggerInteraction.Collide); + var hitPlayers = new HashSet(); + for (int i = 0; i < count; i++) + { + Collider col = meleeBuffer[i]; + if (col == null || PlayerInteractionSphere.ShouldIgnoreEnemyCombatHit(col)) + continue; + + PlayerHealth player = col.GetComponentInParent(); + if (player == null || player.currentHealth <= 0.5f) + continue; + if (!hitPlayers.Add(player.GetInstanceID())) + continue; + + Vector3 away = Flatten(player.transform.position - center); + if (away.sqrMagnitude < 0.01f) + away = transform.forward; + away.Normalize(); + player.TakeDamage(damage, center, away * knockPlanar + Vector3.up * knockLift); + SpellEffectCatalog.ApplyToPlayer(player, SpellEffectId.Burning, 2.4f); + } + } + + private void TryAcquireTarget() + { + if (IsLivingPlayerTarget(chaseTarget)) + return; + + PlayerHealth[] players = FindObjectsByType(FindObjectsSortMode.None); + Transform best = null; + float bestDist = acquireRange; + for (int i = 0; i < players.Length; i++) + { + PlayerHealth p = players[i]; + if (p == null || p.currentHealth <= 0.5f) + continue; + if (!IsPointOnVoid(p.transform.position)) + continue; + float d = PlanarDistance(transform.position, p.transform.position); + if (d < bestDist) + { + bestDist = d; + best = p.transform; + } + } + + chaseTarget = best; + } + + private bool IsPlayerOnVoid(out Transform nearest) + { + nearest = null; + float best = float.MaxValue; + PlayerHealth[] players = FindObjectsByType(FindObjectsSortMode.None); + for (int i = 0; i < players.Length; i++) + { + PlayerHealth p = players[i]; + if (p == null || p.currentHealth <= 0.5f) + continue; + if (!IsPointOnVoid(p.transform.position)) + continue; + float d = PlanarDistance(transform.position, p.transform.position); + if (d < best) + { + best = d; + nearest = p.transform; + } + } + + return nearest != null || IsPointOnVoid(transform.position); + } + + private bool IsPointOnVoid(Vector3 world) + { + cachedWorld ??= FindFirstObjectByType(); + BiomeSettings settings = cachedWorld != null ? cachedWorld.BiomeSettings : null; + int seed = cachedWorld != null ? cachedWorld.TerrainSeed : 0; + float void01 = BiomeClimateSampler.SampleVoidDomain01( + Mathf.RoundToInt(world.x), + Mathf.RoundToInt(world.z), + seed, + settings); + return void01 >= voidOccupancyThreshold; + } + + private void BeginFleeDespawn() + { + if (CurrentPhase == EncounterPhase.Fleeing || isDead) + return; + + syncedPhase = (byte)EncounterPhase.Fleeing; + breathVolume?.StopChannel(); + DrucladWorldFlags.MarkAliveFled(); + if (isServer && netIdentity != null) + { + NetworkServer.Destroy(gameObject); + } + else + { + Destroy(gameObject); + } + } + + private void BindHealth() + { + if (health == null) + return; + health.onDeath.RemoveListener(HandleDeath); + health.onDeath.AddListener(HandleDeath); + health.DamagedFromWorld -= OnDamagedFromWorld; + health.DamagedFromWorld += OnDamagedFromWorld; + } + + private void UnbindHealth() + { + if (health == null) + return; + health.onDeath.RemoveListener(HandleDeath); + health.DamagedFromWorld -= OnDamagedFromWorld; + } + + private void OnDamagedFromWorld(float amount, Vector3? source, bool concealAttackerIdentity) + { + if (isDead) + return; + + // Short immune window during awaken roar. + if (Time.time < immuneUntil) + { + if (health != null && amount > 0f) + health.Heal(amount); + return; + } + + PushHealthRatioToClients(); + firePresentation?.TriggerHurtFlare(); + + // Phase 1 only: brief hit react. Later phases ignore stun. + if (CurrentPhase == EncounterPhase.Phase1 && amount > 8f && Time.time >= moveLockUntil) + { + SetAnimTrigger(DrucladAnimCatalog.HurtTrigger, hurtHash, hasHurt); + moveLockUntil = Time.time + 0.28f; + } + } + + private void HandleDeath() + { + if (isDead) + return; + isDead = true; + syncedPhase = (byte)EncounterPhase.Dead; + syncedHealthRatio = 0f; + breathVolume?.StopChannel(); + firePresentation?.BeginDeathSmolder(); + if (HasAiAuthority) + ServerBeginDeath(); + } + + private void ServerBeginDeath() + { + DrucladWorldFlags.MarkDead(); + SetAnimTrigger(DrucladAnimCatalog.DieTrigger, dieHash, hasDie); + if (hasDead) + animator.SetBool(deadHash, true); + if (deathRoutine != null) + StopCoroutine(deathRoutine); + deathRoutine = StartCoroutine(DeathRoutine()); + } + + private void PushHealthRatioToClients(bool force = false) + { + if (health == null || health.MaxHealth <= 0.01f) + return; + + float ratio = Mathf.Clamp01(health.CurrentHealth / health.MaxHealth); + if (!force && Mathf.Abs(ratio - syncedHealthRatio) < 0.02f) + return; + + syncedHealthRatio = ratio; + firePresentation?.SetHealthRatio(ratio); + } + + private void OnHealthRatioChanged(float _, float newV) + { + firePresentation?.SetHealthRatio(newV); + } + + private IEnumerator DeathRoutine() + { + if (deathLootTable != null) + deathLootTable.TrySpawnDrops(transform, lootBagPrefab); + + yield return new WaitForSeconds(4.2f); + if (isServer && netIdentity != null) + NetworkServer.Destroy(gameObject); + else + Destroy(gameObject); + } + + private void OnDeadChanged(bool _, bool nowDead) + { + if (!nowDead || animator == null) + return; + if (hasDead) + animator.SetBool(deadHash, true); + SetAnimTrigger(DrucladAnimCatalog.DieTrigger, dieHash, hasDie); + } + + private void BootstrapAnimator() + { + networkAnimator ??= GetComponent(); + animator ??= GetComponentInChildren(true); + if (animator == null) + return; + + if (animator.runtimeAnimatorController == null && defaultRuntimeAnimatorController != null) + animator.runtimeAnimatorController = defaultRuntimeAnimatorController; + + animator.cullingMode = AnimatorCullingMode.AlwaysAnimate; + animator.updateMode = AnimatorUpdateMode.Normal; + animator.keepAnimatorStateOnDisable = true; + if (networkAnimator != null && networkAnimator.animator == null) + networkAnimator.animator = animator; + + hasSpeed = ManafallAnimatorUtility.HasParameter(animator, speedParam, AnimatorControllerParameterType.Float); + hasDead = ManafallAnimatorUtility.HasParameter(animator, deadBool, AnimatorControllerParameterType.Bool); + hasHurt = ManafallAnimatorUtility.HasParameter(animator, DrucladAnimCatalog.HurtTrigger, AnimatorControllerParameterType.Trigger); + hasDie = ManafallAnimatorUtility.HasParameter(animator, DrucladAnimCatalog.DieTrigger, AnimatorControllerParameterType.Trigger); + hasRoar = ManafallAnimatorUtility.HasParameter(animator, DrucladAnimCatalog.RoarTrigger, AnimatorControllerParameterType.Trigger); + if (hasSpeed) speedHash = Animator.StringToHash(speedParam); + if (hasDead) deadHash = Animator.StringToHash(deadBool); + if (hasHurt) hurtHash = Animator.StringToHash(DrucladAnimCatalog.HurtTrigger); + if (hasDie) dieHash = Animator.StringToHash(DrucladAnimCatalog.DieTrigger); + if (hasRoar) roarHash = Animator.StringToHash(DrucladAnimCatalog.RoarTrigger); + + for (int i = 0; i < DrucladAnimCatalog.Attacks.Length; i++) + { + string trigger = DrucladAnimCatalog.Attacks[i].TriggerName; + triggerHashes[i] = Animator.StringToHash(trigger); + triggerPresent[i] = ManafallAnimatorUtility.HasParameter( + animator, trigger, AnimatorControllerParameterType.Trigger); + } + } + + private int ResolveTriggerHash(string triggerName) + { + for (int i = 0; i < DrucladAnimCatalog.Attacks.Length; i++) + { + if (DrucladAnimCatalog.Attacks[i].TriggerName == triggerName) + return triggerHashes[i]; + } + + return Animator.StringToHash(triggerName); + } + + private void SetAnimTrigger(string triggerName, int hash, bool present) + { + if (animator == null || string.IsNullOrEmpty(triggerName)) + return; + + bool ok = present; + if (!ok) + ok = ManafallAnimatorUtility.HasParameter(animator, triggerName, AnimatorControllerParameterType.Trigger); + if (!ok) + return; + + if (networkAnimator != null && NetworkServer.active) + networkAnimator.SetTrigger(triggerName); + else + animator.SetTrigger(hash != 0 ? hash : Animator.StringToHash(triggerName)); + } + + private void ApplyAnimatorLocomotion(float speed) + { + if (!hasSpeed || animator == null) + return; + animator.SetFloat(speedHash, speed); + } + + private void EnsureCharacterController() + { + if (characterController == null) + return; + characterController.height = Mathf.Max(characterController.height, 3.6f); + characterController.radius = Mathf.Max(characterController.radius, 0.95f); + characterController.center = new Vector3(0f, characterController.height * 0.5f, 0f); + characterController.slopeLimit = 50f; + characterController.stepOffset = 0.6f; + } + + private void ApplyGravity(float dt) + { + if (characterController == null || !characterController.enabled) + return; + + if (characterController.isGrounded && verticalVelocity < 0f) + verticalVelocity = -2f; + else + verticalVelocity += gravity * dt; + + characterController.Move(Vector3.up * (verticalVelocity * dt)); + } + + private void MovePlanar(Vector3 planar, float dt) + { + if (characterController == null || !characterController.enabled) + { + transform.position += planar * dt; + return; + } + + Vector3 motion = planar; + if (characterController.isGrounded && verticalVelocity < 0f) + verticalVelocity = -2f; + else + verticalVelocity += gravity * dt; + motion.y = verticalVelocity; + characterController.Move(motion * dt); + } + + private Transform ResolveBreathMuzzleTransform() + { + if (breathMuzzle != null) + return breathMuzzle; + if (animator != null) + { + Transform head = animator.GetBoneTransform(HumanBodyBones.Head); + if (head != null) + return head; + } + + return transform; + } + + private Vector3 ResolveCastOrigin() + { + if (castHand != null) + return castHand.position; + if (animator != null) + { + Transform hand = animator.GetBoneTransform(HumanBodyBones.RightHand) + ?? animator.GetBoneTransform(HumanBodyBones.LeftHand); + if (hand != null) + return hand.position; + } + + return transform.position + Vector3.up * 2.2f + transform.forward * 0.9f; + } + + private Vector3 GetGroundPoint() + { + Vector3 p = transform.position; + if (Physics.Raycast(p + Vector3.up * 1.5f, Vector3.down, out RaycastHit hit, 6f)) + return hit.point; + return p; + } + + private static bool IsLivingPlayerTarget(Transform t) + { + if (t == null) + return false; + PlayerHealth hp = t.GetComponent() ?? t.GetComponentInParent(); + return hp != null && hp.currentHealth > 0.5f; + } + + private static float PlanarDistance(Vector3 a, Vector3 b) + { + float dx = a.x - b.x; + float dz = a.z - b.z; + return Mathf.Sqrt(dx * dx + dz * dz); + } + + private static Vector3 Flatten(Vector3 v) => new Vector3(v.x, 0f, v.z); + +#if UNITY_EDITOR + private void OnValidate() + { + characterController ??= GetComponent(); + health ??= GetComponent(); + animator ??= GetComponentInChildren(true); + } +#endif +} diff --git a/Assets/Scripts/Creatures/Druclad/DrucladController.cs.meta b/Assets/Scripts/Creatures/Druclad/DrucladController.cs.meta new file mode 100644 index 000000000..ca4ae707a --- /dev/null +++ b/Assets/Scripts/Creatures/Druclad/DrucladController.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f47593d0f38241f39f6f7004194e43b4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Creatures/Druclad/DrucladFireBoltProjectile.cs b/Assets/Scripts/Creatures/Druclad/DrucladFireBoltProjectile.cs new file mode 100644 index 000000000..581692af2 --- /dev/null +++ b/Assets/Scripts/Creatures/Druclad/DrucladFireBoltProjectile.cs @@ -0,0 +1,139 @@ +using InventoryFramework; +using UnityEngine; + +/// Lightweight homing-ish firebolt used by Druclad cinder / twin bolts. +[DisallowMultipleComponent] +public sealed class DrucladFireBoltProjectile : MonoBehaviour +{ + private Vector3 velocity; + private Transform homingTarget; + private float homingStrength; + private float damage; + private float lifeUntil; + private float knockPlanar; + private float knockVertical; + private bool authoritative; + private bool consumed; + private readonly Collider[] hits = new Collider[8]; + + public static DrucladFireBoltProjectile Spawn( + Vector3 origin, + Vector3 direction, + Transform target, + float speed, + float damage, + float lifeSeconds, + float homing, + float knockPlanar, + float knockVertical, + uint ownerNetId, + bool authoritative) + { + var go = new GameObject("DrucladFireBolt"); + go.transform.SetPositionAndRotation( + origin, + Quaternion.LookRotation(direction.sqrMagnitude > 0.01f ? direction.normalized : Vector3.forward)); + + var bolt = go.AddComponent(); + bolt.velocity = direction.normalized * Mathf.Max(4f, speed); + bolt.homingTarget = target; + bolt.homingStrength = Mathf.Clamp01(homing); + bolt.damage = Mathf.Max(1f, damage); + bolt.lifeUntil = Time.time + Mathf.Clamp(lifeSeconds, 0.4f, 6f); + bolt.knockPlanar = knockPlanar; + bolt.knockVertical = knockVertical; + bolt.authoritative = authoritative; + _ = ownerNetId; + + var sphere = go.AddComponent(); + sphere.isTrigger = true; + sphere.radius = 0.28f; + + var rb = go.AddComponent(); + rb.isKinematic = true; + rb.useGravity = false; + + DrucladFireFx.AttachBoltVisual(go.transform, 0.9f); + DrucladFireFx.PlayCastBurst(origin, direction, 0.75f); + Object.Destroy(go, Mathf.Clamp(lifeSeconds, 0.5f, 6.5f) + 0.25f); + return bolt; + } + + private void Update() + { + if (consumed) + return; + + if (Time.time >= lifeUntil) + { + Destroy(gameObject); + return; + } + + if (homingTarget != null && homingStrength > 0.01f) + { + Vector3 to = homingTarget.position + Vector3.up * 1.1f - transform.position; + if (to.sqrMagnitude > 0.01f) + { + Vector3 desired = to.normalized * velocity.magnitude; + velocity = Vector3.Lerp(velocity, desired, homingStrength * Time.deltaTime * 4.5f); + } + } + + Vector3 next = transform.position + velocity * Time.deltaTime; + if (velocity.sqrMagnitude > 0.01f) + transform.rotation = Quaternion.LookRotation(velocity.normalized, Vector3.up); + transform.position = next; + + if (!authoritative) + return; + + int count = Physics.OverlapSphereNonAlloc(transform.position, 0.4f, hits, ~0, QueryTriggerInteraction.Collide); + for (int i = 0; i < count; i++) + { + Collider col = hits[i]; + if (col == null || PlayerInteractionSphere.ShouldIgnoreEnemyCombatHit(col)) + continue; + + if (col.GetComponentInParent() != null) + continue; + + PlayerHealth player = col.GetComponentInParent(); + if (player != null && player.currentHealth > 0.5f) + { + ApplyHit(player); + return; + } + + Health creature = CreatureMeleeHurtbox.ResolveHealth(col); + if (creature != null && !creature.IsDead) + { + creature.TakeDamage(damage, transform.position); + Explode(); + return; + } + } + } + + private void ApplyHit(PlayerHealth player) + { + Vector3 away = player.transform.position - transform.position; + away.y = 0f; + if (away.sqrMagnitude < 0.01f) + away = transform.forward; + away.Normalize(); + Vector3 knock = away * knockPlanar + Vector3.up * knockVertical; + player.TakeDamage(damage, transform.position, knock); + SpellEffectCatalog.ApplyToPlayer(player, SpellEffectId.Burning, 2.2f); + Explode(); + } + + private void Explode() + { + if (consumed) + return; + consumed = true; + DrucladFireFx.PlayImpact(transform.position, -transform.forward, 0.95f); + Destroy(gameObject); + } +} diff --git a/Assets/Scripts/Creatures/Druclad/DrucladFireBoltProjectile.cs.meta b/Assets/Scripts/Creatures/Druclad/DrucladFireBoltProjectile.cs.meta new file mode 100644 index 000000000..3b104f908 --- /dev/null +++ b/Assets/Scripts/Creatures/Druclad/DrucladFireBoltProjectile.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 69019902bbbb47f8a023acdb4ce5b2ea +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Creatures/Druclad/DrucladFireBreathVolume.cs b/Assets/Scripts/Creatures/Druclad/DrucladFireBreathVolume.cs new file mode 100644 index 000000000..6d10f1663 --- /dev/null +++ b/Assets/Scripts/Creatures/Druclad/DrucladFireBreathVolume.cs @@ -0,0 +1,146 @@ +using System.Collections.Generic; +using InventoryFramework; +using UnityEngine; + +/// Server-side sustained inferno breath cone with tick damage + brief scorch patches. +[DisallowMultipleComponent] +public sealed class DrucladFireBreathVolume : MonoBehaviour +{ + [SerializeField] private float coneLength = 14f; + [SerializeField] private float coneHalfAngleDegrees = 28f; + [SerializeField] private float tickInterval = 0.22f; + [SerializeField] private float damagePerTick = 9f; + [SerializeField] private float burnDuration = 2.4f; + [SerializeField] private float scorchPatchLifetime = 1.6f; + [SerializeField] private LayerMask hitMask = ~0; + + private readonly Collider[] overlap = new Collider[24]; + private readonly Dictionary nextTick = new Dictionary(8); + private Transform muzzle; + private bool channeling; + private float channelUntil; + private DrucladController owner; + + public bool IsChanneling => channeling && Time.time < channelUntil; + public float ConeLength => coneLength; + public float ConeHalfAngleDegrees => coneHalfAngleDegrees; + + public void Bind(DrucladController controller, Transform breathMuzzle) + { + owner = controller; + muzzle = breathMuzzle != null ? breathMuzzle : transform; + } + + public void BeginChannel(float durationSeconds, float damageMul = 1f) + { + channeling = true; + channelUntil = Time.time + Mathf.Max(0.35f, durationSeconds); + damagePerTick = Mathf.Max(4f, 9f * Mathf.Max(0.5f, damageMul)); + nextTick.Clear(); + DrucladFireFx.PlayCastBurst(ResolveOrigin(), ResolveForward(), 1.25f); + } + + public void StopChannel() + { + channeling = false; + channelUntil = 0f; + nextTick.Clear(); + } + + public bool IsPointInCone(Vector3 worldPoint) + { + Vector3 origin = ResolveOrigin(); + Vector3 fwd = ResolveForward(); + Vector3 to = worldPoint - origin; + float dist = to.magnitude; + if (dist < 0.2f) + return true; + if (dist > coneLength) + return false; + return Vector3.Angle(fwd, to) <= coneHalfAngleDegrees; + } + + private void LateUpdate() + { + if (!channeling) + return; + + if (Time.time >= channelUntil) + { + StopChannel(); + return; + } + + Vector3 origin = ResolveOrigin(); + Vector3 fwd = ResolveForward(); + transform.SetPositionAndRotation(origin, Quaternion.LookRotation(fwd, Vector3.up)); + + int count = Physics.OverlapSphereNonAlloc( + origin + fwd * (coneLength * 0.45f), + coneLength * 0.55f, + overlap, + hitMask, + QueryTriggerInteraction.Collide); + + float now = Time.time; + for (int i = 0; i < count; i++) + { + Collider col = overlap[i]; + if (col == null || PlayerInteractionSphere.ShouldIgnoreEnemyCombatHit(col)) + continue; + + PlayerHealth player = col.GetComponentInParent(); + if (player == null || player.currentHealth <= 0.5f) + continue; + + if (!IsPointInCone(player.transform.position + Vector3.up * 0.9f)) + continue; + + int id = player.GetInstanceID(); + if (nextTick.TryGetValue(id, out float ready) && now < ready) + continue; + + nextTick[id] = now + tickInterval; + Vector3 knock = Flatten(player.transform.position - origin).normalized * 2.4f + Vector3.up * 0.8f; + player.TakeDamage(damagePerTick, origin, knock); + SpellEffectCatalog.ApplyToPlayer(player, SpellEffectId.Burning, burnDuration); + + Vector3 foot = player.transform.position; + if (Physics.Raycast(foot + Vector3.up * 1.2f, Vector3.down, out RaycastHit hit, 3f, hitMask)) + foot = hit.point; + DrucladMagmaPuddle.Spawn(foot, radius: 1.1f, lifetime: scorchPatchLifetime, damagePerTick: damagePerTick * 0.45f); + } + } + + private Vector3 ResolveOrigin() + { + if (muzzle != null) + return muzzle.position; + if (owner != null) + return owner.transform.position + Vector3.up * 2.4f + owner.transform.forward * 0.8f; + return transform.position; + } + + private Vector3 ResolveForward() + { + if (muzzle != null && muzzle.forward.sqrMagnitude > 0.01f) + { + Vector3 f = muzzle.forward; + f.y = 0f; + if (f.sqrMagnitude > 0.01f) + return f.normalized; + } + + if (owner != null) + { + Vector3 f = owner.transform.forward; + f.y = 0f; + if (f.sqrMagnitude > 0.01f) + return f.normalized; + } + + return Vector3.forward; + } + + private static Vector3 Flatten(Vector3 v) => new Vector3(v.x, 0f, v.z); +} diff --git a/Assets/Scripts/Creatures/Druclad/DrucladFireBreathVolume.cs.meta b/Assets/Scripts/Creatures/Druclad/DrucladFireBreathVolume.cs.meta new file mode 100644 index 000000000..b0b7070b4 --- /dev/null +++ b/Assets/Scripts/Creatures/Druclad/DrucladFireBreathVolume.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4dbdc979e48a4bbdbbfe44b9e6198dae +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Creatures/Druclad/DrucladFireFx.cs b/Assets/Scripts/Creatures/Druclad/DrucladFireFx.cs new file mode 100644 index 000000000..994de06c8 --- /dev/null +++ b/Assets/Scripts/Creatures/Druclad/DrucladFireFx.cs @@ -0,0 +1,81 @@ +using InventoryFramework; +using UnityEngine; + +/// Thin wrappers over SpellFocus / volcano / troll slam FX for Druclad combat beats. +public static class DrucladFireFx +{ + private static readonly SpellFocusVfxProfile DefaultProfile = new SpellFocusVfxProfile( + scale: 1.15f, + diameter: 0.55f, + trailTime: 0.35f, + trailStartWidth: 0.28f, + trailEndWidth: 0.04f, + castIntensity: 1.1f, + impactIntensity: 1.2f, + orbPulseSpeed: 1.4f); + + public static void PlayCastBurst(Vector3 origin, Vector3 direction, float scale = 1.1f) + { + Vector3 fwd = direction.sqrMagnitude > 0.01f ? direction.normalized : Vector3.forward; + SpellFocusVefectsBridge.PlayFireCast(origin, Quaternion.LookRotation(fwd), scale); + SpellFocusFireVfx.TryPlayCast( + origin, + fwd, + EnchantAspect.FireballFocus, + DefaultProfile, + powerLevel: 28); + } + + public static void PlayImpact(Vector3 point, Vector3 normal, float scale = 1.2f) + { + Vector3 up = normal.sqrMagnitude > 0.01f ? normal.normalized : Vector3.up; + Quaternion rot = Quaternion.LookRotation(Vector3.Cross(up, Vector3.right).normalized, up); + SpellFocusVefectsBridge.PlayFireImpact(point, rot, scale); + SpellFocusFireVfx.TryPlayImpact( + point, + up, + EnchantAspect.FireballFocus, + DefaultProfile, + powerLevel: 30); + } + + public static void PlayGroundSlam(Vector3 groundPoint, float strength = 1.6f) + { + TrollGroundSlamFx.Play(groundPoint, strength); + PlayImpact(groundPoint + Vector3.up * 0.05f, Vector3.up, 0.85f + strength * 0.25f); + SpellFocusVefectsBridge.PlaySmolderingGroundRing( + groundPoint, + Vector3.up, + radius: 1.4f + strength * 0.55f, + duration: 2.4f, + patchScale: 0.9f + strength * 0.2f); + } + + public static void PlayMeteorImpact(Vector3 point, float scale = 1.35f) + { + PlayImpact(point, Vector3.up, scale); + SpellFocusFireVfx.TryPlayPierceImpact(point, Vector3.up, DefaultProfile, powerLevel: 34); + SpellFocusVefectsBridge.PlaySmolderingGroundRing( + point, + Vector3.up, + radius: 2.1f * scale, + duration: 3.2f, + patchScale: 1.15f * scale); + } + + public static void PlayAwakenCrack(Vector3 groundPoint) + { + PlayGroundSlam(groundPoint, 1.85f); + SpellFocusVefectsBridge.PlayFireCast( + groundPoint + Vector3.up * 0.4f, + Quaternion.LookRotation(Vector3.up), + 1.45f); + } + + public static void AttachBoltVisual(Transform root, float scale = 0.85f) + { + if (root == null) + return; + SpellFocusVefectsBridge.AttachFireBolt(root, scale, EnchantAspect.FireballFocus, powerLevel: 26); + } +} diff --git a/Assets/Scripts/Creatures/Druclad/DrucladFireFx.cs.meta b/Assets/Scripts/Creatures/Druclad/DrucladFireFx.cs.meta new file mode 100644 index 000000000..23f34c50b --- /dev/null +++ b/Assets/Scripts/Creatures/Druclad/DrucladFireFx.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b0650c336cda46b18c15e26f73855540 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Creatures/Druclad/DrucladFirePresentation.cs b/Assets/Scripts/Creatures/Druclad/DrucladFirePresentation.cs new file mode 100644 index 000000000..2eb7c8ef2 --- /dev/null +++ b/Assets/Scripts/Creatures/Druclad/DrucladFirePresentation.cs @@ -0,0 +1,320 @@ +using InventoryFramework; +using UnityEngine; + +/// +/// Hellhound-style body fire for Druclad: persistent flames scale with HP, flare on roar/hurt/attacks. +/// +[DisallowMultipleComponent] +public sealed class DrucladFirePresentation : MonoBehaviour +{ + public const string HellhoundBodyFirePrefabPath = + "Assets/GameObjects/Prefabs/VFX/HellhoundBodyFire.prefab"; + public const string EmbeddedChildName = "DrucladBodyFire"; + + [Header("Anchors")] + [SerializeField] private Transform bodyAnchor; + [SerializeField] private AudioSource bodyAudio; + + [Header("Fire VFX")] + [Tooltip("Child on the Druclad prefab (preferred).")] + [SerializeField] private Transform bodyFireInstance; + [Tooltip("Usually the HellhoundBodyFire prefab, scaled up for the boss.")] + [SerializeField] private GameObject bodyFirePrefab; + [SerializeField] private Vector3 bodyFireLocalOffset = new Vector3(0f, 1.15f, 0.1f); + [SerializeField] private float bodyFireBaseScale = 1.85f; + + [Header("Fire palette")] + [SerializeField] private Color healthyFireColor = new Color(1.4f, 0.68f, 0.16f, 1f); + [SerializeField] private Color woundedFireColor = new Color(1.05f, 0.3f, 0.05f, 1f); + [SerializeField] private Color dyingFireColor = new Color(0.48f, 0.12f, 0.04f, 1f); + + [Header("Flares")] + [SerializeField] private float roarFlareDuration = 0.85f; + [SerializeField] private float attackFlareDuration = 0.55f; + [SerializeField] private float hurtFlareDuration = 0.9f; + [SerializeField] private float hurtFlareStrength = 1.5f; + [SerializeField] private float phaseEnrageFlareDuration = 1.15f; + [SerializeField] private float vocalPitchBoost = 0.18f; + [SerializeField] private float vocalVolumeBoost = 0.32f; + + private Transform bodyFireRoot; + private DrucladBodyFireVfx bodyFireVfx; + private HellhoundBodyFireVfx hellhoundBodyFireVfx; + private Light bodyGlow; + private float healthRatio = 1f; + private float flareTimer; + private float flareStrength; + private float hurtFlareTimer; + private float hurtFlareStrengthActive; + private float enrageFlareTimer; + private float baseAudioPitch = 1f; + private float baseAudioVolume = 1f; + private bool bodyHidden; + + public void Bind(Transform anchor, AudioSource audio) + { + bodyAnchor = anchor != null ? anchor : transform; + bodyAudio = audio; + if (bodyAudio != null) + { + baseAudioPitch = bodyAudio.pitch; + baseAudioVolume = bodyAudio.volume; + } + + EnsureBuilt(); + } + + public void SetHealthRatio(float ratio) => healthRatio = Mathf.Clamp01(ratio); + + public void TriggerRoarFlare() + { + flareTimer = roarFlareDuration; + flareStrength = 1f; + EnsureFireVisible(); + } + + public void TriggerAttackFlare() + { + flareTimer = Mathf.Max(flareTimer, attackFlareDuration); + flareStrength = Mathf.Max(flareStrength, 0.85f); + EnsureFireVisible(); + } + + public void TriggerHurtFlare() + { + hurtFlareTimer = hurtFlareDuration; + hurtFlareStrengthActive = hurtFlareStrength; + flareTimer = Mathf.Max(flareTimer, hurtFlareDuration * 0.85f); + flareStrength = Mathf.Max(flareStrength, hurtFlareStrength); + EnsureFireVisible(); + } + + public void TriggerPhaseEnrageFlare() + { + enrageFlareTimer = phaseEnrageFlareDuration; + TriggerRoarFlare(); + } + + public void BeginDeathSmolder() + { + healthRatio = 0f; + TriggerHurtFlare(); + if (bodyFireRoot != null) + { + SpellFocusVefectsBridge.PlayFireCast( + bodyFireRoot.position, + Quaternion.identity, + bodyFireBaseScale * 1.1f); + } + } + + private void Awake() + { + if (bodyAnchor == null) + bodyAnchor = transform; + EnsureBuilt(); + } + + private void Update() + { + float dt = Time.deltaTime; + + if (flareTimer > 0f) + { + flareTimer -= dt; + flareStrength = Mathf.Clamp01(flareTimer / Mathf.Max(0.05f, roarFlareDuration)); + } + else + { + flareStrength = 0f; + } + + if (hurtFlareTimer > 0f) + { + hurtFlareTimer -= dt; + hurtFlareStrengthActive = hurtFlareStrength * Mathf.Clamp01( + hurtFlareTimer / Mathf.Max(0.05f, hurtFlareDuration)); + } + else + { + hurtFlareStrengthActive = 0f; + } + + if (enrageFlareTimer > 0f) + enrageFlareTimer -= dt; + + if (!bodyHidden) + ApplyBodyFirePresentation(); + + ApplyAudioFlare(); + } + + private void ApplyBodyFirePresentation() + { + float combinedFlare = Mathf.Max(flareStrength, hurtFlareStrengthActive); + bool enrage = enrageFlareTimer > 0.01f; + + if (bodyFireVfx != null) + { + bodyFireVfx.ApplyPresentation(healthRatio, combinedFlare, enrage); + return; + } + + if (hellhoundBodyFireVfx != null) + { + hellhoundBodyFireVfx.ApplyPresentation(healthRatio, combinedFlare, enrage); + if (bodyFireRoot != null) + bodyFireRoot.localScale = Vector3.one * bodyFireBaseScale; + return; + } + + Color baseCol = healthRatio > 0.55f + ? Color.Lerp(woundedFireColor, healthyFireColor, (healthRatio - 0.55f) / 0.45f) + : Color.Lerp(dyingFireColor, woundedFireColor, healthRatio / 0.55f); + + float flareMul = 1f + combinedFlare * 1.7f; + if (enrage) + flareMul = Mathf.Max(flareMul, 1.9f); + + float scale = bodyFireBaseScale * (0.55f + healthRatio * 0.7f) * flareMul; + if (bodyFireRoot != null) + bodyFireRoot.localScale = Vector3.one * scale; + + if (bodyGlow != null) + { + bodyGlow.color = baseCol; + bodyGlow.intensity = (1.6f + healthRatio * 3.4f) * flareMul; + bodyGlow.range = 6f + healthRatio * 4f + (enrage ? 3f : 0f); + } + } + + private void ApplyAudioFlare() + { + if (bodyAudio == null) + return; + + bodyAudio.pitch = baseAudioPitch * (1f + flareStrength * vocalPitchBoost); + bodyAudio.volume = Mathf.Clamp01(baseAudioVolume * (1f + flareStrength * vocalVolumeBoost)); + } + + private void EnsureFireVisible() + { + if (bodyFireRoot != null && !bodyFireRoot.gameObject.activeSelf && !bodyHidden) + bodyFireRoot.gameObject.SetActive(true); + } + + private void EnsureBuilt() + { + if (bodyFireRoot != null) + return; + + Transform anchor = bodyAnchor != null ? bodyAnchor : transform; + + if (TryUseEmbeddedBodyFire(anchor)) + { + ApplyBaseScale(); + return; + } + + GameObject prefab = bodyFirePrefab; +#if UNITY_EDITOR + if (prefab == null) + prefab = UnityEditor.AssetDatabase.LoadAssetAtPath(HellhoundBodyFirePrefabPath); +#endif + if (prefab == null) + prefab = Resources.Load("HellhoundBodyFire"); + + if (prefab != null) + { + GameObject fire = Instantiate(prefab, anchor); + fire.name = EmbeddedChildName; + bodyFireRoot = fire.transform; + bodyFireRoot.localPosition = bodyFireLocalOffset; + bodyFireRoot.localRotation = Quaternion.identity; + CacheBodyFireComponents(fire); + ApplyBaseScale(); + EnsureDrucladDriver(fire); + return; + } + + BuildFallbackFire(anchor); + } + + private bool TryUseEmbeddedBodyFire(Transform anchor) + { + Transform embedded = bodyFireInstance; + if (embedded == null) + { + embedded = transform.Find(EmbeddedChildName); + if (embedded == null) + embedded = transform.Find("HellhoundBodyFire"); + if (embedded == null && anchor != transform) + { + embedded = anchor.Find(EmbeddedChildName) + ?? anchor.Find("HellhoundBodyFire"); + } + } + + if (embedded == null) + return false; + + bodyFireRoot = embedded; + CacheBodyFireComponents(embedded.gameObject); + EnsureDrucladDriver(embedded.gameObject); + return true; + } + + private void CacheBodyFireComponents(GameObject fire) + { + bodyFireVfx = fire.GetComponent(); + hellhoundBodyFireVfx = fire.GetComponent(); + bodyGlow = fire.GetComponentInChildren(true); + } + + private void EnsureDrucladDriver(GameObject fire) + { + if (bodyFireVfx != null) + return; + + bodyFireVfx = fire.AddComponent(); + hellhoundBodyFireVfx = null; + if (bodyGlow == null) + bodyGlow = fire.GetComponentInChildren(true); + } + + private void ApplyBaseScale() + { + if (bodyFireRoot != null) + bodyFireRoot.localScale = Vector3.one * bodyFireBaseScale; + } + + private void BuildFallbackFire(Transform anchor) + { + var fireGo = new GameObject(EmbeddedChildName); + bodyFireRoot = fireGo.transform; + bodyFireRoot.SetParent(anchor, false); + bodyFireRoot.localPosition = bodyFireLocalOffset; + bodyFireRoot.localRotation = Quaternion.identity; + bodyFireRoot.localScale = Vector3.one * bodyFireBaseScale; + + var lightGo = new GameObject("BodyGlow"); + lightGo.transform.SetParent(bodyFireRoot, false); + lightGo.transform.localPosition = new Vector3(0f, 0.4f, 0f); + bodyGlow = lightGo.AddComponent(); + bodyGlow.type = LightType.Point; + bodyGlow.shadows = LightShadows.None; + bodyGlow.range = 8f; + bodyGlow.intensity = 3.2f; + bodyGlow.color = healthyFireColor; + + bodyFireVfx = fireGo.AddComponent(); + + // Seed a few looping Vefects bursts as a stand-in when the Hellhound prefab is missing. + SpellFocusVefectsBridge.PlayFireCast(bodyFireRoot.position, Quaternion.identity, 1.2f); + CreatureHovlBridge.TryAttachLoop( + CreatureHovlId.Hellhound, + CreatureHovlPhase.Aura, + bodyFireRoot, + 1.05f); + } +} diff --git a/Assets/Scripts/Creatures/Druclad/DrucladFirePresentation.cs.meta b/Assets/Scripts/Creatures/Druclad/DrucladFirePresentation.cs.meta new file mode 100644 index 000000000..30f363d3e --- /dev/null +++ b/Assets/Scripts/Creatures/Druclad/DrucladFirePresentation.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 31dfa9489b574c089fe560c27b65e872 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Creatures/Druclad/DrucladLootTable.cs b/Assets/Scripts/Creatures/Druclad/DrucladLootTable.cs new file mode 100644 index 000000000..2ef3b0af2 --- /dev/null +++ b/Assets/Scripts/Creatures/Druclad/DrucladLootTable.cs @@ -0,0 +1,260 @@ +using System.Collections.Generic; +using InventoryFramework; +using Mirror; +using UnityEngine; + +/// +/// Druclad death loot. Every item is rolled by tier — +/// Abundant/Common often, Epic almost never. Optional entry.weight overrides are multipliers only. +/// +[CreateAssetMenu(fileName = "DrucladLootTable", menuName = "Manafall/Creatures/Druclad Loot Table")] +public sealed class DrucladLootTable : ScriptableObject +{ + private const string DefaultItemDatabasePath = "Assets/ScriptableObjects/Items/ManafallItemDatabase.asset"; + + [System.Serializable] + public struct Entry + { + public Item item; + [Tooltip("0 = use MarketItemRarity tier weight. >0 multiplies the tier weight.")] + [Min(0f)] public float weightMul; + [Min(1)] public int minAmount; + [Min(1)] public int maxAmount; + } + + public struct ResolvedDrop + { + public Item item; + public int amount; + } + + private struct WeightedCandidate + { + public Item item; + public float weight; + public int minAmount; + public int maxAmount; + } + + [Header("Boss-themed pool (tier from MarketItemRarity)")] + public Entry[] bossPool; + [Min(0)] public int minBossRolls = 2; + [Min(0)] public int maxBossRolls = 4; + + [Header("Bonus generic rolls (also tier-weighted)")] + public bool includeGenericTier = true; + public ItemDatabase itemDatabase; + [Min(0)] public int minGenericRolls = 1; + [Min(0)] public int maxGenericRolls = 2; + [Tooltip("Highest tier allowed in the generic bonus pool.")] + public MarketItemRarity.Tier maxGenericTier = MarketItemRarity.Tier.Rare; + [Min(1)] public int genericMinAmount = 1; + [Min(1)] public int genericMaxAmount = 2; + + [Header("Spread")] + [Min(0.08f)] public float dropSpacing = 0.65f; + + public bool TrySpawnDrops(Transform origin, GameObject lootBagPrefab) + { + if (origin == null || lootBagPrefab == null) + return false; + + bool networked = NetworkServer.active || NetworkClient.active; + if (networked && !NetworkServer.active) + return false; + + CreatureLevelLootScaling.Context ctx = CreatureLevelLootScaling.Begin(origin); + IReadOnlyList drops = RollDrops(); + Vector3 dropOrigin = origin.position + Vector3.up * 0.12f; + int dropIndex = 0; + + for (int i = 0; i < drops.Count; i++) + { + ResolvedDrop drop = drops[i]; + if (drop.item == null || drop.amount <= 0) + continue; + + float angle = (360f / Mathf.Max(1, drops.Count)) * dropIndex; + Vector3 offset = Quaternion.Euler(0f, angle, 0f) * Vector3.forward * dropSpacing; + CreatureLevelLootScaling.TrySpawnDrop( + ref ctx, + lootBagPrefab, + drop.item, + drop.amount, + dropOrigin + offset, + origin); + dropIndex++; + } + + bool finishedLoot = CreatureLevelLootScaling.TryFinishDeathLoot(ref ctx, origin, lootBagPrefab); + return dropIndex > 0 || finishedLoot; + } + + public IReadOnlyList RollDrops() + { + var results = new List(8); + var pickedIds = new HashSet(); + MarketCatalog catalog = MarketCatalog.Resolve(null); + + RollBossPool(pickedIds, results); + if (includeGenericTier) + RollGenericByTier(catalog, pickedIds, results); + + return results; + } + + private void RollBossPool(HashSet pickedIds, List outDrops) + { + if (bossPool == null || bossPool.Length == 0) + return; + + var candidates = new List(bossPool.Length); + for (int i = 0; i < bossPool.Length; i++) + { + Entry row = bossPool[i]; + if (row.item == null || row.item.id <= 0 || pickedIds.Contains(row.item.id)) + continue; + + float mul = row.weightMul > 0.001f ? row.weightMul : 1f; + float weight = ItemLootRarity.ResolveLootWeight(row.item, relativeMul: mul); + if (weight <= 0.001f) + continue; + + int lo = Mathf.Max(1, Mathf.Min(row.minAmount, row.maxAmount)); + int hi = Mathf.Max(lo, Mathf.Max(row.minAmount, row.maxAmount)); + candidates.Add(new WeightedCandidate + { + item = row.item, + weight = weight, + minAmount = lo, + maxAmount = hi, + }); + } + + PickDistinct(candidates, minBossRolls, maxBossRolls, pickedIds, outDrops); + } + + private void RollGenericByTier(MarketCatalog catalog, HashSet pickedIds, List outDrops) + { + ItemDatabase db = ResolveItemDatabase(); + if (db?.items == null || db.items.Count == 0) + return; + + var candidates = new List(64); + for (int i = 0; i < db.items.Count; i++) + { + Item item = db.items[i]; + if (item == null || item.id <= 0 || pickedIds.Contains(item.id)) + continue; + if (IsDrucladThemed(item)) + continue; + if (!ItemLootRarity.CanDropFromCreatureBonusLoot(item, catalog)) + continue; + + MarketItemRarity.Profile profile = MarketItemRarity.GetProfile(item); + if (profile.Tier > maxGenericTier) + continue; + + float weight = ItemLootRarity.ResolveLootWeight(item); + if (weight <= 0.001f) + continue; + + int maxAmount = Mathf.Max(1, Mathf.Min(genericMaxAmount, item.maxStack)); + int minAmount = Mathf.Clamp(genericMinAmount, 1, maxAmount); + candidates.Add(new WeightedCandidate + { + item = item, + weight = weight, + minAmount = minAmount, + maxAmount = maxAmount, + }); + } + + PickDistinct(candidates, minGenericRolls, maxGenericRolls, pickedIds, outDrops); + } + + private static void PickDistinct( + List candidates, + int minRolls, + int maxRolls, + HashSet pickedIds, + List outDrops) + { + if (candidates.Count == 0) + return; + + int lo = Mathf.Clamp(minRolls, 0, candidates.Count); + int hi = Mathf.Clamp(Mathf.Max(minRolls, maxRolls), 0, candidates.Count); + int picks = Random.Range(lo, hi + 1); + + for (int pick = 0; pick < picks; pick++) + { + int index = PickWeightedIndex(candidates); + if (index < 0) + break; + + WeightedCandidate chosen = candidates[index]; + candidates.RemoveAt(index); + pickedIds.Add(chosen.item.id); + + int amount = Random.Range(chosen.minAmount, chosen.maxAmount + 1); + if (amount <= 0) + continue; + + outDrops.Add(new ResolvedDrop { item = chosen.item, amount = amount }); + } + } + + private static int PickWeightedIndex(List candidates) + { + float total = 0f; + for (int i = 0; i < candidates.Count; i++) + total += candidates[i].weight; + + if (total <= 0f) + return -1; + + float roll = Random.Range(0f, total); + float cumulative = 0f; + for (int i = 0; i < candidates.Count; i++) + { + cumulative += candidates[i].weight; + if (roll <= cumulative) + return i; + } + + return candidates.Count - 1; + } + + private static bool IsDrucladThemed(Item item) + { + if (item == null) + return false; + string n = item.itemName ?? string.Empty; + return n.IndexOf("druclad", System.StringComparison.OrdinalIgnoreCase) >= 0 + || n.IndexOf("abyss", System.StringComparison.OrdinalIgnoreCase) >= 0 + || n.IndexOf("abyssal", System.StringComparison.OrdinalIgnoreCase) >= 0 + || n.IndexOf("void cinder", System.StringComparison.OrdinalIgnoreCase) >= 0; + } + + private ItemDatabase ResolveItemDatabase() + { + if (itemDatabase != null) + return itemDatabase; + +#if UNITY_EDITOR + itemDatabase = UnityEditor.AssetDatabase.LoadAssetAtPath(DefaultItemDatabasePath); + if (itemDatabase != null) + return itemDatabase; +#endif + + ItemDatabase[] all = Resources.FindObjectsOfTypeAll(); + for (int i = 0; i < all.Length; i++) + { + if (all[i] != null && all[i].name.IndexOf("Manafall", System.StringComparison.OrdinalIgnoreCase) >= 0) + return all[i]; + } + + return all.Length > 0 ? all[0] : null; + } +} diff --git a/Assets/Scripts/Creatures/Druclad/DrucladLootTable.cs.meta b/Assets/Scripts/Creatures/Druclad/DrucladLootTable.cs.meta new file mode 100644 index 000000000..e24728dc3 --- /dev/null +++ b/Assets/Scripts/Creatures/Druclad/DrucladLootTable.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c462a027fd6c4d44818d3dd7585eca2d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Creatures/Druclad/DrucladMagmaPuddle.cs b/Assets/Scripts/Creatures/Druclad/DrucladMagmaPuddle.cs new file mode 100644 index 000000000..eeaa0623c --- /dev/null +++ b/Assets/Scripts/Creatures/Druclad/DrucladMagmaPuddle.cs @@ -0,0 +1,79 @@ +using InventoryFramework; +using UnityEngine; + +/// Small lingering magma patch (volcano-pool style, boss-scaled down). +[DisallowMultipleComponent] +public sealed class DrucladMagmaPuddle : MonoBehaviour +{ + private float radius; + private float damagePerTick; + private float tickInterval; + private float burnDuration; + private float dieAt; + private float nextTickAt; + private readonly Collider[] buffer = new Collider[12]; + + public static DrucladMagmaPuddle Spawn( + Vector3 groundPoint, + float radius, + float lifetime, + float damagePerTick, + float tickInterval = 0.35f, + float burnDuration = 1.8f) + { + var go = new GameObject("DrucladMagmaPuddle"); + go.transform.position = groundPoint + Vector3.up * 0.03f; + var puddle = go.AddComponent(); + puddle.radius = Mathf.Max(0.6f, radius); + puddle.damagePerTick = Mathf.Max(1f, damagePerTick); + puddle.tickInterval = Mathf.Max(0.12f, tickInterval); + puddle.burnDuration = Mathf.Max(0.4f, burnDuration); + puddle.dieAt = Time.time + Mathf.Max(0.4f, lifetime); + puddle.nextTickAt = Time.time; + + SpellFocusVefectsBridge.PlaySmolderingGroundRing( + groundPoint, + Vector3.up, + radius: puddle.radius, + duration: lifetime, + patchScale: Mathf.Clamp(puddle.radius * 0.55f, 0.55f, 1.6f)); + + Object.Destroy(go, lifetime + 0.35f); + return puddle; + } + + private void Update() + { + if (Time.time >= dieAt) + { + Destroy(gameObject); + return; + } + + if (Time.time < nextTickAt) + return; + + nextTickAt = Time.time + tickInterval; + int count = Physics.OverlapSphereNonAlloc( + transform.position, + radius, + buffer, + ~0, + QueryTriggerInteraction.Collide); + + for (int i = 0; i < count; i++) + { + Collider col = buffer[i]; + if (col == null || PlayerInteractionSphere.ShouldIgnoreEnemyCombatHit(col)) + continue; + + PlayerHealth player = col.GetComponentInParent(); + if (player == null || player.currentHealth <= 0.5f) + continue; + + Vector3 knock = Vector3.up * 0.6f; + player.TakeDamage(damagePerTick, transform.position, knock); + SpellEffectCatalog.ApplyToPlayer(player, SpellEffectId.Burning, burnDuration); + } + } +} diff --git a/Assets/Scripts/Creatures/Druclad/DrucladMagmaPuddle.cs.meta b/Assets/Scripts/Creatures/Druclad/DrucladMagmaPuddle.cs.meta new file mode 100644 index 000000000..7e27f9b03 --- /dev/null +++ b/Assets/Scripts/Creatures/Druclad/DrucladMagmaPuddle.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 88591988a2d749ce9c0e78f87fa558a9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Creatures/Druclad/DrucladMeteorRainDirector.cs b/Assets/Scripts/Creatures/Druclad/DrucladMeteorRainDirector.cs new file mode 100644 index 000000000..99eadc38c --- /dev/null +++ b/Assets/Scripts/Creatures/Druclad/DrucladMeteorRainDirector.cs @@ -0,0 +1,138 @@ +using System.Collections; +using InventoryFramework; +using UnityEngine; + +/// +/// Spawns 4–6 delayed fire meteors in a ring around the player with a brief safe doughnut near Druclad. +/// +public sealed class DrucladMeteorRainDirector : MonoBehaviour +{ + private readonly Collider[] hitBuffer = new Collider[16]; + + public static void Play( + DrucladController owner, + Vector3 playerFocus, + Vector3 bossFeet, + int meteorCount, + float ringRadius, + float safeRadiusNearBoss, + float damage, + float firstDelay, + float stagger) + { + if (owner == null) + return; + + var host = owner.gameObject.AddComponent(); + host.StartCoroutine(host.Run( + owner, + playerFocus, + bossFeet, + Mathf.Clamp(meteorCount, 3, 8), + Mathf.Max(3f, ringRadius), + Mathf.Max(1.2f, safeRadiusNearBoss), + Mathf.Max(8f, damage), + Mathf.Max(0.15f, firstDelay), + Mathf.Max(0.08f, stagger))); + } + + private IEnumerator Run( + DrucladController owner, + Vector3 playerFocus, + Vector3 bossFeet, + int count, + float ringRadius, + float safeRadius, + float damage, + float firstDelay, + float stagger) + { + float yaw0 = Random.Range(0f, 360f); + for (int i = 0; i < count; i++) + { + float yaw = yaw0 + (360f / count) * i + Random.Range(-12f, 12f); + Vector3 offset = Quaternion.Euler(0f, yaw, 0f) * Vector3.forward * ringRadius; + Vector3 impact = playerFocus + offset; + impact.y = playerFocus.y; + + // Keep a short safe doughnut around the boss so melee is not pure death. + Vector3 flatFromBoss = impact - bossFeet; + flatFromBoss.y = 0f; + if (flatFromBoss.sqrMagnitude < safeRadius * safeRadius) + impact = bossFeet + flatFromBoss.normalized * (safeRadius + 0.75f); + + if (Physics.Raycast(impact + Vector3.up * 8f, Vector3.down, out RaycastHit ground, 24f)) + impact = ground.point; + + float delay = firstDelay + stagger * i; + StartCoroutine(DropOne(owner, impact, damage, delay)); + } + + yield return new WaitForSeconds(firstDelay + stagger * count + 1.2f); + Destroy(this); + } + + private IEnumerator DropOne(DrucladController owner, Vector3 impact, float damage, float delay) + { + yield return new WaitForSeconds(delay); + + Vector3 entry = impact + Vector3.up * 28f + Random.insideUnitSphere * 2.5f; + entry.y = impact.y + 26f; + + // Telegraph ring + SpellFocusVefectsBridge.PlaySmolderingGroundRing( + impact, + Vector3.up, + radius: 1.8f, + duration: 0.55f, + patchScale: 0.85f); + + float travel = 0.55f; + float t = 0f; + var head = new GameObject("DrucladMeteorHead"); + head.transform.position = entry; + DrucladFireFx.AttachBoltVisual(head.transform, 1.35f); + + while (t < travel) + { + t += Time.deltaTime; + float u = Mathf.Clamp01(t / travel); + float eased = Mathf.Pow(u, 1.25f); + head.transform.position = Vector3.LerpUnclamped(entry, impact, eased); + yield return null; + } + + Destroy(head); + DrucladFireFx.PlayMeteorImpact(impact, 1.4f); + + if (owner == null || owner.IsDead) + yield break; + + int n = Physics.OverlapSphereNonAlloc(impact, 2.6f, hitBuffer, ~0, QueryTriggerInteraction.Collide); + for (int i = 0; i < n; i++) + { + Collider col = hitBuffer[i]; + if (col == null || PlayerInteractionSphere.ShouldIgnoreEnemyCombatHit(col)) + continue; + + PlayerHealth player = col.GetComponentInParent(); + if (player != null && player.currentHealth > 0.5f) + { + Vector3 away = player.transform.position - impact; + away.y = 0f; + if (away.sqrMagnitude < 0.01f) + away = Vector3.forward; + away.Normalize(); + player.TakeDamage(damage, impact, away * 14f + Vector3.up * 6f); + SpellEffectCatalog.ApplyToPlayer(player, SpellEffectId.Burning, 3.2f); + continue; + } + + Health creature = CreatureMeleeHurtbox.ResolveHealth(col); + if (creature != null && !creature.IsDead && creature.gameObject != owner.gameObject) + creature.TakeDamage(damage * 0.65f, impact); + } + + DrucladMagmaPuddle.Spawn(impact, radius: 1.7f, lifetime: 2.8f, damagePerTick: damage * 0.18f); + } +} diff --git a/Assets/Scripts/Creatures/Druclad/DrucladMeteorRainDirector.cs.meta b/Assets/Scripts/Creatures/Druclad/DrucladMeteorRainDirector.cs.meta new file mode 100644 index 000000000..c5e59a5b2 --- /dev/null +++ b/Assets/Scripts/Creatures/Druclad/DrucladMeteorRainDirector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4476b4351f454294a17f9dcbd9c20d83 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Creatures/Druclad/DrucladWorldFlags.cs b/Assets/Scripts/Creatures/Druclad/DrucladWorldFlags.cs new file mode 100644 index 000000000..a0214ea92 --- /dev/null +++ b/Assets/Scripts/Creatures/Druclad/DrucladWorldFlags.cs @@ -0,0 +1,157 @@ +using System; +using System.IO; +using Mirror; +using UnityEngine; + +/// +/// Once-per-world-seed Druclad encounter flags (alive fled / permanently dead). +/// Persisted next to world-entities.json as druclad.json for host/offline saves. +/// +public static class DrucladWorldFlags +{ + private const string FileName = "druclad.json"; + private const int Version = 1; + + [Serializable] + public sealed class FileBlob + { + public int version = Version; + public int worldSeed; + public bool hasAwakened; + public bool isAlive; + public bool isDead; + } + + private static FileBlob cached; + private static bool loaded; + + public static string FilePath() => + Path.Combine(HostWorldSave.SlotRootDirectory(), FileName); + + public static void NotifySessionEnded() + { + cached = null; + loaded = false; + } + + public static FileBlob Get() + { + EnsureLoaded(); + return cached; + } + + public static bool IsPermanentlyDead() + { + EnsureLoaded(); + return cached != null && cached.isDead; + } + + public static bool CanSpawn(int worldSeed) + { + EnsureLoaded(); + if (cached == null) + return true; + + if (cached.worldSeed != 0 && cached.worldSeed != worldSeed) + { + // Different seed slot — treat as fresh. + cached = new FileBlob { worldSeed = worldSeed }; + return true; + } + + return !cached.isDead; + } + + public static void MarkAwakened(int worldSeed) + { + EnsureLoaded(); + cached.worldSeed = worldSeed; + cached.hasAwakened = true; + cached.isAlive = true; + cached.isDead = false; + Persist(); + } + + public static void MarkAliveFled() + { + EnsureLoaded(); + cached.isAlive = true; + cached.isDead = false; + cached.hasAwakened = true; + Persist(); + } + + public static void MarkDead() + { + EnsureLoaded(); + cached.isAlive = false; + cached.isDead = true; + cached.hasAwakened = true; + Persist(); + } + + public static void ServerCapture() + { + if (!NetworkServer.active || !OfflineSoloSession.WorldPersistenceActive) + return; + if (!loaded || cached == null) + return; + Persist(); + } + + public static void ServerRestore() + { + loaded = false; + cached = null; + EnsureLoaded(); + } + + private static void EnsureLoaded() + { + if (loaded && cached != null) + return; + + loaded = true; + cached = new FileBlob(); + + try + { + SaveFileWriter.Flush(); + string path = FilePath(); + if (!File.Exists(path)) + return; + + string json = File.ReadAllText(path); + if (string.IsNullOrWhiteSpace(json)) + return; + + FileBlob blob = JsonUtility.FromJson(json); + if (blob != null) + cached = blob; + } + catch (Exception ex) + { + Debug.LogWarning("[DrucladWorldFlags] Load failed: " + ex.Message); + } + } + + private static void Persist() + { + if (cached == null) + return; + + try + { + Directory.CreateDirectory(HostWorldSave.SlotRootDirectory()); + string json = JsonUtility.ToJson(cached, true); + if (NetworkServer.active && OfflineSoloSession.WorldPersistenceActive) + SaveFileWriter.Enqueue(FilePath(), json); + else + SaveFileWriter.WriteNow(FilePath(), json); + } + catch (Exception ex) + { + Debug.LogWarning("[DrucladWorldFlags] Persist failed: " + ex.Message); + } + } +} diff --git a/Assets/Scripts/Creatures/Druclad/DrucladWorldFlags.cs.meta b/Assets/Scripts/Creatures/Druclad/DrucladWorldFlags.cs.meta new file mode 100644 index 000000000..a5786fbe0 --- /dev/null +++ b/Assets/Scripts/Creatures/Druclad/DrucladWorldFlags.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fa91401f0bf24c98b7320a0c45851638 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Creatures/Druclad/DrucladWorldSpawner.cs b/Assets/Scripts/Creatures/Druclad/DrucladWorldSpawner.cs new file mode 100644 index 000000000..c9adb01a6 --- /dev/null +++ b/Assets/Scripts/Creatures/Druclad/DrucladWorldSpawner.cs @@ -0,0 +1,177 @@ +using Mirror; +using UnityEngine; + +/// +/// Void-island only Druclad spawner. Awakens at most once per world seed while alive; +/// permanent death is stored in . +/// +[DisallowMultipleComponent] +public sealed class DrucladWorldSpawner : MonoBehaviour +{ + public const string PrefabResourcesName = "Druclad"; + public const string PrefabAssetPath = "Assets/GameObjects/Prefabs/Networked/Druclad.prefab"; + + [SerializeField] private GameObject drucladPrefab; + [SerializeField] private float voidOccupancyThreshold = 0.22f; + [SerializeField] private float dwellSecondsBeforeAwaken = 6f; + [SerializeField] private float pollInterval = 0.75f; + [SerializeField] private float spawnHeightProbe = 80f; + + private WorldGenerator world; + private float dwellAccum; + private float nextPollAt; + private bool spawnedThisSession; + private DrucladController liveInstance; + + private void Awake() + { + world = GetComponent() ?? FindFirstObjectByType(); + } + + private void OnEnable() + { + dwellAccum = 0f; + nextPollAt = 0f; + } + + private void Update() + { + if (!CanDriveSpawn()) + return; + + if (Time.time < nextPollAt) + return; + nextPollAt = Time.time + pollInterval; + + if (spawnedThisSession && liveInstance != null && !liveInstance.IsDead) + return; + + if (liveInstance != null && liveInstance.IsDead) + liveInstance = null; + + int seed = world != null ? world.TerrainSeed : OfflineSoloSession.LockedWorldSeed; + if (!DrucladWorldFlags.CanSpawn(seed)) + return; + + if (!TryFindVoidPlayer(out Transform player, out Vector3 voidPoint)) + { + dwellAccum = 0f; + return; + } + + dwellAccum += pollInterval; + if (dwellAccum < dwellSecondsBeforeAwaken) + return; + + TrySpawnAt(voidPoint, seed); + } + + private bool CanDriveSpawn() + { + if (NetworkServer.active) + return true; + if (!NetworkServer.active && !NetworkClient.active) + return true; + return false; + } + + private bool TryFindVoidPlayer(out Transform player, out Vector3 voidPoint) + { + player = null; + voidPoint = Vector3.zero; + PlayerHealth[] players = FindObjectsByType(FindObjectsSortMode.None); + for (int i = 0; i < players.Length; i++) + { + PlayerHealth hp = players[i]; + if (hp == null || hp.currentHealth <= 0.5f) + continue; + + Vector3 p = hp.transform.position; + if (!IsOnVoid(p)) + continue; + + player = hp.transform; + voidPoint = p; + return true; + } + + return false; + } + + private bool IsOnVoid(Vector3 worldPos) + { + BiomeSettings settings = world != null ? world.BiomeSettings : null; + int seed = world != null ? world.TerrainSeed : 0; + float void01 = BiomeClimateSampler.SampleVoidDomain01( + Mathf.RoundToInt(worldPos.x), + Mathf.RoundToInt(worldPos.z), + seed, + settings); + return void01 >= voidOccupancyThreshold; + } + + private void TrySpawnAt(Vector3 nearPlayer, int seed) + { + GameObject prefab = ResolvePrefab(); + if (prefab == null) + { + Debug.LogWarning("[DrucladWorldSpawner] Prefab missing — run Manafall/Creatures/Setup Druclad."); + return; + } + + BiomeClimateSampler.ResolveVoidLayout( + world != null ? world.BiomeSettings : null, + out Vector2 centerXZ, + out float radius); + + Vector3 spawn = new Vector3(centerXZ.x, nearPlayer.y + 4f, centerXZ.y); + // Prefer a point on the void pad a short offset from the player, still near center. + Vector3 towardCenter = new Vector3(centerXZ.x - nearPlayer.x, 0f, centerXZ.y - nearPlayer.z); + if (towardCenter.sqrMagnitude > 1f) + spawn = nearPlayer + towardCenter.normalized * Mathf.Min(14f, towardCenter.magnitude * 0.45f); + spawn.y = nearPlayer.y + 6f; + + if (Physics.Raycast(spawn + Vector3.up * spawnHeightProbe * 0.25f, Vector3.down, out RaycastHit hit, spawnHeightProbe)) + spawn = hit.point; + + GameObject instance = Instantiate(prefab, spawn, Quaternion.identity); + instance.name = "Druclad"; + liveInstance = instance.GetComponent(); + spawnedThisSession = true; + DrucladWorldFlags.MarkAwakened(seed); + + if (NetworkServer.active) + { + NetworkIdentity identity = instance.GetComponent(); + if (identity != null) + NetworkServer.Spawn(instance); + } + + Debug.Log($"[DrucladWorldSpawner] Awakened Druclad at {spawn} seed={seed} radius={radius:F0}"); + } + + private GameObject ResolvePrefab() + { + if (drucladPrefab != null) + return drucladPrefab; + + GameObject fromResources = Resources.Load(PrefabResourcesName); + if (fromResources != null) + { + drucladPrefab = fromResources; + return fromResources; + } + +#if UNITY_EDITOR + drucladPrefab = UnityEditor.AssetDatabase.LoadAssetAtPath(PrefabAssetPath); +#endif + return drucladPrefab; + } + + /// Editor / debug: force an awaken check immediately. + public void TrySpawnNow() + { + dwellAccum = dwellSecondsBeforeAwaken; + nextPollAt = 0f; + } +} diff --git a/Assets/Scripts/Creatures/Druclad/DrucladWorldSpawner.cs.meta b/Assets/Scripts/Creatures/Druclad/DrucladWorldSpawner.cs.meta new file mode 100644 index 000000000..6fdbfa576 --- /dev/null +++ b/Assets/Scripts/Creatures/Druclad/DrucladWorldSpawner.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 71f7a6570a28473c989785a391dbe325 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Creatures/Druclad/Editor.meta b/Assets/Scripts/Creatures/Druclad/Editor.meta new file mode 100644 index 000000000..0042d4611 --- /dev/null +++ b/Assets/Scripts/Creatures/Druclad/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: da5a04d298b945739ffbc12dc3ba2fa8 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Creatures/Druclad/Editor/DrucladSetupEditorTools.cs b/Assets/Scripts/Creatures/Druclad/Editor/DrucladSetupEditorTools.cs new file mode 100644 index 000000000..8c5ebb333 --- /dev/null +++ b/Assets/Scripts/Creatures/Druclad/Editor/DrucladSetupEditorTools.cs @@ -0,0 +1,885 @@ +#if UNITY_EDITOR +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using InventoryFramework; +using Mirror; +using UnityEditor; +using UnityEditor.Animations; +using UnityEngine; +using Object = UnityEngine.Object; + +[InitializeOnLoad] +internal static class DrucladAutoSetupOnLoad +{ + static DrucladAutoSetupOnLoad() + { + DrucladSetupEditorTools.ResetAutoSetupPass(); + EditorApplication.delayCall += DrucladSetupEditorTools.AutoSetupIfNeeded; + UnityEditor.Compilation.CompilationPipeline.compilationFinished += _ => + { + DrucladSetupEditorTools.ResetAutoSetupPass(); + EditorApplication.delayCall += DrucladSetupEditorTools.AutoSetupIfNeeded; + }; + } +} + +/// +/// One-click Druclad setup: Humanoid retarget from Mixamo (or project fallbacks), +/// animator controller, networked prefab, spawn registration. +/// +public static class DrucladSetupEditorTools +{ + private const string ModelPath = DrucladAnimCatalog.RiggedModelPath; + private const string PrefabPath = DrucladWorldSpawner.PrefabAssetPath; + private const string AnimatorPath = DrucladAnimCatalog.AnimatorPath; + private const string ClipsFolder = DrucladAnimCatalog.ClipsFolder; + private const string LootTablePath = "Assets/Data/Creatures/DrucladLootTable.asset"; + private const string LootBagPath = "Assets/GameObjects/Prefabs/Networked/Loot Bag_Object.prefab"; + private const string NetworkManagerPath = "Assets/GameObjects/Prefabs/NetworkManager.prefab"; + private const string ResourcesPrefabPath = "Assets/Resources/Druclad.prefab"; + private const string BodyFirePrefabPath = DrucladFirePresentation.HellhoundBodyFirePrefabPath; + private const string ItemDatabasePath = "Assets/ScriptableObjects/Items/ManafallItemDatabase.asset"; + private const string EmberAshPath = "Assets/ScriptableObjects/Items/Abyssal Ember Ash.asset"; + private const string CinderScalePath = "Assets/ScriptableObjects/Items/Void Cinder Scale.asset"; + private const string HornShardPath = "Assets/ScriptableObjects/Items/Druclad Horn Shard.asset"; + private const string FireHeartPath = "Assets/ScriptableObjects/Items/Abyss Fire Heart.asset"; + private const int EmberAshId = 7820; + private const int CinderScaleId = 7821; + private const int HornShardId = 7822; + private const int FireHeartId = 7823; + + private const float ModelScale = 3.35f; + private const uint NetworkAssetId = 4182736501u; + private const float BossMaxHealth = 4200f; + + private static bool isRunningSetup; + private static bool autoSetupAttemptedThisPass; + + [MenuItem("Manafall/Creatures/Setup Druclad", priority = 54)] + public static void SetupDrucladFullMenu() => SetupDrucladFull(); + + [MenuItem("Manafall/Creatures/Setup Druclad Animations Only", priority = 55)] + public static void SetupDrucladAnimationsOnlyMenu() => SetupDrucladAnimationsOnly(); + + public static void ResetAutoSetupPass() => autoSetupAttemptedThisPass = false; + + public static void AutoSetupIfNeeded() + { + if (ManafallEditorPlayMode.BlocksAutoSetup) + return; + if (!File.Exists(ModelPath)) + return; + if (isRunningSetup || autoSetupAttemptedThisPass) + return; + + try + { + isRunningSetup = true; + if (NeedsSetupPass()) + SetupDrucladFull(); + else + RegisterSpawnablePrefab(null, AssetDatabase.LoadAssetAtPath(PrefabPath)); + } + catch (Exception ex) + { + Debug.LogWarning($"Manafall: Druclad auto-setup failed this pass. Reason: {ex.Message}"); + } + finally + { + autoSetupAttemptedThisPass = true; + isRunningSetup = false; + } + } + + public static void SetupDrucladFull() + { + var log = new StringBuilder(); + log.AppendLine("=== Manafall: Setup Druclad ==="); + + EnsureFolder("Assets/Animation"); + EnsureFolder("Assets/Data/Creatures"); + EnsureFolder("Assets/GameObjects/Prefabs/Networked"); + EnsureFolder("Assets/ScriptableObjects/Items"); + EnsureFolder(ClipsFolder); + EnsureFolder("Assets/Resources"); + + ConfigureHumanoidModel(log); + EnsureFallbackClipCopies(log); + ConfigureClipHumanoids(log); + + Item emberAsh = EnsureItem(EmberAshPath, EmberAshId, "Abyssal Ember Ash", + "Fine ash that still smolders with void heat.", 30); + Item cinderScale = EnsureItem(CinderScalePath, CinderScaleId, "Void Cinder Scale", + "A plate of cooled magma from Druclad's hide.", 12); + Item hornShard = EnsureItem(HornShardPath, HornShardId, "Druclad Horn Shard", + "A jagged horn fragment glowing with abyss fire.", 4); + Item fireHeart = EnsureItem(FireHeartPath, FireHeartId, "Abyss Fire Heart", + "The rare core of the void fire boss — still pulsing heat.", 1); + + RegisterItemInDatabase(log, emberAsh); + RegisterItemInDatabase(log, cinderScale); + RegisterItemInDatabase(log, hornShard); + RegisterItemInDatabase(log, fireHeart); + + DrucladLootTable lootTable = EnsureLootTable(log, emberAsh, cinderScale, hornShard, fireHeart); + RuntimeAnimatorController animator = BuildAnimatorController(log); + RecreatePrefab(log, animator, lootTable); + RegisterSpawnablePrefab(log, AssetDatabase.LoadAssetAtPath(PrefabPath)); + + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + Debug.Log(log.ToString()); + } + + public static void SetupDrucladAnimationsOnly() + { + var log = new StringBuilder(); + log.AppendLine("=== Manafall: Setup Druclad Animations ==="); + ConfigureHumanoidModel(log); + EnsureFallbackClipCopies(log); + ConfigureClipHumanoids(log); + RuntimeAnimatorController animator = BuildAnimatorController(log); + GameObject prefab = AssetDatabase.LoadAssetAtPath(PrefabPath); + if (prefab != null && animator != null) + { + DrucladLootTable loot = AssetDatabase.LoadAssetAtPath(LootTablePath); + WireExistingPrefab(prefab, animator, loot, log); + } + + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + Debug.Log(log.ToString()); + } + + public static void RunBatchSetupFromCommandLine() + { + AutoSetupIfNeeded(); + SetupDrucladFull(); + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + EditorApplication.Exit(0); + } + + private static bool NeedsSetupPass() + { + GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(PrefabPath); + if (prefabAsset == null) + return true; + if (prefabAsset.GetComponent() == null) + return true; + if (prefabAsset.GetComponent() == null) + return true; + if (prefabAsset.GetComponent() == null) + return true; + if (AssetDatabase.LoadAssetAtPath(AnimatorPath) == null) + return true; + return false; + } + + private static void ConfigureHumanoidModel(StringBuilder log) + { + var importer = AssetImporter.GetAtPath(ModelPath) as ModelImporter; + if (importer == null) + { + log.AppendLine($" ERROR: missing model importer at {ModelPath}"); + return; + } + + bool dirty = false; + if (importer.animationType != ModelImporterAnimationType.Human) + { + importer.animationType = ModelImporterAnimationType.Human; + dirty = true; + } + + if (importer.avatarSetup != ModelImporterAvatarSetup.CreateFromThisModel) + { + importer.avatarSetup = ModelImporterAvatarSetup.CreateFromThisModel; + dirty = true; + } + + importer.globalScale = 1f; + if (dirty) + { + importer.SaveAndReimport(); + log.AppendLine(" Configured Druclad_MixamoRigged as Humanoid."); + } + else + { + log.AppendLine(" Druclad Humanoid already configured."); + } + } + + private static Avatar LoadDrucladAvatar() + { + foreach (Object sub in AssetDatabase.LoadAllAssetsAtPath(ModelPath)) + { + if (sub is Avatar avatar) + return avatar; + } + + return null; + } + + private static void EnsureFallbackClipCopies(StringBuilder log) + { + EnsureFolder(ClipsFolder); + CopyClipIfMissing(DrucladAnimCatalog.IdlePreferred, DrucladAnimCatalog.IdleFallback, log); + CopyClipIfMissing(DrucladAnimCatalog.WalkPreferred, DrucladAnimCatalog.WalkFallback, log); + CopyClipIfMissing(DrucladAnimCatalog.RunPreferred, DrucladAnimCatalog.RunFallback, log); + CopyClipIfMissing(DrucladAnimCatalog.RoarPreferred, DrucladAnimCatalog.RoarFallback, log); + CopyClipIfMissing(DrucladAnimCatalog.HitPreferred, DrucladAnimCatalog.HitFallback, log); + CopyClipIfMissing(DrucladAnimCatalog.DeathPreferred, DrucladAnimCatalog.DeathFallback, log); + CopyClipIfMissing(DrucladAnimCatalog.SwipeLPreferred, DrucladAnimCatalog.SwipeLFallback, log); + CopyClipIfMissing(DrucladAnimCatalog.SwipeRPreferred, DrucladAnimCatalog.SwipeRFallback, log); + CopyClipIfMissing(DrucladAnimCatalog.OverheadPreferred, DrucladAnimCatalog.OverheadFallback, log); + CopyClipIfMissing(DrucladAnimCatalog.StompPreferred, DrucladAnimCatalog.StompFallback, log); + CopyClipIfMissing(DrucladAnimCatalog.BreathWindupPreferred, DrucladAnimCatalog.BreathWindupFallback, log); + CopyClipIfMissing(DrucladAnimCatalog.HurlPreferred, DrucladAnimCatalog.HurlFallback, log); + CopyClipIfMissing(DrucladAnimCatalog.ClapPreferred, DrucladAnimCatalog.ClapFallback, log); + } + + private static void CopyClipIfMissing(string preferredFileName, string fallbackPath, StringBuilder log) + { + string dest = $"{ClipsFolder}/{preferredFileName}"; + if (File.Exists(dest)) + return; + + if (!File.Exists(fallbackPath)) + { + log.AppendLine($" WARN: fallback missing for {preferredFileName}: {fallbackPath}"); + return; + } + + EnsureFolder(ClipsFolder); + if (AssetDatabase.CopyAsset(fallbackPath, dest)) + log.AppendLine($" Copied clip {preferredFileName}"); + else + log.AppendLine($" WARN: failed to copy {fallbackPath} → {dest}"); + } + + private static void ConfigureClipHumanoids(StringBuilder log) + { + Avatar avatar = LoadDrucladAvatar(); + if (avatar == null) + { + log.AppendLine(" WARN: Druclad Avatar missing — clip retarget skipped."); + return; + } + + string[] guids = AssetDatabase.FindAssets("t:Model", new[] { ClipsFolder }); + for (int i = 0; i < guids.Length; i++) + { + string path = AssetDatabase.GUIDToAssetPath(guids[i]); + if (!path.EndsWith(".fbx", StringComparison.OrdinalIgnoreCase)) + continue; + + var importer = AssetImporter.GetAtPath(path) as ModelImporter; + if (importer == null) + continue; + + importer.animationType = ModelImporterAnimationType.Human; + importer.avatarSetup = ModelImporterAvatarSetup.CopyFromOther; + importer.sourceAvatar = avatar; + importer.SaveAndReimport(); + log.AppendLine($" Retargeted {Path.GetFileName(path)} → Druclad Avatar"); + } + } + + private static RuntimeAnimatorController BuildAnimatorController(StringBuilder log) + { + Dictionary clips = LoadClipsFromFolder(); + if (clips.Count == 0) + { + log.AppendLine(" WARN: no Druclad clips found — animator not built."); + return null; + } + + AnimationClip idle = FindClip(clips, "idle", "monster") ?? FirstClip(clips); + AnimationClip walk = FindClip(clips, "walk", "zombie walk") ?? idle; + AnimationClip run = FindClip(clips, "run") ?? walk; + AnimationClip roar = FindClip(clips, "roar") ?? FindClip(clips, "attack") ?? idle; + AnimationClip hit = FindClip(clips, "impact", "hit", "reaction") ?? idle; + AnimationClip die = FindClip(clips, "dying", "death", "die") ?? hit; + AnimationClip swipeL = FindClip(clips, "punch") ?? FindClip(clips, "swipe") ?? idle; + AnimationClip swipeR = FindClip(clips, "hook") ?? FindClip(clips, "attack (1)") ?? swipeL; + AnimationClip overhead = FindClip(clips, "overhead", "axe") ?? swipeR; + AnimationClip stomp = FindClip(clips, "jump", "stomp") ?? overhead; + AnimationClip breath = FindClip(clips, "spell", "breath", "casting") ?? roar; + AnimationClip hurl = FindClip(clips, "frisbee", "throw", "hurl") ?? swipeL; + AnimationClip clap = FindClip(clips, "clap", "sky") ?? breath; + + if (File.Exists(AnimatorPath)) + AssetDatabase.DeleteAsset(AnimatorPath); + + AnimatorController controller = AnimatorController.CreateAnimatorControllerAtPath(AnimatorPath); + controller.AddParameter(DrucladAnimCatalog.SpeedParam, AnimatorControllerParameterType.Float); + controller.AddParameter(DrucladAnimCatalog.DeadBool, AnimatorControllerParameterType.Bool); + controller.AddParameter(DrucladAnimCatalog.HurtTrigger, AnimatorControllerParameterType.Trigger); + controller.AddParameter(DrucladAnimCatalog.DieTrigger, AnimatorControllerParameterType.Trigger); + controller.AddParameter(DrucladAnimCatalog.RoarTrigger, AnimatorControllerParameterType.Trigger); + + for (int i = 0; i < DrucladAnimCatalog.Attacks.Length; i++) + controller.AddParameter(DrucladAnimCatalog.Attacks[i].TriggerName, AnimatorControllerParameterType.Trigger); + + AnimatorStateMachine sm = controller.layers[0].stateMachine; + + BlendTree loco; + AnimatorState locoState = controller.CreateBlendTreeInController(DrucladAnimCatalog.LocomotionState, out loco, 0); + loco.blendType = BlendTreeType.Simple1D; + loco.blendParameter = DrucladAnimCatalog.SpeedParam; + loco.useAutomaticThresholds = false; + loco.AddChild(idle, 0f); + loco.AddChild(walk, 2.2f); + loco.AddChild(run, 5.2f); + sm.defaultState = locoState; + + AnimatorState roarState = AddState(sm, "Roar", roar, new Vector3(420f, -160f, 0f)); + AnimatorState hurtState = AddState(sm, "Hurt", hit, new Vector3(620f, -40f, 0f)); + AnimatorState dieState = AddState(sm, DrucladAnimCatalog.DieState, die, new Vector3(620f, -160f, 0f)); + + AddTriggerAny(sm, DrucladAnimCatalog.RoarTrigger, roarState, locoState, 0.92f); + AddTriggerAny(sm, DrucladAnimCatalog.HurtTrigger, hurtState, locoState, 0.55f); + AddTriggerAny(sm, DrucladAnimCatalog.DieTrigger, dieState, null, 0.98f, exitOnly: true); + + AddAttackState(sm, locoState, "AshSwipeL", swipeL, DrucladAnimCatalog.AttackId.AshSwipeL); + AddAttackState(sm, locoState, "AshSwipeR", swipeR, DrucladAnimCatalog.AttackId.AshSwipeR); + AddAttackState(sm, locoState, "MagmaStomp", stomp, DrucladAnimCatalog.AttackId.MagmaStomp); + AddAttackState(sm, locoState, "CinderBolt", hurl, DrucladAnimCatalog.AttackId.CinderBolt); + AddAttackState(sm, locoState, "InfernoBreath", breath, DrucladAnimCatalog.AttackId.InfernoBreath); + AddAttackState(sm, locoState, "TwinBolts", hurl, DrucladAnimCatalog.AttackId.TwinBolts); + AddAttackState(sm, locoState, "EruptionStep", stomp, DrucladAnimCatalog.AttackId.EruptionStep); + AddAttackState(sm, locoState, "MeteorRain", clap, DrucladAnimCatalog.AttackId.MeteorRain); + AddAttackState(sm, locoState, "WorldbreakerSlam", overhead, DrucladAnimCatalog.AttackId.WorldbreakerSlam); + AddAttackState(sm, locoState, "BreathSwipeString", breath, DrucladAnimCatalog.AttackId.BreathSwipeString); + + EditorUtility.SetDirty(controller); + AssetDatabase.SaveAssets(); + log.AppendLine($" Built {AnimatorPath} with {clips.Count} clips."); + return controller; + } + + private static void AddAttackState( + AnimatorStateMachine sm, + AnimatorState loco, + string stateName, + AnimationClip clip, + DrucladAnimCatalog.AttackId id) + { + if (!DrucladAnimCatalog.TryGet(id, out DrucladAnimCatalog.AttackDef def)) + return; + + AnimatorState state = AddState(sm, stateName, clip, new Vector3(420f, 40f + (int)id * 36f, 0f)); + AddTriggerAny(sm, def.TriggerName, state, loco, def.RecoveryNormalized); + } + + private static AnimatorState AddState(AnimatorStateMachine sm, string name, AnimationClip clip, Vector3 pos) + { + AnimatorState state = sm.AddState(name, pos); + state.motion = clip; + state.writeDefaultValues = true; + return state; + } + + private static void AddTriggerAny( + AnimatorStateMachine sm, + string trigger, + AnimatorState dst, + AnimatorState backTo, + float exitTime, + bool exitOnly = false) + { + AnimatorStateTransition any = sm.AddAnyStateTransition(dst); + any.AddCondition(AnimatorConditionMode.If, 0f, trigger); + if (!exitOnly) + any.AddCondition(AnimatorConditionMode.IfNot, 0f, DrucladAnimCatalog.DeadBool); + any.hasExitTime = false; + any.duration = 0.08f; + any.canTransitionToSelf = false; + + if (backTo == null || exitOnly) + return; + + AnimatorStateTransition back = dst.AddTransition(backTo); + back.hasExitTime = true; + back.exitTime = Mathf.Clamp01(exitTime); + back.duration = 0.1f; + back.hasFixedDuration = true; + } + + private static Dictionary LoadClipsFromFolder() + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + string[] guids = AssetDatabase.FindAssets("t:AnimationClip", new[] { ClipsFolder }); + for (int i = 0; i < guids.Length; i++) + { + string path = AssetDatabase.GUIDToAssetPath(guids[i]); + foreach (Object sub in AssetDatabase.LoadAllAssetsAtPath(path)) + { + if (sub is not AnimationClip clip || clip.name.StartsWith("__", StringComparison.Ordinal)) + continue; + if (!map.ContainsKey(clip.name)) + map.Add(clip.name, clip); + string fileKey = Path.GetFileNameWithoutExtension(path); + if (!map.ContainsKey(fileKey)) + map.Add(fileKey, clip); + } + } + + return map; + } + + private static AnimationClip FindClip(Dictionary clips, params string[] keys) + { + foreach (KeyValuePair pair in clips) + { + string n = pair.Key.ToLowerInvariant(); + for (int i = 0; i < keys.Length; i++) + { + if (n.Contains(keys[i].ToLowerInvariant())) + return pair.Value; + } + } + + return null; + } + + private static AnimationClip FirstClip(Dictionary clips) + { + foreach (KeyValuePair pair in clips) + return pair.Value; + return null; + } + + private static DrucladLootTable EnsureLootTable( + StringBuilder log, + Item emberAsh, + Item cinderScale, + Item hornShard, + Item fireHeart) + { + DrucladLootTable table = AssetDatabase.LoadAssetAtPath(LootTablePath); + if (table == null) + { + table = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(table, LootTablePath); + log.AppendLine($" Created {LootTablePath}"); + } + + // weightMul 0 → MarketItemRarity tier alone sets the roll rate. + table.minBossRolls = 2; + table.maxBossRolls = 4; + table.includeGenericTier = true; + table.itemDatabase = AssetDatabase.LoadAssetAtPath(ItemDatabasePath); + table.minGenericRolls = 1; + table.maxGenericRolls = 2; + table.maxGenericTier = MarketItemRarity.Tier.Rare; + table.genericMinAmount = 1; + table.genericMaxAmount = 2; + table.dropSpacing = 0.65f; + table.bossPool = new[] + { + new DrucladLootTable.Entry { item = emberAsh, weightMul = 0f, minAmount = 2, maxAmount = 5 }, + new DrucladLootTable.Entry { item = cinderScale, weightMul = 0f, minAmount = 1, maxAmount = 3 }, + new DrucladLootTable.Entry { item = hornShard, weightMul = 0f, minAmount = 1, maxAmount = 1 }, + new DrucladLootTable.Entry { item = fireHeart, weightMul = 0f, minAmount = 1, maxAmount = 1 }, + }; + EditorUtility.SetDirty(table); + log.AppendLine(" Wired Druclad loot pool (tier-weighted Common→Epic)."); + return table; + } + + private static Item EnsureItem(string path, int id, string itemName, string description, int maxStack) + { + Item item = AssetDatabase.LoadAssetAtPath(path); + if (item == null) + { + item = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(item, path); + } + + item.id = id; + item.itemName = itemName; + item.description = description; + item.maxStack = Mathf.Max(1, maxStack); + EditorUtility.SetDirty(item); + return item; + } + + private static void RegisterItemInDatabase(StringBuilder log, Item item) + { + if (item == null) + return; + + ItemDatabase db = AssetDatabase.LoadAssetAtPath(ItemDatabasePath); + if (db == null) + { + log.AppendLine($" WARN: ItemDatabase missing at {ItemDatabasePath}"); + return; + } + + if (db.items == null) + db.items = new List(); + + for (int i = db.items.Count - 1; i >= 0; i--) + { + if (db.items[i] == null) + db.items.RemoveAt(i); + } + + bool already = false; + for (int i = 0; i < db.items.Count; i++) + { + Item existing = db.items[i]; + if (existing == item || (existing != null && existing.id == item.id)) + { + db.items[i] = item; + already = true; + break; + } + } + + if (!already) + { + db.items.Add(item); + log.AppendLine($" Registered item '{item.itemName}' (id={item.id}) in database."); + } + + EditorUtility.SetDirty(db); + } + + private static void RecreatePrefab( + StringBuilder log, + RuntimeAnimatorController animator, + DrucladLootTable lootTable) + { + GameObject modelPrefab = AssetDatabase.LoadAssetAtPath(ModelPath); + if (modelPrefab == null) + { + log.AppendLine($" ERROR: model missing at {ModelPath}"); + return; + } + + GameObject existing = AssetDatabase.LoadAssetAtPath(PrefabPath); + if (existing != null) + { + WireExistingPrefab(existing, animator, lootTable, log); + AssignNetworkAssetId(existing, NetworkAssetId, log); + MirrorResourcesCopy(log); + return; + } + + GameObject instance = (GameObject)PrefabUtility.InstantiatePrefab(modelPrefab); + if (instance == null) + { + log.AppendLine(" ERROR: failed to instantiate Druclad model."); + return; + } + + try + { + instance.name = "Druclad"; + instance.transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity); + instance.transform.localScale = Vector3.one * ModelScale; + WireRootComponents(instance, animator, lootTable, log); + GameObject saved = PrefabUtility.SaveAsPrefabAsset(instance, PrefabPath); + if (saved != null) + { + AssignNetworkAssetId(saved, NetworkAssetId, log); + log.AppendLine($" Created {PrefabPath}"); + MirrorResourcesCopy(log); + } + } + finally + { + Object.DestroyImmediate(instance); + } + } + + private static void WireExistingPrefab( + GameObject prefabAsset, + RuntimeAnimatorController animator, + DrucladLootTable lootTable, + StringBuilder log) + { + string path = AssetDatabase.GetAssetPath(prefabAsset); + GameObject instance = PrefabUtility.LoadPrefabContents(path); + try + { + WireRootComponents(instance, animator, lootTable, log); + PrefabUtility.SaveAsPrefabAsset(instance, path); + log.AppendLine($" Updated existing {PrefabPath}."); + } + finally + { + PrefabUtility.UnloadPrefabContents(instance); + } + } + + private static void WireRootComponents( + GameObject root, + RuntimeAnimatorController animator, + DrucladLootTable lootTable, + StringBuilder log) + { + if (root.GetComponent() == null) + root.AddComponent(); + if (root.GetComponent() == null) + root.AddComponent(); + if (root.GetComponent() == null) + root.AddComponent(); + + CharacterController cc = root.GetComponent(); + if (cc == null) + cc = root.AddComponent(); + cc.height = 3.8f; + cc.radius = 1.05f; + cc.center = new Vector3(0f, 1.9f, 0f); + cc.slopeLimit = 50f; + cc.stepOffset = 0.55f; + + Health health = root.GetComponent(); + if (health == null) + health = root.AddComponent(); + health.ConfigureMaxHealth(BossMaxHealth, healToFull: true); + health.DestroyOnDeath = false; + + EnsureHurtboxChild(root.transform); + + AudioSource audio = root.GetComponent(); + if (audio == null) + audio = root.AddComponent(); + audio.spatialBlend = 1f; + audio.playOnAwake = false; + audio.maxDistance = 48f; + + DrucladFirePresentation firePresentation = root.GetComponent(); + if (firePresentation == null) + firePresentation = root.AddComponent(); + + Transform bodyFire = EnsureBodyFireChild(root.transform); + + Animator anim = root.GetComponentInChildren(true); + if (anim == null) + anim = root.AddComponent(); + if (animator != null) + anim.runtimeAnimatorController = animator; + Avatar avatar = LoadDrucladAvatar(); + if (avatar != null) + anim.avatar = avatar; + anim.applyRootMotion = false; + anim.cullingMode = AnimatorCullingMode.AlwaysAnimate; + + NetworkAnimator netAnim = root.GetComponent(); + if (netAnim != null) + netAnim.animator = anim; + + DrucladController controller = root.GetComponent(); + if (controller == null) + controller = root.AddComponent(); + + DrucladFireBreathVolume breath = root.GetComponent(); + if (breath == null) + breath = root.AddComponent(); + + SerializedObject so = new SerializedObject(controller); + so.FindProperty("characterController").objectReferenceValue = cc; + so.FindProperty("health").objectReferenceValue = health; + so.FindProperty("animator").objectReferenceValue = anim; + so.FindProperty("defaultRuntimeAnimatorController").objectReferenceValue = animator; + so.FindProperty("breathVolume").objectReferenceValue = breath; + so.FindProperty("firePresentation").objectReferenceValue = firePresentation; + so.FindProperty("bodyAudio").objectReferenceValue = audio; + so.FindProperty("deathLootTable").objectReferenceValue = lootTable; + GameObject lootBag = AssetDatabase.LoadAssetAtPath(LootBagPath); + if (lootBag != null) + so.FindProperty("lootBagPrefab").objectReferenceValue = lootBag; + so.FindProperty("maxHealthBoss").floatValue = BossMaxHealth; + so.ApplyModifiedPropertiesWithoutUndo(); + + SerializedObject fireSo = new SerializedObject(firePresentation); + fireSo.FindProperty("bodyAnchor").objectReferenceValue = root.transform; + fireSo.FindProperty("bodyAudio").objectReferenceValue = audio; + fireSo.FindProperty("bodyFireInstance").objectReferenceValue = bodyFire; + GameObject bodyFirePrefab = AssetDatabase.LoadAssetAtPath(BodyFirePrefabPath); + if (bodyFirePrefab != null) + fireSo.FindProperty("bodyFirePrefab").objectReferenceValue = bodyFirePrefab; + fireSo.FindProperty("bodyFireLocalOffset").vector3Value = new Vector3(0f, 1.15f, 0.1f); + fireSo.FindProperty("bodyFireBaseScale").floatValue = 1.85f; + fireSo.ApplyModifiedPropertiesWithoutUndo(); + + if (root.GetComponent() == null) + root.AddComponent(); + + log.AppendLine(" Wired DrucladController / body fire / Health / Hurtbox / CharacterController / Network*."); + } + + private static Transform EnsureBodyFireChild(Transform root) + { + Transform existing = root.Find(DrucladFirePresentation.EmbeddedChildName) + ?? root.Find("HellhoundBodyFire"); + if (existing != null) + { + if (existing.GetComponent() == null + && existing.GetComponent() == null) + { + existing.gameObject.AddComponent(); + } + + return existing; + } + + GameObject prefab = AssetDatabase.LoadAssetAtPath(BodyFirePrefabPath); + if (prefab != null) + { + GameObject embedded = (GameObject)PrefabUtility.InstantiatePrefab(prefab, root); + embedded.name = DrucladFirePresentation.EmbeddedChildName; + embedded.transform.localPosition = new Vector3(0f, 1.15f, 0.1f); + embedded.transform.localRotation = Quaternion.identity; + embedded.transform.localScale = Vector3.one * 1.85f; + if (embedded.GetComponent() == null) + embedded.AddComponent(); + return embedded.transform; + } + + var fallback = new GameObject(DrucladFirePresentation.EmbeddedChildName); + fallback.transform.SetParent(root, false); + fallback.transform.localPosition = new Vector3(0f, 1.15f, 0.1f); + fallback.transform.localScale = Vector3.one * 1.85f; + var lightGo = new GameObject("BodyGlow"); + lightGo.transform.SetParent(fallback.transform, false); + Light glow = lightGo.AddComponent(); + glow.type = LightType.Point; + glow.shadows = LightShadows.None; + glow.range = 8f; + glow.intensity = 3.2f; + glow.color = new Color(1.4f, 0.68f, 0.16f, 1f); + fallback.AddComponent(); + return fallback.transform; + } + + private static CapsuleCollider EnsureHurtboxChild(Transform root) + { + Transform hurtboxTransform = root.Find("Hurtbox"); + if (hurtboxTransform == null) + { + var hurtboxGo = new GameObject("Hurtbox"); + hurtboxTransform = hurtboxGo.transform; + hurtboxTransform.SetParent(root, false); + } + + CapsuleCollider hurtbox = hurtboxTransform.GetComponent(); + if (hurtbox == null) + hurtbox = hurtboxTransform.gameObject.AddComponent(); + + // Boss-scale capsule (taller/heavier than Troll). CreatureMeleeHurtbox.Awake + // forces non-trigger so player melee/spells can resolve Health. + hurtbox.isTrigger = false; + hurtbox.direction = 1; + hurtbox.height = 5.6f; + hurtbox.radius = 1.35f; + hurtbox.center = new Vector3(0f, 2.8f, 0.2f); + + if (hurtboxTransform.GetComponent() == null) + hurtboxTransform.gameObject.AddComponent(); + + return hurtbox; + } + + private static void AssignNetworkAssetId(GameObject prefab, uint assetId, StringBuilder log) + { + if (prefab == null) + return; + + string path = AssetDatabase.GetAssetPath(prefab); + GameObject instance = PrefabUtility.LoadPrefabContents(path); + try + { + NetworkIdentity identity = instance.GetComponent(); + if (identity == null) + identity = instance.AddComponent(); + + SerializedObject so = new SerializedObject(identity); + SerializedProperty assetIdProp = so.FindProperty("assetId"); + if (assetIdProp != null) + { + // Mirror stores assetId as uint via serialized field name variations. + assetIdProp.longValue = assetId; + so.ApplyModifiedPropertiesWithoutUndo(); + } + + PrefabUtility.SaveAsPrefabAsset(instance, path); + log.AppendLine($" NetworkIdentity assetId={assetId}"); + } + finally + { + PrefabUtility.UnloadPrefabContents(instance); + } + } + + private static void MirrorResourcesCopy(StringBuilder log) + { + GameObject prefab = AssetDatabase.LoadAssetAtPath(PrefabPath); + if (prefab == null) + return; + + if (File.Exists(ResourcesPrefabPath)) + AssetDatabase.DeleteAsset(ResourcesPrefabPath); + if (AssetDatabase.CopyAsset(PrefabPath, ResourcesPrefabPath)) + log.AppendLine($" Mirrored Resources/{Path.GetFileName(ResourcesPrefabPath)}"); + } + + private static void RegisterSpawnablePrefab(StringBuilder log, GameObject prefab) + { + if (prefab == null) + return; + + GameObject nmPrefab = AssetDatabase.LoadAssetAtPath(NetworkManagerPath); + if (nmPrefab == null) + { + log?.AppendLine(" WARN: NetworkManager prefab missing — spawn list not updated."); + return; + } + + string path = AssetDatabase.GetAssetPath(nmPrefab); + GameObject instance = PrefabUtility.LoadPrefabContents(path); + try + { + NetworkManager manager = instance.GetComponent(); + if (manager == null) + return; + + manager.spawnPrefabs ??= new List(); + if (!manager.spawnPrefabs.Contains(prefab)) + { + manager.spawnPrefabs.Add(prefab); + log?.AppendLine(" Registered Druclad on NetworkManager spawn list."); + } + + PrefabUtility.SaveAsPrefabAsset(instance, path); + } + finally + { + PrefabUtility.UnloadPrefabContents(instance); + } + } + + private static void EnsureFolder(string path) + { + if (AssetDatabase.IsValidFolder(path)) + return; + + string[] parts = path.Split('/'); + string cur = parts[0]; + for (int i = 1; i < parts.Length; i++) + { + string next = cur + "/" + parts[i]; + if (!AssetDatabase.IsValidFolder(next)) + AssetDatabase.CreateFolder(cur, parts[i]); + cur = next; + } + } +} +#endif diff --git a/Assets/Scripts/Creatures/Druclad/Editor/DrucladSetupEditorTools.cs.meta b/Assets/Scripts/Creatures/Druclad/Editor/DrucladSetupEditorTools.cs.meta new file mode 100644 index 000000000..092f79c52 --- /dev/null +++ b/Assets/Scripts/Creatures/Druclad/Editor/DrucladSetupEditorTools.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f058367093f1406b90815d17ac6a9eaf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Creatures/Hellhound/HellhoundLootTable.cs b/Assets/Scripts/Creatures/Hellhound/HellhoundLootTable.cs index 0a6efe13c..f73d6de3a 100644 --- a/Assets/Scripts/Creatures/Hellhound/HellhoundLootTable.cs +++ b/Assets/Scripts/Creatures/Hellhound/HellhoundLootTable.cs @@ -44,13 +44,20 @@ public sealed class HellhoundLootTable : ScriptableObject for (int i = 0; i < pool.Length; i++) { Entry row = pool[i]; - if (row.item == null || row.weight <= 0f) + if (row.item == null) + continue; + + // Loot rate comes from MarketItemRarity tier; serialized weight is a relative mul only. + float relativeMul = row.weight > 0.001f ? row.weight : 1f; + float tierWeight = ItemLootRarity.ResolveLootWeight(row.item, relativeMul: relativeMul); + if (tierWeight <= 0.001f) continue; int lo = Mathf.Min(row.minAmount, row.maxAmount); int hi = Mathf.Max(row.minAmount, row.maxAmount); row.minAmount = lo; row.maxAmount = hi; + row.weight = tierWeight; candidates.Add(row); } diff --git a/Assets/Scripts/Creatures/HorseLootTable.cs b/Assets/Scripts/Creatures/HorseLootTable.cs index 630e5d158..c00de0cb9 100644 --- a/Assets/Scripts/Creatures/HorseLootTable.cs +++ b/Assets/Scripts/Creatures/HorseLootTable.cs @@ -43,10 +43,10 @@ public sealed class HorseLootTable : ScriptableObject [Min(1)] public int genericMinAmount = 1; [Min(1)] public int genericMaxAmount = 2; - public IReadOnlyList RollDrops() + public IReadOnlyList RollDrops(float creatureQuality01 = 0f) { var results = new List(maxDistinctItems); - List candidates = BuildCandidatePool(); + List candidates = BuildCandidatePool(creatureQuality01); if (candidates.Count == 0) return results; @@ -85,7 +85,7 @@ public sealed class HorseLootTable : ScriptableObject CreatureLevelLootScaling.Context ctx = CreatureLevelLootScaling.Begin(origin); - IReadOnlyList drops = RollDrops(); + IReadOnlyList drops = RollDrops(ctx.quality01); Vector3 dropOrigin = origin.position + Vector3.up * 0.12f; int dropIndex = 0; @@ -106,11 +106,11 @@ public sealed class HorseLootTable : ScriptableObject return dropIndex > 0 || spawnedArrows || finishedLoot; } - private List BuildCandidatePool() + private List BuildCandidatePool(float creatureQuality01) { var candidates = new List(64); var seenIds = new HashSet(); - AppendExplicitEntries(candidates, seenIds, favoredPool); + AppendExplicitEntries(candidates, seenIds, favoredPool, creatureQuality01); if (!includeAllNonRareItems || genericNonRareWeight <= 0f) return candidates; @@ -132,13 +132,19 @@ public sealed class HorseLootTable : ScriptableObject if (!ItemLootRarity.CanDropFromGenericLoot(item, catalog)) continue; + float weight = ItemLootRarity.ResolveLootWeight(item, creatureQuality01); + if (weight <= 0.001f) + continue; + if (ItemLootRarity.ResolveTier(item) > MarketItemRarity.Tier.Rare) + continue; + seenIds.Add(item.id); int maxAmount = Mathf.Max(1, Mathf.Min(genericHi, item.maxStack)); int minAmount = Mathf.Clamp(genericLo, 1, maxAmount); candidates.Add(new Entry { item = item, - weight = genericNonRareWeight, + weight = weight, minAmount = minAmount, maxAmount = maxAmount }); @@ -147,7 +153,11 @@ public sealed class HorseLootTable : ScriptableObject return candidates; } - private static void AppendExplicitEntries(List candidates, HashSet seenIds, Entry[] rows) + private static void AppendExplicitEntries( + List candidates, + HashSet seenIds, + Entry[] rows, + float creatureQuality01) { if (rows == null || rows.Length == 0) return; @@ -155,13 +165,19 @@ public sealed class HorseLootTable : ScriptableObject for (int i = 0; i < rows.Length; i++) { Entry row = rows[i]; - if (row.item == null || row.weight <= 0f || seenIds.Contains(row.item.id)) + if (row.item == null || seenIds.Contains(row.item.id)) + continue; + + float relativeMul = row.weight > 0.001f ? row.weight : 1f; + float tierWeight = ItemLootRarity.ResolveLootWeight(row.item, creatureQuality01, relativeMul); + if (tierWeight <= 0.001f) continue; int lo = Mathf.Min(row.minAmount, row.maxAmount); int hi = Mathf.Max(row.minAmount, row.maxAmount); row.minAmount = Mathf.Max(1, lo); row.maxAmount = Mathf.Max(row.minAmount, hi); + row.weight = tierWeight; seenIds.Add(row.item.id); candidates.Add(row); } diff --git a/Assets/Scripts/Creatures/Mammoth/MammothLootTable.cs b/Assets/Scripts/Creatures/Mammoth/MammothLootTable.cs index 7d6493d42..ab643eff2 100644 --- a/Assets/Scripts/Creatures/Mammoth/MammothLootTable.cs +++ b/Assets/Scripts/Creatures/Mammoth/MammothLootTable.cs @@ -52,7 +52,7 @@ public sealed class MammothLootTable : ScriptableObject [Header("Spread")] [Min(0.08f)] public float dropSpacing = 0.55f; - public IReadOnlyList RollDrops() + public IReadOnlyList RollDrops(float creatureQuality01 = 0f) { var results = new List(8); var pickedIds = new HashSet(); @@ -60,8 +60,8 @@ public sealed class MammothLootTable : ScriptableObject AddGuaranteed(mammothHide, minHideAmount, maxHideAmount, pickedIds, results); AddGuaranteed(mammothTusk, minTuskAmount, maxTuskAmount, pickedIds, results); - RollFromPool(mammothBonusPool, minMammothBonusRolls, maxMammothBonusRolls, pickedIds, results); - RollGenericCommonUncommon(catalog, pickedIds, results); + RollFromPool(mammothBonusPool, minMammothBonusRolls, maxMammothBonusRolls, pickedIds, results, creatureQuality01); + RollGenericCommonUncommon(catalog, pickedIds, results, creatureQuality01); return results; } @@ -77,7 +77,7 @@ public sealed class MammothLootTable : ScriptableObject CreatureLevelLootScaling.Context ctx = CreatureLevelLootScaling.Begin(origin); - IReadOnlyList drops = RollDrops(); + IReadOnlyList drops = RollDrops(ctx.quality01); Vector3 dropOrigin = origin.position + Vector3.up * 0.2f; int dropIndex = 0; @@ -123,7 +123,8 @@ public sealed class MammothLootTable : ScriptableObject int minRolls, int maxRolls, HashSet pickedIds, - List outDrops) + List outDrops, + float creatureQuality01 = 0f) { if (source == null || source.Length == 0) return; @@ -132,13 +133,19 @@ public sealed class MammothLootTable : ScriptableObject for (int i = 0; i < source.Length; i++) { Entry row = source[i]; - if (row.item == null || row.weight <= 0f || pickedIds.Contains(row.item.id)) + if (row.item == null || pickedIds.Contains(row.item.id)) + continue; + + float relativeMul = row.weight > 0.001f ? row.weight : 1f; + float tierWeight = ItemLootRarity.ResolveLootWeight(row.item, creatureQuality01, relativeMul); + if (tierWeight <= 0.001f) continue; int lo = Mathf.Max(1, Mathf.Min(row.minAmount, row.maxAmount)); int hi = Mathf.Max(lo, Mathf.Max(row.minAmount, row.maxAmount)); row.minAmount = lo; row.maxAmount = hi; + row.weight = tierWeight; candidates.Add(row); } @@ -167,12 +174,14 @@ public sealed class MammothLootTable : ScriptableObject } } - private void RollGenericCommonUncommon(MarketCatalog catalog, HashSet pickedIds, List outDrops) + private void RollGenericCommonUncommon( + MarketCatalog catalog, + HashSet pickedIds, + List outDrops, + float creatureQuality01 = 0f) { if (!includeGenericCommonUncommon) return; - if (genericCommonWeight <= 0f && genericUncommonWeight <= 0f) - return; ItemDatabase db = ResolveItemDatabase(); if (db?.items == null || db.items.Count == 0) @@ -189,15 +198,12 @@ public sealed class MammothLootTable : ScriptableObject if (!ItemLootRarity.CanDropFromCreatureBonusLoot(item, catalog)) continue; - MarketItemRarity.Profile profile = MarketItemRarity.GetProfile(item); - bool uncommon = IsUncommon(item, catalog); - bool rare = ItemLootRarity.IsRare(item, catalog); - float weight = rare ? genericUncommonWeight * 1.35f - : uncommon ? genericUncommonWeight - : genericCommonWeight; - if (profile.Tier == MarketItemRarity.Tier.Abundant) - weight *= Mathf.Max(1f, profile.CatalogWeight); - if (weight <= 0f) + MarketItemRarity.Tier tier = MarketItemRarity.GetProfile(item).Tier; + if (tier > MarketItemRarity.Tier.Rare) + continue; + + float weight = ItemLootRarity.ResolveLootWeight(item, creatureQuality01); + if (weight <= 0.001f) continue; int maxAmount = Mathf.Max(1, Mathf.Min(genericMaxAmount, item.maxStack)); diff --git a/Assets/Scripts/Creatures/MushroomFairy/MushroomFairyLootTable.cs b/Assets/Scripts/Creatures/MushroomFairy/MushroomFairyLootTable.cs index 9a92b3024..1d8b36d05 100644 --- a/Assets/Scripts/Creatures/MushroomFairy/MushroomFairyLootTable.cs +++ b/Assets/Scripts/Creatures/MushroomFairy/MushroomFairyLootTable.cs @@ -44,13 +44,20 @@ public sealed class MushroomFairyLootTable : ScriptableObject for (int i = 0; i < pool.Length; i++) { Entry row = pool[i]; - if (row.item == null || row.weight <= 0f) + if (row.item == null) + continue; + + // Loot rate comes from MarketItemRarity tier; serialized weight is a relative mul only. + float relativeMul = row.weight > 0.001f ? row.weight : 1f; + float tierWeight = ItemLootRarity.ResolveLootWeight(row.item, relativeMul: relativeMul); + if (tierWeight <= 0.001f) continue; int lo = Mathf.Min(row.minAmount, row.maxAmount); int hi = Mathf.Max(row.minAmount, row.maxAmount); row.minAmount = lo; row.maxAmount = hi; + row.weight = tierWeight; candidates.Add(row); } diff --git a/Assets/Scripts/Creatures/Nightling/Editor/NightlingSetupEditorTools.cs b/Assets/Scripts/Creatures/Nightling/Editor/NightlingSetupEditorTools.cs index 35ee4b70f..bf096196d 100644 --- a/Assets/Scripts/Creatures/Nightling/Editor/NightlingSetupEditorTools.cs +++ b/Assets/Scripts/Creatures/Nightling/Editor/NightlingSetupEditorTools.cs @@ -29,6 +29,7 @@ public static class NightlingSetupEditorTools private const string ShadowShardItemPath = "Assets/ScriptableObjects/Items/Nightling Shadow Shard.asset"; private const string StoneWispEyeItemPath = "Assets/ScriptableObjects/Items/Stone Wisp Eye.asset"; private const string GloomDustItemPath = "Assets/ScriptableObjects/Items/Gloom Dust.asset"; + private const string DuskFeeTokenPath = "Assets/ScriptableObjects/Items/Dusk-Fee Token.asset"; private const string EmissiveMaterialFolder = "Assets/ThirdParty/DownloadedAssets/stonewisp-mutant-stingray-rigged-and-animated/textures"; @@ -43,6 +44,7 @@ public static class NightlingSetupEditorTools private const int ShadowShardItemId = 519; private const int StoneWispEyeItemId = 520; private const int GloomDustItemId = 521; + private const int DuskFeeTokenId = 522; private const uint NetworkAssetId = 3123501245u; private const float PrefabScale = 0.62f; @@ -93,14 +95,22 @@ public static class NightlingSetupEditorTools "Fine ash left when a nightling's glow gutters out. Useful in dusk-tinted brews.", null, maxStack: 20); + Item duskFee = EnsureItem( + DuskFeeTokenPath, + DuskFeeTokenId, + "Dusk-Fee Token", + "A dull disc the Nightling court leaves when a dusk-fee is collected. Still warm after sundown. Nightling-only find.", + null, + maxStack: 5); RegisterItemInDatabase(log, hide); RegisterItemInDatabase(log, essence); RegisterItemInDatabase(log, shadowShard); RegisterItemInDatabase(log, stoneWispEye); RegisterItemInDatabase(log, gloomDust); + RegisterItemInDatabase(log, duskFee); - NightlingLootTable lootTable = EnsureLootTable(log, hide, essence, shadowShard, stoneWispEye, gloomDust); + NightlingLootTable lootTable = EnsureLootTable(log, hide, essence, shadowShard, stoneWispEye, gloomDust, duskFee); NightlingRigRefsSetup.CreateOrUpdateRigRefs(log: false); RecreateNightlingPrefab(log, animator, lootTable); RegisterSpawnablePrefab(log, AssetDatabase.LoadAssetAtPath(PrefabPath)); @@ -731,6 +741,12 @@ public static class NightlingSetupEditorTools nightlingSo.FindProperty("mortarChargeKnockMulAtMax").floatValue = 1.9f; nightlingSo.FindProperty("mortarChargeRadiusMulAtMax").floatValue = 1.55f; nightlingSo.FindProperty("mortarChargeVisualScaleAtMax").floatValue = 2.65f; + SerializedProperty chargeDmg = nightlingSo.FindProperty("chargeContactDamage"); + if (chargeDmg != null) chargeDmg.floatValue = 10f; + SerializedProperty chargeRadius = nightlingSo.FindProperty("chargeContactRadius"); + if (chargeRadius != null) chargeRadius.floatValue = 1.35f; + SerializedProperty chargeInterval = nightlingSo.FindProperty("chargeContactInterval"); + if (chargeInterval != null) chargeInterval.floatValue = 0.35f; nightlingSo.FindProperty("mortarArcGravity").floatValue = 24f; nightlingSo.FindProperty("mortarLeadSeconds").floatValue = 0.55f; nightlingSo.FindProperty("mortarLeadAccuracy").floatValue = 0.42f; @@ -830,7 +846,8 @@ public static class NightlingSetupEditorTools Item essence, Item shadowShard, Item stoneWispEye, - Item gloomDust) + Item gloomDust, + Item duskFee) { NightlingLootTable table = AssetDatabase.LoadAssetAtPath(LootTablePath); if (table == null) @@ -848,6 +865,10 @@ public static class NightlingSetupEditorTools table.genericNonRareWeight = 1f; table.genericMinAmount = 1; table.genericMaxAmount = 2; + table.signatureRareItem = duskFee; + table.signatureRareChance = 0.1f; + table.signatureRareMinAmount = 1; + table.signatureRareMaxAmount = 1; table.favoredPool = new[] { new NightlingLootTable.Entry { item = hide, weight = 12f, minAmount = 1, maxAmount = 2 }, @@ -863,6 +884,7 @@ public static class NightlingSetupEditorTools int genericCount = table.itemDatabase != null ? table.itemDatabase.items?.Count ?? 0 : 0; log.AppendLine( $" Nightling loot: favored={table.favoredPool.Length} bonus={table.bonusPool.Length} " + + $"signature={(duskFee != null ? duskFee.itemName : "none")}@{table.signatureRareChance:P0} " + $"+ up to {genericCount} non-rare items (1–3 loot bags per kill)."); return table; } diff --git a/Assets/Scripts/Creatures/Nightling/NightlingController.cs b/Assets/Scripts/Creatures/Nightling/NightlingController.cs index 4ce6db3e1..0357dfad9 100644 --- a/Assets/Scripts/Creatures/Nightling/NightlingController.cs +++ b/Assets/Scripts/Creatures/Nightling/NightlingController.cs @@ -151,6 +151,13 @@ public sealed class NightlingController : NetworkBehaviour, ICreatureStartleCall [SerializeField] private float mortarChargeRadiusMulAtMax = 1.55f; [Tooltip("Charge VFX scale at full charge.")] [SerializeField] private float mortarChargeVisualScaleAtMax = 2.65f; + [Header("Charge contact damage")] + [Tooltip("Walking into the swirling charge orb hurts the player while an attack is winding up.")] + [SerializeField] private float chargeContactDamage = 10f; + [SerializeField] private float chargeContactRadius = 1.35f; + [SerializeField] private float chargeContactInterval = 0.35f; + [SerializeField] private float chargeContactKnockPlanar = 6.5f; + [SerializeField] private float chargeContactKnockLift = 2.2f; [SerializeField] private float mortarArcGravity = 24f; [SerializeField] private float mortarLeadSeconds = 0.55f; [Tooltip("0 = aim at current player position, 1 = full movement prediction.")] @@ -247,6 +254,9 @@ public sealed class NightlingController : NetworkBehaviour, ICreatureStartleCall private float lastDamagedPlayerTime = -999f; private bool attackFxApplied; private GameObject activeAttackChargeFx; + private bool activeChargeIsMortar; + private float nextChargeContactDamageTime = -999f; + private static readonly Collider[] s_chargeContactHits = new Collider[24]; private int debugCombatWindupSession = -1; private int debugCombatReadySession = -1; private static readonly Dictionary s_globalNextNightlingHitAllowed = new(16); @@ -1100,6 +1110,9 @@ public sealed class NightlingController : NetworkBehaviour, ICreatureStartleCall if (output.mode == NightlingBrain.AiMode.Mortar) UpdateMortarLaunchWindow(); + if (output.mode is NightlingBrain.AiMode.Attack or NightlingBrain.AiMode.Mortar) + UpdateChargeContactDamage(); + float locomotionSpeed = output.mode is NightlingBrain.AiMode.Attack or NightlingBrain.AiMode.Mortar or NightlingBrain.AiMode.Hurt @@ -1711,6 +1724,8 @@ public sealed class NightlingController : NetworkBehaviour, ICreatureStartleCall private void PlayAttackChargeFx(bool mortarCharge) { ClearAttackChargeFx(); + activeChargeIsMortar = mortarCharge; + nextChargeContactDamageTime = Time.time; float duration = mortarCharge ? mortarMaxCharge + 0.35f @@ -1732,6 +1747,7 @@ public sealed class NightlingController : NetworkBehaviour, ICreatureStartleCall private void SpawnAttackChargeFxLocal(bool mortarCharge, float duration, float visualScaleAtMax) { + activeChargeIsMortar = mortarCharge; activeAttackChargeFx = NightlingShadowBallAttackFx.AttachAttackCharge( transform, GetAttackChargeLocalOffset(mortarCharge), @@ -1747,6 +1763,76 @@ public sealed class NightlingController : NetworkBehaviour, ICreatureStartleCall Destroy(activeAttackChargeFx); activeAttackChargeFx = null; + activeChargeIsMortar = false; + } + + /// + /// Players who walk into the swirling charge orb take periodic contact damage + /// while melee / mortar is winding up. + /// + private void UpdateChargeContactDamage() + { + if (!HasAiAuthority || isDead) + return; + if (Time.time < nextChargeContactDamageTime) + return; + + // Prefer live charge VFX; fall back to charge socket while mortar/melee windup is active. + bool charging = activeAttackChargeFx != null + || (activeChargeIsMortar && mortarWindupStartedTime >= 0f && lastLaunchedMortarSessionId != mortarSessionId) + || (!activeChargeIsMortar && Time.time < attackStartedTime + Mathf.Max(0.35f, attackHitDelay)); + if (!charging) + return; + + nextChargeContactDamageTime = Time.time + Mathf.Max(0.12f, chargeContactInterval); + + float charge01 = activeChargeIsMortar ? GetMortarCharge01() : 0.55f; + float radiusMul = activeChargeIsMortar + ? Mathf.Lerp(1f, mortarChargeRadiusMulAtMax, charge01) + : Mathf.Lerp(1f, 1.25f, charge01); + float radius = Mathf.Max(0.55f, chargeContactRadius * radiusMul); + float damage = chargeContactDamage * Mathf.Lerp(0.75f, activeChargeIsMortar ? 1.55f : 1.15f, charge01); + if (damage <= 0.5f) + return; + + Vector3 center = activeAttackChargeFx != null + ? activeAttackChargeFx.transform.position + : transform.TransformPoint(GetAttackChargeLocalOffset(activeChargeIsMortar)); + + int hitCount = Physics.OverlapSphereNonAlloc( + center, + radius, + s_chargeContactHits, + attackHitMask, + QueryTriggerInteraction.Ignore); + + for (int i = 0; i < hitCount; i++) + { + Collider hit = s_chargeContactHits[i]; + if (hit == null) + continue; + if (hit.transform == transform || hit.transform.IsChildOf(transform)) + continue; + + PlayerHealth playerHealth = hit.GetComponentInParent(); + if (playerHealth == null || playerHealth.currentHealth <= 0.5f) + continue; + if (!CanGloballyHitPlayer(playerHealth, Time.time)) + continue; + + Transform player = playerHealth.transform; + Vector3 knock = ComputeMortarKnockImpulse( + player, + center, + chargeContactKnockPlanar, + chargeContactKnockLift); + NightlingCombatDebug.RegisterKnockback(this, playerHealth, knock); + playerHealth.TakeDamage(damage, center, knock); + RegisterGlobalNightlingHit(playerHealth, Time.time); + NightlingCombatDebug.Log(this, + $"CHARGE CONTACT dmg={damage:F1} radius={radius:F2} charge={charge01:F2} " + + $"player={player.name} HP={playerHealth.currentHealth:F1}"); + } } private Vector3 GetAttackChargeLocalOffset(bool mortarCharge) @@ -2825,6 +2911,11 @@ public sealed class NightlingController : NetworkBehaviour, ICreatureStartleCall mortarChargeKnockMulAtMax = Mathf.Clamp(mortarChargeKnockMulAtMax, 1f, 3.5f); mortarChargeRadiusMulAtMax = Mathf.Clamp(mortarChargeRadiusMulAtMax, 1f, 2.5f); mortarChargeVisualScaleAtMax = Mathf.Clamp(mortarChargeVisualScaleAtMax, 1.2f, 4f); + chargeContactDamage = Mathf.Clamp(chargeContactDamage, 0f, 40f); + chargeContactRadius = Mathf.Clamp(chargeContactRadius, 0.45f, 4f); + chargeContactInterval = Mathf.Clamp(chargeContactInterval, 0.12f, 1.5f); + chargeContactKnockPlanar = Mathf.Clamp(chargeContactKnockPlanar, 0f, 30f); + chargeContactKnockLift = Mathf.Clamp(chargeContactKnockLift, 0f, 20f); mortarCooldown = Mathf.Max(1.2f, mortarCooldown); mortarDamage = Mathf.Clamp(mortarDamage, 0f, 80f); mortarExplosionRadius = Mathf.Clamp(mortarExplosionRadius, 2f, 14f); diff --git a/Assets/Scripts/Creatures/Nightling/NightlingLootTable.cs b/Assets/Scripts/Creatures/Nightling/NightlingLootTable.cs index 510b7504f..db1895985 100644 --- a/Assets/Scripts/Creatures/Nightling/NightlingLootTable.cs +++ b/Assets/Scripts/Creatures/Nightling/NightlingLootTable.cs @@ -39,6 +39,13 @@ public sealed class NightlingLootTable : ScriptableObject [Header("Secondary themed drops")] public Entry[] bonusPool; + [Header("Signature rare (Nightling-only)")] + [Tooltip("Exclusive rare drop — rolled separately so it feels special when it lands.")] + public Item signatureRareItem; + [Range(0f, 1f)] public float signatureRareChance = 0.1f; + [Min(1)] public int signatureRareMinAmount = 1; + [Min(1)] public int signatureRareMaxAmount = 1; + [Header("All other non-rare items")] public ItemDatabase itemDatabase; public bool includeAllNonRareItems = true; @@ -46,16 +53,25 @@ public sealed class NightlingLootTable : ScriptableObject [Min(1)] public int genericMinAmount = 1; [Min(1)] public int genericMaxAmount = 2; - public IReadOnlyList RollDrops() + public IReadOnlyList RollDrops(float creatureQuality01 = 0f) { - var results = new List(maxDistinctItems); - List candidates = BuildCandidatePool(); + var results = new List(maxDistinctItems + 1); + var seenIds = new HashSet(); + + TryRollSignatureRare(results, seenIds, creatureQuality01); + + List candidates = BuildCandidatePool(creatureQuality01, seenIds); if (candidates.Count == 0) return results; + int remainingSlots = Mathf.Max(0, maxDistinctItems - results.Count); + if (remainingSlots <= 0) + return results; + int loDistinct = Mathf.Min(minDistinctItems, candidates.Count); int hiDistinct = Mathf.Min(Mathf.Max(minDistinctItems, maxDistinctItems), candidates.Count); int pickCount = Random.Range(loDistinct, hiDistinct + 1); + pickCount = Mathf.Min(pickCount, remainingSlots); for (int pick = 0; pick < pickCount; pick++) { @@ -70,12 +86,42 @@ public sealed class NightlingLootTable : ScriptableObject if (amount <= 0) continue; + seenIds.Add(chosen.item.id); results.Add(new ResolvedDrop { item = chosen.item, amount = amount }); } return results; } + private void TryRollSignatureRare( + List results, + HashSet seenIds, + float creatureQuality01) + { + if (signatureRareItem == null || signatureRareItem.id <= 0) + return; + if (seenIds.Contains(signatureRareItem.id)) + return; + + float chance = signatureRareChance; + // High-level Nightlings pay the fee more often. + chance = Mathf.Clamp01(Mathf.Lerp(chance, Mathf.Min(1f, chance * 2.2f), Mathf.Clamp01(creatureQuality01))); + if (chance <= 0f || Random.value > chance) + return; + + int lo = Mathf.Max(1, Mathf.Min(signatureRareMinAmount, signatureRareMaxAmount)); + int hi = Mathf.Max(lo, Mathf.Max(signatureRareMinAmount, signatureRareMaxAmount)); + if (creatureQuality01 > 0.72f && hi < signatureRareItem.maxStack) + hi = Mathf.Min(signatureRareItem.maxStack, hi + 1); + + int amount = Random.Range(lo, hi + 1); + if (amount <= 0) + return; + + seenIds.Add(signatureRareItem.id); + results.Add(new ResolvedDrop { item = signatureRareItem, amount = amount }); + } + public bool TrySpawnDrops(Transform origin, GameObject lootBagPrefab) { if (origin == null || lootBagPrefab == null) @@ -94,7 +140,7 @@ public sealed class NightlingLootTable : ScriptableObject int oldMax = maxDistinctItems; minDistinctItems = minDistinct; maxDistinctItems = maxDistinct; - IReadOnlyList drops = RollDrops(); + IReadOnlyList drops = RollDrops(ctx.quality01); minDistinctItems = oldMin; maxDistinctItems = oldMax; Vector3 dropOrigin = origin.position + Vector3.up * 0.12f; @@ -117,13 +163,13 @@ public sealed class NightlingLootTable : ScriptableObject return dropIndex > 0 || spawnedArrows || finishedLoot; } - private List BuildCandidatePool() + private List BuildCandidatePool(float creatureQuality01, HashSet seenIds) { var candidates = new List(64); - var seenIds = new HashSet(); + seenIds ??= new HashSet(); - AppendExplicitEntries(candidates, seenIds, favoredPool); - AppendExplicitEntries(candidates, seenIds, bonusPool); + AppendExplicitEntries(candidates, seenIds, favoredPool, creatureQuality01); + AppendExplicitEntries(candidates, seenIds, bonusPool, creatureQuality01); if (includeAllNonRareItems && genericNonRareWeight > 0f) { @@ -142,10 +188,11 @@ public sealed class NightlingLootTable : ScriptableObject if (!ItemLootRarity.CanDropFromCreatureBonusLoot(item, catalog)) continue; + if (ItemLootRarity.ResolveTier(item) > MarketItemRarity.Tier.Rare) + continue; - bool rare = ItemLootRarity.IsRare(item, catalog); - float weight = rare ? genericNonRareWeight * 0.28f : genericNonRareWeight; - if (weight <= 0f) + float weight = ItemLootRarity.ResolveLootWeight(item, creatureQuality01); + if (weight <= 0.001f) continue; seenIds.Add(item.id); @@ -165,7 +212,11 @@ public sealed class NightlingLootTable : ScriptableObject return candidates; } - private static void AppendExplicitEntries(List candidates, HashSet seenIds, Entry[] rows) + private static void AppendExplicitEntries( + List candidates, + HashSet seenIds, + Entry[] rows, + float creatureQuality01) { if (rows == null || rows.Length == 0) return; @@ -173,13 +224,19 @@ public sealed class NightlingLootTable : ScriptableObject for (int i = 0; i < rows.Length; i++) { Entry row = rows[i]; - if (row.item == null || row.weight <= 0f || seenIds.Contains(row.item.id)) + if (row.item == null || seenIds.Contains(row.item.id)) + continue; + + float relativeMul = row.weight > 0.001f ? row.weight : 1f; + float tierWeight = ItemLootRarity.ResolveLootWeight(row.item, creatureQuality01, relativeMul); + if (tierWeight <= 0.001f) continue; int lo = Mathf.Min(row.minAmount, row.maxAmount); int hi = Mathf.Max(row.minAmount, row.maxAmount); row.minAmount = Mathf.Max(1, lo); row.maxAmount = Mathf.Max(row.minAmount, hi); + row.weight = tierWeight; seenIds.Add(row.item.id); candidates.Add(row); } diff --git a/Assets/Scripts/Creatures/Skeleton/SkeletonLootTable.cs b/Assets/Scripts/Creatures/Skeleton/SkeletonLootTable.cs index e0127dd6a..2fe0dec70 100644 --- a/Assets/Scripts/Creatures/Skeleton/SkeletonLootTable.cs +++ b/Assets/Scripts/Creatures/Skeleton/SkeletonLootTable.cs @@ -70,7 +70,7 @@ public sealed class SkeletonLootTable : ScriptableObject if (bonusChance > 0f && Random.value <= bonusChance) { - List picks = RollBonusItems(ctx.pickedIds, commonW, rareW, minRolls, maxRolls); + List picks = RollBonusItems(ctx.pickedIds, commonW, rareW, minRolls, maxRolls, ctx.quality01); for (int i = 0; i < picks.Count; i++) { Item item = picks[i]; @@ -108,15 +108,13 @@ public sealed class SkeletonLootTable : ScriptableObject float commonW, float rareW, int minRolls, - int maxRolls) + int maxRolls, + float creatureQuality01 = 0f) { var results = new List(2); ItemDatabase db = ResolveItemDatabase(); if (db?.items == null || db.items.Count == 0) return results; - if (commonW <= 0f && rareW <= 0f) - return results; - MarketCatalog catalog = MarketCatalog.Resolve(null); var candidates = new List<(Item item, float weight)>(db.items.Count); for (int i = 0; i < db.items.Count; i++) @@ -127,9 +125,12 @@ public sealed class SkeletonLootTable : ScriptableObject if (!ItemLootRarity.CanDropFromCreatureBonusLoot(item, catalog)) continue; - bool rare = ItemLootRarity.IsRare(item, catalog); - float weight = rare ? rareW : commonW; - if (weight <= 0f) + float weight = ItemLootRarity.ResolveLootWeight(item, creatureQuality01); + if (weight <= 0.001f) + continue; + + // Bonus skeleton loot stays at Rare or below. + if (ItemLootRarity.ResolveTier(item) > MarketItemRarity.Tier.Rare) continue; candidates.Add((item, weight)); diff --git a/Assets/Scripts/Creatures/Troll/TrollLootTable.cs b/Assets/Scripts/Creatures/Troll/TrollLootTable.cs index 5b7e3e990..5372d6587 100644 --- a/Assets/Scripts/Creatures/Troll/TrollLootTable.cs +++ b/Assets/Scripts/Creatures/Troll/TrollLootTable.cs @@ -44,14 +44,14 @@ public sealed class TrollLootTable : ScriptableObject [Header("Spread")] [Min(0.08f)] public float dropSpacing = 0.52f; - public IReadOnlyList RollDrops() + public IReadOnlyList RollDrops(float creatureQuality01 = 0f) { var results = new List(8); var pickedIds = new HashSet(); MarketCatalog catalog = MarketCatalog.Resolve(null); - RollFromPool(trollSpecificPool, minTrollSpecificRolls, maxTrollSpecificRolls, pickedIds, results); - RollGenericCommonUncommon(catalog, pickedIds, results); + RollFromPool(trollSpecificPool, minTrollSpecificRolls, maxTrollSpecificRolls, pickedIds, results, creatureQuality01); + RollGenericCommonUncommon(catalog, pickedIds, results, creatureQuality01); return results; } @@ -67,7 +67,7 @@ public sealed class TrollLootTable : ScriptableObject CreatureLevelLootScaling.Context ctx = CreatureLevelLootScaling.Begin(origin); - IReadOnlyList drops = RollDrops(); + IReadOnlyList drops = RollDrops(ctx.quality01); Vector3 dropOrigin = origin.position + Vector3.up * 0.12f; int dropIndex = 0; @@ -93,7 +93,8 @@ public sealed class TrollLootTable : ScriptableObject int minRolls, int maxRolls, HashSet pickedIds, - List outDrops) + List outDrops, + float creatureQuality01 = 0f) { if (source == null || source.Length == 0) return; @@ -102,13 +103,19 @@ public sealed class TrollLootTable : ScriptableObject for (int i = 0; i < source.Length; i++) { Entry row = source[i]; - if (row.item == null || row.weight <= 0f || pickedIds.Contains(row.item.id)) + if (row.item == null || pickedIds.Contains(row.item.id)) + continue; + + float relativeMul = row.weight > 0.001f ? row.weight : 1f; + float tierWeight = ItemLootRarity.ResolveLootWeight(row.item, creatureQuality01, relativeMul); + if (tierWeight <= 0.001f) continue; int lo = Mathf.Max(1, Mathf.Min(row.minAmount, row.maxAmount)); int hi = Mathf.Max(lo, Mathf.Max(row.minAmount, row.maxAmount)); row.minAmount = lo; row.maxAmount = hi; + row.weight = tierWeight; candidates.Add(row); } @@ -137,12 +144,14 @@ public sealed class TrollLootTable : ScriptableObject } } - private void RollGenericCommonUncommon(MarketCatalog catalog, HashSet pickedIds, List outDrops) + private void RollGenericCommonUncommon( + MarketCatalog catalog, + HashSet pickedIds, + List outDrops, + float creatureQuality01 = 0f) { if (!includeGenericCommonUncommon) return; - if (genericCommonWeight <= 0f && genericUncommonWeight <= 0f) - return; ItemDatabase db = ResolveItemDatabase(); if (db?.items == null || db.items.Count == 0) @@ -159,15 +168,13 @@ public sealed class TrollLootTable : ScriptableObject if (!ItemLootRarity.CanDropFromCreatureBonusLoot(item, catalog)) continue; - MarketItemRarity.Profile profile = MarketItemRarity.GetProfile(item); - bool uncommon = IsUncommon(item, catalog); - bool rare = ItemLootRarity.IsRare(item, catalog); - float weight = rare ? genericUncommonWeight * 1.35f - : uncommon ? genericUncommonWeight - : genericCommonWeight; - if (profile.Tier == MarketItemRarity.Tier.Abundant) - weight *= Mathf.Max(1f, profile.CatalogWeight); - if (weight <= 0f) + // Cap generic troll extras at Rare — Epic stays boss-themed. + MarketItemRarity.Tier tier = MarketItemRarity.GetProfile(item).Tier; + if (tier > MarketItemRarity.Tier.Rare) + continue; + + float weight = ItemLootRarity.ResolveLootWeight(item, creatureQuality01); + if (weight <= 0.001f) continue; int maxAmount = Mathf.Max(1, Mathf.Min(genericMaxAmount, item.maxStack)); diff --git a/Assets/Scripts/Creatures/Vikra/VikraLootTable.cs b/Assets/Scripts/Creatures/Vikra/VikraLootTable.cs index c5f1eed8c..73f1c9428 100644 --- a/Assets/Scripts/Creatures/Vikra/VikraLootTable.cs +++ b/Assets/Scripts/Creatures/Vikra/VikraLootTable.cs @@ -47,7 +47,7 @@ public sealed class VikraLootTable : ScriptableObject [Header("Spread")] [Min(0.08f)] public float dropSpacing = 0.5f; - public IReadOnlyList RollDrops() + public IReadOnlyList RollDrops(float creatureQuality01 = 0f) { var results = new List(6); var seenIds = new HashSet(); @@ -60,11 +60,11 @@ public sealed class VikraLootTable : ScriptableObject seenIds.Add(rawMeat.id); } - AppendWeightedPicks(results, seenIds, themedPool, minThemedItems, maxThemedItems); + AppendWeightedPicks(results, seenIds, themedPool, minThemedItems, maxThemedItems, creatureQuality01); if (randomGenericChance > 0f && Random.value <= randomGenericChance) { - List generics = BuildGenericCandidatePool(seenIds); + List generics = BuildGenericCandidatePool(seenIds, creatureQuality01); if (generics.Count > 0) { int index = PickWeightedIndex(generics); @@ -94,7 +94,7 @@ public sealed class VikraLootTable : ScriptableObject CreatureLevelLootScaling.Context ctx = CreatureLevelLootScaling.Begin(origin); - IReadOnlyList drops = RollDrops(); + IReadOnlyList drops = RollDrops(ctx.quality01); Vector3 dropOrigin = origin.position + Vector3.up * 0.18f; int dropIndex = 0; @@ -120,10 +120,11 @@ public sealed class VikraLootTable : ScriptableObject HashSet seenIds, Entry[] pool, int minPicks, - int maxPicks) + int maxPicks, + float creatureQuality01) { List candidates = new List(8); - AppendExplicitEntries(candidates, seenIds, pool); + AppendExplicitEntries(candidates, seenIds, pool, creatureQuality01); if (candidates.Count == 0) return; @@ -148,7 +149,7 @@ public sealed class VikraLootTable : ScriptableObject } } - private List BuildGenericCandidatePool(HashSet seenIds) + private List BuildGenericCandidatePool(HashSet seenIds, float creatureQuality01) { var candidates = new List(32); if (!includeAllNonRareItems || genericNonRareWeight <= 0f) @@ -170,13 +171,19 @@ public sealed class VikraLootTable : ScriptableObject if (!ItemLootRarity.CanDropFromGenericLoot(item, catalog)) continue; + if (ItemLootRarity.ResolveTier(item) > MarketItemRarity.Tier.Rare) + continue; + + float weight = ItemLootRarity.ResolveLootWeight(item, creatureQuality01); + if (weight <= 0.001f) + continue; int maxAmount = Mathf.Max(1, Mathf.Min(genericHi, item.maxStack)); int minAmount = Mathf.Clamp(genericLo, 1, maxAmount); candidates.Add(new Entry { item = item, - weight = genericNonRareWeight, + weight = weight, minAmount = minAmount, maxAmount = maxAmount }); @@ -185,7 +192,11 @@ public sealed class VikraLootTable : ScriptableObject return candidates; } - private static void AppendExplicitEntries(List candidates, HashSet seenIds, Entry[] rows) + private static void AppendExplicitEntries( + List candidates, + HashSet seenIds, + Entry[] rows, + float creatureQuality01) { if (rows == null || rows.Length == 0) return; @@ -193,13 +204,20 @@ public sealed class VikraLootTable : ScriptableObject for (int i = 0; i < rows.Length; i++) { Entry row = rows[i]; - if (row.item == null || row.weight <= 0f || seenIds.Contains(row.item.id)) + if (row.item == null || seenIds.Contains(row.item.id)) + continue; + + // Serialized weight is a relative mul only; 0/1 = pure MarketItemRarity tier rate. + float relativeMul = row.weight > 0.001f ? row.weight : 1f; + float tierWeight = ItemLootRarity.ResolveLootWeight(row.item, creatureQuality01, relativeMul); + if (tierWeight <= 0.001f) continue; int lo = Mathf.Min(row.minAmount, row.maxAmount); int hi = Mathf.Max(row.minAmount, row.maxAmount); row.minAmount = Mathf.Max(1, lo); row.maxAmount = Mathf.Max(row.minAmount, hi); + row.weight = tierWeight; candidates.Add(row); } } diff --git a/Assets/Scripts/Debug/ManafallDisableMirrorImgui.cs b/Assets/Scripts/Debug/ManafallDisableMirrorImgui.cs index ef2c2ddf4..9e4df0a66 100644 --- a/Assets/Scripts/Debug/ManafallDisableMirrorImgui.cs +++ b/Assets/Scripts/Debug/ManafallDisableMirrorImgui.cs @@ -4,12 +4,16 @@ using UnityEngine; /// /// Disables Mirror IMGUI HUDs that allocate tens of KB every frame via OnGUI. /// CLERK: also re-assert after a short delay — NetworkManager can re-enable showGUI on play. +/// Clears sticky F3/F4 debug overlay prefs once so player/probe builds start without screen clutter. /// public static class ManafallDisableMirrorImgui { + private const string DebugOverlaysHiddenPrefsKey = "Manafall.DebugOverlaysHidden_v1"; + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] private static void DisableHeavyImgui() { + ClearStickyDebugOverlayPrefsOnce(); KillMirrorImgui(); var go = new GameObject("ClerkMirrorHudKiller"); Object.DontDestroyOnLoad(go); @@ -17,6 +21,23 @@ public static class ManafallDisableMirrorImgui go.AddComponent(); } + /// + /// One-shot for installed players: turn off saved F3/F4 visibility that agents left on. + /// Does not run in the Editor (F3 still defaults on there). + /// + public static void ClearStickyDebugOverlayPrefsOnce() + { + if (Application.isEditor) + return; + if (PlayerPrefs.GetInt(DebugOverlaysHiddenPrefsKey, 0) != 0) + return; + + PlayerPrefs.SetInt("Manafall.ViewCostDebug", 0); + PlayerPrefs.SetInt("Manafall.MemorySpikeDebug", 0); + PlayerPrefs.SetInt(DebugOverlaysHiddenPrefsKey, 1); + PlayerPrefs.Save(); + } + public static void KillMirrorImgui() { NetworkManagerHUD[] huds = Object.FindObjectsByType( diff --git a/Assets/Scripts/Debug/VillageTeleportDebug.cs b/Assets/Scripts/Debug/VillageTeleportDebug.cs index 577501cc7..c588c2a76 100644 --- a/Assets/Scripts/Debug/VillageTeleportDebug.cs +++ b/Assets/Scripts/Debug/VillageTeleportDebug.cs @@ -1,27 +1,147 @@ using Mirror; using UnityEngine; +using UnityEngine.InputSystem; /// -/// Debug: press F5 to teleport the local player to Npc Village 1 (Forest landmark). +/// Debug: F4 teleports to the volcano island. F5 teleports to the void island. /// public static class VillageTeleportDebug { public const KeyCode TeleportKey = KeyCode.F5; + private static InputAction volcanoKey; + private static InputAction voidKey; + private static int lastPollFrame = -1; + private static bool volcanoHeld; + private static bool voidHeld; + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] private static void EnsureDriver() { -#if !UNITY_EDITOR - return; -#else if (Object.FindFirstObjectByType() != null) return; var go = new GameObject(nameof(VillageTeleportDebugDriver)); Object.DontDestroyOnLoad(go); - go.hideFlags = HideFlags.HideAndDontSave; + go.hideFlags = HideFlags.HideInHierarchy; go.AddComponent(); -#endif + } + + /// Called from the local player so the keys still work if the hidden driver was dropped. + public static void PollIslandHotkeys() + { + if (!Application.isPlaying) + return; + if (lastPollFrame == Time.frameCount) + return; + lastPollFrame = Time.frameCount; + + EnsureKeys(); + if (Pressed(volcanoKey, ref volcanoHeld)) + CompleteJump(TryTeleportToVolcanoIsland(), "[VolcanoTeleport]"); + else if (Pressed(voidKey, ref voidHeld)) + CompleteJump(TryTeleportToVoidBiome(), "[VoidTeleport]"); + } + + private static void EnsureKeys() + { + if (volcanoKey != null) + return; + + volcanoKey = new InputAction("DebugVolcanoTeleport", InputActionType.Button); + volcanoKey.AddBinding("/f4"); + volcanoKey.Enable(); + + voidKey = new InputAction("DebugVoidTeleport", InputActionType.Button); + voidKey.AddBinding("/f5"); + voidKey.Enable(); + } + + private static bool Pressed(InputAction action, ref bool held) + { + bool down = action != null && action.IsPressed(); + bool edge = down && !held; + held = down; + if (edge || (action != null && action.WasPressedThisFrame())) + return true; + return false; + } + + private static void CompleteJump(string result, string tag) + { + if (result.StartsWith("Teleported")) + Debug.Log($"{tag} {result}"); + else + Debug.LogWarning($"{tag} {result}"); + } + + public static string TryTeleportToVolcanoIsland() + { + if (!Application.isPlaying) + return "Enter Play Mode first."; + + Transform player = ResolveLocalPlayer(); + if (player == null) + return "No local player found yet."; + + if (!IsPlayerReadyForWorld(player)) + return "Waiting for class select / limbo exit."; + + WorldGenerator world = Object.FindFirstObjectByType(); + if (world == null) + return "No WorldGenerator in scene."; + + world.EnsureWorldReady(); + if (!world.TryGetVolcanoPlayerSpawnPose(out Vector3 pos, out Quaternion rot)) + return "Volcano island is not ready yet."; + + return FinishIslandTeleport(player.gameObject, world, pos, rot, "the volcano island"); + } + + public static string TryTeleportToVoidBiome() + { + if (!Application.isPlaying) + return "Enter Play Mode first."; + + Transform player = ResolveLocalPlayer(); + if (player == null) + return "No local player found yet."; + + if (!IsPlayerReadyForWorld(player)) + return "Waiting for class select / limbo exit."; + + WorldGenerator world = Object.FindFirstObjectByType(); + if (world == null) + return "No WorldGenerator in scene."; + + world.EnsureWorldReady(); + if (!world.TryGetVoidPlayerSpawnPose(out Vector3 pos, out Quaternion rot)) + return "Void island is not ready yet."; + + return FinishIslandTeleport(player.gameObject, world, pos, rot, "the void island"); + } + + private static string FinishIslandTeleport( + GameObject player, + WorldGenerator world, + Vector3 pos, + Quaternion rot, + string placeName) + { + world.MarkPlayerSpawnHandledExternally(); + world.TryEnsureSpawnColliderReady(pos, radiusChunks: 1); + world.EnsurePhysicsGroundAroundWorldPosition(pos); + + PlayerFallthroughReposition.EnsureOn(player)?.NotifyIntentionalTeleport(pos, rot); + PlayerFirstSpawnPlacement.Teleport(player, pos, rot); + TrySyncNetworkTransform(player, pos, rot); + + PlayerRespawn respawn = player.GetComponent(); + respawn?.SetRespawnPoint(pos, rot); + world.MarkPlayerSpawnHandledExternally(); + PlayerFallthroughReposition.EnsureOn(player)?.NotifyIntentionalTeleport(pos, rot); + + return $"Teleported to {placeName} at {pos}."; } public static string TryTeleportToVillage() @@ -120,44 +240,8 @@ public static class VillageTeleportDebug [DefaultExecutionOrder(5000)] public sealed class VillageTeleportDebugDriver : MonoBehaviour { - private float nextRetryAt = -1f; - private bool pendingRetry; - private void Update() { - if (WasF5Pressed()) - { - pendingRetry = true; - nextRetryAt = -1f; - } - - if (!pendingRetry || Time.unscaledTime < nextRetryAt) - return; - - string result = VillageTeleportDebug.TryTeleportToVillage(); - bool waiting = result.StartsWith("No local player") - || result.StartsWith("No WorldGenerator") - || result.StartsWith("Waiting for class") - || result.StartsWith("Village not spawned"); - - if (waiting) - { - nextRetryAt = Time.unscaledTime + 0.4f; - return; - } - - pendingRetry = false; - if (result.StartsWith("Teleported")) - Debug.Log($"[VillageTeleport] {result}"); - else - Debug.LogWarning($"[VillageTeleport] {result}"); - } - - private static bool WasF5Pressed() - { - UnityEngine.InputSystem.Keyboard kb = UnityEngine.InputSystem.Keyboard.current; - if (kb != null && kb.f5Key.wasPressedThisFrame) - return true; - return Input.GetKeyDown(KeyCode.F5); + VillageTeleportDebug.PollIslandHotkeys(); } } diff --git a/Assets/Scripts/Disease.meta b/Assets/Scripts/Disease.meta new file mode 100644 index 000000000..efa59a10f --- /dev/null +++ b/Assets/Scripts/Disease.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4e95a613ef6679b4e99d9d0b5bb8bb89 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Disease/DiseaseAmbientDriver.cs b/Assets/Scripts/Disease/DiseaseAmbientDriver.cs new file mode 100644 index 000000000..e9e77297e --- /dev/null +++ b/Assets/Scripts/Disease/DiseaseAmbientDriver.cs @@ -0,0 +1,46 @@ +using Mirror; +using UnityEngine; + +/// +/// Slow server/offline tick that rolls swamp-weighted ambient disease contraction. +/// +[DisallowMultipleComponent] +public sealed class DiseaseAmbientDriver : MonoBehaviour +{ + private const float IntervalSeconds = 18f; + + private float nextTickAt; + + public static DiseaseAmbientDriver EnsureOn(GameObject player) + { + if (player == null) + return null; + + DiseaseAmbientDriver existing = player.GetComponent() + ?? player.GetComponentInChildren(true); + return existing != null ? existing : player.AddComponent(); + } + + private void OnEnable() + { + nextTickAt = Time.time + Random.Range(4f, IntervalSeconds); + } + + private void Update() + { + if (!HasAuthority()) + return; + + if (Time.time < nextTickAt) + return; + + nextTickAt = Time.time + IntervalSeconds; + DiseaseInfectionService.ServerTryAmbientTick(gameObject); + } + + private bool HasAuthority() + { + return NetworkServer.active + || (!NetworkClient.active && OfflinePlayMode.IsActive(true)); + } +} diff --git a/Assets/Scripts/Disease/DiseaseAmbientDriver.cs.meta b/Assets/Scripts/Disease/DiseaseAmbientDriver.cs.meta new file mode 100644 index 000000000..a9c25bf80 --- /dev/null +++ b/Assets/Scripts/Disease/DiseaseAmbientDriver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bf003be8be07ef545abbbbb078837608 \ No newline at end of file diff --git a/Assets/Scripts/Disease/DiseaseCatalog.cs b/Assets/Scripts/Disease/DiseaseCatalog.cs new file mode 100644 index 000000000..aa4a074d3 --- /dev/null +++ b/Assets/Scripts/Disease/DiseaseCatalog.cs @@ -0,0 +1,175 @@ +using System; +using UnityEngine; + +/// Static definitions for the twelve mundane diseases. +public static class DiseaseCatalog +{ + public const int MaxConcurrent = 3; + public const int DiseaseCount = 12; + + /// Base per-creature-hit chance before disease/biome weights (~0.4%). + public const float BaseCombatHitChance = 0.004f; + + public enum Severity : byte + { + Mild = 0, + Moderate = 1, + Severe = 2, + } + + public readonly struct Definition + { + public readonly DiseaseId Id; + public readonly string DisplayName; + public readonly Severity Severity; + public readonly DiseaseSymptomKind Symptom; + public readonly int HealerCoinPrice; + public readonly int CureSignatureRecipeId; + public readonly float MinDurationSeconds; + public readonly float MaxDurationSeconds; + public readonly float CombatWeight; + public readonly float AmbientWeight; + public readonly float SwampMultiplier; + public readonly bool PrefersColdBiome; + public readonly bool PrefersUndead; + /// 1 = no strength drain. Multiplies effective Strength level and melee output. + public readonly float StrengthMultiplier; + + public Definition( + DiseaseId id, + string displayName, + Severity severity, + DiseaseSymptomKind symptom, + int healerCoinPrice, + int cureSignatureRecipeId, + float minDurationSeconds, + float maxDurationSeconds, + float combatWeight, + float ambientWeight, + float swampMultiplier, + bool prefersColdBiome = false, + bool prefersUndead = false, + float strengthMultiplier = 1f) + { + Id = id; + DisplayName = displayName; + Severity = severity; + Symptom = symptom; + HealerCoinPrice = healerCoinPrice; + CureSignatureRecipeId = cureSignatureRecipeId; + MinDurationSeconds = minDurationSeconds; + MaxDurationSeconds = maxDurationSeconds; + CombatWeight = combatWeight; + AmbientWeight = ambientWeight; + SwampMultiplier = swampMultiplier; + PrefersColdBiome = prefersColdBiome; + PrefersUndead = prefersUndead; + StrengthMultiplier = Mathf.Clamp(strengthMultiplier, 0.35f, 1f); + } + } + + private static readonly Definition[] Definitions = + { + new(DiseaseId.RawcapGut, "Rawcap Gut", Severity.Mild, DiseaseSymptomKind.PaleDot, + 90, 52, 95f, 150f, 0f, 0f, 1f), + new(DiseaseId.MarshCough, "Marsh Cough", Severity.Mild, DiseaseSymptomKind.StaminaRegenCut, + 110, 53, 120f, 220f, 0.15f, 1.4f, 8f), + new(DiseaseId.Frogitch, "Frogitch", Severity.Mild, DiseaseSymptomKind.WetScratchDot, + 100, 54, 100f, 180f, 0.2f, 1.1f, 6f), + new(DiseaseId.WoundFever, "Wound Fever", Severity.Moderate, DiseaseSymptomKind.FeverDot, + 200, 55, 140f, 260f, 1.2f, 0.05f, 3f, strengthMultiplier: 0.85f), + new(DiseaseId.Mirefoot, "Mirefoot", Severity.Moderate, DiseaseSymptomKind.MoveSlow, + 180, 56, 150f, 280f, 0.1f, 1.3f, 8f), + new(DiseaseId.BlackwaterBelly, "Blackwater Belly", Severity.Moderate, DiseaseSymptomKind.HungerDrain, + 220, 57, 140f, 250f, 0.9f, 0.4f, 7f, strengthMultiplier: 0.9f), + new(DiseaseId.Leechblood, "Leechblood", Severity.Moderate, DiseaseSymptomKind.HealTakenCut, + 240, 58, 160f, 280f, 1.1f, 0.05f, 7f, strengthMultiplier: 0.8f), + new(DiseaseId.BoneChillAgue, "Bone Chill Ague", Severity.Moderate, DiseaseSymptomKind.ColdVulnerability, + 210, 59, 150f, 270f, 1.0f, 0.05f, 0.35f, prefersColdBiome: true, strengthMultiplier: 0.85f), + new(DiseaseId.SwampRot, "Swamp Rot", Severity.Severe, DiseaseSymptomKind.RotDotMaxHp, + 420, 60, 200f, 360f, 0.7f, 0.55f, 8f, strengthMultiplier: 0.7f), + new(DiseaseId.RedscalePox, "Redscale Pox", Severity.Severe, DiseaseSymptomKind.DamageTakenUp, + 380, 61, 180f, 320f, 0.85f, 0.02f, 3f, strengthMultiplier: 0.8f), + new(DiseaseId.GraveDamp, "Grave Damp", Severity.Severe, DiseaseSymptomKind.StaminaSpikeDrain, + 400, 62, 180f, 340f, 0.75f, 0.02f, 1.5f, prefersUndead: true, strengthMultiplier: 0.75f), + new(DiseaseId.HollowLung, "Hollow Lung", Severity.Severe, DiseaseSymptomKind.SprintCostSpike, + 480, 63, 200f, 380f, 0.45f, 0.15f, 5f), + }; + + public static bool TryGet(DiseaseId id, out Definition def) + { + int index = (int)id - 1; + if (index < 0 || index >= Definitions.Length) + { + def = default; + return false; + } + + def = Definitions[index]; + return def.Id == id; + } + + public static Definition Get(DiseaseId id) + { + if (!TryGet(id, out Definition def)) + throw new ArgumentOutOfRangeException(nameof(id), id, "Unknown disease."); + return def; + } + + public static DiseaseId DiseaseForCureRecipe(int signatureRecipeId) + { + for (int i = 0; i < Definitions.Length; i++) + { + if (Definitions[i].CureSignatureRecipeId == signatureRecipeId) + return Definitions[i].Id; + } + + return DiseaseId.None; + } + + public static AlchemySignatureEffectKind CureEffectFor(DiseaseId id) + { + return id switch + { + DiseaseId.RawcapGut => AlchemySignatureEffectKind.CureRawcapGut, + DiseaseId.MarshCough => AlchemySignatureEffectKind.CureMarshCough, + DiseaseId.Frogitch => AlchemySignatureEffectKind.CureFrogitch, + DiseaseId.WoundFever => AlchemySignatureEffectKind.CureWoundFever, + DiseaseId.Mirefoot => AlchemySignatureEffectKind.CureMirefoot, + DiseaseId.BlackwaterBelly => AlchemySignatureEffectKind.CureBlackwaterBelly, + DiseaseId.Leechblood => AlchemySignatureEffectKind.CureLeechblood, + DiseaseId.BoneChillAgue => AlchemySignatureEffectKind.CureBoneChillAgue, + DiseaseId.SwampRot => AlchemySignatureEffectKind.CureSwampRot, + DiseaseId.RedscalePox => AlchemySignatureEffectKind.CureRedscalePox, + DiseaseId.GraveDamp => AlchemySignatureEffectKind.CureGraveDamp, + DiseaseId.HollowLung => AlchemySignatureEffectKind.CureHollowLung, + _ => AlchemySignatureEffectKind.None, + }; + } + + public static DiseaseId DiseaseForCureEffect(AlchemySignatureEffectKind effect) + { + return effect switch + { + AlchemySignatureEffectKind.CureRawcapGut => DiseaseId.RawcapGut, + AlchemySignatureEffectKind.CureMarshCough => DiseaseId.MarshCough, + AlchemySignatureEffectKind.CureFrogitch => DiseaseId.Frogitch, + AlchemySignatureEffectKind.CureWoundFever => DiseaseId.WoundFever, + AlchemySignatureEffectKind.CureMirefoot => DiseaseId.Mirefoot, + AlchemySignatureEffectKind.CureBlackwaterBelly => DiseaseId.BlackwaterBelly, + AlchemySignatureEffectKind.CureLeechblood => DiseaseId.Leechblood, + AlchemySignatureEffectKind.CureBoneChillAgue => DiseaseId.BoneChillAgue, + AlchemySignatureEffectKind.CureSwampRot => DiseaseId.SwampRot, + AlchemySignatureEffectKind.CureRedscalePox => DiseaseId.RedscalePox, + AlchemySignatureEffectKind.CureGraveDamp => DiseaseId.GraveDamp, + AlchemySignatureEffectKind.CureHollowLung => DiseaseId.HollowLung, + _ => DiseaseId.None, + }; + } + + public static float RollDuration(in Definition def) => + UnityEngine.Random.Range(def.MinDurationSeconds, def.MaxDurationSeconds); + + public static bool IsColdBiome(BiomeType biome) => + biome is BiomeType.Snow or BiomeType.Tundra or BiomeType.Mountain; +} diff --git a/Assets/Scripts/Disease/DiseaseCatalog.cs.meta b/Assets/Scripts/Disease/DiseaseCatalog.cs.meta new file mode 100644 index 000000000..26a07f0ce --- /dev/null +++ b/Assets/Scripts/Disease/DiseaseCatalog.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 101f792931345f74d937765efd69b069 \ No newline at end of file diff --git a/Assets/Scripts/Disease/DiseaseId.cs b/Assets/Scripts/Disease/DiseaseId.cs new file mode 100644 index 000000000..38802aa8c --- /dev/null +++ b/Assets/Scripts/Disease/DiseaseId.cs @@ -0,0 +1,17 @@ +/// Mundane player diseases (not lycanthropy / vampirism). +public enum DiseaseId : byte +{ + None = 0, + RawcapGut = 1, + MarshCough = 2, + Frogitch = 3, + WoundFever = 4, + Mirefoot = 5, + BlackwaterBelly = 6, + Leechblood = 7, + BoneChillAgue = 8, + SwampRot = 9, + RedscalePox = 10, + GraveDamp = 11, + HollowLung = 12, +} diff --git a/Assets/Scripts/Disease/DiseaseId.cs.meta b/Assets/Scripts/Disease/DiseaseId.cs.meta new file mode 100644 index 000000000..01669c33d --- /dev/null +++ b/Assets/Scripts/Disease/DiseaseId.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1497c2cf3333450429cbde7d79822a8a \ No newline at end of file diff --git a/Assets/Scripts/Disease/DiseaseInfectionService.cs b/Assets/Scripts/Disease/DiseaseInfectionService.cs new file mode 100644 index 000000000..99ab633cd --- /dev/null +++ b/Assets/Scripts/Disease/DiseaseInfectionService.cs @@ -0,0 +1,166 @@ +using UnityEngine; + +/// Server/offline rolls for contracting mundane diseases. +public static class DiseaseInfectionService +{ + private static readonly Collider[] UndeadProbe = new Collider[12]; + + /// + /// Tiny chance after a creature hit deals damage. Swamp and disease weights raise specific odds. + /// + public static bool ServerTryInfectFromCreatureHit(GameObject player, Vector3 hitOriginWorld) + { + PlayerDiseaseRuntime runtime = PlayerDiseaseRuntime.EnsureOn(player); + if (runtime == null) + return false; + if (runtime.ActiveCount >= DiseaseCatalog.MaxConcurrent) + return false; + + BiomeType biome = SampleBiome(player); + bool inSwamp = biome == BiomeType.Swamp; + bool cold = DiseaseCatalog.IsColdBiome(biome); + bool undeadNear = ProbeUndeadNear(hitOriginWorld); + + // Gate: most hits do nothing. + if (Random.value > DiseaseCatalog.BaseCombatHitChance * (inSwamp ? 2.5f : 1f)) + return false; + + DiseaseId picked = PickCombatDisease(inSwamp, cold, undeadNear); + if (picked == DiseaseId.None) + return false; + + return runtime.ServerTryInfect(picked); + } + + /// Slow ambient contraction while standing in swamp (and wet for Frogitch). + public static void ServerTryAmbientTick(GameObject player) + { + PlayerDiseaseRuntime runtime = PlayerDiseaseRuntime.EnsureOn(player); + if (runtime == null) + return; + if (runtime.ActiveCount >= DiseaseCatalog.MaxConcurrent) + return; + + BiomeType biome = SampleBiome(player); + if (biome != BiomeType.Swamp) + return; + + PlayerWetnessSystem wet = player.GetComponent() + ?? player.GetComponentInChildren(true); + bool wetOk = wet != null && wet.IsWet; + + // ~once per ambient driver interval; keep chance low. + float roll = Random.value; + DiseaseId picked = DiseaseId.None; + if (roll < 0.012f) + picked = DiseaseId.MarshCough; + else if (roll < 0.022f && wetOk) + picked = DiseaseId.Frogitch; + else if (roll < 0.030f) + picked = DiseaseId.Mirefoot; + else if (roll < 0.034f) + picked = DiseaseId.SwampRot; + else if (roll < 0.036f) + picked = DiseaseId.HollowLung; + + if (picked == DiseaseId.None || runtime.Has(picked)) + return; + + runtime.ServerTryInfect(picked); + } + + /// Raw mushroom threshold reached → Rawcap Gut. + public static bool ServerTryInfectRawcapGut(GameObject player) + { + PlayerDiseaseRuntime runtime = PlayerDiseaseRuntime.EnsureOn(player); + if (runtime == null) + return false; + return runtime.ServerTryInfect(DiseaseId.RawcapGut); + } + + private static DiseaseId PickCombatDisease(bool inSwamp, bool cold, bool undeadNear) + { + // Weighted pick among combat-capable diseases. + float total = 0f; + float wMarsh = Weight(DiseaseId.MarshCough, inSwamp, cold, undeadNear); + float wFrog = Weight(DiseaseId.Frogitch, inSwamp, cold, undeadNear); + float wWound = Weight(DiseaseId.WoundFever, inSwamp, cold, undeadNear); + float wMire = Weight(DiseaseId.Mirefoot, inSwamp, cold, undeadNear); + float wBlack = Weight(DiseaseId.BlackwaterBelly, inSwamp, cold, undeadNear); + float wLeech = Weight(DiseaseId.Leechblood, inSwamp, cold, undeadNear); + float wBone = Weight(DiseaseId.BoneChillAgue, inSwamp, cold, undeadNear); + float wRot = Weight(DiseaseId.SwampRot, inSwamp, cold, undeadNear); + float wPox = Weight(DiseaseId.RedscalePox, inSwamp, cold, undeadNear); + float wGrave = Weight(DiseaseId.GraveDamp, inSwamp, cold, undeadNear); + float wLung = Weight(DiseaseId.HollowLung, inSwamp, cold, undeadNear); + + total = wMarsh + wFrog + wWound + wMire + wBlack + wLeech + wBone + wRot + wPox + wGrave + wLung; + if (total <= 0.0001f) + return DiseaseId.None; + + float r = Random.value * total; + if ((r -= wMarsh) < 0f) return DiseaseId.MarshCough; + if ((r -= wFrog) < 0f) return DiseaseId.Frogitch; + if ((r -= wWound) < 0f) return DiseaseId.WoundFever; + if ((r -= wMire) < 0f) return DiseaseId.Mirefoot; + if ((r -= wBlack) < 0f) return DiseaseId.BlackwaterBelly; + if ((r -= wLeech) < 0f) return DiseaseId.Leechblood; + if ((r -= wBone) < 0f) return DiseaseId.BoneChillAgue; + if ((r -= wRot) < 0f) return DiseaseId.SwampRot; + if ((r -= wPox) < 0f) return DiseaseId.RedscalePox; + if ((r -= wGrave) < 0f) return DiseaseId.GraveDamp; + if ((r -= wLung) < 0f) return DiseaseId.HollowLung; + return DiseaseId.WoundFever; + } + + private static float Weight(DiseaseId id, bool inSwamp, bool cold, bool undeadNear) + { + if (!DiseaseCatalog.TryGet(id, out DiseaseCatalog.Definition def)) + return 0f; + if (def.CombatWeight <= 0f) + return 0f; + + float w = def.CombatWeight; + if (inSwamp) + w *= def.SwampMultiplier; + else if (def.SwampMultiplier >= 6f) + w *= 0.25f; // swamp-favoring diseases rare elsewhere + + if (def.PrefersColdBiome) + w *= cold ? 4f : 0.15f; + if (def.PrefersUndead) + w *= undeadNear ? 5f : 0.2f; + + return w; + } + + private static BiomeType SampleBiome(GameObject player) + { + if (player == null) + return BiomeType.Grassland; + + WorldGenerator world = Object.FindFirstObjectByType(); + if (world == null) + return BiomeType.Grassland; + + WorldGenerator.ClimateSample sample = world.SampleClimateAtWorldPosition(player.transform.position); + return sample.valid ? sample.biome : BiomeType.Grassland; + } + + private static bool ProbeUndeadNear(Vector3 origin) + { + int hits = Physics.OverlapSphereNonAlloc(origin, 3.5f, UndeadProbe, ~0, QueryTriggerInteraction.Ignore); + for (int i = 0; i < hits; i++) + { + Collider c = UndeadProbe[i]; + if (c == null) + continue; + if (c.GetComponentInParent() != null) + return true; + if (c.GetComponentInParent() != null) + return true; + } + + return false; + } +} diff --git a/Assets/Scripts/Disease/DiseaseInfectionService.cs.meta b/Assets/Scripts/Disease/DiseaseInfectionService.cs.meta new file mode 100644 index 000000000..32120a2fb --- /dev/null +++ b/Assets/Scripts/Disease/DiseaseInfectionService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ca5d28bc159784140babd676055e7d79 \ No newline at end of file diff --git a/Assets/Scripts/Disease/DiseaseSymptomKind.cs b/Assets/Scripts/Disease/DiseaseSymptomKind.cs new file mode 100644 index 000000000..6d91a202d --- /dev/null +++ b/Assets/Scripts/Disease/DiseaseSymptomKind.cs @@ -0,0 +1,29 @@ +/// Unique mechanical symptom applied while a disease is active. +public enum DiseaseSymptomKind : byte +{ + None = 0, + /// Pale HP bar + slow DoT (Rawcap Gut). + PaleDot = 1, + /// Stamina regen cut (Marsh Cough). + StaminaRegenCut = 2, + /// Scratch DoT while wet (Frogitch). + WetScratchDot = 3, + /// Periodic fever DoT (Wound Fever). + FeverDot = 4, + /// Move speed reduction (Mirefoot). + MoveSlow = 5, + /// Hunger drains faster (Blackwater Belly). + HungerDrain = 6, + /// Incoming heals reduced (Leechblood). + HealTakenCut = 7, + /// Extra cold vulnerability (Bone Chill Ague). + ColdVulnerability = 8, + /// Strong DoT + soft max HP cut (Swamp Rot). + RotDotMaxHp = 9, + /// Incoming damage increased (Redscale Pox). + DamageTakenUp = 10, + /// Periodic stamina spike drain (Grave Damp). + StaminaSpikeDrain = 11, + /// Sprint stamina cost spike (Hollow Lung). + SprintCostSpike = 12, +} diff --git a/Assets/Scripts/Disease/DiseaseSymptomKind.cs.meta b/Assets/Scripts/Disease/DiseaseSymptomKind.cs.meta new file mode 100644 index 000000000..743d4cb7d --- /dev/null +++ b/Assets/Scripts/Disease/DiseaseSymptomKind.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4d11ca0e588661e49b1331117aa5a0fc \ No newline at end of file diff --git a/Assets/Scripts/Disease/Editor.meta b/Assets/Scripts/Disease/Editor.meta new file mode 100644 index 000000000..678c8ecb3 --- /dev/null +++ b/Assets/Scripts/Disease/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 90c5cd951245f6b48bd34105e97d839d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Disease/Editor/DiseaseDebugEditorTools.cs b/Assets/Scripts/Disease/Editor/DiseaseDebugEditorTools.cs new file mode 100644 index 000000000..1ae599b7e --- /dev/null +++ b/Assets/Scripts/Disease/Editor/DiseaseDebugEditorTools.cs @@ -0,0 +1,161 @@ +#if UNITY_EDITOR +using InventoryFramework; +using UnityEditor; +using UnityEngine; + +/// Play Mode helpers for the twelve mundane diseases. +public static class DiseaseDebugEditorTools +{ + private const string AlchemyPotionItemPath = + "Assets/Jinxish/Drag & Drop Inventory & Hotbar Framework/ScriptableObjects/Items/Alchemy/Alchemy Potion.asset"; + + [MenuItem("Manafall/Disease/Infect Local Player With All (capped to 3)")] + public static void InfectThreeSample() + { + if (!TryGetLocalPlayer(out GameObject player)) + return; + + PlayerDiseaseRuntime runtime = PlayerDiseaseRuntime.EnsureOn(player); + DiseaseAmbientDriver.EnsureOn(player); + runtime.ClearForRespawn(); + runtime.ServerTryInfect(DiseaseId.RawcapGut); + runtime.ServerTryInfect(DiseaseId.MarshCough); + runtime.ServerTryInfect(DiseaseId.WoundFever); + Debug.Log($"[Disease] Active: {runtime.BuildActiveNamesList()} price={runtime.GetHealerTotalPrice()}"); + } + + [MenuItem("Manafall/Disease/Infect Local Player — Swamp Rot")] + public static void InfectSwampRot() + { + if (!TryGetLocalPlayer(out GameObject player)) + return; + PlayerDiseaseRuntime.EnsureOn(player)?.ServerTryInfect(DiseaseId.SwampRot); + } + + [MenuItem("Manafall/Disease/Clear All Diseases On Local Player")] + public static void ClearAll() + { + if (!TryGetLocalPlayer(out GameObject player)) + return; + PlayerDiseaseRuntime.EnsureOn(player)?.ClearForRespawn(); + Debug.Log("[Disease] Cleared."); + } + + [MenuItem("Manafall/Disease/Grant All Disease Cure Potions")] + public static void GrantCurePotions() + { + if (!Application.isPlaying) + { + EditorUtility.DisplayDialog("Disease Cures", "Enter Play Mode first.", "OK"); + return; + } + + if (!TryGetLocalPlayer(out GameObject player)) + return; + + Item potionItem = AssetDatabase.LoadAssetAtPath(AlchemyPotionItemPath); + if (potionItem == null) + { + EditorUtility.DisplayDialog("Disease Cures", "Alchemy Potion item missing.", "OK"); + return; + } + + Hotbar hotbar = player.GetComponentInChildren(true); + Inventory inventory = player.GetComponentInChildren(true); + int granted = 0; + for (int id = 52; id <= 63; id++) + { + if (!AlchemySignatureBrewCatalog.TryGetRecipe(id, out _)) + continue; + + float meta = AlchemyPotionEncoding.PackSignature( + id, + potencyTier: 22, + durationTier: 20, + instabilityTier: 2, + AlchemyBrewFlags.Stabilized, + purified: false); + + if (ItemGrantService.TryGiveItem(hotbar, inventory, potionItem, 1, meta)) + granted++; + } + + inventory?.TriggerUpdate(); + ItemGrantService.RefreshUI(hotbar, inventory); + Debug.Log($"[Disease] Granted {granted} cure potions."); + EditorUtility.DisplayDialog("Disease Cures", $"Granted {granted} cure potions.", "OK"); + } + + [MenuItem("Manafall/Disease/Validate Catalog (Editor)")] + public static void ValidateCatalog() + { + var seen = new System.Collections.Generic.HashSet(); + int ok = 0; + for (byte i = 1; i <= DiseaseCatalog.DiseaseCount; i++) + { + DiseaseId id = (DiseaseId)i; + if (!DiseaseCatalog.TryGet(id, out DiseaseCatalog.Definition def)) + { + Debug.LogError($"[DiseaseValidate] Missing def {id}"); + continue; + } + + if (def.CureSignatureRecipeId < 52 || def.CureSignatureRecipeId > 63) + Debug.LogError($"[DiseaseValidate] {id} recipe {def.CureSignatureRecipeId} out of range"); + if (!seen.Add(def.CureSignatureRecipeId)) + Debug.LogError($"[DiseaseValidate] Duplicate recipe {def.CureSignatureRecipeId}"); + if (!AlchemySignatureBrewCatalog.TryGetRecipe(def.CureSignatureRecipeId, out _)) + Debug.LogError($"[DiseaseValidate] Missing signature recipe {def.CureSignatureRecipeId}"); + if (DiseaseCatalog.DiseaseForCureRecipe(def.CureSignatureRecipeId) != id) + Debug.LogError($"[DiseaseValidate] Cure recipe map mismatch for {id}"); + ok++; + } + + string msg = $"Validated {ok}/{DiseaseCatalog.DiseaseCount} diseases, {seen.Count} unique cure recipes."; + Debug.Log("[DiseaseValidate] " + msg); + EditorUtility.DisplayDialog("Disease Validate", msg, "OK"); + } + + [MenuItem("Manafall/Disease/Smoke — Infect, Wrong Cure, Right Cure")] + public static void SmokeInfectCure() + { + if (!TryGetLocalPlayer(out GameObject player)) + return; + + PlayerDiseaseRuntime runtime = PlayerDiseaseRuntime.EnsureOn(player); + runtime.ClearForRespawn(); + bool infected = runtime.ServerTryInfect(DiseaseId.WoundFever); + bool wrong = runtime.ServerTryCure(DiseaseId.RawcapGut); + bool right = runtime.ServerTryCure(DiseaseId.WoundFever); + bool gone = !runtime.Has(DiseaseId.WoundFever); + Debug.Log( + $"[DiseaseSmoke] infected={infected} wrongCure={wrong} rightCure={right} cleared={gone}"); + EditorUtility.DisplayDialog( + "Disease Smoke", + $"infected={infected}\nwrongCure(should false)={wrong}\nrightCure={right}\ncleared={gone}", + "OK"); + } + + private static bool TryGetLocalPlayer(out GameObject player) + { + player = null; + if (!Application.isPlaying) + { + EditorUtility.DisplayDialog("Disease", "Enter Play Mode first.", "OK"); + return false; + } + + if (Mirror.NetworkClient.localPlayer != null) + player = Mirror.NetworkClient.localPlayer.gameObject; + if (player == null) + player = Object.FindFirstObjectByType()?.gameObject; + if (player == null) + { + EditorUtility.DisplayDialog("Disease", "No local player found.", "OK"); + return false; + } + + return true; + } +} +#endif diff --git a/Assets/Scripts/Disease/Editor/DiseaseDebugEditorTools.cs.meta b/Assets/Scripts/Disease/Editor/DiseaseDebugEditorTools.cs.meta new file mode 100644 index 000000000..dd95b6697 --- /dev/null +++ b/Assets/Scripts/Disease/Editor/DiseaseDebugEditorTools.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1d88b2a4b428f834aa8af69b8546df17 \ No newline at end of file diff --git a/Assets/Scripts/Disease/PlayerDiseaseRuntime.cs b/Assets/Scripts/Disease/PlayerDiseaseRuntime.cs new file mode 100644 index 000000000..309a940ef --- /dev/null +++ b/Assets/Scripts/Disease/PlayerDiseaseRuntime.cs @@ -0,0 +1,428 @@ +using System; +using System.Text; +using Mirror; +using UnityEngine; + +/// +/// Server/offline-authoritative active disease set (up to ). +/// +[DisallowMultipleComponent] +public sealed class PlayerDiseaseRuntime : MonoBehaviour +{ + private const float TickIntervalSeconds = 0.5f; + + [Serializable] + public struct ActiveDisease + { + public DiseaseId Id; + public float ExpiresAt; + } + + [Serializable] + public struct SaveSnapshot + { + public byte[] ids; + public float[] remainingSeconds; + } + + private readonly ActiveDisease[] slots = new ActiveDisease[DiseaseCatalog.MaxConcurrent]; + private int count; + private float nextTickAt; + private bool hudSickTint; + + private PlayerHealth health; + private FPSStamina stamina; + private PlayerHunger hunger; + private PlayerWetnessSystem wetness; + private ActiveHotbarConsume hotbarConsume; + private WorldGenerator worldGenerator; + + public int ActiveCount => count; + public bool HasAny => count > 0; + + public static PlayerDiseaseRuntime EnsureOn(GameObject player) + { + if (player == null) + return null; + + PlayerDiseaseRuntime existing = player.GetComponent() + ?? player.GetComponentInChildren(true); + if (existing == null) + existing = player.AddComponent(); + DiseaseAmbientDriver.EnsureOn(player); + return existing; + } + + public bool Has(DiseaseId id) + { + for (int i = 0; i < count; i++) + { + if (slots[i].Id == id && Time.time < slots[i].ExpiresAt) + return true; + } + + return false; + } + + public bool HasSymptom(DiseaseSymptomKind kind) + { + for (int i = 0; i < count; i++) + { + if (Time.time >= slots[i].ExpiresAt) + continue; + if (!DiseaseCatalog.TryGet(slots[i].Id, out DiseaseCatalog.Definition def)) + continue; + if (def.Symptom == kind) + return true; + } + + return false; + } + + public float GetMoveSpeedMultiplier() + { + return HasSymptom(DiseaseSymptomKind.MoveSlow) ? 0.82f : 1f; + } + + public float GetStaminaRegenMultiplier() + { + return HasSymptom(DiseaseSymptomKind.StaminaRegenCut) ? 0.55f : 1f; + } + + public float GetHungerDrainMultiplier() + { + return HasSymptom(DiseaseSymptomKind.HungerDrain) ? 1.65f : 1f; + } + + public float GetIncomingHealMultiplier() + { + return HasSymptom(DiseaseSymptomKind.HealTakenCut) ? 0.55f : 1f; + } + + public float GetIncomingDamageMultiplier() + { + float mul = 1f; + if (HasSymptom(DiseaseSymptomKind.DamageTakenUp)) + mul *= 1.12f; + if (HasSymptom(DiseaseSymptomKind.ColdVulnerability)) + mul *= 1.08f; + return mul; + } + + public float GetColdDamageMultiplier() + { + return HasSymptom(DiseaseSymptomKind.ColdVulnerability) ? 1.35f : 1f; + } + + public float GetSprintStaminaCostMultiplier() + { + return HasSymptom(DiseaseSymptomKind.SprintCostSpike) ? 1.75f : 1f; + } + + public float GetMaxHealthMultiplier() + { + return HasSymptom(DiseaseSymptomKind.RotDotMaxHp) ? 0.85f : 1f; + } + + /// + /// Product of active disease strength multipliers (carry / swim Strength level and melee output). + /// + public float GetStrengthMultiplier() + { + float mul = 1f; + for (int i = 0; i < count; i++) + { + if (Time.time >= slots[i].ExpiresAt) + continue; + if (!DiseaseCatalog.TryGet(slots[i].Id, out DiseaseCatalog.Definition def)) + continue; + mul *= def.StrengthMultiplier; + } + + return Mathf.Clamp(mul, 0.35f, 1f); + } + + /// Server/offline: begin or refresh a disease. Returns false if capped or already present. + public bool ServerTryInfect(DiseaseId id, float durationOverride = -1f) + { + if (!HasAuthority() || id == DiseaseId.None) + return false; + if (!DiseaseCatalog.TryGet(id, out DiseaseCatalog.Definition def)) + return false; + + CompactExpired(); + if (Has(id)) + return false; + if (count >= DiseaseCatalog.MaxConcurrent) + return false; + + float duration = durationOverride > 0f ? durationOverride : DiseaseCatalog.RollDuration(def); + slots[count++] = new ActiveDisease + { + Id = id, + ExpiresAt = Time.time + duration, + }; + nextTickAt = Time.time; + RefreshHud(); + Debug.Log($"[Disease] Infected with {def.DisplayName} ({duration:0}s)."); + return true; + } + + public bool ServerTryCure(DiseaseId id) + { + if (!HasAuthority() || id == DiseaseId.None) + return false; + + bool removed = false; + for (int i = count - 1; i >= 0; i--) + { + if (slots[i].Id != id) + continue; + RemoveAt(i); + removed = true; + } + + if (removed) + RefreshHud(); + return removed; + } + + public bool ServerTryCureAll(out int curedCount, out int totalPrice) + { + curedCount = 0; + totalPrice = 0; + if (!HasAuthority()) + return false; + + CompactExpired(); + for (int i = count - 1; i >= 0; i--) + { + if (!DiseaseCatalog.TryGet(slots[i].Id, out DiseaseCatalog.Definition def)) + { + RemoveAt(i); + continue; + } + + totalPrice += def.HealerCoinPrice; + curedCount++; + RemoveAt(i); + } + + RefreshHud(); + return curedCount > 0; + } + + public int GetHealerTotalPrice() + { + CompactExpired(); + int total = 0; + for (int i = 0; i < count; i++) + { + if (DiseaseCatalog.TryGet(slots[i].Id, out DiseaseCatalog.Definition def)) + total += def.HealerCoinPrice; + } + + return total; + } + + public string BuildActiveNamesList() + { + CompactExpired(); + if (count <= 0) + return string.Empty; + + var sb = new StringBuilder(); + for (int i = 0; i < count; i++) + { + if (!DiseaseCatalog.TryGet(slots[i].Id, out DiseaseCatalog.Definition def)) + continue; + if (sb.Length > 0) + sb.Append(", "); + sb.Append(def.DisplayName); + } + + return sb.ToString(); + } + + public void ClearForRespawn() + { + count = 0; + nextTickAt = 0f; + RefreshHud(); + } + + public SaveSnapshot CaptureSave() + { + CompactExpired(); + var snap = new SaveSnapshot + { + ids = new byte[count], + remainingSeconds = new float[count], + }; + for (int i = 0; i < count; i++) + { + snap.ids[i] = (byte)slots[i].Id; + snap.remainingSeconds[i] = Mathf.Max(0f, slots[i].ExpiresAt - Time.time); + } + + return snap; + } + + public void ApplySave(SaveSnapshot snap) + { + count = 0; + if (snap.ids == null || snap.remainingSeconds == null) + { + RefreshHud(); + return; + } + + int n = Mathf.Min(snap.ids.Length, snap.remainingSeconds.Length); + n = Mathf.Min(n, DiseaseCatalog.MaxConcurrent); + for (int i = 0; i < n; i++) + { + DiseaseId id = (DiseaseId)snap.ids[i]; + if (id == DiseaseId.None || !DiseaseCatalog.TryGet(id, out _)) + continue; + float remaining = Mathf.Max(1f, snap.remainingSeconds[i]); + slots[count++] = new ActiveDisease + { + Id = id, + ExpiresAt = Time.time + remaining, + }; + } + + nextTickAt = Time.time; + RefreshHud(); + } + + private void Awake() + { + CacheRefs(); + } + + private void Update() + { + if (!HasAuthority()) + return; + + CompactExpired(); + if (count <= 0) + return; + + if (Time.time < nextTickAt) + return; + + nextTickAt = Time.time + TickIntervalSeconds; + CacheRefs(); + TickSymptoms(TickIntervalSeconds); + ClampHealthToDiseaseMax(); + } + + private void ClampHealthToDiseaseMax() + { + if (health == null || !HasSymptom(DiseaseSymptomKind.RotDotMaxHp)) + return; + + float softMax = health.maxHealth * GetMaxHealthMultiplier(); + if (health.currentHealth > softMax + 0.01f) + health.TakeDamageFromEffect(health.currentHealth - softMax, transform.position); + } + + private void OnDisable() + { + if (hudSickTint) + ApplySickTint(false); + } + + private void TickSymptoms(float dt) + { + health ??= GetComponent(); + bool wet = wetness != null && wetness.IsWet; + + for (int i = 0; i < count; i++) + { + if (!DiseaseCatalog.TryGet(slots[i].Id, out DiseaseCatalog.Definition def)) + continue; + + switch (def.Symptom) + { + case DiseaseSymptomKind.PaleDot: + health?.TakeDamageFromEffect(0.85f * dt, transform.position); + break; + case DiseaseSymptomKind.FeverDot: + health?.TakeDamageFromEffect(1.1f * dt, transform.position); + break; + case DiseaseSymptomKind.WetScratchDot: + if (wet) + health?.TakeDamageFromEffect(0.7f * dt, transform.position); + break; + case DiseaseSymptomKind.RotDotMaxHp: + health?.TakeDamageFromEffect(1.6f * dt, transform.position); + break; + case DiseaseSymptomKind.StaminaSpikeDrain: + if (stamina != null && (Time.frameCount % 20) == 0) + stamina.TryUseAttackStamina(Mathf.Lerp(4f, 9f, UnityEngine.Random.value)); + break; + } + } + } + + private void CompactExpired() + { + bool changed = false; + for (int i = count - 1; i >= 0; i--) + { + if (Time.time < slots[i].ExpiresAt) + continue; + RemoveAt(i); + changed = true; + } + + if (changed) + RefreshHud(); + } + + private void RemoveAt(int index) + { + for (int j = index; j < count - 1; j++) + slots[j] = slots[j + 1]; + count--; + } + + private void RefreshHud() + { + bool wantTint = HasSymptom(DiseaseSymptomKind.PaleDot) || HasSymptom(DiseaseSymptomKind.RotDotMaxHp); + ApplySickTint(wantTint); + } + + private void ApplySickTint(bool enable) + { + hudSickTint = enable; + hotbarConsume ??= GetComponent(); + if (hotbarConsume != null) + { + hotbarConsume.NotifyMushroomSicknessHud(enable); + return; + } + + PlayerStatusRuntimeHud.EnsureOn(gameObject)?.SetHealthBarSickTint(enable); + } + + private void CacheRefs() + { + health ??= GetComponent(); + stamina ??= GetComponent(); + hunger ??= GetComponent(); + wetness ??= GetComponent() ?? GetComponentInChildren(true); + hotbarConsume ??= GetComponent(); + if (worldGenerator == null) + worldGenerator = FindFirstObjectByType(); + } + + private bool HasAuthority() + { + return NetworkServer.active + || (!NetworkClient.active && OfflinePlayMode.IsActive(true)); + } +} diff --git a/Assets/Scripts/Disease/PlayerDiseaseRuntime.cs.meta b/Assets/Scripts/Disease/PlayerDiseaseRuntime.cs.meta new file mode 100644 index 000000000..c799046e3 --- /dev/null +++ b/Assets/Scripts/Disease/PlayerDiseaseRuntime.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 18366ef1794468e469a251ff419ea195 \ No newline at end of file diff --git a/Assets/Scripts/Enchantment/EnchantableItemRules.cs b/Assets/Scripts/Enchantment/EnchantableItemRules.cs index 8789ab380..2ef964972 100644 --- a/Assets/Scripts/Enchantment/EnchantableItemRules.cs +++ b/Assets/Scripts/Enchantment/EnchantableItemRules.cs @@ -121,6 +121,7 @@ namespace InventoryFramework public const int NightlingShadowShardId = 519; public const int StoneWispEyeId = 520; public const int GloomDustId = 521; + public const int DuskFeeTokenId = 522; public const int BogMossClumpId = 7812; public const int WormChitinSegmentId = 550; public const int PhosphorGlandId = 551; @@ -166,6 +167,7 @@ namespace InventoryFramework case SoulweaveThreadId: case NightlingEssenceId: + case DuskFeeTokenId: twist = EnchantTwist.Leech; return true; @@ -225,6 +227,7 @@ namespace InventoryFramework { StoneWispEyeId => 4, NightlingEssenceId => 5, + DuskFeeTokenId => 6, FrostfangShardId => 4, GlacialPeltLockId => 3, PhosphorGlandId => 3, diff --git a/Assets/Scripts/FPSPlayerController.cs b/Assets/Scripts/FPSPlayerController.cs index 6f54dbaaf..06abdf450 100644 --- a/Assets/Scripts/FPSPlayerController.cs +++ b/Assets/Scripts/FPSPlayerController.cs @@ -511,6 +511,8 @@ public class FPSNetworkMovement : NetworkBehaviour, IAquaticMotorHooks if (!canDrive) return; + VillageTeleportDebug.PollIslandHotkeys(); + // While loot/container UI is open, still allow walking so distance-close can fire. // Skip look/crouch so the free UI cursor is not fighting mouse-look. if (!InputStateManager.AllowsPlayerGameplayInput) @@ -928,6 +930,14 @@ public class FPSNetworkMovement : NetworkBehaviour, IAquaticMotorHooks private void UpdateCrouch() { + if (aquatic != null && aquatic.IsInWaterForMotor) + { + isCrouching = false; + controller.height = Mathf.Lerp(controller.height, standingHeight, crouchTransitionSpeed * Time.deltaTime); + controller.center = new Vector3(0f, controller.height * 0.5f, 0f); + return; + } + if (rideBugMount != null && rideBugMount.BlocksMotor) { isCrouching = false; @@ -1134,7 +1144,8 @@ public class FPSNetworkMovement : NetworkBehaviour, IAquaticMotorHooks Debug.Log($"{VampireTransformationDebug.Tag} jump jumpHeight={jumpHeight:F2} heightMul={jumpHeightMul:F2}", this); } - verticalVelocity = Mathf.Sqrt((jumpHeight * jumpMultiplier) * -2f * gravity); + float jumpG = BiomeGravity.Scale(gravity, transform.position); + verticalVelocity = Mathf.Sqrt((jumpHeight * jumpMultiplier) * -2f * jumpG); ledgeCatchTimer = ledgeCatchWindow; lastJumpPressedTime = float.NegativeInfinity; lastGroundedTime = float.NegativeInfinity; @@ -1157,7 +1168,9 @@ public class FPSNetworkMovement : NetworkBehaviour, IAquaticMotorHooks float gravityMultiplier = status != null ? status.GravityMultiplier : 1f; if (verticalVelocity <= 0f && status != null) gravityMultiplier *= status.DescentGravityMultiplier; - verticalVelocity += gravity * aquaticGravityMul * gravityMultiplier * Time.deltaTime; + // Void island is ~1/3 gravity; BiomeGravity blends across the rim. + float biomeG = BiomeGravity.Scale(gravity, transform.position); + verticalVelocity += biomeG * aquaticGravityMul * gravityMultiplier * Time.deltaTime; } else if (waterWalkGrounded && verticalVelocity < 0f) verticalVelocity = 0f; @@ -1684,6 +1697,10 @@ public class FPSNetworkMovement : NetworkBehaviour, IAquaticMotorHooks speedMultiplier *= status.MoveSpeedMultiplier; if (practiceProgress != null) speedMultiplier *= practiceProgress.ClientMoveSpeedPerkMultiplier; + PlayerDiseaseRuntime diseases = GetComponent() + ?? GetComponentInParent(); + if (diseases != null) + speedMultiplier *= diseases.GetMoveSpeedMultiplier(); return baseSpeed * speedMultiplier; } diff --git a/Assets/Scripts/FPSStamina.cs b/Assets/Scripts/FPSStamina.cs index 8c49d6a66..6140d01e2 100644 --- a/Assets/Scripts/FPSStamina.cs +++ b/Assets/Scripts/FPSStamina.cs @@ -62,6 +62,7 @@ public class FPSStamina : MonoBehaviour private bool isSprinting; private bool sprintLocked; private float swimmingDrainPerSecond; + private float diggingDrainPerSecond; private float environmentalDrainPerSecond; private bool swimmingMoveAttempt; private bool rowingMoveAttempt; @@ -90,7 +91,7 @@ public class FPSStamina : MonoBehaviour private bool restingSleeping; private bool lowVitalsAudioSuppressed; - [SerializeField] private float rowingDrainPerSecond = 0.675f; // was 1.35; halved drain + [SerializeField] private float rowingDrainPerSecond = 0.135f; // was 0.675; rowing costs a fifth public bool CanSprint => !sprintLocked && currentStamina >= 1f; public bool IsSprinting => isSprinting && !sprintLocked; @@ -203,6 +204,14 @@ public class FPSStamina : MonoBehaviour consuming = true; } + if (diggingDrainPerSecond > 0f) + { + float drain = diggingDrainPerSecond * GetEnduranceStaminaMultiplier() * dt; + currentStamina -= drain; + exertionDrain += drain; + consuming = true; + } + if (rowingDrainPerSecond > 0f && rowingMoveAttempt) { float drain = rowingDrainPerSecond * dt; @@ -224,6 +233,10 @@ public class FPSStamina : MonoBehaviour float sprintDrainMul = GetSprintStaminaMultiplier(); if (vampireMotor != null) sprintDrainMul *= vampireMotor.GetStaminaDrainMultiplier(); + PlayerDiseaseRuntime diseases = GetComponent() + ?? GetComponentInParent(); + if (diseases != null) + sprintDrainMul *= diseases.GetSprintStaminaCostMultiplier(); float drain = sprintDrainPerSecond * sprintDrainMul * dt; currentStamina -= drain; @@ -257,6 +270,10 @@ public class FPSStamina : MonoBehaviour regenMul *= werewolfMotor.GetStaminaRegenMultiplier(); regenMul *= GetColdStaminaRegenMultiplier(); + PlayerDiseaseRuntime diseases = GetComponent() + ?? GetComponentInParent(); + if (diseases != null) + regenMul *= diseases.GetStaminaRegenMultiplier(); // Still recover while overheating, just slower so heat remains a pressure. if (environmentalDrainPerSecond > 0.01f) regenMul *= 0.55f; @@ -351,6 +368,12 @@ public class FPSStamina : MonoBehaviour swimmingDrainPerSecond = Mathf.Max(0f, drainPerSecond); } + /// Hold-to-dig applies a slow stamina burn while the shovel channel is active. + public void SetDiggingStaminaDrain(float drainPerSecond) + { + diggingDrainPerSecond = Mathf.Max(0f, drainPerSecond); + } + /// Continuous environmental drain (e.g. desert overheat). Blocks recharge while > 0. public void SetEnvironmentalStaminaDrain(float drainPerSecond) { diff --git a/Assets/Scripts/InputStateManager.cs b/Assets/Scripts/InputStateManager.cs index 53ede85b9..b0bc504da 100644 --- a/Assets/Scripts/InputStateManager.cs +++ b/Assets/Scripts/InputStateManager.cs @@ -424,6 +424,7 @@ public class InputStateManager : MonoBehaviour bool summoning = SummoningAltarUI.IsOpen; bool inventory = IsLocalInventoryOpen(); bool chestLoot = IsLocalChestLootOpen(); + bool listChrome = InventoryFramework.InventoryListView.AnyChromeBlockingGameplay(); bool fullMap = PlayerFullMapHud.IsOpen; // Rune sprite combat stays in gameplay mode so the trainer can walk/look while ordering. bool ruinInspect = Manafall.RuneSprites.RuneSpriteInspectHud.IsOpen; @@ -443,7 +444,7 @@ public class InputStateManager : MonoBehaviour // Re-assert a visible OS cursor (SetUIMode may hide it for gamepad UI). EnterOsDesktopAccess(); } - else if (ShouldHoldForClassSelect() || summoning || inventory || chestLoot || fullMap || ruinInspect || bookReader || pauseMenu || sealclash || marketOpen || pigeonMail || skillPerkPick || chatGroup) + else if (ShouldHoldForClassSelect() || summoning || inventory || chestLoot || listChrome || fullMap || ruinInspect || bookReader || pauseMenu || sealclash || marketOpen || pigeonMail || skillPerkPick || chatGroup) SetUIMode(); else SetGameplayMode(); @@ -470,7 +471,7 @@ public class InputStateManager : MonoBehaviour return false; ChestHudController chestHud = player.GetComponentInChildren(true); - return chestHud != null && chestHud.IsOpen; + return chestHud != null && (chestHud.IsOpen || chestHud.IsPanelActive); } private static PlayerClassSelection ResolveLocalClassSelection() diff --git a/Assets/Scripts/Inventory/InventoryListView.cs b/Assets/Scripts/Inventory/InventoryListView.cs index ec7efb488..e6529cb0a 100644 --- a/Assets/Scripts/Inventory/InventoryListView.cs +++ b/Assets/Scripts/Inventory/InventoryListView.cs @@ -63,9 +63,61 @@ namespace InventoryFramework return v; } - private void OnEnable() { if (!Active.Contains(this)) Active.Add(this); } + private void OnEnable() + { + if (!Active.Contains(this)) + Active.Add(this); + // Hide the grid before the first paint when list mode is on (avoids a one-frame flash). + ApplyListModeVisuals(ListMode); + } + private void OnDisable() { Active.Remove(this); } + /// True when any bag/chest list chrome is still drawing over gameplay. + public static bool AnyChromeBlockingGameplay() + { + for (int i = 0; i < Active.Count; i++) + { + InventoryListView v = Active[i]; + if (v == null || !v.isActiveAndEnabled) + continue; + if (v.IsChromeVisible()) + return true; + } + + return false; + } + + /// Force-hide orphan list chrome (bag closed but list sibling stayed up). + public static void DismissOrphanChrome() + { + for (int i = Active.Count - 1; i >= 0; i--) + { + InventoryListView v = Active[i]; + if (v == null) + continue; + if (!v.IsHostPanelOpen()) + v.HideChrome(); + } + } + + /// Call as the bag opens so list mode hides the grid before the next paint. + public static void PrepareBagOpenVisuals() => PrepareOpenVisuals(chest: false); + + /// Call as a chest/loot panel opens so list mode hides the slot grid before paint. + public static void PrepareChestOpenVisuals() => PrepareOpenVisuals(chest: true); + + private static void PrepareOpenVisuals(bool chest) + { + for (int i = 0; i < Active.Count; i++) + { + InventoryListView v = Active[i]; + if (v == null || v.IsChest != chest) + continue; + v.ApplyListModeVisuals(ListMode); + } + } + private void Init(Transform grid, bool isChest) { slotParent = grid; @@ -127,11 +179,140 @@ namespace InventoryFramework back.sizeDelta = new Vector2(60f, 26f); gridBackToggle = back.gameObject; MakeButtonOn(back, "List", 12f, () => ListMode = true, out _); + gridBackToggle.SetActive(false); PositionGridToggle(); + HideChrome(); + ApplyListModeVisuals(ListMode); } private GameObject gridBackToggle; private readonly Vector3[] corners = new Vector3[4]; + private bool chromeVisible; + + private bool IsHostPanelOpen() + { + if (slotParent == null) + return false; + if (!slotParent.gameObject.activeInHierarchy) + return false; + + if (IsChest) + { + ChestHudController hud = GetComponentInParent(true); + if (hud != null) + return hud.IsOpen || hud.IsPanelActive; + // ChestInventoryUI lives on the chest panel root. + Transform t = slotParent; + while (t != null) + { + if (t.name == "ChestHud") + return t.gameObject.activeInHierarchy; + t = t.parent; + } + } + + return true; + } + + private bool IsChromeVisible() => chromeVisible; + + private void HideChrome() + { + chromeVisible = false; + Image plate = GetComponent(); + if (plate != null) + plate.enabled = false; + if (root != null) + { + for (int i = 0; i < root.childCount; i++) + { + Transform child = root.GetChild(i); + if (child != null) + child.gameObject.SetActive(false); + } + } + + if (gridBackToggle != null) + gridBackToggle.SetActive(false); + } + + private Image hostPlate; + private Sprite savedHostSprite; + private bool hostSpriteCleared; + + private void ApplyListModeVisuals(bool list) + { + if (gridGroup != null) + { + gridGroup.alpha = list ? 0f : 1f; + gridGroup.blocksRaycasts = !list; + gridGroup.interactable = !list; + } + + // CanvasGroup alpha does not cover the chest panel's baked slot sheet, and the + // list plate is translucent, so the old slot frames show through meteor loot. + SetSlotGraphicsDrawn(!list); + SetHostSlotSheet(list); + + Image plate = GetComponent(); + if (plate != null && list) + plate.color = new Color(0.012f, 0.012f, 0.016f, 1f); + } + + private void SetSlotGraphicsDrawn(bool drawn) + { + if (slotParent == null) + return; + + for (int i = 0; i < slotParent.childCount; i++) + { + Transform child = slotParent.GetChild(i); + if (child == null) + continue; + + Graphic[] graphics = child.GetComponentsInChildren(true); + for (int g = 0; g < graphics.Length; g++) + { + Graphic graphic = graphics[g]; + if (graphic != null && graphic.enabled != drawn) + graphic.enabled = drawn; + } + } + } + + private void SetHostSlotSheet(bool list) + { + if (slotParent == null || slotParent.parent == null) + return; + + if (hostPlate == null) + hostPlate = slotParent.parent.GetComponent(); + if (hostPlate == null) + return; + + if (list) + { + if (!hostSpriteCleared && hostPlate.sprite != null + && hostPlate.sprite.name.IndexOf("slot", System.StringComparison.OrdinalIgnoreCase) >= 0) + { + savedHostSprite = hostPlate.sprite; + hostSpriteCleared = true; + hostPlate.sprite = null; + } + + Color c = hostPlate.color; + if (c.a < 0.98f) + hostPlate.color = new Color(c.r, c.g, c.b, 1f); + return; + } + + if (hostSpriteCleared) + { + hostPlate.sprite = savedHostSprite; + savedHostSprite = null; + hostSpriteCleared = false; + } + } /// Cover exactly the grid's on-screen rect (the grid can be resized by layout after Init). private void SyncRect() @@ -184,22 +365,47 @@ namespace InventoryFramework private void LateUpdate() { if (slotParent == null) { Destroy(gameObject); return; } + + // List view is a sibling of the bag/chest panel. When the panel closes, hide chrome + // or Esc/Tab leave a stuck empty menu with no close path. + if (!IsHostPanelOpen()) + { + if (chromeVisible) + HideChrome(); + ApplyListModeVisuals(list: false); + return; + } + bool list = ListMode; - if (list) SyncRect(); - if (lastVersion != Version || gridGroup.alpha != (list ? 0f : 1f)) + ApplyListModeVisuals(list); + if (list) + SyncRect(); + + bool wantChrome = list; + if (lastVersion != Version || chromeVisible != wantChrome || (list && GetComponent() != null && !GetComponent().enabled)) { lastVersion = Version; - gridGroup.alpha = list ? 0f : 1f; - gridGroup.blocksRaycasts = !list; - gridGroup.interactable = !list; - for (int i = 0; i < root.childCount; i++) root.GetChild(i).gameObject.SetActive(list); - GetComponent().enabled = list; - if (gridBackToggle != null) gridBackToggle.SetActive(!list && !ManafallTouchUi.IsActive); - if (toggleLabel != null) toggleLabel.text = "Grid"; + chromeVisible = wantChrome; + for (int i = 0; i < root.childCount; i++) + root.GetChild(i).gameObject.SetActive(list); + Image plate = GetComponent(); + if (plate != null) + plate.enabled = list; + if (gridBackToggle != null) + gridBackToggle.SetActive(!list && !ManafallTouchUi.IsActive); + if (toggleLabel != null) + toggleLabel.text = "Grid"; lastHash = 0; } - if (!list) { PositionGridToggle(); return; } - if (Time.unscaledTime < nextScan) return; + + if (!list) + { + PositionGridToggle(); + return; + } + + if (Time.unscaledTime < nextScan) + return; nextScan = Time.unscaledTime + 0.2f; Rebuild(); } diff --git a/Assets/Scripts/Inventory/ItemLootRarity.cs b/Assets/Scripts/Inventory/ItemLootRarity.cs index 0cd30bfab..2d79cd2a6 100644 --- a/Assets/Scripts/Inventory/ItemLootRarity.cs +++ b/Assets/Scripts/Inventory/ItemLootRarity.cs @@ -1,11 +1,11 @@ using System; +using UnityEngine; namespace InventoryFramework { /// - /// Shared loot rarity rules (matches high-end chest filtering). - /// Prefers tiers so metal bars and similar loot - /// keep a clear Common → Epic ladder. + /// Shared loot rarity rules. Every item resolves to a ; + /// that tier alone drives random loot rates (Abundant often → Epic almost never). /// public static class ItemLootRarity { @@ -62,6 +62,45 @@ namespace InventoryFramework return PassesSharedCreatureLootExclusions(item); } + /// + /// Canonical loot roll weight for any item: → rate. + /// Optional slightly favors Uncommon+ on high-level kills. + /// Optional biases within a themed pool (1 = pure tier). + /// + public static float ResolveLootWeight( + Item item, + float creatureQuality01 = 0f, + float relativeMul = 1f) + { + if (item == null) + return 0f; + + float weight = MarketItemRarity.LootWeightFor(item); + if (weight <= 0.001f) + return 0f; + + float quality = Mathf.Clamp01(creatureQuality01); + if (quality > 0.01f) + { + MarketItemRarity.Tier tier = MarketItemRarity.GetProfile(item).Tier; + if (tier >= MarketItemRarity.Tier.Epic) + weight *= Mathf.Lerp(1f, 4.2f, quality); + else if (tier >= MarketItemRarity.Tier.Rare) + weight *= Mathf.Lerp(1f, 3.5f, quality); + else if (tier >= MarketItemRarity.Tier.Uncommon) + weight *= Mathf.Lerp(1f, 1.55f, quality); + else if (tier <= MarketItemRarity.Tier.Abundant) + weight *= Mathf.Lerp(1f, 0.82f, quality); + } + + float mul = relativeMul > 0.001f ? relativeMul : 1f; + return weight * mul; + } + + /// Tier of an item for UI / filters (never null — InferProfile always assigns one). + public static MarketItemRarity.Tier ResolveTier(Item item) => + MarketItemRarity.GetProfile(item).Tier; + private static bool PassesSharedCreatureLootExclusions(Item item) { if (item == null || item.id <= 0) @@ -88,6 +127,10 @@ namespace InventoryFramework if (LoreBookLootUtility.IsLoreBookItem(item)) return false; + // Tier weight 0 = excluded / special-only — never random creature loot. + if (MarketItemRarity.LootWeightFor(item) <= 0.001f) + return false; + return true; } } diff --git a/Assets/Scripts/Inventory/TierLootRoll.cs b/Assets/Scripts/Inventory/TierLootRoll.cs new file mode 100644 index 000000000..bbdc534c4 --- /dev/null +++ b/Assets/Scripts/Inventory/TierLootRoll.cs @@ -0,0 +1,128 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace InventoryFramework +{ + /// Shared weighted picks where every candidate's chance comes from item tier. + public static class TierLootRoll + { + public struct Candidate + { + public Item item; + public float weight; + public int minAmount; + public int maxAmount; + } + + public static float Weight(Item item, float creatureQuality01 = 0f, float relativeMul = 1f) => + ItemLootRarity.ResolveLootWeight(item, creatureQuality01, relativeMul); + + public static bool TryAdd( + List dest, + HashSet pickedIds, + Item item, + int minAmount, + int maxAmount, + float creatureQuality01 = 0f, + float relativeMul = 1f) + { + if (dest == null || item == null || item.id <= 0) + return false; + if (pickedIds != null && pickedIds.Contains(item.id)) + return false; + + float weight = Weight(item, creatureQuality01, relativeMul); + if (weight <= 0.001f) + return false; + + int lo = Mathf.Max(1, Mathf.Min(minAmount, maxAmount)); + int hi = Mathf.Max(lo, Mathf.Max(minAmount, maxAmount)); + dest.Add(new Candidate + { + item = item, + weight = weight, + minAmount = lo, + maxAmount = hi, + }); + return true; + } + + public static int PickIndex(List candidates) + { + if (candidates == null || candidates.Count == 0) + return -1; + + float total = 0f; + for (int i = 0; i < candidates.Count; i++) + total += candidates[i].weight; + + if (total <= 0f) + return -1; + + float roll = Random.Range(0f, total); + float cumulative = 0f; + for (int i = 0; i < candidates.Count; i++) + { + cumulative += candidates[i].weight; + if (roll <= cumulative) + return i; + } + + return candidates.Count - 1; + } + + public static int PickIndex(List<(Item item, float weight)> candidates) + { + if (candidates == null || candidates.Count == 0) + return -1; + + float total = 0f; + for (int i = 0; i < candidates.Count; i++) + total += candidates[i].weight; + + if (total <= 0f) + return -1; + + float roll = Random.Range(0f, total); + float cumulative = 0f; + for (int i = 0; i < candidates.Count; i++) + { + cumulative += candidates[i].weight; + if (roll <= cumulative) + return i; + } + + return candidates.Count - 1; + } + + /// Weighted pick where each entry's chance is its item's MarketItemRarity tier rate. + public static int PickIndexByItemTier(IReadOnlyList candidates, System.Func itemOf) + { + if (candidates == null || candidates.Count == 0 || itemOf == null) + return -1; + + float total = 0f; + for (int i = 0; i < candidates.Count; i++) + total += Weight(itemOf(candidates[i])); + + if (total <= 0.001f) + return -1; + + float roll = Random.Range(0f, total); + float cumulative = 0f; + int last = -1; + for (int i = 0; i < candidates.Count; i++) + { + float w = Weight(itemOf(candidates[i])); + if (w <= 0.001f) + continue; + last = i; + cumulative += w; + if (roll <= cumulative) + return i; + } + + return last; + } + } +} diff --git a/Assets/Scripts/Inventory/TierLootRoll.cs.meta b/Assets/Scripts/Inventory/TierLootRoll.cs.meta new file mode 100644 index 000000000..44a6cc8e1 --- /dev/null +++ b/Assets/Scripts/Inventory/TierLootRoll.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a8c3e91f4b2d704e9a15c6d8073f1e2b diff --git a/Assets/Scripts/KillChallenger/KillChallengerCorpseLootChest.cs b/Assets/Scripts/KillChallenger/KillChallengerCorpseLootChest.cs index da2e66630..5af0acc34 100644 --- a/Assets/Scripts/KillChallenger/KillChallengerCorpseLootChest.cs +++ b/Assets/Scripts/KillChallenger/KillChallengerCorpseLootChest.cs @@ -434,7 +434,23 @@ public sealed class KillChallengerCorpseLootChest : NetworkChest if (!IsCorpseLootable()) return false; - float range = Mathf.Clamp(InteractRange, 1f, 6f); + return IsBodyInLootRange(playerPosition, InteractRange); + } + + public override bool IsPlayerInRange(Vector3 playerPosition, float range) + { + if (!IsCorpseLootable()) + return false; + + return IsBodyInLootRange(playerPosition, range); + } + + public override bool ServerIsPlayerInRange(Vector3 playerPosition) => + IsCorpseLootable() && IsPlayerInInteractRange(playerPosition); + + private bool IsBodyInLootRange(Vector3 playerPosition, float range) + { + range = Mathf.Clamp(Mathf.Max(0.5f, range), 0.5f, 8f); // Prefer the corpse look volume (covers the fallen body). Transform volumeTf = transform.Find(BodyLookVolumeName); @@ -457,9 +473,6 @@ public sealed class KillChallengerCorpseLootChest : NetworkChest return Mathf.Abs(delta.y) <= range + 1.5f; } - public override bool ServerIsPlayerInRange(Vector3 playerPosition) => - IsCorpseLootable() && IsPlayerInInteractRange(playerPosition); - /// World point near the torso for proximity checks (hips, else mid feet→head). public Vector3 ResolveBodyLootWorldPosition() { diff --git a/Assets/Scripts/LootSpawner.cs b/Assets/Scripts/LootSpawner.cs index e6d432fad..9fac137e0 100644 --- a/Assets/Scripts/LootSpawner.cs +++ b/Assets/Scripts/LootSpawner.cs @@ -1192,18 +1192,18 @@ public class LootSpawner : NetworkChest, IInteractable, IInteractionPrompt LootCandidate picked; if (abundants != null && abundants.Count > 0 && Random.value < Mathf.Clamp01(abundantChance)) - picked = abundants[Random.Range(0, abundants.Count)]; + picked = PickTierWeightedCandidate(abundants); else { bool wantUncommon = Random.value < Mathf.Clamp01(uncommonChance); if (wantUncommon && uncommons.Count > 0) - picked = uncommons[Random.Range(0, uncommons.Count)]; + picked = PickTierWeightedCandidate(uncommons); else if (commons.Count > 0) - picked = commons[Random.Range(0, commons.Count)]; + picked = PickTierWeightedCandidate(commons); else if (uncommons.Count > 0) - picked = uncommons[Random.Range(0, uncommons.Count)]; + picked = PickTierWeightedCandidate(uncommons); else if (abundants != null && abundants.Count > 0) - picked = abundants[Random.Range(0, abundants.Count)]; + picked = PickTierWeightedCandidate(abundants); else { GameObject prefab = PickAnyAllowedLootPrefab(); @@ -1211,11 +1211,28 @@ public class LootSpawner : NetworkChest, IInteractable, IInteractionPrompt } } + if (picked.Item == null) + { + GameObject prefab = PickAnyAllowedLootPrefab(); + return prefab != null && TryResolveItemFromLootPrefab(prefab, out item, out amount) && item != null; + } + item = picked.Item; amount = picked.RollDirectStack ? RollDirectAmount() : Mathf.Max(1, picked.Amount); return item != null; } + private static LootCandidate PickTierWeightedCandidate(List pool) + { + if (pool == null || pool.Count == 0) + return default; + + int index = InventoryFramework.TierLootRoll.PickIndexByItemTier(pool, c => c.Item); + if (index < 0) + return pool[Random.Range(0, pool.Count)]; + return pool[index]; + } + private int RollDirectAmount() { int min = Mathf.Max(1, directLootMinAmount); diff --git a/Assets/Scripts/ManafallIdentity.cs b/Assets/Scripts/ManafallIdentity.cs index e98a248c4..8ef5d873d 100644 --- a/Assets/Scripts/ManafallIdentity.cs +++ b/Assets/Scripts/ManafallIdentity.cs @@ -5,7 +5,7 @@ public static class ManafallIdentity { public const string ProductName = "Manafall"; - public const string Version = "0.1.43"; + public const string Version = "0.1.44"; /// Unity Player Settings Company Name. Replace with your legal name if a store requires it. public const string PublisherField = "Independent"; public const string BundleId = "com.manafall.manafall"; diff --git a/Assets/Scripts/ManafallWaterRippleSimulation.cs b/Assets/Scripts/ManafallWaterRippleSimulation.cs index 8c0333ab0..f349148ab 100644 --- a/Assets/Scripts/ManafallWaterRippleSimulation.cs +++ b/Assets/Scripts/ManafallWaterRippleSimulation.cs @@ -433,7 +433,9 @@ public sealed class ManafallWaterRippleSimulation : MonoBehaviour Vector2 vel = emitter.PlanarVelocityXZ; bool kelvin = emitter.UseKelvinWakeStamps && (s > 0 || emitter.PlanarSpeed >= 0.25f); - float str = Mathf.Max(emitter.EvaluateStampStrength(s), 0.14f); + float str = emitter.EvaluateStampStrength(s); + if (str < 0.02f) + continue; if (!TryStampWorld( _stampPosScratch[s], emitter.EvaluateStampRadius(s), diff --git a/Assets/Scripts/Market/MarketItemRarity.cs b/Assets/Scripts/Market/MarketItemRarity.cs index dbf5bb2e4..8918af3d3 100644 --- a/Assets/Scripts/Market/MarketItemRarity.cs +++ b/Assets/Scripts/Market/MarketItemRarity.cs @@ -174,6 +174,11 @@ namespace InventoryFramework ["Runed Troll Fang"] = new(Tier.Rare, 8_500, 0.12f, false), ["Ancient Troll Idol Fragment"] = new(Tier.Rare, 28_000, 0.08f, false), ["Troll Heart Core"] = new(Tier.Epic, 85_000, 0.04f, false), + // Druclad void fire boss — CatalogWeight is the creature loot roll weight. + ["Abyssal Ember Ash"] = new(Tier.Common, 55, 2.4f, false), + ["Void Cinder Scale"] = new(Tier.Uncommon, 480, 0.9f, false), + ["Druclad Horn Shard"] = new(Tier.Rare, 14_000, 0.14f, false), + ["Abyss Fire Heart"] = new(Tier.Epic, 90_000, 0.04f, false), ["Vikra Talon"] = new(Tier.Rare, 12_000, 0.14f, false), ["Vikra Bone Shard"] = new(Tier.Rare, 9_500, 0.16f, false), ["Vikra Wing Membrane"] = new(Tier.Rare, 15_000, 0.12f, false), @@ -182,6 +187,8 @@ namespace InventoryFramework ["Nightling Hide"] = new(Tier.Rare, 11_000, 0.14f, false), ["Nightling Essence"] = new(Tier.Rare, 22_000, 0.1f, false), ["Nightling Shadow Shard"] = new(Tier.Epic, 65_000, 0.06f, false), + // Nightling-only signature rare — CatalogWeight 0 keeps it out of generic pools. + ["Dusk-Fee Token"] = new(Tier.Rare, 15_000, 0f, false), ["Glacial Pelt Lock"] = new(Tier.Rare, 9_800, 0.12f, false), ["Frostfang Shard"] = new(Tier.Rare, 18_000, 0.1f, false), ["Phosphor Gland"] = new(Tier.Rare, 8_500, 0.14f, false), @@ -235,6 +242,27 @@ namespace InventoryFramework public static int GetBaseCoinPrice(Item item) => GetProfile(item).BaseCoinPrice; + /// + /// Creature/chest loot roll weight from the item's rarity tier. + /// Every item gets a tier via ; CatalogWeight is the rate for that tier + /// (or an explicit ByName override). Weight 0 means the item is excluded from random loot. + /// + public static float LootWeightFor(Item item) + { + if (item == null) + return 0f; + + Profile profile = GetProfile(item); + // Explicit 0 = excluded / special-only (never randomly rolled). + if (profile.CatalogWeight <= 0.001f) + return 0f; + + return profile.CatalogWeight; + } + + /// Default loot rate ladder shared by markets and creature tables. + public static float LootWeightForTier(Tier tier) => WeightForTier(tier); + public static int ClampPriceForTier(Tier tier, int price) { return tier switch @@ -410,7 +438,11 @@ namespace InventoryFramework || name.IndexOf("glacial", StringComparison.OrdinalIgnoreCase) >= 0 || name.IndexOf("frostfang", StringComparison.OrdinalIgnoreCase) >= 0 || name.IndexOf("phosphor", StringComparison.OrdinalIgnoreCase) >= 0 - || name.IndexOf("wisp", StringComparison.OrdinalIgnoreCase) >= 0; + || name.IndexOf("wisp", StringComparison.OrdinalIgnoreCase) >= 0 + || name.IndexOf("druclad", StringComparison.OrdinalIgnoreCase) >= 0 + || name.IndexOf("abyss", StringComparison.OrdinalIgnoreCase) >= 0 + || name.IndexOf("abyssal", StringComparison.OrdinalIgnoreCase) >= 0 + || name.IndexOf("cinder", StringComparison.OrdinalIgnoreCase) >= 0; private static int PriceForTier(Tier tier, Item item) { diff --git a/Assets/Scripts/Menu/MainMenuDirector.cs b/Assets/Scripts/Menu/MainMenuDirector.cs index f55ad4b34..fb824eaeb 100644 --- a/Assets/Scripts/Menu/MainMenuDirector.cs +++ b/Assets/Scripts/Menu/MainMenuDirector.cs @@ -1218,6 +1218,7 @@ public sealed class MainMenuDirector : MonoBehaviour BuildOptionsPanel(canvasRt); BuildCreditsPanel(canvasRt); BuildOfflineSavesPanel(canvasRt); + CreateBuildVersionLabel(canvasRt); } private void OpenSealclashCardGame() @@ -2582,6 +2583,58 @@ public sealed class MainMenuDirector : MonoBehaviour tmp.font = TMP_Settings.defaultFontAsset; } + /// + /// Bottom-left build version so players can tell Mega / Probe / patched installs apart. + /// Uses (Player Settings), not a hardcoded string. + /// + private void CreateBuildVersionLabel(RectTransform canvasRt) + { + if (canvasRt == null) + return; + + Transform existing = canvasRt.Find("BuildVersionLabel"); + if (existing != null) + Destroy(existing.gameObject); + + string appVersion = Application.version; + if (string.IsNullOrWhiteSpace(appVersion)) + appVersion = ManafallIdentity.Version; + + string lastApplied = PlayerPrefs.GetString("Manafall.Patch.LastApplied", string.Empty).Trim(); + string label = "v" + appVersion.Trim(); + if (!string.IsNullOrEmpty(lastApplied) + && !string.Equals(lastApplied, appVersion.Trim(), System.StringComparison.OrdinalIgnoreCase)) + { + label += " (patched " + lastApplied + ")"; + } + +#if DEVELOPMENT_BUILD + label += " DEV"; +#endif + + var go = new GameObject("BuildVersionLabel", typeof(RectTransform)); + go.transform.SetParent(canvasRt, false); + RectTransform rt = go.GetComponent(); + rt.anchorMin = new Vector2(0f, 0f); + rt.anchorMax = new Vector2(0f, 0f); + rt.pivot = new Vector2(0f, 0f); + rt.anchoredPosition = new Vector2(touchMenu ? 18f : 24f, touchMenu ? 14f : 18f); + rt.sizeDelta = new Vector2(touchMenu ? 420f : 520f, touchMenu ? 28f : 26f); + + TextMeshProUGUI tmp = go.AddComponent(); + tmp.text = label; + tmp.fontSize = touchMenu ? 18f : 16f; + tmp.alignment = TextAlignmentOptions.Left; + tmp.color = new Color(1f, 0.96f, 0.86f, 0.72f); + tmp.raycastTarget = false; + if (TMP_Settings.defaultFontAsset != null) + tmp.font = TMP_Settings.defaultFontAsset; + + Debug.Log("[MainMenu] Build version label: " + label + + " identity=" + ManafallIdentity.Version + + " unity=" + Application.unityVersion); + } + private void CreateManafallTitle(RectTransform parent) { Sprite titleSprite = ResolveManafallTitleSprite(); diff --git a/Assets/Scripts/MeteorStrike/Editor/MeteorStrikeAssetBuilder.cs b/Assets/Scripts/MeteorStrike/Editor/MeteorStrikeAssetBuilder.cs index da691f77c..211f97407 100644 --- a/Assets/Scripts/MeteorStrike/Editor/MeteorStrikeAssetBuilder.cs +++ b/Assets/Scripts/MeteorStrike/Editor/MeteorStrikeAssetBuilder.cs @@ -86,8 +86,9 @@ public static class MeteorStrikeAssetBuilder } table.entries.Clear(); - table.entries.Add(new MeteorLootTable.Entry { item = iron, weight = 6f, minAmount = 2, maxAmount = 5 }); - table.entries.Add(new MeteorLootTable.Entry { item = shard, weight = 3f, minAmount = 1, maxAmount = 2 }); + // weight 0 = pure MarketItemRarity tier rate at roll time (see MeteorLootTable.RollLoot). + table.entries.Add(new MeteorLootTable.Entry { item = iron, weight = 0f, minAmount = 2, maxAmount = 5 }); + table.entries.Add(new MeteorLootTable.Entry { item = shard, weight = 0f, minAmount = 1, maxAmount = 2 }); var seen = new HashSet { iron.id, shard.id }; int rare = 0, epic = 0; @@ -116,7 +117,7 @@ public static class MeteorStrikeAssetBuilder table.entries.Add(new MeteorLootTable.Entry { item = item, - weight = tier == MarketItemRarity.Tier.Epic ? 1f : 2.5f, + weight = 0f, minAmount = 1, maxAmount = item.maxStack > 1 ? 2 : 1 }); diff --git a/Assets/Scripts/MeteorStrike/MeteorLootTable.cs b/Assets/Scripts/MeteorStrike/MeteorLootTable.cs index bc0443220..309ab56a7 100644 --- a/Assets/Scripts/MeteorStrike/MeteorLootTable.cs +++ b/Assets/Scripts/MeteorStrike/MeteorLootTable.cs @@ -29,8 +29,20 @@ public sealed class MeteorLootTable : ScriptableObject var results = new List(rolls); var pool = new List(entries.Count); for (int i = 0; i < entries.Count; i++) - if (entries[i].item != null && entries[i].weight > 0f) - pool.Add(entries[i]); + { + Entry row = entries[i]; + if (row.item == null) + continue; + + // Serialized weight is a relative mul; loot rate comes from MarketItemRarity tier. + float relativeMul = row.weight > 0.001f ? row.weight : 1f; + float tierWeight = ItemLootRarity.ResolveLootWeight(row.item, relativeMul: relativeMul); + if (tierWeight <= 0.001f) + continue; + + row.weight = tierWeight; + pool.Add(row); + } for (int r = 0; r < rolls && pool.Count > 0; r++) { diff --git a/Assets/Scripts/Networking/HostWorldSave.cs b/Assets/Scripts/Networking/HostWorldSave.cs index 61d1ea56f..9addf14e6 100644 --- a/Assets/Scripts/Networking/HostWorldSave.cs +++ b/Assets/Scripts/Networking/HostWorldSave.cs @@ -68,6 +68,8 @@ public static class HostWorldSave public int[] witchRecipes = Array.Empty(); public int[] witchBooksRead = Array.Empty(); public string[] blacksmithForgeGrants; + public byte[] diseaseIds; + public float[] diseaseRemainingSeconds; } [Serializable] @@ -120,6 +122,7 @@ public static class HostWorldSave HostMountSave.NotifySessionEnded(); TavernBarChestSave.NotifySessionEnded(); PlaceableOwnership.NotifySessionEnded(); + DrucladWorldFlags.NotifySessionEnded(); } public static void NotifySessionEnded() @@ -130,6 +133,7 @@ public static class HostWorldSave HostMountSave.NotifySessionEnded(); TavernBarChestSave.NotifySessionEnded(); PlaceableOwnership.NotifySessionEnded(); + DrucladWorldFlags.NotifySessionEnded(); } public static string SlotRootDirectory() @@ -174,6 +178,7 @@ public static class HostWorldSave CaptureWorldEntities(); HostMountSave.ServerCapture(); TavernBarChestSave.ServerCapture(); + DrucladWorldFlags.ServerCapture(); // Terrain dig/fill op log (holes, tunnels, mounds) saves with the world. VoxelWorld.Instance?.ServerFlushDigSave(true); long t2 = System.Diagnostics.Stopwatch.GetTimestamp(); @@ -276,6 +281,8 @@ public static class HostWorldSave witchRecipes = CaptureWitchRecipes(player, false), witchBooksRead = CaptureWitchRecipes(player, true), blacksmithForgeGrants = baseData.blacksmithForgeGrants, + diseaseIds = baseData.diseaseIds, + diseaseRemainingSeconds = baseData.diseaseRemainingSeconds, }; return blob; } @@ -671,6 +678,8 @@ public static class HostWorldSave hotbar = blob.hotbar, equipment = blob.equipment, blacksmithForgeGrants = blob.blacksmithForgeGrants, + diseaseIds = blob.diseaseIds, + diseaseRemainingSeconds = blob.diseaseRemainingSeconds, }; // Don't stomp day/night from a remote player's blob. @@ -748,6 +757,7 @@ public static class HostWorldSave SaveFileWriter.Flush(); HostMountSave.ServerRestore(); + DrucladWorldFlags.ServerRestore(); string path = WorldEntitiesPath(); if (!File.Exists(path)) { diff --git a/Assets/Scripts/Networking/NetworkNpcReplication.cs b/Assets/Scripts/Networking/NetworkNpcReplication.cs index 9c091bc21..3bd23caa2 100644 --- a/Assets/Scripts/Networking/NetworkNpcReplication.cs +++ b/Assets/Scripts/Networking/NetworkNpcReplication.cs @@ -50,6 +50,7 @@ public static class NetworkNpcReplication || identity.TryGetComponent(out BanditCampController _) || identity.TryGetComponent(out TrollController _) || identity.TryGetComponent(out HellhoundController _) + || identity.TryGetComponent(out DrucladController _) || identity.TryGetComponent(out IceWolfController _) || identity.TryGetComponent(out Manafall.RuneSprites.RuneSpriteController _); } @@ -362,6 +363,7 @@ public static class NetworkNpcReplication return root.TryGetComponent(out IceWolfController _) || root.TryGetComponent(out HellhoundController _) || root.TryGetComponent(out TrollController _) + || root.TryGetComponent(out DrucladController _) || root.TryGetComponent(out BanditController _) || root.TryGetComponent(out ScorpionController _) || root.TryGetComponent(out CaveWormController _) diff --git a/Assets/Scripts/Networking/OfflineSoloSave.cs b/Assets/Scripts/Networking/OfflineSoloSave.cs index 9a6b3c3b6..7be778347 100644 --- a/Assets/Scripts/Networking/OfflineSoloSave.cs +++ b/Assets/Scripts/Networking/OfflineSoloSave.cs @@ -83,6 +83,10 @@ public static class OfflineSoloSave public SlotRecord[] equipment = Array.Empty(); /// Village smith keys this player may use. Empty on saves from before forge permission was kept. public string[] blacksmithForgeGrants; + /// Active mundane disease ids (byte DiseaseId). Empty/null on older saves. + public byte[] diseaseIds; + /// Remaining seconds per diseaseIds entry. + public float[] diseaseRemainingSeconds; } [Serializable] @@ -582,6 +586,14 @@ public static class OfflineSoloSave data.longTermStamina = stamina.CurrentLongTermStamina; } + PlayerDiseaseRuntime diseases = PlayerDiseaseRuntime.EnsureOn(player.gameObject); + if (diseases != null) + { + PlayerDiseaseRuntime.SaveSnapshot snap = diseases.CaptureSave(); + data.diseaseIds = snap.ids; + data.diseaseRemainingSeconds = snap.remainingSeconds; + } + if (DayNightCycle.Instance != null) { data.timeNormalized = DayNightCycle.Instance.TimeNormalized; @@ -676,6 +688,17 @@ public static class OfflineSoloSave if (stamina != null) stamina.RestoreExact(data.stamina, data.longTermStamina); + PlayerDiseaseRuntime diseases = PlayerDiseaseRuntime.EnsureOn(player.gameObject); + DiseaseAmbientDriver.EnsureOn(player.gameObject); + if (diseases != null) + { + diseases.ApplySave(new PlayerDiseaseRuntime.SaveSnapshot + { + ids = data.diseaseIds, + remainingSeconds = data.diseaseRemainingSeconds, + }); + } + if (DayNightCycle.Instance != null) DayNightCycle.Instance.SetClock(data.timeNormalized, data.dayNumber); diff --git a/Assets/Scripts/PlayerAquaticController.cs b/Assets/Scripts/PlayerAquaticController.cs index 9c530c724..8191845d3 100644 --- a/Assets/Scripts/PlayerAquaticController.cs +++ b/Assets/Scripts/PlayerAquaticController.cs @@ -36,8 +36,8 @@ public sealed class PlayerAquaticController : MonoBehaviour [Tooltip("Once swimming, keep swimming until the feet are shallower than this (avoids flicker at the shore).")] [SerializeField] private float swimStopFeetDepthMeters = 0.7f; private bool swimStateLatched; - [Tooltip("If ground under the feet is within this of the water plane, treat as dry shore (not swimming).")] - [SerializeField] private float dryShoreTerrainClearance = 0.1f; + [Tooltip("If ground under the feet is within this of the calm water plane, treat as dry shore (not swimming).")] + [SerializeField] private float dryShoreTerrainClearance = 0.12f; [Tooltip("Optional; when unset, the first SeaWaterSurface in the scene supplies material params and plane vertical offset.")] [SerializeField] private SeaWaterSurface seaWaterSurface; [Tooltip("Uses Gerstner vertical displacement from Manafall/WaterSurface so breath line and buoyancy track visible swell on the open ocean.")] @@ -50,6 +50,10 @@ public sealed class PlayerAquaticController : MonoBehaviour [SerializeField] private float inWaterGravityMultiplier = 0.035f; [SerializeField] private float buoyancyUpSpeed = 2.4f; [SerializeField] private float swimPaddleUpSpeed = 5.5f; + [Tooltip("Meters per second straight down while Ctrl is held in the water.")] + [SerializeField] private float swimPaddleDownSpeed = 4.8f; + [Tooltip("Sink speed when idle in the water — not holding Space and not swimming.")] + [SerializeField] private float idleSinkSpeed = 1.35f; [Tooltip("Planar speed lerps from land (×1) toward swim multiplier as more of the capsule sits below the surface. Higher = stay closer to land speed until deeper.")] [SerializeField] private float swimSpeedSubmersionExponent = 1.28f; [Tooltip("Fraction of capsule height below the surface before swim slowdown begins (0–1).")] @@ -153,9 +157,13 @@ public sealed class PlayerAquaticController : MonoBehaviour [Header("Debug")] [Tooltip("Logs water/motor evaluation every relevant tick (PrepareMotor runs twice/frame when walking). Search Console for [MF_AQUATIC_MV]. Disable after profiling.")] [SerializeField] private bool logAquaticMovementDebug = false; + [Tooltip("Logs camera vs water surface for breath/tint. Search Console for [MF_UWCAM].")] + [SerializeField] private bool logUnderwaterCameraDebug = true; /// Copy/paste into Unity Console search: [MF_AQUATIC_MV] public const string AquaticMovementSearchToken = "[MF_AQUATIC_MV]"; + /// Copy/paste into Unity Console search: [MF_UWCAM] + public const string UnderwaterCameraDebugToken = "[MF_UWCAM]"; private IAquaticMotorHooks motorHooks; private AquaticPhysicsSimulator aquaticPhysicsSimulator; @@ -164,6 +172,7 @@ public sealed class PlayerAquaticController : MonoBehaviour private PlayerSailBoatMount sailDeckMount; private InputAction swimJumpAction; + private InputAction swimDiveAction; private float oxygen; private float waterSurfaceY; private bool hasWaterSurface; @@ -251,6 +260,12 @@ public sealed class PlayerAquaticController : MonoBehaviour public bool ShouldDriveWaterRipples => aquaticEffectsAllowed && hasWaterSurface && (inWaterForMotor || CapsuleIntersectsWater || bodyInWater); + public bool SwimAscendHeld => swimJumpAction != null && swimJumpAction.IsPressed(); + public bool SwimDescendHeld => swimDiveAction != null && swimDiveAction.IsPressed(); + public bool SwimPlanarHeld => AquaticPlanarInputTrying(); + public float SwimDiveSpeed => swimPaddleDownSpeed; + public float IdleSinkSpeed => idleSinkSpeed; + /// When set, vertical buoyancy/wave-follow runs via (server authority). public bool UseServerAquaticVertical => aquaticPhysicsSimulator != null; @@ -350,16 +365,23 @@ public sealed class PlayerAquaticController : MonoBehaviour swimJumpAction = new InputAction("AquaticSwimJump", InputActionType.Button); swimJumpAction.AddBinding("/space"); swimJumpAction.AddBinding("/buttonSouth"); + + swimDiveAction = new InputAction("AquaticSwimDive", InputActionType.Button); + swimDiveAction.AddBinding("/leftCtrl"); + swimDiveAction.AddBinding("/rightCtrl"); + swimDiveAction.AddBinding("/buttonEast"); } private void OnEnable() { swimJumpAction?.Enable(); + swimDiveAction?.Enable(); } private void OnDisable() { swimJumpAction?.Disable(); + swimDiveAction?.Disable(); ResetAquaticSurfaceSmoothing(); motorHooks?.ResetAquaticMovementMultipliers(); if (stamina != null) @@ -609,6 +631,16 @@ public sealed class PlayerAquaticController : MonoBehaviour if (hasWaterSurface && controller != null) TryPromoteDeepWorldSea(controller.bounds); + // Capsule under the map-wide sea dive volume while map terrain is dry hills above sea: + // refuse ocean swim/breath (same land gate as ManafallUnderwaterRenderFeature). + if (hasWaterSurface && controller != null + && ShouldSuppressOceanUnderDryLand(controller.bounds.center.x, controller.bounds.center.z)) + { + hasWaterSurface = false; + waterSurfaceKind = WaterSurfaceKind.None; + waterSurfaceY = float.NaN; + } + if (!hasWaterSurface) { ResetAquaticSurfaceSmoothing(); @@ -665,14 +697,32 @@ public sealed class PlayerAquaticController : MonoBehaviour playerCamera != null ? playerCamera.transform.position.z : b.center.z); float surfaceAtFeet = GetLocalWaterSurfaceY(b.center.x, b.center.z); waterSurfaceY = ResolveCapsuleDrivingSurfaceY(b); - eyesBelowSurface = GetEyeWorldY() < surfaceAtEyes - surfaceEpsilon; + bool dryShore = IsStandingOnDryShore(b, surfaceAtFeet); + float eyeY = GetEyeWorldY(); + // Breath / tint / underwater audio: camera lens must be under the local surface. + // On WorldSea use the calm plane so wave crests do not drown you on a dry beach. + float eyeSurfaceY = waterSurfaceKind == WaterSurfaceKind.WorldSea + ? ResolveCalmShoreSurfaceY(surfaceAtEyes) + : surfaceAtEyes; + eyesBelowSurface = !dryShore && eyeY < eyeSurfaceY - surfaceEpsilon; + // Grounded under a water lid many meters above the camera is never "eyes underwater" + // (PhysicsColliders tall dive volume / wrong surface pick). + if (eyesBelowSurface + && controller != null + && controller.isGrounded + && eyeSurfaceY > eyeY + 1.5f + && (eyeSurfaceY - eyeY) > 3.5f) + { + eyesBelowSurface = false; + } // Deep dives still count via feet below surface + water column / volume. // Shore standing must NOT count just because beach height sits near the sea plane. - bodyInWater = EvaluateBodyInWater(b, surfaceAtFeet, waterProbeHitsVolume); + bodyInWater = !dryShore && EvaluateBodyInWater(b, surfaceAtFeet, waterProbeHitsVolume); // WorldSea plane is always found, but only allow aquatic state with real immersion. // Lake/collider water uses the same body-in-water gate (volume hit alone on dry shore is ignored). aquaticEffectsAllowed = bodyInWater || eyesBelowSurface; + LogUnderwaterCameraDebug(eyeY, eyeSurfaceY, surfaceAtFeet, dryShore, waterProbeHitsVolume); UpdateSplashAudio(); @@ -897,31 +947,42 @@ public sealed class PlayerAquaticController : MonoBehaviour float dt = Time.deltaTime; - if (!UseServerAquaticVertical) + swimJumpAction?.Enable(); + swimDiveAction?.Enable(); + bool ascend = SwimAscendHeld; + bool descend = SwimDescendHeld; + bool stroking = SwimPlanarHeld; + float equipMul = GetEquipmentMoveSpeedMultiplier(); + + if (descend && !UseServerAquaticVertical) { - float buoyScale = smoothedBuoyancySurfaceFactor * - Mathf.Pow(Mathf.Clamp01(lastSubmergedFractionForMotor), Mathf.Max(0.01f, buoyancySubmersionExponent)); - - bool swimDriveActive = AquaticPlanarInputTrying() || - (swimJumpAction != null && swimJumpAction.IsPressed()); - float buoyIdleMul = swimDriveActive ? 1f : buoyancyIdleScale; - - if (!SwimOverloaded) controller.Move(Vector3.up * buoyancyUpSpeed * buoyScale * buoyIdleMul * Mathf.Clamp01(1f - SwimMediumLoad01()) * dt); - // Idle swimmers float up until the head is just above the surface (Jean: idle player sank - // to ~2 m deep with the head under water). Holding a move/swim key skips this, so - // deliberate dives still work; let go and you drift back up. - // Heavy gear: overloaded swimmers keep sinking even while paddling. Idle float / idle sink - // is driven through the swim motor (GetIdleSwimTargetVerticalSpeed) so it is not fought by gravity. - if (SwimOverloaded && swimDriveActive) + controller.Move(Vector3.down * swimPaddleDownSpeed * equipMul * dt); + } + else if (!UseServerAquaticVertical) + { + bool swimDriveActive = stroking || ascend; + if (!swimDriveActive && !SwimOverloaded) + { + controller.Move(Vector3.down * idleSinkSpeed * dt); + } + else if (!SwimOverloaded) + { + float buoyScale = smoothedBuoyancySurfaceFactor * + Mathf.Pow(Mathf.Clamp01(lastSubmergedFractionForMotor), Mathf.Max(0.01f, buoyancySubmersionExponent)); + controller.Move(Vector3.up * buoyancyUpSpeed * buoyScale * Mathf.Clamp01(1f - SwimMediumLoad01()) * dt); + ApplyWaveSurfaceFollow(dt); + } + else if (swimDriveActive) + { controller.Move(Vector3.down * swimOverloadSinkSpeed * dt); - if (!SwimOverloaded) ApplyWaveSurfaceFollow(dt); + } + ApplyLocalWaveOrbitalDrift(dt); } - swimJumpAction?.Enable(); bool canSwimUp = (stamina == null || stamina.HasSwimLocomotionStamina) && !SwimOverloaded; - if (swimJumpAction != null && swimJumpAction.IsPressed() && smoothedBuoyancySurfaceFactor > 0.12f && canSwimUp) - controller.Move(Vector3.up * swimPaddleUpSpeed * GetEquipmentMoveSpeedMultiplier() * Mathf.Clamp01(lastSwimSpeedBlend01) * dt); + if (ascend && !descend && smoothedBuoyancySurfaceFactor > 0.12f && canSwimUp) + controller.Move(Vector3.up * swimPaddleUpSpeed * equipMul * Mathf.Max(0.45f, Mathf.Clamp01(lastSwimSpeedBlend01)) * dt); } /// @@ -1147,6 +1208,10 @@ public sealed class PlayerAquaticController : MonoBehaviour if (bodyBounds.min.y >= surfaceAtFeet - minWade) return false; + // Dry shore first: the map-wide sea dive volume overlaps beaches just above sea level. + if (IsStandingOnDryShore(bodyBounds, surfaceAtFeet)) + return false; + // Water-layer volume under/around the capsule is enough (lakes, sea surface probes). if (waterProbeHitsVolume) return true; @@ -1156,15 +1221,88 @@ public sealed class PlayerAquaticController : MonoBehaviour return true; // World-sea fallback without a volume hit: require a real water column (ground below the plane). - // Dry beaches sit at/above the plane and must stay out of swim state. - if (!TrySampleGroundUnderFeet(bodyBounds, out float groundY)) + if (!TrySampleGroundUnderFeet(bodyBounds, out _)) return bodyBounds.min.y < surfaceAtFeet - minWade; - float clearance = Mathf.Max(0.02f, dryShoreTerrainClearance); - if (groundY >= surfaceAtFeet - clearance) + return true; + } + + /// + /// Feet planted on map terrain at or above the calm water plane (wave crests ignored). + /// + private bool IsStandingOnDryShore(Bounds bodyBounds, float surfaceAtFeet) + { + if (!TrySampleGroundUnderFeet(bodyBounds, out float groundY)) return false; - return true; + float calmPlane = ResolveCalmShoreSurfaceY(surfaceAtFeet); + float clearance = Mathf.Max(0.02f, dryShoreTerrainClearance); + + // Beach at/above the calm plane stays dry even when Gerstner crests wash over it. + if (groundY >= calmPlane - clearance) + return true; + + // Grounded on a shelf only slightly under the calm plane, but not deep enough to wade. + float minWade = Mathf.Max(0.05f, minWadeDepthMeters); + if (controller != null + && controller.isGrounded + && groundY >= calmPlane - minWade + && bodyBounds.min.y >= calmPlane - minWade) + return true; + + return false; + } + + /// Calm ocean / lake plane for shore dryness — not the live Gerstner crest. + private float ResolveCalmShoreSurfaceY(float surfaceAtFeet) + { + if (waterSurfaceKind != WaterSurfaceKind.WorldSea) + return surfaceAtFeet; + + if (worldGenerator == null) + worldGenerator = FindFirstObjectByType(); + if (worldGenerator == null) + return surfaceAtFeet; + + float seaY = worldGenerator.SeaSurfaceWorldY; + SeaWaterSurface resolvedSea = ResolveSeaWaterSurfaceRef(); + if (includeSeaPlaneVerticalOffsetInPhysics && resolvedSea != null) + seaY += resolvedSea.PlaneVerticalOffset; + + return seaY; + } + + private float nextUnderwaterCameraDebugAt; + private bool lastLoggedEyesBelow; + + private void LogUnderwaterCameraDebug( + float eyeY, + float eyeSurfaceY, + float surfaceAtFeet, + bool dryShore, + bool waterProbeHitsVolume) + { + if (!logUnderwaterCameraDebug) + return; + + bool changed = eyesBelowSurface != lastLoggedEyesBelow; + float now = Time.unscaledTime; + if (!changed && now < nextUnderwaterCameraDebugAt) + return; + + lastLoggedEyesBelow = eyesBelowSurface; + nextUnderwaterCameraDebugAt = now + (eyesBelowSurface || changed ? 0.75f : 2.5f); + + float landY = float.NaN; + if (TrySampleGroundUnderFeet(controller != null ? controller.bounds : new Bounds(transform.position, Vector3.one), out float g)) + landY = g; + + Debug.Log( + $"{UnderwaterCameraDebugToken} eyesBelow={eyesBelowSurface} dryShore={dryShore} " + + $"eyeY={eyeY:F3} eyeSurfY={eyeSurfaceY:F3} feetSurfY={surfaceAtFeet:F3} " + + $"landY={(float.IsNaN(landY) ? "n/a" : landY.ToString("F3"))} " + + $"kind={waterSurfaceKind} bodyInWater={bodyInWater} probe={waterProbeHitsVolume} " + + $"fx={aquaticEffectsAllowed} breath={(aquaticEffectsAllowed && eyesBelowSurface)}"); } private bool TrySampleGroundUnderFeet(Bounds bodyBounds, out float groundY) @@ -1208,10 +1346,13 @@ public sealed class PlayerAquaticController : MonoBehaviour const QueryTriggerInteraction qti = QueryTriggerInteraction.Collide; + float headY = controller != null ? controller.bounds.max.y : transform.position.y + 1.6f; + float feetYProbe = controller != null ? controller.bounds.min.y : transform.position.y; float bestTop = float.NegativeInfinity; + float bestDistToHead = float.MaxValue; if (controller != null && TryCapsuleOverlapWater(mask, qti, out float capTop)) - bestTop = Mathf.Max(bestTop, capTop); + ConsiderPhysicsWaterSurface(capTop, headY, feetYProbe, ref bestTop, ref bestDistToHead); int colCount = Physics.OverlapSphereNonAlloc(p + Vector3.up * 1.5f, 6f, s_overlapBuffer, mask, qti); for (int i = 0; i < colCount; i++) @@ -1220,16 +1361,31 @@ public sealed class PlayerAquaticController : MonoBehaviour if (c == null || !c.enabled || IsIslandRiverWaterObject(c.gameObject)) continue; float y = PreferredWaterPlaneWorldY(c); - if (y > bestTop) - bestTop = y; + ConsiderPhysicsWaterSurface(y, headY, feetYProbe, ref bestTop, ref bestDistToHead); } if (bestTop > float.NegativeInfinity + 1f) { - waterSurfaceKind = WaterSurfaceKind.PhysicsColliders; - waterSurfaceY = bestTop; - hasWaterSurface = true; - return; + // Tall sea-dive lids / parent renderer max can sit many meters above the camera while + // you walk dry. Ignore those and fall through to WorldSea / river. + bool highLidWhileGrounded = controller != null + && controller.isGrounded + && bestTop > headY + 1.5f + && (bestTop - headY) > 3.5f; + if (!highLidWhileGrounded) + { + waterSurfaceKind = WaterSurfaceKind.PhysicsColliders; + waterSurfaceY = bestTop; + hasWaterSurface = true; + return; + } + + if (logUnderwaterCameraDebug) + { + Debug.Log( + $"{UnderwaterCameraDebugToken} skipPhysicsHighLid bestTop={bestTop:F3} headY={headY:F3} " + + $"delta={(bestTop - headY):F3} → try WorldSea"); + } } if (TryResolveIslandRiverWater(p.x, p.z, out float riverY)) @@ -1262,6 +1418,10 @@ public sealed class PlayerAquaticController : MonoBehaviour if (feetY > seaY + 14f) return; + // Dry hills above the ocean plane — never arm WorldSea (shell fall-through / void under terrain). + if (IsMapTerrainDryAboveOcean(p.x, p.z)) + return; + waterSurfaceKind = WaterSurfaceKind.WorldSea; waterSurfaceY = seaY; hasWaterSurface = true; @@ -1270,6 +1430,62 @@ public sealed class PlayerAquaticController : MonoBehaviour } } + /// + /// Map terrain at XZ sits clearly above the ocean plane (matches underwater post land gate). + /// Wet river channels are excluded so real river wading still works. + /// + private bool IsMapTerrainDryAboveOcean(float worldX, float worldZ) + { + if (worldGenerator == null) + worldGenerator = FindFirstObjectByType(); + if (worldGenerator == null) + return false; + + float seaY = worldGenerator.SeaSurfaceWorldY; + SeaWaterSurface resolvedSea = ResolveSeaWaterSurfaceRef(); + if (includeSeaPlaneVerticalOffsetInPhysics && resolvedSea != null) + seaY += resolvedSea.PlaneVerticalOffset; + + if (IslandRiverNetwork.IsReady + && IslandRiverNetwork.TrySample(worldX, worldZ, out IslandRiverNetwork.Sample river) + && river.hit + && river.distance <= IslandRiverNetwork.WetHalfWidth(river.halfWidth) * 0.96f) + return false; + + if (!worldGenerator.TrySampleMapTerrainWorldY(worldX, worldZ, out float landY)) + return false; + + // Beaches just above sea level count as dry (was +0.25, which still wet the waterline). + return landY >= seaY + 0.05f; + } + + /// + /// Ocean surface under dry land must not drive breath/swim. Highland lakes keep a surface + /// well above sea Y and are left alone. Covers PhysicsColliders from the sea dive trigger + /// when the capsule has fallen through the terrain shell. + /// + private bool ShouldSuppressOceanUnderDryLand(float worldX, float worldZ) + { + if (!hasWaterSurface || float.IsNaN(waterSurfaceY)) + return false; + + if (worldGenerator == null) + worldGenerator = FindFirstObjectByType(); + if (worldGenerator == null) + return false; + + float seaY = worldGenerator.SeaSurfaceWorldY; + SeaWaterSurface resolvedSea = ResolveSeaWaterSurfaceRef(); + if (includeSeaPlaneVerticalOffsetInPhysics && resolvedSea != null) + seaY += resolvedSea.PlaneVerticalOffset; + + // Local lake / pond / highland sheet — not the ocean plane. + if (Mathf.Abs(waterSurfaceY - seaY) > 2.5f) + return false; + + return IsMapTerrainDryAboveOcean(worldX, worldZ); + } + /// /// Thin Water-layer probes only cover the top of the sea. Once the capsule is clearly /// under the analytical sea plane, keep WorldSea swim rules so deep dives don't lose buoyancy. @@ -1286,6 +1502,9 @@ public sealed class PlayerAquaticController : MonoBehaviour if (includeSeaPlaneVerticalOffsetInPhysics && resolvedSea != null) seaY += resolvedSea.PlaneVerticalOffset; + if (IsMapTerrainDryAboveOcean(bodyBounds.center.x, bodyBounds.center.z)) + return; + // Head clearly below sea surface → underwater column, not a shallow puddle splash. if (bodyBounds.max.y > seaY - 1.25f) return; @@ -1396,12 +1615,58 @@ public sealed class PlayerAquaticController : MonoBehaviour return parent != null && parent.name == "IslandRivers"; } + /// + /// Prefer the water plane nearest the player's head — not the tallest overlapping volume lid. + /// + private static void ConsiderPhysicsWaterSurface( + float surfaceY, + float headY, + float feetY, + ref float bestTop, + ref float bestDistToHead) + { + if (float.IsNaN(surfaceY) || surfaceY < feetY - 2f) + return; + + // Remap absurd lids toward the head band so distance scoring stays sane. + float scoreY = surfaceY; + if (surfaceY > headY + 8f) + scoreY = headY + 8f; + + float dist = Mathf.Abs(scoreY - headY); + if (dist > bestDistToHead + 0.05f) + return; + if (dist >= bestDistToHead - 0.05f && surfaceY <= bestTop) + return; + + bestDistToHead = dist; + bestTop = surfaceY; + } + /// /// Use the visible mesh top when possible so the logical surface matches the water object; /// thin triggers / thick volumes often leave collider bounds.max.y far from the actual plane. + /// SeaWater dive probes use the analytical sea plane — never parent-renderer max. /// - private static float PreferredWaterPlaneWorldY(Collider c) + private float PreferredWaterPlaneWorldY(Collider c) { + if (c == null) + return float.NegativeInfinity; + + SeaWaterSurface sea = c.GetComponentInParent(); + if (sea != null) + { + if (worldGenerator == null) + worldGenerator = sea.WorldGenerator != null ? sea.WorldGenerator : FindFirstObjectByType(); + if (worldGenerator != null) + { + float seaY = worldGenerator.SeaSurfaceWorldY; + if (includeSeaPlaneVerticalOffsetInPhysics) + seaY += sea.PlaneVerticalOffset; + return seaY; + } + } + Renderer own = c.GetComponent(); if (own != null && own.enabled && own.gameObject.activeInHierarchy) return own.bounds.max.y; @@ -1410,22 +1675,8 @@ public sealed class PlayerAquaticController : MonoBehaviour if (child != null && child.enabled && child.gameObject.activeInHierarchy) return child.bounds.max.y; - Transform parent = c.transform.parent; - if (parent != null) - { - float bestTop = float.NegativeInfinity; - foreach (Renderer r in parent.GetComponentsInChildren(false)) - { - if (r == null || !r.enabled || !r.gameObject.activeInHierarchy) - continue; - if (r.bounds.max.y > bestTop) - bestTop = r.bounds.max.y; - } - - if (bestTop > float.NegativeInfinity + 1f) - return bestTop; - } - + // Do not scan parent renderers for max Y — that picks waterfalls / elevated meshes + // and reports a surface tens of meters above the local sea/lake plane. return c.bounds.max.y; } @@ -1939,6 +2190,7 @@ public sealed class PlayerAquaticController : MonoBehaviour private void OnDestroy() { swimJumpAction?.Dispose(); + swimDiveAction?.Dispose(); if (overlayTex != null) Destroy(overlayTex); diff --git a/Assets/Scripts/PlayerFallthroughReposition.cs b/Assets/Scripts/PlayerFallthroughReposition.cs index 47b44f7b1..083933f0f 100644 --- a/Assets/Scripts/PlayerFallthroughReposition.cs +++ b/Assets/Scripts/PlayerFallthroughReposition.cs @@ -57,6 +57,20 @@ public sealed class PlayerFallthroughReposition : MonoBehaviour private float nextAllowedRescueAt; private readonly RaycastHit[] floorHits = new RaycastHit[12]; + /// + /// A debug island jump is not a fall through the map. Remember the destination + /// so a late rescue cannot drag the player back to the mainland. + /// + public void NotifyIntentionalTeleport(Vector3 footing, Quaternion rotation) + { + lastSolidFooting = footing; + lastSolidRotation = rotation; + lastSolidAt = Time.unscaledTime; + hasLastSolid = true; + underMapAirTimer = 0f; + nextAllowedRescueAt = Time.unscaledTime + 8f; + } + public static PlayerFallthroughReposition EnsureOn(GameObject player) { if (player == null) diff --git a/Assets/Scripts/PlayerHunger.cs b/Assets/Scripts/PlayerHunger.cs index 8ec368dd1..adf1e075a 100644 --- a/Assets/Scripts/PlayerHunger.cs +++ b/Assets/Scripts/PlayerHunger.cs @@ -84,6 +84,10 @@ public class PlayerHunger : MonoBehaviour rate *= WerewolfHungerIntegration.GetHungerRateMultiplier(gameObject); rate *= blessingHungerRateMultiplier; + PlayerDiseaseRuntime diseases = GetComponent() + ?? GetComponentInParent(); + if (diseases != null) + rate *= diseases.GetHungerDrainMultiplier(); PlayerPracticeProgress practice = GetComponent() ?? GetComponentInParent(); if (practice != null) diff --git a/Assets/Scripts/PlayerInteract.cs b/Assets/Scripts/PlayerInteract.cs index 73d73863f..da2300aa9 100644 --- a/Assets/Scripts/PlayerInteract.cs +++ b/Assets/Scripts/PlayerInteract.cs @@ -234,7 +234,10 @@ public class PlayerInteract : NetworkBehaviour } ChestHudController openChestHud = cachedChestHud; // F while loot/container UI is open: toggle closed (do not fall through to reopen). - if (openChestHud != null && openChestHud.IsOpen) + // Also dismiss orphan panels (corpse despawned but ChestHud still active). + bool lootPanelUp = openChestHud != null + && (openChestHud.IsOpen || openChestHud.IsPanelActive); + if (lootPanelUp) { if (wantInteract) { @@ -1124,7 +1127,9 @@ public class PlayerInteract : NetworkBehaviour continue; float lateral = (ray.origin + ray.direction * along - body).magnitude; - if (lateral > 1.45f) + // Creature corpses (esp. Nightling / large wildlife) need a wider aim cone than humanoids. + float maxLateral = challenger != null ? 1.45f : 2.65f; + if (lateral > maxLateral) continue; if (lateral >= bestLateral) diff --git a/Assets/Scripts/PlayerNewLifeStatusReset.cs b/Assets/Scripts/PlayerNewLifeStatusReset.cs index eaf0ac1c8..dcba78339 100644 --- a/Assets/Scripts/PlayerNewLifeStatusReset.cs +++ b/Assets/Scripts/PlayerNewLifeStatusReset.cs @@ -48,6 +48,7 @@ public static class PlayerNewLifeStatusReset playerRoot.GetComponentInChildren(true)?.ClearForRespawn(); playerRoot.GetComponentInChildren(true)?.ClearForRespawn(); playerRoot.GetComponentInChildren(true)?.ClearForRespawn(); + playerRoot.GetComponentInChildren(true)?.ClearForRespawn(); playerRoot.GetComponentInChildren(true)?.ClearForRespawn(); // Status HUD rows that should not linger diff --git a/Assets/Scripts/PlayerRespawn.cs b/Assets/Scripts/PlayerRespawn.cs index f1ab19526..e5d8b624a 100644 --- a/Assets/Scripts/PlayerRespawn.cs +++ b/Assets/Scripts/PlayerRespawn.cs @@ -163,7 +163,10 @@ public class PlayerRespawn : MonoBehaviour // Wipe gear/loot; leave only the starter harvest rock (same as first join). GetComponent()?.ApplyDeathRespawnStarterLoadout(); - deathFx?.ForceEndFromHealthRestore(); + // Do not ForceEndFromHealthRestore here: ServerNotifyOwnerWorldEntry already started + // ReleaseWorldEntryWhenGroundReady (black cover until ground). Ending presentation + // stopped that coroutine and left the scrim up — black screen after every death respawn. + // Cover clears when ground is ready; Unlock restores motor/look immediately. UnlockLocalControlsAfterRespawn(); ClearLivingPlayerMomentum(); health?.ClearHurtOverlayIfHealthy(); diff --git a/Assets/Scripts/Practice/PlayerFirstSpawnPlacement.cs b/Assets/Scripts/Practice/PlayerFirstSpawnPlacement.cs index 2f09014a7..2ea8a7a25 100644 --- a/Assets/Scripts/Practice/PlayerFirstSpawnPlacement.cs +++ b/Assets/Scripts/Practice/PlayerFirstSpawnPlacement.cs @@ -8,6 +8,9 @@ using UnityEngine; /// public static class PlayerFirstSpawnPlacement { + /// Console filter for void / preferred first-spawn: [MF_VOID_SPAWN] + public const string VoidSpawnSearchToken = "[MF_VOID_SPAWN]"; + /// Deep under the map — not a playable world location. public static readonly Vector3 LimboPosition = new Vector3(0f, -2800f, 0f); private const float MinDryAboveSeaMeters = 2.5f; @@ -117,9 +120,9 @@ public static class PlayerFirstSpawnPlacement // after a successful pad so the following New cannot reuse it. int next = (ReadAreaCursor(count) + 1) % count; WriteAreaCursor(next); - Debug.Log( - "[FirstSpawn] NewGame prepare -> next area index " + next + "/" + count - + " salt=" + s_wildernessSessionSalt); + Debug.Log(VoidSpawnSearchToken + + " NewGame prepare cleared cache -> next wilderness index " + next + "/" + count + + " (void preferred still runs first)"); } public static int LastResolvedAreaIndex => s_lastResolvedAreaIndex; @@ -241,8 +244,12 @@ public static class PlayerFirstSpawnPlacement /// public static bool EnsureCachedFirstSpawnPose() { - if (TryGetCachedFirstSpawnPose(out _, out _)) + if (TryGetCachedFirstSpawnPose(out Vector3 cached, out _)) + { + Debug.Log(VoidSpawnSearchToken + " cache hit at " + cached.ToString("F1") + + " label=" + s_lastResolvedAreaLabel); return true; + } // Continue/Load may restore the save pose. Host Offline New must never. bool allowSavePose = !s_newGameWildernessRoll @@ -253,25 +260,105 @@ public static class PlayerFirstSpawnPlacement && !IsInLimbo(savePos)) { CacheFirstSpawnPose(savePos, saveRot); - Debug.Log("[FirstSpawn] cache from save restore at " + savePos.ToString("F1")); + Debug.Log(VoidSpawnSearchToken + " cache from save restore at " + savePos.ToString("F1")); + return true; + } + + // Void / preferred pad before wilderness — wilderness used to win the cache and skip the void. + if (TryResolvePreferredSpawnPose(out Vector3 pos, out Quaternion rot)) + { + CacheFirstSpawnPose(pos, rot); + Debug.Log(VoidSpawnSearchToken + " cache preferred at " + pos.ToString("F1") + + " label=" + s_lastResolvedAreaLabel); return true; } - if (TryResolveRandomIslandSpawnPose(out Vector3 pos, out Quaternion rot)) + if (TryResolveRandomIslandSpawnPose(out pos, out rot)) { CacheFirstSpawnPose(pos, rot); + Debug.Log(VoidSpawnSearchToken + " cache wilderness fallback at " + pos.ToString("F1") + + " (preferred failed)"); return true; } if (TryResolveWorldSpawnPoseUncached(out pos, out rot)) { CacheFirstSpawnPose(pos, rot); + Debug.Log(VoidSpawnSearchToken + " cache uncached fallback at " + pos.ToString("F1")); return true; } + Debug.LogWarning(VoidSpawnSearchToken + " EnsureCachedFirstSpawnPose failed — no pad"); return false; } + /// Void island (or other preferred pad) with dry snap. + private static bool TryResolvePreferredSpawnPose(out Vector3 position, out Quaternion rotation) + { + position = default; + rotation = Quaternion.identity; + WorldGenerator world = Object.FindFirstObjectByType(); + if (world == null) + { + Debug.LogWarning(VoidSpawnSearchToken + " preferred: no WorldGenerator"); + return false; + } + + Debug.Log(VoidSpawnSearchToken + + " preferred try prefersVoid=" + world.PrefersVoidIslandSpawn + + " mode=" + OfflineSoloSession.Mode + + " newRoll=" + s_newGameWildernessRoll); + + // Hard path for void: do not accept Galbesh/bandit when void spawn is requested. + if (world.PrefersVoidIslandSpawn) + { + if (!world.TryGetVoidPlayerSpawnPose(out position, out rotation)) + { + Debug.LogWarning(VoidSpawnSearchToken + " preferred: void flag on but TryGetVoid failed"); + return false; + } + } + else if (!world.TryGetPreferredPlayerSpawnPose(out position, out rotation)) + { + Debug.LogWarning(VoidSpawnSearchToken + " preferred: TryGetPreferredPlayerSpawnPose=false"); + return false; + } + + Vector3 beforeSnap = position; + float void01 = BiomeClimateSampler.SampleVoidDomain01( + Mathf.RoundToInt(beforeSnap.x), + Mathf.RoundToInt(beforeSnap.z), + world.TerrainSeed, + world.BiomeSettings); + bool isVoidPad = world.PrefersVoidIslandSpawn || void01 > 0.12f; + + if (!TrySnapSpawnAboveSea(world, ref position, world.SeaSurfaceWorldY, 1.05f)) + { + if (isVoidPad) + { + position = beforeSnap; + Debug.Log(VoidSpawnSearchToken + + " preferred: snap failed but void pad — keeping analytical " + + position.ToString("F1")); + } + else + { + Debug.LogWarning(VoidSpawnSearchToken + + " preferred: snap failed at " + beforeSnap.ToString("F1") + + " void01=" + void01.ToString("F2")); + return false; + } + } + + RememberNewGameSpawnXZ(position); + s_lastResolvedAreaLabel = isVoidPad ? "Void Island" : "Preferred"; + s_newGameWildernessRoll = false; + Debug.Log(VoidSpawnSearchToken + " preferred ok " + position.ToString("F1") + + " label=" + s_lastResolvedAreaLabel + + " void01=" + void01.ToString("F2")); + return true; + } + public static void GetLimboPose(out Vector3 position, out Quaternion rotation) { position = LimboPosition; @@ -298,9 +385,15 @@ public static class PlayerFirstSpawnPlacement { WorldGenerator world = Object.FindFirstObjectByType(); - // First arrival: wilderness only — not village, ruins, bandit camp, or other sights. + if (TryResolvePreferredSpawnPose(out position, out rotation)) + return true; + + // First arrival: wilderness only — not village, ruins, bandit camp, or other sights. if (TryResolveRandomIslandSpawnPose(out position, out rotation)) + { + Debug.Log(VoidSpawnSearchToken + " uncached wilderness at " + position.ToString("F1")); return true; + } if (world != null) { @@ -775,6 +868,12 @@ public static class PlayerFirstSpawnPlacement if (world == null) return false; + // Offshore void plateau is dry even when the analytical height is still settling. + float void01 = BiomeClimateSampler.SampleVoidDomain01( + Mathf.RoundToInt(worldX), Mathf.RoundToInt(worldZ), world.TerrainSeed, world.BiomeSettings); + if (void01 > 0.18f) + return false; + float seaY = world.SeaSurfaceWorldY; if (terrainY < seaY + MinDryAboveSeaMeters) return true; @@ -1007,7 +1106,10 @@ public static class PlayerFirstSpawnPlacement TryResolveWorldSpawnPose(out pos, out rot); float feet = PlayerSpawnGroundUtility.GetCharacterFeetOffset(player.transform); - if (world.TrySampleMapTerrainWorldY(pos.x, pos.z, out float terrainY) + float voidHere = BiomeClimateSampler.SampleVoidDomain01( + Mathf.RoundToInt(pos.x), Mathf.RoundToInt(pos.z), world.TerrainSeed, world.BiomeSettings); + if (voidHere <= 0.18f + && world.TrySampleMapTerrainWorldY(pos.x, pos.z, out float terrainY) && IsWetSpawnColumn(world, pos.x, pos.z, terrainY)) { // Keep the chosen wilderness sector: ring-search locally first. @@ -1027,8 +1129,41 @@ public static class PlayerFirstSpawnPlacement world.EnsureFullQualityNearWorldPosition(pos, chebyshevRadius: 1); world.TryEnsureSpawnColliderReady(pos, radiusChunks: 1); SnapStandingOntoTerrain(world, player.transform, ref pos); + + // Dry-snap / inland walk must not steal a void New Game onto Galbesh. + if (world.PrefersVoidIslandSpawn) + { + BiomeClimateSampler.SetIslandLayout(world.IslandCenterWorldXZ, world.IslandWorldRadius); + float voidCheck = BiomeClimateSampler.SampleVoidDomain01( + Mathf.RoundToInt(pos.x), Mathf.RoundToInt(pos.z), world.TerrainSeed, world.BiomeSettings); + if (voidCheck <= 0.18f + && world.TryGetVoidPlayerSpawnPose(out Vector3 voidRescue, out Quaternion voidRot)) + { + Debug.LogWarning(VoidSpawnSearchToken + + " ServerPlace left void pad at " + pos.ToString("F1") + + " void01=" + voidCheck.ToString("F2") + + " — rescating " + voidRescue.ToString("F1")); + pos = voidRescue; + rot = voidRot; + world.EnsurePhysicsGroundAroundWorldPosition(pos, radiusChunks: 1); + world.EnsureFullQualityNearWorldPosition(pos, chebyshevRadius: 1); + SnapStandingOntoTerrain(world, player.transform, ref pos); + s_lastResolvedAreaLabel = "Void Island"; + CacheFirstSpawnPose(pos, rot); + } + } } + float voidAtPlace = world != null + ? BiomeClimateSampler.SampleVoidDomain01( + Mathf.RoundToInt(pos.x), Mathf.RoundToInt(pos.z), world.TerrainSeed, world.BiomeSettings) + : 0f; + Debug.Log(VoidSpawnSearchToken + + " ServerPlace teleport to " + pos.ToString("F1") + + " void01=" + voidAtPlace.ToString("F2") + + " save=" + usedSaveRestore + + " label=" + s_lastResolvedAreaLabel); + Teleport(player, pos, rot); // Colliders finish a frame late — snap again so we never stay buried in the mesh. @@ -1037,13 +1172,24 @@ public static class PlayerFirstSpawnPlacement Vector3 stand = player.transform.position; SnapStandingOntoTerrain(world, player.transform, ref stand); if ((stand - player.transform.position).sqrMagnitude > 0.0001f) + { + Debug.Log(VoidSpawnSearchToken + " post-snap " + stand.ToString("F1")); Teleport(player, stand, player.transform.rotation); + } } // Stop Bandit/Galbesh/volcano late snaps from fighting this placement. world?.MarkPlayerSpawnHandledExternally(); Vector3 finalPos = player != null ? player.transform.position : pos; + float voidFinal = world != null + ? BiomeClimateSampler.SampleVoidDomain01( + Mathf.RoundToInt(finalPos.x), Mathf.RoundToInt(finalPos.z), world.TerrainSeed, world.BiomeSettings) + : 0f; + Debug.Log(VoidSpawnSearchToken + + " CONTROL final=" + finalPos.ToString("F1") + + " void01=" + voidFinal.ToString("F2") + + (voidFinal > 0.18f ? " OK_VOID" : " NOT_VOID")); AnnounceControlPose(finalPos, usedSaveRestore); } diff --git a/Assets/Scripts/Practice/StrengthTraining.cs b/Assets/Scripts/Practice/StrengthTraining.cs index f8fa13ac5..b88075f66 100644 --- a/Assets/Scripts/Practice/StrengthTraining.cs +++ b/Assets/Scripts/Practice/StrengthTraining.cs @@ -62,7 +62,19 @@ public static class StrengthTraining if (player == null) return PlayerClassDefinitions.BaseSkillLevel; var pp = player.GetComponent() ?? player.GetComponentInParent(); - return pp != null ? pp.GetSkillLevel(PracticeSkillId.Strength) : PlayerClassDefinitions.BaseSkillLevel; + byte level = pp != null ? pp.GetSkillLevel(PracticeSkillId.Strength) : PlayerClassDefinitions.BaseSkillLevel; + + // Wasting diseases lower effective Strength (carry / swim capacity) without changing saved XP. + PlayerDiseaseRuntime diseases = player.GetComponent() + ?? player.GetComponentInParent(); + if (diseases != null) + { + float mul = diseases.GetStrengthMultiplier(); + if (mul < 0.999f) + level = (byte)Mathf.Clamp(Mathf.RoundToInt(level * mul), 1, 255); + } + + return level; } /// Total carried weight (worn + backpack + hotbar) and swim capacity for a player. diff --git a/Assets/Scripts/Rendering/ManafallMemorySpikeDebug.cs b/Assets/Scripts/Rendering/ManafallMemorySpikeDebug.cs index 562d318e4..b7c398c12 100644 --- a/Assets/Scripts/Rendering/ManafallMemorySpikeDebug.cs +++ b/Assets/Scripts/Rendering/ManafallMemorySpikeDebug.cs @@ -332,8 +332,17 @@ public sealed class ManafallMemorySpikeDebug : MonoBehaviour private void Awake() { - // Default on in player builds too — this is the hitch board now. - visible = PlayerPrefs.GetInt(PrefsKey, 1) != 0; + // Default OFF — the hitch board was sticky-on in player/probe builds and filled the screen. + // F7 still toggles it. One-shot clear so older installs that saved PrefsKey=1 start clean. + const string hideOnceKey = "Manafall.HideWalkHitchOverlay_v1"; + if (!Application.isEditor && PlayerPrefs.GetInt(hideOnceKey, 0) == 0) + { + PlayerPrefs.SetInt(PrefsKey, 0); + PlayerPrefs.SetInt(hideOnceKey, 1); + PlayerPrefs.Save(); + } + + visible = PlayerPrefs.GetInt(PrefsKey, 0) != 0; BuildLabel(); ApplyVisible(); } @@ -356,8 +365,8 @@ public sealed class ManafallMemorySpikeDebug : MonoBehaviour PlayerPrefs.SetInt(PrefsKey, visible ? 1 : 0); ApplyVisible(); UnityEngine.Debug.Log(visible - ? "[WalkHitch] Overlay on (F4). Walk — top rows are ms on hitch frames." - : "[WalkHitch] Overlay off (F4)."); + ? "[WalkHitch] Overlay on (F7). Walk — top rows are ms on hitch frames." + : "[WalkHitch] Overlay off (F7)."); } if (!visible || label == null) @@ -456,7 +465,7 @@ public sealed class ManafallMemorySpikeDebug : MonoBehaviour ExpireWindows(Time.unscaledTime); builder.Length = 0; - builder.Append("WALK HITCHES (F4 hide)\n"); + builder.Append("WALK HITCHES (F7 hide)\n"); builder.AppendFormat( "Frame {0:0.0} ms smooth {1:0.0} ms FPS {2:0} hitch≥{3:0} ms\n", frameMs, @@ -541,7 +550,7 @@ public sealed class ManafallMemorySpikeDebug : MonoBehaviour int n = FillSortedIndicesByWindowMs(); if (n == 0) { - builder.Append(" (no probes yet — keep walking with F4 on)\n"); + builder.Append(" (no probes yet — keep walking with F7 on)\n"); } else { @@ -926,7 +935,7 @@ public sealed class ManafallMemorySpikeDebug : MonoBehaviour private static bool WasTogglePressed() { Keyboard kb = Keyboard.current; - return kb != null && kb.f4Key.wasPressedThisFrame; + return kb != null && kb.f7Key.wasPressedThisFrame; } private static string FmtMb(long bytes) diff --git a/Assets/Scripts/Rendering/ManafallUnderwaterRenderFeature.cs b/Assets/Scripts/Rendering/ManafallUnderwaterRenderFeature.cs index 63ebe1a06..6ad6819f4 100644 --- a/Assets/Scripts/Rendering/ManafallUnderwaterRenderFeature.cs +++ b/Assets/Scripts/Rendering/ManafallUnderwaterRenderFeature.cs @@ -5,12 +5,14 @@ using UnityEngine.Rendering.RenderGraphModule.Util; using UnityEngine.Rendering.Universal; /// -/// Underwater murk/distortion masked per pixel (split view at the surface). Pass runs when the camera -/// may see any part of the water column; the shader skips pixels above the surface. +/// Underwater murk/distortion. The pass runs only when the gameplay camera lens is below the +/// local water surface (river sheet or calm/displaced sea). Dry land never gets murk. /// public sealed class ManafallUnderwaterRenderFeature : ScriptableRendererFeature { - private static readonly int SurfaceYId = Shader.PropertyToID("_ManafallUnderwaterSurfaceY"); + public const string DebugSearchToken = "[MF_UWFX]"; + + private static readonly int SurfaceYId = Shader.PropertyToID("_ManafallUnderwaterSurfaceY"); private static readonly int AbsorptionId = Shader.PropertyToID("_ManafallUnderwaterAbsorption"); private static readonly int SwellAmpId = Shader.PropertyToID("_ManafallUnderwaterSwellAmp"); private static readonly int WeatherParamsId = Shader.PropertyToID("_ManafallUnderwaterWeatherParams"); @@ -28,9 +30,13 @@ public sealed class ManafallUnderwaterRenderFeature : ScriptableRendererFeature [SerializeField] private Material underwaterMaterial; [SerializeField] private RenderPassEvent injectionPoint = RenderPassEvent.BeforeRenderingTransparents; [SerializeField] private bool skipPreviewAndReflectionCameras = true; + [Tooltip("Logs camera vs surface/land gate. Search Console for [MF_UWFX].")] + [SerializeField] private bool logUnderwaterFxDebug = true; private UnderwaterPass m_Pass; private SeaWaterSurface m_CachedSea; + private static float s_NextDebugLogAt; + private static bool s_LastApplied; public override void Create() { @@ -65,31 +71,72 @@ public sealed class ManafallUnderwaterRenderFeature : ScriptableRendererFeature return; if (!TryResolveSurfaceY(out float surfaceY)) + { + LogFxDecision(cam, applied: false, surfaceY: 0f, landY: float.NaN, seaY: float.NaN, "noSurface"); + return; + } + + if (cam == null) return; - float aboveSlack = -0.05f; - if (cam != null && !ShouldApplyUnderwaterAtCamera(cam, ref surfaceY, ref aboveSlack)) + if (!ShouldApplyUnderwaterAtCamera(cam, ref surfaceY, out string reason, out float landY, out float seaY)) + { + LogFxDecision(cam, applied: false, surfaceY, landY, seaY, reason); return; + } - // Ocean uses a small straddle on the displaced wave so the waterline can cross the lens. - // The pass stops as soon as the lens is clearly above that wave. Rivers keep the old - // below-surface test so a wet deck does not keep the murk pass running. - if (cam != null && cam.transform.position.y < surfaceY + aboveSlack) + // Lens must be below the surface — no above-water slack (that wet dry beaches). + if (cam.transform.position.y < surfaceY) { m_Pass.renderPassEvent = injectionPoint; m_Pass.Setup(underwaterMaterial, surfaceY); renderer.EnqueuePass(m_Pass); + LogFxDecision(cam, applied: true, surfaceY, landY, seaY, reason); } + else + LogFxDecision(cam, applied: false, surfaceY, landY, seaY, "camAboveSurface"); + } + + private void LogFxDecision(Camera cam, bool applied, float surfaceY, float landY, float seaY, string reason) + { + if (!logUnderwaterFxDebug) + return; + + // Log on state change or every ~0.75s while murk is on (or refused near the waterline). + float now = Time.unscaledTime; + bool stateChanged = applied != s_LastApplied; + if (!stateChanged && now < s_NextDebugLogAt) + return; + s_LastApplied = applied; + s_NextDebugLogAt = now + (applied || stateChanged ? 0.75f : 2.5f); + + float camY = cam != null ? cam.transform.position.y : float.NaN; + Debug.Log( + $"{DebugSearchToken} apply={applied} reason={reason} camY={camY:F3} surfY={surfaceY:F3} " + + $"seaY={(float.IsNaN(seaY) ? "n/a" : seaY.ToString("F3"))} " + + $"landY={(float.IsNaN(landY) ? "n/a" : landY.ToString("F3"))} " + + $"cam={(cam != null ? cam.name : "null")}"); } /// - /// Ocean murk must not run on dry land or near river banks. Only when the lens is - /// actually under a local water sheet (river channel or open sea). + /// Ocean murk only when the lens is under open sea or a river sheet — never on dry land. /// - private static bool ShouldApplyUnderwaterAtCamera(Camera cam, ref float surfaceY, ref float aboveSlack) + private static bool ShouldApplyUnderwaterAtCamera( + Camera cam, + ref float surfaceY, + out string reason, + out float landY, + out float seaY) { + reason = "ok"; + landY = float.NaN; + seaY = float.NaN; + if (cam == null) - return true; + { + reason = "nullCam"; + return false; + } Vector3 p = cam.transform.position; @@ -97,19 +144,33 @@ public sealed class ManafallUnderwaterRenderFeature : ScriptableRendererFeature if (IslandRiverNetwork.IsReady && IslandRiverNetwork.TrySample(p.x, p.z, out IslandRiverNetwork.Sample river) && river.hit - && river.distance <= river.halfWidth * 0.72f - && p.y < river.waterWorldY - 0.05f - && p.y > river.bedWorldY - 0.5f) + && river.distance <= river.halfWidth * 0.72f) { surfaceY = river.waterWorldY; + if (p.y >= river.waterWorldY) + { + reason = "riverCamAboveSheet"; + return false; + } + + if (p.y < river.bedWorldY - 0.5f) + { + reason = "riverCamBelowBed"; + return false; + } + + reason = "riverUnder"; return true; } WorldGenerator world = Object.FindFirstObjectByType(); if (world == null) - return true; + { + reason = "noWorld"; + return false; + } - float seaY = world.SeaSurfaceWorldY; + seaY = world.SeaSurfaceWorldY; Chunk sampler = world.TerrainHeightSampler; if (sampler != null) { @@ -117,28 +178,39 @@ public sealed class ManafallUnderwaterRenderFeature : ScriptableRendererFeature int wz = Mathf.RoundToInt(p.z); float h01 = sampler.SampleAnalyticalHeight01(wx, wz); h01 = IslandRiverNetwork.CarveHeight01(h01, wx, wz, sampler); - float landY = sampler.Height01ToSurfaceWorldY(wx, wz, h01); + landY = sampler.Height01ToSurfaceWorldY(wx, wz, h01); - // Feet on solid ground above the ocean plane → never use sea murk - // (covers dry river rims and coastal desert 30ft from a trench). - if (landY > seaY + 0.25f) + // Map terrain at/above the calm sea plane → dry (beaches just above waterline). + if (landY >= seaY - 0.02f) + { + reason = "dryLandAboveSea"; return false; + } } else if (world.IslandModeEnabled) { - // No sampler: still refuse sea murk when clearly inland of a river sample. if (IslandRiverNetwork.IsReady && IslandRiverNetwork.TrySample(p.x, p.z, out IslandRiverNetwork.Sample near) && near.hit && near.distance < 40f - && near.bedWorldY > seaY + 0.25f) + && near.bedWorldY >= seaY - 0.02f) + { + reason = "inlandRiverBedDry"; return false; + } } - float displaced = seaY + Shader.GetGlobalFloat(WaveOffsetCamId); + float waveOffset = Shader.GetGlobalFloat(WaveOffsetCamId); + float displaced = seaY + waveOffset; surfaceY = displaced; - aboveSlack = 0.2f; - return p.y < displaced + aboveSlack; + if (p.y >= displaced) + { + reason = "oceanCamAboveWave"; + return false; + } + + reason = "oceanUnder"; + return true; } private static bool IsOffscreenUiPreviewCamera(Camera cam) diff --git a/Assets/Scripts/Rendering/ManafallViewCostDebug.cs b/Assets/Scripts/Rendering/ManafallViewCostDebug.cs index 03597f6c2..35ad25ae8 100644 --- a/Assets/Scripts/Rendering/ManafallViewCostDebug.cs +++ b/Assets/Scripts/Rendering/ManafallViewCostDebug.cs @@ -116,6 +116,16 @@ public sealed class ManafallViewCostDebug : MonoBehaviour private void Awake() { + // Player/probe builds: clear sticky F3 prefs left on from agent smoke runs. + // Editor still defaults on; F3 toggles everywhere. + const string hideOnceKey = "Manafall.HideViewCostOverlay_v1"; + if (!Application.isEditor && PlayerPrefs.GetInt(hideOnceKey, 0) == 0) + { + PlayerPrefs.SetInt(PrefsKey, 0); + PlayerPrefs.SetInt(hideOnceKey, 1); + PlayerPrefs.Save(); + } + visible = PlayerPrefs.GetInt(PrefsKey, Application.isEditor ? 1 : 0) != 0; BuildLabel(); ApplyVisible(); diff --git a/Assets/Scripts/SeaWaterSurface.cs b/Assets/Scripts/SeaWaterSurface.cs index ebaebe744..0d5c0194d 100644 --- a/Assets/Scripts/SeaWaterSurface.cs +++ b/Assets/Scripts/SeaWaterSurface.cs @@ -351,8 +351,64 @@ public class SeaWaterSurface : MonoBehaviour public void BindAndRefresh(WorldGenerator world) { worldGenerator = world; + // Menu / video glass-calm zeros waves and planar reflections. World play must restore. + if (forceGlassCalm) + ConfigureGameplayOpenOceanLook(); EnsurePlaneBuilt(); SyncPose(); + ApplyWaterQualityProfile(); + ApplyQualityMaterialOverrides(); + } + + /// Open-ocean play look after menu glass-calm or video pond/storm presets. + public void ConfigureGameplayOpenOceanLook() + { + forceGlassCalm = false; + forcePondLook = false; + _forceStormSeaLook = false; + _allowHostMeshDisplacement = true; + enableRippleField = true; + + ManafallWaterQuality quality = ResolveWaterQuality(); + if (quality != null) + displacementGridSegments = quality.EffectiveSeaMeshSegments; + + EnsurePlaneBuilt(); + ApplyGameplayOpenOceanMaterialOverrides(); + if (_rippleSimulation != null) + _rippleSimulation.enabled = true; + + // Force quality re-push — glass-calm early-outs cached a matte profile. + _lastAppliedQuality = null; + _lastAppliedMaterial = null; + ApplyWaterQualityProfile(); + ApplyQualityMaterialOverrides(); + PushWaterGlobalsPropertyBlock(); + PushWaterWeatherGlobals(); + } + + private void ApplyGameplayOpenOceanMaterialOverrides() + { + Material mat = EnsureRuntimeWaterMaterialCopy(" (OpenOcean)"); + if (mat == null) + return; + + ApplyBuiltInSeaWaterDefaults(mat); + if (mat.HasProperty("_WaveDisplacementStrength")) + mat.SetFloat("_WaveDisplacementStrength", 2.85f); + if (mat.HasProperty("_WaveBumpStrength")) + mat.SetFloat("_WaveBumpStrength", 0.55f); + if (mat.HasProperty("_WaveGerstnerSteepness")) + mat.SetFloat("_WaveGerstnerSteepness", 0.55f); + if (mat.HasProperty("_ShoreWaveMinAmplitude")) + mat.SetFloat("_ShoreWaveMinAmplitude", 0.26f); + if (mat.HasProperty("_WindChopInfluence")) + mat.SetFloat("_WindChopInfluence", 1f); + if (mat.HasProperty("_SkyReflectionStrength")) + mat.SetFloat("_SkyReflectionStrength", 1.72f); + + _baselineShoreWaveMinAmplitude = 0.26f; + _haveBaselineShoreWaveMinAmplitude = true; } /// @@ -1087,7 +1143,9 @@ public class SeaWaterSurface : MonoBehaviour if (quality.enableShoreCaustics && ManafallShoreCausticsState.IsEnabled) { strength = Mathf.Max(strength, 0.01f); - shaderCausticScale = 0.42f; + // Shore fullscreen pass owns the bed web — keep surface caustics nearly off + // so two different patterns don't stack into a muddy ring field. + shaderCausticScale = 0.08f; } else if (!quality.enableShoreCaustics) { @@ -1098,8 +1156,11 @@ public class SeaWaterSurface : MonoBehaviour Shader.SetGlobalFloat(Shader.PropertyToID("_ManafallShoreCausticStrength"), strength); Shader.SetGlobalFloat(Shader.PropertyToID("_ManafallShaderCausticScale"), shaderCausticScale); - Shader.SetGlobalFloat(Shader.PropertyToID("_ManafallShoreCausticBandWidth"), - mat != null && mat.HasProperty("_ShoreFoamWidth") ? mat.GetFloat("_ShoreFoamWidth") * 2.5f : 6f); + // Floor caustics reach the visible bed, not only the foam ring at the waterline. + float floorBand = 18f; + if (mat != null && mat.HasProperty("_ShoreFoamWidth")) + floorBand = Mathf.Max(mat.GetFloat("_ShoreFoamWidth") * 0.85f, 16f); + Shader.SetGlobalFloat(Shader.PropertyToID("_ManafallShoreCausticBandWidth"), floorBand); float sunElev = 0.5f; if (RenderSettings.sun != null) @@ -1732,7 +1793,9 @@ public class SeaWaterSurface : MonoBehaviour causticScale = mat.GetFloat("_CausticScale"); } - Shader.SetGlobalFloat(CausticTimeId, Time.time * Mathf.Max(drift, 1e-3f)); + // Caustics need a livelier clock than surface drift — otherwise filaments look frozen. + float causticTime = Time.time * Mathf.Max(Mathf.Max(drift * 2.4f, 0.85f), 1e-3f); + Shader.SetGlobalFloat(CausticTimeId, causticTime); Shader.SetGlobalFloat(CausticScaleId, causticScale); } @@ -1986,9 +2049,10 @@ public class SeaWaterSurface : MonoBehaviour m.SetVector("_WaveSpeed", new Vector4(0.92f, 0.58f, 0.68f, 0.42f)); m.SetFloat("_WaveFlowSpeed", 0.58f); m.SetFloat("_ReflectHorizonFocus", 2.5f); - m.SetFloat("_ReflectZenithDim", 0.48f); + m.SetFloat("_ReflectZenithDim", 0.22f); m.SetFloat("_ReflectHorizonBloom", 0.48f); - m.SetFloat("_SkyReflectionStrength", 0.46f); + // Menu glass-calm used 0.46 — that left open-ocean looking matte/unreflective. + m.SetFloat("_SkyReflectionStrength", 1.72f); m.SetFloat("_RefractionLivingDistort", 0.03f); m.SetFloat("_RefractionChromaticPx", 2.8f); m.SetFloat("_RefractionWaterBleed", 0.38f); diff --git a/Assets/Scripts/UI/InGamePauseMenu.cs b/Assets/Scripts/UI/InGamePauseMenu.cs index 7dfe08cf2..114261104 100644 --- a/Assets/Scripts/UI/InGamePauseMenu.cs +++ b/Assets/Scripts/UI/InGamePauseMenu.cs @@ -135,7 +135,7 @@ public sealed class InGamePauseMenu : MonoBehaviour return false; ChestHudController chestHud = player.GetComponentInChildren(true); - if (chestHud != null && chestHud.IsOpen) + if (chestHud != null && (chestHud.IsOpen || chestHud.IsPanelActive)) return false; MarketHudController marketHud = player.GetComponentInChildren(true); diff --git a/Assets/Scripts/UI/LoadingScreenPreviewRig.cs b/Assets/Scripts/UI/LoadingScreenPreviewRig.cs index 4f112efb8..80c2bbf05 100644 --- a/Assets/Scripts/UI/LoadingScreenPreviewRig.cs +++ b/Assets/Scripts/UI/LoadingScreenPreviewRig.cs @@ -17,6 +17,8 @@ public sealed class LoadingScreenPreviewRig private const float AutoYawDegreesPerSecond = 11f; private const float MinZoom = 0.58f; private const float MaxZoom = 2.05f; + private const float ZoomStepPerNotch = 0.055f; + private const float ZoomSmoothTime = 0.42f; private const float DragDegreesPerPixel = 0.38f; private static readonly Vector3 StageOrigin = new Vector3(0f, -25000f, 0f); @@ -44,16 +46,13 @@ public sealed class LoadingScreenPreviewRig private Light rimLight; private Light fillLight; private RenderTexture texture; - private Material hazeMaterial; - private Texture2D hazeTexture; - private Mesh hazeMesh; - private Transform[] hazeQuads = Array.Empty(); - private Vector3[] hazeBaseLocal = Array.Empty(); - private GameObject instance; private int layer = -1; private float yaw; private float zoom = 1f; + private float zoomTarget = 1f; + private float zoomVelocity; + private int savedMipLimit = -1; private float framedDistance = 4f; private Vector3 focusLocal = new Vector3(0f, 0.8f, 0f); private float modelRadius = 1f; @@ -91,17 +90,19 @@ public sealed class LoadingScreenPreviewRig UnityEngine.Rendering.RenderPipelineManager.beginCameraRendering += OnBeginCamera; UnityEngine.Rendering.RenderPipelineManager.endCameraRendering += OnEndCamera; CreateLights(); - CreateHaze(); Frame(1f, new Vector3(0f, 0.9f, 0f)); } public void Show(LoadingScreenEntry entry) { Ensure(); + HoldFullTextures(); yaw = entry != null ? entry.facingYaw : 0f; distanceScale = entry != null ? entry.ResolvedDistanceScale : 1.2f; lookHeightBias = entry != null ? entry.lookHeightBias : 0f; zoom = 1f; + zoomTarget = 1f; + zoomVelocity = 0f; dragging = false; hasPointer = false; ClearInstance(); @@ -130,6 +131,8 @@ public sealed class LoadingScreenPreviewRig if (stage == null || camera == null) return; + HoldFullTextures(); + // The local player's spawn (FPSPlayerController, SceneDumpedPlayerCleanup) disables every // other camera, and Host Offline keeps this overlay up long after that. Keep drawing. if (!camera.enabled) @@ -156,9 +159,12 @@ public sealed class LoadingScreenPreviewRig if (Mathf.Abs(scroll) > 0.01f) { float notches = Mathf.Abs(scroll) > 8f ? scroll / 120f : scroll; - zoom = Mathf.Clamp(zoom - notches * 0.12f, MinZoom, MaxZoom); + zoomTarget = Mathf.Clamp(zoomTarget - notches * ZoomStepPerNotch, MinZoom, MaxZoom); } + float dt = Mathf.Max(0f, unscaledDelta); + zoom = Mathf.SmoothDamp(zoom, zoomTarget, ref zoomVelocity, ZoomSmoothTime, Mathf.Infinity, dt); + if (pivot != null) pivot.localRotation = Quaternion.Euler(0f, yaw, 0f); @@ -169,7 +175,6 @@ public sealed class LoadingScreenPreviewRig } ApplyCamera(); - DriftHaze(); } // The island scene's fog and dim ambient leak into this camera once it loads and turn the @@ -226,7 +231,6 @@ public sealed class LoadingScreenPreviewRig keyLight = null; rimLight = null; fillLight = null; - hazeQuads = Array.Empty(); if (texture != null) { @@ -235,23 +239,23 @@ public sealed class LoadingScreenPreviewRig texture = null; } - if (hazeMaterial != null) - { - UnityEngine.Object.Destroy(hazeMaterial); - hazeMaterial = null; - } + RestoreTextures(); + } - if (hazeTexture != null) - { - UnityEngine.Object.Destroy(hazeTexture); - hazeTexture = null; - } + private void HoldFullTextures() + { + if (savedMipLimit < 0) + savedMipLimit = QualitySettings.globalTextureMipmapLimit; + if (QualitySettings.globalTextureMipmapLimit != 0) + QualitySettings.globalTextureMipmapLimit = 0; + } - if (hazeMesh != null) - { - UnityEngine.Object.Destroy(hazeMesh); - hazeMesh = null; - } + private void RestoreTextures() + { + if (savedMipLimit < 0) + return; + QualitySettings.globalTextureMipmapLimit = savedMipLimit; + savedMipLimit = -1; } private void Spawn(LoadingScreenEntry entry) @@ -269,7 +273,8 @@ public sealed class LoadingScreenPreviewRig StripGameplay(spawned); SetLayerRecursively(spawned); PrepareAnimators(spawned); - ApplyCheapLod(spawned); + ApplyPreviewLod(spawned); + CheapenSkinnedMeshes(spawned); Vector3 scale = spawned.transform.localScale; Quaternion localRot = spawned.transform.localRotation; @@ -281,12 +286,12 @@ public sealed class LoadingScreenPreviewRig shell = null; spawned.SetActive(true); - ApplyCheapLod(spawned); + ApplyPreviewLod(spawned); instance = spawned; spawned = null; if (pivot != null) pivot.localRotation = Quaternion.Euler(0f, yaw, 0f); - KickAnimators(instance); + FreezeAnimatorsInPose(instance); reframeFrames = 2; ReframeFromInstance(); } @@ -329,7 +334,6 @@ public sealed class LoadingScreenPreviewRig framedDistance = modelRadius / Mathf.Max(0.15f, half); ApplyCamera(); PlaceLights(); - PlaceHaze(); } private void ApplyCamera() @@ -352,14 +356,16 @@ public sealed class LoadingScreenPreviewRig camGo.transform.SetParent(stage.transform, false); SetLayer(camGo); - int height = Mathf.Clamp(Mathf.RoundToInt(Screen.height * 0.67f), 360, 720); + // Full-screen stretch of a half-res RT is what made the creature look soft. + int screenH = Screen.height > 0 ? Screen.height : 1080; + int height = Mathf.Clamp(screenH, 720, 1440); float aspect = Screen.height > 0 ? (float)Screen.width / Screen.height : 16f / 9f; - int width = Mathf.Clamp(Mathf.RoundToInt(height * aspect), 640, 1280); + int width = Mathf.Clamp(Mathf.RoundToInt(height * aspect), 1280, 2560); - texture = new RenderTexture(width, height, 16, RenderTextureFormat.ARGB32) + texture = new RenderTexture(width, height, 24, RenderTextureFormat.ARGB32) { name = "LoadingPreviewRT", - antiAliasing = 1, + antiAliasing = 4, useMipMap = false, autoGenerateMips = false, wrapMode = TextureWrapMode.Clamp, @@ -374,7 +380,7 @@ public sealed class LoadingScreenPreviewRig camera.nearClipPlane = 0.05f; camera.farClipPlane = 40f; camera.allowHDR = false; - camera.allowMSAA = false; + camera.allowMSAA = true; camera.useOcclusionCulling = false; camera.cullingMask = 1 << layer; camera.targetTexture = texture; @@ -393,14 +399,15 @@ public sealed class LoadingScreenPreviewRig private void CreateLights() { - keyLight = CreateLight("Key", LightType.Spot, new Color(1f, 0.9f, 0.74f, 1f), 18f, true); + // No shadows — soft spot shadows on a skinned mesh are a common load-screen hitch. + keyLight = CreateLight("Key", LightType.Spot, new Color(1f, 0.9f, 0.74f, 1f), 18f); keyLight.spotAngle = 46f; keyLight.innerSpotAngle = 22f; - rimLight = CreateLight("Rim", LightType.Point, new Color(0.48f, 0.64f, 1f, 1f), 12f, false); - fillLight = CreateLight("Fill", LightType.Point, new Color(0.72f, 0.66f, 0.56f, 1f), 3.2f, false); + rimLight = CreateLight("Rim", LightType.Point, new Color(0.48f, 0.64f, 1f, 1f), 12f); + fillLight = CreateLight("Fill", LightType.Point, new Color(0.72f, 0.66f, 0.56f, 1f), 3.2f); } - private Light CreateLight(string name, LightType type, Color color, float intensity, bool shadows) + private Light CreateLight(string name, LightType type, Color color, float intensity) { GameObject go = new GameObject(name); go.transform.SetParent(stage.transform, false); @@ -410,8 +417,7 @@ public sealed class LoadingScreenPreviewRig light.color = color; light.intensity = intensity; light.range = 16f; - light.shadows = shadows ? LightShadows.Soft : LightShadows.None; - light.shadowStrength = 0.8f; + light.shadows = LightShadows.None; light.cullingMask = 1 << layer; light.renderMode = LightRenderMode.ForcePixel; return light; @@ -444,174 +450,6 @@ public sealed class LoadingScreenPreviewRig light.transform.position = focus + localDir.normalized * (reach * 0.48f); } - private void CreateHaze() - { - hazeTexture = BuildSoftDisc(64); - hazeMesh = BuildQuad(); - Shader shader = FindHazeShader(); - if (shader == null || hazeTexture == null) - return; - - hazeMaterial = new Material(shader) { name = "LoadingPreviewHaze" }; - if (hazeMaterial.HasProperty("_BaseMap")) - hazeMaterial.SetTexture("_BaseMap", hazeTexture); - if (hazeMaterial.HasProperty("_MainTex")) - hazeMaterial.SetTexture("_MainTex", hazeTexture); - if (hazeMaterial.HasProperty("_BaseColor")) - hazeMaterial.SetColor("_BaseColor", new Color(0.78f, 0.8f, 0.84f, 0.22f)); - if (hazeMaterial.HasProperty("_Color")) - hazeMaterial.SetColor("_Color", new Color(0.78f, 0.8f, 0.84f, 0.22f)); - if (hazeMaterial.HasProperty("_Surface")) - hazeMaterial.SetFloat("_Surface", 1f); - if (hazeMaterial.HasProperty("_ZWrite")) - hazeMaterial.SetFloat("_ZWrite", 0f); - hazeMaterial.EnableKeyword("_SURFACE_TYPE_TRANSPARENT"); - if (hazeMaterial.HasProperty("_Cull")) - hazeMaterial.SetFloat("_Cull", 0f); - hazeMaterial.renderQueue = 3000; - - hazeQuads = new Transform[5]; - for (int i = 0; i < hazeQuads.Length; i++) - { - GameObject quad = new GameObject("Haze"); - quad.transform.SetParent(stage.transform, false); - SetLayer(quad); - MeshFilter filter = quad.AddComponent(); - filter.sharedMesh = hazeMesh; - MeshRenderer renderer = quad.AddComponent(); - renderer.sharedMaterial = hazeMaterial; - renderer.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; - renderer.receiveShadows = false; - renderer.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off; - renderer.reflectionProbeUsage = UnityEngine.Rendering.ReflectionProbeUsage.Off; - hazeQuads[i] = quad.transform; - } - } - - private void PlaceHaze() - { - if (hazeQuads == null || stage == null) - return; - - Vector3 focus = stage.transform.TransformPoint(focusLocal); - float size = Mathf.Max(1.6f, modelRadius * 2.4f); - Vector3[] offsets = - { - new Vector3(0f, -0.15f, -0.35f), - new Vector3(-0.55f, 0.1f, 0.15f), - new Vector3(0.6f, 0.25f, 0.2f), - new Vector3(0.1f, -0.45f, 0.35f), - new Vector3(-0.2f, 0.45f, -0.1f), - }; - float[] scales = { 1.15f, 0.8f, 0.72f, 0.95f, 0.66f }; - if (hazeBaseLocal.Length != hazeQuads.Length) - hazeBaseLocal = new Vector3[hazeQuads.Length]; - for (int i = 0; i < hazeQuads.Length; i++) - { - Transform quad = hazeQuads[i]; - if (quad == null) - continue; - quad.position = focus + offsets[i] * modelRadius; - quad.localScale = Vector3.one * (size * scales[i]); - hazeBaseLocal[i] = quad.localPosition; - } - } - - private void DriftHaze() - { - if (hazeQuads == null || camera == null || hazeBaseLocal.Length != hazeQuads.Length) - return; - - float t = Time.unscaledTime; - for (int i = 0; i < hazeQuads.Length; i++) - { - Transform quad = hazeQuads[i]; - if (quad == null) - continue; - - Vector3 drift = new Vector3( - Mathf.Sin(t * 0.17f + i) * modelRadius * 0.08f, - Mathf.Cos(t * 0.13f + i * 1.7f) * modelRadius * 0.05f, - 0f); - quad.localPosition = hazeBaseLocal[i] + drift; - Vector3 toCamera = camera.transform.position - quad.position; - if (toCamera.sqrMagnitude > 0.0001f) - quad.rotation = Quaternion.LookRotation(toCamera, Vector3.up); - } - } - - private static Shader FindHazeShader() - { - string[] names = - { - "Sprites/Default", - "Unlit/Transparent", - "Universal Render Pipeline/Particles/Unlit", - "Particles/Standard Unlit", - "Legacy Shaders/Particles/Alpha Blended", - "Universal Render Pipeline/Unlit", - }; - for (int i = 0; i < names.Length; i++) - { - Shader shader = Shader.Find(names[i]); - if (shader != null) - return shader; - } - - return null; - } - - private static Texture2D BuildSoftDisc(int size) - { - Texture2D tex = new Texture2D(size, size, TextureFormat.RGBA32, false) - { - name = "LoadingPreviewHazeDisc", - wrapMode = TextureWrapMode.Clamp, - filterMode = FilterMode.Bilinear, - }; - float half = (size - 1) * 0.5f; - Color[] pixels = new Color[size * size]; - for (int y = 0; y < size; y++) - { - for (int x = 0; x < size; x++) - { - float dx = (x - half) / half; - float dy = (y - half) / half; - float d = Mathf.Sqrt(dx * dx + dy * dy); - float a = Mathf.Clamp01(1f - d); - a = a * a * (0.35f + 0.65f * a); - pixels[y * size + x] = new Color(1f, 1f, 1f, a); - } - } - - tex.SetPixels(pixels); - tex.Apply(false, true); - return tex; - } - - private static Mesh BuildQuad() - { - Mesh mesh = new Mesh { name = "LoadingPreviewHazeQuad" }; - mesh.vertices = new[] - { - new Vector3(-0.5f, -0.5f, 0f), - new Vector3(0.5f, -0.5f, 0f), - new Vector3(-0.5f, 0.5f, 0f), - new Vector3(0.5f, 0.5f, 0f), - }; - mesh.uv = new[] - { - new Vector2(0f, 0f), - new Vector2(1f, 0f), - new Vector2(0f, 1f), - new Vector2(1f, 1f), - }; - mesh.triangles = new[] { 0, 2, 1, 2, 3, 1 }; - mesh.RecalculateNormals(); - mesh.RecalculateBounds(); - return mesh; - } - private void ReadPointer(out bool pressed, out Vector2 pointer, out float scroll) { pressed = false; @@ -665,18 +503,23 @@ public sealed class LoadingScreenPreviewRig UnityEngine.Object.DestroyImmediate(component); } + StripParticles(root); + } + + private static void StripParticles(GameObject root) + { ParticleSystem[] particles = root.GetComponentsInChildren(true); for (int i = 0; i < particles.Length; i++) { ParticleSystem system = particles[i]; if (system == null) continue; - ParticleSystem.CollisionModule collision = system.collision; - collision.enabled = false; - ParticleSystem.TriggerModule trigger = system.trigger; - trigger.enabled = false; - ParticleSystem.LightsModule lights = system.lights; - lights.enabled = false; + system.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); + system.Clear(true); + ParticleSystemRenderer renderer = system.GetComponent(); + if (renderer != null) + UnityEngine.Object.DestroyImmediate(renderer); + UnityEngine.Object.DestroyImmediate(system); } } @@ -719,25 +562,44 @@ public sealed class LoadingScreenPreviewRig continue; animator.applyRootMotion = false; animator.cullingMode = AnimatorCullingMode.AlwaysAnimate; - animator.updateMode = AnimatorUpdateMode.UnscaledTime; + animator.updateMode = AnimatorUpdateMode.Normal; + animator.speed = 0f; animator.enabled = true; } } - private static void KickAnimators(GameObject root) + /// + /// Sample one idle frame, then disable the animator so the pose stays still and + /// does not keep evaluating while the island loads. + /// + private static void FreezeAnimatorsInPose(GameObject root) { + const float poseNormalizedTime = 0.35f; + Animator[] animators = root.GetComponentsInChildren(true); for (int i = 0; i < animators.Length; i++) { Animator animator = animators[i]; if (animator == null || animator.runtimeAnimatorController == null) + { + if (animator != null) + animator.enabled = false; continue; + } ZeroLocomotion(animator); - if (!TryPlayIdle(animator)) - animator.Update(0f); - else + if (!TryPlayIdle(animator, poseNormalizedTime)) + { animator.Update(0f); + AnimatorStateInfo info = animator.GetCurrentAnimatorStateInfo(0); + if (info.fullPathHash != 0) + animator.Play(info.fullPathHash, 0, poseNormalizedTime); + } + + animator.Update(0f); + animator.speed = 0f; + animator.cullingMode = AnimatorCullingMode.CullCompletely; + animator.enabled = false; } Animation[] legacy = root.GetComponentsInChildren(true); @@ -746,6 +608,8 @@ public sealed class LoadingScreenPreviewRig Animation animation = legacy[i]; if (animation == null) continue; + + bool played = false; foreach (AnimationState state in animation) { if (state == null || state.clip == null) @@ -753,8 +617,40 @@ public sealed class LoadingScreenPreviewRig if (state.name.IndexOf("idle", StringComparison.OrdinalIgnoreCase) < 0) continue; animation.Play(state.name); + state.normalizedTime = poseNormalizedTime; + state.speed = 0f; + played = true; break; } + + if (!played && animation.clip != null) + { + animation.Play(animation.clip.name); + AnimationState fallback = animation[animation.clip.name]; + if (fallback != null) + { + fallback.normalizedTime = poseNormalizedTime; + fallback.speed = 0f; + } + } + + animation.Sample(); + animation.Stop(); + animation.enabled = false; + } + } + + private static void CheapenSkinnedMeshes(GameObject root) + { + SkinnedMeshRenderer[] skins = root.GetComponentsInChildren(true); + for (int i = 0; i < skins.Length; i++) + { + SkinnedMeshRenderer skin = skins[i]; + if (skin == null) + continue; + skin.updateWhenOffscreen = false; + skin.skinnedMotionVectors = false; + skin.allowOcclusionWhenDynamic = false; } } @@ -777,14 +673,14 @@ public sealed class LoadingScreenPreviewRig } } - private static bool TryPlayIdle(Animator animator) + private static bool TryPlayIdle(Animator animator, float normalizedTime) { for (int i = 0; i < IdleStateNames.Length; i++) { int hash = Animator.StringToHash(IdleStateNames[i]); if (!animator.HasState(0, hash)) continue; - animator.Play(hash, 0, 0f); + animator.Play(hash, 0, Mathf.Clamp01(normalizedTime)); return true; } @@ -812,7 +708,10 @@ public sealed class LoadingScreenPreviewRig || name.IndexOf("leap", StringComparison.OrdinalIgnoreCase) >= 0; } - private static void ApplyCheapLod(GameObject root) + /// + /// Studio close-up — lock LOD0. Forced mid/low LODs look blocky on the loading stage. + /// + private static void ApplyPreviewLod(GameObject root) { LODGroup[] groups = root.GetComponentsInChildren(true); for (int i = 0; i < groups.Length; i++) @@ -820,11 +719,7 @@ public sealed class LoadingScreenPreviewRig LODGroup group = groups[i]; if (group == null) continue; - int count = group.lodCount; - if (count <= 1) - continue; - int index = count >= 3 ? count - 2 : count - 1; - group.ForceLOD(index); + group.ForceLOD(0); } } diff --git a/Assets/Scripts/Voxels/PlayerVoxelDigging.cs b/Assets/Scripts/Voxels/PlayerVoxelDigging.cs index b05b3f8fa..4a656935a 100644 --- a/Assets/Scripts/Voxels/PlayerVoxelDigging.cs +++ b/Assets/Scripts/Voxels/PlayerVoxelDigging.cs @@ -32,10 +32,12 @@ public sealed class PlayerVoxelDigging : NetworkBehaviour private double nextLocalEditAt; private double windowStartedAt; private int editsInWindow; - private bool legacyPrev; private float hostResetHeldSeconds; private bool hostResetTriggeredThisHold; private double nextProtectedDigFeedbackAt; + private float digChannelProgress01; + private FPSStamina stamina; + private bool digDrainActive; private void Start() { @@ -51,96 +53,156 @@ public sealed class PlayerVoxelDigging : NetworkBehaviour CmdRequestTerrainDigSync(); } + private void OnDisable() + { + ClearDigStaminaDrain(); + digChannelProgress01 = 0f; + } + private void Update() { if (!HasInputAuthority()) + { + ClearDigStaminaDrain(); return; + } - if (!InputStateManager.AllowsPlayerGameplayInput) - return; - - if (!enableVoxelDigging) + if (!InputStateManager.AllowsPlayerGameplayInput || !enableVoxelDigging) + { + CancelDigChannel(); + HandleHostResetHold(); return; + } HandleHostResetHold(); + TickDigChannel(); + } - if (!PressedThisFrame()) + private void TickDigChannel() + { + if (!IsDigHeld()) + { + CancelDigChannel(); return; + } ResolveReferences(); if (!TryResolveShovel(out Item item, out ShovelDefinition shovel)) { - Debug.Log($"{DigDebugTag} local-no-shovel", this); + CancelDigChannel(); return; } - if (NetworkTime.time < nextLocalEditAt) + if (playerCamera == null) { - Debug.Log($"{DigDebugTag} local-cooldown", this); + CancelDigChannel(); return; } - if (playerCamera == null) + InventorySlot localSlot = hotbar != null && hotbarUI != null + ? hotbar.GetSlot(hotbarUI.SelectedSlotIndex) + : null; + if (localSlot != null && localSlot.HasDurability && localSlot.IsBroken) { - Debug.Log($"{DigDebugTag} local-no-camera", this); + CancelDigChannel(); return; } if (!TryGetShovelHit(shovel, out RaycastHit hit)) { - Debug.Log($"{DigDebugTag} local-ray-miss", this); + CancelDigChannel(); + return; + } + + if (stamina == null) + stamina = GetComponent() ?? GetComponentInParent(); + if (stamina != null && stamina.CurrentStamina < 1f) + { + CancelDigChannel(); return; } DigMaterial hitMaterial = DigSurfaceResolver.ResolveMaterial(hit.collider, hit.point, hit.normal); DigSurfaceKind surface = TerrainDigStrata.ToSurfaceKind(hitMaterial); - float effectiveCooldown = shovel.GetEffectiveCooldownSeconds(surface); - Debug.Log($"{DigDebugTag} local-ray-hit point={hit.point} normal={hit.normal} obj={hit.collider?.name} surface={surface} cd={effectiveCooldown:F2}", this); + float digDuration = shovel.GetEffectiveDigDurationSeconds(surface); + ApplyDigStaminaDrain(shovel.staminaDrainPerSecond); - InventorySlot localSlot = hotbar.GetSlot(hotbarUI.SelectedSlotIndex); - if (localSlot != null && localSlot.HasDurability && localSlot.IsBroken) - { - Debug.Log($"{DigDebugTag} local-shovel-broken", this); + digChannelProgress01 += Time.deltaTime / Mathf.Max(0.5f, digDuration); + if (digChannelProgress01 < 1f) return; - } - nextLocalEditAt = NetworkTime.time + effectiveCooldown; + digChannelProgress01 = 0f; + if (NetworkTime.time < nextLocalEditAt) + return; + + nextLocalEditAt = NetworkTime.time + 0.15d; int expectedItemId = item != null ? item.id : -1; int selectedSlot = hotbarUI != null ? hotbarUI.SelectedSlotIndex : -1; + Debug.Log( + $"{DigDebugTag} local-dig-complete point={hit.point} surface={surface} duration={digDuration:F2}", + this); + if (!NetworkClient.active && !NetworkServer.active) { - bool changed = TryApplyOfflineEdit(hit.point, hit.normal, shovel, out TerrainDigResult offlineResult, out string offlineFailReason); - Debug.Log($"{DigDebugTag} offline-apply changed={changed} surface={surface}", this); + bool changed = TryApplyOfflineEdit( + hit.point, hit.normal, shovel, out TerrainDigResult offlineResult, out string offlineFailReason); if (changed) { ApplyShovelDurabilityLoss(selectedSlot, shovel); GrantDigYield(offlineResult); } + PlayResolvedFeedback(shovel, hit.point, hit.normal, changed); MaybeShowProtectedDigFeedback(changed, offlineFailReason); return; } - Debug.Log($"{DigDebugTag} cmd-send item={expectedItemId} slot={selectedSlot}", this); float safeRadius = Mathf.Max(0.1f, shovel.editRadius); float safeVertical = Mathf.Clamp(shovel.editVerticalScale, 0.15f, 1f); - CmdRequestVoxelEdit(hit.point, hit.normal, safeRadius, safeVertical, shovel.addSolid, shovel.materialId, expectedItemId, selectedSlot); + CmdRequestVoxelEdit( + hit.point, hit.normal, safeRadius, safeVertical, shovel.addSolid, shovel.materialId, + expectedItemId, selectedSlot); } - private bool HasInputAuthority() + private void CancelDigChannel() { - if (!NetworkClient.active && !NetworkServer.active) + digChannelProgress01 = 0f; + ClearDigStaminaDrain(); + } + + private void ApplyDigStaminaDrain(float drainPerSecond) + { + if (stamina == null) + stamina = GetComponent() ?? GetComponentInParent(); + if (stamina == null) + return; + + stamina.SetDiggingStaminaDrain(Mathf.Max(0f, drainPerSecond)); + digDrainActive = true; + } + + private void ClearDigStaminaDrain() + { + if (!digDrainActive) + return; + digDrainActive = false; + if (stamina == null) + stamina = GetComponent() ?? GetComponentInParent(); + stamina?.SetDiggingStaminaDrain(0f); + } + + private static bool IsDigHeld() + { + if (Mouse.current != null && Mouse.current.leftButton.isPressed) return true; - return isLocalPlayer || isOwned; + return Input.GetMouseButton(0); } - private bool PressedThisFrame() + private bool HasInputAuthority() { - bool inputSystemPress = Mouse.current != null && Mouse.current.leftButton.wasPressedThisFrame; - bool legacyPressed = Input.GetMouseButton(0); - bool legacyDown = legacyPressed && !legacyPrev; - legacyPrev = legacyPressed; - return inputSystemPress || legacyDown; + if (!NetworkClient.active && !NetworkServer.active) + return true; + return isLocalPlayer || isOwned; } private void HandleHostResetHold() diff --git a/Assets/Scripts/Voxels/ShovelDefinition.cs b/Assets/Scripts/Voxels/ShovelDefinition.cs index c6d90699f..fdc664e43 100644 --- a/Assets/Scripts/Voxels/ShovelDefinition.cs +++ b/Assets/Scripts/Voxels/ShovelDefinition.cs @@ -1,4 +1,5 @@ using UnityEngine; +using UnityEngine.Serialization; [CreateAssetMenu(menuName = "Combat/Shovel Definition", fileName = "ShovelDefinition")] public class ShovelDefinition : ScriptableObject @@ -7,9 +8,13 @@ public class ShovelDefinition : ScriptableObject [Min(0.5f)] public float maxRange = 4f; [Min(0.1f)] public float editRadius = 0.9f; - [Header("Rate limits")] - [Min(0.05f)] public float cooldownSeconds = 0.4f; - [Min(1)] public int maxEditsPerSecond = 8; + [Header("Dig pace")] + [Tooltip("Seconds to hold dig on sand with digPower 1. Harder ground scales this up.")] + [FormerlySerializedAs("cooldownSeconds")] + [Min(0.5f)] public float digDurationSeconds = 2.8f; + [Tooltip("Stamina drained each second while holding dig (slow burn).")] + [Min(0f)] public float staminaDrainPerSecond = 4f; + [Min(1)] public int maxEditsPerSecond = 2; [Header("Voxel effect")] public bool addSolid = false; @@ -18,7 +23,7 @@ public class ShovelDefinition : ScriptableObject [Range(0.15f, 1f)] public float editVerticalScale = 0.35f; [Header("Ground dig speed")] - [Tooltip("Hardness divisor for sand (lowest = fastest digs). Effective cooldown = base * hardness / digPower.")] + [Tooltip("Hardness divisor for sand (lowest = fastest digs). Effective duration = base * hardness / digPower.")] [Min(0.25f)] public float sandHardness = 1f; [Tooltip("Hardness for normal dirt / grassland / forest ground.")] [Min(0.25f)] public float dirtHardness = 2f; @@ -61,12 +66,15 @@ public class ShovelDefinition : ScriptableObject } } - public float GetEffectiveCooldownSeconds(DigSurfaceKind kind) + public float GetEffectiveDigDurationSeconds(DigSurfaceKind kind) { float hardness = GetHardnessForSurface(kind); float power = Mathf.Max(1, digPower); - return Mathf.Max(0.05f, cooldownSeconds * (hardness / power)); + return Mathf.Max(0.5f, digDurationSeconds * (hardness / power)); } + + /// Legacy name kept for call sites / tests. + public float GetEffectiveCooldownSeconds(DigSurfaceKind kind) => GetEffectiveDigDurationSeconds(kind); } public enum DigSurfaceKind diff --git a/Assets/Scripts/Voxels/TerrainDig/TerrainDigCore.cs b/Assets/Scripts/Voxels/TerrainDig/TerrainDigCore.cs index e1dcf43a2..c5b1b48d9 100644 --- a/Assets/Scripts/Voxels/TerrainDig/TerrainDigCore.cs +++ b/Assets/Scripts/Voxels/TerrainDig/TerrainDigCore.cs @@ -149,6 +149,7 @@ public static class TerrainDigStrata return DigMaterial.Snow; case BiomeType.Mountain: case BiomeType.Volcano: + case BiomeType.Void: return DigMaterial.Stone; default: return DigMaterial.Dirt; diff --git a/Assets/Scripts/Water/AquaticPhysicsSimulator.cs b/Assets/Scripts/Water/AquaticPhysicsSimulator.cs index f517677ba..0070b0b0a 100644 --- a/Assets/Scripts/Water/AquaticPhysicsSimulator.cs +++ b/Assets/Scripts/Water/AquaticPhysicsSimulator.cs @@ -163,6 +163,31 @@ public sealed class AquaticPhysicsSimulator : NetworkBehaviour bool hasWaterVel, Vector3 waterVel) { + bool dive = aquaticController != null && aquaticController.SwimDescendHeld; + bool ascend = aquaticController != null && aquaticController.SwimAscendHeld; + bool stroking = aquaticController != null && aquaticController.SwimPlanarHeld; + if (sample.inWaterForMotor && dive) + { + flags |= AquaticStateFlags.ActiveVertical; + float diveSpeed = aquaticController.SwimDiveSpeed; + float diveSpring = profile != null ? profile.buoyancySpring : 42f; + verticalVelocity = Mathf.MoveTowards(verticalVelocity, -diveSpeed, diveSpring * dt); + float diveStep = verticalVelocity * dt; + float maxStep = profile != null ? profile.maxVerticalStepPerTick : 0.35f; + return Mathf.Clamp(diveStep, -maxStep, maxStep); + } + + if (sample.inWaterForMotor && !ascend && !stroking && !sample.groundedOnSeafloor) + { + flags |= AquaticStateFlags.ActiveVertical; + float sink = aquaticController != null ? aquaticController.IdleSinkSpeed : 1.2f; + float sinkSpring = profile != null ? profile.buoyancySpring * 0.35f : 14f; + verticalVelocity = Mathf.MoveTowards(verticalVelocity, -sink, sinkSpring * dt); + float sinkStep = verticalVelocity * dt; + float maxStep = profile != null ? profile.maxVerticalStepPerTick : 0.35f; + return Mathf.Clamp(sinkStep, -maxStep, maxStep); + } + if (!ShouldDriveVertical(sample)) { verticalVelocity = Mathf.MoveTowards(verticalVelocity, 0f, profile.buoyancyDamping * dt); diff --git a/Assets/Scripts/Water/BoatHydrodynamicDrag.cs b/Assets/Scripts/Water/BoatHydrodynamicDrag.cs index 3ded39903..533d025dc 100644 --- a/Assets/Scripts/Water/BoatHydrodynamicDrag.cs +++ b/Assets/Scripts/Water/BoatHydrodynamicDrag.cs @@ -1,8 +1,10 @@ +using Mirror; using UnityEngine; /// -/// Submersion-weighted planar drag using the same sample points as . -/// Controllers apply returned acceleration before assigning rigidbody planar velocity. +/// Hull in the water: the bow cuts, the beam holds, a start has to push water aside, +/// and the nose rises on its own bow wave. Controllers apply the planar acceleration +/// before they write rigidbody velocity. Attitude (yaw, bow lift) is applied here. /// [DisallowMultipleComponent] [RequireComponent(typeof(Rigidbody))] @@ -22,6 +24,8 @@ public sealed class BoatHydrodynamicDrag : MonoBehaviour [SerializeField] private bool requireOpenOcean = true; private Rigidbody _rb; + private Vector3 _prevPlanarVelocity; + private bool _havePrevPlanarVelocity; public float SurfaceHeightOffset => surfaceHeightOffset; @@ -76,8 +80,20 @@ public sealed class BoatHydrodynamicDrag : MonoBehaviour bool sampleOrbitals = includeWaveOrbitalVelocity && !ManafallWaterSurfaceHeight.UseCheapFlatPhysicsSampling; float orbitalScale = physicsQuality != null ? physicsQuality.dragWaveOrbitalScale : 1f; + float longScale = physicsQuality != null ? physicsQuality.dragLongitudinalScale : 0.68f; + float latScale = physicsQuality != null ? physicsQuality.dragLateralScale : 2.7f; + float addedMass = physicsQuality != null ? physicsQuality.dragAddedMassScale : 0.3f; + + Vector3 bow = transform.forward; + bow.y = 0f; + if (bow.sqrMagnitude < 1e-6f) + bow = Vector3.forward; + bow.Normalize(); + Vector3 beam = new Vector3(bow.z, 0f, -bow.x); + Vector3 totalDragAccel = Vector3.zero; int submergedSamples = 0; + float submersionSum = 0f; for (int i = 0; i < localSamplePoints.Length; i++) { @@ -107,23 +123,46 @@ public sealed class BoatHydrodynamicDrag : MonoBehaviour relativeVel -= waterVel * orbitalScale; Vector3 relativePlanar = new Vector3(relativeVel.x, 0f, relativeVel.z); - float speed = relativePlanar.magnitude; - if (speed <= 1e-4f) + float along = Vector3.Dot(relativePlanar, bow); + float across = Vector3.Dot(relativePlanar, beam); + if (along * along + across * across <= 1e-8f) continue; - Vector3 dragDir = relativePlanar / speed; - float dragMag = linearDrag * speed + quadDrag * speed * speed; - dragMag *= submersion; + // Skin drag along the bow, bluff drag across the beam. + float alongMag = linearDrag * longScale * along + + quadDrag * longScale * along * Mathf.Abs(along); + float acrossMag = linearDrag * latScale * across + + quadDrag * latScale * across * Mathf.Abs(across); - Vector3 sampleAccel = -dragDir * dragMag; + Vector3 sampleAccel = (-bow * alongMag - beam * acrossMag) * submersion; totalDragAccel += sampleAccel; + submersionSum += submersion; submergedSamples++; } if (submergedSamples == 0) + { + _prevPlanarVelocity = hullPlanarVelocityWorld; + _havePrevPlanarVelocity = true; return Vector3.zero; + } totalDragAccel /= submergedSamples; + float meanSub = submersionSum / submergedSamples; + + float dt = Mathf.Max(Time.fixedDeltaTime, 1e-4f); + if (_havePrevPlanarVelocity && addedMass > 1e-4f) + { + Vector3 accel = (hullPlanarVelocityWorld - _prevPlanarVelocity) / dt; + Vector3 held = -accel * addedMass * meanSub; + float heldCap = maxDrag * 0.4f; + if (held.sqrMagnitude > heldCap * heldCap) + held = held.normalized * heldCap; + totalDragAccel += held; + } + + _prevPlanarVelocity = hullPlanarVelocityWorld; + _havePrevPlanarVelocity = true; if (totalDragAccel.sqrMagnitude > maxDrag * maxDrag) totalDragAccel = totalDragAccel.normalized * maxDrag; @@ -131,6 +170,64 @@ public sealed class BoatHydrodynamicDrag : MonoBehaviour return totalDragAccel; } + private void FixedUpdate() + { + if (!HasPhysicsAuthority()) + return; + + float submerged = ComputeMeanSubmergedFraction(); + if (submerged < 0.05f) + return; + + ManafallWaterPhysicsQuality physicsQuality = ManafallWaterPhysicsResolver.Resolve(); + float yawDamp = physicsQuality != null ? physicsQuality.hullYawDamping : 2.1f; + float weathercock = physicsQuality != null ? physicsQuality.hullWeathercock : 1.15f; + float bowPlaning = physicsQuality != null ? physicsQuality.hullBowPlaning : 0.9f; + + Vector3 vel = _rb.linearVelocity; + Vector3 planar = new Vector3(vel.x, 0f, vel.z); + float speed = planar.magnitude; + + float yawRate = _rb.angularVelocity.y; + if (Mathf.Abs(yawRate) > 1e-4f && yawDamp > 0f) + _rb.AddTorque(Vector3.down * yawRate * yawDamp * submerged, ForceMode.Acceleration); + + if (speed > 0.45f && weathercock > 0f) + { + Vector3 bow = transform.forward; + bow.y = 0f; + if (bow.sqrMagnitude > 1e-6f) + { + bow.Normalize(); + float slipDeg = Mathf.Clamp(Vector3.SignedAngle(bow, planar / speed, Vector3.up), -32f, 32f); + float gain = Mathf.Clamp01(speed / 4.5f) * submerged; + float yawAccelDeg = -slipDeg * weathercock * gain; + yawAccelDeg = Mathf.Clamp(yawAccelDeg, -28f, 28f); + _rb.AddTorque(Vector3.up * (yawAccelDeg * Mathf.Deg2Rad), ForceMode.Acceleration); + } + } + + if (bowPlaning > 0f && speed > 0.6f) + { + Vector3 pitchAxis = transform.right; + float pitchRate = Vector3.Dot(_rb.angularVelocity, pitchAxis); + _rb.AddTorque(-pitchAxis * pitchRate * 1.6f * submerged, ForceMode.Acceleration); + float lift = Mathf.Min(bowPlaning * speed * speed * 0.012f * submerged, 0.38f); + _rb.AddTorque(pitchAxis * lift, ForceMode.Acceleration); + } + } + + private bool HasPhysicsAuthority() + { + if (_rb == null || _rb.isKinematic) + return false; + + if (NetworkServer.active) + return true; + + return !NetworkClient.active; + } + public float ComputeMeanSubmergedFraction() { if (localSamplePoints == null || localSamplePoints.Length == 0) diff --git a/Assets/Scripts/Water/ManafallOceanWaveGlobals.cs b/Assets/Scripts/Water/ManafallOceanWaveGlobals.cs index 5ce199da5..80c003871 100644 --- a/Assets/Scripts/Water/ManafallOceanWaveGlobals.cs +++ b/Assets/Scripts/Water/ManafallOceanWaveGlobals.cs @@ -70,9 +70,12 @@ public static class ManafallOceanWaveGlobals float windChopScale = windChopInfluence * Mathf.Lerp(0.5f, 1.05f, Mathf.Clamp01(windMag / 4.6f)); windChopScale *= Mathf.Lerp(0.82f, 1.06f, swell); - float flowSpeed = spectrum.flowSpeed * Mathf.Lerp(0.92f, 1.06f, swell); + // Phase speed stays on the authored value. Scaling it by live swell + // (which follows wind gusts) and then by _Time.y makes crests lurch + // forward for a few seconds and then back when the gust dies. + float flowSpeed = spectrum.flowSpeed; float displacement = spectrum.displacementStrength * Mathf.Lerp(0.96f, 1.1f, swell); - float chopPhaseSpeedMul = Mathf.Lerp(1f, 0.76f, swell); + float chopPhaseSpeedMul = 1f; Shader.SetGlobalVector(WaveSpectrumParamsId, new Vector4(displacement, flowSpeed, spectrum.gerstnerSteepness, MaxWaves)); Shader.SetGlobalVector(WaveWindParamsId, diff --git a/Assets/Scripts/Water/ManafallWaterPhysicsQuality.cs b/Assets/Scripts/Water/ManafallWaterPhysicsQuality.cs index 973f941e9..98ed41cb1 100644 --- a/Assets/Scripts/Water/ManafallWaterPhysicsQuality.cs +++ b/Assets/Scripts/Water/ManafallWaterPhysicsQuality.cs @@ -19,6 +19,18 @@ public sealed class ManafallWaterPhysicsQuality : ScriptableObject [Min(0f)] public float dragPlanarQuadratic = 0.42f; [Range(0f, 1.5f)] public float dragWaveOrbitalScale = 1f; [Min(0f)] public float dragMaxPlanarAcceleration = 26f; + [Tooltip("Drag along the bow. Below 1 lets the hull cut forward.")] + [Range(0.2f, 1.6f)] public float dragLongitudinalScale = 0.68f; + [Tooltip("Drag across the beam. Above 1 stops the hull skating sideways.")] + [Range(0.5f, 5f)] public float dragLateralScale = 2.7f; + [Tooltip("Fraction of this frame's acceleration the water holds back. Starts feel heavy; top speed is unchanged.")] + [Range(0f, 1.2f)] public float dragAddedMassScale = 0.3f; + [Tooltip("How fast leftover spin dies in the water (1/s).")] + [Min(0f)] public float hullYawDamping = 2.1f; + [Tooltip("How hard the bow swings back toward the direction of travel (deg/s² per degree of slip).")] + [Min(0f)] public float hullWeathercock = 1.15f; + [Tooltip("Bow-up acceleration at speed, from the bow wave (rad/s² scale).")] + [Min(0f)] public float hullBowPlaning = 0.9f; [Header("Player aquatic spring-damper")] [Min(0f)] public float aquaticBuoyancySpring = 46f; diff --git a/Assets/Scripts/WaterRippleEmitter.cs b/Assets/Scripts/WaterRippleEmitter.cs index 87011c8f7..b9ecbbe1c 100644 --- a/Assets/Scripts/WaterRippleEmitter.cs +++ b/Assets/Scripts/WaterRippleEmitter.cs @@ -524,6 +524,9 @@ public sealed class WaterRippleEmitter : MonoBehaviour { + if (PlanarSpeed < minSpeedForWake) + return 0f; + float speedMul = EvaluateSpeedStrengthMultiplier() * SubmersionStrengthScale; if (stampIndex <= 0) diff --git a/Assets/Scripts/WeatherSkyDriver.cs b/Assets/Scripts/WeatherSkyDriver.cs index e2d50f959..13da2d5b6 100644 --- a/Assets/Scripts/WeatherSkyDriver.cs +++ b/Assets/Scripts/WeatherSkyDriver.cs @@ -56,7 +56,8 @@ public class WeatherSkyDriver : MonoBehaviour [SerializeField] [Min(0.05f)] private float cloudScrollSmoothing = 8.5f; [SerializeField] private Color clearZenith = new Color(0.26f, 0.52f, 0.94f, 1f); - [SerializeField] private Color clearHorizon = new Color(0.68f, 0.82f, 0.98f, 1f); + // Was 0.68/0.82/0.98 — that chalked a white band across the top of the sky plate. + [SerializeField] private Color clearHorizon = new Color(0.52f, 0.68f, 0.88f, 1f); [SerializeField] private Color cloudyZenith = new Color(0.34f, 0.55f, 0.86f, 1f); [SerializeField] private Color cloudyHorizon = new Color(0.62f, 0.74f, 0.92f, 1f); diff --git a/Assets/Scripts/WheeledCage/CreatureAttitude.cs b/Assets/Scripts/WheeledCage/CreatureAttitude.cs index 1fa33bb75..a7acfeaf8 100644 --- a/Assets/Scripts/WheeledCage/CreatureAttitude.cs +++ b/Assets/Scripts/WheeledCage/CreatureAttitude.cs @@ -45,6 +45,7 @@ public static class CreatureAttitudeCatalog { "NerkrovController", new Range(70, 100) }, { "HellhoundController", new Range(75, 100) }, { "TrollController", new Range(75, 100) }, + { "DrucladController", new Range(90, 100) }, { "ZombieEnemyController", new Range(85, 100) }, { "BanditController", new Range(50, 90) }, { "CivilianController", new Range(5, 70) }, diff --git a/Assets/Scripts/World/BreakableLootVase.cs b/Assets/Scripts/World/BreakableLootVase.cs index 376e0c8b9..140934c1b 100644 --- a/Assets/Scripts/World/BreakableLootVase.cs +++ b/Assets/Scripts/World/BreakableLootVase.cs @@ -524,19 +524,33 @@ public sealed class BreakableLootVase : NetworkBehaviour bool wantUncommon = Random.value < Mathf.Clamp01(uncommonChance); LootCandidate picked; if (wantUncommon && uncommons.Count > 0) - picked = uncommons[Random.Range(0, uncommons.Count)]; + picked = PickTierWeightedCandidate(uncommons); else if (commons.Count > 0) - picked = commons[Random.Range(0, commons.Count)]; + picked = PickTierWeightedCandidate(commons); else if (uncommons.Count > 0) - picked = uncommons[Random.Range(0, uncommons.Count)]; + picked = PickTierWeightedCandidate(uncommons); else return false; + if (picked.Item == null) + return false; + item = picked.Item; amount = picked.RollDirectStack ? RollDirectAmount() : Mathf.Max(1, picked.Amount); return item != null; } + private static LootCandidate PickTierWeightedCandidate(List pool) + { + if (pool == null || pool.Count == 0) + return default; + + int index = InventoryFramework.TierLootRoll.PickIndexByItemTier(pool, c => c.Item); + if (index < 0) + return pool[Random.Range(0, pool.Count)]; + return pool[index]; + } + private void BuildPools(out List commons, out List uncommons) { commons = new List(); diff --git a/Assets/Scripts/World/Foliage/FoliageChunkVisual.cs b/Assets/Scripts/World/Foliage/FoliageChunkVisual.cs index 652f581f6..e09517447 100644 --- a/Assets/Scripts/World/Foliage/FoliageChunkVisual.cs +++ b/Assets/Scripts/World/Foliage/FoliageChunkVisual.cs @@ -73,6 +73,7 @@ public sealed partial class FoliageChunkVisual : MonoBehaviour case 1: SpawnBush(slot, settings); break; case 2: SpawnRock(slot, settings); break; case 3: SpawnCliff(slot, settings, useExactMeshCollider: true); break; + case 5: SpawnFloatingIsland(slot, settings); break; default: SpawnGrassGameObject(slot); break; } @@ -295,6 +296,11 @@ public sealed partial class FoliageChunkVisual : MonoBehaviour // Cliffs stay immediate (silhouette + collider); timed for the slow-spawn log. TimedSpawn(slot, settings, 3); break; + case FoliageSlotType.FloatingIsland: + if (removed) + break; + TimedSpawn(slot, settings, 5); + break; case FoliageSlotType.Grass: if (removed || !includeNearFoliage) break; @@ -1354,6 +1360,45 @@ public sealed partial class FoliageChunkVisual : MonoBehaviour SpawnRock(slot, settings); } + private void SpawnFloatingIsland(FoliageSlot slot, FoliageSettings settings) + { + GameObject prefab = settings != null ? settings.voidFloatingIslandPrefab : null; + if (prefab == null) + prefab = VoidFloatingIslandWorldSpawner.ResolvePrefabFallback(); + + GameObject instance = CreateVisualInstance( + prefab, + slot, + "VoidFloatingIsland", + CreateRockPlaceholder, + instanceRoot, + keepPhysicsColliders: false); + if (instance == null) + return; + + OptimizeDecorativeRenderers(instance, castShadows: true, receiveShadows: true, useLightProbes: false); + ApplyVoidFloatingIslandMaterial(instance, settings); + EnsureBlockingCollider(instance, useExactMesh: true); + decorativeInstances.Add(instance); + decorativePrefabs.Add(prefab); + } + + private static void ApplyVoidFloatingIslandMaterial(GameObject instance, FoliageSettings settings) + { + if (instance == null || settings == null || settings.voidFloatingIslandMaterial == null) + return; + + Material mat = settings.voidFloatingIslandMaterial; + Renderer[] renderers = instance.GetComponentsInChildren(true); + for (int i = 0; i < renderers.Length; i++) + { + Renderer rend = renderers[i]; + if (rend == null) + continue; + rend.sharedMaterial = mat; + } + } + private void SpawnCliff(FoliageSlot slot, FoliageSettings settings, bool useExactMeshCollider = false) { GameObject prefab = settings != null ? settings.desertCliffVisualPrefab : null; diff --git a/Assets/Scripts/World/Foliage/FoliagePopulator.cs b/Assets/Scripts/World/Foliage/FoliagePopulator.cs index 0103573d0..93b3acb8c 100644 --- a/Assets/Scripts/World/Foliage/FoliagePopulator.cs +++ b/Assets/Scripts/World/Foliage/FoliagePopulator.cs @@ -406,6 +406,38 @@ public static partial class FoliagePopulator } long p17cliff = System.Diagnostics.Stopwatch.GetTimestamp(); + + int floatingIslandTarget = treesOnly ? 0 : settings.GetTargetFloatingIslandCount(dominant); + // Offshore void disc may still majority-classify as grassland while void01 is high. + if (floatingIslandTarget <= 0 && !treesOnly && settings.voidFloatingIslands > 0) + { + int cx = Mathf.RoundToInt((chunkCoord.x + 0.5f) * chunkSize); + int cz = Mathf.RoundToInt((chunkCoord.y + 0.5f) * chunkSize); + float voidHost = BiomeClimateSampler.SampleVoidDomain01(cx, cz, seed, world != null ? world.BiomeSettings : null); + if (voidHost > 0.18f) + floatingIslandTarget = settings.voidFloatingIslands; + } + + if (floatingIslandTarget > 0 + && (settings.voidFloatingIslandPrefab != null || VoidFloatingIslandWorldSpawner.ResolvePrefabFallback() != null) + && ChunkIsVoidFloatingIslandHost(seed, chunkCoord, chunkSize, settings)) + { + nextIndex = PopulateVoidFloatingIslands( + world, + sampler, + chunkCoord, + chunkSize, + seed, + settings, + padding, + usable, + floatingIslandTarget, + treePositions, + rockPositions, + results, + nextIndex); + } + // Shared undergrowth patch centers: dense pockets with open gaps between them. PickFoliagePatchCenters( world, @@ -1003,6 +1035,151 @@ public static partial class FoliagePopulator return nextIndex; } + private static bool ChunkIsVoidFloatingIslandHost( + int seed, + Vector2Int chunkCoord, + int chunkSize, + FoliageSettings settings) + { + float cellSize = Mathf.Max(120f, settings.voidFloatingIslandCellSizeMeters); + float chance = Mathf.Clamp01(settings.voidFloatingIslandChunkSpawnChance); + + float chunkCenterX = (chunkCoord.x + 0.5f) * chunkSize; + float chunkCenterZ = (chunkCoord.y + 0.5f) * chunkSize; + int cellX = Mathf.FloorToInt(chunkCenterX / cellSize); + int cellZ = Mathf.FloorToInt(chunkCenterZ / cellSize); + + var cellRng = new System.Random(HashChunkSlot(seed, cellX, cellZ, 0, 7741)); + if (cellRng.NextDouble() >= chance) + return false; + + int chunksPerCell = Mathf.Max(1, Mathf.RoundToInt(cellSize / Mathf.Max(1, chunkSize))); + int hostLocalX = cellRng.Next(0, chunksPerCell); + int hostLocalZ = cellRng.Next(0, chunksPerCell); + int cellOriginChunkX = Mathf.FloorToInt((cellX * cellSize) / chunkSize); + int cellOriginChunkZ = Mathf.FloorToInt((cellZ * cellSize) / chunkSize); + int hostChunkX = cellOriginChunkX + hostLocalX; + int hostChunkZ = cellOriginChunkZ + hostLocalZ; + return chunkCoord.x == hostChunkX && chunkCoord.y == hostChunkZ; + } + + private static ushort PopulateVoidFloatingIslands( + WorldGenerator world, + Chunk sampler, + Vector2Int chunkCoord, + int chunkSize, + int seed, + FoliageSettings settings, + float padding, + float usable, + int islandTarget, + List treePositions, + List rockPositions, + List results, + ushort nextIndex) + { + int target = Mathf.Clamp(islandTarget, 1, 2); + for (int i = 0; i < target; i++) + { + if (!TrySampleVoidFloatingIsland( + world, + sampler, + chunkCoord, + chunkSize, + seed, + i, + settings, + padding, + usable, + treePositions, + rockPositions, + results, + out FoliageSlot slot)) + continue; + + slot.Index = nextIndex++; + results.Add(slot); + } + + return nextIndex; + } + + private static bool TrySampleVoidFloatingIsland( + WorldGenerator world, + Chunk sampler, + Vector2Int chunkCoord, + int chunkSize, + int seed, + int candidateIndex, + FoliageSettings settings, + float padding, + float usable, + List treePositions, + List rockPositions, + List results, + out FoliageSlot slot) + { + slot = default; + var rng = new System.Random(HashChunkSlot(seed, chunkCoord.x, chunkCoord.y, candidateIndex, 7753)); + float scaleMin = Mathf.Min(settings.voidFloatingIslandScaleMin, settings.voidFloatingIslandScaleMax); + float scaleMax = Mathf.Max(settings.voidFloatingIslandScaleMin, settings.voidFloatingIslandScaleMax); + float hover = Mathf.Max(4f, settings.voidFloatingIslandHoverMeters); + + for (int attempt = 0; attempt < 16; attempt++) + { + float localX = padding + (float)rng.NextDouble() * usable; + float localZ = padding + (float)rng.NextDouble() * usable; + int wx = Mathf.RoundToInt(chunkCoord.x * chunkSize + localX); + int wz = Mathf.RoundToInt(chunkCoord.y * chunkSize + localZ); + if (WorldTerrainHeightModifiers.SuppressesFoliageAt(wx, wz)) + continue; + + float groundY = SampleSurfaceWorldY(world, sampler, wx, wz); + float void01 = BiomeClimateSampler.SampleVoidDomain01( + wx, wz, seed, world != null ? world.BiomeSettings : null); + WorldGenerator.ClimateSample climate = SampleClimateMemo(world, new Vector3(wx, groundY, wz)); + bool onVoid = void01 > 0.18f || (climate.valid && climate.biome == BiomeType.Void); + if (!onVoid) + continue; + + float uniform = RandomRange(rng, scaleMin, scaleMax); + Vector3 scale = Vector3.one * uniform; + Vector3 pos = new Vector3(wx, groundY + hover, wz); + if (!PassesSeparation(pos, treePositions, 18f)) + continue; + if (!PassesSeparation(pos, rockPositions, 12f)) + continue; + + bool clearOfIslands = true; + for (int i = 0; i < results.Count; i++) + { + if (results[i].Type != FoliageSlotType.FloatingIsland) + continue; + if ((results[i].WorldPosition - pos).sqrMagnitude < 40f * 40f) + { + clearOfIslands = false; + break; + } + } + + if (!clearOfIslands) + continue; + + slot = new FoliageSlot + { + Type = FoliageSlotType.FloatingIsland, + WorldPosition = pos, + Rotation = Quaternion.Euler(0f, RandomRange(rng, 0f, 360f), 0f), + Scale = scale, + Biome = BiomeType.Void, + PrefabVariant = 0, + }; + return true; + } + + return false; + } + private static bool TrySampleDesertCliff( WorldGenerator world, Chunk sampler, @@ -2352,7 +2529,7 @@ public static partial class FoliagePopulator if (settings.mushroomTreeVisualPrefab == null) return nextIndex; if (dominant is BiomeType.Desert or BiomeType.Snow or BiomeType.Tundra - or BiomeType.Ocean or BiomeType.Beach or BiomeType.Volcano) + or BiomeType.Ocean or BiomeType.Beach or BiomeType.Volcano or BiomeType.Void) return nextIndex; if (dominant == BiomeType.Swamp) @@ -2652,7 +2829,7 @@ public static partial class FoliagePopulator if (!climate.valid) return false; if (climate.biome is BiomeType.Desert or BiomeType.Snow or BiomeType.Tundra - or BiomeType.Volcano or BiomeType.Ocean or BiomeType.Beach) + or BiomeType.Volcano or BiomeType.Void or BiomeType.Ocean or BiomeType.Beach) return false; // Swamp is always mushroom country — skip the mid-temp band used by forest stands. if (climate.biome == BiomeType.Swamp) @@ -2985,6 +3162,7 @@ public static partial class FoliagePopulator int ocean = 0; int swamp = 0; int volcano = 0; + int voidCount = 0; for (int z = 0; z < 3; z++) { @@ -3017,6 +3195,11 @@ public static partial class FoliagePopulator if (volcano01 > 0.12f && height01 >= world.TerrainSeaLevel01 + world.TerrainBeachBand01 * 0.35f) climate.biome = BiomeType.Volcano; + float void01 = BiomeClimateSampler.SampleVoidDomain01( + wx, wz, world.TerrainSeed, world.BiomeSettings); + if (void01 > 0.08f && height01 >= world.TerrainSeaLevel01 - 0.02f) + climate.biome = BiomeType.Void; + samples++; switch (climate.biome) { @@ -3030,6 +3213,7 @@ public static partial class FoliagePopulator case BiomeType.Ocean: ocean++; break; case BiomeType.Swamp: swamp++; break; case BiomeType.Volcano: volcano++; break; + case BiomeType.Void: voidCount++; break; } } } @@ -3041,6 +3225,9 @@ public static partial class FoliagePopulator return BiomeType.Ocean; if (beach >= samples / 2) return BiomeType.Beach; + if (voidCount >= forest && voidCount >= grass && voidCount >= desert && voidCount >= tundra + && voidCount >= mountain && voidCount >= snow && voidCount >= swamp && voidCount >= volcano) + return BiomeType.Void; if (volcano >= forest && volcano >= grass && volcano >= desert && volcano >= tundra && volcano >= mountain && volcano >= snow && volcano >= swamp) return BiomeType.Volcano; diff --git a/Assets/Scripts/World/Foliage/FoliageSettings.cs b/Assets/Scripts/World/Foliage/FoliageSettings.cs index 920909023..2c0755937 100644 --- a/Assets/Scripts/World/Foliage/FoliageSettings.cs +++ b/Assets/Scripts/World/Foliage/FoliageSettings.cs @@ -143,6 +143,20 @@ public sealed class FoliageSettings : ScriptableObject public GameObject desertCliffVisualPrefab; [Tooltip("URP Lit material with albedo/normal/AO so cliffs receive self-shadowing.")] public Material desertCliffMaterial; + [Tooltip("Void biome floating island model (Meshy Floating Meadow Cliff). Falls back to Andrews island if null.")] + public GameObject voidFloatingIslandPrefab; + [Tooltip("URP Lit material for the void floating island (Meshy albedo/normal/metallic).")] + public Material voidFloatingIslandMaterial; + [Tooltip("How many floating islands a void host chunk may place.")] + [Min(0)] public int voidFloatingIslands = 1; + [Tooltip("Chance the chosen host chunk in a void floating-island cell places one.")] + [Range(0f, 1f)] public float voidFloatingIslandChunkSpawnChance = 0.55f; + [Tooltip("World-meter cell size between void floating islands.")] + [Min(80f)] public float voidFloatingIslandCellSizeMeters = 180f; + [Tooltip("Meters above terrain the island mesh hovers.")] + [Min(4f)] public float voidFloatingIslandHoverMeters = 36f; + [Min(0.5f)] public float voidFloatingIslandScaleMin = 2.8f; + [Min(0.5f)] public float voidFloatingIslandScaleMax = 5.2f; [Tooltip("Landmark mother tree prefab (spawned once at world start for horizon silhouette).")] public GameObject motherTreeLandmarkPrefab; [Tooltip("Landmark Ruins of Ishtar prefab (spawned once; terrain is flattened/carved under it).")] @@ -564,6 +578,7 @@ public sealed class FoliageSettings : ScriptableObject case BiomeType.Tundra: return tundraRocks; case BiomeType.Mountain: return mountainRocks; case BiomeType.Volcano: return mountainRocks > 0 ? mountainRocks : desertRocks; + case BiomeType.Void: return mountainRocks > 0 ? Mathf.Max(mountainRocks, desertRocks) : Mathf.Max(8, desertRocks); case BiomeType.Desert: return desertRocks; case BiomeType.Snow: return snowRocks; case BiomeType.Swamp: return swampRocks; @@ -576,6 +591,11 @@ public sealed class FoliageSettings : ScriptableObject return biome == BiomeType.Desert ? desertCliffs : 0; } + public int GetTargetFloatingIslandCount(BiomeType biome) + { + return biome == BiomeType.Void ? voidFloatingIslands : 0; + } + public float RollTreeRespawnDelay(System.Random rng) { float min = Mathf.Max(1f, treeRespawnMinSeconds); diff --git a/Assets/Scripts/World/Foliage/FoliageSlotType.cs b/Assets/Scripts/World/Foliage/FoliageSlotType.cs index ba53f3b36..280ccc6c5 100644 --- a/Assets/Scripts/World/Foliage/FoliageSlotType.cs +++ b/Assets/Scripts/World/Foliage/FoliageSlotType.cs @@ -7,4 +7,6 @@ public enum FoliageSlotType : byte Cliff = 4, /// Harvestable plant rooted on a lake, river, pond, or sea bed. WaterPlant = 5, + /// Hovering void-biome floating island prop (Meshy meadow cliff). + FloatingIsland = 6, } diff --git a/Assets/Scripts/World/Foliage/WorldFoliageManager.cs b/Assets/Scripts/World/Foliage/WorldFoliageManager.cs index 83ab9136c..efedfd722 100644 --- a/Assets/Scripts/World/Foliage/WorldFoliageManager.cs +++ b/Assets/Scripts/World/Foliage/WorldFoliageManager.cs @@ -241,6 +241,9 @@ public sealed partial class WorldFoliageManager : MonoBehaviour settings = UnityEngine.Object.Instantiate(settings); #endif + if (settings.voidFloatingIslandPrefab == null) + VoidFloatingIslandWorldSpawner.TryAssignToFoliageSettings(settings); + ApplySettingsOverrides(settings); ApplyRuntimeMemoryBudgets(settings); diff --git a/Assets/Scripts/World/Village/ChurchHealerDiseaseService.cs b/Assets/Scripts/World/Village/ChurchHealerDiseaseService.cs index b213ba1a0..93174f1a8 100644 --- a/Assets/Scripts/World/Village/ChurchHealerDiseaseService.cs +++ b/Assets/Scripts/World/Village/ChurchHealerDiseaseService.cs @@ -1,15 +1,14 @@ +using InventoryFramework; using Mirror; +using UnityEngine; /// -/// Church healing for lingering illness (e.g. raw-mushroom food sickness). +/// Church healing for mundane diseases — expensive coin rite that clears all active diseases. /// public static class ChurchHealerDiseaseService { public const string NoLingeringIllnessLine = - "You carry no lingering illness that potions cannot touch. When true disease comes — the kind that waits in the blood — come back. Waiting is the medicine."; - - public const string MushroomSicknessCuredLine = - "Your stomach was turning on the raw mushrooms. The chapel rite settles it. Drink less of the forest raw, or cook it over a fire next time."; + "You carry no lingering illness. When disease takes hold — Rawcap Gut, Marsh Cough, Swamp Rot, and the rest — come back with coin. Waiting is medicine; gold is faster."; public static bool ServerTryHealDiseases(NetworkIdentity player, out string response) { @@ -19,17 +18,58 @@ public static class ChurchHealerDiseaseService return false; } - PlayerMushroomSicknessRuntime sickness = - player.GetComponent() - ?? player.GetComponentInChildren(true); + PlayerDiseaseRuntime diseases = PlayerDiseaseRuntime.EnsureOn(player.gameObject); + if (diseases == null || !diseases.HasAny) + { + // Legacy shim path (should be empty once Rawcap lives on disease runtime). + PlayerMushroomSicknessRuntime mushroom = + player.GetComponent() + ?? player.GetComponentInChildren(true); + if (mushroom != null && mushroom.IsSick && mushroom.TryCureFromPotionOrHealer()) + { + response = "Your stomach was turning on the raw mushrooms. The chapel rite settles Rawcap Gut."; + return true; + } + + response = NoLingeringIllnessLine; + return true; + } - if (sickness != null && sickness.TryCureFromPotionOrHealer()) + int price = diseases.GetHealerTotalPrice(); + string names = diseases.BuildActiveNamesList(); + if (price <= 0) { - response = MushroomSicknessCuredLine; + response = NoLingeringIllnessLine; return true; } - response = NoLingeringIllnessLine; + Hotbar hotbar = player.GetComponentInChildren(true); + Inventory inventory = player.GetComponentInChildren(true); + int coins = GoldPressedManaCurrency.CountCoinEquivalent(hotbar, inventory); + if (coins < price) + { + response = + $"You carry {names}. The rite costs {price} coins. You have {coins}. Come back when you can pay — I will not cut the price, and I will not cure half."; + return false; + } + + if (!GoldPressedManaCurrency.TryConsumeCoins(hotbar, inventory, price)) + { + response = + $"You carry {names}. The rite costs {price} coins, and the purse will not open. Try again with coin ready."; + return false; + } + + string curedNames = names; + if (!diseases.ServerTryCureAll(out int cured, out _) || cured <= 0) + { + GoldPressedManaCurrency.TryGrantCoins(hotbar, inventory, price); + response = NoLingeringIllnessLine; + return false; + } + + response = + $"The chapel rite burns {price} coins and clears {curedNames}. Rest, drink water, and do not walk the swamp barefoot for a while."; return true; } } diff --git a/Assets/Scripts/World/VoidFloatingIslandWorldSpawner.cs b/Assets/Scripts/World/VoidFloatingIslandWorldSpawner.cs new file mode 100644 index 000000000..6ec821fee --- /dev/null +++ b/Assets/Scripts/World/VoidFloatingIslandWorldSpawner.cs @@ -0,0 +1,103 @@ +using System.Collections.Generic; +using UnityEngine; +#if UNITY_EDITOR +using UnityEditor; +#endif + +/// +/// Resolves the Meshy Floating Meadow Cliff model for the void biome. +/// Andrews floating island remains a last-resort editor fallback only. +/// +public static class VoidFloatingIslandWorldSpawner +{ + public const string MeshyFolderName = "Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx"; + public const string MeshyFbxAssetPath = + "Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/" + + "Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/" + + "Meshy_AI_Floating_Meadow_Cliff_1019132410_texture.fbx"; + public const string MeshyFbxFlatAssetPath = + "Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/" + + "Meshy_AI_Floating_Meadow_Cliff_1019132410_texture.fbx"; + public const string MeshyMaterialAssetPath = + "Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/" + + "Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Materials/" + + "Meshy_AI_Floating_Meadow_Cliff_1019132410_texture.mat"; + public const string AndrewsIslandAssetPath = + "Assets/ThirdParty/DownloadedAssets/andrews-floating-island-unity-v2/source/" + + "Andrews_Floating_Island_Unity/Andrews_Island_Unity_2.obj"; + + private static GameObject s_cachedFallback; + private static bool s_resolved; + + public static GameObject ResolvePrefabFallback() + { + if (s_resolved) + return s_cachedFallback; + + s_resolved = true; + s_cachedFallback = TryLoadMeshyOrAndrews(); + return s_cachedFallback; + } + + public static void InvalidateCache() + { + s_resolved = false; + s_cachedFallback = null; + } + +#if UNITY_EDITOR + [InitializeOnLoadMethod] + private static void EditorReset() => InvalidateCache(); +#endif + + private static GameObject TryLoadMeshyOrAndrews() + { +#if UNITY_EDITOR + GameObject meshy = AssetDatabase.LoadAssetAtPath(MeshyFbxAssetPath); + if (meshy == null) + meshy = AssetDatabase.LoadAssetAtPath(MeshyFbxFlatAssetPath); + if (meshy != null) + return meshy; + + GameObject andrews = AssetDatabase.LoadAssetAtPath(AndrewsIslandAssetPath); + if (andrews != null) + return andrews; +#endif + + // Runtime Resources path (optional copy of the prefab). + GameObject fromResources = Resources.Load("VoidFloatingIsland"); + if (fromResources != null) + return fromResources; + + return null; + } + + /// + /// Editor helper: assign the Meshy (or Andrews) model onto FoliageSettings. + /// + public static bool TryAssignToFoliageSettings(FoliageSettings settings) + { + if (settings == null) + return false; + + GameObject prefab = ResolvePrefabFallback(); + if (prefab == null) + return false; + + settings.voidFloatingIslandPrefab = prefab; +#if UNITY_EDITOR + Material mat = AssetDatabase.LoadAssetAtPath(MeshyMaterialAssetPath); + if (mat != null) + settings.voidFloatingIslandMaterial = mat; +#endif + return true; + } + + public static IEnumerable ExpectedAssetPaths() + { + yield return MeshyFbxAssetPath; + yield return MeshyFbxFlatAssetPath; + yield return MeshyMaterialAssetPath; + yield return AndrewsIslandAssetPath; + } +} diff --git a/Assets/Scripts/World/VoidFloatingIslandWorldSpawner.cs.meta b/Assets/Scripts/World/VoidFloatingIslandWorldSpawner.cs.meta new file mode 100644 index 000000000..9b5e9a02a --- /dev/null +++ b/Assets/Scripts/World/VoidFloatingIslandWorldSpawner.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2c89a3ba2f544f32ba079715a6f728d1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/WorldGenerator.cs b/Assets/Scripts/WorldGenerator.cs index 0d693db33..9b621c8f7 100644 --- a/Assets/Scripts/WorldGenerator.cs +++ b/Assets/Scripts/WorldGenerator.cs @@ -208,6 +208,8 @@ public class WorldGenerator : MonoBehaviour [SerializeField] private bool spawnPlayerInVolcanoBiome = true; [Tooltip("Radial fraction of volcano radius for spawn (0 center crater … 1 rim).")] [SerializeField] [Range(0.35f, 0.85f)] private float volcanoSpawnRadial01 = 0.48f; + [Tooltip("Prefer spawning on the offshore void island (overrides Galbesh / bandit / volcano).")] + [SerializeField] private bool spawnPlayerOnVoidIsland = true; [Tooltip("Prefer spawning next to Ruins of Galbesh when that landmark exists (overrides volcano).")] [SerializeField] private bool spawnPlayerNearGalbeshRuins = true; [Tooltip("Horizontal meters from Galbesh Ground center to the player spawn.")] @@ -238,6 +240,7 @@ public class WorldGenerator : MonoBehaviour private bool playerGalbeshSpawnApplied; private bool playerBanditCampSpawnApplied; private bool playerRiverSpawnApplied; + private bool playerVoidSpawnApplied; /// True after has finished spawn-chunk pregeneration. public bool IsWorldReady => worldReadyInitialized; @@ -258,7 +261,7 @@ public class WorldGenerator : MonoBehaviour public float IslandWorldRadius => islandRadius; /// - /// Call after an external teleport so Bandit Camp / Galbesh / volcano spawn snaps + /// Call after an external teleport so Bandit Camp / Galbesh / volcano / void spawn snaps /// do not yank the player back. /// public void MarkPlayerSpawnHandledExternally() @@ -267,8 +270,11 @@ public class WorldGenerator : MonoBehaviour playerGalbeshSpawnApplied = true; playerVolcanoSpawnApplied = true; playerRiverSpawnApplied = true; + playerVoidSpawnApplied = true; } + public bool PrefersVoidIslandSpawn => spawnPlayerOnVoidIsland; + public float IslandWorldDiameter => islandRadius * 2f; public bool IslandModeEnabled => islandMode; @@ -358,6 +364,7 @@ public class WorldGenerator : MonoBehaviour sample.biome = reclass; ApplyVolcanoBiomeOverride(ref sample, worldX, worldZ, height01); + ApplyVoidBiomeOverride(ref sample, worldX, worldZ, height01); // Rare inland desert oasis bowls stay shallow Swamp, not Ocean. if (sample.biome == BiomeType.Ocean @@ -399,6 +406,7 @@ public class WorldGenerator : MonoBehaviour cliffSlopeThreshold, biomeSettings); ApplyVolcanoBiomeOverride(ref sample, worldX, worldZ, height01); + ApplyVoidBiomeOverride(ref sample, worldX, worldZ, height01); ApplyNearWaterTemperatureCooling(ref sample); ApplyTemporalTemperatureModulation(ref sample); return sample; @@ -416,6 +424,16 @@ public class WorldGenerator : MonoBehaviour sample.biome = BiomeType.Volcano; } + private void ApplyVoidBiomeOverride(ref ClimateSample sample, int worldX, int worldZ, float height01) + { + if (height01 < seaLevel + beachBand * 0.35f) + return; + + float void01 = BiomeClimateSampler.SampleVoidDomain01(worldX, worldZ, seed, biomeSettings); + if (void01 > 0.12f) + sample.biome = BiomeType.Void; + } + /// 0–1 how much of the local neighborhood matches (for soft heat pulls). public float SampleBiomeWeightAtWorldPosition(Vector3 worldPosition, BiomeType biome, float radiusMeters = -1f) { @@ -838,7 +856,9 @@ public class WorldGenerator : MonoBehaviour ApplyStreamingBudgetCaps(); spawnPlayerInVolcanoBiome = true; spawnPlayerNearGalbeshRuins = true; + spawnPlayerOnVoidIsland = true; EnsureVolcanoBiomeSettingsReady(); + BiomeClimateSampler.EnsureVoidSettingsDefaults(biomeSettings); BiomeClimateSampler.SetIslandLayout(islandCenter, islandRadius); BiomeClimateSampler.ConfigureVolcanoIsland(islandCenter, islandRadius, biomeSettings); TryAutoFitSeaLevelToIsland(); @@ -854,6 +874,7 @@ public class WorldGenerator : MonoBehaviour EnsureVillageWorldSpawner(); EnsureRuinLootGroupWorldSpawner(); EnsureRowBoatWorldSpawner(); + EnsureDrucladWorldSpawner(); ClientViewTerrainVisibility.SetIslandViewDistances(islandMode ? islandRadius : 0f); } @@ -1023,6 +1044,12 @@ public class WorldGenerator : MonoBehaviour spawner.enabled = true; } + private void EnsureDrucladWorldSpawner() + { + if (GetComponent() == null) + gameObject.AddComponent(); + } + private void EnsureDesertCliffWorldSpawner() { if (GetComponent() == null) @@ -1314,7 +1341,9 @@ public class WorldGenerator : MonoBehaviour ApplyStreamingBudgetCaps(); spawnPlayerInVolcanoBiome = true; spawnPlayerNearGalbeshRuins = true; + spawnPlayerOnVoidIsland = true; EnsureVolcanoBiomeSettingsReady(); + BiomeClimateSampler.EnsureVoidSettingsDefaults(biomeSettings); BiomeClimateSampler.SetIslandLayout(islandCenter, islandRadius); BiomeClimateSampler.ConfigureVolcanoIsland(islandCenter, islandRadius, biomeSettings); ClientViewTerrainVisibility.SetIslandViewDistances(islandMode ? islandRadius : 0f); @@ -1338,6 +1367,7 @@ public class WorldGenerator : MonoBehaviour EnsureVillageWorldSpawner(); EnsureRuinLootGroupWorldSpawner(); EnsureRowBoatWorldSpawner(); + EnsureDrucladWorldSpawner(); } // Galbesh (and Bandit when that spawn is on) must exist before volcano boot so we skip @@ -1424,6 +1454,14 @@ public class WorldGenerator : MonoBehaviour FlushPendingColliderBakes(); ForceBakeRing(GetChunkCoord(riverSpawn), 1, 3); } + else if (spawnPlayerOnVoidIsland + && TryGetVoidPlayerSpawnPose(out Vector3 voidSpawn, out _)) + { + ManafallLoadDebug.Log($"warm chunks at void island spawn {voidSpawn}"); + PreGenerateChunksAroundWorldPosition(voidSpawn, syncChebyshevRadius: 1); + FlushPendingColliderBakes(); + ForceBakeRing(GetChunkCoord(voidSpawn), 1, 3); + } else if (spawnPlayerNearBanditCamp && TryGetBanditCampPlayerSpawnPose(out Vector3 banditSpawn, out _)) { @@ -1574,6 +1612,7 @@ public class WorldGenerator : MonoBehaviour { spawnPlayerInVolcanoBiome = true; spawnPlayerNearGalbeshRuins = true; + spawnPlayerOnVoidIsland = true; EnsureVolcanoBiomeSettingsReady(); BiomeClimateSampler.SetIslandLayout(islandCenter, islandRadius); @@ -1588,16 +1627,23 @@ public class WorldGenerator : MonoBehaviour Vector3 pin = new Vector3(volcanoCenter.x, seaLevel * heightScale + 40f, volcanoCenter.y); WorldLandmarkStreamingPins.Register(pin); - // Preferred spawn is Bandit Camp / Galbesh — don't sync-build the whole volcano disk + if (spawnPlayerOnVoidIsland && TryGetVoidPlayerSpawnPose(out Vector3 voidPin, out _)) + { + WorldLandmarkStreamingPins.Register(voidPin); + ManafallLoadDebug.Log($"Void island spawn pin at {voidPin}"); + } + + // Preferred spawn is void / Bandit / Galbesh — don't sync-build the whole volcano disk // (~20s). Streaming pins create those chunks when the player approaches. - bool landmarkSpawnReady = - (spawnPlayerNearBanditCamp && TryGetBanditCampPlayerSpawnPose(out _, out _)) + bool preferredAwayFromVolcano = + (spawnPlayerOnVoidIsland && TryGetVoidPlayerSpawnPose(out _, out _)) + || (spawnPlayerNearBanditCamp && TryGetBanditCampPlayerSpawnPose(out _, out _)) || (spawnPlayerNearGalbeshRuins && TryGetGalbeshPlayerSpawnPose(out _, out _)); - if (landmarkSpawnReady) + if (preferredAwayFromVolcano) { ManafallLoadDebug.Log( $"Volcano island: pin-only at ({volcanoCenter.x:0}, {volcanoCenter.y:0}) " + - $"(player spawn is landmark; defer full disk)"); + $"(player spawn is elsewhere; defer full disk)"); return; } @@ -1919,7 +1965,8 @@ public class WorldGenerator : MonoBehaviour int cix = Mathf.RoundToInt(wx); int ciz = Mathf.RoundToInt(wz); // Volcano is a second landmass — never treat it as coastline for sea fit. - if (BiomeClimateSampler.SampleVolcanoDomain01(cix, ciz, seed, biomeSettings) <= 0.02f) + if (BiomeClimateSampler.SampleVolcanoDomain01(cix, ciz, seed, biomeSettings) <= 0.02f + && BiomeClimateSampler.SampleVoidDomain01(cix, ciz, seed, biomeSettings) <= 0.02f) coastHeights.Add(mapHeightSampler.SampleAnalyticalHeight01(cix, ciz)); float oceanDist = islandRadius * Mathf.Lerp(1.28f, 1.45f, NoiseUtility.GenerateNoise(wx, wz, 0.0015f, seed + 66001)); @@ -1927,7 +1974,8 @@ public class WorldGenerator : MonoBehaviour float oz = islandCenter.y + Mathf.Sin(angle + 0.4f) * oceanDist; int oix = Mathf.RoundToInt(ox); int oiz = Mathf.RoundToInt(oz); - if (BiomeClimateSampler.SampleVolcanoDomain01(oix, oiz, seed, biomeSettings) > 0.02f) + if (BiomeClimateSampler.SampleVolcanoDomain01(oix, oiz, seed, biomeSettings) > 0.02f + || BiomeClimateSampler.SampleVoidDomain01(oix, oiz, seed, biomeSettings) > 0.02f) continue; float oceanH = mapHeightSampler.SampleAnalyticalHeight01(oix, oiz); @@ -2775,27 +2823,47 @@ public class WorldGenerator : MonoBehaviour } /// - /// River bank first when rivers are enabled, then Bandit Camp, Galbesh, volcano. + /// Void island first, then river bank, Bandit Camp, Galbesh, volcano. /// public bool TryGetPreferredPlayerSpawnPose(out Vector3 position, out Quaternion rotation) { + const string tag = PlayerFirstSpawnPlacement.VoidSpawnSearchToken; + if (spawnPlayerOnVoidIsland && TryGetVoidPlayerSpawnPose(out position, out rotation)) + { + Debug.Log(tag + " preferred=void " + position.ToString("F1")); + return true; + } + if (generateRivers && TryGetRiverBankSpawnPose(out position, out rotation)) + { + Debug.Log(tag + " preferred=river (void skipped flag=" + spawnPlayerOnVoidIsland + ")"); return true; + } if (spawnPlayerNearBanditCamp && TryGetBanditCampPlayerSpawnPose(out position, out rotation)) + { + Debug.Log(tag + " preferred=bandit (void skipped)"); return true; + } if (spawnPlayerNearGalbeshRuins && TryGetGalbeshPlayerSpawnPose(out position, out rotation)) + { + Debug.Log(tag + " preferred=galbesh (void skipped flag=" + spawnPlayerOnVoidIsland + ")"); return true; + } if (spawnPlayerInVolcanoBiome && biomeSettings != null && biomeSettings.volcanoDomainStrength > 0.001f && TryGetVolcanoPlayerSpawnPose(out position, out rotation)) + { + Debug.Log(tag + " preferred=volcano (void skipped)"); return true; + } position = default; rotation = Quaternion.identity; + Debug.LogWarning(tag + " preferred=none voidFlag=" + spawnPlayerOnVoidIsland); return false; } @@ -2932,6 +3000,71 @@ public class WorldGenerator : MonoBehaviour return true; } + /// Feet pose near the middle of the offshore void island. + public bool TryGetVoidPlayerSpawnPose(out Vector3 position, out Quaternion rotation) + { + const string tag = PlayerFirstSpawnPlacement.VoidSpawnSearchToken; + position = default; + rotation = Quaternion.identity; + if (biomeSettings == null) + biomeSettings = new BiomeSettings(); + + BiomeClimateSampler.EnsureVoidSettingsDefaults(biomeSettings); + BiomeClimateSampler.SetIslandLayout(islandCenter, islandRadius); + BiomeClimateSampler.ConfigureVolcanoIsland(islandCenter, islandRadius, biomeSettings); + BiomeClimateSampler.ResolveVoidLayout(biomeSettings, out Vector2 voidCenter, out float radius); + if (radius <= 1f) + { + Debug.LogWarning(tag + " void pose: radius=" + radius + + " centerIsland=" + islandCenter + " islandR=" + islandRadius); + return false; + } + + EnsureMapHeightSampler(); + int wx = Mathf.RoundToInt(voidCenter.x); + int wz = Mathf.RoundToInt(voidCenter.y); + float void01 = BiomeClimateSampler.SampleVoidDomain01(wx, wz, seed, biomeSettings); + // Always seat at the authored center — domain probes used to fail open and fall through to Galbesh. + if (void01 <= 0.05f) + { + Debug.LogWarning(tag + " void pose: domain low at center " + + voidCenter + " r=" + radius + " void01=" + void01.ToString("F3") + + " islandR=" + islandRadius + " — forcing center seat"); + void01 = 1f; + } + + float terrainY = mapHeightSampler != null + ? mapHeightSampler.SampleAnalyticalSurfaceWorldY(wx, wz) + : LandHeight01ToWorldY(ResolveAnalyticalHeight01(wx, wz)); + float seaY = seaLevel * heightScale; + if (terrainY < seaY + 1.2f) + { + Debug.Log(tag + " void pose: lift wet analyticalY=" + terrainY.ToString("F1") + + " seaY=" + seaY.ToString("F1")); + terrainY = seaY + 4f; + } + + float feet = playerSpawnFeetOffset > 0.01f ? playerSpawnFeetOffset : 1.05f; + if (player != null) + { + float live = PlayerSpawnGroundUtility.GetCharacterFeetOffset(player); + if (live > 0.01f) + feet = live; + } + + position = new Vector3(wx, terrainY + feet, wz); + Vector3 outward = new Vector3(wx - islandCenter.x, 0f, wz - islandCenter.y); + rotation = outward.sqrMagnitude > 0.01f + ? Quaternion.LookRotation(outward.normalized, Vector3.up) + : Quaternion.identity; + Debug.Log(tag + " void pose ok " + position.ToString("F1") + + " void01=" + void01.ToString("F2") + + " r=" + radius.ToString("F0") + + " center=" + voidCenter + + " islandR=" + islandRadius.ToString("F0")); + return true; + } + /// /// Mid-slope pose on the first volcano climb path, facing the summit. /// @@ -5489,6 +5622,27 @@ public class WorldGenerator : MonoBehaviour return dx * dx + dz * dz <= reach * reach; } + private bool ChunkOverlapsVoidIsland(Vector2Int coord) + { + if (biomeSettings == null || chunkSize <= 0) + return false; + + BiomeClimateSampler.SetIslandLayout(islandCenter, islandRadius); + BiomeClimateSampler.ResolveVoidLayout(biomeSettings, out Vector2 voidCenter, out float radius); + if (radius <= 1f) + return false; + + float edge = biomeSettings.voidDomainEdgeMeters <= 0.001f ? 36f : biomeSettings.voidDomainEdgeMeters; + float reach = radius + Mathf.Max(10f, edge) + chunkSize; + float minX = coord.x * chunkSize; + float minZ = coord.y * chunkSize; + float cx = Mathf.Clamp(voidCenter.x, minX, minX + chunkSize); + float cz = Mathf.Clamp(voidCenter.y, minZ, minZ + chunkSize); + float dx = cx - voidCenter.x; + float dz = cz - voidCenter.y; + return dx * dx + dz * dz <= reach * reach; + } + private int lastChunkCreateFrame = -1; private TerrainSamplePrefetch terrainPrefetch; private Chunk terrainPrefetchSampler; @@ -5689,7 +5843,7 @@ public class WorldGenerator : MonoBehaviour pendingCreateSet.Remove(coord); - bool volcanoTile = forceVolcanoBoot || ChunkOverlapsVolcanoIsland(coord); + bool volcanoTile = forceVolcanoBoot || ChunkOverlapsVolcanoIsland(coord) || ChunkOverlapsVoidIsland(coord); // Voxel Play: fixed resident budget. When full, reuse the farthest live chunk // instead of allocating another Mesh/heightmap (the multi-GB failure mode). @@ -6000,6 +6154,25 @@ public class WorldGenerator : MonoBehaviour if (PlayerFirstSpawnPlacement.IsInLimbo(player.position)) return; + if (spawnPlayerOnVoidIsland + && !playerVoidSpawnApplied + && TryGetVoidPlayerSpawnPose(out Vector3 voidPos, out Quaternion voidRot)) + { + CharacterController vcc = player.GetComponent(); + bool vWasEnabled = vcc != null && vcc.enabled; + if (vcc != null) + vcc.enabled = false; + player.SetPositionAndRotation(voidPos, voidRot); + if (vcc != null) + vcc.enabled = vWasEnabled; + playerVoidSpawnApplied = true; + playerRiverSpawnApplied = true; + playerBanditCampSpawnApplied = true; + playerGalbeshSpawnApplied = true; + playerVolcanoSpawnApplied = true; + return; + } + if (generateRivers && !playerRiverSpawnApplied && TryGetRiverBankSpawnPose(out Vector3 riverPos, out Quaternion riverRot)) diff --git a/Assets/Settings/Mobile_Renderer.asset b/Assets/Settings/Mobile_Renderer.asset index 3e18251c3..35fcafb02 100644 --- a/Assets/Settings/Mobile_Renderer.asset +++ b/Assets/Settings/Mobile_Renderer.asset @@ -109,6 +109,7 @@ MonoBehaviour: underwaterMaterial: {fileID: 2100000, guid: d8e7f6a5b4c3920182736455a4b3c2d1, type: 2} injectionPoint: 450 skipPreviewAndReflectionCameras: 1 + logUnderwaterFxDebug: 1 --- !u!114 &8123456789012345686 MonoBehaviour: m_ObjectHideFlags: 0 diff --git a/Assets/Settings/PC_Renderer.asset b/Assets/Settings/PC_Renderer.asset index 315ef564c..7297ff0cf 100644 --- a/Assets/Settings/PC_Renderer.asset +++ b/Assets/Settings/PC_Renderer.asset @@ -137,7 +137,7 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 6dd81a3604104244482537b55c0f6641, type: 3} m_Name: ManafallPlanarShoreReflection m_EditorClassIdentifier: Assembly-CSharp::ManafallWaterPlanarReflectionFeature - m_Active: 0 + m_Active: 1 reflectionLayers: serializedVersion: 2 m_Bits: 4294967295 @@ -153,7 +153,7 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 077703060909635488781ae46bf1d16e, type: 3} m_Name: ManafallShoreCaustics m_EditorClassIdentifier: Assembly-CSharp::ManafallShoreCausticsFeature - m_Active: 0 + m_Active: 1 causticsMaterial: {fileID: 2100000, guid: e9f8a7b6c5d4938273645546a5b4c3e2, type: 2} injectionPoint: 300 --- !u!114 &8123456789012345684 @@ -172,6 +172,7 @@ MonoBehaviour: underwaterMaterial: {fileID: 2100000, guid: d8e7f6a5b4c3920182736455a4b3c2d1, type: 2} injectionPoint: 450 skipPreviewAndReflectionCameras: 1 + logUnderwaterFxDebug: 1 --- !u!114 &8123456789012345686 MonoBehaviour: m_ObjectHideFlags: 0 diff --git a/Assets/Shaders/Include/ManafallFantasyStarfield.hlsl b/Assets/Shaders/Include/ManafallFantasyStarfield.hlsl index 69f288935..43e750101 100644 --- a/Assets/Shaders/Include/ManafallFantasyStarfield.hlsl +++ b/Assets/Shaders/Include/ManafallFantasyStarfield.hlsl @@ -1,4 +1,5 @@ // White night stars for Manafall/WeatherSky. +// Soft circular point-stars on a fine spherical grid — not floor()-painted cells. // No aurora curtains — those read as magenta/cyan wisps on the Offline night soak. #ifndef MANAFALL_FANTASY_STARFIELD_INCLUDED #define MANAFALL_FANTASY_STARFIELD_INCLUDED @@ -9,25 +10,139 @@ float _ManafallStarNight01; float _ManafallStarBrightness01; #endif +#ifndef MANAFALL_STAR_EXTRAS_DECLARED +#define MANAFALL_STAR_EXTRAS_DECLARED +float _ManafallStarfieldMul; +float _ManafallStarMoonDim01; +#endif + half ManafallStarGate01() { return saturate(_ManafallStarNight01); } +float ManafallStarHash21(float2 p) +{ + return frac(sin(dot(p, float2(127.1, 311.7))) * 43758.5453); +} + +// One sharp point-star layer. density = cells across spherical UV. +// thresh = minimum hash to keep a star (lower = denser). radius = hard core size in cell space. +half ManafallStarLayer(float2 uv, float density, float thresh, float radius, float intensity) +{ + float2 grid = uv * density; + float2 cell = floor(grid); + float2 f = frac(grid); + float h = ManafallStarHash21(cell); + float keep = saturate((h - thresh) / max(1e-3, 1.0 - thresh)); + if (keep <= 0.0) + return 0.0h; + + float2 offset = float2( + ManafallStarHash21(cell + float2(19.1, 7.3)), + ManafallStarHash21(cell + float2(3.7, 41.2))); + // Keep the disc inside the cell so adjacent cells do not clip into squares. + offset = 0.22 + offset * 0.56; + + float dist = length(f - offset); + // Tight size range — wide variation made bright stars into soft halos. + float size = radius * lerp(0.85, 1.15, ManafallStarHash21(cell + float2(8.9, 2.1))); + float t = saturate(1.0 - dist / max(size, 1e-4)); + // Hard core (pow 6) with a tiny secondary spike so gems still punch, not bloom. + float core = t * t * t; + core *= core; + float spike = saturate(1.0 - dist / max(size * 0.35, 1e-4)); + spike = spike * spike * spike; + + float mag = intensity * lerp(0.55, 1.0, ManafallStarHash21(cell + float2(5.5, 13.7))); + return (half)((core * 0.65 + spike * 0.35) * mag * keep); +} + +// Galactic pole. Tilted so the Milky Way is an arc from horizon to horizon, +// not a ring around the zenith and not a flat stripe on the horizon. +float3 ManafallGalacticPole() +{ + return normalize(float3(0.62, 0.38, -0.68)); +} + +// 1 on the galactic equator, 0 toward the poles. Narrow spine, soft wings. +float ManafallMilkyWayBand(float3 dir) +{ + float lat = abs(dot(normalize(dir), ManafallGalacticPole())); + float spine = exp(-(lat * lat) / 0.010); + float wing = exp(-(lat * lat) / 0.042); + return saturate(spine * 0.72 + wing * 0.38); +} + +// Density clumps along the band so it reads as a line of clusters, not a solid bar. +float ManafallMilkyWayClumps(float3 dir) +{ + float3 pole = ManafallGalacticPole(); + float3 tangent = normalize(cross(pole, float3(0.15, 1.0, 0.05))); + float3 bitangent = cross(pole, tangent); + float3 inPlane = dir - pole * dot(dir, pole); + float lon = atan2(dot(inPlane, bitangent), dot(inPlane, tangent)); + + float wave = 0.5 + + 0.34 * sin(lon * 2.1 + 0.55) + + 0.28 * sin(lon * 5.3 - 1.15) + + 0.22 * sin(lon * 9.4 + 2.05) + + 0.12 * sin(lon * 17.0 - 0.4); + return saturate(pow(saturate(wave), 1.55)); +} + +// Raw star intensity (no night gate / brightness). Safe for menu and CG sky paths. +half ManafallSoftStarfieldRaw(float3 dir) +{ + float3 d = normalize(dir); + half elev = saturate((half)d.y); + half horizonFade = saturate((elev - 0.008h) / 0.07h); + if (horizonFade < 1e-3h) + return 0.0h; + + float2 uv = float2( + atan2(d.x, d.z) * 0.15915494309 + 0.5, + asin(clamp(d.y, -1.0, 1.0)) * 0.31830988618 + 0.5); + + // A few bright stars off the band so the rest of the sky is not empty. + half field = 0.0h; + field += ManafallStarLayer(uv, 72.0, 0.90, 0.055, 1.15); + field += ManafallStarLayer(uv, 36.0, 0.955, 0.080, 1.85); + + float band = ManafallMilkyWayBand(d); + float clumps = ManafallMilkyWayClumps(d); + float along = saturate(band * lerp(0.22, 1.0, clumps)); + + // Dense pinpoints packed on the galactic arc. Clumps make bright knots along the line. + half river = 0.0h; + river += ManafallStarLayer(uv, 320.0, 0.28, 0.042, 0.70); + river += ManafallStarLayer(uv, 180.0, 0.48, 0.055, 1.05); + river += ManafallStarLayer(uv, 84.0, 0.70, 0.072, 1.55); + river += ManafallStarLayer(uv, 40.0, 0.84, 0.090, 2.05); + + // Faint milky spine under the clusters. Kept dim so it does not read as a fog band. + half dust = (half)(band * band * lerp(0.35, 1.0, clumps) * 0.11); + + half stars = field * 0.42h + river * (half)along + dust; + return stars * horizonFade; +} + half3 ManafallFantasyStarfield(half3 dir, half cloudAmt) { half gate = ManafallStarGate01(); if (gate < 1e-3h) return (half3)0.0h; - float2 p = dir.xz * float2(37.0, 29.0) + dir.y * float2(11.0, 17.0); - float h0 = frac(sin(dot(p, float2(12.9898, 78.233))) * 43758.5453); - float h1 = frac(sin(dot(p * 2.17 + 4.2, float2(39.346, 11.135))) * 24634.6345); - half star = saturate((h0 - 0.996) * 140.0); - star = max(star, saturate((h1 - 0.998) * 180.0) * 0.65h); + half stars = ManafallSoftStarfieldRaw((float3)dir); half occlude = 1.0h - saturate(cloudAmt); - half bright = saturate(_ManafallStarBrightness01); - return (half3)(star * occlude * gate * bright); + half bright = (half)clamp(_ManafallStarBrightness01, 0.0, 4.0); + if (bright < 1e-3h) + bright = 1.0h; + + half mul = _ManafallStarfieldMul > 1e-4 ? (half)_ManafallStarfieldMul : 1.0h; + half moonDim = _ManafallStarMoonDim01 > 1e-4 ? (half)_ManafallStarMoonDim01 : 1.0h; + + return (half3)(stars * occlude * gate * bright * mul * moonDim); } #endif diff --git a/Assets/Shaders/Include/ManafallHeightFog.hlsl b/Assets/Shaders/Include/ManafallHeightFog.hlsl index afed01c7c..f2fb1205f 100644 --- a/Assets/Shaders/Include/ManafallHeightFog.hlsl +++ b/Assets/Shaders/Include/ManafallHeightFog.hlsl @@ -55,16 +55,13 @@ float ManafallSampleHeightFogDensity(float3 worldPos, float timeSec) float span = max(ceilingY - baseY, 1.0); float height01 = saturate((h - baseY) / span); + // Ground mist only. The old mid-height "shelf" painted a chalky white band across + // the top of the frame whenever the camera looked toward the horizon. float groundMist = exp(-height01 * 3.15); - groundMist *= 1.0 - smoothstep(0.62, 0.98, height01); + groundMist *= 1.0 - smoothstep(0.55, 0.92, height01); - float shelfCenter = lerp(0.42, 0.28, saturate(strength)); - float shelf = exp(-abs(height01 - shelfCenter) * lerp(2.4, 3.6, saturate(strength))); - shelf *= exp(-height01 * 0.85); - shelf *= 1.0 - smoothstep(0.88, 1.0, height01); - - float groundWeight = lerp(0.48, 0.86, saturate(strength * 1.15)); - float heightMul = groundMist * groundWeight + shelf * (1.15 - groundWeight * 0.85); + float groundWeight = lerp(0.72, 0.94, saturate(strength * 1.15)); + float heightMul = groundMist * groundWeight; heightMul = max(heightMul, 0.0); float swirl = ManafallHeightFogBankNoise(worldPos, strength, timeSec); diff --git a/Assets/Shaders/Include/ManafallWaterCaustics.hlsl b/Assets/Shaders/Include/ManafallWaterCaustics.hlsl index 06c697704..f7793841f 100644 --- a/Assets/Shaders/Include/ManafallWaterCaustics.hlsl +++ b/Assets/Shaders/Include/ManafallWaterCaustics.hlsl @@ -1,7 +1,10 @@ #ifndef MANAFALL_WATER_CAUSTICS_INCLUDED #define MANAFALL_WATER_CAUSTICS_INCLUDED -// Same shore caustic the sea surface and the underwater pass share. +// Rev 6 — OPEN filament field shared by seafloor caustics AND surface micro-waves. +// Same phases drive bed light and top normals so the sheet and the sand match. +#define MF_CAUSTIC_REV 6 + float MF_CausticHash21(float2 p) { float3 p3 = frac(float3(p.xyx) * float3(0.1031, 0.1030, 0.0973)); @@ -21,13 +24,127 @@ float MF_CausticNoise2(float2 p) return lerp(lerp(a, b, f.x), lerp(c, d, f.x), f.y); } +float MF_CausticFbm(float2 p) +{ + float v = 0.0; + float a = 0.5; + [unroll] + for (int i = 0; i < 4; i++) + { + v += a * MF_CausticNoise2(p); + p = p * 2.07 + float2(17.0, 9.0); + a *= 0.5; + } + return v; +} + +float MF_CausticFilament(float phase, float sharpness) +{ + float tri = abs(frac(phase) * 2.0 - 1.0); + return pow(saturate(1.0 - tri), sharpness); +} + +/// +/// Shared filament field. Returns: +/// height — soft ridge height in ~[0,1] for surface displacement / normals +/// bright — high-contrast caustic brightness in ~[0,1] for seafloor light +/// +void MF_CausticFilamentField(float2 worldXZ, float time, float scale, out float height, out float bright) +{ + float s = max(scale, 0.25); + float t = time * 1.25; + + float2 p = worldXZ * (s * 0.5); + + float spaceA = lerp(0.7, 1.45, MF_CausticFbm(p * 0.18 + float2(t * 0.03, -t * 0.02))); + float spaceB = lerp(0.7, 1.45, MF_CausticFbm(p * 0.18 + float2(4.2, 1.7) - float2(t * 0.025, t * 0.035))); + float spaceC = lerp(0.75, 1.35, MF_CausticFbm(p * 0.22 + float2(-t * 0.02, t * 0.028) + 2.4)); + + float wobA = (MF_CausticFbm(p * 0.55 + float2(t * 0.09, -t * 0.07)) - 0.5) * 1.65; + float wobB = (MF_CausticFbm(p * 0.6 + float2(-t * 0.06, t * 0.08) + 3.7) - 0.5) * 1.55; + float wobC = (MF_CausticFbm(p * 0.5 + float2(t * 0.05, t * 0.04) + 8.1) - 0.5) * 1.4; + + float2 d0 = float2(0.985, 0.174); + float2 d1 = float2(-0.259, 0.966); + float2 d2 = float2(0.788, -0.616); + float2 d3 = float2(-0.819, -0.574); + + float ph0 = dot(p, d0) * (2.15 * spaceA) + wobA + t * 0.38; + float ph1 = dot(p, d1) * (2.55 * spaceB) + wobB - t * 0.33; + float ph2 = dot(p, d2) * (1.95 * spaceC) + wobC * 0.85 + t * 0.27; + float ph3 = dot(p, d3) * (2.35 * spaceA) + wobA * 0.7 - t * 0.41; + + // Soft ridges for surface height (lower sharpness so normals stay readable). + float h0 = MF_CausticFilament(ph0 * 0.1591549, 4.5); + float h1 = MF_CausticFilament(ph1 * 0.1591549, 4.2); + float h2 = MF_CausticFilament(ph2 * 0.1591549, 5.0); + float h3 = MF_CausticFilament(ph3 * 0.1591549, 4.8); + + float ph4 = dot(p, d0) * (3.8 * spaceB) + wobB * 1.2 - t * 0.55 + 1.9; + float ph5 = dot(p, d1) * (4.2 * spaceC) + wobC * 1.1 + t * 0.49 + 2.7; + float h4 = MF_CausticFilament(ph4 * 0.1591549, 6.0) * 0.45; + float h5 = MF_CausticFilament(ph5 * 0.1591549, 6.0) * 0.45; + + float soft = h0 + h1 + h2 + h3 + h4 + h5; + soft += h0 * h1 * 1.2 + h2 * h3 * 1.0; + float envelope = lerp(0.38, 1.0, MF_CausticNoise2(worldXZ * 0.02 + float2(t * 0.012, -t * 0.01))); + soft *= envelope; + height = saturate(soft * 0.55); + + // Harder filaments for seafloor light (same phases). + float f0 = MF_CausticFilament(ph0 * 0.1591549, 9.0); + float f1 = MF_CausticFilament(ph1 * 0.1591549, 8.5); + float f2 = MF_CausticFilament(ph2 * 0.1591549, 10.0); + float f3 = MF_CausticFilament(ph3 * 0.1591549, 9.5); + float f4 = MF_CausticFilament(ph4 * 0.1591549, 12.0) * 0.5; + float f5 = MF_CausticFilament(ph5 * 0.1591549, 12.0) * 0.5; + + float web = f0 + f1 + f2 + f3 + f4 + f5; + web += f0 * f1 * 3.4; + web += f2 * f3 * 3.0; + web += f0 * f2 * 1.7; + web += f1 * f3 * 1.6; + web += f4 * f5 * 1.4; + web *= envelope; + web = pow(saturate(web * 0.4), 1.4); + bright = saturate(web * 1.9); +} + half MF_ShoreCaustic(float2 worldXZ, float time, float scale) { - float2 cUv = worldXZ * scale * 0.52 + float2(time * 0.084, -time * 0.069); - float cWobble = MF_CausticNoise2(cUv * 0.71 + time * 0.05); - float caustBand = sin(cUv.x * 2.25 + sin(cUv.y * 1.75 + time * 1.05 + cWobble * 4.2)) - * 0.42 + sin(dot(cUv, float2(1.62, -1.28)) * 2.95 + time * 1.22 + cWobble * 3.5) * 0.38; - return (half)saturate(smoothstep(0.08, 0.94, caustBand * 0.5 + 0.62)); + float heightUnused; + float bright; + MF_CausticFilamentField(worldXZ, time, scale, heightUnused, bright); + return (half)bright; +} + +half MF_ShoreCausticLit(float2 worldXZ, float time, float scale, float3 lightDir) +{ + float2 lightXZ = normalize(float2(lightDir.x, lightDir.z) + 1e-4); + float2 skewed = worldXZ + lightXZ * (time * 0.2); + skewed.x *= 1.0 + abs(lightXZ.x) * 0.1; + skewed.y *= 1.0 + abs(lightXZ.y) * 0.1; + return MF_ShoreCaustic(skewed, time, scale); +} + +/// Soft filament height in [0,1] — same shape as seafloor caustics. +float MF_CausticSurfaceHeight(float2 worldXZ, float time, float scale) +{ + float height; + float brightUnused; + MF_CausticFilamentField(worldXZ, time, scale, height, brightUnused); + return height; +} + +/// World-XZ normal from the shared filament height field. +float3 MF_CausticSurfaceNormal(float2 worldXZ, float time, float scale, float strength) +{ + float eps = max(0.35 / max(scale, 0.25), 0.22); + float h0 = MF_CausticSurfaceHeight(worldXZ, time, scale); + float hx = MF_CausticSurfaceHeight(worldXZ + float2(eps, 0.0), time, scale) - h0; + float hz = MF_CausticSurfaceHeight(worldXZ + float2(0.0, eps), time, scale) - h0; + float s = max(strength, 0.0); + return normalize(float3(-hx / eps * s, 1.0, -hz / eps * s)); } #endif diff --git a/Assets/Shaders/Include/ManafallWaterSurfaceFoam.hlsl b/Assets/Shaders/Include/ManafallWaterSurfaceFoam.hlsl index 168dfff63..90b3cdc69 100644 --- a/Assets/Shaders/Include/ManafallWaterSurfaceFoam.hlsl +++ b/Assets/Shaders/Include/ManafallWaterSurfaceFoam.hlsl @@ -87,6 +87,35 @@ return combined; } + // Breaking cap on the top of a wave: a solid lip, then lace trailing off the back. + float EvaluateAaaCrestFoam(Varyings input) + { + float h = max(SampleFragmentWaveHeight(input.flatPositionWS, input.shoreWaveAmpMul), (float)input.waveHeight); + float amp = max(_WaveDisplacementStrength * max((float)input.shoreWaveAmpMul, 0.4) * 0.42, 0.2); + float crest01 = smoothstep(0.16, 0.58, saturate(h / amp)); + float slope = (float)input.waveSlope; + float micro = smoothstep(0.018, 0.07, slope) * smoothstep(0.0, 0.1, h); + crest01 = max(crest01, micro * 0.8); + if (crest01 < 0.02) + return 0.0; + + float2 windXZ = _ManafallGlobalWind.xz; + float windMag = length(windXZ); + float2 windDir = windMag > 1e-4 ? windXZ / windMag : float2(0.86, 0.42); + float2 ortho = float2(-windDir.y, windDir.x); + float2 xz = input.positionWS.xz; + float t = _Time.y; + float along = dot(xz, windDir); + float across = dot(xz, ortho); + float lace = Noise2(float2(along * 0.42, across * 1.85) + float2(t * 0.07, -t * 0.05)); + float laceFine = Noise2(float2(along * 1.15, across * 3.6) + float2(-t * 0.11, t * 0.08)); + float holes = smoothstep(0.22, 0.58, lace * 0.62 + laceFine * 0.38); + float lip = pow(crest01, 1.45) * lerp(0.55, 1.0, holes); + float trail = crest01 * smoothstep(0.48, 0.82, laceFine) * (1.0 - pow(crest01, 2.2)) * 0.55; + float face = lerp(0.4, 1.0, smoothstep(0.012, 0.06, slope)); + return saturate((lip + trail) * face); + } + float SampleAdvectedFoam(float2 worldXZ) { if (_ManafallFoamWorld.w < 0.5) diff --git a/Assets/Shaders/Include/ManafallWaterSurfaceLighting.hlsl b/Assets/Shaders/Include/ManafallWaterSurfaceLighting.hlsl index d066476c1..e9c67ba18 100644 --- a/Assets/Shaders/Include/ManafallWaterSurfaceLighting.hlsl +++ b/Assets/Shaders/Include/ManafallWaterSurfaceLighting.hlsl @@ -27,35 +27,36 @@ waveN = normalize(float3(-dhdxz.x * bump, 1.0, -dhdxz.y * bump)); } - float flow = _WaveFlowSpeed; - float2 microUv = pWS.xz * _MicroWaveScale + float2(-t * flow * 0.11, t * flow * 0.09); - microUv = WindChopOffset(microUv, t); - microUv += (Noise2(microUv * 1.7 + t * 0.03) - 0.5) * 0.8; - - float rain = saturate(_ManafallRainIntensity); - if (rain > 0.01) - { - float2 rUv = pWS.xz * 2.4 + float2(t * 1.9, -t * 1.6); - float2 rCell = floor(rUv); - float2 rFrac = frac(rUv) - 0.5; - float rHash = Hash21(rCell + floor(t * 14.0)); - float ring = 1.0 - saturate(length(rFrac) * (3.2 + rHash * 2.0)); - microUv += rFrac * ring * rain * _RainRippleStrength * 0.18; - } - float m1 = dot(microUv, float2(1.02, 0.53)) * 9.2 + t * 1.12; - float m2 = dot(microUv, float2(-0.48, 0.88)) * 7.8 - t * 0.95; - float2 dm = float2(1.02, 0.53) * (9.2 * cos(m1)) + float2(-0.48, 0.88) * (7.8 * cos(m2)); float radialOpen = EvaluateRadialOpenness(float3(pWS.x, 0.0, pWS.z)); float beachMicro = EvaluateBeachMicroOpenness(float3(pWS.x, 0.0, pWS.z)); + float depthOpen = EvaluateDepthOpenness(float3(pWS.x, 0.0, pWS.z)); + float shallow01 = 1.0 - saturate(depthOpen); // Stronger micro-chop at the beach so shallows glitter instead of going matte-flat. - float microMul = lerp(0.34, 1.0, max(pow(saturate(radialOpen), 1.1), beachMicro * 0.72)) - * lerp(0.72, 1.0, (float)swellShore); - microMul *= 1.0 - distLod * 0.52; + float microMul = lerp(0.55, 1.0, max(pow(saturate(radialOpen), 1.1), beachMicro * 0.85)) + * lerp(0.85, 1.15, shallow01) + * lerp(0.85, 1.0, (float)swellShore); + microMul *= 1.0 - distLod * 0.35; float foamDetBoost = 1.0 + saturate(_ManafallFoamNoiseDetailScale) * 0.22; - float ma = _MicroWaveStrength * 0.048 * _MicroWaveScale * microMul * foamDetBoost; - float3 microN = normalize(float3(-dm.x * ma, 1.0, -dm.y * ma)); + float camDist = length(pWS.xz - _WorldSpaceCameraPos.xz); + float nearDetail = 1.0 - saturate((camDist - 3.0) / 32.0); + float detail01 = max(shallow01 * 0.7, nearDetail); + + // Top micro-waves use the SAME open-filament field as seafloor caustics + // (no rain droplet rings, no sine lattice — those read as frozen circles). + float causticClock = _ManafallCausticTime > 1e-4 ? _ManafallCausticTime : t * 1.25; + float filamentScale = max(_CausticScale, max(_MicroWaveScale, 0.35)); + float rain = saturate(_ManafallRainIntensity); + float filamentStr = _MicroWaveStrength * lerp(0.55, 1.35, detail01) * microMul * foamDetBoost; + filamentStr *= lerp(1.0, 1.0 + _RainRippleStrength * 0.55, rain); + float3 microN = MF_CausticSurfaceNormal(pWS.xz, causticClock, filamentScale, filamentStr * 1.15); + + // Shallow secondary pass — finer filaments, same topology. + float shallowAmp = lerp(0.35, 1.1, shallow01) * max(beachMicro, 0.45) * filamentStr * 0.55; + float3 shallowN = MF_CausticSurfaceNormal( + pWS.xz, causticClock * 1.15 + 1.7, filamentScale * 1.55, shallowAmp); - float3 blended = normalize(waveN + microN * 0.78); + float3 blended = normalize(waveN + (microN - float3(0.0, 1.0, 0.0)) * lerp(0.9, 1.45, shallow01)); + blended = normalize(blended + (shallowN - float3(0.0, 1.0, 0.0)) * lerp(0.25, 1.2, shallow01)); float3 chopN = MF_VisualChopNormal(pWS.xz); blended = normalize(blended + (chopN - float3(0.0, 1.0, 0.0))); @@ -78,7 +79,11 @@ float3 Ndetail = WaterNormal(input.positionWS, NgeomDisp, input.shoreWaveAmpMul); float shapeInf = saturate(_SurfaceShapeInfluence); - float3 N = normalize(lerp(Ndetail, NgeomDisp, shapeInf * 0.62 + 0.28)); + float depthOpenN = EvaluateDepthOpenness(input.flatPositionWS); + float shallow01 = 1.0 - saturate(depthOpenN); + // Was ~0.9 toward the flat mesh normal, which erased shallow ripples. + float geomW = (shapeInf * 0.42 + 0.12) * lerp(1.0, 0.22, shallow01); + float3 N = normalize(lerp(Ndetail, NgeomDisp, geomW)); float3 dpdx = ddx(input.positionWS); float3 dpdy = ddy(input.positionWS); @@ -86,7 +91,7 @@ if (derivLen > 1e-5) { float3 Nss = normalize(cross(dpdy, dpdx)); - float ssW = shapeInf * saturate(derivLen * 14.0) * 0.48; + float ssW = shapeInf * saturate(derivLen * 14.0) * lerp(0.22, 0.08, shallow01); N = normalize(lerp(N, Nss, ssW)); } diff --git a/Assets/Shaders/Include/ManafallWaterVisualChop.hlsl b/Assets/Shaders/Include/ManafallWaterVisualChop.hlsl index 707c9b6d2..0b43160ae 100644 --- a/Assets/Shaders/Include/ManafallWaterVisualChop.hlsl +++ b/Assets/Shaders/Include/ManafallWaterVisualChop.hlsl @@ -2,6 +2,7 @@ #define MANAFALL_WATER_VISUAL_CHOP_INCLUDED // GPU-only capillary chop. Amplitude is hard-capped so CPU Gerstner height stays the gameplay surface. +// Shape matches the shared filament caustic field (ManafallWaterCaustics.hlsl) — not sine rings. float _ManafallVisualChopAmp; float _ManafallVisualChopRadius; @@ -19,18 +20,18 @@ float3 MF_VisualChopDisplacement(float2 worldXZ) if (a < 1e-5) return float3(0.0, 0.0, 0.0); - float t = _Time.y; - float2 wind = _ManafallGlobalWind.xz; - float windLen = length(wind); - float2 dir = windLen > 1e-4 ? wind / windLen : float2(0.86, 0.51); - float2 ortho = float2(-dir.y, dir.x); - float p1 = dot(worldXZ, dir) * 0.62 + t * 1.65; - float p2 = dot(worldXZ, ortho) * 1.15 - t * 2.15; - float h = sin(p1) * 0.62 + sin(p2) * 0.38; - float dh1 = cos(p1) * 0.62 * 0.62; - float dh2 = cos(p2) * 0.38 * 1.15; - float2 slope = dir * dh1 + ortho * dh2; - return float3(-slope.x * a * 0.45, h * a, -slope.y * a * 0.45); + float t = _ManafallCausticTime > 1e-4 ? _ManafallCausticTime : _Time.y * 1.25; + float scale = _ManafallCausticScale > 0.01 ? _ManafallCausticScale : 0.65; + float h01 = MF_CausticSurfaceHeight(worldXZ, t, scale); + // Center around mid so displacement goes up and down along filaments. + float h = (h01 - 0.42) * 2.2; + float eps = 0.55; + float hx = MF_CausticSurfaceHeight(worldXZ + float2(eps, 0.0), t, scale) + - MF_CausticSurfaceHeight(worldXZ - float2(eps, 0.0), t, scale); + float hz = MF_CausticSurfaceHeight(worldXZ + float2(0.0, eps), t, scale) + - MF_CausticSurfaceHeight(worldXZ - float2(0.0, eps), t, scale); + float2 slope = float2(hx, hz) / (eps * 2.0); + return float3(-slope.x * a * 0.55, h * a, -slope.y * a * 0.55); } float3 MF_VisualChopNormal(float2 worldXZ) diff --git a/Assets/Shaders/ManafallMenuNightSky.shader b/Assets/Shaders/ManafallMenuNightSky.shader index 41c7bf50e..08eb8d39f 100644 --- a/Assets/Shaders/ManafallMenuNightSky.shader +++ b/Assets/Shaders/ManafallMenuNightSky.shader @@ -50,6 +50,11 @@ Shader "Manafall/MenuNightSky" float4 _MoonColor; float _StarBrightness; float _ManafallSkyNightBrightness; + // Declared before FantasyStarfield so soft-star helpers parse cleanly. + float _ManafallStarNight01; + float _ManafallStarBrightness01; + #define MANAFALL_STAR_NIGHT_DECLARED + #include "Include/ManafallFantasyStarfield.hlsl" struct appdata_t { @@ -106,9 +111,7 @@ Shader "Manafall/MenuNightSky" half moon = pow(saturate(dot((half3)dir, moonDir)), 170.0h) * (half)max(_MoonIntensity, 0.0); rgb += _MoonColor.rgb * moon; - float2 starUv = float2(atan2(dir.x, dir.z), dir.y); - float starHash = Hash21(floor(starUv * float2(48.0, 36.0))); - half star = saturate((starHash - 0.988) * 90.0) * saturate(dir.y); + half star = ManafallSoftStarfieldRaw(dir); rgb += star * (half)max(_StarBrightness, 0.0) * (1.0h - cloudAmt); half exposure = max((half)_Exposure, 0.55h); @@ -141,6 +144,10 @@ Shader "Manafall/MenuNightSky" float4 _MoonColor; float _StarBrightness; float _ManafallSkyNightBrightness; + float _ManafallStarNight01; + float _ManafallStarBrightness01; + #define MANAFALL_STAR_NIGHT_DECLARED + #include "Include/ManafallFantasyStarfield.hlsl" struct appdata_t { float4 vertex : POSITION; }; struct v2f { float4 vertex : SV_POSITION; float3 texcoord : TEXCOORD0; }; @@ -181,8 +188,7 @@ Shader "Manafall/MenuNightSky" rgb = lerp(rgb, _CloudColor.rgb * lerp(0.78h, 1.12h, saturate(dir.y)), cloudAmt * 0.88h); half3 moonDir = length(_MoonDirWS.xyz) > 1e-4 ? normalize(_MoonDirWS.xyz) : half3(0.35, 0.72, 0.4); rgb += _MoonColor.rgb * pow(saturate(dot((half3)dir, moonDir)), 170.0h) * (half)max(_MoonIntensity, 0.0); - float2 starUv = float2(atan2(dir.x, dir.z), dir.y); - half star = saturate((Hash21(floor(starUv * float2(48.0, 36.0))) - 0.988) * 90.0) * saturate(dir.y); + half star = ManafallSoftStarfieldRaw(dir); rgb += star * (half)max(_StarBrightness, 0.0) * (1.0h - cloudAmt); half skyMul = _ManafallSkyNightBrightness > 1e-4 ? max((half)_ManafallSkyNightBrightness, 0.55h) : 1.0h; rgb *= max((half)_Exposure, 0.55h) * skyMul; diff --git a/Assets/Shaders/ManafallShoreCaustics.shader b/Assets/Shaders/ManafallShoreCaustics.shader index 7052a726a..ddc343456 100644 --- a/Assets/Shaders/ManafallShoreCaustics.shader +++ b/Assets/Shaders/ManafallShoreCaustics.shader @@ -2,15 +2,17 @@ Shader "Hidden/Manafall/ShoreCaustics" { Properties { - _CausticScale ("Caustic Scale", Float) = 3.5 - _CausticSpeed ("Caustic Speed", Float) = 0.12 + _CausticScale ("Caustic Scale", Float) = 4.2 + _CausticSpeed ("Caustic Speed", Float) = 0.95 + // Bump this when the shared include changes so Unity reimports the pass. + _CausticRev ("Caustic Rev", Float) = 6 } SubShader { Tags { "RenderPipeline" = "UniversalPipeline" } Pass { - Name "ShoreCaustics" + Name "ShoreCausticsFilamentV6" ZTest Always ZWrite Off Cull Off @@ -20,109 +22,25 @@ Shader "Hidden/Manafall/ShoreCaustics" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" + #include "Include/ManafallWaterCaustics.hlsl" TEXTURE2D_X(_BlitTexture); SAMPLER(sampler_BlitTexture); float _CausticScale; float _CausticSpeed; + float _CausticRev; float _ManafallShoreCausticSeaY; float _ManafallShoreCausticStrength; float _ManafallShoreCausticBandWidth; float _ManafallShoreCausticPhase; float _ManafallShoreCausticSunElev; - float _ManafallShoreCausticVoronoiScale; float4 _ManafallSeaCoastParams; float4 _ManafallWaterWeatherParams; struct Attributes { uint vertexID : SV_VertexID; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; }; - float Hash21(float2 p) - { - return frac(sin(dot(p, float2(127.1, 311.7))) * 43758.5453); - } - - float2 Hash22(float2 p) - { - return float2(Hash21(p), Hash21(p + float2(19.1, 81.7))); - } - - float VoronoiCaustic(float2 uv) - { - float2 i = floor(uv); - float2 f = frac(uv); - float minDist = 8.0; - float2 nearestCell = float2(0.0, 0.0); - - for (int y = -1; y <= 1; y++) - { - for (int x = -1; x <= 1; x++) - { - float2 g = float2(x, y); - float2 o = Hash22(i + g); - float2 r = g + o - f; - float d = dot(r, r); - if (d < minDist) - { - minDist = d; - nearestCell = r; - } - } - } - - float edge = saturate(1.0 - sqrt(minDist) * 3.2); - float ring = saturate(1.0 - abs(length(nearestCell) - 0.38) * 9.0); - return saturate(edge * 0.55 + ring * 0.72); - } - - float LayeredVoronoiCaustic(float2 worldXZ, float time, float3 lightDir) - { - float2 coastVec = worldXZ - _ManafallSeaCoastParams.xy; - float coastDist = length(coastVec); - float2 coastDir = coastDist > 1e-3 ? coastVec / coastDist : float2(0.0, 1.0); - float2 coastTan = float2(-coastDir.y, coastDir.x); - float2 lightXZ = normalize(float2(lightDir.x, lightDir.z) + 1e-4); - - float2 uv = worldXZ * _CausticScale * 0.85; - uv += coastDir * (_ManafallShoreCausticPhase * 2.4); - uv += coastTan * sin(time * 0.85 + dot(worldXZ, coastTan) * 0.08) * 0.35; - uv += lightXZ * (time * 0.18); - - float t = time * _CausticSpeed + _ManafallShoreCausticPhase; - float v1 = VoronoiCaustic(uv * 1.15 + lightXZ * t * 0.22); - float v2 = VoronoiCaustic(uv * 2.35 - float2(t * 0.14, t * 0.11) + coastTan * 0.4); - float v3 = VoronoiCaustic(uv * 4.8 + float2(t * 0.09, -t * 0.07)); - return saturate(v1 * 0.42 + v2 * 0.34 + v3 * 0.24); - } - - float LegacySineCaustic(float2 worldXZ, float time, float3 lightDir) - { - float2 coastVec = worldXZ - _ManafallSeaCoastParams.xy; - float coastDist = length(coastVec); - float2 coastDir = coastDist > 1e-3 ? coastVec / coastDist : float2(0.0, 1.0); - float2 coastTan = float2(-coastDir.y, coastDir.x); - float2 lightXZ = normalize(float2(lightDir.x, lightDir.z) + 1e-4); - float2 uv = worldXZ * _CausticScale; - uv += coastDir * (_ManafallShoreCausticPhase * 2.4); - uv += coastTan * sin(time * 0.85 + dot(worldXZ, coastTan) * 0.08) * 0.35; - uv += lightXZ * (time * 0.18); - float t = time * _CausticSpeed + _ManafallShoreCausticPhase; - float c1 = sin(uv.x * 3.1 + t) * sin(uv.y * 2.7 - t * 0.8); - float c2 = sin((uv.x + uv.y) * 4.2 - t * 1.3); - float c3 = sin(dot(uv, lightXZ) * 5.5 - t * 1.1); - float n = frac(sin(dot(uv + t * 0.4, float2(12.9898, 78.233))) * 43758.5453); - return saturate(abs(c1) * 0.4 + abs(c2) * 0.32 + abs(c3) * 0.22 + n * 0.28); - } - - float CausticPattern(float2 worldXZ, float time, float3 lightDir) - { - float voronoiWt = saturate(_ManafallShoreCausticVoronoiScale); - float voronoi = LayeredVoronoiCaustic(worldXZ, time, lightDir); - float legacy = LegacySineCaustic(worldXZ, time, lightDir); - return lerp(legacy, voronoi, voronoiWt); - } - Varyings Vert(Attributes input) { Varyings o; @@ -143,19 +61,33 @@ Shader "Hidden/Manafall/ShoreCaustics" float3 worldPos = ComputeWorldSpacePosition(input.uv, rawDepth, UNITY_MATRIX_I_VP); float waterColumn = _ManafallShoreCausticSeaY - worldPos.y; - float band = max(_ManafallShoreCausticBandWidth, 0.5); - if (waterColumn <= 0.05 || waterColumn > band) + float band = max(_ManafallShoreCausticBandWidth, 16.0); + if (waterColumn <= 0.06 || waterColumn > band) return half4(baseCol, 1.0); - float shallow = 1.0 - saturate(waterColumn / band); - float kneeFade = smoothstep(0.02, 0.35, shallow) * (1.0 - smoothstep(0.72, 1.0, shallow)); - float sunGate = smoothstep(0.02, 0.22, _ManafallShoreCausticSunElev); - float rainDim = 1.0 - saturate(_ManafallWaterWeatherParams.x) * 0.42; + float3 dpdx = ddx(worldPos); + float3 dpdy = ddy(worldPos); + float3 floorN = normalize(cross(dpdy, dpdx)); + float floorUp = saturate(floorN.y); + float depthFade = exp(-max(waterColumn - 0.15, 0.0) / 3.1); + depthFade *= smoothstep(0.1, 0.45, waterColumn); + depthFade *= lerp(0.08, 1.0, smoothstep(0.2, 0.75, floorUp)); + float sunGate = smoothstep(0.04, 0.28, _ManafallShoreCausticSunElev); + float rainDim = 1.0 - saturate(_ManafallWaterWeatherParams.x) * 0.55; Light mainLight = GetMainLight(); - float caustic = CausticPattern(worldPos.xz, _Time.y, mainLight.direction) - * _ManafallShoreCausticStrength * kneeFade * sunGate * rainDim; - half3 tint = half3(0.35, 0.42, 0.28) * (half)caustic; - return half4(baseCol + tint, 1.0); + + // Rev-6 open filaments — same field drives surface micro-waves. + float patternScale = max(_CausticScale * 0.2, 0.5); + float time = _Time.y * max(_CausticSpeed, 0.55) + _ManafallShoreCausticPhase + _CausticRev; + float caustic = (float)MF_ShoreCausticLit(worldPos.xz, time, patternScale, mainLight.direction); + caustic = pow(saturate(caustic), 1.05); + + // Stronger lift so the web is unmistakable vs the old droplet rings. + float gain = caustic * _ManafallShoreCausticStrength * 1.35 * depthFade * sunGate * rainDim; + half3 sunRgb = max(mainLight.color.rgb, half3(0.95, 0.9, 0.75)); + half3 lifted = baseCol * (1.0 + sunRgb * (half)gain * 1.35); + lifted += sunRgb * (half)gain * 0.18; + return half4(lifted, 1.0); } ENDHLSL } diff --git a/Assets/Shaders/ManafallUnderwaterPost.shader b/Assets/Shaders/ManafallUnderwaterPost.shader index 9eedb9111..4555428e7 100644 --- a/Assets/Shaders/ManafallUnderwaterPost.shader +++ b/Assets/Shaders/ManafallUnderwaterPost.shader @@ -194,9 +194,9 @@ Shader "Hidden/Manafall/UnderwaterPost" float causticScale = _ManafallCausticScale > 0.01 ? _ManafallCausticScale : 0.65; float causticTime = _ManafallCausticTime; - half caustic = MF_ShoreCaustic(worldPos.xz, causticTime, causticScale); + half caustic = MF_ShoreCaustic(worldPos.xz, causticTime, max(causticScale, 0.45)); half causticMask = (half)saturate(1.0 - column / max(_AbsorptionDepth * 0.65, 1.0)); - col += caustic * causticMask * fade * half3(0.45h, 0.62h, 0.7h) * daylight01; + col += caustic * causticMask * fade * half3(0.55h, 0.72h, 0.78h) * daylight01 * 1.35h; if (_ManafallUnderwaterSnell > 0.5) { diff --git a/Assets/Shaders/ManafallWaterRippleScroll.shader b/Assets/Shaders/ManafallWaterRippleScroll.shader index a24eff377..c10b4422e 100644 --- a/Assets/Shaders/ManafallWaterRippleScroll.shader +++ b/Assets/Shaders/ManafallWaterRippleScroll.shader @@ -34,7 +34,11 @@ Shader "Hidden/Manafall/WaterRippleScroll" half4 Frag(Varyings input) : SV_Target { - float2 uv = input.uv - _ScrollOffset.xy; + // Center moved with the camera. Read the texel that held this world point + // before the shift. The old minus sign flung the wake off the water as you walked. + float2 uv = input.uv + _ScrollOffset.xy; + if (any(uv < 0.0) || any(uv > 1.0)) + return half4(0, 0, 0, 1); half2 v = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv).rg; return half4(v, 0, 1); } diff --git a/Assets/Shaders/ManafallWaterRippleStamp.shader b/Assets/Shaders/ManafallWaterRippleStamp.shader index 0e6f4b3c8..cbe3ec8a6 100644 --- a/Assets/Shaders/ManafallWaterRippleStamp.shader +++ b/Assets/Shaders/ManafallWaterRippleStamp.shader @@ -102,29 +102,33 @@ Shader "Hidden/Manafall/WaterRippleStamp" - float EvaluateKelvinWake(float2 delta, float2 velDir, float spreadAngle, float norm) + float EvaluateKelvinWake(float2 delta, float2 velDir, float spreadAngle) { - float2 perp = float2(-velDir.y, velDir.x); + // Arms open behind the mover. Wide enough to survive a ripple map - float along = dot(delta, velDir); + // that covers a few hundred metres. - float across = dot(delta, perp); + float2 back = -velDir; - float angle = atan2(across, along); + float2 perp = float2(-back.y, back.x); + float along = dot(delta, back); + float across = abs(dot(delta, perp)); - float lobeA = exp(-pow(abs(angle - spreadAngle) / max(spreadAngle * 0.55, 0.08), 2.0) * 3.2); + float spread = max(spreadAngle, 0.18); - float lobeB = exp(-pow(abs(angle + spreadAngle) / max(spreadAngle * 0.55, 0.08), 2.0) * 3.2); + float arm = across - along * tan(spread); - float centerLine = exp(-across * across * 48.0) * saturate(along * 3.5 + 0.15); + float crest = exp(-arm * arm * 5.5); - float vWake = max(lobeA, lobeB) * saturate(1.0 - norm * 0.35); + float behind = saturate(along * 2.4) * saturate(1.25 - along); - return max(vWake, centerLine * 0.45); + float trough = exp(-across * across * 4.0) * behind * 0.45; + + return saturate(crest * behind * 1.45 + trough); } @@ -166,11 +170,9 @@ Shader "Hidden/Manafall/WaterRippleStamp" float2 velDir = vel / velMag; - pattern = EvaluateKelvinWake(delta / rad, velDir, max(_StampVelocity.z, 0.12), norm); - - pattern *= sin(norm * _RingFrequency * 0.65 - _Time.y * _RingPhaseSpeed * 0.85 + pattern = EvaluateKelvinWake(delta / rad, velDir, max(_StampVelocity.z, 0.12)); - + dot(_StampData.xy, float2(11.7, 8.3))) * 0.5 + 0.5; + envelope = exp(-norm * norm * 1.15); } diff --git a/Assets/Shaders/ManafallWaterSurface.shader b/Assets/Shaders/ManafallWaterSurface.shader index e70580220..3d9057d4f 100644 --- a/Assets/Shaders/ManafallWaterSurface.shader +++ b/Assets/Shaders/ManafallWaterSurface.shader @@ -346,6 +346,10 @@ float _PlanarReflectSpreadMeters; float _SkyCubemapRoughnessLod; float _ManafallShoreCausticSunElev; + float _ManafallCausticTime; + float _ManafallCausticScale; + // 1 = surface draws full floor caustics; ~0.4 when the shore fullscreen pass owns the bed. + float _ManafallShaderCausticScale; float _ManafallCombatClarity01; float _ManafallOpenOceanGlare01; float _ManafallFoamNoiseDetailScale; @@ -364,8 +368,8 @@ #include "Include/ManafallWaterWavesGlobals.hlsl" #include "Include/ManafallWaterWaves.hlsl" - #include "Include/ManafallWaterVisualChop.hlsl" #include "Include/ManafallWaterCaustics.hlsl" + #include "Include/ManafallWaterVisualChop.hlsl" float _ManafallWaterInteractCount; float4 _ManafallWaterInteract[8]; @@ -458,14 +462,14 @@ float SoftenWaterColumnForOptics(float measuredColumn, float far01, bool skyOrClear) { - // Stay in the readable-shallow band near the beach; only the far end - // of the coast fade is allowed to look like open-ocean abyss. + // Sky / clear depth buffer: invent a coast→abyss column so the sheet + // still murks. Never shrink a real measured column — that turned a + // meter-deep kelp bay into a glass window down to the bed. float desired = lerp(1.35, 36.0, far01); if (skyOrClear) return desired; - float measured = max(measuredColumn, 0.0); - return lerp(min(measured, desired), measured, far01); + return max(measuredColumn, 0.0); } /// @@ -594,7 +598,7 @@ { // Depth damp softens large swell in puddles, but never zeros beach micro-waves. float shoreOpen = EvaluateShoreWaveOpenness(flatWorld); - float depthOpen = max(EvaluateDepthOpenness(flatWorld), 0.38); + float depthOpen = max(EvaluateDepthOpenness(flatWorld), 0.62); float openness = saturate(min(shoreOpen, depthOpen)); // Prefer shore micro floor when depth sample is noisy/shallow. openness = max(openness, shoreOpen * 0.85); @@ -619,6 +623,9 @@ float radial = EvaluateRadialOpenness(flatWorld); float openness = EvaluateWorldCoastRingOpenness(flatWorld); float depthOpen = EvaluateDepthOpenness(flatWorld); + // Shelf water (a few meters) used to inherit puddle damp and go pane-flat. + // Only a film of water should lose its swell. + depthOpen = max(depthOpen, 0.62); openness = min(openness, depthOpen); float swell01 = saturate(_ManafallSeaStormWaveParams.x); float coastCalm = lerp(1.0, 0.32, swell01 * (1.0 - radial)); @@ -667,9 +674,10 @@ return baseUv; float2 windDir = windXZ / windMag; - float windStr = _ManafallGlobalWind.w * _WindChopInfluence * 0.28; - float2 ortho = float2(-windDir.y, windDir.x); - return baseUv + windDir * t * windStr * 0.11 + ortho * sin(dot(baseUv, windDir) * 3.1 - t * 1.7) * windStr * 0.04; + // Steady crawl. Live gust strength must not multiply _Time.y: + // a gust raises that product and the lines race, then the gust + // fades and the same lines slide back. + return baseUv + windDir * t * 0.035; } float3 ApplyWaterSurfaceDisplacement(float3 flatWorldPos, half waveEffMul) @@ -947,11 +955,13 @@ float waterColumnRaw = waterColumn; float opticsFar01 = EvaluateCoastalOpticsFar01(coastSD, shoreOpen); float opticsColumn = SoftenWaterColumnForOptics(waterColumnRaw, opticsFar01, skyOrClear); - // Longer clear band near shore — shallow stays teal far past the first shelf drop. - float absorbDist = depthFade * lerp(1.55, 2.35, readability * 0.55) * lerp(2.65, 1.0, opticsFar01); - float shallowAbsorb = 1.0 - exp(-(opticsColumn / max(absorbDist, 1e-3)) * _AbsorptionStrength); + // Bed murk: a few meters of water should already hide the floor. + // Old absorbDist (~30–70 m) left kelp and sand lit all the way down. + float absorbDist = depthFade * lerp(0.42, 0.72, readability * 0.4) * lerp(1.15, 1.0, opticsFar01); + absorbDist = max(absorbDist, 2.8); + float shallowAbsorb = 1.0 - exp(-(opticsColumn / max(absorbDist, 1e-3)) * max(_AbsorptionStrength, 0.85)); half shallowMask = saturate(shallowAbsorb); - half deepMask = pow(shallowMask, lerp(1.18, 1.45, readability * 0.55)); + half deepMask = pow(shallowMask, lerp(0.85, 1.15, readability * 0.4)); half3 shallowLit = _ShallowColor.rgb * (half)lerp(1.0, 1.14, readability * (1.0 - (float)deepMask)); half3 waterRgb = lerp(shallowLit, _DeepColor.rgb, deepMask); @@ -1039,6 +1049,30 @@ half3 sceneRgb = lerp(chromRgb, blurRgb, blurMix); half3 refrRgb = sceneRgb * _RefractionTint.rgb; + // Sun nets on the bed, carried in the color you see through the sheet. + // Reconstruct bed XZ from refracted depth so the web sticks to sand, not the sheet. + half floorSun = (half)smoothstep(0.02, 0.2, _ManafallShoreCausticSunElev); + float2 floorXZ = input.flatPositionWS.xz; + if (hasOpaqueBehind > 0.5h && rawDepth < 0.9995) + { + float refrBedDepth = SampleSceneDepth(refrUv); + float3 bedWS = ComputeWorldSpacePosition(refrUv, refrBedDepth, UNITY_MATRIX_I_VP); + floorXZ = bedWS.xz; + } + // Livelier clock than surface drift — otherwise filaments look frozen. + float causticClock = _ManafallCausticTime > 1e-4 ? _ManafallCausticTime : wtMw * 2.4; + half floorCaust = MF_ShoreCaustic(floorXZ, causticClock, max(_CausticScale, 0.45)); + half floorDepth = (half)exp(-max(opticsColumn, 0.0) / 3.2); + half shaderCaustScale = _ManafallShaderCausticScale > 1e-4 + ? (half)_ManafallShaderCausticScale + : 1.0h; + floorCaust *= floorSun * floorDepth * hasOpaqueBehind + * (half)saturate(_CausticStrength) * shaderCaustScale; + // Additive filament lift — bright threads on the bed, not a pale multiply. + half3 causticSun = half3(1.0h, 0.96h, 0.88h); + refrRgb += causticSun * floorCaust * 0.85h; + refrRgb *= 1.0h + floorCaust * 0.28h; + half washN = Noise2(driftXZ * 2.08 + wtMw * float2(0.051, -0.047)); half washAmt = saturate(_RefractionDarkWash) * hasOpaqueBehind * (0.22h + grazingMw * 0.58h); refrRgb *= lerp(half3(1, 1, 1), lerp(half3(0.62h, 0.72h, 0.82h), half3(1, 1, 1), washN), washAmt); @@ -1052,11 +1086,16 @@ float measuredDeep = saturate((waterColumnRaw - 3.2) / 14.0); cliffHide = (half)(measuredDeep * (1.0 - opticsFar01)); } - half refrAmt = saturate(_RefractionDeepBlend * hasOpaqueBehind * saturate(1.0 - exp(-opticsColumn / max(depthFade * 6.0, 0.5)))); + // How much of the bed shows through. Falls off by a few meters of water. + float bedSeeDist = max(depthFade * 0.28, 2.4); + half bedClear = (half)exp(-opticsColumn / bedSeeDist); + half refrAmt = saturate(_RefractionDeepBlend * hasOpaqueBehind * bedClear); refrAmt *= (1.0h - cliffHide * 0.84h); refrAmt *= saturate(1.0 - distCam * 0.00035 * _RefractionDistanceFade); float distMurkFade = saturate(distCam / max(_DistanceMurkStart, 120.0)); refrAmt *= (half)lerp(1.0, 0.72, distMurkFade * _RefractionDistanceFade); + // Deep water is the body color, not the lit sand. + refrAmt *= (1.0h - deepMask * 0.72h); half3 baseCol = lerp(waterRgb, refrRgb, refrAmt); @@ -1164,6 +1203,11 @@ reflAmt *= crestReflBoost * max(_ManafallNightReflectScale, 1e-4); // After the floor: ease the sky-mirror so a seafloor drop does not flash white. reflAmt *= lerp(0.38h, 1.0h, (half)opticsFar01); + // Near and shallow water keeps its body color so the ripples can shade. + // A full sky mirror turned the bay into a flat pale sheet. + float nearShelf = 1.0 - saturate(distCam / 48.0); + nearShelf = max(nearShelf, 1.0 - saturate(EvaluateDepthOpenness(input.flatPositionWS))); + reflAmt *= lerp(1.0h, 0.42h, (half)nearShelf); reflAmt = saturate(reflAmt * 1.08h); half3 reflCol = skyRgb * reflAmt * (0.62h + 0.38h * shadow); @@ -1311,12 +1355,15 @@ peakFoam *= (half)lerp(1.0, 0.45, stormSwell01 * (1.0 - shoreOpen)); foam = saturate(foam + peakFoam * 1.15h); - half rippleFoam = (half)saturate(SampleRippleFieldHeight(input.flatPositionWS.xz) - / max(_RippleFieldHeightScale, 1e-3)); - rippleFoam *= (half)(1.0 - shallowMask * 0.55); + // Crest cap. Not gated by shore or depth — those were erasing the foam on the bay. + half aaaCrest = (half)EvaluateAaaCrestFoam(input) * (half)saturate(max(_CrestFoamStrength, 0.65)); + foam = saturate(foam + aaaCrest); + + half wakeH = (half)saturate(SampleRippleFieldHeight(input.flatPositionWS.xz) + / max(_RippleFieldHeightScale * 0.55, 0.04)); half wakeEdgeFoam = (half)EvaluateWakeEdgeFoam(input.flatPositionWS.xz); - wakeEdgeFoam *= (half)(1.0 - shallowMask * 0.4); - foam = saturate(foam + rippleFoam * 0.18h + wakeEdgeFoam * 0.65h); + // Only the crest of a moving wake. A piled stamp must not paint a white hill. + foam = saturate(foam + smoothstep(0.42h, 0.82h, wakeH) * 0.55h + wakeEdgeFoam * 0.45h); // Quality wake specular (global from ManafallWaterQuality.wakeSpecularScale). half wakeSpecMul = (half)lerp(1.0, 1.0 + saturate(_ManafallWakeSpecular01) * 0.55, wakeEdgeFoam); specRgb *= wakeSpecMul; @@ -1377,48 +1424,32 @@ half3 sssCol = _ShallowColor.rgb * 1.6h + half3(0.04h, 0.12h, 0.10h); diffuseLit += sssCol * sssView * sssCrest * shadow * cloudSh * mainLight.color * 0.35h * (1.0h - deepMask * 0.5h); - half caustLight = MF_ShoreCaustic(input.positionWS.xz, wtMw, _CausticScale); + float causticClockLit = _ManafallCausticTime > 1e-4 ? _ManafallCausticTime : wtMw * 2.4; + half caustLight = MF_ShoreCaustic(input.positionWS.xz, causticClockLit, max(_CausticScale, 0.45)); half sunGateCaust = (half)smoothstep(0.02, 0.22, _ManafallShoreCausticSunElev); - caustLight *= sunGateCaust; + half shaderCaustScaleLit = _ManafallShaderCausticScale > 1e-4 + ? (half)_ManafallShaderCausticScale + : 1.0h; + caustLight *= sunGateCaust * shaderCaustScaleLit; half caustMask = shallowMask * saturate(1.h - deepMask * 0.75h) * saturate(1.h - foam * 0.92h) * hasOpaqueBehind; diffuseLit += caustLight * caustMask * _CausticStrength * mainLight.color * shadow * cloudSh - * (_HorizonColor.rgb * 0.38h + _ZenithColor.rgb * 0.22h + half3(0.85h, 0.92h, 1.0) * 0.25h); + * half3(0.55h, 0.62h, 0.58h) * 0.4h; specRgb *= lerp(1.0h, 1.0h - foam, _SpecularOcclusionByFoam); - float2 glUv = input.positionWS.xz * _GlintScale * 0.1 + float2(_Time.y * _GlintSpeed * 0.28, -_Time.y * _GlintSpeed * 0.22); - float2 gCell = floor(glUv); - float2 glCenter = frac(glUv) - 0.5; - float2 tKey = float2(_Time.y * _GlintSpeed * 1.55, floor(_Time.y * _GlintSpeed * 2.85)); - - float hRot = Hash21(gCell * 1.03 + tKey * float2(0.17, 0.29)) * 6.2831853; - float hStr = 0.58 + Hash21(gCell * 1.91 + tKey.yx * 0.21) * 0.82; - float hSkew = (Hash21(gCell * 2.31 + tKey * 0.37) - 0.5) * 1.25; - - float cr = cos(hRot), sr = sin(hRot); - float2 pr = float2(cr * glCenter.x - sr * glCenter.y, sr * glCenter.x + cr * glCenter.y); - pr.x *= hStr; - pr.y /= max(hStr, 0.32); - pr.x += pr.y * hSkew * 0.38; - - float gh = Hash21(gCell + floor(_Time.y * _GlintSpeed * 6.0)); - float hFreq = 4.8 + Hash21(gCell * 3.17 + float2(gh, tKey.x)) * 4.6; - float2 wob = (Hash22(gCell * 2.71 + tKey * 0.19) - 0.5) * 0.22; - pr += wob * (0.35 + gh); - - float gPh = dot(pr, float2(1.618, -1.427)) * hFreq + _Time.y * _GlintSpeed * (2.1 + gh * 2.9) + gh * 6.2831853; - float gPh2 = dot(pr * float2(-0.93, 1.08), float2(1.12, 1.63)) * hFreq * 0.74 + _Time.y * _GlintSpeed * (1.65 + gh * 2.1); - float hMix = Hash21(gCell * 1.47 + float2(gh * 3.1, tKey.y)); - float wave = lerp(sin(gPh), sin(gPh2), hMix * 0.62); - - float hShape = 0.72 + Hash21(gCell * 5.03 + float2(gh * 2.1, tKey.y)) * 0.62; - float gSp = saturate(wave * hShape - (1.12 - gh * 0.42) + gh * 1.22); - gSp = gSp * gSp * smoothstep(0.05, 1.0, gh); + // Sun glitter follows the shared filament wave field (same shape as bed caustics), + // not cell discs that read as frozen droplet rings on the sheet. + float glintClock = _ManafallCausticTime > 1e-4 ? _ManafallCausticTime : _Time.y * _GlintSpeed * 2.2; + float glintScale = max(_CausticScale, _GlintScale * 0.012); + float filamentH = MF_CausticSurfaceHeight(input.positionWS.xz, glintClock, glintScale); + float filamentBright = (float)MF_ShoreCaustic(input.positionWS.xz, glintClock, glintScale); + float gSp = saturate(filamentH * 1.35 + filamentBright * 0.55 - 0.35); + gSp = gSp * gSp; half glintMask = saturate(fresnel * shadow * cloudSh * (1.0 - foam)); - // Stable sun glitter: tighter contribution + separate grazing sparkle (was dead property). - specRgb += mainLight.color * (gSp * _GlintIntensity * glintMask * 0.46h); - half sparkle = (half)(gSp * saturate(_SunSparkleStrength) * pow((float)grazingEase, 1.35) * (0.5 + 0.5 * (float)sunFacing)); - specRgb += mainLight.color * sparkle * glintMask * 0.34h; + specRgb += mainLight.color * ((half)gSp * _GlintIntensity * glintMask * 0.5h); + half sparkle = (half)(gSp * saturate(_SunSparkleStrength) * pow((float)grazingEase, 1.35) + * (0.5 + 0.5 * (float)sunFacing)); + specRgb += mainLight.color * sparkle * glintMask * 0.32h; specRgb += half3(0.92h, 0.97h, 1.05h) * sparkle * glintMask * 0.08h; float dmStart = max(_DistanceMurkStart, 1.0); @@ -1590,6 +1621,29 @@ return half4(s, c, lod, 1.0h); } + // Wide sun path. The tight GGX lobe only hits when the camera stares at the disc, + // so a calm sea read as a flat grey plate. + float3 Hsheen = normalize(V + L); + half sheen = pow(saturate(dot(Nopt, Hsheen)), 18.0h); + half crestVar = saturate((half)input.waveSlope * 4.0h); + color *= lerp(0.92h, 1.06h, crestVar); + color += mainLight.color * sheen * shadow * (0.42h + fresnel * 0.35h); + // Sky fresnel where the sun is actually sitting on the sheet. Off the glint, + // the pale sky wash was painting the bay solid. + half mirror = saturate(sheen * 2.2h + glareHot); + color = lerp(color, skyRgb, fresnel * lerp(0.28h, 0.05h, (half)nearShelf) * lerp(0.28h, 1.0h, mirror)); + + // Thin water can show a little of the bed. Deeper water closes the window. + half columnSee = (half)exp(-opticsColumn / max(_DepthFade * 0.35, 3.2)); + half transmit = (1.0h - mirror) * lerp(0.45h, 1.0h, saturate(ndotv * 1.35h)); + half seeThru = hasOpaqueBehind * columnSee * transmit * (1.0h - foam) * (1.0h - deepMask); + half3 bed = lerp(refrRgb * 0.7h, waterRgb, 0.55h); + color = lerp(color, bed, seeThru * 0.45h); + // Deeper = more opaque. Thin clear water only where columnSee is high. + half depthAlpha = lerp(0.96h, 0.55h, columnSee * (1.0h - deepMask)); + alpha = max(alpha, depthAlpha); + alpha = lerp(alpha, min(alpha, 0.62h), seeThru * 0.35h); + // Fragment fog from eye distance — vertex fogFactor is unreliable with // multi_compile_fragment FOG_LINEAR (keyword not declared in Vert). half fogIntensity = 1.0h; @@ -1606,10 +1660,16 @@ if (unity_FogParams.z < 0.0) fogIntensity = (half)saturate(nearToFarZ * unity_FogParams.z + unity_FogParams.w); #endif - color = lerp(half3(unity_FogColor.rgb), color, fogIntensity); - // Transparent water + alpha < 1 composites unfogged sky (cyan) behind the sheet. - // At fog end force opaque fog colour so the coast rim cannot reopen. - alpha = lerp(1.0h, alpha, fogIntensity); + // Full MixFog replaced the whole sheet with fog colour. Keep body, sheen, and sky. + // Do not force a 0.9 alpha floor — that painted the bay solid wherever the sun was not glinting. + half fogKeep = fogIntensity; + half hold = lerp(0.92h, 0.7h, saturate(nearToFarZ / 240.0)); + hold = lerp(hold, 0.88h, seeThru); + half through = lerp(hold, 1.0h, fogKeep); + through = max(through, saturate(sheen * 1.6h + fresnel * 0.4h)); + half3 fogTint = half3(unity_FogColor.rgb) * half3(0.78h, 0.84h, 0.94h); + color = lerp(fogTint, color, through); + alpha = saturate(alpha); return half4(color, alpha); } ENDHLSL @@ -1618,4 +1678,4 @@ FallBack "Universal Render Pipeline/Lit" } -// manafall-water-compile-stamp: d3d11-visual-upgrade-v26-crest-sss +// manafall-water-compile-stamp: d3d11-visual-upgrade-v29-depth-murk diff --git a/Assets/Shaders/ManafallWeatherSky.shader b/Assets/Shaders/ManafallWeatherSky.shader index 47ffb80c5..89657e54e 100644 --- a/Assets/Shaders/ManafallWeatherSky.shader +++ b/Assets/Shaders/ManafallWeatherSky.shader @@ -25,7 +25,9 @@ Shader "Manafall/WeatherSky" // That file closed two include-based Passes then left a duplicate inline // HLSLPROGRAM body + stray '}' (line 329) → empty shader name, Shader.Find failed. // Do not #include ManafallAurora.hlsl here — its old '#' comments broke compile. - // UnityCG only. + // UnityCG + soft point-starfield (no floor-cell squares). + // Star uniforms must be declared BEFORE FantasyStarfield — the include's star + // helpers reference them at parse time. Declaring after → compile fail → magenta sky. CGINCLUDE #include "UnityCG.cginc" @@ -55,6 +57,9 @@ Shader "Manafall/WeatherSky" float _ManafallStarBrightness01; float _ManafallAurora01; + #define MANAFALL_STAR_NIGHT_DECLARED + #include "Include/ManafallFantasyStarfield.hlsl" + struct appdata_t { float4 vertex : POSITION; @@ -118,7 +123,9 @@ Shader "Manafall/WeatherSky" float n = ValueNoise(uv * 3.6) * 0.65 + ValueNoise(uv * 7.4 + 11.0) * 0.35; half cov = saturate(_CloudCoverage); half cloudAmt = saturate((n - (1.0 - cov) * 0.40) * (1.2 + cov)); - cloudAmt *= saturate(0.22 + saturate(dir.y + 0.05) * 1.15); + half elevGate = saturate(0.18h + saturate(dir.y + 0.02h) * 0.85h); + elevGate *= 1.0h - saturate((dir.y - 0.35h) * 2.4h) * 0.55h; + cloudAmt *= elevGate; cloudAmt = saturate(cloudAmt * (half)max(_CloudDensity, 0.4)); // Night: keep sheet clouds. Day: thin them if volumes are the hero. @@ -127,7 +134,7 @@ Shader "Manafall/WeatherSky" if (day01 > 0.38h) cloudAmt *= (1.0h - volStrength * 0.40h); - half3 cloudLit = cloudCol * lerp(0.88h, 1.18h, saturate(dir.y)); + half3 cloudLit = cloudCol * lerp(0.88h, 1.02h, saturate(dir.y)); half3 rgb = lerp(baseCol, cloudLit, cloudAmt); float3 sunDir = length(_SunDirWS.xyz) > 1e-4 @@ -146,12 +153,10 @@ Shader "Manafall/WeatherSky" half phase = saturate(abs(_MoonPhase01 - 0.5h) * 2.0h); rgb += _MoonColor.rgb * moon * max(phase, 0.35h); - float2 starUv = float2(atan2(dir.x, dir.z), dir.y); - float starHash = Hash21(floor(starUv * float2(48.0, 36.0))); - half star = saturate((starHash - 0.988) * 90.0) * saturate(dir.y); + half star = ManafallSoftStarfieldRaw(dir); half starGate = max(saturate(_ManafallStarNight01), day01 < 0.38h ? 1.0h : 0.0h); - half starBright = max(_StarBrightness, saturate(_ManafallStarBrightness01) * 2.0); - rgb += star * (half)starBright * starGate * (1.0h - cloudAmt); + half starBright = max(_StarBrightness, saturate(_ManafallStarBrightness01) * 2.0h); + rgb += star * starBright * starGate * (1.0h - cloudAmt); half auroraGate = saturate(_ManafallAurora01); if (auroraGate > 1e-4h && day01 < 0.42h) diff --git a/Assets/Shaders/ManafallWeatherSkyPass.hlsl b/Assets/Shaders/ManafallWeatherSkyPass.hlsl index 50ff87bf2..bc119baf3 100644 --- a/Assets/Shaders/ManafallWeatherSkyPass.hlsl +++ b/Assets/Shaders/ManafallWeatherSkyPass.hlsl @@ -2,6 +2,10 @@ #define MANAFALL_WEATHER_SKY_PASS_INCLUDED #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" +// Star night uniforms before FantasyStarfield so helpers parse (same trap as WeatherSky.shader). +float _ManafallStarNight01; +float _ManafallStarBrightness01; +#define MANAFALL_STAR_NIGHT_DECLARED #include "Include/ManafallFantasyStarfield.hlsl" #include "Include/ManafallAurora.hlsl" #include "Include/ManafallCelestialPlanets.hlsl" @@ -277,7 +281,11 @@ half4 Frag(Varyings input) : SV_Target // Soft then harden — solid storm lobes, not gauze. half cloudAmt = saturate((n - (1.0 - cov) * 0.32) * (1.55 + cov * 1.1)); cloudAmt = saturate(cloudAmt * cloudAmt * (3.0h - 2.0h * cloudAmt)); - cloudAmt *= saturate(0.28 + saturate(dir.y + 0.05) * 1.2); + // Keep sheet clouds off the extreme zenith rim — that read as a white fog band + // across the top of the screen on clear / light-cover days. + half elevGate = saturate(0.18h + saturate(dir.y + 0.02h) * 0.85h); + elevGate *= 1.0h - saturate((dir.y - 0.35h) * 2.4h) * 0.55h; + cloudAmt *= elevGate; cloudAmt = saturate(cloudAmt * (half)max(_CloudDensity, 0.55)); half day01 = saturate(_ManafallAtmosphereDaylight); @@ -318,7 +326,8 @@ half4 Frag(Varyings input) : SV_Target sunTint = sunTint / sunPeak; half litFace = saturate(mu * 0.7h + 0.32h); - half3 cloudLit = cloudCol * lerp(0.88h, 1.18h, saturate(dir.y)); + // Cap overhead lift so sheet lobes stay tinted, not chalk-white at screen-top. + half3 cloudLit = cloudCol * lerp(0.88h, 1.02h, saturate(dir.y)); // Wash sheet clouds with live sun colour so menu time-of-day tint reads on the deck. cloudLit *= lerp(half3(1.0h, 1.0h, 1.0h), sunTint, 0.42h + 0.38h * day01 * litFace); cloudLit += sunTint * pow(mu, 3.4h) * lerp(0.18h, 0.55h, day01) * (1.0h - cloudAmt * 0.2h); @@ -370,10 +379,9 @@ half4 Frag(Varyings input) : SV_Target starGate *= (1.0h - saturate((L.y - 0.02h) / 0.20h)); if (starGate > 1e-4h) { - half3 starsAccum = ManafallFantasyStarfield((half3)dir, 0.0h); + half3 starsAccum = ManafallFantasyStarfield((half3)dir, cloudAmt); + starsAccum *= (half)max(_StarBrightness, 1.0); starsAccum = min(starsAccum, (half3)4.0); - half extraStar = saturate((ManafallSkyHash21(floor(float2(atan2(dir.x, dir.z), dir.y) * float2(48.0, 36.0))) - 0.988) * 90.0) * saturate(dir.y); - starsAccum += extraStar * (half)max(_StarBrightness, 0.0) * (1.0h - cloudAmt); plate += starsAccum * starGate; } diff --git a/Assets/ThirdParty/DownloadedAssets/Druclad.meta b/Assets/ThirdParty/DownloadedAssets/Druclad.meta new file mode 100644 index 000000000..cfbe334a9 --- /dev/null +++ b/Assets/ThirdParty/DownloadedAssets/Druclad.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2c0ead4c993b0744cb22cdad4ba3f115 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ThirdParty/DownloadedAssets/Druclad/Clips.meta b/Assets/ThirdParty/DownloadedAssets/Druclad/Clips.meta new file mode 100644 index 000000000..78f6cfcbc --- /dev/null +++ b/Assets/ThirdParty/DownloadedAssets/Druclad/Clips.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b5d827ef05534929990505e73271f39e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ThirdParty/DownloadedAssets/Druclad/Clips/README.md b/Assets/ThirdParty/DownloadedAssets/Druclad/Clips/README.md new file mode 100644 index 000000000..160e3990b --- /dev/null +++ b/Assets/ThirdParty/DownloadedAssets/Druclad/Clips/README.md @@ -0,0 +1,14 @@ +# Druclad Mixamo clips + +Preferred workflow: + +1. Upload `../Druclad_MixamoRigged.fbx` to Mixamo. +2. Download the kit listed in `DrucladAnimCatalog` as **FBX for Unity (with skin)**. +3. Drop the FBXs into this folder using the preferred filenames from the catalog + (`Monster Idle.fbx`, `Roaring.fbx`, `Jump Attack.fbx`, etc.). +4. In Unity run **Manafall → Creatures → Setup Druclad** (or Animations Only). + That retargets every clip onto the Druclad Humanoid avatar and rebuilds + `Assets/Animation/Druclad.controller`. + +Until dedicated Mixamo downloads land here, Setup copies the closest project +Mixamo fallbacks (skeleton / samurai packs) into this folder automatically. diff --git a/Assets/ThirdParty/DownloadedAssets/Druclad/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture_fbx.meta b/Assets/ThirdParty/DownloadedAssets/Druclad/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture_fbx.meta new file mode 100644 index 000000000..e2b3d5319 --- /dev/null +++ b/Assets/ThirdParty/DownloadedAssets/Druclad/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture_fbx.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6b3519423d53b1a4087a0ace00f20022 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ThirdParty/DownloadedAssets/Druclad/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture_fbx/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture.fbx.meta b/Assets/ThirdParty/DownloadedAssets/Druclad/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture_fbx/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture.fbx.meta new file mode 100644 index 000000000..813b1edaa --- /dev/null +++ b/Assets/ThirdParty/DownloadedAssets/Druclad/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture_fbx/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture.fbx.meta @@ -0,0 +1,110 @@ +fileFormatVersion: 2 +guid: 03f4825c2e4bb3e46af1fc89af851d7c +ModelImporter: + serializedVersion: 24200 + internalIDToNameTable: [] + externalObjects: {} + materials: + materialImportMode: 2 + materialName: 0 + materialSearch: 1 + materialLocation: 1 + animations: + legacyGenerateAnimations: 4 + bakeSimulation: 0 + resampleCurves: 1 + optimizeGameObjects: 0 + removeConstantScaleCurves: 0 + motionNodeName: + animationImportErrors: + animationImportWarnings: + animationRetargetingWarnings: + animationDoRetargetingWarnings: 0 + importAnimatedCustomProperties: 0 + importConstraints: 0 + animationCompression: 1 + animationRotationError: 0.5 + animationPositionError: 0.5 + animationScaleError: 0.5 + animationWrapMode: 0 + extraExposedTransformPaths: [] + extraUserProperties: [] + clipAnimations: [] + isReadable: 0 + meshes: + lODScreenPercentages: [] + globalScale: 1 + meshCompression: 0 + addColliders: 0 + useSRGBMaterialColor: 1 + sortHierarchyByName: 1 + importPhysicalCameras: 1 + importVisibility: 1 + importBlendShapes: 1 + importCameras: 1 + importLights: 1 + nodeNameCollisionStrategy: 1 + fileIdsGeneration: 2 + swapUVChannels: 0 + generateSecondaryUV: 0 + useFileUnits: 1 + keepQuads: 0 + weldVertices: 1 + bakeAxisConversion: 0 + preserveHierarchy: 0 + skinWeightsMode: 0 + maxBonesPerVertex: 4 + minBoneWeight: 0.001 + optimizeBones: 1 + generateMeshLods: 0 + meshLodGenerationFlags: 0 + maximumMeshLod: -1 + meshOptimizationFlags: -1 + indexFormat: 0 + secondaryUVAngleDistortion: 8 + secondaryUVAreaDistortion: 15.000001 + secondaryUVHardAngle: 88 + secondaryUVMarginMethod: 1 + secondaryUVMinLightmapResolution: 40 + secondaryUVMinObjectScale: 1 + secondaryUVPackMargin: 4 + useFileScale: 1 + strictVertexDataChecks: 0 + tangentSpace: + normalSmoothAngle: 60 + normalImportMode: 0 + tangentImportMode: 3 + normalCalculationMode: 4 + legacyComputeAllNormalsFromSmoothingGroupsWhenMeshHasBlendShapes: 0 + blendShapeNormalImportMode: 1 + normalSmoothingSource: 0 + referencedClips: [] + importAnimation: 1 + humanDescription: + serializedVersion: 3 + human: [] + skeleton: [] + armTwist: 0.5 + foreArmTwist: 0.5 + upperLegTwist: 0.5 + legTwist: 0.5 + armStretch: 0.05 + legStretch: 0.05 + feetSpacing: 0 + globalScale: 1 + rootMotionBoneName: + hasTranslationDoF: 0 + hasExtraRoot: 0 + skeletonHasParents: 1 + lastHumanDescriptionAvatarSource: {instanceID: 0} + autoGenerateAvatarMappingIfUnspecified: 1 + animationType: 2 + humanoidOversampling: 1 + avatarSetup: 0 + addHumanoidExtraRootOnlyWhenUsingAvatar: 1 + importBlendShapeDeformPercent: 1 + remapMaterialsIfMaterialImportModeIsNone: 0 + additionalBone: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ThirdParty/DownloadedAssets/Druclad/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture_fbx/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture.png.meta b/Assets/ThirdParty/DownloadedAssets/Druclad/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture_fbx/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture.png.meta new file mode 100644 index 000000000..ecd8c19a6 --- /dev/null +++ b/Assets/ThirdParty/DownloadedAssets/Druclad/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture_fbx/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture.png.meta @@ -0,0 +1,130 @@ +fileFormatVersion: 2 +guid: c73bdbd42e28aaf4090b71d1f580b10e +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Android + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ThirdParty/DownloadedAssets/Druclad/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture_fbx/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture_metallic.png.meta b/Assets/ThirdParty/DownloadedAssets/Druclad/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture_fbx/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture_metallic.png.meta new file mode 100644 index 000000000..ee2624790 --- /dev/null +++ b/Assets/ThirdParty/DownloadedAssets/Druclad/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture_fbx/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture_metallic.png.meta @@ -0,0 +1,130 @@ +fileFormatVersion: 2 +guid: fd0fe63a6c25da24184afaf90fa6fbc3 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Android + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ThirdParty/DownloadedAssets/Druclad/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture_fbx/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture_normal.png.meta b/Assets/ThirdParty/DownloadedAssets/Druclad/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture_fbx/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture_normal.png.meta new file mode 100644 index 000000000..800cb46d5 --- /dev/null +++ b/Assets/ThirdParty/DownloadedAssets/Druclad/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture_fbx/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture_normal.png.meta @@ -0,0 +1,130 @@ +fileFormatVersion: 2 +guid: 85a2f10fc72197247a32f4a63d238274 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Android + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ThirdParty/DownloadedAssets/Druclad/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture_fbx/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture_roughness.png.meta b/Assets/ThirdParty/DownloadedAssets/Druclad/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture_fbx/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture_roughness.png.meta new file mode 100644 index 000000000..79e1207d5 --- /dev/null +++ b/Assets/ThirdParty/DownloadedAssets/Druclad/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture_fbx/Meshy_AI_Horned_Abyssal_Demon_1020014627_texture_roughness.png.meta @@ -0,0 +1,130 @@ +fileFormatVersion: 2 +guid: 5c0d6ab90423db34b9980da30e3c41a1 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Android + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ThirdParty/DownloadedAssets/Druclad/README_MIXAMO.md b/Assets/ThirdParty/DownloadedAssets/Druclad/README_MIXAMO.md new file mode 100644 index 000000000..51ab698f7 --- /dev/null +++ b/Assets/ThirdParty/DownloadedAssets/Druclad/README_MIXAMO.md @@ -0,0 +1,33 @@ +# Druclad → Mixamo / Unity + +## What you have now + +| File | Role | +|------|------| +| `Meshy_AI_Horned_Abyssal_Demon_…_texture.fbx` | Original Meshy mesh (no bones) | +| **`Druclad_MixamoRigged.fbx`** | Blender-built Mixamo-named armature + automatic skin weights | +| `rig_druclad_mixamo.py` | Script used to generate the rigged FBX | + +## Use in Mixamo + +1. Upload **`Druclad_MixamoRigged.fbx`** to [Mixamo](https://www.mixamo.com/). +2. Because Mixamo bone names are already present, you should skip marker placement and go straight to animations. +3. Download clips as **FBX for Unity** (with skin). + +If Mixamo still complains about proportions (horns / non-human shape), use Meshy Auto Rig → Skeleton Template **Mixamo** instead — that AI placement is usually better than a bbox biped. + +## Use in Unity + +1. Import `Druclad_MixamoRigged.fbx` (keep the PNG textures in the same folder / assign materials). +2. Rig tab → Animation Type **Humanoid** → Apply. +3. Open Configure Avatar and verify Hips / spine / limbs look sane; fix any red bones. +4. Drop Mixamo animation FBXs in and set Avatar Definition → **Copy From Other Avatar** → Druclad’s Avatar. + +## Re-run the Blender rig + +```text +blender --background --python Assets/ThirdParty/DownloadedAssets/Druclad/rig_druclad_mixamo.py -- ^ + +``` + +This is a **heuristic biped** (hips/spine/limbs/hands from mesh bounds + automatic weights). It is good enough for Mixamo mapping and first-pass Unity Humanoid; tweak bone placement in Blender if elbows/knees look wrong. diff --git a/Assets/ThirdParty/DownloadedAssets/Druclad/rig_druclad_mixamo.py b/Assets/ThirdParty/DownloadedAssets/Druclad/rig_druclad_mixamo.py new file mode 100644 index 000000000..2dc6104ac --- /dev/null +++ b/Assets/ThirdParty/DownloadedAssets/Druclad/rig_druclad_mixamo.py @@ -0,0 +1,228 @@ +""" +Headless Blender: build a Mixamo-named biped armature on the Druclad mesh +and bind with automatic weights, then export FBX for Mixamo / Unity Humanoid. + +Usage: + blender --background --python rig_druclad_mixamo.py -- +""" +from __future__ import annotations + +import math +import sys +from pathlib import Path + +import bpy +from mathutils import Vector + + +def argv_after_double_dash(): + if "--" in sys.argv: + return sys.argv[sys.argv.index("--") + 1 :] + return [] + + +def clear_scene(): + bpy.ops.object.select_all(action="SELECT") + bpy.ops.object.delete(use_global=False) + for block in (bpy.data.meshes, bpy.data.armatures, bpy.data.materials, bpy.data.images): + for b in list(block): + block.remove(b) + + +def import_mesh(path: Path): + ext = path.suffix.lower() + if ext == ".fbx": + bpy.ops.import_scene.fbx(filepath=str(path), automatic_bone_orientation=False) + elif ext == ".obj": + bpy.ops.wm.obj_import(filepath=str(path)) + else: + raise RuntimeError(f"Unsupported import: {path}") + + meshes = [o for o in bpy.context.scene.objects if o.type == "MESH"] + if not meshes: + raise RuntimeError("No mesh found after import") + + # Merge multi-part Meshy exports into one skinned body. + bpy.ops.object.select_all(action="DESELECT") + for o in meshes: + o.select_set(True) + bpy.context.view_layer.objects.active = meshes[0] + if len(meshes) > 1: + bpy.ops.object.join() + + body = bpy.context.view_layer.objects.active + bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) + return body + + +def mesh_bounds(obj): + corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box] + xs = [c.x for c in corners] + ys = [c.y for c in corners] + zs = [c.z for c in corners] + mn = Vector((min(xs), min(ys), min(zs))) + mx = Vector((max(xs), max(ys), max(zs))) + return mn, mx, mx - mn + + +def add_bone(arm, name, head, tail, parent=None, connect=False): + bone = arm.edit_bones.new(name) + bone.head = Vector(head) + bone.tail = Vector(tail) + if parent is not None: + bone.parent = parent + bone.use_connect = connect + return bone + + +def build_mixamo_armature(body): + mn, mx, size = mesh_bounds(body) + # Assume Y-up FBX from Meshy: height along Z or Y. Pick the tallest axis as up. + if size.z >= size.y and size.z >= size.x: + up = "Z" + height = size.z + foot_y = mn.y + size.y * 0.5 + center_x = (mn.x + mx.x) * 0.5 + ground = mn.z + top = mx.z + depth_back = mn.y + size.y * 0.42 + depth_front = mn.y + size.y * 0.58 + + def P(x, h01, d01=0.5): + # h01: 0 ground .. 1 top; d01: 0 back .. 1 front along Y + return ( + center_x + (x - 0.5) * size.x, + mn.y + size.y * d01, + ground + height * h01, + ) + else: + # Y-up style + height = size.y + center_x = (mn.x + mx.x) * 0.5 + ground = mn.y + top = mx.y + + def P(x, h01, d01=0.5): + return ( + center_x + (x - 0.5) * size.x, + ground + height * h01, + mn.z + size.z * d01, + ) + + bpy.ops.object.armature_add(enter_editmode=True, location=(0, 0, 0)) + arm_obj = bpy.context.object + arm_obj.name = "Armature" + arm = arm_obj.data + arm.name = "Armature" + # Remove default bone + for b in list(arm.edit_bones): + arm.edit_bones.remove(b) + + # Mixamo bone names (no mixamorig: prefix — Mixamo accepts both; Unity Humanoid likes these). + hips = add_bone(arm, "mixamorig:Hips", P(0.5, 0.48), P(0.5, 0.54)) + spine = add_bone(arm, "mixamorig:Spine", P(0.5, 0.54), P(0.5, 0.62), hips, True) + spine1 = add_bone(arm, "mixamorig:Spine1", P(0.5, 0.62), P(0.5, 0.70), spine, True) + spine2 = add_bone(arm, "mixamorig:Spine2", P(0.5, 0.70), P(0.5, 0.78), spine1, True) + neck = add_bone(arm, "mixamorig:Neck", P(0.5, 0.78), P(0.5, 0.88), spine2, True) + head = add_bone(arm, "mixamorig:Head", P(0.5, 0.88), P(0.5, 0.98), neck, True) + + # Legs + lup = add_bone(arm, "mixamorig:LeftUpLeg", P(0.62, 0.48), P(0.64, 0.27), hips, False) + llow = add_bone(arm, "mixamorig:LeftLeg", P(0.64, 0.27), P(0.64, 0.08), lup, True) + lfoot = add_bone(arm, "mixamorig:LeftFoot", P(0.64, 0.08), P(0.64, 0.02, 0.72), llow, True) + add_bone(arm, "mixamorig:LeftToeBase", P(0.64, 0.02, 0.72), P(0.64, 0.01, 0.88), lfoot, True) + + rup = add_bone(arm, "mixamorig:RightUpLeg", P(0.38, 0.48), P(0.36, 0.27), hips, False) + rlow = add_bone(arm, "mixamorig:RightLeg", P(0.36, 0.27), P(0.36, 0.08), rup, True) + rfoot = add_bone(arm, "mixamorig:RightFoot", P(0.36, 0.08), P(0.36, 0.02, 0.72), rlow, True) + add_bone(arm, "mixamorig:RightToeBase", P(0.36, 0.02, 0.72), P(0.36, 0.01, 0.88), rfoot, True) + + # Arms — shoulders out from chest + lsh = add_bone(arm, "mixamorig:LeftShoulder", P(0.55, 0.76), P(0.68, 0.76), spine2, False) + larm = add_bone(arm, "mixamorig:LeftArm", P(0.68, 0.76), P(0.82, 0.62), lsh, True) + lfore = add_bone(arm, "mixamorig:LeftForeArm", P(0.82, 0.62), P(0.90, 0.48), larm, True) + lhand = add_bone(arm, "mixamorig:LeftHand", P(0.90, 0.48), P(0.94, 0.44), lfore, True) + + rsh = add_bone(arm, "mixamorig:RightShoulder", P(0.45, 0.76), P(0.32, 0.76), spine2, False) + rarm = add_bone(arm, "mixamorig:RightArm", P(0.32, 0.76), P(0.18, 0.62), rsh, True) + rfore = add_bone(arm, "mixamorig:RightForeArm", P(0.18, 0.62), P(0.10, 0.48), rarm, True) + rhand = add_bone(arm, "mixamorig:RightHand", P(0.10, 0.48), P(0.06, 0.44), rfore, True) + + # Minimal fingers so Mixamo / Humanoid see hands + for side, hand, sign in (("Left", lhand, 1.0), ("Right", rhand, -1.0)): + hx, hy, hz = hand.tail + for i, finger in enumerate(("Thumb", "Index", "Middle", "Ring", "Pinky")): + off = (i - 2) * 0.012 * size.x + tip_x = hx + sign * 0.03 * size.x + tip_y = hy + off + tip_z = hz - 0.01 * height + mid = add_bone( + arm, + f"mixamorig:{side}Hand{finger}1", + hand.tail, + (hx + sign * 0.015 * size.x, hy + off * 0.5, hz - 0.005 * height), + hand, + False, + ) + add_bone( + arm, + f"mixamorig:{side}Hand{finger}2", + mid.tail, + (tip_x, tip_y, tip_z), + mid, + True, + ) + + bpy.ops.object.mode_set(mode="OBJECT") + return arm_obj + + +def bind_automatic_weights(body, arm_obj): + bpy.ops.object.select_all(action="DESELECT") + body.select_set(True) + arm_obj.select_set(True) + bpy.context.view_layer.objects.active = arm_obj + bpy.ops.object.parent_set(type="ARMATURE_AUTO") + + +def export_fbx(path: Path): + bpy.ops.object.select_all(action="SELECT") + bpy.ops.export_scene.fbx( + filepath=str(path), + use_selection=False, + object_types={"ARMATURE", "MESH"}, + add_leaf_bones=False, + bake_anim=False, + armature_nodetype="NULL", + primary_bone_axis="Y", + secondary_bone_axis="X", + axis_forward="-Z", + axis_up="Y", + apply_scale_options="FBX_SCALE_ALL", + path_mode="COPY", + embed_textures=True, + ) + + +def main(): + args = argv_after_double_dash() + if len(args) < 2: + raise SystemExit("Need: -- ") + + src = Path(args[0]) + dst = Path(args[1]) + if not src.is_file(): + raise SystemExit(f"Missing input: {src}") + + clear_scene() + body = import_mesh(src) + print(f"[DrucladRig] mesh={body.name} verts={len(body.data.vertices)}") + arm = build_mixamo_armature(body) + bind_automatic_weights(body, arm) + dst.parent.mkdir(parents=True, exist_ok=True) + export_fbx(dst) + print(f"[DrucladRig] wrote {dst}") + + +if __name__ == "__main__": + main() diff --git a/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx.meta b/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx.meta new file mode 100644 index 000000000..6824c3c25 --- /dev/null +++ b/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 841ad6726d534b0f966f75f53c208a9a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx.meta b/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx.meta new file mode 100644 index 000000000..f0c9176ef --- /dev/null +++ b/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3d6044e72b289804c815890f742bee28 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Materials.meta b/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Materials.meta new file mode 100644 index 000000000..e6028168e --- /dev/null +++ b/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Materials.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 24c2fb44f9d14d4ab5eb444eb107528d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Materials/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture.mat b/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Materials/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture.mat new file mode 100644 index 000000000..673674c1a --- /dev/null +++ b/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Materials/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture.mat @@ -0,0 +1,137 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!21 &2100000 +Material: + serializedVersion: 8 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: Meshy_AI_Floating_Meadow_Cliff_1019132410_texture + m_Shader: {fileID: 4800000, guid: 933532a4fcc9baf4fa0491de14d08ed7, type: 3} + m_Parent: {fileID: 0} + m_ModifiedSerializedProperties: 0 + m_ValidKeywords: + - _METALLICSPECGLOSSMAP + - _NORMALMAP + m_InvalidKeywords: [] + m_LightmapFlags: 4 + m_EnableInstancingVariants: 0 + m_DoubleSidedGI: 0 + m_CustomRenderQueue: -1 + stringTagMap: + RenderType: Opaque + disabledShaderPasses: + - MOTIONVECTORS + m_LockedProperties: + m_SavedProperties: + serializedVersion: 3 + m_TexEnvs: + - _BaseMap: + m_Texture: {fileID: 2800000, guid: 3fb94b780bea91b47b017999c895f633, type: 3} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _BumpMap: + m_Texture: {fileID: 2800000, guid: 85b039626663f5f4e9063db2266b751a, type: 3} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailAlbedoMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailMask: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailNormalMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _EmissionMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _MainTex: + m_Texture: {fileID: 2800000, guid: 3fb94b780bea91b47b017999c895f633, type: 3} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _MetallicGlossMap: + m_Texture: {fileID: 2800000, guid: ff24d598e2f308c4190616e9a42f9f53, type: 3} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _OcclusionMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _ParallaxMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _SpecGlossMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - unity_Lightmaps: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - unity_LightmapsInd: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - unity_ShadowMasks: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + m_Ints: [] + m_Floats: + - _AlphaClip: 0 + - _AlphaToMask: 0 + - _Blend: 0 + - _BlendModePreserveSpecular: 1 + - _BumpScale: 1 + - _ClearCoatMask: 0 + - _ClearCoatSmoothness: 0 + - _Cull: 2 + - _Cutoff: 0.5 + - _DetailAlbedoMapScale: 1 + - _DetailNormalMapScale: 1 + - _DstBlend: 0 + - _DstBlendAlpha: 0 + - _EnvironmentReflections: 1 + - _GlossMapScale: 1 + - _Glossiness: 0.5 + - _GlossyReflections: 1 + - _Metallic: 0 + - _OcclusionStrength: 1 + - _Parallax: 0.02 + - _QueueOffset: 0 + - _ReceiveShadows: 1 + - _Smoothness: 0.45 + - _SmoothnessTextureChannel: 0 + - _SpecularHighlights: 1 + - _SrcBlend: 1 + - _SrcBlendAlpha: 1 + - _Surface: 0 + - _WorkflowMode: 1 + - _ZWrite: 1 + m_Colors: + - _BaseColor: {r: 1, g: 1, b: 1, a: 1} + - _Color: {r: 1, g: 1, b: 1, a: 1} + - _EmissionColor: {r: 0, g: 0, b: 0, a: 1} + - _SpecColor: {r: 0.2, g: 0.2, b: 0.2, a: 1} + m_BuildTextureStacks: [] + m_AllowLocking: 1 +--- !u!114 &4482567862996937549 +MonoBehaviour: + m_ObjectHideFlags: 11 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: d0353a89b1f911e48b9e16bdc9f2e058, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Editor::UnityEditor.Rendering.Universal.AssetVersion + version: 10 diff --git a/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Materials/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture.mat.meta b/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Materials/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture.mat.meta new file mode 100644 index 000000000..4bb4a317c --- /dev/null +++ b/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Materials/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture.mat.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: bdbdcdb77cef4284b41173f108cdfb0e +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 2100000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture.fbx.meta b/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture.fbx.meta new file mode 100644 index 000000000..32e2a38de --- /dev/null +++ b/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture.fbx.meta @@ -0,0 +1,110 @@ +fileFormatVersion: 2 +guid: 0d7a1997278e7e141b0048da7f71f902 +ModelImporter: + serializedVersion: 24200 + internalIDToNameTable: [] + externalObjects: {} + materials: + materialImportMode: 2 + materialName: 0 + materialSearch: 1 + materialLocation: 1 + animations: + legacyGenerateAnimations: 4 + bakeSimulation: 0 + resampleCurves: 1 + optimizeGameObjects: 0 + removeConstantScaleCurves: 0 + motionNodeName: + animationImportErrors: + animationImportWarnings: + animationRetargetingWarnings: + animationDoRetargetingWarnings: 0 + importAnimatedCustomProperties: 0 + importConstraints: 0 + animationCompression: 1 + animationRotationError: 0.5 + animationPositionError: 0.5 + animationScaleError: 0.5 + animationWrapMode: 0 + extraExposedTransformPaths: [] + extraUserProperties: [] + clipAnimations: [] + isReadable: 1 + meshes: + lODScreenPercentages: [] + globalScale: 1 + meshCompression: 0 + addColliders: 0 + useSRGBMaterialColor: 1 + sortHierarchyByName: 1 + importPhysicalCameras: 1 + importVisibility: 1 + importBlendShapes: 1 + importCameras: 1 + importLights: 1 + nodeNameCollisionStrategy: 1 + fileIdsGeneration: 2 + swapUVChannels: 0 + generateSecondaryUV: 0 + useFileUnits: 1 + keepQuads: 0 + weldVertices: 1 + bakeAxisConversion: 0 + preserveHierarchy: 0 + skinWeightsMode: 0 + maxBonesPerVertex: 4 + minBoneWeight: 0.001 + optimizeBones: 1 + generateMeshLods: 0 + meshLodGenerationFlags: 0 + maximumMeshLod: -1 + meshOptimizationFlags: -1 + indexFormat: 0 + secondaryUVAngleDistortion: 8 + secondaryUVAreaDistortion: 15.000001 + secondaryUVHardAngle: 88 + secondaryUVMarginMethod: 1 + secondaryUVMinLightmapResolution: 40 + secondaryUVMinObjectScale: 1 + secondaryUVPackMargin: 4 + useFileScale: 1 + strictVertexDataChecks: 0 + tangentSpace: + normalSmoothAngle: 60 + normalImportMode: 0 + tangentImportMode: 3 + normalCalculationMode: 4 + legacyComputeAllNormalsFromSmoothingGroupsWhenMeshHasBlendShapes: 0 + blendShapeNormalImportMode: 1 + normalSmoothingSource: 0 + referencedClips: [] + importAnimation: 1 + humanDescription: + serializedVersion: 3 + human: [] + skeleton: [] + armTwist: 0.5 + foreArmTwist: 0.5 + upperLegTwist: 0.5 + legTwist: 0.5 + armStretch: 0.05 + legStretch: 0.05 + feetSpacing: 0 + globalScale: 1 + rootMotionBoneName: + hasTranslationDoF: 0 + hasExtraRoot: 0 + skeletonHasParents: 1 + lastHumanDescriptionAvatarSource: {instanceID: 0} + autoGenerateAvatarMappingIfUnspecified: 1 + animationType: 2 + humanoidOversampling: 1 + avatarSetup: 0 + addHumanoidExtraRootOnlyWhenUsingAvatar: 1 + importBlendShapeDeformPercent: 1 + remapMaterialsIfMaterialImportModeIsNone: 0 + additionalBone: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture.png.meta b/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture.png.meta new file mode 100644 index 000000000..d4db48074 --- /dev/null +++ b/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture.png.meta @@ -0,0 +1,130 @@ +fileFormatVersion: 2 +guid: 3fb94b780bea91b47b017999c895f633 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Android + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_metallic.png.meta b/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_metallic.png.meta new file mode 100644 index 000000000..7c364afc2 --- /dev/null +++ b/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_metallic.png.meta @@ -0,0 +1,130 @@ +fileFormatVersion: 2 +guid: ff24d598e2f308c4190616e9a42f9f53 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Android + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_normal.png.meta b/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_normal.png.meta new file mode 100644 index 000000000..967421e1d --- /dev/null +++ b/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_normal.png.meta @@ -0,0 +1,130 @@ +fileFormatVersion: 2 +guid: 85b039626663f5f4e9063db2266b751a +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 0 + linearTexture: 1 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 1 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Android + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_roughness.png.meta b/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_roughness.png.meta new file mode 100644 index 000000000..464bfff7d --- /dev/null +++ b/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_roughness.png.meta @@ -0,0 +1,130 @@ +fileFormatVersion: 2 +guid: 2ac908849ffc8ea43bbddd143cf7074b +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Android + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/README_IMPORT.txt b/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/README_IMPORT.txt new file mode 100644 index 000000000..87fb2f598 --- /dev/null +++ b/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/README_IMPORT.txt @@ -0,0 +1,10 @@ +Meshy Floating Meadow Cliff — void biome floating island model. + +FBX + textures live in: + Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/ + +FoliageSettings (Resources) references this FBX as voidFloatingIslandPrefab. +Material: Materials/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture.mat + +If the reference is missing after a fresh clone, run Unity menu: + Manafall/World/Assign Void Floating Island Prefab diff --git a/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/README_IMPORT.txt.meta b/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/README_IMPORT.txt.meta new file mode 100644 index 000000000..a5e422ac5 --- /dev/null +++ b/Assets/ThirdParty/DownloadedAssets/Meshy_AI_Floating_Meadow_Cliff_1019132410_texture_fbx/README_IMPORT.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 45be53baa611fde45ac675aa399660a0 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Docs/Playtest/manafall_cloud_agent_full_apply.txt b/Docs/Playtest/manafall_cloud_agent_full_apply.txt new file mode 100644 index 000000000..2125d4b82 --- /dev/null +++ b/Docs/Playtest/manafall_cloud_agent_full_apply.txt @@ -0,0 +1,17 @@ +Manafall cloud-agent playtest source patch +========================================== +Apply onto a clean checkout of main: + + git apply --check Docs/Playtest/manafall_cloud_agent_full.patch + git apply Docs/Playtest/manafall_cloud_agent_full.patch + +Or use the code-only patch (smaller, skips most binary assets): + + git apply Docs/Playtest/manafall_cloud_agent_code.patch + +Debug-overlays-only (tiny): + + git apply Docs/Playtest/manafall_hide_debug_overlays.patch + +Includes: filament water caustics, hide sticky F3/F4 debug overlays in player builds, +and other PR #76 work. Not a Windows game zip — rebuild the client in Unity after apply. diff --git a/Docs/Playtest/manafall_water_filament_caustics_apply.txt b/Docs/Playtest/manafall_water_filament_caustics_apply.txt new file mode 100644 index 000000000..4343ea985 --- /dev/null +++ b/Docs/Playtest/manafall_water_filament_caustics_apply.txt @@ -0,0 +1,4 @@ +Water filament caustics slice — see also manafall_cloud_agent_full_apply.txt +for the complete cloud-agent branch patch (all new GitHub content vs main). + +Patch: Docs/Playtest/manafall_water_filament_caustics.patch diff --git a/Docs/ROADMAP.md b/Docs/ROADMAP.md index 4b6790d7f..d9a427d8b 100644 --- a/Docs/ROADMAP.md +++ b/Docs/ROADMAP.md @@ -94,13 +94,14 @@ Document and spike before full builds. **Done when:** A claimed town is meaningfully different from a lonely yurt — socially and mechanically. ### 2B. Disease / cure system -- General infection framework (source → stages → symptoms → cure / fail states) -- Fold werewolf / vampire toward special-case diseases where clean -- Mundane and magical diseases (wound fever, swamp rot, mana-sick near ley scars) -- Cures via alchemy, Physician play, NPC healers; failure has teeth but isn’t soft-lock -- Optional Compact responses (avoidance, healer stalls, quarantine rumors) - -**Done when:** Non-curse illness can appear, be diagnosed, and be cured; Physician / alchemy feel necessary. +- [x] General infection framework (12 mundane diseases, combat/ambient/food sources, symptoms, save/load) +- [x] Signature cure potions (alchemy recipes 52–63) + expensive village healer rites +- [x] Swamp-weighted prevalence; tiny combat-hit infection chance +- [ ] Fold werewolf / vampire toward special-case diseases where clean +- [ ] Mana-sick near ley scars; Physician play fantasy +- [ ] Optional Compact responses (avoidance, healer stalls, quarantine rumors) + +**Done when:** Non-curse illness can appear, be diagnosed, and be cured; Physician / alchemy feel necessary. (Mundane twelve shipped; curses/Physician still open.) ### 2C. Farming expansion - Expand beyond NPC village plots into stronger player farming diff --git a/ProjectSettings/ProjectSettings.asset b/ProjectSettings/ProjectSettings.asset index df8cc0684..2d2c8a535 100644 --- a/ProjectSettings/ProjectSettings.asset +++ b/ProjectSettings/ProjectSettings.asset @@ -143,7 +143,7 @@ PlayerSettings: loadStoreDebugModeEnabled: 0 visionOSBundleVersion: 1.0 tvOSBundleVersion: 1.0 - bundleVersion: 0.1.40 + bundleVersion: 0.1.44 preloadedAssets: - {fileID: -944628639613478452, guid: 052faaac586de48259a63d0c4782560b, type: 3} metroInputSource: 0 diff --git a/_manafall-website/config.js b/_manafall-website/config.js index 5f66f24f7..6ac016942 100644 --- a/_manafall-website/config.js +++ b/_manafall-website/config.js @@ -1,7 +1,19 @@ window.MANAFALL_SITE = { kofiUrl: "https://ko-fi.com/manafall", - downloadUrl: "https://github.com/Jla1190/manafall-website/releases/tag/v0.1.43", - downloadLabel: "Download for Windows (v0.1.43)", + // Single unzippable Windows zip (Mega). In-game auto-patch still pulls 0.1.43 from patch-manifest.json. + downloadUrl: "https://mega.nz/file/Hd8n3LKb#mvqzUMhjnnLmVwXAzZe67t3_5taRKgEF-L1IWXPRuOc", + downloadLabel: "Download for Windows", + androidDownloadUrl: "https://mega.nz/file/PJ0TEaCY#A_r5KoBl_hKKvjxySZwV73Q2iubmyBwyGeG30xlH8Tw", + androidDownloadLabel: "Download for Android", + // Latest cloud-agent playtest source patch (caustics, Nightling, Druclad, loot tiers, …). + playtestPatchUrl: "files/manafall_cloud_agent_full.patch", + playtestPatchLabel: "Download playtest source patch (full)", + playtestPatchCodeUrl: "files/manafall_cloud_agent_code.patch", + playtestPatchCodeLabel: "Download playtest source patch (code only)", + playtestPatchHideDebugUrl: "files/manafall_hide_debug_overlays.patch", + playtestPatchHideDebugLabel: "Download patch: hide debug overlays only", + playtestPatchNotesUrl: "files/manafall_cloud_agent_full_apply.txt", + playtestBranchUrl: "https://cursor.com/codebase/jla90/manafall/pull/76", creatorName: "Jean-Luc Alexander", discordUrl: "https://discord.gg/CH8GjWaDnx", itchUrl: "https://orangejuice90.itch.io/manafall", diff --git a/_manafall-website/files/manafall_cloud_agent_full_apply.txt b/_manafall-website/files/manafall_cloud_agent_full_apply.txt new file mode 100644 index 000000000..2125d4b82 --- /dev/null +++ b/_manafall-website/files/manafall_cloud_agent_full_apply.txt @@ -0,0 +1,17 @@ +Manafall cloud-agent playtest source patch +========================================== +Apply onto a clean checkout of main: + + git apply --check Docs/Playtest/manafall_cloud_agent_full.patch + git apply Docs/Playtest/manafall_cloud_agent_full.patch + +Or use the code-only patch (smaller, skips most binary assets): + + git apply Docs/Playtest/manafall_cloud_agent_code.patch + +Debug-overlays-only (tiny): + + git apply Docs/Playtest/manafall_hide_debug_overlays.patch + +Includes: filament water caustics, hide sticky F3/F4 debug overlays in player builds, +and other PR #76 work. Not a Windows game zip — rebuild the client in Unity after apply. diff --git a/_manafall-website/index.html b/_manafall-website/index.html index 940cfa6bc..be51130d2 100644 --- a/_manafall-website/index.html +++ b/_manafall-website/index.html @@ -150,7 +150,7 @@

Download

-

The Windows download opens the current release page on GitHub. Android builds are listed separately when available.

+

Windows and Android builds. Direct single-file download on this page, or grab Windows from the official itch.io listing. After install, open the game — the auto-patch brings you up to date.

Download for Windows @@ -164,12 +164,23 @@ Get it on itch.io

Download link appears here once the build is uploaded to secure hosting.

-

Patch 0.1.43 auto-updates in three download parts. Free about 4 GB on your Windows drive first. The bar moves as each part finishes; a full download is about 2.4 GB.

+

In-game auto-patch is live (currently 0.1.43). Unzip the Windows download, launch Manafall, and let the updater run — free about 4 GB first; the full update is about 2.4 GB.

+ + Download playtest source patch (full) + + + Download playtest source patch (code only) + + + Download patch: hide debug overlays only + +

Newest Unity source patch from cloud-agent PR #76 — filament water caustics, hide sticky F3/F4 debug overlays, Nightling, Druclad, loot tiers, diseases, and more. Apply onto main with git apply (see apply notes). Not a Windows game zip — rebuild in Unity after apply.

Official itch.io page - follows, comments, and the same game listing.

    -
  • Official Windows release hosted on GitHub
  • +
  • Direct single zip from the creator (Mega)
  • Also listed on itch.io
  • No bundled adware
  • +
  • Open the game after unzip — auto-patch updates you
  • Expect a large download (multi-GB)
  • After unzip: move Put On Desktop\Manafall.exe to your Desktop — no shortcut needed
  • Rated Teen (13+) — Fantasy Violence, Mild Blood, Mild Drug Use
  • diff --git a/_manafall-website/site.js b/_manafall-website/site.js index 4831abc39..ac0304c01 100644 --- a/_manafall-website/site.js +++ b/_manafall-website/site.js @@ -42,15 +42,71 @@ [downloadBtn, downloadTop].forEach((btn) => { if (!btn) return; btn.href = cfg.downloadUrl; + btn.target = '_blank'; + btn.rel = 'noopener noreferrer'; btn.removeAttribute('aria-disabled'); }); if (downloadLabel && cfg.downloadLabel) downloadLabel.textContent = cfg.downloadLabel; if (dlNote) { - dlNote.textContent = 'Opens the current Windows release page on GitHub. Thanks for playing Manafall.'; + dlNote.textContent = + 'Single Windows zip on Mega — download, unzip, then open Manafall. The in-game auto-patch updates you to the latest build. Thanks for playing Manafall.'; dlNote.classList.add('ready'); } } + const androidBtn = document.getElementById('androidDownloadBtn'); + const androidLabel = document.getElementById('androidDownloadLabel'); + const androidNote = document.getElementById('androidDownloadNote'); + if (cfg.androidDownloadUrl) { + if (androidBtn) { + androidBtn.href = cfg.androidDownloadUrl; + androidBtn.removeAttribute('aria-disabled'); + androidBtn.target = '_blank'; + androidBtn.rel = 'noopener noreferrer'; + } + if (androidLabel && cfg.androidDownloadLabel) androidLabel.textContent = cfg.androidDownloadLabel; + if (androidNote) { + androidNote.textContent = + 'Android APK - sideload (allow Unknown apps). Join via Server Browser on same LAN/VPN.'; + androidNote.classList.add('ready'); + } + } + + const playtestPatchBtn = document.getElementById('playtestPatchBtn'); + const playtestPatchCodeBtn = document.getElementById('playtestPatchCodeBtn'); + const playtestPatchHideDebugBtn = document.getElementById('playtestPatchHideDebugBtn'); + const playtestPatchLabel = document.getElementById('playtestPatchLabel'); + const playtestPatchCodeLabel = document.getElementById('playtestPatchCodeLabel'); + const playtestPatchHideDebugLabel = document.getElementById('playtestPatchHideDebugLabel'); + const playtestPatchNote = document.getElementById('playtestPatchNote'); + if (cfg.playtestPatchUrl && playtestPatchBtn) { + playtestPatchBtn.href = cfg.playtestPatchUrl; + playtestPatchBtn.setAttribute('download', ''); + if (playtestPatchLabel && cfg.playtestPatchLabel) { + playtestPatchLabel.textContent = cfg.playtestPatchLabel; + } + } + if (cfg.playtestPatchCodeUrl && playtestPatchCodeBtn) { + playtestPatchCodeBtn.href = cfg.playtestPatchCodeUrl; + playtestPatchCodeBtn.setAttribute('download', ''); + if (playtestPatchCodeLabel && cfg.playtestPatchCodeLabel) { + playtestPatchCodeLabel.textContent = cfg.playtestPatchCodeLabel; + } + } + if (cfg.playtestPatchHideDebugUrl && playtestPatchHideDebugBtn) { + playtestPatchHideDebugBtn.href = cfg.playtestPatchHideDebugUrl; + playtestPatchHideDebugBtn.setAttribute('download', ''); + if (playtestPatchHideDebugLabel && cfg.playtestPatchHideDebugLabel) { + playtestPatchHideDebugLabel.textContent = cfg.playtestPatchHideDebugLabel; + } + } + if (playtestPatchNote && cfg.playtestBranchUrl) { + playtestPatchNote.innerHTML = + 'Newest Unity source patch from cloud-agent PR #76 — filament water caustics, hide sticky F3/F4 debug overlays, Nightling, Druclad, loot tiers, diseases, and more. Apply onto main with git apply (see apply notes). Not a Windows game zip — rebuild in Unity after apply.'; + } const discordBtn = document.getElementById('discordBtn'); const discordTop = document.getElementById('discordBtnTop'); diff --git a/tools/test_disease.py b/tools/test_disease.py new file mode 100644 index 000000000..befe1726c --- /dev/null +++ b/tools/test_disease.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Headless checks that the twelve disease cures and catalog stay wired.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8", errors="replace") + + +def main() -> int: + catalog = read("Assets/Scripts/Disease/DiseaseCatalog.cs") + effects = read("Assets/Scripts/Alchemy/AlchemySignatureEffectKind.cs") + recipes = read("Assets/Scripts/Alchemy/AlchemySignatureBrewCatalog.cs") + ingredients = read("Assets/Scripts/Alchemy/AlchemyCatalog.cs") + + disease_ids = [ + "RawcapGut", + "MarshCough", + "Frogitch", + "WoundFever", + "Mirefoot", + "BlackwaterBelly", + "Leechblood", + "BoneChillAgue", + "SwampRot", + "RedscalePox", + "GraveDamp", + "HollowLung", + ] + cure_effects = [f"Cure{name}" for name in disease_ids] + + errors: list[str] = [] + + for name in disease_ids: + if f"DiseaseId.{name}" not in catalog and f"({name}" not in catalog: + # catalog uses DiseaseId.X in Definitions + if f"DiseaseId.{name}" not in catalog: + errors.append(f"DiseaseCatalog missing DiseaseId.{name}") + + for effect in cure_effects: + if effect not in effects: + errors.append(f"AlchemySignatureEffectKind missing {effect}") + if effect not in recipes: + errors.append(f"AlchemySignatureBrewCatalog missing {effect}") + + if "RecipeCount = 63" not in recipes: + errors.append("AlchemySignatureBrewCatalog.RecipeCount should be 63") + + for key in ( + "rawcap_bitterleaf", + "rotveil_spore", + "poxiron_flake", + "gravedamp_lichen", + "hollow_lungwort", + ): + if key not in ingredients: + errors.append(f"AlchemyCatalog missing ingredient {key}") + if key not in recipes: + errors.append(f"Signature recipes missing key {key}") + + for recipe_id in range(52, 64): + if f"new({recipe_id}," not in recipes and f"new({recipe_id}, " not in recipes: + errors.append(f"Missing signature recipe id {recipe_id}") + + runtime = read("Assets/Scripts/Disease/PlayerDiseaseRuntime.cs") + if "MaxConcurrent" not in runtime: + errors.append("PlayerDiseaseRuntime missing MaxConcurrent usage") + + health = read("Assets/Scripts/Combat/PlayerHealth.cs") + if "DiseaseInfectionService.ServerTryInfectFromCreatureHit" not in health: + errors.append("PlayerHealth missing combat disease hook") + + strength = read("Assets/Scripts/Practice/StrengthTraining.cs") + if "GetStrengthMultiplier" not in strength: + errors.append("StrengthTraining missing disease strength drain") + + melee = read("Assets/Scripts/Combat/PlayerMeleeCombat.cs") + if "GetStrengthMultiplier" not in melee: + errors.append("PlayerMeleeCombat missing disease strength drain") + + healer = read("Assets/Scripts/World/Village/ChurchHealerDiseaseService.cs") + if "TryConsumeCoins" not in healer: + errors.append("ChurchHealerDiseaseService missing coin charge") + + if "strengthMultiplier: 0.7f" not in catalog: + errors.append("Swamp Rot should drain strength (0.7)") + + if errors: + print("FAIL") + for e in errors: + print(" -", e) + return 1 + + print("OK: 12 diseases, cures 52-63, combat hook, healer pay, rare reagents present") + return 0 + + +if __name__ == "__main__": + sys.exit(main())