diff --git a/.gitignore b/.gitignore index faa687c..f38cd86 100644 --- a/.gitignore +++ b/.gitignore @@ -33,10 +33,18 @@ bin/ # fabric run/ +run2/ # java src/main/generated/.cache src/main/generated +src/test/generated +src/test/generated/.cache -.direnv \ No newline at end of file + +.direnv + +# cursor + +.cursorrules \ No newline at end of file diff --git a/ANIMATION_SYSTEM.md b/ANIMATION_SYSTEM.md new file mode 100644 index 0000000..8f8d92a --- /dev/null +++ b/ANIMATION_SYSTEM.md @@ -0,0 +1,449 @@ +# Bedrock Animation System + +AmbleKit provides a powerful Bedrock Edition animation system that lets you use Blockbench-style geometry and animations on your entities and block entities. This system supports the standard Bedrock model/animation JSON format, making it easy to create and import complex animated models. + +## Table of Contents +- [Overview](#overview) +- [Model Format](#model-format) +- [Animation Format](#animation-format) +- [Setting Up Animated Entities](#setting-up-animated-entities) +- [Setting Up Animated Block Entities](#setting-up-animated-block-entities) +- [Playing Animations](#playing-animations) +- [Animation Features](#animation-features) +- [Commands](#commands) +- [File Locations](#file-locations) + +--- + +## Overview + +The Bedrock Animation System provides: +- **Bedrock Model Support** - Load `.geo.json` models from Blockbench +- **Bedrock Animation Support** - Load `.animation.json` animation files +- **Automatic Renderer Registration** - Use `@HasBedrockModel` annotation for automatic setup +- **Sound Event Integration** - Play sounds at specific animation keyframes +- **Animation Metadata** - Control movement and other behaviors during animations +- **Looping & One-Shot** - Support for both looping and single-play animations + +--- + +## Model Format + +AmbleKit uses the standard Bedrock Edition geometry format (`format_version` 1.12.0+). + +### Model File Structure + +Models should be placed in your resource pack: +``` +assets//geo/.geo.json +``` + +### Example Model File + +```json +{ + "format_version": "1.12.0", + "minecraft:geometry": [ + { + "description": { + "identifier": "geometry.my_entity", + "texture_width": 64, + "texture_height": 64, + "visible_bounds_width": 2, + "visible_bounds_height": 3, + "visible_bounds_offset": [0, 1.5, 0] + }, + "bones": [ + { + "name": "root", + "pivot": [0, 0, 0] + }, + { + "name": "body", + "parent": "root", + "pivot": [0, 24, 0], + "cubes": [ + { + "origin": [-4, 12, -2], + "size": [8, 12, 4], + "uv": [16, 16] + } + ] + }, + { + "name": "head", + "parent": "body", + "pivot": [0, 24, 0], + "cubes": [ + { + "origin": [-4, 24, -4], + "size": [8, 8, 8], + "uv": [0, 0] + } + ] + } + ] + } + ] +} +``` + +### Supported Model Features + +| Feature | Description | +|---------|-------------| +| **Bones** | Hierarchical bone structure with parent-child relationships | +| **Cubes** | Box-shaped geometry with position, size, UV mapping | +| **Pivots** | Rotation pivot points for bones | +| **Rotation** | Default bone rotations | +| **Mirroring** | UV mirroring for symmetric parts | +| **Inflate** | Expansion/contraction of cubes | +| **Locators** | Named positions for particle/effect spawning | + +--- + +## Animation Format + +AmbleKit uses the standard Bedrock Edition animation format. + +### Animation File Structure + +Animations should be placed in your resource pack: +``` +assets//animations/.animation.json +``` + +### Example Animation File + +```json +{ + "format_version": "1.8.0", + "animations": { + "animation.my_entity.walk": { + "loop": true, + "animation_length": 1.0, + "bones": { + "right_leg": { + "rotation": { + "0.0": [22.5, 0, 0], + "0.5": [-22.5, 0, 0], + "1.0": [22.5, 0, 0] + } + }, + "left_leg": { + "rotation": { + "0.0": [-22.5, 0, 0], + "0.5": [22.5, 0, 0], + "1.0": [-22.5, 0, 0] + } + } + } + }, + "animation.my_entity.attack": { + "loop": false, + "animation_length": 0.5, + "bones": { + "right_arm": { + "rotation": { + "0.0": [0, 0, 0], + "0.25": [-90, 0, 0], + "0.5": [0, 0, 0] + } + } + } + } + } +} +``` + +### Keyframe Interpolation + +| Type | Description | +|------|-------------| +| **Linear** | Default smooth interpolation between keyframes | +| **Smooth (Catmull-Rom)** | Extra-smooth transitions using catmull-rom splines | + +### Animation Properties + +| Property | Description | +|----------|-------------| +| `loop` | Whether the animation repeats (`true`/`false`) | +| `animation_length` | Duration in seconds | +| `bones` | Map of bone names to transformation timelines | + +### Bone Transformation Channels + +| Channel | Description | +|---------|-------------| +| `position` | Translate the bone (x, y, z) | +| `rotation` | Rotate the bone (pitch, yaw, roll in degrees) | +| `scale` | Scale the bone (x, y, z multipliers) | + +--- + +## Setting Up Animated Entities + +### Step 1: Implement AnimatedEntity + +Make your entity implement `AnimatedEntity`: + +```java +public class MyEntity extends LivingEntity implements AnimatedEntity { + private final AnimationState animationState = new AnimationState(); + + @Override + public AnimationState getAnimationState() { + return animationState; + } + + @Override + public BedrockModelReference getModel() { + return new BedrockModelReference(new Identifier("mymod", "geo/my_entity.geo.json")); + } + + @Override + public Identifier getTexture() { + return new Identifier("mymod", "textures/entity/my_entity.png"); + } + + @Override + public BedrockAnimationReference getDefaultAnimation() { + return BedrockAnimationReference.parse(new Identifier("mymod", "my_entity.walk")); + } +} +``` + +### Step 2: Register with @HasBedrockModel + +In your `EntityContainer`, annotate the entity type with `@HasBedrockModel`: + +```java +public class MyEntities implements EntityContainer { + @HasBedrockModel + public static final EntityType MY_ENTITY = EntityType.Builder + .create(MyEntity::new, SpawnGroup.CREATURE) + .setDimensions(0.6f, 1.8f) + .build("my_entity"); +} +``` + +The renderer will be automatically registered on the client side. + +--- + +## Setting Up Animated Block Entities + +### Step 1: Implement AnimatedBlockEntity + +```java +public class MyBlockEntity extends BlockEntity implements AnimatedBlockEntity { + private final AnimationState animationState = new AnimationState(); + + @Override + public AnimationState getAnimationState() { + return animationState; + } + + @Override + public BedrockModelReference getModel() { + return new BedrockModelReference(new Identifier("mymod", "geo/my_block.geo.json")); + } + + @Override + public Identifier getTexture() { + return new Identifier("mymod", "textures/block/my_block.png"); + } +} +``` + +### Step 2: Register the Renderer + +Use `BedrockBlockEntityRenderer` for your block entity: + +```java +BlockEntityRendererRegistry.register(MY_BLOCK_ENTITY, BedrockBlockEntityRenderer::new); +``` + +--- + +## Playing Animations + +### Via Java Code + +```java +// Get an AnimatedEntity +AnimatedEntity entity = ...; + +// Create an animation reference +BedrockAnimationReference animation = BedrockAnimationReference.parse( + new Identifier("mymod", "my_entity.attack") +); + +// Play the animation +entity.playAnimation(animation); +``` + +### Animation Reference Format + +Animation references follow this format: +``` +namespace:animation_file.animation_name +``` + +For example: +- `mymod:my_entity.walk` → loads `animation.my_entity.walk` from `assets/mymod/animations/my_entity.animation.json` + +--- + +## Animation Features + +### Sound Events + +Add sounds to play at specific times during animations: + +```json +{ + "animations": { + "animation.my_entity.attack": { + "animation_length": 0.5, + "sound_effects": { + "0.0": { + "effect": "minecraft:entity.player.attack.sweep" + }, + "0.25": { + "effect": "mymod:custom_sound" + } + } + } + } +} +``` + +### Animation Metadata + +Control entity behavior during animations: + +```java +// In your AnimatedEntity implementation +@Override +public AnimationMetadata getAnimationMetadata() { + return new AnimationMetadata( + false // movement: false = freeze entity during animation + ); +} +``` + +### Locators + +Define named positions in your model for spawning particles or effects: + +```json +{ + "bones": [ + { + "name": "right_arm", + "locators": { + "hand": { + "offset": [0, -10, 0], + "rotation": [0, 0, 0] + } + } + } + ] +} +``` + +--- + +## Commands + +### Play Animation Command + +Operators can play animations on entities via command: + +``` +/amblekit animation +``` + +| Argument | Description | +|----------|-------------| +| `target` | Entity selector (e.g., `@e[type=mymod:my_entity,limit=1]`) | +| `animation_id` | Animation identifier (e.g., `mymod:my_entity.attack`) | + +**Examples:** +``` +/amblekit animation @e[type=mymod:my_entity,limit=1] mymod:my_entity.attack +/amblekit animation @s mymod:player.wave +``` + +--- + +## File Locations + +### Resource Pack Structure + +``` +assets// +├── geo/ +│ └── my_entity.geo.json # Model geometry +├── animations/ +│ └── my_entity.animation.json # Animation data +└── textures/ + └── entity/ + └── my_entity.png # Entity texture +``` + +### Automatic Registration + +When using `@HasBedrockModel`: +- The model is loaded from `assets//geo/.geo.json` +- Animations are loaded from `assets//animations/.animation.json` +- Textures should be at `assets//textures/entity/.png` + +--- + +## Advanced Usage + +### Custom Renderers + +For advanced rendering needs, extend `BedrockEntityRenderer`: + +```java +public class MyCustomRenderer extends BedrockEntityRenderer { + public MyCustomRenderer(EntityRendererFactory.Context ctx) { + super(ctx); + } + + @Override + protected void setupAnimations(MyEntity entity, ModelPart root, float tickDelta) { + super.setupAnimations(entity, root, tickDelta); + // Custom animation logic + } +} +``` + +### Animation Tracking + +Query animation state programmatically: + +```java +AnimatedEntity entity = ...; + +// Check if currently animating +BedrockAnimationReference current = entity.getCurrentAnimation(); +if (current != null) { + // Animation is playing +} + +// Check if animation state has changed +if (entity.isAnimationDirty()) { + // Handle animation change +} +``` + +--- + +## See Also + +- [Lua Scripting System](LUA_SCRIPTING.md) - Trigger animations from Lua scripts +- [Registry Containers](README.md#minecraft-registration) - Automatic entity registration diff --git a/GUI_SYSTEM.md b/GUI_SYSTEM.md new file mode 100644 index 0000000..2b2f026 --- /dev/null +++ b/GUI_SYSTEM.md @@ -0,0 +1,728 @@ +# JSON GUI System + +AmbleKit provides a declarative JSON-based GUI system that lets you create Minecraft screens without writing Java code. Define layouts, colors, textures, and interactive buttons entirely in JSON files loaded from resource packs. + +## Table of Contents +- [Getting Started](#getting-started) +- [File Location](#file-location) +- [JSON Structure](#json-structure) +- [Properties Reference](#properties-reference) +- [Background Types](#background-types) +- [Text Elements](#text-elements) +- [Text Input Elements](#text-input-elements) +- [Entity Display Elements](#entity-display-elements) +- [Buttons & Interactivity](#buttons--interactivity) +- [Lua Script Integration](#lua-script-integration) +- [Displaying Screens](#displaying-screens) +- [Complete Example](#complete-example) + +--- + +## Getting Started + +The AmbleKit GUI system allows you to: +- Define screen layouts in JSON files +- Use solid colors or textures as backgrounds +- Create nested container hierarchies +- Add text with automatic word wrapping +- Create interactive buttons with hover/press states +- Attach Lua scripts for dynamic behavior + +--- + +## File Location + +GUI definitions are loaded from resource packs: + +``` +assets//gui/.json +``` + +For example, `assets/mymod/gui/main_menu.json` creates a screen with ID `mymod:main_menu`. + +--- + +## JSON Structure + +Every GUI element shares these core properties: + +```json +{ + "layout": [width, height], + "background": , + "padding": 0, + "spacing": 0, + "alignment": ["centre", "centre"], + "children": [] +} +``` + +--- + +## Properties Reference + +### Layout + +Defines the element's dimensions as `[width, height]`: + +```json +"layout": [200, 150] +``` + +### Padding + +Internal spacing between the element's edge and its children: + +```json +"padding": 10 +``` + +### Spacing + +Gap between child elements: + +```json +"spacing": 5 +``` + +### Alignment + +Controls how children are positioned within the container. Format: `[horizontal, vertical]` + +| Value | Description | +|-------|-------------| +| `"start"` | Align to left/top | +| `"centre"` / `"center"` | Center alignment | +| `"end"` | Align to right/bottom | + +```json +"alignment": ["centre", "centre"] +``` + +### Requires New Row + +Forces this element to start on a new row (for flow layout): + +```json +"requires_new_row": true +``` + +### Should Pause + +Whether the screen pauses the game (singleplayer): + +```json +"should_pause": true +``` + +### ID + +Optional explicit identifier for the element: + +```json +"id": "mymod:my_button" +``` + +--- + +## Background Types + +### Solid Color + +RGB array (0-255), with optional alpha: + +```json +"background": [255, 128, 0] +``` + +With transparency: + +```json +"background": [0, 0, 0, 128] +``` + +### Texture + +Reference a texture file with UV coordinates: + +```json +"background": { + "texture": "mymod:textures/gui/panel.png", + "u": 0, + "v": 0, + "regionWidth": 200, + "regionHeight": 150, + "textureWidth": 256, + "textureHeight": 256 +} +``` + +| Property | Description | +|----------|-------------| +| `texture` | Resource location of the texture | +| `u`, `v` | Top-left corner of the region in the texture | +| `regionWidth`, `regionHeight` | Size of the region to sample | +| `textureWidth`, `textureHeight` | Full dimensions of the texture file | + +--- + +## Text Elements + +Add the `text` property to display text. The text will automatically wrap to fit the container width: + +```json +{ + "layout": [100, 30], + "background": [0, 0, 0, 0], + "text": "gui.mymod.welcome_message" +} +``` + +The `text` value is passed through `Text.translatable()`, so you can use translation keys from your lang files. + +### Text Alignment + +Control text positioning within the element: + +```json +{ + "layout": [100, 30], + "background": [50, 50, 50], + "text": "Hello World", + "text_alignment": ["centre", "centre"] +} +``` + +--- + +## Text Input Elements + +Create interactive text input fields using the `text_input` property. Text inputs support full keyboard navigation, text selection, copy/paste, and horizontal scrolling for long text. + +### Basic Text Input + +```json +{ + "id": "mymod:username_field", + "text_input": true, + "placeholder": "Enter username...", + "layout": [150, 20], + "background": [30, 30, 40] +} +``` + +### Text Input Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `text_input` | boolean | required | Must be `true` to create a text input | +| `placeholder` | string | `""` | Placeholder text shown when empty | +| `max_length` | integer | unlimited | Maximum number of characters allowed | +| `editable` | boolean | `true` | Whether the user can edit the text | +| `text` | string | `""` | Initial text content | +| `text_alignment` | [h, v] | `["start", "centre"]` | Text alignment within the field | + +### Color Customization + +Text inputs support extensive color customization: + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `text_color` | [r,g,b] or [r,g,b,a] | white | Color of the input text | +| `placeholder_color` | [r,g,b] or [r,g,b,a] | gray | Color of placeholder text | +| `selection_color` | [r,g,b,a] | blue | Highlight color for selected text | +| `border_color` | [r,g,b] or [r,g,b,a] | gray | Border color when unfocused | +| `focused_border_color` | [r,g,b] or [r,g,b,a] | light blue | Border color when focused | +| `cursor_color` | [r,g,b] or [r,g,b,a] | white | Color of the text cursor | + +### Styled Text Input Example + +```json +{ + "id": "mymod:styled_input", + "text_input": true, + "placeholder": "Search...", + "max_length": 50, + "layout": [200, 24], + "background": [20, 20, 30], + "border_color": [80, 80, 100], + "focused_border_color": [100, 140, 220], + "selection_color": [80, 120, 200, 128], + "placeholder_color": [100, 100, 120], + "text_color": [255, 255, 255] +} +``` + +### Keyboard Shortcuts + +Text inputs support standard keyboard shortcuts: + +| Shortcut | Action | +|----------|--------| +| `←` / `→` | Move cursor left/right | +| `Ctrl+←` / `Ctrl+→` | Move cursor by word | +| `Shift+←` / `Shift+→` | Select characters | +| `Ctrl+Shift+←` / `Ctrl+Shift+→` | Select words | +| `Home` / `End` | Move to start/end of text | +| `Shift+Home` / `Shift+End` | Select to start/end | +| `Ctrl+A` | Select all text | +| `Ctrl+C` | Copy selected text | +| `Ctrl+X` | Cut selected text | +| `Ctrl+V` | Paste from clipboard | +| `Backspace` | Delete character before cursor | +| `Delete` | Delete character after cursor | +| `Ctrl+Backspace` | Delete word before cursor | +| `Ctrl+Delete` | Delete word after cursor | +| `Tab` | Move focus to next input | +| `Shift+Tab` | Move focus to previous input | + +### Mouse Interactions + +| Action | Result | +|--------|--------| +| Click | Position cursor at click location | +| Click + Drag | Select text range | +| Double-click | Select entire word | +| Shift + Click | Extend selection to click position | + +### Reading Text Input Values in Lua + +```lua +function onClick(self, mouseX, mouseY, button) + local root = getRoot(self) + local usernameInput = findById(root, "mymod:username_field") + + if usernameInput then + local text = usernameInput:getText() + if text and text ~= "" then + -- Use the input value + self:minecraft():sendMessage("You entered: " .. text, false) + end + end +end +``` + +### LuaElement Text Input API + +| Method | Description | +|--------|-------------| +| `self:getText()` | Get the current text content | +| `self:setText(text)` | Set the text content | +| `self:getPlaceholder()` | Get the placeholder text | +| `self:setPlaceholder(text)` | Set the placeholder text | +| `self:getMaxLength()` | Get the maximum length | +| `self:setMaxLength(int)` | Set the maximum length | +| `self:isEditable()` | Check if input is editable | +| `self:setEditable(bool)` | Enable/disable editing | +| `self:isInputFocused()` | Check if input has focus | +| `self:setInputFocused(bool)` | Set focus state | +| `self:getSelectionStart()` | Get selection start index | +| `self:getSelectionEnd()` | Get selection end index | +| `self:setSelection(start, end)` | Set selection range | +| `self:selectAll()` | Select all text | +| `self:setSelectionColor(r,g,b,a)` | Set selection highlight color | +| `self:setBorderColor(r,g,b,a)` | Set border color | +| `self:setFocusedBorderColor(r,g,b,a)` | Set focused border color | +| `self:setTextColor(r,g,b,a)` | Set text color | +| `self:setPlaceholderColor(r,g,b,a)` | Set placeholder color | + +--- + +## Entity Display Elements + +Display living entities within a GUI element using the `entity_uuid` property. This renders the entity in an inventory-screen style, perfect for character viewers, mob displays, or player previews. + +### Basic Entity Display + +```json +{ + "layout": [60, 80], + "background": [40, 40, 60, 200], + "entity_uuid": "player" +} +``` + +The special value `"player"` automatically uses the local player's UUID. + +### Entity Display Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `entity_uuid` | string | required | Entity UUID or `"player"` for local player | +| `follow_cursor` | boolean | `false` | Entity rotates to follow the mouse cursor | +| `look_at` | [x, y] | center | Fixed position the entity looks at (relative to element) | +| `entity_scale` | float | `1.0` | Scale multiplier for entity rendering | + +### Follow Cursor Mode + +Make the entity rotate to track the mouse cursor: + +```json +{ + "layout": [60, 80], + "background": [40, 40, 60], + "entity_uuid": "player", + "follow_cursor": true, + "entity_scale": 0.9 +} +``` + +### Fixed Look-At Position + +Set a specific point the entity should look at: + +```json +{ + "layout": [60, 80], + "background": [60, 40, 40], + "entity_uuid": "", + "follow_cursor": false, + "look_at": [30, 20] +} +``` + +Coordinates are relative to the element's top-left corner. + +### Dynamic Entity Display + +Set the entity UUID dynamically via Lua scripts: + +```json +{ + "id": "mymod:mob_display", + "layout": [60, 80], + "background": [50, 50, 50], + "entity_uuid": "" +} +``` + +```lua +function onDisplay(self) + local mc = self:minecraft() + local nearest = mc:nearestEntity(20) + + -- Find the entity display by ID + local display = findChildById(root, "mymod:mob_display") + if display and nearest then + display:setEntityUuid(nearest:uuid()) + end +end +``` + +### Notes + +- Only `LivingEntity` types (players, mobs, animals) can be rendered +- Non-living entities or invalid UUIDs display "N/A" +- The entity is looked up each render frame using a cached approach for efficiency + +--- + +## Buttons & Interactivity + +Adding any of these properties converts an element into a button: +- `script` - Attach a Lua script +- `on_click` - Run a command on click +- `hover_background` - Background when hovered +- `press_background` - Background when pressed + +When a button has a `text` property, a child text element is automatically created with a transparent background, so you can define text directly on buttons without manually creating children. + +### Basic Button + +```json +{ + "layout": [80, 24], + "background": [100, 100, 100], + "hover_background": [150, 150, 150], + "press_background": [50, 50, 50], + "text": "Click Me", + "on_click": "/say Button clicked!" +} +``` + +### Button with Text Alignment + +```json +{ + "layout": [120, 30], + "background": [80, 80, 80], + "hover_background": [100, 100, 100], + "text": "gui.mymod.button_label", + "text_alignment": ["centre", "centre"], + "script": "mymod:my_handler" +} +``` + +### Command Execution + +The `on_click` property runs a command when clicked: + +```json +"on_click": "/gamemode creative" +``` + +Commands must start with `/` and are executed as the local player. + +--- + +## Lua Script Integration + +For dynamic behavior, attach Lua scripts to buttons. This is where the GUI system integrates with AmbleKit's [Lua Scripting System](LUA_SCRIPTING.md). + +### Attaching a Script + +Reference a script by its ID (without the `script/` prefix or `.lua` suffix): + +```json +{ + "layout": [80, 24], + "background": [0, 200, 0], + "hover_background": [0, 255, 0], + "press_background": [0, 150, 0], + "script": "mymod:button_handler" +} +``` + +This loads `assets/mymod/script/button_handler.lua`. + +### GUI Script Callbacks + +GUI scripts use different callbacks than standalone scripts. They receive a `self` (LuaElement) parameter: + +| Callback | When Called | Parameters | +|----------|-------------|------------| +| `onAttached(self)` | When script is attached during JSON parsing (GUI tree not yet built) | `self` | +| `onDisplay(self)` | On first render when GUI tree is fully built | `self` | +| `onClick(self, mouseX, mouseY, button)` | Mouse button pressed | `self`, coordinates, button (0=left, 1=right) | +| `onRelease(self, mouseX, mouseY, button)` | Mouse button released | `self`, coordinates, button | +| `onHover(self, mouseX, mouseY)` | Mouse hovering over element | `self`, coordinates | + +**Note:** Use `onDisplay` for operations that require traversing the GUI tree (finding elements by ID, accessing parent/children). Use `onAttached` only for early setup that doesn't depend on other elements. + +### LuaElement API + +The `self` parameter provides access to the GUI element: + +| Method | Description | +|--------|-------------| +| `self:id()` | Element's identifier (as string) | +| `self:x()`, `self:y()` | Current position | +| `self:width()`, `self:height()` | Current dimensions | +| `self:setPosition(x, y)` | Update position | +| `self:setDimensions(w, h)` | Update size | +| `self:setVisible(bool)` | Show/hide element | +| `self:parent()` | Parent LuaElement (or nil) | +| `self:child(index)` | Get child at index (0-based) | +| `self:childCount()` | Number of children | +| `self:getText()` | Get text content (text/text input elements) | +| `self:setText(text)` | Set text content (text/text input elements) | +| `self:closeScreen()` | Close the current screen | +| `self:minecraft()` | Get MinecraftData for world/player access | + +#### Text Input Methods + +These methods only work on `AmbleTextInput` elements: + +| Method | Description | +|--------|-------------| +| `self:getPlaceholder()` | Get placeholder text | +| `self:setPlaceholder(text)` | Set placeholder text | +| `self:getMaxLength()` | Get maximum character limit | +| `self:setMaxLength(int)` | Set maximum character limit | +| `self:isEditable()` | Check if input is editable | +| `self:setEditable(bool)` | Enable/disable editing | +| `self:isInputFocused()` | Check if input has focus | +| `self:setInputFocused(bool)` | Set focus state | +| `self:getSelectionStart()` | Get selection start index | +| `self:getSelectionEnd()` | Get selection end index | +| `self:setSelection(start, end)` | Set selection range | +| `self:selectAll()` | Select all text | +| `self:setSelectionColor(r,g,b,a)` | Set selection highlight color | +| `self:setBorderColor(r,g,b,a)` | Set unfocused border color | +| `self:setFocusedBorderColor(r,g,b,a)` | Set focused border color | +| `self:setTextColor(r,g,b,a)` | Set text color | +| `self:setPlaceholderColor(r,g,b,a)` | Set placeholder text color | + +#### Entity Display Methods + +These methods only work on `AmbleEntityDisplay` elements: + +| Method | Description | +|--------|-------------| +| `self:getEntityUuid()` | Get entity UUID as string (or nil) | +| `self:setEntityUuid(uuid)` | Set entity UUID (string or "player") | +| `self:isFollowCursor()` | Check if entity follows cursor | +| `self:setFollowCursor(bool)` | Enable/disable cursor following | +| `self:setLookAt(x, y)` | Set fixed look-at position | +| `self:setEntityScale(scale)` | Set entity scale multiplier | + +### Example GUI Script + +```lua +-- assets/mymod/script/button_handler.lua + +local clickCount = 0 + +function onDisplay(self) + -- Called when the GUI is first displayed (tree is built) + print("Button displayed: " .. self:id()) +end + +function onClick(self, mouseX, mouseY, button) + clickCount = clickCount + 1 + + -- Update button text + for i = 0, self:childCount() - 1 do + local child = self:child(i) + if child:getText() then + child:setText("Clicks: " .. clickCount) + break + end + end + + -- Access Minecraft data + local mc = self:minecraft() + mc:sendMessage("Button clicked " .. clickCount .. " times!", false) + + -- Play a sound + mc:playSound("minecraft:ui.button.click", 1.0, 1.0) +end + +function onHover(self, mouseX, mouseY) + -- Called every frame while hovering +end + +function onRelease(self, mouseX, mouseY, button) + -- Called when mouse button is released +end +``` + +### Accessing World Data from GUI + +Use `self:minecraft()` to access the full [Minecraft API](LUA_SCRIPTING.md#minecraft-api-reference): + +```lua +function onClick(self, mouseX, mouseY, button) + local mc = self:minecraft() + + -- Get player info + local player = mc:player() + local health = player:health() + + -- Check input + if mc:isKeyPressed("sneak") then + mc:sendMessage("Shift-clicked!", false) + end + + -- Run commands + mc:runCommand("/time set day") + + -- Close the screen + self:closeScreen() +end +``` + +--- + +## Displaying Screens + +### From Lua Scripts + +Use `mc:displayScreen(screenId)` to open a registered GUI: + +```lua +function onExecute(mc) + mc:displayScreen("mymod:main_menu") +end +``` + +### From Java + +```java +AmbleContainer screen = AmbleGuiRegistry.getInstance().get(new Identifier("mymod", "main_menu")); +if (screen != null) { + screen.display(); +} +``` + +--- + +## Complete Example + +### GUI Definition + +`assets/mymod/gui/example_menu.json`: + +```json +{ + "layout": [200, 150], + "background": { + "texture": "mymod:textures/gui/menu_bg.png", + "u": 0, "v": 0, + "regionWidth": 200, "regionHeight": 150, + "textureWidth": 256, "textureHeight": 256 + }, + "padding": 15, + "spacing": 8, + "alignment": ["centre", "start"], + "should_pause": true, + "children": [ + { + "layout": [170, 20], + "background": [0, 0, 0, 0], + "text": "gui.mymod.title", + "text_alignment": ["centre", "centre"] + }, + { + "layout": [120, 24], + "background": [80, 80, 80], + "hover_background": [100, 100, 100], + "press_background": [60, 60, 60], + "text": "gui.mymod.play", + "script": "mymod:play_button", + "requires_new_row": true + }, + { + "layout": [120, 24], + "background": [80, 80, 80], + "hover_background": [100, 100, 100], + "press_background": [60, 60, 60], + "text": "gui.mymod.quit", + "on_click": "/quit", + "requires_new_row": true + } + ] +} +``` + +### Attached Lua Script + +`assets/mymod/script/play_button.lua`: + +```lua +function onClick(self, mouseX, mouseY, button) + local mc = self:minecraft() + mc:sendMessage("Starting game...", false) + mc:playSound("minecraft:ui.button.click", 1.0, 1.0) + self:closeScreen() +end +``` + +### Opening the Screen + +`assets/mymod/script/open_menu.lua`: + +```lua +-- Run with: /amblescript execute mymod:open_menu + +function onExecute(mc) + mc:displayScreen("mymod:example_menu") +end +``` + +--- + +## See Also + +- [Lua Scripting System](LUA_SCRIPTING.md) - Full Lua API documentation +- [AmbleGuiRegistry](src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java) - Java registry source diff --git a/LUA_SCRIPTING.md b/LUA_SCRIPTING.md new file mode 100644 index 0000000..b4da54d --- /dev/null +++ b/LUA_SCRIPTING.md @@ -0,0 +1,715 @@ +# Lua Scripting System + +AmbleKit includes a powerful Lua scripting engine (powered by LuaJ) that allows you to extend Minecraft functionality without writing Java code. Scripts can run on both the client and server sides. + +## Table of Contents +- [Script Types & Locations](#script-types--locations) +- [Commands](#commands) +- [Lifecycle Callbacks](#lifecycle-callbacks) +- [Minecraft API Reference](#minecraft-api-reference) +- [Entity API](#entity-api) +- [ItemStack API](#itemstack-api) +- [Example Scripts](#example-scripts) +- [GUI Integration](#gui-integration) + +--- + +## Script Types & Locations + +| Type | Location | Loaded From | +|------|----------|-------------| +| **Client Scripts** | `assets//script/*.lua` | Resource Packs | +| **Server Scripts** | `data//script/*.lua` | Data Packs | + +--- + +## Commands + +### Client-side (available to all players) +``` +/amblescript execute [args...] - Run a script's onExecute function with optional arguments +/amblescript enable - Enable a script (starts onTick loop) +/amblescript disable - Disable a running script +/amblescript toggle - Toggle script enabled state +/amblescript list - Show enabled scripts +/amblescript available - Show all available scripts +``` + +### Server-side (requires operator permissions) +``` +/serverscript execute [args...] - Run a script's onExecute function with optional arguments +/serverscript enable - Enable a script (starts onTick loop) +/serverscript disable - Disable a running script +/serverscript toggle - Toggle script enabled state +/serverscript list - Show enabled scripts +/serverscript available - Show all available scripts +``` + +### Command Arguments + +The `execute` command accepts optional space-separated arguments that are passed to the script's `onExecute` function as a Lua table: + +``` +/serverscript execute mymod:my_script arg1 arg2 arg3 +``` + +In the script, access arguments via the second parameter: + +```lua +function onExecute(mc, args) + if args[1] then + mc:sendMessage("First argument: " .. args[1], false) + end +end +``` + +--- + +## Lifecycle Callbacks + +Scripts can define the following callback functions. Each receives a `mc` (MinecraftData) parameter: + +| Callback | When Called | Use Case | +|----------|-------------|----------| +| `onRegister(mc)` | When script is loaded into the ScriptManager | Early initialization, setup globals | +| `onExecute(mc, args)` | Via `/amblescript execute` or `/serverscript execute` | One-time actions, parameterized commands | +| `onEnable(mc)` | When script is enabled | Initialize state, play sounds | +| `onTick(mc)` | Every game tick while enabled | Continuous monitoring, automation | +| `onDisable(mc)` | When script is disabled | Cleanup, final messages | + +The `args` parameter in `onExecute` is a Lua table containing space-separated arguments from the command (1-indexed, may be empty). + +**Note:** `onRegister` is called once when the script is first loaded (during resource pack loading). Use it for one-time setup that doesn't require the game to be fully loaded. + +--- + +## Minecraft API Reference + +The `mc` parameter provides access to Minecraft data. Methods vary by side: + +### Shared Methods (Client & Server) +| Method | Description | +|--------|-------------| +| `mc:isClientSide()` | Returns true if running on client | +| `mc:dimension()` | Current dimension ID (e.g., "minecraft:overworld") | +| `mc:worldTime()` | Current world time in ticks | +| `mc:dayCount()` | Number of days elapsed | +| `mc:isRaining()` / `mc:isThundering()` | Weather state | +| `mc:biomeAt(x, y, z)` | Biome ID at position | +| `mc:blockAt(x, y, z)` | Block ID at position | +| `mc:lightLevelAt(x, y, z)` | Light level at position | +| `mc:player()` | The executing player entity | +| `mc:entities()` | All entities in the world | +| `mc:nearestEntity(distance)` | Closest entity within range | +| `mc:entitiesInRadius(radius)` | All entities within radius | +| `mc:runCommand(command)` | Execute a command | +| `mc:sendMessage(text, overlay)` | Send message to player (overlay = action bar) | +| `mc:log(message)` | Log to console | +| `mc:callScript(scriptId, funcName, ...)` | Call a function from another script | +| `mc:getScriptGlobal(scriptId, varName)` | Get a global variable from another script | +| `mc:setScriptGlobal(scriptId, varName, value)` | Set a global variable in another script | +| `mc:availableScripts()` | Get list of all available script identifiers | + +### Client-Only Methods +| Method | Description | +|--------|-------------| +| `mc:username()` | Local player's username | +| `mc:selectedSlot()` | Currently selected hotbar slot (1-9) | +| `mc:selectSlot(slot)` | Set hotbar selection | +| `mc:dropStack(slot, entireStack)` | Drop item from inventory | +| `mc:isKeyPressed(key)` | Check if key is pressed ("forward", "jump", "w", etc.) | +| `mc:isMouseButtonPressed(button)` | Check mouse button ("left", "right", "middle") | +| `mc:gameMode()` | Current game mode | +| `mc:playSound(id, volume, pitch)` | Play a sound | +| `mc:lookingAtEntity()` | Entity in crosshairs (or nil) | +| `mc:lookingAtBlock()` | Block position in crosshairs (or nil) | +| `mc:clipboard()` / `mc:setClipboard(text)` | System clipboard access | +| `mc:displayScreen(screenId)` | Open a registered AmbleKit screen | +| `mc:closeScreen()` | Close current screen | + +### Server-Only Methods +| Method | Description | +|--------|-------------| +| `mc:allPlayers()` | List of all online player entities | +| `mc:allPlayerNames()` | List of all online player names | +| `mc:playerCount()` / `mc:maxPlayers()` | Player counts | +| `mc:getPlayerByName(name)` | Get player entity by name | +| `mc:broadcast(message)` | Send message to all players | +| `mc:broadcastToPlayer(name, msg, overlay)` | Send message to specific player | +| `mc:serverName()` | Server name | +| `mc:serverTps()` | Current server TPS | +| `mc:tickCount()` | Total server ticks | +| `mc:isDedicatedServer()` | True if dedicated server | +| `mc:runCommandAs(playerName, command)` | Run command as specific player | + +### Skin Management (Server-Only) + +All skin methods return `true` on success, `false` on failure. + +| Method | Description | +|--------|-------------| +| `mc:setSkin(playerName, skinUsername)` | Set player's skin to another player's skin | +| `mc:setSkinUrl(playerName, url, slim)` | Set player's skin from URL (slim: true/false) | +| `mc:setSkinSlim(playerName, slim)` | Change arm model without changing texture | +| `mc:clearSkin(playerName)` | Remove custom skin, restore original | +| `mc:hasSkin(playerName)` | Check if player has a custom skin | +| `mc:setSkinByUuid(uuid, skinUsername)` | Set skin by UUID string | +| `mc:setSkinUrlByUuid(uuid, url, slim)` | Set skin from URL by UUID string | +| `mc:clearSkinByUuid(uuid)` | Clear skin by UUID string | +| `mc:hasSkinByUuid(uuid)` | Check if entity has custom skin by UUID | + +--- + +## Entity API + +When you get an entity via `mc:player()`, `mc:entities()`, etc., you can call: + +| Method | Description | +|--------|-------------| +| `entity:name()` | Display name | +| `entity:type()` | Entity type ID (e.g., "minecraft:player") | +| `entity:uuid()` | Entity UUID | +| `entity:isPlayer()` | True if player | +| `entity:position()` | Vec3d with x, y, z fields | +| `entity:blockPosition()` | BlockPos with x, y, z fields | +| `entity:health()` / `entity:maxHealth()` | Health values | +| `entity:velocity()` | Current velocity vector | +| `entity:yaw()` / `entity:pitch()` | Rotation | +| `entity:isAlive()` / `entity:isSneaking()` / `entity:isSprinting()` | State checks | +| `entity:isOnFire()` / `entity:isInvisible()` / `entity:isTouchingWater()` | Condition checks | +| `entity:inventory()` | List of ItemStacks | +| `entity:foodLevel()` / `entity:saturation()` | Hunger (players only) | +| `entity:experienceLevel()` / `entity:totalExperience()` | XP (players only) | +| `entity:effects()` | List of active effect IDs | +| `entity:hasEffect(effectId)` | Check for specific effect | +| `entity:armorValue()` | Total armor points | + +--- + +## ItemStack API + +ItemStacks from inventories provide: + +| Method | Description | +|--------|-------------| +| `item:id()` | Item ID (e.g., "minecraft:diamond_sword") | +| `item:name()` | Display name | +| `item:count()` / `item:maxCount()` | Stack counts | +| `item:damage()` / `item:maxDamage()` | Durability | +| `item:durabilityPercent()` | Remaining durability (0.0 - 1.0) | +| `item:isEmpty()` / `item:isStackable()` | Stack properties | +| `item:hasEnchantments()` | Has enchantments | +| `item:enchantments()` | List of "enchant_id:level" strings | +| `item:rarity()` | Item rarity | +| `item:isFood()` | Is food item | +| `item:hasNbt()` / `item:nbt()` | NBT data access | + +--- + +## Example Scripts + +### Script with Arguments +Set a player's skin with command arguments: + +```lua +-- data/mymod/script/skin_set.lua +-- Usage: +-- /serverscript execute mymod:skin_set Notch true +-- /serverscript execute mymod:skin_set Notch false duzo + +function onExecute(mc, args) + -- Validate arguments + if args == nil or #args < 2 then + mc:sendMessage("§cUsage: /serverscript execute mymod:skin_set [target_player]", false) + return + end + + local skinUsername = args[1] + local slim = args[2] == "true" + local targetPlayer = args[3] + + -- If no target player specified, use the executing player + if targetPlayer == nil then + local player = mc:player() + if player == nil then + mc:sendMessage("§cNo player context!", false) + return + end + targetPlayer = player:name() + end + + -- Apply the skin + if mc:setSkin(targetPlayer, skinUsername) then + mc:sendMessage("§aSkin applied to " .. targetPlayer .. "!", false) + mc:setSkinSlim(targetPlayer, slim) + else + mc:sendMessage("§cFailed to apply skin!", false) + end +end +``` + +### Simple Execute Script +Display world info on command: + +```lua +-- assets/mymod/script/world_info.lua +-- Run with: /amblescript execute mymod:world_info + +function onExecute(mc, args) + local player = mc:player() + local pos = player:blockPosition() + + mc:sendMessage("§6=== World Info ===", false) + mc:sendMessage("§7Dimension: §a" .. mc:dimension(), false) + mc:sendMessage("§7Day: §e" .. mc:dayCount(), false) + mc:sendMessage("§7Biome: §b" .. mc:biomeAt(pos.x, pos.y, pos.z), false) + mc:sendMessage("§7Light: §f" .. mc:lightLevelAt(pos.x, pos.y, pos.z), false) +end +``` + +### Tick-Based Script +Continuous monitoring with enable/disable: + +```lua +-- assets/mymod/script/health_monitor.lua +-- Enable with: /amblescript enable mymod:health_monitor +-- Disable with: /amblescript disable mymod:health_monitor + +local lastHealth = 0 + +function onEnable(mc) + lastHealth = mc:player():health() + mc:sendMessage("§aHealth monitor enabled!", false) +end + +function onTick(mc) + local health = mc:player():health() + if health < lastHealth then + mc:sendMessage("§cDamage taken! Health: " .. health, true) + if mc:isClientSide() then + mc:playSound("minecraft:entity.player.hurt", 0.5, 1.0) + end + end + lastHealth = health +end + +function onDisable(mc) + mc:sendMessage("§7Health monitor disabled.", false) +end +``` + +### Server Script +Admin broadcast utility: + +```lua +-- data/mymod/script/server_status.lua +-- Run with: /serverscript execute mymod:server_status + +function onExecute(mc, args) + local playerCount = mc:playerCount() + local maxPlayers = mc:maxPlayers() + local tps = string.format("%.1f", mc:serverTps()) + + mc:broadcast("§6[Server] §fPlayers: §e" .. playerCount .. "/" .. maxPlayers) + mc:broadcast("§6[Server] §fTPS: §a" .. tps) + mc:log("Server status broadcast by admin") +end +``` + +### Skin Management Script +Change player skins on the server: + +```lua +-- data/mymod/script/disguise.lua +-- Run with: /serverscript execute mymod:disguise +-- Or with args: /serverscript execute mymod:disguise Herobrine + +function onExecute(mc, args) + local player = mc:player() + if player == nil then + mc:log("No player context for this script") + return + end + + local playerName = player:name() + local skinName = args[1] or "Notch" -- Default to Notch if no argument provided + + -- Set the player's skin to look like the specified user (returns true/false) + if mc:setSkin(playerName, skinName) then + mc:broadcastToPlayer(playerName, "§aYou are now disguised as " .. skinName .. "!", false) + else + mc:broadcastToPlayer(playerName, "§cFailed to apply disguise!", false) + end +end + +-- Example: Disguise all players as the same skin +function disguiseAll(mc, skinUsername) + local success = 0 + for _, playerName in pairs(mc:allPlayerNames()) do + if mc:setSkin(playerName, skinUsername) then + success = success + 1 + end + end + mc:broadcast("§eDisguised " .. success .. " players!") +end + +-- Example: Clear all disguises +function clearAllDisguises(mc) + for _, playerName in pairs(mc:allPlayerNames()) do + if mc:hasSkin(playerName) then + mc:clearSkin(playerName) + end + end + mc:broadcast("§7All disguises have been removed.") +end +``` + +### Skin from URL Example +Apply custom skins from URLs: + +```lua +-- data/mymod/script/custom_skin.lua +-- Run with: /serverscript execute mymod:custom_skin [slim] +-- Example: /serverscript execute mymod:custom_skin https://example.com/skin.png true + +function onExecute(mc, args) + local player = mc:player() + if player == nil then return end + + local playerName = player:name() + local skinUrl = args[1] or "https://example.com/skins/custom_skin.png" + local slim = args[2] == "true" + + -- Set skin from URL with slim (Alex-style) arms + if mc:setSkinUrl(playerName, skinUrl, slim) then + mc:broadcastToPlayer(playerName, "§aCustom skin applied!", false) + else + mc:broadcastToPlayer(playerName, "§cFailed to apply skin!", false) + end +end + +-- Toggle between slim and wide arm models +function toggleSlimArms(mc) + local player = mc:player() + if player == nil then return end + + local playerName = player:name() + + if mc:hasSkin(playerName) then + if mc:setSkinSlim(playerName, true) then + mc:broadcastToPlayer(playerName, "§7Switched to slim arms.", false) + end + else + mc:broadcastToPlayer(playerName, "§cYou don't have a custom skin!", false) + end +end +``` + +### UUID-Based Skin Management +Use UUIDs directly for non-player entities or stored references: + +```lua +-- data/mymod/script/npc_skins.lua +-- Run with: /serverscript execute mymod:npc_skins [skin_username] + +function onExecute(mc, args) + local player = mc:player() + if player == nil then return end + + -- Get the player's UUID + local uuid = player:uuid() + local skinName = args[1] or "Herobrine" + + -- Set skin using UUID string + if mc:setSkinByUuid(uuid, skinName) then + mc:sendMessage("§cSkin applied via UUID!", false) + end +end + +-- Apply skin to a stored NPC UUID +function applyNpcSkin(mc) + local npcUuid = "550e8400-e29b-41d4-a716-446655440000" -- example UUID + + if mc:setSkinUrlByUuid(npcUuid, "https://example.com/npc_skin.png", false) then + mc:log("NPC skin updated successfully") + else + mc:logWarn("Failed to update NPC skin") + end +end +``` + +### Cross-Script Function Calling + +Scripts can call functions defined in other scripts on the same side. Client scripts can call other client scripts, and server scripts can call other server scripts. + +**Utility Library Script:** +```lua +-- assets/mymod/script/utils.lua (or data/mymod/script/utils.lua for server) +-- A reusable utility library + +-- Format a number with commas (e.g., 1234567 -> "1,234,567") +function formatNumber(num) + local formatted = tostring(num) + local k + while true do + formatted, k = formatted:gsub("^(-?%d+)(%d%d%d)", '%1,%2') + if k == 0 then break end + end + return formatted +end + +-- Calculate distance between two positions +function distance(pos1, pos2) + local dx = pos2.x - pos1.x + local dy = pos2.y - pos1.y + local dz = pos2.z - pos1.z + return math.sqrt(dx*dx + dy*dy + dz*dz) +end + +-- Shared state for other scripts +sharedData = { + lastUpdate = 0, + counter = 0 +} +``` + +**Script Using the Utility Library:** +```lua +-- assets/mymod/script/stats_display.lua +-- Uses utility functions from the utils script + +function onExecute(mc) + local player = mc:player() + local pos = player:position() + + -- Call formatNumber from the utils script + local healthFormatted = mc:callScript("mymod:utils", "formatNumber", math.floor(player:health())) + mc:sendMessage("§aHealth: §f" .. healthFormatted, false) + + -- Read shared state from another script + local sharedData = mc:getScriptGlobal("mymod:utils", "sharedData") + if sharedData then + mc:sendMessage("§7Counter: " .. tostring(sharedData.counter), false) + end + + -- Update shared state in another script + mc:setScriptGlobal("mymod:utils", "sharedData", { + lastUpdate = mc:worldTime(), + counter = (sharedData and sharedData.counter or 0) + 1 + }) +end +``` + +**Server Script Calling Other Server Scripts:** +```lua +-- data/mymod/script/admin_tools.lua +-- Reusable admin utilities + +function warnPlayer(playerName, reason) + return "§c[WARNING] §f" .. reason +end + +function kickMessage(playerName) + return "You have been kicked by an administrator." +end +``` + +```lua +-- data/mymod/script/moderation.lua +-- Uses admin_tools for moderation actions + +function onExecute(mc, args) + if args[1] == nil then + mc:sendMessage("§cUsage: /serverscript execute mymod:moderation [reason]", false) + return + end + + local targetPlayer = args[1] + local reason = args[2] or "No reason provided" + + -- Call warnPlayer from admin_tools + local warning = mc:callScript("mymod:admin_tools", "warnPlayer", targetPlayer, reason) + mc:broadcastToPlayer(targetPlayer, warning, false) + mc:sendMessage("§aWarned " .. targetPlayer, false) +end +``` + +**Listing Available Scripts:** +```lua +-- assets/mymod/script/script_browser.lua +-- List all available scripts + +function onExecute(mc) + local scripts = mc:availableScripts() + + mc:sendMessage("§6=== Available Scripts ===", false) + for i, scriptId in ipairs(scripts) do + mc:sendMessage("§7" .. i .. ". §f" .. scriptId, false) + end + mc:sendMessage("§7Total: §e" .. #scripts .. " scripts", false) +end +``` + +--- + +## GUI Integration + +Lua scripts integrate with AmbleKit's [JSON GUI System](GUI_SYSTEM.md) in two ways: + +1. **Opening screens** from scripts using `mc:displayScreen()` +2. **Handling GUI events** by attaching scripts to buttons + +### Opening GUI Screens from Scripts + +Use `mc:displayScreen(screenId)` to open any registered AmbleKit GUI: + +```lua +-- assets/mymod/script/open_menu.lua + +function onExecute(mc) + -- Open a GUI defined in assets/mymod/gui/my_menu.json + mc:displayScreen("mymod:my_menu") +end +``` + +### Attaching Scripts to GUI Elements + +In your JSON GUI definition, use the `script` property to attach a Lua script to a button: + +```json +{ + "layout": [80, 24], + "background": [100, 100, 100], + "hover_background": [150, 150, 150], + "script": "mymod:my_button_handler" +} +``` + +This loads `assets/mymod/script/my_button_handler.lua`. + +### GUI Callbacks + +GUI scripts use different callbacks than standalone scripts. Instead of `mc`, they receive `self` (a LuaElement wrapper): + +| Callback | When Called | Parameters | +|----------|-------------|------------| +| `onAttached(self)` | When script is attached during JSON parsing (GUI tree not built yet) | `self` | +| `onDisplay(self)` | On first render when GUI tree is fully built | `self` | +| `onClick(self, mouseX, mouseY, button)` | Mouse pressed on element | `self`, coordinates, button (0=left) | +| `onRelease(self, mouseX, mouseY, button)` | Mouse released on element | `self`, coordinates, button | +| `onHover(self, mouseX, mouseY)` | Mouse hovering over element | `self`, coordinates | + +**Note:** Use `onDisplay` for operations that require traversing the GUI tree (finding elements by ID, accessing parent/children). Use `onAttached` only for early setup that doesn't depend on other elements. + +### LuaElement API + +The `self` parameter provides access to the GUI element: + +| Method | Description | +|--------|-------------| +| `self:id()` | Element's identifier (as string) | +| `self:x()`, `self:y()` | Current position | +| `self:width()`, `self:height()` | Current dimensions | +| `self:setPosition(x, y)` | Update position | +| `self:setDimensions(w, h)` | Update size | +| `self:setVisible(bool)` | Show/hide element | +| `self:parent()` | Parent LuaElement (or nil) | +| `self:child(index)` | Get child at index (0-based) | +| `self:childCount()` | Number of children | +| `self:findFirstText()` | Find first text element in tree (or nil) | +| `self:getText()` | Get text content (text elements only) | +| `self:setText(text)` | Set text content (text elements only) | +| `self:closeScreen()` | Close the current screen | +| `self:minecraft()` | Get MinecraftData for full API access | + +#### Entity Display Methods + +These methods only work on `AmbleEntityDisplay` elements (elements with `entity_uuid` property): + +| Method | Description | +|--------|-------------| +| `self:getEntityUuid()` | Get entity UUID as string (or nil) | +| `self:setEntityUuid(uuid)` | Set entity UUID (string or "player") | +| `self:isFollowCursor()` | Check if entity follows cursor | +| `self:setFollowCursor(bool)` | Enable/disable cursor following | +| `self:setLookAt(x, y)` | Set fixed look-at position (relative to element) | +| `self:setEntityScale(scale)` | Set entity scale multiplier | + +### Accessing Minecraft Data from GUI Scripts + +Use `self:minecraft()` to get a MinecraftData object with full API access: + +```lua +function onClick(self, mouseX, mouseY, button) + local mc = self:minecraft() + + -- Access player data + local player = mc:player() + local health = player:health() + + -- Play sounds + mc:playSound("minecraft:ui.button.click", 1.0, 1.0) + + -- Send messages + mc:sendMessage("Health: " .. health, false) + + -- Check input + if mc:isKeyPressed("sneak") then + mc:sendMessage("Shift-click detected!", false) + end +end +``` + +### Complete GUI Script Example + +```lua +-- assets/mymod/script/inventory_button.lua +-- Attached to a button in a JSON GUI + +local clickCount = 0 + +function onDisplay(self) + -- Called when the GUI is first displayed (tree is fully built) + print("Button displayed: " .. self:id()) +end + +function onClick(self, mouseX, mouseY, button) + clickCount = clickCount + 1 + local mc = self:minecraft() + + -- Update the first text element found in this element's tree + local textElement = self:findFirstText() + if textElement then + textElement:setText("Clicked: " .. clickCount) + end + + -- Show player inventory info + local player = mc:player() + local inventory = player:inventory() + local itemCount = 0 + + for _, item in pairs(inventory) do + if not item:isEmpty() then + itemCount = itemCount + item:count() + end + end + + mc:sendMessage("You have " .. itemCount .. " items!", false) + mc:playSound("minecraft:ui.button.click", 1.0, 1.0) +end + +function onHover(self, mouseX, mouseY) + -- Called every frame while hovering (use sparingly) +end + +function onRelease(self, mouseX, mouseY, button) + -- Called when mouse button is released over element +end +``` + +--- + +## See Also + +- [JSON GUI System](GUI_SYSTEM.md) - Full GUI definition documentation +- [Dynamic Skin System](SKIN_SYSTEM.md) - Skin commands, Java API, and persistence diff --git a/README.md b/README.md index 2c4e71e..427795b 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,80 @@ By simply creating an instance of `AmbleLanguageProvider` and passing in your `B There are more datagen utilities akin to this. +### Bedrock Animation System + +Use Blockbench models and animations in your Fabric mods! AmbleKit supports the Bedrock Edition geometry and animation JSON formats, making it easy to import complex animated models. Features include: + +- **Bedrock Model Support** - Load `.geo.json` models directly from Blockbench +- **Bedrock Animations** - Full keyframe animation support with looping and one-shot modes +- **Sound Integration** - Play sounds at specific animation keyframes +- **Automatic Registration** - Use `@HasBedrockModel` annotation for zero-config setup +- **Commands** - Play animations via `/amblekit animation` command + +**[Read the full Animation System documentation](ANIMATION_SYSTEM.md)** + +### Dynamic Skin System + +Change player and entity skins at runtime! Perfect for NPCs, disguises, and roleplay servers: + +- **Multiple Sources** - Load skins by username or direct URL +- **Slim/Wide Support** - Both Alex and Steve arm models +- **Automatic Sync** - Skins sync to all clients automatically +- **Persistent Storage** - Skins persist across server restarts +- **Commands** - Manage skins via `/amblekit skin` command + +**[Read the full Skin System documentation](SKIN_SYSTEM.md)** + +### Lua Scripting System + +Extend Minecraft with Lua scripts - no Java required! AmbleKit's built-in scripting engine lets you create client-side automation, server utilities, and GUI interactions using simple Lua code. Scripts load from resource packs (client) or data packs (server) and have full access to player data, world info, entities, inventories, and more. + +**[Read the full Lua Scripting documentation](LUA_SCRIPTING.md)** + +### JSON GUI System + +Build custom Minecraft screens entirely in JSON - no Java required! Define layouts, backgrounds (colors or textures), text elements, and interactive buttons with hover/press states. Attach Lua scripts to buttons for dynamic behavior like updating text, playing sounds, or accessing player data. GUIs load from resource packs and can be opened via Lua scripts or Java code. + +**[Read the full GUI System documentation](GUI_SYSTEM.md)** + +### Block Behavior System + +Build modular, composable blocks with reusable behaviors: + +- **Horizontal Facing** - Easy directional block placement +- **Block Entities** - Simplified block entity integration +- **Render Behaviors** - Control block rendering (invisible blocks, etc.) +- **Composable Design** - Mix and match behaviors as needed + +### Extended Registry Containers + +Beyond just blocks and items, register any Minecraft content type: + +| Container | Description | +|-----------|-------------| +| `BlockContainer` | Blocks with automatic BlockItem registration | +| `ItemContainer` | Standalone items | +| `EntityContainer` | Entity types with automatic bedrock renderer support | +| `BlockEntityContainer` | Block entity types | +| `SoundContainer` | Sound events | +| `FluidContainer` | Fluid types | +| `PaintingContainer` | Painting variants | +| `ItemGroupContainer` | Creative mode tabs | + +### Extended Data Generation + +Comprehensive datagen utilities beyond translations: + +| Provider | Features | +|----------|----------| +| `AmbleLanguageProvider` | Automatic translations from identifiers | +| `AmbleModelProvider` | Block/item model generation with `@AutomaticModel` | +| `AmbleRecipeProvider` | Shaped, shapeless, stonecutting, smithing, blasting recipes | +| `AmbleAdvancementProvider` | Fluent API for advancement trees | +| `AmbleSoundProvider` | Sound definition generation | +| `AmbleBlockTagProvider` | Block tags with mineable annotations (`@PickaxeMineable`, etc.) | +| `AmbleBlockLootTable` | Block loot table generation | + ### Much more!

@@ -66,39 +140,33 @@ There are more datagen utilities akin to this. ### If you have an already existing mod and want the amblekit then add this to your **build.gradle**! +> [!IMPORTANT] +> We have moved away from JitPack to our own maven repository. If you were using JitPack previously, please update your configuration. See [Issue #55](https://github.com/amblelabs/modkit/issues/55) for more details. - ``` + ```groovy repositories { maven { - url "https://jitpack.io" - - metadataSources { - artifact() // Look directly for artifact - } + url "https://amblelabs.dev/maven" } } dependencies { - modImplementation("com.github.amblelabs:modkit:${project.modkit_version}") { + modImplementation("dev.amble:lib:${project.amblekit_version}") { exclude(group: "net.fabricmc.fabric-api") } } ``` or if you are using kotlin - ``` - repositories { + ```kotlin + repositories { maven { - url = uri("https://jitpack.io") - metadataSources { - artifact() // Look directly for artifact - } + url = uri("https://amblelabs.dev/maven") } mavenCentral() } - - + dependencies { - modImplementation("com.github.amblelabs:modkit:${project.property("modkit_version")}") + modImplementation("dev.amble:lib:${project.property("amblekit_version")}") } ``` diff --git a/SKIN_SYSTEM.md b/SKIN_SYSTEM.md new file mode 100644 index 0000000..dab5635 --- /dev/null +++ b/SKIN_SYSTEM.md @@ -0,0 +1,475 @@ +# Dynamic Skin System + +AmbleKit provides a dynamic player skin system that allows you to change player skins at runtime. This is useful for NPCs, disguises, custom player appearances, and roleplay servers. + +## Table of Contents +- [Overview](#overview) +- [Commands](#commands) +- [Java API](#java-api) +- [Skin Sources](#skin-sources) +- [Persistence](#persistence) +- [Integration](#integration) + +--- + +## Overview + +The Dynamic Skin System provides: +- **Runtime Skin Changes** - Change player/entity skins without relogging +- **Multiple Sources** - Load skins by username or direct URL +- **Slim/Wide Models** - Support for both Alex (slim) and Steve (wide) arm models +- **Server Synchronization** - Skins sync automatically to all connected clients +- **Persistent Storage** - Skins persist across server restarts +- **Entity Support** - Works with any entity implementing `PlayerSkinTexturable` + +--- + +## Commands + +All skin commands require operator permissions (level 2). + +### Set Skin by Username + +Copy another player's skin: + +``` +/amblekit skin set +``` + +**Example:** +``` +/amblekit skin @p set Notch +``` + +### Set Skin by URL + +Load a skin from a direct image URL: + +``` +/amblekit skin slim +``` + +| Parameter | Description | +|-----------|-------------| +| `target` | Entity to modify | +| `slim` | `true` for slim arms (Alex), `false` for wide arms (Steve) | +| `url` | Direct URL to a skin image (PNG) | + +**Example:** +``` +/amblekit skin @p slim false https://example.com/skins/custom_skin.png +``` + +### Toggle Slim Arms + +Change the arm model without changing the skin: + +``` +/amblekit skin slim +``` + +**Example:** +``` +/amblekit skin @p slim true +``` + +### Clear/Reset Skin + +Remove custom skin and restore the original: + +``` +/amblekit skin clear +``` + +**Example:** +``` +/amblekit skin @p clear +``` + +--- + +## Java API + +### Setting Skins Programmatically + +```java +import dev.amble.lib.skin.SkinData; +import dev.amble.lib.skin.SkinTracker; + +// Get the target entity's UUID +UUID targetUuid = player.getUuid(); + +// Set skin by username (async lookup) +SkinData.username("Notch", skinData -> { + skinData.upload(targetUuid); +}); + +// Set skin by username with specific arm model +SkinData data = SkinData.username("Notch", true); // slim = true +SkinTracker.getInstance().putSynced(targetUuid, data); + +// Set skin by URL +SkinData urlSkin = SkinData.url("https://example.com/skin.png", false); // slim = false +SkinTracker.getInstance().putSynced(targetUuid, urlSkin); +``` + +### Clearing Skins + +```java +// Remove custom skin +SkinTracker.getInstance().removeSynced(player.getUuid()); +``` + +### Querying Skins + +```java +import dev.amble.lib.skin.SkinTracker; +import dev.amble.lib.skin.SkinData; + +// Check if entity has custom skin +Optional skin = SkinTracker.getInstance().getOptional(player.getUuid()); + +if (skin.isPresent()) { + SkinData data = skin.get(); + // Entity has a custom skin +} +``` + +### Implementing PlayerSkinTexturable + +To make your own entities support dynamic skins, implement `PlayerSkinTexturable`: + +```java +public class MyNpcEntity extends LivingEntity implements PlayerSkinTexturable { + + @Override + public UUID getUuid() { + return super.getUuid(); + } + + // The skin system will automatically apply skins to entities + // implementing this interface +} +``` + +--- + +## Skin Sources + +### Username-Based Skins + +When you set a skin by username, AmbleKit: +1. Looks up the player's UUID via Mojang API +2. Retrieves their skin data +3. Caches the result for performance + +```java +// Async version (recommended for usernames) +SkinData.username("PlayerName", result -> { + result.upload(targetUuid); +}); + +// Sync version (use with caution - may block) +SkinData data = SkinData.username("PlayerName", false); +``` + +### URL-Based Skins + +Direct URL skins load from any accessible image URL: + +```java +SkinData data = SkinData.url("https://example.com/skin.png", false); +SkinTracker.getInstance().putSynced(targetUuid, data); +``` + +**Requirements:** +- URL must be publicly accessible +- Image should be a valid Minecraft skin (64x64 or 64x32 PNG) +- HTTPS is recommended + +### Arm Model Types + +| Model | Description | Common Use | +|-------|-------------|------------| +| **Wide** (`slim = false`) | Classic Steve-style arms (4px wide) | Default male characters | +| **Slim** (`slim = true`) | Alex-style arms (3px wide) | Default female characters | + +```java +// Change arm model of existing skin +SkinData existingSkin = SkinTracker.getInstance().get(uuid); +if (existingSkin != null) { + SkinData newData = existingSkin.withSlim(true); + SkinTracker.getInstance().putSynced(uuid, newData); +} +``` + +--- + +## Persistence + +### Automatic Saving + +Skins are automatically saved when the server stops: +- Storage location: `/amblekit/skins.json` +- Format: JSON map of UUID to skin data + +### Automatic Loading + +On server start, skins are loaded and synced to all connecting players. + +### Manual Sync + +Force sync all skins to players: + +```java +// Sync to all players +SkinTracker.getInstance().sync(); + +// Sync to specific player +SkinTracker.getInstance().sync(serverPlayerEntity); +``` + +--- + +## Integration + +### With Lua Scripts (Server-Side) + +Server-side Lua scripts have direct access to the skin management API: + +```lua +-- data/mymod/script/disguise_manager.lua +-- Run with: /serverscript execute mymod:disguise_manager + +function onExecute(mc) + local player = mc:player() + if player == nil then return end + + local playerName = player:name() + + -- Set player's skin to another player's skin + mc:setSkin(playerName, "Notch") + mc:broadcastToPlayer(playerName, "§aDisguised as Notch!", false) +end +``` + +#### Server-Side Skin API Methods + +All skin methods return `true` on success, `false` on failure (player not found, invalid UUID, etc.). + +**By Player Name:** +| Method | Description | +|--------|-------------| +| `mc:setSkin(playerName, skinUsername)` | Set player's skin to another player's skin | +| `mc:setSkinUrl(playerName, url, slim)` | Set skin from URL (slim = true for Alex arms) | +| `mc:setSkinSlim(playerName, slim)` | Change arm model (true = slim/Alex, false = wide/Steve) | +| `mc:clearSkin(playerName)` | Remove custom skin, restore original | +| `mc:hasSkin(playerName)` | Check if player has a custom skin | + +**By UUID String:** +| Method | Description | +|--------|-------------| +| `mc:setSkinByUuid(uuid, skinUsername)` | Set skin by UUID string | +| `mc:setSkinUrlByUuid(uuid, url, slim)` | Set skin from URL by UUID string | +| `mc:clearSkinByUuid(uuid)` | Clear skin by UUID string | +| `mc:hasSkinByUuid(uuid)` | Check if entity has custom skin by UUID | + +#### Complete Example: Disguise System + +```lua +-- data/mymod/script/disguise_system.lua + +-- Disguise player as another username +function onExecute(mc) + local player = mc:player() + if player == nil then + mc:log("Script requires player context") + return + end + + local playerName = player:name() + if mc:setSkin(playerName, "Herobrine") then + mc:broadcastToPlayer(playerName, "§cYou are now disguised as Herobrine!", false) + mc:log("Player " .. playerName .. " disguised as Herobrine") + else + mc:logWarn("Failed to disguise player " .. playerName) + end +end + +-- Tick function to auto-disguise players on join (when enabled) +local lastPlayerCount = 0 +function onTick(mc) + local currentCount = mc:playerCount() + if currentCount > lastPlayerCount then + -- New player joined, could auto-apply skins here + mc:log("Player count changed: " .. lastPlayerCount .. " -> " .. currentCount) + end + lastPlayerCount = currentCount +end + +function onDisable(mc) + -- Clear all custom skins when script is disabled + for _, name in pairs(mc:allPlayerNames()) do + if mc:hasSkin(name) then + mc:clearSkin(name) + end + end + mc:broadcast("§7All disguises removed.") +end +``` + +#### Using URL Skins + +```lua +-- Apply custom skin from URL +function applyCustomSkin(mc) + local player = mc:player() + if player == nil then return end + + local playerName = player:name() + local url = "https://example.com/skins/custom.png" + + -- Use slim arms (Alex model) + if mc:setSkinUrl(playerName, url, true) then + mc:broadcastToPlayer(playerName, "§aCustom skin applied!", false) + else + mc:broadcastToPlayer(playerName, "§cFailed to apply skin!", false) + end +end +``` + +#### UUID-Based Skin Changes + +For NPCs or entities stored by UUID, use UUID-based methods: + +```lua +function onExecute(mc) + -- Get all players and apply skins using their UUIDs + local players = mc:allPlayers() + + for _, player in pairs(players) do + local uuid = player:uuid() + if not mc:hasSkinByUuid(uuid) then + if mc:setSkinByUuid(uuid, "Steve") then + mc:log("Applied default skin to " .. player:name()) + end + end + end +end + +-- Apply skin to a stored NPC by UUID +function applyNpcSkin(mc) + local npcUuid = "550e8400-e29b-41d4-a716-446655440000" + + if mc:setSkinUrlByUuid(npcUuid, "https://example.com/npc.png", false) then + mc:log("NPC skin updated") + else + mc:logWarn("Failed to update NPC skin - invalid UUID?") + end +end +``` + +### With Client-Side Scripts + +Client-side scripts can trigger skin changes via commands: + +```lua +-- assets/mymod/script/request_skin.lua +-- Run with: /amblescript execute mymod:request_skin + +function onExecute(mc) + -- Client scripts use commands (requires server permission) + mc:runCommand("/amblekit skin @p set Notch") + mc:sendMessage("§7Skin change requested!", false) +end +``` + +> **Note:** Direct skin API methods (`setSkin`, `clearSkin`, etc.) are only available in server-side scripts. Client scripts must use commands. + +### With NPCs + +Create NPCs with custom skins: + +```java +// Create your NPC entity +MyNpcEntity npc = new MyNpcEntity(world); +npc.setPosition(x, y, z); +world.spawnEntity(npc); + +// Set its skin +SkinData.username("SomePlayer", skin -> { + SkinTracker.getInstance().putSynced(npc.getUuid(), skin); +}); +``` + +### Events + +Listen for skin changes: + +```java +// The skin tracker uses Fabric networking +// Clients receive updates via the SYNC_KEY packet +ClientPlayNetworking.registerGlobalReceiver(SkinTracker.SYNC_KEY, (client, handler, buf, responseSender) -> { + // Handle skin update +}); +``` + +--- + +## Technical Details + +### Network Protocol + +Skins are synchronized using Fabric's networking API: +- **Packet ID:** `amblekit:skin_sync` +- **Direction:** Server → Client +- **Triggered:** On player join, skin change, or manual sync + +### Client-Side Caching + +The `SkinCache` handles texture management: +- Downloads and caches skin textures +- Converts PNG data to Minecraft textures +- Handles slim/wide model variations + +### Thread Safety + +The `SkinTracker` is thread-safe: +- Uses concurrent data structures +- Safe to call from any thread +- Networking handled on appropriate threads + +--- + +## Troubleshooting + +### Skin Not Updating + +1. Ensure the target entity implements `PlayerSkinTexturable` +2. Check that the skin URL is accessible +3. Verify the player has reconnected after server-side changes + +### Invalid Skin Source + +- Username lookups require internet access +- URL skins must be valid PNG images +- Some CDNs may block automated downloads + +### Slim Arms Not Working + +Ensure you're using `withSlim()` or specifying the slim parameter: + +```java +// Correct +SkinData data = SkinData.url(url, true); // slim = true + +// Or modify existing +data = data.withSlim(true); +``` + +--- + +## See Also + +- [Lua Scripting System](LUA_SCRIPTING.md) - Full skin management API for server scripts +- [Animation System](ANIMATION_SYSTEM.md) - Animate entities with custom skins diff --git a/build.gradle b/build.gradle index e0546b2..b031e28 100644 --- a/build.gradle +++ b/build.gradle @@ -49,6 +49,15 @@ loom { ideConfigGenerated project.rootProject == project name = "Testmod Client" source sourceSets.test + programArgs "--username", "Dev" + } + testmodClient2 { + client() + ideConfigGenerated project.rootProject == project + name = "Testmod Client 2" + source sourceSets.test + programArgs "--username", "Dev2" + runDir "run2" } testmodServer { server() @@ -56,6 +65,16 @@ loom { name = "Testmod Server" source sourceSets.test } + testmodDatagen { + inherit server + ideConfigGenerated project.rootProject == project + name = "Testmod Data Generation" + vmArg "-Dfabric-api.datagen" + vmArg "-Dfabric-api.datagen.output-dir=${file("src/test/generated")}" + vmArg "-Dfabric-api.datagen.modid=litmus" + runDir "build/datagen" + source sourceSets.test + } } } @@ -72,6 +91,11 @@ sourceSets { test { runtimeClasspath += main.runtimeClasspath compileClasspath += main.compileClasspath + resources { + srcDirs += [ + "src/test/generated" + ] + } } } @@ -90,6 +114,9 @@ dependencies { include(modApi("dev.drtheo:scheduler:${project.scheduler_version}")) include(modApi("dev.drtheo:queue:${project.queue_version}")) + // LuaJ for GUI scripting + include(implementation("org.luaj:luaj-jse:3.0.1")) + // getter setter compileOnly 'org.projectlombok:lombok:1.18.34' annotationProcessor 'org.projectlombok:lombok:1.18.34' diff --git a/gradle.properties b/gradle.properties index f2d5908..df643f9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,7 +9,7 @@ yarn_mappings=1.20.1+build.10 loader_version=0.16.10 # Mod Properties -mod_version=1.1.15 +mod_version=1.1.16 maven_group=dev.amble publication_base_name=lib archives_base_name=amblekit diff --git a/run2/.gitkeep b/run2/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/dev/amble/lib/AmbleKit.java b/src/main/java/dev/amble/lib/AmbleKit.java index 9c0fbbc..daa4de1 100644 --- a/src/main/java/dev/amble/lib/AmbleKit.java +++ b/src/main/java/dev/amble/lib/AmbleKit.java @@ -7,7 +7,9 @@ import dev.amble.lib.client.bedrock.BedrockAnimationAdapter; import dev.amble.lib.client.bedrock.BedrockModel; import dev.amble.lib.command.PlayAnimationCommand; +import dev.amble.lib.command.ServerScriptCommand; import dev.amble.lib.command.SetSkinCommand; +import dev.amble.lib.script.ServerScriptManager; import dev.amble.lib.skin.SkinTracker; import dev.drtheo.multidim.MultiDimMod; import dev.drtheo.scheduler.SchedulerMod; @@ -35,10 +37,12 @@ public void onInitialize() { ServerLifecycleHooks.init(); SkinTracker.init(); AnimationTracker.init(); + ServerScriptManager.getInstance().init(); CommandRegistrationCallback.EVENT.register((dispatcher, access, env) -> { SetSkinCommand.register(dispatcher); PlayAnimationCommand.register(dispatcher); + ServerScriptCommand.register(dispatcher); }); FabricLoader.getInstance().invokeEntrypoints("amblekit-main", AmbleKitInitializer.class, diff --git a/src/main/java/dev/amble/lib/client/AmbleKitClient.java b/src/main/java/dev/amble/lib/client/AmbleKitClient.java index 0206b6a..b32c964 100644 --- a/src/main/java/dev/amble/lib/client/AmbleKitClient.java +++ b/src/main/java/dev/amble/lib/client/AmbleKitClient.java @@ -3,10 +3,14 @@ import dev.amble.lib.client.bedrock.BedrockAnimationRegistry; import dev.amble.lib.client.bedrock.BedrockModel; import dev.amble.lib.client.bedrock.BedrockModelRegistry; +import dev.amble.lib.client.gui.registry.AmbleGuiRegistry; +import dev.amble.lib.client.command.ClientScriptCommand; import dev.amble.lib.register.AmbleRegistries; +import dev.amble.lib.script.ScriptManager; import dev.amble.lib.skin.client.SkinGrabber; import dev.drtheo.scheduler.client.SchedulerClientMod; import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; import net.fabricmc.loader.api.FabricLoader; @@ -25,13 +29,20 @@ public void onInitializeClient() { AmbleRegistries.getInstance().registerAll( BedrockModelRegistry.getInstance(), - BedrockAnimationRegistry.getInstance() + BedrockAnimationRegistry.getInstance(), + AmbleGuiRegistry.getInstance() ); - ClientTickEvents.END_CLIENT_TICK.register((client) -> { - SkinGrabber.INSTANCE.tick(); - }); + AmbleGuiRegistry.init(); + ClientCommandRegistrationCallback.EVENT.register((dispatcher, access) -> { + ClientScriptCommand.register(dispatcher); + }); + + ClientTickEvents.END_CLIENT_TICK.register((client) -> { + SkinGrabber.INSTANCE.tick(); + ScriptManager.getInstance().tick(); + }); } } diff --git a/src/main/java/dev/amble/lib/client/command/ClientScriptCommand.java b/src/main/java/dev/amble/lib/client/command/ClientScriptCommand.java new file mode 100644 index 0000000..c237b3f --- /dev/null +++ b/src/main/java/dev/amble/lib/client/command/ClientScriptCommand.java @@ -0,0 +1,254 @@ +package dev.amble.lib.client.command; + +import com.mojang.brigadier.Command; +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.suggestion.SuggestionProvider; +import dev.amble.lib.AmbleKit; +import dev.amble.lib.script.LuaScript; +import dev.amble.lib.script.ScriptManager; +import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource; +import net.minecraft.client.MinecraftClient; +import net.minecraft.command.CommandSource; +import net.minecraft.command.argument.IdentifierArgumentType; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +import net.minecraft.util.Identifier; +import org.luaj.vm2.LuaTable; +import org.luaj.vm2.LuaValue; + +import java.util.Set; + +import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.argument; +import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.literal; + +/** + * Client-side command for managing client scripts. + * Usage: /amblescript [execute|enable|disable|toggle|list|available] [script_id] + */ +public class ClientScriptCommand { + + private static final String SCRIPT_PREFIX = "script/"; + private static final String SCRIPT_SUFFIX = ".lua"; + + private static String translationKey(String key) { + return "command." + AmbleKit.MOD_ID + ".client_script." + key; + } + + /** + * Converts a full script identifier to a display-friendly format. + * Removes the "script/" prefix and ".lua" suffix. + */ + private static String getDisplayId(Identifier id) { + String path = id.getPath(); + return path.substring(SCRIPT_PREFIX.length(), path.length() - SCRIPT_SUFFIX.length()); + } + + /** + * Converts a user-provided script ID to the full internal identifier. + */ + private static Identifier toFullScriptId(Identifier scriptId) { + return scriptId.withPrefixedPath(SCRIPT_PREFIX).withSuffixedPath(SCRIPT_SUFFIX); + } + + private static final SuggestionProvider TICKABLE_SCRIPT_SUGGESTIONS = (context, builder) -> { + return CommandSource.suggestIdentifiers( + ScriptManager.getInstance().getCache().entrySet().stream() + .filter(entry -> entry.getValue().onTick() != null && !entry.getValue().onTick().isnil()) + .map(entry -> Identifier.of(entry.getKey().getNamespace(), getDisplayId(entry.getKey()))), + builder + ); + }; + + private static final SuggestionProvider ENABLED_TICKABLE_SCRIPT_SUGGESTIONS = (context, builder) -> { + return CommandSource.suggestIdentifiers( + ScriptManager.getInstance().getEnabledScripts().stream() + .filter(id -> { + LuaScript script = ScriptManager.getInstance().getCache().get(id); + return script != null && script.onTick() != null && !script.onTick().isnil(); + }) + .map(id -> Identifier.of(id.getNamespace(), getDisplayId(id))), + builder + ); + }; + + private static final SuggestionProvider EXECUTABLE_SCRIPT_SUGGESTIONS = (context, builder) -> { + return CommandSource.suggestIdentifiers( + ScriptManager.getInstance().getCache().entrySet().stream() + .filter(entry -> entry.getValue().onExecute() != null && !entry.getValue().onExecute().isnil()) + .map(entry -> Identifier.of(entry.getKey().getNamespace(), getDisplayId(entry.getKey()))), + builder + ); + }; + + public static void register(CommandDispatcher dispatcher) { + dispatcher.register(literal("amblescript") + .then(literal("execute") + .then(argument("id", IdentifierArgumentType.identifier()) + .suggests(EXECUTABLE_SCRIPT_SUGGESTIONS) + .executes(context -> execute(context, "")) + .then(argument("args", StringArgumentType.greedyString()) + .executes(context -> execute(context, StringArgumentType.getString(context, "args")))))) + .then(literal("enable") + .then(argument("id", IdentifierArgumentType.identifier()) + .suggests(TICKABLE_SCRIPT_SUGGESTIONS) + .executes(ClientScriptCommand::enable))) + .then(literal("disable") + .then(argument("id", IdentifierArgumentType.identifier()) + .suggests(ENABLED_TICKABLE_SCRIPT_SUGGESTIONS) + .executes(ClientScriptCommand::disable))) + .then(literal("toggle") + .then(argument("id", IdentifierArgumentType.identifier()) + .suggests(TICKABLE_SCRIPT_SUGGESTIONS) + .executes(ClientScriptCommand::toggle))) + .then(literal("list") + .executes(ClientScriptCommand::listEnabled)) + .then(literal("available") + .executes(ClientScriptCommand::listAvailable))); + } + + private static int execute(CommandContext context, String argsString) { + Identifier scriptId = context.getArgument("id", Identifier.class); + Identifier fullScriptId = toFullScriptId(scriptId); + + try { + LuaScript script = ScriptManager.getInstance().load( + fullScriptId, + MinecraftClient.getInstance().getResourceManager() + ); + + if (script.onExecute() == null || script.onExecute().isnil()) { + context.getSource().sendError(Text.translatable(translationKey("error.no_execute"), scriptId)); + return 0; + } + + LuaValue data = ScriptManager.getInstance().getScriptData(fullScriptId); + + // Parse arguments into a Lua table + LuaTable argsTable = new LuaTable(); + if (!argsString.isEmpty()) { + String[] args = argsString.split(" "); + for (int i = 0; i < args.length; i++) { + argsTable.set(i + 1, LuaValue.valueOf(args[i])); + } + } + + script.onExecute().call(data, argsTable); + context.getSource().sendFeedback(Text.translatable(translationKey("executed"), scriptId)); + return Command.SINGLE_SUCCESS; + } catch (Exception e) { + context.getSource().sendError(Text.translatable(translationKey("error.execute_failed"), scriptId, e.getMessage())); + AmbleKit.LOGGER.error("Failed to execute script {}", scriptId, e); + return 0; + } + } + + private static int enable(CommandContext context) { + Identifier scriptId = context.getArgument("id", Identifier.class); + Identifier fullScriptId = toFullScriptId(scriptId); + + // Ensure script is loaded + try { + ScriptManager.getInstance().load(fullScriptId, MinecraftClient.getInstance().getResourceManager()); + } catch (Exception e) { + context.getSource().sendError(Text.translatable(translationKey("error.not_found"), scriptId)); + return 0; + } + + if (ScriptManager.getInstance().isEnabled(fullScriptId)) { + context.getSource().sendError(Text.translatable(translationKey("error.already_enabled"), scriptId)); + return 0; + } + + if (ScriptManager.getInstance().enable(fullScriptId)) { + context.getSource().sendFeedback(Text.translatable(translationKey("enabled"), scriptId).formatted(Formatting.GREEN)); + return Command.SINGLE_SUCCESS; + } else { + context.getSource().sendError(Text.translatable(translationKey("error.enable_failed"), scriptId)); + return 0; + } + } + + private static int disable(CommandContext context) { + Identifier scriptId = context.getArgument("id", Identifier.class); + Identifier fullScriptId = toFullScriptId(scriptId); + + if (!ScriptManager.getInstance().isEnabled(fullScriptId)) { + context.getSource().sendError(Text.translatable(translationKey("error.not_enabled"), scriptId)); + return 0; + } + + if (ScriptManager.getInstance().disable(fullScriptId)) { + context.getSource().sendFeedback(Text.translatable(translationKey("disabled"), scriptId).formatted(Formatting.RED)); + return Command.SINGLE_SUCCESS; + } else { + context.getSource().sendError(Text.translatable(translationKey("error.disable_failed"), scriptId)); + return 0; + } + } + + private static int toggle(CommandContext context) { + Identifier scriptId = context.getArgument("id", Identifier.class); + Identifier fullScriptId = toFullScriptId(scriptId); + + // Ensure script is loaded + try { + ScriptManager.getInstance().load(fullScriptId, MinecraftClient.getInstance().getResourceManager()); + } catch (Exception e) { + context.getSource().sendError(Text.translatable(translationKey("error.not_found"), scriptId)); + return 0; + } + + boolean wasEnabled = ScriptManager.getInstance().isEnabled(fullScriptId); + ScriptManager.getInstance().toggle(fullScriptId); + + if (wasEnabled) { + context.getSource().sendFeedback(Text.translatable(translationKey("disabled"), scriptId).formatted(Formatting.RED)); + } else { + context.getSource().sendFeedback(Text.translatable(translationKey("enabled"), scriptId).formatted(Formatting.GREEN)); + } + return Command.SINGLE_SUCCESS; + } + + private static int listEnabled(CommandContext context) { + Set enabled = ScriptManager.getInstance().getEnabledScripts(); + + if (enabled.isEmpty()) { + context.getSource().sendFeedback(Text.translatable(translationKey("list.none_enabled")).formatted(Formatting.GRAY)); + return Command.SINGLE_SUCCESS; + } + + context.getSource().sendFeedback(Text.translatable(translationKey("list.enabled_header"), enabled.size()).formatted(Formatting.GOLD, Formatting.BOLD)); + for (Identifier id : enabled) { + String displayId = getDisplayId(id); + context.getSource().sendFeedback( + Text.literal("✓ ").formatted(Formatting.GREEN) + .append(Text.literal(id.getNamespace() + ":" + displayId).formatted(Formatting.WHITE)) + ); + } + return Command.SINGLE_SUCCESS; + } + + private static int listAvailable(CommandContext context) { + Set available = ScriptManager.getInstance().getCache().keySet(); + Set enabled = ScriptManager.getInstance().getEnabledScripts(); + + if (available.isEmpty()) { + context.getSource().sendFeedback(Text.translatable(translationKey("list.none_available")).formatted(Formatting.GRAY)); + return Command.SINGLE_SUCCESS; + } + + context.getSource().sendFeedback(Text.translatable(translationKey("list.available_header"), available.size()).formatted(Formatting.GOLD, Formatting.BOLD)); + for (Identifier id : available) { + String displayId = getDisplayId(id); + Text statusIcon = enabled.contains(id) + ? Text.literal("✓ ").formatted(Formatting.GREEN) + : Text.literal("○ ").formatted(Formatting.GRAY); + context.getSource().sendFeedback( + statusIcon.copy().append(Text.literal(id.getNamespace() + ":" + displayId).formatted(Formatting.WHITE)) + ); + } + return Command.SINGLE_SUCCESS; + } +} diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleButton.java b/src/main/java/dev/amble/lib/client/gui/AmbleButton.java new file mode 100644 index 0000000..982f33f --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/AmbleButton.java @@ -0,0 +1,346 @@ +package dev.amble.lib.client.gui; + + +import com.google.gson.JsonObject; +import dev.amble.lib.AmbleKit; +import dev.amble.lib.client.gui.registry.AmbleElementParser; +import dev.amble.lib.script.lua.LuaBinder; +import dev.amble.lib.client.gui.lua.LuaElement; +import dev.amble.lib.script.LuaScript; +import dev.amble.lib.script.ScriptManager; +import lombok.*; +import net.minecraft.SharedConstants; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; +import org.lwjgl.glfw.GLFW; +import org.luaj.vm2.LuaValue; +import org.luaj.vm2.Varargs; + +import java.awt.*; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +@Setter +public class AmbleButton extends AmbleContainer implements Focusable { + private AmbleDisplayType hoverDisplay; + private AmbleDisplayType pressDisplay; + private @Nullable Runnable onClick; + private @Nullable AmbleDisplayType normalDisplay = null; + private boolean isClicked = false; + private @Nullable LuaScript script; + private boolean focused = false; + + @Override + public void onRelease(double mouseX, double mouseY, int button) { + if (onClick != null) { + onClick.run(); + } + this.setBackground( + isHovered(mouseX, mouseY) ? hoverDisplay : getNormalDisplay() + ); + this.isClicked = false; + + if (script != null && script.onRelease() != null && !script.onRelease().isnil()) { + Varargs args = LuaValue.varargsOf(new LuaValue[]{ + LuaBinder.bind(new LuaElement(this)), + LuaValue.valueOf(mouseX), + LuaValue.valueOf(mouseY), + LuaValue.valueOf(button) + }); + + try { + script.onRelease().invoke(args); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error invoking onRelease script for AmbleButton {}:", id(), e); + } + } + } + + @Override + public void onClick(double mouseX, double mouseY, int button) { + this.setBackground(pressDisplay); + this.isClicked = true; + + + if (script != null && script.onClick() != null && !script.onClick().isnil()) { + Varargs args = LuaValue.varargsOf(new LuaValue[]{ + LuaBinder.bind(new LuaElement(this)), + LuaValue.valueOf(mouseX), + LuaValue.valueOf(mouseY), + LuaValue.valueOf(button) + }); + + try { + script.onClick().invoke(args); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error invoking onClick script for AmbleButton {}:", id(), e); + } + } + } + + public void onHover(double mouseX, double mouseY) { + if (script != null && script.onHover() != null && !script.onHover().isnil()) { + Varargs args = LuaValue.varargsOf(new LuaValue[]{ + LuaBinder.bind(new LuaElement(this)), + LuaValue.valueOf(mouseX), + LuaValue.valueOf(mouseY), + }); + + try { + script.onHover().invoke(args); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error invoking onHover script for AmbleButton {}:", id(), e); + } + } + } + + public @Nullable AmbleDisplayType getNormalDisplay() { + if (normalDisplay == null) { + normalDisplay = this.getBackground(); + } + + return normalDisplay; + } + + private boolean displayCalled = false; + + public void setScript(LuaScript script) { + this.script = script; + this.displayCalled = false; + + // Call onAttached immediately when script is attached (during JSON parsing) + if (script.onAttached() != null && !script.onAttached().isnil()) { + try { + script.onAttached().call(LuaBinder.bind(new LuaElement(this))); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error invoking onAttached script for AmbleButton {}:", id(), e); + } + } + } + + /** + * Calls onDisplay if it hasn't been called yet. + * This is deferred until first render so the GUI tree is fully built. + */ + private void ensureDisplayCalled() { + if (!displayCalled && script != null && script.onDisplay() != null && !script.onDisplay().isnil()) { + displayCalled = true; + try { + script.onDisplay().call(LuaBinder.bind(new LuaElement(this))); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error invoking onDisplay script for AmbleButton {}:", id(), e); + } + } + } + + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + // Call onDisplay on first render when GUI tree is fully built + ensureDisplayCalled(); + + if (isHovered(mouseX, mouseY)) { + onHover(mouseX, mouseY); + } + + if (isClicked) { + setBackground(pressDisplay); + } else if (isHovered(mouseX, mouseY) || focused) { + setBackground(hoverDisplay); + } else { + setBackground(getNormalDisplay()); + } + + super.render(context, mouseX, mouseY, delta); + } + + + // ===== Focusable interface implementation ===== + + @Override + public boolean canFocus() { + return isVisible(); + } + + @Override + public boolean isFocused() { + return focused; + } + + @Override + public void setFocused(boolean focused) { + this.focused = focused; + } + + @Override + public void onFocusChanged(boolean focused) { + this.focused = focused; + } + + @Override + public boolean onKeyPressed(int keyCode, int scanCode, int modifiers) { + if (!focused) return false; + + // Enter or Space to activate the button + if (keyCode == GLFW.GLFW_KEY_ENTER || keyCode == GLFW.GLFW_KEY_KP_ENTER || keyCode == GLFW.GLFW_KEY_SPACE) { + // Simulate click + Rectangle layout = getLayout(); + double centerX = layout.x + layout.width / 2.0; + double centerY = layout.y + layout.height / 2.0; + onClick(centerX, centerY, 0); + onRelease(centerX, centerY, 0); + return true; + } + + return false; + } + + @Override + public boolean onCharTyped(char chr, int modifiers) { + return false; + } + + + public static Builder buttonBuilder() { + return new Builder(); + } + + public static class Builder extends AbstractBuilder { + + @Override + protected AmbleButton create() { + return new AmbleButton(); + } + + @Override + protected Builder self() { + return this; + } + + public Builder hoverDisplay(AmbleDisplayType hoverDisplay) { + container.setHoverDisplay(hoverDisplay); + return this; + } + + public Builder hoverDisplay(Color hoverColor) { + container.setHoverDisplay(AmbleDisplayType.color(hoverColor)); + return this; + } + + public Builder hoverDisplay(AmbleDisplayType.TextureData hoverTexture) { + container.setHoverDisplay(AmbleDisplayType.texture(hoverTexture)); + return this; + } + + public Builder pressDisplay(AmbleDisplayType pressDisplay) { + container.setPressDisplay(pressDisplay); + return this; + } + + public Builder pressDisplay(Color pressColor) { + container.setPressDisplay(AmbleDisplayType.color(pressColor)); + return this; + } + + public Builder pressDisplay(AmbleDisplayType.TextureData pressTexture) { + container.setPressDisplay(AmbleDisplayType.texture(pressTexture)); + return this; + } + + public Builder onClick(Runnable onClick) { + container.setOnClick(onClick); + return this; + } + } + + /** + * Parser for AmbleButton elements. + *

+ * This parser handles JSON objects that have button-specific properties: + * on_click, script, hover_background, or press_background. + */ + public static class Parser implements AmbleElementParser { + + @Override + public @Nullable AmbleContainer parse(JsonObject json, @Nullable Identifier resourceId, AmbleContainer base) { + // Check if this is a button (has button-specific properties) + boolean isButton = json.has("on_click") || json.has("script") || json.has("hover_background") || json.has("press_background"); + + if (!isButton) { + return null; + } + + // Handle text for button - use AmbleText.Parser to create the text child + if (json.has("text")) { + // Create a temporary container to parse text into + AmbleText textChild = (AmbleText) new AmbleText.Parser().parse(json, resourceId, + AmbleText.textBuilder() + .layout(new Rectangle(base.getLayout())) + .background(new Color(0, 0, 0, 0)) + .build()); + if (textChild != null) { + base.addChild(textChild); + } + } + + AmbleButton button = AmbleButton.buttonBuilder().build(); + button.copyFrom(base); + + if (json.has("on_click")) { + // todo run actual java methods via reflection + String clickCommand = json.get("on_click").getAsString(); + button.setOnClick(() -> { + try { + String string2 = SharedConstants.stripInvalidChars(clickCommand); + if (string2.startsWith("/")) { + if (!MinecraftClient.getInstance().player.networkHandler.sendCommand(string2.substring(1))) { + AmbleKit.LOGGER.error("Not allowed to run command with signed argument from click event: '{}'", string2); + } + } else { + AmbleKit.LOGGER.error("Failed to run command without '/' prefix from click event: '{}'", string2); + } + } catch (Exception e) { + AmbleKit.LOGGER.error("Error occurred while running command from click event: '{}'", clickCommand, e); + } + }); + } else { + button.setOnClick(() -> { + }); + } + + if (json.has("script")) { + Identifier scriptId = new Identifier(json.get("script").getAsString()).withPrefixedPath("script/").withSuffixedPath(".lua"); + LuaScript script = ScriptManager.getInstance().load( + scriptId, + MinecraftClient.getInstance().getResourceManager() + ); + + button.setScript(script); + } + + if (json.has("hover_background")) { + AmbleDisplayType hoverBg = AmbleDisplayType.parse(json.get("hover_background")); + button.setHoverDisplay(hoverBg); + } else { + button.setHoverDisplay(button.getBackground()); + } + + if (json.has("press_background")) { + AmbleDisplayType pressBg = AmbleDisplayType.parse(json.get("press_background")); + button.setPressDisplay(pressBg); + } else { + button.setPressDisplay(button.getBackground()); + } + + return button; + } + + @Override + public int priority() { + // Button has higher priority than text since buttons can have text + return 100; + } + } +} diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java b/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java new file mode 100644 index 0000000..443fa85 --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java @@ -0,0 +1,416 @@ +package dev.amble.lib.client.gui; + +import dev.amble.lib.AmbleKit; +import lombok.*; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +import java.awt.*; +import java.util.ArrayList; +import java.util.List; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class AmbleContainer implements AmbleElement { + @Setter + private boolean visible = true; + + @Setter + private Rectangle layout; + + @Setter + private Rectangle preferredLayout; + + @Setter + @Nullable + private AmbleElement parent = null; + + @Setter + private int padding; + + @Setter + private int spacing; + + @Setter + private UIAlign horizontalAlign = UIAlign.START; + + @Setter + private UIAlign verticalAlign = UIAlign.START; + + @Setter + private boolean requiresNewRow = false; + + @Setter + private Text title = Text.empty(); + + @Setter + private AmbleDisplayType background = AmbleDisplayType.color(Color.WHITE); + + @Setter + private boolean shouldPause = false; + + @Setter + private Identifier identifier; + + private @Nullable Screen convertedScreen = null; + private final List children = new ArrayList<>(); + + @Override + public boolean requiresNewRow() { + return requiresNewRow; + } + + @Override + public Identifier id() { + if (identifier == null) { + if (parent != null) { + identifier = parent.id().withPath(parent.id().getPath() + "/" + System.identityHashCode(this)); + } else { + identifier = AmbleKit.id("container/" + System.identityHashCode(this)); + AmbleKit.LOGGER.error("GUI element missing identifier, no parent found to derive from. Generated id: {}", identifier); + } + } + + return identifier; + } + + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + this.background.render(context, getLayout()); + + AmbleElement.super.render(context, mouseX, mouseY, delta); + } + + public Rectangle getLayout() { + if (layout == null) return getPreferredLayout(); + + return layout; + } + + public Rectangle getPreferredLayout() { + if (preferredLayout == null) return layout != null ? layout : fallbackLayout(); + return preferredLayout; + } + + private static final Rectangle FALLBACK_LAYOUT = new Rectangle(0, 0, 100, 100); + private static final Color TRANSPARENT = new Color(0, 0, 0, 0); + + protected Rectangle fallbackLayout() { + AmbleKit.LOGGER.error("GUI element {} is missing layout data, using fallback layout", id()); + + return new Rectangle(FALLBACK_LAYOUT); + } + + public Screen toScreen() { + if (this.convertedScreen == null) { + this.convertedScreen = createScreen(); + } + return this.convertedScreen; + } + + protected Screen createScreen() { + return new AmbleScreen(this); + } + + public void display() { + AmbleContainer primary = AmbleContainer.primaryContainer(); + primary.addChild(this); + primary.setShouldPause(shouldPause); + Screen screen = primary.toScreen(); + MinecraftClient.getInstance().setScreen(screen); + } + + public void copyFrom(AmbleContainer other) { + this.visible = other.visible; + this.layout = other.layout; + this.preferredLayout = other.preferredLayout; + this.parent = other.parent; + this.padding = other.padding; + this.spacing = other.spacing; + this.horizontalAlign = other.horizontalAlign; + this.verticalAlign = other.verticalAlign; + this.requiresNewRow = other.requiresNewRow; + this.background = other.background; + this.identifier = other.identifier; + this.title = other.title; + this.shouldPause = other.shouldPause; + this.children.forEach(e -> e.setParent(null)); + this.children.clear(); + other.children.forEach(this::addChild); + } + + public static Builder builder() { + return new Builder(); + } + + public static AmbleContainer primaryContainer() { + return AmbleContainer.builder() + .layout(new Rectangle(0, 0, + MinecraftClient.getInstance().getWindow().getScaledWidth(), + MinecraftClient.getInstance().getWindow().getScaledHeight())) + .background(TRANSPARENT) + .build(); + } + + public static class AmbleScreen extends Screen { + public final AmbleContainer source; + private @Nullable AmbleElement focusedElement = null; + private long lastClickTime = 0; + private double lastClickX = 0; + private double lastClickY = 0; + + public AmbleScreen(AmbleContainer source) { + super(source.getTitle()); + this.source = source; + } + + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + super.render(context, mouseX, mouseY, delta); + + source.render(context, mouseX, mouseY, delta); + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + // Find if we clicked on a focusable element + AmbleElement clickedFocusable = findFocusableAt(source, mouseX, mouseY); + + // Update focus + if (clickedFocusable != focusedElement) { + if (focusedElement != null) { + if (focusedElement instanceof Focusable focusable) { + focusable.setFocused(false); + focusable.onFocusChanged(false); + } else { + focusedElement.onFocusChanged(false); + } + } + focusedElement = clickedFocusable; + if (focusedElement != null) { + if (focusedElement instanceof Focusable focusable) { + focusable.setFocused(true); + focusable.onFocusChanged(true); + } else { + focusedElement.onFocusChanged(true); + } + } + } + + source.onClick((int) mouseX, (int) mouseY, button); + return super.mouseClicked(mouseX, mouseY, button); + } + + @Override + public boolean mouseReleased(double mouseX, double mouseY, int button) { + source.onRelease((int) mouseX, (int) mouseY, button); + return super.mouseReleased(mouseX, mouseY, button); + } + + @Override + public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) { + if (focusedElement instanceof AmbleTextInput textInput) { + textInput.onMouseDragged(mouseX, mouseY, button); + return true; + } + return super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY); + } + + @Override + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + // Handle Tab for focus navigation + if (keyCode == org.lwjgl.glfw.GLFW.GLFW_KEY_TAB) { + cycleFocus(hasShiftDown()); + return true; + } + + // Delegate to focused element - prefer Focusable interface + if (focusedElement != null) { + boolean handled = focusedElement instanceof Focusable focusable + ? focusable.onKeyPressed(keyCode, scanCode, modifiers) + : focusedElement.onKeyPressed(keyCode, scanCode, modifiers); + if (handled) return true; + } + + return super.keyPressed(keyCode, scanCode, modifiers); + } + + @Override + public boolean charTyped(char chr, int modifiers) { + // Delegate to focused element - prefer Focusable interface + if (focusedElement != null) { + boolean handled = focusedElement instanceof Focusable focusable + ? focusable.onCharTyped(chr, modifiers) + : focusedElement.onCharTyped(chr, modifiers); + if (handled) return true; + } + + return super.charTyped(chr, modifiers); + } + + /** + * Cycles focus to the next/previous focusable element. + */ + private void cycleFocus(boolean reverse) { + java.util.List focusable = new java.util.ArrayList<>(); + source.findFocusableElements(focusable); + + if (focusable.isEmpty()) return; + + int currentIndex = focusedElement != null ? focusable.indexOf(focusedElement) : -1; + + int nextIndex; + if (reverse) { + nextIndex = currentIndex <= 0 ? focusable.size() - 1 : currentIndex - 1; + } else { + nextIndex = currentIndex >= focusable.size() - 1 ? 0 : currentIndex + 1; + } + + // Remove focus from current element + if (focusedElement != null) { + if (focusedElement instanceof Focusable focusableElement) { + focusableElement.setFocused(false); + focusableElement.onFocusChanged(false); + } else { + focusedElement.onFocusChanged(false); + } + } + + // Set focus to new element + focusedElement = focusable.get(nextIndex); + if (focusedElement instanceof Focusable focusableElement) { + focusableElement.setFocused(true); + focusableElement.onFocusChanged(true); + } else { + focusedElement.onFocusChanged(true); + } + } + + /** + * Finds the topmost focusable element at the given coordinates. + */ + private @Nullable AmbleElement findFocusableAt(AmbleElement element, double mouseX, double mouseY) { + // Check children first (reverse order for proper z-order) + java.util.List children = element.getChildren(); + for (int i = children.size() - 1; i >= 0; i--) { + AmbleElement child = children.get(i); + if (child.isVisible() && child.isHovered(mouseX, mouseY)) { + AmbleElement found = findFocusableAt(child, mouseX, mouseY); + if (found != null) return found; + } + } + + // Check this element - prefer Focusable interface + boolean canFocus = element instanceof Focusable focusable + ? focusable.canFocus() + : element.canFocus(); + + if (canFocus && element.isHovered(mouseX, mouseY)) { + return element; + } + + return null; + } + + @Override + public boolean shouldPause() { + return source.isShouldPause(); + } + } + + public static class Builder extends AbstractBuilder { + @Override + protected AmbleContainer create() { + return new AmbleContainer(); + } + + @Override + protected Builder self() { + return this; + } + } + + public static abstract class AbstractBuilder> { + protected final T container = create(); + + protected abstract T create(); + protected abstract B self(); + + public B padding(int padding) { + container.setPadding(padding); + return self(); + } + + public B spacing(int spacing) { + container.setSpacing(spacing); + return self(); + } + + public B horizontalAlign(UIAlign align) { + container.setHorizontalAlign(align); + return self(); + } + + public B verticalAlign(UIAlign align) { + container.setVerticalAlign(align); + return self(); + } + + public B layout(Rectangle layout) { + container.setPreferredLayout(layout); + container.setLayout(layout); + return self(); + } + + public B background(AmbleDisplayType background) { + container.setBackground(background); + return self(); + } + + public B background(Color color) { + container.setBackground(AmbleDisplayType.color(color)); + return self(); + } + + public B background(AmbleDisplayType.TextureData texture) { + container.setBackground(AmbleDisplayType.texture(texture)); + return self(); + } + + public B title(Text title) { + container.setTitle(title); + return self(); + } + + public B requiresNewRow(boolean requiresNewRow) { + container.setRequiresNewRow(requiresNewRow); + return self(); + } + + public B visible(boolean visible) { + container.setVisible(visible); + return self(); + } + + public B children(List children) { + for (AmbleElement child : children) { + container.addChild(child); + } + return self(); + } + + public B shouldPause(boolean shouldPause) { + container.setShouldPause(shouldPause); + return self(); + } + + public T build() { + return container; + } + } +} diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleDisplayType.java b/src/main/java/dev/amble/lib/client/gui/AmbleDisplayType.java new file mode 100644 index 0000000..1ba68c7 --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/AmbleDisplayType.java @@ -0,0 +1,66 @@ +package dev.amble.lib.client.gui; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +import java.awt.Color; +import java.awt.Rectangle; + +public record AmbleDisplayType(@Nullable Color color, @Nullable TextureData texture) { + public AmbleDisplayType { + if (color == null && texture == null) { + throw new IllegalArgumentException("Either color or texture must be provided"); + } + } + + public void render(DrawContext context, Rectangle layout) { + if (color != null) { + context.fill(layout.x, layout.y, layout.x + layout.width, layout.y + layout.height, + color.getRGB()); + } else if (texture != null) { + texture.render(context, layout); + } + } + + public static AmbleDisplayType color(Color color) { + return new AmbleDisplayType(color, null); + } + + public static AmbleDisplayType texture(TextureData identifier) { + return new AmbleDisplayType(null, identifier); + } + + public static AmbleDisplayType parse(JsonElement element) { + if (element.isJsonArray()) { + // parse 3 element array as RGB color, 4th element optional alpha + JsonArray arr = element.getAsJsonArray(); + int r = arr.get(0).getAsInt(); + int g = arr.get(1).getAsInt(); + int b = arr.get(2).getAsInt(); + int a = arr.size() > 3 ? arr.get(3).getAsInt() : 255; + return AmbleDisplayType.color(new Color(r, g, b, a)); + } else if (element.isJsonObject()) { + JsonObject obj = element.getAsJsonObject(); + Identifier texture = new Identifier(obj.get("texture").getAsString()); + int u = obj.get("u").getAsInt(); + int v = obj.get("v").getAsInt(); + int regionWidth = obj.get("regionWidth").getAsInt(); + int regionHeight = obj.get("regionHeight").getAsInt(); + int textureWidth = obj.get("textureWidth").getAsInt(); + int textureHeight = obj.get("textureHeight").getAsInt(); + return AmbleDisplayType.texture(new TextureData(texture, u, v, regionWidth, regionHeight, textureWidth, textureHeight)); + } + + throw new IllegalArgumentException("Invalid AmbleDisplayType JSON element"); + } + + public record TextureData(Identifier texture, int u, int v, int regionWidth, int regionHeight, int textureWidth, int textureHeight) { + public void render(DrawContext context, Rectangle layout) { + context.drawTexture(texture, layout.x, layout.y, layout.width, layout.height, u, v, regionWidth, regionHeight, textureWidth, textureHeight); + } + } +} diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleElement.java b/src/main/java/dev/amble/lib/client/gui/AmbleElement.java new file mode 100644 index 0000000..65ed69b --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/AmbleElement.java @@ -0,0 +1,292 @@ +package dev.amble.lib.client.gui; + +import dev.amble.lib.api.Identifiable; +import it.unimi.dsi.fastutil.ints.IntIntImmutablePair; +import it.unimi.dsi.fastutil.ints.IntIntPair; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.Drawable; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.Vec2f; +import org.jetbrains.annotations.Nullable; + +import java.awt.*; +import java.util.ArrayList; +import java.util.List; + +public interface AmbleElement extends Drawable, Identifiable { + default Vec2f getPosition() { + Rectangle layout = getLayout(); + return new Vec2f(layout.x, layout.y); + } + default void setPosition(Vec2f position) { + Rectangle layout = getLayout(); + layout.x = (int) position.x; + layout.y = (int) position.y; + setPreferredLayout(layout); + + recalcuateLayout(); + + if (getParent() != null) { + getParent().recalcuateLayout(); + } + } + + default void setDimensions(Vec2f dimensions) { + Rectangle layout = getLayout(); + layout.width = (int) dimensions.x; + layout.height = (int) dimensions.y; + setPreferredLayout(layout); + + recalcuateLayout(); + + if (getParent() != null) { + getParent().recalcuateLayout(); + } + } + + boolean isVisible(); + void setVisible(boolean visible); + + Rectangle getLayout(); + void setLayout(Rectangle layout); + + Rectangle getPreferredLayout(); + void setPreferredLayout(Rectangle preferredLayout); + + @Nullable AmbleElement getParent(); + void setParent(@Nullable AmbleElement parent); + + int getPadding(); + void setPadding(int padding); + + int getSpacing(); + void setSpacing(int spacing); + + UIAlign getHorizontalAlign(); + void setHorizontalAlign(UIAlign align); + + UIAlign getVerticalAlign(); + void setVerticalAlign(UIAlign align); + + boolean requiresNewRow(); + void setRequiresNewRow(boolean requiresNewRow); + + List getChildren(); + + default void addChild(AmbleElement child) { + if (!getChildren().contains(child)) { + getChildren().add(child); + child.setParent(this); + + recalcuateLayout(); + } + } + + private IntIntPair layoutRow( + List row, + int startX, + int maxWidth, + int cursorY, + int rowHeight + ) { + if (row.isEmpty()) return IntIntImmutablePair.of(cursorY, rowHeight); + + int rowWidth = row.stream() + .mapToInt(e -> e.getPreferredLayout().width) + .sum() + + getSpacing() * (row.size() - 1); + + int offsetX = switch (row.get(0).getHorizontalAlign()) { + case CENTRE -> (maxWidth - rowWidth) / 2; + case END -> maxWidth - rowWidth; + default -> 0; + }; + + int x = startX + offsetX; + boolean singleElementFullCenter = + row.size() == 1 && + row.get(0).getVerticalAlign() == UIAlign.CENTRE; + + int innerHeight = getLayout().height - getPadding() * 2; + + for (AmbleElement e : row) { + int y; + + if (singleElementFullCenter) { + y = getLayout().y + getPadding() + + (innerHeight - e.getPreferredLayout().height) / 2; + } else { + y = cursorY; + + if (e.getVerticalAlign() == UIAlign.CENTRE) + y += (rowHeight - e.getPreferredLayout().height) / 2; + else if (e.getVerticalAlign() == UIAlign.END) + y += rowHeight - e.getPreferredLayout().height; + } + + e.setLayout(new Rectangle( + x, y, + e.getPreferredLayout().width, + e.getPreferredLayout().height + )); + + e.recalcuateLayout(); + x += e.getPreferredLayout().width + getSpacing(); + } + + cursorY += rowHeight + getSpacing(); + row.clear(); + rowHeight = 0; + return IntIntImmutablePair.of(cursorY, rowHeight); + } + + default void recalcuateLayout() { + + int startX = getLayout().x + getPadding(); + int maxWidth = getLayout().width - getPadding() * 2; + + int cursorX = startX; + int cursorY = getLayout().y + getPadding(); + int rowHeight = 0; + + List row = new ArrayList<>(); + + for (AmbleElement child : getChildren()) { + int w = child.getPreferredLayout().width; + int h = child.getPreferredLayout().height; + + if (cursorX + w > startX + maxWidth || child.requiresNewRow()) { + IntIntPair result = layoutRow( + row, + startX, + maxWidth, + cursorY, + rowHeight + ); + cursorY = result.leftInt(); + rowHeight = result.rightInt(); + cursorX = startX; + } + + row.add(child); + child.recalcuateLayout(); + + cursorX += w + getSpacing(); + rowHeight = Math.max(rowHeight, h); + } + + if (!row.isEmpty()) { + layoutRow( + row, + startX, + maxWidth, + cursorY, + rowHeight + ); + } + } + + @Override + default void render(DrawContext context, int mouseX, int mouseY, float delta) { + if (!isVisible()) return; + + for (AmbleElement child : getChildren()) { + if (!child.isVisible()) continue; + + child.render(context, mouseX, mouseY, delta); + } + } + + default boolean isHovered(double mouseX, double mouseY) { + Rectangle layout = getLayout(); + return mouseX >= layout.x && mouseX <= layout.x + layout.width && + mouseY >= layout.y && mouseY <= layout.y + layout.height; + } + + default void onClick(double mouseX, double mouseY, int button) { + for (AmbleElement child : getChildren()) { + if (!child.isVisible()) continue; + + if (child.isHovered(mouseX, mouseY)) { + child.onClick(mouseX, mouseY, button); + } + } + } + + default void onRelease(double mouseX, double mouseY, int button) { + for (AmbleElement child : getChildren()) { + if (!child.isVisible()) continue; + + if (child.isHovered(mouseX, mouseY)) { + child.onRelease(mouseX, mouseY, button); + } + } + } + + /** + * Called when a key is pressed while this element or a child has focus. + * + * @param keyCode the GLFW key code + * @param scanCode the scan code + * @param modifiers modifier keys (shift, ctrl, alt) + * @return true if the event was handled + */ + default boolean onKeyPressed(int keyCode, int scanCode, int modifiers) { + return false; + } + + /** + * Called when a character is typed while this element or a child has focus. + * + * @param chr the typed character + * @param modifiers modifier keys + * @return true if the event was handled + */ + default boolean onCharTyped(char chr, int modifiers) { + return false; + } + + /** + * Called when focus changes for this element. + * + * @param focused true if gaining focus, false if losing focus + */ + default void onFocusChanged(boolean focused) { + // Default: do nothing + } + + /** + * Returns whether this element can receive keyboard focus. + *

+ * Elements implementing {@link Focusable} should override this or + * implement {@link Focusable#canFocus()} instead. + * + * @return true if focusable + */ + default boolean canFocus() { + return false; + } + + /** + * Collects all focusable elements in this subtree. + *

+ * This method checks both the {@link #canFocus()} method and whether + * the element implements {@link Focusable}. + * + * @param result list to add focusable elements to + */ + default void findFocusableElements(java.util.List result) { + if (this instanceof Focusable focusable && focusable.canFocus()) { + result.add(this); + } else if (canFocus()) { + result.add(this); + } + for (AmbleElement child : getChildren()) { + child.findFocusableElements(result); + } + } + + default Identifier toMcssFile() { + return this.id().withPrefixedPath("gui/").withSuffixedPath(".json"); + } +} diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleEntityDisplay.java b/src/main/java/dev/amble/lib/client/gui/AmbleEntityDisplay.java new file mode 100644 index 0000000..e31ef17 --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/AmbleEntityDisplay.java @@ -0,0 +1,342 @@ +package dev.amble.lib.client.gui; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import dev.amble.lib.client.gui.registry.AmbleElementParser; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.font.TextRenderer; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.ingame.InventoryScreen; +import net.minecraft.entity.Entity; +import net.minecraft.entity.LivingEntity; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.Vec2f; +import org.jetbrains.annotations.Nullable; + +import java.awt.*; +import java.util.UUID; + +/** + * A GUI element that displays an entity within a rectangular area. + *

+ * The entity is rendered using Minecraft's inventory-style entity rendering. + * Supports dynamic entity lookup by UUID, cursor-following for entity rotation, + * and fixed look-at positions. + */ +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class AmbleEntityDisplay extends AmbleContainer { + /** + * Special marker UUID for "use local player". All zeros. + */ + private static final UUID PLAYER_MARKER_UUID = new UUID(0L, 0L); + + /** + * The UUID of the entity to display. If null, displays "N/A". + * If set to PLAYER_MARKER_UUID, uses the local player. + */ + private @Nullable UUID entityUuid; + + /** + * Whether the entity should rotate to follow the mouse cursor. + * If false, uses {@link #fixedLookAt} position instead. + */ + @Setter + private boolean followCursor = false; + + /** + * The fixed position the entity should look at when {@link #followCursor} is false. + * Coordinates are relative to the element's position. + * Defaults to center of the element if not set. + */ + @Setter + private @Nullable Vec2f fixedLookAt = null; + + /** + * Scale multiplier for the entity rendering. + */ + @Setter + private float entityScale = 1.0f; + + // Entity cache + private transient @Nullable UUID cachedUuid = null; + private transient @Nullable LivingEntity cachedEntity = null; + + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + super.render(context, mouseX, mouseY, delta); + + Rectangle layout = getLayout(); + + // Try to find the entity (using cache) + LivingEntity livingEntity = findLivingEntity(); + + if (livingEntity == null) { + // Render "N/A" text centered in the rectangle + renderNoEntity(context, layout); + return; + } + + // Calculate rendering parameters + int centerX = layout.x + layout.width / 2; + int bottomY = layout.y + layout.height - getPadding() - 5; // Offset from bottom + + // Calculate the look-at position relative to entity center + float lookAtX, lookAtY; + if (followCursor) { + lookAtX = centerX - mouseX; + lookAtY = (layout.y + layout.height / 3.0f) - mouseY; + } else if (fixedLookAt != null) { + lookAtX = centerX - (layout.x + fixedLookAt.x); + lookAtY = (layout.y + layout.height / 3.0f) - (layout.y + fixedLookAt.y); + } else { + // Default: look straight ahead + lookAtX = 0; + lookAtY = 0; + } + + // Calculate entity size to fit in the rectangle with a small margin + float entityHeight = livingEntity.getHeight(); + int availableHeight = layout.height - getPadding() * 2; + int size = (int) ((availableHeight / entityHeight) * (entityScale - 0.1f)); + + // Use Minecraft's built-in entity rendering with mouse-based rotation + InventoryScreen.drawEntity(context, centerX, bottomY, size, lookAtX, lookAtY, livingEntity); + } + + /** + * Renders "N/A" text when no entity is available. + */ + private void renderNoEntity(DrawContext context, Rectangle layout) { + TextRenderer textRenderer = MinecraftClient.getInstance().textRenderer; + String text = "N/A"; + int textWidth = textRenderer.getWidth(text); + int textX = layout.x + (layout.width - textWidth) / 2; + int textY = layout.y + (layout.height - textRenderer.fontHeight) / 2; + context.drawText(textRenderer, text, textX, textY, 0xAAAAAA, false); + } + + /** + * Finds the living entity in the world by UUID, using cache for efficiency. + * + * @return the living entity, or null if not found or not a LivingEntity + */ + private @Nullable LivingEntity findLivingEntity() { + if (entityUuid == null) { + cachedEntity = null; + cachedUuid = null; + return null; + } + + MinecraftClient client = MinecraftClient.getInstance(); + if (client.world == null) { + return null; + } + + // Handle special "player" marker UUID + UUID lookupUuid = entityUuid; + if (PLAYER_MARKER_UUID.equals(entityUuid)) { + if (client.player != null) { + lookupUuid = client.player.getUuid(); + } else { + return null; + } + } + + // Check if cache is valid + if (lookupUuid.equals(cachedUuid) && cachedEntity != null && cachedEntity.isAlive()) { + return cachedEntity; + } + + // Cache miss - look up entity + cachedUuid = lookupUuid; + cachedEntity = null; + + // First try to find as player (more efficient) + Entity player = client.world.getPlayerByUuid(lookupUuid); + if (player instanceof LivingEntity living) { + cachedEntity = living; + return living; + } + + // Search through all entities + for (Entity entity : client.world.getEntities()) { + if (lookupUuid.equals(entity.getUuid()) && entity instanceof LivingEntity living) { + cachedEntity = living; + return living; + } + } + + return null; + } + + /** + * Invalidates the entity cache, forcing a re-lookup on next render. + */ + public void invalidateEntityCache() { + cachedEntity = null; + cachedUuid = null; + } + + /** + * Sets the entity UUID. Also invalidates the cache. + */ + public void setEntityUuid(@Nullable UUID entityUuid) { + if (!java.util.Objects.equals(this.entityUuid, entityUuid)) { + this.entityUuid = entityUuid; + invalidateEntityCache(); + } + } + + /** + * Sets the entity UUID from a string. + * + * @param uuidString the UUID string, or "player" for the local player + */ + public void setEntityUuidFromString(@Nullable String uuidString) { + if (uuidString == null || uuidString.isEmpty()) { + setEntityUuid(null); + return; + } + + if ("player".equalsIgnoreCase(uuidString)) { + // Use special marker that will be resolved at render time + setEntityUuid(PLAYER_MARKER_UUID); + return; + } + + try { + setEntityUuid(UUID.fromString(uuidString)); + } catch (IllegalArgumentException e) { + setEntityUuid(null); + } + } + + /** + * Gets the entity UUID as a string. + * + * @return the UUID string, "player" for local player marker, or null if not set + */ + public @Nullable String getEntityUuidAsString() { + if (entityUuid == null) { + return null; + } + if (PLAYER_MARKER_UUID.equals(entityUuid)) { + return "player"; + } + return entityUuid.toString(); + } + + public static Builder entityDisplayBuilder() { + return new Builder(); + } + + public static class Builder extends AbstractBuilder { + + @Override + protected AmbleEntityDisplay create() { + return new AmbleEntityDisplay(); + } + + @Override + protected Builder self() { + return this; + } + + public Builder entityUuid(UUID uuid) { + container.setEntityUuid(uuid); + return this; + } + + public Builder entityUuid(String uuidString) { + container.setEntityUuidFromString(uuidString); + return this; + } + + public Builder followCursor(boolean followCursor) { + container.setFollowCursor(followCursor); + return this; + } + + public Builder fixedLookAt(Vec2f lookAt) { + container.setFixedLookAt(lookAt); + return this; + } + + public Builder fixedLookAt(float x, float y) { + container.setFixedLookAt(new Vec2f(x, y)); + return this; + } + + public Builder entityScale(float scale) { + container.setEntityScale(scale); + return this; + } + } + + /** + * Parser for AmbleEntityDisplay elements. + *

+ * This parser handles JSON objects that have the "entity_uuid" property. + *

+ * Supported JSON properties: + *

    + *
  • {@code entity_uuid} - String UUID or "player" for local player
  • + *
  • {@code follow_cursor} - Boolean, whether entity follows mouse (default: false)
  • + *
  • {@code look_at} - Array [x, y] for fixed look position relative to element
  • + *
  • {@code entity_scale} - Float scale multiplier (default: 1.0)
  • + *
+ */ + public static class Parser implements AmbleElementParser { + + @Override + public @Nullable AmbleContainer parse(JsonObject json, @Nullable Identifier resourceId, AmbleContainer base) { + if (!json.has("entity_uuid")) { + return null; + } + + AmbleEntityDisplay display = AmbleEntityDisplay.entityDisplayBuilder().build(); + display.copyFrom(base); + + // Parse entity UUID + String uuidString = json.get("entity_uuid").getAsString(); + display.setEntityUuidFromString(uuidString); + + // Parse follow_cursor + if (json.has("follow_cursor")) { + display.setFollowCursor(json.get("follow_cursor").getAsBoolean()); + } + + // Parse look_at position + if (json.has("look_at")) { + if (json.get("look_at").isJsonArray()) { + JsonArray lookAtArray = json.get("look_at").getAsJsonArray(); + if (lookAtArray.size() >= 2) { + float x = lookAtArray.get(0).getAsFloat(); + float y = lookAtArray.get(1).getAsFloat(); + display.setFixedLookAt(new Vec2f(x, y)); + } + } + } + + // Parse entity_scale + if (json.has("entity_scale")) { + display.setEntityScale(json.get("entity_scale").getAsFloat()); + } + + return display; + } + + @Override + public int priority() { + // Higher than text (50), lower than button (100) + return 75; + } + } +} + diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleText.java b/src/main/java/dev/amble/lib/client/gui/AmbleText.java new file mode 100644 index 0000000..cb89668 --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/AmbleText.java @@ -0,0 +1,220 @@ +package dev.amble.lib.client.gui; + + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import dev.amble.lib.client.gui.registry.AmbleElementParser; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.font.TextRenderer; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.text.OrderedText; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class AmbleText extends AmbleContainer { + @Setter + private Text text; + @Setter + private UIAlign textHorizontalAlign = UIAlign.CENTRE; + @Setter + private UIAlign textVerticalAlign = UIAlign.CENTRE; + @Setter + private boolean shadow = true; + + // Cache fields for wrapped lines - marked transient to exclude from serialization + // These are recalculated at runtime based on layout and text content + private transient List cachedLines; + private transient int cachedWidth = -1; + private transient Text cachedText; + + /** + * Sets the text and invalidates the cache. + */ + public void setText(Text text) { + if (this.text != text) { + this.text = text; + invalidateTextCache(); + } + } + + /** + * Invalidates the cached wrapped lines, forcing recalculation on next render. + */ + public void invalidateTextCache() { + cachedLines = null; + cachedWidth = -1; + cachedText = null; + } + + @Override + public void recalcuateLayout() { + super.recalcuateLayout(); + invalidateTextCache(); + } + + private List getWrappedLines(TextRenderer tr) { + int currentWidth = getLayout().width - getPadding() * 2; + + // Recalculate if width changed or text changed + if (cachedLines == null || cachedWidth != currentWidth || cachedText != text) { + cachedLines = tr.wrapLines(text, currentWidth); + cachedWidth = currentWidth; + cachedText = text; + } + + return cachedLines; + } + + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + super.render(context, mouseX, mouseY, delta); + + if (text == null) return; + + TextRenderer tr = MinecraftClient.getInstance().textRenderer; + + // Use cached wrapped lines + List lines = getWrappedLines(tr); + + int lineHeight = tr.fontHeight; + int wrappedHeight = lines.size() * lineHeight; + + int wrappedWidth = 0; + for (OrderedText line : lines) { + wrappedWidth = Math.max(wrappedWidth, tr.getWidth(line)); + } + + // Calculate aligned position using WRAPPED size + int textX = getLayout().x; + int textY = getLayout().y; + + switch (textHorizontalAlign) { + case START -> textX += getPadding(); + case CENTRE -> textX += (getLayout().width - wrappedWidth) / 2; + case END -> textX += getLayout().width - wrappedWidth - getPadding(); + } + + switch (textVerticalAlign) { + case START -> textY += getPadding(); + case CENTRE -> textY += (getLayout().height - wrappedHeight) / 2; + case END -> textY += getLayout().height - wrappedHeight - getPadding(); + } + + drawWrappedLines(context, tr, lines, textX, textY, 0xFFFFFF, shadow); + } + + public static void drawWrappedLines( + DrawContext context, + TextRenderer textRenderer, + List lines, + int x, + int y, + int color, + boolean shadow + ) { + for (OrderedText line : lines) { + context.drawText(textRenderer, line, x, y, color, shadow); + y += textRenderer.fontHeight; + } + } + + public static Builder textBuilder() { + return new Builder(); + } + + public static class Builder extends AbstractBuilder { + + @Override + protected AmbleText create() { + return new AmbleText(); + } + + @Override + protected Builder self() { + return this; + } + + public Builder text(Text text) { + container.setText(text); + return this; + } + + public Builder textHorizontalAlign(UIAlign align) { + container.setTextHorizontalAlign(align); + return this; + } + + public Builder textVerticalAlign(UIAlign align) { + container.setTextVerticalAlign(align); + return this; + } + + public Builder shadow(boolean shadow) { + container.setShadow(shadow); + return this; + } + } + + /** + * Parser for AmbleText elements. + *

+ * This parser handles JSON objects that have the "text" property but are not buttons. + * Note: This parser has lower priority than AmbleButton.Parser, so buttons with text + * will be handled by the button parser instead. + */ + public static class Parser implements AmbleElementParser { + + @Override + public @Nullable AmbleContainer parse(JsonObject json, @Nullable Identifier resourceId, AmbleContainer base) { + if (!json.has("text")) { + return null; + } + + String context = resourceId != null ? " (resource: " + resourceId + ")" : ""; + String text = json.get("text").getAsString(); + + // Parse text alignment + UIAlign textHorizAlign = UIAlign.CENTRE; + UIAlign textVertAlign = UIAlign.CENTRE; + if (json.has("text_alignment")) { + if (!json.get("text_alignment").isJsonArray()) { + throw new IllegalStateException("UI text Alignment must be array [horizontal, vertical]" + context); + } + + JsonArray alignmentArray = json.get("text_alignment").getAsJsonArray(); + if (alignmentArray.size() < 2) { + throw new IllegalStateException("UI text Alignment array must have at least 2 elements" + context); + } + String horizAlignKey = alignmentArray.get(0).getAsString(); + String vertAlignKey = alignmentArray.get(1).getAsString(); + + textHorizAlign = UIAlign.valueOf(horizAlignKey.toUpperCase()); + textVertAlign = UIAlign.valueOf(vertAlignKey.toUpperCase()); + } + + // Convert the container to AmbleText + AmbleText ambleText = AmbleText.textBuilder().text(Text.translatable(text)).build(); + ambleText.copyFrom(base); + ambleText.setTextHorizontalAlign(textHorizAlign); + ambleText.setTextVerticalAlign(textVertAlign); + + return ambleText; + } + + @Override + public int priority() { + // Lower priority than button parser since buttons can have text + return 50; + } + } +} diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleTextInput.java b/src/main/java/dev/amble/lib/client/gui/AmbleTextInput.java new file mode 100644 index 0000000..8bc00a2 --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/AmbleTextInput.java @@ -0,0 +1,1007 @@ +package dev.amble.lib.client.gui; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import dev.amble.lib.AmbleKit; +import dev.amble.lib.client.gui.lua.LuaElement; +import dev.amble.lib.client.gui.registry.AmbleElementParser; +import dev.amble.lib.script.LuaScript; +import dev.amble.lib.script.ScriptManager; +import dev.amble.lib.script.lua.LuaBinder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.font.TextRenderer; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; +import org.lwjgl.glfw.GLFW; +import org.luaj.vm2.LuaValue; +import org.luaj.vm2.Varargs; + +import java.awt.*; + +/** + * A text input element that allows users to type and edit text. + *

+ * Features: + *

    + *
  • Full keyboard navigation (arrows, home, end, with ctrl for word jump)
  • + *
  • Text selection via shift+arrows or mouse drag
  • + *
  • Double-click to select word
  • + *
  • Copy/Cut/Paste/Select All (Ctrl+C/X/V/A)
  • + *
  • Horizontal scrolling for long text
  • + *
  • Customizable colors for text, placeholder, selection, and borders
  • + *
  • Placeholder text when empty
  • + *
  • Max length limit
  • + *
+ */ +@Getter +@NoArgsConstructor +public class AmbleTextInput extends AmbleContainer implements Focusable { + private static final int CURSOR_BLINK_RATE = 530; // ms + private static final int DOUBLE_CLICK_TIME = 250; // ms + + // Text content + private String text = ""; + + @Setter + private String placeholder = ""; + + @Setter + private int maxLength = Integer.MAX_VALUE; + + @Setter + private boolean editable = true; + + // Cursor and selection + private int cursorPosition = 0; + private int selectionStart = 0; + private int selectionEnd = 0; + + // Focus state + @Setter + private boolean focused = false; + + // Scroll offset for long text + private int scrollOffset = 0; + + // Cursor blink timing + private long lastCursorBlink = 0; + private boolean cursorVisible = true; + + // Double-click detection + private long lastClickTime = 0; + private int lastClickX = 0; + + // Text alignment + @Setter + private UIAlign textHorizontalAlign = UIAlign.START; + @Setter + private UIAlign textVerticalAlign = UIAlign.CENTRE; + + // Customizable colors + @Setter + private Color textColor = new Color(255, 255, 255); + @Setter + private Color placeholderColor = new Color(128, 128, 128); + @Setter + private Color selectionColor = new Color(0, 120, 215, 128); + @Setter + private Color borderColor = new Color(160, 160, 160); + @Setter + private Color focusedBorderColor = new Color(80, 160, 255); + @Setter + private Color cursorColor = new Color(255, 255, 255); + + // Script support + @Setter + private @Nullable LuaScript script; + + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + // Render background + getBackground().render(context, getLayout()); + + Rectangle layout = getLayout(); + TextRenderer textRenderer = MinecraftClient.getInstance().textRenderer; + + // Draw border + Color border = focused ? focusedBorderColor : borderColor; + drawBorder(context, layout, border); + + // Calculate text area with padding + int textX = layout.x + getPadding() + 2; + int textY = layout.y + (layout.height - textRenderer.fontHeight) / 2; + // Enable scissor to clip text within bounds + context.enableScissor(layout.x + getPadding(), layout.y, layout.x + layout.width - getPadding(), layout.y + layout.height); + + if (text.isEmpty() && !focused) { + // Draw placeholder + context.drawText(textRenderer, placeholder, textX - scrollOffset, textY, placeholderColor.getRGB(), false); + } else { + // Draw selection highlight + if (hasSelection() && focused) { + drawSelection(context, textRenderer, textX, textY); + } + + // Draw text + context.drawText(textRenderer, text, textX - scrollOffset, textY, textColor.getRGB(), false); + + // Draw cursor + if (focused && editable) { + updateCursorBlink(); + if (cursorVisible) { + drawCursor(context, textRenderer, textX, textY); + } + } + } + + context.disableScissor(); + + // Render children (if any) + for (AmbleElement child : getChildren()) { + if (child.isVisible()) { + child.render(context, mouseX, mouseY, delta); + } + } + } + + private void drawBorder(DrawContext context, Rectangle layout, Color color) { + int x = layout.x; + int y = layout.y; + int w = layout.width; + int h = layout.height; + int c = color.getRGB(); + + // Top + context.fill(x, y, x + w, y + 1, c); + // Bottom + context.fill(x, y + h - 1, x + w, y + h, c); + // Left + context.fill(x, y, x + 1, y + h, c); + // Right + context.fill(x + w - 1, y, x + w, y + h, c); + } + + private void drawSelection(DrawContext context, TextRenderer textRenderer, int textX, int textY) { + int selStart = Math.min(selectionStart, selectionEnd); + int selEnd = Math.max(selectionStart, selectionEnd); + + String beforeSelection = text.substring(0, selStart); + String selection = text.substring(selStart, selEnd); + + int startX = textX - scrollOffset + textRenderer.getWidth(beforeSelection); + int endX = startX + textRenderer.getWidth(selection); + + context.fill(startX, textY - 1, endX, textY + textRenderer.fontHeight + 1, selectionColor.getRGB()); + } + + private void drawCursor(DrawContext context, TextRenderer textRenderer, int textX, int textY) { + String beforeCursor = text.substring(0, cursorPosition); + int cursorX = textX - scrollOffset + textRenderer.getWidth(beforeCursor); + + context.fill(cursorX, textY - 1, cursorX + 1, textY + textRenderer.fontHeight + 1, cursorColor.getRGB()); + } + + private void updateCursorBlink() { + long now = System.currentTimeMillis(); + if (now - lastCursorBlink > CURSOR_BLINK_RATE) { + cursorVisible = !cursorVisible; + lastCursorBlink = now; + } + } + + private void resetCursorBlink() { + cursorVisible = true; + lastCursorBlink = System.currentTimeMillis(); + } + + /** + * Ensures the cursor is visible within the text field by adjusting scroll offset. + */ + private void ensureCursorVisible() { + TextRenderer textRenderer = MinecraftClient.getInstance().textRenderer; + Rectangle layout = getLayout(); + int availableWidth = layout.width - getPadding() * 2 - 4; + + String beforeCursor = text.substring(0, cursorPosition); + int cursorX = textRenderer.getWidth(beforeCursor); + + // Scroll left if cursor is before visible area + if (cursorX < scrollOffset) { + scrollOffset = cursorX; + } + + // Scroll right if cursor is after visible area + if (cursorX > scrollOffset + availableWidth) { + scrollOffset = cursorX - availableWidth; + } + + // Ensure scroll offset is never negative + scrollOffset = Math.max(0, scrollOffset); + + // Ensure we don't scroll past the end of text + int textWidth = textRenderer.getWidth(text); + if (textWidth <= availableWidth) { + scrollOffset = 0; + } else { + scrollOffset = Math.min(scrollOffset, textWidth - availableWidth); + } + } + + /** + * Sets the text content and clamps cursor/selection positions to valid bounds. + */ + public void setText(String text) { + this.text = text != null ? text : ""; + // Clamp cursor and selection to valid bounds + cursorPosition = Math.max(0, Math.min(cursorPosition, this.text.length())); + selectionStart = Math.max(0, Math.min(selectionStart, this.text.length())); + selectionEnd = Math.max(0, Math.min(selectionEnd, this.text.length())); + } + + // ===== Selection helpers ===== + + public boolean hasSelection() { + return selectionStart != selectionEnd; + } + + public String getSelectedText() { + if (!hasSelection()) return ""; + int start = Math.min(selectionStart, selectionEnd); + int end = Math.max(selectionStart, selectionEnd); + return text.substring(start, end); + } + + public void clearSelection() { + selectionStart = cursorPosition; + selectionEnd = cursorPosition; + } + + public void selectAll() { + selectionStart = 0; + selectionEnd = text.length(); + cursorPosition = text.length(); + } + + public void setSelection(int start, int end) { + selectionStart = Math.max(0, Math.min(start, text.length())); + selectionEnd = Math.max(0, Math.min(end, text.length())); + cursorPosition = selectionEnd; + } + + public void setCursorPosition(int position) { + this.cursorPosition = Math.max(0, Math.min(position, text.length())); + resetCursorBlink(); + ensureCursorVisible(); + } + + // ===== Text manipulation ===== + + public void insertText(String insert) { + if (!editable) return; + + // Delete selection first if any + if (hasSelection()) { + deleteSelection(); + } + + // Check max length + int availableSpace = maxLength - text.length(); + if (availableSpace <= 0) return; + + if (insert.length() > availableSpace) { + insert = insert.substring(0, availableSpace); + } + + // Filter out invalid characters + StringBuilder filtered = new StringBuilder(); + for (char c : insert.toCharArray()) { + if (isValidChar(c)) { + filtered.append(c); + } + } + insert = filtered.toString(); + + if (insert.isEmpty()) return; + + // Insert at cursor + String before = text.substring(0, cursorPosition); + String after = text.substring(cursorPosition); + text = before + insert + after; + cursorPosition += insert.length(); + clearSelection(); + ensureCursorVisible(); + onTextChanged(); + } + + private boolean isValidChar(char c) { + // Allow printable characters + return c >= 32 && c != 127; + } + + public void deleteSelection() { + if (!hasSelection() || !editable) return; + + int start = Math.min(selectionStart, selectionEnd); + int end = Math.max(selectionStart, selectionEnd); + + String before = text.substring(0, start); + String after = text.substring(end); + text = before + after; + cursorPosition = start; + clearSelection(); + ensureCursorVisible(); + onTextChanged(); + } + + public void deleteCharBefore() { + if (!editable) return; + + if (hasSelection()) { + deleteSelection(); + return; + } + + if (cursorPosition > 0) { + String before = text.substring(0, cursorPosition - 1); + String after = text.substring(cursorPosition); + text = before + after; + cursorPosition--; + ensureCursorVisible(); + onTextChanged(); + } + } + + public void deleteCharAfter() { + if (!editable) return; + + if (hasSelection()) { + deleteSelection(); + return; + } + + if (cursorPosition < text.length()) { + String before = text.substring(0, cursorPosition); + String after = text.substring(cursorPosition + 1); + text = before + after; + ensureCursorVisible(); + onTextChanged(); + } + } + + public void deleteWordBefore() { + if (!editable) return; + + if (hasSelection()) { + deleteSelection(); + return; + } + + int wordStart = findWordStart(cursorPosition); + if (wordStart < cursorPosition) { + String before = text.substring(0, wordStart); + String after = text.substring(cursorPosition); + text = before + after; + cursorPosition = wordStart; + ensureCursorVisible(); + onTextChanged(); + } + } + + public void deleteWordAfter() { + if (!editable) return; + + if (hasSelection()) { + deleteSelection(); + return; + } + + int wordEnd = findWordEnd(cursorPosition); + if (wordEnd > cursorPosition) { + String before = text.substring(0, cursorPosition); + String after = text.substring(wordEnd); + text = before + after; + ensureCursorVisible(); + onTextChanged(); + } + } + + // ===== Word navigation helpers ===== + + private int findWordStart(int position) { + if (position <= 0) return 0; + + int i = position - 1; + + // Skip any whitespace before the word + while (i > 0 && Character.isWhitespace(text.charAt(i))) { + i--; + } + + // Find start of word + while (i > 0 && !Character.isWhitespace(text.charAt(i - 1))) { + i--; + } + + return i; + } + + private int findWordEnd(int position) { + if (position >= text.length()) return text.length(); + + int i = position; + + // Skip current word + while (i < text.length() && !Character.isWhitespace(text.charAt(i))) { + i++; + } + + // Skip whitespace after word + while (i < text.length() && Character.isWhitespace(text.charAt(i))) { + i++; + } + + return i; + } + + /** + * Selects the word at the given cursor position. + */ + public void selectWordAt(int position) { + if (text.isEmpty()) return; + + position = Math.max(0, Math.min(position, text.length() - 1)); + + // If position is at whitespace, just position cursor there + if (Character.isWhitespace(text.charAt(position))) { + cursorPosition = position; + clearSelection(); + return; + } + + // Find word boundaries + int start = position; + while (start > 0 && !Character.isWhitespace(text.charAt(start - 1))) { + start--; + } + + int end = position; + while (end < text.length() && !Character.isWhitespace(text.charAt(end))) { + end++; + } + + selectionStart = start; + selectionEnd = end; + cursorPosition = end; + ensureCursorVisible(); + } + + // ===== Clipboard ===== + + public void copy() { + if (hasSelection()) { + MinecraftClient.getInstance().keyboard.setClipboard(getSelectedText()); + } + } + + public void cut() { + if (hasSelection() && editable) { + copy(); + deleteSelection(); + } + } + + public void paste() { + if (!editable) return; + String clipboard = MinecraftClient.getInstance().keyboard.getClipboard(); + if (clipboard != null && !clipboard.isEmpty()) { + // Remove newlines + clipboard = clipboard.replace("\r\n", " ").replace("\n", " ").replace("\r", " "); + insertText(clipboard); + } + } + + // ===== Cursor movement ===== + + public void moveCursorLeft(boolean selecting, boolean wordJump) { + int newPos; + if (wordJump) { + newPos = findWordStart(cursorPosition); + } else { + newPos = Math.max(0, cursorPosition - 1); + } + + if (selecting) { + if (!hasSelection()) { + selectionStart = cursorPosition; + } + selectionEnd = newPos; + } else { + if (hasSelection()) { + newPos = Math.min(selectionStart, selectionEnd); + } + clearSelection(); + } + + cursorPosition = newPos; + resetCursorBlink(); + ensureCursorVisible(); + } + + public void moveCursorRight(boolean selecting, boolean wordJump) { + int newPos; + if (wordJump) { + newPos = findWordEnd(cursorPosition); + } else { + newPos = Math.min(text.length(), cursorPosition + 1); + } + + if (selecting) { + if (!hasSelection()) { + selectionStart = cursorPosition; + } + selectionEnd = newPos; + } else { + if (hasSelection()) { + newPos = Math.max(selectionStart, selectionEnd); + } + clearSelection(); + } + + cursorPosition = newPos; + resetCursorBlink(); + ensureCursorVisible(); + } + + public void moveCursorToStart(boolean selecting) { + if (selecting) { + if (!hasSelection()) { + selectionStart = cursorPosition; + } + selectionEnd = 0; + } else { + clearSelection(); + } + + cursorPosition = 0; + resetCursorBlink(); + ensureCursorVisible(); + } + + public void moveCursorToEnd(boolean selecting) { + if (selecting) { + if (!hasSelection()) { + selectionStart = cursorPosition; + } + selectionEnd = text.length(); + } else { + clearSelection(); + } + + cursorPosition = text.length(); + resetCursorBlink(); + ensureCursorVisible(); + } + + // ===== Focus handling (Focusable interface) ===== + + @Override + public boolean canFocus() { + return editable && isVisible(); + } + + @Override + public void onFocusChanged(boolean focused) { + this.focused = focused; + if (!focused) { + clearSelection(); + } else { + resetCursorBlink(); + } + } + + // ===== Mouse handling ===== + + @Override + public void onClick(double mouseX, double mouseY, int button) { + if (!isHovered(mouseX, mouseY) || button != 0) { + super.onClick(mouseX, mouseY, button); + return; + } + + long now = System.currentTimeMillis(); + int clickX = (int) mouseX; + + // Double-click detection + if (now - lastClickTime < DOUBLE_CLICK_TIME && Math.abs(clickX - lastClickX) < 5) { + // Double-click: select word + int charIndex = getCharIndexAtX((int) mouseX); + selectWordAt(charIndex); + lastClickTime = 0; // Reset to prevent triple-click issues + } else { + // Single click: position cursor + int charIndex = getCharIndexAtX((int) mouseX); + cursorPosition = charIndex; + + boolean shiftHeld = Screen.hasShiftDown(); + if (shiftHeld && focused) { + // Extend selection + if (!hasSelection()) { + selectionStart = cursorPosition; + } + selectionEnd = charIndex; + cursorPosition = charIndex; + } else { + clearSelection(); + } + + resetCursorBlink(); + ensureCursorVisible(); + lastClickTime = now; + lastClickX = clickX; + } + + // Invoke script onClick if present + if (script != null && script.onClick() != null && !script.onClick().isnil()) { + try { + Varargs args = LuaValue.varargsOf(new LuaValue[]{ + LuaBinder.bind(new LuaElement(this)), + LuaValue.valueOf(mouseX), + LuaValue.valueOf(mouseY), + LuaValue.valueOf(button) + }); + script.onClick().invoke(args); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error invoking onClick script for AmbleTextInput {}:", id(), e); + } + } + + super.onClick(mouseX, mouseY, button); + } + + /** + * Handles mouse drag for selection. + */ + public void onMouseDragged(double mouseX, double mouseY, int button) { + if (!focused || button != 0) return; + + int charIndex = getCharIndexAtX((int) mouseX); + + if (!hasSelection()) { + selectionStart = cursorPosition; + } + selectionEnd = charIndex; + cursorPosition = charIndex; + + resetCursorBlink(); + ensureCursorVisible(); + } + + /** + * Gets the character index at the given screen X coordinate. + */ + private int getCharIndexAtX(int screenX) { + TextRenderer textRenderer = MinecraftClient.getInstance().textRenderer; + Rectangle layout = getLayout(); + + int relativeX = screenX - layout.x - getPadding() - 2 + scrollOffset; + + if (relativeX <= 0) return 0; + if (text.isEmpty()) return 0; + + // Binary search for the character position + int textWidth = textRenderer.getWidth(text); + if (relativeX >= textWidth) return text.length(); + + // Linear search (could optimize with binary search for very long text) + for (int i = 0; i <= text.length(); i++) { + int width = textRenderer.getWidth(text.substring(0, i)); + if (width > relativeX) { + // Check if closer to previous or current character + if (i > 0) { + int prevWidth = textRenderer.getWidth(text.substring(0, i - 1)); + if (relativeX - prevWidth < width - relativeX) { + return i - 1; + } + } + return i; + } + } + + return text.length(); + } + + // ===== Keyboard handling ===== + + @Override + public boolean onKeyPressed(int keyCode, int scanCode, int modifiers) { + if (!focused || !editable) return false; + + boolean ctrl = Screen.hasControlDown(); + boolean shift = Screen.hasShiftDown(); + + switch (keyCode) { + case GLFW.GLFW_KEY_LEFT: + moveCursorLeft(shift, ctrl); + return true; + + case GLFW.GLFW_KEY_RIGHT: + moveCursorRight(shift, ctrl); + return true; + + case GLFW.GLFW_KEY_HOME: + moveCursorToStart(shift); + return true; + + case GLFW.GLFW_KEY_END: + moveCursorToEnd(shift); + return true; + + case GLFW.GLFW_KEY_BACKSPACE: + if (ctrl) { + deleteWordBefore(); + } else { + deleteCharBefore(); + } + return true; + + case GLFW.GLFW_KEY_DELETE: + if (ctrl) { + deleteWordAfter(); + } else { + deleteCharAfter(); + } + return true; + + case GLFW.GLFW_KEY_A: + if (ctrl) { + selectAll(); + return true; + } + break; + + case GLFW.GLFW_KEY_C: + if (ctrl) { + copy(); + return true; + } + break; + + case GLFW.GLFW_KEY_X: + if (ctrl) { + cut(); + return true; + } + break; + + case GLFW.GLFW_KEY_V: + if (ctrl) { + paste(); + return true; + } + break; + } + + return false; + } + + @Override + public boolean onCharTyped(char chr, int modifiers) { + if (!focused || !editable) return false; + + if (isValidChar(chr)) { + insertText(String.valueOf(chr)); + return true; + } + + return false; + } + + // ===== Text change callback ===== + + protected void onTextChanged() { + // Future: could invoke an onTextChanged callback in the script + } + + // ===== Builder ===== + + public static Builder textInputBuilder() { + return new Builder(); + } + + public static class Builder extends AbstractBuilder { + + @Override + protected AmbleTextInput create() { + return new AmbleTextInput(); + } + + @Override + protected Builder self() { + return this; + } + + public Builder text(String text) { + container.setText(text); + return this; + } + + public Builder placeholder(String placeholder) { + container.setPlaceholder(placeholder); + return this; + } + + public Builder maxLength(int maxLength) { + container.setMaxLength(maxLength); + return this; + } + + public Builder editable(boolean editable) { + container.setEditable(editable); + return this; + } + + public Builder textColor(Color color) { + container.setTextColor(color); + return this; + } + + public Builder placeholderColor(Color color) { + container.setPlaceholderColor(color); + return this; + } + + public Builder selectionColor(Color color) { + container.setSelectionColor(color); + return this; + } + + public Builder borderColor(Color color) { + container.setBorderColor(color); + return this; + } + + public Builder focusedBorderColor(Color color) { + container.setFocusedBorderColor(color); + return this; + } + + public Builder cursorColor(Color color) { + container.setCursorColor(color); + return this; + } + + public Builder textHorizontalAlign(UIAlign align) { + container.setTextHorizontalAlign(align); + return this; + } + + public Builder textVerticalAlign(UIAlign align) { + container.setTextVerticalAlign(align); + return this; + } + } + + // ===== Parser ===== + + /** + * Parser for AmbleTextInput elements. + *

+ * This parser handles JSON objects that have the "text_input" property set to true. + *

+ * Supported JSON properties: + *

    + *
  • {@code text_input} - Boolean, must be true to create a text input
  • + *
  • {@code placeholder} - String placeholder text when empty
  • + *
  • {@code max_length} - Integer maximum character count
  • + *
  • {@code editable} - Boolean, whether text can be edited (default: true)
  • + *
  • {@code text} - String initial text content
  • + *
  • {@code text_alignment} - Array [horizontal, vertical] alignment
  • + *
  • {@code text_color} - Color array [r,g,b] or [r,g,b,a]
  • + *
  • {@code placeholder_color} - Color array
  • + *
  • {@code selection_color} - Color array
  • + *
  • {@code border_color} - Color array
  • + *
  • {@code focused_border_color} - Color array
  • + *
  • {@code cursor_color} - Color array
  • + *
  • {@code script} - Script ID for event handling
  • + *
+ */ + public static class Parser implements AmbleElementParser { + + @Override + public @Nullable AmbleContainer parse(JsonObject json, @Nullable Identifier resourceId, AmbleContainer base) { + if (!json.has("text_input") || !json.get("text_input").getAsBoolean()) { + return null; + } + + AmbleTextInput input = AmbleTextInput.textInputBuilder().build(); + input.copyFrom(base); + + // Parse placeholder + if (json.has("placeholder")) { + input.setPlaceholder(json.get("placeholder").getAsString()); + } + + // Parse max length + if (json.has("max_length")) { + input.setMaxLength(json.get("max_length").getAsInt()); + } + + // Parse editable + if (json.has("editable")) { + input.setEditable(json.get("editable").getAsBoolean()); + } + + // Parse initial text + if (json.has("text")) { + input.setText(json.get("text").getAsString()); + } + + // Parse text alignment + if (json.has("text_alignment")) { + JsonArray alignArray = json.get("text_alignment").getAsJsonArray(); + if (alignArray.size() >= 2) { + String hAlign = alignArray.get(0).getAsString().toUpperCase(); + String vAlign = alignArray.get(1).getAsString().toUpperCase(); + if (hAlign.equals("CENTER")) hAlign = "CENTRE"; + if (vAlign.equals("CENTER")) vAlign = "CENTRE"; + input.setTextHorizontalAlign(UIAlign.valueOf(hAlign)); + input.setTextVerticalAlign(UIAlign.valueOf(vAlign)); + } + } + + // Parse colors + if (json.has("text_color")) { + input.setTextColor(parseColor(json.get("text_color").getAsJsonArray())); + } + if (json.has("placeholder_color")) { + input.setPlaceholderColor(parseColor(json.get("placeholder_color").getAsJsonArray())); + } + if (json.has("selection_color")) { + input.setSelectionColor(parseColor(json.get("selection_color").getAsJsonArray())); + } + if (json.has("border_color")) { + input.setBorderColor(parseColor(json.get("border_color").getAsJsonArray())); + } + if (json.has("focused_border_color")) { + input.setFocusedBorderColor(parseColor(json.get("focused_border_color").getAsJsonArray())); + } + if (json.has("cursor_color")) { + input.setCursorColor(parseColor(json.get("cursor_color").getAsJsonArray())); + } + + // Parse script + if (json.has("script")) { + Identifier scriptId = new Identifier(json.get("script").getAsString()) + .withPrefixedPath("script/") + .withSuffixedPath(".lua"); + LuaScript script = ScriptManager.getInstance().load( + scriptId, + MinecraftClient.getInstance().getResourceManager() + ); + input.setScript(script); + } + + return input; + } + + private Color parseColor(JsonArray colorArray) { + int r = colorArray.get(0).getAsInt(); + int g = colorArray.get(1).getAsInt(); + int b = colorArray.get(2).getAsInt(); + int a = colorArray.size() > 3 ? colorArray.get(3).getAsInt() : 255; + return new Color(r, g, b, a); + } + + @Override + public int priority() { + // Higher than entity display (75), lower than button (100) + return 80; + } + } +} + diff --git a/src/main/java/dev/amble/lib/client/gui/Focusable.java b/src/main/java/dev/amble/lib/client/gui/Focusable.java new file mode 100644 index 0000000..1395580 --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/Focusable.java @@ -0,0 +1,77 @@ +package dev.amble.lib.client.gui; + +/** + * Interface for GUI elements that can receive keyboard focus. + *

+ * Implementing this interface allows elements to: + *

    + *
  • Receive keyboard events when focused
  • + *
  • Participate in Tab navigation
  • + *
  • Respond to focus gain/loss events
  • + *
+ *

+ * Elements that implement this interface should also handle rendering + * visual feedback to indicate their focused state (e.g., highlighted border). + */ +public interface Focusable { + + /** + * Returns whether this element can currently receive keyboard focus. + *

+ * An element may return false if it's disabled, invisible, or otherwise + * not ready to accept input. + * + * @return true if this element can receive focus + */ + boolean canFocus(); + + /** + * Returns whether this element currently has keyboard focus. + * + * @return true if this element is focused + */ + boolean isFocused(); + + /** + * Sets the focused state of this element. + *

+ * This is typically called by the screen's focus management system. + * Implementations should update their visual state accordingly. + * + * @param focused true to give focus, false to remove focus + */ + void setFocused(boolean focused); + + /** + * Called when this element gains or loses focus. + *

+ * This callback allows elements to perform additional actions when + * focus changes, such as starting/stopping cursor blink animations + * or clearing selections. + * + * @param focused true if gaining focus, false if losing focus + */ + void onFocusChanged(boolean focused); + + /** + * Called when a key is pressed while this element has focus. + * + * @param keyCode the GLFW key code + * @param scanCode the platform-specific scan code + * @param modifiers bitfield of modifier keys (shift, ctrl, alt) + * @return true if the key event was handled and should not propagate + */ + boolean onKeyPressed(int keyCode, int scanCode, int modifiers); + + /** + * Called when a character is typed while this element has focus. + *

+ * This is called for printable characters after key press events. + * + * @param chr the typed character + * @param modifiers bitfield of modifier keys + * @return true if the character was handled and should not propagate + */ + boolean onCharTyped(char chr, int modifiers); +} + diff --git a/src/main/java/dev/amble/lib/client/gui/UIAlign.java b/src/main/java/dev/amble/lib/client/gui/UIAlign.java new file mode 100644 index 0000000..b7c95fe --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/UIAlign.java @@ -0,0 +1,8 @@ +package dev.amble.lib.client.gui; + +public enum UIAlign { + START, + CENTRE, + END, + STRETCH +} diff --git a/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java b/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java new file mode 100644 index 0000000..918a24f --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java @@ -0,0 +1,581 @@ +package dev.amble.lib.client.gui.lua; + +import dev.amble.lib.client.gui.*; +import dev.amble.lib.script.lua.ClientMinecraftData; +import dev.amble.lib.script.lua.LuaExpose; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.Vec2f; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Nullable; + +import java.awt.*; +import java.util.List; + +/** + * A Lua-friendly wrapper around {@link AmbleElement}. + *

+ * This class implements AmbleElement by delegating to a wrapped element, + * while also exposing a simplified API for Lua scripts. + *

    + *
  • It provides only the methods that make sense for Lua scripting via @LuaExpose
  • + *
  • It converts Java types to Lua-compatible return values
  • + *
  • It implements AmbleElement by delegating to the wrapped element
  • + *
+ */ +public final class LuaElement implements AmbleElement { + + private final AmbleElement element; + private final ClientMinecraftData minecraftData = new ClientMinecraftData(); + + public LuaElement(AmbleElement element) { + this.element = element; + } + + // ===== AmbleElement implementation (delegating to wrapped element) ===== + + @LuaExpose + @Override + public Identifier id() { + return element.id(); + } + + @LuaExpose + @Override + public boolean isVisible() { + return element.isVisible(); + } + + @Override + public Rectangle getLayout() { + return element.getLayout(); + } + + @Override + public void setLayout(Rectangle layout) { + element.setLayout(layout); + } + + @Override + public Rectangle getPreferredLayout() { + return element.getPreferredLayout(); + } + + @Override + public void setPreferredLayout(Rectangle preferredLayout) { + element.setPreferredLayout(preferredLayout); + } + + @Override + public @Nullable AmbleElement getParent() { + return element.getParent(); + } + + @Override + public void setParent(@Nullable AmbleElement parent) { + element.setParent(parent); + } + + @Override + public int getPadding() { + return element.getPadding(); + } + + @Override + public void setPadding(int padding) { + element.setPadding(padding); + } + + @Override + public int getSpacing() { + return element.getSpacing(); + } + + @Override + public void setSpacing(int spacing) { + element.setSpacing(spacing); + } + + @Override + public UIAlign getHorizontalAlign() { + return element.getHorizontalAlign(); + } + + @Override + public void setHorizontalAlign(UIAlign align) { + element.setHorizontalAlign(align); + } + + @Override + public UIAlign getVerticalAlign() { + return element.getVerticalAlign(); + } + + @Override + public void setVerticalAlign(UIAlign align) { + element.setVerticalAlign(align); + } + + @Override + public boolean requiresNewRow() { + return element.requiresNewRow(); + } + + @Override + public void setRequiresNewRow(boolean requiresNewRow) { + element.setRequiresNewRow(requiresNewRow); + } + + @Override + public List getChildren() { + return element.getChildren(); + } + + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + element.render(context, mouseX, mouseY, delta); + } + + // ===== Lua-exposed methods ===== + + @LuaExpose + public int x() { + return element.getLayout().x; + } + + @LuaExpose + public int y() { + return element.getLayout().y; + } + + @LuaExpose + public int width() { + return element.getLayout().width; + } + + @LuaExpose + public int height() { + return element.getLayout().height; + } + + @LuaExpose + public void setPosition(int x, int y) { + element.setPosition(new Vec2f(x, y)); + } + + @LuaExpose + public void setDimensions(int width, int height) { + element.setDimensions(new Vec2f(width, height)); + } + + @Override + @LuaExpose + public void setVisible(boolean visible) { + element.setVisible(visible); + } + + @LuaExpose + public AmbleElement parent() { + return element.getParent(); + } + + @LuaExpose + public AmbleElement child(int index) { + if (index < 0 || index >= element.getChildren().size()) return null; + return element.getChildren().get(index); + } + + @LuaExpose + public int childCount() { + return element.getChildren().size(); + } + + @LuaExpose + public AmbleText findFirstText() { + return findFirstTextRecursive(element); + } + + private static AmbleText findFirstTextRecursive(AmbleElement element) { + if (element instanceof AmbleText text) { + return text; + } + for (AmbleElement child : element.getChildren()) { + AmbleText found = findFirstTextRecursive(child); + if (found != null) { + return found; + } + } + return null; + } + + @LuaExpose + public void setText(String text) { + if (element instanceof AmbleTextInput input) { + input.setText(text); + } else if (element instanceof AmbleText t) { + t.setText(Text.literal(text)); + } + } + + @LuaExpose + public String getText() { + if (element instanceof AmbleTextInput input) { + return input.getText(); + } else if (element instanceof AmbleText t) { + return t.getText().getString(); + } + return null; + } + + @LuaExpose + public void closeScreen() { + MinecraftClient.getInstance().setScreen(null); + } + + @LuaExpose + public ClientMinecraftData minecraft() { + return minecraftData; + } + + // ===== AmbleTextInput methods ===== + + /** + * Gets the placeholder text. + * Only works if the underlying element is an AmbleTextInput. + * + * @return the placeholder text, or null if not a text input + */ + @LuaExpose + public String getPlaceholder() { + if (element instanceof AmbleTextInput input) { + return input.getPlaceholder(); + } + return null; + } + + /** + * Sets the placeholder text. + * Only works if the underlying element is an AmbleTextInput. + * + * @param placeholder the placeholder text + */ + @LuaExpose + public void setPlaceholder(String placeholder) { + if (element instanceof AmbleTextInput input) { + input.setPlaceholder(placeholder); + } + } + + /** + * Gets the maximum text length. + * Only works if the underlying element is an AmbleTextInput. + * + * @return the max length, or -1 if not a text input + */ + @LuaExpose + public int getMaxLength() { + if (element instanceof AmbleTextInput input) { + return input.getMaxLength(); + } + return -1; + } + + /** + * Sets the maximum text length. + * Only works if the underlying element is an AmbleTextInput. + * + * @param maxLength the max length + */ + @LuaExpose + public void setMaxLength(int maxLength) { + if (element instanceof AmbleTextInput input) { + input.setMaxLength(maxLength); + } + } + + /** + * Checks if the text input is editable. + * Only works if the underlying element is an AmbleTextInput. + * + * @return true if editable, false otherwise + */ + @LuaExpose + public boolean isEditable() { + if (element instanceof AmbleTextInput input) { + return input.isEditable(); + } + return false; + } + + /** + * Sets whether the text input is editable. + * Only works if the underlying element is an AmbleTextInput. + * + * @param editable whether the input is editable + */ + @LuaExpose + public void setEditable(boolean editable) { + if (element instanceof AmbleTextInput input) { + input.setEditable(editable); + } + } + + /** + * Checks if the text input is focused. + * Only works if the underlying element is an AmbleTextInput. + * + * @return true if focused, false otherwise + */ + @LuaExpose + public boolean isInputFocused() { + if (element instanceof AmbleTextInput input) { + return input.isFocused(); + } + return false; + } + + /** + * Sets whether the text input is focused. + * Only works if the underlying element is an AmbleTextInput. + * + * @param focused whether the input should be focused + */ + @LuaExpose + public void setInputFocused(boolean focused) { + if (element instanceof AmbleTextInput input) { + input.setFocused(focused); + input.onFocusChanged(focused); + } + } + + /** + * Gets the selection start position. + * Only works if the underlying element is an AmbleTextInput. + * + * @return the selection start, or -1 if not a text input + */ + @LuaExpose + public int getSelectionStart() { + if (element instanceof AmbleTextInput input) { + return input.getSelectionStart(); + } + return -1; + } + + /** + * Gets the selection end position. + * Only works if the underlying element is an AmbleTextInput. + * + * @return the selection end, or -1 if not a text input + */ + @LuaExpose + public int getSelectionEnd() { + if (element instanceof AmbleTextInput input) { + return input.getSelectionEnd(); + } + return -1; + } + + /** + * Sets the text selection range. + * Only works if the underlying element is an AmbleTextInput. + * + * @param start selection start position + * @param end selection end position + */ + @LuaExpose + public void setSelection(int start, int end) { + if (element instanceof AmbleTextInput input) { + input.setSelection(start, end); + } + } + + /** + * Selects all text in the input. + * Only works if the underlying element is an AmbleTextInput. + */ + @LuaExpose + public void selectAll() { + if (element instanceof AmbleTextInput input) { + input.selectAll(); + } + } + + /** + * Sets the selection color. + * Only works if the underlying element is an AmbleTextInput. + * + * @param r red component (0-255) + * @param g green component (0-255) + * @param b blue component (0-255) + * @param a alpha component (0-255) + */ + @LuaExpose + public void setSelectionColor(int r, int g, int b, int a) { + if (element instanceof AmbleTextInput input) { + input.setSelectionColor(new java.awt.Color(r, g, b, a)); + } + } + + /** + * Sets the border color. + * Only works if the underlying element is an AmbleTextInput. + * + * @param r red component (0-255) + * @param g green component (0-255) + * @param b blue component (0-255) + * @param a alpha component (0-255) + */ + @LuaExpose + public void setBorderColor(int r, int g, int b, int a) { + if (element instanceof AmbleTextInput input) { + input.setBorderColor(new java.awt.Color(r, g, b, a)); + } + } + + /** + * Sets the focused border color. + * Only works if the underlying element is an AmbleTextInput. + * + * @param r red component (0-255) + * @param g green component (0-255) + * @param b blue component (0-255) + * @param a alpha component (0-255) + */ + @LuaExpose + public void setFocusedBorderColor(int r, int g, int b, int a) { + if (element instanceof AmbleTextInput input) { + input.setFocusedBorderColor(new java.awt.Color(r, g, b, a)); + } + } + + /** + * Sets the text color. + * Only works if the underlying element is an AmbleTextInput. + * + * @param r red component (0-255) + * @param g green component (0-255) + * @param b blue component (0-255) + * @param a alpha component (0-255) + */ + @LuaExpose + public void setTextColor(int r, int g, int b, int a) { + if (element instanceof AmbleTextInput input) { + input.setTextColor(new java.awt.Color(r, g, b, a)); + } + } + + /** + * Sets the placeholder color. + * Only works if the underlying element is an AmbleTextInput. + * + * @param r red component (0-255) + * @param g green component (0-255) + * @param b blue component (0-255) + * @param a alpha component (0-255) + */ + @LuaExpose + public void setPlaceholderColor(int r, int g, int b, int a) { + if (element instanceof AmbleTextInput input) { + input.setPlaceholderColor(new java.awt.Color(r, g, b, a)); + } + } + + // ===== AmbleEntityDisplay methods ===== + + /** + * Gets the entity UUID as a string. + * Only works if the underlying element is an AmbleEntityDisplay. + * + * @return the UUID string, or null if not an entity display or no UUID set + */ + @LuaExpose + public String getEntityUuid() { + if (element instanceof AmbleEntityDisplay display) { + return display.getEntityUuidAsString(); + } + return null; + } + + /** + * Sets the entity UUID from a string. + * Only works if the underlying element is an AmbleEntityDisplay. + * Accepts a UUID string or "player" for the local player. + * + * @param uuid the UUID string, or "player" for local player + */ + @LuaExpose + public void setEntityUuid(String uuid) { + if (element instanceof AmbleEntityDisplay display) { + display.setEntityUuidFromString(uuid); + } + } + + /** + * Checks if the entity display follows the cursor. + * Only works if the underlying element is an AmbleEntityDisplay. + * + * @return true if following cursor, false otherwise (or if not an entity display) + */ + @LuaExpose + public boolean isFollowCursor() { + if (element instanceof AmbleEntityDisplay display) { + return display.isFollowCursor(); + } + return false; + } + + /** + * Sets whether the entity display should follow the cursor. + * Only works if the underlying element is an AmbleEntityDisplay. + * + * @param followCursor true to follow cursor, false to use fixed look-at position + */ + @LuaExpose + public void setFollowCursor(boolean followCursor) { + if (element instanceof AmbleEntityDisplay display) { + display.setFollowCursor(followCursor); + } + } + + /** + * Sets the fixed look-at position for the entity display. + * Only works if the underlying element is an AmbleEntityDisplay. + * Coordinates are relative to the element's position. + * + * @param x the x coordinate + * @param y the y coordinate + */ + @LuaExpose + public void setLookAt(int x, int y) { + if (element instanceof AmbleEntityDisplay display) { + display.setFixedLookAt(new Vec2f(x, y)); + } + } + + /** + * Sets the entity scale for the entity display. + * Only works if the underlying element is an AmbleEntityDisplay. + * + * @param scale the scale multiplier (1.0 = normal size) + */ + @LuaExpose + public void setEntityScale(float scale) { + if (element instanceof AmbleEntityDisplay display) { + display.setEntityScale(scale); + } + } + + /** + * Returns the underlying AmbleElement wrapped by this LuaElement. + * This method is for internal use only and should not be called from Lua scripts. + * + * @return the wrapped AmbleElement + */ + @ApiStatus.Internal + AmbleElement unwrap() { + return element; + } +} diff --git a/src/main/java/dev/amble/lib/client/gui/registry/AmbleButtonParser.java b/src/main/java/dev/amble/lib/client/gui/registry/AmbleButtonParser.java new file mode 100644 index 0000000..ddcb594 --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/registry/AmbleButtonParser.java @@ -0,0 +1,128 @@ +package dev.amble.lib.client.gui.registry; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import dev.amble.lib.AmbleKit; +import dev.amble.lib.client.gui.*; +import dev.amble.lib.script.LuaScript; +import dev.amble.lib.script.ScriptManager; +import net.minecraft.SharedConstants; +import net.minecraft.client.MinecraftClient; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +import java.awt.*; + +/** + * Parser for AmbleButton elements. + *

+ * This parser handles JSON objects that have button-specific properties: + * on_click, script, hover_background, or press_background. + */ +public class AmbleButtonParser implements AmbleElementParser { + + @Override + public @Nullable AmbleContainer parse(JsonObject json, @Nullable Identifier resourceId, AmbleContainer base) { + // Check if this is a button (has button-specific properties) + boolean isButton = json.has("on_click") || json.has("script") || json.has("hover_background") || json.has("press_background"); + + if (!isButton) { + return null; + } + + String context = resourceId != null ? " (resource: " + resourceId + ")" : ""; + + // Handle text for button - add as child + if (json.has("text")) { + String text = json.get("text").getAsString(); + + // Parse text alignment + UIAlign textHorizAlign = UIAlign.CENTRE; + UIAlign textVertAlign = UIAlign.CENTRE; + if (json.has("text_alignment")) { + if (!json.get("text_alignment").isJsonArray()) { + throw new IllegalStateException("UI text Alignment must be array [horizontal, vertical]" + context); + } + + JsonArray alignmentArray = json.get("text_alignment").getAsJsonArray(); + if (alignmentArray.size() < 2) { + throw new IllegalStateException("UI text Alignment array must have at least 2 elements" + context); + } + String horizAlignKey = alignmentArray.get(0).getAsString(); + String vertAlignKey = alignmentArray.get(1).getAsString(); + + textHorizAlign = UIAlign.valueOf(horizAlignKey.toUpperCase()); + textVertAlign = UIAlign.valueOf(vertAlignKey.toUpperCase()); + } + + // For buttons with text, create a child AmbleText element with transparent background + AmbleText textChild = AmbleText.textBuilder() + .text(Text.translatable(text)) + .textHorizontalAlign(textHorizAlign) + .textVerticalAlign(textVertAlign) + .layout(new Rectangle(base.getLayout())) + .background(new Color(0, 0, 0, 0)) + .build(); + base.addChild(textChild); + } + + AmbleButton button = AmbleButton.buttonBuilder().build(); + button.copyFrom(base); + + if (json.has("on_click")) { + // todo run actual java methods via reflection + String clickCommand = json.get("on_click").getAsString(); + button.setOnClick(() -> { + try { + String string2 = SharedConstants.stripInvalidChars(clickCommand); + if (string2.startsWith("/")) { + if (!MinecraftClient.getInstance().player.networkHandler.sendCommand(string2.substring(1))) { + AmbleKit.LOGGER.error("Not allowed to run command with signed argument from click event: '{}'", string2); + } + } else { + AmbleKit.LOGGER.error("Failed to run command without '/' prefix from click event: '{}'", string2); + } + } catch (Exception e) { + AmbleKit.LOGGER.error("Error occurred while running command from click event: '{}'", clickCommand, e); + } + }); + } else { + button.setOnClick(() -> { + }); + } + + if (json.has("script")) { + Identifier scriptId = new Identifier(json.get("script").getAsString()).withPrefixedPath("script/").withSuffixedPath(".lua"); + LuaScript script = ScriptManager.getInstance().load( + scriptId, + MinecraftClient.getInstance().getResourceManager() + ); + + button.setScript(script); + } + + if (json.has("hover_background")) { + AmbleDisplayType hoverBg = AmbleDisplayType.parse(json.get("hover_background")); + button.setHoverDisplay(hoverBg); + } else { + button.setHoverDisplay(button.getBackground()); + } + + if (json.has("press_background")) { + AmbleDisplayType pressBg = AmbleDisplayType.parse(json.get("press_background")); + button.setPressDisplay(pressBg); + } else { + button.setPressDisplay(button.getBackground()); + } + + return button; + } + + @Override + public int priority() { + // Button has higher priority than text since buttons can have text + return 100; + } +} + diff --git a/src/main/java/dev/amble/lib/client/gui/registry/AmbleElementParser.java b/src/main/java/dev/amble/lib/client/gui/registry/AmbleElementParser.java new file mode 100644 index 0000000..80cb35f --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/registry/AmbleElementParser.java @@ -0,0 +1,74 @@ +package dev.amble.lib.client.gui.registry; + +import com.google.gson.JsonObject; +import dev.amble.lib.client.gui.AmbleContainer; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +/** + * Interface for custom element parsers that can be registered with {@link AmbleGuiRegistry}. + *

+ * Mods can implement this interface to add support for custom GUI element types. + * When a JSON object is being parsed, all registered parsers are checked in order + * until one returns a non-null result. + *

+ * Example usage: + *

{@code
+ * // Register a custom parser during mod initialization
+ * AmbleGuiRegistry.getInstance().registerParser(new AmbleElementParser() {
+ *     @Override
+ *     public @Nullable AmbleContainer parse(JsonObject json, @Nullable Identifier resourceId, AmbleContainer base) {
+ *         if (json.has("my_custom_type") && json.get("my_custom_type").getAsBoolean()) {
+ *             // Create your custom element and copy state from the base container
+ *             MyCustomContainer custom = new MyCustomContainer();
+ *             custom.copyFrom(base);
+ *             // Apply custom properties...
+ *             return custom;
+ *         }
+ *         // Return null to let other parsers handle it
+ *         return null;
+ *     }
+ *
+ *     @Override
+ *     public int priority() {
+ *         return 100; // Higher priority runs first
+ *     }
+ * });
+ * }
+ */ +@FunctionalInterface +public interface AmbleElementParser { + + /** + * Attempts to parse the given JSON object into an AmbleContainer. + *

+ * Implementations should check if the JSON contains properties specific to their + * custom element type. If it does, parse and return the element. If not, return null + * to allow other parsers (or the default parser) to handle it. + *

+ * The base container has already been parsed with all standard properties (layout, + * background, padding, spacing, alignment, children, etc). Custom parsers should + * use {@link AmbleContainer#copyFrom(AmbleContainer)} to copy this state to their + * custom element type. + * + * @param json the JSON object to parse + * @param resourceId the identifier of the resource being parsed (for error context), may be null + * @param base the base AmbleContainer already parsed with standard properties + * @return the parsed AmbleContainer, or null if this parser cannot handle the given JSON + */ + @Nullable + AmbleContainer parse(JsonObject json, @Nullable Identifier resourceId, AmbleContainer base); + + /** + * Returns the priority of this parser. Higher values are checked first. + *

+ * The default parser has a priority of 0. Custom parsers that want to override + * default behavior should return a positive value. + * + * @return the priority of this parser + */ + default int priority() { + return 0; + } +} + diff --git a/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java b/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java new file mode 100644 index 0000000..187cafe --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java @@ -0,0 +1,281 @@ +package dev.amble.lib.client.gui.registry; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import dev.amble.lib.AmbleKit; +import dev.amble.lib.client.gui.*; +import dev.amble.lib.script.ScriptManager; +import dev.amble.lib.register.datapack.DatapackRegistry; +import net.fabricmc.fabric.api.resource.ResourceManagerHelper; +import net.fabricmc.fabric.api.resource.SimpleSynchronousResourceReloadListener; +import net.minecraft.network.PacketByteBuf; +import net.minecraft.resource.ResourceManager; +import net.minecraft.resource.ResourceType; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.util.Identifier; +import org.apache.commons.lang3.NotImplementedException; + +import java.awt.*; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +// TODO: Consider removing dependency on DatapackRegistry - see team discussion +public class AmbleGuiRegistry extends DatapackRegistry implements SimpleSynchronousResourceReloadListener { + private static final AmbleGuiRegistry INSTANCE = new AmbleGuiRegistry(); + private final List parsers = new CopyOnWriteArrayList<>(); + + private AmbleGuiRegistry() { + ResourceManagerHelper.get(ResourceType.CLIENT_RESOURCES).registerReloadListener(this); + + // Register default parsers + registerParser(new AmbleButton.Parser()); + registerParser(new AmbleText.Parser()); + registerParser(new AmbleEntityDisplay.Parser()); + registerParser(new AmbleTextInput.Parser()); + } + + /** + * Registers a custom element parser. + *

+ * Parsers are called in order of priority (highest first) when parsing JSON. + * If a parser returns null, the next parser is tried. If all custom parsers + * return null, the default parsing logic is used. + * + * @param parser the parser to register + */ + public void registerParser(AmbleElementParser parser) { + parsers.add(parser); + parsers.sort(Comparator.comparingInt(AmbleElementParser::priority).reversed()); + } + + /** + * Unregisters a custom element parser. + * + * @param parser the parser to unregister + * @return true if the parser was found and removed + */ + public boolean unregisterParser(AmbleElementParser parser) { + return parsers.remove(parser); + } + + /** + * Initializes the GUI registry and related systems. + * Should be called during client initialization. + */ + public static void init() { + getInstance(); + ScriptManager.getInstance(); + } + + @Override + public AmbleContainer fallback() { + throw new NotImplementedException(); + } + + @Override + public Identifier getFabricId() { + return AmbleKit.id("gui"); + } + + /** + * Parses a JSON object into an AmbleContainer. + * + * @param json the JSON object to parse + * @return the parsed AmbleContainer + * @throws IllegalStateException if required fields are missing or invalid + */ + public static AmbleContainer parse(JsonObject json) { + return parse(json, null); + } + + /** + * Parses a JSON object into an AmbleContainer. + *

+ * This method first parses the base container properties, then checks all + * registered custom parsers in order of priority. If a parser returns a + * non-null result, that result is returned. Otherwise, the base container is returned. + * + * @param json the JSON object to parse + * @param resourceId the identifier of the resource being parsed (for error context), may be null + * @return the parsed AmbleContainer + * @throws IllegalStateException if required fields are missing or invalid + */ + public static AmbleContainer parse(JsonObject json, Identifier resourceId) { + // First parse the base container with all standard properties + AmbleContainer base = parseBase(json, resourceId); + + // Check custom parsers, passing the base container + for (AmbleElementParser parser : INSTANCE.parsers) { + AmbleContainer result = parser.parse(json, resourceId, base); + if (result != null) { + return result; + } + } + + // No parser handled it, return the base container + return base; + } + + /** + * Parses the base AmbleContainer properties from JSON. + *

+ * This method parses all standard container properties (layout, background, + * padding, spacing, alignment, children, etc.) and returns a base AmbleContainer. + * Custom parsers can then copy this state to their custom element types. + * + * @param json the JSON object to parse + * @param resourceId the identifier of the resource being parsed (for error context), may be null + * @return the parsed base AmbleContainer + * @throws IllegalStateException if required fields are missing or invalid + */ + public static AmbleContainer parseBase(JsonObject json, Identifier resourceId) { + String context = resourceId != null ? " (resource: " + resourceId + ")" : ""; + + // first parse background + AmbleDisplayType background; + if (json.has("background")) { + background = AmbleDisplayType.parse(json.get("background")); + } else { + throw new IllegalStateException("Amble container is missing background data" + context); + } + + Rectangle layout = new Rectangle(); + if (json.has("layout") && json.get("layout").isJsonArray()) { + JsonArray layoutArray = json.get("layout").getAsJsonArray(); + if (layoutArray.size() < 2) { + throw new IllegalStateException("Amble container layout must have at least 2 elements (width, height)" + context); + } + layout.setSize(layoutArray.get(0).getAsInt(), layoutArray.get(1).getAsInt()); + } else { + throw new IllegalStateException("Amble container is missing layout data" + context); + } + + int padding = 0; + if (json.has("padding")) { + padding = json.get("padding").getAsInt(); + } + + int spacing = 0; + if (json.has("spacing")) { + spacing = json.get("spacing").getAsInt(); + } + + UIAlign horizAlign = UIAlign.START; + UIAlign vertAlign = UIAlign.START; + if (json.has("alignment")) { + if (!json.get("alignment").isJsonArray()) { + throw new IllegalStateException("UI Alignment must be array [horizontal, vertical]" + context); + } + + JsonArray alignmentArray = json.get("alignment").getAsJsonArray(); + if (alignmentArray.size() < 2) { + throw new IllegalStateException("UI Alignment array must have at least 2 elements" + context); + } + String horizAlignKey = alignmentArray.get(0).getAsString(); + String vertAlignKey = alignmentArray.get(1).getAsString(); + + if (vertAlignKey.equalsIgnoreCase("center")) { + vertAlignKey = "centre"; + } + + if (horizAlignKey.equalsIgnoreCase("center")) { + horizAlignKey = "centre"; + } + + // try parse to enums + horizAlign = UIAlign.valueOf(horizAlignKey.toUpperCase()); + vertAlign = UIAlign.valueOf(vertAlignKey.toUpperCase()); + } + + boolean shouldPause = false; + if (json.has("should_pause")) { + if (!json.get("should_pause").isJsonPrimitive()) { + throw new IllegalStateException("UI should_pause should be boolean" + context); + } + + shouldPause = json.get("should_pause").getAsBoolean(); + } + + List children = new ArrayList<>(); + if (json.has("children")) { + if (!json.get("children").isJsonArray()) { + throw new IllegalStateException("UI children should be an object array of other ui elements" + context); + } + + JsonArray childrenArray = json.get("children").getAsJsonArray(); + + for (int i = 0; i < childrenArray.size(); i++) { + if (!(childrenArray.get(i).isJsonObject())) { + throw new IllegalStateException("UI child at index " + i + " is invalid, got " + childrenArray.get(i) + context); + } + + children.add(parse(childrenArray.get(i).getAsJsonObject(), resourceId)); + } + } + + boolean requiresNewRow = false; + if (json.has("requires_new_row")) { + if (!json.get("requires_new_row").isJsonPrimitive()) { + throw new IllegalStateException("UI requires_new_row should be boolean" + context); + } + requiresNewRow = json.get("requires_new_row").getAsBoolean(); + } + + AmbleContainer created = AmbleContainer.builder().background(background).layout(layout).padding(padding).spacing(spacing).horizontalAlign(horizAlign).verticalAlign(vertAlign).children(children).shouldPause(shouldPause).requiresNewRow(requiresNewRow).build(); + + if (json.has("id")) { + String idStr = json.get("id").getAsString(); + Identifier parsedId = Identifier.tryParse(idStr); + if (parsedId == null) { + throw new IllegalStateException("Invalid identifier '" + idStr + "'" + context); + } + created.setIdentifier(parsedId); + } + + + return created; + } + + @Override + public void reload(ResourceManager manager) { + clearCache(); + + for (Identifier rawId : manager.findResources("gui", filename -> filename.getPath().endsWith(".json")).keySet()) { + try (InputStream stream = manager.getResource(rawId).get().getInputStream()) { + String path = rawId.getPath(); + // remove "gui/" prefix and ".json" suffix + String idPath = path.substring("gui/".length(), path.length() - ".json".length()); + Identifier id = Identifier.of(rawId.getNamespace(), idPath); + + JsonObject json = JsonParser.parseReader(new InputStreamReader(stream)).getAsJsonObject(); + AmbleContainer model = parse(json, id); + model.setIdentifier(id); + + register(model); + + AmbleKit.LOGGER.debug("Loaded AmbleContainer {} {}", id, model); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error occurred while loading resource json {}", rawId.toString(), e); + } + } + } + + @Override + public void syncToClient(ServerPlayerEntity player) { + throw new UnsupportedOperationException("Client-side only registry"); + } + + @Override + public void readFromServer(PacketByteBuf buf) { + throw new UnsupportedOperationException("Client-side only registry"); + } + + public static AmbleGuiRegistry getInstance() { + return INSTANCE; + } +} diff --git a/src/main/java/dev/amble/lib/command/PlayAnimationCommand.java b/src/main/java/dev/amble/lib/command/PlayAnimationCommand.java index 31e2dc1..3c6ab27 100644 --- a/src/main/java/dev/amble/lib/command/PlayAnimationCommand.java +++ b/src/main/java/dev/amble/lib/command/PlayAnimationCommand.java @@ -1,5 +1,6 @@ package dev.amble.lib.command; +import com.mojang.brigadier.Command; import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.arguments.BoolArgumentType; import com.mojang.brigadier.arguments.StringArgumentType; @@ -45,6 +46,6 @@ private static int execute(CommandContext context) { String name = target.getEntityName(); context.getSource().sendFeedback(() -> Text.literal("Playing animation "+ animationId +" on "+ name), true); - return 1; + return Command.SINGLE_SUCCESS; } } diff --git a/src/main/java/dev/amble/lib/command/ServerScriptCommand.java b/src/main/java/dev/amble/lib/command/ServerScriptCommand.java new file mode 100644 index 0000000..bdb5c26 --- /dev/null +++ b/src/main/java/dev/amble/lib/command/ServerScriptCommand.java @@ -0,0 +1,259 @@ +package dev.amble.lib.command; + +import com.mojang.brigadier.Command; +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.suggestion.SuggestionProvider; +import dev.amble.lib.AmbleKit; +import dev.amble.lib.script.LuaScript; +import dev.amble.lib.script.ServerScriptManager; +import dev.amble.lib.script.lua.LuaBinder; +import dev.amble.lib.script.lua.ServerMinecraftData; +import org.luaj.vm2.LuaTable; +import org.luaj.vm2.LuaValue; +import net.minecraft.command.CommandSource; +import net.minecraft.command.argument.IdentifierArgumentType; +import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +import net.minecraft.util.Identifier; + +import java.util.Set; + +import static net.minecraft.server.command.CommandManager.argument; +import static net.minecraft.server.command.CommandManager.literal; + +/** + * Server-side command for managing server scripts. + * Usage: /serverscript [enable|disable|execute|toggle|list|available] [script_id] + */ +public class ServerScriptCommand { + + private static final String SCRIPT_PREFIX = "script/"; + private static final String SCRIPT_SUFFIX = ".lua"; + + private static String translationKey(String key) { + return "command." + AmbleKit.MOD_ID + ".script." + key; + } + + /** + * Converts a full script identifier to a display-friendly format. + * Removes the "script/" prefix and ".lua" suffix. + */ + private static String getDisplayId(Identifier id) { + return id.getPath().replace(SCRIPT_PREFIX, "").replace(SCRIPT_SUFFIX, ""); + } + + /** + * Converts a user-provided script ID to the full internal identifier. + */ + private static Identifier toFullScriptId(Identifier scriptId) { + return scriptId.withPrefixedPath(SCRIPT_PREFIX).withSuffixedPath(SCRIPT_SUFFIX); + } + + private static final SuggestionProvider TICKABLE_SCRIPT_SUGGESTIONS = (context, builder) -> { + return CommandSource.suggestIdentifiers( + ServerScriptManager.getInstance().getCache().entrySet().stream() + .filter(entry -> entry.getValue().onTick() != null && !entry.getValue().onTick().isnil()) + .map(entry -> Identifier.of(entry.getKey().getNamespace(), getDisplayId(entry.getKey()))), + builder + ); + }; + + private static final SuggestionProvider ENABLED_TICKABLE_SCRIPT_SUGGESTIONS = (context, builder) -> { + return CommandSource.suggestIdentifiers( + ServerScriptManager.getInstance().getEnabledScripts().stream() + .filter(id -> { + LuaScript script = ServerScriptManager.getInstance().getCache().get(id); + return script != null && script.onTick() != null && !script.onTick().isnil(); + }) + .map(id -> Identifier.of(id.getNamespace(), getDisplayId(id))), + builder + ); + }; + + private static final SuggestionProvider EXECUTABLE_SCRIPT_SUGGESTIONS = (context, builder) -> { + return CommandSource.suggestIdentifiers( + ServerScriptManager.getInstance().getCache().entrySet().stream() + .filter(entry -> entry.getValue().onExecute() != null && !entry.getValue().onExecute().isnil()) + .map(entry -> Identifier.of(entry.getKey().getNamespace(), getDisplayId(entry.getKey()))), + builder + ); + }; + + public static void register(CommandDispatcher dispatcher) { + dispatcher.register(literal("serverscript") + .requires(source -> source.hasPermissionLevel(2)) // Require operator permissions + .then(literal("execute") + .then(argument("id", IdentifierArgumentType.identifier()) + .suggests(EXECUTABLE_SCRIPT_SUGGESTIONS) + .executes(context -> execute(context, "")) + .then(argument("args", StringArgumentType.greedyString()) + .executes(context -> execute(context, StringArgumentType.getString(context, "args")))))) + .then(literal("enable") + .then(argument("id", IdentifierArgumentType.identifier()) + .suggests(TICKABLE_SCRIPT_SUGGESTIONS) + .executes(ServerScriptCommand::enable))) + .then(literal("disable") + .then(argument("id", IdentifierArgumentType.identifier()) + .suggests(ENABLED_TICKABLE_SCRIPT_SUGGESTIONS) + .executes(ServerScriptCommand::disable))) + .then(literal("toggle") + .then(argument("id", IdentifierArgumentType.identifier()) + .suggests(TICKABLE_SCRIPT_SUGGESTIONS) + .executes(ServerScriptCommand::toggle))) + .then(literal("list") + .executes(ServerScriptCommand::listEnabled)) + .then(literal("available") + .executes(ServerScriptCommand::listAvailable))); + } + + private static int execute(CommandContext context, String argsString) { + Identifier scriptId = context.getArgument("id", Identifier.class); + Identifier fullScriptId = toFullScriptId(scriptId); + + try { + LuaScript script = ServerScriptManager.getInstance().getCache().get(fullScriptId); + + if (script == null) { + context.getSource().sendError(Text.translatable("command.amblekit.script.error.not_found", scriptId)); + context.getSource().sendError(Text.literal("Server script '" + scriptId + "' not found")); + return 0; + } + + if (script.onExecute() == null || script.onExecute().isnil()) { + context.getSource().sendError(Text.translatable(translationKey("error.no_execute"), scriptId)); + return 0; + } + + // Create a new ServerMinecraftData with the executing player + ServerCommandSource source = context.getSource(); + ServerPlayerEntity player = source.getPlayer(); + ServerMinecraftData data = new ServerMinecraftData( + source.getServer(), + source.getWorld(), + player + ); + LuaValue boundData = LuaBinder.bind(data); + + // Parse arguments into a Lua table + LuaTable argsTable = new LuaTable(); + if (!argsString.isEmpty()) { + String[] args = argsString.split(" "); + for (int i = 0; i < args.length; i++) { + argsTable.set(i + 1, LuaValue.valueOf(args[i])); + } + } + + script.onExecute().call(boundData, argsTable); + context.getSource().sendFeedback(() -> Text.translatable(translationKey("executed"), scriptId), true); + return Command.SINGLE_SUCCESS; + } catch (Exception e) { + context.getSource().sendError(Text.translatable(translationKey("error.execute_failed"), scriptId, e.getMessage())); + AmbleKit.LOGGER.error("Failed to execute server script {}", scriptId, e); + return 0; + } + } + + private static int enable(CommandContext context) { + Identifier scriptId = context.getArgument("id", Identifier.class); + Identifier fullScriptId = toFullScriptId(scriptId); + + if (!ServerScriptManager.getInstance().getCache().containsKey(fullScriptId)) { + context.getSource().sendError(Text.translatable(translationKey("error.not_found"), scriptId)); + return 0; + } + + if (ServerScriptManager.getInstance().isEnabled(fullScriptId)) { + context.getSource().sendError(Text.translatable(translationKey("error.already_enabled"), scriptId)); + return 0; + } + + if (ServerScriptManager.getInstance().enable(fullScriptId)) { + context.getSource().sendFeedback(() -> Text.translatable(translationKey("enabled"), scriptId).formatted(Formatting.GREEN), true); + return Command.SINGLE_SUCCESS; + } else { + context.getSource().sendError(Text.translatable(translationKey("error.enable_failed"), scriptId)); + return 0; + } + } + + private static int disable(CommandContext context) { + Identifier scriptId = context.getArgument("id", Identifier.class); + Identifier fullScriptId = toFullScriptId(scriptId); + + if (!ServerScriptManager.getInstance().isEnabled(fullScriptId)) { + context.getSource().sendError(Text.translatable(translationKey("error.not_enabled"), scriptId)); + return 0; + } + + if (ServerScriptManager.getInstance().disable(fullScriptId)) { + context.getSource().sendFeedback(() -> Text.translatable(translationKey("disabled"), scriptId).formatted(Formatting.RED), true); + return Command.SINGLE_SUCCESS; + } else { + context.getSource().sendError(Text.translatable(translationKey("error.disable_failed"), scriptId)); + return 0; + } + } + + private static int toggle(CommandContext context) { + Identifier scriptId = context.getArgument("id", Identifier.class); + Identifier fullScriptId = toFullScriptId(scriptId); + + if (!ServerScriptManager.getInstance().getCache().containsKey(fullScriptId)) { + context.getSource().sendError(Text.translatable(translationKey("error.not_found"), scriptId)); + return 0; + } + + boolean wasEnabled = ServerScriptManager.getInstance().isEnabled(fullScriptId); + ServerScriptManager.getInstance().toggle(fullScriptId); + + if (wasEnabled) { + context.getSource().sendFeedback(() -> Text.translatable(translationKey("disabled"), scriptId).formatted(Formatting.RED), true); + } else { + context.getSource().sendFeedback(() -> Text.translatable(translationKey("enabled"), scriptId).formatted(Formatting.GREEN), true); + } + return Command.SINGLE_SUCCESS; + } + + private static int listEnabled(CommandContext context) { + Set enabled = ServerScriptManager.getInstance().getEnabledScripts(); + + if (enabled.isEmpty()) { + context.getSource().sendFeedback(() -> Text.translatable(translationKey("list.none_enabled")).formatted(Formatting.GRAY), false); + return Command.SINGLE_SUCCESS; + } + + context.getSource().sendFeedback(() -> Text.translatable(translationKey("list.enabled_header"), enabled.size()).formatted(Formatting.GOLD, Formatting.BOLD), false); + for (Identifier id : enabled) { + String displayId = getDisplayId(id); + context.getSource().sendFeedback(() -> + Text.literal("✓ ").formatted(Formatting.GREEN) + .append(Text.literal(id.getNamespace() + ":" + displayId).formatted(Formatting.WHITE)), false); + } + return Command.SINGLE_SUCCESS; + } + + private static int listAvailable(CommandContext context) { + Set available = ServerScriptManager.getInstance().getCache().keySet(); + Set enabled = ServerScriptManager.getInstance().getEnabledScripts(); + + if (available.isEmpty()) { + context.getSource().sendFeedback(() -> Text.translatable(translationKey("list.none_available")).formatted(Formatting.GRAY), false); + return Command.SINGLE_SUCCESS; + } + + context.getSource().sendFeedback(() -> Text.translatable(translationKey("list.available_header"), available.size()).formatted(Formatting.GOLD, Formatting.BOLD), false); + for (Identifier id : available) { + String displayId = getDisplayId(id); + Text statusIcon = enabled.contains(id) + ? Text.literal("✓ ").formatted(Formatting.GREEN) + : Text.literal("○ ").formatted(Formatting.GRAY); + context.getSource().sendFeedback(() -> + statusIcon.copy().append(Text.literal(id.getNamespace() + ":" + displayId).formatted(Formatting.WHITE)), false); + } + return Command.SINGLE_SUCCESS; + } +} diff --git a/src/main/java/dev/amble/lib/command/SetSkinCommand.java b/src/main/java/dev/amble/lib/command/SetSkinCommand.java index fb54d56..f793e93 100644 --- a/src/main/java/dev/amble/lib/command/SetSkinCommand.java +++ b/src/main/java/dev/amble/lib/command/SetSkinCommand.java @@ -1,5 +1,6 @@ package dev.amble.lib.command; +import com.mojang.brigadier.Command; import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.arguments.BoolArgumentType; import com.mojang.brigadier.arguments.StringArgumentType; @@ -12,13 +13,16 @@ import net.minecraft.command.argument.EntityArgumentType; import net.minecraft.entity.Entity; import net.minecraft.server.command.ServerCommandSource; -import net.minecraft.server.network.ServerPlayerEntity; import net.minecraft.text.Text; import static net.minecraft.server.command.CommandManager.argument; import static net.minecraft.server.command.CommandManager.literal; public class SetSkinCommand { + private static String translationKey(String key) { + return "command." + AmbleKit.MOD_ID + ".skin." + key; + } + public static void register(CommandDispatcher dispatcher) { dispatcher.register(literal(AmbleKit.MOD_ID) .requires(source -> source.hasPermissionLevel(2)) @@ -38,19 +42,19 @@ private static int executeClear(CommandContext context) { entity = EntityArgumentType.getEntity(context, "target"); if (!(entity instanceof PlayerSkinTexturable)) { - context.getSource().sendError(Text.literal("Target is not a PlayerSkinTexturable")); + context.getSource().sendError(Text.translatable(translationKey("error.not_texturable"))); return 0; } texturable = (PlayerSkinTexturable) entity; } catch (CommandSyntaxException e) { - context.getSource().sendError(Text.literal("Invalid Target")); + context.getSource().sendError(Text.translatable(translationKey("error.invalid_target"))); return 0; } SkinTracker.getInstance().removeSynced(texturable.getUuid()); String username = entity.getEntityName(); - context.getSource().sendFeedback(() -> Text.literal("Cleared skin of "+ username), true); + context.getSource().sendFeedback(() -> Text.translatable(translationKey("cleared"), username), true); return 1; } @@ -64,19 +68,19 @@ private static int executeSlim(CommandContext context) { entity = EntityArgumentType.getEntity(context, "target"); if (!(entity instanceof PlayerSkinTexturable)) { - context.getSource().sendError(Text.literal("Target is not a PlayerSkinTexturable")); + context.getSource().sendError(Text.translatable(translationKey("error.not_texturable"))); return 0; } texturable = (PlayerSkinTexturable) entity; } catch (CommandSyntaxException e) { - context.getSource().sendError(Text.literal("Invalid Target")); + context.getSource().sendError(Text.translatable(translationKey("error.invalid_target"))); return 0; } SkinData data = SkinTracker.getInstance().get(texturable.getUuid()); if (data == null) { - context.getSource().sendError(Text.literal("Target is not disguised.")); + context.getSource().sendError(Text.translatable(translationKey("error.not_disguised"))); return 0; } @@ -85,7 +89,7 @@ private static int executeSlim(CommandContext context) { SkinTracker.getInstance().putSynced(texturable.getUuid(), data); String username = entity.getEntityName(); - context.getSource().sendFeedback(() -> Text.literal("Set slimness of "+ username +" to " + slim), true); + context.getSource().sendFeedback(() -> Text.translatable(translationKey("slimness_set"), username, slim), true); return 1; } @@ -99,13 +103,13 @@ private static int execute(CommandContext context) { entity = EntityArgumentType.getEntity(context, "target"); if (!(entity instanceof PlayerSkinTexturable)) { - context.getSource().sendError(Text.literal("Target is not a PlayerSkinTexturable")); + context.getSource().sendError(Text.translatable(translationKey("error.not_texturable"))); return 0; } texturable = (PlayerSkinTexturable) entity; } catch (CommandSyntaxException e) { - context.getSource().sendError(Text.literal("Invalid Target")); + context.getSource().sendError(Text.translatable(translationKey("error.invalid_target"))); return 0; } @@ -117,7 +121,7 @@ private static int execute(CommandContext context) { SkinTracker.getInstance().putSynced(texturable.getUuid(), data); String username = entity.getEntityName(); - context.getSource().sendFeedback(() -> Text.literal("Set skin of " + username + " to " + value), true); + context.getSource().sendFeedback(() -> Text.translatable(translationKey("set"), username, value), true); return 1; } @@ -126,7 +130,7 @@ private static int execute(CommandContext context) { result.upload(entity.getUuid()); String username = entity.getEntityName(); - context.getSource().sendFeedback(() -> Text.literal("Set skin of " + username + " to " + value), true); + context.getSource().sendFeedback(() -> Text.translatable(translationKey("set"), username, value), true); }); return 1; @@ -142,13 +146,13 @@ private static int executeWithSlim(CommandContext context) entity = EntityArgumentType.getEntity(context, "target"); if (!(entity instanceof PlayerSkinTexturable)) { - context.getSource().sendError(Text.literal("Target is not a PlayerSkinTexturable")); + context.getSource().sendError(Text.translatable(translationKey("error.not_texturable"))); return 0; } texturable = (PlayerSkinTexturable) entity; } catch (CommandSyntaxException e) { - context.getSource().sendError(Text.literal("Invalid Target")); + context.getSource().sendError(Text.translatable(translationKey("error.invalid_target"))); return 0; } @@ -159,8 +163,8 @@ private static int executeWithSlim(CommandContext context) SkinTracker.getInstance().putSynced(texturable.getUuid(), data); String username = entity.getEntityName(); - context.getSource().sendFeedback(() -> Text.literal("Set skin of "+ username +" to " + value), true); + context.getSource().sendFeedback(() -> Text.translatable(translationKey("set"), username, value), true); - return 1; + return Command.SINGLE_SUCCESS; } } diff --git a/src/main/java/dev/amble/lib/datagen/AmbleKitDatagen.java b/src/main/java/dev/amble/lib/datagen/AmbleKitDatagen.java new file mode 100644 index 0000000..b8e6b84 --- /dev/null +++ b/src/main/java/dev/amble/lib/datagen/AmbleKitDatagen.java @@ -0,0 +1,67 @@ +package dev.amble.lib.datagen; + +import dev.amble.lib.datagen.lang.AmbleLanguageProvider; +import dev.amble.lib.datagen.lang.LanguageType; +import net.fabricmc.fabric.api.datagen.v1.DataGeneratorEntrypoint; +import net.fabricmc.fabric.api.datagen.v1.FabricDataGenerator; +import net.fabricmc.fabric.api.datagen.v1.FabricDataOutput; + +public class AmbleKitDatagen implements DataGeneratorEntrypoint { + @Override + public void onInitializeDataGenerator(FabricDataGenerator generator) { + FabricDataGenerator.Pack pack = generator.createPack(); + pack.addProvider(AmbleKitLanguageProvider::new); + } + + public static class AmbleKitLanguageProvider extends AmbleLanguageProvider { + public AmbleKitLanguageProvider(FabricDataOutput output) { + super(output, LanguageType.EN_US); + } + + @Override + public void generateTranslations(TranslationBuilder builder) { + // Skin command translations + addTranslation("command." + modid + ".skin.error.not_texturable", "Target is not a PlayerSkinTexturable"); + addTranslation("command." + modid + ".skin.error.invalid_target", "Invalid Target"); + addTranslation("command." + modid + ".skin.error.not_disguised", "Target is not disguised."); + addTranslation("command." + modid + ".skin.cleared", "Cleared skin of %s"); + addTranslation("command." + modid + ".skin.slimness_set", "Set slimness of %s to %s"); + addTranslation("command." + modid + ".skin.set", "Set skin of %s to %s"); + + // Server script command translations + addTranslation("command." + modid + ".script.error.not_found", "Server script '%s' not found"); + addTranslation("command." + modid + ".script.error.no_execute", "Server script '%s' has no onExecute function"); + addTranslation("command." + modid + ".script.error.execute_failed", "Failed to execute server script '%s': %s"); + addTranslation("command." + modid + ".script.error.already_enabled", "Server script '%s' is already enabled"); + addTranslation("command." + modid + ".script.error.enable_failed", "Failed to enable server script '%s'"); + addTranslation("command." + modid + ".script.error.not_enabled", "Server script '%s' is not enabled"); + addTranslation("command." + modid + ".script.error.disable_failed", "Failed to disable server script '%s'"); + addTranslation("command." + modid + ".script.executed", "Executed server script: %s"); + addTranslation("command." + modid + ".script.enabled", "Enabled server script: %s"); + addTranslation("command." + modid + ".script.disabled", "Disabled server script: %s"); + addTranslation("command." + modid + ".script.list.none_enabled", "No server scripts are currently enabled"); + addTranslation("command." + modid + ".script.list.enabled_header", "━━━ Enabled Server Scripts (%s) ━━━"); + addTranslation("command." + modid + ".script.list.none_available", "No server scripts available"); + addTranslation("command." + modid + ".script.list.available_header", "━━━ Available Server Scripts (%s) ━━━"); + + // Client script command translations + addTranslation("command." + modid + ".client_script.error.not_found", "Script '%s' not found"); + addTranslation("command." + modid + ".client_script.error.no_execute", "Script '%s' has no onExecute function"); + addTranslation("command." + modid + ".client_script.error.execute_failed", "Failed to execute script '%s': %s"); + addTranslation("command." + modid + ".client_script.error.already_enabled", "Script '%s' is already enabled"); + addTranslation("command." + modid + ".client_script.error.enable_failed", "Failed to enable script '%s'"); + addTranslation("command." + modid + ".client_script.error.not_enabled", "Script '%s' is not enabled"); + addTranslation("command." + modid + ".client_script.error.disable_failed", "Failed to disable script '%s'"); + addTranslation("command." + modid + ".client_script.executed", "Executed script: %s"); + addTranslation("command." + modid + ".client_script.enabled", "Enabled script: %s"); + addTranslation("command." + modid + ".client_script.disabled", "Disabled script: %s"); + addTranslation("command." + modid + ".client_script.list.none_enabled", "No client scripts are currently enabled"); + addTranslation("command." + modid + ".client_script.list.enabled_header", "━━━ Enabled Client Scripts (%s) ━━━"); + addTranslation("command." + modid + ".client_script.list.none_available", "No client scripts available"); + addTranslation("command." + modid + ".client_script.list.available_header", "━━━ Available Client Scripts (%s) ━━━"); + + super.generateTranslations(builder); + } + } +} + diff --git a/src/main/java/dev/amble/lib/script/AbstractScriptManager.java b/src/main/java/dev/amble/lib/script/AbstractScriptManager.java new file mode 100644 index 0000000..a683a66 --- /dev/null +++ b/src/main/java/dev/amble/lib/script/AbstractScriptManager.java @@ -0,0 +1,201 @@ +package dev.amble.lib.script; + +import dev.amble.lib.AmbleKit; +import dev.amble.lib.script.lua.LuaBinder; +import dev.amble.lib.script.lua.MinecraftData; +import net.fabricmc.fabric.api.resource.SimpleSynchronousResourceReloadListener; +import net.minecraft.resource.Resource; +import net.minecraft.resource.ResourceManager; +import net.minecraft.util.Identifier; +import org.luaj.vm2.Globals; +import org.luaj.vm2.LuaValue; +import org.luaj.vm2.lib.jse.JsePlatform; + +import java.io.InputStreamReader; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * Abstract base class for script managers. + * Provides common functionality for loading, caching, and managing Lua scripts. + */ +public abstract class AbstractScriptManager implements SimpleSynchronousResourceReloadListener { + + protected final Map cache = new HashMap<>(); + protected final Map dataCache = new HashMap<>(); + protected final Set enabledScripts = new HashSet<>(); + + /** + * Creates the MinecraftData instance for a script. + * Subclasses should override to provide client or server-specific data. + */ + protected abstract MinecraftData createMinecraftData(); + + /** + * Gets the log prefix for this script manager (e.g., "script" or "server script"). + */ + protected abstract String getLogPrefix(); + + /** + * Reloads all scripts from the given resource manager. + */ + @Override + public void reload(ResourceManager manager) { + // Disable all scripts before clearing cache + for (Identifier id : new HashSet<>(enabledScripts)) { + disable(id); + } + + cache.clear(); + dataCache.clear(); + + // Discover all script files and populate the cache + manager.findResources("script", id -> id.getPath().endsWith(".lua")) + .keySet() + .forEach(id -> { + try { + load(id, manager); + } catch (Exception e) { + AmbleKit.LOGGER.error("Failed to load {} {}", getLogPrefix(), id, e); + } + }); + + AmbleKit.LOGGER.info("Loaded {} {}s", cache.size(), getLogPrefix()); + } + + /** + * Loads a script from the resource manager. + */ + public LuaScript load(Identifier id, ResourceManager manager) { + return cache.computeIfAbsent(id, key -> { + try { + Resource res = manager.getResource(key).orElseThrow(); + Globals globals = JsePlatform.standardGlobals(); + + // Create and cache the minecraft data for this script + MinecraftData data = createMinecraftData(); + data.setScriptName(key.toString()); + LuaValue boundData = LuaBinder.bind(data); + dataCache.put(key, boundData); + + // Inject minecraft global for scripts to use + globals.set("minecraft", boundData); + + LuaValue chunk = globals.load( + new InputStreamReader(res.getInputStream()), + key.toString() + ); + chunk.call(); + + LuaScript script = new LuaScript(globals); + + // Call onRegister when script is first loaded into the manager + if (script.onRegister() != null && !script.onRegister().isnil()) { + try { + script.onRegister().call(boundData); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error in onRegister for {} {}", getLogPrefix(), key, e); + } + } + + return script; + } catch (Exception e) { + throw new RuntimeException("Failed to load " + getLogPrefix() + " " + key, e); + } + }); + } + + public Map getCache() { + return cache; + } + + public Set getEnabledScripts() { + return enabledScripts; + } + + public boolean isEnabled(Identifier id) { + return enabledScripts.contains(id); + } + + public boolean enable(Identifier id) { + if (enabledScripts.contains(id)) { + return false; // Already enabled + } + + LuaScript script = cache.get(id); + if (script == null) { + return false; + } + + enabledScripts.add(id); + + // Call onEnable with minecraft data as first argument + if (script.onEnable() != null && !script.onEnable().isnil()) { + try { + LuaValue data = dataCache.get(id); + script.onEnable().call(data); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error in onEnable for {} {}", getLogPrefix(), id, e); + } + } + + AmbleKit.LOGGER.info("Enabled {}: {}", getLogPrefix(), id); + return true; + } + + public boolean disable(Identifier id) { + if (!enabledScripts.contains(id)) { + return false; // Not enabled + } + + LuaScript script = cache.get(id); + + // Call onDisable with minecraft data as first argument before removing + if (script != null && script.onDisable() != null && !script.onDisable().isnil()) { + try { + LuaValue data = dataCache.get(id); + script.onDisable().call(data); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error in onDisable for {} {}", getLogPrefix(), id, e); + } + } + + enabledScripts.remove(id); + AmbleKit.LOGGER.info("Disabled {}: {}", getLogPrefix(), id); + return true; + } + + public boolean toggle(Identifier id) { + if (isEnabled(id)) { + return disable(id); + } else { + return enable(id); + } + } + + /** + * Called each tick to update enabled scripts. + */ + public void tick() { + for (Identifier id : enabledScripts) { + LuaScript script = cache.get(id); + if (script != null && script.onTick() != null && !script.onTick().isnil()) { + try { + LuaValue data = dataCache.get(id); + script.onTick().call(data); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error in onTick for {} {}", getLogPrefix(), id, e); + } + } + } + } + + /** + * Get the bound minecraft data for a script. + */ + public LuaValue getScriptData(Identifier id) { + return dataCache.get(id); + } +} diff --git a/src/main/java/dev/amble/lib/script/LuaScript.java b/src/main/java/dev/amble/lib/script/LuaScript.java new file mode 100644 index 0000000..9471d24 --- /dev/null +++ b/src/main/java/dev/amble/lib/script/LuaScript.java @@ -0,0 +1,75 @@ +package dev.amble.lib.script; + +import org.luaj.vm2.Globals; +import org.luaj.vm2.LuaValue; + +/** + * Represents a loaded Lua script with its globals. + *

+ * All callbacks are looked up from globals on demand. + */ +public record LuaScript(Globals globals) { + + /** + * Gets a callback by name. + * Returns NIL if the callback is not defined. + */ + public LuaValue getCallback(String name) { + return globals.get(name); + } + + // ===== Core lifecycle callbacks ===== + + /** + * Called when the script is registered to the ScriptManager. + */ + public LuaValue onRegister() { + return getCallback("onRegister"); + } + + public LuaValue onExecute() { + return getCallback("onExecute"); + } + + public LuaValue onEnable() { + return getCallback("onEnable"); + } + + public LuaValue onTick() { + return getCallback("onTick"); + } + + public LuaValue onDisable() { + return getCallback("onDisable"); + } + + // ===== GUI-specific callbacks ===== + + /** + * Called when the script is attached to a GUI element (during JSON parsing). + * The GUI tree is NOT fully built at this point. + */ + public LuaValue onAttached() { + return getCallback("onAttached"); + } + + /** + * Called when the GUI is first displayed and the GUI tree is fully built. + * Use this for operations that need to traverse the GUI tree. + */ + public LuaValue onDisplay() { + return getCallback("onDisplay"); + } + + public LuaValue onClick() { + return getCallback("onClick"); + } + + public LuaValue onRelease() { + return getCallback("onRelease"); + } + + public LuaValue onHover() { + return getCallback("onHover"); + } +} diff --git a/src/main/java/dev/amble/lib/script/ScriptManager.java b/src/main/java/dev/amble/lib/script/ScriptManager.java new file mode 100644 index 0000000..67d587f --- /dev/null +++ b/src/main/java/dev/amble/lib/script/ScriptManager.java @@ -0,0 +1,40 @@ +package dev.amble.lib.script; + +import dev.amble.lib.AmbleKit; +import dev.amble.lib.script.lua.ClientMinecraftData; +import dev.amble.lib.script.lua.MinecraftData; +import net.fabricmc.fabric.api.resource.ResourceManagerHelper; +import net.minecraft.resource.ResourceType; +import net.minecraft.util.Identifier; + +/** + * Client-side script manager for loading and managing Lua scripts from asset packs. + * Scripts are loaded from assets/<namespace>/script/*.lua + */ +public class ScriptManager extends AbstractScriptManager { + private static final ScriptManager INSTANCE = new ScriptManager(); + + private ScriptManager() { + ResourceManagerHelper.get(ResourceType.CLIENT_RESOURCES) + .registerReloadListener(this); + } + + public static ScriptManager getInstance() { + return INSTANCE; + } + + @Override + public Identifier getFabricId() { + return AmbleKit.id("scripts"); + } + + @Override + protected MinecraftData createMinecraftData() { + return new ClientMinecraftData(); + } + + @Override + protected String getLogPrefix() { + return "script"; + } +} diff --git a/src/main/java/dev/amble/lib/script/ServerScriptManager.java b/src/main/java/dev/amble/lib/script/ServerScriptManager.java new file mode 100644 index 0000000..edca734 --- /dev/null +++ b/src/main/java/dev/amble/lib/script/ServerScriptManager.java @@ -0,0 +1,76 @@ +package dev.amble.lib.script; + +import dev.amble.lib.AmbleKit; +import dev.amble.lib.script.lua.MinecraftData; +import dev.amble.lib.script.lua.ServerMinecraftData; +import dev.amble.lib.util.ServerLifecycleHooks; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; +import net.fabricmc.fabric.api.resource.ResourceManagerHelper; +import net.minecraft.resource.ResourceType; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.util.Identifier; + +import java.util.HashSet; + +/** + * Server-side script manager for loading and managing Lua scripts from data packs. + * Scripts are loaded from data/<namespace>/script/*.lua + */ +public class ServerScriptManager extends AbstractScriptManager { + private static final ServerScriptManager INSTANCE = new ServerScriptManager(); + + private MinecraftServer currentServer; + private boolean initialized = false; + + private ServerScriptManager() { + } + + public static ServerScriptManager getInstance() { + return INSTANCE; + } + + /** + * Initialize the server script manager. Should be called from main mod initializer. + */ + public void init() { + if (initialized) return; + initialized = true; + + ResourceManagerHelper.get(ResourceType.SERVER_DATA) + .registerReloadListener(this); + + ServerLifecycleEvents.SERVER_STARTED.register(server -> { + this.currentServer = server; + AmbleKit.LOGGER.info("Server script manager ready"); + }); + + ServerLifecycleEvents.SERVER_STOPPING.register(server -> { + // Disable all scripts before server stops + for (Identifier id : new HashSet<>(enabledScripts)) { + disable(id); + } + this.currentServer = null; + }); + + ServerTickEvents.END_SERVER_TICK.register(server -> tick()); + } + + @Override + public Identifier getFabricId() { + return AmbleKit.id("server_scripts"); + } + + @Override + protected MinecraftData createMinecraftData() { + MinecraftServer server = currentServer != null ? currentServer : ServerLifecycleHooks.get(); + ServerWorld world = server != null ? server.getOverworld() : null; + return new ServerMinecraftData(server, world); + } + + @Override + protected String getLogPrefix() { + return "server script"; + } +} diff --git a/src/main/java/dev/amble/lib/script/lua/ClientMinecraftData.java b/src/main/java/dev/amble/lib/script/lua/ClientMinecraftData.java new file mode 100644 index 0000000..3e563f9 --- /dev/null +++ b/src/main/java/dev/amble/lib/script/lua/ClientMinecraftData.java @@ -0,0 +1,337 @@ +package dev.amble.lib.script.lua; + +import dev.amble.lib.AmbleKit; +import dev.amble.lib.client.gui.AmbleContainer; +import dev.amble.lib.client.gui.registry.AmbleGuiRegistry; +import dev.amble.lib.script.AbstractScriptManager; +import dev.amble.lib.script.ScriptManager; +import net.minecraft.SharedConstants; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.util.InputUtil; +import net.minecraft.entity.Entity; +import net.minecraft.item.ItemStack; +import net.minecraft.network.packet.c2s.play.PlayerActionC2SPacket; +import net.minecraft.registry.Registries; +import net.minecraft.sound.SoundEvent; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; +import net.minecraft.util.hit.BlockHitResult; +import net.minecraft.util.hit.EntityHitResult; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Direction; +import net.minecraft.world.World; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +/** + * Client-side implementation of MinecraftData. + * Provides access to client-only features like input, GUI, clipboard, etc. + */ +public class ClientMinecraftData extends MinecraftData { + private static final MinecraftClient mc = MinecraftClient.getInstance(); + + @Override + @LuaExpose + public boolean isClientSide() { + return true; + } + + @Override + protected World getWorld() { + return mc.world; + } + + @Override + protected Entity getExecutor() { + return mc.player; + } + + // ===== Client-specific entity methods ===== + + @Override + @LuaExpose + public List entities() { + if (mc.world == null) return List.of(); + return StreamSupport.stream(mc.world.getEntities().spliterator(), false) + .collect(Collectors.toList()); + } + + // ===== Session & Identity ===== + + @LuaExpose + public String username() { + return mc.getSession().getUsername(); + } + + // ===== Inventory ===== + + @LuaExpose + public int selectedSlot() { + return mc.player != null ? mc.player.getInventory().selectedSlot + 1 : 0; + } + + @LuaExpose + public void selectSlot(int slot) { + if (mc.player != null) { + mc.player.getInventory().selectedSlot = slot - 1; + } + } + + @LuaExpose + public void dropStack(int slot, boolean entireStack) { + if (mc.player == null) return; + int selected = selectedSlot(); + swapStack(slot, selected); + PlayerActionC2SPacket.Action action = entireStack ? PlayerActionC2SPacket.Action.DROP_ALL_ITEMS : PlayerActionC2SPacket.Action.DROP_ITEM; + ItemStack itemStack = mc.player.getInventory().dropSelectedItem(entireStack); + mc.player.networkHandler.sendPacket(new PlayerActionC2SPacket(action, BlockPos.ORIGIN, Direction.DOWN)); + } + + @LuaExpose + public void swapStack(int fromSlot, int toSlot) { + if (mc.player == null) return; + ItemStack stack = mc.player.getInventory().getStack(fromSlot - 1); + mc.player.getInventory().setStack(fromSlot - 1, mc.player.getInventory().getStack(toSlot - 1)); + mc.player.getInventory().setStack(toSlot - 1, stack); + } + + // ===== Commands & Messages ===== + + @Override + @LuaExpose + public void runCommand(String command) { + if (mc.player == null) return; + try { + String string2 = SharedConstants.stripInvalidChars(command); + if (string2.startsWith("/")) { + if (!mc.player.networkHandler.sendCommand(string2.substring(1))) { + AmbleKit.LOGGER.error("Not allowed to run command with signed argument from lua: '{}'", string2); + } + } else { + AmbleKit.LOGGER.error("Failed to run command without '/' prefix from lua: '{}'", string2); + } + } catch (Exception e) { + AmbleKit.LOGGER.error("Error occurred while running command from lua: '{}'", command, e); + } + } + + @Override + @LuaExpose + public void sendMessage(String message, boolean overlay) { + if (mc.player != null) { + mc.player.sendMessage(Text.literal(message), overlay); + } + } + + // ===== Input ===== + + /** + * Checks if a key or keybind is currently pressed. + *

+ * Supports multiple input types: + *

    + *
  • Shorthand keybind names: "forward", "jump", "inventory", "sprint", etc.
  • + *
  • Raw keyboard keys: "r", "h", "space", "left_shift", "escape", "f1", etc.
  • + *
  • Registered keybind translation keys: "key.inventory", "key.sprint", etc.
  • + *
+ * + * @param keyName the name of the key or keybind to check + * @return true if the key is currently pressed + */ + @LuaExpose + public boolean isKeyPressed(String keyName) { + if (mc.getWindow() == null) return false; + + String lowerName = keyName.toLowerCase(); + + // Check common shorthand keybind names first for backwards compatibility + if (mc.options != null) { + Boolean result = switch (lowerName) { + case "forward" -> mc.options.forwardKey.isPressed(); + case "back" -> mc.options.backKey.isPressed(); + case "left" -> mc.options.leftKey.isPressed(); + case "right" -> mc.options.rightKey.isPressed(); + case "jump" -> mc.options.jumpKey.isPressed(); + case "sneak" -> mc.options.sneakKey.isPressed(); + case "sprint" -> mc.options.sprintKey.isPressed(); + case "attack" -> mc.options.attackKey.isPressed(); + case "use" -> mc.options.useKey.isPressed(); + case "inventory" -> mc.options.inventoryKey.isPressed(); + case "drop" -> mc.options.dropKey.isPressed(); + case "chat" -> mc.options.chatKey.isPressed(); + case "pick_item" -> mc.options.pickItemKey.isPressed(); + case "swap_hands" -> mc.options.swapHandsKey.isPressed(); + default -> null; + }; + + if (result != null) { + return result; + } + + // Search all registered keybinds by translation key + for (net.minecraft.client.option.KeyBinding keyBinding : mc.options.allKeys) { + String translationKey = keyBinding.getTranslationKey(); + // Match by exact translation key or by the suffix after the last dot + if (translationKey.equals(keyName) || translationKey.endsWith("." + lowerName)) { + return keyBinding.isPressed(); + } + } + } + + // Fall back to checking raw keyboard key + long windowHandle = mc.getWindow().getHandle(); + String translationKey = "key.keyboard." + lowerName; + + try { + InputUtil.Key key = InputUtil.fromTranslationKey(translationKey); + if (key != InputUtil.UNKNOWN_KEY) { + return InputUtil.isKeyPressed(windowHandle, key.getCode()); + } + } catch (Exception e) { + // Key not found + } + + return false; + } + + /** + * Checks if a mouse button is currently pressed. + * Accepts button names like "left", "right", "middle", or button numbers (0, 1, 2, etc.). + * + * @param button the mouse button to check (e.g., "left", "right", "middle", or "0", "1", "2") + * @return true if the mouse button is currently pressed + */ + @LuaExpose + public boolean isMouseButtonPressed(String button) { + if (mc.getWindow() == null) return false; + + long windowHandle = mc.getWindow().getHandle(); + String lowerButton = button.toLowerCase(); + + int buttonCode = switch (lowerButton) { + case "left" -> 0; + case "right" -> 1; + case "middle" -> 2; + default -> { + try { + yield Integer.parseInt(button); + } catch (NumberFormatException e) { + yield -1; + } + } + }; + + if (buttonCode >= 0) { + String translationKey = "key.mouse." + (buttonCode + 1); + try { + InputUtil.Key key = InputUtil.fromTranslationKey(translationKey); + if (key != InputUtil.UNKNOWN_KEY) { + return InputUtil.isKeyPressed(windowHandle, key.getCode()); + } + } catch (Exception e) { + // Button not found + } + } + + return false; + } + + /** + * Gets a list of all registered keybind translation keys. + * Useful for discovering available keybinds. + * + * @return list of all keybind translation keys + */ + @LuaExpose + public List getKeybinds() { + if (mc.options == null) return List.of(); + List keybinds = new java.util.ArrayList<>(); + for (net.minecraft.client.option.KeyBinding keyBinding : mc.options.allKeys) { + keybinds.add(keyBinding.getTranslationKey()); + } + return keybinds; + } + + @LuaExpose + public String gameMode() { + return mc.interactionManager != null ? mc.interactionManager.getCurrentGameMode().getName() : "unknown"; + } + + // ===== Audio ===== + + @LuaExpose + public void playSound(String soundId, float volume, float pitch) { + if (mc.player == null) return; + Identifier id = new Identifier(soundId); + SoundEvent sound = Registries.SOUND_EVENT.get(id); + if (sound != null) { + mc.player.playSound(sound, volume, pitch); + } + } + + // ===== Entity Queries ===== + + @LuaExpose + public Entity lookingAtEntity() { + if (mc.crosshairTarget instanceof EntityHitResult hit) { + return hit.getEntity(); + } + return null; + } + + @LuaExpose + public BlockPos lookingAtBlock() { + if (mc.crosshairTarget instanceof BlockHitResult hit) { + return hit.getBlockPos(); + } + return null; + } + + // ===== UI & Clipboard ===== + + @LuaExpose + public void displayScreen(String screenId) { + AmbleContainer screen = AmbleGuiRegistry.getInstance().get(new Identifier(screenId)); + if (screen != null) { + screen.display(); + } else { + AmbleKit.LOGGER.warn("Screen '{}' not found in AmbleGuiRegistry", screenId); + } + } + + @LuaExpose + public void closeScreen() { + mc.setScreen(null); + } + + @LuaExpose + public String clipboard() { + return mc.keyboard != null ? mc.keyboard.getClipboard() : ""; + } + + @LuaExpose + public void setClipboard(String text) { + if (mc.keyboard != null) { + mc.keyboard.setClipboard(text); + } + } + + @LuaExpose + public int windowWidth() { + return mc.getWindow() != null ? mc.getWindow().getScaledWidth() : 0; + } + + @LuaExpose + public int windowHeight() { + return mc.getWindow() != null ? mc.getWindow().getScaledHeight() : 0; + } + + // ===== Cross-script function calling ===== + + @Override + protected AbstractScriptManager getScriptManager() { + return ScriptManager.getInstance(); + } +} diff --git a/src/main/java/dev/amble/lib/script/lua/LuaBinder.java b/src/main/java/dev/amble/lib/script/lua/LuaBinder.java new file mode 100644 index 0000000..daa93e4 --- /dev/null +++ b/src/main/java/dev/amble/lib/script/lua/LuaBinder.java @@ -0,0 +1,348 @@ +package dev.amble.lib.script.lua; + +import dev.amble.lib.client.gui.AmbleElement; +import dev.amble.lib.client.gui.lua.LuaElement; +import net.minecraft.entity.Entity; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.NbtCompound; +import net.minecraft.nbt.NbtElement; +import net.minecraft.nbt.NbtList; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Vec3d; +import org.joml.Vector3f; +import org.luaj.vm2.*; +import org.luaj.vm2.lib.*; +import org.luaj.vm2.lib.jse.CoerceJavaToLua; +import org.luaj.vm2.lib.jse.CoerceLuaToJava; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Binds Java objects to Lua, exposing methods annotated with @LuaExpose. + * Uses MethodHandles for improved performance over reflection. + */ +public final class LuaBinder { + + private static final MethodHandles.Lookup LOOKUP = MethodHandles.lookup(); + private static final Map, LuaTable> CACHE = new HashMap<>(); + + public static LuaValue bind(Object target) { + LuaTable table = CACHE.computeIfAbsent( + target.getClass(), + LuaBinder::buildMetatable + ); + + LuaUserdata userdata = new LuaUserdata(target); + userdata.setmetatable(table); + return userdata; + } + + // ===== Separate coerceResult methods for each type ===== + + /** + * Coerces a null value to Lua NIL. + */ + public static LuaValue coerceNull() { + return LuaValue.NIL; + } + + /** + * Coerces a String to a Lua string. + */ + public static LuaValue coerceString(String value) { + return LuaString.valueOf(value); + } + + /** + * Coerces an integer to a Lua number. + */ + public static LuaValue coerceInt(int value) { + return LuaInteger.valueOf(value); + } + + /** + * Coerces a long to a Lua number. + */ + public static LuaValue coerceLong(long value) { + return LuaInteger.valueOf(value); + } + + /** + * Coerces a float to a Lua number. + */ + public static LuaValue coerceFloat(float value) { + return LuaDouble.valueOf(value); + } + + /** + * Coerces a double to a Lua number. + */ + public static LuaValue coerceDouble(double value) { + return LuaDouble.valueOf(value); + } + + /** + * Coerces a boolean to a Lua boolean. + */ + public static LuaValue coerceBoolean(boolean value) { + return LuaBoolean.valueOf(value); + } + + /** + * Coerces a List to a Lua table (1-indexed array). + */ + public static LuaValue coerceList(List list) { + LuaTable table = new LuaTable(); + for (int i = 0; i < list.size(); i++) { + table.set(i + 1, coerceResult(list.get(i))); + } + return table; + } + + /** + * Coerces a Vector3f to a Lua table with x, y, z fields. + */ + public static LuaValue coerceVector3f(Vector3f vec3) { + LuaTable table = new LuaTable(); + table.set("x", vec3.x()); + table.set("y", vec3.y()); + table.set("z", vec3.z()); + table.set("toString", new ZeroArgFunction() { + @Override + public LuaValue call() { + return LuaString.valueOf("(" + vec3.x() + ", " + vec3.y() + ", " + vec3.z() + ")"); + } + }); + return table; + } + + /** + * Coerces a Vec3d to a Lua table with x, y, z fields. + */ + public static LuaValue coerceVec3d(Vec3d vec3) { + LuaTable table = new LuaTable(); + table.set("x", vec3.x); + table.set("y", vec3.y); + table.set("z", vec3.z); + table.set("toString", new ZeroArgFunction() { + @Override + public LuaValue call() { + return LuaString.valueOf("(" + vec3.x + ", " + vec3.y + ", " + vec3.z + ")"); + } + }); + return table; + } + + /** + * Coerces a BlockPos to a Lua table with x, y, z fields. + */ + public static LuaValue coerceBlockPos(BlockPos pos) { + LuaTable table = new LuaTable(); + table.set("x", pos.getX()); + table.set("y", pos.getY()); + table.set("z", pos.getZ()); + table.set("toString", new ZeroArgFunction() { + @Override + public LuaValue call() { + return LuaString.valueOf("(" + pos.getX() + ", " + pos.getY() + ", " + pos.getZ() + ")"); + } + }); + return table; + } + + /** + * Coerces an ItemStack to a bound LuaItemStack. + */ + public static LuaValue coerceItemStack(ItemStack stack) { + return bind(new LuaItemStack(stack)); + } + + /** + * Coerces an Entity to a bound MinecraftEntity. + */ + public static LuaValue coerceEntity(Entity entity) { + return bind(new MinecraftEntity(entity)); + } + + /** + * Coerces an Identifier to a Lua string. + */ + public static LuaValue coerceIdentifier(Identifier identifier) { + return LuaString.valueOf(identifier.toString()); + } + + /** + * Coerces an AmbleElement to a bound LuaElement. + */ + public static LuaValue coerceAmbleElement(AmbleElement element) { + if (element instanceof LuaElement luaElement) { + return bind(luaElement); + } + return bind(new LuaElement(element)); + } + + /** + * Coerces an NbtCompound to a Lua table. + */ + public static LuaValue coerceNbtCompound(NbtCompound nbt) { + LuaTable table = new LuaTable(); + for (String key : nbt.getKeys()) { + NbtElement element = nbt.get(key); + table.set(key, coerceNbtElement(element)); + } + return table; + } + + /** + * Coerces any NbtElement to the appropriate Lua type. + */ + public static LuaValue coerceNbtElement(NbtElement element) { + if (element == null) { + return LuaValue.NIL; + } + + return switch (element.getType()) { + case NbtElement.BYTE_TYPE -> LuaInteger.valueOf(((net.minecraft.nbt.NbtByte) element).byteValue()); + case NbtElement.SHORT_TYPE -> LuaInteger.valueOf(((net.minecraft.nbt.NbtShort) element).shortValue()); + case NbtElement.INT_TYPE -> LuaInteger.valueOf(((net.minecraft.nbt.NbtInt) element).intValue()); + case NbtElement.LONG_TYPE -> LuaInteger.valueOf(((net.minecraft.nbt.NbtLong) element).longValue()); + case NbtElement.FLOAT_TYPE -> LuaDouble.valueOf(((net.minecraft.nbt.NbtFloat) element).floatValue()); + case NbtElement.DOUBLE_TYPE -> LuaDouble.valueOf(((net.minecraft.nbt.NbtDouble) element).doubleValue()); + case NbtElement.STRING_TYPE -> LuaString.valueOf(element.asString()); + case NbtElement.BYTE_ARRAY_TYPE -> { + byte[] bytes = ((net.minecraft.nbt.NbtByteArray) element).getByteArray(); + LuaTable table = new LuaTable(); + for (int i = 0; i < bytes.length; i++) { + table.set(i + 1, LuaInteger.valueOf(bytes[i])); + } + yield table; + } + case NbtElement.INT_ARRAY_TYPE -> { + int[] ints = ((net.minecraft.nbt.NbtIntArray) element).getIntArray(); + LuaTable table = new LuaTable(); + for (int i = 0; i < ints.length; i++) { + table.set(i + 1, LuaInteger.valueOf(ints[i])); + } + yield table; + } + case NbtElement.LONG_ARRAY_TYPE -> { + long[] longs = ((net.minecraft.nbt.NbtLongArray) element).getLongArray(); + LuaTable table = new LuaTable(); + for (int i = 0; i < longs.length; i++) { + table.set(i + 1, LuaInteger.valueOf(longs[i])); + } + yield table; + } + case NbtElement.LIST_TYPE -> { + NbtList list = (NbtList) element; + LuaTable table = new LuaTable(); + for (int i = 0; i < list.size(); i++) { + table.set(i + 1, coerceNbtElement(list.get(i))); + } + yield table; + } + case NbtElement.COMPOUND_TYPE -> coerceNbtCompound((NbtCompound) element); + default -> LuaString.valueOf(element.asString()); + }; + } + + // ===== Main coerceResult dispatcher ===== + + /** + * Coerces any Java object to an appropriate Lua value. + * Dispatches to type-specific coercion methods. + */ + public static LuaValue coerceResult(Object obj) { + if (obj == null) return coerceNull(); + if (obj instanceof LuaValue lv) return lv; + if (obj instanceof String s) return coerceString(s); + if (obj instanceof Integer i) return coerceInt(i); + if (obj instanceof Long l) return coerceLong(l); + if (obj instanceof Float f) return coerceFloat(f); + if (obj instanceof Double d) return coerceDouble(d); + if (obj instanceof Boolean b) return coerceBoolean(b); + if (obj instanceof Number n) return CoerceJavaToLua.coerce(n); + if (obj instanceof List list) return coerceList(list); + if (obj instanceof Vector3f vec3) return coerceVector3f(vec3); + if (obj instanceof Vec3d vec3) return coerceVec3d(vec3); + if (obj instanceof BlockPos pos) return coerceBlockPos(pos); + if (obj instanceof ItemStack stack) return coerceItemStack(stack); + if (obj instanceof Entity entity) return coerceEntity(entity); + if (obj instanceof NbtCompound nbt) return coerceNbtCompound(nbt); + if (obj instanceof NbtElement nbt) return coerceNbtElement(nbt); + if (obj instanceof Identifier id) return coerceIdentifier(id); + if (obj instanceof LuaElement luaElement) return bind(luaElement); + if (obj instanceof AmbleElement element) return bind(new LuaElement(element)); + + // Fall back to binding the object + return bind(obj); + } + + private static LuaTable buildMetatable(Class clazz) { + LuaTable meta = new LuaTable(); + LuaTable index = new LuaTable(); + + for (Method method : clazz.getMethods()) { + LuaExpose expose = method.getAnnotation(LuaExpose.class); + if (expose == null) continue; + + String luaName = expose.name().isEmpty() + ? method.getName() + : expose.name(); + + // Convert Method to MethodHandle for better performance + MethodHandle handle; + try { + handle = LOOKUP.unreflect(method); + } catch (IllegalAccessException e) { + throw new RuntimeException("Failed to create MethodHandle for " + method.getName(), e); + } + + Class[] paramTypes = method.getParameterTypes(); + String methodName = method.getName(); + + index.set(luaName, new VarArgFunction() { + @Override + public Varargs invoke(Varargs args) { + try { + LuaValue selfValue = args.arg1(); + if (!selfValue.isuserdata()) { + throw new LuaError("Expected userdata but got " + selfValue.typename()); + } + Object javaSelf = selfValue.touserdata(); + if (javaSelf == null || !clazz.isInstance(javaSelf)) { + throw new LuaError("Expected userdata of type " + clazz.getName() + " but got " + (javaSelf == null ? "null" : javaSelf.getClass().getName())); + } + + // Build argument array: [self, arg1, arg2, ...] + Object[] invokeArgs = new Object[paramTypes.length + 1]; + invokeArgs[0] = javaSelf; + + for (int i = 0; i < paramTypes.length; i++) { + invokeArgs[i + 1] = CoerceLuaToJava.coerce( + args.arg(i + 2), + paramTypes[i] + ); + } + + Object result = handle.invokeWithArguments(invokeArgs); + return LuaBinder.coerceResult(result); + } catch (LuaError e) { + throw e; + } catch (Throwable e) { + throw new LuaError("Lua call failed: " + methodName + " " + e); + } + } + }); + } + + meta.set("__index", index); + return meta; + } +} diff --git a/src/main/java/dev/amble/lib/script/lua/LuaExpose.java b/src/main/java/dev/amble/lib/script/lua/LuaExpose.java new file mode 100644 index 0000000..ae685ea --- /dev/null +++ b/src/main/java/dev/amble/lib/script/lua/LuaExpose.java @@ -0,0 +1,9 @@ +package dev.amble.lib.script.lua; + +import java.lang.annotation.*; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface LuaExpose { + String name() default ""; // optional Lua name override +} diff --git a/src/main/java/dev/amble/lib/script/lua/LuaItemStack.java b/src/main/java/dev/amble/lib/script/lua/LuaItemStack.java new file mode 100644 index 0000000..99a96ef --- /dev/null +++ b/src/main/java/dev/amble/lib/script/lua/LuaItemStack.java @@ -0,0 +1,133 @@ +package dev.amble.lib.script.lua; + +import lombok.AllArgsConstructor; +import net.minecraft.enchantment.EnchantmentHelper; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.NbtCompound; +import net.minecraft.registry.Registries; +import org.luaj.vm2.LuaValue; + +import java.util.ArrayList; +import java.util.List; + +/** + * Lua wrapper for Minecraft ItemStack. + * Provides access to item properties and NBT data from Lua scripts. + */ +@AllArgsConstructor +public class LuaItemStack { + public final ItemStack stack; + + @LuaExpose + public int count() { + return stack.getCount(); + } + + @LuaExpose + public String name() { + return stack.getName().getString(); + } + + @LuaExpose + public String id() { + return Registries.ITEM.getId(stack.getItem()).toString(); + } + + // ===== Additional Methods ===== + + @LuaExpose + public int maxCount() { + return stack.getMaxCount(); + } + + @LuaExpose + public int maxDamage() { + return stack.getMaxDamage(); + } + + @LuaExpose + public int damage() { + return stack.getDamage(); + } + + @LuaExpose + public boolean isDamageable() { + return stack.isDamageable(); + } + + @LuaExpose + public boolean hasEnchantments() { + return stack.hasEnchantments(); + } + + @LuaExpose + public boolean isEmpty() { + return stack.isEmpty(); + } + + @LuaExpose + public boolean isStackable() { + return stack.isStackable(); + } + + @LuaExpose + public float durabilityPercent() { + if (!stack.isDamageable()) return 1.0f; + return 1.0f - ((float) stack.getDamage() / (float) stack.getMaxDamage()); + } + + @LuaExpose + public boolean hasCustomName() { + return stack.hasCustomName(); + } + + @LuaExpose + public boolean isFood() { + return stack.isFood(); + } + + @LuaExpose + public String rarity() { + return stack.getRarity().name().toLowerCase(); + } + + @LuaExpose + public List enchantments() { + List result = new ArrayList<>(); + EnchantmentHelper.get(stack).forEach((enchantment, level) -> { + String enchantId = Registries.ENCHANTMENT.getId(enchantment).toString(); + result.add(enchantId + ":" + level); + }); + return result; + } + + @LuaExpose + public boolean hasNbt() { + return stack.hasNbt(); + } + + /** + * Returns the NBT data as a Lua table for structured access. + * @return Lua table representation of the NBT data, or NIL if no NBT + */ + @LuaExpose + public LuaValue nbt() { + NbtCompound nbt = stack.getNbt(); + if (nbt == null) { + return LuaValue.NIL; + } + return LuaBinder.coerceNbtCompound(nbt); + } + + /** + * Returns the NBT data as a string (for debugging/display purposes). + * @return String representation of the NBT data + * @deprecated Use nbt() for structured access + */ + @Deprecated + @LuaExpose + public String nbtString() { + NbtCompound nbt = stack.getNbt(); + return nbt != null ? nbt.toString() : ""; + } +} diff --git a/src/main/java/dev/amble/lib/script/lua/MinecraftData.java b/src/main/java/dev/amble/lib/script/lua/MinecraftData.java new file mode 100644 index 0000000..b675897 --- /dev/null +++ b/src/main/java/dev/amble/lib/script/lua/MinecraftData.java @@ -0,0 +1,332 @@ +package dev.amble.lib.script.lua; + +import dev.amble.lib.AmbleKit; +import dev.amble.lib.script.AbstractScriptManager; +import dev.amble.lib.script.LuaScript; +import net.minecraft.entity.Entity; +import net.minecraft.registry.Registries; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.sound.SoundCategory; +import net.minecraft.sound.SoundEvent; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.World; +import org.luaj.vm2.LuaValue; +import org.luaj.vm2.Varargs; + +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +/** + * Abstract base class for Minecraft data exposed to Lua scripts. + * Contains methods that work on both client and server sides. + */ +public abstract class MinecraftData { + + private String scriptName = null; + + /** + * Sets the name of the script using this data, for logging purposes. + * + * @param scriptName the script name or identifier + */ + public void setScriptName(String scriptName) { + this.scriptName = scriptName; + } + + /** + * Gets the log prefix including the script name if available. + */ + private String getLogPrefix() { + return scriptName != null ? "[Script: " + scriptName + "]" : "[Script]"; + } + + /** + * @return true if this is client-side data, false if server-side + */ + @LuaExpose + public abstract boolean isClientSide(); + + /** + * @return the world this data operates on + */ + protected abstract World getWorld(); + + /** + * Returns the entity that is executing this script context. + * On client, this is typically the local player. + * On server, this may be the player who ran a command, or null for server-initiated scripts. + * + * @return the executor entity, or null if not applicable + */ + protected abstract Entity getExecutor(); + + // ===== World & Environment ===== + + @LuaExpose + public String dimension() { + World world = getWorld(); + return world != null ? world.getRegistryKey().getValue().toString() : "unknown"; + } + + @LuaExpose + public long worldTime() { + World world = getWorld(); + return world != null ? world.getTimeOfDay() : 0; + } + + @LuaExpose + public long dayCount() { + World world = getWorld(); + return world != null ? world.getTimeOfDay() / 24000L : 0; + } + + @LuaExpose + public boolean isRaining() { + World world = getWorld(); + return world != null && world.isRaining(); + } + + @LuaExpose + public boolean isThundering() { + World world = getWorld(); + return world != null && world.isThundering(); + } + + @LuaExpose + public String biomeAt(int x, int y, int z) { + World world = getWorld(); + if (world == null) return "unknown"; + return world.getBiome(new BlockPos(x, y, z)).getKey() + .map(k -> k.getValue().toString()).orElse("unknown"); + } + + @LuaExpose + public String blockAt(int x, int y, int z) { + World world = getWorld(); + if (world == null) return "minecraft:air"; + return Registries.BLOCK.getId(world.getBlockState(new BlockPos(x, y, z)).getBlock()).toString(); + } + + @LuaExpose + public int lightLevelAt(int x, int y, int z) { + World world = getWorld(); + return world != null ? world.getLightLevel(new BlockPos(x, y, z)) : 0; + } + + // ===== Player & Entity ===== + + @LuaExpose + public Entity player() { + return getExecutor(); + } + + @LuaExpose + public List entities() { + World world = getWorld(); + if (world == null) return List.of(); + + if (world instanceof ServerWorld serverWorld) { + return StreamSupport.stream(serverWorld.iterateEntities().spliterator(), false) + .collect(Collectors.toList()); + } + return List.of(); + } + + @LuaExpose + public Entity nearestEntity(double maxDistance) { + World world = getWorld(); + Entity executor = getExecutor(); + if (world == null || executor == null) return null; + + return world.getOtherEntities(executor, executor.getBoundingBox().expand(maxDistance), e -> true) + .stream() + .min(Comparator.comparingDouble(e -> e.squaredDistanceTo(executor))) + .orElse(null); + } + + @LuaExpose + public List entitiesInRadius(double radius) { + World world = getWorld(); + Entity executor = getExecutor(); + if (world == null || executor == null) return List.of(); + + return world.getOtherEntities(executor, executor.getBoundingBox().expand(radius), e -> true); + } + + // ===== Audio (shared implementation) ===== + + @LuaExpose + public void playSoundAt(String soundId, double x, double y, double z, float volume, float pitch) { + World world = getWorld(); + if (world == null) return; + + Identifier id = new Identifier(soundId); + SoundEvent sound = Registries.SOUND_EVENT.get(id); + if (sound != null) { + world.playSound(null, x, y, z, sound, SoundCategory.MASTER, volume, pitch); + } + } + + // ===== Commands ===== + + /** + * Runs a command. Implementation differs between client and server. + * On client: sends command through player network handler + * On server: executes command with server permissions + */ + @LuaExpose + public abstract void runCommand(String command); + + /** + * Sends a message to the player. Implementation differs between client and server. + */ + @LuaExpose + public abstract void sendMessage(String message, boolean overlay); + + /** + * Logs a message to the console. + */ + @LuaExpose + public void log(String message) { + AmbleKit.LOGGER.info("{} {}", getLogPrefix(), message); + } + + @LuaExpose + public void logWarn(String message) { + AmbleKit.LOGGER.warn("{} {}", getLogPrefix(), message); + } + + @LuaExpose + public void logError(String message) { + AmbleKit.LOGGER.error("{} {}", getLogPrefix(), message); + } + + // ===== Cross-script function calling ===== + + /** + * Gets the script manager for this side. + */ + protected abstract AbstractScriptManager getScriptManager(); + + /** + * Converts a user-friendly script ID to the internal identifier format. + * Handles both "modid:scriptname" and full "modid:script/scriptname.lua" formats. + */ + private Identifier toFullScriptId(String scriptId) { + Identifier id = new Identifier(scriptId); + String path = id.getPath(); + if (!path.startsWith("script/")) { + path = "script/" + path; + } + if (!path.endsWith(".lua")) { + path = path + ".lua"; + } + return new Identifier(id.getNamespace(), path); + } + + /** + * Converts a full internal script identifier to display format. + * Removes the "script/" prefix and ".lua" suffix. + */ + private String toDisplayId(Identifier id) { + String path = id.getPath(); + if (path.startsWith("script/")) { + path = path.substring(7); + } + if (path.endsWith(".lua")) { + path = path.substring(0, path.length() - 4); + } + return id.getNamespace() + ":" + path; + } + + /** + * Gets the identifiers of all available scripts. + * + * @return list of script identifiers in "modid:scriptname" format + */ + @LuaExpose + public List availableScripts() { + return getScriptManager().getCache().keySet().stream() + .map(this::toDisplayId) + .collect(Collectors.toList()); + } + + /** + * Calls a function from another script. + * + * @param scriptId the script identifier (e.g., "modid:scriptname") + * @param functionName the name of the function to call + * @param args the arguments to pass to the function + * @return the result of the function call, or nil if the function doesn't exist + */ + @LuaExpose + public Object callScript(String scriptId, String functionName, Object... args) { + Identifier fullId = toFullScriptId(scriptId); + LuaScript script = getScriptManager().getCache().get(fullId); + if (script == null) { + logWarn("Cannot call function '" + functionName + "': script '" + scriptId + "' not found"); + return null; + } + + LuaValue function = script.globals().get(functionName); + if (function.isnil()) { + logWarn("Function '" + functionName + "' not found in script '" + scriptId + "'"); + return null; + } + + try { + // Convert Java args to Lua values + LuaValue[] luaArgs = new LuaValue[args.length]; + for (int i = 0; i < args.length; i++) { + luaArgs[i] = LuaBinder.coerceResult(args[i]); + } + + Varargs result = function.invoke(LuaValue.varargsOf(luaArgs)); + return result.arg1(); + } catch (Exception e) { + logError("Error calling function '" + functionName + "' in script '" + scriptId + "': " + e.getMessage()); + return null; + } + } + + /** + * Gets a global variable from another script. + * + * @param scriptId the script identifier (e.g., "modid:scriptname") + * @param variableName the name of the global variable to get + * @return the value of the variable, or nil if it doesn't exist + */ + @LuaExpose + public Object getScriptGlobal(String scriptId, String variableName) { + Identifier fullId = toFullScriptId(scriptId); + LuaScript script = getScriptManager().getCache().get(fullId); + if (script == null) { + logWarn("Cannot get global '" + variableName + "': script '" + scriptId + "' not found"); + return null; + } + + return script.globals().get(variableName); + } + + /** + * Sets a global variable in another script. + * + * @param scriptId the script identifier (e.g., "modid:scriptname") + * @param variableName the name of the global variable to set + * @param value the value to set + */ + @LuaExpose + public void setScriptGlobal(String scriptId, String variableName, Object value) { + Identifier fullId = toFullScriptId(scriptId); + LuaScript script = getScriptManager().getCache().get(fullId); + if (script == null) { + logWarn("Cannot set global '" + variableName + "': script '" + scriptId + "' not found"); + return; + } + + script.globals().set(variableName, LuaBinder.coerceResult(value)); + } +} diff --git a/src/main/java/dev/amble/lib/script/lua/MinecraftEntity.java b/src/main/java/dev/amble/lib/script/lua/MinecraftEntity.java new file mode 100644 index 0000000..000c44a --- /dev/null +++ b/src/main/java/dev/amble/lib/script/lua/MinecraftEntity.java @@ -0,0 +1,230 @@ +package dev.amble.lib.script.lua; + +import lombok.AllArgsConstructor; +import net.minecraft.entity.Entity; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.effect.StatusEffect; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.item.ItemStack; +import net.minecraft.registry.Registries; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Vec3d; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@AllArgsConstructor +public class MinecraftEntity { + public final Entity entity; + + @LuaExpose + public String name() { + return entity.getName().getString(); + } + + @LuaExpose + public String type() { + return Registries.ENTITY_TYPE.getId(entity.getType()).toString(); + } + + @LuaExpose + public String uuid() { + return entity.getUuid().toString(); + } + + @LuaExpose + public boolean isPlayer() { + return entity.isPlayer(); + } + + @LuaExpose + public Vec3d position() { + return entity.getPos(); + } + + @LuaExpose + public BlockPos blockPosition() { + return entity.getBlockPos(); + } + + @LuaExpose + public double health() { + return entity instanceof LivingEntity livingEntity ? livingEntity.getHealth() : -1; + } + + @LuaExpose + public int age() { + return entity.age; + } + + @LuaExpose + public List inventory() { + if (entity instanceof PlayerEntity player) { + List combined = new ArrayList<>(player.getInventory().main); + combined.addAll(player.getInventory().armor); + combined.addAll(player.getInventory().offHand); + return combined; + } + + Iterable hands = entity.getHandItems(); + Iterable armor = entity.getArmorItems(); + // Combine both iterables into a single list + List combined = new ArrayList<>(); + for (ItemStack item : hands) { + combined.add(item); + } + for (ItemStack item : armor) { + combined.add(item); + } + + return combined; + } + + @LuaExpose + public int foodLevel() { + if (entity instanceof PlayerEntity player) { + return player.getHungerManager().getFoodLevel(); + } + return -1; + } + + // ===== Additional Methods ===== + + @LuaExpose + public double maxHealth() { + return entity instanceof LivingEntity le ? le.getMaxHealth() : -1; + } + + @LuaExpose + public double distanceTo(double x, double y, double z) { + return entity.getPos().distanceTo(new Vec3d(x, y, z)); + } + + @LuaExpose + public Vec3d velocity() { + return entity.getVelocity(); + } + + @LuaExpose + public float yaw() { + return entity.getYaw(); + } + + @LuaExpose + public float pitch() { + return entity.getPitch(); + } + + @LuaExpose + public boolean isAlive() { + return entity.isAlive(); + } + + @LuaExpose + public boolean isSneaking() { + return entity.isSneaking(); + } + + @LuaExpose + public boolean isSprinting() { + return entity.isSprinting(); + } + + @LuaExpose + public boolean isOnFire() { + return entity.isOnFire(); + } + + @LuaExpose + public boolean isInvisible() { + return entity.isInvisible(); + } + + @LuaExpose + public boolean isGlowing() { + return entity.isGlowing(); + } + + @LuaExpose + public boolean isTouchingWater() { + return entity.isTouchingWater(); + } + + @LuaExpose + public List effects() { + if (entity instanceof LivingEntity le) { + return le.getStatusEffects().stream() + .map(e -> Registries.STATUS_EFFECT.getId(e.getEffectType()).toString()) + .collect(Collectors.toList()); + } + return List.of(); + } + + @LuaExpose + public float saturation() { + if (entity instanceof PlayerEntity player) { + return player.getHungerManager().getSaturationLevel(); + } + return -1; + } + + @LuaExpose + public int armorValue() { + return entity instanceof LivingEntity le ? le.getArmor() : 0; + } + + @LuaExpose + public boolean hasEffect(String effectId) { + if (entity instanceof LivingEntity le) { + StatusEffect effect = Registries.STATUS_EFFECT.get(new Identifier(effectId)); + return effect != null && le.hasStatusEffect(effect); + } + return false; + } + + // ===== Player-Specific Methods ===== + + @LuaExpose + public int experienceLevel() { + if (entity instanceof PlayerEntity player) { + return player.experienceLevel; + } + return -1; + } + + @LuaExpose + public float experienceProgress() { + if (entity instanceof PlayerEntity player) { + return player.experienceProgress; + } + return -1; + } + + @LuaExpose + public int totalExperience() { + if (entity instanceof PlayerEntity player) { + return player.totalExperience; + } + return -1; + } + + @LuaExpose + public boolean isOnGround() { + return entity.isOnGround(); + } + + @LuaExpose + public boolean isSwimming() { + return entity.isSwimming(); + } + + @LuaExpose + public boolean isFlying() { + if (entity instanceof PlayerEntity player) { + return player.getAbilities().flying; + } + return false; + } +} diff --git a/src/main/java/dev/amble/lib/script/lua/ServerMinecraftData.java b/src/main/java/dev/amble/lib/script/lua/ServerMinecraftData.java new file mode 100644 index 0000000..0eba0df --- /dev/null +++ b/src/main/java/dev/amble/lib/script/lua/ServerMinecraftData.java @@ -0,0 +1,423 @@ +package dev.amble.lib.script.lua; + +import dev.amble.lib.AmbleKit; +import dev.amble.lib.script.AbstractScriptManager; +import dev.amble.lib.script.ServerScriptManager; +import dev.amble.lib.skin.SkinData; +import dev.amble.lib.skin.SkinTracker; +import dev.amble.lib.util.ServerLifecycleHooks; +import net.minecraft.entity.Entity; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.text.Text; +import net.minecraft.world.World; + +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * Server-side implementation of MinecraftData. + * Provides access to server-only features like broadcasting, player management, etc. + */ +public class ServerMinecraftData extends MinecraftData { + private MinecraftServer server; + private ServerWorld world; + private final ServerPlayerEntity player; // may be null for server-context scripts + + public ServerMinecraftData(MinecraftServer server, ServerWorld world, ServerPlayerEntity player) { + this.server = server; + this.world = world; + this.player = player; + } + + public ServerMinecraftData(MinecraftServer server, ServerWorld world) { + this(server, world, null); + } + + /** + * Gets the server, fetching from lifecycle hooks if not set. + */ + private MinecraftServer getServer() { + if (server == null) { + server = ServerLifecycleHooks.get(); + } + return server; + } + + /** + * Gets the world, fetching overworld from server if not set. + */ + private ServerWorld getServerWorld() { + if (world == null && getServer() != null) { + world = getServer().getOverworld(); + } + return world; + } + + @Override + @LuaExpose + public boolean isClientSide() { + return false; + } + + @Override + protected World getWorld() { + return getServerWorld(); + } + + @Override + protected Entity getExecutor() { + return player; + } + + // ===== Server-specific methods ===== + + @LuaExpose + public List allPlayerNames() { + MinecraftServer srv = getServer(); + if (srv == null) return List.of(); + return srv.getPlayerManager().getPlayerList().stream() + .map(p -> p.getName().getString()) + .collect(Collectors.toList()); + } + + @LuaExpose + public List allPlayers() { + MinecraftServer srv = getServer(); + if (srv == null) return List.of(); + return srv.getPlayerManager().getPlayerList().stream() + .map(p -> (Entity) p) + .collect(Collectors.toList()); + } + + @LuaExpose + public Entity getPlayerByName(String name) { + MinecraftServer srv = getServer(); + if (srv == null) return null; + return srv.getPlayerManager().getPlayer(name); + } + + @LuaExpose + public int playerCount() { + MinecraftServer srv = getServer(); + if (srv == null) return 0; + return srv.getPlayerManager().getCurrentPlayerCount(); + } + + @LuaExpose + public int maxPlayers() { + MinecraftServer srv = getServer(); + if (srv == null) return 0; + return srv.getPlayerManager().getMaxPlayerCount(); + } + + @LuaExpose + public void broadcast(String message) { + MinecraftServer srv = getServer(); + if (srv == null) { + AmbleKit.LOGGER.warn("Cannot broadcast: server not available"); + return; + } + srv.getPlayerManager().broadcast(Text.literal(message), false); + } + + @LuaExpose + public void broadcastToPlayer(String playerName, String message, boolean overlay) { + MinecraftServer srv = getServer(); + if (srv == null) return; + ServerPlayerEntity target = srv.getPlayerManager().getPlayer(playerName); + if (target != null) { + target.sendMessage(Text.literal(message), overlay); + } + } + + // ===== Commands & Messages ===== + + @Override + @LuaExpose + public void runCommand(String command) { + MinecraftServer srv = getServer(); + if (srv == null) return; + try { + String cmd = command.startsWith("/") ? command.substring(1) : command; + ServerCommandSource source = srv.getCommandSource(); + srv.getCommandManager().executeWithPrefix(source, cmd); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error occurred while running server command from lua: '{}'", command, e); + } + } + + /** + * Runs a command as a specific player. + */ + @LuaExpose + public void runCommandAs(String playerName, String command) { + MinecraftServer srv = getServer(); + if (srv == null) return; + ServerPlayerEntity target = srv.getPlayerManager().getPlayer(playerName); + if (target == null) { + AmbleKit.LOGGER.warn("Cannot run command as '{}': player not found", playerName); + return; + } + try { + String cmd = command.startsWith("/") ? command.substring(1) : command; + srv.getCommandManager().executeWithPrefix(target.getCommandSource(), cmd); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error occurred while running command as {} from lua: '{}'", playerName, command, e); + } + } + + @Override + @LuaExpose + public void sendMessage(String message, boolean overlay) { + if (player != null) { + player.sendMessage(Text.literal(message), overlay); + } else { + // If no specific player, log to console + AmbleKit.LOGGER.info("[Script Message] {}", message); + } + } + + // ===== Server Info ===== + + @LuaExpose + public String serverName() { + MinecraftServer srv = getServer(); + return srv != null ? srv.getName() : "unknown"; + } + + @LuaExpose + public int tickCount() { + MinecraftServer srv = getServer(); + return srv != null ? srv.getTicks() : 0; + } + + @LuaExpose + public double serverTps() { + MinecraftServer srv = getServer(); + if (srv == null) return 20.0; + // Average TPS calculation based on tick times + long[] tickTimes = srv.lastTickLengths; + if (tickTimes == null || tickTimes.length == 0) return 20.0; + + long sum = 0; + for (long tickTime : tickTimes) { + sum += tickTime; + } + double avgTickTime = (double) sum / tickTimes.length / 1_000_000.0; // Convert to milliseconds + return Math.min(20.0, 1000.0 / avgTickTime); + } + + @LuaExpose + public boolean isDedicatedServer() { + MinecraftServer srv = getServer(); + return srv != null && srv.isDedicated(); + } + + // ===== World Management ===== + + @LuaExpose + public List worldNames() { + MinecraftServer srv = getServer(); + if (srv == null) return List.of(); + return srv.getWorlds().iterator().hasNext() + ? srv.getWorldRegistryKeys().stream() + .map(key -> key.getValue().toString()) + .collect(Collectors.toList()) + : List.of(); + } + + // ===== Skin Management ===== + + /** + * Gets the UUID for a player by name. + * @param playerName the player's name + * @return the UUID, or null if player not found + */ + private UUID getPlayerUuid(String playerName) { + MinecraftServer srv = getServer(); + if (srv == null) return null; + ServerPlayerEntity target = srv.getPlayerManager().getPlayer(playerName); + return target != null ? target.getUuid() : null; + } + + /** + * Parses a UUID string. + * @param uuidString the UUID as a string + * @return the UUID, or null if invalid + */ + private UUID parseUuid(String uuidString) { + try { + return UUID.fromString(uuidString); + } catch (IllegalArgumentException e) { + AmbleKit.LOGGER.warn("Invalid UUID format: '{}'", uuidString); + return null; + } + } + + /** + * Sets a player's skin to match another player's skin (by username). + * This performs an async lookup of the skin and applies it when ready. + * + * @param playerName the player whose skin to change + * @param skinUsername the username to copy the skin from + * @return true if the player was found, false otherwise + */ + @LuaExpose + public boolean setSkin(String playerName, String skinUsername) { + UUID uuid = getPlayerUuid(playerName); + if (uuid == null) { + AmbleKit.LOGGER.warn("Cannot set skin: player '{}' not found", playerName); + return false; + } + SkinData.usernameUpload(skinUsername, uuid); + return true; + } + + /** + * Sets a player's skin from a direct URL. + * + * @param playerName the player whose skin to change + * @param url the URL to the skin image + * @param slim true for slim (Alex) arms, false for wide (Steve) arms + * @return true if the player was found, false otherwise + */ + @LuaExpose + public boolean setSkinUrl(String playerName, String url, boolean slim) { + UUID uuid = getPlayerUuid(playerName); + if (uuid == null) { + AmbleKit.LOGGER.warn("Cannot set skin: player '{}' not found", playerName); + return false; + } + SkinData.url(url, slim).upload(uuid); + return true; + } + + /** + * Changes a player's arm model (slim or wide) without changing the skin texture. + * + * @param playerName the player whose arm model to change + * @param slim true for slim (Alex) arms, false for wide (Steve) arms + * @return true if successful, false if player not found or has no custom skin + */ + @LuaExpose + public boolean setSkinSlim(String playerName, boolean slim) { + UUID uuid = getPlayerUuid(playerName); + if (uuid == null) { + AmbleKit.LOGGER.warn("Cannot set skin slim: player '{}' not found", playerName); + return false; + } + SkinData existingSkin = SkinTracker.getInstance().get(uuid); + if (existingSkin == null) { + AmbleKit.LOGGER.warn("Cannot set skin slim: player '{}' has no custom skin", playerName); + return false; + } + SkinTracker.getInstance().putSynced(uuid, existingSkin.withSlim(slim)); + return true; + } + + /** + * Clears a player's custom skin, restoring their original skin. + * + * @param playerName the player whose skin to clear + * @return true if the player was found, false otherwise + */ + @LuaExpose + public boolean clearSkin(String playerName) { + UUID uuid = getPlayerUuid(playerName); + if (uuid == null) { + AmbleKit.LOGGER.warn("Cannot clear skin: player '{}' not found", playerName); + return false; + } + SkinTracker.getInstance().removeSynced(uuid); + return true; + } + + /** + * Checks if a player has a custom skin applied. + * + * @param playerName the player to check + * @return true if the player has a custom skin, false otherwise + */ + @LuaExpose + public boolean hasSkin(String playerName) { + UUID uuid = getPlayerUuid(playerName); + if (uuid == null) return false; + return SkinTracker.getInstance().containsKey(uuid); + } + + /** + * Sets a skin by UUID string. + * This performs an async lookup of the skin and applies it when ready. + * + * @param uuidString the UUID of the entity whose skin to change + * @param skinUsername the username to copy the skin from + * @return true if the UUID was valid, false otherwise + */ + @LuaExpose + public boolean setSkinByUuid(String uuidString, String skinUsername) { + UUID uuid = parseUuid(uuidString); + if (uuid == null) { + return false; + } + SkinData.usernameUpload(skinUsername, uuid); + return true; + } + + /** + * Sets a skin from a URL by UUID string. + * + * @param uuidString the UUID of the entity whose skin to change + * @param url the URL to the skin image + * @param slim true for slim (Alex) arms, false for wide (Steve) arms + * @return true if the UUID was valid, false otherwise + */ + @LuaExpose + public boolean setSkinUrlByUuid(String uuidString, String url, boolean slim) { + UUID uuid = parseUuid(uuidString); + if (uuid == null) { + return false; + } + SkinData.url(url, slim).upload(uuid); + return true; + } + + /** + * Clears a custom skin by UUID string. + * + * @param uuidString the UUID of the entity whose skin to clear + * @return true if the UUID was valid, false otherwise + */ + @LuaExpose + public boolean clearSkinByUuid(String uuidString) { + UUID uuid = parseUuid(uuidString); + if (uuid == null) { + return false; + } + SkinTracker.getInstance().removeSynced(uuid); + return true; + } + + /** + * Checks if an entity has a custom skin applied by UUID string. + * + * @param uuidString the UUID to check + * @return true if the entity has a custom skin, false otherwise + */ + @LuaExpose + public boolean hasSkinByUuid(String uuidString) { + UUID uuid = parseUuid(uuidString); + if (uuid == null) return false; + return SkinTracker.getInstance().containsKey(uuid); + } + + // ===== Cross-script function calling ===== + + @Override + protected AbstractScriptManager getScriptManager() { + return ServerScriptManager.getInstance(); + } +} diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 0b68ed9..24d2aa1 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -21,6 +21,9 @@ ], "client": [ "dev.amble.lib.client.AmbleKitClient" + ], + "fabric-datagen": [ + "dev.amble.lib.datagen.AmbleKitDatagen" ] }, "mixins": [ diff --git a/src/test/java/dev/amble/litmus/client/LitmusClient.java b/src/test/java/dev/amble/litmus/client/LitmusClient.java index b235d78..bc9b9bf 100644 --- a/src/test/java/dev/amble/litmus/client/LitmusClient.java +++ b/src/test/java/dev/amble/litmus/client/LitmusClient.java @@ -3,8 +3,10 @@ import dev.amble.lib.animation.client.BedrockBlockEntityRenderer; import dev.amble.lib.animation.client.BedrockEntityRenderer; import dev.amble.litmus.block.entity.LitmusBlockEntityTypes; +import dev.amble.litmus.commands.TestScreenCommand; import dev.amble.litmus.entity.LitmusEntities; import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback; import net.fabricmc.fabric.api.client.rendering.v1.EntityRendererRegistry; import net.minecraft.client.render.block.entity.BlockEntityRendererFactories; import net.minecraft.client.render.entity.EntityRendererFactory; @@ -12,5 +14,8 @@ public class LitmusClient implements ClientModInitializer { @Override public void onInitializeClient() { + ClientCommandRegistrationCallback.EVENT.register((dispatcher, access) -> { + TestScreenCommand.register(dispatcher); + }); } } diff --git a/src/test/java/dev/amble/litmus/commands/TestScreenCommand.java b/src/test/java/dev/amble/litmus/commands/TestScreenCommand.java new file mode 100644 index 0000000..1ec3a7e --- /dev/null +++ b/src/test/java/dev/amble/litmus/commands/TestScreenCommand.java @@ -0,0 +1,71 @@ +package dev.amble.litmus.commands; + +import com.mojang.brigadier.Command; +import com.mojang.brigadier.CommandDispatcher; +import dev.amble.lib.client.gui.*; +import dev.amble.lib.client.gui.registry.AmbleGuiRegistry; +import dev.amble.litmus.LitmusMod; +import dev.drtheo.scheduler.api.TimeUnit; +import dev.drtheo.scheduler.api.client.ClientScheduler; +import net.fabricmc.fabric.api.client.command.v2.ClientCommandManager; +import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource; +import net.minecraft.client.MinecraftClient; +import net.minecraft.command.argument.IdentifierArgumentType; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; + +import java.awt.Color; +import java.awt.Rectangle; + + +public class TestScreenCommand { + private static String translationKey(String key) { + return "command." + LitmusMod.MOD_ID + ".screen." + key; + } + + public static void register(CommandDispatcher dispatcher) { + dispatcher.register(ClientCommandManager.literal("ambleScreen").executes(source -> { + source.getSource().sendFeedback(Text.translatable(translationKey("available"))); + + for (AmbleContainer container : AmbleGuiRegistry.getInstance().toList()) { + source.getSource().sendFeedback(Text.translatable(translationKey("list_item"), container.id().toString())); + } + + MinecraftClient.getInstance().execute(() -> { + ClientScheduler.get().runTaskLater(() -> { + AmbleContainer container = AmbleContainer.builder().layout(new Rectangle(0,0, 216, 138)).background(AmbleDisplayType.texture(new AmbleDisplayType.TextureData(new Identifier(LitmusMod.MOD_ID, "textures/gui/test_screen.png"), 0, 0, 216, 138, 256, 256))).build(); + AmbleContainer child1 = AmbleContainer.builder().layout(new Rectangle(0,0, 50, 50)).background(AmbleDisplayType.color(Color.BLUE)).build(); + AmbleContainer child2 = AmbleContainer.builder().layout(new Rectangle(0,0, 25, 25)).background(AmbleDisplayType.color(Color.ORANGE)).build(); + AmbleButton child3 = AmbleButton.buttonBuilder().layout(new Rectangle(0,0, 75, 40)).horizontalAlign(UIAlign.CENTRE).background(Color.GREEN).hoverDisplay(Color.YELLOW).pressDisplay(Color.RED).onClick(() -> { + System.out.println("Button Clicked!"); + }).build(); + AmbleText child4 = AmbleText.textBuilder().background(AmbleDisplayType.color(new Color(0,0,0,0))).text(Text.translatable("gui." + LitmusMod.MOD_ID + ".test_button")).build(); + child4.setPreferredLayout(child3.getPreferredLayout()); + child3.addChild(child4); + container.setPadding(10); + container.setSpacing(1); + container.addChild(child1); + container.addChild(child2); + container.addChild(child3); + container.setHorizontalAlign(UIAlign.CENTRE); + container.setVerticalAlign(UIAlign.CENTRE); + container.recalcuateLayout(); + + container.display(); + }, TimeUnit.SECONDS, 1); + }); + return Command.SINGLE_SUCCESS; + }).then(ClientCommandManager.argument("id", IdentifierArgumentType.identifier()).executes(source -> { + Identifier id = source.getArgument("id", Identifier.class); + AmbleContainer container = AmbleGuiRegistry.getInstance().get(id); + if (container == null) { + source.getSource().sendError(Text.translatable(translationKey("not_found"), id.toString())); + return 0; + } + + ClientScheduler.get().runTaskLater(container::display, TimeUnit.SECONDS, 1); + + return Command.SINGLE_SUCCESS; + }))); + } +} diff --git a/src/test/java/dev/amble/litmus/datagen/LitmusDatagen.java b/src/test/java/dev/amble/litmus/datagen/LitmusDatagen.java new file mode 100644 index 0000000..e08639b --- /dev/null +++ b/src/test/java/dev/amble/litmus/datagen/LitmusDatagen.java @@ -0,0 +1,35 @@ +package dev.amble.litmus.datagen; + +import dev.amble.lib.datagen.lang.AmbleLanguageProvider; +import dev.amble.lib.datagen.lang.LanguageType; +import net.fabricmc.fabric.api.datagen.v1.DataGeneratorEntrypoint; +import net.fabricmc.fabric.api.datagen.v1.FabricDataGenerator; +import net.fabricmc.fabric.api.datagen.v1.FabricDataOutput; + +public class LitmusDatagen implements DataGeneratorEntrypoint { + @Override + public void onInitializeDataGenerator(FabricDataGenerator generator) { + FabricDataGenerator.Pack pack = generator.createPack(); + pack.addProvider(LitmusLanguageProvider::new); + } + + public static class LitmusLanguageProvider extends AmbleLanguageProvider { + public LitmusLanguageProvider(FabricDataOutput output) { + super(output, LanguageType.EN_US); + } + + @Override + public void generateTranslations(TranslationBuilder builder) { + // Test screen command translations + addTranslation("command." + modid + ".screen.available", "Available screens:"); + addTranslation("command." + modid + ".screen.list_item", " - %s"); + addTranslation("command." + modid + ".screen.not_found", "No screen found with id: %s"); + + // GUI translations + addTranslation("gui." + modid + ".test_button", "press me"); + + super.generateTranslations(builder); + } + } +} + diff --git a/src/test/resources/assets/litmus/gui/skin_changer.json b/src/test/resources/assets/litmus/gui/skin_changer.json new file mode 100644 index 0000000..b7ca7d8 --- /dev/null +++ b/src/test/resources/assets/litmus/gui/skin_changer.json @@ -0,0 +1,209 @@ +{ + "layout": [ + 280, + 200 + ], + "background": { + "texture": "litmus:textures/gui/test_screen.png", + "u": 0, + "v": 0, + "regionWidth": 216, + "regionHeight": 138, + "textureWidth": 256, + "textureHeight": 256 + }, + "padding": 12, + "spacing": 10, + "alignment": [ + "centre", + "centre" + ], + "should_pause": true, + "children": [ + { + "id": "litmus:player_display", + "entity_uuid": "player", + "follow_cursor": true, + "entity_scale": 0.9, + "layout": [ + 70, + 160 + ], + "background": [ + 40, + 40, + 60, + 200 + ] + }, + { + "id": "litmus:right_panel", + "layout": [ + 170, + 160 + ], + "background": [ + 30, + 30, + 50, + 180 + ], + "padding": 8, + "spacing": 8, + "alignment": [ + "centre", + "start" + ], + "children": [ + { + "id": "litmus:title_text", + "layout": [ + 154, + 14 + ], + "background": [ + 0, + 0, + 0, + 0 + ], + "text": "§lSkin Changer", + "text_alignment": [ + "centre", + "centre" + ] + }, + { + "id": "litmus:player_name_text", + "layout": [ + 154, + 12 + ], + "background": [ + 0, + 0, + 0, + 0 + ], + "text": "Current: Loading...", + "text_alignment": [ + "centre", + "centre" + ] + }, + { + "id": "litmus:username_input", + "text_input": true, + "placeholder": "Enter username...", + "max_length": 16, + "layout": [ + 154, + 20 + ], + "background": [ + 20, + 20, + 30, + 255 + ], + "border_color": [ + 80, + 80, + 100 + ], + "focused_border_color": [ + 100, + 140, + 220 + ], + "selection_color": [ + 80, + 120, + 200, + 128 + ], + "placeholder_color": [ + 100, + 100, + 120 + ] + }, + { + "id": "litmus:set_skin_btn", + "script": "litmus:skin_changer", + "hover_background": [ + 70, + 100, + 70 + ], + "press_background": [ + 40, + 70, + 40 + ], + "layout": [ + 154, + 20 + ], + "background": [ + 50, + 80, + 50 + ], + "text": "§fSet Skin", + "text_alignment": [ + "centre", + "centre" + ] + }, + { + "id": "litmus:slim_toggle_btn", + "script": "litmus:skin_changer_slim", + "hover_background": [ + 80, + 80, + 120 + ], + "press_background": [ + 50, + 50, + 80 + ], + "layout": [ + 154, + 20 + ], + "background": [ + 60, + 60, + 90 + ], + "text": "§fModel: Classic", + "text_alignment": [ + "centre", + "centre" + ] + }, + { + "id": "litmus:status_text", + "layout": [ + 154, + 12 + ], + "background": [ + 0, + 0, + 0, + 0 + ], + "text": "", + "text_alignment": [ + "centre", + "centre" + ] + } + ] + } + ] +} + diff --git a/src/test/resources/assets/litmus/gui/test.json b/src/test/resources/assets/litmus/gui/test.json new file mode 100644 index 0000000..58b3e3b --- /dev/null +++ b/src/test/resources/assets/litmus/gui/test.json @@ -0,0 +1,267 @@ +{ + "layout": [ + 360, + 200 + ], + "background": { + "texture": "litmus:textures/gui/test_screen.png", + "u": 0, + "v": 0, + "regionWidth": 216, + "regionHeight": 138, + "textureWidth": 256, + "textureHeight": 256 + }, + "padding": 12, + "spacing": 12, + "alignment": [ + "centre", + "start" + ], + "children": [ + { + "id": "litmus:player_display", + "entity_uuid": "player", + "follow_cursor": true, + "entity_scale": 0.9, + "layout": [ + 60, + 80 + ], + "background": [ + 40, + 40, + 60, + 200 + ] + }, + { + "layout": [ + 110, + 80 + ], + "background": [ + 30, + 30, + 50, + 180 + ], + "padding": 6, + "spacing": 4, + "alignment": [ + "start", + "start" + ], + "children": [ + { + "id": "litmus:player_name", + "layout": [ + 98, + 14 + ], + "background": [ + 0, + 0, + 0, + 0 + ], + "text": "Player", + "text_alignment": [ + "start", + "centre" + ] + }, + { + "id": "litmus:player_health", + "layout": [ + 98, + 12 + ], + "background": [ + 0, + 0, + 0, + 0 + ], + "text": "Health: --", + "text_alignment": [ + "start", + "centre" + ] + }, + { + "id": "litmus:player_pos", + "layout": [ + 98, + 12 + ], + "background": [ + 0, + 0, + 0, + 0 + ], + "text": "Pos: --", + "text_alignment": [ + "start", + "centre" + ] + } + ] + }, + { + "requires_new_row": true, + "id": "litmus:nearest_display", + "entity_uuid": "", + "follow_cursor": false, + "look_at": [ + 30, + 20 + ], + "entity_scale": 0.9, + "layout": [ + 60, + 80 + ], + "background": [ + 60, + 40, + 40, + 200 + ] + }, + { + "layout": [ + 110, + 80 + ], + "background": [ + 50, + 30, + 30, + 180 + ], + "padding": 6, + "spacing": 4, + "alignment": [ + "start", + "start" + ], + "children": [ + { + "id": "litmus:nearest_name", + "layout": [ + 98, + 14 + ], + "background": [ + 0, + 0, + 0, + 0 + ], + "text": "Nearest Entity", + "text_alignment": [ + "start", + "centre" + ] + }, + { + "id": "litmus:nearest_type", + "layout": [ + 98, + 12 + ], + "background": [ + 0, + 0, + 0, + 0 + ], + "text": "Type: --", + "text_alignment": [ + "start", + "centre" + ] + }, + { + "id": "litmus:nearest_health", + "layout": [ + 98, + 12 + ], + "background": [ + 0, + 0, + 0, + 0 + ], + "text": "Health: --", + "text_alignment": [ + "start", + "centre" + ] + }, + { + "id": "litmus:nearest_dist", + "layout": [ + 98, + 12 + ], + "background": [ + 0, + 0, + 0, + 0 + ], + "text": "Dist: --", + "text_alignment": [ + "start", + "centre" + ] + } + ] + }, + { + "script": "litmus:test", + "hover_background": [ + 80, + 80, + 120 + ], + "press_background": [ + 60, + 60, + 100 + ], + "layout": [ + 100, + 20 + ], + "background": [ + 60, + 60, + 90 + ], + "children": [ + { + "layout": [ + 100, + 20 + ], + "background": [ + 0, + 0, + 0, + 0 + ], + "text": "§f⟳ Refresh", + "text_alignment": [ + "centre", + "centre" + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/src/test/resources/assets/litmus/script/auto_torch.lua b/src/test/resources/assets/litmus/script/auto_torch.lua new file mode 100644 index 0000000..c2ff150 --- /dev/null +++ b/src/test/resources/assets/litmus/script/auto_torch.lua @@ -0,0 +1,50 @@ +-- Auto Torch Script: Warns when light level is low (mobs can spawn) +-- Enable with: /amblescript enable litmus:auto_torch +-- Disable with: /amblescript disable litmus:auto_torch +-- +-- Note: minecraft data is passed as first argument to callbacks. + +local WARNING_COOLDOWN = 100 -- ticks between warnings (5 seconds) +local ticksSinceWarning = WARNING_COOLDOWN + +function onEnable(mc) + mc:sendMessage("§e🔦 Auto Torch Advisor enabled!", false) + mc:sendMessage("§7 Will warn you when light level is dangerously low", false) +end + +function onTick(mc) + ticksSinceWarning = ticksSinceWarning + 1 + + -- Only check every 10 ticks for performance + if ticksSinceWarning % 10 ~= 0 then + return + end + + local player = mc:player() + local pos = player:blockPosition() + local lightLevel = mc:lightLevelAt(pos.x, pos.y, pos.z) + + -- Check if we're on the ground and light is low + if player:isOnGround() and lightLevel < 8 and ticksSinceWarning >= WARNING_COOLDOWN then + ticksSinceWarning = 0 + + -- Different warnings based on light level + if lightLevel <= 0 then + mc:sendMessage("§4⚠ DANGER! §cComplete darkness (Light: " .. lightLevel .. ") - Mobs WILL spawn!", true) + if mc:isClientSide() then + mc:playSound("minecraft:block.note_block.bass", 0.8, 0.5) + end + elseif lightLevel <= 3 then + mc:sendMessage("§c⚠ Warning! §eVery dark (Light: " .. lightLevel .. ") - High spawn risk!", true) + if mc:isClientSide() then + mc:playSound("minecraft:block.note_block.hat", 0.5, 0.8) + end + else + mc:sendMessage("§e⚠ Caution: §7Low light (Light: " .. lightLevel .. ") - Mobs can spawn", true) + end + end +end + +function onDisable(mc) + mc:sendMessage("§7🔦 Auto Torch Advisor disabled", false) +end diff --git a/src/test/resources/assets/litmus/script/clipboard_demo.lua b/src/test/resources/assets/litmus/script/clipboard_demo.lua new file mode 100644 index 0000000..bef1662 --- /dev/null +++ b/src/test/resources/assets/litmus/script/clipboard_demo.lua @@ -0,0 +1,53 @@ +-- Clipboard Demo Script: Demonstrates clipboard and UI functionality +-- Run with: /amblescript execute litmus:clipboard_demo +-- +-- Note: This script uses client-only features (clipboard, window size) + +function onExecute(mc, args) + -- Check if we're on the client side + if not mc:isClientSide() then + mc:sendMessage("§cThis script requires client-side features!", false) + return + end + + local player = mc:player() + local pos = player:position() + + -- Header + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Clipboard Demo ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + -- Show current clipboard content + local currentClipboard = mc:clipboard() + if currentClipboard and currentClipboard ~= "" then + local preview = currentClipboard + if #preview > 50 then + preview = preview:sub(1, 50) .. "..." + end + mc:sendMessage("§7Current clipboard: §f" .. preview, false) + else + mc:sendMessage("§7Current clipboard: §8(empty)", false) + end + + mc:sendMessage("", false) + + -- Copy coordinates to clipboard + local coords = string.format("%.0f %.0f %.0f", pos.x, pos.y, pos.z) + mc:setClipboard(coords) + mc:sendMessage("§a✓ Copied coordinates to clipboard!", false) + mc:sendMessage("§7 " .. coords, false) + + mc:sendMessage("", false) + + -- Window info + mc:sendMessage("§e§l✦ Window Info ✦", false) + mc:sendMessage("§7Window size: §f" .. mc:windowWidth() .. "§7 x §f" .. mc:windowHeight(), false) + + -- Play a sound to indicate success + mc:playSound("minecraft:entity.experience_orb.pickup", 1.0, 1.5) + + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§7Tip: Paste (Ctrl+V) to use the coordinates!", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) +end diff --git a/src/test/resources/assets/litmus/script/entity_inspect.lua b/src/test/resources/assets/litmus/script/entity_inspect.lua new file mode 100644 index 0000000..5ea1258 --- /dev/null +++ b/src/test/resources/assets/litmus/script/entity_inspect.lua @@ -0,0 +1,167 @@ +-- Entity Inspect Script: Shows info about the entity you're looking at +-- Run with: /amblescript execute litmus:entity_inspect +-- +-- Note: Uses client-only lookingAtEntity feature + +function onExecute(mc, args) + local target = nil + + -- lookingAtEntity is client-only + if mc:isClientSide() then + target = mc:lookingAtEntity() + end + + -- Header + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Entity Inspector ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + if not target then + mc:sendMessage("§8Look at an entity and run this script!", false) + + -- Show nearest entity instead + local nearest = mc:nearestEntity(10) + if nearest then + mc:sendMessage("", false) + mc:sendMessage("§7Nearest entity (within 10 blocks):", false) + mc:sendMessage("§a→ §f" .. nearest:name() .. " §7(" .. nearest:type():gsub("minecraft:", "") .. ")", false) + + local player = mc:player() + local playerPos = player:position() + local distance = nearest:distanceTo(playerPos.x, playerPos.y, playerPos.z) + mc:sendMessage("§7 Distance: §e" .. string.format("%.1f", distance) .. " blocks", false) + else + mc:sendMessage("§8No entities within 10 blocks!", false) + end + + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + return + end + + -- Basic info + mc:sendMessage("§7Name: §f" .. target:name(), false) + mc:sendMessage("§7Type: §b" .. target:type():gsub("minecraft:", ""), false) + mc:sendMessage("§7UUID: §8" .. target:uuid():sub(1, 8) .. "...", false) + + -- Position + local pos = target:position() + mc:sendMessage("§7Position: §f" .. string.format("%.1f", pos.x) .. "§7, §f" .. string.format("%.1f", pos.y) .. "§7, §f" .. string.format("%.1f", pos.z), false) + + -- Distance from player + local player = mc:player() + local playerPos = player:position() + local distance = target:distanceTo(playerPos.x, playerPos.y, playerPos.z) + mc:sendMessage("§7Distance: §e" .. string.format("%.1f", distance) .. " blocks", false) + + -- Health (if living entity) + local health = target:health() + local maxHealth = target:maxHealth() + if health >= 0 then + -- Health bar + local barLength = 15 + local healthPercent = health / maxHealth + local filledLength = math.floor(healthPercent * barLength) + local healthColor = "§a" + if healthPercent < 0.25 then + healthColor = "§c" + elseif healthPercent < 0.5 then + healthColor = "§e" + end + + local healthBar = healthColor + for i = 1, barLength do + if i <= filledLength then + healthBar = healthBar .. "❤" + else + healthBar = healthBar .. "§8❤" + end + end + + mc:sendMessage("§7Health: " .. healthBar .. " §f" .. string.format("%.1f", health) .. "§7/§f" .. string.format("%.0f", maxHealth), false) + end + + -- Armor + local armor = target:armorValue() + if armor > 0 then + mc:sendMessage("§9Armor: §f" .. armor, false) + end + + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Entity State ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + -- States + local states = {} + + if target:isPlayer() then + table.insert(states, "§a✓ Player") + end + + if target:isAlive() then + table.insert(states, "§a✓ Alive") + else + table.insert(states, "§c✗ Dead") + end + + if target:isOnGround() then + table.insert(states, "§7• On Ground") + else + table.insert(states, "§7• Airborne") + end + + if target:isSprinting() then + table.insert(states, "§e• Sprinting") + end + + if target:isSneaking() then + table.insert(states, "§7• Sneaking") + end + + if target:isTouchingWater() then + table.insert(states, "§b• In Water") + end + + if target:isSwimming() then + table.insert(states, "§b• Swimming") + end + + if target:isOnFire() then + table.insert(states, "§c🔥 On Fire!") + end + + if target:isInvisible() then + table.insert(states, "§7• Invisible") + end + + if target:isGlowing() then + table.insert(states, "§e• Glowing") + end + + for _, state in ipairs(states) do + mc:sendMessage(" " .. state, false) + end + + -- Velocity + local vel = target:velocity() + local speed = math.sqrt(vel.x * vel.x + vel.z * vel.z) + mc:sendMessage("§7Speed: §f" .. string.format("%.2f", speed * 20) .. " §7blocks/sec", false) + + -- Rotation + mc:sendMessage("§7Looking: §fYaw " .. string.format("%.0f", target:yaw()) .. "°, Pitch " .. string.format("%.0f", target:pitch()) .. "°", false) + + -- Age + mc:sendMessage("§7Age: §f" .. target:age() .. " ticks §8(" .. string.format("%.1f", target:age() / 20) .. "s)", false) + + -- Effects + local effects = target:effects() + if #effects > 0 then + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§d§l✦ Status Effects ✦", false) + for _, effect in ipairs(effects) do + local cleanEffect = effect:gsub("minecraft:", ""):gsub("_", " ") + mc:sendMessage(" §d✧ §f" .. cleanEffect, false) + end + end + + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) +end diff --git a/src/test/resources/assets/litmus/script/hotbar_cycle.lua b/src/test/resources/assets/litmus/script/hotbar_cycle.lua new file mode 100644 index 0000000..186b5a9 --- /dev/null +++ b/src/test/resources/assets/litmus/script/hotbar_cycle.lua @@ -0,0 +1,50 @@ +-- Hotbar Cycle Script: Cycles through hotbar slots with a fun animation +-- Run with: /amblescript execute litmus:hotbar_cycle +-- +-- Note: Uses client-only hotbar selection features + +local cycleIndex = 1 +local cycleDirection = 1 + +function onExecute(mc, args) + -- Check if we're on the client side + if not mc:isClientSide() then + mc:sendMessage("§cThis script requires client-side features!", false) + return + end + + local currentSlot = mc:selectedSlot() + + -- Calculate next slot (1-9) + local nextSlot = currentSlot + cycleDirection + + if nextSlot > 9 then + nextSlot = 1 + cycleIndex = cycleIndex + 1 + elseif nextSlot < 1 then + nextSlot = 9 + cycleIndex = cycleIndex + 1 + end + + -- Select the next slot + mc:selectSlot(nextSlot) + + -- Create a visual indicator + local indicator = "" + for i = 1, 9 do + if i == nextSlot then + indicator = indicator .. "§e[" .. i .. "]" + else + indicator = indicator .. "§7 " .. i .. " " + end + end + + mc:sendMessage("§6Hotbar: " .. indicator, true) + + -- Change direction every full cycle + if cycleIndex > 2 then + cycleDirection = -cycleDirection + cycleIndex = 1 + mc:sendMessage("§d✦ Direction reversed! ✦", true) + end +end diff --git a/src/test/resources/assets/litmus/script/input_test.lua b/src/test/resources/assets/litmus/script/input_test.lua new file mode 100644 index 0000000..3a9553e --- /dev/null +++ b/src/test/resources/assets/litmus/script/input_test.lua @@ -0,0 +1,68 @@ +-- Input Test Script: Shows which movement keys are currently pressed +-- Run with: /amblescript execute litmus:input_test +-- +-- Note: Uses client-only input detection features + +function onExecute(mc, args) + -- Check if we're on the client side + if not mc:isClientSide() then + mc:sendMessage("§cThis script requires client-side features!", false) + return + end + + -- Header + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Input State ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + -- Movement keys + local keys = { + {"forward", "W", "Forward"}, + {"back", "S", "Back"}, + {"left", "A", "Left"}, + {"right", "D", "Right"}, + {"jump", "Space", "Jump"}, + {"sneak", "Shift", "Sneak"}, + {"sprint", "Ctrl", "Sprint"}, + {"attack", "LMB", "Attack"}, + {"use", "RMB", "Use"} + } + + -- Visual keyboard layout for WASD + local w = mc:isKeyPressed("forward") and "§a[W]" or "§8[W]" + local a = mc:isKeyPressed("left") and "§a[A]" or "§8[A]" + local s = mc:isKeyPressed("back") and "§a[S]" or "§8[S]" + local d = mc:isKeyPressed("right") and "§a[D]" or "§8[D]" + + mc:sendMessage("§7Movement Keys:", false) + mc:sendMessage(" " .. w, false) + mc:sendMessage(" " .. a .. " " .. s .. " " .. d, false) + mc:sendMessage("", false) + + -- Other keys + mc:sendMessage("§7Action Keys:", false) + + local pressedKeys = {} + + for _, keyData in ipairs(keys) do + local keyName = keyData[1] + local displayKey = keyData[2] + local description = keyData[3] + + if mc:isKeyPressed(keyName) then + table.insert(pressedKeys, " §a✓ " .. displayKey .. " §7(" .. description .. ")") + end + end + + if #pressedKeys > 0 then + for _, msg in ipairs(pressedKeys) do + mc:sendMessage(msg, false) + end + else + mc:sendMessage(" §8No action keys pressed", false) + end + + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§7Tip: Hold keys while running this script!", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) +end diff --git a/src/test/resources/assets/litmus/script/item_info.lua b/src/test/resources/assets/litmus/script/item_info.lua new file mode 100644 index 0000000..9ee7f71 --- /dev/null +++ b/src/test/resources/assets/litmus/script/item_info.lua @@ -0,0 +1,120 @@ +-- Item Info Script: Shows detailed information about held item +-- Run with: /amblescript execute litmus:item_info +-- +-- Note: Uses client-only hotbar selection features + +function onExecute(mc, args) + -- Check if we're on the client side + if not mc:isClientSide() then + mc:sendMessage("§cThis script requires client-side features!", false) + return + end + + local player = mc:player() + local inventory = player:inventory() + local selectedSlot = mc:selectedSlot() + local heldItem = inventory[selectedSlot] + + -- Header + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Held Item Info (Slot " .. selectedSlot .. ") ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + if heldItem:isEmpty() then + mc:sendMessage("§8You're not holding anything!", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + return + end + + -- Basic info + mc:sendMessage("§7Name: §f" .. heldItem:name(), false) + mc:sendMessage("§7ID: §b" .. heldItem:id(), false) + + -- Rarity with color + local rarity = heldItem:rarity() + local rarityColor = "§f" + if rarity == "uncommon" then + rarityColor = "§e" + elseif rarity == "rare" then + rarityColor = "§b" + elseif rarity == "epic" then + rarityColor = "§d" + end + mc:sendMessage("§7Rarity: " .. rarityColor .. rarity:sub(1,1):upper() .. rarity:sub(2), false) + + -- Stack info + local count = heldItem:count() + local maxCount = heldItem:maxCount() + if heldItem:isStackable() then + mc:sendMessage("§7Stack: §f" .. count .. "§7/§f" .. maxCount, false) + else + mc:sendMessage("§7Stack: §8Not stackable", false) + end + + -- Durability + if heldItem:isDamageable() then + local damage = heldItem:damage() + local maxDamage = heldItem:maxDamage() + local durability = maxDamage - damage + local durabilityPercent = heldItem:durabilityPercent() + + -- Durability bar + local barLength = 20 + local filledLength = math.floor(durabilityPercent * barLength) + local durColor = "§a" + if durabilityPercent < 0.25 then + durColor = "§c" + elseif durabilityPercent < 0.5 then + durColor = "§e" + end + + local durBar = durColor + for i = 1, barLength do + if i <= filledLength then + durBar = durBar .. "|" + else + durBar = durBar .. "§8|" + end + end + + mc:sendMessage("§7Durability: " .. durBar .. " §f" .. durability .. "§7/§f" .. maxDamage, false) + end + + -- Food info + if heldItem:isFood() then + mc:sendMessage("§6🍖 This item is edible!", false) + end + + -- Custom name + if heldItem:hasCustomName() then + mc:sendMessage("§7Custom Named: §a✓", false) + end + + -- Enchantments + if heldItem:hasEnchantments() then + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§d§l✦ Enchantments ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + local enchants = heldItem:enchantments() + for _, enchant in ipairs(enchants) do + -- Parse enchantment:level format + local colonPos = enchant:find(":") + if colonPos then + local lastColon = enchant:match(".*():") + local enchantName = enchant:sub(1, lastColon - 1):gsub("minecraft:", ""):gsub("_", " ") + local level = enchant:sub(lastColon + 1) + mc:sendMessage(" §d✧ §f" .. enchantName .. " §7" .. level, false) + else + mc:sendMessage(" §d✧ §f" .. enchant, false) + end + end + end + + -- NBT info + if heldItem:hasNbt() then + mc:sendMessage("§7Has NBT Data: §a✓", false) + end + + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) +end diff --git a/src/test/resources/assets/litmus/script/player_state.lua b/src/test/resources/assets/litmus/script/player_state.lua new file mode 100644 index 0000000..4ffea09 --- /dev/null +++ b/src/test/resources/assets/litmus/script/player_state.lua @@ -0,0 +1,124 @@ +-- Player State Script: Shows detailed player state information +-- Run with: /amblescript execute litmus:player_state +-- +-- Note: minecraft data is passed as first argument to callbacks + +function onExecute(mc, args) + local player = mc:player() + + -- Header - username is client-only, so we use player name instead + local playerName = player:name() + if mc:isClientSide() then + playerName = mc:username() + end + + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Player State: §f" .. playerName .. " §e§l✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + -- Health & Hunger + local health = player:health() + local maxHealth = player:maxHealth() + local food = player:foodLevel() + local saturation = player:saturation() + local armor = player:armorValue() + + mc:sendMessage("§c❤ Health: §f" .. string.format("%.1f", health) .. "§7/§f" .. string.format("%.0f", maxHealth), false) + mc:sendMessage("§6🍖 Hunger: §f" .. food .. "§7/§f20 §8(Saturation: " .. string.format("%.1f", saturation) .. ")", false) + mc:sendMessage("§9🛡 Armor: §f" .. armor, false) + + -- Experience + local xpLevel = player:experienceLevel() + local xpProgress = player:experienceProgress() + local totalXp = player:totalExperience() + + -- XP bar visualization + local barLength = 20 + local filledLength = math.floor(xpProgress * barLength) + local xpBar = "§a" + for i = 1, barLength do + if i <= filledLength then + xpBar = xpBar .. "|" + else + xpBar = xpBar .. "§8|" + end + end + mc:sendMessage("§a✧ Level: §f" .. xpLevel .. " " .. xpBar .. " §7(" .. string.format("%.0f", xpProgress * 100) .. "%)", false) + mc:sendMessage("§7 Total XP: §e" .. totalXp, false) + + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Movement State ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + -- Movement states + local states = {} + + if player:isOnGround() then + table.insert(states, "§a✓ On Ground") + else + table.insert(states, "§c✗ Airborne") + end + + if player:isSprinting() then + table.insert(states, "§a✓ Sprinting") + end + + if player:isSneaking() then + table.insert(states, "§a✓ Sneaking") + end + + if player:isSwimming() then + table.insert(states, "§b✓ Swimming") + end + + if player:isTouchingWater() then + table.insert(states, "§b✓ In Water") + end + + if player:isFlying() then + table.insert(states, "§d✓ Flying") + end + + if player:isOnFire() then + table.insert(states, "§c🔥 On Fire!") + end + + if player:isInvisible() then + table.insert(states, "§7✓ Invisible") + end + + if player:isGlowing() then + table.insert(states, "§e✓ Glowing") + end + + for _, state in ipairs(states) do + mc:sendMessage(" " .. state, false) + end + + -- Velocity + local vel = player:velocity() + local speed = math.sqrt(vel.x * vel.x + vel.z * vel.z) + mc:sendMessage("§7Speed: §f" .. string.format("%.2f", speed * 20) .. " §7blocks/sec", false) + + -- Game mode (client only) + if mc:isClientSide() then + mc:sendMessage("§7Game Mode: §e" .. mc:gameMode(), false) + end + + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Active Effects ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + -- Status effects + local effects = player:effects() + if #effects > 0 then + for _, effect in ipairs(effects) do + local cleanEffect = effect:gsub("minecraft:", ""):gsub("_", " ") + mc:sendMessage(" §d✦ §f" .. cleanEffect, false) + end + else + mc:sendMessage(" §8No active effects", false) + end + + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) +end diff --git a/src/test/resources/assets/litmus/script/skin_changer.lua b/src/test/resources/assets/litmus/script/skin_changer.lua new file mode 100644 index 0000000..164a130 --- /dev/null +++ b/src/test/resources/assets/litmus/script/skin_changer.lua @@ -0,0 +1,96 @@ +-- Skin Changer Script +-- Handles the "Set Skin" button functionality + +-- Helper function to find an element by ID recursively +local function findById(element, targetId) + local elemId = element:id() + if elemId == targetId then + return element + end + + local count = element:childCount() + for i = 0, count - 1 do + local child = element:child(i) + if child then + local found = findById(child, targetId) + if found then + return found + end + end + end + + return nil +end + +-- Find the root element by traversing up from self +local function getRoot(element) + local current = element + while current:parent() do + current = current:parent() + end + return current +end + +function onDisplay(self) + -- Update the player name display + local root = getRoot(self) + local mc = self:minecraft() + + local playerNameText = findById(root, "litmus:player_name_text") + if playerNameText then + local username = mc:username() + playerNameText:setText("Current: " .. username) + end +end + +function onClick(self, mouseX, mouseY, button) + local root = getRoot(self) + local mc = self:minecraft() + + -- Find the text input + local usernameInput = findById(root, "litmus:username_input") + local statusText = findById(root, "litmus:status_text") + + if not usernameInput then + mc:sendMessage("§cError: Could not find username input!", false) + return + end + + local username = usernameInput:getText() + + -- Validate input + if not username or username == "" then + if statusText then + statusText:setText("§cEnter a username!") + end + mc:playSound("minecraft:block.note_block.bass", 0.5, 0.8) + return + end + + -- Trim whitespace (basic) + username = username:match("^%s*(.-)%s*$") + + if username == "" then + if statusText then + statusText:setText("§cEnter a username!") + end + mc:playSound("minecraft:block.note_block.bass", 0.5, 0.8) + return + end + + -- Run the skin command + local command = "/amblekit skin @p set " .. username + mc:runCommand(command) + + -- Update status + if statusText then + statusText:setText("§aSkin set to: " .. username) + end + + -- Play success sound + mc:playSound("minecraft:ui.button.click", 1.0, 1.2) + + -- Clear the input + usernameInput:setText("") +end + diff --git a/src/test/resources/assets/litmus/script/skin_changer_slim.lua b/src/test/resources/assets/litmus/script/skin_changer_slim.lua new file mode 100644 index 0000000..ef3b568 --- /dev/null +++ b/src/test/resources/assets/litmus/script/skin_changer_slim.lua @@ -0,0 +1,79 @@ +-- Skin Changer Slim Toggle Script +-- Handles the "Toggle Slim" button functionality + +-- Track the current slim mode state +local slimMode = false + +-- Helper function to find an element by ID recursively +local function findById(element, targetId) + if element:id() == targetId then + return element + end + + local count = element:childCount() + for i = 0, count - 1 do + local child = element:child(i) + if child then + local found = findById(child, targetId) + if found then + return found + end + end + end + + return nil +end + +-- Find the root element by traversing up from self +local function getRoot(element) + local current = element + while current:parent() do + current = current:parent() + end + return current +end + +-- Update the button text to reflect current mode +local function updateButtonText(self) + -- Find the text child within the button + local textChild = self:findFirstText() + if textChild then + if slimMode then + textChild:setText("§fModel: Slim") + else + textChild:setText("§fModel: Classic") + end + end +end + +function onDisplay(self) + -- Set initial button text + updateButtonText(self) +end + +function onClick(self, mouseX, mouseY, button) + local root = getRoot(self) + local mc = self:minecraft() + local statusText = findById(root, "litmus:status_text") + + -- Toggle the slim mode + slimMode = not slimMode + + -- Run the command + local slimValue = slimMode and "true" or "false" + local command = "/amblekit skin @p slim " .. slimValue + mc:runCommand(command) + + -- Update button text + updateButtonText(self) + + -- Update status + if statusText then + local modelName = slimMode and "Slim" or "Classic" + statusText:setText("§aModel: " .. modelName) + end + + -- Play sound + mc:playSound("minecraft:ui.button.click", 1.0, 1.0) +end + diff --git a/src/test/resources/assets/litmus/script/sprint_monitor.lua b/src/test/resources/assets/litmus/script/sprint_monitor.lua new file mode 100644 index 0000000..e45ca56 --- /dev/null +++ b/src/test/resources/assets/litmus/script/sprint_monitor.lua @@ -0,0 +1,72 @@ +-- Sprint Monitor Script: Shows sprint/movement info in action bar +-- Enable with: /amblescript enable litmus:sprint_monitor +-- Disable with: /amblescript disable litmus:sprint_monitor +-- +-- Note: minecraft data is passed as first argument to callbacks (optional). +-- The 'minecraft' global is also available for backward compatibility. + +local lastUpdate = 0 +local UPDATE_INTERVAL = 2 -- Update every 2 ticks for smooth display + +function onEnable(mc) + -- mc is the ClientMinecraftData passed as argument + -- You can also use the global 'minecraft' variable + mc:sendMessage("§b🏃 Sprint Monitor enabled!", false) +end + +function onTick(mc) + lastUpdate = lastUpdate + 1 + if lastUpdate < UPDATE_INTERVAL then + return + end + lastUpdate = 0 + + local player = mc:player() + local vel = player:velocity() + local speed = math.sqrt(vel.x * vel.x + vel.z * vel.z) * 20 -- blocks per second + + -- Build status string + local status = "" + + -- Movement mode + if player:isFlying() then + status = status .. "§b✈ Flying " + elseif player:isSwimming() then + status = status .. "§3🏊 Swimming " + elseif player:isSprinting() then + status = status .. "§a🏃 Sprinting " + elseif player:isSneaking() then + status = status .. "§7🚶 Sneaking " + elseif speed > 0.1 then + status = status .. "§f🚶 Walking " + else + status = status .. "§8⏸ Still " + end + + -- Speed indicator + local speedColor = "§7" + if speed > 10 then + speedColor = "§c" + elseif speed > 7 then + speedColor = "§e" + elseif speed > 4 then + speedColor = "§a" + end + status = status .. speedColor .. string.format("%.1f", speed) .. " m/s" + + -- Ground state + if not player:isOnGround() and not player:isFlying() and not player:isSwimming() then + status = status .. " §d↑ Airborne" + end + + -- Water state + if player:isTouchingWater() and not player:isSwimming() then + status = status .. " §9💧" + end + + mc:sendMessage(status, true) +end + +function onDisable(mc) + mc:sendMessage("§7🏃 Sprint Monitor disabled", false) +end diff --git a/src/test/resources/assets/litmus/script/stats.lua b/src/test/resources/assets/litmus/script/stats.lua new file mode 100644 index 0000000..e8e7f02 --- /dev/null +++ b/src/test/resources/assets/litmus/script/stats.lua @@ -0,0 +1,98 @@ +-- Stats Script: Shows player info and nearby entities +-- Run with: /amblescript execute litmus:stats +-- +-- Note: minecraft data is passed as first argument to callbacks + +function onExecute(mc, args) + -- Get player info + local player = mc:player() + local pos = player:position() + local health = player:health() + local food = player:foodLevel() + + -- Username is client-only, use player name on server + local username = player:name() + if mc:isClientSide() then + username = mc:username() + end + + -- Selected slot is client-only + local slot = "N/A" + if mc:isClientSide() then + slot = tostring(mc:selectedSlot()) + end + + -- Send a stylish header + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Player Stats for §f" .. username .. " §e§l✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + -- Health bar visualization + local maxHearts = 10 + local currentHearts = math.floor(health / 2) + local heartBar = "" + for i = 1, maxHearts do + if i <= currentHearts then + heartBar = heartBar .. "§c❤" + else + heartBar = heartBar .. "§8❤" + end + end + mc:sendMessage("§7Health: " .. heartBar .. " §f(" .. string.format("%.1f", health) .. ")", false) + + -- Food bar visualization + local maxFood = 10 + local currentFood = math.floor(food / 2) + local foodBar = "" + for i = 1, maxFood do + if i <= currentFood then + foodBar = foodBar .. "§6🍖" + else + foodBar = foodBar .. "§8🍖" + end + end + mc:sendMessage("§7Hunger: " .. foodBar .. " §f(" .. food .. ")", false) + + -- Position + mc:sendMessage("§7Position: §b" .. string.format("%.1f", pos.x) .. "§7, §a" .. string.format("%.1f", pos.y) .. "§7, §d" .. string.format("%.1f", pos.z), false) + mc:sendMessage("§7Selected Slot: §e" .. slot, false) + + -- Count nearby entities + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Nearby Entities ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + local entities = mc:entities() + local entityCounts = {} + local totalCount = 0 + + for _, entity in pairs(entities) do + local entityType = entity:type() + -- Skip the player themselves + if not (entity:isPlayer() and entity:name() == username) then + entityCounts[entityType] = (entityCounts[entityType] or 0) + 1 + totalCount = totalCount + 1 + end + end + + -- Display entity counts (limit to first 8 types) + local displayed = 0 + for entityType, count in pairs(entityCounts) do + if displayed < 8 then + -- Clean up the entity type name + local cleanName = entityType:gsub("minecraft:", ""):gsub("_", " ") + mc:sendMessage("§7• §f" .. cleanName .. "§7: §a" .. count, false) + displayed = displayed + 1 + end + end + + if displayed == 0 then + mc:sendMessage("§7No entities nearby!", false) + elseif totalCount > displayed then + mc:sendMessage("§8...and " .. (totalCount - displayed) .. " more types", false) + end + + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§7Total entities: §e" .. totalCount, false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) +end diff --git a/src/test/resources/assets/litmus/script/test.lua b/src/test/resources/assets/litmus/script/test.lua new file mode 100644 index 0000000..5b0f1a1 --- /dev/null +++ b/src/test/resources/assets/litmus/script/test.lua @@ -0,0 +1,143 @@ +-- Test Script: Entity Display Demo +-- This script demonstrates the AmbleEntityDisplay component +-- showing the player and nearest entity with live info updates +-- +-- Note: GUI scripts receive 'self' (LuaElement) and use self:minecraft() for data + +-- Helper to find a child element by ID +function findChildById(root, targetId) + if root:id() == targetId then + return root + end + for i = 0, root:childCount() - 1 do + local child = root:child(i) + if child then + local found = findChildById(child, targetId) + if found then + return found + end + end + end + return nil +end + +-- Helper to format position +function formatPos(pos) + return string.format("%.0f, %.0f, %.0f", pos.x, pos.y, pos.z) +end + +-- Helper to format health +function formatHealth(current, max) + if current < 0 then + return "--" + end + return string.format("%.1f/%.1f", current, max) +end + +function onDisplay(self) + local mc = self:minecraft() + local player = mc:player() + + -- Get the root container (parent of parent since we're a child element) + local root = self + while root:parent() do + root = root:parent() + end + + -- Update player display - set UUID to local player + local playerDisplay = findChildById(root, "litmus:player_display") + if playerDisplay then + playerDisplay:setEntityUuid("player") + end + + -- Update player info + local playerName = findChildById(root, "litmus:player_name") + if playerName then + playerName:setText("§b" .. mc:username()) + end + + local playerHealth = findChildById(root, "litmus:player_health") + if playerHealth then + local health = player:health() + local maxHealth = player:maxHealth() + playerHealth:setText("§c❤ §f" .. formatHealth(health, maxHealth)) + end + + local playerPos = findChildById(root, "litmus:player_pos") + if playerPos then + local pos = player:position() + playerPos:setText("§7" .. formatPos(pos)) + end + + -- Find nearest entity (excluding player) + local nearest = mc:nearestEntity(20) + + local nearestDisplay = findChildById(root, "litmus:nearest_display") + local nearestName = findChildById(root, "litmus:nearest_name") + local nearestType = findChildById(root, "litmus:nearest_type") + local nearestHealth = findChildById(root, "litmus:nearest_health") + local nearestDist = findChildById(root, "litmus:nearest_dist") + + if nearest then + -- Set entity UUID for display + if nearestDisplay then + nearestDisplay:setEntityUuid(nearest:uuid()) + end + + -- Update info labels + if nearestName then + nearestName:setText("§e" .. nearest:name()) + end + + if nearestType then + local entityType = nearest:type():gsub("minecraft:", "") + nearestType:setText("§7Type: §f" .. entityType) + end + + if nearestHealth then + local health = nearest:health() + local maxHealth = nearest:maxHealth() + if health >= 0 then + nearestHealth:setText("§c❤ §f" .. formatHealth(health, maxHealth)) + else + nearestHealth:setText("§7No health") + end + end + + if nearestDist then + local playerPos = player:position() + local dist = nearest:distanceTo(playerPos.x, playerPos.y, playerPos.z) + nearestDist:setText("§7Dist: §f" .. string.format("%.1f", dist) .. "m") + end + else + -- No entity nearby + if nearestDisplay then + nearestDisplay:setEntityUuid("") + end + if nearestName then + nearestName:setText("§8No entity nearby") + end + if nearestType then + nearestType:setText("§7Type: §8--") + end + if nearestHealth then + nearestHealth:setText("§7Health: §8--") + end + if nearestDist then + nearestDist:setText("§7Dist: §8--") + end + end +end + +function onClick(self, mouseX, mouseY, button) + -- Refresh entity info when clicked + onDisplay(self) + + local mc = self:minecraft() + mc:playSound("minecraft:ui.button.click", 1.0, 1.0) +end + +function onExecute(mc, args) + mc:log("Entity display test script executed!") + mc:sendMessage("§aEntity display demo - open the test GUI to see it!", false) +end diff --git a/src/test/resources/assets/litmus/script/tick_demo.lua b/src/test/resources/assets/litmus/script/tick_demo.lua new file mode 100644 index 0000000..fd7c5b6 --- /dev/null +++ b/src/test/resources/assets/litmus/script/tick_demo.lua @@ -0,0 +1,49 @@ +-- Tick Demo Script: Demonstrates onEnable, onTick, onDisable lifecycle +-- Enable with: /amblescript enable litmus:tick_demo +-- Disable with: /amblescript disable litmus:tick_demo +-- +-- Note: minecraft data is passed as first argument to callbacks. +-- Use mc:isClientSide() to check if running on client or server. + +local tickCount = 0 +local lastSecond = 0 + +function onEnable(mc) + tickCount = 0 + lastSecond = 0 + mc:sendMessage("§a✓ Tick Demo enabled! Counting ticks...", false) + mc:log("Tick Demo enabled - isClientSide: " .. tostring(mc:isClientSide())) + + -- playSound only available on client + if mc:isClientSide() then + mc:playSound("minecraft:block.note_block.pling", 1.0, 2.0) + end +end + +function onTick(mc) + tickCount = tickCount + 1 + + -- Every 20 ticks (1 second), show a message + local currentSecond = math.floor(tickCount / 20) + if currentSecond > lastSecond then + lastSecond = currentSecond + + -- Show in action bar every second + mc:sendMessage("§7Tick Demo: §e" .. tickCount .. " ticks §7(§f" .. currentSecond .. "s§7)", true) + + -- Play a subtle sound every 5 seconds (client only) + if currentSecond % 5 == 0 and mc:isClientSide() then + mc:playSound("minecraft:block.note_block.hat", 0.5, 1.0) + end + end +end + +function onDisable(mc) + local totalSeconds = math.floor(tickCount / 20) + mc:sendMessage("§c✗ Tick Demo disabled!", false) + mc:sendMessage("§7 Ran for §e" .. tickCount .. " ticks §7(§f" .. totalSeconds .. " seconds§7)", false) + + if mc:isClientSide() then + mc:playSound("minecraft:block.note_block.bass", 1.0, 0.5) + end +end diff --git a/src/test/resources/assets/litmus/script/world_info.lua b/src/test/resources/assets/litmus/script/world_info.lua new file mode 100644 index 0000000..057a782 --- /dev/null +++ b/src/test/resources/assets/litmus/script/world_info.lua @@ -0,0 +1,95 @@ +-- World Info Script: Displays world environment information +-- Run with: /amblescript execute litmus:world_info +-- +-- Note: minecraft data is passed as first argument to callbacks. + +function onExecute(mc, args) + local player = mc:player() + local pos = player:blockPosition() + + -- Header + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ World Information ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + -- Dimension + local dimension = mc:dimension() + local dimColor = "§a" + if dimension:find("nether") then + dimColor = "§c" + elseif dimension:find("end") then + dimColor = "§d" + end + mc:sendMessage("§7Dimension: " .. dimColor .. dimension, false) + + -- Time of day + local worldTime = mc:worldTime() + local dayCount = mc:dayCount() + local timeOfDay = worldTime % 24000 + + local timeString = "Day" + local timeIcon = "☀" + if timeOfDay >= 13000 and timeOfDay < 23000 then + timeString = "Night" + timeIcon = "☾" + elseif timeOfDay >= 23000 or timeOfDay < 1000 then + timeString = "Dawn" + timeIcon = "✧" + elseif timeOfDay >= 11000 and timeOfDay < 13000 then + timeString = "Dusk" + timeIcon = "✧" + end + + mc:sendMessage("§7Time: §e" .. timeIcon .. " " .. timeString .. " §7(Day §f" .. dayCount .. "§7)", false) + + -- Weather + local weatherIcon = "☀" + local weatherText = "Clear" + local weatherColor = "§e" + if mc:isThundering() then + weatherIcon = "⚡" + weatherText = "Thunderstorm" + weatherColor = "§5" + elseif mc:isRaining() then + weatherIcon = "🌧" + weatherText = "Raining" + weatherColor = "§9" + end + mc:sendMessage("§7Weather: " .. weatherColor .. weatherIcon .. " " .. weatherText, false) + + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Location Details ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + -- Biome + local biome = mc:biomeAt(pos.x, pos.y, pos.z) + local cleanBiome = biome:gsub("minecraft:", ""):gsub("_", " ") + mc:sendMessage("§7Biome: §a" .. cleanBiome, false) + + -- Block below player + local blockBelow = mc:blockAt(pos.x, pos.y - 1, pos.z) + local cleanBlock = blockBelow:gsub("minecraft:", ""):gsub("_", " ") + mc:sendMessage("§7Standing on: §b" .. cleanBlock, false) + + -- Light level + local lightLevel = mc:lightLevelAt(pos.x, pos.y, pos.z) + local lightColor = "§a" + if lightLevel < 8 then + lightColor = "§c" -- Mobs can spawn + elseif lightLevel < 12 then + lightColor = "§e" + end + mc:sendMessage("§7Light Level: " .. lightColor .. lightLevel .. " §8(mobs spawn below 8)", false) + + -- Looking at block (client only feature) + if mc:isClientSide() then + local lookingAt = mc:lookingAtBlock() + if lookingAt then + local targetBlock = mc:blockAt(lookingAt.x, lookingAt.y, lookingAt.z) + local cleanTarget = targetBlock:gsub("minecraft:", ""):gsub("_", " ") + mc:sendMessage("§7Looking at: §d" .. cleanTarget .. " §8(" .. lookingAt.x .. ", " .. lookingAt.y .. ", " .. lookingAt.z .. ")", false) + end + end + + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) +end diff --git a/src/test/resources/assets/litmus/textures/gui/test_screen.png b/src/test/resources/assets/litmus/textures/gui/test_screen.png new file mode 100644 index 0000000..f21b2a1 Binary files /dev/null and b/src/test/resources/assets/litmus/textures/gui/test_screen.png differ diff --git a/src/test/resources/data/litmus/script/admin_commands.lua b/src/test/resources/data/litmus/script/admin_commands.lua new file mode 100644 index 0000000..b3fb9ca --- /dev/null +++ b/src/test/resources/data/litmus/script/admin_commands.lua @@ -0,0 +1,65 @@ +-- Admin Commands Script: Utility commands for server administration +-- Run with: /serverscript execute litmus:admin_commands +-- +-- This is a SERVER-SIDE script with various admin utilities. + +function onExecute(mc, args) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Admin Commands Executed ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + -- Log current server state + mc:log("=== Admin Commands Executed ===") + mc:log("Server: " .. mc:serverName()) + mc:log("TPS: " .. string.format("%.1f", mc:serverTps())) + mc:log("Players: " .. mc:playerCount() .. "/" .. mc:maxPlayers()) + + -- List all players with their locations + local players = mc:allPlayers() + mc:sendMessage("§e§l✦ Player Locations ✦", false) + + for _, player in ipairs(players) do + local pos = player:position() + local health = player:health() + local maxHealth = player:maxHealth() + + local healthColor = "§a" + if health / maxHealth < 0.25 then + healthColor = "§c" + elseif health / maxHealth < 0.5 then + healthColor = "§e" + end + + local locationStr = string.format("§f%.0f§7, §f%.0f§7, §f%.0f", pos.x, pos.y, pos.z) + local healthStr = healthColor .. string.format("%.0f", health) .. "§7/" .. string.format("%.0f", maxHealth) + + mc:sendMessage(" §a" .. player:name() .. " §7→ " .. locationStr .. " §7(" .. healthStr .. "§7)", false) + mc:log("Player: " .. player:name() .. " at " .. locationStr .. " health: " .. health) + end + + if #players == 0 then + mc:sendMessage(" §8No players online", false) + end + + -- World info + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ World State ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + mc:sendMessage("§7Dimension: §f" .. mc:dimension(), false) + mc:sendMessage("§7World Time: §f" .. mc:worldTime() .. " §7(Day " .. mc:dayCount() .. ")", false) + + local weather = "Clear" + if mc:isThundering() then + weather = "Thunderstorm" + elseif mc:isRaining() then + weather = "Raining" + end + mc:sendMessage("§7Weather: §f" .. weather, false) + + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§7Admin report logged to console.", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + mc:log("=== End Admin Report ===") +end diff --git a/src/test/resources/data/litmus/script/auto_broadcast.lua b/src/test/resources/data/litmus/script/auto_broadcast.lua new file mode 100644 index 0000000..25d2a86 --- /dev/null +++ b/src/test/resources/data/litmus/script/auto_broadcast.lua @@ -0,0 +1,49 @@ +-- Auto Broadcast Script: Periodically broadcasts messages to all players +-- Enable with: /serverscript enable litmus:auto_broadcast +-- Disable with: /serverscript disable litmus:auto_broadcast +-- +-- This is a SERVER-SIDE script that broadcasts messages periodically. + +local tickCount = 0 +local messageIndex = 1 +local BROADCAST_INTERVAL = 1200 -- Every 60 seconds (1200 ticks) + +-- Messages to cycle through +local messages = { + "§6§l[TIP] §r§7Use §e/serverscript list §7to see enabled scripts!", + "§6§l[TIP] §r§7Server-side scripts run for all players automatically.", + "§6§l[TIP] §r§7Scripts are loaded from the §edata §7folder.", + "§6§l[INFO] §r§7This message was sent by a Lua script!", + "§a§l[SERVER] §r§7Welcome to the server! Enjoy your stay.", +} + +function onEnable(mc) + tickCount = 0 + messageIndex = 1 + mc:log("Auto Broadcast enabled with " .. #messages .. " messages") + mc:broadcast("§a§l[Server] §r§7Auto broadcast enabled!") +end + +function onTick(mc) + tickCount = tickCount + 1 + + if tickCount % BROADCAST_INTERVAL ~= 0 then + return + end + + -- Broadcast current message + local message = messages[messageIndex] + mc:broadcast(message) + mc:log("Broadcasted message " .. messageIndex .. ": " .. message) + + -- Move to next message + messageIndex = messageIndex + 1 + if messageIndex > #messages then + messageIndex = 1 + end +end + +function onDisable(mc) + mc:broadcast("§c§l[Server] §r§7Auto broadcast disabled") + mc:log("Auto Broadcast disabled after " .. tickCount .. " ticks") +end diff --git a/src/test/resources/data/litmus/script/player_tracker.lua b/src/test/resources/data/litmus/script/player_tracker.lua new file mode 100644 index 0000000..5a897f9 --- /dev/null +++ b/src/test/resources/data/litmus/script/player_tracker.lua @@ -0,0 +1,51 @@ +-- Player Tracker Script: Monitors players and logs activity +-- Enable with: /serverscript enable litmus:player_tracker +-- Disable with: /serverscript disable litmus:player_tracker +-- +-- This is a SERVER-SIDE script that tracks player activity. + +local lastPlayerCount = 0 +local tickCount = 0 +local CHECK_INTERVAL = 100 -- Check every 5 seconds (100 ticks) + +function onEnable(mc) + lastPlayerCount = mc:playerCount() + tickCount = 0 + mc:log("Player Tracker enabled - tracking " .. lastPlayerCount .. " players") + mc:broadcast("§e[Server] §7Player tracking enabled") +end + +function onTick(mc) + tickCount = tickCount + 1 + + if tickCount % CHECK_INTERVAL ~= 0 then + return + end + + local currentCount = mc:playerCount() + + -- Check if player count changed + if currentCount ~= lastPlayerCount then + local diff = currentCount - lastPlayerCount + if diff > 0 then + mc:log("Player count increased: " .. lastPlayerCount .. " -> " .. currentCount) + else + mc:log("Player count decreased: " .. lastPlayerCount .. " -> " .. currentCount) + end + lastPlayerCount = currentCount + end + + -- Log all player positions every 5 seconds + local players = mc:allPlayers() + for _, player in ipairs(players) do + local pos = player:position() + mc:log("Player " .. player:name() .. " at " .. + string.format("%.0f, %.0f, %.0f", pos.x, pos.y, pos.z) .. + " (Health: " .. string.format("%.1f", player:health()) .. ")") + end +end + +function onDisable(mc) + mc:log("Player Tracker disabled after " .. tickCount .. " ticks") + mc:broadcast("§e[Server] §7Player tracking disabled") +end diff --git a/src/test/resources/data/litmus/script/server_status.lua b/src/test/resources/data/litmus/script/server_status.lua new file mode 100644 index 0000000..0768df4 --- /dev/null +++ b/src/test/resources/data/litmus/script/server_status.lua @@ -0,0 +1,84 @@ +-- Server Status Script: Shows server information and statistics +-- Run with: /serverscript execute litmus:server_status +-- +-- This is a SERVER-SIDE script. It runs on the server and has access to +-- all players, server TPS, and other server-specific information. + +function onExecute(mc, args) + -- Confirm we're on the server + if mc:isClientSide() then + mc:sendMessage("§cThis script should only run on the server!", false) + return + end + + mc:log("Server status script executed") + + -- Header + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Server Status ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + -- Server info + mc:sendMessage("§7Server: §f" .. mc:serverName(), false) + mc:sendMessage("§7Type: §f" .. (mc:isDedicatedServer() and "Dedicated" or "Integrated"), false) + + -- Performance + local tps = mc:serverTps() + local tpsColor = "§a" + if tps < 15 then + tpsColor = "§c" + elseif tps < 18 then + tpsColor = "§e" + end + mc:sendMessage("§7TPS: " .. tpsColor .. string.format("%.1f", tps) .. "§7/20", false) + mc:sendMessage("§7Tick Count: §f" .. mc:tickCount(), false) + + -- World info + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ World Info ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + mc:sendMessage("§7Dimension: §f" .. mc:dimension(), false) + mc:sendMessage("§7Day: §f" .. mc:dayCount(), false) + + local weatherIcon = "☀" + local weatherText = "Clear" + local weatherColor = "§e" + if mc:isThundering() then + weatherIcon = "⚡" + weatherText = "Thunderstorm" + weatherColor = "§5" + elseif mc:isRaining() then + weatherIcon = "🌧" + weatherText = "Raining" + weatherColor = "§9" + end + mc:sendMessage("§7Weather: " .. weatherColor .. weatherIcon .. " " .. weatherText, false) + + -- Players + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Players (" .. mc:playerCount() .. "/" .. mc:maxPlayers() .. ") ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + local players = mc:allPlayerNames() + if #players > 0 then + for _, name in ipairs(players) do + mc:sendMessage(" §a• §f" .. name, false) + end + else + mc:sendMessage(" §8No players online", false) + end + + -- Worlds + local worlds = mc:worldNames() + if #worlds > 0 then + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Loaded Worlds ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + for _, world in ipairs(worlds) do + mc:sendMessage(" §b• §f" .. world, false) + end + end + + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) +end diff --git a/src/test/resources/data/litmus/script/skin_disguise.lua b/src/test/resources/data/litmus/script/skin_disguise.lua new file mode 100644 index 0000000..b96921d --- /dev/null +++ b/src/test/resources/data/litmus/script/skin_disguise.lua @@ -0,0 +1,48 @@ +-- Skin Disguise Script: Disguise yourself as famous Minecraft players +-- Run with: /serverscript execute litmus:skin_disguise +-- +-- This script demonstrates the basic skin API methods. + +-- List of famous/notable Minecraft usernames +local DISGUISES = { + "Notch", + "jeb_", + "Dinnerbone", + "Herobrine", + "Dream", + "Technoblade", + "Ph1LzA", + "TommyInnit" +} + +function onExecute(mc, args) + local player = mc:player() + if player == nil then + mc:log("No player context - run this as a player!") + return + end + + local playerName = player:name() + + -- Check if already disguised + if mc:hasSkin(playerName) then + -- Clear the disguise + if mc:clearSkin(playerName) then + mc:sendMessage("§7Disguise removed! You look like yourself again.", false) + mc:log("Player " .. playerName .. " removed their disguise") + else + mc:sendMessage("§cFailed to remove disguise!", false) + end + else + -- Pick a random disguise + math.randomseed(os.time()) + local disguise = DISGUISES[math.random(#DISGUISES)] + + if mc:setSkin(playerName, disguise) then + mc:sendMessage("§aYou are now disguised as §e" .. disguise .. "§a!", false) + mc:log("Player " .. playerName .. " disguised as " .. disguise) + else + mc:sendMessage("§cFailed to apply disguise!", false) + end + end +end diff --git a/src/test/resources/data/litmus/script/skin_manager.lua b/src/test/resources/data/litmus/script/skin_manager.lua new file mode 100644 index 0000000..e9762cf --- /dev/null +++ b/src/test/resources/data/litmus/script/skin_manager.lua @@ -0,0 +1,97 @@ +-- Skin Manager Script: Comprehensive skin management with tick-based monitoring +-- Enable with: /serverscript enable litmus:skin_manager +-- Disable with: /serverscript disable litmus:skin_manager +-- Execute with: /serverscript execute litmus:skin_manager +-- +-- This script demonstrates: +-- - Setting skins by username and URL +-- - Checking skin status +-- - UUID-based skin operations +-- - Tick-based skin monitoring + +-- Track players we've given skins to +local skinnedPlayers = {} +local tickCounter = 0 + +function onExecute(mc, args) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Skin Manager Status ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + local players = mc:allPlayerNames() + local withSkin = 0 + local withoutSkin = 0 + + for _, name in ipairs(players) do + if mc:hasSkin(name) then + withSkin = withSkin + 1 + mc:sendMessage(" §a✓ §f" .. name .. " §7(custom skin)", false) + else + withoutSkin = withoutSkin + 1 + mc:sendMessage(" §7○ §f" .. name .. " §8(default skin)", false) + end + end + + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§7Custom skins: §a" .. withSkin .. "§7 | Default: §8" .. withoutSkin, false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) +end + +function onEnable(mc) + skinnedPlayers = {} + tickCounter = 0 + mc:broadcast("§e[Skin Manager] §aEnabled! New players will receive skins.") + mc:log("Skin Manager enabled") +end + +function onTick(mc) + tickCounter = tickCounter + 1 + + -- Only check every 100 ticks (5 seconds) + if tickCounter % 100 ~= 0 then + return + end + + -- Check for new players without skins + local players = mc:allPlayers() + + for _, player in ipairs(players) do + local name = player:name() + local uuid = player:uuid() + + -- Skip if we've already processed this player + if skinnedPlayers[uuid] then + goto continue + end + + -- Check if player already has a custom skin + if not mc:hasSkinByUuid(uuid) then + -- Give them a default "welcome" skin + if mc:setSkinByUuid(uuid, "Steve") then + mc:broadcastToPlayer(name, "§e[Skin Manager] §7Welcome! Default skin applied.", false) + mc:log("Applied default skin to new player: " .. name) + end + end + + -- Mark as processed + skinnedPlayers[uuid] = true + + ::continue:: + end +end + +function onDisable(mc) + -- Clear all skins when disabled + local cleared = 0 + for _, name in ipairs(mc:allPlayerNames()) do + if mc:hasSkin(name) then + if mc:clearSkin(name) then + cleared = cleared + 1 + end + end + end + + mc:broadcast("§e[Skin Manager] §cDisabled. Cleared " .. cleared .. " custom skins.") + mc:log("Skin Manager disabled, cleared " .. cleared .. " skins") + skinnedPlayers = {} +end diff --git a/src/test/resources/data/litmus/script/skin_party.lua b/src/test/resources/data/litmus/script/skin_party.lua new file mode 100644 index 0000000..1fdc381 --- /dev/null +++ b/src/test/resources/data/litmus/script/skin_party.lua @@ -0,0 +1,63 @@ +-- Skin Party Script: Shuffle everyone's skins randomly! +-- Run with: /serverscript execute litmus:skin_party +-- +-- This script swaps everyone's skins around for fun. +-- Great for events and parties! + +function onExecute(mc, args) + local players = mc:allPlayers() + local count = #players + + if count < 2 then + mc:sendMessage("§cNeed at least 2 players for a skin party!", false) + return + end + + mc:broadcast("§d§l✨ SKIN PARTY! ✨") + mc:broadcast("§7Everyone's skins are being shuffled...") + + -- Collect all player names and UUIDs + local playerData = {} + for _, player in ipairs(players) do + table.insert(playerData, { + name = player:name(), + uuid = player:uuid() + }) + end + + -- Create a shuffled copy of names for skin sources + local skinSources = {} + for _, data in ipairs(playerData) do + table.insert(skinSources, data.name) + end + + -- Fisher-Yates shuffle + math.randomseed(os.time()) + for i = #skinSources, 2, -1 do + local j = math.random(i) + skinSources[i], skinSources[j] = skinSources[j], skinSources[i] + end + + -- Apply shuffled skins + local success = 0 + for i, data in ipairs(playerData) do + local skinSource = skinSources[i] + + -- Don't give someone their own skin + if skinSource == data.name then + -- Swap with next person + local nextIdx = (i % #skinSources) + 1 + skinSources[i], skinSources[nextIdx] = skinSources[nextIdx], skinSources[i] + skinSource = skinSources[i] + end + + if mc:setSkin(data.name, skinSource) then + mc:broadcastToPlayer(data.name, "§dYou now look like §e" .. skinSource .. "§d!", false) + success = success + 1 + end + end + + mc:broadcast("§d§l✨ " .. success .. " skins shuffled! ✨") + mc:broadcast("§7Run the command again to reshuffle!") + mc:log("Skin party: shuffled " .. success .. " skins") +end diff --git a/src/test/resources/data/litmus/script/skin_set.lua b/src/test/resources/data/litmus/script/skin_set.lua new file mode 100644 index 0000000..3639b18 --- /dev/null +++ b/src/test/resources/data/litmus/script/skin_set.lua @@ -0,0 +1,59 @@ +-- skin_set.lua +-- Set or clear a player's skin with command-line arguments +-- +-- Usage: +-- /serverscript execute litmus:skin_set - Clear your skin +-- /serverscript execute litmus:skin_set - Set skin (wide arms) +-- /serverscript execute litmus:skin_set - Set skin with arm style +-- /serverscript execute litmus:skin_set +-- +-- Examples: +-- /serverscript execute litmus:skin_set - Clear your own skin +-- /serverscript execute litmus:skin_set Notch - Set your skin to Notch (wide arms) +-- /serverscript execute litmus:skin_set Notch true - Set your skin to Notch (slim arms) +-- /serverscript execute litmus:skin_set Notch false duzo - Set duzo's skin to Notch (wide arms) +-- +-- Arguments: +-- skin_username - The Minecraft username to copy the skin from (omit to clear skin) +-- slim - (optional) "true" for slim (Alex) arms, defaults to false (Steve arms) +-- target_player - (optional) Player to apply the skin to; defaults to command executor + +function onExecute(mc, args) + -- Determine target player first + local targetPlayer = args[3] + if targetPlayer == nil or targetPlayer == "" then + local player = mc:player() + if player == nil then + mc:sendMessage("§cNo player context and no target player specified!", false) + return + end + targetPlayer = player:name() + end + + -- No arguments = clear skin + if args == nil or #args < 1 or args[1] == nil or args[1] == "" then + if mc:clearSkin(targetPlayer) then + mc:sendMessage("§aSkin cleared for §f" .. targetPlayer, false) + else + mc:sendMessage("§cFailed to clear skin! Player may not exist.", false) + end + return + end + + local skinUsername = args[1] + local slimArg = args[2] + + -- Parse slim boolean (defaults to false) + local slim = slimArg == "true" or slimArg == "1" or slimArg == "yes" + + -- Apply the skin + mc:sendMessage("§7Setting skin for §f" .. targetPlayer .. "§7 to §f" .. skinUsername .. "§7 (slim: §f" .. tostring(slim) .. "§7)...", false) + + if mc:setSkin(targetPlayer, skinUsername) then + mc:sendMessage("§aSkin applied successfully!", false) + -- Set the arm model + mc:setSkinSlim(targetPlayer, slim) + else + mc:sendMessage("§cFailed to apply skin! Player may not exist.", false) + end +end diff --git a/src/test/resources/data/litmus/script/skin_team.lua b/src/test/resources/data/litmus/script/skin_team.lua new file mode 100644 index 0000000..2bed54b --- /dev/null +++ b/src/test/resources/data/litmus/script/skin_team.lua @@ -0,0 +1,155 @@ +-- Skin Team Script: Assign team-based skins to players +-- Enable with: /serverscript enable litmus:skin_team +-- Disable with: /serverscript disable litmus:skin_team +-- Execute with: /serverscript execute litmus:skin_team +-- +-- This script assigns players to teams based on join order +-- and gives them matching team skins. + +-- Team configurations +local TEAMS = { + { + name = "Red Team", + color = "§c", + skin = "Notch" -- Team captain skin + }, + { + name = "Blue Team", + color = "§9", + skin = "jeb_" -- Team captain skin + }, + { + name = "Green Team", + color = "§a", + skin = "Dinnerbone" + }, + { + name = "Yellow Team", + color = "§e", + skin = "Dream" + } +} + +-- Track team assignments +local playerTeams = {} -- uuid -> team index +local teamCounts = {} -- team index -> player count + +function initTeamCounts() + teamCounts = {} + for i = 1, #TEAMS do + teamCounts[i] = 0 + end +end + +function getSmallestTeam() + local minCount = 999999 + local minTeam = 1 + + for i, count in ipairs(teamCounts) do + if count < minCount then + minCount = count + minTeam = i + end + end + + return minTeam +end + +function onExecute(mc, args) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Team Skin Status ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + -- Show team rosters + for i, team in ipairs(TEAMS) do + local count = teamCounts[i] or 0 + mc:sendMessage(team.color .. "§l" .. team.name .. " §7(" .. count .. " players)", false) + + -- List players on this team + for uuid, teamIdx in pairs(playerTeams) do + if teamIdx == i then + -- Find player name by UUID + for _, player in ipairs(mc:allPlayers()) do + if player:uuid() == uuid then + mc:sendMessage(" " .. team.color .. "• §f" .. player:name(), false) + break + end + end + end + end + end + + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) +end + +function onEnable(mc) + initTeamCounts() + playerTeams = {} + + -- Assign existing players to teams + local players = mc:allPlayers() + for _, player in ipairs(players) do + assignPlayerToTeam(mc, player:name(), player:uuid()) + end + + mc:broadcast("§e[Teams] §aTeam mode enabled! Players assigned to teams.") + mc:log("Team Skin script enabled with " .. #players .. " players") +end + +function assignPlayerToTeam(mc, playerName, uuid) + -- Skip if already assigned + if playerTeams[uuid] then + return + end + + -- Assign to smallest team + local teamIdx = getSmallestTeam() + local team = TEAMS[teamIdx] + + playerTeams[uuid] = teamIdx + teamCounts[teamIdx] = (teamCounts[teamIdx] or 0) + 1 + + -- Apply team skin + if mc:setSkinByUuid(uuid, team.skin) then + mc:broadcastToPlayer(playerName, team.color .. "§lYou joined " .. team.name .. "!", false) + mc:log("Assigned " .. playerName .. " to " .. team.name) + else + mc:logWarn("Failed to apply team skin for " .. playerName) + end +end + +-- Track player count to detect new joins +local lastPlayerCount = 0 + +function onTick(mc) + local currentCount = mc:playerCount() + + -- Check for new players + if currentCount > lastPlayerCount then + local players = mc:allPlayers() + for _, player in ipairs(players) do + local uuid = player:uuid() + if not playerTeams[uuid] then + assignPlayerToTeam(mc, player:name(), uuid) + end + end + end + + lastPlayerCount = currentCount +end + +function onDisable(mc) + -- Clear all team skins + local cleared = 0 + for uuid, _ in pairs(playerTeams) do + if mc:clearSkinByUuid(uuid) then + cleared = cleared + 1 + end + end + + mc:broadcast("§e[Teams] §cTeam mode disabled. Cleared " .. cleared .. " team skins.") + mc:log("Team Skin script disabled") + + playerTeams = {} + initTeamCounts() +end diff --git a/src/test/resources/data/litmus/script/skin_url_test.lua b/src/test/resources/data/litmus/script/skin_url_test.lua new file mode 100644 index 0000000..8fe9331 --- /dev/null +++ b/src/test/resources/data/litmus/script/skin_url_test.lua @@ -0,0 +1,122 @@ +-- Skin URL Test Script: Test applying skins from URLs +-- Run with: /serverscript execute litmus:skin_url_test +-- +-- This script tests URL-based skin application and slim arm toggling. + +-- Example skin URLs (these are placeholder URLs - replace with real ones) +local TEST_SKINS = { + { + name = "Classic Steve", + url = "https://assets.mojang.com/SkinTemplates/steve.png", + slim = false + }, + { + name = "Classic Alex", + url = "https://assets.mojang.com/SkinTemplates/alex.png", + slim = true + } +} + +-- Current test index per player +local playerTestIndex = {} + +function onExecute(mc, args) + local player = mc:player() + if player == nil then + mc:log("No player context!") + return + end + + local playerName = player:name() + local uuid = player:uuid() + + -- Get or initialize test index for this player + if not playerTestIndex[uuid] then + playerTestIndex[uuid] = 0 + end + + -- Cycle through tests + playerTestIndex[uuid] = playerTestIndex[uuid] + 1 + local testNum = playerTestIndex[uuid] + + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Skin URL Test #" .. testNum .. " ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + if testNum == 1 then + -- Test: Set skin by username + mc:sendMessage("§7Test: Setting skin by username (Notch)...", false) + if mc:setSkin(playerName, "Notch") then + mc:sendMessage("§a✓ Success! Skin set to Notch", false) + else + mc:sendMessage("§c✗ Failed to set skin by username", false) + end + + elseif testNum == 2 then + -- Test: Check hasSkin + mc:sendMessage("§7Test: Checking hasSkin()...", false) + local hasSkin = mc:hasSkin(playerName) + local hasSkinUuid = mc:hasSkinByUuid(uuid) + mc:sendMessage("§7hasSkin(name): " .. (hasSkin and "§atrue" or "§cfalse"), false) + mc:sendMessage("§7hasSkinByUuid(uuid): " .. (hasSkinUuid and "§atrue" or "§cfalse"), false) + + elseif testNum == 3 then + -- Test: Toggle slim arms + mc:sendMessage("§7Test: Toggling slim arms (true)...", false) + if mc:hasSkin(playerName) then + if mc:setSkinSlim(playerName, true) then + mc:sendMessage("§a✓ Success! Slim arms enabled", false) + else + mc:sendMessage("§c✗ Failed to set slim arms", false) + end + else + mc:sendMessage("§c✗ No custom skin to modify!", false) + end + + elseif testNum == 4 then + -- Test: Toggle slim arms back + mc:sendMessage("§7Test: Toggling slim arms (false)...", false) + if mc:hasSkin(playerName) then + if mc:setSkinSlim(playerName, false) then + mc:sendMessage("§a✓ Success! Wide arms enabled", false) + else + mc:sendMessage("§c✗ Failed to set wide arms", false) + end + else + mc:sendMessage("§c✗ No custom skin to modify!", false) + end + + elseif testNum == 5 then + -- Test: Set by UUID + mc:sendMessage("§7Test: Setting skin by UUID...", false) + mc:sendMessage("§8UUID: " .. uuid, false) + if mc:setSkinByUuid(uuid, "jeb_") then + mc:sendMessage("§a✓ Success! Skin set to jeb_ via UUID", false) + else + mc:sendMessage("§c✗ Failed to set skin by UUID", false) + end + + elseif testNum == 6 then + -- Test: Clear skin + mc:sendMessage("§7Test: Clearing skin...", false) + if mc:clearSkin(playerName) then + mc:sendMessage("§a✓ Success! Skin cleared", false) + else + mc:sendMessage("§c✗ Failed to clear skin", false) + end + + -- Verify it's cleared + if not mc:hasSkin(playerName) then + mc:sendMessage("§a✓ Verified: hasSkin() returns false", false) + else + mc:sendMessage("§c✗ Warning: hasSkin() still returns true!", false) + end + + -- Reset test counter + playerTestIndex[uuid] = 0 + mc:sendMessage("§7Tests complete! Run again to restart.", false) + end + + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§7Run again for next test (" .. (testNum % 6 + 1) .. "/6)", false) +end diff --git a/src/test/resources/data/litmus/script/tick_counter.lua b/src/test/resources/data/litmus/script/tick_counter.lua new file mode 100644 index 0000000..a3b47d2 --- /dev/null +++ b/src/test/resources/data/litmus/script/tick_counter.lua @@ -0,0 +1,51 @@ +-- Tick Counter Script: Simple server-side tick counter demonstration +-- Enable with: /serverscript enable litmus:tick_counter +-- Disable with: /serverscript disable litmus:tick_counter +-- +-- This is a SERVER-SIDE script demonstrating the tick lifecycle. + +local tickCount = 0 +local lastSecond = 0 +local LOG_INTERVAL = 200 -- Log every 10 seconds (200 ticks) + +function onEnable(mc) + tickCount = 0 + lastSecond = 0 + + mc:log("Server Tick Counter enabled") + mc:log(" - TPS: " .. string.format("%.1f", mc:serverTps())) + mc:log(" - Players online: " .. mc:playerCount()) + + -- Notify all players + mc:broadcast("§a[Server] §7Tick counter script enabled!") +end + +function onTick(mc) + tickCount = tickCount + 1 + + -- Log to console every LOG_INTERVAL ticks + if tickCount % LOG_INTERVAL == 0 then + local seconds = tickCount / 20 + mc:log("Tick Counter: " .. tickCount .. " ticks (" .. seconds .. "s) - TPS: " .. string.format("%.1f", mc:serverTps())) + end + + -- Broadcast to players every 60 seconds + local currentSecond = math.floor(tickCount / 20) + if currentSecond > lastSecond and currentSecond % 60 == 0 then + lastSecond = currentSecond + local minutes = math.floor(currentSecond / 60) + mc:broadcast("§7[Server] Script running for §e" .. minutes .. " minute" .. (minutes > 1 and "s" or "")) + end +end + +function onDisable(mc) + local totalSeconds = math.floor(tickCount / 20) + local minutes = math.floor(totalSeconds / 60) + local seconds = totalSeconds % 60 + + mc:log("Server Tick Counter disabled") + mc:log(" - Total ticks: " .. tickCount) + mc:log(" - Runtime: " .. minutes .. "m " .. seconds .. "s") + + mc:broadcast("§c[Server] §7Tick counter disabled after §e" .. minutes .. "m " .. seconds .. "s") +end diff --git a/src/test/resources/data/litmus/script/weather_announcer.lua b/src/test/resources/data/litmus/script/weather_announcer.lua new file mode 100644 index 0000000..73a7221 --- /dev/null +++ b/src/test/resources/data/litmus/script/weather_announcer.lua @@ -0,0 +1,70 @@ +-- Weather Announcer Script: Announces weather changes to all players +-- Enable with: /serverscript enable litmus:weather_announcer +-- Disable with: /serverscript disable litmus:weather_announcer +-- +-- This is a SERVER-SIDE script that monitors and announces weather changes. + +local lastRaining = false +local lastThundering = false +local CHECK_INTERVAL = 20 -- Check every second + +local tickCount = 0 + +function onEnable(mc) + lastRaining = mc:isRaining() + lastThundering = mc:isThundering() + tickCount = 0 + + -- Announce current weather + local weather = getWeatherName(lastRaining, lastThundering) + mc:broadcast("§e☁ Weather Announcer enabled! §7Current weather: " .. weather) + mc:log("Weather Announcer enabled - current weather: " .. weather) +end + +function getWeatherName(raining, thundering) + if thundering then + return "§5⚡ Thunderstorm" + elseif raining then + return "§9🌧 Rain" + else + return "§e☀ Clear" + end +end + +function onTick(mc) + tickCount = tickCount + 1 + + if tickCount % CHECK_INTERVAL ~= 0 then + return + end + + local currentRaining = mc:isRaining() + local currentThundering = mc:isThundering() + + -- Check for weather changes + if currentRaining ~= lastRaining or currentThundering ~= lastThundering then + local oldWeather = getWeatherName(lastRaining, lastThundering) + local newWeather = getWeatherName(currentRaining, currentThundering) + + mc:broadcast("§6§l[Weather] §r" .. oldWeather .. " §7→ " .. newWeather) + mc:log("Weather changed: " .. oldWeather .. " -> " .. newWeather) + + -- Play ambient sounds based on weather + if currentThundering and not lastThundering then + -- Could play thunder sound at all player locations + mc:broadcast("§5⚡ A thunderstorm is approaching!") + elseif currentRaining and not lastRaining then + mc:broadcast("§9🌧 It's starting to rain...") + elseif not currentRaining and lastRaining then + mc:broadcast("§e☀ The weather is clearing up!") + end + + lastRaining = currentRaining + lastThundering = currentThundering + end +end + +function onDisable(mc) + mc:broadcast("§7☁ Weather Announcer disabled") + mc:log("Weather Announcer disabled") +end diff --git a/src/test/resources/fabric.mod.json b/src/test/resources/fabric.mod.json index be930a6..9690168 100644 --- a/src/test/resources/fabric.mod.json +++ b/src/test/resources/fabric.mod.json @@ -21,7 +21,10 @@ ], "client": [ "dev.amble.litmus.client.LitmusClient" - ] + ], + "fabric-datagen": [ + "dev.amble.litmus.datagen.LitmusDatagen" + ] }, "depends": { "fabricloader": ">=0.16.10",