diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a6ef2f8..58ea72b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,129 +1,123 @@ # Copilot instructions for ResearchCube -## Big picture -- This is a NeoForge 1.21.1 mod (`Java 21`) with one core gameplay loop: players run research at a `Research Station`, then unlock gated `drive_crafting` recipes. -- Research definitions are **datapack-driven** from `data/*/research/*.json` and loaded on server reload via `ResearchManager`. -- The authoritative flow is server-side: UI click → packet → block entity validation → timed completion → recipe ID imprinted into drive NBT. -- The mod is fully implemented (build system, items, blocks, research system, recipes, menu/screen UI, GeckoLib renderer, per-player tracking, cancel/refund, drive capacity, fluid costs, fluid tank, bucket slot handling, research book, Drive Crafting Table, JEI/EMI integration, ambient sound, HUD overlay, Jade integration, Patchouli guidebook, Processing Station, Research Tree screen, Drive Inspector screen, advancements/criterion triggers). Build is verified `BUILD SUCCESSFUL`. - -## Tech stack -- **NeoForge 21.1.219** for Minecraft 1.21.1 -- **GeckoLib 4.7.1** — animated Research Station block entity (rotating brain, `animation.researchstation.idle`) -- **Java 21**, Gradle 8.10.2, `net.neoforged.moddev` plugin 2.0.42-beta -- **Parchment mappings** 2024.11.17 for readable parameter names -- **JEI 19.21.0.247** (`mezz.jei`) — `compileOnly` dependency; recipe categories for drive-crafting and processing recipes. Only loaded when JEI is present at runtime. -- **EMI 1.1.18+1.21.1** (`dev.emi`) — `compileOnly` dependency; full parity with JEI plugin. Only loaded when EMI is present. -- **Jade 15.x** (`maven.modrinth:jade`) — `compileOnly`; block overlay for Research Station and Processing Station. -- **Patchouli 1.21.x** — `compileOnly`; guidebook registered at `assets/researchcube/patchouli_books/guide/`. -- `DataComponents.CUSTOM_DATA` / `NbtUtil` for item data (NeoForge 1.21.1 pattern — no raw `CompoundTag` on items) -- `MapCodec` + `StreamCodec` for recipe serialization (NeoForge 1.21.1 pattern — no `fromJson`/`toJson`) -- `SimpleJsonResourceReloadListener` for datapack research loading -- `SavedData` for per-player completed research persistence (`ResearchSavedData`) -- `IMenuTypeExtension.create(...)` with `FriendlyByteBuf` constructor for menu type registration -- `SimpleContainerData` wrapped in anonymous `ContainerData` for correct client-side sync (the `set()` method must store values — a pure read-only anonymous class breaks client sync) -- `FluidTank` (NeoForge) for the Research Station's internal fluid storage (capacity 8000 mB = 8 buckets) -- `IFluidHandler` capability exposed by both the Research Station and Processing Station block entities via `RegisterCapabilitiesEvent` - -## Architecture and data flow - -### Research loading path -1. `event/ModServerEvents` registers `ResearchManager` as a `AddReloadListenerEvent` listener. -2. `research/ResearchManager` (extends `SimpleJsonResourceReloadListener`) scans `data/{ns}/research/*.json` on every reload, parses them into `ResearchDefinition` objects, and populates `ResearchRegistry`. - -### Runtime research path -1. Player right-clicks the Research Station block → `ResearchTableBlock.useWithoutItem` opens the menu via `ServerPlayer.openMenu`, writing `BlockPos` + the player's completed research `Set` into the `FriendlyByteBuf`. -2. `client/screen/ResearchTableScreen` renders the research list (tier-colored, locked with lock icon if prerequisites unmet), slots, active research name, and progress bar; player selects a row then clicks Start. -3. Screen sends `network/StartResearchPacket` (client → server) containing `BlockPos` + research ID string. -4. `StartResearchPacket.handle` (server thread) calls `ResearchTableBlockEntity.tryStartResearch`, which validates tier rules, prerequisites, drive capacity, item costs, and fluid costs — all failures log a `[ResearchCube]` WARN. On success, items are consumed and the fluid is drained from the tank; both are snapshotted for refund. -5. `ResearchTableBlockEntity.serverTick` checks elapsed ticks vs. `definition.getDuration()`. On completion, `completeResearch()` picks a weighted-random recipe from the pool via `WeightedRecipe`, calls `NbtUtil.addRecipe()` to imprint the recipe ID onto the drive stack, and records the research in `ResearchSavedData`. The `CompleteResearchTrigger` criterion is also fired here. -6. `ContainerData` (backed by `SimpleContainerData`) syncs 4 values each tick: `DATA_PROGRESS` (0–1000), `DATA_IS_RESEARCHING` (0/1), `DATA_FLUID_AMOUNT` (0–8000 mB), `DATA_FLUID_TYPE` (0=empty, 1=thinking, 2=pondering, 3=reasoning, 4=imagination). The screen shows the running research name (tier-colored), a gradient progress bar, a fluid gauge, and activates the Stop button. -7. If cancelled via `CancelResearchPacket`, `cancelResearchWithRefund()` returns the consumed item costs into the cost slots. - -### Crafting gate path -- `recipe/DriveCraftingRecipe` (type `researchcube:drive_crafting`) checks the crafting grid for a `DriveItem` whose NBT contains the required `recipe_id`, then matches the remaining ingredients shapelessly or shaped. **The drive is returned intact via `getRemainingItems` — the recipe_id is kept so the same recipe can be crafted repeatedly.** Only the other ingredients are consumed. - -## Package map - -| Package | Purpose | -|---|---| -| `com.researchcube` | `ResearchCubeMod` — entry point, `rl()` helper, DeferredRegister wiring, `IFluidHandler` capability registration | -| `block` | `ResearchTableBlock` (opens menu on right-click), `ResearchTableBlockEntity` (GeoBlockEntity, ticking, 11-slot inventory + FluidTank, `getUpdateTag`/`getUpdatePacket` for client sync), `DriveCraftingTableBlock`, `DriveCraftingTableBlockEntity`, `ProcessingStationBlock`, `ProcessingStationBlockEntity` | -| `item` | `DriveItem` (tiered, stores recipe IDs in CustomData, `isFull()` capacity check, foil effect, opens `DriveInspectorScreen` on right-click), `CubeItem` (tiered, validation only), `ResearchBookItem` (opens research book screen via packet), `ResearchChipItem`, `ResearchFluidBucketItem` (custom bucket for research fluids) | -| `menu` | `ResearchTableMenu` — 11 BE slots + player inventory, 4-value `SimpleContainerData`, `completedResearch` set from buf; `DriveCraftingTableMenu` — drive crafting container; `ProcessingStationMenu` — processing station container | -| `client` | `ModClientEvents` — registers screens + GeckoLib renderer + sound (Dist.CLIENT, MOD bus); `ClientSoundHandler` — starts/stops `ResearchStationSoundInstance`; `ClientResearchData` — client-side cache of completed research for JEI/EMI integration; `ResearchHudOverlay` — on-screen HUD showing active research progress | -| `client/screen` | `ResearchTableScreen` — scrollable research list with tier colors + lock icons, prereq tooltip, fluid gauge, gradient progress bar, Start/Stop buttons; `DriveCraftingTableScreen`; `ProcessingStationScreen`; `ResearchBookScreen` — read-only research encyclopedia; `ResearchTreeScreen` — tree visualization; `DriveInspectorScreen` — shows recipes stored on a drive; `ScreenRenderHelper` — shared rendering utilities | -| `client/renderer` | `ResearchStationModel`, `ResearchStationRenderer` — GeckoLib geo/animation/texture wiring | -| `client/sound` | `ResearchStationSoundInstance` — looping ambient sound while research is active | -| `compat/jei` | `ResearchCubeJEIPlugin` (`@JeiPlugin`), `DriveCraftingCategory`, `ProcessingCategory` — JEI recipe categories | -| `compat/emi` | `ResearchCubeEMIPlugin`, `EmiDriveCraftingRecipe`, `EmiProcessingRecipe` — full EMI parity | -| `compat/jade` | `ResearchCubeJadePlugin`, `ResearchStationProvider`, `ProcessingStationProvider` — Jade block overlays | -| `network` | `StartResearchPacket`, `CancelResearchPacket`, `WipeTankPacket`, `OpenResearchBookPacket`, `StartProcessingPacket` (all client→server); `SyncResearchProgressPacket` (server→client); `ModNetworking` (PayloadRegistrar) | -| `recipe` | `DriveCraftingRecipe`, `DriveCraftingRecipeSerializer`, `ProcessingRecipe`, `ProcessingRecipeSerializer`, `ProcessingFluidStack` | -| `research` | `ResearchDefinition` (id, tier, duration, prerequisites, itemCosts, fluidCost, recipePool, name, description, category, ideaChip), `ResearchRegistry`, `ResearchManager`, `ResearchTier` (with `maxRecipes` and `getColor()`), `ItemCost`, `FluidCost`, `WeightedRecipe`, `ResearchSavedData` | -| `research/prerequisite` | `Prerequisite` interface (with `describe()`), `AndPrerequisite`, `OrPrerequisite`, `SinglePrerequisite`, `NonePrerequisite`, `PrerequisiteParser` | -| `research/criterion` | `CompleteResearchTrigger` — advancement criterion fired on research completion | -| `registry` | `ModItems`, `ModBlocks`, `ModBlockEntities`, `ModMenus`, `ModCreativeTabs`, `ModRecipeTypes`, `ModRecipeSerializers`, `ModFluids`, `ModConfig`, `ModCriterionTriggers` | -| `util` | `NbtUtil` (CustomData read/write), `TierUtil` (canResearch validation), `RecipeOutputResolver`, `IdeaChipMatcher` (partial ItemStack matching for idea chip validation) | -| `event` | `ModServerEvents` (AddReloadListenerEvent) | - -## Critical workflows -- Build: `.\gradlew.bat build` -- Run client dev instance: `.\gradlew.bat runClient` -- Run dedicated server: `.\gradlew.bat runServer` -- Regenerate data outputs: `.\gradlew.bat runData` (writes into `src/generated/resources`, already included in main resources) -- No test suite is configured; validate changes with `build` + in-game `runClient` behavior. +## Project at a glance +- NeoForge 1.21.1 mod using Java 21. +- Core loop: run research at Research Station, then unlock gated `drive_crafting` recipes. +- Research is datapack-driven from `data/*/research/*.json` and loaded on server reload. +- Server-authoritative flow: UI click -> packet -> server validation -> timed completion -> recipe ID written to drive NBT. +- Implementation status: complete and playable (build, UI, networking, fluids, compat integrations, guidebook, advancements). +- Key blocks: **Research Station** (research + unlock recipes), **Drive Crafting Table** (research-gated crafting), **Processing Station** (fluid + item processing). -## Commit message convention -- Use Conventional Commits for any suggested commit message: `type(scope): short imperative summary`. -- Keep the summary concise, lowercase, and focused on the user-visible change or technical root cause. -- Prefer these types: `feat`, `fix`, `refactor`, `docs`, `chore`, `test`, `build`, `ci`, `style`, `perf`. -- Use scope when it adds clarity, usually a package, system, or feature area such as `research`, `ui`, `network`, `recipe`, or `data`. -- Good examples: - - `fix(research): preserve selected research id during screen refresh` - - `feat(processing): add fluid tank sync to station menu` - - `docs(readme): clarify datapack research format` +## Stack and key APIs +- NeoForge `21.1.219` +- GeckoLib `4.7.1` +- Java `21`, Gradle `8.10.2` +- Parchment mappings `2024.11.17` +- Optional compat (`compileOnly`): JEI, EMI, Jade, Patchouli +- Item data: `DataComponents.CUSTOM_DATA` via `NbtUtil` (never raw item `CompoundTag`) +- Recipe codecs: `MapCodec` + `StreamCodec` +- Datapack loading: `SimpleJsonResourceReloadListener` +- Research persistence: `ResearchSavedData` keyed by `team:` with UUID fallback +- Menu creation: `IMenuTypeExtension.create(...)` with `FriendlyByteBuf` +- Menu sync storage: `SimpleContainerData` must back `ContainerData` +- Research Station tank: `FluidTank` capacity `8000` mB + +## GUI Architecture +- **ResearchTableScreen**: Main research station GUI with slot display, fluid tank, progress bar, and "View Research Tree" button. + - View modes: `NORMAL` (see slots + controls) and `TREE` (research tree overlay). + - Control buttons: Start/Cancel research, View Tree, Wipe Tank (when not researching). +- **ResearchTreeScreen**: Displays available research nodes in a tree structure. + - Shows prerequisites, tier, costs, completion status. + - Click to select research; "Start Research" button navigates back with selection. + - Control buttons: Back to table, Start Research (when valid research selected). +- GUI packet flow: Client screen -> packet -> server handler -> block entity logic -> menu sync -> client screen update. + +## Recipe Types +Three distinct recipe systems: +1. **`researchcube:drive_crafting`** — Drive Crafting Table recipes (research-locked) + - Shaped: `pattern` (3-row string array) + `key` (char → item map) + `result` + - Shapeless: `ingredients` (list) + `result` + - **Always needs `recipe_id` field** matching `"researchcube:"` + - Requires drive with matching `recipe_id` in NBT from research unlock +2. **`researchcube:processing`** — Processing Station recipes (freely available unless in research pool) + - Fields: `inputs`, `fluid_inputs`, `outputs`, `fluid_output` (optional), `duration` + - **NO `recipe_id` field** — ID comes from filename +3. **`minecraft:crafting_shaped` / `minecraft:crafting_shapeless`** — vanilla crafting (always available) + +## Non-negotiable rules +- Keep all registrations namespaced with `ResearchCubeMod.rl(...)` and mod id `researchcube`. +- Use DeferredRegister in `registry/Mod*` classes and wire in `ResearchCubeMod`. +- Never perform research start/complete/cancel logic on the client. +- Never write raw `CompoundTag` directly on `ItemStack`; use `NbtUtil`. +- Drives are never consumed by drive crafting; `recipe_id` remains on drive. +- Always log silent research validation failures in `tryStartResearch()` with `[ResearchCube] WARN` and reason. +- For `ResearchTableMenu` sync, use writable `SimpleContainerData` storage; no-op `set()` breaks client state. +- `textures/block/baum.txt` is an inside joke. **Never delete or modify it.** + +## Slot and data sync contracts +- Research Station slots: + - `SLOT_DRIVE=0` + - `SLOT_CUBE=1` + - `COST_SLOT_START=2` + - `SLOT_BUCKET_IN=8` + - `SLOT_BUCKET_OUT=9` + - `SLOT_IDEA_CHIP=10` + - `TOTAL_SLOTS=11` +- Item costs are only slots `2..7` (iterate from `COST_SLOT_START` to `SLOT_BUCKET_IN` exclusive). +- Tank capacity is `TANK_CAPACITY=8000` mB. +- `ResearchTableMenu` data indexes: + - `DATA_PROGRESS=0` (`0..1000`) + - `DATA_IS_RESEARCHING=1` (`0/1`) + - `DATA_FLUID_AMOUNT=2` (`0..8000`) + - `DATA_FLUID_TYPE=3` (`0 empty, 1 thinking, 2 pondering, 3 reasoning, 4 imagination`) +- Menu buffer pattern: write extra fields in `openMenu(..., bufWriter)` and read in same order in menu buffer constructor. + +## Research sharing scope +- Completed research is team-shared: + - key format `team:` when player has scoreboard team + - UUID-string fallback when no team +- Use `ResearchSavedData.getResearchKey(ServerPlayer)` consistently. + +## Runtime flow (authoritative) +1. `ResearchTableBlock.useWithoutItem` opens menu and writes `BlockPos` plus completed set for current research key. +2. `ResearchTableScreen` sends `StartResearchPacket` (client -> server). +3. `StartResearchPacket.handle` calls `ResearchTableBlockEntity.tryStartResearch` (tier/prereq/drive/cost/fluid checks). +4. On success, costs are consumed and snapshot for refund. +5. `serverTick` tracks progress; completion writes weighted recipe ID into drive and records completed research. +6. `CancelResearchPacket` triggers `cancelResearchWithRefund()`. + +## Tier rules +- Enforce with `TierUtil.canResearch(cubeTier, driveTier, researchTier)`: + - cube tier must be >= research tier + - drive tier must be == research tier + +## ResearchTier reference +Order: `IRRECOVERABLE`, `UNSTABLE`, `BASIC`, `ADVANCED`, `PRECISE`, `FLAWLESS`, `SELF_AWARE` -## Project-specific conventions -- Keep all new content namespaced with `ResearchCubeMod.rl(...)` and mod id `researchcube`. -- Register new game objects using `DeferredRegister` in `registry/Mod*` classes, then ensure they are registered in `ResearchCubeMod` constructor. -- **Server authority**: never start/complete/cancel research from client code. Client screens send packets; server validates. -- Preserve slot semantics in `ResearchTableBlockEntity` / `ResearchTableMenu`: - - slot 0 = drive, slot 1 = cube, slots 2–7 = item costs, slot 8 = bucket_in, slot 9 = bucket_out, slot 10 = idea_chip (`SLOT_DRIVE=0`, `SLOT_CUBE=1`, `COST_SLOT_START=2`, `SLOT_BUCKET_IN=8`, `SLOT_BUCKET_OUT=9`, `SLOT_IDEA_CHIP=10`, `TOTAL_SLOTS=11`). - - When iterating cost slots, always loop `COST_SLOT_START` to `SLOT_BUCKET_IN` (exclusive), i.e. slots 2–7 only. Never include bucket slots in item-cost validation or consumption. - - The block entity also holds a `FluidTank` (capacity `TANK_CAPACITY = 8000` mB). Fluid cost is validated and drained separately from item costs. -- Enforce tier rules through `TierUtil.canResearch(cubeTier, driveTier, researchTier)` — cube tier ≥ research tier AND drive tier == research tier. -- Store custom item data through `DataComponents.CUSTOM_DATA` via `NbtUtil`; never write raw `CompoundTag` directly onto an `ItemStack`. -- For new recipes: add type + serializer in `ModRecipeTypes`/`ModRecipeSerializers`, register both in `ResearchCubeMod`, and provide JSON under `data/researchcube/recipe/`. -- Client-only registrations (screens, renderers) belong in `client/ModClientEvents` — annotated `@EventBusSubscriber(value = Dist.CLIENT, bus = Bus.MOD)`. -- `@EventBusSubscriber` deprecation warnings for `Bus.MOD` are harmless in the current NeoForge version; leave as-is until the API stabilises. -- **ContainerData sync**: always back `ContainerData` with a `SimpleContainerData` storage so `set()` actually stores client-received values. A pure read-only anonymous `ContainerData` (no-op `set()`) will silently break all client-side sync. `ResearchTableMenu` exposes 4 data slots: `DATA_PROGRESS=0`, `DATA_IS_RESEARCHING=1`, `DATA_FLUID_AMOUNT=2`, `DATA_FLUID_TYPE=3`. -- **Menu buffer pattern**: when opening a menu with `ServerPlayer.openMenu(provider, bufWriter)`, write all extra data (e.g. completed research set) in the buf lambda; read it in the `FriendlyByteBuf` constructor of the menu in the same order. -- **Research failure logging**: all silent validation failures in `tryStartResearch()` must log a `[ResearchCube]` WARN with the specific reason so bugs are diagnosable without a debugger. -- **Research ID tracking in screen**: store `selectedId` (ResourceLocation) not just `selectedIndex` — the list is rebuilt every tick, so an index-only selection is immediately lost. -- **Drive behavior in crafting**: drives are **never consumed**. `DriveCraftingRecipe.getRemainingItems()` returns the drive stack unchanged so the stored recipe IDs persist and the same recipe can be crafted repeatedly. - -## ResearchTier enum -Values in ordinal order (0–6): `IRRECOVERABLE`, `UNSTABLE`, `BASIC`, `ADVANCED`, `PRECISE`, `FLAWLESS`, `SELF_AWARE`. -- Drives map: irrecoverable→IRRECOVERABLE, unstable→UNSTABLE, reclaimed→BASIC, enhanced→ADVANCED, elaborate→PRECISE, cybernetic→FLAWLESS, self_aware→SELF_AWARE. -- Cubes exist for UNSTABLE through SELF_AWARE (no IRRECOVERABLE cube). -- Each tier has a `maxRecipes` capacity: IRRECOVERABLE=0, UNSTABLE=2, BASIC=4, ADVANCED=8, PRECISE=12, FLAWLESS=16, SELF_AWARE=-1 (unlimited). -- `getColor()` returns an RGB int used for tier-colored text in the UI: - - IRRECOVERABLE = `0x888888` (gray) - - UNSTABLE = `0xFFFFFF` (white) - - BASIC = `0x55FF55` (green) - - ADVANCED = `0x5555FF` (blue) - - PRECISE = `0xAA00AA` (purple) - - FLAWLESS = `0xFFAA00` (gold) - - SELF_AWARE = `0xFF5555` (red) -- `hasRecipeLimit()` returns false only for SELF_AWARE. `isFunctional()` returns false only for IRRECOVERABLE. - -## JSON schemas +Capacity by tier: +- IRRECOVERABLE `0` +- UNSTABLE `2` +- BASIC `4` +- ADVANCED `8` +- PRECISE `12` +- FLAWLESS `16` +- SELF_AWARE `-1` (unlimited) +Tier colors (`getColor()`): +- IRRECOVERABLE `0x888888` +- UNSTABLE `0xFFFFFF` +- BASIC `0x55FF55` +- ADVANCED `0x5555FF` +- PRECISE `0xAA00AA` +- FLAWLESS `0xFFAA00` +- SELF_AWARE `0xFF5555` + +## Data formats ### Research definition (`data/{ns}/research/*.json`) ```json { "name": "Basic Circuit", - "description": "Short human-readable description shown in tooltip.", + "description": "Shown in tooltips/UI.", "category": "circuits", "tier": "BASIC", "duration": 1200, @@ -136,11 +130,9 @@ Values in ordinal order (0–6): `IRRECOVERABLE`, `UNSTABLE`, `BASIC`, `ADVANCED ] } ``` -- `name`, `description`, `category`, `item_costs`, `fluid_cost`, and `recipe_pool` are all optional. -- `getDisplayName()` falls back to the ID path if `name` is absent. -- `prerequisites` may be: a string ID, `{"type":"AND","values":[...]}`, or `{"type":"OR","values":[...]}` (recursive). -- `recipe_pool` entries may be plain strings (weight defaults to 1) or objects `{"id": "...", "weight": N}`. Selection is weighted-random via `WeightedRecipe`. -- `fluid_cost` specifies the fluid (`researchcube:thinking_fluid`, `pondering_fluid`, `reasoning_fluid`, or `imagination_fluid`) and amount in mB that must be in the Research Station's tank before research can start. The fluid is drained on research start and refunded on cancel. +- Optional fields: `name`, `description`, `category`, `item_costs`, `fluid_cost`, `recipe_pool`. +- `prerequisites` supports string, recursive `AND`, recursive `OR`. +- `recipe_pool` supports string (weight 1) or object with `weight`. ### Drive crafting recipe (`data/{ns}/recipe/*.json`) ```json @@ -151,76 +143,132 @@ Values in ordinal order (0–6): `IRRECOVERABLE`, `UNSTABLE`, `BASIC`, `ADVANCED "result": { "id": "minecraft:iron_block", "count": 1 } } ``` -- Drive containing the `recipe_id` must be present in the grid; it is **returned intact after crafting**. -- Up to 8 additional ingredient slots (shapeless). -- For shaped recipes add `"pattern"` and `"key"` fields (standard NeoForge shaped format); drive slot position is identified by its key character. - -## Research fluids +- **CRITICAL**: Drive crafting recipes MUST have `recipe_id` field matching `"researchcube:"` +- Drive with matching `recipe_id` in NBT is required and returned unchanged +- Supports shapeless (ingredient list) and shaped mode (`pattern` + `key`) +- Player must have completed research that includes this `recipe_id` in its `recipe_pool` -Four custom fluids are registered in `ModFluids`, each with a source + flowing variant and a bucket item in `ModItems`. **No liquid block** is registered — these fluids cannot be placed in the world. +### Processing recipe (`data/{ns}/recipe/*.json`) +```json +{ + "type": "researchcube:processing", + "inputs": [{ "item": "minecraft:iron_ingot", "count": 2 }], + "fluid_inputs": [{ "fluid": "researchcube:thinking_fluid", "amount": 100 }], + "outputs": [{ "item": "researchcube:research_chip", "count": 1 }], + "duration": 200 +} +``` +- **NO `recipe_id` field** — recipe ID comes from filename only +- Most processing recipes are freely available (no research lock) +- Some may be gated via research `recipe_pool` if needed -| Fluid ID | Bucket item | Color | Typical tier usage | -|---|---|---|---| -| `researchcube:thinking_fluid` | `thinking_fluid_bucket` | Cyan (`#55CCFF`) | UNSTABLE / BASIC | -| `researchcube:pondering_fluid` | `pondering_fluid_bucket` | Purple (`#AA55FF`) | ADVANCED | -| `researchcube:reasoning_fluid` | `reasoning_fluid_bucket` | Gold (`#FFAA00`) | PRECISE / FLAWLESS | -| `researchcube:imagination_fluid` | `imagination_fluid_bucket` | Pink (`#FF5599`) | SELF_AWARE | +## Research fluids +Registered fluids (source + flowing + bucket item, no placeable liquid block): +- `researchcube:thinking_fluid` (cyan) — for UNSTABLE/BASIC tier research +- `researchcube:pondering_fluid` (purple) — for ADVANCED tier research +- `researchcube:reasoning_fluid` (gold) — for PRECISE/FLAWLESS tier research +- `researchcube:imagination_fluid` (pink) — for SELF_AWARE tier research -- Fluids have a `FluidType` with custom colour registered via `ModFluids`. -- Bucket items use `ResearchFluidBucketItem` (not vanilla `BucketItem`), `stacksTo(1)`, `craftRemainder = Items.BUCKET`. -- The Research Station holds up to `TANK_CAPACITY = 8000` mB in a single `FluidTank`. Place a filled bucket in slot 8 (`SLOT_BUCKET_IN`) to fill the tank; the empty bucket lands in slot 9 (`SLOT_BUCKET_OUT`). Send `WipeTankPacket` to drain the tank. -- `DATA_FLUID_TYPE` encodes the current fluid as an integer (0=empty, 1=thinking, 2=pondering, 3=reasoning, 4=imagination) for `ContainerData` sync. -- Both the Research Station and the Processing Station expose `IFluidHandler` capabilities registered in `ResearchCubeMod.registerCapabilities`. The Processing Station exposes a combined handler for all its tanks via `getCombinedFluidHandler()`. +Fluid behavior: +- Bucket class: `ResearchFluidBucketItem` (`stacksTo(1)`, remainder `Items.BUCKET`). +- Station fills from slot 8, outputs empty bucket to slot 9. +- `WipeTankPacket` drains the tank. +- Both Research Station and Processing Station expose `IFluidHandler` capability. -## GeckoLib assets -- Geo: `assets/researchcube/geo/research_station.geo.json` — identifier `geometry.unknown`, 128×128 UV, root bones: `ResearchStation → base, top, screen, Brain (→ center, b1–b8)`. -- Animation: `assets/researchcube/animations/research_station.animation.json` — animation `animation.researchstation.idle`, loops 24 s, Brain bone rotates 360°/360°/360° and bobs. -- Texture: `assets/researchcube/textures/research_station/research_station.png` — 128×128. -- Renderer uses `ResearchStationModel` + `ResearchStationRenderer`; block entity registers the idle controller unconditionally. +## Mod Items Reference +- **Drives**: `metadata_unstable`, `metadata_reclaimed`, `metadata_enhanced`, `metadata_elaborate`, `metadata_cybernetic`, `metadata_self_aware` +- **Cubes**: `cube_unstable`, `cube_basic`, `cube_advanced`, `cube_precise`, `cube_flawless`, `cube_self_aware` +- **Blocks**: `research_station_item`, `drive_crafting_table`, `processing_station` +- **Other**: `research_chip`, `research_book`, `*_fluid_bucket` (thinking/pondering/reasoning/imagination) -## High-value reference files +## Where to look first +### Core Logic - `src/main/java/com/researchcube/ResearchCubeMod.java` - `src/main/java/com/researchcube/block/ResearchTableBlockEntity.java` - `src/main/java/com/researchcube/block/ResearchTableBlock.java` -- `src/main/java/com/researchcube/block/DriveCraftingTableBlockEntity.java` -- `src/main/java/com/researchcube/block/ProcessingStationBlockEntity.java` - `src/main/java/com/researchcube/menu/ResearchTableMenu.java` -- `src/main/java/com/researchcube/menu/DriveCraftingTableMenu.java` -- `src/main/java/com/researchcube/menu/ProcessingStationMenu.java` -- `src/main/java/com/researchcube/client/screen/ResearchTableScreen.java` -- `src/main/java/com/researchcube/client/screen/DriveCraftingTableScreen.java` -- `src/main/java/com/researchcube/client/screen/ProcessingStationScreen.java` -- `src/main/java/com/researchcube/client/screen/ResearchBookScreen.java` -- `src/main/java/com/researchcube/client/screen/DriveInspectorScreen.java` -- `src/main/java/com/researchcube/client/screen/ResearchTreeScreen.java` -- `src/main/java/com/researchcube/client/ResearchHudOverlay.java` - `src/main/java/com/researchcube/research/ResearchManager.java` - `src/main/java/com/researchcube/research/ResearchSavedData.java` -- `src/main/java/com/researchcube/research/WeightedRecipe.java` -- `src/main/java/com/researchcube/research/FluidCost.java` -- `src/main/java/com/researchcube/research/criterion/CompleteResearchTrigger.java` + +### GUI and Screens +- `src/main/java/com/researchcube/client/screen/ResearchTableScreen.java` +- `src/main/java/com/researchcube/client/screen/ResearchTreeScreen.java` + +### Recipes +- `src/main/java/com/researchcube/recipe/DriveCraftingRecipe.java` +- `src/main/java/com/researchcube/recipe/ProcessingRecipe.java` + +### Networking - `src/main/java/com/researchcube/network/StartResearchPacket.java` - `src/main/java/com/researchcube/network/CancelResearchPacket.java` - `src/main/java/com/researchcube/network/WipeTankPacket.java` -- `src/main/java/com/researchcube/network/SyncResearchProgressPacket.java` -- `src/main/java/com/researchcube/recipe/DriveCraftingRecipe.java` -- `src/main/java/com/researchcube/recipe/ProcessingRecipe.java` + +### Registry and Fluids - `src/main/java/com/researchcube/registry/ModFluids.java` -- `src/main/java/com/researchcube/registry/ModConfig.java` -- `src/main/java/com/researchcube/compat/jei/ResearchCubeJEIPlugin.java` -- `src/main/java/com/researchcube/compat/emi/ResearchCubeEMIPlugin.java` -- `src/main/java/com/researchcube/compat/jade/ResearchCubeJadePlugin.java` -- `src/main/resources/data/researchcube/research/advanced_processor.json` -- `src/main/resources/data/researchcube/recipe/processor_recipe_1.json` - -## Project Management - -### todo.lock File -- **Location**: `todo.lock` at the workspace root contains the project's task tracker with explicit instructions for AI agents. -- **AI Instructions** (lines 1–6): Read the `[AI INSTRUCTIONS]` block at the top—it specifies task priorities and restrictions. -- **Task Prefixes**: - - `[MAJOR]` or `[DANGER]`: Requires explicit user approval before touching. - - `[LOW PRIO]`: Can be deferred if necessary. - - `[DONE]`: Completed; do not suggest or implement again. -- **AI Agent Convention**: When adding new tasks to `todo.lock`, prefix them with `[AI]` so they are distinguishable from user-created tasks. -- Always check task status before starting work — don't re-implement completed tasks or violate approval requirements. +- `src/main/java/com/researchcube/registry/ModBlocks.java` +- `src/main/java/com/researchcube/registry/ModItems.java` + +### Data Files +- Research definitions: `src/main/resources/data/researchcube/research/` +- Recipe files: `src/main/resources/data/researchcube/recipe/` + +## Build and validation +- Build: `./gradlew.bat build` +- Client dev run: `./gradlew.bat runClient` +- Server dev run: `./gradlew.bat runServer` +- Data generation: `./gradlew.bat runData` +- No automated test suite: validate with build + in-game behavior. + +## Commit message convention +Use Conventional Commits: +- format: `type(scope): short imperative summary` +- preferred types: `feat`, `fix`, `refactor`, `docs`, `chore`, `test`, `build`, `ci`, `style`, `perf` +- examples: + - `fix(research): preserve selected research id during screen refresh` + - `feat(processing): add fluid tank sync to station menu` + - `docs(readme): clarify datapack research format` + +## Task management rules (`todo.lock`) +- Read `[AI INSTRUCTIONS]` at the top before starting work. +- Do not touch tasks marked `[DONE]`. +- `[MAJOR]` and `[DANGER]` items require explicit user approval. +- `[LOW PRIO]` items can be deferred. +- If adding AI-created tasks, prefix with `[AI]`. + +## Common Patterns +### Accessing Drive/Cube NBT +- Use `NbtUtil.getRecipeId(ItemStack)` to read drive recipe ID +- Use `NbtUtil.setRecipeId(ItemStack, ResourceLocation)` to write drive recipe ID +- Use `TierUtil.getCubeTier(ItemStack)` / `getDriveTier(ItemStack)` for tier checks + +### Research Validation Flow +1. Check drive tier matches research tier exactly +2. Check cube tier >= research tier +3. Verify prerequisites are completed (check saved data) +4. Verify sufficient item costs in slots 2-7 +5. Verify sufficient fluid in tank (matches research fluid type) +6. If any check fails, log warning and return false + +### Menu Synchronization +- Always use `SimpleContainerData` with writable backing storage +- Update `containerData.set(index, value)` on server side +- Client reads via `containerData.get(index)` in screen +- Never use no-op storage that ignores `set()` calls + +### Packet Handling +- Packets arrive on network thread; always use `source.enqueueWork(() -> {...})` +- Validate player permissions and world state before proceeding +- Return `InteractionResult.SUCCESS` only after successful operation +- Log failures for debugging + +### Resource Location Handling +- Use `ResearchCubeMod.rl("path")` for mod namespace +- Recipe IDs must match research `recipe_pool` entries exactly +- Research IDs come from filename (e.g., `basic_circuit.json` → `researchcube:basic_circuit`) + +## Debug Tips +- Research validation failures are logged as `[ResearchCube] WARN` with reason +- Check server logs for packet handling issues +- Use `/researchcube debug` commands to inspect saved data (if implemented) +- Tank sync issues: verify `DATA_FLUID_AMOUNT` and `DATA_FLUID_TYPE` are updated +- Drive not working: verify `recipe_id` exists in drive NBT and matches recipe file diff --git a/src/main/java/com/researchcube/block/ResearchTableBlockEntity.java b/src/main/java/com/researchcube/block/ResearchTableBlockEntity.java index 4838a53..19ef521 100644 --- a/src/main/java/com/researchcube/block/ResearchTableBlockEntity.java +++ b/src/main/java/com/researchcube/block/ResearchTableBlockEntity.java @@ -334,6 +334,8 @@ public boolean tryStartResearch(String researchId, Set completedResearch this.startTime = level.getGameTime(); this.researchKey = researchKey; setChanged(); + // Sync block entity state to client so GUI can display progress view + level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 3); // Play start sound (subtle click/buzz) level.playSound(null, worldPosition, SoundEvents.UI_BUTTON_CLICK.value(), SoundSource.BLOCKS, 0.6f, 1.2f); @@ -539,6 +541,8 @@ public void clearResearch() { private void clearResearchAndNotify(Level level) { String key = this.researchKey; clearResearch(); + // Sync block entity state to client so GUI can switch back to list view + level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 3); if (key != null && level instanceof ServerLevel serverLevel) { sendClearPacket(serverLevel, key); } diff --git a/src/main/java/com/researchcube/client/screen/ResearchTableScreen.java b/src/main/java/com/researchcube/client/screen/ResearchTableScreen.java index 5de6a67..1deb5d4 100644 --- a/src/main/java/com/researchcube/client/screen/ResearchTableScreen.java +++ b/src/main/java/com/researchcube/client/screen/ResearchTableScreen.java @@ -10,7 +10,6 @@ import com.researchcube.registry.ModFluids; import com.researchcube.research.ResearchDefinition; import com.researchcube.research.ResearchRegistry; -import com.researchcube.research.ResearchTier; import com.researchcube.research.ItemCost; import com.researchcube.research.FluidCost; import com.researchcube.research.prerequisite.NonePrerequisite; @@ -24,6 +23,7 @@ import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; import net.neoforged.neoforge.network.PacketDistributor; import org.jetbrains.annotations.Nullable; @@ -33,61 +33,41 @@ /** * Client-side screen for the Research Table. * - * Expanded layout (520x286): - * Left panel: Drive slot, Cube slot, 3×2 cost grid, progress bar, Start/Stop buttons - * Right panel: Scrollable research list grouped by category (8 visible rows, 130px wide) - * Bottom: Player inventory + hotbar (centered) + * Layout (470x260) - compact square design: + * Top section: Upper panel with 3 view modes: + * - LIST: Search bar, Research list (4 rows), Detail pane + * - TREE: Embedded research tree visualization + * - PROGRESS: Active research info, description, progress bar + * Bottom section: Machine panel (left) + Player inventory (right) + * - Machine: Drive, Cube, Idea, Cost grid, Fluid gauge, Buckets, Buttons + * - Player inventory: 9x3 main + 9x1 hotbar */ public class ResearchTableScreen extends AbstractContainerScreen { + /** View mode for the upper panel. */ + private enum ViewMode { LIST, TREE, PROGRESS } + /** A display row in the research list: either a category header or a research entry. */ private record ListRow(boolean isHeader, String headerText, ResearchDefinition definition) { static ListRow header(String text) { return new ListRow(true, text, null); } static ListRow entry(ResearchDefinition def) { return new ListRow(false, null, def); } } - // ── Layout constants ── - - // Research list (right panel) - private static final int LIST_X = 194; - private static final int LIST_Y = 38; - private static final int LIST_W = 306; - private static final int ROW_H = 14; - private static final int VISIBLE_ROWS = 7; - - // Progress bar - private static final int PROGRESS_X = 26; - private static final int PROGRESS_Y = 118; - private static final int PROGRESS_W = 146; - private static final int PROGRESS_H = 8; - - // Fluid gauge (vertical bar on right side of left panel) - private static final int GAUGE_X = 154; - private static final int GAUGE_Y = 34; - private static final int GAUGE_W = 16; - private static final int GAUGE_H = 58; - - // Panel regions - private static final int LEFT_PANEL_X = 20; - private static final int LEFT_PANEL_Y = 20; - private static final int LEFT_PANEL_W = 160; - private static final int LEFT_PANEL_H = 132; - - private static final int RIGHT_PANEL_X = 186; - private static final int RIGHT_PANEL_Y = 20; - private static final int RIGHT_PANEL_W = 314; - private static final int RIGHT_PANEL_H = 132; - - // Colors - private static final int BG_OUTER = 0xFFC6C6C6; - private static final int PANEL_BG = 0xFF4A4F60; + // ── Texture ── + private static final ResourceLocation TEXTURE = + ResourceLocation.fromNamespaceAndPath(ResearchCubeMod.MOD_ID, "textures/gui/research_table.png"); + private static final int TEX_W = ResearchTableMenu.GUI_WIDTH; + private static final int TEX_H = ResearchTableMenu.GUI_HEIGHT; + + // Colors (for dynamic elements) private static final int PANEL_BORDER_LIGHT = 0xFF7E87A6; private static final int PANEL_BORDER_DARK = 0xFF1A1A1A; - private static final int PANEL_INNER = 0xFF2E3342; - private static final int SLOT_BG = 0xFF8B8B8B; - private static final int SLOT_INNER = 0xFF2B2E38; private static final int LIST_BG = 0xFF252A3E; + // View mode state + private ViewMode currentView = ViewMode.LIST; + private ViewMode preferredView = ViewMode.LIST; // user preference when not researching + // Research list state private List availableResearch = new ArrayList<>(); private List displayRows = new ArrayList<>(); @@ -99,44 +79,61 @@ private record ListRow(boolean isHeader, String headerText, ResearchDefinition d private Button cancelButton; private Button wipeButton; private Button treeViewButton; + private Button listViewButton; private EditBox searchBox; private String searchFilter = ""; public ResearchTableScreen(ResearchTableMenu menu, Inventory playerInv, Component title) { super(menu, playerInv, title); - this.imageWidth = 520; - this.imageHeight = 286; - this.inventoryLabelX = 179; - this.inventoryLabelY = 154; + this.imageWidth = ResearchTableMenu.GUI_WIDTH; + this.imageHeight = ResearchTableMenu.GUI_HEIGHT; + this.inventoryLabelX = ResearchTableMenu.PLAYER_INV_X; + this.inventoryLabelY = ResearchTableMenu.PLAYER_INV_Y - 10; } @Override protected void init() { super.init(); - // Start / Cancel buttons below the progress bar + // Start button startButton = Button.builder(Component.literal("Start"), btn -> onStartResearch()) - .bounds(leftPos + 26, topPos + 132, 70, 18) + .bounds(leftPos + ResearchTableMenu.START_BTN_X, topPos + ResearchTableMenu.BUTTON_Y, + ResearchTableMenu.BUTTON_W, ResearchTableMenu.BUTTON_H) .build(); addRenderableWidget(startButton); + // Cancel/Stop button cancelButton = Button.builder(Component.literal("Stop"), btn -> onCancelResearch()) - .bounds(leftPos + 102, topPos + 132, 70, 18) + .bounds(leftPos + ResearchTableMenu.STOP_BTN_X, topPos + ResearchTableMenu.BUTTON_Y, + ResearchTableMenu.BUTTON_W, ResearchTableMenu.BUTTON_H) .build(); addRenderableWidget(cancelButton); + // Wipe tank button wipeButton = Button.builder(Component.literal("Wipe"), btn -> onWipeTank()) - .bounds(leftPos + 114, topPos + 86, 34, 16) + .bounds(leftPos + ResearchTableMenu.WIPE_BTN_X, topPos + ResearchTableMenu.BUTTON_Y, + ResearchTableMenu.BUTTON_W, ResearchTableMenu.BUTTON_H) .build(); addRenderableWidget(wipeButton); - treeViewButton = Button.builder(Component.literal("Tree"), btn -> onOpenTreeView()) - .bounds(leftPos + 432, topPos + 22, 66, 18) + // Tree view button (switches to tree view) + treeViewButton = Button.builder(Component.literal("Tree"), btn -> onSwitchToTreeView()) + .bounds(leftPos + ResearchTableMenu.TREE_BTN_X, topPos + ResearchTableMenu.TREE_BTN_Y, + ResearchTableMenu.TREE_BTN_W, ResearchTableMenu.TREE_BTN_H) .build(); addRenderableWidget(treeViewButton); + // List view button (switches back to list view) + listViewButton = Button.builder(Component.literal("List"), btn -> onSwitchToListView()) + .bounds(leftPos + ResearchTableMenu.LIST_BTN_X, topPos + ResearchTableMenu.LIST_BTN_Y, + ResearchTableMenu.LIST_BTN_W, ResearchTableMenu.LIST_BTN_H) + .build(); + addRenderableWidget(listViewButton); + // Search box above the research list - searchBox = new EditBox(font, leftPos + LIST_X, topPos + LIST_Y - 14, LIST_W, 12, + searchBox = new EditBox(font, + leftPos + ResearchTableMenu.SEARCH_X, topPos + ResearchTableMenu.SEARCH_Y, + ResearchTableMenu.SEARCH_W, ResearchTableMenu.SEARCH_H, Component.literal("Search...")); searchBox.setMaxLength(50); searchBox.setHint(Component.literal("Search...").withStyle(s -> s.withColor(0xFF666666))); @@ -148,6 +145,7 @@ protected void init() { addRenderableWidget(searchBox); refreshResearchList(); + updateViewMode(); } // ══════════════════════════════════════════════════════════════ @@ -233,8 +231,7 @@ private void onStartResearch() { if (menu.isResearching()) return; ResearchDefinition def = availableResearch.get(selectedIndex); - if (!isPrerequisiteMet(def)) return; - if (!isIdeaChipSatisfied(def)) return; + if (!canStartResearch(def)) return; ResearchTableBlockEntity be = menu.getBlockEntity(); PacketDistributor.sendToServer(new StartResearchPacket(be.getBlockPos(), def.getId().toString())); @@ -251,24 +248,111 @@ private void onWipeTank() { PacketDistributor.sendToServer(new WipeTankPacket(be.getBlockPos())); } - private void onOpenTreeView() { + private void onSwitchToTreeView() { Minecraft mc = Minecraft.getInstance(); if (mc.player == null) return; mc.setScreen(new ResearchTreeScreen(menu, mc.player.getInventory(), this.title)); } + private void onSwitchToListView() { + preferredView = ViewMode.LIST; + updateViewMode(); + } + + /** + * Updates the view mode based on research state and user preference. + * When researching AND definition is available: show PROGRESS view + * When not researching: show user's preferred view (LIST or TREE) + */ + private void updateViewMode() { + if (menu.isResearching()) { + // Only show PROGRESS view if the active definition is synced to client + ResearchDefinition activeDef = menu.getBlockEntity().getActiveDefinition(); + if (activeDef != null) { + currentView = ViewMode.PROGRESS; + } else { + // Stay in current view until definition is synced + currentView = preferredView; + } + } else { + currentView = preferredView; + } + + // Update visibility of view-specific components + boolean showListComponents = (currentView == ViewMode.LIST); + searchBox.visible = showListComponents; + treeViewButton.visible = showListComponents; + listViewButton.visible = (currentView == ViewMode.TREE); + } + @Override public void containerTick() { super.containerTick(); refreshResearchList(); + updateViewMode(); - boolean canStart = !menu.isResearching() && selectedIndex >= 0 && selectedIndex < availableResearch.size() - && isPrerequisiteMet(availableResearch.get(selectedIndex)) - && isIdeaChipSatisfied(availableResearch.get(selectedIndex)); + // Start button: only active if research is selected AND all requirements are met + ResearchDefinition selectedDef = getSelectedDefinition(); + boolean canStart = !menu.isResearching() && selectedDef != null && canStartResearch(selectedDef); startButton.active = canStart; cancelButton.active = menu.isResearching(); wipeButton.active = menu.getFluidAmount() > 0; - treeViewButton.active = true; + } + + /** + * Checks if research can be started (prerequisites, idea chip, and recipe items/fluids available). + */ + private boolean canStartResearch(ResearchDefinition def) { + if (!isPrerequisiteMet(def)) return false; + if (!isIdeaChipSatisfied(def)) return false; + if (!hasRequiredItems(def)) return false; + if (!hasRequiredFluid(def)) return false; + return true; + } + + /** + * Checks if the required item costs are present in the cost slots. + * Uses menu slots directly for proper client-side sync. + */ + private boolean hasRequiredItems(ResearchDefinition def) { + if (def.getItemCosts().isEmpty()) return true; + + // Collect all items in cost slots using menu's synced slots + Map available = new HashMap<>(); + for (int i = 0; i < 6; i++) { + int slotIndex = ResearchTableBlockEntity.COST_SLOT_START + i; + ItemStack stack = menu.getSlot(slotIndex).getItem(); + if (!stack.isEmpty()) { + available.merge(stack.getItem(), stack.getCount(), Integer::sum); + } + } + + // Check each required cost + for (ItemCost cost : def.getItemCosts()) { + Item requiredItem = cost.getItem(); + int requiredCount = cost.count(); + int availableCount = available.getOrDefault(requiredItem, 0); + if (availableCount < requiredCount) return false; + } + return true; + } + + /** + * Checks if the required fluid is present in the tank. + */ + private boolean hasRequiredFluid(ResearchDefinition def) { + FluidCost fluidCost = def.getFluidCost(); + if (fluidCost == null) return true; + + int requiredAmount = fluidCost.amount(); + int tankFluidType = menu.getFluidType(); + int tankAmount = menu.getFluidAmount(); + + // Check if fluid type matches + int requiredType = ModFluids.getFluidIndex(fluidCost.getFluid()); + if (tankFluidType != requiredType) return false; + + return tankAmount >= requiredAmount; } private boolean isPrerequisiteMet(ResearchDefinition def) { @@ -276,31 +360,19 @@ private boolean isPrerequisiteMet(ResearchDefinition def) { return def.getPrerequisites().isSatisfied(completed); } - /** - * Returns true if the selected research has no idea chip requirement, - * or if the requirement is satisfied by the current slot contents. - */ private boolean isIdeaChipSatisfied(ResearchDefinition def) { if (def.getIdeaChip().isEmpty()) return true; ItemStack required = def.getIdeaChip().get(); - ItemStack candidate = menu.getBlockEntity().getInventory() - .getStackInSlot(ResearchTableBlockEntity.SLOT_IDEA_CHIP); + // Use menu's synced slot instead of direct BlockEntity inventory access + ItemStack candidate = menu.getSlot(ResearchTableBlockEntity.SLOT_IDEA_CHIP).getItem(); return IdeaChipMatcher.matches(required, candidate); } - /** - * Render contextual overlay on the idea chip slot: - * - Dimmed when no requirement for the selected research - * - Red border when requirement exists but not satisfied - * - Normal when satisfied - */ private void renderIdeaChipOverlay(GuiGraphics g, int sx, int sy) { ResearchDefinition selected = getSelectedDefinition(); if (selected == null || selected.getIdeaChip().isEmpty()) { - // No requirement: dimmed grey overlay g.fill(sx, sy, sx + 16, sy + 16, 0x88000000); } else if (!isIdeaChipSatisfied(selected)) { - // Requirement not met: red-tinted border int x0 = sx - 1; int y0 = sy - 1; g.fill(x0, y0, x0 + 18, y0 + 1, 0xFFFF3333); @@ -308,7 +380,6 @@ private void renderIdeaChipOverlay(GuiGraphics g, int sx, int sy) { g.fill(x0, y0, x0 + 1, y0 + 18, 0xFFFF3333); g.fill(x0 + 17, y0, x0 + 18, y0 + 18, 0xFFFF3333); } - // Otherwise: slot renders normally (requirement satisfied) } @Nullable @@ -326,124 +397,253 @@ protected void renderBg(GuiGraphics g, float partialTick, int mouseX, int mouseY int x = leftPos; int y = topPos; - // ── Outer container background (vanilla grey) ── - g.fill(x, y, x + imageWidth, y + imageHeight, BG_OUTER); - // Top/left highlight, bottom/right shadow (bevel) - g.fill(x, y, x + imageWidth, y + 1, 0xFFFFFFFF); - g.fill(x, y, x + 1, y + imageHeight, 0xFFFFFFFF); - g.fill(x + imageWidth - 1, y, x + imageWidth, y + imageHeight, PANEL_BORDER_DARK); - g.fill(x, y + imageHeight - 1, x + imageWidth, y + imageHeight, PANEL_BORDER_DARK); - - // ── Left panel (dark inset) ── - drawInsetPanel(g, x + LEFT_PANEL_X, y + LEFT_PANEL_Y, LEFT_PANEL_W, LEFT_PANEL_H); - - // ── Right panel (dark inset) ── - drawInsetPanel(g, x + RIGHT_PANEL_X, y + RIGHT_PANEL_Y, RIGHT_PANEL_W, RIGHT_PANEL_H); - - // ── Player inventory area ── - drawInsetPanel(g, x + 20, y + 156, 480, 122); - - // ── Slot backgrounds ── - // Drive slot - drawSlotBg(g, x + ResearchTableMenu.DRIVE_X, y + ResearchTableMenu.DRIVE_Y); - // Cube slot - drawSlotBg(g, x + ResearchTableMenu.CUBE_X, y + ResearchTableMenu.CUBE_Y); - // Cost slots 3×2 - for (int row = 0; row < 2; row++) { - for (int col = 0; col < 3; col++) { - drawSlotBg(g, x + ResearchTableMenu.COST_X + col * 18, y + ResearchTableMenu.COST_Y + row * 18); + // ── Static background from texture ── + g.blit(TEXTURE, x, y, 0, 0, imageWidth, imageHeight, TEX_W, TEX_H); + + // ── Upper panel content (view mode dependent) ── + renderUpperPanel(g, x, y, mouseX, mouseY); + + // ── Slot labels (above slot row) ── + int labelY = y + ResearchTableMenu.LABEL_Y; + g.drawString(font, "Dr", x + ResearchTableMenu.DRIVE_X + 2, labelY, 0xFFD3D7E5, false); + g.drawString(font, "Cb", x + ResearchTableMenu.CUBE_X + 2, labelY, 0xFFD3D7E5, false); + g.drawString(font, "Id", x + ResearchTableMenu.IDEA_CHIP_X + 2, labelY, 0xFFD3D7E5, false); + g.drawString(font, "Costs", x + ResearchTableMenu.COST_X, labelY, 0xFFD3D7E5, false); + g.drawString(font, "Fl", x + ResearchTableMenu.FLUID_GAUGE_X + 3, labelY, 0xFFD3D7E5, false); + g.drawString(font, "I/O", x + ResearchTableMenu.BUCKET_IN_X, labelY, 0xFFD3D7E5, false); + + // ── Idea chip dynamic overlay ── + renderIdeaChipOverlay(g, x + ResearchTableMenu.IDEA_CHIP_X, y + ResearchTableMenu.IDEA_CHIP_Y); + + // ── Fluid gauge (dynamic) ── + drawFluidGauge(g, x + ResearchTableMenu.FLUID_GAUGE_X, y + ResearchTableMenu.FLUID_GAUGE_Y, + ResearchTableMenu.FLUID_GAUGE_W, ResearchTableMenu.FLUID_GAUGE_H); + } + + /** + * Renders the upper panel content based on current view mode. + */ + private void renderUpperPanel(GuiGraphics g, int x, int y, int mouseX, int mouseY) { + switch (currentView) { + case LIST -> { + // List view: search + list + detail pane handled in render() + } + case TREE -> { + // Tree view: render embedded tree + renderTreeView(g, x, y, mouseX, mouseY); + } + case PROGRESS -> { + // Progress view: detailed progress info + renderProgressView(g, x, y); } } - // Player inventory slots - for (int row = 0; row < 3; row++) { - for (int col = 0; col < 9; col++) { - drawSlotBg(g, x + ResearchTableMenu.PLAYER_INV_X + col * 18, y + ResearchTableMenu.PLAYER_INV_Y + row * 18); + } + + /** + * Renders the embedded tree view in the upper panel. + */ + private void renderTreeView(GuiGraphics g, int x, int y, int mouseX, int mouseY) { + int vx = x + ResearchTableMenu.PROGRESS_VIEW_X; + int vy = y + ResearchTableMenu.PROGRESS_VIEW_Y; + int vw = ResearchTableMenu.PROGRESS_VIEW_W; + int vh = ResearchTableMenu.PROGRESS_VIEW_H; + + // Background + g.fill(vx, vy, vx + vw, vy + vh, 0xFF1A1E2A); + + // TODO: Integrate tree rendering from ResearchTreeScreen + // For now, show a placeholder message + g.drawString(font, "Research Tree View", vx + 4, vy + 4, 0xFFD3D7E5, false); + g.drawString(font, "(Click 'List' to return to list view)", vx + 4, vy + 16, 0xFF888888, false); + + // Draw a mini-map style representation + Set completed = menu.getCompletedResearch(); + int nodeX = vx + 10; + int nodeY = vy + 36; + int nodeSize = 12; + int spacing = 20; + int col = 0; + int row = 0; + int maxCols = vw / spacing - 1; + + for (ResearchDefinition def : ResearchRegistry.getAll()) { + boolean isComplete = completed.contains(def.getId().toString()); + boolean isAvailable = def.getPrerequisites().isSatisfied(completed); + + int color; + if (isComplete) { + color = 0xFF55FF55; + } else if (isAvailable) { + color = def.getTier().getColor() | 0xFF000000; + } else { + color = 0xFF444444; + } + + int nx = nodeX + col * spacing; + int ny = nodeY + row * spacing; + + g.fill(nx, ny, nx + nodeSize, ny + nodeSize, color); + g.fill(nx, ny, nx + nodeSize, ny + 1, PANEL_BORDER_LIGHT); + g.fill(nx, ny, nx + 1, ny + nodeSize, PANEL_BORDER_LIGHT); + g.fill(nx + nodeSize - 1, ny, nx + nodeSize, ny + nodeSize, PANEL_BORDER_DARK); + g.fill(nx, ny + nodeSize - 1, nx + nodeSize, ny + nodeSize, PANEL_BORDER_DARK); + + col++; + if (col >= maxCols) { + col = 0; + row++; + if (nodeY + row * spacing + nodeSize > vy + vh - 8) break; } } - // Hotbar slots - for (int col = 0; col < 9; col++) { - drawSlotBg(g, x + ResearchTableMenu.HOTBAR_X + col * 18, y + ResearchTableMenu.HOTBAR_Y); + } + + /** + * Renders the progress view showing active research details. + * Shows: name, tier, category, description, costs, progress bar. + */ + private void renderProgressView(GuiGraphics g, int x, int y) { + int vx = x + ResearchTableMenu.PROGRESS_VIEW_X; + int vy = y + ResearchTableMenu.PROGRESS_VIEW_Y; + int vw = ResearchTableMenu.PROGRESS_VIEW_W; + int vh = ResearchTableMenu.PROGRESS_VIEW_H; + + // Background + g.fill(vx, vy, vx + vw, vy + vh, 0xFF1A1E2A); + g.fill(vx, vy, vx + vw, vy + 1, PANEL_BORDER_DARK); + g.fill(vx, vy, vx + 1, vy + vh, PANEL_BORDER_DARK); + g.fill(vx + vw - 1, vy, vx + vw, vy + vh, PANEL_BORDER_LIGHT); + g.fill(vx, vy + vh - 1, vx + vw, vy + vh, PANEL_BORDER_LIGHT); + + ResearchTableBlockEntity be = menu.getBlockEntity(); + ResearchDefinition activeDef = be.getActiveDefinition(); + + if (activeDef == null) { + g.drawString(font, "No active research", vx + 8, vy + 8, 0xFF666666, false); + return; } - // ── Slot labels ── - g.drawString(font, "Drive", x + 24, y + 28, 0xFFD3D7E5, false); - g.drawString(font, "Cube", x + 24, y + 64, 0xFFD3D7E5, false); - g.drawString(font, "Costs", x + 70, y + 28, 0xFFD3D7E5, false); + int textY = vy + 6; + int lineHeight = 11; + int tierColor = activeDef.getTier().getColor() | 0xFF000000; + + // Header: Research name with tier badge + String nameStr = activeDef.getDisplayName(); + String tierBadge = " [" + activeDef.getTier().getDisplayName() + "]"; + g.drawString(font, nameStr, vx + 8, textY, tierColor, false); + g.drawString(font, tierBadge, vx + 8 + font.width(nameStr), textY, 0xFF888888, false); + textY += lineHeight + 2; + + // Category line + if (activeDef.getCategory() != null && !activeDef.getCategory().isEmpty()) { + g.drawString(font, "Category: " + activeDef.getCategory(), vx + 8, textY, 0xFFCCAA00, false); + textY += lineHeight; + } - // ── Bucket slots ── - drawSlotBg(g, x + ResearchTableMenu.BUCKET_IN_X, y + ResearchTableMenu.BUCKET_IN_Y); - drawSlotBg(g, x + ResearchTableMenu.BUCKET_OUT_X, y + ResearchTableMenu.BUCKET_OUT_Y); - g.drawString(font, "\u25BC", x + ResearchTableMenu.BUCKET_IN_X + 3, y + ResearchTableMenu.BUCKET_IN_Y - 4, 0xFF55CCFF, false); - g.drawString(font, "\u25B2", x + ResearchTableMenu.BUCKET_OUT_X + 3, y + ResearchTableMenu.BUCKET_OUT_Y - 4, 0xFF999999, false); + // Description/flavor text (supports multi-line wrapping) + if (activeDef.getDescription() != null && !activeDef.getDescription().isEmpty()) { + String desc = activeDef.getDescription(); + int maxWidth = vw - 16; - // ── Idea chip slot ── - drawSlotBg(g, x + ResearchTableMenu.IDEA_CHIP_X, y + ResearchTableMenu.IDEA_CHIP_Y); - g.drawString(font, "Idea", x + ResearchTableMenu.IDEA_CHIP_X - 2, y + ResearchTableMenu.IDEA_CHIP_Y - 10, 0xFFD3D7E5, false); - renderIdeaChipOverlay(g, x + ResearchTableMenu.IDEA_CHIP_X, y + ResearchTableMenu.IDEA_CHIP_Y); + // Split into multiple lines if needed + List lines = new ArrayList<>(); + while (!desc.isEmpty() && lines.size() < 2) { + if (font.width(desc) <= maxWidth) { + lines.add(desc); + break; + } + // Find break point + int cutIdx = desc.length(); + while (cutIdx > 0 && font.width(desc.substring(0, cutIdx)) > maxWidth) { + cutIdx--; + } + // Try to break at space + int spaceIdx = desc.lastIndexOf(' ', cutIdx); + if (spaceIdx > cutIdx / 2) { + cutIdx = spaceIdx; + } + lines.add(desc.substring(0, cutIdx).trim()); + desc = desc.substring(cutIdx).trim(); + } + if (!desc.isEmpty() && lines.size() == 2) { + // Add ellipsis to last line if there's more + String lastLine = lines.get(1); + while (font.width(lastLine + "...") > maxWidth && lastLine.length() > 3) { + lastLine = lastLine.substring(0, lastLine.length() - 1); + } + lines.set(1, lastLine + "..."); + } - // ── Fluid gauge ── - drawFluidGauge(g, x + GAUGE_X, y + GAUGE_Y, GAUGE_W, GAUGE_H); + for (String line : lines) { + g.drawString(font, line, vx + 8, textY, 0xFFAAB0C0, false); + textY += lineHeight - 1; + } + } - // ── Progress bar ── - if (menu.isResearching()) { - float progress = Math.min(1.0f, menu.getScaledProgress()); - int filledWidth = Math.round(PROGRESS_W * progress); - - // Bar background - g.fill(x + PROGRESS_X, y + PROGRESS_Y, - x + PROGRESS_X + PROGRESS_W, y + PROGRESS_Y + PROGRESS_H, - 0xFF222222); - // Bar border - g.fill(x + PROGRESS_X - 1, y + PROGRESS_Y - 1, - x + PROGRESS_X + PROGRESS_W + 1, y + PROGRESS_Y, - PANEL_BORDER_DARK); - g.fill(x + PROGRESS_X - 1, y + PROGRESS_Y + PROGRESS_H, - x + PROGRESS_X + PROGRESS_W + 1, y + PROGRESS_Y + PROGRESS_H + 1, - PANEL_BORDER_LIGHT); - - // Filled portion (green gradient) - if (filledWidth > 0) { - int green = 0xCC + (int) (0x33 * progress); - int barColor = 0xFF000000 | (green << 8); - g.fill(x + PROGRESS_X, y + PROGRESS_Y, - x + PROGRESS_X + filledWidth, y + PROGRESS_Y + PROGRESS_H, - barColor); + // Costs summary (compact, single line) + StringBuilder costsStr = new StringBuilder(); + if (!activeDef.getItemCosts().isEmpty()) { + for (var cost : activeDef.getItemCosts()) { + if (costsStr.length() > 0) costsStr.append(", "); + costsStr.append(cost.getItem().getDescription().getString()).append(" x").append(cost.count()); } } - } + if (activeDef.getFluidCost() != null) { + if (costsStr.length() > 0) costsStr.append(" | "); + costsStr.append(activeDef.getFluidCost().amount()).append("mB ").append(activeDef.getFluidCost().getFluidName()); + } + if (costsStr.length() > 0) { + String costs = "Costs: " + costsStr; + if (font.width(costs) > vw - 16) { + while (font.width(costs + "...") > vw - 16 && costs.length() > 10) { + costs = costs.substring(0, costs.length() - 1); + } + costs += "..."; + } + g.drawString(font, costs, vx + 8, textY, 0xFF77AADD, false); + textY += lineHeight; + } - /** Draw an inset dark panel with a 1px bevelled border. */ - private void drawInsetPanel(GuiGraphics g, int px, int py, int pw, int ph) { - // Dark fill - g.fill(px, py, px + pw, py + ph, PANEL_BG); - g.fill(px + 1, py + 1, px + pw - 1, py + ph - 1, PANEL_INNER); - // Top/left shadow (darker) - g.fill(px, py, px + pw, py + 1, PANEL_BORDER_DARK); - g.fill(px, py, px + 1, py + ph, PANEL_BORDER_DARK); - // Bottom/right highlight (lighter) - g.fill(px + pw - 1, py, px + pw, py + ph, PANEL_BORDER_LIGHT); - g.fill(px, py + ph - 1, px + pw, py + ph, PANEL_BORDER_LIGHT); - } + // Progress info + textY += 4; + float progress = Math.min(1.0f, menu.getScaledProgress()); + int percent = (int) (progress * 100); + float totalSeconds = activeDef.getDurationSeconds(); + float remainingSeconds = totalSeconds * (1.0f - progress); + int mins = (int) (remainingSeconds / 60); + int secs = (int) (remainingSeconds % 60); + + String progressText = String.format("Progress: %d%% | Time remaining: %d:%02d", percent, mins, secs); + g.drawString(font, progressText, vx + 8, textY, 0xFFE6EAF5, false); + + // Progress bar + int barX = x + ResearchTableMenu.PROGRESS_BAR_X; + int barY = y + ResearchTableMenu.PROGRESS_BAR_Y; + int barW = ResearchTableMenu.PROGRESS_BAR_W; + int barH = ResearchTableMenu.PROGRESS_BAR_H; + + // Bar background + g.fill(barX, barY, barX + barW, barY + barH, 0xFF222222); + g.fill(barX, barY, barX + barW, barY + 1, PANEL_BORDER_DARK); + g.fill(barX, barY, barX + 1, barY + barH, PANEL_BORDER_DARK); + g.fill(barX + barW - 1, barY, barX + barW, barY + barH, PANEL_BORDER_LIGHT); + g.fill(barX, barY + barH - 1, barX + barW, barY + barH, PANEL_BORDER_LIGHT); + + // Filled portion with gradient effect + int filledWidth = Math.round((barW - 2) * progress); + if (filledWidth > 0) { + g.fill(barX + 1, barY + 1, barX + 1 + filledWidth, barY + barH - 1, tierColor); + // Highlight at top of bar + int highlightColor = (tierColor & 0x00FFFFFF) | 0x44FFFFFF; + g.fill(barX + 1, barY + 1, barX + 1 + filledWidth, barY + 2, highlightColor); + } - /** Draw the 18×18 slot background (Minecraft-style inset square). */ - private void drawSlotBg(GuiGraphics g, int sx, int sy) { - // The slot position in Minecraft is the top-left of the 16×16 inner area. - // The border starts 1px above/left. - int x0 = sx - 1; - int y0 = sy - 1; - g.fill(x0, y0, x0 + 18, y0 + 18, SLOT_BG); - g.fill(x0 + 1, y0 + 1, x0 + 17, y0 + 17, SLOT_INNER); - // Top/left border shadow - g.fill(x0, y0, x0 + 18, y0 + 1, PANEL_BORDER_DARK); - g.fill(x0, y0, x0 + 1, y0 + 18, PANEL_BORDER_DARK); + // Percentage text on bar (centered) + String pctStr = percent + "%"; + int textWidth = font.width(pctStr); + g.drawString(font, pctStr, barX + (barW - textWidth) / 2, barY + 2, 0xFFFFFFFF, true); } - /** - * Draw the vertical fluid gauge bar showing current tank contents. - * Fills from bottom to top, colored by fluid type. - */ private void drawFluidGauge(GuiGraphics g, int gx, int gy, int gw, int gh) { - // Frame (inset border) g.fill(gx - 1, gy - 1, gx + gw + 1, gy + gh + 1, PANEL_BORDER_DARK); g.fill(gx, gy, gx + gw, gy + gh, 0xFF222222); @@ -458,14 +658,12 @@ private void drawFluidGauge(GuiGraphics g, int gx, int gy, int gw, int gh) { int fluidColor = ModFluids.getFluidColor(fluidType); g.fill(gx, fillY, gx + gw, gy + gh, fluidColor); - // Subtle shine line at the top of the fill if (fillHeight > 2) { int shine = (fluidColor & 0x00FFFFFF) | 0x44000000; g.fill(gx, fillY, gx + gw, fillY + 1, shine); } } - // Bottom/right highlight g.fill(gx + gw, gy - 1, gx + gw + 1, gy + gh + 1, PANEL_BORDER_LIGHT); g.fill(gx - 1, gy + gh, gx + gw + 1, gy + gh + 1, PANEL_BORDER_LIGHT); } @@ -474,24 +672,17 @@ private void drawFluidGauge(GuiGraphics g, int gx, int gy, int gw, int gh) { public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { super.render(graphics, mouseX, mouseY, partialTick); - // Research list - renderResearchList(graphics, mouseX, mouseY); + // View-specific content + if (currentView == ViewMode.LIST) { + // Research list + renderResearchList(graphics, mouseX, mouseY); - // Active research name + percentage - if (menu.isResearching()) { - ResearchTableBlockEntity be = menu.getBlockEntity(); - ResearchDefinition activeDef = be.getActiveDefinition(); - if (activeDef != null) { - String activeName = activeDef.getDisplayName(); - int nameColor = activeDef.getTier().getColor() | 0xFF000000; - int percent = (int) (menu.getScaledProgress() * 100); - graphics.drawCenteredString(font, activeName + " " + percent + "%", - leftPos + PROGRESS_X + PROGRESS_W / 2, topPos + PROGRESS_Y - 10, nameColor); - } - } + // Detail pane for selected research + renderDetailPane(graphics); - // Tooltip on research row hover - renderResearchTooltip(graphics, mouseX, mouseY); + // Tooltip on research row hover + renderResearchTooltip(graphics, mouseX, mouseY); + } // Tooltip on fluid gauge hover renderFluidGaugeTooltip(graphics, mouseX, mouseY); @@ -502,60 +693,129 @@ public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTi renderTooltip(graphics, mouseX, mouseY); } - private void renderResearchList(GuiGraphics graphics, int mouseX, int mouseY) { - int x = leftPos + LIST_X; - int y = topPos + LIST_Y; + /** + * Renders the detail pane below the research list showing info about the selected research. + */ + private void renderDetailPane(GuiGraphics g) { + int x = leftPos + ResearchTableMenu.DETAIL_X; + int y = topPos + ResearchTableMenu.DETAIL_Y; + int w = ResearchTableMenu.DETAIL_W; + int h = ResearchTableMenu.DETAIL_H; + + // Detail pane background - use same padding as list (-2/+2) for alignment + g.fill(x - 2, y, x + w + 2, y + h, 0xFF1E2233); + g.fill(x - 2, y, x + w + 2, y + 1, PANEL_BORDER_DARK); + g.fill(x - 2, y, x - 1, y + h, PANEL_BORDER_DARK); + g.fill(x + w + 1, y, x + w + 2, y + h, PANEL_BORDER_LIGHT); + g.fill(x - 2, y + h - 1, x + w + 2, y + h, PANEL_BORDER_LIGHT); + + ResearchDefinition def = getSelectedDefinition(); + if (def == null) { + g.drawString(font, "Select a research entry", x + 2, y + 4, 0xFF666666, false); + return; + } + + int textY = y + 4; + int lineHeight = 10; + + // Line 1: Research name + tier badge + int nameColor = def.getTier().getColor() | 0xFF000000; + String nameStr = def.getDisplayName(); + String tierBadge = " [" + def.getTier().getDisplayName() + "]"; + String durationStr = String.format(" - %.0fs", def.getDurationSeconds()); + + g.drawString(font, nameStr, x + 4, textY, nameColor, false); + int textX = x + 4 + font.width(nameStr); + g.drawString(font, tierBadge, textX, textY, 0xFF888888, false); + textX += font.width(tierBadge); + g.drawString(font, durationStr, textX, textY, 0xFFAAB0C0, false); + textY += lineHeight; + + // Line 2: Description (if available) + String description = def.getDescription(); + if (description != null && !description.isEmpty()) { + if (font.width(description) > w - 12) { + while (font.width(description + "\u2026") > w - 12 && description.length() > 3) { + description = description.substring(0, description.length() - 1); + } + description += "\u2026"; + } + g.drawString(font, description, x + 4, textY, 0xFFAAB0C0, false); + textY += lineHeight; + } + + // Line 3: Costs summary + StringBuilder costs = new StringBuilder(); + if (!def.getItemCosts().isEmpty()) { + for (var cost : def.getItemCosts()) { + if (costs.length() > 0) costs.append(", "); + costs.append(cost.getItem().getDescription().getString()).append(" x").append(cost.count()); + } + } + if (def.getFluidCost() != null) { + if (costs.length() > 0) costs.append(" | "); + costs.append(def.getFluidCost().amount()).append("mB ").append(def.getFluidCost().getFluidName()); + } + if (costs.length() > 0) { + String costStr = costs.toString(); + if (font.width(costStr) > w - 12) { + while (font.width(costStr + "\u2026") > w - 12 && costStr.length() > 3) { + costStr = costStr.substring(0, costStr.length() - 1); + } + costStr += "\u2026"; + } + g.drawString(font, costStr, x + 4, textY, 0xFFAAB0C0, false); + } + } - // Header label - graphics.drawString(font, "Research", x, y - 12, 0xFFC8D2EE, false); + private void renderResearchList(GuiGraphics graphics, int mouseX, int mouseY) { + int x = leftPos + ResearchTableMenu.LIST_X; + int y = topPos + ResearchTableMenu.LIST_Y; + int listW = ResearchTableMenu.LIST_W; + int rowH = ResearchTableMenu.LIST_ROW_H; + int visibleRows = ResearchTableMenu.LIST_VISIBLE_ROWS; // List background - graphics.fill(x - 2, y - 2, x + LIST_W + 2, y + VISIBLE_ROWS * ROW_H + 2, LIST_BG); + graphics.fill(x - 2, y - 2, x + listW + 2, y + visibleRows * rowH + 2, LIST_BG); // Border - graphics.fill(x - 2, y - 2, x + LIST_W + 2, y - 1, PANEL_BORDER_DARK); - graphics.fill(x - 2, y - 2, x - 1, y + VISIBLE_ROWS * ROW_H + 2, PANEL_BORDER_DARK); - graphics.fill(x + LIST_W + 1, y - 2, x + LIST_W + 2, y + VISIBLE_ROWS * ROW_H + 2, PANEL_BORDER_LIGHT); - graphics.fill(x - 2, y + VISIBLE_ROWS * ROW_H + 1, x + LIST_W + 2, y + VISIBLE_ROWS * ROW_H + 2, PANEL_BORDER_LIGHT); + graphics.fill(x - 2, y - 2, x + listW + 2, y - 1, PANEL_BORDER_DARK); + graphics.fill(x - 2, y - 2, x - 1, y + visibleRows * rowH + 2, PANEL_BORDER_DARK); + graphics.fill(x + listW + 1, y - 2, x + listW + 2, y + visibleRows * rowH + 2, PANEL_BORDER_LIGHT); + graphics.fill(x - 2, y + visibleRows * rowH + 1, x + listW + 2, y + visibleRows * rowH + 2, PANEL_BORDER_LIGHT); - for (int i = 0; i < VISIBLE_ROWS; i++) { + for (int i = 0; i < visibleRows; i++) { int displayIdx = scrollOffset + i; if (displayIdx >= displayRows.size()) break; ListRow row = displayRows.get(displayIdx); - int rowY = y + i * ROW_H; + int rowY = y + i * rowH; if (row.isHeader()) { - // Category header row String headerLabel = "\u25B8 " + row.headerText(); - if (font.width(headerLabel) > LIST_W - 4) { - // Truncate to fit - while (font.width(headerLabel + "\u2026") > LIST_W - 4 && headerLabel.length() > 3) { + if (font.width(headerLabel) > listW - 4) { + while (font.width(headerLabel + "\u2026") > listW - 4 && headerLabel.length() > 3) { headerLabel = headerLabel.substring(0, headerLabel.length() - 1); } headerLabel += "\u2026"; } - graphics.fill(x, rowY, x + LIST_W, rowY + ROW_H, 0xFF2A2A1A); + graphics.fill(x, rowY, x + listW, rowY + rowH, 0xFF2A2A1A); graphics.drawString(font, headerLabel, x + 3, rowY + 3, 0xFFCCAA00, false); } else { ResearchDefinition def = row.definition(); boolean locked = !isPrerequisiteMet(def); - // Selection highlight if (def.getId().equals(selectedId)) { - graphics.fill(x, rowY, x + LIST_W, rowY + ROW_H, locked ? 0xFF442222 : 0xFF334488); - } else if (mouseX >= x && mouseX < x + LIST_W && mouseY >= rowY && mouseY < rowY + ROW_H) { - // Hover highlight - graphics.fill(x, rowY, x + LIST_W, rowY + ROW_H, 0xFF2A2A3A); + graphics.fill(x, rowY, x + listW, rowY + rowH, locked ? 0xFF442222 : 0xFF334488); + } else if (mouseX >= x && mouseX < x + listW && mouseY >= rowY && mouseY < rowY + rowH) { + graphics.fill(x, rowY, x + listW, rowY + rowH, 0xFF2A2A3A); } - // Build label String prefix = locked ? "\uD83D\uDD12 " : ""; String name = def.getDisplayName(); String label = prefix + name; - // Truncate to fit list width - if (font.width(label) > LIST_W - 6) { - while (font.width(label + "\u2026") > LIST_W - 6 && label.length() > 3) { + if (font.width(label) > listW - 6) { + while (font.width(label + "\u2026") > listW - 6 && label.length() > 3) { label = label.substring(0, label.length() - 1); } label += "\u2026"; @@ -568,25 +828,25 @@ private void renderResearchList(GuiGraphics graphics, int mouseX, int mouseY) { // Scroll indicators if (scrollOffset > 0) { - graphics.drawString(font, "\u25B2", x + LIST_W - 8, y - 12, 0xAAAAAAA, false); + graphics.drawString(font, "\u25B2", x + listW - 8, y - 12, 0xAAAAAAA, false); } - if (scrollOffset + VISIBLE_ROWS < displayRows.size()) { - graphics.drawString(font, "\u25BC", x + LIST_W - 8, y + VISIBLE_ROWS * ROW_H + 3, 0xAAAAAA, false); + if (scrollOffset + visibleRows < displayRows.size()) { + graphics.drawString(font, "\u25BC", x + listW - 8, y + visibleRows * rowH + 3, 0xAAAAAA, false); } } - /** - * Renders a tooltip when hovering over a research list entry. - */ private void renderResearchTooltip(GuiGraphics graphics, int mouseX, int mouseY) { - int x = leftPos + LIST_X; - int y = topPos + LIST_Y; + int x = leftPos + ResearchTableMenu.LIST_X; + int y = topPos + ResearchTableMenu.LIST_Y; + int listW = ResearchTableMenu.LIST_W; + int rowH = ResearchTableMenu.LIST_ROW_H; + int visibleRows = ResearchTableMenu.LIST_VISIBLE_ROWS; - if (mouseX < x || mouseX >= x + LIST_W || mouseY < y || mouseY >= y + VISIBLE_ROWS * ROW_H) { + if (mouseX < x || mouseX >= x + listW || mouseY < y || mouseY >= y + visibleRows * rowH) { return; } - int row = (mouseY - y) / ROW_H; + int row = (mouseY - y) / rowH; int displayIdx = scrollOffset + row; if (displayIdx < 0 || displayIdx >= displayRows.size()) return; @@ -596,28 +856,23 @@ private void renderResearchTooltip(GuiGraphics graphics, int mouseX, int mouseY) ResearchDefinition def = listRow.definition(); List tooltip = new ArrayList<>(); - // Name tooltip.add(Component.literal(def.getDisplayName()) .withStyle(s -> s.withColor(def.getTier().getColor()))); - // Description if (def.getDescription() != null) { tooltip.add(Component.literal(def.getDescription()) .withStyle(s -> s.withColor(0xAAAAAA).withItalic(true))); } - // Category if (def.getCategory() != null) { tooltip.add(Component.literal("Category: " + def.getCategory()) .withStyle(s -> s.withColor(0xCCAA00))); } - // Tier + Duration tooltip.add(Component.literal("Tier: " + def.getTier().getDisplayName() + " | " + String.format("%.0fs", def.getDurationSeconds())) .withStyle(s -> s.withColor(0x888888))); - // Item costs if (!def.getItemCosts().isEmpty()) { tooltip.add(Component.literal("Costs:").withStyle(s -> s.withColor(0xCCCC00))); for (ItemCost cost : def.getItemCosts()) { @@ -626,7 +881,6 @@ private void renderResearchTooltip(GuiGraphics graphics, int mouseX, int mouseY) } } - // Fluid cost FluidCost fluidCost = def.getFluidCost(); if (fluidCost != null) { String fluidName = fluidCost.getFluidName(); @@ -635,7 +889,6 @@ private void renderResearchTooltip(GuiGraphics graphics, int mouseX, int mouseY) .withStyle(s -> s.withColor(0x55CCFF))); } - // Idea chip requirement if (def.getIdeaChip().isPresent()) { ItemStack chip = def.getIdeaChip().get(); boolean satisfied = isIdeaChipSatisfied(def); @@ -645,7 +898,6 @@ private void renderResearchTooltip(GuiGraphics graphics, int mouseX, int mouseY) .withStyle(s -> s.withColor(color))); } - // Prerequisites status if (!(def.getPrerequisites() instanceof NonePrerequisite)) { boolean met = isPrerequisiteMet(def); String prereqIcon = met ? "\u2714" : "\u2718"; @@ -654,7 +906,6 @@ private void renderResearchTooltip(GuiGraphics graphics, int mouseX, int mouseY) .withStyle(s -> s.withColor(prereqColor))); } - // Recipe pool — resolve to output item names if (def.hasRecipePool()) { StringBuilder rewardLine = new StringBuilder("Rewards: "); boolean first = true; @@ -670,14 +921,13 @@ private void renderResearchTooltip(GuiGraphics graphics, int mouseX, int mouseY) graphics.renderTooltip(font, tooltip, Optional.empty(), mouseX, mouseY); } - /** - * Renders a tooltip when hovering over the fluid gauge bar. - */ private void renderFluidGaugeTooltip(GuiGraphics graphics, int mouseX, int mouseY) { - int gx = leftPos + GAUGE_X; - int gy = topPos + GAUGE_Y; + int gx = leftPos + ResearchTableMenu.FLUID_GAUGE_X; + int gy = topPos + ResearchTableMenu.FLUID_GAUGE_Y; + int gw = ResearchTableMenu.FLUID_GAUGE_W; + int gh = ResearchTableMenu.FLUID_GAUGE_H; - if (mouseX < gx - 1 || mouseX >= gx + GAUGE_W + 1 || mouseY < gy - 1 || mouseY >= gy + GAUGE_H + 1) { + if (mouseX < gx - 1 || mouseX >= gx + gw + 1 || mouseY < gy - 1 || mouseY >= gy + gh + 1) { return; } @@ -703,9 +953,6 @@ private void renderFluidGaugeTooltip(GuiGraphics graphics, int mouseX, int mouse graphics.renderTooltip(font, tooltip, Optional.empty(), mouseX, mouseY); } - /** - * Renders a tooltip when hovering over the idea chip slot. - */ private void renderIdeaChipTooltip(GuiGraphics graphics, int mouseX, int mouseY) { int sx = leftPos + ResearchTableMenu.IDEA_CHIP_X; int sy = topPos + ResearchTableMenu.IDEA_CHIP_Y; @@ -714,7 +961,6 @@ private void renderIdeaChipTooltip(GuiGraphics graphics, int mouseX, int mouseY) return; } - // Don't show custom tooltip if a slot item tooltip is already being shown ItemStack slotStack = menu.getBlockEntity().getInventory() .getStackInSlot(ResearchTableBlockEntity.SLOT_IDEA_CHIP); if (!slotStack.isEmpty()) return; @@ -744,24 +990,30 @@ private void renderIdeaChipTooltip(GuiGraphics graphics, int mouseX, int mouseY) @Override public boolean mouseClicked(double mouseX, double mouseY, int button) { - int x = leftPos + LIST_X; - int y = topPos + LIST_Y; - - if (mouseX >= x && mouseX < x + LIST_W && mouseY >= y && mouseY < y + VISIBLE_ROWS * ROW_H) { - int row = (int) ((mouseY - y) / ROW_H); - int displayIdx = scrollOffset + row; - if (displayIdx >= 0 && displayIdx < displayRows.size()) { - ListRow listRow = displayRows.get(displayIdx); - if (!listRow.isHeader()) { - ResearchDefinition def = listRow.definition(); - selectedId = def.getId(); - for (int i = 0; i < availableResearch.size(); i++) { - if (availableResearch.get(i).getId().equals(selectedId)) { - selectedIndex = i; - break; + // List clicks only work in LIST view + if (currentView == ViewMode.LIST) { + int x = leftPos + ResearchTableMenu.LIST_X; + int y = topPos + ResearchTableMenu.LIST_Y; + int listW = ResearchTableMenu.LIST_W; + int rowH = ResearchTableMenu.LIST_ROW_H; + int visibleRows = ResearchTableMenu.LIST_VISIBLE_ROWS; + + if (mouseX >= x && mouseX < x + listW && mouseY >= y && mouseY < y + visibleRows * rowH) { + int row = (int) ((mouseY - y) / rowH); + int displayIdx = scrollOffset + row; + if (displayIdx >= 0 && displayIdx < displayRows.size()) { + ListRow listRow = displayRows.get(displayIdx); + if (!listRow.isHeader()) { + ResearchDefinition def = listRow.definition(); + selectedId = def.getId(); + for (int i = 0; i < availableResearch.size(); i++) { + if (availableResearch.get(i).getId().equals(selectedId)) { + selectedIndex = i; + break; + } } + return true; } - return true; } } } @@ -771,16 +1023,22 @@ public boolean mouseClicked(double mouseX, double mouseY, int button) { @Override public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) { - int x = leftPos + LIST_X; - int y = topPos + LIST_Y; - - if (mouseX >= x && mouseX < x + LIST_W && mouseY >= y && mouseY < y + VISIBLE_ROWS * ROW_H + 14) { - if (scrollY > 0 && scrollOffset > 0) { - scrollOffset--; - } else if (scrollY < 0 && scrollOffset + VISIBLE_ROWS < displayRows.size()) { - scrollOffset++; + // List scrolling only works in LIST view + if (currentView == ViewMode.LIST) { + int x = leftPos + ResearchTableMenu.LIST_X; + int y = topPos + ResearchTableMenu.LIST_Y; + int listW = ResearchTableMenu.LIST_W; + int rowH = ResearchTableMenu.LIST_ROW_H; + int visibleRows = ResearchTableMenu.LIST_VISIBLE_ROWS; + + if (mouseX >= x && mouseX < x + listW && mouseY >= y && mouseY < y + visibleRows * rowH + 14) { + if (scrollY > 0 && scrollOffset > 0) { + scrollOffset--; + } else if (scrollY < 0 && scrollOffset + visibleRows < displayRows.size()) { + scrollOffset++; + } + return true; } - return true; } return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY); @@ -788,12 +1046,10 @@ public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, doubl @Override public boolean keyPressed(int keyCode, int scanCode, int modifiers) { - // If search box is focused and Escape is pressed, clear the search first if (keyCode == 256 && searchBox != null && searchBox.isFocused() && !searchBox.getValue().isEmpty()) { searchBox.setValue(""); return true; } - // If search box is focused, let it consume typing keys instead of closing the screen if (searchBox != null && searchBox.isFocused()) { return searchBox.keyPressed(keyCode, scanCode, modifiers) || super.keyPressed(keyCode, scanCode, modifiers); } @@ -810,9 +1066,10 @@ public boolean charTyped(char c, int modifiers) { @Override protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) { - // Dark title for the light outer strip. - graphics.drawString(this.font, this.title, this.titleLabelX, this.titleLabelY, 0xFF343841, false); - // Inventory label on dark panel should be bright. + // Don't render title in LIST view - it would overlap the search bar + if (currentView != ViewMode.LIST) { + graphics.drawString(this.font, this.title, this.titleLabelX, this.titleLabelY, 0xFF343841, false); + } graphics.drawString(this.font, this.playerInventoryTitle, this.inventoryLabelX, this.inventoryLabelY, 0xFFE6EAF5, false); } } diff --git a/src/main/java/com/researchcube/client/screen/ResearchTreeScreen.java b/src/main/java/com/researchcube/client/screen/ResearchTreeScreen.java index bdaa1eb..752ef75 100644 --- a/src/main/java/com/researchcube/client/screen/ResearchTreeScreen.java +++ b/src/main/java/com/researchcube/client/screen/ResearchTreeScreen.java @@ -1,8 +1,12 @@ package com.researchcube.client.screen; +import com.researchcube.ResearchCubeMod; import com.researchcube.block.ResearchTableBlockEntity; import com.researchcube.menu.ResearchTableMenu; +import com.researchcube.network.CancelResearchPacket; import com.researchcube.network.StartResearchPacket; +import com.researchcube.network.WipeTankPacket; +import com.researchcube.registry.ModFluids; import com.researchcube.research.FluidCost; import com.researchcube.research.ItemCost; import com.researchcube.research.ResearchDefinition; @@ -12,6 +16,7 @@ import com.researchcube.research.prerequisite.OrPrerequisite; import com.researchcube.research.prerequisite.Prerequisite; import com.researchcube.research.prerequisite.SinglePrerequisite; +import com.researchcube.util.IdeaChipMatcher; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.components.Button; @@ -19,6 +24,7 @@ import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; import net.neoforged.neoforge.network.PacketDistributor; @@ -34,13 +40,22 @@ /** * Alternative research UI that visualizes the research dependency graph. + * + * Uses same layout dimensions as ResearchTableScreen (470x260) to match menu slot positions. + * Upper panel: Graph viewport with zoom/pan + * Lower section: Machine panel (Drive, Cube, Costs, etc.) + Player inventory */ public class ResearchTreeScreen extends AbstractContainerScreen { - private static final int BG_OUTER = 0xFFC6C6C6; - private static final int PANEL_BG = 0xFF252838; - private static final int PANEL_DARK = 0xFF121521; - private static final int PANEL_LIGHT = 0xFF5A6078; + // ── Texture ── + private static final ResourceLocation TEXTURE = + ResourceLocation.fromNamespaceAndPath(ResearchCubeMod.MOD_ID, "textures/gui/research_table.png"); + private static final int TEX_W = ResearchTableMenu.GUI_WIDTH; + private static final int TEX_H = ResearchTableMenu.GUI_HEIGHT; + + // Colors (for dynamic elements) + private static final int PANEL_BORDER_LIGHT = 0xFF7E87A6; + private static final int PANEL_BORDER_DARK = 0xFF1A1A1A; private static final int GRAPH_BG = 0xFF171A26; private static final int EDGE_SINGLE = 0xFF9CA3AF; @@ -52,10 +67,11 @@ public class ResearchTreeScreen extends AbstractContainerScreen onStartResearch()) - .bounds(leftPos + 22, topPos + 104, 140, 18) - .build()); - - this.listButton = addRenderableWidget(Button.builder(Component.literal("List View"), b -> openListView()) - .bounds(leftPos + 22, topPos + 126, 62, 18) + // Start button + this.startButton = addRenderableWidget(Button.builder(Component.literal("Start"), b -> onStartResearch()) + .bounds(leftPos + ResearchTableMenu.START_BTN_X, topPos + ResearchTableMenu.BUTTON_Y, + ResearchTableMenu.BUTTON_W, ResearchTableMenu.BUTTON_H) .build()); - this.fitButton = addRenderableWidget(Button.builder(Component.literal("Fit"), b -> fitGraphToViewport()) - .bounds(leftPos + 88, topPos + 126, 36, 18) + // Cancel/Stop button + this.cancelButton = addRenderableWidget(Button.builder(Component.literal("Stop"), b -> onCancelResearch()) + .bounds(leftPos + ResearchTableMenu.STOP_BTN_X, topPos + ResearchTableMenu.BUTTON_Y, + ResearchTableMenu.BUTTON_W, ResearchTableMenu.BUTTON_H) .build()); - this.zoomOutButton = addRenderableWidget(Button.builder(Component.literal("-"), b -> adjustZoom(-0.12f)) - .bounds(leftPos + 128, topPos + 126, 18, 18) + // Wipe tank button + this.wipeButton = addRenderableWidget(Button.builder(Component.literal("Wipe"), b -> onWipeTank()) + .bounds(leftPos + ResearchTableMenu.WIPE_BTN_X, topPos + ResearchTableMenu.BUTTON_Y, + ResearchTableMenu.BUTTON_W, ResearchTableMenu.BUTTON_H) .build()); - this.zoomInButton = addRenderableWidget(Button.builder(Component.literal("+"), b -> adjustZoom(0.12f)) - .bounds(leftPos + 150, topPos + 126, 18, 18) + // List view button (switches back to list view) + this.listButton = addRenderableWidget(Button.builder(Component.literal("List"), b -> openListView()) + .bounds(leftPos + ResearchTableMenu.LIST_BTN_X, topPos + ResearchTableMenu.TREE_BTN_Y, + ResearchTableMenu.LIST_BTN_W, ResearchTableMenu.LIST_BTN_H) .build()); buildGraph(); @@ -157,12 +176,12 @@ public void containerTick() { clampPan(); } - boolean canStart = !menu.isResearching() && selectedId != null; - if (canStart) { - ResearchDefinition selected = ResearchRegistry.get(selectedId); - canStart = selected != null && isPrerequisiteMet(selected); - } + // Update button states (same logic as ResearchTableScreen) + ResearchDefinition selectedDef = getSelectedDefinition(); + boolean canStart = !menu.isResearching() && selectedDef != null && canStartResearch(selectedDef); startButton.active = canStart; + cancelButton.active = menu.isResearching(); + wipeButton.active = menu.getFluidAmount() > 0; } private void openListView() { @@ -174,10 +193,71 @@ private void openListView() { private void onStartResearch() { if (selectedId == null || menu.isResearching()) return; ResearchDefinition def = ResearchRegistry.get(selectedId); - if (def == null || !isPrerequisiteMet(def)) return; + if (def == null || !canStartResearch(def)) return; ResearchTableBlockEntity be = menu.getBlockEntity(); PacketDistributor.sendToServer(new StartResearchPacket(be.getBlockPos(), selectedId.toString())); + + // Switch to list view to show the progress bar + openListView(); + } + + private void onCancelResearch() { + if (!menu.isResearching()) return; + ResearchTableBlockEntity be = menu.getBlockEntity(); + PacketDistributor.sendToServer(new CancelResearchPacket(be.getBlockPos())); + } + + private void onWipeTank() { + ResearchTableBlockEntity be = menu.getBlockEntity(); + PacketDistributor.sendToServer(new WipeTankPacket(be.getBlockPos())); + } + + private boolean canStartResearch(ResearchDefinition def) { + if (!isPrerequisiteMet(def)) return false; + if (!isIdeaChipSatisfied(def)) return false; + if (!hasRequiredItems(def)) return false; + if (!hasRequiredFluid(def)) return false; + return true; + } + + private boolean hasRequiredItems(ResearchDefinition def) { + if (def.getItemCosts().isEmpty()) return true; + Map available = new HashMap<>(); + for (int i = 0; i < 6; i++) { + int slotIndex = ResearchTableBlockEntity.COST_SLOT_START + i; + ItemStack stack = menu.getSlot(slotIndex).getItem(); + if (!stack.isEmpty()) { + available.merge(stack.getItem(), stack.getCount(), Integer::sum); + } + } + for (ItemCost cost : def.getItemCosts()) { + int availableCount = available.getOrDefault(cost.getItem(), 0); + if (availableCount < cost.count()) return false; + } + return true; + } + + private boolean hasRequiredFluid(ResearchDefinition def) { + FluidCost fluidCost = def.getFluidCost(); + if (fluidCost == null) return true; + int tankFluidType = menu.getFluidType(); + int tankAmount = menu.getFluidAmount(); + int requiredType = ModFluids.getFluidIndex(fluidCost.getFluid()); + if (tankFluidType != requiredType) return false; + return tankAmount >= fluidCost.amount(); + } + + private boolean isIdeaChipSatisfied(ResearchDefinition def) { + if (def.getIdeaChip().isEmpty()) return true; + ItemStack required = def.getIdeaChip().get(); + ItemStack candidate = menu.getSlot(ResearchTableBlockEntity.SLOT_IDEA_CHIP).getItem(); + return IdeaChipMatcher.matches(required, candidate); + } + + private ResearchDefinition getSelectedDefinition() { + if (selectedId == null) return null; + return ResearchRegistry.get(selectedId); } private boolean isPrerequisiteMet(ResearchDefinition def) { @@ -398,90 +478,82 @@ protected void renderBg(GuiGraphics g, float partialTick, int mouseX, int mouseY int x = leftPos; int y = topPos; - g.fill(x, y, x + imageWidth, y + imageHeight, BG_OUTER); - g.fill(x, y, x + imageWidth, y + 1, 0xFFFFFFFF); - g.fill(x, y, x + 1, y + imageHeight, 0xFFFFFFFF); - g.fill(x + imageWidth - 1, y, x + imageWidth, y + imageHeight, PANEL_DARK); - g.fill(x, y + imageHeight - 1, x + imageWidth, y + imageHeight, PANEL_DARK); - - drawPanel(g, x + 10, y + 20, imageWidth - 20, 132); - drawPanel(g, x + 10, y + 156, imageWidth - 20, 122); - - // Left utility/slot dock tied to the existing ResearchTableMenu slots. - drawPanel(g, x + 20, y + 34, 160, 118); - drawSlotBg(g, x + ResearchTableMenu.DRIVE_X, y + ResearchTableMenu.DRIVE_Y); - drawSlotBg(g, x + ResearchTableMenu.CUBE_X, y + ResearchTableMenu.CUBE_Y); - for (int row = 0; row < 2; row++) { - for (int col = 0; col < 3; col++) { - drawSlotBg(g, x + ResearchTableMenu.COST_X + col * 18, y + ResearchTableMenu.COST_Y + row * 18); - } - } - drawSlotBg(g, x + ResearchTableMenu.BUCKET_IN_X, y + ResearchTableMenu.BUCKET_IN_Y); - drawSlotBg(g, x + ResearchTableMenu.BUCKET_OUT_X, y + ResearchTableMenu.BUCKET_OUT_Y); - drawFluidGauge(g, x + 154, y + 36, 16, 58); + // ── Static background from texture (same as ResearchTableScreen) ── + g.blit(TEXTURE, x, y, 0, 0, imageWidth, imageHeight, TEX_W, TEX_H); - // Player inventory slot backgrounds - for (int row = 0; row < 3; row++) { - for (int col = 0; col < 9; col++) { - drawSlotBg(g, x + ResearchTableMenu.PLAYER_INV_X + col * 18, y + ResearchTableMenu.PLAYER_INV_Y + row * 18); - } - } - for (int col = 0; col < 9; col++) { - drawSlotBg(g, x + ResearchTableMenu.HOTBAR_X + col * 18, y + ResearchTableMenu.HOTBAR_Y); - } + // ── Slot labels (above slot row) ── + int labelY = y + ResearchTableMenu.LABEL_Y; + g.drawString(font, "Dr", x + ResearchTableMenu.DRIVE_X + 2, labelY, 0xFFD3D7E5, false); + g.drawString(font, "Cb", x + ResearchTableMenu.CUBE_X + 2, labelY, 0xFFD3D7E5, false); + g.drawString(font, "Id", x + ResearchTableMenu.IDEA_CHIP_X + 2, labelY, 0xFFD3D7E5, false); + g.drawString(font, "Costs", x + ResearchTableMenu.COST_X, labelY, 0xFFD3D7E5, false); + g.drawString(font, "Fl", x + ResearchTableMenu.FLUID_GAUGE_X + 3, labelY, 0xFFD3D7E5, false); + g.drawString(font, "I/O", x + ResearchTableMenu.BUCKET_IN_X, labelY, 0xFFD3D7E5, false); - // Header strip - g.fill(x + 20, y + 22, x + imageWidth - 20, y + 32, 0xFF1C2030); + // ── Idea chip dynamic overlay ── + renderIdeaChipOverlay(g, x + ResearchTableMenu.IDEA_CHIP_X, y + ResearchTableMenu.IDEA_CHIP_Y); + // ── Fluid gauge (dynamic) ── + drawFluidGauge(g, x + ResearchTableMenu.FLUID_GAUGE_X, y + ResearchTableMenu.FLUID_GAUGE_Y, + ResearchTableMenu.FLUID_GAUGE_W, ResearchTableMenu.FLUID_GAUGE_H); + + // Graph viewport (render on top of texture background) int gx = x + GRAPH_X; int gy = y + GRAPH_Y; g.fill(gx, gy, gx + GRAPH_W, gy + GRAPH_H, GRAPH_BG); - g.fill(gx, gy, gx + GRAPH_W, gy + 1, PANEL_DARK); - g.fill(gx, gy, gx + 1, gy + GRAPH_H, PANEL_DARK); - g.fill(gx + GRAPH_W - 1, gy, gx + GRAPH_W, gy + GRAPH_H, PANEL_LIGHT); - g.fill(gx, gy + GRAPH_H - 1, gx + GRAPH_W, gy + GRAPH_H, PANEL_LIGHT); + g.fill(gx, gy, gx + GRAPH_W, gy + 1, PANEL_BORDER_DARK); + g.fill(gx, gy, gx + 1, gy + GRAPH_H, PANEL_BORDER_DARK); + g.fill(gx + GRAPH_W - 1, gy, gx + GRAPH_W, gy + GRAPH_H, PANEL_BORDER_LIGHT); + g.fill(gx, gy + GRAPH_H - 1, gx + GRAPH_W, gy + GRAPH_H, PANEL_BORDER_LIGHT); g.enableScissor(gx + 1, gy + 1, gx + GRAPH_W - 1, gy + GRAPH_H - 1); drawEdges(g, gx, gy); drawNodes(g, gx, gy); g.disableScissor(); + + // ── Edge legend (inside graph viewport, top-left corner) ── + int legendX = gx + 4; + int legendY = gy + 4; + g.fill(legendX - 2, legendY - 2, legendX + 62, legendY + 12, 0xAA1A1E2A); + g.drawString(font, "AND", legendX, legendY, EDGE_AND, false); + g.drawString(font, "OR", legendX + 22, legendY, EDGE_OR, false); + g.drawString(font, "S", legendX + 38, legendY, EDGE_SINGLE, false); } - private void drawSlotBg(GuiGraphics g, int sx, int sy) { - int x0 = sx - 1; - int y0 = sy - 1; - g.fill(x0, y0, x0 + 18, y0 + 18, 0xFF8B8B8B); - g.fill(x0 + 1, y0 + 1, x0 + 17, y0 + 17, 0xFF30303A); - g.fill(x0, y0, x0 + 18, y0 + 1, 0xFF151728); - g.fill(x0, y0, x0 + 1, y0 + 18, 0xFF151728); + private void renderIdeaChipOverlay(GuiGraphics g, int sx, int sy) { + ResearchDefinition selected = getSelectedDefinition(); + if (selected == null || selected.getIdeaChip().isEmpty()) { + g.fill(sx, sy, sx + 16, sy + 16, 0x88000000); + } else if (!isIdeaChipSatisfied(selected)) { + int x0 = sx - 1; + int y0 = sy - 1; + g.fill(x0, y0, x0 + 18, y0 + 1, 0xFFFF3333); + g.fill(x0, y0 + 17, x0 + 18, y0 + 18, 0xFFFF3333); + g.fill(x0, y0, x0 + 1, y0 + 18, 0xFFFF3333); + g.fill(x0 + 17, y0, x0 + 18, y0 + 18, 0xFFFF3333); + } } private void drawFluidGauge(GuiGraphics g, int gx, int gy, int gw, int gh) { - g.fill(gx - 1, gy - 1, gx + gw + 1, gy + gh + 1, 0xFF121521); - g.fill(gx, gy, gx + gw, gy + gh, 0xFF1B2030); + g.fill(gx - 1, gy - 1, gx + gw + 1, gy + gh + 1, PANEL_BORDER_DARK); + g.fill(gx, gy, gx + gw, gy + gh, 0xFF222222); int fluidAmount = menu.getFluidAmount(); int fluidType = menu.getFluidType(); if (fluidAmount > 0 && fluidType > 0) { int fillHeight = Math.min(gh, Math.round((float) gh * fluidAmount / ResearchTableBlockEntity.TANK_CAPACITY)); int fillY = gy + gh - fillHeight; - int color = switch (fluidType) { - case 1 -> 0xFF00C8FF; - case 2 -> 0xFFB05DFF; - case 3 -> 0xFFFFC741; - case 4 -> 0xFFFF76D6; - default -> 0xFF6C768E; - }; + int color = ModFluids.getFluidColor(fluidType); g.fill(gx, fillY, gx + gw, gy + gh, color); + + if (fillHeight > 2) { + int shine = (color & 0x00FFFFFF) | 0x44000000; + g.fill(gx, fillY, gx + gw, fillY + 1, shine); + } } - } - private void drawPanel(GuiGraphics g, int px, int py, int pw, int ph) { - g.fill(px, py, px + pw, py + ph, PANEL_BG); - g.fill(px, py, px + pw, py + 1, PANEL_DARK); - g.fill(px, py, px + 1, py + ph, PANEL_DARK); - g.fill(px + pw - 1, py, px + pw, py + ph, PANEL_LIGHT); - g.fill(px, py + ph - 1, px + pw, py + ph, PANEL_LIGHT); + g.fill(gx + gw, gy - 1, gx + gw + 1, gy + gh + 1, PANEL_BORDER_LIGHT); + g.fill(gx - 1, gy + gh, gx + gw + 1, gy + gh + 1, PANEL_BORDER_LIGHT); } private void drawEdges(GuiGraphics g, int graphScreenX, int graphScreenY) { @@ -666,12 +738,81 @@ public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, doubl public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { super.render(graphics, mouseX, mouseY, partialTick); + // Node tooltip Optional hovered = getHoveredNode(mouseX, mouseY); hovered.ifPresent(node -> renderNodeTooltip(graphics, node, mouseX, mouseY)); + // Fluid gauge tooltip + renderFluidGaugeTooltip(graphics, mouseX, mouseY); + + // Idea chip tooltip + renderIdeaChipTooltip(graphics, mouseX, mouseY); + renderTooltip(graphics, mouseX, mouseY); } + private void renderFluidGaugeTooltip(GuiGraphics graphics, int mouseX, int mouseY) { + int gx = leftPos + ResearchTableMenu.FLUID_GAUGE_X; + int gy = topPos + ResearchTableMenu.FLUID_GAUGE_Y; + int gw = ResearchTableMenu.FLUID_GAUGE_W; + int gh = ResearchTableMenu.FLUID_GAUGE_H; + + if (mouseX < gx - 1 || mouseX >= gx + gw + 1 || mouseY < gy - 1 || mouseY >= gy + gh + 1) { + return; + } + + List tooltip = new ArrayList<>(); + int fluidAmount = menu.getFluidAmount(); + int fluidType = menu.getFluidType(); + + if (fluidAmount > 0 && fluidType > 0) { + int color = ModFluids.getFluidColor(fluidType); + tooltip.add(Component.literal(ModFluids.getFluidName(fluidType)) + .withStyle(s -> s.withColor(color & 0x00FFFFFF))); + tooltip.add(Component.literal(fluidAmount + " / " + ResearchTableBlockEntity.TANK_CAPACITY + " mB") + .withStyle(s -> s.withColor(0xBBBBBB))); + } else { + tooltip.add(Component.literal("Empty") + .withStyle(s -> s.withColor(0x888888))); + tooltip.add(Component.literal("0 / " + ResearchTableBlockEntity.TANK_CAPACITY + " mB") + .withStyle(s -> s.withColor(0xBBBBBB))); + } + tooltip.add(Component.literal("Insert fluid buckets below") + .withStyle(s -> s.withColor(0x666666).withItalic(true))); + + graphics.renderTooltip(font, tooltip, Optional.empty(), mouseX, mouseY); + } + + private void renderIdeaChipTooltip(GuiGraphics graphics, int mouseX, int mouseY) { + int sx = leftPos + ResearchTableMenu.IDEA_CHIP_X; + int sy = topPos + ResearchTableMenu.IDEA_CHIP_Y; + + if (mouseX < sx - 1 || mouseX >= sx + 17 || mouseY < sy - 1 || mouseY >= sy + 17) { + return; + } + + ItemStack slotStack = menu.getSlot(ResearchTableBlockEntity.SLOT_IDEA_CHIP).getItem(); + if (!slotStack.isEmpty()) return; + + List tooltip = new ArrayList<>(); + ResearchDefinition selected = getSelectedDefinition(); + + if (selected == null || selected.getIdeaChip().isEmpty()) { + tooltip.add(Component.literal("Idea Chip Slot") + .withStyle(s -> s.withColor(0x888888))); + tooltip.add(Component.literal("No idea chip required for this research.") + .withStyle(s -> s.withColor(0x666666).withItalic(true))); + } else { + ItemStack required = selected.getIdeaChip().get(); + tooltip.add(Component.literal("Idea Chip Slot") + .withStyle(s -> s.withColor(0xFF5555))); + tooltip.add(Component.literal("Requires: " + required.getHoverName().getString()) + .withStyle(s -> s.withColor(0xFFAAAA))); + } + + graphics.renderTooltip(font, tooltip, Optional.empty(), mouseX, mouseY); + } + private void renderNodeTooltip(GuiGraphics graphics, NodeBox node, int mouseX, int mouseY) { List lines = new ArrayList<>(); boolean completed = menu.getCompletedResearch().contains(node.def.getId().toString()); @@ -717,19 +858,13 @@ private void renderNodeTooltip(GuiGraphics graphics, NodeBox node, int mouseX, i @Override protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) { - graphics.drawString(this.font, this.title, this.titleLabelX, this.titleLabelY, 0xFF202020, false); + graphics.drawString(this.font, this.title, this.titleLabelX, this.titleLabelY, 0xFF343841, false); graphics.drawString(this.font, this.playerInventoryTitle, this.inventoryLabelX, this.inventoryLabelY, 0xFFE6EAF5, false); - graphics.drawString(this.font, "Tree View | scroll = zoom, right-drag = pan", 22, 24, 0xFFE5E7EB, false); - graphics.drawString(this.font, "AND", 440, 24, EDGE_AND, false); - graphics.drawString(this.font, "OR", 468, 24, EDGE_OR, false); - graphics.drawString(this.font, "S", 486, 24, EDGE_SINGLE, false); - graphics.drawString(this.font, "Drive", 24, 36, 0xFFD3D7E5, false); - graphics.drawString(this.font, "Cube", 24, 72, 0xFFD3D7E5, false); - graphics.drawString(this.font, "Costs", 70, 36, 0xFFD3D7E5, false); - graphics.drawString(this.font, "Fluid", 154, 36, 0xFFD3D7E5, false); + + // Researching indicator (same as ResearchTableScreen) if (menu.isResearching()) { - graphics.drawString(this.font, "\u25CF Researching", 22, 148, 0xFF77DD77, false); + graphics.drawString(this.font, "\u25CF Researching", + ResearchTableMenu.MACHINE_PANEL_X + 4, ResearchTableMenu.BUTTON_Y + 18, 0xFF77DD77, false); } - graphics.drawString(this.font, "Zoom " + Math.round(zoom * 100f) + "%", 390, 24, 0xFFD9DDE7, false); } -} \ No newline at end of file +} diff --git a/src/main/java/com/researchcube/menu/ResearchTableMenu.java b/src/main/java/com/researchcube/menu/ResearchTableMenu.java index 7da4207..39a2e80 100644 --- a/src/main/java/com/researchcube/menu/ResearchTableMenu.java +++ b/src/main/java/com/researchcube/menu/ResearchTableMenu.java @@ -52,23 +52,129 @@ public class ResearchTableMenu extends AbstractContainerMenu { public static final int DATA_FLUID_TYPE = 3; // 0=empty, 1=thinking, 2=pondering, 3=reasoning, 4=imagination public static final int DATA_COUNT = 4; - // Layout coordinates shared by screens - public static final int DRIVE_X = 26; - public static final int DRIVE_Y = 42; - public static final int CUBE_X = 26; - public static final int CUBE_Y = 78; - public static final int COST_X = 70; - public static final int COST_Y = 42; - public static final int BUCKET_IN_X = 70; - public static final int BUCKET_IN_Y = 86; - public static final int BUCKET_OUT_X = 92; - public static final int BUCKET_OUT_Y = 86; - public static final int IDEA_CHIP_X = 134; - public static final int IDEA_CHIP_Y = 60; - public static final int PLAYER_INV_X = 179; - public static final int PLAYER_INV_Y = 164; - public static final int HOTBAR_X = 179; - public static final int HOTBAR_Y = 222; + // ══════════════════════════════════════════════════════════════ + // Layout coordinates (shared by screens) + // ══════════════════════════════════════════════════════════════ + // + // GUI Dimensions: 470 x 280 + // + // TOP SECTION: Research Browser (centered, y=8 to y=140) + // - Search box + Tree button + // - Research list (5 visible rows) + // - Detail pane + // + // BOTTOM SECTION: Machine Panel + Player Inventory (side by side, y=148) + // - Machine Panel (left): Drive, Cube, Cost grid, Buckets, Idea, Gauge, Progress, Buttons + // - Player Inventory (right): 9x3 main + hotbar + + public static final int GUI_WIDTH = 470; + public static final int GUI_HEIGHT = 260; + + // ═══════════════════════════════════════════════════════════════ + // UPPER PANEL (top, centered) - shows List/Tree/Progress views + // Panel contains: search/controls bar + main content area + // ═══════════════════════════════════════════════════════════════ + public static final int UPPER_PANEL_X = 8; + public static final int UPPER_PANEL_Y = 4; + public static final int UPPER_PANEL_W = 454; + public static final int UPPER_PANEL_H = 140; // extended to fit detail pane + + // Controls bar (search + buttons) + public static final int SEARCH_X = 16; + public static final int SEARCH_Y = 10; + public static final int SEARCH_W = 340; + public static final int SEARCH_H = 14; + + public static final int TREE_BTN_X = 364; + public static final int TREE_BTN_Y = 8; + public static final int TREE_BTN_W = 46; + public static final int TREE_BTN_H = 16; + + public static final int LIST_BTN_X = 414; + public static final int LIST_BTN_Y = 8; + public static final int LIST_BTN_W = 40; + public static final int LIST_BTN_H = 16; + + // List view components + public static final int LIST_X = 16; + public static final int LIST_Y = 28; + public static final int LIST_W = 438; + public static final int LIST_ROW_H = 18; + public static final int LIST_VISIBLE_ROWS = 4; + + public static final int DETAIL_X = 16; + public static final int DETAIL_Y = 102; + public static final int DETAIL_W = 438; + public static final int DETAIL_H = 38; + + // Progress view components (replaces list when researching) + public static final int PROGRESS_VIEW_X = 16; + public static final int PROGRESS_VIEW_Y = 28; + public static final int PROGRESS_VIEW_W = 438; + public static final int PROGRESS_VIEW_H = 112; // full content area for progress info + + public static final int PROGRESS_BAR_X = 16; + public static final int PROGRESS_BAR_Y = 120; // near bottom of upper panel + public static final int PROGRESS_BAR_W = 438; + public static final int PROGRESS_BAR_H = 12; + + // ═══════════════════════════════════════════════════════════════ + // MACHINE PANEL (bottom left) - HORIZONTAL LAYOUT + // Row 1: Drive, Cube, Idea | Cost grid top | Fluid gauge, Bucket In + // Row 2: | Cost grid bot | Fluid gauge, Bucket Out + // Row 3: Buttons + // ═══════════════════════════════════════════════════════════════ + public static final int MACHINE_PANEL_X = 12; + public static final int MACHINE_PANEL_Y = 148; + public static final int MACHINE_PANEL_W = 200; + + // Labels row + public static final int LABEL_Y = 156; + public static final int LABEL_HEIGHT = 10; + + // Slot row 1 & 2 (y=168 and y=186) + public static final int SLOT_ROW_1_Y = 168; + public static final int SLOT_ROW_2_Y = 186; + + // Drive/Cube/Idea - horizontal row + public static final int DRIVE_X = 20; + public static final int DRIVE_Y = 168; + public static final int CUBE_X = 40; + public static final int CUBE_Y = 168; + public static final int IDEA_CHIP_X = 60; + public static final int IDEA_CHIP_Y = 168; + + // Cost grid (3x2) - center + public static final int COST_X = 96; + public static final int COST_Y = 168; + // Grid: x=96,114,132 ; y=168,186 + + // Fluid gauge + Buckets - right side + public static final int FLUID_GAUGE_X = 168; + public static final int FLUID_GAUGE_Y = 168; + public static final int FLUID_GAUGE_W = 18; + public static final int FLUID_GAUGE_H = 36; // 2 slots tall + + public static final int BUCKET_IN_X = 190; + public static final int BUCKET_IN_Y = 168; + public static final int BUCKET_OUT_X = 190; + public static final int BUCKET_OUT_Y = 186; + + // Buttons (single row, moved up) + public static final int BUTTON_Y = 210; + public static final int BUTTON_W = 42; + public static final int BUTTON_H = 14; + public static final int START_BTN_X = 20; + public static final int STOP_BTN_X = 66; + public static final int WIPE_BTN_X = 112; + + // ═══════════════════════════════════════════════════════════════ + // PLAYER INVENTORY (bottom right) - aligned with machine panel + // ═══════════════════════════════════════════════════════════════ + public static final int PLAYER_INV_X = 229; + public static final int PLAYER_INV_Y = 168; // same as slot row 1 + public static final int HOTBAR_X = 229; + public static final int HOTBAR_Y = 230; // below main inv // ── Constructor from server (block entity available) ── public ResearchTableMenu(int containerId, Inventory playerInv, ResearchTableBlockEntity be) { diff --git a/src/main/java/com/researchcube/research/ResearchDefinition.java b/src/main/java/com/researchcube/research/ResearchDefinition.java index c1d0efa..0706b12 100644 --- a/src/main/java/com/researchcube/research/ResearchDefinition.java +++ b/src/main/java/com/researchcube/research/ResearchDefinition.java @@ -49,7 +49,8 @@ public ResearchDefinition(ResourceLocation id, ResearchTier tier, int duration, Prerequisite prerequisites, List itemCosts, List weightedRecipePool, @Nullable String name, @Nullable String description, - @Nullable String category, @Nullable FluidCost fluidCost, + @Nullable String category, + @Nullable FluidCost fluidCost, Optional ideaChip) { this.id = id; this.tier = tier; @@ -72,7 +73,8 @@ public ResearchDefinition(ResourceLocation id, ResearchTier tier, int duration, Prerequisite prerequisites, List itemCosts, List weightedRecipePool, @Nullable String name, @Nullable String description, - @Nullable String category, @Nullable FluidCost fluidCost) { + @Nullable String category, + @Nullable FluidCost fluidCost) { this(id, tier, duration, prerequisites, itemCosts, weightedRecipePool, name, description, category, fluidCost, Optional.empty()); } @@ -97,7 +99,7 @@ public ResearchDefinition(ResourceLocation id, ResearchTier tier, int duration, List recipePool) { this(id, tier, duration, prerequisites, itemCosts, recipePool.stream().map(rl -> new WeightedRecipe(rl, 1)).toList(), - null, null, null, null); + null, null, null, null, null); } public ResourceLocation getId() { diff --git a/src/main/java/com/researchcube/research/ResearchManager.java b/src/main/java/com/researchcube/research/ResearchManager.java index 3312e8b..b973481 100644 --- a/src/main/java/com/researchcube/research/ResearchManager.java +++ b/src/main/java/com/researchcube/research/ResearchManager.java @@ -115,8 +115,8 @@ private ResearchDefinition parseDefinition(ResourceLocation id, JsonObject json) } return new ResearchDefinition(id, tier, duration, prerequisites, itemCosts, weightedRecipePool, - parseName(json), parseDescription(json), parseCategory(json), parseFluidCost(json), - parseIdeaChip(json)); + parseName(json), parseDescription(json), parseCategory(json), + parseFluidCost(json), parseIdeaChip(json)); } @Nullable diff --git a/src/main/java/com/researchcube/util/TierUtil.java b/src/main/java/com/researchcube/util/TierUtil.java index 2f40a33..9cf96e6 100644 --- a/src/main/java/com/researchcube/util/TierUtil.java +++ b/src/main/java/com/researchcube/util/TierUtil.java @@ -12,7 +12,7 @@ private TierUtil() {} /** * Validates whether a research operation is allowed based on tier rules: * - cube.tier >= research.tier - * - drive.tier == research.tier + * - drive.tier >= research.tier (higher tier drives can research lower tier research) * * @param cubeTier the tier of the cube in the Research Table * @param driveTier the tier of the drive in the Research Table @@ -31,7 +31,7 @@ public static boolean canResearch(ResearchTier cubeTier, ResearchTier driveTier, if (!cubeTier.isAtLeast(researchTier)) { return false; } - // Drive must exactly match the research tier - return driveTier == researchTier; + // Drive must be at least the research tier (higher tier drives can research lower tier) + return driveTier.isAtLeast(researchTier); } } diff --git a/src/main/resources/assets/researchcube/textures/gui/research_table.png b/src/main/resources/assets/researchcube/textures/gui/research_table.png index a9e00a3..fe6c85d 100644 Binary files a/src/main/resources/assets/researchcube/textures/gui/research_table.png and b/src/main/resources/assets/researchcube/textures/gui/research_table.png differ diff --git a/src/main/resources/data/researchcube/research/cosmic_assembly.json b/src/main/resources/data/researchcube/research/cosmic_assembly.json index 0e450cf..e139346 100644 --- a/src/main/resources/data/researchcube/research/cosmic_assembly.json +++ b/src/main/resources/data/researchcube/research/cosmic_assembly.json @@ -1,6 +1,7 @@ { "name": "Cosmic Assembly", "description": "The ultimate capstone research — assemble cosmic-tier items. Demonstrates a 3-recipe weighted pool at the highest tier. Requires both star_forging and void_synthesis.", + "flavor_text": "When star and void converge, creation itself bends to your will.", "category": "endgame", "tier": "SELF_AWARE", "duration": 48000, diff --git a/src/main/resources/data/researchcube/research/crystal_growing.json b/src/main/resources/data/researchcube/research/crystal_growing.json index 4c740ea..5d79df5 100644 --- a/src/main/resources/data/researchcube/research/crystal_growing.json +++ b/src/main/resources/data/researchcube/research/crystal_growing.json @@ -1,6 +1,7 @@ { "name": "Crystal Growing", "description": "Grow crystals in a controlled environment using the Processing Station. A processing-only recipe at PRECISE tier.", + "flavor_text": "Patience is the seed from which all great crystals grow.", "category": "processing", "tier": "PRECISE", "duration": 3200, diff --git a/src/main/resources/data/researchcube/research/ender_weaving.json b/src/main/resources/data/researchcube/research/ender_weaving.json index 6a4ccb6..dcff34f 100644 --- a/src/main/resources/data/researchcube/research/ender_weaving.json +++ b/src/main/resources/data/researchcube/research/ender_weaving.json @@ -1,6 +1,7 @@ { "name": "Ender Weaving", "description": "Weave ender energy into items. Requires an Irrecoverable Drive as an idea chip and AND prerequisites (lapis_infusion AND potion_brewing).", + "flavor_text": "Between the cracks of reality, the Ender threads wait to be spun.", "category": "magic", "tier": "FLAWLESS", "duration": 8000, diff --git a/src/main/resources/data/researchcube/research/iron_working.json b/src/main/resources/data/researchcube/research/iron_working.json index 51b2d54..5c38a86 100644 --- a/src/main/resources/data/researchcube/research/iron_working.json +++ b/src/main/resources/data/researchcube/research/iron_working.json @@ -1,6 +1,7 @@ { "name": "Iron Working", "description": "Master iron crafting techniques. Demonstrates a simple prerequisite and unlocks both a shaped drive crafting recipe and a processing recipe.", + "flavor_text": "The forge remembers every hammer blow. Make each one count.", "category": "materials", "tier": "BASIC", "duration": 1000, diff --git a/src/main/resources/data/researchcube/research/redstone_basics.json b/src/main/resources/data/researchcube/research/redstone_basics.json index 1d393c6..ef9ecb1 100644 --- a/src/main/resources/data/researchcube/research/redstone_basics.json +++ b/src/main/resources/data/researchcube/research/redstone_basics.json @@ -1,6 +1,7 @@ { "name": "Redstone Basics", "description": "Unlock fundamental redstone mechanisms. Demonstrates the idea chip feature — requires an Irrecoverable Drive as an idea chip to begin.", + "flavor_text": "A single pulse of redstone can awaken an entire machine.", "category": "redstone", "tier": "BASIC", "duration": 1200, diff --git a/src/main/resources/data/researchcube/research/soul_extraction.json b/src/main/resources/data/researchcube/research/soul_extraction.json index 037a0f8..9f2ccc9 100644 --- a/src/main/resources/data/researchcube/research/soul_extraction.json +++ b/src/main/resources/data/researchcube/research/soul_extraction.json @@ -1,6 +1,7 @@ { "name": "Soul Extraction", "description": "Extract soul energy from soul sand for crafting. A shaped drive crafting recipe from the crystal growing branch.", + "flavor_text": "The sand whispers of lives long past. Listen carefully.", "category": "magic", "tier": "FLAWLESS", "duration": 8000, diff --git a/src/main/resources/data/researchcube/research/star_forging.json b/src/main/resources/data/researchcube/research/star_forging.json index c28eb5b..831eef1 100644 --- a/src/main/resources/data/researchcube/research/star_forging.json +++ b/src/main/resources/data/researchcube/research/star_forging.json @@ -1,6 +1,7 @@ { "name": "Star Forging", "description": "Forge nether stars into powerful components. Demonstrates a weighted recipe pool at the SELF_AWARE tier with two drive crafting recipes.", + "flavor_text": "To hold a star is to hold the memory of a world that burned.", "category": "endgame", "tier": "SELF_AWARE", "duration": 24000, diff --git a/src/main/resources/data/researchcube/research/stone_refinement.json b/src/main/resources/data/researchcube/research/stone_refinement.json index 24abe41..73d633c 100644 --- a/src/main/resources/data/researchcube/research/stone_refinement.json +++ b/src/main/resources/data/researchcube/research/stone_refinement.json @@ -1,6 +1,7 @@ { "name": "Stone Refinement", "description": "Learn to process raw stone into refined building materials. A simple entry-level research with no prerequisites.", + "flavor_text": "Even the humblest cobblestone holds secrets for those who dare to look.", "category": "materials", "tier": "UNSTABLE", "duration": 600, diff --git a/src/main/resources/data/researchcube/research/void_synthesis.json b/src/main/resources/data/researchcube/research/void_synthesis.json index 53cdb39..84ed61c 100644 --- a/src/main/resources/data/researchcube/research/void_synthesis.json +++ b/src/main/resources/data/researchcube/research/void_synthesis.json @@ -1,6 +1,7 @@ { "name": "Void Synthesis", "description": "Synthesize void energy into tangible form. Requires an Irrecoverable Drive as an idea chip — demonstrating endgame idea chip gating. Uses AND prerequisites.", + "flavor_text": "Nothing is never truly empty. The void itself has substance.", "category": "endgame", "tier": "SELF_AWARE", "duration": 24000, diff --git a/todo.lock b/todo.lock index bb2dd5b..571aad0 100644 --- a/todo.lock +++ b/todo.lock @@ -7,6 +7,12 @@ Tasks marked as [DONE] have been completed and should not be suggested for new w [END AI INSTRUCTIONS] +Known issues: +[RESEARCH STATION] +- (none — all known issues resolved) + + +Todo: [DONE — Phase 1: Foundation] - [DONE] Build system: build.gradle, settings.gradle, gradle.properties, neoforge.mods.toml, pack.mcmeta - [DONE] ResearchTier enum (7 values: IRRECOVERABLE → SELF_AWARE), StringRepresentable