commit f3ac95b71eefa101cee272664e8fdea51d2118e9 Author: HoutarouOreki Date: Thu Mar 12 14:42:36 2026 +0100 initial commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..87029d7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,265 @@ +# Remove the line below if you want to inherit .editorconfig settings from higher directories +root = true + +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 4 +indent_style = space +tab_width = 4 + +# New line preferences +end_of_line = crlf +insert_final_newline = false + +#### .NET Code Actions #### + +# Type members +dotnet_hide_advanced_members = false +dotnet_member_insertion_location = with_other_members_of_the_same_kind +dotnet_property_generation_behavior = prefer_throwing_properties + +# Symbol search +dotnet_search_reference_assemblies = true + +#### .NET Coding Conventions #### + +# Organize usings +dotnet_separate_import_directive_groups = false +dotnet_sort_system_directives_first = false +file_header_template = unset + +# this. and Me. preferences +dotnet_style_qualification_for_event = false +dotnet_style_qualification_for_field = false +dotnet_style_qualification_for_method = false +dotnet_style_qualification_for_property = false + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true +dotnet_style_predefined_type_for_member_access = true + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity +dotnet_style_parentheses_in_other_operators = never_if_unnecessary +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members + +# Expression-level preferences +dotnet_prefer_system_hash_code = true +dotnet_style_coalesce_expression = true +dotnet_style_collection_initializer = true +dotnet_style_explicit_tuple_names = true +dotnet_style_namespace_match_folder = true +dotnet_style_null_propagation = true +dotnet_style_object_initializer = false +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true:warning +dotnet_style_prefer_collection_expression = when_types_loosely_match +dotnet_style_prefer_compound_assignment = true +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = false:suggestion +dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed +dotnet_style_prefer_inferred_anonymous_type_member_names = true +dotnet_style_prefer_inferred_tuple_names = true +dotnet_style_prefer_is_null_check_over_reference_equality_method = true +dotnet_style_prefer_simplified_boolean_expressions = true +dotnet_style_prefer_simplified_interpolation = true + +# Field preferences +dotnet_style_readonly_field = true + +# Parameter preferences +dotnet_code_quality_unused_parameters = all + +# Suppression preferences +dotnet_remove_unnecessary_suppression_exclusions = none + +# New line preferences +dotnet_style_allow_multiple_blank_lines_experimental = true +dotnet_style_allow_statement_immediately_after_block_experimental = true + +#### C# Coding Conventions #### + +# var preferences +csharp_style_var_elsewhere = false +csharp_style_var_for_built_in_types = true +csharp_style_var_when_type_is_apparent = true + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true +csharp_style_expression_bodied_constructors = false +csharp_style_expression_bodied_indexers = true +csharp_style_expression_bodied_lambdas = true:suggestion +csharp_style_expression_bodied_local_functions = true:suggestion +csharp_style_expression_bodied_methods = false +csharp_style_expression_bodied_operators = false +csharp_style_expression_bodied_properties = true + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true +csharp_style_pattern_matching_over_is_with_cast_check = true +csharp_style_prefer_extended_property_pattern = true +csharp_style_prefer_not_pattern = true +csharp_style_prefer_pattern_matching = true:suggestion +csharp_style_prefer_switch_expression = true + +# Null-checking preferences +csharp_style_conditional_delegate_call = true + +# Modifier preferences +csharp_prefer_static_anonymous_function = true +csharp_prefer_static_local_function = true +csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async +csharp_style_prefer_readonly_struct = true +csharp_style_prefer_readonly_struct_member = true + +# Code-block preferences +csharp_prefer_braces = false:warning +csharp_prefer_simple_using_statement = true +csharp_prefer_system_threading_lock = true +csharp_style_namespace_declarations = file_scoped:warning +csharp_style_prefer_method_group_conversion = true +csharp_style_prefer_primary_constructors = true +csharp_style_prefer_simple_property_accessors = true +csharp_style_prefer_top_level_statements = true + +# Expression-level preferences +csharp_prefer_simple_default_expression = true +csharp_style_deconstructed_variable_declaration = true +csharp_style_implicit_object_creation_when_type_is_apparent = true +csharp_style_inlined_variable_declaration = true +csharp_style_prefer_implicitly_typed_lambda_expression = true +csharp_style_prefer_index_operator = true +csharp_style_prefer_local_over_anonymous_function = true +csharp_style_prefer_null_check_over_type_check = true +csharp_style_prefer_range_operator = true +csharp_style_prefer_tuple_swap = true +csharp_style_prefer_unbound_generic_type_in_nameof = true +csharp_style_prefer_utf8_string_literals = true +csharp_style_throw_expression = true +csharp_style_unused_value_assignment_preference = discard_variable +csharp_style_unused_value_expression_statement_preference = discard_variable + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:suggestion + +# New line preferences +csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true +csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true +csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true +csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true +csharp_style_allow_embedded_statements_on_same_line_experimental = true + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#### Naming styles #### + +# Naming rules + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.private_consts_should_be_lowercase.severity = suggestion +dotnet_naming_rule.private_consts_should_be_lowercase.symbols = private_consts +dotnet_naming_rule.private_consts_should_be_lowercase.style = underscore_lower + +dotnet_naming_rule.public_consts_should_be_uppercase.severity = suggestion +dotnet_naming_rule.public_consts_should_be_uppercase.symbols = public_consts +dotnet_naming_rule.public_consts_should_be_uppercase.style = underscore_upper + +# Symbol specifications + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.private_consts.applicable_kinds = field +dotnet_naming_symbols.private_consts.applicable_accessibilities = private, protected_internal, private_protected +dotnet_naming_symbols.private_consts.required_modifiers = const, static + +dotnet_naming_symbols.public_consts.applicable_kinds = field +dotnet_naming_symbols.public_consts.applicable_accessibilities = public, internal, protected +dotnet_naming_symbols.public_consts.required_modifiers = const, static + +# Naming styles + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case + +dotnet_naming_style.underscore_lower.required_prefix = +dotnet_naming_style.underscore_lower.required_suffix = +dotnet_naming_style.underscore_lower.word_separator = _ +dotnet_naming_style.underscore_lower.capitalization = all_lower + +dotnet_naming_style.underscore_upper.required_prefix = +dotnet_naming_style.underscore_upper.required_suffix = +dotnet_naming_style.underscore_upper.word_separator = _ +dotnet_naming_style.underscore_upper.capitalization = all_upper diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8e4f89c --- /dev/null +++ b/.gitignore @@ -0,0 +1,675 @@ +# Created by https://www.toptal.com/developers/gitignore/api/csharp,visualstudio,visualstudiocode,rider +# Edit at https://www.toptal.com/developers/gitignore?templates=csharp,visualstudio,visualstudiocode,rider + +### Csharp ### +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml + +### Rider ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### VisualStudioCode ### +!.vscode/*.code-snippets + +# Local History for Visual Studio Code + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### VisualStudio ### + +# User-specific files + +# User-specific files (MonoDevelop/Xamarin Studio) + +# Mono auto generated files + +# Build results + +# Visual Studio 2015/2017 cache/options directory +# Uncomment if you have tasks that create the project's static files in wwwroot + +# Visual Studio 2017 auto generated files + +# MSTest test Results + +# NUnit + +# Build Results of an ATL Project + +# Benchmark Results + +# .NET Core + +# ASP.NET Scaffolding + +# StyleCop + +# Files built by Visual Studio + +# Chutzpah Test files + +# Visual C++ cache files + +# Visual Studio profiler + +# Visual Studio Trace Files + +# TFS 2012 Local Workspace + +# Guidance Automation Toolkit + +# ReSharper is a .NET coding add-in + +# TeamCity is a build add-in + +# DotCover is a Code Coverage Tool + +# AxoCover is a Code Coverage Tool + +# Coverlet is a free, cross platform Code Coverage Tool + +# Visual Studio code coverage results + +# NCrunch + +# MightyMoose + +# Web workbench (sass) + +# Installshield output folder + +# DocProject is a documentation generator add-in + +# Click-Once directory + +# Publish Web Output +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted + +# NuGet Packages +# NuGet Symbol Packages +# The packages folder can be ignored because of Package Restore +# except build/, which is used as an MSBuild target. +# Uncomment if necessary however generally it will be regenerated when needed +# NuGet v3's project.json files produces more ignorable files + +# Microsoft Azure Build Output + +# Microsoft Azure Emulator + +# Windows Store app package directories and files + +# Visual Studio cache files +# files ending in .cache can be ignored +# but keep track of directories ending in .cache + +# Others + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) + +# RIA/Silverlight projects + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) + +# SQL Server files + +# Business Intelligence projects + +# Microsoft Fakes + +# GhostDoc plugin setting file + +# Node.js Tools for Visual Studio + +# Visual Studio 6 build log + +# Visual Studio 6 workspace options file + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) + +# Visual Studio 6 technical files + +# Visual Studio LightSwitch build output + +# Paket dependency manager + +# FAKE - F# Make + +# CodeRush personal settings + +# Python Tools for Visual Studio (PTVS) + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio + +# Telerik's JustMock configuration file + +# BizTalk build output + +# OpenCover UI analysis results + +# Azure Stream Analytics local run output + +# MSBuild Binary and Structured Log + +# NVidia Nsight GPU debugger configuration file + +# MFractors (Xamarin productivity tool) working folder + +# Local History for Visual Studio + +# Visual Studio History (VSHistory) files + +# BeatPulse healthcheck temp database + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 + +# Ionide (cross platform F# VS Code tools) working folder + +# Fody - auto-generated XML schema + +# VS Code files for those working on multiple tools + +# Local History for Visual Studio Code + +# Windows Installer files from build outputs + +# JetBrains Rider + +### VisualStudio Patch ### +# Additional files built by Visual Studio + +# End of https://www.toptal.com/developers/gitignore/api/csharp,visualstudio,visualstudiocode,rider \ No newline at end of file diff --git a/.timetracker b/.timetracker new file mode 100644 index 0000000..915d48c --- /dev/null +++ b/.timetracker @@ -0,0 +1,15 @@ +{ + "total": 49944, + "sessions": [ + { + "begin": "2026-03-11T23:50:47+01:00", + "end": "2026-03-12T09:25:14+01:00", + "duration": 34467 + }, + { + "begin": "2026-03-12T17:18:14+01:00", + "end": "2026-03-12T21:36:11+01:00", + "duration": 15477 + } + ] +} \ No newline at end of file diff --git a/ChestPreview.slnx b/ChestPreview.slnx new file mode 100644 index 0000000..f78578e --- /dev/null +++ b/ChestPreview.slnx @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/ChestPreview/ChestPreview/ChestPreview.csproj b/ChestPreview/ChestPreview/ChestPreview.csproj new file mode 100644 index 0000000..5dca78e --- /dev/null +++ b/ChestPreview/ChestPreview/ChestPreview.csproj @@ -0,0 +1,80 @@ + + + + net8.0 + false + bin\$(Configuration)\Mods\mod + enable + Debug;Release;Version22 + + + + + net10.0 + $(VINTAGE_STORY_22PRE2) + --dataPath "..\VintageStory22Data" + + + + + $(VINTAGE_STORY)/VintagestoryAPI.dll + false + + + $(VINTAGE_STORY)/Mods/VSSurvivalMod.dll + False + + + $(VINTAGE_STORY)/Mods/VSEssentials.dll + False + + + $(VINTAGE_STORY)/Mods/VSCreativeMod.dll + False + + + $(VINTAGE_STORY)/Lib/Newtonsoft.Json.dll + False + + + $(VINTAGE_STORY)/Lib/0Harmony.dll + False + + + $(VINTAGE_STORY)/VintagestoryLib.dll + false + + + $(VINTAGE_STORY)/Lib/protobuf-net.dll + False + + + $(VINTAGE_STORY)/Lib/cairo-sharp.dll + False + + + $(VINTAGE_STORY)/Lib/Microsoft.Data.Sqlite.dll + False + + + + + + E:\Code\VintageStory\configlib_1.10.14\configlib.dll + False + + + + + + PreserveNewest + + + + + + PreserveNewest + + + + diff --git a/ChestPreview/ChestPreview/Configs/Config.cs b/ChestPreview/ChestPreview/Configs/Config.cs new file mode 100644 index 0000000..779b1d5 --- /dev/null +++ b/ChestPreview/ChestPreview/Configs/Config.cs @@ -0,0 +1,20 @@ +namespace ChestPreview.Configs; + +public sealed class Config +{ + public string Mode { get; set; } = PreviewModes.UNDER_CURSOR; + + public bool HoldKey { get; set; } = true; + + public int BillboardColumnsPerBlock { get; set; } = 4; + + public int ColumnsUnderCursor { get; set; } = 10; + + public int PreviewNearbyRadius { get; set; } = 8; + + public bool WhitelistedContainersOnly { get; set; } = true; + + public string WhitelistedContainers { get; set; } = "barrel, crate, chest-*, trunk-*, storagevessel-*, stationarybasket-*, labeledchest-*"; + + public string BlacklistedContainers { get; set; } = ""; +} \ No newline at end of file diff --git a/ChestPreview/ChestPreview/Configs/ConfigLibBridge.cs b/ChestPreview/ChestPreview/Configs/ConfigLibBridge.cs new file mode 100644 index 0000000..8b7dddd --- /dev/null +++ b/ChestPreview/ChestPreview/Configs/ConfigLibBridge.cs @@ -0,0 +1,50 @@ +using ChestPreview.Core; +using ConfigLib; +using System; +using Vintagestory.API.Client; + +namespace ChestPreview.Configs; + +internal class ConfigLibBridge(Config config) : IDisposable +{ + public const string CONFIGLIB_MODID = "configlib"; + + private readonly Config config = config; + + private ConfigLibModSystem? configLibSystem; + + public static ConfigLibBridge? TryCreate(ICoreClientAPI api, Config config) + { + if (!api.ModLoader.IsModEnabled(CONFIGLIB_MODID)) + return null; + + ConfigLibModSystem? configLibSystem = api.ModLoader.GetModSystem(); + if (configLibSystem == null) + return null; + + var bridge = new ConfigLibBridge(config) + { + configLibSystem = configLibSystem + }; + + configLibSystem.SettingChanged += bridge.OnSettingChanged; + return bridge; + } + + public void Dispose() + { + if (configLibSystem == null) + return; + + configLibSystem.SettingChanged -= OnSettingChanged; + configLibSystem = null; + } + + private void OnSettingChanged(string domain, IConfig configObject, ISetting settingObject) + { + if (domain != ChestPreviewModSystem.CONFIGLIB_DOMAIN || settingObject == null) + return; + + settingObject.AssignSettingValue(config); + } +} \ No newline at end of file diff --git a/ChestPreview/ChestPreview/Configs/PreviewModes.cs b/ChestPreview/ChestPreview/Configs/PreviewModes.cs new file mode 100644 index 0000000..442c55a --- /dev/null +++ b/ChestPreview/ChestPreview/Configs/PreviewModes.cs @@ -0,0 +1,21 @@ +namespace ChestPreview.Configs; + +public static class PreviewModes +{ + public const string NONE = "None"; + public const string UNDER_CURSOR = "UnderCursor"; + public const string ON_HOVERED_CONTAINER = "OnHoveredContainer"; + public const string ON_NEARBY_CONTAINERS = "OnNearbyContainers"; + + public static string Normalize(string? mode) + { + return mode switch + { + NONE => NONE, + UNDER_CURSOR => UNDER_CURSOR, + ON_HOVERED_CONTAINER => ON_HOVERED_CONTAINER, + ON_NEARBY_CONTAINERS => ON_NEARBY_CONTAINERS, + _ => ON_HOVERED_CONTAINER + }; + } +} diff --git a/ChestPreview/ChestPreview/Core/ChestPreviewModSystem.cs b/ChestPreview/ChestPreview/Core/ChestPreviewModSystem.cs new file mode 100644 index 0000000..a7d6e73 --- /dev/null +++ b/ChestPreview/ChestPreview/Core/ChestPreviewModSystem.cs @@ -0,0 +1,111 @@ +using ChestPreview.Configs; +using ChestPreview.Rendering; +using System; +using Vintagestory.API.Client; +using Vintagestory.API.Common; +using Vintagestory.API.Config; + +namespace ChestPreview.Core; + +public class ChestPreviewModSystem : ModSystem +{ + internal const string CONFIG_FILENAME = "chestpreview.json"; + internal const string CONFIGLIB_DOMAIN = "chestpreview"; + internal const string PREVIEW_CONTAINERS_HOTKEY_CODE = "chestpreview-preview-containers"; + internal const string PREVIEW_CONTAINERS_NEARBY_HOTKEY_CODE = "chestpreview-preview-containers-nearby"; + + private Config config = new(); + private CardRenderer? cardRenderer; + private ConfigLibBridge? configLibBridge; + private StorageHoverHudRenderer? storageHoverHudRenderer; + private WorldBillboardRenderer? worldBillboardRenderer; + + public override bool ShouldLoad(EnumAppSide forSide) => forSide is EnumAppSide.Client; + + public override void StartClientSide(ICoreClientAPI api) + { + config = LoadClientConfig(api); + RegisterHotkeys(api); + if (api.ModLoader.IsModEnabled(ConfigLibBridge.CONFIGLIB_MODID)) + configLibBridge = ConfigLibBridge.TryCreate(api, config); + cardRenderer = new CardRenderer(api, config); + + storageHoverHudRenderer = new StorageHoverHudRenderer(api, config, cardRenderer); + api.Event.RegisterRenderer(storageHoverHudRenderer, EnumRenderStage.Ortho, "chestpreview-storage-hover-hud"); + + worldBillboardRenderer = new WorldBillboardRenderer(api, config, cardRenderer); + api.Event.RegisterRenderer(worldBillboardRenderer, EnumRenderStage.AfterOIT, "chestpreview-world-billboard-preview"); + } + + public override void Dispose() + { + configLibBridge?.Dispose(); + configLibBridge = null; + + if (storageHoverHudRenderer is { } renderer) + { + renderer.Api.Event.UnregisterRenderer(renderer, EnumRenderStage.Ortho); + renderer.Dispose(); + storageHoverHudRenderer = null; + } + + if (worldBillboardRenderer is { } billboardRenderer) + { + billboardRenderer.Api.Event.UnregisterRenderer(billboardRenderer, EnumRenderStage.AfterOIT); + billboardRenderer.Dispose(); + worldBillboardRenderer = null; + } + + cardRenderer?.Dispose(); + cardRenderer = null; + } + + public Config LoadClientConfig(ICoreClientAPI api) + { + Config config; + + try + { + config = api.LoadModConfig(CONFIG_FILENAME) ?? new Config(); + } + catch (Exception ex) + { + Mod.Logger.Error("Error while loading config."); + Mod.Logger.Error(ex); + config = new Config(); + } + + config.Mode = PreviewModes.Normalize(config.Mode); + if (config.BillboardColumnsPerBlock <= 0) + config.BillboardColumnsPerBlock = 5; + + if (config.ColumnsUnderCursor <= 0) + config.ColumnsUnderCursor = 10; + + config.PreviewNearbyRadius = config.PreviewNearbyRadius; + + api.StoreModConfig(config, CONFIG_FILENAME); + return config; + } + + private static void RegisterHotkeys(ICoreClientAPI api) + { + api.Input.RegisterHotKey( + hotkeyCode: PREVIEW_CONTAINERS_HOTKEY_CODE, + name: Lang.Get("chestpreview:hotkey-preview-container"), + key: GlKeys.LShift, + type: HotkeyType.HelpAndOverlays, + altPressed: false, + ctrlPressed: false, + shiftPressed: false); + + api.Input.RegisterHotKey( + hotkeyCode: PREVIEW_CONTAINERS_NEARBY_HOTKEY_CODE, + name: Lang.Get("chestpreview:hotkey-preview-containers-nearby"), + key: GlKeys.LShift, + type: HotkeyType.HelpAndOverlays, + altPressed: false, + ctrlPressed: true, + shiftPressed: false); + } +} diff --git a/ChestPreview/ChestPreview/Models/BillboardTarget.cs b/ChestPreview/ChestPreview/Models/BillboardTarget.cs new file mode 100644 index 0000000..46e4883 --- /dev/null +++ b/ChestPreview/ChestPreview/Models/BillboardTarget.cs @@ -0,0 +1,22 @@ +using Vintagestory.API.MathTools; + +namespace ChestPreview.Models; + +internal readonly struct BillboardTarget(PreviewTarget previewTarget, Vec3d center, Vec3f right, Vec3f up, Vec3f forward, float width, float height, bool isFrontPlacement) +{ + public PreviewTarget PreviewTarget { get; } = previewTarget; + + public Vec3d Center { get; } = center; + + public Vec3f Right { get; } = right; + + public Vec3f Up { get; } = up; + + public Vec3f Forward { get; } = forward; + + public float Width { get; } = width; + + public float Height { get; } = height; + + public bool IsFrontPlacement { get; } = isFrontPlacement; +} diff --git a/ChestPreview/ChestPreview/Models/PreviewTarget.cs b/ChestPreview/ChestPreview/Models/PreviewTarget.cs new file mode 100644 index 0000000..1500ea1 --- /dev/null +++ b/ChestPreview/ChestPreview/Models/PreviewTarget.cs @@ -0,0 +1,15 @@ +using Vintagestory.API.Common; +using Vintagestory.API.MathTools; + +namespace ChestPreview.Models; + +internal readonly struct PreviewTarget(Block block, BlockEntity blockEntity, string mode, Vec3d anchor) +{ + public Block Block { get; } = block; + + public BlockEntity BlockEntity { get; } = blockEntity; + + public string Mode { get; } = mode; + + public Vec3d Anchor { get; } = anchor; +} diff --git a/ChestPreview/ChestPreview/PreviewTargetProvider.cs b/ChestPreview/ChestPreview/PreviewTargetProvider.cs new file mode 100644 index 0000000..b800330 --- /dev/null +++ b/ChestPreview/ChestPreview/PreviewTargetProvider.cs @@ -0,0 +1,284 @@ +using ChestPreview.Configs; +using ChestPreview.Core; +using ChestPreview.Models; +using ChestPreview.Utils; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Text.RegularExpressions; +using Vintagestory.API.Client; +using Vintagestory.API.Common; +using Vintagestory.API.MathTools; +using Vintagestory.API.Util; + +namespace ChestPreview; + +internal partial class PreviewTargetProvider(ICoreClientAPI api, Config config) : IDisposable +{ + private const float nearby_scan_interval_seconds = 0.1f; + private static readonly Regex list_separator_regex = ListSeparatorRegex(); + + private readonly ICoreClientAPI api = api; + private readonly Config config = config; + private readonly List nearbyContainerEntities = []; + private float nearbyScanAccumulator; + + public void CollectTargets(float deltaTime, List targets) + { + string mode = GetActiveMode(); + if (mode == PreviewModes.NONE) + return; + + if (mode == PreviewModes.ON_NEARBY_CONTAINERS) + { + CollectNearbyTargets(deltaTime, targets); + return; + } + + if (!TryGetHoveredContainer(out Block hoveredBlock, out BlockEntity hoveredBlockEntity)) + return; + + if (!CanAccessContainer(hoveredBlockEntity)) + return; + + if (!IsContainerAllowed(hoveredBlock)) + return; + + targets.Add(new PreviewTarget(hoveredBlock, hoveredBlockEntity, mode, GetContainerAnchor(hoveredBlock, hoveredBlockEntity.Pos))); + } + + public void Dispose() + { + nearbyContainerEntities.Clear(); + } + + private void CollectNearbyTargets(float deltaTime, List targets) + { + nearbyScanAccumulator += deltaTime; + bool shouldRefresh = nearbyContainerEntities.Count == 0 || nearbyScanAccumulator >= nearby_scan_interval_seconds; + + if (shouldRefresh) + { + RefreshNearbyContainers(); + nearbyScanAccumulator = 0f; + } + + foreach (BlockEntity blockEntity in nearbyContainerEntities) + { + Block block = api.World.BlockAccessor.GetBlock(blockEntity.Pos); + if (block.Id == 0) + continue; + + if (!CanAccessContainer(blockEntity)) + continue; + + if (!IsContainerAllowed(block)) + continue; + + targets.Add(new PreviewTarget(block, blockEntity, PreviewModes.ON_NEARBY_CONTAINERS, GetContainerAnchor(block, blockEntity.Pos))); + } + } + + private string GetActiveMode() + { + if (IsHotkeyHeld(ChestPreviewModSystem.PREVIEW_CONTAINERS_NEARBY_HOTKEY_CODE)) + return PreviewModes.ON_NEARBY_CONTAINERS; + + if (config.HoldKey && !IsHotkeyHeld(ChestPreviewModSystem.PREVIEW_CONTAINERS_HOTKEY_CODE)) + return PreviewModes.NONE; + + return PreviewModes.Normalize(config.Mode); + } + + private bool IsHotkeyHeld(string hotkeyCode) + { + HotKey? hotKey = api.Input.GetHotKeyByCode(hotkeyCode); + if (hotKey?.CurrentMapping == null) + return false; + + KeyCombination mapping = hotKey.CurrentMapping; + if (!IsKeyHeld(mapping.KeyCode)) + return false; + + if (mapping.SecondKeyCode.HasValue && !IsKeyHeld(mapping.SecondKeyCode.Value)) + return false; + + EntityControls? controls = api.World.Player?.Entity?.Controls; + if (mapping.Ctrl && controls?.CtrlKey != true) + return false; + + if (mapping.Shift && controls?.ShiftKey != true) + return false; + + return !mapping.Alt || IsAltHeld(); + } + + private bool IsKeyHeld(int keyCode) + { + bool[] keyStates = api.Input.KeyboardKeyStateRaw; + return keyCode >= 0 && keyCode < keyStates.Length && keyStates[keyCode]; + } + + private bool IsAltHeld() + { + return IsKeyHeld((int)GlKeys.AltLeft) || IsKeyHeld((int)GlKeys.AltRight); + } + + private bool TryGetHoveredContainer(out Block block, out BlockEntity blockEntity) + { + block = null!; + blockEntity = null!; + + BlockSelection? currentBlockSelection = api.World.Player?.CurrentBlockSelection; + if (currentBlockSelection == null) + return false; + + BlockEntity? currentBlockEntity = api.World.BlockAccessor.GetBlockEntity(currentBlockSelection.Position); + if (currentBlockEntity is IBlockEntityContainer) + { + block = currentBlockSelection.Block; + blockEntity = currentBlockEntity; + return true; + } + + if (!TryResolveMultiblockControllerContainer(currentBlockSelection, out Block controllerBlock, out BlockEntity controllerBlockEntity)) + return false; + + block = controllerBlock; + blockEntity = controllerBlockEntity; + return true; + } + + private bool TryResolveMultiblockControllerContainer(BlockSelection blockSelection, out Block controllerBlock, out BlockEntity controllerBlockEntity) + { + controllerBlock = null!; + controllerBlockEntity = null!; + + if (blockSelection.Block is not IMultiblockOffset multiblockOffset) + return false; + + BlockPos controllerPos = multiblockOffset.GetControlBlockPos(blockSelection.Position); + BlockEntity? blockEntity = api.World.BlockAccessor.GetBlockEntity(controllerPos); + if (blockEntity is not IBlockEntityContainer) + return false; + + controllerBlock = api.World.BlockAccessor.GetBlock(controllerPos); + if (controllerBlock.Id == 0) + return false; + + controllerBlockEntity = blockEntity; + return true; + } + + private void RefreshNearbyContainers() + { + nearbyContainerEntities.Clear(); + + EntityPlayer? playerEntity = api.World.Player?.Entity; + if (playerEntity?.CameraPos == null) + return; + + float nearbyRadius = config.PreviewNearbyRadius; + float nearbyRadiusSquared = nearbyRadius * nearbyRadius; + + Vec3d nearbyRadiusOffset = new(nearbyRadius, nearbyRadius, nearbyRadius); + BlockPos minPos = (playerEntity.CameraPos - nearbyRadiusOffset).AsBlockPos; + BlockPos maxPos = (playerEntity.CameraPos + nearbyRadiusOffset + new Vec3d(1d, 1d, 1d)).AsBlockPos; + + api.World.BlockAccessor.WalkBlocks(minPos, maxPos, (block, x, y, z) => + { + double horizontalDx = x + 0.5d - playerEntity.CameraPos.X; + double horizontalDz = z + 0.5d - playerEntity.CameraPos.Z; + if (horizontalDx * horizontalDx + horizontalDz * horizontalDz > nearbyRadiusSquared) + return; + + if (Math.Abs(y + 0.5d - playerEntity.CameraPos.Y) > nearbyRadius) + return; + + BlockEntity? blockEntity = api.World.BlockAccessor.GetBlockEntity(new BlockPos(x, y, z)); + if (blockEntity is not IBlockEntityContainer) + return; + + if (block.Id == 0) + return; + + nearbyContainerEntities.Add(blockEntity); + }); + } + + private Vec3d GetContainerAnchor(Block block, BlockPos blockPos) + { + GetContainerBounds(block, blockPos, out Vector3 min, out Vector3 max); + + Vector3 anchor = blockPos.ToVector3() + new Vector3((min.X + max.X) * 0.5f, max.Y + 0.1f, (min.Z + max.Z) * 0.5f); + return anchor.ToVec3d(); + } + + private void GetContainerBounds(Block block, BlockPos blockPos, out Vector3 min, out Vector3 max) + { + Cuboidf[]? selectionBoxes = block.GetSelectionBoxes(api.World.BlockAccessor, blockPos); + + min = new Vector3(0f, 0f, 0f); + max = new Vector3(1f, 1f, 1f); + + if (selectionBoxes is not { Length: > 0 }) + return; + + foreach (Cuboidf selectionBox in selectionBoxes) + { + min = Vector3.Min(min, selectionBox.Start.ToVector3()); + max = Vector3.Max(max, selectionBox.End.ToVector3()); + } + } + + private bool CanAccessContainer(BlockEntity blockEntity) + { + if (blockEntity is not IBlockEntityContainer container || container.Inventory is not InventoryBase inventory) + return false; + + IPlayer? player = api.World.Player; + EntityPlayer? playerEntity = player?.Entity; + if (player == null || playerEntity == null) + return false; + + return inventory.CanPlayerAccess(player, playerEntity.Pos); + } + + private bool IsContainerAllowed(Block block) + { + string? code = block.Code?.Path; + if (string.IsNullOrWhiteSpace(code)) + return true; + + HashSet blacklist = ParseContainerCodes(config.BlacklistedContainers); + if (blacklist.Any(x => WildcardUtil.Match(x, code))) + return false; + + if (!config.WhitelistedContainersOnly) + return true; + + HashSet whitelist = ParseContainerCodes(config.WhitelistedContainers); + return whitelist.Any(x => WildcardUtil.Match(x, code)); + } + + private static HashSet ParseContainerCodes(string rawList) + { + if (string.IsNullOrWhiteSpace(rawList)) + return []; + + string[] entries = list_separator_regex.Split(rawList.Trim()); + HashSet result = new(StringComparer.OrdinalIgnoreCase); + + foreach (string entry in entries) + { + if (!string.IsNullOrWhiteSpace(entry)) + result.Add(entry); + } + + return result; + } + + [GeneratedRegex("[,;\\s]+", RegexOptions.Compiled)] + private static partial Regex ListSeparatorRegex(); +} \ No newline at end of file diff --git a/ChestPreview/ChestPreview/Properties/launchSettings.json b/ChestPreview/ChestPreview/Properties/launchSettings.json new file mode 100644 index 0000000..af8e6d0 --- /dev/null +++ b/ChestPreview/ChestPreview/Properties/launchSettings.json @@ -0,0 +1,10 @@ +{ + "profiles": { + "Client": { + "commandName": "Executable", + "executablePath": "dotnet", + "commandLineArgs": "\"$(VINTAGE_STORY)/Vintagestory.dll\" --tracelog --addModPath \"$(ProjectDir)/bin/$(Configuration)/Mods\" --addOrigin \"$(ProjectDir)/assets\"$(VintageStoryDataPathArgs)", + "workingDirectory": "$(VINTAGE_STORY)" + } + } +} \ No newline at end of file diff --git a/ChestPreview/ChestPreview/Rendering/CardRenderer.cs b/ChestPreview/ChestPreview/Rendering/CardRenderer.cs new file mode 100644 index 0000000..0ec04e8 --- /dev/null +++ b/ChestPreview/ChestPreview/Rendering/CardRenderer.cs @@ -0,0 +1,525 @@ +using Cairo; +using ChestPreview.Configs; +using ChestPreview.Models; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using Vintagestory.API.Client; +using Vintagestory.API.Common; +using Vintagestory.API.Config; +using Vintagestory.API.MathTools; + +namespace ChestPreview.Rendering; + +internal class CardRenderer(ICoreClientAPI api, Config config) : IDisposable +{ + private static readonly int rendered_icon_size = 64; + private static readonly float hud_z = 0f; + private static readonly double hud_outer_alpha = 0.36d; + private static readonly float cell_padding = 10f; + private static readonly float cell_gap = 6f; + private static readonly float cell_width = 72f; + private static readonly float cell_height = 86f; + private static readonly float icon_size = 64f; + private static readonly double count_font_size = 20d; + private static readonly float count_anchor_inset = 2f; + + private readonly ICoreClientAPI api = api; + private readonly Config config = config; + private readonly Dictionary cardTextureByTarget = []; + private readonly Dictionary atlasTextureById = []; + private readonly Dictionary renderedIconByStack = []; + private int iconRenderVersion; + + public static float Width => GetCardWidth(1); + + public static float Height => GetCardHeight(1); + + public bool TryGetOrCreateCardTexture(PreviewTarget target, out LoadedTexture texture) + { + return TryGetOrCreateCardTexture(target, GetDefaultMaxColumns(target), out texture); + } + + public bool TryGetOrCreateCardTexture(PreviewTarget target, int maxColumns, out LoadedTexture texture) + { + texture = null!; + + if (target.BlockEntity is not IBlockEntityContainer container || container.Inventory is not InventoryBase inventory) + return false; + + IPlayer? player = api.World.Player; + EntityPlayer? playerEntity = player?.Entity; + if (player == null || playerEntity == null || !inventory.CanPlayerAccess(player, playerEntity.Pos)) + return false; + + string targetKey = CreateTargetKey(target.BlockEntity.Pos); + if (!cardTextureByTarget.TryGetValue(targetKey, out CachedCardTexture? cachedTexture)) + { + cachedTexture = new CachedCardTexture(new LoadedTexture(api), string.Empty); + cardTextureByTarget[targetKey] = cachedTexture; + } + + BuildLayout(inventory, maxColumns, out List cells, out int usedColumns, out int usedRows, out string fingerprint); + if (cachedTexture.Fingerprint != fingerprint) + { + ComposeTexture(cachedTexture.Texture, cells, usedColumns, usedRows); + cachedTexture.Fingerprint = fingerprint; + } + + texture = cachedTexture.Texture; + return texture.TextureId != 0; + } + + public void DrawAtScreen(float x, float y, LoadedTexture texture) + { + if (texture.TextureId == 0) + return; + + api.Render.RenderTexture(texture.TextureId, x, y, texture.Width, texture.Height, hud_z, new Vec4f(1f, 1f, 1f, 1f)); + } + + public void ClampToScreen(ref float x, ref float y) + { + ClampToScreen(ref x, ref y, Width, Height); + } + + public void ClampToScreen(ref float x, ref float y, float cardWidth, float cardHeight) + { + float max_x = api.Render.FrameWidth - cardWidth; + float max_y = api.Render.FrameHeight - cardHeight; + + x = Math.Clamp(x, 0f, max_x); + y = Math.Clamp(y, 0f, max_y); + } + + public void Dispose() + { + foreach (CachedCardTexture cardTexture in cardTextureByTarget.Values) + cardTexture.Texture.Dispose(); + + cardTextureByTarget.Clear(); + atlasTextureById.Clear(); + renderedIconByStack.Clear(); + } + + private void BuildLayout(IInventory inventory, int maxColumns, out List cells, out int usedColumns, out int usedRows, out string fingerprint) + { + List groupedItems = []; + int naturalOrder = 0; + + foreach (ItemSlot? slot in inventory) + { + if (slot == null || slot.Empty || slot.Itemstack == null) + continue; + + ItemStack stack = slot.Itemstack; + int existingIndex = FindMatchingItemIndex(groupedItems, stack); + if (existingIndex >= 0) + { + GroupedItemCell existing = groupedItems[existingIndex]; + existing.Count += stack.StackSize; + continue; + } + + ItemStack representativeStack = stack.Clone(); + representativeStack.StackSize = 1; + groupedItems.Add(new GroupedItemCell(representativeStack, stack.StackSize, naturalOrder)); + naturalOrder++; + } + + groupedItems.Sort(static (left, right) => + { + int countComparison = right.Count.CompareTo(left.Count); + return countComparison != 0 ? countComparison : left.NaturalOrder.CompareTo(right.NaturalOrder); + }); + + maxColumns = Math.Max(1, maxColumns); + usedColumns = GetUsedColumnCount(groupedItems.Count, maxColumns); + usedRows = GetRowCount(groupedItems.Count, maxColumns); + + cells = groupedItems; + fingerprint = BuildFingerprint(cells, maxColumns, usedColumns, usedRows); + } + + private int FindMatchingItemIndex(List groupedItems, ItemStack stack) + { + for (int i = 0; i < groupedItems.Count; i++) + { + if (AreStacksEquivalent(groupedItems[i].Stack, stack)) + return i; + } + + return -1; + } + + private bool AreStacksEquivalent(ItemStack left, ItemStack right) + { + if (left.Class != right.Class || left.Id != right.Id) + return false; + + ItemStack leftComparable = left.Clone(); + ItemStack rightComparable = right.Clone(); + leftComparable.StackSize = 1; + rightComparable.StackSize = 1; + return leftComparable.Equals(api.World, rightComparable, GlobalConstants.IgnoredStackAttributes); + } + + private string BuildFingerprint(List cells, int maxColumns, int usedColumns, int usedRows) + { + StringBuilder builder = new(); + builder.Append('m'); + builder.Append(maxColumns); + builder.Append('|'); + builder.Append('w'); + builder.Append(GetCardWidth(usedColumns).ToString(CultureInfo.InvariantCulture)); + builder.Append('h'); + builder.Append(GetCardHeight(usedRows).ToString(CultureInfo.InvariantCulture)); + builder.Append('|'); + builder.Append("iv"); + builder.Append(iconRenderVersion); + builder.Append('|'); + + foreach (GroupedItemCell cell in cells) + { + builder.Append((int)cell.Stack.Class); + builder.Append(':'); + builder.Append(cell.Stack.Id); + builder.Append(':'); + builder.Append(cell.Count); + builder.Append(':'); + builder.Append(HashBytes(cell.Stack.ToBytes())); + builder.Append('|'); + } + + return builder.ToString(); + } + + private static uint HashBytes(byte[] bytes) + { + const uint offset_basis = 2166136261; + const uint prime = 16777619; + + uint hash = offset_basis; + for (int i = 0; i < bytes.Length; i++) + hash = (hash ^ bytes[i]) * prime; + + return hash; + } + + private void ComposeTexture(LoadedTexture targetTexture, List cells, int usedColumns, int usedRows) + { + float hudWidth = GetCardWidth(usedColumns); + float hudHeight = GetCardHeight(usedRows); + + using ImageSurface surface = new(Format.Argb32, (int)hudWidth, (int)hudHeight); + using Context context = new(surface); + + context.Operator = Operator.Source; + context.SetSourceRGBA(0d, 0d, 0d, 0d); + context.Paint(); + + context.SetSourceRGBA(0.28d, 0.28d, 0.28d, hud_outer_alpha); + context.Rectangle(0d, 0d, hudWidth, hudHeight); + context.Fill(); + + context.SelectFontFace("sans", FontSlant.Normal, FontWeight.Bold); + context.SetFontSize(count_font_size); + + api.Gui.LoadOrUpdateCairoTexture(surface, true, ref targetTexture); + + for (int i = 0; i < cells.Count; i++) + { + GroupedItemCell cell = cells[i]; + if (!TryGetCellBounds(i, usedColumns, hudWidth, hudHeight, out float cellX, out float cellY)) + continue; + + if (!TryGetIconTexture(cell.Stack, out LoadedTexture atlasTexture, out TextureAtlasPosition textureAtlasPosition)) + continue; + + float sourceX = textureAtlasPosition.x1 * atlasTexture.Width; + float sourceY = textureAtlasPosition.y1 * atlasTexture.Height; + float sourceWidth = (textureAtlasPosition.x2 - textureAtlasPosition.x1) * atlasTexture.Width; + float sourceHeight = (textureAtlasPosition.y2 - textureAtlasPosition.y1) * atlasTexture.Height; + + float iconX = cellX + (cell_width - icon_size) / 2f; + float iconY = cellY + 1f; + + api.Render.RenderTextureIntoTexture(atlasTexture, sourceX, sourceY, sourceWidth, sourceHeight, targetTexture, iconX, iconY, 0.01f); + } + + using ImageSurface labelsSurface = new(Format.Argb32, (int)hudWidth, (int)hudHeight); + using Context labelsContext = new(labelsSurface); + LoadedTexture labelsTexture = new(api); + + labelsContext.Operator = Operator.Source; + labelsContext.SetSourceRGBA(0d, 0d, 0d, 0d); + labelsContext.Paint(); + labelsContext.SelectFontFace("sans", FontSlant.Normal, FontWeight.Bold); + labelsContext.SetFontSize(count_font_size); + + for (int i = 0; i < cells.Count; i++) + { + GroupedItemCell cell = cells[i]; + if (!TryGetCellBounds(i, usedColumns, hudWidth, hudHeight, out float cellX, out float cellY)) + continue; + + float iconX = cellX + (cell_width - icon_size) / 2f; + float iconY = cellY + 1f; + float iconRight = iconX + icon_size - count_anchor_inset; + float iconBottom = iconY + icon_size - count_anchor_inset; + + string countText = cell.Count.ToString(CultureInfo.InvariantCulture); + TextExtents textExtents = labelsContext.TextExtents(countText); + + double textX = iconRight - (textExtents.Width + textExtents.XBearing); + double textY = iconBottom - (textExtents.Height + textExtents.YBearing); + + labelsContext.SetSourceRGBA(0d, 0d, 0d, 0.95d); + labelsContext.MoveTo(textX + 1d, textY + 1d); + labelsContext.ShowText(countText); + + labelsContext.SetSourceRGBA(1d, 1d, 1d, 1d); + labelsContext.MoveTo(textX, textY); + labelsContext.ShowText(countText); + } + + try + { + api.Gui.LoadOrUpdateCairoTexture(labelsSurface, true, ref labelsTexture); + api.Render.RenderTextureIntoTexture(labelsTexture, 0f, 0f, labelsTexture.Width, labelsTexture.Height, targetTexture, 0f, 0f, 0.01f); + } + finally + { + labelsTexture.Dispose(); + } + + api.Render.BindTexture2d(targetTexture.TextureId); + } + + private bool TryGetIconTexture(ItemStack stack, out LoadedTexture atlasTexture, out TextureAtlasPosition textureAtlasPosition) + { + if (TryGetRenderedItemIcon(stack, out atlasTexture, out textureAtlasPosition)) + return true; + + TextureAtlasPosition fallbackTextureAtlasPosition = api.Render.GetTextureAtlasPosition(stack); + if (fallbackTextureAtlasPosition == null || !TryGetAtlasTexture(fallbackTextureAtlasPosition, out atlasTexture)) + return false; + + textureAtlasPosition = fallbackTextureAtlasPosition; + return true; + } + + private bool TryGetRenderedItemIcon(ItemStack stack, out LoadedTexture atlasTexture, out TextureAtlasPosition textureAtlasPosition) + { + atlasTexture = null!; + textureAtlasPosition = null!; + + string key = BuildStackKey(stack); + if (!renderedIconByStack.TryGetValue(key, out RenderedIconEntry? entry)) + { + entry = new RenderedIconEntry(stack.Clone()); + renderedIconByStack[key] = entry; + } + + if (!entry.Requested) + { + entry.Requested = true; + api.Render.RenderItemStackToAtlas( + entry.Stack, + api.ItemTextureAtlas, + rendered_icon_size, + textureSubId => + { + entry.TextureSubId = textureSubId; + entry.Ready = true; + iconRenderVersion++; + }, + ColorUtil.WhiteArgb, + 0f, + 1f + ); + } + + if (!entry.Ready) + return false; + + if (!TryGetAtlasPosition(api.ItemTextureAtlas, entry.TextureSubId, out TextureAtlasPosition texPos)) + return false; + + if (!TryGetAtlasTexture(api.ItemTextureAtlas, texPos.atlasNumber, out LoadedTexture tex)) + return false; + + atlasTexture = tex; + textureAtlasPosition = texPos; + return true; + } + + private bool TryGetAtlasTexture(TextureAtlasPosition textureAtlasPosition, out LoadedTexture atlasTexture) + { + atlasTexture = null!; + + int textureId = textureAtlasPosition.atlasTextureId; + if (textureId <= 0) + return false; + + if (atlasTextureById.TryGetValue(textureId, out LoadedTexture? cachedAtlasTexture)) + { + atlasTexture = cachedAtlasTexture; + return true; + } + + int atlasSize = ResolveAtlasSize(textureId); + if (atlasSize <= 0) + return false; + + atlasTexture = new LoadedTexture(api, textureId, atlasSize, atlasSize) + { + IgnoreUndisposed = true + }; + + atlasTextureById[textureId] = atlasTexture; + return true; + } + + private int ResolveAtlasSize(int textureId) + { + if (TryGetAtlasSize(api.ItemTextureAtlas, textureId, out int itemAtlasSize)) + return itemAtlasSize; + + if (TryGetAtlasSize(api.BlockTextureAtlas, textureId, out int blockAtlasSize)) + return blockAtlasSize; + + if (TryGetAtlasSize(api.EntityTextureAtlas, textureId, out int entityAtlasSize)) + return entityAtlasSize; + + return 0; + } + + private static bool TryGetAtlasPosition(ITextureAtlasAPI atlas, int textureSubId, out TextureAtlasPosition textureAtlasPosition) + { + textureAtlasPosition = null!; + if (atlas.Positions == null || textureSubId < 0 || textureSubId >= atlas.Positions.Length) + return false; + + textureAtlasPosition = atlas.Positions[textureSubId]; + return textureAtlasPosition != null; + } + + private static bool TryGetAtlasTexture(ITextureAtlasAPI? atlas, int atlasNumber, out LoadedTexture texture) + { + texture = null!; + if (atlas == null || atlas.AtlasTextures == null || atlasNumber < 0 || atlasNumber >= atlas.AtlasTextures.Count) + return false; + + LoadedTexture atlasTexture = atlas.AtlasTextures[atlasNumber]; + if (atlasTexture.TextureId == 0) + return false; + + texture = atlasTexture; + return true; + } + + private static bool TryGetAtlasSize(ITextureAtlasAPI? atlas, int textureId, out int size) + { + size = 0; + if (atlas == null || atlas.AtlasTextures == null) + return false; + + foreach (LoadedTexture atlasTexture in atlas.AtlasTextures) + { + if (atlasTexture.TextureId != textureId) + continue; + + size = atlasTexture.Width; + if (size > 0) + return true; + } + + return false; + } + + private static string CreateTargetKey(BlockPos blockPos) + { + return $"{blockPos.X}:{blockPos.Y}:{blockPos.Z}"; + } + + private static string BuildStackKey(ItemStack stack) + { + return $"{(int)stack.Class}:{stack.Id}:{HashBytes(stack.ToBytes())}"; + } + + private static bool TryGetCellBounds(int index, int maxColumns, float hudWidth, float hudHeight, out float x, out float y) + { + int row = index / maxColumns; + int column = index % maxColumns; + + x = cell_padding + column * (cell_width + cell_gap); + y = cell_padding + row * (cell_height + cell_gap); + if (x + cell_width > hudWidth - cell_padding || y + cell_height > hudHeight - cell_padding) + return false; + + return true; + } + + private static float GetCardWidth(int maxColumns) + { + return cell_padding * 2f + maxColumns * cell_width + (maxColumns - 1) * cell_gap; + } + + private static float GetCardHeight(int rows) + { + return cell_padding * 2f + rows * cell_height + (rows - 1) * cell_gap; + } + + private static int GetRowCount(int itemCount, int maxColumns) + { + int safeMaxColumns = Math.Max(1, maxColumns); + int safeItemCount = Math.Max(1, itemCount); + return (safeItemCount + safeMaxColumns - 1) / safeMaxColumns; + } + + private static int GetUsedColumnCount(int itemCount, int maxColumns) + { + int safeMaxColumns = Math.Max(1, maxColumns); + if (itemCount <= 0) + return 1; + + return Math.Min(itemCount, safeMaxColumns); + } + + private int GetDefaultMaxColumns(PreviewTarget target) + { + if (target.Mode == PreviewModes.UNDER_CURSOR) + return Math.Max(1, config.ColumnsUnderCursor <= 0 ? 1 : config.ColumnsUnderCursor); + + return Math.Max(1, config.BillboardColumnsPerBlock <= 0 ? 1 : config.BillboardColumnsPerBlock); + } + + private class CachedCardTexture(LoadedTexture texture, string fingerprint) + { + public LoadedTexture Texture { get; } = texture; + + public string Fingerprint { get; set; } = fingerprint; + } + + private class GroupedItemCell(ItemStack stack, int count, int naturalOrder) + { + public ItemStack Stack { get; } = stack; + + public int Count { get; set; } = count; + + public int NaturalOrder { get; } = naturalOrder; + } + + private class RenderedIconEntry(ItemStack stack) + { + public ItemStack Stack { get; } = stack; + + public bool Requested { get; set; } + + public bool Ready { get; set; } + + public int TextureSubId { get; set; } = -1; + } +} diff --git a/ChestPreview/ChestPreview/Rendering/StorageHoverHudRenderer.cs b/ChestPreview/ChestPreview/Rendering/StorageHoverHudRenderer.cs new file mode 100644 index 0000000..ef9fc16 --- /dev/null +++ b/ChestPreview/ChestPreview/Rendering/StorageHoverHudRenderer.cs @@ -0,0 +1,57 @@ +using ChestPreview.Configs; +using ChestPreview.Models; +using System.Collections.Generic; +using Vintagestory.API.Client; + +namespace ChestPreview.Rendering; + +internal sealed class StorageHoverHudRenderer(ICoreClientAPI api, Config config, CardRenderer cardRenderer) : IRenderer +{ + private const float hud_offset_y = 24f; + private readonly List frameTargets = []; + private readonly CardRenderer cardRenderer = cardRenderer; + private readonly PreviewTargetProvider targetProvider = new(api, config); + + public ICoreClientAPI Api { get; } = api; + + public double RenderOrder => 5; + + public int RenderRange => 0; + + public void OnRenderFrame(float deltaTime, EnumRenderStage stage) + { + frameTargets.Clear(); + targetProvider.CollectTargets(deltaTime, frameTargets); + + foreach (PreviewTarget target in frameTargets) + DrawHudForTarget(target); + } + + public void Dispose() + { + frameTargets.Clear(); + targetProvider.Dispose(); + } + + private void DrawHudForTarget(PreviewTarget target) + { + if (target.Mode is not PreviewModes.UNDER_CURSOR) + return; + + if (!cardRenderer.TryGetOrCreateCardTexture(target, out LoadedTexture texture)) + return; + + if (!TryGetHudPosition(texture.Width, texture.Height, out float x, out float y)) + return; + + cardRenderer.DrawAtScreen(x, y, texture); + } + + private bool TryGetHudPosition(float cardWidth, float cardHeight, out float x, out float y) + { + x = Api.Input.MouseX - cardWidth / 2f; + y = Api.Input.MouseY + hud_offset_y; + cardRenderer.ClampToScreen(ref x, ref y, cardWidth, cardHeight); + return true; + } +} diff --git a/ChestPreview/ChestPreview/Rendering/WorldBillboardPresenter.cs b/ChestPreview/ChestPreview/Rendering/WorldBillboardPresenter.cs new file mode 100644 index 0000000..7526206 --- /dev/null +++ b/ChestPreview/ChestPreview/Rendering/WorldBillboardPresenter.cs @@ -0,0 +1,329 @@ +using ChestPreview.Configs; +using ChestPreview.Models; +using ChestPreview.Utils; +using System; +using System.Collections.Generic; +using System.Numerics; +using Vintagestory.API.Client; +using Vintagestory.API.Common; +using Vintagestory.API.MathTools; +using Vintagestory.GameContent; + +namespace ChestPreview.Rendering; + +internal class WorldBillboardPresenter(ICoreClientAPI api) +{ + private const int multiblock_scan_radius = 2; + private static readonly float front_face_offset = 0.01f; + + private readonly ICoreClientAPI api = api; + private readonly List frameBillboards = []; + + public IReadOnlyList FrameBillboards => frameBillboards; + + public void PrepareFrame(List previewTargets) + { + frameBillboards.Clear(); + + foreach (PreviewTarget previewTarget in previewTargets) + { + if (!CanPresentAsBillboard(previewTarget)) + continue; + + if (!TryCreateBillboardTarget(previewTarget, out BillboardTarget billboardTarget)) + continue; + + frameBillboards.Add(billboardTarget); + } + } + + public void Clear() + { + frameBillboards.Clear(); + } + + private static bool CanPresentAsBillboard(PreviewTarget previewTarget) + { + return previewTarget.Mode is PreviewModes.ON_HOVERED_CONTAINER or PreviewModes.ON_NEARBY_CONTAINERS; + } + + private bool TryCreateBillboardTarget(PreviewTarget previewTarget, out BillboardTarget billboardTarget) + { + return TryCreateFrontBillboardTarget(previewTarget, out billboardTarget); + } + + private bool TryCreateFrontBillboardTarget(PreviewTarget previewTarget, out BillboardTarget billboardTarget) + { + billboardTarget = default; + + Block block = previewTarget.Block; + BlockPos blockPos = previewTarget.BlockEntity.Pos; + GetContainerBounds(block, blockPos, out Vector3 min, out Vector3 max); + + if (!TryResolveFacingNormal(previewTarget.Block, previewTarget.BlockEntity, out Vec3f forward, out Vec3f right, out Vec3f up)) + return false; + + Vector3 centerLocal = (min + max) * 0.5f; + Vector3 halfExtents = (max - min) * 0.5f; + Vector3 rightVector = right.ToVector3(); + Vector3 upVector = up.ToVector3(); + Vector3 forwardVector = forward.ToVector3(); + + float halfWidth = Vector3.Dot(Vector3.Abs(rightVector), halfExtents); + float halfHeight = Vector3.Dot(Vector3.Abs(upVector), halfExtents); + float halfDepth = ResolveStableHalfDepth(forwardVector, halfExtents); + + float width = halfWidth * 2f; + float height = halfHeight * 2f; + + Vector3 center = blockPos.ToVector3() + centerLocal + forwardVector * (halfDepth + front_face_offset); + + width = Math.Max(0.05f, width); + height = Math.Max(0.05f, height); + + billboardTarget = new BillboardTarget(previewTarget, center.ToVec3d(), right, up, forward, width, height, true); + return true; + } + + private static float ResolveStableHalfDepth(Vector3 forward, Vector3 halfExtents) + { + if (MathF.Abs(forward.Y) < 0.5f) + return MathF.Abs(forward.X) >= MathF.Abs(forward.Z) ? halfExtents.X : halfExtents.Z; + + return MathF.Abs(forward.Y) >= MathF.Abs(forward.Z) ? halfExtents.Y : halfExtents.Z; + } + + private void GetContainerBounds(Block block, BlockPos blockPos, out Vector3 min, out Vector3 max) + { + min = new Vector3(0f, 0f, 0f); + max = new Vector3(1f, 1f, 1f); + + ExpandBoundsFromSelectionBoxes(block, blockPos, blockPos, ref min, ref max); + ExpandBoundsFromLinkedMultiblockParts(blockPos, ref min, ref max); + } + + private void ExpandBoundsFromLinkedMultiblockParts(BlockPos controllerPos, ref Vector3 min, ref Vector3 max) + { + int minScanX = controllerPos.X - multiblock_scan_radius; + int maxScanX = controllerPos.X + multiblock_scan_radius; + int minScanY = controllerPos.Y - multiblock_scan_radius; + int maxScanY = controllerPos.Y + multiblock_scan_radius; + int minScanZ = controllerPos.Z - multiblock_scan_radius; + int maxScanZ = controllerPos.Z + multiblock_scan_radius; + Vector3 minLocal = min; + Vector3 maxLocal = max; + + api.World.BlockAccessor.WalkBlocks(new BlockPos(minScanX, minScanY, minScanZ), new BlockPos(maxScanX, maxScanY, maxScanZ), (partBlock, x, y, z) => + { + if (x == controllerPos.X && y == controllerPos.Y && z == controllerPos.Z) + return; + + if (partBlock.Id == 0 || partBlock is not IMultiblockOffset multiblockOffset) + return; + + BlockPos partPos = new(x, y, z); + BlockPos linkedControllerPos = multiblockOffset.GetControlBlockPos(partPos); + if (!IsSameBlockPos(linkedControllerPos, controllerPos)) + return; + + ExpandBoundsFromSelectionBoxes(partBlock, partPos, controllerPos, ref minLocal, ref maxLocal); + }); + + min = minLocal; + max = maxLocal; + } + + private void ExpandBoundsFromSelectionBoxes(Block block, BlockPos sourcePos, BlockPos originPos, ref Vector3 min, ref Vector3 max) + { + Cuboidf[]? selectionBoxes = block.GetSelectionBoxes(api.World.BlockAccessor, sourcePos); + Vector3 sourceOffset = sourcePos.ToVector3() - originPos.ToVector3(); + if (selectionBoxes is not { Length: > 0 }) + { + min = Vector3.Min(min, sourceOffset); + max = Vector3.Max(max, sourceOffset + Vector3.One); + return; + } + + foreach (Cuboidf selectionBox in selectionBoxes) + { + min = Vector3.Min(min, sourceOffset + selectionBox.Start.ToVector3()); + max = Vector3.Max(max, sourceOffset + selectionBox.End.ToVector3()); + } + } + + private static bool IsSameBlockPos(BlockPos a, BlockPos b) + { + return a.X == b.X && a.Y == b.Y && a.Z == b.Z; + } + + private bool TryResolveFacingNormal(Block block, BlockEntity blockEntity, out Vec3f forward, out Vec3f right, out Vec3f up) + { + forward = new Vec3f(0f, 0f, 1f); + right = new Vec3f(1f, 0f, 0f); + up = new Vec3f(0f, 1f, 0f); + + if (TryGetMeshAngle(blockEntity, out float meshAngleRadians)) + { + // MeshAngle is around Y axis in radians. + forward = new Vec3f(MathF.Sin(meshAngleRadians), 0f, MathF.Cos(meshAngleRadians)); + NormalizeFacing(ref forward); + BuildBasisFromForward(forward, out right, out up); + return true; + } + + if (TryGetBlockSideFacing(block, out forward)) + { + BuildBasisFromForward(forward, out right, out up); + return true; + } + + if (!TryGetPlayerFacing(blockEntity.Pos, out forward)) + return false; + + BuildBasisFromForward(forward, out right, out up); + return true; + } + + private bool TryGetPlayerFacing(BlockPos blockPos, out Vec3f facing) + { + facing = new Vec3f(0f, 0f, 1f); + + EntityPlayer? playerEntity = api.World.Player?.Entity; + if (playerEntity?.CameraPos == null) + return false; + + Vec3d cameraPos = playerEntity.CameraPos + playerEntity.LocalEyePos; + double dx = cameraPos.X - (blockPos.X + 0.5d); + double dz = cameraPos.Z - (blockPos.Z + 0.5d); + if (Math.Abs(dx) <= 0.0001d && Math.Abs(dz) <= 0.0001d) + return false; + + double absDx = Math.Abs(dx); + double absDz = Math.Abs(dz); + double diagonalTolerance = Math.Max(absDx, absDz) * 0.65d; + bool isDiagonalView = Math.Abs(absDx - absDz) <= diagonalTolerance; + if (isDiagonalView) + { + Vec3f facingX = dx >= 0d ? new Vec3f(1f, 0f, 0f) : new Vec3f(-1f, 0f, 0f); + Vec3f facingZ = dz >= 0d ? new Vec3f(0f, 0f, 1f) : new Vec3f(0f, 0f, -1f); + + int obstructionX = GetFacingObstructionScore(blockPos, facingX); + int obstructionZ = GetFacingObstructionScore(blockPos, facingZ); + if (obstructionX != obstructionZ) + { + facing = obstructionX < obstructionZ ? facingX : facingZ; + return true; + } + } + + facing = absDx >= absDz + ? (dx >= 0d ? new Vec3f(1f, 0f, 0f) : new Vec3f(-1f, 0f, 0f)) + : (dz >= 0d ? new Vec3f(0f, 0f, 1f) : new Vec3f(0f, 0f, -1f)); + + return true; + } + + private int GetFacingObstructionScore(BlockPos blockPos, Vec3f facing) + { + int offsetX = Math.Sign(facing.X); + int offsetZ = Math.Sign(facing.Z); + int score = 0; + + for (int yOffset = 0; yOffset <= 1; yOffset++) + { + BlockPos checkPos = new(blockPos.X + offsetX, blockPos.Y + yOffset, blockPos.Z + offsetZ); + if (IsSolidBlock(checkPos)) + score++; + } + + return score; + } + + private bool IsSolidBlock(BlockPos blockPos) + { + Block block = api.World.BlockAccessor.GetBlock(blockPos); + if (block.Id == 0) + return false; + + Cuboidf[]? collisionBoxes = block.GetCollisionBoxes(api.World.BlockAccessor, blockPos); + return collisionBoxes is { Length: > 0 }; + } + + private static bool TryGetBlockSideFacing(Block block, out Vec3f facing) + { + facing = new Vec3f(0f, 0f, 1f); + + string? side = block.Variant?["side"]; + if (string.IsNullOrWhiteSpace(side)) + return false; + + switch (side.ToLowerInvariant()) + { + case "north": + facing = new Vec3f(0f, 0f, -1f); + return true; + case "south": + facing = new Vec3f(0f, 0f, 1f); + return true; + case "east": + facing = new Vec3f(1f, 0f, 0f); + return true; + case "west": + facing = new Vec3f(-1f, 0f, 0f); + return true; + case "up": + facing = new Vec3f(0f, 1f, 0f); + return true; + case "down": + facing = new Vec3f(0f, -1f, 0f); + return true; + default: + return false; + } + } + + private static void BuildBasisFromForward(Vec3f forward, out Vec3f right, out Vec3f up) + { + up = new Vec3f(0f, 1f, 0f); + + if (Math.Abs(forward.Y) > 0.5f) + { + up = forward.Y > 0f ? new Vec3f(0f, 0f, -1f) : new Vec3f(0f, 0f, 1f); + right = new Vec3f(1f, 0f, 0f); + return; + } + + right = new Vec3f(-forward.Z, 0f, forward.X); + } + + private static void NormalizeFacing(ref Vec3f facing) + { + float length = MathF.Sqrt(facing.X * facing.X + facing.Y * facing.Y + facing.Z * facing.Z); + if (length <= 0.0001f) + { + facing = new Vec3f(0f, 0f, 1f); + return; + } + + float invLength = 1f / length; + facing = new Vec3f(facing.X * invLength, facing.Y * invLength, facing.Z * invLength); + } + + private static bool TryGetMeshAngle(BlockEntity blockEntity, out float radians) + { + if (blockEntity is BlockEntityGenericTypedContainer genericContainer) + { + radians = genericContainer.MeshAngle; + return true; + } + + if (blockEntity is BlockEntityCrate crate) + { + radians = crate.MeshAngle; + return true; + } + + radians = 0f; + return false; + } +} diff --git a/ChestPreview/ChestPreview/Rendering/WorldBillboardRenderer.cs b/ChestPreview/ChestPreview/Rendering/WorldBillboardRenderer.cs new file mode 100644 index 0000000..420c28b --- /dev/null +++ b/ChestPreview/ChestPreview/Rendering/WorldBillboardRenderer.cs @@ -0,0 +1,214 @@ +using ChestPreview.Configs; +using ChestPreview.Models; +using System; +using System.Collections.Generic; +using Vintagestory.API.Client; +using Vintagestory.API.Common; +using Vintagestory.API.MathTools; + +namespace ChestPreview.Rendering; + +internal class WorldBillboardRenderer(ICoreClientAPI api, Config config, CardRenderer cardRenderer) : IRenderer +{ + private static float card_cell_padding => 2f; + private static float card_cell_width => 72f; + private static float card_cell_gap => 4f; + private static float billboard_brightness => 1.18f; + + private readonly ICoreClientAPI api = api; + private readonly List frameTargets = []; + private readonly CardRenderer cardRenderer = cardRenderer; + private readonly PreviewTargetProvider targetProvider = new(api, config); + private readonly WorldBillboardPresenter worldBillboardPresenter = new(api); + private readonly MeshRef quadMeshRef = api.Render.UploadMesh( + QuadMeshUtil.GetCustomQuadModelData( + 1f, + v: 1f, + 0f, + v2: 0f, + dx: -1f, + dy: -1f, + dw: 2f, + dh: 2f, + r: byte.MaxValue, + g: byte.MaxValue, + b: byte.MaxValue, + a: byte.MaxValue + ) + ); + private bool disposed; + + public ICoreClientAPI Api { get; } = api; + + public Config Config { get; } = config; + + public double RenderOrder => 0.61; + + public int RenderRange => 24; + + public void OnRenderFrame(float deltaTime, EnumRenderStage stage) + { + if (disposed || stage != EnumRenderStage.AfterOIT) + return; + + frameTargets.Clear(); + targetProvider.CollectTargets(deltaTime, frameTargets); + worldBillboardPresenter.PrepareFrame(frameTargets); + + if (worldBillboardPresenter.FrameBillboards.Count == 0) + return; + + api.Render.GlDisableCullFace(); + api.Render.GLEnableDepthTest(); + api.Render.GlToggleBlend(true, EnumBlendMode.PremultipliedAlpha); + try + { + foreach (BillboardTarget billboardTarget in worldBillboardPresenter.FrameBillboards) + { + int maxColumns = GetBillboardMaxColumns(billboardTarget); + if (!cardRenderer.TryGetOrCreateCardTexture(billboardTarget.PreviewTarget, maxColumns, out LoadedTexture texture)) + continue; + + RenderBillboard(billboardTarget, texture, maxColumns); + } + } + finally + { + StopShader(api.Render.CurrentActiveShader); + api.Render.GlToggleBlend(false, EnumBlendMode.PremultipliedAlpha); + api.Render.GLDisableDepthTest(); + api.Render.GlEnableCullFace(); + } + } + + public void Dispose() + { + if (disposed) + return; + + disposed = true; + frameTargets.Clear(); + worldBillboardPresenter.Clear(); + targetProvider.Dispose(); + quadMeshRef.Dispose(); + } + + private void RenderBillboard(BillboardTarget billboardTarget, LoadedTexture texture, int maxColumns) + { + EntityPlayer? playerEntity = api.World.Player?.Entity; + if (playerEntity?.CameraPos == null) + return; + + float referenceWidthPx = GetCardWidthPx(maxColumns); + float widthScale = texture.Width / Math.Max(1f, referenceWidthPx); + float textureAspect = texture.Height / (float)System.Math.Max(1, texture.Width); + float scaledWidth; + float textureHeight; + Vec3d center; + + if (billboardTarget.IsFrontPlacement) + { + scaledWidth = billboardTarget.Width * widthScale; + textureHeight = scaledWidth * textureAspect; + + if (scaledWidth > billboardTarget.Width) + { + scaledWidth = billboardTarget.Width; + textureHeight = scaledWidth * textureAspect; + } + + if (textureHeight > billboardTarget.Height) + { + textureHeight = billboardTarget.Height; + scaledWidth = textureHeight / Math.Max(0.0001f, textureAspect); + } + + center = billboardTarget.Center; + } + else + { + scaledWidth = billboardTarget.Width * widthScale; + textureHeight = scaledWidth * textureAspect; + center = new Vec3d( + billboardTarget.PreviewTarget.Anchor.X, + billboardTarget.PreviewTarget.Anchor.Y + textureHeight / 2f, + billboardTarget.PreviewTarget.Anchor.Z + ); + } + + float[] modelMatrix = CreateModelMatrix(billboardTarget, center, scaledWidth, textureHeight, playerEntity.CameraPos); + float brightness = billboard_brightness; + IStandardShaderProgram shader = api.Render.PreparedStandardShader( + billboardTarget.PreviewTarget.BlockEntity.Pos.X, + billboardTarget.PreviewTarget.BlockEntity.Pos.Y, + billboardTarget.PreviewTarget.BlockEntity.Pos.Z, + new Vec4f(brightness, brightness, brightness, 1f) + ); + + shader.Tex2D = texture.TextureId; + shader.AlphaTest = 0.01f; + shader.RgbaAmbientIn = new Vec3f(brightness, brightness, brightness); + shader.RgbaLightIn = new Vec4f(brightness, brightness, brightness, 1f); + shader.RgbaGlowIn = new Vec4f(0f, 0f, 0f, 0f); + shader.RgbaTint = new Vec4f(brightness, brightness, brightness, 1f); + shader.ModelMatrix = modelMatrix; + shader.ViewMatrix = api.Render.CameraMatrixOriginf; + shader.ProjectionMatrix = api.Render.CurrentProjectionMatrix; + + api.Render.BindTexture2d(texture.TextureId); + api.Render.RenderMesh(quadMeshRef); + } + + private static void StopShader(IShaderProgram? shader) + { + if (shader == null) + return; + + try + { + shader.Stop(); + } + catch + { + } + } + + private static float GetCardWidthPx(int columns) + { + int safeColumns = Math.Max(1, columns); + return card_cell_padding * 2f + safeColumns * card_cell_width + (safeColumns - 1) * card_cell_gap; + } + + private int GetBillboardMaxColumns(BillboardTarget billboardTarget) + { + int columnsPerBlock = Math.Max(1, Config.BillboardColumnsPerBlock <= 0 ? 1 : Config.BillboardColumnsPerBlock); + int blockWidth = Math.Max(1, (int)MathF.Round(billboardTarget.Width)); + return columnsPerBlock * blockWidth; + } + + private static float[] CreateModelMatrix(BillboardTarget billboardTarget, Vec3d center, float width, float height, Vec3d cameraPos) + { + float halfWidth = width / 2f; + float halfHeight = height / 2f; + + return + [ + billboardTarget.Right.X * halfWidth, + billboardTarget.Right.Y * halfWidth, + billboardTarget.Right.Z * halfWidth, + 0f, + billboardTarget.Up.X * halfHeight, + billboardTarget.Up.Y * halfHeight, + billboardTarget.Up.Z * halfHeight, + 0f, + billboardTarget.Forward.X, + billboardTarget.Forward.Y, + billboardTarget.Forward.Z, + 0f, + (float)(center.X - cameraPos.X), + (float)(center.Y - cameraPos.Y), + (float)(center.Z - cameraPos.Z), + 1f + ]; + } +} diff --git a/ChestPreview/ChestPreview/Utils/MathUtils.cs b/ChestPreview/ChestPreview/Utils/MathUtils.cs new file mode 100644 index 0000000..9368c31 --- /dev/null +++ b/ChestPreview/ChestPreview/Utils/MathUtils.cs @@ -0,0 +1,22 @@ +using System.Numerics; +using Vintagestory.API.MathTools; + +namespace ChestPreview.Utils; + +internal static class MathUtils +{ + internal static Vector3 ToVector3(this BlockPos blockPos) + { + return new Vector3(blockPos.X, blockPos.Y, blockPos.Z); + } + + internal static Vector3 ToVector3(this Vec3f vector) + { + return new Vector3(vector.X, vector.Y, vector.Z); + } + + internal static Vec3d ToVec3d(this Vector3 vector) + { + return new Vec3d(vector.X, vector.Y, vector.Z); + } +} diff --git a/ChestPreview/ChestPreview/assets/chestpreview/config/configlib-patches.json b/ChestPreview/ChestPreview/assets/chestpreview/config/configlib-patches.json new file mode 100644 index 0000000..f9f7665 --- /dev/null +++ b/ChestPreview/ChestPreview/assets/chestpreview/config/configlib-patches.json @@ -0,0 +1,86 @@ +{ + "version": 1, + "file": "chestpreview.json", + "patches": {}, + "settings": [ + { + "type": "separator", + "title": "Client-side" + }, + { + "code": "Mode", + "comment": "config-desc-Mode", + "type": "string", + "default": "UnderCursor", + "values": [ + "None", + "UnderCursor", + "OnHoveredContainer", + "OnNearbyContainers" + ], + "clientSide": true + }, + { + "code": "HoldKey", + "comment": "config-desc-HoldKey", + "type": "boolean", + "default": true, + "clientSide": true + }, + { + "code": "BillboardColumnsPerBlock", + "comment": "config-desc-BillboardColumnsPerBlock", + "type": "int", + "default": 4, + "clientSide": true, + "range": { + "min": 2, + "max": 20 + } + }, + { + "code": "ColumnsUnderCursor", + "comment": "config-desc-ColumnsUnderCursor", + "type": "int", + "default": 10, + "clientSide": true, + "range": { + "min": 2, + "max": 20 + } + }, + { + "code": "PreviewNearbyRadius", + "comment": "config-desc-PreviewNearbyRadius", + "type": "int", + "default": 8, + "clientSide": true, + "logarithmic": true, + "range": { + "min": 3, + "max": 30 + } + }, + { + "code": "WhitelistedContainersOnly", + "comment": "config-desc-WhitelistedContainersOnly", + "type": "boolean", + "default": true, + "clientSide": true + }, + { + "code": "WhitelistedContainers", + "comment": "config-desc-WhitelistedContainers", + "type": "string", + "default": "barrel, crate, chest-*, trunk-*, storagevessel-*, stationarybasket-*, labeledchest-*", + "clientSide": true + }, + { + "code": "BlacklistedContainers", + "comment": "config-desc-BlacklistedContainers", + "type": "string", + "default": "", + "clientSide": true + } + ] +} \ No newline at end of file diff --git a/ChestPreview/ChestPreview/assets/chestpreview/lang/en.json b/ChestPreview/ChestPreview/assets/chestpreview/lang/en.json new file mode 100644 index 0000000..d33bc72 --- /dev/null +++ b/ChestPreview/ChestPreview/assets/chestpreview/lang/en.json @@ -0,0 +1,12 @@ +{ + "hotkey-preview-container": "Preview container", + "hotkey-preview-containers-nearby": "Preview containers nearby", + "config-desc-Mode": "Preview mode. Valid values: None, UnderCursor, OnHoveredContainer, OnNearbyContainers.", + "config-desc-HoldKey": "Previews only show while the \"Preview containers\" key is held.", + "config-desc-BillboardColumnsPerBlock": "Columns per block width used for world billboards. For containers 2 blocks wide it's doubled.", + "config-desc-ColumnsUnderCursor": "Columns in the UnderCursor mode.", + "config-desc-PreviewNearbyRadius": "Radius for \"Preview containers nearby\".", + "config-desc-WhitelistedContainersOnly": "If true, only container codes in WhitelistedContainers are handled.", + "config-desc-WhitelistedContainers": "Allowed container codes, separated by comma, semicolon, or spaces. No effectif WhitelistedContainersOnly==false.", + "config-desc-BlacklistedContainers": "Blocked container codes, separated by comma, semicolon, or spaces." +} \ No newline at end of file diff --git a/ChestPreview/ChestPreview/modinfo.json b/ChestPreview/ChestPreview/modinfo.json new file mode 100644 index 0000000..07a69f6 --- /dev/null +++ b/ChestPreview/ChestPreview/modinfo.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://moddbcdn.vintagestory.at/schema/modinfo.latest.json", + "type": "Code", + "modid": "chestpreview", + "name": "Chest Preview", + "authors": [ + "OrekiWoof" + ], + "description": "see containers' contents without having to open them", + "version": "1.0.0-pre.1", + "dependencies": { + "game": "1.21.0" + }, + "side": "Client" +} \ No newline at end of file diff --git a/ChestPreview/ZZCakeBuild/CakeBuild.csproj b/ChestPreview/ZZCakeBuild/CakeBuild.csproj new file mode 100644 index 0000000..178e80b --- /dev/null +++ b/ChestPreview/ZZCakeBuild/CakeBuild.csproj @@ -0,0 +1,20 @@ + + + Exe + net8.0 + $(MSBuildProjectDirectory) + Debug;Release;Version22 + + + + + + + + + + + $(VINTAGE_STORY)/VintagestoryAPI.dll + + + \ No newline at end of file diff --git a/ChestPreview/ZZCakeBuild/Program.cs b/ChestPreview/ZZCakeBuild/Program.cs new file mode 100644 index 0000000..b336022 --- /dev/null +++ b/ChestPreview/ZZCakeBuild/Program.cs @@ -0,0 +1,122 @@ +using Cake.Common; +using Cake.Common.IO; +using Cake.Common.Tools.DotNet; +using Cake.Common.Tools.DotNet.Clean; +using Cake.Common.Tools.DotNet.Publish; +using Cake.Core; +using Cake.Frosting; +using Cake.Json; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; +using System.IO; +using System.Linq; +using Vintagestory.API.Common; + +namespace CakeBuild; + +public static class Program +{ + public static int Main(string[] args) + { + return new CakeHost() + .UseContext() + .Run(args); + } +} + +public class BuildContext : FrostingContext +{ + public const string PROJECT_NAME = "ChestPreview"; + public string BuildConfiguration { get; } + public string Version { get; } + public string Name { get; } + public bool SkipJsonValidation { get; } + public string GameVersion { get; } + + public BuildContext(ICakeContext context) + : base(context) + { + BuildConfiguration = context.Argument("configuration", "Release"); + SkipJsonValidation = context.Argument("skipJsonValidation", false); + var modInfo = context.DeserializeJsonFromFile($"../{PROJECT_NAME}/modinfo.json"); + Version = modInfo.Version; + Name = modInfo.ModID; + GameVersion = modInfo.Dependencies.First(x => x.ModID == "game").Version; + } +} + +[TaskName("ValidateJson")] +public sealed class ValidateJsonTask : FrostingTask +{ + public override void Run(BuildContext context) + { + if (context.SkipJsonValidation) + { + return; + } + var jsonFiles = context.GetFiles($"../{BuildContext.PROJECT_NAME}/assets/**/*.json"); + foreach (var file in jsonFiles) + { + try + { + var json = File.ReadAllText(file.FullPath); + JToken.Parse(json); + } + catch (JsonException ex) + { + throw new Exception($"Validation failed for JSON file: {file.FullPath}{Environment.NewLine}{ex.Message}", ex); + } + } + } +} + +[TaskName("Build")] +[IsDependentOn(typeof(ValidateJsonTask))] +public sealed class BuildTask : FrostingTask +{ + public override void Run(BuildContext context) + { + context.DotNetClean($"../{BuildContext.PROJECT_NAME}/{BuildContext.PROJECT_NAME}.csproj", + new DotNetCleanSettings + { + Configuration = context.BuildConfiguration + }); + + + context.DotNetPublish($"../{BuildContext.PROJECT_NAME}/{BuildContext.PROJECT_NAME}.csproj", + new DotNetPublishSettings + { + Configuration = context.BuildConfiguration + }); + } +} + +[TaskName("Package")] +[IsDependentOn(typeof(BuildTask))] +public sealed class PackageTask : FrostingTask +{ + public override void Run(BuildContext context) + { + context.EnsureDirectoryExists("../Releases"); + context.CleanDirectory("../Releases"); + context.EnsureDirectoryExists($"../Releases/{context.Name}"); + context.CopyFiles($"../{BuildContext.PROJECT_NAME}/bin/{context.BuildConfiguration}/Mods/mod/publish/*", $"../Releases/{context.Name}"); + if (context.DirectoryExists($"../{BuildContext.PROJECT_NAME}/assets")) + { + context.CopyDirectory($"../{BuildContext.PROJECT_NAME}/assets", $"../Releases/{context.Name}/assets"); + } + context.CopyFile($"../{BuildContext.PROJECT_NAME}/modinfo.json", $"../Releases/{context.Name}/modinfo.json"); + if (context.FileExists($"../{BuildContext.PROJECT_NAME}/modicon.png")) + { + context.CopyFile($"../{BuildContext.PROJECT_NAME}/modicon.png", $"../Releases/{context.Name}/modicon.png"); + } + context.Zip($"../Releases/{context.Name}", $"../Releases/{context.Name}_v{context.Version}-v{context.GameVersion}.zip"); + } +} + +[TaskName("Default")] +[IsDependentOn(typeof(PackageTask))] +public class DefaultTask : FrostingTask +{ +} \ No newline at end of file diff --git a/ChestPreview/build.ps1 b/ChestPreview/build.ps1 new file mode 100644 index 0000000..a2ee10a --- /dev/null +++ b/ChestPreview/build.ps1 @@ -0,0 +1,2 @@ +dotnet run --project ZZCakeBuild/CakeBuild.csproj -- $args +exit $LASTEXITCODE; \ No newline at end of file diff --git a/ChestPreview/build.sh b/ChestPreview/build.sh new file mode 100644 index 0000000..1a40ae7 --- /dev/null +++ b/ChestPreview/build.sh @@ -0,0 +1 @@ +dotnet run --project ./ZZCakeBuild/CakeBuild.csproj -- "$@"