Initial sanctuary sources

This commit is contained in:
cnstiout
2026-07-04 20:27:08 +02:00
commit c796a20321
1582 changed files with 253264 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
.gradle/
build/
run/
*.class
*.log
*.tmp
*.bak
.DS_Store
.idea/
*.iml
out/
+165
View File
@@ -0,0 +1,165 @@
plugins {
id "java"
id "maven-publish"
id "net.fabricmc.fabric-loom" version "${loom_version}"
}
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
version = project.mod_version
group = project.maven_group
base {
archivesName = project.archives_base_name
}
loom {
splitEnvironmentSourceSets()
runs {
client {
programArgs "--username", "BlocodexDev", "--uuid", "000000000000400080000000000b10cd"
}
}
mods {
blocodex {
sourceSet sourceSets.main
sourceSet sourceSets.client
}
}
}
dependencies {
minecraft "com.mojang:minecraft:${project.minecraft_version}"
implementation "net.fabricmc:fabric-loader:${project.loader_version}"
implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}"
testImplementation "org.junit.jupiter:junit-jupiter-api:5.10.3"
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.10.3"
testRuntimeOnly "org.junit.platform:junit-platform-launcher"
}
processResources {
inputs.property "version", project.version
filesMatching("fabric.mod.json") {
expand "version": inputs.properties.version
}
}
tasks.withType(JavaCompile).configureEach {
options.release = 25
}
java {
withSourcesJar()
sourceCompatibility = JavaVersion.VERSION_25
targetCompatibility = JavaVersion.VERSION_25
}
jar {
from("license.txt")
}
test {
useJUnitPlatform()
}
tasks.register("importWsmcSnapshot") {
group = "blocodex"
description = "Import the local WSMC snapshot into a compact runtime asset."
def sourceFile = file("/Users/koka/Documents/wsmc/data/output/latest/snapshot.json")
def outputFile = file("src/main/resources/assets/blocodex/data/wsmc_snapshot_compact.json")
inputs.file(sourceFile)
outputs.file(outputFile)
doLast {
if (!sourceFile.isFile()) {
throw new GradleException("WSMC snapshot not found: ${sourceFile}")
}
def snapshot = new JsonSlurper().parse(sourceFile)
def entries = (snapshot.blocks ?: [])
.findAll { it.visible == true && ["block", "item", "mob"].contains(it.entry_type) }
.collect { entry ->
[
id : entry.id,
name : entry.name,
entry_type : entry.entry_type,
description: entry.description ?: "",
history : (entry.history ?: []).collect { note ->
[
version: note.version ?: "",
status : note.status ?: "",
notes : note.notes ?: ""
]
},
colors : [
primary : entry.colors?.primary ?: entry.colors?.average_hex ?: entry.colors?.dominant_hex ?: "#000000",
dominant_hex : entry.colors?.dominant_hex ?: entry.colors?.primary ?: "#000000",
average_hex : entry.colors?.average_hex ?: entry.colors?.primary ?: "#000000",
hue_group : entry.colors?.hue_group ?: "neutral",
lightness : entry.colors?.lightness ?: 0.0,
saturation : entry.colors?.saturation ?: 0.0,
palette : entry.colors?.palette ?: [],
color_groups : (entry.colors?.color_groups ?: []).collect { group ->
[
hex : group.hex ?: "#000000",
weight : group.weight ?: 0.0,
lightness : group.lightness ?: 0.0,
saturation: group.saturation ?: 0.0,
hue_group : group.hue_group ?: "neutral"
]
},
source : entry.colors?.source ?: ""
],
icon : entry.icon ?: "",
family : entry.family ?: "",
relations : (entry.relations ?: []).collect { relation ->
[
type : relation.type ?: "",
target : relation.target ?: "",
label : relation.label ?: "",
confidence: relation.confidence ?: 0.0
]
},
category : entry.category ?: ""
]
}
.sort { a, b -> a.id <=> b.id }
def counts = entries.countBy { it.entry_type }
def compact = [
kind : "blocodex-wsmc-compact",
schema_version : "0.1.0",
minecraft_version: snapshot.minecraft_version ?: project.minecraft_version,
source : sourceFile.absolutePath,
source_fingerprint: snapshot.source_fingerprint ?: "",
generated_at : snapshot.generated_at ?: "",
entry_count : entries.size(),
entry_type_counts: [
block: counts.block ?: 0,
item : counts.item ?: 0,
mob : counts.mob ?: 0
],
entries : entries
]
outputFile.parentFile.mkdirs()
outputFile.text = JsonOutput.toJson(compact) + "\n"
logger.lifecycle("Imported ${entries.size()} WSMC entries into ${outputFile}")
}
}
publishing {
publications {
create("mavenJava", MavenPublication) {
artifactId = project.archives_base_name
from components.java
}
}
}
+54
View File
@@ -0,0 +1,54 @@
# Blocodex GUI Textures
Editable PNG assets live in:
`src/main/resources/assets/blocodex/textures/gui/`
The UI uses these files directly. Edit the PNGs, restart or reload the client resources, and the Blocodex screen will pick up the new look.
## Active Textures
| File | Size | Slice | Used for |
| --- | ---: | ---: | --- |
| `device_panel.png` | 32x32 | 8 px | Main Blocodex body nine-slice |
| `screen_frame.png` | 32x32 | 8 px | Left screen and right grid frame nine-slice |
| `button_square.png` | 24x24 | 6 px | Menu button nine-slice |
| `button_wide.png` | 34x24 | 6 px | Left/right button nine-slice |
| `slider_well.png` | 20x32 | L/R 6 px, T/B 8 px | Vertical slider well nine-slice |
| `slider_knob.png` | 18x12 | none | Slider handle |
| `vent_slots.png` | 96x24 | none | Speaker/vent ornament |
| `status_light.png` | 12x12 | none | Small top-left light |
| `blocodex_wordmark.png` | 80x16 | none | Top wordmark |
| `icon_menu.png` | 16x16 | none | Menu icon |
| `icon_arrow_left.png` | 16x16 | none | Previous icon |
| `icon_arrow_right.png` | 16x16 | none | Next icon |
| `grid_overlay_9x9.png` | 144x144 | none | Thin 9x9 grid overlay |
| `selection_frame.png` | 16x16 | none | Selected grid cell frame |
| `bottom_panel.png` | 32x24 | 6 px | Hidden debug/nav panel, kept for later |
`_gui_texture_preview.png` is only a contact sheet for quick visual checks.
## Nine-Slice Rules
For nine-slice textures, keep the corners intact. Paint borders freely, but avoid putting unique details in the center if they should not stretch.
- `device_panel.png`: corners and borders are 8 px.
- `screen_frame.png`: corners and borders are 8 px.
- `button_square.png` and `button_wide.png`: corners and borders are 6 px.
- `slider_well.png`: left/right borders are 6 px; top/bottom borders are 8 px.
- `bottom_panel.png`: corners and borders are 6 px.
## Regeneration
The seed drawings come from:
`tools/GenerateGuiTextures.java`
Run this only when you want to overwrite the PNGs with the generated base set:
```sh
javac -d /tmp/blocodex-gui-tools tools/GenerateGuiTextures.java
java -cp /tmp/blocodex-gui-tools GenerateGuiTextures
```
If you hand-paint the PNGs, do not run the generator afterward unless you want to reset them.
+12
View File
@@ -0,0 +1,12 @@
org.gradle.jvmargs=-Xmx2G
org.gradle.parallel=true
org.gradle.configuration-cache=false
minecraft_version=26.1.2
loader_version=0.19.2
loom_version=1.16-SNAPSHOT
fabric_api_version=0.148.0+26.1.2
mod_version=1.0.0
maven_group=dev.blocodex
archives_base_name=blocodex
Binary file not shown.
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+93
View File
@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+15
View File
@@ -0,0 +1,15 @@
Hello World is released as a layered project.
Original source code is licensed under LGPL-3.0-or-later.
Original documentation, writing, website content, concepts, configuration curation,
and non-code creative assets are licensed under CC BY-NC-SA 4.0,
unless otherwise stated.
The Hello World name, logo, visual identity, and project marks are not licensed
under the above licenses. They may be used only to identify, discuss, review,
or link to the original project, and not to imply endorsement or publish a
confusing derivative project.
Minecraft, Mojang, Microsoft, and all Minecraft assets remain the property of
their respective owners. Third-party mods remain under their own licenses.
+12
View File
@@ -0,0 +1,12 @@
pluginManagement {
repositories {
maven {
name = "Fabric"
url = "https://maven.fabricmc.net/"
}
mavenCentral()
gradlePluginPortal()
}
}
rootProject.name = "blocodex-java"
@@ -0,0 +1,153 @@
package dev.blocodex.client;
import com.mojang.blaze3d.platform.InputConstants;
import dev.blocodex.client.gui.BlocodexComputerScreen;
import dev.blocodex.client.gui.BlocodexConstructionScreen;
import dev.blocodex.client.gui.BlocodexScreen;
import dev.blocodex.client.gui.BlocodexStudioScreen;
import dev.blocodex.client.voxel.BlocodexBuildPlacementController;
import dev.blocodex.network.BlocodexOpenComputerPayload;
import dev.blocodex.network.BlocodexStudioPalettePayload;
import dev.blocodex.network.BlocodexUiSnapshotPayload;
import dev.blocodex.network.BlocodexVoxelProjectPayload;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.keymapping.v1.KeyMappingHelper;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.fabricmc.fabric.api.event.player.AttackBlockCallback;
import net.fabricmc.fabric.api.event.player.UseBlockCallback;
import net.fabricmc.fabric.api.event.player.UseItemCallback;
import net.minecraft.client.KeyMapping;
import net.minecraft.client.Minecraft;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.world.InteractionResult;
import org.lwjgl.glfw.GLFW;
public final class BlocodexClient implements ClientModInitializer {
private static KeyMapping openKey;
private static KeyMapping constructionKey;
@Override
public void onInitializeClient() {
openKey = KeyMappingHelper.registerKeyMapping(new KeyMapping(
"key.blocodex.open",
InputConstants.Type.KEYSYM,
GLFW.GLFW_KEY_B,
KeyMapping.Category.INVENTORY
));
constructionKey = KeyMappingHelper.registerKeyMapping(new KeyMapping(
"key.blocodex.construction",
InputConstants.Type.KEYSYM,
GLFW.GLFW_KEY_C,
KeyMapping.Category.INVENTORY
));
BlocodexBuildPlacementController.initialize();
ClientPlayNetworking.registerGlobalReceiver(BlocodexUiSnapshotPayload.TYPE, (payload, context) ->
context.client().execute(() -> {
if (context.client().screen instanceof BlocodexScreen screen) {
screen.acceptSnapshot(payload);
} else if (context.client().screen instanceof BlocodexComputerScreen screen) {
screen.acceptSnapshot(payload);
} else if (context.client().screen instanceof BlocodexConstructionScreen screen) {
screen.acceptSnapshot(payload);
}
}));
ClientPlayNetworking.registerGlobalReceiver(BlocodexVoxelProjectPayload.TYPE, (payload, context) ->
context.client().execute(() -> {
if (context.client().screen instanceof BlocodexComputerScreen screen) {
screen.acceptProject(payload.project());
} else if (context.client().screen instanceof BlocodexConstructionScreen screen) {
screen.acceptProject(payload.project());
}
}));
ClientPlayNetworking.registerGlobalReceiver(BlocodexStudioPalettePayload.TYPE, (payload, context) ->
context.client().execute(() -> {
if (context.client().screen instanceof BlocodexStudioScreen screen) {
screen.acceptPalette(payload);
}
}));
ClientPlayNetworking.registerGlobalReceiver(BlocodexOpenComputerPayload.TYPE, (payload, context) ->
context.client().execute(() -> context.client().setScreen(new BlocodexComputerScreen())));
ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> BlocodexBuildPlacementController.reset());
AttackBlockCallback.EVENT.register((player, world, hand, pos, direction) -> {
if (world.isClientSide()) {
return BlocodexBuildPlacementController.confirmFromAttack(pos, direction);
}
return InteractionResult.PASS;
});
UseBlockCallback.EVENT.register((player, world, hand, hitResult) -> {
if (world.isClientSide()) {
return BlocodexBuildPlacementController.confirmFromUse(hitResult);
}
return InteractionResult.PASS;
});
UseItemCallback.EVENT.register((player, world, hand) -> {
if (world.isClientSide()) {
return BlocodexBuildPlacementController.confirmCurrentAnchor();
}
return InteractionResult.PASS;
});
ClientTickEvents.END_CLIENT_TICK.register(client -> {
BlocodexBuildPlacementController.onEndTick(client);
if (client.screen instanceof BlocodexScreen || client.screen instanceof BlocodexComputerScreen || client.screen instanceof BlocodexConstructionScreen || client.screen instanceof BlocodexStudioScreen) {
while (openKey.consumeClick()) {
// Closing is handled by the screen key event so the same key press cannot reopen it.
}
while (constructionKey.consumeClick()) {
// Closing is handled by the screen key event so the same key press cannot reopen it.
}
return;
}
while (openKey.consumeClick()) {
toggleScreen(client);
}
while (constructionKey.consumeClick()) {
if (BlocodexBuildPlacementController.isConstructionActive()) {
BlocodexBuildPlacementController.togglePreviewVisibility(client);
} else {
toggleConstructionScreen(client);
}
}
});
}
public static boolean matchesOpenKey(KeyEvent event) {
return openKey != null && openKey.matches(event);
}
public static boolean matchesConstructionKey(KeyEvent event) {
return constructionKey != null && constructionKey.matches(event);
}
private static void toggleScreen(Minecraft client) {
if (client.screen instanceof BlocodexScreen) {
client.setScreen(null);
return;
}
if (client.screen == null && client.player != null) {
client.setScreen(new BlocodexScreen());
}
}
private static void toggleConstructionScreen(Minecraft client) {
if (client.screen instanceof BlocodexConstructionScreen) {
client.setScreen(null);
return;
}
if (client.screen == null && client.player != null) {
client.setScreen(new BlocodexConstructionScreen());
}
}
}
@@ -0,0 +1,499 @@
package dev.blocodex.client.gui;
import dev.blocodex.client.BlocodexClient;
import dev.blocodex.client.voxel.BlocodexBuildPlacementController;
import dev.blocodex.memory.VoxelProjectMemory;
import dev.blocodex.network.BlocodexUiMode;
import dev.blocodex.network.BlocodexUiRequestPayload;
import dev.blocodex.network.BlocodexUiSnapshotPayload;
import dev.blocodex.network.BlocodexVoxelProjectDeletePayload;
import dev.blocodex.network.BlocodexVoxelProjectRequestPayload;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.client.input.MouseButtonEvent;
import net.minecraft.network.chat.Component;
import org.lwjgl.glfw.GLFW;
import java.util.ArrayList;
import java.util.List;
public final class BlocodexConstructionScreen extends Screen {
private static final int OVERLAY_COLOR = 0x66000000;
private static final int PANEL_COLOR = 0x55000000;
private static final int PANEL_ALT_COLOR = 0x33101010;
private static final int PANEL_BORDER = 0x668C8C8C;
private static final int TEXT_COLOR = 0xFFFFFFFF;
private static final int MUTED_COLOR = 0xFFC8C8C8;
private static final int SELECTED_COLOR = 0x886A6A6A;
private static final int LINE_HEIGHT = 12;
private static final int FOOTER_HEIGHT = 42;
private BlocodexUiSnapshotPayload snapshot = BlocodexUiSnapshotPayload.empty();
private int selectedIndex = 0;
private int scrollOffset = 0;
private int requestCooldown = 0;
private PendingProjectAction pendingProjectAction = PendingProjectAction.NONE;
public BlocodexConstructionScreen() {
super(Component.literal("Galerie voxel"));
}
public void acceptSnapshot(BlocodexUiSnapshotPayload payload) {
if (BlocodexUiMode.byId(payload.mode()) != BlocodexUiMode.CREATIONS) {
return;
}
snapshot = payload;
selectedIndex = payload.selectedIndex();
scrollOffset = Math.min(scrollOffset, maxScroll());
}
public void acceptProject(VoxelProjectMemory project) {
if (pendingProjectAction == PendingProjectAction.BUILD) {
minecraft.setScreen(null);
if (minecraft.player != null && minecraft.player.isCreative()) {
BlocodexBuildPlacementController.startCreativeDirect(project);
} else {
BlocodexBuildPlacementController.startBlueprint(project);
}
} else if (pendingProjectAction == PendingProjectAction.BLUEPRINT) {
minecraft.setScreen(null);
BlocodexBuildPlacementController.startBlueprint(project);
} else if (pendingProjectAction == PendingProjectAction.CREATIVE_DIRECT) {
minecraft.setScreen(null);
BlocodexBuildPlacementController.startCreativeDirect(project);
}
pendingProjectAction = PendingProjectAction.NONE;
}
@Override
protected void init() {
int y = height - 28;
int center = width / 2;
addRenderableWidget(Button.builder(Component.literal("Construire"), ignored -> requestProject(PendingProjectAction.BUILD))
.bounds(center - 108, y, 102, 20)
.build());
addRenderableWidget(Button.builder(Component.literal("Supprimer"), ignored -> deleteSelectedProject())
.bounds(center - 2, y, 102, 20)
.build());
addRenderableWidget(Button.builder(Component.literal("Termine"), ignored -> minecraft.setScreen(null))
.bounds(center + 104, y, 102, 20)
.build());
requestSnapshot();
}
@Override
public void tick() {
if (requestCooldown > 0) {
requestCooldown--;
}
if (requestCooldown == 0) {
requestCooldown = 40;
requestSnapshot();
}
}
@Override
public boolean isPauseScreen() {
return false;
}
@Override
public boolean keyPressed(KeyEvent event) {
if (BlocodexClient.matchesConstructionKey(event)) {
minecraft.setScreen(null);
return true;
}
if (event.key() == GLFW.GLFW_KEY_UP) {
selectProject(-1);
return true;
}
if (event.key() == GLFW.GLFW_KEY_DOWN) {
selectProject(1);
return true;
}
if (event.key() == GLFW.GLFW_KEY_PAGE_UP) {
selectProject(-visibleRows(listArea()));
return true;
}
if (event.key() == GLFW.GLFW_KEY_PAGE_DOWN) {
selectProject(visibleRows(listArea()));
return true;
}
if (event.key() == GLFW.GLFW_KEY_HOME) {
selectProject(-selectedIndex);
return true;
}
if (event.key() == GLFW.GLFW_KEY_END) {
selectProject(entryLines(snapshot.leftLines()).size() - selectedIndex - 1);
return true;
}
return super.keyPressed(event);
}
@Override
public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
if (super.mouseClicked(event, doubleClick)) {
return true;
}
if (!insideList(event.x(), event.y())) {
return false;
}
int row = ((int) event.y() - listArea().y() - 6) / LINE_HEIGHT;
if (row < 0) {
return false;
}
List<String> entries = entryLines(snapshot.leftLines());
int lineIndex = scrollOffset + row;
if (lineIndex >= entries.size()) {
return false;
}
Integer entryIndex = entryIndex(entries.get(lineIndex));
if (entryIndex != null) {
selectedIndex = entryIndex;
requestSnapshot();
return true;
}
return false;
}
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
if (!insideList(mouseX, mouseY)) {
return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
}
int direction = scrollY < 0 ? 1 : -1;
scrollOffset = Math.max(0, Math.min(maxScroll(), scrollOffset + direction * 3));
return true;
}
@Override
public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
graphics.fill(0, 0, width, height, OVERLAY_COLOR);
graphics.centeredText(font, title, width / 2, 13, TEXT_COLOR);
drawContent(graphics);
super.extractRenderState(graphics, mouseX, mouseY, partialTick);
}
private void requestProject(PendingProjectAction action) {
String projectId = snapshot.selectedBlockId();
if (projectId == null || projectId.isBlank()) {
if (minecraft != null && minecraft.player != null) {
minecraft.player.sendOverlayMessage(Component.literal("Aucun projet selectionne."));
}
return;
}
pendingProjectAction = action;
if (ClientPlayNetworking.canSend(BlocodexVoxelProjectRequestPayload.TYPE)) {
ClientPlayNetworking.send(new BlocodexVoxelProjectRequestPayload(projectId));
}
}
private void deleteSelectedProject() {
String projectId = snapshot.selectedBlockId();
if (projectId == null || projectId.isBlank()) {
if (minecraft != null && minecraft.player != null) {
minecraft.player.sendOverlayMessage(Component.literal("Aucun projet selectionne."));
}
return;
}
if (ClientPlayNetworking.canSend(BlocodexVoxelProjectDeletePayload.TYPE)) {
ClientPlayNetworking.send(new BlocodexVoxelProjectDeletePayload(projectId));
selectedIndex = Math.max(0, selectedIndex - 1);
requestCooldown = 0;
requestSnapshot();
}
}
private void requestSnapshot() {
try {
if (ClientPlayNetworking.canSend(BlocodexUiRequestPayload.TYPE)) {
ClientPlayNetworking.send(new BlocodexUiRequestPayload(BlocodexUiMode.CREATIONS.id(), 0, 0, selectedIndex, ""));
}
} catch (IllegalStateException ignored) {
// The server networking channel can briefly be unavailable while the screen opens.
}
}
private void selectProject(int delta) {
List<String> entries = entryLines(snapshot.leftLines());
if (entries.isEmpty()) {
selectedIndex = 0;
scrollOffset = 0;
return;
}
selectedIndex = Math.max(0, Math.min(entries.size() - 1, selectedIndex + delta));
ensureSelectedVisible(entries.size());
requestSnapshot();
}
private void ensureSelectedVisible(int entryCount) {
int rows = visibleRows(listArea());
if (selectedIndex < scrollOffset) {
scrollOffset = selectedIndex;
} else if (selectedIndex >= scrollOffset + rows) {
scrollOffset = selectedIndex - rows + 1;
}
scrollOffset = Math.max(0, Math.min(Math.max(0, entryCount - rows), scrollOffset));
}
private void drawContent(GuiGraphicsExtractor graphics) {
Rect list = listPanel();
Rect detail = detailPanel();
drawPanel(graphics, list);
drawPanel(graphics, detail);
drawEntryList(graphics, listArea(), entryLines(snapshot.leftLines()));
drawDetails(graphics, detail, detailLines(snapshot.leftLines()));
}
private void drawEntryList(GuiGraphicsExtractor graphics, Rect area, List<String> entries) {
scrollOffset = Math.max(0, Math.min(maxScroll(), scrollOffset));
graphics.enableScissor(area.x(), area.y(), area.right(), area.bottom());
int rows = visibleRows(area);
for (int row = 0; row < rows; row++) {
int index = scrollOffset + row;
if (index >= entries.size()) {
break;
}
int y = area.y() + 6 + row * LINE_HEIGHT;
drawEntryLine(graphics, entries.get(index), area.x() + 8, area.right() - 8, y);
}
graphics.disableScissor();
drawScrollbar(graphics, entries.size(), area.x(), area.right(), area.y(), area.bottom(), rows, scrollOffset);
}
private void drawEntryLine(GuiGraphicsExtractor graphics, String rawLine, int left, int right, int y) {
String[] parts = rawLine.substring("ENTRY:".length()).split("\\|", 4);
if (parts.length < 4) {
graphics.text(font, trim(rawLine, right - left), left, y, MUTED_COLOR, false);
return;
}
int entryIndex = parseInt(parts[0], -1);
if (entryIndex == selectedIndex) {
graphics.fill(left - 4, y - 2, right + 4, y + LINE_HEIGHT - 1, SELECTED_COLOR);
} else if (entryIndex >= 0 && entryIndex % 2 == 0) {
graphics.fill(left - 4, y - 2, right + 4, y + LINE_HEIGHT - 1, 0x223C4F75);
}
int sizeX = right - 62;
graphics.text(font, trim(parts[1], Math.max(48, sizeX - left - 8)), left, y, TEXT_COLOR, false);
graphics.text(font, trim(parts[2], 56), sizeX, y, MUTED_COLOR, false);
}
private void drawDetails(GuiGraphicsExtractor graphics, Rect area, List<String> lines) {
int left = area.x() + 10;
int right = area.right() - 10;
int y = area.y() + 8;
String name = detailValue(lines, "Nom");
graphics.text(font, trim(name.isBlank() ? "Selection" : name, right - left), left, y, TEXT_COLOR, false);
y += LINE_HEIGHT + 4;
PreviewSize previewSize = selectedProjectPreviewSize(lines);
if (previewSize.width() > 0 && previewSize.height() > 0 && snapshot.gridColors().size() >= previewSize.width() * previewSize.height()) {
int preview = Math.max(44, Math.min(86, Math.min(right - left, area.h() / 3)));
drawVoxelPreview(graphics, left, y, right - left, preview, previewSize);
y += preview + 8;
}
for (String line : lines) {
if (line == null || line.isBlank() || line.startsWith("Nom\t") || line.startsWith("Preview\t")) {
continue;
}
if (y + LINE_HEIGHT > area.bottom() - 8) {
break;
}
drawDetailLine(graphics, line, left, right, y);
y += LINE_HEIGHT;
}
}
private void drawVoxelPreview(GuiGraphicsExtractor graphics, int left, int top, int maxWidth, int maxHeight, PreviewSize previewSize) {
int pixel = Math.max(1, Math.min(maxWidth / previewSize.width(), maxHeight / previewSize.height()));
int widthPx = previewSize.width() * pixel;
int heightPx = previewSize.height() * pixel;
int x0 = left + Math.max(0, (maxWidth - widthPx) / 2);
int y0 = top + Math.max(0, (maxHeight - heightPx) / 2);
graphics.fill(x0 - 2, y0 - 2, x0 + widthPx + 2, y0 + heightPx + 2, 0x553A4868);
for (int row = 0; row < previewSize.height(); row++) {
int rowStart = row * previewSize.width();
for (int col = 0; col < previewSize.width(); col++) {
int color = snapshot.gridColors().get(rowStart + col);
if ((color >>> 24) == 0) {
continue;
}
int x = x0 + col * pixel;
int y = y0 + row * pixel;
graphics.fill(x, y, x + pixel, y + pixel, color);
}
}
}
private void drawDetailLine(GuiGraphicsExtractor graphics, String rawLine, int left, int right, int y) {
int split = rawLine.indexOf('\t');
if (split >= 0) {
String label = rawLine.substring(0, split);
String value = rawLine.substring(split + 1);
graphics.text(font, label, left, y, MUTED_COLOR, false);
graphics.text(font, trim(value, Math.max(24, right - left - 70)), left + 70, y, TEXT_COLOR, false);
return;
}
graphics.text(font, trim(rawLine, right - left), left, y, MUTED_COLOR, false);
}
private void drawPanel(GuiGraphicsExtractor graphics, Rect rect) {
graphics.fill(rect.x(), rect.y(), rect.right(), rect.bottom(), PANEL_COLOR);
graphics.fill(rect.x() + 1, rect.y() + 1, rect.right() - 1, rect.bottom() - 1, PANEL_ALT_COLOR);
graphics.fill(rect.x(), rect.y(), rect.right(), rect.y() + 1, PANEL_BORDER);
graphics.fill(rect.x(), rect.bottom() - 1, rect.right(), rect.bottom(), 0x66000000);
graphics.fill(rect.x(), rect.y(), rect.x() + 1, rect.bottom(), PANEL_BORDER);
graphics.fill(rect.right() - 1, rect.y(), rect.right(), rect.bottom(), 0x66000000);
}
private void drawScrollbar(GuiGraphicsExtractor graphics, int lineCount, int left, int right, int top, int bottom, int rows, int offset) {
if (lineCount <= rows) {
return;
}
int trackX = right - 6;
graphics.fill(trackX, top, trackX + 4, bottom, 0xFF000000);
int trackHeight = bottom - top;
int thumbHeight = Math.max(18, trackHeight * rows / lineCount);
int maxScroll = Math.max(1, lineCount - rows);
int thumbY = top + (trackHeight - thumbHeight) * offset / maxScroll;
graphics.fill(trackX, thumbY, trackX + 4, thumbY + thumbHeight, 0xFFBFC8D8);
}
private boolean insideList(double mouseX, double mouseY) {
Rect list = listArea();
return mouseX >= list.x() && mouseX < list.right() && mouseY >= list.y() && mouseY < list.bottom();
}
private List<String> entryLines(List<String> lines) {
return lines.stream().filter(line -> line != null && line.startsWith("ENTRY:")).toList();
}
private List<String> detailLines(List<String> lines) {
List<String> details = new ArrayList<>();
boolean inDetail = false;
for (String line : lines) {
if (line == null || line.isBlank() || line.startsWith("TITLE:") || line.startsWith("ENTRY:")) {
continue;
}
if (line.equals("SECTION:Detail")) {
inDetail = true;
continue;
}
if (line.startsWith("SECTION:")) {
break;
}
if (inDetail) {
details.add(line);
}
}
return details;
}
private String detailValue(List<String> lines, String key) {
String prefix = key + "\t";
for (String line : lines) {
if (line.startsWith(prefix)) {
return line.substring(prefix.length());
}
}
return "";
}
private PreviewSize selectedProjectPreviewSize(List<String> lines) {
String value = detailValue(lines, "Preview");
int split = value.indexOf('x');
if (split < 0) {
return new PreviewSize(0, 0);
}
int width = parseInt(value.substring(0, split), 0);
int height = parseInt(value.substring(split + 1), 0);
return new PreviewSize(Math.max(0, width), Math.max(0, height));
}
private Integer entryIndex(String line) {
int split = line == null ? -1 : line.indexOf('|');
if (split < 0 || !line.startsWith("ENTRY:")) {
return null;
}
int value = parseInt(line.substring("ENTRY:".length(), split), -1);
return value < 0 ? null : value;
}
private int maxScroll() {
return Math.max(0, entryLines(snapshot.leftLines()).size() - visibleRows(listArea()));
}
private int visibleRows(Rect area) {
return Math.max(1, (area.bottom() - area.y() - 8) / LINE_HEIGHT);
}
private Rect listPanel() {
int available = Math.max(120, width - 24);
int contentWidth = Math.min(720, available);
int left = (width - contentWidth) / 2;
int top = 34;
int bottom = Math.max(top + 50, height - FOOTER_HEIGHT);
int detailWidth = Math.min(230, Math.max(132, contentWidth / 3));
return new Rect(left + detailWidth + 8, top, contentWidth - detailWidth - 8, bottom - top);
}
private Rect detailPanel() {
int available = Math.max(120, width - 24);
int contentWidth = Math.min(720, available);
int left = (width - contentWidth) / 2;
int top = 34;
int bottom = Math.max(top + 50, height - FOOTER_HEIGHT);
int detailWidth = Math.min(230, Math.max(132, contentWidth / 3));
return new Rect(left, top, detailWidth, bottom - top);
}
private Rect listArea() {
Rect list = listPanel();
return new Rect(list.x() + 6, list.y() + 6, list.w() - 12, list.h() - 12);
}
private String trim(String text, int maxWidth) {
if (font.width(text) <= maxWidth) {
return text;
}
String suffix = "...";
int end = text.length();
while (end > 0 && font.width(text.substring(0, end) + suffix) > maxWidth) {
end--;
}
return text.substring(0, Math.max(0, end)) + suffix;
}
private int parseInt(String value, int fallback) {
try {
return Integer.parseInt(value);
} catch (NumberFormatException ignored) {
return fallback;
}
}
private enum PendingProjectAction {
NONE,
BUILD,
BLUEPRINT,
CREATIVE_DIRECT
}
private record Rect(int x, int y, int w, int h) {
int right() {
return x + w;
}
int bottom() {
return y + h;
}
}
private record PreviewSize(int width, int height) {
}
}
@@ -0,0 +1,911 @@
package dev.blocodex.client.gui;
import dev.blocodex.Blocodex;
import dev.blocodex.client.BlocodexClient;
import dev.blocodex.network.BlocodexUiMode;
import dev.blocodex.network.BlocodexUiRequestPayload;
import dev.blocodex.network.BlocodexUiSnapshotPayload;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.input.CharacterEvent;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.client.input.MouseButtonEvent;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.client.renderer.texture.TextureAtlasSprite;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import org.lwjgl.glfw.GLFW;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public final class BlocodexScreen extends Screen {
private static final int GRID_CELLS = 9;
private static final int TEXT = 0xFFE8E1D3;
private static final int MUTED_TEXT = 0xFF9F9A8E;
private static final int PANEL_DARK = 0xFF0A0D0C;
private static final int DEVICE_MARGIN = 36;
private static final int DEVICE_MAX_WIDTH = 420;
private static final int DEVICE_MAX_HEIGHT = 205;
private static final int DEVICE_MIN_HEIGHT = 200;
private static final int DEVICE_TOP_LIFT = 17;
private static final int DEVICE_LEFT_PADDING = 22;
private static final int DEVICE_RIGHT_PADDING = 16;
private static final int SCREEN_FRAME_OUTSET = 8;
private static final int SCREEN_CONTENT_GAP = 7;
private static final float SCREEN_HEIGHT_RATIO = 0.72F;
private static final float SCREEN_TOP_RATIO = 0.16F;
private static final float LEFT_SCREEN_WIDTH_RATIO = 0.88F;
private static final int MIN_SCREEN_GAP = 18;
private static final int MIN_SLIDER_GAP = 16;
private static final int GAP_WIDTH_DIVISOR = 80;
private static final int SLIDER_WIDTH_DIVISOR = 44;
private static final int BUTTON_TOP_GAP = SCREEN_FRAME_OUTSET + SCREEN_CONTENT_GAP;
private static final int BUTTON_SIDE_GAP = 6;
private static final int BUTTON_PAIR_GAP = 4;
private static final int SLIDER_KNOB_WIDTH = 18;
private static final int SLIDER_KNOB_HEIGHT = 12;
private static final Identifier VANILLA_BUTTON = Identifier.withDefaultNamespace("widget/button");
private static final Identifier VANILLA_BUTTON_HOVERED = Identifier.withDefaultNamespace("widget/button_highlighted");
private static final Identifier DEVICE_PANEL = Blocodex.id("textures/gui/device_panel.png");
private static final Identifier SCREEN_FRAME = Blocodex.id("textures/gui/screen_frame.png");
private static final Identifier BOTTOM_PANEL = Blocodex.id("textures/gui/bottom_panel.png");
private static final Identifier SLIDER_WELL = Blocodex.id("textures/gui/slider_well.png");
private static final Identifier SLIDER_KNOB = Blocodex.id("textures/gui/slider_knob.png");
private static final Identifier VENT_SLOTS = Blocodex.id("textures/gui/vent_slots.png");
private static final Identifier STATUS_LIGHT = Blocodex.id("textures/gui/status_light.png");
private static final Identifier ICON_MENU = Blocodex.id("textures/gui/icon_menu.png");
private static final Identifier ICON_ARROW_LEFT = Blocodex.id("textures/gui/icon_arrow_left.png");
private static final Identifier ICON_ARROW_RIGHT = Blocodex.id("textures/gui/icon_arrow_right.png");
private static final Identifier SELECTION_FRAME = Blocodex.id("textures/gui/selection_frame.png");
private static final Identifier GRID_OVERLAY = Blocodex.id("textures/gui/grid_overlay_9x9.png");
private static BlocodexUiMode rememberedMode = BlocodexUiMode.STATS;
private static int rememberedSliderValue = 0;
private static int rememberedPage = 0;
private static int rememberedSelectedIndex = 40;
private BlocodexUiMode mode;
private int sliderValue;
private int page;
private int selectedIndex;
private int requestCooldown = 0;
private int screenTicks = 0;
private int leftScrollOffset = 0;
private String colorQuery = "";
private boolean draggingSlider = false;
private BlocodexUiSnapshotPayload snapshot = BlocodexUiSnapshotPayload.empty();
public BlocodexScreen() {
super(Component.literal("Blocodex"));
mode = rememberedMode;
sliderValue = rememberedSliderValue;
page = rememberedPage;
selectedIndex = rememberedSelectedIndex;
Minecraft client = Minecraft.getInstance();
if (client.player != null && mode == BlocodexUiMode.WORLD && sliderValue == 0) {
sliderValue = client.player.blockPosition().getY();
}
}
public void acceptSnapshot(BlocodexUiSnapshotPayload payload) {
snapshot = payload;
mode = BlocodexUiMode.byId(payload.mode());
sliderValue = payload.sliderValue();
page = payload.page();
selectedIndex = payload.selectedIndex();
rememberState();
}
@Override
protected void init() {
requestSnapshot();
}
@Override
public void tick() {
screenTicks++;
if (requestCooldown > 0) {
requestCooldown--;
}
if (requestCooldown == 0) {
requestCooldown = isColorLoading() ? 5 : 40;
requestSnapshot();
}
}
@Override
public boolean isPauseScreen() {
return false;
}
@Override
public boolean keyPressed(KeyEvent event) {
if (BlocodexClient.matchesOpenKey(event)) {
minecraft.setScreen(null);
return true;
}
if (event.key() == GLFW.GLFW_KEY_LEFT) {
previous();
return true;
}
if (event.key() == GLFW.GLFW_KEY_RIGHT) {
next();
return true;
}
if (event.key() == GLFW.GLFW_KEY_TAB || event.key() == GLFW.GLFW_KEY_M) {
nextMode();
return true;
}
if (mode == BlocodexUiMode.COLOR && event.key() == GLFW.GLFW_KEY_BACKSPACE && !colorQuery.isEmpty()) {
colorQuery = (event.modifiers() & GLFW.GLFW_MOD_CONTROL) != 0 ? "" : colorQuery.substring(0, colorQuery.length() - 1);
selectedIndex = 0;
resetLeftScroll();
rememberState();
requestSnapshot();
return true;
}
return super.keyPressed(event);
}
@Override
public boolean charTyped(CharacterEvent event) {
int codePoint = event.codepoint();
if (mode == BlocodexUiMode.COLOR && event.isAllowedChatCharacter() && colorQuery.length() < 48) {
colorQuery += new String(Character.toChars(codePoint));
selectedIndex = 0;
resetLeftScroll();
rememberState();
requestSnapshot();
return true;
}
return super.charTyped(event);
}
@Override
public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
Layout layout = layout();
double mouseX = event.x();
double mouseY = event.y();
if (inside(mouseX, mouseY, layout.menuButton())) {
nextMode();
return true;
}
if (inside(mouseX, mouseY, layout.leftButton())) {
previous();
return true;
}
if (inside(mouseX, mouseY, layout.rightButton())) {
next();
return true;
}
if (inside(mouseX, mouseY, layout.sliderWell())) {
draggingSlider = true;
setSliderFromMouse(layout, mouseY);
return true;
}
if (mode != BlocodexUiMode.STATS && inside(mouseX, mouseY, layout.grid())) {
int cell = cellAt(layout, mouseX, mouseY);
if (cell >= 0) {
selectedIndex = cell;
resetLeftScroll();
requestSnapshot();
return true;
}
}
return super.mouseClicked(event, doubleClick);
}
@Override
public boolean mouseDragged(MouseButtonEvent event, double dragX, double dragY) {
if (draggingSlider) {
setSliderFromMouse(layout(), event.y());
return true;
}
return super.mouseDragged(event, dragX, dragY);
}
@Override
public boolean mouseReleased(MouseButtonEvent event) {
if (draggingSlider) {
draggingSlider = false;
requestSnapshot();
return true;
}
return super.mouseReleased(event);
}
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
Layout layout = layout();
if (inside(mouseX, mouseY, layout.leftScreen())) {
int maxScroll = maxLeftScroll(layout);
if (maxScroll > 0) {
int direction = scrollY < 0 ? 1 : -1;
leftScrollOffset = Math.max(0, Math.min(maxScroll, leftScrollOffset + direction * 18));
return true;
}
}
if (mode == BlocodexUiMode.STATS) {
int direction = scrollY < 0 ? 1 : -1;
sliderValue = Math.max(snapshot.minSliderValue(), Math.min(snapshot.maxSliderValue(), sliderValue + direction));
rememberState();
requestSnapshot();
return true;
}
return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
}
@Override
public void removed() {
rememberState();
}
@Override
public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
Layout layout = layout();
drawDevice(graphics, layout);
drawLeftScreen(graphics, layout);
drawGrid(graphics, layout);
drawSlider(graphics, layout);
drawButtons(graphics, layout, mouseX, mouseY);
}
private void drawDevice(GuiGraphicsExtractor graphics, Layout layout) {
Rect body = layout.body();
drawNineSlice(graphics, DEVICE_PANEL, body.x(), body.y(), body.w(), body.h(), 32, 32, 8);
String title = "BLOCODEX";
int titleX = layout.leftScreen().x() + (layout.leftScreen().w() - font.width(title)) / 2;
graphics.text(font, title, titleX, body.y() + 14, 0xFF2A2925, false);
drawTexture(graphics, STATUS_LIGHT, body.x() + 8, body.y() + 13, 10, 10, 12, 12);
int ventX = layout.grid().x() + 8;
int ventY = body.y() + 5;
drawTexture(graphics, VENT_SLOTS, ventX, ventY, layout.grid().w() - 16, 20, 96, 24);
}
private void drawLeftScreen(GuiGraphicsExtractor graphics, Layout layout) {
Rect screen = layout.leftScreen();
drawInset(graphics, screen);
List<String> lines = isColorLoading() ? loadingLines() : snapshot.leftLines();
Rect content = new Rect(screen.x() + SCREEN_CONTENT_GAP, screen.y() + SCREEN_CONTENT_GAP, screen.w() - SCREEN_CONTENT_GAP * 2, screen.h() - SCREEN_CONTENT_GAP * 2);
leftScrollOffset = Math.max(0, Math.min(maxLeftScroll(layout), leftScrollOffset));
graphics.enableScissor(content.x(), content.y(), content.right(), content.bottom());
drawLeftContent(graphics, content, lines, content.y() - leftScrollOffset);
graphics.disableScissor();
drawLeftScrollbar(graphics, content, leftContentHeight(lines, content.w()));
}
private void drawLeftContent(GuiGraphicsExtractor graphics, Rect content, List<String> lines, int y) {
if (shouldShowEntryIcon()) {
int iconSize = 28;
drawEntryIcon(graphics, snapshot.selectedBlockId(), content.x() + (content.w() - iconSize) / 2, y, iconSize, snapshot.selectedColor());
y += iconSize + 6;
}
for (String rawLine : lines) {
if (rawLine == null) {
continue;
}
if (rawLine.isBlank()) {
y += 6;
continue;
}
if (rawLine.startsWith("TITLE:")) {
String title = rawLine.substring("TITLE:".length());
graphics.text(font, trim(title, content.w()), content.x() + (content.w() - Math.min(font.width(title), content.w())) / 2, y, TEXT, false);
y += 16;
} else if (rawLine.startsWith("TYPE:")) {
y = drawWrappedLines(graphics, rawLine.substring("TYPE:".length()), content, y, MUTED_TEXT, true);
} else if (rawLine.startsWith("ID:")) {
y = drawWrappedLines(graphics, rawLine.substring("ID:".length()), content, y, 0xFF77736A, true);
y += 2;
} else if (rawLine.startsWith("DESC:")) {
y = drawWrappedLines(graphics, rawLine.substring("DESC:".length()), content, y, MUTED_TEXT, false);
y += 4;
} else if (rawLine.startsWith("SECTION:")) {
graphics.text(font, trim(rawLine.substring("SECTION:".length()), content.w()), content.x(), y, TEXT, false);
y += 16;
} else {
y = drawWrappedLines(graphics, rawLine, content, y, MUTED_TEXT, false);
}
}
}
private int drawWrappedLines(GuiGraphicsExtractor graphics, String text, Rect content, int y, int color, boolean centered) {
for (String line : wrapText(text, content.w())) {
int x = centered ? content.x() + (content.w() - Math.min(font.width(line), content.w())) / 2 : content.x();
graphics.text(font, trim(line, content.w()), x, y, color, false);
y += 12;
}
return y;
}
private int leftContentHeight(List<String> lines, int width) {
int height = shouldShowEntryIcon() ? 34 : 0;
for (String rawLine : lines) {
if (rawLine == null || rawLine.isBlank()) {
height += 6;
} else if (rawLine.startsWith("TITLE:") || rawLine.startsWith("SECTION:")) {
height += 16;
} else {
height += wrapText(stripLinePrefix(rawLine), width).size() * 12;
if (rawLine.startsWith("ID:")) {
height += 2;
} else if (rawLine.startsWith("DESC:")) {
height += 4;
}
}
}
return height;
}
private int maxLeftScroll(Layout layout) {
List<String> lines = isColorLoading() ? loadingLines() : snapshot.leftLines();
Rect screen = layout.leftScreen();
int contentHeight = leftContentHeight(lines, screen.w() - SCREEN_CONTENT_GAP * 2);
return Math.max(0, contentHeight - (screen.h() - SCREEN_CONTENT_GAP * 2));
}
private void drawLeftScrollbar(GuiGraphicsExtractor graphics, Rect content, int contentHeight) {
if (contentHeight <= content.h()) {
return;
}
int trackX = content.right() - 2;
graphics.fill(trackX, content.y(), trackX + 1, content.bottom(), 0x55000000);
int thumbH = Math.max(12, content.h() * content.h() / contentHeight);
int maxScroll = Math.max(1, contentHeight - content.h());
int thumbY = content.y() + (content.h() - thumbH) * leftScrollOffset / maxScroll;
graphics.fill(trackX - 1, thumbY, trackX + 2, thumbY + thumbH, 0x99E8E1D3);
}
private List<String> wrapText(String text, int maxWidth) {
List<String> lines = new ArrayList<>();
if (text == null || text.isBlank()) {
return lines;
}
StringBuilder line = new StringBuilder();
for (String word : text.strip().split("\\s+")) {
String candidate = line.isEmpty() ? word : line + " " + word;
if (font.width(candidate) <= maxWidth) {
line.setLength(0);
line.append(candidate);
} else {
if (!line.isEmpty()) {
lines.add(line.toString());
}
line.setLength(0);
line.append(word);
}
}
if (!line.isEmpty()) {
lines.add(line.toString());
}
return lines;
}
private String stripLinePrefix(String line) {
int split = line.indexOf(':');
return split > 0 && line.substring(0, split).chars().allMatch(Character::isUpperCase) ? line.substring(split + 1) : line;
}
private boolean shouldShowEntryIcon() {
return mode == BlocodexUiMode.COLOR && !isColorLoading() && snapshot.selectedBlockId() != null && !snapshot.selectedBlockId().isBlank();
}
private void drawEntryIcon(GuiGraphicsExtractor graphics, String id, int x, int y, int size, int fallbackColor) {
Optional<Block> block = block(id);
if (block.isPresent() && block.get() != Blocks.AIR) {
graphics.fill(x, y, x + size, y + size, fallbackColor);
drawBlockSprite(graphics, id, x, y, size, size, colorTint(id, fallbackColor));
outline(graphics, x, y, size, size, 0xFF3A403B, 0xFF020303);
return;
}
Optional<Item> maybeItem = item(id);
if (maybeItem.isPresent() && maybeItem.get() != Items.AIR) {
graphics.fill(x, y, x + size, y + size, fallbackColor);
drawScaledFakeItem(graphics, new ItemStack(maybeItem.get()), x, y, size, Math.min(20, size - 4));
outline(graphics, x, y, size, size, 0xFF3A403B, 0xFF020303);
return;
}
graphics.fill(x, y, x + size, y + size, fallbackColor);
outline(graphics, x, y, size, size, 0xFF3A403B, 0xFF020303);
}
private void drawScaledFakeItem(GuiGraphicsExtractor graphics, ItemStack stack, int boxX, int boxY, int boxSize, int itemSize) {
if (stack.isEmpty()) {
return;
}
int clampedSize = Math.max(6, Math.min(itemSize, boxSize));
float scale = clampedSize / 16.0F;
int x = boxX + (boxSize - clampedSize) / 2;
int y = boxY + (boxSize - clampedSize) / 2;
graphics.pose().pushMatrix();
graphics.pose().translate(x, y);
graphics.pose().scale(scale, scale);
graphics.fakeItem(stack, 0, 0);
graphics.pose().popMatrix();
}
private void drawGrid(GuiGraphicsExtractor graphics, Layout layout) {
Rect grid = layout.grid();
drawInset(graphics, grid);
int cell = grid.w() / GRID_CELLS;
int gridSize = cell * GRID_CELLS;
int startX = grid.x() + (grid.w() - gridSize) / 2;
int startY = grid.y() + (grid.h() - gridSize) / 2;
if (mode == BlocodexUiMode.STATS) {
drawStatsMap(graphics, startX, startY, cell, gridSize);
return;
}
if (isColorLoading()) {
drawColorLoadingVortex(graphics, startX, startY, cell, gridSize);
return;
}
for (int row = 0; row < GRID_CELLS; row++) {
for (int col = 0; col < GRID_CELLS; col++) {
int index = row * GRID_CELLS + col;
int x = startX + col * cell;
int y = startY + row * cell;
int color = index < snapshot.gridColors().size() ? snapshot.gridColors().get(index) : PANEL_DARK;
String blockId = index < snapshot.gridBlockIds().size() ? snapshot.gridBlockIds().get(index) : "";
if (shouldRenderTexture(blockId)) {
graphics.fill(x, y, x + cell, y + cell, color);
drawBlockSprite(graphics, blockId, x, y, cell, cell, colorTint(blockId, color));
} else if (mode == BlocodexUiMode.COLOR && shouldRenderItem(blockId)) {
graphics.fill(x, y, x + cell, y + cell, color);
drawScaledFakeItem(graphics, new ItemStack(item(blockId).orElse(Items.AIR)), x, y, cell, cell);
} else {
graphics.fill(x, y, x + cell, y + cell, color);
}
}
}
drawTexture(graphics, GRID_OVERLAY, startX, startY, gridSize, gridSize, 144, 144);
int selected = Math.max(0, Math.min(80, selectedIndex));
int sx = startX + (selected % GRID_CELLS) * cell;
int sy = startY + (selected / GRID_CELLS) * cell;
drawTexture(graphics, SELECTION_FRAME, sx, sy, cell, cell, 16, 16);
}
private void drawStatsMap(GuiGraphicsExtractor graphics, int startX, int startY, int cell, int gridSize) {
graphics.fill(startX, startY, startX + gridSize, startY + gridSize, 0xFF070909);
for (int row = 0; row < GRID_CELLS; row++) {
for (int col = 0; col < GRID_CELLS; col++) {
int index = row * GRID_CELLS + col;
int x = startX + col * cell;
int y = startY + row * cell;
int color = index < snapshot.gridColors().size() ? snapshot.gridColors().get(index) : PANEL_DARK;
graphics.fill(x, y, x + cell, y + cell, color);
}
}
int centerX = startX + 4 * cell + cell / 2;
int centerY = startY + 4 * cell + cell / 2;
int headSize = Math.max(12, cell);
drawPlayerHead(graphics, centerX - headSize / 2, centerY - headSize / 2, headSize);
}
private void drawColorLoadingVortex(GuiGraphicsExtractor graphics, int startX, int startY, int cell, int gridSize) {
graphics.fill(startX, startY, startX + gridSize, startY + gridSize, 0xFF050707);
double phase = screenTicks * 0.18D;
for (int row = 0; row < GRID_CELLS; row++) {
for (int col = 0; col < GRID_CELLS; col++) {
double dx = col - 4.0D;
double dy = row - 4.0D;
double radius = Math.sqrt(dx * dx + dy * dy);
double angle = Math.atan2(dy, dx) + phase;
double arm = Math.max(0.0D, Math.cos(angle * 4.0D - radius * 1.35D));
double swirl = Math.max(0.0D, Math.cos(angle - radius * 0.85D + phase * 1.5D));
double energy = Math.min(1.0D, arm * 0.7D + swirl * 0.45D);
float saturation = (float) Math.min(1.0D, 0.25D + energy * 0.75D);
float value = (float) Math.max(0.08D, Math.min(1.0D, 0.18D + energy * 0.82D - radius * 0.035D));
int hue = Math.floorMod(sliderValue + (int) Math.round(screenTicks * 2.2D + radius * 14.0D), 360);
int x = startX + col * cell;
int y = startY + row * cell;
graphics.fill(x, y, x + cell, y + cell, hslToArgb(hue, saturation, value));
}
}
drawTexture(graphics, GRID_OVERLAY, startX, startY, gridSize, gridSize, 144, 144);
}
private void drawSlider(GuiGraphicsExtractor graphics, Layout layout) {
Rect track = layout.sliderTrack();
drawNineSlice(graphics, SLIDER_WELL, layout.sliderWell().x(), layout.sliderWell().y(), layout.sliderWell().w(), layout.sliderWell().h(), 20, 32, 6, 6, 8, 8);
graphics.fill(track.x(), track.y(), track.right(), track.bottom(), 0xFF090A09);
double normal = sliderNormal();
int handleY = track.y() + (int) Math.round((1.0D - normal) * (track.h() - SLIDER_KNOB_HEIGHT));
drawTexture(graphics, SLIDER_KNOB, track.x() - 7, handleY, SLIDER_KNOB_WIDTH, SLIDER_KNOB_HEIGHT, 18, 12);
}
private void drawButtons(GuiGraphicsExtractor graphics, Layout layout, int mouseX, int mouseY) {
drawButton(graphics, layout.menuButton(), "menu", inside(mouseX, mouseY, layout.menuButton()));
drawButton(graphics, layout.leftButton(), "left", inside(mouseX, mouseY, layout.leftButton()));
drawButton(graphics, layout.rightButton(), "right", inside(mouseX, mouseY, layout.rightButton()));
}
private void drawButton(GuiGraphicsExtractor graphics, Rect rect, String icon, boolean hovered) {
Identifier texture = hovered ? VANILLA_BUTTON_HOVERED : VANILLA_BUTTON;
graphics.blitSprite(RenderPipelines.GUI_TEXTURED, texture, rect.x(), rect.y(), rect.w(), rect.h(), 0xFFFFFFFF);
int cx = rect.x() + rect.w() / 2;
int cy = rect.y() + rect.h() / 2;
if ("menu".equals(icon)) {
drawTexture(graphics, ICON_MENU, cx - 8, cy - 8, 16, 16, 16, 16);
} else if ("left".equals(icon)) {
drawTexture(graphics, ICON_ARROW_LEFT, cx - 8, cy - 8, 16, 16, 16, 16);
} else {
drawTexture(graphics, ICON_ARROW_RIGHT, cx - 8, cy - 8, 16, 16, 16, 16);
}
}
private void drawBottomPanel(GuiGraphicsExtractor graphics, Layout layout) {
Rect panel = layout.bottomPanel();
drawNineSlice(graphics, BOTTOM_PANEL, panel.x(), panel.y(), panel.w(), panel.h(), 32, 24, 6);
}
private void drawInset(GuiGraphicsExtractor graphics, Rect rect) {
drawNineSlice(graphics, SCREEN_FRAME, rect.x() - SCREEN_FRAME_OUTSET, rect.y() - SCREEN_FRAME_OUTSET, rect.w() + SCREEN_FRAME_OUTSET * 2, rect.h() + SCREEN_FRAME_OUTSET * 2, 32, 32, SCREEN_FRAME_OUTSET);
}
private void outline(GuiGraphicsExtractor graphics, int x, int y, int w, int h, int light, int dark) {
graphics.fill(x, y, x + w, y + 1, light);
graphics.fill(x, y, x + 1, y + h, light);
graphics.fill(x, y + h - 1, x + w, y + h, dark);
graphics.fill(x + w - 1, y, x + w, y + h, dark);
}
private void drawTexture(GuiGraphicsExtractor graphics, Identifier texture, int x, int y, int w, int h, int textureW, int textureH) {
drawTextureRegion(graphics, texture, x, y, w, h, 0, 0, textureW, textureH, textureW, textureH);
}
private void drawNineSlice(GuiGraphicsExtractor graphics, Identifier texture, int x, int y, int w, int h, int textureW, int textureH, int slice) {
drawNineSlice(graphics, texture, x, y, w, h, textureW, textureH, slice, slice, slice, slice);
}
private void drawNineSlice(GuiGraphicsExtractor graphics, Identifier texture, int x, int y, int w, int h, int textureW, int textureH, int left, int right, int top, int bottom) {
int centerW = Math.max(0, w - left - right);
int centerH = Math.max(0, h - top - bottom);
int sourceCenterW = textureW - left - right;
int sourceCenterH = textureH - top - bottom;
drawTextureRegion(graphics, texture, x, y, left, top, 0, 0, left, top, textureW, textureH);
drawTextureRegion(graphics, texture, x + left, y, centerW, top, left, 0, sourceCenterW, top, textureW, textureH);
drawTextureRegion(graphics, texture, x + left + centerW, y, right, top, textureW - right, 0, right, top, textureW, textureH);
drawTextureRegion(graphics, texture, x, y + top, left, centerH, 0, top, left, sourceCenterH, textureW, textureH);
drawTextureRegion(graphics, texture, x + left, y + top, centerW, centerH, left, top, sourceCenterW, sourceCenterH, textureW, textureH);
drawTextureRegion(graphics, texture, x + left + centerW, y + top, right, centerH, textureW - right, top, right, sourceCenterH, textureW, textureH);
drawTextureRegion(graphics, texture, x, y + top + centerH, left, bottom, 0, textureH - bottom, left, bottom, textureW, textureH);
drawTextureRegion(graphics, texture, x + left, y + top + centerH, centerW, bottom, left, textureH - bottom, sourceCenterW, bottom, textureW, textureH);
drawTextureRegion(graphics, texture, x + left + centerW, y + top + centerH, right, bottom, textureW - right, textureH - bottom, right, bottom, textureW, textureH);
}
private void drawTextureRegion(GuiGraphicsExtractor graphics, Identifier texture, int x, int y, int w, int h, int u, int v, int sourceW, int sourceH, int textureW, int textureH) {
if (w <= 0 || h <= 0 || sourceW <= 0 || sourceH <= 0) {
return;
}
graphics.blit(RenderPipelines.GUI_TEXTURED, texture, x, y, (float) u, (float) v, w, h, sourceW, sourceH, textureW, textureH);
}
private void drawBlockSprite(GuiGraphicsExtractor graphics, String blockId, int x, int y, int w, int h, int tint) {
Optional<Block> maybeBlock = block(blockId);
if (maybeBlock.isEmpty()) {
return;
}
TextureAtlasSprite sprite = blockSprite(blockId, maybeBlock.get());
graphics.blitSprite(RenderPipelines.GUI_TEXTURED, sprite, x, y, w, h, tint);
}
private TextureAtlasSprite blockSprite(String blockId, Block block) {
return minecraft.getModelManager()
.getBlockStateModelSet()
.getParticleMaterial(block.defaultBlockState())
.sprite();
}
private void drawPlayerHead(GuiGraphicsExtractor graphics, int x, int y, int size) {
if (minecraft.player == null) {
graphics.fill(x, y, x + size, y + size, 0xFF2D8DFF);
outline(graphics, x, y, size, size, 0xFFE8F7FF, 0xFF00111E);
return;
}
Identifier skin = minecraft.player.getSkin().body().texturePath();
drawTextureRegion(graphics, skin, x, y, size, size, 8, 8, 8, 8, 64, 64);
drawTextureRegion(graphics, skin, x, y, size, size, 40, 8, 8, 8, 64, 64);
outline(graphics, x, y, size, size, 0xFFE8F7FF, 0xFF00111E);
}
private boolean shouldRenderTexture(String blockId) {
return blockId != null && !blockId.isBlank() && block(blockId).orElse(Blocks.AIR) != Blocks.AIR;
}
private boolean shouldRenderItem(String itemId) {
return itemId != null && !itemId.isBlank() && item(itemId).orElse(Items.AIR) != Items.AIR;
}
private int colorTint(String blockId, int color) {
int tint = canonicalTint(blockId);
return tint == -1 ? 0xFFFFFFFF : tint;
}
private int canonicalTint(String blockId) {
String path = blockPath(blockId);
if (path.equals("water") || path.equals("bubble_column") || path.equals("water_cauldron")) {
return 0xFF3F76E4;
}
if (path.equals("birch_leaves")) {
return 0xFF80A755;
}
if (path.equals("spruce_leaves")) {
return 0xFF619961;
}
if (path.endsWith("_leaves") || path.equals("vine")) {
return 0xFF48B518;
}
if (path.equals("grass_block") || path.equals("short_grass") || path.equals("tall_grass")
|| path.equals("fern") || path.equals("large_fern") || path.equals("potted_fern")
|| path.equals("bush") || path.equals("sugar_cane")) {
return 0xFF79C05A;
}
if (path.equals("lily_pad")) {
return 0xFF208030;
}
return -1;
}
private String blockPath(String blockId) {
if (blockId == null) {
return "";
}
int split = blockId.indexOf(':');
return split >= 0 ? blockId.substring(split + 1) : blockId;
}
private Optional<Block> block(String blockId) {
Identifier id = Identifier.tryParse(blockId);
if (id == null) {
return Optional.empty();
}
return BuiltInRegistries.BLOCK.getOptional(id);
}
private Optional<Item> item(String itemId) {
Identifier id = Identifier.tryParse(itemId);
if (id == null) {
return Optional.empty();
}
return BuiltInRegistries.ITEM.getOptional(id);
}
private void nextMode() {
mode = mode.next();
page = 0;
selectedIndex = 40;
resetLeftScroll();
if (mode == BlocodexUiMode.WORLD && minecraft.player != null) {
sliderValue = minecraft.player.blockPosition().getY();
} else if (mode == BlocodexUiMode.COLOR) {
sliderValue = 0;
} else if (mode == BlocodexUiMode.STATS) {
sliderValue = 0;
}
rememberState();
requestSnapshot();
}
private void previous() {
page--;
resetLeftScroll();
rememberState();
requestSnapshot();
}
private void next() {
page++;
resetLeftScroll();
rememberState();
requestSnapshot();
}
private void setSliderFromMouse(Layout layout, double mouseY) {
Rect track = layout.sliderTrack();
int travel = Math.max(1, track.h() - SLIDER_KNOB_HEIGHT);
double normal = 1.0D - Math.max(0.0D, Math.min(1.0D, (mouseY - track.y() - SLIDER_KNOB_HEIGHT / 2.0D) / travel));
sliderValue = (int) Math.round(snapshot.minSliderValue() + normal * (snapshot.maxSliderValue() - snapshot.minSliderValue()));
rememberState();
requestSnapshot();
}
private double sliderNormal() {
int min = snapshot.minSliderValue();
int max = Math.max(min + 1, snapshot.maxSliderValue());
return Math.max(0.0D, Math.min(1.0D, (sliderValue - min) / (double) (max - min)));
}
private void requestSnapshot() {
try {
if (ClientPlayNetworking.canSend(BlocodexUiRequestPayload.TYPE)) {
String query = mode == BlocodexUiMode.COLOR ? colorQuery : "";
ClientPlayNetworking.send(new BlocodexUiRequestPayload(mode.id(), sliderValue, page, selectedIndex, query));
}
} catch (IllegalStateException ignored) {
// The screen can be constructed before the play networking channel is ready.
}
}
private void resetLeftScroll() {
leftScrollOffset = 0;
}
private void rememberState() {
rememberedMode = mode;
rememberedSliderValue = sliderValue;
rememberedPage = page;
rememberedSelectedIndex = selectedIndex;
}
private int cellAt(Layout layout, double mouseX, double mouseY) {
Rect grid = layout.grid();
int cell = grid.w() / GRID_CELLS;
int gridSize = cell * GRID_CELLS;
int startX = grid.x() + (grid.w() - gridSize) / 2;
int startY = grid.y() + (grid.h() - gridSize) / 2;
int col = (int) ((mouseX - startX) / cell);
int row = (int) ((mouseY - startY) / cell);
if (col < 0 || col >= GRID_CELLS || row < 0 || row >= GRID_CELLS) {
return -1;
}
return row * GRID_CELLS + col;
}
private boolean inside(double mouseX, double mouseY, Rect rect) {
return mouseX >= rect.x() && mouseX < rect.right() && mouseY >= rect.y() && mouseY < rect.bottom();
}
private boolean isColorLoading() {
if (mode != BlocodexUiMode.COLOR) {
return false;
}
if (BlocodexUiMode.byId(snapshot.mode()) != BlocodexUiMode.COLOR) {
return true;
}
return !snapshot.gridLabels().isEmpty() && "LOADING".equals(snapshot.gridLabels().getFirst());
}
private List<String> loadingLines() {
if (BlocodexUiMode.byId(snapshot.mode()) == BlocodexUiMode.COLOR && !snapshot.leftLines().isEmpty()) {
return snapshot.leftLines();
}
return List.of(
"TITLE:Color Space",
"Chargement",
"HSL",
"Snapshots"
);
}
private String trim(String text, int maxWidth) {
if (font.width(text) <= maxWidth) {
return text;
}
String suffix = "...";
int end = text.length();
while (end > 0 && font.width(text.substring(0, end) + suffix) > maxWidth) {
end--;
}
return text.substring(0, Math.max(0, end)) + suffix;
}
private int hslToArgb(float hue, float saturation, float lightness) {
float c = (1.0F - Math.abs(2.0F * lightness - 1.0F)) * saturation;
float x = c * (1.0F - Math.abs((hue / 60.0F) % 2.0F - 1.0F));
float m = lightness - c / 2.0F;
float r;
float g;
float b;
if (hue < 60.0F) {
r = c;
g = x;
b = 0.0F;
} else if (hue < 120.0F) {
r = x;
g = c;
b = 0.0F;
} else if (hue < 180.0F) {
r = 0.0F;
g = c;
b = x;
} else if (hue < 240.0F) {
r = 0.0F;
g = x;
b = c;
} else if (hue < 300.0F) {
r = x;
g = 0.0F;
b = c;
} else {
r = c;
g = 0.0F;
b = x;
}
return 0xFF000000
| (Math.round((r + m) * 255.0F) << 16)
| (Math.round((g + m) * 255.0F) << 8)
| Math.round((b + m) * 255.0F);
}
private Layout layout() {
int availableW = Math.max(220, width - DEVICE_MARGIN);
int availableH = Math.max(180, height - DEVICE_MARGIN);
int bodyH = Math.min(availableH, DEVICE_MAX_HEIGHT);
bodyH = Math.max(Math.min(availableH, DEVICE_MIN_HEIGHT), bodyH);
int screenH = (int) (bodyH * SCREEN_HEIGHT_RATIO);
int buttonH = Math.max(28, bodyH / 10);
int leftH = Math.max(96, screenH - BUTTON_TOP_GAP - buttonH);
int leftW = (int) (screenH * LEFT_SCREEN_WIDTH_RATIO);
int screenGap = Math.max(MIN_SCREEN_GAP, DEVICE_MAX_WIDTH / GAP_WIDTH_DIVISOR);
int sliderGap = MIN_SLIDER_GAP;
int gridW = screenH;
int sliderW = Math.max(16, DEVICE_MAX_WIDTH / SLIDER_WIDTH_DIVISOR);
int totalW = leftW + screenGap + gridW + sliderGap + sliderW;
int bodyW = Math.min(Math.min(availableW, DEVICE_MAX_WIDTH), totalW + DEVICE_LEFT_PADDING + DEVICE_RIGHT_PADDING);
int bodyX = (width - bodyW) / 2;
int bodyY = Math.max(4, (height - bodyH) / 2 - DEVICE_TOP_LIFT);
Rect body = new Rect(bodyX, bodyY, bodyW, bodyH);
int top = bodyY + (int) (bodyH * SCREEN_TOP_RATIO);
int extraW = Math.max(0, bodyW - totalW);
int startX = bodyX + Math.min(DEVICE_LEFT_PADDING, extraW / 2);
Rect leftScreen = new Rect(startX, top, leftW, leftH);
Rect grid = new Rect(leftScreen.right() + screenGap, top, gridW, screenH);
Rect sliderWell = new Rect(grid.right() + sliderGap, top, sliderW, screenH);
Rect sliderTrack = new Rect(sliderWell.x() + sliderWell.w() / 2 - 2, sliderWell.y() + 8, 4, sliderWell.h() - 16);
int buttonY = leftScreen.bottom() + BUTTON_TOP_GAP;
int buttonW = buttonH;
Rect menu = new Rect(leftScreen.x(), buttonY, buttonW, buttonH);
Rect left = new Rect(menu.right() + BUTTON_SIDE_GAP, buttonY, buttonW + 6, buttonH);
Rect right = new Rect(left.right() + BUTTON_PAIR_GAP, buttonY, buttonW + 6, buttonH);
Rect bottom = new Rect(grid.x() - 56, buttonY - 14, grid.w() + 104, buttonH + 8);
return new Layout(body, leftScreen, grid, sliderWell, sliderTrack, menu, left, right, bottom);
}
private record Layout(Rect body, Rect leftScreen, Rect grid, Rect sliderWell, Rect sliderTrack, Rect menuButton, Rect leftButton, Rect rightButton, Rect bottomPanel) {
}
private record Rect(int x, int y, int w, int h) {
int right() {
return x + w;
}
int bottom() {
return y + h;
}
}
}
@@ -0,0 +1,36 @@
package dev.blocodex.client.gui;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.renderer.state.gui.GuiElementRenderState;
import net.minecraft.client.renderer.state.gui.GuiRenderState;
import java.lang.reflect.Field;
final class StudioGuiRenderAccess {
private static final Field GUI_RENDER_STATE_FIELD = guiRenderStateField();
private StudioGuiRenderAccess() {
}
static boolean submit(GuiGraphicsExtractor graphics, GuiElementRenderState state) {
if (GUI_RENDER_STATE_FIELD == null) {
return false;
}
try {
((GuiRenderState) GUI_RENDER_STATE_FIELD.get(graphics)).addGuiElement(state);
return true;
} catch (IllegalAccessException | ClassCastException exception) {
return false;
}
}
private static Field guiRenderStateField() {
try {
Field field = GuiGraphicsExtractor.class.getDeclaredField("guiRenderState");
field.setAccessible(true);
return field;
} catch (NoSuchFieldException | SecurityException exception) {
return null;
}
}
}
@@ -0,0 +1,181 @@
package dev.blocodex.client.gui;
import com.mojang.blaze3d.pipeline.RenderPipeline;
import com.mojang.blaze3d.vertex.VertexConsumer;
import net.minecraft.client.gui.navigation.ScreenRectangle;
import net.minecraft.client.gui.render.TextureSetup;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.client.renderer.state.gui.GuiElementRenderState;
import org.joml.Matrix3x2f;
import org.joml.Matrix3x2fc;
import java.util.ArrayList;
import java.util.List;
final class StudioViewportRenderState implements GuiElementRenderState {
private static final RenderPipeline DEFAULT_PIPELINE = RenderPipelines.GUI;
private static final TextureSetup DEFAULT_TEXTURE_SETUP = TextureSetup.noTexture();
private final Matrix3x2f pose;
private final List<Quad> quads;
private final ScreenRectangle scissorArea;
private final ScreenRectangle bounds;
private final RenderPipeline pipeline;
private final TextureSetup textureSetup;
private final boolean textured;
StudioViewportRenderState(Matrix3x2fc pose, List<Quad> quads, ScreenRectangle scissorArea, RenderPipeline pipeline, TextureSetup textureSetup, boolean textured) {
this.pose = new Matrix3x2f(pose);
this.quads = List.copyOf(quads);
this.scissorArea = scissorArea;
this.bounds = bounds(quads, scissorArea);
this.pipeline = pipeline;
this.textureSetup = textureSetup;
this.textured = textured;
}
@Override
public void buildVertices(VertexConsumer consumer) {
for (Quad quad : quads) {
addVertex(consumer, quad.x0(), quad.y0(), quad.u0(), quad.v0(), quad.color());
addVertex(consumer, quad.x1(), quad.y1(), quad.u1(), quad.v1(), quad.color());
addVertex(consumer, quad.x2(), quad.y2(), quad.u2(), quad.v2(), quad.color());
addVertex(consumer, quad.x3(), quad.y3(), quad.u3(), quad.v3(), quad.color());
}
}
private void addVertex(VertexConsumer consumer, float x, float y, float u, float v, int color) {
VertexConsumer vertex = consumer.addVertexWith2DPose(pose, x, y);
if (textured) {
vertex.setUv(u, v);
}
vertex.setColor(color);
}
@Override
public RenderPipeline pipeline() {
return pipeline;
}
@Override
public TextureSetup textureSetup() {
return textureSetup;
}
@Override
public ScreenRectangle scissorArea() {
return scissorArea;
}
@Override
public ScreenRectangle bounds() {
return bounds;
}
static Builder builder() {
return new Builder(DEFAULT_PIPELINE, DEFAULT_TEXTURE_SETUP, false);
}
static Builder texturedBuilder(TextureSetup textureSetup) {
return new Builder(RenderPipelines.GUI_TEXTURED, textureSetup, true);
}
private static ScreenRectangle bounds(List<Quad> quads, ScreenRectangle fallback) {
if (quads.isEmpty()) {
return fallback;
}
int left = Integer.MAX_VALUE;
int top = Integer.MAX_VALUE;
int right = Integer.MIN_VALUE;
int bottom = Integer.MIN_VALUE;
for (Quad quad : quads) {
left = Math.min(left, (int) Math.floor(Math.min(Math.min(quad.x0(), quad.x1()), Math.min(quad.x2(), quad.x3()))));
top = Math.min(top, (int) Math.floor(Math.min(Math.min(quad.y0(), quad.y1()), Math.min(quad.y2(), quad.y3()))));
right = Math.max(right, (int) Math.ceil(Math.max(Math.max(quad.x0(), quad.x1()), Math.max(quad.x2(), quad.x3()))));
bottom = Math.max(bottom, (int) Math.ceil(Math.max(Math.max(quad.y0(), quad.y1()), Math.max(quad.y2(), quad.y3()))));
}
return new ScreenRectangle(left, top, Math.max(1, right - left), Math.max(1, bottom - top));
}
static final class Builder {
private final List<Quad> quads = new ArrayList<>();
private final RenderPipeline pipeline;
private final TextureSetup textureSetup;
private final boolean textured;
private Builder(RenderPipeline pipeline, TextureSetup textureSetup, boolean textured) {
this.pipeline = pipeline;
this.textureSetup = textureSetup;
this.textured = textured;
}
void rect(int x0, int y0, int x1, int y1, int color) {
if (x0 == x1 || y0 == y1 || (color >>> 24) == 0) {
return;
}
int left = Math.min(x0, x1);
int right = Math.max(x0, x1);
int top = Math.min(y0, y1);
int bottom = Math.max(y0, y1);
float rightEdge = Math.max(left + 1, right);
float bottomEdge = Math.max(top + 1, bottom);
quads.add(new Quad(left, top, 0.0F, 0.0F, left, bottomEdge, 0.0F, 0.0F, rightEdge, bottomEdge, 0.0F, 0.0F, rightEdge, top, 0.0F, 0.0F, color));
}
void line(int x0, int y0, int x1, int y1, int thickness, int color) {
if ((color >>> 24) == 0) {
return;
}
float dx = x1 - x0;
float dy = y1 - y0;
float length = (float) Math.sqrt(dx * dx + dy * dy);
if (length <= 0.001F) {
rect(x0, y0, x0 + Math.max(1, thickness), y0 + Math.max(1, thickness), color);
return;
}
float half = Math.max(1.0F, thickness) * 0.5F;
float nx = -dy / length * half;
float ny = dx / length * half;
quads.add(new Quad(x0 + nx, y0 + ny, 0.0F, 0.0F, x1 + nx, y1 + ny, 0.0F, 0.0F, x1 - nx, y1 - ny, 0.0F, 0.0F, x0 - nx, y0 - ny, 0.0F, 0.0F, color));
}
void quad(float x0, float y0, float x1, float y1, float x2, float y2, float x3, float y3, int color) {
quad(x0, y0, 0.0F, 0.0F, x1, y1, 0.0F, 0.0F, x2, y2, 0.0F, 0.0F, x3, y3, 0.0F, 0.0F, color);
}
void texturedQuad(float x0, float y0, float x1, float y1, float x2, float y2, float x3, float y3, float u0, float v0, float u1, float v1, int color) {
quad(x0, y0, u0, v0, x1, y1, u1, v0, x2, y2, u1, v1, x3, y3, u0, v1, color);
}
private void quad(float x0, float y0, float u0, float v0, float x1, float y1, float u1, float v1, float x2, float y2, float u2, float v2, float x3, float y3, float u3, float v3, int color) {
if ((color >>> 24) == 0) {
return;
}
if (signedArea(x0, y0, x1, y1, x2, y2, x3, y3) > 0.0F) {
quads.add(new Quad(x0, y0, u0, v0, x3, y3, u3, v3, x2, y2, u2, v2, x1, y1, u1, v1, color));
} else {
quads.add(new Quad(x0, y0, u0, v0, x1, y1, u1, v1, x2, y2, u2, v2, x3, y3, u3, v3, color));
}
}
private float signedArea(float x0, float y0, float x1, float y1, float x2, float y2, float x3, float y3) {
return 0.5F * (
x0 * y1 - y0 * x1
+ x1 * y2 - y1 * x2
+ x2 * y3 - y2 * x3
+ x3 * y0 - y3 * x0
);
}
boolean isEmpty() {
return quads.isEmpty();
}
StudioViewportRenderState build(Matrix3x2fc pose, ScreenRectangle scissorArea) {
return new StudioViewportRenderState(pose, quads, scissorArea, pipeline, textureSetup, textured);
}
}
private record Quad(float x0, float y0, float u0, float v0, float x1, float y1, float u1, float v1, float x2, float y2, float u2, float v2, float x3, float y3, float u3, float v3, int color) {
}
}
@@ -0,0 +1,422 @@
package dev.blocodex.client.voxel;
import com.mojang.blaze3d.platform.InputConstants;
import dev.blocodex.memory.VoxelProjectMemory;
import dev.blocodex.network.BlocodexPlaceVoxelProjectPayload;
import dev.blocodex.voxel.VoxelProjectPlacement;
import net.fabricmc.fabric.api.client.keymapping.v1.KeyMappingHelper;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.KeyMapping;
import net.minecraft.client.Minecraft;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.gizmos.GizmoStyle;
import net.minecraft.gizmos.Gizmos;
import net.minecraft.gizmos.TextGizmo;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.level.ClipContext;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.material.MapColor;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.HitResult;
import net.minecraft.world.phys.Vec3;
import org.lwjgl.glfw.GLFW;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
public final class BlocodexBuildPlacementController {
private static final double RAYCAST_DISTANCE = 128.0D;
private static final int BLUEPRINT_COLOR = 0xFF62D6A4;
private static final int DIRECT_COLOR = 0xFFFFC857;
private static final int CURRENT_COLOR = 0xFFB7F7FF;
private static final int MAX_PREVIEW_CELLS = 4096;
private static final Map<String, Integer> BLOCK_COLOR_CACHE = new HashMap<>();
private static KeyMapping rotate;
private static KeyMapping cancel;
private static KeyMapping confirm;
private static KeyMapping undo;
private static KeyMapping redo;
private static VoxelProjectMemory activeProject;
private static Mode mode = Mode.BLUEPRINT;
private static BlockPos anchor;
private static Direction facing = Direction.NORTH;
private static boolean awaitingAnchor;
private static boolean previewVisible = true;
private BlocodexBuildPlacementController() {
}
public static void initialize() {
if (rotate != null && cancel != null && confirm != null) {
return;
}
rotate = KeyMappingHelper.registerKeyMapping(new KeyMapping(
"key.blocodex.rotate_blueprint",
InputConstants.Type.KEYSYM,
GLFW.GLFW_KEY_R,
KeyMapping.Category.INVENTORY
));
cancel = KeyMappingHelper.registerKeyMapping(new KeyMapping(
"key.blocodex.cancel_blueprint",
InputConstants.Type.KEYSYM,
GLFW.GLFW_KEY_V,
KeyMapping.Category.INVENTORY
));
confirm = KeyMappingHelper.registerKeyMapping(new KeyMapping(
"key.blocodex.confirm_blueprint",
InputConstants.Type.KEYSYM,
GLFW.GLFW_KEY_ENTER,
KeyMapping.Category.INVENTORY
));
undo = KeyMappingHelper.registerKeyMapping(new KeyMapping(
"key.blocodex.undo_construction",
InputConstants.Type.KEYSYM,
GLFW.GLFW_KEY_Z,
KeyMapping.Category.INVENTORY
));
redo = KeyMappingHelper.registerKeyMapping(new KeyMapping(
"key.blocodex.redo_construction",
InputConstants.Type.KEYSYM,
GLFW.GLFW_KEY_Y,
KeyMapping.Category.INVENTORY
));
}
public static void startBlueprint(VoxelProjectMemory project) {
start(project, Mode.BLUEPRINT);
}
public static void startCreativeDirect(VoxelProjectMemory project) {
Minecraft client = Minecraft.getInstance();
if (client.player != null && !client.player.isCreative()) {
client.player.sendOverlayMessage(Component.literal("Pose directe reservee au mode creatif."));
return;
}
start(project, Mode.CREATIVE_DIRECT);
}
public static void reset() {
activeProject = null;
anchor = null;
awaitingAnchor = false;
mode = Mode.BLUEPRINT;
previewVisible = true;
}
public static boolean isConstructionActive() {
return activeProject != null;
}
public static void togglePreviewVisibility(Minecraft client) {
if (activeProject == null || client.player == null) {
return;
}
previewVisible = !previewVisible;
client.player.sendOverlayMessage(Component.literal(previewVisible
? "Apercu de construction affiche."
: "Apercu de construction cache. Appuie sur C pour le revoir."));
}
public static void onEndTick(Minecraft client) {
if (client.player == null || client.level == null) {
if (activeProject != null) {
reset();
}
return;
}
if (client.screen == null) {
while (undo != null && undo.consumeClick()) {
requestHistory(BlocodexPlaceVoxelProjectPayload.MODE_UNDO);
}
while (redo != null && redo.consumeClick()) {
requestHistory(BlocodexPlaceVoxelProjectPayload.MODE_REDO);
}
}
if (activeProject == null) {
return;
}
if (awaitingAnchor) {
anchor = resolvePreviewAnchor(client);
}
if (client.screen == null) {
while (rotate != null && rotate.consumeClick()) {
if (awaitingAnchor) {
facing = facing.getClockWise();
}
}
while (cancel != null && cancel.consumeClick()) {
reset();
client.player.sendOverlayMessage(Component.literal("Blueprint annule."));
}
while (awaitingAnchor && confirm != null && confirm.consumeClick()) {
if (anchor != null) {
confirmAnchor(anchor);
if (activeProject == null) {
return;
}
}
}
while (awaitingAnchor && client.options.keyUse.consumeClick()) {
if (anchor != null) {
confirmAnchor(anchor);
if (activeProject == null) {
return;
}
}
}
}
if (previewVisible) {
render(client);
}
}
public static InteractionResult confirmFromAttack(BlockPos pos, Direction face) {
if (face == null) {
return InteractionResult.PASS;
}
return confirmAnchor(pos.relative(face));
}
public static InteractionResult confirmFromUse(BlockHitResult hitResult) {
return confirmAnchor(hitResult.getBlockPos().relative(hitResult.getDirection()));
}
public static InteractionResult confirmCurrentAnchor() {
if (anchor == null) {
return InteractionResult.PASS;
}
return confirmAnchor(anchor);
}
private static void start(VoxelProjectMemory project, Mode nextMode) {
activeProject = project;
mode = nextMode;
awaitingAnchor = true;
anchor = null;
previewVisible = true;
Minecraft client = Minecraft.getInstance();
if (client.player != null) {
facing = horizontal(client.player.getDirection());
client.player.sendOverlayMessage(Component.literal(nextMode == Mode.CREATIVE_DIRECT
? "Choisis l'ancre de pose directe."
: "Choisis l'ancre du blueprint."));
}
}
private static InteractionResult confirmAnchor(BlockPos selectedAnchor) {
if (activeProject == null || !awaitingAnchor) {
return InteractionResult.PASS;
}
anchor = selectedAnchor.immutable();
awaitingAnchor = false;
Minecraft client = Minecraft.getInstance();
if (mode == Mode.CREATIVE_DIRECT) {
if (ClientPlayNetworking.canSend(BlocodexPlaceVoxelProjectPayload.TYPE)) {
ClientPlayNetworking.send(new BlocodexPlaceVoxelProjectPayload(
activeProject.id(),
anchor,
facing,
BlocodexPlaceVoxelProjectPayload.MODE_CREATIVE_DIRECT
));
}
reset();
} else if (client.player != null) {
client.player.sendOverlayMessage(Component.literal("Blueprint actif: pose le bloc indique."));
}
return InteractionResult.FAIL;
}
private static void requestHistory(String action) {
if (ClientPlayNetworking.canSend(BlocodexPlaceVoxelProjectPayload.TYPE)) {
ClientPlayNetworking.send(new BlocodexPlaceVoxelProjectPayload(
"",
BlockPos.ZERO,
Direction.NORTH,
action
));
}
}
private static void render(Minecraft client) {
if (anchor == null || activeProject == null) {
return;
}
try (Gizmos.TemporaryCollection ignored = client.collectPerTickGizmos()) {
AABB box = projectBox(activeProject, anchor, facing);
int color = mode == Mode.CREATIVE_DIRECT ? DIRECT_COLOR : BLUEPRINT_COLOR;
Gizmos.cuboid(box, GizmoStyle.strokeAndFill(color, 2.0F, withAlpha(color, 0x18)), false).setAlwaysOnTop();
renderProjectCells(client, activeProject, anchor, facing);
Vec3 center = box.getCenter();
String title = mode == Mode.CREATIVE_DIRECT ? "Pose creative: " + activeProject.name() : activeProject.name();
Gizmos.billboardText(title, new Vec3(center.x, box.maxY + 1.25D, center.z), TextGizmo.Style.forColorAndCentered(color).withScale(0.78F)).setAlwaysOnTop();
if (awaitingAnchor) {
Gizmos.billboardText("Clic droit/Entree ancrer | R tourner | V annuler", new Vec3(center.x, box.maxY + 0.72D, center.z), TextGizmo.Style.forColorAndCentered(0xFFD3ECFF).withScale(0.58F)).setAlwaysOnTop();
return;
}
Progress progress = progress(client, activeProject, anchor, facing);
Gizmos.billboardText(progress.done() + " / " + progress.total(), new Vec3(center.x, box.maxY + 0.72D, center.z), TextGizmo.Style.forColorAndCentered(0xFFD3ECFF).withScale(0.62F)).setAlwaysOnTop();
if (progress.current() != null) {
BlockPos pos = VoxelProjectPlacement.transform(activeProject, progress.current(), anchor, facing);
Gizmos.cuboid(new AABB(pos), GizmoStyle.strokeAndFill(CURRENT_COLOR, 2.8F, withAlpha(CURRENT_COLOR, 0x35)), false).setAlwaysOnTop();
Gizmos.billboardText(shortId(progress.current().blockId()), new Vec3(pos.getX() + 0.5D, pos.getY() + 1.25D, pos.getZ() + 0.5D), TextGizmo.Style.forColorAndCentered(CURRENT_COLOR).withScale(0.55F)).setAlwaysOnTop();
} else {
Gizmos.billboardText("Termine", new Vec3(center.x, box.maxY + 0.25D, center.z), TextGizmo.Style.forColorAndCentered(0xFF9CFFB6).withScale(0.72F)).setAlwaysOnTop();
}
}
}
private static Progress progress(Minecraft client, VoxelProjectMemory project, BlockPos anchor, Direction facing) {
List<VoxelProjectPlacement.Cell> cells = VoxelProjectPlacement.orderedCells(project);
int done = 0;
VoxelProjectPlacement.Cell current = null;
for (VoxelProjectPlacement.Cell cell : cells) {
BlockPos worldPos = VoxelProjectPlacement.transform(project, cell, anchor, facing);
if (matches(client, worldPos, cell.blockId())) {
done++;
} else if (current == null) {
current = cell;
}
}
return new Progress(done, cells.size(), current);
}
private static boolean matches(Minecraft client, BlockPos pos, String blockId) {
if (client.level == null) {
return false;
}
Identifier id = Identifier.tryParse(blockId);
if (id == null) {
return false;
}
Optional<Block> block = BuiltInRegistries.BLOCK.getOptional(id);
return block.isPresent() && client.level.getBlockState(pos).getBlock() == block.get();
}
private static void renderProjectCells(Minecraft client, VoxelProjectMemory project, BlockPos anchor, Direction facing) {
List<VoxelProjectPlacement.Cell> cells = VoxelProjectPlacement.orderedCells(project);
int stride = Math.max(1, (int) Math.ceil(cells.size() / (double) MAX_PREVIEW_CELLS));
for (int i = 0; i < cells.size(); i += stride) {
VoxelProjectPlacement.Cell cell = cells.get(i);
BlockPos pos = VoxelProjectPlacement.transform(project, cell, anchor, facing);
int color = blockPreviewColor(client, cell.blockId());
Gizmos.cuboid(new AABB(pos), GizmoStyle.strokeAndFill(withAlpha(color, 0xB8), 0.25F, withAlpha(color, 0x48)), false).setAlwaysOnTop();
}
}
private static int blockPreviewColor(Minecraft client, String blockId) {
Integer cached = BLOCK_COLOR_CACHE.get(blockId);
if (cached != null) {
return cached;
}
Identifier id = Identifier.tryParse(blockId);
Block block = id == null ? Blocks.AIR : BuiltInRegistries.BLOCK.getOptional(id).orElse(Blocks.AIR);
int color = 0xFF7F7F7F;
if (block != Blocks.AIR && client.level != null) {
MapColor mapColor = block.defaultBlockState().getMapColor(client.level, BlockPos.ZERO);
color = 0xFF000000 | (mapColor.calculateARGBColor(MapColor.Brightness.NORMAL) & 0x00FFFFFF);
}
BLOCK_COLOR_CACHE.put(blockId, color);
return color;
}
private static AABB projectBox(VoxelProjectMemory project, BlockPos anchor, Direction facing) {
List<VoxelProjectPlacement.Cell> cells = VoxelProjectPlacement.orderedCells(project);
if (cells.isEmpty()) {
return new AABB(anchor);
}
int minX = Integer.MAX_VALUE;
int minY = Integer.MAX_VALUE;
int minZ = Integer.MAX_VALUE;
int maxX = Integer.MIN_VALUE;
int maxY = Integer.MIN_VALUE;
int maxZ = Integer.MIN_VALUE;
for (VoxelProjectPlacement.Cell cell : cells) {
BlockPos pos = VoxelProjectPlacement.transform(project, cell, anchor, facing);
minX = Math.min(minX, pos.getX());
minY = Math.min(minY, pos.getY());
minZ = Math.min(minZ, pos.getZ());
maxX = Math.max(maxX, pos.getX());
maxY = Math.max(maxY, pos.getY());
maxZ = Math.max(maxZ, pos.getZ());
}
return new AABB(minX, minY, minZ, maxX + 1, maxY + 1, maxZ + 1);
}
private static Direction horizontal(Direction direction) {
if (direction == Direction.UP || direction == Direction.DOWN || direction == null) {
return Direction.NORTH;
}
return direction;
}
private static BlockHitResult resolvePreviewHit(Minecraft client) {
if (client.level == null) {
return null;
}
Entity cameraEntity = client.getCameraEntity();
if (cameraEntity == null) {
cameraEntity = client.player;
}
if (cameraEntity == null) {
return null;
}
Vec3 from = cameraEntity.getEyePosition();
Vec3 to = from.add(cameraEntity.getViewVector(1.0F).scale(RAYCAST_DISTANCE));
BlockHitResult hitResult = client.level.clip(new ClipContext(from, to, ClipContext.Block.OUTLINE, ClipContext.Fluid.NONE, cameraEntity));
return hitResult.getType() == HitResult.Type.BLOCK ? hitResult : null;
}
private static BlockPos resolvePreviewAnchor(Minecraft client) {
Entity cameraEntity = client.getCameraEntity();
if (cameraEntity == null) {
cameraEntity = client.player;
}
if (cameraEntity == null) {
return anchor;
}
BlockHitResult hit = resolvePreviewHit(client);
if (hit != null) {
return hit.getBlockPos().relative(hit.getDirection()).immutable();
}
Vec3 from = cameraEntity.getEyePosition();
Vec3 to = from.add(cameraEntity.getViewVector(1.0F).scale(8.0D));
return BlockPos.containing(to).immutable();
}
private static String shortId(String id) {
int split = id.indexOf(':');
return split >= 0 ? id.substring(split + 1) : id;
}
private static int withAlpha(int color, int alpha) {
return (alpha << 24) | (color & 0x00FFFFFF);
}
private enum Mode {
BLUEPRINT,
CREATIVE_DIRECT
}
private record Progress(int done, int total, VoxelProjectPlacement.Cell current) {
}
}
@@ -0,0 +1,744 @@
package dev.blocodex.client.voxel;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import dev.blocodex.codex.WsmcCodexIndex;
import dev.blocodex.memory.VoxelProjectMemory;
import dev.blocodex.voxel.VoxelProjectPlacement;
import dev.blocodex.voxel.VoxelProjectValidator;
import net.minecraft.client.Minecraft;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.server.packs.resources.Resource;
import net.minecraft.server.packs.resources.ResourceManager;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.material.MapColor;
import net.minecraft.world.level.block.Block;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
public final class ClientVoxelProjectBuilder {
private static final int GRID = 16;
private static final Map<String, Integer> FALLBACK_CONCRETE_COLORS = Map.ofEntries(
Map.entry("minecraft:white_concrete", 0xF9FFFE),
Map.entry("minecraft:orange_concrete", 0xF9801D),
Map.entry("minecraft:magenta_concrete", 0xC74EBD),
Map.entry("minecraft:light_blue_concrete", 0x3AB3DA),
Map.entry("minecraft:yellow_concrete", 0xFED83D),
Map.entry("minecraft:lime_concrete", 0x80C71F),
Map.entry("minecraft:pink_concrete", 0xF38BAA),
Map.entry("minecraft:gray_concrete", 0x474F52),
Map.entry("minecraft:light_gray_concrete", 0x9D9D97),
Map.entry("minecraft:cyan_concrete", 0x169C9C),
Map.entry("minecraft:purple_concrete", 0x8932B8),
Map.entry("minecraft:blue_concrete", 0x3C44AA),
Map.entry("minecraft:brown_concrete", 0x835432),
Map.entry("minecraft:green_concrete", 0x5E7C16),
Map.entry("minecraft:red_concrete", 0xB02E26),
Map.entry("minecraft:black_concrete", 0x1D1D21)
);
private ClientVoxelProjectBuilder() {
}
public enum BuildMode {
FACE("face", "Face"),
HOLLOW("hollow", "Vide"),
FULL("full", "Plein");
private final String id;
private final String label;
BuildMode(String id, String label) {
this.id = id;
this.label = label;
}
public String id() {
return id;
}
public String label() {
return label;
}
public BuildMode next() {
BuildMode[] values = values();
return values[(ordinal() + 1) % values.length];
}
}
public static VoxelProjectMemory build(String entryId, String displayName) {
return build(entryId, displayName, BuildMode.FACE, List.of());
}
public static VoxelProjectMemory build(String entryId, String displayName, BuildMode mode) {
return build(entryId, displayName, mode, List.of());
}
public static VoxelProjectMemory build(String entryId, String displayName, BuildMode mode, List<String> paletteBlockIds) {
Identifier id = Identifier.tryParse(entryId);
if (id == null) {
throw new IllegalArgumentException("Identifiant invalide: " + entryId);
}
ResourceManager resources = Minecraft.getInstance().getResourceManager();
ColorPalette colorPalette = ColorPalette.from(paletteBlockIds);
Optional<Block> block = BuiltInRegistries.BLOCK.getOptional(id);
if (block.isPresent()) {
return blockProject(resources, id, block.get(), displayName, mode == null ? BuildMode.FACE : mode, colorPalette);
}
Optional<Item> item = BuiltInRegistries.ITEM.getOptional(id);
if (item.isPresent() && item.get() != Items.AIR) {
return itemProject(resources, id, displayName, colorPalette);
}
throw new IllegalArgumentException("Entree non voxelisable pour l'instant: " + entryId);
}
private static VoxelProjectMemory blockProject(ResourceManager resources, Identifier id, Block block, String displayName, BuildMode mode, ColorPalette colorPalette) {
String blockId = id.toString();
if (!VoxelProjectValidator.isBuildableFullBlock(blockId)) {
throw new IllegalArgumentException("Bloc non constructible plein: " + blockId);
}
colorPalette.requireUsable();
Map<String, String> cells = new LinkedHashMap<>();
Set<String> palette = new LinkedHashSet<>();
Map<String, BufferedImage> textureCache = new LinkedHashMap<>();
List<JsonObject> models = blockModels(resources, id);
if (models.isEmpty()) {
addFallbackBlock(resources, id, cells, palette, textureCache, mode, colorPalette);
} else {
for (JsonObject model : models) {
JsonArray elements = array(model, "elements");
if (elements.isEmpty()) {
addFallbackBlock(resources, id, cells, palette, textureCache, mode, colorPalette);
continue;
}
for (JsonElement element : elements) {
if (!element.isJsonObject()) {
continue;
}
addTexturedElement(resources, model, element.getAsJsonObject(), cells, palette, textureCache, mode, colorPalette);
}
}
}
if (cells.isEmpty()) {
throw new IllegalArgumentException("Aucun voxel opaque a generer: " + blockId);
}
return new VoxelProjectMemory(
projectId(id, mode),
projectName(id, displayName, mode),
GRID,
GRID,
mode == BuildMode.FACE ? 1 : GRID,
List.copyOf(palette),
cells,
0L,
System.currentTimeMillis()
);
}
private static VoxelProjectMemory itemProject(ResourceManager resources, Identifier id, String displayName, ColorPalette colorPalette) {
colorPalette.requireUsable();
JsonObject itemModel = itemModel(resources, id);
JsonObject textures = object(itemModel, "textures");
String textureRef = resolveTexture(textures, "#layer0");
if (textureRef == null) {
textureRef = resolveTexture(textures, "#all");
}
if (textureRef == null) {
textureRef = resolveTexture(textures, "#particle");
}
if (textureRef == null) {
throw new IllegalArgumentException("Texture item introuvable: " + id);
}
BufferedImage image = readTexture(resources, textureRef);
Map<String, String> cells = new LinkedHashMap<>();
Set<String> palette = new LinkedHashSet<>();
for (int x = 0; x < GRID; x++) {
for (int y = 0; y < GRID; y++) {
int sampleX = Math.min(image.getWidth() - 1, x * image.getWidth() / GRID);
int sampleY = Math.min(image.getHeight() - 1, y * image.getHeight() / GRID);
int argb = image.getRGB(sampleX, sampleY);
int alpha = (argb >>> 24) & 0xFF;
if (alpha < 24) {
continue;
}
String blockId = colorPalette.nearest(argb & 0xFFFFFF);
palette.add(blockId);
cells.put(VoxelProjectPlacement.cellKey(x, GRID - 1 - y, 0), blockId);
}
}
if (cells.isEmpty()) {
throw new IllegalArgumentException("Aucun pixel opaque a voxeliser: " + id);
}
return new VoxelProjectMemory(
projectId(id, BuildMode.FACE),
displayName == null || displayName.isBlank() ? title(id) : displayName,
GRID,
GRID,
1,
List.copyOf(palette),
cells,
0L,
System.currentTimeMillis()
);
}
private static List<JsonObject> blockModels(ResourceManager resources, Identifier id) {
JsonObject blockstate = readJson(resources, Identifier.fromNamespaceAndPath(id.getNamespace(), "blockstates/" + id.getPath() + ".json"));
List<String> refs = new ArrayList<>();
JsonObject variants = object(blockstate, "variants");
if (!variants.entrySet().isEmpty()) {
JsonElement first = variants.entrySet().iterator().next().getValue();
addModelRefs(refs, first);
}
JsonArray multipart = array(blockstate, "multipart");
for (JsonElement element : multipart) {
if (element.isJsonObject()) {
addModelRefs(refs, element.getAsJsonObject().get("apply"));
}
}
List<JsonObject> models = new ArrayList<>();
for (String ref : refs) {
resolveModel(resources, ref, new LinkedHashSet<>()).ifPresent(models::add);
}
return models;
}
private static JsonObject itemModel(ResourceManager resources, Identifier id) {
String modelRef = null;
Optional<JsonObject> itemDefinition = maybeReadJson(resources, Identifier.fromNamespaceAndPath(id.getNamespace(), "items/" + id.getPath() + ".json"));
if (itemDefinition.isPresent()) {
JsonObject model = object(itemDefinition.get(), "model");
if ("minecraft:model".equals(string(model, "type", ""))) {
modelRef = string(model, "model", null);
}
}
if (modelRef == null || modelRef.isBlank()) {
modelRef = id.getNamespace() + ":item/" + id.getPath();
}
String resolvedModelRef = modelRef;
return resolveModel(resources, resolvedModelRef, new LinkedHashSet<>())
.orElseThrow(() -> new IllegalArgumentException("Modele item introuvable: " + resolvedModelRef));
}
private static void addModelRefs(List<String> refs, JsonElement element) {
if (element == null) {
return;
}
if (element.isJsonArray()) {
for (JsonElement child : element.getAsJsonArray()) {
addModelRefs(refs, child);
}
return;
}
if (!element.isJsonObject()) {
return;
}
String model = string(element.getAsJsonObject(), "model", "");
if (!model.isBlank()) {
refs.add(model);
}
}
private static Optional<JsonObject> resolveModel(ResourceManager resources, String ref, Set<String> seen) {
Ref split = splitRef(ref);
String key = split.namespace() + ":" + split.path();
if (!seen.add(key)) {
return Optional.empty();
}
Optional<JsonObject> maybeRaw = maybeReadJson(resources, Identifier.fromNamespaceAndPath(split.namespace(), "models/" + split.path() + ".json"));
if (maybeRaw.isEmpty()) {
return Optional.empty();
}
JsonObject raw = maybeRaw.get();
JsonObject merged = new JsonObject();
JsonObject textures = new JsonObject();
String parent = string(raw, "parent", "");
if (!parent.isBlank()) {
resolveModel(resources, parent, seen).ifPresent(parentModel -> {
copyObject(object(parentModel, "textures"), textures);
if (parentModel.has("elements")) {
merged.add("elements", parentModel.get("elements").deepCopy());
}
});
}
copyObject(object(raw, "textures"), textures);
merged.add("textures", textures);
if (raw.has("elements")) {
merged.add("elements", raw.get("elements").deepCopy());
}
return Optional.of(merged);
}
private static void addFallbackBlock(ResourceManager resources, Identifier id, Map<String, String> cells, Set<String> palette, Map<String, BufferedImage> textureCache, BuildMode mode, ColorPalette colorPalette) {
JsonObject model = new JsonObject();
JsonObject textures = new JsonObject();
textures.addProperty("all", id.getNamespace() + ":block/" + id.getPath());
textures.addProperty("particle", "#" + "all");
model.add("textures", textures);
JsonObject element = new JsonObject();
element.add("from", coordsArray(0, 0, 0));
element.add("to", coordsArray(GRID, GRID, GRID));
JsonObject faces = new JsonObject();
for (String face : List.of("north", "south", "east", "west", "up", "down")) {
JsonObject faceObject = new JsonObject();
faceObject.addProperty("texture", "#all");
faces.add(face, faceObject);
}
element.add("faces", faces);
addTexturedElement(resources, model, element, cells, palette, textureCache, mode, colorPalette);
}
private static void addTexturedElement(
ResourceManager resources,
JsonObject model,
JsonObject element,
Map<String, String> cells,
Set<String> palette,
Map<String, BufferedImage> textureCache,
BuildMode mode,
ColorPalette colorPalette
) {
int[] from = coords(element.get("from"), 0, true);
int[] to = coords(element.get("to"), GRID, false);
int startX = clamp(Math.min(from[0], to[0]));
int startY = clamp(Math.min(from[1], to[1]));
int startZ = clamp(Math.min(from[2], to[2]));
int endX = clamp(Math.max(from[0], to[0]));
int endY = clamp(Math.max(from[1], to[1]));
int endZ = clamp(Math.max(from[2], to[2]));
JsonObject faces = object(element, "faces");
if (mode == BuildMode.FACE) {
addProjectedFace(resources, model, faces, startX, startY, startZ, endX, endY, endZ, cells, palette, textureCache, colorPalette);
return;
}
for (int x = startX; x < endX; x++) {
for (int y = startY; y < endY; y++) {
for (int z = startZ; z < endZ; z++) {
if (mode == BuildMode.HOLLOW && !isBoundary(x, y, z, startX, startY, startZ, endX, endY, endZ)) {
continue;
}
String face = nearestFace(x, y, z, startX, startY, startZ, endX, endY, endZ);
String blockId = blockForFace(resources, model, faces, face, x, y, z, startX, startY, startZ, endX, endY, endZ, textureCache, colorPalette);
if (blockId != null) {
palette.add(blockId);
cells.put(VoxelProjectPlacement.cellKey(x, y, z), blockId);
}
}
}
}
}
private static void addProjectedFace(
ResourceManager resources,
JsonObject model,
JsonObject faces,
int startX,
int startY,
int startZ,
int endX,
int endY,
int endZ,
Map<String, String> cells,
Set<String> palette,
Map<String, BufferedImage> textureCache,
ColorPalette colorPalette
) {
String face = projectedFace(faces);
for (int x = startX; x < endX; x++) {
for (int y = startY; y < endY; y++) {
int sourceZ = face.equals("south") ? endZ - 1 : startZ;
String blockId = blockForFace(resources, model, faces, face, x, y, sourceZ, startX, startY, startZ, endX, endY, endZ, textureCache, colorPalette);
if (blockId != null) {
palette.add(blockId);
cells.put(VoxelProjectPlacement.cellKey(x, y, 0), blockId);
}
}
}
}
private static String projectedFace(JsonObject faces) {
for (String face : List.of("north", "south", "east", "west", "up", "down")) {
if (faces.has(face)) {
return face;
}
}
return "north";
}
private static String blockForFace(
ResourceManager resources,
JsonObject model,
JsonObject faces,
String face,
int x,
int y,
int z,
int startX,
int startY,
int startZ,
int endX,
int endY,
int endZ,
Map<String, BufferedImage> textureCache,
ColorPalette colorPalette
) {
JsonObject faceObject = object(faces, face);
String textureRef = faceTexture(model, faces, face);
if (textureRef == null) {
return null;
}
BufferedImage image = readTextureCached(resources, textureRef, textureCache);
int[] uv = uv(faceObject);
int[] faceUv = faceUv(face, x, y, z, startX, startY, startZ, endX, endY, endZ);
int argb = sampleTexture(image, uv, faceUv[0], faceUv[1]);
int alpha = (argb >>> 24) & 0xFF;
return alpha < 24 ? null : colorPalette.nearest(argb & 0xFFFFFF);
}
private static String faceTexture(JsonObject model, JsonObject faces, String face) {
JsonObject textures = object(model, "textures");
JsonObject faceObject = object(faces, face);
String ref = string(faceObject, "texture", "");
String texture = resolveTexture(textures, ref);
if (texture != null) {
return texture;
}
for (String fallback : textureFallbacks(face)) {
texture = resolveTexture(textures, fallback);
if (texture != null) {
return texture;
}
}
return null;
}
private static List<String> textureFallbacks(String face) {
return switch (face) {
case "up" -> List.of("#up", "#top", "#end", "#all", "#side", "#particle");
case "down" -> List.of("#down", "#bottom", "#end", "#all", "#side", "#particle");
default -> List.of("#" + face, "#side", "#all", "#particle", "#top");
};
}
private static int[] uv(JsonObject faceObject) {
JsonArray uv = array(faceObject, "uv");
if (uv.size() >= 4) {
return new int[]{
clampTexture((int) Math.floor(uv.get(0).getAsDouble())),
clampTexture((int) Math.floor(uv.get(1).getAsDouble())),
clampTexture((int) Math.ceil(uv.get(2).getAsDouble())),
clampTexture((int) Math.ceil(uv.get(3).getAsDouble()))
};
}
return new int[]{0, 0, GRID, GRID};
}
private static int[] faceUv(String face, int x, int y, int z, int startX, int startY, int startZ, int endX, int endY, int endZ) {
int localX = scaleToGrid(x - startX, endX - startX);
int localY = scaleToGrid(y - startY, endY - startY);
int localZ = scaleToGrid(z - startZ, endZ - startZ);
return switch (face) {
case "south" -> new int[]{localX, GRID - 1 - localY};
case "east" -> new int[]{localZ, GRID - 1 - localY};
case "west" -> new int[]{GRID - 1 - localZ, GRID - 1 - localY};
case "up" -> new int[]{localX, localZ};
case "down" -> new int[]{localX, GRID - 1 - localZ};
default -> new int[]{GRID - 1 - localX, GRID - 1 - localY};
};
}
private static int sampleTexture(BufferedImage image, int[] uv, int uIndex, int vIndex) {
double uStart = uv[0];
double vStart = uv[1];
double uEnd = uv[2];
double vEnd = uv[3];
double u = uStart + (uIndex + 0.5D) * (uEnd - uStart) / GRID;
double v = vStart + (vIndex + 0.5D) * (vEnd - vStart) / GRID;
int sampleX = Math.max(0, Math.min(image.getWidth() - 1, (int) Math.floor(u * image.getWidth() / GRID)));
int sampleY = Math.max(0, Math.min(image.getHeight() - 1, (int) Math.floor(v * image.getHeight() / GRID)));
return image.getRGB(sampleX, sampleY);
}
private static boolean isBoundary(int x, int y, int z, int startX, int startY, int startZ, int endX, int endY, int endZ) {
return x == startX || x == endX - 1 || y == startY || y == endY - 1 || z == startZ || z == endZ - 1;
}
private static String nearestFace(int x, int y, int z, int startX, int startY, int startZ, int endX, int endY, int endZ) {
int bestDistance = z - startZ;
String best = "north";
int south = endZ - 1 - z;
if (south < bestDistance) {
bestDistance = south;
best = "south";
}
int west = x - startX;
if (west < bestDistance) {
bestDistance = west;
best = "west";
}
int east = endX - 1 - x;
if (east < bestDistance) {
bestDistance = east;
best = "east";
}
int down = y - startY;
if (down < bestDistance) {
bestDistance = down;
best = "down";
}
int up = endY - 1 - y;
if (up < bestDistance) {
best = "up";
}
return best;
}
private static int[] coords(JsonElement element, int fallback, boolean min) {
if (element == null || !element.isJsonArray() || element.getAsJsonArray().size() < 3) {
return new int[]{fallback, fallback, fallback};
}
JsonArray values = element.getAsJsonArray();
return new int[]{
clamp((int) (min ? Math.floor(values.get(0).getAsDouble()) : Math.ceil(values.get(0).getAsDouble()))),
clamp((int) (min ? Math.floor(values.get(1).getAsDouble()) : Math.ceil(values.get(1).getAsDouble()))),
clamp((int) (min ? Math.floor(values.get(2).getAsDouble()) : Math.ceil(values.get(2).getAsDouble())))
};
}
private static JsonArray coordsArray(int x, int y, int z) {
JsonArray array = new JsonArray();
array.add(x);
array.add(y);
array.add(z);
return array;
}
private static BufferedImage readTextureCached(ResourceManager resources, String textureRef, Map<String, BufferedImage> cache) {
BufferedImage cached = cache.get(textureRef);
if (cached != null) {
return cached;
}
BufferedImage image = readTexture(resources, textureRef);
cache.put(textureRef, image);
return image;
}
private static BufferedImage readTexture(ResourceManager resources, String textureRef) {
Ref split = splitRef(textureRef);
Identifier textureId = Identifier.fromNamespaceAndPath(split.namespace(), "textures/" + split.path() + ".png");
Resource resource = resources.getResource(textureId)
.orElseThrow(() -> new IllegalArgumentException("Texture introuvable: " + textureRef));
try (InputStream stream = resource.open()) {
BufferedImage image = ImageIO.read(stream);
if (image == null) {
throw new IllegalArgumentException("Texture illisible: " + textureRef);
}
return image;
} catch (IOException exception) {
throw new IllegalArgumentException("Texture illisible: " + textureRef, exception);
}
}
private static JsonObject readJson(ResourceManager resources, Identifier id) {
return maybeReadJson(resources, id).orElseThrow(() -> new IllegalArgumentException("Asset introuvable: " + id));
}
private static Optional<JsonObject> maybeReadJson(ResourceManager resources, Identifier id) {
Optional<Resource> resource = resources.getResource(id);
if (resource.isEmpty()) {
return Optional.empty();
}
try (Reader reader = resource.get().openAsReader()) {
JsonElement parsed = JsonParser.parseReader(reader);
return parsed.isJsonObject() ? Optional.of(parsed.getAsJsonObject()) : Optional.empty();
} catch (IOException | IllegalStateException exception) {
return Optional.empty();
}
}
private static String resolveTexture(JsonObject textures, String ref) {
if (ref == null || ref.isBlank()) {
return null;
}
String current = ref;
Set<String> seen = new LinkedHashSet<>();
while (current.startsWith("#")) {
String key = current.substring(1);
if (!seen.add(key) || !textures.has(key) || !textures.get(key).isJsonPrimitive()) {
return null;
}
current = textures.get(key).getAsString();
}
Ref split = splitRef(current);
return split.namespace() + ":" + split.path();
}
private static int fallbackColor(String blockId) {
Integer concrete = FALLBACK_CONCRETE_COLORS.get(blockId);
if (concrete != null) {
return concrete;
}
WsmcCodexIndex.WsmcEntry entry = WsmcCodexIndex.loadDefault().entry(blockId).orElse(null);
if (entry != null) {
return entry.primaryArgb() & 0xFFFFFF;
}
Identifier id = Identifier.tryParse(blockId);
if (id == null) {
return 0x7F7F7F;
}
Block block = BuiltInRegistries.BLOCK.getOptional(id).orElse(null);
if (block == null || Minecraft.getInstance().level == null) {
return 0x7F7F7F;
}
MapColor mapColor = block.defaultBlockState().getMapColor(Minecraft.getInstance().level, BlockPos.ZERO);
return mapColor.calculateARGBColor(MapColor.Brightness.NORMAL) & 0xFFFFFF;
}
private static String nearestColor(int rgb, List<PaletteEntry> entries) {
String best = entries.getFirst().blockId();
long bestDistance = Long.MAX_VALUE;
int r = (rgb >> 16) & 0xFF;
int g = (rgb >> 8) & 0xFF;
int b = rgb & 0xFF;
for (PaletteEntry entry : entries) {
int color = entry.rgb();
long dr = r - ((color >> 16) & 0xFF);
long dg = g - ((color >> 8) & 0xFF);
long db = b - (color & 0xFF);
long distance = dr * dr + dg * dg + db * db;
if (distance < bestDistance) {
bestDistance = distance;
best = entry.blockId();
}
}
return best;
}
private static JsonObject object(JsonObject object, String key) {
JsonElement element = object.get(key);
return element != null && element.isJsonObject() ? element.getAsJsonObject() : new JsonObject();
}
private static JsonArray array(JsonObject object, String key) {
JsonElement element = object.get(key);
return element != null && element.isJsonArray() ? element.getAsJsonArray() : new JsonArray();
}
private static String string(JsonObject object, String key, String fallback) {
JsonElement element = object.get(key);
return element != null && element.isJsonPrimitive() ? element.getAsString() : fallback;
}
private static void copyObject(JsonObject source, JsonObject target) {
for (Map.Entry<String, JsonElement> entry : source.entrySet()) {
target.add(entry.getKey(), entry.getValue().deepCopy());
}
}
private static int clamp(int value) {
return Math.max(0, Math.min(GRID, value));
}
private static int clampTexture(int value) {
return Math.max(0, Math.min(GRID, value));
}
private static int scaleToGrid(int value, int span) {
if (span <= 1) {
return 0;
}
return Math.max(0, Math.min(GRID - 1, value * GRID / span));
}
private static String projectId(Identifier id, BuildMode mode) {
return "asset/" + id.getNamespace() + "/" + id.getPath() + "/" + mode.id();
}
private static String projectName(Identifier id, String displayName, BuildMode mode) {
String base = displayName == null || displayName.isBlank() ? title(id) : displayName;
return base + " (" + mode.label() + ")";
}
private static String title(Identifier id) {
String[] parts = id.getPath().replace('/', '_').replace('_', ' ').split("\\s+");
StringBuilder builder = new StringBuilder();
for (String part : parts) {
if (part.isBlank()) {
continue;
}
if (!builder.isEmpty()) {
builder.append(' ');
}
builder.append(Character.toUpperCase(part.charAt(0))).append(part.substring(1));
}
return builder.isEmpty() ? id.toString() : builder.toString();
}
private static Ref splitRef(String ref) {
String clean = ref == null ? "" : ref;
int split = clean.indexOf(':');
if (split >= 0) {
return new Ref(clean.substring(0, split), clean.substring(split + 1));
}
return new Ref("minecraft", clean);
}
private record Ref(String namespace, String path) {
}
private record ColorPalette(List<PaletteEntry> entries) {
private static ColorPalette from(List<String> blockIds) {
List<PaletteEntry> entries = new ArrayList<>();
Set<String> seen = new LinkedHashSet<>();
for (String blockId : blockIds == null ? List.<String>of() : blockIds) {
if (blockId == null || blockId.isBlank() || !seen.add(blockId) || !VoxelProjectValidator.isOpaquePaletteBlock(blockId)) {
continue;
}
entries.add(new PaletteEntry(blockId, fallbackColor(blockId)));
}
return new ColorPalette(List.copyOf(entries));
}
private void requireUsable() {
if (entries.isEmpty()) {
throw new IllegalArgumentException("Palette Minecraft vide: decouvre des blocs ou utilise /blocodex cheat unlock_all.");
}
}
private String nearest(int rgb) {
return nearestColor(rgb, entries);
}
}
private record PaletteEntry(String blockId, int rgb) {
}
}
@@ -0,0 +1,32 @@
package dev.blocodex;
import dev.blocodex.command.BlocodexCommands;
import dev.blocodex.codex.WsmcCodexIndex;
import dev.blocodex.network.BlocodexNetworking;
import dev.blocodex.registry.BlocodexBlocks;
import dev.blocodex.registry.BlocodexItems;
import dev.blocodex.server.BlocodexServerEvents;
import net.fabricmc.api.ModInitializer;
import net.minecraft.resources.Identifier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public final class Blocodex implements ModInitializer {
public static final String MOD_ID = "blocodex";
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
@Override
public void onInitialize() {
BlocodexItems.initialize();
BlocodexBlocks.initialize();
WsmcCodexIndex.loadDefault();
BlocodexNetworking.register();
BlocodexCommands.register();
BlocodexServerEvents.register();
LOGGER.info("Blocodex memory engine initialized.");
}
public static Identifier id(String path) {
return Identifier.fromNamespaceAndPath(MOD_ID, path);
}
}
@@ -0,0 +1,50 @@
package dev.blocodex.block;
import com.mojang.serialization.MapCodec;
import dev.blocodex.network.BlocodexOpenComputerPayload;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.HorizontalDirectionalBlock;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.phys.BlockHitResult;
public final class ComputerBlock extends HorizontalDirectionalBlock {
public static final MapCodec<ComputerBlock> CODEC = simpleCodec(ComputerBlock::new);
public ComputerBlock(BlockBehaviour.Properties properties) {
super(properties);
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH));
}
@Override
protected MapCodec<? extends HorizontalDirectionalBlock> codec() {
return CODEC;
}
@Override
public BlockState getStateForPlacement(BlockPlaceContext context) {
return defaultBlockState().setValue(FACING, context.getHorizontalDirection().getOpposite());
}
@Override
protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos, Player player, BlockHitResult hit) {
if (player instanceof ServerPlayer serverPlayer) {
ServerPlayNetworking.send(serverPlayer, new BlocodexOpenComputerPayload());
}
return InteractionResult.SUCCESS;
}
@Override
protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
builder.add(FACING);
}
}
@@ -0,0 +1,394 @@
package dev.blocodex.codex;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import dev.blocodex.Blocodex;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
public final class WsmcCodexIndex {
private static final String RESOURCE_PATH = "/assets/blocodex/data/wsmc_snapshot_compact.json";
private static volatile WsmcCodexIndex defaultIndex;
private final String minecraftVersion;
private final Map<String, WsmcEntry> entries;
private final Map<String, Integer> typeCounts;
private WsmcCodexIndex(String minecraftVersion, Map<String, WsmcEntry> entries, Map<String, Integer> typeCounts) {
this.minecraftVersion = minecraftVersion;
this.entries = Map.copyOf(entries);
this.typeCounts = Map.copyOf(typeCounts);
}
public static WsmcCodexIndex loadDefault() {
WsmcCodexIndex loaded = defaultIndex;
if (loaded != null) {
return loaded;
}
synchronized (WsmcCodexIndex.class) {
if (defaultIndex != null) {
return defaultIndex;
}
try (InputStream stream = WsmcCodexIndex.class.getResourceAsStream(RESOURCE_PATH)) {
if (stream == null) {
Blocodex.LOGGER.warn("Blocodex WSMC asset not found: {}", RESOURCE_PATH);
defaultIndex = empty();
return defaultIndex;
}
try (Reader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) {
defaultIndex = fromReader(reader);
Blocodex.LOGGER.info("Loaded {} WSMC Blocodex entries for Minecraft {}.", defaultIndex.size(), defaultIndex.minecraftVersion());
return defaultIndex;
}
} catch (IOException | IllegalStateException exception) {
Blocodex.LOGGER.warn("Could not load Blocodex WSMC asset.", exception);
defaultIndex = empty();
return defaultIndex;
}
}
}
public static WsmcCodexIndex fromReader(Reader reader) {
JsonObject root = JsonParser.parseReader(reader).getAsJsonObject();
String minecraftVersion = string(root, "minecraft_version", "");
Map<String, Integer> typeCounts = new LinkedHashMap<>();
JsonObject counts = object(root, "entry_type_counts");
for (String type : List.of("block", "item", "mob")) {
typeCounts.put(type, integer(counts, type, 0));
}
Map<String, WsmcEntry> entries = new LinkedHashMap<>();
for (JsonElement element : array(root, "entries")) {
if (!element.isJsonObject()) {
continue;
}
WsmcEntry entry = entry(element.getAsJsonObject());
if (!entry.id().isBlank()) {
entries.put(entry.id(), entry);
}
}
return new WsmcCodexIndex(minecraftVersion, entries, typeCounts);
}
public static WsmcCodexIndex empty() {
return new WsmcCodexIndex("", Map.of(), Map.of("block", 0, "item", 0, "mob", 0));
}
public Optional<WsmcEntry> entry(String id) {
return Optional.ofNullable(entries.get(id));
}
public Optional<WsmcEntry> entityEntry(String id) {
WsmcEntry direct = entries.get(id);
if (direct != null && "mob".equals(direct.entryType())) {
return Optional.of(direct);
}
WsmcEntry mobVariant = entries.get(id + "/mob");
if (mobVariant != null && "mob".equals(mobVariant.entryType())) {
return Optional.of(mobVariant);
}
String prefix = id + "/";
return entries.values().stream()
.filter(entry -> "mob".equals(entry.entryType()))
.filter(entry -> entry.id().startsWith(prefix))
.min(Comparator.comparingInt(WsmcCodexIndex::entityVariantRank).thenComparing(WsmcEntry::id));
}
public List<WsmcEntry> entries(Collection<String> ids) {
List<WsmcEntry> found = new ArrayList<>();
for (String id : ids) {
WsmcEntry entry = entries.get(id);
if (entry != null) {
found.add(entry);
}
}
found.sort(Comparator.comparing(WsmcEntry::id));
return found;
}
public List<WsmcEntry> entries() {
return entries.values().stream()
.sorted(Comparator.comparing(WsmcEntry::id))
.toList();
}
private static int entityVariantRank(WsmcEntry entry) {
if (entry.id().endsWith("/temperate")) {
return 0;
}
if (entry.id().endsWith("/mob")) {
return 1;
}
return 2;
}
public int size() {
return entries.size();
}
public String minecraftVersion() {
return minecraftVersion;
}
public Map<String, Integer> typeCounts() {
return typeCounts;
}
private static WsmcEntry entry(JsonObject object) {
JsonObject colors = object(object, "colors");
List<ColorGroup> groups = new ArrayList<>();
for (JsonElement element : array(colors, "color_groups")) {
if (!element.isJsonObject()) {
continue;
}
JsonObject group = element.getAsJsonObject();
groups.add(new ColorGroup(
string(group, "hex", "#000000"),
number(group, "weight", 0.0D),
number(group, "saturation", -1.0D),
number(group, "lightness", -1.0D),
string(group, "hue_group", "neutral")
));
}
ColorProfile colorProfile = new ColorProfile(
string(colors, "primary", "#000000"),
string(colors, "dominant_hex", "#000000"),
string(colors, "average_hex", "#000000"),
string(colors, "hue_group", "neutral"),
number(colors, "saturation", -1.0D),
number(colors, "lightness", -1.0D),
strings(colors, "palette"),
groups,
string(colors, "source", "")
);
List<HistoryNote> history = new ArrayList<>();
for (JsonElement element : array(object, "history")) {
if (!element.isJsonObject()) {
continue;
}
JsonObject note = element.getAsJsonObject();
history.add(new HistoryNote(
string(note, "version", ""),
string(note, "status", ""),
string(note, "notes", "")
));
}
List<Relation> relations = new ArrayList<>();
for (JsonElement element : array(object, "relations")) {
if (!element.isJsonObject()) {
continue;
}
JsonObject relation = element.getAsJsonObject();
relations.add(new Relation(
string(relation, "type", ""),
string(relation, "target", ""),
string(relation, "label", ""),
number(relation, "confidence", 0.0D)
));
}
return new WsmcEntry(
string(object, "id", ""),
string(object, "name", ""),
string(object, "entry_type", ""),
string(object, "description", ""),
history,
colorProfile,
string(object, "icon", ""),
string(object, "family", ""),
relations,
string(object, "category", "")
);
}
private static JsonObject object(JsonObject object, String key) {
JsonElement element = object.get(key);
return element != null && element.isJsonObject() ? element.getAsJsonObject() : new JsonObject();
}
private static JsonArray array(JsonObject object, String key) {
JsonElement element = object.get(key);
return element != null && element.isJsonArray() ? element.getAsJsonArray() : new JsonArray();
}
private static List<String> strings(JsonObject object, String key) {
List<String> values = new ArrayList<>();
for (JsonElement element : array(object, key)) {
if (element.isJsonPrimitive()) {
values.add(element.getAsString());
}
}
return values;
}
private static String string(JsonObject object, String key, String fallback) {
JsonElement element = object.get(key);
return element != null && element.isJsonPrimitive() ? element.getAsString() : fallback;
}
private static int integer(JsonObject object, String key, int fallback) {
JsonElement element = object.get(key);
return element != null && element.isJsonPrimitive() ? element.getAsInt() : fallback;
}
private static double number(JsonObject object, String key, double fallback) {
JsonElement element = object.get(key);
return element != null && element.isJsonPrimitive() ? element.getAsDouble() : fallback;
}
public record WsmcEntry(
String id,
String name,
String entryType,
String description,
List<HistoryNote> history,
ColorProfile colors,
String icon,
String family,
List<Relation> relations,
String category
) {
public WsmcEntry {
history = List.copyOf(history);
relations = List.copyOf(relations);
}
public int primaryArgb() {
return hexToArgb(colors.primary());
}
public Hsl hsl() {
Hsl fromHex = rgbToHsl(primaryArgb());
double saturation = normalized(colors.saturation()) ? colors.saturation() : fromHex.saturation();
double lightness = normalized(colors.lightness()) ? colors.lightness() : fromHex.lightness();
return new Hsl(fromHex.hue(), saturation, lightness);
}
public double distanceToHsl(double hue, double saturation, double lightness) {
Hsl primary = hsl();
double primaryDistance = hslDistance(primary, hue, saturation, lightness);
double groupDistance = colors.colorGroups().stream()
.mapToDouble(group -> group.distanceToHsl(hue, saturation, lightness))
.min()
.orElse(primaryDistance);
return Math.min(primaryDistance, groupDistance * 0.85D + primaryDistance * 0.15D);
}
public String displayName() {
return name.isBlank() ? id : name;
}
}
public record ColorProfile(
String primary,
String dominantHex,
String averageHex,
String hueGroup,
double saturation,
double lightness,
List<String> palette,
List<ColorGroup> colorGroups,
String source
) {
public ColorProfile {
palette = List.copyOf(palette);
colorGroups = List.copyOf(colorGroups);
}
}
public record ColorGroup(String hex, double weight, double saturation, double lightness, String hueGroup) {
private double distanceToHsl(double hue, double targetSaturation, double targetLightness) {
Hsl fromHex = rgbToHsl(hexToArgb(hex));
double groupSaturation = normalized(saturation) ? saturation : fromHex.saturation();
double groupLightness = normalized(lightness) ? lightness : fromHex.lightness();
double distance = hslDistance(new Hsl(fromHex.hue(), groupSaturation, groupLightness), hue, targetSaturation, targetLightness);
return Math.max(0.0D, distance - Math.max(0.0D, weight) * 0.03D);
}
}
public record HistoryNote(String version, String status, String notes) {
}
public record Relation(String type, String target, String label, double confidence) {
}
public record Hsl(double hue, double saturation, double lightness) {
}
private static boolean normalized(double value) {
return value >= 0.0D && value <= 1.0D;
}
private static int hexToArgb(String hex) {
if (hex == null || hex.isBlank()) {
return 0xFF000000;
}
String clean = hex.startsWith("#") ? hex.substring(1) : hex;
if (clean.length() != 6) {
return 0xFF000000;
}
try {
return 0xFF000000 | Integer.parseInt(clean, 16);
} catch (NumberFormatException ignored) {
return 0xFF000000;
}
}
private static Hsl rgbToHsl(int argb) {
double red = ((argb >> 16) & 255) / 255.0D;
double green = ((argb >> 8) & 255) / 255.0D;
double blue = (argb & 255) / 255.0D;
double max = Math.max(red, Math.max(green, blue));
double min = Math.min(red, Math.min(green, blue));
double lightness = (max + min) / 2.0D;
double hue;
double saturation;
if (max == min) {
hue = 0.0D;
saturation = 0.0D;
} else {
double delta = max - min;
saturation = lightness > 0.5D ? delta / (2.0D - max - min) : delta / (max + min);
if (max == red) {
hue = (green - blue) / delta + (green < blue ? 6.0D : 0.0D);
} else if (max == green) {
hue = (blue - red) / delta + 2.0D;
} else {
hue = (red - green) / delta + 4.0D;
}
hue *= 60.0D;
}
return new Hsl(hue, saturation, lightness);
}
private static double hslDistance(Hsl hsl, double hue, double saturation, double lightness) {
double hueDistance = Math.abs(hsl.hue() - hue);
hueDistance = Math.min(hueDistance, 360.0D - hueDistance) / 180.0D;
double saturationDistance = hsl.saturation() - saturation;
double lightnessDistance = hsl.lightness() - lightness;
return hueDistance * hueDistance * 1.4D
+ saturationDistance * saturationDistance
+ lightnessDistance * lightnessDistance * 1.15D;
}
}
@@ -0,0 +1,262 @@
package dev.blocodex.command;
import com.mojang.brigadier.Command;
import com.mojang.brigadier.arguments.IntegerArgumentType;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.context.CommandContext;
import dev.blocodex.memory.BlocodexMemory;
import dev.blocodex.memory.BlocodexSavedData;
import dev.blocodex.memory.ChunkKey;
import dev.blocodex.memory.ChunkMemory;
import dev.blocodex.memory.MemoryMath;
import dev.blocodex.memory.PlayerMemory;
import dev.blocodex.memory.WorldMemory;
import dev.blocodex.server.BlocodexScanner;
import dev.blocodex.server.BlocodexUiSnapshots;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.server.permissions.Permissions;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.tags.TagKey;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.FallingBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.Vec3;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
public final class BlocodexCommands {
private BlocodexCommands() {
}
public static void register() {
CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> dispatcher.register(
Commands.literal("blocodex")
.then(Commands.literal("entry")
.then(Commands.argument("id", StringArgumentType.word())
.executes(BlocodexCommands::showEntry)))
.then(Commands.literal("me")
.executes(BlocodexCommands::showPlayerMemory))
.then(Commands.literal("world")
.executes(BlocodexCommands::showWorldMemory))
.then(Commands.literal("chunk")
.executes(BlocodexCommands::showCurrentChunk)
.then(Commands.argument("dimension", StringArgumentType.word())
.then(Commands.argument("x", IntegerArgumentType.integer())
.then(Commands.argument("z", IntegerArgumentType.integer())
.executes(BlocodexCommands::showSpecifiedChunk)))))
.then(Commands.literal("rescan")
.requires(source -> source.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER))
.executes(context -> rescan(context, 1))
.then(Commands.argument("radius", IntegerArgumentType.integer(0, 8))
.executes(context -> rescan(context, IntegerArgumentType.getInteger(context, "radius")))))
.then(Commands.literal("reset")
.requires(source -> source.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER))
.executes(BlocodexCommands::resetMemory))
.then(Commands.literal("cheat")
.requires(source -> source.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER))
.then(Commands.literal("discover_all")
.executes(BlocodexCommands::cheatDiscoverAllBlocks))
.then(Commands.literal("unlock_all")
.executes(BlocodexCommands::cheatDiscoverAllBlocks)))
));
}
private static int showEntry(CommandContext<CommandSourceStack> context) {
String rawId = StringArgumentType.getString(context, "id");
Identifier id = Identifier.tryParse(rawId);
if (id == null) {
context.getSource().sendFailure(Component.literal("Invalid entry id: " + rawId));
return 0;
}
Optional<Block> block = BuiltInRegistries.BLOCK.getOptional(id);
Optional<Item> item = BuiltInRegistries.ITEM.getOptional(id);
if (block.isEmpty() && item.isEmpty()) {
context.getSource().sendFailure(Component.literal("No block or item registered as " + id));
return 0;
}
BlocodexMemory memory = BlocodexSavedData.get(context.getSource().getServer()).memory();
send(context, "Blocodex entry: " + id);
block.ifPresent(value -> describeBlock(context.getSource(), memory, id.toString(), value));
item.ifPresent(value -> describeItem(context.getSource(), id.toString(), value));
return Command.SINGLE_SUCCESS;
}
private static void describeBlock(CommandSourceStack source, BlocodexMemory memory, String id, Block block) {
Level level = source.getLevel();
Vec3 position = source.getPosition();
BlockPos pos = BlockPos.containing(position);
BlockState state = block.defaultBlockState();
boolean collides = !state.getCollisionShape(level, pos).isEmpty();
boolean fluid = !state.getFluidState().isEmpty();
boolean gravity = block instanceof FallingBlock;
Map<String, Long> biomeContexts = new LinkedHashMap<>();
long chunkMatches = 0;
for (ChunkMemory chunk : memory.chunks().values()) {
if (chunk.blockCounts().containsKey(id)) {
chunkMatches++;
for (String biome : chunk.biomes()) {
MemoryMath.addCount(biomeContexts, biome, 1);
}
}
}
String blockTags = block.builtInRegistryHolder().tags()
.map(TagKey::location)
.map(location -> "#" + location)
.sorted()
.limit(10)
.collect(Collectors.joining(", "));
send(source, "color: map=" + state.getMapColor(level, pos));
send(source, "shape: collision=" + collides + ", solidRender=" + state.isSolidRender() + ", canOcclude=" + state.canOcclude());
send(source, "properties: light=" + state.getLightEmission() + ", hardness=" + state.getDestroySpeed(level, pos) + ", fluid=" + fluid + ", gravity=" + gravity + ", redstone=" + state.isSignalSource());
send(source, "context: chunks=" + chunkMatches + ", globalCount=" + memory.world().blockCounts().getOrDefault(id, 0L) + ", biomes=" + MemoryMath.topLine(biomeContexts, 5));
send(source, "origin: blockTags=" + (blockTags.isBlank() ? "none" : blockTags) + ", recipes=deferred");
}
private static void describeItem(CommandSourceStack source, String id, Item item) {
String itemTags = item.builtInRegistryHolder().tags()
.map(TagKey::location)
.map(location -> "#" + location)
.sorted()
.limit(10)
.collect(Collectors.joining(", "));
send(source, "item: " + item.getName(item.getDefaultInstance()).getString());
send(source, "origin: itemTags=" + (itemTags.isBlank() ? "none" : itemTags) + ", recipes=deferred");
}
private static int showPlayerMemory(CommandContext<CommandSourceStack> context) throws com.mojang.brigadier.exceptions.CommandSyntaxException {
ServerPlayer player = context.getSource().getPlayerOrException();
BlocodexMemory memory = BlocodexSavedData.get(context.getSource().getServer()).memory();
PlayerMemory playerMemory = memory.player(player.getUUID().toString());
send(context, "Blocodex player memory");
send(context, "chunks=" + playerMemory.visitedChunks().size() + ", blocks=" + playerMemory.discoveredBlocks().size() + ", biomes=" + playerMemory.biomes().size());
send(context, "top observed blocks: " + MemoryMath.topLine(playerMemory.observedBlockCounts(), 8));
send(context, "top broken blocks: " + MemoryMath.topLine(playerMemory.brokenBlocks(), 8));
send(context, "items=" + playerMemory.items().size() + ", entities=" + playerMemory.entities().size());
return Command.SINGLE_SUCCESS;
}
private static int showWorldMemory(CommandContext<CommandSourceStack> context) {
WorldMemory world = BlocodexSavedData.get(context.getSource().getServer()).memory().world();
send(context, "Blocodex world memory (anonymous)");
send(context, "observedChunks=" + world.observedChunks());
send(context, "top blocks: " + MemoryMath.topLine(world.blockCounts(), 8));
send(context, "top biomes: " + MemoryMath.topLine(world.biomeCounts(), 8));
send(context, "dimensions: " + MemoryMath.topLine(world.dimensionCounts(), 8));
send(context, "entities: " + MemoryMath.topLine(world.entityCounts(), 8));
send(context, "items: " + MemoryMath.topLine(world.itemCounts(), 8));
return Command.SINGLE_SUCCESS;
}
private static int showCurrentChunk(CommandContext<CommandSourceStack> context) throws com.mojang.brigadier.exceptions.CommandSyntaxException {
ServerPlayer player = context.getSource().getPlayerOrException();
ChunkKey key = BlocodexScanner.keyFor(player.level(), player.chunkPosition().x(), player.chunkPosition().z());
return showChunk(context, key);
}
private static int showSpecifiedChunk(CommandContext<CommandSourceStack> context) {
ChunkKey key = new ChunkKey(
StringArgumentType.getString(context, "dimension"),
IntegerArgumentType.getInteger(context, "x"),
IntegerArgumentType.getInteger(context, "z")
);
return showChunk(context, key);
}
private static int showChunk(CommandContext<CommandSourceStack> context, ChunkKey key) {
BlocodexMemory memory = BlocodexSavedData.get(context.getSource().getServer()).memory();
ChunkMemory chunk = memory.chunk(key);
if (chunk == null) {
context.getSource().sendFailure(Component.literal("No Blocodex snapshot for chunk " + key.asStorageKey()));
return 0;
}
send(context, "Blocodex chunk: " + key.asStorageKey());
send(context, "observations=" + chunk.observations() + ", y=" + chunk.minY() + ".." + chunk.maxY() + ", lastTick=" + chunk.lastObservedTick());
send(context, "biomes=" + (chunk.biomes().isEmpty() ? "none" : String.join(", ", chunk.biomes())));
send(context, "top blocks: " + MemoryMath.topLine(chunk.blockCounts(), 10));
try {
ServerPlayer player = context.getSource().getPlayerOrException();
PlayerMemory playerMemory = memory.player(player.getUUID().toString());
long ticks = playerMemory.chunkTimeTicks().getOrDefault(key.asStorageKey(), 0L);
send(context, "your heat ticks here=" + ticks);
} catch (com.mojang.brigadier.exceptions.CommandSyntaxException ignored) {
// Console can inspect chunk snapshots without a personal heatmap line.
}
return Command.SINGLE_SUCCESS;
}
private static int rescan(CommandContext<CommandSourceStack> context, int radius) throws com.mojang.brigadier.exceptions.CommandSyntaxException {
ServerPlayer player = context.getSource().getPlayerOrException();
int scanned = BlocodexScanner.scanAroundPlayer(player, radius, Integer.MAX_VALUE);
send(context, "Queued immediate Blocodex rescan around you: " + scanned + " chunks.");
return Command.SINGLE_SUCCESS;
}
private static int resetMemory(CommandContext<CommandSourceStack> context) {
BlocodexSavedData data = BlocodexSavedData.get(context.getSource().getServer());
data.memory().clear();
data.setDirty();
BlocodexUiSnapshots.clearColorCaches();
send(context, "Blocodex reset: all discovered entries, chunks, player memories and world aggregates were cleared.");
return Command.SINGLE_SUCCESS;
}
private static int cheatDiscoverAllBlocks(CommandContext<CommandSourceStack> context) throws com.mojang.brigadier.exceptions.CommandSyntaxException {
ServerPlayer player = context.getSource().getPlayerOrException();
List<String> blockIds = new ArrayList<>();
for (Block block : BuiltInRegistries.BLOCK) {
if (!block.defaultBlockState().isAir()) {
blockIds.add(BuiltInRegistries.BLOCK.getKey(block).toString());
}
}
List<String> itemIds = new ArrayList<>();
for (Item item : BuiltInRegistries.ITEM) {
itemIds.add(BuiltInRegistries.ITEM.getKey(item).toString());
}
List<String> entityIds = new ArrayList<>();
for (net.minecraft.world.entity.EntityType<?> entityType : BuiltInRegistries.ENTITY_TYPE) {
entityIds.add(BuiltInRegistries.ENTITY_TYPE.getKey(entityType).toString());
}
BlocodexSavedData data = BlocodexSavedData.get(context.getSource().getServer());
PlayerMemory playerMemory = data.memory().player(player.getUUID().toString());
int addedBlocks = playerMemory.discoverBlocks(blockIds);
int addedItems = playerMemory.discoverItems(itemIds);
int addedEntities = playerMemory.discoverEntities(entityIds);
data.setDirty();
send(context, "Blocodex cheat: +" + addedBlocks + " blocs, +" + addedItems + " items, +" + addedEntities + " mobs debloques.");
send(context, "Totaux: " + playerMemory.discoveredBlocks().size() + " blocs, " + playerMemory.items().size() + " items, " + playerMemory.entities().size() + " mobs.");
return Command.SINGLE_SUCCESS;
}
private static void send(CommandContext<CommandSourceStack> context, String line) {
send(context.getSource(), line);
}
private static void send(CommandSourceStack source, String line) {
source.sendSuccess(() -> Component.literal(line), false);
}
}
@@ -0,0 +1,18 @@
package dev.blocodex.item;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
import net.minecraft.world.level.Level;
public final class BlocodexItem extends Item {
public BlocodexItem(Properties properties) {
super(properties);
}
@Override
public InteractionResult use(Level level, Player player, InteractionHand hand) {
return InteractionResult.PASS;
}
}
@@ -0,0 +1,137 @@
package dev.blocodex.memory;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
public final class BlocodexMemory {
public static final Codec<Map<String, PlayerMemory>> PLAYER_MAP_CODEC = Codec.unboundedMap(Codec.STRING, PlayerMemory.CODEC)
.xmap(LinkedHashMap::new, map -> map);
public static final Codec<Map<String, ChunkMemory>> CHUNK_MAP_CODEC = Codec.unboundedMap(Codec.STRING, ChunkMemory.CODEC)
.xmap(LinkedHashMap::new, map -> map);
public static final Codec<BlocodexMemory> CODEC = RecordCodecBuilder.create(instance -> instance.group(
PLAYER_MAP_CODEC.optionalFieldOf("players", Map.of()).forGetter(BlocodexMemory::players),
CHUNK_MAP_CODEC.optionalFieldOf("chunks", Map.of()).forGetter(BlocodexMemory::chunks),
WorldMemory.CODEC.optionalFieldOf("world", new WorldMemory()).forGetter(BlocodexMemory::world)
).apply(instance, BlocodexMemory::new));
private final Map<String, PlayerMemory> players;
private final Map<String, ChunkMemory> chunks;
private final WorldMemory world;
public BlocodexMemory() {
this(Map.of(), Map.of(), new WorldMemory());
}
public BlocodexMemory(Map<String, PlayerMemory> players, Map<String, ChunkMemory> chunks, WorldMemory world) {
this.players = new LinkedHashMap<>(players);
this.chunks = new LinkedHashMap<>(chunks);
this.world = world;
}
public boolean observeChunk(String playerId, ChunkKey key, Map<String, Long> blockCounts, Map<Integer, Map<String, Long>> layerBlockCounts, Set<String> biomes, int minY, int maxY, long tick) {
PlayerMemory player = player(playerId);
boolean changed = player.observeChunk(key, blockCounts, biomes);
String storageKey = key.asStorageKey();
ChunkMemory previous = chunks.get(storageKey);
if (previous == null) {
ChunkMemory created = new ChunkMemory(key);
created.replaceSnapshot(blockCounts, layerBlockCounts, biomes, minY, maxY, tick);
chunks.put(storageKey, created);
world.recordNewChunk(key, blockCounts, biomes);
return true;
}
Map<String, Long> blockDelta = MemoryMath.diff(blockCounts, previous.blockCounts());
Set<String> addedBiomes = new LinkedHashSet<>(biomes);
addedBiomes.removeAll(previous.biomes());
Set<String> removedBiomes = new LinkedHashSet<>(previous.biomes());
removedBiomes.removeAll(biomes);
previous.replaceSnapshot(blockCounts, layerBlockCounts, biomes, minY, maxY, tick);
if (!blockDelta.isEmpty() || !addedBiomes.isEmpty() || !removedBiomes.isEmpty()) {
world.applyChunkDelta(blockDelta, addedBiomes, removedBiomes);
changed = true;
}
return changed;
}
public boolean addChunkTime(String playerId, ChunkKey key, long ticks) {
return player(playerId).addChunkTime(key, ticks);
}
public boolean recordItem(String playerId, String itemId) {
boolean changed = player(playerId).recordItem(itemId);
if (changed) {
world.recordItem(itemId);
}
return changed;
}
public boolean recordItem(String playerId, String itemId, long tick, String dimension, int x, int y, int z) {
boolean changed = recordItem(playerId, itemId);
if (changed) {
player(playerId).recordRecentEvent("item", itemId, tick, dimension, x, y, z);
}
return changed;
}
public boolean recordEntity(String playerId, String entityId) {
boolean changed = player(playerId).recordEntity(entityId);
if (changed) {
world.recordEntity(entityId);
}
return changed;
}
public boolean recordEntity(String playerId, String entityId, long tick, String dimension, int x, int y, int z) {
boolean changed = recordEntity(playerId, entityId);
if (changed) {
player(playerId).recordRecentEvent("entity", entityId, tick, dimension, x, y, z);
}
return changed;
}
public boolean recordBrokenBlock(String playerId, String blockId) {
return player(playerId).recordBrokenBlock(blockId);
}
public boolean recordBrokenBlock(String playerId, String blockId, long tick, String dimension, int x, int y, int z) {
boolean changed = recordBrokenBlock(playerId, blockId);
player(playerId).recordRecentEvent("block_broken", blockId, tick, dimension, x, y, z);
return changed;
}
public void clear() {
players.clear();
chunks.clear();
world.clear();
}
public PlayerMemory player(String playerId) {
return players.computeIfAbsent(playerId, ignored -> new PlayerMemory());
}
public ChunkMemory chunk(ChunkKey key) {
return chunks.get(key.asStorageKey());
}
public Map<String, PlayerMemory> players() {
return players;
}
public Map<String, ChunkMemory> chunks() {
return chunks;
}
public WorldMemory world() {
return world;
}
}
@@ -0,0 +1,40 @@
package dev.blocodex.memory;
import com.mojang.serialization.Codec;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
public final class BlocodexSavedData extends SavedData {
private static final Codec<BlocodexSavedData> CODEC = BlocodexMemory.CODEC.xmap(BlocodexSavedData::new, BlocodexSavedData::memory);
private static final SavedDataType<BlocodexSavedData> TYPE = new SavedDataType<>(
Identifier.fromNamespaceAndPath("blocodex", "memory"),
BlocodexSavedData::new,
CODEC,
null
);
private final BlocodexMemory memory;
public BlocodexSavedData() {
this(new BlocodexMemory());
}
public BlocodexSavedData(BlocodexMemory memory) {
this.memory = memory;
}
public static BlocodexSavedData get(MinecraftServer server) {
ServerLevel level = server.getLevel(ServerLevel.OVERWORLD);
if (level == null) {
return new BlocodexSavedData();
}
return level.getDataStorage().computeIfAbsent(TYPE);
}
public BlocodexMemory memory() {
return memory;
}
}
@@ -0,0 +1,43 @@
package dev.blocodex.memory;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import dev.blocodex.Blocodex;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
public final class BlocodexWorldSetupData extends SavedData {
private static final Codec<BlocodexWorldSetupData> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.BOOL.optionalFieldOf("spawn_computer_handled", false).forGetter(BlocodexWorldSetupData::spawnComputerHandled)
).apply(instance, BlocodexWorldSetupData::new));
private static final SavedDataType<BlocodexWorldSetupData> TYPE = new SavedDataType<>(
Blocodex.id("world_setup"),
BlocodexWorldSetupData::new,
CODEC,
null
);
private boolean spawnComputerHandled;
public BlocodexWorldSetupData() {
this(false);
}
public BlocodexWorldSetupData(boolean spawnComputerHandled) {
this.spawnComputerHandled = spawnComputerHandled;
}
public static BlocodexWorldSetupData get(ServerLevel level) {
return level.getDataStorage().computeIfAbsent(TYPE);
}
public boolean spawnComputerHandled() {
return spawnComputerHandled;
}
public void markSpawnComputerHandled() {
spawnComputerHandled = true;
setDirty();
}
}
@@ -0,0 +1,27 @@
package dev.blocodex.memory;
import java.util.Objects;
import java.util.Optional;
public record ChunkKey(String dimension, int x, int z) {
public ChunkKey {
Objects.requireNonNull(dimension, "dimension");
}
public String asStorageKey() {
return dimension + "|" + x + "|" + z;
}
public static Optional<ChunkKey> parse(String value) {
String[] parts = value.split("\\|", 3);
if (parts.length != 3) {
return Optional.empty();
}
try {
return Optional.of(new ChunkKey(parts[0], Integer.parseInt(parts[1]), Integer.parseInt(parts[2])));
} catch (NumberFormatException ignored) {
return Optional.empty();
}
}
}
@@ -0,0 +1,116 @@
package dev.blocodex.memory;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
public final class ChunkMemory {
public static final Codec<ChunkMemory> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.STRING.fieldOf("dimension").forGetter(memory -> memory.key.dimension()),
Codec.INT.fieldOf("x").forGetter(memory -> memory.key.x()),
Codec.INT.fieldOf("z").forGetter(memory -> memory.key.z()),
MemoryCodecs.STRING_LONG_MAP.optionalFieldOf("block_counts", Map.of()).forGetter(ChunkMemory::blockCounts),
MemoryCodecs.LAYER_BLOCK_COUNTS.optionalFieldOf("layer_block_counts", Map.of()).forGetter(ChunkMemory::layerBlockCounts),
MemoryCodecs.STRING_SET.optionalFieldOf("biomes", Set.of()).forGetter(ChunkMemory::biomes),
Codec.INT.optionalFieldOf("min_y", 0).forGetter(ChunkMemory::minY),
Codec.INT.optionalFieldOf("max_y", 0).forGetter(ChunkMemory::maxY),
Codec.LONG.optionalFieldOf("observations", 0L).forGetter(ChunkMemory::observations),
Codec.LONG.optionalFieldOf("last_observed_tick", 0L).forGetter(ChunkMemory::lastObservedTick)
).apply(instance, ChunkMemory::new));
private final ChunkKey key;
private final Map<String, Long> blockCounts;
private final Map<Integer, Map<String, Long>> layerBlockCounts;
private final Set<String> biomes;
private int minY;
private int maxY;
private long observations;
private long lastObservedTick;
public ChunkMemory(ChunkKey key) {
this(key.dimension(), key.x(), key.z(), Map.of(), Map.of(), Set.of(), 0, 0, 0L, 0L);
}
public ChunkMemory(
String dimension,
int x,
int z,
Map<String, Long> blockCounts,
Map<Integer, Map<String, Long>> layerBlockCounts,
Set<String> biomes,
int minY,
int maxY,
long observations,
long lastObservedTick
) {
this.key = new ChunkKey(dimension, x, z);
this.blockCounts = new LinkedHashMap<>(blockCounts);
this.layerBlockCounts = copyLayers(layerBlockCounts);
this.biomes = new LinkedHashSet<>(biomes);
this.minY = minY;
this.maxY = maxY;
this.observations = observations;
this.lastObservedTick = lastObservedTick;
}
public ChunkKey key() {
return key;
}
public Map<String, Long> blockCounts() {
return blockCounts;
}
public Map<Integer, Map<String, Long>> layerBlockCounts() {
return layerBlockCounts;
}
public Map<String, Long> layerBlockCounts(int y) {
return layerBlockCounts.getOrDefault(y, Map.of());
}
public Set<String> biomes() {
return biomes;
}
public int minY() {
return minY;
}
public int maxY() {
return maxY;
}
public long observations() {
return observations;
}
public long lastObservedTick() {
return lastObservedTick;
}
public void replaceSnapshot(Map<String, Long> nextBlockCounts, Map<Integer, Map<String, Long>> nextLayerBlockCounts, Set<String> nextBiomes, int nextMinY, int nextMaxY, long tick) {
blockCounts.clear();
blockCounts.putAll(nextBlockCounts);
layerBlockCounts.clear();
layerBlockCounts.putAll(copyLayers(nextLayerBlockCounts));
biomes.clear();
biomes.addAll(nextBiomes);
minY = nextMinY;
maxY = nextMaxY;
observations++;
lastObservedTick = tick;
}
private static Map<Integer, Map<String, Long>> copyLayers(Map<Integer, Map<String, Long>> layers) {
Map<Integer, Map<String, Long>> copy = new LinkedHashMap<>();
for (Map.Entry<Integer, Map<String, Long>> entry : layers.entrySet()) {
copy.put(entry.getKey(), new LinkedHashMap<>(entry.getValue()));
}
return copy;
}
}
@@ -0,0 +1,70 @@
package dev.blocodex.memory;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
public final class GridDistribution {
private GridDistribution() {
}
public static List<String> normalize(Map<String, Long> counts, int cells) {
List<String> result = new ArrayList<>(cells);
if (cells <= 0) {
return result;
}
long total = counts.values().stream().mapToLong(Long::longValue).filter(value -> value > 0).sum();
if (total <= 0) {
for (int i = 0; i < cells; i++) {
result.add("");
}
return result;
}
List<Entry> entries = counts.entrySet().stream()
.filter(entry -> entry.getValue() > 0)
.map(entry -> {
double exact = (double) entry.getValue() * cells / total;
int base = (int) Math.floor(exact);
return new Entry(entry.getKey(), entry.getValue(), base, exact - base);
})
.sorted(Comparator.comparingLong(Entry::count).reversed().thenComparing(Entry::id))
.toList();
int allocated = 0;
for (Entry entry : entries) {
allocated += entry.base();
}
List<Entry> remainderOrder = entries.stream()
.sorted(Comparator.comparingDouble(Entry::remainder).reversed()
.thenComparing(Entry::count, Comparator.reverseOrder())
.thenComparing(Entry::id))
.toList();
int[] bonuses = new int[entries.size()];
int remaining = cells - allocated;
for (int i = 0; i < remaining; i++) {
Entry bonusEntry = remainderOrder.get(i % remainderOrder.size());
bonuses[entries.indexOf(bonusEntry)]++;
}
for (int i = 0; i < entries.size(); i++) {
Entry entry = entries.get(i);
int repeats = entry.base() + bonuses[i];
for (int j = 0; j < repeats && result.size() < cells; j++) {
result.add(entry.id());
}
}
while (result.size() < cells) {
result.add("");
}
return result;
}
private record Entry(String id, long count, int base, double remainder) {
}
}
@@ -0,0 +1,41 @@
package dev.blocodex.memory;
import com.mojang.serialization.Codec;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
public final class MemoryCodecs {
public static final Codec<Map<String, Long>> STRING_LONG_MAP = Codec.unboundedMap(Codec.STRING, Codec.LONG)
.xmap(LinkedHashMap::new, map -> map);
public static final Codec<Map<Integer, Map<String, Long>>> LAYER_BLOCK_COUNTS = Codec.unboundedMap(Codec.STRING, STRING_LONG_MAP)
.xmap(MemoryCodecs::decodeLayerKeys, MemoryCodecs::encodeLayerKeys);
public static final Codec<Set<String>> STRING_SET = Codec.STRING.listOf()
.xmap(LinkedHashSet::new, ArrayList::new);
private MemoryCodecs() {
}
private static Map<Integer, Map<String, Long>> decodeLayerKeys(Map<String, Map<String, Long>> encoded) {
Map<Integer, Map<String, Long>> decoded = new LinkedHashMap<>();
for (Map.Entry<String, Map<String, Long>> entry : encoded.entrySet()) {
try {
decoded.put(Integer.parseInt(entry.getKey()), new LinkedHashMap<>(entry.getValue()));
} catch (NumberFormatException ignored) {
// Ignore malformed legacy/user-edited layer keys instead of failing the whole save.
}
}
return decoded;
}
private static Map<String, Map<String, Long>> encodeLayerKeys(Map<Integer, Map<String, Long>> decoded) {
Map<String, Map<String, Long>> encoded = new LinkedHashMap<>();
for (Map.Entry<Integer, Map<String, Long>> entry : decoded.entrySet()) {
encoded.put(Integer.toString(entry.getKey()), new LinkedHashMap<>(entry.getValue()));
}
return encoded;
}
}
@@ -0,0 +1,56 @@
package dev.blocodex.memory;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class MemoryMath {
private MemoryMath() {
}
public static void addCount(Map<String, Long> counts, String id, long amount) {
if (id == null || id.isBlank() || amount == 0) {
return;
}
long next = counts.getOrDefault(id, 0L) + amount;
if (next <= 0) {
counts.remove(id);
} else {
counts.put(id, next);
}
}
public static Map<String, Long> diff(Map<String, Long> next, Map<String, Long> previous) {
Map<String, Long> delta = new LinkedHashMap<>();
for (Map.Entry<String, Long> entry : next.entrySet()) {
long change = entry.getValue() - previous.getOrDefault(entry.getKey(), 0L);
if (change != 0) {
delta.put(entry.getKey(), change);
}
}
for (Map.Entry<String, Long> entry : previous.entrySet()) {
if (!next.containsKey(entry.getKey())) {
delta.put(entry.getKey(), -entry.getValue());
}
}
return delta;
}
public static List<Map.Entry<String, Long>> top(Map<String, Long> counts, int limit) {
return counts.entrySet().stream()
.sorted(Map.Entry.<String, Long>comparingByValue(Comparator.reverseOrder()).thenComparing(Map.Entry.comparingByKey()))
.limit(limit)
.toList();
}
public static String topLine(Map<String, Long> counts, int limit) {
List<String> parts = new ArrayList<>();
for (Map.Entry<String, Long> entry : top(counts, limit)) {
parts.add(entry.getKey() + "=" + entry.getValue());
}
return parts.isEmpty() ? "none" : String.join(", ", parts);
}
}
@@ -0,0 +1,356 @@
package dev.blocodex.memory;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
public final class PlayerMemory {
private static final int MAX_RECENT_EVENTS = 40;
public static final int MAX_STAT_SAMPLES = 288;
public static final Codec<List<RecentMemoryEvent>> RECENT_EVENTS_CODEC = RecentMemoryEvent.CODEC.listOf()
.xmap(ArrayList::new, List::copyOf);
public static final Codec<List<PlayerStatSample>> STAT_SAMPLES_CODEC = PlayerStatSample.CODEC.listOf()
.xmap(ArrayList::new, List::copyOf);
public static final Codec<Map<String, VoxelProjectMemory>> VOXEL_PROJECT_MAP_CODEC = Codec.unboundedMap(Codec.STRING, VoxelProjectMemory.CODEC)
.xmap(LinkedHashMap::new, map -> map);
public static final Codec<PlayerMemory> CODEC = RecordCodecBuilder.create(instance -> instance.group(
MemoryCodecs.STRING_SET.optionalFieldOf("visited_chunks", Set.of()).forGetter(PlayerMemory::visitedChunks),
MemoryCodecs.STRING_LONG_MAP.optionalFieldOf("chunk_time_ticks", Map.of()).forGetter(PlayerMemory::chunkTimeTicks),
MemoryCodecs.STRING_SET.optionalFieldOf("discovered_blocks", Set.of()).forGetter(PlayerMemory::discoveredBlocks),
MemoryCodecs.STRING_LONG_MAP.optionalFieldOf("observed_block_counts", Map.of()).forGetter(PlayerMemory::observedBlockCounts),
MemoryCodecs.STRING_SET.optionalFieldOf("biomes", Set.of()).forGetter(PlayerMemory::biomes),
MemoryCodecs.STRING_SET.optionalFieldOf("items", Set.of()).forGetter(PlayerMemory::items),
MemoryCodecs.STRING_SET.optionalFieldOf("entities", Set.of()).forGetter(PlayerMemory::entities),
MemoryCodecs.STRING_LONG_MAP.optionalFieldOf("broken_blocks", Map.of()).forGetter(PlayerMemory::brokenBlocks),
RECENT_EVENTS_CODEC.optionalFieldOf("recent_events", List.of()).forGetter(PlayerMemory::recentEvents),
STAT_SAMPLES_CODEC.optionalFieldOf("stat_samples", List.of()).forGetter(PlayerMemory::statSamples),
VOXEL_PROJECT_MAP_CODEC.optionalFieldOf("voxel_projects", Map.of()).forGetter(PlayerMemory::voxelProjects),
MemoryCodecs.STRING_SET.optionalFieldOf("loaded_library_entries", Set.of()).forGetter(PlayerMemory::loadedLibraryEntries),
MemoryCodecs.STRING_SET.optionalFieldOf("favorite_library_entries", Set.of()).forGetter(PlayerMemory::favoriteLibraryEntries),
MemoryCodecs.STRING_SET.optionalFieldOf("research_library_entries", Set.of()).forGetter(PlayerMemory::researchLibraryEntries)
).apply(instance, PlayerMemory::new));
private final Set<String> visitedChunks;
private final Map<String, Long> chunkTimeTicks;
private final Set<String> discoveredBlocks;
private final Map<String, Long> observedBlockCounts;
private final Set<String> biomes;
private final Set<String> items;
private final Set<String> entities;
private final Map<String, Long> brokenBlocks;
private final List<RecentMemoryEvent> recentEvents;
private final List<PlayerStatSample> statSamples;
private final Map<String, VoxelProjectMemory> voxelProjects;
private final Set<String> loadedLibraryEntries;
private final Set<String> favoriteLibraryEntries;
private final Set<String> researchLibraryEntries;
public PlayerMemory() {
this(Set.of(), Map.of(), Set.of(), Map.of(), Set.of(), Set.of(), Set.of(), Map.of(), List.of(), List.of(), Map.of(), Set.of(), Set.of(), Set.of());
}
public PlayerMemory(
Set<String> visitedChunks,
Map<String, Long> chunkTimeTicks,
Set<String> discoveredBlocks,
Map<String, Long> observedBlockCounts,
Set<String> biomes,
Set<String> items,
Set<String> entities,
Map<String, Long> brokenBlocks,
List<RecentMemoryEvent> recentEvents,
Map<String, VoxelProjectMemory> voxelProjects
) {
this(visitedChunks, chunkTimeTicks, discoveredBlocks, observedBlockCounts, biomes, items, entities, brokenBlocks, recentEvents, List.of(), voxelProjects, Set.of(), Set.of(), Set.of());
}
public PlayerMemory(
Set<String> visitedChunks,
Map<String, Long> chunkTimeTicks,
Set<String> discoveredBlocks,
Map<String, Long> observedBlockCounts,
Set<String> biomes,
Set<String> items,
Set<String> entities,
Map<String, Long> brokenBlocks,
List<RecentMemoryEvent> recentEvents,
Map<String, VoxelProjectMemory> voxelProjects,
Set<String> loadedLibraryEntries,
Set<String> favoriteLibraryEntries,
Set<String> researchLibraryEntries
) {
this(visitedChunks, chunkTimeTicks, discoveredBlocks, observedBlockCounts, biomes, items, entities, brokenBlocks, recentEvents, List.of(), voxelProjects, loadedLibraryEntries, favoriteLibraryEntries, researchLibraryEntries);
}
public PlayerMemory(
Set<String> visitedChunks,
Map<String, Long> chunkTimeTicks,
Set<String> discoveredBlocks,
Map<String, Long> observedBlockCounts,
Set<String> biomes,
Set<String> items,
Set<String> entities,
Map<String, Long> brokenBlocks,
List<RecentMemoryEvent> recentEvents,
List<PlayerStatSample> statSamples,
Map<String, VoxelProjectMemory> voxelProjects,
Set<String> loadedLibraryEntries,
Set<String> favoriteLibraryEntries,
Set<String> researchLibraryEntries
) {
this.visitedChunks = new LinkedHashSet<>(visitedChunks);
this.chunkTimeTicks = new LinkedHashMap<>(chunkTimeTicks);
this.discoveredBlocks = new LinkedHashSet<>(discoveredBlocks);
this.observedBlockCounts = new LinkedHashMap<>(observedBlockCounts);
this.biomes = new LinkedHashSet<>(biomes);
this.items = new LinkedHashSet<>(items);
this.entities = new LinkedHashSet<>(entities);
this.brokenBlocks = new LinkedHashMap<>(brokenBlocks);
this.recentEvents = new ArrayList<>(recentEvents);
this.statSamples = new ArrayList<>(statSamples);
this.voxelProjects = new LinkedHashMap<>(voxelProjects);
this.loadedLibraryEntries = new LinkedHashSet<>(loadedLibraryEntries);
this.favoriteLibraryEntries = new LinkedHashSet<>(favoriteLibraryEntries);
this.researchLibraryEntries = new LinkedHashSet<>(researchLibraryEntries);
trimRecentEvents();
trimStatSamples();
}
public boolean observeChunk(ChunkKey key, Map<String, Long> blockCounts, Set<String> chunkBiomes) {
boolean changed = false;
boolean firstVisit = visitedChunks.add(key.asStorageKey());
changed |= firstVisit;
changed |= discoveredBlocks.addAll(blockCounts.keySet());
changed |= biomes.addAll(chunkBiomes);
if (firstVisit) {
for (Map.Entry<String, Long> entry : blockCounts.entrySet()) {
MemoryMath.addCount(observedBlockCounts, entry.getKey(), entry.getValue());
}
}
return changed;
}
public boolean addChunkTime(ChunkKey key, long ticks) {
if (ticks <= 0) {
return false;
}
visitedChunks.add(key.asStorageKey());
MemoryMath.addCount(chunkTimeTicks, key.asStorageKey(), ticks);
return true;
}
public boolean recordItem(String itemId) {
return items.add(itemId);
}
public boolean recordEntity(String entityId) {
return entities.add(entityId);
}
public boolean recordBrokenBlock(String blockId) {
MemoryMath.addCount(brokenBlocks, blockId, 1);
discoveredBlocks.add(blockId);
return true;
}
public int discoverBlocks(Collection<String> blockIds) {
return addAll(discoveredBlocks, blockIds);
}
public int discoverItems(Collection<String> itemIds) {
return addAll(items, itemIds);
}
public int discoverEntities(Collection<String> entityIds) {
return addAll(entities, entityIds);
}
public boolean recordRecentEvent(String type, String id, long tick, String dimension, int x, int y, int z) {
if (type == null || type.isBlank() || id == null || id.isBlank()) {
return false;
}
recentEvents.addFirst(new RecentMemoryEvent(type, id, tick, dimension == null ? "" : dimension, x, y, z));
trimRecentEvents();
return true;
}
public boolean recordStatSample(PlayerStatSample sample) {
if (sample == null) {
return false;
}
if (!statSamples.isEmpty()) {
PlayerStatSample last = statSamples.getLast();
if (sample.playTimeTicks() < last.playTimeTicks()) {
return false;
}
if (sample.playTimeTicks() == last.playTimeTicks()) {
if (sample.equals(last)) {
return false;
}
statSamples.set(statSamples.size() - 1, sample);
return true;
}
}
statSamples.add(sample);
trimStatSamples();
return true;
}
public boolean saveVoxelProject(VoxelProjectMemory project) {
if (project == null || project.id().isBlank()) {
return false;
}
voxelProjects.put(project.id(), project);
return true;
}
public boolean deleteVoxelProject(String projectId) {
if (projectId == null || projectId.isBlank()) {
return false;
}
return voxelProjects.remove(projectId) != null;
}
public boolean loadLibraryEntry(String entryId) {
return addId(loadedLibraryEntries, entryId);
}
public boolean favoriteLibraryEntry(String entryId) {
boolean changed = loadLibraryEntry(entryId);
changed |= addId(favoriteLibraryEntries, entryId);
return changed;
}
public boolean unfavoriteLibraryEntry(String entryId) {
return favoriteLibraryEntries.remove(entryId);
}
public boolean toggleFavoriteLibraryEntry(String entryId) {
if (favoriteLibraryEntries.contains(entryId)) {
return unfavoriteLibraryEntry(entryId);
}
return favoriteLibraryEntry(entryId);
}
public boolean addResearchLibraryEntry(String entryId) {
return addId(researchLibraryEntries, entryId);
}
public int addResearchLibraryEntries(Collection<String> entryIds) {
return addAll(researchLibraryEntries, entryIds);
}
public boolean removeResearchLibraryEntry(String entryId) {
return researchLibraryEntries.remove(entryId);
}
public boolean toggleResearchLibraryEntry(String entryId) {
if (researchLibraryEntries.contains(entryId)) {
return removeResearchLibraryEntry(entryId);
}
return addResearchLibraryEntry(entryId);
}
private static int addAll(Set<String> target, Collection<String> ids) {
int added = 0;
for (String id : ids) {
if (id == null || id.isBlank()) {
continue;
}
if (addId(target, id)) {
added++;
}
}
return added;
}
private static boolean addId(Set<String> target, String id) {
if (id == null || id.isBlank()) {
return false;
}
return target.add(id);
}
public Set<String> visitedChunks() {
return visitedChunks;
}
public Map<String, Long> chunkTimeTicks() {
return chunkTimeTicks;
}
public Set<String> discoveredBlocks() {
return discoveredBlocks;
}
public Map<String, Long> observedBlockCounts() {
return observedBlockCounts;
}
public Set<String> biomes() {
return biomes;
}
public Set<String> items() {
return items;
}
public Set<String> entities() {
return entities;
}
public Map<String, Long> brokenBlocks() {
return brokenBlocks;
}
public List<RecentMemoryEvent> recentEvents() {
return recentEvents;
}
public List<PlayerStatSample> statSamples() {
return statSamples;
}
public Map<String, VoxelProjectMemory> voxelProjects() {
return voxelProjects;
}
public Set<String> loadedLibraryEntries() {
return loadedLibraryEntries;
}
public Set<String> favoriteLibraryEntries() {
return favoriteLibraryEntries;
}
public Set<String> researchLibraryEntries() {
return researchLibraryEntries;
}
private void trimRecentEvents() {
while (recentEvents.size() > MAX_RECENT_EVENTS) {
recentEvents.removeLast();
}
}
private void trimStatSamples() {
while (statSamples.size() > MAX_STAT_SAMPLES) {
statSamples.removeFirst();
}
}
}
@@ -0,0 +1,24 @@
package dev.blocodex.memory;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
public record PlayerStatSample(
long playTimeTicks,
long distanceCm,
long brokenBlocks,
long crafts,
int visitedChunks,
int biomes,
int discoveredBlocks
) {
public static final Codec<PlayerStatSample> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.LONG.optionalFieldOf("play_time_ticks", 0L).forGetter(PlayerStatSample::playTimeTicks),
Codec.LONG.optionalFieldOf("distance_cm", 0L).forGetter(PlayerStatSample::distanceCm),
Codec.LONG.optionalFieldOf("broken_blocks", 0L).forGetter(PlayerStatSample::brokenBlocks),
Codec.LONG.optionalFieldOf("crafts", 0L).forGetter(PlayerStatSample::crafts),
Codec.INT.optionalFieldOf("visited_chunks", 0).forGetter(PlayerStatSample::visitedChunks),
Codec.INT.optionalFieldOf("biomes", 0).forGetter(PlayerStatSample::biomes),
Codec.INT.optionalFieldOf("discovered_blocks", 0).forGetter(PlayerStatSample::discoveredBlocks)
).apply(instance, PlayerStatSample::new));
}
@@ -0,0 +1,24 @@
package dev.blocodex.memory;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
public record RecentMemoryEvent(
String type,
String id,
long tick,
String dimension,
int x,
int y,
int z
) {
public static final Codec<RecentMemoryEvent> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.STRING.optionalFieldOf("type", "").forGetter(RecentMemoryEvent::type),
Codec.STRING.optionalFieldOf("id", "").forGetter(RecentMemoryEvent::id),
Codec.LONG.optionalFieldOf("tick", 0L).forGetter(RecentMemoryEvent::tick),
Codec.STRING.optionalFieldOf("dimension", "").forGetter(RecentMemoryEvent::dimension),
Codec.INT.optionalFieldOf("x", 0).forGetter(RecentMemoryEvent::x),
Codec.INT.optionalFieldOf("y", 0).forGetter(RecentMemoryEvent::y),
Codec.INT.optionalFieldOf("z", 0).forGetter(RecentMemoryEvent::z)
).apply(instance, RecentMemoryEvent::new));
}
@@ -0,0 +1,37 @@
package dev.blocodex.memory;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public record VoxelProjectMemory(
String id,
String name,
int width,
int height,
int depth,
List<String> palette,
Map<String, String> cells,
long createdTick,
long updatedTick
) {
public static final Codec<VoxelProjectMemory> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.STRING.fieldOf("id").forGetter(VoxelProjectMemory::id),
Codec.STRING.optionalFieldOf("name", "Untitled").forGetter(VoxelProjectMemory::name),
Codec.INT.optionalFieldOf("width", 0).forGetter(VoxelProjectMemory::width),
Codec.INT.optionalFieldOf("height", 0).forGetter(VoxelProjectMemory::height),
Codec.INT.optionalFieldOf("depth", 0).forGetter(VoxelProjectMemory::depth),
Codec.STRING.listOf().optionalFieldOf("palette", List.of()).forGetter(VoxelProjectMemory::palette),
Codec.unboundedMap(Codec.STRING, Codec.STRING).optionalFieldOf("cells", Map.of()).forGetter(VoxelProjectMemory::cells),
Codec.LONG.optionalFieldOf("created_tick", 0L).forGetter(VoxelProjectMemory::createdTick),
Codec.LONG.optionalFieldOf("updated_tick", 0L).forGetter(VoxelProjectMemory::updatedTick)
).apply(instance, VoxelProjectMemory::new));
public VoxelProjectMemory {
palette = List.copyOf(palette);
cells = new LinkedHashMap<>(cells);
}
}
@@ -0,0 +1,112 @@
package dev.blocodex.memory;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
public final class WorldMemory {
public static final Codec<WorldMemory> CODEC = RecordCodecBuilder.create(instance -> instance.group(
MemoryCodecs.STRING_LONG_MAP.optionalFieldOf("block_counts", Map.of()).forGetter(WorldMemory::blockCounts),
MemoryCodecs.STRING_LONG_MAP.optionalFieldOf("biome_counts", Map.of()).forGetter(WorldMemory::biomeCounts),
MemoryCodecs.STRING_LONG_MAP.optionalFieldOf("dimension_counts", Map.of()).forGetter(WorldMemory::dimensionCounts),
MemoryCodecs.STRING_LONG_MAP.optionalFieldOf("entity_counts", Map.of()).forGetter(WorldMemory::entityCounts),
MemoryCodecs.STRING_LONG_MAP.optionalFieldOf("item_counts", Map.of()).forGetter(WorldMemory::itemCounts),
Codec.LONG.optionalFieldOf("observed_chunks", 0L).forGetter(WorldMemory::observedChunks)
).apply(instance, WorldMemory::new));
private final Map<String, Long> blockCounts;
private final Map<String, Long> biomeCounts;
private final Map<String, Long> dimensionCounts;
private final Map<String, Long> entityCounts;
private final Map<String, Long> itemCounts;
private long observedChunks;
public WorldMemory() {
this(Map.of(), Map.of(), Map.of(), Map.of(), Map.of(), 0L);
}
public WorldMemory(
Map<String, Long> blockCounts,
Map<String, Long> biomeCounts,
Map<String, Long> dimensionCounts,
Map<String, Long> entityCounts,
Map<String, Long> itemCounts,
long observedChunks
) {
this.blockCounts = new LinkedHashMap<>(blockCounts);
this.biomeCounts = new LinkedHashMap<>(biomeCounts);
this.dimensionCounts = new LinkedHashMap<>(dimensionCounts);
this.entityCounts = new LinkedHashMap<>(entityCounts);
this.itemCounts = new LinkedHashMap<>(itemCounts);
this.observedChunks = observedChunks;
}
public void recordNewChunk(ChunkKey key, Map<String, Long> blockCounts, Set<String> biomes) {
observedChunks++;
MemoryMath.addCount(dimensionCounts, key.dimension(), 1);
applyBlockDelta(blockCounts);
for (String biome : biomes) {
MemoryMath.addCount(biomeCounts, biome, 1);
}
}
public void applyChunkDelta(Map<String, Long> blockDelta, Set<String> addedBiomes, Set<String> removedBiomes) {
applyBlockDelta(blockDelta);
for (String biome : addedBiomes) {
MemoryMath.addCount(biomeCounts, biome, 1);
}
for (String biome : removedBiomes) {
MemoryMath.addCount(biomeCounts, biome, -1);
}
}
public void recordItem(String itemId) {
MemoryMath.addCount(itemCounts, itemId, 1);
}
public void recordEntity(String entityId) {
MemoryMath.addCount(entityCounts, entityId, 1);
}
public void clear() {
blockCounts.clear();
biomeCounts.clear();
dimensionCounts.clear();
entityCounts.clear();
itemCounts.clear();
observedChunks = 0L;
}
private void applyBlockDelta(Map<String, Long> delta) {
for (Map.Entry<String, Long> entry : delta.entrySet()) {
MemoryMath.addCount(blockCounts, entry.getKey(), entry.getValue());
}
}
public Map<String, Long> blockCounts() {
return blockCounts;
}
public Map<String, Long> biomeCounts() {
return biomeCounts;
}
public Map<String, Long> dimensionCounts() {
return dimensionCounts;
}
public Map<String, Long> entityCounts() {
return entityCounts;
}
public Map<String, Long> itemCounts() {
return itemCounts;
}
public long observedChunks() {
return observedChunks;
}
}
@@ -0,0 +1,33 @@
package dev.blocodex.network;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import dev.blocodex.Blocodex;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
public record BlocodexLibraryActionPayload(String action, String entryId) implements CustomPacketPayload {
public static final String ACTION_LOAD = "load";
public static final String ACTION_TOGGLE_FAVORITE = "toggle_favorite";
public static final String ACTION_TOGGLE_RESEARCH = "toggle_research";
public static final String ACTION_REMOVE_RESEARCH = "remove_research";
public static final String ACTION_BUILD_ENTRY = "build_entry";
public static final Type<BlocodexLibraryActionPayload> TYPE = new Type<>(Blocodex.id("library_action"));
public static final Codec<BlocodexLibraryActionPayload> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.STRING.fieldOf("action").forGetter(BlocodexLibraryActionPayload::action),
Codec.STRING.fieldOf("entry_id").forGetter(BlocodexLibraryActionPayload::entryId)
).apply(instance, BlocodexLibraryActionPayload::new));
public static final StreamCodec<RegistryFriendlyByteBuf, BlocodexLibraryActionPayload> STREAM_CODEC = ByteBufCodecs.fromCodec(CODEC).cast();
public BlocodexLibraryActionPayload {
action = action == null ? "" : action.strip();
entryId = entryId == null ? "" : entryId.strip();
}
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
}
@@ -0,0 +1,342 @@
package dev.blocodex.network;
import dev.blocodex.codex.WsmcCodexIndex;
import dev.blocodex.memory.BlocodexSavedData;
import dev.blocodex.memory.PlayerMemory;
import dev.blocodex.memory.VoxelProjectMemory;
import dev.blocodex.server.BlocodexScanner;
import dev.blocodex.server.BlocodexStudioPalettes;
import dev.blocodex.server.BlocodexUiSnapshots;
import dev.blocodex.voxel.VoxelProjectPlacement;
import dev.blocodex.voxel.VoxelProjectRequirements;
import dev.blocodex.voxel.VoxelProjectValidator;
import net.minecraft.ChatFormatting;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.BuiltInRegistries;
import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
public final class BlocodexNetworking {
private static final int MAX_PLACEMENT_HISTORY = 32;
private static final Map<UUID, Deque<PlacementSnapshot>> UNDO_HISTORY = new HashMap<>();
private static final Map<UUID, Deque<PlacementSnapshot>> REDO_HISTORY = new HashMap<>();
private BlocodexNetworking() {
}
public static void register() {
PayloadTypeRegistry.serverboundPlay().register(BlocodexUiRequestPayload.TYPE, BlocodexUiRequestPayload.STREAM_CODEC);
PayloadTypeRegistry.serverboundPlay().register(BlocodexLibraryActionPayload.TYPE, BlocodexLibraryActionPayload.STREAM_CODEC);
PayloadTypeRegistry.serverboundPlay().register(BlocodexVoxelProjectSavePayload.TYPE, BlocodexVoxelProjectSavePayload.STREAM_CODEC);
PayloadTypeRegistry.serverboundPlay().register(BlocodexVoxelProjectRequestPayload.TYPE, BlocodexVoxelProjectRequestPayload.STREAM_CODEC);
PayloadTypeRegistry.serverboundPlay().register(BlocodexVoxelProjectDeletePayload.TYPE, BlocodexVoxelProjectDeletePayload.STREAM_CODEC);
PayloadTypeRegistry.serverboundPlay().register(BlocodexPlaceVoxelProjectPayload.TYPE, BlocodexPlaceVoxelProjectPayload.STREAM_CODEC);
PayloadTypeRegistry.serverboundPlay().register(BlocodexStudioPaletteRequestPayload.TYPE, BlocodexStudioPaletteRequestPayload.STREAM_CODEC);
PayloadTypeRegistry.clientboundPlay().register(BlocodexUiSnapshotPayload.TYPE, BlocodexUiSnapshotPayload.STREAM_CODEC);
PayloadTypeRegistry.clientboundPlay().register(BlocodexOpenComputerPayload.TYPE, BlocodexOpenComputerPayload.STREAM_CODEC);
PayloadTypeRegistry.clientboundPlay().register(BlocodexVoxelProjectPayload.TYPE, BlocodexVoxelProjectPayload.STREAM_CODEC);
PayloadTypeRegistry.clientboundPlay().register(BlocodexStudioPalettePayload.TYPE, BlocodexStudioPalettePayload.STREAM_CODEC);
ServerPlayNetworking.registerGlobalReceiver(BlocodexUiRequestPayload.TYPE, (payload, context) ->
context.server().execute(() -> {
BlocodexScanner.scanAroundPlayer(context.player(), 0, 1);
ServerPlayNetworking.send(context.player(), BlocodexUiSnapshots.create(context.player(), payload));
}));
ServerPlayNetworking.registerGlobalReceiver(BlocodexLibraryActionPayload.TYPE, (payload, context) ->
context.server().execute(() -> handleLibraryAction(context.player(), payload)));
ServerPlayNetworking.registerGlobalReceiver(BlocodexVoxelProjectSavePayload.TYPE, (payload, context) ->
context.server().execute(() -> handleVoxelProjectSave(context.player(), payload.project())));
ServerPlayNetworking.registerGlobalReceiver(BlocodexVoxelProjectRequestPayload.TYPE, (payload, context) ->
context.server().execute(() -> handleVoxelProjectRequest(context.player(), payload.projectId())));
ServerPlayNetworking.registerGlobalReceiver(BlocodexVoxelProjectDeletePayload.TYPE, (payload, context) ->
context.server().execute(() -> handleVoxelProjectDelete(context.player(), payload.projectId())));
ServerPlayNetworking.registerGlobalReceiver(BlocodexPlaceVoxelProjectPayload.TYPE, (payload, context) ->
context.server().execute(() -> handlePlaceVoxelProject(context.player(), payload)));
ServerPlayNetworking.registerGlobalReceiver(BlocodexStudioPaletteRequestPayload.TYPE, (payload, context) ->
context.server().execute(() -> handleStudioPaletteRequest(context.player(), payload)));
}
private static void handleLibraryAction(ServerPlayer player, BlocodexLibraryActionPayload payload) {
if (payload.entryId().isBlank()) {
return;
}
if (!isAddressableLibraryEntry(player, payload.entryId())) {
player.sendSystemMessage(Component.literal("Entree Blocotheque inconnue: " + payload.entryId()).withStyle(ChatFormatting.RED));
return;
}
BlocodexSavedData data = BlocodexSavedData.get(player.level().getServer());
PlayerMemory memory = data.memory().player(player.getUUID().toString());
boolean changed;
if (BlocodexLibraryActionPayload.ACTION_BUILD_ENTRY.equals(payload.action())) {
changed = buildLibraryEntry(player, memory, payload.entryId());
} else if (BlocodexLibraryActionPayload.ACTION_TOGGLE_FAVORITE.equals(payload.action())) {
changed = memory.toggleFavoriteLibraryEntry(payload.entryId());
} else if (BlocodexLibraryActionPayload.ACTION_TOGGLE_RESEARCH.equals(payload.action())) {
changed = memory.toggleResearchLibraryEntry(payload.entryId());
} else if (BlocodexLibraryActionPayload.ACTION_REMOVE_RESEARCH.equals(payload.action())) {
changed = memory.removeResearchLibraryEntry(payload.entryId());
} else {
changed = memory.loadLibraryEntry(payload.entryId());
}
if (changed) {
data.setDirty();
}
}
private static boolean buildLibraryEntry(ServerPlayer player, PlayerMemory memory, String entryId) {
if (!player.isCreative()) {
player.sendSystemMessage(Component.literal("Build Blocotheque reserve au mode creatif pour l'instant.").withStyle(ChatFormatting.YELLOW));
return false;
}
if (!isKnownLibraryEntry(player, entryId)) {
player.sendSystemMessage(Component.literal("Entree verrouillee: " + entryId).withStyle(ChatFormatting.RED));
return false;
}
ItemStack stack = stackFor(entryId);
if (stack.isEmpty()) {
player.sendSystemMessage(Component.literal("Aucun item constructible pour " + entryId).withStyle(ChatFormatting.RED));
return false;
}
stack.setCount(Math.min(stack.getMaxStackSize(), 64));
if (!player.getInventory().add(stack)) {
player.drop(stack, false);
}
boolean changed = memory.loadLibraryEntry(entryId);
player.sendSystemMessage(Component.literal("Build ajoute a l'inventaire: " + stack.getHoverName().getString()).withStyle(ChatFormatting.GREEN));
return changed;
}
private static ItemStack stackFor(String entryId) {
Identifier id = Identifier.tryParse(entryId);
if (id == null) {
return ItemStack.EMPTY;
}
Optional<Item> item = BuiltInRegistries.ITEM.getOptional(id);
if (item.isPresent() && item.get() != Items.AIR) {
return new ItemStack(item.get());
}
Optional<Block> block = BuiltInRegistries.BLOCK.getOptional(id);
if (block.isPresent() && block.get().asItem() != Items.AIR) {
return new ItemStack(block.get().asItem());
}
Identifier spawnEggId = Identifier.fromNamespaceAndPath(id.getNamespace(), id.getPath() + "_spawn_egg");
Optional<Item> spawnEgg = BuiltInRegistries.ITEM.getOptional(spawnEggId);
return spawnEgg.isPresent() && spawnEgg.get() != Items.AIR ? new ItemStack(spawnEgg.get()) : ItemStack.EMPTY;
}
private static void handleVoxelProjectSave(ServerPlayer player, VoxelProjectMemory project) {
VoxelProjectValidator.ValidationResult validation = VoxelProjectValidator.validate(project);
if (!validation.valid()) {
player.sendSystemMessage(Component.literal(validation.message()).withStyle(ChatFormatting.RED));
return;
}
BlocodexSavedData data = BlocodexSavedData.get(player.level().getServer());
PlayerMemory memory = data.memory().player(player.getUUID().toString());
VoxelProjectMemory existing = memory.voxelProjects().get(project.id());
long now = player.level().getGameTime();
VoxelProjectMemory saved = new VoxelProjectMemory(
project.id(),
project.name(),
project.width(),
project.height(),
project.depth(),
project.palette(),
project.cells(),
existing == null || existing.createdTick() == 0L ? now : existing.createdTick(),
now
);
memory.saveVoxelProject(saved);
int addedResearch = memory.addResearchLibraryEntries(VoxelProjectRequirements.missingRequiredBlocks(saved, memory));
data.setDirty();
String suffix = addedResearch > 0 ? " (" + addedResearch + " blocs ajoutes aux recherches)" : "";
player.sendSystemMessage(Component.literal("Creation sauvegardee: " + saved.name() + suffix).withStyle(ChatFormatting.GREEN));
}
private static void handleVoxelProjectRequest(ServerPlayer player, String projectId) {
if (projectId.isBlank()) {
return;
}
PlayerMemory memory = BlocodexSavedData.get(player.level().getServer()).memory().player(player.getUUID().toString());
VoxelProjectMemory project = memory.voxelProjects().get(projectId);
if (project != null && ServerPlayNetworking.canSend(player, BlocodexVoxelProjectPayload.TYPE)) {
ServerPlayNetworking.send(player, new BlocodexVoxelProjectPayload(project));
}
}
private static void handleStudioPaletteRequest(ServerPlayer player, BlocodexStudioPaletteRequestPayload payload) {
if (ServerPlayNetworking.canSend(player, BlocodexStudioPalettePayload.TYPE)) {
ServerPlayNetworking.send(player, BlocodexStudioPalettes.create(player, payload));
}
}
private static void handleVoxelProjectDelete(ServerPlayer player, String projectId) {
if (projectId.isBlank()) {
return;
}
BlocodexSavedData data = BlocodexSavedData.get(player.level().getServer());
PlayerMemory memory = data.memory().player(player.getUUID().toString());
if (memory.deleteVoxelProject(projectId)) {
data.setDirty();
player.sendSystemMessage(Component.literal("Creation supprimee: " + projectId).withStyle(ChatFormatting.YELLOW));
}
}
private static void handlePlaceVoxelProject(ServerPlayer player, BlocodexPlaceVoxelProjectPayload payload) {
if (BlocodexPlaceVoxelProjectPayload.MODE_UNDO.equals(payload.mode())) {
undoPlacement(player);
return;
}
if (BlocodexPlaceVoxelProjectPayload.MODE_REDO.equals(payload.mode())) {
redoPlacement(player);
return;
}
if (!BlocodexPlaceVoxelProjectPayload.MODE_CREATIVE_DIRECT.equals(payload.mode())) {
player.sendSystemMessage(Component.literal("Mode de pose inconnu.").withStyle(ChatFormatting.RED));
return;
}
if (!player.isCreative()) {
player.sendSystemMessage(Component.literal("Pose directe reservee au mode creatif.").withStyle(ChatFormatting.RED));
return;
}
PlayerMemory memory = BlocodexSavedData.get(player.level().getServer()).memory().player(player.getUUID().toString());
VoxelProjectMemory project = memory.voxelProjects().get(payload.projectId());
VoxelProjectValidator.ValidationResult validation = VoxelProjectValidator.validate(project);
if (!validation.valid()) {
player.sendSystemMessage(Component.literal(validation.message()).withStyle(ChatFormatting.RED));
return;
}
int placed = 0;
List<BlockChange> changes = new ArrayList<>();
for (VoxelProjectPlacement.Cell cell : VoxelProjectPlacement.orderedCells(project)) {
Optional<Block> block = block(cell.blockId());
if (block.isEmpty() || block.get() == Blocks.AIR) {
continue;
}
BlockPos worldPos = VoxelProjectPlacement.transform(project, cell, payload.anchor(), payload.facing());
BlockState before = player.level().getBlockState(worldPos);
BlockState after = block.get().defaultBlockState();
if (before.equals(after)) {
continue;
}
player.level().setBlock(worldPos, after, 3);
changes.add(new BlockChange(worldPos.immutable(), before, after));
placed++;
}
if (!changes.isEmpty()) {
pushUndo(player.getUUID(), new PlacementSnapshot(List.copyOf(changes)));
REDO_HISTORY.computeIfAbsent(player.getUUID(), ignored -> new ArrayDeque<>()).clear();
}
player.sendSystemMessage(Component.literal("Structure posee: " + placed + " blocs.").withStyle(ChatFormatting.GREEN));
}
private static void undoPlacement(ServerPlayer player) {
Deque<PlacementSnapshot> undo = UNDO_HISTORY.computeIfAbsent(player.getUUID(), ignored -> new ArrayDeque<>());
PlacementSnapshot snapshot = undo.pollFirst();
if (snapshot == null) {
player.sendSystemMessage(Component.literal("Aucune construction a annuler.").withStyle(ChatFormatting.YELLOW));
return;
}
restore(player, snapshot, false);
REDO_HISTORY.computeIfAbsent(player.getUUID(), ignored -> new ArrayDeque<>()).push(snapshot);
player.sendSystemMessage(Component.literal("Construction annulee.").withStyle(ChatFormatting.YELLOW));
}
private static void redoPlacement(ServerPlayer player) {
Deque<PlacementSnapshot> redo = REDO_HISTORY.computeIfAbsent(player.getUUID(), ignored -> new ArrayDeque<>());
PlacementSnapshot snapshot = redo.pollFirst();
if (snapshot == null) {
player.sendSystemMessage(Component.literal("Aucune construction a retablir.").withStyle(ChatFormatting.YELLOW));
return;
}
restore(player, snapshot, true);
pushUndo(player.getUUID(), snapshot);
player.sendSystemMessage(Component.literal("Construction retablie.").withStyle(ChatFormatting.GREEN));
}
private static void restore(ServerPlayer player, PlacementSnapshot snapshot, boolean after) {
List<BlockChange> changes = snapshot.changes();
if (after) {
for (BlockChange change : changes) {
player.level().setBlock(change.pos(), change.after(), 3);
}
} else {
for (int index = changes.size() - 1; index >= 0; index--) {
BlockChange change = changes.get(index);
player.level().setBlock(change.pos(), change.before(), 3);
}
}
}
private static void pushUndo(UUID playerId, PlacementSnapshot snapshot) {
Deque<PlacementSnapshot> undo = UNDO_HISTORY.computeIfAbsent(playerId, ignored -> new ArrayDeque<>());
undo.push(snapshot);
while (undo.size() > MAX_PLACEMENT_HISTORY) {
undo.removeLast();
}
}
private static boolean isKnownLibraryEntry(ServerPlayer player, String entryId) {
PlayerMemory memory = BlocodexSavedData.get(player.level().getServer()).memory().player(player.getUUID().toString());
return memory.discoveredBlocks().contains(entryId)
|| memory.items().contains(entryId)
|| memory.entities().contains(entryId)
|| memory.loadedLibraryEntries().contains(entryId)
|| memory.favoriteLibraryEntries().contains(entryId)
|| memory.researchLibraryEntries().contains(entryId);
}
private static boolean isAddressableLibraryEntry(ServerPlayer player, String entryId) {
if (isKnownLibraryEntry(player, entryId)) {
return true;
}
Identifier id = Identifier.tryParse(entryId);
if (id != null && (BuiltInRegistries.BLOCK.containsKey(id) || BuiltInRegistries.ITEM.containsKey(id) || BuiltInRegistries.ENTITY_TYPE.containsKey(id))) {
return true;
}
WsmcCodexIndex index = WsmcCodexIndex.loadDefault();
return index.entry(entryId).isPresent() || index.entityEntry(entryId).isPresent();
}
private static Optional<Block> block(String blockId) {
Identifier id = Identifier.tryParse(blockId);
return id == null ? Optional.empty() : BuiltInRegistries.BLOCK.getOptional(id);
}
private record PlacementSnapshot(List<BlockChange> changes) {
}
private record BlockChange(BlockPos pos, BlockState before, BlockState after) {
}
}
@@ -0,0 +1,16 @@
package dev.blocodex.network;
import dev.blocodex.Blocodex;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
public record BlocodexOpenComputerPayload() implements CustomPacketPayload {
public static final Type<BlocodexOpenComputerPayload> TYPE = new Type<>(Blocodex.id("open_computer"));
public static final StreamCodec<RegistryFriendlyByteBuf, BlocodexOpenComputerPayload> STREAM_CODEC = StreamCodec.unit(new BlocodexOpenComputerPayload());
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
}
@@ -0,0 +1,39 @@
package dev.blocodex.network;
import dev.blocodex.Blocodex;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
public record BlocodexPlaceVoxelProjectPayload(String projectId, BlockPos anchor, Direction facing, String mode) implements CustomPacketPayload {
public static final String MODE_CREATIVE_DIRECT = "creative_direct";
public static final String MODE_UNDO = "undo";
public static final String MODE_REDO = "redo";
public static final Type<BlocodexPlaceVoxelProjectPayload> TYPE = new Type<>(Blocodex.id("place_voxel_project"));
public static final StreamCodec<RegistryFriendlyByteBuf, BlocodexPlaceVoxelProjectPayload> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.STRING_UTF8,
BlocodexPlaceVoxelProjectPayload::projectId,
BlockPos.STREAM_CODEC,
BlocodexPlaceVoxelProjectPayload::anchor,
Direction.STREAM_CODEC,
BlocodexPlaceVoxelProjectPayload::facing,
ByteBufCodecs.STRING_UTF8,
BlocodexPlaceVoxelProjectPayload::mode,
BlocodexPlaceVoxelProjectPayload::new
);
public BlocodexPlaceVoxelProjectPayload {
projectId = projectId == null ? "" : projectId.strip();
anchor = anchor == null ? BlockPos.ZERO : anchor.immutable();
facing = facing == null ? Direction.NORTH : facing;
mode = mode == null ? "" : mode.strip();
}
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
}
@@ -0,0 +1,45 @@
package dev.blocodex.network;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import dev.blocodex.Blocodex;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import java.util.List;
public record BlocodexStudioPalettePayload(
List<String> blockIds,
List<Integer> colors,
List<String> labels,
String selectedBlockId,
int selectedColor
) implements CustomPacketPayload {
public static final Type<BlocodexStudioPalettePayload> TYPE = new Type<>(Blocodex.id("studio_palette"));
public static final Codec<BlocodexStudioPalettePayload> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.STRING.listOf().optionalFieldOf("block_ids", List.of()).forGetter(BlocodexStudioPalettePayload::blockIds),
Codec.INT.listOf().optionalFieldOf("colors", List.of()).forGetter(BlocodexStudioPalettePayload::colors),
Codec.STRING.listOf().optionalFieldOf("labels", List.of()).forGetter(BlocodexStudioPalettePayload::labels),
Codec.STRING.optionalFieldOf("selected_block_id", "").forGetter(BlocodexStudioPalettePayload::selectedBlockId),
Codec.INT.optionalFieldOf("selected_color", 0xFF777777).forGetter(BlocodexStudioPalettePayload::selectedColor)
).apply(instance, BlocodexStudioPalettePayload::new));
public static final StreamCodec<RegistryFriendlyByteBuf, BlocodexStudioPalettePayload> STREAM_CODEC = ByteBufCodecs.fromCodec(CODEC).cast();
public BlocodexStudioPalettePayload {
blockIds = blockIds == null ? List.of() : List.copyOf(blockIds);
colors = (colors == null ? List.<Integer>of() : colors).stream()
.map(color -> 0xFF000000 | (color & 0x00FFFFFF))
.toList();
labels = labels == null ? List.of() : List.copyOf(labels);
selectedBlockId = selectedBlockId == null ? "" : selectedBlockId.strip();
selectedColor = 0xFF000000 | (selectedColor & 0x00FFFFFF);
}
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
}
@@ -0,0 +1,53 @@
package dev.blocodex.network;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import dev.blocodex.Blocodex;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
public record BlocodexStudioPaletteRequestPayload(String query, int hue, double saturation, double lightness) implements CustomPacketPayload {
private static final int MAX_QUERY_LENGTH = 64;
public static final Type<BlocodexStudioPaletteRequestPayload> TYPE = new Type<>(Blocodex.id("studio_palette_request"));
public static final Codec<BlocodexStudioPaletteRequestPayload> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.STRING.optionalFieldOf("query", "").forGetter(BlocodexStudioPaletteRequestPayload::query),
Codec.INT.optionalFieldOf("hue", 0).forGetter(BlocodexStudioPaletteRequestPayload::hue),
Codec.DOUBLE.optionalFieldOf("saturation", 0.0D).forGetter(BlocodexStudioPaletteRequestPayload::saturation),
Codec.DOUBLE.optionalFieldOf("lightness", 0.0D).forGetter(BlocodexStudioPaletteRequestPayload::lightness)
).apply(instance, BlocodexStudioPaletteRequestPayload::new));
public static final StreamCodec<RegistryFriendlyByteBuf, BlocodexStudioPaletteRequestPayload> STREAM_CODEC = ByteBufCodecs.fromCodec(CODEC).cast();
public BlocodexStudioPaletteRequestPayload {
query = sanitizeQuery(query);
hue = normalizeHue(hue);
saturation = clamp01(saturation);
lightness = clamp01(lightness);
}
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
private static String sanitizeQuery(String query) {
if (query == null) {
return "";
}
String clean = query.replace('\n', ' ').replace('\r', ' ').strip();
return clean.length() > MAX_QUERY_LENGTH ? clean.substring(0, MAX_QUERY_LENGTH) : clean;
}
private static int normalizeHue(int hue) {
int normalized = hue % 360;
return normalized < 0 ? normalized + 360 : normalized;
}
private static double clamp01(double value) {
if (!Double.isFinite(value)) {
return 0.0D;
}
return Math.max(0.0D, Math.min(1.0D, value));
}
}
@@ -0,0 +1,48 @@
package dev.blocodex.network;
public enum BlocodexUiMode {
STATS(0, "STATISTIQUES"),
WORLD(1, "CHUNK SCANNER"),
COLOR(2, "COLOR SPACE"),
PROFILE(3, "Joueur"),
WORLD_OVERVIEW(4, "Monde"),
LIBRARY(5, "Blocotheque"),
CREATIONS(6, "Galerie");
private final int id;
private final String label;
BlocodexUiMode(int id, String label) {
this.id = id;
this.label = label;
}
public int id() {
return id;
}
public String label() {
return label;
}
public BlocodexUiMode next() {
return switch (this) {
case STATS -> WORLD;
case WORLD -> COLOR;
default -> STATS;
};
}
public static BlocodexUiMode[] computerModes() {
return new BlocodexUiMode[]{PROFILE, WORLD_OVERVIEW, LIBRARY, CREATIONS};
}
public static BlocodexUiMode byId(int id) {
for (BlocodexUiMode mode : values()) {
if (mode.id == id) {
return mode;
}
}
return STATS;
}
}
@@ -0,0 +1,43 @@
package dev.blocodex.network;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import dev.blocodex.Blocodex;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
public record BlocodexUiRequestPayload(int mode, int sliderValue, int page, int selectedIndex, String query) implements CustomPacketPayload {
private static final int MAX_QUERY_LENGTH = 48;
public static final Type<BlocodexUiRequestPayload> TYPE = new Type<>(Blocodex.id("ui_request"));
public static final Codec<BlocodexUiRequestPayload> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.INT.fieldOf("mode").forGetter(BlocodexUiRequestPayload::mode),
Codec.INT.fieldOf("slider_value").forGetter(BlocodexUiRequestPayload::sliderValue),
Codec.INT.fieldOf("page").forGetter(BlocodexUiRequestPayload::page),
Codec.INT.fieldOf("selected_index").forGetter(BlocodexUiRequestPayload::selectedIndex),
Codec.STRING.optionalFieldOf("query", "").forGetter(BlocodexUiRequestPayload::query)
).apply(instance, BlocodexUiRequestPayload::new));
public static final StreamCodec<RegistryFriendlyByteBuf, BlocodexUiRequestPayload> STREAM_CODEC = ByteBufCodecs.fromCodec(CODEC).cast();
public BlocodexUiRequestPayload(int mode, int sliderValue, int page, int selectedIndex) {
this(mode, sliderValue, page, selectedIndex, "");
}
public BlocodexUiRequestPayload {
query = sanitizeQuery(query);
}
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
private static String sanitizeQuery(String query) {
if (query == null) {
return "";
}
String clean = query.replace('\n', ' ').replace('\r', ' ').strip();
return clean.length() > MAX_QUERY_LENGTH ? clean.substring(0, MAX_QUERY_LENGTH) : clean;
}
}
@@ -0,0 +1,119 @@
package dev.blocodex.network;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import dev.blocodex.Blocodex;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import java.util.List;
public record BlocodexUiSnapshotPayload(
int mode,
int sliderValue,
int page,
int selectedIndex,
int minSliderValue,
int maxSliderValue,
List<String> leftLines,
List<String> gridBlockIds,
List<Integer> gridColors,
List<String> gridLabels,
String selectedBlockId,
int selectedColor,
List<String> paletteBlockIds,
List<String> graphPoints
) implements CustomPacketPayload {
public static final Type<BlocodexUiSnapshotPayload> TYPE = new Type<>(Blocodex.id("ui_snapshot"));
public static final Codec<BlocodexUiSnapshotPayload> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.INT.fieldOf("mode").forGetter(BlocodexUiSnapshotPayload::mode),
Codec.INT.fieldOf("slider_value").forGetter(BlocodexUiSnapshotPayload::sliderValue),
Codec.INT.fieldOf("page").forGetter(BlocodexUiSnapshotPayload::page),
Codec.INT.fieldOf("selected_index").forGetter(BlocodexUiSnapshotPayload::selectedIndex),
Codec.INT.fieldOf("min_slider_value").forGetter(BlocodexUiSnapshotPayload::minSliderValue),
Codec.INT.fieldOf("max_slider_value").forGetter(BlocodexUiSnapshotPayload::maxSliderValue),
Codec.STRING.listOf().fieldOf("left_lines").forGetter(BlocodexUiSnapshotPayload::leftLines),
Codec.STRING.listOf().fieldOf("grid_block_ids").forGetter(BlocodexUiSnapshotPayload::gridBlockIds),
Codec.INT.listOf().fieldOf("grid_colors").forGetter(BlocodexUiSnapshotPayload::gridColors),
Codec.STRING.listOf().fieldOf("grid_labels").forGetter(BlocodexUiSnapshotPayload::gridLabels),
Codec.STRING.fieldOf("selected_block_id").forGetter(BlocodexUiSnapshotPayload::selectedBlockId),
Codec.INT.fieldOf("selected_color").forGetter(BlocodexUiSnapshotPayload::selectedColor),
Codec.STRING.listOf().optionalFieldOf("palette_block_ids", List.of()).forGetter(BlocodexUiSnapshotPayload::paletteBlockIds),
Codec.STRING.listOf().optionalFieldOf("graph_points", List.of()).forGetter(BlocodexUiSnapshotPayload::graphPoints)
).apply(instance, BlocodexUiSnapshotPayload::new));
public static final StreamCodec<RegistryFriendlyByteBuf, BlocodexUiSnapshotPayload> STREAM_CODEC = ByteBufCodecs.fromCodec(CODEC).cast();
public BlocodexUiSnapshotPayload(
int mode,
int sliderValue,
int page,
int selectedIndex,
int minSliderValue,
int maxSliderValue,
List<String> leftLines,
List<String> gridBlockIds,
List<Integer> gridColors,
List<String> gridLabels,
String selectedBlockId,
int selectedColor
) {
this(mode, sliderValue, page, selectedIndex, minSliderValue, maxSliderValue, leftLines, gridBlockIds, gridColors, gridLabels, selectedBlockId, selectedColor, List.of(), List.of());
}
public BlocodexUiSnapshotPayload(
int mode,
int sliderValue,
int page,
int selectedIndex,
int minSliderValue,
int maxSliderValue,
List<String> leftLines,
List<String> gridBlockIds,
List<Integer> gridColors,
List<String> gridLabels,
String selectedBlockId,
int selectedColor,
List<String> paletteBlockIds
) {
this(mode, sliderValue, page, selectedIndex, minSliderValue, maxSliderValue, leftLines, gridBlockIds, gridColors, gridLabels, selectedBlockId, selectedColor, paletteBlockIds, List.of());
}
public BlocodexUiSnapshotPayload {
paletteBlockIds = List.copyOf(paletteBlockIds);
graphPoints = List.copyOf(graphPoints);
}
public static BlocodexUiSnapshotPayload empty() {
return new BlocodexUiSnapshotPayload(
BlocodexUiMode.STATS.id(),
0,
0,
40,
0,
8,
List.of("En attente du serveur..."),
emptyStrings(81),
repeatColor(81, 0xFF111111),
emptyStrings(81),
"",
0xFF111111,
List.of(),
List.of()
);
}
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
private static List<String> emptyStrings(int size) {
return java.util.Collections.nCopies(size, "");
}
private static List<Integer> repeatColor(int size, int color) {
return java.util.Collections.nCopies(size, color);
}
}
@@ -0,0 +1,26 @@
package dev.blocodex.network;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import dev.blocodex.Blocodex;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
public record BlocodexVoxelProjectDeletePayload(String projectId) implements CustomPacketPayload {
public static final Type<BlocodexVoxelProjectDeletePayload> TYPE = new Type<>(Blocodex.id("voxel_project_delete"));
public static final Codec<BlocodexVoxelProjectDeletePayload> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.STRING.fieldOf("project_id").forGetter(BlocodexVoxelProjectDeletePayload::projectId)
).apply(instance, BlocodexVoxelProjectDeletePayload::new));
public static final StreamCodec<RegistryFriendlyByteBuf, BlocodexVoxelProjectDeletePayload> STREAM_CODEC = ByteBufCodecs.fromCodec(CODEC).cast();
public BlocodexVoxelProjectDeletePayload {
projectId = projectId == null ? "" : projectId.strip();
}
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
}
@@ -0,0 +1,23 @@
package dev.blocodex.network;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import dev.blocodex.Blocodex;
import dev.blocodex.memory.VoxelProjectMemory;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
public record BlocodexVoxelProjectPayload(VoxelProjectMemory project) implements CustomPacketPayload {
public static final Type<BlocodexVoxelProjectPayload> TYPE = new Type<>(Blocodex.id("voxel_project"));
public static final Codec<BlocodexVoxelProjectPayload> CODEC = RecordCodecBuilder.create(instance -> instance.group(
VoxelProjectMemory.CODEC.fieldOf("project").forGetter(BlocodexVoxelProjectPayload::project)
).apply(instance, BlocodexVoxelProjectPayload::new));
public static final StreamCodec<RegistryFriendlyByteBuf, BlocodexVoxelProjectPayload> STREAM_CODEC = ByteBufCodecs.fromCodec(CODEC).cast();
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
}
@@ -0,0 +1,26 @@
package dev.blocodex.network;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import dev.blocodex.Blocodex;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
public record BlocodexVoxelProjectRequestPayload(String projectId) implements CustomPacketPayload {
public static final Type<BlocodexVoxelProjectRequestPayload> TYPE = new Type<>(Blocodex.id("voxel_project_request"));
public static final Codec<BlocodexVoxelProjectRequestPayload> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.STRING.fieldOf("project_id").forGetter(BlocodexVoxelProjectRequestPayload::projectId)
).apply(instance, BlocodexVoxelProjectRequestPayload::new));
public static final StreamCodec<RegistryFriendlyByteBuf, BlocodexVoxelProjectRequestPayload> STREAM_CODEC = ByteBufCodecs.fromCodec(CODEC).cast();
public BlocodexVoxelProjectRequestPayload {
projectId = projectId == null ? "" : projectId.strip();
}
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
}
@@ -0,0 +1,23 @@
package dev.blocodex.network;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import dev.blocodex.Blocodex;
import dev.blocodex.memory.VoxelProjectMemory;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
public record BlocodexVoxelProjectSavePayload(VoxelProjectMemory project) implements CustomPacketPayload {
public static final Type<BlocodexVoxelProjectSavePayload> TYPE = new Type<>(Blocodex.id("voxel_project_save"));
public static final Codec<BlocodexVoxelProjectSavePayload> CODEC = RecordCodecBuilder.create(instance -> instance.group(
VoxelProjectMemory.CODEC.fieldOf("project").forGetter(BlocodexVoxelProjectSavePayload::project)
).apply(instance, BlocodexVoxelProjectSavePayload::new));
public static final StreamCodec<RegistryFriendlyByteBuf, BlocodexVoxelProjectSavePayload> STREAM_CODEC = ByteBufCodecs.fromCodec(CODEC).cast();
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
}
@@ -0,0 +1,43 @@
package dev.blocodex.registry;
import dev.blocodex.Blocodex;
import dev.blocodex.block.ComputerBlock;
import net.minecraft.core.Registry;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.item.BlockItem;
import net.minecraft.world.item.Item;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.SoundType;
import net.minecraft.world.level.block.state.BlockBehaviour;
import java.util.function.Function;
public final class BlocodexBlocks {
public static final Block COMPUTER = register(
"computer",
ComputerBlock::new,
BlockBehaviour.Properties.of().strength(2.0F, 6.0F).sound(SoundType.METAL),
true
);
private BlocodexBlocks() {
}
public static void initialize() {
}
private static Block register(String name, Function<BlockBehaviour.Properties, Block> factory, BlockBehaviour.Properties properties, boolean registerItem) {
ResourceKey<Block> blockKey = ResourceKey.create(Registries.BLOCK, Blocodex.id(name));
Block block = factory.apply(properties.setId(blockKey));
if (registerItem) {
ResourceKey<Item> itemKey = ResourceKey.create(Registries.ITEM, Blocodex.id(name));
BlockItem item = new BlockItem(block, new Item.Properties().setId(itemKey).useBlockDescriptionPrefix());
Registry.register(BuiltInRegistries.ITEM, itemKey, item);
}
return Registry.register(BuiltInRegistries.BLOCK, blockKey, block);
}
}
@@ -0,0 +1,27 @@
package dev.blocodex.registry;
import dev.blocodex.Blocodex;
import dev.blocodex.item.BlocodexItem;
import net.minecraft.core.Registry;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.item.Item;
import java.util.function.Function;
public final class BlocodexItems {
public static final Item BLOCODEX = register("blocodex", BlocodexItem::new, new Item.Properties().stacksTo(1));
private BlocodexItems() {
}
public static void initialize() {
}
public static <T extends Item> T register(String name, Function<Item.Properties, T> factory, Item.Properties properties) {
ResourceKey<Item> key = ResourceKey.create(Registries.ITEM, Blocodex.id(name));
T item = factory.apply(properties.setId(key));
return Registry.register(BuiltInRegistries.ITEM, key, item);
}
}
@@ -0,0 +1,271 @@
package dev.blocodex.server;
import dev.blocodex.memory.BlocodexSavedData;
import dev.blocodex.memory.ChunkKey;
import dev.blocodex.memory.PlayerMemory;
import dev.blocodex.memory.PlayerStatSample;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.stats.ServerStatsCounter;
import net.minecraft.stats.Stats;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.phys.AABB;
import java.util.ArrayDeque;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Queue;
import java.util.Set;
public final class BlocodexScanner {
private static final int PASSIVE_RADIUS = 1;
private static final int SCAN_INTERVAL_TICKS = 40;
private static final int SCAN_BUDGET_PER_TICK = 2;
private static final int HEAT_INTERVAL_TICKS = 20;
private static final int STAT_SAMPLE_INTERVAL_TICKS = 6000;
private static final Queue<ScanTarget> PENDING_SCANS = new ArrayDeque<>();
private BlocodexScanner() {
}
public static void tick(MinecraftServer server) {
int tick = server.getTickCount();
if (tick % HEAT_INTERVAL_TICKS == 0) {
recordPlayerPresence(server, HEAT_INTERVAL_TICKS);
}
if (tick % SCAN_INTERVAL_TICKS == 0) {
enqueuePassiveScans(server, PASSIVE_RADIUS);
}
drainScanBudget(SCAN_BUDGET_PER_TICK);
}
public static int scanAroundPlayer(ServerPlayer player, int radius, int budget) {
int scanned = 0;
ChunkPos center = player.chunkPosition();
for (int x = center.x() - radius; x <= center.x() + radius; x++) {
for (int z = center.z() - radius; z <= center.z() + radius; z++) {
if (scanned >= budget) {
return scanned;
}
if (scanLoadedChunk(player, player.level(), x, z)) {
scanned++;
}
}
}
return scanned;
}
public static ChunkKey keyFor(ServerLevel level, int chunkX, int chunkZ) {
return new ChunkKey(level.dimension().identifier().toString(), chunkX, chunkZ);
}
private static void recordPlayerPresence(MinecraftServer server, long ticks) {
BlocodexSavedData data = BlocodexSavedData.get(server);
boolean changed = false;
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
ChunkPos chunkPos = player.chunkPosition();
ChunkKey key = keyFor(player.level(), chunkPos.x(), chunkPos.z());
String playerId = player.getUUID().toString();
changed |= data.memory().addChunkTime(playerId, key, ticks);
changed |= recordInventory(data, playerId, player);
changed |= recordNearbyEntities(data, playerId, player);
changed |= recordCurrentStatSample(data, playerId, player);
}
if (changed) {
data.setDirty();
}
}
private static boolean recordInventory(BlocodexSavedData data, String playerId, ServerPlayer player) {
boolean changed = false;
String dimension = player.level().dimension().identifier().toString();
BlockPos pos = player.blockPosition();
for (int slot = 0; slot < player.getInventory().getContainerSize(); slot++) {
ItemStack stack = player.getInventory().getItem(slot);
if (!stack.isEmpty()) {
String itemId = BuiltInRegistries.ITEM.getKey(stack.getItem()).toString();
changed |= data.memory().recordItem(playerId, itemId, player.level().getGameTime(), dimension, pos.getX(), pos.getY(), pos.getZ());
}
}
return changed;
}
private static boolean recordCurrentStatSample(BlocodexSavedData data, String playerId, ServerPlayer player) {
PlayerMemory memory = data.memory().player(playerId);
ServerStatsCounter stats = player.getStats();
long playTimeTicks = stats.getValue(Stats.CUSTOM.get(Stats.PLAY_TIME));
if (!memory.statSamples().isEmpty()) {
PlayerStatSample last = memory.statSamples().getLast();
if (playTimeTicks - last.playTimeTicks() < STAT_SAMPLE_INTERVAL_TICKS) {
return false;
}
}
PlayerStatSample sample = new PlayerStatSample(
playTimeTicks,
distanceCentimeters(stats),
sum(memory.brokenBlocks()),
craftedTotal(stats),
memory.visitedChunks().size(),
memory.biomes().size(),
memory.discoveredBlocks().size()
);
return memory.recordStatSample(sample);
}
private static long distanceCentimeters(ServerStatsCounter stats) {
return (long) stats.getValue(Stats.CUSTOM.get(Stats.WALK_ONE_CM))
+ stats.getValue(Stats.CUSTOM.get(Stats.CROUCH_ONE_CM))
+ stats.getValue(Stats.CUSTOM.get(Stats.SPRINT_ONE_CM))
+ stats.getValue(Stats.CUSTOM.get(Stats.WALK_ON_WATER_ONE_CM))
+ stats.getValue(Stats.CUSTOM.get(Stats.WALK_UNDER_WATER_ONE_CM))
+ stats.getValue(Stats.CUSTOM.get(Stats.SWIM_ONE_CM))
+ stats.getValue(Stats.CUSTOM.get(Stats.FLY_ONE_CM))
+ stats.getValue(Stats.CUSTOM.get(Stats.AVIATE_ONE_CM));
}
private static long craftedTotal(ServerStatsCounter stats) {
long total = 0;
for (Item item : BuiltInRegistries.ITEM) {
total += stats.getValue(Stats.ITEM_CRAFTED, item);
}
return total;
}
private static long sum(Map<String, Long> counts) {
return counts.values().stream().mapToLong(Long::longValue).sum();
}
private static boolean recordNearbyEntities(BlocodexSavedData data, String playerId, ServerPlayer player) {
boolean changed = false;
String dimension = player.level().dimension().identifier().toString();
BlockPos pos = player.blockPosition();
AABB box = player.getBoundingBox().inflate(32.0D);
for (Entity entity : player.level().getEntities(player, box)) {
String entityId = BuiltInRegistries.ENTITY_TYPE.getKey(entity.getType()).toString();
changed |= data.memory().recordEntity(playerId, entityId, player.level().getGameTime(), dimension, pos.getX(), pos.getY(), pos.getZ());
}
return changed;
}
private static void enqueuePassiveScans(MinecraftServer server, int radius) {
Set<String> queued = new HashSet<>();
for (ScanTarget pending : PENDING_SCANS) {
queued.add(pending.queueKey());
}
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
ChunkPos center = player.chunkPosition();
ServerLevel level = player.level();
for (int x = center.x() - radius; x <= center.x() + radius; x++) {
for (int z = center.z() - radius; z <= center.z() + radius; z++) {
ScanTarget target = new ScanTarget(player, level, x, z);
if (queued.add(target.queueKey())) {
PENDING_SCANS.add(target);
}
}
}
}
}
private static void drainScanBudget(int budget) {
int scanned = 0;
while (scanned < budget && !PENDING_SCANS.isEmpty()) {
ScanTarget target = PENDING_SCANS.poll();
if (target != null && scanLoadedChunk(target.player(), target.level(), target.chunkX(), target.chunkZ())) {
scanned++;
}
}
}
private static boolean scanLoadedChunk(ServerPlayer player, ServerLevel level, int chunkX, int chunkZ) {
LevelChunk chunk = level.getChunkSource().getChunkNow(chunkX, chunkZ);
if (chunk == null) {
return false;
}
Map<String, Long> blockCounts = new LinkedHashMap<>();
Map<Integer, Map<String, Long>> layerBlockCounts = new LinkedHashMap<>();
Set<String> biomes = new LinkedHashSet<>();
BlockPos.MutableBlockPos mutablePos = new BlockPos.MutableBlockPos();
int minFoundY = Integer.MAX_VALUE;
int maxFoundY = Integer.MIN_VALUE;
int minY = level.getMinY();
int maxY = level.getMaxY();
int baseX = chunkX << 4;
int baseZ = chunkZ << 4;
for (int localX = 0; localX < 16; localX++) {
for (int localZ = 0; localZ < 16; localZ++) {
for (int y = minY; y < maxY; y++) {
mutablePos.set(baseX + localX, y, baseZ + localZ);
BlockState state = chunk.getBlockState(mutablePos);
if (state.isAir()) {
continue;
}
blockCounts.merge(BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString(), 1L, Long::sum);
layerBlockCounts
.computeIfAbsent(y, ignored -> new LinkedHashMap<>())
.merge(BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString(), 1L, Long::sum);
minFoundY = Math.min(minFoundY, y);
maxFoundY = Math.max(maxFoundY, y);
}
}
}
int biomeY = Math.max(minY, Math.min(level.getSeaLevel(), maxY - 1));
for (int localX = 0; localX < 16; localX += 8) {
for (int localZ = 0; localZ < 16; localZ += 8) {
mutablePos.set(baseX + localX, biomeY, baseZ + localZ);
Optional<ResourceKey<Biome>> biomeKey = level.getBiome(mutablePos).unwrapKey();
biomeKey.map(key -> key.identifier().toString()).ifPresent(biomes::add);
}
}
if (minFoundY == Integer.MAX_VALUE) {
minFoundY = 0;
maxFoundY = 0;
}
BlocodexSavedData data = BlocodexSavedData.get(level.getServer());
boolean changed = data.memory().observeChunk(
player.getUUID().toString(),
keyFor(level, chunkX, chunkZ),
blockCounts,
layerBlockCounts,
biomes,
minFoundY,
maxFoundY,
level.getGameTime()
);
if (changed) {
data.setDirty();
}
return true;
}
private record ScanTarget(ServerPlayer player, ServerLevel level, int chunkX, int chunkZ) {
private String queueKey() {
return player.getUUID() + "|" + level.dimension().identifier() + "|" + chunkX + "|" + chunkZ;
}
}
}
@@ -0,0 +1,43 @@
package dev.blocodex.server;
import dev.blocodex.memory.BlocodexSavedData;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
public final class BlocodexServerEvents {
private BlocodexServerEvents() {
}
public static void register() {
ServerTickEvents.END_SERVER_TICK.register(BlocodexServerEvents::onServerTick);
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) ->
BlocodexSpawnComputer.handlePlayerJoin(handler.player, server));
PlayerBlockBreakEvents.AFTER.register((level, player, pos, state, blockEntity) -> {
if (!(level instanceof ServerLevel serverLevel)) {
return;
}
BlocodexSavedData data = BlocodexSavedData.get(serverLevel.getServer());
String blockId = BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString();
data.memory().recordBrokenBlock(
player.getUUID().toString(),
blockId,
serverLevel.getGameTime(),
serverLevel.dimension().identifier().toString(),
pos.getX(),
pos.getY(),
pos.getZ()
);
data.setDirty();
});
}
private static void onServerTick(MinecraftServer server) {
BlocodexScanner.tick(server);
BlocodexUiSnapshots.warmColorCaches(server, 3);
}
}
@@ -0,0 +1,116 @@
package dev.blocodex.server;
import dev.blocodex.Blocodex;
import dev.blocodex.memory.BlocodexWorldSetupData;
import dev.blocodex.registry.BlocodexBlocks;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.HorizontalDirectionalBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.storage.LevelData;
import java.util.Optional;
public final class BlocodexSpawnComputer {
private static final long NEW_WORLD_GRACE_TICKS = 20L * 60L * 5L;
private static final int SEARCH_RADIUS = 8;
private BlocodexSpawnComputer() {
}
public static void handlePlayerJoin(ServerPlayer player, MinecraftServer server) {
ServerLevel level = server.getLevel(Level.OVERWORLD);
if (level == null || player.level() != level) {
return;
}
BlocodexWorldSetupData setupData = BlocodexWorldSetupData.get(level);
if (setupData.spawnComputerHandled()) {
return;
}
if (level.getGameTime() > NEW_WORLD_GRACE_TICKS) {
setupData.markSpawnComputerHandled();
return;
}
BlockPos spawnCenter = level.getRespawnData().pos();
Optional<BlockPos> computerPos = findComputerPosition(level, spawnCenter);
if (computerPos.isEmpty()) {
return;
}
placeComputer(level, computerPos.get());
BlockPos playerSpawn = computerPos.get().above();
level.setRespawnData(LevelData.RespawnData.of(Level.OVERWORLD, playerSpawn, 180.0F, 0.0F));
player.teleportTo(playerSpawn.getX() + 0.5D, playerSpawn.getY(), playerSpawn.getZ() + 0.5D);
player.resetFallDistance();
setupData.markSpawnComputerHandled();
Blocodex.LOGGER.info("Placed the Blocodex spawn computer at {}", computerPos.get());
}
private static Optional<BlockPos> findComputerPosition(ServerLevel level, BlockPos center) {
for (int radius = 0; radius <= SEARCH_RADIUS; radius++) {
for (int dx = -radius; dx <= radius; dx++) {
for (int dz = -radius; dz <= radius; dz++) {
if (radius > 0 && Math.abs(dx) != radius && Math.abs(dz) != radius) {
continue;
}
Optional<BlockPos> candidate = computerPositionAt(level, center.getX() + dx, center.getZ() + dz);
if (candidate.isPresent()) {
return candidate;
}
}
}
}
return Optional.empty();
}
private static Optional<BlockPos> computerPositionAt(ServerLevel level, int x, int z) {
int minY = level.getMinY() + 1;
int maxY = level.getMaxY() - 3;
if (maxY < minY) {
return Optional.empty();
}
int y = clamp(level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z), minY, maxY);
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(x, y, z);
while (cursor.getY() <= maxY) {
if (canPrepareSpawnComputer(level, cursor)) {
return Optional.of(cursor.immutable());
}
cursor.move(Direction.UP);
}
return Optional.empty();
}
private static boolean canPrepareSpawnComputer(ServerLevel level, BlockPos pos) {
return canReplace(level.getBlockState(pos))
&& canReplace(level.getBlockState(pos.above()))
&& canReplace(level.getBlockState(pos.above(2)));
}
private static boolean canReplace(BlockState state) {
return state.isAir() || state.canBeReplaced();
}
private static void placeComputer(ServerLevel level, BlockPos pos) {
BlockState computer = BlocodexBlocks.COMPUTER.defaultBlockState()
.setValue(HorizontalDirectionalBlock.FACING, Direction.NORTH);
level.setBlockAndUpdate(pos.above(2), Blocks.AIR.defaultBlockState());
level.setBlockAndUpdate(pos.above(), Blocks.AIR.defaultBlockState());
level.setBlockAndUpdate(pos, computer);
}
private static int clamp(int value, int min, int max) {
return Math.max(min, Math.min(max, value));
}
}
@@ -0,0 +1,307 @@
package dev.blocodex.server;
import dev.blocodex.codex.WsmcCodexIndex;
import dev.blocodex.codex.WsmcCodexIndex.WsmcEntry;
import dev.blocodex.memory.BlocodexSavedData;
import dev.blocodex.memory.PlayerMemory;
import dev.blocodex.network.BlocodexStudioPalettePayload;
import dev.blocodex.network.BlocodexStudioPaletteRequestPayload;
import dev.blocodex.voxel.VoxelProjectValidator;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.EmptyBlockGetter;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.material.MapColor;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Predicate;
import java.util.function.ToIntFunction;
public final class BlocodexStudioPalettes {
private static final int MAX_ENTRIES = 256;
private static final int GRID_SIZE = 9;
private static final int FALLBACK_COLOR = 0xFF777777;
private static final Map<String, Integer> DYE_COLORS = Map.ofEntries(
Map.entry("white", 0xFFF9FFFE),
Map.entry("orange", 0xFFF9801D),
Map.entry("magenta", 0xFFC74EBD),
Map.entry("light_blue", 0xFF3AB3DA),
Map.entry("yellow", 0xFFFED83D),
Map.entry("lime", 0xFF80C71F),
Map.entry("pink", 0xFFF38BAA),
Map.entry("gray", 0xFF474F52),
Map.entry("light_gray", 0xFF9D9D97),
Map.entry("cyan", 0xFF169C9C),
Map.entry("purple", 0xFF8932B8),
Map.entry("blue", 0xFF3C44AA),
Map.entry("brown", 0xFF835432),
Map.entry("green", 0xFF5E7C16),
Map.entry("red", 0xFFB02E26),
Map.entry("black", 0xFF1D1D21)
);
private static final Map<String, Integer> WOOD_COLORS = Map.ofEntries(
Map.entry("oak", 0xFFA7834C),
Map.entry("spruce", 0xFF6B4B2A),
Map.entry("birch", 0xFFC8B66B),
Map.entry("jungle", 0xFFA66C4B),
Map.entry("acacia", 0xFFB45F2A),
Map.entry("dark_oak", 0xFF3F2B18),
Map.entry("mangrove", 0xFF77332E),
Map.entry("cherry", 0xFFE3AFC0),
Map.entry("bamboo", 0xFFC6B75B),
Map.entry("crimson", 0xFF7A3651),
Map.entry("warped", 0xFF36877F),
Map.entry("pale_oak", 0xFFD8D3BC)
);
private BlocodexStudioPalettes() {
}
public static BlocodexStudioPalettePayload create(ServerPlayer player, BlocodexStudioPaletteRequestPayload request) {
PlayerMemory memory = BlocodexSavedData.get(player.level().getServer()).memory().player(player.getUUID().toString());
return query(
memory,
WsmcCodexIndex.loadDefault(),
player.level(),
player.blockPosition(),
request
);
}
static BlocodexStudioPalettePayload query(PlayerMemory memory, WsmcCodexIndex index, BlockGetter level, BlockPos pos, BlocodexStudioPaletteRequestPayload request) {
return queryDiscoveredBlocks(
memory.discoveredBlocks(),
index,
request,
VoxelProjectValidator::isOpaquePaletteBlock,
blockId -> fallbackColor(level, pos, blockId)
);
}
static BlocodexStudioPalettePayload queryDiscoveredBlocks(Set<String> discoveredBlocks, WsmcCodexIndex index, BlocodexStudioPaletteRequestPayload request, Predicate<String> opaqueBuildableBlock, ToIntFunction<String> fallbackColor) {
String query = normalizeQuery(request.query());
List<PaletteCandidate> candidates = discoveredBlocks.stream()
.filter(opaqueBuildableBlock)
.map(blockId -> candidate(blockId, index, fallbackColor, request))
.filter(candidate -> query.isBlank() || matches(candidate, query))
.limit(MAX_ENTRIES)
.toList();
PaletteCandidate selected = candidates.stream()
.min(Comparator.<PaletteCandidate>comparingDouble(candidate -> candidate.distanceTo(request.hue(), request.saturation(), request.lightness()))
.thenComparing(PaletteCandidate::label, String.CASE_INSENSITIVE_ORDER)
.thenComparing(PaletteCandidate::blockId))
.orElse(null);
List<PaletteCandidate> grid = hslGrid(candidates, request);
List<String> blockIds = grid.stream().map(PaletteCandidate::blockId).toList();
List<Integer> colors = grid.stream().map(PaletteCandidate::argb).toList();
List<String> labels = grid.stream().map(PaletteCandidate::label).toList();
String selectedBlockId = selected == null ? "" : selected.blockId();
int selectedColor = selected == null ? FALLBACK_COLOR : selected.argb();
return new BlocodexStudioPalettePayload(
blockIds,
colors,
labels,
selectedBlockId,
selectedColor
);
}
private static PaletteCandidate candidate(String blockId, WsmcCodexIndex index, ToIntFunction<String> fallbackColor, BlocodexStudioPaletteRequestPayload request) {
Optional<WsmcEntry> entry = index.entry(blockId).filter(candidate -> "block".equals(candidate.entryType()));
int color = entry.map(WsmcEntry::primaryArgb).orElseGet(() -> fallbackColor.applyAsInt(blockId));
String label = entry.map(WsmcEntry::displayName).orElseGet(() -> fallbackLabel(blockId));
return new PaletteCandidate(blockId, color, label, entry.orElse(null));
}
private static List<PaletteCandidate> hslGrid(List<PaletteCandidate> candidates, BlocodexStudioPaletteRequestPayload request) {
List<PaletteCandidate> remaining = new java.util.ArrayList<>(candidates);
List<PaletteCandidate> grid = new java.util.ArrayList<>(GRID_SIZE * GRID_SIZE);
for (int row = 0; row < GRID_SIZE && !remaining.isEmpty(); row++) {
double lightness = 1.0D - row / (double) (GRID_SIZE - 1);
for (int col = 0; col < GRID_SIZE && !remaining.isEmpty(); col++) {
double saturation = col / (double) (GRID_SIZE - 1);
PaletteCandidate best = remaining.stream()
.min(Comparator.<PaletteCandidate>comparingDouble(candidate -> candidate.distanceTo(request.hue(), saturation, lightness))
.thenComparing(PaletteCandidate::label, String.CASE_INSENSITIVE_ORDER)
.thenComparing(PaletteCandidate::blockId))
.orElseThrow();
grid.add(best);
remaining.remove(best);
}
}
if (grid.size() < GRID_SIZE * GRID_SIZE) {
candidates.stream()
.sorted(Comparator.<PaletteCandidate>comparingDouble(candidate -> candidate.distanceTo(request.hue(), request.saturation(), request.lightness()))
.thenComparing(PaletteCandidate::label, String.CASE_INSENSITIVE_ORDER)
.thenComparing(PaletteCandidate::blockId))
.filter(candidate -> !grid.contains(candidate))
.limit(GRID_SIZE * GRID_SIZE - grid.size())
.forEach(grid::add);
}
return grid;
}
private static boolean matches(PaletteCandidate candidate, String query) {
return containsQuery(candidate.blockId(), query) || containsQuery(candidate.label(), query);
}
private static int fallbackColor(BlockGetter level, BlockPos pos, String blockId) {
Optional<Block> block = block(blockId);
if (block.isEmpty() || block.get() == Blocks.AIR) {
return FALLBACK_COLOR;
}
Integer named = namedPaletteColor(blockPath(blockId));
if (named != null) {
return named;
}
BlockGetter safeLevel = level == null ? EmptyBlockGetter.INSTANCE : level;
BlockPos safePos = pos == null ? BlockPos.ZERO : pos;
BlockState state = block.get().defaultBlockState();
int color = state.getMapColor(safeLevel, safePos).calculateARGBColor(MapColor.Brightness.NORMAL);
return 0xFF000000 | (color & 0x00FFFFFF);
}
private static String fallbackLabel(String blockId) {
return blockPath(blockId).replace('_', ' ');
}
private static Optional<Block> block(String blockId) {
Identifier id = Identifier.tryParse(blockId);
return id == null ? Optional.empty() : BuiltInRegistries.BLOCK.getOptional(id);
}
private static String normalizeQuery(String query) {
return query == null ? "" : query.strip().toLowerCase(Locale.ROOT);
}
private static boolean containsQuery(String value, String query) {
return value != null && value.toLowerCase(Locale.ROOT).contains(query);
}
private static Integer namedPaletteColor(String path) {
if (path.equals("grass_block")) {
return 0xFF79C05A;
}
if (path.equals("water")) {
return 0xFF3F76E4;
}
if (path.equals("lava")) {
return 0xFFFF5A00;
}
if (path.equals("birch_leaves")) {
return 0xFF80A755;
}
if (path.equals("spruce_leaves")) {
return 0xFF619961;
}
if (path.endsWith("_leaves")) {
return 0xFF48B518;
}
for (Map.Entry<String, Integer> entry : DYE_COLORS.entrySet()) {
String prefix = entry.getKey() + "_";
if (path.startsWith(prefix) && isDyedPaletteMaterial(path)) {
return entry.getValue();
}
}
for (Map.Entry<String, Integer> entry : WOOD_COLORS.entrySet()) {
String wood = entry.getKey();
if (isWoodPaletteMaterial(path, wood)) {
return entry.getValue();
}
}
return null;
}
private static boolean isDyedPaletteMaterial(String path) {
return path.endsWith("_concrete")
|| path.endsWith("_concrete_powder")
|| path.endsWith("_wool")
|| path.endsWith("_terracotta")
|| path.endsWith("_glazed_terracotta")
|| path.endsWith("_stained_glass");
}
private static boolean isWoodPaletteMaterial(String path, String wood) {
return path.equals(wood + "_planks")
|| path.equals(wood + "_log")
|| path.equals(wood + "_wood")
|| path.equals(wood + "_stem")
|| path.equals(wood + "_hyphae")
|| path.equals("stripped_" + wood + "_log")
|| path.equals("stripped_" + wood + "_wood")
|| path.equals("stripped_" + wood + "_stem")
|| path.equals("stripped_" + wood + "_hyphae")
|| path.equals(wood + "_block")
|| path.equals("stripped_" + wood + "_block")
|| path.equals(wood + "_mosaic");
}
private static String blockPath(String blockId) {
int split = blockId.indexOf(':');
return split >= 0 ? blockId.substring(split + 1) : blockId;
}
private static double colorDistance(int argb, double hue, double saturation, double lightness) {
Hsl hsl = rgbToHsl(argb);
double hueDistance = Math.abs(hsl.hue() - hue);
hueDistance = Math.min(hueDistance, 360.0D - hueDistance) / 180.0D;
double saturationDistance = hsl.saturation() - saturation;
double lightnessDistance = hsl.lightness() - lightness;
return hueDistance * hueDistance * 1.4D
+ saturationDistance * saturationDistance
+ lightnessDistance * lightnessDistance * 1.15D;
}
private static Hsl rgbToHsl(int argb) {
double red = ((argb >> 16) & 255) / 255.0D;
double green = ((argb >> 8) & 255) / 255.0D;
double blue = (argb & 255) / 255.0D;
double max = Math.max(red, Math.max(green, blue));
double min = Math.min(red, Math.min(green, blue));
double lightness = (max + min) / 2.0D;
double hue;
double saturation;
if (max == min) {
hue = 0.0D;
saturation = 0.0D;
} else {
double delta = max - min;
saturation = lightness > 0.5D ? delta / (2.0D - max - min) : delta / (max + min);
if (max == red) {
hue = (green - blue) / delta + (green < blue ? 6.0D : 0.0D);
} else if (max == green) {
hue = (blue - red) / delta + 2.0D;
} else {
hue = (red - green) / delta + 4.0D;
}
hue *= 60.0D;
}
return new Hsl(hue, saturation, lightness);
}
private record Hsl(double hue, double saturation, double lightness) {
}
private record PaletteCandidate(String blockId, int argb, String label, WsmcEntry entry) {
private double distanceTo(double hue, double saturation, double lightness) {
return entry == null
? colorDistance(argb, hue, saturation, lightness)
: entry.distanceToHsl(hue, saturation, lightness);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,454 @@
package dev.blocodex.voxel;
import dev.blocodex.memory.VoxelProjectMemory;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Queue;
import java.util.Set;
public final class StudioVoxelEditor {
public static final int DEFAULT_WIDTH = 16;
public static final int DEFAULT_HEIGHT = 16;
public static final int DEFAULT_DEPTH = 16;
public static final String DEFAULT_PROJECT_ID = "studio-project";
public static final String DEFAULT_PROJECT_NAME = "Untitled";
private static final int SHAPE_TOOL_RADIUS = 2;
public enum Tool {
PIPETTE,
BRUSH,
ADD,
REMOVE,
SQUARE,
ROUND,
FILL;
public String label() {
return switch (this) {
case PIPETTE -> "Pipette";
case BRUSH -> "Pinceau";
case ADD -> "Ajouter";
case REMOVE -> "Retirer";
case SQUARE -> "Carre";
case ROUND -> "Rond";
case FILL -> "Remplir";
};
}
}
private VoxelProjectMemory project;
private String selectedBlockId;
private StudioVoxelEditor(VoxelProjectMemory project, String selectedBlockId) {
this.project = normalize(project);
this.selectedBlockId = cleanBlockId(selectedBlockId);
}
public static StudioVoxelEditor blank() {
return new StudioVoxelEditor(blankProject(), null);
}
public static StudioVoxelEditor blank(String id, String name, int width, int height, int depth) {
requirePositiveDimensions(width, height, depth);
return new StudioVoxelEditor(blankProject(id, name, width, height, depth), null);
}
public static StudioVoxelEditor edit(VoxelProjectMemory project) {
return fromProject(project);
}
public static StudioVoxelEditor fromProject(VoxelProjectMemory project) {
return new StudioVoxelEditor(project, null);
}
public static VoxelProjectMemory blankProject() {
return blankProject(DEFAULT_PROJECT_ID, DEFAULT_PROJECT_NAME);
}
public static VoxelProjectMemory blankProject(String id, String name) {
return blankProject(id, name, DEFAULT_WIDTH, DEFAULT_HEIGHT, DEFAULT_DEPTH);
}
public static VoxelProjectMemory blankProject(String id, String name, int width, int height, int depth) {
requirePositiveDimensions(width, height, depth);
return new VoxelProjectMemory(
id,
name,
width,
height,
depth,
List.of(),
Map.of(),
0L,
0L
);
}
public VoxelProjectMemory project() {
return project;
}
public String id() {
return project.id();
}
public String name() {
return project.name();
}
public int width() {
return project.width();
}
public int height() {
return project.height();
}
public int depth() {
return project.depth();
}
public Map<String, String> cells() {
return Collections.unmodifiableMap(new LinkedHashMap<>(project.cells()));
}
public String selectedBlockId() {
return selectedBlockId;
}
public void setName(String name) {
project = new VoxelProjectMemory(
project.id(),
name,
project.width(),
project.height(),
project.depth(),
project.palette(),
project.cells(),
project.createdTick(),
project.updatedTick()
);
}
public StudioVoxelEditor selectBlock(String blockId) {
selectedBlockId = cleanBlockId(blockId);
return this;
}
public StudioVoxelEditor replaceProject(VoxelProjectMemory project) {
this.project = normalize(project);
return this;
}
public StudioVoxelEditor resize(int width, int height, int depth) {
requirePositiveDimensions(width, height, depth);
Map<String, String> cells = new LinkedHashMap<>();
for (Map.Entry<String, String> entry : project.cells().entrySet()) {
CellEntry pos = parseCellKey(entry.getKey(), entry.getValue());
if (pos != null && inBounds(pos.x(), pos.y(), pos.z(), width, height, depth)) {
cells.put(entry.getKey(), entry.getValue());
}
}
return withProject(width, height, depth, cells);
}
public StudioVoxelEditor brush(int x, int y, int z) {
return add(x, y, z, selectedBlockId);
}
public StudioVoxelEditor add(int x, int y, int z, String blockId) {
String cleanBlockId = requireBlockId(blockId);
if (!inProjectBounds(x, y, z)) {
return this;
}
Map<String, String> cells = mutableCells();
cells.put(cellKey(x, y, z), cleanBlockId);
return withCells(cells);
}
public StudioVoxelEditor remove(int x, int y, int z) {
if (!inProjectBounds(x, y, z)) {
return this;
}
Map<String, String> cells = mutableCells();
cells.remove(cellKey(x, y, z));
return withCells(cells);
}
public StudioVoxelEditor fillSquare(int centerX, int y, int centerZ, int radius) {
return fillSquare(centerX - radius, y, centerZ - radius, centerX + radius, centerZ + radius, selectedBlockId);
}
public StudioVoxelEditor fillSquare(int centerX, int y, int centerZ, int radius, String blockId) {
return fillSquare(centerX - radius, y, centerZ - radius, centerX + radius, centerZ + radius, blockId);
}
public StudioVoxelEditor fillSquare(int minX, int y, int minZ, int maxX, int maxZ, String blockId) {
String cleanBlockId = requireBlockId(blockId);
if (y < 0 || y >= project.height()) {
return this;
}
int startX = Math.max(0, Math.min(minX, maxX));
int endX = Math.min(project.width() - 1, Math.max(minX, maxX));
int startZ = Math.max(0, Math.min(minZ, maxZ));
int endZ = Math.min(project.depth() - 1, Math.max(minZ, maxZ));
if (startX > endX || startZ > endZ) {
return this;
}
Map<String, String> cells = mutableCells();
for (int z = startZ; z <= endZ; z++) {
for (int x = startX; x <= endX; x++) {
cells.put(cellKey(x, y, z), cleanBlockId);
}
}
return withCells(cells);
}
public StudioVoxelEditor fillRound(int centerX, int y, int centerZ, int radius) {
return fillRound(centerX, y, centerZ, radius, selectedBlockId);
}
public StudioVoxelEditor fillRound(int centerX, int y, int centerZ, int radius, String blockId) {
String cleanBlockId = requireBlockId(blockId);
if (radius < 0 || y < 0 || y >= project.height()) {
return this;
}
int startX = Math.max(0, centerX - radius);
int endX = Math.min(project.width() - 1, centerX + radius);
int startZ = Math.max(0, centerZ - radius);
int endZ = Math.min(project.depth() - 1, centerZ + radius);
int radiusSquared = radius * radius;
Map<String, String> cells = mutableCells();
for (int z = startZ; z <= endZ; z++) {
int dz = z - centerZ;
for (int x = startX; x <= endX; x++) {
int dx = x - centerX;
if (dx * dx + dz * dz <= radiusSquared) {
cells.put(cellKey(x, y, z), cleanBlockId);
}
}
}
return withCells(cells);
}
public StudioVoxelEditor floodFill2d(int startX, int y, int startZ) {
return floodFill2d(startX, y, startZ, selectedBlockId);
}
public StudioVoxelEditor floodFill2d(int startX, int y, int startZ, String blockId) {
String cleanBlockId = requireBlockId(blockId);
if (!inProjectBounds(startX, y, startZ)) {
return this;
}
String targetBlockId = project.cells().get(cellKey(startX, y, startZ));
if (Objects.equals(targetBlockId, cleanBlockId)) {
return this;
}
Map<String, String> cells = mutableCells();
Queue<LayerPos> queue = new ArrayDeque<>();
Set<LayerPos> visited = new HashSet<>();
queue.add(new LayerPos(startX, startZ));
while (!queue.isEmpty()) {
LayerPos current = queue.remove();
if (!visited.add(current) || !inProjectBounds(current.x(), y, current.z())) {
continue;
}
String currentKey = cellKey(current.x(), y, current.z());
if (!Objects.equals(cells.get(currentKey), targetBlockId)) {
continue;
}
cells.put(currentKey, cleanBlockId);
queue.add(new LayerPos(current.x() + 1, current.z()));
queue.add(new LayerPos(current.x() - 1, current.z()));
queue.add(new LayerPos(current.x(), current.z() + 1));
queue.add(new LayerPos(current.x(), current.z() - 1));
}
return withCells(cells);
}
public String blockAt(int x, int y, int z) {
if (!inProjectBounds(x, y, z)) {
return null;
}
return project.cells().get(cellKey(x, y, z));
}
public void apply(Tool tool, int x, int y, int z, String blockId) {
Objects.requireNonNull(tool, "tool");
switch (tool) {
case PIPETTE -> {
}
case BRUSH, ADD -> add(x, y, z, blockId);
case REMOVE -> remove(x, y, z);
case SQUARE -> fillSquare(x, y, z, SHAPE_TOOL_RADIUS, blockId);
case ROUND -> fillRound(x, y, z, SHAPE_TOOL_RADIUS, blockId);
case FILL -> floodFill2d(x, y, z, blockId);
}
}
public VoxelProjectMemory toProject(long createdTick, long updatedTick) {
VoxelProjectMemory normalized = normalize(project);
return new VoxelProjectMemory(
normalized.id(),
normalized.name(),
normalized.width(),
normalized.height(),
normalized.depth(),
normalized.palette(),
normalized.cells(),
createdTick,
updatedTick
);
}
public VoxelProjectMemory export() {
return normalize(project);
}
public static VoxelProjectMemory export(VoxelProjectMemory project) {
return normalize(project);
}
private StudioVoxelEditor withCells(Map<String, String> cells) {
return withProject(project.width(), project.height(), project.depth(), cells);
}
private StudioVoxelEditor withProject(int width, int height, int depth, Map<String, String> cells) {
VoxelProjectMemory nextProject = new VoxelProjectMemory(
project.id(),
project.name(),
width,
height,
depth,
paletteFromCells(cells),
cells,
project.createdTick(),
project.updatedTick()
);
project = normalize(nextProject);
return this;
}
private Map<String, String> mutableCells() {
return new LinkedHashMap<>(project.cells());
}
private boolean inProjectBounds(int x, int y, int z) {
return inBounds(x, y, z, project.width(), project.height(), project.depth());
}
private static VoxelProjectMemory normalize(VoxelProjectMemory project) {
Objects.requireNonNull(project, "project");
requirePositiveDimensions(project.width(), project.height(), project.depth());
Map<String, String> cells = new LinkedHashMap<>();
normalizedCellEntries(project).forEach(entry -> cells.put(entry.key(), entry.blockId()));
return new VoxelProjectMemory(
project.id(),
project.name(),
project.width(),
project.height(),
project.depth(),
paletteFromCells(cells),
cells,
project.createdTick(),
project.updatedTick()
);
}
private static List<CellEntry> normalizedCellEntries(VoxelProjectMemory project) {
List<CellEntry> entries = new ArrayList<>();
for (Map.Entry<String, String> entry : project.cells().entrySet()) {
CellEntry pos = parseCellKey(entry.getKey(), entry.getValue());
if (pos != null && inBounds(pos.x(), pos.y(), pos.z(), project.width(), project.height(), project.depth())) {
entries.add(pos);
}
}
entries.sort(Comparator.comparingInt(CellEntry::y).thenComparingInt(CellEntry::z).thenComparingInt(CellEntry::x));
return entries;
}
private static List<String> paletteFromCells(Map<String, String> cells) {
List<String> palette = new ArrayList<>();
for (String blockId : cells.values()) {
if (!palette.contains(blockId)) {
palette.add(blockId);
}
}
return palette;
}
private static boolean inBounds(int x, int y, int z, int width, int height, int depth) {
return x >= 0 && x < width && y >= 0 && y < height && z >= 0 && z < depth;
}
private static CellEntry parseCellKey(String key, String blockId) {
String cleanBlockId = cleanBlockId(blockId);
if (key == null || cleanBlockId == null) {
return null;
}
String[] parts = key.split(",", 3);
if (parts.length != 3) {
return null;
}
try {
return new CellEntry(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]), Integer.parseInt(parts[2]), cleanBlockId);
} catch (NumberFormatException exception) {
return null;
}
}
private static String cellKey(int x, int y, int z) {
return x + "," + y + "," + z;
}
private static void requirePositiveDimensions(int width, int height, int depth) {
if (width <= 0 || height <= 0 || depth <= 0) {
throw new IllegalArgumentException("Voxel project dimensions must be positive");
}
}
private static String requireBlockId(String blockId) {
String cleanBlockId = cleanBlockId(blockId);
if (cleanBlockId == null) {
throw new IllegalStateException("A block must be selected before painting");
}
return cleanBlockId;
}
private static String cleanBlockId(String blockId) {
if (blockId == null || blockId.isBlank()) {
return null;
}
return blockId.trim();
}
private record LayerPos(int x, int z) {
}
private record CellEntry(int x, int y, int z, String blockId) {
String key() {
return cellKey(x, y, z);
}
}
}
@@ -0,0 +1,83 @@
package dev.blocodex.voxel;
import dev.blocodex.memory.VoxelProjectMemory;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
public final class VoxelProjectPlacement {
private VoxelProjectPlacement() {
}
public static List<Cell> orderedCells(VoxelProjectMemory project) {
List<Cell> cells = new ArrayList<>();
for (Map.Entry<String, String> entry : project.cells().entrySet()) {
BlockPos pos = parseCell(entry.getKey());
if (pos != null) {
cells.add(new Cell(pos.getX(), pos.getY(), pos.getZ(), entry.getValue()));
}
}
cells.sort(Comparator.comparingInt(Cell::y).thenComparingInt(Cell::z).thenComparingInt(Cell::x));
return cells;
}
public static BlockPos transform(VoxelProjectMemory project, Cell cell, BlockPos anchor, Direction facing) {
Direction horizontal = horizontal(facing);
int centeredX = cell.x() - project.width() / 2;
int centeredZ = cell.z() - project.depth() / 2;
int worldX;
int worldZ;
switch (horizontal) {
case SOUTH -> {
worldX = -centeredX;
worldZ = -centeredZ;
}
case EAST -> {
worldX = -centeredZ;
worldZ = centeredX;
}
case WEST -> {
worldX = centeredZ;
worldZ = -centeredX;
}
default -> {
worldX = centeredX;
worldZ = centeredZ;
}
}
return anchor.offset(worldX, cell.y(), worldZ).immutable();
}
public static BlockPos parseCell(String key) {
if (key == null) {
return null;
}
String[] parts = key.split(",", 3);
if (parts.length != 3) {
return null;
}
try {
return new BlockPos(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]), Integer.parseInt(parts[2]));
} catch (NumberFormatException exception) {
return null;
}
}
public static String cellKey(int x, int y, int z) {
return x + "," + y + "," + z;
}
public static Direction horizontal(Direction facing) {
if (facing == Direction.UP || facing == Direction.DOWN || facing == null) {
return Direction.NORTH;
}
return facing;
}
public record Cell(int x, int y, int z, String blockId) {
}
}
@@ -0,0 +1,88 @@
package dev.blocodex.voxel;
import dev.blocodex.memory.PlayerMemory;
import dev.blocodex.memory.VoxelProjectMemory;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class VoxelProjectRequirements {
private VoxelProjectRequirements() {
}
public static Map<String, Integer> requiredBlockCounts(VoxelProjectMemory project) {
Map<String, Integer> counts = new LinkedHashMap<>();
if (project == null) {
return counts;
}
for (String blockId : project.cells().values()) {
if (blockId == null || blockId.isBlank()) {
continue;
}
counts.merge(blockId, 1, Integer::sum);
}
return sortedByCount(counts);
}
public static List<String> missingRequiredBlocks(VoxelProjectMemory project, PlayerMemory memory) {
Map<String, Integer> required = requiredBlockCounts(project);
List<String> missing = new ArrayList<>();
for (String blockId : required.keySet()) {
if (!memory.discoveredBlocks().contains(blockId)) {
missing.add(blockId);
}
}
return missing;
}
public static String formatCounts(Map<String, Integer> counts, int limit) {
if (counts.isEmpty()) {
return "aucun";
}
List<String> parts = new ArrayList<>();
int index = 0;
for (Map.Entry<String, Integer> entry : counts.entrySet()) {
if (index >= limit) {
parts.add("+" + (counts.size() - limit));
break;
}
parts.add(shortId(entry.getKey()) + " x" + entry.getValue());
index++;
}
return String.join(", ", parts);
}
public static String formatIds(List<String> ids, int limit) {
if (ids.isEmpty()) {
return "aucun";
}
List<String> parts = new ArrayList<>();
for (int i = 0; i < ids.size(); i++) {
if (i >= limit) {
parts.add("+" + (ids.size() - limit));
break;
}
parts.add(shortId(ids.get(i)));
}
return String.join(", ", parts);
}
private static Map<String, Integer> sortedByCount(Map<String, Integer> counts) {
Map<String, Integer> sorted = new LinkedHashMap<>();
counts.entrySet().stream()
.sorted(Comparator.<Map.Entry<String, Integer>>comparingInt(Map.Entry::getValue).reversed().thenComparing(Map.Entry::getKey))
.forEach(entry -> sorted.put(entry.getKey(), entry.getValue()));
return sorted;
}
private static String shortId(String id) {
int split = id.indexOf(':');
return split >= 0 ? id.substring(split + 1) : id;
}
}
@@ -0,0 +1,104 @@
package dev.blocodex.voxel;
import dev.blocodex.memory.VoxelProjectMemory;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.EmptyBlockGetter;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import java.util.HashSet;
import java.util.Set;
public final class VoxelProjectValidator {
public static final int MAX_DIMENSION = 32;
public static final int MAX_CELLS = 8192;
public static final int MAX_PALETTE = 1024;
private VoxelProjectValidator() {
}
public static ValidationResult validate(VoxelProjectMemory project) {
if (project == null) {
return ValidationResult.error("Projet absent.");
}
if (project.id() == null || project.id().isBlank()) {
return ValidationResult.error("Identifiant de projet invalide.");
}
if (!validDimension(project.width()) || !validDimension(project.height()) || !validDimension(project.depth())) {
return ValidationResult.error("Dimensions invalides. Maximum " + MAX_DIMENSION + " par axe.");
}
if (project.cells().size() > MAX_CELLS) {
return ValidationResult.error("Structure trop grande. Maximum " + MAX_CELLS + " voxels.");
}
if (project.palette().size() > MAX_PALETTE) {
return ValidationResult.error("Palette trop grande. Maximum " + MAX_PALETTE + " blocs.");
}
Set<String> palette = new HashSet<>(project.palette());
for (String blockId : palette) {
if (!isBuildableFullBlock(blockId)) {
return ValidationResult.error("Bloc non constructible dans la palette: " + blockId);
}
}
for (var entry : project.cells().entrySet()) {
BlockPos cell = VoxelProjectPlacement.parseCell(entry.getKey());
if (cell == null || cell.getX() < 0 || cell.getY() < 0 || cell.getZ() < 0
|| cell.getX() >= project.width() || cell.getY() >= project.height() || cell.getZ() >= project.depth()) {
return ValidationResult.error("Cellule hors limites: " + entry.getKey());
}
String blockId = entry.getValue();
if (!palette.contains(blockId)) {
return ValidationResult.error("Cellule hors palette: " + blockId);
}
if (!isBuildableFullBlock(blockId)) {
return ValidationResult.error("Bloc non constructible dans une cellule: " + blockId);
}
}
return ValidationResult.ok();
}
public static boolean isBuildableFullBlock(String blockId) {
Identifier id = Identifier.tryParse(blockId);
if (id == null) {
return false;
}
Block block = BuiltInRegistries.BLOCK.getOptional(id).orElse(Blocks.AIR);
if (block == Blocks.AIR || block.asItem() == Items.AIR) {
return false;
}
BlockState state = block.defaultBlockState();
return !state.isAir() && state.isCollisionShapeFullBlock(EmptyBlockGetter.INSTANCE, BlockPos.ZERO);
}
public static boolean isOpaquePaletteBlock(String blockId) {
if (!isBuildableFullBlock(blockId)) {
return false;
}
Identifier id = Identifier.tryParse(blockId);
if (id == null) {
return false;
}
BlockState state = BuiltInRegistries.BLOCK.getOptional(id).orElse(Blocks.AIR).defaultBlockState();
return state.canOcclude() && state.isSolidRender();
}
private static boolean validDimension(int value) {
return value > 0 && value <= MAX_DIMENSION;
}
public record ValidationResult(boolean valid, String message) {
public static ValidationResult ok() {
return new ValidationResult(true, "");
}
public static ValidationResult error(String message) {
return new ValidationResult(false, message == null ? "Projet invalide." : message);
}
}
}
@@ -0,0 +1,19 @@
{
"variants": {
"facing=east": {
"model": "blocodex:block/computer",
"y": 90
},
"facing=north": {
"model": "blocodex:block/computer"
},
"facing=south": {
"model": "blocodex:block/computer",
"y": 180
},
"facing=west": {
"model": "blocodex:block/computer",
"y": 270
}
}
}
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "blocodex:item/blocodex"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "blocodex:block/computer"
}
}
@@ -0,0 +1,12 @@
{
"block.blocodex.computer": "Computer",
"item.blocodex.blocodex": "Blocodex",
"itemGroup.blocodex": "Blocodex",
"key.blocodex.open": "Open Blocodex",
"key.blocodex.construction": "Open construction panel",
"key.blocodex.rotate_blueprint": "Rotate Blocodex blueprint",
"key.blocodex.confirm_blueprint": "Confirm Blocodex blueprint anchor",
"key.blocodex.cancel_blueprint": "Cancel Blocodex blueprint",
"key.blocodex.undo_construction": "Undo Blocodex construction",
"key.blocodex.redo_construction": "Redo Blocodex construction"
}
@@ -0,0 +1,12 @@
{
"block.blocodex.computer": "Computer",
"item.blocodex.blocodex": "Blocodex",
"itemGroup.blocodex": "Blocodex",
"key.blocodex.open": "Ouvrir le Blocodex",
"key.blocodex.construction": "Ouvrir le panneau de construction",
"key.blocodex.rotate_blueprint": "Tourner le blueprint Blocodex",
"key.blocodex.confirm_blueprint": "Valider l'ancre du blueprint Blocodex",
"key.blocodex.cancel_blueprint": "Annuler le blueprint Blocodex",
"key.blocodex.undo_construction": "Annuler la construction Blocodex",
"key.blocodex.redo_construction": "Retablir la construction Blocodex"
}
@@ -0,0 +1,12 @@
{
"parent": "minecraft:block/cube",
"textures": {
"down": "blocodex:block/computer_bottom",
"east": "blocodex:block/computer_left",
"north": "blocodex:block/computer_front",
"particle": "blocodex:block/computer_front",
"south": "blocodex:block/computer_back",
"up": "blocodex:block/computer_top",
"west": "blocodex:block/computer_right"
}
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "minecraft:item/book"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 191 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 657 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 B

@@ -0,0 +1,32 @@
{
"parent": "minecraft:recipes/root",
"criteria": {
"has_redstone": {
"conditions": {
"items": [
{
"items": "minecraft:redstone"
}
]
},
"trigger": "minecraft:inventory_changed"
},
"has_the_recipe": {
"conditions": {
"recipe": "blocodex:computer"
},
"trigger": "minecraft:recipe_unlocked"
}
},
"requirements": [
[
"has_the_recipe",
"has_redstone"
]
],
"rewards": {
"recipes": [
"blocodex:computer"
]
}
}
@@ -0,0 +1,21 @@
{
"type": "minecraft:block",
"pools": [
{
"bonus_rolls": 0.0,
"conditions": [
{
"condition": "minecraft:survives_explosion"
}
],
"entries": [
{
"type": "minecraft:item",
"name": "blocodex:computer"
}
],
"rolls": 1.0
}
],
"random_sequence": "blocodex:blocks/computer"
}
@@ -0,0 +1,16 @@
{
"type": "minecraft:crafting_shaped",
"category": "redstone",
"key": {
"#": "minecraft:iron_ingot",
"R": "minecraft:redstone"
},
"pattern": [
"###",
"#R#",
"###"
],
"result": {
"id": "blocodex:computer"
}
}
@@ -0,0 +1,28 @@
{
"schemaVersion": 1,
"id": "blocodex",
"version": "${version}",
"name": "Blocodex",
"description": "A native Minecraft memory engine with WSMC resources, world observation, player context, palette analysis, and an integrated voxel editor.",
"authors": [
"KOKA99CAB"
],
"contact": {},
"license": "LGPL-3.0-or-later / CC-BY-NC-SA-4.0; see license.txt",
"icon": "assets/blocodex/icon.png",
"environment": "*",
"entrypoints": {
"main": [
"dev.blocodex.Blocodex"
],
"client": [
"dev.blocodex.client.BlocodexClient"
]
},
"depends": {
"fabricloader": ">=0.19.2",
"minecraft": "~26.1.2",
"java": ">=25",
"fabric-api": "*"
}
}
@@ -0,0 +1,41 @@
package dev.blocodex.codex;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class WsmcCodexIndexTest {
@Test
void loadsCompactWsmcSnapshotWithBlocksItemsAndMobs() {
WsmcCodexIndex index = WsmcCodexIndex.loadDefault();
assertTrue(index.size() >= 1800);
assertEquals("1.21.11", index.minecraftVersion());
assertEquals("block", index.entry("minecraft:stone").orElseThrow().entryType());
assertEquals("item", index.entry("minecraft:diamond").orElseThrow().entryType());
assertEquals("mob", index.entry("minecraft:zombie").orElseThrow().entryType());
assertFalse(index.entry("minecraft:stone").orElseThrow().relations().isEmpty());
}
@Test
void exposesStableHslProfilesFromWsmcColors() {
WsmcCodexIndex.WsmcEntry diamond = WsmcCodexIndex.loadDefault().entry("minecraft:diamond").orElseThrow();
WsmcCodexIndex.Hsl hsl = diamond.hsl();
assertTrue(hsl.hue() > 150.0D && hsl.hue() < 200.0D);
assertTrue(hsl.saturation() > 0.3D);
assertTrue(hsl.lightness() > 0.45D && hsl.lightness() < 0.65D);
assertTrue(diamond.distanceToHsl(hsl.hue(), hsl.saturation(), hsl.lightness()) < diamond.distanceToHsl(20.0D, 0.9D, 0.5D));
}
@Test
void resolvesEntityIdsToMobVariantsWhenNeeded() {
WsmcCodexIndex index = WsmcCodexIndex.loadDefault();
assertEquals("minecraft:pig/temperate", index.entityEntry("minecraft:pig").orElseThrow().id());
assertEquals("minecraft:cod/mob", index.entityEntry("minecraft:cod").orElseThrow().id());
assertEquals("minecraft:enderman", index.entityEntry("minecraft:enderman").orElseThrow().id());
}
}
@@ -0,0 +1,328 @@
package dev.blocodex.memory;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.mojang.serialization.JsonOps;
import dev.blocodex.network.BlocodexUiMode;
import dev.blocodex.network.BlocodexUiRequestPayload;
import dev.blocodex.network.BlocodexUiSnapshotPayload;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class BlocodexMemoryTest {
@Test
void roundTripsThroughCodec() {
BlocodexMemory memory = new BlocodexMemory();
ChunkKey key = new ChunkKey("minecraft:overworld", 1, 2);
memory.observeChunk("player-a", key, Map.of("minecraft:stone", 12L, "minecraft:dirt", 3L), Map.of(12, Map.of("minecraft:stone", 4L)), Set.of("minecraft:plains"), -12, 64, 100L);
memory.addChunkTime("player-a", key, 80L);
memory.recordItem("player-a", "minecraft:oak_log");
memory.recordEntity("player-a", "minecraft:cow");
memory.recordBrokenBlock("player-a", "minecraft:stone");
memory.player("player-a").loadLibraryEntry("minecraft:stone");
memory.player("player-a").favoriteLibraryEntry("minecraft:oak_planks");
memory.player("player-a").addResearchLibraryEntry("minecraft:blue_concrete");
memory.player("player-a").recordRecentEvent("item", "minecraft:oak_log", 120L, "minecraft:overworld", 1, 64, 2);
memory.player("player-a").recordStatSample(new PlayerStatSample(1200L, 6400L, 7L, 3L, 2, 1, 4));
memory.player("player-a").saveVoxelProject(new VoxelProjectMemory(
"hut",
"Starter Hut",
5,
4,
6,
List.of("minecraft:oak_planks"),
Map.of("0,0,0", "minecraft:oak_planks"),
100L,
120L
));
JsonElement encoded = BlocodexMemory.CODEC.encodeStart(JsonOps.INSTANCE, memory).result().orElseThrow();
BlocodexMemory decoded = BlocodexMemory.CODEC.parse(JsonOps.INSTANCE, encoded).result().orElseThrow();
assertEquals(1, decoded.players().size());
assertEquals(1, decoded.chunks().size());
assertEquals(12L, decoded.world().blockCounts().get("minecraft:stone"));
assertEquals(4L, decoded.chunk(key).layerBlockCounts(12).get("minecraft:stone"));
assertEquals(80L, decoded.player("player-a").chunkTimeTicks().get(key.asStorageKey()));
assertTrue(decoded.player("player-a").items().contains("minecraft:oak_log"));
assertTrue(decoded.player("player-a").entities().contains("minecraft:cow"));
assertTrue(decoded.player("player-a").loadedLibraryEntries().contains("minecraft:stone"));
assertTrue(decoded.player("player-a").loadedLibraryEntries().contains("minecraft:oak_planks"));
assertTrue(decoded.player("player-a").favoriteLibraryEntries().contains("minecraft:oak_planks"));
assertTrue(decoded.player("player-a").researchLibraryEntries().contains("minecraft:blue_concrete"));
assertEquals("minecraft:oak_log", decoded.player("player-a").recentEvents().getFirst().id());
assertEquals(6400L, decoded.player("player-a").statSamples().getFirst().distanceCm());
assertEquals("Starter Hut", decoded.player("player-a").voxelProjects().get("hut").name());
}
@Test
void repeatedSameSnapshotDoesNotDoubleGlobalCounts() {
BlocodexMemory memory = new BlocodexMemory();
ChunkKey key = new ChunkKey("minecraft:overworld", 0, 0);
Map<String, Long> snapshot = Map.of("minecraft:stone", 10L, "minecraft:dirt", 2L);
assertTrue(memory.observeChunk("player-a", key, snapshot, Map.of(20, snapshot), Set.of("minecraft:plains"), -4, 62, 10L));
assertFalse(memory.observeChunk("player-a", key, snapshot, Map.of(20, snapshot), Set.of("minecraft:plains"), -4, 62, 20L));
assertEquals(10L, memory.world().blockCounts().get("minecraft:stone"));
assertEquals(2L, memory.world().blockCounts().get("minecraft:dirt"));
assertEquals(1L, memory.world().observedChunks());
}
@Test
void repeatedItemAndEntityScansDoNotInflateWorldCounts() {
BlocodexMemory memory = new BlocodexMemory();
assertTrue(memory.recordItem("player-a", "minecraft:diamond"));
assertFalse(memory.recordItem("player-a", "minecraft:diamond"));
assertTrue(memory.recordEntity("player-a", "minecraft:zombie"));
assertFalse(memory.recordEntity("player-a", "minecraft:zombie"));
assertEquals(1L, memory.world().itemCounts().get("minecraft:diamond"));
assertEquals(1L, memory.world().entityCounts().get("minecraft:zombie"));
}
@Test
void changedSnapshotAppliesDelta() {
BlocodexMemory memory = new BlocodexMemory();
ChunkKey key = new ChunkKey("minecraft:overworld", 0, 0);
memory.observeChunk("player-a", key, Map.of("minecraft:stone", 10L, "minecraft:dirt", 2L), Map.of(20, Map.of("minecraft:stone", 10L, "minecraft:dirt", 2L)), Set.of("minecraft:plains"), -4, 62, 10L);
memory.observeChunk("player-a", key, Map.of("minecraft:stone", 8L, "minecraft:oak_log", 3L), Map.of(20, Map.of("minecraft:stone", 8L), 21, Map.of("minecraft:oak_log", 3L)), Set.of("minecraft:forest"), -4, 70, 20L);
assertEquals(8L, memory.world().blockCounts().get("minecraft:stone"));
assertFalse(memory.world().blockCounts().containsKey("minecraft:dirt"));
assertEquals(3L, memory.world().blockCounts().get("minecraft:oak_log"));
assertFalse(memory.world().biomeCounts().containsKey("minecraft:plains"));
assertEquals(1L, memory.world().biomeCounts().get("minecraft:forest"));
}
@Test
void heatTicksAccumulateSeparatelyFromSnapshots() {
BlocodexMemory memory = new BlocodexMemory();
ChunkKey key = new ChunkKey("minecraft:overworld", 4, -2);
memory.observeChunk("player-a", key, Map.of("minecraft:stone", 1L), Map.of(0, Map.of("minecraft:stone", 1L)), Set.of(), 0, 1, 1L);
memory.addChunkTime("player-a", key, 20L);
memory.addChunkTime("player-a", key, 40L);
assertEquals(1L, memory.world().blockCounts().get("minecraft:stone"));
assertEquals(60L, memory.player("player-a").chunkTimeTicks().get(key.asStorageKey()));
}
@Test
void personalMemoryStaysSeparateFromAnonymousWorldLayer() {
BlocodexMemory memory = new BlocodexMemory();
ChunkKey a = new ChunkKey("minecraft:overworld", 0, 0);
ChunkKey b = new ChunkKey("minecraft:overworld", 1, 0);
memory.observeChunk("player-a", a, Map.of("minecraft:stone", 2L), Map.of(0, Map.of("minecraft:stone", 2L)), Set.of(), 0, 10, 1L);
memory.observeChunk("player-b", b, Map.of("minecraft:dirt", 5L), Map.of(0, Map.of("minecraft:dirt", 5L)), Set.of(), 0, 10, 1L);
assertTrue(memory.player("player-a").discoveredBlocks().contains("minecraft:stone"));
assertFalse(memory.player("player-a").discoveredBlocks().contains("minecraft:dirt"));
assertTrue(memory.player("player-b").discoveredBlocks().contains("minecraft:dirt"));
assertEquals(2L, memory.world().observedChunks());
assertEquals(2, memory.world().blockCounts().size());
}
@Test
void clearResetsPlayersChunksAndWorldAggregates() {
BlocodexMemory memory = new BlocodexMemory();
ChunkKey key = new ChunkKey("minecraft:overworld", 0, 0);
memory.observeChunk("player-a", key, Map.of("minecraft:stone", 2L), Map.of(0, Map.of("minecraft:stone", 2L)), Set.of("minecraft:plains"), 0, 10, 1L);
memory.recordItem("player-a", "minecraft:diamond");
memory.recordEntity("player-a", "minecraft:zombie");
memory.clear();
assertTrue(memory.players().isEmpty());
assertTrue(memory.chunks().isEmpty());
assertTrue(memory.world().blockCounts().isEmpty());
assertTrue(memory.world().itemCounts().isEmpty());
assertTrue(memory.world().entityCounts().isEmpty());
assertEquals(0L, memory.world().observedChunks());
}
@Test
void cheatDiscoveryUnlocksPersonalBlocksWithoutWorldCounts() {
BlocodexMemory memory = new BlocodexMemory();
PlayerMemory playerMemory = memory.player("player-a");
int first = playerMemory.discoverBlocks(List.of("minecraft:stone", "minecraft:dirt", "minecraft:stone"));
int second = playerMemory.discoverBlocks(List.of("minecraft:stone", "minecraft:oak_log"));
int items = playerMemory.discoverItems(List.of("minecraft:diamond", "minecraft:diamond"));
int entities = playerMemory.discoverEntities(List.of("minecraft:zombie"));
assertEquals(2, first);
assertEquals(1, second);
assertEquals(1, items);
assertEquals(1, entities);
assertTrue(playerMemory.discoveredBlocks().contains("minecraft:stone"));
assertTrue(playerMemory.discoveredBlocks().contains("minecraft:dirt"));
assertTrue(playerMemory.discoveredBlocks().contains("minecraft:oak_log"));
assertTrue(playerMemory.items().contains("minecraft:diamond"));
assertTrue(playerMemory.entities().contains("minecraft:zombie"));
assertEquals(0, playerMemory.observedBlockCounts().size());
assertEquals(0, memory.world().blockCounts().size());
}
@Test
void chunkMemoryKeepsLayerSnapshots() {
BlocodexMemory memory = new BlocodexMemory();
ChunkKey key = new ChunkKey("minecraft:overworld", 2, 3);
memory.observeChunk(
"player-a",
key,
Map.of("minecraft:stone", 6L, "minecraft:coal_ore", 2L),
Map.of(
-12, Map.of("minecraft:stone", 3L),
-11, Map.of("minecraft:stone", 3L, "minecraft:coal_ore", 2L)
),
Set.of("minecraft:stony_peaks"),
-12,
-11,
40L
);
JsonElement encoded = BlocodexMemory.CODEC.encodeStart(JsonOps.INSTANCE, memory).result().orElseThrow();
BlocodexMemory decoded = BlocodexMemory.CODEC.parse(JsonOps.INSTANCE, encoded).result().orElseThrow();
assertEquals(3L, decoded.chunk(key).layerBlockCounts(-12).get("minecraft:stone"));
assertEquals(2L, decoded.chunk(key).layerBlockCounts(-11).get("minecraft:coal_ore"));
assertEquals(2, decoded.chunk(key).layerBlockCounts().size());
}
@Test
void gridDistributionNormalizesToFixedCellCount() {
List<String> cells = GridDistribution.normalize(Map.of(
"minecraft:stone", 75L,
"minecraft:dirt", 25L
), 81);
long stone = cells.stream().filter("minecraft:stone"::equals).count();
long dirt = cells.stream().filter("minecraft:dirt"::equals).count();
assertEquals(81, cells.size());
assertEquals(61, stone);
assertEquals(20, dirt);
}
@Test
void uiSnapshotPayloadRoundTripsThroughCodec() {
BlocodexUiSnapshotPayload payload = new BlocodexUiSnapshotPayload(
BlocodexUiMode.WORLD.id(),
12,
1,
40,
-64,
319,
List.of("MODE MONDE", "Y: 12"),
java.util.Collections.nCopies(81, "minecraft:stone"),
java.util.Collections.nCopies(81, 0xFF777777),
java.util.Collections.nCopies(81, "stone"),
"minecraft:stone",
0xFF777777,
List.of("minecraft:stone", "minecraft:dirt"),
List.of("0|0|0|0|1|1|2", "6000|120|3|4|2|1|5")
);
JsonElement encoded = BlocodexUiSnapshotPayload.CODEC.encodeStart(JsonOps.INSTANCE, payload).result().orElseThrow();
BlocodexUiSnapshotPayload decoded = BlocodexUiSnapshotPayload.CODEC.parse(JsonOps.INSTANCE, encoded).result().orElseThrow();
assertEquals(BlocodexUiMode.WORLD.id(), decoded.mode());
assertEquals(81, decoded.gridBlockIds().size());
assertEquals("minecraft:stone", decoded.selectedBlockId());
assertEquals(List.of("minecraft:stone", "minecraft:dirt"), decoded.paletteBlockIds());
assertEquals(2, decoded.graphPoints().size());
}
@Test
void uiRequestPayloadRoundTripsQueryThroughCodec() {
BlocodexUiRequestPayload payload = new BlocodexUiRequestPayload(
BlocodexUiMode.COLOR.id(),
180,
0,
12,
"diamond"
);
JsonElement encoded = BlocodexUiRequestPayload.CODEC.encodeStart(JsonOps.INSTANCE, payload).result().orElseThrow();
BlocodexUiRequestPayload decoded = BlocodexUiRequestPayload.CODEC.parse(JsonOps.INSTANCE, encoded).result().orElseThrow();
assertEquals(BlocodexUiMode.COLOR.id(), decoded.mode());
assertEquals("diamond", decoded.query());
}
@Test
void recentEventsStayBoundedNewestFirst() {
PlayerMemory memory = new PlayerMemory();
for (int i = 0; i < 45; i++) {
memory.recordRecentEvent("item", "minecraft:item_" + i, i, "minecraft:overworld", i, 64, i);
}
assertEquals(40, memory.recentEvents().size());
assertEquals("minecraft:item_44", memory.recentEvents().getFirst().id());
assertEquals("minecraft:item_5", memory.recentEvents().getLast().id());
}
@Test
void playerStatSampleRoundTripsThroughCodec() {
PlayerStatSample sample = new PlayerStatSample(6000L, 12345L, 12L, 3L, 8, 4, 16);
JsonElement encoded = PlayerStatSample.CODEC.encodeStart(JsonOps.INSTANCE, sample).result().orElseThrow();
PlayerStatSample decoded = PlayerStatSample.CODEC.parse(JsonOps.INSTANCE, encoded).result().orElseThrow();
assertEquals(sample, decoded);
}
@Test
void oldPlayerMemoryWithoutStatSamplesLoadsEmpty() {
PlayerMemory memory = PlayerMemory.CODEC.parse(JsonOps.INSTANCE, new JsonObject()).result().orElseThrow();
assertTrue(memory.statSamples().isEmpty());
}
@Test
void statSamplesStayBoundedToRollingWindow() {
PlayerMemory memory = new PlayerMemory();
for (int i = 0; i < 300; i++) {
memory.recordStatSample(new PlayerStatSample(i, i * 10L, i, i, i, i, i));
}
assertEquals(PlayerMemory.MAX_STAT_SAMPLES, memory.statSamples().size());
assertEquals(12L, memory.statSamples().getFirst().playTimeTicks());
assertEquals(299L, memory.statSamples().getLast().playTimeTicks());
}
@Test
void voxelProjectsCanBeDeleted() {
PlayerMemory memory = new PlayerMemory();
memory.saveVoxelProject(new VoxelProjectMemory(
"draft",
"Draft",
1,
1,
1,
List.of("minecraft:stone"),
Map.of("0,0,0", "minecraft:stone"),
1L,
1L
));
assertTrue(memory.deleteVoxelProject("draft"));
assertFalse(memory.voxelProjects().containsKey("draft"));
assertFalse(memory.deleteVoxelProject("draft"));
}
}
@@ -0,0 +1,94 @@
package dev.blocodex.server;
import dev.blocodex.codex.WsmcCodexIndex;
import dev.blocodex.memory.PlayerMemory;
import dev.blocodex.network.BlocodexStudioPalettePayload;
import dev.blocodex.network.BlocodexStudioPaletteRequestPayload;
import org.junit.jupiter.api.Test;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class BlocodexStudioPalettesTest {
@Test
void returnsOnlyDiscoveredOpaqueBuildableBlocks() {
PlayerMemory memory = new PlayerMemory();
memory.discoverBlocks(List.of(
"minecraft:stone",
"minecraft:glass",
"minecraft:water",
"minecraft:oak_door",
"minecraft:blue_concrete"
));
BlocodexStudioPalettePayload payload = query(memory, "", Set.of("minecraft:stone", "minecraft:blue_concrete"));
List<String> blockIds = payload.blockIds();
assertTrue(blockIds.contains("minecraft:stone"));
assertTrue(blockIds.contains("minecraft:blue_concrete"));
assertFalse(blockIds.contains("minecraft:glass"));
assertFalse(blockIds.contains("minecraft:water"));
assertFalse(blockIds.contains("minecraft:oak_door"));
assertFalse(blockIds.contains("minecraft:red_concrete"));
assertEquals(5, memory.discoveredBlocks().size());
}
@Test
void filtersByTextQueryAgainstIdsAndLabels() {
PlayerMemory memory = new PlayerMemory();
memory.discoverBlocks(List.of("minecraft:stone", "minecraft:blue_concrete", "minecraft:red_concrete"));
BlocodexStudioPalettePayload payload = query(memory, "Blue", memory.discoveredBlocks());
assertEquals(List.of("minecraft:blue_concrete"), payload.blockIds());
assertEquals("minecraft:blue_concrete", payload.selectedBlockId());
assertEquals(payload.colors().getFirst(), payload.selectedColor());
assertEquals("Blue Concrete", payload.labels().getFirst());
}
@Test
void sortsByWsmcHslDistanceWhenEntriesExist() {
PlayerMemory memory = new PlayerMemory();
memory.discoverBlocks(List.of("minecraft:red_concrete", "minecraft:blue_concrete", "minecraft:lime_concrete"));
BlocodexStudioPalettePayload payload = BlocodexStudioPalettes.queryDiscoveredBlocks(
memory.discoveredBlocks(),
WsmcCodexIndex.loadDefault(),
new BlocodexStudioPaletteRequestPayload("", 240, 0.7D, 0.45D),
blockId -> true,
blockId -> 0xFF777777
);
assertEquals("minecraft:blue_concrete", payload.selectedBlockId());
}
@Test
void requestPayloadSanitizesQueryAndHslTargets() {
BlocodexStudioPaletteRequestPayload payload = new BlocodexStudioPaletteRequestPayload(
" " + "x".repeat(80) + "\n",
-30,
2.0D,
Double.NaN
);
assertEquals(64, payload.query().length());
assertEquals(330, payload.hue());
assertEquals(1.0D, payload.saturation());
assertEquals(0.0D, payload.lightness());
}
private static BlocodexStudioPalettePayload query(PlayerMemory memory, String textQuery, Set<String> opaqueBuildableBlocks) {
return BlocodexStudioPalettes.queryDiscoveredBlocks(
new LinkedHashSet<>(memory.discoveredBlocks()),
WsmcCodexIndex.loadDefault(),
new BlocodexStudioPaletteRequestPayload(textQuery, 0, 0.0D, 0.5D),
opaqueBuildableBlocks::contains,
blockId -> 0xFF777777
);
}
}
@@ -0,0 +1,244 @@
package dev.blocodex.server;
import dev.blocodex.codex.WsmcCodexIndex;
import dev.blocodex.memory.PlayerMemory;
import dev.blocodex.memory.VoxelProjectMemory;
import net.minecraft.core.BlockPos;
import org.junit.jupiter.api.Test;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class BlocodexUiSnapshotsTest {
@Test
void chunkLayerLinesShowPercentagesThenCounts() {
Map<String, Long> layer = Map.of(
"minecraft:stone", 42L,
"minecraft:deepslate", 21L,
"minecraft:diamond_ore", 7L
);
List<String> percentages = BlocodexUiSnapshots.chunkLayerLines(-26, layer, 0);
List<String> counts = BlocodexUiSnapshots.chunkLayerLines(-26, layer, 1);
assertEquals("TITLE:Y -26", percentages.getFirst());
assertEquals("stone 60%", percentages.get(1));
assertEquals("deepslate 30%", percentages.get(2));
assertEquals("diamond_ore 10%", percentages.get(3));
assertEquals("Total: 70", counts.get(1));
assertEquals("stone: 42", counts.get(2));
}
@Test
void statsLinesKeepRequestedInformationOrder() {
List<String> lines = BlocodexUiSnapshots.statsLines(
"New World",
new BlockPos(12, -26, 44),
"overworld",
30,
2400,
8,
17,
"123 m",
"1h 5m",
4,
3
);
assertEquals("TITLE:New World", lines.get(0));
assertEquals("XYZ: 12 -26 44", lines.get(1));
assertEquals("Dim: overworld", lines.get(2));
assertEquals("Blocs vus: 30", lines.get(3));
assertEquals("Zoom: x3 / rayon 12", lines.get(10));
}
@Test
void colorQueryMatchesWsmcNameIdTypeCategoryOrFamily() {
WsmcCodexIndex index = WsmcCodexIndex.loadDefault();
assertTrue(BlocodexUiSnapshots.entryMatchesQuery(index.entry("minecraft:diamond").orElseThrow(), "diamond"));
assertTrue(BlocodexUiSnapshots.entryMatchesQuery(index.entry("minecraft:zombie").orElseThrow(), "mob"));
assertTrue(BlocodexUiSnapshots.entryMatchesQuery(index.entry("minecraft:stone").orElseThrow(), "stone"));
}
@Test
void hybridColorGridReducesAdjacencyWhileStayingWithinDiscoveries() {
WsmcCodexIndex index = WsmcCodexIndex.loadDefault();
List<WsmcCodexIndex.WsmcEntry> entries = List.of(
index.entry("minecraft:diamond").orElseThrow(),
index.entry("minecraft:diamond_ore").orElseThrow(),
index.entry("minecraft:lapis_block").orElseThrow(),
index.entry("minecraft:blue_concrete").orElseThrow(),
index.entry("minecraft:cyan_concrete").orElseThrow(),
index.entry("minecraft:water").orElseThrow(),
index.entry("minecraft:packed_ice").orElseThrow()
);
Set<String> allowed = new LinkedHashSet<>(entries.stream().map(WsmcCodexIndex.WsmcEntry::id).toList());
List<String> ids = BlocodexUiSnapshots.colorGridIdsForTesting(entries, 190);
assertEquals(81, ids.size());
assertTrue(ids.stream().allMatch(allowed::contains));
assertTrue(adjacentDuplicates(ids) < 80);
}
@Test
void profileLinesExposeVanillaPcStatsAndProgressionSection() {
PlayerMemory memory = new PlayerMemory();
memory.discoverBlocks(List.of("minecraft:stone", "minecraft:dirt"));
memory.discoverItems(List.of("minecraft:diamond"));
memory.discoverEntities(List.of("minecraft:zombie"));
List<String> lines = BlocodexUiSnapshots.profileLines(
"BlocodexDev",
"New World",
"overworld",
new BlockPos(1, 64, 2),
memory,
"50 m",
"0h 4m",
3,
7
);
assertTrue(lines.contains("Temps de jeu\t0h 4m"));
assertTrue(lines.contains("Blocs decouverts\t2"));
assertTrue(lines.contains("SECTION:Progression"));
assertTrue(lines.stream().noneMatch(line -> line.startsWith("SECTION:Activite recente")));
assertTrue(lines.stream().noneMatch(line -> line.contains("Bloc casse")));
}
@Test
void worldLinesExposeMapMode() {
List<String> lines = BlocodexUiSnapshots.worldLines(
"New World",
new dev.blocodex.memory.WorldMemory(),
new PlayerMemory(),
BlockPos.ZERO,
"overworld",
"0 m",
"0h 0m",
0L,
0L,
true,
2
).lines();
assertTrue(lines.contains("Vue\tBiomes"));
assertTrue(lines.contains("Carte\tChargee"));
}
@Test
void libraryLinesIncludeSelectedDetailAndEntries() {
List<BlocodexUiSnapshots.LibraryEntry> entries = List.of(
new BlocodexUiSnapshots.LibraryEntry("minecraft:dirt", "Dirt", "block", "Earthy.", 0xFF8A6B45, "brown", 30.0D, 0.4D),
new BlocodexUiSnapshots.LibraryEntry("minecraft:stone", "Stone", "block", "Rock.", 0xFF777777, "neutral", 0.0D, 0.5D)
);
List<String> lines = BlocodexUiSnapshots.libraryLines(entries, 1, "stone", false);
assertTrue(lines.contains("Recherche\tstone"));
assertTrue(lines.contains("Nom\tStone"));
assertTrue(lines.contains("DESC:Rock."));
assertTrue(lines.contains("Etat\tDecouvert"));
assertTrue(lines.stream().anyMatch(line -> line.equals("ENTRY:1|Stone|Bloc|minecraft:stone")));
}
@Test
void creationLinesRenderEmptyAndSavedProjects() {
List<String> empty = BlocodexUiSnapshots.creationLines(List.of());
assertTrue(empty.contains("Projets sauvegardes\t0"));
List<String> saved = BlocodexUiSnapshots.creationLines(List.of(new VoxelProjectMemory(
"tower",
"Tower",
3,
9,
3,
List.of("minecraft:stone"),
Collections.emptyMap(),
10L,
20L
)));
assertTrue(saved.contains("Projets sauvegardes\t1"));
assertTrue(saved.stream().anyMatch(line -> line.equals("ENTRY:0|Tower|3x9x3|tower")));
}
@Test
void creationLinesShowRequiredAndMissingBlocks() {
PlayerMemory memory = new PlayerMemory();
memory.discoverBlocks(List.of("minecraft:stone"));
List<String> lines = BlocodexUiSnapshots.creationLines(List.of(new VoxelProjectMemory(
"mixed",
"Mixed",
2,
1,
1,
List.of("minecraft:stone", "minecraft:gray_concrete"),
Map.of(
"0,0,0", "minecraft:stone",
"1,0,0", "minecraft:gray_concrete"
),
10L,
20L
)), 0, memory);
assertTrue(lines.contains("Blocs requis\t2"));
assertTrue(lines.contains("A rechercher\t1"));
assertTrue(lines.contains("Manquants\tgray_concrete"));
}
@Test
void creationLinesKeepDistinctEntryIndexes() {
List<String> saved = BlocodexUiSnapshots.creationLines(List.of(
new VoxelProjectMemory(
"hut",
"Hut",
4,
3,
5,
List.of("minecraft:oak_planks"),
Collections.emptyMap(),
10L,
20L
),
new VoxelProjectMemory(
"tower",
"Tower",
3,
9,
3,
List.of("minecraft:stone"),
Collections.emptyMap(),
10L,
20L
)
));
assertTrue(saved.stream().anyMatch(line -> line.equals("ENTRY:0|Hut|4x3x5|hut")));
assertTrue(saved.stream().anyMatch(line -> line.equals("ENTRY:1|Tower|3x9x3|tower")));
}
private static int adjacentDuplicates(List<String> ids) {
int duplicates = 0;
for (int row = 0; row < 9; row++) {
for (int col = 0; col < 9; col++) {
String id = ids.get(row * 9 + col);
if (col > 0 && id.equals(ids.get(row * 9 + col - 1))) {
duplicates++;
}
if (row > 0 && id.equals(ids.get((row - 1) * 9 + col))) {
duplicates++;
}
}
}
return duplicates;
}
}
@@ -0,0 +1,288 @@
package dev.blocodex.voxel;
import dev.blocodex.memory.VoxelProjectMemory;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
class StudioVoxelEditorTest {
@Test
void createsDefaultBlankProject() {
VoxelProjectMemory project = StudioVoxelEditor.blankProject();
assertEquals(16, project.width());
assertEquals(16, project.height());
assertEquals(16, project.depth());
assertEquals(List.of(), project.palette());
assertEquals(Map.of(), project.cells());
}
@Test
void exposesClientFacingProjectStateAndExport() {
StudioVoxelEditor editor = StudioVoxelEditor.blank("castle", "Castle", 8, 6, 7);
editor.setName("Tower");
editor.apply(StudioVoxelEditor.Tool.BRUSH, 1, 2, 3, "minecraft:stone");
assertEquals("castle", editor.id());
assertEquals("Tower", editor.name());
assertEquals(8, editor.width());
assertEquals(6, editor.height());
assertEquals(7, editor.depth());
assertEquals(Map.of("1,2,3", "minecraft:stone"), editor.cells());
assertEquals("minecraft:stone", editor.blockAt(1, 2, 3));
assertNull(editor.blockAt(7, 5, 6));
assertNull(editor.blockAt(8, 5, 6));
VoxelProjectMemory exported = editor.toProject(12L, 34L);
assertEquals("castle", exported.id());
assertEquals("Tower", exported.name());
assertEquals(List.of("minecraft:stone"), exported.palette());
assertEquals(12L, exported.createdTick());
assertEquals(34L, exported.updatedTick());
}
@Test
void fromProjectNormalizesInvalidCellsAndPalette() {
VoxelProjectMemory source = new VoxelProjectMemory(
"source",
"Source",
2,
2,
2,
List.of("minecraft:unused"),
Map.of(
"0,0,0", "minecraft:stone",
"3,0,0", "minecraft:dirt",
"1,1,1", " "
),
1L,
2L
);
VoxelProjectMemory project = StudioVoxelEditor.fromProject(source).toProject(3L, 4L);
assertEquals(Map.of("0,0,0", "minecraft:stone"), project.cells());
assertEquals(List.of("minecraft:stone"), project.palette());
assertEquals(3L, project.createdTick());
assertEquals(4L, project.updatedTick());
}
@Test
void resizePreservesOnlyCellsInsideNewBounds() {
StudioVoxelEditor editor = StudioVoxelEditor.edit(project(4, 3, 4, Map.of(
"0,0,0", "minecraft:stone",
"2,1,2", "minecraft:dirt",
"3,2,3", "minecraft:oak_planks"
)));
VoxelProjectMemory resized = editor.resize(3, 2, 3).export();
assertEquals(3, resized.width());
assertEquals(2, resized.height());
assertEquals(3, resized.depth());
assertEquals(Map.of(
"0,0,0", "minecraft:stone",
"2,1,2", "minecraft:dirt"
), resized.cells());
assertEquals(List.of("minecraft:stone", "minecraft:dirt"), resized.palette());
}
@Test
void selectedBlockBrushAddsAndRemoveClearsCells() {
StudioVoxelEditor editor = StudioVoxelEditor.blank()
.selectBlock("minecraft:stone")
.brush(1, 2, 3)
.brush(99, 2, 3)
.remove(1, 2, 3);
assertEquals(Map.of(), editor.export().cells());
assertThrows(IllegalStateException.class, () -> StudioVoxelEditor.blank().brush(0, 0, 0));
}
@Test
void applySupportsAddRemoveSquareRoundAndFillTools() {
StudioVoxelEditor editor = StudioVoxelEditor.blank("tools", "Tools", 5, 3, 5);
editor.apply(StudioVoxelEditor.Tool.ADD, 0, 2, 0, "minecraft:stone");
editor.apply(StudioVoxelEditor.Tool.SQUARE, 2, 1, 2, "minecraft:dirt");
editor.apply(StudioVoxelEditor.Tool.ROUND, 4, 1, 4, "minecraft:gold_block");
editor.apply(StudioVoxelEditor.Tool.REMOVE, 2, 1, 2, "minecraft:ignored");
editor.apply(StudioVoxelEditor.Tool.FILL, 0, 0, 0, "minecraft:oak_planks");
assertEquals("minecraft:stone", editor.blockAt(0, 2, 0));
assertNull(editor.blockAt(2, 1, 2));
assertEquals("minecraft:dirt", editor.blockAt(1, 1, 1));
assertEquals("minecraft:gold_block", editor.blockAt(4, 1, 4));
assertEquals("minecraft:gold_block", editor.blockAt(3, 1, 3));
assertEquals("minecraft:gold_block", editor.blockAt(3, 1, 4));
assertEquals("minecraft:oak_planks", editor.blockAt(4, 0, 4));
}
@Test
void addBrushAndPipetteDoNotPaintAreaFootprints() {
StudioVoxelEditor editor = StudioVoxelEditor.blank("tools", "Tools", 7, 3, 7);
editor.apply(StudioVoxelEditor.Tool.ADD, 2, 1, 2, "minecraft:stone");
editor.apply(StudioVoxelEditor.Tool.BRUSH, 4, 1, 4, "minecraft:dirt");
editor.apply(StudioVoxelEditor.Tool.PIPETTE, 3, 1, 3, "minecraft:gold_block");
assertEquals(2, editor.export().cells().size());
assertEquals("minecraft:stone", editor.blockAt(2, 1, 2));
assertEquals("minecraft:dirt", editor.blockAt(4, 1, 4));
assertNull(editor.blockAt(3, 1, 3));
}
@Test
void applySquarePaintsRadiusTwoArea() {
StudioVoxelEditor editor = StudioVoxelEditor.blank("tools", "Tools", 7, 3, 7);
editor.apply(StudioVoxelEditor.Tool.SQUARE, 3, 1, 3, "minecraft:stone");
assertEquals(25, editor.export().cells().size());
assertEquals("minecraft:stone", editor.blockAt(1, 1, 1));
assertEquals("minecraft:stone", editor.blockAt(5, 1, 5));
assertEquals("minecraft:stone", editor.blockAt(3, 1, 3));
assertNull(editor.blockAt(0, 1, 3));
assertNull(editor.blockAt(3, 2, 3));
}
@Test
void applyRoundPaintsRadiusTwoDisk() {
StudioVoxelEditor editor = StudioVoxelEditor.blank("tools", "Tools", 7, 3, 7);
editor.apply(StudioVoxelEditor.Tool.ROUND, 3, 1, 3, "minecraft:stone");
assertEquals(13, editor.export().cells().size());
assertEquals("minecraft:stone", editor.blockAt(3, 1, 3));
assertEquals("minecraft:stone", editor.blockAt(1, 1, 3));
assertEquals("minecraft:stone", editor.blockAt(5, 1, 3));
assertEquals("minecraft:stone", editor.blockAt(3, 1, 1));
assertEquals("minecraft:stone", editor.blockAt(3, 1, 5));
assertNull(editor.blockAt(1, 1, 1));
assertNull(editor.blockAt(3, 2, 3));
}
@Test
void squareFillPaintsOneLayerWithinBounds() {
VoxelProjectMemory project = StudioVoxelEditor.blank()
.selectBlock("minecraft:stone")
.fillSquare(0, 2, 0, 1)
.export();
assertEquals(4, project.cells().size());
assertEquals("minecraft:stone", project.cells().get("0,2,0"));
assertEquals("minecraft:stone", project.cells().get("1,2,0"));
assertEquals("minecraft:stone", project.cells().get("0,2,1"));
assertEquals("minecraft:stone", project.cells().get("1,2,1"));
assertFalse(project.cells().containsKey("0,1,0"));
}
@Test
void roundFillPaintsOnlyCellsInsideRadiusOnOneLayer() {
VoxelProjectMemory project = StudioVoxelEditor.blank()
.selectBlock("minecraft:stone")
.fillRound(2, 4, 2, 1)
.export();
assertEquals(5, project.cells().size());
assertEquals("minecraft:stone", project.cells().get("2,4,2"));
assertEquals("minecraft:stone", project.cells().get("1,4,2"));
assertEquals("minecraft:stone", project.cells().get("3,4,2"));
assertEquals("minecraft:stone", project.cells().get("2,4,1"));
assertEquals("minecraft:stone", project.cells().get("2,4,3"));
assertFalse(project.cells().containsKey("1,4,1"));
assertFalse(project.cells().containsKey("2,5,2"));
}
@Test
void floodFill2dReplacesBoundedConnectedAreaOnOneLayer() {
VoxelProjectMemory source = project(5, 3, 5, Map.of(
"0,1,0", "minecraft:stone",
"1,1,0", "minecraft:stone",
"0,1,1", "minecraft:stone",
"3,1,3", "minecraft:stone",
"1,2,0", "minecraft:stone",
"1,1,1", "minecraft:dirt"
));
VoxelProjectMemory filled = StudioVoxelEditor.edit(source)
.selectBlock("minecraft:gold_block")
.floodFill2d(0, 1, 0)
.export();
assertEquals("minecraft:gold_block", filled.cells().get("0,1,0"));
assertEquals("minecraft:gold_block", filled.cells().get("1,1,0"));
assertEquals("minecraft:gold_block", filled.cells().get("0,1,1"));
assertEquals("minecraft:stone", filled.cells().get("3,1,3"));
assertEquals("minecraft:stone", filled.cells().get("1,2,0"));
assertEquals("minecraft:dirt", filled.cells().get("1,1,1"));
}
@Test
void floodFill2dCanFillBlankBoundedArea() {
VoxelProjectMemory source = project(3, 2, 3, Map.of(
"1,0,1", "minecraft:stone"
));
VoxelProjectMemory filled = StudioVoxelEditor.edit(source)
.floodFill2d(0, 0, 0, "minecraft:dirt")
.export();
assertEquals(9, filled.cells().size());
assertEquals("minecraft:stone", filled.cells().get("1,0,1"));
assertEquals("minecraft:dirt", filled.cells().get("2,0,2"));
assertFalse(filled.cells().containsKey("0,1,0"));
}
@Test
void exportNormalizesCellsAndRecalculatesPalette() {
VoxelProjectMemory source = new VoxelProjectMemory(
"messy",
"Messy",
3,
2,
3,
List.of("minecraft:unused"),
Map.of(
"2,0,0", "minecraft:stone",
"0,0,0", "minecraft:dirt",
"99,0,0", "minecraft:gold_block",
"1,0,0", " ",
"bad", "minecraft:oak_planks"
),
4L,
5L
);
VoxelProjectMemory exported = StudioVoxelEditor.export(source);
assertEquals(Map.of(
"0,0,0", "minecraft:dirt",
"2,0,0", "minecraft:stone"
), exported.cells());
assertEquals(List.of("minecraft:dirt", "minecraft:stone"), exported.palette());
assertEquals(4L, exported.createdTick());
assertEquals(5L, exported.updatedTick());
}
private static VoxelProjectMemory project(int width, int height, int depth, Map<String, String> cells) {
return new VoxelProjectMemory(
"test",
"Test",
width,
height,
depth,
List.of(),
cells,
0L,
0L
);
}
}
@@ -0,0 +1,72 @@
package dev.blocodex.voxel;
import dev.blocodex.memory.VoxelProjectMemory;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
class VoxelProjectPlacementTest {
@Test
void orderedCellsSortBottomToTopThenRows() {
VoxelProjectMemory project = project(Map.of(
"2,1,1", "minecraft:stone",
"0,0,1", "minecraft:dirt",
"1,0,0", "minecraft:oak_planks"
));
List<VoxelProjectPlacement.Cell> cells = VoxelProjectPlacement.orderedCells(project);
assertEquals(VoxelProjectPlacement.cellKey(1, 0, 0), VoxelProjectPlacement.cellKey(cells.get(0).x(), cells.get(0).y(), cells.get(0).z()));
assertEquals(VoxelProjectPlacement.cellKey(0, 0, 1), VoxelProjectPlacement.cellKey(cells.get(1).x(), cells.get(1).y(), cells.get(1).z()));
assertEquals(VoxelProjectPlacement.cellKey(2, 1, 1), VoxelProjectPlacement.cellKey(cells.get(2).x(), cells.get(2).y(), cells.get(2).z()));
}
@Test
void transformCentersAndRotatesAroundAnchor() {
VoxelProjectMemory project = project(Map.of());
BlockPos anchor = new BlockPos(10, 64, 20);
VoxelProjectPlacement.Cell northWestBottom = new VoxelProjectPlacement.Cell(0, 0, 0, "minecraft:stone");
VoxelProjectPlacement.Cell southEastTop = new VoxelProjectPlacement.Cell(2, 1, 1, "minecraft:stone");
assertEquals(new BlockPos(9, 64, 19), VoxelProjectPlacement.transform(project, northWestBottom, anchor, Direction.NORTH));
assertEquals(new BlockPos(11, 65, 20), VoxelProjectPlacement.transform(project, southEastTop, anchor, Direction.NORTH));
assertEquals(new BlockPos(11, 64, 19), VoxelProjectPlacement.transform(project, northWestBottom, anchor, Direction.EAST));
assertEquals(new BlockPos(10, 65, 21), VoxelProjectPlacement.transform(project, southEastTop, anchor, Direction.EAST));
}
@Test
void transformUsesLowerHalfAsAnchorForEvenFootprints() {
VoxelProjectMemory project = project(16, 1, 16, Map.of());
BlockPos anchor = new BlockPos(0, 64, 0);
VoxelProjectPlacement.Cell first = new VoxelProjectPlacement.Cell(0, 0, 0, "minecraft:stone");
VoxelProjectPlacement.Cell last = new VoxelProjectPlacement.Cell(15, 0, 15, "minecraft:stone");
assertEquals(new BlockPos(-8, 64, -8), VoxelProjectPlacement.transform(project, first, anchor, Direction.NORTH));
assertEquals(new BlockPos(7, 64, 7), VoxelProjectPlacement.transform(project, last, anchor, Direction.NORTH));
assertEquals(new BlockPos(8, 64, -8), VoxelProjectPlacement.transform(project, first, anchor, Direction.EAST));
assertEquals(new BlockPos(-7, 64, 7), VoxelProjectPlacement.transform(project, last, anchor, Direction.EAST));
}
private static VoxelProjectMemory project(Map<String, String> cells) {
return project(3, 2, 2, cells);
}
private static VoxelProjectMemory project(int width, int height, int depth, Map<String, String> cells) {
return new VoxelProjectMemory(
"test",
"Test",
width,
height,
depth,
List.of("minecraft:stone", "minecraft:dirt", "minecraft:oak_planks"),
cells,
0L,
0L
);
}
}
@@ -0,0 +1,36 @@
package dev.blocodex.voxel;
import dev.blocodex.memory.PlayerMemory;
import dev.blocodex.memory.VoxelProjectMemory;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
class VoxelProjectRequirementsTest {
@Test
void countsRequiredBlocksAndReportsOnlyUndiscoveredBlocks() {
VoxelProjectMemory project = new VoxelProjectMemory(
"stone-giant",
"Stone Giant",
2,
2,
1,
List.of("minecraft:stone", "minecraft:gray_concrete"),
Map.of(
"0,0,0", "minecraft:stone",
"1,0,0", "minecraft:stone",
"0,1,0", "minecraft:gray_concrete"
),
0L,
0L
);
PlayerMemory memory = new PlayerMemory();
memory.discoverBlocks(List.of("minecraft:stone"));
assertEquals(Map.of("minecraft:stone", 2, "minecraft:gray_concrete", 1), VoxelProjectRequirements.requiredBlockCounts(project));
assertEquals(List.of("minecraft:gray_concrete"), VoxelProjectRequirements.missingRequiredBlocks(project, memory));
}
}
@@ -0,0 +1,203 @@
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public final class GenerateBlockTextures {
private static final Path OUT = Path.of("src/main/resources/assets/blocodex/textures/block");
private static final int BODY = 0xFFE0D7C6;
private static final int BODY_MID = 0xFFCABFAC;
private static final int BODY_DARK = 0xFF80776A;
private static final int BODY_LIGHT = 0xFFF8F0DD;
private static final int OUTLINE = 0xFF2A2723;
private static final int BLACK = 0xFF050706;
private static final int SCREEN = 0xFF0B2A25;
private static final int SCREEN_GLOW = 0xFF5BFFB5;
private static final int SLOT = 0xFF191611;
private static final int RED = 0xFFC8403C;
private static final int GREEN = 0xFF5DC462;
private static final int YELLOW = 0xFFE3C34B;
private static final int WHITE = 0xFFEAE3D5;
private static final int METAL = 0xFF9FA5A2;
private GenerateBlockTextures() {
}
public static void main(String[] args) throws Exception {
Files.createDirectories(OUT);
write("computer_front.png", front());
write("computer_right.png", right());
write("computer_left.png", left());
write("computer_back.png", back());
write("computer_top.png", top());
write("computer_bottom.png", bottom());
}
private static BufferedImage front() {
BufferedImage image = base();
rect(image, 2, 2, 12, 10, OUTLINE);
rect(image, 3, 3, 11, 9, BLACK);
rect(image, 4, 4, 10, 8, SCREEN);
set(image, 4, 4, 0xFF17463C);
set(image, 9, 7, SCREEN_GLOW);
set(image, 8, 7, SCREEN_GLOW);
set(image, 5, 5, 0xFF2E836D);
rect(image, 2, 11, 10, 13, BODY_LIGHT);
line(image, 3, 12, 9, 12, SLOT);
set(image, 8, 11, 0xFF6DA6C8);
set(image, 9, 11, 0xFF3B657D);
rect(image, 12, 11, 14, 13, GREEN);
set(image, 12, 11, 0xFFA9F1A7);
rect(image, 14, 11, 15, 13, RED);
line(image, 3, 14, 7, 14, SLOT);
line(image, 9, 14, 13, 14, SLOT);
set(image, 2, 14, BODY_DARK);
return image;
}
private static BufferedImage right() {
BufferedImage image = base();
rect(image, 2, 2, 14, 14, BODY_MID);
line(image, 3, 3, 13, 3, BODY_LIGHT);
line(image, 13, 3, 13, 13, BODY_DARK);
line(image, 3, 5, 7, 5, BODY_DARK);
line(image, 3, 8, 7, 8, BODY_DARK);
line(image, 3, 11, 7, 11, BODY_DARK);
rca(image, 10, 4, YELLOW);
rca(image, 10, 8, WHITE);
rca(image, 10, 12, RED);
screw(image, 3, 3);
screw(image, 3, 12);
return image;
}
private static BufferedImage left() {
BufferedImage image = base();
rect(image, 2, 2, 14, 14, BODY_MID);
line(image, 3, 3, 13, 3, BODY_LIGHT);
line(image, 13, 3, 13, 13, BODY_DARK);
line(image, 3, 5, 12, 5, SLOT);
line(image, 3, 7, 12, 7, SLOT);
line(image, 3, 9, 12, 9, SLOT);
line(image, 3, 11, 12, 11, SLOT);
set(image, 12, 5, BODY_LIGHT);
set(image, 12, 7, BODY_LIGHT);
set(image, 12, 9, BODY_LIGHT);
set(image, 12, 11, BODY_LIGHT);
screw(image, 3, 3);
screw(image, 12, 12);
return image;
}
private static BufferedImage back() {
BufferedImage image = base();
rect(image, 2, 2, 14, 14, BODY_MID);
line(image, 3, 3, 13, 3, BODY_LIGHT);
rect(image, 4, 4, 12, 10, OUTLINE);
rect(image, 5, 5, 11, 9, BLACK);
set(image, 7, 6, METAL);
set(image, 9, 6, METAL);
set(image, 8, 8, BODY_DARK);
line(image, 4, 12, 7, 12, SLOT);
line(image, 9, 12, 12, 12, SLOT);
line(image, 4, 14, 12, 14, SLOT);
screw(image, 3, 3);
screw(image, 13, 3);
return image;
}
private static BufferedImage top() {
BufferedImage image = base();
rect(image, 2, 2, 14, 14, BODY);
line(image, 3, 3, 13, 3, BODY_LIGHT);
line(image, 3, 3, 3, 13, BODY_LIGHT);
line(image, 3, 13, 13, 13, BODY_DARK);
line(image, 13, 3, 13, 13, BODY_DARK);
line(image, 5, 6, 11, 6, SLOT);
line(image, 5, 8, 11, 8, SLOT);
line(image, 5, 10, 11, 10, SLOT);
set(image, 12, 12, BODY_LIGHT);
return image;
}
private static BufferedImage bottom() {
BufferedImage image = base();
rect(image, 2, 2, 14, 14, BODY_DARK);
rect(image, 4, 4, 7, 7, OUTLINE);
rect(image, 10, 4, 13, 7, OUTLINE);
rect(image, 4, 10, 7, 13, OUTLINE);
rect(image, 10, 10, 13, 13, OUTLINE);
line(image, 7, 8, 9, 8, SLOT);
line(image, 7, 9, 9, 9, SLOT);
return image;
}
private static BufferedImage base() {
BufferedImage image = new BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB);
rect(image, 0, 0, 16, 16, BODY);
line(image, 0, 0, 15, 0, OUTLINE);
line(image, 0, 0, 0, 15, OUTLINE);
line(image, 0, 15, 15, 15, OUTLINE);
line(image, 15, 0, 15, 15, OUTLINE);
line(image, 1, 1, 14, 1, BODY_LIGHT);
line(image, 1, 1, 1, 14, BODY_LIGHT);
line(image, 1, 14, 14, 14, BODY_DARK);
line(image, 14, 1, 14, 14, BODY_DARK);
return image;
}
private static void rca(BufferedImage image, int x, int y, int color) {
set(image, x, y - 1, OUTLINE);
set(image, x - 1, y, OUTLINE);
set(image, x, y, color);
set(image, x + 1, y, OUTLINE);
set(image, x, y + 1, OUTLINE);
set(image, x - 1, y - 1, METAL);
}
private static void screw(BufferedImage image, int x, int y) {
set(image, x, y, OUTLINE);
set(image, x + 1, y, METAL);
set(image, x, y + 1, METAL);
set(image, x + 1, y + 1, BODY_DARK);
}
private static void rect(BufferedImage image, int x1, int y1, int x2, int y2, int color) {
for (int y = Math.max(0, y1); y < Math.min(image.getHeight(), y2); y++) {
for (int x = Math.max(0, x1); x < Math.min(image.getWidth(), x2); x++) {
image.setRGB(x, y, color);
}
}
}
private static void line(BufferedImage image, int x1, int y1, int x2, int y2, int color) {
if (x1 == x2) {
rect(image, x1, Math.min(y1, y2), x1 + 1, Math.max(y1, y2) + 1, color);
return;
}
if (y1 == y2) {
rect(image, Math.min(x1, x2), y1, Math.max(x1, x2) + 1, y1 + 1, color);
}
}
private static void set(BufferedImage image, int x, int y, int color) {
if (x >= 0 && y >= 0 && x < image.getWidth() && y < image.getHeight()) {
image.setRGB(x, y, color);
}
}
private static void write(String name, BufferedImage image) throws IOException {
ImageIO.write(image, "png", OUT.resolve(name).toFile());
}
}

Some files were not shown because too many files have changed in this diff Show More