diff --git a/openspec/changes/add-configurator-target-history/.openspec.yaml b/openspec/changes/add-configurator-target-history/.openspec.yaml new file mode 100644 index 00000000..3a038210 --- /dev/null +++ b/openspec/changes/add-configurator-target-history/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-25 diff --git a/openspec/changes/add-configurator-target-history/design.md b/openspec/changes/add-configurator-target-history/design.md new file mode 100644 index 00000000..ebc249ca --- /dev/null +++ b/openspec/changes/add-configurator-target-history/design.md @@ -0,0 +1,74 @@ +## Context + +The Ultimate Configurator currently persists one active configurator mode, block position, and dimension. Crouch-right-clicking air clears that binding and mode-specific item data; a detached configurator has no air-use behavior. Four configuration modes are registered from block-state predicates, and existing client screens use loader-neutral screen-opening hooks plus validated serverbound messages. + +This change adds item-local target history and favorites. Records can outlive loaded chunks, removed blocks, and dimension changes, so stored identity and every mutation must be validated without loading chunks or trusting client screen state. + +## Goals / Non-Goals + +**Goals:** + +- Preserve three distinct recently attached targets and up to 16 favorites on each configurator. +- Make detached configurators a fast, native UI entry point for reattachment and favorite management. +- Keep the item stack's server-side NBT authoritative while providing responsive client controls. +- Safely retain cross-dimension and temporarily unavailable records. + +**Non-Goals:** + +- Loading chunks, teleporting players, or remotely configuring a stored target. +- Sharing target lists between configurators or players. +- Preserving mode-specific screen selections and rendering preferences per history entry. +- Adding a container menu, external dependency, or Lua API. + +## Decisions + +### Store compact target records on the item + +Store history order and favorite metadata as separate NBT lists. Each target record contains the configurator mode ID, dimension ID, and block position; favorite records may additionally contain a custom name. A target's identity is its dimension and position. Attaching at an existing identity replaces its stored mode and moves it to the front of history, which retains every favorite and the three latest non-favorites. + +Favoriting changes metadata without changing last-use order, duplicate favorite requests do not create another record, and the server rejects additions after 16 entries. Selecting any target promotes it through the shared attachment path; renaming does not reorder it. Existing stacks whose three-entry history omits older favorites append those favorites after known last-used entries. + +Alternative considered: store timestamps on each target. Rejected because one bounded order list plus favorite metadata directly represents the required ordering without clock data or migration. + +### Open a non-container screen only while detached + +A normal right-click on air with no active mode opens a client `Screen` through the existing loader-neutral client platform. The screen reads the held stack's history and favorite NBT and displays one last-used list. Default rows show the translated target block name, dimension, and coordinates. A named favorite displays only its custom name with a golden outline. + +Each row provides immediate selection. Non-favorite recent rows provide a favorite action, while favorite rows provide an edit action that opens a focused child screen for renaming or removing the favorite. Submitting an empty name removes the custom name and restores the default label; non-empty names are limited to 64 characters. The screen remains non-pausing and closes after a successful selection. + +The detached screen also has a Settings tab. It uses vanilla item hover-name storage for the configurator name and the shared `TextStyle`/`BoxStyle` enums for favorite world rendering. Favorite records may override text and box colors through the existing network-manager color picker. + +The central configuration render registry handles detached configurators by passing current-dimension favorites directly to `TargetRenderHelper`. Attached configurators retain their mode-specific renderer; detached rendering never loads chunks or resolves block entities. + +Alternative considered: add a `Menu` and synchronized container. Rejected because no inventory slots or continuously authoritative world data are involved; normal item synchronization and explicit mutations are smaller. + +### Route all changes through validated serverbound actions + +Selection, favorite toggling, and rename submission use a loader-neutral serverbound message carrying the requested action and target identity rather than accepting replacement NBT from the client. The handler requires the player's main-hand item to be an Ultimate Configurator and detached, then resolves the referenced target from its current NBT. + +Favorite and rename actions mutate only records already present in the server's recent or favorite lists. Rename additionally requires the target to be a favorite and enforces the 64-character limit. The item is synchronized after successful mutation. + +Selection requires the player to be in the target dimension, the target position to be loaded, and the current block state to resolve to the stored configurator mode. A successful selection uses the same active-mode save path as direct attachment, updates recent history, synchronizes the item, and closes the screen. A rejected selection leaves the item detached and provides feedback. + +Alternative considered: have the client directly edit item NBT or attach from historical data without checking the world. Rejected because either path permits stale or forged targets and bypasses current block compatibility. + +### Record history at the shared attachment path + +The shared active-mode save operation records recent history, covering both direct crouch-click attachment and menu selection once. Detaching clears only active mode and mode-specific transient data; it does not clear recent targets or favorites. + +Alternative considered: update history in each interaction caller. Rejected because it duplicates ordering and trimming behavior and can miss future attachment paths. + +## Risks / Trade-offs + +- [A stored block is removed, changed, unloaded, or in another dimension] -> Keep the record visible and editable, but reject selection until the player is in the dimension and the loaded block matches the stored mode. +- [A client acts on a stale row after item state changes] -> Resolve identity against current server NBT and require a detached main-hand configurator for every action. +- [Mode IDs or malformed records become invalid after upgrades] -> Ignore invalid records when reading and bound both lists whenever writing. +- [Long dimensions or coordinates overflow a row] -> Use clipped or scrolling row presentation while retaining the full value in hover text where needed. + +## Migration Plan + +Existing configurators have no history or favorite tags and therefore open an empty detached menu. The first successful attachment initializes recent history; no data fixer is required. Rolling back leaves unknown item tags that older versions ignore. + +## Open Questions + +None. diff --git a/openspec/changes/add-configurator-target-history/proposal.md b/openspec/changes/add-configurator-target-history/proposal.md new file mode 100644 index 00000000..dbb825f8 --- /dev/null +++ b/openspec/changes/add-configurator-target-history/proposal.md @@ -0,0 +1,30 @@ +## Why + +Detaching an Ultimate Configurator currently discards convenient access to its previous target, forcing players to revisit blocks whenever they want to switch configurations. A detached-item menu with recent and favorite targets makes repeated configuration work faster while keeping each configurator self-contained. + +## What Changes + +- Record all favorites plus the three most recently attached distinct non-favorite blocks on the Ultimate Configurator, ordered by last use. +- Allow up to 16 recorded targets to be favorited and unfavorited from a detached configurator menu. +- Open a native target-selection menu when the player right-clicks air with a detached Ultimate Configurator. +- Reattach immediately when the player selects a valid stored target. +- Display each stored target's configurator type and coordinates, including its dimension, and allow favorites to be renamed. +- Hide a renamed favorite's coordinates behind its custom name and mark it with a golden outline; clearing the name restores the type-and-coordinate label. +- Provide a Settings tab for naming the configurator and choosing favorite text and box render styles, with per-favorite color overrides. +- Render favorites in the world while a detached Ultimate Configurator is held. +- Persist recent targets, favorites, and favorite names in the Ultimate Configurator's NBT. + +## Capabilities + +### New Capabilities + +- `configurator-target-history`: Detached Ultimate Configurator target history, favorites, naming, validation, and selection UI. + +### Modified Capabilities + +None. + +## Impact + +- Affects Ultimate Configurator attachment and air-use behavior, item NBT, client screen opening, loader-neutral networking, localization, and client/server GameTests. +- Adds no external dependencies and does not change configured block data or Lua APIs. diff --git a/openspec/changes/add-configurator-target-history/specs/configurator-target-history/spec.md b/openspec/changes/add-configurator-target-history/specs/configurator-target-history/spec.md new file mode 100644 index 00000000..96ce2070 --- /dev/null +++ b/openspec/changes/add-configurator-target-history/specs/configurator-target-history/spec.md @@ -0,0 +1,127 @@ +## ADDED Requirements + +### Requirement: Target history +The Ultimate Configurator SHALL persist one last-used target order containing every favorite and the three most recently attached distinct non-favorites. Each target SHALL include its configuration type, dimension, and block coordinates, and target identity SHALL be determined by dimension and coordinates. + +#### Scenario: Attach a new target +- **WHEN** a player attaches the configurator to a configurable block not already in its history +- **THEN** the system stores that target first and retains every favorite plus at most the three newest distinct non-favorites + +#### Scenario: Reattach a stored target +- **WHEN** a player attaches the configurator to a dimension and coordinates already in its history +- **THEN** the system updates the stored configuration type, moves that target to the first position, and does not create a duplicate + +#### Scenario: Detach the configurator +- **WHEN** a player detaches an Ultimate Configurator +- **THEN** the system clears its active binding without clearing recent targets or favorites + +### Requirement: Detached target menu +The system SHALL open a non-pausing native target menu when a player right-clicks air with a detached main-hand Ultimate Configurator. The menu SHALL display one list containing all favorites and the three latest non-favorites, ordered by last use. + +#### Scenario: Open detached configurator menu +- **WHEN** a player normally right-clicks air with a detached Ultimate Configurator in the main hand +- **THEN** the system opens the target menu and displays the configurator's stored targets in last-used order + +#### Scenario: Use an attached configurator on air +- **WHEN** a player normally right-clicks air with an attached Ultimate Configurator +- **THEN** the system preserves the active configuration mode's existing air-use behavior instead of opening the target menu + +#### Scenario: Empty history +- **WHEN** the target menu opens for a configurator with no stored targets +- **THEN** the system displays the list as empty without creating records + +### Requirement: Favorite target management +The target menu SHALL show a favorite action for non-favorites and an edit action for favorites. Each Ultimate Configurator SHALL persist no more than 16 distinct favorites while preserving last-use ordering in the combined target list. + +#### Scenario: Favorite a recent target +- **WHEN** a player favorites a recent target and fewer than 16 favorites exist +- **THEN** the system marks it as a favorite without changing its last-used position + +#### Scenario: Favorite an existing favorite +- **WHEN** a duplicate favorite action references a target already in the favorite list +- **THEN** the system retains one favorite record and does not exceed the limit + +#### Scenario: Reach the favorite limit +- **WHEN** a player attempts to add a distinct seventeenth favorite +- **THEN** the server rejects the addition and retains the existing 16 favorites + +#### Scenario: Unfavorite a target +- **WHEN** a player removes a target from favorites +- **THEN** the system removes its favorite record and retains it only if it remains among the three latest non-favorites + +#### Scenario: Select or rename a favorite +- **WHEN** a player selects an existing favorite +- **THEN** the system promotes it to the first last-used position +- **WHEN** a player only renames an existing favorite +- **THEN** the system preserves its last-used position + +### Requirement: Favorite target names +The target menu SHALL allow a player to open a favorite editor and assign a custom name of at most 64 characters. A named favorite SHALL display only its custom name with a golden outline; an unnamed target SHALL display its translated block name, dimension, and coordinates. + +#### Scenario: Rename a favorite +- **WHEN** a player submits a non-empty valid custom name for a favorite +- **THEN** the system persists the name on the configurator and hides that row's type, dimension, and coordinates behind the custom name + +#### Scenario: Clear a favorite name +- **WHEN** a player submits an empty custom name for a named favorite +- **THEN** the system removes the custom name and restores the type, dimension, and coordinate label + +#### Scenario: Reject an oversized name +- **WHEN** a player submits a custom name longer than 64 characters +- **THEN** the server rejects the mutation and preserves the previous name + +#### Scenario: Rename a non-favorite target +- **WHEN** a client requests a rename for a target that is not currently a favorite +- **THEN** the server rejects the mutation + +### Requirement: Configurator appearance settings +The detached menu SHALL provide a Settings tab for assigning a custom Ultimate Configurator name and favorite text and box render styles. A favorite editor SHALL allow each favorite to override text and box colors using the existing color picker. + +#### Scenario: Change configurator settings +- **WHEN** a player submits a valid name or favorite render style from the Settings tab +- **THEN** the server persists it on that Ultimate Configurator and synchronizes the item + +#### Scenario: Override favorite colors +- **WHEN** a player selects a valid text or outline color in a favorite editor +- **THEN** the server persists that color on the favorite and the target list uses it instead of the configurator default + +### Requirement: Detached favorite rendering +While a detached Ultimate Configurator is held in the main hand, the client SHALL render all favorites in the current dimension using the configured text and box styles and each favorite's colors. + +#### Scenario: Render detached favorites +- **WHEN** a player holds a detached Ultimate Configurator with current-dimension favorites +- **THEN** the client renders their configured labels and box effects in the world + +#### Scenario: Hide cross-dimension favorites +- **WHEN** a favorite belongs to another dimension +- **THEN** the client does not render it in the current world + +### Requirement: Validated target selection +Selecting a stored target row SHALL immediately reattach the configurator and close the menu only when the target is in the player's current dimension, its position is loaded, and its current block state supports the stored configuration type. + +#### Scenario: Select a valid target +- **WHEN** a player selects a stored target in the current dimension whose loaded block still supports its stored configuration type +- **THEN** the server attaches the configurator, promotes the target in recent history, synchronizes the item, and closes the menu + +#### Scenario: Select a target in another dimension +- **WHEN** a player selects a stored target outside the current dimension +- **THEN** the system leaves the configurator detached, keeps the record, and reports that the target is unavailable + +#### Scenario: Select an unloaded target +- **WHEN** a player selects a stored target whose position is not loaded +- **THEN** the system does not load the position, leaves the configurator detached, keeps the record, and reports that the target is unavailable + +#### Scenario: Select a changed or removed target +- **WHEN** a player selects a loaded stored target whose block no longer supports the stored configuration type +- **THEN** the system leaves the configurator detached, keeps the record, and reports that the target is unavailable + +### Requirement: Server-authoritative item mutations +The server SHALL accept target selection, favorite, unfavorite, and rename actions only for a detached Ultimate Configurator in the player's main hand and SHALL resolve each requested target against that stack's current stored records. + +#### Scenario: Mutate a stale or forged target +- **WHEN** a client requests an action for a target absent from the server-side configurator history and favorites +- **THEN** the server rejects the action without changing item NBT + +#### Scenario: Mutate a different item state +- **WHEN** a client requests an action while the main-hand item is not a detached Ultimate Configurator +- **THEN** the server rejects the action without changing either item diff --git a/openspec/changes/add-configurator-target-history/tasks.md b/openspec/changes/add-configurator-target-history/tasks.md new file mode 100644 index 00000000..aa9d28d0 --- /dev/null +++ b/openspec/changes/add-configurator-target-history/tasks.md @@ -0,0 +1,27 @@ +## 1. Target Data And Attachment + +- [x] 1.1 Add bounded NBT serialization and parsing for target records, a last-used history containing all favorites plus three non-favorites, a 16-favorite limit, and optional 64-character favorite names. +- [x] 1.2 Record recent targets in the shared active-mode save path while preserving history and favorites during detach. +- [x] 1.3 Add item mutation helpers for favorite toggle, rename/reset, and validated reattachment using current dimension, loaded position, and registered block mode. + +## 2. Networking And Interaction + +- [x] 2.1 Add and register a loader-neutral serverbound target action message that validates a detached main-hand configurator and resolves target identity from authoritative item NBT. +- [x] 2.2 Synchronize successful item mutations and report rejected, unavailable, and favorite-limit actions without loading target chunks. +- [x] 2.3 Open the target-history screen on client-side normal air use only when the Ultimate Configurator is detached, preserving all attached-mode air interactions. + +## 3. Target Menu + +- [x] 3.1 Add the loader-neutral client platform hook and non-pausing screen entry for the held detached configurator. +- [x] 3.2 Implement one last-used target list with multi-row pagination, default block/dimension/coordinate labels, and golden-outlined custom-name-only favorite labels. +- [x] 3.3 Implement immediate row selection, non-favorite Favorite actions, favorite Edit actions, and a favorite edit screen with rename/reset/removal controls, a 64-character client limit, and server-authoritative refresh/close behavior. +- [x] 3.4 Add English and Ukrainian localization source entries for the screen, controls, empty states, and mutation feedback, then regenerate loader resources. +- [x] 3.5 Add a Settings tab for configurator naming and favorite text/box styles, plus per-favorite text and box color pickers. +- [x] 3.6 Render current-dimension favorites in the world while a detached configurator is held. + +## 4. Verification + +- [x] 4.1 Add server GameTests for MRU deduplication/trimming, detach retention, favorite ordering/limit, rename validation, malformed NBT handling, and target-selection validation. +- [x] 4.2 Add client GameTests for detached air-use opening, section rendering and labels, favorite/rename controls, successful selection closure, and attached-mode interaction precedence. +- [x] 4.3 Run the root Fabric and Forge GameTests under Xvfb with an explicit timeout and fix failures. +- [x] 4.4 Run the timed root multi-loader build and fix failures. diff --git a/projects/core/src/main/kotlin/site/siredvin/peripheralworks/client/configurator/ConfigurationModeRenderRegistry.kt b/projects/core/src/main/kotlin/site/siredvin/peripheralworks/client/configurator/ConfigurationModeRenderRegistry.kt index 18ceec11..a399c31b 100755 --- a/projects/core/src/main/kotlin/site/siredvin/peripheralworks/client/configurator/ConfigurationModeRenderRegistry.kt +++ b/projects/core/src/main/kotlin/site/siredvin/peripheralworks/client/configurator/ConfigurationModeRenderRegistry.kt @@ -3,8 +3,12 @@ package site.siredvin.peripheralworks.client.configurator import com.mojang.blaze3d.vertex.PoseStack import net.minecraft.client.Camera import net.minecraft.client.Minecraft +import net.minecraft.network.chat.Component import net.minecraft.resources.ResourceLocation +import net.minecraft.world.phys.AABB +import net.minecraft.world.phys.Vec3 import org.joml.Matrix4f +import site.siredvin.peripheralworks.common.item.UltimateConfigurator import site.siredvin.peripheralworks.common.setup.Items import site.siredvin.peripheralworks.subsystem.configurator.EntityLinkMode import site.siredvin.peripheralworks.subsystem.configurator.NetworkManagerMode @@ -30,7 +34,33 @@ object ConfigurationModeRenderRegistry { fun render(minecraft: Minecraft, poseStack: PoseStack, partialTick: Float, camera: Camera, projectionMatrix: Matrix4f) { val player = minecraft.player ?: return if (player.mainHandItem.`is`(Items.ULTIMATE_CONFIGURATOR.get())) { - val activeModePair = Items.ULTIMATE_CONFIGURATOR.get().getActiveMode(player.mainHandItem) ?: return + val configurator = Items.ULTIMATE_CONFIGURATOR.get() + val activeModePair = configurator.getActiveMode(player.mainHandItem) + if (activeModePair == null) { + val dimension = minecraft.level?.dimension()?.location() ?: return + val favorites = configurator.getFavoriteTargets(player.mainHandItem).filter { it.dimensionID == dimension } + val textStyle = configurator.getFavoriteTextStyle(player.mainHandItem) + val boxStyle = configurator.getFavoriteBoxStyle(player.mainHandItem) + TargetRenderHelper.renderEffects( + poseStack, + camera, + partialTick, + favorites.map { TargetRenderHelper.Effect(AABB(it.pos), Vec3.atCenterOf(it.pos), boxStyle, it.boxColor ?: UltimateConfigurator.DEFAULT_FAVORITE_BOX_COLOR) }, + ) + TargetRenderHelper.renderLabels( + poseStack, + camera, + favorites.map { + TargetRenderHelper.Label( + it.name?.let(Component::literal) ?: Component.translatable("block.${it.modeID.namespace}.${it.modeID.path}"), + Vec3(it.pos.x + 0.5, it.pos.y + 1.2, it.pos.z + 0.5), + textStyle, + it.textColor ?: UltimateConfigurator.DEFAULT_FAVORITE_TEXT_COLOR, + ) + }, + ) + return + } val renderMode = get(activeModePair.first.modeID) ?: return renderMode.render(minecraft, activeModePair.second, poseStack, partialTick, camera, projectionMatrix) } diff --git a/projects/core/src/main/kotlin/site/siredvin/peripheralworks/client/configurator/ConfiguratorFavoriteEditScreen.kt b/projects/core/src/main/kotlin/site/siredvin/peripheralworks/client/configurator/ConfiguratorFavoriteEditScreen.kt new file mode 100644 index 00000000..86c85131 --- /dev/null +++ b/projects/core/src/main/kotlin/site/siredvin/peripheralworks/client/configurator/ConfiguratorFavoriteEditScreen.kt @@ -0,0 +1,87 @@ +package site.siredvin.peripheralworks.client.configurator + +import net.minecraft.client.gui.GuiGraphics +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.EditBox +import net.minecraft.client.gui.screens.Screen +import net.minecraft.network.chat.CommonComponents +import org.lwjgl.glfw.GLFW +import site.siredvin.peripheralworks.common.item.UltimateConfigurator +import site.siredvin.peripheralworks.common.setup.Items +import site.siredvin.peripheralworks.data.ModText +import site.siredvin.peripheralworks.networking.ClientNetworking +import site.siredvin.peripheralworks.networking.ConfiguratorTargetActionMessage +import site.siredvin.peripheralworks.subsystem.configurator.ConfiguratorTarget + +class ConfiguratorFavoriteEditScreen( + private val parent: ConfiguratorTargetHistoryScreen, + private val target: ConfiguratorTarget, +) : Screen(ModText.CONFIGURATOR_HISTORY_EDIT_TITLE.text) { + private lateinit var name: EditBox + + override fun isPauseScreen(): Boolean = false + + override fun init() { + val left = width / 2 - 120 + val top = height / 2 - 72 + name = addRenderableWidget( + EditBox(font, left, top, 240, 20, ModText.CONFIGURATOR_HISTORY_RENAME.text).apply { + setHint(ModText.CONFIGURATOR_HISTORY_RENAME.text) + setMaxLength(ConfiguratorTarget.MAX_NAME_LENGTH) + setValue(target.name.orEmpty()) + }, + ) + addRenderableWidget(Button.builder(ModText.CONFIGURATOR_HISTORY_APPLY.text) { rename() }.bounds(left, top + 28, 116, 20).build()) + addRenderableWidget(Button.builder(CommonComponents.GUI_CANCEL) { onClose() }.bounds(left + 124, top + 28, 116, 20).build()) + addRenderableWidget(Button.builder(ModText.CONFIGURATOR_HISTORY_UNFAVORITE.text) { removeFavorite() }.bounds(left, top + 56, 240, 20).build()) + addRenderableWidget( + Button.builder(ModText.CONFIGURATOR_SETTINGS_TEXT_COLOR.format("#%06X".format(target.textColor ?: UltimateConfigurator.DEFAULT_FAVORITE_TEXT_COLOR))) { + minecraft?.setScreen(NetworkManagerColorPickerScreen(this, target.textColor ?: UltimateConfigurator.DEFAULT_FAVORITE_TEXT_COLOR) { send(ConfiguratorTargetActionMessage.Action.FAVORITE_TEXT_COLOR, color = it) }) + }.bounds(left, top + 84, 240, 20).build(), + ) + addRenderableWidget( + Button.builder(ModText.CONFIGURATOR_SETTINGS_BOX_COLOR.format("#%06X".format(target.boxColor ?: UltimateConfigurator.DEFAULT_FAVORITE_BOX_COLOR))) { + minecraft?.setScreen(NetworkManagerColorPickerScreen(this, target.boxColor ?: UltimateConfigurator.DEFAULT_FAVORITE_BOX_COLOR) { send(ConfiguratorTargetActionMessage.Action.FAVORITE_BOX_COLOR, color = it) }) + }.bounds(left, top + 108, 240, 20).build(), + ) + setInitialFocus(name) + } + + private fun rename() { + send(ConfiguratorTargetActionMessage.Action.RENAME, name.value) + onClose() + } + + private fun removeFavorite() { + send(ConfiguratorTargetActionMessage.Action.TOGGLE_FAVORITE) + onClose() + } + + private fun send(action: ConfiguratorTargetActionMessage.Action, name: String = "", color: Int = -1) { + ClientNetworking.sendToServer(ConfiguratorTargetActionMessage(action, target.dimensionID, target.pos, name, color)) + } + + override fun tick() { + val stack = minecraft?.player?.mainHandItem ?: return minecraft?.setScreen(null) ?: Unit + val configurator = stack.item as? UltimateConfigurator + if (!stack.`is`(Items.ULTIMATE_CONFIGURATOR.get()) || configurator?.getActiveMode(stack) != null) minecraft?.setScreen(null) + } + + override fun keyPressed(keyCode: Int, scanCode: Int, modifiers: Int): Boolean { + if (keyCode == GLFW.GLFW_KEY_ENTER || keyCode == GLFW.GLFW_KEY_KP_ENTER) { + rename() + return true + } + return super.keyPressed(keyCode, scanCode, modifiers) + } + + override fun onClose() { + minecraft?.setScreen(parent) + } + + override fun render(graphics: GuiGraphics, mouseX: Int, mouseY: Int, partialTick: Float) { + renderBackground(graphics) + super.render(graphics, mouseX, mouseY, partialTick) + graphics.drawCenteredString(font, title, width / 2, height / 2 - 96, 0xffffff) + } +} diff --git a/projects/core/src/main/kotlin/site/siredvin/peripheralworks/client/configurator/ConfiguratorTargetHistoryScreen.kt b/projects/core/src/main/kotlin/site/siredvin/peripheralworks/client/configurator/ConfiguratorTargetHistoryScreen.kt new file mode 100644 index 00000000..2790bb4e --- /dev/null +++ b/projects/core/src/main/kotlin/site/siredvin/peripheralworks/client/configurator/ConfiguratorTargetHistoryScreen.kt @@ -0,0 +1,194 @@ +package site.siredvin.peripheralworks.client.configurator + +import net.minecraft.client.gui.Font +import net.minecraft.client.gui.GuiGraphics +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.EditBox +import net.minecraft.client.gui.components.Tooltip +import net.minecraft.client.gui.screens.Screen +import net.minecraft.network.chat.Component +import site.siredvin.peripheralworks.common.item.UltimateConfigurator +import site.siredvin.peripheralworks.common.setup.Items +import site.siredvin.peripheralworks.data.ModText +import site.siredvin.peripheralworks.networking.ClientNetworking +import site.siredvin.peripheralworks.networking.ConfiguratorTargetActionMessage +import site.siredvin.peripheralworks.subsystem.configurator.BoxStyle +import site.siredvin.peripheralworks.subsystem.configurator.ConfiguratorTarget +import site.siredvin.peripheralworks.subsystem.configurator.TextStyle + +class ConfiguratorTargetHistoryScreen : Screen(ModText.CONFIGURATOR_HISTORY_TITLE.text) { + private var tab = Tab.TARGETS + private var page = 0 + private var snapshot = "" + private var empty = false + + override fun isPauseScreen(): Boolean = false + + override fun init() { + val configurator = configurator() ?: return onClose() + val stack = minecraft?.player?.mainHandItem ?: return onClose() + val targets = configurator.getTargetHistory(stack) + val favorites = configurator.getFavoriteTargets(stack) + val panelWidth = (width - 24).coerceAtMost(420) + val left = (width - panelWidth) / 2 + addRenderableWidget( + Button.builder(ModText.CONFIGURATOR_HISTORY_TARGETS.text) { + tab = Tab.TARGETS + rebuild() + }.bounds(left, 26, panelWidth / 2 - 2, 20).build(), + ).active = tab != Tab.TARGETS + addRenderableWidget( + Button.builder(ModText.CONFIGURATOR_SETTINGS_TITLE.text) { + tab = Tab.SETTINGS + rebuild() + }.bounds(left + panelWidth / 2 + 2, 26, panelWidth / 2 - 2, 20).build(), + ).active = tab != Tab.SETTINGS + if (tab == Tab.SETTINGS) { + addSettings(configurator, stack, left, panelWidth) + snapshot = snapshot() + return + } + + empty = targets.isEmpty() + val targetsPerPage = ((height - TARGETS_TOP - 24) / 22).coerceIn(3, UltimateConfigurator.MAX_TARGET_HISTORY) + val pageCount = ((targets.size + targetsPerPage - 1) / targetsPerPage).coerceAtLeast(1) + page = page.coerceIn(0, pageCount - 1) + targets.drop(page * targetsPerPage).take(targetsPerPage).forEachIndexed { index, target -> + addRow(target, favorites.any(target::matches), left, TARGETS_TOP + index * 22, panelWidth) + } + if (pageCount > 1) { + val paginationY = TARGETS_TOP + targetsPerPage * 22 + addRenderableWidget( + Button.builder(Component.literal("<")) { + page-- + rebuild() + }.bounds(left, paginationY, 24, 20).build(), + ).active = page > 0 + addRenderableWidget(Button.builder(Component.literal("${page + 1}/$pageCount")) {}.bounds(left + 28, paginationY, panelWidth - 56, 20).build()).active = false + addRenderableWidget( + Button.builder(Component.literal(">")) { + page++ + rebuild() + }.bounds(left + panelWidth - 24, paginationY, 24, 20).build(), + ).active = page + 1 < pageCount + } + snapshot = snapshot() + } + + private fun addSettings(configurator: UltimateConfigurator, stack: net.minecraft.world.item.ItemStack, left: Int, panelWidth: Int) { + val name = addRenderableWidget( + EditBox(font, left, 62, panelWidth, 20, ModText.CONFIGURATOR_SETTINGS_NAME.text).apply { + setHint(ModText.CONFIGURATOR_SETTINGS_NAME.text) + setMaxLength(ConfiguratorTarget.MAX_NAME_LENGTH) + setValue(stack.takeIf { it.hasCustomHoverName() }?.hoverName?.string.orEmpty()) + }, + ) + addRenderableWidget(Button.builder(ModText.CONFIGURATOR_HISTORY_APPLY.text) { sendSetting(ConfiguratorTargetActionMessage.Action.CONFIGURATOR_NAME, name.value) }.bounds(left, 88, panelWidth, 20).build()) + addRenderableWidget( + RenderStyleButton(left, 116, panelWidth, textStyleText(configurator.getFavoriteTextStyle(stack))) { direction -> + val styles = TextStyle.entries + sendSetting(ConfiguratorTargetActionMessage.Action.DEFAULT_TEXT_STYLE, styles[Math.floorMod(configurator.getFavoriteTextStyle(stack).ordinal + direction, styles.size)].name) + }, + ) + addRenderableWidget( + RenderStyleButton(left, 142, panelWidth, boxStyleText(configurator.getFavoriteBoxStyle(stack))) { direction -> + val styles = BoxStyle.entries + sendSetting(ConfiguratorTargetActionMessage.Action.DEFAULT_BOX_STYLE, styles[Math.floorMod(configurator.getFavoriteBoxStyle(stack).ordinal + direction, styles.size)].name) + }, + ) + } + + private fun addRow(target: ConfiguratorTarget, favorite: Boolean, left: Int, top: Int, panelWidth: Int) { + if (favorite) { + val stack = minecraft?.player?.mainHandItem ?: return + val configurator = stack.item as UltimateConfigurator + addTargetButton(target, left, top, panelWidth - 64, target.textColor ?: UltimateConfigurator.DEFAULT_FAVORITE_TEXT_COLOR, target.boxColor ?: UltimateConfigurator.DEFAULT_FAVORITE_BOX_COLOR) + addRenderableWidget(Button.builder(ModText.CONFIGURATOR_HISTORY_EDIT.text) { minecraft?.setScreen(ConfiguratorFavoriteEditScreen(this, target)) }.bounds(left + panelWidth - 60, top, 60, 20).build()) + } else { + addTargetButton(target, left, top, panelWidth - 92, 0xffffff, null) + addRenderableWidget(Button.builder(ModText.CONFIGURATOR_HISTORY_FAVORITE.text) { toggle(target) }.bounds(left + panelWidth - 88, top, 88, 20).build()) + } + } + + private fun addTargetButton(target: ConfiguratorTarget, left: Int, top: Int, width: Int, textColor: Int, boxColor: Int?) { + val label = targetLabel(target) + addRenderableWidget(LeftAlignedButton(left, top, width, label, textColor, boxColor) { send(ConfiguratorTargetActionMessage.Action.SELECT, target) }).tooltip = Tooltip.create(defaultTargetLabel(target)) + } + + private fun targetLabel(target: ConfiguratorTarget): Component = target.name?.let(Component::literal) ?: defaultTargetLabel(target) + + private fun defaultTargetLabel(target: ConfiguratorTarget): Component = Component.translatable("block.${target.modeID.namespace}.${target.modeID.path}") + .append(" | ${target.dimensionID} | ${target.pos.x}, ${target.pos.y}, ${target.pos.z}") + + private fun toggle(target: ConfiguratorTarget) = send(ConfiguratorTargetActionMessage.Action.TOGGLE_FAVORITE, target) + + private fun send(action: ConfiguratorTargetActionMessage.Action, target: ConfiguratorTarget, name: String = "") { + ClientNetworking.sendToServer(ConfiguratorTargetActionMessage(action, target.dimensionID, target.pos, name)) + } + + private fun sendSetting(action: ConfiguratorTargetActionMessage.Action, name: String = "", color: Int = -1) { + ClientNetworking.sendToServer(ConfiguratorTargetActionMessage(action, net.minecraft.resources.ResourceLocation.tryParse("minecraft:overworld")!!, net.minecraft.core.BlockPos.ZERO, name, color)) + } + + private fun textStyleText(style: TextStyle): Component = ModText.NETWORK_MANAGER_TEXT_STYLE.format( + when (style) { + TextStyle.NONE -> ModText.NETWORK_MANAGER_STYLE_NONE.text + TextStyle.REGULAR -> ModText.NETWORK_MANAGER_TEXT_REGULAR.text + TextStyle.BOLD -> ModText.NETWORK_MANAGER_TEXT_BOLD.text + }, + ) + + private fun boxStyleText(style: BoxStyle): Component = ModText.NETWORK_MANAGER_BOX_STYLE.format( + when (style) { + BoxStyle.NONE -> ModText.NETWORK_MANAGER_STYLE_NONE.text + BoxStyle.OUTLINE -> ModText.NETWORK_MANAGER_BOX_OUTLINE.text + BoxStyle.FILLED -> ModText.NETWORK_MANAGER_BOX_FILLED.text + BoxStyle.FLARE -> ModText.NETWORK_MANAGER_BOX_FLARE.text + }, + ) + + private fun configurator(): UltimateConfigurator? { + val stack = minecraft?.player?.mainHandItem ?: return null + return (stack.item as? UltimateConfigurator)?.takeIf { stack.`is`(Items.ULTIMATE_CONFIGURATOR.get()) && it.getActiveMode(stack) == null } + } + + private fun snapshot(): String = minecraft?.player?.mainHandItem?.tag?.toString().orEmpty() + + private fun rebuild() { + clearWidgets() + init() + } + + override fun tick() { + if (configurator() == null) return onClose() + if (snapshot() != snapshot) rebuild() + } + + override fun render(graphics: GuiGraphics, mouseX: Int, mouseY: Int, partialTick: Float) { + renderBackground(graphics) + super.render(graphics, mouseX, mouseY, partialTick) + val panelWidth = (width - 24).coerceAtMost(420) + val left = (width - panelWidth) / 2 + graphics.drawCenteredString(font, title, width / 2, 12, 0xffffff) + if (tab == Tab.TARGETS && empty) graphics.drawString(font, ModText.CONFIGURATOR_HISTORY_EMPTY.text, left, 56, 0xaaaaaa) + } + + companion object { + private const val TARGETS_TOP = 52 + } + + private enum class Tab { TARGETS, SETTINGS } + + private class LeftAlignedButton(x: Int, y: Int, width: Int, message: Component, private val textColor: Int, private val boxColor: Int?, onPress: () -> Unit) : Button(x, y, width, 20, message, { onPress() }, DEFAULT_NARRATION) { + override fun renderWidget(graphics: GuiGraphics, mouseX: Int, mouseY: Int, partialTick: Float) { + super.renderWidget(graphics, mouseX, mouseY, partialTick) + boxColor?.let { graphics.renderOutline(x, y, width, height, 0xff000000.toInt() or it) } + } + + override fun renderString(graphics: GuiGraphics, font: Font, color: Int) { + graphics.enableScissor(x + 4, y, x + width - 4, y + height) + graphics.drawString(font, message, x + 4, y + (height - 8) / 2, textColor) + graphics.disableScissor() + } + } +} diff --git a/projects/core/src/main/kotlin/site/siredvin/peripheralworks/client/configurator/ConfiguratorTargetHistoryScreenEntry.kt b/projects/core/src/main/kotlin/site/siredvin/peripheralworks/client/configurator/ConfiguratorTargetHistoryScreenEntry.kt new file mode 100644 index 00000000..75fe0c5a --- /dev/null +++ b/projects/core/src/main/kotlin/site/siredvin/peripheralworks/client/configurator/ConfiguratorTargetHistoryScreenEntry.kt @@ -0,0 +1,16 @@ +package site.siredvin.peripheralworks.client.configurator + +import net.minecraft.client.Minecraft +import site.siredvin.peripheralworks.common.item.UltimateConfigurator +import site.siredvin.peripheralworks.common.setup.Items + +object ConfiguratorTargetHistoryScreenEntry { + fun open() { + val minecraft = Minecraft.getInstance() + val stack = minecraft.player?.mainHandItem ?: return + val configurator = stack.item as? UltimateConfigurator ?: return + if (stack.`is`(Items.ULTIMATE_CONFIGURATOR.get()) && configurator.getActiveMode(stack) == null) { + minecraft.setScreen(ConfiguratorTargetHistoryScreen()) + } + } +} diff --git a/projects/core/src/main/kotlin/site/siredvin/peripheralworks/common/item/UltimateConfigurator.kt b/projects/core/src/main/kotlin/site/siredvin/peripheralworks/common/item/UltimateConfigurator.kt index f465e6b8..b199276d 100644 --- a/projects/core/src/main/kotlin/site/siredvin/peripheralworks/common/item/UltimateConfigurator.kt +++ b/projects/core/src/main/kotlin/site/siredvin/peripheralworks/common/item/UltimateConfigurator.kt @@ -1,7 +1,9 @@ package site.siredvin.peripheralworks.common.item import net.minecraft.core.BlockPos +import net.minecraft.nbt.ListTag import net.minecraft.nbt.NbtUtils +import net.minecraft.nbt.Tag import net.minecraft.network.chat.Component import net.minecraft.resources.ResourceLocation import net.minecraft.world.InteractionHand @@ -16,8 +18,12 @@ import net.minecraft.world.phys.BlockHitResult import net.minecraft.world.phys.HitResult import site.siredvin.broccolium.modules.base.item.DescriptiveItem import site.siredvin.peripheralworks.data.ModTooltip +import site.siredvin.peripheralworks.subsystem.configurator.BoxStyle import site.siredvin.peripheralworks.subsystem.configurator.ConfigurationMode import site.siredvin.peripheralworks.subsystem.configurator.ConfiguratorModeRegistry +import site.siredvin.peripheralworks.subsystem.configurator.ConfiguratorTarget +import site.siredvin.peripheralworks.subsystem.configurator.TextStyle +import site.siredvin.peripheralworks.xplat.ModClientPlatform class UltimateConfigurator : DescriptiveItem(Properties().stacksTo(1)) { @@ -25,6 +31,15 @@ class UltimateConfigurator : DescriptiveItem(Properties().stacksTo(1)) { const val ACTIVE_MOD_NAME = "activeMod" const val ACTIVE_MOD_POS = "activeModPos" const val ACTIVE_MOD_DIMENSION = "activeModDimension" + const val RECENT_TARGETS = "recentTargets" + const val FAVORITE_TARGETS = "favoriteTargets" + const val MAX_RECENT_TARGETS = 3 + const val MAX_FAVORITE_TARGETS = 16 + const val MAX_TARGET_HISTORY = MAX_RECENT_TARGETS + MAX_FAVORITE_TARGETS + const val FAVORITE_TEXT_STYLE = "favoriteTextStyle" + const val FAVORITE_BOX_STYLE = "favoriteBoxStyle" + const val DEFAULT_FAVORITE_TEXT_COLOR = 0xffffff + const val DEFAULT_FAVORITE_BOX_COLOR = 0xffaa00 fun isActiveModeDimension(stack: ItemStack, level: Level): Boolean = stack.tag?.getString(ACTIVE_MOD_DIMENSION) == level.dimension().location().toString() } @@ -52,23 +67,73 @@ class UltimateConfigurator : DescriptiveItem(Properties().stacksTo(1)) { if (!data.contains(ACTIVE_MOD_POS)) { return null } - @Suppress("DEPRECATION", "KotlinRedundantDiagnosticSuppress") - val configurationMode = ConfiguratorModeRegistry.get(ResourceLocation(data.getString(ACTIVE_MOD_NAME))) ?: return null + val modeID = ResourceLocation.tryParse(data.getString(ACTIVE_MOD_NAME)) ?: return null + val configurationMode = ConfiguratorModeRegistry.get(modeID) ?: return null return Pair( configurationMode, NbtUtils.readBlockPos(data.getCompound(ACTIVE_MOD_POS)), ) } - private fun saveActiveMode(stack: ItemStack, mode: ConfigurationMode, targetBlock: BlockPos, level: Level) { + fun getFavoriteTargets(stack: ItemStack): List = readTargets(stack, FAVORITE_TARGETS, MAX_FAVORITE_TARGETS) + + private fun getStoredHistory(stack: ItemStack): List = readTargets(stack, RECENT_TARGETS, MAX_TARGET_HISTORY) + + fun getTargetHistory(stack: ItemStack): List { + val favorites = getFavoriteTargets(stack) + val history = getStoredHistory(stack) + var nonFavorites = 0 + return (history + favorites.filter { favorite -> history.none(favorite::matches) }).mapNotNull { target -> + favorites.firstOrNull(target::matches) ?: target.takeIf { nonFavorites++ < MAX_RECENT_TARGETS } + } + } + + fun getRecentTargets(stack: ItemStack): List { + val favorites = getFavoriteTargets(stack) + return getStoredHistory(stack).filter { target -> favorites.none(target::matches) }.take(MAX_RECENT_TARGETS) + } + + private fun readTargets(stack: ItemStack, key: String, limit: Int): List { + val list = stack.tag?.getList(key, Tag.TAG_COMPOUND.toInt()) ?: return emptyList() + return buildList { + for (index in 0 until list.size) { + val target = ConfiguratorTarget.fromNBT(list.getCompound(index)) ?: continue + if (none(target::matches)) add(target) + if (size == limit) break + } + } + } + + private fun writeTargets(stack: ItemStack, key: String, targets: List, limit: Int) { + val list = ListTag() + targets.distinctBy { it.dimensionID to it.pos }.take(limit).forEach { list.add(it.toNBT()) } + stack.orCreateTag.put(key, list) + } + + private fun writeHistory(stack: ItemStack, targets: List, favorites: List) { + var nonFavorites = 0 + val complete = targets + favorites.filter { favorite -> targets.none(favorite::matches) } + writeTargets( + stack, + RECENT_TARGETS, + complete.filter { target -> favorites.any(target::matches) || nonFavorites++ < MAX_RECENT_TARGETS }, + MAX_TARGET_HISTORY, + ) + } + + fun saveActiveMode(stack: ItemStack, mode: ConfigurationMode, targetBlock: BlockPos, level: Level) { getActiveMode(stack)?.first?.clearData(stack) val data = stack.orCreateTag data.putString(ACTIVE_MOD_NAME, mode.modeID.toString()) data.put(ACTIVE_MOD_POS, NbtUtils.writeBlockPos(targetBlock)) data.putString(ACTIVE_MOD_DIMENSION, level.dimension().location().toString()) + val target = ConfiguratorTarget(mode.modeID, level.dimension().location(), targetBlock) + val favorites = getFavoriteTargets(stack).map { if (it.matches(target)) it.copy(modeID = target.modeID) else it } + writeTargets(stack, FAVORITE_TARGETS, favorites, MAX_FAVORITE_TARGETS) + writeHistory(stack, listOf(target) + getStoredHistory(stack).filterNot(target::matches), favorites) } - private fun clearActiveMode(stack: ItemStack): ItemStack { + fun clearActiveMode(stack: ItemStack): ItemStack { val data = stack.tag ?: return stack getActiveMode(stack)?.first?.clearData(stack) data.remove(ACTIVE_MOD_NAME) @@ -77,6 +142,64 @@ class UltimateConfigurator : DescriptiveItem(Properties().stacksTo(1)) { return stack } + fun toggleFavorite(stack: ItemStack, target: ConfiguratorTarget): FavoriteResult { + val stored = getTargetHistory(stack).firstOrNull(target::matches) ?: return FavoriteResult.REJECTED + val favorites = getFavoriteTargets(stack) + if (favorites.any(target::matches)) { + val updated = favorites.filterNot(target::matches) + writeTargets(stack, FAVORITE_TARGETS, updated, MAX_FAVORITE_TARGETS) + writeHistory(stack, getStoredHistory(stack), updated) + return FavoriteResult.REMOVED + } + if (favorites.size >= MAX_FAVORITE_TARGETS) return FavoriteResult.LIMIT + val updated = listOf(stored.copy(name = null)) + favorites + writeTargets(stack, FAVORITE_TARGETS, updated, MAX_FAVORITE_TARGETS) + writeHistory(stack, getStoredHistory(stack), updated) + return FavoriteResult.ADDED + } + + fun renameFavorite(stack: ItemStack, target: ConfiguratorTarget, name: String): Boolean { + if (name.length > ConfiguratorTarget.MAX_NAME_LENGTH) return false + val favorites = getFavoriteTargets(stack) + if (favorites.none(target::matches)) return false + writeTargets(stack, FAVORITE_TARGETS, favorites.map { if (it.matches(target)) it.copy(name = name.ifEmpty { null }) else it }, MAX_FAVORITE_TARGETS) + return true + } + + fun setFavoriteColor(stack: ItemStack, target: ConfiguratorTarget, color: Int, text: Boolean): Boolean { + if (color !in 0..0xffffff) return false + val favorites = getFavoriteTargets(stack) + if (favorites.none(target::matches)) return false + writeTargets(stack, FAVORITE_TARGETS, favorites.map { if (it.matches(target)) if (text) it.copy(textColor = color) else it.copy(boxColor = color) else it }, MAX_FAVORITE_TARGETS) + return true + } + + fun getFavoriteTextStyle(stack: ItemStack): TextStyle = stack.tag?.getString(FAVORITE_TEXT_STYLE)?.let { runCatching { TextStyle.valueOf(it) }.getOrNull() } ?: TextStyle.REGULAR + + fun getFavoriteBoxStyle(stack: ItemStack): BoxStyle = stack.tag?.getString(FAVORITE_BOX_STYLE)?.let { runCatching { BoxStyle.valueOf(it) }.getOrNull() } ?: BoxStyle.OUTLINE + + fun setSettings(stack: ItemStack, name: String?, textStyle: TextStyle? = null, boxStyle: BoxStyle? = null): Boolean { + if (name != null) { + if (name.length > ConfiguratorTarget.MAX_NAME_LENGTH) return false + if (name.isEmpty()) stack.resetHoverName() else stack.hoverName = Component.literal(name) + } + textStyle?.let { stack.orCreateTag.putString(FAVORITE_TEXT_STYLE, it.name) } + boxStyle?.let { stack.orCreateTag.putString(FAVORITE_BOX_STYLE, it.name) } + return true + } + + fun selectTarget(stack: ItemStack, level: Level, target: ConfiguratorTarget): SelectionResult { + val stored = getTargetHistory(stack).firstOrNull(target::matches) ?: return SelectionResult.REJECTED + if (stored.dimensionID != level.dimension().location() || !level.isLoaded(stored.pos)) return SelectionResult.UNAVAILABLE + val mode = ConfiguratorModeRegistry.get(level.getBlockState(stored.pos)) + if (mode?.modeID != stored.modeID) return SelectionResult.UNAVAILABLE + saveActiveMode(stack, mode, stored.pos, level) + return SelectionResult.SUCCESS + } + + enum class FavoriteResult { ADDED, REMOVED, LIMIT, REJECTED } + enum class SelectionResult { SUCCESS, UNAVAILABLE, REJECTED } + private fun tryActivateMode(stack: ItemStack, player: Player, hit: BlockHitResult, level: Level): InteractionResultHolder { if (player.pose == Pose.CROUCHING) { val targetState = level.getBlockState(hit.blockPos) @@ -103,7 +226,11 @@ class UltimateConfigurator : DescriptiveItem(Properties().stacksTo(1)) { if (player.pose == Pose.CROUCHING) { InteractionResultHolder.consume(clearActiveMode(itemStack)) } else { - val activeModePair = getActiveMode(itemStack) ?: return InteractionResultHolder.pass(itemStack) + val activeModePair = getActiveMode(itemStack) + if (activeModePair == null) { + if (level.isClientSide) ModClientPlatform.openConfiguratorTargetHistoryScreen() + return InteractionResultHolder.consume(itemStack) + } return activeModePair.first.onBlockMiss(activeModePair.second, itemStack, player, level) } } else { diff --git a/projects/core/src/main/kotlin/site/siredvin/peripheralworks/data/ModEnLanguageProvider.kt b/projects/core/src/main/kotlin/site/siredvin/peripheralworks/data/ModEnLanguageProvider.kt index c30e218d..6b50d2ae 100644 --- a/projects/core/src/main/kotlin/site/siredvin/peripheralworks/data/ModEnLanguageProvider.kt +++ b/projects/core/src/main/kotlin/site/siredvin/peripheralworks/data/ModEnLanguageProvider.kt @@ -121,6 +121,22 @@ class ModEnLanguageProvider( add(ModText.TARGET_RENDER_SETTINGS_TITLE, "Target Render Settings") add(ModText.TARGET_RENDER_SETTINGS_UNAVAILABLE, "The bound target is unavailable") add(ModText.TARGET_RENDER_SETTINGS_REQUEST_REJECTED, "Target render settings request rejected") + add(ModText.CONFIGURATOR_HISTORY_TITLE, "Configurator Targets") + add(ModText.CONFIGURATOR_HISTORY_TARGETS, "Targets") + add(ModText.CONFIGURATOR_HISTORY_EMPTY, "No stored targets") + add(ModText.CONFIGURATOR_HISTORY_FAVORITE, "Favorite") + add(ModText.CONFIGURATOR_HISTORY_UNFAVORITE, "Remove favorite") + add(ModText.CONFIGURATOR_HISTORY_EDIT, "Edit") + add(ModText.CONFIGURATOR_HISTORY_EDIT_TITLE, "Edit Favorite") + add(ModText.CONFIGURATOR_HISTORY_RENAME, "Favorite name") + add(ModText.CONFIGURATOR_HISTORY_APPLY, "Apply") + add(ModText.CONFIGURATOR_HISTORY_REQUEST_REJECTED, "Configurator target request rejected") + add(ModText.CONFIGURATOR_HISTORY_TARGET_UNAVAILABLE, "That configurator target is unavailable") + add(ModText.CONFIGURATOR_HISTORY_FAVORITE_LIMIT, "A configurator can have at most 16 favorites") + add(ModText.CONFIGURATOR_SETTINGS_TITLE, "Settings") + add(ModText.CONFIGURATOR_SETTINGS_NAME, "Configurator name") + add(ModText.CONFIGURATOR_SETTINGS_TEXT_COLOR, "Favorite text color: %s") + add(ModText.CONFIGURATOR_SETTINGS_BOX_COLOR, "Favorite outline color: %s") add(ModText.TECH_REBORN_ENERGY, "Tech reborn energy") diff --git a/projects/core/src/main/kotlin/site/siredvin/peripheralworks/data/ModText.kt b/projects/core/src/main/kotlin/site/siredvin/peripheralworks/data/ModText.kt index 15fa32e8..83935ebc 100644 --- a/projects/core/src/main/kotlin/site/siredvin/peripheralworks/data/ModText.kt +++ b/projects/core/src/main/kotlin/site/siredvin/peripheralworks/data/ModText.kt @@ -78,6 +78,22 @@ enum class ModText : TextRecord { TARGET_RENDER_SETTINGS_TITLE, TARGET_RENDER_SETTINGS_UNAVAILABLE, TARGET_RENDER_SETTINGS_REQUEST_REJECTED, + CONFIGURATOR_HISTORY_TITLE, + CONFIGURATOR_HISTORY_TARGETS, + CONFIGURATOR_HISTORY_EMPTY, + CONFIGURATOR_HISTORY_FAVORITE, + CONFIGURATOR_HISTORY_UNFAVORITE, + CONFIGURATOR_HISTORY_EDIT, + CONFIGURATOR_HISTORY_EDIT_TITLE, + CONFIGURATOR_HISTORY_RENAME, + CONFIGURATOR_HISTORY_APPLY, + CONFIGURATOR_HISTORY_REQUEST_REJECTED, + CONFIGURATOR_HISTORY_TARGET_UNAVAILABLE, + CONFIGURATOR_HISTORY_FAVORITE_LIMIT, + CONFIGURATOR_SETTINGS_TITLE, + CONFIGURATOR_SETTINGS_NAME, + CONFIGURATOR_SETTINGS_TEXT_COLOR, + CONFIGURATOR_SETTINGS_BOX_COLOR, ; override val textID: String by lazy { diff --git a/projects/core/src/main/kotlin/site/siredvin/peripheralworks/data/ModUaLanguageProvider.kt b/projects/core/src/main/kotlin/site/siredvin/peripheralworks/data/ModUaLanguageProvider.kt index c063489f..e91c9d7c 100644 --- a/projects/core/src/main/kotlin/site/siredvin/peripheralworks/data/ModUaLanguageProvider.kt +++ b/projects/core/src/main/kotlin/site/siredvin/peripheralworks/data/ModUaLanguageProvider.kt @@ -121,6 +121,22 @@ class ModUaLanguageProvider( add(ModText.TARGET_RENDER_SETTINGS_TITLE, "Налаштування відображення цілей") add(ModText.TARGET_RENDER_SETTINGS_UNAVAILABLE, "Прив'язана ціль недоступна") add(ModText.TARGET_RENDER_SETTINGS_REQUEST_REJECTED, "Запит налаштувань відображення цілей відхилено") + add(ModText.CONFIGURATOR_HISTORY_TITLE, "Цілі конфігуратора") + add(ModText.CONFIGURATOR_HISTORY_TARGETS, "Цілі") + add(ModText.CONFIGURATOR_HISTORY_EMPTY, "Немає збережених цілей") + add(ModText.CONFIGURATOR_HISTORY_FAVORITE, "Обрати") + add(ModText.CONFIGURATOR_HISTORY_UNFAVORITE, "Прибрати з обраних") + add(ModText.CONFIGURATOR_HISTORY_EDIT, "Змінити") + add(ModText.CONFIGURATOR_HISTORY_EDIT_TITLE, "Змінити обрану ціль") + add(ModText.CONFIGURATOR_HISTORY_RENAME, "Назва обраної цілі") + add(ModText.CONFIGURATOR_HISTORY_APPLY, "Застосувати") + add(ModText.CONFIGURATOR_HISTORY_REQUEST_REJECTED, "Запит цілі конфігуратора відхилено") + add(ModText.CONFIGURATOR_HISTORY_TARGET_UNAVAILABLE, "Ця ціль конфігуратора недоступна") + add(ModText.CONFIGURATOR_HISTORY_FAVORITE_LIMIT, "Конфігуратор може мати щонайбільше 16 обраних цілей") + add(ModText.CONFIGURATOR_SETTINGS_TITLE, "Налаштування") + add(ModText.CONFIGURATOR_SETTINGS_NAME, "Назва конфігуратора") + add(ModText.CONFIGURATOR_SETTINGS_TEXT_COLOR, "Колір тексту обраних цілей: %s") + add(ModText.CONFIGURATOR_SETTINGS_BOX_COLOR, "Колір контуру обраних цілей: %s") add(ModText.TECH_REBORN_ENERGY, "Енергія з Tech reborn") diff --git a/projects/core/src/main/kotlin/site/siredvin/peripheralworks/networking/ConfiguratorTargetActionMessage.kt b/projects/core/src/main/kotlin/site/siredvin/peripheralworks/networking/ConfiguratorTargetActionMessage.kt new file mode 100644 index 00000000..27908805 --- /dev/null +++ b/projects/core/src/main/kotlin/site/siredvin/peripheralworks/networking/ConfiguratorTargetActionMessage.kt @@ -0,0 +1,75 @@ +package site.siredvin.peripheralworks.networking + +import net.minecraft.core.BlockPos +import net.minecraft.network.FriendlyByteBuf +import net.minecraft.resources.ResourceLocation +import site.siredvin.peripheralworks.common.item.UltimateConfigurator +import site.siredvin.peripheralworks.common.setup.Items +import site.siredvin.peripheralworks.data.ModText +import site.siredvin.peripheralworks.subsystem.configurator.BoxStyle +import site.siredvin.peripheralworks.subsystem.configurator.ConfiguratorTarget +import site.siredvin.peripheralworks.subsystem.configurator.TextStyle + +class ConfiguratorTargetActionMessage( + private val action: Action, + private val dimension: ResourceLocation, + private val pos: BlockPos, + private val name: String = "", + private val color: Int = -1, +) : NetworkMessage { + enum class Action { SELECT, TOGGLE_FAVORITE, RENAME, FAVORITE_TEXT_COLOR, FAVORITE_BOX_COLOR, CONFIGURATOR_NAME, DEFAULT_TEXT_STYLE, DEFAULT_BOX_STYLE } + + constructor(buf: FriendlyByteBuf) : this( + buf.readEnum(Action::class.java), + buf.readResourceLocation(), + buf.readBlockPos(), + buf.readUtf(MAX_WIRE_NAME_LENGTH), + buf.readInt(), + ) + + override fun type(): MessageType<*> = NetworkMessages.CONFIGURATOR_TARGET_ACTION + + override fun write(buf: FriendlyByteBuf) { + buf.writeEnum(action) + buf.writeResourceLocation(dimension) + buf.writeBlockPos(pos) + buf.writeUtf(name, MAX_WIRE_NAME_LENGTH) + buf.writeInt(color) + } + + override fun handle(context: ServerNetworkContext) { + val player = context.getSender() + val stack = player.mainHandItem + val configurator = stack.item as? UltimateConfigurator + if (!stack.`is`(Items.ULTIMATE_CONFIGURATOR.get()) || configurator == null || configurator.getActiveMode(stack) != null) { + player.displayClientMessage(ModText.CONFIGURATOR_HISTORY_REQUEST_REJECTED.text, true) + return + } + val target = ConfiguratorTarget(PLACEHOLDER_MODE, dimension, pos) + val result = when (action) { + Action.SELECT -> when (configurator.selectTarget(stack, player.level(), target)) { + UltimateConfigurator.SelectionResult.SUCCESS -> null + UltimateConfigurator.SelectionResult.UNAVAILABLE -> ModText.CONFIGURATOR_HISTORY_TARGET_UNAVAILABLE + UltimateConfigurator.SelectionResult.REJECTED -> ModText.CONFIGURATOR_HISTORY_REQUEST_REJECTED + } + Action.TOGGLE_FAVORITE -> when (configurator.toggleFavorite(stack, target)) { + UltimateConfigurator.FavoriteResult.ADDED, UltimateConfigurator.FavoriteResult.REMOVED -> null + UltimateConfigurator.FavoriteResult.LIMIT -> ModText.CONFIGURATOR_HISTORY_FAVORITE_LIMIT + UltimateConfigurator.FavoriteResult.REJECTED -> ModText.CONFIGURATOR_HISTORY_REQUEST_REJECTED + } + Action.RENAME -> if (configurator.renameFavorite(stack, target, name)) null else ModText.CONFIGURATOR_HISTORY_REQUEST_REJECTED + Action.FAVORITE_TEXT_COLOR -> if (configurator.setFavoriteColor(stack, target, color, true)) null else ModText.CONFIGURATOR_HISTORY_REQUEST_REJECTED + Action.FAVORITE_BOX_COLOR -> if (configurator.setFavoriteColor(stack, target, color, false)) null else ModText.CONFIGURATOR_HISTORY_REQUEST_REJECTED + Action.CONFIGURATOR_NAME -> if (configurator.setSettings(stack, name)) null else ModText.CONFIGURATOR_HISTORY_REQUEST_REJECTED + Action.DEFAULT_TEXT_STYLE -> if (runCatching { TextStyle.valueOf(name) }.getOrNull()?.let { configurator.setSettings(stack, null, textStyle = it) } == true) null else ModText.CONFIGURATOR_HISTORY_REQUEST_REJECTED + Action.DEFAULT_BOX_STYLE -> if (runCatching { BoxStyle.valueOf(name) }.getOrNull()?.let { configurator.setSettings(stack, null, boxStyle = it) } == true) null else ModText.CONFIGURATOR_HISTORY_REQUEST_REJECTED + } + if (result == null) player.inventoryMenu.broadcastChanges() else player.displayClientMessage(result.text, true) + } + + companion object { + @Suppress("DEPRECATION", "KotlinRedundantDiagnosticSuppress") + private val PLACEHOLDER_MODE = ResourceLocation("minecraft", "empty") + private const val MAX_WIRE_NAME_LENGTH = 256 + } +} diff --git a/projects/core/src/main/kotlin/site/siredvin/peripheralworks/networking/NetworkMessages.kt b/projects/core/src/main/kotlin/site/siredvin/peripheralworks/networking/NetworkMessages.kt index 3c8889d4..f411be02 100644 --- a/projects/core/src/main/kotlin/site/siredvin/peripheralworks/networking/NetworkMessages.kt +++ b/projects/core/src/main/kotlin/site/siredvin/peripheralworks/networking/NetworkMessages.kt @@ -36,6 +36,13 @@ object NetworkMessages { ::TargetRenderStyleMessage, ) + val CONFIGURATOR_TARGET_ACTION = registerServerbound( + 604, + "configurator_target_action", + ConfiguratorTargetActionMessage::class.java, + ::ConfiguratorTargetActionMessage, + ) + private fun > register( messages: MutableList>>, id: Int, diff --git a/projects/core/src/main/kotlin/site/siredvin/peripheralworks/subsystem/configurator/ConfiguratorTarget.kt b/projects/core/src/main/kotlin/site/siredvin/peripheralworks/subsystem/configurator/ConfiguratorTarget.kt new file mode 100644 index 00000000..69be28f5 --- /dev/null +++ b/projects/core/src/main/kotlin/site/siredvin/peripheralworks/subsystem/configurator/ConfiguratorTarget.kt @@ -0,0 +1,49 @@ +package site.siredvin.peripheralworks.subsystem.configurator + +import net.minecraft.core.BlockPos +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.NbtUtils +import net.minecraft.nbt.Tag +import net.minecraft.resources.ResourceLocation + +data class ConfiguratorTarget( + val modeID: ResourceLocation, + val dimensionID: ResourceLocation, + val pos: BlockPos, + val name: String? = null, + val textColor: Int? = null, + val boxColor: Int? = null, +) { + fun matches(other: ConfiguratorTarget): Boolean = dimensionID == other.dimensionID && pos == other.pos + + fun toNBT(): CompoundTag = CompoundTag().apply { + putString(MODE, modeID.toString()) + putString(DIMENSION, dimensionID.toString()) + put(POS, NbtUtils.writeBlockPos(pos)) + name?.takeIf { it.isNotEmpty() && it.length <= MAX_NAME_LENGTH }?.let { putString(NAME, it) } + textColor?.let { putInt(TEXT_COLOR, it) } + boxColor?.let { putInt(BOX_COLOR, it) } + } + + companion object { + const val MAX_NAME_LENGTH = 64 + private const val MODE = "mode" + private const val DIMENSION = "dimension" + private const val POS = "pos" + private const val NAME = "name" + private const val TEXT_COLOR = "textColor" + private const val BOX_COLOR = "boxColor" + + fun fromNBT(tag: CompoundTag): ConfiguratorTarget? { + if (!tag.contains(MODE, Tag.TAG_STRING.toInt()) || !tag.contains(DIMENSION, Tag.TAG_STRING.toInt()) || !tag.contains(POS, Tag.TAG_COMPOUND.toInt())) return null + val mode = ResourceLocation.tryParse(tag.getString(MODE))?.takeIf { ConfiguratorModeRegistry.get(it) != null } ?: return null + val dimension = ResourceLocation.tryParse(tag.getString(DIMENSION)) ?: return null + val posTag = tag.getCompound(POS) + if (!posTag.contains("X", Tag.TAG_ANY_NUMERIC.toInt()) || !posTag.contains("Y", Tag.TAG_ANY_NUMERIC.toInt()) || !posTag.contains("Z", Tag.TAG_ANY_NUMERIC.toInt())) return null + val name = tag.getString(NAME).takeIf { tag.contains(NAME, Tag.TAG_STRING.toInt()) && it.isNotEmpty() && it.length <= MAX_NAME_LENGTH } + val textColor = tag.getInt(TEXT_COLOR).takeIf { tag.contains(TEXT_COLOR, Tag.TAG_INT.toInt()) && it in 0..0xffffff } + val boxColor = tag.getInt(BOX_COLOR).takeIf { tag.contains(BOX_COLOR, Tag.TAG_INT.toInt()) && it in 0..0xffffff } + return ConfiguratorTarget(mode, dimension, NbtUtils.readBlockPos(posTag), name, textColor, boxColor) + } + } +} diff --git a/projects/core/src/main/kotlin/site/siredvin/peripheralworks/xplat/ModClientInternalPlatform.kt b/projects/core/src/main/kotlin/site/siredvin/peripheralworks/xplat/ModClientInternalPlatform.kt index 34e79011..40381c34 100644 --- a/projects/core/src/main/kotlin/site/siredvin/peripheralworks/xplat/ModClientInternalPlatform.kt +++ b/projects/core/src/main/kotlin/site/siredvin/peripheralworks/xplat/ModClientInternalPlatform.kt @@ -10,4 +10,5 @@ interface ModClientInternalPlatform { fun registerBlockEntityRendererCallback(sup: Supplier, BlockEntityRendererProvider>>>) fun openNetworkManagerScreen(pos: BlockPos) fun openTargetRenderSettingsScreen(pos: BlockPos) + fun openConfiguratorTargetHistoryScreen() } diff --git a/projects/core/src/main/kotlin/site/siredvin/peripheralworks/xplat/ModClientPlatform.kt b/projects/core/src/main/kotlin/site/siredvin/peripheralworks/xplat/ModClientPlatform.kt index 492f14f9..76a30e3d 100644 --- a/projects/core/src/main/kotlin/site/siredvin/peripheralworks/xplat/ModClientPlatform.kt +++ b/projects/core/src/main/kotlin/site/siredvin/peripheralworks/xplat/ModClientPlatform.kt @@ -37,4 +37,6 @@ object ModClientPlatform { fun openNetworkManagerScreen(pos: BlockPos) = baseInnerPlatform.openNetworkManagerScreen(pos) fun openTargetRenderSettingsScreen(pos: BlockPos) = baseInnerPlatform.openTargetRenderSettingsScreen(pos) + + fun openConfiguratorTargetHistoryScreen() = baseInnerPlatform.openConfiguratorTargetHistoryScreen() } diff --git a/projects/core/src/testMod/kotlin/site/siredvin/peripheralworks/testmod/NetworkManagerClientGameTests.kt b/projects/core/src/testMod/kotlin/site/siredvin/peripheralworks/testmod/NetworkManagerClientGameTests.kt index 27d28bcc..4d23da8c 100644 --- a/projects/core/src/testMod/kotlin/site/siredvin/peripheralworks/testmod/NetworkManagerClientGameTests.kt +++ b/projects/core/src/testMod/kotlin/site/siredvin/peripheralworks/testmod/NetworkManagerClientGameTests.kt @@ -1,5 +1,6 @@ package site.siredvin.peripheralworks.testmod +import net.minecraft.client.Minecraft import net.minecraft.client.gui.components.AbstractSliderButton import net.minecraft.client.gui.components.AbstractWidget import net.minecraft.client.gui.components.Button @@ -13,6 +14,8 @@ import net.minecraft.network.chat.CommonComponents import net.minecraft.world.InteractionHand import net.minecraft.world.entity.Pose import net.minecraft.world.item.ItemStack +import site.siredvin.peripheralworks.client.configurator.ConfiguratorFavoriteEditScreen +import site.siredvin.peripheralworks.client.configurator.ConfiguratorTargetHistoryScreen import site.siredvin.peripheralworks.client.configurator.NetworkManagerColorPickerScreen import site.siredvin.peripheralworks.client.configurator.NetworkManagerScreen import site.siredvin.peripheralworks.client.configurator.TargetRenderSettingsScreen @@ -36,6 +39,106 @@ import java.io.File @TestGroup("network-manager-client") class NetworkManagerClientGameTests { + @ClientGameTest(template = "empty", timeoutTicks = 600) + fun managesDetachedConfiguratorTargetsAndPreservesAttachedUse(helper: GameTestHelper) { + val proxyPos = BlockPos(1, 1, 1) + val observerPos = BlockPos(3, 1, 1) + helper.startSequence() + .thenExecute { + helper.setBlock(proxyPos, Blocks.PERIPHERAL_PROXY.get()) + helper.setBlock(observerPos, Blocks.REMOTE_OBSERVER.get()) + val configurator = Items.ULTIMATE_CONFIGURATOR.get() as UltimateConfigurator + val stack = ItemStack(configurator) + configurator.saveActiveMode(stack, PeripheralProxyMode, helper.absolutePos(proxyPos), helper.level) + val proxyTarget = configurator.getRecentTargets(stack).first() + check(configurator.toggleFavorite(stack, proxyTarget) == UltimateConfigurator.FavoriteResult.ADDED) + check(configurator.renameFavorite(stack, proxyTarget, "Workshop Proxy")) + repeat(3) { index -> + configurator.saveActiveMode(stack, RemoteObserverMode, helper.absolutePos(BlockPos(5 + index, 1, 1)), helper.level) + check(configurator.toggleFavorite(stack, configurator.getRecentTargets(stack).first()) == UltimateConfigurator.FavoriteResult.ADDED) + } + configurator.saveActiveMode(stack, RemoteObserverMode, helper.absolutePos(observerPos), helper.level) + configurator.clearActiveMode(stack) + player(helper).setItemInHand(InteractionHand.MAIN_HAND, stack) + } + .thenIdle(5) + .thenOnClient { + val player = minecraft.player ?: error("Client player is missing") + player.xRot = -90f + minecraft.gameMode!!.useItem(player, InteractionHand.MAIN_HAND) + val screen = minecraft.screen as? ConfiguratorTargetHistoryScreen ?: error("Detached configurator target screen did not open") + val labels = screen.children().filterIsInstance