Commit 96482484 by Jonathan Thomas

Merge branch 'develop' into improved-chat-prompt

# Conflicts:
#	CHANGELOG.md
parents 12c71217 0c75b901
Pipeline #13346 passed with stages
in 2 minutes 22 seconds
......@@ -7,21 +7,52 @@ All notable changes to **CreatureChat** are documented in this file. The format
## Unreleased
### Added
- Wither now drops a Nether Star at max friendship (for pacifists)
- Added Entity Maturity (baby or adult) into system-chat prompt
- Added LLM Comparison HTML Output (for human eval of different LLMs with CreatureChat)
- Rate limiter for LLM unit tests (to prevent rate limit issues from certain providers when running all tests)
- Added many new speaking styles (minimalist, nerdy, stupid, gen-z, old timer, boomer, etc...)
- Rate limiter for LLM unit tests (to prevent rate limit issues from certain providers when running all tests)
- Check friendship direction (+ or -) in LLM unit tests (to verify friendship is output correctly)
### Changed
- Broadcasting and receiving chat messages now ignores if the UUID is valid (to keep data synced)
- Improved error handling to prevent broken "..." pending chat status. (HTTP and message processing is more protected)
- Improved LLM unit tests to check for both a positive and negative behaviors (i.e. FOLLOW and not LEAD, ATTACK and not FLEE, etc...)
- Simplified system-chat prompt (less tokens), rounded health & hunger values, and improved variety of examples (less tokens)
- Improved LLM unit tests to check for both a positive and negative behaviors (i.e. FOLLOW and not LEAD, ATTACK and not FLEE, etc...)
- Check friendship direction (+ or -) in LLM unit tests (to verify friendship is output correctly)
- Removed a few variables from the chat context (creative mode, hardcore, difficulty)
### Fixed
- Bees no longer forget their chat data when entering/leaving hives (writeNbt & readNbt modified)
- Vexes no longer take damage when chat data exists
- Wandering Trader no longer despawns if it has chat data
- Removed randomized error messages from chat history (so it doesn't break the chat history when an error is shown)
- Reduced death message output in logs to use DEBUG log level
- Fixed unit tests for friendship (some were being skipped)
## [1.3.0] - 2025-01-14
### Added
- In-game chat messages are now displayed in chat bubbles above players heads!
- Custom player icons (icons can be embedded in player skin file)
- Step-by-Step **Icon** Tutorial: [ICON.md](ICONS.md)
- Mixin to extend PlayerSkinTexture to make a copy of the NativeImage + pixel toggle to enable
- New command `/creaturechat chatbubbles set <on | off>` to show or hide player chat messages in bubbles
- Improved LLM Unit tests (to prevent rate limit issues from certain providers when running all tests)
- Check friendship direction (+ or -) in LLM unit tests (to verify friendship direction is output correctly)
### Changed
- Seperated Player and Entity message broadcasts (different packets for simplicity)
- Reduced size of player skin face on chat bubble, to match sizes of custom icons (for consistency)
- Updated entity icons for allay, creeper, and pig
### Fixed
- Hide death messages for mobs with no chat data
- Fixed transparent background behind chat screen for Minecraft 1.20 and 1.20.1.
- Removed extra message broadcast (which was unnecessary)
## [1.2.1] - 2025-01-01
### Changed
......
# Icon Tutorial for CreatureChat
<img src="src/main/resources/assets/creaturechat/screenshots/side-by-side-icons.png" width="100%" style="image-rendering: pixelated;">
### Customize entity and player icons in **CreatureChat** by following this step-by-step guide.
---
## **Custom Entity Icons**
<img src="src/main/resources/assets/creaturechat/screenshots/big-pig.png" width="440" style="image-rendering: pixelated;">
### Folder Structure:
To add custom icons for entities, place the icon files in the following path:
```
/main/resources/assets/creaturechat/textures/entity/pig/pig.png
/main/resources/assets/creaturechat/textures/entity/cat/black.png
/main/resources/assets/creaturechat/textures/entity/alligator.png
/main/resources/assets/creaturechat/textures/entity/YOUR-ENTITY.png
...
```
- Entity icons should be `32x32` pixels, and PNG format.
- The icon file path should match the **renderer texture path** of the entity.
- This supports all entities, including those from other mods.
---
## **Custom Player Icons**
### Step 1: Draw your character on top of the rainbow template
Player icons should be `24x24` pixels, and PNG format.
- Download [rainbow-icon-template.png](src/main/resources/assets/creaturechat/screenshots/rainbow-icon-template.png)
<img src="src/main/resources/assets/creaturechat/screenshots/example-player-icon1.png" width="256" style="image-rendering: pixelated;">
&nbsp; <img src="src/main/resources/assets/creaturechat/screenshots/example-player-icon2.png" width="256" style="image-rendering: pixelated;">
&nbsp; <img src="src/main/resources/assets/creaturechat/screenshots/example-player-icon3.png" width="256" style="image-rendering: pixelated;">
### Step 2: Position your icon on your skin
- Download [skin-template.png](src/main/resources/assets/creaturechat/screenshots/skin-template.png)
<img src="src/main/resources/assets/creaturechat/screenshots/example-player-skin1.png" width="256" style="image-rendering: pixelated;">
&nbsp; <img src="src/main/resources/assets/creaturechat/screenshots/example-player-skin2.png" width="256" style="image-rendering: pixelated;">
&nbsp; <img src="src/main/resources/assets/creaturechat/screenshots/example-player-skin4.png" width="256" style="image-rendering: pixelated;">
### Step 3: Toggle Icon Visibility
To activate a custom player icon, include a **black and white key** in your skin:
<img src="src/main/resources/assets/creaturechat/screenshots/example-skin1.png" width="256" style="image-rendering: pixelated;">
&nbsp; <img src="src/main/resources/assets/creaturechat/screenshots/example-skin2.png" width="256" style="image-rendering: pixelated;">
&nbsp; <img src="src/main/resources/assets/creaturechat/screenshots/example-player-skin4.png" width="256" style="image-rendering: pixelated;">
1. Add a **black square** at: `(28, 48)`
2. Add a **white square** at: `(32, 48)`
CreatureChat will detect this key and enable your custom icon.
### Step 4: Upload Skin in the Minecraft Launcher
- Don't forget to upload your new skin which includes the icon.
- Test your changes in-game by talking to a mob (in F5 mode)
## UV Coordinates for Icon:
Here are the full list of coordinates for the custom player icon UV.
```
UV_COORDINATES = [
[0.0, 0.0, 8.0, 8.0, 0.0, 0.0], # row 1 left
[24.0, 0.0, 32.0, 8.0, 8.0, 0.0], # row 1 middle
[32.0, 0.0, 40.0, 8.0, 16.0, 0.0], # row 1 right
[56.0, 0.0, 64.0, 8.0, 0.0, 8.0], # row 2 left
[56.0, 20.0, 64.0, 28.0, 8.0, 8.0], # row 2 middle
[36.0, 16.0, 44.0, 20.0, 16.0, 8.0], # row 2 top right
[56.0, 16.0, 64.0, 20.0, 16.0, 12.0], # row 2 bottom right
[56.0, 28.0, 64.0, 36.0, 0.0, 16.0], # row 3 left
[56.0, 36.0, 64.0, 44.0, 8.0, 16.0], # row 3 middle
[56.0, 44.0, 64.0, 48.0, 16.0, 16.0], # row 3 top right
[12.0, 48.0, 20.0, 52.0, 16.0, 20.0], # row 3 bottom right
]
```
---
## Enjoy customizing your CreatureChat experience! 😊
......@@ -115,6 +115,7 @@ Supports CreatureChat by purchasing tokens from the developers:
- [Join us on Discord](https://discord.gg/m9dvPFmN3e)
- [Build Instructions](INSTALL.md) ([Source Code](http://gitlab.openshot.org/minecraft/creature-chat))
- [Player & Entity Icon Tutorial](ICONS.md)
- Download from [Modrinth](https://modrinth.com/project/creaturechat)
- Follow Us: [YouTube](https://www.youtube.com/@CreatureChat/featured) |
[Twitter](https://twitter.com/TheCreatureChat) |
......
......@@ -3,7 +3,7 @@ org.gradle.jvmargs=-Xmx1G
org.gradle.parallel=true
# Mod Properties
mod_version=1.2.1
mod_version=1.3.0
maven_group=com.owlmaddie
archives_base_name=creaturechat
......@@ -11,7 +11,7 @@ archives_base_name=creaturechat
# check these on https://fabricmc.net/develop
minecraft_version=1.20.4
yarn_mappings=1.20.4+build.3
loader_version=0.15.10
loader_version=0.15.11
#Fabric api
fabric_version=0.97.0+1.20.4
\ No newline at end of file
package com.owlmaddie.mixin.client;
import com.owlmaddie.skin.PlayerCustomTexture;
import com.owlmaddie.skin.SkinUtils;
import net.minecraft.util.Identifier;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Overwrite;
/**
* A Mixin for PlayerCustomTexture to implement hasCustomIcon using SkinUtils.
*/
@Mixin(PlayerCustomTexture.class)
public abstract class MixinPlayerCustomTexture {
/**
* Overwrites the default implementation of hasCustomIcon to provide custom skin support.
*
* @param skinId the Identifier of the skin
* @return true if the skin has a custom icon; false otherwise
*
* @reason Add functionality to determine custom icons based on SkinUtils logic.
* @author jonoomph
*/
@Overwrite
public static boolean hasCustomIcon(Identifier skinId) {
// Delegate to SkinUtils to check for custom skin properties
return SkinUtils.checkCustomSkinKey(skinId);
}
}
package com.owlmaddie.mixin.client;
import com.owlmaddie.skin.IPlayerSkinTexture;
import net.minecraft.client.texture.NativeImage;
import net.minecraft.client.texture.PlayerSkinTexture;
import net.minecraft.client.texture.ResourceTexture;
import net.minecraft.util.Identifier;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* The {@code MixinPlayerSkinTexture} class injects code into the PlayerSkinTexture class, to make a copy
* of the player's skin native image, so we can later use it for pixel checking (black/white key) for
* loading custom player icons in the unused UV coordinates of the player skin image.
*/
@Mixin(PlayerSkinTexture.class)
public abstract class MixinPlayerSkinTexture extends ResourceTexture implements IPlayerSkinTexture {
@Unique
private NativeImage cachedSkinImage;
public MixinPlayerSkinTexture(Identifier location) {
super(location);
}
@Inject(method = "onTextureLoaded", at = @At("HEAD"))
private void captureNativeImage(NativeImage image, CallbackInfo ci) {
// Instead of image.copy(), we do a manual clone
this.cachedSkinImage = cloneNativeImage(image);
}
@Override
public NativeImage getLoadedImage() {
return this.cachedSkinImage;
}
// Example of the utility method in the same class (or in a separate helper):
private static NativeImage cloneNativeImage(NativeImage source) {
NativeImage copy = new NativeImage(
source.getFormat(),
source.getWidth(),
source.getHeight(),
false
);
copy.copyFrom(source);
return copy;
}
}
......@@ -108,17 +108,9 @@ public class ClientPackets {
public static void register() {
// Client-side packet handler, message sync
ClientPlayNetworking.registerGlobalReceiver(ServerPackets.PACKET_S2C_MESSAGE, (client, handler, buffer, responseSender) -> {
ClientPlayNetworking.registerGlobalReceiver(ServerPackets.PACKET_S2C_ENTITY_MESSAGE, (client, handler, buffer, responseSender) -> {
// Read the data from the server packet
UUID entityId = UUID.fromString(buffer.readString());
String sendingPlayerIdStr = buffer.readString(32767);
String senderPlayerName = buffer.readString(32767);
UUID senderPlayerId;
if (!sendingPlayerIdStr.isEmpty()) {
senderPlayerId = UUID.fromString(sendingPlayerIdStr);
} else {
senderPlayerId = null;
}
String message = buffer.readString(32767);
int line = buffer.readInt();
String status_name = buffer.readString(32767);
......@@ -135,35 +127,44 @@ public class ClientPackets {
return;
}
// Update the chat data manager on the client-side
MobEntity entity = ClientEntityFinder.getEntityByUUID(client.world, entityId);
if (entity == null) {
LOGGER.warn("Entity with ID '{}' not found. Skipping message processing.", entityId);
return;
}
// Get entity chat data for current entity & player
ChatDataManager chatDataManager = ChatDataManager.getClientInstance();
EntityChatData chatData = chatDataManager.getOrCreateChatData(entity.getUuidAsString());
if (senderPlayerId != null && sender == ChatDataManager.ChatSender.USER && status == ChatDataManager.ChatStatus.DISPLAY) {
// Add player message to queue for rendering
PlayerMessageManager.addMessage(senderPlayerId, message, senderPlayerName, ChatDataManager.TICKS_TO_DISPLAY_USER_MESSAGE);
chatData.status = ChatDataManager.ChatStatus.PENDING;
EntityChatData chatData = chatDataManager.getOrCreateChatData(entityId.toString());
} else {
// Add entity message
if (!message.isEmpty()) {
chatData.currentMessage = message;
}
chatData.currentLineNumber = line;
chatData.status = status;
chatData.sender = sender;
chatData.players = players;
// Add entity message
if (!message.isEmpty()) {
chatData.currentMessage = message;
}
chatData.currentLineNumber = line;
chatData.status = status;
chatData.sender = sender;
chatData.players = players;
// Play sound with volume based on distance (from player or entity)
playNearbyUISound(client, entity, 0.2f);
MobEntity entity = ClientEntityFinder.getEntityByUUID(client.world, entityId);
if (entity != null) {
playNearbyUISound(client, entity, 0.2f);
}
});
});
// Client-side packet handler, message sync
ClientPlayNetworking.registerGlobalReceiver(ServerPackets.PACKET_S2C_PLAYER_MESSAGE, (client, handler, buffer, responseSender) -> {
// Read the data from the server packet
UUID senderPlayerId = UUID.fromString(buffer.readString());
String senderPlayerName = buffer.readString(32767);
String message = buffer.readString(32767);
// Update the chat data manager on the client-side
client.execute(() -> { // Make sure to run on the client thread
// Ensure client.player is initialized
if (client.player == null || client.world == null) {
LOGGER.warn("Client not fully initialized. Dropping message for sender '{}'.", senderPlayerId);
return;
}
// Add player message to queue for rendering
PlayerMessageManager.addMessage(senderPlayerId, message, senderPlayerName, ChatDataManager.TICKS_TO_DISPLAY_USER_MESSAGE);
});
});
......
package com.owlmaddie.skin;
import net.minecraft.client.texture.NativeImage;
import org.jetbrains.annotations.Nullable;
/**
* The {@code IPlayerSkinTexture} interface adds a new getLoadedImage method to PlayerSkinTexture instances
*/
public interface IPlayerSkinTexture {
@Nullable
NativeImage getLoadedImage();
}
\ No newline at end of file
package com.owlmaddie.skin;
import net.minecraft.util.Identifier;
public class PlayerCustomTexture {
public static boolean hasCustomIcon(Identifier skinId) {
// By default, do nothing special
return false;
}
}
package com.owlmaddie.skin;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.texture.AbstractTexture;
import net.minecraft.client.texture.NativeImage;
import net.minecraft.util.Identifier;
/**
* SkinUtils contains functions to check for certain black and white pixel values in a skin, to determine
* if the skin contains a custom hidden icon to show in the player chat message.
*/
public class SkinUtils {
public static boolean checkCustomSkinKey(Identifier skinId) {
// Grab the AbstractTexture from the TextureManager
AbstractTexture tex = MinecraftClient.getInstance().getTextureManager().getTexture(skinId);
// Check if it implements our Mixin interface: IPlayerSkinTexture
if (tex instanceof IPlayerSkinTexture iSkin) {
// Get the NativeImage we stored in the Mixin
NativeImage image = iSkin.getLoadedImage();
if (image != null) {
int width = image.getWidth();
int height = image.getHeight();
// Check we have the full 64x64
if (width == 64 && height == 64) {
// Example: black & white pixel at (31,48) and (32,48)
int color31_48 = image.getColor(31, 49);
int color32_48 = image.getColor(32, 49);
return (color31_48 == 0xFF000000 && color32_48 == 0xFFFFFFFF);
}
}
}
// If it's still loading, or not a PlayerSkinTexture, or no NativeImage loaded yet
return false;
}
}
......@@ -4,9 +4,8 @@ import com.mojang.blaze3d.systems.RenderSystem;
import com.owlmaddie.chat.ChatDataManager;
import com.owlmaddie.chat.EntityChatData;
import com.owlmaddie.chat.PlayerData;
import com.owlmaddie.utils.EntityHeights;
import com.owlmaddie.utils.EntityRendererAccessor;
import com.owlmaddie.utils.TextureLoader;
import com.owlmaddie.skin.PlayerCustomTexture;
import com.owlmaddie.utils.*;
import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderContext;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.font.TextRenderer;
......@@ -228,6 +227,9 @@ public class BubbleRenderer {
EntityRenderer renderer = EntityRendererAccessor.getEntityRenderer(entity);
Identifier playerTexture = renderer.getTexture(entity);
// Check for black and white pixels (using the Mixin-based check)
boolean customSkinFound = PlayerCustomTexture.hasCustomIcon(playerTexture);
// Set shader & texture
RenderSystem.setShader(GameRenderer::getPositionColorTexLightmapProgram);
RenderSystem.setShaderTexture(0, playerTexture);
......@@ -241,42 +243,90 @@ public class BubbleRenderer {
// Prepare the tessellator and buffer
Tessellator tessellator = Tessellator.getInstance();
BufferBuilder bufferBuilder = tessellator.getBuffer();
// Get the current matrix position
Matrix4f matrix4f = matrices.peek().getPositionMatrix();
// Begin drawing quads with the correct vertex format
bufferBuilder.begin(VertexFormat.DrawMode.QUADS, VertexFormats.POSITION_COLOR_TEXTURE_LIGHT);
// Texture coordinates for the face region (8, 8) to (16, 16) in a 64x64 texture
float textureWidth = 64.0F;
float textureHeight = 64.0F;
float u1 = 8.0F / textureWidth;
float v1 = 8.0F / textureHeight;
float u2 = 16.0F / textureWidth;
float v2 = 16.0F / textureHeight;
Matrix4f matrix4f = matrices.peek().getPositionMatrix();
float z = -0.01F;
// Draw face
bufferBuilder.vertex(matrix4f, x, y + height, z).color(255, 255, 255, 255).texture(u1, v2).light(light).overlay(overlay).next(); // bottom left
bufferBuilder.vertex(matrix4f, x + width, y + height, z).color(255, 255, 255, 255).texture(u2, v2).light(light).overlay(overlay).next(); // bottom right
bufferBuilder.vertex(matrix4f, x + width, y, z).color(255, 255, 255, 255).texture(u2, v1).light(light).overlay(overlay).next(); // top right
bufferBuilder.vertex(matrix4f, x, y, z).color(255, 255, 255, 255).texture(u1, v1).light(light).overlay(overlay).next(); // top left
// Coordinates for the hat (overlay)
float hatU1 = 40.0F / textureWidth;
float hatV1 = 8.0F / textureHeight;
float hatU2 = 48.0F / textureWidth;
float hatV2 = 16.0F / textureHeight;
// Adjust depth for hat layer
z -= 0.01F;
// Draw hat (overlay)
bufferBuilder.vertex(matrix4f, x, y + height, z).color(255, 255, 255, 255).texture(hatU1, hatV2).light(light).overlay(overlay).next();
bufferBuilder.vertex(matrix4f, x + width, y + height, z).color(255, 255, 255, 255).texture(hatU2, hatV2).light(light).overlay(overlay).next();
bufferBuilder.vertex(matrix4f, x + width, y, z).color(255, 255, 255, 255).texture(hatU2, hatV1).light(light).overlay(overlay).next();
bufferBuilder.vertex(matrix4f, x, y, z).color(255, 255, 255, 255).texture(hatU1, hatV1).light(light).overlay(overlay).next();
if (customSkinFound) {
// Hidden icon UV coordinates
float[][] newCoordinates = {
{0.0F, 0.0F, 8.0F, 8.0F, 0F, 0F}, // Row 1 left
{24.0F, 0.0F, 32.0F, 8.0F, 8F, 0F}, // Row 1 middle
{32.0F, 0.0F, 40.0F, 8.0F, 16F, 0F}, // Row 1 right
{56.0F, 0.0F, 64.0F, 8.0F, 0F, 8F}, // Row 2 left
{56.0F, 20.0F, 64.0F, 28.0F, 8F, 8F}, // Row 2 middle
{36.0F, 16.0F, 44.0F, 20.0F, 16F, 8F},// Row 2 right top
{56.0F, 16.0F, 64.0F, 20.0F, 16F, 12F},// Row 2 right bottom
{56.0F, 28.0F, 64.0F, 36.0F, 0F, 16F}, // Row 3 left
{56.0F, 36.0F, 64.0F, 44.0F, 8F, 16F}, // Row 3 middle
{56.0F, 44.0F, 64.0F, 48, 16F, 16F}, // Row 3 top right
{12.0F, 48.0F, 20.0F, 52, 16F, 20F}, // Row 3 bottom right
};
float scaleFactor = 0.77F;
for (float[] coords : newCoordinates) {
float newU1 = coords[0] / 64.0F;
float newV1 = coords[1] / 64.0F;
float newU2 = coords[2] / 64.0F;
float newV2 = coords[3] / 64.0F;
float offsetX = coords[4] * scaleFactor;
float offsetY = coords[5] * scaleFactor;
float scaledX = x + offsetX;
float scaledY = y + offsetY;
float scaledWidth = (coords[2] - coords[0]) * scaleFactor;
float scaledHeight = (coords[3] - coords[1]) * scaleFactor;
bufferBuilder.vertex(matrix4f, scaledX, scaledY + scaledHeight, z)
.color(255, 255, 255, 255).texture(newU1, newV2).light(light).overlay(overlay).next();
bufferBuilder.vertex(matrix4f, scaledX + scaledWidth, scaledY + scaledHeight, z)
.color(255, 255, 255, 255).texture(newU2, newV2).light(light).overlay(overlay).next();
bufferBuilder.vertex(matrix4f, scaledX + scaledWidth, scaledY, z)
.color(255, 255, 255, 255).texture(newU2, newV1).light(light).overlay(overlay).next();
bufferBuilder.vertex(matrix4f, scaledX, scaledY, z)
.color(255, 255, 255, 255).texture(newU1, newV1).light(light).overlay(overlay).next();
}
} else {
// make skin appear smaller and centered
x += 2;
y += 2;
width -= 4;
height -= 4;
// Normal face coordinates
float u1 = 8.0F / 64.0F;
float v1 = 8.0F / 64.0F;
float u2 = 16.0F / 64.0F;
float v2 = 16.0F / 64.0F;
bufferBuilder.vertex(matrix4f, x, y + height, z)
.color(255, 255, 255, 255).texture(u1, v2).light(light).overlay(overlay).next();
bufferBuilder.vertex(matrix4f, x + width, y + height, z)
.color(255, 255, 255, 255).texture(u2, v2).light(light).overlay(overlay).next();
bufferBuilder.vertex(matrix4f, x + width, y, z)
.color(255, 255, 255, 255).texture(u2, v1).light(light).overlay(overlay).next();
bufferBuilder.vertex(matrix4f, x, y, z)
.color(255, 255, 255, 255).texture(u1, v1).light(light).overlay(overlay).next();
// Hat layer
float hatU1 = 40.0F / 64.0F;
float hatV1 = 8.0F / 64.0F;
float hatU2 = 48.0F / 64.0F;
float hatV2 = 16.0F / 64.0F;
z -= 0.01F;
bufferBuilder.vertex(matrix4f, x, y + height, z)
.color(255, 255, 255, 255).texture(hatU1, hatV2).light(light).overlay(overlay).next();
bufferBuilder.vertex(matrix4f, x + width, y + height, z)
.color(255, 255, 255, 255).texture(hatU2, hatV2).light(light).overlay(overlay).next();
bufferBuilder.vertex(matrix4f, x + width, y, z)
.color(255, 255, 255, 255).texture(hatU2, hatV1).light(light).overlay(overlay).next();
bufferBuilder.vertex(matrix4f, x, y, z)
.color(255, 255, 255, 255).texture(hatU1, hatV1).light(light).overlay(overlay).next();
}
tessellator.draw();
// Disable blending and depth test
......
......@@ -2,6 +2,7 @@ package com.owlmaddie.ui;
import com.owlmaddie.chat.ChatDataManager;
import com.owlmaddie.network.ClientPackets;
import com.owlmaddie.utils.VersionUtils;
import net.minecraft.client.gui.DrawContext;
import net.minecraft.client.gui.screen.Screen;
import net.minecraft.client.gui.widget.ButtonWidget;
......@@ -99,6 +100,11 @@ public class ChatScreen extends Screen {
@Override
public void render(DrawContext context, int mouseX, int mouseY, float delta) {
// Render custom background only for older versions
if (VersionUtils.isOlderThan("1.20.2")) {
renderBackground(context);
}
// Render the label text above the text field
int labelWidth = textRenderer.getWidth(labelText);
int labelX = (this.width - labelWidth) / 2; // Centered X position
......@@ -116,6 +122,11 @@ public class ChatScreen extends Screen {
super.render(context, mouseX, mouseY, delta);
}
public void renderBackground(DrawContext context) {
// Draw a slightly lighter semi-transparent rectangle as the background
context.fillGradient(0, 0, this.width, this.height, 0xA3000000, 0xA3000000);
}
@Override
public boolean shouldCloseOnEsc() {
// Return true if you want the screen to close when the ESC key is pressed
......
package com.owlmaddie.utils;
import net.minecraft.SharedConstants;
/**
* The {@code VersionUtils} class is used to quickly compare the current version of Minecraft.
*/
public class VersionUtils {
public static boolean isOlderThan(String targetVersion) {
String currentVersion = SharedConstants.getGameVersion().getName();
return currentVersion.compareTo(targetVersion) < 0;
}
}
\ No newline at end of file
......@@ -3,8 +3,18 @@
"package": "com.owlmaddie.mixin.client",
"compatibilityLevel": "JAVA_17",
"client": [
"EntityRendererMixin"
"EntityRendererMixin",
"MixinPlayerCustomTexture",
"MixinPlayerSkinTexture"
],
"env": {
"client.MixinPlayerCustomTexture": {
"minVersion": "1.20"
},
"client.MixinPlayerSkinTexture": {
"minVersion": "1.20"
}
},
"injectors": {
"defaultRequire": 1
}
......
......@@ -85,7 +85,7 @@ public class ChatDataManager {
LOGGER.info("Updated chat data from UUID (" + oldUUID + ") to UUID (" + newUUID + ")");
// Broadcast to all players
ServerPackets.BroadcastPacketMessage(data, null);
ServerPackets.BroadcastEntityMessage(data);
} else {
LOGGER.info("Unable to update chat data, UUID not found: " + oldUUID);
}
......
......@@ -196,6 +196,7 @@ public class ChatGPTRequest {
lastErrorMessage = cleanError;
} catch (Exception e) {
LOGGER.error("Failed to read error response", e);
lastErrorMessage = "Failed to read error response: " + e.getMessage();
}
return null;
} else {
......@@ -214,12 +215,16 @@ public class ChatGPTRequest {
if (chatGPTResponse != null && chatGPTResponse.choices != null && !chatGPTResponse.choices.isEmpty()) {
String content = chatGPTResponse.choices.get(0).message.content;
return content;
} else {
lastErrorMessage = "Failed to parse response from LLM";
return null;
}
}
} catch (IOException e) {
LOGGER.error("Failed to fetch message from ChatGPT", e);
} catch (Exception e) {
LOGGER.error("Failed to request message from LLM", e);
lastErrorMessage = "Failed to request message from LLM: " + e.getMessage();
return null;
}
return null; // If there was an error or no response, return null
});
}
}
......
......@@ -71,6 +71,7 @@ public class ConfigurationHandler {
private int maxOutputTokens = 200;
private double percentOfContext = 0.75;
private int timeout = 10;
private boolean chatBubbles = true;
private List<String> whitelist = new ArrayList<>();
private List<String> blacklist = new ArrayList<>();
private String story = "";
......@@ -114,5 +115,9 @@ public class ConfigurationHandler {
public String getStory() { return story; }
public void setStory(String story) { this.story = story; }
// Add getter and setter
public boolean getChatBubbles() { return chatBubbles; }
public void setChatBubbles(boolean chatBubblesEnabled) { this.chatBubbles = chatBubblesEnabled; }
}
}
......@@ -49,6 +49,7 @@ public class CreatureChatCommands {
.then(registerStoryCommand())
.then(registerWhitelistCommand())
.then(registerBlacklistCommand())
.then(registerChatBubbleCommand())
.then(registerHelpCommand()));
}
......@@ -95,6 +96,35 @@ public class CreatureChatCommands {
.map(Identifier::toString)
.collect(Collectors.toList());
}
private static LiteralArgumentBuilder<ServerCommandSource> registerChatBubbleCommand() {
return CommandManager.literal("chatbubble")
.requires(source -> source.hasPermissionLevel(4))
.then(CommandManager.literal("set")
.then(CommandManager.literal("on")
.then(addConfigArgs((context, useServerConfig) -> setChatBubbleEnabled(context, true, useServerConfig)))
.executes(context -> setChatBubbleEnabled(context, true, false)))
.then(CommandManager.literal("off")
.then(addConfigArgs((context, useServerConfig) -> setChatBubbleEnabled(context, false, useServerConfig)))
.executes(context -> setChatBubbleEnabled(context, false, false))));
}
private static int setChatBubbleEnabled(CommandContext<ServerCommandSource> context, boolean enabled, boolean useServerConfig) {
ServerCommandSource source = context.getSource();
ConfigurationHandler configHandler = new ConfigurationHandler(source.getServer());
ConfigurationHandler.Config config = configHandler.loadConfig();
config.setChatBubbles(enabled);
if (configHandler.saveConfig(config, useServerConfig)) {
Text feedbackMessage = Text.literal("Player chat bubbles have been " + (enabled ? "enabled" : "disabled") + ".").formatted(Formatting.GREEN);
source.sendFeedback(() -> feedbackMessage, true);
return 1;
} else {
Text feedbackMessage = Text.literal("Failed to update player chat bubble setting.").formatted(Formatting.RED);
source.sendFeedback(() -> feedbackMessage, false);
return 0;
}
}
private static LiteralArgumentBuilder<ServerCommandSource> registerWhitelistCommand() {
return CommandManager.literal("whitelist")
......@@ -135,6 +165,7 @@ public class CreatureChatCommands {
+ "/creaturechat model set <model> - Sets the model\n"
+ "/creaturechat timeout set <seconds> - Sets the API timeout\n"
+ "/creaturechat story set \"<story>\" - Sets a custom story\n"
+ "/creaturechat chatbubbles set <on | off> - Show player chat bubbles\n"
+ "/creaturechat whitelist <entityType | all | clear> - Show chat bubbles\n"
+ "/creaturechat blacklist <entityType | all | clear> - Hide chat bubbles\n"
+ "\n"
......@@ -151,43 +182,42 @@ public class CreatureChatCommands {
.requires(source -> source.hasPermissionLevel(4))
.then(CommandManager.literal("set")
.then(CommandManager.argument("value", StringArgumentType.string())
.executes(context -> {
.then(addConfigArgs((context, useServerConfig) -> {
String story = StringArgumentType.getString(context, "value");
ConfigurationHandler.Config config = new ConfigurationHandler(context.getSource().getServer()).loadConfig();
config.setStory(story); // Assuming Config has a `setStory` method
if (new ConfigurationHandler(context.getSource().getServer()).saveConfig(config, true)) {
config.setStory(story);
if (new ConfigurationHandler(context.getSource().getServer()).saveConfig(config, useServerConfig)) {
context.getSource().sendFeedback(() -> Text.literal("Story set successfully: " + story).formatted(Formatting.GREEN), true);
return 1;
} else {
context.getSource().sendFeedback(() -> Text.literal("Failed to set story!").formatted(Formatting.RED), false);
return 0;
}
})
))
}))))
.then(CommandManager.literal("clear")
.executes(context -> {
.then(addConfigArgs((context, useServerConfig) -> {
ConfigurationHandler.Config config = new ConfigurationHandler(context.getSource().getServer()).loadConfig();
config.setStory(""); // Clear the story
if (new ConfigurationHandler(context.getSource().getServer()).saveConfig(config, true)) {
config.setStory("");
if (new ConfigurationHandler(context.getSource().getServer()).saveConfig(config, useServerConfig)) {
context.getSource().sendFeedback(() -> Text.literal("Story cleared successfully!").formatted(Formatting.GREEN), true);
return 1;
} else {
context.getSource().sendFeedback(() -> Text.literal("Failed to clear story!").formatted(Formatting.RED), false);
return 0;
}
}))
})))
.then(CommandManager.literal("display")
.executes(context -> {
ConfigurationHandler.Config config = new ConfigurationHandler(context.getSource().getServer()).loadConfig();
String story = config.getStory(); // Assuming Config has a `getStory` method
if (story == null || story.isEmpty()) {
context.getSource().sendFeedback(() -> Text.literal("No story is currently set.").formatted(Formatting.RED), false);
return 0;
} else {
context.getSource().sendFeedback(() -> Text.literal("Current story: " + story).formatted(Formatting.AQUA), false);
return 1;
}
}));
.executes(context -> {
ConfigurationHandler.Config config = new ConfigurationHandler(context.getSource().getServer()).loadConfig();
String story = config.getStory();
if (story == null || story.isEmpty()) {
context.getSource().sendFeedback(() -> Text.literal("No story is currently set.").formatted(Formatting.RED), false);
return 0;
} else {
context.getSource().sendFeedback(() -> Text.literal("Current story: " + story).formatted(Formatting.AQUA), false);
return 1;
}
}));
}
private static <T> int setConfig(ServerCommandSource source, String settingName, T value, boolean useServerConfig, String settingDescription) {
......
package com.owlmaddie.mixin;
import com.owlmaddie.chat.ChatDataManager;
import com.owlmaddie.chat.EntityChatData;
import net.minecraft.entity.Entity;
import net.minecraft.nbt.NbtCompound;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
import java.util.UUID;
@Mixin(Entity.class)
public abstract class MixinEntityChatData {
@Shadow
public abstract UUID getUuid();
/**
* When writing NBT data, if the entity has chat data then store its UUID under "CCUUID".
*/
@Inject(method = "writeNbt", at = @At("TAIL"))
private void writeChatData(NbtCompound nbt, CallbackInfoReturnable<NbtCompound> cir) {
UUID currentUUID = this.getUuid();
// Retrieve or create the chat data for this entity.
EntityChatData chatData = ChatDataManager.getServerInstance().getOrCreateChatData(currentUUID.toString());
// If the entity actually has chat data (for example, if its character sheet is non-empty), add CCUUID.
if (!chatData.characterSheet.isEmpty()) {
// Note: cir.getReturnValue() returns the NBT compound the method is about to return.
cir.getReturnValue().putUuid("CCUUID", currentUUID);
}
}
/**
* When reading NBT data, if there is a "CCUUID" entry and it does not match the entity’s current UUID,
* update our chat data key to reflect the change.
*/
@Inject(method = "readNbt", at = @At("TAIL"))
private void readChatData(NbtCompound nbt, CallbackInfo ci) {
UUID currentUUID = this.getUuid();
if (nbt.contains("CCUUID")) {
UUID originalUUID = nbt.getUuid("CCUUID");
if (!originalUUID.equals(currentUUID)) {
ChatDataManager.getServerInstance().updateUUID(originalUUID.toString(), currentUUID.toString());
}
}
}
}
package com.owlmaddie.mixin;
import com.owlmaddie.chat.EntityChatData;
import com.owlmaddie.commands.ConfigurationHandler;
import com.owlmaddie.network.ServerPackets;
import net.minecraft.network.packet.c2s.play.ChatMessageC2SPacket;
import net.minecraft.server.network.ServerPlayNetworkHandler;
import net.minecraft.server.network.ServerPlayerEntity;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import static com.owlmaddie.network.ServerPackets.BroadcastPlayerMessage;
/**
* The {@code MixinOnChat} mixin class intercepts chat messages from players, and broadcasts them as chat bubbles
*/
@Mixin(ServerPlayNetworkHandler.class)
public abstract class MixinOnChat {
@Inject(method = "onChatMessage", at = @At("HEAD"), cancellable = true)
private void onChatMessage(ChatMessageC2SPacket packet, CallbackInfo ci) {
ConfigurationHandler.Config config = new ConfigurationHandler(ServerPackets.serverInstance).loadConfig();
if (config.getChatBubbles()) {
// Get the player who sent the message
ServerPlayNetworkHandler handler = (ServerPlayNetworkHandler) (Object) this;
ServerPlayerEntity player = handler.player;
// Get the chat message
String chatMessage = packet.chatMessage();
// Example: Call your broadcast function
EntityChatData chatData = new EntityChatData(player.getUuidAsString());
chatData.currentMessage = chatMessage;
BroadcastPlayerMessage(chatData, player);
// Optionally, cancel the event to prevent the default behavior
//ci.cancel();
}
}
}
package com.owlmaddie.mixin;
import com.owlmaddie.chat.ChatDataManager;
import com.owlmaddie.chat.EntityChatData;
import net.minecraft.entity.mob.VexEntity;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Mixin to modify Vex behavior by setting `alive = false` if chat data exists.
*/
@Mixin(VexEntity.class)
public abstract class MixinVexEntity {
@Shadow
private boolean alive;
@Inject(method = "tick", at = @At("HEAD"))
private void disableVexIfChatData(CallbackInfo ci) {
VexEntity vex = (VexEntity) (Object) this;
// Get chat data for this Vex
EntityChatData chatData = ChatDataManager.getServerInstance().getOrCreateChatData(vex.getUuidAsString());
if (this.alive && !chatData.characterSheet.isEmpty()) {
this.alive = false; // Prevents the Vex from ticking and taking damage
}
}
}
package com.owlmaddie.mixin;
import com.owlmaddie.chat.ChatDataManager;
import com.owlmaddie.chat.EntityChatData;
import net.minecraft.entity.passive.WanderingTraderEntity;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Prevents WanderingTraderEntity from despawning if it has chat data or a character sheet.
*/
@Mixin(WanderingTraderEntity.class)
public abstract class MixinWanderingTrader {
private static final Logger LOGGER = LoggerFactory.getLogger("creaturechat");
@Inject(method = "tickDespawnDelay", at = @At("HEAD"), cancellable = true)
private void preventTraderDespawn(CallbackInfo ci) {
WanderingTraderEntity trader = (WanderingTraderEntity) (Object) this;
// Get chat data for this trader
EntityChatData chatData = ChatDataManager.getServerInstance().getOrCreateChatData(trader.getUuidAsString());
// If the character sheet is not empty, cancel the function to prevent despawning
if (!chatData.characterSheet.isEmpty()) {
ci.cancel();
}
}
}
package com.owlmaddie.mixin;
import com.owlmaddie.utils.WitherEntityAccessor;
import net.minecraft.entity.boss.WitherEntity;
import net.minecraft.entity.damage.DamageSource;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
/**
* Mixin to expose the protected dropEquipment method from WitherEntity.
*/
@Mixin(WitherEntity.class)
public abstract class MixinWitherEntity implements WitherEntityAccessor {
@Shadow
protected abstract void dropEquipment(DamageSource source, int lootingMultiplier, boolean allowDrops);
@Override
public void callDropEquipment(DamageSource source, int lootingMultiplier, boolean allowDrops) {
dropEquipment(source, lootingMultiplier, allowDrops);
}
}
......@@ -57,7 +57,8 @@ public class ServerPackets {
public static final Identifier PACKET_C2S_OPEN_CHAT = new Identifier("creaturechat", "packet_c2s_open_chat");
public static final Identifier PACKET_C2S_CLOSE_CHAT = new Identifier("creaturechat", "packet_c2s_close_chat");
public static final Identifier PACKET_C2S_SEND_CHAT = new Identifier("creaturechat", "packet_c2s_send_chat");
public static final Identifier PACKET_S2C_MESSAGE = new Identifier("creaturechat", "packet_s2c_message");
public static final Identifier PACKET_S2C_ENTITY_MESSAGE = new Identifier("creaturechat", "packet_s2c_entity_message");
public static final Identifier PACKET_S2C_PLAYER_MESSAGE = new Identifier("creaturechat", "packet_s2c_player_message");
public static final Identifier PACKET_S2C_LOGIN = new Identifier("creaturechat", "packet_s2c_login");
public static final Identifier PACKET_S2C_WHITELIST = new Identifier("creaturechat", "packet_s2c_whitelist");
public static final Identifier PACKET_S2C_PLAYER_STATUS = new Identifier("creaturechat", "packet_s2c_player_status");
......@@ -342,21 +343,18 @@ public class ServerPackets {
}
// Send new message to all connected players
public static void BroadcastPacketMessage(EntityChatData chatData, ServerPlayerEntity sender) {
public static void BroadcastEntityMessage(EntityChatData chatData) {
// Log useful information before looping through all players
LOGGER.info("Broadcasting message: sender={}, entityId={}, status={}, currentMessage={}, currentLineNumber={}, senderType={}",
sender != null ? sender.getDisplayName().getString() : "Unknown",
chatData.entityId,
chatData.status,
LOGGER.info("Broadcasting entity message: entityId={}, status={}, currentMessage={}, currentLineNumber={}, senderType={}",
chatData.entityId, chatData.status,
chatData.currentMessage.length() > 24 ? chatData.currentMessage.substring(0, 24) + "..." : chatData.currentMessage,
chatData.currentLineNumber,
chatData.sender);
chatData.currentLineNumber, chatData.sender);
for (ServerWorld world : serverInstance.getWorlds()) {
// Find Entity by UUID and update custom name
UUID entityId = UUID.fromString(chatData.entityId);
MobEntity entity = (MobEntity)ServerEntityFinder.getEntityByUUID(world, entityId);
if (entity != null) {
// Set custom name (if null)
String characterName = chatData.getCharacterProp("name");
if (!characterName.isEmpty() && !characterName.equals("N/A") && entity.getCustomName() == null) {
LOGGER.debug("Setting entity name to " + characterName + " for " + chatData.entityId);
......@@ -364,36 +362,47 @@ public class ServerPackets {
entity.setCustomNameVisible(true);
entity.setPersistent();
}
}
// Make auto-generated message appear as a pending icon (attack, show/give, arrival)
if (chatData.sender == ChatDataManager.ChatSender.USER && chatData.auto_generated > 0) {
chatData.status = ChatDataManager.ChatStatus.PENDING;
}
// Iterate over all players and send the packet
for (ServerPlayerEntity player : serverInstance.getPlayerManager().getPlayerList()) {
PacketByteBuf buffer = new PacketByteBuf(Unpooled.buffer());
// Make auto-generated message appear as a pending icon (attack, show/give, arrival)
if (chatData.sender == ChatDataManager.ChatSender.USER && chatData.auto_generated > 0) {
chatData.status = ChatDataManager.ChatStatus.PENDING;
}
// Write the entity's chat updated data
buffer.writeString(chatData.entityId);
if (sender != null && chatData.auto_generated == 0) {
buffer.writeString(sender.getUuidAsString());
buffer.writeString(sender.getDisplayName().getString());
} else {
buffer.writeString("");
buffer.writeString("Unknown");
}
buffer.writeString(chatData.currentMessage);
buffer.writeInt(chatData.currentLineNumber);
buffer.writeString(chatData.status.toString());
buffer.writeString(chatData.sender.toString());
writePlayerDataMap(buffer, chatData.players);
// Send message to player
ServerPlayNetworking.send(player, PACKET_S2C_MESSAGE, buffer);
}
break;
// Iterate over all players and send the packet
for (ServerPlayerEntity player : serverInstance.getPlayerManager().getPlayerList()) {
PacketByteBuf buffer = new PacketByteBuf(Unpooled.buffer());
buffer.writeString(chatData.entityId);
buffer.writeString(chatData.currentMessage);
buffer.writeInt(chatData.currentLineNumber);
buffer.writeString(chatData.status.toString());
buffer.writeString(chatData.sender.toString());
writePlayerDataMap(buffer, chatData.players);
// Send message to player
ServerPlayNetworking.send(player, PACKET_S2C_ENTITY_MESSAGE, buffer);
}
break;
}
}
// Send new message to all connected players
public static void BroadcastPlayerMessage(EntityChatData chatData, ServerPlayerEntity sender) {
// Log the specific data being sent
LOGGER.info("Broadcasting player message: senderUUID={}, message={}", sender.getUuidAsString(),
chatData.currentMessage);
// Create the buffer for the packet
PacketByteBuf buffer = new PacketByteBuf(Unpooled.buffer());
// Write the sender's UUID and the chat message to the buffer
buffer.writeString(sender.getUuidAsString());
buffer.writeString(sender.getDisplayName().getString());
buffer.writeString(chatData.currentMessage);
// Iterate over all connected players and send the packet
for (ServerPlayerEntity serverPlayer : serverInstance.getPlayerManager().getPlayerList()) {
ServerPlayNetworking.send(serverPlayer, PACKET_S2C_PLAYER_MESSAGE, buffer);
}
}
......
package com.owlmaddie.utils;
import net.minecraft.entity.damage.DamageSource;
/**
* Accessor interface for WitherEntity to allow calling dropEquipment externally.
*/
public interface WitherEntityAccessor {
void callDropEquipment(DamageSource source, int lootingMultiplier, boolean allowDrops);
}
......@@ -7,7 +7,12 @@
"MixinMobEntityAccessor",
"MixinLivingEntity",
"MixinBucketable",
"MixinVillagerEntity"
"MixinEntityChatData",
"MixinWitherEntity",
"MixinVexEntity",
"MixinWanderingTrader",
"MixinVillagerEntity",
"MixinOnChat"
],
"injectors": {
"defaultRequire": 1
......
......@@ -33,7 +33,7 @@
"accessWidener": "creaturechat.accesswidener",
"depends": {
"fabricloader": ">=0.14.22",
"minecraft": "~1.20.2",
"minecraft": "~1.20.4",
"java": ">=17",
"fabric-api": "*"
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment