diff --git a/.clang-format b/.clang-format index d7f5caf8cc6..2bf3c36849c 100644 --- a/.clang-format +++ b/.clang-format @@ -1,75 +1,164 @@ --- Language: Cpp -AccessModifierOffset: '-4' +AccessModifierOffset: -4 AlignAfterOpenBracket: DontAlign -AlignConsecutiveAssignments: 'false' -AlignConsecutiveDeclarations: 'false' -AlignEscapedNewlinesLeft: Left -AlignTrailingComments: 'false' -AllowAllParametersOfDeclarationOnNextLine: 'false' -AllowShortBlocksOnASingleLine: 'false' -AllowShortCaseLabelsOnASingleLine: 'true' -AllowShortFunctionsOnASingleLine: Empty -AllowShortIfStatementsOnASingleLine: 'false' -AllowShortLoopsOnASingleLine: 'false' -AlwaysBreakAfterReturnType: All -AlwaysBreakBeforeMultilineStrings: 'false' -AlwaysBreakTemplateDeclarations: 'false' -BinPackArguments: 'true' -BinPackParameters: 'false' +AlignArrayOfStructures: None +AlignConsecutiveAssignments: None +AlignConsecutiveBitFields: None +AlignConsecutiveDeclarations: None +AlignConsecutiveMacros: None +AlignEscapedNewlines: Right +AlignOperands: DontAlign +AlignTrailingComments: false +AllowAllArgumentsOnNextLine: true +AllowAllParametersOfDeclarationOnNextLine: true +AllowShortBlocksOnASingleLine: Never +AllowShortCaseLabelsOnASingleLine: false +AllowShortEnumsOnASingleLine: false +AllowShortFunctionsOnASingleLine: None +AllowShortIfStatementsOnASingleLine: false +AllowShortLambdasOnASingleLine: All +AllowShortLoopsOnASingleLine: false +AlwaysBreakAfterDefinitionReturnType: None +AlwaysBreakAfterReturnType: None +AlwaysBreakBeforeMultilineStrings: false +AlwaysBreakTemplateDeclarations: Yes +BinPackArguments: true +BinPackParameters: true +BitFieldColonSpacing: Both +BreakBeforeBraces: Allman +#BreakBeforeBraces: Custom +#BraceWrapping: +# AfterClass: false +# AfterControlStatement: false +# AfterEnum: false +# AfterFunction: true +# AfterNamespace: false +# AfterStruct: false +# AfterUnion: false +# # AfterExternBlock: false +# BeforeCatch: false +# BeforeElse: false +# IndentBraces: false +# SplitEmptyFunction: true +# SplitEmptyRecord: false +# SplitEmptyNamespace: true +BreakAfterAttributes: Never BreakBeforeBinaryOperators: None -# BreakBeforeBraces: Attach -BreakBeforeBraces: Custom -BraceWrapping: - AfterClass: false - AfterControlStatement: false - AfterEnum: false - AfterFunction: true - AfterNamespace: false - AfterStruct: false - AfterUnion: false - # AfterExternBlock: false - BeforeCatch: false - BeforeElse: false - IndentBraces: false - SplitEmptyFunction: true - SplitEmptyRecord: false - SplitEmptyNamespace: true -BreakBeforeInheritanceComma: 'false' -BreakBeforeTernaryOperators: 'false' +BreakBeforeConceptDeclarations: Never +BreakBeforeInlineASMColon: OnlyMultiline +BreakBeforeTernaryOperators: false BreakConstructorInitializers: AfterColon +BreakInheritanceList: AfterComma +BreakStringLiterals: false +ColumnLimit: 120 # Works fine even without this # CommentPragmas: '^! \\' -CompactNamespaces: 'true' -ConstructorInitializerAllOnOneLineOrOnePerLine: 'false' -Cpp11BracedListStyle: 'true' -DerivePointerAlignment: 'false' -IndentCaseLabels: 'false' -IndentWidth: '4' -IndentWrappedFunctionNames: 'true' -KeepEmptyLinesAtTheStartOfBlocks: 'true' -MaxEmptyLinesToKeep: '3' +CompactNamespaces: true +ConstructorInitializerIndentWidth: 4 +ContinuationIndentWidth: 4 +Cpp11BracedListStyle: true +DerivePointerAlignment: false +EmptyLineAfterAccessModifier: Never +EmptyLineBeforeAccessModifier: LogicalBlock +FixNamespaceComments: true +IncludeBlocks: Regroup +IncludeCategories: + - Regex: "(<|\")(Jolt|boost)\/.*" + Priority: 2 + - Regex: "<.*>" + Priority: 1 + - Regex: "\"Include\\.h.*" + Priority: 3 + SortPriority: 3 + - Regex: "\"(core\/ForwardDefinitions\\.hpp).*" + Priority: 3 + SortPriority: 4 + - Regex: "\".*\/" + Priority: 5 + - Regex: ".*" + Priority: 6 +IndentAccessModifiers: false +IndentCaseLabels: true +IndentCaseBlocks: false +IndentExternBlock: AfterExternBlock +IndentGotoLabels: false +IndentPPDirectives: None +IndentRequiresClause: false +IndentWidth: 4 +IndentWrappedFunctionNames: true +InsertBraces: false +InsertNewlineAtEOF: true +KeepEmptyLinesAtTheStartOfBlocks: false +LambdaBodyIndentation: Signature +LineEnding: DeriveLF +# MacroBlockBegin: +# MacroBlockEnd: +# Macros: +MaxEmptyLinesToKeep: 1 NamespaceIndentation: None +# NamespaceMacros: +# - +PPIndentWidth: 1 +PackConstructorInitializers: BinPack +PenaltyExcessCharacter: 100 +PenaltyReturnTypeOnItsOwnLine: 20 PointerAlignment: Left -ReflowComments: 'true' -SortIncludes: 'true' -SortUsingDeclarations: 'true' -SpaceAfterCStyleCast: 'false' -SpaceAfterTemplateKeyword: 'false' -SpaceBeforeAssignmentOperators: 'true' -SpaceBeforeParens: Never -# This is also a new setting not yet available -# SpaceBeforeRangeBasedForLoopColon: 'true' -SpaceInEmptyParentheses: 'false' -SpacesBeforeTrailingComments: '1' -SpacesInAngles: 'false' -SpacesInCStyleCastParentheses: 'false' -SpacesInParentheses: 'false' -SpacesInContainerLiterals: 'false' -SpacesInParentheses: 'false' -SpacesInSquareBrackets: 'false' -Standard: Cpp11 -TabWidth: '4' +QualifierAlignment: Custom +QualifierOrder: + - static + - constexpr + - inline + - const + - volatile + - restrict + - friend + - type +# This needs to be left as otherwise functions will look ugly +ReferenceAlignment: Left +ReflowComments: true +RemoveSemicolon: true +RequiresClausePosition: OwnLine +RequiresExpressionIndentation: OuterScope +SeparateDefinitionBlocks: Always +ShortNamespaceLines: 0 +SortIncludes: CaseInsensitive +SortUsingDeclarations: Lexicographic +SpaceAfterCStyleCast: false +SpaceAfterLogicalNot: false +SpaceAfterTemplateKeyword: false +SpaceAroundPointerQualifiers: Before +SpaceBeforeAssignmentOperators: true +SpaceBeforeCaseColon: false +SpaceBeforeCpp11BracedList: false +SpaceBeforeCtorInitializerColon: true +SpaceBeforeInheritanceColon: true +SpaceBeforeParens: ControlStatementsExceptControlMacros +SpaceBeforeRangeBasedForLoopColon: true +SpaceBeforeSquareBrackets: false +SpaceInEmptyBlock: false +SpaceInEmptyParentheses: false +SpacesBeforeTrailingComments: 1 +SpacesInAngles: false +SpacesInCStyleCastParentheses: false +SpacesInConditionalStatement: false +SpacesInContainerLiterals: false +SpacesInLineCommentPrefix: + Minimum: 1 + Maximum: 1 +SpacesInParentheses: false +SpacesInSquareBrackets: false +Standard: c++17 +# StatementAttributeLikeMacros: +# - +StatementMacros: + - JPH_ADD_ATTRIBUTE + - JPH_ADD_BASE_CLASS +TabWidth: 4 +# TypenameMacros: +# - UseTab: Never +# WhitespaceSensitiveMacros: +# - ... diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 00000000000..b690a22139a --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,5 @@ +--- +Checks: '-*,boost-*,bugprone-*,cert-*,clang-analyzer-*,concurrency-*,cppcoreguidelines-*,hicpp-*, + llvm-*,-llvm-header-guard,misc-*,-misc-no-recursion,modernize-*,-modernize-use-trailing-return-type, + performance-*,portability-*,readability-*' +ShortStatementLines: 1 diff --git a/.gitignore b/.gitignore index c5f24895d6d..93bdbe23621 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /.import export.cfg /builds +/cmake-build* /inspect_results.xml /files_to_check.txt @@ -67,7 +68,6 @@ _ReSharper*/ *.vssscc $tf*/ [Bb]in -[Dd]ebug*/ # Libraries *.lib diff --git a/.gitmodules b/.gitmodules index ab8334e749f..f322f71cb79 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "RevolutionaryGamesCommon"] path = RevolutionaryGamesCommon url = https://github.com/Revolutionary-Games/RevolutionaryGamesCommon.git +[submodule "third_party/JoltPhysics"] + path = third_party/JoltPhysics + url = https://github.com/jrouwe/JoltPhysics.git diff --git a/CIConfiguration.yml b/CIConfiguration.yml index c15fc992b30..fa3c50805f8 100644 --- a/CIConfiguration.yml +++ b/CIConfiguration.yml @@ -5,15 +5,15 @@ jobs: image: thrive/godot-ci:v21 cache: loadFrom: - - v7-{Branch}-build - - v7-master-build - writeTo: v7-{Branch}-build + - v8-{Branch}-build + - v8-master-build + writeTo: v8-{Branch}-build shared: - .git/lfs: v4-lfs - .import: v7-import - builds: v3-builds + .git/lfs: v5-lfs + .import: v8-import + builds: v4-builds system: - /root/.nuget: v3-nuget + /root/.nuget: v4-nuget steps: - run: name: Make project valid for compile @@ -33,15 +33,15 @@ jobs: image: thrive/godot-ci:v21 cache: loadFrom: - - v4-{Branch}-jetbrains - - v4-master-jetbrains - writeTo: v4-{Branch}-jetbrains + - v5-{Branch}-jetbrains + - v5-master-jetbrains + writeTo: v5-{Branch}-jetbrains shared: - .git/lfs: v4-lfs - .import: v7-import - builds: v3-jetbrains-dummy-builds + .git/lfs: v5-lfs + .import: v8-import + builds: v4-jetbrains-dummy-builds system: - /root/.nuget: v3-nuget + /root/.nuget: v4-nuget artifacts: paths: - files_to_check.txt @@ -94,13 +94,13 @@ jobs: image: thrive/godot-ci:v21 cache: loadFrom: - - v4-{Branch}-format - - v4-master-format - writeTo: v4-{Branch}-format + - v5-{Branch}-format + - v5-master-format + writeTo: v5-{Branch}-format shared: - .git/lfs: v4-lfs + .git/lfs: v5-lfs system: - /root/.nuget: v3-nuget + /root/.nuget: v4-nuget artifacts: paths: - format_diff.patch diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 00000000000..cc7bdad3692 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,75 @@ +# Native code side of Thrive +cmake_minimum_required(VERSION 3.10) + +project(Thrive) + +# If you want to get compile commands run cmake with +# "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON" + +# Options +option(USE_OBJECT_POOLS + "Use object pools instead of direct memory allocation (can be turned off for memory debugging)" + ON) + +option(LOCK_FREE_COLLISION_RECORDING + "If on uses lock free collision data recording which is hopefully faster than with locks" ON) + +option(USE_SMALL_VECTOR_POOLS + "If on uses also pools for small list allocations in physics" OFF) + +option(WARNINGS_AS_ERRORS "When on treat compiler warnings as errors" ON) + +option(NULL_HAS_UNUSUAL_REPRESENTATION + "When on it is not assumed that null equals numeric 0" OFF) + +# set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} "${PROJECT_SOURCE_DIR}/CMake") + +# Also use a lib prefix on windows for consistency +if(WIN32) + set(CMAKE_SHARED_LIBRARY_PREFIX_CXX "lib") +endif() + +# static building +set(CMAKE_FIND_LIBRARY_SUFFIXES ".a") +set(BUILD_SHARED_LIBS OFF) + +# Common options +if(CMAKE_BUILD_TYPE STREQUAL "") + set(CMAKE_BUILD_TYPE Release CACHE STRING + "Set the build type, usually Debug or Distribution" FORCE) +endif() + +# A bit unneeded Jolt hack +# # This and the following hack is required due to Jolt being very debug-y in +# # release mode +# elseif(CMAKE_BUILD_TYPE STREQUAL "Release") +# set(CMAKE_BUILD_TYPE Distribution) +# elseif(CMAKE_BUILD_TYPE STREQUAL "RelWithDebInfo") +# set(CMAKE_BUILD_TYPE Distribution) + +if(CMAKE_BUILD_TYPE STREQUAL "Debug") + set(CMAKE_INSTALL_PREFIX "${CMAKE_INSTALL_PREFIX}/debug") +else() + set(CMAKE_INSTALL_PREFIX "${CMAKE_INSTALL_PREFIX}/release") +endif() + +# Detect library version +file(READ "src/native/NativeConstants.cs" versionFile) + +string(REGEX MATCH "Version = ([0-9]+);" _ "${versionFile}") +set(NATIVE_LIBRARY_VERSION ${CMAKE_MATCH_1}) + +if(NOT NATIVE_LIBRARY_VERSION) + message(FATAL_ERROR "Failed to parse native library version") +endif() + +message(STATUS "Configured native library version ${NATIVE_LIBRARY_VERSION}") + +# Configure include file +configure_file("src/native/Include.h.in" "${PROJECT_BINARY_DIR}/Include.h") +include_directories(${PROJECT_BINARY_DIR}) + +# Add the subfolders that define the actual things to build +add_subdirectory(third_party) + +add_subdirectory(src/native) diff --git a/LICENSE.txt b/LICENSE.txt index 8b031e024b6..7847dd9ca36 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -55,3 +55,6 @@ AngleSharp: The MIT License (MIT) Copyright (c) 2013 - 2023 AngleSharp YamlDotNet: MIT License Copyright (c) 2008, 2009, 2010, 2011, 2012, 2013, 2014 Antoine Aubry and contributors +JoltPhysics: MIT License Copyright 2021 Jorrit Rouwe + +Boost C++ Libraries: Boost Software License - Version 1.0 diff --git a/Properties/AssemblyInfo.cs b/Properties/AssemblyInfo.cs index 34ed4065fa8..b96df39bf9c 100644 --- a/Properties/AssemblyInfo.cs +++ b/Properties/AssemblyInfo.cs @@ -19,9 +19,9 @@ // NOTE: the version info is read by a regex in a script so don't add complicated things to this file // NOTE: When changing the version number you need to also change "export_presets.cfg" -[assembly: AssemblyVersion("0.6.3.0")] +[assembly: AssemblyVersion("0.6.4.0")] -[assembly: AssemblyInformationalVersion("")] +[assembly: AssemblyInformationalVersion("-alpha")] // The following attributes are used to specify the signing key for the assembly, // if desired. See the Mono documentation for more information about signing. diff --git a/RevolutionaryGamesCommon b/RevolutionaryGamesCommon index 8eb8fd8e782..4650b092a91 160000 --- a/RevolutionaryGamesCommon +++ b/RevolutionaryGamesCommon @@ -1 +1 @@ -Subproject commit 8eb8fd8e782f6744588795b49f053569f50b797d +Subproject commit 4650b092a91389b660b1fcf67337fd3b20ada4cd diff --git a/Scripts/CodeChecks.cs b/Scripts/CodeChecks.cs index 0593cc2e98b..2ff1a8a2bd0 100644 --- a/Scripts/CodeChecks.cs +++ b/Scripts/CodeChecks.cs @@ -64,8 +64,10 @@ public class CodeChecks : CodeChecksBase protected override IEnumerable ExtraIgnoredJetbrainsInspectWildcards => new[] { - "third_party/*", - "RevolutionaryGamesCommon/*", + "third_party/**", + "RevolutionaryGamesCommon/**", + "src/native/**.cpp", + "src/native/**.hpp", }; protected override string MainSolutionFile => "Thrive.sln"; diff --git a/Scripts/NativeLibs.cs b/Scripts/NativeLibs.cs new file mode 100644 index 00000000000..a7389675ea9 --- /dev/null +++ b/Scripts/NativeLibs.cs @@ -0,0 +1,580 @@ +namespace Scripts; + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using ScriptsBase.Utilities; +using SharedBase.Models; +using SharedBase.Utilities; + +/// +/// Handles the native C++ modules needed by Thrive +/// +public class NativeLibs +{ + private const string LibraryFolder = "native_libs"; + private const string DistributableFolderName = "distributable"; + private const string GodotEditorLibraryFolder = ".mono/temp/bin/Debug"; + private const string GodotReleaseLibraryFolder = ".mono/assemblies/Release"; + + private readonly Program.NativeLibOptions options; + + private readonly IList platforms; + + public NativeLibs(Program.NativeLibOptions options) + { + this.options = options; + + if (options.Libraries is { Count: < 1 }) + { + options.Libraries = null; + } + else if (options.Libraries != null) + { + ColourConsole.WriteNormalLine("Only processing following libraries:"); + + foreach (var library in options.Libraries) + { + ColourConsole.WriteNormalLine(" > " + library); + } + } + + if (options.DebugLibrary) + { + ColourConsole.WriteNormalLine("Using debug versions of libraries (these are not available " + + "for download usually)"); + } + + // Explicitly selected platforms override defaults + if (this.options.Platforms is { Count: > 0 }) + { + platforms = this.options.Platforms; + return; + } + + // Set sensible default platform definitions + if (this.options.Operations.Any(o => o == Program.NativeLibOptions.OperationMode.Install)) + { + // Install is just for current platform as it doesn't make sense to try to make the editor work on other + // platforms on one computer + platforms = new List { PlatformUtilities.GetCurrentPlatform() }; + } + else if (OperatingSystem.IsMacOS()) + { + // Mac stuff only can be done on a mac + platforms = new List { PackagePlatform.Mac }; + } + else if (this.options.Operations.Any(o => o == Program.NativeLibOptions.OperationMode.Build)) + { + // Other platforms have cross compile but build defaults to just current platform + platforms = new List { PlatformUtilities.GetCurrentPlatform() }; + } + else + { + platforms = new List { PackagePlatform.Linux, PackagePlatform.Windows }; + } + } + + public enum Library + { + /// + /// The main native side library that is pure C++ and doesn't depend on Godot + /// + ThriveNative, + } + + public async Task Run(CancellationToken cancellationToken) + { + // Make sure this base folder exists + Directory.CreateDirectory(LibraryFolder); + + await PackageTool.EnsureGodotIgnoreFileExistsInFolder(LibraryFolder); + + foreach (var operation in options.Operations) + { + if (!await RunOperation(operation, cancellationToken)) + return false; + } + + return true; + } + + /// + /// Copies required native library files to a Thrive release + /// + /// The root of the release folder (with Thrive.pck and other files) + /// The platform this release is for + /// + /// If true then only distributable libraries (with symbols extracted and stripped) are used. Otherwise normally + /// built local libraries can be used. + /// + public bool CopyToThriveRelease(string releaseFolder, PackagePlatform platform, bool useDistributableLibraries) + { + var libraries = options.Libraries ?? Enum.GetValues(); + + if (!Directory.Exists(releaseFolder)) + { + ColourConsole.WriteErrorLine($"Release folder to install native library in doesn't exist: {releaseFolder}"); + ColourConsole.WriteErrorLine("Will not create / attempt to copy anyway as the release would likely " + + "be broken due to file structure changing to what this script doesn't expect"); + + return false; + } + + // Godot doesn't by default put anything in the mono folder so we need to create it + var targetFolder = Path.Join(releaseFolder, GodotReleaseLibraryFolder); + Directory.CreateDirectory(targetFolder); + + foreach (var library in libraries) + { + ColourConsole.WriteDebugLine($"Copying native library: {library} to {targetFolder}"); + + if (!CopyLibraryFiles(library, platform, useDistributableLibraries, targetFolder)) + { + ColourConsole.WriteErrorLine($"Error copying library {library}"); + return false; + } + + ColourConsole.WriteNormalLine($"Copied library {library}"); + } + + ColourConsole.WriteSuccessLine($"Native libraries for {platform} copied to {releaseFolder}"); + return true; + } + + private string GetLibraryVersion(Library library) + { + switch (library) + { + case Library.ThriveNative: + return NativeConstants.Version.ToString(); + default: + throw new ArgumentOutOfRangeException(nameof(library), library, null); + } + } + + private string GetLibraryDllName(Library library, PackagePlatform platform) + { + switch (library) + { + case Library.ThriveNative: + switch (platform) + { + case PackagePlatform.Linux: + return "libthrive_native.so"; + case PackagePlatform.Windows: + return "libthrive_native.dll"; + case PackagePlatform.Windows32: + throw new NotSupportedException("32-bit support is not done currently"); + case PackagePlatform.Mac: + throw new NotImplementedException("TODO: name for this"); + default: + throw new ArgumentOutOfRangeException(nameof(platform), platform, null); + } + + default: + throw new ArgumentOutOfRangeException(nameof(library), library, null); + } + } + + private async Task RunOperation(Program.NativeLibOptions.OperationMode operation, + CancellationToken cancellationToken) + { + ColourConsole.WriteNormalLine($"Performing operation {operation}"); + + switch (operation) + { + case Program.NativeLibOptions.OperationMode.Check: + return await OperateOnAllLibraries( + (library, platform, token) => CheckLibrary(library, platform, false, token), + cancellationToken); + case Program.NativeLibOptions.OperationMode.CheckDistributable: + return await OperateOnAllLibraries( + (library, platform, token) => CheckLibrary(library, platform, true, token), + cancellationToken); + + case Program.NativeLibOptions.OperationMode.Install: + return await OperateOnAllLibraries(InstallLibraryForEditor, cancellationToken); + case Program.NativeLibOptions.OperationMode.Fetch: + throw new NotImplementedException("TODO: implement downloading"); + case Program.NativeLibOptions.OperationMode.Build: + return await OperateOnAllLibraries(BuildLocally, cancellationToken); + case Program.NativeLibOptions.OperationMode.Package: + throw new NotImplementedException("TODO: implement packaging"); + case Program.NativeLibOptions.OperationMode.Upload: + throw new NotImplementedException("TODO: implement uploading (and package / symbol extract)"); + default: + throw new ArgumentOutOfRangeException(); + } + } + + private async Task OperateOnAllLibraries( + Func> operation, + CancellationToken cancellationToken) + { + var libraries = options.Libraries ?? Enum.GetValues(); + + foreach (var library in libraries) + { + ColourConsole.WriteDebugLine($"Operating on library: {library}"); + + foreach (var platform in platforms) + { + cancellationToken.ThrowIfCancellationRequested(); + ColourConsole.WriteDebugLine($"Operating on platform: {platform}"); + + if (!await operation.Invoke(library, platform, cancellationToken)) + { + ColourConsole.WriteErrorLine($"Operation failed on: {library} for platform: {platform}"); + return false; + } + } + + ColourConsole.WriteSuccessLine($"Successfully performed operation on library: {library}"); + } + + return true; + } + + private Task CheckLibrary(Library library, PackagePlatform platform, bool distributableVersion, + CancellationToken cancellationToken) + { + var file = GetPathToLibraryDll(library, platform, GetLibraryVersion(library), distributableVersion); + + if (File.Exists(file)) + { + ColourConsole.WriteSuccessLine($"Library exists at: {file}"); + return Task.FromResult(true); + } + + // TODO: more library files per library? + cancellationToken.ThrowIfCancellationRequested(); + + ColourConsole.WriteErrorLine($"Library does not exist: {file}"); + return Task.FromResult(false); + } + + private Task InstallLibraryForEditor(Library library, PackagePlatform platform, + CancellationToken cancellationToken) + { + var target = GodotEditorLibraryFolder; + + if (!Directory.Exists(target)) + { + ColourConsole.WriteWarningLine($"Target folder to install native library in doesn't exist: {target}"); + ColourConsole.WriteInfoLine( + "Trying to install anyway but the install location might be wrong and Godot might not " + + "find the library"); + + Directory.CreateDirectory(target); + } + + var linkTo = GetPathToLibraryDll(library, platform, GetLibraryVersion(library), false); + var originalLinkTo = linkTo; + + if (!File.Exists(linkTo)) + { + // Fall back to distributable version + linkTo = GetPathToLibraryDll(library, platform, GetLibraryVersion(library), true); + + if (!File.Exists(linkTo)) + { + ColourConsole.WriteErrorLine( + $"Expected library doesn't exist (please 'Fetch' or 'Build' first): {originalLinkTo}"); + ColourConsole.WriteNormalLine("Distributable version also didn't exist"); + return Task.FromResult(false); + } + } + + var linkFile = Path.Join(target, GetLibraryDllName(library, platform)); + + CreateLinkTo(linkFile, linkTo); + + if (platform != PlatformUtilities.GetCurrentPlatform()) + { + ColourConsole.WriteWarningLine( + "Linking non-current platform library, this is likely not what's desired for the Godot Editor"); + } + + ColourConsole.WriteSuccessLine($"Successfully linked {library} on {platform}"); + return Task.FromResult(true); + } + + private bool CopyLibraryFiles(Library library, PackagePlatform platform, bool useDistributableLibraries, + string target) + { + // TODO: other files? + var file = GetPathToLibraryDll(library, platform, GetLibraryVersion(library), useDistributableLibraries); + + if (!File.Exists(file)) + { + ColourConsole.WriteErrorLine($"Expected file doesn't exist at: {file}"); + ColourConsole.WriteNormalLine("Have the native libraries been built / downloaded?"); + return false; + } + + var targetFile = Path.Join(target, Path.GetFileName(file)); + + File.Copy(file, targetFile, true); + + ColourConsole.WriteNormalLine($"Copied {file} -> {targetFile} for {platform}"); + + return true; + } + + private async Task BuildLocally(Library library, PackagePlatform platform, + CancellationToken cancellationToken) + { + if (platform != PlatformUtilities.GetCurrentPlatform()) + { + ColourConsole.WriteErrorLine("Building for non-current platform without podman is not supported"); + return false; + } + + ColourConsole.WriteInfoLine( + $"Building {library} for local use ({platform}) with CMake (hopefully all native dependencies " + + "are installed)"); + + var buildFolder = GetNativeBuildFolder(); + + // TODO: flag for clean builds + Directory.CreateDirectory(buildFolder); + + // Ensure Godot doesn't try to import anything funny from the build folder + await PackageTool.EnsureGodotIgnoreFileExistsInFolder(buildFolder); + + var installPath = Path.GetFullPath(GetLocalCMakeInstallTarget(platform, GetLibraryVersion(library))); + + var startInfo = new ProcessStartInfo("cmake") + { + WorkingDirectory = buildFolder, + }; + + // ReSharper disable StringLiteralTypo + startInfo.ArgumentList.Add("-DCMAKE_EXPORT_COMPILE_COMMANDS=ON"); + + if (options.DebugLibrary) + { + startInfo.ArgumentList.Add("-DCMAKE_BUILD_TYPE=Debug"); + } + else + { + startInfo.ArgumentList.Add("-DCMAKE_BUILD_TYPE=RelWithDebInfo"); + } + + if (!string.IsNullOrEmpty(options.Compiler) || !string.IsNullOrEmpty(options.CCompiler)) + { + ColourConsole.WriteNormalLine( + $"Using custom specified compiler: CXX: {options.Compiler}, C: {options.CCompiler}"); + + if (!string.IsNullOrEmpty(options.Compiler)) + { + startInfo.ArgumentList.Add($"-DCMAKE_CXX_COMPILER={options.Compiler}"); + } + + if (!string.IsNullOrEmpty(options.CCompiler)) + { + startInfo.ArgumentList.Add($"-DCMAKE_C_COMPILER={options.CCompiler}"); + } + } + else + { + if (OperatingSystem.IsLinux()) + { + // Use clang by default + startInfo.ArgumentList.Add("-DCMAKE_CXX_COMPILER=clang++"); + startInfo.ArgumentList.Add("-DCMAKE_C_COMPILER=clang"); + } + } + + if (!string.IsNullOrEmpty(options.CmakeGenerator)) + { + startInfo.ArgumentList.Add("-G"); + startInfo.ArgumentList.Add(options.CmakeGenerator); + } + + // TODO: add support for non-visual studio builds on windows + // When not using visual studio using ninja would be needed to avoid a dependency on it + // if (string.IsNullOrEmpty(ExecutableFinder.Which("ninja"))) + // { + // ColourConsole.WriteErrorLine( + // "Could not find ninja executable, generating probably will fail. Please install it " + + // "and add to PATH or specify another generator system"); + // } + // + // startInfo.ArgumentList.Add("-G"); + // startInfo.ArgumentList.Add("Ninja"); + + startInfo.ArgumentList.Add($"-DCMAKE_INSTALL_PREFIX={installPath}"); + + startInfo.ArgumentList.Add("../"); + + // ReSharper restore StringLiteralTypo + var result = await ProcessRunHelpers.RunProcessAsync(startInfo, cancellationToken, false); + + if (result.ExitCode != 0) + { + ColourConsole.WriteErrorLine( + $"CMake configuration failed (exit: {result.ExitCode}). " + + "Do you have the required build tools installed?"); + + return false; + } + + ColourConsole.WriteInfoLine("Succeeded in configuring cmake project"); + + ColourConsole.WriteNormalLine("Compiling..."); + + startInfo = new ProcessStartInfo("cmake") + { + WorkingDirectory = buildFolder, + }; + + startInfo.ArgumentList.Add("--build"); + startInfo.ArgumentList.Add("."); + startInfo.ArgumentList.Add("--config"); + + if (OperatingSystem.IsWindows()) + { + ColourConsole.WriteWarningLine("TODO: Windows Jolt build with MSVC only supports Release mode, " + + "building Thrive in release mode as well, there won't be debug symbols"); + + startInfo.ArgumentList.Add(options.DebugLibrary ? "Debug" : "Release"); + } + else + { + startInfo.ArgumentList.Add(options.DebugLibrary ? "Debug" : "RelWithDebInfo"); + } + + startInfo.ArgumentList.Add("--target"); + startInfo.ArgumentList.Add("install"); + + startInfo.ArgumentList.Add("-j"); + + // TODO: add option to not use all cores + startInfo.ArgumentList.Add(Environment.ProcessorCount.ToString()); + + result = await ProcessRunHelpers.RunProcessAsync(startInfo, cancellationToken, false); + + if (result.ExitCode != 0) + { + ColourConsole.WriteErrorLine($"Failed to run compiler through CMake (exit: {result.ExitCode}). " + + "See above for build output"); + + return false; + } + + if (!Directory.Exists(installPath)) + { + ColourConsole.WriteErrorLine($"Expected compile target folder doesn't exist: {installPath}"); + return false; + } + + ColourConsole.WriteSuccessLine($"Successfully compiled library {library}"); + return true; + } + + /// + /// Path to the library's root where all version specific folders are added + /// + private string GetPathToLibrary(Library library, PackagePlatform platform, string version, + bool distributableVersion) + { + if (distributableVersion) + { + return Path.Combine(LibraryFolder, DistributableFolderName, platform.ToString().ToLowerInvariant(), + library.ToString(), version, options.DebugLibrary ? "debug" : "release"); + } + + // TODO: should the paths for the libraries include the library name? (cmake is used to compile all at once) + + // The paths are a bit convoluted to easily be able to install with cmake to the target + return Path.Combine(LibraryFolder, platform.ToString().ToLowerInvariant(), version, + options.DebugLibrary ? "debug" : "release"); + } + + private string GetLocalCMakeInstallTarget(PackagePlatform platform, string version) + { + return Path.Combine(LibraryFolder, platform.ToString().ToLowerInvariant(), version); + } + + private string GetPathToLibraryDll(Library library, PackagePlatform platform, string version, + bool distributableVersion) + { + var basePath = GetPathToLibrary(library, platform, version, distributableVersion); + + if (platform is PackagePlatform.Windows or PackagePlatform.Windows32) + { + return Path.Combine(basePath, "bin", GetLibraryDllName(library, platform)); + } + + // This is for Linux + return Path.Combine(basePath, "lib", GetLibraryDllName(library, platform)); + } + + private void CreateLinkTo(string linkFile, string linkTo) + { + if (File.Exists(linkFile)) + { + ColourConsole.WriteDebugLine($"Removing existing library file: {linkFile}"); + File.Delete(linkFile); + } + + if (!OperatingSystem.IsWindows() || options.UseSymlinks) + { + File.CreateSymbolicLink(linkFile, Path.GetFullPath(linkTo)); + } + else + { + bool fallback = true; + + if (OperatingSystem.IsWindows()) + { + ColourConsole.WriteWarningLine("Not using symbolic link to install for editor. Any newly " + + "compiled library versions may not be visible to the editor without re-running " + + "this install command"); + ColourConsole.WriteNormalLine("Symbolic links can be specifically enabled but require " + + "administrator privileges on Windows"); + + try + { + if (NativeMethods.CreateHardLink(linkFile, Path.GetFullPath(linkTo), IntPtr.Zero)) + { + fallback = false; + } + } + catch (Exception e) + { + ColourConsole.WriteWarningLine($"Failed to call hardlink creation: {e}"); + } + } + + if (fallback) + { + ColourConsole.WriteWarningLine("Copying library instead of linking. The library won't update " + + "without re-running this tool!"); + File.Copy(linkTo, linkFile); + } + } + } + + private string GetNativeBuildFolder() + { + if (options.DebugLibrary) + return "build-debug"; + + return "build"; + } + + private static class NativeMethods + { + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + public static extern bool CreateHardLink(string lpFileName, string lpExistingFileName, + IntPtr lpSecurityAttributes); + } +} diff --git a/Scripts/PackageTool.cs b/Scripts/PackageTool.cs index c18a934fc66..df56a0a7369 100644 --- a/Scripts/PackageTool.cs +++ b/Scripts/PackageTool.cs @@ -143,6 +143,7 @@ public static async Task EnsureGodotIgnoreFileExistsInFolder(string folder) if (!File.Exists(ignoreFile)) { + ColourConsole.WriteDebugLine($"Creating .gdignore file in folder: {folder}"); await using var writer = File.Create(ignoreFile); } } @@ -369,6 +370,38 @@ protected override async Task PrepareToExport(PackagePlatform platform, Ca } } + // Copy needed native libraries + var nativeLibraryTool = new NativeLibs(new Program.NativeLibOptions + { + DebugLibrary = false, + DisableColour = options.DisableColour, + Verbose = options.Verbose, + }); + + ColourConsole.WriteNormalLine("Copying native libraries (hopefully they were downloaded / compiled already)"); + + if (!nativeLibraryTool.CopyToThriveRelease(folder, platform, true)) + { + bool success = false; + + if (options.FallbackToLocalNative) + { + ColourConsole.WriteWarningLine("Falling back to native library versions only meant for local use"); + ColourConsole.WriteNormalLine("Releases made like this are not the best and may not work on " + + "all target systems due to system version differences"); + + success = nativeLibraryTool.CopyToThriveRelease(folder, platform, false); + } + + if (!success) + { + ColourConsole.WriteErrorLine("Could not copy native libraries for release, this release won't work"); + return false; + } + } + + ColourConsole.WriteSuccessLine("Native library operations succeeded"); + return true; } diff --git a/Scripts/Program.cs b/Scripts/Program.cs index d50d96086ef..92c8f4fdeed 100644 --- a/Scripts/Program.cs +++ b/Scripts/Program.cs @@ -1,9 +1,12 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using CommandLine; +using CommandLine.Text; using Scripts; using ScriptsBase.Models; using ScriptsBase.Utilities; +using SharedBase.Models; using SharedBase.Utilities; public class Program @@ -14,12 +17,14 @@ public static int Main(string[] args) RunFolderChecker.EnsureRightRunningFolder("Thrive.sln"); var result = CommandLineHelpers.CreateParser() - .ParseArguments(args) .MapResult( (CheckOptions options) => RunChecks(options), + (NativeLibOptions options) => RunNativeLibsTool(options), (TestOptions options) => RunTests(options), (ChangesOptions options) => RunChangesFinding(options), (LocalizationOptions options) => RunLocalization(options), @@ -58,6 +63,19 @@ private static int RunChecks(CheckOptions options) return checker.Run().Result; } + private static int RunNativeLibsTool(NativeLibOptions options) + { + CommandLineHelpers.HandleDefaultOptions(options); + + ColourConsole.WriteDebugLine("Running native library handling tool"); + + var tokenSource = ConsoleHelpers.CreateSimpleConsoleCancellationSource(); + + var tool = new NativeLibs(options); + + return tool.Run(tokenSource.Token).Result ? 0 : 1; + } + private static int RunTests(TestOptions options) { CommandLineHelpers.HandleDefaultOptions(options); @@ -260,6 +278,97 @@ public class CheckOptions : CheckOptionsBase { } + [Verb("native", HelpText = "Handling for native libraries needed by Thrive")] + public class NativeLibOptions : ScriptOptionsBase + { + public enum OperationMode + { + /// + /// Check if libraries are present + /// + Check, + + /// + /// Check if libraries are present for distribution + /// + CheckDistributable, + + /// + /// Installs a library to work with Godot editor + /// + Install, + + /// + /// Downloads required libraries (if available) + /// + Fetch, + + /// + /// Build a locally working version with native tools + /// + Build, + + // TODO: add a command to clean old library versions + + /// + /// Build libraries for distribution or uploading using podman + /// + Package, + + /// + /// Upload packaged libraries missing from the server + /// + Upload, + } + + [Usage(ApplicationAlias = "dotnet run --project Scripts --")] + public static IEnumerable Examples + { + get + { + yield return new Example("download all available libraries", + new NativeLibOptions { Operations = new[] { OperationMode.Fetch } }); + yield return new Example("install library locally to make Godot Editor debugging work", + new NativeLibOptions { Operations = new[] { OperationMode.Install } }); + yield return new Example("compile libraries locally", + new NativeLibOptions { Operations = new[] { OperationMode.Build } }); + yield return new Example("prepare library versions for distribution or uploading with podman", + new NativeLibOptions { Operations = new[] { OperationMode.Package } }); + } + } + + [Value(0, MetaName = "OPERATIONS", Required = false, HelpText = "What native operation(s) to do")] + public IList Operations { get; set; } = new List { OperationMode.Check }; + + [Option('l', "library", Required = false, Default = null, MetaValue = "LIBRARIES", + HelpText = "Libraries to work on, default is all.")] + public IList? Libraries { get; set; } = new List(); + + [Option('d', "debug", Required = false, Default = false, + HelpText = "Set to work on debug versions of the libraries")] + public bool DebugLibrary { get; set; } + + [Option('p', "platform", Required = false, Default = null, + HelpText = "Use to override detected platforms for selected operation")] + public IList? Platforms { get; set; } = new List(); + + [Option('c', "compiler", Required = false, Default = null, MetaValue = "COMPILER", + HelpText = "Manually specify compiler to use")] + public string? Compiler { get; set; } + + [Option("c-compiler", Required = false, Default = null, MetaValue = "COMPILER", + HelpText = "Manually specify C compiler to use")] + public string? CCompiler { get; set; } + + [Option('g', "generator", Required = false, Default = null, MetaValue = "GENERATOR", + HelpText = "Manually specify which CMake generator to use")] + public string? CmakeGenerator { get; set; } + + [Option('s', "symbolic-links", Required = false, Default = false, + HelpText = "If specified prefer to use symlinks even on Windows")] + public bool UseSymlinks { get; set; } + } + [Verb("test", HelpText = "Run tests using 'dotnet' command")] public class TestOptions : ScriptOptionsBase { @@ -298,6 +407,10 @@ public class PackageOptions : PackageOptionsBase HelpText = "Make dehydrated builds by separating out big files. For use with DevBuilds")] public bool Dehydrated { get; set; } + [Option("fallback-native-local-only", Default = false, + HelpText = "Fallback to using native library only meant for local play (not recommended for release)")] + public bool FallbackToLocalNative { get; set; } + public override bool Compress => CompressRaw == true; } diff --git a/Scripts/Scripts.csproj b/Scripts/Scripts.csproj index d39709b0008..3b8d70e1c22 100644 --- a/Scripts/Scripts.csproj +++ b/Scripts/Scripts.csproj @@ -6,7 +6,7 @@ disable enable Revolutionary Games Studio - 1.1.0 + 1.2.0 @@ -21,4 +21,10 @@ + + + NativeConstants.cs + + + diff --git a/Scripts/WikiUpdater.cs b/Scripts/WikiUpdater.cs index 4d07f70c6ae..1318e27c5cf 100644 --- a/Scripts/WikiUpdater.cs +++ b/Scripts/WikiUpdater.cs @@ -33,6 +33,8 @@ public static class WikiUpdater "Mucilage", "Nitrogen", "Oxygen", + + // ReSharper disable once StringLiteralTypo "OxyToxy", "Phosphates", "Sunlight", diff --git a/Thrive.csproj b/Thrive.csproj index 9f99db6b2f9..a06a07090c9 100644 --- a/Thrive.csproj +++ b/Thrive.csproj @@ -29,6 +29,8 @@ + + @@ -106,6 +108,8 @@ + + @@ -117,13 +121,75 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -141,8 +207,12 @@ + + + + + - @@ -208,6 +278,7 @@ + @@ -224,21 +295,27 @@ + + + + + + - + @@ -368,6 +445,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + @@ -418,7 +520,6 @@ - @@ -450,15 +551,15 @@ + - - + @@ -471,23 +572,24 @@ - + - - + + + - - - + + + + - @@ -500,10 +602,8 @@ - - @@ -512,11 +612,8 @@ - - - @@ -524,7 +621,6 @@ - @@ -538,26 +634,54 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - @@ -567,10 +691,6 @@ - - - - @@ -585,7 +705,6 @@ - @@ -607,8 +726,6 @@ - - @@ -619,6 +736,9 @@ + + + @@ -651,7 +771,6 @@ - @@ -777,12 +896,12 @@ + - @@ -790,6 +909,9 @@ 1.0.5 + + 0.17.2 + 13.0.3 @@ -835,6 +957,7 @@ + diff --git a/Thrive.sln.DotSettings b/Thrive.sln.DotSettings index e0a2d455816..b77806bd9a8 100644 --- a/Thrive.sln.DotSettings +++ b/Thrive.sln.DotSettings @@ -516,6 +516,7 @@ True True True + True True True True @@ -524,6 +525,7 @@ True True True + True True True True @@ -548,10 +550,13 @@ True True True + True + True True True True True + True True True True @@ -583,6 +588,7 @@ True True True + True True True True @@ -595,6 +601,7 @@ True True True + True True True True @@ -603,6 +610,7 @@ True True True + True True True True @@ -659,6 +667,8 @@ True True True + True + True True True True @@ -666,6 +676,7 @@ True True True + True True True True @@ -705,7 +716,9 @@ True True True + True True + True True True True diff --git a/assets/models/ErrorModel.tscn b/assets/models/ErrorModel.tscn new file mode 100644 index 00000000000..dec27343786 --- /dev/null +++ b/assets/models/ErrorModel.tscn @@ -0,0 +1,9 @@ +[gd_scene load_steps=2 format=2] + +[sub_resource type="SpatialMaterial" id=1] +albedo_color = Color( 0.960784, 0.266667, 1, 1 ) + +[node name="ErrorModel" type="Spatial"] + +[node name="CSGBox" type="CSGBox" parent="."] +material = SubResource( 1 ) diff --git a/assets/sounds/soundeffects/binding.ogg.import b/assets/sounds/soundeffects/binding.ogg.import index 581046f3941..3b07ce67f6e 100644 --- a/assets/sounds/soundeffects/binding.ogg.import +++ b/assets/sounds/soundeffects/binding.ogg.import @@ -11,5 +11,5 @@ dest_files=[ "res://.import/binding.ogg-ae2dea40b42e027b384b3d534175ca91.oggstr" [params] -loop=true +loop=false loop_offset=0 diff --git a/assets/sounds/soundeffects/engulfment.ogg.import b/assets/sounds/soundeffects/engulfment.ogg.import index b95b9b86771..77b225cb2a0 100644 --- a/assets/sounds/soundeffects/engulfment.ogg.import +++ b/assets/sounds/soundeffects/engulfment.ogg.import @@ -11,5 +11,5 @@ dest_files=[ "res://.import/engulfment.ogg-611507f7591f8b37c22e7da0ff59f886.oggs [params] -loop=true +loop=false loop_offset=0 diff --git a/doc/setup_instructions.md b/doc/setup_instructions.md index 2b0f2e39530..6d2fc37f78e 100644 --- a/doc/setup_instructions.md +++ b/doc/setup_instructions.md @@ -338,13 +338,44 @@ Compiling --------- Now you should be able to return to the Godot editor and hit the build -button in the top right corner. If that succeeds then you should be -good to go. +button in the top right corner. If that succeeds then the C# side of +things should be working. -You can also compile from your development -environment (and not the Godot editor) to see warnings and get -highlighting of errors in the source code. However running the game -from Visual Studio is a bit complicated. +## Native Libraries + +Thrive depends on some native libraries which must be present before +the game can be ran. + +In the future it will be possible to download compatible libraries +with a script: +```sh +dotnet run --project Scripts -- native Fetch Install +``` + +You can compile these libraries locally after installing C++ +development tools: cmake, and a compiler. On Linux clang is +recommended. On Windows Visual Studio probably works best, but +technically clang should work (please send us a PR if you can tweak it +to work). On Mac Xcode (or at least the command line tools for it) +should be used. + +You can compile and install the native libraries for the Godot Editor +in the Thrive folder with the following script: +```sh +dotnet run --project Scripts -- native Build Install +``` + +Debug versions for easier native code development / more robust error +condition checking can be built and installed by adding `-d` to the +end of the previous command to specify debug versions of the +libraries to be used. + +## Using Development Environments + +You can also compile from your development environment (and not the +Godot editor) to see warnings and get highlighting of errors in the +source code. However running the game from Visual Studio is a bit +complicated. If the compile fails with a bunch of `Godot.something` or `Node` not found, undefined references, you need to compile the game from the @@ -377,7 +408,8 @@ configuration editor. Done ---- -If the build in Godot editor succeeded, you should be now good to go. +If the build in Godot editor succeeded and the native library install +step succeeded without errors, you should be now good to go. You can run the game by pressing the play button in the top right of the Godot editor or by pressing F5. Additionally if you open different diff --git a/export_presets.cfg b/export_presets.cfg index 1cd1334cabb..9d93cefd990 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -58,8 +58,8 @@ codesign/description="" codesign/custom_options=PoolStringArray( ) application/modify_resources=true application/icon="res://assets/misc/icon.ico" -application/file_version="0.6.3.0" -application/product_version="0.6.3.0" +application/file_version="0.6.4.0" +application/product_version="0.6.4.0" application/company_name="Revolutionary Games Studio" application/product_name="Thrive" application/file_description="Thrive Game" @@ -101,8 +101,8 @@ codesign/description="" codesign/custom_options=PoolStringArray( ) application/modify_resources=true application/icon="res://assets/misc/icon.ico" -application/file_version="0.6.3.0" -application/product_version="0.6.3.0" +application/file_version="0.6.4.0" +application/product_version="0.6.4.0" application/company_name="Revolutionary Games Studio" application/product_name="Thrive" application/file_description="Thrive Game" @@ -132,8 +132,8 @@ application/icon="res://assets/misc/icon.png" application/identifier="com.revolutionarygamesstudio.thrive" application/signature="" application/app_category="Games" -application/short_version="0.6.3.0" -application/version="0.6.3.0" +application/short_version="0.6.4.0" +application/version="0.6.4.0" application/copyright="Copyright (C) 2013-2023 Revolutionary Games" display/high_res=true privacy/microphone_usage_description="" @@ -243,8 +243,8 @@ codesign/description="" codesign/custom_options=PoolStringArray( ) application/modify_resources=true application/icon="res://assets/misc/icon.ico" -application/file_version="0.6.3.0" -application/product_version="0.6.3.0" +application/file_version="0.6.4.0" +application/product_version="0.6.4.0" application/company_name="Revolutionary Games Studio" application/product_name="Thrive" application/file_description="Thrive Game" diff --git a/native_libs/.gdignore b/native_libs/.gdignore new file mode 100644 index 00000000000..e69de29bb2d diff --git a/project.godot b/project.godot index 89e7072cf53..29b09917cb9 100644 --- a/project.godot +++ b/project.godot @@ -51,6 +51,7 @@ ProceduralDataCache="*res://src/engine/ProceduralDataCache.cs" PhotoStudio="*res://src/engine/PhotoStudio.tscn" PauseManager="*res://src/engine/PauseManager.cs" GUIFocusSetter="*res://src/engine/GUIFocusSetter.cs" +DebugDrawer="*res://src/engine/DebugDrawer.tscn" PostStartupActions="*res://src/engine/PostStartupActions.cs" [debug] @@ -465,6 +466,11 @@ g_science={ "events": [ Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"alt":false,"shift":false,"control":false,"meta":false,"command":false,"pressed":false,"scancode":84,"physical_scancode":0,"unicode":0,"echo":false,"script":null) ] } +d_physics_debug={ +"deadzone": 0.5, +"events": [ Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"alt":false,"shift":false,"control":false,"meta":false,"command":false,"pressed":false,"scancode":16777250,"physical_scancode":0,"unicode":0,"echo":false,"script":null) + ] +} [layer_names] @@ -497,4 +503,5 @@ project/assembly_name="Thrive" [rendering] quality/driver/fallback_to_gles2=true +limits/buffers/immediate_buffer_size_kb=8096 environment/default_environment="res://default_env.tres" diff --git a/simulation_parameters/Constants.cs b/simulation_parameters/Constants.cs index 52263974f6b..de681673b8f 100644 --- a/simulation_parameters/Constants.cs +++ b/simulation_parameters/Constants.cs @@ -11,6 +11,18 @@ /// public static class Constants { + /// + /// Used to prevent lag causing massive simulation instability spikes (due to resource consumption etc. scaling + /// but storage not scaling) + /// + public const float SIMULATION_MAX_DELTA_TIME = 0.2f; + + /// + /// Makes sure that at least this many task threads are left idle when creating membrane generation background + /// tasks. + /// + public const int MEMBRANE_TASKS_LEAVE_EMPTY_THREADS = 2; + /// /// Default length in seconds for an in-game day. If this is changed, the placeholder values in /// NewGameSettings.tscn should also be changed. @@ -18,9 +30,9 @@ public static class Constants public const int DEFAULT_DAY_LENGTH = 180; /// - /// How long the player stays dead before respawning + /// How long the player stays dead before respawning (this is after the animation of dying ends) /// - public const float PLAYER_RESPAWN_TIME = 5.0f; + public const float PLAYER_RESPAWN_TIME = 2.0f; /// /// How long the initial compounds should last (in seconds) @@ -45,6 +57,8 @@ public static class Constants /// public const float SPAWN_SECTOR_SIZE = 120.0f; + public const float MIN_DISTANCE_FROM_PLAYER_FOR_SPAWN = SPAWN_SECTOR_SIZE - 10; + /// /// Scale factor for density of compound cloud spawns /// @@ -130,20 +144,26 @@ public static class Constants public const float FLAGELLA_ENERGY_COST = 4.0f; - public const float FLAGELLA_BASE_FORCE = 75.7f; + public const float FLAGELLA_BASE_FORCE = 250.7f; - public const float CELL_BASE_THRUST = 50.6f; + public const float BASE_MOVEMENT_FORCE = 910.0f; - public const float MICROBE_MOVEMENT_SOUND_EMIT_COOLDOWN = 1.3f; + /// + /// How much the default has volume in a cell. This determines how much + /// additional organelles impact the cell. A normal organelle has a weight of 1 so if this value is 4 then the + /// base density has as much impact on the average density as 4 organelles. + /// + public const float BASE_CELL_DENSITY_VOLUME = 4; - public const float MICROBE_DIGESTION_UPDATE_INTERVAL = 0.0333f; + public const float BASE_CELL_DENSITY = 1000; - public const float CELL_BASE_ROTATION = 0.2f; - public const float CELL_MAX_ROTATION = 0.40f; - public const float CELL_MIN_ROTATION = 0.005f; - public const float CELL_MOMENT_OF_INERTIA_DISTANCE_MULTIPLIER = 0.5f; - public const float CILIA_ROTATION_FACTOR = 0.008f; - public const float CILIA_RADIUS_FACTOR_MULTIPLIER = 0.7f; + public const float MICROBE_MOVEMENT_SOUND_EMIT_COOLDOWN = 1.3f; + + // Note that the speed is reversed, i.e. lower values mean faster + public const float CELL_MAX_ROTATION = 3.0f; + public const float CELL_MIN_ROTATION = 0.2f; + public const float CILIA_ROTATION_FACTOR = 0.08f; + public const float CILIA_RADIUS_FACTOR_MULTIPLIER = 0.8f; public const float CELL_COLONY_MAX_ROTATION_MULTIPLIER = 2.5f; public const float CELL_COLONY_MIN_ROTATION_MULTIPLIER = 0.05f; @@ -166,8 +186,6 @@ public static class Constants public const float CILIA_PULLING_FORCE_FALLOFF_FACTOR = 0.1f; public const float CILIA_CURRENT_GENERATION_ANIMATION_SPEED = 5.0f; - public const int PROCESS_OBJECTS_PER_TASK = 15; - public const int MICROBE_SPAWN_RADIUS = 350; public const int CLOUD_SPAWN_RADIUS = 350; @@ -192,7 +210,7 @@ public static class Constants /// Multiplier for how much cells in a colony contribute to the entity limit. Actually colonies seem quite a bit /// heavier than normal microbes, as such this is set pretty high. /// - public const float MICROBE_COLONY_MEMBER_ENTITY_WEIGHT_MULTIPLIER = 1.15f; + public const float MICROBE_COLONY_MEMBER_ENTITY_WEIGHT_MULTIPLIER = 0.95f; /// /// Extra radius added to the spawn radius of things to allow them to move in the "wrong" direction a bit @@ -225,7 +243,7 @@ public static class Constants /// /// The maximum force that can be applied by currents in the fluid system /// - public const float MAX_FORCE_APPLIED_BY_CURRENTS = 0.0525f; + public const float MAX_FORCE_APPLIED_BY_CURRENTS = 5.25f; public const int TRANSLATION_VERY_INCOMPLETE_THRESHOLD = 30; public const int TRANSLATION_INCOMPLETE_THRESHOLD = 70; @@ -238,12 +256,11 @@ public static class Constants public const float MICROBE_AI_THINK_INTERVAL = 0.3f; /// - /// This is how often the AI microbes look for emitted signaling agent signals from members of their species. - /// This is set pretty high to reduce the performance impact. + /// This is how often entities for emitted signals from other entities. + /// This is set relatively high to reduce the performance impact. This is used for example for AI microbes to + /// detect signaling agents. /// - public const float MICROBE_AI_SIGNAL_REACT_INTERVAL = 1.2f; - - public const int MICROBE_AI_OBJECTS_PER_TASK = 12; + public const float ENTITY_SIGNAL_UPDATE_INTERVAL = 0.15f; public const int INITIAL_SPECIES_POPULATION = 100; @@ -259,22 +276,43 @@ public static class Constants /// public const bool CREATE_COPY_OF_EDITED_SPECIES = false; + public const string MICROBE_MOVEMENT_SOUND = "res://assets/sounds/soundeffects/microbe-movement-ambience.ogg"; + public const string MICROBE_ENGULFING_MODE_SOUND = "res://assets/sounds/soundeffects/engulfment.ogg"; + public const string MICROBE_BINDING_MODE_SOUND = "res://assets/sounds/soundeffects/binding.ogg"; + + public const float MICROBE_MOVEMENT_SOUND_MAX_VOLUME = 0.4f; + + // TODO: should this volume be actually 0? + public const float MICROBE_MOVEMENT_SOUND_START_VOLUME = 1; + /// - /// Max number of concurrent audio players that may be spawned per entity. + /// Max number of concurrent audio players that may be used per entity. /// public const int MAX_CONCURRENT_SOUNDS_PER_ENTITY = 10; + public const float MICROBE_SOUND_MAX_DISTANCE = 300; + public const float MICROBE_SOUND_MAX_DISTANCE_SQUARED = MICROBE_SOUND_MAX_DISTANCE * MICROBE_SOUND_MAX_DISTANCE; + + public const int MAX_CONCURRENT_SOUNDS = 100; + /// /// Max number of concurrent audio players that may be spawned for UI sounds. /// public const int MAX_CONCURRENT_UI_AUDIO_PLAYERS = 10; - public const float CONTACT_IMPULSE_TO_BUMP_SOUND = 8; + public const float CONTACT_PENETRATION_TO_BUMP_SOUND = 0.1f; + + public const float INTERVAL_BETWEEN_SOUND_CACHE_CLEAR = 0.321f; + + /// + /// How long to keep a played sound in memory in case it will be shortly played again + /// + public const float DEFAULT_SOUND_CACHE_TIME = 30; /// - /// Controls with how much force agents are fired + /// Controls with how much speed agents are fired /// - public const float AGENT_EMISSION_IMPULSE_STRENGTH = 20.0f; + public const float AGENT_EMISSION_VELOCITY = 10.0f; public const float OXYTOXY_DAMAGE = 15.0f; @@ -298,14 +336,29 @@ public static class Constants /// public const float MUCILAGE_COOLDOWN_TIMER = 1.5f; + public const float TOXIN_PROJECTILE_PHYSICS_SIZE = 1; + + public const float TOXIN_PROJECTILE_PHYSICS_DENSITY = 700; + + public const float CHUNK_PHYSICS_DAMPING = 0.2f; + public const float MICROBE_PHYSICS_DAMPING = 0.97f; + + /// + /// This only really matters when cells are dead + /// + public const float MICROBE_PHYSICS_DAMPING_ANGULAR = 0.9f; + /// /// Delay when a toxin hits or expires until it is destroyed. This is used to give some time for the effect to /// fade so this must always be at least as long as how long the despawn effect takes visually /// - public const float PROJECTILE_DESPAWN_DELAY = 3; + public const float EMITTER_DESPAWN_DELAY = 3; public const float AGENT_EMISSION_DISTANCE_OFFSET = 0.5f; + /// + /// How long a toxin projectile can fly for before despawning if it doesn't hit anything before that + /// public const float EMITTED_AGENT_LIFETIME = 5.0f; public const int MAX_EMITTED_AGENTS_ON_DEATH = 5; @@ -318,10 +371,33 @@ public static class Constants public const float COMPOUND_RELEASE_FRACTION = 0.9f; + public const float PHYSICS_ALLOWED_Y_AXIS_DRIFT = 0.1f; + + /// + /// Buffers bigger than this number of elements will never be cached so if many entities track more than this + /// many collisions that's going to be bad in terms of memory allocations + /// + public const int MAX_COLLISION_CACHE_BUFFER_RETURN_SIZE = 50; + + /// + /// How many buffers of similar length can be in the collision cache. This is quite high to ensure that basically + /// all entities' buffers can go to the cache for example when loading a save while in game. That is required + /// because most entities have the exact same buffer length. + /// + public const int MAX_COLLISION_CACHE_BUFFERS_OF_SIMILAR_LENGHT = 500; + /// - /// Base mass all microbes have on top of their organelle masses + /// How many collisions each normal entity can detect at once (if more collisions happen during an update the + /// rest are lost and can't be detected by the game logic) /// - public const float MICROBE_BASE_MASS = 0.7f; + public const int MAX_SIMULTANEOUS_COLLISIONS_SMALL = 8; + + /// + /// A very small limit of collisions for entities that don't need to be able to detect many collisions. Note + /// that this is specifically picked to be lower by a power of two than the small limit to make collision + /// recording buffer cache work better (as it should hopefully put these two categories to separate buckets) + /// + public const int MAX_SIMULTANEOUS_COLLISIONS_TINY = 4; /// /// Cooldown between agent emissions, in seconds. @@ -358,13 +434,11 @@ public static class Constants /// public const float COMPOUNDS_TO_VENT_PER_SECOND = 5.0f; - /// - /// Limits how often floating chunks are processed to save on some performance - /// - public const float FLOATING_CHUNK_PROCESS_INTERVAL = 0.05f; + public const float DEFAULT_MICROBE_VENT_THRESHOLD = 2.0f; /// - /// If more chunks exist at once than this, then some are forced to dissolve immediately + /// If more chunks exist at once than this, then some are forced to despawn immediately. This value is lowered + /// as spawned and microbe corpse chunks have now their individual limits (so the real limit is double this) /// public const int FLOATING_CHUNK_MAX_COUNT = 35; @@ -410,29 +484,28 @@ public static class Constants public const float NAME_LABEL_VISIBILITY_DISTANCE = 300.0f; /// - /// This is used just as the default value for health and max - /// health of a microbe. The default membrane actually - /// determines the default health. + /// Maximum number of damage events allowed for an entity. Any more are not recorded and is an error. /// - public const float DEFAULT_HEALTH = 100.0f; + public const int MAX_DAMAGE_EVENTS = 1000; /// /// Amount of health per second regenerated /// - public const float REGENERATION_RATE = 1.5f; + public const float HEALTH_REGENERATION_RATE = 1.5f; + + /// + /// Cells need at least this much ATP to regenerate health passively + /// + public const float HEALTH_REGENERATION_ATP_THRESHOLD = 1; /// /// How often in seconds ATP damage is checked and applied if cell has no ATP /// public const float ATP_DAMAGE_CHECK_INTERVAL = 0.9f; + // TODO: remove if unused with ECS public const float MICROBE_REPRODUCTION_PROGRESS_INTERVAL = 0.05f; - /// - /// Used to prevent lag / loading causing big jumps in reproduction progress - /// - public const float MICROBE_REPRODUCTION_MAX_DELTA_FRAME = 0.2f; - /// /// Because reproduction progress is most often time limited, /// the bars can go to the reproduction ready state way too early, so this being false prevents that. @@ -515,9 +588,9 @@ public static class Constants public const float ENGULFING_ATP_COST_PER_SECOND = 1.5f; /// - /// The speed reduction when a cell is in engulfing mode. + /// The speed reduction (multiplies the movement force) when a cell is in engulfing mode. /// - public const float ENGULFING_MOVEMENT_DIVISION = 1.7f; + public const float ENGULFING_MOVEMENT_MULTIPLIER = 0.588f; /// /// The minimum size ratio between a cell and a possible engulfing victim. @@ -529,6 +602,8 @@ public static class Constants /// public const float ENGULF_EJECTED_COOLDOWN = 2.0f; + public const float ENGULF_EJECTION_VELOCITY = 20.0f; + public const float ENGULF_EJECTION_FORCE = 20.0f; /// @@ -544,7 +619,8 @@ public static class Constants public const float PARTIALLY_DIGESTED_THRESHOLD = 0.5f; /// - /// The maximum digestion progress in which an engulfable is considered fully digested. + /// The maximum digestion progress in which an engulfable is considered fully digested. Do not change this. + /// It is assumed elsewhere that 1 means fully digested so this will break a bunch of stuff if you change this. /// public const float FULLY_DIGESTED_LIMIT = 1.0f; @@ -605,25 +681,27 @@ public static class Constants /// public const float PILUS_BASE_DAMAGE = 20.0f; + public const float PILUS_PHYSICS_SIZE = 4.6f; + /// /// Damage a single injectisome stab does /// public const float INJECTISOME_BASE_DAMAGE = 20.0f; + public const string PILUS_INJECTISOME_UPGRADE_NAME = "injectisome"; + /// - /// How much time (in seconds) a pilus applies invulnerability upon damage. + /// How much time (in seconds) an injectisome applies invulnerability upon damage. Note the invulnerability is + /// not against all other damage types. /// - public const float PILUS_INVULNERABLE_TIME = 0.25f; + public const float PILUS_INVULNERABLE_TIME = 0.35f; /// /// Osmoregulation ATP cost per second per hex /// public const float ATP_COST_FOR_OSMOREGULATION = 1.0f; - /// - /// The default contact store count for objects using contact reporting - /// - public const int DEFAULT_STORE_CONTACTS_COUNT = 4; + public const float MICROBE_FLASH_DURATION = 0.6f; // Darwinian Evo Values public const int CREATURE_DEATH_POPULATION_LOSS = -60; @@ -860,7 +938,11 @@ public static class Constants /// /// Multiplier for how much organelles inside spawned cells contribute to the entity count. /// - public const float ORGANELLE_ENTITY_WEIGHT = 0.5f; + public const float ORGANELLE_ENTITY_WEIGHT = 0.1f; + + public const float MICROBE_BASE_ENTITY_WEIGHT = 2; + + public const float FLOATING_CHUNK_ENTITY_WEIGHT = 1; /// /// How often despawns happen on top of the normal despawns that are part of the spawn cycle @@ -894,6 +976,8 @@ public static class Constants /// public const float HOVER_PANEL_UPDATE_INTERVAL = 0.1f; + public const int MAX_RAY_HITS_FOR_INSPECT = 20; + public const float TOOLTIP_OFFSET = 20; public const float TOOLTIP_DEFAULT_DELAY = 1.0f; public const float TOOLTIP_FADE_SPEED = 0.25f; @@ -974,68 +1058,20 @@ public static class Constants /// public const float MICROBE_HOVER_DETECTION_EXTRA_RADIUS_SQUARED = 2 * 2; - public const float PROCEDURAL_CACHE_CLEAN_INTERVAL = 9.3f; - public const float PROCEDURAL_CACHE_MEMBRANE_KEEP_TIME = 500; - - /// - /// All Nodes tagged with this are handled by the spawn system for despawning - /// - public const string SPAWNED_GROUP = "spawned"; - /// - /// All Nodes tagged with this are handled by the timed life system for despawning + /// Buffs small bacteria /// - public const string TIMED_GROUP = "timed"; + public const float MICROBE_MIN_ABSORB_RADIUS = 3; - /// - /// All RigidBody nodes tagged with this are affected by currents by the fluid system - /// - public const string FLUID_EFFECT_GROUP = "fluid_effect"; - - /// - /// All Nodes tagged with this are handled by the process system. Can't be just "process" as that conflicts with - /// godot idle_process and process, at least I think it does. - /// - public const string PROCESS_GROUP = "run_processes"; - - /// - /// All Nodes tagged with this are handled by the ai system - /// - public const string AI_GROUP = "ai"; - - /// - /// Microbes tagged with this are handled by the to be processed. - /// - /// - /// - /// NOTE: This is not related to which is in the context of in-game compounds - /// processes, this is related to the engine's on the nodes. - /// - /// - public const string RUNNABLE_MICROBE_GROUP = "microbe_runnable"; + public const float PROCEDURAL_CACHE_CLEAN_INTERVAL = 9.3f; + public const float PROCEDURAL_CACHE_MEMBRANE_KEEP_TIME = 500; + public const float PROCEDURAL_CACHE_MICROBE_SHAPE_TIME = 7000; + public const float PROCEDURAL_CACHE_LOADED_SHAPE_KEEP_TIME = 1000; - /// - /// All Nodes tagged with this are considered Microbes that the AI can react to - /// - /// - /// - /// TODO: quite a few of these AI_TAG starting constants need to be renamed as these are generally used to - /// find relevant entities for things that aren't the AI system - /// - /// - public const string AI_TAG_MICROBE = "microbe"; + // TODO: convert prototypes over to an ECS system as well public const string ENTITY_TAG_CREATURE = "creature"; - /// - /// All Nodes tagged with this are considered FloatingChunks that the AI can react to - /// - public const string AI_TAG_CHUNK = "chunk"; - - public const string PLAYER_GROUP = "player"; - - public const string PLAYER_REPRODUCED_GROUP = "player_offspring"; - public const string INTERACTABLE_GROUP = "interactable"; public const string CITY_ENTITY_GROUP = "city"; @@ -1098,6 +1134,8 @@ public static class Constants public const string BUILD_INFO_FILE = "res://simulation_parameters/revision.json"; + public const string PHYSICS_DUMP_PATH = LOGS_FOLDER + "/physics_dump.bin"; + public const bool VERBOSE_SIMULATION_PARAMETER_LOADING = false; /// @@ -1141,6 +1179,11 @@ public static class Constants public const int KIBIBYTE = 1024; public const int MEBIBYTE = 1024 * KIBIBYTE; + /// + /// Max bytes to allocate on the stack, any bigger data needs to allocate heap memory + /// + public const int MAX_STACKALLOC = 1024; + /// /// Delay for the compound row to hide when standing still and compound amount is 0. /// @@ -1237,6 +1280,11 @@ public static class Constants public const float PATCH_REGION_BORDER_WIDTH = 6.0f; public const int PATCH_GENERATION_MAX_RETRIES = 100; + /// + /// If set to true then physics debug draw gets enabled when the game starts + /// + public const bool AUTOMATICALLY_TURN_ON_PHYSICS_DEBUG_DRAW = false; + /// /// Extra time passed to when exiting the editor. Needs to be close to (or higher) /// than the long message time as defined in @@ -1424,9 +1472,6 @@ public static class Constants private const uint FreeCompoundAmountIsLessThanUsePerSecond = (MICROBE_REPRODUCTION_FREE_COMPOUNDS < MICROBE_REPRODUCTION_MAX_COMPOUND_USE) ? 0 : -42; - private const uint ReproductionProgressIntervalLessThanMaxDelta = - (MICROBE_REPRODUCTION_PROGRESS_INTERVAL < MICROBE_REPRODUCTION_MAX_DELTA_FRAME) ? 0 : -42; - private const uint ReproductionTutorialDelaysAreSensible = (MICROBE_REPRODUCTION_TUTORIAL_DELAY + 1 < MICROBE_EDITOR_BUTTON_TUTORIAL_DELAY) ? 0 : -42; diff --git a/simulation_parameters/SimulationParameters.cs b/simulation_parameters/SimulationParameters.cs index 15f6ace2812..79a8ff068bf 100644 --- a/simulation_parameters/SimulationParameters.cs +++ b/simulation_parameters/SimulationParameters.cs @@ -47,6 +47,8 @@ public class SimulationParameters : Node private Dictionary unitTypes = null!; private Dictionary spaceStructures = null!; private Dictionary technologies = null!; + private Dictionary visualResources = null!; + private Dictionary visualResourceByIdentifier = null!; // These are for mutations to be able to randomly pick items in a weighted manner private List prokaryoticOrganelles = null!; @@ -170,6 +172,9 @@ public override void _Ready() technologies = LoadRegistry("res://simulation_parameters/awakening_stage/technologies.json"); + visualResources = + LoadRegistry("res://simulation_parameters/common/visual_resources.json"); + // Build info is only loaded if the file is present using var directory = new Directory(); @@ -490,6 +495,29 @@ public IEnumerable GetTechnologies() return technologies.Values; } + public VisualResourceData GetVisualResource(VisualResourceIdentifier identifier) + { + if (visualResourceByIdentifier.TryGetValue(identifier, out var result)) + return result; + + GD.PrintErr("Visual resource doesn't exist: ", (long)identifier); + return GetErrorVisual(); + } + + public VisualResourceData GetVisualResource(string internalName) + { + if (visualResources.TryGetValue(internalName, out var result)) + return result; + + GD.PrintErr("Visual resource internal name doesn't exist: ", internalName); + return GetErrorVisual(); + } + + public VisualResourceData GetErrorVisual() + { + return visualResourceByIdentifier[VisualResourceIdentifier.Error]; + } + /// /// Applies translations to all registry loaded types. Called whenever the locale is changed /// @@ -517,6 +545,7 @@ public void ApplyTranslations() ApplyRegistryObjectTranslations(unitTypes); ApplyRegistryObjectTranslations(spaceStructures); ApplyRegistryObjectTranslations(technologies); + ApplyRegistryObjectTranslations(visualResources); } private static void CheckRegistryType(Dictionary registry) @@ -661,6 +690,7 @@ private void CheckForInvalidValues() CheckRegistryType(unitTypes); CheckRegistryType(spaceStructures); CheckRegistryType(technologies); + CheckRegistryType(visualResources); NameGenerator.Check(string.Empty); PatchMapNameGenerator.Check(string.Empty); @@ -737,6 +767,8 @@ private void ResolveValueRelationships() BuildOrganelleChances(); // TODO: there could also be a check for making sure non-existent compounds, processes etc. are not used + + visualResourceByIdentifier = visualResources.ToDictionary(t => t.Value.Identifier, t => t.Value); } private void BuildOrganelleChances() diff --git a/simulation_parameters/VisualResourceData.cs b/simulation_parameters/VisualResourceData.cs new file mode 100644 index 00000000000..ddb05a5486d --- /dev/null +++ b/simulation_parameters/VisualResourceData.cs @@ -0,0 +1,44 @@ +using System; +using Newtonsoft.Json; + +/// +/// Info on loading a visual resource (with potentially different quality levels for graphics settings) +/// +public class VisualResourceData : IRegistryType +{ + [JsonIgnore] + public VisualResourceIdentifier Identifier { get; private set; } + + [JsonProperty] + public string NormalQualityPath { get; private set; } = string.Empty; + + [JsonIgnore] + public string InternalName { get; set; } = null!; + + public void Check(string name) + { + if (string.IsNullOrWhiteSpace(NormalQualityPath)) + throw new InvalidRegistryDataException(name, GetType().Name, "Missing normal quality scene path"); + + // TODO: for safety should these objects verify the scene paths? That would mean checking a ton of scenes if + // a bunch of game visuals will go through this system + + // Parse the identifier from the internal name + if (!Enum.TryParse(InternalName, out VisualResourceIdentifier identifier)) + { + throw new InvalidRegistryDataException(name, GetType().Name, "Failed to parse internal name as identifier"); + } + + Identifier = identifier; + + if (Identifier == VisualResourceIdentifier.None) + throw new InvalidRegistryDataException(name, GetType().Name, "Resource identifier type is none"); + + // Don't load any of the scenes here as otherwise all of the game visuals would always be forced to be loaded + // in memory. Instead individual game states should manage keeping scenes loaded while they might instance them + } + + public void ApplyTranslations() + { + } +} diff --git a/simulation_parameters/VisualResourceIdentifier.cs b/simulation_parameters/VisualResourceIdentifier.cs new file mode 100644 index 00000000000..0d32385c61e --- /dev/null +++ b/simulation_parameters/VisualResourceIdentifier.cs @@ -0,0 +1,20 @@ +/// +/// Identifiers for . Exact values are used in saves so new values must be appended +/// at the end. +/// +public enum VisualResourceIdentifier +{ + /// + /// No visual resource + /// + None = 0, + + /// + /// An error model when something can't be found + /// + Error, + + CellBurstEffect, + + AgentProjectile, +} diff --git a/simulation_parameters/common/visual_resources.json b/simulation_parameters/common/visual_resources.json new file mode 100644 index 00000000000..6714e19d06e --- /dev/null +++ b/simulation_parameters/common/visual_resources.json @@ -0,0 +1,11 @@ +{ + "Error": { + "NormalQualityPath": "res://assets/models/ErrorModel.tscn" + }, + "CellBurstEffect": { + "NormalQualityPath": "res://src/microbe_stage/particles/CellBurstEffect.tscn" + }, + "AgentProjectile": { + "NormalQualityPath": "res://src/microbe_stage/AgentProjectile.tscn" + } +} diff --git a/simulation_parameters/microbe_stage/biomes.json b/simulation_parameters/microbe_stage/biomes.json index 8dc795a4702..052e3b36083 100644 --- a/simulation_parameters/microbe_stage/biomes.json +++ b/simulation_parameters/microbe_stage/biomes.json @@ -26,7 +26,8 @@ "name": "FLOATING_HAZARD", "meshes": [ { - "scenePath": "res://src/microbe_stage/ToxinCloud.tscn" + "scenePath": "res://src/microbe_stage/ToxinCloud.tscn", + "isParticles": true } ], "density": 0.0001, @@ -88,7 +89,7 @@ "dissolves": true, "radius": 10, "chunkScale": 1, - "mass": 100, + "mass": 10, "size": 100, "ventAmount": 20, "damages": 0, @@ -106,7 +107,8 @@ { "scenePath": "res://assets/models/easter_eggs/GooglyEyeCell.tscn", "sceneModelPath": "Armature/Skeleton/Cube", - "sceneAnimationPath": "AnimationPlayer" + "sceneAnimationPath": "AnimationPlayer", + "playAnimation": true } ], "density": 1e-7, @@ -195,7 +197,8 @@ "name": "FLOATING_HAZARD", "meshes": [ { - "scenePath": "res://src/microbe_stage/ToxinCloud.tscn" + "scenePath": "res://src/microbe_stage/ToxinCloud.tscn", + "isParticles": true } ], "density": 0.0001, @@ -337,7 +340,7 @@ "dissolves": true, "radius": 10, "chunkScale": 1, - "mass": 100, + "mass": 10, "size": 100, "ventAmount": 20, "damages": 0, @@ -355,7 +358,8 @@ { "scenePath": "res://assets/models/easter_eggs/GooglyEyeCell.tscn", "sceneModelPath": "Armature/Skeleton/Cube", - "sceneAnimationPath": "AnimationPlayer" + "sceneAnimationPath": "AnimationPlayer", + "playAnimation": true } ], "density": 1e-7, @@ -442,7 +446,8 @@ "name": "FLOATING_HAZARD", "meshes": [ { - "scenePath": "res://src/microbe_stage/ToxinCloud.tscn" + "scenePath": "res://src/microbe_stage/ToxinCloud.tscn", + "isParticles": true } ], "density": 0.0001, @@ -504,7 +509,7 @@ "dissolves": true, "radius": 10, "chunkScale": 1, - "mass": 100, + "mass": 10, "size": 100, "ventAmount": 20, "damages": 0, @@ -522,7 +527,8 @@ { "scenePath": "res://assets/models/easter_eggs/GooglyEyeCell.tscn", "sceneModelPath": "Armature/Skeleton/Cube", - "sceneAnimationPath": "AnimationPlayer" + "sceneAnimationPath": "AnimationPlayer", + "playAnimation": true } ], "density": 1e-7, @@ -608,7 +614,8 @@ "name": "FLOATING_HAZARD", "meshes": [ { - "scenePath": "res://src/microbe_stage/ToxinCloud.tscn" + "scenePath": "res://src/microbe_stage/ToxinCloud.tscn", + "isParticles": true } ], "density": 0.00007, @@ -670,7 +677,7 @@ "dissolves": true, "radius": 10, "chunkScale": 1, - "mass": 100, + "mass": 10, "size": 100, "ventAmount": 20, "damages": 0, @@ -768,7 +775,8 @@ { "scenePath": "res://assets/models/easter_eggs/GooglyEyeCell.tscn", "sceneModelPath": "Armature/Skeleton/Cube", - "sceneAnimationPath": "AnimationPlayer" + "sceneAnimationPath": "AnimationPlayer", + "playAnimation": true } ], "density": 1e-7, @@ -854,7 +862,8 @@ "name": "FLOATING_HAZARD", "meshes": [ { - "scenePath": "res://src/microbe_stage/ToxinCloud.tscn" + "scenePath": "res://src/microbe_stage/ToxinCloud.tscn", + "isParticles": true } ], "density": 0.0001, @@ -916,7 +925,7 @@ "dissolves": true, "radius": 10, "chunkScale": 1, - "mass": 100, + "mass": 10, "size": 100, "ventAmount": 20, "damages": 0, @@ -1014,7 +1023,8 @@ { "scenePath": "res://assets/models/easter_eggs/GooglyEyeCell.tscn", "sceneModelPath": "Armature/Skeleton/Cube", - "sceneAnimationPath": "AnimationPlayer" + "sceneAnimationPath": "AnimationPlayer", + "playAnimation": true } ], "density": 1e-7, @@ -1100,7 +1110,8 @@ "name": "FLOATING_HAZARD", "meshes": [ { - "scenePath": "res://src/microbe_stage/ToxinCloud.tscn" + "scenePath": "res://src/microbe_stage/ToxinCloud.tscn", + "isParticles": true } ], "density": 0.0001, @@ -1162,7 +1173,7 @@ "dissolves": true, "radius": 10, "chunkScale": 1, - "mass": 100, + "mass": 10, "size": 100, "ventAmount": 20, "damages": 0, @@ -1260,7 +1271,8 @@ { "scenePath": "res://assets/models/easter_eggs/GooglyEyeCell.tscn", "sceneModelPath": "Armature/Skeleton/Cube", - "sceneAnimationPath": "AnimationPlayer" + "sceneAnimationPath": "AnimationPlayer", + "playAnimation": true } ], "density": 1e-7, @@ -1347,7 +1359,8 @@ "name": "FLOATING_HAZARD", "meshes": [ { - "scenePath": "res://src/microbe_stage/ToxinCloud.tscn" + "scenePath": "res://src/microbe_stage/ToxinCloud.tscn", + "isParticles": true } ], "density": 0.0001, @@ -1409,7 +1422,7 @@ "dissolves": true, "radius": 10, "chunkScale": 1, - "mass": 100, + "mass": 10, "size": 100, "ventAmount": 20, "damages": 0, @@ -1427,7 +1440,8 @@ { "scenePath": "res://assets/models/easter_eggs/GooglyEyeCell.tscn", "sceneModelPath": "Armature/Skeleton/Cube", - "sceneAnimationPath": "AnimationPlayer" + "sceneAnimationPath": "AnimationPlayer", + "playAnimation": true } ], "density": 1e-7, @@ -1513,7 +1527,8 @@ "name": "FLOATING_HAZARD", "meshes": [ { - "scenePath": "res://src/microbe_stage/ToxinCloud.tscn" + "scenePath": "res://src/microbe_stage/ToxinCloud.tscn", + "isParticles": true } ], "density": 0.0001, @@ -1575,7 +1590,7 @@ "dissolves": true, "radius": 10, "chunkScale": 1, - "mass": 100, + "mass": 10, "size": 100, "ventAmount": 20, "damages": 0, @@ -1593,7 +1608,8 @@ { "scenePath": "res://assets/models/easter_eggs/GooglyEyeCell.tscn", "sceneModelPath": "Armature/Skeleton/Cube", - "sceneAnimationPath": "AnimationPlayer" + "sceneAnimationPath": "AnimationPlayer", + "playAnimation": true } ], "density": 1e-7, @@ -1683,7 +1699,8 @@ "name": "FLOATING_HAZARD", "meshes": [ { - "scenePath": "res://src/microbe_stage/ToxinCloud.tscn" + "scenePath": "res://src/microbe_stage/ToxinCloud.tscn", + "isParticles": true } ], "density": 0.00005, @@ -1764,7 +1781,7 @@ "dissolves": true, "radius": 10, "chunkScale": 1, - "mass": 100, + "mass": 10, "size": 100, "ventAmount": 20, "damages": 0, @@ -1782,7 +1799,8 @@ { "scenePath": "res://assets/models/easter_eggs/GooglyEyeCell.tscn", "sceneModelPath": "Armature/Skeleton/Cube", - "sceneAnimationPath": "AnimationPlayer" + "sceneAnimationPath": "AnimationPlayer", + "playAnimation": true } ], "density": 1e-7, @@ -1869,7 +1887,8 @@ "name": "FLOATING_HAZARD", "meshes": [ { - "scenePath": "res://src/microbe_stage/ToxinCloud.tscn" + "scenePath": "res://src/microbe_stage/ToxinCloud.tscn", + "isParticles": true } ], "density": 0.0001, @@ -1931,7 +1950,7 @@ "dissolves": true, "radius": 10, "chunkScale": 1, - "mass": 100, + "mass": 10, "size": 100, "ventAmount": 20, "damages": 0, @@ -1949,7 +1968,8 @@ { "scenePath": "res://assets/models/easter_eggs/GooglyEyeCell.tscn", "sceneModelPath": "Armature/Skeleton/Cube", - "sceneAnimationPath": "AnimationPlayer" + "sceneAnimationPath": "AnimationPlayer", + "playAnimation": true } ], "density": 1e-7, @@ -2035,7 +2055,8 @@ "name": "FLOATING_HAZARD", "meshes": [ { - "scenePath": "res://src/microbe_stage/ToxinCloud.tscn" + "scenePath": "res://src/microbe_stage/ToxinCloud.tscn", + "isParticles": true } ], "density": 0.0001, @@ -2097,7 +2118,7 @@ "dissolves": true, "radius": 10, "chunkScale": 1, - "mass": 100, + "mass": 10, "size": 100, "ventAmount": 20, "damages": 0, @@ -2195,7 +2216,8 @@ { "scenePath": "res://assets/models/easter_eggs/GooglyEyeCell.tscn", "sceneModelPath": "Armature/Skeleton/Cube", - "sceneAnimationPath": "AnimationPlayer" + "sceneAnimationPath": "AnimationPlayer", + "playAnimation": true } ], "density": 1e-7, diff --git a/simulation_parameters/microbe_stage/organelles.json b/simulation_parameters/microbe_stage/organelles.json index 7f786846a45..dbc5e616c9a 100644 --- a/simulation_parameters/microbe_stage/organelles.json +++ b/simulation_parameters/microbe_stage/organelles.json @@ -12,14 +12,16 @@ } ], "processes": {}, - "components": { - "pilus": {} - }, + "featureTags": [ + "Pilus" + ], "shouldScale": false, "prokaryoteChance": 0.5, "chanceToCreate": 0.5, - "mass": 0.3, + "Density": 1500, + "RelativeDensityVolume": 0, "displayScene": "res://assets/models/organelles/Pilus.tscn", + "positionedExternally": true, "name": "ORGANELLE_PILUS", "iconPath": "res://assets/textures/gui/bevel/parts/PilusIcon.png", "editorButtonGroup": "External", @@ -63,7 +65,7 @@ }, "prokaryoteChance": 2, "chanceToCreate": 0.5, - "mass": 0.08, + "Density": 1100, "displayScene": "res://assets/models/organelles/Rusticyanin.tscn", "name": "RUSTICYANIN", "productionColour": "#3293f7", @@ -95,7 +97,7 @@ }, "prokaryoteChance": 2, "chanceToCreate": 1, - "mass": 0.08, + "Density": 1000, "displayScene": "res://assets/models/organelles/Nitrogenase.tscn", "name": "NITROGENASE", "productionColour": "#3feb67", @@ -126,7 +128,7 @@ }, "prokaryoteChance": 2, "chanceToCreate": 0, - "mass": 0.1, + "Density": 1000, "displayScene": "", "corpseChunkScene": "res://assets/models/organelles/Cytoplasm.tscn", "name": "PROTOPLASM", @@ -159,7 +161,7 @@ }, "prokaryoteChance": 2, "chanceToCreate": 0.5, - "mass": 0.08, + "Density": 900, "displayScene": "res://assets/models/organelles/ChemoSynthesizingProteins.tscn", "name": "CHEMOSYNTHESIZING_PROTEINS", "productionColour": "#64f995", @@ -195,7 +197,7 @@ }, "prokaryoteChance": 0.5, "chanceToCreate": 1, - "mass": 0.08, + "Density": 1000, "displayScene": "res://assets/models/organelles/OxytoxyProteins.tscn", "name": "OXYTOXISOME", "productionColour": "#834acb", @@ -227,7 +229,7 @@ }, "prokaryoteChance": 2, "chanceToCreate": 0.5, - "mass": 0.1, + "Density": 1200, "displayScene": "res://assets/models/organelles/Chromatophore.tscn", "name": "THYLAKOIDS", "productionColour": "#40f0ac", @@ -258,7 +260,7 @@ }, "prokaryoteChance": 2, "chanceToCreate": 0.5, - "mass": 0.08, + "Density": 900, "displayScene": "res://assets/models/organelles/Metabolosome.tscn", "name": "METABOLOSOMES", "productionColour": "#26e0ff", @@ -293,7 +295,7 @@ }, "prokaryoteChance": 0, "chanceToCreate": 1, - "mass": 0.1, + "Density": 1200, "displayScene": "res://assets/models/organelles/NitrogenFixingPlastid.tscn", "name": "NITROGEN_FIXING_PLASTID", "productionColour": "#4fc9ff", @@ -325,7 +327,7 @@ }, "prokaryoteChance": 2, "chanceToCreate": 0.5, - "mass": 0.1, + "Density": 900, "displayScene": "res://assets/models/organelles/Thermosynthase.tscn", "name": "THERMOSYNTHASE", "productionColour": "#6a5cc6", @@ -361,7 +363,8 @@ }, "prokaryoteChance": 0, "chanceToCreate": 1, - "mass": 0.1, + "Density": 900, + "RelativeDensityVolume": 1.3, "displayScene": "res://assets/models/organelles/Chemoplast.tscn", "name": "CHEMOPLAST", "iconPath": "res://assets/textures/gui/bevel/parts/ChemoplastIcon.png", @@ -387,16 +390,17 @@ "capacity": 0.5 }, "movement": { - "torque": 300, "momentum": 50 } }, "prokaryoteChance": 2, "chanceToCreate": 6, - "mass": 0.2, + "Density": 1200, + "RelativeDensityVolume": 0.2, "displayScene": "res://assets/models/organelles/Flagellum.tscn", "displaySceneModelPath": "Armature001/Skeleton/flagella", "displaySceneAnimation": "AnimationPlayer", + "positionedExternally": true, "name": "FLAGELLUM", "productionColour": "#ff9721", "consumptionColour": "#ff9721", @@ -424,7 +428,7 @@ }, "prokaryoteChance": 0, "chanceToCreate": 3, - "mass": 0.3, + "Density": 500, "displayScene": "res://assets/models/organelles/Vacuole.tscn", "name": "VACUOLE", "iconPath": "res://assets/textures/gui/bevel/parts/VacuoleIcon.png", @@ -459,7 +463,8 @@ }, "prokaryoteChance": 0, "chanceToCreate": 3, - "mass": 0.1, + "Density": 900, + "RelativeDensityVolume": 1.2, "displayScene": "res://assets/models/organelles/Mitochondrion.tscn", "name": "MITOCHONDRION", "productionColour": "#26ffce", @@ -495,7 +500,7 @@ }, "prokaryoteChance": 0, "chanceToCreate": 1, - "mass": 0.1, + "Density": 1000, "displayScene": "res://assets/models/organelles/Oxytoxy.tscn", "name": "TOXIN_VACUOLE", "productionColour": "#624acb", @@ -521,12 +526,14 @@ "components": { "storage": { "capacity": 1 - }, - "bindingAgent": {} + } }, + "featureTags": [ + "BindingAgent" + ], "prokaryoteChance": 0, "chanceToCreate": 0, - "mass": 0.1, + "Density": 500, "displayScene": "res://assets/models/organelles/BindingAgent.tscn", "name": "BINDING_AGENT", "iconPath": "res://assets/textures/gui/bevel/parts/BindingAgentIcon.png", @@ -565,7 +572,8 @@ }, "prokaryoteChance": 0, "chanceToCreate": 1, - "mass": 0.3, + "Density": 1200, + "RelativeDensityVolume": 1.8, "displayScene": "res://assets/models/organelles/Chloroplast.tscn", "name": "CHLOROPLAST", "iconPath": "res://assets/textures/gui/bevel/parts/ChloroplastIcon.png", @@ -595,7 +603,7 @@ }, "prokaryoteChance": 1, "chanceToCreate": 1, - "mass": 0.08, + "Density": 1000, "displayScene": "", "corpseChunkScene": "res://assets/models/organelles/Cytoplasm.tscn", "name": "CYTOPLASM", @@ -657,13 +665,16 @@ "components": { "storage": { "capacity": 4 - }, - "nucleus": {} + } }, + "featureTags": [ + "Nucleus" + ], "shouldScale": false, "prokaryoteChance": 0, "chanceToCreate": 0, - "mass": 0.5, + "Density": 1500, + "RelativeDensityVolume": 6, "displayScene": "res://assets/models/organelles/Nucleus.tscn", "name": "NUCLEUS", "iconPath": "res://assets/textures/gui/bevel/parts/NucleusIcon.png", @@ -689,8 +700,10 @@ }, "prokaryoteChance": 0.2, "chanceToCreate": 0.2, - "mass": 0.1, + "Density": 1200, + "RelativeDensityVolume": 0.5, "displayScene": "res://assets/models/organelles/Chemoreceptor.tscn", + "positionedExternally": true, "name": "CHEMORECEPTOR", "iconPath": "res://assets/textures/gui/bevel/parts/ChemoreceptorIcon.png", "upgradeGUI": "res://src/microbe_stage/editor/upgrades/ChemoreceptorUpgradeGUI.tscn", @@ -720,10 +733,12 @@ }, "prokaryoteChance": 0.3, "chanceToCreate": 0.3, - "mass": 0.1, + "Density": 900, + "RelativeDensityVolume": 0.8, "displayScene": "res://assets/models/organelles/SlimeJet.tscn", "displaySceneModelPath": "Armature/Skeleton/Cube", "displaySceneAnimation": "AnimationPlayer", + "positionedExternally": true, "name": "SLIME_JET", "consumptionColour": "#00ffcc", "iconPath": "res://assets/textures/gui/bevel/parts/SlimeJetIcon.png", @@ -743,12 +758,12 @@ } ], "processes": {}, - "components": { - "signalingAgent": {} - }, + "featureTags": [ + "SignalingAgent" + ], "prokaryoteChance": 0, "chanceToCreate": 0.4, - "mass": 0.1, + "Density": 900, "displayScene": "res://assets/models/organelles/SignalingAgent.tscn", "name": "SIGNALING_AGENT", "iconPath": "res://assets/textures/gui/bevel/parts/SignalingAgentIcon.png", @@ -806,7 +821,7 @@ }, "prokaryoteChance": 0, "chanceToCreate": 1, - "mass": 0.2, + "Density": 900, "displayScene": "res://assets/models/organelles/Thermoplast.tscn", "name": "THERMOPLAST", "productionColour": "#9168b5", @@ -835,10 +850,12 @@ }, "prokaryoteChance": 0, "chanceToCreate": 6, - "mass": 0.22, + "Density": 1200, + "RelativeDensityVolume": 0.2, "displayScene": "res://assets/models/organelles/Cilia.tscn", "displaySceneModelPath": "Armature/Skeleton/Cube003", "displaySceneAnimation": "AnimationPlayer", + "positionedExternally": true, "name": "CILIA", "productionColour": "#ffae21", "consumptionColour": "#ffae21", @@ -886,7 +903,7 @@ }, "prokaryoteChance": 0, "chanceToCreate": 0.4, - "mass": 0.1, + "Density": 500, "displayScene": "res://assets/models/organelles/Lysosome.tscn", "name": "LYSOSOME", "iconPath": "res://assets/textures/gui/bevel/parts/LysosomeIcon.png", @@ -908,13 +925,15 @@ } ], "processes": {}, - "components": { - "axon": {} - }, + "featureTags": [ + "Axon" + ], "prokaryoteChance": 0, "chanceToCreate": 0, - "mass": 0.15, + "Density": 1200, + "RelativeDensityVolume": 1.2, "displayScene": "res://assets/models/organelles/Axon.tscn", + "positionedExternally": true, "name": "ORGANELLE_AXON", "iconPath": "res://assets/textures/gui/bevel/parts/AxonIcon.png", "requiresNucleus": true, @@ -950,12 +969,13 @@ } ], "processes": {}, - "components": { - "myofibril": {} - }, + "featureTags": [ + "Myofibril" + ], "prokaryoteChance": 0, "chanceToCreate": 0, - "mass": 0.2, + "Density": 1200, + "RelativeDensityVolume": 3, "displayScene": "res://assets/models/organelles/Myofibril.tscn", "name": "ORGANELLE_MYOFIBRIL", "iconPath": "res://assets/textures/gui/bevel/parts/MyofibrilIcon.png", diff --git a/src/auto-evo/simulation/SimulationCache.cs b/src/auto-evo/simulation/SimulationCache.cs index a077a02c8a0..4352fd1fa41 100644 --- a/src/auto-evo/simulation/SimulationCache.cs +++ b/src/auto-evo/simulation/SimulationCache.cs @@ -2,6 +2,7 @@ { using System; using System.Collections.Generic; + using Systems; /// /// Caches some information in auto-evo runs to speed them up diff --git a/src/benchmark/DummySpawnSystem.cs b/src/benchmark/DummySpawnSystem.cs index 2c1386b48a9..7ab75a3ad9a 100644 --- a/src/benchmark/DummySpawnSystem.cs +++ b/src/benchmark/DummySpawnSystem.cs @@ -1,4 +1,4 @@ -using System; +using DefaultEcs.Command; using Godot; /// @@ -6,19 +6,25 @@ /// public class DummySpawnSystem : ISpawnSystem { - private readonly Action? addTrackedCallback; + private readonly OnEntityAddedCallback? addTrackedCallback; - public DummySpawnSystem(Action? addTrackedCallback = null) + public DummySpawnSystem(OnEntityAddedCallback? addTrackedCallback = null) { this.addTrackedCallback = addTrackedCallback; } + public delegate void OnEntityAddedCallback(in EntityRecord entityRecord); + public bool AllowReproduction { get; set; } public void Init() { } + public void Update(float delta) + { + } + public void Clear() { } @@ -27,11 +33,11 @@ public void DespawnAll() { } - public void Process(float delta, Vector3 playerPosition) + public void ReportPlayerPosition(Vector3 position) { } - public void AddEntityToTrack(ISpawned entity) + public void NotifyExternalEntitySpawned(in EntityRecord entity, float despawnRadiusSquared, float entityWeight) { addTrackedCallback?.Invoke(entity); } diff --git a/src/benchmark/microbe/MicrobeBenchmark.cs b/src/benchmark/microbe/MicrobeBenchmark.cs index bab2396ce79..bc493699d1b 100644 --- a/src/benchmark/microbe/MicrobeBenchmark.cs +++ b/src/benchmark/microbe/MicrobeBenchmark.cs @@ -3,6 +3,8 @@ using System.Globalization; using System.Linq; using System.Text; +using Components; +using DefaultEcs; using Godot; /// @@ -76,7 +78,7 @@ public class MicrobeBenchmark : Node // to be added private readonly DummySpawnSystem dummySpawnSystem = new(); - private readonly List> spawnedMicrobes = new(); + private readonly List spawnedMicrobes = new(); private readonly List generatedSpecies = new(); private readonly List fpsValues = new(); @@ -93,8 +95,6 @@ public class MicrobeBenchmark : Node private Node worldRoot = null!; private Node dynamicRoot = null!; - private PackedScene microbeScene = null!; - private CompoundCloudSystem? cloudSystem; #pragma warning restore CA2213 @@ -104,21 +104,17 @@ public class MicrobeBenchmark : Node private GameWorld? world; private GameProperties? gameProperties; - private FluidSystem? fluidSystem; - private MicrobeSystem? microbeSystem; - private ProcessSystem? processSystem; - private MicrobeAISystem? microbeAI; - private FloatingChunkSystem? chunkSystem; - private TimedLifeSystem? timedLifeSystem; + + private MicrobeWorldSimulation? microbeSimulation; + + private EntitySet? microbeEntities; private Random random = new(RANDOM_SEED); - private Random aiRandom = new(); private int aiGroup1Seed; private int aiGroup2Seed; private bool preventDying; - private bool runAI; private int internalPhaseCounter; private float timer; @@ -127,6 +123,7 @@ public class MicrobeBenchmark : Node private double spawnAngle; private float spawnDistance; private float timeSinceSpawn; + private bool spawnedSomething; private float microbeStationaryResult; private float microbeAIResult; @@ -156,8 +153,6 @@ public override void _Ready() worldRoot = GetNode(WorldRootPath); dynamicRoot = GetNode(DynamicRootPath); - microbeScene = SpawnHelpers.LoadMicrobeScene(); - guiContainer.Visible = true; benchmarkFinishedText.Visible = false; copyResultsButton.Visible = false; @@ -186,6 +181,9 @@ public override void _Process(float delta) timer += delta; timeSinceSpawn += delta; + if (spawnedSomething) + CheckSpawnedMicrobes(); + PruneDeadMicrobes(); if (preventDying) @@ -193,26 +191,15 @@ public override void _Process(float delta) // Force health back up for cells to prevent them from dying foreach (var entityReference in spawnedMicrobes) { - var microbe = entityReference.Value; + ref var health = ref entityReference.Get(); - microbe?.TestOverrideHitpoints(microbe.MaxHitpoints); + health.CurrentHealth = health.MaxHealth; } } - processSystem?.Process(delta); - - // Run the microbe system to process microbes - microbeSystem?.Process(delta); + microbeEntities?.Complete(); - chunkSystem?.Process(delta, new Vector3(0, 0, 0)); - timedLifeSystem?.Process(delta); - - if (runAI) - { - // The AI thinking randomly adds some randomness - // Update AI for the cells - microbeAI?.Process(delta, aiRandom); - } + microbeSimulation?.ProcessAll(delta); switch (internalPhaseCounter) { @@ -223,11 +210,14 @@ public override void _Process(float delta) BenchmarkHelpers.PerformBenchmarkSetup(storedSettings); GenerateWorldAndSpecies(); - SetupEnoughGameSystemsToRun(); + SetupSimulation(); + + if (microbeSimulation == null) + throw new InvalidOperationException("Microbe sim not setup"); spawnAngle = 0; spawnDistance = 1; - runAI = false; + microbeSimulation.RunAI = false; preventDying = true; IncrementPhase(); @@ -279,8 +269,8 @@ public override void _Process(float delta) case 4: { // Enable AI - runAI = true; - aiRandom = new Random(aiGroup1Seed); + microbeSimulation!.RunAI = true; + microbeSimulation.OverrideMicrobeAIRandomSeed(aiGroup1Seed); IncrementPhase(); break; @@ -311,13 +301,13 @@ public override void _Process(float delta) preventDying = false; spawnCounter = 0; - dynamicRoot.FreeChildren(); + microbeSimulation!.DestroyAllEntities(); spawnedMicrobes.Clear(); cloudSystem!.EmptyAllClouds(); spawnAngle = 0; spawnDistance = 1; - aiRandom = new Random(aiGroup2Seed); + microbeSimulation.OverrideMicrobeAIRandomSeed(aiGroup2Seed); IncrementPhase(); break; @@ -403,6 +393,9 @@ protected override void Dispose(bool disposing) { if (disposing) { + microbeSimulation?.Dispose(); + microbeEntities?.Dispose(); + if (GUIContainerPath != null) { GUIContainerPath.Dispose(); @@ -464,26 +457,20 @@ private void GenerateWorldAndSpecies() } } - private void SetupEnoughGameSystemsToRun() + private void SetupSimulation() { - fluidSystem = new FluidSystem(worldRoot); - cloudSystem = new CompoundCloudSystem(); worldRoot.AddChild(cloudSystem); - cloudSystem.Init(fluidSystem); + microbeSimulation = new MicrobeWorldSimulation(); + microbeSimulation.Init(dynamicRoot, cloudSystem); + microbeSimulation.InitForCurrentGame(gameProperties ?? throw new Exception("game properties not set")); - microbeSystem = new MicrobeSystem(dynamicRoot); - microbeAI = new MicrobeAISystem(dynamicRoot, cloudSystem); - - processSystem = new ProcessSystem(dynamicRoot); + microbeEntities = microbeSimulation.EntitySystem.GetEntities().With().With() + .AsSet(); // ReSharper disable once StringLiteralTypo - processSystem.SetBiome(SimulationParameters.Instance.GetBiome("aavolcanic_vent").Conditions); - - chunkSystem = new FloatingChunkSystem(dynamicRoot, cloudSystem); - - timedLifeSystem = new TimedLifeSystem(dynamicRoot); + microbeSimulation.SetSimulationBiome(SimulationParameters.Instance.GetBiome("aavolcanic_vent").Conditions); } private void SpawnAndUpdatePositionState() @@ -507,10 +494,10 @@ private void SpawnAndUpdatePositionState() private void SpawnMicrobe(Vector3 position) { - var microbe = SpawnHelpers.SpawnMicrobe(generatedSpecies[spawnCounter % generatedSpecies.Count], position, - dynamicRoot, microbeScene, true, cloudSystem!, dummySpawnSystem, gameProperties!); + SpawnHelpers.SpawnMicrobe(microbeSimulation!, generatedSpecies[spawnCounter % generatedSpecies.Count], position, + true); - spawnedMicrobes.Add(new EntityReference(microbe)); + spawnedSomething = true; ++spawnCounter; // Spawning also gives a glucose cloud to ensure the spawned microbe doesn't instantly just die @@ -520,9 +507,27 @@ private void SpawnMicrobe(Vector3 position) cloudSystem!.AddCloud(random.Next(0, 2) == 1 ? phosphates : ammonia, AMMONIA_PHOSPHATE_CLOUD_AMOUNT, position); } + private void CheckSpawnedMicrobes() + { + // Find the spawned microbes. This needs to be done separately from SpawnMicrobe because they are only queued + // spawns at that point + foreach (var existingMicrobe in microbeEntities!.GetEntities()) + { + if (existingMicrobe.Get().Dead) + continue; + + if (spawnedMicrobes.Any(m => m == existingMicrobe)) + continue; + + spawnedMicrobes.Add(existingMicrobe); + } + + spawnedSomething = false; + } + private void PruneDeadMicrobes() { - spawnedMicrobes.RemoveAll(r => !r.IsAlive); + spawnedMicrobes.RemoveAll(r => !r.IsAlive || r.Get().Dead); } private void WaitForStableFPS() diff --git a/src/early_multicellular_stage/CellLayout.cs b/src/early_multicellular_stage/CellLayout.cs index 8759063de14..d60ade7bece 100644 --- a/src/early_multicellular_stage/CellLayout.cs +++ b/src/early_multicellular_stage/CellLayout.cs @@ -7,8 +7,15 @@ /// /// A list of positioned cells. Verifies that they don't overlap /// +/// +/// +/// This is a JSON reference so that can reference this from a +/// species. +/// +/// /// The type of organelle contained in this layout [UseThriveSerializer] +[JsonObject(IsReference = true)] public class CellLayout : HexLayout where T : class, IPositionedCell { @@ -32,15 +39,20 @@ public Hex CenterOfMass { get { - float totalMass = 0; + // TODO: with the new physics its no longer possible to easily calculate the center of mass exactly + // this instead has to rely on just hex positions (for now). See OrganelleLayout.CenterOfMass Vector3 weightedSum = Vector3.Zero; + int count = 0; foreach (var organelle in existingHexes.SelectMany(c => c.Organelles)) { - totalMass += organelle.Definition.Mass; - weightedSum += Hex.AxialToCartesian(organelle.Position) * organelle.Definition.Mass; + ++count; + weightedSum += Hex.AxialToCartesian(organelle.Position); } - return Hex.CartesianToAxial(weightedSum / totalMass); + if (count == 0) + return new Hex(0, 0); + + return Hex.CartesianToAxial(weightedSum / count); } } diff --git a/src/early_multicellular_stage/CellType.cs b/src/early_multicellular_stage/CellType.cs index 9f2b42a6395..4464496fe46 100644 --- a/src/early_multicellular_stage/CellType.cs +++ b/src/early_multicellular_stage/CellType.cs @@ -1,5 +1,4 @@ using System; -using System.Linq; using Godot; using Newtonsoft.Json; @@ -53,12 +52,6 @@ public CellType(MicrobeSpecies microbeSpecies) : this(microbeSpecies.MembraneTyp public float BaseRotationSpeed { get; set; } public bool CanEngulf { get; } - /// - /// Total mass of all the organelles in this cell type - /// - [JsonIgnore] - public float TotalMass => Organelles.Sum(o => o.Definition.Mass); - [JsonIgnore] public string FormattedName => TypeName; @@ -90,7 +83,7 @@ public bool IsBrainTissueType() { foreach (var organelle in Organelles) { - if (organelle.Definition.HasComponentFactory()) + if (organelle.Definition.HasFeatureTag(OrganelleFeatureTag.Axon)) { return true; } @@ -107,8 +100,10 @@ public void ApplySceneParameters(Spatial instancedScene) public float CalculatePhotographDistance(Spatial instancedScene) { - return PhotoStudio.CameraDistanceFromRadiusOfObject(((Microbe)instancedScene).Radius * - Constants.PHOTO_STUDIO_CELL_RADIUS_MULTIPLIER); + throw new NotImplementedException(); + + // return PhotoStudio.CameraDistanceFromRadiusOfObject(((Microbe)instancedScene).Radius * + // Constants.PHOTO_STUDIO_CELL_RADIUS_MULTIPLIER); } public object Clone() @@ -148,6 +143,7 @@ public override int GetHashCode() private void CalculateRotationSpeed() { - BaseRotationSpeed = MicrobeInternalCalculations.CalculateRotationSpeed(Organelles); + BaseRotationSpeed = + MicrobeInternalCalculations.CalculateRotationSpeed(Organelles.Organelles, MembraneType, IsBacteria); } } diff --git a/src/early_multicellular_stage/EarlyMulticellularSpecies.cs b/src/early_multicellular_stage/EarlyMulticellularSpecies.cs index 44d8417f5bd..9e9994efb8d 100644 --- a/src/early_multicellular_stage/EarlyMulticellularSpecies.cs +++ b/src/early_multicellular_stage/EarlyMulticellularSpecies.cs @@ -3,6 +3,7 @@ using System.ComponentModel; using System.Linq; using Newtonsoft.Json; +using Systems; /// /// Represents an early multicellular species that is composed of multiple cells diff --git a/src/early_multicellular_stage/Microbe.Multicellular.cs b/src/early_multicellular_stage/Microbe.Multicellular.cs deleted file mode 100644 index 35fa7406f4a..00000000000 --- a/src/early_multicellular_stage/Microbe.Multicellular.cs +++ /dev/null @@ -1,419 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Godot; -using Newtonsoft.Json; - -/// -/// Early multicellular functionality of the microbes. This is a separate file to put all of the core logic of -/// the overridden behaviours in the same place. -/// -public partial class Microbe -{ - [JsonProperty] - private int nextBodyPlanCellToGrowIndex = -1; - - /// - /// List of cells that need to be regrown after being lost in - /// - [JsonProperty] - private List? lostPartsOfBodyPlan; - - /// - /// Once all lost body plan parts have been grown, this is the index the growing resumes at - /// - [JsonProperty] - private int? resumeBodyPlanAfterReplacingLost; - - [JsonProperty] - private bool enoughResourcesForBudding; - - [JsonProperty] - private Dictionary? compoundsNeededForNextCell; - - [JsonProperty] - private Dictionary? compoundsUsedForMulticellularGrowth; - - [JsonProperty] - private Dictionary? totalNeededForMulticellularGrowth; - - [JsonIgnore] - public bool IsFullyGrownMulticellular => nextBodyPlanCellToGrowIndex >= CastedMulticellularSpecies.Cells.Count; - - /// - /// Used to keep track of which part of a body plan a non-first cell in a multicellular colony is. - /// This is required for regrowing after losing a cell. - /// - [JsonProperty] - public int MulticellularBodyPlanPartIndex { get; set; } - - public void ApplyMulticellularNonFirstCellSpecies(EarlyMulticellularSpecies species, CellType cellType) - { - cachedMicrobeSpecies = null; - cachedMulticellularSpecies = species; - MulticellularCellType = cellType; - - Species = species; - - FinishSpeciesSetup(); - - // We have to force our membrane to be setup here so that the attach logic will have valid membrane data - // to work with - SendOrganellePositionsToMembrane(); - } - - /// - /// Adds the next cell missing from this multicellular species' body plan to this microbe's colony - /// - public void AddMulticellularGrowthCell(bool keepCompounds = false) - { - if (Colony == null) - { - MicrobeColony.CreateColonyForMicrobe(this); - - if (Colony == null) - throw new Exception("An issue occured during colony creation!"); - } - - var template = CastedMulticellularSpecies.Cells[nextBodyPlanCellToGrowIndex]; - - var cell = CreateMulticellularColonyMemberCell(template.CellType, keepCompounds); - cell.MulticellularBodyPlanPartIndex = nextBodyPlanCellToGrowIndex; - - // We don't reset our state here in case we want to be in engulf mode - cell.State = State; - - // Attach the created cell to the right spot in our colony - var ourTransform = GlobalTransform; - - var attachVector = ourTransform.origin + ourTransform.basis.Xform(Hex.AxialToCartesian(template.Position)); - - // Ensure no tiny y component exists here - attachVector.y = 0; - - var newCellTransform = new Transform( - MathUtils.CreateRotationForOrganelle(template.Orientation) * ourTransform.basis.Quat(), - attachVector); - cell.GlobalTransform = newCellTransform; - - var newCellPosition = newCellTransform.origin; - - // Adding a cell to a colony snaps it close to its colony parent so we need to find the closes existing - // cell in the colony to use as that here - var parent = this; - var currentDistanceSquared = (newCellPosition - ourTransform.origin).LengthSquared(); - - foreach (var colonyMember in Colony.ColonyMembers) - { - if (colonyMember == this) - continue; - - var distance = (colonyMember.GlobalTransform.origin - newCellPosition).LengthSquared(); - - if (distance < currentDistanceSquared) - { - parent = colonyMember; - currentDistanceSquared = distance; - } - } - - Colony.AddToColony(cell, parent); - - ++nextBodyPlanCellToGrowIndex; - compoundsNeededForNextCell = null; - } - - public void BecomeFullyGrownMulticellularColony() - { - while (!IsFullyGrownMulticellular) - { - AddMulticellularGrowthCell(true); - } - } - - private void HandleMulticellularReproduction(float elapsedSinceLastUpdate) - { - compoundsUsedForMulticellularGrowth ??= new Dictionary(); - - var (remainingAllowedCompoundUse, remainingFreeCompounds) = - CalculateFreeCompoundsAndLimits(elapsedSinceLastUpdate); - - if (compoundsNeededForNextCell == null) - { - // Regrow lost cells - if (lostPartsOfBodyPlan is { Count: > 0 }) - { - // Store where we will resume from - resumeBodyPlanAfterReplacingLost ??= nextBodyPlanCellToGrowIndex; - - // Grow from the first cell to grow back, in the body plan grow order - nextBodyPlanCellToGrowIndex = lostPartsOfBodyPlan.Min(); - lostPartsOfBodyPlan.Remove(nextBodyPlanCellToGrowIndex); - } - else if (resumeBodyPlanAfterReplacingLost != null) - { - // Done regrowing, resume where we were - nextBodyPlanCellToGrowIndex = resumeBodyPlanAfterReplacingLost.Value; - resumeBodyPlanAfterReplacingLost = null; - } - - // Need to setup the next cell to be grown in our body plan - if (IsFullyGrownMulticellular) - { - // We have completed our body plan and can (once enough resources) reproduce - if (enoughResourcesForBudding) - { - ReadyToReproduce(); - } - else - { - // Apply the base reproduction cost at this point after growing the full layout - if (!ProcessBaseReproductionCost(ref remainingAllowedCompoundUse, ref remainingFreeCompounds, - compoundsUsedForMulticellularGrowth)) - { - // Not ready yet for budding - return; - } - - // Budding cost is after the base reproduction cost has been overcome - compoundsNeededForNextCell = GetCompoundsNeededForNextCell(); - } - - return; - } - - compoundsNeededForNextCell = GetCompoundsNeededForNextCell(); - } - - bool stillNeedsSomething = false; - - // Consume some compounds for the next cell in the layout - // Similar logic for "growing" more cells than in PlacedOrganelle growth - foreach (var entry in consumeReproductionCompoundsReverse ? - compoundsNeededForNextCell.Reverse() : - compoundsNeededForNextCell) - { - var amountNeeded = entry.Value; - - float usedAmount = 0; - - float allowedUseAmount = Math.Min(amountNeeded, remainingAllowedCompoundUse); - - if (remainingFreeCompounds > 0) - { - var usedFreeCompounds = Math.Min(allowedUseAmount, remainingFreeCompounds); - usedAmount += usedFreeCompounds; - allowedUseAmount -= usedFreeCompounds; - - // As we loop just once we don't need to update the free compounds or allowed use compounds variables - } - - stillNeedsSomething = true; - - var amountAvailable = Compounds.GetCompoundAmount(entry.Key) - - Constants.ORGANELLE_GROW_STORAGE_MUST_HAVE_AT_LEAST; - - if (amountAvailable > MathUtils.EPSILON) - { - // We can take some - var amountToTake = Mathf.Min(allowedUseAmount, amountAvailable); - - usedAmount += Compounds.TakeCompound(entry.Key, amountToTake); - } - - var left = amountNeeded - usedAmount; - - if (left < 0.0001f) - { - compoundsNeededForNextCell.Remove(entry.Key); - } - else - { - compoundsNeededForNextCell[entry.Key] = left; - } - - compoundsUsedForMulticellularGrowth.TryGetValue(entry.Key, out float alreadyUsed); - - compoundsUsedForMulticellularGrowth[entry.Key] = alreadyUsed + usedAmount; - - // As we modify the list, we are content just consuming one type of compound per frame - break; - } - - if (!stillNeedsSomething) - { - // The current cell to grow is now ready to be added - // Except in the case that we were just getting resources for budding, skip in that case - if (!IsFullyGrownMulticellular) - { - AddMulticellularGrowthCell(); - } - else - { - // Has collected enough resources to spawn the first cell type as budding type reproduction - enoughResourcesForBudding = true; - compoundsNeededForNextCell = null; - } - } - } - - private Dictionary GetCompoundsNeededForNextCell() - { - return CastedMulticellularSpecies.Cells[IsFullyGrownMulticellular ? 0 : nextBodyPlanCellToGrowIndex].CellType - .CalculateTotalComposition(); - } - - private void ResetMulticellularProgress() - { - // Clear variables - - // The first cell is the last to duplicate (budding reproduction) so the body plan starts filling at index 1 - nextBodyPlanCellToGrowIndex = 1; - enoughResourcesForBudding = false; - - compoundsNeededForNextCell = null; - compoundsUsedForMulticellularGrowth = null; - - totalNeededForMulticellularGrowth = null; - - // Delete the cells in our colony currently - if (Colony != null) - { - GD.Print("Resetting growth in a multicellular colony"); - var cellsToDestroy = Colony.ColonyMembers.Where(m => m != this).ToList(); - - Colony.RemoveFromColony(this); - - foreach (var microbe in cellsToDestroy) - { - microbe.DetachAndQueueFree(); - } - } - } - - private Microbe CreateMulticellularColonyMemberCell(CellType cellType, bool keepCompounds) - { - var newCell = SpawnHelpers.SpawnMicrobe(Species, Translation, - GetParent(), SpawnHelpers.LoadMicrobeScene(), true, cloudSystem!, spawnSystem!, CurrentGame, cellType); - - // Make it despawn like normal (if our colony is accidentally somehow disbanded) - spawnSystem!.AddEntityToTrack(newCell); - - if (!keepCompounds) - { - // Remove the compounds from the created cell - newCell.Compounds.ClearCompounds(); - } - - // TODO: different sound effect? - PlaySoundEffect("res://assets/sounds/soundeffects/reproduction.ogg"); - - return newCell; - } - - private void OnMulticellularColonyCellLost(Microbe cell) - { - // Don't bother if this cell is being destroyed - if (destroyed) - return; - - // We need to reset our growth towards the next cell and instead replace the cell we just lost - lostPartsOfBodyPlan ??= new List(); - - // TODO: figure out why these duplicate calls come from colonies, we ignore them for now - if (lostPartsOfBodyPlan.Contains(cell.MulticellularBodyPlanPartIndex)) - return; - - lostPartsOfBodyPlan.Add(cell.MulticellularBodyPlanPartIndex); - allOrganellesDivided = false; - - if (resumeBodyPlanAfterReplacingLost != null) - { - // We are already regrowing something, so we need to remember that by adding it back to the list - lostPartsOfBodyPlan.Add(nextBodyPlanCellToGrowIndex); - } - - var usedForProgress = new Dictionary(); - - if (compoundsNeededForNextCell != null) - { - var totalNeededForCurrentlyGrowingCell = GetCompoundsNeededForNextCell(); - - foreach (var entry in totalNeededForCurrentlyGrowingCell) - { - var compound = entry.Key; - var neededAmount = entry.Value; - - if (compoundsNeededForNextCell.TryGetValue(compound, out var left)) - { - var alreadyUsed = neededAmount - left; - - if (alreadyUsed > 0) - usedForProgress.Add(compound, alreadyUsed); - } - } - - compoundsNeededForNextCell = null; - } - else if (enoughResourcesForBudding) - { - // Refund the budding cost - usedForProgress = GetCompoundsNeededForNextCell(); - } - - enoughResourcesForBudding = false; - - // TODO: maybe we should use a separate store for the used compounds for the next cell progress, for now - // just add those to our storage (even with the risk of us losing some compounds due to too little storage) - foreach (var entry in usedForProgress) - { - if (entry.Value > MathUtils.EPSILON) - Compounds.AddCompound(entry.Key, entry.Value); - } - - // Adjust the already used compound amount to lose the progress we made for the current cell and also towards - // the lost cell, this we the total progress bar should be correct - if (compoundsUsedForMulticellularGrowth != null) - { - var totalNeededForLostCell = CastedMulticellularSpecies.Cells[cell.MulticellularBodyPlanPartIndex].CellType - .CalculateTotalComposition(); - - foreach (var compound in compoundsUsedForMulticellularGrowth.Keys.ToArray()) - { - var totalUsed = compoundsUsedForMulticellularGrowth[compound]; - - if (usedForProgress.TryGetValue(compound, out var wasted)) - { - totalUsed -= wasted; - } - - if (totalNeededForLostCell.TryGetValue(compound, out wasted)) - { - totalUsed -= wasted; - } - - if (totalUsed < 0) - totalUsed = 0; - - compoundsUsedForMulticellularGrowth[compound] = totalUsed; - } - } - } - - private Dictionary CalculateTotalBodyPlanCompounds() - { - if (totalNeededForMulticellularGrowth == null) - { - totalNeededForMulticellularGrowth = new Dictionary(); - - foreach (var cell in CastedMulticellularSpecies.Cells) - { - totalNeededForMulticellularGrowth.Merge(cell.CellType.CalculateTotalComposition()); - } - - totalNeededForMulticellularGrowth.Merge(Species.BaseReproductionCost); - } - - return totalNeededForMulticellularGrowth; - } -} diff --git a/src/early_multicellular_stage/components/EarlyMulticellularSpeciesMember.cs b/src/early_multicellular_stage/components/EarlyMulticellularSpeciesMember.cs new file mode 100644 index 00000000000..a330b0d3ecf --- /dev/null +++ b/src/early_multicellular_stage/components/EarlyMulticellularSpeciesMember.cs @@ -0,0 +1,46 @@ +namespace Components +{ + using System; + + /// + /// Entity is an early multicellular thing. Still exists in the microbial environment. + /// + public struct EarlyMulticellularSpeciesMember + { + public EarlyMulticellularSpecies Species; + + /// + /// For each part of a multicellular species, the cell type they are must be known + /// + public CellType MulticellularCellType; + + /// + /// Used to keep track of which part of a body plan a non-first cell in a multicellular colony is. + /// This is required for regrowing after losing a cell. This is the index of + /// in the + /// + public int MulticellularBodyPlanPartIndex; + + // /// + // /// Set to false if the species is changed + // /// + // public bool SpeciesApplied; + + public EarlyMulticellularSpeciesMember(EarlyMulticellularSpecies species, CellType cellType) + { + Species = species; + MulticellularCellType = cellType; + + MulticellularBodyPlanPartIndex = species.CellTypes.FindIndex(c => c == cellType); + + if (MulticellularBodyPlanPartIndex == -1) + { + MulticellularBodyPlanPartIndex = 0; + +#if DEBUG + throw new ArgumentException("Multicellular growth given invalid first cell type"); +#endif + } + } + } +} diff --git a/src/early_multicellular_stage/components/MulticellularGrowth.cs b/src/early_multicellular_stage/components/MulticellularGrowth.cs new file mode 100644 index 00000000000..bfece2c52a6 --- /dev/null +++ b/src/early_multicellular_stage/components/MulticellularGrowth.cs @@ -0,0 +1,296 @@ +namespace Components +{ + using System; + using System.Collections.Generic; + using System.Linq; + using DefaultEcs; + using DefaultEcs.Command; + using Newtonsoft.Json; + + /// + /// Keeps track of multicellular growth data + /// + public struct MulticellularGrowth + { + public Dictionary? GrownCells; + + /// + /// List of cells that need to be regrown after being lost in + /// + /// + public List? LostPartsOfBodyPlan; + + public Dictionary? CompoundsNeededForNextCell; + + public Dictionary? CompoundsUsedForMulticellularGrowth; + + public Dictionary? TotalNeededForMulticellularGrowth; + + /// + /// The final cell layout this multicellular species member is growing towards + /// + public CellLayout? TargetCellLayout; + + // TODO: switch this to non-nullable + /// + /// Once all lost body plan parts have been grown, this is the index the growing resumes at + /// + public int? ResumeBodyPlanAfterReplacingLost; + + // TODO: MulticellularBodyPlanPartIndex used to be here, now it is in EarlyMulticellularSpeciesMember + // which means that a new system is needed to create MulticellularGrowth components on ejected cells that + // should be allowed to resume growing + + public int NextBodyPlanCellToGrowIndex; + + public bool EnoughResourcesForBudding; + + public MulticellularGrowth(EarlyMulticellularSpecies species) + { + NextBodyPlanCellToGrowIndex = -1; + + GrownCells = null; + LostPartsOfBodyPlan = null; + CompoundsNeededForNextCell = null; + CompoundsUsedForMulticellularGrowth = null; + TotalNeededForMulticellularGrowth = null; + ResumeBodyPlanAfterReplacingLost = null; + EnoughResourcesForBudding = false; + + TargetCellLayout = species.Cells; + + // TODO: this needs to be recalculated if the species' properties changes + this.CalculateTotalBodyPlanCompounds(species); + } + + [JsonIgnore] + public bool IsFullyGrownMulticellular => NextBodyPlanCellToGrowIndex >= + (TargetCellLayout?.Count ?? throw new InvalidOperationException("Unknown full layout")); + } + + public static class MulticellularGrowthHelpers + { + /// + /// Adds the next cell missing from this multicellular species' body plan to this microbe's colony + /// + public static void AddMulticellularGrowthCell(this ref MulticellularGrowth multicellularGrowth) + { + throw new NotImplementedException(); + + /*if (Colony == null) + { + MicrobeColony.CreateColonyForMicrobe(this); + + if (Colony == null) + throw new Exception("An issue occured during colony creation!"); + } + + var template = CastedMulticellularSpecies.Cells[nextBodyPlanCellToGrowIndex]; + + var cell = CreateMulticellularColonyMemberCell(template.CellType); + cell.MulticellularBodyPlanPartIndex = multicellularGrowth.NextBodyPlanCellToGrowIndex; + + // We don't reset our state here in case we want to be in engulf mode + // TODO: grab this from the colony + cell.State = State; + + // Attach the created cell to the right spot in our colony + var ourTransform = GlobalTransform; + + var attachVector = ourTransform.origin + ourTransform.basis.Xform(Hex.AxialToCartesian(template.Position)); + + // Ensure no tiny y component exists here + attachVector.y = 0; + + var newCellTransform = new Transform( + MathUtils.CreateRotationForOrganelle(template.Orientation) * ourTransform.basis.Quat(), + attachVector); + cell.GlobalTransform = newCellTransform; + + var newCellPosition = newCellTransform.origin; + + // Adding a cell to a colony snaps it close to its colony parent so we need to find the closes existing + // cell in the colony to use as that here + var parent = this; + var currentDistanceSquared = (newCellPosition - ourTransform.origin).LengthSquared(); + + foreach (var colonyMember in Colony.ColonyMembers) + { + if (colonyMember == this) + continue; + + var distance = (colonyMember.GlobalTransform.origin - newCellPosition).LengthSquared(); + + if (distance < currentDistanceSquared) + { + parent = colonyMember; + currentDistanceSquared = distance; + } + } + + Colony.AddToColony(cell, parent);*/ + + ++multicellularGrowth.NextBodyPlanCellToGrowIndex; + multicellularGrowth.CompoundsNeededForNextCell = null; + } + + public static void BecomeFullyGrownMulticellularColony(this ref MulticellularGrowth multicellularGrowth) + { + while (!multicellularGrowth.IsFullyGrownMulticellular) + { + multicellularGrowth.AddMulticellularGrowthCell(); + } + } + + // TODO: determine if it is good to always require passing the recorder parameter or if this should take in + // a world simulation object + public static void ResetMulticellularProgress(this ref MulticellularGrowth multicellularGrowth, + in Entity entity, IWorldSimulation worldSimulation, EntityCommandRecorder recorder) + { + // Clear variables + + // The first cell is the last to duplicate (budding reproduction) so the body plan starts filling at index 1 + multicellularGrowth.NextBodyPlanCellToGrowIndex = 1; + multicellularGrowth.EnoughResourcesForBudding = false; + + multicellularGrowth.CompoundsNeededForNextCell = null; + multicellularGrowth.CompoundsUsedForMulticellularGrowth = null; + + multicellularGrowth.TotalNeededForMulticellularGrowth = null; + + // Delete the cells in our colony currently + if (entity.Has()) + { + ref var colony = ref entity.Get(); + + foreach (var member in colony.ColonyMembers) + { + if (member == entity) + continue; + + worldSimulation.DestroyEntity(member); + } + + var entityRecord = recorder.Record(entity); + entityRecord.Remove(); + } + } + + public static void OnMulticellularColonyCellLost(this ref MulticellularGrowth multicellularGrowth, + ref OrganelleContainer organelleContainer, CompoundBag compoundRefundLocation, in Entity colonyEntity, + in Entity lostCell) + { + var species = colonyEntity.Get().Species; + + var lostPartIndex = lostCell.Get().MulticellularBodyPlanPartIndex; + + // We need to reset our growth towards the next cell and instead replace the cell we just lost + multicellularGrowth.LostPartsOfBodyPlan ??= new List(); + + // TODO: figure out why these duplicate calls come from colonies, we ignore them for now + if (multicellularGrowth.LostPartsOfBodyPlan.Contains(lostPartIndex)) + return; + + multicellularGrowth.LostPartsOfBodyPlan.Add(lostPartIndex); + organelleContainer.AllOrganellesDivided = false; + + if (multicellularGrowth.ResumeBodyPlanAfterReplacingLost != null) + { + // We are already regrowing something, so we need to remember that by adding it back to the list + multicellularGrowth.LostPartsOfBodyPlan.Add(multicellularGrowth.NextBodyPlanCellToGrowIndex); + } + + var usedForProgress = new Dictionary(); + + if (multicellularGrowth.CompoundsNeededForNextCell != null) + { + var totalNeededForCurrentlyGrowingCell = multicellularGrowth.GetCompoundsNeededForNextCell(species); + + foreach (var entry in totalNeededForCurrentlyGrowingCell) + { + var compound = entry.Key; + var neededAmount = entry.Value; + + if (multicellularGrowth.CompoundsNeededForNextCell!.TryGetValue(compound, out var left)) + { + var alreadyUsed = neededAmount - left; + + if (alreadyUsed > 0) + usedForProgress.Add(compound, alreadyUsed); + } + } + + multicellularGrowth.CompoundsNeededForNextCell = null; + } + else if (multicellularGrowth.EnoughResourcesForBudding) + { + // Refund the budding cost + usedForProgress = multicellularGrowth.GetCompoundsNeededForNextCell(species); + } + + multicellularGrowth.EnoughResourcesForBudding = false; + + // TODO: maybe we should use a separate store for the used compounds for the next cell progress, for now + // just add those to our storage (even with the risk of us losing some compounds due to too little storage) + foreach (var entry in usedForProgress) + { + if (entry.Value > MathUtils.EPSILON) + compoundRefundLocation.AddCompound(entry.Key, entry.Value); + } + + // Adjust the already used compound amount to lose the progress we made for the current cell and also + // towards the lost cell, this we the total progress bar should be correct + if (multicellularGrowth.CompoundsUsedForMulticellularGrowth != null) + { + var totalNeededForLostCell = species.Cells[lostPartIndex] + .CellType + .CalculateTotalComposition(); + + foreach (var compound in multicellularGrowth.CompoundsUsedForMulticellularGrowth.Keys.ToArray()) + { + var totalUsed = multicellularGrowth.CompoundsUsedForMulticellularGrowth[compound]; + + if (usedForProgress.TryGetValue(compound, out var wasted)) + { + totalUsed -= wasted; + } + + if (totalNeededForLostCell.TryGetValue(compound, out wasted)) + { + totalUsed -= wasted; + } + + if (totalUsed < 0) + totalUsed = 0; + + multicellularGrowth.CompoundsUsedForMulticellularGrowth[compound] = totalUsed; + } + } + } + + public static Dictionary GetCompoundsNeededForNextCell( + this ref MulticellularGrowth multicellularGrowth, EarlyMulticellularSpecies species) + { + return species + .Cells[ + multicellularGrowth.IsFullyGrownMulticellular ? 0 : multicellularGrowth.NextBodyPlanCellToGrowIndex] + .CellType + .CalculateTotalComposition(); + } + + public static void CalculateTotalBodyPlanCompounds(this ref MulticellularGrowth multicellularGrowth, + Species species) + { + multicellularGrowth.TotalNeededForMulticellularGrowth ??= new Dictionary(); + multicellularGrowth.TotalNeededForMulticellularGrowth.Clear(); + + foreach (var cell in multicellularGrowth.TargetCellLayout ?? + throw new InvalidOperationException("Unknown target layout")) + { + multicellularGrowth.TotalNeededForMulticellularGrowth.Merge(cell.CellType.CalculateTotalComposition()); + } + + multicellularGrowth.TotalNeededForMulticellularGrowth.Merge(species.BaseReproductionCost); + } + } +} diff --git a/src/early_multicellular_stage/editor/CellBodyPlanEditorComponent.cs b/src/early_multicellular_stage/editor/CellBodyPlanEditorComponent.cs index ee6d21c885b..efe4a0ae4c1 100644 --- a/src/early_multicellular_stage/editor/CellBodyPlanEditorComponent.cs +++ b/src/early_multicellular_stage/editor/CellBodyPlanEditorComponent.cs @@ -93,15 +93,13 @@ public partial class CellBodyPlanEditorComponent : private ButtonGroup cellTypeButtonGroup = new(); - private PackedScene microbeScene = null!; - private CellPopupMenu cellPopupMenu = null!; #pragma warning restore CA2213 // Microbe scale applies done with 3 frame delay (that's why there are multiple list variables) - private List pendingScaleApplies = new(); - private List nextFrameScaleApplies = new(); - private List thisFrameScaleApplies = new(); + private List pendingScaleApplies = new(); + private List nextFrameScaleApplies = new(); + private List thisFrameScaleApplies = new(); [JsonProperty] private string newName = "unset"; @@ -251,14 +249,16 @@ public override void _Process(float delta) foreach (var microbe in thisFrameScaleApplies) { - // This check is here for simplicity's sake as model display nodes can be destroyed on subsequent frames - if (!IsInstanceValid(microbe)) - continue; - - // Scale is computed so that all the cells are the size of 1 hex when placed - // TODO: figure out why the extra multiplier to make things smaller is needed - microbe.OverrideScaleForPreview(1.0f / microbe.Radius * Constants.DEFAULT_HEX_SIZE * - Constants.MULTICELLULAR_EDITOR_PREVIEW_MICROBE_SCALE_MULTIPLIER); + throw new NotImplementedException(); + + // // This check is here for simplicity's sake as model display nodes can be destroyed on subsequent frames + // if (!IsInstanceValid(microbe)) + // continue; + // + // // Scale is computed so that all the cells are the size of 1 hex when placed + // // TODO: figure out why the extra multiplier to make things smaller is needed + // microbe.OverrideScaleForPreview(1.0f / microbe.Radius * Constants.DEFAULT_HEX_SIZE * + // Constants.MULTICELLULAR_EDITOR_PREVIEW_MICROBE_SCALE_MULTIPLIER); } thisFrameScaleApplies.Clear(); @@ -497,13 +497,6 @@ protected override int CalculateCurrentActionCost() return Editor.WhatWouldActionsCost(moveOccupancies.Data); } - protected override void LoadScenes() - { - base.LoadScenes(); - - microbeScene = GD.Load("res://src/microbe_stage/Microbe.tscn"); - } - protected override void PerformActiveAction() { AddCell(CellTypeFromName(activeActionName ?? throw new InvalidOperationException("no action active"))); @@ -1001,7 +994,11 @@ private void ShowCellTypeInModelHolder(SceneDisplayer modelHolder, CellType cell var rotation = MathUtils.CreateRotationForOrganelle(1 * orientation); // Create a new microbe if one is not already in the model holder - Microbe microbe; + + // TODO: reimplement with MicrobeVisualOnlySimulation + throw new NotImplementedException(); + + /*Microbe microbe; var newSpecies = new MicrobeSpecies(new MicrobeSpecies(0, string.Empty, string.Empty), cell); @@ -1044,9 +1041,11 @@ private void ShowCellTypeInModelHolder(SceneDisplayer modelHolder, CellType cell } // Scale needs to be applied some frames later so that organelle positions are sent - pendingScaleApplies.Add(microbe); + pendingScaleApplies.Add(microbe);*/ // TODO: render order setting for the cells? (similarly to how organelles are handled in the cell editor) + // This is probably not needed but when converted to quads, maybe 0.01 of randomness in y-position would be + // fine? } private void OnSpeciesNameChanged(string newText) diff --git a/src/early_multicellular_stage/systems/MulticellularGrowthSystem.cs b/src/early_multicellular_stage/systems/MulticellularGrowthSystem.cs new file mode 100644 index 00000000000..cb42128c138 --- /dev/null +++ b/src/early_multicellular_stage/systems/MulticellularGrowthSystem.cs @@ -0,0 +1,237 @@ +namespace Systems +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + using Godot; + using World = DefaultEcs.World; + + /// + /// Handles growth in multicellular cell colonies + /// + [With(typeof(EarlyMulticellularSpeciesMember))] + [With(typeof(MulticellularGrowth))] + [With(typeof(CompoundStorage))] + [With(typeof(MicrobeStatus))] + [With(typeof(OrganelleContainer))] + [With(typeof(Health))] + public sealed class MulticellularGrowthSystem : AEntitySetSystem + { + private GameWorld? gameWorld; + + public MulticellularGrowthSystem(World world, IParallelRunner runner) : base(world, runner) + { + } + + public void SetWorld(GameWorld world) + { + gameWorld = world; + } + + protected override void PreUpdate(float delta) + { + if (gameWorld == null) + throw new InvalidOperationException("GameWorld not set"); + + base.PreUpdate(delta); + } + + protected override void Update(float delta, in Entity entity) + { + ref var health = ref entity.Get(); + + // Dead multicellular colonies can't reproduce + if (health.Dead) + return; + + ref var growth = ref entity.Get(); + HandleMulticellularReproduction(ref growth, entity, delta); + + // TODO: when spawning a new cell to add to colony it needs to be ensured that its membrane is ready before + // attach to calculate the attach position + } + + private void HandleMulticellularReproduction(ref MulticellularGrowth multicellularGrowth, in Entity entity, + float elapsedSinceLastUpdate) + { + ref var speciesData = ref entity.Get(); + + var compounds = entity.Get().Compounds; + + ref var organelleContainer = ref entity.Get(); + + multicellularGrowth.CompoundsUsedForMulticellularGrowth ??= new Dictionary(); + + var (remainingAllowedCompoundUse, remainingFreeCompounds) = + MicrobeReproductionSystem.CalculateFreeCompoundsAndLimits(gameWorld!.WorldSettings, + organelleContainer.HexCount, false, elapsedSinceLastUpdate); + + if (multicellularGrowth.CompoundsNeededForNextCell == null) + { + // Regrow lost cells + if (multicellularGrowth.LostPartsOfBodyPlan is { Count: > 0 }) + { + // Store where we will resume from + multicellularGrowth.ResumeBodyPlanAfterReplacingLost ??= + multicellularGrowth.NextBodyPlanCellToGrowIndex; + + // Grow from the first cell to grow back, in the body plan grow order + multicellularGrowth.NextBodyPlanCellToGrowIndex = multicellularGrowth.LostPartsOfBodyPlan.Min(); + multicellularGrowth.LostPartsOfBodyPlan.Remove(multicellularGrowth.NextBodyPlanCellToGrowIndex); + } + else if (multicellularGrowth.ResumeBodyPlanAfterReplacingLost != null) + { + // Done regrowing, resume where we were + multicellularGrowth.NextBodyPlanCellToGrowIndex = + multicellularGrowth.ResumeBodyPlanAfterReplacingLost.Value; + multicellularGrowth.ResumeBodyPlanAfterReplacingLost = null; + } + + // Need to setup the next cell to be grown in our body plan + if (multicellularGrowth.IsFullyGrownMulticellular) + { + // We have completed our body plan and can (once enough resources) reproduce + if (multicellularGrowth.EnoughResourcesForBudding) + { + ReadyToReproduce(ref organelleContainer, entity); + } + else + { + // Apply the base reproduction cost at this point after growing the full layout + throw new NotImplementedException(); + + // if (!MicrobeReproductionSystem.ProcessBaseReproductionCost(ref remainingAllowedCompoundUse, + // ref remainingFreeCompounds, + // multicellularGrowth.CompoundsUsedForMulticellularGrowth)) + // { + // // Not ready yet for budding + // return; + // } + + // Budding cost is after the base reproduction cost has been overcome + multicellularGrowth.CompoundsNeededForNextCell = + multicellularGrowth.GetCompoundsNeededForNextCell(speciesData.Species); + } + + return; + } + + multicellularGrowth.CompoundsNeededForNextCell = + multicellularGrowth.GetCompoundsNeededForNextCell(speciesData.Species); + } + + bool stillNeedsSomething = false; + + ref var microbeStatus = ref entity.Get(); + microbeStatus.ConsumeReproductionCompoundsReverse = !microbeStatus.ConsumeReproductionCompoundsReverse; + + // Consume some compounds for the next cell in the layout + // Similar logic for "growing" more cells than in PlacedOrganelle growth + foreach (var entry in microbeStatus.ConsumeReproductionCompoundsReverse ? + multicellularGrowth.CompoundsNeededForNextCell.Reverse() : + multicellularGrowth.CompoundsNeededForNextCell) + { + var amountNeeded = entry.Value; + + float usedAmount = 0; + + float allowedUseAmount = Math.Min(amountNeeded, remainingAllowedCompoundUse); + + if (remainingFreeCompounds > 0) + { + var usedFreeCompounds = Math.Min(allowedUseAmount, remainingFreeCompounds); + usedAmount += usedFreeCompounds; + allowedUseAmount -= usedFreeCompounds; + + // As we loop just once we don't need to update the free compounds or allowed use compounds + // variables + } + + stillNeedsSomething = true; + + var amountAvailable = compounds.GetCompoundAmount(entry.Key) - + Constants.ORGANELLE_GROW_STORAGE_MUST_HAVE_AT_LEAST; + + if (amountAvailable > MathUtils.EPSILON) + { + // We can take some + var amountToTake = Mathf.Min(allowedUseAmount, amountAvailable); + + usedAmount += compounds.TakeCompound(entry.Key, amountToTake); + } + + var left = amountNeeded - usedAmount; + + if (left < 0.0001f) + { + multicellularGrowth.CompoundsNeededForNextCell.Remove(entry.Key); + } + else + { + multicellularGrowth.CompoundsNeededForNextCell[entry.Key] = left; + } + + multicellularGrowth.CompoundsUsedForMulticellularGrowth!.TryGetValue(entry.Key, out float alreadyUsed); + + multicellularGrowth.CompoundsUsedForMulticellularGrowth[entry.Key] = alreadyUsed + usedAmount; + + // As we modify the list, we are content just consuming one type of compound per frame + break; + } + + if (!stillNeedsSomething) + { + // The current cell to grow is now ready to be added + // Except in the case that we were just getting resources for budding, skip in that case + if (!multicellularGrowth.IsFullyGrownMulticellular) + { + multicellularGrowth.AddMulticellularGrowthCell(); + } + else + { + // Has collected enough resources to spawn the first cell type as budding type reproduction + multicellularGrowth.EnoughResourcesForBudding = true; + multicellularGrowth.CompoundsNeededForNextCell = null; + } + } + } + + private void ReadyToReproduce(ref OrganelleContainer organelles, in Entity entity) + { + Action? reproductionCallback; + if (entity.Has()) + { + ref var callbacks = ref entity.Get(); + reproductionCallback = callbacks.OnReproductionStatus; + } + else + { + reproductionCallback = null; + } + + // Entities with a reproduction callback don't divide automatically + if (reproductionCallback != null) + { + // The player doesn't split automatically + organelles.AllOrganellesDivided = true; + + reproductionCallback.Invoke(entity, true); + } + else + { + throw new NotImplementedException(); + + // enoughResourcesForBudding = false; + // + // // Let's require the base reproduction cost to be fulfilled again as well, to keep down the + // colony + // // spam, and for consistency with non-multicellular microbes + // SetupRequiredBaseReproductionCompounds(); + } + } + } +} diff --git a/src/engine/DebugDrawer.cs b/src/engine/DebugDrawer.cs new file mode 100644 index 00000000000..2d994c7cd95 --- /dev/null +++ b/src/engine/DebugDrawer.cs @@ -0,0 +1,306 @@ +using System; +using Godot; + +/// +/// Handles drawing debug lines +/// +public class DebugDrawer : ControlWithInput +{ + /// + /// Needs to match what's defined in PhysicalWorld.hpp + /// + private const int MaxPhysicsDebugLevel = 7; + + /// + /// Assumption of what the vertex layout memory use is for immediate geometry (3 floats for position, + /// 3 floats for normal, 2 floats for UVs, 4 floats for colour). + /// + /// + /// + /// It's really hard to find this in Godot source code so this is a pure assumption that has been tested to + /// work fine. + /// + /// + private const long MemoryUseOfIntermediateVertex = sizeof(float) * (3 + 3 + 2 + 4); + + // 2 vertices + space in index buffer + private const long SingleLineDrawMemoryUse = MemoryUseOfIntermediateVertex * 2 + sizeof(uint); + + // 3 vertices + private const long SingleTriangleDrawMemoryUse = MemoryUseOfIntermediateVertex * 3 + sizeof(uint); + + private static DebugDrawer? instance; + +#pragma warning disable CA2213 + private ImmediateGeometry lineDrawer = null!; + private ImmediateGeometry triangleDrawer = null!; +#pragma warning restore CA2213 + + private int currentPhysicsDebugLevel; + + private bool physicsDebugSupported; + private bool warnedAboutNotBeingSupported; + private bool warnedAboutHittingMemoryLimit; + + // Note that only one debug draw geometry can be going on at once so drawing lines intermixed with triangles is + // note very efficient + private bool lineDrawStarted; + private bool triangleDrawStarted; + + private long usedDrawMemory; + private long drawMemoryLimit; + private long extraNeededDrawMemory; + + private bool drawnThisFrame; + + private DebugDrawer() + { + instance = this; + } + + public delegate void OnPhysicsDebugLevelChanged(int level); + + public delegate void OnPhysicsDebugCameraPositionChanged(Vector3 position); + + public event OnPhysicsDebugLevelChanged? OnPhysicsDebugLevelChangedHandler; + public event OnPhysicsDebugCameraPositionChanged? OnPhysicsDebugCameraPositionChangedHandler; + + public static DebugDrawer Instance => instance ?? throw new InstanceNotLoadedYetException(); + + public int DebugLevel => currentPhysicsDebugLevel; + public Vector3 DebugCameraLocation { get; private set; } + + public static void DumpPhysicsState(PhysicalWorld world) + { + var path = ProjectSettings.GlobalizePath(Constants.PHYSICS_DUMP_PATH); + + GD.Print("Starting dumping of physics world state to: ", path); + + if (world.DumpPhysicsState(path)) + { + GD.Print("Physics dump finished"); + } + } + + public override void _Ready() + { + lineDrawer = GetNode("LineDrawer"); + triangleDrawer = GetNode("TriangleDrawer"); + + physicsDebugSupported = NativeInterop.RegisterDebugDrawer(DrawLine, DrawTriangle); + + // Make sure the debug stuff is always rendered + lineDrawer.SetCustomAabb(new AABB(float.MinValue, float.MinValue, float.MinValue, float.MaxValue, + float.MaxValue, float.MaxValue)); + triangleDrawer.SetCustomAabb(new AABB(float.MinValue, float.MinValue, float.MinValue, float.MaxValue, + float.MaxValue, float.MaxValue)); + + // TODO: implement debug text drawing (this is a Control to support that in the future) + + // Determine how much stuff we can draw before having all of the drawn stuff disappear + var limit = ProjectSettings.Singleton.Get("rendering/limits/buffers/immediate_buffer_size_kb"); + + if (limit == null) + { + GD.PrintErr("Unknown immediate geometry buffer size limit, can't draw debug lines"); + } + else + { + drawMemoryLimit = (int)limit * 1024; + } + + if (GetTree().DebugCollisionsHint) + { + GD.Print("Enabling physics debug drawing on next frame as debug for that was enabled on the scene tree"); + Invoke.Instance.Queue(IncrementPhysicsDebugLevel); + } + else + { + // ReSharper disable HeuristicUnreachableCode +#pragma warning disable CS0162 + if (Constants.AUTOMATICALLY_TURN_ON_PHYSICS_DEBUG_DRAW) + { + GD.Print("Starting with debug draw on due to debug draw constant being enabled"); + Invoke.Instance.Queue(IncrementPhysicsDebugLevel); + } + + // ReSharper restore HeuristicUnreachableCode +#pragma warning restore CS0162 + } + } + + public override void _ExitTree() + { + base._ExitTree(); + + NativeInterop.RemoveDebugDrawer(); + } + + public override void _Process(float delta) + { + if (drawnThisFrame) + { + // Finish the geometry + if (lineDrawStarted) + { + lineDrawStarted = false; + lineDrawer.End(); + } + + if (triangleDrawStarted) + { + triangleDrawStarted = false; + triangleDrawer.End(); + } + + lineDrawer.Visible = true; + triangleDrawer.Visible = true; + drawnThisFrame = false; + + // Send camera position to the debug draw for LOD purposes + try + { + DebugCameraLocation = GetViewport().GetCamera().GlobalTranslation; + + OnPhysicsDebugCameraPositionChangedHandler?.Invoke(DebugCameraLocation); + } + catch (Exception e) + { + GD.PrintErr("Failed to send camera position to physics debug draw", e); + } + + if (!warnedAboutHittingMemoryLimit && usedDrawMemory + SingleTriangleDrawMemoryUse * 100 >= drawMemoryLimit) + { + warnedAboutHittingMemoryLimit = true; + + // Put some extra buffer in the memory advice + extraNeededDrawMemory += SingleTriangleDrawMemoryUse * 100; + + GD.PrintErr( + "Debug drawer hit immediate geometry memory limit (extra needed memory: " + + $"{extraNeededDrawMemory / 1024} KiB), some things were not rendered " + + "(this message won't repeat even if the problem occurs again)"); + } + + // This needs to reset here so that StartDrawingIfNotYetThisFrame gets called again + usedDrawMemory = 0; + } + else if (currentPhysicsDebugLevel < 1) + { + lineDrawer.Visible = false; + triangleDrawer.Visible = false; + } + } + + [RunOnKeyDown("d_physics_debug", Priority = -2)] + public void IncrementPhysicsDebugLevel() + { + if (!physicsDebugSupported) + { + if (!warnedAboutNotBeingSupported) + { + GD.PrintErr("The version of the loaded native Thrive library doesn't support physics " + + "debug drawing, debug drawing will not be attempted"); + warnedAboutNotBeingSupported = true; + } + } + else + { + currentPhysicsDebugLevel = (currentPhysicsDebugLevel + 1) % MaxPhysicsDebugLevel; + + GD.Print("Setting physics debug level to: ", currentPhysicsDebugLevel); + + OnPhysicsDebugLevelChangedHandler?.Invoke(currentPhysicsDebugLevel); + } + } + + private void DrawLine(Vector3 from, Vector3 to, Color colour) + { + if (usedDrawMemory + SingleLineDrawMemoryUse >= drawMemoryLimit) + { + extraNeededDrawMemory += SingleLineDrawMemoryUse; + return; + } + + try + { + StartDrawingIfNotYetThisFrame(); + + if (!lineDrawStarted) + { + if (triangleDrawStarted) + { + triangleDrawStarted = false; + triangleDrawer.End(); + } + + lineDrawStarted = true; + lineDrawer.Begin(Mesh.PrimitiveType.Lines); + } + + lineDrawer.SetColor(colour); + lineDrawer.AddVertex(from); + lineDrawer.AddVertex(to); + + usedDrawMemory += SingleLineDrawMemoryUse; + } + catch (Exception e) + { + GD.PrintErr("Error in debug drawing: ", e); + } + } + + private void DrawTriangle(Vector3 vertex1, Vector3 vertex2, Vector3 vertex3, Color colour) + { + if (usedDrawMemory + SingleTriangleDrawMemoryUse >= drawMemoryLimit) + { + extraNeededDrawMemory += SingleLineDrawMemoryUse; + return; + } + + try + { + StartDrawingIfNotYetThisFrame(); + + if (!triangleDrawStarted) + { + if (lineDrawStarted) + { + lineDrawStarted = false; + lineDrawer.End(); + } + + triangleDrawStarted = true; + triangleDrawer.Begin(Mesh.PrimitiveType.Triangles); + } + + triangleDrawer.SetColor(colour); + + triangleDrawer.AddVertex(vertex1); + triangleDrawer.AddVertex(vertex2); + triangleDrawer.AddVertex(vertex3); + + usedDrawMemory += SingleTriangleDrawMemoryUse; + } + catch (Exception e) + { + GD.PrintErr("Error in debug drawing: ", e); + } + } + + private void StartDrawingIfNotYetThisFrame() + { + if (drawnThisFrame) + return; + + lineDrawer.Clear(); + usedDrawMemory = 0; + extraNeededDrawMemory = 0; + lineDrawStarted = false; + + triangleDrawer.Clear(); + triangleDrawStarted = false; + + drawnThisFrame = true; + } +} diff --git a/src/engine/DebugDrawer.tscn b/src/engine/DebugDrawer.tscn new file mode 100644 index 00000000000..96e219ca7d2 --- /dev/null +++ b/src/engine/DebugDrawer.tscn @@ -0,0 +1,45 @@ +[gd_scene load_steps=5 format=2] + +[ext_resource path="res://src/engine/DebugDrawer.cs" type="Script" id=1] +[ext_resource path="res://src/gui_common/thrive_theme.tres" type="Theme" id=2] + +[sub_resource type="SpatialMaterial" id=1] +render_priority = 127 +flags_transparent = true +flags_unshaded = true +flags_no_depth_test = true +flags_do_not_receive_shadows = true +flags_disable_ambient_light = true +vertex_color_use_as_albedo = true +params_depth_draw_mode = 2 + +[sub_resource type="SpatialMaterial" id=2] +render_priority = 126 +flags_transparent = true +flags_unshaded = true +flags_no_depth_test = true +flags_do_not_receive_shadows = true +flags_disable_ambient_light = true +vertex_color_use_as_albedo = true +params_depth_draw_mode = 2 + +[node name="DebugDrawer" type="Control"] +pause_mode = 2 +process_priority = 10000 +anchor_right = 1.0 +anchor_bottom = 1.0 +mouse_filter = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +theme = ExtResource( 2 ) +script = ExtResource( 1 ) + +[node name="LineDrawer" type="ImmediateGeometry" parent="."] +portal_mode = 3 +material_override = SubResource( 1 ) +cast_shadow = 0 + +[node name="TriangleDrawer" type="ImmediateGeometry" parent="."] +portal_mode = 3 +material_override = SubResource( 2 ) +cast_shadow = 0 diff --git a/src/engine/DebugOverlays.EntityLabel.cs b/src/engine/DebugOverlays.EntityLabel.cs index 79a36fa6643..0388d97d913 100644 --- a/src/engine/DebugOverlays.EntityLabel.cs +++ b/src/engine/DebugOverlays.EntityLabel.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using Godot; @@ -38,7 +39,8 @@ private void InitiateEntityLabels() private void UpdateLabelColour(IEntity entity, Label label) { - var node = entity.EntityNode; + throw new NotImplementedException(); + /*var node = entity.EntityNode; if (!entity.AliveMarker.Alive) { @@ -79,7 +81,7 @@ private void UpdateLabelColour(IEntity entity, Label label) break; } - } + }*/ } private void UpdateEntityLabels() @@ -90,7 +92,9 @@ private void UpdateEntityLabels() if (activeCamera == null) return; - foreach (var pair in entityLabels) + throw new NotImplementedException(); + + /*foreach (var pair in entityLabels) { var entity = pair.Key; var node = entity.EntityNode; @@ -129,7 +133,7 @@ private void UpdateEntityLabels() break; } } - } + }*/ } private void OnNodeAdded(Node node) @@ -141,16 +145,19 @@ private void OnNodeAdded(Node node) labelsLayer.AddChild(label); entityLabels.Add(entity, label); - switch (entity) - { - case FloatingChunk: - case AgentProjectile: - { - // To reduce the labels overlapping each other - label.AddFontOverride("font", smallerFont); - break; - } - } + // TODO: reimplement + throw new NotImplementedException(); + + // switch (entity) + // { + // case FloatingChunk: + // case AgentProjectile: + // { + // // To reduce the labels overlapping each other + // label.AddFontOverride("font", smallerFont); + // break; + // } + // } } private void OnNodeRemoved(Node node) diff --git a/src/engine/EntityReference.cs b/src/engine/EntityReference.cs index e188695cb23..356ebda5912 100644 --- a/src/engine/EntityReference.cs +++ b/src/engine/EntityReference.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using Newtonsoft.Json; +// TODO: delete this class as with ECS this shouldn't be necessary anywhere /// /// Allows safely keeping references to game entities across multiple frames. Needs to be used instead of raw /// references as those can't clear themselves when the entity is disposed @@ -11,6 +12,8 @@ public class EntityReference { // TODO: should this be somehow set to null when we detect that the alive marker is no longer alive // Currently set to clear on fetch + // TODO: should this be a weak reference to allow garbage collection to happen faster? This is probably good enough + // for most current use as when trying to retrieve a dead entity this gets set to null private T? currentInstance; private AliveMarker? currentAliveMarker; diff --git a/src/engine/FeatureInformation.cs b/src/engine/FeatureInformation.cs index d622c72b298..15033c3d8f9 100644 --- a/src/engine/FeatureInformation.cs +++ b/src/engine/FeatureInformation.cs @@ -6,19 +6,23 @@ /// public static class FeatureInformation { + public const string PlatformWindows = "Windows"; + public const string PlatformLinux = "Linux"; + public const string PlatformMac = "OSX"; + private static readonly string[] SimpleFeaturePlatforms = { "Android", "HTML5", - "Windows", - "OSX", + PlatformWindows, + PlatformMac, "iOS", }; public static string GetOS() { if (OS.HasFeature("X11")) - return "Linux"; + return PlatformLinux; foreach (var feature in SimpleFeaturePlatforms) { diff --git a/src/engine/ICacheableData.cs b/src/engine/ICacheableData.cs index a3224c9186c..695313820b0 100644 --- a/src/engine/ICacheableData.cs +++ b/src/engine/ICacheableData.cs @@ -4,7 +4,13 @@ /// /// Interface for data that can be stored in /// -public interface ICacheableData +/// +/// +/// This is disposable to allow releasing extra resources that were allocated when removed from the cache. +/// Note that after dispose this cache data instance is not safe to use at all. +/// +/// +public interface ICacheableData : IDisposable { /// /// Used to check that data returned from cache didn't suffer a hash collision @@ -30,8 +36,7 @@ public static class CacheableDataExtensions if (!currentParameters.MatchesCacheParameters(fetchedFromCache)) { - GD.PrintErr("Hash collision for procedural cache data. Losing performance due to recomputation! ", - "Multiple ", typeof(T).Name, " have hash of ", currentHash); + OnCacheHashCollision(currentHash); return null; } @@ -43,4 +48,10 @@ public static class CacheableDataExtensions { return FetchDataFromCache(currentParameters, dataFetch); } + + public static void OnCacheHashCollision(long hash) + { + GD.PrintErr("Hash collision for procedural cache data. Losing performance due to recomputation! ", + "Multiple ", typeof(T).Name, " have hash of ", hash); + } } diff --git a/src/engine/IGameCamera.cs b/src/engine/IGameCamera.cs new file mode 100644 index 00000000000..cb50e670744 --- /dev/null +++ b/src/engine/IGameCamera.cs @@ -0,0 +1,13 @@ +using Godot; + +/// +/// Base interface for game cameras that work to follow an entity +/// +public interface IGameCamera +{ + /// + /// Updates camera position to follow the object. Has to be called manually each frame (or update) by the system + /// owning the camera. + /// + public void UpdateCameraPosition(float delta, Vector3? followedObject); +} diff --git a/src/engine/NodeHelpers.cs b/src/engine/NodeHelpers.cs index b4e44eb9fa0..48849df2e01 100644 --- a/src/engine/NodeHelpers.cs +++ b/src/engine/NodeHelpers.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using Godot; /// @@ -162,7 +163,7 @@ public static ShaderMaterial GetMaterial(this Node node, NodePath? modelPath = n GeometryInstance geometry; // Fetch the actual model from the scene - if (string.IsNullOrEmpty(modelPath)) + if (modelPath == null || modelPath.IsEmpty()) { geometry = (GeometryInstance)node; } @@ -171,7 +172,17 @@ public static ShaderMaterial GetMaterial(this Node node, NodePath? modelPath = n geometry = node.GetNode(modelPath); } - return (ShaderMaterial)geometry.MaterialOverride; + try + { + return (ShaderMaterial)geometry.MaterialOverride; + } + catch (InvalidCastException) + { + GD.PrintErr("Converting material to ShaderMaterial failed, on node: " + node.GetPath(), " relative path: ", + modelPath); + + throw; + } } /// diff --git a/src/engine/PhysicsHelpers.cs b/src/engine/PhysicsHelpers.cs index 390189bab39..53abe49a262 100644 --- a/src/engine/PhysicsHelpers.cs +++ b/src/engine/PhysicsHelpers.cs @@ -1,7 +1,6 @@ -using System.Collections.Generic; -using Godot; -using Godot.Collections; +using Godot; +// TODO: delete this class /// /// Common helper operations for CollisionObjects and other physics stuff /// @@ -33,33 +32,4 @@ public static uint CreateShapeOwnerWithTransform(this CollisionObject entity, Tr var newShapeOwnerId = CreateShapeOwnerWithTransform(newParent, transform, shape); return newShapeOwnerId; } - - /// - /// Extension of . Results from intersections will be stored - /// in . - /// - public static void IntersectRay(this PhysicsDirectSpaceState space, List hits, Vector3 from, - Vector3 to, Array? exclude = null, uint collisionMask = 2147483647u, bool collideWithBodies = true, - bool collideWithAreas = false) - { - exclude ??= new Array(); - - while (true) - { - var hit = space.IntersectRay(from, to, exclude, collisionMask, collideWithBodies, collideWithAreas); - if (hit.Count <= 0) - break; - - var result = new RaycastResult( - hit["collider"], - (int)hit["collider_id"], - (Vector3)hit["normal"], - (Vector3)hit["position"], - (RID)hit["rid"], - (int)hit["shape"]); - - hits.Add(result); - exclude.Add(result.Collider); - } - } } diff --git a/src/engine/PostShutdownActions.cs b/src/engine/PostShutdownActions.cs index da7ea3e09d4..0aa1ae68360 100644 --- a/src/engine/PostShutdownActions.cs +++ b/src/engine/PostShutdownActions.cs @@ -46,5 +46,10 @@ private void OnAfterGameShutdown() { GD.PrintErr("Some tooltips have not been unregistered on game shutdown"); } + + GD.Print("Shutting down native library"); + NativeInterop.Shutdown(); + + GD.Print("Shutdown actions complete"); } } diff --git a/src/engine/ProceduralDataCache.cs b/src/engine/ProceduralDataCache.cs index 464e44383fc..a28865f386f 100644 --- a/src/engine/ProceduralDataCache.cs +++ b/src/engine/ProceduralDataCache.cs @@ -9,7 +9,11 @@ public class ProceduralDataCache : Node { private static ProceduralDataCache? instance; - private readonly Dictionary> membraneCache = new(); + private readonly Dictionary> membraneCache = new(); + + private readonly Dictionary> loadedShapes = new(); + + private readonly Dictionary> membraneCollisions = new(); private MainGameState previousState = MainGameState.Invalid; @@ -23,21 +27,58 @@ private ProceduralDataCache() public static ProceduralDataCache Instance => instance ?? throw new InstanceNotLoadedYetException(); + public override void _ExitTree() + { + base._ExitTree(); + + // Clear all caches on shutdown to get a cleaner game shutdown without a bunch of still alive resources + + lock (membraneCache) + { + ClearCacheData(membraneCache); + } + + lock (loadedShapes) + { + ClearCacheData(loadedShapes); + } + + lock (membraneCollisions) + { + ClearCacheData(membraneCollisions); + } + } + public override void _Process(float delta) { + // This is just incidentally used inside lock blocks + // ReSharper disable once InconsistentlySynchronizedField currentTime += delta; timeSinceClean += delta; - if (timeSinceClean > Constants.PROCEDURAL_CACHE_CLEAN_INTERVAL) - { - timeSinceClean = 0; + if (!(timeSinceClean > Constants.PROCEDURAL_CACHE_CLEAN_INTERVAL)) + return; + + timeSinceClean = 0; + lock (membraneCache) + { CleanOldCacheEntriesIn(membraneCache, Constants.PROCEDURAL_CACHE_MEMBRANE_KEEP_TIME); } + + lock (loadedShapes) + { + CleanOldCacheEntriesIn(loadedShapes, Constants.PROCEDURAL_CACHE_LOADED_SHAPE_KEEP_TIME); + } + + lock (membraneCollisions) + { + CleanOldCacheEntriesIn(membraneCollisions, Constants.PROCEDURAL_CACHE_MICROBE_SHAPE_TIME); + } } /// - /// Notify about entering a game state. Used to clear unnecessary cached data + /// Notify about entering a game state. Used to clear unnecessary cached pointData /// /// The new game state the game is moving to /// @@ -46,10 +87,9 @@ public override void _Process(float delta) /// immediately load a save of the same stage they were in previously, wasting time. /// /// - /// TODO: need to add calls to this public void OnEnterState(MainGameState gameState) { - // Editor(s) should keep the same data cache as the stage(s) + // Editor(s) should keep the same pointData cache as the stage(s) if (gameState == MainGameState.MicrobeEditor) gameState = MainGameState.MicrobeStage; @@ -60,37 +100,164 @@ public void OnEnterState(MainGameState gameState) if (gameState != MainGameState.MicrobeStage) { - membraneCache.Clear(); + lock (membraneCache) + { + ClearCacheData(membraneCache); + } + + lock (membraneCollisions) + { + ClearCacheData(membraneCollisions); + } } } - public ComputedMembraneData? ReadMembraneData(long hash) + public MembranePointData? ReadMembraneData(long hash) { - if (!membraneCache.TryGetValue(hash, out var entry)) - return null; + lock (membraneCache) + { + if (!membraneCache.TryGetValue(hash, out var entry)) + return null; - entry.LastUsed = currentTime; - return entry.Value; + entry.LastUsed = currentTime; + return entry.Value; + } } - public void WriteMembraneData(ComputedMembraneData data) + /// + /// Writes calculated membrane data to the cache + /// + /// The data to write + /// The hash of the cache entry + public long WriteMembraneData(MembranePointData pointData) { - membraneCache[data.ComputeCacheHash()] = new CacheEntry(data, currentTime); + var hash = pointData.ComputeCacheHash(); + + lock (membraneCache) + { + // Ensure old data is not lost without disposing + if (membraneCache.TryGetValue(hash, out var existing)) + { + // Skip adding same object to the cache multiple times + if (ReferenceEquals(existing.Value, pointData)) + { + existing.LastUsed = currentTime; + return hash; + } + + existing.Value.Dispose(); + } + + membraneCache[hash] = new CacheEntry(pointData, currentTime); + } + + return hash; + } + + public PhysicsShape? ReadLoadedShape(string filePath, float density) + { + var hash = CacheableShape.CalculateHash(filePath, density); + + lock (loadedShapes) + { + if (!loadedShapes.TryGetValue(hash, out var entry)) + return null; + + entry.LastUsed = currentTime; + return entry.Value.Shape; + } + } + + public long WriteLoadedShape(string filePath, float density, PhysicsShape shape) + { + var hash = CacheableShape.CalculateHash(filePath, density); + + lock (loadedShapes) + { + if (loadedShapes.TryGetValue(hash, out var existing)) + { + // Skip adding same object to the cache multiple times + if (ReferenceEquals(existing.Value.Shape, shape)) + { + existing.LastUsed = currentTime; + return hash; + } + + existing.Value.Dispose(); + } + + loadedShapes[hash] = + new CacheEntry(new CacheableShape(shape, filePath, density), currentTime); + } + + return hash; + } + + public MembraneCollisionShape? ReadMembraneCollisionShape(long hash) + { + lock (membraneCollisions) + { + if (!membraneCollisions.TryGetValue(hash, out var entry)) + return null; + + entry.LastUsed = currentTime; + return entry.Value; + } + } + + public long WriteMembraneCollisionShape(MembraneCollisionShape shape) + { + var hash = shape.ComputeCacheHash(); + + lock (membraneCollisions) + { + if (membraneCollisions.TryGetValue(hash, out var existing)) + { + // Skip adding same object to the cache multiple times + if (ReferenceEquals(existing.Value, shape)) + { + existing.LastUsed = currentTime; + return hash; + } + + existing.Value.Dispose(); + } + + membraneCollisions[hash] = new CacheEntry(shape, currentTime); + } + + return hash; } private void CleanOldCacheEntriesIn(Dictionary> entries, float keepTime) + where T : ICacheableData { if (entries.Count < 1) return; + // This is just incidentally locked in one place + // ReSharper disable once InconsistentlySynchronizedField var cutoff = currentTime - keepTime; + // TODO: avoid this temporary list allocation here foreach (var toRemove in entries.Where(e => e.Value.LastUsed < cutoff).ToList()) { + toRemove.Value.Value.Dispose(); entries.Remove(toRemove.Key); } } + private void ClearCacheData(Dictionary> entries) + where T : ICacheableData + { + foreach (var entry in entries) + { + entry.Value.Value.Dispose(); + } + + entries.Clear(); + } + private class CacheEntry { /// @@ -106,4 +273,43 @@ public CacheEntry(T value, float currentTime) LastUsed = currentTime; } } + + private class CacheableShape : ICacheableData + { + private readonly string path; + private readonly float density; + + public CacheableShape(PhysicsShape shape, string path, float density) + { + this.path = path; + this.density = density; + Shape = shape; + } + + public PhysicsShape Shape { get; } + + public static long CalculateHash(string path, float density) + { + return path.GetHashCode() + ((long)density.GetHashCode() << 32); + } + + public bool MatchesCacheParameters(ICacheableData cacheData) + { + if (cacheData is CacheableShape otherShape) + return path == otherShape.path && density == otherShape.density; + + return false; + } + + public long ComputeCacheHash() + { + return CalculateHash(path, density); + } + + public void Dispose() + { + // Don't dispose point as something else might still be referring to it + // Shape.Dispose(); + } + } } diff --git a/src/engine/RaycastResult.cs b/src/engine/RaycastResult.cs index c10462d433d..5f282702bb0 100644 --- a/src/engine/RaycastResult.cs +++ b/src/engine/RaycastResult.cs @@ -1,73 +1 @@ -using System; -using Godot; - -/// -/// Wraps info returned by . -/// -public readonly struct RaycastResult : IEquatable -{ - public RaycastResult(object collider, int colliderId, Vector3 normal, Vector3 position, RID rid, int shape) - { - Collider = collider; - ColliderId = colliderId; - Normal = normal; - Position = position; - Rid = rid; - Shape = shape; - } - - /// - /// The colliding object. - /// - public object Collider { get; } - - /// - /// The colliding object's ID. - /// - public int ColliderId { get; } - - /// - /// The object's surface normal at the intersection point. - /// - public Vector3 Normal { get; } - - /// - /// The intersection point. - /// - public Vector3 Position { get; } - - /// - /// The intersecting object's RID. - /// - public RID Rid { get; } - - /// - /// The shape index of the colliding shape. - /// - public int Shape { get; } - - public static bool operator ==(RaycastResult left, RaycastResult right) - { - return left.Equals(right); - } - - public static bool operator !=(RaycastResult left, RaycastResult right) - { - return !(left == right); - } - - public bool Equals(RaycastResult other) - { - return Rid.GetId() == other.Rid.GetId() && Shape == other.Shape; - } - - public override bool Equals(object? obj) - { - return obj is RaycastResult result && Equals(result); - } - - public override int GetHashCode() - { - return Rid.GetId().GetHashCode() ^ Shape.GetHashCode(); - } -} + \ No newline at end of file diff --git a/src/engine/StartupActions.cs b/src/engine/StartupActions.cs index 7c1f5e3f177..28532f870ec 100644 --- a/src/engine/StartupActions.cs +++ b/src/engine/StartupActions.cs @@ -34,6 +34,25 @@ private StartupActions() GD.Print("Game logs are written to: ", Path.Combine(userDir, Constants.LOGS_FOLDER_NAME), " latest log is 'log.txt'"); + bool skipNative = false; + + try + { + NativeInterop.Load(); + } + catch (DllNotFoundException) + { + if (Engine.EditorHint) + { + skipNative = true; + GD.Print("Skipping native library load in editor as it is not available"); + } + else + { + throw; + } + } + // Load settings here, to make sure locales etc. are applied to the main loaded and autoloaded scenes try { @@ -45,5 +64,8 @@ private StartupActions() { GD.PrintErr("Failed to initialize settings: ", e); } + + if (!skipNative) + NativeInterop.Init(Settings.Instance); } } diff --git a/src/engine/attributes/ReadsComponentAttribute.cs b/src/engine/attributes/ReadsComponentAttribute.cs new file mode 100644 index 00000000000..78f71415341 --- /dev/null +++ b/src/engine/attributes/ReadsComponentAttribute.cs @@ -0,0 +1,17 @@ +using System; +using DefaultEcs.System; + +/// +/// Marks a system as reading from a component. Can be used to mark a relationship +/// as read only or mark a component the system might read. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = true)] +public class ReadsComponentAttribute : Attribute +{ + public ReadsComponentAttribute(Type readsFrom) + { + ReadsFrom = readsFrom; + } + + public Type ReadsFrom { get; set; } +} diff --git a/src/engine/attributes/RunsAfterAttribute.cs b/src/engine/attributes/RunsAfterAttribute.cs new file mode 100644 index 00000000000..47fe8e953dc --- /dev/null +++ b/src/engine/attributes/RunsAfterAttribute.cs @@ -0,0 +1,15 @@ +using System; + +/// +/// Marks that the system with this attribute has to run after another system has finished +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = true)] +public class RunsAfterAttribute : Attribute +{ + public RunsAfterAttribute(Type afterSystem) + { + AfterSystem = afterSystem; + } + + public Type AfterSystem { get; set; } +} diff --git a/src/engine/attributes/RunsBeforeAttribute.cs b/src/engine/attributes/RunsBeforeAttribute.cs new file mode 100644 index 00000000000..066db40c0ee --- /dev/null +++ b/src/engine/attributes/RunsBeforeAttribute.cs @@ -0,0 +1,16 @@ +using System; + +/// +/// Marks that the system with this attribute has to run after another system has finished. For example due to +/// another system clearing data that this needs to run +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = true)] +public class RunsBeforeAttribute : Attribute +{ + public RunsBeforeAttribute(Type beforeSystem) + { + BeforeSystem = beforeSystem; + } + + public Type BeforeSystem { get; set; } +} diff --git a/src/engine/attributes/RunsOnFrameAttribute.cs b/src/engine/attributes/RunsOnFrameAttribute.cs new file mode 100644 index 00000000000..90f9d3987ab --- /dev/null +++ b/src/engine/attributes/RunsOnFrameAttribute.cs @@ -0,0 +1,10 @@ +using System; + +/// +/// Marks a system as running on each rendered frame rather than on logic update (logic updates run most usually +/// 60 times per second at most) +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +public class RunsOnFrameAttribute : Attribute +{ +} diff --git a/src/engine/attributes/RunsOnMainThreadAttribute.cs b/src/engine/attributes/RunsOnMainThreadAttribute.cs new file mode 100644 index 00000000000..93a5a74b99b --- /dev/null +++ b/src/engine/attributes/RunsOnMainThreadAttribute.cs @@ -0,0 +1,9 @@ +using System; + +/// +/// Marks a system as needing to run on the main thread where it is allowed to do any Godot engine operations +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +public class RunsOnMainThreadAttribute : Attribute +{ +} diff --git a/src/engine/attributes/WritesToComponentAttribute.cs b/src/engine/attributes/WritesToComponentAttribute.cs new file mode 100644 index 00000000000..ecb1120aba5 --- /dev/null +++ b/src/engine/attributes/WritesToComponentAttribute.cs @@ -0,0 +1,17 @@ +using System; +using DefaultEcs.System; + +/// +/// Marks a system as writing to a component. automatically implies this so this is +/// needed only when the relationship is not clear +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = true)] +public class WritesToComponentAttribute : Attribute +{ + public WritesToComponentAttribute(Type writesTo) + { + WritesTo = writesTo; + } + + public Type WritesTo { get; set; } +} diff --git a/src/engine/common_components/AnimationControl.cs b/src/engine/common_components/AnimationControl.cs new file mode 100644 index 00000000000..7754017859a --- /dev/null +++ b/src/engine/common_components/AnimationControl.cs @@ -0,0 +1,30 @@ +namespace Components +{ + using Newtonsoft.Json; + + /// + /// Allows controlling in a (note that if + /// spatial is recreated needs to be set to false for the animation to reapply) + /// + public struct AnimationControl + { + // TODO: add speed / animation to play fields to make this generally useful + + /// + /// If not null will try to find the animation player to control based on this path starting from the + /// graphics instance of this entity + /// + public string? AnimationPlayerPath; + + /// + /// If set to true, all animations are stopped + /// + public bool StopPlaying; + + /// + /// Set to false when any properties change in this component to re-apply them + /// + [JsonIgnore] + public bool AnimationApplied; + } +} diff --git a/src/engine/common_components/AttachedChildren.cs b/src/engine/common_components/AttachedChildren.cs new file mode 100644 index 00000000000..a77bd7c0dcd --- /dev/null +++ b/src/engine/common_components/AttachedChildren.cs @@ -0,0 +1,21 @@ +namespace Components +{ + using System.Collections.Generic; + using System.Linq; + using DefaultEcs; + + // TODO: delete this if not necessary (this is currently not updated at all for the microbe colony or engulf logic) + /// + /// Added to the parent entity when is added to the child entity. This tracks all + /// of the entities that are attached to this entity to allow easily finding them for required operations. + /// + public struct AttachedChildren + { + public List Children; + + public AttachedChildren(IEnumerable children) + { + Children = children.ToList(); + } + } +} diff --git a/src/engine/common_components/AttachedToEntity.cs b/src/engine/common_components/AttachedToEntity.cs new file mode 100644 index 00000000000..9688b78548f --- /dev/null +++ b/src/engine/common_components/AttachedToEntity.cs @@ -0,0 +1,43 @@ +namespace Components +{ + using DefaultEcs; + using Godot; + + /// + /// Entity data regarding being attached to another entity + /// + public struct AttachedToEntity + { + /// + /// Entity this is attached to. Should be valid whenever this component exists + /// + public Entity AttachedTo; + + /// + /// Position relative to the parent entity + /// + public Vector3 RelativePosition; + + /// + /// Rotation relative to the parent entity + /// + public Quat RelativeRotation; + + public AttachedToEntity(in Entity parentEntity, Vector3 relativePosition, Quat relativeRotation) + { + AttachedTo = parentEntity; + RelativePosition = relativePosition; + RelativeRotation = relativeRotation; + } + } + + public static class AttachedToEntityHelpers + { + /// + /// Hold this lock whenever entity attach relationships are modified. This will hopefully ensure that we + /// don't end up with many very complex state bugs that trigger if some entity is tried to be attached to + /// multiple different places on exactly the same frame. + /// + public static readonly object EntityAttachRelationshipModifyLock = new(); + } +} diff --git a/src/engine/common_components/CameraFollowTarget.cs b/src/engine/common_components/CameraFollowTarget.cs new file mode 100644 index 00000000000..2b35a25d956 --- /dev/null +++ b/src/engine/common_components/CameraFollowTarget.cs @@ -0,0 +1,15 @@ +namespace Components +{ + /// + /// Marks an entity as the one for the game's camera to follow. Also requires a + /// component. + /// + public struct CameraFollowTarget + { + /// + /// If set to true this target is ignored. Only one active target should exist as once, otherwise a random + /// one is selected to show with the camera. + /// + public bool Disabled; + } +} diff --git a/src/engine/common_components/CollisionManagement.cs b/src/engine/common_components/CollisionManagement.cs new file mode 100644 index 00000000000..c6dd3f637c9 --- /dev/null +++ b/src/engine/common_components/CollisionManagement.cs @@ -0,0 +1,113 @@ +namespace Components +{ + using System; + using System.Collections.Generic; + using System.Runtime.CompilerServices; + using System.Runtime.InteropServices; + using System.Threading; + using DefaultEcs; + using Newtonsoft.Json; + + /// + /// Allows modifying collisions of this entity + /// + public struct CollisionManagement + { + /// + /// Collisions experienced by this entity note that needs to be 1 or + /// more for this list to the populated. Don't reassign this list as otherwise it will stop being updated + /// by the underlying physics body. + /// + [JsonIgnore] + public PhysicsCollision[]? ActiveCollisions; + + /// + /// Pointer to the field that stores the size of valid collisions inside . + /// Use + /// + [JsonIgnore] + public IntPtr ActiveCollisionCountPtr; + + public List? IgnoredCollisionsWith; + + /// + /// When specified this callback is called before any physics collisions are allowed to happen. Returning + /// false will prevent that collision. Note that no state should be modified (that is not completely + /// thread-safe and entity order safe) by this. Also this will increase the physics processing expensiveness + /// of an entity so if at all possible other approaches should be used to filter out unwanted collisions. + /// Or only react to detected collisions of wanted type. The filter works by calling from the native side + /// back to the C# side inside the physics simulation. + /// + /// + /// + /// When clearing this, it is extremely important to set as otherwise the C++ + /// side will hold onto an invalid callback and cause very weird method call bugs. The only case where that + /// is fine if this delegate refers to a static method. + /// + /// + /// + /// + /// TODO: plan if this should be saved (in which case some objects don't want their callbacks to save, + /// for example the toxin collision system) or if all systems will need to reapply their filters after load + /// + /// + [JsonIgnore] + public PhysicalWorld.OnCollisionFilterCallback? CollisionFilter; + + /// + /// When set above 0 up to this many collisions are recorded in + /// + /// + /// + /// Note that increasing or lowering this value after recording has been enabled has no effect. All + /// entities should just initially figure out how many max collisions they should handle. + /// + /// + public int RecordActiveCollisions; + + /// + /// Must be set to false after changing any properties to have them apply (after the initial creation) + /// + [JsonIgnore] + public bool StateApplied; + + // The following variables are internal for the collision management system and should not be modified + [JsonIgnore] + public bool CurrentCollisionState; + + [JsonIgnore] + public bool CollisionFilterCallbackRegistered; + + /// + /// Internal flag don't touch. Used as an optimization to not always have to call to the native side library. + /// + [JsonIgnore] + public bool CollisionIgnoresUsed; + } + + public static class CollisionManagementHelpers + { + public static void StartCollisionRecording(this ref CollisionManagement collisionManagement, int maxCollisions) + { + if (collisionManagement.RecordActiveCollisions >= maxCollisions) + return; + + Interlocked.Add(ref collisionManagement.RecordActiveCollisions, maxCollisions); + collisionManagement.StateApplied = false; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int GetActiveCollisions(this ref CollisionManagement collisionManagement, + out PhysicsCollision[]? collisions) + { + // If state is not correct for reading + collisions = collisionManagement.ActiveCollisions; + if (collisions == null || collisionManagement.ActiveCollisionCountPtr.ToInt64() == 0) + { + return 0; + } + + return Marshal.ReadInt32(collisionManagement.ActiveCollisionCountPtr); + } + } +} diff --git a/src/engine/common_components/ColourAnimation.cs b/src/engine/common_components/ColourAnimation.cs new file mode 100644 index 00000000000..f5788252d6a --- /dev/null +++ b/src/engine/common_components/ColourAnimation.cs @@ -0,0 +1,147 @@ +namespace Components +{ + using Godot; + using Newtonsoft.Json; + + /// + /// Specifies simple colour changing animations + /// + public struct ColourAnimation + { + /// + /// The default colour that can be returned to. For example stores the base microbe colour to reset to after + /// animating. + /// + public Color DefaultColour; + + public Color AnimationTargetColour; + public Color AnimationStartColour; + + public float AnimationDuration; + + /// + /// The code triggering animations may store whatever info it wants about the animations here. For example + /// how important the current animation is to know if some other animation is allowed to overwrite this. + /// + public int AnimationUserInfo; + + /// + /// This shouldn't be changed manually outside the colour animation system + /// + public float AnimationElapsed; + + /// + /// If true the animation is played in reverse after it completes once. Used for example for colour flashes. + /// + public bool AutoReverseAnimation; + + /// + /// Needs to be set to true to trigger the animation to happen + /// + public bool Animating; + + /// + /// if true only the first material is animated on an entity and the other ones are left untouched + /// + public bool AnimateOnlyFirstMaterial; + + /// + /// True when whatever entity / stage specific system that handles applying the colour is + /// + [JsonIgnore] + public bool ColourApplied; + + public ColourAnimation(Color defaultColour) + { + DefaultColour = defaultColour; + AnimationTargetColour = defaultColour; + AnimationStartColour = default; + + AnimationDuration = 0; + AnimationUserInfo = 0; + AnimationElapsed = 0; + + AutoReverseAnimation = false; + Animating = false; + AnimateOnlyFirstMaterial = false; + + ColourApplied = false; + } + + /// + /// The current colour value that should be displayed. Note that this component by itself is not enough to + /// get this to display anywhere. + /// + [JsonIgnore] + public Color CurrentColour + { + get + { + if (!Animating || AnimationElapsed >= AnimationDuration) + return AnimationTargetColour; + + return AnimationStartColour.LinearInterpolate(AnimationTargetColour, + AnimationElapsed / AnimationDuration); + } + } + } + + public static class ColourAnimationHelpers + { + /// + /// Plays a flashing animation + /// + /// Where to put the animation + /// The colour to flash as + /// How long the change to the target colour takes + /// + /// Used to skip previous animations, if this is higher than current + /// then this replaces the current animation. Otherwise this + /// is silently ignored. + /// + public static void Flash(this ref ColourAnimation animation, Color targetColour, float duration, + int priority = 1) + { + if (animation.Animating && animation.AnimationUserInfo >= priority) + return; + + animation.AnimationStartColour = animation.CurrentColour; + animation.AnimationTargetColour = targetColour; + + animation.AnimationDuration = duration; + animation.AutoReverseAnimation = true; + animation.AnimationElapsed = 0; + animation.AnimationUserInfo = priority; + + animation.Animating = true; + } + + /// + /// Stops animations and resets to default colour (and forces a colour update to happen) + /// + public static void ResetColour(this ref ColourAnimation animation) + { + animation.AnimationTargetColour = animation.DefaultColour; + + animation.Animating = false; + animation.ColourApplied = false; + } + + /// + /// Updates current animation to work with a changed value + /// + public static void UpdateAnimationForNewDefaultColour(this ref ColourAnimation animation) + { + if (!animation.Animating) + return; + + // Replace current animation with one going to the new base colour + animation.AutoReverseAnimation = false; + + animation.AnimationStartColour = animation.CurrentColour; + animation.AnimationTargetColour = animation.DefaultColour; + animation.AnimationElapsed = 0; + animation.AnimationUserInfo = 0; + } + } +} diff --git a/src/engine/common_components/CountLimited.cs b/src/engine/common_components/CountLimited.cs new file mode 100644 index 00000000000..674795704af --- /dev/null +++ b/src/engine/common_components/CountLimited.cs @@ -0,0 +1,11 @@ +namespace Components +{ + /// + /// Limit for how many entities can exist in the configured group. Requires as + /// despawning is done far away from the player. + /// + public struct CountLimited + { + public LimitGroup Group; + } +} diff --git a/src/engine/common_components/DamageCooldown.cs b/src/engine/common_components/DamageCooldown.cs new file mode 100644 index 00000000000..fbe99d95214 --- /dev/null +++ b/src/engine/common_components/DamageCooldown.cs @@ -0,0 +1,28 @@ +namespace Components +{ + /// + /// Entity keeps track of damage cooldown + /// + public struct DamageCooldown + { + public float CooldownRemaining; + } + + public static class DamageCooldownHelpers + { + public static bool IsInCooldown(this ref DamageCooldown damageCooldown) + { + return damageCooldown.CooldownRemaining > 0; + } + + public static void StartCooldown(this ref DamageCooldown damageCooldown, float cooldownTime) + { + damageCooldown.CooldownRemaining = cooldownTime; + } + + public static void StartInjectisomeCooldown(this ref DamageCooldown damageCooldown) + { + damageCooldown.StartCooldown(Constants.PILUS_INVULNERABLE_TIME); + } + } +} diff --git a/src/engine/common_components/DamageOnTouch.cs b/src/engine/common_components/DamageOnTouch.cs new file mode 100644 index 00000000000..097fa96ed87 --- /dev/null +++ b/src/engine/common_components/DamageOnTouch.cs @@ -0,0 +1,44 @@ +namespace Components +{ + using Newtonsoft.Json; + + /// + /// Damages any entities touched by this entity. Requires + /// + public struct DamageOnTouch + { + /// + /// The name of the caused damage type this deals + /// + public string DamageType; + + /// + /// The amount of damage this causes. This is allowed to be 0 to implement entities that just get destroyed + /// on touch. When is true this is the inflicted damage, otherwise this is the + /// damage per second. + /// + public float DamageAmount; + + /// + /// If true then this is destroyed when this collides with something this could deal damage to + /// + public bool DestroyOnTouch; + + /// + /// Uses a microbe stage dissolve effect on the visuals when being destroyed + /// + public bool UsesMicrobialDissolveEffect; + + /// + /// Internal variable, don't modify + /// + [JsonIgnore] + public bool StartedDestroy; + + /// + /// Internal variable, don't modify + /// + [JsonIgnore] + public bool RegisteredWithCollisions; + } +} diff --git a/src/engine/common_components/EntityMaterial.cs b/src/engine/common_components/EntityMaterial.cs new file mode 100644 index 00000000000..04232030b05 --- /dev/null +++ b/src/engine/common_components/EntityMaterial.cs @@ -0,0 +1,32 @@ +namespace Components +{ + using Godot; + using Newtonsoft.Json; + + /// + /// Access to a material defined on an entity + /// + public struct EntityMaterial + { + [JsonIgnore] + public ShaderMaterial[]? Materials; + + /// + /// If not null then uses this as the relative path from the + /// node to where the material is retrieved from + /// + public string? AutoRetrieveModelPath; + + /// + /// When true and this entity has a component the material is automatically + /// fetched + /// + public bool AutoRetrieveFromSpatial; + + /// + /// Internal flag, don't modify + /// + [JsonIgnore] + public bool MaterialFetchPerformed; + } +} diff --git a/src/engine/common_components/EntityRadiusInfo.cs b/src/engine/common_components/EntityRadiusInfo.cs new file mode 100644 index 00000000000..4e670a6a9d3 --- /dev/null +++ b/src/engine/common_components/EntityRadiusInfo.cs @@ -0,0 +1,22 @@ +namespace Components +{ + /// + /// Entity is roughly circular and this provides easy access to that entity's radius + /// + /// + /// + /// This component type was added as I wasn't confident enough in remaking the + /// without having access to microbe chunk radius when calculating engulf + /// positions -hhyyrylainen + /// + /// + public struct EntityRadiusInfo + { + public float Radius; + + public EntityRadiusInfo(float radius) + { + Radius = radius; + } + } +} diff --git a/src/engine/common_components/FadeOutActions.cs b/src/engine/common_components/FadeOutActions.cs new file mode 100644 index 00000000000..93232de46db --- /dev/null +++ b/src/engine/common_components/FadeOutActions.cs @@ -0,0 +1,31 @@ +namespace Components +{ + /// + /// Special actions to perform on time to live expiring and fading out + /// + public struct FadeOutActions + { + public float FadeTime; + + public bool DisableCollisions; + public bool RemoveVelocity; + public bool RemoveAngularVelocity; + + /// + /// Disables a particles emitter if there is one on the entity spatial root + /// + public bool DisableParticles; + + public bool UsesMicrobialDissolveEffect; + + /// + /// If true then is emptied on fade out + /// + public bool VentCompounds; + + /// + /// Internal variable for use by the managing system + /// + public bool CallbackRegistered; + } +} diff --git a/src/engine/common_components/Health.cs b/src/engine/common_components/Health.cs new file mode 100644 index 00000000000..d64f9a92e7e --- /dev/null +++ b/src/engine/common_components/Health.cs @@ -0,0 +1,210 @@ +namespace Components +{ + using System; + using System.Collections.Generic; + using Godot; + + /// + /// Things that have a health and can be damaged + /// + public struct Health + { + public List? RecentDamageReceived; + + public float CurrentHealth; + public float MaxHealth; + + // TODO: an invulnerability duration to automatically turn of invulnerability? (needed to reimplement pilus + // damage cooldown) + + public bool Invulnerable; + + /// + /// Simple flag to check if this entity has died. A stage specific death system will set this flag when + /// an entity runs out of health (or some other condition is fulfilled for death) + /// + public bool Dead; + + /// + /// This health class is stage agnostic, so each stage needs its own entity death system to handle dying. + /// To at least make that easier this flag exists for such a system to store the info on if it has already + /// handled a dead entity or not. + /// + public bool DeathProcessed; + + public Health(float defaultHealth) + { + MaxHealth = defaultHealth; + CurrentHealth = MaxHealth; + + Invulnerable = false; + Dead = false; + DeathProcessed = false; + RecentDamageReceived = null; + } + } + + public static class HealthHelpers + { + public static float CalculateMicrobeHealth(MembraneType membraneType, float membraneRigidity) + { + return membraneType.Hitpoints + + (membraneRigidity * Constants.MEMBRANE_RIGIDITY_HITPOINTS_MODIFIER); + } + + /// + /// A general damage dealing method that doesn't apply any damage reductions or anything like that + /// + public static void DealDamage(this ref Health health, float damage, string damageSource) + { + if (health.Invulnerable) + { + // Just consume this damage event if the target is not taking damage + return; + } + + if (string.IsNullOrEmpty(damageSource)) + throw new ArgumentException("damage source is empty"); + + // This is probably no longer needed, but just for safety this makes sure no negative damage is applied + if (damage < 0) + { + GD.PrintErr("Trying to deal negative damage"); + return; + } + + // Can't damage dead things (or deal no damage) + if (health.Dead || damage == 0) + return; + + // This should result in at least reasonable health even if thread race conditions hit here + health.CurrentHealth = Math.Max(0, health.CurrentHealth - damage); + + var damageEvent = new DamageEventNotice(damageSource, damage); + var damageList = health.RecentDamageReceived; + + if (damageList == null) + { + // Create new damage list, don't really care if due to data race some info is lost here so we don't + // immediately set the list here and lock it + damageList = new List { damageEvent }; + + health.RecentDamageReceived = damageList; + } + else + { + lock (damageList) + { + // Skip tracking damage after max number of events + if (damageList.Count >= Constants.MAX_DAMAGE_EVENTS) + return; + + damageList.Add(damageEvent); + + if (damageList.Count == Constants.MAX_DAMAGE_EVENTS) + { + // Print an error once per entity + GD.PrintErr("Damage event overflow for an entity, all entities should always have " + + "a system clearing their damage events"); + } + } + } + + // TODO: probably need a separate system to trigger this + // ModLoader.ModInterface.TriggerOnDamageReceived(this, amount, IsPlayerMicrobe); + } + + /// + /// Applies damage but takes microbe damage resistances into account. This should be (almost always) be used + /// for microbes to calculate the right damage rather than + /// + /// + /// + /// TODO: would it be cleaner design to bake in resistances / a damage callback into the base Health type + /// so that no more entity type specific methods like this would be needed? + /// + /// + public static void DealMicrobeDamage(this ref Health health, ref CellProperties cellProperties, float damage, + string damageSource) + { + // TODO: reimplement this (probably better to use the invulnerable health property and also make engulf + // check that to prevent engulfing of the player) + // if (IsPlayerMicrobe && CheatManager.GodMode) + // return; + + // Damage reduction is only wanted for non-starving damage + bool canApplyDamageReduction = true; + + if (damageSource is "toxin" or "oxytoxy" or "injectisome") + { + // Divide damage by toxin resistance + damage /= cellProperties.MembraneType.ToxinResistance; + } + else if (damageSource is "pilus" or "chunk" or "ice") + { + // Divide damage by physical resistance + damage /= cellProperties.MembraneType.PhysicalResistance; + } + else if (damageSource == "atpDamage") + { + canApplyDamageReduction = false; + } + + if (!cellProperties.IsBacteria && canApplyDamageReduction) + { + damage /= 2; + } + + health.DealDamage(damage, damageSource); + } + + /// + /// Immediately kills this entity + /// + /// The health to mark dead + /// If true also kills invulnerable entities + public static void Kill(this ref Health health, bool goesThroughInvulnerability = true) + { + if (health.Invulnerable && !goesThroughInvulnerability) + return; + + health.CurrentHealth = 0; + health.Invulnerable = false; + } + + /// + /// Modifies the max health and rescales remaining health percentage to be the same with the new value than + /// it currently is + /// + /// Health to update max health for + /// New max health value to set + public static void RescaleMaxHealth(this ref Health health, float newMaxHealth) + { + // Safety check against bad data + if (newMaxHealth <= 0) + newMaxHealth = 1; + + float currentFraction = health.CurrentHealth / health.MaxHealth; + + health.MaxHealth = newMaxHealth; + + health.CurrentHealth = health.MaxHealth * currentFraction; + } + } + + /// + /// Notice to an entity that it took damage. Used for example to play sounds or other feedback about taking + /// damage + /// + public class DamageEventNotice + { + public string DamageSource; + public float Amount; + + public DamageEventNotice(string damageSource, float amount) + { + DamageSource = damageSource; + Amount = amount; + } + } +} diff --git a/src/engine/common_components/LimitGroup.cs b/src/engine/common_components/LimitGroup.cs new file mode 100644 index 00000000000..85702b450a0 --- /dev/null +++ b/src/engine/common_components/LimitGroup.cs @@ -0,0 +1,22 @@ +namespace Components +{ + /// + /// Group of entities that are limited. Each group has a separately counted limit. + /// + /// + /// + /// Don't reorder the values here otherwise saving will break + /// + /// + public enum LimitGroup + { + General = 0, + + Chunk, + + /// + /// Chunks spawned by the spawn system + /// + ChunkSpawned, + } +} diff --git a/src/engine/common_components/ManualPhysicsControl.cs b/src/engine/common_components/ManualPhysicsControl.cs new file mode 100644 index 00000000000..4380a43467c --- /dev/null +++ b/src/engine/common_components/ManualPhysicsControl.cs @@ -0,0 +1,23 @@ +namespace Components +{ + using Godot; + + /// + /// Allows manual physics control over physical entities + /// + public struct ManualPhysicsControl + { + // Note: to allow multiple places in the code to use this this should have values added with += instead of + // assigning to not remove the previous value. + public Vector3 ImpulseToGive; + public Vector3 AngularImpulseToGive; + + public bool RemoveVelocity; + public bool RemoveAngularVelocity; + + /// + /// Needs to be set false whenever anything is changed here, otherwise the physics state is not applied + /// + public bool PhysicsApplied; + } +} diff --git a/src/engine/common_components/PathLoadedSceneVisuals.cs b/src/engine/common_components/PathLoadedSceneVisuals.cs new file mode 100644 index 00000000000..a6d529c4be5 --- /dev/null +++ b/src/engine/common_components/PathLoadedSceneVisuals.cs @@ -0,0 +1,22 @@ +namespace Components +{ + using Newtonsoft.Json; + + /// + /// Specifies an exact scene path to load from. Using + /// should be preferred for all cases where that is usable for the situation. + /// + public struct PathLoadedSceneVisuals + { + /// + /// The scene to display. Setting this to null stops displaying the current scene + /// + public string? ScenePath; + + /// + /// Internal variable for the loading system, do not touch + /// + [JsonIgnore] + public string? LastLoadedScene; + } +} diff --git a/src/engine/common_components/Physics.cs b/src/engine/common_components/Physics.cs new file mode 100644 index 00000000000..7d6a974c108 --- /dev/null +++ b/src/engine/common_components/Physics.cs @@ -0,0 +1,105 @@ +namespace Components +{ + using System; + using Godot; + using Newtonsoft.Json; + + /// + /// Physics body for an entity + /// + public struct Physics + { + /// + /// Allows direct physics state control. need to be false for this to apply. + /// Only applies on body creation unless also component exists on the + /// current entity. + /// + public Vector3 Velocity; + + public Vector3 AngularVelocity; + + [JsonIgnore] + public NativePhysicsBody? Body; + + public float? LinearDamping; + + /// + /// Angular damping. Note that this only applies if is also not null. + /// + public float? AngularDamping; + + /// + /// Set to false if the new velocities should apply to the entity + /// + [JsonIgnore] + public bool VelocitiesApplied; + + /// + /// Set to false if new damping values are set + /// + [JsonIgnore] + public bool DampingApplied; + + /// + /// When true is updated from the physics system each update + /// + public bool TrackVelocity; + + /// + /// Sets the axis lock type applied when the body is created (for example constraining to the the Y-axis). + /// This limitation exists because there's currently no need to allow physics bodies to add / remove the + /// axis lock dynamically, so if this value is changed then the body needs to be forcefully recreated. + /// + public AxisLockType AxisLock; + + /// + /// When set to , this disables all *further* + /// collisions for the object. This doesn't stop any existing collisions. To do that the physics body needs + /// to be removed entirely from the world with . + /// + public CollisionState DisableCollisionState; + + // TODO: flags for teleporting the physics body to current WorldPosition and also overriding velocity + angular + + /// + /// When the body is disabled the body state is no longer read into the position variables allowing custom + /// control. And it is removed from the physics system to not interact with anything. + /// + public bool BodyDisabled; + + /// + /// Internal variable for the disable system, don't touch elsewhere + /// + [JsonIgnore] + public bool InternalDisableState; + + [JsonIgnore] + public bool InternalDisableCollisionState; + + [Flags] + public enum AxisLockType : byte + { + None = 0, + YAxis = 1, + AlsoLockRotation = 2, + YAxisWithRotation = 3, + } + + public enum CollisionState : byte + { + DoNotChange = 0, + EnableCollisions = 1, + DisableCollisions = 2, + } + } + + public static class PhysicsHelpers + { + public static void SetCollisionDisableState(this ref Physics physics, bool disableCollisions) + { + physics.DisableCollisionState = disableCollisions ? + Physics.CollisionState.DisableCollisions : + Physics.CollisionState.EnableCollisions; + } + } +} diff --git a/src/engine/common_components/PhysicsShapeHolder.cs b/src/engine/common_components/PhysicsShapeHolder.cs new file mode 100644 index 00000000000..1f6c2b537e7 --- /dev/null +++ b/src/engine/common_components/PhysicsShapeHolder.cs @@ -0,0 +1,45 @@ +namespace Components +{ + using Newtonsoft.Json; + + /// + /// Holds a physics shape once one is ready and then allows creating a physics body from it + /// + public struct PhysicsShapeHolder + { + [JsonIgnore] + public PhysicsShape? Shape; + + /// + /// When true the body is created as a static body that cannot move + /// + public bool BodyIsStatic; + + /// + /// When true the related physics body will be updated from when the shape is ready. + /// Will be automatically reset to false afterwards. + /// + public bool UpdateBodyShapeIfCreated; + } + + public static class PhysicsShapeHolderExtensions + { + /// + /// Gets the mass of a shape holder's shape if exist (if doesn't exist sets mass to 1) + /// + /// Shape holder to look at + /// The found shape mass or 1000 if not found + /// True if mass was retrieved + public static bool TryGetShapeMass(this ref PhysicsShapeHolder shapeHolder, out float mass) + { + if (shapeHolder.Shape == null) + { + mass = 1000; + return false; + } + + mass = shapeHolder.Shape.GetMass(); + return true; + } + } +} diff --git a/src/engine/common_components/PlayerMarker.cs b/src/engine/common_components/PlayerMarker.cs new file mode 100644 index 00000000000..6c45a2fe448 --- /dev/null +++ b/src/engine/common_components/PlayerMarker.cs @@ -0,0 +1,13 @@ +namespace Components +{ + /// + /// Marks entity as the player's controlled character + /// + public struct PlayerMarker + { + /// + /// Used for a few player specific dying conditions that take different amount of time than for AI creatures + /// + public float PlayerDeathTimer; + } +} diff --git a/src/engine/common_components/PredefinedVisuals.cs b/src/engine/common_components/PredefinedVisuals.cs new file mode 100644 index 00000000000..3fff38911eb --- /dev/null +++ b/src/engine/common_components/PredefinedVisuals.cs @@ -0,0 +1,18 @@ +namespace Components +{ + /// + /// Entity uses a predefined visual + /// + public struct PredefinedVisuals + { + /// + /// Specifies what this entity should display as its visuals + /// + public VisualResourceIdentifier VisualIdentifier; + + /// + /// Don't touch this, used by the system for handling this + /// + public VisualResourceIdentifier LoadedInstance; + } +} diff --git a/src/engine/common_components/ReadableName.cs b/src/engine/common_components/ReadableName.cs new file mode 100644 index 00000000000..507d46a62d8 --- /dev/null +++ b/src/engine/common_components/ReadableName.cs @@ -0,0 +1,15 @@ +namespace Components +{ + /// + /// Player readable name for an entity. Must be set on init so always use the constructor. + /// + public struct ReadableName + { + public LocalizedString Name; + + public ReadableName(LocalizedString name) + { + Name = name; + } + } +} diff --git a/src/engine/common_components/RenderOrderOverride.cs b/src/engine/common_components/RenderOrderOverride.cs new file mode 100644 index 00000000000..f2a3ebfb22b --- /dev/null +++ b/src/engine/common_components/RenderOrderOverride.cs @@ -0,0 +1,33 @@ +namespace Components +{ + using Newtonsoft.Json; + + /// + /// Overrides rendering order for an entity with . Used for some specific rendering + /// effects that can't be done otherwise. + /// + public struct RenderOrderOverride + { + /// + /// Overrides the render priority of this Spatial. Use + /// to set this to ensure the applied flag is + /// reset to have the effect be applied. + /// + public int RenderPriority; + + /// + /// Must be set to false when changing to have a new value be applied + /// + [JsonIgnore] + public bool RenderPriorityApplied; + } + + public static class RenderOrderOverrideHelpers + { + public static void SetRenderPriority(this ref RenderOrderOverride spatialInstance, int priority) + { + spatialInstance.RenderPriorityApplied = false; + spatialInstance.RenderPriority = priority; + } + } +} diff --git a/src/engine/common_components/ReproductionStatus.cs b/src/engine/common_components/ReproductionStatus.cs new file mode 100644 index 00000000000..fa4710e0561 --- /dev/null +++ b/src/engine/common_components/ReproductionStatus.cs @@ -0,0 +1,66 @@ +namespace Components +{ + using System.Collections.Generic; + + /// + /// General info about the reproduction status of a creature + /// + public struct ReproductionStatus + { + public Dictionary? MissingCompoundsForBaseReproduction; + + // TODO: remove if unused for now + public bool ReadyToReproduce; + + public ReproductionStatus(IReadOnlyDictionary baseReproductionCost) + { + MissingCompoundsForBaseReproduction = baseReproductionCost.CloneShallow(); + + ReadyToReproduce = false; + } + } + + public static class ReproductionStatusHelpers + { + /// + /// Sets up the base reproduction cost that is on top of the normal costs (for microbes) + /// + public static void SetupRequiredBaseReproductionCompounds(this ref ReproductionStatus reproductionStatus, + Species species) + { + reproductionStatus.MissingCompoundsForBaseReproduction ??= new Dictionary(); + + reproductionStatus.MissingCompoundsForBaseReproduction.Clear(); + reproductionStatus.MissingCompoundsForBaseReproduction.Merge(species.BaseReproductionCost); + + // TODO: there was a line here to reset the multicellular growth needed totals, so whatever calls this will + // need to handle that in the future + } + + public static void CalculateAlreadyUsedBaseReproductionCompounds(this ref ReproductionStatus reproductionStatus, + Species species, Dictionary resultReceiver) + { + if (reproductionStatus.MissingCompoundsForBaseReproduction == null) + return; + + foreach (var totalCost in species.BaseReproductionCost) + { + if (!reproductionStatus.MissingCompoundsForBaseReproduction.TryGetValue(totalCost.Key, + out var left)) + { + // If we used any unknown values (which are 0) to calculate the absorbed amounts, this would be + // vastly incorrect + continue; + } + + var absorbed = totalCost.Value - left; + + if (!(absorbed > 0)) + continue; + + resultReceiver.TryGetValue(totalCost.Key, out var alreadyAbsorbed); + resultReceiver[totalCost.Key] = alreadyAbsorbed + absorbed; + } + } + } +} diff --git a/src/engine/common_components/Selectable.cs b/src/engine/common_components/Selectable.cs new file mode 100644 index 00000000000..89871dba009 --- /dev/null +++ b/src/engine/common_components/Selectable.cs @@ -0,0 +1,10 @@ +namespace Components +{ + /// + /// Entity can be selected somehow (using stage specific mechanics) + /// + public struct Selectable + { + public bool Selected; + } +} diff --git a/src/engine/common_components/SoundEffectPlayer.cs b/src/engine/common_components/SoundEffectPlayer.cs new file mode 100644 index 00000000000..c16a58a7113 --- /dev/null +++ b/src/engine/common_components/SoundEffectPlayer.cs @@ -0,0 +1,384 @@ +namespace Components +{ + using System.Runtime.CompilerServices; + using Godot; + using Newtonsoft.Json; + + /// + /// Entity that can play sound effects (short sounds). Requires a to function. + /// + public struct SoundEffectPlayer + { + public SoundEffectSlot[]? SoundEffectSlots; + + /// + /// If not 0 then this is the max distance from (squared) the player that this sound player will play + /// anything at all + /// + public float AbsoluteMaxDistanceSquared; + + /// + /// When true the played sounds are automatically played in 2D for the player's entity (by having a + /// component that is active) + /// + public bool AutoDetectPlayer; + + [JsonIgnore] + public bool SoundsApplied; + } + + public struct SoundEffectSlot + { + /// + /// What this slot should be playing + /// + public string? SoundFile; + + /// + /// Volume multiplier for this sound. Should be in range [0, 1] + /// + public float Volume; + + /// + /// Set to true when this slot should play. Automatically unset when the sound finishes by the sound system + /// + public bool Play; + + /// + /// If true then this sound keeps playing (looping) and never automatically stops. Can + /// be set to false to stop the audio playing after the current loop or manually immediately stopped. + /// + public bool Loop; + + /// + /// Internal flag don't touch + /// + [JsonIgnore] + public ushort InternalPlayingState; + + [JsonIgnore] + public bool InternalAppliedState; + } + + public static class SoundEffectPlayerHelpers + { + /// + /// Starts playing a new sound effect + /// + /// The player component to use + /// The sound file to play + /// Volume to play the sound at + /// True if the sound was started, false if all playing slots were full already + public static bool PlaySoundEffect(this ref SoundEffectPlayer soundEffectPlayer, string sound, float volume = 1) + { + // There's a race condition here but it should only extremely rarely happen if two sounds want to start + // at the exact same moment in time + SoundEffectSlot[]? slots = soundEffectPlayer.SoundEffectSlots; + + if (slots == null) + { + slots ??= new SoundEffectSlot[Constants.MAX_CONCURRENT_SOUNDS_PER_ENTITY]; + soundEffectPlayer.SoundEffectSlots = slots; + } + + lock (slots) + { + int slotCount = slots.Length; + + for (int i = 0; i < slotCount; ++i) + { + ref var slot = ref slots[i]; + + if (!IsSlotReadyForReUse(ref slot)) + continue; + + // Found an empty slot to play in + slot.SoundFile = sound; + slot.Volume = volume; + slot.Play = true; + + slot.Loop = false; + + slot.InternalAppliedState = false; + soundEffectPlayer.SoundsApplied = false; + return true; + } + } + + return false; + } + + /// + /// Plays a sound effect if it isn't already playing + /// + /// True if now playing or was playing already + public static bool PlaySoundEffectIfNotPlayingAlready(this ref SoundEffectPlayer soundEffectPlayer, + string sound, float volume = 1) + { + return soundEffectPlayer.EnsureSoundIsPlaying(sound, false, volume); + } + + /// + /// Plays a looping sound. If the sound is already playing only adjusts the volume, doesn't start another + /// sound. + /// + /// The player component to use + /// The sound file to play in a looping way + /// + /// Volume to play the sound at, note if currently playing this is immediately applied + /// + /// True if sound is now playing, false if the sound could not be started + public static bool PlayLoopingSound(this ref SoundEffectPlayer soundEffectPlayer, string sound, + float volume = 1) + { + // TODO: should looping sounds steal slots from non-looping sounds to ensure looping sounds play? + // When there aren't enough slots + + return soundEffectPlayer.EnsureSoundIsPlaying(sound, true, volume); + } + + /// + /// Stops a sound that is currently playing + /// + /// The player that may have the sound + /// + /// The sound to stop. The first slot playing this sound is stopped. If there are multiple instances of the + /// same sound playing not all of them will be stopped. + /// + /// + /// If false and the sound is looping, then it will only stop playing after the current loop ends + /// + /// True if found a sound to stop + public static bool StopSound(this ref SoundEffectPlayer soundEffectPlayer, string sound, + bool immediately = true) + { + SoundEffectSlot[]? slots = soundEffectPlayer.SoundEffectSlots; + + if (slots == null) + return false; + + lock (slots) + { + int slotCount = slots.Length; + + for (int i = 0; i < slotCount; ++i) + { + ref var slot = ref slots[i]; + + if (!slot.Play || slot.SoundFile != sound) + continue; + + if (slot.Loop && !immediately) + { + // Stop after current loop + slot.Loop = false; + } + else + { + slot.Play = false; + } + + slot.InternalAppliedState = false; + soundEffectPlayer.SoundsApplied = false; + return true; + } + } + + return false; + } + + /// + /// Handles starting and turning up a looping sound effect. Used for microbe movement sound handling, + /// for example. + /// + /// True when the sound is now playing, false if out of slots + public static bool PlayGraduallyTurningUpLoopingSound(this ref SoundEffectPlayer soundEffectPlayer, + string sound, float maxVolume, float initialVolume, float changeSpeed) + { + // See the comments in PlaySoundEffect + SoundEffectSlot[]? slots = soundEffectPlayer.SoundEffectSlots; + + if (slots == null) + { + slots ??= new SoundEffectSlot[Constants.MAX_CONCURRENT_SOUNDS_PER_ENTITY]; + soundEffectPlayer.SoundEffectSlots = slots; + } + + lock (slots) + { + int slotCount = slots.Length; + + int emptySlot = -1; + + for (int i = 0; i < slotCount; ++i) + { + ref var slot = ref slots[i]; + + // Detect already playing sound + if (slot.SoundFile == sound) + { + var targetVolume = Mathf.Clamp(slot.Volume + changeSpeed, 0, maxVolume); + + // The volume mostly changes until it reaches the max volume which is always the exact same + // ReSharper disable once CompareOfFloatsByEqualityOperator + if (slot.Volume != targetVolume || !slot.Loop || !slot.Play) + { + slot.Volume = targetVolume; + slot.Play = true; + slot.Loop = true; + + slot.InternalAppliedState = false; + soundEffectPlayer.SoundsApplied = false; + } + + return true; + } + + if (IsSlotReadyForReUse(ref slot) && emptySlot == -1) + { + emptySlot = i; + } + } + + // Need a new slot + if (emptySlot != -1) + { + ref var slot = ref slots[emptySlot]; + + slot.SoundFile = sound; + slot.Volume = initialVolume; + slot.Loop = true; + slot.Play = true; + + slot.InternalAppliedState = false; + soundEffectPlayer.SoundsApplied = false; + return true; + } + } + + return false; + } + + /// + /// The opposite of for handling stopping sounds started + /// like that + /// + /// True if there was a sound to lower volume of or stop + public static bool PlayGraduallyTurningDownSound(this ref SoundEffectPlayer soundEffectPlayer, string sound, + float changeSpeed) + { + SoundEffectSlot[]? slots = soundEffectPlayer.SoundEffectSlots; + + if (slots == null) + return false; + + lock (slots) + { + int slotCount = slots.Length; + + for (int i = 0; i < slotCount; ++i) + { + ref var slot = ref slots[i]; + + if (!slot.Play || slot.SoundFile != sound) + continue; + + slot.Loop = false; + slot.InternalAppliedState = false; + soundEffectPlayer.SoundsApplied = false; + + var targetVolume = slot.Volume - changeSpeed; + + if (targetVolume <= 0) + { + // Immediately stop when volume reaches zero + slot.Play = false; + return true; + } + + slot.Volume = targetVolume; + + return true; + } + } + + return false; + } + + public static bool EnsureSoundIsPlaying(this ref SoundEffectPlayer soundEffectPlayer, string sound, bool loop, + float volume) + { + // See the comments in PlaySoundEffect + SoundEffectSlot[]? slots = soundEffectPlayer.SoundEffectSlots; + + if (slots == null) + { + slots ??= new SoundEffectSlot[Constants.MAX_CONCURRENT_SOUNDS_PER_ENTITY]; + soundEffectPlayer.SoundEffectSlots = slots; + } + + lock (slots) + { + int slotCount = slots.Length; + + int emptySlot = -1; + + for (int i = 0; i < slotCount; ++i) + { + ref var slot = ref slots[i]; + + // Detect already playing sound + if (slot.Play && slot.SoundFile == sound) + { + // These are explicitly set by calling code so exact values should be fine + // ReSharper disable once CompareOfFloatsByEqualityOperator + if (slot.Volume != volume || slot.Loop != loop) + { + slot.Volume = volume; + slot.Loop = loop; + slot.InternalAppliedState = false; + soundEffectPlayer.SoundsApplied = false; + } + + return true; + } + + if (IsSlotReadyForReUse(ref slot) && emptySlot == -1) + { + emptySlot = i; + } + } + + // Need a new slot + if (emptySlot != -1) + { + ref var slot = ref slots[emptySlot]; + + slot.SoundFile = sound; + slot.Volume = volume; + slot.Loop = loop; + slot.Play = true; + + slot.InternalAppliedState = false; + soundEffectPlayer.SoundsApplied = false; + return true; + } + } + + return false; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsSlotReadyForReUse(ref SoundEffectSlot slot) + { + // The Play variable is used to both control the playback and to detect when it has ended, as such a slot + // with Play false is not necessarily ready for re-use yet, so we kind of naughtily check the internal + // state to detect when the slot is truly free + return !slot.Play && slot.InternalPlayingState == 0; + + // TODO: would it be better to adjust the sound slot reuse logic when the slot is still playing? + // There's currently an error print in HandleSoundEntityStateApply that triggers if a slot is reused + // before its internal player is reset + } + } +} diff --git a/src/engine/common_components/SoundListener.cs b/src/engine/common_components/SoundListener.cs new file mode 100644 index 00000000000..90dd7a805fe --- /dev/null +++ b/src/engine/common_components/SoundListener.cs @@ -0,0 +1,16 @@ +namespace Components +{ + /// + /// Places a at this entity. Requires a to function. + /// + public struct SoundListener + { + /// + /// When set to true sound is set to come from the side of the screen relative to the + /// camera rather than using the entity's rotation. + /// + public bool UseTopDownRotation; + + public bool Disabled; + } +} diff --git a/src/engine/common_components/SpatialInstance.cs b/src/engine/common_components/SpatialInstance.cs new file mode 100644 index 00000000000..bd0d1794ac1 --- /dev/null +++ b/src/engine/common_components/SpatialInstance.cs @@ -0,0 +1,23 @@ +namespace Components +{ + using Godot; + + /// + /// Displays a single as this entity's graphics in Godot + /// + public struct SpatialInstance + { + public Spatial? GraphicalInstance; + + /// + /// Visual scale to set. Only applies when is set to true to only require + /// entities that want to scale to set this field + /// + public Vector3 VisualScale; + + /// + /// If true applies visual scale to + /// + public bool ApplyVisualScale; + } +} diff --git a/src/engine/common_components/Spawned.cs b/src/engine/common_components/Spawned.cs new file mode 100644 index 00000000000..b044ea17c56 --- /dev/null +++ b/src/engine/common_components/Spawned.cs @@ -0,0 +1,24 @@ +namespace Components +{ + /// + /// Entity that has been spawned by a spawn system and can be automatically despawned + /// + public struct Spawned + { + /// + /// If the squared distance to the player of this object is greater than this, it is despawned. + /// + public float DespawnRadiusSquared; + + /// + /// How much this entity contributes to the entity limit relative to a single node + /// + public float EntityWeight; + + /// + /// Set to true when despawning is disallowed temporarily. For permanently disallowing despawning remove this + /// component. + /// + public bool DisallowDespawning; + } +} diff --git a/src/engine/common_components/SpeciesMember.cs b/src/engine/common_components/SpeciesMember.cs new file mode 100644 index 00000000000..18cc826a330 --- /dev/null +++ b/src/engine/common_components/SpeciesMember.cs @@ -0,0 +1,27 @@ +namespace Components +{ + /// + /// General marker for species members to be able to check other members of their species + /// + public struct SpeciesMember + { + /// + /// Access to the full species data. Comparing species should be done with the ID, but this is required here + /// as entities need to know various properties about their species for various gameplay purposes. + /// + public Species Species; + + /// + /// ID of the species this is a member of. The should make sure there can't be + /// duplicate IDs. If there are then it is a world or mutation problem. Used as an optimization to quickly + /// compare species. + /// + public uint ID; + + public SpeciesMember(Species species) + { + Species = species; + ID = species.ID; + } + } +} diff --git a/src/engine/common_components/TimedLife.cs b/src/engine/common_components/TimedLife.cs new file mode 100644 index 00000000000..b1819e69775 --- /dev/null +++ b/src/engine/common_components/TimedLife.cs @@ -0,0 +1,37 @@ +namespace Components +{ + using DefaultEcs; + using Newtonsoft.Json; + + /// + /// Entities that despawn after a certain amount of time + /// + public struct TimedLife + { + /// + /// Custom callback to be triggered when the timed life is over. If this returns false then the entity won't + /// be automatically destroyed. If this callback sets then this also won't + /// be automatically destroyed. + /// + /// + /// + /// This is save ignored with the intention that any systems that will use the time over callback will + /// re-apply it after the save is loaded. + /// + /// + [JsonIgnore] + public OnTimeOver? CustomTimeOverCallback; + + public float TimeToLiveRemaining; + + /// + /// If not null then this entity is fading out and the timed despawn system will wait until this time is up + /// as well + /// + public float? FadeTimeRemaining; + + public bool OnTimeOverTriggered; + + public delegate bool OnTimeOver(Entity entity, ref TimedLife timedLife); + } +} diff --git a/src/engine/common_components/WorldPosition.cs b/src/engine/common_components/WorldPosition.cs new file mode 100644 index 00000000000..e810efaf5f5 --- /dev/null +++ b/src/engine/common_components/WorldPosition.cs @@ -0,0 +1,34 @@ +namespace Components +{ + using Godot; + + /// + /// World-space coordinates of an entity. Note a constructor must be used to get + /// initialized correctly + /// + public struct WorldPosition + { + public Vector3 Position; + public Quat Rotation; + + public WorldPosition(Vector3 position) + { + Position = position; + Rotation = Quat.Identity; + } + + public WorldPosition(Vector3 position, Quat rotation) + { + Position = position; + Rotation = rotation; + } + } + + public static class WorldPositionHelpers + { + public static Transform ToTransform(this ref WorldPosition position) + { + return new Transform(new Basis(position.Rotation), position.Position); + } + } +} diff --git a/src/engine/common_systems/AnimationControlSystem.cs b/src/engine/common_systems/AnimationControlSystem.cs new file mode 100644 index 00000000000..2a72c5e1006 --- /dev/null +++ b/src/engine/common_systems/AnimationControlSystem.cs @@ -0,0 +1,69 @@ +namespace Systems +{ + using Components; + using DefaultEcs; + using DefaultEcs.System; + using Godot; + using World = DefaultEcs.World; + + /// + /// System that handles + /// + [With(typeof(AnimationControl))] + [With(typeof(SpatialInstance))] + [RunsOnMainThread] + public sealed class AnimationControlSystem : AEntitySetSystem + { + public AnimationControlSystem(World world) : base(world, null) + { + } + + protected override void Update(float state, in Entity entity) + { + ref var animation = ref entity.Get(); + + if (animation.AnimationApplied) + return; + + ref var spatial = ref entity.Get(); + + // Wait until graphics instance is initialized + if (spatial.GraphicalInstance == null) + return; + + var player = GetPlayer(spatial.GraphicalInstance, animation.AnimationPlayerPath); + + if (player == null) + { + GD.PrintErr($"{nameof(AnimationControl)} component couldn't find animation player from node: ", + spatial.GraphicalInstance, " with relative path: ", animation.AnimationPlayerPath); + + // Set the animation as applied to not spam this error message over and over + animation.AnimationApplied = true; + return; + } + + if (animation.StopPlaying) + { + // TODO: parameter in the component to allow passing reset: false? + player.Stop(); + } + + animation.AnimationApplied = true; + } + + private AnimationPlayer? GetPlayer(Spatial spatial, string? playerPath) + { + // TODO: cache for animation players to allow fast per-update data access + // For now a cache is not implemented as this is just for stopping playing an animation once and then not + // doing anything + + // When no path provided, assume default name. This is needed as the AnimationPlayer doesn't inherit + // Spatial so we can't try to even cast that here + if (string.IsNullOrEmpty(playerPath)) + return spatial.GetNode(nameof(AnimationPlayer)); + + return spatial.GetNode(playerPath); + } + } +} diff --git a/src/engine/common_systems/AttachedEntityPositionSystem.cs b/src/engine/common_systems/AttachedEntityPositionSystem.cs new file mode 100644 index 00000000000..07252c8c9e5 --- /dev/null +++ b/src/engine/common_systems/AttachedEntityPositionSystem.cs @@ -0,0 +1,44 @@ +namespace Systems +{ + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + using World = DefaultEcs.World; + + /// + /// Handles positioning of entities attached to each other + /// + [With(typeof(AttachedToEntity))] + [With(typeof(WorldPosition))] + [RunsAfter(typeof(PhysicsUpdateAndPositionSystem))] + [RunsBefore(typeof(SpatialPositionSystem))] + public sealed class AttachedEntityPositionSystem : AEntitySetSystem + { + public AttachedEntityPositionSystem(World world, IParallelRunner runner) : base(world, runner) + { + } + + protected override void Update(float state, in Entity entity) + { + ref var attachInfo = ref entity.Get(); + + if (!attachInfo.AttachedTo.Has()) + { + // This can happen if the entity is dead now + // TODO: should this queue a clear of the data (it's not safe to remove during an update without using + // the recorder interface) + return; + } + + // TODO: optimize for attached entities where the position / parent position doesn't change each frame? + + ref var parentPosition = ref attachInfo.AttachedTo.Get(); + + ref var position = ref entity.Get(); + + position.Position = parentPosition.Position + attachInfo.RelativePosition; + position.Rotation = parentPosition.Rotation * attachInfo.RelativeRotation; + } + } +} diff --git a/src/engine/common_systems/CameraFollowSystem.cs b/src/engine/common_systems/CameraFollowSystem.cs new file mode 100644 index 00000000000..f8718517688 --- /dev/null +++ b/src/engine/common_systems/CameraFollowSystem.cs @@ -0,0 +1,85 @@ +namespace Systems +{ + using Components; + using DefaultEcs; + using DefaultEcs.System; + using Godot; + using Newtonsoft.Json; + using World = DefaultEcs.World; + + /// + /// Handles moving a camera to follow entity with component + /// + [With(typeof(CameraFollowTarget))] + [With(typeof(WorldPosition))] + public sealed class CameraFollowSystem : AEntitySetSystem + { + private bool cameraUsed; + + private bool errorPrinted; + private bool warnedAboutMissingCamera; + + public CameraFollowSystem(World world) : base(world, null) + { + } + + /// + /// Needs to be set by the game stage using this system to the camera that needs updating, otherwise this + /// system does nothing + /// + [JsonIgnore] + public IGameCamera? Camera { get; set; } + + protected override void PreUpdate(float delta) + { + base.PreUpdate(delta); + + cameraUsed = false; + } + + protected override void Update(float delta, in Entity entity) + { + ref var followTarget = ref entity.Get(); + + if (followTarget.Disabled) + return; + + if (cameraUsed) + { + if (!errorPrinted) + { + errorPrinted = true; + GD.PrintErr( + "Multiple entities have active CameraFollowTarget components, camera will follow a random one"); + } + + return; + } + + cameraUsed = true; + + if (Camera != null) + { + ref var position = ref entity.Get(); + + Camera.UpdateCameraPosition(delta, position.Position); + } + else if (!warnedAboutMissingCamera) + { + warnedAboutMissingCamera = true; + GD.PrintErr("CameraFollowSystem doesn't have camera set, can't follow an entity"); + } + } + + protected override void PostUpdate(float delta) + { + base.PostUpdate(delta); + + if (!cameraUsed) + { + // Update camera without a target + Camera?.UpdateCameraPosition(delta, null); + } + } + } +} diff --git a/src/engine/common_systems/ColourAnimationSystem.cs b/src/engine/common_systems/ColourAnimationSystem.cs new file mode 100644 index 00000000000..76842a9e55e --- /dev/null +++ b/src/engine/common_systems/ColourAnimationSystem.cs @@ -0,0 +1,62 @@ +namespace Systems +{ + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + using Godot; + using World = DefaultEcs.World; + + /// + /// Handles updating the state of based on animations triggered elsewhere + /// + [With(typeof(ColourAnimation))] + public sealed class ColourAnimationSystem : AEntitySetSystem + { + public ColourAnimationSystem(World world, IParallelRunner runner) : base(world, runner) + { + } + + protected override void Update(float delta, in Entity entity) + { + ref var colourAnimation = ref entity.Get(); + + if (!colourAnimation.Animating) + return; + + if (colourAnimation.AnimationDuration <= 0) + { + GD.PrintErr("Animation duration for ColourAnimation not set properly"); + colourAnimation.AnimationDuration = 0.001f; + } + + colourAnimation.AnimationElapsed += delta; + + if (colourAnimation.AnimationElapsed >= colourAnimation.AnimationDuration) + { + // Finished animation + + if (colourAnimation.AutoReverseAnimation) + { + // Play in reverse + colourAnimation.AutoReverseAnimation = false; + + // Swap direction + (colourAnimation.AnimationTargetColour, colourAnimation.AnimationStartColour) = ( + colourAnimation.AnimationStartColour, colourAnimation.AnimationTargetColour); + colourAnimation.AnimationElapsed -= colourAnimation.AnimationDuration; + + if (colourAnimation.AnimationElapsed < 0) + colourAnimation.AnimationElapsed = 0; + } + else + { + // No new animation to run, stop processing this entity + colourAnimation.Animating = false; + } + } + + colourAnimation.ColourApplied = false; + } + } +} diff --git a/src/engine/common_systems/CountLimitedDespawnSystem.cs b/src/engine/common_systems/CountLimitedDespawnSystem.cs new file mode 100644 index 00000000000..e6d3bc9418c --- /dev/null +++ b/src/engine/common_systems/CountLimitedDespawnSystem.cs @@ -0,0 +1,145 @@ +namespace Systems +{ + using System.Collections.Generic; + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + using Godot; + using World = DefaultEcs.World; + + /// + /// Despawns entities with when there are too many of them, starting from entities + /// the farthest from the player. + /// + [With(typeof(CountLimited))] + [With(typeof(WorldPosition))] + public sealed class CountLimitedDespawnSystem : AEntitySetSystem + { + private readonly IEntityContainer entityContainer; + + private readonly Dictionary groupData = new(); + + private int maxDespawnsPerFrame = Constants.MAX_DESPAWNS_PER_FRAME; + + private Vector3 playerPosition; + + public CountLimitedDespawnSystem(IEntityContainer entityContainer, World world, IParallelRunner runner) : + base(world, runner) + { + this.entityContainer = entityContainer; + } + + public void ReportPlayerPosition(Vector3 position) + { + playerPosition = position; + } + + protected override void Update(float delta, in Entity entity) + { + ref var countLimited = ref entity.Get(); + ref var position = ref entity.Get(); + + if (!groupData.TryGetValue(countLimited.Group, out var group)) + { + groupData[countLimited.Group] = group = new EntityGroup(); + + switch (countLimited.Group) + { + case LimitGroup.General: + break; + + case LimitGroup.Chunk: + group.Limit = Constants.FLOATING_CHUNK_MAX_COUNT; + break; + + case LimitGroup.ChunkSpawned: + group.Limit = Constants.FLOATING_CHUNK_MAX_COUNT; + break; + + default: + GD.PrintErr("Unknown entity limit for group: ", countLimited.Group); + break; + } + } + + ++group.Count; + + var distance = position.Position.DistanceSquaredTo(playerPosition); + + if (!group.HasFarthestEntity) + { + group.HasFarthestEntity = true; + group.FarthestDistance = distance; + group.FarthestEntity = entity; + return; + } + + if (distance > group.FarthestDistance) + { + group.FarthestDistance = distance; + group.FarthestEntity = entity; + } + } + + protected override void PostUpdate(float state) + { + base.PostUpdate(state); + + // Limit despawns per frame + int despawnsLeft = maxDespawnsPerFrame; + + // Process all the groups and despawn the farthest entity from each group where the group size is over its + // limit + foreach (var pair in groupData) + { + var group = pair.Value; + + if (group.Count > group.Limit && group.HasFarthestEntity && despawnsLeft > 0) + { + if (group.Limit < 1) + { + GD.PrintErr("Badly configured entity group limit"); + } + else + { + // TODO: allow things like chunks to pop out their compounds when they are removed + // if (group.FarthestEntity.Has()) + // { + // + // } + + if (!entityContainer.DestroyEntity(group.FarthestEntity)) + { + GD.PrintErr("Count limited entity despawn failed"); + } + + --despawnsLeft; + } + } + + // Clear the data to prepare for next frame + group.Count = 0; + group.FarthestDistance = float.MaxValue; + group.HasFarthestEntity = false; + } + } + + private class EntityGroup + { + // For now only one entity of each group can be despawned per frame, this is probably good enough. This + // design is done to not need a dynamically allocated list here + public Entity FarthestEntity; + public float FarthestDistance = float.MaxValue; + + public int Count; + public int Limit = 100; + + /// + /// True when has valid data, this is used instead of nullable field type + /// to avoid boxing of the data + /// + public bool HasFarthestEntity; + } + } +} diff --git a/src/engine/common_systems/DamageCooldownSystem.cs b/src/engine/common_systems/DamageCooldownSystem.cs new file mode 100644 index 00000000000..4ef8b244ff3 --- /dev/null +++ b/src/engine/common_systems/DamageCooldownSystem.cs @@ -0,0 +1,31 @@ +namespace Systems +{ + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + + /// + /// Processes cooldowns for + /// + [With(typeof(DamageCooldown))] + public sealed class DamageCooldownSystem : AEntitySetSystem + { + public DamageCooldownSystem(World world, IParallelRunner runner) : base(world, runner) + { + } + + protected override void Update(float delta, in Entity entity) + { + ref var cooldown = ref entity.Get(); + + if (cooldown.CooldownRemaining <= 0) + return; + + cooldown.CooldownRemaining -= delta; + + if (cooldown.CooldownRemaining < 0) + cooldown.CooldownRemaining = 0; + } + } +} diff --git a/src/engine/common_systems/DamageOnTouchSystem.cs b/src/engine/common_systems/DamageOnTouchSystem.cs new file mode 100644 index 00000000000..3544ba79f6e --- /dev/null +++ b/src/engine/common_systems/DamageOnTouchSystem.cs @@ -0,0 +1,112 @@ +namespace Systems +{ + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + + /// + /// Handles component setup and dealing the damage + /// + [With(typeof(DamageOnTouch))] + [With(typeof(CollisionManagement))] + public sealed class DamageOnTouchSystem : AEntitySetSystem + { + private readonly WorldSimulation worldSimulation; + + public DamageOnTouchSystem(WorldSimulation worldSimulation, World world, IParallelRunner runner) : base(world, + runner) + { + this.worldSimulation = worldSimulation; + } + + protected override void Update(float delta, in Entity entity) + { + ref var damageTouch = ref entity.Get(); + + if (damageTouch.StartedDestroy) + return; + + ref var collisionManagement = ref entity.Get(); + + // Entity setup + if (!damageTouch.RegisteredWithCollisions) + { + collisionManagement.StartCollisionRecording(Constants.MAX_SIMULTANEOUS_COLLISIONS_SMALL); + + damageTouch.RegisteredWithCollisions = true; + } + + // Handle any current collisions + bool collided = false; + + var count = collisionManagement.GetActiveCollisions(out var collisions); + for (int i = 0; i < count; ++i) + { + ref var collision = ref collisions![i]; + + // Skip collisions with things that can't be damaged + if (!collision.SecondEntity.Has()) + continue; + + ref var health = ref collision.SecondEntity.Get(); + + if (DealDamage(collision.SecondEntity, ref health, ref damageTouch, delta)) + { + collided = true; + } + } + + if (collided && damageTouch.DestroyOnTouch) + { + // Destroy this entity + damageTouch.StartedDestroy = true; + + ref var physics = ref entity.Get(); + + // Disable *further* collisions (any active collisions will stay) + physics.SetCollisionDisableState(true); + + if (damageTouch.UsesMicrobialDissolveEffect) + { + // We assume that damage on touch is always done by chunks + entity.StartDissolveAnimation(worldSimulation, true, true); + } + else + { + worldSimulation.DestroyEntity(entity); + } + } + } + + private bool DealDamage(in Entity entity, ref Health health, ref DamageOnTouch damageTouch, float delta) + { + if (damageTouch.DestroyOnTouch) + { + return HandlePotentialMicrobeDamage(ref health, entity, damageTouch.DamageAmount, + damageTouch.DamageType); + } + + return HandlePotentialMicrobeDamage(ref health, entity, damageTouch.DamageAmount * delta, + damageTouch.DamageType); + } + + private bool HandlePotentialMicrobeDamage(ref Health health, in Entity entity, float damageValue, + string damageType) + { + if (entity.Has()) + { + // TODO: disable dealing damage to a pilus + // return false + + health.DealMicrobeDamage(ref entity.Get(), damageValue, damageType); + } + else + { + health.DealDamage(damageValue, damageType); + } + + return true; + } + } +} diff --git a/src/engine/common_systems/DisallowPlayerBodySleepSystem.cs b/src/engine/common_systems/DisallowPlayerBodySleepSystem.cs new file mode 100644 index 00000000000..29a0acd82fc --- /dev/null +++ b/src/engine/common_systems/DisallowPlayerBodySleepSystem.cs @@ -0,0 +1,43 @@ +namespace Systems +{ + using System; + using Components; + using DefaultEcs; + using DefaultEcs.System; + + /// + /// Makes sure the player's physics body is never allowed to sleep. This makes sure the microbe stage doesn't get + /// stuck as microbe movement cannot be applied if the physics world has only sleeping bodies (as the body + /// control apply operation will be skipped). + /// + [With(typeof(PlayerMarker))] + [With(typeof(Physics))] + public sealed class DisallowPlayerBodySleepSystem : AEntitySetSystem + { + private readonly PhysicalWorld physicalWorld; + private WeakReference? appliedSleepDisableTo; + + public DisallowPlayerBodySleepSystem(PhysicalWorld physicalWorld, World world) : base(world, null) + { + this.physicalWorld = physicalWorld; + } + + protected override void Update(float delta, in Entity entity) + { + ref var physics = ref entity.Get(); + + if (physics.BodyDisabled || physics.Body == null) + return; + + if (appliedSleepDisableTo != null && appliedSleepDisableTo.TryGetTarget(out var appliedTo) && + ReferenceEquals(appliedTo, physics.Body)) + { + return; + } + + // Apply no sleep to the new body + physicalWorld.SetBodyAllowSleep(physics.Body, false); + appliedSleepDisableTo = new WeakReference(physics.Body); + } + } +} diff --git a/src/engine/common_systems/EntityMaterialFetchSystem.cs b/src/engine/common_systems/EntityMaterialFetchSystem.cs new file mode 100644 index 00000000000..387fa552fd0 --- /dev/null +++ b/src/engine/common_systems/EntityMaterialFetchSystem.cs @@ -0,0 +1,65 @@ +namespace Systems +{ + using System; + using Components; + using DefaultEcs; + using DefaultEcs.System; + using Godot; + using World = DefaultEcs.World; + + /// + /// Fetches the materials for that have auto fetch on + /// + [With(typeof(EntityMaterial))] + [RunsOnMainThread] + public sealed class EntityMaterialFetchSystem : AEntitySetSystem + { + // TODO: determine if it is thread safe to fetch Godot materials + public EntityMaterialFetchSystem(World world) : base(world, null) + { + } + + protected override void Update(float delta, in Entity entity) + { + ref var materialComponent = ref entity.Get(); + + if (materialComponent.MaterialFetchPerformed || materialComponent.Materials != null) + return; + + if (materialComponent.AutoRetrieveFromSpatial) + { + try + { + ref var spatial = ref entity.Get(); + + // Wait until spatial gets an instance + if (spatial.GraphicalInstance == null) + return; + + if (string.IsNullOrEmpty(materialComponent.AutoRetrieveModelPath)) + { + materialComponent.Materials = new[] { spatial.GraphicalInstance.GetMaterial() }; + } + else + { + using var nodePath = new NodePath(materialComponent.AutoRetrieveModelPath); + materialComponent.Materials = new[] { spatial.GraphicalInstance.GetMaterial(nodePath) }; + } + + if (materialComponent.Materials is not { Length: > 0 } || materialComponent.Materials[0] == null) + { + throw new NullReferenceException( + "Expected material not set, this component doesn't wait for material to be set later"); + } + } + catch (Exception e) + { + GD.PrintErr("Entity with material auto retrieve from spatial cannot access " + + "spatial component's material: ", e); + } + } + + materialComponent.MaterialFetchPerformed = true; + } + } +} diff --git a/src/engine/common_systems/FadeOutActionSystem.cs b/src/engine/common_systems/FadeOutActionSystem.cs new file mode 100644 index 00000000000..a4da11671c5 --- /dev/null +++ b/src/engine/common_systems/FadeOutActionSystem.cs @@ -0,0 +1,152 @@ +namespace Systems +{ + using System; + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + using Godot; + using World = DefaultEcs.World; + + /// + /// Handles fading out animations on entities + /// + [With(typeof(FadeOutActions))] + [With(typeof(TimedLife))] + public sealed class FadeOutActionSystem : AEntitySetSystem + { + private readonly IWorldSimulation worldSimulation; + + public FadeOutActionSystem(IWorldSimulation worldSimulation, World world, IParallelRunner runner) : + base(world, runner) + { + this.worldSimulation = worldSimulation; + } + + protected override void Update(float delta, in Entity entity) + { + ref var actions = ref entity.Get(); + + if (actions.CallbackRegistered) + return; + + actions.CallbackRegistered = true; + + ref var timed = ref entity.Get(); + + // Register the callback which we use to trigger the actions for + timed.CustomTimeOverCallback = PerformTimeOverActions; + } + + private bool PerformTimeOverActions(Entity entity, ref TimedLife timedLife) + { + try + { + ref var actions = ref entity.Get(); + + if (actions.FadeTime <= 0) + { + // For now this indicates bad data but in the future we might get some more custom "time over" + // actions + GD.PrintErr("Custom fade out actions fade time is zero"); + return true; + } + + timedLife.FadeTimeRemaining = actions.FadeTime; + + if (actions.DisableCollisions) + PerformPhysicsOperations(entity, actions.DisableCollisions); + + if (actions.RemoveVelocity || actions.RemoveAngularVelocity) + { + PerformManualPhysicsControlOperations(entity, actions.RemoveVelocity, + actions.RemoveAngularVelocity); + } + + if (actions.DisableParticles) + DisableParticleEmission(entity); + + if (actions.UsesMicrobialDissolveEffect) + { + entity.StartDissolveAnimation(worldSimulation, true, false); + } + + if (actions.VentCompounds) + { + // TODO: implement this + GD.PrintErr("TODO: implement vent compounds on fade"); + throw new NotImplementedException(); + } + + // Fade started, don't destroy yet + return false; + } + catch (Exception e) + { + GD.PrintErr("Failed to perform custom fade out actions on timer over: ", e); + return true; + } + } + + private void PerformPhysicsOperations(Entity entity, bool disableCollisions) + { + try + { + ref var physics = ref entity.Get(); + + physics.SetCollisionDisableState(disableCollisions); + } + catch (Exception e) + { + GD.PrintErr( + $"Cannot apply all fade out actions due to missing {nameof(Physics)} " + + "component on entity: ", e); + } + } + + private void PerformManualPhysicsControlOperations(Entity entity, bool removeVelocity, + bool removeAngularVelocity) + { + try + { + ref var physicsControl = ref entity.Get(); + + physicsControl.RemoveVelocity = removeVelocity; + physicsControl.RemoveAngularVelocity = removeAngularVelocity; + + physicsControl.PhysicsApplied = false; + } + catch (Exception e) + { + GD.PrintErr( + $"Cannot apply all fade out actions due to missing {nameof(ManualPhysicsControl)} " + + "component on entity: ", e); + } + } + + private void DisableParticleEmission(Entity entity) + { + try + { + ref var spatial = ref entity.Get(); + + var particles = spatial.GraphicalInstance as Particles; + + if (particles == null) + throw new NullReferenceException("Graphical instance casted as particles is null"); + + particles.Emitting = false; + + // TODO: do we need a feature to automatically read the particle lifetime here and then adjust the + // fade out time accordingly? + // particleFadeTimer = particles.Lifetime; + } + catch (Exception e) + { + GD.PrintErr( + $"Cannot apply all fade out actions due to missing {nameof(SpatialInstance)} " + + "or the visuals not being particles: ", e); + } + } + } +} diff --git a/src/engine/common_systems/PathBasedSceneLoader.cs b/src/engine/common_systems/PathBasedSceneLoader.cs new file mode 100644 index 00000000000..0dd393e410e --- /dev/null +++ b/src/engine/common_systems/PathBasedSceneLoader.cs @@ -0,0 +1,113 @@ +namespace Systems +{ + using System; + using System.Collections.Generic; + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + using Godot; + using World = DefaultEcs.World; + + /// + /// Loader for into a + /// + [With(typeof(PathLoadedSceneVisuals))] + [With(typeof(SpatialInstance))] + public sealed class PathBasedSceneLoader : AEntitySetSystem + { + /// + /// This stores all the scenes seen in this world. This is done with the assumption that any once used scene + /// will get used again in this world at some point. + /// + private readonly Dictionary usedScenes = new(); + + private PackedScene? errorScene; + + public PathBasedSceneLoader(World world, IParallelRunner runner) : base(world, runner) + { + // TODO: will we be able to at some point load Godot scenes in parallel without issues? + if (runner.DegreeOfParallelism > 1) + throw new ArgumentException("This system cannot be ran in parallel"); + } + + public override void Dispose() + { + Dispose(true); + + // This doesn't have a destructor + // GC.SuppressFinalize(this); + } + + protected override void Update(float delta, in Entity entity) + { + ref var sceneVisuals = ref entity.Get(); + + // Skip update if nothing to do + if (sceneVisuals.ScenePath == sceneVisuals.LastLoadedScene) + return; + + ref var spatial = ref entity.Get(); + + sceneVisuals.LastLoadedScene = sceneVisuals.ScenePath; + + if (sceneVisuals.LastLoadedScene == null) + { + // Clearing visuals wanted + spatial.GraphicalInstance = null; + + // The resource will be deleted by SpatialAttachSystem next time it runs as the node instance reference + // is gone + return; + } + + if (!usedScenes.TryGetValue(sceneVisuals.LastLoadedScene, out var scene)) + { + scene = LoadScene(sceneVisuals.LastLoadedScene); + + if (scene == null) + { + // Try to fallback to an error scene + // If we get different quality levels, they are very unlikely to matter for an error so this + // situation doesn't need to be complicated if that kind of thing is added + errorScene ??= LoadScene(SimulationParameters.Instance.GetErrorVisual().NormalQualityPath); + scene = errorScene; + } + + usedScenes.Add(sceneVisuals.LastLoadedScene, scene); + } + + if (scene == null) + { + // Even error scene failed + return; + } + + // TODO: could add a debug-only leak detector system that checks no leaks persist + // Note that the above TODO is also in PredefinedVisualLoaderSystem + + try + { + spatial.GraphicalInstance = scene.Instance(); + } + catch (Exception e) + { + GD.PrintErr( + $"Godot scene ({sceneVisuals.LastLoadedScene}) doesn't have a Spatial root node visual: ", e); + } + } + + private PackedScene? LoadScene(string path) + { + return GD.Load(path); + } + + private void Dispose(bool disposing) + { + if (disposing) + { + usedScenes.Clear(); + } + } + } +} diff --git a/src/engine/common_systems/PhysicsBodyControlSystem.cs b/src/engine/common_systems/PhysicsBodyControlSystem.cs new file mode 100644 index 00000000000..74354f6dd0e --- /dev/null +++ b/src/engine/common_systems/PhysicsBodyControlSystem.cs @@ -0,0 +1,79 @@ +namespace Systems +{ + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + using Godot; + using World = DefaultEcs.World; + + /// + /// Applies external (impulse, direct velocity) control to physics bodies + /// + [With(typeof(Physics))] + [With(typeof(ManualPhysicsControl))] + public sealed class PhysicsBodyControlSystem : AEntitySetSystem + { + private readonly PhysicalWorld physicalWorld; + + public PhysicsBodyControlSystem(PhysicalWorld physicalWorld, World world, IParallelRunner runner) : + base(world, runner) + { + this.physicalWorld = physicalWorld; + } + + protected override void Update(float delta, in Entity entity) + { + ref var physics = ref entity.Get(); + + var body = physics.Body; + if (physics.BodyDisabled || body == null) + return; + + ref var control = ref entity.Get(); + + if (control.PhysicsApplied && physics.VelocitiesApplied) + return; + + if (!physics.VelocitiesApplied) + { + physicalWorld.SetBodyVelocity(body, physics.Velocity, physics.AngularVelocity); + physics.VelocitiesApplied = true; + } + + if (!control.PhysicsApplied) + { + if (control.RemoveVelocity && control.RemoveAngularVelocity) + { + control.RemoveVelocity = false; + control.RemoveAngularVelocity = false; + physicalWorld.SetBodyVelocity(body, Vector3.Zero, Vector3.Zero); + } + else if (control.RemoveVelocity) + { + control.RemoveVelocity = false; + physicalWorld.SetOnlyBodyVelocity(body, Vector3.Zero); + } + else if (control.RemoveAngularVelocity) + { + control.RemoveAngularVelocity = false; + physicalWorld.SetOnlyBodyAngularVelocity(body, Vector3.Zero); + } + + if (control.ImpulseToGive != Vector3.Zero) + { + control.ImpulseToGive = Vector3.Zero; + physicalWorld.GiveImpulse(body, control.ImpulseToGive); + } + + if (control.AngularImpulseToGive != Vector3.Zero) + { + control.AngularImpulseToGive = Vector3.Zero; + physicalWorld.GiveAngularImpulse(body, control.AngularImpulseToGive); + } + + control.PhysicsApplied = true; + } + } + } +} diff --git a/src/engine/common_systems/PhysicsBodyCreationSystem.cs b/src/engine/common_systems/PhysicsBodyCreationSystem.cs new file mode 100644 index 00000000000..bdbea057ff0 --- /dev/null +++ b/src/engine/common_systems/PhysicsBodyCreationSystem.cs @@ -0,0 +1,148 @@ +namespace Systems +{ + using System.Collections.Generic; + using System.Runtime.CompilerServices; + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + using Godot; + using World = DefaultEcs.World; + + /// + /// Creates physics bodies for entities that have a shape defined for them. Also handles deleting unused bodies. + /// + [With(typeof(Physics))] + [With(typeof(PhysicsShapeHolder))] + [With(typeof(WorldPosition))] + public sealed class PhysicsBodyCreationSystem : AEntitySetSystem + { + private readonly IWorldSimulationWithPhysics worldSimulationWithPhysics; + private readonly OnBodyDeleted? deleteCallback; + + private readonly List createdBodies = new(); + + public PhysicsBodyCreationSystem(IWorldSimulationWithPhysics worldSimulationWithPhysics, + OnBodyDeleted? deleteCallback, World world, IParallelRunner runner) : base(world, runner) + { + this.worldSimulationWithPhysics = worldSimulationWithPhysics; + this.deleteCallback = deleteCallback; + } + + public delegate void OnBodyDeleted(NativePhysicsBody body); + + protected override void PreUpdate(float delta) + { + // TODO: would it be better to have the world take care of destroying physics bodies when the entity + // destruction is triggered? + foreach (var createdBody in createdBodies) + { + createdBody.Marked = false; + } + } + + protected override void Update(float delta, in Entity entity) + { + ref var physics = ref entity.Get(); + + var body = physics.Body; + + // Mark bodies in use + if (body != null) + body.Marked = true; + + if (physics.BodyDisabled) + return; + + ref var shapeHolder = ref entity.Get(); + + // Don't need to do anything if body is already created and it is not requested to be recreated + if (body != null && !shapeHolder.UpdateBodyShapeIfCreated) + return; + + // Skip if not ready to create the body yet + if (shapeHolder.Shape == null) + return; + + if (body != null && shapeHolder.UpdateBodyShapeIfCreated) + { + // Change the shape of the body + var physicalWorld = worldSimulationWithPhysics.PhysicalWorld; + physicalWorld.ChangeBodyShape(body, shapeHolder.Shape); + shapeHolder.UpdateBodyShapeIfCreated = false; + + // TODO: apply changing shapeHolder.BodyIsStatic variable if that needs to ever work (probably + // should just fallback to the normal creation logic, as switching from static to moving doesn't need + // to preserve velocity) + + return; + } + + // Create a new body + + ref var position = ref entity.Get(); + + if (shapeHolder.BodyIsStatic) + { + body = worldSimulationWithPhysics.CreateStaticBody(shapeHolder.Shape, position.Position, + position.Rotation); + } + else + { + if (physics.AxisLock != Physics.AxisLockType.None) + { + body = worldSimulationWithPhysics.CreateMovingBodyWithAxisLock(shapeHolder.Shape, position.Position, + position.Rotation, Vector3.Up, (physics.AxisLock & Physics.AxisLockType.AlsoLockRotation) != 0); + } + else + { + body = worldSimulationWithPhysics.CreateMovingBody(shapeHolder.Shape, position.Position, + position.Rotation); + } + + var physicalWorld = worldSimulationWithPhysics.PhysicalWorld; + + // Apply initial velocity + physicalWorld.SetBodyVelocity(body, physics.Velocity, physics.AngularVelocity); + + if (physics.LinearDamping != null) + { + physicalWorld.SetDamping(body, physics.LinearDamping.Value, physics.AngularDamping); + } + } + + // Store the entity in the body to make physics callbacks reported back from the physics system tell us + // the entities involved in them + body.SetEntityReference(entity); + + body.Marked = true; + createdBodies.Add(body); + + physics.VelocitiesApplied = true; + physics.DampingApplied = true; + + physics.Body = body; + shapeHolder.UpdateBodyShapeIfCreated = false; + } + + protected override void PostUpdate(float delta) + { + createdBodies.RemoveAll(DestroyBodyIfNotMarked); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool DestroyBodyIfNotMarked(NativePhysicsBody body) + { + if (body.Marked) + return false; + + // Notify external things about the deleted body + deleteCallback?.Invoke(body); + + // TODO: ensure this works fine if the body is currently in disabled state + worldSimulationWithPhysics.DestroyBody(body); + + return true; + } + } +} diff --git a/src/engine/common_systems/PhysicsBodyDisablingSystem.cs b/src/engine/common_systems/PhysicsBodyDisablingSystem.cs new file mode 100644 index 00000000000..e7017909fcb --- /dev/null +++ b/src/engine/common_systems/PhysicsBodyDisablingSystem.cs @@ -0,0 +1,96 @@ +namespace Systems +{ + using System; + using System.Collections.Generic; + using Components; + using DefaultEcs.System; + using Godot; + using World = DefaultEcs.World; + + /// + /// Handles disabling and enabling the physics body for an entity (disabled bodies don't exist in the physical + /// world at all) + /// + public sealed class PhysicsBodyDisablingSystem : AComponentSystem + { + private readonly PhysicalWorld physicalWorld; + + private readonly HashSet disabledBodies = new(); + + public PhysicsBodyDisablingSystem(PhysicalWorld physicalWorld, World world) : base(world) + { + this.physicalWorld = physicalWorld; + } + + // TODO: figure out where this would need to be called + /// + /// Needs to be called when a body is deleted so that state tracking for body disabling can remove it + /// + /// The deleted body + public void OnBodyDeleted(NativePhysicsBody body) + { + // TODO: if needed for deletion this could reattach the body here? + + disabledBodies.Remove(body); + } + + public override void Dispose() + { + Dispose(true); + base.Dispose(); + } + + protected override void Update(float state, Span components) + { + foreach (ref Physics physics in components) + { + // Skip objects that are up to date + if (physics.InternalDisableState == physics.BodyDisabled) + continue; + + var body = physics.Body; + if (body == null) + continue; + + if (physics.InternalDisableState) + { + // Need to restore body + physics.InternalDisableState = false; + + // In case the body was recreated, then we need to skip this (as the body instance was not removed + // from the world by us) + if (disabledBodies.Remove(body)) + { + physicalWorld.AddBody(body); + } + } + else + { + // Disable the body + physics.InternalDisableState = true; + + if (disabledBodies.Add(body)) + { + physicalWorld.DetachBody(body); + } + else + { + GD.PrintErr("Body that was to be disabled was already disabled somehow"); + } + } + } + } + + private void Dispose(bool disposing) + { + if (disposing) + { + // TODO: would this be needed? + foreach (var disabledBody in disabledBodies) + { + physicalWorld.DestroyBody(disabledBody); + } + } + } + } +} diff --git a/src/engine/common_systems/PhysicsCollisionManagementSystem.cs b/src/engine/common_systems/PhysicsCollisionManagementSystem.cs new file mode 100644 index 00000000000..d0bc9c13740 --- /dev/null +++ b/src/engine/common_systems/PhysicsCollisionManagementSystem.cs @@ -0,0 +1,167 @@ +namespace Systems +{ + using System; + using System.Collections.Generic; + using System.Runtime.CompilerServices; + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + using Godot; + using World = DefaultEcs.World; + + [With(typeof(Physics))] + [With(typeof(CollisionManagement))] + public sealed class PhysicsCollisionManagementSystem : AEntitySetSystem + { + private readonly PhysicalWorld physicalWorld; + + /// + /// Used for temporary storage during an update + /// + private readonly List resolvedBodyReferences = new(); + + public PhysicsCollisionManagementSystem(PhysicalWorld physicalWorld, World world, IParallelRunner runner) : + base(world, runner) + { + this.physicalWorld = physicalWorld; + } + + protected override void Update(float delta, in Entity entity) + { + ref var physics = ref entity.Get(); + ref var collisionManagement = ref entity.Get(); + + if (collisionManagement.StateApplied) + return; + + var physicsBody = physics.Body; + + if (physicsBody == null) + { + // Body not initialized yet + return; + } + + collisionManagement.StateApplied = true; + + // All collision disable is now in Physics directly and applied by PhysicsUpdateAndPositionSystem + + // Collision disable against specific bodies + try + { + ref var ignoreCollisions = ref collisionManagement.IgnoredCollisionsWith; + if (ignoreCollisions == null) + { + if (collisionManagement.CollisionIgnoresUsed) + { + collisionManagement.CollisionIgnoresUsed = false; + physicalWorld.BodyClearCollisionsIgnores(physicsBody); + } + } + else if (ignoreCollisions.Count > 0) + { + collisionManagement.CollisionIgnoresUsed = true; + + if (ignoreCollisions.Count < 2) + { + // When ignoring just one collision use the single body API as that doesn't need to allocate + // any lists + var ignoreWith = GetPhysicsForEntity(ignoreCollisions[0], ref collisionManagement); + if (ignoreWith != null) + physicalWorld.BodySetCollisionIgnores(physicsBody, ignoreWith); + } + else + { + foreach (var ignoredEntity in ignoreCollisions) + { + var ignoreWith = GetPhysicsForEntity(ignoredEntity, ref collisionManagement); + + if (ignoreWith != null) + resolvedBodyReferences.Add(ignoreWith); + } + + physicalWorld.BodySetCollisionIgnores(physicsBody, resolvedBodyReferences); + + resolvedBodyReferences.Clear(); + } + } + } + catch (Exception e) + { + GD.PrintErr("Failed to apply body collision ignores: ", e); + } + + if (collisionManagement.RecordActiveCollisions > 0) + { + if (collisionManagement.ActiveCollisions == null || collisionManagement.ActiveCollisions.Length != + collisionManagement.RecordActiveCollisions) + { + // Start recording collisions + collisionManagement.ActiveCollisions = physicalWorld.BodyStartCollisionRecording(physicsBody, + collisionManagement.RecordActiveCollisions, out collisionManagement.ActiveCollisionCountPtr); + } + } + else if (collisionManagement.ActiveCollisions != null) + { + // Stop recording collisions + collisionManagement.ActiveCollisions = null; + collisionManagement.ActiveCollisionCountPtr = IntPtr.Zero; + + physicalWorld.BodyStopCollisionRecording(physicsBody); + } + + bool wantedFilterState = collisionManagement.CollisionFilter != null; + + if (wantedFilterState != collisionManagement.CollisionFilterCallbackRegistered) + { + if (wantedFilterState) + { + // TODO: can we somehow ensure that if the filter is set to null then StateApplied is set to false? + // Because otherwise we might get delegate data corruption when called from the native side? + + physicalWorld.BodyAddCollisionFilter(physicsBody, collisionManagement.CollisionFilter!); + + collisionManagement.CollisionFilterCallbackRegistered = true; + } + else + { + physicalWorld.BodyDisableCollisionFilter(physicsBody); + collisionManagement.CollisionFilterCallbackRegistered = false; + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private NativePhysicsBody? GetPhysicsForEntity(Entity entity, ref CollisionManagement management) + { + NativePhysicsBody? body; + + try + { + ref var physics = ref entity.Get(); + body = physics.Body; + } + catch (Exception e) + { + GD.PrintErr("Collision management refers to another entity that doesn't have the physics component: ", + e); + return null; + } + + // In case the body we don't want to collide with is not ready yet, we return null here to skip it, but + // make sure we will try again next update until we get it + + if (body == null) + { + management.StateApplied = false; + + // TODO: could show an error after some time if still failing? + + return null; + } + + return body; + } + } +} diff --git a/src/engine/common_systems/PhysicsUpdateAndPositionSystem.cs b/src/engine/common_systems/PhysicsUpdateAndPositionSystem.cs new file mode 100644 index 00000000000..15ea83330f5 --- /dev/null +++ b/src/engine/common_systems/PhysicsUpdateAndPositionSystem.cs @@ -0,0 +1,98 @@ +namespace Systems +{ + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + using Godot; + using World = DefaultEcs.World; + + /// + /// Reads the physics state into position and also applies a few physics component state things + /// + [With(typeof(Physics))] + [With(typeof(WorldPosition))] + [RunsBefore(typeof(SpatialPositionSystem))] + public sealed class PhysicsUpdateAndPositionSystem : AEntitySetSystem + { + private readonly PhysicalWorld physicalWorld; + + public PhysicsUpdateAndPositionSystem(PhysicalWorld physicalWorld, World world, IParallelRunner runner) : base( + world, runner) + { + this.physicalWorld = physicalWorld; + } + + /// + /// If true Y-axis fixed bodies are ensured they don't get too far away from Y=0. Should be unnecessary now + /// with + /// + public bool EnforceYPosition { get; set; } + + protected override void Update(float delta, in Entity entity) + { + ref var physics = ref entity.Get(); + + var body = physics.Body; + if (physics.BodyDisabled || body == null) + return; + + ref var position = ref entity.Get(); + + // TODO: implement this operation + // if (physics.TeleportBodyPosition || physics.TeleportBodyRotationAlso) + // { + // if (physics.TeleportBodyRotationAlso) + // { + // } + // else + // { + // physics.BodyCreatedInWorld!.SetBodyPosition(body, position.Position); + // } + // } + + (position.Position, position.Rotation) = physicalWorld.ReadBodyPosition(body); + + if (physics.TrackVelocity) + { + (physics.Velocity, physics.AngularVelocity) = physicalWorld.ReadBodyVelocity(body); + } + + if (EnforceYPosition && (physics.AxisLock & Physics.AxisLockType.YAxis) != 0) + { + // Apply fixing to Y-position if drifted too far + var driftAmount = Mathf.Abs(position.Position.y); + + if (driftAmount > Constants.PHYSICS_ALLOWED_Y_AXIS_DRIFT) + { + physicalWorld.FixBodyYCoordinateToZero(body); + } + } + + // Apply updated damping values (physics body creation applies the initial value) + if (!physics.DampingApplied) + { + physics.DampingApplied = true; + + if (physics.LinearDamping != null) + { + physicalWorld.SetDamping(body, physics.LinearDamping.Value, physics.AngularDamping); + } + } + + if (physics.DisableCollisionState != Physics.CollisionState.DoNotChange) + { + // Because the struct default data is 0 (false) we need to use a reversed value for the flag here + bool wantedState = physics.DisableCollisionState == Physics.CollisionState.DisableCollisions; + + if (wantedState != physics.InternalDisableCollisionState) + { + physics.InternalDisableCollisionState = wantedState; + + // And then flip it again in this call + physicalWorld.SetBodyCollisionsEnabledState(body, !wantedState); + } + } + } + } +} diff --git a/src/engine/common_systems/PredefinedVisualLoaderSystem.cs b/src/engine/common_systems/PredefinedVisualLoaderSystem.cs new file mode 100644 index 00000000000..21f5c01d84c --- /dev/null +++ b/src/engine/common_systems/PredefinedVisualLoaderSystem.cs @@ -0,0 +1,111 @@ +namespace Systems +{ + using System; + using System.Collections.Generic; + using Components; + using DefaultEcs; + using DefaultEcs.System; + using Godot; + using World = DefaultEcs.World; + + [With(typeof(PredefinedVisuals))] + [With(typeof(SpatialInstance))] + [RunsOnMainThread] + public sealed class PredefinedVisualLoaderSystem : AEntitySetSystem + { + /// + /// This stores all the scenes seen in this world. This is done with the assumption that any once used scene + /// will get used again in this world at some point. + /// + private readonly Dictionary usedScenes = new(); + + private PackedScene? errorScene; + + // External resource that should not be disposed +#pragma warning disable CA2213 + private SimulationParameters simulationParameters = null!; +#pragma warning restore CA2213 + + public PredefinedVisualLoaderSystem(World world) : base(world, null) + { + // TODO: will we be able to at some point load Godot scenes in parallel without issues? + // Also a proper resource manager would basically remove the need for that + } + + // TODO: this will need a callback for when graphics visual level is updated and this needs to redo all of the + // loaded graphics (if we add a quality level graphics option) + + public override void Dispose() + { + Dispose(true); + + // This doesn't have a destructor + // GC.SuppressFinalize(this); + } + + protected override void PreUpdate(float state) + { + simulationParameters = SimulationParameters.Instance; + } + + protected override void Update(float delta, in Entity entity) + { + ref var visuals = ref entity.Get(); + + // Skip update if nothing to do + if (visuals.VisualIdentifier == visuals.LoadedInstance) + return; + + ref var spatial = ref entity.Get(); + + visuals.LoadedInstance = visuals.VisualIdentifier; + + if (!usedScenes.TryGetValue(visuals.VisualIdentifier, out var scene)) + { + scene = LoadVisual(simulationParameters.GetVisualResource(visuals.LoadedInstance)); + + if (scene == null) + { + // Try to fallback to an error scene + errorScene ??= LoadVisual(simulationParameters.GetErrorVisual()); + scene = errorScene; + } + + usedScenes.Add(visuals.VisualIdentifier, scene); + } + + if (scene == null) + { + // Even error scene failed + return; + } + + // SpatialAttachSystem will handle deleting the graphics instance if not used + + // TODO: could add a debug-only leak detector system that checks no leaks persist + + try + { + spatial.GraphicalInstance = scene.Instance(); + } + catch (Exception e) + { + GD.PrintErr("Predefined visual is not convertible to Spatial: ", e); + } + } + + private PackedScene? LoadVisual(VisualResourceData visualResourceData) + { + // TODO: visual quality (/ LOD level?) + return GD.Load(visualResourceData.NormalQualityPath); + } + + private void Dispose(bool disposing) + { + if (disposing) + { + usedScenes.Clear(); + } + } + } +} diff --git a/src/engine/common_systems/RenderOrderSystem.cs b/src/engine/common_systems/RenderOrderSystem.cs new file mode 100644 index 00000000000..e6fdd3a8133 --- /dev/null +++ b/src/engine/common_systems/RenderOrderSystem.cs @@ -0,0 +1,40 @@ +namespace Systems +{ + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + + /// + /// Applies + /// + [With(typeof(RenderOrderOverride))] + [With(typeof(EntityMaterial))] + public sealed class RenderOrderSystem : AEntitySetSystem + { + public RenderOrderSystem(World world, IParallelRunner runner) : base(world, runner) + { + } + + protected override void Update(float delta, in Entity entity) + { + ref var renderOrder = ref entity.Get(); + + if (renderOrder.RenderPriorityApplied) + return; + + ref var material = ref entity.Get(); + + // Wait until material becomes available + if (material.Materials == null) + return; + + foreach (var shaderMaterial in material.Materials) + { + shaderMaterial.RenderPriority = renderOrder.RenderPriority; + } + + renderOrder.RenderPriorityApplied = true; + } + } +} diff --git a/src/engine/common_systems/SoundEffectSystem.cs b/src/engine/common_systems/SoundEffectSystem.cs new file mode 100644 index 00000000000..2b65eb4b727 --- /dev/null +++ b/src/engine/common_systems/SoundEffectSystem.cs @@ -0,0 +1,599 @@ +namespace Systems +{ + using System; + using System.Collections.Generic; + using Components; + using DefaultEcs; + using DefaultEcs.System; + using Godot; + using World = DefaultEcs.World; + + /// + /// Plays the sounds from + /// + [With(typeof(SoundEffectPlayer))] + [With(typeof(WorldPosition))] + [RunsOnMainThread] + public sealed class SoundEffectSystem : AEntitySetSystem + { + private const string AudioBus = "SFX"; + + private readonly Dictionary soundCache = new(); + private readonly List soundCacheEntriesToClear = new(); + + // TODO: could maybe pool the playing audio player wrappers? + + private readonly Stack freePositionalPlayers = new(); + private readonly List usedPositionalPlayers = new(); + + private readonly Stack free2DPlayers = new(); + private readonly List used2DPlayers = new(); + + private readonly List<(Entity Entity, float Distance)> entitiesThatNeedProcessing = new(); + + private readonly Node soundPlayerParent; + + private int playingSoundCount; + + private float timeCounter; + private float lastClearedSoundCacheTime; + + private ushort soundIdentifierCounter; + + private Vector3 playerPosition; + + public SoundEffectSystem(Node soundPlayerParentNode, World world) : base(world, null) + { + soundPlayerParent = soundPlayerParentNode; + } + + public void ReportPlayerPosition(Vector3 position) + { + playerPosition = position; + } + + protected override void PreUpdate(float delta) + { + base.PreUpdate(delta); + + timeCounter += delta; + + playingSoundCount = 0; + + // First check the status of any sound players to detect when some end playing, and handle looping + int positionalCount = usedPositionalPlayers.Count; + for (int i = 0; i < positionalCount; ++i) + { + var playerWrapper = usedPositionalPlayers[i]; + if (playerWrapper.Player.Playing) + { + ++playingSoundCount; + } + else if (playerWrapper.Loop) + { + playerWrapper.Player.Play(); + ++playingSoundCount; + } + else + { + // Add to the free list + playerWrapper.MarkEnded(); + freePositionalPlayers.Push(playerWrapper.Player); + + usedPositionalPlayers.RemoveWithoutPreservingOrder(i); + --positionalCount; + + // Need to go back two spaces as this current slot may have something swapped into it now + i -= 2; + + // Next loop increments i by one so invalid values are -2 and below as that + 1 won't be a valid + // index + if (i < -1) + break; + } + } + + int nonPositionalCount = used2DPlayers.Count; + for (int i = 0; i < nonPositionalCount; ++i) + { + var playerWrapper = used2DPlayers[i]; + if (playerWrapper.Player.Playing) + { + ++playingSoundCount; + } + else if (playerWrapper.Loop) + { + playerWrapper.Player.Play(); + ++playingSoundCount; + } + else + { + playerWrapper.MarkEnded(); + free2DPlayers.Push(playerWrapper.Player); + + used2DPlayers.RemoveWithoutPreservingOrder(i); + --nonPositionalCount; + + i -= 2; + + if (i < -1) + break; + } + } + } + + protected override void Update(float delta, ReadOnlySpan entities) + { + // Collect sound playing entities that need processing + foreach (ref readonly var entity in entities) + { + ref var soundEffectPlayer = ref entity.Get(); + + if (soundEffectPlayer.SoundsApplied) + continue; + + ref var position = ref entity.Get(); + + var distance = position.Position.DistanceSquaredTo(playerPosition); + + // Skip so far away players that they shouldn't be handled at all + // TODO: maybe still stop sounds in these from playing? (for example if some code wanted to stop a + // sound on an entity that doesn't get processed due to distance, that sound playing won't stop) + if (soundEffectPlayer.AbsoluteMaxDistanceSquared > 0 && + distance > soundEffectPlayer.AbsoluteMaxDistanceSquared) + { + continue; + } + + entitiesThatNeedProcessing.Add((entity, distance)); + } + + // Play sounds starting from the nearest to the player to make the concurrently playing limit + // work correctly + entitiesThatNeedProcessing.Sort((x, y) => (int)(x.Distance - y.Distance)); + + HandleSoundEntityStateApply(); + + entitiesThatNeedProcessing.Clear(); + } + + protected override void PostUpdate(float delta) + { + base.PostUpdate(delta); + + // Update active positional players to have the right positions for the sounds + foreach (var usedPositionalPlayer in usedPositionalPlayers) + { + if (usedPositionalPlayer.GetUpdatedPositionIfEntityIsValid(out var position)) + { + usedPositionalPlayer.Player.Translation = position; + } + } + + ExpireOldAudioCacheEntries(delta); + } + + private static void MarkSoundEndedOnEntityIfPossible(in Entity entity, uint slotId, string sound) + { + if (!entity.IsAlive) + return; + + ref var entityPlayer = ref entity.Get(); + + var slots = entityPlayer.SoundEffectSlots; + + if (slots == null) + return; + + var count = slots.Length; + + for (int i = 0; i < count; ++i) + { + ref var slot = ref slots[i]; + + if (slot.InternalPlayingState == slotId && slot.SoundFile == sound) + { + slot.InternalPlayingState = 0; + slot.Play = false; + return; + } + } + + // If no exact match, do a partial match to report the player is no longer associated with the sound slot + + for (int i = 0; i < count; ++i) + { + ref var slot = ref slots[i]; + + if (slot.InternalPlayingState == slotId) + { + slot.InternalPlayingState = 0; + + // Don't reset play here unconditionally, instead let the full update determine what to do next + + if (slot.SoundFile == null) + { + // Situation was that the file to play was cleared, it should be fully safe here to reset + // play to false + slot.Play = false; + } + + return; + } + } + } + + private void HandleSoundEntityStateApply() + { + foreach (var entry in entitiesThatNeedProcessing) + { + var entity = entry.Entity; + + ref var soundEffectPlayer = ref entity.Get(); + + var slots = soundEffectPlayer.SoundEffectSlots; + + if (slots == null) + continue; + + bool play2D = soundEffectPlayer.AutoDetectPlayer && entity.Has(); + + bool skippedSomething = false; + + // The slots are intentionally left unlocked as if sound effects are modified while this system runs + // it would lead to sometimes unexpected results + + int slotCount = slots.Length; + + for (int i = 0; i < slotCount; ++i) + { + ref var slot = ref slots[i]; + + if (slot.InternalAppliedState) + continue; + + if (!slot.Play) + { + // See if there is a sound to stop + + if (slot.InternalPlayingState != 0) + { + var existing = FindPlayingSlot(play2D, entity, slot.InternalPlayingState); + + existing?.Stop(); + } + + slot.InternalPlayingState = 0; + } + else + { + // This slot wants to play a sound + + bool startNew = slot.InternalPlayingState == 0; + + if (!startNew) + { + // Adjusting existing sound + var existing = FindPlayingSlot(play2D, entity, slot.InternalPlayingState); + + if (existing != null) + { + if (existing.Sound == slot.SoundFile) + { + existing.AdjustProperties(slot.Loop, slot.Volume); + } + else + { + // Sound file changed but it wasn't fully correctly cleared + GD.PrintErr("Playing sound effect file changed incorrectly"); + existing.Stop(); + startNew = true; + } + } + else + { + // Existing player no longer valid + startNew = true; + } + } + + if (startNew && !string.IsNullOrEmpty(slot.SoundFile)) + { + // Only start playing if can + // TODO: don't apply this limit to the player + if (playingSoundCount >= Constants.MAX_CONCURRENT_SOUNDS) + { + // This leaves SoundsApplied false so that this entity can keep trying until there are + // empty sound playing slots + skippedSomething = true; + continue; + } + + // New sound start requested + slot.InternalPlayingState = NextSoundIdentifier(); + + // TODO: do we need to guard against a situation where a long playing sound has an ID we + // have wrapped around to here? (by scanning the slot internal playing states on this + // entity to see if there are duplicates) + + StartPlaying(play2D, entity, slot.InternalPlayingState, slot.SoundFile!, slot.Loop, + slot.Volume); + } + } + + slot.InternalAppliedState = true; + } + + // Only mark as applied if we had room to start all sounds that should be played + if (skippedSomething) + continue; + + soundEffectPlayer.SoundsApplied = true; + } + } + + private void StartPlaying(bool play2D, in Entity entity, ushort id, string sound, bool loop, float volume) + { + ++playingSoundCount; + + if (!play2D) + { + AudioStreamPlayer3D player; + + // Use a free player if available + if (freePositionalPlayers.Count > 0) + { + player = freePositionalPlayers.Pop(); + } + else + { + // Allocate a new one if we ran out + player = CreateNewPositional(); + } + + player.Stream = GetAudioStream(sound); + player.UnitDb = GD.Linear2Db(volume); + player.Play(); + + usedPositionalPlayers.Add(new PlayingPositionalPlayer(player, entity, sound, id, loop)); + } + else + { + AudioStreamPlayer player; + + if (free2DPlayers.Count > 0) + { + player = free2DPlayers.Pop(); + } + else + { + // Allocate a new one if we ran out + player = CreateNew2DPlayer(); + } + + player.Stream = GetAudioStream(sound); + player.VolumeDb = GD.Linear2Db(volume); + player.Play(); + + used2DPlayers.Add(new NonPlayingPositionalPlayer(player, entity, sound, id, loop)); + } + } + + private CurrentlyPlayingBase? FindPlayingSlot(bool is2D, in Entity entity, ushort id) + { + // TODO: do we need to switch to a map based (or maybe structs in an array?) storage to speed this up? + if (is2D) + { + foreach (var used2DPlayer in used2DPlayers) + { + if (used2DPlayer.SoundId == id && used2DPlayer.Entity == entity) + return used2DPlayer; + } + } + else + { + foreach (var usedPositional in usedPositionalPlayers) + { + if (usedPositional.SoundId == id && usedPositional.Entity == entity) + return usedPositional; + } + } + + return null; + } + + /// + /// Returns next pseudo-unique identifier for a sound + /// + /// The next identifier to use + private ushort NextSoundIdentifier() + { + ++soundIdentifierCounter; + + if (soundIdentifierCounter == 0) + ++soundIdentifierCounter; + + return soundIdentifierCounter; + } + + private AudioStreamPlayer3D CreateNewPositional() + { + var player = new AudioStreamPlayer3D + { + Bus = AudioBus, + + // TODO: should max distance be set here? + // MaxDistance = Constants.MICROBE_SOUND_MAX_DISTANCE, + }; + + soundPlayerParent.AddChild(player); + + return player; + } + + private AudioStreamPlayer CreateNew2DPlayer() + { + var player = new AudioStreamPlayer + { + Bus = AudioBus, + }; + + soundPlayerParent.AddChild(player); + + return player; + } + + private AudioStream GetAudioStream(string sound) + { + if (soundCache.TryGetValue(sound, out var result)) + { + result.LastUsed = timeCounter; + return result.Stream; + } + + var stream = GD.Load(sound); + + soundCache[sound] = new CachedSound(stream, timeCounter); + + return stream; + } + + private void ExpireOldAudioCacheEntries(float delta) + { + lastClearedSoundCacheTime += delta; + + if (lastClearedSoundCacheTime < Constants.INTERVAL_BETWEEN_SOUND_CACHE_CLEAR) + return; + + lastClearedSoundCacheTime = 0; + + foreach (var entry in soundCache) + { + if (timeCounter - entry.Value.LastUsed > Constants.DEFAULT_SOUND_CACHE_TIME) + soundCacheEntriesToClear.Add(entry.Key); + } + + foreach (var toDelete in soundCacheEntriesToClear) + { + soundCache.Remove(toDelete); + } + + soundCacheEntriesToClear.Clear(); + } + + /// + /// This and derived classes include extra info that is needed on a currently playing audio player for this + /// system + /// + /// + /// + /// TODO: reuse instances of this class as well to reduce memory allocation (instead of just reusing the + /// underlying player objects) + /// + /// + private abstract class CurrentlyPlayingBase + { + public readonly Entity Entity; + + public readonly string Sound; + + public readonly ushort SoundId; + + public bool Loop; + + protected CurrentlyPlayingBase(in Entity entity, string sound, ushort id, bool loop) + { + Entity = entity; + Sound = sound; + SoundId = id; + Loop = loop; + } + + public void MarkEnded() + { + MarkSoundEndedOnEntityIfPossible(Entity, SoundId, Sound); + } + + public abstract void Stop(); + + public void AdjustProperties(bool slotLoop, float slotVolume) + { + Loop = slotLoop; + SetVolume(slotVolume); + } + + public abstract void SetVolume(float linearVolume); + } + + /// + /// Stores the info on where to read the updated position from for a positional player + /// + private class PlayingPositionalPlayer : CurrentlyPlayingBase + { + public readonly AudioStreamPlayer3D Player; + + public PlayingPositionalPlayer(AudioStreamPlayer3D player, in Entity entity, string sound, ushort id, + bool loop) : base(entity, sound, id, loop) + { + Player = player; + } + + public bool GetUpdatedPositionIfEntityIsValid(out Vector3 position) + { + if (!Entity.IsAlive) + { + position = Vector3.Zero; + return false; + } + + position = Entity.Get().Position; + return true; + } + + public override void Stop() + { + Player.Stop(); + Loop = false; + } + + public override void SetVolume(float linearVolume) + { + Player.UnitDb = GD.Linear2Db(linearVolume); + } + } + + private class NonPlayingPositionalPlayer : CurrentlyPlayingBase + { + public readonly AudioStreamPlayer Player; + + public NonPlayingPositionalPlayer(AudioStreamPlayer player, in Entity entity, string sound, ushort id, + bool loop) : base(entity, sound, id, loop) + { + Player = player; + } + + public override void Stop() + { + Player.Stop(); + Loop = false; + } + + public override void SetVolume(float linearVolume) + { + Player.VolumeDb = GD.Linear2Db(linearVolume); + } + } + + private class CachedSound + { + public readonly AudioStream Stream; + public float LastUsed; + + public CachedSound(AudioStream stream, float currentTime) + { + Stream = stream; + LastUsed = currentTime; + } + } + } +} diff --git a/src/engine/common_systems/SoundListenerSystem.cs b/src/engine/common_systems/SoundListenerSystem.cs new file mode 100644 index 00000000000..6b63a5c1de2 --- /dev/null +++ b/src/engine/common_systems/SoundListenerSystem.cs @@ -0,0 +1,107 @@ +namespace Systems +{ + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + using Godot; + using World = DefaultEcs.World; + + /// + /// Plays the sounds from + /// + [With(typeof(SoundListener))] + [With(typeof(WorldPosition))] + public sealed class SoundListenerSystem : AEntitySetSystem + { + private readonly Listener listener; + + private Transform? wantedListenerPosition; + + private bool useTopDownOrientation; + + private bool printedError; + + public SoundListenerSystem(Node listenerParentNode, World world, IParallelRunner runner) : base(world, runner) + { + listener = new Listener(); + listener.ClearCurrent(); + listenerParentNode.AddChild(listener); + } + + public override void Dispose() + { + Dispose(true); + base.Dispose(); + + // GC.SuppressFinalize(this); + } + + protected override void PreUpdate(float state) + { + base.PreUpdate(state); + + wantedListenerPosition = null; + } + + protected override void Update(float delta, in Entity entity) + { + ref var soundListener = ref entity.Get(); + + if (soundListener.Disabled) + return; + + ref var position = ref entity.Get(); + + if (wantedListenerPosition != null) + { + if (!printedError) + { + GD.PrintErr("Multiple SoundListener entities are active at once. Only last one will work! " + + "This error won't be printed again."); + printedError = true; + } + } + + useTopDownOrientation = soundListener.UseTopDownRotation; + wantedListenerPosition = position.ToTransform(); + } + + protected override void PostUpdate(float state) + { + base.PostUpdate(state); + + if (wantedListenerPosition == null) + { + if (listener.IsCurrent()) + listener.ClearCurrent(); + } + else + { + if (useTopDownOrientation) + { + // Listener is directional, so in this case we want to separate the rotation out from the entity + // transform to not use it + Transform transform = wantedListenerPosition.Value; + transform.basis = new Basis(new Vector3(0.0f, 0.0f, -1.0f)); + listener.GlobalTransform = transform; + } + else + { + listener.GlobalTransform = wantedListenerPosition.Value; + } + + if (!listener.IsCurrent()) + listener.MakeCurrent(); + } + } + + private void Dispose(bool disposing) + { + if (disposing) + { + listener.Dispose(); + } + } + } +} diff --git a/src/engine/common_systems/SpatialAttachSystem.cs b/src/engine/common_systems/SpatialAttachSystem.cs new file mode 100644 index 00000000000..0f90ad11939 --- /dev/null +++ b/src/engine/common_systems/SpatialAttachSystem.cs @@ -0,0 +1,85 @@ +namespace Systems +{ + using System; + using System.Collections.Generic; + using Components; + using DefaultEcs.System; + using Godot; + using World = DefaultEcs.World; + + /// + /// Attaches to the Godot scene and handles freeing unused spatial instances. + /// Must run before . + /// + public sealed class SpatialAttachSystem : AComponentSystem + { + private readonly Node godotWorldRoot; + + private readonly Dictionary attachedSpatialInstances = new(); + private readonly List instancesToDelete = new(); + + public SpatialAttachSystem(Node godotWorldRoot, World world) : base(world, null) + { + this.godotWorldRoot = godotWorldRoot; + } + + protected override void PreUpdate(float state) + { + // Unmark all + foreach (var info in attachedSpatialInstances.Values) + { + info.Marked = false; + } + } + + protected override void Update(float state, Span components) + { + foreach (ref SpatialInstance spatial in components) + { + var graphicalInstance = spatial.GraphicalInstance; + if (graphicalInstance == null) + continue; + + if (!attachedSpatialInstances.TryGetValue(graphicalInstance, out var info)) + { + // New spatial to attach + godotWorldRoot.AddChild(graphicalInstance); + + info = new AttachedInfo(); + attachedSpatialInstances.Add(graphicalInstance, info); + } + else + { + info.Marked = true; + } + } + } + + protected override void PostUpdate(float state) + { + // Delete unmarked + foreach (var pair in attachedSpatialInstances) + { + if (!pair.Value.Marked) + instancesToDelete.Add(pair.Key); + } + + foreach (var spatial in instancesToDelete) + { + attachedSpatialInstances.Remove(spatial); + spatial.QueueFree(); + } + + instancesToDelete.Clear(); + } + + /// + /// Info (really just a marked status) for spatial instances. This breaks the use of only value types by + /// systems, so there might be some more efficient way to implement this (for example with two hash sets) + /// + private class AttachedInfo + { + public bool Marked = true; + } + } +} diff --git a/src/engine/common_systems/SpatialPositionSystem.cs b/src/engine/common_systems/SpatialPositionSystem.cs new file mode 100644 index 00000000000..25177cc4978 --- /dev/null +++ b/src/engine/common_systems/SpatialPositionSystem.cs @@ -0,0 +1,38 @@ +namespace Systems +{ + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + using Godot; + using World = DefaultEcs.World; + + [With(typeof(WorldPosition))] + [With(typeof(SpatialInstance))] + public sealed class SpatialPositionSystem : AEntitySetSystem + { + public SpatialPositionSystem(World world, IParallelRunner runner) : base(world, runner) + { + } + + protected override void Update(float delta, in Entity entity) + { + ref var position = ref entity.Get(); + ref var spatial = ref entity.Get(); + + if (spatial.GraphicalInstance == null) + return; + + if (spatial.ApplyVisualScale) + { + spatial.GraphicalInstance.Transform = + new Transform(new Basis(position.Rotation).Scaled(spatial.VisualScale), position.Position); + } + else + { + spatial.GraphicalInstance.Transform = + new Transform(new Basis(position.Rotation), position.Position); + } + } + } +} diff --git a/src/engine/common_systems/TimedLifeSystem.cs b/src/engine/common_systems/TimedLifeSystem.cs new file mode 100644 index 00000000000..ddb254d1691 --- /dev/null +++ b/src/engine/common_systems/TimedLifeSystem.cs @@ -0,0 +1,80 @@ +namespace Systems +{ + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + + /// + /// System that deletes nodes that are in the timed group after their lifespan expires. + /// + [With(typeof(TimedLife))] + public sealed class TimedLifeSystem : AEntitySetSystem + { + private readonly IEntityContainer entityContainer; + + public TimedLifeSystem(IEntityContainer entityContainer, World world, IParallelRunner runner) : + base(world, runner) + { + this.entityContainer = entityContainer; + } + + /// + /// Despawns all timed entities + /// + public void DespawnAll() + { + foreach (var entity in World.GetEntities().With().AsEnumerable()) + { + entityContainer.DestroyEntity(entity); + } + } + + protected override void Update(float delta, in Entity entity) + { + ref var timed = ref entity.Get(); + + // Fading timing is now also handled by this system + if (timed.FadeTimeRemaining != null) + { + timed.FadeTimeRemaining -= delta; + + if (timed.FadeTimeRemaining <= 0) + { + // Fade time ended + entityContainer.DestroyEntity(entity); + } + + return; + } + + timed.TimeToLiveRemaining -= delta; + + if (timed.TimeToLiveRemaining <= 0.0f && !timed.OnTimeOverTriggered) + { + timed.OnTimeOverTriggered = true; + var callback = timed.CustomTimeOverCallback; + + // If there is a custom callback call it first as it can set the fade time + bool wantsToLive = callback != null && !callback.Invoke(entity, ref timed); + + if (timed.FadeTimeRemaining != null && timed.FadeTimeRemaining.Value > 0) + { + // Entity doesn't want to die just yet + wantsToLive = true; + } + + if (!wantsToLive) + { + entityContainer.DestroyEntity(entity); + } + else + { + // Disable saving for this entity as fade out states are not programmed to resume well after loading + // a save + entityContainer.ReportEntityDyingSoon(entity); + } + } + } + } +} diff --git a/src/engine/physics/NativePhysicsBody.cs b/src/engine/physics/NativePhysicsBody.cs new file mode 100644 index 00000000000..2bb0e2a47a5 --- /dev/null +++ b/src/engine/physics/NativePhysicsBody.cs @@ -0,0 +1,205 @@ +using System; +using System.Buffers; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using DefaultEcs; +using Godot; + +/// +/// Physics body implemented by the Thrive native code library (this is a wrapper around that native handle) +/// +[StructLayout(LayoutKind.Sequential)] +public class NativePhysicsBody : IDisposable, IEquatable +{ + /// + /// This is only used by external code, not this class at all to know which bodies are in use without having to + /// allocate extra memory. + /// + /// + /// + /// This being the first field makes the memory layout non-optimal but some of the private fields have been + /// juggled around a bit for better layout. + /// + /// + public bool Marked = true; + + private static readonly ArrayPool CollisionDataBufferPool = + ArrayPool.Create(Constants.MAX_COLLISION_CACHE_BUFFER_RETURN_SIZE, + Constants.MAX_COLLISION_CACHE_BUFFERS_OF_SIMILAR_LENGHT); + + private static readonly int EntityDataSize = Marshal.SizeOf(); + + private bool disposed; + + /// + /// Storage variable for collision recording, when this is active the pin handle is used to pin down this + /// piece of memory to ensure the native code size can directly write here with pointers + /// + private PhysicsCollision[]? activeCollisions; + + private GCHandle activeCollisionsPinHandle; + + private IntPtr nativeInstance; + + internal NativePhysicsBody(IntPtr nativeInstance) + { + this.nativeInstance = nativeInstance; + + if (this.nativeInstance.ToInt64() == 0) + { + // TODO: should this crash the game? + GD.PrintErr( + "Physics body can't be created from null native pointer, we probably ran out of physics bodies"); + } + } + + ~NativePhysicsBody() + { + Dispose(false); + } + + /// + /// Active collisions for this body. Only updated if started through + /// + /// + public PhysicsCollision[]? ActiveCollisions => activeCollisions; + + /// + /// C# side tracking for physics bodies with microbe control being enabled + /// + public bool MicrobeControlEnabled { get; set; } + + public static bool operator ==(NativePhysicsBody? left, NativePhysicsBody? right) + { + return Equals(left, right); + } + + public static bool operator !=(NativePhysicsBody? left, NativePhysicsBody? right) + { + return !Equals(left, right); + } + + /// + /// Stores an entity in this body's user data for use in collision callbacks + /// + /// The entity data to store + public void SetEntityReference(in Entity entity) + { + NativeMethods.PhysicsBodySetUserData(AccessBodyInternal(), entity, EntityDataSize); + } + + public bool Equals(NativePhysicsBody? other) + { + if (other == null) + return false; + + return nativeInstance.ToInt64() != 0 && nativeInstance == other.nativeInstance; + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) + return false; + if (ReferenceEquals(this, obj)) + return true; + if (obj.GetType() != GetType()) + return false; + + return Equals((NativePhysicsBody)obj); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + public override int GetHashCode() + { + return nativeInstance.GetHashCode(); + } + + internal (PhysicsCollision[] CollisionsArray, IntPtr ArrayAddress) + SetupCollisionRecording(int maxCollisions) + { + // Ensure no previous state. This is safe as each physics body can only be recording one set of collisions + // at once, so all of our very briefly dangling pointers will be fixed very soon. + NotifyCollisionRecordingStopped(); + + activeCollisions = CollisionDataBufferPool.Rent(maxCollisions); + activeCollisionsPinHandle = GCHandle.Alloc(activeCollisions, GCHandleType.Pinned); + + return (activeCollisions, activeCollisionsPinHandle.AddrOfPinnedObject()); + } + + internal void NotifyCollisionRecordingStopped() + { + if (activeCollisions != null) + { + CollisionDataBufferPool.Return(activeCollisions); + activeCollisions = null; + + activeCollisionsPinHandle.Free(); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal IntPtr AccessBodyInternal() + { + if (disposed) + throw new ObjectDisposedException(nameof(NativePhysicsBody)); + + return nativeInstance; + } + + protected virtual void Dispose(bool disposing) + { + ReleaseUnmanagedResources(); + if (disposing) + { + disposed = true; + } + } + + private void ReleaseUnmanagedResources() + { + if (nativeInstance.ToInt64() != 0) + { + ForceStopCollisionRecording(); + + NativeMethods.ReleasePhysicsBodyReference(nativeInstance); + nativeInstance = new IntPtr(0); + } + } + + /// + /// Ensures that no pointers are left given to the native side that will become invalid after this object is + /// disposed + /// + private void ForceStopCollisionRecording() + { + if (activeCollisions == null) + return; + + GD.PrintErr("Force stopping collision reporting! This should not happen when properly destroying bodies"); + + NativeMethods.PhysicsBodyForceClearRecordingTargets(nativeInstance); + + NotifyCollisionRecordingStopped(); + } +} + +/// +/// Thrive native library methods related to bodies +/// +internal static partial class NativeMethods +{ + [DllImport("thrive_native")] + internal static extern void ReleasePhysicsBodyReference(IntPtr body); + + [DllImport("thrive_native")] + internal static extern void PhysicsBodySetUserData(IntPtr body, in Entity userData, int userDataSize); + + [DllImport("thrive_native")] + internal static extern void PhysicsBodyForceClearRecordingTargets(IntPtr body); +} diff --git a/src/engine/physics/PhysicalWorld.cs b/src/engine/physics/PhysicalWorld.cs new file mode 100644 index 00000000000..5e14a20d58d --- /dev/null +++ b/src/engine/physics/PhysicalWorld.cs @@ -0,0 +1,659 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Godot; + +/// +/// Wrapper for the native side physical world which is the main part of the physics simulation +/// +public class PhysicalWorld : IDisposable +{ + private bool disposed; + private bool stackAllocWarned; + private IntPtr nativeInstance; + + private PhysicalWorld(IntPtr nativeInstance) + { + this.nativeInstance = nativeInstance; + + var debugDrawer = DebugDrawer.Instance; + debugDrawer.OnPhysicsDebugLevelChangedHandler += SetUpdatedDebugLevel; + debugDrawer.OnPhysicsDebugCameraPositionChangedHandler += UpdateDebugCameraInfo; + + // Apply debug level set before this object was created (as we can't have received the signal about the + // incremented debug level + var currentDebugLevel = debugDrawer.DebugLevel; + + if (currentDebugLevel > 0) + { + SetUpdatedDebugLevel(currentDebugLevel); + UpdateDebugCameraInfo(debugDrawer.DebugCameraLocation); + } + } + + ~PhysicalWorld() + { + Dispose(false); + } + + /// + /// Callback to determine if a collision is allowed to happen. Note that the penetration amount is not + /// necessarily initialized if the callback is registered so that it doesn't want to calculate that info (as it + /// is not available without extra calculation when a collision begins) + /// + public delegate bool OnCollisionFilterCallback(ref PhysicsCollision collision); + + public float LatestPhysicsDuration => NativeMethods.PhysicalWorldGetPhysicsLatestTime(AccessWorldInternal()); + + /// + /// Time in seconds on average that physics simulation steps take + /// + public float AveragePhysicsDuration => NativeMethods.PhysicalWorldGetPhysicsAverageTime(AccessWorldInternal()); + + public static PhysicalWorld Create() + { + return new PhysicalWorld(NativeMethods.CreatePhysicalWorld()); + } + + /// + /// Steps the physics simulation forward, if enough time has passed + /// + /// Time since the last call of this method + /// True when at least one physics simulation step was performed + public bool ProcessPhysics(float delta) + { + bool processed = NativeMethods.ProcessPhysicalWorld(AccessWorldInternal(), delta); + + return processed; + } + + /// + /// Creates a new moving body + /// + /// The shape for the body + /// Initial position of the body + /// Initial rotation of the body + /// + /// If false then the body won't be automatically added to the world and needs to be called + /// + /// The created physics body instance + public NativePhysicsBody CreateMovingBody(PhysicsShape shape, Vector3 position, Quat rotation, + bool addToWorld = true) + { + return new NativePhysicsBody(NativeMethods.PhysicalWorldCreateMovingBody(AccessWorldInternal(), + shape.AccessShapeInternal(), new JVec3(position), new JQuat(rotation), addToWorld)); + } + + /// + /// Creates a moving body with axis locks. When is on, the locked axis is the only + /// one around which rotation is allowed. + /// + /// The created physics body + public NativePhysicsBody CreateMovingBodyWithAxisLock(PhysicsShape shape, Vector3 position, Quat rotation, + Vector3 lockedAxes, bool lockRotation, bool addToWorld = true) + { + if (lockedAxes.LengthSquared() < MathUtils.EPSILON) + throw new ArgumentException("Locked axes needs to specify at least one locked axis", nameof(lockedAxes)); + + return new NativePhysicsBody(NativeMethods.PhysicalWorldCreateMovingBodyWithAxisLock(AccessWorldInternal(), + shape.AccessShapeInternal(), new JVec3(position), new JQuat(rotation), new JVecF3(lockedAxes), lockRotation, + addToWorld)); + } + + public NativePhysicsBody CreateStaticBody(PhysicsShape shape, Vector3 position, Quat rotation, + bool addToWorld = true) + { + return new NativePhysicsBody(NativeMethods.PhysicalWorldCreateStaticBody(AccessWorldInternal(), + shape.AccessShapeInternal(), + new JVec3(position), new JQuat(rotation), addToWorld)); + } + + /// + /// Adds an existing body back to this world + /// + /// The body to add + /// When true the body is activated (wakes from sleep etc.) + public void AddBody(NativePhysicsBody body, bool activate = true) + { + NativeMethods.PhysicalWorldAddBody(AccessWorldInternal(), body.AccessBodyInternal(), activate); + } + + /// + /// Detaches a body from the world for adding back later with . Very different from + /// destroying the body. + /// + /// The body to detach + public void DetachBody(NativePhysicsBody body) + { + NativeMethods.PhysicalWorldDetachBody(AccessWorldInternal(), body.AccessBodyInternal()); + } + + /// + /// Destroys a body entirely on the native side. + /// + /// Body to be destroyed immediately. No longer valid for any physics calls after this + /// + /// When true the body is disposed automatically. If false the caller can call dispose when it wants to or + /// not call it at all, which should be fine but then the body wrapper object may exist for a long time. + /// + public void DestroyBody(NativePhysicsBody body, bool dispose = true) + { + // As the body will be forcefully destroyed, all the collision writing resources can be freed + body.NotifyCollisionRecordingStopped(); + + NativeMethods.DestroyPhysicalWorldBody(AccessWorldInternal(), body.AccessBodyInternal()); + + if (dispose) + body.Dispose(); + } + + public void SetDamping(NativePhysicsBody body, float linearDamping, float? angularDamping = null) + { + if (angularDamping != null) + { + NativeMethods.SetPhysicsBodyLinearAndAngularDamping(AccessWorldInternal(), body.AccessBodyInternal(), + linearDamping, angularDamping.Value); + } + else + { + NativeMethods.SetPhysicsBodyLinearDamping(AccessWorldInternal(), body.AccessBodyInternal(), linearDamping); + } + } + + public Transform ReadBodyTransform(NativePhysicsBody body) + { + var data = ReadBodyPosition(body); + + return new Transform(new Basis(data.Rotation), data.Position); + } + + public (Vector3 Position, Quat Rotation) ReadBodyPosition(NativePhysicsBody body) + { + // TODO: could probably make things a bit more efficient if the C# body stored the body ID to avoid one level + // of indirection here (the indirection is maybe on the C++ side -hhyyrylainen) + NativeMethods.ReadPhysicsBodyTransform(AccessWorldInternal(), body.AccessBodyInternal(), + out var position, out var orientation); + + return (position, orientation); + } + + public (Vector3 Velocity, Vector3 AngularVelocity) ReadBodyVelocity(NativePhysicsBody body) + { + NativeMethods.ReadPhysicsBodyVelocity(AccessWorldInternal(), body.AccessBodyInternal(), + out var velocity, out var angularVelocity); + + return (velocity, angularVelocity); + } + + public void GiveImpulse(NativePhysicsBody body, Vector3 impulse) + { + NativeMethods.GiveImpulse(AccessWorldInternal(), body.AccessBodyInternal(), new JVecF3(impulse)); + } + + public void GiveAngularImpulse(NativePhysicsBody body, Vector3 angularImpulse) + { + NativeMethods.GiveAngularImpulse(AccessWorldInternal(), body.AccessBodyInternal(), new JVecF3(angularImpulse)); + } + + /// + /// Applies microbe movement control on a physics body. Note that there has to be at least one active physics + /// body (not sleeping) to have this apply. If there are no active physics bodies this has no effect. + /// + /// The physics body to control + /// World-space movement vector + /// Target look rotation + /// + /// How fast the body rotates to face , higher values are slower + /// + public void ApplyBodyMicrobeControl(NativePhysicsBody body, Vector3 movementImpulse, Quat lookDirection, + float rotationSpeedDivisor) + { + // Too low speed divisor causes too fast rotation and instability that way + if (rotationSpeedDivisor < 0.01f) + rotationSpeedDivisor = 0.01f; + + body.MicrobeControlEnabled = true; + + NativeMethods.SetBodyControl(AccessWorldInternal(), body.AccessBodyInternal(), + new JVecF3(movementImpulse), new JQuat(lookDirection), rotationSpeedDivisor); + } + + public void DisableMicrobeBodyControl(NativePhysicsBody body) + { + if (!body.MicrobeControlEnabled) + { + // Skip trying to disable if already disabled, this is done to not need an extra variable in places trying + // to disable this to check first + return; + } + + NativeMethods.DisableBodyControl(AccessWorldInternal(), body.AccessBodyInternal()); + body.MicrobeControlEnabled = false; + } + + public void SetBodyPosition(NativePhysicsBody body, Vector3 position) + { + NativeMethods.SetBodyPosition(AccessWorldInternal(), body.AccessBodyInternal(), new JVec3(position)); + } + + /// + /// Sets velocity for a body + /// + public void SetBodyVelocity(NativePhysicsBody body, Vector3 velocity, Vector3 angularVelocity) + { + NativeMethods.SetBodyVelocityAndAngularVelocity(AccessWorldInternal(), body.AccessBodyInternal(), + new JVecF3(velocity), + new JVecF3(angularVelocity)); + } + + /// + /// Only sets velocity without affecting angular velocity. This should only be used if only velocity is wanted + /// to be changed as it is much less efficient to use this and than + /// calling the combined method + /// + public void SetOnlyBodyVelocity(NativePhysicsBody body, Vector3 velocity) + { + NativeMethods.SetBodyVelocity(AccessWorldInternal(), body.AccessBodyInternal(), new JVecF3(velocity)); + } + + public void SetOnlyBodyAngularVelocity(NativePhysicsBody body, Vector3 angularVelocity) + { + NativeMethods.SetBodyAngularVelocity(AccessWorldInternal(), body.AccessBodyInternal(), + new JVecF3(angularVelocity)); + } + + public void SetBodyAllowSleep(NativePhysicsBody body, bool allowSleep) + { + NativeMethods.SetBodyAllowSleep(AccessWorldInternal(), body.AccessBodyInternal(), allowSleep); + } + + public bool FixBodyYCoordinateToZero(NativePhysicsBody body) + { + return NativeMethods.FixBodyYCoordinateToZero(AccessWorldInternal(), body.AccessBodyInternal()); + } + + public void ChangeBodyShape(NativePhysicsBody body, PhysicsShape shape, bool activate = true) + { + NativeMethods.ChangeBodyShape(AccessWorldInternal(), body.AccessBodyInternal(), + shape.AccessShapeInternal(), activate); + } + + /// + /// Makes this body unable to move on the given axis. Used to make microbes move only in a 2D plane. Call after + /// the body is added to the world. + /// + /// The body to add the axis lock on + /// + /// The axis to lock this body to, for example for microbe stage objects + /// + /// When true also locks rotation to only happen around the given axis + public void AddAxisLockConstraint(NativePhysicsBody body, Vector3 axis, bool lockRotation) + { + NativeMethods.PhysicsBodyAddAxisLock(AccessWorldInternal(), body.AccessBodyInternal(), new JVecF3(axis), + lockRotation); + } + + public void SetBodyCollisionsEnabledState(NativePhysicsBody body, bool collisionsEnabled) + { + NativeMethods.PhysicsBodySetCollisionEnabledState(AccessWorldInternal(), body.AccessBodyInternal(), + collisionsEnabled); + } + + public void BodyIgnoreCollisionsWithBody(NativePhysicsBody body, NativePhysicsBody otherBody) + { + NativeMethods.PhysicsBodyAddCollisionIgnore(AccessWorldInternal(), body.AccessBodyInternal(), + otherBody.AccessBodyInternal()); + } + + public void BodyRemoveCollisionIgnoreWith(NativePhysicsBody body, NativePhysicsBody otherBody) + { + NativeMethods.PhysicsBodyRemoveCollisionIgnore(AccessWorldInternal(), body.AccessBodyInternal(), + otherBody.AccessBodyInternal()); + } + + public void BodyClearCollisionsIgnores(NativePhysicsBody body) + { + NativeMethods.PhysicsBodyClearCollisionIgnores(AccessWorldInternal(), body.AccessBodyInternal()); + } + + public void BodySetCollisionIgnores(NativePhysicsBody body, IReadOnlyList ignoredBodies) + { + // Optimization if the list is empty + if (ignoredBodies.Count < 1) + { + BodyClearCollisionsIgnores(body); + return; + } + + var size = ignoredBodies.Count; + + if (size * 8 > Constants.MAX_STACKALLOC) + { + if (!stackAllocWarned) + { + GD.PrintErr("Stackalloc not usable due to size of collision ignore list, performance problem"); + stackAllocWarned = true; + } + + // Less efficient, simpler approach here as this is not meant to be triggered in any sensible use + var array = ignoredBodies.Select(b => b.AccessBodyInternal()).ToArray(); + + var pinHandle = GCHandle.Alloc(array, GCHandleType.Pinned); + + try + { + NativeMethods.PhysicsBodySetCollisionIgnores(AccessWorldInternal(), body.AccessBodyInternal(), + pinHandle.AddrOfPinnedObject(), size); + } + finally + { + pinHandle.Free(); + } + } + else + { + Span nativePointers = stackalloc IntPtr[ignoredBodies.Count]; + + for (int i = 0; i < size; ++i) + { + nativePointers[i] = ignoredBodies[i].AccessBodyInternal(); + } + + NativeMethods.PhysicsBodySetCollisionIgnores(AccessWorldInternal(), body.AccessBodyInternal(), + MemoryMarshal.GetReference(nativePointers), size); + } + } + + public void BodySetCollisionIgnores(NativePhysicsBody body, NativePhysicsBody singleIgnoredBody) + { + NativeMethods.PhysicsBodyClearAndSetSingleIgnore(AccessWorldInternal(), body.AccessBodyInternal(), + singleIgnoredBody.AccessBodyInternal()); + } + + public PhysicsCollision[] BodyStartCollisionRecording(NativePhysicsBody body, int maxRecordedCollisions, + out IntPtr receiverOfAddressOfCollisionCount) + { + if (maxRecordedCollisions < 1) + throw new ArgumentException("Need to record at least one collision", nameof(maxRecordedCollisions)); + + var (collisionsArray, arrayAddress) = + body.SetupCollisionRecording(maxRecordedCollisions); + + receiverOfAddressOfCollisionCount = NativeMethods.PhysicsBodyEnableCollisionRecording(AccessWorldInternal(), + body.AccessBodyInternal(), arrayAddress, maxRecordedCollisions); + + return collisionsArray; + } + + public void BodyStopCollisionRecording(NativePhysicsBody body) + { + body.NotifyCollisionRecordingStopped(); + NativeMethods.PhysicsBodyDisableCollisionRecording(AccessWorldInternal(), body.AccessBodyInternal()); + } + + public void BodyAddCollisionFilter(NativePhysicsBody body, OnCollisionFilterCallback filterCallback) + { + NativeMethods.PhysicsBodyAddCollisionFilter(AccessWorldInternal(), body.AccessBodyInternal(), filterCallback); + } + + public void BodyDisableCollisionFilter(NativePhysicsBody body) + { + NativeMethods.PhysicsBodyDisableCollisionFilter(AccessWorldInternal(), body.AccessBodyInternal()); + } + + public void SetGravity(JVecF3? gravity = null) + { + gravity ??= new JVecF3(0.0f, -9.81f, 0.0f); + + NativeMethods.PhysicalWorldSetGravity(AccessWorldInternal(), gravity.Value); + } + + public void RemoveGravity() + { + NativeMethods.PhysicalWorldRemoveGravity(AccessWorldInternal()); + } + + /// + /// Casts a ray from start to (start + directionAndLength) collecting all hit objects in results + /// + /// Start world point + /// Vector to add to start to get to the end point + /// Will be filled with the hit objects. Needs to have size greater than 0 + /// The number of hits in results, all other array indexes are left untouched + public int CastRayGetAllHits(Vector3 start, Vector3 directionAndLength, PhysicsRayWithUserData[] results) + { + return NativeMethods.PhysicalWorldCastRayGetAll(AccessWorldInternal(), new JVec3(start), + new JVecF3(directionAndLength), ref results[0], results.Length); + } + + /// + /// Variant of raycast that automatically rents an array from the buffer pool, which must be returned with + /// after use. + /// + public int CastRayGetAllHits(Vector3 start, Vector3 directionAndLength, int maxHits, + out PhysicsRayWithUserData[] results) + { + results = ArrayPool.Shared.Rent(maxHits); + + return CastRayGetAllHits(start, directionAndLength, results); + } + + /// + /// Return a buffer from raycasting + /// + public void ReturnRayCastBuffer(PhysicsRayWithUserData[] buffer) + { + ArrayPool.Shared.Return(buffer); + } + + public bool DumpPhysicsState(string path) + { + return NativeMethods.PhysicalWorldDumpPhysicsState(AccessWorldInternal(), path); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal IntPtr AccessWorldInternal() + { + if (disposed) + throw new ObjectDisposedException(nameof(PhysicalWorld)); + + return nativeInstance; + } + + protected virtual void Dispose(bool disposing) + { + ReleaseUnmanagedResources(); + if (disposing) + { + DebugDrawer.Instance.OnPhysicsDebugLevelChangedHandler -= SetUpdatedDebugLevel; + + disposed = true; + } + } + + private void ReleaseUnmanagedResources() + { + if (nativeInstance.ToInt64() != 0) + { + NativeMethods.DestroyPhysicalWorld(nativeInstance); + nativeInstance = new IntPtr(0); + } + } + + private void SetUpdatedDebugLevel(int level) + { + if (nativeInstance.ToInt64() != 0) + { + NativeMethods.PhysicalWorldSetDebugDrawLevel(AccessWorldInternal(), level); + } + } + + private void UpdateDebugCameraInfo(Vector3 position) + { + if (nativeInstance.ToInt64() != 0) + { + NativeMethods.PhysicalWorldSetDebugDrawCameraLocation(AccessWorldInternal(), new JVecF3(position)); + } + } +} + +/// +/// Thrive native library methods related to physics worlds +/// +internal static partial class NativeMethods +{ + [DllImport("thrive_native")] + internal static extern IntPtr CreatePhysicalWorld(); + + [DllImport("thrive_native")] + internal static extern void DestroyPhysicalWorld(IntPtr physicalWorld); + + [DllImport("thrive_native")] + internal static extern bool ProcessPhysicalWorld(IntPtr physicalWorld, float delta); + + [DllImport("thrive_native")] + internal static extern IntPtr PhysicalWorldCreateMovingBody(IntPtr physicalWorld, IntPtr shape, + JVec3 position, JQuat rotation, bool addToWorld); + + [DllImport("thrive_native")] + internal static extern IntPtr PhysicalWorldCreateMovingBodyWithAxisLock(IntPtr physicalWorld, IntPtr shape, + JVec3 position, JQuat rotation, JVecF3 lockedAxes, bool lockRotation, bool addToWorld); + + [DllImport("thrive_native")] + internal static extern IntPtr PhysicalWorldCreateStaticBody(IntPtr physicalWorld, IntPtr shape, + JVec3 position, JQuat rotation, bool addToWorld); + + [DllImport("thrive_native")] + internal static extern void PhysicalWorldAddBody(IntPtr physicalWorld, IntPtr body, bool activate); + + [DllImport("thrive_native")] + internal static extern void PhysicalWorldDetachBody(IntPtr physicalWorld, IntPtr body); + + [DllImport("thrive_native")] + internal static extern void DestroyPhysicalWorldBody(IntPtr physicalWorld, IntPtr body); + + [DllImport("thrive_native")] + internal static extern void SetPhysicsBodyLinearDamping(IntPtr physicalWorld, IntPtr body, float damping); + + [DllImport("thrive_native")] + internal static extern void SetPhysicsBodyLinearAndAngularDamping(IntPtr physicalWorld, IntPtr body, + float linearDamping, float angularDamping); + + [DllImport("thrive_native")] + internal static extern void ReadPhysicsBodyTransform(IntPtr world, IntPtr body, [Out] out JVec3 position, + [Out] out JQuat orientation); + + [DllImport("thrive_native")] + internal static extern void ReadPhysicsBodyVelocity(IntPtr world, IntPtr body, [Out] out JVecF3 velocity, + [Out] out JVecF3 angularVelocity); + + [DllImport("thrive_native")] + internal static extern void GiveImpulse(IntPtr world, IntPtr body, JVecF3 impulse); + + [DllImport("thrive_native")] + internal static extern void GiveAngularImpulse(IntPtr world, IntPtr body, JVecF3 angularImpulse); + + [DllImport("thrive_native")] + internal static extern void SetBodyControl(IntPtr world, IntPtr body, JVecF3 movementImpulse, + JQuat targetRotation, float reachTargetInSeconds); + + [DllImport("thrive_native")] + internal static extern void DisableBodyControl(IntPtr world, IntPtr body); + + [DllImport("thrive_native")] + internal static extern void SetBodyPosition(IntPtr world, IntPtr body, JVec3 position, bool activate = true); + + [DllImport("thrive_native")] + internal static extern void SetBodyVelocity(IntPtr world, IntPtr body, JVecF3 velocity); + + [DllImport("thrive_native")] + internal static extern void SetBodyAngularVelocity(IntPtr world, IntPtr body, JVecF3 angularVelocity); + + [DllImport("thrive_native")] + internal static extern void SetBodyVelocityAndAngularVelocity(IntPtr world, IntPtr body, JVecF3 velocity, + JVecF3 angularVelocity); + + [DllImport("thrive_native")] + internal static extern void SetBodyAllowSleep(IntPtr world, IntPtr body, bool allowSleep); + + [DllImport("thrive_native")] + internal static extern bool FixBodyYCoordinateToZero(IntPtr world, IntPtr body); + + [DllImport("thrive_native")] + internal static extern void ChangeBodyShape(IntPtr world, IntPtr body, IntPtr shape, bool activate); + + [DllImport("thrive_native")] + internal static extern IntPtr PhysicsBodyAddAxisLock(IntPtr physicalWorld, IntPtr body, JVecF3 axis, + bool lockRotation); + + [DllImport("thrive_native")] + internal static extern void PhysicsBodySetCollisionEnabledState(IntPtr physicalWorld, + IntPtr body, bool collisionsEnabled); + + [DllImport("thrive_native")] + internal static extern void PhysicsBodyAddCollisionIgnore(IntPtr physicalWorld, IntPtr body, + IntPtr addIgnore); + + [DllImport("thrive_native")] + internal static extern void PhysicsBodyRemoveCollisionIgnore(IntPtr physicalWorld, IntPtr body, + IntPtr removeIgnore); + + [DllImport("thrive_native")] + internal static extern void PhysicsBodyClearCollisionIgnores(IntPtr physicalWorld, IntPtr body); + + [DllImport("thrive_native")] + internal static extern void PhysicsBodySetCollisionIgnores(IntPtr physicalWorld, IntPtr body, + in IntPtr ignoredBodies, int count); + + [DllImport("thrive_native")] + internal static extern void PhysicsBodyClearAndSetSingleIgnore(IntPtr physicalWorld, + IntPtr body, IntPtr onlyIgnoredBody); + + [DllImport("thrive_native")] + internal static extern IntPtr PhysicsBodyEnableCollisionRecording(IntPtr physicalWorld, IntPtr body, + IntPtr collisionRecordingTarget, int maxRecordedCollisions); + + [DllImport("thrive_native")] + internal static extern void PhysicsBodyDisableCollisionRecording(IntPtr physicalWorld, IntPtr body); + + [DllImport("thrive_native")] + internal static extern void PhysicsBodyAddCollisionFilter(IntPtr physicalWorld, IntPtr body, + PhysicalWorld.OnCollisionFilterCallback callback); + + [DllImport("thrive_native")] + internal static extern void PhysicsBodyDisableCollisionFilter(IntPtr physicalWorld, IntPtr body); + + [DllImport("thrive_native")] + internal static extern void PhysicalWorldSetGravity(IntPtr physicalWorld, JVecF3 gravity); + + [DllImport("thrive_native")] + internal static extern void PhysicalWorldRemoveGravity(IntPtr physicalWorld); + + [DllImport("thrive_native")] + internal static extern int PhysicalWorldCastRayGetAll(IntPtr physicalWorld, JVec3 start, + JVecF3 endOffset, ref PhysicsRayWithUserData dataReceiver, int maxHits); + + [DllImport("thrive_native")] + internal static extern float PhysicalWorldGetPhysicsLatestTime(IntPtr physicalWorld); + + [DllImport("thrive_native")] + internal static extern float PhysicalWorldGetPhysicsAverageTime(IntPtr physicalWorld); + + [DllImport("thrive_native", CharSet = CharSet.Ansi, BestFitMapping = false)] + internal static extern bool PhysicalWorldDumpPhysicsState(IntPtr physicalWorld, string path); + + [DllImport("thrive_native")] + internal static extern void PhysicalWorldSetDebugDrawLevel(IntPtr physicalWorld, int level); + + [DllImport("thrive_native")] + internal static extern void PhysicalWorldSetDebugDrawCameraLocation(IntPtr physicalWorld, JVecF3 position); +} diff --git a/src/engine/physics/PhysicsCollision.cs b/src/engine/physics/PhysicsCollision.cs new file mode 100644 index 00000000000..8bdeb6b3b3f --- /dev/null +++ b/src/engine/physics/PhysicsCollision.cs @@ -0,0 +1,59 @@ +using System; +using System.Runtime.InteropServices; +using DefaultEcs; + +/// +/// Info regarding a physics collision in an entity simulation. Must match the PhysicsCollision class byte layout +/// defined on the C++ side. +/// +[StructLayout(LayoutKind.Sequential)] +public struct PhysicsCollision +{ + /// + /// When a sub shape data is equal to this, the shape is unknown and not a sub-shape. This must match what the + /// native side has defined. + /// + public const uint COLLISION_UNKNOWN_SUB_SHAPE = uint.MaxValue; + + // Native code side handles writing to these objects + // ReSharper disable UnassignedReadonlyField + + // The fields are here in an order optimized to minimize padding and not grouped logically + + /// + /// The first entity participating in this collision. This is a bitwise copy of the entity identifier of the + /// entity this physics body was created for. Note that the ordering is always guaranteed so that the entity + /// recording the collision or checking inside a collision filter is always the first body. So code does not have + /// to check if the first or second entity is the entity that created this collision object. + /// + public readonly Entity FirstEntity; + + public readonly Entity SecondEntity; + + /// + /// First colliding body, this is not wrapped in a to avoid extra reference + /// counting and object allocations, rather this is directly the pointer to the native side body + /// + public readonly IntPtr FirstBody; + + public readonly IntPtr SecondBody; + + /// + /// Physics sub-shape data for this collision. Unknown (uint.Max) when used in a collision filter. + /// + public readonly uint FirstSubShapeData; + + public readonly uint SecondSubShapeData; + + /// + /// How hard the collision is. This is not calculated in the collision filter + /// + public readonly float PenetrationAmount; + + /// + /// True on the first physics update this collision appeared (always true in the collision filter) + /// + public readonly bool JustStarted; + + // ReSharper restore UnassignedReadonlyField +} diff --git a/src/engine/physics/PhysicsRayWithUserData.cs b/src/engine/physics/PhysicsRayWithUserData.cs new file mode 100644 index 00000000000..266883a5c15 --- /dev/null +++ b/src/engine/physics/PhysicsRayWithUserData.cs @@ -0,0 +1,31 @@ +using System; +using System.Runtime.InteropServices; +using DefaultEcs; + +/// +/// Info regarding a physics ray hit. Must match the PhysicsRayWithUserData class byte layout defined on the +/// native side. +/// +[StructLayout(LayoutKind.Sequential)] +public struct PhysicsRayWithUserData +{ + /// + /// The hit entity. Maybe be 0 bytes if hits a physics object not created through the ECS simulation + /// + public readonly Entity BodyEntity; + + /// + /// Raw pointer that is not wrapped in a for performance reasons + /// + public readonly IntPtr Body; + + /// + /// Sub-shape that was hit. Equals if unknown. + /// + public readonly uint SubShapeData; + + /// + /// How far along the cast ray this hit was (as a fraction of the total ray length) + /// + public readonly float HitFraction; +} diff --git a/src/engine/physics/PhysicsShape.cs b/src/engine/physics/PhysicsShape.cs new file mode 100644 index 00000000000..489a53a9e0f --- /dev/null +++ b/src/engine/physics/PhysicsShape.cs @@ -0,0 +1,284 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Godot; + +/// +/// Wrapper for native side physics shape. And also contains factories for making various shapes. +/// +public class PhysicsShape : IDisposable +{ + private bool disposed; + private IntPtr nativeInstance; + + private PhysicsShape(IntPtr nativeInstance) + { + this.nativeInstance = nativeInstance; + } + + ~PhysicsShape() + { + Dispose(false); + } + + public static PhysicsShape CreateBox(float halfSideLength, float density = 1000) + { + return new PhysicsShape(NativeMethods.CreateBoxShape(halfSideLength, density)); + } + + public static PhysicsShape CreateBox(Vector3 halfDimensions, float density = 1000) + { + return new PhysicsShape(NativeMethods.CreateBoxShapeWithDimensions(new JVecF3(halfDimensions), density)); + } + + public static PhysicsShape CreateSphere(float radius, float density = 1000) + { + return new PhysicsShape(NativeMethods.CreateSphereShape(radius, density)); + } + + public static PhysicsShape CreateCylinder(float halfHeight, float radius, float density = 1000) + { + return new PhysicsShape(NativeMethods.CreateCylinderShape(halfHeight, radius, density)); + } + + /// + /// Creates a microbe shape or returns from one from the cache + /// + /// The shape made from a convex body for the microbe + public static PhysicsShape GetOrCreateMicrobeShape(IReadOnlyList membranePoints, int pointCount, + float overallDensity, bool scaleAsBacteria) + { + var cache = ProceduralDataCache.Instance; + + var hash = MembraneCollisionShape.ComputeMicrobeShapeCacheHash(membranePoints, pointCount, + overallDensity, scaleAsBacteria); + + var result = cache.ReadMembraneCollisionShape(hash); + + if (result != null) + { + if (result.MatchesCacheParameters(membranePoints, pointCount, overallDensity, scaleAsBacteria)) + { + return result.Shape; + } + + CacheableDataExtensions.OnCacheHashCollision(hash); + } + + // Need to convert the data to call the method that uses the native side to create the body + // TODO: find out if a more performant way can be done to copy this data or not (luckily only needed when cache + // is missing data for this membrane) + var convertedData = ArrayPool.Shared.Rent(membranePoints.Count); + + for (int i = 0; i < pointCount; ++i) + { + convertedData[i] = new JVecF3(membranePoints[i].x, 0, membranePoints[i].y); + } + + // The rented array from the pool will be returned when the cache entry is disposed + result = new MembraneCollisionShape( + CreateMicrobeShape(new ReadOnlySpan(convertedData, 0, pointCount), overallDensity, scaleAsBacteria), + convertedData, pointCount, overallDensity, scaleAsBacteria); + + cache.WriteMembraneCollisionShape(result); + + return result.Shape; + } + + // TODO: hashing and caching based on the parameters to avoid needing to constantly create new shapes + public static PhysicsShape CreateMicrobeShape(ReadOnlySpan organellePositions, float overallDensity, + bool scaleAsBacteria, bool createAsSpheres = false) + { + if (createAsSpheres) + { + return new PhysicsShape(NativeMethods.CreateMicrobeShapeSpheres( + MemoryMarshal.GetReference(organellePositions), + (uint)organellePositions.Length, overallDensity, scaleAsBacteria ? 0.5f : 1)); + } + + return new PhysicsShape(NativeMethods.CreateMicrobeShapeConvex( + MemoryMarshal.GetReference(organellePositions), + (uint)organellePositions.Length, overallDensity, scaleAsBacteria ? 0.5f : 1)); + } + + public static PhysicsShape CreateCombinedShapeStatic( + IReadOnlyList<(PhysicsShape Shape, Vector3 Position, Quat Rotation)> subShapes) + { + var pool = ArrayPool.Shared; + + // Need some temporary memory to hold the sub-shapes in + var count = subShapes.Count; + var buffer = pool.Rent(count); + + try + { + for (int i = 0; i < count; ++i) + { + var data = subShapes[i]; + buffer[i] = new SubShapeDefinition(data.Position, data.Rotation, data.Shape.AccessShapeInternal()); + } + + // TODO: does this need to fix the buffer memory? + return new PhysicsShape(NativeMethods.CreateStaticCompoundShape(buffer[0], (uint)count)); + + // return new PhysicsShape(NativeMethods.CreateStaticCompoundShape(pin.AddrOfPinnedObject(), (uint)count)); + } + finally + { + pool.Return(buffer); + } + } + + /// + /// Loads a physics shape from a Godot resource + /// + /// Path to the Godot resource + /// + /// The density of the created body. Note that this avoid caching if the same shape has different density so + /// avoid slight density changes if they wouldn't have any concrete impact anyway + /// + /// The loaded shape or null if there is an error processing + public static PhysicsShape? CreateShapeFromGodotResource(string path, float density) + { + var cache = ProceduralDataCache.Instance; + + var cached = cache.ReadLoadedShape(path, density); + + if (cached != null) + return cached; + + // TODO: pre-bake collision shapes for game export (the fallback conversion below should only need to be used + // when debugging to make the release version perform better) + + var godotData = GD.Load(path); + + if (godotData == null) + { + // TODO: support for other shapes if we need them + GD.PrintErr("Failed to load Godot physics shape for converting: ", path); + return null; + } + + var dataSource = godotData.Points; + int points = dataSource.Length; + + if (points < 1) + throw new NotSupportedException("Can't convert convex polygon with no points"); + + // We need a temporary buffer in case the data byte format is not exactly right + var pool = ArrayPool.Shared; + var buffer = pool.Rent(points); + + for (int i = 0; i < points; ++i) + { + buffer[i] = new JVecF3(dataSource[i]); + } + + // TODO: does this need to fix the buffer memory? + cached = new PhysicsShape(NativeMethods.CreateConvexShape(buffer[0], (uint)points, density)); + + cache.WriteLoadedShape(path, density, cached); + + pool.Return(buffer); + + return cached; + } + + /// + /// Gets the mass of this shape, unit size of normal density has mass of 1000 so in most cases the mass should be + /// divided by 1000 for processing purposes (though physics forces will work correctly with unadjusted values) + /// + /// The mass + public float GetMass() + { + return NativeMethods.ShapeGetMass(AccessShapeInternal()); + } + + /// + /// Calculates how much angular velocity this shape would get given the torque (based on this shapes rotational + /// inertia) + /// + /// The raw torque to apply + /// Resulting angular velocities around the same axes as the torque was given in + public Vector3 CalculateResultingTorqueFromInertia(Vector3 torque) + { + return NativeMethods.ShapeCalculateResultingAngularVelocity(AccessShapeInternal(), new JVecF3(torque)); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal IntPtr AccessShapeInternal() + { + if (disposed) + throw new ObjectDisposedException(nameof(PhysicsShape)); + + return nativeInstance; + } + + protected virtual void Dispose(bool disposing) + { + ReleaseUnmanagedResources(); + if (disposing) + { + disposed = true; + } + } + + private void ReleaseUnmanagedResources() + { + if (nativeInstance.ToInt64() != 0) + { + NativeMethods.ReleaseShape(nativeInstance); + nativeInstance = new IntPtr(0); + } + } +} + +/// +/// Thrive native library methods related to physics shapes +/// +internal static partial class NativeMethods +{ + [DllImport("thrive_native")] + internal static extern IntPtr CreateBoxShape(float halfSideLength, float density); + + [DllImport("thrive_native")] + internal static extern IntPtr CreateBoxShapeWithDimensions(JVecF3 halfDimensions, float density); + + [DllImport("thrive_native")] + internal static extern IntPtr CreateSphereShape(float radius, float density); + + [DllImport("thrive_native")] + internal static extern IntPtr CreateCylinderShape(float halfHeight, float radius, float density); + + [DllImport("thrive_native")] + internal static extern IntPtr CreateMicrobeShapeConvex(in JVecF3 microbePoints, uint pointCount, float density, + float scale, float thickness = 1); + + [DllImport("thrive_native")] + internal static extern IntPtr CreateMicrobeShapeSpheres(in JVecF3 microbePoints, uint pointCount, float density, + float scale); + + [DllImport("thrive_native")] + internal static extern IntPtr CreateConvexShape(in JVecF3 convexPoints, uint pointCount, float density); + + [DllImport("thrive_native")] + internal static extern IntPtr CreateStaticCompoundShape(in SubShapeDefinition subShapes, uint shapeCount); + + [DllImport("thrive_native")] + internal static extern void ReleaseShape(IntPtr shape); + + [DllImport("thrive_native")] + internal static extern float ShapeGetMass(IntPtr shape); + + [DllImport("thrive_native")] + internal static extern JVecF3 ShapeCalculateResultingAngularVelocity(IntPtr shape, JVecF3 appliedTorque, + float deltaTime = 1); +} diff --git a/src/general/GameWorld.cs b/src/general/GameWorld.cs index ddb558441c1..2a5f379e009 100644 --- a/src/general/GameWorld.cs +++ b/src/general/GameWorld.cs @@ -71,7 +71,7 @@ public GameWorld(WorldGenerationSettings settings, Species? startingSpecies = nu startingSpecies.OnEdited(); // Need to update the species ID in case it was different in a previous game - startingSpecies.OnBecomePartOfWorld(++speciesIdCounter); + startingSpecies.OnBecomePartOfWorld(GetNextSpeciesID()); worldSpecies[startingSpecies.ID] = startingSpecies; PlayerSpecies = startingSpecies; @@ -221,7 +221,7 @@ public void AddCurrentGenerationToHistory() /// public MicrobeSpecies NewMicrobeSpecies(string genus, string epithet) { - var species = new MicrobeSpecies(++speciesIdCounter, genus, epithet); + var species = new MicrobeSpecies(GetNextSpeciesID(), genus, epithet); worldSpecies[species.ID] = species; return species; @@ -295,7 +295,8 @@ public Species CreateMutatedSpecies(Species species) } /// - /// Registers a species created by auto-evo in this world. Updates the ID + /// Registers a species created by auto-evo in this world. Updates the ID to ensure it is unique and valid for + /// this world. /// /// The species to register public void RegisterAutoEvoCreatedSpecies(Species species) @@ -303,7 +304,7 @@ public void RegisterAutoEvoCreatedSpecies(Species species) if (worldSpecies.Any(p => p.Value == species)) throw new ArgumentException("Species is already in this world"); - species.OnBecomePartOfWorld(++speciesIdCounter); + species.OnBecomePartOfWorld(GetNextSpeciesID()); worldSpecies[species.ID] = species; } @@ -685,6 +686,22 @@ private void CreateRunIfMissing() autoEvo = AutoEvo.AutoEvo.CreateRun(this); } + private uint GetNextSpeciesID() + { + var speciesId = ++speciesIdCounter; + + // Guard against running out of IDs + if (speciesId == uint.MaxValue) + { + // TODO: implement species ID sequence reset (need to find unused ranges of species ID numbers for this + // world + throw new NotImplementedException( + "World ran out of species ID numbers and restarting sequence is not implemented"); + } + + return speciesId; + } + private void SwitchSpecies(Species old, Species newSpecies) { GD.Print("Moving species ", old.FormattedIdentifier, " from ", old.GetType().Name, " to ", diff --git a/src/general/IAliveTracked.cs b/src/general/IAliveTracked.cs new file mode 100644 index 00000000000..f0475c745cf --- /dev/null +++ b/src/general/IAliveTracked.cs @@ -0,0 +1,29 @@ +using Newtonsoft.Json; + +/// +/// Any kind of object that is tracked to be alive or dead, allows to work +/// +[JSONAlwaysDynamicType] +public interface IAliveTracked +{ + /// + /// Gets an alive marker associated with this entity (object). When this is Freed or QueueFreed this marker needs + /// to be set to non-alive state if this is a Godot entity. In other cases use the relevant destruction logic + /// for that object type. + /// + /// + /// + /// For now this is JSON ignored as all objects that refer to each other can easily re-grab the alive marker. + /// + /// + [JsonIgnore] + public AliveMarker AliveMarker { get; } + + public void OnDestroyed(); + + // TODO: have this implementation here (and also for AliveMarker) once Godot updates their dotnet runtime version + // requirement, currently this doesn't compile if this default implementation is uncommented + /*{ + AliveMarker.Alive = false; + }*/ +} diff --git a/src/general/IEntity.cs b/src/general/IEntity.cs index 2ac44bb6ea1..022bea44048 100644 --- a/src/general/IEntity.cs +++ b/src/general/IEntity.cs @@ -2,33 +2,14 @@ using Newtonsoft.Json; /// -/// All game entities implement this interface to provide support for needed operations regarding them +/// All Godot-based game entities implement this interface to provide support for needed operations regarding them. +/// For other simulated entities see . /// -public interface IEntity +public interface IEntity : IAliveTracked { - /// - /// Gets an alive marker associated with this entity. When this is Freed or QueueFreed this marker needs to be - /// set to non-alive state. - /// - /// - /// - /// For now this is JSON ignored as all objects that refer to each other can easily re-grab the alive marker. - /// - /// - [JsonIgnore] - public AliveMarker AliveMarker { get; } - /// /// The Node that this entity is in the game world as /// [JsonIgnore] public Spatial EntityNode { get; } - - public void OnDestroyed(); - - // TODO: have this implementation here (and also for AliveMarker) once Godot updates their dotnet runtime version - // requirement, currently this doesn't compile if this default implementation is uncommented - /*{ - AliveMarker.Alive = false; - }*/ } diff --git a/src/general/IInspectableEntity.cs b/src/general/IInspectableEntity.cs deleted file mode 100644 index 07082f0579c..00000000000 --- a/src/general/IInspectableEntity.cs +++ /dev/null @@ -1,22 +0,0 @@ -/// -/// A game entity the player can inspect (with mouse), for querying information and displayed on GUI -/// -/// -/// -/// NOTE: Entity must have collision shapes thus be a collision object to be detected. -/// -/// -public interface IInspectableEntity : IEntity, IPlayerReadableName -{ - /// - /// Called when a raycast hits this entity. - /// - /// The raycast data. - public void OnMouseEnter(RaycastResult raycastResult); - - /// - /// Called when a raycast no longer hits this entity. - /// - /// The data of the last intersecting raycast. - public void OnMouseExit(RaycastResult raycastResult); -} diff --git a/src/general/ITimedLife.cs b/src/general/ITimedLife.cs deleted file mode 100644 index 18c5f4206d4..00000000000 --- a/src/general/ITimedLife.cs +++ /dev/null @@ -1,9 +0,0 @@ -/// -/// All nodes that despawn after some time need to implement this. -/// -public interface ITimedLife -{ - public float TimeToLiveRemaining { get; set; } - - public void OnTimeOver(); -} diff --git a/src/general/PlayerInspectInfo.cs b/src/general/PlayerInspectInfo.cs index a1282aace01..e16a97d4154 100644 --- a/src/general/PlayerInspectInfo.cs +++ b/src/general/PlayerInspectInfo.cs @@ -1,6 +1,7 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; +using Components; +using DefaultEcs; using Godot; /// @@ -14,36 +15,52 @@ public class PlayerInspectInfo : Node [Export] public float RaycastDistance = 1000; - private readonly List hits = new(); - private readonly HashSet previousHits = new(); + private readonly PhysicsRayWithUserData[] hits = new PhysicsRayWithUserData[Constants.MAX_RAY_HITS_FOR_INSPECT]; + private readonly HashSet previousHits = new(); + + private int validHits; + + /// + /// Needs to be set to the physical world to use + /// + public PhysicalWorld? PhysicalWorld { get; set; } /// - /// All inspectable entities the player is pointing at. + /// All (physics) entities the player is pointing at. /// - public IEnumerable InspectableEntities => - hits.Select(h => h.Collider).OfType(); + public IEnumerable Entities => + hits.Take(validHits).Where(h => h.BodyEntity != default).Select(h => h.BodyEntity); public virtual void Process(float delta) { var viewport = GetViewport(); - var space = viewport.World.DirectSpaceState; var mousePos = viewport.GetMousePosition(); var camera = viewport.GetCamera(); - var from = camera.ProjectRayOrigin(mousePos); - var to = from + camera.ProjectRayNormal(mousePos) * RaycastDistance; + // Safety check to disable this node when there's no active camera + if (camera == null) + return; + + if (PhysicalWorld == null) + { + GD.PrintErr($"{nameof(PlayerInspectInfo)} doesn't have physics world set"); + return; + } - hits.Clear(); + var from = camera.ProjectRayOrigin(mousePos); + var offsetToEnd = camera.ProjectRayNormal(mousePos) * RaycastDistance; - space.IntersectRay(hits, from, to); + validHits = PhysicalWorld.CastRayGetAllHits(from, offsetToEnd, hits); previousHits.RemoveWhere(m => { - if (!hits.Contains(m)) + if (hits.Take(validHits).All(h => h.BodyEntity != m)) { - if (m.Collider is IInspectableEntity entity) + // Hit removed + if (m.IsAlive && m.Has()) { - entity.OnMouseExit(m); + ref var selectable = ref m.Get(); + selectable.Selected = false; } return true; @@ -52,31 +69,39 @@ public virtual void Process(float delta) return false; }); - foreach (var hit in hits) + foreach (var hit in hits.Take(validHits)) { - if (!previousHits.Add(hit)) + if (!previousHits.Add(hit.BodyEntity)) continue; - if (hit.Collider is IInspectableEntity entity) + // New hit added + + if (hit.BodyEntity.IsAlive && hit.BodyEntity.Has()) { - entity.OnMouseEnter(hit); + ref var selectable = ref hit.BodyEntity.Get(); + selectable.Selected = true; } } } /// - /// Returns the raycast data of the given raycast inspectable entity. + /// Returns the raycast data of the given raycast hit entity. /// - /// The raycast data or null if not found. - public RaycastResult? GetRaycastData(IInspectableEntity entity) + /// Entity to get the data for + /// Where to put the found ray data, initialized to default if not found + /// True when the data was found + public bool GetRaycastData(Entity entity, out PhysicsRayWithUserData rayData) { - try - { - return hits.First(h => h.Collider == entity); - } - catch (InvalidOperationException) + for (int i = 0; i < validHits; ++i) { - return null; + if (hits[i].BodyEntity == entity) + { + rayData = hits[i]; + return true; + } } + + rayData = default; + return false; } } diff --git a/src/general/SpawnQueue.cs b/src/general/SpawnQueue.cs new file mode 100644 index 00000000000..ad4da45fadf --- /dev/null +++ b/src/general/SpawnQueue.cs @@ -0,0 +1,232 @@ +using System; +using System.Collections.Generic; +using DefaultEcs.Command; +using Godot; + +/// +/// Spawn queue used by to facilitate spawning entities in limited amount per frame but still +/// allowing spawners to create big spawn groups that spawn over multiple frames +/// +public abstract class SpawnQueue : IDisposable +{ + protected SpawnQueue(Spawner relatedSpawnType) + { + RelatedSpawnType = relatedSpawnType; + } + + /// + /// The spawn type used in this queue. This needs to be known by the spawn system when spawning things over + /// multiple frames. + /// + public Spawner RelatedSpawnType { get; } + + /// + /// True once this queue has ended and should be destroyed + /// + public abstract bool Ended { get; protected set; } + + /// + /// Returns true if a potential spawn location is too close to the player + /// + /// The location to check + /// Where the player is (approximately) known to be + /// True if spawn should be skipped + public static bool IsTooCloseToPlayer(Vector3 spawnLocation, Vector3 playerPosition) + { + if ((playerPosition - spawnLocation).Length() < Constants.MIN_DISTANCE_FROM_PLAYER_FOR_SPAWN) + { + return true; + } + + return false; + } + + /// + /// Prunes too close spawn locations from list + /// + /// The location list to prune + /// Approximate player position + /// True if there are no more valid positions and the list is now empty + public static bool PruneSpawnListPositions(IList positions, Vector3 playerPosition) + { + while (true) + { + if (positions.Count < 1) + return true; + + if (IsTooCloseToPlayer(positions[0], playerPosition)) + { + positions.RemoveAt(0); + } + else + { + // Found a valid position + return false; + } + } + } + + public abstract (EntityCommandRecorder CommandRecorder, float SpawnedWeight) SpawnNext(out EntityRecord entity); + + /// + /// Checks that this spawn queue is still good to spawn from + /// + /// + /// The player position to check against. If spawning is too close to the player it should be skipped. + /// + /// + /// + /// Derived classes should set to true if they override this and determine a spawn + /// shouldn't happen + /// + /// + public virtual void CheckIsSpawningStillPossible(Vector3 playerPosition) + { + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + _ = disposing; + + // Ensure this is ended so that no one calls SpawnNext again + Ended = true; + } +} + +/// +/// A spawn queue that can just spawn a single item (from a callback) +/// +public class SingleItemSpawnQueue : SpawnQueue +{ + private readonly Factory factory; + + public SingleItemSpawnQueue(Factory factory, Spawner fromSpawnType) : base(fromSpawnType) + { + this.factory = factory; + } + + public delegate (EntityCommandRecorder CommandRecorder, float SpawnedWeight) Factory(out EntityRecord entity); + + public override bool Ended { get; protected set; } + + public override (EntityCommandRecorder CommandRecorder, float SpawnedWeight) SpawnNext(out EntityRecord entity) + { + Ended = true; + return factory(out entity); + } +} + +/// +/// A callback based spawn queue with multiple items +/// +/// The state to pass to the spawn function +public class CallbackSpawnQueue : SpawnQueue +{ + private readonly Factory factory; + private readonly CheckTooCloseToPlayer cancelCheck; + private T stateData; + + public CallbackSpawnQueue(Factory factory, T initialData, CheckTooCloseToPlayer cancelCheck, Spawner fromSpawnType) + : base(fromSpawnType) + { + this.factory = factory; + stateData = initialData; + this.cancelCheck = cancelCheck; + } + + public delegate (EntityCommandRecorder Recorder, float SpawnedWeight, bool LastItem) Factory(T state, + out EntityRecord entity); + + public delegate bool CheckTooCloseToPlayer(T state, Vector3 playerPosition); + + public override bool Ended { get; protected set; } + + public override (EntityCommandRecorder CommandRecorder, float SpawnedWeight) SpawnNext(out EntityRecord entity) + { + var (recorder, weight, ended) = factory(stateData, out entity); + + if (ended) + Ended = true; + + return (recorder, weight); + } + + public override void CheckIsSpawningStillPossible(Vector3 playerPosition) + { + if (cancelCheck(stateData, playerPosition)) + { + Ended = true; + } + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (disposing) + { + if (stateData is IDisposable disposable) + { + disposable.Dispose(); + } + } + } +} + +/// +/// Spawn queue that consists of other spawn queues +/// +public class CombinedSpawnQueue : SpawnQueue +{ + private readonly SpawnQueue[] spawnQueues; + private int usedSpawnIndex; + + public CombinedSpawnQueue(params SpawnQueue[] spawns) : base(spawns[0].RelatedSpawnType) + { + spawnQueues = spawns; + } + + public override bool Ended + { + get => usedSpawnIndex >= spawnQueues.Length; + protected set + { + if (value == false) + throw new NotSupportedException("Can't reset spawn index"); + + usedSpawnIndex = int.MaxValue; + } + } + + public override (EntityCommandRecorder CommandRecorder, float SpawnedWeight) SpawnNext(out EntityRecord entity) + { + if (Ended) + throw new InvalidOperationException("Spawn queue has ended"); + + var result = spawnQueues[usedSpawnIndex].SpawnNext(out entity); + + // When one queue ends, we move onto the next one + if (spawnQueues[usedSpawnIndex].Ended) + ++usedSpawnIndex; + + return result; + } + + public override void CheckIsSpawningStillPossible(Vector3 playerPosition) + { + if (Ended) + return; + + spawnQueues[usedSpawnIndex].CheckIsSpawningStillPossible(playerPosition); + + // Automatically end the queue if CheckIsSpawningStillPossible set it to ended + if (spawnQueues[usedSpawnIndex].Ended) + ++usedSpawnIndex; + } +} diff --git a/src/general/Species.cs b/src/general/Species.cs index 692d48e37fb..e646e005ea3 100644 --- a/src/general/Species.cs +++ b/src/general/Species.cs @@ -40,7 +40,7 @@ protected Species(uint id, string genus, string epithet) /// The base compounds needed to reproduce an individual of this species. Do not modify the returned value. /// [JsonIgnore] - public Dictionary BaseReproductionCost => + public IReadOnlyDictionary BaseReproductionCost => cachedBaseReproductionCost ??= CalculateBaseReproductionCost(); public string Genus { get; set; } diff --git a/src/general/TimedLifeSystem.cs b/src/general/TimedLifeSystem.cs deleted file mode 100644 index 15aece35f0c..00000000000 --- a/src/general/TimedLifeSystem.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Godot; - -/// -/// System that deletes nodes that are in the timed group after their lifespan expires. -/// -public class TimedLifeSystem -{ - private readonly Node worldRoot; - - public TimedLifeSystem(Node worldRoot) - { - this.worldRoot = worldRoot; - } - - public void Process(float delta) - { - foreach (var entity in worldRoot.GetChildrenToProcess(Constants.TIMED_GROUP)) - { - var timed = entity as ITimedLife; - - if (timed == null) - { - GD.PrintErr("A node has been put in the timed group but it isn't derived from ITimedLife"); - continue; - } - - timed.TimeToLiveRemaining -= delta; - - if (timed.TimeToLiveRemaining <= 0.0f) - { - timed.OnTimeOver(); - } - } - } - - /// - /// Despawns all timed entities - /// - public void DespawnAll() - { - foreach (var entity in worldRoot.GetChildrenToProcess(Constants.TIMED_GROUP)) - { - if (entity.IsQueuedForDeletion()) - continue; - - var asProperEntity = entity as IEntity; - - if (asProperEntity == null) - { - entity.DetachAndQueueFree(); - continue; - } - - asProperEntity.DestroyDetachAndQueueFree(); - } - } -} diff --git a/src/general/base_stage/CreatureStageBase.cs b/src/general/base_stage/CreatureStageBase.cs index 117db841cb7..9d04e32feb0 100644 --- a/src/general/base_stage/CreatureStageBase.cs +++ b/src/general/base_stage/CreatureStageBase.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Components; using Godot; using Newtonsoft.Json; @@ -8,10 +9,11 @@ /// Base stage for the stages where the player controls a single creature /// /// The type of the player object +/// The type of simulation this stage uses [JsonObject(IsReference = true)] [UseThriveSerializer] -public abstract class CreatureStageBase : StageBase, ICreatureStage - where TPlayer : class +public abstract class CreatureStageBase : StageBase, ICreatureStage + where TSimulation : class, IWorldSimulation, new() { #pragma warning disable CA2213 protected DirectionalLight worldLight = null!; @@ -32,15 +34,19 @@ public abstract class CreatureStageBase : StageBase, ICreatureStage [JsonProperty] protected bool playerExtinctInCurrentPatch; + // TODO: eventually convert this just to a Entity without having any generic type configurability here /// /// The current player or null. - /// TODO: check: Due to references on save load this needs to be after the systems /// [JsonProperty] public TPlayer? Player { get; protected set; } [JsonIgnore] - public bool HasPlayer => Player != null; + public abstract bool HasPlayer { get; } + + [JsonProperty] + [AssignOnlyChildItemsOnDeserialize] + public TSimulation WorldSimulation { get; private set; } = new(); /// /// True when transitioning to the editor. Note this should only be unset *after* switching scenes to the editor @@ -177,9 +183,12 @@ public override void _Process(float delta) float totalEntityWeight = 0; int totalEntityCount = 0; - foreach (var entity in rootOfDynamicallySpawned.GetChildrenToProcess(Constants.SPAWNED_GROUP)) + foreach (var entity in WorldSimulation.EntitySystem) { - totalEntityWeight += entity.EntityWeight; + if (!entity.Has()) + continue; + + totalEntityWeight += entity.Get().EntityWeight; ++totalEntityCount; } diff --git a/src/general/base_stage/CreatureStageHUDBase.cs b/src/general/base_stage/CreatureStageHUDBase.cs index e09c2f433f5..85983838ec7 100644 --- a/src/general/base_stage/CreatureStageHUDBase.cs +++ b/src/general/base_stage/CreatureStageHUDBase.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Components; using Godot; using Newtonsoft.Json; using Array = Godot.Collections.Array; @@ -130,6 +131,7 @@ public abstract class CreatureStageHUDBase : HUDWithPausing, ICreatureSt [Export] public NodePath SecreteSlimeHotkeyPath = null!; + // TODO: rename to SignalingAgentsHotkeyPath [Export] public NodePath SignallingAgentsHotkeyPath = null!; @@ -212,7 +214,7 @@ public abstract class CreatureStageHUDBase : HUDWithPausing, ICreatureSt protected GridContainer? environmentPanelBarContainer; protected ActionButton engulfHotkey = null!; protected ActionButton secreteSlimeHotkey = null!; - protected ActionButton signallingAgentsHotkey = null!; + protected ActionButton signalingAgentsHotkey = null!; protected ProgressBar oxygenBar = null!; protected ProgressBar co2Bar = null!; @@ -403,7 +405,7 @@ public override void _Ready() engulfHotkey = GetNode(EngulfHotkeyPath); secreteSlimeHotkey = GetNode(SecreteSlimeHotkeyPath); fireToxinHotkey = GetNode(FireToxinHotkeyPath); - signallingAgentsHotkey = GetNode(SignallingAgentsHotkeyPath); + signalingAgentsHotkey = GetNode(SignallingAgentsHotkeyPath); processPanel = GetNode(ProcessPanelPath); @@ -672,9 +674,15 @@ public void HideFossilisationButtons() /// The button attached to the organism to fossilise public void ShowFossilisationDialog(FossilisationButton button) { - if (button.AttachedEntity is Microbe microbe) + if (!button.AttachedEntity.IsAlive) { - fossilisationDialog.SelectedSpecies = microbe.Species; + GD.PrintErr("Tried to show fossilization dialog for a dead entity"); + return; + } + + if (button.AttachedEntity.Has()) + { + fossilisationDialog.SelectedSpecies = button.AttachedEntity.Get().Species; fossilisationDialog.PopupCenteredShrink(); } else @@ -1025,12 +1033,12 @@ protected void UpdatePanelSizing(float delta) engulfHotkey.Visible = showEngulf; fireToxinHotkey.Visible = showToxin; secreteSlimeHotkey.Visible = showSlime; - signallingAgentsHotkey.Visible = showingSignaling; + signalingAgentsHotkey.Visible = showingSignaling; engulfHotkey.Pressed = engulfOn; fireToxinHotkey.Pressed = Input.IsActionPressed(fireToxinHotkey.ActionName); secreteSlimeHotkey.Pressed = Input.IsActionPressed(secreteSlimeHotkey.ActionName); - signallingAgentsHotkey.Pressed = Input.IsActionPressed(signallingAgentsHotkey.ActionName); + signalingAgentsHotkey.Pressed = Input.IsActionPressed(signalingAgentsHotkey.ActionName); } protected void OpenMenu() diff --git a/src/general/base_stage/DummyWorldSimulation.cs b/src/general/base_stage/DummyWorldSimulation.cs new file mode 100644 index 00000000000..6ac0e526d1e --- /dev/null +++ b/src/general/base_stage/DummyWorldSimulation.cs @@ -0,0 +1,75 @@ +using System; +using DefaultEcs; +using DefaultEcs.Command; + +/// +/// For use in the prototypes not yet converted to using world simulations +/// +public class DummyWorldSimulation : IWorldSimulation +{ + public World EntitySystem { get; } = new(); + public bool Processing { get; set; } + + public Entity CreateEmptyEntity() + { + throw new NotSupportedException("Dummy simulation doesn't support adding entities"); + } + + public EntityRecord CreateEntityDeferred(WorldRecord activeRecording) + { + throw new NotSupportedException("Dummy simulation doesn't support adding entities"); + } + + public bool DestroyEntity(Entity entity) + { + return false; + } + + public void DestroyAllEntities(Entity? skip = null) + { + throw new NotImplementedException(); + } + + public void ReportEntityDyingSoon(in Entity entity) + { + } + + public bool IsEntityInWorld(Entity entity) + { + return false; + } + + public bool IsQueuedForDeletion(Entity entity) + { + return false; + } + + public EntityCommandRecorder StartRecordingEntityCommands() + { + // Technically we could support this but we'd need actually some logic in the process method of ours + throw new NotSupportedException("Dummy simulation doesn't support deferred commands"); + } + + public WorldRecord GetRecorderWorld(EntityCommandRecorder recorder) + { + throw new NotSupportedException("Dummy simulation doesn't support deferred commands"); + } + + public void FinishRecordingEntityCommands(EntityCommandRecorder recorder) + { + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + EntitySystem.Dispose(); + } + } +} diff --git a/src/general/base_stage/HexEditorComponentBase.cs b/src/general/base_stage/HexEditorComponentBase.cs index 769d44981ab..d2de438dedf 100644 --- a/src/general/base_stage/HexEditorComponentBase.cs +++ b/src/general/base_stage/HexEditorComponentBase.cs @@ -232,8 +232,6 @@ public override void Init(TEditor owningEditor, bool fresh) "This editor component was loaded from a save and is not fully functional"); } - camera.ObjectToFollow = cameraFollow; - if (fresh) { placementRotation = 0; @@ -304,6 +302,8 @@ public override void _Process(float delta) editorGrid.Translation = camera!.CursorWorldPos; editorGrid.Visible = Editor.ShowHover && !ForceHideHover; + + camera.UpdateCameraPosition(delta, cameraFollow.GlobalTranslation); } public void ResetSymmetryButton() diff --git a/src/general/base_stage/IEntityContainer.cs b/src/general/base_stage/IEntityContainer.cs new file mode 100644 index 00000000000..9e93655bc5b --- /dev/null +++ b/src/general/base_stage/IEntityContainer.cs @@ -0,0 +1,33 @@ +using DefaultEcs; + +/// +/// Anything that supports the entity management (creation, deletion) operations +/// +public interface IEntityContainer +{ + /// + /// Adds an entity to this simulation / container that is empty. Note not thread safe! + /// + public Entity CreateEmptyEntity(); + + /// + /// Destroys an entity (some simulations will queue destroys and only perform them at the end of the current + /// simulation frame) + /// + /// Entity to destroy + /// True when destroyed, false if the entity was not added + public bool DestroyEntity(Entity entity); + + /// + /// Destroys all entities in this container + /// + /// An optional entity to skip deleting + public void DestroyAllEntities(Entity? skip = null); + + /// + /// Reports that an entity will die soon and it should not be saved and loaded if the game is loaded before + /// gets called + /// + /// The entity that will die very soon + public void ReportEntityDyingSoon(in Entity entity); +} diff --git a/src/general/base_stage/IWorldSimulation.cs b/src/general/base_stage/IWorldSimulation.cs new file mode 100644 index 00000000000..08162740851 --- /dev/null +++ b/src/general/base_stage/IWorldSimulation.cs @@ -0,0 +1,62 @@ +using System; +using DefaultEcs; +using DefaultEcs.Command; + +/// +/// Interface for to give flexibility for swapping out things +/// +public interface IWorldSimulation : IEntityContainer, IDisposable +{ + /// + /// Access to the ECS system for adding and modifying components. Note that entity modification is not allowed + /// during certain system running. For that see + /// + public World EntitySystem { get; } + + /// + /// True if this is currently processing a simulation + /// + public bool Processing { get; } + + /// + /// Thread safe variant of + /// + /// Record of the deferred entity creation referring to it + public EntityRecord CreateEntityDeferred(WorldRecord activeRecording); + + /// + /// Checks that the entity is in this world and is not being deleted + /// + /// The entity to check + /// True when the entity is in this world and is not queued for deletion + public bool IsEntityInWorld(Entity entity); + + /// + /// Returns true when the given entity is queued for destruction + /// + public bool IsQueuedForDeletion(Entity entity); + + /// + /// Starts recording a new set of entity commands. The commands will be applied automatically near the end of the + /// current (or next) entity update cycle. + /// + /// An object that records instead of applies the entity modification commands performed on it + public EntityCommandRecorder StartRecordingEntityCommands(); + + /// + /// Activates a recorder and gets an entity manager proxy that can be used to perform entity operations + /// + /// + /// The recorder in use by the caller (received from ) + /// + /// An entity manager instance that is safe to call entity modification operations on + public WorldRecord GetRecorderWorld(EntityCommandRecorder recorder); + + /// + /// Notify that the code using a command recorder is now done. This must be called when done with the recorder, + /// otherwise an error will be reported about an unfinished recorder. This allows early reuse of the recorder + /// as well. + /// + /// The recorder to return + public void FinishRecordingEntityCommands(EntityCommandRecorder recorder); +} diff --git a/src/general/base_stage/IWorldSimulationWithPhysics.cs b/src/general/base_stage/IWorldSimulationWithPhysics.cs new file mode 100644 index 00000000000..8126e5a5af7 --- /dev/null +++ b/src/general/base_stage/IWorldSimulationWithPhysics.cs @@ -0,0 +1,21 @@ +using Godot; +using Newtonsoft.Json; + +public interface IWorldSimulationWithPhysics : IWorldSimulation +{ + /// + /// The physical world of this simulation. This is accessible to allow body operations that need to be called + /// through the world object. + /// + [JsonIgnore] + public PhysicalWorld PhysicalWorld { get; } + + public NativePhysicsBody CreateMovingBody(PhysicsShape shape, Vector3 position, Quat rotation); + + public NativePhysicsBody CreateMovingBodyWithAxisLock(PhysicsShape shape, Vector3 position, Quat rotation, + Vector3 lockedAxis, bool lockRotation); + + public NativePhysicsBody CreateStaticBody(PhysicsShape shape, Vector3 position, Quat rotation); + + public void DestroyBody(NativePhysicsBody body); +} diff --git a/src/general/base_stage/StageBase.cs b/src/general/base_stage/StageBase.cs index 53f21c3217f..f796e87b0be 100644 --- a/src/general/base_stage/StageBase.cs +++ b/src/general/base_stage/StageBase.cs @@ -73,8 +73,8 @@ public abstract class StageBase : NodeWithInput, IStageBase, IGodotEarlyNodeReso /// /// /// - /// This used to have an internal set ( had that as - /// well) but with the needed that seems no longer possible + /// This used to have an internal set ( + /// had that as well) but with the needed that seems no longer possible /// /// [JsonIgnore] diff --git a/src/general/base_stage/WorldSimulation.cs b/src/general/base_stage/WorldSimulation.cs new file mode 100644 index 00000000000..51e222cec7e --- /dev/null +++ b/src/general/base_stage/WorldSimulation.cs @@ -0,0 +1,446 @@ +using System; +using System.Collections.Generic; +using DefaultEcs; +using DefaultEcs.Command; +using Godot; +using Newtonsoft.Json; +using World = DefaultEcs.World; + +/// +/// Any type of game world simulation where everything needed to run that simulation is collected under. Note that +/// is an object holding the game world's information like species etc. These simulation +/// types implementing this interface are in charge of running the gameplay simulation side of things. For example +/// microbe moving around, processing compounds, colliding, rendering etc. +/// +public abstract class WorldSimulation : IWorldSimulation +{ + protected readonly World entities = new(); + + // TODO: did these protected property loading work? Loading / saving for the entities + protected readonly List queuedForDelete = new(); + + /// + /// Used to tell a few systems the approximate player position which might not always exist + /// + [JsonIgnore] + protected Vector3? reportedPlayerPosition; + + [JsonProperty] + protected float minimumTimeBetweenLogicUpdates = 1 / 60.0f; + + protected float accumulatedLogicTime; + + /// + /// True when multithreaded system updates are running + /// + protected bool runningMultithreaded; + + private readonly List entitiesToNotSave = new(); + + private readonly Queue queuedInvokes = new(); + + private readonly Queue availableRecorders = new(); + private readonly HashSet nonEmptyRecorders = new(); + private int totalCreatedRecorders; + + /// + /// Access to this world's entity system directly. + /// + /// + /// + /// Note that any component modification operations may not be done while this simulation is currently doing + /// a simulation run. + /// + /// + /// Also looping all entities to find relevant ones is only allowed for one-off operations that don't occur + /// very often (for example each frame). Systems must be implemented for per-frame operations that act on + /// entities having specific components. + /// + /// + [JsonIgnore] + public World EntitySystem => entities; + + /// + /// Count of entities (with simulation heaviness weight) in the simulation. + /// Spawning can be limited when over some limit to ensure performance doesn't degrade too much. + /// + [JsonProperty] + public float EntityCount { get; protected set; } + + /// + /// When set to false disables AI running + /// + [JsonProperty] + public bool RunAI { get; set; } = true; + + /// + /// Player position used to control the simulation accuracy around the player (and despawn things too far away) + /// + [JsonProperty] + public Vector3 PlayerPosition { get; private set; } + + [JsonIgnore] + public bool Initialized { get; private set; } + + [JsonIgnore] + public bool Processing { get; private set; } + + /// + /// Process everything that needs to be done in a neat single method call + /// + /// Time since last time this was called + /// + /// + /// This is an alternative to calling and separately + /// + /// + public void ProcessAll(float delta) + { + ProcessLogic(delta); + ProcessFrameLogic(delta); + } + + /// + /// Processes non-framerate dependent logic and steps the physics simulation once enough time has accumulated + /// + /// + /// Time since previous call, used to determine when it is actually time to do something + /// + public virtual void ProcessLogic(float delta) + { + ThrowIfNotInitialized(); + + accumulatedLogicTime += delta; + + // TODO: is it a good idea to rate limit physics to not be able to run on update frames when the logic + // wasn't ran? + if (accumulatedLogicTime < minimumTimeBetweenLogicUpdates) + return; + + if (accumulatedLogicTime > Constants.SIMULATION_MAX_DELTA_TIME) + { + // Prevent lag spikes from messing with game logic too bad. The downside here is that at extremely low + // framerate the game will run in slow motion + accumulatedLogicTime = Constants.SIMULATION_MAX_DELTA_TIME; + } + + Processing = true; + + OnCheckPhysicsBeforeProcessStart(); + + // Make sure all commands are flushed if someone added some in the time between updates + ApplyRecordedCommands(); + + OnProcessFixedLogic(accumulatedLogicTime); + + ApplyRecordedCommands(); + + ProcessDestroyQueue(); + + lock (queuedInvokes) + { + while (queuedInvokes.Count > 0) + { + queuedInvokes.Dequeue().Invoke(); + } + } + + OnProcessPhysics(accumulatedLogicTime); + + accumulatedLogicTime = 0; + Processing = false; + + // TODO: periodically run + // EntitySystem.Optimize() and maybe TrimExcess + } + + /// + /// Perform per-frame logic. Should be only used for things where the additional precision matters for example + /// for GUI animation quality. Needs to be called after for a frame when this occurs + /// (if a logic update was also performed this frame). + /// + public abstract void ProcessFrameLogic(float delta); + + public Entity CreateEmptyEntity() + { + // Ensure thread unsafe operation doesn't happen + if (runningMultithreaded) + { + throw new InvalidOperationException( + "Can't use thread unsafe create entity at this time, use deferred create"); + } + + return entities.CreateEntity(); + } + + public EntityRecord CreateEntityDeferred(WorldRecord activeRecording) + { + return activeRecording.CreateEntity(); + } + + public bool DestroyEntity(Entity entity) + { + if (queuedForDelete.Contains(entity)) + { + // Already queued for delete + return true; + } + + queuedForDelete.Add(entity); + return true; + } + + public void DestroyAllEntities(Entity? skip = null) + { + ProcessDestroyQueue(); + + foreach (var entity in entities) + { + if (entity == skip) + continue; + + // TODO: check that this destroy while looping entities doesn't cause an issue + + PerformEntityDestroy(entity); + } + + queuedForDelete.Clear(); + } + + public void ReportEntityDyingSoon(in Entity entity) + { + entitiesToNotSave.Add(entity); + } + + /// + /// Returns true when the given entity is queued for destruction + /// + public bool IsQueuedForDeletion(Entity entity) + { + return queuedForDelete.Contains(entity); + } + + public EntityCommandRecorder StartRecordingEntityCommands() + { + lock (availableRecorders) + { + if (availableRecorders.Count > 0) + return availableRecorders.Dequeue(); + + ++totalCreatedRecorders; + return new EntityCommandRecorder(); + } + } + + public WorldRecord GetRecorderWorld(EntityCommandRecorder recorder) + { + return recorder.Record(entities); + } + + public void FinishRecordingEntityCommands(EntityCommandRecorder recorder) + { + lock (availableRecorders) + { +#if DEBUG + if (availableRecorders.Contains(recorder)) + throw new ArgumentException("Entity command recorder already returned"); +#endif + + availableRecorders.Enqueue(recorder); + + // This check is here to allow "failed" recording code to simply return the recorders with this same method + // even if they didn't record anything + if (recorder.Size > 0) + nonEmptyRecorders.Add(recorder); + } + } + + /// + /// Checks that the entity is in this world and is not being deleted + /// + /// The entity to check + /// True when the entity is in this world and is not queued for deletion + public bool IsEntityInWorld(Entity entity) + { + return entity.IsAlive && !queuedForDelete.Contains(entity); + } + + public virtual void ReportPlayerPosition(Vector3 position) + { + PlayerPosition = position; + reportedPlayerPosition = position; + } + + /// + /// Queue a function to run after the next world logic update cycle + /// + /// Callable to invoke + public void Invoke(Action action) + { + lock (queuedInvokes) + { + queuedInvokes.Enqueue(action); + } + } + + /// + /// Immediately perform any delayed / queued entity spawns. This can only be used outside the normal update cycle + /// to get immediate access to a created entity. For example used when spawning the player. + /// + /// If an update is currently running + public void ProcessDelaySpawnedEntitiesImmediately() + { + if (Processing) + throw new InvalidOperationException("Do not call this while world is being processed"); + + ApplyRecordedCommands(); + } + + /// + /// Used in conjunction with to find the player after spawn + /// + /// Type of component to look for + /// The first found entity or an invalid entity + public Entity FindFirstEntityWithComponent() + { + foreach (var entity in EntitySystem) + { + if (entity.Has()) + return entity; + } + + return default; + } + + /// + /// Sets maximum rate at which runs the logic. Note that this also constraints the + /// physics update rate (though internally consistent steps are guaranteed) + /// + /// The log framerate (recommended to be always 60) + public void SetLogicMaxUpdateRate(float logicFPS) + { + minimumTimeBetweenLogicUpdates = 1 / logicFPS; + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Checks that previously started (on previous update) physics runs are complete before running this update. + /// Also if the physics simulation is behind by too much then this steps the simulation extra times. + /// + protected virtual void OnCheckPhysicsBeforeProcessStart() + { + WaitForStartedPhysicsRun(); + + while (RunPhysicsIfBehind()) + { + } + } + + protected virtual void OnProcessPhysics(float delta) + { + OnCheckPhysicsBeforeProcessStart(); + OnStartPhysicsRunIfTime(delta); + } + + /// + /// Needs to be called by a derived class when its init method is called + /// + protected void OnInitialized() + { + if (Initialized) + throw new InvalidOperationException("This simulation was already initialized"); + + Initialized = true; + } + + protected abstract void WaitForStartedPhysicsRun(); + protected abstract void OnStartPhysicsRunIfTime(float delta); + + /// + /// Should run the physics simulation if it is falling behind + /// + /// + /// Should return true when behind and a step was run, this will be executed until this returns false + /// + protected abstract bool RunPhysicsIfBehind(); + + protected abstract void OnProcessFixedLogic(float delta); + + protected void PerformEntityDestroy(Entity entity) + { + entitiesToNotSave.Remove(entity); + + // Destroy the entity from the ECS system + entity.Dispose(); + } + + protected void ThrowIfNotInitialized() + { + if (!Initialized) + throw new InvalidOperationException("Init needs to be called first on this simulation before use"); + } + + protected virtual void Dispose(bool disposing) + { + DestroyAllEntities(); + + if (disposing) + { + entities.Dispose(); + } + } + + private void ProcessDestroyQueue() + { + foreach (var entity in queuedForDelete) + { + PerformEntityDestroy(entity); + } + + // TODO: would it make sense to switch entity count reporting to this class? + + queuedForDelete.Clear(); + } + + private void ApplyRecordedCommands() + { + // availableRecorders is not locked here as things are going very wrong already if some system update thread is + // still running at this time + if (nonEmptyRecorders.Count < 1) + return; + + if (availableRecorders.Count != totalCreatedRecorders) + { + GD.PrintErr("Not all world entity command recorders were returned, some has leaked a recorder (", + availableRecorders.Count, " != ", totalCreatedRecorders, " expected)"); + +#if DEBUG + throw new Exception("Leaked command recorder detected"); +#endif + } + + foreach (var recorder in nonEmptyRecorders) + { + try + { + recorder.Execute(); + } + catch (Exception e) + { + GD.PrintErr("Deferred entity command applying caused an exception: ", e); + recorder.Clear(); + +#if DEBUG + throw; +#endif + } + } + + nonEmptyRecorders.Clear(); + } +} diff --git a/src/general/base_stage/WorldSimulationWithPhysics.cs b/src/general/base_stage/WorldSimulationWithPhysics.cs new file mode 100644 index 00000000000..9febe489453 --- /dev/null +++ b/src/general/base_stage/WorldSimulationWithPhysics.cs @@ -0,0 +1,92 @@ +using System.Collections.Generic; +using Godot; + +/// +/// World simulation that uses the external physics engine in the native code module +/// +public abstract class WorldSimulationWithPhysics : WorldSimulation, IWorldSimulationWithPhysics +{ + protected readonly PhysicalWorld physics = PhysicalWorld.Create(); + + /// + /// All created physics bodies. Must be tracked to correctly destroy them all + /// + protected readonly List createdBodies = new(); + + ~WorldSimulationWithPhysics() + { + Dispose(false); + } + + public PhysicalWorld PhysicalWorld => physics; + + public NativePhysicsBody CreateMovingBody(PhysicsShape shape, Vector3 position, Quat rotation) + { + var body = physics.CreateMovingBody(shape, position, rotation); + createdBodies.Add(body); + return body; + } + + public NativePhysicsBody CreateMovingBodyWithAxisLock(PhysicsShape shape, Vector3 position, Quat rotation, + Vector3 lockedAxis, bool lockRotation) + { + var body = physics.CreateMovingBodyWithAxisLock(shape, position, rotation, lockedAxis, lockRotation); + createdBodies.Add(body); + return body; + } + + public NativePhysicsBody CreateStaticBody(PhysicsShape shape, Vector3 position, Quat rotation) + { + var body = physics.CreateStaticBody(shape, position, rotation); + createdBodies.Add(body); + return body; + } + + public void DestroyBody(NativePhysicsBody body) + { + if (!createdBodies.Remove(body)) + { + GD.PrintErr("Can't destroy body not in simulation"); + return; + } + + physics.DestroyBody(body); + } + + protected override void WaitForStartedPhysicsRun() + { + // TODO: implement multithreading + } + + protected override bool RunPhysicsIfBehind() + { + // TODO: implement this once multithreaded running is added + return false; + } + + protected override void OnStartPhysicsRunIfTime(float delta) + { + physics.ProcessPhysics(delta); + } + + protected override void Dispose(bool disposing) + { + ReleaseUnmanagedResources(); + if (disposing) + { + physics.Dispose(); + } + + base.Dispose(disposing); + } + + private void ReleaseUnmanagedResources() + { + foreach (var createdBody in createdBodies) + { + physics.DestroyBody(createdBody); + } + + createdBodies.Clear(); + } +} diff --git a/src/general/utils/DictionaryUtils.cs b/src/general/utils/DictionaryUtils.cs index 40fab1a7a13..9f84a46b62e 100644 --- a/src/general/utils/DictionaryUtils.cs +++ b/src/general/utils/DictionaryUtils.cs @@ -49,7 +49,7 @@ public static float SumValues(this Dictionary items) /// Items to add things to. As well as the result /// Values to add to items. public static void Merge(this Dictionary items, - Dictionary valuesToAdd) + IReadOnlyDictionary valuesToAdd) { foreach (var entry in valuesToAdd) { diff --git a/src/general/utils/ListUtils.cs b/src/general/utils/ListUtils.cs index 08c9e5f1cf9..1cbb31caf77 100644 --- a/src/general/utils/ListUtils.cs +++ b/src/general/utils/ListUtils.cs @@ -52,4 +52,30 @@ public static int FindIndex(this IReadOnlyList list, Predicate match) return -1; } + + /// + /// Removes an item from a list at index without preserving the list order, this should be faster than normal + /// list remove that preserves order + /// + /// The list to modify + /// Index in the list to remove an item at + /// Type of items in the list + public static void RemoveWithoutPreservingOrder(this IList list, int indexToRemove) + { + var itemCount = list.Count; + + if (indexToRemove + 1 == itemCount) + { + // Already last + } + else + { + // Need to swap the last item to the indexToRemove to preserve it + var temp = list[itemCount - 1]; + + list[indexToRemove] = temp; + } + + list.RemoveAt(itemCount - 1); + } } diff --git a/src/general/utils/MathUtils.cs b/src/general/utils/MathUtils.cs index 1662cd70831..09b207aeb12 100644 --- a/src/general/utils/MathUtils.cs +++ b/src/general/utils/MathUtils.cs @@ -9,6 +9,7 @@ public static class MathUtils { public const float EPSILON = 0.00000001f; public const float DEGREES_TO_RADIANS = Mathf.Pi / 180; + public const float RADIANS_TO_DEGREES = 180 / Mathf.Pi; public const double FULL_CIRCLE = Math.PI * 2; public const float RIGHT_ANGLE = Mathf.Pi / 2; @@ -57,15 +58,6 @@ public static Quat CreateRotationForExternal(float angle) new Quat(new Vector3(0, 1, 0), angle * DEGREES_TO_RADIANS); } - /// - /// Rotation for the pilus physics cone - /// - public static Quat CreateRotationForPhysicsOrganelle(float angle) - { - return new Quat(new Vector3(-1, 0, 0), 90 * DEGREES_TO_RADIANS) * - new Quat(new Vector3(0, 0, -1), (180 - angle) * DEGREES_TO_RADIANS); - } - /// /// Returns a Lerped value, and snaps to the target value if current and target /// value is approximately equal by the specified tolerance value. @@ -167,4 +159,20 @@ public static int PositiveModulo(this int val, int mod) return distance; } + + public static float NormalToWithNegativesRadians(float radian) + { + return radian <= Math.PI ? radian : radian - (float)(2 * Math.PI); + } + + public static float WithNegativesToNormalRadians(float radian) + { + return radian >= 0 ? radian : (float)(2 * Math.PI) - radian; + } + + public static float DistanceBetweenRadians(float p1, float p2) + { + float distance = Math.Abs(p1 - p2); + return distance <= Math.PI ? distance : (float)(2 * Math.PI) - distance; + } } diff --git a/src/gui_common/CustomRichTextLabel.cs b/src/gui_common/CustomRichTextLabel.cs index c7e7eb36839..5392b1f91b7 100644 --- a/src/gui_common/CustomRichTextLabel.cs +++ b/src/gui_common/CustomRichTextLabel.cs @@ -565,7 +565,7 @@ string GetResizedImage(string imagePath, int width, int height, int ascent) case "ConditionFulfilled": { - output = GetResizedImage(GUICommon.Instance.RequirementFullfilledIconPath, 20, 0, 3); + output = GetResizedImage(GUICommon.Instance.RequirementFulfilledIconPath, 20, 0, 3); break; } diff --git a/src/gui_common/GUICommon.cs b/src/gui_common/GUICommon.cs index 8741ea3c2a6..7ee9f8b9cff 100644 --- a/src/gui_common/GUICommon.cs +++ b/src/gui_common/GUICommon.cs @@ -43,7 +43,7 @@ private GUICommon() /// /// Path for the generic icon representing a condition fulfilled. /// - public string RequirementFullfilledIconPath => "res://assets/textures/gui/bevel/RequirementFulfilled.png"; + public string RequirementFulfilledIconPath => "res://assets/textures/gui/bevel/RequirementFulfilled.png"; /// /// Path for the generic icon representing a condition unfulfilled. @@ -238,7 +238,7 @@ public Texture GetRequirementFulfillmentIcon(bool fulfilled) { if (fulfilled) { - return requirementFulfilledIcon ??= GD.Load(RequirementFullfilledIconPath); + return requirementFulfilledIcon ??= GD.Load(RequirementFulfilledIconPath); } return requirementInsufficientIcon ??= GD.Load(RequirementInsufficientIconPath); diff --git a/src/late_multicellular_stage/LateMulticellularSpecies.cs b/src/late_multicellular_stage/LateMulticellularSpecies.cs index 3c1d5739a7b..870b47afd4b 100644 --- a/src/late_multicellular_stage/LateMulticellularSpecies.cs +++ b/src/late_multicellular_stage/LateMulticellularSpecies.cs @@ -181,7 +181,9 @@ private static float CalculateBrainPowerFromLayout(MetaballLayout Size * 0.5f; /// - /// Volume of the metaball sphere + /// Volume of the metaball sphere. Do not scale the result returned from this, use + /// instead. /// [JsonIgnore] - public float Volume => (float)(4.0f * Math.PI * Math.Pow(Radius, 3) / 3.0f); + public float Volume => GetVolume(); /// /// For animation and convolution surfaces we need to know the structure of metaballs @@ -43,6 +44,11 @@ public abstract class Metaball /// True if these are fundamentally the same kind of placed ball public abstract bool MatchesDefinition(Metaball other); + public float GetVolume(float multiplier = 1) + { + return (float)(4.0f * Math.PI * Math.Pow(Radius * multiplier, 3) / 3.0f); + } + /// /// Calculates how many parent links need to be travelled to reach the root /// diff --git a/src/late_multicellular_stage/MulticellularCreature.cs b/src/late_multicellular_stage/MulticellularCreature.cs index a9f190c6254..195eb06ab99 100644 --- a/src/late_multicellular_stage/MulticellularCreature.cs +++ b/src/late_multicellular_stage/MulticellularCreature.cs @@ -12,7 +12,7 @@ [JSONAlwaysDynamicType] [SceneLoadedClass("res://src/late_multicellular_stage/MulticellularCreature.tscn", UsesEarlyResolve = false)] [DeserializedCallbackTarget] -public class MulticellularCreature : RigidBody, ISpawned, IProcessable, ISaveLoadedTracked, ICharacterInventory, +public class MulticellularCreature : RigidBody, ISaveLoadedTracked, ICharacterInventory, IStructureSelectionReceiver, IActionProgressSource { private static readonly Vector3 SwimUpForce = new(0, 20, 0); @@ -142,14 +142,6 @@ public class MulticellularCreature : RigidBody, ISpawned, IProcessable, ISaveLoa [JsonIgnore] public Spatial EntityNode => this; - public int DespawnRadiusSquared { get; set; } - - /// - /// TODO: adjust entity weight once fleshed out - /// - [JsonIgnore] - public float EntityWeight => 1.0f; - [JsonIgnore] public bool IsLoadedFromSave { get; set; } @@ -252,7 +244,9 @@ public void ApplySpecies(Species species) compounds.NominalCapacity = 100; // TODO: better mass calculation - Mass = lateSpecies.BodyLayout.Sum(m => m.Size * m.CellType.TotalMass); + // TotalMass is no longer available due to microbe stage physics refactor + // Mass = lateSpecies.BodyLayout.Sum(m => m.Size * m.CellType.TotalMass); + Mass = lateSpecies.BodyLayout.Sum(m => m.Size * 30); // Setup graphics // TODO: handle lateSpecies.Scale @@ -293,7 +287,8 @@ public MulticellularCreature SpawnOffspring() GetParent(), SpawnHelpers.LoadMulticellularScene(), true, spawnSystem!, CurrentGame); // Make it despawn like normal - spawnSystem!.AddEntityToTrack(copyEntity); + // TODO: reimplement spawn system for the multicellular stage + // spawnSystem!.NotifyExternalEntitySpawned(copyEntity); // TODO: some kind of resource splitting for the offspring? diff --git a/src/late_multicellular_stage/MulticellularStage.cs b/src/late_multicellular_stage/MulticellularStage.cs index ee3bc766178..704c2fe8302 100644 --- a/src/late_multicellular_stage/MulticellularStage.cs +++ b/src/late_multicellular_stage/MulticellularStage.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Linq; using Godot; using Newtonsoft.Json; @@ -11,7 +10,7 @@ [SceneLoadedClass("res://src/late_multicellular_stage/MulticellularStage.tscn")] [DeserializedCallbackTarget] [UseThriveSerializer] -public class MulticellularStage : CreatureStageBase +public class MulticellularStage : CreatureStageBase { [Export] public NodePath? InteractableSystemPath; @@ -32,7 +31,7 @@ public class MulticellularStage : CreatureStageBase [JsonProperty] [AssignOnlyChildItemsOnDeserialize] - private SpawnSystem dummySpawner = null!; + private ISpawnSystem dummySpawner = null!; #pragma warning disable CA2213 private InteractableSystem interactableSystem = null!; @@ -84,6 +83,9 @@ public class MulticellularStage : CreatureStageBase [JsonIgnore] public PlayerInspectInfo HoverInfo { get; private set; } = null!; + [JsonIgnore] + public override bool HasPlayer => Player != null; + [JsonIgnore] protected override ICreatureStageHUD BaseHUD => HUD; @@ -134,7 +136,7 @@ public override void ResolveNodeReferences() // We don't actually spawn anything currently, and anyway will want a different spawn system for late // multicellular - dummySpawner = new SpawnSystem(rootOfDynamicallySpawned); + dummySpawner = new DummySpawnSystem(); } public override void _Process(float delta) @@ -279,6 +281,8 @@ public override void OnReturnFromEditor() base.OnReturnFromEditor(); + ProceduralDataCache.Instance.OnEnterState(MainGameState.MulticellularStage); + // TODO: // // Spawn free food if difficulty settings call for it // if (GameWorld.WorldSettings.FreeGlucoseCloud) @@ -638,6 +642,8 @@ protected override void SetupStage() // if (!IsLoadedFromSave) // spawner.Init(); + ProceduralDataCache.Instance.OnEnterState(MainGameState.MulticellularStage); + CurrentGame!.TechWeb.OnTechnologyUnlockedHandler += ShowTechnologyUnlockMessage; // TODO: implement @@ -652,14 +658,16 @@ protected override void SetupStage() // TODO: change the view } - // TODO: remove + // TODO: reimplement this + throw new NotImplementedException(); + + /*// TODO: remove // Spawn a chunk to give the player some navigation reference var mesh = new ChunkConfiguration.ChunkScene { ScenePath = "res://assets/models/Iron5.tscn", ConvexShapePath = "res://assets/models/Iron5.shape", }; - mesh.LoadScene(); SpawnHelpers.SpawnChunk(new ChunkConfiguration { Name = "test", @@ -669,7 +677,7 @@ protected override void SetupStage() ChunkScale = 1, Meshes = new List { mesh }, }, new Vector3(3, 0, -15), rootOfDynamicallySpawned, SpawnHelpers.LoadChunkScene(), - random); + random);*/ } // patchManager.CurrentGame = CurrentGame; @@ -715,7 +723,6 @@ protected override void SpawnPlayer() Player = SpawnHelpers.SpawnCreature(GameWorld.PlayerSpecies, new Vector3(0, 0, 0), rootOfDynamicallySpawned, SpawnHelpers.LoadMulticellularScene(), false, dummySpawner, CurrentGame!); - Player.AddToGroup(Constants.PLAYER_GROUP); Player.OnDeath = OnPlayerDied; diff --git a/src/microbe_stage/AgentProjectile.cs b/src/microbe_stage/AgentProjectile.cs deleted file mode 100644 index 844eee0da29..00000000000 --- a/src/microbe_stage/AgentProjectile.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System; -using Godot; -using Newtonsoft.Json; - -/// -/// This is a shot agent projectile, does damage on hitting a cell of different species -/// -[JSONAlwaysDynamicType] -[SceneLoadedClass("res://src/microbe_stage/AgentProjectile.tscn", UsesEarlyResolve = false)] -public class AgentProjectile : RigidBody, ITimedLife, IInspectableEntity -{ -#pragma warning disable CA2213 - private Particles particles = null!; -#pragma warning restore CA2213 - - public float TimeToLiveRemaining { get; set; } - public float Amount { get; set; } - public AgentProperties? Properties { get; set; } - public EntityReference Emitter { get; set; } = new(); - - public Spatial EntityNode => this; - - public AliveMarker AliveMarker { get; } = new(); - - [JsonIgnore] - public string ReadableName => Properties?.ToString() ?? TranslationServer.Translate("N_A"); - - [JsonProperty] - private float? FadeTimeRemaining { get; set; } - - public override void _Ready() - { - if (Properties == null) - throw new InvalidOperationException($"{nameof(Properties)} is required"); - - particles = GetNode("Particles"); - - var emitterNode = Emitter.Value?.EntityNode; - - if (emitterNode != null) - AddCollisionExceptionWith(emitterNode); - - Connect("body_shape_entered", this, nameof(OnContactBegin)); - } - - public override void _Process(float delta) - { - if (FadeTimeRemaining == null) - return; - - FadeTimeRemaining -= delta; - if (FadeTimeRemaining <= 0) - this.DestroyDetachAndQueueFree(); - } - - public void OnTimeOver() - { - if (FadeTimeRemaining == null) - BeginDestroy(); - } - - public void OnDestroyed() - { - AliveMarker.Alive = false; - } - - public void OnMouseEnter(RaycastResult raycastResult) - { - } - - public void OnMouseExit(RaycastResult raycastResult) - { - } - - private void OnContactBegin(int bodyID, Node body, int bodyShape, int localShape) - { - _ = bodyID; - _ = localShape; - - if (body is not Microbe microbe) - return; - - if (microbe.Species == Properties!.Species) - return; - - // If more stuff needs to be damaged we could make an IAgentDamageable interface. - var target = microbe.GetMicrobeFromShape(bodyShape); - - if (target == null) - return; - - Invoke.Instance.Perform( - () => target.Damage(Constants.OXYTOXY_DAMAGE * Amount, Properties.AgentType)); - - if (FadeTimeRemaining == null) - { - // We should probably get some *POP* effect here. - BeginDestroy(); - } - } - - /// - /// Stops particle emission and destroys the object after 5 seconds. - /// - private void BeginDestroy() - { - particles.Emitting = false; - - // Disable collisions and stop this entity - // This isn't the recommended way (disabling the collision shape), but as we don't have a reference to that here - // this should also work for disabling the collisions - CollisionLayer = 0; - CollisionMask = 0; - LinearVelocity = Vector3.Zero; - - // Timer that delays despawn of projectiles - FadeTimeRemaining = Constants.PROJECTILE_DESPAWN_DELAY; - - AliveMarker.Alive = false; - } -} diff --git a/src/microbe_stage/AgentProjectile.tscn b/src/microbe_stage/AgentProjectile.tscn index bb0e29741af..bf677f45fe7 100644 --- a/src/microbe_stage/AgentProjectile.tscn +++ b/src/microbe_stage/AgentProjectile.tscn @@ -1,11 +1,7 @@ -[gd_scene load_steps=10 format=2] +[gd_scene load_steps=8 format=2] -[ext_resource path="res://src/microbe_stage/AgentProjectile.cs" type="Script" id=1] [ext_resource path="res://src/microbe_stage/particles/projectile_material.tres" type="Material" id=2] -[sub_resource type="SphereShape" id=1] -radius = 1.41921 - [sub_resource type="Gradient" id=2] offsets = PoolRealArray( 0.00740741, 0.481481, 1 ) colors = PoolColorArray( 1, 1, 1, 0.0352941, 1, 1, 1, 1, 1, 1, 1, 0.0352941 ) @@ -34,21 +30,7 @@ color_ramp = SubResource( 3 ) material = ExtResource( 2 ) size = Vector2( 5, 5 ) -[node name="AgentProjectile" type="RigidBody"] -transform = Transform( 1, 0, 0, 0, 1, 0, 0, 0, 1, 0.00433397, 0, 0.00440288 ) -collision_layer = 1073741824 -collision_mask = 3 -mass = 0.5 -contacts_reported = 1 -contact_monitor = true -axis_lock_linear_y = true -script = ExtResource( 1 ) - -[node name="CollisionShape" type="CollisionShape" parent="."] -transform = Transform( 1, 0, 0, 0, 1, 0, 0, 0, 1, 0.00369835, 0, -0.0593928 ) -shape = SubResource( 1 ) - -[node name="Particles" type="Particles" parent="."] +[node name="AgentProjectile" type="Particles"] transform = Transform( 1, 0, 0, 0, 1, 0, 0, 0, 1, -0.128502, 0, -0.092073 ) amount = 55 visibility_aabb = AABB( -100, -50, -100, 200, 100, 200 ) diff --git a/src/microbe_stage/AgentProperties.cs b/src/microbe_stage/AgentProperties.cs index 5721a5e06c7..4a9428ceb28 100644 --- a/src/microbe_stage/AgentProperties.cs +++ b/src/microbe_stage/AgentProperties.cs @@ -1,4 +1,5 @@ -using Godot; +using Components; +using Newtonsoft.Json; /// /// Properties of an agent. Mainly used currently to block friendly fire @@ -15,8 +16,28 @@ public AgentProperties(Species species, Compound compound) public string AgentType { get; set; } = "oxytoxy"; public Compound Compound { get; set; } + // This has to be used like this to ensure the translation extractor sees this + // ReSharper disable once ArrangeObjectCreationWhenTypeEvident + [JsonIgnore] + public LocalizedString Name => + new LocalizedString("AGENT_NAME", new LocalizedString(Compound.GetUntranslatedName())); + + public void DealDamage(ref Health health, ref CellProperties hitCellProperties, float toxinAmount) + { + var damage = Constants.OXYTOXY_DAMAGE * toxinAmount; + + health.DealMicrobeDamage(ref hitCellProperties, damage, AgentType); + } + + public void DealDamage(ref Health health, float toxinAmount) + { + var damage = Constants.OXYTOXY_DAMAGE * toxinAmount; + + health.DealDamage(damage, AgentType); + } + public override string ToString() { - return TranslationServer.Translate("AGENT_NAME").FormatSafe(Compound.Name); + return Name.ToString(); } } diff --git a/src/microbe_stage/Biome.cs b/src/microbe_stage/Biome.cs index 9077fdf1f3a..8cb99837bd0 100644 --- a/src/microbe_stage/Biome.cs +++ b/src/microbe_stage/Biome.cs @@ -85,8 +85,6 @@ public void Check(string name) /// public void Resolve(SimulationParameters parameters) { - Conditions.Resolve(parameters); - LoadedIcon = GD.Load(Icon); } diff --git a/src/microbe_stage/BiomeConditions.cs b/src/microbe_stage/BiomeConditions.cs index 05b157c98fc..6b088450e30 100644 --- a/src/microbe_stage/BiomeConditions.cs +++ b/src/microbe_stage/BiomeConditions.cs @@ -6,7 +6,7 @@ /// The conditions of a biome that can change. This is a separate class to make serialization work regarding the biome /// [UseThriveSerializer] -public class BiomeConditions : ICloneable, ISaveLoadable +public class BiomeConditions : ICloneable { // TODO: make this also a property / private public Dictionary Chunks = null!; @@ -187,16 +187,6 @@ public void Check(string name) } } - public void Resolve(SimulationParameters parameters) - { - LoadChunkScenes(); - } - - public void FinishLoading(ISaveContext? context) - { - LoadChunkScenes(); - } - public object Clone() { // Shallow cloning is enough here thanks to us using value types (structs) as the dictionary values @@ -209,15 +199,4 @@ public object Clone() return result; } - - private void LoadChunkScenes() - { - foreach (var entry in Chunks) - { - foreach (var meshEntry in entry.Value.Meshes) - { - meshEntry.LoadScene(); - } - } - } } diff --git a/src/microbe_stage/ChunkConfiguration.cs b/src/microbe_stage/ChunkConfiguration.cs index fc12e19c1b8..21ee36d0926 100644 --- a/src/microbe_stage/ChunkConfiguration.cs +++ b/src/microbe_stage/ChunkConfiguration.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using Godot; using Newtonsoft.Json; /// @@ -15,7 +14,11 @@ public struct ChunkConfiguration : IEquatable /// public List Meshes; + /// + /// This is the spawn density of the chunk + /// public float Density; + public bool Dissolves; public float Radius; public float ChunkScale; @@ -51,6 +54,10 @@ public struct ChunkConfiguration : IEquatable /// public string DissolverEnzyme; + // TODO: convert the JSON data to directly specify the physics density + [JsonIgnore] + public float PhysicsDensity => Mass * 1000; + public static bool operator ==(ChunkConfiguration left, ChunkConfiguration right) { return left.Equals(right); @@ -133,7 +140,7 @@ public bool Equals(ChunkCompound other) /// /// Don't modify instances of this class /// - public class ChunkScene : ISaveLoadable + public class ChunkScene { public string ScenePath = null!; @@ -152,26 +159,14 @@ public class ChunkScene : ISaveLoadable /// public string? SceneAnimationPath; - [JsonIgnore] - public PackedScene? LoadedScene; - - [JsonIgnore] - public ConvexPolygonShape? LoadedConvexShape; - - public void LoadScene() - { - if (string.IsNullOrEmpty(ScenePath)) - throw new InvalidOperationException($"{nameof(ScenePath)} is required for a ChunkScene"); - - LoadedScene = GD.Load(ScenePath); - - if (!string.IsNullOrEmpty(ConvexShapePath)) - LoadedConvexShape = GD.Load(ConvexShapePath); - } + /// + /// Need to be set to true on particle type visuals as those need special handling + /// + public bool IsParticles; - public void FinishLoading(ISaveContext? context) - { - LoadScene(); - } + /// + /// If true animations won't be stopped on this scene when this is spawned as a chunk + /// + public bool PlayAnimation; } } diff --git a/src/microbe_stage/ColonyCompoundBag.cs b/src/microbe_stage/ColonyCompoundBag.cs index ffbb184cb44..fe5bab87906 100644 --- a/src/microbe_stage/ColonyCompoundBag.cs +++ b/src/microbe_stage/ColonyCompoundBag.cs @@ -1,33 +1,72 @@ using System.Collections; using System.Collections.Generic; using System.Linq; +using Components; +using DefaultEcs; using Godot; -using Newtonsoft.Json; -[UseThriveSerializer] +/// +/// Access to a microbe colony's compounds through a unified interface. Instances of this class should not be stored +/// and only be accessed with +/// public class ColonyCompoundBag : ICompoundStorage { + private readonly object refreshListLock = new(); + + private List colonyBags = new(); + private List bagBuilder = new(); + private bool nanIssueReported; - public ColonyCompoundBag(MicrobeColony colony) + public ColonyCompoundBag(Entity[] colonyMembers) { - Colony = colony; + // This +4 is here basically for fun to give a reasonable initial size (as colonies start mostly with 2 + // members) + bagBuilder.Capacity = colonyMembers.Length + 4; + UpdateColonyMembers(colonyMembers); } - [JsonProperty] - private MicrobeColony Colony { get; } - public float GetCapacityForCompound(Compound compound) { return GetCompoundBags().Sum(p => p.GetCapacityForCompound(compound)); } + /// + /// Updates the colony members of this bag. Should only be called from the colony helper methods for adding and + /// removing members + /// + /// The new colony member entities + public void UpdateColonyMembers(Entity[] colonyMembers) + { + lock (refreshListLock) + { + bagBuilder.Clear(); + + // Initialize capacity to something that probably fits + if (bagBuilder.Capacity < 1) + bagBuilder.Capacity = colonyBags.Capacity + 2; + + foreach (var colonyMember in colonyMembers) + { + if (!colonyMember.Has()) + { + GD.PrintErr("Colony compound bag member entity has no compound storage"); + continue; + } + + bagBuilder.Add(colonyMember.Get().Compounds); + } + + (colonyBags, bagBuilder) = (bagBuilder, colonyBags); + } + } + /// /// Evenly spreads out the compounds among all microbes /// public void DistributeCompoundSurplus() { - var bags = GetCompoundBags().ToList(); + var bags = GetCompoundBags(); foreach (var currentPair in this) { @@ -142,9 +181,9 @@ private static bool IsUsefulInAnyCompoundBag(Compound compound, IEnumerable p.IsUseful(compound)); } - private IEnumerable GetCompoundBags() + private ICollection GetCompoundBags() { - return Colony.ColonyMembers.Select(p => p.Compounds); + return colonyBags; } private bool IsUsefulInAnyCompoundBag(Compound compound) diff --git a/src/microbe_stage/Compound.cs b/src/microbe_stage/Compound.cs index 44cdb762acb..9bf60aa8e83 100644 --- a/src/microbe_stage/Compound.cs +++ b/src/microbe_stage/Compound.cs @@ -130,6 +130,11 @@ public void ApplyTranslations() TranslationHelper.ApplyTranslations(this); } + public string GetUntranslatedName() + { + return untranslatedName ?? "error"; + } + public override string ToString() { return Name; diff --git a/src/microbe_stage/CompoundBag.cs b/src/microbe_stage/CompoundBag.cs index c26a24cb8e8..332a22f6f90 100644 --- a/src/microbe_stage/CompoundBag.cs +++ b/src/microbe_stage/CompoundBag.cs @@ -8,6 +8,7 @@ /// Object that stores compound amounts and capacities /// [UseThriveSerializer] +[JsonObject(IsReference = true)] public class CompoundBag : ICompoundStorage { private readonly HashSet usefulCompounds = new(); @@ -51,10 +52,32 @@ public float GetCapacityForCompound(Compound compound) return NominalCapacity; } - public void SetCapacityForCompound(Compound compound, float capacity) + /// + /// Adds specialized capacity for a compound. must be set before calling this. + /// To reset this value call to restart filling this info. + /// + /// The compound type + /// Capacity to add for this compound + /// + /// + /// This now adds capacity (and starts capacities from the nominal capacity) instead of setting the value + /// directly. This is to allow the method that updates this to avoid + /// memory allocations. + /// + /// + public void AddSpecificCapacityForCompound(Compound compound, float capacityToAdd) { compoundCapacities ??= new Dictionary(); - compoundCapacities[compound] = capacity; + + if (!compoundCapacities.TryGetValue(compound, out var existing)) + { + // Add nominal capacity as the base amount here when the first specific capacity value is added + compoundCapacities[compound] = capacityToAdd + NominalCapacity; + } + else + { + compoundCapacities[compound] = existing + capacityToAdd; + } } public float GetCompoundAmount(Compound compound) @@ -77,7 +100,7 @@ public float GetFreeSpaceForCompound(Compound compound) public float TakeCompound(Compound compound, float amount) { - if (!Compounds.TryGetValue(compound, out var existingAmount) || amount <= 0.0f) + if (amount <= 0.0f || !Compounds.TryGetValue(compound, out var existingAmount)) return 0.0f; amount = Math.Min(existingAmount, amount); @@ -105,11 +128,6 @@ public float AddCompound(Compound compound, float amount) return Compounds.GetEnumerator(); } - IEnumerator IEnumerable.GetEnumerator() - { - return GetEnumerator(); - } - public void ClearCompounds() { Compounds.Clear(); @@ -131,9 +149,8 @@ public void SetUseful(Compound compound) } /// - /// Returns true if at least one compound type has been marked - /// useful. This is used to detect that process system has ran - /// before venting. + /// Returns true if at least one compound type has been marked useful. + /// This is used to detect that process system has ran before venting. /// public bool HasAnyBeenSetUseful() { @@ -160,6 +177,38 @@ public bool AreAnySpecificallySetUseful(IEnumerable compounds) return compounds.Any(usefulCompounds.Contains); } + /// + /// Returns true only if this compound bag contains any compounds whatsoever + /// + /// True if not empty + public bool HasAnyCompounds() + { + foreach (var compoundsValue in Compounds.Values) + { + if (compoundsValue > 0) + return true; + } + + return false; + } + + public void AddInitialCompounds(IReadOnlyDictionary compounds) + { + foreach (var entry in compounds) + { + if (!Compounds.TryGetValue(entry.Key, out var existingAmount)) + { + Compounds[entry.Key] = entry.Value; + continue; + } + + float toAdd = entry.Value - existingAmount; + + if (toAdd > 0) + Compounds[entry.Key] = existingAmount + toAdd; + } + } + public void ClampNegativeCompoundAmounts() { var negative = Compounds.Where(c => c.Value < 0.0f).ToList(); @@ -184,4 +233,9 @@ public void FixNaNCompounds() Compounds[entry.Key] = 0; } } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } } diff --git a/src/microbe_stage/CompoundCloudPlane.cs b/src/microbe_stage/CompoundCloudPlane.cs index 6b9e371309a..daa02d1ebc3 100644 --- a/src/microbe_stage/CompoundCloudPlane.cs +++ b/src/microbe_stage/CompoundCloudPlane.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using Godot; using Newtonsoft.Json; +using Systems; using Vector2 = Godot.Vector2; using Vector3 = Godot.Vector3; @@ -37,9 +38,9 @@ public class CompoundCloudPlane : CSGMesh, ISaveLoadedTracked // JSON file and use it instead. private const float VISCOSITY = 0.0525f; - private Image image = null!; + private Image? image; private ImageTexture texture = null!; - private FluidSystem? fluidSystem; + private FluidCurrentsSystem? fluidSystem; private Vector4 decayRates; @@ -82,10 +83,10 @@ public override void _Ready() /// /// Initializes this cloud. cloud2 onwards can be null /// - public void Init(FluidSystem fluidSystem, int renderPriority, Compound cloud1, Compound? cloud2, + public void Init(FluidCurrentsSystem turbulenceSource, int renderPriority, Compound cloud1, Compound? cloud2, Compound? cloud3, Compound? cloud4) { - this.fluidSystem = fluidSystem; + fluidSystem = turbulenceSource; Compounds = new Compound?[Constants.CLOUDS_IN_ONE] { cloud1, cloud2, cloud3, cloud4 }; decayRates = new Vector4(cloud1.DecayRate, cloud2?.DecayRate ?? 1.0f, @@ -347,7 +348,7 @@ public void QueueUpdateCloud(float delta, List queue) /// public void QueueUpdateTextureImage(List queue) { - image.Lock(); + image!.Lock(); for (int i = 0; i < Constants.CLOUD_SQUARES_PER_SIDE; i++) { @@ -366,7 +367,7 @@ public void QueueUpdateTextureImage(List queue) public void UpdateTexture() { - image.Unlock(); + image!.Unlock(); texture.CreateFromImage(image, (uint)Texture.FlagsEnum.Filter | (uint)Texture.FlagsEnum.Repeat); } @@ -506,7 +507,7 @@ public Vector3 ConvertToWorld(int cloudX, int cloudY) /// Absorbs compounds from this cloud /// public void AbsorbCompounds(int localX, int localY, CompoundBag storage, - Dictionary totals, float delta, float rate) + Dictionary? totals, float delta, float rate) { if (rate < 0) throw new ArgumentException("Rate can't be negative"); @@ -549,9 +550,12 @@ public Vector3 ConvertToWorld(int cloudX, int cloudY) storage.AddCompound(compound, taken); - // Keep track of total compounds absorbed for the cell - totals.TryGetValue(compound, out var existingValue); - totals[compound] = existingValue + taken; + if (totals != null) + { + // Keep track of total compounds absorbed for the cell + totals.TryGetValue(compound, out var existingValue); + totals[compound] = existingValue + taken; + } } } @@ -577,8 +581,11 @@ protected override void Dispose(bool disposing) { if (disposing) { - texture.Dispose(); - image.Dispose(); + if (image != null) + { + image.Dispose(); + texture.Dispose(); + } } base.Dispose(disposing); @@ -724,7 +731,7 @@ private void PartialUpdateTextureImage(int x0, int y0, int width, int height) for (int y = y0; y < y0 + height; y++) { var pixel = Density[x, y] * (1 / Constants.CLOUD_MAX_INTENSITY_SHOWN); - image.SetPixel(x, y, new Color(pixel.X, pixel.Y, pixel.Z, pixel.W)); + image!.SetPixel(x, y, new Color(pixel.X, pixel.Y, pixel.Z, pixel.W)); } } } diff --git a/src/microbe_stage/CompoundCloudSystem.cs b/src/microbe_stage/CompoundCloudSystem.cs index f37ea8c38eb..18b4c18817e 100644 --- a/src/microbe_stage/CompoundCloudSystem.cs +++ b/src/microbe_stage/CompoundCloudSystem.cs @@ -4,11 +4,12 @@ using System.Threading.Tasks; using Godot; using Newtonsoft.Json; +using Systems; /// /// Manages spawning and processing compound clouds /// -public class CompoundCloudSystem : Node, ISaveLoadedTracked +public class CompoundCloudSystem : Node, IReadonlyCompoundClouds, ISaveLoadedTracked { [JsonProperty] private int neededCloudsAtOnePosition; @@ -50,7 +51,7 @@ public override void _Ready() /// /// Resets the cloud contents and positions as well as the compound types they store /// - public void Init(FluidSystem fluidSystem) + public void Init(FluidCurrentsSystem fluidSystem) { var allCloudCompounds = SimulationParameters.Instance.GetCloudCompounds(); @@ -213,9 +214,6 @@ public float AmountAvailable(Compound compound, Vector3 worldPosition, float fra return 0; } - /// - /// Returns the total amount of all compounds at position - /// public void GetAllAvailableAt(Vector3 worldPosition, Dictionary result, bool onlyAbsorbable = true) { foreach (var cloud in clouds) @@ -231,7 +229,7 @@ public void GetAllAvailableAt(Vector3 worldPosition, Dictionary /// Absorbs compounds from clouds into a bag /// public void AbsorbCompounds(Vector3 position, float radius, CompoundBag storage, - Dictionary totals, float delta, float rate) + Dictionary? totals, float delta, float rate) { // It might be fine to remove this check but this was in the old code if (radius < 1.0f) @@ -297,14 +295,6 @@ public void GetAllAvailableAt(Vector3 worldPosition, Dictionary } } - /// - /// Tries to find specified compound as close to the point as possible. - /// - /// Position to search around - /// What compound to search for - /// How wide to search around the point - /// Limits search to only find concentrations higher than this - /// The nearest found point for the compound or null public Vector3? FindCompoundNearPoint(Vector3 position, Compound compound, float searchRadius = 200, float minConcentration = 120) { diff --git a/src/microbe_stage/DigestCheckResult.cs b/src/microbe_stage/DigestCheckResult.cs new file mode 100644 index 00000000000..c9fcbaf9ef2 --- /dev/null +++ b/src/microbe_stage/DigestCheckResult.cs @@ -0,0 +1,5 @@ +public enum DigestCheckResult +{ + Ok, + MissingEnzyme, +} diff --git a/src/microbe_stage/Endosome.cs b/src/microbe_stage/Endosome.cs index d329b548aa8..738d5230d88 100644 --- a/src/microbe_stage/Endosome.cs +++ b/src/microbe_stage/Endosome.cs @@ -3,14 +3,12 @@ using Newtonsoft.Json; /// -/// This does nothing (for now) and only exist so saving could work. +/// Visuals of engulfing something and encasing it in a "membrane" bubble /// -[JSONAlwaysDynamicType] -[SceneLoadedClass("res://src/microbe_stage/Endosome.tscn", UsesEarlyResolve = false)] public class Endosome : Spatial, IEntity { [JsonProperty] - private Color tint; + private Color tint = Colors.White; [JsonProperty] private int renderPriority; @@ -24,6 +22,11 @@ public Color Tint get => tint; set { + // EngulfingSystem always updates the property values so we skip applying this to the shader if the value + // didn't change + if (tint == value) + return; + tint = value; ApplyTint(); } diff --git a/src/microbe_stage/EngulfCheckResult.cs b/src/microbe_stage/EngulfCheckResult.cs new file mode 100644 index 00000000000..d684d2be4f9 --- /dev/null +++ b/src/microbe_stage/EngulfCheckResult.cs @@ -0,0 +1,20 @@ +public enum EngulfCheckResult +{ + /// + /// Target can be engulfed + /// + Ok, + + /// + /// Targeting an entity that can't be engulfed at all (missing components for example) + /// + InvalidEntity, + + NotInEngulfMode, + RecentlyExpelled, + TargetDead, + TargetTooBig, + IngestedMatterFull, + CannotCannibalize, + TargetInvulnerable, +} diff --git a/src/microbe_stage/FloatingChunk.cs b/src/microbe_stage/FloatingChunk.cs deleted file mode 100644 index e4bbe5929aa..00000000000 --- a/src/microbe_stage/FloatingChunk.cs +++ /dev/null @@ -1,628 +0,0 @@ -using System; -using System.Collections.Generic; -using Godot; -using Newtonsoft.Json; - -/// -/// Script for the floating chunks (cell parts, rocks, hazards) -/// -[JSONAlwaysDynamicType] -[SceneLoadedClass("res://src/microbe_stage/FloatingChunk.tscn", UsesEarlyResolve = false)] -public class FloatingChunk : RigidBody, ISpawned, IEngulfable, IInspectableEntity -{ -#pragma warning disable CA2213 // a shared resource from the chunk definition - [Export] - [JsonProperty] - public PackedScene GraphicsScene = null!; -#pragma warning restore CA2213 - - /// - /// If this is null, a sphere shape is used as a default for collision detections. - /// - [Export] - [JsonProperty] - public ConvexPolygonShape? ConvexPhysicsMesh; - - /// - /// The node path to the mesh of this chunk - /// - public string? ModelNodePath; - - /// - /// The node path to the animation of this chunk - /// - public string? AnimationPath; - - /// - /// Used to check if a microbe wants to engulf this - /// - private HashSet touchingMicrobes = new(); - -#pragma warning disable CA2213 - private MeshInstance? chunkMesh; - private Particles? particles; -#pragma warning restore CA2213 - - [JsonProperty] - private bool isDissolving; - - [JsonProperty] - private bool isFadingParticles; - - [JsonProperty] - private float particleFadeTimer; - - [JsonProperty] - private float dissolveEffectValue; - - [JsonProperty] - private float elapsedSinceProcess; - - [JsonProperty] - private int renderPriority; - - [JsonProperty] - private float engulfSize; - - public int DespawnRadiusSquared { get; set; } - - [JsonIgnore] - public float EntityWeight => 1.0f; - - [JsonIgnore] - public Spatial EntityNode => this; - - [JsonIgnore] - public GeometryInstance EntityGraphics - { - get - { - if (chunkMesh != null) - return chunkMesh; - - if (particles != null) - return particles; - - throw new InstanceNotLoadedYetException(); - } - } - - [JsonIgnore] - public int RenderPriority - { - get => renderPriority; - set - { - renderPriority = value; - ApplyRenderPriority(); - } - } - - /// - /// Determines how big this chunk is for engulfing calculations. Set to <= 0 to disable - /// - [JsonIgnore] - public float EngulfSize - { - get => engulfSize * (1 - DigestedAmount); - set => engulfSize = value; - } - - /// - /// Compounds this chunk contains, and vents - /// - /// - /// - /// Capacity is set to 0 so that no compounds can be added the normal way to the chunk. - /// - /// - [JsonProperty] - public CompoundBag Compounds { get; private set; } = new(0.0f); - - /// - /// How much of each compound is vented per second - /// - public float VentPerSecond { get; set; } = 5.0f; - - /// - /// If true this chunk is destroyed when all compounds are vented - /// - public bool Dissolves { get; set; } - - /// - /// If > 0 applies damage to a cell on touch - /// - public float Damages { get; set; } - - /// - /// When true, the chunk will despawn when the despawn timer finishes - /// - public bool UsesDespawnTimer { get; set; } - - /// - /// How much time has passed since a chunk that uses this timer has been spawned - /// - [JsonProperty] - public float DespawnTimer { get; private set; } - - /// - /// If true this gets deleted when a cell touches this - /// - public bool DeleteOnTouch { get; set; } - - public float Radius { get; set; } - - public float ChunkScale { get; set; } - - /// - /// The name of kind of damage type this chunk inflicts. Default is "chunk". - /// - public string DamageType { get; set; } = "chunk"; - - public string ChunkName { get; set; } = string.Empty; - - public bool EasterEgg { get; set; } - - [JsonIgnore] - public AliveMarker AliveMarker { get; } = new(); - - [JsonProperty] - public PhagocytosisPhase PhagocytosisStep { get; set; } - - [JsonProperty] - public EntityReference HostileEngulfer { get; private set; } = new(); - - [JsonProperty] - public Enzyme? RequisiteEnzymeToDigest { get; private set; } - - /// - /// This is both the digestion and dissolve effect progress value for now. - /// - [JsonIgnore] - public float DigestedAmount - { - get => dissolveEffectValue; - set - { - dissolveEffectValue = Mathf.Clamp(value, 0.0f, 1.0f); - UpdateDissolveEffect(); - } - } - - [JsonIgnore] - public string ReadableName => TranslationServer.Translate(ChunkName); - - public override void _Ready() - { - InitGraphics(); - - if (chunkMesh == null && particles == null) - throw new InvalidOperationException("Can't make a chunk without graphics scene"); - - InitPhysics(); - } - - /// - /// Grabs data from the type to initialize this - /// - /// - /// - /// Doesn't initialize the graphics scene which needs to be set separately - /// - /// - public void Init(ChunkConfiguration chunkType, string? modelPath, string? animationPath) - { - // Grab data - ChunkName = chunkType.Name; - VentPerSecond = chunkType.VentAmount; - Dissolves = chunkType.Dissolves; - EngulfSize = chunkType.Size; - Damages = chunkType.Damages; - DeleteOnTouch = chunkType.DeleteOnTouch; - DamageType = string.IsNullOrEmpty(chunkType.DamageType) ? "chunk" : chunkType.DamageType; - EasterEgg = chunkType.EasterEgg; - - Mass = chunkType.Mass; - - // These are stored for saves to work - Radius = chunkType.Radius; - ChunkScale = chunkType.ChunkScale; - - ModelNodePath = modelPath; - AnimationPath = animationPath; - - // Copy compounds to vent - if (chunkType.Compounds?.Count > 0) - { - foreach (var entry in chunkType.Compounds) - { - Compounds.Compounds.Add(entry.Key, entry.Value.Amount); - } - } - - if (!string.IsNullOrEmpty(chunkType.DissolverEnzyme)) - RequisiteEnzymeToDigest = SimulationParameters.Instance.GetEnzyme(chunkType.DissolverEnzyme); - } - - /// - /// Reverses the action of Init back to a ChunkConfiguration - /// - /// The reversed chunk configuration - public ChunkConfiguration CreateChunkConfigurationFromThis() - { - var config = default(ChunkConfiguration); - - config.Name = ChunkName; - config.VentAmount = VentPerSecond; - config.Dissolves = Dissolves; - config.Size = EngulfSize; - config.Damages = Damages; - config.DeleteOnTouch = DeleteOnTouch; - config.Mass = Mass; - config.DamageType = DamageType; - - config.Radius = Radius; - config.ChunkScale = ChunkScale; - - // Read graphics data set by the spawn function - config.Meshes = new List(); - - var item = new ChunkConfiguration.ChunkScene - { - LoadedScene = GraphicsScene, ScenePath = GraphicsScene.ResourcePath, SceneModelPath = ModelNodePath, - LoadedConvexShape = ConvexPhysicsMesh, ConvexShapePath = ConvexPhysicsMesh?.ResourcePath, - SceneAnimationPath = AnimationPath, - }; - - config.Meshes.Add(item); - - if (Compounds.Compounds.Count > 0) - { - config.Compounds = new Dictionary(); - - foreach (var entry in Compounds) - { - config.Compounds.Add(entry.Key, new ChunkConfiguration.ChunkCompound { Amount = entry.Value }); - } - } - - if (RequisiteEnzymeToDigest != null) - config.DissolverEnzyme = RequisiteEnzymeToDigest.InternalName; - - return config; - } - - public void ProcessChunk(float delta, CompoundCloudSystem compoundClouds) - { - if (PhagocytosisStep != PhagocytosisPhase.None) - return; - - if (isDissolving) - HandleDissolving(delta); - - if (isFadingParticles) - { - particleFadeTimer -= delta; - - if (particleFadeTimer <= 0) - { - this.DestroyDetachAndQueueFree(); - } - } - - elapsedSinceProcess += delta; - - // Skip some of our more expensive operations if not enough time has passed - // This doesn't actually seem to have that much effect with reasonable chunk counts... but doesn't seem - // to hurt either, so for the future I think we should keep this -hhyyrylainen - if (elapsedSinceProcess < Constants.FLOATING_CHUNK_PROCESS_INTERVAL) - return; - - VentCompounds(elapsedSinceProcess, compoundClouds); - - if (UsesDespawnTimer) - DespawnTimer += elapsedSinceProcess; - - // Check contacts - foreach (var microbe in touchingMicrobes) - { - // TODO: is it possible that this throws the disposed exception? - if (microbe.Dead) - continue; - - // Damage - if (Damages > 0) - { - if (DeleteOnTouch) - { - microbe.Damage(Damages, DamageType); - } - else - { - microbe.Damage(Damages * elapsedSinceProcess, DamageType); - } - } - - if (DeleteOnTouch) - { - DissolveOrRemove(); - break; - } - } - - if (DespawnTimer > Constants.DESPAWNING_CHUNK_LIFETIME) - { - VentAllCompounds(compoundClouds); - DissolveOrRemove(); - } - - elapsedSinceProcess = 0; - } - - public void PopImmediately(CompoundCloudSystem compoundClouds) - { - VentAllCompounds(compoundClouds); - this.DestroyDetachAndQueueFree(); - } - - public void VentAllCompounds(CompoundCloudSystem compoundClouds) - { - // Vent all remaining compounds immediately - if (Compounds.Compounds.Count > 0) - { - var pos = Translation; - - var keys = new List(Compounds.Compounds.Keys); - - foreach (var compound in keys) - { - var amount = Compounds.GetCompoundAmount(compound); - Compounds.TakeCompound(compound, amount); - - if (amount < MathUtils.EPSILON) - continue; - - VentCompound(pos, compound, amount, compoundClouds); - } - } - } - - public void OnDestroyed() - { - AliveMarker.Alive = false; - } - - public Dictionary? CalculateAdditionalDigestibleCompounds() - { - return null; - } - - public void OnAttemptedToBeEngulfed() - { - } - - public void OnIngestedFromEngulfment() - { - } - - public void OnExpelledFromEngulfment() - { - if (DigestedAmount > 0) - { - // Just dissolve this chunk entirely (assume that it has become unstable from digestion) - DespawnTimer = Constants.DESPAWNING_CHUNK_LIFETIME + 1; - } - } - - public void OnMouseEnter(RaycastResult result) - { - } - - public void OnMouseExit(RaycastResult result) - { - } - - private void InitGraphics() - { - var graphicsNode = GraphicsScene.Instance(); - GetNode("NodeToScale").AddChild(graphicsNode); - - if (!string.IsNullOrEmpty(ModelNodePath)) - { - chunkMesh = graphicsNode.GetNode(ModelNodePath); - return; - } - - if (graphicsNode.IsClass("MeshInstance")) - { - chunkMesh = (MeshInstance)graphicsNode; - } - else if (graphicsNode.IsClass("Particles")) - { - particles = (Particles)graphicsNode; - } - else - { - throw new Exception("Invalid class"); - } - } - - private void InitPhysics() - { - // Apply physics shape - var shape = GetNode("CollisionShape"); - - if (ConvexPhysicsMesh == null) - { - var sphereShape = new SphereShape { Radius = Radius }; - shape.Shape = sphereShape; - } - else - { - if (chunkMesh == null) - throw new InvalidOperationException("Can't use convex physics shape without mesh for chunk"); - - shape.Shape = ConvexPhysicsMesh; - shape.Transform = chunkMesh.Transform; - } - - // Needs physics callback when this is engulfable or damaging - if (Damages > 0 || DeleteOnTouch || EngulfSize > 0) - { - ContactsReported = Constants.DEFAULT_STORE_CONTACTS_COUNT; - Connect("body_shape_entered", this, nameof(OnContactBegin)); - Connect("body_shape_exited", this, nameof(OnContactEnd)); - } - } - - /// - /// Vents compounds if this is a chunk that contains compounds - /// - private void VentCompounds(float delta, CompoundCloudSystem compoundClouds) - { - if (Compounds.Compounds.Count <= 0) - return; - - var pos = Translation; - - var keys = new List(Compounds.Compounds.Keys); - - // Loop through all the compounds in the storage bag and eject them - bool vented = false; - foreach (var compound in keys) - { - var amount = Compounds.GetCompoundAmount(compound); - - if (amount <= 0) - continue; - - var got = Compounds.TakeCompound(compound, VentPerSecond * delta); - - if (got > MathUtils.EPSILON) - { - VentCompound(pos, compound, got, compoundClouds); - vented = true; - } - } - - // If you did not vent anything this step and the venter component - // is flagged to dissolve you, dissolve you - if (!vented && Dissolves) - { - isDissolving = true; - } - } - - private void VentCompound(Vector3 pos, Compound compound, float amount, CompoundCloudSystem compoundClouds) - { - compoundClouds.AddCloud(compound, amount * Constants.CHUNK_VENT_COMPOUND_MULTIPLIER, pos); - } - - /// - /// Handles the dissolving effect for the chunks when they run out of compounds. - /// - private void HandleDissolving(float delta) - { - if (chunkMesh == null) - throw new InvalidOperationException("Chunk without a mesh can't dissolve"); - - if (PhagocytosisStep != PhagocytosisPhase.None) - return; - - // Disable collisions - CollisionLayer = 0; - CollisionMask = 0; - - DigestedAmount += delta * Constants.FLOATING_CHUNKS_DISSOLVE_SPEED; - - if (DigestedAmount >= Constants.FULLY_DIGESTED_LIMIT) - { - this.DestroyDetachAndQueueFree(); - } - } - - private void UpdateDissolveEffect() - { - if (chunkMesh == null) - throw new InvalidOperationException("Chunk without a mesh can't dissolve"); - - if (chunkMesh.MaterialOverride is ShaderMaterial material) - material.SetShaderParam("dissolveValue", dissolveEffectValue); - } - - private void ApplyRenderPriority() - { - if (chunkMesh == null) - throw new InvalidOperationException("Chunk without a mesh can't be applied a render priority"); - - chunkMesh.MaterialOverride.RenderPriority = RenderPriority; - } - - private void OnContactBegin(int bodyID, Node body, int bodyShape, int localShape) - { - _ = bodyID; - _ = localShape; - - if (body is Microbe microbe) - { - // Can't engulf with a pilus - if (microbe.IsPilus(microbe.ShapeFindOwner(bodyShape))) - return; - - var target = microbe.GetMicrobeFromShape(bodyShape); - if (target != null) - touchingMicrobes.Add(target); - } - } - - private void OnContactEnd(int bodyID, Node body, int bodyShape, int localShape) - { - _ = bodyID; - _ = localShape; - - if (body is Microbe microbe) - { - var shapeOwner = microbe.ShapeFindOwner(bodyShape); - - // This can happen when a microbe unbinds while also touching a floating chunk - // TODO: Do something more elegant to stop the error messages in the log - if (shapeOwner == 0) - { - touchingMicrobes.Remove(microbe); - return; - } - - // This might help in a case where the cell is touching with both a pilus and non-pilus part - if (microbe.IsPilus(shapeOwner)) - return; - - var target = microbe.GetMicrobeFromShape(bodyShape); - - if (target != null) - touchingMicrobes.Remove(target); - } - } - - private void DissolveOrRemove() - { - if (Dissolves) - { - isDissolving = true; - } - else if (particles != null && !isFadingParticles) - { - isFadingParticles = true; - - // Disable collisions - CollisionLayer = 0; - CollisionMask = 0; - - particles.Emitting = false; - particleFadeTimer = particles.Lifetime; - } - else if (particles == null) - { - this.DestroyDetachAndQueueFree(); - } - } -} diff --git a/src/microbe_stage/FloatingChunkSystem.cs b/src/microbe_stage/FloatingChunkSystem.cs deleted file mode 100644 index a3a36681274..00000000000 --- a/src/microbe_stage/FloatingChunkSystem.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Godot; - -/// -/// Handles floating chunks emitting compounds and dissolving. This is centralized to be able to apply the max chunks -/// cap. -/// -public class FloatingChunkSystem -{ - private readonly Node worldRoot; - - private readonly CompoundCloudSystem clouds; - - private Vector3 latestPlayerPosition = Vector3.Zero; - - public FloatingChunkSystem(Node worldRoot, CompoundCloudSystem cloudSystem) - { - this.worldRoot = worldRoot; - clouds = cloudSystem; - } - - public void Process(float delta, Vector3? playerPosition) - { - if (playerPosition != null) - latestPlayerPosition = playerPosition.Value; - - var chunks = worldRoot.GetChildrenToProcess(Constants.AI_TAG_CHUNK).ToList(); - - var findTooManyChunksTask = new Task>(() => - { - int tooManyChunks = - Math.Min(Constants.MAX_DESPAWNS_PER_FRAME, chunks.Count - Constants.FLOATING_CHUNK_MAX_COUNT); - - if (tooManyChunks < 1) - return Array.Empty(); - - var comparePosition = latestPlayerPosition; - - return chunks.OrderByDescending(c => c.Translation.DistanceSquaredTo(comparePosition)) - .Take(tooManyChunks); - }); - - TaskExecutor.Instance.AddTask(findTooManyChunksTask); - - foreach (var chunk in chunks) - { - chunk.ProcessChunk(delta, clouds); - } - - findTooManyChunksTask.Wait(); - foreach (var toDespawn in findTooManyChunksTask.Result) - { - toDespawn.PopImmediately(clouds); - } - } -} diff --git a/src/microbe_stage/FluidSystem.cs b/src/microbe_stage/FluidSystem.cs deleted file mode 100644 index 7fa814e005c..00000000000 --- a/src/microbe_stage/FluidSystem.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System; -using Godot; - -public class FluidSystem -{ - // private const float MaxForceApplied = 0.525f; - - private const float DISTURBANCE_TIMESCALE = 0.001f; - private const float CURRENTS_TIMESCALE = 0.001f / 500.0f; - private const float CURRENTS_STRETCHING_MULTIPLIER = 1.0f / 10.0f; - private const float MIN_CURRENT_INTENSITY = 0.4f; - private const float DISTURBANCE_TO_CURRENTS_RATIO = 0.15f; - private const float POSITION_SCALING = 0.9f; - - private readonly FastNoiseLite noiseDisturbancesX; - private readonly FastNoiseLite noiseDisturbancesY; - private readonly FastNoiseLite noiseCurrentsX; - private readonly FastNoiseLite noiseCurrentsY; - - // private readonly Vector2 scale = new Vector2(0.05f, 0.05f); - - private readonly Node worldRoot; - - // TODO: this should be probably saved in the future to make currents consistent after loading a save - private float millisecondsPassed; - - public FluidSystem(Node worldRoot) - { - noiseDisturbancesX = new FastNoiseLite(69); - noiseDisturbancesX.SetNoiseType(FastNoiseLite.NoiseType.Perlin); - - noiseDisturbancesY = new FastNoiseLite(13); - noiseDisturbancesY.SetNoiseType(FastNoiseLite.NoiseType.Perlin); - - noiseCurrentsX = new FastNoiseLite(420); - noiseCurrentsX.SetNoiseType(FastNoiseLite.NoiseType.Perlin); - - noiseCurrentsY = new FastNoiseLite(1337); - noiseCurrentsY.SetNoiseType(FastNoiseLite.NoiseType.Perlin); - this.worldRoot = worldRoot; - } - - public void Process(float delta) - { - millisecondsPassed += delta / 1000.0f; - } - - public void PhysicsProcess(float delta) - { - _ = delta; - foreach (var body in worldRoot.GetChildrenToProcess(Constants.FLUID_EFFECT_GROUP)) - { - var pos = new Vector2(body.Translation.x, body.Translation.z); - var vel = VelocityAt(pos) * Constants.MAX_FORCE_APPLIED_BY_CURRENTS; - body.ApplyCentralImpulse(new Vector3(vel.x, 0, vel.y)); - } - } - - public Vector2 VelocityAt(Vector2 position) - { - var scaledPosition = position * POSITION_SCALING; - - float disturbancesX = noiseDisturbancesX.GetNoise(scaledPosition.x, scaledPosition.y, - millisecondsPassed * DISTURBANCE_TIMESCALE); - float disturbancesY = noiseDisturbancesY.GetNoise(scaledPosition.x, scaledPosition.y, - millisecondsPassed * DISTURBANCE_TIMESCALE); - - float currentsX = noiseCurrentsX.GetNoise(scaledPosition.x * CURRENTS_STRETCHING_MULTIPLIER, - scaledPosition.y, millisecondsPassed * CURRENTS_TIMESCALE); - float currentsY = noiseCurrentsY.GetNoise(scaledPosition.x, scaledPosition.y * CURRENTS_STRETCHING_MULTIPLIER, - millisecondsPassed * CURRENTS_TIMESCALE); - - var disturbancesVelocity = new Vector2(disturbancesX, disturbancesY); - var currentsVelocity = new Vector2( - Math.Abs(currentsX) > MIN_CURRENT_INTENSITY ? currentsX : 0.0f, - Math.Abs(currentsY) > MIN_CURRENT_INTENSITY ? currentsY : 0.0f); - - return (disturbancesVelocity * DISTURBANCE_TO_CURRENTS_RATIO) + - (currentsVelocity * (1.0f - DISTURBANCE_TO_CURRENTS_RATIO)); - } -} diff --git a/src/microbe_stage/ICellProperties.cs b/src/microbe_stage/ICellProperties.cs index 21f6764e0e8..8b38f481eda 100644 --- a/src/microbe_stage/ICellProperties.cs +++ b/src/microbe_stage/ICellProperties.cs @@ -11,6 +11,10 @@ public interface ICellProperties public float MembraneRigidity { get; set; } public Color Colour { get; set; } public bool IsBacteria { get; set; } + + // TODO: this is a bit expensive property now as this uses MicrobeInternalCalculations.CalculateRotationSpeed which + // now needs to generate the full physics shape to calculate inertia. Maybe the users of this could be switched + // to a lazy method to ensure that species generation and modification is faster? public float BaseRotationSpeed { get; set; } /// @@ -30,6 +34,7 @@ public interface ICellProperties public static class CellPropertiesHelpers { + // TODO: this can probably be deleted entirely as unused old code /// /// The total compounds in the composition of all organelles /// diff --git a/src/microbe_stage/IComputedMembraneData.cs b/src/microbe_stage/IComputedMembraneData.cs deleted file mode 100644 index c0eb844e26d..00000000000 --- a/src/microbe_stage/IComputedMembraneData.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using Godot; - -/// -/// Access to membrane properties that are needed for caching generated membranes -/// -public interface IComputedMembraneData : ICacheableData -{ - public IReadOnlyList OrganellePositions { get; } - public MembraneType Type { get; } -} - -public static class MembraneComputationHelpers -{ - public static long ComputeMembraneDataHash(this IComputedMembraneData data) - { - var positions = data.OrganellePositions; - - unchecked - { - var nameHash = data.Type.InternalName.GetHashCode(); - long hash = 1409 + nameHash + ((long)nameHash << 28); - - hash ^= (positions.Count + 1) * 7793; - int hashMultiply = 1; - - foreach (var position in positions) - { - var posHash = position.GetHashCode(); - hash ^= (hashMultiply * posHash) ^ ((5081L * hashMultiply * hashMultiply + posHash) << 32); - ++hashMultiply; - } - - return hash; - } - } - - public static bool MembraneDataFieldsEqual(this IComputedMembraneData data, IComputedMembraneData other) - { - return data.Type.Equals(other.Type) && data.OrganellePositions.SequenceEqual(other.OrganellePositions); - } -} - -/// -/// Final, computed data for a membrane. This is a separate class to support caching this -/// -/// -/// -/// TODO: check if this needs to dispose the GeneratedMesh. That'll be a bit difficult as existing membranes -/// can still be using this object even when this is removed from the cache -/// -/// -public class ComputedMembraneData : IComputedMembraneData -{ - public ComputedMembraneData(IReadOnlyList organellePositions, MembraneType type, List vertices2D, - ArrayMesh mesh, int surfaceIndex) - { - OrganellePositions = organellePositions; - Type = type; - Vertices2D = vertices2D; - GeneratedMesh = mesh; - SurfaceIndex = surfaceIndex; - } - - public IReadOnlyList OrganellePositions { get; } - public MembraneType Type { get; } - public List Vertices2D { get; } - - public ArrayMesh GeneratedMesh { get; } - public int SurfaceIndex { get; } - - public bool MatchesCacheParameters(ICacheableData cacheData) - { - if (cacheData is IComputedMembraneData data) - return this.MembraneDataFieldsEqual(data); - - return false; - } - - public long ComputeCacheHash() - { - return this.ComputeMembraneDataHash(); - } -} diff --git a/src/microbe_stage/IEngulfable.cs b/src/microbe_stage/IEngulfable.cs deleted file mode 100644 index 7d793e49c35..00000000000 --- a/src/microbe_stage/IEngulfable.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System.Collections.Generic; - -/// -/// Objects that can be engulfed by a microbe. -/// -[UseThriveSerializer] -public interface IEngulfable : IGraphicalEntity -{ - /// - /// The engulf size of this engulfable object. - /// - /// - /// - /// Some classes may scale this with for richer gameplay depth. - /// For example see . - /// - /// - public float EngulfSize { get; } - - public float Radius { get; } - - public EntityReference HostileEngulfer { get; } - - /// - /// The current step of phagocytosis process this engulfable is currently in. - /// If not phagocytized, state is . - /// - public PhagocytosisPhase PhagocytosisStep { get; set; } - - /// - /// What specific enzyme needed to digest (break down) this engulfable. If null default is used (lipase). - /// - public Enzyme? RequisiteEnzymeToDigest { get; } - - public CompoundBag Compounds { get; } - - /// - /// The value for how much this engulfable has been digested in the range of 0 to 1, - /// where 1 means fully digested. - /// - public float DigestedAmount { get; set; } - - /// - /// Additional means bonus compounds that can be acquired on top of from this engulfable - /// for predating microbes. - /// - /// - /// - /// Use case: Say you want to engulf and digest an object, but it only has little resources stored in it, you - /// want to boost (or even add compounds to) this by specifying additional digestible compounds. - /// For example on how this is already used: In microbes every organelle has a build cost, which is ammonia and - /// phosphates. Most of the times, AI microbes have none of these so we want to reward players fairly by giving - /// them enough of these two. - /// - /// - /// The additional compounds, null if there's none. - public Dictionary? CalculateAdditionalDigestibleCompounds(); - - /// - /// Called once when this engulfable is currently being attempted to be engulfed by a microbe. - /// - public void OnAttemptedToBeEngulfed(); - - /// - /// Called once when this engulfable has been completely internalized by a microbe. - /// - public void OnIngestedFromEngulfment(); - - /// - /// Called once when this engulfable has been expelled by a microbe. - /// - public void OnExpelledFromEngulfment(); -} diff --git a/src/microbe_stage/IMembraneDataSource.cs b/src/microbe_stage/IMembraneDataSource.cs new file mode 100644 index 00000000000..8a12b8ce7f6 --- /dev/null +++ b/src/microbe_stage/IMembraneDataSource.cs @@ -0,0 +1,171 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using Godot; + +/// +/// Access to membrane properties that are needed for generating and caching generated membrane shapes +/// +public interface IMembraneDataSource +{ + public Vector2[] HexPositions { get; } + public int HexPositionCount { get; } + public MembraneType Type { get; } +} + +/// +/// Struct that holds parameters about a membrane generation request +/// +public struct MembraneGenerationParameters : IMembraneDataSource +{ + public MembraneGenerationParameters(Vector2[] hexPositions, int hexPositionCount, MembraneType type) + { + HexPositions = hexPositions; + HexPositionCount = hexPositionCount; + Type = type; + } + + public Vector2[] HexPositions { get; } + public int HexPositionCount { get; } + + public MembraneType Type { get; } +} + +/// +/// Helpers related to the source data needed to be fed into the membrane generation algorithm +/// +public static class MembraneComputationHelpers +{ + private static readonly HexPositionComparer HexComparer = new(); + + public static Vector2[] PrepareHexPositionsForMembraneCalculations(IReadOnlyList organelles, + out int length) + { + // First calculate needed size and then allocate the memory and fill it + length = 0; + + var collectionCount = organelles.Count; + + for (int i = 0; i < collectionCount; ++i) + { + length += organelles[i].Definition.HexCount; + } + + var result = ArrayPool.Shared.Rent(length); + + for (int i = 0; i < collectionCount; ++i) + { + // The membrane needs hex positions (rather than organelle positions) to handle cells with multihex + // organelles + var entry = organelles[i]; + + foreach (var hex in entry.Definition.GetRotatedHexes(entry.Orientation)) + { + var hexCartesian = Hex.AxialToCartesian(entry.Position + hex); + result[i] = new Vector2(hexCartesian.x, hexCartesian.z); + } + } + + // Points are sorted to ensure same shape but different order of organelles results in reusable data + // TODO: check if this is actually a good idea or it is better to not sort and let duplicate membrane data + // just be generated + Array.Sort(result, 0, length, HexComparer); + + return result; + } + + public static MembranePointData GetOrComputeMembraneShape(IReadOnlyList organelles, + MembraneType membraneType) + { + var hexes = PrepareHexPositionsForMembraneCalculations(organelles, out var length); + + var cache = ProceduralDataCache.Instance; + + var hash = ComputeMembraneDataHash(hexes, length, membraneType); + + var result = cache.ReadMembraneData(hash); + + if (result != null) + { + // Return the no longer needed hex positions to the cache (when we need to generate new data, the hexes + // will get owned by the cache entry) + ArrayPool.Shared.Return(hexes); + + return result; + } + + // Need to compute the data now, it doesn't exist in the cache + result = MembraneShapeGenerator.GetThreadSpecificGenerator().GenerateShape(hexes, length, membraneType); + + cache.WriteMembraneData(result); + return result; + } + + public static long ComputeMembraneDataHash(Vector2[] positions, int count, MembraneType type) + { + var nameHash = type.InternalName.GetHashCode(); + + unchecked + { + long hash = 1409 + nameHash + ((long)nameHash << 28); + + hash ^= (count + 1) * 7793; + int hashMultiply = 1; + + for (int i = 0; i < count; ++i) + { + var posHash = positions[i].GetHashCode(); + + // TODO: switch to using rotate left here once we can (after Godot 4) + hash ^= (hashMultiply * posHash) ^ ((5081L * hashMultiply * hashMultiply + posHash) << 32); + ++hashMultiply; + } + + return hash; + } + } + + public static long ComputeMembraneDataHash(this IMembraneDataSource dataSource) + { + return ComputeMembraneDataHash(dataSource.HexPositions, dataSource.HexPositionCount, dataSource.Type); + } + + public static bool MembraneDataFieldsEqual(this IMembraneDataSource dataSource, IMembraneDataSource other) + { + return dataSource.MembraneDataFieldsEqual(other.HexPositions, other.HexPositionCount, other.Type); + } + + public static bool MembraneDataFieldsEqual(this IMembraneDataSource dataSource, Vector2[] otherPoints, + int otherPointCount, MembraneType otherType) + { + if (!dataSource.Type.Equals(otherType)) + return false; + + if (dataSource.HexPositionCount != otherPointCount) + return false; + + var count = dataSource.HexPositionCount; + + var sourcePoints = dataSource.HexPositions; + + for (int i = 0; i < count; ++i) + { + if (sourcePoints[i] != otherPoints[i]) + return false; + } + + return true; + } + + private class HexPositionComparer : IComparer + { + public int Compare(Vector2 first, Vector2 second) + { + var xComparison = first.x.CompareTo(second.x); + if (xComparison != 0) + return xComparison; + + return first.y.CompareTo(second.y); + } + } +} diff --git a/src/microbe_stage/IMicrobeAI.cs b/src/microbe_stage/IMicrobeAI.cs deleted file mode 100644 index cd4ee763b48..00000000000 --- a/src/microbe_stage/IMicrobeAI.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; - -public interface IMicrobeAI -{ - public float TimeUntilNextAIUpdate { get; set; } - - /// - /// Runs AI thinking on this microbe. Should only be called by the MicrobeAISystem. - /// This is ran in parallel so this shouldn't affect the states of other microbes or rely on their variables that - /// the AI updates. Otherwise the results are not deterministic. - /// - /// Elapsed time in seconds. - /// Randomness source - /// Common data for AI agents, should not be modified - public void AIThink(float delta, Random random, MicrobeAICommonData data); -} diff --git a/src/microbe_stage/IOrganelleComponent.cs b/src/microbe_stage/IOrganelleComponent.cs index 2c3d03b6bee..24db5af4ce1 100644 --- a/src/microbe_stage/IOrganelleComponent.cs +++ b/src/microbe_stage/IOrganelleComponent.cs @@ -1,22 +1,35 @@ -using Godot; +using Components; +using DefaultEcs; /// /// Base interface that all organelle components need to implement /// public interface IOrganelleComponent { + /// + /// When true this component gets sync processing (Godot calls, other non-thread safe things allowed) + /// + public bool UsesSyncProcess { get; } + public void OnAttachToCell(PlacedOrganelle organelle); - public void OnDetachFromCell(PlacedOrganelle organelle); /// /// This update is called from multiple threads at once so only operations that aren't timing sensitive between /// multiple objects and don't modify Godot data are allowed. Everything else needs to be in /// /// + /// Organelle container instance this organelle is inside + /// Entity reference of the entity that contains this organelle /// Time since the last update in seconds - public void UpdateAsync(float delta); + public void UpdateAsync(ref OrganelleContainer organelleContainer, in Entity microbeEntity, float delta); - public void UpdateSync(); - - public void OnShapeParentChanged(Microbe newShapeParent, Vector3 offset); + /// + /// Sync processing that is allowed to do non-thread safe things (this is called on the main thread). Only called + /// if is true. For parameter explanations see . + /// + /// + /// If this is called but is false derived types are allowed to throw + /// this exception + /// + public void UpdateSync(in Entity microbeEntity, float delta); } diff --git a/src/microbe_stage/IOrganelleComponentFactory.cs b/src/microbe_stage/IOrganelleComponentFactory.cs index c4d1408fd23..cc1e15eb7c2 100644 --- a/src/microbe_stage/IOrganelleComponentFactory.cs +++ b/src/microbe_stage/IOrganelleComponentFactory.cs @@ -7,6 +7,12 @@ public interface IOrganelleComponentFactory /// Creates a new organelle component of the type that this factory makes /// /// The created component. + /// + /// + /// TODO: refactor this to take in to allow more easily initializing the + /// component state in the constructor rather than using null overrides so much. + /// + /// public IOrganelleComponent Create(); /// diff --git a/src/microbe_stage/IProcessable.cs b/src/microbe_stage/IProcessable.cs deleted file mode 100644 index 0ae94e28173..00000000000 --- a/src/microbe_stage/IProcessable.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.Collections.Generic; - -/// -/// Thing that has processes for ProcessSystem to run -/// -public interface IProcessable -{ - /// - /// The active processes that ProcessSystem handles - /// - /// - /// - /// All processes that perform the same action should be combined together rather than listing that process - /// multiple times in this list (as that results in unexpected things as that isn't semantically how this - /// property is meant to be structured) - /// - /// - public List ActiveProcesses { get; } - - /// - /// Input and output storage for the compounds used in processes - /// - public CompoundBag ProcessCompoundStorage { get; } - - /// - /// Optional statistics object to get data out of the process system on what processes it actually ran - /// - public ProcessStatistics? ProcessStatistics { get; } -} diff --git a/src/microbe_stage/IReadonlyCompoundClouds.cs b/src/microbe_stage/IReadonlyCompoundClouds.cs new file mode 100644 index 00000000000..f2a523f6b32 --- /dev/null +++ b/src/microbe_stage/IReadonlyCompoundClouds.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using Godot; + +/// +/// Read-only access to the compound clouds to allow some places safely to read data in a multithreaded way regarding +/// the clouds +/// +public interface IReadonlyCompoundClouds +{ + /// + /// Returns the amount of specified compound available at the given position + /// + /// The compound to look for + /// Position to look at + /// + /// Adjusts the resulting amount by multiplying with this. Used to estimate available amounts when taking into + /// absorption effectiveness ratio. + /// + /// The available amount or 0 + public float AmountAvailable(Compound compound, Vector3 worldPosition, float fraction); + + /// + /// Returns the total amount of all compounds at position + /// + public void GetAllAvailableAt(Vector3 worldPosition, Dictionary result, + bool onlyAbsorbable = true); + + /// + /// Tries to find specified compound as close to the point as possible. + /// + /// Position to search around + /// What compound to search for + /// How wide to search around the point + /// Limits search to only find concentrations higher than this + /// The nearest found point for the compound or null + public Vector3? FindCompoundNearPoint(Vector3 position, Compound compound, float searchRadius = 200, + float minConcentration = 120); +} diff --git a/src/microbe_stage/ISpawnSystem.cs b/src/microbe_stage/ISpawnSystem.cs index 68f82d546fc..8948e38690e 100644 --- a/src/microbe_stage/ISpawnSystem.cs +++ b/src/microbe_stage/ISpawnSystem.cs @@ -1,4 +1,5 @@ -using Godot; +using DefaultEcs.Command; +using Godot; using Newtonsoft.Json; /// @@ -8,34 +9,32 @@ public interface ISpawnSystem { /// - /// Prepares the spawn system for a new game - /// - /// - /// - /// TODO: if later spawn systems than microbe don't need this either, this should probably be refactored out - /// - /// - public void Init(); - - /// - /// Clears the registered spawners + /// Despawns all spawned entities /// - public void Clear(); + public void DespawnAll(); /// - /// Despawns all spawned entities + /// Reports the current player position around which spawning happens. Needs to be called before + /// /// - public void DespawnAll(); + public void ReportPlayerPosition(Vector3 position); /// /// Processes spawning and despawning things /// - public void Process(float delta, Vector3 playerPosition); + public void Update(float delta); /// - /// Adds an externally spawned entity to be despawned and tracked by the system + /// Notifies this that an externally created entity is now in the world. And needs to be despawned by this. + /// Used to setup the despawn radius for it and make sure entity count is up to date. /// - public void AddEntityToTrack(ISpawned entity); + /// The entity that needs proper despawning support + /// + /// How far the entity can be from the player before being despawned, this value needs to be squared (this is + /// done to speed up distance checks). + /// + /// How much "space" the entity takes up in the spawn system + public void NotifyExternalEntitySpawned(in EntityRecord entity, float despawnRadiusSquared, float entityWeight); /// /// Checks if the approximate entity count is not too much over the entity limit diff --git a/src/microbe_stage/ISpawned.cs b/src/microbe_stage/ISpawned.cs deleted file mode 100644 index 4405bcce760..00000000000 --- a/src/microbe_stage/ISpawned.cs +++ /dev/null @@ -1,16 +0,0 @@ -/// -/// All nodes that can be spawned with the spawn system must implement this interface -/// -public interface ISpawned : IEntity -{ - /// - /// If the squared distance to the player of this object is - /// greater than this, it is despawned. - /// - public int DespawnRadiusSquared { get; set; } - - /// - /// How much this entity contributes to the entity limit relative to a single node - /// - public float EntityWeight { get; } -} diff --git a/src/microbe_stage/ISpeciesMemberLocationData.cs b/src/microbe_stage/ISpeciesMemberLocationData.cs new file mode 100644 index 00000000000..c66d316f1f4 --- /dev/null +++ b/src/microbe_stage/ISpeciesMemberLocationData.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using DefaultEcs; +using Godot; + +/// +/// Provides fast access to microbe species member location information +/// +/// +/// +/// TODO: should probably make a dedicated system for this rather than keeping the functionality in the microbe AI +/// and having external places in the code call it +/// +/// +public interface ISpeciesMemberLocationData +{ + /// + /// Returns a list of all known members of the given species if any exist + /// + /// The species to look for + /// List with members of this species along with their positions and sizes + public IReadOnlyList<(Entity Entity, Vector3 Position, float EngulfSize)>? GetSpeciesMembers(Species species); +} + +public static class SpeciesMemberLocationDataHelpers +{ + /// + /// Tries to find specified Species as close to the point as possible. + /// + /// Access to data about microbe positions + /// Position to search around + /// What species to search for + /// How wide to search around the point + /// + /// When this returns true then this contains the entity that is the closest species member to the given position + /// + /// The position of when that value is valid + /// True if a nearest species member is found + public static bool FindSpeciesNearPoint(this ISpeciesMemberLocationData locationData, Vector3 position, + Species species, float searchRadius, out Entity foundMicrobe, out Vector3 foundPosition) + { + if (searchRadius < 1) + throw new ArgumentException("searchRadius must be >= 1"); + + bool closestFound = false; + float nearestDistanceSquared = float.MaxValue; + + var searchRadiusSquared = searchRadius * searchRadius; + + var members = locationData.GetSpeciesMembers(species); + + // These are set here to make the compiler allow us to simply exit this method as the exit condition is complex + foundMicrobe = default; + foundPosition = Vector3.Zero; + + if (members == null) + { + return false; + } + + foreach (var microbe in members) + { + var microbeGlobalPosition = microbe.Position; + + // Skip candidates for performance + if (Math.Abs(microbeGlobalPosition.x - position.x) > searchRadius || + Math.Abs(microbeGlobalPosition.y - position.y) > searchRadius) + { + continue; + } + + var distanceSquared = (microbeGlobalPosition - position).LengthSquared(); + + if (distanceSquared < nearestDistanceSquared && + distanceSquared < searchRadiusSquared && + distanceSquared > 1) + { + closestFound = true; + nearestDistanceSquared = distanceSquared; + foundMicrobe = microbe.Entity; + foundPosition = microbeGlobalPosition; + } + } + + return closestFound; + } +} diff --git a/src/microbe_stage/Membrane.cs b/src/microbe_stage/Membrane.cs index 78819256626..2b891af51a9 100644 --- a/src/microbe_stage/Membrane.cs +++ b/src/microbe_stage/Membrane.cs @@ -1,145 +1,79 @@ using System; -using System.Collections.Generic; using Godot; -using Newtonsoft.Json; -using Array = Godot.Collections.Array; /// /// Membrane for microbes /// -public class Membrane : MeshInstance, IComputedMembraneData +public class Membrane : MeshInstance { [Export] public ShaderMaterial? MaterialToEdit; - private static readonly List PreviewMembraneOrganellePositions = new() { new Vector2(0, 0) }; + // It used to be the case that membrane could be previewed in the Godot editor, if anyone is still interested in + // that feature, please reimplement it - /// - /// Stores the generated 2-Dimensional membrane. Needed for contains calculations - /// - private readonly List vertices2D = new(); +#pragma warning disable CA2213 + private Texture? albedoTexture; /// - /// Buffer for starting points when generating membrane data + /// Shared cache data about the calculated points for this membrane. This is not disposed as the cache manager + /// handles doing that. /// - private readonly List startingBuffer = new(); + private MembranePointData membraneData = null!; +#pragma warning restore CA2213 + + private string? currentlyLoadedAlbedoTexture; private float healthFraction = 1.0f; private float wigglyNess = 1.0f; private float sizeWigglyNessDampeningFactor = 0.22f; private float movementWigglyNess = 1.0f; private float sizeMovementWigglyNessDampeningFactor = 0.32f; - private Color tint = Colors.White; - private float dissolveEffectValue; - - private MembraneType? type; - -#pragma warning disable CA2213 - private Texture? albedoTexture; - private Texture noiseTexture = null!; -#pragma warning restore CA2213 - - private string? currentlyLoadedAlbedoTexture; - - private bool dirty = true; - private bool radiusIsDirty = true; - private bool convexShapeIsDirty = true; - private float cachedRadius; - private Vector3[] cachedConvexShape = null!; /// - /// Amount of segments on one side of the above described - /// square. The amount of points on the side of the membrane. + /// When true the material properties need to be reapplied /// - private int membraneResolution = Constants.MEMBRANE_RESOLUTION; + public bool Dirty { get; set; } = true; /// - /// When true the mesh needs to be regenerated and material properties applied - /// - public bool Dirty - { - get => dirty; - set - { - if (value) - { - radiusIsDirty = true; - convexShapeIsDirty = true; - } - - dirty = value; - } - } - - /// - /// Organelle positions of the microbe, needs to be set for the membrane to appear. This includes all hex - /// positions of the organelles to work better with cells that have multihex organelles. As a result this - /// doesn't contain just the center positions of organelles but will contain multiple entries for multihex - /// organelles. + /// This is true when a new instance is being generated but it is not ready yet. /// /// /// - /// The contents in this list should not be modified, a new list should be assigned. + /// This is automatically reset when the next time is set /// /// - public IReadOnlyList OrganellePositions { get; set; } = PreviewMembraneOrganellePositions; + public bool IsChangingShape { get; set; } /// - /// Returns a convex shaped 3-Dimensional array of vertices from the generated . + /// Generated membrane point data. Must be set when instances of this class is created before anything is allowed + /// to be done with this. /// - /// - /// - /// NOTE: This is not the same as the 3D vertices used for the visuals. - /// - /// - [JsonIgnore] - public Vector3[] ConvexShape + public MembranePointData MembraneData { - get + get => membraneData; + set { - if (convexShapeIsDirty) - { - if (Dirty) - Update(); - - float height = 0.1f; + // Cache returns the same instance, so skip doing anything here if we got applied the same data again as we + // already had + if (ReferenceEquals(membraneData, value)) + return; - if (Type.CellWall) - height = 0.05f; + bool reapply = membraneData != null!; - cachedConvexShape = new Vector3[vertices2D.Count]; - for (var i = 0; i < vertices2D.Count; ++i) - { - cachedConvexShape[i] = new Vector3(vertices2D[i].x, height / 2, vertices2D[i].y); - } + membraneData = value; + IsChangingShape = false; - convexShapeIsDirty = false; - } + // This needs to be marked dirty purely to support swapping membrane types, other shader parameters would + // just happily keep working when the material is applied to the new mesh + Dirty = true; - return cachedConvexShape; + if (reapply) + SetMesh(); } } - /// - /// The type of the membrane. - /// - /// When trying to read before this is initialized - /// If value is attempted to be set to null - public MembraneType Type - { - get => type ?? throw new InvalidOperationException("Membrane type has not been set yet"); - set - { - if (value == null) - throw new ArgumentNullException(); - - if (type == value) - return; - - type = value; - dirty = true; - } - } + public MembraneType Type => membraneData.Type; /// /// How healthy the cell is, mixes in a damaged texture. Range 0.0f - 1.0f @@ -154,7 +88,17 @@ public float HealthFraction return; healthFraction = value; - ApplyHealth(); + + // Health is a special case as this is applied so often compared to the other properties that this has an + // apply shortcut to reduce processing + if (MaterialToEdit == null) + { + Dirty = true; + } + else + { + ApplyHealth(); + } } } @@ -167,7 +111,7 @@ public float WigglyNess set { wigglyNess = Mathf.Clamp(value, 0.0f, 1.0f); - ApplyWiggly(); + Dirty = true; } } @@ -177,71 +121,36 @@ public float MovementWigglyNess set { movementWigglyNess = Mathf.Clamp(value, 0.0f, 1.0f); - ApplyMovementWiggly(); - } - } - - public Color Tint - { - get => tint; - set - { - // Desaturate it here so it looks nicer (could implement as method that - // could be called i suppose) - - // According to stack overflow HSV and HSB are the same thing - value.ToHsv(out var hue, out var saturation, out var brightness); - - value = Color.FromHsv(hue, saturation * 0.75f, brightness, - Mathf.Clamp(value.a, 0.4f - brightness * 0.3f, 1.0f)); - - if (tint == value) - return; - - tint = value; - - // If we already have created a material we need to re-apply it - ApplyTint(); + Dirty = true; } } /// /// Quick radius value for the membrane size /// - public float EncompassingCircleRadius + public float EncompassingCircleRadius => membraneData.Radius; + + public static Color MembraneTintFromSpeciesColour(Color color) { - get - { - if (radiusIsDirty) - { - cachedRadius = CalculateEncompassingCircleRadius(); - radiusIsDirty = false; - } + // Desaturate it here so it looks nicer (could implement as method that + // could be called i suppose) - return cachedRadius; - } - } + // According to stack overflow HSV and HSB are the same thing + color.ToHsv(out var hue, out var saturation, out var brightness); - public float DissolveEffectValue - { - get => dissolveEffectValue; - set - { - dissolveEffectValue = value; - ApplyDissolveEffect(); - } + return Color.FromHsv(hue, saturation * 0.75f, brightness, + Mathf.Clamp(color.a, 0.4f - brightness * 0.3f, 1.0f)); } public override void _Ready() { - type ??= SimulationParameters.Instance.GetMembrane("single"); + if (membraneData == null!) + throw new InvalidOperationException("Membrane was not property initialized with membrane data"); if (MaterialToEdit == null) - GD.PrintErr("MaterialToEdit on Membrane is not set"); - - noiseTexture = GD.Load("res://assets/textures/dissolve_noise.tres"); + throw new Exception("MaterialToEdit on Membrane is not set"); - Dirty = true; + SetMesh(); } public override void _Process(float delta) @@ -249,7 +158,8 @@ public override void _Process(float delta) if (!Dirty) return; - Update(); + Dirty = false; + ApplyAllMaterialParameters(); } /// @@ -264,16 +174,18 @@ public bool Contains(float x, float y) { bool crosses = false; - int n = vertices2D.Count; + int n = membraneData.VertexCount; + var vertices = membraneData.Vertices2D; + for (int i = 0; i < n - 1; i++) { - if ((vertices2D[i].y <= y && y < vertices2D[i + 1].y) || - (vertices2D[i + 1].y <= y && y < vertices2D[i].y)) + if ((vertices[i].y <= y && y < vertices[i + 1].y) || + (vertices[i + 1].y <= y && y < vertices[i].y)) { - if (x < (vertices2D[i + 1].x - vertices2D[i].x) * - (y - vertices2D[i].y) / - (vertices2D[i + 1].y - vertices2D[i].y) + - vertices2D[i].x) + if (x < (vertices[i + 1].x - vertices[i].x) * + (y - vertices[i].y) / + (vertices[i + 1].y - vertices[i].y) + + vertices[i].x) { crosses = !crosses; } @@ -291,215 +203,41 @@ public bool Contains(float x, float y) /// Used for finding out where to put an external organelle. /// /// - /// The returned Vector is in world coordinates (x, 0, z) and - /// not in internal membrane coordinates (x, y, 0). This is so - /// that gameplay code doesn't have to do the conversion - /// everywhere this is used. + /// The returned Vector is in world coordinates (x, 0, z) and not in internal membrane coordinates (x, y, 0). + /// This is so that gameplay code doesn't have to do the conversion everywhere this is used. /// /// public Vector3 GetVectorTowardsNearestPointOfMembrane(float x, float y) { - // Calculate now if dirty to make flagella positioning only have to be done once - // NOTE: that flagella position should only be read once all organelles that are - // going to be added / removed on this game update are done. - if (Dirty) - Update(); - float organelleAngle = Mathf.Atan2(y, x); Vector2 closestSoFar = new Vector2(0, 0); float angleToClosest = Mathf.Pi * 2; - foreach (var vertex in vertices2D) + int count = membraneData.VertexCount; + var vertices = membraneData.Vertices2D; + + for (int i = 0; i < count; ++i) { - if (Mathf.Abs(Mathf.Atan2(vertex.y, vertex.x) - organelleAngle) < - angleToClosest) + var vertex = vertices[i]; + if (Mathf.Abs(Mathf.Atan2(vertex.y, vertex.x) - organelleAngle) < angleToClosest) { closestSoFar = new Vector2(vertex.x, vertex.y); - angleToClosest = - Mathf.Abs(Mathf.Atan2(vertex.y, vertex.x) - organelleAngle); + angleToClosest = Mathf.Abs(Mathf.Atan2(vertex.y, vertex.x) - organelleAngle); } } return new Vector3(closestSoFar.x, 0, closestSoFar.y); } - public bool MatchesCacheParameters(ICacheableData cacheData) - { - if (cacheData is IComputedMembraneData data) - return this.MembraneDataFieldsEqual(data); - - return false; - } - - public long ComputeCacheHash() - { - return this.ComputeMembraneDataHash(); - } - - /// - /// First generates the 2D vertices and then builds the 3D mesh - /// - private void InitializeMesh() - { - // First try to get from cache as it's very expensive to generate the membrane - var cached = this.FetchDataFromCache(ProceduralDataCache.Instance.ReadMembraneData); - - if (cached != null) - { - CopyMeshFromCache(cached); - return; - } - - // The length in pixels (probably not accurate?) of a side of the square that bounds the membrane. - // Half the side length of the original square that is compressed to make the membrane. - int cellDimensions = 10; - - foreach (var pos in OrganellePositions) - { - if (Mathf.Abs(pos.x) + 1 > cellDimensions) - { - cellDimensions = (int)Mathf.Abs(pos.x) + 1; - } - - if (Mathf.Abs(pos.y) + 1 > cellDimensions) - { - cellDimensions = (int)Mathf.Abs(pos.y) + 1; - } - } - - // Make the length longer to guarantee that everything fits easily inside the square - cellDimensions *= 100; - - startingBuffer.Clear(); - - // Integer divides are intentional here - // ReSharper disable PossibleLossOfFraction - - for (int i = membraneResolution; i > 0; i--) - { - startingBuffer.Add(new Vector2(-cellDimensions, - cellDimensions - 2 * cellDimensions / membraneResolution * i)); - } - - for (int i = membraneResolution; i > 0; i--) - { - startingBuffer.Add(new Vector2( - cellDimensions - 2 * cellDimensions / membraneResolution * i, - cellDimensions)); - } - - for (int i = membraneResolution; i > 0; i--) - { - startingBuffer.Add(new Vector2(cellDimensions, - -cellDimensions + 2 * cellDimensions / membraneResolution * i)); - } - - for (int i = membraneResolution; i > 0; i--) - { - startingBuffer.Add(new Vector2( - -cellDimensions + 2 * cellDimensions / membraneResolution * i, - -cellDimensions)); - } - - // ReSharper restore PossibleLossOfFraction - - // Get new membrane points for vertices2D - GenerateMembranePoints(startingBuffer, vertices2D); - - BuildMesh(); - } - - private int InitializeCorrectMembrane(int writeIndex, Vector3[] vertices, - Vector2[] uvs) - { - // common variables - float height = 0.1f; - float multiplier = 2.0f * Mathf.Pi; - var center = new Vector2(0.5f, 0.5f); - - // cell walls need obvious inner/outer membranes (we can worry - // about chitin later) - if (Type.CellWall) - { - height = 0.05f; - } - - vertices[writeIndex] = new Vector3(0, height / 2, 0); - uvs[writeIndex] = center; - ++writeIndex; - - for (int i = 0, end = vertices2D.Count; i < end + 1; i++) - { - // Finds the UV coordinates be projecting onto a plane and - // stretching to fit a circle. - - float currentRadians = multiplier * i / end; - - vertices[writeIndex] = new Vector3(vertices2D[i % end].x, height / 2, - vertices2D[i % end].y); - - uvs[writeIndex] = center + - new Vector2(Mathf.Cos(currentRadians), Mathf.Sin(currentRadians)) / 2; - - ++writeIndex; - } - - return writeIndex; - } - /// - /// Updates things and marks as not dirty + /// Applies the mesh to us from the shared cache data (first membrane to apply the mesh causes the mesh to be + /// created) /// - private void Update() + private void SetMesh() { - Dirty = false; - InitializeMesh(); - ApplyAllMaterialParameters(); - } - - /// - /// Return the position of the closest organelle hex to the target point. - /// - private Vector2 FindClosestOrganelleHex(Vector2 target) - { - float closestDistanceSoFar = float.MaxValue; - Vector2 closest = new Vector2(0.0f, 0.0f); - - foreach (var pos in OrganellePositions) - { - float lenToObject = (target - pos).LengthSquared(); - - if (lenToObject < closestDistanceSoFar) - { - closestDistanceSoFar = lenToObject; - closest = pos; - } - } - - return closest; - } - - /// - /// Cheaper version of contains for absorbing stuff.Calculates a - /// circle radius that contains all the points (when it is - /// placed at 0,0 local coordinate). - /// - private float CalculateEncompassingCircleRadius() - { - if (Dirty) - Update(); - - float distanceSquared = 0; - - foreach (var vertex in vertices2D) - { - var currentDistance = vertex.LengthSquared(); - if (currentDistance > distanceSquared) - distanceSquared = currentDistance; - } - - return Mathf.Sqrt(distanceSquared); + Mesh = membraneData.GeneratedMesh; + SetSurfaceMaterial(membraneData.SurfaceIndex, MaterialToEdit); } private void ApplyAllMaterialParameters() @@ -507,9 +245,7 @@ private void ApplyAllMaterialParameters() ApplyWiggly(); ApplyMovementWiggly(); ApplyHealth(); - ApplyTint(); ApplyTextures(); - ApplyDissolveEffect(); } private void ApplyWiggly() @@ -517,11 +253,6 @@ private void ApplyWiggly() if (MaterialToEdit == null) return; - // Don't apply wigglyness too early if this is dirty as getting the circle radius forces membrane position - // calculation, which we don't want to do twice when initializing a microbe - if (Dirty) - return; - float wigglyNessToApply = WigglyNess / (EncompassingCircleRadius * sizeWigglyNessDampeningFactor); @@ -533,10 +264,6 @@ private void ApplyMovementWiggly() if (MaterialToEdit == null) return; - // See comment in ApplyWiggly - if (Dirty) - return; - float wigglyNessToApply = MovementWigglyNess / (EncompassingCircleRadius * sizeMovementWigglyNessDampeningFactor); @@ -548,15 +275,10 @@ private void ApplyHealth() MaterialToEdit?.SetShaderParam("healthFraction", HealthFraction); } - private void ApplyTint() - { - MaterialToEdit?.SetShaderParam("tint", Tint); - } - private void ApplyTextures() { // We must update the texture on already-existing membranes, due to the membrane texture changing - // for the player microbe. + // for the player microbe (thanks to edits made in the cell editor). if (albedoTexture != null && currentlyLoadedAlbedoTexture == Type.AlbedoTexture) return; @@ -565,182 +287,7 @@ private void ApplyTextures() MaterialToEdit!.SetShaderParam("albedoTexture", albedoTexture); MaterialToEdit.SetShaderParam("normalTexture", Type.LoadedNormalTexture); MaterialToEdit.SetShaderParam("damagedTexture", Type.LoadedDamagedTexture); - MaterialToEdit.SetShaderParam("dissolveTexture", noiseTexture); currentlyLoadedAlbedoTexture = Type.AlbedoTexture; } - - private void ApplyDissolveEffect() - { - MaterialToEdit?.SetShaderParam("dissolveValue", DissolveEffectValue); - } - - private void CopyMeshFromCache(ComputedMembraneData cached) - { - // TODO: check if it would be better for us to just keep readonly data in the membrane cache so we could - // just copy a reference here - vertices2D.Clear(); - vertices2D.AddRange(cached.Vertices2D); - - // Apply the mesh to us - Mesh = cached.GeneratedMesh; - SetSurfaceMaterial(cached.SurfaceIndex, MaterialToEdit); - } - - /// - /// Creates the actual mesh object. Call InitializeMesh instead of this directly - /// - private void BuildMesh() - { - // This is actually a triangle list, but the index buffer is used to build - // the indices (to emulate a triangle fan) - var bufferSize = vertices2D.Count + 2; - var indexSize = vertices2D.Count * 3; - - var arrays = new Array(); - arrays.Resize((int)Mesh.ArrayType.Max); - - // Build vertex, index, and uv lists - - // Index mapping to build all triangles - var indices = new int[indexSize]; - int currentVertexIndex = 1; - - for (int i = 0; i < indexSize; i += 3) - { - indices[i] = 0; - indices[i + 1] = currentVertexIndex + 1; - indices[i + 2] = currentVertexIndex; - - ++currentVertexIndex; - } - - // Write mesh data // - var vertices = new Vector3[bufferSize]; - var uvs = new Vector2[bufferSize]; - - int writeIndex = 0; - writeIndex = InitializeCorrectMembrane(writeIndex, vertices, uvs); - - if (writeIndex != bufferSize) - throw new Exception("Membrane buffer write ended up at wrong index"); - - // Godot might do this automatically - // // Set the bounds to get frustum culling and LOD to work correctly. - // // TODO: make this more accurate by calculating the actual extents - // m_mesh->_setBounds(Ogre::Aabb(Float3::ZERO, Float3::UNIT_SCALE * 50) - // /*, false*/); - // m_mesh->_setBoundingSphereRadius(50); - - arrays[(int)Mesh.ArrayType.Vertex] = vertices; - arrays[(int)Mesh.ArrayType.Index] = indices; - arrays[(int)Mesh.ArrayType.TexUv] = uvs; - - // Create the mesh - var generatedMesh = new ArrayMesh(); - - var surfaceIndex = generatedMesh.GetSurfaceCount(); - generatedMesh.AddSurfaceFromArrays(Mesh.PrimitiveType.Triangles, arrays); - - // Apply the mesh to us - Mesh = generatedMesh; - SetSurfaceMaterial(surfaceIndex, MaterialToEdit); - - ProceduralDataCache.Instance.WriteMembraneData(CreateDataForCache(generatedMesh, surfaceIndex)); - } - - private void GenerateMembranePoints(List sourceBuffer, List targetBuffer) - { - // Move all the points in the source buffer close to organelles - // This operation used to be iterative but this is now a much faster version that moves things all the way in - // a single step - for (int i = 0, end = sourceBuffer.Count; i < end; ++i) - { - var closestOrganelle = FindClosestOrganelleHex(sourceBuffer[i]); - - var direction = (sourceBuffer[i] - closestOrganelle).Normalized(); - var movement = direction * Constants.MEMBRANE_ROOM_FOR_ORGANELLES; - - sourceBuffer[i] = closestOrganelle + movement; - } - - float circumference = 0.0f; - - for (int i = 0, end = sourceBuffer.Count; i < end; ++i) - { - circumference += (sourceBuffer[(i + 1) % end] - sourceBuffer[i]).Length(); - } - - targetBuffer.Clear(); - - var lastAddedPoint = sourceBuffer[0]; - - targetBuffer.Add(lastAddedPoint); - - float gap = circumference / sourceBuffer.Count; - float distanceToLastAddedPoint = 0.0f; - float distanceToLastPassedPoint = 0.0f; - - // Go around the membrane and place points evenly in the target buffer. - for (int i = 0, end = sourceBuffer.Count; i < end; ++i) - { - var currentPoint = sourceBuffer[i]; - var nextPoint = sourceBuffer[(i + 1) % end]; - float distance = (nextPoint - currentPoint).Length(); - - // Add a new point if the next point is too far - if (distance + distanceToLastAddedPoint - distanceToLastPassedPoint > gap) - { - var direction = (nextPoint - currentPoint).Normalized(); - var movement = direction * (gap - distanceToLastAddedPoint + distanceToLastPassedPoint); - - lastAddedPoint = currentPoint + movement; - - targetBuffer.Add(lastAddedPoint); - - if (targetBuffer.Count >= end) - break; - - distanceToLastPassedPoint = (lastAddedPoint - currentPoint).Length(); - distanceToLastAddedPoint = 0.0f; - --i; - } - else - { - distanceToLastAddedPoint += distance - distanceToLastPassedPoint; - distanceToLastPassedPoint = 0.0f; - } - } - - float waveFrequency = 2.0f * Mathf.Pi * Constants.MEMBRANE_NUMBER_OF_WAVES / targetBuffer.Count; - - float heightMultiplier = Type.CellWall ? - Constants.MEMBRANE_WAVE_HEIGHT_MULTIPLIER_CELL_WALL : - Constants.MEMBRANE_WAVE_HEIGHT_MULTIPLIER; - - float waveHeight = Mathf.Pow(circumference, Constants.MEMBRANE_WAVE_HEIGHT_DEPENDENCE_ON_SIZE) - * heightMultiplier; - - // Make the membrane wavier - for (int i = 0, end = targetBuffer.Count; i < end; ++i) - { - var point = targetBuffer[i]; - var nextPoint = targetBuffer[(i + 1) % end]; - var direction = (nextPoint - point).Normalized(); - - // Turn 90 degrees - direction = new Vector2(-direction.y, direction.x); - - var movement = direction * Mathf.Sin(waveFrequency * i) * waveHeight; - - targetBuffer[i] = point + movement; - } - } - - private ComputedMembraneData CreateDataForCache(ArrayMesh mesh, int surfaceIndex) - { - // Need to copy our data here when caching it as if we get new organelles and change we would pollute the - // cache entry - return new ComputedMembraneData(OrganellePositions, Type, new List(vertices2D), mesh, surfaceIndex); - } } diff --git a/src/microbe_stage/MembranePointData.cs b/src/microbe_stage/MembranePointData.cs new file mode 100644 index 00000000000..90b81ad7ef3 --- /dev/null +++ b/src/microbe_stage/MembranePointData.cs @@ -0,0 +1,294 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using Godot; + +/// +/// Final, computed point data for a membrane. This is a separate class from to support +/// caching this. The mesh is only initialized the first time it is accessed from the main thread. See +/// for easily using this. +/// +public sealed class MembranePointData : IMembraneDataSource, ICacheableData +{ + private readonly Lazy<(ArrayMesh Mesh, int SurfaceIndex)> finalMesh; + + private float radius; + + private bool radiusCalculated; + private bool disposed; + + public MembranePointData(Vector2[] hexPositions, int hexPositionCount, MembraneType type, + IReadOnlyList verticesToCopy) + { + HexPositions = hexPositions; + Type = type; + HexPositionCount = hexPositionCount; + + // Setup mesh to be generated (on the main thread) only when required + finalMesh = new Lazy<(ArrayMesh Mesh, int SurfaceIndex)>(() => + MembraneShapeGenerator.GetThreadSpecificGenerator().GenerateMesh(this)); + + // Copy the membrane data, this copied array can then be referenced by Membrane instances as long as there + // might exist a reference to this class instance (that's why it is only released in the finalizer) + int count = verticesToCopy.Count; + var copyTarget = ArrayPool.Shared.Rent(count); + + for (int i = 0; i < count; ++i) + { + copyTarget[i] = verticesToCopy[i]; + } + + Vertices2D = copyTarget; + VertexCount = count; + } + + ~MembranePointData() + { + Dispose(); + + // Now safe to return shared pool data that could have been referenced by a Membrane instance + ReleaseSharedData(); + } + + /// + /// Organelle positions of the microbe, must have points for membrane generation to be valid. This includes all + /// hex positions of the organelles to work better with cells that have multihex organelles. As a result this + /// doesn't contain just the center positions of organelles but will contain multiple entries for multihex + /// organelles. + /// + public Vector2[] HexPositions { get; } + + public int HexPositionCount { get; } + + public MembraneType Type { get; } + + // TODO: check all uses when switching this + public Vector2[] Vertices2D { get; } + + public int VertexCount { get; } + + public ArrayMesh GeneratedMesh => finalMesh.Value.Mesh; + public int SurfaceIndex => finalMesh.Value.SurfaceIndex; + + public float Radius + { + get + { + if (!radiusCalculated) + CalculateEncompassingCircleRadius(); + + return radius; + } + } + + public bool MatchesCacheParameters(ICacheableData cacheData) + { + if (cacheData is IMembraneDataSource data) + return this.MembraneDataFieldsEqual(data); + + return false; + } + + public long ComputeCacheHash() + { + return this.ComputeMembraneDataHash(); + } + + public void Dispose() + { + if (!disposed) + { + disposed = true; + + ArrayPool.Shared.Return(HexPositions); + } + } + + /// + /// Cheaper version of contains for absorbing stuff.Calculates a circle radius that contains all the points + /// (when it is placed at 0,0 local coordinate). Also now used for a ton of other stuff that just needs an + /// approximate membrane shape. + /// + private void CalculateEncompassingCircleRadius() + { + float distanceSquared = 0; + + foreach (var vertex in Vertices2D) + { + var currentDistance = vertex.LengthSquared(); + if (currentDistance > distanceSquared) + distanceSquared = currentDistance; + } + + radius = Mathf.Sqrt(distanceSquared); + radiusCalculated = true; + } + + private void ReleaseSharedData() + { + if (finalMesh.IsValueCreated) + finalMesh.Value.Mesh.Dispose(); + + ArrayPool.Shared.Return(Vertices2D); + } +} + +/// +/// Cache entry that holds the base collision shape of a microbe (the physics shape of the membrane) +/// +public sealed class MembraneCollisionShape : ICacheableData +{ + /// + /// Only access this through + /// + private readonly JVecF3[] membranePoints; + + private bool disposed; + + public MembraneCollisionShape(PhysicsShape shape, JVecF3[] membranePoints, int pointCount, float density, + bool isBacteria) + { + Shape = shape; + this.membranePoints = membranePoints; + PointCount = pointCount; + Density = density; + IsBacteria = isBacteria; + } + + public PhysicsShape Shape { get; } + + public int PointCount { get; } + public float Density { get; } + public bool IsBacteria { get; } + + private JVecF3[] MembranePoints + { + get + { + if (disposed) + throw new ObjectDisposedException("Cannot access membrane points after dispose"); + + return membranePoints; + } + } + + public static long ComputeMicrobeShapeCacheHash(JVecF3[] points, int pointCount, float density, bool isBacteria) + { + unchecked + { + long hash = ~(long)pointCount; + hash ^= density.GetHashCode(); + + for (int i = 0; i < pointCount; ++i) + { + hash ^= i * 17 + points[i].GetHashCode(); + } + + hash ^= isBacteria ? 7907 : 7867; + + return hash; + } + } + + public static long ComputeMicrobeShapeCacheHash(IReadOnlyList points, int pointCount, float density, + bool isBacteria) + { + if (pointCount > points.Count) + throw new ArgumentException("Point count is more than list size", nameof(pointCount)); + + unchecked + { + long hash = ~(long)pointCount; + hash ^= density.GetHashCode(); + + for (int i = 0; i < pointCount; ++i) + { + var point = points[i]; + hash ^= i * 17 + JVecF3.GetCompatibleHashCode(point.x, 0, point.y); + } + + hash ^= isBacteria ? 7907 : 7867; + + return hash; + } + } + + public bool MatchesCacheParameters(ICacheableData cacheData) + { + if (cacheData is not MembraneCollisionShape data) + return false; + + var count = PointCount; + if (data.PointCount != count) + return false; + + if (!MatchesParameters(data.Density, data.IsBacteria)) + return false; + + var points = MembranePoints; + var otherPoints = data.MembranePoints; + + for (int i = 0; i < count; ++i) + { + if (points[i] != otherPoints[i]) + return false; + } + + return true; + } + + public bool MatchesCacheParameters(IReadOnlyList otherMembranePoints, int pointCount, float density, + bool isBacteria) + { + if (!MatchesParameters(density, isBacteria)) + return false; + + var count = PointCount; + if (pointCount != count) + return false; + + var points = MembranePoints; + + for (int i = 0; i < count; ++i) + { + var point = points[i]; + var otherPoint = otherMembranePoints[i]; + + if (!point.X.Equals(otherPoint.x) || !point.Z.Equals(otherPoint.y)) + return false; + } + + return true; + } + + public bool MatchesParameters(float density, bool isBacteria) + { + // It'd probably fine to do an exact compare on the density as the calculations to derive densities + // probably are pretty stable, but it doesn't hurt if things that aren't exactly the same density are + // considered equal + if (Math.Abs(density - Density) > 0.000001f || isBacteria != IsBacteria) + return false; + + return true; + } + + public long ComputeCacheHash() + { + return ComputeMicrobeShapeCacheHash(MembranePoints, PointCount, Density, IsBacteria); + } + + public void Dispose() + { + if (!disposed) + { + disposed = true; + + // Access directly to not fail to read this data here, we are being disposed so going through the property + // would fail already. + ArrayPool.Shared.Return(membranePoints); + + // Don't dispose the shape as it can still be used by other places on the C# side (the finalizer on the C# + // class will then signal to the native side that the shape is no longer used) + } + } +} diff --git a/src/microbe_stage/MembraneShapeGenerator.cs b/src/microbe_stage/MembraneShapeGenerator.cs new file mode 100644 index 00000000000..c11247ca10a --- /dev/null +++ b/src/microbe_stage/MembraneShapeGenerator.cs @@ -0,0 +1,343 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using Godot; +using Array = Godot.Collections.Array; + +/// +/// This implements the membrane algorithm from going from 2D hex locations to a membrane mesh that can be used for +/// rendering or calculations that use the membrane point +/// +public class MembraneShapeGenerator +{ + private static readonly ThreadLocal ThreadLocalGenerator = + new(() => new MembraneShapeGenerator()); + + /// + /// Amount of segments on one side of the above described square. The amount of points on the side of + /// the membrane. + /// + private static readonly int MembraneResolution = Constants.MEMBRANE_RESOLUTION; + + /// + /// Stores the generated 2-Dimensional membrane. Needed as easily resizable target work area. Data is copied + /// from here to for actual usage (and checks like containing points) + /// + private readonly List vertices2D = new(); + + /// + /// Buffer for starting points when generating membrane data + /// + private readonly List startingBuffer = new(); + + /// + /// Gets a generator for the current thread. This is required to be used as the generators are not thread safe. + /// + /// A generator that can be used by the calling thread + public static MembraneShapeGenerator GetThreadSpecificGenerator() + { + return ThreadLocalGenerator.Value; + } + + /// + /// Generates the 2D points for a membrane given the parameters that affect the shape + /// + /// + /// Computed data in a cache entry format (to be used with , which should be + /// checked for existing data before computing new data) + /// + public MembranePointData GenerateShape(Vector2[] hexPositions, int hexCount, MembraneType membraneType) + { + // The length in pixels (probably not accurate?) of a side of the square that bounds the membrane. + // Half the side length of the original square that is compressed to make the membrane. + int cellDimensions = 10; + + for (int i = 0; i < hexCount; ++i) + { + var pos = hexPositions[i]; + if (Mathf.Abs(pos.x) + 1 > cellDimensions) + { + cellDimensions = (int)Mathf.Abs(pos.x) + 1; + } + + if (Mathf.Abs(pos.y) + 1 > cellDimensions) + { + cellDimensions = (int)Mathf.Abs(pos.y) + 1; + } + } + + // Make the length longer to guarantee that everything fits easily inside the square + cellDimensions *= 100; + + startingBuffer.Clear(); + + // Integer divides are intentional here + // ReSharper disable PossibleLossOfFraction + + for (int i = MembraneResolution; i > 0; i--) + { + startingBuffer.Add(new Vector2(-cellDimensions, + cellDimensions - 2 * cellDimensions / MembraneResolution * i)); + } + + for (int i = MembraneResolution; i > 0; i--) + { + startingBuffer.Add(new Vector2( + cellDimensions - 2 * cellDimensions / MembraneResolution * i, + cellDimensions)); + } + + for (int i = MembraneResolution; i > 0; i--) + { + startingBuffer.Add(new Vector2(cellDimensions, + -cellDimensions + 2 * cellDimensions / MembraneResolution * i)); + } + + for (int i = MembraneResolution; i > 0; i--) + { + startingBuffer.Add(new Vector2( + -cellDimensions + 2 * cellDimensions / MembraneResolution * i, + -cellDimensions)); + } + + // ReSharper restore PossibleLossOfFraction + + // Get new membrane points for vertices2D + GenerateMembranePoints(hexPositions, hexCount, membraneType); + + // This makes a copy of the vertices so the data is safe to modify in further calls to this method + return new MembranePointData(hexPositions, hexCount, membraneType, vertices2D); + } + + public MembranePointData GenerateShape(ref MembraneGenerationParameters parameters) + { + return GenerateShape(parameters.HexPositions, parameters.HexPositionCount, parameters.Type); + } + + public MembranePointData GenerateShape(IMembraneDataSource parameters) + { + return GenerateShape(parameters.HexPositions, parameters.HexPositionCount, parameters.Type); + } + + /// + /// Creates the visual mesh from the overall shape (see GenerateShape method above) that is already created + /// + public (ArrayMesh Mesh, int SurfaceIndex) GenerateMesh(MembranePointData shapeData) + { + // TODO: should the 3D membrane generation already happen when GenerateMembranePoints is called? + // That would reduce the load on the main thread when generating the final visual mesh, though the membrane + // properties are also used in non-graphical context (species speed) so that'd result in quite a bit of + // unnecessary computations + var mesh = BuildMesh(shapeData.Vertices2D, shapeData.VertexCount, shapeData.Type, out var surfaceIndex); + + return (mesh, surfaceIndex); + } + + private static int InitializeCorrectMembrane(Vector2[] vertices2D, int vertexCount, int writeIndex, + Vector3[] vertices, Vector2[] uvs, MembraneType membraneType) + { + // common variables + float height = 0.1f; + float multiplier = 2.0f * Mathf.Pi; + var center = new Vector2(0.5f, 0.5f); + + // cell walls need obvious inner/outer membranes (we can worry + // about chitin later) + if (membraneType.CellWall) + { + height = 0.05f; + } + + vertices[writeIndex] = new Vector3(0, height / 2, 0); + uvs[writeIndex] = center; + ++writeIndex; + + for (int i = 0, end = vertexCount; i < end + 1; i++) + { + // Finds the UV coordinates be projecting onto a plane and + // stretching to fit a circle. + + float currentRadians = multiplier * i / end; + + var sourceVertex = vertices2D[i % end]; + vertices[writeIndex] = new Vector3(sourceVertex.x, height / 2, sourceVertex.y); + + uvs[writeIndex] = center + + new Vector2(Mathf.Cos(currentRadians), Mathf.Sin(currentRadians)) / 2; + + ++writeIndex; + } + + return writeIndex; + } + + /// + /// Return the position of the closest organelle hex to the target point. + /// + private static Vector2 FindClosestOrganelleHex(Vector2[] hexPositions, int hexCount, Vector2 target) + { + float closestDistanceSoFar = float.MaxValue; + Vector2 closest = new Vector2(0.0f, 0.0f); + + for (int i = 0; i < hexCount; ++i) + { + var pos = hexPositions[i]; + float lenToObject = (target - pos).LengthSquared(); + + if (lenToObject < closestDistanceSoFar) + { + closestDistanceSoFar = lenToObject; + closest = pos; + } + } + + return closest; + } + + /// + /// Creates the actual mesh object. + /// + private static ArrayMesh BuildMesh(Vector2[] vertices2D, int vertexCount, MembraneType membraneType, + out int surfaceIndex) + { + // This is actually a triangle list, but the index buffer is used to build + // the indices (to emulate a triangle fan) + var bufferSize = vertexCount + 2; + var indexSize = vertexCount * 3; + + var arrays = new Array(); + arrays.Resize((int)Mesh.ArrayType.Max); + + // Build vertex, index, and uv lists + + // Index mapping to build all triangles + var indices = new int[indexSize]; + int currentVertexIndex = 1; + + for (int i = 0; i < indexSize; i += 3) + { + indices[i] = 0; + indices[i + 1] = currentVertexIndex + 1; + indices[i + 2] = currentVertexIndex; + + ++currentVertexIndex; + } + + // Write mesh data // + var vertices = new Vector3[bufferSize]; + var uvs = new Vector2[bufferSize]; + + int writeIndex = 0; + writeIndex = InitializeCorrectMembrane(vertices2D, vertexCount, writeIndex, vertices, uvs, membraneType); + + if (writeIndex != bufferSize) + throw new Exception("Membrane buffer write ended up at wrong index"); + + // Godot might do this automatically + // // Set the bounds to get frustum culling and LOD to work correctly. + // // TODO: make this more accurate by calculating the actual extents + // m_mesh->_setBounds(Ogre::Aabb(Float3::ZERO, Float3::UNIT_SCALE * 50) + // /*, false*/); + // m_mesh->_setBoundingSphereRadius(50); + + arrays[(int)Mesh.ArrayType.Vertex] = vertices; + arrays[(int)Mesh.ArrayType.Index] = indices; + arrays[(int)Mesh.ArrayType.TexUv] = uvs; + + // Create the mesh + var generatedMesh = new ArrayMesh(); + + surfaceIndex = generatedMesh.GetSurfaceCount(); + generatedMesh.AddSurfaceFromArrays(Mesh.PrimitiveType.Triangles, arrays); + + return generatedMesh; + } + + private void GenerateMembranePoints(Vector2[] hexPositions, int hexCount, MembraneType membraneType) + { + // Move all the points in the source buffer close to organelles + // This operation used to be iterative but this is now a much faster version that moves things all the way in + // a single step + for (int i = 0, end = startingBuffer.Count; i < end; ++i) + { + var closestOrganelle = FindClosestOrganelleHex(hexPositions, hexCount, startingBuffer[i]); + + var direction = (startingBuffer[i] - closestOrganelle).Normalized(); + var movement = direction * Constants.MEMBRANE_ROOM_FOR_ORGANELLES; + + startingBuffer[i] = closestOrganelle + movement; + } + + float circumference = 0.0f; + + for (int i = 0, end = startingBuffer.Count; i < end; ++i) + { + circumference += (startingBuffer[(i + 1) % end] - startingBuffer[i]).Length(); + } + + vertices2D.Clear(); + + var lastAddedPoint = startingBuffer[0]; + + vertices2D.Add(lastAddedPoint); + + float gap = circumference / startingBuffer.Count; + float distanceToLastAddedPoint = 0.0f; + float distanceToLastPassedPoint = 0.0f; + + // Go around the membrane and place points evenly in the target buffer. + for (int i = 0, end = startingBuffer.Count; i < end; ++i) + { + var currentPoint = startingBuffer[i]; + var nextPoint = startingBuffer[(i + 1) % end]; + float distance = (nextPoint - currentPoint).Length(); + + // Add a new point if the next point is too far + if (distance + distanceToLastAddedPoint - distanceToLastPassedPoint > gap) + { + var direction = (nextPoint - currentPoint).Normalized(); + var movement = direction * (gap - distanceToLastAddedPoint + distanceToLastPassedPoint); + + lastAddedPoint = currentPoint + movement; + + vertices2D.Add(lastAddedPoint); + + if (vertices2D.Count >= end) + break; + + distanceToLastPassedPoint = (lastAddedPoint - currentPoint).Length(); + distanceToLastAddedPoint = 0.0f; + --i; + } + else + { + distanceToLastAddedPoint += distance - distanceToLastPassedPoint; + distanceToLastPassedPoint = 0.0f; + } + } + + float waveFrequency = 2.0f * Mathf.Pi * Constants.MEMBRANE_NUMBER_OF_WAVES / vertices2D.Count; + + float heightMultiplier = membraneType.CellWall ? + Constants.MEMBRANE_WAVE_HEIGHT_MULTIPLIER_CELL_WALL : + Constants.MEMBRANE_WAVE_HEIGHT_MULTIPLIER; + + float waveHeight = Mathf.Pow(circumference, Constants.MEMBRANE_WAVE_HEIGHT_DEPENDENCE_ON_SIZE) + * heightMultiplier; + + // Make the membrane wavier + for (int i = 0, end = vertices2D.Count; i < end; ++i) + { + var point = vertices2D[i]; + var nextPoint = vertices2D[(i + 1) % end]; + var direction = (nextPoint - point).Normalized(); + + // Turn 90 degrees + direction = new Vector2(-direction.y, direction.x); + + var movement = direction * Mathf.Sin(waveFrequency * i) * waveHeight; + + vertices2D[i] = point + movement; + } + } +} diff --git a/src/microbe_stage/MembraneType.cs b/src/microbe_stage/MembraneType.cs index 2a99c84e9c4..c8d96567cbb 100644 --- a/src/microbe_stage/MembraneType.cs +++ b/src/microbe_stage/MembraneType.cs @@ -53,6 +53,9 @@ public class MembraneType : IRegistryType [JsonIgnore] public string UntranslatedName { get; private set; } = null!; + [JsonIgnore] + public bool CanEngulf => !CellWall; + public void Check(string name) { if (string.IsNullOrEmpty(IconPath)) diff --git a/src/microbe_stage/Microbe.Contact.cs b/src/microbe_stage/Microbe.Contact.cs deleted file mode 100644 index f5f6374dc8b..00000000000 --- a/src/microbe_stage/Microbe.Contact.cs +++ /dev/null @@ -1,2033 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Godot; -using Newtonsoft.Json; -using Array = Godot.Collections.Array; - -/// -/// Main script on each cell in the game. -/// Partial class: Engulf, Bind/Unbind, Colony, -/// Damage, Kill, Pilus, Membrane -/// -public partial class Microbe -{ -#pragma warning disable CA2213 - private PackedScene endosomeScene = null!; - - private PackedScene cellBurstEffectScene = null!; -#pragma warning restore CA2213 - - // private SphereShape pseudopodRangeSphereShape = null!; - - /// - /// Contains the pili this microbe has for collision checking and weather or not - /// they have the injectisome upgrade - /// - private Dictionary pilusPhysicsShapes = new(); - - private bool membraneOrganellePositionsAreDirty = true; - private bool membraneOrganellesWereUpdatedThisFrame; - - private bool destroyed; - - [JsonProperty] - private float escapeInterval; - - [JsonProperty] - private bool hasEscaped; - - /// - /// Tracks entities this is touching, for beginning engulfing and cell binding. - /// - private HashSet touchedEntities = new(); - - /// - /// Tracks entities this is trying to engulf. - /// - [JsonProperty] - private HashSet attemptingToEngulf = new(); - - /// - /// Tracks entities this already engulfed. - /// - [JsonProperty] - private List engulfedObjects = new(); - - /// - /// Tracks entities this has previously engulfed. - /// - [JsonProperty] - private List expelledObjects = new(); - - // private HashSet engulfablesInPseudopodRange = new(); - - // private MeshInstance pseudopodTarget = null!; - - /// - /// Controls for how long the flashColour is held before going - /// back to species colour. - /// - [JsonProperty] - private float flashDuration; - - [JsonProperty] - private Color flashColour = new(0, 0, 0, 0); - - /// - /// This determines how important the current flashing action is. This allows higher priority flash colours to - /// take over. - /// - [JsonProperty] - private int flashPriority; - - /// - /// This determines how much time is left (in seconds) until this cell can take damage again after becoming - /// invulnerable due to a damage source. This was added to balance pili but might extend to more sources. - /// - [JsonProperty] - private float invulnerabilityDuration; - - [JsonProperty] - private bool deathParticlesSpawned; - - /// - /// Used to log just once when the touched microbe disposed issue happens to reduce log spam - /// - private bool loggedTouchedDisposeIssue; - - [JsonProperty] - private MicrobeState state; - - public enum EngulfCheckResult - { - Ok, - NotInEngulfMode, - RecentlyExpelled, - TargetDead, - TargetTooBig, - IngestedMatterFull, - CannotCannibalize, - TargetInvulnerable, - } - - /// - /// The colony this microbe is currently in - /// - /// - /// - /// Order = 1 due to colony values requiring this to be fully initialized. - /// - /// - [JsonProperty(Order = 1)] - public MicrobeColony? Colony { get; set; } - - [JsonProperty] - public Microbe? ColonyParent { get; set; } - - [JsonProperty] - public List? ColonyChildren { get; set; } - - /// - /// The membrane of this Microbe. Used for grabbing radius / points from this. - /// - [JsonIgnore] - public Membrane Membrane { get; private set; } = null!; - - [JsonProperty] - public float Hitpoints { get; private set; } = Constants.DEFAULT_HEALTH; - - [JsonProperty] - public float MaxHitpoints { get; private set; } = Constants.DEFAULT_HEALTH; - - // Properties for engulfing - [JsonProperty] - public PhagocytosisPhase PhagocytosisStep { get; set; } - - /// - /// The amount of space all of the currently engulfed objects occupy in the cytoplasm. This is used to determine - /// whether a cell can ingest any more objects or not due to being full. - /// - /// - /// - /// In a more technical sense, this is the accumulated from all - /// the ingested objects. Maximum should be this cell's own . - /// - /// - [JsonProperty] - public float UsedIngestionCapacity { get; private set; } - - [JsonProperty] - public EntityReference HostileEngulfer { get; private set; } = new(); - - [JsonIgnore] - public AliveMarker AliveMarker { get; } = new(); - - /// - /// The current state of the microbe. Shared across the colony - /// - [JsonIgnore] - public MicrobeState State - { - get => Colony?.State ?? state; - set - { - if (state == value) - return; - - state = value; - if (Colony != null) - Colony.State = value; - - if (value == MicrobeState.Unbinding && IsPlayerMicrobe) - OnUnbindEnabled?.Invoke(this); - } - } - - /// - /// The size this microbe is for engulfing calculations - /// - [JsonIgnore] - public float EngulfSize - { - get - { - // Scale with digested progress - var size = HexCount * (1 - DigestedAmount); - - if (CellTypeProperties.IsBacteria) - return size * 0.5f; - - return size; - } - } - - /// - /// Just like but decoupled from Species and is based on the local - /// condition of the microbe instead. - /// - /// True if this cell fills all the requirements needed to enter engulf mode. - [JsonIgnore] - public bool CanEngulf => !Membrane.Type.CellWall; - - /// - /// Returns true when this microbe can enable binding mode. Multicellular species can't attach random cells - /// to themselves anymore - /// - [JsonIgnore] - public bool CanBind => !IsMulticellular && (organelles?.Any(p => p.IsBindingAgent) == true || Colony != null); - - [JsonIgnore] - public bool CanUnbind => !IsMulticellular && Colony != null; - - /// - /// Called when this Microbe dies - /// - [JsonProperty] - public Action? OnDeath { get; set; } - - [JsonProperty] - public Action? OnUnbindEnabled { get; set; } - - [JsonProperty] - public Action? OnUnbound { get; set; } - - [JsonProperty] - public Action? OnIngestedByHostile { get; set; } - - [JsonProperty] - public Action? OnSuccessfulEngulfment { get; set; } - - [JsonProperty] - public Action? OnEngulfmentStorageFull { get; set; } - - [JsonProperty] - public Action? OnNoticeMessage { get; set; } - - /// - /// Updates the intensity of wigglyness of this cell's membrane based on membrane type, taking - /// membrane rigidity into account. - /// - public void ApplyMembraneWigglyness() - { - Membrane.WigglyNess = Membrane.Type.BaseWigglyness - (CellTypeProperties.MembraneRigidity / - Membrane.Type.BaseWigglyness) * 0.2f; - Membrane.MovementWigglyNess = Membrane.Type.MovementWigglyness - (CellTypeProperties.MembraneRigidity / - Membrane.Type.MovementWigglyness) * 0.2f; - } - - /// - /// Give this microbe a specified amount of invulnerability time. Overrides previous value. - /// NOTE: Not all damage sources apply invulnerability, check method usages. - /// - public void MakeInvulnerable(float duration) - { - invulnerabilityDuration = duration; - } - - /// - /// Flashes the membrane a specific colour for duration. A new - /// flash is not started if currently flashing and priority is lower than the current flash priority. - /// - /// True when a new flash was started, false if already flashing - public bool Flash(float duration, Color colour, int priority = 0) - { - if (colour != flashColour && (priority > flashPriority || flashDuration <= 0)) - { - AbortFlash(); - } - else if (flashDuration > 0) - { - return false; - } - - flashDuration = duration; - flashColour = colour; - flashPriority = priority; - return true; - } - - public void AbortFlash() - { - flashDuration = 0; - flashColour = new Color(0, 0, 0, 0); - flashPriority = 0; - Membrane.Tint = CellTypeProperties.Colour; - } - - /// - /// Applies damage to this cell. killing it if its hitpoints drop low enough - /// - public void Damage(float amount, string source) - { - if (IsPlayerMicrobe && CheatManager.GodMode) - return; - - if (amount == 0 || Dead) - return; - - if (string.IsNullOrEmpty(source)) - throw new ArgumentException("damage type is empty"); - - // This seems to be triggered sometimes, even though our logic for damage seems right everywhere. - // One possible explanation is that delta is negative sometimes? So we just print an error and do nothing - // else here - if (amount < 0) - { - GD.PrintErr("Trying to deal negative damage"); - return; - } - - // Damage reduction is only wanted for non-starving damage - bool canApplyDamageReduction = true; - - if (source is "toxin" or "oxytoxy") - { - // Play the toxin sound - PlaySoundEffect("res://assets/sounds/soundeffects/microbe-toxin-damage.ogg"); - - // TODO: fix this, currently "toxin" is used both by microbes and chunks, as well as damage from ingested - // toxins - // OnNoticeMessage?.Invoke(this, - // new SimpleHUDMessage(TranslationServer.Translate("NOTICE_DAMAGED_BY_ENVIRONMENTAL_TOXIN"))); - - // Divide damage by toxin resistance - amount /= CellTypeProperties.MembraneType.ToxinResistance; - } - else if (source == "pilus") - { - if (invulnerabilityDuration > 0) - return; - - // Play the pilus sound - PlaySoundEffect("res://assets/sounds/soundeffects/pilus_puncture_stab.ogg", 4.0f); - - // Give immunity to prevent massive damage at some angles - // https://github.com/Revolutionary-Games/Thrive/issues/3267 - MakeInvulnerable(Constants.PILUS_INVULNERABLE_TIME); - - // TODO: this may get triggered a lot more than the toxin - // so this might need to be rate limited or something - // Divide damage by physical resistance - amount /= CellTypeProperties.MembraneType.PhysicalResistance; - } - else if (source == "injectisome") - { - if (invulnerabilityDuration > 0) - return; - - // Play the pilus sound - // TODO: Use a different sound than the toxin damage sound - PlaySoundEffect("res://assets/sounds/soundeffects/microbe-toxin-damage.ogg"); - - // Give immunity to prevent massive damage at some angles - // https://github.com/Revolutionary-Games/Thrive/issues/3267 - MakeInvulnerable(Constants.PILUS_INVULNERABLE_TIME); - - // TODO: this may get triggered a lot more than the toxin - // so this might need to be rate limited or something - - // Divide damage by toxin resistance - amount /= CellTypeProperties.MembraneType.ToxinResistance; - } - else if (source == "chunk") - { - // TODO: Replace this take damage sound with a more appropriate one. - - PlaySoundEffect("res://assets/sounds/soundeffects/microbe-toxin-damage.ogg"); - - // Divide damage by physical resistance - amount /= CellTypeProperties.MembraneType.PhysicalResistance; - } - else if (source == "atpDamage") - { - PlaySoundEffect("res://assets/sounds/soundeffects/microbe-atp-damage.ogg"); - canApplyDamageReduction = false; - - OnNoticeMessage?.Invoke(this, - new SimpleHUDMessage(TranslationServer.Translate("NOTICE_DAMAGED_BY_NO_ATP"), - DisplayDuration.Short)); - } - else if (source == "ice") - { - PlayNonPositionalSoundEffect("res://assets/sounds/soundeffects/microbe-ice-damage.ogg", 0.5f); - - // Divide damage by physical resistance - amount /= CellTypeProperties.MembraneType.PhysicalResistance; - } - - if (!CellTypeProperties.IsBacteria && canApplyDamageReduction) - { - amount /= 2; - } - - Hitpoints -= amount; - - ModLoader.ModInterface.TriggerOnDamageReceived(this, amount, IsPlayerMicrobe); - - // Flash the microbe red - Flash(1.0f, new Color(1, 0, 0, 0.5f), 1); - - // Kill if ran out of health - if (Hitpoints <= 0.0f) - { - Hitpoints = 0.0f; - Kill(); - } - } - - /// - /// Overrides this microbe's health. Used by testing code to ensure microbes don't die when not wanted - /// - /// - /// The new health to set the microbe to. Setting to 0 won't immediately kill the microbe - /// - public void TestOverrideHitpoints(float newHitpoints) - { - Hitpoints = Mathf.Clamp(newHitpoints, 0, MaxHitpoints); - } - - /// - /// Returns the check result whether this microbe can engulf the target - /// - public EngulfCheckResult CanEngulfObject(IEngulfable target) - { - if (target.PhagocytosisStep != PhagocytosisPhase.None) - return EngulfCheckResult.NotInEngulfMode; - - // Membranes with Cell Wall cannot engulf - if (!CanEngulf) - return EngulfCheckResult.NotInEngulfMode; - - // Can't engulf recently ejected objects, this act as a cooldown - if (expelledObjects.Any(m => m.Object == target)) - return EngulfCheckResult.RecentlyExpelled; - - var targetAsMicrobe = target as Microbe; - - // Can't engulf already destroyed microbes. We don't use entity references so we need to manually check if - // something is destroyed or not here (especially now that the Invoke the engulf start callback) - if (targetAsMicrobe != null && targetAsMicrobe.destroyed) - return EngulfCheckResult.TargetDead; - - // Can't engulf dead microbes (unlikely to happen but this is fail-safe) - if (targetAsMicrobe != null && targetAsMicrobe.Dead) - return EngulfCheckResult.TargetDead; - - // Log error if trying to engulf something that is disposed, we got a crash log trace with an error with that - // TODO: find out why disposed microbes can be attempted to be engulfed - try - { - if (targetAsMicrobe != null) - { - // Access a Godot property to throw disposed exception - _ = targetAsMicrobe.GlobalTransform; - } - } - catch (ObjectDisposedException) - { - if (!loggedTouchedDisposeIssue) - { - GD.PrintErr("Touched microbe has been disposed before engulfing could start"); - loggedTouchedDisposeIssue = true; - } - - return EngulfCheckResult.TargetDead; - } - - // The following checks are in a specific order to make sure the fail reporting logic gives sensible results - - // Disallow cannibalism - if (targetAsMicrobe != null && targetAsMicrobe.Species == Species) - return EngulfCheckResult.CannotCannibalize; - - // Needs to be big enough to engulf - if (EngulfSize < target.EngulfSize * Constants.ENGULF_SIZE_RATIO_REQ) - return EngulfCheckResult.TargetTooBig; - - // Limit amount of things that can be engulfed at once - if (UsedIngestionCapacity >= EngulfSize || UsedIngestionCapacity + target.EngulfSize >= EngulfSize) - return EngulfCheckResult.IngestedMatterFull; - - // Too many things attempted to be pulled in at once - if (UsedIngestionCapacity + attemptingToEngulf.Sum(e => e.EngulfSize) + target.EngulfSize >= EngulfSize) - return EngulfCheckResult.IngestedMatterFull; - - // Godmode grants player complete engulfment invulnerability - if (targetAsMicrobe != null && targetAsMicrobe.IsPlayerMicrobe && CheatManager.GodMode) - return EngulfCheckResult.TargetInvulnerable; - - return EngulfCheckResult.Ok; - } - - /// - /// Returns true if this microbe OR the colony this microbe is part of has the capability to engulf. - /// - public bool CanEngulfInColony() - { - if (Colony != null) - return Colony.CanEngulf; - - return CanEngulf; - } - - public void OnAttemptedToBeEngulfed() - { - Membrane.WigglyNess = 0; - - UnreadyToReproduce(); - - // Make the render priority of our organelles be on top of the highest possible render priority - // of the hostile engulfer's organelles - var hostile = HostileEngulfer.Value; - if (hostile != null) - { - foreach (var organelle in organelles!) - { - var newPriority = Mathf.Clamp(Hex.GetRenderPriority(organelle.Position) + - hostile.OrganelleMaxRenderPriority, 0, Material.RenderPriorityMax); - organelle.UpdateRenderPriority(newPriority); - } - } - - Colony?.RemoveFromColony(this); - - // Just in case player is engulfed again after escaping - playerEngulfedDeathTimer = 0; - } - - public void OnIngestedFromEngulfment() - { - OnIngestedByHostile?.Invoke(this, HostileEngulfer.Value!); - } - - public void OnExpelledFromEngulfment() - { - var hostile = HostileEngulfer.Value; - - // Reset wigglyness - ApplyMembraneWigglyness(); - - // Reset our organelles' render priority back to their original values - foreach (var organelle in organelles!) - { - organelle.UpdateRenderPriority(Hex.GetRenderPriority(organelle.Position)); - } - - if (DigestedAmount >= Constants.PARTIALLY_DIGESTED_THRESHOLD) - { - // Cell is too damaged from digestion, can't live in open environment and is considered dead - // Kill() is not called here because it's already called during partial digestion - OnDestroyed(); - var droppedChunks = OnKilled().ToList(); - - if (hostile == null) - return; - - foreach (var chunk in droppedChunks) - { - var direction = hostile.Transform.origin.DirectionTo(chunk.Transform.origin); - chunk.Translation += direction * - Constants.EJECTED_PARTIALLY_DIGESTED_CELL_CORPSE_CHUNKS_SPAWN_OFFSET; - - var impulse = direction * chunk.Mass * Constants.ENGULF_EJECTION_FORCE; - - // Apply outwards ejection force - chunk.ApplyCentralImpulse(impulse + LinearVelocity); - } - } - else - { - hasEscaped = true; - escapeInterval = 0; - playerEngulfedDeathTimer = 0; - } - } - - public void ClearEngulfedObjects() - { - foreach (var engulfed in engulfedObjects.ToList()) - { - if (engulfed.Object.Value != null) - { - engulfedObjects.Remove(engulfed); - engulfed.Object.Value.DestroyDetachAndQueueFree(); - } - - engulfed.Phagosome.Value?.DestroyDetachAndQueueFree(); - } - - engulfedObjects.Clear(); - } - - /// - /// Returns true if this microbe is currently in the process of ingesting engulfables. - /// - public bool IsPullingInEngulfables() - { - return attemptingToEngulf.Any(); - } - - /// - /// Offset relative to the colony lead cell. Throws if this cell is not part of a colony - /// - /// The offset - public Vector3 GetOffsetRelativeToMaster() - { - return (GlobalTransform.origin - Colony!.Master.GlobalTransform.origin).Rotated(Vector3.Down, - Colony.Master.Rotation.y); - } - - /// - /// Public because it needs to be called by external organelles only. - /// Not meant for other uses. - /// - public void SendOrganellePositionsToMembrane() - { - if (organelles == null) - throw new InvalidOperationException("Microbe must be initialized first"); - - var organellePositions = new List(); - - foreach (var entry in organelles.Organelles) - { - // The membrane needs hex positions to handle cells with multihex organelles - foreach (var hex in entry.Definition.GetRotatedHexes(entry.Orientation)) - { - var hexCartesian = Hex.AxialToCartesian(entry.Position + hex); - organellePositions.Add(new Vector2(hexCartesian.x, hexCartesian.z)); - } - } - - Membrane.OrganellePositions = organellePositions; - Membrane.Dirty = true; - membraneOrganellePositionsAreDirty = false; - } - - /// - /// Instantly kills this microbe and queues this entity to be destroyed - /// - /// - /// The dropped corpse chunks. Null if this cell is already dead or is engulfed. - /// - public IEnumerable? Kill() - { - if (Dead) - return null; - - Dead = true; - - OnDeath?.Invoke(this); - ModLoader.ModInterface.TriggerOnMicrobeDied(this, IsPlayerMicrobe); - - // If being phagocytized don't continue further because the entity reference is still needed to - // maintain related functions, also dropping corpse chunks won't make sense while inside a cell - if (PhagocytosisStep != PhagocytosisPhase.None) - return null; - - OnDestroyed(); - - // Post-death handling is done in HandleDeath - - return OnKilled(); - } - - public void OnDestroyed() - { - if (destroyed) - return; - - destroyed = true; - - // TODO: find out a way to cleanly despawn colonies without having to run the reproduction progress lost logic - Colony?.RemoveFromColony(this); - - AliveMarker.Alive = false; - } - - /// - /// Removes this cell and child cells from the colony. - /// - /// - /// - /// If this is the colony master, this disbands the whole colony - /// - /// - public void UnbindAll() - { - if (State is MicrobeState.Unbinding or MicrobeState.Binding) - State = MicrobeState.Normal; - - if (!CanUnbind) - return; - - // TODO: once the colony leader can leave without the entire colony disbanding this perhaps should keep the - // disband entire colony functionality - Colony!.RemoveFromColony(this); - } - - public void OnMouseEnter(RaycastResult result) - { - var microbe = GetMicrobeFromShape(result.Shape); - - if (microbe != null) - microbe.IsHoveredOver = true; - } - - public void OnMouseExit(RaycastResult result) - { - var microbe = GetMicrobeFromShape(result.Shape); - - if (microbe != null) - microbe.IsHoveredOver = false; - } - - internal void OnColonyMemberRemoved(Microbe microbe) - { - cachedColonyRotationMultiplier = null; - - if (microbe == this) - { - OnUnbound?.Invoke(this); - - if (PhagocytosisStep == PhagocytosisPhase.None) - RevertNodeParent(); - - ai?.ResetAI(); - - Mode = ModeEnum.Rigid; - - return; - } - - if (IsMulticellular && Colony?.Master == this) - { - // Lost a member of the multicellular organism - OnMulticellularColonyCellLost(microbe); - } - - if (HostileEngulfer != microbe) - microbe.RemoveCollisionExceptionWith(this); - if (microbe.HostileEngulfer != this) - RemoveCollisionExceptionWith(microbe); - } - - internal void ReParentShapes(Microbe to, Vector3 offset) - { - // TODO: if microbeRotation is the rotation of *this* instance we should use the variable here directly - // An object doesn't need to be told its own member variable in a method... - // https://github.com/Revolutionary-Games/Thrive/issues/2504 - foreach (var organelle in organelles!) - organelle.ReParentShapes(to, offset); - } - - internal void OnColonyMemberAdded(Microbe microbe) - { - cachedColonyRotationMultiplier = null; - - if (microbe == this) - { - if (Colony == null) - { - throw new InvalidOperationException( - $"{nameof(Colony)} must be set before calling {nameof(OnColonyMemberAdded)}"); - } - - OnIGotAddedToColony(); - - if (Colony.Master != this) - { - Mode = ModeEnum.Static; - } - - ReParentShapes(Colony.Master, GetOffsetRelativeToMaster()); - } - else - { - AddCollisionExceptionWith(microbe); - microbe.AddCollisionExceptionWith(this); - } - } - - internal void SuccessfulScavenge() - { - GameWorld.AlterSpeciesPopulationInCurrentPatch(Species, - Constants.CREATURE_SCAVENGE_POPULATION_GAIN, - TranslationServer.Translate("SUCCESSFUL_SCAVENGE")); - } - - internal void SuccessfulKill() - { - GameWorld.AlterSpeciesPopulationInCurrentPatch(Species, - Constants.CREATURE_KILL_POPULATION_GAIN, - TranslationServer.Translate("SUCCESSFUL_KILL")); - } - - /// - /// Operations that should be done when this cell is killed. ONLY use this independently of - /// if you've already made sure that this microbe is marked as dead since this doesn't do that. - /// - /// - /// The dropped corpse chunks. - /// - private IEnumerable OnKilled() - { - // Reset some stuff - State = MicrobeState.Normal; - MovementDirection = new Vector3(0, 0, 0); - LinearVelocity = new Vector3(0, 0, 0); - allOrganellesDivided = false; - - // Releasing all the agents. - // To not completely deadlock in this there is a maximum limit - int createdAgents = 0; - - if (AgentVacuoleCount > 0) - { - var oxytoxy = SimulationParameters.Instance.GetCompound("oxytoxy"); - - var amount = Compounds.GetCompoundAmount(oxytoxy); - - var props = new AgentProperties(Species, oxytoxy); - - var agentScene = SpawnHelpers.LoadAgentScene(); - - while (amount > Constants.MAXIMUM_AGENT_EMISSION_AMOUNT) - { - var direction = new Vector3(random.Next(0.0f, 1.0f) * 2 - 1, - 0, random.Next(0.0f, 1.0f) * 2 - 1); - - var agent = SpawnHelpers.SpawnAgent(props, Constants.MAXIMUM_AGENT_EMISSION_AMOUNT, - Constants.EMITTED_AGENT_LIFETIME, - Translation, direction, GetStageAsParent(), - agentScene, this); - - ModLoader.ModInterface.TriggerOnToxinEmitted(agent); - - amount -= Constants.MAXIMUM_AGENT_EMISSION_AMOUNT; - ++createdAgents; - - if (createdAgents >= Constants.MAX_EMITTED_AGENTS_ON_DEATH) - break; - } - } - - // Eject the compounds that was in the microbe - var compoundsToRelease = new Dictionary(); - - foreach (var type in SimulationParameters.Instance.GetCloudCompounds()) - { - var amount = Compounds.GetCompoundAmount(type) * - Constants.COMPOUND_RELEASE_FRACTION; - - compoundsToRelease[type] = amount; - } - - // Eject some part of the build cost of all the organelles - foreach (var organelle in organelles!) - { - foreach (var entry in organelle.Definition.InitialComposition) - { - compoundsToRelease.TryGetValue(entry.Key, out var existing); - - // Only add up if there's still some compounds left, otherwise - // we're releasing compounds out of thin air. - if (existing > 0) - { - compoundsToRelease[entry.Key] = existing + (entry.Value * - Constants.COMPOUND_MAKEUP_RELEASE_FRACTION); - } - } - } - - CalculateBonusDigestibleGlucose(compoundsToRelease); - - // Queues either 1 corpse chunk or a factor of the hexes - int chunksToSpawn = Math.Max(1, HexCount / Constants.CORPSE_CHUNK_DIVISOR); - - var droppedCorpseChunks = new HashSet(chunksToSpawn); - - var chunkScene = SpawnHelpers.LoadChunkScene(); - - // An enumerator to step through all available organelles in a random order when making chunks - using var organellesAvailableEnumerator = organelles.OrderBy(_ => random.Next()).GetEnumerator(); - - // The default model for chunks is the cytoplasm model in case there isn't a model left in the species - var defaultChunkScene = SimulationParameters.Instance - .GetOrganelleType(Constants.DEFAULT_CHUNK_MODEL_NAME).LoadedCorpseChunkScene ?? - throw new Exception("No default chunk scene"); - - for (int i = 0; i < chunksToSpawn; ++i) - { - // Amount of compound in one chunk - float amount = HexCount / Constants.CORPSE_CHUNK_AMOUNT_DIVISOR; - - var positionAdded = new Vector3(random.Next(-2.0f, 2.0f), 0, - random.Next(-2.0f, 2.0f)); - - var chunkType = new ChunkConfiguration - { - ChunkScale = 1.0f, - Dissolves = true, - Mass = 1.0f, - Radius = 1.0f, - Size = 3.0f, - VentAmount = 0.1f, - - // Add compounds - Compounds = new Dictionary(), - }; - - // They were added in order already so looping through this other thing is fine - foreach (var entry in compoundsToRelease) - { - var compoundValue = new ChunkConfiguration.ChunkCompound - { - // Randomize compound amount a bit so things "rot away" - Amount = (entry.Value / (random.Next(amount / 3.0f, amount) * - Constants.CHUNK_ENGULF_COMPOUND_DIVISOR)) * Constants.CORPSE_COMPOUND_COMPENSATION, - }; - - chunkType.Compounds[entry.Key] = compoundValue; - } - - chunkType.Meshes = new List(); - - var sceneToUse = new ChunkConfiguration.ChunkScene - { - LoadedScene = defaultChunkScene, - }; - - // Will only loop if there are still organelles available - while (organellesAvailableEnumerator.MoveNext() && organellesAvailableEnumerator.Current != null) - { - if (!string.IsNullOrEmpty(organellesAvailableEnumerator.Current.Definition.CorpseChunkScene)) - { - sceneToUse.LoadedScene = - organellesAvailableEnumerator.Current.Definition.LoadedCorpseChunkScene; - } - else if (!string.IsNullOrEmpty(organellesAvailableEnumerator.Current.Definition.DisplayScene)) - { - sceneToUse.LoadedScene = organellesAvailableEnumerator.Current.Definition.LoadedScene; - sceneToUse.SceneModelPath = - organellesAvailableEnumerator.Current.Definition.DisplaySceneModelPath; - } - else - { - continue; - } - - if (sceneToUse.LoadedScene != null) - break; - } - - if (sceneToUse.LoadedScene == null) - throw new Exception("loaded scene is null"); - - chunkType.Meshes.Add(sceneToUse); - - // Finally spawn a chunk with the settings - var chunk = SpawnHelpers.SpawnChunk(chunkType, Translation + positionAdded, GetStageAsParent(), - chunkScene, random); - droppedCorpseChunks.Add(chunk); - - // Add to the spawn system to make these chunks limit possible number of entities - spawnSystem!.AddEntityToTrack(chunk); - - ModLoader.ModInterface.TriggerOnChunkSpawned(chunk, false); - } - - // Subtract population - if (!IsPlayerMicrobe && !Species.PlayerSpecies) - { - GameWorld.AlterSpeciesPopulationInCurrentPatch(Species, - Constants.CREATURE_DEATH_POPULATION_LOSS, TranslationServer.Translate("DEATH")); - } - - if (IsPlayerMicrobe) - { - // If you died before entering the editor disable that - OnReproductionStatus?.Invoke(this, false); - } - - if (IsPlayerMicrobe) - { - // Playing from a positional audio player won't have any effect since the listener is - // directly on it. - PlayNonPositionalSoundEffect("res://assets/sounds/soundeffects/microbe-death-2.ogg", 0.5f); - } - else - { - PlaySoundEffect("res://assets/sounds/soundeffects/microbe-death-2.ogg"); - } - - // Disable collisions - CollisionLayer = 0; - CollisionMask = 0; - - return droppedCorpseChunks; - } - - private Microbe? GetColonyMemberWithShapeOwner(uint ownerID, MicrobeColony colony) - { - foreach (var microbe in colony.ColonyMembers) - { - if (microbe.organelles!.Any(o => o.HasShape(ownerID)) || microbe.IsPilus(ownerID)) - return microbe; - } - - // Now as we consider 0 valid, still don't want to crash here - // TODO: re-check this once this issue is done: https://github.com/Revolutionary-Games/Thrive/issues/2671 - if (ownerID == 0) - return null; - - // TODO: I really hope there is no way to hit this. I would really hate to reduce the game stability due to - // possibly bogus ownerID values that sometimes seem to come from Godot - // https://github.com/Revolutionary-Games/Thrive/issues/2504 - throw new InvalidOperationException(); - } - - private void OnIGotAddedToColony() - { - // Multicellular creature can stay in engulf mode when growing things - if (!IsMulticellular || State != MicrobeState.Engulf) - { - State = MicrobeState.Normal; - } - - UnreadyToReproduce(); - - if (ColonyParent == null) - return; - - var newTransform = GetNewRelativeTransform(); - - Rotation = newTransform.Rotation; - Translation = newTransform.Translation; - - ChangeNodeParent(ColonyParent); - } - - private void SetMembraneFromSpecies() - { - Membrane.Type = CellTypeProperties.MembraneType; - Membrane.Tint = CellTypeProperties.Colour; - Membrane.Dirty = true; - ApplyMembraneWigglyness(); - - foreach (var engulfed in engulfedObjects) - { - if (engulfed.Phagosome.Value != null) - engulfed.Phagosome.Value.Tint = CellTypeProperties.Colour; - } - } - - private void CheckEngulfShape() - { - /* - var wantedRadius = Radius * 5; - if (pseudopodRangeSphereShape.Radius != wantedRadius) - { - pseudopodRangeSphereShape.Radius = wantedRadius; - } - */ - } - - /// - /// Decrease the remaining invulnerability time - /// - private void HandleInvulnerabilityDecay(float delta) - { - if (invulnerabilityDuration > 0) - { - invulnerabilityDuration -= delta; - } - } - - /// - /// Flashes the membrane colour when Flash has been called - /// - private void HandleFlashing(float delta) - { - // Flash membrane if something happens. - if (flashDuration > 0 && flashColour != new Color(0, 0, 0, 0)) - { - flashDuration -= delta; - - // How frequent it flashes, would be nice to update - // the flash void to have this variable{ - if (flashDuration % 0.6f < 0.3f) - { - Membrane.Tint = flashColour; - } - else - { - // Restore colour - Membrane.Tint = CellTypeProperties.Colour; - } - - // Flashing ended - if (flashDuration <= 0) - { - flashDuration = 0; - - // Restore colour - Membrane.Tint = CellTypeProperties.Colour; - } - } - } - - /// - /// Handles things related to binding - /// - private void HandleBinding(float delta) - { - if (State != MicrobeState.Binding) - { - if (bindingAudio.Playing && bindingAudio.Volume > 0) - { - bindingAudio.Volume -= delta; - - if (bindingAudio.Volume <= 0) - bindingAudio.Stop(); - } - - return; - } - - // Drain atp - var cost = Constants.BINDING_ATP_COST_PER_SECOND * delta; - - if (Compounds.TakeCompound(atp, cost) < cost - 0.001f) - { - State = MicrobeState.Normal; - } - - if (!bindingAudio.Playing) - bindingAudio.Play(); - - // To balance loudness, here the binding audio's max volume is reduced to 0.6 in linear volume - if (bindingAudio.Volume < 0.6f) - { - bindingAudio.Volume += delta; - } - else if (bindingAudio.Volume >= 0.6f) - { - bindingAudio.Volume = 0.6f; - } - - Flash(1, new Color(0.2f, 0.5f, 0.0f, 0.5f)); - } - - /// - /// Handles things related to unbinding - /// - private void HandleUnbinding() - { - if (State != MicrobeState.Unbinding) - return; - - if (IsHoveredOver) - { - Flash(1, new Color(1.0f, 0.0f, 0.0f, 0.5f)); - } - else - { - Flash(1, new Color(1.0f, 0.5f, 0.2f, 0.5f)); - } - } - - /// - /// Handles things related to engulfing. Works together with the physics callbacks - /// - private void HandleEngulfing(float delta) - { - var actuallyEngulfing = State == MicrobeState.Engulf && CanEngulf; - - if (actuallyEngulfing) - { - // Drain atp - var cost = Constants.ENGULFING_ATP_COST_PER_SECOND * delta; - - if (Compounds.TakeCompound(atp, cost) < cost - 0.001f || PhagocytosisStep != PhagocytosisPhase.None) - { - State = MicrobeState.Normal; - } - } - else - { - attemptingToEngulf.Clear(); - } - - // Play sound - if (actuallyEngulfing) - { - if (!engulfAudio.Playing) - engulfAudio.Play(); - - // To balance loudness, here the engulfment audio's max volume is reduced to 0.6 in linear volume - - if (engulfAudio.Volume < 0.6f) - { - engulfAudio.Volume += delta; - } - else if (engulfAudio.Volume >= 0.6f) - { - engulfAudio.Volume = 0.6f; - } - - // Flash the membrane blue. - Flash(1, new Color(0.2f, 0.5f, 1.0f, 0.5f)); - } - else - { - if (engulfAudio.Playing && engulfAudio.Volume > 0) - { - engulfAudio.Volume -= delta; - - if (engulfAudio.Volume <= 0) - engulfAudio.Stop(); - } - } - - // Movement modifier - if (actuallyEngulfing) - { - MovementFactor /= Constants.ENGULFING_MOVEMENT_DIVISION; - } - - // Still considered to be chased for CREATURE_ESCAPE_INTERVAL milliseconds - if (hasEscaped) - { - escapeInterval += delta; - if (escapeInterval >= Constants.CREATURE_ESCAPE_INTERVAL) - { - hasEscaped = false; - escapeInterval = 0; - - GameWorld.AlterSpeciesPopulationInCurrentPatch(Species, - Constants.CREATURE_ESCAPE_POPULATION_GAIN, - TranslationServer.Translate("ESCAPE_ENGULFING")); - } - } - - for (int i = engulfedObjects.Count - 1; i >= 0; --i) - { - var engulfedObject = engulfedObjects[i]; - - var engulfable = engulfedObject.Object.Value; - - // ReSharper disable once UseNullPropagation - if (engulfable == null) - continue; - - var body = engulfable as RigidBody; - if (body == null) - { - attemptingToEngulf.Remove(engulfable); - engulfedObjects.Remove(engulfedObject); - continue; - } - - body.Mode = ModeEnum.Static; - - if (engulfable.PhagocytosisStep == PhagocytosisPhase.Digested) - { - engulfedObject.TargetValuesToLerp = (null, null, Vector3.One * Mathf.Epsilon); - StartBulkTransport(engulfedObject, 1.5f, false); - } - - if (!engulfedObject.Interpolate) - continue; - - if (AnimateBulkTransport(delta, engulfedObject)) - { - switch (engulfable.PhagocytosisStep) - { - case PhagocytosisPhase.Ingestion: - CompleteIngestion(engulfedObject); - break; - case PhagocytosisPhase.Digested: - engulfable.DestroyAndQueueFree(); - engulfedObjects.Remove(engulfedObject); - break; - case PhagocytosisPhase.Exocytosis: - engulfedObject.Phagosome.Value?.Hide(); - engulfedObject.TargetValuesToLerp = (null, engulfedObject.OriginalScale, null); - StartBulkTransport(engulfedObject, 1.0f); - engulfable.PhagocytosisStep = PhagocytosisPhase.Ejection; - continue; - case PhagocytosisPhase.Ejection: - CompleteEjection(engulfedObject); - break; - } - } - } - - foreach (var expelled in expelledObjects) - expelled.TimeElapsedSinceEjection += delta; - - expelledObjects.RemoveAll(e => e.TimeElapsedSinceEjection >= Constants.ENGULF_EJECTED_COOLDOWN); - - /* Membrane engulf stretch debug code - if (state == MicrobeState.Engulf) - { - foreach (Spatial engulfable in engulfablesInPseudopodRange) - { - pseudopodTarget.Translation = ToLocal(pseudopodTarget.GlobalTransform.origin.LinearInterpolate( - engulfable.GlobalTransform.origin, 0.5f * delta)); - } - } - else - { - pseudopodTarget.Translation = ToLocal( - pseudopodTarget.GlobalTransform.origin.LinearInterpolate(GlobalTransform.origin, 0.5f * delta)); - } - - Membrane.EngulfPosition = pseudopodTarget.Translation; - Membrane.EngulfRadius = ((SphereMesh)pseudopodTarget.Mesh).Radius; - Membrane.EngulfOffset = 1.0f; - */ - } - - /// - /// Handles the death of this microbe. This queues this object - /// for deletion and handles some post-death actions. - /// - private void HandleDeath(float delta) - { - if (PhagocytosisStep != PhagocytosisPhase.None) - return; - - // Spawn cell death particles. - if (!deathParticlesSpawned && DigestedAmount <= 0) - { - deathParticlesSpawned = true; - - var cellBurstEffectParticles = (CellBurstEffect)cellBurstEffectScene.Instance(); - cellBurstEffectParticles.Translation = Translation; - cellBurstEffectParticles.Radius = Radius; - cellBurstEffectParticles.AddToGroup(Constants.TIMED_GROUP); - - GetParent().AddChild(cellBurstEffectParticles); - - // This loop is placed here (which isn't related to the particles but for convenience) - // so this loop is run only once - foreach (var engulfed in engulfedObjects.ToList()) - { - if (engulfed.Object.Value != null) - EjectEngulfable(engulfed.Object.Value); - } - } - - foreach (var organelle in organelles!) - { - organelle.Hide(); - } - - Membrane.DissolveEffectValue += delta * Constants.MEMBRANE_DISSOLVE_SPEED; - - if (Membrane.DissolveEffectValue >= 1) - { - this.DestroyDetachAndQueueFree(); - } - } - - private void ChangeNodeParent(Microbe parent) - { - // We unset Colony temporarily as otherwise our exit tree callback would remove us from the colony immediately - // TODO: it would be perhaps a nicer code approach to only set the Colony after this is re-parented - var savedColony = Colony; - Colony = null; - - this.ReParent(parent); - - // And restore the colony after completing the re-parenting of this node - Colony = savedColony; - } - - private void RevertNodeParent() - { - if (Colony == null) - { - throw new InvalidOperationException( - $"{nameof(RevertNodeParent)} can only be called on microbes in a colony"); - } - - var pos = GlobalTransform; - - if (Colony.Master != this) - { - var newParent = GetStageAsParent(); - - // See the comment in ChangeNodeParent - var savedColony = Colony; - Colony = null; - - this.ReParent(newParent); - - Colony = savedColony; - } - - GlobalTransform = pos; - } - - private void OnContactBegin(int bodyID, Node body, int bodyShape, int localShape) - { - _ = bodyID; - - var thisOwnerId = ShapeFindOwner(localShape); - var thisMicrobe = GetMicrobeFromShape(localShape); - - // localShape is invalid. This can happen during re-parenting - if (thisMicrobe == null) - return; - - if (body is Microbe colonyLeader) - { - var touchedOwnerId = colonyLeader.ShapeFindOwner(bodyShape); - var touchedMicrobe = colonyLeader.GetMicrobeFromShape(bodyShape); - - // bodyShape is invalid. This can happen during re-parenting - // Disabled this warning here as touchedMicrobe is used so diversely that it's much more convenient to - // do null check just once - // ReSharper disable once UseNullPropagationWhenPossible - if (touchedMicrobe == null) - return; - - // TODO: does this need to check for disposed exception? - // https://github.com/Revolutionary-Games/Thrive/issues/2504 - if (touchedMicrobe.Dead || (Colony != null && Colony == touchedMicrobe.Colony)) - return; - - bool otherIsPilus = touchedMicrobe.IsPilus(touchedOwnerId); - bool oursIsPilus = thisMicrobe.IsPilus(thisOwnerId); - - // Pilus logic - if (otherIsPilus && oursIsPilus) - { - // Pilus on pilus doesn't deal damage and you can't engulf - return; - } - - if (otherIsPilus || oursIsPilus) - { - // Us attacking the other microbe, or it is attacking us - - // Disallow cannibalism - if (touchedMicrobe.Species == thisMicrobe.Species) - return; - - var target = otherIsPilus ? thisMicrobe : touchedMicrobe; - var attacker = otherIsPilus ? touchedMicrobe : thisMicrobe; - var attackingPilusID = otherIsPilus ? touchedOwnerId : thisOwnerId; - - if (attacker.IsInjectisome(attackingPilusID)) - { - Invoke.Instance.Perform(() => target.Damage(Constants.INJECTISOME_BASE_DAMAGE, "injectisome")); - } - else - { - Invoke.Instance.Perform(() => target.Damage(Constants.PILUS_BASE_DAMAGE, "pilus")); - } - - return; - } - - // Pili don't stop engulfing - if (thisMicrobe.touchedEntities.Add(touchedMicrobe)) - { - Invoke.Instance.Perform(() => - { - thisMicrobe.CheckStartEngulfingOnCandidate(touchedMicrobe); - thisMicrobe.CheckBinding(); - }); - } - - // Play bump sound if certain total collision impulse is reached (adjusted by mass) - if (thisMicrobe.collisionForce / Mass > Constants.CONTACT_IMPULSE_TO_BUMP_SOUND) - { - Invoke.Instance.Perform(() => - thisMicrobe.PlaySoundEffect("res://assets/sounds/soundeffects/microbe-collision.ogg")); - } - } - else if (body is IEngulfable engulfable) - { - if (thisMicrobe.touchedEntities.Add(engulfable)) - { - thisMicrobe.CheckStartEngulfingOnCandidate(engulfable); - } - } - } - - private void OnContactEnd(int bodyID, Node body, int bodyShape, int localShape) - { - _ = bodyID; - _ = bodyShape; - - if (body is IEngulfable engulfable) - { - // GetMicrobeFromShape returns null when it was provided an invalid shape id. - // This can happen when re-parenting is in progress. - // https://github.com/Revolutionary-Games/Thrive/issues/2504 - var hitMicrobe = GetMicrobeFromShape(localShape) ?? this; - - // TODO: should this also check for pilus before removing the collision? - hitMicrobe.touchedEntities.Remove(engulfable); - - if (engulfable.PhagocytosisStep == PhagocytosisPhase.None) - hitMicrobe.attemptingToEngulf.Remove(engulfable); - } - } - - /* - private void OnBodyEnteredPseudopodRange(Node body) - { - if (body == this) - return; - - if (body is IEngulfable engulfable) - { - engulfablesInPseudopodRange.Add(engulfable); - } - } - - private void OnBodyExitedPseudopodRange(Node body) - { - if (body is IEngulfable engulfable) - { - engulfablesInPseudopodRange.Remove(engulfable); - } - } - */ - - /// - /// Attempts to engulf the given target into the cytoplasm. Does not check whether the target - /// can be engulfed or not. - /// - private void IngestEngulfable(IEngulfable target, float animationSpeed = 2.0f) - { - if (target.PhagocytosisStep != PhagocytosisPhase.None) - return; - - var body = target as RigidBody; - if (body == null) - { - // Engulfable must be of rigidbody type to be ingested - return; - } - - attemptingToEngulf.Add(target); - touchedEntities.Remove(target); - - target.HostileEngulfer.Value = this; - target.PhagocytosisStep = PhagocytosisPhase.Ingestion; - - body.ReParentWithTransform(this); - - // Below is for figuring out where to place the object attempted to be engulfed inside the cytoplasm, - // calculated accordingly to hopefully minimize any part of the object sticking out the membrane. - // Note: extremely long and thin objects might still stick out - - var targetRadiusNormalized = Mathf.Clamp(target.Radius / Radius, 0.0f, 1.0f); - - var nearestPointOfMembraneToTarget = Membrane.GetVectorTowardsNearestPointOfMembrane( - body.Translation.x, body.Translation.z); - - // The point nearest to the membrane calculation doesn't take being bacteria into account - if (CellTypeProperties.IsBacteria) - nearestPointOfMembraneToTarget *= 0.5f; - - // From the calculated nearest point of membrane above we then linearly interpolate it by the engulfed's - // normalized radius to this cell's center in order to "shrink" the point relative to this cell's origin. - // This will then act as a "maximum extent/edge" that qualifies as the interior of the engulfer's membrane - var viableStoringAreaEdge = nearestPointOfMembraneToTarget.LinearInterpolate( - Vector3.Zero, targetRadiusNormalized); - - // Get the final storing position by taking a value between this cell's center and the storing area edge. - // This would lessen the possibility of engulfed things getting bunched up in the same position. - var ingestionPoint = new Vector3( - random.Next(0.0f, viableStoringAreaEdge.x), - body.Translation.y, - random.Next(0.0f, viableStoringAreaEdge.z)); - - var boundingBoxSize = target.EntityGraphics.GetAabb().Size; - - // In the case of flat mesh (like membrane) we don't want the endosome to end up completely flat - // as it can cause unwanted visual glitch - if (boundingBoxSize.y < Mathf.Epsilon) - boundingBoxSize = new Vector3(boundingBoxSize.x, 0.1f, boundingBoxSize.z); - - // Form phagosome - var phagosome = endosomeScene.Instance(); - phagosome.Transform = target.EntityGraphics.Transform.Scaled(Vector3.Zero); - phagosome.Tint = CellTypeProperties.Colour; - phagosome.RenderPriority = target.RenderPriority + engulfedObjects.Count + 1; - target.EntityGraphics.AddChild(phagosome); - - var engulfedObject = new EngulfedObject(target, phagosome) - { - TargetValuesToLerp = (ingestionPoint, body.Scale / 2, boundingBoxSize), - OriginalScale = body.Scale, - OriginalRenderPriority = target.RenderPriority, - OriginalCollisionLayer = body.CollisionLayer, - OriginalCollisionMask = body.CollisionMask, - }; - - engulfedObjects.Add(engulfedObject); - - // We want the ingested material to be always visible over the organelles - target.RenderPriority += OrganelleMaxRenderPriority + 1; - - // Disable collisions - body.CollisionLayer = 0; - body.CollisionMask = 0; - - foreach (string group in engulfedObject.OriginalGroups) - { - if (group != Constants.RUNNABLE_MICROBE_GROUP) - target.EntityNode.RemoveFromGroup(group); - } - - StartBulkTransport(engulfedObject, animationSpeed); - - target.OnAttemptedToBeEngulfed(); - } - - /// - /// Expels an ingested object from this microbe out into the environment. - /// - private void EjectEngulfable(IEngulfable target, float animationSpeed = 2.0f) - { - if (PhagocytosisStep != PhagocytosisPhase.None || target.PhagocytosisStep is PhagocytosisPhase.Exocytosis or - PhagocytosisPhase.None) - { - return; - } - - attemptingToEngulf.Remove(target); - - var body = target as RigidBody; - if (body == null) - { - // Engulfable must be of rigidbody type to be ejected - return; - } - - var engulfedObject = engulfedObjects.Find(e => e.Object == target); - if (engulfedObject == null) - return; - - target.PhagocytosisStep = PhagocytosisPhase.Exocytosis; - - // The back of the microbe - var exit = Hex.AxialToCartesian(new Hex(0, 1)); - var nearestPointOfMembraneToTarget = Membrane.GetVectorTowardsNearestPointOfMembrane(exit.x, exit.z); - - // The point nearest to the membrane calculation doesn't take being bacteria into account - if (CellTypeProperties.IsBacteria) - nearestPointOfMembraneToTarget *= 0.5f; - - // If engulfer cell is dead (us) or the engulfed is positioned outside any of our closest membrane, immediately - // eject it without animation - // TODO: Asses performance cost in massive cells? - if (Dead || !Membrane.Contains(body.Translation.x, body.Translation.z)) - { - CompleteEjection(engulfedObject); - body.Scale = engulfedObject.OriginalScale; - engulfedObjects.Remove(engulfedObject); - return; - } - - // Animate object move to the nearest point of the membrane - engulfedObject.TargetValuesToLerp = (nearestPointOfMembraneToTarget, null, Vector3.One * Mathf.Epsilon); - StartBulkTransport(engulfedObject, animationSpeed); - - // The rest of the operation is done in CompleteEjection - } - - private bool CanBindToMicrobe(IEntity other) - { - if (other is Microbe microbe) - { - // Cannot hijack the player, other species or other colonies (TODO: yet) - return !microbe.Dead && !microbe.IsPlayerMicrobe && microbe.Colony == null && microbe.Species == Species; - } - - return false; - } - - private void CheckBinding() - { - if (State != MicrobeState.Binding) - return; - - if (!CanBind) - { - State = MicrobeState.Normal; - return; - } - - var other = touchedEntities.FirstOrDefault(CanBindToMicrobe); - - // If there is no touching microbe that can bind, no need to invoke binding. - if (other == null) - return; - - // Invoke this on the next frame to avoid crashing when adding a third cell - Invoke.Instance.Queue(BeginBind); - } - - private void BeginBind() - { - var other = touchedEntities.FirstOrDefault(CanBindToMicrobe) as Microbe; - - if (other == null) - { - GD.PrintErr("Touched eligible microbe has disappeared before binding could start"); - return; - } - - touchedEntities.Remove(other); - - try - { - other.touchedEntities.Remove(this); - - other.MovementDirection = Vector3.Zero; - - // This should ensure that Godot side will not throw disposed exception in an unexpected place causing - // binding problems - _ = other.GlobalTransform; - } - catch (ObjectDisposedException) - { - GD.PrintErr("Touched eligible microbe has been disposed before binding could start"); - return; - } - - // This is probably unnecessary, but I'd like to make sure we have proper logging if this condition is ever - // reached -hhyyrylainen - try - { - _ = GlobalTransform; - } - catch (ObjectDisposedException e) - { - GD.PrintErr("Microbe that should be bound to is disposed. This should never happen. Please report this. ", - e); - return; - } - - // Create a colony if there isn't one yet - if (Colony == null) - { - MicrobeColony.CreateColonyForMicrobe(this); - - if (Colony == null) - { - GD.PrintErr("An issue occured during colony creation!"); - return; - } - - GD.Print("Created a new colony"); - } - - // Move out of binding state before adding the colony member to avoid accidental collisions being able to - // recursively trigger colony attachment - State = MicrobeState.Normal; - other.State = MicrobeState.Normal; - - Colony.AddToColony(other, this); - } - - /// - /// This checks if we can start engulfing - /// - private void CheckStartEngulfingOnCandidate(IEngulfable engulfable) - { - if (State != MicrobeState.Engulf) - return; - - foreach (var entity in touchedEntities) - { - if (entity is Microbe microbe && microbe.destroyed) - { - GD.Print($"Removed destroyed microbe from {nameof(touchedEntities)}"); - touchedEntities.Remove(microbe); - break; - } - } - - var engulfCheckResult = CanEngulfObject(engulfable); - - if (engulfCheckResult == EngulfCheckResult.Ok) - { - IngestEngulfable(engulfable); - } - else if (engulfCheckResult == EngulfCheckResult.IngestedMatterFull) - { - OnEngulfmentStorageFull?.Invoke(this); - - OnNoticeMessage?.Invoke(this, - new SimpleHUDMessage(TranslationServer.Translate("NOTICE_ENGULF_STORAGE_FULL"))); - } - else if (engulfCheckResult == EngulfCheckResult.TargetTooBig) - { - OnNoticeMessage?.Invoke(this, - new SimpleHUDMessage(TranslationServer.Translate("NOTICE_ENGULF_SIZE_TOO_SMALL"))); - } - } - - /// - /// Animates transporting objects from phagocytosis process with linear interpolation. - /// - /// True when Lerp finishes. - private bool AnimateBulkTransport(float delta, EngulfedObject engulfed) - { - if (engulfed.Object.Value == null || engulfed.Phagosome.Value == null) - return false; - - var body = (RigidBody)engulfed.Object.Value; - - if (engulfed.AnimationTimeElapsed < engulfed.LerpDuration) - { - engulfed.AnimationTimeElapsed += delta; - - var fraction = engulfed.AnimationTimeElapsed / engulfed.LerpDuration; - - // Ease out - fraction = Mathf.Sin(fraction * Mathf.Pi * 0.5f); - - if (engulfed.TargetValuesToLerp.Translation.HasValue) - { - body.Translation = engulfed.InitialValuesToLerp.Translation.LinearInterpolate( - engulfed.TargetValuesToLerp.Translation.Value, fraction); - } - - if (engulfed.TargetValuesToLerp.Scale.HasValue) - { - body.Scale = engulfed.InitialValuesToLerp.Scale.LinearInterpolate( - engulfed.TargetValuesToLerp.Scale.Value, fraction); - } - - if (engulfed.TargetValuesToLerp.EndosomeScale.HasValue) - { - engulfed.Phagosome.Value.Scale = engulfed.InitialValuesToLerp.EndosomeScale.LinearInterpolate( - engulfed.TargetValuesToLerp.EndosomeScale.Value, fraction); - } - - return false; - } - - // Snap values - if (engulfed.TargetValuesToLerp.Translation.HasValue) - body.Translation = engulfed.TargetValuesToLerp.Translation.Value; - - if (engulfed.TargetValuesToLerp.Scale.HasValue) - body.Scale = engulfed.TargetValuesToLerp.Scale.Value; - - if (engulfed.TargetValuesToLerp.EndosomeScale.HasValue) - engulfed.Phagosome.Value.Scale = engulfed.TargetValuesToLerp.EndosomeScale.Value; - - StopBulkTransport(engulfed); - - return true; - } - - /// - /// Begins phagocytosis related lerp animation - /// - private void StartBulkTransport(EngulfedObject engulfedObject, float duration, bool resetElapsedTime = true) - { - if (engulfedObject.Object.Value == null || engulfedObject.Phagosome.Value == null) - return; - - if (resetElapsedTime) - engulfedObject.AnimationTimeElapsed = 0; - - var body = (RigidBody)engulfedObject.Object.Value; - engulfedObject.InitialValuesToLerp = (body.Translation, body.Scale, engulfedObject.Phagosome.Value.Scale); - engulfedObject.LerpDuration = duration; - engulfedObject.Interpolate = true; - } - - /// - /// Stops phagocytosis related lerp animation - /// - private void StopBulkTransport(EngulfedObject engulfedObject) - { - engulfedObject.AnimationTimeElapsed = 0; - engulfedObject.Interpolate = false; - } - - private void CompleteIngestion(EngulfedObject engulfed) - { - var engulfable = engulfed.Object.Value; - if (engulfable == null) - return; - - engulfable.PhagocytosisStep = PhagocytosisPhase.Ingested; - - attemptingToEngulf.Remove(engulfable); - touchedEntities.Remove(engulfable); - - OnSuccessfulEngulfment?.Invoke(this, engulfable); - engulfable.OnIngestedFromEngulfment(); - } - - private void CompleteEjection(EngulfedObject engulfed) - { - var engulfable = engulfed.Object.Value; - if (engulfable == null) - return; - - attemptingToEngulf.Remove(engulfable); - engulfedObjects.Remove(engulfed); - expelledObjects.Add(engulfed); - - engulfable.PhagocytosisStep = PhagocytosisPhase.None; - - foreach (string group in engulfed.OriginalGroups) - { - if (group != Constants.RUNNABLE_MICROBE_GROUP) - engulfable.EntityNode.AddToGroup(group); - } - - // Reset render priority - engulfable.RenderPriority = engulfed.OriginalRenderPriority; - - engulfed.Phagosome.Value?.DestroyDetachAndQueueFree(); - - // Ignore possible invalid cast as the engulfed node should be a rigidbody either way - var body = (RigidBody)engulfable; - - body.Mode = ModeEnum.Rigid; - - // Re-parent to world node - body.ReParentWithTransform(GetStageAsParent()); - - // Reset collision layer and mask - body.CollisionLayer = engulfed.OriginalCollisionLayer; - body.CollisionMask = engulfed.OriginalCollisionMask; - - var impulse = Transform.origin.DirectionTo(body.Transform.origin) * body.Mass * - Constants.ENGULF_EJECTION_FORCE; - - // Apply outwards ejection force - body.ApplyCentralImpulse(impulse + LinearVelocity); - - // We have our own engulfer and it wants to claim this object we've just expelled - HostileEngulfer.Value?.IngestEngulfable(engulfable); - - engulfable.OnExpelledFromEngulfment(); - engulfable.HostileEngulfer.Value = null; - } - - /// - /// Stores extra information to the objects that have been engulfed. - /// - private class EngulfedObject - { - public EngulfedObject(IEngulfable @object, Endosome phagosome) - { - Object = new EntityReference(@object); - Phagosome = new EntityReference(phagosome); - - AdditionalEngulfableCompounds = @object.CalculateAdditionalDigestibleCompounds()? - .Where(c => c.Key.Digestible) - .ToDictionary(c => c.Key, c => c.Value); - - InitialTotalEngulfableCompounds = @object.Compounds.Compounds - .Where(c => c.Key.Digestible) - .Sum(c => c.Value); - - if (AdditionalEngulfableCompounds != null) - InitialTotalEngulfableCompounds += AdditionalEngulfableCompounds.Sum(c => c.Value); - - OriginalGroups = @object.EntityNode.GetGroups(); - } - - [JsonConstructor] - public EngulfedObject(IEngulfable @object, Endosome phagosome, - Dictionary additionalEngulfableCompounds, float initialTotalEngulfableCompounds) - { - Object = new EntityReference(@object); - Phagosome = new EntityReference(phagosome); - AdditionalEngulfableCompounds = additionalEngulfableCompounds; - InitialTotalEngulfableCompounds = initialTotalEngulfableCompounds; - } - - /// - /// The solid matter that has been engulfed. - /// - public EntityReference Object { get; private set; } - - /// - /// A food vacuole containing the engulfed object. Only decorative. - /// - public EntityReference Phagosome { get; private set; } - - [JsonProperty] - public Dictionary? AdditionalEngulfableCompounds { get; private set; } - - [JsonProperty] - public float? InitialTotalEngulfableCompounds { get; private set; } - - [JsonProperty] - public Array OriginalGroups { get; private set; } = new(); - - public bool Interpolate { get; set; } - public float LerpDuration { get; set; } - public float AnimationTimeElapsed { get; set; } - public float TimeElapsedSinceEjection { get; set; } - public (Vector3? Translation, Vector3? Scale, Vector3? EndosomeScale) TargetValuesToLerp { get; set; } - public (Vector3 Translation, Vector3 Scale, Vector3 EndosomeScale) InitialValuesToLerp { get; set; } - public Vector3 OriginalScale { get; set; } - public int OriginalRenderPriority { get; set; } - - // These values (default microbe collision layer & mask) are here for save compatibility - public uint OriginalCollisionLayer { get; set; } = 3; - public uint OriginalCollisionMask { get; set; } = 3; - } -} diff --git a/src/microbe_stage/Microbe.Interior.cs b/src/microbe_stage/Microbe.Interior.cs deleted file mode 100644 index 9a9f9e20c5a..00000000000 --- a/src/microbe_stage/Microbe.Interior.cs +++ /dev/null @@ -1,1755 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Godot; -using Newtonsoft.Json; - -/// -/// Main script on each cell in the game. -/// Partial class: Compounds, ATP, Reproduction -/// Divide, Organelles, Toxin, Digestion -/// -public partial class Microbe -{ - [JsonProperty] - private readonly CompoundBag compounds = new(0.0f); - - [JsonProperty] - private readonly Dictionary requiredCompoundsForBaseReproduction = new(); - - private Compound atp = null!; - private Compound glucose = null!; - private Compound mucilage = null!; - - private Enzyme lipase = null!; - - [JsonProperty] - private CompoundCloudSystem? cloudSystem; - - [JsonProperty] - private ISpawnSystem? spawnSystem; - - [JsonProperty] - private Compound? queuedToxinToEmit; - - /// - /// The organelles in this microbe - /// - [JsonProperty] - private OrganelleLayout? organelles; - - private bool enzymesDirty = true; - private Dictionary enzymes = new(); - - [JsonProperty] - private float lastCheckedATPDamage; - - [JsonProperty] - private float lastCheckedOxytoxyDigestionDamage; - - [JsonProperty] - private float dissolveEffectValue; - - [JsonProperty] - private float playerEngulfedDeathTimer; - - [JsonProperty] - private float slimeSecretionCooldown; - - [JsonProperty] - private float queuedSlimeSecretionTime; - - private float lastCheckedReproduction; - - /// - /// Flips every reproduction update. Used to make compound use for reproduction distribute more evenly between - /// the compound types. - /// - private bool consumeReproductionCompoundsReverse; - - /// - /// The microbe stores here the sum of capacity of all the - /// current organelles. This is here to prevent anyone from - /// messing with this value if we used the the - /// CompoundBag for the calculations that use this. - /// - private float organellesCapacity; - - /// - /// Stores additional capacity for compounds outside of organellesCapacity. Currently, this only stores - /// additional capacity granted from specialized vacuoles - /// - private Dictionary additionalCompoundCapacities = new(); - - /// - /// True once all organelles are divided to not continuously run code that is triggered - /// when a cell is ready to reproduce. - /// - /// - /// - /// This is not saved so that the player cell can enable the editor when loading a save - /// where the player is ready to reproduce. If more code is added to be ran just once based - /// on this flag, it needs to be made sure that that code re-running after loading a save is - /// not a problem. - /// - /// - private bool allOrganellesDivided; - - private float timeUntilChemoreceptionUpdate = Constants.CHEMORECEPTOR_SEARCH_UPDATE_INTERVAL; - private float timeUntilDigestionUpdate = Constants.MICROBE_DIGESTION_UPDATE_INTERVAL; - - private bool organelleMaxRenderPriorityDirty = true; - private int cachedOrganelleMaxRenderPriority; - - public enum DigestCheckResult - { - Ok, - MissingEnzyme, - } - - /// - /// The stored compounds in this microbe - /// - [JsonIgnore] - public CompoundBag Compounds => compounds; - - /// - /// True only when this cell has been killed to let know things - /// being engulfed by us that we are dead. - /// - [JsonProperty] - public bool Dead { get; private set; } - - /// - /// The number of agent vacuoles. Determines the time between - /// toxin shots. - /// - [JsonProperty] - public int AgentVacuoleCount { get; private set; } - - /// - /// The slime jets attached to this microbe. JsonIgnore as the components add themselves to this list each load. - /// - [JsonIgnore] - public List SlimeJets { get; private set; } = new(); - - /// - /// All organelle nodes need to be added to this node to make scale work - /// - [JsonIgnore] - public Spatial OrganelleParent { get; private set; } = null!; - - /// - /// The cached highest assigned render priority from all of the organelles. - /// - /// - /// - /// A possibly cheaper version of . - /// - /// - [JsonIgnore] - public int OrganelleMaxRenderPriority - { - get - { - if (organelleMaxRenderPriorityDirty) - CountOrganelleMaxRenderPriority(); - - return cachedOrganelleMaxRenderPriority; - } - } - - [JsonIgnore] - public CompoundBag ProcessCompoundStorage => Compounds; - - /// - /// For use by the AI to do run and tumble to find compounds. Also used by player cell for tutorials - /// - [JsonProperty] - public Dictionary TotalAbsorbedCompounds { get; set; } = new(); - - [JsonProperty] - public float AgentEmissionCooldown { get; private set; } - - [JsonIgnore] - public Enzyme RequisiteEnzymeToDigest => SimulationParameters.Instance.GetEnzyme(Membrane.Type.DissolverEnzyme); - - [JsonIgnore] - public float DigestedAmount - { - get => dissolveEffectValue; - set - { - dissolveEffectValue = Mathf.Clamp(value, 0.0f, 1.0f); - UpdateDissolveEffect(); - } - } - - /// - /// Called when the reproduction status of this microbe changes - /// - [JsonProperty] - public Action? OnReproductionStatus { get; set; } - - /// - /// Called periodically to report the chemoreception settings of the microbe - /// - [JsonProperty] - public Action, - IEnumerable<(Species Species, float Range, Color Colour)>>? OnChemoreceptionInfo { get; set; } - - /// - /// Resets the organelles in this microbe to match the species definition - /// - public void ResetOrganelleLayout() - { - // TODO: It would be much better if only organelles that need to be removed where removed, - // instead of everything. - // When doing that all organelles will need to be re-added anyway if this turned from a prokaryote to eukaryote - - if (organelles == null) - { - organelles = new OrganelleLayout(OnOrganelleAdded, OnOrganelleRemoved); - } - else - { - // Just clear the existing ones - organelles.Clear(); - } - - foreach (var entry in CellTypeProperties.Organelles.Organelles) - { - var placed = new PlacedOrganelle(entry.Definition, entry.Position, entry.Orientation) - { - Upgrades = entry.Upgrades, - }; - - organelles.Add(placed); - } - - // Reproduction progress is lost - allOrganellesDivided = false; - SetupRequiredBaseReproductionCompounds(); - - // Unbind if a colony's master cell removed its binding agent. - if (Colony != null && Colony.Master == this && !organelles.Any(p => p.IsBindingAgent)) - Colony.RemoveFromColony(this); - - // Make chemoreception update happen immediately in case the settings changed so that new information is - // used earlier - timeUntilChemoreceptionUpdate = 0; - - if (IsMulticellular) - ResetMulticellularProgress(); - } - - /// - /// Applies the set species' color to all of this microbe's organelles - /// - public void ApplyPreviewOrganelleColours() - { - if (!IsForPreviewOnly) - throw new InvalidOperationException("Microbe must be a preview-only type"); - - if (organelles == null) - throw new InvalidOperationException("Microbe must be initialized"); - - foreach (var entry in organelles.Organelles) - { - entry.Colour = CellTypeProperties.Colour; - entry.UpdateAsync(0); - - // This applies the colour so UpdateAsync is not technically needed but to avoid weird bugs we just do it - // as well - entry.UpdateSync(); - } - } - - /// - /// Tries to fire a toxin if possible - /// - public void EmitToxin(Compound? agentType = null) - { - if (PhagocytosisStep != PhagocytosisPhase.None) - return; - - agentType ??= SimulationParameters.Instance.GetCompound("oxytoxy"); - - PerformForOtherColonyMembersIfWeAreLeader(m => m.EmitToxin(agentType)); - - if (AgentEmissionCooldown > 0) - return; - - // Only shoot if you have an agent vacuole. - if (AgentVacuoleCount < 1) - return; - - float amountAvailable = Compounds.GetCompoundAmount(agentType); - - // Emit as much as you have, but don't start the cooldown if that's zero - float amountEmitted = Math.Min(amountAvailable, Constants.MAXIMUM_AGENT_EMISSION_AMOUNT); - if (amountEmitted < Constants.MINIMUM_AGENT_EMISSION_AMOUNT) - return; - - Compounds.TakeCompound(agentType, amountEmitted); - - // The cooldown time is inversely proportional to the amount of agent vacuoles. - AgentEmissionCooldown = Constants.AGENT_EMISSION_COOLDOWN / AgentVacuoleCount; - - float ejectionDistance = Membrane.EncompassingCircleRadius + - Constants.AGENT_EMISSION_DISTANCE_OFFSET; - - if (CellTypeProperties.IsBacteria) - ejectionDistance *= 0.5f; - - var props = new AgentProperties(Species, agentType); - - // Find the direction the microbe is facing - // (actual rotation, not LookAtPoint, also takes colony membership into account) - Vector3 direction = FacingDirection(); - - var position = GlobalTransform.origin + (direction * ejectionDistance); - - var agent = SpawnHelpers.SpawnAgent(props, amountEmitted, Constants.EMITTED_AGENT_LIFETIME, - position, direction, GetStageAsParent(), - SpawnHelpers.LoadAgentScene(), this); - - ModLoader.ModInterface.TriggerOnToxinEmitted(agent); - - if (amountEmitted < Constants.MAXIMUM_AGENT_EMISSION_AMOUNT / 2) - { - PlaySoundEffect("res://assets/sounds/soundeffects/microbe-release-toxin-low.ogg"); - } - else - { - PlaySoundEffect("res://assets/sounds/soundeffects/microbe-release-toxin.ogg"); - } - } - - /// - /// Handles colony logic to determine the actual facing vector of this microbe - /// - /// A Vector3 of this microbe's real facing - public Vector3 FacingDirection() - { - if (Colony != null) - { - return Colony.Master.GlobalTransform - .basis.Quat().Normalized().Xform(Vector3.Forward); - } - - return GlobalTransform.basis.Quat().Normalized().Xform(Vector3.Forward); - } - - /// - /// Makes this Microbe fire a toxin on the next update. Used by the AI from a background thread. - /// Only one can be queued at once - /// - /// The toxin type to emit - public void QueueEmitToxin(Compound toxinCompound) - { - queuedToxinToEmit = toxinCompound; - } - - public void QueueSecreteSlime(float duration) - { - PerformForOtherColonyMembersIfWeAreLeader(m => m.QueueSecreteSlime(duration)); - - if (SlimeJets.Count < 1) - return; - - queuedSlimeSecretionTime += duration; - } - - /// - /// Report that a pilus shape was added to this microbe. Called by PilusComponent - /// - public void AddPilus(uint shapeOwner, bool injectisome) - { - pilusPhysicsShapes.Add(shapeOwner, injectisome); - } - - public bool RemovePilus(uint shapeOwner) - { - return pilusPhysicsShapes.Remove(shapeOwner); - } - - public bool IsPilus(uint shape) - { - return pilusPhysicsShapes.ContainsKey(shape); - } - - public bool IsInjectisome(uint shape) - { - if (!IsPilus(shape)) - return false; - - return pilusPhysicsShapes[shape]; - } - - /// - /// Resets the compounds to be the ones this species spawns with. Called by spawn helpers - /// - public void SetInitialCompounds() - { - Compounds.ClearCompounds(); - - foreach (var entry in Species.InitialCompounds) - { - // Temporary code before proper initial compounds set method is added to CompoundBag - Compounds.Compounds.TryGetValue(entry.Key, out var existing); - Compounds.Compounds[entry.Key] = existing + entry.Value; - } - } - - /// - /// Triggers reproduction on this cell (even if not ready) - /// - /// - /// - /// Now with multicellular colonies are also allowed to divide so there's no longer a check against that - /// - /// - public Microbe Divide() - { - if (ColonyParent != null) - throw new ArgumentException("Cell that is a colony member (non-leader) can't divide"); - - var currentPosition = GlobalTransform.origin; - - // Find the direction to the right from where the cell is facing - var direction = GlobalTransform.basis.Quat().Normalized().Xform(Vector3.Right); - - // Start calculating separation distance - var organellePositions = organelles!.Organelles.Select(o => Hex.AxialToCartesian(o.Position)).ToList(); - - float distanceRight = MathUtils.GetMaximumDistanceInDirection(Vector3.Right, Vector3.Zero, organellePositions); - float distanceLeft = MathUtils.GetMaximumDistanceInDirection(Vector3.Left, Vector3.Zero, organellePositions); - - if (Colony != null) - { - var colonyMembers = Colony.ColonyMembers.Select(c => c.GlobalTransform.origin); - - distanceRight += MathUtils.GetMaximumDistanceInDirection(direction, currentPosition, colonyMembers); - } - - float width = distanceLeft + distanceRight + Constants.DIVIDE_EXTRA_DAUGHTER_OFFSET; - - if (CellTypeProperties.IsBacteria) - width *= 0.5f; - - // Create the one daughter cell. - var copyEntity = SpawnHelpers.SpawnMicrobe(Species, currentPosition + direction * width, - GetParent(), SpawnHelpers.LoadMicrobeScene(), true, cloudSystem!, spawnSystem!, CurrentGame); - - // Since the daughter spawns right next to the cell, it should face the same way to avoid colliding - var daughterBasis = new Basis(Transform.basis.Quat()) - { - Scale = copyEntity.Transform.basis.Scale, - }; - - copyEntity.Transform = new Transform(daughterBasis, copyEntity.Translation); - - // Make it despawn like normal - spawnSystem!.AddEntityToTrack(copyEntity); - - // Remove the compounds from the created cell - copyEntity.Compounds.ClearCompounds(); - - var keys = new List(Compounds.Compounds.Keys); - var reproductionCompounds = copyEntity.CalculateTotalCompounds(); - - // Split the compounds between the two cells. - foreach (var compound in keys) - { - var amount = Compounds.GetCompoundAmount(compound); - - if (amount <= 0) - continue; - - // If the compound is for reproduction we give player and NPC microbes different amounts. - if (reproductionCompounds.TryGetValue(compound, out float divideAmount)) - { - // The amount taken away from the parent cell depends on if it is a player or NPC. Player - // cells always have 50% of the compounds they divided with taken away. - float amountToTake = amount * 0.5f; - - if (!IsPlayerMicrobe) - { - // NPC parent cells have at least 50% taken away, or more if it would leave them - // with more than 90% of the compound it would take to immediately divide again. - amountToTake = Math.Max(amountToTake, amount - (divideAmount * 0.9f)); - } - - Compounds.TakeCompound(compound, amountToTake); - - // Since the child cell is always an NPC they are given either 50% of the compound from the - // parent, or 90% of the amount required to immediately divide again, whichever is smaller. - float amountToGive = Math.Min(amount * 0.5f, divideAmount * 0.9f); - var addedCompound = copyEntity.Compounds.AddCompound(compound, amountToGive); - - if (addedCompound < amountToGive) - { - // TODO: handle the excess compound that didn't fit in the other cell - } - } - else - { - // Non-reproductive compounds just always get split evenly to both cells. - Compounds.TakeCompound(compound, amount * 0.5f); - - var amountAdded = copyEntity.Compounds.AddCompound(compound, amount * 0.5f); - - if (amountAdded < amount) - { - // TODO: handle the excess compound that didn't fit in the other cell - } - } - } - - // Play the split sound - PlaySoundEffect("res://assets/sounds/soundeffects/reproduction.ogg"); - - return copyEntity; - } - - /// - /// Throws some compound out of this Microbe, up to maxAmount - /// - /// The compound type to eject - /// The maximum amount to eject - /// The direction in which to eject relative to the microbe - /// How far away from the microbe to eject - public float EjectCompound(Compound compound, float maxAmount, Vector3 direction, float displacement = 0) - { - float amount = Compounds.TakeCompound(compound, maxAmount); - - SpawnEjectedCompound(compound, amount, direction, displacement); - return amount; - } - - /// - /// Calculates the reproduction progress for a cell, used to - /// show how close the player is getting to the editor. - /// - public float CalculateReproductionProgress(out Dictionary gatheredCompounds, - out Dictionary totalCompounds) - { - // Calculate total compounds needed to split all organelles - totalCompounds = CalculateTotalCompounds(); - - // Calculate how many compounds the cell already has absorbed to grow - gatheredCompounds = CalculateAlreadyAbsorbedCompounds(); - - // Add the currently held compounds, but only if configured as this can be pretty confusing for players - // to have the bars in ready to reproduce state for a while before the time limited reproduction actually - // catches up - if (Constants.ALWAYS_SHOW_STORED_COMPOUNDS_IN_REPRODUCTION_PROGRESS || - !GameWorld.WorldSettings.LimitReproductionCompoundUseSpeed) - { - foreach (var key in gatheredCompounds.Keys.ToList()) - { - float value = Math.Max(0.0f, Compounds.GetCompoundAmount(key) - - Constants.ORGANELLE_GROW_STORAGE_MUST_HAVE_AT_LEAST); - - if (value > 0) - { - float existing = gatheredCompounds[key]; - - // Only up to the total needed - float total = totalCompounds[key]; - - gatheredCompounds[key] = Math.Min(total, existing + value); - } - } - } - - float totalFraction = 0; - - foreach (var entry in totalCompounds) - { - if (gatheredCompounds.TryGetValue(entry.Key, out var gathered) && entry.Value != 0) - totalFraction += gathered / entry.Value; - } - - return totalFraction / totalCompounds.Count; - } - - /// - /// Calculates total compounds needed for a cell to reproduce, used by calculateReproductionProgress to calculate - /// the fraction done. - /// - public Dictionary CalculateTotalCompounds() - { - if (organelles == null) - throw new InvalidOperationException("Microbe must be initialized first"); - - if (IsMulticellular) - return CalculateTotalBodyPlanCompounds(); - - var result = CellTypeProperties.CalculateTotalComposition(); - - result.Merge(Species.BaseReproductionCost); - - return result; - } - - /// - /// Calculates how much compounds organelles have already absorbed - /// - public Dictionary CalculateAlreadyAbsorbedCompounds() - { - if (organelles == null) - throw new InvalidOperationException("Microbe must be initialized first"); - - var result = new Dictionary(); - - foreach (var organelle in organelles) - { - if (organelle.IsDuplicate) - continue; - - if (organelle.WasSplit) - { - // Organelles are reset on split, so we use the full - // cost as the gathered amount - result.Merge(organelle.Definition.InitialComposition); - continue; - } - - organelle.CalculateAbsorbedCompounds(result); - } - - if (compoundsUsedForMulticellularGrowth != null) - { - result.Merge(compoundsUsedForMulticellularGrowth); - } - else - { - // For single microbes the base reproduction cost needs to be calculated here - // TODO: can we make this more efficient somehow - foreach (var entry in Species.BaseReproductionCost) - { - requiredCompoundsForBaseReproduction.TryGetValue(entry.Key, out var remaining); - - var used = entry.Value - remaining; - - result.TryGetValue(entry.Key, out var alreadyUsed); - - result[entry.Key] = alreadyUsed + used; - } - } - - return result; - } - - public Dictionary CalculateAdditionalDigestibleCompounds() - { - var result = new Dictionary(); - - // Add some part of the build cost of all the organelles - foreach (var organelle in organelles!) - { - foreach (var entry in organelle.Definition.InitialComposition) - { - result.TryGetValue(entry.Key, out float existing); - result[entry.Key] = existing + entry.Value; - } - } - - CalculateBonusDigestibleGlucose(result); - return result; - } - - /// - /// Returns the check result whether this microbe can digest the target (has the enzyme necessary). - /// - /// - /// - /// This is different from because ingestibility and digestibility - /// are separate, you can engulf a walled cell but not digest it if you're missing the enzyme required to do - /// so. - /// - /// - public DigestCheckResult CanDigestObject(IEngulfable engulfable) - { - var enzyme = engulfable.RequisiteEnzymeToDigest; - - if (enzyme != null && !Enzymes.ContainsKey(enzyme)) - return DigestCheckResult.MissingEnzyme; - - return DigestCheckResult.Ok; - } - - /// - /// Perform an action for all members of this cell's colony other than this cell if this is the colony leader. - /// - private void PerformForOtherColonyMembersIfWeAreLeader(Action action) - { - if (Colony?.Master == this) - { - foreach (var cell in Colony.ColonyMembers) - { - if (cell == this) - continue; - - action(cell); - } - } - } - - private void HandleCompoundAbsorbing(float delta) - { - if (PhagocytosisStep != PhagocytosisPhase.None) - return; - - // max here buffs compound absorbing for the smallest cells - var grabRadius = Mathf.Max(Radius, 3.0f); - - cloudSystem!.AbsorbCompounds(GlobalTransform.origin, grabRadius, Compounds, - TotalAbsorbedCompounds, delta, Membrane.Type.ResourceAbsorptionFactor); - - // Cells with jets aren't affected by mucilage - slowedBySlime = SlimeJets.Count < 1 && cloudSystem.AmountAvailable(mucilage, GlobalTransform.origin, 1.0f) > - Constants.COMPOUND_DENSITY_CATEGORY_FAIR_AMOUNT; - - if (IsPlayerMicrobe && CheatManager.InfiniteCompounds) - { - var usefulCompounds = SimulationParameters.Instance.GetCloudCompounds().Where(Compounds.IsUseful); - foreach (var usefulCompound in usefulCompounds) - Compounds.AddCompound(usefulCompound, Compounds.GetFreeSpaceForCompound(usefulCompound)); - } - } - - /// - /// Vents (throws out) non-useful compounds from this cell - /// - private void HandleCompoundVenting(float delta) - { - // Skip if process system has not run yet - if (!Compounds.HasAnyBeenSetUseful()) - return; - - if (PhagocytosisStep != PhagocytosisPhase.None) - return; - - float amountToVent = Constants.COMPOUNDS_TO_VENT_PER_SECOND * delta; - - // Cloud types are ones that can be vented - foreach (var type in SimulationParameters.Instance.GetCloudCompounds()) - { - // Vent if not useful, or if overflowed the capacity - // The multiply by 2 is here to be more kind to cells that have just divided and make it much less likely - // the player often sees their cell venting away their precious compounds - if (!Compounds.IsUseful(type)) - { - amountToVent -= EjectCompound(type, amountToVent, Vector3.Back); - } - else if (Compounds.GetCompoundAmount(type) > 2 * Compounds.GetCapacityForCompound(type)) - { - // Vent the part that went over - float toVent = Compounds.GetCompoundAmount(type) - (2 * Compounds.GetCapacityForCompound(type)); - - amountToVent -= EjectCompound(type, Math.Min(toVent, amountToVent), Vector3.Back); - } - - if (amountToVent <= 0) - break; - } - } - - /// - /// Regenerate hitpoints while the cell has atp - /// - private void HandleHitpointsRegeneration(float delta) - { - if (Hitpoints < MaxHitpoints) - { - if (Compounds.GetCompoundAmount(atp) >= 1.0f) - { - Hitpoints += Constants.REGENERATION_RATE * delta; - if (Hitpoints > MaxHitpoints) - { - Hitpoints = MaxHitpoints; - } - } - } - } - - /// - /// Sets up the hitpoints of this microbe based on the Species membrane - /// - private void SetupMicrobeHitpoints() - { - float currentHealth = Hitpoints / MaxHitpoints; - - MaxHitpoints = CellTypeProperties.MembraneType.Hitpoints + - (CellTypeProperties.MembraneRigidity * Constants.MEMBRANE_RIGIDITY_HITPOINTS_MODIFIER); - - Hitpoints = MaxHitpoints * currentHealth; - } - - /// - /// Handles feeding the organelles in this microbe in order for them to split. After all are split this is - /// ready to reproduce. - /// - /// - /// - /// AI cells will immediately reproduce when they can. On the player cell the editor is unlocked when - /// reproducing is possible. - /// - /// - /// TODO: split this into two parts: giving compounds to grow, and actually spawning things to be able to - /// do multithreading here - /// - /// - private void HandleReproduction(float delta) - { - // Dead or engulfed cells can't reproduce - if (Dead || PhagocytosisStep != PhagocytosisPhase.None) - return; - - if (allOrganellesDivided) - { - // Ready to reproduce already. Only the player gets here as other cells split and reset automatically - return; - } - - lastCheckedReproduction += delta; - - // Limit how often the reproduction logic is ran - if (lastCheckedReproduction < Constants.MICROBE_REPRODUCTION_PROGRESS_INTERVAL) - return; - - // Limit how big progress spikes lag can cause - if (lastCheckedReproduction > Constants.MICROBE_REPRODUCTION_MAX_DELTA_FRAME) - lastCheckedReproduction = Constants.MICROBE_REPRODUCTION_MAX_DELTA_FRAME; - - var elapsedSinceLastUpdate = lastCheckedReproduction; - consumeReproductionCompoundsReverse = !consumeReproductionCompoundsReverse; - - lastCheckedReproduction = 0; - - // Multicellular microbes in a colony still run reproduction logic as long as they are the colony leader - if (IsMulticellular && ColonyParent == null) - { - HandleMulticellularReproduction(elapsedSinceLastUpdate); - return; - } - - if (Colony != null) - { - // TODO: should the colony just passively get the reproduction compounds in its storage? - // Otherwise early multicellular colonies lose the passive reproduction feature - return; - } - - var (remainingAllowedCompoundUse, remainingFreeCompounds) = - CalculateFreeCompoundsAndLimits(elapsedSinceLastUpdate); - - // Process base cost first so the player can be their designed cell (without extra organelles) for a while - bool reproductionStageComplete = - ProcessBaseReproductionCost(ref remainingAllowedCompoundUse, ref remainingFreeCompounds); - - // For this stage and all others below, reproductionStageComplete tracks whether the previous reproduction - // stage completed, i.e. whether we should proceed with the next stage - if (reproductionStageComplete) - { - // Organelles that are ready to split - var organellesToAdd = new List(); - - // Grow all the organelles, except the unique organelles which are given compounds last - foreach (var organelle in organelles!.Organelles) - { - // Check if already done - if (organelle.WasSplit) - continue; - - // If we ran out of allowed compound use, stop early - if (remainingAllowedCompoundUse <= 0) - { - reproductionStageComplete = false; - break; - } - - // We are in G1 phase of the cell cycle, duplicate all organelles. - - // Except the unique organelles - if (organelle.Definition.Unique) - continue; - - // Give it some compounds to make it larger. - organelle.GrowOrganelle(Compounds, ref remainingAllowedCompoundUse, ref remainingFreeCompounds, - consumeReproductionCompoundsReverse); - - if (organelle.GrowthValue >= 1.0f) - { - // Queue this organelle for splitting after the loop. - organellesToAdd.Add(organelle); - } - else - { - // Needs more stuff - reproductionStageComplete = false; - } - - // TODO: can we quit this loop early if we still would have dozens of organelles to check but don't have - // any compounds left to give them (that are probably useful)? - } - - // Splitting the queued organelles. - foreach (var organelle in organellesToAdd) - { - // Mark this organelle as done and return to its normal size. - organelle.ResetGrowth(); - organelle.WasSplit = true; - - // Create a second organelle. - var organelle2 = SplitOrganelle(organelle); - organelle2.WasSplit = true; - organelle2.IsDuplicate = true; - organelle2.SisterOrganelle = organelle; - } - } - - if (reproductionStageComplete) - { - foreach (var organelle in organelles!.Organelles) - { - // In the second phase all unique organelles are given compounds - // It used to be that only the nucleus was given compounds here - if (!organelle.Definition.Unique) - continue; - - // If we ran out of allowed compound use, stop early - if (remainingAllowedCompoundUse <= 0) - { - reproductionStageComplete = false; - break; - } - - // Unique organelles don't split so we use the growth value to know when something is fully grown - if (organelle.GrowthValue < 1.0f) - { - organelle.GrowOrganelle(Compounds, ref remainingAllowedCompoundUse, ref remainingFreeCompounds, - consumeReproductionCompoundsReverse); - - // Nucleus (or another unique organelle) needs more compounds - reproductionStageComplete = false; - } - } - } - - if (reproductionStageComplete) - { - // All organelles and base reproduction cost is now fulfilled, we are fully ready to reproduce - allOrganellesDivided = true; - - // For NPC cells this immediately splits them and the allOrganellesDivided flag is reset - ReadyToReproduce(); - } - } - - private (float RemainingAllowedCompoundUse, float RemainingFreeCompounds) - CalculateFreeCompoundsAndLimits(float delta) - { - var gameWorldWorldSettings = GameWorld.WorldSettings; - - // Skip some computations when they are not needed - if (!gameWorldWorldSettings.PassiveGainOfReproductionCompounds && - !gameWorldWorldSettings.LimitReproductionCompoundUseSpeed) - { - return (float.MaxValue, 0); - } - - // TODO: make the current patch affect this? - // TODO: make being in a colony affect this - float remainingFreeCompounds = Constants.MICROBE_REPRODUCTION_FREE_COMPOUNDS * - (HexCount * Constants.MICROBE_REPRODUCTION_FREE_RATE_FROM_HEX + 1.0f) * delta; - - if (IsMulticellular) - remainingFreeCompounds *= Constants.EARLY_MULTICELLULAR_REPRODUCTION_COMPOUND_MULTIPLIER; - - float remainingAllowedCompoundUse = float.MaxValue; - - if (gameWorldWorldSettings.LimitReproductionCompoundUseSpeed) - { - remainingAllowedCompoundUse = remainingFreeCompounds * Constants.MICROBE_REPRODUCTION_MAX_COMPOUND_USE; - } - - // Reset the free compounds if we don't want to give free compounds. - // It was necessary to calculate for the above math to be able to use it, but we don't want it to apply when - // not enabled. - if (!gameWorldWorldSettings.PassiveGainOfReproductionCompounds) - { - remainingFreeCompounds = 0; - } - - return (remainingAllowedCompoundUse, remainingFreeCompounds); - } - - private bool ProcessBaseReproductionCost(ref float remainingAllowedCompoundUse, ref float remainingFreeCompounds, - Dictionary? trackCompoundUse = null) - { - if (remainingAllowedCompoundUse <= 0) - { - return false; - } - - bool reproductionStageComplete = true; - - foreach (var key in consumeReproductionCompoundsReverse ? - requiredCompoundsForBaseReproduction.Keys.Reverse() : - requiredCompoundsForBaseReproduction.Keys) - { - var amountNeeded = requiredCompoundsForBaseReproduction[key]; - - if (amountNeeded <= 0.0f) - continue; - - // TODO: the following is very similar code to PlacedOrganelle.GrowOrganelle - float usedAmount = 0; - - float allowedUseAmount = Math.Min(amountNeeded, remainingAllowedCompoundUse); - - if (remainingFreeCompounds > 0) - { - var usedFreeCompounds = Math.Min(allowedUseAmount, remainingFreeCompounds); - usedAmount += usedFreeCompounds; - allowedUseAmount -= usedFreeCompounds; - remainingFreeCompounds -= usedFreeCompounds; - } - - // For consistency we apply the ORGANELLE_GROW_STORAGE_MUST_HAVE_AT_LEAST constant here like for - // organelle growth - var amountAvailable = - compounds.GetCompoundAmount(key) - Constants.ORGANELLE_GROW_STORAGE_MUST_HAVE_AT_LEAST; - - if (amountAvailable > MathUtils.EPSILON) - { - // We can take some - var amountToTake = Mathf.Min(allowedUseAmount, amountAvailable); - - usedAmount += compounds.TakeCompound(key, amountToTake); - } - - if (usedAmount < MathUtils.EPSILON) - continue; - - remainingAllowedCompoundUse -= usedAmount; - - if (trackCompoundUse != null) - { - trackCompoundUse.TryGetValue(key, out var trackedAlreadyUsed); - trackCompoundUse[key] = trackedAlreadyUsed + usedAmount; - } - - var left = amountNeeded - usedAmount; - - if (left < 0.0001f) - { - // We don't remove these values even when empty as we rely on detecting this being empty for earlier - // save compatibility, so we just leave 0 values in requiredCompoundsForBaseReproduction - left = 0; - } - - requiredCompoundsForBaseReproduction[key] = left; - - // As we don't make duplicate lists, we can only process a single type per call - // So we can't know here if we are fully ready - reproductionStageComplete = false; - break; - } - - return reproductionStageComplete; - } - - /// - /// Sets up the base reproduction cost that is on top of the normal costs - /// - private void SetupRequiredBaseReproductionCompounds() - { - requiredCompoundsForBaseReproduction.Clear(); - requiredCompoundsForBaseReproduction.Merge(Species.BaseReproductionCost); - totalNeededForMulticellularGrowth = null; - } - - private void OnPlayerDuplicationCheat(object sender, EventArgs e) - { - allOrganellesDivided = true; - - Divide(); - } - - private PlacedOrganelle SplitOrganelle(PlacedOrganelle organelle) - { - var q = organelle.Position.Q; - var r = organelle.Position.R; - - // The position used here will be overridden with the right value when we manage to find a place - // for this organelle - var newOrganelle = new PlacedOrganelle(organelle.Definition, new Hex(q, r), 0) - { - Upgrades = organelle.Upgrades, - }; - - // Spiral search for space for the organelle - int radius = 1; - while (true) - { - // Moves into the ring of radius "radius" and center the old organelle - var radiusOffset = Hex.HexNeighbourOffset[Hex.HexSide.BottomLeft]; - q += radiusOffset.Q; - r += radiusOffset.R; - - // Iterates in the ring - for (int side = 1; side <= 6; ++side) - { - var offset = Hex.HexNeighbourOffset[(Hex.HexSide)side]; - - // Moves "radius" times into each direction - for (int i = 1; i <= radius; ++i) - { - q += offset.Q; - r += offset.R; - - // Checks every possible rotation value. - for (int j = 0; j <= 5; ++j) - { - newOrganelle.Position = new Hex(q, r); - - // TODO: in the old code this was always i * - // 60 so this didn't actually do what it meant - // to do. But perhaps that was right? This is - // now fixed to actually try the different - // rotations. - newOrganelle.Orientation = j; - if (organelles!.CanPlace(newOrganelle)) - { - organelles.Add(newOrganelle); - return newOrganelle; - } - } - } - } - - ++radius; - } - } - - /// - /// Copies this microbe (if this isn't the player). The new - /// microbe will not have the stored compounds of this one. - /// - private void ReadyToReproduce() - { - if (IsPlayerMicrobe) - { - // The player doesn't split automatically - allOrganellesDivided = true; - - OnReproductionStatus?.Invoke(this, Colony == null || IsMulticellular); - } - else - { - // Skip reproducing if we would go too much over the entity limit - if (!spawnSystem!.IsUnderEntityLimitForReproducing()) - { - // Set this to false so that we re-check in a few frames if we can reproduce then - allOrganellesDivided = false; - return; - } - - if (!Species.PlayerSpecies) - { - GameWorld.AlterSpeciesPopulationInCurrentPatch(Species, - Constants.CREATURE_REPRODUCE_POPULATION_GAIN, TranslationServer.Translate("REPRODUCED")); - } - - if (!IsMulticellular) - { - // Return the first cell to its normal, non duplicated cell arrangement and spawn a daughter cell - ResetOrganelleLayout(); - - Divide(); - } - else - { - Divide(); - - enoughResourcesForBudding = false; - - // Let's require the base reproduction cost to be fulfilled again as well, to keep down the colony - // spam, and for consistency with non-multicellular microbes - SetupRequiredBaseReproductionCompounds(); - } - } - } - - /// - /// Removes the player's ability to go to the editor. - /// Does nothing when called by the AI. - /// - private void UnreadyToReproduce() - { - // Sets this flag to false to make full recomputation on next reproduction readiness check - // This notably allows to reactivate editor button upon colony unbinding. - allOrganellesDivided = false; - OnReproductionStatus?.Invoke(this, false); - } - - private void HandleOsmoregulation(float delta) - { - if (PhagocytosisStep != PhagocytosisPhase.None) - return; - - var osmoregulationCost = (HexCount * CellTypeProperties.MembraneType.OsmoregulationFactor * - Constants.ATP_COST_FOR_OSMOREGULATION) * delta; - - // 5% osmoregulation bonus per colony member - if (Colony != null) - { - osmoregulationCost *= 20.0f / (20.0f + Colony.ColonyMembers.Count); - } - - if (Species.PlayerSpecies) - osmoregulationCost *= CurrentGame.GameWorld.WorldSettings.OsmoregulationMultiplier; - - Compounds.TakeCompound(atp, osmoregulationCost); - } - - private void HandleMovement(float delta) - { - if (PhagocytosisStep != PhagocytosisPhase.None) - { - // Reset movement - MovementDirection = Vector3.Zero; - queuedMovementForce = Vector3.Zero; - - return; - } - - if (MovementDirection != Vector3.Zero || queuedMovementForce != Vector3.Zero) - { - // Movement direction should not be normalized to allow different speeds - Vector3 totalMovement = Vector3.Zero; - - if (MovementDirection != Vector3.Zero) - { - totalMovement += DoBaseMovementForce(delta); - } - - totalMovement += queuedMovementForce; - - ApplyMovementImpulse(totalMovement, delta); - - var deltaAcceleration = (linearAcceleration - lastLinearAcceleration).LengthSquared(); - - if (movementSoundCooldownTimer > 0) - movementSoundCooldownTimer -= delta; - - // The cell starts moving from a relatively idle velocity, so play the begin movement sound - // TODO: Account for cell turning, I can't figure out a reliable way to do that using the current - // calculation - Kasterisk - if (movementSoundCooldownTimer <= 0 && deltaAcceleration > lastLinearAcceleration.LengthSquared() && - lastLinearVelocity.LengthSquared() <= 1) - { - movementSoundCooldownTimer = Constants.MICROBE_MOVEMENT_SOUND_EMIT_COOLDOWN; - PlaySoundEffect("res://assets/sounds/soundeffects/microbe-movement-1.ogg"); - } - - if (!movementAudio.Playing) - movementAudio.Play(); - - // Max volume is 0.4 - if (movementAudio.Volume < 0.4f) - movementAudio.Volume += delta; - } - else - { - if (movementAudio.Playing) - { - movementAudio.Volume -= delta; - - if (movementAudio.Volume <= 0) - movementAudio.Stop(); - } - } - } - - /// - /// Damage the microbe if its too low on ATP. - /// - private void ApplyATPDamage() - { - if (Compounds.GetCompoundAmount(atp) <= 0.0f) - { - // TODO: put this on a GUI notification. - // if(microbeComponent.isPlayerMicrobe and not this.playerAlreadyShownAtpDamage){ - // this.playerAlreadyShownAtpDamage = true - // showMessage("No ATP hurts you!") - // } - - Damage(MaxHitpoints * Constants.NO_ATP_DAMAGE_FRACTION, "atpDamage"); - } - } - - [DeserializedCallbackAllowed] - private void OnOrganelleAdded(PlacedOrganelle organelle) - { - organelle.OnAddedToMicrobe(this); - processesDirty = true; - enzymesDirty = true; - cachedHexCountDirty = true; - membraneOrganellePositionsAreDirty = true; - hasSignalingAgent = null; - cachedRotationSpeed = null; - organelleMaxRenderPriorityDirty = true; - - if (organelle.IsAgentVacuole) - AgentVacuoleCount += 1; - - // This is calculated here as it would be a bit difficult to - // hook up computing this when the StorageBag needs this info. - UpdateCapacity(organelle, false); - UpdateCompoundBagCapacities(); - } - - [DeserializedCallbackAllowed] - private void OnOrganelleRemoved(PlacedOrganelle organelle) - { - UpdateCapacity(organelle, true); - - if (organelle.IsAgentVacuole) - AgentVacuoleCount -= 1; - - if (organelle.IsSlimeJet) - SlimeJets.Remove((SlimeJetComponent)organelle.Components.First(c => c is SlimeJetComponent)); - - organelle.OnRemovedFromMicrobe(); - - // The organelle only detaches but doesn't delete itself, so we delete it here - organelle.DetachAndQueueFree(); - - processesDirty = true; - enzymesDirty = true; - cachedHexCountDirty = true; - membraneOrganellePositionsAreDirty = true; - hasSignalingAgent = null; - cachedRotationSpeed = null; - organelleMaxRenderPriorityDirty = true; - - UpdateCompoundBagCapacities(); - } - - /// - /// Recomputes storage from organelles, used after loading a save - /// - private void RecomputeOrganelleCapacity() - { - foreach (PlacedOrganelle organelle in organelles!) - UpdateCapacity(organelle, false); - - UpdateCompoundBagCapacities(); - } - - /// - /// Updates and - /// to take into account the addition or removal of an organelle - /// - /// The organelle being placed or removed - /// Should be true if the organelle is being removed, otherwise false - private void UpdateCapacity(PlacedOrganelle organelle, bool negative) - { - int sign = negative ? -1 : 1; - - organellesCapacity += MicrobeInternalCalculations - .GetNominalCapacityForOrganelle(organelle.Definition, organelle.Upgrades) * sign; - - var capacityTuple = MicrobeInternalCalculations - .GetAdditionalCapacityForOrganelle(organelle.Definition, organelle.Upgrades); - - if (capacityTuple.Compound == null) - return; - - additionalCompoundCapacities.TryGetValue(capacityTuple.Compound, out var existing); - additionalCompoundCapacities[capacityTuple.Compound] = existing + capacityTuple.Capacity * sign; - } - - /// - /// Updates to adjust for changes in and - /// - /// - private void UpdateCompoundBagCapacities() - { - Compounds.NominalCapacity = organellesCapacity; - - Compounds.ClearSpecificCapacities(); - - foreach (var entry in additionalCompoundCapacities) - Compounds.SetCapacityForCompound(entry.Key, entry.Value + organellesCapacity); - } - - private bool CheckHasSignalingAgent() - { - if (hasSignalingAgent != null) - return hasSignalingAgent.Value; - - hasSignalingAgent = organelles!.Any(o => o.HasComponent()); - return hasSignalingAgent.Value; - } - - /// - /// Ejects compounds from the microbes behind position, into the environment - /// - /// - /// - /// Note that the compounds ejected are created in this world - /// and not taken from the microbe. This is purely for adding - /// the compound to the cloud system at the right position. - /// - /// - private void SpawnEjectedCompound(Compound compound, float amount, Vector3 direction, float displacement = 0) - { - var amountToEject = amount * Constants.MICROBE_VENT_COMPOUND_MULTIPLIER; - - if (amountToEject <= MathUtils.EPSILON) - return; - - cloudSystem!.AddCloud(compound, amountToEject, CalculateNearbyWorldPosition(direction, displacement)); - } - - /// - /// Calculates a world pos for emitting compounds - /// - private Vector3 CalculateNearbyWorldPosition(Vector3 direction, float displacement = 0) - { - // OLD CODE kept here in case we want a more accurate membrane position, also this code - // produces an incorrect world position which needs fixing if this were to be used - /* - // The back of the microbe - var exit = Hex.AxialToCartesian(new Hex(0, 1)); - var membraneCoords = Membrane.GetVectorTowardsNearestPointOfMembrane(exit.x, exit.z); - - // Get the distance to eject the compounds - var ejectionDistance = Membrane.EncompassingCircleRadius; - - // The membrane radius doesn't take being bacteria into account - if (CellTypeProperties.IsBacteria) - ejectionDistance *= 0.5f; - - float angle = 180; - - // Find the direction the microbe is facing - var yAxis = Transform.basis.y; - var microbeAngle = Mathf.Atan2(yAxis.x, yAxis.y); - if (microbeAngle < 0) - { - microbeAngle += 2 * Mathf.Pi; - } - - microbeAngle = microbeAngle * 180 / Mathf.Pi; - - // Take the microbe angle into account so we get world relative degrees - var finalAngle = (angle + microbeAngle) % 360; - - var s = Mathf.Sin(finalAngle / 180 * Mathf.Pi); - var c = Mathf.Cos(finalAngle / 180 * Mathf.Pi); - - var ejectionDirection = new Vector3(-membraneCoords.x * c + membraneCoords.z * s, 0, - membraneCoords.x * s + membraneCoords.z * c); - - return Translation + (ejectionDirection * ejectionDistance); - */ - - // Unlike the commented block of code above, this uses cheap membrane radius to calculate - // distance for cheaper computations - var distance = Membrane.EncompassingCircleRadius; - - // The membrane radius doesn't take being bacteria into account - if (CellTypeProperties.IsBacteria) - distance *= 0.5f; - - distance += displacement; - - var ejectionDirection = GlobalTransform.basis.Quat().Normalized().Xform(direction); - - var result = GlobalTransform.origin + (ejectionDirection * distance); - - return result; - } - - private void HandleChemoreceptorLines(float delta) - { - timeUntilChemoreceptionUpdate -= delta; - - if (timeUntilChemoreceptionUpdate > 0 || Dead) - return; - - OnChemoreceptionInfo?.Invoke(this, activeCompoundDetections, activeSpeciesDetections); - - // TODO: should this be cleared each time or only when the chemoreception update interval has elapsed? - activeCompoundDetections.Clear(); - activeSpeciesDetections.Clear(); - } - - /// - /// Absorbs compounds/nutrients from ingested objects. - /// - private void HandleDigestion(float delta) - { - timeUntilDigestionUpdate -= delta; - - if (timeUntilDigestionUpdate > 0 || Dead) - return; - - timeUntilDigestionUpdate = Constants.MICROBE_DIGESTION_UPDATE_INTERVAL; - - var compoundTypes = SimulationParameters.Instance.GetAllCompounds(); - var oxytoxy = SimulationParameters.Instance.GetCompound("oxytoxy"); - - float usedCapacity = 0.0f; - - // Handle logic if the objects that are being digested are the ones we have engulfed - for (int i = engulfedObjects.Count - 1; i >= 0; --i) - { - var engulfedObject = engulfedObjects[i]; - - var engulfable = engulfedObject.Object.Value; - if (engulfable == null) - continue; - - // Expel this engulfed object if the cell loses some of its size and its ingestion capacity - // is overloaded - if (UsedIngestionCapacity > EngulfSize) - { - EjectEngulfable(engulfable); - continue; - } - - // Doesn't make sense to digest non ingested objects, i.e. objects that are being engulfed, - // being ejected, etc. So skip them. - if (engulfable.PhagocytosisStep != PhagocytosisPhase.Ingested) - continue; - - Enzyme usedEnzyme; - - var digestibility = CanDigestObject(engulfable); - - switch (digestibility) - { - case DigestCheckResult.Ok: - usedEnzyme = engulfable.RequisiteEnzymeToDigest ?? lipase; - break; - case DigestCheckResult.MissingEnzyme: - EjectEngulfable(engulfable); - OnNoticeMessage?.Invoke(this, - new SimpleHUDMessage(TranslationServer.Translate("NOTICE_ENGULF_MISSING_ENZYME") - .FormatSafe(engulfable.RequisiteEnzymeToDigest!.Name))); - continue; - default: - throw new InvalidOperationException("Unhandled digestibility check result, won't digest"); - } - - var containedCompounds = engulfable.Compounds; - var additionalCompounds = engulfedObject.AdditionalEngulfableCompounds; - - // Workaround to avoid NaN compounds in engulfed objects, leading to glitches like infinite compound - // ejection and incorrect ingested matter display - // https://github.com/Revolutionary-Games/Thrive/issues/3548 - containedCompounds.FixNaNCompounds(); - - var totalAmountLeft = 0.0f; - - foreach (var compound in compoundTypes.Values) - { - if (!compound.Digestible) - continue; - - var originalAmount = containedCompounds.GetCompoundAmount(compound); - - var additionalAmount = 0.0f; - additionalCompounds?.TryGetValue(compound, out additionalAmount); - - var totalAvailable = originalAmount + additionalAmount; - totalAmountLeft += totalAvailable; - - if (totalAvailable <= 0) - continue; - - var amount = MicrobeInternalCalculations.CalculateDigestionSpeed(Enzymes[usedEnzyme]); - amount *= delta; - - // Efficiency starts from Constants.ENGULF_BASE_COMPOUND_ABSORPTION_YIELD up to - // Constants.ENZYME_DIGESTION_EFFICIENCY_MAXIMUM. This means at least 7 lysosomes - // are needed to achieve "maximum" efficiency - var efficiency = MicrobeInternalCalculations.CalculateDigestionEfficiency(Enzymes[usedEnzyme]); - - var taken = Mathf.Min(totalAvailable, amount); - - // Toxin damage - if (compound == oxytoxy && taken > 0) - { - lastCheckedOxytoxyDigestionDamage += delta; - - if (lastCheckedOxytoxyDigestionDamage >= Constants.TOXIN_DIGESTION_DAMAGE_CHECK_INTERVAL) - { - lastCheckedOxytoxyDigestionDamage -= Constants.TOXIN_DIGESTION_DAMAGE_CHECK_INTERVAL; - Damage(MaxHitpoints * Constants.TOXIN_DIGESTION_DAMAGE_FRACTION, "oxytoxy"); - - OnNoticeMessage?.Invoke(this, - new SimpleHUDMessage(TranslationServer.Translate("NOTICE_ENGULF_DAMAGE_FROM_TOXIN"), - DisplayDuration.Short)); - } - } - - if (additionalCompounds?.ContainsKey(compound) == true) - additionalCompounds[compound] -= taken; - - engulfable.Compounds.TakeCompound(compound, taken); - - var takenAdjusted = taken * efficiency; - var added = Compounds.AddCompound(compound, takenAdjusted); - - // Eject excess - SpawnEjectedCompound(compound, takenAdjusted - added, Vector3.Back); - } - - var initialTotalEngulfableCompounds = engulfedObject.InitialTotalEngulfableCompounds; - - if (initialTotalEngulfableCompounds.HasValue && initialTotalEngulfableCompounds.Value != 0) - { - engulfable.DigestedAmount = 1 - - (totalAmountLeft / initialTotalEngulfableCompounds.Value); - } - - if (totalAmountLeft <= 0 || engulfable.DigestedAmount >= Constants.FULLY_DIGESTED_LIMIT) - { - engulfable.PhagocytosisStep = PhagocytosisPhase.Digested; - } - else - { - usedCapacity += engulfable.EngulfSize; - } - } - - UsedIngestionCapacity = usedCapacity; - - // Else handle logic if the cell that's being/has been digested is us - if (PhagocytosisStep == PhagocytosisPhase.None) - { - if (DigestedAmount > 0 && DigestedAmount < Constants.PARTIALLY_DIGESTED_THRESHOLD) - { - // Cell is not too damaged, can heal itself in open environment and continue living - DigestedAmount -= delta * Constants.ENGULF_COMPOUND_ABSORBING_PER_SECOND; - } - } - else - { - // Species handling for the player microbe in case the process into partial digestion took too long - // so here we want to limit how long the player should wait until they respawn - if (IsPlayerMicrobe && PhagocytosisStep == PhagocytosisPhase.Ingested) - playerEngulfedDeathTimer += delta; - - if (DigestedAmount >= Constants.PARTIALLY_DIGESTED_THRESHOLD || playerEngulfedDeathTimer >= - Constants.PLAYER_ENGULFED_DEATH_DELAY_MAX) - { - playerEngulfedDeathTimer = 0; - - // Microbe is beyond repair, might as well consider it as dead - Kill(); - - if (IsPlayerMicrobe) - { - // Playing from a positional audio player won't have any effect since the listener is - // directly on it. - PlayNonPositionalSoundEffect("res://assets/sounds/soundeffects/microbe-death-2.ogg", 0.5f); - } - - var hostile = HostileEngulfer.Value; - if (hostile == null) - return; - - // Transfer ownership of all the objects we engulfed to our engulfer - foreach (var other in engulfedObjects.ToList()) - { - var engulfed = other.Object.Value; - if (engulfedObjects.Remove(other) && engulfed != null) - { - engulfed.HostileEngulfer.Value = hostile; - hostile.engulfedObjects.Add(other); - engulfed.EntityNode.ReParentWithTransform(hostile); - } - } - } - } - } - - private void CalculateBonusDigestibleGlucose(Dictionary result) - { - result.TryGetValue(glucose, out float existingGlucose); - result[glucose] = existingGlucose + Compounds.GetCapacityForCompound(glucose) * - Constants.ADDITIONAL_DIGESTIBLE_GLUCOSE_AMOUNT_MULTIPLIER; - } - - private void HandleSlimeSecretion(float delta) - { - // Ignore if we have no slime jets - if (SlimeJets.Count < 1) - return; - - // Start a cooldown timer if we're out of mucilage to prevent visible trails or puffs when empty. - // Scaling by slime jet count ensures we aren't producing mucilage fast enough to beat this check. - if (compounds.GetCompoundAmount(mucilage) < Constants.MUCILAGE_MIN_TO_VENT * SlimeJets.Count) - slimeSecretionCooldown = Constants.MUCILAGE_COOLDOWN_TIMER; - - // If we've been told to secrete slime and can do it, proceed - if (queuedSlimeSecretionTime > 0 && slimeSecretionCooldown <= 0) - { - // Play a sound only if we've just started, i.e. only if no jets are already active - if (SlimeJets.All(c => !c.Active)) - PlaySoundEffect("res://assets/sounds/soundeffects/microbe-slime-jet.ogg"); - - // Activate all jets, which will constantly secrete slime until we turn them off - foreach (var jet in SlimeJets) - jet.Active = true; - } - else - { - // Deactivate the jets if we aren't supposed to secrete slime - foreach (var jet in SlimeJets) - jet.Active = false; - } - - queuedSlimeSecretionTime -= delta; - if (queuedSlimeSecretionTime < 0) - queuedSlimeSecretionTime = 0; - } - - private void UpdateDissolveEffect() - { - Membrane.DissolveEffectValue = dissolveEffectValue; - - foreach (var organelle in organelles!) - { - organelle.DissolveEffectValue = dissolveEffectValue; - - if (IsForPreviewOnly || PhagocytosisStep == PhagocytosisPhase.Ingested) - { - organelle.UpdateAsync(0); - organelle.UpdateSync(); - } - } - } - - private void CountOrganelleMaxRenderPriority() - { - cachedOrganelleMaxRenderPriority = 0; - - if (organelles == null) - return; - - cachedOrganelleMaxRenderPriority = organelles.MaxRenderPriority; - organelleMaxRenderPriorityDirty = false; - } -} diff --git a/src/microbe_stage/Microbe.cs b/src/microbe_stage/Microbe.cs deleted file mode 100644 index 75ac3e8f5c6..00000000000 --- a/src/microbe_stage/Microbe.cs +++ /dev/null @@ -1,1362 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Godot; -using Newtonsoft.Json; - -/// -/// Main script on each cell in the game. -/// Partial class: Init, _Ready, _Process, -/// Processes, Species, Audio, Movement -/// -[JsonObject(IsReference = true)] -[JSONAlwaysDynamicType] -[SceneLoadedClass("res://src/microbe_stage/Microbe.tscn", UsesEarlyResolve = false)] -[DeserializedCallbackTarget] -public partial class Microbe : RigidBody, ISpawned, IProcessable, IMicrobeAI, ISaveLoadedTracked, IEngulfable, - IInspectableEntity -{ - /// - /// The point towards which the microbe will move to point to - /// - public Vector3 LookAtPoint = new(0, 0, -1); - - /// - /// The direction the microbe wants to move. Doesn't need to be normalized - /// - public Vector3 MovementDirection = new(0, 0, 0); - -#pragma warning disable CA2213 - private HybridAudioPlayer engulfAudio = null!; - private HybridAudioPlayer bindingAudio = null!; - private HybridAudioPlayer movementAudio = null!; -#pragma warning restore CA2213 - - private List otherAudioPlayers = new(); - private List nonPositionalAudioPlayers = new(); - - /// - /// Init can call _Ready if it hasn't been called yet - /// - private bool onReadyCalled; - - /// - /// We need to know when we should process ourselves or we are ran through . - /// This is this way as microbes can be used for editor previews and also in . - /// - private bool usesExternalProcess; - - private bool absorptionSkippedEarly; - - private bool processesDirty = true; - private List processes = new(); - - private bool cachedHexCountDirty = true; - private int cachedHexCount; - - private float? cachedRotationSpeed; - - private float? cachedColonyRotationMultiplier; - - private float collisionForce; - - private Vector3 queuedMovementForce; - - private Vector3 lastLinearVelocity; - private Vector3 lastLinearAcceleration; - private Vector3 linearAcceleration; - - private float movementSoundCooldownTimer; - - /// - /// Whether this microbe is currently being slowed by environmental slime - /// - private bool slowedBySlime; - - [JsonProperty] - private int renderPriority = 18; - - private Random random = new(); - - private HashSet<(Compound Compound, float Range, float MinAmount, Color Colour)> - activeCompoundDetections = new(); - - private HashSet<(Species Species, float Range, Color Colour)> - activeSpeciesDetections = new(); - - private bool? hasSignalingAgent; - - [JsonProperty] - private MicrobeSignalCommand command = MicrobeSignalCommand.None; - - [JsonProperty] - private MicrobeAI? ai; - -#pragma warning disable CA2213 - - /// - /// 3d audio listener attached to this microbe if it is the player owned one. - /// - private Listener? listener; -#pragma warning restore CA2213 - - private MicrobeSpecies? cachedMicrobeSpecies; - private EarlyMulticellularSpecies? cachedMulticellularSpecies; - - /// - /// The species of this microbe. It's mandatory to initialize this with otherwise - /// random stuff in this instance won't work - /// - [JsonProperty] - public Species Species { get; private set; } = null!; - - [JsonProperty] - public CellType? MulticellularCellType { get; private set; } - - /// - /// True when this is the player's microbe - /// - [JsonProperty] - public bool IsPlayerMicrobe { get; private set; } - - [JsonIgnore] - public string ReadableName => Species.FormattedName; - - [JsonIgnore] - public bool IsHoveredOver { get; set; } - - /// - /// Multiplied on the movement speed of the microbe. - /// - [JsonProperty] - public float MovementFactor { get; private set; } = 1.0f; - - [JsonIgnore] - public bool IsMulticellular => MulticellularCellType != null; - - [JsonIgnore] - public ICellProperties CellTypeProperties - { - get - { - if (MulticellularCellType != null) - { - return MulticellularCellType; - } - - return CastedMicrobeSpecies; - } - } - - [JsonIgnore] - public int HexCount - { - get - { - if (cachedHexCountDirty) - CountHexes(); - - return cachedHexCount; - } - } - - [JsonIgnore] - public float Radius - { - get - { - var radius = Membrane.EncompassingCircleRadius; - - if (CellTypeProperties.IsBacteria) - radius *= 0.5f; - - return radius; - } - } - - [JsonIgnore] - public float RotationSpeed => cachedRotationSpeed ??= - MicrobeInternalCalculations.CalculateRotationSpeed(organelles ?? - throw new InvalidOperationException("Organelles not initialized yet")); - - [JsonIgnore] - public float MassFromOrganelles => organelles?.Sum(o => o.Definition.Mass) ?? - throw new InvalidOperationException("organelles not initialized"); - - [JsonIgnore] - public bool HasSignalingAgent - { - get - { - if (hasSignalingAgent != null) - return hasSignalingAgent.Value; - - return CheckHasSignalingAgent(); - } - } - - [JsonIgnore] - public MicrobeSignalCommand SignalCommand - { - get - { - if (!CheckHasSignalingAgent() || Dead) - return MicrobeSignalCommand.None; - - return command; - } - } - - /// - /// Because AI is ran in parallel thread, if it wants to change the signaling, it needs to do it through this - /// - [JsonProperty] - public MicrobeSignalCommand? QueuedSignalingCommand { get; set; } - - /// - /// Returns a squared value of . - /// - [JsonIgnore] - public float RadiusSquared => Radius * Radius; - - [JsonProperty] - public int DespawnRadiusSquared { get; set; } - - /// - /// Entity weight for microbes counts all organelles with a scaling factor. - /// - [JsonIgnore] - public float EntityWeight - { - get - { - var weight = organelles?.Count * Constants.ORGANELLE_ENTITY_WEIGHT ?? - throw new InvalidOperationException("Organelles not initialised on microbe spawn"); - - if (Colony != null) - { - // Only colony lead cells have the extra entity weight from the colony added - // As the colony reads this property on the other members, we do not throw here - if (Colony.Master == this) - weight += Colony.EntityWeight; - } - - return weight; - } - } - - /// - /// If true this shifts the purpose of this cell for visualizations-only - /// (Completely stops the normal functioning of the cell). - /// - [JsonIgnore] - public bool IsForPreviewOnly { get; set; } - - [JsonIgnore] - public Spatial EntityNode => this; - - [JsonIgnore] - public GeometryInstance EntityGraphics => Membrane; - - [JsonIgnore] - public int RenderPriority - { - get => renderPriority; - set - { - renderPriority = value; - - if (onReadyCalled) - ApplyRenderPriority(); - } - } - - [JsonIgnore] - public List ActiveProcesses - { - get - { - if (processesDirty) - RefreshProcesses(); - return processes; - } - } - - [JsonIgnore] - public Dictionary Enzymes - { - get - { - if (enzymesDirty) - RefreshEnzymes(); - return enzymes; - } - } - - /// - /// Process running statistics for this cell. For now only computed for the player cell - /// - [JsonIgnore] - public ProcessStatistics? ProcessStatistics { get; private set; } - - /// - /// For checking if the player is in freebuild mode or not - /// - [JsonProperty] - public GameProperties CurrentGame { get; private set; } = null!; - - /// - /// Needs access to the world for population changes - /// - [JsonIgnore] - public GameWorld GameWorld => CurrentGame.GameWorld; - - [JsonProperty] - public float TimeUntilNextAIUpdate { get; set; } - - public bool IsLoadedFromSave { get; set; } - - protected MicrobeSpecies CastedMicrobeSpecies - { - get - { - if (cachedMicrobeSpecies != null) - return cachedMicrobeSpecies; - - cachedMicrobeSpecies = (MicrobeSpecies)Species; - return cachedMicrobeSpecies; - } - } - - protected EarlyMulticellularSpecies CastedMulticellularSpecies - { - get - { - if (cachedMulticellularSpecies != null) - return cachedMulticellularSpecies; - - cachedMulticellularSpecies = (EarlyMulticellularSpecies)Species; - return cachedMulticellularSpecies; - } - } - - public override void _Ready() - { - if (cloudSystem == null && !IsForPreviewOnly) - throw new InvalidOperationException("Microbe not initialized"); - - if (onReadyCalled) - return; - - Membrane = GetNode("Membrane"); - OrganelleParent = GetNode("OrganelleParent"); - - if (IsForPreviewOnly) - { - // Disable our physics to not cause issues with multiple preview cells bumping into each other - Mode = ModeEnum.Kinematic; - return; - } - - atp = SimulationParameters.Instance.GetCompound("atp"); - glucose = SimulationParameters.Instance.GetCompound("glucose"); - mucilage = SimulationParameters.Instance.GetCompound("mucilage"); - lipase = SimulationParameters.Instance.GetEnzyme("lipase"); - - engulfAudio = GetNode("EngulfAudio"); - bindingAudio = GetNode("BindingAudio"); - movementAudio = GetNode("MovementAudio"); - - cellBurstEffectScene = GD.Load("res://src/microbe_stage/particles/CellBurstEffect.tscn"); - endosomeScene = GD.Load("res://src/microbe_stage/Endosome.tscn"); - - engulfAudio.Positional = movementAudio.Positional = bindingAudio.Positional = !IsPlayerMicrobe; - - // You may notice that there are two separate ways that an audio is played in this class: - // using pre-existing audio node e.g "bindingAudio", "movementAudio" and through method e.g "PlaySoundEffect", - // "PlayNonPositionalSoundEffect". The former is approach best used to play looping sounds with more control - // to the audio player while the latter is more convenient for dynamic and various short one-time sound effects - // in expense of lesser audio player control. - - if (IsPlayerMicrobe) - { - // Creates and activates the audio listener for the player microbe. Positional sound will be - // received by it instead of the main camera. - listener = new Listener(); - AddChild(listener); - listener.MakeCurrent(); - - // Setup tracking running processes - ProcessStatistics = new ProcessStatistics(); - - GD.Print("Player Microbe spawned"); - } - - // pseudopodTarget = GetNode("PseudopodTarget"); - // var pseudopodRange = GetNode("PseudopodRange"); - // pseudopodRangeSphereShape = (SphereShape)pseudopodRange.GetNode("SphereShape").Shape; - - // pseudopodRange.Connect("body_entered", this, nameof(OnBodyEnteredPseudopodRange)); - // pseudopodRange.Connect("body_exited", this, nameof(OnBodyExitedPseudopodRange)); - - // Setup physics callback stuff - ContactsReported = Constants.DEFAULT_STORE_CONTACTS_COUNT; - Connect("body_shape_entered", this, nameof(OnContactBegin)); - Connect("body_shape_exited", this, nameof(OnContactEnd)); - - Mass = Constants.MICROBE_BASE_MASS; - - if (IsLoadedFromSave) - { - if (organelles == null) - throw new JsonException($"Loaded microbe is missing {nameof(organelles)} property"); - - // Fix base reproduction cost if we we were loaded from an older save - if (requiredCompoundsForBaseReproduction.Count < 1) - SetupRequiredBaseReproductionCompounds(); - - // Fix the tree of colonies - if (ColonyChildren != null) - { - foreach (var child in ColonyChildren) - { - AddChild(child); - } - } - - // Need to re-attach our organelles - foreach (var organelle in organelles) - OrganelleParent.AddChild(organelle); - - // Colony children shapes need re-parenting to their master - // The shapes have to be re-parented to their original microbe then to the master again, maybe engine bug - // Also re-add to the collision exception and change the mode to static as it should be - // And add remake mass for colony master - if (Colony != null && this != Colony.Master) - { - ReParentShapes(this, Vector3.Zero); - ReParentShapes(Colony.Master, GetOffsetRelativeToMaster()); - Colony.Master.AddCollisionExceptionWith(this); - AddCollisionExceptionWith(Colony.Master); - Mode = ModeEnum.Static; - Colony.Master.Mass += Mass; - } - - // And recompute storage - RecomputeOrganelleCapacity(); - - // Do species setup that we need on load - SetScaleFromSpecies(); - SetMembraneFromSpecies(); - - // Re-attach engulfed objects - foreach (var engulfed in engulfedObjects) - { - var engulfable = engulfed.Object.Value; - if (engulfable == null) - continue; - - // Some engulfables were already parented to the world, in their case they don't need to be reattached - // here since the world node already does that. - // TODO: find out why some engulfables in engulfedObject are not parented to the engulfer? - if (!engulfable.EntityNode.IsInsideTree()) - AddChild(engulfable.EntityNode); - - if (engulfed.Phagosome.Value != null) - { - // Defer call to avoid a state where EntityGraphics is still null. - // NOTE: My reasoning to why this can happen is due to some IEngulfables implementing - // EntityGraphics in a way that it's initialized on _Ready and the problem occurs probably when - // that IEngulfable is not yet inside the tree. - Kasterisk - Invoke.Instance.Queue(() => engulfable.EntityGraphics.AddChild(engulfed.Phagosome.Value)); - } - } - } - - ApplyRenderPriority(); - - onReadyCalled = true; - } - - public override void _EnterTree() - { - base._EnterTree(); - - if (IsPlayerMicrobe) - CheatManager.OnPlayerDuplicationCheatUsed += OnPlayerDuplicationCheat; - } - - public override void _ExitTree() - { - base._ExitTree(); - - if (IsPlayerMicrobe) - CheatManager.OnPlayerDuplicationCheatUsed -= OnPlayerDuplicationCheat; - } - - /// - /// Must be called when spawned to provide access to the needed systems - /// - public void Init(CompoundCloudSystem cloudSystem, ISpawnSystem spawnSystem, GameProperties currentGame, - bool isPlayer) - { - this.cloudSystem = cloudSystem; - this.spawnSystem = spawnSystem; - CurrentGame = currentGame; - IsPlayerMicrobe = isPlayer; - - if (!isPlayer) - ai = new MicrobeAI(this); - - // Needed for immediately applying the species - _Ready(); - } - - public override void _Process(float delta) - { - if (usesExternalProcess) - { - GD.PrintErr("_Process was called for microbe that uses external processing"); - return; - } - - ProcessEarlyAsync(delta); - ProcessSync(delta); - } - - public override void _PhysicsProcess(float delta) - { - linearAcceleration = (LinearVelocity - lastLinearVelocity) / delta; - - // Movement - if (ColonyParent == null && !IsForPreviewOnly) - { - HandleMovement(delta); - } - else - { - Colony?.Master.AddMovementForce(queuedMovementForce); - } - - lastLinearVelocity = LinearVelocity; - lastLinearAcceleration = linearAcceleration; - } - - public override void _IntegrateForces(PhysicsDirectBodyState physicsState) - { - if (ColonyParent != null) - return; - - // TODO: should movement also be applied here? - - physicsState.Transform = GetNewPhysicsRotation(physicsState.Transform); - - // Reset total sum from previous collisions - collisionForce = 0.0f; - - // Sum impulses from all contact points - for (var i = 0; i < physicsState.GetContactCount(); ++i) - { - // TODO: Godot currently does not provide a convenient way to access a collision impulse, this - // for example is luckily available only in Bullet which makes things a bit easier. Would need - // proper handling for this in the future. - collisionForce += physicsState.GetContactImpulse(i); - } - } - - /// - /// Applies the species for this cell. Called when spawned - /// - public void ApplySpecies(Species species) - { - cachedMicrobeSpecies = null; - cachedMulticellularSpecies = null; - - Species = species; - - if (species is MicrobeSpecies microbeSpecies) - { - // We might as well store this here as we already casted it. This property is not saved to make working - // with earlier saves easier - cachedMicrobeSpecies = microbeSpecies; - } - else if (species is EarlyMulticellularSpecies earlyMulticellularSpecies) - { - // The first cell of a species is the first cell of the multicellular species, others are created with - // ApplyMulticellularNonFirstCellSpecies - MulticellularCellType = earlyMulticellularSpecies.Cells[0].CellType; - - cachedMulticellularSpecies = earlyMulticellularSpecies; - } - else - { - throw new ArgumentException("Microbe can only be a microbe or early multicellular species"); - } - - cachedRotationSpeed = CellTypeProperties.BaseRotationSpeed; - - if (!IsForPreviewOnly) - { - SetupRequiredBaseReproductionCompounds(); - } - - FinishSpeciesSetup(); - } - - /// - /// Gets the actually hit microbe (potentially in a colony) - /// - /// The shape that was hit - /// The actual microbe that was hit or null if the bodyShape was not found - public Microbe? GetMicrobeFromShape(int bodyShape) - { - if (Colony == null) - return this; - - var touchedOwnerId = ShapeFindOwner(bodyShape); - - // Not found - if (touchedOwnerId == uint.MaxValue) - return null; - - return GetColonyMemberWithShapeOwner(touchedOwnerId, Colony); - } - - /// - /// Called from movement organelles to add movement force - /// - public void AddMovementForce(Vector3 force) - { - queuedMovementForce += force; - } - - public void ReportActiveCompoundChemoreceptor(Compound compound, float range, float minAmount, Color colour) - { - activeCompoundDetections.Add((compound, range, minAmount, colour)); - } - - public void ReportActiveSpeciesChemoreceptor(Species species, float range, float minAmount, Color colour) - { - activeSpeciesDetections.Add((species, range, colour)); - } - - public void PlaySoundEffect(string effect, float volume = 1.0f) - { - // TODO: make these sound objects only be loaded once - var sound = GD.Load(effect); - - // Find a player not in use or create a new one if none are available. - var player = otherAudioPlayers.Find(nextPlayer => !nextPlayer.Playing); - - if (player == null) - { - // If we hit the player limit just return and ignore the sound. - if (otherAudioPlayers.Count >= Constants.MAX_CONCURRENT_SOUNDS_PER_ENTITY) - return; - - player = new AudioStreamPlayer3D(); - player.MaxDistance = 100.0f; - player.Bus = "SFX"; - - AddChild(player); - otherAudioPlayers.Add(player); - } - - player.UnitDb = GD.Linear2Db(volume); - player.Stream = sound; - player.Play(); - } - - public void PlayNonPositionalSoundEffect(string effect, float volume = 1.0f) - { - // TODO: make these sound objects only be loaded once - var sound = GD.Load(effect); - - // Find a player not in use or create a new one if none are available. - var player = nonPositionalAudioPlayers.Find(nextPlayer => !nextPlayer.Playing); - - if (player == null) - { - // If we hit the player limit just return and ignore the sound. - if (nonPositionalAudioPlayers.Count >= Constants.MAX_CONCURRENT_SOUNDS_PER_ENTITY) - return; - - player = new AudioStreamPlayer(); - player.Bus = "SFX"; - - AddChild(player); - nonPositionalAudioPlayers.Add(player); - } - - player.VolumeDb = GD.Linear2Db(volume); - player.Stream = sound; - player.Play(); - } - - public void NotifyExternalProcessingIsUsed() - { - if (usesExternalProcess) - return; - - usesExternalProcess = true; - SetProcess(false); - } - - /// - /// Async part of microbe processing - /// - /// Time since the last call - /// - /// - /// TODO: microbe processing needs more refactoring in the individual operation methods to really allow more - /// work to be put in this asynchronous processing method - /// - /// - public void ProcessEarlyAsync(float delta) - { - if (membraneOrganellePositionsAreDirty) - { - // Redo the cell membrane. - SendOrganellePositionsToMembrane(); - - membraneOrganellesWereUpdatedThisFrame = true; - } - else - { - membraneOrganellesWereUpdatedThisFrame = false; - } - - // The code below starting from here is not needed for a display-only cell - if (IsForPreviewOnly) - return; - - // Movement factor is reset here. HandleEngulfing will set the right value - MovementFactor = 1.0f; - queuedMovementForce = new Vector3(0, 0, 0); - - // Reduce agent emission cooldown - AgentEmissionCooldown -= delta; - if (AgentEmissionCooldown < 0) - AgentEmissionCooldown = 0; - - slimeSecretionCooldown -= delta; - if (slimeSecretionCooldown < 0) - slimeSecretionCooldown = 0; - - lastCheckedATPDamage += delta; - - if (!Membrane.Dirty) - { - HandleCompoundAbsorbing(delta); - } - else - { - absorptionSkippedEarly = true; - } - - // Colony members have their movement update before organelle update, - // so that the movement organelles see the direction - // The colony master should be already updated as the movement direction is either set by the player input or - // microbe AI, neither of which will happen concurrently, so this should always get the up to date value - if (Colony != null && Colony.Master != this) - MovementDirection = Colony.Master.MovementDirection; - - // Let organelles do stuff (this for example gets the movement force from flagella) - foreach (var organelle in organelles!.Organelles) - { - organelle.UpdateAsync(delta); - } - - HandleHitpointsRegeneration(delta); - - HandleInvulnerabilityDecay(delta); - - HandleOsmoregulation(delta); - - if (!Membrane.Dirty) - HandleCompoundVenting(delta); - } - - public void ProcessSync(float delta) - { - // Updates the listener if this is the player owned microbe. - if (listener != null) - { - // Listener is directional and since it is a child of the microbe it will have the same forward - // vector as the parent. Since we want sound to come from the side of the screen relative to the - // camera rather than the microbe we need to force the listener to face up every frame. - Transform transform = GlobalTransform; - transform.basis = new Basis(new Vector3(0.0f, 0.0f, -1.0f)); - listener.GlobalTransform = transform; - } - - if (membraneOrganellesWereUpdatedThisFrame && IsForPreviewOnly) - { - if (organelles == null) - throw new InvalidOperationException("Preview microbe was not initialized with organelles list"); - - // Update once for the positioning of external organelles - foreach (var organelle in organelles.Organelles) - { - organelle.UpdateAsync(delta); - organelle.UpdateSync(); - } - } - - // The code below starting from here is not needed for a display-only cell - if (IsForPreviewOnly) - return; - - CheckEngulfShape(); - - // Fire queued agents - if (queuedToxinToEmit != null) - { - EmitToxin(queuedToxinToEmit); - queuedToxinToEmit = null; - } - - HandleSlimeSecretion(delta); - - // If we didn't have our membrane ready yet in the async process we need to do these now - if (absorptionSkippedEarly) - { - HandleCompoundAbsorbing(delta); - HandleCompoundVenting(delta); - absorptionSkippedEarly = false; - } - - HandleFlashing(delta); - - HandleReproduction(delta); - - // Handles engulfing related stuff as well as modifies the movement factor. - // This needs to be done before Update is called on organelles as movement organelles will use MovementFactor. - HandleEngulfing(delta); - - HandleDigestion(delta); - - // Handles binding related stuff - HandleBinding(delta); - HandleUnbinding(); - - // Let organelles do stuff (this for example gets the movement force from flagella) - foreach (var organelle in organelles!.Organelles) - { - organelle.UpdateSync(); - } - - if (QueuedSignalingCommand != null) - { - command = QueuedSignalingCommand.Value; - QueuedSignalingCommand = null; - } - - // Rotation is applied in the physics force callback as that's the place where the body rotation - // can be directly set without problems - - HandleChemoreceptorLines(delta); - - if (Colony != null && Colony.Master == this) - Colony.Process(delta); - - while (lastCheckedATPDamage >= Constants.ATP_DAMAGE_CHECK_INTERVAL) - { - lastCheckedATPDamage -= Constants.ATP_DAMAGE_CHECK_INTERVAL; - ApplyATPDamage(); - } - - Membrane.HealthFraction = Hitpoints / MaxHitpoints; - - if (Hitpoints <= 0 || Dead) - { - HandleDeath(delta); - } - else - { - // As long as the player has been alive they can go to the editor in freebuild - if (OnReproductionStatus != null && CurrentGame.FreeBuild) - { - OnReproductionStatus(this, true); - } - } - } - - public void AIThink(float delta, Random random, MicrobeAICommonData data) - { - if (IsPlayerMicrobe) - throw new InvalidOperationException("AI can't run on the player microbe"); - - if (Dead || IsForPreviewOnly || PhagocytosisStep != PhagocytosisPhase.None) - return; - - try - { - ai!.Think(delta, random, data); - } -#pragma warning disable CA1031 // AI needs to be boxed good - catch (Exception e) -#pragma warning restore CA1031 - { - GD.PrintErr("Microbe AI failure! ", e); - } - } - - /// - /// Returns a list of tuples, representing all possible compound targets. These are not all clouds that the - /// microbe can smell; only the best candidate of each compound type. - /// - /// CompoundCloudSystem to scan - /// - /// A list of tuples. Each tuple contains the type of compound, the color of the line (if any needs to be drawn), - /// and the location where the compound is located. - /// - public List<(Compound Compound, Color Colour, Vector3 Target)> GetDetectedCompounds(CompoundCloudSystem clouds) - { - HashSet<(Compound Compound, float Range, float MinAmount, Color Colour)> collectedUniqueCompoundDetections; - - // Colony lead cell uses all the chemoreceptors in the colony to make them all work - if (Colony != null && Colony.Master == this) - { - collectedUniqueCompoundDetections = - new HashSet<(Compound Compound, float Range, float MinAmount, Color Colour)>(); - - foreach (var colonyMicrobe in Colony.ColonyMembers) - { - collectedUniqueCompoundDetections.UnionWith(colonyMicrobe.activeCompoundDetections); - } - } - else - { - collectedUniqueCompoundDetections = activeCompoundDetections; - } - - var detections = new List<(Compound Compound, Color Colour, Vector3 Target)>(); - var position = GlobalTranslation; - - foreach (var (compound, range, minAmount, colour) in collectedUniqueCompoundDetections) - { - var detectedCompound = clouds.FindCompoundNearPoint(position, compound, range, minAmount); - - if (detectedCompound != null) - { - detections.Add((compound, colour, detectedCompound.Value)); - } - } - - return detections; - } - - /// - /// Returns a list of tuples, representing all possible microbe targets. These are not all the - /// other microbes that the microbe can smell; only the best candidate of each species. - /// - /// MicrobeSystem to scan - /// - /// A list of tuples. Each tuple contains the type of species, the color of the line (if any needs to be drawn), - /// and the location where the microbe is located. - /// - public List<(Microbe Microbe, Color Colour, Vector3 Target)> GetDetectedSpecies(MicrobeSystem microbeSystem) - { - HashSet<(Species Species, float Range, Color Colour)> collectedUniqueSpeciesDetections; - - // Colony lead cell uses all the chemoreceptors in the colony to make them all work - if (Colony != null && Colony.Master == this) - { - collectedUniqueSpeciesDetections = - new HashSet<(Species Species, float Range, Color Colour)>(); - - foreach (var colonyMicrobe in Colony.ColonyMembers) - { - collectedUniqueSpeciesDetections.UnionWith(colonyMicrobe.activeSpeciesDetections); - } - } - else - { - collectedUniqueSpeciesDetections = activeSpeciesDetections; - } - - var detections = new List<(Microbe Microbe, Color Colour, Vector3 Target)>(); - var position = GlobalTranslation; - - foreach (var (species, range, colour) in collectedUniqueSpeciesDetections) - { - var tuple = microbeSystem.FindSpeciesNearPoint(position, species, range); - - if (tuple != null) - { - detections.Add((tuple.Value.Microbe, colour, tuple.Value.Position)); - } - } - - return detections; - } - - /// - /// Tries to find an engulfable entity as close to this microbe as possible. - /// - /// List of all engulfable entities in the world - /// How wide to search around the point - /// The nearest found point for the engulfable entity or null - public Vector3? FindNearestEngulfable(List engulfables, float searchRadius = 200) - { - if (searchRadius < 1) - throw new ArgumentException("searchRadius must be >= 1"); - - // If the microbe cannot absorb, no need for this - if (!CanEngulf) - return null; - - Vector3? nearestPoint = null; - float nearestDistanceSquared = float.MaxValue; - var searchRadiusSquared = searchRadius * searchRadius; - - // Retrieve nearest potential entities - foreach (var entity in engulfables) - { - if (entity.Compounds.Compounds.Count <= 0 || entity.PhagocytosisStep != PhagocytosisPhase.None) - continue; - - var spatial = entity.EntityNode; - - // Skip entities that are out of range - if ((spatial.Translation - Translation).LengthSquared() > searchRadiusSquared) - continue; - - // Skip non-engulfable entities - if (CanEngulfObject(entity) != EngulfCheckResult.Ok) - continue; - - // Skip entities that have no useful compounds - if (!entity.Compounds.Compounds.Any(x => Compounds.IsUseful(x.Key))) - continue; - - var distance = (spatial.Translation - Translation).LengthSquared(); - - if (nearestPoint == null || distance < nearestDistanceSquared) - { - nearestPoint = spatial.Translation; - nearestDistanceSquared = distance; - } - } - - return nearestPoint; - } - - public void OverrideScaleForPreview(float scale) - { - if (!IsForPreviewOnly) - throw new InvalidOperationException("Scale can only be overridden for preview microbes"); - - ApplyScale(new Vector3(scale, scale, scale)); - } - - /// - /// This method calculates the relative rotation and translation this microbe should have to its microbe parent. - /// - /// Visual explanation - /// - /// - /// - /// - /// Storing the old global translation and rotation, re-parenting and then reapplying the stored values is - /// worse than this code because this code utilizes GetVectorTowardsNearestPointOfMembrane. This reduces the - /// visual gap between the microbes in a colony. - /// - /// - /// Returns relative translation and rotation - private (Vector3 Translation, Vector3 Rotation) GetNewRelativeTransform() - { - if (ColonyParent == null) - throw new InvalidOperationException("This microbe doesn't have colony parent set"); - - // Gets the global rotation of the parent - var globalParentRotation = ColonyParent.GlobalTransform.basis.GetEuler(); - - // A vector from the parent to me - var vectorFromParent = GlobalTransform.origin - ColonyParent.GlobalTransform.origin; - - // A vector from me to the parent - var vectorToParent = -vectorFromParent; - - // TODO: using quaternions here instead of assuming that rotating about the up/down axis is right would be nice - // This vector represents the vectorToParent as if I had no rotation. - // This works by rotating vectorToParent by the negative value (therefore Down) of my current rotation - // This is important, because GetVectorTowardsNearestPointOfMembrane only works with non-rotated microbes - var vectorToParentWithoutRotation = vectorToParent.Rotated(Vector3.Down, Rotation.y); - - // This vector represents the vectorFromParent as if the parent had no rotation. - var vectorFromParentWithoutRotation = vectorFromParent.Rotated(Vector3.Down, globalParentRotation.y); - - // Calculates the vector from the center of the parent's membrane towards me with canceled out rotation. - // This gets added to the vector calculated one call before. - var correctedVectorFromParent = ColonyParent.Membrane - .GetVectorTowardsNearestPointOfMembrane(vectorFromParentWithoutRotation.x, - vectorFromParentWithoutRotation.z).Rotated(Vector3.Up, globalParentRotation.y); - - // Calculates the vector from my center to my membrane towards the parent. - // This vector gets rotated back to cancel out the rotation applied two calls above. - // -= to negate the vector, so that the two membrane vectors amplify - correctedVectorFromParent -= Membrane - .GetVectorTowardsNearestPointOfMembrane(vectorToParentWithoutRotation.x, vectorToParentWithoutRotation.z) - .Rotated(Vector3.Up, Rotation.y); - - // Rotated because the rotational scope is different. - var newTranslation = correctedVectorFromParent.Rotated(Vector3.Down, globalParentRotation.y); - - return (newTranslation, Rotation - globalParentRotation); - } - - private void FinishSpeciesSetup() - { - if (CellTypeProperties.Organelles.Count < 1) - throw new ArgumentException("Species with no organelles is not valid"); - - SetScaleFromSpecies(); - - ResetOrganelleLayout(); - - SetMembraneFromSpecies(); - - if (!CanEngulf) - { - // Reset engulf mode if the new membrane doesn't allow it - if (State == MicrobeState.Engulf) - State = MicrobeState.Normal; - } - - SetupMicrobeHitpoints(); - } - - private void SetScaleFromSpecies() - { - var scale = new Vector3(1.0f, 1.0f, 1.0f); - - // Bacteria are 50% the size of other cells - if (CellTypeProperties.IsBacteria) - scale = new Vector3(0.5f, 0.5f, 0.5f); - - ApplyScale(scale); - } - - private void ApplyScale(Vector3 scale) - { - // Scale only the graphics parts to not have physics affected - Membrane.Scale = scale; - OrganelleParent.Scale = scale; - } - - private void ApplyRenderPriority() - { - var material = Membrane.MaterialToEdit; - - if (material != null) - material.RenderPriority = RenderPriority; - } - - private Node GetStageAsParent() - { - if (HostileEngulfer.Value != null) - return HostileEngulfer.Value.GetStageAsParent(); - - if (Colony == null) - return GetParent(); - - // If the colony leader is engulfed, the colony children, when the colony is disbanded, need to access the - // stage through the engulfer. Because at that point the colony leader is already re-parented to the engulfer, - // so its parent is no longer the stage here. - if (Colony.Master.HostileEngulfer.Value != null) - return Colony.Master.HostileEngulfer.Value.GetStageAsParent(); - - return Colony.Master.GetParent(); - } - - private Vector3 DoBaseMovementForce(float delta) - { - var cost = (Constants.BASE_MOVEMENT_ATP_COST * HexCount) * delta; - - var got = Compounds.TakeCompound(atp, cost); - - float force = Constants.CELL_BASE_THRUST; - float appliedFactor = MovementFactor; - if (Colony != null && Colony.Master == this) - { - // Multiplies the movement factor as if the colony has the normal microbe speed - // Then it subtracts movement speed from 100% up to 75%(soft cap), - // using a series that converges to 1 , value = (1/2 + 1/4 + 1/8 +.....) = 1 - 1/2^n - // when specialized cells become a reality the cap could be lowered to encourage cell specialization - appliedFactor *= Colony.ColonyMembers.Count; - var seriesValue = 1 - 1 / (float)Math.Pow(2, Colony.ColonyMembers.Count - 1); - appliedFactor -= (appliedFactor * 0.15f) * seriesValue; - } - - // Halve speed if out of ATP - if (got < cost) - { - // Not enough ATP to move at full speed - force *= 0.5f; - } - - if (slowedBySlime) - force /= Constants.MUCILAGE_IMPEDE_FACTOR; - - if (IsPlayerMicrobe && CheatManager.Speed > 1) - force *= Mass * CheatManager.Speed; - - return Transform.basis.Xform(MovementDirection * force) * appliedFactor * - (CellTypeProperties.MembraneType.MovementFactor - - (CellTypeProperties.MembraneRigidity * Constants.MEMBRANE_RIGIDITY_BASE_MOBILITY_MODIFIER)); - } - - private void ApplyMovementImpulse(Vector3 movement, float delta) - { - if (movement.x == 0.0f && movement.z == 0.0f) - return; - - // Scale movement by delta time (not by framerate). We aren't Fallout 4 - // TODO: it seems that at low framerate (below 20 or so) cells get a speed boost for some reason - ApplyCentralImpulse(movement * delta); - } - - /// - /// Just slerps towards the target point with the amount being defined by the cell rotation speed. - /// For now, eventually we want to use physics forces to turn - /// - private Transform GetNewPhysicsRotation(Transform transform) - { - var target = transform.LookingAt(LookAtPoint, new Vector3(0, 1, 0)); - - float speed = RotationSpeed; - - if (IsPlayerMicrobe && CheatManager.Speed > 1) - speed *= CheatManager.Speed; - - var ownRotation = RotationSpeed; - - if (Colony != null && ColonyParent == null) - { - // Calculate help and extra inertia caused by the colony member cells - if (cachedColonyRotationMultiplier == null) - { - // TODO: move this to MicrobeInternalCalculations once this is needed to be shown in the multicellular - // editor - float colonyInertia = 0.1f; - float colonyRotationHelp = 0; - - foreach (var colonyMember in Colony.ColonyMembers) - { - if (colonyMember == this) - continue; - - var distance = colonyMember.Transform.origin.LengthSquared(); - - if (distance < MathUtils.EPSILON) - continue; - - colonyInertia += distance * colonyMember.MassFromOrganelles * - Constants.CELL_MOMENT_OF_INERTIA_DISTANCE_MULTIPLIER; - - // TODO: should this use the member rotation speed (which is dependent on its size and how many - // cilia there are that far away) or just a count of cilia and the distance - colonyRotationHelp += colonyMember.RotationSpeed * - Constants.CELL_COLONY_MEMBER_ROTATION_FACTOR_MULTIPLIER * Mathf.Sqrt(distance); - } - - var multiplier = colonyRotationHelp / colonyInertia; - - cachedColonyRotationMultiplier = Mathf.Clamp(multiplier, Constants.CELL_COLONY_MIN_ROTATION_MULTIPLIER, - Constants.CELL_COLONY_MAX_ROTATION_MULTIPLIER); - } - - speed *= cachedColonyRotationMultiplier.Value; - - speed = Mathf.Clamp(speed, Constants.CELL_MIN_ROTATION, - Math.Min(ownRotation * Constants.CELL_COLONY_MAX_ROTATION_HELP, Constants.CELL_MAX_ROTATION)); - } - - // Need to manually normalize everything, otherwise the slerp fails - // Delta is not used here as the physics frames occur at a fixed number of times per second - Quat slerped = transform.basis.Quat().Normalized().Slerp(target.basis.Quat().Normalized(), speed); - - return new Transform(new Basis(slerped), transform.origin); - } - - /// - /// Updates the list of processes organelles do - /// - private void RefreshProcesses() - { - processes.Clear(); - - if (organelles == null) - return; - - foreach (var entry in organelles.Organelles) - { - // Duplicate processes need to be combined into a single TweakedProcess - foreach (var process in entry.Definition.RunnableProcesses) - { - bool found = false; - - foreach (var existing in processes) - { - if (existing.Process == process.Process) - { - existing.Rate += process.Rate; - found = true; - break; - } - } - - if (!found) - { - // Because we modify the process, we must duplicate the object for each microbe - processes.Add((TweakedProcess)process.Clone()); - } - } - } - - processesDirty = false; - } - - private void RefreshEnzymes() - { - enzymes.Clear(); - - if (organelles == null) - return; - - // Cells have a minimum of at least one unit of lipase enzyme - enzymes[lipase] = 1; - - foreach (var organelle in organelles.Organelles) - { - foreach (var enzyme in organelle.StoredEnzymes) - { - // Filter out invalid enzyme values - if (enzyme.Value <= 0) - continue; - - enzymes.TryGetValue(enzyme.Key, out int existing); - enzymes[enzyme.Key] = existing + enzyme.Value; - } - } - - enzymesDirty = false; - } - - private void CountHexes() - { - cachedHexCount = 0; - - if (organelles == null) - return; - - foreach (var entry in organelles.Organelles) - { - cachedHexCount += entry.Definition.Hexes.Count; - } - - cachedHexCountDirty = false; - } -} diff --git a/src/microbe_stage/Microbe.tscn b/src/microbe_stage/Microbe.tscn deleted file mode 100644 index f8420bfce87..00000000000 --- a/src/microbe_stage/Microbe.tscn +++ /dev/null @@ -1,81 +0,0 @@ -[gd_scene load_steps=13 format=2] - -[ext_resource path="res://src/microbe_stage/Membrane.tscn" type="PackedScene" id=1] -[ext_resource path="res://assets/textures/FresnelGradient.png" type="Texture" id=2] -[ext_resource path="res://assets/textures/FresnelGradientDamaged.png" type="Texture" id=3] -[ext_resource path="res://shaders/Membrane.shader" type="Shader" id=4] -[ext_resource path="res://src/microbe_stage/Microbe.cs" type="Script" id=5] -[ext_resource path="res://assets/sounds/soundeffects/engulfment.ogg" type="AudioStream" id=6] -[ext_resource path="res://assets/sounds/soundeffects/microbe-movement-ambience.ogg" type="AudioStream" id=7] -[ext_resource path="res://src/general/HybridAudioPlayer.cs" type="Script" id=8] - -[sub_resource type="ShaderMaterial" id=1] -resource_local_to_scene = true -render_priority = 18 -shader = ExtResource( 4 ) -shader_param/wigglyNess = 1.0 -shader_param/movementWigglyNess = 1.0 -shader_param/dissolveValue = null -shader_param/healthFraction = 0.25 -shader_param/tint = Plane( 1, 1, 1, 1 ) -shader_param/albedoTexture = ExtResource( 2 ) -shader_param/damagedTexture = ExtResource( 3 ) - -[sub_resource type="SphereShape" id=7] - -[sub_resource type="SpatialMaterial" id=5] -resource_local_to_scene = true -render_priority = 2 -flags_transparent = true -albedo_color = Color( 0, 1, 0, 0.470588 ) - -[sub_resource type="SphereMesh" id=3] - -[node name="Microbe" type="RigidBody"] -process_priority = 1 -collision_layer = 3 -collision_mask = 3 -contact_monitor = true -can_sleep = false -axis_lock_linear_y = true -axis_lock_angular_x = true -axis_lock_angular_y = true -axis_lock_angular_z = true -linear_damp = 2.46 -script = ExtResource( 5 ) -__meta__ = { -"_editor_description_": "The collision shape is created in code so the warning on this is not serious" -} - -[node name="Membrane" parent="." instance=ExtResource( 1 )] -transform = Transform( 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0 ) -MaterialToEdit = SubResource( 1 ) - -[node name="OrganelleParent" type="Spatial" parent="."] - -[node name="EngulfAudio" type="Spatial" parent="."] -script = ExtResource( 8 ) -Stream = ExtResource( 6 ) -Volume = 0.0 - -[node name="BindingAudio" type="Spatial" parent="."] -script = ExtResource( 8 ) -Stream = ExtResource( 6 ) -Volume = 0.0 - -[node name="MovementAudio" type="Spatial" parent="."] -script = ExtResource( 8 ) -Stream = ExtResource( 7 ) -Volume = 0.0 - -[node name="PseudopodRange" type="Area" parent="."] -visible = false -monitorable = false - -[node name="SphereShape" type="CollisionShape" parent="PseudopodRange"] -shape = SubResource( 7 ) - -[node name="PseudopodTarget" type="MeshInstance" parent="."] -visible = false -material_override = SubResource( 5 ) -mesh = SubResource( 3 ) diff --git a/src/microbe_stage/MicrobeAI.cs b/src/microbe_stage/MicrobeAI.cs index bcf46575cf2..5f282702bb0 100644 --- a/src/microbe_stage/MicrobeAI.cs +++ b/src/microbe_stage/MicrobeAI.cs @@ -1,881 +1 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Godot; -using Newtonsoft.Json; - -/// -/// AI for a single Microbe. This is a separate class to contain all the AI status variables as well as make the -/// Microbe.cs file cleaner as this AI has a lot of code. -/// -/// -/// -/// This is run in a background thread so no state changing or scene spawning methods on Microbe may be called. -/// -/// -/// TODO: this should be updated to have special handling for cell colonies -/// -/// -public class MicrobeAI -{ - private readonly Compound atp; - private readonly Compound glucose; - private readonly Compound iron; - private readonly Compound oxytoxy; - private readonly Compound ammonia; - private readonly Compound phosphates; - - [JsonProperty] - private Microbe microbe; - - [JsonProperty] - private float previousAngle; - - [JsonProperty] - private Vector3 targetPosition = new(0, 0, 0); - - [JsonIgnore] - private EntityReference focusedPrey = new(); - - [JsonIgnore] - private Vector3? lastSmelledCompoundPosition; - - [JsonProperty] - private float pursuitThreshold; - - /// - /// A value between 0.0f and 1.0f, this is the portion of the microbe's atp bar that needs to refill - /// before resuming motion. - /// - [JsonProperty] - private float atpThreshold; - - /// - /// Stores the value of microbe.totalAbsorbedCompound at tick t-1 before it is cleared and updated at tick t. - /// Used for compounds gradient computation. - /// - /// - /// - /// Memory of the previous absorption step is required to compute gradient (which is a variation). - /// Values dictionary rather than single value as they will be combined with variable weights. - /// - /// - [JsonProperty] - private Dictionary previouslyAbsorbedCompounds; - - [JsonIgnore] - private Dictionary compoundsSearchWeights; - - [JsonIgnore] - private float timeSinceSignalSniffing; - - [JsonIgnore] - private EntityReference lastFoundSignalEmitter = new(); - - [JsonIgnore] - private MicrobeSignalCommand receivedCommand = MicrobeSignalCommand.None; - - [JsonProperty] - private bool hasBeenNearPlayer; - - public MicrobeAI(Microbe microbe) - { - this.microbe = microbe ?? throw new ArgumentException("no microbe given", nameof(microbe)); - - atp = SimulationParameters.Instance.GetCompound("atp"); - glucose = SimulationParameters.Instance.GetCompound("glucose"); - iron = SimulationParameters.Instance.GetCompound("iron"); - oxytoxy = SimulationParameters.Instance.GetCompound("oxytoxy"); - ammonia = SimulationParameters.Instance.GetCompound("ammonia"); - phosphates = SimulationParameters.Instance.GetCompound("phosphates"); - - previouslyAbsorbedCompounds = new Dictionary(microbe.TotalAbsorbedCompounds); - compoundsSearchWeights = new Dictionary(); - } - - private float SpeciesAggression => microbe.Species.Behaviour.Aggression * - (receivedCommand == MicrobeSignalCommand.BecomeAggressive ? 1.5f : 1.0f); - - private float SpeciesFear => microbe.Species.Behaviour.Fear * - (receivedCommand == MicrobeSignalCommand.BecomeAggressive ? 0.75f : 1.0f); - - private float SpeciesActivity => microbe.Species.Behaviour.Activity * - (receivedCommand == MicrobeSignalCommand.BecomeAggressive ? 1.25f : 1.0f); - - private float SpeciesFocus => microbe.Species.Behaviour.Focus; - private float SpeciesOpportunism => microbe.Species.Behaviour.Opportunism; - - private bool IsSessile => SpeciesActivity < Constants.MAX_SPECIES_ACTIVITY / 10; - - public void Think(float delta, Random random, MicrobeAICommonData data) - { - // Disable most AI in a colony - if (microbe.ColonyParent != null) - return; - - timeSinceSignalSniffing += delta; - - if (timeSinceSignalSniffing > Constants.MICROBE_AI_SIGNAL_REACT_INTERVAL) - { - timeSinceSignalSniffing = 0; - - if (microbe.HasSignalingAgent) - DetectSignalingAgents(data.AllMicrobes.Where(m => m.Species == microbe.Species)); - } - - var signaler = lastFoundSignalEmitter.Value; - - if (signaler != null) - { - receivedCommand = signaler.SignalCommand; - } - - ChooseActions(random, data, signaler); - - // Store the absorbed compounds for run and rumble - previouslyAbsorbedCompounds.Clear(); - foreach (var compound in microbe.TotalAbsorbedCompounds) - { - previouslyAbsorbedCompounds[compound.Key] = compound.Value; - } - - // We clear here for update, this is why we stored above! - microbe.TotalAbsorbedCompounds.Clear(); - } - - /// - /// Resets AI status when this AI controlled microbe is removed from a colony - /// - public void ResetAI() - { - previousAngle = 0; - targetPosition = Vector3.Zero; - focusedPrey.Value = null; - pursuitThreshold = 0; - microbe.MovementDirection = Vector3.Zero; - microbe.TotalAbsorbedCompounds.Clear(); - } - - private void ChooseActions(Random random, MicrobeAICommonData data, Microbe? signaler) - { - // If nothing is engulfing me right now, see if there's something that might want to hunt me - // TODO: https://github.com/Revolutionary-Games/Thrive/issues/2323 - Vector3? predator = GetNearestPredatorItem(data.AllMicrobes)?.GlobalTransform.origin; - if (predator.HasValue && - DistanceFromMe(predator.Value) < (1500.0 * SpeciesFear / Constants.MAX_SPECIES_FEAR)) - { - FleeFromPredators(random, predator.Value); - return; - } - - // If this microbe is out of ATP, pick an amount of time to rest - if (microbe.Compounds.GetCompoundAmount(atp) < 1.0f) - { - // Keep the maximum at 95% full, as there is flickering when near full - atpThreshold = 0.95f * SpeciesFocus / Constants.MAX_SPECIES_FOCUS; - } - - if (atpThreshold > 0.0f) - { - if (microbe.Compounds.GetCompoundAmount(atp) < microbe.Compounds.GetCapacityForCompound(atp) * atpThreshold - && microbe.Compounds.Any(compound => IsVitalCompound(compound.Key) && compound.Value > 0.0f)) - { - SetMoveSpeed(0.0f); - return; - } - - atpThreshold = 0.0f; - } - - // Follow received commands if we have them - // TODO: tweak the balance between following commands and doing normal behaviours - // TODO: and also probably we want to add some randomness to the positions and speeds based on distance - switch (receivedCommand) - { - case MicrobeSignalCommand.MoveToMe: - if (signaler != null) - { - MoveToLocation(signaler.Translation); - return; - } - - break; - case MicrobeSignalCommand.FollowMe: - if (signaler != null && DistanceFromMe(signaler.Translation) > Constants.AI_FOLLOW_DISTANCE_SQUARED) - { - MoveToLocation(signaler.Translation); - return; - } - - break; - case MicrobeSignalCommand.FleeFromMe: - if (signaler != null && DistanceFromMe(signaler.Translation) < Constants.AI_FLEE_DISTANCE_SQUARED) - { - microbe.State = MicrobeState.Normal; - SetMoveSpeed(Constants.AI_BASE_MOVEMENT); - - // Direction is calculated to be the opposite from where we should flee - targetPosition = microbe.Translation + (microbe.Translation - signaler.Translation); - microbe.LookAtPoint = targetPosition; - SetMoveSpeed(Constants.AI_BASE_MOVEMENT); - return; - } - - break; - } - - // If I'm very far from the player, and I have not been near the player yet, get on stage - if (!hasBeenNearPlayer) - { - var player = data.AllMicrobes.Where(otherMicrobe => otherMicrobe.IsPlayerMicrobe).FirstOrDefault(); - if (player != null) - { - // Only move if we aren't sessile - if (DistanceFromMe(player.GlobalTransform.origin) > Math.Pow(Constants.SPAWN_SECTOR_SIZE, 2) * 0.75f && - !IsSessile) - { - MoveToLocation(player.GlobalTransform.origin); - return; - } - - hasBeenNearPlayer = true; - } - } - - // If there are no threats, look for a chunk to eat - // TODO: still consider engulfing things if we're in a colony that can engulf (has engulfer cells) - if (microbe.CanEngulf) - { - var targetChunk = GetNearestChunkItem(data.AllChunks, data.AllMicrobes, random); - if (targetChunk != null && targetChunk.PhagocytosisStep == PhagocytosisPhase.None) - { - PursueAndConsumeChunks(targetChunk.Translation, random); - return; - } - } - - // If there are no chunks, look for living prey to hunt - var possiblePrey = GetNearestPreyItem(data.AllMicrobes, random); - if (possiblePrey != null && possiblePrey.PhagocytosisStep == PhagocytosisPhase.None) - { - var prey = possiblePrey.GlobalTransform.origin; - - bool engulfPrey = microbe.CanEngulfObject(possiblePrey) == Microbe.EngulfCheckResult.Ok && - DistanceFromMe(possiblePrey.GlobalTransform.origin) < 10.0f * microbe.EngulfSize; - - EngagePrey(prey, random, engulfPrey); - return; - } - - // There is no reason to be engulfing at this stage - microbe.State = MicrobeState.Normal; - - // Otherwise just wander around and look for compounds - if (!IsSessile) - { - SeekCompounds(random, data); - } - else - { - // This organism is sessile, and will not act until the environment changes - SetMoveSpeed(0.0f); - } - } - - private FloatingChunk? GetNearestChunkItem(List allChunks, List allMicrobes, Random random) - { - FloatingChunk? chosenChunk = null; - - // If the microbe cannot absorb, no need for this - // TODO: still consider engulfing things if we're in a colony that can engulf (has engulfer cells) - if (!microbe.CanEngulf) - { - return null; - } - - // Retrieve nearest potential chunk - foreach (var chunk in allChunks) - { - if (chunk.Compounds.Compounds.Count <= 0) - continue; - - if (microbe.EngulfSize > chunk.EngulfSize * Constants.ENGULF_SIZE_RATIO_REQ - && (chunk.Translation - microbe.Translation).LengthSquared() - <= (20000.0 * SpeciesFocus / Constants.MAX_SPECIES_FOCUS) + 1500.0 - && chunk.PhagocytosisStep == PhagocytosisPhase.None) - { - if (chunk.Compounds.Compounds.Any(x => microbe.Compounds.IsUseful(x.Key) && x.Key.Digestible)) - { - if (chosenChunk == null || - (chosenChunk.Translation - microbe.Translation).LengthSquared() > - (chunk.Translation - microbe.Translation).LengthSquared()) - { - chosenChunk = chunk; - } - } - } - } - - // Don't bother with chunks when there's a lot of microbes to compete with - if (chosenChunk != null) - { - var rivals = 0; - var distanceToChunk = (microbe.Translation - chosenChunk.Translation).LengthSquared(); - foreach (var rival in allMicrobes) - { - if (rival != microbe) - { - var rivalDistance = (rival.GlobalTransform.origin - chosenChunk.Translation).LengthSquared(); - if (rivalDistance < 500.0f && - rivalDistance < distanceToChunk) - { - rivals++; - } - } - } - - int rivalThreshold; - if (SpeciesOpportunism < Constants.MAX_SPECIES_OPPORTUNISM / 3) - { - rivalThreshold = 1; - } - else if (SpeciesOpportunism < Constants.MAX_SPECIES_OPPORTUNISM * 2 / 3) - { - rivalThreshold = 3; - } - else - { - rivalThreshold = 5; - } - - // In rare instances, microbes will choose to be much more ambitious - if (RollCheck(SpeciesFocus, Constants.MAX_SPECIES_FOCUS, random)) - { - rivalThreshold *= 2; - } - - if (rivals > rivalThreshold) - { - chosenChunk = null; - } - } - - return chosenChunk; - } - - /// - /// Gets the nearest prey item. And builds the prey list - /// - /// The nearest prey item. - /// All microbes. - /// Randomness source - private Microbe? GetNearestPreyItem(List allMicrobes, Random random) - { - var focused = focusedPrey.Value; - if (focused != null) - { - var distanceToFocusedPrey = DistanceFromMe(focused.GlobalTransform.origin); - if (!focused.Dead && focused.PhagocytosisStep == PhagocytosisPhase.None && distanceToFocusedPrey < - (3500.0f * SpeciesFocus / Constants.MAX_SPECIES_FOCUS)) - { - if (distanceToFocusedPrey < pursuitThreshold) - { - // Keep chasing, but expect to keep getting closer - LowerPursuitThreshold(); - return focused; - } - - // If prey hasn't gotten closer by now, it's probably too fast, or juking you - // Remember who focused prey is, so that you don't fall for this again - return null; - } - - focusedPrey.Value = null; - } - - Microbe? chosenPrey = null; - - foreach (var otherMicrobe in allMicrobes) - { - if (!otherMicrobe.Dead && otherMicrobe.PhagocytosisStep == PhagocytosisPhase.None) - { - if (DistanceFromMe(otherMicrobe.GlobalTransform.origin) < - (2500.0f * SpeciesAggression / Constants.MAX_SPECIES_AGGRESSION) - && CanTryToEatMicrobe(otherMicrobe, random)) - { - if (chosenPrey == null || - (chosenPrey.GlobalTransform.origin - microbe.Translation).LengthSquared() > - (otherMicrobe.GlobalTransform.origin - microbe.Translation).LengthSquared()) - { - chosenPrey = otherMicrobe; - } - } - } - } - - focusedPrey.Value = chosenPrey; - - if (chosenPrey != null) - { - pursuitThreshold = DistanceFromMe(chosenPrey.GlobalTransform.origin) * 3.0f; - } - - return chosenPrey; - } - - /// - /// Building the predator list and setting the scariest one to be predator - /// - /// All microbes. - private Microbe? GetNearestPredatorItem(List allMicrobes) - { - var fleeThreshold = 3.0f - (2 * - (SpeciesFear / Constants.MAX_SPECIES_FEAR) * - (10 - (9 * microbe.Hitpoints / microbe.MaxHitpoints))); - Microbe? predator = null; - foreach (var otherMicrobe in allMicrobes) - { - if (otherMicrobe == microbe) - continue; - - // Based on species fear, threshold to be afraid ranges from 0.8 to 1.8 microbe size. - if (otherMicrobe.Species != microbe.Species - && !otherMicrobe.Dead && otherMicrobe.PhagocytosisStep == PhagocytosisPhase.None - && otherMicrobe.EngulfSize > microbe.EngulfSize * fleeThreshold) - { - if (predator == null || DistanceFromMe(predator.GlobalTransform.origin) > - DistanceFromMe(otherMicrobe.GlobalTransform.origin)) - { - predator = otherMicrobe; - } - } - } - - return predator; - } - - private void PursueAndConsumeChunks(Vector3 chunk, Random random) - { - // This is a slight offset of where the chunk is, to avoid a forward-facing part blocking it - targetPosition = chunk + new Vector3(0.5f, 0.0f, 0.5f); - microbe.LookAtPoint = targetPosition; - SetEngulfIfClose(); - - // Just in case something is obstructing chunk engulfing, wiggle a little sometimes - if (random.NextDouble() < 0.05) - { - MoveWithRandomTurn(0.1f, 0.2f, random); - } - - // If this Microbe is right on top of the chunk, stop instead of spinning - if (DistanceFromMe(chunk) < Constants.AI_ENGULF_STOP_DISTANCE) - { - SetMoveSpeed(0.0f); - } - else - { - SetMoveSpeed(Constants.AI_BASE_MOVEMENT); - } - } - - private void FleeFromPredators(Random random, Vector3 predatorLocation) - { - microbe.State = MicrobeState.Normal; - - targetPosition = (2 * (microbe.Translation - predatorLocation)) + microbe.Translation; - - microbe.LookAtPoint = targetPosition; - - if (DistanceFromMe(predatorLocation) < 100.0f) - { - if (microbe.SlimeJets.Count > 0 && RollCheck(SpeciesFear, Constants.MAX_SPECIES_FEAR, random)) - { - // There's a chance to jet away if we can - SecreteSlime(random); - } - else if (RollCheck(SpeciesAggression, Constants.MAX_SPECIES_AGGRESSION, random)) - { - // If the predator is right on top of us there's a chance to try and swing with a pilus - MoveWithRandomTurn(2.5f, 3.0f, random); - } - } - - // If prey is confident enough, it will try and launch toxin at the predator - if (SpeciesAggression > SpeciesFear && - DistanceFromMe(predatorLocation) > - 300.0f - (5.0f * SpeciesAggression) + (6.0f * SpeciesFear) && - RollCheck(SpeciesAggression, Constants.MAX_SPECIES_AGGRESSION, random)) - { - LaunchToxin(predatorLocation); - } - - // No matter what, I want to make sure I'm moving - SetMoveSpeed(Constants.AI_BASE_MOVEMENT); - } - - private void EngagePrey(Vector3 target, Random random, bool engulf) - { - microbe.State = engulf ? MicrobeState.Engulf : MicrobeState.Normal; - targetPosition = target; - microbe.LookAtPoint = targetPosition; - if (CanShootToxin()) - { - LaunchToxin(target); - - if (RollCheck(SpeciesAggression, Constants.MAX_SPECIES_AGGRESSION / 5, random)) - { - SetMoveSpeed(Constants.AI_BASE_MOVEMENT); - } - } - else - { - SetMoveSpeed(Constants.AI_BASE_MOVEMENT); - } - - // Predators can use slime jets as an ambush mechanism - if (RollCheck(SpeciesAggression, Constants.MAX_SPECIES_AGGRESSION, random)) - { - SetMoveSpeed(Constants.AI_BASE_MOVEMENT); - SecreteSlime(random); - } - } - - private void SeekCompounds(Random random, MicrobeAICommonData data) - { - // More active species just try to get distance to avoid over-clustering - if (RollCheck(SpeciesActivity, Constants.MAX_SPECIES_ACTIVITY + (Constants.MAX_SPECIES_ACTIVITY / 2), random)) - { - SetMoveSpeed(Constants.AI_BASE_MOVEMENT); - return; - } - - if (random.Next(Constants.AI_STEPS_PER_SMELL) == 0) - { - SmellForCompounds(data); - } - - // If the AI has smelled a compound (currently only possible with a chemoreceptor), go towards it. - if (lastSmelledCompoundPosition != null) - { - var distance = DistanceFromMe(lastSmelledCompoundPosition.Value); - - // If the compound isn't getting closer, either something else has taken it, or we're stuck - LowerPursuitThreshold(); - if (distance > pursuitThreshold) - { - lastSmelledCompoundPosition = null; - RunAndTumble(random); - return; - } - - if (distance > 3.0f) - { - targetPosition = lastSmelledCompoundPosition.Value; - microbe.LookAtPoint = targetPosition; - } - else - { - SetMoveSpeed(0.0f); - SmellForCompounds(data); - } - } - else - { - RunAndTumble(random); - } - } - - private void SmellForCompounds(MicrobeAICommonData data) - { - ComputeCompoundsSearchWeights(); - - var detections = microbe.GetDetectedCompounds(data.Clouds) - .OrderBy(detection => compoundsSearchWeights.TryGetValue(detection.Compound, out var weight) ? - weight : - 0).ToList(); - - if (detections.Count > 0) - { - lastSmelledCompoundPosition = detections[0].Target; - pursuitThreshold = DistanceFromMe(lastSmelledCompoundPosition.Value) - * (1 + (SpeciesFocus / Constants.MAX_SPECIES_FOCUS)); - } - else - { - lastSmelledCompoundPosition = null; - } - } - - // For doing run and tumble - /// - /// For doing run and tumble - /// - /// Random values to use - private void RunAndTumble(Random random) - { - // If this microbe is currently stationary, just initialize by moving in a random direction. - // Used to get newly spawned microbes to move. - if (microbe.MovementDirection.Length() == 0) - { - MoveWithRandomTurn(0, Mathf.Pi, random); - return; - } - - // Run and tumble - // A biased random walk, they turn more if they are picking up less compounds. - // The scientifically accurate algorithm has been flipped to account for the compound - // deposits being a lot smaller compared to the microbes - // https://www.mit.edu/~kardar/teaching/projects/chemotaxis(AndreaSchmidt)/home.htm - - ComputeCompoundsSearchWeights(); - - float gradientValue = 0.0f; - foreach (var compoundWeight in compoundsSearchWeights) - { - // Note this is about absorbed quantities (which is all microbe has access to) not the ones in the clouds. - // Gradient computation is therefore cell-centered, and might be different for different cells. - float compoundDifference = 0.0f; - - microbe.TotalAbsorbedCompounds.TryGetValue(compoundWeight.Key, out float quantityAbsorbedThisStep); - previouslyAbsorbedCompounds.TryGetValue(compoundWeight.Key, out float quantityAbsorbedPreviousStep); - - compoundDifference += quantityAbsorbedThisStep - quantityAbsorbedPreviousStep; - - compoundDifference *= compoundWeight.Value; - gradientValue += compoundDifference; - } - - // Implement a detection threshold to possibly rule out too tiny variations - // TODO: possibly include cell capacity correction - float differenceDetectionThreshold = Constants.AI_GRADIENT_DETECTION_THRESHOLD; - - // If food density is going down, back up and see if there's some more - if (gradientValue < -differenceDetectionThreshold && random.Next(0, 10) < 9) - { - MoveWithRandomTurn(2.5f, 3.0f, random); - } - - // If there isn't any food here, it's a good idea to keep moving - if (Math.Abs(gradientValue) <= differenceDetectionThreshold && random.Next(0, 10) < 5) - { - MoveWithRandomTurn(0.0f, 0.4f, random); - } - - // If positive last step you gained compounds, so let's move toward the source - if (gradientValue > differenceDetectionThreshold) - { - // There's a decent chance to turn by 90° to explore gradient - // 180° is useless since previous position let you absorb less compounds already - if (random.Next(0, 10) < 4) - { - MoveWithRandomTurn(0.0f, 1.5f, random); - } - } - } - - /// - /// Prioritizing compounds that are stored in lesser quantities. - /// If ATP-producing compounds are low (less than half storage capacities), - /// non ATP-related compounds are discarded. - /// Updates compoundsSearchWeights instance dictionary. - /// - private void ComputeCompoundsSearchWeights() - { - IEnumerable usefulCompounds = microbe.Compounds.Compounds.Keys; - - // If this microbe lacks vital compounds don't bother with ammonia and phosphate - if (usefulCompounds.Any(c => IsVitalCompound(c) && microbe.Compounds.GetCompoundAmount(c) < 0.5f - * microbe.Compounds.GetCapacityForCompound(c))) - { - usefulCompounds = usefulCompounds.Where(x => x != ammonia && x != phosphates); - } - - compoundsSearchWeights.Clear(); - foreach (var compound in usefulCompounds) - { - // The priority of a compound is inversely proportional to its availability - // Should be tweaked with consumption - var compoundPriority = 1 - microbe.Compounds.GetCompoundAmount(compound) / - microbe.Compounds.GetCapacityForCompound(compound); - - compoundsSearchWeights.Add(compound, compoundPriority); - } - } - - /// - /// Tells if a compound is vital to this microbe. - /// Vital compounds are *direct* ATP producers - /// - /// - /// - /// TODO: what is used here is a shortcut linked to the current game state: such compounds could be used for - /// other processes in future versions - /// - /// - private bool IsVitalCompound(Compound compound) - { - // TODO: looking for mucilage should be prevented - return microbe.Compounds.IsUseful(compound) && - (compound == glucose || compound == iron); - } - - private void SetEngulfIfClose() - { - // Turn on engulf mode if close - // Sometimes "close" is hard to discern since microbes can range from straight lines to circles - if ((microbe.Translation - targetPosition).LengthSquared() <= microbe.EngulfSize * 2.0f) - { - microbe.State = MicrobeState.Engulf; - } - else - { - microbe.State = MicrobeState.Normal; - } - } - - private void LaunchToxin(Vector3 target) - { - if (microbe.Hitpoints > 0 && microbe.AgentVacuoleCount > 0 && - (microbe.Translation - target).LengthSquared() <= SpeciesFocus * 10.0f) - { - if (CanShootToxin()) - { - microbe.LookAtPoint = target; - - // Hold fire until the target is lined up. - if (microbe.FacingDirection().Normalized().AngleTo(microbe.LookAtPoint.Normalized()) < - 0.1f + SpeciesActivity / (Constants.AI_BASE_TOXIN_SHOOT_ANGLE_PRECISION * SpeciesFocus)) - { - microbe.QueueEmitToxin(oxytoxy); - } - } - } - } - - private void SecreteSlime(Random random) - { - if (microbe.Hitpoints > 0 && microbe.SlimeJets.Count > 0) - { - // Randomise the time spent ejecting slime, from 0 to 3 seconds - microbe.QueueSecreteSlime(3 * random.NextFloat()); - } - } - - private void MoveWithRandomTurn(float minTurn, float maxTurn, Random random) - { - var turn = random.Next(minTurn, maxTurn); - if (random.Next(2) == 1) - { - turn = -turn; - } - - var randDist = random.Next(SpeciesActivity, Constants.MAX_SPECIES_ACTIVITY); - targetPosition = microbe.Translation - + new Vector3(Mathf.Cos(previousAngle + turn) * randDist, - 0, - Mathf.Sin(previousAngle + turn) * randDist); - previousAngle += turn; - microbe.LookAtPoint = targetPosition; - SetMoveSpeed(Constants.AI_BASE_MOVEMENT); - } - - private void MoveToLocation(Vector3 location) - { - microbe.State = MicrobeState.Normal; - targetPosition = location; - microbe.LookAtPoint = targetPosition; - SetMoveSpeed(Constants.AI_BASE_MOVEMENT); - } - - private void DetectSignalingAgents(IEnumerable ownSpeciesMicrobes) - { - // We kind of simulate how strong the "smell" of a signal is by finding the closest active signal - float? closestSignalSquared = null; - Microbe? selectedMicrobe = null; - - var previous = lastFoundSignalEmitter.Value; - - if (previous != null && previous.SignalCommand != MicrobeSignalCommand.None) - { - selectedMicrobe = previous; - closestSignalSquared = DistanceFromMe(previous.Translation); - } - - foreach (var speciesMicrobe in ownSpeciesMicrobes) - { - if (speciesMicrobe.SignalCommand == MicrobeSignalCommand.None) - continue; - - // Don't detect your own signals - if (speciesMicrobe == microbe) - continue; - - var distance = DistanceFromMe(speciesMicrobe.Translation); - - if (closestSignalSquared == null || distance < closestSignalSquared.Value) - { - selectedMicrobe = speciesMicrobe; - closestSignalSquared = distance; - } - } - - // TODO: should there be a max distance after which the signaling agent is considered to be so weak that it - // is not detected? - - lastFoundSignalEmitter.Value = selectedMicrobe; - } - - private void SetMoveSpeed(float speed) - { - microbe.MovementDirection = new Vector3(0, 0, -speed); - } - - private void LowerPursuitThreshold() - { - pursuitThreshold *= 0.95f; - } - - private bool CanTryToEatMicrobe(Microbe targetMicrobe, Random random) - { - var sizeRatio = microbe.EngulfSize / targetMicrobe.EngulfSize; - - // sometimes the AI will randomly decide to try in vain to eat something - var choosingToEngulf = microbe.CanDigestObject(targetMicrobe) == Microbe.DigestCheckResult.Ok || - random.NextDouble() < - Constants.AI_BAD_ENGULF_CHANCE * SpeciesOpportunism / Constants.MAX_SPECIES_OPPORTUNISM; - - var choosingToAttackWithToxin = SpeciesOpportunism - > Constants.MAX_SPECIES_OPPORTUNISM * 0.3f && CanShootToxin(); - - return choosingToEngulf && - targetMicrobe.Species != microbe.Species && ( - choosingToAttackWithToxin - || (sizeRatio >= Constants.ENGULF_SIZE_RATIO_REQ)); - } - - private bool CanShootToxin() - { - return microbe.Compounds.GetCompoundAmount(oxytoxy) >= - Constants.MAXIMUM_AGENT_EMISSION_AMOUNT * SpeciesFocus / Constants.MAX_SPECIES_FOCUS; - } - - private float DistanceFromMe(Vector3 target) - { - return (target - microbe.Translation).LengthSquared(); - } - - private bool RollCheck(float ourStat, float dc, Random random) - { - return random.Next(0.0f, dc) <= ourStat; - } - - private bool RollReverseCheck(float ourStat, float dc, Random random) - { - return ourStat <= random.Next(0.0f, dc); - } - - private void DebugFlash() - { - microbe.Flash(1.0f, new Color(255.0f, 0.0f, 0.0f)); - } -} + \ No newline at end of file diff --git a/src/microbe_stage/MicrobeAICommonData.cs b/src/microbe_stage/MicrobeAICommonData.cs deleted file mode 100644 index bfa1ef07210..00000000000 --- a/src/microbe_stage/MicrobeAICommonData.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Collections.Generic; - -/// -/// Common MicrobeAI data shared by each instance. THIS MAY NOT BE MODIFIED OUTSIDE MicrobeAISystem! -/// -public class MicrobeAICommonData -{ - public MicrobeAICommonData(List allMicrobes, List allChunks, CompoundCloudSystem clouds) - { - AllMicrobes = allMicrobes; - AllChunks = allChunks; - Clouds = clouds; - } - - public List AllMicrobes { get; } - public List AllChunks { get; } - public CompoundCloudSystem Clouds { get; } -} diff --git a/src/microbe_stage/MicrobeAISystem.cs b/src/microbe_stage/MicrobeAISystem.cs deleted file mode 100644 index c9cbd9c2a9d..00000000000 --- a/src/microbe_stage/MicrobeAISystem.cs +++ /dev/null @@ -1,96 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Godot; - -public class MicrobeAISystem -{ - private readonly List tasks = new(); - - private readonly Node worldRoot; - - /// - /// Because this is run in a threaded environment (and because this is the AI), this should - /// NEVER call a data changing method from this class - /// - private readonly CompoundCloudSystem clouds; - - public MicrobeAISystem(Node worldRoot, CompoundCloudSystem cloudSystem) - { - this.worldRoot = worldRoot; - clouds = cloudSystem; - } - - public void Process(float delta, Random? random = null) - { - if (CheatManager.NoAI) - return; - - var nodes = worldRoot.GetChildrenToProcess(Constants.AI_GROUP).ToList(); - - // TODO: it would be nice to only rebuild these lists if some AI think interval has elapsed and these are needed - var allMicrobes = worldRoot.GetTree().GetNodesInGroup(Constants.AI_TAG_MICROBE); - var allChunks = worldRoot.GetChildrenToProcess(Constants.AI_TAG_CHUNK); - - var data = new MicrobeAICommonData(allMicrobes.Cast().ToList(), - allChunks.ToList(), clouds); - - // The objects are processed here in order to take advantage of threading - var executor = TaskExecutor.Instance; - - random ??= new Random(); - - for (int i = 0; i < nodes.Count; i += Constants.MICROBE_AI_OBJECTS_PER_TASK) - { - int start = i; - int seed = random.Next(); - - var task = new Task(() => - { - var tasksRandom = new Random(seed); - for (int a = start; - a < start + Constants.MICROBE_AI_OBJECTS_PER_TASK && a < nodes.Count; - ++a) - { - RunAIFor(nodes[a], delta, tasksRandom, data); - } - }); - - tasks.Add(task); - } - - // Start and wait for tasks to finish - executor.RunTasks(tasks); - tasks.Clear(); - } - - /// - /// Main AI think function for cells - /// - /// The thing with AI interface implemented - /// Passed time - /// Randomness source - /// Common data for AI agents, should not be modified - private void RunAIFor(IMicrobeAI? ai, float delta, Random random, MicrobeAICommonData data) - { - if (ai == null) - { - GD.PrintErr("A node has been put in the ai group but it isn't derived from IMicrobeAI"); - return; - } - - // Limit how often the AI is run - ai.TimeUntilNextAIUpdate -= delta; - - if (ai.TimeUntilNextAIUpdate > 0) - return; - - // TODO: would be nice to add a tiny bit of randomness to the times here so that not all cells think at once - ai.TimeUntilNextAIUpdate = Constants.MICROBE_AI_THINK_INTERVAL; - - // As the AI think interval is made constant, we pass that value as the delta to make time keeping be actually - // (mostly) consistent in the AI code - ai.AIThink(Constants.MICROBE_AI_THINK_INTERVAL, random, data); - } -} diff --git a/src/microbe_stage/MicrobeCamera.cs b/src/microbe_stage/MicrobeCamera.cs index b25599d99bd..fad68aa3dde 100644 --- a/src/microbe_stage/MicrobeCamera.cs +++ b/src/microbe_stage/MicrobeCamera.cs @@ -5,13 +5,8 @@ /// /// Camera script for the microbe stage and the cell editor /// -public class MicrobeCamera : Camera, IGodotEarlyNodeResolve, ISaveLoadedTracked +public class MicrobeCamera : Camera, IGodotEarlyNodeResolve, ISaveLoadedTracked, IGameCamera { - /// - /// Object the camera positions itself over - /// - public Spatial? ObjectToFollow; - /// /// How fast the camera zooming is /// @@ -48,6 +43,13 @@ public class MicrobeCamera : Camera, IGodotEarlyNodeResolve, ISaveLoadedTracked [JsonProperty] public float InterpolateZoomSpeed = 0.3f; + /// + /// Now required with native physics to ensure that there's no occasional hitching with the camera + /// + [Export] + [JsonProperty] + public float SnapWithDistanceLessThan = 7.0f; + /// /// How many units of light level can change per second /// @@ -190,22 +192,31 @@ public override void _Process(float delta) } } - /// - /// Updates camera position to follow the object - /// - public override void _PhysicsProcess(float delta) + public void UpdateCameraPosition(float delta, Vector3? followedObject) { var currentFloorPosition = new Vector3(Translation.x, 0, Translation.z); var currentCameraHeight = new Vector3(0, Translation.y, 0); var newCameraHeight = new Vector3(0, CameraHeight, 0); - if (ObjectToFollow != null) + if (followedObject != null) { var newFloorPosition = new Vector3( - ObjectToFollow.GlobalTransform.origin.x, 0, ObjectToFollow.GlobalTransform.origin.z); - - var target = currentFloorPosition.LinearInterpolate(newFloorPosition, InterpolateSpeed) - + currentCameraHeight.LinearInterpolate(newCameraHeight, InterpolateZoomSpeed); + followedObject.Value.x, 0, followedObject.Value.z); + + Vector3 target; + + if (currentFloorPosition.DistanceTo(newFloorPosition) < SnapWithDistanceLessThan) + { + // Don't interpolate floor position, this stops every few seconds slight hitching happening visually + // with the player movement using the new physics (even when multiplying InterpolateSpeed with delta) + target = newFloorPosition + + currentCameraHeight.LinearInterpolate(newCameraHeight, InterpolateZoomSpeed); + } + else + { + target = currentFloorPosition.LinearInterpolate(newFloorPosition, InterpolateSpeed) + + currentCameraHeight.LinearInterpolate(newCameraHeight, InterpolateZoomSpeed); + } Translation = target; } diff --git a/src/microbe_stage/MicrobeColony.cs b/src/microbe_stage/MicrobeColony.cs deleted file mode 100644 index b10f27de24f..00000000000 --- a/src/microbe_stage/MicrobeColony.cs +++ /dev/null @@ -1,238 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Godot; -using Newtonsoft.Json; - -[JsonObject(IsReference = true)] -[UseThriveSerializer] -public class MicrobeColony -{ - private MicrobeState state; - - private bool membersDirty = true; - private float hexCount; - private bool canEngulf; - private float entityWeight; - - [JsonConstructor] - private MicrobeColony(Microbe master) - { - Master = master; - master.ColonyChildren = new List(); - ColonyMembers = new List { master }; - ColonyCompounds = new ColonyCompoundBag(this); - - // Grab initial state from microbe to preserve that (only really important for multicellular) - state = master.State; - } - - /// - /// The colony lead cell. Needs to be before for JSON deserialization to work - /// - [JsonProperty] - public Microbe Master { get; private set; } - - /// - /// Returns all members of this colony including the colony leader. - /// - [JsonProperty] - public List ColonyMembers { get; private set; } - - [JsonProperty] - public ColonyCompoundBag ColonyCompounds { get; private set; } - - [JsonProperty] - public MicrobeState State - { - get => state; - set - { - if (state == value) - return; - - state = value; - foreach (var cell in ColonyMembers) - cell.State = value; - } - } - - /// - /// The total hex count from all members of this colony. - /// - [JsonIgnore] - public float HexCount - { - get - { - if (membersDirty) - UpdateDerivedProperties(); - return hexCount; - } - } - - /// - /// Whether one or more member of this colony is allowed to enter engulf mode. - /// - [JsonIgnore] - public bool CanEngulf - { - get - { - if (membersDirty) - UpdateDerivedProperties(); - return canEngulf; - } - } - - /// - /// Total entity weight of the colony. Colony member weights are modified with a multiplier to end up with - /// this number; - /// - /// - /// - /// Note that this doesn't include the weight as the intended use for this property is - /// to be read through where this is added on top for the colony lead cell. - /// - /// - [JsonIgnore] - public float EntityWeight - { - get - { - if (membersDirty) - UpdateDerivedProperties(); - return entityWeight; - } - } - - /// - /// The accumulation of all the colony member's . - /// - /// - /// - /// This unfortunately is not cached as can change - /// every frame. - /// - /// - [JsonIgnore] - public float UsedIngestionCapacity => ColonyMembers.Sum(c => c.UsedIngestionCapacity); - - /// - /// Creates a colony for a microbe, with the given microbe as the master, - /// and handles related updates (like microbe's colony and access to the editor button). - /// - /// - /// - /// Should be used instead of the colony constructor, unless for loading from Json. - /// - /// - public static void CreateColonyForMicrobe(Microbe microbe) - { - microbe.Colony = new MicrobeColony(microbe); - microbe.OnColonyMemberAdded(microbe); - } - - public void Process(float delta) - { - _ = delta; - - ColonyCompounds.DistributeCompoundSurplus(); - } - - public void RemoveFromColony(Microbe? microbe) - { - if (microbe?.Colony == null) - throw new ArgumentException("Microbe null or not a member of a colony"); - - if (!Equals(microbe.Colony, this)) - throw new ArgumentException("Cannot remove a colony member who isn't a member"); - - if (microbe.ColonyChildren == null) - throw new ArgumentException("Invalid microbe with no colony children setup on it"); - - if (State == MicrobeState.Unbinding) - State = MicrobeState.Normal; - - foreach (var colonyMember in ColonyMembers) - colonyMember.OnColonyMemberRemoved(microbe); - - microbe.Colony = null; - - microbe.ReParentShapes(microbe, Vector3.Zero); - - while (microbe.ColonyChildren.Count != 0) - RemoveFromColony(microbe.ColonyChildren[0]); - - ColonyMembers.Remove(microbe); - - microbe.ColonyParent?.ColonyChildren?.Remove(microbe); - - if (microbe.ColonyParent?.Colony != null && microbe.ColonyParent?.ColonyParent == null && - microbe.ColonyParent?.ColonyChildren?.Count == 0) - { - RemoveFromColony(microbe.ColonyParent); - } - - microbe.ColonyParent = null; - microbe.ColonyChildren = null; - if (microbe != Master) - Master.Mass -= microbe.Mass; - - membersDirty = true; - } - - public void AddToColony(Microbe microbe, Microbe master) - { - if (microbe == null || master == null || microbe.Colony != null) - throw new ArgumentException("Microbe or master null or microbe already is in a colony"); - - ColonyMembers.Add(microbe); - Master.Mass += microbe.Mass; - - microbe.ColonyParent = master; - master.ColonyChildren!.Add(microbe); - microbe.Colony = this; - microbe.ColonyChildren = new List(); - - ColonyMembers.ForEach(m => m.OnColonyMemberAdded(microbe)); - - membersDirty = true; - } - - private void UpdateDerivedProperties() - { - UpdateHexCountAndWeight(); - UpdateCanEngulf(); - - membersDirty = false; - } - - private void UpdateHexCountAndWeight() - { - hexCount = 0; - entityWeight = 0; - - foreach (var member in ColonyMembers) - { - hexCount += member.EngulfSize; - - if (member != Master) - entityWeight += member.EntityWeight * Constants.MICROBE_COLONY_MEMBER_ENTITY_WEIGHT_MULTIPLIER; - } - } - - private void UpdateCanEngulf() - { - canEngulf = false; - - foreach (var member in ColonyMembers) - { - if (!member.CanEngulf) - continue; - - canEngulf = true; - break; - } - } -} diff --git a/src/microbe_stage/MicrobeHUD.cs b/src/microbe_stage/MicrobeHUD.cs index bab411af568..9ba52b7547d 100644 --- a/src/microbe_stage/MicrobeHUD.cs +++ b/src/microbe_stage/MicrobeHUD.cs @@ -1,7 +1,8 @@ using System; using System.Collections.Generic; using System.Globalization; -using System.Linq; +using Components; +using DefaultEcs; using Godot; using Newtonsoft.Json; @@ -39,7 +40,7 @@ public class MicrobeHUD : CreatureStageHUDBase private const string FLOATING_CHUNKS_CATEGORY = "chunks"; private const string AGENTS_CATEGORY = "agents"; - private readonly Dictionary<(string Category, string Name), int> hoveredEntities = new(); + private readonly Dictionary<(string Category, LocalizedString Name), int> hoveredEntities = new(); private readonly Dictionary hoveredCompoundControls = new(); private ActionButton bindingModeHotkey = null!; @@ -57,7 +58,7 @@ public class MicrobeHUD : CreatureStageHUDBase /// /// If not null the signaling agent radial menu is open for the given microbe, which should be the player /// - private Microbe? signalingAgentMenuOpenForMicrobe; + private Entity? signalingAgentMenuOpenForMicrobe; private int? playerColonySize; @@ -109,8 +110,8 @@ public override void _Process(float delta) if (stage.HasPlayer) { - UpdateMulticellularButton(stage.Player!); - UpdateMacroscopicButton(stage.Player!); + UpdateMulticellularButton(stage.Player); + UpdateMacroscopicButton(stage.Player); } else { @@ -130,8 +131,14 @@ public override void _Notification(int what) } } - public void ShowSignalingCommandsMenu(Microbe player) + public void ShowSignalingCommandsMenu(Entity player) { + if (!player.Has()) + { + GD.PrintErr("Can't show signaling commands for entity with no signaler component"); + return; + } + if (packControlRadial.Visible) { GD.PrintErr("Radial menu is already open for signaling commands"); @@ -174,9 +181,9 @@ public void ShowSignalingCommandsMenu(Microbe player) /// /// The command to apply /// The target microbe - public void ApplySignalCommand(MicrobeSignalCommand? command, Microbe microbe) + public void ApplySignalCommand(MicrobeSignalCommand? command, Entity microbe) { - microbe.QueuedSignalingCommand = command; + microbe.Get().QueuedSignalingCommand = command; signalingAgentMenuOpenForMicrobe = null; } @@ -200,21 +207,24 @@ public void ToggleWinBox() public override void ShowFossilisationButtons() { - var microbes = GetTree().GetNodesInGroup(Constants.AI_TAG_MICROBE).Cast(); var fossils = FossilisedSpecies.CreateListOfFossils(false); - foreach (var microbe in microbes) + + foreach (var entity in stage!.WorldSimulation.EntitySystem) { - if (microbe.Species is not MicrobeSpecies) + // TODO: buttons to fossilize early multicellular species + if (!entity.Has()) continue; + var species = entity.Get().Species; + var button = FossilisationButtonScene.Instance(); - button.AttachedEntity = microbe; + button.AttachedEntity = entity; button.Connect(nameof(FossilisationButton.OnFossilisationDialogOpened), this, nameof(ShowFossilisationDialog)); // Display a faded button with a different hint if the species has been fossilised. var alreadyFossilised = - FossilisedSpecies.IsSpeciesAlreadyFossilised(microbe.Species.FormattedName, fossils); + FossilisedSpecies.IsSpeciesAlreadyFossilised(species.FormattedName, fossils); button.AlreadyFossilised = alreadyFossilised; button.HintTooltip = alreadyFossilised ? TranslationServer.Translate("FOSSILISATION_HINT_ALREADY_FOSSILISED") : @@ -224,15 +234,21 @@ public override void ShowFossilisationButtons() } } - protected override void ReadPlayerHitpoints(out float hp, out float maxHP) + protected override void ReadPlayerHitpoints(out float hp, out float maxHealth) { - hp = stage!.Player!.Hitpoints; - maxHP = stage.Player.MaxHitpoints; + ref var health = ref stage!.Player.Get(); + + hp = health.CurrentHealth; + maxHealth = health.MaxHealth; } protected override void UpdateHealth(float delta) { - if (stage?.Player != null && stage.Player.PhagocytosisStep != PhagocytosisPhase.Ingested) + if (stage == null) + throw new InvalidOperationException("UpdateHealth called before stage is set"); + + // Normal health update if there is a player and the player was not engulfed + if (stage.HasPlayer && stage.Player.Get().PhagocytosisStep != PhagocytosisPhase.Ingested) { playerWasDigested = false; healthBar.TintProgress = defaultHealthBarColour; @@ -247,12 +263,12 @@ protected override void UpdateHealth(float delta) hp.ToString(CultureInfo.CurrentCulture); // Update to the player's current digested progress, unless the player does not exist - if (stage!.HasPlayer) + if (stage.HasPlayer) { var percentageValue = TranslationServer.Translate("PERCENTAGE_VALUE"); // Show the digestion progress to the player - hp = 1 - (stage.Player!.DigestedAmount / Constants.PARTIALLY_DIGESTED_THRESHOLD); + hp = 1 - (stage.Player.Get().DigestedAmount / Constants.PARTIALLY_DIGESTED_THRESHOLD); maxHP = Constants.FULLY_DIGESTED_LIMIT; hpText = percentageValue.FormatSafe(Mathf.Round((1 - hp) * 100)); playerWasDigested = true; @@ -267,19 +283,26 @@ protected override void UpdateHealth(float delta) protected override CompoundBag? GetPlayerUsefulCompounds() { - return stage!.Player?.Compounds; + if (stage?.HasPlayer != true) + return null; + + if (!stage.Player.Has()) + return null; + + return stage.Player.Get().Compounds; } protected override Func GetIsUsefulCheck() { - var colony = stage!.Player!.Colony; - if (colony == null) + if (!stage!.Player.Has()) { - var compounds = stage.Player.Compounds; + var compounds = stage.Player.Get().Compounds; return compound => compounds.IsUseful(compound); } - return compound => colony.ColonyMembers.Any(c => c.Compounds.IsUseful(compound)); + throw new NotImplementedException(); + + // return compound => colony.ColonyMembers.Any(c => c.Compounds.IsUseful(compound)); } protected override bool SpecialHandleBar(ProgressBar bar) @@ -295,67 +318,92 @@ protected override bool SpecialHandleBar(ProgressBar bar) protected override bool ShouldShowAgentsPanel() { - var colony = stage!.Player!.Colony; - if (colony == null) + if (!stage!.Player.Has()) { return GetPlayerUsefulCompounds()!.AreAnySpecificallySetUseful(allAgents); } - return colony.ColonyMembers.Any( - c => c.Compounds.AreAnySpecificallySetUseful(allAgents)); + throw new NotImplementedException(); + + // return colony.ColonyMembers.Any( + // c => c.Compounds.AreAnySpecificallySetUseful(allAgents)); } protected override ICompoundStorage GetPlayerStorage() { - return stage!.Player!.Colony?.ColonyCompounds ?? (ICompoundStorage)stage.Player.Compounds; + if (!stage!.Player.Has()) + { + return stage.Player.Get().Compounds; + } + + throw new NotImplementedException(); + + // return stage!.Player!.Colony?.ColonyCompounds; } protected override void UpdateCompoundBars(float delta) { base.UpdateCompoundBars(delta); - ingestedMatterBar.MaxValue = stage!.Player!.Colony?.HexCount ?? stage.Player.HexCount; + if (stage!.Player.Has()) + { + // TODO: calculate total engulf size (probably don't need to cache this as only the GUI needs this + // currently) + throw new NotImplementedException(); + } + + ingestedMatterBar.MaxValue = stage.Player.Get().EngulfStorageSize; GUICommon.SmoothlyUpdateBar(ingestedMatterBar, GetPlayerUsedIngestionCapacity(), delta); ingestedMatterBar.GetNode [DeserializedCallbackAllowed] - private void HandlePlayerChemoreception(Microbe microbe, - IEnumerable<(Compound Compound, float Range, float MinAmount, Color Colour)> activeCompoundDetections, - IEnumerable<(Species Species, float Range, Color Colour)> activeSpeciesDetections) + private void HandlePlayerChemoreception(Entity microbe, + List<(Compound Compound, Color Colour, Vector3 Target)>? activeCompoundDetections, + List<(Species Species, Entity Entity, Color Colour, Vector3 Target)>? activeSpeciesDetections) { if (microbe != Player) GD.PrintErr("Chemoreception data reported for non-player cell"); int currentLineIndex = 0; - var position = microbe.GlobalTransform.origin; + var position = microbe.Get().Position; - foreach (var detectedCompound in microbe.GetDetectedCompounds(Clouds)) + if (activeCompoundDetections != null) { - UpdateOrCreateGuidanceLine(currentLineIndex++, - null, detectedCompound.Colour, position, detectedCompound.Target, true); + foreach (var detectedCompound in activeCompoundDetections) + { + UpdateOrCreateGuidanceLine(currentLineIndex++, + default, detectedCompound.Colour, position, detectedCompound.Target, true); + } } - foreach (var detectedSpecies in microbe.GetDetectedSpecies(microbeSystem)) + if (activeSpeciesDetections != null) { - UpdateOrCreateGuidanceLine(currentLineIndex++, - detectedSpecies.Microbe, detectedSpecies.Colour, position, detectedSpecies.Target, true); + foreach (var detectedSpecies in activeSpeciesDetections) + { + UpdateOrCreateGuidanceLine(currentLineIndex++, + detectedSpecies.Entity, detectedSpecies.Colour, position, detectedSpecies.Target, true); + } } // Remove excess lines @@ -915,7 +1058,7 @@ private void OnPlayerNoticeMessage(Microbe player, IHUDMessage message) private void UpdateLinePlayerPosition() { - if (Player == null || Player?.Dead == true) + if (!HasPlayer || Player.Get().Dead) { foreach (var chemoreception in chemoreceptionLines) chemoreception.Line.Visible = false; @@ -923,7 +1066,7 @@ private void UpdateLinePlayerPosition() return; } - var position = Player!.GlobalTranslation; + var position = Player.Get().Position; foreach (var chemoreception in chemoreceptionLines) { @@ -932,24 +1075,16 @@ private void UpdateLinePlayerPosition() chemoreception.Line.LineStart = position; - // The target needs to be updated for detected microbes but not compounds. - if (chemoreception.TargetMicrobe != null) + // The target needs to be updated for entities with a position. + if (chemoreception.TargetEntity.IsAlive && chemoreception.TargetEntity.Has()) { - // Use colony parent position to avoid calling GlobalTranslation - if (chemoreception.TargetMicrobe.Colony != null) - { - chemoreception.Line.LineEnd = chemoreception.TargetMicrobe.Colony.Master.Translation; - } - else - { - chemoreception.Line.LineEnd = chemoreception.TargetMicrobe.Translation; - } + chemoreception.Line.LineEnd = chemoreception.TargetEntity.Get().Position; } } } private void UpdateOrCreateGuidanceLine(int index, - Microbe? targetMicrobe, Color colour, Vector3 lineStart, Vector3 lineEnd, bool visible) + Entity potentialTargetEntity, Color colour, Vector3 lineStart, Vector3 lineEnd, bool visible) { if (index >= chemoreceptionLines.Count) { @@ -958,18 +1093,11 @@ private void UpdateLinePlayerPosition() var line = new GuidanceLine(); AddChild(line); - if (targetMicrobe != null) - { - chemoreceptionLines.Add((line, targetMicrobe)); - } - else - { - chemoreceptionLines.Add((line, null)); - } + chemoreceptionLines.Add((line, potentialTargetEntity)); } else { - chemoreceptionLines[index] = (chemoreceptionLines[index].Line, targetMicrobe); + chemoreceptionLines[index] = (chemoreceptionLines[index].Line, potentialTargetEntity); } chemoreceptionLines[index].Line.Colour = colour; @@ -977,4 +1105,28 @@ private void UpdateLinePlayerPosition() chemoreceptionLines[index].Line.LineEnd = lineEnd; chemoreceptionLines[index].Line.Visible = visible; } + + private bool IsPlayerAlive() + { + if (!HasPlayer) + return false; + + try + { + return !Player.Get().Dead; + } + catch (Exception e) + { + GD.PrintErr("Couldn't read player health: " + e); + return false; + } + } + + private void TranslationsForFeaturesToReimplement() + { + // TODO: reimplement the microbe features that depend on these translations + TranslationServer.Translate("SUCCESSFUL_KILL"); + TranslationServer.Translate("SUCCESSFUL_SCAVENGE"); + TranslationServer.Translate("ESCAPE_ENGULFING"); + } } diff --git a/src/microbe_stage/MicrobeSystem.cs b/src/microbe_stage/MicrobeSystem.cs index 5f43489de72..5f282702bb0 100644 --- a/src/microbe_stage/MicrobeSystem.cs +++ b/src/microbe_stage/MicrobeSystem.cs @@ -1,118 +1 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Godot; - -/// -/// Handles processing s in a multithreaded way -/// -public class MicrobeSystem -{ - private readonly List tasks = new(); - - private readonly Node worldRoot; - - private Microbe[]? microbes; - - public MicrobeSystem(Node worldRoot) - { - this.worldRoot = worldRoot; - } - - public void Process(float delta) - { - microbes = worldRoot.GetTree().GetNodesInGroup(Constants.RUNNABLE_MICROBE_GROUP).Cast() - .ToArray(); - - // Start of async early processing - var executor = TaskExecutor.Instance; - - for (int i = 0; i < microbes.Length; i += Constants.MICROBE_AI_OBJECTS_PER_TASK) - { - int start = i; - - var task = new Task(() => - { - for (int a = start; - a < start + Constants.MICROBE_AI_OBJECTS_PER_TASK && a < microbes.Length; - ++a) - { - var microbe = microbes[a]; - microbe.ProcessEarlyAsync(delta); - } - }); - - tasks.Add(task); - } - - // Start and wait for tasks to finish - executor.RunTasks(tasks); - tasks.Clear(); - - // And then process the synchronous part for all microbes - foreach (var microbe in microbes) - { - microbe.NotifyExternalProcessingIsUsed(); - microbe.ProcessSync(delta); - } - } - - /// - /// Tries to find specified Species as close to the point as possible. - /// - /// Position to search around - /// What species to search for - /// How wide to search around the point - /// The nearest found point for the species or null - public (Microbe Microbe, Vector3 Position)? FindSpeciesNearPoint(Vector3 position, Species species, - float searchRadius = 200) - { - if (searchRadius < 1) - throw new ArgumentException("searchRadius must be >= 1"); - - (Microbe Microbe, Vector3 Position)? closestMicrobe = null; - float nearestDistanceSquared = float.MaxValue; - var searchRadiusSquared = searchRadius * searchRadius; - - microbes ??= worldRoot.GetTree().GetNodesInGroup(Constants.RUNNABLE_MICROBE_GROUP).Cast() - .ToArray(); - - foreach (var microbe in microbes) - { - if (microbe.Species != species) - continue; - - Vector3 microbeGlobalPosition; - - // Use colony parent position to avoid calling GlobalTranslation - if (microbe.Colony != null) - { - microbeGlobalPosition = microbe.Colony.Master.Translation; - } - else - { - microbeGlobalPosition = microbe.Translation; - } - - // Skip candidates for performance - if (Math.Abs(microbeGlobalPosition.x - position.x) > searchRadius || - Math.Abs(microbeGlobalPosition.y - position.y) > searchRadius) - { - continue; - } - - var distanceSquared = (microbeGlobalPosition - position).LengthSquared(); - - if (distanceSquared < nearestDistanceSquared && - distanceSquared < searchRadiusSquared && - distanceSquared > 1) - { - nearestDistanceSquared = distanceSquared; - closestMicrobe = (microbe, microbeGlobalPosition); - } - } - - return closestMicrobe; - } -} + \ No newline at end of file diff --git a/src/microbe_stage/MicrobeVisualOnlySimulation.cs b/src/microbe_stage/MicrobeVisualOnlySimulation.cs new file mode 100644 index 00000000000..3771b99db22 --- /dev/null +++ b/src/microbe_stage/MicrobeVisualOnlySimulation.cs @@ -0,0 +1,267 @@ +using System; +using Components; +using DefaultEcs; +using DefaultEcs.Threading; +using Godot; +using Systems; + +/// +/// Handles displaying just microbe visuals (as alternative to the full ) +/// +public sealed class MicrobeVisualOnlySimulation : WorldSimulation +{ + // Base systems + private AnimationControlSystem animationControlSystem = null!; + private AttachedEntityPositionSystem attachedEntityPositionSystem = null!; + private ColourAnimationSystem colourAnimationSystem = null!; + private EntityMaterialFetchSystem entityMaterialFetchSystem = null!; + private FadeOutActionSystem fadeOutActionSystem = null!; + private PathBasedSceneLoader pathBasedSceneLoader = null!; + private PredefinedVisualLoaderSystem predefinedVisualLoaderSystem = null!; + + // private RenderOrderSystem renderOrderSystem = null! = null!; + + private SpatialAttachSystem spatialAttachSystem = null!; + private SpatialPositionSystem spatialPositionSystem = null!; + + // Microbe systems + private CellBurstEffectSystem cellBurstEffectSystem = null!; + + // private ColonyBindingSystem colonyBindingSystem = null!; + private MicrobeFlashingSystem microbeFlashingSystem = null!; + private MicrobeShaderSystem microbeShaderSystem = null!; + private MicrobeVisualsSystem microbeVisualsSystem = null!; + private TintColourAnimationSystem tintColourAnimationSystem = null!; + +#pragma warning disable CA2213 + private Node visualsParent = null!; +#pragma warning restore CA2213 + + /// + /// Initialized this visual simulation for use + /// + /// Root node to place all visuals under + public void Init(Node visualDisplayRoot) + { + visualsParent = visualDisplayRoot; + + // This is not used for intensive use, and even is used in the background of normal gameplay so this should use + // just a single thread + var runner = new DefaultParallelRunner(1); + + animationControlSystem = new AnimationControlSystem(EntitySystem); + attachedEntityPositionSystem = new AttachedEntityPositionSystem(EntitySystem, runner); + colourAnimationSystem = new ColourAnimationSystem(EntitySystem, runner); + + entityMaterialFetchSystem = new EntityMaterialFetchSystem(EntitySystem); + fadeOutActionSystem = new FadeOutActionSystem(this, EntitySystem, runner); + pathBasedSceneLoader = new PathBasedSceneLoader(EntitySystem, runner); + + predefinedVisualLoaderSystem = new PredefinedVisualLoaderSystem(EntitySystem); + + spatialAttachSystem = new SpatialAttachSystem(visualsParent, EntitySystem); + spatialPositionSystem = new SpatialPositionSystem(EntitySystem, runner); + cellBurstEffectSystem = new CellBurstEffectSystem(EntitySystem); + + // For previewing early multicellular some colony operations will be needed + // colonyBindingSystem = new ColonyBindingSystem(this, EntitySystem, parallelRunner); + + microbeFlashingSystem = new MicrobeFlashingSystem(EntitySystem, runner); + microbeShaderSystem = new MicrobeShaderSystem(EntitySystem); + + microbeVisualsSystem = new MicrobeVisualsSystem(EntitySystem); + + // organelleComponentFetchSystem = new OrganelleComponentFetchSystem(EntitySystem, runner); + + // TODO: is there a need for the movement system / OrganelleTickSystem to control animations on organelles + // if those are used then also OrganelleComponentFetchSystem would be needed + // organelleTickSystem = new OrganelleTickSystem(EntitySystem, runner); + + tintColourAnimationSystem = new TintColourAnimationSystem(EntitySystem); + + OnInitialized(); + } + + public override void ProcessFrameLogic(float delta) + { + ThrowIfNotInitialized(); + + colourAnimationSystem.Update(delta); + microbeShaderSystem.Update(delta); + tintColourAnimationSystem.Update(delta); + } + + /// + /// Creates a simple visualization microbe in this world at origin that can then be manipulated with the microbe + /// visualization methods below + /// + /// The created entity + public Entity CreateVisualisationMicrobe(Species species) + { + // TODO: should we have a separate spawn method to just spawn the visual aspects of a microbe? + // The downside would be duplicated code, but it could skip the component types that don't impact the visuals + + // We pass AI controlled true here to avoid creating player specific data but as we don't have the AI system + // it is fine to create the AI properties as it won't actually do anything + SpawnHelpers.SpawnMicrobe(this, species, Vector3.Zero, true); + + ProcessDelaySpawnedEntitiesImmediately(); + + // Grab the created entity + Entity foundEntity = default; + + foreach (var entity in EntitySystem) + { + if (!entity.Has()) + continue; + + // In case there are already multiple microbes, grab the last one + foundEntity = entity; + } + + if (foundEntity == default) + throw new Exception("Could not find microbe entity that should have been created"); + + return foundEntity; + } + + public void ApplyNewVisualisationMicrobeSpecies(Entity microbe, MicrobeSpecies species) + { + if (!microbe.Has()) + { + GD.PrintErr("Can't apply new species to visualization entity as it is missing a component"); + return; + } + + // Do a full update apply with the general code method + ref var cellProperties = ref microbe.Get(); + cellProperties.ReApplyCellTypeProperties(microbe, species, species); + + // TODO: update species member component if species changed? + } + + /// + /// Applies just a colour value as the species colour to a microbe + /// + /// Microbe entity + /// Colour to apply to it (overrides any previously applied species colour) + public void ApplyMicrobeColour(Entity microbe, Color colour) + { + if (!microbe.Has()) + { + GD.PrintErr("Can't apply new rigidity to visualization entity as it is missing a component"); + return; + } + + ref var cellProperties = ref microbe.Get(); + + // Reset the initial used colour + cellProperties.Colour = colour; + + // Reset the colour used when updating (should be fine to cancel the animation here) + ref var colourComponent = ref microbe.Get(); + colourComponent.DefaultColour = Membrane.MembraneTintFromSpeciesColour(colour); + colourComponent.ResetColour(); + + // We have to update all organelle visuals to get them to apply the new colour + ref var organelleContainer = ref microbe.Get(); + organelleContainer.OrganelleVisualsCreated = false; + } + + public void ApplyMicrobeRigidity(Entity microbe, float membraneRigidity) + { + if (!microbe.Has()) + { + GD.PrintErr("Can't apply new rigidity to visualization entity as it is missing a component"); + return; + } + + ref var cellProperties = ref microbe.Get(); + cellProperties.MembraneRigidity = membraneRigidity; + + ref var organelleContainer = ref microbe.Get(); + + // Needed to re-apply membrane data + organelleContainer.OrganelleVisualsCreated = false; + } + + public void ApplyMicrobeMembraneType(Entity microbe, MembraneType membraneType) + { + if (!microbe.Has()) + { + GD.PrintErr("Can't apply new membrane type to visualization entity as it is missing a component"); + return; + } + + ref var cellProperties = ref microbe.Get(); + cellProperties.MembraneType = membraneType; + + ref var organelleContainer = ref microbe.Get(); + + organelleContainer.OrganelleVisualsCreated = false; + } + + // This world doesn't use physics + protected override void WaitForStartedPhysicsRun() + { + } + + protected override void OnStartPhysicsRunIfTime(float delta) + { + } + + protected override bool RunPhysicsIfBehind() + { + return false; + } + + protected override void OnProcessFixedLogic(float delta) + { + microbeVisualsSystem.Update(delta); + pathBasedSceneLoader.Update(delta); + predefinedVisualLoaderSystem.Update(delta); + entityMaterialFetchSystem.Update(delta); + animationControlSystem.Update(delta); + + attachedEntityPositionSystem.Update(delta); + + // colonyBindingSystem.Update(delta); + + spatialAttachSystem.Update(delta); + spatialPositionSystem.Update(delta); + + // organelleComponentFetchSystem.Update(delta); + // organelleTickSystem.Update(delta); + + fadeOutActionSystem.Update(delta); + + // renderOrderSystem.Update(delta); + + cellBurstEffectSystem.Update(delta); + + microbeFlashingSystem.Update(delta); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + animationControlSystem.Dispose(); + attachedEntityPositionSystem.Dispose(); + colourAnimationSystem.Dispose(); + entityMaterialFetchSystem.Dispose(); + fadeOutActionSystem.Dispose(); + pathBasedSceneLoader.Dispose(); + predefinedVisualLoaderSystem.Dispose(); + spatialAttachSystem.Dispose(); + spatialPositionSystem.Dispose(); + cellBurstEffectSystem.Dispose(); + microbeFlashingSystem.Dispose(); + microbeShaderSystem.Dispose(); + microbeVisualsSystem.Dispose(); + tintColourAnimationSystem.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/src/microbe_stage/MicrobeWorldSimulation.cs b/src/microbe_stage/MicrobeWorldSimulation.cs new file mode 100644 index 00000000000..c6ce60847ee --- /dev/null +++ b/src/microbe_stage/MicrobeWorldSimulation.cs @@ -0,0 +1,412 @@ +using DefaultEcs.Threading; +using Godot; +using Newtonsoft.Json; +using Systems; + +/// +/// Contains all the parts needed to simulate a microbial world. Separate from (but used by) the +/// to also allow other parts of the code to easily run a microbe simulation +/// +public class MicrobeWorldSimulation : WorldSimulationWithPhysics +{ + private readonly IParallelRunner nonParallelRunner = new DefaultParallelRunner(1); + + // Base systems + private AnimationControlSystem animationControlSystem = null!; + private AttachedEntityPositionSystem attachedEntityPositionSystem = null!; + private ColourAnimationSystem colourAnimationSystem = null!; + private CountLimitedDespawnSystem countLimitedDespawnSystem = null!; + private DamageCooldownSystem damageCooldownSystem = null!; + private DamageOnTouchSystem damageOnTouchSystem = null!; + private DisallowPlayerBodySleepSystem disallowPlayerBodySleepSystem = null!; + private EntityMaterialFetchSystem entityMaterialFetchSystem = null!; + private FadeOutActionSystem fadeOutActionSystem = null!; + private PathBasedSceneLoader pathBasedSceneLoader = null!; + private PhysicsBodyControlSystem physicsBodyControlSystem = null!; + private PhysicsBodyCreationSystem physicsBodyCreationSystem = null!; + private PhysicsBodyDisablingSystem physicsBodyDisablingSystem = null!; + private PhysicsCollisionManagementSystem physicsCollisionManagementSystem = null!; + private PhysicsUpdateAndPositionSystem physicsUpdateAndPositionSystem = null!; + private PredefinedVisualLoaderSystem predefinedVisualLoaderSystem = null!; + + // private RenderOrderSystem renderOrderSystem = null! = null!; + + private SoundEffectSystem soundEffectSystem = null!; + private SoundListenerSystem soundListenerSystem = null!; + private SpatialAttachSystem spatialAttachSystem = null!; + private SpatialPositionSystem spatialPositionSystem = null!; + + // Microbe systems + private AllCompoundsVentingSystem allCompoundsVentingSystem = null!; + private CellBurstEffectSystem cellBurstEffectSystem = null!; + private ColonyBindingSystem colonyBindingSystem = null!; + private ColonyCompoundDistributionSystem colonyCompoundDistributionSystem = null!; + private ColonyStatsUpdateSystem colonyStatsUpdateSystem = null!; + private CompoundAbsorptionSystem compoundAbsorptionSystem = null!; + private DamageSoundSystem damageSoundSystem = null!; + private EngulfedDigestionSystem engulfedDigestionSystem = null!; + private EngulfedHandlingSystem engulfedHandlingSystem = null!; + private EngulfingSystem engulfingSystem = null!; + private EntitySignalingSystem entitySignalingSystem = null!; + private FluidCurrentsSystem fluidCurrentsSystem = null!; + private MicrobeAISystem microbeAI = null!; + private MicrobeCollisionSoundSystem microbeCollisionSoundSystem = null!; + private MicrobeDeathSystem microbeDeathSystem = null!; + private MicrobeEventCallbackSystem microbeEventCallbackSystem = null!; + private MicrobeFlashingSystem microbeFlashingSystem = null!; + private MicrobeMovementSoundSystem microbeMovementSoundSystem = null!; + private MicrobeMovementSystem microbeMovementSystem = null!; + private MicrobeShaderSystem microbeShaderSystem = null!; + private MicrobeVisualsSystem microbeVisualsSystem = null!; + private OrganelleComponentFetchSystem organelleComponentFetchSystem = null!; + private OrganelleTickSystem organelleTickSystem = null!; + private OsmoregulationAndHealingSystem osmoregulationAndHealingSystem = null!; + private PilusDamageSystem pilusDamageSystem = null!; + private SlimeSlowdownSystem slimeSlowdownSystem = null!; + private MicrobePhysicsCreationAndSizeSystem microbePhysicsCreationAndSizeSystem = null!; + private MicrobeReproductionSystem microbeReproductionSystem = null!; + private TintColourAnimationSystem tintColourAnimationSystem = null!; + private ToxinCollisionSystem toxinCollisionSystem = null!; + private UnneededCompoundVentingSystem unneededCompoundVentingSystem = null!; + +#pragma warning disable CA2213 + private Node visualsParent = null!; +#pragma warning restore CA2213 + + // External system references + + [JsonIgnore] + public CompoundCloudSystem CloudSystem { get; private set; } = null!; + + // Systems accessible to the outside as these have some very specific methods to be called on them + [JsonIgnore] + public CameraFollowSystem CameraFollowSystem { get; private set; } = null!; + + // TODO: check that + [JsonProperty] + [AssignOnlyChildItemsOnDeserialize] + public SpawnSystem SpawnSystem { get; private set; } = null!; + + [JsonIgnore] + public ProcessSystem ProcessSystem { get; private set; } = null!; + + // TODO: could replace this reference in PatchManager by it just calling ClearPlayerLocationDependentCaches + [JsonIgnore] + public TimedLifeSystem TimedLifeSystem { get; private set; } = null!; + + /// + /// First initialization step which creates all the system objects. When loading from a save objects of this + /// type should have and this method should be called + /// before those child properties are loaded. + /// + /// Godot Node to place all simulation graphics underneath + /// + /// Compound cloud simulation system. This method will call + /// + public void Init(Node visualDisplayRoot, CompoundCloudSystem cloudSystem) + { + visualsParent = visualDisplayRoot; + + // TODO: add threading + var parallelRunner = new DefaultParallelRunner(1); + + // Systems stored in fields + animationControlSystem = new AnimationControlSystem(EntitySystem); + attachedEntityPositionSystem = new AttachedEntityPositionSystem(EntitySystem, parallelRunner); + colourAnimationSystem = new ColourAnimationSystem(EntitySystem, parallelRunner); + countLimitedDespawnSystem = new CountLimitedDespawnSystem(this, EntitySystem, parallelRunner); + damageCooldownSystem = new DamageCooldownSystem(EntitySystem, parallelRunner); + damageOnTouchSystem = new DamageOnTouchSystem(this, EntitySystem, parallelRunner); + disallowPlayerBodySleepSystem = new DisallowPlayerBodySleepSystem(physics, EntitySystem); + entityMaterialFetchSystem = new EntityMaterialFetchSystem(EntitySystem); + fadeOutActionSystem = new FadeOutActionSystem(this, EntitySystem, parallelRunner); + pathBasedSceneLoader = new PathBasedSceneLoader(EntitySystem, nonParallelRunner); + physicsBodyControlSystem = new PhysicsBodyControlSystem(physics, EntitySystem, parallelRunner); + physicsBodyCreationSystem = new PhysicsBodyCreationSystem(this, null, EntitySystem, nonParallelRunner); + physicsBodyDisablingSystem = new PhysicsBodyDisablingSystem(physics, EntitySystem); + physicsCollisionManagementSystem = new PhysicsCollisionManagementSystem(physics, EntitySystem, parallelRunner); + physicsUpdateAndPositionSystem = new PhysicsUpdateAndPositionSystem(physics, EntitySystem, parallelRunner); + predefinedVisualLoaderSystem = new PredefinedVisualLoaderSystem(EntitySystem); + + // TODO: different root for sounds? + soundEffectSystem = new SoundEffectSystem(visualsParent, EntitySystem); + soundListenerSystem = new SoundListenerSystem(visualsParent, EntitySystem, parallelRunner); + spatialAttachSystem = new SpatialAttachSystem(visualsParent, EntitySystem); + spatialPositionSystem = new SpatialPositionSystem(EntitySystem, parallelRunner); + + allCompoundsVentingSystem = new AllCompoundsVentingSystem(cloudSystem, this, EntitySystem, parallelRunner); + cellBurstEffectSystem = new CellBurstEffectSystem(EntitySystem); + + colonyBindingSystem = new ColonyBindingSystem(this, EntitySystem, parallelRunner); + colonyCompoundDistributionSystem = new ColonyCompoundDistributionSystem(EntitySystem, parallelRunner); + colonyStatsUpdateSystem = new ColonyStatsUpdateSystem(EntitySystem, parallelRunner); + + // TODO: clouds currently only allow 2 thread to absorb at once + compoundAbsorptionSystem = new CompoundAbsorptionSystem(cloudSystem, EntitySystem, parallelRunner); + + damageSoundSystem = new DamageSoundSystem(EntitySystem, parallelRunner); + engulfedDigestionSystem = new EngulfedDigestionSystem(cloudSystem, EntitySystem, parallelRunner); + engulfedHandlingSystem = new EngulfedHandlingSystem(EntitySystem, parallelRunner); + entitySignalingSystem = new EntitySignalingSystem(EntitySystem, parallelRunner); + + fluidCurrentsSystem = new FluidCurrentsSystem(EntitySystem, parallelRunner); + + microbeMovementSystem = new MicrobeMovementSystem(PhysicalWorld, EntitySystem, parallelRunner); + + // TODO: this definitely needs to be (along with the process system) the first systems to be multithreaded + microbeAI = new MicrobeAISystem(cloudSystem, EntitySystem, parallelRunner); + microbeCollisionSoundSystem = new MicrobeCollisionSoundSystem(EntitySystem, parallelRunner); + + microbeEventCallbackSystem = new MicrobeEventCallbackSystem(cloudSystem, microbeAI, EntitySystem); + microbeFlashingSystem = new MicrobeFlashingSystem(EntitySystem, parallelRunner); + microbeMovementSoundSystem = new MicrobeMovementSoundSystem(EntitySystem, parallelRunner); + microbeShaderSystem = new MicrobeShaderSystem(EntitySystem); + + microbeVisualsSystem = new MicrobeVisualsSystem(EntitySystem); + organelleComponentFetchSystem = new OrganelleComponentFetchSystem(EntitySystem, parallelRunner); + organelleTickSystem = new OrganelleTickSystem(EntitySystem, parallelRunner); + osmoregulationAndHealingSystem = new OsmoregulationAndHealingSystem(EntitySystem, parallelRunner); + pilusDamageSystem = new PilusDamageSystem(EntitySystem, parallelRunner); + slimeSlowdownSystem = new SlimeSlowdownSystem(cloudSystem, EntitySystem, parallelRunner); + microbePhysicsCreationAndSizeSystem = new MicrobePhysicsCreationAndSizeSystem(EntitySystem, parallelRunner); + tintColourAnimationSystem = new TintColourAnimationSystem(EntitySystem); + + toxinCollisionSystem = new ToxinCollisionSystem(EntitySystem, parallelRunner); + unneededCompoundVentingSystem = new UnneededCompoundVentingSystem(cloudSystem, EntitySystem, parallelRunner); + + // Systems stored in properties + CameraFollowSystem = new CameraFollowSystem(EntitySystem); + + ProcessSystem = new ProcessSystem(EntitySystem, parallelRunner); + + TimedLifeSystem = new TimedLifeSystem(this, EntitySystem, parallelRunner); + + SpawnSystem = new SpawnSystem(this); + + microbeReproductionSystem = new MicrobeReproductionSystem(this, SpawnSystem, EntitySystem, parallelRunner); + microbeDeathSystem = new MicrobeDeathSystem(this, SpawnSystem, EntitySystem, parallelRunner); + engulfingSystem = new EngulfingSystem(this, SpawnSystem, EntitySystem); + + CloudSystem = cloudSystem; + cloudSystem.Init(fluidCurrentsSystem); + + OnInitialized(); + } + + /// + /// Second phase initialization that requires access to the current game info + /// + /// Currently started game + public void InitForCurrentGame(GameProperties currentGame) + { + osmoregulationAndHealingSystem.SetWorld(currentGame.GameWorld); + microbeReproductionSystem.SetWorld(currentGame.GameWorld); + microbeDeathSystem.SetWorld(currentGame.GameWorld); + } + + public override void ProcessFrameLogic(float delta) + { + ThrowIfNotInitialized(); + + colourAnimationSystem.Update(delta); + microbeShaderSystem.Update(delta); + tintColourAnimationSystem.Update(delta); + } + + public void SetSimulationBiome(BiomeConditions biomeConditions) + { + ProcessSystem.SetBiome(biomeConditions); + } + + public override void ReportPlayerPosition(Vector3 position) + { + // TODO: reporting the player position to all systems on game load + + base.ReportPlayerPosition(position); + + // Immediately report to some systems + countLimitedDespawnSystem.ReportPlayerPosition(position); + soundEffectSystem.ReportPlayerPosition(position); + SpawnSystem.ReportPlayerPosition(position); + + // Report to the kind of external clouds system as this simplifies code using the simulation + CloudSystem.ReportPlayerPosition(position); + } + + /// + /// Clears system data that has been stored based on the player location. Call this when the player changes + /// locations a lot by respawning or by moving patches + /// + public void ClearPlayerLocationDependentCaches() + { + SpawnSystem.ClearSpawnCoordinates(); + } + + internal void OverrideMicrobeAIRandomSeed(int seed) + { + microbeAI.OverrideAIRandomSeed(seed); + } + + protected override void OnProcessFixedLogic(float delta) + { + TimedLifeSystem.Update(delta); + + microbeVisualsSystem.Update(delta); + pathBasedSceneLoader.Update(delta); + predefinedVisualLoaderSystem.Update(delta); + entityMaterialFetchSystem.Update(delta); + animationControlSystem.Update(delta); + + microbePhysicsCreationAndSizeSystem.Update(delta); + physicsBodyCreationSystem.Update(delta); + physicsBodyDisablingSystem.Update(delta); + + physicsCollisionManagementSystem.Update(delta); + + physicsUpdateAndPositionSystem.Update(delta); + attachedEntityPositionSystem.Update(delta); + + fluidCurrentsSystem.Update(delta); + + engulfingSystem.Update(delta); + engulfedDigestionSystem.Update(delta); + engulfedHandlingSystem.Update(delta); + + colonyBindingSystem.Update(delta); + + spatialAttachSystem.Update(delta); + spatialPositionSystem.Update(delta); + + allCompoundsVentingSystem.Update(delta); + unneededCompoundVentingSystem.Update(delta); + compoundAbsorptionSystem.Update(delta); + entitySignalingSystem.Update(delta); + + damageCooldownSystem.Update(delta); + toxinCollisionSystem.Update(delta); + damageOnTouchSystem.Update(delta); + pilusDamageSystem.Update(delta); + + ProcessSystem.Update(delta); + + colonyCompoundDistributionSystem.Update(delta); + + osmoregulationAndHealingSystem.Update(delta); + + microbeReproductionSystem.Update(delta); + organelleComponentFetchSystem.Update(delta); + + if (RunAI) + { + // Update AI for the cells (note that the AI system itself can also be disabled, due to cheats) + microbeAI.ReportPotentialPlayerPosition(reportedPlayerPosition); + microbeAI.Update(delta); + } + + countLimitedDespawnSystem.Update(delta); + + SpawnSystem.Update(delta); + + colonyStatsUpdateSystem.Update(delta); + + microbeEventCallbackSystem.Update(delta); + + microbeDeathSystem.Update(delta); + + disallowPlayerBodySleepSystem.Update(delta); + + slimeSlowdownSystem.Update(delta); + microbeMovementSystem.Update(delta); + microbeMovementSoundSystem.Update(delta); + + organelleTickSystem.Update(delta); + + fadeOutActionSystem.Update(delta); + physicsBodyControlSystem.Update(delta); + + // renderOrderSystem.Update(delta); + + cellBurstEffectSystem.Update(delta); + + microbeFlashingSystem.Update(delta); + + damageSoundSystem.Update(delta); + microbeCollisionSoundSystem.Update(delta); + soundEffectSystem.Update(delta); + + soundListenerSystem.Update(delta); + + // This needs to be here to not visually jitter the player position + CameraFollowSystem.Update(delta); + + reportedPlayerPosition = null; + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + nonParallelRunner.Dispose(); + + animationControlSystem.Dispose(); + attachedEntityPositionSystem.Dispose(); + colourAnimationSystem.Dispose(); + countLimitedDespawnSystem.Dispose(); + damageCooldownSystem.Dispose(); + damageOnTouchSystem.Dispose(); + disallowPlayerBodySleepSystem.Dispose(); + entityMaterialFetchSystem.Dispose(); + fadeOutActionSystem.Dispose(); + pathBasedSceneLoader.Dispose(); + physicsBodyControlSystem.Dispose(); + physicsBodyCreationSystem.Dispose(); + physicsBodyDisablingSystem.Dispose(); + physicsCollisionManagementSystem.Dispose(); + physicsUpdateAndPositionSystem.Dispose(); + predefinedVisualLoaderSystem.Dispose(); + soundEffectSystem.Dispose(); + soundListenerSystem.Dispose(); + spatialAttachSystem.Dispose(); + spatialPositionSystem.Dispose(); + + allCompoundsVentingSystem.Dispose(); + cellBurstEffectSystem.Dispose(); + colonyBindingSystem.Dispose(); + colonyCompoundDistributionSystem.Dispose(); + colonyStatsUpdateSystem.Dispose(); + compoundAbsorptionSystem.Dispose(); + damageSoundSystem.Dispose(); + engulfedDigestionSystem.Dispose(); + engulfedHandlingSystem.Dispose(); + engulfingSystem.Dispose(); + entitySignalingSystem.Dispose(); + fluidCurrentsSystem.Dispose(); + microbeAI.Dispose(); + microbeCollisionSoundSystem.Dispose(); + microbeDeathSystem.Dispose(); + microbeEventCallbackSystem.Dispose(); + microbeFlashingSystem.Dispose(); + microbeMovementSoundSystem.Dispose(); + microbeMovementSystem.Dispose(); + microbeShaderSystem.Dispose(); + microbeVisualsSystem.Dispose(); + organelleComponentFetchSystem.Dispose(); + organelleTickSystem.Dispose(); + osmoregulationAndHealingSystem.Dispose(); + pilusDamageSystem.Dispose(); + slimeSlowdownSystem.Dispose(); + microbePhysicsCreationAndSizeSystem.Dispose(); + microbeReproductionSystem.Dispose(); + tintColourAnimationSystem.Dispose(); + toxinCollisionSystem.Dispose(); + unneededCompoundVentingSystem.Dispose(); + + CameraFollowSystem.Dispose(); + ProcessSystem.Dispose(); + TimedLifeSystem.Dispose(); + SpawnSystem.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/src/microbe_stage/OrganelleDefinition.cs b/src/microbe_stage/OrganelleDefinition.cs index f514f3bdaae..8f0b35a3753 100644 --- a/src/microbe_stage/OrganelleDefinition.cs +++ b/src/microbe_stage/OrganelleDefinition.cs @@ -17,34 +17,6 @@ /// public class OrganelleDefinition : IRegistryType { - // TODO: split the following comment to the actual properties in this class: - /* - Organelle attributes: - mass: How heavy an organelle is. Affects speed, mostly. - - mpCost: The cost (in mutation points) an organelle costs in the - microbe editor. - - mesh: The name of the mesh file of the organelle. - It has to be in the models folder. - - texture: The name of the texture file to use - - hexes: A table of the hexes that the organelle occupies. - - chanceToCreate: The (relative) chance this organelle will appear in a - randomly generated or mutated microbe (to do roulette selection). - - prokaryoteChance: The (relative) chance this organelle will appear in a - randomly generated or mutated prokaryotes (to do roulette selection). - - processes: A table with all the processes this organelle does, - and the capacity of the process - - upgradeGUI: path to a scene that is used to modify / upgrade the organelle. If not set the organelle is not - modifiable - */ - /// /// User readable name /// @@ -72,16 +44,16 @@ microbe editor. public string? DisplaySceneAnimation; /// - /// Loaded scene instance to be used when organelle of this type is placed + /// When true the graphics for this organelle are positioned externally (i.e. moved to the membrane edge and + /// point outside from the cell) /// - [JsonIgnore] - public PackedScene? LoadedScene; + public bool PositionedExternally; /// - /// Loaded scene instance to be used when organelle of this type needs to be displayed for a dead microbe + /// Loaded scene instance to be used when organelle of this type is placed /// [JsonIgnore] - public PackedScene? LoadedCorpseChunkScene; + public PackedScene? LoadedScene; /// /// Loaded icon for display in GUIs @@ -89,15 +61,30 @@ microbe editor. [JsonIgnore] public Texture? LoadedIcon; - public float Mass; + // TODO: switch this out for a density value and start using this in the physics body creation + + /// + /// Density of this organelle. Note that densities should fall into just a few categories to ensure that cached + /// microbe collision shapes can be reused more widely + /// + public float Density = 1000; + + /// + /// How much the density of this organelle contributes. Should be set to 0 for pilus and other organelles that + /// have separate physics shapes created for them. Bigger organelles should have larger values to make them + /// impact the overall physics mass more. Similarly to this should also have only a few + /// used values among all organelles to make shape caching more effective (as the cache depends on density). + /// + public float RelativeDensityVolume = 1; /// - /// The chance this organelle is placed in an eukaryote when applying mutations + /// The (relative) chance this organelle is placed in an eukaryote when applying mutations or generating random + /// species (to do roulette selection). /// public float ChanceToCreate; /// - /// Same as ChanceToCreate but for prokaryotes (bacteria) + /// Same as but for prokaryotes (bacteria) /// public float ProkaryoteChance; @@ -117,8 +104,13 @@ microbe editor. /// public int EditorButtonOrder; - [JsonRequired] - public OrganelleComponentFactoryInfo Components = null!; + public OrganelleComponentFactoryInfo Components = new(); + + /// + /// Lightweight feature tags that this organelle has. This is used for simple features that don't need the full + /// features that provides. + /// + public OrganelleFeatureTag[] FeatureTags = Array.Empty(); /// /// Defines the processes this organelle does and their speed multipliers @@ -130,7 +122,13 @@ microbe editor. /// public List Hexes = null!; - public Dictionary? Enzymes; + [JsonProperty(PropertyName = "enzymes")] + public Dictionary? RawEnzymes; + + /// + /// Enzymes contained in this organelle + /// + public Dictionary Enzymes = new(); /// /// The compounds this organelle consists of (how many resources are needed to duplicate this) @@ -153,7 +151,7 @@ microbe editor. public string? IconPath; /// - /// Cost of placing this organelle in the editor + /// Cost of placing this organelle in the editor (in mutation points) /// public int MPCost; @@ -178,7 +176,7 @@ microbe editor. public bool LAWK = true; /// - /// Path to a scene that is used to modify / upgrade the organelle. If not set the organelle is not modifiable + /// Path to a scene that is used to modify / upgrade the organelle. If not set the organelle is not modifiable. /// public string? UpgradeGUI; @@ -196,6 +194,8 @@ microbe editor. private string? untranslatedName; #pragma warning restore 169,649 + private Vector3 modelOffset; + public enum OrganelleGroup { /// @@ -235,6 +235,9 @@ public enum OrganelleGroup [JsonIgnore] public int HexCount => Hexes.Count; + [JsonIgnore] + public Vector3 ModelOffset => modelOffset; + public string InternalName { get; set; } = null!; // Faster checks for specific components @@ -242,6 +245,17 @@ public enum OrganelleGroup public bool HasMovementComponent { get; private set; } public bool HasCiliaComponent { get; private set; } + /// + /// True if this is an agent vacuole. Number of agent vacuoles determine how often a cell can shoot toxins. + /// + public bool HasAgentVacuoleComponent { get; private set; } + + public bool HasSlimeJetComponent { get; private set; } + + public bool HasBindingFeature { get; private set; } + + public bool HasSignalingFeature { get; private set; } + [JsonIgnore] public string UntranslatedName => untranslatedName ?? throw new InvalidOperationException("Translations not initialized"); @@ -280,26 +294,6 @@ public IEnumerable GetRotatedHexes(int rotation) return rotated; } - public Vector3 CalculateCenterOffset() - { - var offset = new Vector3(0, 0, 0); - - foreach (var hex in Hexes) - { - offset += Hex.AxialToCartesian(hex); - } - - offset /= Hexes.Count; - return offset; - } - - public Vector3 CalculateModelOffset() - { - var temp = CalculateCenterOffset(); - temp /= HexCount; - return temp * Constants.DEFAULT_HEX_SIZE; - } - /// /// Returns true when this has the specified component factory. /// For example . @@ -324,6 +318,22 @@ public bool HasComponentFactory() return false; } + /// + /// Returns true when this has the specified organelle feature tag. These are lightweight alternative markers for + /// supported features compared to + /// + /// True when this has the feature + public bool HasFeatureTag(OrganelleFeatureTag featureTag) + { + foreach (var feature in FeatureTags) + { + if (featureTag == feature) + return true; + } + + return false; + } + public void Check(string name) { if (string.IsNullOrEmpty(Name)) @@ -336,21 +346,13 @@ public void Check(string name) if (Unimplemented) return; - if (Components == null) - { - throw new InvalidRegistryDataException(name, GetType().Name, "No components specified"); - } - Components.Check(name); - if (Components.Count < 1) - { - throw new InvalidRegistryDataException(name, GetType().Name, "No components specified"); - } + // Components list is now allowed to be empty as some organelles do not need any components - if (Mass <= 0.0f) + if (Density < 100) { - throw new InvalidRegistryDataException(name, GetType().Name, "Mass is unset"); + throw new InvalidRegistryDataException(name, GetType().Name, "Density is unset or unrealistically low"); } if (ProkaryoteChance != 0 && RequiresNucleus) @@ -416,6 +418,18 @@ public void Check(string name) throw new InvalidRegistryDataException(name, GetType().Name, "Multiple default upgrades specified"); } + +#if DEBUG + if (!string.IsNullOrEmpty(CorpseChunkScene)) + { + using var directory = new Directory(); + if (!directory.FileExists(CorpseChunkScene)) + { + throw new InvalidRegistryDataException(name, GetType().Name, + "Corpse chunk scene path doesn't exist"); + } + } +#endif } /// @@ -423,6 +437,8 @@ public void Check(string name) /// public void Resolve(SimulationParameters parameters) { + CalculateModelOffset(); + RunnableProcesses = new List(); // Preload the scene for instantiating in microbes @@ -431,11 +447,6 @@ public void Resolve(SimulationParameters parameters) LoadedScene = GD.Load(DisplayScene); } - if (!string.IsNullOrEmpty(CorpseChunkScene)) - { - LoadedCorpseChunkScene = GD.Load(CorpseChunkScene); - } - if (!string.IsNullOrEmpty(IconPath)) { LoadedIcon = GD.Load(IconPath); @@ -451,6 +462,17 @@ public void Resolve(SimulationParameters parameters) } } + // Resolve enzymes from strings to Enzyme objects + if (RawEnzymes != null) + { + foreach (var entry in RawEnzymes) + { + var enzyme = parameters.GetEnzyme(entry.Key); + + Enzymes[enzyme] = entry.Value; + } + } + if (Unimplemented) return; @@ -493,26 +515,45 @@ public override string ToString() private void ComputeFactoryCache() { - HasPilusComponent = HasComponentFactory(); + HasPilusComponent = HasFeatureTag(OrganelleFeatureTag.Pilus); HasMovementComponent = HasComponentFactory(); HasCiliaComponent = HasComponentFactory(); + HasAgentVacuoleComponent = HasComponentFactory(); + HasSlimeJetComponent = HasComponentFactory(); + + HasBindingFeature = HasFeatureTag(OrganelleFeatureTag.BindingAgent); + HasSignalingFeature = HasFeatureTag(OrganelleFeatureTag.SignalingAgent); + } + + private void CalculateModelOffset() + { + var temp = CalculateCenterOffset(); + temp /= HexCount; + modelOffset = temp * Constants.DEFAULT_HEX_SIZE; + } + + private Vector3 CalculateCenterOffset() + { + var offset = new Vector3(0, 0, 0); + + foreach (var hex in Hexes) + { + offset += Hex.AxialToCartesian(hex); + } + + offset /= Hexes.Count; + return offset; } public class OrganelleComponentFactoryInfo { - public NucleusComponentFactory? Nucleus; public StorageComponentFactory? Storage; public AgentVacuoleComponentFactory? AgentVacuole; - public BindingAgentComponentFactory? BindingAgent; public MovementComponentFactory? Movement; public SlimeJetComponentFactory? SlimeJet; - public PilusComponentFactory? Pilus; public ChemoreceptorComponentFactory? Chemoreceptor; - public SignalingAgentComponentFactory? SignalingAgent; public CiliaComponentFactory? Cilia; public LysosomeComponentFactory? Lysosome; - public AxonComponentFactory? Axon; - public MyofibrilComponentFactory? Myofibril; private readonly List allFactories = new(); @@ -533,13 +574,6 @@ public void Check(string name) { count = 0; - if (Nucleus != null) - { - Nucleus.Check(name); - allFactories.Add(Nucleus); - ++count; - } - if (Storage != null) { Storage.Check(name); @@ -554,13 +588,6 @@ public void Check(string name) ++count; } - if (BindingAgent != null) - { - BindingAgent.Check(name); - allFactories.Add(BindingAgent); - ++count; - } - if (Movement != null) { Movement.Check(name); @@ -575,13 +602,6 @@ public void Check(string name) ++count; } - if (Pilus != null) - { - Pilus.Check(name); - allFactories.Add(Pilus); - ++count; - } - if (Chemoreceptor != null) { Chemoreceptor.Check(name); @@ -589,13 +609,6 @@ public void Check(string name) ++count; } - if (SignalingAgent != null) - { - SignalingAgent.Check(name); - allFactories.Add(SignalingAgent); - ++count; - } - if (Cilia != null) { Cilia.Check(name); @@ -609,20 +622,6 @@ public void Check(string name) allFactories.Add(Lysosome); ++count; } - - if (Axon != null) - { - Axon.Check(name); - allFactories.Add(Axon); - ++count; - } - - if (Myofibril != null) - { - Myofibril.Check(name); - allFactories.Add(Myofibril); - ++count; - } } } } diff --git a/src/microbe_stage/OrganelleFeatureTag.cs b/src/microbe_stage/OrganelleFeatureTag.cs new file mode 100644 index 00000000000..56a999d86d1 --- /dev/null +++ b/src/microbe_stage/OrganelleFeatureTag.cs @@ -0,0 +1,17 @@ +/// +/// Marks organelle as having some feature. This is a more lightweight system than full on components and is used for +/// feature markers that don't need the full-blown functionality. +/// +public enum OrganelleFeatureTag +{ + SignalingAgent, + Axon, + BindingAgent, + Myofibril, + Nucleus, + + /// + /// Adds a stabby thing to the cell, positioned similarly to the flagellum + /// + Pilus, +} diff --git a/src/microbe_stage/OrganelleLayout.cs b/src/microbe_stage/OrganelleLayout.cs index abc1d1297f8..aea18fb4f96 100644 --- a/src/microbe_stage/OrganelleLayout.cs +++ b/src/microbe_stage/OrganelleLayout.cs @@ -23,6 +23,9 @@ public OrganelleLayout() [JsonIgnore] public IReadOnlyList Organelles => existingHexes; + [JsonIgnore] + public int HexCount => existingHexes.Sum(h => h.Definition.HexCount); + /// /// The center of mass of the contained organelles. /// @@ -31,15 +34,24 @@ public Hex CenterOfMass { get { - float totalMass = 0; + // TODO: this used to weigh the center position based on the organelle masses, this is no longer possible + // to do as simply + // float totalMass = 0; + int count = 0; Vector3 weightedSum = Vector3.Zero; + + // TODO: shouldn't this take multihex organelles into account? foreach (var organelle in Organelles) { - totalMass += organelle.Definition.Mass; - weightedSum += Hex.AxialToCartesian(organelle.Position) * organelle.Definition.Mass; + // totalMass += organelle.Definition.Mass; + ++count; + weightedSum += Hex.AxialToCartesian(organelle.Position) /* * organelle.Definition.Mass*/; } - return Hex.CartesianToAxial(weightedSum / totalMass); + if (count == 0) + return new Hex(0, 0); + + return Hex.CartesianToAxial(weightedSum / count); } } diff --git a/src/microbe_stage/OrganelleMeshWithChildren.cs b/src/microbe_stage/OrganelleMeshWithChildren.cs index af9c52e1d71..acad77ff2b3 100644 --- a/src/microbe_stage/OrganelleMeshWithChildren.cs +++ b/src/microbe_stage/OrganelleMeshWithChildren.cs @@ -1,28 +1,18 @@ -using Godot; +using System.Collections.Generic; +using Godot; /// -/// Applies the tint to the defined children +/// Applies organelle shader parameters to child nodes /// public class OrganelleMeshWithChildren : MeshInstance { - public void SetTintOfChildren(Color value) + public void GetChildrenMaterials(List result) { foreach (GeometryInstance mesh in GetChildren()) { if (mesh.MaterialOverride is ShaderMaterial shaderMaterial) { - shaderMaterial.SetShaderParam("tint", value); - } - } - } - - public void SetDissolveEffectOfChildren(float value) - { - foreach (GeometryInstance mesh in GetChildren()) - { - if (mesh.MaterialOverride is ShaderMaterial shaderMaterial) - { - shaderMaterial.SetShaderParam("dissolveValue", value); + result.Add(shaderMaterial); } } } diff --git a/src/microbe_stage/OrganelleTemplate.cs b/src/microbe_stage/OrganelleTemplate.cs index f56afb2b11e..667bab89148 100644 --- a/src/microbe_stage/OrganelleTemplate.cs +++ b/src/microbe_stage/OrganelleTemplate.cs @@ -24,7 +24,7 @@ public OrganelleTemplate(OrganelleDefinition definition, Hex location, int rotat public Hex Position { get; set; } [JsonIgnore] - public Vector3 OrganelleModelPosition => Hex.AxialToCartesian(Position) + Definition.CalculateModelOffset(); + public Vector3 OrganelleModelPosition => Hex.AxialToCartesian(Position) + Definition.ModelOffset; /// /// This is now the number of times to rotate. This used to be the angle in degrees diff --git a/src/microbe_stage/OrganelleUpgradeHelpers.cs b/src/microbe_stage/OrganelleUpgradeHelpers.cs new file mode 100644 index 00000000000..ecf53e96a2e --- /dev/null +++ b/src/microbe_stage/OrganelleUpgradeHelpers.cs @@ -0,0 +1,13 @@ +/// +/// Easy access to often required upgrade data checks +/// +public static class OrganelleUpgradeHelpers +{ + public static bool HasInjectisomeUpgrade(this OrganelleUpgrades? organelleUpgrades) + { + if (organelleUpgrades == null) + return false; + + return organelleUpgrades.UnlockedFeatures.Contains(Constants.PILUS_INJECTISOME_UPGRADE_NAME); + } +} diff --git a/src/microbe_stage/PatchManager.cs b/src/microbe_stage/PatchManager.cs index 9f297360661..b5a123c426f 100644 --- a/src/microbe_stage/PatchManager.cs +++ b/src/microbe_stage/PatchManager.cs @@ -4,9 +4,10 @@ using System.Linq; using Godot; using Newtonsoft.Json; +using Systems; /// -/// Manages applying patch data and setting up spawns +/// Manages applying patch data and setting up spawns in a /// public class PatchManager : IChildPropertiesLoadCallback { @@ -33,15 +34,13 @@ public class PatchManager : IChildPropertiesLoadCallback private bool skipDespawn; public PatchManager(SpawnSystem spawnSystem, ProcessSystem processSystem, - CompoundCloudSystem compoundCloudSystem, TimedLifeSystem timedLife, DirectionalLight worldLight, - GameProperties? currentGame) + CompoundCloudSystem compoundCloudSystem, TimedLifeSystem timedLife, DirectionalLight worldLight) { this.spawnSystem = spawnSystem; this.processSystem = processSystem; this.compoundCloudSystem = compoundCloudSystem; this.timedLife = timedLife; this.worldLight = worldLight; - CurrentGame = currentGame; } public GameProperties? CurrentGame { get; set; } diff --git a/src/microbe_stage/PhagocytosisPhase.cs b/src/microbe_stage/PhagocytosisPhase.cs index 0440b18f11c..b0fe347333b 100644 --- a/src/microbe_stage/PhagocytosisPhase.cs +++ b/src/microbe_stage/PhagocytosisPhase.cs @@ -6,7 +6,7 @@ public enum PhagocytosisPhase /// /// Not being phagocytized in any way. /// - None, + None = 0, /// /// Engulfable is in the process of being moved into the cytoplasm to be stored. @@ -23,6 +23,12 @@ public enum PhagocytosisPhase /// Digested, + /// + /// Just before ejection is started for an engulfed entity. This can be set from anywhere to easily start + /// ejecting an engulfable. + /// + RequestExocytosis, + /// /// Engulfable is in the process of being moved into the membrane layer for ejection. /// diff --git a/src/microbe_stage/PlacedOrganelle.cs b/src/microbe_stage/PlacedOrganelle.cs index 66512b87ff3..9ae0c7b45bd 100644 --- a/src/microbe_stage/PlacedOrganelle.cs +++ b/src/microbe_stage/PlacedOrganelle.cs @@ -3,63 +3,62 @@ using System.Linq; using Godot; using Newtonsoft.Json; +using Systems; /// -/// An organelle that has been placed in a microbe. +/// An organelle that has been placed in a simulated microbe. Very different from and +/// . /// -public class PlacedOrganelle : Spatial, IPositionedOrganelle, ISaveLoadedTracked +public class PlacedOrganelle : IPositionedOrganelle { - [JsonIgnore] - private readonly List shapes = new(); - - private bool needsColourUpdate = true; - private bool needsDissolveEffectUpdate = true; - - [JsonProperty] - private Color colour = Colors.White; - - [JsonProperty] - private float dissolveEffectValue; - private bool growthValueDirty = true; private float growthValue; - private Microbe? currentShapesParent; - -#pragma warning disable CA2213 - - /// - /// Used to update the tint - /// - private ShaderMaterial? organelleMaterial; - - private Spatial? organelleSceneInstance; -#pragma warning restore CA2213 - /// /// The compounds still needed to divide. Initialized from Definition.InitialComposition /// [JsonProperty] - private Dictionary compoundsLeft = new(); + private Dictionary compoundsLeft; - private List? components; + [JsonConstructor] + public PlacedOrganelle(OrganelleDefinition definition, Hex position, int orientation, OrganelleUpgrades? upgrades) + { + Definition = definition; + Position = position; + Orientation = orientation; + + // Upgrades must be applied before initializing the components + Upgrades = upgrades; + + InitializeComponents(); + + compoundsLeft ??= new Dictionary(); + ResetGrowth(); + } - public PlacedOrganelle(OrganelleDefinition definition, Hex position, int orientation) + /// + /// JSON constructor that avoid re-running some core logic + /// + [JsonConstructor] + public PlacedOrganelle(OrganelleDefinition definition, Hex position, int orientation, + Dictionary compoundsLeft, OrganelleUpgrades? upgrades) { Definition = definition; Position = position; Orientation = orientation; + this.compoundsLeft = compoundsLeft; + Upgrades = upgrades; + + // TODO: figure out if re-creating components on loading a save is the right approach + InitializeComponents(); } - public OrganelleDefinition Definition { get; set; } + public OrganelleDefinition Definition { get; } public Hex Position { get; set; } public int Orientation { get; set; } - [JsonProperty] - public Microbe? ParentMicrobe { get; private set; } - /// /// The graphics child node of this organelle /// @@ -72,19 +71,6 @@ public PlacedOrganelle(OrganelleDefinition definition, Hex position, int orienta [JsonIgnore] public AnimationPlayer? OrganelleAnimation { get; private set; } - /// - /// The tint colour of this organelle. - /// - public Color Colour - { - get => colour; - set - { - colour = value; - needsColourUpdate = true; - } - } - /// /// Value between 0 and 1 on how far along to splitting this organelle is /// @@ -95,18 +81,8 @@ public float GrowthValue { if (growthValueDirty) RecalculateGrowthValue(); - return growthValue; - } - } - [JsonIgnore] - public float DissolveEffectValue - { - get => dissolveEffectValue; - set - { - dissolveEffectValue = value; - needsDissolveEffectUpdate = true; + return growthValue; } } @@ -130,22 +106,20 @@ public float DissolveEffectValue public PlacedOrganelle? SisterOrganelle { get; set; } /// - /// The components instantiated for this placed organelle. Throws if not currently in a microbe + /// The components instantiated for this placed organelle. Not saved as components are re-created on save load. + /// See the comments about saving. /// [JsonIgnore] - public List Components => components ?? - throw new InvalidOperationException("This must be placed in a microbe before accessing components"); + public List Components { get; } = new(); /// /// The upgrades that this organelle has which affect how the components function /// [JsonProperty] - public OrganelleUpgrades? Upgrades { get; set; } + public OrganelleUpgrades? Upgrades { get; private set; } /// - /// Computes the total storage capacity of this organelle. Works - /// only after being added to a microbe and before being - /// removed. + /// Computes the total storage capacity of this organelle /// [JsonIgnore] public float StorageCapacity @@ -166,49 +140,38 @@ public float StorageCapacity } } - [JsonProperty] - public Dictionary StoredEnzymes { get; private set; } = new(); - /// - /// True if this is an agent vacuole. Number of agent vacuoles - /// determine how often a cell can shoot toxins. + /// Can be set by organelle components to override the enzymes returned by . This is + /// not saved right now as this is only used by which will re-add when the + /// component is re-initialized. /// [JsonIgnore] - public bool IsAgentVacuole => HasComponent(); + public Dictionary? OverriddenEnzymes { get; set; } - [JsonIgnore] - public bool IsSlimeJet => HasComponent(); + public static Color CalculateHSVForOrganelle(Color rawColour) + { + // Get hue saturation and brightness for the colour - [JsonIgnore] - public bool IsBindingAgent => HasComponent(); + // According to stack overflow HSV and HSB are the same thing + rawColour.ToHsv(out var hue, out var saturation, out var brightness); - public bool IsLoadedFromSave { get; set; } + return Color.FromHsv(hue, saturation * 2, brightness); + } /// - /// Guards against adding this to the scene not through OnAddedToMicrobe + /// Gets the effective enzymes provided by this organelle. TODO: allow this to change over time, right now only + /// when organelles are attached this is effective /// - public override void _Ready() + /// Effective enzyme data for this organelle + public IReadOnlyDictionary GetEnzymes() { - if (Definition == null) - throw new InvalidOperationException($"{nameof(Definition)} of {nameof(PlacedOrganelle)} is null"); - - if (ParentMicrobe == null) - { - throw new InvalidOperationException( - $"{nameof(PlacedOrganelle)} not added to scene through {nameof(OnAddedToMicrobe)}"); - } - - if (IsLoadedFromSave) - FinishAttachToMicrobe(); - - ApplyScale(); - } + if (OverriddenEnzymes != null) + return OverriddenEnzymes; - public bool HasShape(uint searchShape) - { - return shapes.Contains(searchShape); + return Definition.Enzymes; } + // TODO: remove if this stays unused /// /// Checks if this organelle has the specified component type /// @@ -225,101 +188,12 @@ public bool HasComponent() return false; } - /// - /// Called by a microbe when this organelle has been added to it - /// - public void OnAddedToMicrobe(Microbe microbe) - { - if (Definition == null) - throw new InvalidOperationException("PlacedOrganelle has no definition set"); - - if (ParentMicrobe != null) - throw new InvalidOperationException("PlacedOrganelle is already in a microbe"); - - // Store parameters - ParentMicrobe = microbe; - - // Grab the species colour for us - Colour = microbe.CellTypeProperties.Colour; - - ParentMicrobe.OrganelleParent.AddChild(this); - - FinishAttachToMicrobe(); - - ResetGrowth(); - } - - /// - /// Called by a microbe when this organelle has been removed from it - /// - public void OnRemovedFromMicrobe() - { - if (ParentMicrobe == null) - throw new InvalidOperationException("This organelle is not in a microbe"); - - ParentMicrobe.OrganelleParent.RemoveChild(this); - - // Remove physics - ParentMicrobe.Mass -= Definition.Mass; - - // Remove our sub collisions - foreach (var shape in shapes) - { - currentShapesParent!.RemoveShapeOwner(shape); - } - - currentShapesParent = null; - shapes.Clear(); - - // Remove components - foreach (var component in Components) - { - component.OnDetachFromCell(this); - } - - components = null; - - ParentMicrobe = null; - } - - /// - /// Called by Microbe.Update - /// - /// Time since last call - public void UpdateAsync(float delta) - { - foreach (var component in Components) - { - component.UpdateAsync(delta); - } - } - - /// - /// The part of update that is allowed to modify Godot resources - /// - public void UpdateSync() - { - // Update each OrganelleComponent - foreach (var component in Components) - { - component.UpdateSync(); - } - - // If the organelle is supposed to be another color. - if (needsColourUpdate) - { - UpdateColour(); - } - - if (needsDissolveEffectUpdate) - UpdateDissolveEffect(); - } - /// /// Gives organelles more compounds to grow (or takes free compounds). /// If goes to 0 stops early and doesn't use any more compounds. /// - public void GrowOrganelle(CompoundBag compounds, ref float allowedCompoundUse, ref float freeCompoundsLeft, + /// True when this has grown a bit and visuals transform needs to be re-applied + public bool GrowOrganelle(CompoundBag compounds, ref float allowedCompoundUse, ref float freeCompoundsLeft, bool reverseCompoundsLeftOrder) { float totalTaken = 0; @@ -383,7 +257,27 @@ public void UpdateSync() { growthValueDirty = true; - ApplyScale(); + return true; + } + + return false; + } + + /// + /// Called by when graphics have been created for this organelle + /// + /// The graphics initialized from this organelle's type's specified scene + public void ReportCreatedGraphics(Spatial visualsInstance) + { + if (OrganelleGraphics != null) + throw new InvalidOperationException("Can't set organelle graphics multiple times"); + + OrganelleGraphics = visualsInstance; + + // Store animation player for later use + if (!string.IsNullOrEmpty(Definition.DisplaySceneAnimation)) + { + OrganelleAnimation = visualsInstance.GetNode(Definition.DisplaySceneAnimation); } } @@ -428,7 +322,8 @@ public float CalculateAbsorbedCompounds(Dictionary result) } /// - /// Resets the state. Used after dividing + /// Resets the state. Used after dividing. Note that the organelle container visuals need to be marked dirty for + /// the sizing to apply /// public void ResetGrowth() { @@ -444,15 +339,9 @@ public void ResetGrowth() compoundsLeft.Add(entry.Key, entry.Value); } - ApplyScale(); - - // If it was split from a primary organelle, destroy it. if (IsDuplicate) { - GD.PrintErr("ResetGrowth called on a duplicate organelle, " + - "this is currently unsupported"); - - // parentMicrobe.RemoveOrganelle(this); + GD.PrintErr("ResetGrowth called on a duplicate organelle, this is not allowed"); } else { @@ -461,137 +350,43 @@ public void ResetGrowth() } } - public void UpdateRenderPriority(int priority) + public Transform CalculateVisualsTransform() { - if (organelleMaterial == null) - return; - - organelleMaterial.RenderPriority = priority; - } + var scale = CalculateTransformScale(); - /// - /// Returns the rotated position, as it should be in the colony. - /// Used for re-parenting shapes to other microbes - /// - public Vector3 RotatedPositionInsideColony(Vector3 shapePosition) - { - var rotation = Quat.Identity; - if (ParentMicrobe?.Colony != null) - { - var parent = ParentMicrobe; + return new Transform(new Basis( + MathUtils.CreateRotationForOrganelle(1 * Orientation)).Scaled(new Vector3(scale, scale, scale)), + Hex.AxialToCartesian(Position) + Definition.ModelOffset); - // Get the rotation of all colony ancestors up to master - while (parent != ParentMicrobe.Colony.Master) - { - if (parent == null) - throw new Exception("Reached a null parent microbe without finding the colony leader"); - - rotation *= new Quat(parent.Transform.basis); - parent = parent.ColonyParent; - } - } - else - { - return shapePosition; - } - - rotation = rotation.Normalized(); - - // Transform the vector with the rotation quaternion - shapePosition = rotation.Xform(shapePosition); - return shapePosition; + // TODO: check is this still needed + // For some reason MathUtils.CreateRotationForOrganelle(Orientation) in the above transform doesn't work + // OrganelleGraphics.RotateY(Orientation * -60 * MathUtils.DEGREES_TO_RADIANS); } - /// - /// Re-parents the organelle shape to the "to" microbe. - /// - public void ReParentShapes(Microbe to, Vector3 offset) + public Transform CalculateVisualsTransformExternal(Vector3 externalPosition, Quat orientation) { - if (to == currentShapesParent) - return; - - if (ParentMicrobe == null || currentShapesParent == null) - throw new InvalidOperationException("This organelle needs to be placed in a microbe first"); - - // TODO: we are in trouble if ever the hex count mismatches with the shapes. It's fine if this can never happen - // but a more bulletproof way would be to add code to at least detect and try to recover if there is no - // matching hex for a shape - // https://github.com/Revolutionary-Games/Thrive/issues/2504 - var hexes = Definition.GetRotatedHexes(Orientation).ToArray(); + var scale = CalculateTransformScale(); - for (int i = 0; i < shapes.Count; i++) - { - Vector3 shapePosition = ShapeTruePosition(hexes[i]); - - // Rotate the position of the organelle to its true position relative to the master - shapePosition = RotatedPositionInsideColony(shapePosition); - - // Scale for bacteria physics. - if (ParentMicrobe.CellTypeProperties.IsBacteria) - shapePosition *= 0.5f; - - shapePosition += offset; - - var ownerId = shapes[i]; - var transform = new Transform(Quat.Identity, shapePosition); - - // Create a new owner id and apply the new position to it - shapes[i] = currentShapesParent.CreateNewOwnerId(to, transform, ownerId); - currentShapesParent.RemoveShapeOwner(ownerId); - } - - foreach (var component in Components) - { - component.OnShapeParentChanged(to, offset); - } - - currentShapesParent = to; + // TODO: check that the rotation of ModelOffset works correctly here + return new Transform(new Basis(orientation).Scaled(new Vector3(scale, scale, scale)), + externalPosition + orientation.Xform(Definition.ModelOffset)); } - private static Color CalculateHSVForOrganelle(Color rawColour) + public (Vector3 Position, Quat Rotation) CalculatePhysicsExternalTransform(Vector3 externalPosition, + Quat orientation, bool isBacteria) { - // Get hue saturation and brightness for the colour + // The shape needs to be rotated 90 degrees to point forward for (so that the pilus is not a vertical column + // but is instead a stabby thing) + var extraRotation = new Quat(new Vector3(1, 0, 0), Mathf.Pi * 0.5f); - // According to stack overflow HSV and HSB are the same thing - rawColour.ToHsv(out var hue, out var saturation, out var brightness); + // Maybe should have a variable for physics shape offset if different organelles need different things + var offset = new Vector3(0, 0, isBacteria ? 1.0f : -1.0f); - return Color.FromHsv(hue, saturation * 2, brightness); + return (externalPosition + orientation.Xform(offset), orientation * extraRotation); } - private void FinishAttachToMicrobe() + private void InitializeComponents() { - // Graphical display - if (Definition.LoadedScene != null) - { - SetupOrganelleGraphics(); - } - - // Physics - ParentMicrobe!.Mass += Definition.Mass; - - // TODO: if organelles can grow while cells are in a colony this will be needed - // Add the mass of the organelles to the colony master - // if (ParentMicrobe.Colony != null && ParentMicrobe != ParentMicrobe.Colony.Master && - // !IsLoadedFromSave) - // ParentMicrobe.Colony.Master.Mass += Definition.Mass; - - // We don't need preview cells to be collidable (as it can lag the editor if the cell is massive). - if (!ParentMicrobe.IsForPreviewOnly) - MakeCollisionShapes(ParentMicrobe.Colony?.Master ?? ParentMicrobe); - - if (Definition.Enzymes != null) - { - foreach (var entry in Definition.Enzymes) - { - var enzyme = SimulationParameters.Instance.GetEnzyme(entry.Key); - - StoredEnzymes[enzyme] = entry.Value; - } - } - - // Components - components = new List(); - foreach (var factory in Definition.ComponentFactories) { var component = factory.Create(); @@ -601,129 +396,31 @@ private void FinishAttachToMicrobe() component.OnAttachToCell(this); - components.Add(component); + Components.Add(component); } - - growthValueDirty = true; } - private Vector3 ShapeTruePosition(Hex parentOffset) + private float CalculateTransformScale() { - return Hex.AxialToCartesian(parentOffset) + Hex.AxialToCartesian(Position); - } - - /// - /// Creates the collision shape(s) necessary for this organelle - /// - /// The microbe to add the shapes to - /// - /// - /// TODO: make this take into initial colony membership into account so that calling ReParentShapes twice - /// when loading a game is not necessary - /// - /// - private void MakeCollisionShapes(Microbe to) - { - currentShapesParent = to; - - float hexSize = Constants.DEFAULT_HEX_SIZE; - - // Scale the physics hex size down for bacteria - if (ParentMicrobe!.CellTypeProperties.IsBacteria) - hexSize *= 0.5f; - - // Add hex collision shapes - foreach (Hex hex in Definition.GetRotatedHexes(Orientation)) + float growth; + if (Definition.ShouldScale) { - var shape = new SphereShape(); - shape.Radius = hexSize * 2.0f; - - // The shape is in our parent so the final position is our - // offset plus the hex offset - Vector3 shapePosition = ShapeTruePosition(hex); - - // Scale for bacteria physics. - if (ParentMicrobe.CellTypeProperties.IsBacteria) - shapePosition *= 0.5f; - - // Create a transform for a shape position - var transform = new Transform(Quat.Identity, shapePosition); - var ownerId = to.CreateShapeOwnerWithTransform(transform, shape); - shapes.Add(ownerId); + growth = GrowthValue; } - } - - private void RecalculateGrowthValue() - { - growthValueDirty = false; - - growthValue = 1.0f - CalculateCompoundsLeft() / Definition.OrganelleCost; - } - - private void ApplyScale() - { - if (!Definition.ShouldScale) - return; - - if (OrganelleGraphics != null) - OrganelleGraphics.Scale = new Vector3(1 + GrowthValue, 1 + GrowthValue, 1 + GrowthValue); - } - - private void UpdateColour() - { - var color = CalculateHSVForOrganelle(Colour); - if (organelleSceneInstance is OrganelleMeshWithChildren organelleMeshWithChildren) - { - organelleMeshWithChildren.SetTintOfChildren(color); - } - - organelleMaterial?.SetShaderParam("tint", color); - - needsColourUpdate = false; - } - - private void UpdateDissolveEffect() - { - if (organelleSceneInstance is OrganelleMeshWithChildren organelleMeshWithChildren) + else { - organelleMeshWithChildren.SetDissolveEffectOfChildren(dissolveEffectValue); + growth = 0; } - organelleMaterial?.SetShaderParam("dissolveValue", dissolveEffectValue); - - needsDissolveEffectUpdate = false; + // TODO: organelle scale used to be 1 + GrowthValue before the refactor, and now this is probably *more* + // intended way, but might be worse looking than before + return Constants.DEFAULT_HEX_SIZE + growth; } - private void SetupOrganelleGraphics() + private void RecalculateGrowthValue() { - organelleSceneInstance = (Spatial)Definition.LoadedScene!.Instance(); - - // Store animation player for later use - if (!string.IsNullOrEmpty(Definition.DisplaySceneAnimation)) - { - OrganelleAnimation = organelleSceneInstance.GetNode(Definition.DisplaySceneAnimation); - } - - // Store the material of the organelle to be updated - organelleMaterial = organelleSceneInstance.GetMaterial(Definition.DisplaySceneModelPath); - UpdateRenderPriority(Hex.GetRenderPriority(Position)); - - // There is an intermediate node so that the organelle scene root rotation and scale work - OrganelleGraphics = new Spatial(); - OrganelleGraphics.AddChild(organelleSceneInstance); - - AddChild(OrganelleGraphics); - - OrganelleGraphics.Scale = new Vector3(Constants.DEFAULT_HEX_SIZE, Constants.DEFAULT_HEX_SIZE, - Constants.DEFAULT_HEX_SIZE); - - // Position the intermediate node relative to origin of cell - var transform = new Transform(Quat.Identity, - Hex.AxialToCartesian(Position) + Definition.CalculateModelOffset()); - - OrganelleGraphics.Transform = transform; + growthValueDirty = false; - // For some reason MathUtils.CreateRotationForOrganelle(Orientation) in the above transform doesn't work - OrganelleGraphics.RotateY(Orientation * -60 * MathUtils.DEGREES_TO_RADIANS); + growthValue = 1.0f - CalculateCompoundsLeft() / Definition.OrganelleCost; } } diff --git a/src/microbe_stage/PlayerMicrobeInput.cs b/src/microbe_stage/PlayerMicrobeInput.cs index 011140597b9..57e076d5e03 100644 --- a/src/microbe_stage/PlayerMicrobeInput.cs +++ b/src/microbe_stage/PlayerMicrobeInput.cs @@ -1,5 +1,7 @@ using System; using System.Linq; +using Components; +using DefaultEcs; using Godot; /// @@ -50,14 +52,18 @@ public void OnMovement(float delta, float forwardMovement, float leftRightMoveme autoMove = false; } - var player = stage.Player; - if (player != null) + if (stage.HasPlayer) { - if (player.State == MicrobeState.Unbinding) + var player = stage.Player; + + ref var position = ref player.Get(); + ref var control = ref player.Get(); + + if (control.State == MicrobeState.Unbinding) { // It's probably fine to not update the tutorial state here with events as this state doesn't last // that long and the player needs a pretty long time to get so far in the game as to get here - player.MovementDirection = Vector3.Zero; + control.MovementDirection = Vector3.Zero; return; } @@ -75,127 +81,167 @@ public void OnMovement(float delta, float forwardMovement, float leftRightMoveme if (inputMethod == ActiveInputMethod.Controller) { // TODO: look direction for controller input https://github.com/Revolutionary-Games/Thrive/issues/4034 - player.LookAtPoint = player.GlobalTranslation + new Vector3(0, 0, -10); + control.LookAtPoint = position.Position + new Vector3(0, 0, -10); } else { - player.LookAtPoint = stage.Camera.CursorWorldPos; + control.LookAtPoint = stage.Camera.CursorWorldPos; } // Rotate the inputs when we want to use screen relative movement to make it happen if (screenRelative) { - // Rotate the opposite of the player orientation to get back to screen - movement = player.GlobalTransform.basis.Quat().Inverse().Xform(movement); + // Rotate the opposite of the player orientation to get back to screen (as when applying movement + // vector the normal rotation is used to rotate the movement direction so these two operations cancel + // out) + movement = position.Rotation.Inverse().Xform(movement); } if (autoMove) { - player.MovementDirection = new Vector3(0, 0, -1); + control.MovementDirection = new Vector3(0, 0, -1); } else { // We only normalize when the length is over to make moving slowly with a controller work - player.MovementDirection = movement.Length() > 1 ? movement.Normalized() : movement; + control.MovementDirection = movement.Length() > 1 ? movement.Normalized() : movement; } stage.TutorialState.SendEvent(TutorialEventType.MicrobePlayerMovement, - new MicrobeMovementEventArgs(screenRelative, player.MovementDirection, - player.LookAtPoint - player.GlobalTranslation), this); + new MicrobeMovementEventArgs(screenRelative, control.MovementDirection, + control.LookAtPoint - position.Position), this); } } [RunOnKeyDown("g_fire_toxin")] public void EmitToxin() { - stage.Player?.EmitToxin(); + if (!stage.HasPlayer) + return; + + ref var control = ref stage.Player.Get(); + ref var compoundStorage = ref stage.Player.Get(); + + control.EmitToxin(ref stage.Player.Get(), compoundStorage.Compounds, stage.Player); } [RunOnKey("g_secrete_slime")] public void SecreteSlime(float delta) { - stage.Player?.QueueSecreteSlime(delta); + if (!stage.HasPlayer) + return; + + ref var control = ref stage.Player.Get(); + + control.QueueSecreteSlime(ref stage.Player.Get(), stage.Player, delta); } [RunOnKeyDown("g_toggle_engulf")] public void ToggleEngulf() { - if (stage.Player == null) + if (!stage.HasPlayer) return; - if (stage.Player.State == MicrobeState.Engulf) + ref var control = ref stage.Player.Get(); + ref var cellProperties = ref stage.Player.Get(); + + if (control.State == MicrobeState.Engulf) { - stage.Player.State = MicrobeState.Normal; + control.State = MicrobeState.Normal; } - else if (stage.Player.CanEngulfInColony()) + else if (cellProperties.CanEngulfInColony(stage.Player)) { - stage.Player.State = MicrobeState.Engulf; + control.State = MicrobeState.Engulf; } } [RunOnKeyDown("g_toggle_binding")] public void ToggleBinding() { - if (stage.Player == null) + if (!stage.HasPlayer) return; - if (stage.Player.State == MicrobeState.Binding) + ref var control = ref stage.Player.Get(); + ref var organelles = ref stage.Player.Get(); + + if (control.State == MicrobeState.Binding) { - stage.Player.State = MicrobeState.Normal; + control.State = MicrobeState.Normal; } - else if (stage.Player.CanBind) + else if (organelles.HasBindingAgent) { - stage.Player.State = MicrobeState.Binding; + control.State = MicrobeState.Binding; } } [RunOnKeyDown("g_toggle_unbinding")] public void ToggleUnbinding() { - if (stage.Player == null) + if (!stage.HasPlayer) return; - if (stage.Player.State == MicrobeState.Unbinding) + ref var control = ref stage.Player.Get(); + + if (control.State == MicrobeState.Unbinding) { stage.HUD.HintText = string.Empty; - stage.Player.State = MicrobeState.Normal; + control.State = MicrobeState.Normal; } - else if (stage.Player.Colony != null && !stage.Player.IsMulticellular) + else if (stage.Player.Has() && stage.GameWorld.PlayerSpecies is MicrobeSpecies) { stage.HUD.HintText = TranslationServer.Translate("UNBIND_HELP_TEXT"); - stage.Player.State = MicrobeState.Unbinding; + control.State = MicrobeState.Unbinding; } } [RunOnKeyDown("g_unbind_all")] public void UnbindAll() { - stage.Player?.UnbindAll(); + if (!stage.HasPlayer) + return; + + if (stage.Player.Has()) + { + throw new NotImplementedException(); + + // stage.Player?.UnbindAll(); + } } [RunOnKeyDown("g_perform_unbinding", Priority = 1)] public bool AcceptUnbind() { - if (stage.Player?.State != MicrobeState.Unbinding) + if (!stage.HasPlayer) return false; - var inspectables = stage.HoverInfo.InspectableEntities.ToList(); - if (inspectables.Count == 0) + ref var control = ref stage.Player.Get(); + + if (control.State != MicrobeState.Unbinding) return false; - var target = inspectables[0]; - if (target is not Microbe microbe) + var target = stage.HoverInfo.Entities.FirstOrDefault(); + if (target == default || !target.IsAlive) return false; - var raycastData = stage.HoverInfo.GetRaycastData(target); - if (raycastData == null) + // This checks for the microbe species member as all cell colonies are merged to have a single physics body + // so this always hits the colony lead cell + if (!target.IsAlive || !target.Has()) return false; - var actualMicrobe = microbe.GetMicrobeFromShape(raycastData.Value.Shape); - if (actualMicrobe == null) + // If didn't hit a cell colony, can't do anything + if (!target.Has()) return false; - RemoveCellFromColony(actualMicrobe); + if (!stage.HoverInfo.GetRaycastData(target, out var raycastData)) + return false; + + throw new NotImplementedException(); + + // var actualMicrobe = microbe.GetMicrobeFromShape(raycastData.Value.Shape); + // if (actualMicrobe == null) + // return false; + // + // RemoveCellFromColony(actualMicrobe); stage.HUD.HintText = string.Empty; return true; @@ -204,7 +250,12 @@ public bool AcceptUnbind() [RunOnKeyDown("g_pack_commands")] public bool ShowSignalingCommandsMenu() { - if (stage.Player?.HasSignalingAgent != true) + if (!stage.HasPlayer) + return false; + + ref var organelles = ref stage.Player.Get(); + + if (!organelles.HasSignalingAgent) return false; stage.HUD.ShowSignalingCommandsMenu(stage.Player); @@ -218,7 +269,7 @@ public void CloseSignalingCommandsMenu() { var command = stage.HUD.SelectSignalCommandIfOpen(); - if (stage.Player != null) + if (stage.HasPlayer) stage.HUD.ApplySignalCommand(command, stage.Player); } @@ -258,15 +309,17 @@ public void CheatPhosphates(float delta) } } - private void RemoveCellFromColony(Microbe target) + private void RemoveCellFromColony(Entity target) { - if (target.Colony == null) - { - GD.PrintErr("Target microbe is not a part of colony"); - return; - } - - target.Colony.RemoveFromColony(target); + throw new NotImplementedException(); + + // if (target.Colony == null) + // { + // GD.PrintErr("Target microbe is not a part of colony"); + // return; + // } + // + // target.Colony.RemoveFromColony(target); } private void SpawnCheatCloud(string name, float delta) @@ -274,7 +327,7 @@ private void SpawnCheatCloud(string name, float delta) float multiplier = 1.0f; // To make cheating easier in multicellular with large cell layouts - if (stage.Player?.IsMulticellular == true) + if (stage.GameWorld.PlayerSpecies is not MicrobeSpecies) multiplier = 4; stage.Clouds.AddCloud(SimulationParameters.Instance.GetCompound(name), diff --git a/src/microbe_stage/ProcessSystem.cs b/src/microbe_stage/ProcessSystem.cs index b56e30730e1..5f282702bb0 100644 --- a/src/microbe_stage/ProcessSystem.cs +++ b/src/microbe_stage/ProcessSystem.cs @@ -1,650 +1 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Godot; - -/// -/// Runs processes in parallel on entities -/// -public class ProcessSystem -{ - private static readonly Compound ATP = SimulationParameters.Instance.GetCompound("atp"); - private static readonly Compound Temperature = SimulationParameters.Instance.GetCompound("temperature"); - private static readonly Compound Sunlight = SimulationParameters.Instance.GetCompound("sunlight"); - private readonly List tasks = new(); - - private readonly Node worldRoot; - private BiomeConditions? biome; - - public ProcessSystem(Node worldRoot) - { - this.worldRoot = worldRoot; - } - - /// - /// Computes the process efficiency numbers for given organelles given the active biome data. - /// specifies how changes during an in-game day are taken into account. - /// - public static Dictionary ComputeOrganelleProcessEfficiencies( - IEnumerable organelles, BiomeConditions biome, CompoundAmountType amountType) - { - var result = new Dictionary(); - - foreach (var organelle in organelles) - { - var info = new OrganelleEfficiency(organelle); - - foreach (var process in organelle.RunnableProcesses) - { - info.Processes.Add(CalculateProcessMaximumSpeed(process, biome, amountType)); - } - - result[organelle.InternalName] = info; - } - - return result; - } - - /// - /// Computes the energy balance for the given organelles in biome and at a given time during the day (or type - /// can be specified to be a different type of value) - /// - public static EnergyBalanceInfo ComputeEnergyBalance(IEnumerable organelles, - BiomeConditions biome, MembraneType membrane, bool isPlayerSpecies, - WorldGenerationSettings worldSettings, CompoundAmountType amountType) - { - var organellesList = organelles.ToList(); - - var maximumMovementDirection = MicrobeInternalCalculations.MaximumSpeedDirection(organellesList); - return ComputeEnergyBalance(organellesList, biome, membrane, maximumMovementDirection, isPlayerSpecies, - worldSettings, amountType); - } - - /// - /// Computes the energy balance for the given organelles in biome - /// - /// The organelles to compute the balance with - /// The conditions the organelles are simulated in - /// The membrane type to adjust the energy balance with - /// - /// Only movement organelles that can move in this (cell origin relative) direction are calculated. Other - /// movement organelles are assumed to be inactive in the balance calculation. - /// - /// Whether this microbe is a member of the player's species - /// The world generation settings for this game - /// Specifies how changes during an in-game day are taken into account - public static EnergyBalanceInfo ComputeEnergyBalance(IEnumerable organelles, - BiomeConditions biome, MembraneType membrane, Vector3 onlyMovementInDirection, - bool isPlayerSpecies, WorldGenerationSettings worldSettings, CompoundAmountType amountType) - { - var result = new EnergyBalanceInfo(); - - float processATPProduction = 0.0f; - float processATPConsumption = 0.0f; - float movementATPConsumption = 0.0f; - - int hexCount = 0; - - foreach (var organelle in organelles) - { - foreach (var process in organelle.Definition.RunnableProcesses) - { - var processData = CalculateProcessMaximumSpeed(process, biome, amountType); - - if (processData.WritableInputs.TryGetValue(ATP, out var amount)) - { - processATPConsumption += amount; - - result.AddConsumption(organelle.Definition.InternalName, amount); - } - - if (processData.WritableOutputs.TryGetValue(ATP, out amount)) - { - processATPProduction += amount; - - result.AddProduction(organelle.Definition.InternalName, amount); - } - } - - // Take special cell components that take energy into account - if (organelle.Definition.HasMovementComponent) - { - var amount = Constants.FLAGELLA_ENERGY_COST; - - var organelleDirection = MicrobeInternalCalculations.GetOrganelleDirection(organelle); - if (organelleDirection.Dot(onlyMovementInDirection) > 0) - { - movementATPConsumption += amount; - result.Flagella += amount; - result.AddConsumption(organelle.Definition.InternalName, amount); - } - } - - if (organelle.Definition.HasCiliaComponent) - { - var amount = Constants.CILIA_ENERGY_COST; - - movementATPConsumption += amount; - result.Cilia += amount; - result.AddConsumption(organelle.Definition.InternalName, amount); - } - - // Store hex count - hexCount += organelle.Definition.HexCount; - } - - // Add movement consumption together - result.BaseMovement = Constants.BASE_MOVEMENT_ATP_COST * hexCount; - result.AddConsumption("baseMovement", result.BaseMovement); - result.TotalMovement = movementATPConsumption + result.BaseMovement; - - // Add osmoregulation - result.Osmoregulation = Constants.ATP_COST_FOR_OSMOREGULATION * hexCount * - membrane.OsmoregulationFactor; - - if (isPlayerSpecies) - { - result.Osmoregulation *= worldSettings.OsmoregulationMultiplier; - } - - result.AddConsumption("osmoregulation", result.Osmoregulation); - - // Compute totals - result.TotalProduction = processATPProduction; - result.TotalConsumptionStationary = processATPConsumption + result.Osmoregulation; - result.TotalConsumption = result.TotalConsumptionStationary + result.TotalMovement; - - result.FinalBalance = result.TotalProduction - result.TotalConsumption; - result.FinalBalanceStationary = result.TotalProduction - result.TotalConsumptionStationary; - - return result; - } - - /// - /// Computes the compound balances for given organelle list in a patch and at a given time during the day (or - /// using longer timespan values) - /// - /// - /// - /// Assumes that all processes run at maximum speed - /// - /// - public static Dictionary ComputeCompoundBalance( - IEnumerable organelles, BiomeConditions biome, CompoundAmountType amountType) - { - var result = new Dictionary(); - - void MakeSureResultExists(Compound compound) - { - if (!result.ContainsKey(compound)) - { - result[compound] = new CompoundBalance(); - } - } - - foreach (var organelle in organelles) - { - foreach (var process in organelle.RunnableProcesses) - { - var speedAdjusted = CalculateProcessMaximumSpeed(process, biome, amountType); - - foreach (var input in speedAdjusted.Inputs) - { - MakeSureResultExists(input.Key); - result[input.Key].AddConsumption(organelle.InternalName, input.Value); - } - - foreach (var output in speedAdjusted.Outputs) - { - MakeSureResultExists(output.Key); - result[output.Key].AddProduction(organelle.InternalName, output.Value); - } - } - } - - return result; - } - - public static Dictionary ComputeCompoundBalance( - IEnumerable organelles, BiomeConditions biome, CompoundAmountType amountType) - { - return ComputeCompoundBalance(organelles.Select(o => o.Definition), biome, amountType); - } - - /// - /// Computes the compound balances for given organelle list in a patch and at a given time during the day (or - /// using longer timespan values) - /// - /// - /// - /// Assumes that the cell produces at most as much ATP as it consumes - /// - /// - public static Dictionary ComputeCompoundBalanceAtEquilibrium( - IEnumerable organelles, BiomeConditions biome, CompoundAmountType amountType, - EnergyBalanceInfo energyBalance) - { - var result = new Dictionary(); - - void MakeSureResultExists(Compound compound) - { - if (!result.ContainsKey(compound)) - { - result[compound] = new CompoundBalance(); - } - } - - float consumptionProductionRatio = energyBalance.TotalConsumption / energyBalance.TotalProduction; - bool useRatio; - - foreach (var organelle in organelles) - { - foreach (var process in organelle.RunnableProcesses) - { - var speedAdjusted = CalculateProcessMaximumSpeed(process, biome, amountType); - - useRatio = false; - - // If the cell produces more ATP than it needs, its ATP producing processes need to be toned down - if (speedAdjusted.Outputs.ContainsKey(ATP) && consumptionProductionRatio < 1.0f) - useRatio = true; - - foreach (var input in speedAdjusted.Inputs) - { - if (input.Key == ATP) - continue; - - float amount = input.Value; - - if (useRatio) - amount *= consumptionProductionRatio; - - MakeSureResultExists(input.Key); - result[input.Key].AddConsumption(organelle.InternalName, amount); - } - - foreach (var output in speedAdjusted.Outputs) - { - if (output.Key == ATP) - continue; - - float amount = output.Value; - - if (useRatio) - amount *= consumptionProductionRatio; - - MakeSureResultExists(output.Key); - result[output.Key].AddProduction(organelle.InternalName, amount); - } - } - } - - return result; - } - - public static Dictionary ComputeCompoundBalanceAtEquilibrium( - IEnumerable organelles, BiomeConditions biome, CompoundAmountType amountType, - EnergyBalanceInfo energyBalance) - { - return ComputeCompoundBalanceAtEquilibrium(organelles.Select(o => o.Definition), biome, amountType, - energyBalance); - } - - /// - /// Calculates the maximum speed a process can run at in a biome based on the environmental compounds. - /// Can be switched between the average, maximum etc. conditions that occur in the span of an in-game day. - /// - public static ProcessSpeedInformation CalculateProcessMaximumSpeed(TweakedProcess process, - BiomeConditions biome, CompoundAmountType pointInTimeType) - { - var result = new ProcessSpeedInformation(process.Process); - - float speedFactor = 1.0f; - float efficiency = 1.0f; - - // Environmental inputs need to be processed first - foreach (var input in process.Process.Inputs) - { - if (!input.Key.IsEnvironmental) - continue; - - // Environmental compound that can limit the rate - var availableInEnvironment = GetAmbientInBiome(input.Key, biome, pointInTimeType); - - var availableRate = input.Key == Temperature ? - CalculateTemperatureEffect(availableInEnvironment) : - availableInEnvironment / input.Value; - - result.AvailableAmounts[input.Key] = availableInEnvironment; - - efficiency *= availableInEnvironment; - - // More than needed environment value boosts the effectiveness - result.AvailableRates[input.Key] = availableRate; - - speedFactor *= availableRate; - - result.WritableInputs[input.Key] = input.Value; - } - - result.Efficiency = efficiency; - - speedFactor *= process.Rate; - - // Note that we don't consider storage constraints here so we don't use spaceConstraintModifier calculations - - // So that the speed factor is available here - foreach (var entry in process.Process.Inputs) - { - if (entry.Key.IsEnvironmental) - continue; - - // Normal, cloud input - - result.WritableInputs.Add(entry.Key, entry.Value * speedFactor); - } - - foreach (var entry in process.Process.Outputs) - { - var amount = entry.Value * speedFactor; - - result.WritableOutputs[entry.Key] = amount; - - if (amount <= 0) - result.WritableLimitingCompounds.Add(entry.Key); - } - - result.CurrentSpeed = speedFactor; - - return result; - } - - public void Process(float delta) - { - if (biome == null) - { - GD.PrintErr("ProcessSystem has no biome set"); - return; - } - - var nodes = worldRoot.GetTree().GetNodesInGroup(Constants.PROCESS_GROUP); - var nodeCount = nodes.Count; - - // Used to go from the calculated compound values to per second values for reporting statistics - float inverseDelta = 1.0f / delta; - - // The objects are processed here in order to take advantage of threading - var executor = TaskExecutor.Instance; - - for (int i = 0; i < nodeCount; i += Constants.PROCESS_OBJECTS_PER_TASK) - { - int start = i; - - var task = new Task(() => - { - for (int a = start; - a < start + Constants.PROCESS_OBJECTS_PER_TASK && a < nodeCount; ++a) - { - ProcessNode(nodes[a] as IProcessable, delta, inverseDelta); - } - }); - - tasks.Add(task); - } - - // Start and wait for tasks to finish - executor.RunTasks(tasks); - tasks.Clear(); - } - - /// - /// Sets the biome whose environmental values affect processes - /// - public void SetBiome(BiomeConditions newBiome) - { - biome = newBiome; - } - - /// - /// Get the current amount of environmental compound - /// - public float GetAmbient(Compound compound, CompoundAmountType amountType) - { - if (biome == null) - throw new InvalidOperationException("Biome needs to be set before getting ambient compounds"); - - return GetAmbientInBiome(compound, biome, amountType); - } - - private static float GetAmbientInBiome(Compound compound, BiomeConditions biome, CompoundAmountType amountType) - { - if (!biome.TryGetCompound(compound, amountType, out var environmentalCompoundProperties)) - return 0; - - return environmentalCompoundProperties.Ambient; - } - - /// - /// Since temperature works differently to other compounds, we use this method to deal with it. Logic here - /// is liable to be updated in the future to use alternative effect models. - /// - private static float CalculateTemperatureEffect(float temperature) - { - // Assume thermosynthetic processes are most efficient at 100°C and drop off linearly to zero - var optimal = 100; - return Mathf.Clamp(temperature / optimal, 0, 2 - temperature / optimal); - } - - private void ProcessNode(IProcessable? processor, float delta, float inverseDelta) - { - if (processor == null) - { - GD.PrintErr("A node has been put in the process group but it isn't derived from IProcessable"); - return; - } - - var bag = processor.ProcessCompoundStorage; - - // Set all compounds to not be useful, when some compound is - // used it will be marked useful - bag.ClearUseful(); - - var processStatistics = processor.ProcessStatistics; - - processStatistics?.MarkAllUnused(); - - foreach (var process in processor.ActiveProcesses) - { - // If rate is 0 dont do it - // The rate specifies how fast fraction of the specified process numbers this cell can do - // TODO: would be nice still to report these to process statistics - if (process.Rate <= 0.0f) - continue; - - // TODO: reporting duplicate process types would be nice in debug mode here - - var processData = process.Process; - - var currentProcessStatistics = processStatistics?.GetAndMarkUsed(process.Process); - currentProcessStatistics?.BeginFrame(delta); - - RunProcess(delta, processData, bag, process, currentProcessStatistics, inverseDelta); - } - - bag.ClampNegativeCompoundAmounts(); - bag.FixNaNCompounds(); - - processStatistics?.RemoveUnused(); - } - - private void RunProcess(float delta, BioProcess processData, CompoundBag bag, TweakedProcess process, - SingleProcessStatistics? currentProcessStatistics, float inverseDelta) - { - // Can your cell do the process - bool canDoProcess = true; - - float environmentModifier = 1.0f; - - // This modifies the process overall speed to allow really fast processes to run, for example if there are - // a ton of one organelle it might consume 100 glucose per go, which might be unlikely for the cell to have - // so if there is *some* but not enough space for results (and also inputs) this can run the process as - // fraction of the speed to allow the cell to still function well - float spaceConstraintModifier = 1.0f; - - // First check the environmental compounds so that we can build the right environment modifier for accurate - // check of normal compound input amounts - foreach (var entry in processData.Inputs) - { - // Set used compounds to be useful, we dont want to purge those - bag.SetUseful(entry.Key); - - if (!entry.Key.IsEnvironmental) - continue; - - // Processing runs on the current game time following values - var ambient = GetAmbient(entry.Key, CompoundAmountType.Current); - - // currentProcessStatistics?.AddInputAmount(entry.Key, entry.Value * inverseDelta); - currentProcessStatistics?.AddInputAmount(entry.Key, ambient); - - // do environmental modifier here, and save it for later - environmentModifier *= entry.Key == Temperature ? - CalculateTemperatureEffect(ambient) : - ambient / entry.Value; - - if (environmentModifier <= MathUtils.EPSILON) - currentProcessStatistics?.AddLimitingFactor(entry.Key); - } - - if (environmentModifier <= MathUtils.EPSILON) - canDoProcess = false; - - // Compute spaceConstraintModifier before updating the final use and input amounts - foreach (var entry in processData.Inputs) - { - if (entry.Key.IsEnvironmental) - continue; - - var inputRemoved = entry.Value * process.Rate * environmentModifier; - - // currentProcessStatistics?.AddInputAmount(entry.Key, 0); - // We don't multiply by delta here because we report the per-second values anyway. In the actual process - // output numbers (computed after testing the speed), we need to multiply by inverse delta - currentProcessStatistics?.AddInputAmount(entry.Key, inputRemoved); - - inputRemoved = inputRemoved * delta * spaceConstraintModifier; - - // If not enough we can't run the process unless we can lower spaceConstraintModifier enough - var availableAmount = bag.GetCompoundAmount(entry.Key); - if (availableAmount < inputRemoved) - { - bool canRun = false; - - if (availableAmount > MathUtils.EPSILON) - { - var neededModifier = availableAmount / inputRemoved; - - if (neededModifier > Constants.MINIMUM_RUNNABLE_PROCESS_FRACTION) - { - spaceConstraintModifier = neededModifier; - canRun = true; - - // Due to rounding errors there can be very small disparity here between the amount available - // and what we will take with the modifiers. See the comment in outputs for more details - } - } - - if (!canRun) - { - canDoProcess = false; - currentProcessStatistics?.AddLimitingFactor(entry.Key); - } - } - } - - foreach (var entry in processData.Outputs) - { - // For now lets assume compounds we produce are also useful - bag.SetUseful(entry.Key); - - var outputAdded = entry.Value * process.Rate * environmentModifier; - - // currentProcessStatistics?.AddOutputAmount(entry.Key, 0); - currentProcessStatistics?.AddOutputAmount(entry.Key, outputAdded); - - outputAdded = outputAdded * delta * spaceConstraintModifier; - - // if environmental right now this isn't released anywhere - if (entry.Key.IsEnvironmental) - continue; - - // If no space we can't do the process, if we can't adjust the space constraint modifier enough - var remainingSpace = bag.GetCapacityForCompound(entry.Key) - bag.GetCompoundAmount(entry.Key); - if (outputAdded > remainingSpace) - { - bool canRun = false; - - if (remainingSpace > MathUtils.EPSILON) - { - var neededModifier = remainingSpace / outputAdded; - - if (neededModifier > Constants.MINIMUM_RUNNABLE_PROCESS_FRACTION) - { - spaceConstraintModifier = neededModifier; - canRun = true; - } - - // With all of the modifiers we can lose a tiny bit of compound that won't fit due to rounding - // errors, but we ignore that here - } - - if (!canRun) - { - canDoProcess = false; - currentProcessStatistics?.AddCapacityProblem(entry.Key); - } - } - } - - // Only carry out this process if you have all the required ingredients and enough space for the outputs - if (!canDoProcess) - { - if (currentProcessStatistics != null) - currentProcessStatistics.CurrentSpeed = 0; - return; - } - - float totalModifier = process.Rate * delta * environmentModifier * spaceConstraintModifier; - - if (currentProcessStatistics != null) - currentProcessStatistics.CurrentSpeed = process.Rate * environmentModifier * spaceConstraintModifier; - - // Consume inputs - foreach (var entry in processData.Inputs) - { - if (entry.Key.IsEnvironmental) - continue; - - var inputRemoved = entry.Value * totalModifier; - - currentProcessStatistics?.AddInputAmount(entry.Key, inputRemoved * inverseDelta); - - // This should always succeed (due to the earlier check) so it is always assumed here that this succeeded - bag.TakeCompound(entry.Key, inputRemoved); - } - - // Add outputs - foreach (var entry in processData.Outputs) - { - if (entry.Key.IsEnvironmental) - continue; - - var outputGenerated = entry.Value * totalModifier; - - currentProcessStatistics?.AddOutputAmount(entry.Key, outputGenerated * inverseDelta); - - bag.AddCompound(entry.Key, outputGenerated); - } - } -} + \ No newline at end of file diff --git a/src/microbe_stage/SpawnSystem.cs b/src/microbe_stage/SpawnSystem.cs deleted file mode 100644 index c213d34879a..00000000000 --- a/src/microbe_stage/SpawnSystem.cs +++ /dev/null @@ -1,607 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Godot; -using Newtonsoft.Json; -using Nito.Collections; - -/// -/// Spawns AI cells and other environmental things as the player moves around -/// -[JsonObject(IsReference = true)] -public class SpawnSystem : ISpawnSystem -{ - /// - /// Sets how often the spawn system runs and checks things - /// - [JsonProperty] - private float interval = 1.0f; - - [JsonProperty] - private float elapsed; - - [JsonProperty] - private float despawnElapsed; - - /// - /// Root node to parent all spawned things to - /// - private Node worldRoot; - - private ShuffleBag spawnTypes; - - [JsonProperty] - private Random random = new(); - - /// - /// This is used to spawn only a few entities per frame with minimal changes needed to code that wants to - /// spawn a bunch of stuff at once - /// - /// - /// - /// This isn't saved but the likelihood that losing out on spawning some things is not super critical. - /// Also it is probably the case that this isn't even used on most frames so it is perhaps uncommon - /// that there are queued things when saving. - /// - /// - private Deque queuedSpawns = new(); - - /// - /// Estimate count of existing spawned entities, cached to make delayed spawns cheaper - /// - private float estimateEntityCount; - - /// - /// Estimate count of existing spawn entities within the current spawn radius of the player; - /// Used to prevent a "spawn belt" of densely spawned entities when player doesn't move. - /// - [JsonProperty] - private HashSet coordinatesSpawned = new(); - - public SpawnSystem(Node root) - { - worldRoot = root; - spawnTypes = new ShuffleBag(random); - } - - public void Init() - { - Clear(); - } - - /// - /// Adds a new spawner. Sets up the spawn radius, this radius squared, - /// and frequency fields based on the parameters of this - /// function. - /// - public void AddSpawnType(Spawner spawner, float spawnDensity, int spawnRadius) - { - spawner.SpawnRadius = spawnRadius; - spawner.SpawnRadiusSquared = spawnRadius * spawnRadius; - - float minSpawnRadius = spawnRadius * Constants.MIN_SPAWN_RADIUS_RATIO; - spawner.MinSpawnRadiusSquared = minSpawnRadius * minSpawnRadius; - spawner.Density = spawnDensity; - - spawnTypes.Add(spawner); - } - - /// - /// Removes a spawn type immediately. Note that it's easier to just set DestroyQueued to true on an spawner. - /// - public void RemoveSpawnType(Spawner spawner) - { - spawnTypes.Remove(spawner); - } - - public void Clear() - { - spawnTypes.Clear(); - - foreach (var queuedSpawn in queuedSpawns) - queuedSpawn.Dispose(); - - queuedSpawns.Clear(); - - elapsed = 0; - despawnElapsed = 0; - } - - public void DespawnAll() - { - ClearSpawnQueue(); - float despawned = 0.0f; - - foreach (var spawned in worldRoot.GetChildrenToProcess(Constants.SPAWNED_GROUP)) - { - if (!spawned.EntityNode.IsQueuedForDeletion()) - { - despawned += spawned.EntityWeight; - spawned.DestroyDetachAndQueueFree(); - } - } - - var debugOverlay = DebugOverlays.Instance; - - if (debugOverlay.PerformanceMetricsVisible) - debugOverlay.ReportDespawns(despawned); - - ClearSpawnCoordinates(); - } - - /// - /// Clears all of the queued spawns. For use when the queue might contain something that - /// should not be allowed to spawn. - /// - public void ClearSpawnQueue() - { - foreach (var queuedSpawn in queuedSpawns) - queuedSpawn.Dispose(); - - queuedSpawns.Clear(); - } - - /// - /// Forgets all record of where clouds have spawned, so clouds can spawn anywhere. - /// - public void ClearSpawnCoordinates() - { - coordinatesSpawned.Clear(); - } - - public void Process(float delta, Vector3 playerPosition) - { - elapsed += delta; - despawnElapsed += delta; - - // Remove the y-position from player position - playerPosition.y = 0; - - float spawnsLeftThisFrame = Constants.MAX_SPAWNS_PER_FRAME; - - // If we have queued spawns to do spawn those - HandleQueuedSpawns(ref spawnsLeftThisFrame, playerPosition); - - if (spawnsLeftThisFrame <= 0) - return; - - // This is now an if to make sure that the spawn system is - // only ran once per frame to avoid spawning a bunch of stuff - // all at once after a lag spike - // NOTE: that as QueueFree is used it's not safe to just switch this to a loop - if (elapsed >= interval) - { - elapsed -= interval; - - estimateEntityCount = DespawnEntities(playerPosition); - - spawnTypes.RemoveAll(entity => entity.DestroyQueued); - - SpawnAllTypes(playerPosition, ref spawnsLeftThisFrame); - } - else if (despawnElapsed > Constants.DESPAWN_INTERVAL) - { - despawnElapsed = 0; - - DespawnEntities(playerPosition); - } - } - - public void AddEntityToTrack(ISpawned entity) - { - entity.DespawnRadiusSquared = Constants.MICROBE_DESPAWN_RADIUS_SQUARED; - entity.EntityNode.AddToGroup(Constants.SPAWNED_GROUP); - - // Update entity count estimate to keep this about up to date, this will be corrected within a few seconds - // with the next spawn cycle to be exactly correct - estimateEntityCount += entity.EntityWeight; - } - - public bool IsUnderEntityLimitForReproducing() - { - return estimateEntityCount < Settings.Instance.MaxSpawnedEntities.Value * - Constants.REPRODUCTION_ALLOW_EXCEED_ENTITY_LIMIT_MULTIPLIER; - } - - /// - /// Ensures that the entity limit is not overfilled by a lot after player reproduction by force despawning things - /// - public void EnsureEntityLimitAfterPlayerReproduction(Vector3 playerPosition, ISpawned? doNotDespawn) - { - // Take the just spawned thing we shouldn't despawn into account in the entity count as our estimate won't - // likely include it yet - var extra = doNotDespawn?.EntityWeight ?? 0; - - var entityLimit = Settings.Instance.MaxSpawnedEntities.Value; - - float limitExcess = estimateEntityCount + extra - entityLimit * - Constants.REPRODUCTION_PLAYER_ALLOWED_ENTITY_LIMIT_EXCEED; - - if (limitExcess < 1) - return; - - // We need to despawn something - GD.Print("After player reproduction entity limit is exceeded, will force despawn something"); - - float playerReproductionWeight = 0; - - var playerReproducedEntities = new List(); - - foreach (var spawned in worldRoot.GetChildrenToProcess(Constants.PLAYER_REPRODUCED_GROUP)) - { - if (spawned.EntityNode.IsQueuedForDeletion()) - continue; - - playerReproductionWeight += spawned.EntityWeight; - - if (spawned != doNotDespawn) - { - playerReproducedEntities.Add(spawned); - } - } - - // Despawn one player reproduced copy first if the player reproduced copies are taking up a ton of space - if (playerReproductionWeight > entityLimit * Constants.PREFER_DESPAWN_PLAYER_REPRODUCED_COPY_AFTER && - playerReproducedEntities.Count > 0) - { - var despawn = playerReproducedEntities - .OrderByDescending(s => s.EntityNode.GlobalTranslation.DistanceSquaredTo(playerPosition)).First(); - - var weight = despawn.EntityWeight; - estimateEntityCount -= weight; - limitExcess -= weight; - despawn.DestroyDetachAndQueueFree(); - } - - if (limitExcess <= 1) - return; - - // We take weight as well as distance into account here to not just despawn a ton of really far away objects - // with weight of 1 - using var deSpawnableEntities = worldRoot.GetChildrenToProcess(Constants.SPAWNED_GROUP) - .OrderByDescending(s => - Math.Log(s.EntityNode.GlobalTranslation.DistanceSquaredTo(playerPosition)) + Math.Log(s.EntityWeight)) - .GetEnumerator(); - - // Then try to despawn enough stuff for us to get under the limit - while (limitExcess >= 1) - { - ISpawned? bestCandidate = null; - - if (deSpawnableEntities.MoveNext() && deSpawnableEntities.Current != null) - bestCandidate = deSpawnableEntities.Current; - - if (bestCandidate == doNotDespawn || bestCandidate?.EntityNode.IsQueuedForDeletion() == true) - continue; - - if (bestCandidate != null) - { - var weight = bestCandidate.EntityWeight; - estimateEntityCount -= weight; - limitExcess -= weight; - bestCandidate.DestroyDetachAndQueueFree(); - - continue; - } - - // If we couldn't despawn anything sensible, give up - GD.PrintErr("Force despawning could not find enough things to despawn"); - break; - } - } - - private void HandleQueuedSpawns(ref float spawnsLeftThisFrame, Vector3 playerPosition) - { - float spawned = 0.0f; - - // Spawn from the queue - while (spawnsLeftThisFrame > 0 && queuedSpawns.Count > 0) - { - var spawn = queuedSpawns.First(); - var enumerator = spawn.Spawns; - - bool finished = false; - - while (estimateEntityCount < Settings.Instance.MaxSpawnedEntities.Value && - spawnsLeftThisFrame > 0) - { - if (!enumerator.MoveNext()) - { - finished = true; - break; - } - - if (enumerator.Current == null) - throw new Exception("Queued spawn enumerator returned null"); - - // Discard the whole spawn if we're too close to the player - var entityPosition = ((Spatial)enumerator.Current).GlobalTransform.origin; - if ((playerPosition - entityPosition).Length() < Constants.SPAWN_SECTOR_SIZE) - { - enumerator.Current.DestroyDetachAndQueueFree(); - finished = true; - break; - } - - // Next was spawned - ProcessSpawnedEntity(enumerator.Current, spawn.SpawnType); - - var weight = enumerator.Current.EntityWeight; - estimateEntityCount += weight; - spawnsLeftThisFrame -= weight; - spawned += weight; - } - - if (finished) - { - // Finished spawning everything from this enumerator, if we didn't finish we save this spawn for the - // next queued spawns handling cycle - queuedSpawns.RemoveFromFront(); - spawn.Dispose(); - } - else - { - break; - } - } - - if (spawned > 0) - { - var debugOverlay = DebugOverlays.Instance; - - if (debugOverlay.PerformanceMetricsVisible) - debugOverlay.ReportSpawns(spawned); - } - } - - private void SpawnAllTypes(Vector3 playerPosition, ref float spawnsLeftThisFrame) - { - var playerCoordinatePoint = new Tuple(Mathf.RoundToInt(playerPosition.x / - Constants.SPAWN_SECTOR_SIZE), Mathf.RoundToInt(playerPosition.z / Constants.SPAWN_SECTOR_SIZE)); - - // Spawn for all sectors immediately outside a 3x3 box around the player - var sectorsToSpawn = new List(12); - for (int y = -1; y <= 1; y++) - { - sectorsToSpawn.Add(new Int2(playerCoordinatePoint.Item1 - 2, playerCoordinatePoint.Item2 + y)); - } - - for (int x = -1; x <= 1; x++) - { - sectorsToSpawn.Add(new Int2(playerCoordinatePoint.Item1 + 2, playerCoordinatePoint.Item2 + x)); - } - - for (int y = -1; y <= 1; y++) - { - sectorsToSpawn.Add(new Int2(playerCoordinatePoint.Item1 + y, playerCoordinatePoint.Item2 - 2)); - } - - for (int x = -1; x <= 1; x++) - { - sectorsToSpawn.Add(new Int2(playerCoordinatePoint.Item1 + x, playerCoordinatePoint.Item2 + 2)); - } - - foreach (var newSector in sectorsToSpawn) - { - if (coordinatesSpawned.Add(newSector)) - { - SpawnInSector(newSector, ref spawnsLeftThisFrame); - } - } - - // Only spawn microbes around the player if below the threshold. - // This is to prioritize spawning in sectors. - float entitiesThreshold = Settings.Instance.MaxSpawnedEntities.Value * - Constants.ENTITY_SPAWNING_AROUND_PLAYER_THRESHOLD; - if (estimateEntityCount < entitiesThreshold) - { - SpawnMicrobesAroundPlayer(playerPosition, ref spawnsLeftThisFrame); - } - } - - /// - /// Handles all spawning for this section of the play area, as it will look when the player enters. Does NOT - /// handle recording that the sector was spawned. - /// - /// - /// X/Y coordinates of the sector to be spawned, in units - /// - /// How many spawns are still allowed this frame - private void SpawnInSector(Int2 sector, ref float spawnsLeftThisFrame) - { - float spawns = 0.0f; - - foreach (var spawnType in spawnTypes) - { - if (SpawnsBlocked(spawnType)) - continue; - - var sectorCenter = new Vector3(sector.x * Constants.SPAWN_SECTOR_SIZE, 0, - sector.y * Constants.SPAWN_SECTOR_SIZE); - - // Distance from the sector center. - var displacement = new Vector3(random.NextFloat() * Constants.SPAWN_SECTOR_SIZE - - (Constants.SPAWN_SECTOR_SIZE / 2), 0, - random.NextFloat() * Constants.SPAWN_SECTOR_SIZE - (Constants.SPAWN_SECTOR_SIZE / 2)); - - spawns += SpawnWithSpawner(spawnType, sectorCenter + displacement, ref spawnsLeftThisFrame); - } - - var debugOverlay = DebugOverlays.Instance; - - if (debugOverlay.PerformanceMetricsVisible) - debugOverlay.ReportSpawns(spawns); - } - - private void SpawnMicrobesAroundPlayer(Vector3 playerLocation, ref float spawnsLeftThisFrame) - { - var angle = random.NextFloat() * 2 * Mathf.Pi; - - float spawns = 0.0f; - foreach (var spawnType in spawnTypes) - { - if (!SpawnsBlocked(spawnType) && spawnType is MicrobeSpawner) - { - spawns += SpawnWithSpawner(spawnType, - playerLocation + new Vector3(Mathf.Cos(angle) * Constants.SPAWN_SECTOR_SIZE * 2, 0, - Mathf.Sin(angle) * Constants.SPAWN_SECTOR_SIZE * 2), ref spawnsLeftThisFrame); - } - } - - var debugOverlay = DebugOverlays.Instance; - - if (debugOverlay.PerformanceMetricsVisible) - debugOverlay.ReportSpawns(spawns); - } - - /// - /// Checks whether we're currently blocked from spawning this type - /// - private bool SpawnsBlocked(Spawner spawnType) - { - return spawnType.SpawnsEntities && estimateEntityCount >= Settings.Instance.MaxSpawnedEntities.Value; - } - - /// - /// Does a single spawn with a spawner. Does NOT check we're under the entity limit. - /// - private float SpawnWithSpawner(Spawner spawnType, Vector3 location, ref float spawnsLeftThisFrame) - { - float spawns = 0.0f; - - if (random.NextFloat() > spawnType.Density) - { - return spawns; - } - - var enumerable = spawnType.Spawn(worldRoot, location, this); - - if (enumerable == null) - return spawns; - - bool finished = false; - - var spawner = enumerable.GetEnumerator(); - - while (spawnsLeftThisFrame > 0) - { - if (!spawner.MoveNext()) - { - finished = true; - break; - } - - if (spawner.Current == null) - throw new NullReferenceException("spawn enumerator is not allowed to return null"); - - ProcessSpawnedEntity(spawner.Current, spawnType); - var weight = spawner.Current.EntityWeight; - spawns += weight; - estimateEntityCount += weight; - spawnsLeftThisFrame -= weight; - } - - if (!finished) - { - // Store the remaining items in the enumerator for later - queuedSpawns.AddToBack(new QueuedSpawn(spawnType, spawner)); - } - else - { - spawner.Dispose(); - } - - return spawns; - } - - /// - /// Despawns entities that are far away from the player - /// - /// The number of alive entities, used to limit the total - private float DespawnEntities(Vector3 playerPosition) - { - float entitiesDeleted = 0.0f; - float spawnedEntityWeight = 0.0f; - - int despawnedCount = 0; - - foreach (var spawned in worldRoot.GetChildrenToProcess(Constants.SPAWNED_GROUP)) - { - var entityWeight = spawned.EntityWeight; - spawnedEntityWeight += entityWeight; - - // Keep counting all entities to have an accurate count at the end of this loop, even if we are no longer - // allowed to despawn things - if (despawnedCount >= Constants.MAX_DESPAWNS_PER_FRAME) - continue; - - // Global position must be used here as otherwise colony members are despawned - // This should now just process the colony lead cells as this now uses GetChildrenToProcess, but - // GlobalTransform is kept here just for good measure to make sure the distances are accurate. - var entityPosition = ((Spatial)spawned).GlobalTransform.origin; - var squaredDistance = (playerPosition - entityPosition).LengthSquared(); - - // If the entity is too far away from the player, despawn it. - if (squaredDistance > spawned.DespawnRadiusSquared) - { - entitiesDeleted += entityWeight; - spawned.DestroyDetachAndQueueFree(); - - ++despawnedCount; - } - } - - var debugOverlay = DebugOverlays.Instance; - - if (debugOverlay.PerformanceMetricsVisible) - debugOverlay.ReportDespawns(entitiesDeleted); - - return spawnedEntityWeight - entitiesDeleted; - } - - /// - /// Add the entity to the spawned group and add the despawn radius - /// - private void ProcessSpawnedEntity(ISpawned entity, Spawner spawnType) - { - float radius = spawnType.SpawnRadius + Constants.DESPAWN_RADIUS_OFFSET; - entity.DespawnRadiusSquared = (int)(radius * radius); - - entity.EntityNode.AddToGroup(Constants.SPAWNED_GROUP); - } - - // TODO Could use to be moved to mathUtils? - private float NormalToWithNegativesRadians(float radian) - { - return radian <= Math.PI ? radian : radian - (float)(2 * Math.PI); - } - - private float WithNegativesToNormalRadians(float radian) - { - return radian >= 0 ? radian : (float)(2 * Math.PI) - radian; - } - - private float DistanceBetweenRadians(float p1, float p2) - { - float distance = Math.Abs(p1 - p2); - return distance <= Math.PI ? distance : (float)(2 * Math.PI) - distance; - } - - private class QueuedSpawn : IDisposable - { - public QueuedSpawn(Spawner spawnType, IEnumerator spawns) - { - SpawnType = spawnType; - Spawns = spawns; - } - - public Spawner SpawnType { get; } - - public IEnumerator Spawns { get; } - - public void Dispose() - { - Spawns.Dispose(); - } - } -} diff --git a/src/microbe_stage/Spawner.cs b/src/microbe_stage/Spawner.cs index 01095bc510b..12264121ba0 100644 --- a/src/microbe_stage/Spawner.cs +++ b/src/microbe_stage/Spawner.cs @@ -1,5 +1,4 @@ -using System.Collections.Generic; -using Godot; +using Godot; /// /// Spawner that can be added to a SpawnSystem to be used for spawning things @@ -43,9 +42,12 @@ public abstract class Spawner /// /// Spawns the next thing. This is an enumerator to be able to control how many things to spawn per frame easily /// - /// The parent node of spawned entities + /// The simulation to create the entity in /// Location the spawn system wants to spawn a thing at /// The spawn system that is requesting the spawn to happen - /// An enumerator that on each next call spawns one thing - public abstract IEnumerable? Spawn(Node worldNode, Vector3 location, ISpawnSystem spawnSystem); + /// + /// A spawn queue that on each next call spawns one thing. Null if this spawner doesn't spawn entities, + /// for example compound clouds. + /// + public abstract SpawnQueue? Spawn(IWorldSimulation worldSimulation, Vector3 location, ISpawnSystem spawnSystem); } diff --git a/src/microbe_stage/Spawners.cs b/src/microbe_stage/Spawners.cs index 278b73e8ce8..49ca5f2c6fe 100644 --- a/src/microbe_stage/Spawners.cs +++ b/src/microbe_stage/Spawners.cs @@ -3,6 +3,9 @@ using System; using System.Collections.Generic; +using Components; +using DefaultEcs; +using DefaultEcs.Command; using Godot; /// @@ -13,17 +16,11 @@ public static class Spawners public static MicrobeSpawner MakeMicrobeSpawner(Species species, CompoundCloudSystem cloudSystem, GameProperties currentGame) { - return new MicrobeSpawner(species, cloudSystem, currentGame); + return new MicrobeSpawner(species); } public static ChunkSpawner MakeChunkSpawner(ChunkConfiguration chunkType) { - foreach (var mesh in chunkType.Meshes) - { - if (mesh.LoadedScene == null) - throw new ArgumentException("configured chunk spawner has a mesh that has no scene loaded"); - } - return new ChunkSpawner(chunkType); } @@ -39,38 +36,589 @@ public static ChunkSpawner MakeChunkSpawner(ChunkConfiguration chunkType) /// public static class SpawnHelpers { - public static Microbe SpawnMicrobe(Species species, Vector3 location, - Node worldRoot, PackedScene microbeScene, bool aiControlled, - CompoundCloudSystem cloudSystem, ISpawnSystem spawnSystem, GameProperties currentGame, - CellType? multicellularCellType = null) + /// + /// Call this when using the "WithoutFinalizing" variants of spawn methods that allow additional entity + /// customization. This is not mandatory to call when other operations are to be batched with the same recorder. + /// In which case the recorder should be just directly submitted to + /// . + /// + /// The entityRecorder returned from the without finalize method + /// The world simulation used to start the entity spawn + public static void FinalizeEntitySpawn(EntityCommandRecorder entityRecorder, IWorldSimulation worldSimulation) { - var microbe = (Microbe)microbeScene.Instance(); + worldSimulation.FinishRecordingEntityCommands(entityRecorder); + } - // The second parameter is (isPlayer), and we assume that if the - // cell is not AI controlled it is the player's cell - microbe.Init(cloudSystem, spawnSystem, currentGame, !aiControlled); + public static void SpawnCellBurstEffect(IWorldSimulation worldSimulation, Vector3 location, float radius) + { + // Support spawning this at any time during an update cycle + var recorder = worldSimulation.StartRecordingEntityCommands(); + + SpawnCellBurstEffectWithoutFinalizing(recorder, worldSimulation, location, radius); + worldSimulation.FinishRecordingEntityCommands(recorder); + } + + public static void SpawnCellBurstEffectWithoutFinalizing(EntityCommandRecorder entityRecorder, + IWorldSimulation worldSimulation, Vector3 location, float radius) + { + // Support spawning this at any time during an update cycle + var entityCreator = worldSimulation.GetRecorderWorld(entityRecorder); + + var entity = worldSimulation.CreateEntityDeferred(entityCreator); + + entity.Set(new WorldPosition(location)); + + entity.Set(); + entity.Set(new PredefinedVisuals + { + VisualIdentifier = VisualResourceIdentifier.CellBurstEffect, + }); + + // The cell burst effect component initialization by its system configures this + entity.Set(); + entity.Set(new CellBurstEffect(radius)); + } + + /// + /// Spawns an agent projectile + /// + public static EntityRecord SpawnAgentProjectile(IWorldSimulation worldSimulation, AgentProperties properties, + float amount, float lifetime, Vector3 location, Vector3 direction, float scale, Entity emitter) + { + var recorder = SpawnAgentProjectileWithoutFinalizing(worldSimulation, properties, + amount, lifetime, location, direction, scale, emitter, out var entity); + + FinalizeEntitySpawn(recorder, worldSimulation); + + return entity; + } + + public static EntityCommandRecorder SpawnAgentProjectileWithoutFinalizing(IWorldSimulation worldSimulation, + AgentProperties properties, float amount, float lifetime, Vector3 location, Vector3 direction, float scale, + Entity emitter, out EntityRecord entity) + { + var recorder = worldSimulation.StartRecordingEntityCommands(); + + entity = SpawnAgentProjectileWithoutFinalizing(worldSimulation, recorder, properties, amount, lifetime, + location, direction, scale, emitter); + + worldSimulation.FinishRecordingEntityCommands(recorder); + + return recorder; + } + + public static EntityRecord SpawnAgentProjectileWithoutFinalizing(IWorldSimulation worldSimulation, + EntityCommandRecorder commandRecorder, AgentProperties properties, float amount, float lifetime, + Vector3 location, Vector3 direction, float scale, Entity emitter) + { + var normalizedDirection = direction.Normalized(); + + var entityCreator = worldSimulation.GetRecorderWorld(commandRecorder); + + var entity = worldSimulation.CreateEntityDeferred(entityCreator); + + entity.Set(new WorldPosition(location + direction * 1.5f)); + + entity.Set(new PredefinedVisuals + { + VisualIdentifier = VisualResourceIdentifier.AgentProjectile, + }); + + entity.Set(new SpatialInstance + { + VisualScale = new Vector3(scale, scale, scale), + ApplyVisualScale = Math.Abs(scale - 1) > MathUtils.EPSILON, + }); + + entity.Set(new TimedLife + { + TimeToLiveRemaining = lifetime, + }); + entity.Set(new FadeOutActions + { + FadeTime = Constants.EMITTER_DESPAWN_DELAY, + DisableCollisions = true, + RemoveVelocity = true, + DisableParticles = true, + }); + + entity.Set(new ToxinDamageSource + { + ToxinAmount = amount, + ToxinProperties = properties, + }); + + entity.Set(new Physics + { + Velocity = normalizedDirection * Constants.AGENT_EMISSION_VELOCITY, + AxisLock = Physics.AxisLockType.YAxis, + }); + entity.Set(new PhysicsShapeHolder + { + Shape = PhysicsShape.CreateSphere(Constants.TOXIN_PROJECTILE_PHYSICS_SIZE, + Constants.TOXIN_PROJECTILE_PHYSICS_DENSITY), + }); + entity.Set(new CollisionManagement + { + IgnoredCollisionsWith = new List { emitter }, + + // Callbacks are initialized by ToxinCollisionSystem + }); + + entity.Set(new ReadableName(properties.Name)); + + return entity; + } + + /// + /// Spawn a floating chunk (cell parts floating around, rocks, hazards) + /// + public static void SpawnChunk(IWorldSimulation worldSimulation, ChunkConfiguration chunkType, Vector3 location, + Random random, bool microbeDrop) + { + var recorder = SpawnChunkWithoutFinalizing(worldSimulation, chunkType, location, random, microbeDrop, out _); + + FinalizeEntitySpawn(recorder, worldSimulation); + } + + public static EntityCommandRecorder SpawnChunkWithoutFinalizing(IWorldSimulation worldSimulation, + ChunkConfiguration chunkType, Vector3 location, Random random, bool microbeDrop, out EntityRecord entity) + { + var recorder = worldSimulation.StartRecordingEntityCommands(); + + entity = SpawnChunkWithoutFinalizing(worldSimulation, recorder, chunkType, location, random, microbeDrop, + Vector3.Zero); + return recorder; + } + + public static EntityRecord SpawnChunkWithoutFinalizing(IWorldSimulation worldSimulation, + EntityCommandRecorder commandRecorder, ChunkConfiguration chunkType, Vector3 location, Random random, + bool microbeDrop, Vector3 initialVelocity) + { + // Resolve the final chunk settings as the chunk configuration is a group of potential things + var selectedMesh = chunkType.Meshes.Random(random); + + // TODO: do something with these properties: + // selectedMesh.SceneModelPath, + + // Chunk is spawned with random rotation (in the 2D plane if it's an Easter egg) + var rotationAxis = chunkType.EasterEgg ? new Vector3(0, 1, 0) : new Vector3(0, 1, 1); + + var entityCreator = worldSimulation.GetRecorderWorld(commandRecorder); + + var entity = worldSimulation.CreateEntityDeferred(entityCreator); + + entity.Set(new WorldPosition(location, new Quat( + rotationAxis.Normalized(), 2 * Mathf.Pi * (float)random.NextDouble()))); + + // TODO: redo chunk visuals with the loadable visual definitions + // entity.Set(new PredefinedVisuals + // { + // VisualIdentifier = VisualResourceIdentifier.AgentProjectile, + // }); + entity.Set(new PathLoadedSceneVisuals + { + ScenePath = selectedMesh.ScenePath, + }); + + entity.Set(new SpatialInstance + { + VisualScale = new Vector3(chunkType.ChunkScale, chunkType.ChunkScale, chunkType.ChunkScale), + ApplyVisualScale = Math.Abs(chunkType.ChunkScale - 1) > MathUtils.EPSILON, + }); + + // This needs to be skipped for particle type chunks (as they don't have materials) + if (!selectedMesh.IsParticles) + { + entity.Set(new EntityMaterial + { + AutoRetrieveFromSpatial = true, + AutoRetrieveModelPath = selectedMesh.SceneModelPath, + }); + + entity.Set(); + } + + if (!string.IsNullOrEmpty(selectedMesh.SceneAnimationPath)) + { + // Stop any animations from playing on this organelle when it is dropped as a chunk. Some chunk types do + // want to keep playing an animation so there's this extra if + if (!selectedMesh.PlayAnimation) + { + entity.Set(new AnimationControl + { + AnimationPlayerPath = selectedMesh.SceneAnimationPath, + StopPlaying = true, + }); + } + } + + // Setup compounds to vent + // TODO: do something about this variable (I can't remember anymore why I added this -hhyyrylainen) + bool hasCompounds = false; + if (chunkType.Compounds?.Count > 0) + { + hasCompounds = true; + + // Capacity is 0 to disallow adding any more compounds to the compound bag + var compounds = new CompoundBag(0); + + foreach (var entry in chunkType.Compounds) + { + // Directly write compounds to avoid the capacity limit + compounds.Compounds.Add(entry.Key, entry.Value.Amount); + } + + entity.Set(new CompoundStorage + { + Compounds = compounds, + }); + + entity.Set(new CompoundVenter + { + VentEachCompoundPerSecond = chunkType.VentAmount, + DestroyOnEmpty = chunkType.Dissolves, + UsesMicrobialDissolveEffect = true, + }); + } + + // Chunks that don't dissolve naturally when running out of compounds, are despawned with a timer + if (!chunkType.Dissolves) + { + entity.Set(new TimedLife + { + TimeToLiveRemaining = Constants.DESPAWNING_CHUNK_LIFETIME, + }); + entity.Set(new FadeOutActions + { + FadeTime = Constants.EMITTER_DESPAWN_DELAY, + DisableCollisions = true, + RemoveVelocity = true, + DisableParticles = true, + UsesMicrobialDissolveEffect = true, + VentCompounds = true, + }); + } + + entity.Set(new Physics + { + AxisLock = Physics.AxisLockType.YAxis, + LinearDamping = Constants.CHUNK_PHYSICS_DAMPING, + Velocity = initialVelocity, + }); + entity.Set(new PhysicsShapeHolder + { + Shape = selectedMesh.ConvexShapePath != null ? + PhysicsShape.CreateShapeFromGodotResource(selectedMesh.ConvexShapePath, chunkType.PhysicsDensity) : + PhysicsShape.CreateSphere(chunkType.Radius, chunkType.PhysicsDensity), + }); + + // See the remarks comment on EntityRadiusInfo + entity.Set(new EntityRadiusInfo(chunkType.Radius)); + + entity.Set(); + + if (chunkType.Damages > 0) + { + entity.Set(new DamageOnTouch + { + DamageAmount = chunkType.Damages, + DestroyOnTouch = chunkType.DeleteOnTouch, + DamageType = string.IsNullOrEmpty(chunkType.DamageType) ? "chunk" : chunkType.DamageType, + }); + } + else if (chunkType.DeleteOnTouch) + { + // No damage but deletes on touch + entity.Set(new DamageOnTouch + { + DamageAmount = 0, + DestroyOnTouch = chunkType.DeleteOnTouch, + }); + } + + // TODO: rename Size to EngulfSize after making sure it isn't used for other purposes + if (chunkType.Size > 0) + { + entity.Set(new Engulfable + { + BaseEngulfSize = chunkType.Size, + RequisiteEnzymeToDigest = !string.IsNullOrEmpty(chunkType.DissolverEnzyme) ? + SimulationParameters.Instance.GetEnzyme(chunkType.DissolverEnzyme) : + null, + DestroyIfPartiallyDigested = true, + }); + } + + entity.Set(); + entity.Set(); + + // Despawn chunks when there are too many + entity.Set(new CountLimited + { + Group = microbeDrop ? LimitGroup.Chunk : LimitGroup.ChunkSpawned, + }); + + entity.Set(new ReadableName(new LocalizedString(chunkType.Name))); + + return entity; + } + + public static void SpawnMicrobe(IWorldSimulation worldSimulation, Species species, Vector3 location, + bool aiControlled, CellType? multicellularCellType = null) + { + var (recorder, _) = SpawnMicrobeWithoutFinalizing(worldSimulation, species, location, aiControlled, + multicellularCellType, out _); + + FinalizeEntitySpawn(recorder, worldSimulation); + } + + public static (EntityCommandRecorder Recorder, float Weight) SpawnMicrobeWithoutFinalizing( + IWorldSimulation worldSimulation, Species species, + Vector3 location, bool aiControlled, CellType? multicellularCellType, out EntityRecord entity) + { + // If this method is modified it must be ensured that CellPropertiesHelpers.ReApplyCellTypeProperties and + // MicrobeVisualOnlySimulation microbe update methods are also up to date - worldRoot.AddChild(microbe); - microbe.Translation = location; + var recorder = worldSimulation.StartRecordingEntityCommands(); + var entityCreator = worldSimulation.GetRecorderWorld(recorder); - microbe.AddToGroup(Constants.AI_TAG_MICROBE); - microbe.AddToGroup(Constants.PROCESS_GROUP); - microbe.AddToGroup(Constants.RUNNABLE_MICROBE_GROUP); + entity = worldSimulation.CreateEntityDeferred(entityCreator); + // Position + entity.Set(new WorldPosition(location, Quat.Identity)); + + entity.Set(new SpeciesMember(species)); + + // Player vs. AI controlled microbe components if (aiControlled) - microbe.AddToGroup(Constants.AI_GROUP); + { + entity.Set(); + + // Darwinian evolution statistic tracking (these are the external effects that are passed to auto-evo) + entity.Set(); + + entity.Set(new SoundEffectPlayer + { + AbsoluteMaxDistanceSquared = Constants.MICROBE_SOUND_MAX_DISTANCE_SQUARED, + }); + } + else + { + // We assume that if the cell is not AI controlled it is the player's cell + entity.Set(); + + // The player's "ears" are placed at the player microbe + entity.Set(new SoundListener + { + UseTopDownRotation = true, + }); + + entity.Set(new SoundEffectPlayer + { + AbsoluteMaxDistanceSquared = Constants.MICROBE_SOUND_MAX_DISTANCE_SQUARED, + + // As this takes a bit of extra performance this is just set for the player + AutoDetectPlayer = true, + }); + } + + // Base species-based data initialization + ICellProperties usedCellProperties; + MembraneType membraneType; + + if (species is EarlyMulticellularSpecies earlyMulticellularSpecies) + { + CellType resolvedCellType; + + if (multicellularCellType != null) + { + // Non-first cell in an early multicellular colony - if (multicellularCellType != null) + resolvedCellType = multicellularCellType; + + usedCellProperties = multicellularCellType; + var properties = new CellProperties(multicellularCellType); + membraneType = properties.MembraneType; + entity.Set(properties); + } + else + { + resolvedCellType = earlyMulticellularSpecies.Cells[0].CellType; + + usedCellProperties = resolvedCellType; + var properties = new CellProperties(usedCellProperties); + membraneType = properties.MembraneType; + entity.Set(properties); + + entity.Set(new MulticellularGrowth(earlyMulticellularSpecies)); + } + + entity.Set(new EarlyMulticellularSpeciesMember(earlyMulticellularSpecies, resolvedCellType)); + } + else if (species is MicrobeSpecies microbeSpecies) { - microbe.ApplyMulticellularNonFirstCellSpecies((EarlyMulticellularSpecies)species, multicellularCellType); + entity.Set(new MicrobeSpeciesMember + { + Species = microbeSpecies, + }); + + usedCellProperties = microbeSpecies; + var properties = new CellProperties(microbeSpecies); + membraneType = properties.MembraneType; + entity.Set(properties); + + if (multicellularCellType != null) + GD.PrintErr("Multicellular cell type may not be set when spawning a MicrobeSpecies instance"); } else { - microbe.ApplySpecies(species); + throw new NotSupportedException("Unknown species type to spawn a microbe from"); + } + + var bioProcesses = new BioProcesses + { + ProcessStatistics = aiControlled ? null : new ProcessStatistics(), + }; + + int organelleCount; + float engulfSize; + + // Initialize organelles for the cell type + { + var container = default(OrganelleContainer); + + container.CreateOrganelleLayout(usedCellProperties); + container.RecalculateOrganelleBioProcesses(ref bioProcesses); + + organelleCount = container.Organelles!.Count; + engulfSize = container.HexCount; + + // Compound storage + var storage = new CompoundStorage + { + // 0 is used here as this is updated before adding the component anyway + Compounds = new CompoundBag(0), + }; + + // Run the storage update logic for the first time (to ensure consistency with later updates) + // This has to be called as CreateOrganelleLayout doesn't do this automatically + container.UpdateCompoundBagStorageFromOrganelles(ref storage); + + // Finish setting up these two components + entity.Set(container); + + storage.Compounds.AddInitialCompounds(species.InitialCompounds); + entity.Set(storage); } - microbe.SetInitialCompounds(); - return microbe; + entity.Set(bioProcesses); + + entity.Set(new ReproductionStatus(species.BaseReproductionCost)); + + // Visuals + var scale = usedCellProperties.IsBacteria ? new Vector3(0.5f, 0.5f, 0.5f) : new Vector3(1, 1, 1); + + entity.Set(new SpatialInstance + { + VisualScale = scale, + ApplyVisualScale = true, + }); + + entity.Set(); + + entity.Set(new ColourAnimation(Membrane.MembraneTintFromSpeciesColour(usedCellProperties.Colour)) + { + AnimateOnlyFirstMaterial = true, + }); + + entity.Set(); + + entity.Set(new CompoundAbsorber + { + // This gets set properly later once the membrane is ready by MicrobePhysicsCreationAndSizeSystem + AbsorbRadius = Constants.MICROBE_MIN_ABSORB_RADIUS, + + // Microbes only want to grab stuff they want + OnlyAbsorbUseful = true, + + AbsorptionRatio = usedCellProperties.MembraneType.ResourceAbsorptionFactor, + + // AI requires this, player doesn't (or at least I can't remember right now that it would -hhyyrylainen) + // but it isn't too big a problem to also specify this for the player + TotalAbsorbedCompounds = new Dictionary(), + }); + + entity.Set(new UnneededCompoundVenter + { + VentThreshold = Constants.DEFAULT_MICROBE_VENT_THRESHOLD, + }); + + // Physics + entity.Set(new Physics + { + AxisLock = Physics.AxisLockType.YAxisWithRotation, + LinearDamping = Constants.MICROBE_PHYSICS_DAMPING, + AngularDamping = Constants.MICROBE_PHYSICS_DAMPING_ANGULAR, + TrackVelocity = true, + }); + + entity.Set(); + + // Used in certain damage types to apply a cooldown + entity.Set(); + + entity.Set(new CollisionManagement + { + RecordActiveCollisions = Constants.MAX_SIMULTANEOUS_COLLISIONS_SMALL, + }); + + // The shape is created in the background to reduce lag when something spawns + entity.Set(new PhysicsShapeHolder + { + Shape = null, + }); + + // Movement + // TODO: calculate rotation rate + entity.Set(new MicrobeControl(location)); + entity.Set(); + + // Other cell features + entity.Set(new MicrobeStatus + { + TimeUntilChemoreceptionUpdate = Constants.CHEMORECEPTOR_SEARCH_UPDATE_INTERVAL, + }); + + entity.Set(new Health(HealthHelpers.CalculateMicrobeHealth(usedCellProperties.MembraneType, + usedCellProperties.MembraneRigidity))); + + entity.Set(new CommandSignaler + { + SignalingChannel = species.ID, + }); + + entity.Set(new Engulfable + { + BaseEngulfSize = engulfSize, + RequisiteEnzymeToDigest = SimulationParameters.Instance.GetEnzyme(membraneType.DissolverEnzyme), + }); + + entity.Set(new Engulfer + { + EngulfingSize = engulfSize, + EngulfStorageSize = engulfSize, + }); + + // Microbes are not affected by currents before they are visualized + // entity.Set(); + + // Selecting is used to throw out specific colony members + entity.Set(); + + entity.Set(new ReadableName(new LocalizedString(species.FormattedName))); + + return (recorder, OrganelleContainerHelpers.CalculateCellEntityWeight(organelleCount)); } /// @@ -79,94 +627,68 @@ public static class SpawnHelpers /// The multicellular microbe /// Random to use for the randomness /// If the microbe is not multicellular - public static void GiveFullyGrownChanceForMulticellular(Microbe microbe, Random random) + public static void GiveFullyGrownChanceForMulticellular(Entity microbe, Random random) { - if (!microbe.IsMulticellular) + throw new NotImplementedException(); + + /*if (!microbe.IsMulticellular) throw new ArgumentException("must be multicellular"); // Chance to spawn fully grown or partially grown if (random.NextDouble() < Constants.CHANCE_MULTICELLULAR_SPAWNS_GROWN) { - microbe.BecomeFullyGrownMulticellularColony(); + throw new NotImplementedException(); + + // microbe.BecomeFullyGrownMulticellularColony(); } else if (random.NextDouble() < Constants.CHANCE_MULTICELLULAR_SPAWNS_PARTLY_GROWN) { while (!microbe.IsFullyGrownMulticellular) { - microbe.AddMulticellularGrowthCell(true); + throw new NotImplementedException(); + + // microbe.AddMulticellularGrowthCell(true); if (random.NextDouble() > Constants.CHANCE_MULTICELLULAR_PARTLY_GROWN_CELL_CHANCE) break; } - } + }*/ + + // TODO: need to adjust entity weight in the spawned entity + // throw new NotImplementedException(); } - // TODO: this is likely a huge cause of lag. Would be nice to be able - // to spawn these so that only one per tick is spawned. - public static IEnumerable SpawnBacteriaColony(Species species, Vector3 location, - Node worldRoot, PackedScene microbeScene, CompoundCloudSystem cloudSystem, ISpawnSystem spawnSystem, - GameProperties currentGame, Random random) + /// + /// Calculates spaced out positions to spawn a bacteria swarm (to avoid them all overlapping) + /// + public static List CalculateBacteriaSwarmPositions(Vector3 initialLocation, Random random) { - var curSpawn = new Vector3(random.Next(1, 8), 0, random.Next(1, 8)); + var currentPoint = new Vector3(random.Next(1, 8), 0, random.Next(1, 8)); var clumpSize = random.Next(Constants.MIN_BACTERIAL_COLONY_SIZE, Constants.MAX_BACTERIAL_COLONY_SIZE + 1); + + var result = new List(clumpSize); + for (int i = 0; i < clumpSize; i++) { - // Dont spawn them on top of each other because it - // causes them to bounce around and lag - yield return SpawnMicrobe(species, location + curSpawn, worldRoot, microbeScene, true, - cloudSystem, spawnSystem, currentGame); + result.Add(initialLocation + currentPoint); - curSpawn += new Vector3(random.Next(-7, 8), 0, random.Next(-7, 8)); + currentPoint += new Vector3(random.Next(-7, 8), 0, random.Next(-7, 8)); } - } - - public static PackedScene LoadMicrobeScene() - { - return GD.Load("res://src/microbe_stage/Microbe.tscn"); - } - - public static FloatingChunk SpawnChunk(ChunkConfiguration chunkType, - Vector3 location, Node worldNode, PackedScene chunkScene, Random random) - { - var chunk = (FloatingChunk)chunkScene.Instance(); - - // Settings need to be applied before adding it to the scene - var selectedMesh = chunkType.Meshes.Random(random); - chunk.GraphicsScene = selectedMesh.LoadedScene ?? - throw new Exception("Chunk scene has not been loaded even though it should be loaded here"); - chunk.ConvexPhysicsMesh = selectedMesh.LoadedConvexShape; - - if (chunk.GraphicsScene == null) - throw new ArgumentException("couldn't find a graphics scene for a chunk"); - - // Pass on the chunk data - chunk.Init(chunkType, selectedMesh.SceneModelPath, selectedMesh.SceneAnimationPath); - chunk.UsesDespawnTimer = !chunkType.Dissolves; - - worldNode.AddChild(chunk); - - // Chunk is spawned with random rotation (in the 2D plane if it's an Easter egg) - var rotationAxis = chunk.EasterEgg ? new Vector3(0, 1, 0) : new Vector3(0, 1, 1); - chunk.Transform = new Transform(new Quat( - rotationAxis.Normalized(), 2 * Mathf.Pi * (float)random.NextDouble()), location); - - chunk.GetNode("NodeToScale").Scale = new Vector3(chunkType.ChunkScale, chunkType.ChunkScale, - chunkType.ChunkScale); - chunk.AddToGroup(Constants.FLUID_EFFECT_GROUP); - chunk.AddToGroup(Constants.AI_TAG_CHUNK); - return chunk; + return result; } - public static PackedScene LoadChunkScene() + public static (EntityCommandRecorder Recorder, float Weight) SpawnBacteriaSwarmMember( + IWorldSimulation worldSimulation, Species species, + Vector3 location, out EntityRecord entity) { - return GD.Load("res://src/microbe_stage/FloatingChunk.tscn"); + return SpawnMicrobeWithoutFinalizing(worldSimulation, species, location, true, null, out entity); } - public static void SpawnCloud(CompoundCloudSystem clouds, Vector3 location, - Compound compound, float amount, Random random) + public static void SpawnCloud(CompoundCloudSystem clouds, Vector3 location, Compound compound, float amount, + Random random) { int resolution = Settings.Instance.CloudResolution; @@ -181,38 +703,6 @@ public static PackedScene LoadChunkScene() clouds.AddCloud(compound, amount, location + new Vector3(0, 0, 0)); } - /// - /// Spawns an agent projectile - /// - public static AgentProjectile SpawnAgent(AgentProperties properties, float amount, - float lifetime, Vector3 location, Vector3 direction, - Node worldRoot, PackedScene agentScene, IEntity emitter) - { - var normalizedDirection = direction.Normalized(); - - var agent = (AgentProjectile)agentScene.Instance(); - agent.Properties = properties; - agent.Amount = amount; - agent.TimeToLiveRemaining = lifetime; - agent.Emitter = new EntityReference(emitter); - - worldRoot.AddChild(agent); - agent.Translation = location + (direction * 1.5f); - var scaleValue = amount / Constants.MAXIMUM_AGENT_EMISSION_AMOUNT; - agent.Scale = new Vector3(scaleValue, scaleValue, scaleValue); - - agent.ApplyCentralImpulse(normalizedDirection * - Constants.AGENT_EMISSION_IMPULSE_STRENGTH); - - agent.AddToGroup(Constants.TIMED_GROUP); - return agent; - } - - public static PackedScene LoadAgentScene() - { - return GD.Load("res://src/microbe_stage/AgentProjectile.tscn"); - } - public static MulticellularCreature SpawnCreature(Species species, Vector3 location, Node worldRoot, PackedScene multicellularScene, bool aiControlled, ISpawnSystem spawnSystem, GameProperties currentGame) @@ -227,11 +717,12 @@ public static PackedScene LoadAgentScene() creature.Translation = location; creature.AddToGroup(Constants.ENTITY_TAG_CREATURE); - creature.AddToGroup(Constants.PROCESS_GROUP); creature.AddToGroup(Constants.PROGRESS_ENTITY_GROUP); if (aiControlled) - creature.AddToGroup(Constants.AI_GROUP); + { + // TODO: AI + } creature.ApplySpecies(species); creature.ApplyMovementModeFromSpecies(); @@ -443,54 +934,71 @@ private static Quat RandomRotationForResourceEntity(Random random) /// public class MicrobeSpawner : Spawner { - private readonly PackedScene microbeScene; - private readonly CompoundCloudSystem cloudSystem; - private readonly GameProperties currentGame; private readonly Random random = new(); - public MicrobeSpawner(Species species, CompoundCloudSystem cloudSystem, GameProperties currentGame) + public MicrobeSpawner(Species species) { Species = species ?? throw new ArgumentException("species is null"); - - microbeScene = SpawnHelpers.LoadMicrobeScene(); - this.cloudSystem = cloudSystem; - this.currentGame = currentGame; } public override bool SpawnsEntities => true; public Species Species { get; } - public override IEnumerable? Spawn(Node worldNode, Vector3 location, ISpawnSystem spawnSystem) + public override SpawnQueue Spawn(IWorldSimulation worldSimulation, Vector3 location, ISpawnSystem spawnSystem) { // This should no longer happen, but let's keep this print here to keep track of the situation if (Species.Obsolete) GD.PrintErr("Obsolete species microbe has spawned"); - // The true here is that this is AI controlled - var first = SpawnHelpers.SpawnMicrobe(Species, location, worldNode, microbeScene, true, cloudSystem, - spawnSystem, currentGame); + bool bacteria = false; - if (first.IsMulticellular) + if (Species is MicrobeSpecies microbeSpecies) { - SpawnHelpers.GiveFullyGrownChanceForMulticellular(first, random); + bacteria = microbeSpecies.IsBacteria; } - yield return first; - - ModLoader.ModInterface.TriggerOnMicrobeSpawned(first); - - // Just in case the is bacteria flag is not correct in a multicellular cell type, here's an extra safety check - if (first.CellTypeProperties.IsBacteria && !first.IsMulticellular) + var firstSpawn = new SingleItemSpawnQueue((out EntityRecord entity) => { - foreach (var colonyMember in SpawnHelpers.SpawnBacteriaColony(Species, location, worldNode, - microbeScene, cloudSystem, spawnSystem, currentGame, random)) + // The true here is that this is AI controlled + var (recorder, weight) = SpawnHelpers.SpawnMicrobeWithoutFinalizing(worldSimulation, Species, + location, true, null, out entity); + + if (Species is EarlyMulticellularSpecies) { - yield return colonyMember; + throw new NotImplementedException(); - ModLoader.ModInterface.TriggerOnMicrobeSpawned(colonyMember); + // SpawnHelpers.GiveFullyGrownChanceForMulticellular(first, random); + // TODO: weight needs to be adjusted for the created colony } + + ModLoader.ModInterface.TriggerOnMicrobeSpawned(entity); + + return (recorder, weight); + }, this); + + if (!bacteria) + { + // Simple case of just spawning a single microbe + return firstSpawn; } + + // More complex, first need to do a normal spawn, and then continue onto bacteria swarm ones so we use a + // combined queue specifically written for this use case + + var stateData = SpawnHelpers.CalculateBacteriaSwarmPositions(location, random); + + var swarmQueue = new CallbackSpawnQueue>((List positions, out EntityRecord entity) => + { + var (recorder, weight) = SpawnHelpers.SpawnBacteriaSwarmMember(worldSimulation, Species, + positions[0], out entity); + + positions.RemoveAt(0); + + return (recorder, weight, positions.Count < 1); + }, stateData, SpawnQueue.PruneSpawnListPositions, this); + + return new CombinedSpawnQueue(firstSpawn, swarmQueue); } public override string ToString() @@ -518,7 +1026,7 @@ public CompoundCloudSpawner(Compound compound, CompoundCloudSystem clouds, float public override bool SpawnsEntities => false; - public override IEnumerable? Spawn(Node worldNode, Vector3 location, ISpawnSystem spawnSystem) + public override SpawnQueue? Spawn(IWorldSimulation worldSimulation, Vector3 location, ISpawnSystem spawnSystem) { SpawnHelpers.SpawnCloud(clouds, location, compound, amount, random); @@ -537,26 +1045,27 @@ public override string ToString() /// public class ChunkSpawner : Spawner { - private readonly PackedScene chunkScene; private readonly ChunkConfiguration chunkType; private readonly Random random = new(); public ChunkSpawner(ChunkConfiguration chunkType) { this.chunkType = chunkType; - chunkScene = SpawnHelpers.LoadChunkScene(); } public override bool SpawnsEntities => true; - public override IEnumerable? Spawn(Node worldNode, Vector3 location, ISpawnSystem spawnSystem) + public override SpawnQueue Spawn(IWorldSimulation worldSimulation, Vector3 location, ISpawnSystem spawnSystem) { - var chunk = SpawnHelpers.SpawnChunk(chunkType, location, worldNode, chunkScene, - random); + return new SingleItemSpawnQueue((out EntityRecord entity) => + { + var recorder = SpawnHelpers.SpawnChunkWithoutFinalizing(worldSimulation, + chunkType, location, random, false, out entity); - yield return chunk; + ModLoader.ModInterface.TriggerOnChunkSpawned(entity, true); - ModLoader.ModInterface.TriggerOnChunkSpawned(chunk, true); + return (recorder, Constants.FLOATING_CHUNK_ENTITY_WEIGHT); + }, this); } public override string ToString() diff --git a/src/microbe_stage/components/BioProcesses.cs b/src/microbe_stage/components/BioProcesses.cs new file mode 100644 index 00000000000..5e3c85c652f --- /dev/null +++ b/src/microbe_stage/components/BioProcesses.cs @@ -0,0 +1,27 @@ +namespace Components +{ + using System.Collections.Generic; + + /// + /// Entity has bio processes to run by the + /// + public struct BioProcesses + { + /// + /// The active processes that ProcessSystem handles + /// + /// + /// + /// All processes that perform the same action should be combined together rather than listing that process + /// multiple times in this list (as that results in unexpected things as that isn't semantically how this + /// property is meant to be structured) + /// + /// + public List? ActiveProcesses; + + /// + /// If set to not-null process statistics are gathered here + /// + public ProcessStatistics? ProcessStatistics; + } +} diff --git a/src/microbe_stage/components/CellBurstEffect.cs b/src/microbe_stage/components/CellBurstEffect.cs new file mode 100644 index 00000000000..e005e2f5246 --- /dev/null +++ b/src/microbe_stage/components/CellBurstEffect.cs @@ -0,0 +1,21 @@ +namespace Components +{ + public struct CellBurstEffect + { + /// + /// Radius of the effect, needs to be set before this gets initialized + /// + public float Radius; + + /// + /// Used by the burst system to detect which entities are not initialized yet + /// + public bool Initialized; + + public CellBurstEffect(float radius) + { + Radius = radius; + Initialized = false; + } + } +} diff --git a/src/microbe_stage/components/CellProperties.cs b/src/microbe_stage/components/CellProperties.cs new file mode 100644 index 00000000000..d10b84d569c --- /dev/null +++ b/src/microbe_stage/components/CellProperties.cs @@ -0,0 +1,511 @@ +namespace Components +{ + using System; + using System.Collections.Generic; + using System.Linq; + using DefaultEcs; + using DefaultEcs.Command; + using Godot; + using Newtonsoft.Json; + + /// + /// Base properties of a microbe (separate from the species info as early multicellular species object couldn't + /// work there) + /// + public struct CellProperties + { + /// + /// Base colour of the cell. This is used when initializing organelles as it would otherwise be difficult to + /// to obtain the colour + /// + public Color Colour; + + public float UnadjustedRadius; + + // public float MassFromOrganelles + + public MembraneType MembraneType; + public float MembraneRigidity; + + /// + /// The membrane created for this cell. This is here so that some other systems apart from the visuals system + /// can have access to the membrane data. + /// + [JsonIgnore] + public Membrane? CreatedMembrane; + + public bool IsBacteria; + + /// + /// Set to false when shape needs to be recreated + /// + [JsonIgnore] + public bool ShapeCreated; + + public CellProperties(ICellProperties initialProperties) + { + Colour = initialProperties.Colour; + MembraneType = initialProperties.MembraneType; + MembraneRigidity = initialProperties.MembraneRigidity; + CreatedMembrane = null; + IsBacteria = initialProperties.IsBacteria; + + // These are initialized later + UnadjustedRadius = 0; + + // TODO: do we need to copy some more properties? + + ShapeCreated = false; + } + + public float Radius => IsBacteria ? UnadjustedRadius * 0.5f : UnadjustedRadius; + } + + public static class CellPropertiesHelpers + { + /// + /// The default visual position if the organelle is on the microbe's center + /// TODO: this should be made organelle type specific, chemoreceptors and pilus should point backward + /// (in Godot coordinates to point forwards by default, and flagella should keep this current default value). + /// Actually latest issue describing how this could be solved is: + /// https://github.com/Revolutionary-Games/Thrive/issues/3620 and + /// https://github.com/Revolutionary-Games/Thrive/issues/3109 + /// + public static readonly Vector3 DefaultVisualPos = Vector3.Forward; + + public delegate void ModifyDividedCellCallback(ref EntityRecord entity); + + /// + /// Checks this cell and also the entire colony if something can enter engulf mode in it + /// + public static bool CanEngulfInColony(this ref CellProperties cellProperties, in Entity entity) + { + if (entity.Has()) + { + ref var colony = ref entity.Get(); + + colony.CanEngulf(); + } + + return cellProperties.MembraneType.CanEngulf; + } + + /// + /// Checks can a cell engulf a target entity. This is the preferred way to check instead of directly using + /// just the check as this also validates the cell has the right properties to be able + /// to engulf. + /// + public static EngulfCheckResult CanEngulfObject(this ref CellProperties cellProperties, + ref SpeciesMember cellSpecies, ref Engulfer engulfer, in Entity target) + { + // Membranes with Cell Wall cannot engulf + if (!cellProperties.MembraneType.CanEngulf) + return EngulfCheckResult.NotInEngulfMode; + + return engulfer.CanEngulfObject(cellSpecies.ID, in target); + } + + /// + /// Checks that membrane is created and ready. Various cell operations require the membrane to be ready + /// before they can be used. + /// + public static bool IsMembraneReady(this ref CellProperties cellProperties) + { + if (cellProperties.CreatedMembrane == null) + return false; + + return !cellProperties.CreatedMembrane.IsChangingShape; + } + + /// + /// Throws some compound out of this Microbe, up to maxAmount + /// + /// Cell to use to calculate where to eject the compounds + /// + /// The position the cell is currently at to use it as a base for the emission location calculation + /// + /// The cell's current compounds to eject from + /// Cloud system to emit to + /// The compound type to eject + /// The maximum amount to eject + /// The direction in which to eject relative to the microbe + /// How far away from the microbe to eject + /// The amount of emitted compound, can be less than the + public static float EjectCompound(this ref CellProperties cellProperties, ref WorldPosition cellPosition, + CompoundBag compounds, CompoundCloudSystem compoundCloudSystem, Compound compound, float maxAmount, + Vector3 direction, float displacement = 0) + { + float amount = compounds.TakeCompound(compound, maxAmount); + + cellProperties.SpawnEjectedCompound(ref cellPosition, compoundCloudSystem, compound, amount, direction, + displacement); + return amount; + } + + /// + /// Triggers reproduction on this cell (even if not ready) + /// + /// + /// + /// Now with multicellular colonies are also allowed to divide so there's no longer a check against that + /// + /// + public static void Divide(this ref CellProperties cellProperties, ref OrganelleContainer organelles, + in Entity entity, Species species, IWorldSimulation worldSimulation, ISpawnSystem spawnerToRegisterWith, + ModifyDividedCellCallback? customizeCallback) + { + if (organelles.Organelles == null) + throw new InvalidOperationException("Organelles not initialized"); + + if (entity.Has()) + throw new ArgumentException("Cell that is a colony member (non-leader) can't divide"); + + ref var position = ref entity.Get(); + + var currentPosition = position.Position; + + // Find the direction to the right from where the cell is facing + var direction = position.Rotation.Xform(Vector3.Right); + + // Start calculating separation distance + // TODO: fix for multihex organelles + var organellePositions = organelles.Organelles.Select(o => Hex.AxialToCartesian(o.Position)).ToList(); + + // TODO: switch this to using membrane radius as that'll hopefully fix the last few divide bugs + float distanceRight = + MathUtils.GetMaximumDistanceInDirection(Vector3.Right, Vector3.Zero, organellePositions); + float distanceLeft = + MathUtils.GetMaximumDistanceInDirection(Vector3.Left, Vector3.Zero, organellePositions); + + if (entity.Has()) + { + // Bigger separation for cell colonies + + throw new NotImplementedException(); + + // TODO: there is still a problem with colonies being able to spawn inside each other + + // var colonyMembers = Colony.ColonyMembers.Select(c => c.GlobalTransform.origin); + // + // distanceRight += MathUtils.GetMaximumDistanceInDirection(direction, currentPosition, colonyMembers); + } + + float width = distanceLeft + distanceRight + Constants.DIVIDE_EXTRA_DAUGHTER_OFFSET; + + if (cellProperties.IsBacteria) + width *= 0.5f; + + Dictionary reproductionCompounds; + + // This method only supports microbe and early multicellular species + if (species is MicrobeSpecies microbeSpecies) + { + reproductionCompounds = microbeSpecies.CalculateTotalComposition(); + } + else + { + reproductionCompounds = ((EarlyMulticellularSpecies)species).Cells[0].CalculateTotalComposition(); + } + + var spawnPosition = currentPosition + direction * width; + + // Create the one daughter cell. + var (recorder, weight) = SpawnHelpers.SpawnMicrobeWithoutFinalizing(worldSimulation, species, spawnPosition, + true, null, out var copyEntity); + + // Since the daughter spawns right next to the cell, it should face the same way to avoid colliding + // This probably wastes a bit of memory but should be fine to overwrite the WorldPosition component like + // this + copyEntity.Set(new WorldPosition(spawnPosition, position.Rotation)); + + // TODO: should this also set an initial look direction that is the same? + + // Make it despawn like normal + spawnerToRegisterWith.NotifyExternalEntitySpawned(copyEntity, + Constants.MICROBE_SPAWN_RADIUS * Constants.MICROBE_SPAWN_RADIUS, weight); + + // Remove the compounds from the created cell + var originalCompounds = entity.Get().Compounds; + + // Copying the capacity should be fine like this as the original cell should be reset to the normal + // capacity already so + var copyEntityCompounds = new CompoundBag(originalCompounds.NominalCapacity); + + var keys = new List(originalCompounds.Compounds.Keys); + + bool isPlayerMicrobe = entity.Has(); + + // Split the compounds between the two cells. + foreach (var compound in keys) + { + var amount = originalCompounds.GetCompoundAmount(compound); + + if (amount <= 0) + continue; + + // If the compound is for reproduction we give player and NPC microbes different amounts. + if (reproductionCompounds.TryGetValue(compound, out float divideAmount)) + { + // The amount taken away from the parent cell depends on if it is a player or NPC. Player + // cells always have 50% of the compounds they divided with taken away. + float amountToTake = amount * 0.5f; + + if (!isPlayerMicrobe) + { + // NPC parent cells have at least 50% taken away, or more if it would leave them + // with more than 90% of the compound it would take to immediately divide again. + amountToTake = Math.Max(amountToTake, amount - (divideAmount * 0.9f)); + } + + originalCompounds.TakeCompound(compound, amountToTake); + + // Since the child cell is always an NPC they are given either 50% of the compound from the + // parent, or 90% of the amount required to immediately divide again, whichever is smaller. + float amountToGive = Math.Min(amount * 0.5f, divideAmount * 0.9f); + var addedCompound = copyEntityCompounds.AddCompound(compound, amountToGive); + + if (addedCompound < amountToGive) + { + // TODO: handle the excess compound that didn't fit in the other cell + } + } + else + { + // Non-reproductive compounds just always get split evenly to both cells. + originalCompounds.TakeCompound(compound, amount * 0.5f); + + var amountAdded = copyEntityCompounds.AddCompound(compound, amount * 0.5f); + + if (amountAdded < amount) + { + // TODO: handle the excess compound that didn't fit in the other cell + } + } + } + + copyEntity.Set(new CompoundStorage + { + Compounds = copyEntityCompounds, + }); + + customizeCallback?.Invoke(ref copyEntity); + + SpawnHelpers.FinalizeEntitySpawn(recorder, worldSimulation); + + if (entity.Has()) + { + // Play the split sound + ref var soundEffectPlayer = ref entity.Get(); + soundEffectPlayer.PlaySoundEffect("res://assets/sounds/soundeffects/reproduction.ogg"); + } + } + + /// + /// Ejects compounds from the microbes behind position (taking direction into account), into the environment + /// (but doesn't remove it from the microbe storage, see ) + /// + /// + /// + /// Note that the compounds ejected are created in this world and not taken from the microbe. + /// This is purely for adding the compound to the cloud system at the right position. Other methods need + /// to be used to remove the ejected compounds from this microbe. + /// + /// + /// + /// True on success, false if the membrane was not ready yet (this error should be checked for by the caller + /// before even calling this method) + /// + public static bool SpawnEjectedCompound(this ref CellProperties cellProperties, ref WorldPosition cellPosition, + CompoundCloudSystem compoundCloudSystem, Compound compound, float amount, Vector3 direction, + float displacement = 0) + { + var amountToEject = amount * Constants.MICROBE_VENT_COMPOUND_MULTIPLIER; + + if (amountToEject <= MathUtils.EPSILON) + return true; + + if (cellProperties.CreatedMembrane == null) + { + GD.PrintErr($"{nameof(SpawnEjectedCompound)} called before membrane is created, ignoring eject"); + return false; + } + + compoundCloudSystem.AddCloud(compound, amountToEject, + cellProperties.CalculateNearbyWorldPosition(ref cellPosition, direction, displacement)); + + return true; + } + + /// + /// Calculates a world position for emitting compounds. Requires membrane to be valid already. + /// + public static Vector3 CalculateNearbyWorldPosition(this ref CellProperties cellProperties, + ref WorldPosition cellPosition, Vector3 direction, float displacement = 0) + { + if (cellProperties.CreatedMembrane == null) + throw new InvalidOperationException("Membrane not ready yet"); + + // OLD CODE kept here in case we want a more accurate membrane position, also this code + // produces an incorrect world position which needs fixing if this were to be used + /* + // The back of the microbe + var exit = Hex.AxialToCartesian(new Hex(0, 1)); + var membraneCoords = Membrane.GetVectorTowardsNearestPointOfMembrane(exit.x, exit.z); + + // Get the distance to eject the compounds + var ejectionDistance = Membrane.EncompassingCircleRadius; + + // The membrane radius doesn't take being bacteria into account + if (CellTypeProperties.IsBacteria) + ejectionDistance *= 0.5f; + + float angle = 180; + + // Find the direction the microbe is facing + var yAxis = Transform.basis.y; + var microbeAngle = Mathf.Atan2(yAxis.x, yAxis.y); + if (microbeAngle < 0) + { + microbeAngle += 2 * Mathf.Pi; + } + + microbeAngle = microbeAngle * 180 / Mathf.Pi; + + // Take the microbe angle into account so we get world relative degrees + var finalAngle = (angle + microbeAngle) % 360; + + var s = Mathf.Sin(finalAngle / 180 * Mathf.Pi); + var c = Mathf.Cos(finalAngle / 180 * Mathf.Pi); + + var ejectionDirection = new Vector3(-membraneCoords.x * c + membraneCoords.z * s, 0, + membraneCoords.x * s + membraneCoords.z * c); + + return Translation + (ejectionDirection * ejectionDistance); + */ + + // Unlike the commented block of code above, this uses cheap membrane radius to calculate + // distance for cheaper computations + var distance = cellProperties.CreatedMembrane.EncompassingCircleRadius; + + // The membrane radius doesn't take being bacteria into account + if (cellProperties.IsBacteria) + distance *= 0.5f; + + distance += displacement; + + var ejectionDirection = cellPosition.Rotation.Xform(direction); + + var result = cellPosition.Position + (ejectionDirection * distance); + + return result; + } + + public static Vector3 CalculateExternalOrganellePosition(this ref CellProperties cellProperties, + Hex hexPosition, int orientation, out Quat rotation) + { + // TODO: https://github.com/Revolutionary-Games/Thrive/issues/3109 + _ = orientation; + + var membrane = cellProperties.CreatedMembrane; + if (membrane == null) + { + throw new InvalidOperationException( + "Membrane is missing for cell properties, can't get external position"); + } + + var organellePos = Hex.AxialToCartesian(hexPosition); + + Vector3 middle = Hex.AxialToCartesian(new Hex(0, 0)); + var relativeOrganellePosition = middle - organellePos; + + if (relativeOrganellePosition == Vector3.Zero) + relativeOrganellePosition = DefaultVisualPos; + + Vector3 exit = middle - relativeOrganellePosition; + var membraneCoords = membrane.GetVectorTowardsNearestPointOfMembrane(exit.x, + exit.z); + + var calculatedNewAngle = GetExternalOrganelleAngle(relativeOrganellePosition); + + rotation = MathUtils.CreateRotationForExternal(calculatedNewAngle); + + return membraneCoords; + } + + /// + /// Applies settings from the cell properties (of a species) again to a spawned entity (and resets + /// reproduction progress). This is needed if species properties need to be applied to an already spawned + /// cell (for example the player). + /// + /// + /// + /// This is basically the new version of ApplySpecies. This must be kept up to date in regards to the + /// spawn microbe method. + /// + /// + /// The cell to apply new settings to + /// Entity of the cell, needed to apply new state to other components + /// The new properties to apply + /// + /// Where to get base reproduction cost from. Other species properties are not used + /// ( applies instead). Note if species object instance changes from what it + /// was before, the code calling this method must do that adjustment manually. + /// + public static void ReApplyCellTypeProperties(this ref CellProperties cellProperties, in Entity entity, + ICellProperties newProperties, Species baseReproductionCostFrom) + { + // Copy new cell type properties + cellProperties.MembraneType = newProperties.MembraneType; + cellProperties.IsBacteria = newProperties.IsBacteria; + cellProperties.Colour = newProperties.Colour; + cellProperties.MembraneRigidity = newProperties.MembraneRigidity; + + ref var spatial = ref entity.Get(); + + spatial.VisualScale = cellProperties.IsBacteria ? new Vector3(0.5f, 0.5f, 0.5f) : new Vector3(1, 1, 1); + + ref var organelleContainer = ref entity.Get(); + + // Reset all the duplicates organelles / reproduction progress of the entity + // This also resets multicellular creature's reproduction progress + organelleContainer.ResetOrganelleLayout(ref entity.Get(), ref entity.Get(), + entity, newProperties, baseReproductionCostFrom); + + // Reset runtime colour + if (entity.Has()) + { + ref var colourAnimation = ref entity.Get(); + colourAnimation.DefaultColour = Membrane.MembraneTintFromSpeciesColour(newProperties.Colour); + + colourAnimation.UpdateAnimationForNewDefaultColour(); + } + } + + public static void ApplyMembraneWigglyness(this ref CellProperties cellProperties, Membrane targetMembrane) + { + targetMembrane.WigglyNess = cellProperties.MembraneType.BaseWigglyness - + (cellProperties.MembraneRigidity / + cellProperties.MembraneType.BaseWigglyness) * 0.2f; + + targetMembrane.MovementWigglyNess = cellProperties.MembraneType.MovementWigglyness - + (cellProperties.MembraneRigidity / + cellProperties.MembraneType.BaseWigglyness) * 0.2f; + } + + /// + /// Gets the angle of rotation of an externally placed organelle + /// + /// The difference between the cell middle and the external organelle position + private static float GetExternalOrganelleAngle(Vector3 delta) + { + float angle = Mathf.Atan2(-delta.z, delta.x); + if (angle < 0) + { + angle += 2 * Mathf.Pi; + } + + angle = (angle * 180 / Mathf.Pi - 90) % 360; + return angle; + } + } +} diff --git a/src/microbe_stage/components/CommandSignaler.cs b/src/microbe_stage/components/CommandSignaler.cs new file mode 100644 index 00000000000..ac4514af83a --- /dev/null +++ b/src/microbe_stage/components/CommandSignaler.cs @@ -0,0 +1,41 @@ +namespace Components +{ + using DefaultEcs; + using Godot; + + /// + /// Sends and receivers command signals (signaling agent). Requires a to function + /// as the origin of the signaling command. + /// + public struct CommandSignaler + { + /// + /// Stores the position the command signal was received from. Only valid if is + /// not . + /// + public Vector3 ReceivedCommandSource; + + /// + /// Entity that sent the detected signal. Not valid if is not set (see + /// documentation on ). + /// + public Entity ReceivedCommandFromEntity; + + /// + /// Used to limit signals reaching entities they shouldn't. In the microbe stage this contains the entity's + /// species ID to allow species-wide signaling. + /// + public ulong SignalingChannel; + + /// + /// Because AI is ran in parallel thread, if it wants to change the signaling, it needs to do it through this + /// + public MicrobeSignalCommand? QueuedSignalingCommand; + + public MicrobeSignalCommand Command; + + public MicrobeSignalCommand ReceivedCommand; + + // TODO: should this have a bool flag to disable this component when the microbe doesn't have a signaling agent? + } +} diff --git a/src/microbe_stage/components/CompoundAbsorber.cs b/src/microbe_stage/components/CompoundAbsorber.cs new file mode 100644 index 00000000000..5944b450672 --- /dev/null +++ b/src/microbe_stage/components/CompoundAbsorber.cs @@ -0,0 +1,37 @@ +namespace Components +{ + using System.Collections.Generic; + + /// + /// Entity that can absorb compounds from . Requires + /// and components as well. + /// + public struct CompoundAbsorber + { + /// + /// If not null then this tracks the total absorbed compounds + /// + public Dictionary? TotalAbsorbedCompounds; + + /// + /// How big the radius for absorption is + /// + public float AbsorbRadius; + + /// + /// How fast this can absorb things. If 0 then the absorption speed is not limited. + /// + public float AbsorbSpeed; + + /// + /// The effectiveness (ratio of gained vs compounds taken from the clouds) of absorption + /// + public float AbsorptionRatio; + + /// + /// When true, then the that we put things in must have useful compounds set and + /// only those will be absorbed + /// + public bool OnlyAbsorbUseful; + } +} diff --git a/src/microbe_stage/components/CompoundStorage.cs b/src/microbe_stage/components/CompoundStorage.cs new file mode 100644 index 00000000000..f1d1bb74868 --- /dev/null +++ b/src/microbe_stage/components/CompoundStorage.cs @@ -0,0 +1,51 @@ +namespace Components +{ + using System.Collections.Generic; + using Godot; + + /// + /// Entity has storage space for compounds + /// + public struct CompoundStorage + { + public CompoundBag Compounds; + } + + public static class CompoundStorageHelpers + { + /// + /// Vent all remaining compounds immediately + /// + public static void VentAllCompounds(this ref CompoundStorage storage, Vector3 position, + CompoundCloudSystem compoundClouds) + { + if (storage.Compounds.Compounds.Count > 0) + { + var keys = new List(storage.Compounds.Compounds.Keys); + + foreach (var compound in keys) + { + var amount = storage.Compounds.GetCompoundAmount(compound); + storage.Compounds.TakeCompound(compound, amount); + + if (amount < MathUtils.EPSILON) + continue; + + VentChunkCompound(ref storage, compound, amount, position, compoundClouds); + } + } + } + + public static bool VentChunkCompound(this ref CompoundStorage storage, Compound compound, float amount, + Vector3 position, CompoundCloudSystem compoundClouds) + { + amount = storage.Compounds.TakeCompound(compound, amount); + + if (amount <= 0) + return false; + + compoundClouds.AddCloud(compound, amount * Constants.CHUNK_VENT_COMPOUND_MULTIPLIER, position); + return amount > MathUtils.EPSILON; + } + } +} diff --git a/src/microbe_stage/components/CompoundVenter.cs b/src/microbe_stage/components/CompoundVenter.cs new file mode 100644 index 00000000000..ab44107320f --- /dev/null +++ b/src/microbe_stage/components/CompoundVenter.cs @@ -0,0 +1,40 @@ +namespace Components +{ + /// + /// An entity that constantly leaks compounds into the environment. Requires . + /// + public struct CompoundVenter + { + /// + /// How much of each compound is vented per second + /// + public float VentEachCompoundPerSecond; + + /// + /// When true venting is prevented (used for example when a chunk is engulfed) + /// + public bool VentingPrevented; + + public bool DestroyOnEmpty; + + /// + public bool UsesMicrobialDissolveEffect; + + /// + /// Internal flag, don't touch + /// + public bool RanOutOfVentableCompounds; + } + + public static class CompoundVenterHelpers + { + public static void PopImmediately(this ref CompoundVenter venter, ref CompoundStorage compoundStorage, + ref WorldPosition position, CompoundCloudSystem compoundClouds) + { + compoundStorage.VentAllCompounds(position.Position, compoundClouds); + + // For now nothing else except immediately venting everything happens + _ = venter; + } + } +} diff --git a/src/microbe_stage/components/CurrentAffected.cs b/src/microbe_stage/components/CurrentAffected.cs new file mode 100644 index 00000000000..dd9a18dc0fc --- /dev/null +++ b/src/microbe_stage/components/CurrentAffected.cs @@ -0,0 +1,14 @@ +namespace Components +{ + using Systems; + + /// + /// Marks entity as being affected by . Additionally + /// and are required components. + /// This exists as currents need to be skipped for microbes for now as we don't have visualizations for the + /// currents. + /// + public struct CurrentAffected + { + } +} diff --git a/src/microbe_stage/components/Engulfable.cs b/src/microbe_stage/components/Engulfable.cs new file mode 100644 index 00000000000..85eb59f4e59 --- /dev/null +++ b/src/microbe_stage/components/Engulfable.cs @@ -0,0 +1,308 @@ +namespace Components +{ + using System; + using System.Collections.Generic; + using DefaultEcs; + using Godot; + using Newtonsoft.Json; + using Systems; + + /// + /// Something that can be engulfed by a microbe + /// + public struct Engulfable + { + /// + /// If this is being engulfed then this is not default and is a reference to the entity (trying to) eating us + /// + public Entity HostileEngulfer; + + /// + /// If not null then the engulfer must have the specified enzyme to be able to eat this + /// + public Enzyme? RequisiteEnzymeToDigest; + + /// + /// Set when an object is engulfed + /// (by ) to the additional resources + /// on top of what the entity's contains that are gained by digestion + /// + public Dictionary? AdditionalEngulfableCompounds; + + public BulkTransportAnimation? BulkTransport; + + /// + /// Base, unadjusted engulfable size of this. That is the number an engulfer compares their ability to engulf + /// against to see if something is too big. + /// + /// + /// + /// Note that the AI assumes this is the same as the same entity's engulfing size (in + /// ) is the same as this to save a bit of memory when storing things. + /// + /// + public float BaseEngulfSize; + + public float DigestedAmount; + + /// + /// When this is engulfed this gets the total amount of compounds that exist here for digestion progress. + /// + /// + /// + /// TODO: investigate if this is correct as the process system keeps running for engulfed cells + /// + /// + public float InitialTotalEngulfableCompounds; + + /// + /// The current step of phagocytosis process this engulfable is currently in. If not phagocytized, + /// state is None. + /// + public PhagocytosisPhase PhagocytosisStep; + + // This might not need a reference to the hostile engulfer as this should have AttachedToEntity to mark what + // this is attached to + + // TODO: implement this for when ejected + /// + /// If this is partially digested when ejected from an engulfer, this is destroyed (with a dissolve animation + /// if detected to be possible) + /// + public bool DestroyIfPartiallyDigested; + + [JsonIgnore] + public float AdjustedEngulfSize => BaseEngulfSize * (1 - DigestedAmount); + + public class BulkTransportAnimation + { + /// + /// If false the animation is complete and doesn't require actions + /// + public bool Interpolate; + + public float LerpDuration; + public float AnimationTimeElapsed; + + // TODO: refactor this to not use nullable values as that will save a bunch of boxing and memory allocation + public (Vector3? Translation, Vector3? Scale, Vector3? EndosomeScale) TargetValuesToLerp; + public (Vector3 Translation, Vector3 Scale, Vector3 EndosomeScale) InitialValuesToLerp; + + public Vector3 OriginalScale; + + // public int OriginalRenderPriority { get; set; } + } + } + + public static class EngulfableHelpers + { + /// + /// Effective size of the engulfable for engulfability calculations + /// + public static float EffectiveEngulfSize(this ref Engulfable engulfable) + { + return engulfable.BaseEngulfSize * (1 - engulfable.DigestedAmount); + } + + /// + /// Calculates additional digestible compounds to be made available when entity is engulfed. Note that only + /// compounds may be returned as the result. + /// + /// + /// The extra compounds to add (this also shouldn't have any 0 values in it for clarity). Or null if there + /// aren't any extra digestible compounds. + /// + public static Dictionary? CalculateAdditionalDigestibleCompounds( + this ref Engulfable engulfable, in Entity entity) + { + // Extra digestible compounds for microbes + if (entity.Has() && entity.Has()) + { + return CalculateMicrobeAdditionalDigestibleCompounds(ref entity.Get(), + ref entity.Get()); + } + + // This entity type doesn't have extra digestible compounds + return null; + } + + /// + /// Called when this becomes engulfed and starts to be pulled in (this may get immediately thrown out if this + /// is not digestible by the attacker) + /// + public static void OnBecomeEngulfed(this ref Engulfable engulfable, in Entity entity) + { + if (entity.Has()) + { + ref var cellProperties = ref entity.Get(); + + if (cellProperties.CreatedMembrane != null) + { + // Make membrane not wiggle to make it look better + cellProperties.CreatedMembrane.WigglyNess = 0; + } + } + + // Stop being in ready to reproduce state while engulfed + if (entity.Has()) + { + ref var organelleContainer = ref entity.Get(); + organelleContainer.AllOrganellesDivided = false; + } + + if (entity.Has()) + { + ref var callbacks = ref entity.Get(); + + callbacks.OnReproductionStatus?.Invoke(entity, false); + } + + // TODO: render priority re-implementation (if we need this). Note that also + // EngulfingSystem.IngestEngulfable has code that interacts with render priorities + // Make the render priority of our organelles be on top of the highest possible render priority + // of the hostile engulfer's organelles + // var hostile = HostileEngulfer.Value; + // if (hostile != null) + // { + // foreach (var organelle in organelles!) + // { + // var newPriority = Mathf.Clamp(Hex.GetRenderPriority(organelle.Position) + + // hostile.OrganelleMaxRenderPriority, 0, Material.RenderPriorityMax); + // organelle.UpdateRenderPriority(newPriority); + // } + // } + } + + /// + /// Called when it is confirmed that an engulfable will be digested (i.e. will not be thrown out immediately + /// due to being inedible) + /// + public static void OnReportBecomeIngestedIfCallbackRegistered(this ref Engulfable engulfable, in Entity entity) + { + if (!entity.Has()) + return; + + ref var callbacks = ref entity.Get(); + + callbacks.OnIngestedByHostile?.Invoke(entity, engulfable.HostileEngulfer); + } + + /// + /// Called when an entity is thrown out from the engulfer, for example due to being indigestible or if the + /// attacker dies + /// + /// + /// + /// This needs to take in the and spawn system to be able to spawn death + /// chunks as a special case for a microbe that basically died during engulfment. + /// + /// + public static void OnExpelledFromEngulfment(this ref Engulfable engulfable, in Entity entity, + ISpawnSystem spawnSystem, IWorldSimulation worldSimulation) + { + if (engulfable.DigestedAmount >= Constants.PARTIALLY_DIGESTED_THRESHOLD) + { + if (entity.Has() && entity.Has()) + { + // Cell is too damaged from digestion, can't live in open environment and is considered dead + ref var health = ref entity.Get(); + health.Kill(); + + // Organelles must be initialized to drop chunks + ref var organelleContainer = ref entity.Get(); + + if (organelleContainer.Organelles != null) + { + ref var position = ref entity.Get(); + + // Most of the normal microbe death gets skipped on engulfed things, instead we do some stuff + // here + + MicrobeDeathSystem.CustomizeSpawnedChunk? customizeCallback = null; + + if (engulfable.HostileEngulfer.Has()) + { + var hostilePosition = engulfable.HostileEngulfer.Get().Position; + + customizeCallback = (ref Vector3 position) => + { + var direction = hostilePosition.DirectionTo(position); + position += direction * + Constants.EJECTED_PARTIALLY_DIGESTED_CELL_CORPSE_CHUNKS_SPAWN_OFFSET; + + // Apply outwards ejection velocity + // TODO: this used to also add the linear velocity of the ejected entity (which was + // probably not doing much, but now we could take the velocity from the engulfer + // and add it here) + return direction * Constants.ENGULF_EJECTION_VELOCITY; + }; + } + + var recorder = worldSimulation.StartRecordingEntityCommands(); + + MicrobeDeathSystem.SpawnCorpseChunks(ref organelleContainer, + entity.Get().Compounds, spawnSystem, worldSimulation, recorder, + position.Position, new Random(), customizeCallback, null); + + SpawnHelpers.FinalizeEntitySpawn(recorder, worldSimulation); + } + } + } + + // There used to be an else branch here that set the escaped flag for the microbe for use in population + // bonus. That is now gone as this feature didn't really do anything anymore due to the new engulf + // mechanics which are extremely hard to escape. + + if (entity.Has()) + { + ref var cellProperties = ref entity.Get(); + + // Reset wigglyness (which was cleared when this was engulfed) + if (cellProperties.CreatedMembrane != null) + cellProperties.ApplyMembraneWigglyness(cellProperties.CreatedMembrane); + } + + // Reset our organelles' render priority back to their original values + // TODO: unify this with the render priority re-apply that exists in EngulfingSystem.CompleteEjection + // foreach (var organelle in organelles!) + // { + // organelle.UpdateRenderPriority(Hex.GetRenderPriority(organelle.Position)); + // } + } + + public static void CalculateBonusDigestibleGlucose(Dictionary result, + CompoundBag compoundCapacityInfo, Compound? glucose = null) + { + glucose ??= SimulationParameters.Instance.GetCompound("glucose"); + + result.TryGetValue(glucose, out float existingGlucose); + result[glucose] = existingGlucose + compoundCapacityInfo.GetCapacityForCompound(glucose) * + Constants.ADDITIONAL_DIGESTIBLE_GLUCOSE_AMOUNT_MULTIPLIER; + } + + private static Dictionary CalculateMicrobeAdditionalDigestibleCompounds( + ref OrganelleContainer organelleContainer, ref CompoundStorage heldCompounds) + { + if (organelleContainer.Organelles == null) + throw new ArgumentException("Organelle container has to be initialized"); + + var result = new Dictionary(); + + // Add some part of the build cost of all the organelles + foreach (var organelle in organelleContainer.Organelles) + { + foreach (var entry in organelle.Definition.InitialComposition) + { + if (!entry.Key.Digestible) + continue; + + result.TryGetValue(entry.Key, out float existing); + result[entry.Key] = existing + entry.Value; + } + } + + CalculateBonusDigestibleGlucose(result, heldCompounds.Compounds); + return result; + } + } +} diff --git a/src/microbe_stage/components/Engulfer.cs b/src/microbe_stage/components/Engulfer.cs new file mode 100644 index 00000000000..8e0b25db716 --- /dev/null +++ b/src/microbe_stage/components/Engulfer.cs @@ -0,0 +1,293 @@ +namespace Components +{ + using System; + using System.Collections.Generic; + using System.Linq; + using DefaultEcs; + using Godot; + + /// + /// Entity that can engulf s + /// + public struct Engulfer + { + /// + /// Tracks entities this already engulfed. Or is in the process of currently pulling in or expelling. + /// + public List? EngulfedObjects; + + /// + /// Tracks entities this has previously engulfed. This is used to not constantly attempt to re-engulf + /// something this cannot fully engulf. The value is how long since the object was expelled. Values are + /// automatically removed once the time reaches + /// + public Dictionary? ExpelledObjects; + + /// + /// The attacking capability of this engulfer. Used to determine what this can eat + /// + public float EngulfingSize; + + /// + /// The amount of space all of the currently engulfed objects occupy in the cytoplasm. This is used to + /// determine whether a cell can ingest any more objects or not due to being full. + /// + /// + /// + /// In a more technical sense, this is the accumulated from all + /// the ingested objects. Maximum should be this cell's own . + /// + /// + public float UsedIngestionCapacity; + + /// + /// Total size that all engulfed objects need to fit in + /// + public float EngulfStorageSize; + } + + public static class EngulferHelpers + { + /// + /// Direct engulfing check. Microbe should use + /// + public static EngulfCheckResult CanEngulfObject(this ref Engulfer engulfer, uint engulferSpeciesID, + in Entity target) + { + if (!target.IsAlive) + return EngulfCheckResult.TargetDead; + + bool invulnerable = false; + + // Can't engulf dead microbes (unlikely to happen but this is a fail-safe) + if (target.Has()) + { + ref var health = ref target.Get(); + + if (health.Dead) + return EngulfCheckResult.TargetDead; + + invulnerable = health.Invulnerable; + } + + // Can't engulf recently ejected objects, this act as a cooldown + if (engulfer.ExpelledObjects != null && engulfer.ExpelledObjects.ContainsKey(target)) + return EngulfCheckResult.RecentlyExpelled; + + try + { + ref var engulfable = ref target.Get(); + + if (engulfable.PhagocytosisStep != PhagocytosisPhase.None) + return EngulfCheckResult.NotInEngulfMode; + + // The following checks are in a specific order to make sure the fail reporting logic gives + // sensible results (this means that a few things that shouldn't be necessary to be inside this try + // block are in here) + + // Disallow cannibalism + if (target.Has() && target.Get().ID == engulferSpeciesID) + return EngulfCheckResult.CannotCannibalize; + + // Needs to be big enough to engulf + if (engulfer.EngulfingSize < engulfable.AdjustedEngulfSize * Constants.ENGULF_SIZE_RATIO_REQ) + return EngulfCheckResult.TargetTooBig; + + // Limit amount of things that can be engulfed at once + if (engulfer.UsedIngestionCapacity >= engulfer.EngulfStorageSize || + engulfer.UsedIngestionCapacity + engulfable.AdjustedEngulfSize >= engulfer.EngulfStorageSize) + { + return EngulfCheckResult.IngestedMatterFull; + } + + // Too many things attempted to be pulled in at once + if (engulfer.UsedIngestionCapacity + engulfable.AdjustedEngulfSize >= engulfer.EngulfStorageSize) + { + return EngulfCheckResult.IngestedMatterFull; + } + } + catch (Exception e) + { + GD.PrintErr("Cannot check engulfing an object that is missing Engulfable component: " + e); + return EngulfCheckResult.InvalidEntity; + } + + // Godmode grants player complete engulfment invulnerability + if (invulnerable) + return EngulfCheckResult.TargetInvulnerable; + + return EngulfCheckResult.Ok; + } + + /// + /// Tries to find an engulfable entity as close to this engulfer as possible. Note that this is *slow* and + /// not meant for normal gameplay code (just using this for the player infrequently is fine as there's only + /// ever one player at once) + /// + /// The engulfer that wants to engulf something + /// + /// Cell properties to determine if this engulfer can even engulf things in the first place + /// + /// Organelles the engulfer has, used to determine what it can eat or digest + /// Location of the engulfer to search nearby positions for + /// + /// Used to filter engulfables to only ones this bag considers useful + /// + /// Entity of the engulfer, used to skip self engulfment check + /// Engulfer species ID to use in engulfability checks + /// Where to fetch potential entities + /// How wide to search around the position + /// The nearest found point for the engulfable entity or null + public static Vector3? FindNearestEngulfableSlow(this ref Engulfer engulfer, + ref CellProperties cellProperties, ref OrganelleContainer organelles, ref WorldPosition position, + CompoundBag usefulCompoundSource, in Entity engulferEntity, uint engulferSpeciesID, IWorldSimulation world, + float searchRadius = 200) + { + if (searchRadius < 1) + throw new ArgumentException("searchRadius must be >= 1"); + + // If the microbe cannot engulf, no need for this + if (!cellProperties.MembraneType.CanEngulf) + return null; + + Vector3? nearestPoint = null; + float nearestDistanceSquared = float.MaxValue; + var searchRadiusSquared = searchRadius * searchRadius; + + // Retrieve nearest potential entities + foreach (var entity in world.EntitySystem) + { + if (!entity.Has() || !entity.Has()) + continue; + + ref var engulfable = ref entity.Get(); + var compounds = entity.Get().Compounds; + + if (compounds.Compounds.Count <= 0 || engulfable.PhagocytosisStep != PhagocytosisPhase.None) + continue; + + if (!entity.Has()) + continue; + + ref var entityPosition = ref entity.Get(); + + // Skip entities that are out of range + var distance = (entityPosition.Position - position.Position).LengthSquared(); + if (distance > searchRadiusSquared) + continue; + + // Skip non-engulfable or digestible entities + if (organelles.CanDigestObject(ref engulfable) != DigestCheckResult.Ok || + engulfer.CanEngulfObject(engulferSpeciesID, entity) != EngulfCheckResult.Ok) + { + continue; + } + + // Skip entities that have no useful compounds + if (!compounds.Compounds.Any(x => usefulCompoundSource.IsUseful(x.Key))) + continue; + + if (nearestPoint == null || distance < nearestDistanceSquared) + { + nearestPoint = entityPosition.Position; + nearestDistanceSquared = distance; + } + } + + return nearestPoint; + } + + public static bool EjectEngulfable(this ref Engulfer engulfer, ref Engulfable engulfable) + { + // Cannot start ejecting a thing that is not in a valid state for that + switch (engulfable.PhagocytosisStep) + { + case PhagocytosisPhase.Ingestion: + case PhagocytosisPhase.Ingested: + break; + + case PhagocytosisPhase.RequestExocytosis: + // Already requested + return true; + + default: + return false; + } + + engulfable.PhagocytosisStep = PhagocytosisPhase.RequestExocytosis; + return true; + } + + /// + /// Immediately deletes all engulfed objects. Should only be used in special cases. + /// + public static void DeleteEngulfedObjects(this ref Engulfer engulfer, IWorldSimulation worldSimulation) + { + if (engulfer.EngulfedObjects != null) + { + foreach (var engulfedObject in engulfer.EngulfedObjects) + { + worldSimulation.DestroyEntity(engulfedObject); + } + + engulfer.UsedIngestionCapacity = 0; + } + + engulfer.ExpelledObjects?.Clear(); + } + + /// + /// Moves all engulfables from to + /// + public static void TransferEngulferObjectsToAnotherEngulfer(this ref Engulfer engulfer, + in Entity engulferEntity, ref Engulfer targetEngulfer, in Entity targetEngulferEntity) + { + lock (AttachedToEntityHelpers.EntityAttachRelationshipModifyLock) + { + if (engulfer.EngulfedObjects is not { Count: > 0 }) + return; + + // Can't move to a dead engulfer + if (targetEngulferEntity.Get().Dead) + return; + + foreach (var ourEngulfedEntity in engulfer.EngulfedObjects.ToList()) + { + if (!engulfer.EngulfedObjects.Remove(ourEngulfedEntity) || !ourEngulfedEntity.IsAlive || + !ourEngulfedEntity.Has()) + { + continue; + } + + ref var engulfed = ref ourEngulfedEntity.Get(); + + targetEngulfer.TakeOwnershipOfEngulfed(targetEngulferEntity, ref engulfed, ourEngulfedEntity); + } + } + } + + /// + /// Moves an already engulfed object to be engulfed by this engulfer + /// + /// + /// + /// This has to be called with + /// already locked + /// + /// + private static void TakeOwnershipOfEngulfed(this ref Engulfer engulfer, in Entity engulferEntity, + ref Engulfable engulfable, in Entity engulfableEntity) + { + engulfable.HostileEngulfer = engulfableEntity; + + engulfer.EngulfedObjects ??= new List(); + + engulfer.EngulfedObjects.Add(engulfableEntity); + + // TODO: Modify the attached to component to point to the new parent + // TODO: adjust position relative position + + throw new NotImplementedException(); + } + } +} diff --git a/src/microbe_stage/components/MicrobeAI.cs b/src/microbe_stage/components/MicrobeAI.cs new file mode 100644 index 00000000000..46648e1bf4e --- /dev/null +++ b/src/microbe_stage/components/MicrobeAI.cs @@ -0,0 +1,103 @@ +namespace Components +{ + using System; + using System.Collections.Generic; + using DefaultEcs; + using Godot; + using Newtonsoft.Json; + using Systems; + + /// + /// AI for a single Microbe (enables the . to run on this). And also the memory for + /// the AI. + /// + public struct MicrobeAI + { + public float TimeUntilNextThink; + + public float PreviousAngle; + + public Vector3 TargetPosition; + + public Entity FocusedPrey; + + public Vector3? LastSmelledCompoundPosition; + + public float PursuitThreshold; + + /// + /// A value between 0.0f and 1.0f, this is the portion of the microbe's atp bar that needs to refill + /// before resuming motion. + /// + public float ATPThreshold; + + /// + /// Stores the value of microbe.totalAbsorbedCompound at tick t-1 before it is cleared and updated at tick t. + /// Used for compounds gradient computation. + /// + /// + /// + /// Memory of the previous absorption step is required to compute gradient (which is a variation). + /// Values dictionary rather than single value as they will be combined with variable weights. + /// + /// + public Dictionary? PreviouslyAbsorbedCompounds; + + [JsonIgnore] + public Dictionary? CompoundsSearchWeights; + + [JsonProperty] + public bool HasBeenNearPlayer; + } + + public static class MicrobeAIHelpers + { + /// + /// Resets AI status when this AI controlled microbe is removed from a colony + /// + public static void ResetAI(this ref MicrobeAI ai) + { + ai.PreviousAngle = 0; + ai.TargetPosition = Vector3.Zero; + ai.FocusedPrey = default; + ai.PursuitThreshold = 0; + + throw new NotImplementedException(); + + // microbe.MovementDirection = Vector3.Zero; + // microbe.TotalAbsorbedCompounds.Clear(); + } + + public static void MoveToLocation(this ref MicrobeAI ai, Vector3 targetPosition, ref MicrobeControl control) + { + control.State = MicrobeState.Normal; + ai.TargetPosition = targetPosition; + control.LookAtPoint = ai.TargetPosition; + control.SetMoveSpeed(Constants.AI_BASE_MOVEMENT); + } + + public static void MoveWithRandomTurn(this ref MicrobeAI ai, float minTurn, float maxTurn, + Vector3 currentPosition, ref MicrobeControl control, float speciesActivity, Random random) + { + var turn = random.Next(minTurn, maxTurn); + if (random.Next(2) == 1) + { + turn = -turn; + } + + var randDist = random.Next(speciesActivity, Constants.MAX_SPECIES_ACTIVITY); + ai.TargetPosition = currentPosition + + new Vector3(Mathf.Cos(ai.PreviousAngle + turn) * randDist, + 0, + Mathf.Sin(ai.PreviousAngle + turn) * randDist); + ai.PreviousAngle += turn; + control.LookAtPoint = ai.TargetPosition; + control.SetMoveSpeed(Constants.AI_BASE_MOVEMENT); + } + + public static void LowerPursuitThreshold(this ref MicrobeAI ai) + { + ai.PursuitThreshold *= 0.95f; + } + } +} diff --git a/src/microbe_stage/components/MicrobeColony.cs b/src/microbe_stage/components/MicrobeColony.cs new file mode 100644 index 00000000000..00fef7a0e37 --- /dev/null +++ b/src/microbe_stage/components/MicrobeColony.cs @@ -0,0 +1,693 @@ +namespace Components +{ + using System; + using System.Collections.Generic; + using System.Linq; + using DefaultEcs; + using DefaultEcs.Command; + using Godot; + using Newtonsoft.Json; + + /// + /// Microbe colony newMember. This component is added to the colony lead cell. This contains the overall info + /// about the cell colony or early multicellular creature. + /// + public struct MicrobeColony + { + /// + /// All colony members of this colony. The cell at index 0 has to be the . Only modify + /// this data through the helper methods to ensure everything is consistent. + /// + public Entity[] ColonyMembers; + + /// + /// Lead cell of the colony. This is the newMember that exists separately in the world, all others are + /// attached to it with components. Note this is always assumed to be the + /// same as the entity that has this component on it. + /// + public Entity Leader; + + /// + /// This maps parent cells to their children in the colony hierarchy. All cells are merged into the leader, + /// but certain operations like removing cells need to not leave gaps in the colony for that this is used to + /// detect which cells are also lost if one cell is lost. Key is the dependent cell and the value is its + /// parent. + /// + public Dictionary ColonyStructure; + + /// + /// The colony compounds. Use the for accessing this as it + /// automatically sets this up if missing. + /// + [JsonIgnore] + public ColonyCompoundBag? ColonyCompounds; + + public float ColonyRotationMultiplier; + + /// + /// The overall state of the colony, this variable is required to allow colonies where only some cells can + /// engulf to properly enter engulf mode etc. and ensure newly added cells pick up the right mode. + /// + public MicrobeState ColonyState; + + // Note that the following statistics should be accessed through the helpers to ensure that they have been + // calculated. This is implemented like this to simplify spawning to not require full entities to exist at that + // point. Instead only when the properties are used they are calculated when the colony member entities are + // certainly created. + + public int HexCount; + public bool CanEngulf; + + /// + /// Internal variable don't touch. + /// + public bool DerivedStatisticsCalculated; + + public bool EntityWeightApplied; + + /// + /// Internal variable, don't touch + /// + [JsonIgnore] + public bool ColonyRotationMultiplierCalculated; + + /// + /// Creates a new colony with a leader and cells attached to it. Assumes a flat hierarchy where all members + /// are directly attached to the leader + /// + public MicrobeColony(in Entity leader, MicrobeState initialState, params Entity[] allMembers) + { + if (allMembers.Length < 2) + throw new ArgumentException("Microbe colony requires at least one lead cell and one member"); + +#if DEBUG + if (allMembers[0] != leader || allMembers[1] == leader) + throw new ArgumentException("Colony leader needs to be first in member array"); +#endif + + Leader = leader; + ColonyMembers = allMembers; + + // Grab initial state from leader to preserve that (only really important for multicellular) + ColonyState = initialState; + + ColonyStructure = new Dictionary(); + + foreach (var member in allMembers) + { + if (member == leader) + continue; + + ColonyStructure[member] = leader; + } + + ColonyRotationMultiplier = 1; + ColonyRotationMultiplierCalculated = false; + ColonyCompounds = null; + + HexCount = 0; + CanEngulf = false; + DerivedStatisticsCalculated = false; + EntityWeightApplied = false; + } + } + + public static class MicrobeColonyHelpers + { + // TODO: implement this (will need to swap all users of the member list to also read a new count variable + // from the colony class) + // public static readonly ArrayPool MicrobeColonyMemberListPool = ArrayPool.Create(100, 50); + + private static readonly List DependentMembersToRemove = new(); + + public static ColonyCompoundBag GetCompounds(this ref MicrobeColony colony) + { + if (colony.ColonyCompounds != null) + return colony.ColonyCompounds; + + return colony.ColonyCompounds = new ColonyCompoundBag(colony.ColonyMembers); + } + + /// + /// Applies a colony-wide state (for example makes all cells that can be in engulf mode in the colony be in + /// engulf mode) + /// + public static void SetColonyState(this ref MicrobeColony colony, MicrobeState state) + { + if (state == colony.ColonyState) + return; + + colony.ColonyState = state; + + foreach (var cell in colony.ColonyMembers) + { + if (cell.IsAlive) + { + // Setting this directly relies on all systems unsetting the state on cells that can't actually + // perform the state + cell.Get().State = state; + } + } + } + + /// + /// Whether one or more member of this colony is allowed to enter engulf mode. This is recalculated if + /// the value is not currently known. + /// + /// True if any can engulf + public static bool CanEngulf(this ref MicrobeColony colony) + { + if (!colony.DerivedStatisticsCalculated) + colony.UpdateColonyEntityCachedStatistics(); + + return colony.CanEngulf; + } + + /// + /// Hex count in the entire colony. Recalculates the value if it isn't currently known. + /// + /// Total number of organelle hexes in the colony + public static int HexCount(this ref MicrobeColony colony) + { + if (!colony.DerivedStatisticsCalculated) + colony.UpdateColonyEntityCachedStatistics(); + + return colony.HexCount; + } + + /// + /// The accumulation of all the colony member's . + /// + /// + /// + /// This unfortunately is not cached as can change + /// every frame. And this is relatively expensive to calculate as this needs to read a lot of entities. + /// + /// + public static float CalculateUsedIngestionCapacity(this ref MicrobeColony colony) + { + float usedCapacity = 0; + + foreach (var colonyMember in colony.ColonyMembers) + { + ref var engulfer = ref colonyMember.Get(); + usedCapacity += engulfer.UsedIngestionCapacity; + } + + return usedCapacity; + } + + /// + /// Perform an action for all members of this cell's colony other than this cell if this is the colony leader. + /// + public static void PerformForOtherColonyMembersThanLeader(this ref MicrobeColony colony, Action action, + Entity skipEntity) + { + foreach (var cell in colony.ColonyMembers) + { + if (cell == skipEntity) + continue; + + action(cell); + } + } + + public static bool GetMicrobeFromSubShape(this ref MicrobeColony colony, + ref MicrobePhysicsExtraData physicsExtraData, uint subShape, out Entity microbe) + { + if (physicsExtraData.MicrobeIndexFromSubShape(subShape, out int microbeIndex)) + { +#if DEBUG + if (microbeIndex == -1) + throw new InvalidOperationException("Bad calculated microbe index"); +#endif + + // In case the physics data is not yet up to date compared to the colony members, skip + if (microbeIndex > colony.ColonyMembers.Length) + { + microbe = default; + return false; + } + + microbe = colony.ColonyMembers[microbeIndex]; + return true; + } + + microbe = default; + return false; + } + + /// + /// Adds a member to a colony. This takes in a recorder to ensure thread safety during a world update. + /// + /// + /// True when added. False if some data like membrane wasn't ready yet (this will print an error) + /// + public static bool AddToColony(this ref MicrobeColony colony, in Entity colonyEntity, int parentIndex, + Entity newMember, EntityCommandRecorder recorder) + { + if (newMember.Has()) + throw new ArgumentException("Microbe or master null or microbe already is in a colony"); + +#if DEBUG + if (colony.ColonyMembers.Contains(newMember)) + { + throw new InvalidOperationException("Trying to add same newMember twice to colony"); + } +#endif + + ref var cellProperties = ref colonyEntity.Get(); + + ref var newMemberPosition = ref newMember.Get(); + ref var newMemberProperties = ref newMember.Get(); + + var parentMicrobe = colony.ColonyMembers[parentIndex]; + + // Calculate the attach position + Vector3 offsetToColonyLeader; + Quat rotationToLeader; + + try + { + (offsetToColonyLeader, rotationToLeader) = GetNewRelativeTransform( + ref parentMicrobe.Get(), + ref parentMicrobe.Get(), ref newMemberPosition, ref newMemberProperties); + + if (parentIndex != 0) + { + // Not attaching directly to the colony leader, need to combine the offsets + ref var parentsAttachOffset = ref parentMicrobe.Get(); + + offsetToColonyLeader += parentsAttachOffset.RelativePosition; + + // TODO: check that the multiply order is right here + rotationToLeader = parentsAttachOffset.RelativeRotation * rotationToLeader; + } + } + catch (Exception e) + { + GD.PrintErr("Microbe colony related data not initialized enough to add colony member: ", e); + return false; + } + + // TODO: switch to using a pool here. Can't easily switch right now as the array length is used in various + // places currently so having the length exceed the actual member count will be problematic + var newMembers = new Entity[colony.ColonyMembers.Length + 1]; + + for (int i = 0; i < colony.ColonyMembers.Length; ++i) + { + newMembers[i] = colony.ColonyMembers[i]; + } + + newMembers[colony.ColonyMembers.Length] = newMember; + colony.ColonyMembers = newMembers; + + colony.MarkMembersChanged(); + + // Need to recreate the physics body for this colony + cellProperties.ShapeCreated = false; + + colony.ColonyStructure[newMember] = parentMicrobe; + + var memberRecord = recorder.Record(newMember); + memberRecord.Set(new MicrobeColonyMember(colonyEntity)); + memberRecord.Set(new AttachedToEntity(colonyEntity, offsetToColonyLeader, rotationToLeader)); + + OnColonyMemberAdded(newMember); + return true; + } + + /// + /// Removes a member from this colony. If this is called directly check the usage of + /// + /// + /// True when the colony still exists. False if the entire colony was disbanded + public static bool RemoveFromColony(this ref MicrobeColony colony, in Entity colonyEntity, Entity removedMember, + EntityCommandRecorder recorder) + { + if (colonyEntity.Has()) + { + // Lost a member of the multicellular organism + throw new NotImplementedException(); + + // OnMulticellularColonyCellLost(microbe); + } + + if (!removedMember.Has()) + throw new ArgumentException("Microbe not a member of a colony"); + + if (!colony.ColonyMembers.Contains(removedMember)) + throw new ArgumentException("Cannot remove a colony member who isn't actually a member"); + + ref var control = ref colonyEntity.Get(); + + // Exit cell unbind mode if currently in it (as the user has selected something to unbind) + if (control.State == MicrobeState.Unbinding) + control.State = MicrobeState.Normal; + + // Need to recreate the physics body + ref var cellProperties = ref colonyEntity.Get(); + cellProperties.ShapeCreated = false; + + if (colony.ColonyMembers.Length <= 2) + { + // The whole colony is disbanding + recorder.Record(colonyEntity).Remove(); + + // Call the remove callback on the members + for (int i = 0; i < colony.ColonyMembers.Length; ++i) + { + if (colony.ColonyMembers[i] != colonyEntity) + { + // Handle the normal cleanup here for the non-leader cells (we already queued delete of the + // entire colony component above) + QueueRemoveFormerColonyMemberComponents(removedMember, recorder); + } + + OnColonyMemberRemoved(colony.ColonyMembers[i]); + } + + return false; + } + + // TODO: pooling (see the TODO in the add method) + // TODO: when recursively removing members somehow make sure that we don't need to keep creating more and + // more of these lists... + var newMembers = new Entity[colony.ColonyMembers.Length - 1]; + + int writeIndex = 0; + + // Copy all members except the removed one + for (int i = 0; i < colony.ColonyMembers.Length; ++i) + { + var member = colony.ColonyMembers[i]; + + if (member == removedMember) + continue; + + newMembers[writeIndex++] = member; + } + + if (writeIndex != newMembers.Length) + throw new Exception("Logic error in new member array copy"); + + colony.ColonyMembers = newMembers; + + OnColonyMemberRemoved(removedMember); + + // Remove colony members that depend on the removed member + foreach (var entry in colony.ColonyStructure) + { + if (entry.Value == removedMember) + DependentMembersToRemove.Add(entry.Key); + } + + while (DependentMembersToRemove.Count > 0) + { + var next = DependentMembersToRemove[DependentMembersToRemove.Count - 1]; + + // This is this way around to support recursive calls also adding things here + DependentMembersToRemove.RemoveAt(DependentMembersToRemove.Count - 1); + + if (!colony.RemoveFromColony(colonyEntity, next, recorder)) + { + // Colony is entirely disbanded, doesn't make sense to continue removing things + DependentMembersToRemove.Clear(); + return false; + } + } + + // Remove structure data regarding the removed member + colony.ColonyStructure.Remove(removedMember); + + colony.MarkMembersChanged(); + + QueueRemoveFormerColonyMemberComponents(removedMember, recorder); + + return true; + } + + /// + /// Removes this cell and child cells from the colony. + /// + /// + /// + /// If this is the colony leader, this disbands the whole colony + /// + /// + public static void UnbindAll(in Entity entity, EntityCommandRecorder entityCommandRecorder) + { + ref var control = ref entity.Get(); + + if (control.State is MicrobeState.Unbinding or MicrobeState.Binding) + control.State = MicrobeState.Normal; + + ref var organelles = ref entity.Get(); + + if (!organelles.CanUnbind(ref entity.Get(), entity)) + return; + + lock (AttachedToEntityHelpers.EntityAttachRelationshipModifyLock) + { + if (entity.Has()) + { + // TODO: once the colony leader can leave without the entire colony disbanding this perhaps should + // keep the disband entire colony functionality + // Colony!.RemoveFromColony(this); + + ref var colony = ref entity.Get(); + + try + { + colony.RemoveFromColony(entity, entity, entityCommandRecorder); + } + catch (Exception e) + { + GD.PrintErr("Disbanding a colony for a leader failed: ", e); + } + } + else if (entity.Has()) + { + ref var member = ref entity.Get(); + + if (!member.ColonyLeader.Has()) + { + GD.PrintErr("Microbe colony lead newMember is invalid for unbind"); + return; + } + + ref var colony = ref member.ColonyLeader.Get(); + + try + { + colony.RemoveFromColony(member.ColonyLeader, entity, entityCommandRecorder); + } + catch (Exception e) + { + GD.PrintErr("Disbanding a colony from a member failed: ", e); + } + } + } + } + + /// + /// Variant of unbind allowed to be called *only* outside the game update loop + /// + public static void UnbindAllOutsideGameUpdate(in Entity entity, IWorldSimulation entityWorld) + { + if (entityWorld.Processing) + { + throw new InvalidOperationException( + "Cannot unbind all with this method while running a world simulation"); + } + + var recorder = entityWorld.StartRecordingEntityCommands(); + UnbindAll(entity, recorder); + + recorder.Execute(); + entityWorld.FinishRecordingEntityCommands(recorder); + } + + /// + /// Called for each newMember that is removed from a cell colony + /// + public static void OnColonyMemberRemoved(in Entity removedEntity) + { + if (removedEntity.Has()) + { + ref var callbacks = ref removedEntity.Get(); + + callbacks.OnUnbound?.Invoke(removedEntity); + } + + if (removedEntity.Has()) + { + ref var ai = ref removedEntity.Get(); + ai.ResetAI(); + } + + ref var control = ref removedEntity.Get(); + + // Reset movement to not immediately move after unbind + control.MovementDirection = Vector3.Zero; + + // TODO: should we calculate a look at point here that doesn't cause immediate rotation? + } + + /// + /// Called for each newMember that is added to a cell colony + /// + public static void OnColonyMemberAdded(in Entity addedEntity) + { + ref var control = ref addedEntity.Get(); + + // Multicellular creature can stay in engulf mode when growing things + if (!addedEntity.Has() || control.State != MicrobeState.Engulf) + { + control.State = MicrobeState.Normal; + } + + throw new NotImplementedException(); + + // UnreadyToReproduce(); + } + + /// + /// Calculates an updated newMember weight for a microbe colony to be passed to the + /// component as a new value + /// + /// Recalculated newMember weight of the colony + public static bool CalculateEntityWeight(this ref MicrobeColony colony, in Entity entity, out float weight) + { + // As a good enough approximation assume each cell is about as complex as the first cell + var organelles = entity.Get().Organelles; + + if (organelles == null) + { + weight = 0; + return false; + } + + var singleCellWeight = OrganelleContainerHelpers.CalculateCellEntityWeight(organelles.Count); + + weight = singleCellWeight + singleCellWeight * Constants.MICROBE_COLONY_MEMBER_ENTITY_WEIGHT_MULTIPLIER * + colony.ColonyMembers.Length; + return true; + } + + public static void CalculateRotationMultiplier(this ref MicrobeColony colony) + { + throw new NotImplementedException(); + + colony.ColonyRotationMultiplierCalculated = true; + } + + /// + /// This method calculates the relative rotation and translation this microbe should have to its + /// microbe colony parent. + /// + /// Visual explanation + /// + /// + /// Returns relative translation and rotation + public static (Vector3 Translation, Quat Rotation) GetNewRelativeTransform( + ref WorldPosition colonyParentPosition, ref CellProperties colonyParentProperties, + ref WorldPosition cellPosition, ref CellProperties cellProperties) + { + if (colonyParentProperties.CreatedMembrane == null) + throw new InvalidOperationException("Colony parent cell has no membrane set"); + + if (cellProperties.CreatedMembrane == null) + throw new InvalidOperationException("Cell to add to colony has no membrane set"); + + // Gets the global rotation of the parent + // TODO: verify that the quaternion math is correct here + var globalParentRotation = colonyParentPosition.Rotation; + + // A vector from the parent to me + var vectorFromParent = cellPosition.Position - colonyParentPosition.Position; + + // A vector from me to the parent + var vectorToParent = -vectorFromParent; + + // TODO: using quaternions here instead of assuming that rotating about the up/down axis is right + // would be nice + // This vector represents the vectorToParent as if I had no rotation. + // This works by rotating vectorToParent by the negative value (therefore Down) of my current rotation + // This is important, because GetVectorTowardsNearestPointOfMembrane only works with non-rotated microbes + var vectorToParentWithoutRotation = + vectorToParent.Rotated(Vector3.Down, cellPosition.Rotation.GetEuler().y); + + // This vector represents the vectorFromParent as if the parent had no rotation. + var vectorFromParentWithoutRotation = vectorFromParent.Rotated(Vector3.Down, globalParentRotation.y); + + // Calculates the vector from the center of the parent's membrane towards me with canceled out rotation. + // This gets added to the vector calculated one call before. + var correctedVectorFromParent = colonyParentProperties.CreatedMembrane + .GetVectorTowardsNearestPointOfMembrane(vectorFromParentWithoutRotation.x, + vectorFromParentWithoutRotation.z).Rotated(Vector3.Up, globalParentRotation.y); + + // Calculates the vector from my center to my membrane towards the parent. + // This vector gets rotated back to cancel out the rotation applied two calls above. + // -= to negate the vector, so that the two membrane vectors amplify + correctedVectorFromParent -= cellProperties.CreatedMembrane + .GetVectorTowardsNearestPointOfMembrane(vectorToParentWithoutRotation.x, + vectorToParentWithoutRotation.z) + .Rotated(Vector3.Up, cellPosition.Rotation.GetEuler().y); + + // Rotated because the rotational scope is different. + var newTranslation = correctedVectorFromParent.Rotated(Vector3.Down, globalParentRotation.y); + + // TODO: this used to just negate the euler angles here, check that multiplying by inverse rotation is + // correct + return (newTranslation, cellPosition.Rotation * globalParentRotation.Inverse()); + } + + private static void MarkMembersChanged(this ref MicrobeColony colony) + { + colony.DerivedStatisticsCalculated = false; + colony.EntityWeightApplied = false; + colony.ColonyRotationMultiplierCalculated = false; + + // TODO: maybe in some situations creating the compound bag could be entirely safely skipped here + colony.GetCompounds().UpdateColonyMembers(colony.ColonyMembers); + } + + /// + /// Removes the components from the detached entity that no longer should be on it + /// + private static void QueueRemoveFormerColonyMemberComponents(in Entity removedMember, + EntityCommandRecorder recorder) + { + var memberRecord = recorder.Record(removedMember); + memberRecord.Remove(); + memberRecord.Remove(); + } + + private static void UpdateColonyEntityCachedStatistics(this ref MicrobeColony colony) + { + bool canEngulf = false; + int hexCount = 0; + bool success = true; + + foreach (var colonyMember in colony.ColonyMembers) + { + var organelles = colonyMember.Get().Organelles; + + if (organelles != null) + hexCount += organelles.HexCount; + + if (colonyMember.Get().MembraneType.CanEngulf) + canEngulf = true; + } + + colony.CanEngulf = canEngulf; + + if (success) + { + colony.HexCount = hexCount; + colony.DerivedStatisticsCalculated = true; + } + } + } +} diff --git a/src/microbe_stage/components/MicrobeColonyMember.cs b/src/microbe_stage/components/MicrobeColonyMember.cs new file mode 100644 index 00000000000..d8ae7c136d2 --- /dev/null +++ b/src/microbe_stage/components/MicrobeColonyMember.cs @@ -0,0 +1,45 @@ +namespace Components +{ + using DefaultEcs; + + /// + /// Marker for microbes that are in a cell colony. The cell colony leader has + /// component on it. + /// + public struct MicrobeColonyMember + { + /// + /// The colony leader can be accessed through this if colony members need to send messages back to the + /// colony + /// + public Entity ColonyLeader; + + public MicrobeColonyMember(in Entity colonyLeader) + { + ColonyLeader = colonyLeader; + } + } + + public static class MicrobeColonyMemberHelpers + { + /// + /// Gets the from that colony's member + /// + /// Colony member to start from + /// Set to the colony entity when successful + /// + /// True on success, false if the colony was incorrectly destroyed with this still being a member + /// + public static bool GetColonyFromMember(this ref MicrobeColonyMember member, out Entity colonyEntity) + { + if (member.ColonyLeader.IsAlive && member.ColonyLeader.Has()) + { + colonyEntity = member.ColonyLeader; + return true; + } + + colonyEntity = default; + return false; + } + } +} diff --git a/src/microbe_stage/components/MicrobeControl.cs b/src/microbe_stage/components/MicrobeControl.cs new file mode 100644 index 00000000000..c378ff7167a --- /dev/null +++ b/src/microbe_stage/components/MicrobeControl.cs @@ -0,0 +1,143 @@ +namespace Components +{ + using System; + using DefaultEcs; + using Godot; + + /// + /// Control variables for specifying how a microbe wants to move / behave + /// + public struct MicrobeControl + { + /// + /// The point towards which the microbe will move to point to + /// + public Vector3 LookAtPoint; + + /// + /// The direction the microbe wants to move. Doesn't need to be normalized + /// + public Vector3 MovementDirection; + + /// + /// If not null this microbe will fire the specified toxin on next update. This is done to allow + /// multithreaded AI to decide to fire a toxin. + /// + public Compound? QueuedToxinToEmit; + + /// + /// This is here as this is very closely related to + /// + public float SlimeSecretionCooldown; + + /// + /// How long this microbe wants to emit slime (this is done so that AI which doesn't run each frame can still + /// sufficiently control the emission of slime) + /// + public float QueuedSlimeSecretionTime; + + /// + /// Time until this microbe can fire agents (toxin) again + /// + public float AgentEmissionCooldown; + + /// + /// This is an overall state of the Microbe + /// + public MicrobeState State; + + /// + /// Whether this microbe is currently being slowed by environmental slime + /// + public bool SlowedBySlime; + + /// + /// Constructs an instance with a sensible set + /// + /// World position this entity is starting at + public MicrobeControl(Vector3 startingPosition) + { + LookAtPoint = startingPosition + new Vector3(0, 0, -1); + MovementDirection = new Vector3(0, 0, 0); + QueuedToxinToEmit = null; + SlimeSecretionCooldown = 0; + QueuedSlimeSecretionTime = 0; + AgentEmissionCooldown = 0; + State = MicrobeState.Normal; + SlowedBySlime = false; + } + } + + public static class MicrobeControlHelpers + { + /// + /// Queues a toxin emit if possible. Only one can be queued at a time. + /// + public static bool EmitToxin(this ref MicrobeControl control, ref OrganelleContainer organelles, + CompoundBag availableCompounds, in Entity entity, Compound? agentType = null) + { + // Disallow toxins when engulfed + if (entity.Get().PhagocytosisStep != PhagocytosisPhase.None) + return false; + + agentType ??= SimulationParameters.Instance.GetCompound("oxytoxy"); + + if (entity.Has()) + { + throw new NotImplementedException(); + + // PerformForOtherColonyMembersIfWeAreLeader(m => m.EmitToxin(agentType)); + } + + if (control.AgentEmissionCooldown > 0) + return false; + + // Only shoot if you have an agent vacuole. + if (organelles.AgentVacuoleCount < 1) + return false; + + float amountAvailable = availableCompounds.GetCompoundAmount(agentType); + + // Skip if too little agent available + if (amountAvailable < Constants.MINIMUM_AGENT_EMISSION_AMOUNT) + return false; + + control.QueuedToxinToEmit = agentType; + + return true; + } + + public static void SetMoveSpeed(this ref MicrobeControl control, float speed) + { + control.MovementDirection = new Vector3(0, 0, -speed); + } + + public static void QueueSecreteSlime(this ref MicrobeControl control, + ref OrganelleContainer organelleInfo, in Entity entity, float duration) + { + if (entity.Has()) + { + throw new NotImplementedException(); + + // PerformForOtherColonyMembersIfWeAreLeader(m => m.QueueSecreteSlime(duration)); + } + + if (organelleInfo.SlimeJets == null || organelleInfo.SlimeJets.Count < 1) + return; + + control.QueuedSlimeSecretionTime += duration; + } + + public static void SecreteSlimeForSomeTime(this ref MicrobeControl control, + ref OrganelleContainer organelleInfo, Random random) + { + // TODO: AI might want in the future to use all slime jets in a colony + + if ((organelleInfo.SlimeJets?.Count ?? 0) > 0) + { + // Randomise the time spent ejecting slime, from 0 to 3 seconds + control.QueuedSlimeSecretionTime = 3 * random.NextFloat(); + } + } + } +} diff --git a/src/microbe_stage/components/MicrobeEventCallbacks.cs b/src/microbe_stage/components/MicrobeEventCallbacks.cs new file mode 100644 index 00000000000..a3505f65392 --- /dev/null +++ b/src/microbe_stage/components/MicrobeEventCallbacks.cs @@ -0,0 +1,95 @@ +namespace Components +{ + using System; + using System.Collections.Generic; + using DefaultEcs; + using Godot; + + /// + /// Entity that triggers various microbe event callbacks when things happens to it. This is mostly used for + /// connecting the player cell to the GUI and game stage. + /// + public struct MicrobeEventCallbacks + { + public Action? OnUnbindEnabled; + + public Action? OnUnbound; + + public Action? OnIngestedByHostile; + + public Action? OnSuccessfulEngulfment; + + public Action? OnEngulfmentStorageFull; + + public Action? OnNoticeMessage; + + /// + /// Called when the reproduction status of this microbe changes + /// + public Action? OnReproductionStatus; + + /// + /// Called periodically to report the chemoreception settings of the microbe. Reports both compound and + /// species detections. + /// + public Action?, + List<(Species Species, Entity Entity, Color Colour, Vector3 Target)>?>? OnChemoreceptionInfo; + } + + public static class MicrobeEventCallbackHelpers + { + /// + /// Send a microbe notice message to the entity if possible + /// + /// Entity to send the message to + /// The message text + /// True if sent, false if missing the component or callback + public static bool SendNoticeIfPossible(this in Entity entity, LocalizedString message) + { + if (!entity.Has()) + return false; + + ref var callbacks = ref entity.Get(); + + if (callbacks.OnNoticeMessage == null) + return false; + + callbacks.OnNoticeMessage.Invoke(entity, new SimpleHUDMessage(message.ToString())); + return true; + } + + /// + /// Variant that uses a factory method that is only called if the message can be sent to generate the message + /// + public static bool SendNoticeIfPossible(this in Entity entity, Func messageFactory) + { + if (!entity.Has()) + return false; + + ref var callbacks = ref entity.Get(); + + if (callbacks.OnNoticeMessage == null) + return false; + + callbacks.OnNoticeMessage.Invoke(entity, messageFactory()); + return true; + } + + /// + /// Variant that uses an always allocated HUD message + /// + public static bool SendNoticeIfPossible(this in Entity entity, SimpleHUDMessage message) + { + if (!entity.Has()) + return false; + + ref var callbacks = ref entity.Get(); + + if (callbacks.OnNoticeMessage == null) + return false; + + callbacks.OnNoticeMessage.Invoke(entity, new SimpleHUDMessage(message.ToString())); + return true; + } + } +} diff --git a/src/microbe_stage/components/MicrobePhysicsExtraData.cs b/src/microbe_stage/components/MicrobePhysicsExtraData.cs new file mode 100644 index 00000000000..95c88763c7f --- /dev/null +++ b/src/microbe_stage/components/MicrobePhysicsExtraData.cs @@ -0,0 +1,78 @@ +namespace Components +{ + using Systems; + + /// + /// Extra data about microbe physics bodies. Stores info on colony members and pili for special physics handling + /// when certain sub-shapes collide + /// + public struct MicrobePhysicsExtraData + { + /// + /// When this is 0 this data is not initialized. Don't change the values in this struct from anywhere else + /// than + /// + public int TotalShapeCount; + + /// + /// Total microbe shapes. In the physics body there's this many physics shapes first that represent cells. + /// The indexes of the sub-shapes match the order of cells in the microbe colony. + /// + public int MicrobeShapesCount; + + /// + /// How many pilus collision shapes there are in the physics body after the microbe shapes. 0 means there + /// are no pili. + /// + public int PilusCount; + + /// + /// How many of the last shapes are injectisomes. If 0 then all pili are normal + /// pili. + /// + public int PilusInjectisomeCount; + } + + public static class MicrobePhysicsExtraDataHelpers + { + public static bool IsSubShapePilus(this ref MicrobePhysicsExtraData physicsExtraData, uint subShape) + { + // Index needs to be higher than all the microbes index but lower than the number of pili above that + return subShape >= physicsExtraData.MicrobeShapesCount && + subShape < physicsExtraData.MicrobeShapesCount + physicsExtraData.PilusCount; + } + + /// + /// After returns true this can be used to check if a pilus is an injectisome + /// or normal pilus. + /// + /// True if injectisome + public static bool IsSubShapeInjectisomeIfIsPilus(this ref MicrobePhysicsExtraData physicsExtraData, + uint subShape) + { + var pilusIndex = subShape - physicsExtraData.MicrobeShapesCount; + + return pilusIndex >= physicsExtraData.PilusCount - physicsExtraData.PilusInjectisomeCount; + } + + public static bool MicrobeIndexFromSubShape(this ref MicrobePhysicsExtraData physicsExtraData, uint subShape, + out int index) + { + if (subShape < physicsExtraData.MicrobeShapesCount) + { + index = (int)subShape; + return true; + } + + // When there's one subs-shape it gets mapped to uint.max so handle that + if (subShape == PhysicsCollision.COLLISION_UNKNOWN_SUB_SHAPE && physicsExtraData.MicrobeShapesCount == 1) + { + index = 0; + return true; + } + + index = -1; + return false; + } + } +} diff --git a/src/microbe_stage/components/MicrobeShaderParameters.cs b/src/microbe_stage/components/MicrobeShaderParameters.cs new file mode 100644 index 00000000000..63dd7072040 --- /dev/null +++ b/src/microbe_stage/components/MicrobeShaderParameters.cs @@ -0,0 +1,95 @@ +namespace Components +{ + using DefaultEcs; + using DefaultEcs.Command; + + /// + /// Allows control over the few (animation) shader parameters available in the microbe stage for some entities. + /// Requires to apply. + /// + public struct MicrobeShaderParameters + { + /// + /// Dissolve effect value, range [0, 1]. 0 is default not dissolved state + /// + public float DissolveValue; + + /// + /// Automatically animate the when this is not 0 and + /// is true. 1 is default speed. + /// + public float DissolveAnimationSpeed; + + /// + /// Set to true to enable playing any of the separate animations. If this is false none of the animations + /// play at all. + /// + public bool PlayAnimations; + + /// + /// Always reset this to false after changing something to have the changes apply + /// + public bool ParametersApplied; + } + + public static class MicrobeShaderParametersHelpers + { + /// + /// Starts a dissolve animation on an entity. If this also adds a timed + /// life component (when missing on the entity) to delete the entity once the animation is complete + /// + /// The time in seconds the animation is expected to take + public static float StartDissolveAnimation(this Entity entity, IWorldSimulation newComponentCreator, + bool useChunkSpeed, bool addTimedLifeIfMissing) + { + float speed = 1; + + if (useChunkSpeed) + speed = Constants.FLOATING_CHUNKS_DISSOLVE_SPEED; + + EntityCommandRecorder? recorder = null; + + if (entity.Has()) + { + ref var shaderParameters = ref entity.Get(); + + shaderParameters.DissolveAnimationSpeed = speed; + shaderParameters.PlayAnimations = true; + } + else + { + recorder = newComponentCreator.StartRecordingEntityCommands(); + + var record = recorder.Record(entity); + + record.Set(new MicrobeShaderParameters + { + DissolveAnimationSpeed = speed, + PlayAnimations = true, + }); + } + + // Add a tiny bit of extra time to ensure the animation is finished by the time is elapsed (for example + // despawning with a delay purposes) + var duration = 1 / speed + 0.0001f; + + if (addTimedLifeIfMissing && !entity.Has()) + { + // Add a timed life component as the dissolve animation doesn't despawn the entity + + recorder ??= newComponentCreator.StartRecordingEntityCommands(); + var record = recorder.Record(entity); + + record.Set(new TimedLife + { + TimeToLiveRemaining = duration, + }); + } + + if (recorder != null) + newComponentCreator.FinishRecordingEntityCommands(recorder); + + return duration; + } + } +} diff --git a/src/microbe_stage/components/MicrobeSpeciesMember.cs b/src/microbe_stage/components/MicrobeSpeciesMember.cs new file mode 100644 index 00000000000..cac5f536854 --- /dev/null +++ b/src/microbe_stage/components/MicrobeSpeciesMember.cs @@ -0,0 +1,11 @@ +namespace Components +{ + /// + /// Entity is a member of a species and has species related data applied to it. Note that for most things + /// should be used instead as that works for early multicellular things as well. + /// + public struct MicrobeSpeciesMember + { + public MicrobeSpecies Species; + } +} diff --git a/src/microbe_stage/components/MicrobeStatus.cs b/src/microbe_stage/components/MicrobeStatus.cs new file mode 100644 index 00000000000..d5754afc4cf --- /dev/null +++ b/src/microbe_stage/components/MicrobeStatus.cs @@ -0,0 +1,31 @@ +namespace Components +{ + using Godot; + + /// + /// A collection place for various microbe status flags and variables that don't have more sensible components + /// to put them in + /// + public struct MicrobeStatus + { + // Variables related to movement sound playing + public Vector3 LastLinearVelocity; + public Vector3 LastLinearAcceleration; + public float MovementSoundCooldownTimer; + + public float LastCheckedATPDamage; + + public float LastCheckedOxytoxyDigestionDamage; + + // TODO: remove if rate limited reproduction is not needed + // public float LastCheckedReproduction; + + public float TimeUntilChemoreceptionUpdate; + + /// + /// Flips every reproduction update. Used to make compound use for reproduction distribute more evenly between + /// the compound types. + /// + public bool ConsumeReproductionCompoundsReverse; + } +} diff --git a/src/microbe_stage/components/OrganelleContainer.cs b/src/microbe_stage/components/OrganelleContainer.cs new file mode 100644 index 00000000000..0140511cd07 --- /dev/null +++ b/src/microbe_stage/components/OrganelleContainer.cs @@ -0,0 +1,661 @@ +namespace Components +{ + using System; + using System.Collections.Generic; + using System.Linq; + using DefaultEcs; + using Godot; + using Newtonsoft.Json; + using Systems; + + /// + /// Entity that contains + /// + public struct OrganelleContainer + { + /// + /// Instances of all the organelles in this entity. This is saved but components are not saved. This means + /// that components are re-created when a save is loaded. + /// + public OrganelleLayout? Organelles; + + public Dictionary? AvailableEnzymes; + + // The following few component vectors exist to allow access ti update the state of a few organelle components + // from various systems to update the visuals state + + // TODO: maybe move these component caches to a separate component to reduce this component's size? + /// + /// The slime jets attached to this microbe. JsonIgnore as the components add themselves to this list each + /// load (as they are recreated). + /// + [JsonIgnore] + public List? SlimeJets; + + /// + /// Flagellum components that need to be animated when the cell is moving at top speed + /// + [JsonIgnore] + public List? ThrustComponents; + + /// + /// Cilia components that need to be animated when the cell is rotating fast + /// + [JsonIgnore] + public List? RotationComponents; + + /// + /// Compound detections set by chemoreceptor organelles. + /// + [JsonIgnore] + public HashSet<(Compound Compound, float Range, float MinAmount, Color Colour)>? ActiveCompoundDetections; + + /// + /// Compound detections set by chemoreceptor organelles. + /// + [JsonIgnore] + public HashSet<(Species TargetSpecies, float Range, Color Colour)>? ActiveSpeciesDetections; + + /// + /// The number of agent vacuoles. Determines the time between toxin shots. + /// + public int AgentVacuoleCount; + + /// + /// The microbe stores here the sum of capacity of all the current organelles. This is here to prevent anyone + /// from messing with this value if we used the Capacity from the CompoundBag for the calculations that use + /// this. + /// + public float OrganellesCapacity; + + public int HexCount; + + public float RotationSpeed; + + // TODO: add the following variables only if really needed + // private bool organelleMaxRenderPriorityDirty = true; + // private int cachedOrganelleMaxRenderPriority; + + // TODO: could maybe redo these "feature flags" by having separate tagging components? + public bool HasSignalingAgent; + + public bool HasBindingAgent; + + /// + /// True once all organelles are divided to not continuously run code that is triggered when a cell is ready + /// to reproduce. + /// + /// + /// + /// This is not saved so that the player cell can enable the editor when loading a save where the player is + /// ready to reproduce. If more code is added to be ran just once based on this flag, it needs to be made + /// sure that that code re-running after loading a save is not a problem. + /// + /// + [JsonIgnore] + public bool AllOrganellesDivided; + + /// + /// Reset this if the organelles are changed to make the recreate them + /// + [JsonIgnore] + public bool OrganelleVisualsCreated; + + /// + /// Reset this if organelles are changed. Otherwise etc. variables won't work + /// correctly + /// + [JsonIgnore] + public bool OrganelleComponentsCached; + + /// + /// Internal variable used by the to only create visuals for missing / + /// removed organelles + /// + [JsonIgnore] + public Dictionary? CreatedOrganelleVisuals; + + // TODO: maybe put the process list refresh variable here and a some new system to regenerate the process list? + // instead of just doing it when changing the organelles? + } + + public static class OrganelleContainerHelpers + { + private static readonly Lazy Lipase = new(() => SimulationParameters.Instance.GetEnzyme("lipase")); + + /// + /// Returns the check result whether this microbe can digest the target (has the enzyme necessary). + /// + /// + /// + /// This is different from because ingestibility and + /// digestibility are separate, you can engulf a walled cell but not digest it if you're missing the enzyme + /// required to do so. + /// + /// + public static DigestCheckResult CanDigestObject(this OrganelleContainer organelleContainer, + ref Engulfable engulfable) + { + var enzyme = engulfable.RequisiteEnzymeToDigest; + + if (enzyme != null && organelleContainer.AvailableEnzymes?.ContainsKey(enzyme) != true) + return DigestCheckResult.MissingEnzyme; + + return DigestCheckResult.Ok; + } + + /// + /// Returns true if the given organelles can enter binding mode. Multicellular species can't attach random + /// cells to themselves anymore. + /// + public static bool CanBind(this ref OrganelleContainer organelleContainer, ref SpeciesMember species) + { + return species.Species is MicrobeSpecies && organelleContainer.HasBindingAgent; + } + + public static bool CanBind(this ref OrganelleContainer organelleContainer, Species species) + { + return species is MicrobeSpecies && organelleContainer.HasBindingAgent; + } + + /// + /// Returns true if this entity can bind with the target + /// + public static bool CanBindWith(this ref OrganelleContainer organelleContainer, Species ourSpecies, + Entity other) + { + // Can only bind with microbes + if (!other.Has()) + return false; + + // Things with missing binding agents can't bind (this is just an extra safety check and an excuse to make + // organelleContainer parameter be actually used) + if (!organelleContainer.HasBindingAgent) + return false; + + // Cannot hijack the player + if (other.Has()) + return false; + + // Cannot bind with other species (this explicitly doesn't use the ID check as this is a pretty important + // thing to never go wrong by binding a cell that shouldn't be bound to) + if (other.Get().Species != ourSpecies) + return false; + + // Cannot hijack other colonies (TODO: yet) + if (other.Has() || other.Has()) + return false; + + // Can't bind with dead things + if (other.Get().Dead) + return false; + + // Other must have membrane created (but not absolutely necessarily up to date) + if (other.Get().CreatedMembrane == null) + return false; + + return true; + } + + public static bool CanUnbind(this ref OrganelleContainer organelleContainer, ref SpeciesMember species, + in Entity entity) + { + return species.Species is MicrobeSpecies && entity.Has(); + } + + public static void CreateOrganelleLayout(this ref OrganelleContainer container, ICellProperties cellProperties) + { + container.Organelles?.Clear(); + + container.Organelles ??= new OrganelleLayout(); + + foreach (var organelleTemplate in cellProperties.Organelles) + { + container.Organelles.Add(new PlacedOrganelle(organelleTemplate.Definition, organelleTemplate.Position, + organelleTemplate.Orientation, organelleTemplate.Upgrades)); + } + + container.CalculateOrganelleLayoutStatistics(); + + container.AllOrganellesDivided = false; + + // Reset this to notify the visuals system that it needs to check the new changed organelles + container.OrganelleVisualsCreated = false; + } + + /// + /// Resets a created layout of organelles on an existing microbe. This variant exists as this can perform + /// some extra operations not yet valid when initially creating a layout. + /// + public static void ResetOrganelleLayout(this ref OrganelleContainer container, + ref CompoundStorage storageToUpdate, ref BioProcesses bioProcessesToUpdate, in Entity entity, + ICellProperties cellProperties, Species baseReproductionCostFrom) + { + container.CreateOrganelleLayout(cellProperties); + + // Reproduction progress is lost + container.AllOrganellesDivided = false; + + ref var reproduction = ref entity.Get(); + reproduction.SetupRequiredBaseReproductionCompounds(baseReproductionCostFrom); + + // Unbind if a colony's master cell removed its binding agent. + if (!container.HasBindingAgent && entity.Has()) + { + throw new NotImplementedException(); + + // Colony.RemoveFromColony(this); + } + + ref var status = ref entity.Get(); + + // Make chemoreception update happen immediately in case the settings changed so that new information is + // used earlier + status.TimeUntilChemoreceptionUpdate = 0; + + if (entity.Has()) + { + throw new NotImplementedException(); + + // ResetMulticellularProgress(); + } + + container.UpdateCompoundBagStorageFromOrganelles(ref storageToUpdate); + + container.RecalculateOrganelleBioProcesses(ref bioProcessesToUpdate); + } + + /// + /// Marks that the organelles have changed. Has to be called for things to be refreshed. + /// + public static void OnOrganellesChanged(this ref OrganelleContainer container, ref CompoundStorage storage, + ref BioProcesses bioProcesses) + { + container.OrganelleVisualsCreated = false; + container.OrganelleComponentsCached = false; + + // TODO: should there be a specific system that refreshes this data? + // CreateOrganelleLayout might need changes in that case to call this method immediately + container.CalculateOrganelleLayoutStatistics(); + container.UpdateCompoundBagStorageFromOrganelles(ref storage); + + container.RecalculateOrganelleBioProcesses(ref bioProcesses); + } + + public static void RecalculateOrganelleBioProcesses(this ref OrganelleContainer container, + ref BioProcesses bioProcesses) + { + if (container.Organelles != null) + bioProcesses.ActiveProcesses = ProcessSystem.ComputeActiveProcessList(container.Organelles); + } + + /// + /// Returns a list of tuples, representing all possible compound targets. These are not all clouds that the + /// microbe can smell using the instanced organelles that add chemoreception capability; + /// only the best candidate of each compound type. + /// + /// The current organelles to use + /// + /// Entity doing the smelling, this is required to perform a check for microbe colony membership and access + /// that data so this method may need to traverse quite a lot of data. + /// + /// The position the smelling entity is at + /// CompoundCloudSystem to scan + /// + /// A list of tuples. Each tuple contains the type of compound, the color of the line (if any needs to be + /// drawn), and the location where the compound is located. + /// + public static List<(Compound Compound, Color Colour, Vector3 Target)>? PerformCompoundDetection( + this ref OrganelleContainer container, in Entity entity, Vector3 position, + IReadonlyCompoundClouds clouds) + { + HashSet<(Compound Compound, float Range, float MinAmount, Color Colour)> collectedUniqueCompoundDetections; + + // Colony lead cell uses all the chemoreceptors in the colony to make them all work + if (entity.Has()) + { + // TODO: reimplement recursive colony smell settings collection + throw new NotImplementedException("colony smelling not reimplemented yet"); + } + else + { + if (container.ActiveCompoundDetections == null) + return null; + + collectedUniqueCompoundDetections = container.ActiveCompoundDetections; + } + + var detections = new List<(Compound Compound, Color Colour, Vector3 Target)>(); + + foreach (var (compound, range, minAmount, colour) in collectedUniqueCompoundDetections) + { + var detectedCompound = clouds.FindCompoundNearPoint(position, compound, range, minAmount); + + if (detectedCompound != null) + { + detections.Add((compound, colour, detectedCompound.Value)); + } + } + + return detections; + } + + public static List<(Species Species, Entity Entity, Color Colour, Vector3 Target)>? PerformMicrobeDetections( + this ref OrganelleContainer container, in Entity entity, Vector3 position, + ISpeciesMemberLocationData microbePositionData) + { + HashSet<(Species Species, float Range, Color Colour)> collectedUniqueSpeciesDetections; + + if (entity.Has()) + { + throw new NotImplementedException(); + } + else + { + if (container.ActiveSpeciesDetections == null) + return null; + + collectedUniqueSpeciesDetections = container.ActiveSpeciesDetections; + } + + var detections = new List<(Species Species, Entity Entity, Color Colour, Vector3 Target)>(); + + foreach (var (species, range, colour) in collectedUniqueSpeciesDetections) + { + if (microbePositionData.FindSpeciesNearPoint(position, species, range, out var foundEntity, + out var foundPosition)) + { + detections.Add((species, foundEntity, colour, foundPosition)); + } + } + + return detections; + } + + public static void CalculateOrganelleLayoutStatistics(this ref OrganelleContainer container) + { + container.AvailableEnzymes?.Clear(); + container.AvailableEnzymes ??= new Dictionary(); + + // TODO: should the cached components (like slime jets) be cleared here? or is it better to keep the old + // components around for a little bit? + // container.SlimeJets?.Clear(); etc... + + container.OrganelleComponentsCached = false; + + // Cells have a minimum of at least one unit of lipase enzyme + container.AvailableEnzymes[Lipase.Value] = 1; + + container.AgentVacuoleCount = 0; + container.OrganellesCapacity = 0; + container.HasSignalingAgent = false; + container.HasBindingAgent = false; + + // TODO: rotation speed calculation + // TODO: rotation penalty from size + // TODO: rotation speed from cilia + // Lower value is faster rotation + container.RotationSpeed = 0.2f; + + if (container.Organelles == null) + throw new InvalidOperationException("Organelle list needs to be initialized first"); + + container.HexCount = container.Organelles.HexCount; + + foreach (var organelle in container.Organelles) + { + foreach (var organelleComponent in organelle.Components) + { + if (organelleComponent is AgentVacuoleComponent) + { + ++container.AgentVacuoleCount; + } + else if (organelleComponent is SlimeJetComponent slimeJetComponent) + { + container.SlimeJets ??= new List(); + container.SlimeJets.Add(slimeJetComponent); + } + else if (organelleComponent is MovementComponent thrustComponent) + { + container.ThrustComponents ??= new List(); + container.ThrustComponents.Add(thrustComponent); + } + else if (organelleComponent is CiliaComponent rotationComponent) + { + container.RotationComponents ??= new List(); + container.RotationComponents.Add(rotationComponent); + } + } + + if (organelle.Definition.HasSignalingFeature) + container.HasSignalingAgent = true; + + if (organelle.Definition.HasBindingFeature) + container.HasBindingAgent = true; + + container.OrganellesCapacity += + MicrobeInternalCalculations.GetNominalCapacityForOrganelle(organelle.Definition, + organelle.Upgrades); + + var enzymes = organelle.GetEnzymes(); + + if (enzymes.Count > 0) + { + foreach (var enzyme in enzymes) + { + // Filter out invalid enzyme values + if (enzyme.Value <= 0) + { + if (enzyme.Value < 0) + { + GD.PrintErr("Enzyme amount in organelle is negative"); + } + + continue; + } + + container.AvailableEnzymes.TryGetValue(enzyme.Key, out var existing); + container.AvailableEnzymes[enzyme.Key] = existing + enzyme.Value; + } + } + } + } + + /// + /// Updates the of a microbe to account for changes in organelles. + /// + /// Organelle data + /// Target compound storage to update + public static void UpdateCompoundBagStorageFromOrganelles(this ref OrganelleContainer container, + ref CompoundStorage compoundStorage) + { + if (container.Organelles == null) + throw new InvalidOperationException("Organelle list needs to be initialized first"); + + var compounds = compoundStorage.Compounds; + + compounds.NominalCapacity = container.OrganellesCapacity; + + MicrobeInternalCalculations.UpdateSpecificCapacities(compounds, container.Organelles); + } + + /// + /// Finds the organelle components that are needed from the outside of the organelles and stores them in the + /// lists in the container component. Updated by a system after + /// + public static void FetchLayoutOrganelleComponents(this ref OrganelleContainer container) + { + container.SlimeJets?.Clear(); + container.ThrustComponents?.Clear(); + container.RotationComponents?.Clear(); + + // This method can be safely called again if this happened to run too early + if (container.Organelles == null) + return; + + foreach (var organelle in container.Organelles) + { + foreach (var organelleComponent in organelle.Components) + { + if (organelleComponent is SlimeJetComponent slimeJetComponent) + { + container.SlimeJets ??= new List(); + container.SlimeJets.Add(slimeJetComponent); + } + else if (organelleComponent is MovementComponent thrustComponent) + { + container.ThrustComponents ??= new List(); + container.ThrustComponents.Add(thrustComponent); + } + else if (organelleComponent is CiliaComponent rotationComponent) + { + container.RotationComponents ??= new List(); + container.RotationComponents.Add(rotationComponent); + } + } + } + + container.OrganelleComponentsCached = true; + } + + /// + /// Calculates the reproduction progress for a cell, used to show how close the player is getting to + /// the editor. + /// + /// The total reproduction progress + public static float CalculateReproductionProgress(this ref OrganelleContainer organelleContainer, + ref ReproductionStatus reproductionStatus, ref SpeciesMember speciesMember, in Entity entity, + CompoundBag storedCompounds, WorldGenerationSettings worldSettings, + out Dictionary gatheredCompounds, out Dictionary totalCompounds) + { + // Calculate total compounds needed to split all organelles + totalCompounds = organelleContainer.CalculateTotalReproductionCompounds(entity, speciesMember.Species); + + // Calculate how many compounds the cell already has absorbed to grow + gatheredCompounds = organelleContainer.CalculateAlreadyAbsorbedCompounds( + ref entity.Get(), + entity, speciesMember.Species); + + // Add the currently held compounds, but only if configured as this can be pretty confusing for players + // to have the bars in ready to reproduce state for a while before the time limited reproduction actually + // catches up + if (Constants.ALWAYS_SHOW_STORED_COMPOUNDS_IN_REPRODUCTION_PROGRESS || + !worldSettings.LimitReproductionCompoundUseSpeed) + { + foreach (var key in gatheredCompounds.Keys.ToList()) + { + float value = Math.Max(0.0f, storedCompounds.GetCompoundAmount(key) - + Constants.ORGANELLE_GROW_STORAGE_MUST_HAVE_AT_LEAST); + + if (value > 0) + { + float existing = gatheredCompounds[key]; + + // Only up to the total needed + float total = totalCompounds[key]; + + gatheredCompounds[key] = Math.Min(total, existing + value); + } + } + } + + float totalFraction = 0; + + foreach (var entry in totalCompounds) + { + if (gatheredCompounds.TryGetValue(entry.Key, out var gathered) && entry.Value != 0) + totalFraction += gathered / entry.Value; + } + + return totalFraction / totalCompounds.Count; + } + + /// + /// Calculates total compounds needed for a cell to reproduce, used by calculateReproductionProgress to + /// calculate the fraction done. + /// + public static Dictionary CalculateTotalReproductionCompounds( + this ref OrganelleContainer organelleContainer, in Entity entity, Species species) + { + if (entity.Has()) + { + throw new NotImplementedException(); + + // return CalculateTotalBodyPlanCompounds(); + } + + var result = organelleContainer.CalculateNonDuplicateOrganelleInitialCompositionTotals(); + + result.Merge(species.BaseReproductionCost); + + return result; + } + + public static Dictionary CalculateNonDuplicateOrganelleInitialCompositionTotals( + this ref OrganelleContainer organelleContainer) + { + if (organelleContainer.Organelles == null) + throw new InvalidOperationException("OrganelleContainer must be initialized first"); + + var result = new Dictionary(); + + foreach (var organelle in organelleContainer.Organelles) + { + if (organelle.IsDuplicate) + continue; + + result.Merge(organelle.Definition.InitialComposition); + } + + return result; + } + + /// + /// Calculates how much compounds organelles have already absorbed + /// + public static Dictionary CalculateAlreadyAbsorbedCompounds( + this ref OrganelleContainer organelleContainer, ref ReproductionStatus baseReproductionInfo, + in Entity entity, Species species) + { + if (organelleContainer.Organelles == null) + throw new InvalidOperationException("OrganelleContainer must be initialized first"); + + var result = new Dictionary(); + + foreach (var organelle in organelleContainer.Organelles) + { + if (organelle.IsDuplicate) + continue; + + if (organelle.WasSplit) + { + // Organelles are reset on split, so we use the full + // cost as the gathered amount + result.Merge(organelle.Definition.InitialComposition); + continue; + } + + organelle.CalculateAbsorbedCompounds(result); + } + + if (entity.Has()) + { + throw new NotImplementedException(); + + // result.Merge(compoundsUsedForMulticellularGrowth); + } + else + { + // For single microbes the base reproduction cost needs to be calculated here + baseReproductionInfo.CalculateAlreadyUsedBaseReproductionCompounds(species, result); + } + + return result; + } + + public static float CalculateCellEntityWeight(int organelleCount) + { + return Constants.MICROBE_BASE_ENTITY_WEIGHT + organelleCount * Constants.ORGANELLE_ENTITY_WEIGHT; + } + } +} diff --git a/src/microbe_stage/components/PlayerOffspring.cs b/src/microbe_stage/components/PlayerOffspring.cs new file mode 100644 index 00000000000..f40b5f5cb91 --- /dev/null +++ b/src/microbe_stage/components/PlayerOffspring.cs @@ -0,0 +1,41 @@ +namespace Components +{ + using DefaultEcs; + + /// + /// Marks entities as being player reproduced copies + /// + public struct PlayerOffspring + { + /// + /// Which offspring this is in number of the player's offspring. Used to detect which is the latest offspring + /// + public int OffspringOrderNumber; + } + + public static class PlayerOffspringHelpers + { + /// + /// A pretty slow method to find the latest spawned offspring (fine for occasional calls) + /// + /// The latest offspring or invalid entity value if there are no offspring + public static Entity FindLatestSpawnedOffspring(World entitySystem) + { + int highest = int.MinValue; + Entity result = default; + + foreach (var entity in entitySystem.GetEntities().With().AsEnumerable()) + { + var current = entity.Get().OffspringOrderNumber; + + if (current > highest) + { + highest = current; + result = entity; + } + } + + return result; + } + } +} diff --git a/src/microbe_stage/components/SurvivalStatistics.cs b/src/microbe_stage/components/SurvivalStatistics.cs new file mode 100644 index 00000000000..053589be34e --- /dev/null +++ b/src/microbe_stage/components/SurvivalStatistics.cs @@ -0,0 +1,16 @@ +namespace Components +{ + /// + /// Collects information to give population bonuses and penalties to species based on how well they do in the + /// stage interacting with each other and the player for real + /// + public struct SurvivalStatistics + { + public float EscapeInterval; + + /// + /// Used to prevent population bonus from escaping a predator triggering too much + /// + public bool HasEscaped; + } +} diff --git a/src/microbe_stage/components/ToxinDamageSource.cs b/src/microbe_stage/components/ToxinDamageSource.cs new file mode 100644 index 00000000000..29968f53980 --- /dev/null +++ b/src/microbe_stage/components/ToxinDamageSource.cs @@ -0,0 +1,29 @@ +namespace Components +{ + using Newtonsoft.Json; + + /// + /// Defines toxin damage dealt by an entity + /// + public struct ToxinDamageSource + { + /// + /// Scales the damage + /// + public float ToxinAmount; + + public AgentProperties ToxinProperties; + + /// + /// Set to true when this projectile has hit and can't no longer deal damage + /// + public bool ProjectileUsed; + + /// + /// Used by systems internally to know when they have processed the initial adding of a toxin. Should not be + /// modified from other places. + /// + [JsonIgnore] + public bool ProjectileInitialized; + } +} diff --git a/src/microbe_stage/components/UnneededCompoundVenter.cs b/src/microbe_stage/components/UnneededCompoundVenter.cs new file mode 100644 index 00000000000..c7cadb58e35 --- /dev/null +++ b/src/microbe_stage/components/UnneededCompoundVenter.cs @@ -0,0 +1,14 @@ +namespace Components +{ + /// + /// Makes entities vent excess (or not-useful) compounds from + /// + public struct UnneededCompoundVenter + { + /// + /// Sets how many extra compounds above capacity a thing needs to have before some are vented. For example + /// 2 means any compounds that are above 2x the capacity will be vented. + /// + public float VentThreshold; + } +} diff --git a/src/microbe_stage/editor/CellEditorComponent.Callbacks.cs b/src/microbe_stage/editor/CellEditorComponent.Callbacks.cs index c754990842b..45476c9cb2c 100644 --- a/src/microbe_stage/editor/CellEditorComponent.Callbacks.cs +++ b/src/microbe_stage/editor/CellEditorComponent.Callbacks.cs @@ -13,14 +13,14 @@ public partial class CellEditorComponent private void OnOrganelleAdded(OrganelleTemplate organelle) { organelleDataDirty = true; - membraneOrganellePositionsAreDirty = true; + microbeVisualizationOrganellePositionsAreDirty = true; } [DeserializedCallbackAllowed] private void OnOrganelleRemoved(OrganelleTemplate organelle) { organelleDataDirty = true; - membraneOrganellePositionsAreDirty = true; + microbeVisualizationOrganellePositionsAreDirty = true; } [DeserializedCallbackAllowed] @@ -221,11 +221,12 @@ private void DoMembraneChangeAction(MembraneActionData data) StartAutoEvoPrediction(); - if (previewMicrobe != null) + if (previewMicrobeSpecies != null) { - previewMicrobe.Membrane.Type = membrane; - previewMicrobe.Membrane.Dirty = true; - previewMicrobe.ApplyMembraneWigglyness(); + previewMicrobeSpecies.MembraneType = membrane; + + if (previewMicrobe.IsAlive) + previewSimulation.ApplyMicrobeMembraneType(previewMicrobe, membrane); } } @@ -242,11 +243,12 @@ private void UndoMembraneChangeAction(MembraneActionData data) StartAutoEvoPrediction(); - if (previewMicrobe != null) + if (previewMicrobeSpecies != null) { - previewMicrobe.Membrane.Type = Membrane; - previewMicrobe.Membrane.Dirty = true; - previewMicrobe.ApplyMembraneWigglyness(); + previewMicrobeSpecies.MembraneType = Membrane; + + if (previewMicrobe.IsAlive) + previewSimulation.ApplyMicrobeMembraneType(previewMicrobe, Membrane); } } diff --git a/src/microbe_stage/editor/CellEditorComponent.cs b/src/microbe_stage/editor/CellEditorComponent.cs index ac66782edd7..31cbc8473af 100644 --- a/src/microbe_stage/editor/CellEditorComponent.cs +++ b/src/microbe_stage/editor/CellEditorComponent.cs @@ -3,8 +3,10 @@ using System.Globalization; using System.Linq; using AutoEvo; +using DefaultEcs; using Godot; using Newtonsoft.Json; +using Systems; /// /// The cell editor component combining the organelle and other editing logic with the GUI for it @@ -222,7 +224,7 @@ public partial class CellEditorComponent : private PackedScene organelleSelectionButtonScene = null!; - private PackedScene microbeScene = null!; + private Spatial? cellPreviewVisualsRoot; #pragma warning restore CA2213 private OrganelleDefinition protoplasm = null!; @@ -268,16 +270,14 @@ public partial class CellEditorComponent : [JsonProperty] private string newName = "unset"; -#pragma warning disable CA2213 - /// - /// We're taking advantage of the available membrane and organelle system already present in - /// the microbe class for the cell preview. + /// We're taking advantage of the available membrane and organelle system already present in the microbe stage + /// for the membrane preview. /// - private Microbe? previewMicrobe; -#pragma warning restore CA2213 + private MicrobeVisualOnlySimulation previewSimulation = new(); private MicrobeSpecies? previewMicrobeSpecies; + private Entity previewMicrobe; [JsonProperty] private Color colour; @@ -310,7 +310,7 @@ public partial class CellEditorComponent : /// membrane mesh has been redone. Used so the membrane doesn't have to be rebuild everytime when /// switching back and forth between structure and membrane tab (without editing organelle placements). /// - private bool membraneOrganellePositionsAreDirty = true; + private bool microbeVisualizationOrganellePositionsAreDirty = true; private bool microbePreviewMode; @@ -340,11 +340,13 @@ public float Rigidity { rigidity = value; - if (previewMicrobeSpecies != null) - { - previewMicrobeSpecies.MembraneRigidity = value; - previewMicrobe!.ApplyMembraneWigglyness(); - } + if (previewMicrobeSpecies == null) + return; + + previewMicrobeSpecies.MembraneRigidity = value; + + if (previewMicrobe.IsAlive) + previewSimulation.ApplyMicrobeRigidity(previewMicrobe, previewMicrobeSpecies.MembraneRigidity); } } @@ -365,12 +367,13 @@ public Color Colour { colour = value; - if (previewMicrobe?.Species != null) - { - previewMicrobe.Species.Colour = value; - previewMicrobe.Membrane.Tint = value; - previewMicrobe.ApplyPreviewOrganelleColours(); - } + if (previewMicrobeSpecies == null) + return; + + previewMicrobeSpecies.Colour = value; + + if (previewMicrobe.IsAlive) + previewSimulation.ApplyMicrobeColour(previewMicrobe, previewMicrobeSpecies.Colour); } } @@ -404,10 +407,11 @@ public bool MicrobePreviewMode { microbePreviewMode = value; - UpdateCellVisualization(); + if (cellPreviewVisualsRoot != null) + cellPreviewVisualsRoot.Visible = value; - if (previewMicrobe != null) - previewMicrobe.Visible = value; + // Need to reapply the species as changes to it are ignored when the appearance tab is not shown + UpdateCellVisualization(); placedHexes.ForEach(entry => entry.Visible = !MicrobePreviewMode); placedModels.ForEach(entry => entry.Visible = !MicrobePreviewMode); @@ -625,7 +629,7 @@ public override void Init(ICellEditorData owningEditor, bool fresh) if (Editor.EditedCellProperties != null) { UpdateGUIAfterLoadingSpecies(Editor.EditedBaseSpecies, Editor.EditedCellProperties); - SetupPreviewMicrobe(); + CreatePreviewMicrobeIfNeeded(); UpdateArrow(false); } else @@ -671,10 +675,22 @@ public override void Init(ICellEditorData owningEditor, bool fresh) Editor.CurrentPatch.GetCompoundAmount(sunlight, CompoundAmountType.Maximum) > 0.0f; ApplySymmetryForCurrentOrganelle(); + + cellPreviewVisualsRoot = new Spatial + { + Name = "CellPreviewVisuals", + }; + + Editor.RootOfDynamicallySpawned.AddChild(cellPreviewVisualsRoot); + + previewSimulation.Init(cellPreviewVisualsRoot); } public override void _Process(float delta) { + if (cellPreviewVisualsRoot == null) + throw new InvalidOperationException("This editor component is not initialized"); + base._Process(delta); if (!Visible) @@ -696,6 +712,10 @@ public override void _Process(float delta) organelleDataDirty = false; } + // Process microbe visuals preview when it is visible + if (cellPreviewVisualsRoot.Visible) + previewSimulation.ProcessAll(delta); + // Show the organelle that is about to be placed if (Editor.ShowHover && !MicrobePreviewMode) { @@ -780,7 +800,7 @@ public override void OnEditorSpeciesSetup(Species species) UpdateGUIAfterLoadingSpecies(Editor.EditedBaseSpecies, Editor.EditedCellProperties); // Setup the display cell - SetupPreviewMicrobe(); + CreatePreviewMicrobeIfNeeded(); UpdateArrow(false); } @@ -840,12 +860,12 @@ public void OnFinishEditing(bool shouldUpdatePosition) public override void SetEditorWorldTabSpecificObjectVisibility(bool shown) { + if (cellPreviewVisualsRoot == null) + throw new InvalidOperationException("This component is not initialized yet"); + base.SetEditorWorldTabSpecificObjectVisibility(shown && !MicrobePreviewMode); - if (previewMicrobe != null) - { - previewMicrobe.Visible = shown && MicrobePreviewMode; - } + cellPreviewVisualsRoot.Visible = shown && MicrobePreviewMode; } public override bool CanFinishEditing(IEnumerable userOverrides) @@ -995,6 +1015,7 @@ public void OnRigidityChanged(int desiredRigidity) /// /// Show options for the organelle under the cursor /// + /// True when this was able to do something and consume the keypress [RunOnKeyDown("e_secondary")] public bool ShowOrganelleOptions() { @@ -1031,12 +1052,14 @@ public bool ShowOrganelleOptions() public float CalculateSpeed() { - return MicrobeInternalCalculations.CalculateSpeed(editedMicrobeOrganelles, Membrane, Rigidity); + return MicrobeInternalCalculations.CalculateSpeed(editedMicrobeOrganelles.Organelles, Membrane, Rigidity, + !HasNucleus); } public float CalculateRotationSpeed() { - return MicrobeInternalCalculations.CalculateRotationSpeed(editedMicrobeOrganelles); + return MicrobeInternalCalculations.CalculateRotationSpeed(editedMicrobeOrganelles.Organelles, Membrane, + !HasNucleus); } public float CalculateHitpoints() @@ -1133,13 +1156,6 @@ protected override int CalculateCurrentActionCost() return Editor.WhatWouldActionsCost(moveOccupancies.Data); } - protected override void LoadScenes() - { - base.LoadScenes(); - - microbeScene = GD.Load("res://src/microbe_stage/Microbe.tscn"); - } - protected override void PerformActiveAction() { if (AddOrganelle(ActiveActionName!)) @@ -1287,30 +1303,60 @@ protected override void Dispose(bool disposing) AutoEvoPredictionExplanationLabelPath.Dispose(); OrganelleUpgradeGUIPath.Dispose(); } + + previewSimulation.Dispose(); } base.Dispose(disposing); } - private void SetupPreviewMicrobe() + private bool CreatePreviewMicrobeIfNeeded() { - if (previewMicrobe != null) + if (previewMicrobe.IsAlive && previewMicrobeSpecies != null) + return false; + + if (cellPreviewVisualsRoot == null) { - GD.Print("Preview microbe already setup"); - previewMicrobe.Visible = MicrobePreviewMode; - return; + throw new InvalidOperationException("Editor component not initialized yet (cell visuals root missing)"); } - previewMicrobe = (Microbe)microbeScene.Instance(); - previewMicrobe.IsForPreviewOnly = true; - Editor.RootOfDynamicallySpawned.AddChild(previewMicrobe); previewMicrobeSpecies = new MicrobeSpecies(Editor.EditedBaseSpecies, Editor.EditedCellProperties ?? - throw new InvalidOperationException("can't setup preview before cell properties are known")); - previewMicrobe.ApplySpecies(previewMicrobeSpecies); + throw new InvalidOperationException("can't setup preview before cell properties are known")) + { + // Force large normal size (instead of showing bacteria as smaller scale than the editor hexes) + IsBacteria = false, + }; + + previewMicrobe = previewSimulation.CreateVisualisationMicrobe(previewMicrobeSpecies); // Set its initial visibility - previewMicrobe.Visible = MicrobePreviewMode; + cellPreviewVisualsRoot.Visible = MicrobePreviewMode; + + return true; + } + + /// + /// Updates the membrane and organelle placement of the preview cell. + /// + private void UpdateCellVisualization() + { + if (previewMicrobeSpecies == null) + return; + + // Don't redo the preview cell when not in the preview mode to avoid unnecessary lags + if (!MicrobePreviewMode || !microbeVisualizationOrganellePositionsAreDirty) + return; + + CopyEditedPropertiesToSpecies(previewMicrobeSpecies); + + // Intentionally force it to not be bacteria to show it at full size + previewMicrobeSpecies.IsBacteria = false; + + // This is now just for applying changes in the species to the preview cell + previewSimulation.ApplyNewVisualisationMicrobeSpecies(previewMicrobe, previewMicrobeSpecies); + + microbeVisualizationOrganellePositionsAreDirty = false; } private bool HasOrganelle(OrganelleDefinition organelleDefinition) @@ -1581,7 +1627,7 @@ private void RenderHighlightedOrganelle(int q, int r, int rotation, OrganelleDef organelleModel.Transform = new Transform( MathUtils.CreateRotationForOrganelle(rotation), - cartesianPosition + shownOrganelle.CalculateModelOffset()); + cartesianPosition + shownOrganelle.ModelOffset); organelleModel.Scale = new Vector3(Constants.DEFAULT_HEX_SIZE, Constants.DEFAULT_HEX_SIZE, Constants.DEFAULT_HEX_SIZE); @@ -1593,29 +1639,6 @@ private void RenderHighlightedOrganelle(int q, int r, int rotation, OrganelleDef } } - /// - /// Updates the membrane and organelle placement of the preview cell. - /// - private void UpdateCellVisualization() - { - if (previewMicrobe == null) - return; - - // Don't redo the preview cell when not in the preview mode to avoid unnecessary lags - if (!MicrobePreviewMode || !membraneOrganellePositionsAreDirty) - return; - - CopyEditedPropertiesToSpecies(previewMicrobeSpecies!); - - // Intentionally force it to not be bacteria to show it at full size - previewMicrobeSpecies!.IsBacteria = false; - - // This is now just for applying changes in the species to the preview cell - previewMicrobe.ApplySpecies(previewMicrobeSpecies); - - membraneOrganellePositionsAreDirty = false; - } - /// /// Places an organelle of the specified type under the cursor and also applies symmetry to /// place multiple at once. diff --git a/src/microbe_stage/editor/MicrobeEditor.cs b/src/microbe_stage/editor/MicrobeEditor.cs index 5bed3a38643..252a657be66 100644 --- a/src/microbe_stage/editor/MicrobeEditor.cs +++ b/src/microbe_stage/editor/MicrobeEditor.cs @@ -138,6 +138,8 @@ protected override void InitEditor(bool fresh) reportTab.UpdatePatchDetails(CurrentPatch, patchMapTab.SelectedPatch); } + ProceduralDataCache.Instance.OnEnterState(MainGameState.MicrobeEditor); + cellEditorTab.UpdateBackgroundImage(CurrentPatch.BiomeTemplate); // Make tutorials run diff --git a/src/microbe_stage/organelle_components/AxonComponent.cs b/src/microbe_stage/organelle_components/AxonComponent.cs deleted file mode 100644 index 11cd8c42d77..00000000000 --- a/src/microbe_stage/organelle_components/AxonComponent.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Godot; - -public class AxonComponent : ExternallyPositionedComponent -{ - protected override void OnPositionChanged(Quat rotation, float angle, Vector3 membraneCoords) - { - organelle!.OrganelleGraphics!.Transform = new Transform(rotation, membraneCoords); - } -} - -public class AxonComponentFactory : IOrganelleComponentFactory -{ - public IOrganelleComponent Create() - { - return new AxonComponent(); - } - - public void Check(string name) - { - } -} diff --git a/src/microbe_stage/organelle_components/BindingAgentComponent.cs b/src/microbe_stage/organelle_components/BindingAgentComponent.cs deleted file mode 100644 index e4f97e8dfea..00000000000 --- a/src/microbe_stage/organelle_components/BindingAgentComponent.cs +++ /dev/null @@ -1,18 +0,0 @@ -/// -/// Used to detect if a binding agent is present -/// -public class BindingAgentComponent : EmptyOrganelleComponent -{ -} - -public class BindingAgentComponentFactory : IOrganelleComponentFactory -{ - public IOrganelleComponent Create() - { - return new BindingAgentComponent(); - } - - public void Check(string name) - { - } -} diff --git a/src/microbe_stage/organelle_components/ChemoreceptorComponent.cs b/src/microbe_stage/organelle_components/ChemoreceptorComponent.cs index c0237451919..d7edacdc36f 100644 --- a/src/microbe_stage/organelle_components/ChemoreceptorComponent.cs +++ b/src/microbe_stage/organelle_components/ChemoreceptorComponent.cs @@ -1,10 +1,13 @@ using System; +using System.Collections.Generic; +using Components; +using DefaultEcs; using Godot; /// /// Adds radar capability to a cell /// -public class ChemoreceptorComponent : ExternallyPositionedComponent +public class ChemoreceptorComponent : IOrganelleComponent { // Either target compound or species should be null private Compound? targetCompound; @@ -13,27 +16,10 @@ public class ChemoreceptorComponent : ExternallyPositionedComponent private float searchAmount; private Color lineColour = Colors.White; - public override void UpdateAsync(float delta) - { - base.UpdateAsync(delta); - - if (targetCompound != null) - { - organelle!.ParentMicrobe!.ReportActiveCompoundChemoreceptor( - targetCompound, searchRange, searchAmount, lineColour); - } - else if (targetSpecies != null) - { - organelle!.ParentMicrobe!.ReportActiveSpeciesChemoreceptor( - targetSpecies, searchRange, searchAmount, lineColour); - } - } + public bool UsesSyncProcess => false; - protected override void CustomAttach() + public void OnAttachToCell(PlacedOrganelle organelle) { - if (organelle?.OrganelleGraphics == null) - throw new InvalidOperationException("Chemoreceptor needs parent organelle to have graphics"); - var configuration = organelle.Upgrades?.CustomUpgradeData; // Use default values if not configured @@ -44,17 +30,32 @@ protected override void CustomAttach() } SetConfiguration((ChemoreceptorUpgrades)configuration); + + if (targetCompound == null && targetSpecies == null) + GD.PrintErr("Chemoreceptor has no target compound or species, invalid configuration"); } - protected override bool NeedsUpdateAnyway() + public void UpdateAsync(ref OrganelleContainer organelleContainer, in Entity microbeEntity, float delta) { - // TODO: https://github.com/Revolutionary-Games/Thrive/issues/2906 - return organelle!.OrganelleGraphics!.Transform.basis == Transform.Identity.basis; + if (targetCompound != null) + { + organelleContainer.ActiveCompoundDetections ??= + new HashSet<(Compound Compound, float Range, float MinAmount, Color Colour)>(); + + organelleContainer.ActiveCompoundDetections.Add((targetCompound, searchRange, searchAmount, lineColour)); + } + else if (targetSpecies != null) + { + organelleContainer.ActiveSpeciesDetections ??= + new HashSet<(Species TargetSpecies, float Range, Color Colour)>(); + + organelleContainer.ActiveSpeciesDetections.Add((targetSpecies, searchRange, lineColour)); + } } - protected override void OnPositionChanged(Quat rotation, float angle, Vector3 membraneCoords) + public void UpdateSync(in Entity microbeEntity, float delta) { - organelle!.OrganelleGraphics!.Transform = new Transform(rotation, membraneCoords); + throw new NotSupportedException(); } private void SetConfiguration(ChemoreceptorUpgrades configuration) diff --git a/src/microbe_stage/organelle_components/CiliaComponent.cs b/src/microbe_stage/organelle_components/CiliaComponent.cs index 27bd19f4b51..c4079ad35ae 100644 --- a/src/microbe_stage/organelle_components/CiliaComponent.cs +++ b/src/microbe_stage/organelle_components/CiliaComponent.cs @@ -1,40 +1,78 @@ using System; -using System.Diagnostics.CodeAnalysis; +using Components; +using DefaultEcs; using Godot; -[SuppressMessage("Design", "CA1001:Types that own disposable fields should be disposable", - Justification = "We don't dispose Godot scene-attached objects")] -public class CiliaComponent : ExternallyPositionedComponent +public class CiliaComponent : IOrganelleComponent { private const string CILIA_PULL_UPGRADE_NAME = "pull"; private readonly Compound atp = SimulationParameters.Instance.GetCompound("atp"); + private PlacedOrganelle parentOrganelle = null!; + private float currentSpeed = 1.0f; private float targetSpeed; + private bool animationDirty = true; private float timeSinceRotationSample; private Quat? previousCellRotation; - private AnimationPlayer? animation; - - private Area? attractorArea; - private SphereShape? attractorShape; + public bool UsesSyncProcess => animationDirty; - public override void UpdateAsync(float delta) + public void OnAttachToCell(PlacedOrganelle organelle) { - // Visual positioning code - base.UpdateAsync(delta); + parentOrganelle = organelle; + + SetSpeedFactor(Constants.CILIA_DEFAULT_ANIMATION_SPEED); + + // Only pulling cilia gets the following physics features + if (organelle.Upgrades?.UnlockedFeatures.Contains(CILIA_PULL_UPGRADE_NAME) != true) + return; - var microbe = organelle!.ParentMicrobe!; + throw new NotImplementedException(); - if (microbe.PhagocytosisStep != PhagocytosisPhase.None) + /* + these were fields: + private Area? attractorArea; + private SphereShape? attractorShape; + + attractorArea = new Area { - targetSpeed = 0; + GravityPoint = true, + GravityDistanceScale = Constants.CILIA_PULLING_FORCE_FALLOFF_FACTOR, + Gravity = Constants.CILIA_PULLING_FORCE, + CollisionLayer = 0, + CollisionMask = microbe.CollisionMask, + Translation = Hex.AxialToCartesian(organelle.Position), + }; + + attractorShape ??= new SphereShape(); + attractorArea.ShapeOwnerAddShape(attractorArea.CreateShapeOwner(attractorShape), attractorShape); + microbe.AddChild(attractorArea);*/ + + // TODO: also the code for detach (destroy of the placed organelle) was the following: + /* + attractorArea?.DetachAndQueueFree(); + attractorArea = null; + attractorShape = null; + */ + } + + public void UpdateAsync(ref OrganelleContainer organelleContainer, in Entity microbeEntity, float delta) + { + // Stop animating when being engulfed + if (microbeEntity.Get().PhagocytosisStep != PhagocytosisPhase.None) + { + SetSpeedFactor(0); return; } - var currentCellRotation = microbe.GlobalTransform.basis.Quat(); + // TODO: for cell colonies the animation speed of the cells should probably also take rotation around + // the colony origin into account + ref var position = ref microbeEntity.Get(); + + var currentCellRotation = position.Rotation; if (previousCellRotation == null) { @@ -50,24 +88,31 @@ public override void UpdateAsync(float delta) if (timeSinceRotationSample < Constants.CILIA_ROTATION_SAMPLE_INTERVAL) return; + // ref var control = ref microbeEntity.Get(); + // Calculate how fast the cell is turning for controlling the animation speed var rawRotation = previousCellRotation.Value.AngleTo(currentCellRotation); var rotationSpeed = rawRotation * Constants.CILIA_ROTATION_ANIMATION_SPEED_MULTIPLIER; - if (microbe.State == MicrobeState.Engulf && attractorArea != null) - { - // We are using cilia pulling, play animation at fixed rate - targetSpeed = Constants.CILIA_CURRENT_GENERATION_ANIMATION_SPEED; - } - else - { - targetSpeed = Mathf.Clamp(rotationSpeed, Constants.CILIA_MIN_ANIMATION_SPEED, - Constants.CILIA_MAX_ANIMATION_SPEED); - } + // TODO: pulling cilia reimplementation + // if (control.State == MicrobeState.Engulf && attractorArea != null) + // { + // // We are using cilia pulling, play animation at fixed rate + // targetSpeed = Constants.CILIA_CURRENT_GENERATION_ANIMATION_SPEED; + // } + // else + // { + targetSpeed = Mathf.Clamp(rotationSpeed, Constants.CILIA_MIN_ANIMATION_SPEED, + Constants.CILIA_MAX_ANIMATION_SPEED); + + // } + + SetSpeedFactor(targetSpeed); previousCellRotation = currentCellRotation; - // Consume extra ATP when rotating (above certain speed + // Consume extra ATP when rotating (above certain speed) + // TODO: would it make more sense if (rawRotation > Constants.CILIA_ROTATION_NEEDED_FOR_ATP_COST) { var cost = Mathf.Clamp(rawRotation * Constants.CILIA_ROTATION_ENERGY_BASE_MULTIPLIER, @@ -75,7 +120,9 @@ public override void UpdateAsync(float delta) var requiredEnergy = cost * timeSinceRotationSample; - var availableEnergy = microbe.Compounds.TakeCompound(atp, requiredEnergy); + var compounds = microbeEntity.Get().Compounds; + + var availableEnergy = compounds.TakeCompound(atp, requiredEnergy); if (availableEnergy < requiredEnergy) { @@ -86,19 +133,19 @@ public override void UpdateAsync(float delta) timeSinceRotationSample = 0; } - public override void UpdateSync() + public void UpdateSync(in Entity microbeEntity, float delta) { - base.UpdateSync(); - - // ReSharper disable once CompareOfFloatsByEqualityOperator - if (currentSpeed != targetSpeed) + // Skip applying speed if this happens before the organelle graphics are loaded + if (parentOrganelle.OrganelleAnimation != null) { - // It seems it would be safe to call this in an async way as the MovementComponent does, but it's probably - // better to do things correctly here as this is newer code... - SetSpeedFactor(targetSpeed); + parentOrganelle.OrganelleAnimation.PlaybackSpeed = currentSpeed; + animationDirty = false; } - if (attractorArea != null) + // TODO: pull upgrade handling (note this might need to set animation dirty every now and then to make sure + // this gets re-run). Also if this needs access to different organelle data, this needs to mark those in the + // tick system + /*if (attractorArea != null) { // Enable cilia pulling force if parent cell is not in engulf mode and is not being engulfed var enable = organelle!.ParentMicrobe!.State == MicrobeState.Engulf && @@ -115,76 +162,18 @@ public override void UpdateSync() // Make the pulling force's radius scales with the organelle's growth value attractorShape.Radius = Constants.CILIA_PULLING_FORCE_FIELD_RADIUS + (Constants.CILIA_PULLING_FORCE_GROW_STEP * organelle!.GrowthValue); - } + }*/ } - protected override void CustomAttach() + private void SetSpeedFactor(float speed) { - if (organelle?.OrganelleGraphics == null) - throw new InvalidOperationException("Cilia needs parent organelle to have graphics"); - - animation = organelle.OrganelleAnimation; - - if (animation == null) - { - GD.PrintErr("CiliaComponent's organelle has no animation player set"); - } - - SetSpeedFactor(Constants.CILIA_DEFAULT_ANIMATION_SPEED); - - // Only pulling cilia gets the following physics features - if (organelle.Upgrades?.UnlockedFeatures.Contains(CILIA_PULL_UPGRADE_NAME) != true) + // We use exact speed values in the code + // ReSharper disable once CompareOfFloatsByEqualityOperator + if (speed != currentSpeed) return; - var microbe = organelle.ParentMicrobe!; - - attractorArea = new Area - { - GravityPoint = true, - GravityDistanceScale = Constants.CILIA_PULLING_FORCE_FALLOFF_FACTOR, - Gravity = Constants.CILIA_PULLING_FORCE, - CollisionLayer = 0, - CollisionMask = microbe.CollisionMask, - Translation = Hex.AxialToCartesian(organelle.Position), - }; - - attractorShape ??= new SphereShape(); - attractorArea.ShapeOwnerAddShape(attractorArea.CreateShapeOwner(attractorShape), attractorShape); - microbe.AddChild(attractorArea); - } - - protected override void CustomDetach() - { - attractorArea?.DetachAndQueueFree(); - attractorArea = null; - attractorShape = null; - } - - protected override bool NeedsUpdateAnyway() - { - // The basis of the transform represents the rotation, as long as the rotation is not modified, - // the organelle needs to be updated. - // TODO: Calculated rotations should never equal the identity, - // it should be kept an eye on if it does. The engine for some reason doesnt update THIS basis - // unless checked with some condition (if or return) - // SEE: https://github.com/Revolutionary-Games/Thrive/issues/2906 - return organelle!.OrganelleGraphics!.Transform.basis == Transform.Identity.basis; - } - - protected override void OnPositionChanged(Quat rotation, float angle, - Vector3 membraneCoords) - { - organelle!.OrganelleGraphics!.Transform = new Transform(rotation, membraneCoords); - } - - private void SetSpeedFactor(float speed) - { currentSpeed = speed; - - if (animation != null) - { - animation.PlaybackSpeed = speed; - } + animationDirty = true; } } diff --git a/src/microbe_stage/organelle_components/EmptyOrganelleComponent.cs b/src/microbe_stage/organelle_components/EmptyOrganelleComponent.cs index 0646a698bc8..497ff8bbb03 100644 --- a/src/microbe_stage/organelle_components/EmptyOrganelleComponent.cs +++ b/src/microbe_stage/organelle_components/EmptyOrganelleComponent.cs @@ -1,27 +1,25 @@ -using Godot; +using System; +using Components; +using DefaultEcs; /// -/// An organelle component that doesn't do anything +/// An organelle component that doesn't do anything. Used to allow organelle components that store data in their +/// object instances to exist. /// public abstract class EmptyOrganelleComponent : IOrganelleComponent { - public void OnAttachToCell(PlacedOrganelle organelle) - { - } + public bool UsesSyncProcess => false; - public void OnDetachFromCell(PlacedOrganelle organelle) - { - } - - public void UpdateAsync(float delta) + public void OnAttachToCell(PlacedOrganelle organelle) { } - public void UpdateSync() + public void UpdateAsync(ref OrganelleContainer organelleContainer, in Entity microbeEntity, float delta) { } - public void OnShapeParentChanged(Microbe newShapeParent, Vector3 offset) + public void UpdateSync(in Entity microbeEntity, float delta) { + throw new NotSupportedException(); } } diff --git a/src/microbe_stage/organelle_components/ExternallyPositionedComponent.cs b/src/microbe_stage/organelle_components/ExternallyPositionedComponent.cs deleted file mode 100644 index e35cea56652..00000000000 --- a/src/microbe_stage/organelle_components/ExternallyPositionedComponent.cs +++ /dev/null @@ -1,152 +0,0 @@ -using Godot; - -/// -/// Base class for organelle components that position their model on a membrane edge -/// -public abstract class ExternallyPositionedComponent : IOrganelleComponent -{ - /// - /// The default visual position if the organelle is on the microbe's center - /// TODO: this should be made organelle type specific, chemoreceptors and pilus should point backward (in Godot - /// coordinates to point forwards by default, and flagella should keep this current default value) - /// - protected static readonly Vector3 DefaultVisualPos = Vector3.Forward; - - protected PlacedOrganelle? organelle; - - /// - /// Needed to calculate final pos on update - /// - protected Vector3 organellePos; - - /// - /// Last calculated position, Used to not have to recreate the physics all the time - /// - protected Vector3 lastCalculatedPosition = Vector3.Zero; - - /// - /// To support splitting processing to the async and sync phases this stores the async phase result before - /// applying this to the Godot objects - /// - protected Vector3? calculatedNewPosition; - - protected float calculatedNewAngle; - - private bool skippedAsyncProcess; - - public void OnAttachToCell(PlacedOrganelle organelle) - { - this.organelle = organelle; - organellePos = Hex.AxialToCartesian(organelle.Position); - - CustomAttach(); - } - - public void OnDetachFromCell(PlacedOrganelle organelle) - { - CustomDetach(); - - this.organelle = null; - } - - /// - /// Positions the external organelle the right way - /// - /// Time since last frame - /// - /// - /// TODO: in profiling this is quite a hot spot so this should be optimized for when this needs to run - /// - /// - public virtual void UpdateAsync(float delta) - { - // TODO: it would be nicer if this were notified when the membrane changes to not recheck this constantly - - var membrane = organelle!.ParentMicrobe!.Membrane; - - // Skip updating if membrane is not ready yet for us to read it and do it in the sync update instead - if (membrane.Dirty) - { - skippedAsyncProcess = true; - return; - } - - CheckPositioningWithMembrane(); - } - - public virtual void UpdateSync() - { - if (skippedAsyncProcess) - { - CheckPositioningWithMembrane(); - skippedAsyncProcess = false; - } - - if (calculatedNewPosition == null) - return; - - var rotation = MathUtils.CreateRotationForExternal(calculatedNewAngle); - - OnPositionChanged(rotation, calculatedNewAngle, calculatedNewPosition.Value); - - calculatedNewPosition = null; - } - - public virtual void OnShapeParentChanged(Microbe newShapeParent, Vector3 offset) - { - } - - /// - /// Gets the angle of rotation of an externally placed organelle - /// - /// The difference between the cell middle and the external organelle position - protected float GetAngle(Vector3 delta) - { - float angle = Mathf.Atan2(-delta.z, delta.x); - if (angle < 0) - { - angle += 2 * Mathf.Pi; - } - - angle = (angle * 180 / Mathf.Pi - 90) % 360; - return angle; - } - - protected virtual void CustomAttach() - { - } - - protected virtual void CustomDetach() - { - } - - protected virtual bool NeedsUpdateAnyway() - { - return false; - } - - protected abstract void OnPositionChanged(Quat rotation, float angle, - Vector3 membraneCoords); - - private void CheckPositioningWithMembrane() - { - var membrane = organelle!.ParentMicrobe!.Membrane; - - Vector3 middle = Hex.AxialToCartesian(new Hex(0, 0)); - var relativeOrganellePosition = middle - organellePos; - - if (relativeOrganellePosition == Vector3.Zero) - relativeOrganellePosition = DefaultVisualPos; - - Vector3 exit = middle - relativeOrganellePosition; - var membraneCoords = membrane.GetVectorTowardsNearestPointOfMembrane(exit.x, - exit.z); - - if (!membraneCoords.Equals(lastCalculatedPosition) || NeedsUpdateAnyway()) - { - calculatedNewAngle = GetAngle(relativeOrganellePosition); - calculatedNewPosition = membraneCoords; - lastCalculatedPosition = membraneCoords; - } - } -} diff --git a/src/microbe_stage/organelle_components/LysosomeComponent.cs b/src/microbe_stage/organelle_components/LysosomeComponent.cs index 8bf88b77771..a71369d3750 100644 --- a/src/microbe_stage/organelle_components/LysosomeComponent.cs +++ b/src/microbe_stage/organelle_components/LysosomeComponent.cs @@ -1,34 +1,41 @@ -using Godot; +using System; +using System.Collections.Generic; +using Components; +using DefaultEcs; +/// +/// Adds extra digestion enzymes to an organelle +/// public class LysosomeComponent : IOrganelleComponent { + public bool UsesSyncProcess { get; set; } + public void OnAttachToCell(PlacedOrganelle organelle) { var configuration = organelle.Upgrades?.CustomUpgradeData; - var upgrades = configuration as LysosomeUpgrades; - - var enzyme = upgrades == null ? SimulationParameters.Instance.GetEnzyme("lipase") : upgrades.Enzyme; - - organelle.StoredEnzymes.Clear(); - organelle.StoredEnzymes[enzyme] = 1; - } - - public void OnDetachFromCell(PlacedOrganelle organelle) - { - } + var enzyme = configuration is LysosomeUpgrades upgrades ? + upgrades.Enzyme : + SimulationParameters.Instance.GetEnzyme("lipase"); - public void UpdateAsync(float delta) - { - // TODO: Animate lysosomes sticking onto phagosomes (if possible) + // TODO: avoid allocating memory like this for each lysosome component + // Could most likely refactor the PlacedOrganelle.GetEnzymes to take in the container.AvailableEnzymes + // dictionary and write updated values to that + organelle.OverriddenEnzymes = new Dictionary + { + { enzyme, 1 }, + }; } - public void UpdateSync() + public void UpdateAsync(ref OrganelleContainer organelleContainer, in Entity microbeEntity, float delta) { + // TODO: Animate lysosomes sticking onto phagosomes (if possible). This probably should happen in the + // engulfing system (this at least can't happen here as Godot data update needs to happen in sync update) } - public void OnShapeParentChanged(Microbe newShapeParent, Vector3 offset) + public void UpdateSync(in Entity microbeEntity, float delta) { + throw new NotSupportedException(); } } diff --git a/src/microbe_stage/organelle_components/MovementComponent.cs b/src/microbe_stage/organelle_components/MovementComponent.cs index 0a79e52c4d6..1a28ffc7f47 100644 --- a/src/microbe_stage/organelle_components/MovementComponent.cs +++ b/src/microbe_stage/organelle_components/MovementComponent.cs @@ -1,86 +1,80 @@ -using System; +using Components; +using DefaultEcs; using Godot; /// -/// Flagellum for making cells move faster +/// Flagellum for making cells move faster. TODO: rename this to FlagellumComponent (this is named like this due to +/// only it being initially being named like this) /// -public class MovementComponent : ExternallyPositionedComponent +public class MovementComponent : IOrganelleComponent { - public float Momentum; - public float Torque; + // TODO: set this private + public readonly float Momentum; private readonly Compound atp = SimulationParameters.Instance.GetCompound("atp"); - private bool movingTail; - private Vector3 force; + private PlacedOrganelle parentOrganelle = null!; - private AnimationPlayer? animation; + private float animationSpeed = 0.25f; + private bool animationDirty = true; - public MovementComponent(float momentum, float torque) + private bool lastUsed; + private Vector3 force; + + public MovementComponent(float momentum) { Momentum = momentum; - Torque = torque; } - public override void UpdateAsync(float delta) + public bool UsesSyncProcess => animationDirty; + + public void OnAttachToCell(PlacedOrganelle organelle) { - // Visual positioning code - base.UpdateAsync(delta); + // No longer can check for animation here as the organelle graphics are created later than this is attached to + // a cell + parentOrganelle = organelle; - // Movement force - var microbe = organelle!.ParentMicrobe!; + force = CalculateForce(organelle.Position, Momentum); + } - if (microbe.PhagocytosisStep != PhagocytosisPhase.None) + public void UpdateAsync(ref OrganelleContainer organelleContainer, in Entity microbeEntity, float delta) + { + // Stop animating when being engulfed + if (microbeEntity.Get().PhagocytosisStep != PhagocytosisPhase.None) { SetSpeedFactor(0); return; } - var movement = CalculateMovementForce(microbe, delta); - - if (movement != new Vector3(0, 0, 0)) - microbe.AddMovementForce(movement); - } - - protected override void CustomAttach() - { - if (organelle?.OrganelleGraphics == null) - throw new InvalidOperationException("Flagellum needs parent organelle to have graphics"); - - force = CalculateForce(organelle!.Position, Momentum); - - animation = organelle.OrganelleAnimation; - - if (animation == null) + // Slow down animation when not used for movement + if (!lastUsed) { - GD.PrintErr("MovementComponent's organelle has no animation player set"); + SetSpeedFactor(0.25f); } - SetSpeedFactor(0.25f); + lastUsed = false; } - protected override bool NeedsUpdateAnyway() + public void UpdateSync(in Entity microbeEntity, float delta) { - // The basis of the transform represents the rotation, as long as the rotation is not modified, - // the organelle needs to be updated. - // TODO: Calculated rotations should never equal the identity, - // it should be kept an eye on if it does. The engine for some reason doesnt update THIS basis - // unless checked with some condition (if or return) - // SEE: https://github.com/Revolutionary-Games/Thrive/issues/2906 - return organelle!.OrganelleGraphics!.Transform.basis == Transform.Identity.basis; + // Skip applying speed if this happens before the organelle graphics are loaded + if (parentOrganelle.OrganelleAnimation != null) + { + parentOrganelle.OrganelleAnimation.PlaybackSpeed = animationSpeed; + animationDirty = false; + } } - protected override void OnPositionChanged(Quat rotation, float angle, - Vector3 membraneCoords) + public float UseForMovement(Vector3 wantedMovementDirection, CompoundBag compounds, Quat extraColonyRotation, + float delta) { - organelle!.OrganelleGraphics!.Transform = new Transform(rotation, membraneCoords); + return CalculateMovementForce(compounds, wantedMovementDirection, extraColonyRotation, delta); } /// - /// Calculate the momentum of the movement organelle based on - /// angle towards middle of cell - /// If the flagella is placed in the microbe's center, hence delta equals 0, - /// consider defaultPos as the organelle's "false" position. + /// Calculate the momentum of the movement organelle based on angle towards middle of cell. + /// If the flagella is placed in the microbe's center, hence delta equals 0, consider defaultPos as the + /// organelle's "false" position. /// private static Vector3 CalculateForce(Hex pos, float momentum) { @@ -88,51 +82,50 @@ private static Vector3 CalculateForce(Hex pos, float momentum) Vector3 middle = Hex.AxialToCartesian(new Hex(0, 0)); var delta = middle - organellePosition; if (delta == Vector3.Zero) - delta = DefaultVisualPos; + delta = Components.CellPropertiesHelpers.DefaultVisualPos; return delta.Normalized() * momentum; } private void SetSpeedFactor(float speed) { - if (animation != null) - { - animation.PlaybackSpeed = speed; - } + // We use exact values set in the code + // ReSharper disable once CompareOfFloatsByEqualityOperator + if (animationSpeed == speed) + return; + + animationSpeed = speed; + animationDirty = true; } /// - /// The final calculated force is multiplied by elapsed before - /// applying. So we don't have to do that. But we need to take - /// the right amount of atp. + /// The final calculated force is multiplied by elapsed before applying. So we don't have to do that. + /// But we need to take the right amount of atp. /// - private Vector3 CalculateMovementForce(Microbe microbe, float elapsed) + /// + /// + /// The movementDirection is the player or AI input. It is in non-rotated cell oriented coordinates + /// + /// + private float CalculateMovementForce(CompoundBag compounds, Vector3 wantedMovementDirection, + Quat extraColonyRotation, float elapsed) { - // The movementDirection is the player or AI input - Vector3 direction = microbe.MovementDirection; - // Real force the flagella applied to the colony (considering rotation) - var realForce = organelle!.RotatedPositionInsideColony(force); - var forceMagnitude = realForce.Dot(direction); + var realForce = extraColonyRotation.Xform(force); + var forceMagnitude = realForce.Dot(wantedMovementDirection); - if (forceMagnitude <= 0 || direction.LengthSquared() < MathUtils.EPSILON || + if (forceMagnitude <= 0 || wantedMovementDirection.LengthSquared() < MathUtils.EPSILON || realForce.LengthSquared() < MathUtils.EPSILON) { - if (movingTail) - { - movingTail = false; - - SetSpeedFactor(0.25f); - } - - return new Vector3(0, 0, 0); + SetSpeedFactor(0.25f); + return 0; } - var animationSpeed = 2.3f; - movingTail = true; + var newAnimationSpeed = 2.3f; + lastUsed = true; var requiredEnergy = Constants.FLAGELLA_ENERGY_COST * elapsed; - var availableEnergy = microbe.Compounds.TakeCompound(atp, requiredEnergy); + var availableEnergy = compounds.TakeCompound(atp, requiredEnergy); if (availableEnergy < requiredEnergy) { @@ -141,36 +134,23 @@ private Vector3 CalculateMovementForce(Microbe microbe, float elapsed) forceMagnitude *= fraction; - animationSpeed = 0.25f + (animationSpeed - 0.25f) * fraction; - } - - float impulseMagnitude = Constants.FLAGELLA_BASE_FORCE * microbe.MovementFactor * - forceMagnitude / 100.0f; - - // Rotate the 'thrust' based on our orientation - if (microbe.Colony?.Master == null) - { - direction = microbe.Transform.basis.Xform(direction); - } - else - { - direction = microbe.Colony.Master.Transform.basis.Xform(direction); + newAnimationSpeed = 0.25f + (newAnimationSpeed - 0.25f) * fraction; } - SetSpeedFactor(animationSpeed); + SetSpeedFactor(newAnimationSpeed); - return direction * impulseMagnitude; + // TODO: adjust the flagella force for the new physics engine + return Constants.FLAGELLA_BASE_FORCE * forceMagnitude; } } public class MovementComponentFactory : IOrganelleComponentFactory { public float Momentum; - public float Torque; public IOrganelleComponent Create() { - return new MovementComponent(Momentum, Torque); + return new MovementComponent(Momentum); } public void Check(string name) @@ -180,11 +160,5 @@ public void Check(string name) throw new InvalidRegistryDataException(name, GetType().Name, "Momentum needs to be > 0.0f"); } - - if (Torque <= 0.0f) - { - throw new InvalidRegistryDataException(name, GetType().Name, - "Torque needs to be > 0.0f"); - } } } diff --git a/src/microbe_stage/organelle_components/MyofibrilComponent.cs b/src/microbe_stage/organelle_components/MyofibrilComponent.cs deleted file mode 100644 index 420a4a3a574..00000000000 --- a/src/microbe_stage/organelle_components/MyofibrilComponent.cs +++ /dev/null @@ -1,15 +0,0 @@ -public class MyofibrilComponent : EmptyOrganelleComponent -{ -} - -public class MyofibrilComponentFactory : IOrganelleComponentFactory -{ - public IOrganelleComponent Create() - { - return new MyofibrilComponent(); - } - - public void Check(string name) - { - } -} diff --git a/src/microbe_stage/organelle_components/NucleusComponent.cs b/src/microbe_stage/organelle_components/NucleusComponent.cs deleted file mode 100644 index ce6db2f211e..00000000000 --- a/src/microbe_stage/organelle_components/NucleusComponent.cs +++ /dev/null @@ -1,19 +0,0 @@ -/// -/// Literally does nothing anymore. If this isn't used as PlacedOrganelle.HasComponent type -/// This serves no purpose anymore. -/// -public class NucleusComponent : EmptyOrganelleComponent -{ -} - -public class NucleusComponentFactory : IOrganelleComponentFactory -{ - public IOrganelleComponent Create() - { - return new NucleusComponent(); - } - - public void Check(string name) - { - } -} diff --git a/src/microbe_stage/organelle_components/PilusComponent.cs b/src/microbe_stage/organelle_components/PilusComponent.cs index 703e960698e..5f282702bb0 100644 --- a/src/microbe_stage/organelle_components/PilusComponent.cs +++ b/src/microbe_stage/organelle_components/PilusComponent.cs @@ -1,163 +1 @@ -using System; -using System.Collections.Generic; -using Godot; - -/// -/// Adds a stabby thing to the cell, positioned similarly to the flagellum -/// -public class PilusComponent : ExternallyPositionedComponent -{ - private const string PILUS_INJECTISOME_UPGRADE_NAME = "injectisome"; - - private List addedChildShapes = new(); - - private Microbe? currentShapesParent; - - public override void OnShapeParentChanged(Microbe newShapeParent, Vector3 offset) - { - if (currentShapesParent == null) - throw new InvalidOperationException("Pilus not attached to a microbe yet"); - - // Check if the pilus exists - if (NeedsUpdateAnyway()) - { - // Send the organelle positions to the membrane then update the pilus - currentShapesParent.SendOrganellePositionsToMembrane(); - UpdateAsync(0); - UpdateSync(); - - if (newShapeParent.Colony != null) - OnShapeParentChanged(newShapeParent, offset); - } - else - { - // Firstly the rotation relative to the master. - var position = organelle!.RotatedPositionInsideColony(lastCalculatedPosition); - - // Then the position - position += offset; - Vector3 middle = offset; - Vector3 membranePointDirection = (position - middle).Normalized(); - position += membranePointDirection * Constants.DEFAULT_HEX_SIZE * 2; - - // Pilus rotation - var angle = GetAngle(middle - position); - var rotation = MathUtils.CreateRotationForPhysicsOrganelle(angle); - var transform = new Transform(rotation, position); - - // New ownerId - var ownerId = currentShapesParent.CreateNewOwnerId(newShapeParent, transform, addedChildShapes[0]); - var isInjectisome = organelle.Upgrades?.UnlockedFeatures.Contains(PILUS_INJECTISOME_UPGRADE_NAME); - newShapeParent.AddPilus(ownerId, isInjectisome == true); - - // Destroy the old shape owner - DestroyShape(); - addedChildShapes.Add(ownerId); - } - - currentShapesParent = newShapeParent; - } - - protected override void CustomAttach() - { - if (organelle?.OrganelleGraphics == null) - throw new InvalidOperationException("Pilus needs parent organelle to have graphics"); - - currentShapesParent = organelle!.ParentMicrobe; - } - - protected override void CustomDetach() - { - DestroyShape(); - currentShapesParent = null; - } - - protected override bool NeedsUpdateAnyway() - { - return addedChildShapes.Count < 1; - } - - protected override void OnPositionChanged(Quat rotation, float angle, - Vector3 membraneCoords) - { - organelle!.OrganelleGraphics!.Transform = new Transform(rotation, membraneCoords); - - Vector3 middle = Hex.AxialToCartesian(new Hex(0, 0)); - Vector3 membranePointDirection = (membraneCoords - middle).Normalized(); - - membraneCoords += membranePointDirection * Constants.DEFAULT_HEX_SIZE * 2; - - if (organelle.ParentMicrobe!.CellTypeProperties.IsBacteria) - { - membraneCoords *= 0.5f; - } - - var physicsRotation = MathUtils.CreateRotationForPhysicsOrganelle(angle); - var parentMicrobe = currentShapesParent!; - - if (parentMicrobe.Colony != null && !NeedsUpdateAnyway()) - { - // Get the real position of the pilus while in the colony - membraneCoords = organelle.RotatedPositionInsideColony(membraneCoords); - membraneCoords += parentMicrobe.GetOffsetRelativeToMaster(); - } - - var transform = new Transform(physicsRotation, membraneCoords); - if (NeedsUpdateAnyway()) - CreateShape(parentMicrobe); - - parentMicrobe.ShapeOwnerSetTransform(addedChildShapes[0], transform); - } - - private void CreateShape(Microbe parent) - { - float pilusSize = 4.6f; - - // Scale the size down for bacteria - if (organelle!.ParentMicrobe!.CellTypeProperties.IsBacteria) - { - pilusSize *= 0.5f; - } - - // TODO: Godot doesn't have Cone shape. - // https://github.com/godotengine/godot-proposals/issues/610 - // So this uses a cylinder for now - // Create the shape - var shape = new CylinderShape(); - shape.Radius = pilusSize / 10.0f; - shape.Height = pilusSize; - - var isInjectisome = organelle.Upgrades?.UnlockedFeatures.Contains(PILUS_INJECTISOME_UPGRADE_NAME); - - var ownerId = parent.CreateShapeOwner(shape); - parent.ShapeOwnerAddShape(ownerId, shape); - parent.AddPilus(ownerId, isInjectisome == true); - addedChildShapes.Add(ownerId); - } - - private void DestroyShape() - { - if (addedChildShapes.Count > 0) - { - foreach (var shape in addedChildShapes) - { - currentShapesParent!.RemovePilus(shape); - currentShapesParent.RemoveShapeOwner(shape); - } - - addedChildShapes.Clear(); - } - } -} - -public class PilusComponentFactory : IOrganelleComponentFactory -{ - public IOrganelleComponent Create() - { - return new PilusComponent(); - } - - public void Check(string name) - { - } -} + \ No newline at end of file diff --git a/src/microbe_stage/organelle_components/SignalingAgentComponent.cs b/src/microbe_stage/organelle_components/SignalingAgentComponent.cs deleted file mode 100644 index e5ea44fe3e4..00000000000 --- a/src/microbe_stage/organelle_components/SignalingAgentComponent.cs +++ /dev/null @@ -1,15 +0,0 @@ -public class SignalingAgentComponent : EmptyOrganelleComponent -{ -} - -public class SignalingAgentComponentFactory : IOrganelleComponentFactory -{ - public IOrganelleComponent Create() - { - return new SignalingAgentComponent(); - } - - public void Check(string name) - { - } -} diff --git a/src/microbe_stage/organelle_components/SlimeJetComponent.cs b/src/microbe_stage/organelle_components/SlimeJetComponent.cs index 1cc41b68f52..fb437e2af8e 100644 --- a/src/microbe_stage/organelle_components/SlimeJetComponent.cs +++ b/src/microbe_stage/organelle_components/SlimeJetComponent.cs @@ -1,140 +1,115 @@ using System; +using Components; +using DefaultEcs; using Godot; /// /// Slime-powered jet for adding bursts of speed /// -public class SlimeJetComponent : ExternallyPositionedComponent +public class SlimeJetComponent : IOrganelleComponent { - private bool active; + private bool animationActive; + private bool animationDirty = true; - private AnimationPlayer? animation; + private AnimationPlayer animation = null!; - private Compound mucilage = null!; + private Vector3 organellePosition; + private Vector3 queuedForce = Vector3.Zero; - /// - /// The amount of slime secreted in the current process cycle - /// - private float slimeToSecrete; + public bool UsesSyncProcess => animationDirty; /// - /// Whether this jet is currently secreting slime and animating + /// Whether this jet is currently secreting slime (and animating) /// public bool Active { - get => active; + get => animationActive; set { - active = value; + if (animationActive == value) + return; - if (animation != null) - { - // Play the animation if active, and vice versa - animation.PlaybackSpeed = active ? 1.0f : 0.0f; - } + animationActive = value; + animationDirty = true; } } - public override void UpdateAsync(float delta) + public void OnAttachToCell(PlacedOrganelle organelle) { - // Visual positioning code - base.UpdateAsync(delta); - - var microbe = organelle!.ParentMicrobe!; - - if (microbe.PhagocytosisStep != PhagocytosisPhase.None) - return; + animation = organelle.OrganelleAnimation ?? + throw new InvalidOperationException("Slime jet requires animation player on organelle"); - var movement = CalculateMovementForce(microbe, delta); - - if (movement != Vector3.Zero) - microbe.AddMovementForce(movement); + organellePosition = Hex.AxialToCartesian(organelle.Position); } - public override void UpdateSync() + public void UpdateAsync(ref OrganelleContainer organelleContainer, in Entity microbeEntity, float delta) { - base.UpdateSync(); + // All of the logic for this ended up in MicrobeEmissionSystem and MicrobeMovementSystem, just the animation + // applying is here anymore... + } - var microbe = organelle!.ParentMicrobe!; + public void UpdateSync(in Entity microbeEntity, float delta) + { + // Play the animation if active, and vice versa + animation.PlaybackSpeed = animationActive ? 1.0f : 0.0f; + animationDirty = false; + } - if (microbe.PhagocytosisStep != PhagocytosisPhase.None) + public void AddQueuedForce(in Entity entity, float slimeAmount) + { + if (!Active) { - slimeToSecrete = 0.0f; + GD.PrintErr("Non-active slime jet attempt to add force"); return; } - var direction = GetDirection(); - - // Eject mucilage at the maximum rate in the opposite direction to this organelle's rotation - microbe.EjectCompound(mucilage, slimeToSecrete, -direction, 2); - slimeToSecrete = 0.0f; + queuedForce += CalculateMovementForce(entity, slimeAmount); } - /// - /// Determines the movement impulse imparted by this jet by ejecting some mucilage - /// - public Vector3 CalculateMovementForce(Microbe microbe, float delta) + public void ConsumeMovementForce(out Vector3 force) { - if (!Active) - return Vector3.Zero; + force = queuedForce; - var currentCellRotation = microbe.GlobalTransform.basis.Quat().Normalized(); - var direction = GetDirection(); - - // Preview the amount of mucilage we'll eject to calculate force here - // Don't actually eject, as this is unsafe here. See: https://github.com/Revolutionary-Games/Thrive/issues/3270 - slimeToSecrete = Math.Min(Constants.COMPOUNDS_TO_VENT_PER_SECOND * delta, - microbe.Compounds.GetCompoundAmount(mucilage)); - - // Scale total added force by the amount ejected - return Constants.MUCILAGE_JET_FACTOR * slimeToSecrete * - currentCellRotation.Xform(direction) / microbe.MassFromOrganelles; + queuedForce.x = 0; + queuedForce.y = 0; + queuedForce.z = 0; } public Vector3 GetDirection() { - Vector3 organellePosition = Hex.AxialToCartesian(organelle!.Position); Vector3 middle = Hex.AxialToCartesian(new Hex(0, 0)); var delta = middle - organellePosition; if (delta == Vector3.Zero) - delta = DefaultVisualPos; + delta = Components.CellPropertiesHelpers.DefaultVisualPos; return delta.Normalized(); } - protected override void CustomAttach() + /// + /// Determines the movement impulse imparted by this jet by ejecting some mucilage + /// + private Vector3 CalculateMovementForce(in Entity entity, float slimeAmount) { - mucilage = SimulationParameters.Instance.GetCompound("mucilage"); + if (!Active) + return Vector3.Zero; - if (organelle?.OrganelleGraphics == null) - throw new InvalidOperationException("Slime jet needs parent organelle to have graphics"); + // Scale total added force by the amount ejected + // TODO: this used to be divided by "microbe.MassFromOrganelles" make sure this force still makes sense (and + // considering the new physics engine) + float force = Constants.MUCILAGE_JET_FACTOR * slimeAmount; - animation = organelle.OrganelleAnimation; + var direction = GetDirection(); - if (animation == null) + // Take rotation in colony into account + // TODO: verify this math actually ends up correct considering the rotating of the movement vector in the + // microbe movement system + if (entity.Has()) { - GD.PrintErr("SlimeJetComponent's organelle has no animation player set"); - return; - } - - // Add to the microbe's slime jet list so we can activate/deactivate from the microbe class - organelle.ParentMicrobe!.SlimeJets.Add(this); - } + var extraRotation = entity.Get().RelativeRotation; - protected override bool NeedsUpdateAnyway() - { - // The basis of the transform represents the rotation, as long as the rotation is not modified, - // the organelle needs to be updated. - // TODO: Calculated rotations should never equal the identity, - // it should be kept an eye on if it does. The engine for some reason doesnt update THIS basis - // unless checked with some condition (if or return) - // SEE: https://github.com/Revolutionary-Games/Thrive/issues/2906 - return organelle!.OrganelleGraphics!.Transform.basis == Transform.Identity.basis; - } + return extraRotation.Xform(direction) * force; + } - protected override void OnPositionChanged(Quat rotation, float angle, - Vector3 membraneCoords) - { - organelle!.OrganelleGraphics!.Transform = new Transform(rotation, membraneCoords); + return direction * force; } } diff --git a/src/microbe_stage/particles/CellBurstEffect.cs b/src/microbe_stage/particles/CellBurstEffect.cs deleted file mode 100644 index 94bb0def08c..00000000000 --- a/src/microbe_stage/particles/CellBurstEffect.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Godot; -using Newtonsoft.Json; - -[JSONAlwaysDynamicType] -[SceneLoadedClass("res://src/microbe_stage/particles/CellBurstEffect.tscn", UsesEarlyResolve = false)] -public class CellBurstEffect : Spatial, ITimedLife -{ - [JsonProperty] - public float Radius; - -#pragma warning disable CA2213 - private Particles particles = null!; -#pragma warning restore CA2213 - - public float TimeToLiveRemaining { get; set; } - - public override void _Ready() - { - particles = GetNode("Particles"); - - TimeToLiveRemaining = particles.Lifetime; - - var material = (ParticlesMaterial)particles.ProcessMaterial; - - material.EmissionSphereRadius = Radius / 2; - material.LinearAccel = Radius / 2; - particles.OneShot = true; - } - - public void OnTimeOver() - { - this.DetachAndQueueFree(); - } -} diff --git a/src/microbe_stage/particles/CellBurstEffect.tscn b/src/microbe_stage/particles/CellBurstEffect.tscn index 67fc58d97d2..6ce68545ad0 100644 --- a/src/microbe_stage/particles/CellBurstEffect.tscn +++ b/src/microbe_stage/particles/CellBurstEffect.tscn @@ -1,7 +1,6 @@ -[gd_scene load_steps=12 format=2] +[gd_scene load_steps=11 format=2] [ext_resource path="res://assets/textures/snowflake2.png" type="Texture" id=1] -[ext_resource path="res://src/microbe_stage/particles/CellBurstEffect.cs" type="Script" id=2] [sub_resource type="Gradient" id=1] offsets = PoolRealArray( 0, 0.576577, 1 ) @@ -55,10 +54,7 @@ albedo_texture = ExtResource( 1 ) [sub_resource type="QuadMesh" id=9] material = SubResource( 8 ) -[node name="CellBurstEffect" type="Spatial"] -script = ExtResource( 2 ) - -[node name="Particles" type="Particles" parent="."] +[node name="CellBurstEffect" type="Particles"] amount = 300 lifetime = 5.0 explosiveness = 1.0 diff --git a/src/microbe_stage/systems/AllCompoundsVentingSystem.cs b/src/microbe_stage/systems/AllCompoundsVentingSystem.cs new file mode 100644 index 00000000000..2280cac5fef --- /dev/null +++ b/src/microbe_stage/systems/AllCompoundsVentingSystem.cs @@ -0,0 +1,104 @@ +namespace Systems +{ + using System; + using System.Collections.Generic; + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + using World = DefaultEcs.World; + + /// + /// Vents all compounds until empty from a that has a . + /// Requires a + /// + [With(typeof(CompoundVenter))] + [With(typeof(CompoundStorage))] + [With(typeof(WorldPosition))] + public sealed class AllCompoundsVentingSystem : AEntitySetSystem + { + private readonly CompoundCloudSystem compoundCloudSystem; + private readonly WorldSimulation worldSimulation; + + private readonly List processedCompoundKeys = new(); + + public AllCompoundsVentingSystem(CompoundCloudSystem compoundClouds, WorldSimulation worldSimulation, + World world, IParallelRunner runner) : base(world, runner) + { + compoundCloudSystem = compoundClouds; + this.worldSimulation = worldSimulation; + + if (runner.DegreeOfParallelism > 1) + throw new InvalidOperationException("This system can't run in parallel"); + } + + protected override void Update(float delta, in Entity entity) + { + // TODO: rate limit updates if needed for performance? + + ref var venter = ref entity.Get(); + + if (venter.VentingPrevented) + return; + + ref var compounds = ref entity.Get(); + + if (compounds.Compounds.Compounds.Count < 1) + { + // Empty, perform defined actions for when this venter runs out + OnOutOfCompounds(in entity, ref venter); + return; + } + + ref var position = ref entity.Get(); + + processedCompoundKeys.Clear(); + processedCompoundKeys.AddRange(compounds.Compounds.Compounds.Keys); + + // Loop through all the compounds in the storage bag and eject them + bool vented = false; + foreach (var compound in processedCompoundKeys) + { + if (compounds.VentChunkCompound(compound, delta * venter.VentEachCompoundPerSecond, position.Position, + compoundCloudSystem)) + { + vented = true; + } + } + + if (!vented) + { + OnOutOfCompounds(in entity, ref venter); + } + } + + private void OnOutOfCompounds(in Entity entity, ref CompoundVenter venter) + { + if (venter.RanOutOfVentableCompounds) + return; + + // Stop venting + venter.VentingPrevented = true; + venter.RanOutOfVentableCompounds = true; + + if (venter.UsesMicrobialDissolveEffect) + { + // Disable physics to stop collisions + if (entity.Has()) + { + ref var physics = ref entity.Get(); + physics.BodyDisabled = true; + } + + entity.StartDissolveAnimation(worldSimulation, true, true); + + // This entity is no longer important to save + worldSimulation.ReportEntityDyingSoon(entity); + } + else if (venter.DestroyOnEmpty) + { + worldSimulation.DestroyEntity(entity); + } + } + } +} diff --git a/src/microbe_stage/systems/CellBurstEffectSystem.cs b/src/microbe_stage/systems/CellBurstEffectSystem.cs new file mode 100644 index 00000000000..a34ab8026b1 --- /dev/null +++ b/src/microbe_stage/systems/CellBurstEffectSystem.cs @@ -0,0 +1,56 @@ +namespace Systems +{ + using Components; + using DefaultEcs; + using DefaultEcs.System; + using Godot; + using World = DefaultEcs.World; + + /// + /// Updates cell burst effects as time elapses. As this just setups the effect this doesn't need to run per frame + /// normal update frequency is fine. + /// + [With(typeof(CellBurstEffect))] + [With(typeof(TimedLife))] + [With(typeof(SpatialInstance))] + [RunsBefore(typeof(TimedLifeSystem))] + [RunsOnMainThread] + public sealed class CellBurstEffectSystem : AEntitySetSystem + { + public CellBurstEffectSystem(World world) : base(world, null) + { + } + + protected override void Update(float delta, in Entity entity) + { + ref var burstEffect = ref entity.Get(); + ref var timedLife = ref entity.Get(); + ref var spatial = ref entity.Get(); + + if (burstEffect.Initialized) + return; + + // Skip if can't initialize yet + if (spatial.GraphicalInstance == null) + return; + + burstEffect.Initialized = true; + + var particles = spatial.GraphicalInstance as Particles; + + if (particles == null) + { + GD.PrintErr("Cell burst effect visual instance is not particles"); + return; + } + + timedLife.TimeToLiveRemaining = particles.Lifetime; + + var material = (ParticlesMaterial)particles.ProcessMaterial; + + material.EmissionSphereRadius = burstEffect.Radius / 2; + material.LinearAccel = burstEffect.Radius / 2; + particles.OneShot = true; + } + } +} diff --git a/src/microbe_stage/systems/ColonyBindingSystem.cs b/src/microbe_stage/systems/ColonyBindingSystem.cs new file mode 100644 index 00000000000..cec215b0a7e --- /dev/null +++ b/src/microbe_stage/systems/ColonyBindingSystem.cs @@ -0,0 +1,218 @@ +namespace Systems +{ + using System; + using Components; + using DefaultEcs; + using DefaultEcs.Command; + using DefaultEcs.System; + using DefaultEcs.Threading; + using Godot; + using World = DefaultEcs.World; + + /// + /// Handles microbe binding mode for creating microbe colonies + /// + [With(typeof(MicrobeControl))] + [With(typeof(CollisionManagement))] + [With(typeof(MicrobeSpeciesMember))] + [With(typeof(Health))] + [With(typeof(SoundEffectPlayer))] + [With(typeof(CompoundStorage))] + [With(typeof(OrganelleContainer))] + [With(typeof(MicrobePhysicsExtraData))] + [With(typeof(CellProperties))] + [With(typeof(WorldPosition))] + [Without(typeof(AttachedToEntity))] + [RunsBefore(typeof(MicrobeFlashingSystem))] + [ReadsComponent(typeof(WorldPosition))] + [WritesToComponent(typeof(Spawned))] + public sealed class ColonyBindingSystem : AEntitySetSystem + { + private readonly IWorldSimulation worldSimulation; + private readonly Compound atp; + + public ColonyBindingSystem(IWorldSimulation worldSimulation, World world, IParallelRunner parallelRunner) : + base(world, parallelRunner) + { + this.worldSimulation = worldSimulation; + atp = SimulationParameters.Instance.GetCompound("atp"); + } + + protected override void Update(float delta, in Entity entity) + { + ref var control = ref entity.Get(); + + if (control.State == MicrobeState.Unbinding) + { + throw new NotImplementedException(); + } + else if (control.State == MicrobeState.Binding) + { + HandleBindingMode(ref control, entity, delta); + } + } + + private void HandleBindingMode(ref MicrobeControl control, in Entity entity, float delta) + { + ref var health = ref entity.Get(); + + // Disallow binding to happen when dead + if (health.Dead) + return; + + ref var organelles = ref entity.Get(); + ref var ourSpecies = ref entity.Get(); + + if (!organelles.CanBind(ourSpecies.Species)) + { + // Force exit binding mode if a cell that cannot bind has entered binding mode + control.State = MicrobeState.Normal; + return; + } + + // Drain atp + var cost = Constants.BINDING_ATP_COST_PER_SECOND * delta; + + var compounds = entity.Get().Compounds; + + if (compounds.TakeCompound(atp, cost) < cost - 0.001f) + { + control.State = MicrobeState.Normal; + return; + } + + ref var soundPlayer = ref entity.Get(); + + // To simplify the logic this audio is now played non-looping + // TODO: if this sounds too bad with the sound volume no longer fading then this will need to change + soundPlayer.PlaySoundEffectIfNotPlayingAlready(Constants.MICROBE_BINDING_MODE_SOUND, 0.6f); + + var count = entity.Get().GetActiveCollisions(out var collisions); + + if (count <= 0) + return; + + // Can't bind when membrane is not ready (note this doesn't manage to check colony members so this isn't + // an exact check meaning the actual bind method can still fail later even if this check passes) + ref var cellProperties = ref entity.Get(); + + // TODO: should this require an up to date membrane data? + if (cellProperties.CreatedMembrane == null /*|| cellProperties.CreatedMembrane.IsChangingShape*/) + return; + + ref var extraPhysicsData = ref entity.Get(); + + for (int i = 0; i < count; ++i) + { + ref var collision = ref collisions![i]; + + if (!organelles.CanBindWith(ourSpecies.Species, collision.SecondEntity)) + continue; + + // TODO: to ensure no engulf can start on the same frame as a bind, maybe we need a cache of touched + // entities in AttachedToEntityHelpers that gets cleared each world update? + // Can't bind with an attached entity (engulfed entity for example) + // The above check already checks against binding to something that is in a colony + if (collision.SecondEntity.Has()) + continue; + + // Second entity is not a full microbe (this shouldn't happen but for safety this check is here) + if (!collision.SecondEntity.Has()) + continue; + + // Skip if trying to bind through a pilus + if (extraPhysicsData.IsSubShapePilus(collision.FirstSubShapeData)) + continue; + + if (collision.SecondEntity.Get() + .IsSubShapePilus(collision.SecondSubShapeData)) + { + continue; + } + + if (!extraPhysicsData.MicrobeIndexFromSubShape(collision.FirstSubShapeData, + out var indexOfMemberToBindTo)) + { + GD.PrintErr("Couldn't get colony member index to bind to"); + continue; + } + + // Lock here to try to guarantee no entity is going to get attached to multiple colonies at the same + // time + lock (AttachedToEntityHelpers.EntityAttachRelationshipModifyLock) + { + // Binding can proceed + if (BeginBind(ref control, entity, indexOfMemberToBindTo, collision.SecondEntity)) + { + // Try to bind at most once per frame + break; + } + } + } + } + + private bool BeginBind(ref MicrobeControl control, in Entity primaryEntity, int indexOfMemberToBindTo, + in Entity other) + { + if (!other.IsAlive) + { + GD.PrintErr("Binding attempted on a dead entity"); + return false; + } + + // A recorder is used to record the new components to ensure thread safety here + var recorder = worldSimulation.StartRecordingEntityCommands(); + + bool success; + + // Create a colony if there isn't one yet + if (!primaryEntity.Has()) + { + if (indexOfMemberToBindTo != 0) + { + // This should never happen as the colony is not yet created, the parent cell is by itself so the + // index should always be 0 + GD.PrintErr("Initial colony creation doesn't have parent entity index in colony of 0"); + indexOfMemberToBindTo = 0; + } + + var colony = new MicrobeColony(primaryEntity, control.State, other); + + MicrobeColonyHelpers.OnColonyMemberAdded(primaryEntity); + + success = HandleAddToColony(ref colony, primaryEntity, indexOfMemberToBindTo, other, recorder); + + recorder.Record(primaryEntity).Set(colony); + } + else + { + ref var colony = ref primaryEntity.Get(); + + success = HandleAddToColony(ref colony, primaryEntity, indexOfMemberToBindTo, other, recorder); + } + + if (!success) + { + GD.PrintErr("Failed to bind a new cell to a colony, rolling back entity commands"); + recorder.Clear(); + worldSimulation.FinishRecordingEntityCommands(recorder); + return false; + } + + // Move out of binding state before adding the colony member to avoid accidental collisions being able to + // recursively trigger colony attachment + control.State = MicrobeState.Normal; + + // Other cell control is set by MicrobeColonyHelpers.OnColonyMemberAdded + + worldSimulation.FinishRecordingEntityCommands(recorder); + return true; + } + + private bool HandleAddToColony(ref MicrobeColony colony, in Entity colonyEntity, int parentIndex, + in Entity newCell, EntityCommandRecorder entityCommandRecorder) + { + return colony.AddToColony(colonyEntity, parentIndex, newCell, entityCommandRecorder); + } + } +} diff --git a/src/microbe_stage/systems/ColonyCompoundDistributionSystem.cs b/src/microbe_stage/systems/ColonyCompoundDistributionSystem.cs new file mode 100644 index 00000000000..2f56c88f414 --- /dev/null +++ b/src/microbe_stage/systems/ColonyCompoundDistributionSystem.cs @@ -0,0 +1,28 @@ +namespace Systems +{ + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + + /// + /// Evenly distributes compounds (except ones that can't be shared between cells like ATP) between cells in a + /// colony + /// + [With(typeof(MicrobeColony))] + [Without(typeof(AttachedToEntity))] + public sealed class ColonyCompoundDistributionSystem : AEntitySetSystem + { + public ColonyCompoundDistributionSystem(World world, IParallelRunner parallelRunner) : base(world, + parallelRunner) + { + } + + protected override void Update(float delta, in Entity entity) + { + ref var colony = ref entity.Get(); + + colony.GetCompounds().DistributeCompoundSurplus(); + } + } +} diff --git a/src/microbe_stage/systems/ColonyStatsUpdateSystem.cs b/src/microbe_stage/systems/ColonyStatsUpdateSystem.cs new file mode 100644 index 00000000000..6d74dffff01 --- /dev/null +++ b/src/microbe_stage/systems/ColonyStatsUpdateSystem.cs @@ -0,0 +1,53 @@ +namespace Systems +{ + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + using MicrobeColony = Components.MicrobeColony; + + /// + /// Handles updating the statistics values (and applying the ones that apply to other components, for example + /// entity weight) for microbe colonies + /// + [With(typeof(MicrobeColony))] + [WritesToComponent(typeof(Spawned))] + [RunsAfter(typeof(SpawnSystem))] + [RunsAfter(typeof(MulticellularGrowthSystem))] + public sealed class ColonyStatsUpdateSystem : AEntitySetSystem + { + public ColonyStatsUpdateSystem(World world, IParallelRunner parallelRunner) : base(world, + parallelRunner) + { + } + + protected override void Update(float delta, in Entity entity) + { + ref var colony = ref entity.Get(); + + colony.CanEngulf(); + + if (!colony.ColonyRotationMultiplierCalculated) + colony.CalculateRotationMultiplier(); + + if (!colony.EntityWeightApplied) + { + if (entity.Has()) + { + ref var spawned = ref entity.Get(); + + // Weight calculation may not be ready immediately so this can fail (in which case this is retried) + if (colony.CalculateEntityWeight(entity, out var weight)) + { + spawned.EntityWeight = weight; + colony.EntityWeightApplied = true; + } + } + else + { + colony.EntityWeightApplied = true; + } + } + } + } +} diff --git a/src/microbe_stage/systems/CompoundAbsorptionSystem.cs b/src/microbe_stage/systems/CompoundAbsorptionSystem.cs new file mode 100644 index 00000000000..cb1ff010b35 --- /dev/null +++ b/src/microbe_stage/systems/CompoundAbsorptionSystem.cs @@ -0,0 +1,75 @@ +namespace Systems +{ + using System; + using System.Linq; + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + + /// + /// Handles absorbing compounds from into + /// + [With(typeof(CompoundAbsorber))] + [With(typeof(CompoundStorage))] + [With(typeof(WorldPosition))] + public sealed class CompoundAbsorptionSystem : AEntitySetSystem + { + private readonly CompoundCloudSystem compoundCloudSystem; + + public CompoundAbsorptionSystem(CompoundCloudSystem compoundCloudSystem, World world, IParallelRunner runner) : + base(world, runner) + { + this.compoundCloudSystem = compoundCloudSystem; + } + + protected override void Update(float delta, in Entity entity) + { + ref var absorber = ref entity.Get(); + + if (absorber.AbsorbRadius <= 0 || absorber.AbsorbSpeed < 0) + return; + + if (absorber.AbsorbSpeed != 0) + { + // Rate limited absorbing is not implemented + throw new NotImplementedException(); + } + + ref var storage = ref entity.Get(); + + if (absorber.OnlyAbsorbUseful && !storage.Compounds.HasAnyBeenSetUseful()) + { + // Skip processing until something is set useful + // TODO: maybe there is a conceivable scenario where only generally useful compounds should be absorbed + // in which case this check fails even though the generally useful stuff should be absorbed + return; + } + + if (!absorber.OnlyAbsorbUseful) + { + // The clouds by default check that the bag has a compound set useful before absorbing it, so if this + // flag is set to false we would need to communicate that to the clouds someway + throw new NotImplementedException(); + } + + ref var position = ref entity.Get(); + + compoundCloudSystem.AbsorbCompounds(position.Position, absorber.AbsorbRadius, storage.Compounds, + absorber.TotalAbsorbedCompounds, delta, absorber.AbsorptionRatio); + + // Player infinite compounds cheat, doesn't *really* belong here but this is probably the best place to put + // this instead of creating a dedicated cheats handling system + if (CheatManager.InfiniteCompounds && entity.Has()) + { + var usefulCompounds = + SimulationParameters.Instance.GetCloudCompounds().Where(storage.Compounds.IsUseful); + foreach (var usefulCompound in usefulCompounds) + { + storage.Compounds.AddCompound(usefulCompound, + storage.Compounds.GetFreeSpaceForCompound(usefulCompound)); + } + } + } + } +} diff --git a/src/microbe_stage/systems/DamageSoundSystem.cs b/src/microbe_stage/systems/DamageSoundSystem.cs new file mode 100644 index 00000000000..7b57dcac5b7 --- /dev/null +++ b/src/microbe_stage/systems/DamageSoundSystem.cs @@ -0,0 +1,73 @@ +namespace Systems +{ + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + + /// + /// Handles playing microbe damage sounds and clearing the list of received damage on a microbe + /// + [With(typeof(Health))] + [With(typeof(SoundEffectPlayer))] + [RunsBefore(typeof(SoundEffectSystem))] + public sealed class DamageSoundSystem : AEntitySetSystem + { + public DamageSoundSystem(World world, IParallelRunner parallelRunner) : base(world, parallelRunner) + { + } + + protected override void Update(float state, in Entity entity) + { + ref var health = ref entity.Get(); + + var receivedDamage = health.RecentDamageReceived; + + // We don't lock here before checking the count, it's probably fine as it should just read a single int + // but in the future if we get random crashes add a "lock" statement around also the count access. + if (receivedDamage == null || receivedDamage.Count < 1) + return; + + ref var soundEffectPlayer = ref entity.Get(); + + lock (receivedDamage) + { + foreach (var damageEventNotice in receivedDamage) + { + var damageSource = damageEventNotice.DamageSource; + + // TODO: different injectisome sound effect? + if (damageSource is "toxin" or "oxytoxy" or "injectisome") + { + // Play the toxin sound + soundEffectPlayer.PlaySoundEffect("res://assets/sounds/soundeffects/microbe-toxin-damage.ogg"); + } + else if (damageSource == "pilus") + { + // Play the pilus sound + soundEffectPlayer.PlaySoundEffect("res://assets/sounds/soundeffects/pilus_puncture_stab.ogg", + 4.0f); + } + else if (damageSource == "chunk") + { + // TODO: Replace this take damage sound with a more appropriate one. + + soundEffectPlayer.PlaySoundEffect("res://assets/sounds/soundeffects/microbe-toxin-damage.ogg"); + } + else if (damageSource == "atpDamage") + { + soundEffectPlayer.PlaySoundEffect("res://assets/sounds/soundeffects/microbe-atp-damage.ogg"); + } + else if (damageSource == "ice") + { + // TODO: check the volume here as this was before set to play non-positionally + soundEffectPlayer.PlaySoundEffect("res://assets/sounds/soundeffects/microbe-ice-damage.ogg", + 0.5f); + } + } + + receivedDamage.Clear(); + } + } + } +} diff --git a/src/microbe_stage/systems/EngulfedDigestionSystem.cs b/src/microbe_stage/systems/EngulfedDigestionSystem.cs new file mode 100644 index 00000000000..ce8f08ba4b1 --- /dev/null +++ b/src/microbe_stage/systems/EngulfedDigestionSystem.cs @@ -0,0 +1,247 @@ +namespace Systems +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + using Godot; + using World = DefaultEcs.World; + + /// + /// Handles digestion of engulfed objects (and starting ejection of indigestible things). + /// for the system responsible for pulling in and ejecting engulfables. + /// + [With(typeof(Engulfer))] + [With(typeof(OrganelleContainer))] + [With(typeof(CompoundStorage))] + [With(typeof(MicrobeStatus))] + [With(typeof(CellProperties))] + [With(typeof(Health))] + [With(typeof(WorldPosition))] + [ReadsComponent(typeof(WorldPosition))] + [RunsAfter(typeof(EngulfingSystem))] + public sealed class EngulfedDigestionSystem : AEntitySetSystem + { + private readonly CompoundCloudSystem compoundCloudSystem; + private readonly Compound oxytoxy; + private readonly IReadOnlyCollection digestibleCompounds; + + private readonly Enzyme lipase; + + public EngulfedDigestionSystem(CompoundCloudSystem compoundCloudSystem, World world, + IParallelRunner parallelRunner) : base(world, parallelRunner) + { + this.compoundCloudSystem = compoundCloudSystem; + var simulationParameters = SimulationParameters.Instance; + oxytoxy = simulationParameters.GetCompound("oxytoxy"); + digestibleCompounds = simulationParameters.GetAllCompounds().Values.Where(c => c.Digestible).ToList(); + lipase = simulationParameters.GetEnzyme("lipase"); + } + + protected override void Update(float delta, in Entity entity) + { + ref var engulfer = ref entity.Get(); + + if (engulfer.EngulfedObjects == null || engulfer.EngulfedObjects.Count < 1) + return; + + ref var organelles = ref entity.Get(); + var compounds = entity.Get().Compounds; + + HandleDigestion(entity, ref engulfer, ref organelles, compounds, delta); + } + + /// + /// Absorbs compounds from ingested objects. + /// + private void HandleDigestion(in Entity entity, ref Engulfer engulfer, ref OrganelleContainer organelles, + CompoundBag compounds, float delta) + { + // Skip if enzymes aren't calculated yet + if (organelles.AvailableEnzymes == null) + return; + + float usedCapacity = 0; + + ref var cellProperties = ref entity.Get(); + ref var position = ref entity.Get(); + + for (int i = engulfer.EngulfedObjects!.Count - 1; i >= 0; --i) + { + var engulfedObject = engulfer.EngulfedObjects![i]; + +#if DEBUG + if (!engulfedObject.IsAlive) + { + throw new Exception( + "Digestion system has a non-alive engulfed object, engulfing system should have taken care " + + "of this before it reached us"); + } +#endif + + if (!engulfedObject.Has()) + { + GD.PrintErr("Microbe has engulfed object that isn't engulfable"); + continue; + } + + ref var engulfable = ref engulfedObject.Get(); + + // Skip processing things that aren't currently fully ingested + if (engulfable.PhagocytosisStep != PhagocytosisPhase.Ingested) + continue; + + // Expel this engulfed object if the cell loses some of its size and its ingestion capacity + // is overloaded + if (engulfer.UsedIngestionCapacity > engulfer.EngulfStorageSize) + { + engulfer.EjectEngulfable(ref engulfable); + continue; + } + + // Doesn't make sense to digest non ingested objects, i.e. objects that are being engulfed, + // being ejected, etc. So skip them. + if (engulfable.PhagocytosisStep != PhagocytosisPhase.Ingested) + continue; + + Enzyme usedEnzyme; + + var digestibility = organelles.CanDigestObject(ref engulfable); + + switch (digestibility) + { + case DigestCheckResult.Ok: + { + usedEnzyme = engulfable.RequisiteEnzymeToDigest ?? lipase; + + // TODO: only call this once + engulfable.OnReportBecomeIngestedIfCallbackRegistered(engulfedObject); + + break; + } + + case DigestCheckResult.MissingEnzyme: + { + engulfer.EjectEngulfable(ref engulfable); + + entity.SendNoticeIfPossible(new LocalizedString("NOTICE_ENGULF_MISSING_ENZYME", + engulfable.RequisiteEnzymeToDigest!.Name)); + continue; + } + + default: + throw new InvalidOperationException("Unhandled digestibility check result, won't digest"); + } + + CompoundBag? containedCompounds = null; + + if (engulfedObject.Has()) + { + containedCompounds = engulfedObject.Get().Compounds; + } + + var additionalCompounds = engulfable.AdditionalEngulfableCompounds; + + // Workaround to avoid NaN compounds in engulfed objects, leading to glitches like infinite compound + // ejection and incorrect ingested matter display + // https://github.com/Revolutionary-Games/Thrive/issues/3548 + containedCompounds?.FixNaNCompounds(); + + var totalAmountLeft = 0.0f; + + foreach (var compound in digestibleCompounds) + { + var storageAmount = containedCompounds?.GetCompoundAmount(compound) ?? 0; + + var additionalAmount = 0.0f; + additionalCompounds?.TryGetValue(compound, out additionalAmount); + + var totalAvailable = storageAmount + additionalAmount; + totalAmountLeft += totalAvailable; + + if (totalAvailable <= 0) + continue; + + var amount = + MicrobeInternalCalculations.CalculateDigestionSpeed(organelles.AvailableEnzymes[usedEnzyme]); + amount *= delta; + + // Efficiency starts from Constants.ENGULF_BASE_COMPOUND_ABSORPTION_YIELD up to + // Constants.ENZYME_DIGESTION_EFFICIENCY_MAXIMUM. This means at least 7 lysosomes + // are needed to achieve "maximum" efficiency + var efficiency = + MicrobeInternalCalculations.CalculateDigestionEfficiency( + organelles.AvailableEnzymes[usedEnzyme]); + + var taken = Mathf.Min(totalAvailable, amount); + + // Toxin damage + if (compound == oxytoxy && taken > 0) + { + ref var status = ref entity.Get(); + + status.LastCheckedOxytoxyDigestionDamage += delta; + + if (status.LastCheckedOxytoxyDigestionDamage >= Constants.TOXIN_DIGESTION_DAMAGE_CHECK_INTERVAL) + { + status.LastCheckedOxytoxyDigestionDamage -= Constants.TOXIN_DIGESTION_DAMAGE_CHECK_INTERVAL; + + ref var health = ref entity.Get(); + + health.DealMicrobeDamage(ref cellProperties, + health.MaxHealth * Constants.TOXIN_DIGESTION_DAMAGE_FRACTION, "oxytoxy"); + + entity.SendNoticeIfPossible(() => new SimpleHUDMessage( + TranslationServer.Translate("NOTICE_ENGULF_DAMAGE_FROM_TOXIN"), + DisplayDuration.Short)); + } + } + + if (additionalCompounds?.ContainsKey(compound) == true) + additionalCompounds[compound] -= taken; + + if (engulfedObject.Has()) + { + // TODO: shouldn't this read the amount of compounds actually taken here? + // This used to be like this even before the ECS conversion + engulfedObject.Get().Compounds.TakeCompound(compound, taken); + } + + var takenAdjusted = taken * efficiency; + var added = compounds.AddCompound(compound, takenAdjusted); + + // Eject excess + cellProperties.SpawnEjectedCompound(ref position, compoundCloudSystem, compound, + takenAdjusted - added, Vector3.Back); + } + + var initialTotalEngulfableCompounds = engulfable.InitialTotalEngulfableCompounds; + + if (initialTotalEngulfableCompounds != 0) + { + engulfable.DigestedAmount = 1 - (totalAmountLeft / initialTotalEngulfableCompounds); + } + else + { + GD.PrintErr("Engulfing system hasn't initialized InitialTotalEngulfableCompounds"); + } + + if (totalAmountLeft <= 0 || engulfable.DigestedAmount >= Constants.FULLY_DIGESTED_LIMIT) + { + engulfable.PhagocytosisStep = PhagocytosisPhase.Digested; + } + + // This is always applied, even when digested fully now. This is because EngulfingSystem will subtract + // the engulfing size when ejecting an object so this ensures that a digested object cannot contribute + // negative size for a short while. The digested object's impact will be correctly recalculated once + // it is ejected and this system runs again. + usedCapacity += engulfable.AdjustedEngulfSize; + } + + engulfer.UsedIngestionCapacity = usedCapacity; + } + } +} diff --git a/src/microbe_stage/systems/EngulfedHandlingSystem.cs b/src/microbe_stage/systems/EngulfedHandlingSystem.cs new file mode 100644 index 00000000000..8e325d52002 --- /dev/null +++ b/src/microbe_stage/systems/EngulfedHandlingSystem.cs @@ -0,0 +1,134 @@ +namespace Systems +{ + using System; + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + using Godot; + using World = DefaultEcs.World; + + /// + /// Handles entities that are currently engulfed or have been engulfed before and should + /// heal + /// + /// + /// + /// TODO: implement safety against being engulfed by a dead entity at which point the engulfable should be + /// automatically freed from engulfment + /// + /// + [With(typeof(Engulfable))] + [With(typeof(Engulfer))] + [With(typeof(Health))] + [With(typeof(SoundEffectPlayer))] + [RunsAfter(typeof(EngulfedDigestionSystem))] + public sealed class EngulfedHandlingSystem : AEntitySetSystem + { + private float playerEngulfedDeathTimer; + private float previousPlayerEngulfedDeathTimer; + + public EngulfedHandlingSystem(World world, IParallelRunner parallelRunner) : base(world, parallelRunner) + { + } + + protected override void PreUpdate(float delta) + { + base.PreUpdate(delta); + + previousPlayerEngulfedDeathTimer = playerEngulfedDeathTimer; + } + + protected override void Update(float delta, in Entity entity) + { + ref var engulfable = ref entity.Get(); + + // Handle logic if the cell that's being/has been digested is us + if (engulfable.PhagocytosisStep == PhagocytosisPhase.None) + { + if (engulfable.DigestedAmount > 0 && engulfable.DigestedAmount < Constants.PARTIALLY_DIGESTED_THRESHOLD) + { + // Cell is not too damaged, can heal itself in open environment and continue living + engulfable.DigestedAmount -= delta * Constants.ENGULF_COMPOUND_ABSORBING_PER_SECOND; + } + } + else + { + // TODO: it seems that this code is always ran, though with the PARTIALLY_DIGESTED_THRESHOLD check + // maybe this shouldn't always run? + + // Species handling for the player microbe in case the process into partial digestion took too long + // so here we want to limit how long the player should wait until they respawn + if (engulfable.PhagocytosisStep == PhagocytosisPhase.Ingested && entity.Has()) + playerEngulfedDeathTimer += delta; + + if (engulfable.DigestedAmount >= Constants.PARTIALLY_DIGESTED_THRESHOLD || (playerEngulfedDeathTimer >= + Constants.PLAYER_ENGULFED_DEATH_DELAY_MAX && entity.Has())) + { + // Microbe is beyond repair, might as well consider it as dead + ref var health = ref entity.Get(); + + if (!health.Dead) + + KillEngulfed(entity, ref health, ref engulfable); + } + + // If the engulfing entity is dead, then this should have been ejected + // See the TODO in the remarks section + if (!engulfable.HostileEngulfer.IsAlive) + { + GD.PrintErr("Entity is stuck inside a dead engulfer!"); + +#if DEBUG + throw new InvalidOperationException("Entity is inside a dead engulfer (not ejected)"); +#endif + } + } + } + + protected override void PostUpdate(float state) + { + base.PostUpdate(state); + + // If there's no player digestion progress reset the timer + // ReSharper disable once CompareOfFloatsByEqualityOperator + if (previousPlayerEngulfedDeathTimer == playerEngulfedDeathTimer) + { + // Just in case player is engulfed again after escaping to make sure the player doesn't die faster + playerEngulfedDeathTimer = 0; + } + } + + private void KillEngulfed(Entity entity, ref Health health, ref Engulfable engulfable) + { + health.Kill(); + + if (entity.Has()) + { + playerEngulfedDeathTimer = 0; + + ref var soundEffectPlayer = ref entity.Get(); + + soundEffectPlayer.PlaySoundEffect("res://assets/sounds/soundeffects/microbe-death-2.ogg", 0.5f); + } + + var hostile = engulfable.HostileEngulfer; + if (!hostile.IsAlive || !hostile.Has()) + return; + + ref var engulfer = ref entity.Get(); + + if (engulfer.EngulfedObjects is not { Count: > 0 }) + { + // We haven't engulfed anything + return; + } + + ref var hostileEngulfer = ref hostile.Get(); + + // Transfer ownership of all the objects we engulfed to our engulfer (otherwise we'd spill them out + // when we are processed as dead) + engulfer.TransferEngulferObjectsToAnotherEngulfer(entity, ref hostileEngulfer, hostile); + } + } +} diff --git a/src/microbe_stage/systems/EngulfingSystem.cs b/src/microbe_stage/systems/EngulfingSystem.cs new file mode 100644 index 00000000000..41ea21ed5bd --- /dev/null +++ b/src/microbe_stage/systems/EngulfingSystem.cs @@ -0,0 +1,1264 @@ +namespace Systems +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Components; + using DefaultEcs; + using DefaultEcs.System; + using Godot; + using Vector3 = Godot.Vector3; + using World = DefaultEcs.World; + + /// + /// Handles starting pulling in to entities and also expelling + /// things engulfers don't want to eat. Handles the endosome graphics as well. + /// + /// + /// + /// In an optimal ECS design this would be a much more general system, but due to being ported from the old + /// microbe code, this is heavily dependent on microbes being the engulfers. If this was done with a brand new + /// design this code wouldn't be this good to have so many assumptions about the types of engulfers. + /// + /// + [With(typeof(Engulfer))] + [With(typeof(Health))] + [With(typeof(CollisionManagement))] + [With(typeof(MicrobePhysicsExtraData))] + [With(typeof(MicrobeControl))] + [With(typeof(CellProperties))] + [With(typeof(CompoundStorage))] + [With(typeof(SoundEffectPlayer))] + [With(typeof(SpatialInstance))] + [With(typeof(SpeciesMember))] + [WritesToComponent(typeof(Engulfable))] + [WritesToComponent(typeof(AttachedChildren))] + [WritesToComponent(typeof(Physics))] + [ReadsComponent(typeof(CellProperties))] + [ReadsComponent(typeof(SpeciesMember))] + [ReadsComponent(typeof(MicrobeEventCallbacks))] + [ReadsComponent(typeof(PhysicsShapeHolder))] + [RunsAfter(typeof(PilusDamageSystem))] + [RunsAfter(typeof(MicrobeVisualsSystem))] + [RunsOnMainThread] + public sealed class EngulfingSystem : AEntitySetSystem + { +#pragma warning disable CA2213 + private readonly PackedScene endosomeScene; +#pragma warning restore CA2213 + + private readonly IWorldSimulation worldSimulation; + private readonly ISpawnSystem spawnSystem; + + private readonly Compound atp; + + private readonly Random random = new(); + + /// + /// Holds graphics instances. The second level dictionary maps from the engulfed + /// entity to the endosome that is placed on it to visually show it being engulfed. + /// + private readonly Dictionary> entityEngulfingEndosomeGraphics = new(); + + // Temporary variables to handle deleting unused endosome graphics without temporary lists + private readonly List usedTopLevelEngulfers = new(); + private readonly List topLevelEngulfersToDelete = new(); + private readonly List> usedEngulfedObjects = new(); + private readonly List> engulfedObjectsToDelete = new(); + + /// + /// Used to avoid a temporary list allocation + /// + private readonly List tempEntitiesToEject = new(); + + /// + /// Used to keep track of entities that just began to be engulfed. Transport animation and other operations + /// are skipped on these for one update to avoid a problem where the attached component is not created yet. + /// + private readonly HashSet beginningEngulfedObjects = new(); + + /// + /// Cache to re-use bulk transport animation objects + /// + private readonly Queue unusedTransportAnimations = new(); + + // TODO: caching for Endosome scenes (will need to report as intentionally orphaned nodes) + + /// + /// Temporary storage for some expelled object expire time calculations, used to avoid allocating an extra + /// list per update. + /// + private readonly List> tempWorkSpaceForTimeReduction = new(); + + public EngulfingSystem(IWorldSimulation worldSimulation, ISpawnSystem spawnSystem, World world) : + base(world, null) + { + this.worldSimulation = worldSimulation; + this.spawnSystem = spawnSystem; + endosomeScene = GD.Load("res://src/microbe_stage/Endosome.tscn"); + + atp = SimulationParameters.Instance.GetCompound("atp"); + } + + protected override void Update(float delta, in Entity entity) + { + ref var engulfer = ref entity.Get(); + ref var health = ref entity.Get(); + + // Don't process engulfing when dead + if (health.Dead) + { + // TODO: should this wait until death is processed? + + // Need to eject everything + if (engulfer.EngulfedObjects != null) + { + // This sets the list to null to not constantly run this (the if block this is in won't get + // executed anymore) + EjectEverythingFromDeadEngulfer(ref engulfer, entity); + } + + return; + } + + usedTopLevelEngulfers.Add(entity); + + ref var control = ref entity.Get(); + ref var cellProperties = ref entity.Get(); + + var actuallyEngulfing = control.State == MicrobeState.Engulf && cellProperties.MembraneType.CanEngulf; + + if (actuallyEngulfing) + { + // Drain atp + var cost = Constants.ENGULFING_ATP_COST_PER_SECOND * delta; + + var compounds = entity.Get().Compounds; + + // Stop engulfing if out of ATP or if this is an engulfable that has been engulfed + bool engulfed = false; + + if (entity.Has()) + { + engulfed = entity.Get().PhagocytosisStep != PhagocytosisPhase.None; + } + + if (compounds.TakeCompound(atp, cost) < cost - 0.001f || engulfed) + { + control.State = MicrobeState.Normal; + } + else + { + if (entity.Has()) + { + // TODO: fix colony members to be able to engulf things + throw new NotImplementedException(); + } + + CheckStartEngulfing(ref entity.Get(), ref cellProperties, ref engulfer, + entity); + } + } + else + { + if (control.State == MicrobeState.Engulf) + { + // Force out of incorrect state + control.State = MicrobeState.Normal; + } + } + + ref var soundPlayer = ref entity.Get(); + + // Play sound + if (actuallyEngulfing) + { + // To balance loudness, here the engulfment audio's max volume is reduced to 0.6 in linear volume + soundPlayer.PlayGraduallyTurningUpLoopingSound(Constants.MICROBE_ENGULFING_MODE_SOUND, 0.6f, 0, delta); + } + else + { + soundPlayer.PlayGraduallyTurningDownSound(Constants.MICROBE_ENGULFING_MODE_SOUND, delta); + } + + HandleExpiringExpelledObjects(ref engulfer, delta); + + if (engulfer.EngulfedObjects == null) + return; + + // Update animations and move between different states when necessary for all the currently engulfed + // objects + for (int i = engulfer.EngulfedObjects.Count - 1; i >= 0; --i) + { + var engulfedEntity = engulfer.EngulfedObjects![i]; + + if (!engulfedEntity.IsAlive || !engulfedEntity.Has()) + { + // Clear once the object has been fully eaten / deleted. We can't call RemoveEngulfedObject + // as the engulfed object may be invalid already + engulfer.EngulfedObjects.RemoveAt(i); + + continue; + } + + usedEngulfedObjects.Add(new KeyValuePair(entity, engulfedEntity)); + + // Entities that were just engulfed need one extra update to materialize their new components + if (beginningEngulfedObjects.Contains(engulfedEntity)) + continue; + + ref var engulfable = ref engulfedEntity.Get(); + + var transportData = engulfable.BulkTransport; + + if (engulfable.PhagocytosisStep == PhagocytosisPhase.Digested) + { + if (transportData == null) + { + transportData = new Engulfable.BulkTransportAnimation(); + engulfable.BulkTransport = transportData; + } + + if (!engulfedEntity.Has()) + { + GD.PrintErr("Engulfable is in Digested state but it has no attached component"); + engulfer.EngulfedObjects.RemoveAt(i); + } + + var currentEndosomeScale = Vector3.One * Mathf.Epsilon; + var endosome = GetEndosomeIfExists(entity, engulfedEntity); + + if (endosome != null) + currentEndosomeScale = endosome.Scale; + + transportData.TargetValuesToLerp = (null, null, Vector3.One * Mathf.Epsilon); + StartBulkTransport(ref engulfable, engulfedEntity, ref engulfedEntity.Get(), 1.5f, + currentEndosomeScale, false); + } + + // Only handle the animations / state changes when they need updating + if (transportData?.Interpolate != true) + continue; + + if (AnimateBulkTransport(entity, ref engulfable, engulfedEntity, delta)) + { + switch (engulfable.PhagocytosisStep) + { + case PhagocytosisPhase.Ingestion: + CompleteIngestion(entity, ref engulfable, engulfedEntity); + break; + + case PhagocytosisPhase.Digested: + RemoveEngulfedObject(ref engulfer, engulfedEntity, ref engulfable); + worldSimulation.DestroyEntity(engulfedEntity); + break; + + case PhagocytosisPhase.RequestExocytosis: + EjectEngulfable(ref engulfer, ref cellProperties, entity, false, ref engulfable, + engulfedEntity); + break; + + case PhagocytosisPhase.Exocytosis: + { + var endosome = GetEndosomeIfExists(entity, engulfedEntity); + + if (endosome != null) + { + endosome.Hide(); + DeleteEndosome(endosome); + } + + transportData.TargetValuesToLerp = (null, transportData.OriginalScale, null); + StartBulkTransport(ref engulfable, engulfedEntity, + ref engulfedEntity.Get(), 1.0f, + Vector3.One); + engulfable.PhagocytosisStep = PhagocytosisPhase.Ejection; + break; + } + + case PhagocytosisPhase.Ejection: + CompleteEjection(ref engulfer, entity, ref engulfable, engulfedEntity); + break; + } + } + } + + var colour = cellProperties.Colour; + SetPhagosomeColours(entity, colour); + } + + protected override void PostUpdate(float state) + { + base.PostUpdate(state); + + beginningEngulfedObjects.Clear(); + + // Delete unused endosome graphics. First mark unused things + foreach (var entry in entityEngulfingEndosomeGraphics) + { + if (!usedTopLevelEngulfers.Contains(entry.Key)) + { + topLevelEngulfersToDelete.Add(entry.Key); + continue; + } + + foreach (var childEntry in entry.Value) + { + var key = new KeyValuePair(entry.Key, childEntry.Key); + if (!usedEngulfedObjects.Contains(key)) + { + engulfedObjectsToDelete.Add(key); + } + } + } + + usedTopLevelEngulfers.Clear(); + usedEngulfedObjects.Clear(); + + // Then delete them + foreach (var toDelete in topLevelEngulfersToDelete) + { + // Delete this entire top level entry + var data = entityEngulfingEndosomeGraphics[toDelete]; + + foreach (var endosome in data.Values) + { + DeleteEndosome(endosome); + } + + entityEngulfingEndosomeGraphics.Remove(toDelete); + } + + // Single child object deletions instead of top level deletions + foreach (var toDelete in engulfedObjectsToDelete) + { + var container = entityEngulfingEndosomeGraphics[toDelete.Key]; + + if (container.TryGetValue(toDelete.Value, out var endosome)) + { + DeleteEndosome(endosome); + } + else + { + GD.PrintErr("Failed to get endosome to delete"); + } + } + + topLevelEngulfersToDelete.Clear(); + engulfedObjectsToDelete.Clear(); + } + + private Endosome CreateEndosome(in Entity entity, ref SpatialInstance endosomeParent, in Entity engulfedObject, + int engulfedMaxRenderPriority) + { + if (endosomeParent.GraphicalInstance == null) + throw new InvalidOperationException("Endosome parent SpatialInstance has no graphics"); + + if (!entityEngulfingEndosomeGraphics.TryGetValue(entity, out var dataContainer)) + { + dataContainer = new Dictionary(); + + entityEngulfingEndosomeGraphics[entity] = dataContainer; + } + + if (dataContainer.TryGetValue(engulfedObject, out var endosome)) + return endosome; + + // New entry needed + var newData = endosomeScene.Instance(); + + // Tint is not applied here as all phagosome tints are applied always after processing an engulfer + + newData.RenderPriority = engulfedMaxRenderPriority + dataContainer.Count + 1; + + endosomeParent.GraphicalInstance.AddChild(newData); + + dataContainer[engulfedObject] = newData; + return newData; + } + + private Endosome? GetEndosomeIfExists(in Entity entity, in Entity engulfedObject) + { + if (!entityEngulfingEndosomeGraphics.TryGetValue(entity, out var dataContainer)) + return null; + + if (dataContainer.TryGetValue(engulfedObject, out var endosome)) + return endosome; + + return null; + } + + private void EjectEverythingFromDeadEngulfer(ref Engulfer engulfer, in Entity entity) + { + if (engulfer.EngulfedObjects == null) + return; + + // A copy of the list is needed as in some situations EjectEngulfable immediately removes an object + // and modifies the engulfed list + tempEntitiesToEject.AddRange(engulfer.EngulfedObjects); + + ref var cellProperties = ref entity.Get(); + + foreach (var engulfedObject in tempEntitiesToEject) + { + // In case here, the engulfer being dead, we check to make sure the engulfed objects aren't incorrect + if (!engulfedObject.IsAlive || !engulfedObject.Has()) + { + GD.PrintErr("Ejecting everything from a dead engulfable encountered a destroyed engulfed entity"); + continue; + } + + EjectEngulfable(ref engulfer, ref cellProperties, entity, true, ref engulfedObject.Get(), + engulfedObject); + } + + tempEntitiesToEject.Clear(); + + // Should be fine to clear this list object like this as a dead entity should get deleted entirely + // soon + engulfer.EngulfedObjects = null; + } + + private void CheckStartEngulfing(ref CollisionManagement collisionManagement, ref CellProperties cellProperties, + ref Engulfer engulfer, in Entity entity) + { + ref var ourExtraData = ref entity.Get(); + + var count = collisionManagement.GetActiveCollisions(out var collisions); + + if (count < 1) + return; + + ref var species = ref entity.Get(); + + for (int i = 0; i < count; ++i) + { + ref var collision = ref collisions![i]; + + if (!collision.SecondEntity.Has()) + continue; + + // Can't engulf through our pilus + if (ourExtraData.IsSubShapePilus(collision.FirstSubShapeData)) + continue; + + // Also can't engulf when the other physics body has a pilus + if (collision.SecondEntity.Has() && collision.SecondEntity + .Get().IsSubShapePilus(collision.SecondSubShapeData)) + { + continue; + } + + // Can't engulf dead things + if (collision.SecondEntity.Has() && collision.SecondEntity.Get().Dead) + continue; + + // Pili don't block engulfing, check starting engulfing + if (CheckStartEngulfingOnCandidate(ref cellProperties, ref engulfer, ref species, in entity, + collision.SecondEntity)) + { + // Engulf at most one thing per update, if the collision still exist next update we'll pull it in + // then + return; + } + } + } + + /// + /// This checks if we can start engulfing + /// + /// True if something started to be engulfed + private bool CheckStartEngulfingOnCandidate(ref CellProperties cellProperties, + ref Engulfer engulfer, ref SpeciesMember speciesMember, in Entity entity, in Entity engulfable) + { + var engulfCheckResult = cellProperties.CanEngulfObject(ref speciesMember, ref engulfer, engulfable); + + if (!engulfable.Has()) + { + GD.PrintErr("Cannot start engulfing entity that passed engulf check as it is missing " + + "engulfable component"); + return false; + } + + if (engulfCheckResult == EngulfCheckResult.Ok) + { + // TODO: add some way for this to detect delay added components so that this can't conflict with the + // binding system + lock (AttachedToEntityHelpers.EntityAttachRelationshipModifyLock) + { + return IngestEngulfable(ref engulfer, ref cellProperties, entity, ref engulfable.Get(), + engulfable); + } + } + + if (engulfCheckResult == EngulfCheckResult.IngestedMatterFull) + { + if (entity.Has()) + { + ref var callbacks = ref entity.Get(); + + callbacks.OnEngulfmentStorageFull?.Invoke(entity); + + entity.SendNoticeIfPossible(() => + new SimpleHUDMessage(TranslationServer.Translate("NOTICE_ENGULF_STORAGE_FULL"))); + } + } + else if (engulfCheckResult == EngulfCheckResult.TargetTooBig) + { + if (entity.Has()) + { + entity.SendNoticeIfPossible(() => + new SimpleHUDMessage(TranslationServer.Translate("NOTICE_ENGULF_SIZE_TOO_SMALL"))); + } + } + + return false; + } + + /// + /// Attempts to engulf the given target into the cytoplasm. Does not check whether the target + /// can be engulfed or not (as that check should be done already). + /// + /// + /// + /// This can be called from a different entity for another entity to engulf something. For example in the + /// case where an entity that has engulfed another is itself engulfed, in that case anything the first + /// engulfer ejects will get ingested by the other engulfer automatically. + /// + /// + private bool IngestEngulfable(ref Engulfer engulfer, ref CellProperties engulferCellProperties, + in Entity engulferEntity, ref Engulfable engulfable, in Entity targetEntity, float animationSpeed = 2.0f) + { + // Can't ingest before our membrane and the target membrane are ready (if target is a microbe) + if (engulferCellProperties.CreatedMembrane == null) + return false; + + if (!targetEntity.Has()) + { + GD.PrintErr("Only entities with spatial instance can be engulfed"); + return false; + } + + ref var targetSpatial = ref targetEntity.Get(); + + // TODO: should this wait until target graphics are initialized? + // if (targetSpatial.GraphicalInstance == null) + + float targetRadius = 1; + + if (targetEntity.Has()) + { + ref var targetCellProperties = ref targetEntity.Get(); + + // Skip for now if target membrane is not ready + if (targetCellProperties.CreatedMembrane == null) + return false; + + targetRadius = targetCellProperties.CreatedMembrane.EncompassingCircleRadius; + + if (targetCellProperties.IsBacteria) + targetRadius *= 0.5f; + } + else if (targetEntity.Has()) + { + targetRadius = targetEntity.Get().Radius; + } + else + { + GD.PrintErr("Unknown radius of engulfed object, won't know how far in it needs to be pulled"); + } + + if (engulfable.PhagocytosisStep != PhagocytosisPhase.None) + { + GD.Print("Tried to ingest something that is already target of a phagocytosis process"); + return false; + } + + float radius = engulferCellProperties.CreatedMembrane.EncompassingCircleRadius; + + if (engulferCellProperties.IsBacteria) + radius *= 0.5f; + + ref var targetEntityPosition = ref targetEntity.Get(); + ref var engulferPosition = ref engulferEntity.Get(); + + engulfable.HostileEngulfer = engulferEntity; + engulfable.PhagocytosisStep = PhagocytosisPhase.Ingestion; + + engulfer.EngulfedObjects ??= new List(); + engulfer.EngulfedObjects.Add(targetEntity); + + // Update used engulfing space, this will be re-calculated by the digestion system (as used space changes + // as digestion progresses) + engulfer.UsedIngestionCapacity += engulfable.AdjustedEngulfSize; + + CalculateAdditionalCompoundsInNewlyEngulfedObject(ref engulfable, targetEntity); + + if (targetEntity.Has()) + { + // Steal this cell from a colony if it is in a colony currently + + // TODO: implement eating members from colonies + throw new NotImplementedException(); + + // Colony?.RemoveFromColony(targetEntity); + } + + // Below is for figuring out where to place the object attempted to be engulfed inside the cytoplasm, + // calculated accordingly to hopefully minimize any part of the object sticking out the membrane. + // Note: extremely long and thin objects might still stick out + + var targetRadiusNormalized = Mathf.Clamp(targetRadius / radius, 0.0f, 1.0f); + + var relativePosition = targetEntityPosition.Position - engulferPosition.Position; + var rotatedRelativeVector = engulferPosition.Rotation.Xform(relativePosition); + + var nearestPointOfMembraneToTarget = + engulferCellProperties.CreatedMembrane.GetVectorTowardsNearestPointOfMembrane( + rotatedRelativeVector.x, rotatedRelativeVector.z); + + // The point nearest to the membrane calculation doesn't take being bacteria into account + if (engulferCellProperties.IsBacteria) + nearestPointOfMembraneToTarget *= 0.5f; + + // From the calculated nearest point of membrane above we then linearly interpolate it by the engulfed's + // normalized radius to this cell's center in order to "shrink" the point relative to this cell's origin. + // This will then act as a "maximum extent/edge" that qualifies as the interior of the engulfer's membrane + var viableStoringAreaEdge = nearestPointOfMembraneToTarget.LinearInterpolate( + Vector3.Zero, targetRadiusNormalized); + + // Get the final storing position by taking a value between this cell's center and the storing area edge. + // This would lessen the possibility of engulfed things getting bunched up in the same position. + var ingestionPoint = new Vector3( + random.Next(0.0f, viableStoringAreaEdge.x), + engulferPosition.Position.y, + random.Next(0.0f, viableStoringAreaEdge.z)); + + var boundingBoxSize = Vector3.One; + + if (targetSpatial.GraphicalInstance is GeometryInstance geometryInstance) + { + boundingBoxSize = geometryInstance.GetAabb().Size; + } + else + { + GD.PrintErr("Engulfed something that couldn't have AABB calculated (graphical instance: ", + targetSpatial.GraphicalInstance, ")"); + } + + // In the case of flat mesh (like membrane) we don't want the endosome to end up completely flat + // as it can cause unwanted visual glitch + if (boundingBoxSize.y < Mathf.Epsilon) + boundingBoxSize = new Vector3(boundingBoxSize.x, 0.1f, boundingBoxSize.z); + + var originalScale = Vector3.One; + + if (targetSpatial.ApplyVisualScale) + originalScale = targetSpatial.VisualScale; + + // Phagosome is now created when needed to be updated by the transport method instead of here immediately + + var bulkTransport = unusedTransportAnimations.Count > 0 ? + unusedTransportAnimations.Dequeue() : + new Engulfable.BulkTransportAnimation(); + + bulkTransport.TargetValuesToLerp = (ingestionPoint, originalScale / 2, boundingBoxSize); + bulkTransport.OriginalScale = originalScale; + + // TODO: store original render priority? + // bulkTransport.OriginalRenderPriority = target.RenderPriority, + + engulfable.BulkTransport = bulkTransport; + + // Disable physics for the engulfed entity + ref var physics = ref targetEntity.Get(); + physics.BodyDisabled = true; + + // TODO check what the initial scale of the endosome should be? + var initialEndosomeScale = Vector3.One * Mathf.Epsilon; + + // If the other body is already attached this needs to handle that correctly + if (targetEntity.Has()) + { + ref var attached = ref targetEntity.Get(); + attached.AttachedTo = engulferEntity; + attached.RelativePosition = relativePosition; + attached.RelativeRotation = targetEntityPosition.Rotation.Inverse(); + + StartBulkTransport(ref engulfable, targetEntity, ref attached, animationSpeed, initialEndosomeScale); + } + else + { + var recorder = worldSimulation.StartRecordingEntityCommands(); + + var targetRecord = recorder.Record(targetEntity); + + var attached = new AttachedToEntity(engulferEntity, relativePosition, + targetEntityPosition.Rotation.Inverse()); + + StartBulkTransport(ref engulfable, targetEntity, ref attached, animationSpeed, initialEndosomeScale); + + targetRecord.Set(attached); + + worldSimulation.FinishRecordingEntityCommands(recorder); + } + + // TODO: render priority + // We want the ingested material to be always visible over the organelles + // target.RenderPriority += OrganelleMaxRenderPriority + 1; + + engulfable.OnBecomeEngulfed(targetEntity); + + // Skip updating this engulfable during this update as the attached component will only be created when + // the command recorder is executed. And for consistency in the case that the component existed we still + // do this as there should be no harm in this delay. + beginningEngulfedObjects.Add(targetEntity); + + return true; + } + + private void CompleteIngestion(in Entity entity, ref Engulfable engulfable, in Entity engulfedObject) + { + engulfable.PhagocytosisStep = PhagocytosisPhase.Ingested; + + if (entity.Has()) + { + ref var callbacks = ref entity.Get(); + + callbacks.OnSuccessfulEngulfment?.Invoke(entity, engulfedObject); + } + + // There used to be an ingest callback like for the ejection but it didn't end up having any code in it + // so it is now removed. Just the event callback above is left. + } + + /// + /// Expels an ingested object from this microbe out into the environment. + /// + /// + /// + /// Doesn't set to null even if empty + /// + /// + private void EjectEngulfable(ref Engulfer engulfer, ref CellProperties engulferCellProperties, in Entity entity, + bool engulferDead, ref Engulfable engulfable, in Entity engulfedObject, float animationSpeed = 2.0f) + { + // If entity itself is engulfed, then it can't expel things. Except when dead as that overrides things + if (entity.Has() && entity.Get().PhagocytosisStep != PhagocytosisPhase.None && + !engulferDead) + { + return; + } + + // TODO: being dead should probably override the following two if checks + // Need to skip until the engulfer's membrane is ready + if (engulferCellProperties.CreatedMembrane == null) + return; + + if (engulfable.PhagocytosisStep is PhagocytosisPhase.Exocytosis or PhagocytosisPhase.None + or PhagocytosisPhase.Ejection) + { + return; + } + + if (engulfer.EngulfedObjects == null) + return; + + if (!engulfer.EngulfedObjects.Contains(engulfedObject)) + return; + + engulfable.PhagocytosisStep = PhagocytosisPhase.Exocytosis; + + // The back of the microbe + var exit = Hex.AxialToCartesian(new Hex(0, 1)); + var nearestPointOfMembraneToTarget = + engulferCellProperties.CreatedMembrane.GetVectorTowardsNearestPointOfMembrane(exit.x, exit.z); + + // The point nearest to the membrane calculation doesn't take being bacteria into account + if (engulferCellProperties.IsBacteria) + nearestPointOfMembraneToTarget *= 0.5f; + + var animation = engulfable.BulkTransport; + + // If the animation is missing then for simplicity we just eject immediately or if the attached to + // component is missing even though it should be always there + + if (animation == null || engulfedObject.Has()) + { + GD.Print("Immediately ejecting engulfable that has no animation properties (or missing " + + "attached component)"); + + CompleteEjection(ref engulfer, entity, ref engulfable, engulfedObject); + +#if DEBUG + if (engulfer.EngulfedObjects?.Contains(engulfedObject) == true) + { + throw new Exception("Complete ejection didn't remove engulfed object from list"); + } +#endif + + return; + } + + ref var attached = ref engulfedObject.Get(); + + var relativePosition = attached.RelativePosition; + + // If engulfer cell is dead (us) or the engulfed is positioned outside any of our closest membrane, + // immediately eject it without animation. + // TODO: Asses performance cost in massive cells (of the membrane Contains)? + if (engulferDead || + !engulferCellProperties.CreatedMembrane.Contains(relativePosition.x, relativePosition.z)) + { + CompleteEjection(ref engulfer, entity, ref engulfable, engulfedObject); + +#if DEBUG + if (engulfer.EngulfedObjects?.Contains(engulfedObject) == true) + { + throw new Exception("Complete ejection didn't remove engulfed object from list"); + } +#endif + return; + } + + // Animate object move to the nearest point of the membrane + var targetEndosomeScale = Vector3.One * Mathf.Epsilon; + + var currentEndosomeScale = targetEndosomeScale; + + var endosome = GetEndosomeIfExists(entity, engulfedObject); + + if (endosome != null) + { + currentEndosomeScale = endosome.Scale; + } + + animation.TargetValuesToLerp = (nearestPointOfMembraneToTarget, null, targetEndosomeScale); + StartBulkTransport(ref engulfable, engulfedObject, ref attached, animationSpeed, currentEndosomeScale); + + // The rest of the operation is done in CompleteEjection + } + + private void CompleteEjection(ref Engulfer engulfer, in Entity entity, ref Engulfable engulfable, + in Entity engulfableObject) + { + if (engulfer.EngulfedObjects == null) + { + throw new InvalidOperationException( + "Engulfer trying to eject something when it doesn't even have engulfed objects list"); + } + + engulfer.ExpelledObjects ??= new Dictionary(); + + // Mark the object as recently expelled (0 seconds since ejection) + engulfer.ExpelledObjects[engulfableObject] = 0; + + Vector3 relativePosition = Vector3.Forward; + + // This lock is a bit useless but for symmetry on start this is also used here on eject + lock (AttachedToEntityHelpers.EntityAttachRelationshipModifyLock) + { + if (!engulfableObject.Has()) + { + GD.PrintErr("Ejected entity that has no attached component"); + } + else + { + relativePosition = engulfableObject.Get().RelativePosition; + } + + var recorder = worldSimulation.StartRecordingEntityCommands(); + + var recorderEntity = recorder.Record(engulfableObject); + + // Stop this entity being attached to us + recorderEntity.Remove(); + + worldSimulation.FinishRecordingEntityCommands(recorder); + } + + // Try to get velocity of the engulfer for ejection impulse strength calculation + var engulferVelocity = Vector3.Zero; + + // This failing is not critical as a stationary non-physics based engulfer could make sense, in which case + // the engulfer's velocity being assumed to be 0 is entirely correct + if (entity.Has()) + { + ref var engulferPhysics = ref entity.Get(); + + if (engulferPhysics.TrackVelocity) + { + engulferVelocity = engulferPhysics.Velocity; + } + else + { + GD.PrintErr("Engulfer doesn't track velocity, can't apply correct ejection impulse"); + } + } + + // Try to get mass for ejection impulse strength calculation + float mass = 1000; + if (engulfableObject.Has()) + { + ref var shape = ref engulfableObject.Get(); + + if (shape.Shape != null) + { + mass = shape.Shape.GetMass(); + } + else + { + GD.PrintErr("Expelled engulfed object doesn't have physics shape initialized, " + + "ejection impulse won't be correctly calculated"); + } + } + else + { + GD.PrintErr("Engulfed object doesn't have shape component, can't know mass for ejection impulse"); + } + + // Re-enable physics + ref var physics = ref engulfableObject.Get(); + physics.BodyDisabled = false; + + ref var engulferPosition = ref entity.Get(); + + // And give an impulse + // TODO: check is it correct to rotate by the rotation here on the relative position for this force + var impulse = engulferPosition.Rotation.Xform(relativePosition) * mass * Constants.ENGULF_EJECTION_FORCE; + + // Apply outwards ejection force + ref var manualPhysicsControl = ref engulfableObject.Get(); + manualPhysicsControl.ImpulseToGive += impulse + engulferVelocity; + manualPhysicsControl.PhysicsApplied = false; + + var animation = engulfable.BulkTransport; + + // For now assume that if the animation is missing then no property modifications were done, so this is + // perfectly fine to skip + if (animation != null) + { + // Reset render priority + // TODO: render priority + // engulfable.RenderPriority = animation.OriginalRenderPriority; + + // Restore scale + if (engulfableObject.Has()) + { + ref var spatial = ref engulfableObject.Get(); + spatial.VisualScale = animation.OriginalScale; + +#if DEBUG + if (animation.OriginalScale.Length() < MathUtils.EPSILON) + { + GD.PrintErr("Ejected engulfable with zero original scale"); + } +#endif + } + } + + // Reset engulfable state after the ejection (but before RemoveEngulfedObject to allow this to still see + // the hostile engulfer entity) + engulfable.OnExpelledFromEngulfment(engulfableObject, spawnSystem, worldSimulation); + + RemoveEngulfedObject(ref engulfer, engulfableObject, ref engulfable); + + // The phagosome will be deleted automatically, we just hide it here to make it disappear on the same frame + // as the ejection completes + var phagosome = GetEndosomeIfExists(entity, engulfableObject); + + phagosome?.Hide(); + + if (entity.Has()) + { + ref var engulfersEngulfable = ref entity.Get(); + + if (engulfersEngulfable.PhagocytosisStep != PhagocytosisPhase.None) + { + if (!engulfersEngulfable.HostileEngulfer.IsAlive || + !engulfersEngulfable.HostileEngulfer.Has()) + { + GD.PrintErr("Attempt to pass ejected object to our engulfer failed because that " + + "engulfer is not alive"); + return; + } + + // Skip sending to the hostile engulfer if it is dead + if (engulfersEngulfable.HostileEngulfer.Has() && + engulfersEngulfable.HostileEngulfer.Get().Dead) + { + GD.Print("Not sending engulfable to our engulfer as that is dead"); + return; + } + + ref var hostileEngulfer = ref engulfersEngulfable.HostileEngulfer.Get(); + + // We have our own engulfer and it wants to claim this object we've just expelled + if (!IngestEngulfable(ref hostileEngulfer, + ref engulfersEngulfable.HostileEngulfer.Get(), + engulfersEngulfable.HostileEngulfer, ref engulfable, + engulfableObject)) + { + GD.PrintErr("Failed to pass ejected object from an engulfed object to its engulfer"); + } + } + } + } + + /// + /// Removes an engulfed object from the data lists in an engulfer and detaches the animation state. + /// Doesn't do any ejection actions. This is purely for once data needs to be removed once it is safe to do + /// so. + /// + private void RemoveEngulfedObject(ref Engulfer engulfer, Entity engulfedEntity, ref Engulfable engulfable) + { + if (engulfer.EngulfedObjects == null) + throw new InvalidOperationException("Engulfed objects should not be null when this is called"); + + if (!engulfer.EngulfedObjects.Remove(engulfedEntity)) + { + GD.PrintErr("Failed to remove engulfed object from engulfer's list of engulfed objects"); + } + + var transport = engulfable.BulkTransport; + if (transport != null) + { + transport.Interpolate = false; + unusedTransportAnimations.Enqueue(transport); + engulfable.BulkTransport = null; + } + + engulfable.PhagocytosisStep = PhagocytosisPhase.None; + engulfable.HostileEngulfer = default; + + // Thanks to digestion decreasing the size of engulfed objects, this doesn't match what we took in + // originally. This relies on teh digestion system updating this later to make sure this is correct + engulfer.UsedIngestionCapacity = + Math.Max(0, engulfer.UsedIngestionCapacity - engulfable.AdjustedEngulfSize); + } + + /// + /// Begins phagocytosis related lerp animation. Note that + /// must be set before calling this. + /// + private void StartBulkTransport(ref Engulfable engulfable, in Entity engulfedObject, + ref AttachedToEntity initialRelativePositionInfo, float duration, + Vector3 currentEndosomeScale, bool resetElapsedTime = true) + { + var transportData = engulfable.BulkTransport; + + // Only need to recreate the animation data when one doesn't exist, we can reuse existing data in other + // cases + if (transportData == null) + { + transportData = new Engulfable.BulkTransportAnimation(); + engulfable.BulkTransport = transportData; + + // TODO: this is kind of bad to assume the scale is right like this + transportData.OriginalScale = Vector3.One; + GD.PrintErr("New backup engulf animation data was created, this should be avoided " + + "(data should be created before bulk transport starts)"); + } + + if (resetElapsedTime) + transportData.AnimationTimeElapsed = 0; + + Vector3 scale = Vector3.One; + + ref var spatial = ref engulfedObject.Get(); + + if (spatial.ApplyVisualScale) + scale = spatial.VisualScale; + + transportData.InitialValuesToLerp = + (initialRelativePositionInfo.RelativePosition, scale, currentEndosomeScale); + transportData.LerpDuration = duration; + transportData.Interpolate = true; + } + + /// + /// Stops phagocytosis related lerp animation + /// + private void StopBulkTransport(Engulfable.BulkTransportAnimation animation) + { + // This tells the animation to not run anymore + animation.Interpolate = false; + + animation.AnimationTimeElapsed = 0; + } + + /// + /// Animates transporting objects from phagocytosis process with linear interpolation. + /// + /// True when Lerp finishes. + private bool AnimateBulkTransport(in Entity entity, ref Engulfable engulfable, in Entity engulfedObject, + float delta) + { + ref var spatial = ref entity.Get(); + + if (spatial.GraphicalInstance == null) + { + // Can't create phagosome until spatial instance is created. Returning false here will retry the bulk + // transport animation each update. + return false; + } + + var animation = engulfable.BulkTransport; + + if (animation == null) + { + // Some code didn't initialize the animation data + GD.PrintErr($"{nameof(AnimateBulkTransport)} cannot run because bulk animation data is null"); + return true; + } + + // Safety check in case the animation started too soon (component not created yet) + if (!engulfedObject.Has()) + { + GD.PrintErr("Engulfed object doesn't have attached to component set when doing bulk animation"); + return false; + } + + var phagosome = GetEndosomeIfExists(entity, engulfedObject); + + if (phagosome == null) + { + // TODO: if state is ejecting then phagosome creation should be skipped to save creating an object that + // will be deleted in a few frames anyway + + // TODO: render priority calculated properly + int maxRenderPriority = Constants.HEX_RENDER_PRIORITY_DISTANCE + 1; + + // Form phagosome as it is missing + phagosome = CreateEndosome(entity, ref spatial, engulfedObject, maxRenderPriority); + } + + ref var relativePosition = ref engulfedObject.Get(); + + if (animation.AnimationTimeElapsed < animation.LerpDuration) + { + animation.AnimationTimeElapsed += delta; + + var fraction = animation.AnimationTimeElapsed / animation.LerpDuration; + + // Ease out + fraction = Mathf.Sin(fraction * Mathf.Pi * 0.5f); + + if (animation.TargetValuesToLerp.Translation.HasValue) + { + relativePosition.RelativePosition = animation.InitialValuesToLerp.Translation.LinearInterpolate( + animation.TargetValuesToLerp.Translation.Value, fraction); + } + + if (animation.TargetValuesToLerp.Scale.HasValue) + { + spatial.VisualScale = animation.InitialValuesToLerp.Scale.LinearInterpolate( + animation.TargetValuesToLerp.Scale.Value, fraction); + spatial.ApplyVisualScale = true; + } + + if (animation.TargetValuesToLerp.EndosomeScale.HasValue) + { + phagosome.Scale = animation.InitialValuesToLerp.EndosomeScale.LinearInterpolate( + animation.TargetValuesToLerp.EndosomeScale.Value, fraction); + } + + return false; + } + + // Snap values + if (animation.TargetValuesToLerp.Translation.HasValue) + relativePosition.RelativePosition = animation.TargetValuesToLerp.Translation.Value; + + if (animation.TargetValuesToLerp.Scale.HasValue) + { + spatial.VisualScale = animation.TargetValuesToLerp.Scale.Value; + spatial.ApplyVisualScale = true; + } + + if (animation.TargetValuesToLerp.EndosomeScale.HasValue) + phagosome.Scale = animation.TargetValuesToLerp.EndosomeScale.Value; + + StopBulkTransport(animation); + + return true; + } + + private void HandleExpiringExpelledObjects(ref Engulfer engulfer, float delta) + { + if (engulfer.ExpelledObjects == null) + return; + + foreach (var expelled in engulfer.ExpelledObjects) + { + tempWorkSpaceForTimeReduction.Add(new KeyValuePair(expelled.Key, + expelled.Value + delta)); + } + + foreach (var pair in tempWorkSpaceForTimeReduction) + { + if (pair.Value >= Constants.ENGULF_EJECTED_COOLDOWN) + { + engulfer.ExpelledObjects.Remove(pair.Key); + } + else + { + engulfer.ExpelledObjects[pair.Key] = pair.Value; + } + } + + tempWorkSpaceForTimeReduction.Clear(); + } + + private void SetPhagosomeColours(in Entity entity, Color colour) + { + if (!entityEngulfingEndosomeGraphics.TryGetValue(entity, out var endosomes)) + return; + + foreach (var endosomeEntry in endosomes) + { + endosomeEntry.Value.Tint = colour; + } + } + + private void DeleteEndosome(Endosome endosome) + { + try + { + if (endosome.IsQueuedForDeletion()) + return; + + endosome.QueueFree(); + } + catch (ObjectDisposedException) + { + // This can probably happen when the engulfed entity's visual instance has already been destroyed and + // that resulted in the endosome graphics node to be deleted as it is parented there + + GD.Print("Endosome was already disposed"); + + // If caching is added already destroyed endosomes have to be skipped here + // return; + } + + // TODO: caching for endosomes + } + + private void CalculateAdditionalCompoundsInNewlyEngulfedObject(ref Engulfable engulfable, + in Entity engulfableEntity) + { + engulfable.AdditionalEngulfableCompounds = + engulfable.CalculateAdditionalDigestibleCompounds(engulfableEntity); + + engulfable.InitialTotalEngulfableCompounds = engulfableEntity.Get().Compounds + .Where(c => c.Key.Digestible) + .Sum(c => c.Value); + + if (engulfable.AdditionalEngulfableCompounds != null) + { + engulfable.InitialTotalEngulfableCompounds += + engulfable.AdditionalEngulfableCompounds.Sum(c => c.Value); + } + } + } +} diff --git a/src/microbe_stage/systems/EntitySignalingSystem.cs b/src/microbe_stage/systems/EntitySignalingSystem.cs new file mode 100644 index 00000000000..ab1e918c8fb --- /dev/null +++ b/src/microbe_stage/systems/EntitySignalingSystem.cs @@ -0,0 +1,167 @@ +namespace Systems +{ + using System; + using System.Collections.Generic; + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + using Godot; + using Newtonsoft.Json; + using World = DefaultEcs.World; + + /// + /// Updates components that also have + /// + [With(typeof(CommandSignaler))] + [With(typeof(WorldPosition))] + public sealed class EntitySignalingSystem : AEntitySetSystem + { + private readonly Dictionary> entitiesOnChannels = new(); + + [JsonProperty] + private float elapsedSinceUpdate; + + private bool timeToUpdate; + + public EntitySignalingSystem(World world, IParallelRunner runner) : base(world, runner) + { + } + + protected override void PreUpdate(float state) + { + base.PreUpdate(state); + + elapsedSinceUpdate += state; + + if (elapsedSinceUpdate > Constants.ENTITY_SIGNAL_UPDATE_INTERVAL) + { + elapsedSinceUpdate = 0; + timeToUpdate = true; + } + else + { + timeToUpdate = false; + } + + if (!timeToUpdate) + return; + + // Clear old signaling cache (and delete any cache categories that aren't in use anymore) + foreach (var entry in entitiesOnChannels) + { + // Skip non-empty channels + if (entry.Value.Count > 0) + continue; + + entitiesOnChannels.Remove(entry.Key); + + // It should be fine to just delete up to one category per system run as we shouldn't have that many + // categories being abandoned multiple times per second. Though if we end up with many signalers + // turning off and on often then we might not see the actually abandoned channels quickly. + // This is done to avoid having to take a clone of the dictionary keys + break; + } + + // Clear still left categories + foreach (var value in entitiesOnChannels.Values) + { + value.Clear(); + } + + // Update the queued commands to active commands first in a non-multithreaded way + // TODO: this could also be multithreaded as long as this finishes before the Update calls start running + // as long as the channel cache updating can be fast enough + foreach (ref readonly var entity in Set.GetEntities()) + { + ref var signaling = ref entity.Get(); + + if (signaling.QueuedSignalingCommand != null) + { + signaling.Command = signaling.QueuedSignalingCommand.Value; + signaling.QueuedSignalingCommand = null; + } + + // Build a mapping of signalers by their channel and position to speed up the update logic below + if (signaling.Command == MicrobeSignalCommand.None) + continue; + + if (!entitiesOnChannels.TryGetValue(signaling.SignalingChannel, out var channel)) + { + channel = new List<(Entity Entity, Vector3 Position)>(); + + entitiesOnChannels[signaling.SignalingChannel] = channel; + } + + ref var position = ref entity.Get(); + + // TODO: determine if it is faster to copy the position here rather than continuously looking up + // the position again in Update when comparing positions to signal receivers + channel.Add((entity, position.Position)); + } + } + + protected override void Update(float delta, ReadOnlySpan entities) + { + if (!timeToUpdate) + return; + + base.Update(delta, entities); + } + + protected override void Update(float delta, in Entity entity) + { + ref var signaling = ref entity.Get(); + + // Find closest signaler on the channel this entity is on + bool foundSignal = false; + + if (entitiesOnChannels.TryGetValue(signaling.SignalingChannel, out var signalers)) + { + ref var position = ref entity.Get(); + + // We kind of simulate how strong the "smell" of a signal is by finding the closest active signal + (Entity Entity, Vector3 Position)? bestSignaler = null; + float minDistanceFound = float.MaxValue; + + // In the old microbe AI implementation this actually used the last smelled position to calculate a new + // min distance, which could result in different kind of "pinning" behaviour of previous commands. That + // is now gone as this does a fresh look each time. + + foreach (var signaler in signalers) + { + var distance = position.Position.DistanceSquaredTo(signaler.Position); + if (distance < minDistanceFound) + { + // Ignore our own signals + if (signaler.Entity == entity) + continue; + + minDistanceFound = distance; + + bestSignaler = signaler; + } + } + + if (bestSignaler != null) + { + // TODO: should there be a max distance after which the signaling agent is considered to be so + // weak that it is not detected? + + signaling.ReceivedCommandSource = bestSignaler.Value.Position; + signaling.ReceivedCommandFromEntity = bestSignaler.Value.Entity; + + ref var signalerData = ref bestSignaler.Value.Entity.Get(); + signaling.ReceivedCommand = signalerData.Command; + + foundSignal = true; + } + } + + if (!foundSignal) + { + signaling.ReceivedCommand = MicrobeSignalCommand.None; + } + } + } +} diff --git a/src/microbe_stage/systems/FluidCurrentsSystem.cs b/src/microbe_stage/systems/FluidCurrentsSystem.cs new file mode 100644 index 00000000000..5674fd02262 --- /dev/null +++ b/src/microbe_stage/systems/FluidCurrentsSystem.cs @@ -0,0 +1,102 @@ +namespace Systems +{ + using System; + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + using Godot; + using Newtonsoft.Json; + using World = DefaultEcs.World; + + /// + /// Gives a push from currents in a fluid to physics entities (that have ). + /// Only acts on entities marked with . + /// + [With(typeof(CurrentAffected))] + [With(typeof(Physics))] + [With(typeof(ManualPhysicsControl))] + [With(typeof(WorldPosition))] + public sealed class FluidCurrentsSystem : AEntitySetSystem + { + private const float DISTURBANCE_TIMESCALE = 0.001f; + private const float CURRENTS_TIMESCALE = 0.001f / 500.0f; + private const float CURRENTS_STRETCHING_MULTIPLIER = 1.0f / 10.0f; + private const float MIN_CURRENT_INTENSITY = 0.4f; + private const float DISTURBANCE_TO_CURRENTS_RATIO = 0.15f; + private const float POSITION_SCALING = 0.9f; + + private readonly FastNoiseLite noiseDisturbancesX; + private readonly FastNoiseLite noiseDisturbancesY; + private readonly FastNoiseLite noiseCurrentsX; + private readonly FastNoiseLite noiseCurrentsY; + + // private readonly Vector2 scale = new Vector2(0.05f, 0.05f); + + // TODO: verify that this can be loaded / saved + [JsonProperty] + private float millisecondsPassed; + + public FluidCurrentsSystem(World world, IParallelRunner runner) : base(world, runner) + { + noiseDisturbancesX = new FastNoiseLite(69); + noiseDisturbancesX.SetNoiseType(FastNoiseLite.NoiseType.Perlin); + + noiseDisturbancesY = new FastNoiseLite(13); + noiseDisturbancesY.SetNoiseType(FastNoiseLite.NoiseType.Perlin); + + noiseCurrentsX = new FastNoiseLite(420); + noiseCurrentsX.SetNoiseType(FastNoiseLite.NoiseType.Perlin); + + noiseCurrentsY = new FastNoiseLite(1337); + noiseCurrentsY.SetNoiseType(FastNoiseLite.NoiseType.Perlin); + } + + public Vector2 VelocityAt(Vector2 position) + { + var scaledPosition = position * POSITION_SCALING; + + float disturbancesX = noiseDisturbancesX.GetNoise(scaledPosition.x, scaledPosition.y, + millisecondsPassed * DISTURBANCE_TIMESCALE); + float disturbancesY = noiseDisturbancesY.GetNoise(scaledPosition.x, scaledPosition.y, + millisecondsPassed * DISTURBANCE_TIMESCALE); + + float currentsX = noiseCurrentsX.GetNoise(scaledPosition.x * CURRENTS_STRETCHING_MULTIPLIER, + scaledPosition.y, millisecondsPassed * CURRENTS_TIMESCALE); + float currentsY = noiseCurrentsY.GetNoise(scaledPosition.x, + scaledPosition.y * CURRENTS_STRETCHING_MULTIPLIER, + millisecondsPassed * CURRENTS_TIMESCALE); + + var disturbancesVelocity = new Vector2(disturbancesX, disturbancesY); + var currentsVelocity = new Vector2( + Math.Abs(currentsX) > MIN_CURRENT_INTENSITY ? currentsX : 0.0f, + Math.Abs(currentsY) > MIN_CURRENT_INTENSITY ? currentsY : 0.0f); + + return (disturbancesVelocity * DISTURBANCE_TO_CURRENTS_RATIO) + + (currentsVelocity * (1.0f - DISTURBANCE_TO_CURRENTS_RATIO)); + } + + protected override void PreUpdate(float delta) + { + base.PreUpdate(delta); + + millisecondsPassed += delta / 1000.0f; + } + + protected override void Update(float delta, in Entity entity) + { + ref var physics = ref entity.Get(); + + if (physics.Body == null) + return; + + ref var position = ref entity.Get(); + ref var physicsControl = ref entity.Get(); + + var pos = new Vector2(position.Position.x, position.Position.z); + var vel = VelocityAt(pos) * Constants.MAX_FORCE_APPLIED_BY_CURRENTS; + + physicsControl.ImpulseToGive += new Vector3(vel.x, 0, vel.y) * delta; + } + } +} diff --git a/src/microbe_stage/systems/MicrobeAISystem.cs b/src/microbe_stage/systems/MicrobeAISystem.cs new file mode 100644 index 00000000000..7b88c22e560 --- /dev/null +++ b/src/microbe_stage/systems/MicrobeAISystem.cs @@ -0,0 +1,1140 @@ +namespace Systems +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + using Godot; + using World = DefaultEcs.World; + + /// + /// Microbe AI logic + /// + /// + /// + /// Without the attached component here stops this from running for microbes in colonies + /// + /// + [With(typeof(MicrobeAI))] + [With(typeof(SpeciesMember))] + [With(typeof(MicrobeControl))] + [With(typeof(WorldPosition))] + [With(typeof(Health))] + [With(typeof(CompoundAbsorber))] + [With(typeof(CompoundStorage))] + [With(typeof(OrganelleContainer))] + [With(typeof(CommandSignaler))] + [With(typeof(CellProperties))] + [With(typeof(Engulfer))] + [Without(typeof(AttachedToEntity))] + public sealed class MicrobeAISystem : AEntitySetSystem, ISpeciesMemberLocationData + { + private readonly Compound atp; + private readonly Compound glucose; + private readonly Compound iron; + private readonly Compound oxytoxy; + private readonly Compound ammonia; + private readonly Compound phosphates; + + private readonly IReadonlyCompoundClouds clouds; + + // TODO: for actual consistency these should probably be in the MicrobeAI component so that each AI entity + // consistently uses its own random instance, instead of just a few being used per update for whatever set of + // microbes want to update right this second + // TODO: save these for more consistency after loading a save? + /// + /// Stored random instances for use by the individual AI methods which may run in multiple threads + /// + private readonly List thinkRandoms = new(); + + // New access to the world stuff for AI to see + private readonly EntitySet microbesSet; + private readonly EntitySet chunksSet; + + private readonly List speciesCachesToDrop = new(); + + private readonly Dictionary> microbesBySpecies = + new(); + + private readonly List<(Entity Entity, Vector3 Position, float EngulfSize, CompoundBag Compounds)> + chunkDataCache = new(); + + private bool microbeCacheBuilt; + private bool chunkCacheBuilt; + + private Vector3? potentiallyKnownPlayerPosition; + + private Random aiThinkRandomSource = new(); + + private bool printedPlayerControlMessage; + + private int usedAIThinkRandomIndex; + + private bool skipAI; + + public MicrobeAISystem(IReadonlyCompoundClouds cloudSystem, World world, IParallelRunner runner) : + base(world, runner) + { + clouds = cloudSystem; + + // Microbes that aren't colony non-leaders (and also not eaten) + // The WorldPosition require is here to just ensure that the AI won't accidentally throw an exception if + // it sees an entity with no position + microbesSet = world.GetEntities().With().With() + .With().With().With().Without().AsSet(); + + // Engulfables, which are basically all chunks when they aren't cells, and aren't attached so that they + // also aren't eaten already + chunksSet = world.GetEntities().With().With().With() + .Without().Without().AsSet(); + + var simulationParameters = SimulationParameters.Instance; + atp = simulationParameters.GetCompound("atp"); + glucose = simulationParameters.GetCompound("glucose"); + iron = simulationParameters.GetCompound("iron"); + oxytoxy = simulationParameters.GetCompound("oxytoxy"); + ammonia = simulationParameters.GetCompound("ammonia"); + phosphates = simulationParameters.GetCompound("phosphates"); + } + + public void OverrideAIRandomSeed(int seed) + { + lock (thinkRandoms) + { + thinkRandoms.Clear(); + usedAIThinkRandomIndex = 0; + + aiThinkRandomSource = new Random(seed); + } + } + + public void ReportPotentialPlayerPosition(Vector3? playerPosition) + { + potentiallyKnownPlayerPosition = playerPosition; + } + + public IReadOnlyList<(Entity Entity, Vector3 Position, float EngulfSize)>? GetSpeciesMembers(Species species) + { + BuildMicrobesCache(); + var id = species.ID; + + if (microbesBySpecies.TryGetValue(id, out var result)) + return result; + + return null; + } + + public override void Dispose() + { + Dispose(true); + base.Dispose(); + } + + protected override void PreUpdate(float delta) + { + base.PreUpdate(delta); + + skipAI = CheatManager.NoAI; + usedAIThinkRandomIndex = 0; + + if (!skipAI) + { + // Clean up old cached microbes + CleanMicrobeCache(); + CleanChunkCache(); + } + } + + protected override void Update(float delta, in Entity entity) + { + if (skipAI) + return; + + ref var ai = ref entity.Get(); + + ai.TimeUntilNextThink -= delta; + + if (ai.TimeUntilNextThink > 0) + return; + + // TODO: would be nice to add a tiny bit of randomness to the times here so that not all cells think at once + ai.TimeUntilNextThink = Constants.MICROBE_AI_THINK_INTERVAL; + + // This is probably pretty useless for most situations, but hopefully this doesn't eat too much + // performance + if (entity.Has()) + { + if (!printedPlayerControlMessage) + { + GD.Print("AI is controlling the player microbe"); + printedPlayerControlMessage = true; + } + } + + ref var health = ref entity.Get(); + + if (health.Dead) + return; + + // This shouldn't be needed thanks to the check that this doesn't run on attached entities + // ref var engulfable = ref entity.Get(); + // if (engulfable.PhagocytosisStep != PhagocytosisPhase.None) + // return; + + AIThink(GetNextAIRandom(), in entity, ref ai, ref health); + } + + protected override void PostUpdate(float state) + { + base.PostUpdate(state); + + microbesSet.Complete(); + chunksSet.Complete(); + } + + private static bool RollCheck(float ourStat, float dc, Random random) + { + return random.Next(0.0f, dc) <= ourStat; + } + + private static bool RollReverseCheck(float ourStat, float dc, Random random) + { + return ourStat <= random.Next(0.0f, dc); + } + + /// + /// Main AI think function for cells + /// + private void AIThink(Random random, in Entity entity, ref MicrobeAI ai, ref Health health) + { + ref var absorber = ref entity.Get(); + + if (absorber.TotalAbsorbedCompounds == null) + throw new InvalidOperationException("AI microbe doesn't have compound absorb tracking on"); + + ai.PreviouslyAbsorbedCompounds ??= new Dictionary(absorber.TotalAbsorbedCompounds); + + ChooseActions(in entity, ref ai, ref absorber, ref health, random); + + // Store the absorbed compounds for run and rumble + ai.PreviouslyAbsorbedCompounds!.Clear(); + + foreach (var compound in absorber.TotalAbsorbedCompounds!) + { + ai.PreviouslyAbsorbedCompounds[compound.Key] = compound.Value; + } + + // We clear here for update, this is why we stored above! + absorber.TotalAbsorbedCompounds.Clear(); + } + + private void ChooseActions(in Entity entity, ref MicrobeAI ai, ref CompoundAbsorber absorber, + ref Health health, Random random) + { + // Fetch all the components that are usually needed + ref var position = ref entity.Get(); + + ref var ourSpecies = ref entity.Get(); + + ref var organelles = ref entity.Get(); + + ref var cellProperties = ref entity.Get(); + + ref var signaling = ref entity.Get(); + + ref var engulfer = ref entity.Get(); + + ref var control = ref entity.Get(); + + var compounds = entity.Get().Compounds; + + // Adjusted behaviour values (calculated here as these are needed by various methods) + float speciesAggression = ourSpecies.Species.Behaviour.Aggression * + (signaling.ReceivedCommand == MicrobeSignalCommand.BecomeAggressive ? 1.5f : 1.0f); + + float speciesFear = ourSpecies.Species.Behaviour.Fear * + (signaling.ReceivedCommand == MicrobeSignalCommand.BecomeAggressive ? 0.75f : 1.0f); + + float speciesActivity = ourSpecies.Species.Behaviour.Activity * + (signaling.ReceivedCommand == MicrobeSignalCommand.BecomeAggressive ? 1.25f : 1.0f); + + float speciesFocus = ourSpecies.Species.Behaviour.Focus; + float speciesOpportunism = ourSpecies.Species.Behaviour.Opportunism; + + // If nothing is engulfing me right now, see if there's something that might want to hunt me + Vector3? predator = + GetNearestPredatorItem(ref health, ref ourSpecies, ref engulfer, ref position, speciesFear)?.Position; + if (predator.HasValue && position.Position.DistanceSquaredTo(predator.Value) < + (1500.0 * speciesFear / Constants.MAX_SPECIES_FEAR)) + { + FleeFromPredators(ref position, ref ai, ref control, ref organelles, compounds, predator.Value, + speciesFocus, speciesActivity, speciesAggression, speciesFear, random); + return; + } + + // If this microbe is out of ATP, pick an amount of time to rest + if (compounds.GetCompoundAmount(atp) < 1.0f) + { + // Keep the maximum at 95% full, as there is flickering when near full + ai.ATPThreshold = 0.95f * speciesFocus / Constants.MAX_SPECIES_FOCUS; + } + + if (ai.ATPThreshold > 0.0f) + { + if (compounds.GetCompoundAmount(atp) < compounds.GetCapacityForCompound(atp) * ai.ATPThreshold + && compounds.Any(compound => IsVitalCompound(compound.Key, compounds) && compound.Value > 0.0f)) + { + control.SetMoveSpeed(0.0f); + return; + } + + ai.ATPThreshold = 0.0f; + } + + // Follow received commands if we have them + if (organelles.HasSignalingAgent && signaling.ReceivedCommand != MicrobeSignalCommand.None) + { + // TODO: tweak the balance between following commands and doing normal behaviours + // TODO: and also probably we want to add some randomness to the positions and speeds based on distance + switch (signaling.ReceivedCommand) + { + case MicrobeSignalCommand.MoveToMe: + { + if (signaling.ReceivedCommandFromEntity.Has()) + { + ai.MoveToLocation(signaling.ReceivedCommandFromEntity.Get().Position, + ref control); + return; + } + + break; + } + + case MicrobeSignalCommand.FollowMe: + { + if (signaling.ReceivedCommandFromEntity.Has()) + { + var signalerPosition = signaling.ReceivedCommandFromEntity.Get().Position; + if (position.Position.DistanceSquaredTo(signalerPosition) > + Constants.AI_FOLLOW_DISTANCE_SQUARED) + { + ai.MoveToLocation(signalerPosition, ref control); + return; + } + + return; + } + + break; + } + + case MicrobeSignalCommand.FleeFromMe: + { + if (signaling.ReceivedCommandFromEntity.Has()) + { + var signalerPosition = signaling.ReceivedCommandFromEntity.Get().Position; + if (position.Position.DistanceSquaredTo(signalerPosition) < + Constants.AI_FLEE_DISTANCE_SQUARED) + { + control.State = MicrobeState.Normal; + control.SetMoveSpeed(Constants.AI_BASE_MOVEMENT); + + // Direction is calculated to be the opposite from where we should flee + ai.TargetPosition = position.Position + (position.Position - signalerPosition); + control.LookAtPoint = ai.TargetPosition; + control.SetMoveSpeed(Constants.AI_BASE_MOVEMENT); + return; + } + } + + break; + } + } + } + + bool isSessile = speciesActivity < Constants.MAX_SPECIES_ACTIVITY / 10; + + // If I'm very far from the player, and I have not been near the player yet, get on stage + if (!ai.HasBeenNearPlayer) + { + if (potentiallyKnownPlayerPosition != null) + { + // Only move if we aren't sessile + if (position.Position.DistanceSquaredTo(potentiallyKnownPlayerPosition.Value) > + Math.Pow(Constants.SPAWN_SECTOR_SIZE, 2) * 0.75f && + !isSessile) + { + ai.MoveToLocation(potentiallyKnownPlayerPosition.Value, ref control); + return; + } + + ai.HasBeenNearPlayer = true; + } + } + + // If there are no threats, look for a chunk to eat + // TODO: still consider engulfing things if we're in a colony that can engulf (has engulfer cells) + if (cellProperties.MembraneType.CanEngulf) + { + var targetChunk = GetNearestChunkItem(in entity, ref engulfer, ref position, compounds, + speciesFocus, speciesOpportunism, random); + if (targetChunk != null) + { + PursueAndConsumeChunks(ref position, ref ai, ref control, ref engulfer, targetChunk.Value.Position, + speciesActivity, random); + return; + } + } + + // If there are no chunks, look for living prey to hunt + var possiblePrey = GetNearestPreyItem(ref ai, ref position, ref organelles, ref ourSpecies, ref engulfer, + compounds, speciesFocus, speciesAggression, speciesOpportunism, random); + if (possiblePrey != default && possiblePrey.IsAlive) + { + Vector3 prey; + + try + { + prey = possiblePrey.Get().Position; + } + catch (Exception e) + { + GD.PrintErr("Microbe AI tried to engage prey with no position: " + e); + ai.FocusedPrey = default; + return; + } + + bool engulfPrey = cellProperties.CanEngulfObject(ref ourSpecies, ref engulfer, possiblePrey) == + EngulfCheckResult.Ok && position.Position.DistanceSquaredTo(prey) < + 10.0f * engulfer.EngulfingSize; + + EngagePrey(ref ai, ref control, ref organelles, ref position, compounds, prey, engulfPrey, + speciesAggression, speciesFocus, speciesActivity, random); + return; + } + + // There is no reason to be engulfing at this stage + control.State = MicrobeState.Normal; + + // Otherwise just wander around and look for compounds + if (!isSessile) + { + SeekCompounds(in entity, ref ai, ref position, ref control, ref organelles, ref absorber, compounds, + speciesActivity, speciesFocus, random); + } + else + { + // This organism is sessile, and will not act until the environment changes + control.SetMoveSpeed(0.0f); + } + } + + private (Entity Entity, Vector3 Position, float EngulfSize, CompoundBag Compounds)? GetNearestChunkItem( + in Entity entity, ref Engulfer engulfer, ref WorldPosition position, + CompoundBag ourCompounds, float speciesFocus, float speciesOpportunism, Random random) + { + (Entity Entity, Vector3 Position, float EngulfSize, CompoundBag Compounds)? chosenChunk = null; + float bestFoundChunkDistance = float.MaxValue; + + BuildChunksCache(); + + // Retrieve nearest potential chunk + foreach (var chunk in chunkDataCache) + { + // Skip too big things + if (engulfer.EngulfingSize < chunk.EngulfSize * Constants.ENGULF_SIZE_RATIO_REQ) + continue; + + // And too distant things + var distance = (chunk.Position - position.Position).LengthSquared(); + + if (distance > bestFoundChunkDistance) + continue; + + if (distance > (20000.0 * speciesFocus / Constants.MAX_SPECIES_FOCUS) + 1500.0) + continue; + + if (chunk.Compounds.Compounds.Any(p => ourCompounds.IsUseful(p.Key) && p.Key.Digestible)) + { + if (chosenChunk == null) + { + chosenChunk = chunk; + bestFoundChunkDistance = distance; + } + } + } + + // Don't bother with chunks when there's a lot of microbes to compete with + if (chosenChunk != null) + { + BuildMicrobesCache(); + + var rivals = 0; + foreach (var entry in microbesBySpecies) + { + // Take own species members also into account when considering rivals + + foreach (var rival in entry.Value) + { + // Don't compete against yourself + if (rival.Entity == entity) + continue; + + var rivalDistance = (rival.Position - chosenChunk.Value.Position).LengthSquared(); + if (rivalDistance < 500.0f && rivalDistance < bestFoundChunkDistance) + { + rivals++; + } + } + } + + int rivalThreshold = 5; + + // Less opportunistic species will avoid chunks even when there are just a few rivals + if (speciesOpportunism < Constants.MAX_SPECIES_OPPORTUNISM / 3) + { + rivalThreshold = 1; + } + else if (speciesOpportunism < Constants.MAX_SPECIES_OPPORTUNISM * 2 / 3) + { + rivalThreshold = 3; + } + + // In rare instances, microbes will choose to be much more ambitious + if (RollCheck(speciesFocus, Constants.MAX_SPECIES_FOCUS, random)) + { + rivalThreshold *= 2; + } + + if (rivals > rivalThreshold) + { + chosenChunk = null; + } + } + + return chosenChunk; + } + + /// + /// Gets the nearest prey item. And builds the prey list + /// + /// The nearest prey item. + private Entity GetNearestPreyItem(ref MicrobeAI ai, ref WorldPosition position, + ref OrganelleContainer organelles, ref SpeciesMember ourSpecies, + ref Engulfer engulfer, CompoundBag ourCompounds, float speciesFocus, float speciesAggression, + float speciesOpportunism, Random random) + { + if (ai.FocusedPrey != default && ai.FocusedPrey.IsAlive) + { + var focused = ai.FocusedPrey; + try + { + var distanceToFocusedPrey = + position.Position.DistanceSquaredTo(focused.Get().Position); + if (!focused.Get().Dead && + focused.Get().PhagocytosisStep == PhagocytosisPhase.None && distanceToFocusedPrey < + (3500.0f * speciesFocus / Constants.MAX_SPECIES_FOCUS)) + { + if (distanceToFocusedPrey < ai.PursuitThreshold) + { + // Keep chasing, but expect to keep getting closer + ai.LowerPursuitThreshold(); + return focused; + } + + // If prey hasn't gotten closer by now, it's probably too fast, or juking you + // Remember who focused prey is, so that you don't fall for this again + return default; + } + } + catch (Exception e) + { + GD.PrintErr("Invalid focused prey, resetting, error: " + e); + } + + ai.FocusedPrey = default; + } + + (Entity Entity, Vector3 Position, float EngulfSize)? chosenPrey = null; + float minDistance = float.MaxValue; + + BuildMicrobesCache(); + + foreach (var entry in microbesBySpecies) + { + // Don't try to eat members of the same species + if (entry.Key == ourSpecies.ID) + continue; + + foreach (var otherMicrobeInfo in entry.Value) + { + var distance = position.Position.DistanceSquaredTo(otherMicrobeInfo.Position); + + // Early skip farther away entities than already found, or too far away entities to consider eating + if (distance > minDistance || + distance > 2500.0f * speciesAggression / Constants.MAX_SPECIES_AGGRESSION) + { + continue; + } + + if (CanTryToEatMicrobe(ref ourSpecies, ref engulfer, ref organelles, ourCompounds, + ref otherMicrobeInfo.Entity.Get(), + ref otherMicrobeInfo.Entity.Get(), speciesOpportunism, speciesFocus, random)) + { + if (chosenPrey == null) + { + chosenPrey = otherMicrobeInfo; + minDistance = distance; + } + } + } + } + + if (chosenPrey != null) + { + ai.FocusedPrey = chosenPrey.Value.Entity; + ai.PursuitThreshold = position.Position.DistanceSquaredTo(chosenPrey.Value.Position) * 3.0f; + } + else + { + ai.FocusedPrey = default; + } + + return ai.FocusedPrey; + } + + /// + /// Building the predator list and setting the scariest one to be predator + /// + private (Entity Entity, Vector3 Position, float EngulfSize)? GetNearestPredatorItem(ref Health health, + ref SpeciesMember ourSpecies, ref Engulfer engulfer, ref WorldPosition position, float speciesFear) + { + var fleeThreshold = 3.0f - (2 * + (speciesFear / Constants.MAX_SPECIES_FEAR) * + (10 - (9 * health.CurrentHealth / health.MaxHealth))); + + (Entity Entity, Vector3 Position, float EngulfSize)? predator = null; + float minDistance = float.MaxValue; + + BuildMicrobesCache(); + + foreach (var entry in microbesBySpecies) + { + // Don't be scared of the same species + if (entry.Key == ourSpecies.ID) + continue; + + foreach (var otherMicrobeInfo in entry.Value) + { + // Based on species fear, threshold to be afraid ranges from 0.8 to 1.8 microbe size. + if (otherMicrobeInfo.EngulfSize > engulfer.EngulfingSize * fleeThreshold) + { + var distance = position.Position.DistanceSquaredTo(otherMicrobeInfo.Position); + if (predator == null || minDistance > distance) + { + predator = otherMicrobeInfo; + minDistance = distance; + } + } + } + } + + return predator; + } + + private void PursueAndConsumeChunks(ref WorldPosition position, ref MicrobeAI ai, ref MicrobeControl control, + ref Engulfer engulfer, Vector3 chunk, float speciesActivity, Random random) + { + // This is a slight offset of where the chunk is, to avoid a forward-facing part blocking it + ai.TargetPosition = chunk + new Vector3(0.5f, 0.0f, 0.5f); + control.LookAtPoint = ai.TargetPosition; + SetEngulfIfClose(ref control, ref engulfer, ref position, chunk); + + // Just in case something is obstructing chunk engulfing, wiggle a little sometimes + if (random.NextDouble() < 0.05) + { + ai.MoveWithRandomTurn(0.1f, 0.2f, position.Position, ref control, speciesActivity, random); + } + + // If this Microbe is right on top of the chunk, stop instead of spinning + if (position.Position.DistanceSquaredTo(chunk) < Constants.AI_ENGULF_STOP_DISTANCE) + { + control.SetMoveSpeed(0.0f); + } + else + { + control.SetMoveSpeed(Constants.AI_BASE_MOVEMENT); + } + } + + private void FleeFromPredators(ref WorldPosition position, ref MicrobeAI ai, ref MicrobeControl control, + ref OrganelleContainer organelles, CompoundBag ourCompounds, Vector3 predatorLocation, float speciesFocus, + float speciesActivity, float speciesAggression, float speciesFear, Random random) + { + control.State = MicrobeState.Normal; + + ai.TargetPosition = (2 * (position.Position - predatorLocation)) + position.Position; + + control.LookAtPoint = ai.TargetPosition; + + if (position.Position.DistanceSquaredTo(predatorLocation) < 100.0f) + { + if ((organelles.SlimeJets?.Count ?? 0) > 0 && + RollCheck(speciesFear, Constants.MAX_SPECIES_FEAR, random)) + { + // There's a chance to jet away if we can + control.SecreteSlimeForSomeTime(ref organelles, random); + } + else if (RollCheck(speciesAggression, Constants.MAX_SPECIES_AGGRESSION, random)) + { + // If the predator is right on top of us there's a chance to try and swing with a pilus + ai.MoveWithRandomTurn(2.5f, 3.0f, position.Position, ref control, speciesActivity, random); + } + } + + // If prey is confident enough, it will try and launch toxin at the predator + if (speciesAggression > speciesFear && + position.Position.DistanceSquaredTo(predatorLocation) > + 300.0f - (5.0f * speciesAggression) + (6.0f * speciesFear) && + RollCheck(speciesAggression, Constants.MAX_SPECIES_AGGRESSION, random)) + { + LaunchToxin(ref control, ref organelles, ref position, predatorLocation, ourCompounds, speciesFocus, + speciesActivity); + } + + // No matter what, I want to make sure I'm moving + control.SetMoveSpeed(Constants.AI_BASE_MOVEMENT); + } + + private void EngagePrey(ref MicrobeAI ai, ref MicrobeControl control, ref OrganelleContainer organelles, + ref WorldPosition position, CompoundBag ourCompounds, Vector3 target, bool engulf, float speciesAggression, + float speciesFocus, float speciesActivity, Random random) + { + control.State = engulf ? MicrobeState.Engulf : MicrobeState.Normal; + ai.TargetPosition = target; + control.LookAtPoint = ai.TargetPosition; + if (CanShootToxin(ourCompounds, speciesFocus)) + { + LaunchToxin(ref control, ref organelles, ref position, target, ourCompounds, speciesFocus, + speciesActivity); + + if (RollCheck(speciesAggression, Constants.MAX_SPECIES_AGGRESSION / 5, random)) + { + control.SetMoveSpeed(Constants.AI_BASE_MOVEMENT); + } + } + else + { + control.SetMoveSpeed(Constants.AI_BASE_MOVEMENT); + } + + // Predators can use slime jets as an ambush mechanism + if (RollCheck(speciesAggression, Constants.MAX_SPECIES_AGGRESSION, random)) + { + control.SetMoveSpeed(Constants.AI_BASE_MOVEMENT); + control.SecreteSlimeForSomeTime(ref organelles, random); + } + } + + private void SeekCompounds(in Entity entity, ref MicrobeAI ai, ref WorldPosition position, + ref MicrobeControl control, ref OrganelleContainer organelles, ref CompoundAbsorber absorber, + CompoundBag compounds, float speciesActivity, float speciesFocus, Random random) + { + // More active species just try to get distance to avoid over-clustering + if (RollCheck(speciesActivity, Constants.MAX_SPECIES_ACTIVITY + (Constants.MAX_SPECIES_ACTIVITY / 2), + random)) + { + control.SetMoveSpeed(Constants.AI_BASE_MOVEMENT); + return; + } + + if (random.Next(Constants.AI_STEPS_PER_SMELL) == 0) + { + SmellForCompounds(in entity, ref ai, ref position, ref organelles, compounds, speciesFocus); + } + + // If the AI has smelled a compound (currently only possible with a chemoreceptor), go towards it. + if (ai.LastSmelledCompoundPosition != null) + { + var distance = position.Position.DistanceSquaredTo(ai.LastSmelledCompoundPosition.Value); + + // If the compound isn't getting closer, either something else has taken it, or we're stuck + ai.LowerPursuitThreshold(); + if (distance > ai.PursuitThreshold) + { + ai.LastSmelledCompoundPosition = null; + RunAndTumble(ref ai, ref control, ref position, ref absorber, compounds, speciesActivity, random); + return; + } + + if (distance > 3.0f) + { + ai.TargetPosition = ai.LastSmelledCompoundPosition!.Value; + control.LookAtPoint = ai.TargetPosition; + } + else + { + control.SetMoveSpeed(0.0f); + SmellForCompounds(in entity, ref ai, ref position, ref organelles, compounds, speciesFocus); + } + } + else + { + RunAndTumble(ref ai, ref control, ref position, ref absorber, compounds, speciesActivity, random); + } + } + + private void SmellForCompounds(in Entity entity, ref MicrobeAI ai, ref WorldPosition position, + ref OrganelleContainer organelles, CompoundBag compounds, float speciesFocus) + { + ComputeCompoundsSearchWeights(ref ai, compounds); + + var weights = ai.CompoundsSearchWeights!; + + var detections = organelles.PerformCompoundDetection(in entity, position.Position, clouds); + + if (detections is { Count: > 0 }) + { + ai.LastSmelledCompoundPosition = detections.OrderBy(detection => + weights.TryGetValue(detection.Compound, out var weight) ? + weight : + 0).First().Target; + ai.PursuitThreshold = position.Position.DistanceSquaredTo(ai.LastSmelledCompoundPosition.Value) + * (1 + (speciesFocus / Constants.MAX_SPECIES_FOCUS)); + } + else + { + ai.LastSmelledCompoundPosition = null; + } + } + + /// + /// For doing run and tumble + /// + private void RunAndTumble(ref MicrobeAI ai, ref MicrobeControl control, ref WorldPosition position, + ref CompoundAbsorber absorber, CompoundBag compounds, float speciesActivity, Random random) + { + // If this microbe is currently stationary, just initialize by moving in a random direction. + // Used to get newly spawned microbes to move. + if (control.MovementDirection.Length() == 0) + { + ai.MoveWithRandomTurn(0, Mathf.Pi, position.Position, ref control, speciesActivity, random); + return; + } + + // Run and tumble + // A biased random walk, they turn more if they are picking up less compounds. + // The scientifically accurate algorithm has been flipped to account for the compound + // deposits being a lot smaller compared to the microbes + // https://www.mit.edu/~kardar/teaching/projects/chemotaxis(AndreaSchmidt)/home.htm + + ComputeCompoundsSearchWeights(ref ai, compounds); + + float gradientValue = 0.0f; + foreach (var compoundWeight in ai.CompoundsSearchWeights!) + { + // Note this is about absorbed quantities (which is all microbe has access to) not the ones in the + // clouds. Gradient computation is therefore cell-centered, and might be different for different cells. + float compoundDifference = 0.0f; + + absorber.TotalAbsorbedCompounds!.TryGetValue(compoundWeight.Key, out float quantityAbsorbedThisStep); + ai.PreviouslyAbsorbedCompounds!.TryGetValue(compoundWeight.Key, out float quantityAbsorbedPreviousStep); + + compoundDifference += quantityAbsorbedThisStep - quantityAbsorbedPreviousStep; + + compoundDifference *= compoundWeight.Value; + gradientValue += compoundDifference; + } + + // Implement a detection threshold to possibly rule out too tiny variations + // TODO: possibly include cell capacity correction + float differenceDetectionThreshold = Constants.AI_GRADIENT_DETECTION_THRESHOLD; + + // If food density is going down, back up and see if there's some more + if (gradientValue < -differenceDetectionThreshold && random.Next(0, 10) < 9) + { + ai.MoveWithRandomTurn(2.5f, 3.0f, position.Position, ref control, speciesActivity, random); + } + + // If there isn't any food here, it's a good idea to keep moving + if (Math.Abs(gradientValue) <= differenceDetectionThreshold && random.Next(0, 10) < 5) + { + ai.MoveWithRandomTurn(0.0f, 0.4f, position.Position, ref control, speciesActivity, random); + } + + // If positive last step you gained compounds, so let's move toward the source + if (gradientValue > differenceDetectionThreshold) + { + // There's a decent chance to turn by 90° to explore gradient + // 180° is useless since previous position let you absorb less compounds already + if (random.Next(0, 10) < 4) + { + ai.MoveWithRandomTurn(0.0f, 1.5f, position.Position, ref control, speciesActivity, random); + } + } + } + + /// + /// Prioritizing compounds that are stored in lesser quantities. + /// If ATP-producing compounds are low (less than half storage capacities), + /// non ATP-related compounds are discarded. + /// Updates compoundsSearchWeights instance dictionary. + /// + private void ComputeCompoundsSearchWeights(ref MicrobeAI ai, CompoundBag storedCompounds) + { + // TODO: should this really assume that all stored compounds are immediately useful + IEnumerable usefulCompounds = storedCompounds.Compounds.Keys; + + // If this microbe lacks vital compounds don't bother with ammonia and phosphate + if (usefulCompounds.Any(c => + IsVitalCompound(c, storedCompounds) && storedCompounds.GetCompoundAmount(c) < + 0.5f * storedCompounds.GetCapacityForCompound(c))) + { + usefulCompounds = usefulCompounds.Where(x => x != ammonia && x != phosphates); + } + + if (ai.CompoundsSearchWeights == null) + { + ai.CompoundsSearchWeights = new Dictionary(); + } + else + { + ai.CompoundsSearchWeights.Clear(); + } + + foreach (var compound in usefulCompounds) + { + // The priority of a compound is inversely proportional to its availability + // Should be tweaked with consumption + var compoundPriority = 1 - storedCompounds.GetCompoundAmount(compound) / + storedCompounds.GetCapacityForCompound(compound); + + ai.CompoundsSearchWeights.Add(compound, compoundPriority); + } + } + + /// + /// Tells if a compound is vital to this microbe. + /// Vital compounds are *direct* ATP producers + /// + /// + /// + /// TODO: what is used here is a shortcut linked to the current game state: such compounds could be used for + /// other processes in future versions + /// + /// + private bool IsVitalCompound(Compound compound, CompoundBag compounds) + { + // TODO: looking for mucilage should be prevented + return compounds.IsUseful(compound) && + (compound == glucose || compound == iron); + } + + private void SetEngulfIfClose(ref MicrobeControl control, ref Engulfer engulfer, ref WorldPosition position, + Vector3 targetPosition) + { + // Turn on engulf mode if close + // Sometimes "close" is hard to discern since microbes can range from straight lines to circles + if ((position.Position - targetPosition).LengthSquared() <= engulfer.EngulfingSize * 2.0f) + { + control.State = MicrobeState.Engulf; + } + else + { + control.State = MicrobeState.Normal; + } + } + + private void LaunchToxin(ref MicrobeControl control, ref OrganelleContainer organelles, + ref WorldPosition position, Vector3 target, CompoundBag compounds, float speciesFocus, + float speciesActivity) + { + // TODO: AI should be able to use all toxin vacuoles in cell colonies + + if (organelles.AgentVacuoleCount > 0 && + (position.Position - target).LengthSquared() <= speciesFocus * 10.0f) + { + if (CanShootToxin(compounds, speciesFocus)) + { + control.LookAtPoint = target; + + // Hold fire until the target is lined up. + // TODO: verify that this calculation is now correct, this used to use just the angle to the world + // look at point which certainly wasn't correct when the microbe was not positioned at world origin + var currentLookDirection = position.Rotation.Xform(Vector3.Forward); + + if (currentLookDirection.Normalized() + .AngleTo((control.LookAtPoint - position.Position).Normalized()) < + 0.1f + speciesActivity / (Constants.AI_BASE_TOXIN_SHOOT_ANGLE_PRECISION * speciesFocus)) + { + control.QueuedToxinToEmit = oxytoxy; + } + } + } + } + + private bool CanTryToEatMicrobe(ref SpeciesMember ourSpecies, ref Engulfer engulfer, + ref OrganelleContainer organelles, CompoundBag ourCompounds, + ref Engulfable targetMicrobe, ref SpeciesMember targetSpecies, float speciesOpportunism, float speciesFocus, + Random random) + { + var sizeRatio = engulfer.EngulfingSize / targetMicrobe.EffectiveEngulfSize(); + + // Sometimes the AI will randomly decide to try in vain to eat something + var choosingToEngulf = organelles.CanDigestObject(ref targetMicrobe) == DigestCheckResult.Ok || + random.NextDouble() < + Constants.AI_BAD_ENGULF_CHANCE * speciesOpportunism / Constants.MAX_SPECIES_OPPORTUNISM; + + var choosingToAttackWithToxin = speciesOpportunism + > Constants.MAX_SPECIES_OPPORTUNISM * 0.3f && CanShootToxin(ourCompounds, speciesFocus); + + return choosingToEngulf && + targetSpecies.ID != ourSpecies.ID && ( + choosingToAttackWithToxin + || (sizeRatio >= Constants.ENGULF_SIZE_RATIO_REQ)); + } + + private bool CanShootToxin(CompoundBag compounds, float speciesFocus) + { + return compounds.GetCompoundAmount(oxytoxy) >= + Constants.MAXIMUM_AGENT_EMISSION_AMOUNT * speciesFocus / Constants.MAX_SPECIES_FOCUS; + } + + private void CleanMicrobeCache() + { + foreach (var entry in microbesBySpecies) + { + if (entry.Value.Count < 1) + { + speciesCachesToDrop.Add(entry.Key); + } + else + { + // Empty out the cache lists as BuildMicrobesCache rebuilds them always from scratch + entry.Value.Clear(); + } + } + + // Remove unused species lists from the species cache + foreach (var toClear in speciesCachesToDrop) + { + microbesBySpecies.Remove(toClear); + } + + speciesCachesToDrop.Clear(); + + microbeCacheBuilt = false; + } + + /// + /// Builds a full cache of all alive, non-engulfed and non-colony member cells (colony lead cells are + /// included) + /// + private void BuildMicrobesCache() + { + // To allow multithreaded AI access safely + lock (microbesBySpecies) + { + if (microbeCacheBuilt) + return; + + foreach (ref readonly var microbe in microbesSet.GetEntities()) + { + // Skip considering dead microbes + ref var health = ref microbe.Get(); + + if (health.Dead) + continue; + + ref var microbeSpecies = ref microbe.Get(); + + // TODO: determine if it is a good idea to resolve this data here immediately (at least position + // should be fine as it is needed by other systems as well, see ISpeciesMemberLocationData) + ref var position = ref microbe.Get(); + ref var engulfer = ref microbe.Get(); + + // We assume here that the engulfable size is the same as the engulfing size so we don't fetch + // Engulfable here + + if (!microbesBySpecies.TryGetValue(microbeSpecies.ID, out var targetList)) + { + targetList = new List<(Entity Entity, Vector3 Position, float EngulfSize)>(); + + microbesBySpecies[microbeSpecies.ID] = targetList; + } + + targetList.Add((microbe, position.Position, engulfer.EngulfingSize)); + } + + microbeCacheBuilt = true; + } + } + + private void CleanChunkCache() + { + chunkDataCache.Clear(); + chunkCacheBuilt = false; + } + + /// + /// Builds a full cache of all non-engulfed chunks that aren't dissolving currently + /// + private void BuildChunksCache() + { + // To allow multithreaded AI access safely + lock (chunkDataCache) + { + if (chunkCacheBuilt) + return; + + foreach (ref readonly var chunk in chunksSet.GetEntities()) + { + // Ignore already despawning chunks + ref var timed = ref chunk.Get(); + + if (timed.TimeToLiveRemaining <= 0) + continue; + + // Ignore chunks that wouldn't yield any useful compounds when absorbing + ref var compounds = ref chunk.Get(); + + if (!compounds.Compounds.HasAnyCompounds()) + continue; + + // TODO: determine if it is a good idea to resolve this data here immediately + ref var position = ref chunk.Get(); + ref var engulfable = ref chunk.Get(); + + chunkDataCache.Add((chunk, position.Position, engulfable.AdjustedEngulfSize, compounds.Compounds)); + } + + chunkCacheBuilt = true; + } + } + + private Random GetNextAIRandom() + { + lock (thinkRandoms) + { + while (usedAIThinkRandomIndex >= thinkRandoms.Count) + { + thinkRandoms.Add(new Random(aiThinkRandomSource.Next())); + } + + return thinkRandoms[usedAIThinkRandomIndex++]; + } + } + + private void Dispose(bool disposing) + { + if (disposing) + { + microbesSet.Dispose(); + chunksSet.Dispose(); + } + } + } +} diff --git a/src/microbe_stage/systems/MicrobeCollisionSoundSystem.cs b/src/microbe_stage/systems/MicrobeCollisionSoundSystem.cs new file mode 100644 index 00000000000..11ccb085105 --- /dev/null +++ b/src/microbe_stage/systems/MicrobeCollisionSoundSystem.cs @@ -0,0 +1,52 @@ +namespace Systems +{ + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + + /// + /// Plays a sound effect when two cells collide hard enough + /// + [With(typeof(CollisionManagement))] + [With(typeof(SoundEffectPlayer))] + [With(typeof(SpeciesMember))] + public sealed class MicrobeCollisionSoundSystem : AEntitySetSystem + { + public MicrobeCollisionSoundSystem(World world, IParallelRunner parallelRunner) : base(world, parallelRunner) + { + } + + protected override void Update(float delta, in Entity entity) + { + ref var collisionManagement = ref entity.Get(); + + var count = collisionManagement.GetActiveCollisions(out var collisions); + if (count < 1) + return; + + ref var soundEffectPlayer = ref entity.Get(); + + for (int i = 0; i < count; ++i) + { + ref var collision = ref collisions![i]; + + // Only process just started collisions to not trigger the sound multiple times + if (!collision.JustStarted) + continue; + + // TODO: should collisions with any physics entities count? + // For now collisions with just microbes count + if (!collision.SecondEntity.Has()) + continue; + + // Play bump sound if the collision is hard enough (there's enough physics bodies overlap) + if (collision.PenetrationAmount > Constants.CONTACT_PENETRATION_TO_BUMP_SOUND) + { + // TODO: scale volume with the impact penetration + soundEffectPlayer.PlaySoundEffect("res://assets/sounds/soundeffects/microbe-collision.ogg"); + } + } + } + } +} diff --git a/src/microbe_stage/systems/MicrobeDeathSystem.cs b/src/microbe_stage/systems/MicrobeDeathSystem.cs new file mode 100644 index 00000000000..cabd2017024 --- /dev/null +++ b/src/microbe_stage/systems/MicrobeDeathSystem.cs @@ -0,0 +1,433 @@ +namespace Systems +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Components; + using DefaultEcs; + using DefaultEcs.Command; + using DefaultEcs.System; + using DefaultEcs.Threading; + using Godot; + using World = DefaultEcs.World; + + /// + /// Handles microbes dying when they run out of health and also updates the membrane visuals to indicate how + /// close to death a microbe is + /// + [With(typeof(Health))] + [With(typeof(OrganelleContainer))] + [With(typeof(MicrobeShaderParameters))] + [With(typeof(CellProperties))] + [With(typeof(Physics))] + [With(typeof(WorldPosition))] + [With(typeof(MicrobeControl))] + [With(typeof(ManualPhysicsControl))] + [With(typeof(SoundEffectPlayer))] + [With(typeof(CompoundAbsorber))] + [RunsAfter(typeof(OsmoregulationAndHealingSystem))] + [RunsBefore(typeof(EngulfingSystem))] + public sealed class MicrobeDeathSystem : AEntitySetSystem + { + private readonly IWorldSimulation worldSimulation; + private readonly ISpawnSystem spawnSystem; + + private readonly Random random = new(); + + private readonly Compound oxytoxy = SimulationParameters.Instance.GetCompound("oxytoxy"); + private readonly Compound glucose = SimulationParameters.Instance.GetCompound("glucose"); + + private GameWorld? gameWorld; + + public MicrobeDeathSystem(IWorldSimulation worldSimulation, ISpawnSystem spawnSystem, World world, + IParallelRunner parallelRunner) : base(world, parallelRunner) + { + this.worldSimulation = worldSimulation; + this.spawnSystem = spawnSystem; + } + + /// + /// Delegate called to customize things spawned by . + /// The return value defines the initial velocity to set. Modifying the position parameter allows + /// controlling the spawn location. + /// + public delegate Vector3 CustomizeSpawnedChunk(ref Vector3 position); + + public static void SpawnCorpseChunks(ref OrganelleContainer organelleContainer, CompoundBag compounds, + ISpawnSystem spawnSystem, IWorldSimulation worldSimulation, EntityCommandRecorder recorder, + Vector3 basePosition, Random random, + CustomizeSpawnedChunk? customizeCallback, + Compound? glucose) + { + if (organelleContainer.Organelles == null) + throw new InvalidOperationException("Organelles can't be null when determining chunks to drop"); + + // Eject the compounds that was in the microbe + var compoundsToRelease = new Dictionary(); + + foreach (var type in SimulationParameters.Instance.GetCloudCompounds()) + { + var amount = compounds.GetCompoundAmount(type) * + Constants.COMPOUND_RELEASE_FRACTION; + + compoundsToRelease[type] = amount; + } + + // Eject some part of the build cost of all the organelles + foreach (var organelle in organelleContainer.Organelles!) + { + foreach (var entry in organelle.Definition.InitialComposition) + { + compoundsToRelease.TryGetValue(entry.Key, out var existing); + + // Only add up if there's still some compounds left, otherwise + // we're releasing compounds out of thin air. + if (existing > 0) + { + compoundsToRelease[entry.Key] = existing + (entry.Value * + Constants.COMPOUND_MAKEUP_RELEASE_FRACTION); + } + } + } + + EngulfableHelpers.CalculateBonusDigestibleGlucose(compoundsToRelease, compounds, glucose); + + // Queues either 1 corpse chunk or a factor of the hexes + // TODO: should there be a max amount (maybe like 20?) + int chunksToSpawn = Math.Max(1, organelleContainer.HexCount / Constants.CORPSE_CHUNK_DIVISOR); + + // An enumerator to step through all available organelles in a random order when making chunks + using var organellesAvailableEnumerator = + organelleContainer.Organelles.Organelles.OrderBy(_ => random.Next()).GetEnumerator(); + + // The default model for chunks is the cytoplasm model in case there isn't a model left in the species + var defaultChunkScene = SimulationParameters.Instance + .GetOrganelleType(Constants.DEFAULT_CHUNK_MODEL_NAME).CorpseChunkScene ?? + throw new Exception("No chunk scene set on default organelle type to use"); + + for (int i = 0; i < chunksToSpawn; ++i) + { + // Amount of compound in one chunk + float amount = organelleContainer.HexCount / Constants.CORPSE_CHUNK_AMOUNT_DIVISOR; + + var positionAdded = new Vector3(random.Next(-2.0f, 2.0f), 0, + random.Next(-2.0f, 2.0f)); + + var chunkType = new ChunkConfiguration + { + ChunkScale = 1.0f, + Dissolves = true, + Mass = 1.0f, + Radius = 1.0f, + Size = 3.0f, + VentAmount = 0.1f, + + // Add compounds + Compounds = new Dictionary(), + }; + + // They were added in order already so looping through this other thing is fine + foreach (var entry in compoundsToRelease) + { + var compoundValue = new ChunkConfiguration.ChunkCompound + { + // Randomize compound amount a bit so things "rot away" + Amount = (entry.Value / (random.Next(amount / 3.0f, amount) * + Constants.CHUNK_ENGULF_COMPOUND_DIVISOR)) * Constants.CORPSE_COMPOUND_COMPENSATION, + }; + + chunkType.Compounds[entry.Key] = compoundValue; + } + + chunkType.Meshes = new List(); + + var sceneToUse = new ChunkConfiguration.ChunkScene + { + ScenePath = defaultChunkScene, + }; + + // Will only loop if there are still organelles available + while (organellesAvailableEnumerator.MoveNext() && organellesAvailableEnumerator.Current != null) + { + var organelleDefinition = organellesAvailableEnumerator.Current.Definition; + + if (!string.IsNullOrEmpty(organelleDefinition.CorpseChunkScene)) + { + sceneToUse.ScenePath = organelleDefinition.CorpseChunkScene!; + } + else if (!string.IsNullOrEmpty(organelleDefinition.DisplayScene)) + { + sceneToUse.ScenePath = organelleDefinition.DisplayScene!; + sceneToUse.SceneModelPath = organelleDefinition.DisplaySceneModelPath; + } + else + { + continue; + } + + // ScenePath is always valid now here so we just break after the first organelle we were able to + // use + break; + } + + chunkType.Meshes.Add(sceneToUse); + + var position = basePosition + positionAdded; + Vector3 velocity = Vector3.Zero; + + if (customizeCallback != null) + { + velocity = customizeCallback.Invoke(ref position); + } + + // Finally spawn a chunk with the settings + + var chunk = SpawnHelpers.SpawnChunkWithoutFinalizing(worldSimulation, recorder, chunkType, + position, random, true, velocity); + + // Add to the spawn system to make these chunks limit possible number of entities + spawnSystem.NotifyExternalEntitySpawned(chunk, + Constants.MICROBE_SPAWN_RADIUS * Constants.MICROBE_SPAWN_RADIUS, 1); + + ModLoader.ModInterface.TriggerOnChunkSpawned(chunk, false); + } + } + + public void SetWorld(GameWorld world) + { + gameWorld = world; + } + + protected override void PreUpdate(float state) + { + base.PreUpdate(state); + + if (gameWorld == null) + throw new InvalidOperationException("GameWorld not set"); + } + + protected override void Update(float delta, in Entity entity) + { + ref var health = ref entity.Get(); + + if (health.DeathProcessed) + return; + + ref var cellProperties = ref entity.Get(); + if (cellProperties.CreatedMembrane != null) + { + if (health.MaxHealth <= 0) + { + GD.PrintErr("Cell doesn't have max health set"); + cellProperties.CreatedMembrane.HealthFraction = 0; + } + else + { + cellProperties.CreatedMembrane.HealthFraction = health.CurrentHealth / health.MaxHealth; + } + } + + if (health.CurrentHealth <= 0 || health.Dead) + { + // Ensure dead flag is always set, as otherwise this will cause "zombies" + health.Dead = true; + + if (HandleMicrobeDeath(ref cellProperties, entity)) + health.DeathProcessed = true; + } + } + + private bool HandleMicrobeDeath(ref CellProperties cellProperties, in Entity entity) + { + if (entity.Has()) + { + // When in a colony needs to detach + if (entity.Has()) + { + throw new NotImplementedException(); + } + + // Else, being engulfed handling is in OnKilled and OnExpelledFromEngulfment + // Dropping corpse chunks won't make sense while inside a cell (being engulfed) + // TODO: check that this is setup correctly + return true; + } + + if (entity.Has()) + { + // TODO: handle colony lead cell dying (disband the colony) + throw new NotImplementedException(); + } + + if (OnKilled(ref cellProperties, entity)) + { + // TODO: engulfed death doesn't trigger this mod interface... + ModLoader.ModInterface.TriggerOnMicrobeDied(entity, entity.Has()); + return true; + } + + return false; + } + + /// + /// Operations that should be done when this cell is killed + /// + /// + /// True when the death could be processed, false if the entity isn't ready to process the death + /// + private bool OnKilled(ref CellProperties cellProperties, in Entity entity) + { + ref var organelleContainer = ref entity.Get(); + + if (organelleContainer.Organelles == null) + { + GD.Print("Can't kill a microbe yet with uninitialized organelles"); + return false; + } + + ref var control = ref entity.Get(); + ref var physics = ref entity.Get(); + + // Reset some stuff + control.State = MicrobeState.Normal; + control.MovementDirection = new Vector3(0, 0, 0); + organelleContainer.AllOrganellesDivided = false; + + // Stop compound absorbing + ref var absorber = ref entity.Get(); + absorber.AbsorbSpeed = -1; + absorber.AbsorbRadius = -1; + + // Disable collisions + physics.SetCollisionDisableState(true); + + // TODO: should we reset the velocity here? + // ref var physicsControl = ref entity.Get(); + // physicsControl.RemoveVelocity = true; + // physicsControl.PhysicsApplied = false; + + var species = entity.Get().Species; + + // Subtract population + if (!entity.Has() && !species.PlayerSpecies) + { + gameWorld!.AlterSpeciesPopulationInCurrentPatch(species, + Constants.CREATURE_DEATH_POPULATION_LOSS, TranslationServer.Translate("DEATH")); + } + + ref var engulfable = ref entity.Get(); + + if (engulfable.PhagocytosisStep != PhagocytosisPhase.None) + { + // When dying when engulfed the normal actions don't apply + // Special handling for this is in EngulfableHelpers.OnExpelledFromEngulfment + return true; + } + + var compounds = entity.Get().Compounds; + ref var position = ref entity.Get(); + + var recorder = worldSimulation.StartRecordingEntityCommands(); + + ApplyDeathVisuals(ref cellProperties, ref organelleContainer, ref position, entity, recorder); + + var entityRecord = recorder.Record(entity); + + // Add a timed life component to make sure the entity will despawn after the death animation + entityRecord.Set(new TimedLife + { + TimeToLiveRemaining = 1 / Constants.MEMBRANE_DISSOLVE_SPEED * 2, + }); + + // Ejecting all engulfed objects on death are now handled by EngulfingSystem + + // Releasing all the agents. + + if (organelleContainer.AgentVacuoleCount > 0) + { + ReleaseAllAgents(ref position, entity, compounds, species, recorder); + } + + // Eject compounds and build costs as corpse chunks of the cell + SpawnCorpseChunks(ref organelleContainer, compounds, spawnSystem, worldSimulation, recorder, + position.Position, random, null, glucose); + + if (entity.Has()) + { + ref var callbacks = ref entity.Get(); + + // If a microbe died, notify about this. This does really important stuff if the player died before + // entering the editor + callbacks.OnReproductionStatus?.Invoke(entity, false); + } + + ref var soundPlayer = ref entity.Get(); + + soundPlayer.PlaySoundEffect("res://assets/sounds/soundeffects/microbe-death-2.ogg"); + + worldSimulation.FinishRecordingEntityCommands(recorder); + + // TODO: if we have problems with dead microbes behaving weirdly in loaded saves, uncomment the next line + // worldSimulation.ReportEntityDyingSoon(entity); + + return true; + } + + private void ReleaseAllAgents(ref WorldPosition position, in Entity entity, CompoundBag compounds, + Species species, EntityCommandRecorder recorder) + { + // To not completely deadlock in this there is a maximum limit + int createdAgents = 0; + + var amount = compounds.GetCompoundAmount(oxytoxy); + + var props = new AgentProperties(species, oxytoxy); + + while (amount > Constants.MAXIMUM_AGENT_EMISSION_AMOUNT) + { + var direction = new Vector3(random.Next(0.0f, 1.0f) * 2 - 1, + 0, random.Next(0.0f, 1.0f) * 2 - 1); + + var spawnedRecord = SpawnHelpers.SpawnAgentProjectileWithoutFinalizing(worldSimulation, recorder, + props, Constants.MAXIMUM_AGENT_EMISSION_AMOUNT, Constants.EMITTED_AGENT_LIFETIME, + position.Position, direction, Constants.MAXIMUM_AGENT_EMISSION_AMOUNT, entity); + + ModLoader.ModInterface.TriggerOnToxinEmitted(spawnedRecord); + + amount -= Constants.MAXIMUM_AGENT_EMISSION_AMOUNT; + ++createdAgents; + + if (createdAgents >= Constants.MAX_EMITTED_AGENTS_ON_DEATH) + break; + } + } + + private void ApplyDeathVisuals(ref CellProperties cellProperties, ref OrganelleContainer organelleContainer, + ref WorldPosition position, in Entity entity, EntityCommandRecorder recorder) + { + // Spawn cell death particles. + float radius = 1; + + if (cellProperties.CreatedMembrane != null) + { + radius = cellProperties.CreatedMembrane.EncompassingCircleRadius; + + if (cellProperties.IsBacteria) + radius *= 0.5f; + } + + SpawnHelpers.SpawnCellBurstEffectWithoutFinalizing(recorder, worldSimulation, position.Position, radius); + + // Mark visuals as needing an update to have visuals system re-process this + // TODO: determine if it is necessary to re-implement the organelle hiding on death or if a fade animation + // will be fine for those + organelleContainer.OrganelleVisualsCreated = false; + + ref var shaderParameters = ref entity.Get(); + + shaderParameters.DissolveAnimationSpeed = Constants.MEMBRANE_DISSOLVE_SPEED; + shaderParameters.PlayAnimations = true; + shaderParameters.ParametersApplied = false; + } + } +} diff --git a/src/microbe_stage/systems/MicrobeEmissionSystem.cs b/src/microbe_stage/systems/MicrobeEmissionSystem.cs new file mode 100644 index 00000000000..d67c1e26485 --- /dev/null +++ b/src/microbe_stage/systems/MicrobeEmissionSystem.cs @@ -0,0 +1,232 @@ +namespace Systems +{ + using System; + using System.Linq; + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + using Godot; + using World = DefaultEcs.World; + + /// + /// Handles microbes emitting agents (toxins) or slime + /// + [With(typeof(MicrobeControl))] + [With(typeof(SpeciesMember))] + [With(typeof(OrganelleContainer))] + [With(typeof(CellProperties))] + [With(typeof(SoundEffectPlayer))] + [With(typeof(WorldPosition))] + [With(typeof(CompoundStorage))] + [ReadsComponent(typeof(Engulfable))] + [ReadsComponent(typeof(AttachedToEntity))] + [RunsBefore(typeof(MicrobeMovementSystem))] + public sealed class MicrobeEmissionSystem : AEntitySetSystem + { + private readonly IWorldSimulation worldSimulation; + private readonly CompoundCloudSystem clouds; + + private readonly Compound mucilage; + + public MicrobeEmissionSystem(IWorldSimulation worldSimulation, CompoundCloudSystem cloudSystem, World world, + IParallelRunner parallelRunner) : + base(world, parallelRunner) + { + this.worldSimulation = worldSimulation; + clouds = cloudSystem; + + mucilage = SimulationParameters.Instance.GetCompound("mucilage"); + } + + protected override void Update(float delta, in Entity entity) + { + ref var control = ref entity.Get(); + + // Reduce agent emission cooldown + control.AgentEmissionCooldown -= delta; + if (control.AgentEmissionCooldown < 0) + control.AgentEmissionCooldown = 0; + + control.SlimeSecretionCooldown -= delta; + if (control.SlimeSecretionCooldown < 0) + control.SlimeSecretionCooldown = 0; + + ref var organelles = ref entity.Get(); + ref var cellProperties = ref entity.Get(); + ref var position = ref entity.Get(); + ref var soundEffectPlayer = ref entity.Get(); + + var compounds = entity.Get().Compounds; + + bool engulfed = entity.Has() && + entity.Get().PhagocytosisStep != PhagocytosisPhase.None; + + // Fire queued agents + if (control.QueuedToxinToEmit != null) + { + EmitToxin(entity, ref control, ref organelles, ref cellProperties, ref soundEffectPlayer, ref position, + control.QueuedToxinToEmit, compounds, engulfed); + control.QueuedToxinToEmit = null; + } + + // This method itself checks for the preconditions on emitting slime + HandleSlimeSecretion(entity, ref control, ref organelles, ref cellProperties, ref soundEffectPlayer, + ref position, compounds, engulfed, delta); + } + + /// + /// Handles colony logic to determine the actual facing vector of this microbe + /// + /// A Vector3 of this microbe's real facing + private static Vector3 FacingDirection(in Entity entity, ref WorldPosition position) + { + if (entity.Has()) + { + var attachedTo = entity.Get().AttachedTo; + + if (attachedTo.Has()) + { + // Use parent rotation rather than our own to get the whole cell colony facing direction rather + // than our facing direction in world space + return attachedTo.Get().Rotation.Xform(Vector3.Forward); + } + } + + return position.Rotation.Xform(Vector3.Forward); + } + + /// + /// Tries to fire a toxin if possible + /// + private void EmitToxin(in Entity entity, ref MicrobeControl control, ref OrganelleContainer organelles, + ref CellProperties cellProperties, ref SoundEffectPlayer soundEffectPlayer, ref WorldPosition position, + Compound agentType, CompoundBag compounds, bool engulfed) + { + if (engulfed) + return; + + if (entity.Has()) + { + throw new NotImplementedException(); + + // PerformForOtherColonyMembersIfWeAreLeader(m => m.EmitToxin(agentType)); + } + + if (control.AgentEmissionCooldown > 0) + return; + + // Only shoot if you have an agent vacuole. + if (organelles.AgentVacuoleCount < 1) + return; + + // Can't shoot if membrane is not ready + if (!cellProperties.IsMembraneReady()) + return; + + float amountAvailable = compounds.GetCompoundAmount(agentType); + + // Emit as much as you have, but don't start the cooldown if that's zero + float amountEmitted = Math.Min(amountAvailable, Constants.MAXIMUM_AGENT_EMISSION_AMOUNT); + if (amountEmitted < Constants.MINIMUM_AGENT_EMISSION_AMOUNT) + return; + + // TODO: the above part is already implemented as extension for PlayerMicrobeInput + + compounds.TakeCompound(agentType, amountEmitted); + + // The cooldown time is inversely proportional to the amount of agent vacuoles. + control.AgentEmissionCooldown = Constants.AGENT_EMISSION_COOLDOWN / organelles.AgentVacuoleCount; + + float ejectionDistance = cellProperties.CreatedMembrane!.EncompassingCircleRadius + + Constants.AGENT_EMISSION_DISTANCE_OFFSET; + + if (cellProperties.IsBacteria) + ejectionDistance *= 0.5f; + + // Find the direction the microbe is facing + // (actual rotation, not LookAtPoint, also takes colony membership into account and uses the + // parent rotation) + var direction = FacingDirection(entity, ref position); + + var emissionPosition = position.Position + (direction * ejectionDistance); + + var agent = SpawnHelpers.SpawnAgentProjectile(worldSimulation, + new AgentProperties(entity.Get().Species, agentType), amountEmitted, + Constants.EMITTED_AGENT_LIFETIME, emissionPosition, direction, amountEmitted, entity); + + ModLoader.ModInterface.TriggerOnToxinEmitted(agent); + + if (amountEmitted < Constants.MAXIMUM_AGENT_EMISSION_AMOUNT / 2) + { + soundEffectPlayer.PlaySoundEffect("res://assets/sounds/soundeffects/microbe-release-toxin-low.ogg"); + } + else + { + soundEffectPlayer.PlaySoundEffect("res://assets/sounds/soundeffects/microbe-release-toxin.ogg"); + } + } + + private void HandleSlimeSecretion(in Entity entity, ref MicrobeControl control, + ref OrganelleContainer organelles, ref CellProperties cellProperties, + ref SoundEffectPlayer soundEffectPlayer, ref WorldPosition worldPosition, + CompoundBag compounds, bool engulfed, float delta) + { + // Ignore if we have no slime jets + if (organelles.SlimeJets == null) + return; + + int jetCount = organelles.SlimeJets.Count; + + if (jetCount < 1) + return; + + // Start a cooldown timer if we're out of mucilage to prevent visible trails or puffs when empty. + // Scaling by slime jet count ensures we aren't producing mucilage fast enough to beat this check. + if (compounds.GetCompoundAmount(mucilage) < Constants.MUCILAGE_MIN_TO_VENT * jetCount) + control.SlimeSecretionCooldown = Constants.MUCILAGE_COOLDOWN_TIMER; + + // Don't emit slime when engulfed + if (engulfed) + control.QueuedSlimeSecretionTime = 0; + + // If we've been told to secrete slime and can do it, proceed + if (control.QueuedSlimeSecretionTime > 0 && control.SlimeSecretionCooldown <= 0) + { + // Play a sound only if we've just started, i.e. only if no jets are already active + if (organelles.SlimeJets.All(c => !c.Active)) + soundEffectPlayer.PlaySoundEffect("res://assets/sounds/soundeffects/microbe-slime-jet.ogg"); + + // Activate all jets, which will constantly secrete slime until we turn them off + foreach (var jet in organelles.SlimeJets) + { + // Make sure this is animating + jet.Active = true; + + // Secrete the slime + float slimeToSecrete = Math.Min(Constants.COMPOUNDS_TO_VENT_PER_SECOND * delta, + compounds.GetCompoundAmount(mucilage)); + + var direction = jet.GetDirection(); + + // Eject mucilage at the maximum rate in the opposite direction to this organelle's rotation + slimeToSecrete = cellProperties.EjectCompound(ref worldPosition, compounds, clouds, mucilage, + slimeToSecrete, -direction, 2); + + // Queue movement force to be used by the movement system based on the amount of slime ejected + jet.AddQueuedForce(entity, slimeToSecrete); + } + } + else + { + // Deactivate the jets if we aren't supposed to secrete slime + foreach (var jet in organelles.SlimeJets) + jet.Active = false; + } + + control.QueuedSlimeSecretionTime -= delta; + if (control.QueuedSlimeSecretionTime < 0) + control.QueuedSlimeSecretionTime = 0; + } + } +} diff --git a/src/microbe_stage/systems/MicrobeEventCallbackSystem.cs b/src/microbe_stage/systems/MicrobeEventCallbackSystem.cs new file mode 100644 index 00000000000..f77cde174c0 --- /dev/null +++ b/src/microbe_stage/systems/MicrobeEventCallbackSystem.cs @@ -0,0 +1,106 @@ +namespace Systems +{ + using System.Collections.Generic; + using Components; + using DefaultEcs; + using DefaultEcs.System; + using Godot; + using World = DefaultEcs.World; + + /// + /// Handles the various that are not handled directly by other systems. + /// This is mostly used just for the player + /// + [With(typeof(MicrobeEventCallbacks))] + [With(typeof(MicrobeStatus))] + [With(typeof(Health))] + [With(typeof(WorldPosition))] + [RunsBefore(typeof(DamageSoundSystem))] + [RunsAfter(typeof(OrganelleTickSystem))] + [RunsAfter(typeof(MicrobeAISystem))] + [RunsOnMainThread] + public sealed class MicrobeEventCallbackSystem : AEntitySetSystem + { + private readonly IReadonlyCompoundClouds compoundClouds; + private readonly ISpeciesMemberLocationData microbeLocationData; + + public MicrobeEventCallbackSystem(IReadonlyCompoundClouds compoundClouds, + ISpeciesMemberLocationData microbeLocationData, World world) : + base(world, null) + { + this.compoundClouds = compoundClouds; + this.microbeLocationData = microbeLocationData; + } + + protected override void Update(float delta, in Entity entity) + { + ref var callbacks = ref entity.Get(); + ref var status = ref entity.Get(); + ref var health = ref entity.Get(); + + // Don't run callbacks for dead cells + if (health.Dead) + return; + + HandleChemoreceptorLines(entity, ref status, ref callbacks, delta); + + // Damage callbacks + var damage = health.RecentDamageReceived; + + if (damage != null) + { + lock (damage) + { + ProcessDamageEvents(entity, damage); + } + } + } + + private void HandleChemoreceptorLines(in Entity entity, ref MicrobeStatus status, + ref MicrobeEventCallbacks callbacks, float delta) + { + if (callbacks.OnChemoreceptionInfo == null) + return; + + status.TimeUntilChemoreceptionUpdate -= delta; + + if (status.TimeUntilChemoreceptionUpdate > 0) + return; + + status.TimeUntilChemoreceptionUpdate = Constants.CHEMORECEPTOR_SEARCH_UPDATE_INTERVAL; + + if (!entity.Has()) + { + GD.PrintErr($"Entity wanting chemoreception callback is missing {nameof(OrganelleContainer)}"); + return; + } + + ref var organelleContainer = ref entity.Get(); + var position = entity.Get().Position; + + callbacks.OnChemoreceptionInfo.Invoke(entity, + organelleContainer.PerformCompoundDetection(entity, position, compoundClouds), + organelleContainer.PerformMicrobeDetections(entity, position, microbeLocationData)); + } + + private void ProcessDamageEvents(in Entity entity, List damageEvents) + { + foreach (var damageEvent in damageEvents) + { + if (damageEvent.DamageSource is "toxin") + { + // TODO: fix this, currently "toxin" is used both by microbes and chunks, as well as damage from + // ingested toxins + // OnNoticeMessage?.Invoke(this, + // new SimpleHUDMessage(TranslationServer.Translate("NOTICE_DAMAGED_BY_ENVIRONMENTAL_TOXIN"))); + } + else if (damageEvent.DamageSource == "atpDamage") + { + entity.SendNoticeIfPossible(() => + new SimpleHUDMessage(TranslationServer.Translate("NOTICE_DAMAGED_BY_NO_ATP"), + DisplayDuration.Short)); + } + } + } + } +} diff --git a/src/microbe_stage/systems/MicrobeFlashingSystem.cs b/src/microbe_stage/systems/MicrobeFlashingSystem.cs new file mode 100644 index 00000000000..45633d18306 --- /dev/null +++ b/src/microbe_stage/systems/MicrobeFlashingSystem.cs @@ -0,0 +1,84 @@ +namespace Systems +{ + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + using Godot; + using World = DefaultEcs.World; + + /// + /// Handles flashing microbes different colour based on the mode they are in or if they are taking damage. Needs + /// to run before the damage events are cleared. + /// + [With(typeof(MicrobeControl))] + [With(typeof(ColourAnimation))] + [With(typeof(Health))] + [With(typeof(Selectable))] + [RunsAfter(typeof(OsmoregulationAndHealingSystem))] + [RunsBefore(typeof(DamageSoundSystem))] + public sealed class MicrobeFlashingSystem : AEntitySetSystem + { + public MicrobeFlashingSystem(World world, IParallelRunner parallelRunner) : base(world, parallelRunner) + { + } + + protected override void Update(float delta, in Entity entity) + { + ref var animation = ref entity.Get(); + + if (HasReceivedDamage(entity)) + { + // Flash the microbe red + animation.Flash(new Color(1, 0, 0, 0.5f), Constants.MICROBE_FLASH_DURATION, 1); + return; + } + + // Flash based on current state of the microbe + ref var control = ref entity.Get(); + + switch (control.State) + { + default: + case MicrobeState.Normal: + break; + case MicrobeState.Binding: + animation.Flash(new Color(0.2f, 0.5f, 0.0f, 0.5f), Constants.MICROBE_FLASH_DURATION); + break; + case MicrobeState.Unbinding: + { + if (entity.Get().Selected) + { + animation.Flash(new Color(1.0f, 0.0f, 0.0f, 0.5f), Constants.MICROBE_FLASH_DURATION); + } + else + { + animation.Flash(new Color(1.0f, 0.5f, 0.2f, 0.5f), Constants.MICROBE_FLASH_DURATION); + } + + break; + } + + case MicrobeState.Engulf: + // Flash the membrane blue. + animation.Flash(new Color(0.2f, 0.5f, 1.0f, 0.5f), Constants.MICROBE_FLASH_DURATION); + break; + } + } + + private bool HasReceivedDamage(in Entity entity) + { + ref var health = ref entity.Get(); + + var damageEvents = health.RecentDamageReceived; + + if (damageEvents == null) + return false; + + lock (damageEvents) + { + return damageEvents.Count > 0; + } + } + } +} diff --git a/src/microbe_stage/systems/MicrobeMovementSoundSystem.cs b/src/microbe_stage/systems/MicrobeMovementSoundSystem.cs new file mode 100644 index 00000000000..cc24dd4d59e --- /dev/null +++ b/src/microbe_stage/systems/MicrobeMovementSoundSystem.cs @@ -0,0 +1,68 @@ +namespace Systems +{ + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + using Godot; + using World = DefaultEcs.World; + + /// + /// Handles playing (and stopping) of microbe movement sound + /// + [With(typeof(MicrobeStatus))] + [With(typeof(MicrobeControl))] + [With(typeof(Engulfable))] + [With(typeof(Physics))] + [With(typeof(SoundEffectPlayer))] + [RunsAfter(typeof(PhysicsUpdateAndPositionSystem))] + [RunsAfter(typeof(MicrobeMovementSystem))] + public sealed class MicrobeMovementSoundSystem : AEntitySetSystem + { + public MicrobeMovementSoundSystem(World world, IParallelRunner parallelRunner) : + base(world, parallelRunner) + { + } + + protected override void Update(float delta, in Entity entity) + { + ref var status = ref entity.Get(); + ref var control = ref entity.Get(); + ref var physics = ref entity.Get(); + ref var soundEffectPlayer = ref entity.Get(); + + if (control.MovementDirection != Vector3.Zero && + entity.Get().PhagocytosisStep == PhagocytosisPhase.None) + { + var acceleration = physics.Velocity - status.LastLinearVelocity; + var deltaAcceleration = (acceleration - status.LastLinearAcceleration).LengthSquared(); + + if (status.MovementSoundCooldownTimer > 0) + status.MovementSoundCooldownTimer -= delta; + + // The cell starts moving from a relatively idle velocity, so play the begin movement sound + // TODO: Account for cell turning, I can't figure out a reliable way to do that using the current + // calculation - Kasterisk + if (status.MovementSoundCooldownTimer <= 0 && + deltaAcceleration > status.LastLinearAcceleration.LengthSquared() && + status.LastLinearVelocity.LengthSquared() <= 1) + { + status.MovementSoundCooldownTimer = Constants.MICROBE_MOVEMENT_SOUND_EMIT_COOLDOWN; + soundEffectPlayer.PlaySoundEffect("res://assets/sounds/soundeffects/microbe-movement-1.ogg"); + } + + soundEffectPlayer.PlayGraduallyTurningUpLoopingSound(Constants.MICROBE_MOVEMENT_SOUND, + Constants.MICROBE_MOVEMENT_SOUND_MAX_VOLUME, Constants.MICROBE_MOVEMENT_SOUND_START_VOLUME, delta); + + status.LastLinearVelocity = physics.Velocity; + status.LastLinearAcceleration = acceleration; + } + else + { + // If not moving or this is engulfed, then start turning down the movement sound + + soundEffectPlayer.PlayGraduallyTurningDownSound(Constants.MICROBE_MOVEMENT_SOUND, delta); + } + } + } +} diff --git a/src/microbe_stage/systems/MicrobeMovementSystem.cs b/src/microbe_stage/systems/MicrobeMovementSystem.cs new file mode 100644 index 00000000000..d62d281fa33 --- /dev/null +++ b/src/microbe_stage/systems/MicrobeMovementSystem.cs @@ -0,0 +1,293 @@ +namespace Systems +{ + using System; + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + using Godot; + using World = DefaultEcs.World; + + /// + /// Handles applying to a microbe + /// + [With(typeof(MicrobeControl))] + [With(typeof(OrganelleContainer))] + [With(typeof(CellProperties))] + [With(typeof(CompoundStorage))] + [With(typeof(Physics))] + [With(typeof(WorldPosition))] + [With(typeof(Health))] + [ReadsComponent(typeof(AttachedToEntity))] + [ReadsComponent(typeof(MicrobeColony))] + [ReadsComponent(typeof(Health))] + public sealed class MicrobeMovementSystem : AEntitySetSystem + { + private readonly PhysicalWorld physicalWorld; + private readonly Compound atp; + + public MicrobeMovementSystem(PhysicalWorld physicalWorld, World world, IParallelRunner runner) : base(world, + runner) + { + this.physicalWorld = physicalWorld; + + atp = SimulationParameters.Instance.GetCompound("atp"); + } + + protected override void Update(float delta, in Entity entity) + { + ref var physics = ref entity.Get(); + + if (physics.BodyDisabled || physics.Body == null) + return; + + // Skip dead microbes being allowed to move, this is now needed as the death system keeps the physics body + // alive so velocity still moves microbes for a bit even after death + if (entity.Get().Dead) + { + // Disable control to not have the dead microbes maintain rotation or anything like that + physicalWorld.DisableMicrobeBodyControl(physics.Body); + return; + } + + ref var organelles = ref entity.Get(); + ref var control = ref entity.Get(); + + // Position is used to calculate the look direction + ref var position = ref entity.Get(); + + var lookVector = control.LookAtPoint - position.Position; + lookVector.y = 0; + + var length = lookVector.Length(); + + if (length > MathUtils.EPSILON) + { + // Normalize vector when it has a length + lookVector /= length; + } + else + { + // Without any difference with the look at point compared to the current position, default to looking + // forward + lookVector = Vector3.Forward; + } + +#if DEBUG + if (!lookVector.IsNormalized()) + throw new Exception("Look vector not normalized"); +#endif + + var up = Vector3.Up; + + // Math loaned from Godot.Transform.SetLookAt adapted to fit here and removed one extra + var column0 = up.Cross(lookVector); + var column1 = lookVector.Cross(column0); + var wantedRotation = new Basis(column0.Normalized(), column1.Normalized(), lookVector).Quat(); + +#if DEBUG + if (!wantedRotation.IsNormalized()) + throw new Exception("Created target microbe rotation is not normalized"); +#endif + + var compounds = entity.Get().Compounds; + ref var cellProperties = ref entity.Get(); + + var rotationSpeed = CalculateRotationSpeed(entity, ref organelles); + + var movementImpulse = + CalculateMovementForce(entity, ref control, ref cellProperties, ref position, ref organelles, compounds, + delta); + + physicalWorld.ApplyBodyMicrobeControl(physics.Body, movementImpulse, wantedRotation, rotationSpeed); + } + + private static float CalculateRotationSpeed(in Entity entity, ref OrganelleContainer organelles) + { + float rotationSpeed = organelles.RotationSpeed; + + // Note that cilia rotation taking ATP is in CiliaComponent class + // TODO: especially as there's a comment in there about slowing down rotation when there isn't enough ATP + // for the cilia + + // Lower value is faster rotation + if (CheatManager.Speed > 1 && entity.Has()) + rotationSpeed /= CheatManager.Speed; + + if (entity.Has()) + { + throw new NotImplementedException(); + + /*// Calculate help and extra inertia caused by the colony member cells + if (cachedColonyRotationMultiplier == null) + { + // TODO: move this to MicrobeInternalCalculations once this is needed to be shown in + // the multicellular editor + float colonyInertia = 0.1f; + float colonyRotationHelp = 0; + + foreach (var colonyMember in Colony.ColonyMembers) + { + if (colonyMember == this) + continue; + + var distance = colonyMember.Transform.origin.LengthSquared(); + + if (distance < MathUtils.EPSILON) + continue; + + colonyInertia += distance * colonyMember.MassFromOrganelles * + Constants.CELL_MOMENT_OF_INERTIA_DISTANCE_MULTIPLIER; + + // TODO: should this use the member rotation speed (which is dependent on its size and + // how many cilia there are that far away) or just a count of cilia and the distance + colonyRotationHelp += colonyMember.RotationSpeed * + Constants.CELL_COLONY_MEMBER_ROTATION_FACTOR_MULTIPLIER * Mathf.Sqrt(distance); + } + + var multiplier = colonyRotationHelp / colonyInertia; + + cachedColonyRotationMultiplier = Mathf.Clamp(multiplier, + Constants.CELL_COLONY_MIN_ROTATION_MULTIPLIER, + Constants.CELL_COLONY_MAX_ROTATION_MULTIPLIER); + } + + speed *= cachedColonyRotationMultiplier.Value; + + speed = Mathf.Clamp(speed, Constants.CELL_MIN_ROTATION, + Math.Min(ownRotation * Constants.CELL_COLONY_MAX_ROTATION_HELP, Constants.CELL_MAX_ROTATION));*/ + } + + return rotationSpeed; + } + + private Vector3 CalculateMovementForce(in Entity entity, ref MicrobeControl control, + ref CellProperties cellProperties, ref WorldPosition position, + ref OrganelleContainer organelles, CompoundBag compounds, float delta) + { + if (control.MovementDirection == Vector3.Zero) + return Vector3.Zero; + + // Ensure no cells attempt to move on the y-axis + control.MovementDirection.y = 0; + + // Normalize if length is over 1 to not allow diagonal movement to be very fast + var length = control.MovementDirection.Length(); + + // Movement direction should not be normalized *always* to allow different speeds + if (length > 1) + { + control.MovementDirection /= length; + length = 1; + } + + // Base movement force + float force = Constants.BASE_MOVEMENT_FORCE; + + // Length is multiplied here so that cells that set very slow movement speed don't need to pay the entire + // movement cost + var cost = Constants.BASE_MOVEMENT_ATP_COST * organelles.HexCount * length * delta; + + var got = compounds.TakeCompound(atp, cost); + + // Halve base movement speed if out of ATP + if (got < cost) + { + // Not enough ATP to move at full speed + force *= 0.5f; + } + + // Speed from flagella (these also take ATP otherwise they won't work) + if (organelles.ThrustComponents != null && control.MovementDirection != Vector3.Zero) + { + foreach (var flagellum in organelles.ThrustComponents) + { + force += flagellum.UseForMovement(control.MovementDirection, compounds, Quat.Identity, delta); + } + } + + if (control.MovementDirection != Vector3.Zero && entity.Has()) + { + CalculateColonyImpactOnMovementForce(ref force); + } + + if (control.SlowedBySlime) + force /= Constants.MUCILAGE_IMPEDE_FACTOR; + + // Movement modifier from engulf (this used to be handled in the engulfing code, now it's here) + if (control.State == MicrobeState.Engulf) + force *= Constants.ENGULFING_MOVEMENT_MULTIPLIER; + + force *= cellProperties.MembraneType.MovementFactor - + (cellProperties.MembraneRigidity * Constants.MEMBRANE_RIGIDITY_BASE_MOBILITY_MODIFIER); + + if (CheatManager.Speed > 1 && entity.Has()) + { + float mass = 1000; + + if (entity.Has()) + { + entity.Get().TryGetShapeMass(out mass); + } + + force *= mass / 1000.0f * CheatManager.Speed; + } + + var movementVector = control.MovementDirection * force; + + // Speed from jets (these are related to a non-rotated state of the cell so this is done before rotating + // by the transform) + if (organelles.SlimeJets is { Count: > 0 }) + { + foreach (var jet in organelles.SlimeJets) + { + if (!jet.Active) + continue; + + // It might be better to consume the queued force always but, this probably results at most in just + // one extra frame of thrust whenever the jets are engaged + jet.ConsumeMovementForce(out var jetForce); + movementVector += jetForce; + } + } + + // MovementDirection is proportional to the current cell rotation, so we need to rotate the movement + // vector to work correctly + return position.Rotation.Xform(movementVector); + } + + private void CalculateColonyImpactOnMovementForce(ref float force) + { + // TODO: movement from colony member organelles + // // Colony members have their movement update before organelle update, + // // so that the movement organelles see the direction + // // The colony master should be already updated as the movement direction is either set by the + // // player input or + // // microbe AI, neither of which will happen concurrently, so this should always get the up to + // // date value + // if (Colony != null && Colony.Master != this) + // MovementDirection = Colony.Master.MovementDirection; + + // Multiplies the movement factor as if the colony has the normal microbe speed + // Then it subtracts movement speed from 100% up to 75%(soft cap), + // using a series that converges to 1 , value = (1/2 + 1/4 + 1/8 +.....) = 1 - 1/2^n + // when specialized cells become a reality the cap could be lowered to encourage cell specialization + // int memberCount; + + throw new NotImplementedException(); + + // force *= memberCount; + // var seriesValue = 1 - 1 / (float)Math.Pow(2, memberCount - 1); + // force -= (force * 0.15f) * seriesValue; + + // // Flagella in colony members + // if (colonyMemberOrganelles.ThrustComponents != null) + // { + // foreach (var flagellum in organelles.ThrustComponents) + // { + // force += flagellum.UseForMovement(control.MovementDirection, compounds, rotationInColony, delta); + // } + // } + } + } +} diff --git a/src/microbe_stage/systems/MicrobePhysicsCreationAndSizeSystem.cs b/src/microbe_stage/systems/MicrobePhysicsCreationAndSizeSystem.cs new file mode 100644 index 00000000000..d18f7d345d6 --- /dev/null +++ b/src/microbe_stage/systems/MicrobePhysicsCreationAndSizeSystem.cs @@ -0,0 +1,302 @@ +namespace Systems +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading; + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + using Godot; + using World = DefaultEcs.World; + + /// + /// Handles creating microbe physics and handling a few house keeping tasks based on the final cell size data + /// from the membrane + /// + [With(typeof(CellProperties))] + [With(typeof(MicrobePhysicsExtraData))] + [With(typeof(OrganelleContainer))] + [With(typeof(PhysicsShapeHolder))] + [RunsAfter(typeof(MicrobeVisualsSystem))] + [WritesToComponent(typeof(CompoundAbsorber))] + public sealed class MicrobePhysicsCreationAndSizeSystem : AEntitySetSystem + { + private readonly float pilusDensity; + + private readonly ThreadLocal> + temporaryCombinedShapeData = new(() => new List<(PhysicsShape Shape, Vector3 Position, Quat Rotation)>()); + + private readonly Lazy eukaryoticPilus; + + /// + /// Scaled down pilus size for bacteria + /// + private readonly Lazy prokaryoticPilus; + + public MicrobePhysicsCreationAndSizeSystem(World world, IParallelRunner parallelRunner) : base(world, + parallelRunner) + { + pilusDensity = SimulationParameters.Instance.GetOrganelleType("pilus").Density; + + eukaryoticPilus = new Lazy(() => CreatePilusShape(Constants.PILUS_PHYSICS_SIZE)); + prokaryoticPilus = new Lazy(() => CreatePilusShape(Constants.PILUS_PHYSICS_SIZE * 0.5f)); + } + + public override void Dispose() + { + Dispose(true); + base.Dispose(); + } + + protected override void Update(float delta, in Entity entity) + { + ref var cellProperties = ref entity.Get(); + + if (cellProperties.ShapeCreated) + return; + + ref var shapeHolder = ref entity.Get(); + + // We don't skip creating a shape if there is already one as microbes can change shape, so we re-apply + // the shape if there is a previous one + + // Create a shape for an entity missing it + + var membrane = cellProperties.CreatedMembrane; + + // Wait until membrane is created (and no longer being updated) + if (!cellProperties.IsMembraneReady()) + return; + + ref var extraData = ref entity.Get(); + + // This catch is here in the very unlikely case that the membrane would throw an exception (due to being + // disposed if a microbe was deleted before it got a physics body initialized for it) + try + { + var rawData = membrane!.MembraneData.Vertices2D; + var count = membrane.MembraneData.VertexCount; + + if (count < 1) + { + GD.PrintErr("Generated membrane data has no vertices, can't create collision shape"); + return; + } + + UpdateNonPhysicsSizeData(entity, membrane.EncompassingCircleRadius, ref cellProperties); + + ref var organelles = ref entity.Get(); + + if (organelles.Organelles == null) + { + throw new InvalidOperationException( + "Organelles need to be initialized before membrane is generated for shape creation"); + } + + // TODO: shape creation could be postponed for colony members until they are detached (right now + // their bodies won't get created as they are disabled, so make sure that works and then remove this + // TODO comment) + + // If there are no pili or colony members then a single shape is enough for this microbe + bool requiresCompoundShape = false; + + if (entity.Has()) + { + requiresCompoundShape = true; + + // TODO: skip creating shape if some colony member isn't ready yet + + throw new NotImplementedException(); + } + else if (organelles.Organelles.Any(o => o.Definition.HasPilusComponent)) + { + requiresCompoundShape = true; + } + + extraData.MicrobeShapesCount = 0; + extraData.TotalShapeCount = 0; + extraData.PilusCount = 0; + + // TODO: background thread shape creation to not take up main thread time (or maybe at least the + // density calculation?) + + var oldShape = shapeHolder.Shape; + + if (!requiresCompoundShape) + { + shapeHolder.Shape = CreateSimpleMicrobeShape(ref extraData, ref organelles, ref cellProperties, + rawData, count); + } + else + { + // TODO: caching of compound shapes to make the old shape detection work + shapeHolder.Shape = CreateCompoundMicrobeShape(ref extraData, ref organelles, ref cellProperties, + entity, rawData, count); + } + + // Skip updating the physics body shape if we got the same cached shape as we had before + if (!ReferenceEquals(oldShape, shapeHolder.Shape)) + { + // Ensure physics body is recreated if the shape changed + shapeHolder.UpdateBodyShapeIfCreated = true; + } + + cellProperties.ShapeCreated = true; + } + catch (Exception e) + { + GD.PrintErr("Failed to create physics body for a microbe: " + e); + } + } + + private PhysicsShape CreateSimpleMicrobeShape(ref MicrobePhysicsExtraData extraData, + ref OrganelleContainer organelles, ref CellProperties cellProperties, + Vector2[] membraneVertices, int vertexCount) + { + var shape = PhysicsShape.GetOrCreateMicrobeShape(membraneVertices, vertexCount, + MicrobeInternalCalculations.CalculateAverageDensity(organelles.Organelles!), + cellProperties.IsBacteria); + + UpdateRotationRate(shape, ref organelles); + + ++extraData.MicrobeShapesCount; + ++extraData.TotalShapeCount; + + // Simple shape can't have pili in it + + return shape; + } + + private PhysicsShape CreateCompoundMicrobeShape(ref MicrobePhysicsExtraData extraData, + ref OrganelleContainer organelles, ref CellProperties cellProperties, in Entity entity, + Vector2[] membraneVertices, int vertexCount) + { + var combinedData = temporaryCombinedShapeData.Value; + + // Base microbe shape is always first + combinedData.Add(( + CreateSimpleMicrobeShape(ref extraData, ref organelles, ref cellProperties, membraneVertices, + vertexCount), Vector3.Zero, Quat.Identity)); + + UpdateRotationRate(combinedData[combinedData.Count - 1].Shape, ref organelles); + + // Then the (potential) colony members + if (entity.Has()) + { + // TODO: cell colony physics (and colony member pili), the bodies need to be added colony member + // list order (should skip until all colony members are initialized) + // Add to shape count first + + // TODO: colony rotation rate update / calculation + + throw new NotImplementedException(); + } + + // Pili are after the microbe shapes, otherwise pilus collision detection can't be done as we just + // compare the sub-shape index to the number of microbe collisions to determine if something is a pilus + // And to detect between the pilus variants, first normal pili are created and only then injectisomes + bool hasInjectisomes = false; + + foreach (var organelle in organelles.Organelles!) + { + if (organelle.Definition.HasPilusComponent) + { + if (organelle.Upgrades.HasInjectisomeUpgrade()) + { + hasInjectisomes = true; + continue; + } + + combinedData.Add(CreatePilusShape(ref extraData, ref cellProperties, organelle)); + } + } + + if (hasInjectisomes) + { + foreach (var organelle in organelles.Organelles) + { + if (organelle.Definition.HasPilusComponent && organelle.Upgrades.HasInjectisomeUpgrade()) + { + combinedData.Add(CreatePilusShape(ref extraData, ref cellProperties, organelle)); + ++extraData.PilusInjectisomeCount; + } + } + } + + if (extraData.TotalShapeCount != combinedData.Count) + throw new Exception("Incorrect total shape count result in microbe physics creation"); + + // Create the final shape + // This uses a static combined shape as the shapes are fully re-created each time + // TODO: investigate if modifiable combined shape would be a better fit for the game + var combinedShape = PhysicsShape.CreateCombinedShapeStatic(combinedData); + + combinedData.Clear(); + + return combinedShape; + } + + private (PhysicsShape Shape, Vector3 Position, Quat Rotation) CreatePilusShape( + ref MicrobePhysicsExtraData extraData, ref CellProperties cellProperties, + PlacedOrganelle placedOrganelle) + { + var externalPosition = cellProperties.CalculateExternalOrganellePosition(placedOrganelle.Position, + placedOrganelle.Orientation, out var rotation); + + var (position, orientation) = + placedOrganelle.CalculatePhysicsExternalTransform(externalPosition, rotation, + cellProperties.IsBacteria); + + ++extraData.PilusCount; + ++extraData.TotalShapeCount; + + return (cellProperties.IsBacteria ? prokaryoticPilus.Value : eukaryoticPilus.Value, position, orientation); + } + + /// + /// Updates the microbe movement's used rotation rate. This is here as it is more efficient to calculate this + /// when the physics shape is also done. + /// + private void UpdateRotationRate(PhysicsShape baseShape, ref OrganelleContainer organelleContainer) + { + if (organelleContainer.Organelles == null) + { + throw new InvalidOperationException( + "Can't calculate rotation rate for organelle container with no organelles"); + } + + organelleContainer.RotationSpeed = + MicrobeInternalCalculations.CalculateRotationSpeed(baseShape, organelleContainer.Organelles); + } + + private PhysicsShape CreatePilusShape(float size) + { + var radius = size / 9.0f; + + // Jolt physics also doesn't support cones so, cylinder is now the permanent pilus shape + return PhysicsShape.CreateCylinder(size * 0.5f, radius, pilusDensity); + } + + private void UpdateNonPhysicsSizeData(in Entity entity, float membraneRadius, ref CellProperties cellProperties) + { + cellProperties.UnadjustedRadius = membraneRadius; + + if (entity.Has()) + { + // Max here buffs compound absorbing for the smallest cells + entity.Get().AbsorbRadius = + Math.Max(cellProperties.Radius, Constants.MICROBE_MIN_ABSORB_RADIUS); + } + } + + private void Dispose(bool disposing) + { + if (disposing) + { + temporaryCombinedShapeData.Dispose(); + } + } + } +} diff --git a/src/microbe_stage/systems/MicrobeReproductionSystem.cs b/src/microbe_stage/systems/MicrobeReproductionSystem.cs new file mode 100644 index 00000000000..36713623744 --- /dev/null +++ b/src/microbe_stage/systems/MicrobeReproductionSystem.cs @@ -0,0 +1,505 @@ +namespace Systems +{ + using System; + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.Linq; + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + using Godot; + using World = DefaultEcs.World; + + /// + /// Handles reproduction progress in microbes that are not in aa cell colony. is + /// used to skip reproduction for engulfed cells or cells in colonies. + /// + [With(typeof(ReproductionStatus))] + [With(typeof(OrganelleContainer))] + [With(typeof(MicrobeStatus))] + [With(typeof(CompoundStorage))] + [With(typeof(CellProperties))] + [With(typeof(MicrobeSpeciesMember))] + [With(typeof(Health))] + [With(typeof(BioProcesses))] + [Without(typeof(AttachedToEntity))] + [Without(typeof(EarlyMulticellularSpeciesMember))] + public sealed class MicrobeReproductionSystem : AEntitySetSystem + { + private readonly IWorldSimulation worldSimulation; + private readonly ISpawnSystem spawnSystem; + + private readonly ConcurrentStack organellesNeedingScaleUpdate = new(); + + private GameWorld? gameWorld; + + private float reproductionDelta; + + public MicrobeReproductionSystem(IWorldSimulation worldSimulation, ISpawnSystem spawnSystem, World world, + IParallelRunner parallelRunner) : base(world, parallelRunner) + { + this.worldSimulation = worldSimulation; + this.spawnSystem = spawnSystem; + } + + public static (float RemainingAllowedCompoundUse, float RemainingFreeCompounds) + CalculateFreeCompoundsAndLimits(WorldGenerationSettings worldSettings, int hexCount, bool isMulticellular, + float delta) + { + // Skip some computations when they are not needed + if (!worldSettings.PassiveGainOfReproductionCompounds && + !worldSettings.LimitReproductionCompoundUseSpeed) + { + return (float.MaxValue, 0); + } + + // TODO: make the current patch affect this? + // TODO: make being in a colony affect this + float remainingFreeCompounds = Constants.MICROBE_REPRODUCTION_FREE_COMPOUNDS * + (hexCount * Constants.MICROBE_REPRODUCTION_FREE_RATE_FROM_HEX + 1.0f) * delta; + + if (isMulticellular) + remainingFreeCompounds *= Constants.EARLY_MULTICELLULAR_REPRODUCTION_COMPOUND_MULTIPLIER; + + float remainingAllowedCompoundUse = float.MaxValue; + + if (worldSettings.LimitReproductionCompoundUseSpeed) + { + remainingAllowedCompoundUse = remainingFreeCompounds * Constants.MICROBE_REPRODUCTION_MAX_COMPOUND_USE; + } + + // Reset the free compounds if we don't want to give free compounds. + // It was necessary to calculate for the above math to be able to use it, but we don't want it to apply when + // not enabled. + if (!worldSettings.PassiveGainOfReproductionCompounds) + { + remainingFreeCompounds = 0; + } + + return (remainingAllowedCompoundUse, remainingFreeCompounds); + } + + public void SetWorld(GameWorld world) + { + gameWorld = world; + } + + protected override void PreUpdate(float delta) + { + if (gameWorld == null) + throw new InvalidOperationException("GameWorld not set"); + + base.PreUpdate(delta); + + reproductionDelta = delta; + + // TODO: rate limit how often reproduction update is allowed to run? + // // Limit how often the reproduction logic is ran + // if (lastCheckedReproduction < Constants.MICROBE_REPRODUCTION_PROGRESS_INTERVAL) + // return; + + while (organellesNeedingScaleUpdate.TryPop(out _)) + { + GD.PrintErr("Organelles needing scale list is not empty like it should before a system run"); + } + } + + protected override void Update(float state, in Entity entity) + { + ref var health = ref entity.Get(); + + // Dead cells can't reproduce + if (health.Dead) + return; + + ref var organelles = ref entity.Get(); + + if (organelles.AllOrganellesDivided) + { + // Ready to reproduce already. Only the player gets here as other cells split and reset automatically + return; + } + + ref var status = ref entity.Get(); + + status.ConsumeReproductionCompoundsReverse = !status.ConsumeReproductionCompoundsReverse; + + bool isInColony = entity.Has(); + + if (isInColony) + { + // TODO: should the colony just passively get the reproduction compounds in its storage? + // Otherwise early multicellular colonies lose the passive reproduction feature + return; + } + + HandleNormalMicrobeReproduction(entity, ref organelles, status.ConsumeReproductionCompoundsReverse); + } + + protected override void PostUpdate(float state) + { + base.PostUpdate(state); + + // Apply scales + while (organellesNeedingScaleUpdate.TryPop(out var organelle)) + { + if (organelle.OrganelleGraphics == null) + continue; + + // The parent node of the organelle graphics is what needs to be scaled + // TODO: check if it would be better to just store this node directly in the PlacedOrganelle to not + // re-read it like this + var nodeToScale = organelle.OrganelleGraphics.GetParentSpatial(); + + if (!organelle.Definition.PositionedExternally) + { + nodeToScale.Transform = organelle.CalculateVisualsTransform(); + } + else + { + // TODO: handle this somehow... (probably caching the position and rotation from last call in + // the visuals system?) + throw new NotImplementedException(); + + // nodeToScale.Transform = organelle.CalculateVisualsTransformExternal(); + } + } + } + + /// + /// Handles feeding the organelles in a microbe in order for them to split. After all are split the microbe + /// is ready to reproduce. This is allowed to be called only for non-multicellular growth only (and not in + /// a cell colony) + /// + /// + /// + /// AI cells will immediately reproduce when they can. On the player cell the editor is unlocked when + /// reproducing is possible. + /// + /// + private void HandleNormalMicrobeReproduction(in Entity entity, ref OrganelleContainer organelles, + bool consumeInReverseOrder) + { + // Skip not initialized microbes yet + if (organelles.Organelles == null) + return; + + var (remainingAllowedCompoundUse, remainingFreeCompounds) = + CalculateFreeCompoundsAndLimits(gameWorld!.WorldSettings, organelles.HexCount, false, + reproductionDelta); + + ref var storage = ref entity.Get(); + var compounds = storage.Compounds; + + ref var baseReproduction = ref entity.Get(); + + // Process base cost first so the player can be their designed cell (without extra organelles) for a while + bool reproductionStageComplete = + ProcessBaseReproductionCost(baseReproduction.MissingCompoundsForBaseReproduction, compounds, + ref remainingAllowedCompoundUse, ref remainingFreeCompounds, + consumeInReverseOrder); + + // For this stage and all others below, reproductionStageComplete tracks whether the previous reproduction + // stage completed, i.e. whether we should proceed with the next stage + if (reproductionStageComplete) + { + // Organelles that are ready to split + var organellesToAdd = new List(); + + // Grow all the organelles, except the unique organelles which are given compounds last + foreach (var organelle in organelles.Organelles) + { + // Check if already done + if (organelle.WasSplit) + continue; + + // If we ran out of allowed compound use, stop early + if (remainingAllowedCompoundUse <= 0) + { + reproductionStageComplete = false; + break; + } + + // We are in G1 phase of the cell cycle, duplicate all organelles. + + // Except the unique organelles + if (organelle.Definition.Unique) + continue; + + // Give it some compounds to make it larger. + bool grown = organelle.GrowOrganelle(compounds, ref remainingAllowedCompoundUse, + ref remainingFreeCompounds, consumeInReverseOrder); + + if (organelle.GrowthValue >= 1.0f) + { + // Queue this organelle for splitting after the loop. + organellesToAdd.Add(organelle); + } + else + { + // Needs more stuff + reproductionStageComplete = false; + + // When not splitting, just the scale needs to be potentially updated + if (grown) + { + organellesNeedingScaleUpdate.Push(organelle); + } + } + + // TODO: can we quit this loop early if we still would have dozens of organelles to check but + // don't have any compounds left to give them (that are probably useful)? + } + + // Splitting the queued organelles. + foreach (var organelle in organellesToAdd) + { + // Mark this organelle as done and return to its normal size. + organelle.ResetGrowth(); + + // This doesn't need to update individual scales as a full organelles change is queued below for + // a different system to handle + + organelle.WasSplit = true; + + // Create a second organelle. + var organelle2 = SplitOrganelle(organelles.Organelles!, organelle); + organelle2.WasSplit = true; + organelle2.IsDuplicate = true; + organelle2.SisterOrganelle = organelle; + + organelles.OnOrganellesChanged(ref storage, ref entity.Get()); + } + } + + if (reproductionStageComplete) + { + foreach (var organelle in organelles.Organelles!) + { + // In the second phase all unique organelles are given compounds + // It used to be that only the nucleus was given compounds here + if (!organelle.Definition.Unique) + continue; + + // If we ran out of allowed compound use, stop early + if (remainingAllowedCompoundUse <= 0) + { + reproductionStageComplete = false; + break; + } + + // Unique organelles don't split so we use the growth value to know when something is fully grown + if (organelle.GrowthValue < 1.0f) + { + if (organelle.GrowOrganelle(compounds, ref remainingAllowedCompoundUse, + ref remainingFreeCompounds, + consumeInReverseOrder)) + { + organellesNeedingScaleUpdate.Push(organelle); + } + + // Nucleus (or another unique organelle) needs more compounds + reproductionStageComplete = false; + } + } + } + + if (reproductionStageComplete) + { + // All organelles and base reproduction cost is now fulfilled, we are fully ready to reproduce + organelles.AllOrganellesDivided = true; + + // For NPC cells this immediately splits them and the allOrganellesDivided flag is reset + ReadyToReproduce(entity, ref organelles); + } + } + + private bool ProcessBaseReproductionCost(Dictionary? requiredCompoundsForBaseReproduction, + CompoundBag compounds, ref float remainingAllowedCompoundUse, + ref float remainingFreeCompounds, bool consumeInReverseOrder, + Dictionary? trackCompoundUse = null) + { + // If no info created yet we don't know if we are done + if (requiredCompoundsForBaseReproduction == null) + return false; + + if (remainingAllowedCompoundUse <= 0) + { + return false; + } + + bool reproductionStageComplete = true; + + foreach (var key in consumeInReverseOrder ? + requiredCompoundsForBaseReproduction.Keys.Reverse() : + requiredCompoundsForBaseReproduction.Keys) + { + var amountNeeded = requiredCompoundsForBaseReproduction[key]; + + if (amountNeeded <= 0.0f) + continue; + + // TODO: the following is very similar code to PlacedOrganelle.GrowOrganelle + float usedAmount = 0; + + float allowedUseAmount = Math.Min(amountNeeded, remainingAllowedCompoundUse); + + if (remainingFreeCompounds > 0) + { + var usedFreeCompounds = Math.Min(allowedUseAmount, remainingFreeCompounds); + usedAmount += usedFreeCompounds; + allowedUseAmount -= usedFreeCompounds; + remainingFreeCompounds -= usedFreeCompounds; + } + + // For consistency we apply the ORGANELLE_GROW_STORAGE_MUST_HAVE_AT_LEAST constant here like for + // organelle growth + var amountAvailable = + compounds.GetCompoundAmount(key) - Constants.ORGANELLE_GROW_STORAGE_MUST_HAVE_AT_LEAST; + + if (amountAvailable > MathUtils.EPSILON) + { + // We can take some + var amountToTake = Mathf.Min(allowedUseAmount, amountAvailable); + + usedAmount += compounds.TakeCompound(key, amountToTake); + } + + if (usedAmount < MathUtils.EPSILON) + continue; + + remainingAllowedCompoundUse -= usedAmount; + + if (trackCompoundUse != null) + { + trackCompoundUse.TryGetValue(key, out var trackedAlreadyUsed); + trackCompoundUse[key] = trackedAlreadyUsed + usedAmount; + } + + var left = amountNeeded - usedAmount; + + if (left < 0.0001f) + { + // We don't remove these values even when empty as we rely on detecting this being empty for earlier + // save compatibility, so we just leave 0 values in requiredCompoundsForBaseReproduction + left = 0; + } + + requiredCompoundsForBaseReproduction[key] = left; + + // As we don't make duplicate lists, we can only process a single type per call + // So we can't know here if we are fully ready + reproductionStageComplete = false; + break; + } + + return reproductionStageComplete; + } + + private PlacedOrganelle SplitOrganelle(OrganelleLayout organelles, PlacedOrganelle organelle) + { + var q = organelle.Position.Q; + var r = organelle.Position.R; + + // The position used here will be overridden with the right value when we manage to find a place + // for this organelle + var newOrganelle = new PlacedOrganelle(organelle.Definition, new Hex(q, r), 0, organelle.Upgrades); + + // Spiral search for space for the organelle + int radius = 1; + while (true) + { + // Moves into the ring of radius "radius" and center the old organelle + var radiusOffset = Hex.HexNeighbourOffset[Hex.HexSide.BottomLeft]; + q += radiusOffset.Q; + r += radiusOffset.R; + + // Iterates in the ring + for (int side = 1; side <= 6; ++side) + { + var offset = Hex.HexNeighbourOffset[(Hex.HexSide)side]; + + // Moves "radius" times into each direction + for (int i = 1; i <= radius; ++i) + { + q += offset.Q; + r += offset.R; + + // Checks every possible rotation value. + for (int j = 0; j <= 5; ++j) + { + newOrganelle.Position = new Hex(q, r); + + // TODO: in the old code this was always i * + // 60 so this didn't actually do what it meant + // to do. But perhaps that was right? This is + // now fixed to actually try the different + // rotations. + newOrganelle.Orientation = j; + if (organelles.CanPlace(newOrganelle)) + { + organelles.Add(newOrganelle); + return newOrganelle; + } + } + } + } + + ++radius; + } + } + + /// + /// Called when a microbe is ready to reproduce. Divides this microbe (if this isn't the player). + /// + private void ReadyToReproduce(in Entity entity, ref OrganelleContainer organelles) + { + Action? reproductionCallback; + if (entity.Has()) + { + ref var callbacks = ref entity.Get(); + reproductionCallback = callbacks.OnReproductionStatus; + } + else + { + reproductionCallback = null; + } + + // Entities with a reproduction callback don't divide automatically + if (reproductionCallback != null) + { + // The player doesn't split automatically + organelles.AllOrganellesDivided = true; + + reproductionCallback.Invoke(entity, true); + } + else + { + // Skip reproducing if we would go too much over the entity limit + if (!spawnSystem.IsUnderEntityLimitForReproducing()) + { + // Set this to false so that we re-check in a few frames if we can reproduce then + organelles.AllOrganellesDivided = false; + return; + } + + var species = entity.Get().Species; + + if (!species.PlayerSpecies) + { + gameWorld!.AlterSpeciesPopulationInCurrentPatch(species, + Constants.CREATURE_REPRODUCE_POPULATION_GAIN, TranslationServer.Translate("REPRODUCED")); + } + + ref var cellProperties = ref entity.Get(); + + // Return the first cell to its normal, non duplicated cell arrangement and spawn a daughter cell + organelles.ResetOrganelleLayout(ref entity.Get(), ref entity.Get(), + entity, species, species); + + cellProperties.Divide(ref organelles, entity, species, worldSimulation, spawnSystem, null); + } + } + } +} diff --git a/src/microbe_stage/systems/MicrobeShaderSystem.cs b/src/microbe_stage/systems/MicrobeShaderSystem.cs new file mode 100644 index 00000000000..fb92d317687 --- /dev/null +++ b/src/microbe_stage/systems/MicrobeShaderSystem.cs @@ -0,0 +1,74 @@ +namespace Systems +{ + using Components; + using DefaultEcs; + using DefaultEcs.System; + using Godot; + using World = DefaultEcs.World; + + /// + /// Handles things related to . This should run each frame and pause when + /// the game is paused. + /// + [With(typeof(MicrobeShaderParameters))] + [With(typeof(EntityMaterial))] + [RunsOnFrame] + public sealed class MicrobeShaderSystem : AEntitySetSystem + { + // private readonly Lazy noiseTexture = GD.Load("res://assets/textures/dissolve_noise.tres"); + + public MicrobeShaderSystem(World world) : base(world, null) + { + } + + protected override void Update(float delta, in Entity entity) + { + ref var shaderParameters = ref entity.Get(); + + if (shaderParameters.ParametersApplied && !shaderParameters.PlayAnimations) + return; + + if (shaderParameters.PlayAnimations) + { + if (shaderParameters.DissolveAnimationSpeed != 0) + { + if (float.IsNaN(shaderParameters.DissolveValue)) + { + GD.PrintErr("Correcting NaN as dissolve shader parameter"); + shaderParameters.DissolveValue = 0; + } + else if (shaderParameters.DissolveValue < 1) + { + shaderParameters.DissolveValue += shaderParameters.DissolveAnimationSpeed * delta; + + if (shaderParameters.DissolveValue > 1) + shaderParameters.DissolveValue = 1; + } + } + else + { + GD.PrintErr("Entity has incorrectly enabled animations playing but no animation flag " + + "is turned on"); + shaderParameters.PlayAnimations = false; + } + } + + ref var entityMaterial = ref entity.Get(); + + // Wait for the material to be defined + if (entityMaterial.Materials == null) + return; + + foreach (var material in entityMaterial.Materials) + { + material.SetShaderParam("dissolveValue", shaderParameters.DissolveValue); + } + + // TODO: remove this and the lazy value if unnecessary (if necessary this should be applied just once and + // not each frame) + // entityMaterial.Material.SetShaderParam("dissolveTexture", noiseTexture); + + shaderParameters.ParametersApplied = true; + } + } +} diff --git a/src/microbe_stage/systems/MicrobeVisualsSystem.cs b/src/microbe_stage/systems/MicrobeVisualsSystem.cs new file mode 100644 index 00000000000..471e3d755b3 --- /dev/null +++ b/src/microbe_stage/systems/MicrobeVisualsSystem.cs @@ -0,0 +1,401 @@ +namespace Systems +{ + using System; + using System.Buffers; + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + using Components; + using DefaultEcs; + using DefaultEcs.System; + using Godot; + using World = DefaultEcs.World; + + /// + /// Generates the visuals needed for microbes. Handles the membrane and organelle graphics. Attaching to the + /// Godot scene tree is handled by + /// + [With(typeof(OrganelleContainer))] + [With(typeof(CellProperties))] + [With(typeof(SpatialInstance))] + [With(typeof(EntityMaterial))] + [RunsBefore(typeof(SpatialAttachSystem))] + [RunsOnMainThread] + public sealed class MicrobeVisualsSystem : AEntitySetSystem + { + private readonly Lazy membraneScene = + new(() => GD.Load("res://src/microbe_stage/Membrane.tscn")); + + private readonly List tempMaterialsList = new(); + private readonly List tempVisualsToDelete = new(); + + /// + /// Used to detect which organelle graphics are no longer used and should be deleted + /// + private readonly HashSet inUseOrganelles = new(); + + private readonly ConcurrentQueue membranesToGenerate = new(); + + /// + /// Used to avoid requesting the same membrane data to be generated multiple times + /// + private readonly HashSet pendingGenerationsOfMembraneHashes = new(); + + /// + /// Keeps track of generated tasks, just to allow disposing this object safely by waiting for them all + /// + private readonly List activeGenerationTasks = new(); + + private int runningMembraneTaskCount; + + public MicrobeVisualsSystem(World world) : base(world, null) + { + } + + public override void Dispose() + { + base.Dispose(); + + var maxWait = TimeSpan.FromSeconds(10); + foreach (var task in activeGenerationTasks) + { + if (!task.Wait(maxWait)) + { + GD.PrintErr("Failed to wait for a background membrane generation task to finish on " + + "dispose"); + } + } + + activeGenerationTasks.Clear(); + } + + protected override void PreUpdate(float delta) + { + base.PreUpdate(delta); + + activeGenerationTasks.RemoveAll(t => t.IsCompleted); + } + + protected override void Update(float delta, in Entity entity) + { + ref var organelleContainer = ref entity.Get(); + + if (organelleContainer.OrganelleVisualsCreated) + return; + + // Skip if no organelle data + if (organelleContainer.Organelles == null) + { + GD.PrintErr("Missing organelles list for MicrobeVisualsSystem"); + return; + } + + ref var cellProperties = ref entity.Get(); + + ref var spatialInstance = ref entity.Get(); + + // Create graphics top level node if missing for entity + spatialInstance.GraphicalInstance ??= new Spatial(); + + // Bacteria is 50% of the scale of other microbes + spatialInstance.GraphicalInstance.Scale = + cellProperties.IsBacteria ? new Vector3(0.5f, 0.5f, 0.5f) : Vector3.One; + + ref var materialStorage = ref entity.Get(); + + // Background thread membrane generation + var data = GetMembraneDataIfReadyOrStartGenerating(ref cellProperties, ref organelleContainer); + + if (data == null) + { + if (cellProperties.CreatedMembrane != null) + { + // Let other users of the membrane know that we are in the process of re-creating the shape + cellProperties.CreatedMembrane.IsChangingShape = true; + } + + // Need to wait for membrane generation. Organelle visuals aren't created yet even if they could be + // to avoid the organelles popping in before the membrane. + return; + } + + if (cellProperties.CreatedMembrane == null) + { + var membrane = membraneScene.Value.Instance() ?? + throw new Exception("Invalid membrane scene"); + + SetMembraneDisplayData(membrane, data, ref cellProperties); + + spatialInstance.GraphicalInstance.AddChild(membrane); + cellProperties.CreatedMembrane = membrane; + } + else + { + // Existing membrane should have its properties updated to make sure they are up to date + // For example an engulfed cell has its membrane wigglyness removed + SetMembraneDisplayData(cellProperties.CreatedMembrane, data, ref cellProperties); + } + + // Material is initialized in _Ready so this is after AddChild of membrane + tempMaterialsList.Add( + cellProperties.CreatedMembrane!.MaterialToEdit ?? + throw new Exception("Membrane didn't set material to edit")); + + // TODO: should this hide organelles when the microbe is dead? (hiding / deleting organelle instances is + // also talked about in the microbe death system) + + CreateOrganelleVisuals(spatialInstance.GraphicalInstance, ref organelleContainer, ref cellProperties); + + materialStorage.Materials = tempMaterialsList.ToArray(); + tempMaterialsList.Clear(); + + organelleContainer.OrganelleVisualsCreated = true; + + // Force recreation of physics body in case organelles changed to make sure the shape matches growth status + cellProperties.ShapeCreated = false; + } + + protected override void PostUpdate(float state) + { + base.PostUpdate(state); + + // TODO: if we need a separate mechanism to communicate our results back, then cleaning up that mechanism + // here and in on PreUpdate will be needed + // // Clear any ready resources that weren't required to not keep them forever (but only ones that were + // // ready in PreUpdate to ensure no resources that managed to finish while update was running are lost) + + // Ensure we have at least some tasks running even if no new membrane generation requests were started + // this frame + lock (pendingGenerationsOfMembraneHashes) + { + if (pendingGenerationsOfMembraneHashes.Count > runningMembraneTaskCount / 2 || + (runningMembraneTaskCount <= 0 && pendingGenerationsOfMembraneHashes.Count > 0)) + { + StartMembraneGenerationJobs(); + } + } + } + + private MembranePointData? GetMembraneDataIfReadyOrStartGenerating(ref CellProperties cellProperties, + ref OrganelleContainer organelleContainer) + { + // TODO: should we consider the situation where a membrane was requested on the previous update but is not + // ready yet? This causes extra memory usage here in those cases. + var hexes = MembraneComputationHelpers.PrepareHexPositionsForMembraneCalculations( + organelleContainer.Organelles!.Organelles, out var hexCount); + + var hash = MembraneComputationHelpers.ComputeMembraneDataHash(hexes, hexCount, cellProperties.MembraneType); + + var cachedMembrane = ProceduralDataCache.Instance.ReadMembraneData(hash); + + if (cachedMembrane != null) + { + // TODO: hopefully this can't get into a permanent loop where 2 conflicting membranes want to + // re-generate on each game update cycle + if (!cachedMembrane.MembraneDataFieldsEqual(hexes, hexCount, cellProperties.MembraneType)) + { + CacheableDataExtensions.OnCacheHashCollision(hash); + cachedMembrane = null; + } + } + + if (cachedMembrane != null) + { + // Membrane was ready now + return cachedMembrane; + } + + // Need to generate a new membrane + + lock (pendingGenerationsOfMembraneHashes) + { + if (!pendingGenerationsOfMembraneHashes.Add(hash)) + { + // Already queued, don't need to queue again + + // Return the unnecessary array that there won't be a cache entry to hold to the pool + ArrayPool.Shared.Return(hexes); + + return null; + } + } + + membranesToGenerate.Enqueue(new MembraneGenerationParameters(hexes, hexCount, cellProperties.MembraneType)); + + // Immediately start some jobs to give background threads something to do while the main thread is busy + // potentially setting up other visuals + StartMembraneGenerationJobs(); + + return null; + } + + private void SetMembraneDisplayData(Membrane membrane, MembranePointData cacheData, + ref CellProperties cellProperties) + { + membrane.MembraneData = cacheData; + +#if DEBUG + if (membrane.IsChangingShape) + throw new Exception("This field should have been reset automatically"); +#endif + + // TODO: this shouldn't override membrane wigglyness if it was set to 0 due to being engulfed (thankfully + // it's probably the case that visuals aren't currently updated while something is engulfed) + cellProperties.ApplyMembraneWigglyness(membrane); + } + + private void CreateOrganelleVisuals(Spatial parentNode, ref OrganelleContainer organelleContainer, + ref CellProperties cellProperties) + { + organelleContainer.CreatedOrganelleVisuals ??= new Dictionary(); + + var organelleColour = PlacedOrganelle.CalculateHSVForOrganelle(cellProperties.Colour); + + foreach (var placedOrganelle in organelleContainer.Organelles!) + { + // Only handle organelles that have graphics + if (placedOrganelle.Definition.LoadedScene == null) + continue; + + inUseOrganelles.Add(placedOrganelle); + + Transform transform; + + if (!placedOrganelle.Definition.PositionedExternally) + { + // Get the transform with right scale (growth) and position + transform = placedOrganelle.CalculateVisualsTransform(); + } + else + { + // Positioned externally + var externalPosition = cellProperties.CalculateExternalOrganellePosition(placedOrganelle.Position, + placedOrganelle.Orientation, out var rotation); + + transform = placedOrganelle.CalculateVisualsTransformExternal(externalPosition, rotation); + } + + if (!organelleContainer.CreatedOrganelleVisuals.ContainsKey(placedOrganelle)) + { + // New visuals needed + + // TODO: slime jet handling (and other animation controlled organelles handling) + + // For organelle visuals to work, they need to be wrapped in an extra layer of Spatial to not + // mess with the normal scale that is used by many organelle scenes + var extraLayer = new Spatial + { + Transform = transform, + }; + + var visualsInstance = placedOrganelle.Definition.LoadedScene.Instance(); + placedOrganelle.ReportCreatedGraphics(visualsInstance); + + extraLayer.AddChild(visualsInstance); + parentNode.AddChild(extraLayer); + + organelleContainer.CreatedOrganelleVisuals.Add(placedOrganelle, visualsInstance); + } + + // Visuals already exist + var graphics = placedOrganelle.OrganelleGraphics; + + if (graphics == null) + throw new Exception("Organelle graphics should not get reset to null"); + + // Materials need to be always fully fetched again to make sure we don't forget any active ones + int start = tempMaterialsList.Count; + if (graphics is OrganelleMeshWithChildren organelleMeshWithChildren) + { + organelleMeshWithChildren.GetChildrenMaterials(tempMaterialsList); + } + + var material = graphics.GetMaterial(placedOrganelle.Definition.DisplaySceneModelPath); + tempMaterialsList.Add(material); + + // Apply tint (again) to make sure it is up to date + int count = tempMaterialsList.Count; + for (int i = start; i < count; ++i) + { + tempMaterialsList[i].SetShaderParam("tint", organelleColour); + } + + // TODO: render order? + } + + // Delete unused visuals + foreach (var entry in organelleContainer.CreatedOrganelleVisuals) + { + if (!inUseOrganelles.Contains(entry.Key)) + { + entry.Value.QueueFree(); + tempVisualsToDelete.Add(entry.Key); + } + } + + foreach (var toDelete in tempVisualsToDelete) + { + organelleContainer.CreatedOrganelleVisuals.Remove(toDelete); + } + + inUseOrganelles.Clear(); + tempVisualsToDelete.Clear(); + } + + /// + /// Starts more membrane generation task instances if it makes sense to do so + /// + private void StartMembraneGenerationJobs() + { + var executor = TaskExecutor.Instance; + + // Limit concurrent tasks + int max = Math.Max(1, executor.ParallelTasks - Constants.MEMBRANE_TASKS_LEAVE_EMPTY_THREADS); + if (runningMembraneTaskCount + 1 >= max) + return; + + // Don't uselessly spawn too many tasks + if (runningMembraneTaskCount >= membranesToGenerate.Count) + return; + + var task = new Task(RunMembraneGenerationThread); + + activeGenerationTasks.Add(task); + executor.AddTask(task); + } + + private void RunMembraneGenerationThread() + { + Interlocked.Increment(ref runningMembraneTaskCount); + + // Process membrane generation requests until empty + while (membranesToGenerate.TryDequeue(out var generationParameters)) + { + var generator = MembraneShapeGenerator.GetThreadSpecificGenerator(); + + var cacheEntry = generator.GenerateShape(ref generationParameters); + + // Cache entry now owns the array data that was in the generationParameters and will return it to the + // pool when the cache disposes it + + var hash = ProceduralDataCache.Instance.WriteMembraneData(cacheEntry); + + // TODO: already generate the 3D points here for use on the main thread for faster membrane + // creation? + + lock (pendingGenerationsOfMembraneHashes) + { + if (!pendingGenerationsOfMembraneHashes.Remove(hash)) + GD.PrintErr("Membrane generation result is a hash that wasn't in the pending hashes"); + } + + // TODO: can we always rely on the dynamic data cache or should we have an explicit method for + // communicating the results back to ourselves in Update? It's fine as long as there isn't a max size + // for the cache and the clear time is long enough for this to not have to worry about that + } + + Interlocked.Decrement(ref runningMembraneTaskCount); + } + } +} diff --git a/src/microbe_stage/systems/OrganelleComponentFetchSystem.cs b/src/microbe_stage/systems/OrganelleComponentFetchSystem.cs new file mode 100644 index 00000000000..1bf18642932 --- /dev/null +++ b/src/microbe_stage/systems/OrganelleComponentFetchSystem.cs @@ -0,0 +1,31 @@ +namespace Systems +{ + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + + /// + /// Fills out the component vectors like + /// + [With(typeof(OrganelleContainer))] + [RunsAfter(typeof(MicrobeReproductionSystem))] + [RunsBefore(typeof(MicrobeMovementSystem))] + [RunsBefore(typeof(OrganelleTickSystem))] + public sealed class OrganelleComponentFetchSystem : AEntitySetSystem + { + public OrganelleComponentFetchSystem(World world, IParallelRunner runner) : base(world, runner) + { + } + + protected override void Update(float delta, in Entity entity) + { + ref var container = ref entity.Get(); + + if (container.OrganelleComponentsCached) + return; + + container.FetchLayoutOrganelleComponents(); + } + } +} diff --git a/src/microbe_stage/systems/OrganelleTickSystem.cs b/src/microbe_stage/systems/OrganelleTickSystem.cs new file mode 100644 index 00000000000..37fc06b9471 --- /dev/null +++ b/src/microbe_stage/systems/OrganelleTickSystem.cs @@ -0,0 +1,76 @@ +namespace Systems +{ + using System.Collections.Concurrent; + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + using Godot; + using World = DefaultEcs.World; + + /// + /// Handles calling and other tick methods on organelles each game + /// update + /// + /// + /// + /// This runs after as this mostly deals with animating movement + /// organelles. Other operations are less time sensitive so they are fine to be detected next frame. + /// + /// + [With(typeof(OrganelleContainer))] + [With(typeof(CompoundStorage))] + [With(typeof(WorldPosition))] + [ReadsComponent(typeof(Engulfable))] + [ReadsComponent(typeof(MicrobeControl))] + [ReadsComponent(typeof(Physics))] + [ReadsComponent(typeof(WorldPosition))] + [RunsAfter(typeof(MicrobeMovementSystem))] + [RunsOnMainThread] + public sealed class OrganelleTickSystem : AEntitySetSystem + { + private readonly ConcurrentStack<(IOrganelleComponent Component, Entity Entity)> queuedSyncRuns = new(); + + public OrganelleTickSystem(World world, IParallelRunner parallelRunner) : base(world, parallelRunner) + { + } + + protected override void Update(float delta, in Entity entity) + { + ref var organelleContainer = ref entity.Get(); + + if (organelleContainer.Organelles == null) + return; + + // Clear state that needs to be rebuilt each frame + organelleContainer.ActiveCompoundDetections?.Clear(); + organelleContainer.ActiveSpeciesDetections?.Clear(); + + foreach (var organelle in organelleContainer.Organelles.Organelles) + { + foreach (var component in organelle.Components) + { + component.UpdateAsync(ref organelleContainer, entity, delta); + + if (component.UsesSyncProcess) + queuedSyncRuns.Push((component, entity)); + } + } + } + + protected override void PostUpdate(float delta) + { + base.PostUpdate(delta); + + while (queuedSyncRuns.TryPop(out var entry)) + { + // TODO: determine if it is a good idea to always fetch the container like for UpdateAsync here + // ref entry.Entity.Get() + entry.Component.UpdateSync(entry.Entity, delta); + } + + if (!queuedSyncRuns.IsEmpty) + GD.PrintErr("Queued sync runs for organelle updates is not empty after processing"); + } + } +} diff --git a/src/microbe_stage/systems/OsmoregulationAndHealingSystem.cs b/src/microbe_stage/systems/OsmoregulationAndHealingSystem.cs new file mode 100644 index 00000000000..61dc183e60b --- /dev/null +++ b/src/microbe_stage/systems/OsmoregulationAndHealingSystem.cs @@ -0,0 +1,166 @@ +namespace Systems +{ + using System; + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + using MicrobeColony = Components.MicrobeColony; + + /// + /// Handles taking energy from microbes for osmoregulation (staying alive) cost and dealing damage if there's not + /// enough energy. If microbe has non-zero ATP then passive health regeneration happens. + /// + [With(typeof(OrganelleContainer))] + [With(typeof(CellProperties))] + [With(typeof(MicrobeStatus))] + [With(typeof(MicrobeControl))] + [With(typeof(CompoundStorage))] + [With(typeof(Engulfable))] + [With(typeof(SpeciesMember))] + [With(typeof(Health))] + [ReadsComponent(typeof(CellProperties))] + [ReadsComponent(typeof(Engulfable))] + [ReadsComponent(typeof(SpeciesMember))] + [ReadsComponent(typeof(Health))] + public sealed class OsmoregulationAndHealingSystem : AEntitySetSystem + { + private readonly Compound atp; + + private GameWorld? gameWorld; + + public OsmoregulationAndHealingSystem(World world, IParallelRunner parallelRunner) : + base(world, parallelRunner) + { + atp = SimulationParameters.Instance.GetCompound("atp"); + } + + public void SetWorld(GameWorld world) + { + gameWorld = world; + } + + protected override void PreUpdate(float state) + { + base.PreUpdate(state); + + if (gameWorld == null) + throw new InvalidOperationException("GameWorld not set"); + } + + protected override void Update(float delta, in Entity entity) + { + ref var status = ref entity.Get(); + ref var control = ref entity.Get(); + ref var health = ref entity.Get(); + ref var cellProperties = ref entity.Get(); + + // Dead cells may not regenerate health + if (health.Dead || health.CurrentHealth <= 0) + return; + + var compounds = entity.Get().Compounds; + + HandleHitpointsRegeneration(ref health, compounds, delta); + + TakeOsmoregulationEnergyCost(entity, ref cellProperties, compounds, delta); + + HandleOsmoregulationDamage(entity, ref status, ref health, ref cellProperties, compounds, delta); + + // Take extra ATP if in engulf mode (and disable engulf mode if out of ATP) + if (control.State == MicrobeState.Engulf) + { + var cost = Constants.ENGULFING_ATP_COST_PER_SECOND * delta; + + if (compounds.TakeCompound(atp, cost) < cost) + { + // Ran out of ATP, disable engulf + control.State = MicrobeState.Normal; + } + } + } + + private void HandleOsmoregulationDamage(in Entity entity, ref MicrobeStatus status, ref Health health, + ref CellProperties cellProperties, CompoundBag compounds, float delta) + { + status.LastCheckedATPDamage += delta; + + // TODO: should this loop be made into a single if to ensure that ATP damage can't stack a lot if the game + // lags? + while (status.LastCheckedATPDamage >= Constants.ATP_DAMAGE_CHECK_INTERVAL) + { + status.LastCheckedATPDamage -= Constants.ATP_DAMAGE_CHECK_INTERVAL; + + // When engulfed osmoregulation cost is not taken + if (entity.Get().PhagocytosisStep != PhagocytosisPhase.None) + return; + + ApplyATPDamage(compounds, ref health, ref cellProperties); + } + } + + private void TakeOsmoregulationEnergyCost(in Entity entity, ref CellProperties cellProperties, + CompoundBag compounds, float delta) + { + ref var organelles = ref entity.Get(); + + var osmoregulationCost = (organelles.HexCount * cellProperties.MembraneType.OsmoregulationFactor * + Constants.ATP_COST_FOR_OSMOREGULATION) * delta; + + int colonySize = 0; + if (entity.Has()) + { + colonySize = entity.Get().ColonyMembers.Length; + } + else if (entity.Has()) + { + if (entity.Get().GetColonyFromMember(out var colonyEntity)) + { + colonySize = colonyEntity.Get().ColonyMembers.Length; + } + } + + // 5% osmoregulation bonus per colony member + if (colonySize != 0) + { + osmoregulationCost *= 20.0f / (20.0f + colonySize); + } + + // Only player species benefits from lowered osmoregulation + if (entity.Get().Species.PlayerSpecies) + osmoregulationCost *= gameWorld!.WorldSettings.OsmoregulationMultiplier; + + compounds.TakeCompound(atp, osmoregulationCost); + } + + /// + /// Damage the microbe if its too low on ATP. + /// + private void ApplyATPDamage(CompoundBag compounds, ref Health health, ref CellProperties cellProperties) + { + if (compounds.GetCompoundAmount(atp) > 0) + return; + + health.DealMicrobeDamage(ref cellProperties, health.MaxHealth * Constants.NO_ATP_DAMAGE_FRACTION, + "atpDamage"); + } + + /// + /// Regenerate hitpoints while the cell has atp + /// + private void HandleHitpointsRegeneration(ref Health health, CompoundBag compounds, float delta) + { + if (health.CurrentHealth >= health.MaxHealth) + return; + + if (compounds.GetCompoundAmount(atp) < Constants.HEALTH_REGENERATION_ATP_THRESHOLD) + return; + + health.CurrentHealth += Constants.HEALTH_REGENERATION_RATE * delta; + if (health.CurrentHealth > health.MaxHealth) + { + health.CurrentHealth = health.MaxHealth; + } + } + } +} diff --git a/src/microbe_stage/systems/PilusDamageSystem.cs b/src/microbe_stage/systems/PilusDamageSystem.cs new file mode 100644 index 00000000000..c339b902ca4 --- /dev/null +++ b/src/microbe_stage/systems/PilusDamageSystem.cs @@ -0,0 +1,97 @@ +namespace Systems +{ + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + + /// + /// Handles applying pilus damage to microbes + /// + [With(typeof(OrganelleContainer))] + [With(typeof(CollisionManagement))] + [With(typeof(MicrobePhysicsExtraData))] + [With(typeof(Species))] + public sealed class PilusDamageSystem : AEntitySetSystem + { + public PilusDamageSystem(World world, IParallelRunner parallelRunner) : base(world, parallelRunner) + { + } + + protected override void Update(float delta, in Entity entity) + { + ref var collisionManagement = ref entity.Get(); + + var count = collisionManagement.GetActiveCollisions(out var collisions); + if (count < 1) + return; + + ref var ourExtraData = ref entity.Get(); + ref var ourSpecies = ref entity.Get(); + + for (int i = 0; i < count; ++i) + { + ref var collision = ref collisions![i]; + + // Only process just started collisions for pilus damage + if (!collision.JustStarted) + continue; + + if (!collision.SecondEntity.Has()) + continue; + + bool otherIsPilus = collision.SecondEntity.Get() + .IsSubShapePilus(collision.SecondSubShapeData); + bool oursIsPilus = ourExtraData.IsSubShapePilus(collision.FirstSubShapeData); + + // Pilus logic + if (otherIsPilus && oursIsPilus) + { + // Pilus on pilus doesn't deal damage + continue; + } + + if (!oursIsPilus) + continue; + + // Us attacking the other microbe. In the case the other entity is attacking us it will be + // detected by that entity's physics callback + + // Disallow cannibalism + if (ourSpecies.ID == collision.SecondEntity.Get().ID) + return; + + ref var targetHealth = ref collision.SecondEntity.Get(); + + if (ourExtraData.IsSubShapeInjectisomeIfIsPilus(collision.FirstSubShapeData)) + { + // Injectisome attack, this deals non-physics force based damage, so this uses a cooldown + ref var cooldown = ref collision.SecondEntity.Get(); + + if (cooldown.IsInCooldown()) + continue; + + targetHealth.DealMicrobeDamage(ref collision.SecondEntity.Get(), + Constants.INJECTISOME_BASE_DAMAGE, "injectisome"); + + cooldown.StartInjectisomeCooldown(); + continue; + } + + // TODO: readjust the pilus damage now that this takes penetration depth into account + float damage = Constants.PILUS_BASE_DAMAGE * collision.PenetrationAmount; + + // TODO: as this will be done differently ensure game balance still works + // // Give immunity to prevent massive damage at some angles + // // https://github.com/Revolutionary-Games/Thrive/issues/3267 + // MakeInvulnerable(Constants.PILUS_INVULNERABLE_TIME); + + // Skip too small damage + if (damage < 0.0001f) + continue; + + targetHealth.DealMicrobeDamage(ref collision.SecondEntity.Get(), damage, "pilus"); + } + } + } +} diff --git a/src/microbe_stage/systems/ProcessSystem.cs b/src/microbe_stage/systems/ProcessSystem.cs new file mode 100644 index 00000000000..01b458b0e4a --- /dev/null +++ b/src/microbe_stage/systems/ProcessSystem.cs @@ -0,0 +1,650 @@ +namespace Systems +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + using Godot; + using World = DefaultEcs.World; + + /// + /// Runs biological processes on entities + /// + [With(typeof(CompoundStorage))] + [With(typeof(BioProcesses))] + public sealed class ProcessSystem : AEntitySetSystem + { + private static readonly Compound ATP = SimulationParameters.Instance.GetCompound("atp"); + private static readonly Compound Temperature = SimulationParameters.Instance.GetCompound("temperature"); + + private BiomeConditions? biome; + + /// + /// Used to go from the calculated compound values to per second values for reporting statistics + /// + private float inverseDelta; + + public ProcessSystem(World world, IParallelRunner runner) : base(world, runner) + { + } + + /// + /// Creates a process list to send to from a given list of existing organelles + /// + public static List ComputeActiveProcessList(IEnumerable organelles) + { + // TODO: switch to a manual approach if the performance characteristics of this LINQ query is not good + // The old approach just uses a linear scan of the already handled process types and adds to their existing + // rate + return organelles.Select(o => o.Definition).SelectMany(o => o.RunnableProcesses).GroupBy(p => p.Process) + .Select(g => new TweakedProcess(g.Key, g.Sum(p => p.Rate))).ToList(); + } + + /// + /// Computes the process efficiency numbers for given organelles given the active biome data. + /// specifies how changes during an in-game day are taken into account. + /// + public static Dictionary ComputeOrganelleProcessEfficiencies( + IEnumerable organelles, BiomeConditions biome, CompoundAmountType amountType) + { + var result = new Dictionary(); + + foreach (var organelle in organelles) + { + var info = new OrganelleEfficiency(organelle); + + foreach (var process in organelle.RunnableProcesses) + { + info.Processes.Add(CalculateProcessMaximumSpeed(process, biome, amountType)); + } + + result[organelle.InternalName] = info; + } + + return result; + } + + /// + /// Computes the energy balance for the given organelles in biome and at a given time during the day (or type + /// can be specified to be a different type of value) + /// + public static EnergyBalanceInfo ComputeEnergyBalance(IEnumerable organelles, + BiomeConditions biome, MembraneType membrane, bool isPlayerSpecies, + WorldGenerationSettings worldSettings, CompoundAmountType amountType) + { + var organellesList = organelles.ToList(); + + var maximumMovementDirection = MicrobeInternalCalculations.MaximumSpeedDirection(organellesList); + return ComputeEnergyBalance(organellesList, biome, membrane, maximumMovementDirection, isPlayerSpecies, + worldSettings, amountType); + } + + /// + /// Computes the energy balance for the given organelles in biome + /// + /// The organelles to compute the balance with + /// The conditions the organelles are simulated in + /// The membrane type to adjust the energy balance with + /// + /// Only movement organelles that can move in this (cell origin relative) direction are calculated. Other + /// movement organelles are assumed to be inactive in the balance calculation. + /// + /// Whether this microbe is a member of the player's species + /// The world generation settings for this game + /// Specifies how changes during an in-game day are taken into account + public static EnergyBalanceInfo ComputeEnergyBalance(IEnumerable organelles, + BiomeConditions biome, MembraneType membrane, Vector3 onlyMovementInDirection, + bool isPlayerSpecies, WorldGenerationSettings worldSettings, CompoundAmountType amountType) + { + var result = new EnergyBalanceInfo(); + + float processATPProduction = 0.0f; + float processATPConsumption = 0.0f; + float movementATPConsumption = 0.0f; + + int hexCount = 0; + + foreach (var organelle in organelles) + { + foreach (var process in organelle.Definition.RunnableProcesses) + { + var processData = CalculateProcessMaximumSpeed(process, biome, amountType); + + if (processData.WritableInputs.TryGetValue(ATP, out var amount)) + { + processATPConsumption += amount; + + result.AddConsumption(organelle.Definition.InternalName, amount); + } + + if (processData.WritableOutputs.TryGetValue(ATP, out amount)) + { + processATPProduction += amount; + + result.AddProduction(organelle.Definition.InternalName, amount); + } + } + + // Take special cell components that take energy into account + if (organelle.Definition.HasMovementComponent) + { + var amount = Constants.FLAGELLA_ENERGY_COST; + + var organelleDirection = MicrobeInternalCalculations.GetOrganelleDirection(organelle); + if (organelleDirection.Dot(onlyMovementInDirection) > 0) + { + movementATPConsumption += amount; + result.Flagella += amount; + result.AddConsumption(organelle.Definition.InternalName, amount); + } + } + + if (organelle.Definition.HasCiliaComponent) + { + var amount = Constants.CILIA_ENERGY_COST; + + movementATPConsumption += amount; + result.Cilia += amount; + result.AddConsumption(organelle.Definition.InternalName, amount); + } + + // Store hex count + hexCount += organelle.Definition.HexCount; + } + + // Add movement consumption together + result.BaseMovement = Constants.BASE_MOVEMENT_ATP_COST * hexCount; + result.AddConsumption("baseMovement", result.BaseMovement); + result.TotalMovement = movementATPConsumption + result.BaseMovement; + + // Add osmoregulation + result.Osmoregulation = Constants.ATP_COST_FOR_OSMOREGULATION * hexCount * + membrane.OsmoregulationFactor; + + if (isPlayerSpecies) + { + result.Osmoregulation *= worldSettings.OsmoregulationMultiplier; + } + + result.AddConsumption("osmoregulation", result.Osmoregulation); + + // Compute totals + result.TotalProduction = processATPProduction; + result.TotalConsumptionStationary = processATPConsumption + result.Osmoregulation; + result.TotalConsumption = result.TotalConsumptionStationary + result.TotalMovement; + + result.FinalBalance = result.TotalProduction - result.TotalConsumption; + result.FinalBalanceStationary = result.TotalProduction - result.TotalConsumptionStationary; + + return result; + } + + /// + /// Computes the compound balances for given organelle list in a patch and at a given time during the day (or + /// using longer timespan values) + /// + /// + /// + /// Assumes that all processes run at maximum speed + /// + /// + public static Dictionary ComputeCompoundBalance( + IEnumerable organelles, BiomeConditions biome, CompoundAmountType amountType) + { + var result = new Dictionary(); + + void MakeSureResultExists(Compound compound) + { + if (!result.ContainsKey(compound)) + { + result[compound] = new CompoundBalance(); + } + } + + foreach (var organelle in organelles) + { + foreach (var process in organelle.RunnableProcesses) + { + var speedAdjusted = CalculateProcessMaximumSpeed(process, biome, amountType); + + foreach (var input in speedAdjusted.Inputs) + { + MakeSureResultExists(input.Key); + result[input.Key].AddConsumption(organelle.InternalName, input.Value); + } + + foreach (var output in speedAdjusted.Outputs) + { + MakeSureResultExists(output.Key); + result[output.Key].AddProduction(organelle.InternalName, output.Value); + } + } + } + + return result; + } + + public static Dictionary ComputeCompoundBalance( + IEnumerable organelles, BiomeConditions biome, CompoundAmountType amountType) + { + return ComputeCompoundBalance(organelles.Select(o => o.Definition), biome, amountType); + } + + /// + /// Computes the compound balances for given organelle list in a patch and at a given time during the day (or + /// using longer timespan values) + /// + /// + /// + /// Assumes that the cell produces at most as much ATP as it consumes + /// + /// + public static Dictionary ComputeCompoundBalanceAtEquilibrium( + IEnumerable organelles, BiomeConditions biome, CompoundAmountType amountType, + EnergyBalanceInfo energyBalance) + { + var result = new Dictionary(); + + void MakeSureResultExists(Compound compound) + { + if (!result.ContainsKey(compound)) + { + result[compound] = new CompoundBalance(); + } + } + + float consumptionProductionRatio = energyBalance.TotalConsumption / energyBalance.TotalProduction; + bool useRatio; + + foreach (var organelle in organelles) + { + foreach (var process in organelle.RunnableProcesses) + { + var speedAdjusted = CalculateProcessMaximumSpeed(process, biome, amountType); + + useRatio = false; + + // If the cell produces more ATP than it needs, its ATP producing processes need to be toned down + if (speedAdjusted.Outputs.ContainsKey(ATP) && consumptionProductionRatio < 1.0f) + useRatio = true; + + foreach (var input in speedAdjusted.Inputs) + { + if (input.Key == ATP) + continue; + + float amount = input.Value; + + if (useRatio) + amount *= consumptionProductionRatio; + + MakeSureResultExists(input.Key); + result[input.Key].AddConsumption(organelle.InternalName, amount); + } + + foreach (var output in speedAdjusted.Outputs) + { + if (output.Key == ATP) + continue; + + float amount = output.Value; + + if (useRatio) + amount *= consumptionProductionRatio; + + MakeSureResultExists(output.Key); + result[output.Key].AddProduction(organelle.InternalName, amount); + } + } + } + + return result; + } + + public static Dictionary ComputeCompoundBalanceAtEquilibrium( + IEnumerable organelles, BiomeConditions biome, CompoundAmountType amountType, + EnergyBalanceInfo energyBalance) + { + return ComputeCompoundBalanceAtEquilibrium(organelles.Select(o => o.Definition), biome, amountType, + energyBalance); + } + + /// + /// Calculates the maximum speed a process can run at in a biome based on the environmental compounds. + /// Can be switched between the average, maximum etc. conditions that occur in the span of an in-game day. + /// + public static ProcessSpeedInformation CalculateProcessMaximumSpeed(TweakedProcess process, + BiomeConditions biome, CompoundAmountType pointInTimeType) + { + var result = new ProcessSpeedInformation(process.Process); + + float speedFactor = 1.0f; + float efficiency = 1.0f; + + // Environmental inputs need to be processed first + foreach (var input in process.Process.Inputs) + { + if (!input.Key.IsEnvironmental) + continue; + + // Environmental compound that can limit the rate + var availableInEnvironment = GetAmbientInBiome(input.Key, biome, pointInTimeType); + + var availableRate = input.Key == Temperature ? + CalculateTemperatureEffect(availableInEnvironment) : + availableInEnvironment / input.Value; + + result.AvailableAmounts[input.Key] = availableInEnvironment; + + efficiency *= availableInEnvironment; + + // More than needed environment value boosts the effectiveness + result.AvailableRates[input.Key] = availableRate; + + speedFactor *= availableRate; + + result.WritableInputs[input.Key] = input.Value; + } + + result.Efficiency = efficiency; + + speedFactor *= process.Rate; + + // Note that we don't consider storage constraints here so we don't use spaceConstraintModifier calculations + + // So that the speed factor is available here + foreach (var entry in process.Process.Inputs) + { + if (entry.Key.IsEnvironmental) + continue; + + // Normal, cloud input + + result.WritableInputs.Add(entry.Key, entry.Value * speedFactor); + } + + foreach (var entry in process.Process.Outputs) + { + var amount = entry.Value * speedFactor; + + result.WritableOutputs[entry.Key] = amount; + + if (amount <= 0) + result.WritableLimitingCompounds.Add(entry.Key); + } + + result.CurrentSpeed = speedFactor; + + return result; + } + + /// + /// Sets the biome whose environmental values affect processes + /// + public void SetBiome(BiomeConditions newBiome) + { + biome = newBiome; + } + + /// + /// Get the current amount of environmental compound + /// + public float GetAmbient(Compound compound, CompoundAmountType amountType) + { + if (biome == null) + throw new InvalidOperationException("Biome needs to be set before getting ambient compounds"); + + return GetAmbientInBiome(compound, biome, amountType); + } + + protected override void PreUpdate(float delta) + { + if (biome == null) + { + GD.PrintErr("ProcessSystem has no biome set"); + } + + inverseDelta = 1.0f / delta; + } + + protected override void Update(float delta, in Entity entity) + { + ref var storage = ref entity.Get(); + ref var processes = ref entity.Get(); + + ProcessNode(ref processes, ref storage, delta); + } + + private static float GetAmbientInBiome(Compound compound, BiomeConditions biome, CompoundAmountType amountType) + { + if (!biome.TryGetCompound(compound, amountType, out var environmentalCompoundProperties)) + return 0; + + return environmentalCompoundProperties.Ambient; + } + + /// + /// Since temperature works differently to other compounds, we use this method to deal with it. Logic here + /// is liable to be updated in the future to use alternative effect models. + /// + private static float CalculateTemperatureEffect(float temperature) + { + // Assume thermosynthetic processes are most efficient at 100°C and drop off linearly to zero + var optimal = 100; + return Mathf.Clamp(temperature / optimal, 0, 2 - temperature / optimal); + } + + private void ProcessNode(ref BioProcesses processor, ref CompoundStorage storage, float delta) + { + var bag = storage.Compounds; + + // Set all compounds to not be useful, when some compound is used it will be marked useful + bag.ClearUseful(); + + var processStatistics = processor.ProcessStatistics; + + processStatistics?.MarkAllUnused(); + + if (processor.ActiveProcesses != null) + { + foreach (var process in processor.ActiveProcesses) + { + // If rate is 0 dont do it + // The rate specifies how fast fraction of the specified process numbers this cell can do + // TODO: would be nice still to report these to process statistics + if (process.Rate <= 0.0f) + continue; + + // TODO: reporting duplicate process types would be nice in debug mode here + + var processData = process.Process; + + var currentProcessStatistics = processStatistics?.GetAndMarkUsed(process.Process); + currentProcessStatistics?.BeginFrame(delta); + + RunProcess(delta, processData, bag, process, currentProcessStatistics); + } + } + + bag.ClampNegativeCompoundAmounts(); + bag.FixNaNCompounds(); + + processStatistics?.RemoveUnused(); + } + + private void RunProcess(float delta, BioProcess processData, CompoundBag bag, TweakedProcess process, + SingleProcessStatistics? currentProcessStatistics) + { + // Can your cell do the process + bool canDoProcess = true; + + float environmentModifier = 1.0f; + + // This modifies the process overall speed to allow really fast processes to run, for example if there are + // a ton of one organelle it might consume 100 glucose per go, which might be unlikely for the cell to have + // so if there is *some* but not enough space for results (and also inputs) this can run the process as + // fraction of the speed to allow the cell to still function well + float spaceConstraintModifier = 1.0f; + + // First check the environmental compounds so that we can build the right environment modifier for accurate + // check of normal compound input amounts + foreach (var entry in processData.Inputs) + { + // Set used compounds to be useful, we dont want to purge those + bag.SetUseful(entry.Key); + + if (!entry.Key.IsEnvironmental) + continue; + + // Processing runs on the current game time following values + var ambient = GetAmbient(entry.Key, CompoundAmountType.Current); + + // currentProcessStatistics?.AddInputAmount(entry.Key, entry.Value * inverseDelta); + currentProcessStatistics?.AddInputAmount(entry.Key, ambient); + + // do environmental modifier here, and save it for later + environmentModifier *= entry.Key == Temperature ? + CalculateTemperatureEffect(ambient) : + ambient / entry.Value; + + if (environmentModifier <= MathUtils.EPSILON) + currentProcessStatistics?.AddLimitingFactor(entry.Key); + } + + if (environmentModifier <= MathUtils.EPSILON) + canDoProcess = false; + + // Compute spaceConstraintModifier before updating the final use and input amounts + foreach (var entry in processData.Inputs) + { + if (entry.Key.IsEnvironmental) + continue; + + var inputRemoved = entry.Value * process.Rate * environmentModifier; + + // currentProcessStatistics?.AddInputAmount(entry.Key, 0); + // We don't multiply by delta here because we report the per-second values anyway. In the actual + // process output numbers (computed after testing the speed), we need to multiply by inverse delta + currentProcessStatistics?.AddInputAmount(entry.Key, inputRemoved); + + inputRemoved = inputRemoved * delta * spaceConstraintModifier; + + // If not enough we can't run the process unless we can lower spaceConstraintModifier enough + var availableAmount = bag.GetCompoundAmount(entry.Key); + if (availableAmount < inputRemoved) + { + bool canRun = false; + + if (availableAmount > MathUtils.EPSILON) + { + var neededModifier = availableAmount / inputRemoved; + + if (neededModifier > Constants.MINIMUM_RUNNABLE_PROCESS_FRACTION) + { + spaceConstraintModifier = neededModifier; + canRun = true; + + // Due to rounding errors there can be very small disparity here between the amount + // available and what we will take with the modifiers. See the comment in outputs for + // more details + } + } + + if (!canRun) + { + canDoProcess = false; + currentProcessStatistics?.AddLimitingFactor(entry.Key); + } + } + } + + foreach (var entry in processData.Outputs) + { + // For now lets assume compounds we produce are also useful + bag.SetUseful(entry.Key); + + var outputAdded = entry.Value * process.Rate * environmentModifier; + + // currentProcessStatistics?.AddOutputAmount(entry.Key, 0); + currentProcessStatistics?.AddOutputAmount(entry.Key, outputAdded); + + outputAdded = outputAdded * delta * spaceConstraintModifier; + + // if environmental right now this isn't released anywhere + if (entry.Key.IsEnvironmental) + continue; + + // If no space we can't do the process, if we can't adjust the space constraint modifier enough + var remainingSpace = bag.GetCapacityForCompound(entry.Key) - bag.GetCompoundAmount(entry.Key); + if (outputAdded > remainingSpace) + { + bool canRun = false; + + if (remainingSpace > MathUtils.EPSILON) + { + var neededModifier = remainingSpace / outputAdded; + + if (neededModifier > Constants.MINIMUM_RUNNABLE_PROCESS_FRACTION) + { + spaceConstraintModifier = neededModifier; + canRun = true; + } + + // With all of the modifiers we can lose a tiny bit of compound that won't fit due to rounding + // errors, but we ignore that here + } + + if (!canRun) + { + canDoProcess = false; + currentProcessStatistics?.AddCapacityProblem(entry.Key); + } + } + } + + // Only carry out this process if you have all the required ingredients and enough space for the outputs + if (!canDoProcess) + { + if (currentProcessStatistics != null) + currentProcessStatistics.CurrentSpeed = 0; + return; + } + + float totalModifier = process.Rate * delta * environmentModifier * spaceConstraintModifier; + + if (currentProcessStatistics != null) + currentProcessStatistics.CurrentSpeed = process.Rate * environmentModifier * spaceConstraintModifier; + + // Consume inputs + foreach (var entry in processData.Inputs) + { + if (entry.Key.IsEnvironmental) + continue; + + var inputRemoved = entry.Value * totalModifier; + + currentProcessStatistics?.AddInputAmount(entry.Key, inputRemoved * inverseDelta); + + // This should always succeed (due to the earlier check) so it is always assumed here that this + // succeeded + bag.TakeCompound(entry.Key, inputRemoved); + } + + // Add outputs + foreach (var entry in processData.Outputs) + { + if (entry.Key.IsEnvironmental) + continue; + + var outputGenerated = entry.Value * totalModifier; + + currentProcessStatistics?.AddOutputAmount(entry.Key, outputGenerated * inverseDelta); + + bag.AddCompound(entry.Key, outputGenerated); + } + } + } +} diff --git a/src/microbe_stage/systems/SlimeSlowdownSystem.cs b/src/microbe_stage/systems/SlimeSlowdownSystem.cs new file mode 100644 index 00000000000..0b11b348277 --- /dev/null +++ b/src/microbe_stage/systems/SlimeSlowdownSystem.cs @@ -0,0 +1,48 @@ +namespace Systems +{ + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + + /// + /// Handles slowing down cells that are currently moving through slime (and don't have slime jets themselves) + /// + [With(typeof(MicrobeControl))] + [With(typeof(OrganelleContainer))] + [With(typeof(WorldPosition))] + [Without(typeof(AttachedToEntity))] + public sealed class SlimeSlowdownSystem : AEntitySetSystem + { + private readonly IReadonlyCompoundClouds compoundCloudSystem; + + private readonly Compound mucilage; + + public SlimeSlowdownSystem(IReadonlyCompoundClouds compoundCloudSystem, World world, IParallelRunner runner) : + base(world, runner) + { + this.compoundCloudSystem = compoundCloudSystem; + + mucilage = SimulationParameters.Instance.GetCompound("mucilage"); + } + + protected override void Update(float delta, in Entity entity) + { + ref var control = ref entity.Get(); + + ref var organelles = ref entity.Get(); + + // Cells with jets aren't affected by mucilage + if (organelles.SlimeJets is { Count: > 0 }) + { + control.SlowedBySlime = false; + return; + } + + ref var position = ref entity.Get(); + + control.SlowedBySlime = compoundCloudSystem.AmountAvailable(mucilage, position.Position, 1.0f) > + Constants.COMPOUND_DENSITY_CATEGORY_FAIR_AMOUNT; + } + } +} diff --git a/src/microbe_stage/systems/SpawnSystem.cs b/src/microbe_stage/systems/SpawnSystem.cs new file mode 100644 index 00000000000..5cf63e3ecda --- /dev/null +++ b/src/microbe_stage/systems/SpawnSystem.cs @@ -0,0 +1,618 @@ +namespace Systems +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Components; + using DefaultEcs; + using DefaultEcs.Command; + using DefaultEcs.System; + using Godot; + using Newtonsoft.Json; + using Nito.Collections; + + // TODO: need to reimplement saving of the properties here + /// + /// Spawns AI cells and other environmental things as the player moves around + /// + [JsonObject(IsReference = true)] + public sealed class SpawnSystem : ISystem, ISpawnSystem + { + private readonly EntitySet spawnedEntitiesSet; + + /// + /// Sets how often the spawn system runs and checks things + /// + [JsonProperty] + private float interval = 1.0f; + + [JsonProperty] + private float elapsed; + + [JsonProperty] + private float despawnElapsed; + + private IWorldSimulation world; + + private Vector3 playerPosition; + + private ShuffleBag spawnTypes; + + [JsonProperty] + private Random random = new(); + + /// + /// This is used to spawn only a few entities per frame with minimal changes needed to code that wants to + /// spawn a bunch of stuff at once + /// + /// + /// + /// This isn't saved but the likelihood that losing out on spawning some things is not super critical. + /// Also it is probably the case that this isn't even used on most frames so it is perhaps uncommon + /// that there are queued things when saving. In addition it would be very hard to make sure all the + /// possible queued spawn type data (as it is mostly based on temporary lambdas) is saved. + /// + /// + private Deque queuedSpawns = new(); + + /// + /// Estimate count of existing spawned entities, cached to make delayed spawns cheaper + /// + private float estimateEntityCount; + + /// + /// Estimate count of existing spawn entities within the current spawn radius of the player; + /// Used to prevent a "spawn belt" of densely spawned entities when player doesn't move. + /// + [JsonProperty] + private HashSet coordinatesSpawned = new(); + + public SpawnSystem(IWorldSimulation world) + { + this.world = world; + spawnedEntitiesSet = world.EntitySystem.GetEntities().With().With().AsSet(); + + spawnTypes = new ShuffleBag(random); + } + + public bool IsEnabled { get; set; } = true; + + public void Update(float delta) + { + if (!IsEnabled) + return; + + elapsed += delta; + despawnElapsed += delta; + + float spawnsLeftThisFrame = Constants.MAX_SPAWNS_PER_FRAME; + + // If we have queued spawns to do spawn those + HandleQueuedSpawns(ref spawnsLeftThisFrame); + + if (spawnsLeftThisFrame <= 0) + return; + + // This is now an if to make sure that the spawn system is + // only ran once per frame to avoid spawning a bunch of stuff + // all at once after a lag spike + // NOTE: that as QueueFree is used it's not safe to just switch this to a loop + if (elapsed >= interval) + { + elapsed -= interval; + + estimateEntityCount = DespawnEntities(); + + spawnTypes.RemoveAll(entity => entity.DestroyQueued); + + SpawnAllTypes(ref spawnsLeftThisFrame); + } + else if (despawnElapsed > Constants.DESPAWN_INTERVAL) + { + despawnElapsed = 0; + + DespawnEntities(); + } + + spawnedEntitiesSet.Complete(); + } + + /// + /// Adds a new spawner. Sets up the spawn radius, this radius squared, + /// and frequency fields based on the parameters of this + /// function. + /// + public void AddSpawnType(Spawner spawner, float spawnDensity, int spawnRadius) + { + spawner.SpawnRadius = spawnRadius; + spawner.SpawnRadiusSquared = spawnRadius * spawnRadius; + + float minSpawnRadius = spawnRadius * Constants.MIN_SPAWN_RADIUS_RATIO; + spawner.MinSpawnRadiusSquared = minSpawnRadius * minSpawnRadius; + spawner.Density = spawnDensity; + + spawnTypes.Add(spawner); + } + + /// + /// Removes a spawn type immediately. Note that it's easier to just set DestroyQueued to true on an spawner. + /// + public void RemoveSpawnType(Spawner spawner) + { + spawnTypes.Remove(spawner); + } + + public void DespawnAll() + { + ClearSpawnQueue(); + + float despawned = 0.0f; + + foreach (ref readonly var entity in spawnedEntitiesSet.GetEntities()) + { + ref var spawned = ref entity.Get(); + + if (spawned.DisallowDespawning) + continue; + + if (world.IsEntityInWorld(entity)) + { + despawned += spawned.EntityWeight; + world.DestroyEntity(entity); + } + } + + var debugOverlay = DebugOverlays.Instance; + if (debugOverlay.PerformanceMetricsVisible) + debugOverlay.ReportDespawns(despawned); + + ClearSpawnCoordinates(); + } + + /// + /// Clears all of the queued spawns. For use when the queue might contain something that + /// should not be allowed to spawn. + /// + public void ClearSpawnQueue() + { + foreach (var queuedSpawn in queuedSpawns) + queuedSpawn.Dispose(); + + queuedSpawns.Clear(); + } + + /// + /// Forgets all record of where clouds have spawned, so clouds can spawn anywhere. + /// + public void ClearSpawnCoordinates() + { + coordinatesSpawned.Clear(); + } + + public void ReportPlayerPosition(Vector3 position) + { + playerPosition = position; + + // Remove the y-position from player position + playerPosition.y = 0; + } + + public void NotifyExternalEntitySpawned(in EntityRecord entity, float despawnRadiusSquared, float entityWeight) + { + if (entityWeight <= 0) + throw new ArgumentException("weight needs to be positive", nameof(entityWeight)); + + entity.Set(new Spawned + { + DespawnRadiusSquared = despawnRadiusSquared, + EntityWeight = entityWeight, + }); + + // Update entity count estimate to keep this about up to date, this will be corrected within a few seconds + // with the next spawn cycle to be exactly correct + estimateEntityCount += entityWeight; + } + + public bool IsUnderEntityLimitForReproducing() + { + return estimateEntityCount < Settings.Instance.MaxSpawnedEntities.Value * + Constants.REPRODUCTION_ALLOW_EXCEED_ENTITY_LIMIT_MULTIPLIER; + } + + /// + /// Ensures that the entity limit is not overfilled by a lot after player reproduction by force despawning + /// things + /// + public void EnsureEntityLimitAfterPlayerReproduction(Vector3 keepEntitiesNear, Entity doNotDespawn) + { + float extra = 0; + + if (doNotDespawn != default && doNotDespawn.Has()) + { + // Take the just spawned thing we shouldn't despawn into account in the entity count as our estimate + // won't likely include it yet + extra = doNotDespawn.Get().EntityWeight; + } + + var entityLimit = Settings.Instance.MaxSpawnedEntities.Value; + + float limitExcess = estimateEntityCount + extra - entityLimit * + Constants.REPRODUCTION_PLAYER_ALLOWED_ENTITY_LIMIT_EXCEED; + + if (limitExcess < 1) + return; + + // We need to despawn something + GD.Print("After player reproduction entity limit is exceeded, will force despawn something"); + + float playerReproductionWeight = 0; + + var playerReproducedEntities = new List<(Entity Entity, Vector3 Position, float Weight)>(); + + foreach (var entity in world.EntitySystem.GetEntities().With().With() + .With().AsEnumerable()) + { + if (world.IsQueuedForDeletion(entity)) + continue; + + ref var spawned = ref entity.Get(); + + if (spawned.DisallowDespawning) + continue; + + playerReproductionWeight += spawned.EntityWeight; + + if (doNotDespawn == default || entity != doNotDespawn) + { + ref var position = ref entity.Get(); + + playerReproducedEntities.Add((entity, position.Position, spawned.EntityWeight)); + } + } + + // Despawn one player reproduced copy first if the player reproduced copies are taking up a ton of space + if (playerReproductionWeight > entityLimit * Constants.PREFER_DESPAWN_PLAYER_REPRODUCED_COPY_AFTER && + playerReproducedEntities.Count > 0) + { + var despawn = playerReproducedEntities + .OrderByDescending(s => s.Position.DistanceSquaredTo(keepEntitiesNear)).First(); + + estimateEntityCount -= despawn.Weight; + limitExcess -= despawn.Weight; + + world.DestroyEntity(despawn.Entity); + } + + if (limitExcess <= 1) + return; + + // We take weight as well as distance into account here to not just despawn a ton of really far away objects + // with weight of 1 + using var deSpawnableEntities = world.EntitySystem.GetEntities().With() + .With().AsEnumerable().Where(e => !e.Get().DisallowDespawning) + .Select(e => + { + ref var spawned = ref e.Get(); + ref var position = ref e.Get(); + + return (e, spawned.EntityWeight, position.Position) as (Entity Entity, float EntityWeight, Vector3 + Position)?; + }) + .OrderByDescending(t => + Math.Log(t!.Value.Position.DistanceSquaredTo(keepEntitiesNear)) + Math.Log(t.Value.EntityWeight)) + .GetEnumerator(); + + // Then try to despawn enough stuff for us to get under the limit + while (limitExcess >= 1) + { + (Entity Entity, float EntityWeight, Vector3 Position)? bestCandidate = null; + + if (deSpawnableEntities.MoveNext() && deSpawnableEntities.Current != null) + bestCandidate = deSpawnableEntities.Current; + + if (doNotDespawn != default && bestCandidate?.Entity == doNotDespawn) + continue; + + if (bestCandidate != null && world.IsQueuedForDeletion(bestCandidate.Value.Entity)) + continue; + + if (bestCandidate != null) + { + var weight = bestCandidate.Value.EntityWeight; + estimateEntityCount -= weight; + limitExcess -= weight; + world.DestroyEntity(bestCandidate.Value.Entity); + + continue; + } + + // If we couldn't despawn anything sensible, give up + GD.PrintErr("Force despawning could not find enough things to despawn"); + break; + } + } + + public void Dispose() + { + spawnedEntitiesSet.Dispose(); + } + + private void HandleQueuedSpawns(ref float spawnsLeftThisFrame) + { + float spawnedCount = 0.0f; + + // Spawn from the queue + while (spawnsLeftThisFrame > 0 && queuedSpawns.Count > 0) + { + var spawn = queuedSpawns.First(); + + bool finished = false; + + while (estimateEntityCount < Settings.Instance.MaxSpawnedEntities.Value && + spawnsLeftThisFrame > 0) + { + // Disallow spawning too close to the player + spawn.CheckIsSpawningStillPossible(playerPosition); + + if (spawn.Ended) + { + finished = true; + break; + } + + // Next can be spawned + var (recorder, weight) = spawn.SpawnNext(out var current); + + AddSpawnedComponent(current, weight, spawn.RelatedSpawnType); + SpawnHelpers.FinalizeEntitySpawn(recorder, world); + + estimateEntityCount += weight; + spawnsLeftThisFrame -= weight; + spawnedCount += weight; + } + + if (finished) + { + // Finished spawning everything from this enumerator, if we didn't finish we save this spawn for the + // next queued spawns handling cycle + queuedSpawns.RemoveFromFront(); + spawn.Dispose(); + } + else + { + break; + } + } + + if (spawnedCount > 0) + { + var debugOverlay = DebugOverlays.Instance; + + if (debugOverlay.PerformanceMetricsVisible) + debugOverlay.ReportSpawns(spawnedCount); + } + } + + private void SpawnAllTypes(ref float spawnsLeftThisFrame) + { + var playerCoordinatePoint = new Tuple(Mathf.RoundToInt(playerPosition.x / + Constants.SPAWN_SECTOR_SIZE), Mathf.RoundToInt(playerPosition.z / Constants.SPAWN_SECTOR_SIZE)); + + // Spawn for all sectors immediately outside a 3x3 box around the player + var sectorsToSpawn = new List(12); + for (int y = -1; y <= 1; y++) + { + sectorsToSpawn.Add(new Int2(playerCoordinatePoint.Item1 - 2, playerCoordinatePoint.Item2 + y)); + } + + for (int x = -1; x <= 1; x++) + { + sectorsToSpawn.Add(new Int2(playerCoordinatePoint.Item1 + 2, playerCoordinatePoint.Item2 + x)); + } + + for (int y = -1; y <= 1; y++) + { + sectorsToSpawn.Add(new Int2(playerCoordinatePoint.Item1 + y, playerCoordinatePoint.Item2 - 2)); + } + + for (int x = -1; x <= 1; x++) + { + sectorsToSpawn.Add(new Int2(playerCoordinatePoint.Item1 + x, playerCoordinatePoint.Item2 + 2)); + } + + foreach (var newSector in sectorsToSpawn) + { + if (coordinatesSpawned.Add(newSector)) + { + SpawnInSector(newSector, ref spawnsLeftThisFrame); + } + } + + // Only spawn microbes around the player if below the threshold. + // This is to prioritize spawning in sectors. + float entitiesThreshold = Settings.Instance.MaxSpawnedEntities.Value * + Constants.ENTITY_SPAWNING_AROUND_PLAYER_THRESHOLD; + if (estimateEntityCount < entitiesThreshold) + { + SpawnMicrobesAroundPlayer(playerPosition, ref spawnsLeftThisFrame); + } + } + + /// + /// Handles all spawning for this section of the play area, as it will look when the player enters. Does NOT + /// handle recording that the sector was spawned. + /// + /// + /// X/Y coordinates of the sector to be spawned, in units + /// + /// How many spawns are still allowed this frame + private void SpawnInSector(Int2 sector, ref float spawnsLeftThisFrame) + { + float spawns = 0.0f; + + foreach (var spawnType in spawnTypes) + { + if (SpawnsBlocked(spawnType)) + continue; + + var sectorCenter = new Vector3(sector.x * Constants.SPAWN_SECTOR_SIZE, 0, + sector.y * Constants.SPAWN_SECTOR_SIZE); + + // Distance from the sector center. + var displacement = new Vector3(random.NextFloat() * Constants.SPAWN_SECTOR_SIZE - + (Constants.SPAWN_SECTOR_SIZE / 2), 0, + random.NextFloat() * Constants.SPAWN_SECTOR_SIZE - (Constants.SPAWN_SECTOR_SIZE / 2)); + + spawns += SpawnWithSpawner(spawnType, sectorCenter + displacement, ref spawnsLeftThisFrame); + } + + var debugOverlay = DebugOverlays.Instance; + + if (debugOverlay.PerformanceMetricsVisible) + debugOverlay.ReportSpawns(spawns); + } + + private void SpawnMicrobesAroundPlayer(Vector3 playerLocation, ref float spawnsLeftThisFrame) + { + var angle = random.NextFloat() * 2 * Mathf.Pi; + + float spawns = 0.0f; + foreach (var spawnType in spawnTypes) + { + if (!SpawnsBlocked(spawnType) && spawnType is MicrobeSpawner) + { + spawns += SpawnWithSpawner(spawnType, + playerLocation + new Vector3(Mathf.Cos(angle) * Constants.SPAWN_SECTOR_SIZE * 2, 0, + Mathf.Sin(angle) * Constants.SPAWN_SECTOR_SIZE * 2), ref spawnsLeftThisFrame); + } + } + + var debugOverlay = DebugOverlays.Instance; + + if (debugOverlay.PerformanceMetricsVisible) + debugOverlay.ReportSpawns(spawns); + } + + /// + /// Checks whether we're currently blocked from spawning this type + /// + private bool SpawnsBlocked(Spawner spawnType) + { + return spawnType.SpawnsEntities && estimateEntityCount >= Settings.Instance.MaxSpawnedEntities.Value; + } + + /// + /// Does a single spawn with a spawner. Does NOT check we're under the entity limit. + /// + private float SpawnWithSpawner(Spawner spawnType, Vector3 location, ref float spawnsLeftThisFrame) + { + float spawns = 0.0f; + + if (random.NextFloat() > spawnType.Density) + { + return spawns; + } + + var spawnQueue = spawnType.Spawn(world, location, this); + + // Non-entity type spawn + if (spawnQueue == null) + return spawns; + + bool finished = false; + + while (spawnsLeftThisFrame > 0) + { + spawnQueue.CheckIsSpawningStillPossible(playerPosition); + + if (spawnQueue.Ended) + { + finished = true; + break; + } + + var (recorder, weight) = spawnQueue.SpawnNext(out var current); + + AddSpawnedComponent(current, weight, spawnType); + SpawnHelpers.FinalizeEntitySpawn(recorder, world); + + spawns += weight; + estimateEntityCount += weight; + spawnsLeftThisFrame -= weight; + } + + if (!finished) + { + // Store the remaining items in the enumerator for later + queuedSpawns.AddToBack(spawnQueue); + } + else + { + spawnQueue.Dispose(); + } + + return spawns; + } + + /// + /// Despawns entities that are far away from the player + /// + /// The number of alive entities (combined weight), used to limit the total + private float DespawnEntities() + { + float entitiesDeleted = 0.0f; + float spawnedEntityWeight = 0.0f; + + int despawnedCount = 0; + + foreach (ref readonly var entity in spawnedEntitiesSet.GetEntities()) + { + ref var spawned = ref entity.Get(); + + if (spawned.DisallowDespawning) + continue; + + var entityWeight = spawned.EntityWeight; + spawnedEntityWeight += entityWeight; + + // Keep counting all entities to have an accurate count at the end of this loop, even if we are no + // longer allowed to despawn things + if (despawnedCount >= Constants.MAX_DESPAWNS_PER_FRAME) + continue; + + // Global position must be used here as otherwise colony members are despawned + // This should now just process the colony lead cells as this now uses GetChildrenToProcess, but + // GlobalTransform is kept here just for good measure to make sure the distances are accurate. + ref var position = ref entity.Get(); + var squaredDistance = (playerPosition - position.Position).LengthSquared(); + + // If the entity is too far away from the player, despawn it. + if (squaredDistance > spawned.DespawnRadiusSquared) + { + entitiesDeleted += entityWeight; + world.DestroyEntity(entity); + + ++despawnedCount; + } + } + + var debugOverlay = DebugOverlays.Instance; + + if (debugOverlay.PerformanceMetricsVisible) + debugOverlay.ReportDespawns(entitiesDeleted); + + return spawnedEntityWeight - entitiesDeleted; + } + + private void AddSpawnedComponent(in EntityRecord entity, float weight, Spawner spawnType) + { + float radius = spawnType.SpawnRadius + Constants.DESPAWN_RADIUS_OFFSET; + + entity.Set(new Spawned + { + DespawnRadiusSquared = radius * radius, + EntityWeight = weight, + }); + } + } +} diff --git a/src/microbe_stage/systems/TintColourAnimationSystem.cs b/src/microbe_stage/systems/TintColourAnimationSystem.cs new file mode 100644 index 00000000000..4c730d67c06 --- /dev/null +++ b/src/microbe_stage/systems/TintColourAnimationSystem.cs @@ -0,0 +1,53 @@ +namespace Systems +{ + using Components; + using DefaultEcs; + using DefaultEcs.System; + + /// + /// Handles applying the shader "tint" parameter based on to an + /// that has microbe stage compatible shader parameter names + /// + [With(typeof(ColourAnimation))] + [With(typeof(EntityMaterial))] + public sealed class TintColourAnimationSystem : AEntitySetSystem + { + public TintColourAnimationSystem(World world) : base(world, null) + { + } + + protected override void Update(float delta, in Entity entity) + { + ref var animation = ref entity.Get(); + + if (animation.ColourApplied) + return; + + ref var entityMaterial = ref entity.Get(); + + if (entityMaterial.Materials == null) + return; + + var materials = entityMaterial.Materials; + + var currentColour = animation.CurrentColour; + + if (animation.AnimateOnlyFirstMaterial) + { + if (materials.Length > 0) + { + materials[0].SetShaderParam("tint", currentColour); + } + } + else + { + foreach (var material in materials) + { + material.SetShaderParam("tint", currentColour); + } + } + + animation.ColourApplied = true; + } + } +} diff --git a/src/microbe_stage/systems/ToxinCollisionSystem.cs b/src/microbe_stage/systems/ToxinCollisionSystem.cs new file mode 100644 index 00000000000..a9175cdb876 --- /dev/null +++ b/src/microbe_stage/systems/ToxinCollisionSystem.cs @@ -0,0 +1,173 @@ +namespace Systems +{ + using System; + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + using Godot; + using World = DefaultEcs.World; + + /// + /// Handles detected toxin collisions with microbes + /// + [With(typeof(ToxinDamageSource))] + [With(typeof(CollisionManagement))] + [With(typeof(Physics))] + [With(typeof(TimedLife))] + public sealed class ToxinCollisionSystem : AEntitySetSystem + { + public ToxinCollisionSystem(World world, IParallelRunner runner) : base(world, runner) + { + } + + protected override void Update(float delta, in Entity entity) + { + ref var damageSource = ref entity.Get(); + + // Quickly detect already hit projectiles + if (damageSource.ProjectileUsed) + return; + + ref var collisions = ref entity.Get(); + + if (!damageSource.ProjectileInitialized) + { + damageSource.ProjectileInitialized = true; + + // Need to setup callbacks etc. for this to work + + // TODO: make sure this system runs before the collision management to make sure no double data apply + // happens + + collisions.CollisionFilter = FilterCollisions; + + collisions.StartCollisionRecording(Constants.MAX_SIMULTANEOUS_COLLISIONS_TINY); + + collisions.StateApplied = false; + } + + // Check for active collisions that count as a hit and use up this projectile + var count = collisions.GetActiveCollisions(out var activeCollisions); + for (int i = 0; i < count; ++i) + { + ref var collision = ref activeCollisions![i]; + + if (!HandlePotentiallyDamagingCollision(ref collision)) + continue; + + // Applied a damaging hit, destroy this toxin + // TODO: We should probably get some *POP* effect here. + + // Expire right now + ref var timedLife = ref entity.Get(); + timedLife.TimeToLiveRemaining = -1; + + ref var physics = ref entity.Get(); + + // TODO: should this instead of disabling the further collisions be removed from the world immediately + // to cause less of a physics impact? + // physics.BodyDisabled = true; + physics.DisableCollisionState = Physics.CollisionState.DisableCollisions; + + // And make sure the flag we check for is set immediately to not process this projectile again + // (this is just extra safety against the time over callback configuration not working correctly) + damageSource.ProjectileUsed = true; + } + } + + /// + /// Collision filter to disable collisions with microbes the toxin can't damage + /// + /// False when should pass through + private static bool FilterCollisions(ref PhysicsCollision collision) + { + // TODO: maybe this could cache something for slight speed up? (though the cache would need clearing + // periodically) + + // Toxin is always the first entity as it is what registers this collision callback + if (!collision.SecondEntity.Has()) + { + // Hit something other than a microbe + return true; + } + + ref var speciesComponent = ref collision.SecondEntity.Get(); + + try + { + ref var damageSource = ref collision.FirstEntity.Get(); + + // Don't hit microbes of the same species as the toxin shooter + if (speciesComponent.Species.ID == damageSource.ToxinProperties.Species.ID) + return false; + } + catch (Exception e) + { + GD.PrintErr($"Entity that collided as a toxin is missing {nameof(ToxinDamageSource)} component: ", e); + } + + // No reason why this shouldn't collie + return true; + } + + private static bool HandlePotentiallyDamagingCollision(ref PhysicsCollision collision) + { + // TODO: switch this to also take ref once we use .NET 5 or newer: + // https://learn.microsoft.com/en-us/dotnet/api/system.runtime.interopservices.collectionsmarshal.asspan + + // TODO: see the TODOs about combining code with FilterCollisions + + var damageTarget = collision.SecondEntity; + + // Skip if hit something that's not a microbe (we don't know how to damage other things currently) + if (!damageTarget.Has()) + return false; + + ref var speciesComponent = ref damageTarget.Get(); + + try + { + ref var damageSource = ref collision.FirstEntity.Get(); + + // Disallow friendly fire + if (speciesComponent.Species == damageSource.ToxinProperties.Species) + return false; + + ref var health = ref damageTarget.Get(); + + if (health.Invulnerable) + { + // Consume this even though this won't deal damage + return true; + } + + if (damageTarget.Has()) + { + // Hit a microbe colony, forward the damage to the exact colony member that was hit + // TODO: forward damage to specific microbe + throw new NotImplementedException(); + } + + if (damageTarget.Has()) + { + damageSource.ToxinProperties.DealDamage(ref health, ref damageTarget.Get(), + damageSource.ToxinAmount); + } + else + { + damageSource.ToxinProperties.DealDamage(ref health, damageSource.ToxinAmount); + } + + return true; + } + catch (Exception e) + { + GD.PrintErr("Error processing toxin collision: ", e); + + // Destroy this toxin to avoid recurring error printing spam + return true; + } + } + } +} diff --git a/src/microbe_stage/systems/UnneededCompoundVentingSystem.cs b/src/microbe_stage/systems/UnneededCompoundVentingSystem.cs new file mode 100644 index 00000000000..cf2eaf7ac21 --- /dev/null +++ b/src/microbe_stage/systems/UnneededCompoundVentingSystem.cs @@ -0,0 +1,78 @@ +namespace Systems +{ + using System; + using System.Collections.Generic; + using Components; + using DefaultEcs; + using DefaultEcs.System; + using DefaultEcs.Threading; + using Godot; + using World = DefaultEcs.World; + + /// + /// Handles venting unneeded compounds or compounds that exceed storage capacity from microbes + /// + [With(typeof(UnneededCompoundVenter))] + [With(typeof(CellProperties))] + [With(typeof(CompoundStorage))] + [With(typeof(WorldPosition))] + [Without(typeof(AttachedToEntity))] + public sealed class UnneededCompoundVentingSystem : AEntitySetSystem + { + private readonly CompoundCloudSystem compoundCloudSystem; + private readonly IReadOnlyCollection ventableCompounds; + + public UnneededCompoundVentingSystem(CompoundCloudSystem compoundCloudSystem, World world, + IParallelRunner parallelRunner) : base(world, parallelRunner) + { + this.compoundCloudSystem = compoundCloudSystem; + ventableCompounds = SimulationParameters.Instance.GetCloudCompounds(); + } + + protected override void Update(float delta, in Entity entity) + { + ref var venter = ref entity.Get(); + + if (venter.VentThreshold >= float.MaxValue) + return; + + var compounds = entity.Get().Compounds; + + // Skip until something is marked as useful (set by bio process system) + if (!compounds.HasAnyBeenSetUseful()) + return; + + ref var position = ref entity.Get(); + ref var cellProperties = ref entity.Get(); + + float amountToVent = Constants.COMPOUNDS_TO_VENT_PER_SECOND * delta; + + // Cloud types are ones that can be vented + foreach (var type in ventableCompounds) + { + var capacity = compounds.GetCapacityForCompound(type); + + // Vent if not useful, or if overflowed the capacity + // The multiply by threshold is here to be more kind to cells that have just divided and make it + // much less likely the player often sees their cell venting away their precious compounds + if (!compounds.IsUseful(type)) + { + amountToVent -= + cellProperties.EjectCompound(ref position, compounds, compoundCloudSystem, type, amountToVent, + Vector3.Back); + } + else if (compounds.GetCompoundAmount(type) > venter.VentThreshold * capacity) + { + // Vent the part that went over + float toVent = compounds.GetCompoundAmount(type) - venter.VentThreshold * capacity; + + amountToVent -= cellProperties.EjectCompound(ref position, compounds, compoundCloudSystem, type, + Math.Min(toVent, amountToVent), Vector3.Back); + } + + if (amountToVent <= 0) + break; + } + } + } +} diff --git a/src/modding/IModInterface.cs b/src/modding/IModInterface.cs index 03c3e7ce456..78fe66c4752 100644 --- a/src/modding/IModInterface.cs +++ b/src/modding/IModInterface.cs @@ -1,4 +1,6 @@ -using Godot; +using DefaultEcs; +using DefaultEcs.Command; +using Godot; /// /// This interface provides an interface for mods to interact with the game through an API that will try to stay @@ -20,17 +22,17 @@ public interface IModInterface { public delegate void OnSceneChangedHandler(Node newScene); - public delegate void OnDamageReceivedHandler(Node damageReceiver, float amount, bool isPlayer); + public delegate void OnDamageReceivedHandler(Entity damageReceiver, float amount, bool isPlayer); - public delegate void OnPlayerMicrobeSpawnedHandler(Microbe player); + public delegate void OnPlayerMicrobeSpawnedHandler(Entity player); - public delegate void OnMicrobeSpawnedHandler(Microbe microbe); + public delegate void OnMicrobeSpawnedHandler(EntityRecord microbe); - public delegate void OnChunkSpawnedHandler(FloatingChunk chunk, bool environmental); + public delegate void OnChunkSpawnedHandler(EntityRecord chunk, bool environmental); - public delegate void OnToxinEmittedHandler(AgentProjectile toxin); + public delegate void OnToxinEmittedHandler(EntityRecord toxin); - public delegate void OnMicrobeDiedHandler(Microbe microbe, bool isPlayer); + public delegate void OnMicrobeDiedHandler(Entity microbe, bool isPlayer); // Game events mods can listen to // If something you'd want to use is missing, please request it: diff --git a/src/modding/ModInterface.cs b/src/modding/ModInterface.cs index 3ce10a5ddd4..a494f5a1948 100644 --- a/src/modding/ModInterface.cs +++ b/src/modding/ModInterface.cs @@ -1,4 +1,6 @@ -using Godot; +using DefaultEcs; +using DefaultEcs.Command; +using Godot; /// /// Implementation of the default @@ -11,6 +13,7 @@ public ModInterface(SceneTree sceneTree) } public event IModInterface.OnSceneChangedHandler? OnSceneChanged; + public event IModInterface.OnDamageReceivedHandler? OnDamageReceived; public event IModInterface.OnPlayerMicrobeSpawnedHandler? OnPlayerMicrobeSpawned; public event IModInterface.OnMicrobeSpawnedHandler? OnMicrobeSpawned; @@ -27,32 +30,32 @@ public void TriggerOnSceneChanged(Node newScene) OnSceneChanged?.Invoke(newScene); } - public void TriggerOnDamageReceived(Node damageReceiver, float amount, bool isPlayer) + public void TriggerOnDamageReceived(Entity damageReceiver, float amount, bool isPlayer) { OnDamageReceived?.Invoke(damageReceiver, amount, isPlayer); } - public void TriggerOnPlayerMicrobeSpawned(Microbe player) + public void TriggerOnPlayerMicrobeSpawned(Entity player) { OnPlayerMicrobeSpawned?.Invoke(player); } - public void TriggerOnMicrobeSpawned(Microbe microbe) + public void TriggerOnMicrobeSpawned(EntityRecord microbe) { OnMicrobeSpawned?.Invoke(microbe); } - public void TriggerOnChunkSpawned(FloatingChunk chunk, bool environmental) + public void TriggerOnChunkSpawned(EntityRecord chunk, bool environmental) { OnChunkSpawned?.Invoke(chunk, environmental); } - public void TriggerOnToxinEmitted(AgentProjectile toxin) + public void TriggerOnToxinEmitted(EntityRecord toxin) { OnToxinEmitted?.Invoke(toxin); } - public void TriggerOnMicrobeDied(Microbe microbe, bool isPlayer) + public void TriggerOnMicrobeDied(Entity microbe, bool isPlayer) { OnMicrobeDied?.Invoke(microbe, isPlayer); } diff --git a/src/native/CMakeLists.txt b/src/native/CMakeLists.txt new file mode 100644 index 00000000000..576aefc4aef --- /dev/null +++ b/src/native/CMakeLists.txt @@ -0,0 +1,95 @@ +# Main library of the native side of things (non-Godot using) +add_library(thrive_native SHARED + "${PROJECT_BINARY_DIR}/Include.h" core/ForwardDefinitions.hpp + interop/CInterop.cpp interop/CInterop.h + interop/CStructures.h interop/JoltTypeConversions.hpp + core/Logger.cpp core/Logger.hpp + core/Mutex.hpp + core/NonCopyable.hpp core/Reference.hpp + core/RefCounted.hpp + core/Spinlock.hpp + # core/TaskSystem.cpp core/TaskSystem.hpp + core/Time.hpp + physics/BodyActivationListener.cpp physics/BodyActivationListener.hpp + physics/BodyControlState.hpp + physics/ContactListener.cpp physics/ContactListener.hpp + physics/CustomConstraintTypes.hpp + physics/Layers.hpp + physics/PhysicalWorld.cpp physics/PhysicalWorld.hpp + physics/PhysicsBody.cpp physics/PhysicsBody.hpp + physics/ShapeCreator.cpp physics/ShapeCreator.hpp + physics/ShapeWrapper.cpp physics/ShapeWrapper.hpp + physics/SimpleShapes.cpp physics/SimpleShapes.hpp + physics/TrackedConstraint.cpp physics/TrackedConstraint.hpp + physics/StepListener.cpp physics/StepListener.hpp + physics/DebugDrawForwarder.cpp physics/DebugDrawForwarder.hpp + physics/PhysicsCollision.hpp + physics/PhysicsRayWithUserData.hpp + physics/ArrayRayCollector.hpp) + +if("${CMAKE_CXX_COMPILER_ID}" STREQUAL "MSVC") + target_compile_options(thrive_native PRIVATE /W4 /wd4068) + + if(WARNINGS_AS_ERRORS) + target_compile_options(thrive_native PRIVATE /WX) + endif() +else() + target_compile_options(thrive_native PRIVATE -Wall -Wextra -Wpedantic -Werror + -Wno-unknown-pragmas) + + if(WARNINGS_AS_ERRORS) + target_compile_options(thrive_native PRIVATE -Werror) + endif() +endif() + + +target_compile_definitions(thrive_native PRIVATE THRIVE_NATIVE_BUILD) + +target_link_libraries(thrive_native PRIVATE Jolt) +target_link_libraries(thrive_native PUBLIC Boost::intrusive + Boost::circular_buffer Boost::pool) + +target_include_directories(thrive_native PUBLIC ${CMAKE_CURRENT_LIST_DIR}) + +# target_include_directories(thrive_native PUBLIC "../../third_party/JoltPhysics/") + +# TODO: the one private precompiled header will probably cause issues with +# other libraries linking to this +target_precompile_headers(thrive_native PUBLIC "${PROJECT_BINARY_DIR}/Include.h" + "core/ForwardDefinitions.hpp" "core/RefCounted.hpp" + PRIVATE "../../third_party/JoltPhysics/Jolt/Jolt.h") + +set_target_properties(thrive_native PROPERTIES + CXX_STANDARD 20 + CXX_EXTENSIONS OFF) + +if(UNIX OR CMAKE_GENERATOR MATCHES "Visual Studio") + # This seems to fail native Windows builds (when trying to use msys2) + set_target_properties(thrive_native PROPERTIES INTERPROCEDURAL_OPTIMIZATION ON) +else() + message(STATUS "Not enabling interprocedural optimization (lto) " + "as it is probably not working on this platform") +endif() + +message(STATUS "TODO: static standard lib for easier distributing (make sure it is the clang one)") +# # Static standard lib +# target_link_libraries(thrive_native PRIVATE -static-libgcc -static-libstdc++) + +# # Fully static executable +# if(CMAKE_SYSTEM_NAME STREQUAL "Windows") +# target_link_libraries(thrive_native PRIVATE -static) +# endif() + +# if(CMAKE_CXX_COMPILER_ID STREQUAL "Clang") +# set_target_properties(thrive_native PROPERTIES LINK_FLAGS_RELEASE "-fuse-ld=lld") +# elseif(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") +# set_target_properties(thrive_native PROPERTIES LINK_FLAGS_RELEASE "-fuse-ld=gold") +# endif() + +if(NOT WIN32) + set_target_properties(thrive_native PROPERTIES LINK_FLAGS_RELEASE "-fuse-ld=gold") +else() + # set_target_properties(thrive_native PROPERTIES LINK_FLAGS_RELEASE "") +endif() + +install(TARGETS thrive_native) diff --git a/src/native/Include.h.in b/src/native/Include.h.in new file mode 100644 index 00000000000..458329822f6 --- /dev/null +++ b/src/native/Include.h.in @@ -0,0 +1,96 @@ +#pragma once + +// +// File configured by CMake do not edit Include.h (you can edit Include.h.in) +// + +#ifdef __cplusplus +#include +#include +#include +#endif + +// clang-format off +#define THRIVE_LIBRARY_VERSION @NATIVE_LIBRARY_VERSION@ +// clang-format on + +#ifdef THRIVE_NATIVE_BUILD +#ifdef WIN32 +#define THRIVE_NATIVE_API __declspec(dllexport) +#else +#define THRIVE_NATIVE_API __attribute__((visibility("default"))) +#endif // WIN32 +#else +#ifdef WIN32 +#define THRIVE_NATIVE_API __declspec(dllimport) +#else +#define THRIVE_NATIVE_API __attribute__((visibility("default"))) +#endif // WIN32 +#endif // THRIVE_NATIVE_BUILD + +#cmakedefine USE_OBJECT_POOLS + +#cmakedefine NULL_HAS_UNUSUAL_REPRESENTATION + +#cmakedefine USE_SMALL_VECTOR_POOLS + +#cmakedefine LOCK_FREE_COLLISION_RECORDING + +#ifdef WIN32 +#define FORCE_INLINE __forceinline +#else +#define FORCE_INLINE __attribute__((always_inline)) inline +#endif + +#ifdef _MSC_VER +#define PACKED_STRUCT +#define BEGIN_PACKED_STRUCT __pragma( pack(push, 1) ) +#define END_PACKED_STRUCT __pragma( pack(pop)) +#else +#define PACKED_STRUCT __attribute__((packed)) +#define BEGIN_PACKED_STRUCT +#define END_PACKED_STRUCT +#endif + +#if _MSC_VER +#define HYPER_THREAD_YIELD _mm_pause() +#else +#define HYPER_THREAD_YIELD __builtin_ia32_pause() +#endif + +// 64-bit pointers. TODO: support for 32-bit compiling? +#define POINTER_SIZE 8 + +#define UNUSED(x) (void)x + +// Size in bytes that physics body user data is (used for collision callbacks). Has to be a macro to work in C. +#define PHYSICS_USER_DATA_SIZE 8 + +// Note this only works in 64-bit mode right now. The extra +3 at the end is to account for padding +#define PHYSICS_COLLISION_DATA_SIZE (PHYSICS_USER_DATA_SIZE * 2 + POINTER_SIZE * 2 + 13 + 3) + +// The second + 4 is padding here +#define PHYSICS_RAY_DATA_SIZE (PHYSICS_USER_DATA_SIZE + POINTER_SIZE + 4 + 4) + +// This is needed to allow including this in the C interop header +#ifdef __cplusplus + +namespace Thrive +{ +constexpr float PI = 3.14159265f; +constexpr double PI_DOUBLE = 3.1415926535897932; + +/// Always zero bytes in pointers that stuff extra info in them thanks to alignment requirements +constexpr size_t UNUSED_POINTER_BITS = 3; + +constexpr size_t STUFFED_POINTER_ALIGNMENT = 8; + +constexpr uint64_t STUFFED_POINTER_DATA_MASK = 0x7; + +constexpr uint64_t STUFFED_POINTER_POINTER_MASK = ~STUFFED_POINTER_DATA_MASK; + +constexpr uint32_t COLLISION_UNKNOWN_SUB_SHAPE = std::numeric_limits::max(); + +} // namespace Thrive + +#endif diff --git a/src/native/NativeConstants.cs b/src/native/NativeConstants.cs new file mode 100644 index 00000000000..1e71c75424c --- /dev/null +++ b/src/native/NativeConstants.cs @@ -0,0 +1,4 @@ +public class NativeConstants +{ + public const int Version = 1; +} diff --git a/src/native/core/ForwardDefinitions.hpp b/src/native/core/ForwardDefinitions.hpp new file mode 100644 index 00000000000..1654d12b39e --- /dev/null +++ b/src/native/core/ForwardDefinitions.hpp @@ -0,0 +1,21 @@ +#pragma once + +/// \file Forward definitions for various important classes in this library for reducing header include amounts + +#include "Include.h" + +#include "Reference.hpp" + +namespace Thrive +{ +class TaskSystem; + +namespace Physics +{ +class ContactListener; +class BodyActivationListener; +class PhysicsBody; +class PhysicalWorld; +class TrackedConstraint; +} // namespace Physics +} // namespace Thrive diff --git a/src/native/core/Logger.cpp b/src/native/core/Logger.cpp new file mode 100644 index 00000000000..8f1233403b9 --- /dev/null +++ b/src/native/core/Logger.cpp @@ -0,0 +1,47 @@ +// ------------------------------------ // +#include "Logger.hpp" + +#include + +// ------------------------------------ // +namespace Thrive +{ + +void Logger::Log(std::string_view message, LogLevel level) +{ + // Drop logs that are not important enough with current level + if (level < currentLoggingLevel) + return; + + // TODO: should we have a mutex to lock before outputting? + + if (isRedirected) + { + redirectedLogReceiver(message, level); + return; + } + + std::cout << message << "\n"; + + if (level >= LogLevel::Error && flushOnError) + { + std::cout.flush(); + } +} + +// ------------------------------------ // +void Logger::SetLogTargetOverride(std::function&& logReceiver) +{ + if (!logReceiver) + { + isRedirected = false; + redirectedLogReceiver = nullptr; + } + else + { + redirectedLogReceiver = std::move(logReceiver); + isRedirected = true; + } +} + +} // namespace Thrive diff --git a/src/native/core/Logger.hpp b/src/native/core/Logger.hpp new file mode 100644 index 00000000000..c29e8dd06bb --- /dev/null +++ b/src/native/core/Logger.hpp @@ -0,0 +1,79 @@ +#pragma once + +#include "Include.h" + +namespace Thrive +{ + +enum class LogLevel : int8_t +{ + Debug = 0, + Info = 1, + Warning = 2, + Error = 3 +}; + +/// \brief Provides native side logging support. Forwards logging to the usual Godot log +/// \todo For Visual Studio debugging implement the Windows debugger output writing when outputting to std::cout +class Logger +{ +public: + THRIVE_NATIVE_API static Logger& Get() + { + static Logger instance; + return instance; + } + + THRIVE_NATIVE_API inline void LogDebug(std::string_view message) + { + Log(message, LogLevel::Debug); + } + + THRIVE_NATIVE_API inline void LogInfo(std::string_view message) + { + Log(message, LogLevel::Info); + } + + THRIVE_NATIVE_API inline void LogWarning(std::string_view message) + { + Log(message, LogLevel::Warning); + } + + THRIVE_NATIVE_API inline void LogError(std::string_view message) + { + Log(message, LogLevel::Error); + } + + THRIVE_NATIVE_API void Log(std::string_view message, LogLevel level); + + /// \brief Sets the log level which controls what log messages actually get passed + THRIVE_NATIVE_API void SetLogLevel(LogLevel level){ + currentLoggingLevel = level; + } + + /// \brief Overrides the log output from standard to the given methods + /// + /// This is used by the managed side of things to setup logging + /// \see CInterop.h + THRIVE_NATIVE_API void SetLogTargetOverride(std::function&& logReceiver); + + /// \brief When flush on error is on, the output is flushed on each error message + THRIVE_NATIVE_API void SetFlushOnError(bool flush){ + flushOnError = flush; + } + +private: + bool flushOnError = true; + + bool isRedirected = false; + std::function redirectedLogReceiver; + + LogLevel currentLoggingLevel = LogLevel::Info; +}; + +}; // namespace Thrive + +#define LOG_DEBUG(x) Thrive::Logger::Get().LogDebug(x) +#define LOG_INFO(x) Thrive::Logger::Get().LogInfo(x) +#define LOG_WARNING(x) Thrive::Logger::Get().LogWarning(x) +#define LOG_ERROR(x) Thrive::Logger::Get().LogError(x) diff --git a/src/native/core/Mutex.hpp b/src/native/core/Mutex.hpp new file mode 100644 index 00000000000..ba94a6b6526 --- /dev/null +++ b/src/native/core/Mutex.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include +#include + +// Jolt says the mutex is not fast for all platforms so here's some flexibility to allow redefining it + +namespace Thrive +{ +using Mutex = std::mutex; +using SharedMutex = std::shared_mutex; +using Lock = std::lock_guard; +using SharedLock = std::lock_guard; +} // namespace Thrive diff --git a/src/native/core/NonCopyable.hpp b/src/native/core/NonCopyable.hpp new file mode 100644 index 00000000000..5974f648248 --- /dev/null +++ b/src/native/core/NonCopyable.hpp @@ -0,0 +1,20 @@ +#pragma once + +namespace Thrive +{ + +/// \brief Disables copying for all derived classes +class NonCopyable +{ +protected: + NonCopyable() = default; + +public: + NonCopyable(const NonCopyable& other) = delete; + NonCopyable(NonCopyable&& other) = delete; + + NonCopyable operator=(const NonCopyable& other) = delete; + NonCopyable operator=(NonCopyable&& other) = delete; +}; + +} // namespace Thrive diff --git a/src/native/core/RefCounted.hpp b/src/native/core/RefCounted.hpp new file mode 100644 index 00000000000..bdc39b3241f --- /dev/null +++ b/src/native/core/RefCounted.hpp @@ -0,0 +1,210 @@ +#pragma once + +#include + +#include "boost/intrusive_ptr.hpp" + +#include "Include.h" + +#include "NonCopyable.hpp" +#include "Reference.hpp" + +#ifdef USE_OBJECT_POOLS +#include "boost/pool/singleton_pool.hpp" + +#include "Reference.hpp" +#endif + +namespace Thrive +{ + +/// \brief Base for all reference counted objects (can't be used directly) +/// +/// We use intrusive pointers for our objects to allow the C interop interface to also work +class RefCountedBase : NonCopyable +{ + // Protected to avoid anyone using this class directly +protected: + inline THRIVE_NATIVE_API RefCountedBase() : refCount(0) + { + // The reference count is started at one so that it is easy to create an instance of this class and just then + // put this in an intrusive_ptr + } + + inline THRIVE_NATIVE_API virtual ~RefCountedBase() noexcept = default; + +public: + /// \brief Adds one to the reference count of this object + FORCE_INLINE void AddRef() const + { + intrusive_ptr_add_ref(this); + } + +protected: + friend void intrusive_ptr_add_ref(const RefCountedBase* obj) + { + // TODO: have someone really knowledgeable determine what's the right memory order here and in release + // https://en.cppreference.com/w/cpp/atomic/memory_order + + // Jolt also uses this approach, so it should be good + obj->refCount.fetch_add(1, std::memory_order_relaxed); + + // obj->refCount.fetch_add(1, std::memory_order_release); + } + +protected: + mutable std::atomic_int_fast32_t refCount; +}; + +/// \brief RefCounted variant that uses a custom release method to delete the object (used for pooled objects) +template +class RefCountedWithRelease : public RefCountedBase +{ +public: + using ReleaseCallback = void (*)(const AllocatedPtrT* ptr); + + // Protected to avoid anyone using this class directly +protected: + explicit inline THRIVE_NATIVE_API RefCountedWithRelease(ReleaseCallback deleteCallback) : + customDelete(deleteCallback) + { + } + +public: + /// \brief removes a reference and deletes the object with the custom callback if the reference count falls to 0 + FORCE_INLINE void Release() const + { + // This cast to derived type is needed here to make the call work. This should be fine hopefully if no one is + // crazy enough to give the wrong class as template parameter to this class when inheriting from this. + intrusive_ptr_release(static_cast(this)); + } + +protected: + friend void intrusive_ptr_release(const AllocatedPtrT* obj) + { + // if (obj->refCount.fetch_sub(1, std::memory_order_acq_rel) == 1) + // Jolt also uses this + if (obj->refCount.fetch_sub(1, std::memory_order_release) == 1) + { + std::atomic_thread_fence(std::memory_order_acquire); + + obj->customDelete(obj); + } + } + +private: + ReleaseCallback customDelete; +}; + +/// \brief RefCounted variant that doesn't have a custom release method for slightly more performance in cases that +/// don't need the extra complexity +class RefCountedBasic : public RefCountedBase +{ +public: + /// \brief removes a reference and deletes the object if reference count reaches zero + FORCE_INLINE void Release() const + { + intrusive_ptr_release(this); + } + +protected: + friend void intrusive_ptr_release(const RefCountedBasic* obj) + { + // if (obj->refCount.fetch_sub(1, std::memory_order_acq_rel) == 1) + // Jolt also uses this + if (obj->refCount.fetch_sub(1, std::memory_order_release) == 1) + { + std::atomic_thread_fence(std::memory_order_acquire); + delete obj; + } + } +}; + +#ifdef USE_OBJECT_POOLS + +// Releasing global pool using helpers +template +inline void ReleaseWithGlobalPool(const ObjectT* obj) +{ + // Pool handles just bytes meaning we need to handle destroying the object + obj->~ObjectT(); + + // Cast the pointer back to void and remove the const qualifier to get the original pointer result of the pool + // malloc method back in order to free the object for reuse + boost::singleton_pool::free(const_cast(static_cast(obj))); +} + +template +FORCE_INLINE inline void ReleaseWithGlobalPool(const ObjectT* obj) +{ + ReleaseWithGlobalPool(obj); +} + +// Raw pointer object allocations from their global pool, note the non-raw variants should be preferred in user code +// for safety. These raw variants have to manually be wrapped in an intrusive_ptr or called AddRef on +template +inline ObjectT* ConstructFromGlobalPoolRaw(ArgT&& arg) +{ + // Pool handles only bytes so use a placement new to construct the instance + void* ptr = boost::singleton_pool::malloc(); + + return new (ptr) ObjectT(std::forward(arg), &ReleaseWithGlobalPool); +} + +template +FORCE_INLINE inline ObjectT* ConstructFromGlobalPoolRaw(ArgT&& arg) +{ + return ConstructFromGlobalPoolRaw(std::forward(arg)); +} + +template +inline ObjectT* ConstructFromGlobalPoolRaw(ArgT&&... arg) +{ + void* ptr = boost::singleton_pool::malloc(); + + return new (ptr) ObjectT(std::forward(arg)..., &ReleaseWithGlobalPool); +} + +template +FORCE_INLINE inline ObjectT* ConstructFromGlobalPoolRaw(ArgT&&... arg) +{ + return ConstructFromGlobalPoolRaw(std::forward(arg)...); +} + +// Constructing objects from their global pool and returning them as safe Ref (intrusive_ptr) that holds the object +template +inline Ref ConstructFromGlobalPool(ArgT&& arg) +{ + return Ref( + ConstructFromGlobalPoolRaw(std::forward(arg))); +} + +template +FORCE_INLINE inline Ref ConstructFromGlobalPool(ArgT&& arg) +{ + return ConstructFromGlobalPool(std::forward(arg)); +} + +template +inline Ref ConstructFromGlobalPool(ArgT&&... arg) +{ + return Ref( + ConstructFromGlobalPoolRaw(std::forward(arg)...)); +} + +template +FORCE_INLINE inline Ref ConstructFromGlobalPool(ArgT&&... arg) +{ + return ConstructFromGlobalPool(std::forward(arg)...); +} + +/// When using pooling the reference counted type to use needs a callback to deallocate from the right pool +template +using RefCounted = RefCountedWithRelease; +#else +/// Plain reference counted type. This takes in a template argument to be syntax compatible with the pooled variant +template +using RefCounted = RefCountedBasic; +#endif + +} // namespace Thrive diff --git a/src/native/core/Reference.hpp b/src/native/core/Reference.hpp new file mode 100644 index 00000000000..91a3569c616 --- /dev/null +++ b/src/native/core/Reference.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include "boost/intrusive_ptr.hpp" + +namespace Thrive +{ +template +using Ref = boost::intrusive_ptr; + +template +using RefConst = boost::intrusive_ptr; + +} // namespace Thrive diff --git a/src/native/core/Spinlock.hpp b/src/native/core/Spinlock.hpp new file mode 100644 index 00000000000..a95515ffa76 --- /dev/null +++ b/src/native/core/Spinlock.hpp @@ -0,0 +1,60 @@ +#pragma once + +#include + +#include "NonCopyable.hpp" + +namespace Thrive +{ + +/// \brief A simple spinlock implemented with an atomic flag +/// +/// For extra info see: https://www.talkinghightech.com/en/implementing-a-spinlock-in-c/ and +/// https://rigtorp.se/spinlock/ +class Spinlock final : NonCopyable +{ +public: + Spinlock() = default; + + void Lock() noexcept + { + while (true) + { + // Optimized approach by first trying to just get the flag once + if (!lockedFlag.test_and_set(std::memory_order_acquire)) + { + // Lock was acquired + break; + } + + // And if it fails falling back to a cheaper memory order to keep testing when the flag is unset and going + // back to retrying the lock + while (lockedFlag.test(std::memory_order_relaxed)) + { + // Spin while waiting + + // Reduce hyperthreaded core usage to work more efficiently on modern systems + HYPER_THREAD_YIELD; + + // TODO: create a hybrid lock variant that falls back to a condition variable after some time to let + // the thread sleep + } + } + } + + bool TryLock() noexcept + { + // This does a test first to optimize the case where TryLock is used in a while loop + return !lockedFlag.test(std::memory_order_relaxed) && !lockedFlag.test_and_set(std::memory_order_acquire); + } + + void Unlock() noexcept + { + lockedFlag.clear(std::memory_order_release); + } + +private: + std::atomic_flag lockedFlag = ATOMIC_FLAG_INIT; +}; + +} // namespace Thrive diff --git a/src/native/core/Time.hpp b/src/native/core/Time.hpp new file mode 100644 index 00000000000..bf4c986ee33 --- /dev/null +++ b/src/native/core/Time.hpp @@ -0,0 +1,15 @@ +#pragma once + +#include +#include + +namespace Thrive +{ +using MillisecondDuration = std::chrono::duration; +using MicrosecondDuration = std::chrono::duration; +using SecondDuration = std::chrono::duration>; +using PreciseSecondDuration = std::chrono::duration>; + +using TimingClock = std::chrono::high_resolution_clock; +using SteadyClock = std::chrono::steady_clock; +} // namespace Thrive diff --git a/src/native/interop/CInterop.cpp b/src/native/interop/CInterop.cpp new file mode 100644 index 00000000000..5602d2d738f --- /dev/null +++ b/src/native/interop/CInterop.cpp @@ -0,0 +1,652 @@ +// ------------------------------------ // +#include "CInterop.h" + +#include +#include + +#include "Jolt/Core/Factory.h" +#include "Jolt/Core/Memory.h" +#include "Jolt/Jolt.h" +#include "Jolt/RegisterTypes.h" + +#include "physics/DebugDrawForwarder.hpp" +#include "physics/PhysicalWorld.hpp" +#include "physics/PhysicsBody.hpp" +#include "physics/ShapeCreator.hpp" +#include "physics/ShapeWrapper.hpp" +#include "physics/SimpleShapes.hpp" +#include "physics/TrackedConstraint.hpp" + +#include "JoltTypeConversions.hpp" + +#ifdef USE_OBJECT_POOLS +#include "boost/pool/singleton_pool.hpp" +#endif + +#pragma clang diagnostic push +#pragma ide diagnostic ignored "cppcoreguidelines-pro-type-reinterpret-cast" + +// ------------------------------------ // +void PhysicsTrace(const char* fmt, ...); + +#ifdef USE_OBJECT_POOLS +using ShapePool = boost::singleton_pool; +#endif + +#ifdef JPH_ENABLE_ASSERTS +bool PhysicsAssert(const char* expression, const char* message, const char* file, unsigned int line); +#endif + +int32_t CheckAPIVersion() +{ + return THRIVE_LIBRARY_VERSION; +} + +int32_t InitThriveLibrary() +{ + // Register physics things + JPH::RegisterDefaultAllocator(); + + JPH::Trace = PhysicsTrace; + JPH_IF_ENABLE_ASSERTS(JPH::AssertFailed = PhysicsAssert;) + + JPH::Factory::sInstance = new JPH::Factory(); + + JPH::RegisterTypes(); + + LOG_DEBUG("Native library init succeeded"); + return 0; +} + +void ShutdownThriveLibrary() +{ + // Unregister physics + JPH::UnregisterTypes(); + + delete JPH::Factory::sInstance; + JPH::Factory::sInstance = nullptr; + + SetLogForwardingCallback(nullptr); +} + +// ------------------------------------ // +void SetLogLevel(int8_t level) +{ + Thrive::Logger::Get().SetLogLevel(static_cast(level)); +} + +void SetLogForwardingCallback(OnLogMessage callback) +{ + if (callback == nullptr) + { + Thrive::Logger::Get().SetLogTargetOverride(nullptr); + } + else + { + Thrive::Logger::Get().SetLogTargetOverride([callback](std::string_view message, Thrive::LogLevel level) + { callback(message.data(), static_cast(message.length()), static_cast(level)); }); + + LOG_DEBUG("Native log message forwarding setup"); + } +} + +// ------------------------------------ // +PhysicalWorld* CreatePhysicalWorld() +{ + return reinterpret_cast(new Thrive::Physics::PhysicalWorld()); +} + +void DestroyPhysicalWorld(PhysicalWorld* physicalWorld) +{ + if (physicalWorld == nullptr) + return; + + delete reinterpret_cast(physicalWorld); +} + +bool ProcessPhysicalWorld(PhysicalWorld* physicalWorld, float delta) +{ + return reinterpret_cast(physicalWorld)->Process(delta); +} + +PhysicsBody* PhysicalWorldCreateMovingBody( + PhysicalWorld* physicalWorld, PhysicsShape* shape, JVec3 position, JQuat rotation, bool addToWorld) +{ + const auto body = reinterpret_cast(physicalWorld) + ->CreateMovingBody(reinterpret_cast(shape)->GetShape(), + Thrive::DVec3FromCAPI(position), Thrive::QuatFromCAPI(rotation), addToWorld); + + if (body) + body->AddRef(); + + return reinterpret_cast(body.get()); +} + +PhysicsBody* PhysicalWorldCreateMovingBodyWithAxisLock(PhysicalWorld* physicalWorld, PhysicsShape* shape, + JVec3 position, JQuat rotation, JVecF3 lockedAxes, bool lockRotation, bool addToWorld) +{ + const auto body = + reinterpret_cast(physicalWorld) + ->CreateMovingBodyWithAxisLock(reinterpret_cast(shape)->GetShape(), + Thrive::DVec3FromCAPI(position), Thrive::QuatFromCAPI(rotation), Thrive::Vec3FromCAPI(lockedAxes), + lockRotation, addToWorld); + + if (body) + body->AddRef(); + + return reinterpret_cast(body.get()); +} + +PhysicsBody* PhysicalWorldCreateStaticBody( + PhysicalWorld* physicalWorld, PhysicsShape* shape, JVec3 position, JQuat rotation, bool addToWorld) +{ + const auto body = reinterpret_cast(physicalWorld) + ->CreateStaticBody(reinterpret_cast(shape)->GetShape(), + Thrive::DVec3FromCAPI(position), Thrive::QuatFromCAPI(rotation), addToWorld); + + if (body) + body->AddRef(); + + return reinterpret_cast(body.get()); +} + +void PhysicalWorldAddBody(PhysicalWorld* physicalWorld, PhysicsBody* body, bool activate) +{ + if (body == nullptr) + return; + + reinterpret_cast(physicalWorld) + ->AddBody(*reinterpret_cast(body), activate); +} + +void PhysicalWorldDetachBody(PhysicalWorld* physicalWorld, PhysicsBody* body) +{ + if (body == nullptr) + return; + + reinterpret_cast(physicalWorld) + ->DetachBody(*reinterpret_cast(body)); +} + +void DestroyPhysicalWorldBody(PhysicalWorld* physicalWorld, PhysicsBody* body) +{ + if (physicalWorld == nullptr || body == nullptr) + return; + + reinterpret_cast(physicalWorld) + ->DestroyBody(reinterpret_cast(body)); +} + +void SetPhysicsBodyLinearDamping(PhysicalWorld* physicalWorld, PhysicsBody* body, float damping) +{ + reinterpret_cast(physicalWorld) + ->SetDamping(reinterpret_cast(body)->GetId(), damping, nullptr); +} + +void SetPhysicsBodyLinearAndAngularDamping( + PhysicalWorld* physicalWorld, PhysicsBody* body, float linearDamping, float angularDamping) +{ + reinterpret_cast(physicalWorld) + ->SetDamping(reinterpret_cast(body)->GetId(), linearDamping, &angularDamping); +} + +#pragma clang diagnostic push +#pragma ide diagnostic ignored "cppcoreguidelines-pro-type-member-init" + +void ReadPhysicsBodyTransform( + PhysicalWorld* physicalWorld, PhysicsBody* body, JVec3* positionReceiver, JQuat* rotationReceiver) +{ +#ifndef NDEBUG + if (physicalWorld == nullptr || body == nullptr || positionReceiver == nullptr || rotationReceiver == nullptr) + { + LOG_ERROR("Physics body read transform call with invalid parameters"); + return; + } +#endif + + JPH::DVec3 readPosition; + JPH::Quat readQuat; + + reinterpret_cast(physicalWorld) + ->ReadBodyTransform(reinterpret_cast(body)->GetId(), readPosition, readQuat); + + *positionReceiver = Thrive::DVec3ToCAPI(readPosition); + *rotationReceiver = Thrive::QuatToCAPI(readQuat); +} + +void ReadPhysicsBodyVelocity( + PhysicalWorld* physicalWorld, PhysicsBody* body, JVecF3* velocityReceiver, JVecF3* angularVelocityReceiver) +{ +#ifndef NDEBUG + if (physicalWorld == nullptr || body == nullptr || velocityReceiver == nullptr || + angularVelocityReceiver == nullptr) + { + LOG_ERROR("Physics body read velocity call with invalid parameters"); + return; + } +#endif + + JPH::Vec3 readVelocity; + JPH::Vec3 readAngular; + + reinterpret_cast(physicalWorld) + ->ReadBodyVelocity(reinterpret_cast(body)->GetId(), readVelocity, readAngular); + + *velocityReceiver = Thrive::Vec3ToCAPI(readVelocity); + *angularVelocityReceiver = Thrive::Vec3ToCAPI(readAngular); +} + +#pragma clang diagnostic pop + +void GiveImpulse(PhysicalWorld* physicalWorld, PhysicsBody* body, JVecF3 impulse) +{ + reinterpret_cast(physicalWorld) + ->GiveImpulse(reinterpret_cast(body)->GetId(), Thrive::Vec3FromCAPI(impulse)); +} + +void GiveAngularImpulse(PhysicalWorld* physicalWorld, PhysicsBody* body, JVecF3 angularImpulse) +{ + reinterpret_cast(physicalWorld) + ->GiveAngularImpulse( + reinterpret_cast(body)->GetId(), Thrive::Vec3FromCAPI(angularImpulse)); +} + +void SetBodyControl( + PhysicalWorld* physicalWorld, PhysicsBody* body, JVecF3 movementImpulse, JQuat targetRotation, float rotationRate) +{ + if (physicalWorld == nullptr || body == nullptr) + { + LOG_ERROR("Invalid call to physics body applying control"); + return; + } + + reinterpret_cast(physicalWorld) + ->SetBodyControl(*reinterpret_cast(body), Thrive::Vec3FromCAPI(movementImpulse), + Thrive::QuatFromCAPI(targetRotation), rotationRate); +} + +void DisableBodyControl(PhysicalWorld* physicalWorld, PhysicsBody* body) +{ + if (physicalWorld == nullptr || body == nullptr) + { + return; + } + + reinterpret_cast(physicalWorld) + ->DisableBodyControl(*reinterpret_cast(body)); +} + +void SetBodyPosition(PhysicalWorld* physicalWorld, PhysicsBody* body, JVec3 position, bool activate) +{ + reinterpret_cast(physicalWorld) + ->SetPosition( + reinterpret_cast(body)->GetId(), Thrive::DVec3FromCAPI(position), activate); +} + +void SetBodyVelocity(PhysicalWorld* physicalWorld, PhysicsBody* body, JVecF3 velocity) +{ + reinterpret_cast(physicalWorld) + ->SetVelocity(reinterpret_cast(body)->GetId(), Thrive::Vec3FromCAPI(velocity)); +} + +void SetBodyAngularVelocity(PhysicalWorld* physicalWorld, PhysicsBody* body, JVecF3 angularVelocity) +{ + reinterpret_cast(physicalWorld) + ->SetAngularVelocity( + reinterpret_cast(body)->GetId(), Thrive::Vec3FromCAPI(angularVelocity)); +} + +void SetBodyVelocityAndAngularVelocity( + PhysicalWorld* physicalWorld, PhysicsBody* body, JVecF3 velocity, JVecF3 angularVelocity) +{ + reinterpret_cast(physicalWorld) + ->SetVelocityAndAngularVelocity(reinterpret_cast(body)->GetId(), + Thrive::Vec3FromCAPI(velocity), Thrive::Vec3FromCAPI(angularVelocity)); +} + +void SetBodyAllowSleep(PhysicalWorld* physicalWorld, PhysicsBody* body, bool allowSleep) +{ + reinterpret_cast(physicalWorld) + ->SetBodyAllowSleep(reinterpret_cast(body)->GetId(), allowSleep); +} + +bool FixBodyYCoordinateToZero(PhysicalWorld* physicalWorld, PhysicsBody* body) +{ + return reinterpret_cast(physicalWorld) + ->FixBodyYCoordinateToZero(reinterpret_cast(body)->GetId()); +} + +void ChangeBodyShape(PhysicalWorld* physicalWorld, PhysicsBody* body, PhysicsShape* shape, bool activate) +{ + return reinterpret_cast(physicalWorld) + ->ChangeBodyShape(reinterpret_cast(body)->GetId(), + reinterpret_cast(shape)->GetShape(), activate); +} + +void PhysicsBodyAddAxisLock(PhysicalWorld* physicalWorld, PhysicsBody* body, JVecF3 axis, bool lockRotation) +{ + reinterpret_cast(physicalWorld) + ->CreateAxisLockConstraint( + *reinterpret_cast(body), Thrive::Vec3FromCAPI(axis), lockRotation); +} + +// ------------------------------------ // +void PhysicsBodySetCollisionEnabledState(PhysicalWorld* physicalWorld, PhysicsBody* body, bool collisionsEnabled) +{ + reinterpret_cast(physicalWorld) + ->SetCollisionDisabledState(*reinterpret_cast(body), !collisionsEnabled); +} + +// ------------------------------------ // +void PhysicsBodyAddCollisionIgnore(PhysicalWorld* physicalWorld, PhysicsBody* body, PhysicsBody* addIgnore) +{ + bool handleDuplicates = true; + + reinterpret_cast(physicalWorld) + ->AddCollisionIgnore(*reinterpret_cast(body), + *reinterpret_cast(addIgnore), handleDuplicates); +} + +void PhysicsBodyRemoveCollisionIgnore(PhysicalWorld* physicalWorld, PhysicsBody* body, PhysicsBody* removeIgnore) +{ + reinterpret_cast(physicalWorld) + ->RemoveCollisionIgnore(*reinterpret_cast(body), + *reinterpret_cast(removeIgnore)); +} + +void PhysicsBodyClearCollisionIgnores(PhysicalWorld* physicalWorld, PhysicsBody* body) +{ + reinterpret_cast(physicalWorld) + ->ClearCollisionIgnores(*reinterpret_cast(body)); +} + +void PhysicsBodySetCollisionIgnores( + PhysicalWorld* physicalWorld, PhysicsBody* body, PhysicsBody* ignoredBodies[], int32_t count) +{ + reinterpret_cast(physicalWorld) + ->SetCollisionIgnores(*reinterpret_cast(body), + reinterpret_cast(ignoredBodies), count); +} + +void PhysicsBodyClearAndSetSingleIgnore(PhysicalWorld* physicalWorld, PhysicsBody* body, PhysicsBody* onlyIgnoredBody) +{ + reinterpret_cast(physicalWorld) + ->SetSingleCollisionIgnore(*reinterpret_cast(body), + *reinterpret_cast(onlyIgnoredBody)); +} + +// ------------------------------------ // +int32_t* PhysicsBodyEnableCollisionRecording( + PhysicalWorld* physicalWorld, PhysicsBody* body, char* collisionRecordingTarget, int32_t maxRecordedCollisions) +{ + return const_cast( + reinterpret_cast(physicalWorld) + ->EnableCollisionRecording(*reinterpret_cast(body), + reinterpret_cast(collisionRecordingTarget), + maxRecordedCollisions)); +} + +void PhysicsBodyDisableCollisionRecording(PhysicalWorld* physicalWorld, PhysicsBody* body) +{ + reinterpret_cast(physicalWorld) + ->DisableCollisionRecording(*reinterpret_cast(body)); +} + +void PhysicsBodyAddCollisionFilter(PhysicalWorld* physicalWorld, PhysicsBody* body, OnFilterPhysicsCollision callback) +{ + reinterpret_cast(physicalWorld) + ->AddCollisionFilter(*reinterpret_cast(body), + reinterpret_cast(callback)); +} + +void PhysicsBodyDisableCollisionFilter(PhysicalWorld* physicalWorld, PhysicsBody* body) +{ + reinterpret_cast(physicalWorld) + ->DisableCollisionFilter(*reinterpret_cast(body)); +} + +// ------------------------------------ // +void PhysicalWorldSetGravity(PhysicalWorld* physicalWorld, JVecF3 gravity) +{ + return reinterpret_cast(physicalWorld)->SetGravity(Thrive::Vec3FromCAPI(gravity)); +} + +void PhysicalWorldRemoveGravity(PhysicalWorld* physicalWorld) +{ + return reinterpret_cast(physicalWorld)->RemoveGravity(); +} + +// ------------------------------------ // +int32_t PhysicalWorldCastRayGetAll( + PhysicalWorld* physicalWorld, JVec3 start, JVecF3 endOffset, PhysicsRayWithUserData* dataReceiver, int32_t maxHits) +{ + return reinterpret_cast(physicalWorld) + ->CastRayGetAllUserData(Thrive::DVec3FromCAPI(start), Thrive::Vec3FromCAPI(endOffset), + reinterpret_cast(dataReceiver), maxHits); +} + +// ------------------------------------ // +float PhysicalWorldGetPhysicsLatestTime(PhysicalWorld* physicalWorld) +{ + return reinterpret_cast(physicalWorld)->GetLatestPhysicsTime(); +} + +float PhysicalWorldGetPhysicsAverageTime(PhysicalWorld* physicalWorld) +{ + return reinterpret_cast(physicalWorld)->GetAveragePhysicsTime(); +} + +bool PhysicalWorldDumpPhysicsState(PhysicalWorld* physicalWorld, const char* path) +{ + return reinterpret_cast(physicalWorld)->DumpSystemState(path); +} + +void PhysicalWorldSetDebugDrawLevel(PhysicalWorld* physicalWorld, int32_t level) +{ + return reinterpret_cast(physicalWorld)->SetDebugLevel(level); +} + +void PhysicalWorldSetDebugDrawCameraLocation(PhysicalWorld* physicalWorld, JVecF3 position) +{ + return reinterpret_cast(physicalWorld) + ->SetDebugCameraLocation(Thrive::Vec3FromCAPI(position)); +} + +// ------------------------------------ // +void ReleasePhysicsBodyReference(PhysicsBody* body) +{ + if (body == nullptr) + return; + + reinterpret_cast(body)->Release(); +} + +void PhysicsBodySetUserData(PhysicsBody* body, const char* data, int32_t dataLength) +{ + if (!reinterpret_cast(body)->SetUserData(data, dataLength)) + { + LOG_ERROR("PhysicsBodySetUserData: called with wrong data length, cannot store the data"); + } +} + +void PhysicsBodyForceClearRecordingTargets(PhysicsBody* body) +{ + reinterpret_cast(body)->ClearCollisionRecordingTarget(); +} + +// ------------------------------------ // +template +inline Thrive::Physics::ShapeWrapper* CreateShapeWrapper(ArgsT&&... args) +{ + Thrive::Physics::ShapeWrapper* result; + +#ifdef USE_OBJECT_POOLS + result = Thrive::ConstructFromGlobalPoolRaw(std::forward(args)...); +#else + result = new Thrive::Physics::ShapeWrapper(std::forward(args)...); +#endif + + if (!result) + LOG_ERROR("Failed to allocate ShapeWrapper"); + + result->AddRef(); + return result; +} + +PhysicsShape* CreateBoxShape(float halfSideLength, float density) +{ + return reinterpret_cast( + CreateShapeWrapper(Thrive::Physics::SimpleShapes::CreateBox(halfSideLength, density))); +} + +PhysicsShape* CreateBoxShapeWithDimensions(JVecF3 halfDimensions, float density) +{ + return reinterpret_cast( + CreateShapeWrapper(Thrive::Physics::SimpleShapes::CreateBox(Thrive::Vec3FromCAPI(halfDimensions), density))); +} + +PhysicsShape* CreateSphereShape(float radius, float density) +{ + return reinterpret_cast( + CreateShapeWrapper(Thrive::Physics::SimpleShapes::CreateSphere(radius, density))); +} + +PhysicsShape* CreateCylinderShape(float halfHeight, float radius, float density) +{ + return reinterpret_cast( + CreateShapeWrapper(Thrive::Physics::SimpleShapes::CreateCylinder(halfHeight, radius, density))); +} + +PhysicsShape* CreateMicrobeShapeConvex(JVecF3* points, uint32_t pointCount, float density, float scale, float thickness) +{ + // We don't want to do any extra data copies here (as the C# marshalling already copies stuff) so this API takes + // in the JVecF3 pointer + + return reinterpret_cast(CreateShapeWrapper( + Thrive::Physics::ShapeCreator::CreateMicrobeShapeConvex(points, pointCount, density, scale, thickness))); +} + +PhysicsShape* CreateMicrobeShapeSpheres(JVecF3* points, uint32_t pointCount, float density, float scale) +{ + // We don't want to do any extra data copies here (as the C# marshalling already copies stuff) so this API takes + // in the JVecF3 pointer + + return reinterpret_cast(CreateShapeWrapper( + Thrive::Physics::ShapeCreator::CreateMicrobeShapeSpheres(points, pointCount, density, scale))); +} + +PhysicsShape* CreateConvexShape(JVecF3* points, uint32_t pointCount, float density) +{ + return reinterpret_cast( + CreateShapeWrapper(Thrive::Physics::ShapeCreator::CreateConvex(points, pointCount, density))); +} + +PhysicsShape* CreateStaticCompoundShape(SubShapeDefinition* subShapes, uint32_t shapeCount) +{ + return reinterpret_cast(CreateShapeWrapper(Thrive::Physics::ShapeCreator::CreateStaticCompound( + reinterpret_cast(subShapes), shapeCount))); +} + +// ------------------------------------ // +void ReleaseShape(PhysicsShape* shape) +{ + if (shape == nullptr) + return; + + reinterpret_cast(shape)->Release(); +} + +// ------------------------------------ // +float ShapeGetMass(PhysicsShape* shape) +{ + return reinterpret_cast(shape)->GetShape()->GetMassProperties().mMass; +} + +JVecF3 ShapeCalculateResultingAngularVelocity(PhysicsShape* shape, JVecF3 appliedTorque, float deltaTime) +{ + // This approach is created by combining MotionProperties::ApplyForceTorqueAndDragInternal and + // MultiplyWorldSpaceInverseInertiaByVector + + const auto& massProperties = + reinterpret_cast(shape)->GetShape()->GetMassProperties(); + + const auto inverseRotationInertia = massProperties.mInertia.Inversed().GetRotation(); + + const auto mInvInertiaDiagonal = inverseRotationInertia.GetDiagonal3(); + const auto mInertiaRotation = inverseRotationInertia.GetQuaternion().Normalized(); + + const auto accumulatedTorque = Thrive::Vec3FromCAPI(appliedTorque); + const auto assumedBodyRotation = JPH::Quat::sIdentity(); + + JPH::Mat44 rotation = JPH::Mat44::sRotation(assumedBodyRotation * mInertiaRotation); + auto result = rotation.Multiply3x3(mInvInertiaDiagonal * rotation.Multiply3x3Transposed(accumulatedTorque)); + + return Thrive::Vec3ToCAPI(deltaTime * result); +} + +// ------------------------------------ // +bool SetDebugDrawerCallbacks(OnLineDraw lineDraw, OnTriangleDraw triangleDraw) +{ +#ifdef JPH_DEBUG_RENDERER + if (!lineDraw || !triangleDraw) + { + DisableDebugDrawerCallbacks(); + return false; + } + + auto& instance = Thrive::Physics::DebugDrawForwarder::GetInstance(); + + instance.SetOutputLineReceiver([lineDraw](JPH::RVec3Arg from, JPH::RVec3Arg to, JPH::Float4 colour) + { lineDraw(Thrive::DVec3ToCAPI(from), Thrive::DVec3ToCAPI(to), Thrive::ColorToCAPI(colour)); }); + + instance.SetOutputTriangleReceiver( + [triangleDraw](JPH::RVec3Arg v1, JPH::RVec3Arg v2, JPH::RVec3Arg v3, JPH::Float4 colour) + { + triangleDraw( + Thrive::DVec3ToCAPI(v1), Thrive::DVec3ToCAPI(v2), Thrive::DVec3ToCAPI(v3), Thrive::ColorToCAPI(colour)); + }); + return true; +#else + UNUSED(lineDraw); + UNUSED(triangleDraw); + return false; +#endif +} + +void DisableDebugDrawerCallbacks() +{ +#ifdef JPH_DEBUG_RENDERER + Thrive::Physics::DebugDrawForwarder::GetInstance().ClearOutputReceivers(); +#endif +} + +#pragma clang diagnostic pop + +void PhysicsTrace(const char* fmt, ...) +{ + const char prefix[] = "[Jolt:Trace] "; + constexpr size_t prefixLength = sizeof(prefix); + + // Format the message + va_list list; + va_start(list, fmt); + char buffer[1024]; + vsnprintf(buffer + prefixLength, sizeof(buffer) - prefixLength, fmt, list); + va_end(list); + + std::memcpy(buffer, prefix, prefixLength); + buffer[1023] = 0; + + LOG_INFO(std::string_view(buffer, std::strlen(buffer))); +} + +#ifdef JPH_ENABLE_ASSERTS +bool PhysicsAssert(const char* expression, const char* message, const char* file, unsigned int line) +{ + LOG_ERROR(std::string("Jolt assert failed in ") + file + ":" + std::to_string(line) + " (" + expression + ") " + + (message ? message : "")); + + // True seems to indicate that break is wanted (TODO: maybe set to false in production?) + return true; +} +#endif diff --git a/src/native/interop/CInterop.h b/src/native/interop/CInterop.h new file mode 100644 index 00000000000..d5e47c4cad4 --- /dev/null +++ b/src/native/interop/CInterop.h @@ -0,0 +1,202 @@ +#pragma once + +#include + +#include "Include.h" + +#include "interop/CStructures.h" + +/// \file Defines all of the API methods that can be called from C# + +extern "C" +{ + typedef void (*OnLogMessage)(const char* message, int32_t messageLength, int8_t logLevel); + typedef void (*OnLineDraw)(JVec3 from, JVec3 to, JColour colour); + typedef void (*OnTriangleDraw)(JVec3 vertex1, JVec3 vertex2, JVec3 vertex3, JColour colour); + + typedef bool (*OnFilterPhysicsCollision)(PhysicsCollision* potentialCollision); + + // ------------------------------------ // + // General + + /// \returns The API version the native library was compiled with, if different from C# the library should not be + /// used + [[maybe_unused]] THRIVE_NATIVE_API int32_t CheckAPIVersion(); + + /// \brief Prepares the native library for use, must be called first (right after the version check) + [[maybe_unused]] THRIVE_NATIVE_API int32_t InitThriveLibrary(); + + /// \brief Prepares the native library for shutdown should be called before the process is ended and after all + /// other calls to the library have been performed + [[maybe_unused]] THRIVE_NATIVE_API void ShutdownThriveLibrary(); + + // ------------------------------------ // + // Logging + + [[maybe_unused]] THRIVE_NATIVE_API void SetLogLevel(int8_t level); + [[maybe_unused]] THRIVE_NATIVE_API void SetLogForwardingCallback(OnLogMessage callback); + + // ------------------------------------ // + // Physics world + + [[maybe_unused]] THRIVE_NATIVE_API PhysicalWorld* CreatePhysicalWorld(); + [[maybe_unused]] THRIVE_NATIVE_API void DestroyPhysicalWorld(PhysicalWorld* physicalWorld); + + [[maybe_unused]] THRIVE_NATIVE_API bool ProcessPhysicalWorld(PhysicalWorld* physicalWorld, float delta); + + [[maybe_unused]] THRIVE_NATIVE_API PhysicsBody* PhysicalWorldCreateMovingBody(PhysicalWorld* physicalWorld, + PhysicsShape* shape, JVec3 position, JQuat rotation = QuatIdentity, bool addToWorld = true); + [[maybe_unused]] THRIVE_NATIVE_API PhysicsBody* PhysicalWorldCreateMovingBodyWithAxisLock( + PhysicalWorld* physicalWorld, PhysicsShape* shape, JVec3 position, JQuat rotation, JVecF3 lockedAxes, + bool lockRotation, bool addToWorld = true); + + [[maybe_unused]] THRIVE_NATIVE_API PhysicsBody* PhysicalWorldCreateStaticBody(PhysicalWorld* physicalWorld, + PhysicsShape* shape, JVec3 position, JQuat rotation = QuatIdentity, bool addToWorld = true); + + [[maybe_unused]] THRIVE_NATIVE_API void PhysicalWorldAddBody( + PhysicalWorld* physicalWorld, PhysicsBody* body, bool activate); + + [[maybe_unused]] THRIVE_NATIVE_API void PhysicalWorldDetachBody(PhysicalWorld* physicalWorld, PhysicsBody* body); + + [[maybe_unused]] THRIVE_NATIVE_API void DestroyPhysicalWorldBody(PhysicalWorld* physicalWorld, PhysicsBody* body); + + [[maybe_unused]] THRIVE_NATIVE_API void SetPhysicsBodyLinearDamping( + PhysicalWorld* physicalWorld, PhysicsBody* body, float damping); + + [[maybe_unused]] THRIVE_NATIVE_API void SetPhysicsBodyLinearAndAngularDamping( + PhysicalWorld* physicalWorld, PhysicsBody* body, float linearDamping, float angularDamping); + + [[maybe_unused]] THRIVE_NATIVE_API void ReadPhysicsBodyTransform( + PhysicalWorld* physicalWorld, PhysicsBody* body, JVec3* positionReceiver, JQuat* rotationReceiver); + + [[maybe_unused]] THRIVE_NATIVE_API void ReadPhysicsBodyVelocity( + PhysicalWorld* physicalWorld, PhysicsBody* body, JVecF3* velocityReceiver, JVecF3* angularVelocityReceiver); + + [[maybe_unused]] THRIVE_NATIVE_API void GiveImpulse( + PhysicalWorld* physicalWorld, PhysicsBody* body, JVecF3 impulse); + + [[maybe_unused]] THRIVE_NATIVE_API void GiveAngularImpulse( + PhysicalWorld* physicalWorld, PhysicsBody* body, JVecF3 angularImpulse); + + [[maybe_unused]] THRIVE_NATIVE_API void ApplyBodyControl(PhysicalWorld* physicalWorld, PhysicsBody* body, + JVecF3 movementImpulse, JQuat targetRotation, float rotationRate); + + [[maybe_unused]] THRIVE_NATIVE_API void SetBodyControl(PhysicalWorld* physicalWorld, PhysicsBody* body, + JVecF3 movementImpulse, JQuat targetRotation, float rotationRate); + [[maybe_unused]] THRIVE_NATIVE_API void DisableBodyControl(PhysicalWorld* physicalWorld, PhysicsBody* body); + + [[maybe_unused]] THRIVE_NATIVE_API void SetBodyPosition( + PhysicalWorld* physicalWorld, PhysicsBody* body, JVec3 position, bool activate); + + [[maybe_unused]] THRIVE_NATIVE_API void SetBodyVelocity( + PhysicalWorld* physicalWorld, PhysicsBody* body, JVecF3 velocity); + + [[maybe_unused]] THRIVE_NATIVE_API void SetBodyAngularVelocity( + PhysicalWorld* physicalWorld, PhysicsBody* body, JVecF3 angularVelocity); + + [[maybe_unused]] THRIVE_NATIVE_API void SetBodyVelocityAndAngularVelocity( + PhysicalWorld* physicalWorld, PhysicsBody* body, JVecF3 velocity, JVecF3 angularVelocity); + + [[maybe_unused]] THRIVE_NATIVE_API void SetBodyAllowSleep( + PhysicalWorld* physicalWorld, PhysicsBody* body, bool allowSleep); + + [[maybe_unused]] THRIVE_NATIVE_API bool FixBodyYCoordinateToZero(PhysicalWorld* physicalWorld, PhysicsBody* body); + + [[maybe_unused]] THRIVE_NATIVE_API void ChangeBodyShape( + PhysicalWorld* physicalWorld, PhysicsBody* body, PhysicsShape* shape, bool activate); + + [[maybe_unused]] THRIVE_NATIVE_API void PhysicsBodyAddAxisLock( + PhysicalWorld* physicalWorld, PhysicsBody* body, JVecF3 axis, bool lockRotation); + + [[maybe_unused]] THRIVE_NATIVE_API void PhysicsBodySetCollisionEnabledState( + PhysicalWorld* physicalWorld, PhysicsBody* body, bool collisionsEnabled); + + [[maybe_unused]] THRIVE_NATIVE_API void PhysicsBodyAddCollisionIgnore( + PhysicalWorld* physicalWorld, PhysicsBody* body, PhysicsBody* addIgnore); + + [[maybe_unused]] THRIVE_NATIVE_API void PhysicsBodyRemoveCollisionIgnore( + PhysicalWorld* physicalWorld, PhysicsBody* body, PhysicsBody* removeIgnore); + + [[maybe_unused]] THRIVE_NATIVE_API void PhysicsBodyClearCollisionIgnores( + PhysicalWorld* physicalWorld, PhysicsBody* body); + + [[maybe_unused]] THRIVE_NATIVE_API void PhysicsBodySetCollisionIgnores( + PhysicalWorld* physicalWorld, PhysicsBody* body, PhysicsBody* ignoredBodies[], int32_t count); + + [[maybe_unused]] THRIVE_NATIVE_API void PhysicsBodyClearAndSetSingleIgnore( + PhysicalWorld* physicalWorld, PhysicsBody* body, PhysicsBody* onlyIgnoredBody); + + /// Sets up collision recording for a body. The returned value is a pointer to read the currently active collisions + /// that have been written to collisionRecordingTarget + [[maybe_unused]] THRIVE_NATIVE_API int32_t* PhysicsBodyEnableCollisionRecording( + PhysicalWorld* physicalWorld, PhysicsBody* body, char* collisionRecordingTarget, int32_t maxRecordedCollisions); + + [[maybe_unused]] THRIVE_NATIVE_API void PhysicsBodyDisableCollisionRecording( + PhysicalWorld* physicalWorld, PhysicsBody* body); + + [[maybe_unused]] THRIVE_NATIVE_API void PhysicsBodyAddCollisionFilter( + PhysicalWorld* physicalWorld, PhysicsBody* body, OnFilterPhysicsCollision callback); + + [[maybe_unused]] THRIVE_NATIVE_API void PhysicsBodyDisableCollisionFilter( + PhysicalWorld* physicalWorld, PhysicsBody* body); + + [[maybe_unused]] THRIVE_NATIVE_API void PhysicalWorldSetGravity(PhysicalWorld* physicalWorld, JVecF3 gravity); + [[maybe_unused]] THRIVE_NATIVE_API void PhysicalWorldRemoveGravity(PhysicalWorld* physicalWorld); + + [[maybe_unused]] THRIVE_NATIVE_API int32_t PhysicalWorldCastRayGetAll(PhysicalWorld* physicalWorld, JVec3 start, + JVecF3 endOffset, PhysicsRayWithUserData* dataReceiver, int32_t maxHits); + + [[maybe_unused]] THRIVE_NATIVE_API float PhysicalWorldGetPhysicsLatestTime(PhysicalWorld* physicalWorld); + [[maybe_unused]] THRIVE_NATIVE_API float PhysicalWorldGetPhysicsAverageTime(PhysicalWorld* physicalWorld); + + [[maybe_unused]] THRIVE_NATIVE_API bool PhysicalWorldDumpPhysicsState( + PhysicalWorld* physicalWorld, const char* path); + + [[maybe_unused]] THRIVE_NATIVE_API void PhysicalWorldSetDebugDrawLevel( + PhysicalWorld* physicalWorld, int32_t level = 0); + [[maybe_unused]] THRIVE_NATIVE_API void PhysicalWorldSetDebugDrawCameraLocation( + PhysicalWorld* physicalWorld, JVecF3 position); + + // ------------------------------------ // + // Body functions + [[maybe_unused]] THRIVE_NATIVE_API void ReleasePhysicsBodyReference(PhysicsBody* body); + + /// Set user data for a physics body, note that currently all data needs to be the same size to fully work, + /// which is specified by Thrive::PHYSICS_USER_DATA_SIZE + [[maybe_unused]] THRIVE_NATIVE_API void PhysicsBodySetUserData( + PhysicsBody* body, const char* data, int32_t dataLength); + + [[maybe_unused]] THRIVE_NATIVE_API void PhysicsBodyForceClearRecordingTargets(PhysicsBody* body); + + // ------------------------------------ // + // Physics shapes + [[maybe_unused]] THRIVE_NATIVE_API PhysicsShape* CreateBoxShape(float halfSideLength, float density = 1000); + [[maybe_unused]] THRIVE_NATIVE_API PhysicsShape* CreateBoxShapeWithDimensions( + JVecF3 halfDimensions, float density = 1000); + [[maybe_unused]] THRIVE_NATIVE_API PhysicsShape* CreateSphereShape(float radius, float density = 1000); + [[maybe_unused]] THRIVE_NATIVE_API PhysicsShape* CreateCylinderShape( + float halfHeight, float radius, float density = 1000); + + [[maybe_unused]] THRIVE_NATIVE_API PhysicsShape* CreateMicrobeShapeConvex( + JVecF3* points, uint32_t pointCount, float density, float scale, float thickness); + [[maybe_unused]] THRIVE_NATIVE_API PhysicsShape* CreateMicrobeShapeSpheres( + JVecF3* points, uint32_t pointCount, float density, float scale); + + [[maybe_unused]] THRIVE_NATIVE_API PhysicsShape* CreateConvexShape( + JVecF3* points, uint32_t pointCount, float density); + + [[maybe_unused]] THRIVE_NATIVE_API PhysicsShape* CreateStaticCompoundShape( + SubShapeDefinition* subShapes, uint32_t shapeCount); + + [[maybe_unused]] THRIVE_NATIVE_API void ReleaseShape(PhysicsShape* shape); + + [[maybe_unused]] THRIVE_NATIVE_API float ShapeGetMass(PhysicsShape* shape); + + [[maybe_unused]] THRIVE_NATIVE_API JVecF3 ShapeCalculateResultingAngularVelocity( + PhysicsShape* shape, JVecF3 appliedTorque, float deltaTime = 1); + + // ------------------------------------ // + // Misc + [[maybe_unused]] THRIVE_NATIVE_API bool SetDebugDrawerCallbacks(OnLineDraw lineDraw, OnTriangleDraw triangleDraw); + [[maybe_unused]] THRIVE_NATIVE_API void DisableDebugDrawerCallbacks(); +} diff --git a/src/native/interop/CStructures.h b/src/native/interop/CStructures.h new file mode 100644 index 00000000000..1d76ca4dd90 --- /dev/null +++ b/src/native/interop/CStructures.h @@ -0,0 +1,57 @@ +#pragma once + +#include "Include.h" + +#pragma clang diagnostic push +#pragma ide diagnostic ignored "modernize-use-using" + +extern "C" +{ + typedef struct PhysicalWorld PhysicalWorld; + typedef struct PhysicsBody PhysicsBody; + typedef struct PhysicsShape PhysicsShape; + + typedef struct JVec3 + { + double X, Y, Z; + } JVec3; + + typedef struct JVecF3 + { + float X, Y, Z; + } JVecF3; + + typedef struct JQuat + { + float X, Y, Z, W; + } JQuat; + + typedef struct JColour + { + float R, G, B, A; + } JColour; + + // See the C++ side for the layout of the contained members + typedef struct PhysicsCollision{ + char CollisionData[PHYSICS_COLLISION_DATA_SIZE]; + } PhysicsCollision; + + typedef struct PhysicsRayWithUserData{ + char RayData[PHYSICS_RAY_DATA_SIZE]; + } PhysicsRayWithUserData; + + BEGIN_PACKED_STRUCT; + typedef struct PACKED_STRUCT SubShapeDefinition + { + JQuat Rotation; + JVecF3 Position; + uint32_t UserData; + PhysicsShape* Shape; + } SubShapeDefinition; + + END_PACKED_STRUCT; + + static const inline JQuat QuatIdentity = JQuat{0, 0, 0, 1}; +} + +#pragma clang diagnostic pop diff --git a/src/native/interop/JVec3.cs b/src/native/interop/JVec3.cs new file mode 100644 index 00000000000..0891556c834 --- /dev/null +++ b/src/native/interop/JVec3.cs @@ -0,0 +1,166 @@ +using System; +using System.Runtime.InteropServices; +using Godot; + +// This file has all of the interop structs +// This is named after the first one to avoid having to have a bunch of small files for everything + +[StructLayout(LayoutKind.Sequential)] +public struct JVec3 +{ + public double X; + public double Y; + public double Z; + + public JVec3(Vector3 vector) + { + X = vector.x; + Y = vector.y; + Z = vector.z; + } + + public static implicit operator Vector3(JVec3 d) + { + return new Vector3((float)d.X, (float)d.Y, (float)d.Z); + } +} + +[StructLayout(LayoutKind.Sequential)] +public struct JQuat +{ + public static JQuat Identity = new() { X = 0, Y = 0, Z = 0, W = 1 }; + + public float X; + public float Y; + public float Z; + public float W; + + public JQuat(Quat quat) + { + X = quat.x; + Y = quat.y; + Z = quat.z; + W = quat.w; + } + + public static implicit operator Quat(JQuat d) + { + return new Quat(d.X, d.Y, d.Z, d.W); + } +} + +[StructLayout(LayoutKind.Sequential)] +public struct JVecF3 : IEquatable +{ + public float X; + public float Y; + public float Z; + + public JVecF3(Vector3 vector) + { + X = vector.x; + Y = vector.y; + Z = vector.z; + } + + public JVecF3(float x, float y, float z) + { + X = x; + Y = y; + Z = z; + } + + public static implicit operator Vector3(JVecF3 d) + { + return new Vector3(d.X, d.Y, d.Z); + } + + public static bool operator ==(JVecF3 left, JVecF3 right) + { + return left.Equals(right); + } + + public static bool operator !=(JVecF3 left, JVecF3 right) + { + return !left.Equals(right); + } + + public static int GetCompatibleHashCode(float x, float y, float z) + { + unchecked + { + var hashCode = x.GetHashCode(); + hashCode = (hashCode * 397) ^ y.GetHashCode(); + hashCode = (hashCode * 401) ^ z.GetHashCode(); + return hashCode; + } + } + + public bool Equals(JVecF3 other) + { + return X.Equals(other.X) && Y.Equals(other.Y) && Z.Equals(other.Z); + } + + public override bool Equals(object? obj) + { + return obj is JVecF3 other && Equals(other); + } + + public override int GetHashCode() + { + return GetCompatibleHashCode(X, Y, Z); + } +} + +[StructLayout(LayoutKind.Sequential)] +public struct JColour +{ + public float R; + public float G; + public float B; + public float A; + + public JColour(Color color) + { + R = color.r; + G = color.g; + B = color.b; + A = color.a; + } + + public JColour(float r, float g, float b, float a) + { + R = r; + G = g; + B = b; + A = a; + } + + public static implicit operator Color(JColour d) + { + return new Color(d.R, d.G, d.B, d.A); + } +} + +/// +/// Sub-shape data for the native side methods +/// +[StructLayout(LayoutKind.Sequential)] +public struct SubShapeDefinition +{ + public JQuat Rotation; + + public JVecF3 Position; + + public uint UserData; + + public IntPtr ShapeNativePtr; + + public SubShapeDefinition(Vector3 position, Quat rotation, IntPtr shapePtr, uint userData = 0) + { + Position = new JVecF3(position); + Rotation = new JQuat(rotation); + ShapeNativePtr = shapePtr; + UserData = userData; + } +} diff --git a/src/native/interop/JoltTypeConversions.hpp b/src/native/interop/JoltTypeConversions.hpp new file mode 100644 index 00000000000..966e4cd0193 --- /dev/null +++ b/src/native/interop/JoltTypeConversions.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include "Jolt/Math/DVec3.h" +#include "Jolt/Math/Quat.h" + +#include "Include.h" + +#include "CStructures.h" + +/// \file Implements type conversion between the C API types and Jolt types + +namespace Thrive +{ + +FORCE_INLINE JPH::DVec3 DVec3FromCAPI(JVec3 vec) +{ + return {vec.X, vec.Y, vec.Z}; +} + +FORCE_INLINE JVec3 DVec3ToCAPI(JPH::DVec3 vec) +{ + return JVec3{vec.GetX(), vec.GetY(), vec.GetZ()}; +} + +FORCE_INLINE JPH::Vec3 Vec3FromCAPI(JVecF3 vec) +{ + return {vec.X, vec.Y, vec.Z}; +} + +FORCE_INLINE JVecF3 Vec3ToCAPI(JPH::Vec3 vec) +{ + return JVecF3{vec.GetX(), vec.GetY(), vec.GetZ()}; +} + +FORCE_INLINE JPH::Quat QuatFromCAPI(JQuat quat) +{ + return {quat.X, quat.Y, quat.Z, quat.W}; +} + +FORCE_INLINE JQuat QuatToCAPI(JPH::Quat quat) +{ + return JQuat{quat.GetX(), quat.GetY(), quat.GetZ(), quat.GetW()}; +} + +FORCE_INLINE JColour ColorToCAPI(JPH::Float4 colour) +{ + return {colour.x, colour.y, colour.z, colour.w}; +} + +FORCE_INLINE JPH::Float4 ColorFromCAPI(JColour colour) +{ + return {colour.R, colour.G, colour.B, colour.A}; +} + +} // namespace Thrive diff --git a/src/native/interop/NativeInterop.cs b/src/native/interop/NativeInterop.cs new file mode 100644 index 00000000000..9fbd257c31b --- /dev/null +++ b/src/native/interop/NativeInterop.cs @@ -0,0 +1,215 @@ +using System; +using System.Runtime.InteropServices; +using Godot; + +/// +/// Calling interface from C# to the native code side of things for the native module +/// +public static class NativeInterop +{ + private static bool loadCalled; + private static bool debugDrawIsPossible; + + public delegate void OnLineDraw(Vector3 from, Vector3 to, Color colour); + + public delegate void OnTriangleDraw(Vector3 vertex1, Vector3 vertex2, Vector3 vertex3, Color colour); + + // These forwarding static event handlers are needed, otherwise the callback coming back will have just entirely + // bogus "this" values + private static event OnLineDraw? OnLineDrawHandler; + private static event OnTriangleDraw? OnTriangleDrawHandler; + + /// + /// Performs any initialization needed by the native library (note has to be called after the library is loaded) + /// + /// Current game settings + public static void Init(Settings settings) + { + // Settings are passed as probably in the future something needs to be setup right in the native side of + // things for the initial settings + _ = settings; + + NativeMethods.SetLogForwardingCallback(ForwardMessage); + + var result = NativeMethods.InitThriveLibrary(); + + if (result != 0) + { + throw new InvalidOperationException($"Failed to initialize Thrive native library, code: {result}"); + } + + try + { + debugDrawIsPossible = NativeMethods.SetDebugDrawerCallbacks(ForwardLineDraw, ForwardTriangleDraw); + } + catch (Exception e) + { + GD.PrintErr("Failed to initialize potential for debug drawing: ", e); + debugDrawIsPossible = false; + } + +#if DEBUG + CheckSizesOfInteropTypes(); +#endif + } + + /// + /// Loads and checks the native library is good to use + /// + /// If the library is not fine (wrong version) + /// If finding the library failed + public static void Load() + { + if (loadCalled) + throw new InvalidOperationException("Load has been called already"); + + loadCalled = true; + + // ReSharper disable once CommentTypo + // TODO: come up with some approach for putting the native library to a sensible folder, + // approach trying to manually load the library doesn't work (unless we manually look up all the methods + // instead of using DllImportAttribute, also mono_dllmap_insert doesn't work as still the attributes load + // before that can be used to set. With .NET 7 it should be possible to finally cleanly fix this: + // https://learn.microsoft.com/en-us/dotnet/standard/native-interop/cross-platform#custom-import-resolver + // `NativeLibrary.Load` would probably also be a good way to do something + + int version = NativeMethods.CheckAPIVersion(); + + if (version != NativeConstants.Version) + { + throw new Exception($"Failed to initialize Thrive native library, unexpected version {version} " + + $"is not required: {NativeConstants.Version}"); + } + + GD.Print("Loaded native Thrive library version ", version); + + // Enable debug logging if this is being debugged +#if DEBUG + NativeMethods.SetLogLevel(NativeMethods.LogLevel.Debug); +#endif + } + + /// + /// Releases all native resources and prepares the library for process exit + /// + public static void Shutdown() + { + NativeMethods.DisableDebugDrawerCallbacks(); + NativeMethods.ShutdownThriveLibrary(); + } + + public static bool RegisterDebugDrawer(OnLineDraw lineDraw, OnTriangleDraw triangleDraw) + { + if (!debugDrawIsPossible) + return false; + + OnLineDrawHandler += lineDraw; + OnTriangleDrawHandler += triangleDraw; + + return true; + } + + public static void RemoveDebugDrawer() + { + // TODO: do single objects need to be able to unregister? + OnLineDrawHandler = null; + OnTriangleDrawHandler = null; + + NativeMethods.DisableDebugDrawerCallbacks(); + } + + private static void ForwardMessage(IntPtr messageData, int messageLength, NativeMethods.LogLevel level) + { + var message = Marshal.PtrToStringAnsi(messageData, messageLength); + + if (level <= NativeMethods.LogLevel.Info) + { + GD.Print("[NATIVE] ", message); + } + else if (level <= NativeMethods.LogLevel.Warning) + { + // TODO: something different for warning level? + GD.Print("[NATIVE] WARNING:", message); + } + else + { + GD.PrintErr("[NATIVE] ", message); + } + } + + private static void ForwardLineDraw(JVec3 from, JVec3 to, JColour colour) + { + // TODO: is it possible to preserve precision by for example positioning the debug draw near the player? + OnLineDrawHandler?.Invoke(from, to, colour); + } + + private static void ForwardTriangleDraw(JVec3 vertex1, JVec3 vertex2, JVec3 vertex3, JColour colour) + { + OnTriangleDrawHandler?.Invoke(vertex1, vertex2, vertex3, colour); + } + + private static void CheckSizesOfInteropTypes() + { + CheckSizeOfType(3 * 8); + CheckSizeOfType(3 * 4); + CheckSizeOfType(4 * 4); + CheckSizeOfType(4 * 4); + + CheckSizeOfType(48); + CheckSizeOfType(40); + } + + private static void CheckSizeOfType(int expected) + { + var size = Marshal.SizeOf(); + if (size != expected) + { + throw new Exception( + $"Unexpected size for type {typeof(T).FullName}, expected size to be: {expected} but it is {size}"); + } + } +} + +/// +/// Thrive native library general methods / things needed from multiple places. Specific class methods are split out +/// as the partial classes to logically split the methods into groups +/// +internal static partial class NativeMethods +{ + internal delegate void OnLogMessage(IntPtr messageData, int messageLength, LogLevel level); + + internal delegate void OnLineDraw(JVec3 from, JVec3 to, JColour colour); + + internal delegate void OnTriangleDraw(JVec3 vertex1, JVec3 vertex2, JVec3 vertex3, JColour colour); + + internal enum LogLevel : byte + { + Debug = 0, + Info = 1, + Warning = 2, + Error = 3, + } + + [DllImport("thrive_native")] + internal static extern int InitThriveLibrary(); + + [DllImport("thrive_native")] + internal static extern int CheckAPIVersion(); + + [DllImport("thrive_native")] + internal static extern void ShutdownThriveLibrary(); + + [DllImport("thrive_native")] + internal static extern void SetLogLevel(LogLevel level); + + [DllImport("thrive_native")] + internal static extern void SetLogForwardingCallback(OnLogMessage callback); + + [DllImport("thrive_native")] + internal static extern bool SetDebugDrawerCallbacks(OnLineDraw lineDraw, OnTriangleDraw triangleDraw); + + [DllImport("thrive_native")] + internal static extern void DisableDebugDrawerCallbacks(); + + // The wrapper-specific methods are in their respective files like PhysicalWorld.cs etc. +} diff --git a/src/native/physics/ArrayRayCollector.hpp b/src/native/physics/ArrayRayCollector.hpp new file mode 100644 index 00000000000..f4c8f7834bc --- /dev/null +++ b/src/native/physics/ArrayRayCollector.hpp @@ -0,0 +1,100 @@ +#pragma once + +#include "Jolt/Physics/Body/BodyLock.h" +#include "Jolt/Physics/Body/BodyLockInterface.h" +#include "Jolt/Physics/Collision/CastResult.h" +#include "Jolt/Physics/Collision/Shape/Shape.h" + +#include "PhysicsBody.hpp" +#include "PhysicsRayWithUserData.hpp" + +namespace Thrive::Physics +{ + +/// \brief Helper class to collect ray hits from Jolt into PhysicsRayWithUserData array +class ArrayRayCollector final + : public JPH::CollisionCollector, + public NonCopyable +{ +public: + ArrayRayCollector( + PhysicsRayWithUserData dataReceiver[], int maxHits, const JPH::BodyLockInterface& bodyLockInterface) : + bodyInterface(bodyLockInterface), + hitStorage(dataReceiver), hitStorageSpaceLeft(maxHits) + { + if (hitStorage == nullptr) + { + using namespace JPH; + + JPH_ASSERT(hitStorage); + + // This is an error condition, but we don't really want to throw here so instead this just for safety clear + // the maxHits + hitStorageSpaceLeft = 0; + } + } + + void AddHit(const ResultType& inResult) final + { + // Safety check against this being called too many times after running out of space + if (hitStorageSpaceLeft < 1) + { + ForceEarlyOut(); + return; + } + + // Store this hit + // We need to lock the body to read the user data in it + + JPH::BodyLockRead lock(bodyInterface, inResult.mBodyID); + if (!lock.Succeeded()) [[unlikely]] + { + // Can't read body + return; + } + + const JPH::Body& body = lock.GetBody(); + + const auto* bodyWrapper = PhysicsBody::FromJoltBody(body.GetUserData()); + hitStorage->Body = bodyWrapper; + + if (bodyWrapper != nullptr) + { + std::memcpy( + hitStorage->BodyUserData.data(), bodyWrapper->GetUserData().data(), hitStorage->BodyUserData.size()); + } + else + { + std::memset(hitStorage->BodyUserData.data(), 0, hitStorage->BodyUserData.size()); + } + + hitStorage->HitFraction = inResult.mFraction; + + hitStorage->SubShapeData = inResult.mSubShapeID2.GetValue(); + + // Increment the place in the given array we are writing to + ++hitCount; + ++hitStorage; + --hitStorageSpaceLeft; + + // Stop collecting hits once there's no longer space + if (hitStorageSpaceLeft < 0) + { + ForceEarlyOut(); + } + } + + [[nodiscard]] inline int GetHitCount() const noexcept + { + return hitCount; + } + +private: + const JPH::BodyLockInterface& bodyInterface; + + PhysicsRayWithUserData* hitStorage; + int hitStorageSpaceLeft; + int hitCount = 0; +}; + +} // namespace Thrive::Physics diff --git a/src/native/physics/BodyActivationListener.cpp b/src/native/physics/BodyActivationListener.cpp new file mode 100644 index 00000000000..bb548236e8d --- /dev/null +++ b/src/native/physics/BodyActivationListener.cpp @@ -0,0 +1,30 @@ +// ------------------------------------ // +#include "BodyActivationListener.hpp" + +#include "PhysicsBody.hpp" + +// ------------------------------------ // +namespace Thrive::Physics +{ + +void BodyActivationListener::OnBodyActivated(const JPH::BodyID& bodyID, uint64_t bodyUserData) +{ + UNUSED(bodyID); + + auto bodyWrapper = PhysicsBody::FromJoltBody(bodyUserData); + + if (bodyWrapper != nullptr) + bodyWrapper->NotifyActiveStatus(true); +} + +void BodyActivationListener::OnBodyDeactivated(const JPH::BodyID& bodyID, uint64_t bodyUserData) +{ + UNUSED(bodyID); + + auto bodyWrapper = PhysicsBody::FromJoltBody(bodyUserData); + + if (bodyWrapper != nullptr) + bodyWrapper->NotifyActiveStatus(false); +} + +} // namespace Thrive::Physics diff --git a/src/native/physics/BodyActivationListener.hpp b/src/native/physics/BodyActivationListener.hpp new file mode 100644 index 00000000000..57d250a9046 --- /dev/null +++ b/src/native/physics/BodyActivationListener.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include "Jolt/Physics/Body/BodyActivationListener.h" + +namespace Thrive::Physics +{ +/// \brief Jolt physics body activation state listener (bodies that don't move for a while go to sleep) +class BodyActivationListener : public JPH::BodyActivationListener +{ +public: + void OnBodyActivated(const JPH::BodyID& bodyID, uint64_t bodyUserData) override; + + void OnBodyDeactivated(const JPH::BodyID& bodyID, uint64_t bodyUserData) override; +}; + +} // namespace Thrive::Physics diff --git a/src/native/physics/BodyControlState.hpp b/src/native/physics/BodyControlState.hpp new file mode 100644 index 00000000000..98a4b443261 --- /dev/null +++ b/src/native/physics/BodyControlState.hpp @@ -0,0 +1,26 @@ +#pragma once + +namespace Thrive::Physics +{ + +/// \brief Variables needed for tracking body control over multiple physics updates to make sure the control is stable +class BodyControlState +{ +public: + BodyControlState() = default; + + JPH::Quat previousRotation = {}; + JPH::Quat previousTarget = {}; + JPH::Quat targetRotation = {}; + + JPH::Vec3 movement = {}; + + /// \brief Rotation speed divisor. Should at smallest be 1 for full speed, any higher value has chance of causing + /// unwanted oscillation. Higher values slow down rotation. + float rotationRate = 1; + + bool justStarted = true; + bool targetChanged = false; +}; + +} // namespace Thrive::Physics diff --git a/src/native/physics/ContactListener.cpp b/src/native/physics/ContactListener.cpp new file mode 100644 index 00000000000..9e53eb78367 --- /dev/null +++ b/src/native/physics/ContactListener.cpp @@ -0,0 +1,372 @@ +// ------------------------------------ // +#include "ContactListener.hpp" + +#include "Jolt/Physics/Body/Body.h" +#include "Jolt/Physics/Collision/CollideShape.h" + +#include "DebugDrawForwarder.hpp" +#include "PhysicsBody.hpp" + +// ------------------------------------ // +namespace Thrive::Physics +{ +ContactListener::ContactListener() +{ + if (COLLISION_UNKNOWN_SUB_SHAPE != JPH::SubShapeID().GetValue()) + { + LOG_ERROR("Incorrectly configured unknown collision value compared to what Jolt has"); + abort(); + } +} + +// ------------------------------------ // +inline void PrepareBasicCollisionInfo(PhysicsCollision& collision, const PhysicsBody* body1, const PhysicsBody* body2) +{ + collision.FirstBody = body1; + collision.SecondBody = body2; + + if (body1->HasUserData()) [[likely]] + { + collision.FirstUserData = body1->GetUserData(); + } + else + { + // In case there is no user data (in Thrive use there should always be when used from entities) do a safety + // thing and fill in zeros + std::memset(collision.FirstUserData.data(), 0, collision.FirstUserData.size()); + } + + if (body2->HasUserData()) [[likely]] + { + collision.SecondUserData = body2->GetUserData(); + } + else + { + std::memset(collision.SecondUserData.data(), 0, collision.SecondUserData.size()); + } +} + +inline void ClearUnknownDataForCollisionFilter(PhysicsCollision& collision) +{ + collision.FirstSubShapeData = COLLISION_UNKNOWN_SUB_SHAPE; + collision.SecondSubShapeData = COLLISION_UNKNOWN_SUB_SHAPE; + collision.PenetrationAmount = -1; +} + +inline void PrepareCollisionInfoFromManifold(PhysicsCollision& collision, const PhysicsBody* body1, + const PhysicsBody* body2, const JPH::ContactManifold& manifold, bool justStarted, bool swapOrder) +{ + if (swapOrder) + { + PrepareBasicCollisionInfo(collision, body2, body1); + } + else + { + PrepareBasicCollisionInfo(collision, body1, body2); + } + + if (swapOrder) + { + collision.SecondSubShapeData = manifold.mSubShapeID1.GetValue(); + collision.FirstSubShapeData = manifold.mSubShapeID2.GetValue(); + } + else + { + collision.FirstSubShapeData = manifold.mSubShapeID1.GetValue(); + collision.SecondSubShapeData = manifold.mSubShapeID2.GetValue(); + } + + collision.PenetrationAmount = manifold.mPenetrationDepth; + + collision.JustStarted = justStarted; +} + +JPH::ValidateResult ContactListener::OnContactValidate(const JPH::Body& body1, const JPH::Body& body2, + JPH::RVec3Arg baseOffset, const JPH::CollideShapeResult& collisionResult) +{ + JPH::ValidateResult result; + if (chainedListener != nullptr) + { + result = chainedListener->OnContactValidate(body1, body2, baseOffset, collisionResult); + } + else + { + result = JPH::ContactListener::OnContactValidate(body1, body2, baseOffset, collisionResult); + } + + // Body-specific filtering. Likely is used here as the base method always allows contact, and we don't use chained + // listeners + if (result == JPH::ValidateResult::AcceptAllContactsForThisBodyPair || result == JPH::ValidateResult::AcceptContact) + [[likely]] + { + // PhysicsCollision struct not initialized to only initialize it when required +#pragma clang diagnostic push +#pragma ide diagnostic ignored "cppcoreguidelines-pro-type-member-init" + + PhysicsCollision collisionData; + +#pragma clang diagnostic pop + + bool collisionDataFilled = false; + + const auto userData1 = body1.GetUserData(); + const auto userData2 = body2.GetUserData(); + + const bool body1UsesFilter = userData1 & PHYSICS_BODY_SPECIAL_COLLISION_FLAG; + const bool body2UsesFilter = userData2 & PHYSICS_BODY_SPECIAL_COLLISION_FLAG; + + if (body1UsesFilter || body2UsesFilter) + { + // Some special collision handling has to occur + bool disallow = false; + + const auto body1Object = PhysicsBody::FromJoltBody(userData1); + const auto body2Object = PhysicsBody::FromJoltBody(userData2); + + // Check all collision disable first + if (userData1 & PHYSICS_BODY_DISABLE_COLLISION_FLAG || userData2 & PHYSICS_BODY_DISABLE_COLLISION_FLAG) + { + disallow = true; + } + else if (body1UsesFilter) + { + // Filter based on custom filter callback if defined + const auto filter1 = body1Object->GetCollisionFilter(); + + if (filter1) + { + // Prepare collision data for the callback + PrepareBasicCollisionInfo(collisionData, body1Object, body2Object); + ClearUnknownDataForCollisionFilter(collisionData); + collisionData.JustStarted = true; + collisionDataFilled = true; + + disallow = !filter1(collisionData); + } + + // And then based on ignore list + if (!disallow) + disallow = body1Object->IsBodyIgnored(body2.GetID()); + } + + if (!disallow) + { + // If first body allows collision, check the second one (all disable for second body was already + // checked above) + + if (body2UsesFilter) + { + // Filter based on custom filter callback if defined + const auto filter2 = body2Object->GetCollisionFilter(); + + if (filter2) + { + // The filter always has the current object as the first body so this data needs to be always + // written + PrepareBasicCollisionInfo(collisionData, body2Object, body1Object); + + // Prepare the common collision data for the callback if not already + if (!collisionDataFilled) + { + ClearUnknownDataForCollisionFilter(collisionData); + collisionData.JustStarted = true; + } + + disallow = !filter2(collisionData); + } + + // And then based on ignore list + if (!disallow) + disallow = body2Object->IsBodyIgnored(body1.GetID()); + } + } + + if (disallow) + result = JPH::ValidateResult::RejectAllContactsForThisBodyPair; + } + } + +#ifdef JPH_DEBUG_RENDERER + if (debugDrawer != nullptr) + { + const auto contact_point = baseOffset + collisionResult.mContactPointOn1; + + if (result != JPH::ValidateResult::RejectContact && + result != JPH::ValidateResult::RejectAllContactsForThisBodyPair) + { + debugDrawer->DrawArrow(contact_point, + contact_point - collisionResult.mPenetrationAxis.NormalizedOr(JPH::Vec3::sZero()), JPH::Color::sBlue, + 0.05f); + } + else + { + debugDrawer->DrawArrow(contact_point, + contact_point - collisionResult.mPenetrationAxis.NormalizedOr(JPH::Vec3::sZero()), JPH::Color::sDarkRed, + 0.05f); + } + } +#endif + + return result; +} + +void ContactListener::OnContactAdded(const JPH::Body& body1, const JPH::Body& body2, + const JPH::ContactManifold& manifold, JPH::ContactSettings& settings) +{ + // Note the bodies are sorted (`body1.GetID() < body2.GetID()`) + + // Add the new collision + { + Lock lock(currentCollisionsMutex); + JPH::SubShapeIDPair key(body1.GetID(), manifold.mSubShapeID1, body2.GetID(), manifold.mSubShapeID2); + currentCollisions[key] = CollisionPair(manifold.mBaseOffset, manifold.mRelativeContactPointsOn1); + } + + if (chainedListener != nullptr) + chainedListener->OnContactAdded(body1, body2, manifold, settings); + + // TODO: should relative velocities be stored somehow here? The Jolt documentation mentions that can be used to + // determine how hard the collision is + + // Recording collisions (we record the start as only on the next update does the persisted connection trigger, + // and well there are some potential gameplay uses for the initial collision flag) + const auto userData1 = body1.GetUserData(); + const auto userData2 = body2.GetUserData(); + + if (userData1 & PHYSICS_BODY_RECORDING_FLAG) + { + const auto body1Object = PhysicsBody::FromJoltBody(userData1); + + // Get target location to directly write the collision info to, this saves one memory copy per recorded + // collision + auto* writeTarget = body1Object->GetNextCollisionRecordLocation(physicsStep); + + // Likely is used here as we are optimistic the collision counts are in control in terms of how many recording + // slots there are + if (writeTarget) [[likely]] + { + PrepareCollisionInfoFromManifold( + *writeTarget, body1Object, PhysicsBody::FromJoltBody(userData2), manifold, true, false); + } + } + + if (userData2 & PHYSICS_BODY_RECORDING_FLAG) + { + const auto body2Object = PhysicsBody::FromJoltBody(userData2); + + auto* writeTarget = body2Object->GetNextCollisionRecordLocation(physicsStep); + + if (writeTarget) [[likely]] + { + PrepareCollisionInfoFromManifold( + *writeTarget, PhysicsBody::FromJoltBody(userData1), body2Object, manifold, true, true); + } + } + +#ifdef JPH_DEBUG_RENDERER + if (debugDrawer != nullptr) + { + debugDrawer->DrawWirePolygon(JPH::RMat44::sTranslation(manifold.mBaseOffset), + manifold.mRelativeContactPointsOn1, JPH::Color::sGreen, 0.05f); + debugDrawer->DrawWirePolygon(JPH::RMat44::sTranslation(manifold.mBaseOffset), + manifold.mRelativeContactPointsOn2, JPH::Color::sGreen, 0.05f); + debugDrawer->DrawArrow(manifold.GetWorldSpaceContactPointOn1(0), + manifold.GetWorldSpaceContactPointOn1(0) + manifold.mWorldSpaceNormal, JPH::Color::sGreen, 0.05f); + } +#endif +} + +void ContactListener::OnContactPersisted(const JPH::Body& body1, const JPH::Body& body2, + const JPH::ContactManifold& manifold, JPH::ContactSettings& settings) +{ + // Update existing collision info + { + Lock lock(currentCollisionsMutex); + + JPH::SubShapeIDPair key(body1.GetID(), manifold.mSubShapeID1, body2.GetID(), manifold.mSubShapeID2); + + const auto iter = currentCollisions.find(key); + if (iter != currentCollisions.end()) + { + iter->second = CollisionPair(manifold.mBaseOffset, manifold.mRelativeContactPointsOn1); + } + } + + if (chainedListener != nullptr) + chainedListener->OnContactPersisted(body1, body2, manifold, settings); + + // Contact recording + const auto userData1 = body1.GetUserData(); + const auto userData2 = body2.GetUserData(); + + if (userData1 & PHYSICS_BODY_RECORDING_FLAG) + { + const auto body1Object = PhysicsBody::FromJoltBody(userData1); + + // TODO: if we ever move to an approach where multiple physics steps can happen and collisions need to be + // recorded persistently over a few physics updates we might need to somehow implement filtering here + auto* writeTarget = body1Object->GetNextCollisionRecordLocation(physicsStep); + + if (writeTarget) [[likely]] + { + PrepareCollisionInfoFromManifold( + *writeTarget, body1Object, PhysicsBody::FromJoltBody(userData2), manifold, false, false); + } + } + + if (userData2 & PHYSICS_BODY_RECORDING_FLAG) + { + const auto body2Object = PhysicsBody::FromJoltBody(userData2); + + auto* writeTarget = body2Object->GetNextCollisionRecordLocation(physicsStep); + + if (writeTarget) [[likely]] + { + PrepareCollisionInfoFromManifold( + *writeTarget, PhysicsBody::FromJoltBody(userData1), body2Object, manifold, false, true); + } + } + +#ifdef JPH_DEBUG_RENDERER + if (!drawOnlyNew && debugDrawer != nullptr) + { + debugDrawer->DrawWirePolygon(JPH::RMat44::sTranslation(manifold.mBaseOffset), + manifold.mRelativeContactPointsOn1, JPH::Color::sYellow, 0.05f); + debugDrawer->DrawWirePolygon(JPH::RMat44::sTranslation(manifold.mBaseOffset), + manifold.mRelativeContactPointsOn2, JPH::Color::sYellow, 0.05f); + debugDrawer->DrawArrow(manifold.GetWorldSpaceContactPointOn1(0), + manifold.GetWorldSpaceContactPointOn1(0) + manifold.mWorldSpaceNormal, JPH::Color::sYellow, 0.05f); + } +#endif +} + +void ContactListener::OnContactRemoved(const JPH::SubShapeIDPair& subShapePair) +{ + // Remove the contact + { + Lock lock(currentCollisionsMutex); + + const auto iter = currentCollisions.find(subShapePair); + if (iter != currentCollisions.end()) + currentCollisions.erase(iter); + } + + if (chainedListener != nullptr) + chainedListener->OnContactRemoved(subShapePair); +} + +// ------------------------------------ // +#ifdef JPH_DEBUG_RENDERER +void ContactListener::DrawActiveContacts(JPH::DebugRenderer& debugRenderer) +{ + Lock lock(currentCollisionsMutex); + for (const auto& collision : currentCollisions) + { + for (const auto offset : collision.second.second) + { + debugRenderer.DrawWireSphere(collision.second.first + offset, 0.05f, JPH::Color::sRed, 1); + } + } +} +#endif +} // namespace Thrive::Physics diff --git a/src/native/physics/ContactListener.hpp b/src/native/physics/ContactListener.hpp new file mode 100644 index 00000000000..9421002f217 --- /dev/null +++ b/src/native/physics/ContactListener.hpp @@ -0,0 +1,78 @@ +#pragma once + +#include "Jolt/Physics/Collision/ContactListener.h" + +#include "core/Mutex.hpp" + +#ifdef JPH_DEBUG_RENDERER +namespace JPH +{ +class DebugRenderer; +} // namespace JPH +#endif + +namespace Thrive::Physics +{ +/// \brief Contact listener implementation +class ContactListener : public JPH::ContactListener +{ + using CollisionPair = std::pair; + +public: + ContactListener(); + + JPH::ValidateResult OnContactValidate(const JPH::Body& body1, const JPH::Body& body2, JPH::RVec3Arg baseOffset, + const JPH::CollideShapeResult& collisionResult) override; + + void OnContactAdded(const JPH::Body& body1, const JPH::Body& body2, const JPH::ContactManifold& manifold, + JPH::ContactSettings& settings) override; + + void OnContactPersisted(const JPH::Body& body1, const JPH::Body& body2, const JPH::ContactManifold& manifold, + JPH::ContactSettings& settings) override; + + void OnContactRemoved(const JPH::SubShapeIDPair& subShapePair) override; + + inline void SetNextListener(JPH::ContactListener* listener) noexcept + { + chainedListener = listener; + } + + inline void ReportStepNumber(uint32_t step) noexcept + { + physicsStep = step; + } + +#ifdef JPH_DEBUG_RENDERER + void DrawActiveContacts(JPH::DebugRenderer& debugRenderer); + + inline void SetDebugDraw(JPH::DebugRenderer* debugRenderer) + { + debugDrawer = debugRenderer; + } + + /// \brief When using physics debug draw rate limiting we only want to draw new things to avoid missing drawing + /// anything + inline void SetDrawOnlyNewContacts(bool onlyNew) + { + drawOnlyNew = onlyNew; + } +#endif + +private: + Mutex currentCollisionsMutex; + + // TODO: JPH seems to use a custom allocator here so we might need to do so as well (for performance) + std::unordered_map currentCollisions; + + // TODO: remove the chained listener feature if nothing is going to use it + JPH::ContactListener* chainedListener = nullptr; + + uint32_t physicsStep = std::numeric_limits::max(); + +#ifdef JPH_DEBUG_RENDERER + JPH::DebugRenderer* debugDrawer = nullptr; + bool drawOnlyNew = false; +#endif +}; + +} // namespace Thrive::Physics diff --git a/src/native/physics/CustomConstraintTypes.hpp b/src/native/physics/CustomConstraintTypes.hpp new file mode 100644 index 00000000000..fb501c75757 --- /dev/null +++ b/src/native/physics/CustomConstraintTypes.hpp @@ -0,0 +1,8 @@ +#pragma once + +#include "Jolt/Physics/Constraints/Constraint.h" + +namespace Thrive::Physics::ConstraintTypes +{ +static constexpr auto AxisLock = JPH::EConstraintSubType::User1; +} // namespace Thrive::Physics::ConstraintTypes diff --git a/src/native/physics/DebugDrawForwarder.cpp b/src/native/physics/DebugDrawForwarder.cpp new file mode 100644 index 00000000000..7962070711f --- /dev/null +++ b/src/native/physics/DebugDrawForwarder.cpp @@ -0,0 +1,347 @@ +// ------------------------------------ // +#include "DebugDrawForwarder.hpp" + +#include "Jolt/Math/Float4.h" + +#include "core/Logger.hpp" +#include "core/Time.hpp" + +// #define ENSURE_NO_COLOUR_OVER_SATURATION + +// ------------------------------------ // +#ifdef JPH_DEBUG_RENDERER +namespace Thrive::Physics +{ + +/// \brief Apparently Jolt requires us to handle geometry references +class BatchImpl : public JPH::RefTargetVirtual, + public RefCountedBasic +{ +public: + explicit BatchImpl(uint32_t inID) : id(inID) + { + } + + void AddRef() override + { + RefCountedBasic::AddRef(); + } + + void Release() override + { + RefCountedBasic::Release(); + } + + /// Vertices if this is specified like that, if empty then vertices are used + std::vector triangles; + + /// Alternative to specifying entire triangles, this is an indexed rendering approach + std::vector vertices; + std::vector indices; + + uint32_t id; +}; + +JPH::DVec3 FloatToDVec(JPH::Float3 input) +{ + return {input.x, input.y, input.z}; +} + +JPH::Float4 ColorToFloat4(const JPH::ColorArg color) +{ + constexpr float multiplier = 1 / 255.0f; + return {(float)color.r * multiplier, (float)color.g * multiplier, (float)color.b * multiplier, + (float)color.a * multiplier}; +} + +#ifdef ENSURE_NO_COLOUR_OVER_SATURATION +JPH::Float4 MixColour(JPH::Float4 baseColour, JPH::Float4 colourTint) +{ + return {std::max(baseColour.x * colourTint.x, 1.0f), std::max(baseColour.y * colourTint.y, 1.0f), + std::max(baseColour.z * colourTint.z, 1.0f), std::max(baseColour.w * colourTint.w, 1.0f)}; +} +#else +JPH::Float4 MixColour(JPH::Float4 baseColour, JPH::Float4 colourTint) +{ + return {baseColour.x * colourTint.x, baseColour.y * colourTint.y, baseColour.z * colourTint.z, + baseColour.w * colourTint.w}; +} +#endif // ENSURE_NO_COLOUR_OVER_SATURATION + +// Apparently we need to act like a GPU just to get debug rendering done... +DebugDrawForwarder::DVertex TransformVertex(const JPH::RMat44& matrix, const JPH::DebugRenderer::Vertex& vertex) +{ + // TODO: for proper usage should we transform the normal as well? + return DebugDrawForwarder::DVertex{ + matrix * FloatToDVec(vertex.mPosition), vertex.mNormal, vertex.mUV, ColorToFloat4(vertex.mColor)}; +} + +// ------------------------------------ // +DebugDrawForwarder::DebugDrawForwarder() +{ + Initialize(); +} + +// ------------------------------------ // +void DebugDrawForwarder::FlushOutput() +{ + const auto startTime = TimingClock::now(); + + Lock lock(mutex); + + // Send the accumulated data + if (lineCallback != nullptr) + { + for (const auto& line : lineBuffer) + { + lineCallback(std::get<0>(line), std::get<1>(line), std::get<2>(line)); + } + } + + if (triangleCallback != nullptr) + { + for (const auto& triangle : triangleBuffer) + { + triangleCallback( + std::get<0>(triangle), std::get<1>(triangle), std::get<2>(triangle), std::get<3>(triangle)); + } + } + + lineBuffer.clear(); + triangleBuffer.clear(); + + // TODO: clear geometries that haven't been used for a long time? + + if (adjustRateOnLag) + { + const auto duration = std::chrono::duration_cast(TimingClock::now() - startTime).count(); + + if (duration < 0.012f) + { + minDrawDelta = MaxDebugDrawRate; + } + else if (duration < 0.018f) + { + minDrawDelta = 1 / 30.0f; + } + else if (duration < 0.025f) + { + minDrawDelta = 1 / 15.0f; + } + else + { + // Lag is bad, set to the lowest possible time + minDrawDelta = 1 / 10.0f; + } + } +} + +void DebugDrawForwarder::SetOutputLineReceiver(std::function callback) +{ + lineCallback = std::move(callback); +} + +void DebugDrawForwarder::SetOutputTriangleReceiver(std::function callback) +{ + triangleCallback = std::move(callback); +} + +void DebugDrawForwarder::ClearOutputReceivers() +{ + lineCallback = nullptr; + triangleCallback = nullptr; +} + +bool DebugDrawForwarder::HasAReceiver() const noexcept +{ + return lineCallback || triangleCallback; +} + +// ------------------------------------ // +void DebugDrawForwarder::DrawLine(JPH::RVec3Arg inFrom, JPH::RVec3Arg inTo, JPH::ColorArg inColor) +{ + Lock lock(mutex); + lineBuffer.emplace_back(inFrom, inTo, ColorToFloat4(inColor)); +} + +void DebugDrawForwarder::DrawTriangle( + JPH::RVec3Arg inV1, JPH::RVec3Arg inV2, JPH::RVec3Arg inV3, JPH::ColorArg inColor, ECastShadow inCastShadow) +{ + // TODO: shadow support? + UNUSED(inCastShadow); + + Lock lock(mutex); + triangleBuffer.emplace_back(inV1, inV2, inV3, ColorToFloat4(inColor)); +} + +// It is always assumed that the renderer was responsible for creating the geometry instances, so we can cast them here +#pragma clang diagnostic push +#pragma ide diagnostic ignored "cppcoreguidelines-pro-type-static-cast-downcast" + +void DebugDrawForwarder::DrawGeometry(JPH::RMat44Arg inModelMatrix, const JPH::AABox& inWorldSpaceBounds, + float inLODScaleSq, JPH::ColorArg inModelColor, const JPH::DebugRenderer::GeometryRef& inGeometry, + JPH::DebugRenderer::ECullMode inCullMode, JPH::DebugRenderer::ECastShadow inCastShadow, + JPH::DebugRenderer::EDrawMode inDrawMode) +{ + // Skip rendering too faraway objects + const auto distance = inWorldSpaceBounds.GetSqDistanceTo(cameraPosition); + if (distance > maxModelDistance * maxModelDistance) + return; + + const JPH::RMat44 transformMatrix = inModelMatrix; + + // TODO: support for different cull modes and shadows (front face probably needs to flip the triangles and no cull + // needs to somehow signal up that culling should not be used + if (inCullMode == ECullMode::CullBackFace) + { + // Default, already works + } + + UNUSED(inCastShadow); + + // TODO: frustum culling? (or at least max distance from camera?) + UNUSED(inWorldSpaceBounds); + + // TODO: Geometry caching (this is different from the raw batches as this has LODs) + /*auto& geometryID = cachedGeometries[inGeometry]; + if (geometryID == 0) + { + // New geometry + geometryID = nextGeometryID++; + + inGeometry->mBounds.mMin; + inGeometry->mBounds.mMax; + + for (const LOD& lod : inGeometry->mLODs) + { + lod.mDistance; + static_cast(lod.mTriangleBatch.GetPtr())->id; + } + }*/ + + const auto modelTint = ColorToFloat4(inModelColor); + + for (const LOD& lod : inGeometry->mLODs) + { + // Due to us just sending all triangles etc. on each frame we try to pick a pretty low LOD here + if (lod.mDistance * inLODScaleSq < distance * cameraLODBias) + continue; + + const bool wireframe = inDrawMode == JPH::DebugRenderer::EDrawMode::Wireframe; + const auto& meshData = *static_cast(lod.mTriangleBatch.GetPtr()); + + Lock lock(mutex); + + if (meshData.triangles.empty()) + { + for (size_t i = 0; i < meshData.indices.size(); i += 3) + { + DrawTriangleInternal(TransformVertex(transformMatrix, meshData.vertices[meshData.indices[i]]), + TransformVertex(transformMatrix, meshData.vertices[meshData.indices[i + 1]]), + TransformVertex(transformMatrix, meshData.vertices[meshData.indices[i + 2]]), modelTint, wireframe); + } + } + else + { + for (const auto& triangle : meshData.triangles) + { + DrawTriangleInternal(TransformVertex(transformMatrix, triangle.mV[0]), + TransformVertex(transformMatrix, triangle.mV[1]), TransformVertex(transformMatrix, triangle.mV[2]), + modelTint, wireframe); + } + } + + return; + } + + LOG_ERROR("No debug draw LOD could be selected"); +} + +#pragma clang diagnostic pop + +void DebugDrawForwarder::DrawText3D( + JPH::RVec3Arg inPosition, const std::string_view& inString, JPH::ColorArg inColor, float inHeight) +{ + // TODO: text rendering + UNUSED(inPosition); + UNUSED(inString); + UNUSED(inColor); + UNUSED(inHeight); +} + +// ------------------------------------ // +JPH::DebugRenderer::Batch DebugDrawForwarder::CreateTriangleBatch( + const JPH::DebugRenderer::Triangle* inTriangles, int inTriangleCount) +{ + if (inTriangles == nullptr || inTriangleCount == 0) + return new BatchImpl(0); + + Lock lock(mutex); + + // TODO: maybe would be better to send this data to the other side to not duplicate a ton of vertices when drawing + // Note that for certain data it is dynamically generated each frame so some solution is also needed there + + const auto batchId = nextBatchID++; + + // This isn't immediately wrapped in the smart pointer so this could leak if the copying throws, but as this is + // just debug rendering there's not much point in doing this exactly right + auto result = new BatchImpl(batchId); + + result->triangles.reserve(inTriangleCount); + for (int i = 0; i < inTriangleCount; ++i) + { + result->triangles.emplace_back(inTriangles[i]); + } + + return result; +} + +JPH::DebugRenderer::Batch DebugDrawForwarder::CreateTriangleBatch( + const JPH::DebugRenderer::Vertex* inVertices, int inVertexCount, const uint32_t* inIndices, int inIndexCount) +{ + if (inVertices == nullptr || inVertexCount == 0 || inIndices == nullptr || inIndexCount == 0) + return new BatchImpl(0); + + Lock lock(mutex); + + // See the TODO in the above method + + const auto batchId = nextBatchID++; + + auto result = new BatchImpl(batchId); + + result->vertices.reserve(inVertexCount); + for (int i = 0; i < inVertexCount; ++i) + { + result->vertices.emplace_back(inVertices[i]); + } + + result->indices.reserve(inVertexCount); + for (int i = 0; i < inIndexCount; ++i) + { + result->indices.emplace_back(inIndices[i]); + } + + return result; +} + +// ------------------------------------ // +void DebugDrawForwarder::DrawTriangleInternal( + const DVertex& vertex1, const DVertex& vertex2, const DVertex& vertex3, JPH::Float4 colourTint, bool wireFrame) +{ + if (wireFrame) + { + lineBuffer.emplace_back(vertex1.mPosition, vertex2.mPosition, MixColour(vertex1.mColor, colourTint)); + lineBuffer.emplace_back(vertex2.mPosition, vertex3.mPosition, MixColour(vertex2.mColor, colourTint)); + lineBuffer.emplace_back(vertex3.mPosition, vertex1.mPosition, MixColour(vertex3.mColor, colourTint)); + } + else + { + // TODO: per vertex colour + triangleBuffer.emplace_back( + vertex1.mPosition, vertex2.mPosition, vertex3.mPosition, MixColour(vertex1.mColor, colourTint)); + } +} + +} // namespace Thrive::Physics +#endif // JPH_DEBUG_RENDERER diff --git a/src/native/physics/DebugDrawForwarder.hpp b/src/native/physics/DebugDrawForwarder.hpp new file mode 100644 index 00000000000..b5ddc03fb15 --- /dev/null +++ b/src/native/physics/DebugDrawForwarder.hpp @@ -0,0 +1,149 @@ +#pragma once + +#include "Jolt/Jolt.h" + +#ifdef JPH_DEBUG_RENDERER + +#include "Jolt/Renderer/DebugRenderer.h" + +#include "core/Mutex.hpp" + +namespace Thrive::Physics +{ + +constexpr float MaxDebugDrawRate = 1 / 60.0f; +constexpr bool AutoAdjustDebugDrawRateWhenSlow = true; +constexpr float DebugDrawLODBias = 2; +constexpr float DefaultMaxDistanceToDrawLinesFromCamera = 120; + +/// \brief Forwards debug draw from the physics system out of this native library +/// \todo It would probably benefit the performance a ton if this was directly made to interact with the Godot C++ API +class DebugDrawForwarder : public JPH::DebugRenderer +{ +public: + using LineCallback = void(JPH::RVec3Arg from, JPH::RVec3Arg to, JPH::Float4 colour); + using TriangleCallback = void(JPH::RVec3Arg v1, JPH::RVec3Arg v2, JPH::RVec3Arg v3, JPH::Float4 colour); + + /// \brief Variant of vertex that doesn't require converting back to floats after world space calculation + /// and has already converted colour info + class DVertex + { + public: + JPH::RVec3Arg mPosition; + JPH::Float3 mNormal; + JPH::Float2 mUV; + JPH::Float4 mColor; + }; + +private: + DebugDrawForwarder(); + +public: + static DebugDrawForwarder& GetInstance() + { + static DebugDrawForwarder instance; + return instance; + } + + void FlushOutput(); + void SetOutputLineReceiver(std::function callback); + void SetOutputTriangleReceiver(std::function callback); + + void ClearOutputReceivers(); + + bool HasAReceiver() const noexcept; + + // DebugRenderer interface implementation + void DrawLine(JPH::RVec3Arg inFrom, JPH::RVec3Arg inTo, JPH::ColorArg inColor) override; + void DrawTriangle(JPH::RVec3Arg inV1, JPH::RVec3Arg inV2, JPH::RVec3Arg inV3, JPH::ColorArg inColor, + ECastShadow inCastShadow = ECastShadow::Off) override; + void DrawGeometry(JPH::RMat44Arg inModelMatrix, const JPH::AABox& inWorldSpaceBounds, float inLODScaleSq, + JPH::ColorArg inModelColor, const GeometryRef& inGeometry, ECullMode inCullMode, ECastShadow inCastShadow, + EDrawMode inDrawMode) override; + void DrawText3D( + JPH::RVec3Arg inPosition, const std::string_view& inString, JPH::ColorArg inColor, float inHeight) override; + + // These seem to be about caching and reusing things + Batch CreateTriangleBatch(const Triangle* inTriangles, int inTriangleCount) override; + Batch CreateTriangleBatch( + const Vertex* inVertices, int inVertexCount, const uint32_t* inIndices, int inIndexCount) override; + + /// \brief Returns true once it is time to render debug stuff + /// + /// This is used to rate limit the expensive debug drawing a bit + [[nodiscard]] bool TimeToRenderDebug(float delta) + { + timeSinceDraw += delta; + + if (timeSinceDraw >= minDrawDelta) + { + timeSinceDraw = 0; + return true; + } + + return false; + } + + inline void SetCameraPositionForLOD(JPH::Vec3Arg position) + { + cameraPosition = position; + } + + inline void SetCameraLODBias(float newBias) + { + cameraLODBias = newBias; + } + + inline void SetMaxDebugDrawFPS(float framerate) + { + minDrawDelta = 1 / framerate; + } + + inline void SetAutoAdjustMaxDrawFPS(bool autoAdjustOnLag) + { + adjustRateOnLag = autoAdjustOnLag; + } + + inline void SetMaxDrawDistance(float drawDistance) + { + maxModelDistance = drawDistance; + } + +private: + void DrawTriangleInternal( + const DVertex& vertex1, const DVertex& vertex2, const DVertex& vertex3, JPH::Float4 colourTint, bool wireFrame); + +private: + /// Apparently debug rendering happens from multiple threads so we need a lock + Mutex mutex; + + /// Next ID to use for a predefined batch of geometry + uint32_t nextBatchID = 1; + + // Might get used if sending each geometry just once is implemented + // uint32_t nextGeometryID = 1; + + // /// Predefined geometries and mapping to their IDs + // std::unordered_map cachedGeometries; + + // ------------------------------------ // + // Actual variables of this debug forwarder, everything else needed to be default Jolt stuff + + std::vector> lineBuffer; + std::vector> triangleBuffer; + + std::function lineCallback; + std::function triangleCallback; + + JPH::Vec3 cameraPosition = {}; + float cameraLODBias = DebugDrawLODBias; + float minDrawDelta = MaxDebugDrawRate; + bool adjustRateOnLag = AutoAdjustDebugDrawRateWhenSlow; + float maxModelDistance = DefaultMaxDistanceToDrawLinesFromCamera; + + float timeSinceDraw = 1; +}; + +} // namespace Thrive::Physics + +#endif // JPH_DEBUG_RENDERER diff --git a/src/native/physics/Layers.hpp b/src/native/physics/Layers.hpp new file mode 100644 index 00000000000..fdd8a2a143f --- /dev/null +++ b/src/native/physics/Layers.hpp @@ -0,0 +1,144 @@ +#pragma once + +#include "Jolt/Jolt.h" +#include "Jolt/Physics/Collision/BroadPhase/BroadPhaseLayer.h" +#include "Jolt/Physics/Collision/ObjectLayer.h" + +#include "core/Logger.hpp" + +namespace Thrive::Physics +{ + +/// \brief Overall layer configuration, note that in Jolt these are not meant to be gameplay categories and there +/// should only exist a couple of these +namespace Layers +{ +static constexpr JPH::ObjectLayer NON_MOVING = 0; +static constexpr JPH::ObjectLayer MOVING = 1; +static constexpr JPH::ObjectLayer DEBRIS = 2; +static constexpr JPH::ObjectLayer SENSOR = 3; +static constexpr JPH::ObjectLayer PROJECTILE = 4; +static constexpr JPH::ObjectLayer NUM_LAYERS = 8; +}; // namespace Layers + +/// \brief Configuration for which object layer types can collide with each other +class ObjectLayerPairFilter : public JPH::ObjectLayerPairFilter +{ +public: + [[nodiscard]] bool ShouldCollide(JPH::ObjectLayer object1, JPH::ObjectLayer object2) const override + { + switch (object1) + { + case Layers::NON_MOVING: + return object2 == Layers::MOVING || object2 == Layers::DEBRIS || object2 == Layers::PROJECTILE; + case Layers::MOVING: + return object2 == Layers::NON_MOVING || object2 == Layers::MOVING || object2 == Layers::SENSOR || + object2 == Layers::PROJECTILE; + case Layers::DEBRIS: + return object2 == Layers::NON_MOVING; + case Layers::SENSOR: + return object2 == Layers::MOVING; + case Layers::PROJECTILE: + return object2 == Layers::NON_MOVING || object2 == Layers::MOVING; + default: + LOG_ERROR("Invalid object layer checked for collision"); + return false; + } + } +}; + +/// \brief Broadphase layers (there needs to be a mapping from each object layer to a broadphase layer) +namespace BroadPhaseLayers +{ +static constexpr JPH::BroadPhaseLayer NON_MOVING(0); +static constexpr JPH::BroadPhaseLayer MOVING(1); +static constexpr JPH::BroadPhaseLayer DEBRIS(2); +static constexpr JPH::BroadPhaseLayer SENSOR(3); +static constexpr JPH::BroadPhaseLayer PROJECTILE(4); +static constexpr unsigned int NUM_LAYERS(5); +}; // namespace BroadPhaseLayers + +/// \brief Broadphase layer handling, converts object layers to broadphase layers +class BroadPhaseLayerInterface final : public JPH::BroadPhaseLayerInterface +{ +public: + [[nodiscard]] unsigned int GetNumBroadPhaseLayers() const override + { + return BroadPhaseLayers::NUM_LAYERS; + } + + [[nodiscard]] JPH::BroadPhaseLayer GetBroadPhaseLayer(JPH::ObjectLayer layer) const override + { + // This is where the layer mapping is defined + // Hopefully this gets optimized very well by the compiler (the samples used a map but that was probably worse + // approach than this for performance). At least in release mode with llvm this gets compiled down to just + // a few assembly instructions and the layer to layer collide checks are many more instructions + + switch (layer) + { + case Layers::NON_MOVING: + return BroadPhaseLayers::NON_MOVING; + case Layers::MOVING: + return BroadPhaseLayers::MOVING; + case Layers::DEBRIS: + return BroadPhaseLayers::DEBRIS; + case Layers::SENSOR: + return BroadPhaseLayers::SENSOR; + case Layers::PROJECTILE: + return BroadPhaseLayers::PROJECTILE; + case Layers::NUM_LAYERS: + default: + LOG_ERROR("Attempt to get broadphase layer that doesn't exist"); + std::abort(); + } + } + +#if defined(JPH_EXTERNAL_PROFILE) || defined(JPH_PROFILE_ENABLED) + const char* GetBroadPhaseLayerName(JPH::BroadPhaseLayer layer) const override + { + switch ((JPH::BroadPhaseLayer::Type)layer) + { + case (JPH::BroadPhaseLayer::Type)BroadPhaseLayers::NON_MOVING: + return "NON_MOVING"; + case (JPH::BroadPhaseLayer::Type)BroadPhaseLayers::MOVING: + return "MOVING"; + case (JPH::BroadPhaseLayer::Type)BroadPhaseLayers::DEBRIS: + return "DEBRIS"; + case (JPH::BroadPhaseLayer::Type)BroadPhaseLayers::SENSOR: + return "SENSOR"; + case (JPH::BroadPhaseLayer::Type)BroadPhaseLayers::PROJECTILE: + return "PROJECTILE"; + default: + return "INVALID"; + } + } +#endif // JPH_EXTERNAL_PROFILE || JPH_PROFILE_ENABLED +}; + +/// \brief Specifies which object layers can collide with which broadphase layers +class ObjectToBroadPhaseLayerFilter : public JPH::ObjectVsBroadPhaseLayerFilter +{ +public: + [[nodiscard]] bool ShouldCollide(JPH::ObjectLayer objectLayer, JPH::BroadPhaseLayer broadPhaseLayer) const override + { + switch (objectLayer) + { + case Layers::NON_MOVING: + return broadPhaseLayer == BroadPhaseLayers::MOVING || broadPhaseLayer == BroadPhaseLayers::PROJECTILE; + case Layers::MOVING: + return broadPhaseLayer == BroadPhaseLayers::NON_MOVING || broadPhaseLayer == BroadPhaseLayers::MOVING || + broadPhaseLayer == BroadPhaseLayers::SENSOR || broadPhaseLayer == BroadPhaseLayers::PROJECTILE; + case Layers::DEBRIS: + return broadPhaseLayer == BroadPhaseLayers::NON_MOVING; + case Layers::SENSOR: + return broadPhaseLayer == BroadPhaseLayers::MOVING; + case Layers::PROJECTILE: + return broadPhaseLayer == BroadPhaseLayers::NON_MOVING || broadPhaseLayer == BroadPhaseLayers::MOVING; + default: + LOG_ERROR("Invalid object layer checked for collision against broadphase layers"); + return false; + } + } +}; + +} // namespace Thrive::Physics diff --git a/src/native/physics/PhysicalWorld.cpp b/src/native/physics/PhysicalWorld.cpp new file mode 100644 index 00000000000..f66ce02e8c9 --- /dev/null +++ b/src/native/physics/PhysicalWorld.cpp @@ -0,0 +1,1378 @@ +// ------------------------------------ // +#include "PhysicalWorld.hpp" + +#include +#include + +#include "boost/circular_buffer.hpp" + +// TODO: switch to a custom thread pool +#include "Jolt/Core/JobSystemThreadPool.h" +#include "Jolt/Core/StreamWrapper.h" +#include "Jolt/Physics/Body/BodyCreationSettings.h" +#include "Jolt/Physics/Collision/CastResult.h" +#include "Jolt/Physics/Collision/RayCast.h" +#include "Jolt/Physics/Constraints/SixDOFConstraint.h" +#include "Jolt/Physics/PhysicsScene.h" +#include "Jolt/Physics/PhysicsSettings.h" +#include "Jolt/Physics/PhysicsSystem.h" + +// #include "core/TaskSystem.hpp" + +#include "core/Mutex.hpp" +#include "core/Spinlock.hpp" +#include "core/Time.hpp" + +#include "ArrayRayCollector.hpp" +#include "BodyActivationListener.hpp" +#include "BodyControlState.hpp" +#include "ContactListener.hpp" +#include "PhysicsBody.hpp" +#include "StepListener.hpp" +#include "TrackedConstraint.hpp" + +#ifdef JPH_DEBUG_RENDERER +#include "DebugDrawForwarder.hpp" +#endif + +JPH_SUPPRESS_WARNINGS + +// Enables slower turning in ApplyBodyControl when close to the target rotation +// #define USE_SLOW_TURN_NEAR_TARGET + +// ------------------------------------ // +namespace Thrive::Physics +{ + +class PhysicalWorld::Pimpl +{ +public: + Pimpl() : durationBuffer(30) + { +#ifdef JPH_DEBUG_RENDERER + // Convex shapes + // This is very expensive in terms of debug rendering data amount + bodyDrawSettings.mDrawGetSupportFunction = false; + + // Wireframe is preferred when + bodyDrawSettings.mDrawShapeWireframe = true; + + bodyDrawSettings.mDrawCenterOfMassTransform = true; + + // TODO: some of the extra settings should be enableable +#endif + + activeBodiesWithCollisions.reserve(50); + } + + void AddPerStepControlBody(PhysicsBody& body) + { + bodiesStepControlLock.Lock(); + + // TODO: avoid duplicates if someone else will also add items to this list + bodiesWithPerStepControl.emplace_back(&body); + + bodiesStepControlLock.Unlock(); + } + + void RemovePerStepControlBody(PhysicsBody& body) + { + bodiesStepControlLock.Lock(); + + for (auto iter = bodiesWithPerStepControl.begin(); iter != bodiesWithPerStepControl.end(); ++iter) + { + if ((*iter).get() == &body) + { + // TODO: if items can be in this vector for multiple reasons this will need to check that + bodiesWithPerStepControl.erase(iter); + bodiesStepControlLock.Unlock(); + return; + } + } + + bodiesStepControlLock.Unlock(); + + LOG_ERROR("Didn't find body in internal vector of bodies needing operations each step"); + } + + float AddAndCalculateAverageTime(float duration) + { + durationBuffer.push_back(duration); + + const auto size = durationBuffer.size(); + + if (size < 1) + { + LOG_ERROR("Duration circular buffer empty"); + return -1; + } + + float durations = 0; + + for (const auto value : durationBuffer) + { + durations += value; + } + + return durations / static_cast(size); + } + + inline void PushBodyWithActiveCollisions(PhysicsBody& body) + { + activeBodyWriteLock.Lock(); + + activeBodiesWithCollisions.emplace_back(&body); + + activeBodyWriteLock.Unlock(); + } + + /// \brief Ensures a body is no longer referenced by any step data + void NotifyBodyRemove(PhysicsBody* body) noexcept // NOLINT(*-make-member-function-const) + { + const auto size = activeBodiesWithCollisions.size(); + for (size_t i = 0; i < size; ++i) + { + if (activeBodiesWithCollisions[i] != body) + continue; + + // Need to remove this, as we don't have to ensure the order we can swap this to be last in the vector + // and pop the last + if (i + 1 < size) + { + std::swap(activeBodiesWithCollisions[i], activeBodiesWithCollisions[size - 1]); + } + else + { + // Already last + } + + activeBodiesWithCollisions.pop_back(); + + return; + } + } + + void HandleExpiringBodyCollisions() // NOLINT(*-make-member-function-const) + { + // Mark all previous collision data as empty + const auto size = activeBodiesWithCollisions.size(); + for (size_t i = 0; i < size; ++i) + { + activeBodiesWithCollisions[i]->ClearRecordedData(); + } + + activeBodiesWithCollisions.clear(); + } + + inline void IncrementStepCounter() noexcept + { + ++stepCounter; + + if (stepCounter >= std::numeric_limits::max()) + { + // Skip the last value to ensure no uninitialized values cause a problem if they appear on this update + stepCounter = 1; + } + } + +public: + BroadPhaseLayerInterface broadPhaseLayer; + ObjectToBroadPhaseLayerFilter objectToBroadPhaseLayer; + ObjectLayerPairFilter objectToObjectPair; + + JPH::PhysicsSettings physicsSettings; + + boost::circular_buffer durationBuffer; + + std::vector> bodiesWithPerStepControl; + + Spinlock bodiesStepControlLock; + + JPH::Vec3 gravity = JPH::Vec3(0, -9.81f, 0); + + std::vector activeBodiesWithCollisions; + + Spinlock activeBodyWriteLock; + + uint32_t stepCounter = 0; + +#ifdef JPH_DEBUG_RENDERER + JPH::BodyManager::DrawSettings bodyDrawSettings; + + JPH::Vec3Arg debugDrawCameraLocation = {}; +#endif +}; + +PhysicalWorld::PhysicalWorld() : pimpl(std::make_unique()) +{ +#ifdef USE_OBJECT_POOLS + tempAllocator = std::make_unique(32 * 1024 * 1024); +#else + tempAllocator = std::make_unique(); +#endif + + // Create job system + // TODO: configurable threads (should be about 1-8), or well if we share thread with other systems then maybe up + // to like any cores not used by the C# background tasks + int physicsThreads = 2; + jobSystem = + std::make_unique(JPH::cMaxPhysicsJobs, JPH::cMaxPhysicsBarriers, physicsThreads); + + InitPhysicsWorld(); +} + +PhysicalWorld::~PhysicalWorld() +{ + if (bodyCount != 0) + { + LOG_ERROR( + "PhysicalWorld destroyed while not all bodies were removed, existing bodies: " + std::to_string(bodyCount)); + } +} + +// ------------------------------------ // +void PhysicalWorld::InitPhysicsWorld() +{ + physicsSystem = std::make_unique(); + physicsSystem->Init(maxBodies, maxBodyMutexes, maxBodyPairs, maxContactConstraints, pimpl->broadPhaseLayer, + pimpl->objectToBroadPhaseLayer, pimpl->objectToObjectPair); + physicsSystem->SetPhysicsSettings(pimpl->physicsSettings); + + physicsSystem->SetGravity(pimpl->gravity); + + // Contact listening + contactListener = std::make_unique(); + + // contactListener->SetNextListener(something); + physicsSystem->SetContactListener(contactListener.get()); + + // Activation listening + activationListener = std::make_unique(); + physicsSystem->SetBodyActivationListener(activationListener.get()); + + stepListener = std::make_unique(*this); + physicsSystem->AddStepListener(stepListener.get()); +} + +// ------------------------------------ // +bool PhysicalWorld::Process(float delta) +{ + // TODO: update thread count if changed (we won't need this when we have the custom job system done) + + elapsedSinceUpdate += delta; + + const auto singlePhysicsFrame = 1 / physicsFrameRate; + + bool simulatedPhysics = false; + float simulatedTime = 0; + + // TODO: limit max steps per frame to avoid massive potential for lag spikes + // TODO: alternatively to this it is possible to use a bigger timestep at once but then collision steps and + // integration steps should be incremented + while (elapsedSinceUpdate > singlePhysicsFrame) + { + elapsedSinceUpdate -= singlePhysicsFrame; + simulatedTime += singlePhysicsFrame; + StepPhysics(*jobSystem, singlePhysicsFrame); + simulatedPhysics = true; + } + + if (!simulatedPhysics) + return false; + + // TODO: Trigger stuff from the collision detection (but maybe some stuff needs to trigger for each step?) + + DrawPhysics(simulatedTime); + + return true; +} + +// ------------------------------------ // +Ref PhysicalWorld::CreateMovingBody(const JPH::RefConst& shape, JPH::RVec3Arg position, + JPH::Quat rotation /* = JPH::Quat::sIdentity()*/, bool addToWorld /*= true*/) +{ + if (shape == nullptr) + { + LOG_ERROR("No shape given to body create"); + return nullptr; + } + + // TODO: multithreaded body adding? + return OnBodyCreated(CreateBody(*shape, JPH::EMotionType::Dynamic, Layers::MOVING, position, rotation), addToWorld); +} + +Ref PhysicalWorld::CreateMovingBodyWithAxisLock(const JPH::RefConst& shape, + JPH::RVec3Arg position, JPH::Quat rotation, JPH::Vec3 lockedAxes, bool lockRotation, bool addToWorld /*= true*/) +{ + if (shape == nullptr) + { + LOG_ERROR("No shape given to body create"); + return nullptr; + } + + JPH::EAllowedDOFs degreesOfFreedom = JPH::EAllowedDOFs::All; + + if (lockedAxes.GetX() != 0) + degreesOfFreedom &= ~JPH::EAllowedDOFs::TranslationX; + + if (lockedAxes.GetY() != 0) + degreesOfFreedom &= ~JPH::EAllowedDOFs::TranslationY; + + if (lockedAxes.GetZ() != 0) + degreesOfFreedom &= ~JPH::EAllowedDOFs::TranslationZ; + + if (lockRotation) + { + if (lockedAxes.GetX() != 0) + { + degreesOfFreedom &= ~JPH::EAllowedDOFs::RotationY; + degreesOfFreedom &= ~JPH::EAllowedDOFs::RotationZ; + } + + if (lockedAxes.GetY() != 0) + { + degreesOfFreedom &= ~JPH::EAllowedDOFs::RotationX; + degreesOfFreedom &= ~JPH::EAllowedDOFs::RotationZ; + } + + if (lockedAxes.GetZ() != 0) + { + degreesOfFreedom &= ~JPH::EAllowedDOFs::RotationX; + degreesOfFreedom &= ~JPH::EAllowedDOFs::RotationY; + } + } + + // TODO: multithreaded body adding? + return OnBodyCreated( + CreateBody(*shape, JPH::EMotionType::Dynamic, Layers::MOVING, position, rotation, degreesOfFreedom), + addToWorld); +} + +Ref PhysicalWorld::CreateStaticBody(const JPH::RefConst& shape, JPH::RVec3Arg position, + JPH::Quat rotation /* = JPH::Quat::sIdentity()*/, bool addToWorld /*= true*/) +{ + if (shape == nullptr) + { + LOG_ERROR("No shape given to static body create"); + return nullptr; + } + + // TODO: multithreaded body adding? + auto body = CreateBody(*shape, JPH::EMotionType::Static, Layers::NON_MOVING, position, rotation); + + if (body == nullptr) + return nullptr; + + if (addToWorld) + { + physicsSystem->GetBodyInterface().AddBody(body->GetId(), JPH::EActivation::DontActivate); + OnPostBodyAdded(*body); + } + + return body; +} + +void PhysicalWorld::AddBody(PhysicsBody& body, bool activate) +{ + if (body.IsInWorld() && !body.IsDetached()) + { + LOG_ERROR("Physics body is already in some world, not adding it to this world"); + return; + } + + if (body.IsInSpecificWorld(this)) + { + LOG_ERROR("Physics body can only be added back to the world it was created for"); + return; + } + + // Create constraints if not done yet + for (auto& constraint : body.GetConstraints()) + { + if (!constraint->IsCreatedInWorld()) + { + // TODO: constraint creation has to be skipped if the other body the constraint is on is currently + // detached + + physicsSystem->AddConstraint(constraint->GetConstraint().GetPtr()); + constraint->OnRegisteredToWorld(*this); + } + } + + physicsSystem->GetBodyInterface().AddBody( + body.GetId(), activate ? JPH::EActivation::Activate : JPH::EActivation::DontActivate); + OnPostBodyAdded(body); +} + +void PhysicalWorld::DetachBody(PhysicsBody& body) +{ + if (!body.IsInWorld() || body.IsDetached()) + { + LOG_ERROR("Can't detach physics body not in world or detached already"); + return; + } + + auto& bodyInterface = physicsSystem->GetBodyInterface(); + + OnBodyPreLeaveWorld(body); + + bodyInterface.RemoveBody(body.GetId()); + + OnPostBodyLeaveWorld(body); + + body.MarkDetached(); +} + +void PhysicalWorld::DestroyBody(const Ref& body) +{ + if (body == nullptr) + return; + + if (!body->IsInWorld()) + { + LOG_ERROR("Cannot destroy a physics body not in the world"); + return; + } + + auto& bodyInterface = physicsSystem->GetBodyInterface(); + + // Special handling for bodies that are detached as part of their destruction logic has already been performed + if (body->IsDetached()) + { + bodyInterface.DestroyBody(body->GetId()); + body->MarkRemovedFromWorld(); + + return; + } + + OnBodyPreLeaveWorld(*body); + + bodyInterface.RemoveBody(body->GetId()); + + // Permanently destroy the body + bodyInterface.DestroyBody(body->GetId()); + body->MarkRemovedFromWorld(); + + OnPostBodyLeaveWorld(*body); + + changesToBodies = true; +} + +// ------------------------------------ // +void PhysicalWorld::SetDamping(JPH::BodyID bodyId, float damping, const float* angularDamping /*= nullptr*/) +{ + JPH::BodyLockWrite lock(physicsSystem->GetBodyLockInterface(), bodyId); + if (!lock.Succeeded()) [[unlikely]] + { + LOG_ERROR("Couldn't lock body for setting damping"); + return; + } + + JPH::Body& body = lock.GetBody(); + auto* motionProperties = body.GetMotionProperties(); + + motionProperties->SetLinearDamping(damping); + + if (angularDamping != nullptr) + motionProperties->SetAngularDamping(*angularDamping); +} + +// ------------------------------------ // +void PhysicalWorld::ReadBodyTransform( + JPH::BodyID bodyId, JPH::RVec3& positionReceiver, JPH::Quat& rotationReceiver) const +{ + JPH::BodyLockRead lock(physicsSystem->GetBodyLockInterface(), bodyId); + if (lock.Succeeded()) [[likely]] + { + const JPH::Body& body = lock.GetBody(); + + positionReceiver = body.GetPosition(); + rotationReceiver = body.GetRotation(); + } + else + { + LOG_ERROR("Couldn't lock body for reading transform"); + std::memset(&positionReceiver, 0, sizeof(positionReceiver)); + std::memset(&rotationReceiver, 0, sizeof(rotationReceiver)); + } +} + +void PhysicalWorld::ReadBodyVelocity( + JPH::BodyID bodyId, JPH::Vec3& velocityReceiver, JPH::Vec3& angularVelocityReceiver) const +{ + JPH::BodyLockRead lock(physicsSystem->GetBodyLockInterface(), bodyId); + if (lock.Succeeded()) [[likely]] + { + const JPH::Body& body = lock.GetBody(); + + velocityReceiver = body.GetLinearVelocity(); + angularVelocityReceiver = body.GetAngularVelocity(); + } + else + { + LOG_ERROR("Couldn't lock body for reading velocity"); + std::memset(&velocityReceiver, 0, sizeof(velocityReceiver)); + std::memset(&angularVelocityReceiver, 0, sizeof(angularVelocityReceiver)); + } +} + +void PhysicalWorld::GiveImpulse(JPH::BodyID bodyId, JPH::Vec3Arg impulse) +{ + JPH::BodyLockWrite lock(physicsSystem->GetBodyLockInterface(), bodyId); + if (!lock.Succeeded()) [[unlikely]] + { + LOG_ERROR("Couldn't lock body for giving impulse"); + return; + } + + JPH::Body& body = lock.GetBody(); + body.AddImpulse(impulse); +} + +void PhysicalWorld::SetVelocity(JPH::BodyID bodyId, JPH::Vec3Arg velocity) +{ + JPH::BodyLockWrite lock(physicsSystem->GetBodyLockInterface(), bodyId); + if (!lock.Succeeded()) [[unlikely]] + { + LOG_ERROR("Couldn't lock body for setting velocity"); + return; + } + + JPH::Body& body = lock.GetBody(); + body.SetLinearVelocityClamped(velocity); +} + +void PhysicalWorld::SetAngularVelocity(JPH::BodyID bodyId, JPH::Vec3Arg velocity) +{ + JPH::BodyLockWrite lock(physicsSystem->GetBodyLockInterface(), bodyId); + if (!lock.Succeeded()) [[unlikely]] + { + LOG_ERROR("Couldn't lock body for setting angular velocity"); + return; + } + + JPH::Body& body = lock.GetBody(); + body.SetAngularVelocityClamped(velocity); +} + +void PhysicalWorld::GiveAngularImpulse(JPH::BodyID bodyId, JPH::Vec3Arg impulse) +{ + JPH::BodyLockWrite lock(physicsSystem->GetBodyLockInterface(), bodyId); + if (!lock.Succeeded()) [[unlikely]] + { + LOG_ERROR("Couldn't lock body for giving angular impulse"); + return; + } + + JPH::Body& body = lock.GetBody(); + body.AddAngularImpulse(impulse); +} + +void PhysicalWorld::SetVelocityAndAngularVelocity( + JPH::BodyID bodyId, JPH::Vec3Arg velocity, JPH::Vec3Arg angularVelocity) +{ + JPH::BodyLockWrite lock(physicsSystem->GetBodyLockInterface(), bodyId); + if (!lock.Succeeded()) [[unlikely]] + { + LOG_ERROR("Couldn't lock body for setting velocity and angular velocity"); + return; + } + + JPH::Body& body = lock.GetBody(); + body.SetLinearVelocityClamped(velocity); + body.SetAngularVelocityClamped(angularVelocity); +} + +void PhysicalWorld::SetBodyControl( + PhysicsBody& bodyWrapper, JPH::Vec3Arg movementImpulse, JPH::Quat targetRotation, float rotationRate) +{ + // Used to detect when the target has changed enough to warrant logic change in the control apply. This needs + // to be relatively large to avoid oscillation + constexpr auto newRotationTargetAfter = 0.01f; + + if (rotationRate <= 0) [[unlikely]] + { + LOG_ERROR("Invalid rotationRate variable for controlling a body, needs to be positive"); + return; + } + + BodyControlState* state; + + bool justEnabled = bodyWrapper.EnableBodyControlIfNotAlready(); + + state = bodyWrapper.GetBodyControlState(); + + if (state == nullptr) [[unlikely]] + { + LOG_ERROR("Logic error in body control state creation (state should have been created)"); + return; + } + + if (justEnabled) [[unlikely]] + { + pimpl->AddPerStepControlBody(bodyWrapper); + + state->previousTarget = targetRotation; + state->targetRotation = targetRotation; + state->targetChanged = true; + state->justStarted = true; + } + else + { + state->targetRotation = targetRotation; + + if (!targetRotation.IsClose(state->previousTarget, newRotationTargetAfter)) + { + state->targetChanged = true; + state->previousTarget = state->targetRotation; + } + } + + state->movement = movementImpulse; + state->rotationRate = rotationRate; +} + +void PhysicalWorld::DisableBodyControl(PhysicsBody& bodyWrapper) +{ + if (bodyWrapper.DisableBodyControl()) + { + pimpl->RemovePerStepControlBody(bodyWrapper); + } +} + +void PhysicalWorld::SetPosition(JPH::BodyID bodyId, JPH::DVec3Arg position, bool activate) +{ + physicsSystem->GetBodyInterface().SetPosition( + bodyId, position, activate ? JPH::EActivation::Activate : JPH::EActivation::DontActivate); +} + +void PhysicalWorld::SetBodyAllowSleep(JPH::BodyID bodyId, bool allowSleeping) +{ + JPH::BodyLockWrite lock(physicsSystem->GetBodyLockInterface(), bodyId); + if (!lock.Succeeded()) [[unlikely]] + { + LOG_ERROR("Couldn't lock body for setting allow sleep"); + return; + } + + JPH::Body& body = lock.GetBody(); + body.SetAllowSleeping(allowSleeping); +} + +bool PhysicalWorld::FixBodyYCoordinateToZero(JPH::BodyID bodyId) +{ + decltype(std::declval().GetPosition()) position; + + { + // TODO: maybe there's a way to avoid the double lock here? (setting position takes a lock as well) + JPH::BodyLockRead lock(physicsSystem->GetBodyLockInterface(), bodyId); + if (!lock.Succeeded()) [[unlikely]] + { + LOG_ERROR("Can't lock body for y-position fix"); + return false; + } + + const JPH::Body& body = lock.GetBody(); + + position = body.GetPosition(); + } + + // Likely is used here as this is only called from C# when drifting has actually been detected + if (std::abs(position.GetY()) > 0.0005f) [[likely]] + { + SetPosition(bodyId, {position.GetX(), 0, position.GetZ()}, false); + return true; + } + + return false; +} + +void PhysicalWorld::ChangeBodyShape(JPH::BodyID bodyId, const JPH::RefConst& shape, bool activate) +{ + // For now this always recalculates mass and inertia + physicsSystem->GetBodyInterface().SetShape( + bodyId, shape, true, activate ? JPH::EActivation::Activate : JPH::EActivation::DontActivate); +} + +// ------------------------------------ // +const int32_t* PhysicalWorld::EnableCollisionRecording( + PhysicsBody& body, CollisionRecordListType collisionRecordingTarget, int maxRecordedCollisions) +{ + body.SetCollisionRecordingTarget(collisionRecordingTarget, maxRecordedCollisions); + + if (body.MarkCollisionRecordingEnabled()) + { + UpdateBodyUserPointer(body); + } + + return body.GetRecordedCollisionTargetAddress(); +} + +void PhysicalWorld::DisableCollisionRecording(PhysicsBody& body) +{ + body.ClearCollisionRecordingTarget(); + + if (body.MarkCollisionRecordingDisabled()) + { + UpdateBodyUserPointer(body); + } +} + +void PhysicalWorld::AddCollisionIgnore(PhysicsBody& body, const PhysicsBody& ignoredBody, bool skipDuplicates) +{ + body.AddCollisionIgnore(ignoredBody, skipDuplicates); + + if (body.MarkCollisionFilterEnabled()) + { + UpdateBodyUserPointer(body); + } +} + +bool PhysicalWorld::RemoveCollisionIgnore(PhysicsBody& body, const PhysicsBody& noLongerIgnoredBody) +{ + const auto changes = body.RemoveCollisionIgnore(noLongerIgnoredBody); + + if (body.MarkCollisionFilterEnabled()) + { + UpdateBodyUserPointer(body); + } + + return changes; +} + +void PhysicalWorld::SetCollisionIgnores(PhysicsBody& body, PhysicsBody* const& ignoredBodies, int ignoreCount) +{ + body.SetCollisionIgnores(ignoredBodies, ignoreCount); + + if (body.MarkCollisionFilterEnabled()) + { + UpdateBodyUserPointer(body); + } +} + +void PhysicalWorld::SetSingleCollisionIgnore(PhysicsBody& body, const PhysicsBody& onlyIgnoredBody) +{ + body.SetSingleCollisionIgnore(onlyIgnoredBody); + + if (body.MarkCollisionFilterEnabled()) + { + UpdateBodyUserPointer(body); + } +} + +void PhysicalWorld::ClearCollisionIgnores(PhysicsBody& body) +{ + body.ClearCollisionIgnores(); + + if (body.MarkCollisionFilterDisabled()) + { + UpdateBodyUserPointer(body); + } +} + +void PhysicalWorld::SetCollisionDisabledState(PhysicsBody& body, bool disableAllCollisions) +{ + if (!body.SetDisableAllCollisions(disableAllCollisions)) + { + // No changes + return; + } + + if (disableAllCollisions) + { + body.MarkCollisionDisableFlagEnabled(); + } + else + { + body.MarkCollisionDisableFlagDisabled(); + } + + UpdateBodyUserPointer(body); +} + +void PhysicalWorld::AddCollisionFilter(PhysicsBody& body, CollisionFilterCallback callback) +{ + body.SetCollisionFilter(callback); + + if (body.MarkCollisionFilterCallbackUsed()) + { + UpdateBodyUserPointer(body); + } +} + +void PhysicalWorld::DisableCollisionFilter(PhysicsBody& body) +{ + body.RemoveCollisionFilter(); + + if (body.MarkCollisionFilterCallbackDisabled()) + { + UpdateBodyUserPointer(body); + } +} + +// ------------------------------------ // +Ref PhysicalWorld::CreateAxisLockConstraint(PhysicsBody& body, JPH::Vec3 axis, bool lockRotation) +{ + JPH::BodyLockWrite lock(physicsSystem->GetBodyLockInterface(), body.GetId()); + if (!lock.Succeeded()) [[unlikely]] + { + LOG_ERROR("Locking body for adding a constraint failed"); + return nullptr; + } + + JPH::SixDOFConstraintSettings constraintSettings; + + // This was in an example at https://github.com/jrouwe/JoltPhysics/issues/359 but would require some extra + // space calculation so this is left out + // constraintSettings.mSpace = JPH::EConstraintSpace::LocalToBodyCOM; + + if (axis.GetX() != 0) + constraintSettings.MakeFixedAxis(JPH::SixDOFConstraintSettings::TranslationX); + + if (axis.GetY() != 0) + constraintSettings.MakeFixedAxis(JPH::SixDOFConstraintSettings::TranslationY); + + if (axis.GetZ() != 0) + constraintSettings.MakeFixedAxis(JPH::SixDOFConstraintSettings::TranslationZ); + + if (lockRotation) + { + if (axis.GetX() != 0) + { + constraintSettings.MakeFixedAxis(JPH::SixDOFConstraintSettings::RotationY); + constraintSettings.MakeFixedAxis(JPH::SixDOFConstraintSettings::RotationZ); + } + + if (axis.GetY() != 0) + { + constraintSettings.MakeFixedAxis(JPH::SixDOFConstraintSettings::RotationX); + constraintSettings.MakeFixedAxis(JPH::SixDOFConstraintSettings::RotationZ); + } + + if (axis.GetZ() != 0) + { + constraintSettings.MakeFixedAxis(JPH::SixDOFConstraintSettings::RotationY); + constraintSettings.MakeFixedAxis(JPH::SixDOFConstraintSettings::RotationZ); + } + } + + // Needed for precision on the axis lock to actually stay relatively close to the target value + constraintSettings.mPosition1 = constraintSettings.mPosition2 = lock.GetBody().GetCenterOfMassPosition(); + + auto constraintPtr = (JPH::SixDOFConstraint*)constraintSettings.Create(JPH::Body::sFixedToWorld, lock.GetBody()); + +#ifdef USE_OBJECT_POOLS + auto trackedConstraint = + ConstructFromGlobalPool(JPH::Ref(constraintPtr), Ref(&body)); +#else + auto trackedConstraint = Ref( + new TrackedConstraint(JPH::Ref(constraintPtr), Ref(&body))); +#endif + + if (body.IsInWorld() && !body.IsDetached()) + { + // Immediately register the constraint if the body is in the world currently + + // TODO: multithreaded adding? + physicsSystem->AddConstraint(trackedConstraint->GetConstraint().GetPtr()); + trackedConstraint->OnRegisteredToWorld(*this); + } + + return trackedConstraint; +} + +void PhysicalWorld::DestroyConstraint(TrackedConstraint& constraint) +{ + // TODO: allow multithreading + physicsSystem->RemoveConstraint(constraint.GetConstraint().GetPtr()); + constraint.OnDestroyByWorld(*this); +} + +// ------------------------------------ // +std::optional> PhysicalWorld::CastRay(JPH::RVec3 start, JPH::Vec3 endOffset) +{ + // The Jolt samples app has some really nice alternative cast modes that could be added in the future + + JPH::RRayCast ray{start, endOffset}; + + // Cast ray + JPH::RayCastResult hit; + + // TODO: could ignore certain groups + bool hitSomething = physicsSystem->GetNarrowPhaseQuery().CastRay(ray, hit); + + if (!hitSomething) + return {}; + + const auto resultPosition = ray.GetPointOnRay(hit.mFraction); + const auto resultFraction = hit.mFraction; + const auto resultID = hit.mBodyID; + + // Could do something with the hit sub-shape + // hit.mSubShapeID2 + + // Or material + // JPH::BodyLockRead lock(physicsSystem->GetBodyLockInterface(), hit.mBodyID); + // if (lock.Succeeded()) + // { + // const JPH::Body& resultBody = lock.GetBody(); + // const JPH::PhysicsMaterial* material = resultBody.GetShape()->GetMaterial(hit.mSubShapeID2); + // } + // else + // { + // LOG_ERROR("Failed to get body read lock for ray cast"); + // } + + return std::tuple(resultFraction, resultPosition, resultID); +} + +int PhysicalWorld::CastRayGetAllUserData( + JPH::RVec3 start, JPH::Vec3 endOffset, PhysicsRayWithUserData* dataReceiver, int maxHits) +{ + if (maxHits < 1 || dataReceiver == nullptr) + { + LOG_ERROR("Physics ray collection given no storage space for results"); + return 0; + } + + JPH::RRayCast ray{start, endOffset}; + + ArrayRayCollector rayCollector{dataReceiver, maxHits, physicsSystem->GetBodyLockInterface()}; + + JPH::RayCastSettings settings; + + // TODO: should the option to treat convex as solid be set to false? + // settings.mTreatConvexAsSolid = false; + + // TODO: could ignore certain groups + physicsSystem->GetNarrowPhaseQuery().CastRay(ray, settings, rayCollector); + + return rayCollector.GetHitCount(); +} + +// ------------------------------------ // +void PhysicalWorld::SetGravity(JPH::Vec3 newGravity) +{ + pimpl->gravity = newGravity; + + physicsSystem->SetGravity(pimpl->gravity); +} + +void PhysicalWorld::RemoveGravity() +{ + SetGravity(JPH::Vec3(0, 0, 0)); +} + +// ------------------------------------ // +bool PhysicalWorld::DumpSystemState(std::string_view path) +{ + // Dump a Jolt snapshot to the path + JPH::Ref scene = new JPH::PhysicsScene(); + scene->FromPhysicsSystem(physicsSystem.get()); + + std::ofstream stream(path.data(), std::ofstream::out | std::ofstream::trunc | std::ofstream::binary); + JPH::StreamOutWrapper wrapper(stream); + + if (stream.is_open()) [[likely]] + { + scene->SaveBinaryState(wrapper, true, true); + } + else + { + LOG_ERROR(std::string("Can't dump physics state to non-writable file at: ") + path.data()); + return false; + } + + return true; +} + +// ------------------------------------ // +void PhysicalWorld::StepPhysics(JPH::JobSystemThreadPool& jobs, float time) +{ + if (changesToBodies) [[unlikely]] + { + if (simulationsToNextOptimization <= 0) + { + // Queue an optimization + simulationsToNextOptimization = simulationsBetweenBroadPhaseOptimization; + } + + changesToBodies = false; + } + + // Optimize broadphase (but at most quite rarely) + if (simulationsToNextOptimization > 0) [[unlikely]] + { + if (--simulationsToNextOptimization <= 0) + { + simulationsToNextOptimization = 0; + + // Time to optimize + physicsSystem->OptimizeBroadPhase(); + } + } + + // TODO: physics processing time tracking with a high resolution timer (should get the average time over the last + // second) + const auto start = TimingClock::now(); + + // Per physics step forces are applied in PerformPhysicsStepOperations triggered by the step listener + + const auto result = physicsSystem->Update(time, collisionStepsPerUpdate, tempAllocator.get(), &jobs); + + const auto elapsed = std::chrono::duration_cast(TimingClock::now() - start).count(); + + switch (result) + { + [[likely]] case JPH::EPhysicsUpdateError::None: + break; + case JPH::EPhysicsUpdateError::ManifoldCacheFull: + LOG_ERROR("Physics update error: manifold cache full"); + break; + case JPH::EPhysicsUpdateError::BodyPairCacheFull: + LOG_ERROR("Physics update error: body pair cache full"); + break; + case JPH::EPhysicsUpdateError::ContactConstraintsFull: + LOG_ERROR("Physics update error: contact constraints full"); + break; + default: + LOG_ERROR("Physics update error: unknown"); + } + + latestPhysicsTime = elapsed; + + averagePhysicsTime = pimpl->AddAndCalculateAverageTime(elapsed); +} + +void PhysicalWorld::ReportBodyWithActiveCollisions(PhysicsBody& body) +{ + pimpl->PushBodyWithActiveCollisions(body); +} + +void PhysicalWorld::PerformPhysicsStepOperations(float delta) +{ + pimpl->IncrementStepCounter(); + + // Collision setup + contactListener->ReportStepNumber(pimpl->stepCounter); + + pimpl->HandleExpiringBodyCollisions(); + + // Apply per-step physics body state + + // This is locked just for safety, but it should be the case that no physics modify operations should be allowed + // once physics runs have started + pimpl->bodiesStepControlLock.Lock(); + + // TODO: multithreading if there's a ton of bodies using this + for (const auto& bodyPtr : pimpl->bodiesWithPerStepControl) + { + auto& body = *bodyPtr; + + if (body.GetBodyControlState() != nullptr) [[likely]] + ApplyBodyControl(body, delta); + } + + pimpl->bodiesStepControlLock.Unlock(); +} + +Ref PhysicalWorld::CreateBody(const JPH::Shape& shape, JPH::EMotionType motionType, JPH::ObjectLayer layer, + JPH::RVec3Arg position, JPH::Quat rotation /*= JPH::Quat::sIdentity()*/, + JPH::EAllowedDOFs allowedDegreesOfFreedom /*= JPH::EAllowedDOFs::All*/) +{ +#ifndef NDEBUG + // Sanity check some layer stuff + if (motionType == JPH::EMotionType::Dynamic && layer == Layers::NON_MOVING) + { + LOG_ERROR("Incorrect motion type for layer specified"); + return nullptr; + } +#endif + + auto creationSettings = JPH::BodyCreationSettings(&shape, position, rotation, motionType, layer); + creationSettings.mAllowedDOFs = allowedDegreesOfFreedom; + + const auto body = physicsSystem->GetBodyInterface().CreateBody(creationSettings); + + if (body == nullptr) [[unlikely]] + { + LOG_ERROR("Ran out of physics bodies"); + return nullptr; + } + + changesToBodies = true; + +#ifdef USE_OBJECT_POOLS + return ConstructFromGlobalPool(body, body->GetID()); +#else + return {new PhysicsBody(body, body->GetID())}; +#endif +} + +Ref PhysicalWorld::OnBodyCreated(Ref&& body, bool addToWorld) +{ + if (body == nullptr) [[unlikely]] + return nullptr; + + // Safety check for pointer data alignment + if (reinterpret_cast(body.get()) & STUFFED_POINTER_DATA_MASK) [[unlikely]] + { + LOG_ERROR("Allocated PhysicsBody doesn't follow alignment requirements! It uses low bits in the pointer."); + std::abort(); + } + + if (addToWorld) + { + physicsSystem->GetBodyInterface().AddBody(body->GetId(), JPH::EActivation::Activate); + OnPostBodyAdded(*body); + } + + return std::move(body); +} + +void PhysicalWorld::OnPostBodyAdded(PhysicsBody& body) +{ + body.MarkUsedInWorld(this); + + // Add an extra reference to the body to keep it from being deleted while in this world + // TODO: does detached body also need to keep an extra reference? + body.AddRef(); + ++bodyCount; +} + +void PhysicalWorld::OnBodyPreLeaveWorld(PhysicsBody& body) +{ + // TODO: allow detaching bodies to keep the constraint data intact for re-creating constraints when adding them + // back + // Destroy constraints + while (!body.GetConstraints().empty()) + { + DestroyConstraint(*body.GetConstraints().back()); + } + + if (body.GetBodyControlState() != nullptr) + DisableBodyControl(body); +} + +void PhysicalWorld::OnPostBodyLeaveWorld(PhysicsBody& body) +{ + pimpl->NotifyBodyRemove(&body); + + // Remove the extra body reference that we added for the physics system keeping a pointer to the body + body.Release(); + --bodyCount; +} + +void PhysicalWorld::UpdateBodyUserPointer(const PhysicsBody& body) +{ + JPH::BodyLockWrite lock(physicsSystem->GetBodyLockInterface(), body.GetId()); + if (!lock.Succeeded()) [[unlikely]] + { + LOG_ERROR("Can't lock body for updating user pointer bits, the enabled / disabled feature won't apply on it"); + } + else [[likely]] + { + JPH::Body& joltBody = lock.GetBody(); + joltBody.SetUserData(body.CalculateUserPointer()); + } +} + +// ------------------------------------ // +void PhysicalWorld::ApplyBodyControl(PhysicsBody& bodyWrapper, float delta) +{ + constexpr auto allowedRotationDifference = 0.0001f; + constexpr auto overshootDetectWhenAllAnglesLessThan = PI * 0.025f; + +#ifdef USE_SLOW_TURN_NEAR_TARGET + constexpr auto closeToTargetThreshold = 0.20f; +#endif + + // Normalize delta to 60Hz update rate to make gameplay logic not depend on the physics framerate + float normalizedDelta = delta / (1 / 60.0f); + + BodyControlState* controlState = bodyWrapper.GetBodyControlState(); + const auto bodyId = bodyWrapper.GetId(); + + // This method is called by the step listener meaning that all bodies are already locked so this needs to be used + // like this + JPH::BodyLockWrite lock(physicsSystem->GetBodyLockInterfaceNoLock(), bodyId); + if (!lock.Succeeded()) [[unlikely]] + { + LOG_ERROR("Couldn't lock body for applying body control"); + return; + } + + JPH::Body& body = lock.GetBody(); + const auto degreesOfFreedom = body.GetMotionProperties()->GetAllowedDOFs(); + + if (controlState->movement.LengthSq() > 0.000001f) + body.AddImpulse(controlState->movement * normalizedDelta); + + const auto& currentRotation = body.GetRotation(); + + const auto inversedTargetRotation = controlState->targetRotation.Inversed(); + const auto difference = currentRotation * inversedTargetRotation; + + if (difference.IsClose(JPH::Quat::sIdentity(), allowedRotationDifference)) + { + // At rotation target, stop rotation + // TODO: we could allow small velocities to allow external objects to force our rotation off a bit after which + // this would correct itself + body.SetAngularVelocity({0, 0, 0}); + } + else + { + // Not currently at the rotation target + auto differenceAngles = difference.GetEulerAngles(); + + // Things break a lot if we add rotation on an axis where rotation is not allowed due to DOF + if ((degreesOfFreedom & JPH::AllRotationAllowed) != JPH::AllRotationAllowed) + { + if (static_cast((degreesOfFreedom & JPH::EAllowedDOFs::RotationX)) == 0) + { + differenceAngles.SetX(0); + } + + if (static_cast((degreesOfFreedom & JPH::EAllowedDOFs::RotationY)) == 0) + { + differenceAngles.SetY(0); + } + + if (static_cast((degreesOfFreedom & JPH::EAllowedDOFs::RotationZ)) == 0) + { + differenceAngles.SetZ(0); + } + } + + bool setNormalVelocity = true; + + if (!controlState->justStarted && !controlState->targetChanged) + { + // Check if we overshot the target and should stop to avoid oscillating + + // Compare the current rotation state with the previous one to detect if we are now on different side of + // the target rotation than the previous rotation was + const auto oldDifference = controlState->previousRotation * inversedTargetRotation; + const auto oldAngles = oldDifference.GetEulerAngles(); + + const auto angleDifference = oldAngles - differenceAngles; + + bool potentiallyOvershot = std::signbit(oldAngles.GetX()) != std::signbit(differenceAngles.GetX()) || + std::signbit(oldAngles.GetY()) != std::signbit(differenceAngles.GetY()) || + std::signbit(oldAngles.GetZ()) != std::signbit(differenceAngles.GetZ()); + + // If the signs are different and the angles are close enough (to make sure if we overshoot a ton we + // correct) then detect an overshoot + if (potentiallyOvershot && std::abs(angleDifference.GetX()) < overshootDetectWhenAllAnglesLessThan && + std::abs(angleDifference.GetY()) < overshootDetectWhenAllAnglesLessThan && + std::abs(angleDifference.GetZ()) < overshootDetectWhenAllAnglesLessThan) + { + // Overshot and we are within angle limits, reset velocity to 0 to prevent oscillation + body.SetAngularVelocity({0, 0, 0}); + setNormalVelocity = false; + } + } + + if (setNormalVelocity) + { +#ifdef USE_SLOW_TURN_NEAR_TARGET + // When near the target slow down rotation + const bool nearTarget = difference.IsClose(JPH::Quat::sIdentity(), closeToTargetThreshold); + + // It seems as these angles are the distance left, these are hopefully fine to be as-is without any kind + // of delta adjustment + if (nearTarget) + { + body.SetAngularVelocityClamped(differenceAngles / controlState->rotationRate * 0.5f); + } + else + { + body.SetAngularVelocityClamped(differenceAngles / controlState->rotationRate); + } +#else + body.SetAngularVelocityClamped(differenceAngles / controlState->rotationRate); +#endif // USE_SLOW_TURN_NEAR_TARGET + } + } + + controlState->previousRotation = currentRotation; + controlState->justStarted = false; + controlState->targetChanged = false; +} + +#pragma clang diagnostic push +#pragma ide diagnostic ignored "readability-make-member-function-const" +#pragma ide diagnostic ignored "readability-convert-member-functions-to-static" + +void PhysicalWorld::DrawPhysics(float delta) +{ + if (debugDrawLevel < 1) [[likely]] + { +#ifdef JPH_DEBUG_RENDERER + contactListener->SetDebugDraw(nullptr); +#endif + + return; + } + +#ifdef JPH_DEBUG_RENDERER + auto& drawer = DebugDrawForwarder::GetInstance(); + + if (!drawer.HasAReceiver()) + return; + + drawer.SetCameraPositionForLOD(pimpl->debugDrawCameraLocation); + + if (!drawer.TimeToRenderDebug(delta)) + { + // Rate limiting the drawing + // New contacts will be drawn on the next non-rate limited frame + contactListener->SetDrawOnlyNewContacts(true); + return; + } + + if (debugDrawLevel > 2) + { + contactListener->SetDebugDraw(&drawer); + contactListener->SetDrawOnlyNewContacts(false); + } + else + { + contactListener->SetDebugDraw(nullptr); + } + + pimpl->bodyDrawSettings.mDrawBoundingBox = debugDrawLevel > 1; + pimpl->bodyDrawSettings.mDrawVelocity = debugDrawLevel > 1; + + physicsSystem->DrawBodies(pimpl->bodyDrawSettings, &drawer); + + if (debugDrawLevel > 3) + { + contactListener->DrawActiveContacts(drawer); + } + + if (debugDrawLevel > 4) + physicsSystem->DrawConstraints(&drawer); + + if (debugDrawLevel > 5) + physicsSystem->DrawConstraintLimits(&drawer); + + if (debugDrawLevel > 6) + physicsSystem->DrawConstraintReferenceFrame(&drawer); + + drawer.FlushOutput(); +#endif +} + +void PhysicalWorld::SetDebugCameraLocation(JPH::Vec3Arg position) noexcept +{ +#ifdef JPH_DEBUG_RENDERER + pimpl->debugDrawCameraLocation = position; +#else + UNUSED(position); +#endif +} + +#pragma clang diagnostic pop + +} // namespace Thrive::Physics diff --git a/src/native/physics/PhysicalWorld.hpp b/src/native/physics/PhysicalWorld.hpp new file mode 100644 index 00000000000..c6b95fe567f --- /dev/null +++ b/src/native/physics/PhysicalWorld.hpp @@ -0,0 +1,285 @@ +#pragma once + +#include +#include + +#include "Jolt/Core/Reference.h" +#include "Jolt/Physics/Body/AllowedDOFs.h" +#include "Jolt/Physics/Body/MotionType.h" + +#include "core/ForwardDefinitions.hpp" + +#include "Layers.hpp" +#include "PhysicsCollision.hpp" +#include "PhysicsRayWithUserData.hpp" + +namespace JPH +{ +class PhysicsSystem; +class TempAllocator; +class JobSystemThreadPool; +class BodyID; +class Shape; + +constexpr EAllowedDOFs AllRotationAllowed = EAllowedDOFs::RotationX | EAllowedDOFs::RotationY | EAllowedDOFs::RotationZ; +} // namespace JPH + +namespace Thrive::Physics +{ + +class PhysicsBody; +class StepListener; + +/// \brief Main handling class of the physics simulation +/// +/// Before starting the physics an allocator needs to be enabled for Jolt (for example the C interface library init +/// does this) and collision types registered. +class PhysicalWorld +{ + friend StepListener; + + // Pimpl-idiom class for hiding some properties to reduce needed headers and size of this class + class Pimpl; + +public: + PhysicalWorld(); + ~PhysicalWorld(); + + // TODO: multithread this and allow physics to run while other stuff happens + /// \brief Process physics + /// \returns True when enough time has passed and physics was stepped + bool Process(float delta); + + // TODO: physics debug drawing + + // ------------------------------------ // + // Bodies + Ref CreateMovingBody(const JPH::RefConst& shape, JPH::RVec3Arg position, + JPH::Quat rotation = JPH::Quat::sIdentity(), bool addToWorld = true); + + Ref CreateMovingBodyWithAxisLock(const JPH::RefConst& shape, JPH::RVec3Arg position, + JPH::Quat rotation, JPH::Vec3 lockedAxes, bool lockRotation, bool addToWorld = true); + + Ref CreateStaticBody(const JPH::RefConst& shape, JPH::RVec3Arg position, + JPH::Quat rotation = JPH::Quat::sIdentity(), bool addToWorld = true); + + /// \brief Add a body that has been created but not added to the physics simulation in this world + void AddBody(PhysicsBody& body, bool activate); + + /// \brief Detaches a body to remove it from the simulation. It can be added back with AddBody + /// + /// Note that currently all constraints this body is part of will be deleted permanently (i.e. they won't be + /// restored even if this body is added back to the world). Also bodies are world specific so the body cannot be + /// added back to a different physics world. + void DetachBody(PhysicsBody& body); + + void DestroyBody(const Ref& body); + + void SetDamping(JPH::BodyID bodyId, float damping, const float* angularDamping = nullptr); + + void ReadBodyTransform(JPH::BodyID bodyId, JPH::RVec3& positionReceiver, JPH::Quat& rotationReceiver) const; + void ReadBodyVelocity(JPH::BodyID bodyId, JPH::Vec3& velocityReceiver, JPH::Vec3& angularVelocityReceiver) const; + + void GiveImpulse(JPH::BodyID bodyId, JPH::Vec3Arg impulse); + void SetVelocity(JPH::BodyID bodyId, JPH::Vec3Arg velocity); + + void SetAngularVelocity(JPH::BodyID bodyId, JPH::Vec3Arg velocity); + void GiveAngularImpulse(JPH::BodyID bodyId, JPH::Vec3Arg impulse); + + void SetVelocityAndAngularVelocity(JPH::BodyID bodyId, JPH::Vec3Arg velocity, JPH::Vec3Arg angularVelocity); + + /// \brief Enables (or updates settings) for a body to have per step movement control + /// + /// This is thread safe as long as no two same bodies get this called at the same time + void SetBodyControl( + PhysicsBody& bodyWrapper, JPH::Vec3Arg movementImpulse, JPH::Quat targetRotation, float rotationRate); + + void DisableBodyControl(PhysicsBody& bodyWrapper); + + void SetPosition(JPH::BodyID bodyId, JPH::DVec3Arg position, bool activate = true); + + void SetBodyAllowSleep(JPH::BodyID bodyId, bool allowSleeping); + + /// \brief Ensures body's Y coordinate is 0, if not moves it so that it is 0 + /// \returns True if the body's position changed, false if no fix was needed + bool FixBodyYCoordinateToZero(JPH::BodyID bodyId); + + void ChangeBodyShape(JPH::BodyID bodyId, const JPH::RefConst& shape, bool activate = true); + + // ------------------------------------ // + // Collisions + + /// \brief Starts collision recording. collisionRecordingTarget must have at least space for maxRecordedCollisions + /// elements, otherwise this will overwrite random memory + const int32_t* EnableCollisionRecording( + PhysicsBody& body, CollisionRecordListType collisionRecordingTarget, int maxRecordedCollisions); + + void DisableCollisionRecording(PhysicsBody& body); + + /// \brief Makes body ignore collisions with ignoredBody + void AddCollisionIgnore(PhysicsBody& body, const PhysicsBody& ignoredBody, bool skipDuplicates); + + /// \brief Removes a previously added body ignore + /// + /// Note that this removes the ignore just from body so if the ignore relationship is two-ways this doesn't make + /// collisions happen + /// \returns True when removed, false if the body was not ignored + bool RemoveCollisionIgnore(PhysicsBody& body, const PhysicsBody& noLongerIgnoredBody); + + /// \brief Sets an exact list of ignored bodies for body. Removes all existing ignores + /// \param ignoredBodies list of bodies to ignore (should be a pointer to array of references) + /// \param ignoreCount specifies the length of the ignoredBodies array, note that instead of passing an array of + /// length 0 calling ClearCollisionIgnores is preferred + void SetCollisionIgnores(PhysicsBody& body, PhysicsBody* const& ignoredBodies, int ignoreCount); + + /// \brief More efficient variant of clearing all ignores and setting just one + void SetSingleCollisionIgnore(PhysicsBody& body, const PhysicsBody& onlyIgnoredBody); + + /// \brief Clears all collision ignores on a body + void ClearCollisionIgnores(PhysicsBody& body); + + /// \brief When called with true this disables all collisions for the given body (can be restored by calling this + /// method again with false parameter) + void SetCollisionDisabledState(PhysicsBody& body, bool disableAllCollisions); + + void AddCollisionFilter(PhysicsBody& body, CollisionFilterCallback callback); + + void DisableCollisionFilter(PhysicsBody& body); + + // ------------------------------------ // + // Constraints + + //! \deprecated Use CreateMovingBodyWithAxisLock instead (this is kept just to show how other constraint types + //! should be added in the future) + Ref CreateAxisLockConstraint(PhysicsBody& body, JPH::Vec3 axis, bool lockRotation); + + void DestroyConstraint(TrackedConstraint& constraint); + + void SetGravity(JPH::Vec3 newGravity); + void RemoveGravity(); + + // ------------------------------------ // + // Misc + + /// \brief Cast a ray from start point to endOffset (i.e. end = start + endOffset) + /// \returns When hit something a tuple of the fraction from start to end, the hit position, and the ID of the hit + // body + [[nodiscard]] std::optional> CastRay( + JPH::RVec3 start, JPH::Vec3 endOffset); + + /// \brief Cast a ray from start point to start + endOffset like CastRay but find all hits (up to a limit) and + /// return the bodies' user data with the collision info + /// \returns The number of hits or 0 if nothing is hit. This only writes to dataReceiver up to the number of hits + /// received everything else is untouched + int CastRayGetAllUserData( + JPH::RVec3 start, JPH::Vec3 endOffset, PhysicsRayWithUserData dataReceiver[], int maxHits); + + [[nodiscard]] inline float GetLatestPhysicsTime() const + { + return latestPhysicsTime; + } + + [[nodiscard]] inline float GetAveragePhysicsTime() const + { + return averagePhysicsTime; + } + + bool DumpSystemState(std::string_view path); + + inline void SetDebugLevel(int level) noexcept + { + debugDrawLevel = level; + } + + void SetDebugCameraLocation(JPH::Vec3Arg position) noexcept; + + /// \brief Called by PhysicsBody when it has a recorded collision. This is done to reset bodies that haven't + /// received new collisions on the next physics update + void ReportBodyWithActiveCollisions(PhysicsBody& body); + +protected: + void PerformPhysicsStepOperations(float delta); + +private: + /// \brief Creates the physics system + void InitPhysicsWorld(); + + void StepPhysics(JPH::JobSystemThreadPool& jobs, float time); + + Ref CreateBody(const JPH::Shape& shape, JPH::EMotionType motionType, JPH::ObjectLayer layer, + JPH::RVec3Arg position, JPH::Quat rotation = JPH::Quat::sIdentity(), + JPH::EAllowedDOFs allowedDegreesOfFreedom = JPH::EAllowedDOFs::All); + + /// \brief Called after body has been created + Ref OnBodyCreated(Ref&& body, bool addToWorld); + + /// \brief Called when body is added to the world (can happen multiple times for each body) + void OnPostBodyAdded(PhysicsBody& body); + + void OnBodyPreLeaveWorld(PhysicsBody& body); + void OnPostBodyLeaveWorld(PhysicsBody& body); + + /// \brief Updates the user pointer for a body to enable / disable newly set bitflags in the pointer for some + /// various features + void UpdateBodyUserPointer(const PhysicsBody& body); + + /// \brief Applies physics body control operations + /// \param delta Is the physics step delta + void ApplyBodyControl(PhysicsBody& bodyWrapper, float delta); + + void DrawPhysics(float delta); + +private: + float elapsedSinceUpdate = 0; + + int bodyCount = 0; + bool changesToBodies = true; + int simulationsToNextOptimization = 1; + float latestPhysicsTime = 0; + float averagePhysicsTime = 0; + + /// \brief Debug draw level (0 is disabled) + /// + /// 1 is just bodies + /// 2 is also contacts + /// 3 is also active contact points + /// 4 is also body bounding boxes and velocities + /// 5 is also constraints + /// 6 is also constraint limits + /// 7 is also constraint reference frames + int debugDrawLevel = 0; + + /// \brief The main part, the physics system that simulates this world + std::unique_ptr physicsSystem; + + std::unique_ptr contactListener; + std::unique_ptr activationListener; + std::unique_ptr stepListener; + + // TODO: switch to this custom one + // std::unique_ptr jobSystem; + std::unique_ptr jobSystem; + + std::unique_ptr tempAllocator; + + // Simulation configuration + float physicsFrameRate = 60; + int collisionStepsPerUpdate = 1; + + int simulationsBetweenBroadPhaseOptimization = 67; + + // Settings that only apply when creating a new physics system + + const unsigned int maxBodies = 10240; + + /// \details Jolt documentation says that 0 means automatic + const unsigned int maxBodyMutexes = 0; + + const unsigned int maxBodyPairs = 65536; + const unsigned int maxContactConstraints = 20480; + + // This is last to make sure resources held by this are deleted last + std::unique_ptr pimpl; +}; + +} // namespace Thrive::Physics diff --git a/src/native/physics/PhysicsBody.cpp b/src/native/physics/PhysicsBody.cpp new file mode 100644 index 00000000000..11af1c8c92a --- /dev/null +++ b/src/native/physics/PhysicsBody.cpp @@ -0,0 +1,170 @@ +// ------------------------------------ // +#include "PhysicsBody.hpp" + +#include "Jolt/Physics/Body/Body.h" + +#include "core/Logger.hpp" + +#include "BodyControlState.hpp" +#include "TrackedConstraint.hpp" + +// ------------------------------------ // +namespace Thrive::Physics +{ +#ifdef USE_OBJECT_POOLS +PhysicsBody::PhysicsBody(JPH::Body* body, JPH::BodyID bodyId, ReleaseCallback deleteCallback) noexcept : + RefCounted(deleteCallback), userData(), +#else +PhysicsBody::PhysicsBody(JPH::Body* body, JPH::BodyID bodyId) noexcept : +#endif + id(bodyId) +{ + body->SetUserData(CalculateUserPointer()); + + // Zero out the user data to ensure it has a consistent value when not initialized later + userData.fill(0); +} + +PhysicsBody::~PhysicsBody() noexcept +{ + if (containedInWorld != nullptr) + LOG_ERROR("PhysicsBody deleted while it is still in the world, this is going to cause memory corruption!"); +} + +// ------------------------------------ // +void PhysicsBody::SetCollisionRecordingTarget(CollisionRecordListType target, int maxCount) noexcept +{ + collisionRecordingTarget = target; + maxCollisionsToRecord = maxCount; + activeRecordedCollisionCount = 0; +} + +void PhysicsBody::ClearCollisionRecordingTarget() noexcept +{ + collisionRecordingTarget = nullptr; + maxCollisionsToRecord = 0; + activeRecordedCollisionCount = 0; +} + +// ------------------------------------ // +bool PhysicsBody::AddCollisionIgnore(const PhysicsBody& ignoredBody, bool skipDuplicates) noexcept +{ + const auto idToAdd = ignoredBody.GetId(); + + if (skipDuplicates) + { + const auto end = ignoredCollisions.end(); + for (auto iter = ignoredCollisions.begin(); iter != end; ++iter) + { + if (*iter == idToAdd) + { + return false; + } + } + } + + ignoredCollisions.emplace_back(idToAdd); + return true; +} + +bool PhysicsBody::RemoveCollisionIgnore(const PhysicsBody& noLongerIgnored) noexcept +{ + const auto idToRemove = noLongerIgnored.GetId(); + + const auto end = ignoredCollisions.end(); + for (auto iter = ignoredCollisions.begin(); iter != end; ++iter) + { + if (*iter == idToRemove){ + ignoredCollisions.erase(iter); + return true; + } + } + + return false; +} + +void PhysicsBody::SetCollisionIgnores(PhysicsBody* const& ignoredBodies, int ignoreCount) noexcept +{ + ignoredCollisions.clear(); + + for (int i = 0; i < ignoreCount; ++i) + { + ignoredCollisions.emplace_back(ignoredBodies[i].GetId()); + } +} + +void PhysicsBody::SetSingleCollisionIgnore(const PhysicsBody& ignoredBody) noexcept +{ + ignoredCollisions.clear(); + ignoredCollisions.emplace_back(ignoredBody.GetId()); +} + +void PhysicsBody::ClearCollisionIgnores() noexcept +{ + ignoredCollisions.clear(); +} + +// ------------------------------------ // +bool PhysicsBody::EnableBodyControlIfNotAlready() noexcept +{ + // If already enabled + if (bodyControlStateIfActive != nullptr) + return false; + + bodyControlStateIfActive = std::make_unique(); + + return true; +} + +bool PhysicsBody::DisableBodyControl() noexcept +{ + if (bodyControlStateIfActive == nullptr) + return false; + + bodyControlStateIfActive.reset(); + + return true; +} + +// ------------------------------------ // +void PhysicsBody::MarkUsedInWorld(PhysicalWorld* world) noexcept +{ + if (containedInWorld) + LOG_ERROR("PhysicsBody marked used when already in use"); + + containedInWorld = world; + + // Calling this method is the way that bodies become attached again (if detached previously) + detached = false; +} + +void PhysicsBody::MarkRemovedFromWorld() noexcept +{ + if (!containedInWorld) + LOG_ERROR("PhysicsBody marked removed from world when it wasn't used in the first place"); + + containedInWorld = nullptr; +} + +void PhysicsBody::NotifyConstraintAdded(TrackedConstraint& constraint) noexcept +{ + constraintsThisIsPartOf.emplace_back(&constraint); + + // To save on performance this doesn't check on duplicate constraint adds +} + +void PhysicsBody::NotifyConstraintRemoved(TrackedConstraint& constraint) noexcept +{ + for (auto iter = constraintsThisIsPartOf.rbegin(); iter != constraintsThisIsPartOf.rend(); ++iter) + { + if (iter->get() == &constraint) + { + constraintsThisIsPartOf.erase((iter + 1).base()); + return; + } + } + + LOG_ERROR("PhysicsBody notified of removed constraint that this wasn't a part of"); +} + +} // namespace Thrive::Physics diff --git a/src/native/physics/PhysicsBody.hpp b/src/native/physics/PhysicsBody.hpp new file mode 100644 index 00000000000..220ee2f14f8 --- /dev/null +++ b/src/native/physics/PhysicsBody.hpp @@ -0,0 +1,500 @@ +#pragma once + +#include +#include +#include + +#include "Jolt/Core/Reference.h" +#include "Jolt/Jolt.h" +#include "Jolt/Physics/Body/Body.h" +#include "Jolt/Physics/Body/BodyID.h" + +#include "Include.h" +#include "core/ForwardDefinitions.hpp" + +#include "core/RefCounted.hpp" + +#include "PhysicsCollision.hpp" + +// This needs to be included to allow one collision recording method to be inline +#include "PhysicalWorld.hpp" + +#ifndef LOCK_FREE_COLLISION_RECORDING +#include "core/Mutex.hpp" +#endif + +#ifdef USE_SMALL_VECTOR_POOLS +#include "boost/pool/pool_alloc.hpp" +#endif + +namespace JPH +{ +class Shape; +} // namespace JPH + +namespace Thrive::Physics +{ + +class PhysicalWorld; +class BodyControlState; + +// Flags to put in the physics user data field as a stuffed pointer, max count is UNUSED_POINTER_BITS +constexpr uint64_t PHYSICS_BODY_COLLISION_FILTER_FLAG = 0x1; +constexpr uint64_t PHYSICS_BODY_RECORDING_FLAG = 0x2; +constexpr uint64_t PHYSICS_BODY_DISABLE_COLLISION_FLAG = 0x4; + +// Combined flag values +constexpr uint64_t PHYSICS_BODY_SPECIAL_COLLISION_FLAG = + PHYSICS_BODY_COLLISION_FILTER_FLAG | PHYSICS_BODY_DISABLE_COLLISION_FLAG; + +#ifdef USE_SMALL_VECTOR_POOLS +using IgnoredCollisionList = std::vector>; +#else +using IgnoredCollisionList = std::vector; +#endif + +/// \brief Our physics body wrapper that has extra data +class alignas(STUFFED_POINTER_ALIGNMENT) PhysicsBody : public RefCounted +{ + friend PhysicalWorld; + friend BodyActivationListener; + friend TrackedConstraint; + friend ContactListener; + + // Flags used only internally to track some extra state + static constexpr uint64_t EXTRA_FLAG_FILTER_LIST = 0x8; + static constexpr uint64_t EXTRA_FLAG_FILTER_CALLBACK = 0x16; + +protected: +#ifndef USE_OBJECT_POOLS + PhysicsBody(JPH::Body* body, JPH::BodyID bodyId) noexcept; +#endif + +public: +#ifdef USE_OBJECT_POOLS + /// Even though this is public this should only be called by PhysicalWorld, so any other code should ask the world + /// to make new bodies + PhysicsBody(JPH::Body* body, JPH::BodyID bodyId, ReleaseCallback deleteCallback) noexcept; +#endif + + ~PhysicsBody() noexcept override; + + PhysicsBody(const PhysicsBody& other) = delete; + PhysicsBody(PhysicsBody&& other) = delete; + + PhysicsBody& operator=(const PhysicsBody& other) = delete; + PhysicsBody& operator=(PhysicsBody&& other) = delete; + + /// \brief Retrieves an instance of this class from a physics body user data + [[nodiscard]] FORCE_INLINE static PhysicsBody* FromJoltBody(const JPH::Body* body) noexcept + { + return FromJoltBody(body->GetUserData()); + } + + [[nodiscard]] FORCE_INLINE static PhysicsBody* FromJoltBody(uint64_t bodyUserData) noexcept + { + bodyUserData &= STUFFED_POINTER_POINTER_MASK; + +#ifdef NULL_HAS_UNUSUAL_REPRESENTATION + if (bodyUserData == 0) + return nullptr; +#endif + + return reinterpret_cast(bodyUserData); + } + + // ------------------------------------ // + // Recording + void SetCollisionRecordingTarget(CollisionRecordListType target, int maxCount) noexcept; + void ClearCollisionRecordingTarget() noexcept; + +#ifdef LOCK_FREE_COLLISION_RECORDING + inline const int32_t* GetRecordedCollisionTargetAddress() const noexcept + { + static_assert( + sizeof(int32_t) == sizeof(activeRecordedCollisionCount), "atomic assumed same size as underlying type"); + + return reinterpret_cast(&(this->activeRecordedCollisionCount)); + } +#else + inline const int32_t* GetRecordedCollisionTargetAddress() const noexcept + { + return &(this->activeRecordedCollisionCount); + } +#endif + + // ------------------------------------ // + // Collision ignores + + bool AddCollisionIgnore(const PhysicsBody& ignoredBody, bool skipDuplicates) noexcept; + bool RemoveCollisionIgnore(const PhysicsBody& noLongerIgnored) noexcept; + + void SetCollisionIgnores(PhysicsBody* const& ignoredBodies, int ignoreCount) noexcept; + void SetSingleCollisionIgnore(const PhysicsBody& ignoredBody) noexcept; + + void ClearCollisionIgnores() noexcept; + + inline bool IsBodyIgnored(JPH::BodyID bodyId) const noexcept + { + for (const auto& ignored : ignoredCollisions) + { + if (ignored == bodyId) + return true; + } + + return false; + } + + inline void SetCollisionFilter(CollisionFilterCallback callback) noexcept + { + callbackBasedFilter = callback; + } + + inline void RemoveCollisionFilter() noexcept + { + callbackBasedFilter = nullptr; + } + + FORCE_INLINE CollisionFilterCallback GetCollisionFilter() const noexcept + { + return callbackBasedFilter; + } + + // ------------------------------------ // + // State flags + + [[nodiscard]] inline bool IsActive() const noexcept + { + return active; + } + + [[nodiscard]] inline bool IsInWorld() const noexcept + { + return containedInWorld != nullptr; + } + + [[nodiscard]] inline JPH::BodyID GetId() const + { + return id; + } + + [[nodiscard]] const inline auto& GetConstraints() const noexcept + { + return constraintsThisIsPartOf; + } + + [[nodiscard]] inline BodyControlState* GetBodyControlState() const noexcept + { + return bodyControlStateIfActive.get(); + } + + // ------------------------------------ // + // User pointer flags + + inline bool MarkCollisionFilterEnabled() noexcept + { + const auto old = activeUserPointerFlags; + + // This and the following set flag methods are a two-step flag, i.e. we have two fields that control one of + // the primary fields + activeUserPointerFlags |= EXTRA_FLAG_FILTER_LIST; + + if (old == activeUserPointerFlags) + return false; + + activeUserPointerFlags |= PHYSICS_BODY_COLLISION_FILTER_FLAG; + return true; + } + + inline bool MarkCollisionFilterDisabled() noexcept + { + const auto old = activeUserPointerFlags; + + activeUserPointerFlags &= ~EXTRA_FLAG_FILTER_LIST; + + if (old == activeUserPointerFlags) + return false; + + // Keep the main flag on if the other flag controlling this is still on + if (activeUserPointerFlags & EXTRA_FLAG_FILTER_CALLBACK) + return true; + + activeUserPointerFlags &= ~PHYSICS_BODY_COLLISION_FILTER_FLAG; + + return true; + } + + inline bool MarkCollisionFilterCallbackUsed() noexcept + { + const auto old = activeUserPointerFlags; + + activeUserPointerFlags |= EXTRA_FLAG_FILTER_CALLBACK; + + if (old == activeUserPointerFlags) + return false; + + activeUserPointerFlags |= PHYSICS_BODY_COLLISION_FILTER_FLAG; + return true; + } + + inline bool MarkCollisionFilterCallbackDisabled() noexcept + { + const auto old = activeUserPointerFlags; + + activeUserPointerFlags &= ~EXTRA_FLAG_FILTER_CALLBACK; + + if (old == activeUserPointerFlags) + return false; + + // Keep the main flag on if the other flag controlling this is still on + if (activeUserPointerFlags & EXTRA_FLAG_FILTER_LIST) + return true; + + activeUserPointerFlags &= ~PHYSICS_BODY_COLLISION_FILTER_FLAG; + + return true; + } + + inline bool MarkCollisionRecordingEnabled() noexcept + { + const auto old = activeUserPointerFlags; + + activeUserPointerFlags |= PHYSICS_BODY_RECORDING_FLAG; + + return old != activeUserPointerFlags; + } + + inline bool MarkCollisionRecordingDisabled() noexcept + { + const auto old = activeUserPointerFlags; + + activeUserPointerFlags &= ~PHYSICS_BODY_RECORDING_FLAG; + + return old != activeUserPointerFlags; + } + + /// \brief Just a simple way to store this one bool separately in this class, used by PhysicalWorld + inline bool SetDisableAllCollisions(bool newValue) noexcept + { + if (allCollisionsDisabled == newValue) + return false; + + allCollisionsDisabled = newValue; + return true; + } + + inline bool MarkCollisionDisableFlagEnabled() noexcept + { + const auto old = activeUserPointerFlags; + + activeUserPointerFlags |= PHYSICS_BODY_DISABLE_COLLISION_FLAG; + + return old != activeUserPointerFlags; + } + + inline bool MarkCollisionDisableFlagDisabled() noexcept + { + const auto old = activeUserPointerFlags; + + activeUserPointerFlags &= ~PHYSICS_BODY_DISABLE_COLLISION_FLAG; + + return old != activeUserPointerFlags; + } + + [[nodiscard]] inline uint64_t CalculateUserPointer() const noexcept + { + return reinterpret_cast(this) | + (static_cast(activeUserPointerFlags) & STUFFED_POINTER_DATA_MASK); + } + + // ------------------------------------ // + // Collision callback user data (C# side provides this) + + [[nodiscard]] inline bool HasUserData() const noexcept + { + return userDataLength > 0; + } + + [[nodiscard]] inline const std::array& GetUserData() const noexcept + { + return userData; + } + + inline bool SetUserData(const char* data, int length) noexcept + { + static_assert(PHYSICS_USER_DATA_SIZE < std::numeric_limits::max()); + + // Fail if too much data given + if (length > static_cast(userData.size())) + { + userDataLength = 0; + return false; + } + + // Data clearing + if (data == nullptr) + { + userDataLength = 0; + return true; + } + + // New data is set + std::memcpy(userData.data(), data, length); + userDataLength = length; + return true; + } + + inline bool IsDetached() const noexcept + { + return detached; + } + +protected: + bool EnableBodyControlIfNotAlready() noexcept; + bool DisableBodyControl() noexcept; + + void MarkUsedInWorld(PhysicalWorld* containedInWorld) noexcept; + void MarkRemovedFromWorld() noexcept; + + inline void MarkDetached() noexcept + { + detached = true; + + // Clear out any currently active collisions if any were recorded + activeRecordedCollisionCount = 0; + } + + inline bool IsInSpecificWorld(const PhysicalWorld* world) const noexcept + { + return containedInWorld == world; + } + + void NotifyConstraintAdded(TrackedConstraint& constraint) noexcept; + void NotifyConstraintRemoved(TrackedConstraint& constraint) noexcept; + + inline void NotifyActiveStatus(bool newActiveValue) noexcept + { + active = newActiveValue; + } + +#ifdef LOCK_FREE_COLLISION_RECORDING + /// \brief Prepares a location to record a new collision on this body for this physics update + /// \returns Pointer to write the data to, null if there was an overflow on the number of recorded collisions + /// this frame and the data can't be recorded + inline PhysicsCollision* GetNextCollisionRecordLocation(uint32_t stepIdentifier) noexcept + { + auto originalStepValue = lastRecordedPhysicsStep.load(std::memory_order_acquire); + if (stepIdentifier != originalStepValue) + { + // Atomic exchange here to ensure only one thread gets here + if (lastRecordedPhysicsStep.compare_exchange_strong( + originalStepValue, stepIdentifier, std::memory_order_release, std::memory_order_relaxed)) + { + // New step started, report that we have active collisions so that the world will clear our + // activeRecordedCollisionCount before next physics step + containedInWorld->ReportBodyWithActiveCollisions(*this); + } + } + + // Atomically acquire the array index to write to + const auto indexToWriteTo = activeRecordedCollisionCount.fetch_add(1, std::memory_order::acq_rel); + + // Skip if too many collisions + if (indexToWriteTo >= maxCollisionsToRecord) [[unlikely]] + { + // If we go over the number of allowed recorded collisions, we need to reset the memory back + if (indexToWriteTo > maxCollisionsToRecord) + activeRecordedCollisionCount.store(maxCollisionsToRecord, std::memory_order_release); + + return nullptr; + } + + return &collisionRecordingTarget[indexToWriteTo]; + } +#else + /// \brief Prepares a location to record a new collision on this body for this physics update + /// \returns Pointer to write the data to, null if there was an overflow on the number of recorded collisions + /// this frame and the data can't be recorded + inline PhysicsCollision* GetNextCollisionRecordLocation(uint32_t stepIdentifier) noexcept + { + Lock lock(collisionRecordMutex); + + if (stepIdentifier != lastRecordedPhysicsStep) + { + lastRecordedPhysicsStep = stepIdentifier; + + // New step started, report that we have active collisions so that the world will clear our + // activeRecordedCollisionCount before next physics step + containedInWorld->ReportBodyWithActiveCollisions(*this); + } + + // Skip if too many collisions + if (activeRecordedCollisionCount >= maxCollisionsToRecord) + return false; + + return &collisionRecordingTarget[activeRecordedCollisionCount++]; + } +#endif + + /// \brief Clears recorded collision data + /// + /// This is used by the PhysicalWorld to prepare collision recording for the next frame + FORCE_INLINE void ClearRecordedData() + { + activeRecordedCollisionCount = 0; + + // TODO: could maybe switch the last step number to a simple bool flag to determine if we have registered our + // selves already or not to be cleared of collisions on next update + } + +private: + std::array userData; + + IgnoredCollisionList ignoredCollisions; + + /// This is memory not owned by us where recorded collisions are written to + CollisionRecordListType collisionRecordingTarget = nullptr; + + std::vector> constraintsThisIsPartOf; + +#ifndef LOCK_FREE_COLLISION_RECORDING + Mutex collisionRecordMutex; +#endif + + const JPH::BodyID id; + + std::unique_ptr bodyControlStateIfActive; + + /// This is purely used to compare against world pointers to check that this is in a specific world. Do not call + /// anything through this pointer as it is not guaranteed safe. The only exception is using this during a physics + /// step in GetNextCollisionRecordLocation + PhysicalWorld* containedInWorld = nullptr; + + CollisionFilterCallback callbackBasedFilter = nullptr; + + int userDataLength = 0; + + int maxCollisionsToRecord = 0; + +#ifdef LOCK_FREE_COLLISION_RECORDING + /// A pointer to this is passed out for users of the collision recording array + std::atomic activeRecordedCollisionCount{0}; + + /// Used to detect when a new batch of collisions begins and old ones should be cleared + std::atomic lastRecordedPhysicsStep{std::numeric_limits::max()}; +#else + /// A pointer to this is passed out for users of the collision recording array + int32_t activeRecordedCollisionCount = 0; + + /// Used to detect when a new batch of collisions begins and old ones should be cleared + uint32_t lastRecordedPhysicsStep = -1; +#endif + + uint8_t activeUserPointerFlags = 0; + + bool detached = false; + bool active = true; + bool allCollisionsDisabled = false; +}; + +} // namespace Thrive::Physics diff --git a/src/native/physics/PhysicsCollision.hpp b/src/native/physics/PhysicsCollision.hpp new file mode 100644 index 00000000000..5099bab5cb1 --- /dev/null +++ b/src/native/physics/PhysicsCollision.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include +#include + +#include "Include.h" + +namespace Thrive::Physics +{ +class PhysicsBody; + +/// \brief Recorded physics collision. Must match the memory layout of the C# side PhysicsCollision class. +/// +/// If the size in bytes is changed, PhysicsCollision in CStructures.h must also be updated (size defined in +/// Include.h.in) +struct PhysicsCollision +{ +public: + std::array FirstUserData; + + std::array SecondUserData; + + /// The first colliding body. Note that these are always sorted so that the recording body or the body running the + /// collision callback is the first body and the second body is the something else + const PhysicsBody* FirstBody; + + /// Note that even though this is a pointer, this should never be null as each physics body always gets a + /// PhysicsBody wrapper instance + const PhysicsBody* SecondBody; + + // Sub shape data for detecting which specific parts of the objects collided. Note that in CollisionFilterCallback + // these are unknown and are set to COLLISION_UNKNOWN_SUB_SHAPE + uint32_t FirstSubShapeData; + + uint32_t SecondSubShapeData; + + /// How big the object overlap is (this is directly correlated to how hard the collision is) + float PenetrationAmount; + + /// True in collision filter and on the first physics update this collision appeared + bool JustStarted; + + // Without packed attribute there are 3 bytes of extra padding here +}; + +static_assert(sizeof(PhysicsCollision) == PHYSICS_COLLISION_DATA_SIZE); + +using CollisionRecordListType = PhysicsCollision*; + +/// Callback that returns false when a collision should not be allowed. Note that sub shapes and penetration amounts +/// are not calculated yet when this is called. The first body is always the body that has this callback attached. +using CollisionFilterCallback = bool (*)(const PhysicsCollision& potentialCollision); + +} // namespace Thrive::Physics diff --git a/src/native/physics/PhysicsRayWithUserData.hpp b/src/native/physics/PhysicsRayWithUserData.hpp new file mode 100644 index 00000000000..755bda27fdd --- /dev/null +++ b/src/native/physics/PhysicsRayWithUserData.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include +#include + +#include "Include.h" + +namespace Thrive::Physics +{ +class PhysicsBody; + +/// \brief Recorded physics cast ray hit. Contains user data from the hit body. Must match the memory layout of the +/// C# side PhysicsRayWithUserData class. +/// +/// If the size in bytes is changed, PhysicsRayWithUserData in CStructures.h must also be updated (size defined in +/// Include.h.in) +struct PhysicsRayWithUserData +{ +public: + std::array BodyUserData; + + /// Pointer to the hit body's extra data object + const PhysicsBody* Body; + + /// The hit sub shape + uint32_t SubShapeData; + + /// How far along the cast ray this hit was as a fraction of the total length + float HitFraction; + + // There's 4 bytes of extra padding here +}; + +static_assert(sizeof (PhysicsRayWithUserData) == PHYSICS_RAY_DATA_SIZE); + + +} // namespace Thrive::Physics diff --git a/src/native/physics/ShapeCreator.cpp b/src/native/physics/ShapeCreator.cpp new file mode 100644 index 00000000000..066ec79810b --- /dev/null +++ b/src/native/physics/ShapeCreator.cpp @@ -0,0 +1,214 @@ +// ------------------------------------ // +#include "ShapeCreator.hpp" + +#include "Jolt/Math/Trigonometry.h" +#include "Jolt/Physics/Collision/Shape/ConvexHullShape.h" +#include "Jolt/Physics/Collision/Shape/MeshShape.h" +#include "Jolt/Physics/Collision/Shape/MutableCompoundShape.h" +#include "Jolt/Physics/Collision/Shape/StaticCompoundShape.h" + +#include "core/Logger.hpp" +#include "interop/JoltTypeConversions.hpp" + +#include "ShapeWrapper.hpp" + +// ------------------------------------ // +namespace Thrive::Physics +{ + +JPH::RefConst ShapeCreator::CreateConvex(const JPH::Array& points, float density /*= 1000*/, + float convexRadius /*= 0.01f*/, const JPH::PhysicsMaterial* material /*= nullptr*/) +{ + auto settings = JPH::ConvexHullShapeSettings(points, convexRadius, material); + settings.SetDensity(density); + + return settings.Create().Get(); +} + +JPH::RefConst ShapeCreator::CreateConvex( + const JVecF3* points, size_t pointCount, float density, float convexRadius, const JPH::PhysicsMaterial* material) +{ + // We need to convert the data, so we pass empty data for the points here + auto settings = JPH::ConvexHullShapeSettings(nullptr, 0, convexRadius, material); + + auto& pointTarget = settings.mPoints; + + pointTarget.reserve(pointCount); + + for (size_t i = 0; i < pointCount; ++i) + { + const auto& point = points[i]; + + pointTarget.emplace_back(point.X, point.Y, point.Z); + } + + settings.SetDensity(density); + + return settings.Create().Get(); +} + +JPH::RefConst ShapeCreator::CreateStaticCompound( + const std::vector, JPH::Vec3, JPH::Quat, uint32_t>>& subShapes) +{ + JPH::StaticCompoundShapeSettings settings; + + settings.mSubShapes.reserve(subShapes.size()); + + for (const auto& shape : subShapes) + { + settings.AddShape(std::get<1>(shape), std::get<2>(shape), std::get<0>(shape), std::get<3>(shape)); + } + + return settings.Create().Get(); +} + +JPH::RefConst ShapeCreator::CreateStaticCompound(SubShapeDefinition* subShapes, size_t count) +{ + JPH::StaticCompoundShapeSettings settings; + + settings.mSubShapes.reserve(count); + + for (size_t i = 0; i < count; ++i) + { + const auto& shape = subShapes[i]; + + JPH_ASSERT(shape.Shape); + + settings.AddShape( + Vec3FromCAPI(shape.Position), QuatFromCAPI(shape.Rotation), shape.Shape->GetShape(), shape.UserData); + } + + return settings.Create().Get(); +} + +JPH::RefConst ShapeCreator::CreateMutableCompound( + const std::vector, JPH::Vec3, JPH::Quat, uint32_t>>& subShapes) +{ + JPH::MutableCompoundShapeSettings settings; + + settings.mSubShapes.reserve(subShapes.size()); + + for (const auto& shape : subShapes) + { + settings.AddShape(std::get<1>(shape), std::get<2>(shape), std::get<0>(shape), std::get<3>(shape)); + } + + return settings.Create().Get(); +} + +JPH::RefConst ShapeCreator::CreateMutableCompound(SubShapeDefinition* subShapes, size_t count) +{ + JPH::MutableCompoundShapeSettings settings; + + settings.mSubShapes.reserve(count); + + for (size_t i = 0; i < count; ++i) + { + const auto& shape = subShapes[i]; + + JPH_ASSERT(shape.Shape); + + settings.AddShape( + Vec3FromCAPI(shape.Position), QuatFromCAPI(shape.Rotation), shape.Shape->GetShape(), shape.UserData); + } + + return settings.Create().Get(); +} + +JPH::RefConst ShapeCreator::CreateMesh( + JPH::Array&& vertices, JPH::Array&& triangles) +{ + // Create torus + JPH::MeshShapeSettings mesh; + + // TODO: materials support (each triangle can have a different one) + // mesh.mMaterials + + mesh.mTriangleVertices = std::move(vertices); + mesh.mIndexedTriangles = std::move(triangles); + + return mesh.Create().Get(); +} + +// ------------------------------------ // +JPH::RefConst ShapeCreator::CreateMicrobeShapeConvex(JVecF3* points, uint32_t pointCount, float density, + float scale, float thickness, const JPH::PhysicsMaterial* material /*= nullptr*/) +{ + if (pointCount < 1) + { + LOG_ERROR("Microbe shape point count is 0"); + return nullptr; + } + + const auto halfThickness = thickness * 0.5f; + + // We don't use any of the explicit constructors as we want to do any needed type and scale conversions when + // actually copying data to the array in the settings + auto settings = JPH::ConvexHullShapeSettings(); + settings.mMaxConvexRadius = JPH::cDefaultConvexRadius; + + auto& pointTarget = settings.mPoints; + pointTarget.reserve(pointCount * 2); + + if (scale != 1) + { + for (uint32_t i = 0; i < pointCount; ++i) + { + const auto& sourcePoint = points[i]; + + const auto scaledX = sourcePoint.X * scale; + const auto scaledY = sourcePoint.Y * scale; + const auto scaledZ = sourcePoint.Z * scale; + + pointTarget.emplace_back(scaledX, scaledY - halfThickness, scaledZ); + pointTarget.emplace_back(scaledX, scaledY + halfThickness, scaledZ); + } + } + else + { + for (uint32_t i = 0; i < pointCount; ++i) + { + const auto& sourcePoint = points[i]; + + pointTarget.emplace_back(sourcePoint.X, sourcePoint.Y - halfThickness, sourcePoint.Z); + pointTarget.emplace_back(sourcePoint.X, sourcePoint.Y + halfThickness, sourcePoint.Z); + } + } + + if (material != nullptr) + settings.mMaterial = material; + + settings.SetDensity(density); + + return settings.Create().Get(); +} + +JPH::RefConst ShapeCreator::CreateMicrobeShapeSpheres( + JVecF3* points, uint32_t pointCount, float density, float scale, const JPH::PhysicsMaterial* material /*= nullptr*/) +{ + if (pointCount < 1) + { + LOG_ERROR("Microbe shape point count is 0"); + return nullptr; + } + + const auto sphereShape = SimpleShapes::CreateSphere(1 * scale, density, material); + + JPH::StaticCompoundShapeSettings settings; + + const auto rotation = JPH::Quat::sIdentity(); + + for (uint32_t i = 0; i < pointCount; ++i) + { + const auto& sourcePoint = points[i]; + + settings.AddShape( + {sourcePoint.X * scale, sourcePoint.Y * scale, sourcePoint.Z * scale}, rotation, sphereShape.GetPtr(), 0); + } + + // Individual materials and densities are set in the sub shapes, hopefully that is enough + + return settings.Create().Get(); +} + +} // namespace Thrive::Physics diff --git a/src/native/physics/ShapeCreator.hpp b/src/native/physics/ShapeCreator.hpp new file mode 100644 index 00000000000..59028356b83 --- /dev/null +++ b/src/native/physics/ShapeCreator.hpp @@ -0,0 +1,78 @@ +#pragma once + +#include "interop/CStructures.h" + +#include "SimpleShapes.hpp" + +namespace JPH +{ +class IndexedTriangle; +} // namespace JPH + +namespace Thrive::Physics +{ + +class ShapeWrapper; + +BEGIN_PACKED_STRUCT; + +struct PACKED_STRUCT SubShapeDefinition +{ + JQuat Rotation; + JVecF3 Position; + uint32_t UserData; + ShapeWrapper* Shape; +}; + +END_PACKED_STRUCT; + +/// \brief More advanced shape creation helper class than SimpleShapes +class ShapeCreator +{ +public: + ShapeCreator() = delete; + + /// \brief Create a convex shape from a list of points + /// \param convexRadius Used convex radius for this shape, should be lower than the default value used for other + /// shapes + static JPH::RefConst CreateConvex(const JPH::Array& points, float density = 1000, + float convexRadius = 0.01f, const JPH::PhysicsMaterial* material = nullptr); + + /// \brief Variant for avoiding extra data copy from C-API + static JPH::RefConst CreateConvex(const JVecF3* points, size_t pointCount, float density = 1000, + float convexRadius = 0.01f, const JPH::PhysicsMaterial* material = nullptr); + + /// \brief Creates a shape composed of multiple other shapes that cannot change after creation + /// \todo Figure out how to use physics materials here + static JPH::RefConst CreateStaticCompound( + const std::vector, JPH::Vec3, JPH::Quat, uint32_t>>& subShapes); + static JPH::RefConst CreateStaticCompound(SubShapeDefinition* subShapes, size_t count); + + /// \brief Variant of the compound shape that is allowed to be modified (but has lower performance than static) + static JPH::RefConst CreateMutableCompound( + const std::vector, JPH::Vec3, JPH::Quat, uint32_t>>& subShapes); + static JPH::RefConst CreateMutableCompound(SubShapeDefinition* subShapes, size_t count); + + /// \brief Creates a mesh collision (note that the performance is worse and this can't collide with everything even + /// when movable) + /// + /// This doesn't support setting a density and the Jolt documentation says that two moving meshes can't collide + /// with each other, so this is likely only usable on static or kinematic bodies + /// \todo Materials support, each triangle can have its own so this is a bit complicated to setup + static JPH::RefConst CreateMesh( + JPH::Array&& vertices, JPH::Array&& triangles); + + // ------------------------------------ // + // Advanced game related shapes + + // TODO: pili + static JPH::RefConst CreateMicrobeShapeConvex(JVecF3* points, uint32_t pointCount, float density = 1000, + float scale = 1, float thickness = 1.0f, const JPH::PhysicsMaterial* material = nullptr); + static JPH::RefConst CreateMicrobeShapeSpheres(JVecF3* points, uint32_t pointCount, + float density = 1000, float scale = 1, const JPH::PhysicsMaterial* material = nullptr); +}; + +} // namespace Thrive::Physics + +static_assert( + sizeof(SubShapeDefinition) == sizeof(Thrive::Physics::SubShapeDefinition), "sub-shape creation data size mismatch"); diff --git a/src/native/physics/ShapeWrapper.cpp b/src/native/physics/ShapeWrapper.cpp new file mode 100644 index 00000000000..61c53d3ca0f --- /dev/null +++ b/src/native/physics/ShapeWrapper.cpp @@ -0,0 +1,35 @@ +// ------------------------------------ // +#include "ShapeWrapper.hpp" + +#include "core/Logger.hpp" + +// ------------------------------------ // +namespace Thrive::Physics +{ + +#ifdef USE_OBJECT_POOLS +ShapeWrapper::ShapeWrapper(const JPH::RefConst& wrappedShape, ReleaseCallback deleteCallback) : + RefCounted(deleteCallback), +#else +ShapeWrapper::ShapeWrapper(const JPH::RefConst& wrappedShape) : +#endif + shape(wrappedShape) +{ + if (shape == nullptr) + { + LOG_ERROR("Cannot create a shape where the Jolt shape failed to be created"); + abort(); + } +} + +#ifdef USE_OBJECT_POOLS +ShapeWrapper::ShapeWrapper(JPH::RefConst&& wrappedShape, ReleaseCallback deleteCallback) : + RefCounted(deleteCallback), +#else +ShapeWrapper::ShapeWrapper(JPH::RefConst&& wrappedShape) : +#endif + shape(wrappedShape) +{ +} + +} // namespace Thrive::Physics diff --git a/src/native/physics/ShapeWrapper.hpp b/src/native/physics/ShapeWrapper.hpp new file mode 100644 index 00000000000..06231f3ab94 --- /dev/null +++ b/src/native/physics/ShapeWrapper.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include "Jolt/Core/Reference.h" +#include "Jolt/Physics/Collision/Shape/Shape.h" + +#include "Include.h" + +namespace Thrive::Physics +{ + +/// \brief Wrapper class around JPH::Shape to allow the C API to use shapes +class ShapeWrapper : public RefCounted +{ +public: +#ifdef USE_OBJECT_POOLS + explicit ShapeWrapper(const JPH::RefConst& wrappedShape, ReleaseCallback deleteCallback); + explicit ShapeWrapper(JPH::RefConst&& wrappedShape, ReleaseCallback deleteCallback); +#else + explicit ShapeWrapper(const JPH::RefConst& wrappedShape); + explicit ShapeWrapper(JPH::RefConst&& wrappedShape); +#endif + + const inline JPH::RefConst& GetShape() const + { + return shape; + } + +private: + const JPH::RefConst shape; +}; + +} // namespace Thrive::Physics diff --git a/src/native/physics/SimpleShapes.cpp b/src/native/physics/SimpleShapes.cpp new file mode 100644 index 00000000000..d069979b4b1 --- /dev/null +++ b/src/native/physics/SimpleShapes.cpp @@ -0,0 +1,63 @@ +// ------------------------------------ // +#include "SimpleShapes.hpp" + +#include "Jolt/Physics/Collision/Shape/BoxShape.h" +#include "Jolt/Physics/Collision/Shape/CapsuleShape.h" +#include "Jolt/Physics/Collision/Shape/CylinderShape.h" +#include "Jolt/Physics/Collision/Shape/ScaledShape.h" +#include "Jolt/Physics/Collision/Shape/Shape.h" +#include "Jolt/Physics/Collision/Shape/SphereShape.h" + +// ------------------------------------ // +namespace Thrive::Physics +{ + +JPH::RefConst SimpleShapes::CreateSphere( + float radius, float density /*= 1000*/, const JPH::PhysicsMaterial* material /*= nullptr*/) +{ + auto shape = new JPH::SphereShape(radius, material); + shape->SetDensity(density); + + return shape; +} + +JPH::RefConst SimpleShapes::CreateBox( + float halfSideLength, float density /*= 1000*/, const JPH::PhysicsMaterial* material /*= nullptr*/) +{ + return CreateBox(JPH::Vec3(halfSideLength, halfSideLength, halfSideLength), density, material); +} + +JPH::RefConst SimpleShapes::CreateBox( + JPH::Vec3 halfExtends, float density /*= 1000*/, const JPH::PhysicsMaterial* material /*= nullptr*/) +{ + auto shape = new JPH::BoxShape(halfExtends, JPH::cDefaultConvexRadius, material); + shape->SetDensity(density); + + return shape; +} + +JPH::RefConst SimpleShapes::CreateCylinder( + float halfHeight, float radius, float density /*= 1000*/, const JPH::PhysicsMaterial* material /*= nullptr*/) +{ + auto shape = new JPH::CylinderShape(halfHeight, radius, JPH::cDefaultConvexRadius, material); + shape->SetDensity(density); + + return shape; +} + +JPH::RefConst SimpleShapes::CreateCapsule( + float halfHeight, float radius, float density /*= 1000*/, const JPH::PhysicsMaterial* material /*= nullptr*/) +{ + auto shape = new JPH::CapsuleShape(halfHeight, radius, material); + shape->SetDensity(density); + + return shape; +} + +// ------------------------------------ // +JPH::RefConst SimpleShapes::Scale(const JPH::RefConst& shape, float scale) +{ + return new JPH::ScaledShape(shape, JPH::Vec3(scale, scale, scale)); +} + +} // namespace Thrive::Physics diff --git a/src/native/physics/SimpleShapes.hpp b/src/native/physics/SimpleShapes.hpp new file mode 100644 index 00000000000..038b8de1850 --- /dev/null +++ b/src/native/physics/SimpleShapes.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include "Jolt/Core/Reference.h" + +#include "Include.h" + +namespace JPH +{ +class Shape; +class PhysicsMaterial; +} // namespace JPH + +namespace Thrive::Physics +{ + +/// \brief Helpers for creating simple shapes +class SimpleShapes +{ +public: + SimpleShapes() = delete; + + static JPH::RefConst CreateSphere(float radius, float density = 1000, const JPH::PhysicsMaterial* material = nullptr); + + static JPH::RefConst CreateBox(float halfSideLength, float density = 1000, const JPH::PhysicsMaterial* material = nullptr); + static JPH::RefConst CreateBox(JPH::Vec3 halfExtends, float density = 1000, const JPH::PhysicsMaterial* material = nullptr); + + static JPH::RefConst CreateCylinder( + float halfHeight, float radius, float density = 1000, const JPH::PhysicsMaterial* material = nullptr); + + static JPH::RefConst CreateCapsule( + float halfHeight, float radius, float density = 1000, const JPH::PhysicsMaterial* material = nullptr); + + static JPH::RefConst Scale(const JPH::RefConst& shape, float scale); +}; + +} // namespace Thrive::Physics diff --git a/src/native/physics/StepListener.cpp b/src/native/physics/StepListener.cpp new file mode 100644 index 00000000000..e463b97d78d --- /dev/null +++ b/src/native/physics/StepListener.cpp @@ -0,0 +1,22 @@ +// ------------------------------------ // +#include "StepListener.hpp" + +#include "PhysicalWorld.hpp" + +// ------------------------------------ // +namespace Thrive::Physics +{ + +StepListener::StepListener(PhysicalWorld& world) : notifyWorld(world) +{ +} + +void StepListener::OnStep(float inDeltaTime, JPH::PhysicsSystem& inPhysicsSystem) +{ + // We assume here that the physics system is our target world's system + UNUSED(inPhysicsSystem); + + notifyWorld.PerformPhysicsStepOperations(inDeltaTime); +} + +} // namespace Thrive::Physics diff --git a/src/native/physics/StepListener.hpp b/src/native/physics/StepListener.hpp new file mode 100644 index 00000000000..48425c3f641 --- /dev/null +++ b/src/native/physics/StepListener.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include "Jolt/Physics/PhysicsStepListener.h" + +namespace Thrive::Physics +{ + +/// \brief Listener for physics steps to apply per-step physics state +class StepListener : public JPH::PhysicsStepListener +{ +public: + explicit StepListener(PhysicalWorld& world); + + /// \summary Called each physics step, but only if there is at least one non-sleeping physics body + void OnStep(float inDeltaTime, JPH::PhysicsSystem& inPhysicsSystem) override; + +private: + PhysicalWorld& notifyWorld; +}; + +} // namespace Thrive::Physics diff --git a/src/native/physics/TrackedConstraint.cpp b/src/native/physics/TrackedConstraint.cpp new file mode 100644 index 00000000000..76c91362e00 --- /dev/null +++ b/src/native/physics/TrackedConstraint.cpp @@ -0,0 +1,71 @@ +// ------------------------------------ // +#include "TrackedConstraint.hpp" + +#include "Jolt/Physics/Constraints/Constraint.h" + +#include "PhysicsBody.hpp" + +// ------------------------------------ // +namespace Thrive::Physics +{ + +#ifdef USE_OBJECT_POOLS +TrackedConstraint::TrackedConstraint( + const JPH::Ref& constraint, const Ref& body1, ReleaseCallback deleteCallback) : + RefCounted(deleteCallback), +#else +TrackedConstraint::TrackedConstraint(const JPH::Ref& constraint, const Ref& body1) : +#endif + firstBody(body1), constraintInstance(constraint) +{ + if (constraint == nullptr || body1 == nullptr) + throw std::runtime_error("missing constraint or body for tracked constraint"); + + firstBody->NotifyConstraintAdded(*this); +} + +#ifdef USE_OBJECT_POOLS +TrackedConstraint::TrackedConstraint(const JPH::Ref& constraint, const Ref& body1, + const Ref& body2, ReleaseCallback deleteCallback) : + RefCounted(deleteCallback), +#else +TrackedConstraint::TrackedConstraint(const JPH::Ref& constraint, const Ref& body1, const Ref& body2) : +#endif + firstBody(body1), optionalSecondBody(body2), constraintInstance(constraint) +{ + if (constraint == nullptr || body1 == nullptr || body2 == nullptr) + throw std::runtime_error("missing constraint or body for tracked constraint"); + + firstBody->NotifyConstraintAdded(*this); + optionalSecondBody->NotifyConstraintAdded(*this); +} + +TrackedConstraint::~TrackedConstraint() +{ + if (IsAttachedToBodies()) + { + firstBody->NotifyConstraintRemoved(*this); + + if (optionalSecondBody != nullptr) + { + optionalSecondBody->NotifyConstraintRemoved(*this); + } + } + + if (createdInWorld != nullptr) + LOG_ERROR("Constraint on destruction still exists in a world, this will likely crash the physics system"); +} + +void TrackedConstraint::DetachFromBodies() +{ + firstBody->NotifyConstraintRemoved(*this); + + if (optionalSecondBody != nullptr) + { + optionalSecondBody->NotifyConstraintRemoved(*this); + } + + attachedToBodies = false; +} + +} // namespace Thrive::Physics diff --git a/src/native/physics/TrackedConstraint.hpp b/src/native/physics/TrackedConstraint.hpp new file mode 100644 index 00000000000..9d372b8a48e --- /dev/null +++ b/src/native/physics/TrackedConstraint.hpp @@ -0,0 +1,109 @@ +#pragma once + +#include "Jolt/Core/Reference.h" + +#include "core/Logger.hpp" + +namespace JPH +{ +class Constraint; +} // namespace JPH + +namespace Thrive::Physics +{ + +/// \brief Tracks an existing constraint. This is needed as the physics engine doesn't track the existing constraints +/// itself +class TrackedConstraint : public RefCounted +{ + friend class PhysicalWorld; + +public: +#ifdef USE_OBJECT_POOLS + /// \brief Constraint between a single body and the world + TrackedConstraint( + const JPH::Ref& constraint, const Ref& body1, ReleaseCallback deleteCallback); + + /// \brief Constraint between two bodies + TrackedConstraint(const JPH::Ref& constraint, const Ref& body1, + const Ref& body2, ReleaseCallback deleteCallback); +#else + /// \brief Constraint between a single body and the world + TrackedConstraint(const JPH::Ref& constraint, const Ref& body1); + + /// \brief Constraint between two bodies + TrackedConstraint( + const JPH::Ref& constraint, const Ref& body1, const Ref& body2); +#endif + + ~TrackedConstraint() override; + + [[nodiscard]] bool IsCreatedInWorld() const noexcept + { + return createdInWorld != nullptr; + } + + [[nodiscard]] bool IsAttachedToBodies() const noexcept + { + return attachedToBodies; + } + + [[nodiscard]] const inline JPH::Ref& GetConstraint() const noexcept + { + return constraintInstance; + } + +protected: + inline void OnRegisteredToWorld(PhysicalWorld& world) + { + createdInWorld = &world; + } + + inline void OnRemoveFromWorld(PhysicalWorld& world) + { + if (createdInWorld != &world) + { + LOG_ERROR("Constraint tried to be removed from world it is not in"); + return; + } + + createdInWorld = nullptr; + } + + inline void OnDestroyByWorld(PhysicalWorld& world) + { + if (createdInWorld != &world) + { + LOG_ERROR("Constraint tried to be destroyed by world it is not in"); + return; + } + + OnRemoveFromWorld(world); + + // Make sure destructor doesn't run while detaching + AddRef(); + + // To make this be released, tell both of the bodies that this is no longer wanted to exist to free up + // references + DetachFromBodies(); + + // This should get deleted now + Release(); + } + + // TODO: method to delete the constraint from the bodies + +private: + void DetachFromBodies(); + +private: + const Ref firstBody; + const Ref optionalSecondBody; + const JPH::Ref constraintInstance; + + PhysicalWorld* createdInWorld = nullptr; + + bool attachedToBodies = true; +}; + +} // namespace Thrive::Physics diff --git a/src/saving/SaveHelper.cs b/src/saving/SaveHelper.cs index 2ba42675c7e..c8397b57500 100644 --- a/src/saving/SaveHelper.cs +++ b/src/saving/SaveHelper.cs @@ -24,6 +24,7 @@ public static class SaveHelper "0.5.3.1", "0.5.5.0-alpha", "0.5.9.0-alpha", + "0.6.4.0-alpha", }; private static readonly IReadOnlyList StagesAllowingPrototypeSaving = new[] @@ -541,6 +542,10 @@ private static void SetMessageAboutPrototypeSaving(InProgressSave inProgressSave private static void PerformSave(InProgressSave inProgress, Save save) { + // TODO: reimplement saving and then remove this + inProgress.ReportStatus(false, "Saving is not reimplemented for ECS components yet"); + return; + // Ensure prototype state flag is also in the info data for use by the save list save.Info.IsPrototype = save.SavedProperties?.InPrototypes ?? throw new InvalidOperationException("Saved properties of a save to write to disk is unset"); diff --git a/src/saving/serializers/EntityReferenceConverter.cs b/src/saving/serializers/EntityReferenceConverter.cs deleted file mode 100644 index 590e48750c9..00000000000 --- a/src/saving/serializers/EntityReferenceConverter.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System; -using System.Reflection; -using Newtonsoft.Json; - -public class EntityReferenceConverter : JsonConverter -{ - public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) - { - if (value == null) - { - writer.WriteNull(); - return; - } - - var objectType = value.GetType(); - - var genericTypes = objectType.GenericTypeArguments; - - if (genericTypes.Length != 1) - throw new JsonException("Invalid generic types for EntityReference"); - - // Even though Microbe is used here, it shouldn't matter at all, as we just want to grab the property name - var property = objectType.GetProperty(nameof(EntityReference.Value)); - - if (property == null) - throw new JsonException("Value property not found in EntityReference"); - - var internalValue = property.GetValue(value); - - if (internalValue == null) - { - writer.WriteNull(); - return; - } - - serializer.Serialize(writer, internalValue, genericTypes[0]); - } - - public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, - JsonSerializer serializer) - { - ConstructorInfo? constructor; - if (reader.TokenType == JsonToken.Null) - { - constructor = objectType.GetConstructor(BindingFlags.Public | BindingFlags.Instance, null, - CallingConventions.HasThis, Array.Empty(), null); - - if (constructor == null) - throw new JsonException("could not find default constructor for EntityReference"); - - return constructor.Invoke(Array.Empty()); - } - - var genericTypes = objectType.GenericTypeArguments; - - if (genericTypes.Length != 1) - throw new JsonException("Invalid generic types for EntityReference"); - - constructor = objectType.GetConstructor(BindingFlags.Public | BindingFlags.Instance, null, - CallingConventions.HasThis, new[] { genericTypes[0] }, null); - - if (constructor == null) - throw new JsonException("could not find single argument constructor for EntityReference"); - - return constructor.Invoke(new[] { serializer.Deserialize(reader, genericTypes[0]) }); - } - - public override bool CanConvert(Type objectType) - { - if (!objectType.IsGenericType) - return false; - - return objectType.GetGenericTypeDefinition() == typeof(EntityReference<>); - } -} diff --git a/src/saving/serializers/SerializationBinder.cs b/src/saving/serializers/SerializationBinder.cs index 4b1af4f56c8..33a49ff4392 100644 --- a/src/saving/serializers/SerializationBinder.cs +++ b/src/saving/serializers/SerializationBinder.cs @@ -22,6 +22,9 @@ public override Type BindToType(string? assemblyName, string typeName) if (type.IsArray) type = type.GetElementType() ?? throw new Exception("Array type doesn't have element type"); + if (type.IsAbstract || type.IsInterface) + throw new JsonException($"Dynamically typed JSON object is of interface or abstract type {typeName}"); + if (type.CustomAttributes.Any(attr => attr.AttributeType == DynamicTypeAllowedAttribute || attr.AttributeType == AlwaysDynamicTypeAttribute || attr.AttributeType == SceneLoadedClassAttribute || attr.AttributeType == CustomizedRegistryTypeAttribute)) @@ -47,7 +50,7 @@ public override Type BindToType(string? assemblyName, string typeName) /// When a class has this attribute this type is allowed to be dynamically de-serialized from json, /// as well as the type is written if something is a subclass of a type with this attribute /// -[AttributeUsage(AttributeTargets.Class)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface)] public class JSONDynamicTypeAllowedAttribute : Attribute { } @@ -62,7 +65,7 @@ public class JSONDynamicTypeAllowedAttribute : Attribute /// For example the MicrobeStage dynamic entities use this so that they can be stored in a single list /// /// -[AttributeUsage(AttributeTargets.Class)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface)] public class JSONAlwaysDynamicTypeAttribute : Attribute { } diff --git a/src/saving/serializers/ThriveJsonConverter.cs b/src/saving/serializers/ThriveJsonConverter.cs index 3f7e80fd82f..bbad15f5752 100644 --- a/src/saving/serializers/ThriveJsonConverter.cs +++ b/src/saving/serializers/ThriveJsonConverter.cs @@ -56,8 +56,6 @@ private ThriveJsonConverter(SaveContext context) // to not use this new BaseNodeConverter(context), - new EntityReferenceConverter(), - // Converter for all types with a specific few attributes for this to be enabled new DefaultThriveJSONConverter(context), }; diff --git a/src/thriveopedia/fossilisation/FossilisationButton.cs b/src/thriveopedia/fossilisation/FossilisationButton.cs index 3b73de7c05b..2bd734e0264 100644 --- a/src/thriveopedia/fossilisation/FossilisationButton.cs +++ b/src/thriveopedia/fossilisation/FossilisationButton.cs @@ -1,4 +1,6 @@ -using Godot; +using Components; +using DefaultEcs; +using Godot; /// /// Button shown above organisms in pause mode to fossilise (save) them. @@ -13,7 +15,7 @@ public class FossilisationButton : TextureButton /// /// The entity (organism) this button is attached to. /// - public IEntity AttachedEntity = null!; + public Entity AttachedEntity; /// /// Whether this species has already been fossilised. @@ -62,13 +64,13 @@ public void UpdatePosition() camera = GetViewport().GetCamera(); // If the entity is removed (e.g. forcefully despawned) - if (AttachedEntity.AliveMarker.Alive == false) + if (!AttachedEntity.IsAlive || !AttachedEntity.Has()) { this.DetachAndQueueFree(); return; } - RectGlobalPosition = camera.UnprojectPosition(AttachedEntity.EntityNode.GlobalTransform.origin); + RectGlobalPosition = camera.UnprojectPosition(AttachedEntity.Get().Position); } private void OnPressed() diff --git a/src/thriveopedia/pages/wiki/OrganelleInfoBox.cs b/src/thriveopedia/pages/wiki/OrganelleInfoBox.cs index 06c99fef366..ffb0892fea5 100644 --- a/src/thriveopedia/pages/wiki/OrganelleInfoBox.cs +++ b/src/thriveopedia/pages/wiki/OrganelleInfoBox.cs @@ -196,12 +196,12 @@ private void UpdateValues() .Aggregate((a, b) => a + "\n" + b) : TranslationServer.Translate("NONE"); - var hasEnzymes = organelle.Enzymes != null && organelle.Enzymes.Count > 0; + var hasEnzymes = organelle.Enzymes.Count > 0; enzymesLabel.Modulate = hasEnzymes ? opaque : translucent; enzymesLabel.Text = hasEnzymes ? - organelle.Enzymes! + organelle.Enzymes .Where(e => e.Value > 0) - .Select(e => SimulationParameters.Instance.GetEnzyme(e.Key).Name) + .Select(e => e.Key.Name) .Aggregate((a, b) => a + "\n" + b) : TranslationServer.Translate("NONE"); @@ -216,7 +216,17 @@ private void UpdateValues() nameLabel.Text = organelle.Name; costLabel.Text = organelle.MPCost.ToString(CultureInfo.CurrentCulture); - massLabel.Text = organelle.Mass.ToString(CultureInfo.CurrentCulture); + + // TODO: make this make more sense now that we only have physics density to use + if (organelle.RelativeDensityVolume > 0) + { + massLabel.Text = (organelle.Density * organelle.RelativeDensityVolume).ToString(CultureInfo.CurrentCulture); + } + else + { + massLabel.Text = organelle.Density.ToString(CultureInfo.CurrentCulture); + } + sizeLabel.Text = organelle.HexCount.ToString(CultureInfo.CurrentCulture); osmoregulationCostLabel.Text = organelle.HexCount.ToString(CultureInfo.CurrentCulture); storageLabel.Text = (organelle.Components.Storage?.Capacity ?? 0).ToString(CultureInfo.CurrentCulture); diff --git a/src/tutorial/TutorialEventArgs.cs b/src/tutorial/TutorialEventArgs.cs index 1889f91e624..1357b76182d 100644 --- a/src/tutorial/TutorialEventArgs.cs +++ b/src/tutorial/TutorialEventArgs.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using DefaultEcs; using Godot; /// @@ -11,17 +12,17 @@ public class TutorialEventArgs : EventArgs public class MicrobeEventArgs : TutorialEventArgs { - public MicrobeEventArgs(Microbe microbe) + public MicrobeEventArgs(Entity microbe) { Microbe = microbe; } - public Microbe Microbe { get; } + public Entity Microbe { get; } } public class RotationEventArgs : TutorialEventArgs { - public RotationEventArgs(Basis rotation, Vector3 rotationInDegrees) + public RotationEventArgs(Quat rotation, Vector3 rotationInDegrees) { Rotation = rotation; RotationInDegrees = rotationInDegrees; @@ -30,7 +31,7 @@ public RotationEventArgs(Basis rotation, Vector3 rotationInDegrees) /// /// Quaternion of the rotation /// - public Basis Rotation { get; } + public Quat Rotation { get; } /// /// Axis-wise degree rotations @@ -114,14 +115,14 @@ public CallbackEventArgs(Action data) public class MicrobeColonyEventArgs : TutorialEventArgs { - public MicrobeColonyEventArgs(MicrobeColony? colony) + public MicrobeColonyEventArgs(bool hasColony, int memberCount) { - Colony = colony; + HasColony = hasColony; + MemberCount = memberCount; } - public MicrobeColony? Colony { get; } - - public bool HasColony => Colony != null; + public bool HasColony { get; } + public int MemberCount { get; } } public class EnergyBalanceEventArgs : TutorialEventArgs diff --git a/src/tutorial/microbe_stage/LeaveColonyTutorial.cs b/src/tutorial/microbe_stage/LeaveColonyTutorial.cs index c14612db939..dc833ede785 100644 --- a/src/tutorial/microbe_stage/LeaveColonyTutorial.cs +++ b/src/tutorial/microbe_stage/LeaveColonyTutorial.cs @@ -34,7 +34,7 @@ public override void ApplyGUIState(MicrobeTutorialGUI gui) var data = (MicrobeColonyEventArgs)args; // Give advice if player is in a colony, but not big enough to get to the next stage - hasColony = data.HasColony && data.Colony!.ColonyMembers.Count < + hasColony = data.HasColony && data.MemberCount < Constants.COLONY_SIZE_REQUIRED_FOR_MULTICELLULAR; } diff --git a/test/PhysicsTest.cs b/test/PhysicsTest.cs new file mode 100644 index 00000000000..b09cf5a5928 --- /dev/null +++ b/test/PhysicsTest.cs @@ -0,0 +1,936 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Godot; + +/// +/// Tests / stress tests the physics system +/// +public class PhysicsTest : Node +{ + [Export] + public TestType Type = TestType.MicrobePlaceholders; + + [Export] + public int SpawnPattern = 2; + + [Export] + public float CameraZoomSpeed = 11.4f; + + /// + /// Sets MultiMesh position data with a single array assignment. Faster when all of the data has changed, but + /// slower when a lot of the data has not changed. + /// + [Export] + public bool UseSingleVectorMultiMeshUpdate; + + [Export] + public bool CreateMicrobeAsSpheres; + + [Export] + public float MicrobeDamping = 0.3f; + + [Export] + public NodePath? WorldVisualsPath; + + [Export] + public NodePath CameraPath = null!; + + [Export] + public NodePath GUIWindowRootPath = null!; + + [Export] + public NodePath DeltaLabelPath = null!; + + [Export] + public NodePath PhysicsTimingLabelPath = null!; + + [Export] + public NodePath TestNameLabelPath = null!; + + [Export] + public NodePath TestExtraInfoLabelPath = null!; + + [Export] + public NodePath PhysicsBodiesCountLabelPath = null!; + + [Export] + public NodePath SpawnPatternInfoLabelPath = null!; + + /// + /// When using external physics it is possible to not display any visuals when far away + /// + private const float MicrobeVisibilityDistance = 150; + + /// + /// Initially used bigger visuals range to ensure first frame loads most of the displayers to make sure lag spike + /// happens only on the first frame + /// + private const float InitialVisibilityRangeIncrease = 100; + + private const float MicrobeCameraDefaultHeight = 50; + + private const float YDriftThreshold = 0.05f; + + private readonly List allCreatedBodies = new(); + private readonly List sphereBodies = new(); + + private readonly List testVisuals = new(); + private readonly List otherCreatedNodes = new(); + + private readonly List microbeAnalogueBodies = new(); + private readonly List testMicrobesToProcess = new(); + +#pragma warning disable CA2213 + private Node worldVisuals = null!; + + private Camera camera = null!; + + private CustomWindow guiWindowRoot = null!; + private Label deltaLabel = null!; + private Label physicsTimingLabel = null!; + private Label testNameLabel = null!; + private Label testExtraInfoLabel = null!; + private Label physicsBodiesCountLabel = null!; + private Label spawnPatternInfoLabel = null!; + + private MultiMesh? sphereMultiMesh; + private PhysicalWorld physicalWorld = null!; +#pragma warning restore CA2213 + + private JVecF3[]? testMicrobeOrganellePositions; + + private int followedTestVisualIndex; + + /// + /// Player controller camera zoom level + /// + private float cameraHeightOffset; + + private float timeSincePhysicsReport; + + private bool testVisualsStarted; + private bool resetTest; + + private float driftingCheckTimer = 30; + + public enum TestType + { + Spheres, + SpheresIndividualNodes, + SpheresGodotPhysics, + MicrobePlaceholders, + MicrobePlaceholdersGodotPhysics, + } + + public override void _Ready() + { + worldVisuals = GetNode(WorldVisualsPath); + camera = GetNode(CameraPath); + + guiWindowRoot = GetNode(GUIWindowRootPath); + deltaLabel = GetNode