Gui changes and cleanup

This commit is contained in:
Plastered_Crab
2026-06-20 12:19:35 -07:00
parent 7fa0c296c6
commit a2e3ca23b8
14 changed files with 450 additions and 645 deletions
@@ -93,8 +93,6 @@ class PokedexGUI private constructor(
private val globeIcon = cobblemonResource("textures/gui/pokedex/globe_icon.png")
private val caughtSeenIcon = cobblemonResource("textures/gui/pokedex/caught_seen_icon.png")
private val pageArrowLeftIcon = cobblemonResource("textures/gui/pokedex/forms_arrow_left.png")
private val pageArrowRightIcon = cobblemonResource("textures/gui/pokedex/forms_arrow_right.png")
private val categoryBarOverlay = cobblemonResource("textures/gui/pokedex/pokedex_screen_bar_category.png")
@@ -108,11 +106,7 @@ class PokedexGUI private constructor(
cobblemonResource("textures/gui/pokedex/tab_moves.png")
)
private const val PAGE_ARROW_WIDTH = 10
private const val PAGE_ARROW_HEIGHT = 16
private const val PAGE_SELECTOR_GAP = 7
// Back button on the main dex (returns to the CobblePedia hub) — same 16x16 two-frame sheet as CobblePedia.
// Back button on the main dex returns to the CobblePedia hub, sharing CobblePedia's 16x16 two-frame sheet.
private val backButtonIcon = cobblemonResource("textures/gui/pokedex/pokedex_back_button.png")
private const val BACK_X = 20
private const val BACK_Y = 14
@@ -198,14 +192,12 @@ class PokedexGUI private constructor(
val x = (width - BASE_WIDTH) / 2
val y = (height - BASE_HEIGHT) / 2
//Info Widget
if (::pokemonInfoWidget.isInitialized) removeWidget(pokemonInfoWidget)
pokemonInfoWidget = PokemonInfoWidget(x + 180, y + 28) { newForm -> updateSelectedForm(newForm) }
addRenderableWidget(pokemonInfoWidget)
setUpTabs()
//Tab Info Widget
displaytabInfoElement(tabInfoIndex, false)
if (::searchWidget.isInitialized) removeWidget(searchWidget)
@@ -310,7 +302,6 @@ class PokedexGUI private constructor(
val x = (width - BASE_WIDTH) / 2
val y = (height - BASE_HEIGHT) / 2
// Render Base Resource
blitk(
matrixStack = matrices,
texture = type.getTexturePath(),
@@ -334,13 +325,12 @@ class PokedexGUI private constructor(
return
}
// Back button (main dex → CobblePedia hub), top-left.
// Top-left back button returns the main dex to the CobblePedia hub.
run {
val hovered = mouseX in (x + BACK_X)..(x + BACK_X + BACK_W) && mouseY in (y + BACK_Y)..(y + BACK_Y + BACK_H)
blitk(matrixStack = matrices, texture = backButtonIcon, x = x + BACK_X, y = y + BACK_Y, width = BACK_W, height = BACK_H, vOffset = if (hovered) BACK_H else 0, textureHeight = BACK_H * 2)
}
// Region (shifted right to make room for the back button at top-left).
blitk(
matrixStack = matrices,
texture = globeIcon,
@@ -351,7 +341,6 @@ class PokedexGUI private constructor(
scale = SCALE
)
// Region label
drawScaledText(
context = context,
font = CobblemonResources.DEFAULT_LARGE,
@@ -361,7 +350,6 @@ class PokedexGUI private constructor(
shadow = true
)
// Seen icon
blitk(
matrixStack = matrices,
texture = caughtSeenIcon,
@@ -374,7 +362,6 @@ class PokedexGUI private constructor(
scale = SCALE
)
// Caught icon
blitk(
matrixStack = matrices,
texture = caughtSeenIcon,
@@ -387,7 +374,6 @@ class PokedexGUI private constructor(
scale = SCALE
)
// Seen
drawScaledText(
context = context,
font = CobblemonResources.DEFAULT_LARGE,
@@ -397,7 +383,6 @@ class PokedexGUI private constructor(
shadow = true
)
// Owned
drawScaledText(
context = context,
font = CobblemonResources.DEFAULT_LARGE,
@@ -407,7 +392,6 @@ class PokedexGUI private constructor(
shadow = true
)
// Category filter
blitk(
matrixStack = matrices,
texture = categoryBarOverlay,
@@ -436,9 +420,8 @@ class PokedexGUI private constructor(
shadow = true
)
// Show selected tab pointer if selected Pokémon has tab info to be shown
// Only point at the active tab when the selected form has info to show there.
if (selectedEntry?.let { selectedForm in getSufficientlyKnownForms(it, tabInfoIndex) } == true) {
// Tab arrow
blitk(
matrixStack = matrices,
texture = tabSelectArrow,
@@ -452,7 +435,6 @@ class PokedexGUI private constructor(
super.render(context, mouseX, mouseY, delta)
// Search type tooltip
if (searchByTypeButton.isButtonHovered(mouseX, mouseY)) {
val searchTypeText = lang("ui.pokedex.search.search_by", lang("ui.pokedex.search.type.${selectedSearchByType.name.lowercase()}")).bold()
renderTooltip(
@@ -827,33 +809,9 @@ class PokedexGUI private constructor(
shadow = true,
centered = true
)
// Page-switch arrows removed — navigation is via the CobblePedia hub tiles + back buttons.
}
private fun handlePageNavigationClick(mouseX: Double, mouseY: Double): Boolean {
if (pages.size <= 1) return false
val x = (width - BASE_WIDTH) / 2
val y = (height - BASE_HEIGHT) / 2
val centerX = x + (BASE_WIDTH / 2)
val leftX = centerX - PAGE_SELECTOR_GAP - PAGE_ARROW_WIDTH
val rightX = centerX + PAGE_SELECTOR_GAP
val arrowY = y + 13
if (isInsidePageArrow(mouseX, mouseY, leftX, arrowY)) {
cyclePage(next = false)
return true
}
if (isInsidePageArrow(mouseX, mouseY, rightX, arrowY)) {
cyclePage(next = true)
return true
}
return false
}
/** Switch to the first top-level page of [pageClass] (used by CobblePedia tiles + the back buttons). */
/** Switch to the first top-level page of [pageClass] (used by CobblePedia tiles and the back buttons). */
fun openPage(pageClass: Class<out PokedexGuiPage>) {
val index = pages.indexOfFirst { pageClass.isInstance(it) }
if (index < 0 || index == selectedPageIndex) return
@@ -867,29 +825,6 @@ class PokedexGUI private constructor(
playSound(CobblemonSounds.POKEDEX_CLICK_SHORT)
}
private fun cyclePage(next: Boolean) {
if (pages.size <= 1) return
val oldPage = currentPage
selectedPageIndex = if (next) {
(selectedPageIndex + 1) % pages.size
} else {
(selectedPageIndex - 1 + pages.size) % pages.size
}
canDragRender = false
isDragging = false
setFocused(null)
oldPage?.onClose(this)
currentPage?.onSelected(this)
playSound(CobblemonSounds.POKEDEX_CLICK_SHORT)
}
private fun isInsidePageArrow(mouseX: Number, mouseY: Number, x: Int, y: Int): Boolean {
val mX = mouseX.toDouble()
val mY = mouseY.toDouble()
return mX >= x && mX <= (x + PAGE_ARROW_WIDTH) && mY >= y && mY <= (y + PAGE_ARROW_HEIGHT)
}
fun canSelectTab(tabIndex: Int): Boolean {
val selectedForm = this.selectedForm ?: return false
val selectedEntry = this.selectedEntry ?: return false
@@ -912,10 +847,8 @@ class PokedexGUI private constructor(
override fun isPauseScreen(): Boolean = false
override fun keyPressed(keyCode: Int, scanCode: Int, modifiers: Int): Boolean {
// Give the active page first crack at the key BEFORE the inventory-key close. Pages with their own text
// inputs (the MoveDex/BerryDex/WorldDex search boxes use a custom focus flag, not an EditBox) consume the
// keystroke when focused, so typing the inventory letter (default "e") into a search box no longer closes
// the GUI. A page only returns true for keys it actually handles, so "e" still closes when nothing's focused.
// Let the page consume the key before the inventory-key close, so typing the inventory letter into a
// page search box (which uses a custom focus flag, not an EditBox) does not close the GUI.
val page = currentPage
if (page != null && page !is MainDexPokedexPage && page.keyPressed(this, keyCode, scanCode, modifiers)) {
return true
@@ -33,51 +33,46 @@ import kotlin.math.max
/**
* The BerryDex: a scrollable, sortable tile grid of every berry on the left (undiscovered berries
* render as dark silhouettes named "????"), and a detail pane on the right for the selected berry —
* a drag-rotatable berry tree preview with arrows cycling its growth ages, and tabs underneath for
* Description, Flavor stats, Crossbreeding partners, Bait effects, and preferred Location/mulch.
* render as dark silhouettes named "????"), and a detail pane on the right for the selected berry.
* The detail pane shows a drag-rotatable berry tree preview with arrows cycling its growth ages, and
* tabs underneath for Description, Flavor stats, Crossbreeding partners, Bait effects, and preferred
* Location/mulch.
*
* Discovery = the berry has been scanned into the BERRIES bucket of the player's scanned data.
* Placeholder flat-colour styling, like the WorldDex; real texture work is a later art pass.
* A berry counts as discovered once it has been scanned into the BERRIES bucket of the player's
* scanned data.
*/
class BerryDexView {
companion object {
// Left grid
private const val GRID_X = 18
private const val GRID_TOP = 56
private const val SEARCH_Y = 26
private const val SEARCH_H = 12
private const val COLS = 5 // match the main Pokédex grid (5 portrait-tile columns)
private const val SLOT = 25 // pokedex_slot.png native tile size
private const val COL_STEP = 27 // SLOT + 2px gap
private const val ROW_STEP = 34 // SLOT + name label + gap
private const val TILE_HIT_H = 33 // clickable cell height (slot + name)
private const val COL_STEP = 27 // SLOT plus 2px gap
private const val ROW_STEP = 34 // SLOT plus name label plus gap
private const val TILE_HIT_H = 33 // clickable cell height (slot plus name)
private const val VISIBLE_TILE_ROWS = 4
private const val SCROLLBAR_W = 3
private const val SORT_Y = 42
private const val SORT_H = 11
// Right detail pane
private const val DETAIL_X = 166
private const val DETAIL_W = 160
private const val HEADER_Y = 26
private const val HEADER_H = 12
private const val VIEW_Y = 41
private const val VIEW_H = 90
private const val ARROW_W = 10
private const val CONTENT_Y = 134 // content panel now sits directly below the viewport
private const val CONTENT_H = 38 // ends ~172, just above the bottom tab row
private const val TAB_ROW_Y = 178 // info-tab icon row at the BOTTOM (icons 178-186, arrow 174)
private const val CONTENT_Y = 134 // content panel sits directly below the viewport
private const val CONTENT_H = 38 // ends just above the bottom tab row
private const val TAB_ROW_Y = 178 // info-tab icon row at the bottom
private const val CONTENT_ROW_H = 9
private const val CONTENT_VISIBLE_ROWS = 4
private const val PANEL_BG = 0xCC10303A.toInt()
private const val TILE_BG = 0xFFE9F6F6.toInt()
private const val TILE_BG_HOVER = 0xFFC8E8E8.toInt()
private const val TILE_BG_SELECTED = 0xFFA9DCDC.toInt()
private const val BUTTON_BG = 0xFFE9F6F6.toInt()
private const val BUTTON_BG_ACTIVE = 0xFFA9DCDC.toInt()
private const val PREVIEW_BG = 0xC0101010.toInt()
private const val TEXT_DARK = 0x33586B
private const val INFO_TEXT = 0x606B6E // matches the main Pokédex info-page text colour
private const val SCROLL_TRACK = 0x6010303A
@@ -86,12 +81,12 @@ class BerryDexView {
/** The Pokédex's existing "?" slot icon, reused for undiscovered berries. */
private val UNKNOWN_ICON = cobblemonResource("textures/gui/pokedex/pokedex_slot_unknown.png")
// Berry tiles reuse the main Pokédex portrait tile button (pokedex_slot.png) + slot_select highlight.
// Berry tiles reuse the main Pokédex portrait tile button (pokedex_slot.png) plus slot_select highlight.
private val SLOT_TEXTURE = cobblemonResource("textures/gui/pokedex/pokedex_slot.png")
private val SLOT_SELECT = cobblemonResource("textures/gui/pokedex/slot_select.png")
// Custom right-panel background skin (shared with the WorldDex).
// Custom right-panel background skin, shared with the WorldDex.
private val INFO_BG = cobblemonResource("textures/gui/pokedex/cobblepedia_info_background.png")
// Bottom-right info tabs reuse the Pokédex tab-row style + best-fit existing tab icons (swap freely later).
// Bottom-right info tabs reuse the Pokédex tab-row style with best-fit existing tab icons.
private val TAB_SELECT_ARROW = cobblemonResource("textures/gui/pokedex/select_arrow.png")
private val TAB_INFO = cobblemonResource("textures/gui/pokedex/tab_info.png")
private val TAB_STATS = cobblemonResource("textures/gui/pokedex/tab_stats.png")
@@ -209,7 +204,7 @@ class BerryDexView {
renderBerryCount(context, x, y)
}
/** [discovered] / [total] berries, top-right — mirrors the main Pokédex's caught/total counter. */
/** Discovered / total berries, shown top-right like the main Pokédex's caught/total counter. */
private fun renderBerryCount(context: GuiGraphics, x: Int, y: Int) {
val all = Berries.all()
val text = Component.literal("${all.count { discovered(it) }} / ${all.count()}").bold()
@@ -292,13 +287,13 @@ class BerryDexView {
val tileY = y + GRID_TOP + row * ROW_STEP
val isDiscovered = discovered(berry)
val hovered = mouseX in tileX..(tileX + SLOT) && mouseY in tileY..(tileY + TILE_HIT_H)
// The main Pokédex portrait tile button, with the slot_select highlight (bottom half) on hover/selected.
// Portrait tile button, with the slot_select highlight (bottom half of the texture) on hover or selection.
blitk(matrixStack = context.pose(), texture = SLOT_TEXTURE, x = tileX, y = tileY, width = SLOT, height = SLOT)
if (berry === selected || hovered) {
blitk(matrixStack = context.pose(), texture = SLOT_SELECT, x = tileX, y = tileY, width = SLOT, height = SLOT, vOffset = SLOT, textureHeight = SLOT * 2)
}
// Item sprite for discovered berries; the Pokédex "?" slot icon for undiscovered ones.
// Item sprite for discovered berries, the Pokédex "?" slot icon for undiscovered ones.
if (isDiscovered) {
berry.item()?.let { item ->
renderScaledGuiItemIcon(
@@ -331,7 +326,6 @@ class BerryDexView {
)
}
// Scroll bar.
if (totalRows > VISIBLE_TILE_ROWS) {
val trackX = x + GRID_X + COLS * COL_STEP
val trackTop = y + GRID_TOP
@@ -347,9 +341,8 @@ class BerryDexView {
val berry = selected ?: return
val left = x + DETAIL_X
// Custom right-panel background skin (header bar + tab-area bars). Drawn first, behind all content.
// Background skin first, behind all content.
blitk(matrixStack = context.pose(), texture = INFO_BG, x = left, y = y + HEADER_Y, width = DETAIL_W, height = TAB_ROW_Y + TAB_ICON_DISP + 2 - HEADER_Y)
// Header: berry name.
drawScaledText(
context = context,
text = Component.literal(displayName(berry)).bold(),
@@ -359,10 +352,9 @@ class BerryDexView {
scale = 0.75F
)
// Tree viewport + age cycle arrows.
val viewLeft = left + ARROW_W + 2
val viewWidth = DETAIL_W - (ARROW_W + 2) * 2
// No dark backdrop — the tree renders directly on the panel background texture for a cutout look.
// The tree renders directly on the panel background texture for a cutout look (no dark backdrop).
BerryTreePreviewRenderer.render(context, berry, age, viewLeft, y + VIEW_Y, viewWidth, VIEW_H, yaw, pitch, zoom)
renderArrow(context, left, y, mouseX, mouseY, right = false)
renderArrow(context, left + DETAIL_W - ARROW_W, y, mouseX, mouseY, right = true)
@@ -376,8 +368,7 @@ class BerryDexView {
scale = 0.5F
)
// Info tabs — icon buttons at the BOTTOM, styled exactly like the main Pokédex / MoveDex tab row
// (16x32 two-state icon at half scale, bottom half on hover/active, select arrow over the active tab).
// Info tabs: 16x32 two-state icons at half scale (bottom half on hover or active), select arrow over the active tab.
Tab.entries.forEachIndexed { index, tab ->
val ix = tabIconX(left, index)
val active = tab == activeTab
@@ -406,7 +397,6 @@ class BerryDexView {
}
}
// Tab content (no dark box — drawn straight on the background texture).
when (activeTab) {
Tab.FLAVOR -> renderFlavorContent(context, left, y, berry)
Tab.CROSSBREED -> renderCrossbreedContent(context, left, y, berry)
@@ -476,7 +466,7 @@ class BerryDexView {
for (i in contentScroll until end) {
val (partnerId, resultId) = mutations[i]
val rowY = y + CONTENT_Y + 1 + (i - contentScroll) * CONTENT_ROW_H
// [selected berry icon+name] + [partner] -> [result]
// Row layout: selected berry icon and name, plus partner, arrow, result.
berry.item()?.let {
renderScaledGuiItemIcon(ItemStack(it), (left + 4).toDouble(), rowY.toDouble(), matrixStack = context.pose(), scale = 0.5)
}
@@ -49,9 +49,10 @@ class CobblePediaPokedexPage(
private const val LIST_LINE_HEIGHT = 11
private const val LIST_VISIBLE_ROWS = 11
private const val BACK_X = 20
private const val BACK_Y = 14 // raised vertically (was 26)
private const val BACK_W = 16 // back-button sheet is 16x16 = two stacked 16x8 frames
private const val BACK_H = 8 // one frame: top = idle, bottom = hovered
private const val BACK_Y = 14
// Back-button sheet is 16x16, two stacked 16x8 frames (top idle, bottom hovered).
private const val BACK_W = 16
private const val BACK_H = 8
private val BACK_BUTTON = cobblemonResource("textures/gui/pokedex/pokedex_back_button.png")
private const val TILE_BG = 0xCC1A1A1A.toInt()
@@ -59,7 +60,7 @@ class CobblePediaPokedexPage(
private const val TILE_BORDER = 0xFF7EE7E5.toInt()
private const val BARE_SUB = "" // sentinel sub id for a category's bare bucket
// Right-side structure preview pane (relative to the page origin x,y)
// Right-side structure preview pane, relative to the page origin x,y.
private const val PREVIEW_X = 170
private const val PREVIEW_Y = 42
private const val PREVIEW_W = 155
@@ -68,7 +69,7 @@ class CobblePediaPokedexPage(
private const val LIST_ROW_HIGHLIGHT = 0x803A3A3A.toInt()
private const val LIST_ROW_WIDTH = 130
// "Refresh" button overlaid at the preview pane's top-right: re-rolls processor substitutions.
// Refresh button overlaid at the preview pane's top-right; re-rolls processor substitutions.
private const val REFRESH_W = 50
private const val REFRESH_H = 12
private const val REFRESH_BG = 0xCC1A1A1A.toInt()
@@ -95,8 +96,7 @@ class CobblePediaPokedexPage(
private var selectedSub: String? = null // null = sub-grid (or direct list if no subs); non-null = a bucket's list
private var scrollRow = 0
// WorldDex (replaces the Habitats/Structures categories): a category submenu (Habitats/Ruins/
// Fossils) leading into a per-category browser.
// WorldDex: a category submenu (Habitats/Ruins/Fossils) leading into a per-category browser.
private var worldDexMode = false
private var worldDexCategory: WorldDexData.Category? = null
private val worldDexView = WorldDexView()
@@ -189,7 +189,7 @@ class CobblePediaPokedexPage(
/**
* The top-level hub tiles in render order, each paired with its click action. Order: the main Pokédex,
* the scanned-data categories (Berries/Items/Misc — Eggs/Blocks hidden), WorldDex, MoveDex, Photo Gallery.
* the scanned-data categories (Berries/Items/Misc, with Eggs/Blocks hidden), WorldDex, MoveDex, Photo Gallery.
* A count of -1 renders no count line (used for the page-link tiles). [gui] is needed to switch top-level pages.
*/
private fun hubTiles(gui: PokedexGUI): List<Pair<Tile, () -> Unit>> {
@@ -329,7 +329,7 @@ class CobblePediaPokedexPage(
val bx = x + BACK_X
val by = y + BACK_Y
val hovered = mouseX in bx..(bx + BACK_W) && mouseY in by..(by + BACK_H)
// 16x16 sheet = two vertically-stacked 16x8 frames: top half (vOffset 0) idle, bottom half (vOffset 8) hover.
// Hovered state uses the bottom frame (vOffset = BACK_H).
blitk(
matrixStack = context.pose(),
texture = BACK_BUTTON,
@@ -514,7 +514,7 @@ class CobblePediaPokedexPage(
val scanned = CobblemonClient.clientScannedData.scanned
.filterKeys { ScannedDataCategory.categoryOfBucket(it) == category }
.values.flatten()
// TEMPORARY: all habitats and structures are treated as unlocked for testing the page + preview.
// Habitats and structures are treated as fully unlocked to exercise the page and preview.
return when (category) {
ScannedDataCategory.HABITATS -> (scanned + TestUnlockedHabitats.entries).distinct()
ScannedDataCategory.STRUCTURES -> (scanned + TestUnlockedStructures.entries).distinct()
@@ -27,11 +27,11 @@ import com.cobblemon.mod.common.client.render.structure.StructurePreviewResource
/**
* Reads a move's action-effect timeline and flattens it into timed cues for the MoveDex viewport.
*
* Rather than re-parse the JSON by hand (which diverged from the real format), this deserializes the file with
* the SAME [ActionEffects] gson the battle system uses, then walks the resulting typed [ActionEffectKeyframe]s —
* so sequence/parallel/fork nesting, molang `delay`/`pause`, conditions, and `q.play_animation` overlays are all
* interpreted exactly as in battle. Produces: the cycle duration (for auto-pause/loop), the timed bedrock
* animation cues (actor windup + target hit/throw, `is_user == false` ⇒ target), and the timed sound cues.
* Deserializes the file with the same [ActionEffects] gson the battle system uses, then walks the resulting
* typed [ActionEffectKeyframe]s, so sequence/parallel/fork nesting, molang `delay`/`pause`, conditions, and
* `q.play_animation` overlays are interpreted exactly as in battle. Produces the cycle duration (for
* auto-pause/loop), the timed bedrock animation cues (actor windup plus target hit/throw, where
* `is_user == false` means the target), and the timed sound cues.
*/
object MoveDexAnimation {
@@ -39,8 +39,8 @@ object MoveDexAnimation {
private const val TAIL_SECONDS = 1.5f
// Animation name out of a molang play_animation call: q.bedrock_stateful('seismictoss','actor'). Move-specific
// animations live in shared files keyed "animation.<group>.<variant>"; we rebuild "<group>:<key>" so
// PosableModel.extractAnimation resolves them against that shared repo (not the species poser).
// animations live in shared files keyed "animation.<group>.<variant>"; rebuilding "<group>:<key>" lets
// PosableModel.extractAnimation resolve them against that shared repo (not the species poser).
private val ANIM_RE = Regex("""bedrock(?:_stateful)?\(\s*['"]([^'"]+)['"]\s*,\s*['"]([^'"]+)['"]""", RegexOption.IGNORE_CASE)
private val SOUND_RE = Regex("""q\.sound\(\s*['"]([^'"]+)['"]""", RegexOption.IGNORE_CASE)
@@ -59,7 +59,7 @@ object MoveDexAnimation {
runCatching { compute(move) }.getOrElse { Parsed(DEFAULT_SECONDS, emptyList(), emptyList(), false) }
}
/** Whether the move actually ships an action effect (timeline). False ⇒ the MoveDex marks it "no animation yet". */
/** Whether the move actually ships an action effect (timeline). False means the MoveDex marks it "no animation yet". */
fun hasActionEffect(move: MoveTemplate): Boolean = parsed(move).hasEffect
/** Estimated cycle length in seconds (timeline end + a tail for trailing/looping particles). */
@@ -20,7 +20,7 @@ import net.minecraft.resources.ResourceLocation
/**
* Backing data for the MoveDex: which moves the player has discovered (aggregated across every seen
* species' learnset, learned TMs, or all moves when the server unlocks the dex), and the reverse
* learnset — for a given move, which species can learn it and by what method.
* learnset (for a given move, which species can learn it and by what method).
*/
object MoveDexData {
@@ -55,7 +55,7 @@ object MoveDexData {
fun discoveredCount(): Int = discoveredCache?.size ?: 0
/** Total number of moves in the game — the MoveDex denominator. */
/** Total number of moves in the game (the MoveDex denominator). */
fun totalCount(): Int = Moves.all().count()
fun learnedBy(move: MoveTemplate): List<LearnEntry> = reverseIndex[move] ?: emptyList()
@@ -50,14 +50,14 @@ import net.minecraft.world.phys.Vec3
import org.joml.Quaternionf
/**
* Plays a move's REAL snowstorm particle effects inside the MoveDex viewport as a simulated 3D scene.
* Plays a move's real snowstorm particle effects inside the MoveDex viewport as a simulated 3D scene.
*
* Rather than reimplement the particle engine, this runs the genuine [ParticleStorm] lifecycle: each
* Rather than reimplement the particle engine, this runs the genuine [ParticleStorm] lifecycle. Each
* `entity_particles` keyframe's storm is constructed with `guiScene = this` so it (and every effect it
* event-spawns — trails, secondary bursts, heal sparkles) reports here instead of the world particle
* engine. The scene then ticks every storm and particle at 20 Hz with their real `tick()` methods — so
* emitter shapes/rates, MoLang motion, `target_delta*` drains, flipbook UVs, tints, materials, collisions
* and child-effect event timelines all behave exactly as in battle — and renders each particle through
* event-spawns, such as trails, secondary bursts, and heal sparkles) reports here instead of the world
* particle engine. The scene then ticks every storm and particle at 20 Hz with their real `tick()`
* methods, so emitter shapes/rates, MoLang motion, `target_delta*` drains, flipbook UVs, tints, materials,
* collisions and child-effect event timelines all behave as in battle, and renders each particle through
* its real [com.cobblemon.mod.common.api.snowstorm.ParticleCameraMode] via [com.cobblemon.mod.common.client.render.SnowstormParticle.renderInScene].
*
* Storms live high in the sky (empty collision/lighting) and never touch the world engine, so they appear
@@ -70,21 +70,21 @@ class MoveDexParticleScene : GuiParticleScene {
// Per-storm: which doll it renders against (true = target/B, false = user/A) and the tick it starts on.
private val sideOf = HashMap<ParticleStorm, Boolean>()
private val startTickOf = HashMap<ParticleStorm, Int>()
// Per-storm body-relative emit offset (height, front-toward-opponent) — the hitbox approximation, used
// Per-storm body-relative emit offset (height, front-toward-opponent): the hitbox approximation, used
// for headless measurement and as a fallback when the model has no matching geo locator.
private val locatorOffsetOf = HashMap<ParticleStorm, Pair<Float, Float>>()
// Per-storm keyframe locator names (e.g. ["special","target"]); resolved against the model's real geo
// locators at render time so Pokémon emit from their actual locators, exactly like battle.
// locators at render time so Pokemon emit from their actual locators, like battle.
private val locatorNamesOf = HashMap<ParticleStorm, List<String>>()
// Per-storm MUTABLE aim point (a 1-element holder the targetPos lambda reads); refreshed each tick from the
// real emit locator -> the opposing doll's centre, so beams angle toward the target instead of dead-level.
// Per-storm mutable aim point (a 1-element holder the targetPos lambda reads); refreshed each tick from the
// real emit locator toward the opposing doll's centre, so beams angle at the target instead of dead-level.
private val targetHolderOf = HashMap<ParticleStorm, Array<Vec3>>()
// Per-storm emit anchor frozen at the moment the storm starts firing. The real geo locator animates with the
// actor's head; without freezing, a stretched beam (a single quad anchored at the mouth) would slide its whole
// length — including its far end — around with the head. Locked once firing, so the beam holds steady.
// actor's head. Without freezing, a stretched beam (a single quad anchored at the mouth) would slide its whole
// length (including its far end) around with the head. Locked once firing, so the beam holds steady.
private val frozenEmitAnchorOf = HashMap<ParticleStorm, FloatArray>()
// Aim context (set each frame by the view): the doll states/scales/Z anchors + their centre heights, used
// Aim context (set each frame by the view): the doll states/scales/Z anchors plus their centre heights, used
// to resolve the real emit locator and the opposing doll's centre when refreshing aim points.
private var ctxActorState: PosableState? = null
private var ctxTargetState: PosableState? = null
@@ -95,7 +95,7 @@ class MoveDexParticleScene : GuiParticleScene {
private var ctxActorMid = 1f
private var ctxTargetMid = 1f
// Z of the centre of the target block's face that faces the attacker (its near face). Moves that fire at the
// target aim here — the front face — instead of the block's centre, since that's the surface a projectile hits.
// target aim at this front face instead of the block's centre, since that's the surface a projectile hits.
private var ctxTargetFaceZ = 0f
/** Provides the per-frame doll context used to keep dynamic aim points aligned with the real emit locators. */
@@ -119,17 +119,16 @@ class MoveDexParticleScene : GuiParticleScene {
val state = if (atTarget) ctxTargetState else ctxActorState
val scale = if (atTarget) ctxTargetScale else ctxActorScale
val sideZ = if (atTarget) ctxTargetZ else ctxUserZ
val e = emitAnchor(storm, atTarget, state, scale, sideZ) ?: continue // unresolved locator → skip
// A keyframe's targetLocators ALWAYS refer to the DEFENDER (battle's TargetsProvider), so the aim is
// the target doll regardless of which side emits: for a user-side effect (a beam) that's the opposing
// doll; for a DEFENDER-side effect it's the SAME doll — e.g. Draco Meteor's meteors target their own
// `root` so they rain down ON the target. Aiming target-side storms at the attacker (the old behavior)
// made those meteors land way off near the attacker.
val e = emitAnchor(storm, atTarget, state, scale, sideZ) ?: continue // unresolved locator: skip
// A keyframe's targetLocators always refer to the defender (battle's TargetsProvider), so the aim is
// the target doll regardless of which side emits. For a user-side effect (a beam) that's the opposing
// doll; for a defender-side effect it's the same doll, e.g. Draco Meteor's meteors target their own
// `root` so they rain down on the target.
val aimY = ctxTargetMid // the target doll's centre height
val aimZ = if (atTarget) ctxTargetZ else ctxTargetFaceZ // self-aim: target centre; incoming: its near face
holder[0] = origin.add((0f - e[0]).toDouble(), (aimY - e[1]).toDouble(), (aimZ - e[2]).toDouble())
}
// Re-evaluate every storm's storm-level target_delta* from the live aim point so TRAVELING emitters
// Re-evaluate every storm's storm-level target_delta* from the live aim point so traveling emitters
// (which read v.target_deltaz off the storm runtime, not per-particle) follow the target block's near
// face. Children inherit the parent's targetPos lambda, so they re-aim from the same holder too.
for (storm in storms) storm.refreshAttackingVariables()
@@ -140,9 +139,8 @@ class MoveDexParticleScene : GuiParticleScene {
private var tickCount = 0
private var renderPartial = 0f
// DIAGNOSTIC (temporary): per-effect counts of storms registered vs dropped by the cap, plus the peak live
// storm count. Logged once per build cycle in [clear] so a single Eruption run reveals whether the trail
// child effects (eruption_targetflame) are being starved by the storm cap.
// Per-effect counts of storms registered vs dropped by the cap, plus the peak live storm count. Logged once
// per build cycle in [clear] to reveal whether child effects are being starved by the storm cap.
private val spawnTally = HashMap<String, Int>()
private val dropTally = HashMap<String, Int>()
private var peakStorms = 0
@@ -154,17 +152,17 @@ class MoveDexParticleScene : GuiParticleScene {
private var nextLocatorOffset = 1.0f to 0f
private var nextLocatorNames = emptyList<String>()
// Hitbox dims (height, width in world blocks) of the emitting dolls, used to place named locators exactly
// like battle's LocatorAccess bounding-box approximations.
// Hitbox dims (height, width in world blocks) of the emitting dolls, used to place named locators like
// battle's LocatorAccess bounding-box approximations.
private var actorHeight = 2.0f
private var actorWidth = 1.0f
private var targetHeight = 2.0f
private var targetWidth = 1.0f
// The locators battle's LocatorAccess auto-generates from the entity bounding box for EVERY entity. A keyframe
// requesting one of these always resolves (we approximate it from the hitbox); a keyframe requesting only
// The locators battle's LocatorAccess auto-generates from the entity bounding box for every entity. A keyframe
// requesting one of these always resolves (approximated from the hitbox); a keyframe requesting only
// model-specific geo locators the doll doesn't define (hand_primary/hand/physical/mouth/...) resolves to
// nothing and the storm is skipped — matching battle, where such an effect simply isn't spawned.
// nothing and the storm is skipped, matching battle, where such an effect simply isn't spawned.
private val AUTOMATIC_LOCATORS = setOf("root", "target", "special_attack", "middle", "top")
fun clear() {
@@ -180,8 +178,7 @@ class MoveDexParticleScene : GuiParticleScene {
// Log the previous cycle's storm pressure before wiping it, so one run shows whether the cap drops storms.
if (spawnTally.isNotEmpty() || dropTally.isNotEmpty()) {
Cobblemon.LOGGER.info("MoveDex particle cycle: peakStorms={} spawned={} dropped={}", peakStorms, spawnTally, dropTally)
// If ANY effect was dropped, this move is still hitting the cap — surface it loudly so it's obvious
// which move (the effect ids name it) needs the ceiling raised further, not just Eruption.
// Surface dropped storms loudly, so it's obvious which move (named by its effect ids) is hitting the cap.
if (dropTally.isNotEmpty()) Cobblemon.LOGGER.warn("MoveDex particle scene hit the storm cap (peak {}); dropped child storms: {}", peakStorms, dropTally)
}
spawnTally.clear(); dropTally.clear(); peakStorms = 0
@@ -195,9 +192,9 @@ class MoveDexParticleScene : GuiParticleScene {
}
/**
* Body-relative emit offset (height up, distance toward the opponent) for one of battle's AUTOMATIC bounding-
* box locators (the ones [LocatorAccess] generates from the entity hitbox: root=base, middle=mid-height,
* target/special_attack=mid-height + forward, top=head). Only ever called by [emitAnchor] for a name that IS an
* Body-relative emit offset (height up, distance toward the opponent) for one of battle's automatic
* bounding-box locators (the ones [LocatorAccess] generates from the entity hitbox: root=base, middle=mid-height,
* target/special_attack=mid-height + forward, top=head). Only ever called by [emitAnchor] for a name that is an
* automatic locator, so the catch-alls just mirror `middle`.
*/
private fun locatorOffset(name: String?, atTarget: Boolean): Pair<Float, Float> {
@@ -214,10 +211,10 @@ class MoveDexParticleScene : GuiParticleScene {
}
/**
* Collision floor for [storm]: the dolls' feet, which is the storm's emit HEIGHT below the scene origin. Every
* Collision floor for [storm]: the dolls' feet, which is the storm's emit height below the scene origin. Every
* particle simulates at [origin] (the per-locator height is applied only at render via the emit anchor), so a
* storm emitting at mid-height (e.g. an impact burst at the `target` locator) must have its floor that height
* below the origin — otherwise its particles spawn already touching the slab and any `expire_on_contact` effect
* below the origin, otherwise its particles spawn already touching the slab and any `expire_on_contact` effect
* dies instantly. A ground emitter (root, height 0) keeps the floor at the origin, so Bulldoze still settles on it.
*/
override fun guiFloorY(storm: ParticleStorm): Double = origin.y - (locatorOffsetOf[storm]?.first?.toDouble() ?: 0.0)
@@ -225,7 +222,7 @@ class MoveDexParticleScene : GuiParticleScene {
/**
* Solid box for [storm]'s target, centred on its live aim point (the holder updated each tick to the target
* block's near-face centre). The aim point is built as origin + (aimPoint - emitAnchor), which renders back
* exactly onto the target's face — so a box here, in the same sim space the particles move in, stops
* exactly onto the target's face, so a box here, in the same sim space the particles move in, stops
* traveling/colliding effects at the visible target instead of letting them shoot past. Sized to the thing
* being hit (the block for user effects, the attacker for target effects). Null if the storm has no target.
*/
@@ -245,11 +242,9 @@ class MoveDexParticleScene : GuiParticleScene {
fun floorOriginY(): Double = origin.y
override fun addStorm(storm: ParticleStorm) {
// Safety cap: event-spawned children can multiply; never let the scene grow unbounded. 256 was far too low
// for emitter-heavy moves — Eruption's burning rocks each spawn a steady stream of `eruption_targetflame`
// trail storms on top of the actor-side fountain, so the later trails were hitting the cap and getting
// dropped (rocks survived because their storm was already registered). These are lightweight bookkeeping
// objects; the real cost is particles, which each storm bounds via its own max_particles.
// Safety cap: event-spawned children can multiply, so never let the scene grow unbounded. Storms are
// lightweight bookkeeping objects; the real cost is particles, which each storm bounds via its own
// max_particles.
if (storms.size + pending.size >= 4096) {
dropTally.merge(storm.effect.id.toString(), 1, Int::plus)
return
@@ -260,29 +255,16 @@ class MoveDexParticleScene : GuiParticleScene {
startTickOf[storm] = nextStartTick
locatorOffsetOf[storm] = nextLocatorOffset
locatorNamesOf[storm] = nextLocatorNames
// if (storm.effect.id.path.contains("rockslide")) {
// val sp = storm.effect.space; val col = storm.effect.particle.collision
// runCatching {
// Cobblemon.LOGGER.info(
// "ROCKDBG STORM-ADD id={} side={} startTick={} locOffset={} locNames={} | space[localPos={} localRot={} isLocalSpace={}] | coll[enabled={} radius={} expireOnContact={}] | maxAgeSec={} motion={} stormXYZ=({},{},{}) floorY={}",
// storm.effect.id.path, currentSide, nextStartTick, nextLocatorOffset, nextLocatorNames,
// sp.localPosition, sp.localRotation, sp.isLocalSpace,
// storm.runtime.resolveBoolean(col.enabled), "%.3f".format(storm.runtime.resolveDouble(col.radius)), col.expiresOnContact,
// "%.3f".format(storm.runtime.resolveDouble(storm.effect.particle.maxAge)), storm.effect.particle.motion::class.simpleName,
// "%.2f".format(storm.getX()), "%.2f".format(storm.getY()), "%.2f".format(storm.getZ()), "%.2f".format(guiFloorY(storm))
// )
// }
// }
// Battle sets entity_width/height/size/radius/scale from the emitting entity's bounding box; our storms
// have no entity, so populate them here from the emitting doll's hitbox — otherwise effects sized off the
// Pokémon (e.g. Dragon Claw's aura disc `radius: v.entity_radius`) collapse to a point. Done for EVERY
// storm (roots AND event-spawned children, which also get fresh entity-less runtimes).
// have no entity, so populate them here from the emitting doll's hitbox, otherwise effects sized off the
// Pokemon (e.g. Dragon Claw's aura disc `radius: v.entity_radius`) collapse to a point. Done for every
// storm (roots and event-spawned children, which also get fresh entity-less runtimes).
val ew = (if (currentSide) targetWidth else actorWidth).toDouble()
val eh = (if (currentSide) targetHeight else actorHeight).toDouble()
val longer = maxOf(ew, eh)
val env = storm.runtime.environment
// Effects reference these as BOTH variables (v.entity_*) and queries (q.entity_size/height/radius/width are
// each used by 100+ effects), so provide both namespaces — otherwise size-relative effects collapse to 0.
// Effects reference these as both variables (v.entity_*) and queries (q.entity_size/height/radius/width are
// each used by 100+ effects), so provide both namespaces, otherwise size-relative effects collapse to 0.
env.setSimpleVariable("entity_width", DoubleValue(ew)); env.query.addFunction("entity_width") { DoubleValue(ew) }
env.setSimpleVariable("entity_height", DoubleValue(eh)); env.query.addFunction("entity_height") { DoubleValue(eh) }
env.setSimpleVariable("entity_size", DoubleValue(longer)); env.query.addFunction("entity_size") { DoubleValue(longer) }
@@ -306,9 +288,8 @@ class MoveDexParticleScene : GuiParticleScene {
val resource = "data/cobblemon/action_effects/moves/${move.name}.json"
val stream = StructurePreviewResources.openStream(resource) ?: return
// Parse with the SAME deserializer the battle system uses, then walk the typed keyframes — so locators,
// entity conditions, molang delays, and sequence/parallel/fork nesting are interpreted exactly as in
// battle, instead of a hand-rolled JSON walk that drifted from the real format.
// Parse with the same deserializer the battle system uses, then walk the typed keyframes, so locators,
// entity conditions, molang delays, and sequence/parallel/fork nesting are interpreted as in battle.
val actionEffect = stream.use { ActionEffects.gson.fromJson(it.reader(), ActionEffectTimeline::class.java) } ?: return
walk(actionEffect.timeline, 0f, world, targetDistance)
}.onFailure { Cobblemon.LOGGER.warn("MoveDex particle scene build failed for {}", move.name, it) }
@@ -319,7 +300,7 @@ class MoveDexParticleScene : GuiParticleScene {
/**
* Walks the typed timeline keeping a running clock so each storm spawns at its real start time. Keyframes run
* sequentially (each advancing the clock by its `delay`/`pause`); `sequence` chains its children, `parallel`
* sequentially (each advancing the clock by its `delay`/`pause`). `sequence` chains its children, `parallel`
* runs them from the same start (clock = latest end), and `fork` spawns its branch without blocking. This is
* the same control flow [ActionEffectTimeline] uses to play the effect in battle.
*/
@@ -349,28 +330,28 @@ class MoveDexParticleScene : GuiParticleScene {
private fun spawnRoot(kf: EntityParticlesActionEffectKeyframe, startClock: Float, world: ClientLevel, targetDistance: Float) {
val effectId = kf.effect?.let(ResourceLocation::tryParse) ?: return // skip molang-computed ids (e.g. generic_move)
val effect = BedrockParticleOptionsRepository.getEffect(effectId) ?: return
// Which doll emits is decided by the keyframe's entityCondition, exactly as in battle:
// absent -> the USER (point A); "is_user == false" -> the defending Pokémon (point B).
// Which doll emits is decided by the keyframe's entityCondition, as in battle:
// absent means the user (point A); "is_user == false" means the defending Pokemon (point B).
val emitsAtTarget = kf.entityCondition.getString().replace(" ", "").contains("is_user==false")
// The typed `locators`/`targetLocators` fields ARE what battle reads (so a stray singular "locator" key is
// ignored here too — matching battle rather than our old hand-parser).
// The typed `locators`/`targetLocators` fields are what battle reads, so a stray singular "locator" key is
// ignored here too.
val locNames = kf.locators
val targetLocs = kf.targetLocators
val hasTarget = !targetLocs.isNullOrEmpty()
val wrapper = MatrixWrapper()
val matrix = PoseStack()
// Face the emitter at its opponent, exactly like the battle entity's locator matrix. The effect's local
// FORWARD is -Z; the user already looks down -Z (target sits there), but the TARGET's opponent (the
// attacker) is at +Z, so its emitter must be turned 180° — otherwise every "fire forward" effect on the
// defender (Close Combat's hit burst, Draco Meteor's meteors, etc.) sprays out the BACK of the target.
// Face the emitter at its opponent, like the battle entity's locator matrix. The effect's local forward
// is -Z; the user already looks down -Z (target sits there), but the target's opponent (the attacker) is
// at +Z, so its emitter must be turned 180 degrees, otherwise every "fire forward" effect on the
// defender (Close Combat's hit burst, Draco Meteor's meteors, etc.) sprays out the back of the target.
if (emitsAtTarget) matrix.mulPose(Axis.YP.rotationDegrees(180f))
// The scene origin goes in the wrapper's POSITION, NOT baked into the matrix translation. The particle
// The scene origin goes in the wrapper's position, not baked into the matrix translation. The particle
// engine's position-attachment step (ParticleStorm.tick) sets a storm's emitter position to
// attachedMatrix.getOrigin() = origin; if the matrix ALSO carried translate(origin), transformPosition
// would add the origin TWICE — sending parametric particles (Swords Dance's swords, Rock Slide's rocks)
// to ~2×origin (~666 blocks). Keeping the matrix orientation-only makes that re-add idempotent, matching
// how battle's locator matrices behave. getOrigin()/transformPosition still yield the same world values.
// attachedMatrix.getOrigin() = origin; if the matrix also carried translate(origin), transformPosition
// would add the origin twice, sending parametric particles (Swords Dance's swords, Rock Slide's rocks)
// to ~2x origin. Keeping the matrix orientation-only makes that re-add idempotent, matching how battle's
// locator matrices behave. getOrigin()/transformPosition still yield the same world values.
wrapper.updateMatrix(matrix.last().pose())
wrapper.updatePosition(origin)
// The opponent sits along the emitter's FORWARD (-Z) axis (every directional effect is authored against
@@ -384,12 +365,11 @@ class MoveDexParticleScene : GuiParticleScene {
// toward the opposing doll's centre each tick.
val holder = arrayOf(origin.add(0.0, (targetHeight - emitHeight).toDouble(), towardZ.toDouble()))
val runtime = MoLangRuntime().setup().setupClient()
// Set the spawn context (side, windup start tick, locators) BEFORE constructing the storm. The storm's
// `init` synchronously fires its creation events — which spawn child effects like Bubble Beam's bubble
// column — and those children inherit this context through addStorm. If it were set AFTER construction the
// child would grab the STALE nextStartTick left over from the previous cycle's ticking (a value near the
// loop point), so it would start too late or past the loop and only appear on some cycles. That was the
// "column shows every few loops" bug.
// Set the spawn context (side, windup start tick, locators) before constructing the storm. The storm's
// `init` synchronously fires its creation events, which spawn child effects like Bubble Beam's bubble
// column, and those children inherit this context through addStorm. If it were set after construction the
// child would grab the stale nextStartTick left over from the previous cycle's ticking (a value near the
// loop point), so it would start too late or past the loop and only appear on some cycles.
currentSide = emitsAtTarget
nextStartTick = (startClock * 20f).toInt()
nextLocatorOffset = locatorOffset(locNames.firstOrNull(), emitsAtTarget)
@@ -416,8 +396,8 @@ class MoveDexParticleScene : GuiParticleScene {
/**
* Runs the (already-built) effect headlessly for [ticks] 20 Hz steps, sampling every particle's scene-local
* position each step, then UNIONS [box] (minX,minY,minZ,maxX,maxY,maxZ) with the non-outlier extent of
* those samples. "Outlier" is the standard Tukey rule (beyond 1.5×IQR past the 25th/75th percentile) applied
* per axis — a general rule, not move-specific: any particle whose position is wild compared to the bulk
* those samples. "Outlier" is the standard Tukey rule (beyond 1.5x IQR past the 25th/75th percentile) applied
* per axis, a general rule, not move-specific: any particle whose position is wild compared to the bulk
* (e.g. an effect that fires sparks at speed 1299 which, lacking ground in the sky scene, fly hundreds of
* blocks) is ignored, so it can't blow up the framing. Consumes the scene; rebuild it for playback.
*/
@@ -437,11 +417,11 @@ class MoveDexParticleScene : GuiParticleScene {
val sx = (p.getX() - origin.x).toFloat()
val sy = (-(p.getY() - origin.y) - height).toFloat()
val sz = (p.getZ() - origin.z + anchorZ).toFloat()
// Drop non-finite AND far-flung samples: some moves have a runaway in a particle's parametric/
// dynamic motion that sends MANY particles hundreds of blocks away (Swords Dance's swords hit
// ~666). The Tukey filter only rejects statistical outliers, so when the runaway is the majority
// it survives and blows the fit box up → zoom clamps to 4 → the whole viewport reads as blank.
// Nothing legitimate in this scene is past ±64 blocks, so an absolute clamp keeps framing sane.
// Drop non-finite and far-flung samples: some moves have a runaway in a particle's parametric/
// dynamic motion that sends many particles hundreds of blocks away. The Tukey filter only rejects
// statistical outliers, so when the runaway is the majority it survives and blows the fit box up,
// zoom clamps to 4, and the whole viewport reads as blank. Nothing legitimate in this scene is
// past +/-64 blocks, so an absolute clamp keeps framing sane.
if (sx.isFinite() && sy.isFinite() && sz.isFinite() &&
sx > -64f && sx < 64f && sy > -64f && sy < 64f && sz > -64f && sz < 64f) {
xs.add(sx); ys.add(sy); zs.add(sz)
@@ -456,13 +436,13 @@ class MoveDexParticleScene : GuiParticleScene {
unionNonOutlier(box, zs, 2, 5)
}
/** Unions box[loIdx]/box[hiIdx] with the Tukey-fence (1.5×IQR) bounds of the sorted samples. */
/** Unions box[loIdx]/box[hiIdx] with the Tukey-fence (1.5x IQR) bounds of the sorted samples. */
private fun unionNonOutlier(box: FloatArray, sorted: List<Float>, loIdx: Int, hiIdx: Int) {
fun at(p: Float) = sorted[(sorted.size * p).toInt().coerceIn(0, sorted.size - 1)]
val q1 = at(0.25f); val q3 = at(0.75f)
val fence = 1.5f * (q3 - q1)
val lo = maxOf(sorted.first(), q1 - fence) // smallest non-outlier (>= lower fence)
val hi = minOf(sorted.last(), q3 + fence) // largest non-outlier (<= upper fence)
val hi = minOf(sorted.last(), q3 + fence) // largest non-outlier (<= upper fence)
if (lo < box[loIdx]) box[loIdx] = lo
if (hi > box[hiIdx]) box[hiIdx] = hi
}
@@ -470,7 +450,7 @@ class MoveDexParticleScene : GuiParticleScene {
/**
* Advances the scene by [ticksToStep] whole 20 Hz ticks (driven by the caller off the authoritative
* game-tick clock, so speed matches the real engine) and stores [partialTick] (the in-tick fraction)
* as the render interpolation amount. Stepping by whole ticks — never by accumulated frame fractions —
* as the render interpolation amount. Stepping by whole ticks, never by accumulated frame fractions,
* is what makes the particle motion and flipbook frames run at the correct battle speed.
*/
fun update(ticksToStep: Int, partialTick: Float) {
@@ -494,9 +474,8 @@ class MoveDexParticleScene : GuiParticleScene {
nextLocatorOffset = locatorOffsetOf[storm] ?: (1.0f to 0f)
nextLocatorNames = locatorNamesOf[storm] ?: emptyList()
runCatching {
// A stopped emitter has finished EMITTING, but its already-spawned particles must keep
// A stopped emitter has finished emitting, but its already-spawned particles must keep
// ticking until they age out (in the real engine the storm and particles tick separately).
// Skipping them here was freezing every `once`-emitter move's particles after active_time.
if (!storm.stopped) {
storm.tick() // emit + emitter event timeline while alive
} else if (!storm.despawned) {
@@ -510,38 +489,30 @@ class MoveDexParticleScene : GuiParticleScene {
}
/**
* Renders every storm's particles into the caller's already-applied scene pose (the iso viewport space).
* Each storm is translated to its scene-local anchor along the forward axis — [targetLocalZ] for
* target-side effects, [userLocalZ] otherwise — and its particles are placed by world-position-minus-
* [origin] so a storm and its event-spawned children share one consistent 3D space.
* [cameraAngle]/[cameraYaw]/[cameraPitch] describe the virtual battle camera (the inverse of the pose's
* view rotation) so each particle billboards through its real cameraMode like the world render. Guarded.
*/
/**
* Scene-local emit anchor [x,y,z] for a storm. Uses the doll's REAL geo locator (target/special/mouth/...)
* resolved from [state].locatorStates when it exists — placing particles at the exact spot battle uses,
* scaled to the model — falling back to the hitbox approximation otherwise. The locator offset is taken
* Scene-local emit anchor [x,y,z] for a storm. Uses the doll's real geo locator (target/special/mouth/...)
* resolved from [state].locatorStates when it exists, placing particles at the exact spot battle uses,
* scaled to the model, falling back to the hitbox approximation otherwise. The locator offset is taken
* relative to "root", un-flipped from LocatorAccess's ZP(180), facing-rotated, and scaled by [scale].
*/
private fun emitAnchor(storm: ParticleStorm, atTarget: Boolean, state: PosableState?, scale: Float, sideZ: Float): FloatArray? {
// Once frozen (after the storm starts firing) the emit point holds steady — see [frozenEmitAnchorOf].
// Once frozen (after the storm starts firing) the emit point holds steady; see [frozenEmitAnchorOf].
frozenEmitAnchorOf[storm]?.let { return it }
val names = locatorNamesOf[storm] ?: emptyList()
val root = state?.locatorStates?.get("root")
// Resolve the emit locator IN ORDER, exactly like battle (createAtEntity): the first requested name that is
// either a real geo locator on this doll OR one of the AUTOMATIC bbox locators (which battle auto-generates
// for every entity) wins. A name that is neither — e.g. Fire Punch / Dragon Claw's hand effects asking for
// hand_primary/hand/physical on a doll that defines none — does NOT resolve. If NO name resolves we return
// null and the renderer SKIPS the storm, because battle wouldn't spawn it there either.
// Resolve the emit locator in order, like battle (createAtEntity): the first requested name that is
// either a real geo locator on this doll or one of the automatic bbox locators (which battle auto-generates
// for every entity) wins. A name that is neither (e.g. Fire Punch / Dragon Claw's hand effects asking for
// hand_primary/hand/physical on a doll that defines none) does not resolve. If no name resolves we return
// null and the renderer skips the storm, because battle wouldn't spawn it there either.
for (name in names) {
val geoName = if (state != null) state.getMatchingLocators(name).firstOrNull() else null
val locWrapper = geoName?.let { state?.locatorStates?.get(it) }
if (root != null && locWrapper != null) {
val loc = locWrapper.getOrigin(); val r = root.getOrigin()
// getOrigin() returns the locator's true model-space offset from root (the per-locator ZP180 is a
// post-multiplied ROTATION, so it doesn't change the translation we read). X must NOT be negated —
// doing so mirrored left/right, e.g. Charizard's "physical" locator (its left hand at x=-21.5)
// emitted from the right. Y is negated to match the render's upright flip; Z is forward.
// post-multiplied rotation, so it doesn't change the translation we read). X must not be negated
// (doing so mirrors left/right, e.g. Charizard's "physical" locator, its left hand at x=-21.5,
// would emit from the right). Y is negated to match the render's upright flip; Z is forward.
var mx = (loc.x - r.x).toFloat(); val my = -(loc.y - r.y).toFloat(); var mz = (loc.z - r.z).toFloat()
if (atTarget) { mx = -mx; mz = -mz } // the target doll faces +Z (rendered at facingYaw 180)
val anchor = floatArrayOf(scale * mx, scale * my, sideZ + scale * mz)
@@ -562,19 +533,26 @@ class MoveDexParticleScene : GuiParticleScene {
return null
}
// DIAGNOSTIC (temporary): for each storm, log ONCE where its emit anchor resolved — which locator matched (or
// FALLBACK), the requested locator names, and the scene-space anchor [x, up, z]. Reveals e.g. Dragon Claw's
// effects defaulting to `target` vs landing somewhere unexpected. Soft-capped so emitter-heavy moves don't spam.
// For each storm, log once where its emit anchor resolved: which locator matched, the requested locator
// names, and the scene-space anchor [x, up, z]. Soft-capped so emitter-heavy moves don't spam.
private val loggedBeam = HashSet<ParticleStorm>()
private fun logBeamAnchor(storm: ParticleStorm, matched: String?, names: List<String>, anchor: FloatArray) {
if (loggedBeam.size >= 24 || !loggedBeam.add(storm)) return
// particleSimY = the storm's first particle's WORLD-Y minus origin (sim space): >0 means it sits ABOVE the
// particleSimY = the storm's first particle's world-Y minus origin (sim space): >0 means it sits above the
// scene origin (e.g. a Rock Slide rock lofted high by guide2), ~0 means it's at the dolls' feet level.
val pY = storm.particles.firstOrNull()?.let { (it.getY() - origin.y) }
Cobblemon.LOGGER.info("MoveDex emit {} locator='{}' (requested {}) anchor=[{}, {}, {}] particleSimY={}",
storm.effect.id, matched, names, anchor[0], anchor[1], anchor[2], pY)
}
/**
* Renders every storm's particles into the caller's already-applied scene pose (the iso viewport space).
* Each storm is translated to its scene-local anchor along the forward axis ([targetLocalZ] for target-side
* effects, [userLocalZ] otherwise) and its particles are placed by world-position-minus-[origin] so a storm
* and its event-spawned children share one consistent 3D space.
* [cameraAngle]/[cameraYaw]/[cameraPitch] describe the virtual battle camera (the inverse of the pose's
* view rotation) so each particle billboards through its real cameraMode like the world render. Guarded.
*/
fun render(
context: GuiGraphics,
userLocalZ: Float,
@@ -595,20 +573,20 @@ class MoveDexParticleScene : GuiParticleScene {
lightTexture.turnOnLightLayer()
RenderSystem.setShaderColor(1f, 1f, 1f, 1f)
// Occlude particles by the 3D scene: the dolls, floor and target block were drawn into the depth
// buffer, so turn depth TESTING on (the particle materials only ever set depthMask, never the test) —
// without it every particle drew on top of the models even when behind them. depthMask is forced OFF
// below so the transparent particles are hidden by nearer opaque geometry but don't write depth
// themselves, which would let them wrongly occlude one another and the 2D GUI drawn afterwards.
// buffer, so turn depth testing on (the particle materials only ever set depthMask, never the test),
// so a particle behind a model is hidden. depthMask is forced off below so the transparent particles
// are hidden by nearer opaque geometry but don't write depth themselves, which would let them wrongly
// occlude one another and the 2D GUI drawn afterwards.
RenderSystem.enableDepthTest()
val pose = context.pose()
// Batch every storm of a given material into ONE begin/build/draw — effects spawn many child
// Batch every storm of a given material into one begin/build/draw: effects spawn many child
// storms (trails, sub-bursts), and a draw call per storm per frame is what stutters. Each
// particle bakes its own doll-anchor, so no per-storm pose push is needed.
val active = storms.filter { it.particles.isNotEmpty() }
for ((material, group) in active.groupBy { it.effect.particle.material }) {
val builder = materialFor(material).begin(Tesselator.getInstance(), Minecraft.getInstance().textureManager) ?: continue
// The GUI scene pose winds the billboard quads back-facing, so the cull-enabled materials
// (particles_alpha/opaque — most effects) would cull them away. Force cull off: flat billboards.
// (particles_alpha/opaque, most effects) would cull them away. Force cull off: flat billboards.
RenderSystem.disableCull()
RenderSystem.depthMask(false) // test against the models' depth, but don't write our own
@@ -617,7 +595,7 @@ class MoveDexParticleScene : GuiParticleScene {
val state = if (atTarget) targetState else actorState
val scale = if (atTarget) targetScale else actorScale
val sideZ = if (atTarget) targetLocalZ else userLocalZ
val a = emitAnchor(storm, atTarget, state, scale, sideZ) ?: continue // unresolved locator → don't render
val a = emitAnchor(storm, atTarget, state, scale, sideZ) ?: continue // unresolved locator: don't render
storm.particles.forEach { it.renderInScene(pose, builder, cameraAngle, cameraYaw, cameraPitch, origin, a[0], a[1], a[2], renderPartial) }
}
builder.build()?.let { BufferUploader.drawWithShader(it) }
@@ -35,12 +35,6 @@ object MoveDexSceneRenderer {
* follow-camera right after each renderModel call to keep big-motion moves (e.g. Seismic Toss) in frame. */
var lastRootRiseY = 0f
/**
* Renders the model [identifier] (a species resource id, or the substitute fallback) at the current
* pose origin, rotated to [facingYaw] degrees and scaled by [modelScale]. The caller is expected to
* have already applied the scene transform (iso rotation, viewport placement) and translated to this
* doll's position. Returns true if it rendered, so the caller can fall back to the substitute.
*/
/**
* Draws the stadium ground plane: a horizontal textured quad at the dolls' feet (pose y = 0), spanning
* [halfX] left/right and [halfZ] forward/back (blocks, before the scene's zoom). The texture's two stand
@@ -52,14 +46,14 @@ object MoveDexSceneRenderer {
val pose = context.pose()
val entry = pose.last()
val matrix = entry.pose()
// Translucent (not cutout) so the texture's FULL alpha channel blends — semi-transparent floor pixels
// Translucent (not cutout) so the texture's full alpha channel blends: semi-transparent floor pixels
// show the panel background through them instead of being hard-clipped at a cutout threshold.
val renderType = RenderType.entityTranslucent(texture)
val immediate = Minecraft.getInstance().renderBuffers().bufferSource()
val buffer = immediate.getBuffer(renderType)
val light = LightTexture.pack(15, 15)
// Pose +Y points down in this scene (particles negate Y to rise, dolls flip to stand upright), so the
// floor's up-normal is -Y — matching the dolls so it shades the same under the inventory lighting.
// floor's up-normal is -Y, matching the dolls so it shades the same under the inventory lighting.
fun vert(x: Float, z: Float, u: Float, v: Float) {
buffer.addVertex(matrix, x, 0f, z)
.setColor(255, 255, 255, 255)
@@ -107,7 +101,7 @@ object MoveDexSceneRenderer {
pose.popPose()
}
/** Draws a small wireframe box [half] blocks around (x,y,z) in the current pose — a debug locator marker. */
/** Draws a small wireframe box [half] blocks around (x,y,z) in the current pose as a debug locator marker. */
fun renderDebugMarker(context: GuiGraphics, x: Float, y: Float, z: Float, half: Float, r: Float, g: Float, b: Float) {
val immediate = Minecraft.getInstance().renderBuffers().bufferSource()
val buffer = immediate.getBuffer(RenderType.lines())
@@ -142,8 +136,8 @@ object MoveDexSceneRenderer {
state.setPoseToFirstSuitable(PoseType.PROFILE)
state.updatePartialTicks(partialTicks)
model.applyAnimations(null, state, 0f, 0f, 0f, 0f, 0f)
// Capture how far the move animation has translated the model's ROOT vertically (Seismic Toss throws
// it way up). rootPart.y is raw bedrock units (÷16 -> blocks) and worldScale is the doll scale, so this
// Capture how far the move animation has translated the model's root vertically (Seismic Toss throws
// it way up). rootPart.y is raw bedrock units (÷16 to blocks) and worldScale is the doll scale, so this
// is the scene-space rise the follow-camera uses to keep the action framed. Positive = up.
lastRootRiseY = ((model.rootPart as? ModelPart)?.y ?: 0f) / 16f * worldScale
// Populate the model's real geo-defined locators (target/special/mouth/etc.) into state.locatorStates
@@ -153,9 +147,9 @@ object MoveDexSceneRenderer {
val pose = context.pose()
pose.pushPose()
pose.mulPose(Axis.YP.rotationDegrees(facingYaw))
// Render at the model's TRUE world scale (baseScale, in blocks) — the same as battle — so the model
// Render at the model's true world scale (baseScale, in blocks), the same as battle, so the model
// and the move's particles share one world scale. The scene's auto-fit handles fitting the window;
// we must NOT use profileScale here (it normalises model size and would decouple it from particles).
// profileScale must not be used here (it normalises model size and would decouple it from particles).
pose.scale(worldScale, worldScale, worldScale)
val immediate = Minecraft.getInstance().renderBuffers().bufferSource()
@@ -53,9 +53,6 @@ import kotlin.math.min
* - Info: the move's type/category/power/accuracy/PP/effect/description, like the Pokédex move info.
* - Learnset: every species that can learn the move, by method (level/TM/egg/tutor); "???" for species
* the player hasn't seen.
*
* NOTE: faithful in-battle move particles/animations are a server-driven subsystem; this first pass
* renders the dolls and play/pause scaffolding, with the action-effect particle playback to follow.
*/
class MoveDexView {
companion object {
@@ -71,7 +68,7 @@ class MoveDexView {
private const val ROW_H = 13
private const val VISIBLE_ROWS = 10
private const val SCALE = 0.5F // matches PokedexGUIConstants.SCALE for icon/button blits
private const val CATEGORY_BAR_W = 78 // filter portion width; sort buttons sit to its right (5 now)
private const val CATEGORY_BAR_W = 78 // filter portion width; sort buttons sit to its right
private const val SORT_BTN_SIZE = 20 // sort sheet is 20x40; rendered at SORT_BTN_SIZE*SCALE
private const val SORT_BTN_SPACING = 12
@@ -79,27 +76,25 @@ class MoveDexView {
private const val DETAIL_W = 160
private const val HEADER_Y = 24
private const val HEADER_H = 12
private const val SPECIES_Y = 37
private const val VIEW_Y = 50
private const val VIEW_H = 74
private const val CONTENT_TOP = 126 // content begins just below the viewport (VIEW_Y+VIEW_H=124)
private const val INFO_PANEL_Y = 148 // info_move_background panel top (133x38) — lowered 20px per request
private const val INFO_PANEL_Y = 148 // info_move_background panel top (133x38)
private const val SUBTAB_Y = 128 // learnset method sub-filter buttons (near content top)
private const val SUBTAB_H = 10
private const val CONTENT_BOTTOM = 190 // content ends above the bottom tab row
private const val TAB_ROW_Y = 196 // tabs follow the lowered panel; icons 196-204 (BASE_HEIGHT=207)
private const val SPECIES_H = 11
private const val TAB_ROW_Y = 196 // bottom tab icon row (BASE_HEIGHT=207)
private val SUBSTITUTE = cobblemonResource("substitute")
private val STADIUM_FLOOR = cobblemonResource("textures/gui/pokedex/movedex_stadium_floor.png")
// Default actor (the Pokémon performing the move): Smeargle — a real species — rather than the substitute doll.
// Default actor (the Pokémon performing the move) is Smeargle, a real species rather than the substitute doll.
private val DEFAULT_ACTOR = cobblemonResource("smeargle")
// The move's target is a substitute doll (a battle defender stand-in), so it can play the move's real
// `target` bedrock animation (throw/squash/recoil) exactly like a real defender.
private val TARGET = SUBSTITUTE
// Render scale + approximate hitbox dims (blocks) of the substitute target, used for aiming and the floor.
// Render scale and approximate hitbox dims (blocks) of the substitute target, used for aiming and the floor.
// Matched to the substitute geo: its `top` locator sits ~16.5px (~1 block) up at scale 1.0, so it's ~1
// block tall and ~0.9 wide. (Effects emit from its real geo locators now; these only drive the aim/floor.)
// block tall and ~0.9 wide. Effects emit from its real geo locators; these only drive the aim and floor.
private const val TARGET_SCALE = 1.0f
private const val TARGET_HEIGHT = 1.0f
private const val TARGET_WIDTH = 0.9f
@@ -108,25 +103,24 @@ class MoveDexView {
// however far apart the per-move separation pushes them.
private const val FLOOR_PAD_V = 0.25f
// Info tabs reuse the MAIN Pokédex's tab-row look exactly: 16x32 two-state icon sheets (top half = idle,
// bottom half = hover/active) drawn at half scale (8px on screen), with the select arrow over the active
// tab. Same textures, same scale, same vOffset trick as PokedexGUI's ScaledButton tab row.
// Info tabs reuse the main Pokédex tab-row look: 16x32 two-state icon sheets (top half = idle, bottom
// half = hover/active) drawn at half scale (8px on screen), with the select arrow over the active tab.
private val TAB_INFO_ICON = cobblemonResource("textures/gui/pokedex/tab_info.png")
private val TAB_LEARNSET_ICON = cobblemonResource("textures/gui/pokedex/tab_moves.png")
private val TAB_SELECT_ARROW = cobblemonResource("textures/gui/pokedex/select_arrow.png")
private const val TAB_ICON_NATIVE = 16 // texture is 16 wide x 32 tall (two stacked 16px states)
private const val TAB_ICON_SCALE = 0.5f // displayed 8x8 — identical to the main Pokédex tabs
private const val TAB_ICON_SCALE = 0.5f // displayed 8x8, identical to the main Pokédex tabs
private const val TAB_ICON_DISP = 8 // TAB_ICON_NATIVE * TAB_ICON_SCALE (on-screen px)
private const val TAB_ICON_GAP = 22 // same inter-tab spacing as the main Pokédex
private const val TAB_ICON_X0 = 2 // left indent of the first tab icon within the detail panel
// Move-info panel reuses the MAIN Pokédex's per-Pokémon move panel assets verbatim (info_move_background
// border + the shared power/accuracy/effect icons), so the MoveDex's Move Information tab matches it.
// Move-info panel reuses the main Pokédex per-Pokémon move panel assets (info_move_background border plus
// the shared power/accuracy/effect icons), so the MoveDex Info tab matches it.
private val MOVE_INFO_BACKGROUND = cobblemonResource("textures/gui/pokedex/info_move_background.png")
// Custom right-panel background skin for the MoveDex detail side.
private val INFO_BG = cobblemonResource("textures/gui/pokedex/movedex_info_background.png")
private const val INFO_SCALE = 0.5f // shared Pokédex move-panel text/icon scale
private const val INFO_TEXT_COLOUR = 0x606B6E // dark slate, as in the main Pokédex move panel
private const val INFO_TEXT_COLOUR = 0x606B6E // dark slate, matching the main Pokédex move panel
// Pokédex move-list assets, reused so the MoveDex list matches the Pokédex info-tab move list.
private val MOVE_SLOT = cobblemonResource("textures/gui/pokedex/move_slot.png")
@@ -149,7 +143,7 @@ class MoveDexView {
private const val SCENE_SEPARATION = 2.5 // base half-gap (blocks) along the forward (Z) axis
private const val SEP_BASE = 0.8 // separation = SEP_BASE + (actorWidth+targetWidth)*SEP_FACTOR
private const val SEP_FACTOR = 0.85 // so bigger Pokémon (Wailord) get pushed further apart
private const val DEFAULT_ZOOM = 26f // scale (px per scene block); scroll adjusts, recentre keeps it framed
private const val DEFAULT_ZOOM = 26f // scale (px per scene block); scroll adjusts it
private fun fallback(key: String, text: String) = Component.translatableWithFallback("cobblemon.ui.movedex.$key", text)
}
@@ -177,7 +171,7 @@ class MoveDexView {
private var actorState = FloatingState()
private val targetState = FloatingState()
private val scene = MoveDexParticleScene()
// The Pokémon performing the move — fixed to Smeargle (the actor can't be changed from the MoveDex).
// The Pokémon performing the move, fixed to Smeargle (the actor can't be changed from the MoveDex).
private var actorSpecies: ResourceLocation? = DEFAULT_ACTOR
// Timed bedrock animation cues (names, time, isTarget): actor windup + target damage/recoil.
private var animEvents: List<Triple<Set<String>, Float, Boolean>> = emptyList()
@@ -188,7 +182,7 @@ class MoveDexView {
private var muted = false // speaker toggle
private var debugLocators = false // draw locator/target-spot markers (toggle: L key or button)
// Follow-camera state: when a move flings a doll upward (Seismic Toss), the camera pans up and eases the zoom
// out just enough to keep the action framed, then settles back — all smoothed so normal moves look static.
// out just enough to keep the action framed, then settles back, all smoothed so normal moves look static.
private var dollRiseY = 0f // last frame's max doll vertical rise (scene units)
private var followZoomFactor = 1f // smoothed multiplicative zoom-out applied to sceneZoom
private var followPanY = 0f // smoothed vertical pan offset (scene units, up = -Y)
@@ -226,7 +220,7 @@ class MoveDexView {
sceneZoom = DEFAULT_ZOOM
}
/** The model identifier for the actor doll — the chosen species, or the Smeargle default. */
/** The model identifier for the actor doll: the chosen species, or the Smeargle default. */
private fun actorIdentifier(): ResourceLocation = actorSpecies ?: DEFAULT_ACTOR
/** (height, width) hitbox of the chosen actor species in world blocks; substitute-ish default otherwise. */
@@ -248,14 +242,9 @@ class MoveDexView {
}
/**
* Builds the move's particle scene and measures the CENTROID of (dolls + the move's full particle volume),
* pre-simulating the whole effect once. That centroid is what the camera pans to so everything stays nicely
* centred at any zoom/rotation. Scale itself is the single manual [sceneZoom]. Leaves the scene freshly built.
*/
/**
* Kills everything in flight on both dolls before a fresh move starts/loops: any active + primary bedrock
* Kills everything in flight on both dolls before a fresh move starts or loops: any active and primary bedrock
* animations, and any queued-but-unplayed animation cues. The particle scene's own [MoveDexParticleScene.clear]
* (run by [buildScene]) kills the active emitters and particles. Together this guarantees a new move/cycle
* (run by [buildScene]) kills the active emitters and particles. Together this guarantees a new move or cycle
* never inherits a half-played animation or leftover storm from the previous one.
*/
private fun resetDolls() {
@@ -272,7 +261,7 @@ class MoveDexView {
sceneSeparation = SEP_BASE + (aw + tw) * SEP_FACTOR
val sep = sceneSeparation.toFloat()
// Seed with the two dolls' bounds. Dolls render in the same "up = -Y" frame as particles (feet at 0,
// head toward -Y), so the box goes [-height, small] in Y — not symmetric.
// head toward -Y), so the box goes [-height, small] in Y, not symmetric.
fitBox[0] = -max(aw, tw); fitBox[1] = -max(ah, th); fitBox[2] = -(sep + max(aw, tw) * 0.5f)
fitBox[3] = max(aw, tw); fitBox[4] = 0.3f; fitBox[5] = sep + max(aw, tw) * 0.5f
@@ -289,7 +278,7 @@ class MoveDexView {
// Auto-fit the zoom to this move: project the 8 box corners through the view rotation and size so the
// projected extent fits BOTH viewport dimensions. This is the single sceneZoom value (scroll overrides
// it); it's what lets a tiny move (Charmander) and a tall one (Thunder) each fill the window sensibly.
// it), and it lets a tiny move (Charmander) and a tall one (Thunder) each fill the window sensibly.
val r = Quaternionf().mul(Axis.XP.rotationDegrees(scenePitch)).mul(Axis.YP.rotationDegrees(sceneYaw))
var minSX = Float.MAX_VALUE; var maxSX = -Float.MAX_VALUE
var minSY = Float.MAX_VALUE; var maxSY = -Float.MAX_VALUE
@@ -306,8 +295,8 @@ class MoveDexView {
val projW = max(0.5f, maxSX - minSX)
val projH = max(0.5f, maxSY - minSY)
sceneZoom = min((DETAIL_W - 14f) / projW, (VIEW_H - 12f) / projH).coerceIn(4f, 80f)
// DIAGNOSTIC (temporary): the measured fit box, pivot and resulting zoom. A wildly large box or off-centre
// pivot here = the scene zooms to nothing / pans off-screen, which reads as "the whole viewport is blank".
// Logs the measured fit box, pivot, and zoom. A wildly large box or off-centre pivot zooms the scene to
// nothing or pans it off-screen, which reads as a blank viewport.
Cobblemon.LOGGER.info("MoveDex fit {}: box=[{}, {}, {} .. {}, {}, {}] pivot=[{}, {}, {}] projW={} projH={} zoom={}",
move.name, fitBox[0], fitBox[1], fitBox[2], fitBox[3], fitBox[4], fitBox[5],
fixedPivot[0], fixedPivot[1], fixedPivot[2], projW, projH, sceneZoom)
@@ -345,8 +334,6 @@ class MoveDexView {
move.damageCategory.name.lowercase().contains(query)
}
// ---- rendering ----
fun render(context: GuiGraphics, x: Int, y: Int, mouseX: Int, mouseY: Int, delta: Float) {
renderSearchBar(context, x, y, mouseX, mouseY)
renderControls(context, x, y, mouseX, mouseY)
@@ -355,7 +342,7 @@ class MoveDexView {
renderMoveCount(context, x, y)
}
/** [discovered] / [total] moves, top-right — mirrors the main Pokédex's caught/total counter. */
/** Discovered / total moves, top-right, mirroring the main Pokédex's caught/total counter. */
private fun renderMoveCount(context: GuiGraphics, x: Int, y: Int) {
val text = Component.literal("${MoveDexData.discoveredCount()} / ${MoveDexData.totalCount()}").bold()
drawScaledTextJustifiedRight(context = context, font = CobblemonResources.DEFAULT_LARGE, text = text, x = x + DETAIL_X + DETAIL_W - 4, y = y + 14, colour = INFO_TEXT_COLOUR)
@@ -463,19 +450,19 @@ class MoveDexView {
// Custom right-panel background skin (header bar + tab-area bars). Drawn first, behind all content.
blitk(matrixStack = context.pose(), texture = INFO_BG, x = left, y = y + HEADER_Y, width = DETAIL_W, height = TAB_ROW_Y + TAB_ICON_DISP + 2 - HEADER_Y)
// Move name — styled like the main Pokédex species name (DEFAULT_LARGE, dark, not type-coloured).
// Move name, styled like the main Pokédex species name (DEFAULT_LARGE, dark, not type-coloured).
drawScaledText(context = context, font = CobblemonResources.DEFAULT_LARGE, text = move.displayName.copy().bold(), x = left + 4, y = y + HEADER_Y + 3, colour = INFO_TEXT_COLOUR, scale = 0.75F)
// Bigger elemental-type symbol for the selected move, on the right side of the header.
TypeIcon(x = left + DETAIL_W - 21, y = y + HEADER_Y - 2, type = move.elementalType, small = false).render(context)
// Battle-style viewport — no dark backdrop; the scene renders straight on the panel background texture.
// Battle-style viewport with no dark backdrop; the scene renders straight on the panel background texture.
renderDolls(context, left, y, delta)
renderPlayPause(context, left, y, mouseX, mouseY)
renderMuteButton(context, left, y, mouseX, mouseY)
renderLocatorButton(context, left, y, mouseX, mouseY)
// Info tabs — drawn exactly like the main Pokédex's tab row (16x32 two-state icon at half scale, bottom
// half on hover/active, with the select arrow above the active tab).
// Info tabs drawn like the main Pokédex tab row (16x32 two-state icon at half scale, bottom half on
// hover/active, with the select arrow above the active tab).
Tab.entries.forEachIndexed { index, tab ->
val ix = tabIconX(left, index)
val active = tab == activeTab
@@ -492,7 +479,7 @@ class MoveDexView {
scale = TAB_ICON_SCALE
)
if (active) {
// 12x6 arrow (shown 6x3) centred over the 8px icon, just above it — same offset as the Pokédex.
// 12x6 arrow (shown 6x3) centred over the 8px icon, just above it, same offset as the Pokédex.
blitk(
matrixStack = context.pose(),
texture = TAB_SELECT_ARROW,
@@ -512,171 +499,170 @@ class MoveDexView {
}
private fun renderDolls(context: GuiGraphics, left: Int, y: Int, delta: Float) {
// Advance by REAL wall-clock time at a fixed 20 Hz (50 ms/tick), independent of any screen pause or
// game-tick throttling, so the effect plays at exact battle speed. `delta` (the screen's partial-tick)
// is ignored for timing; the leftover-ms fraction drives interpolation instead.
val nowMs = Util.getMillis()
if (lastMs == 0L) lastMs = nowMs
val elapsedMs = (nowMs - lastMs).coerceIn(0L, 250L)
lastMs = nowMs
// Real wall-clock time at a fixed 20 Hz (50 ms/tick), independent of screen pause or game-tick throttling,
// so the effect plays at exact battle speed. The leftover-ms fraction drives interpolation.
val now = Util.getMillis()
if (lastMs == 0L) lastMs = now
val elapsedMs = (now - lastMs).coerceIn(0L, 250L)
lastMs = now
var steps = 0
if (playing) {
tickAccumulatorMs += elapsedMs.toFloat()
while (tickAccumulatorMs >= 50f && steps < 10) {
tickAccumulatorMs -= 50f
steps++
}
animTime += steps / 20f
// Play the move's sound cues as their timeline times arrive (advance the index even when muted so
// unmuting mid-cycle doesn't replay past sounds).
while (nextSoundIndex < moveSounds.size && moveSounds[nextSoundIndex].second <= animTime) {
if (!muted) playMoveSound(moveSounds[nextSoundIndex].first)
nextSoundIndex++
}
// Queue any animation cues now due (actor windup / target hit-recoil); played after the models render
// (addFirstAnimation needs currentModel set).
while (nextAnimIndex < animEvents.size && animEvents[nextAnimIndex].second <= animTime) {
val ev = animEvents[nextAnimIndex]
pendingAnims.add(ev.first to ev.third)
nextAnimIndex++
}
// Loop only once EVERYTHING has played — particles gone (scene emptied), every animation/sound cue
// fired, AND both dolls' bedrock animations have finished — so a move with trailing cues or a long
// doll animation after its last particle (common on longer moves) isn't cut off and restarted early.
// This skips only the empty trailing battle pauses. animDuration is the hard fallback cap.
val cuesRemain = nextAnimIndex < animEvents.size || nextSoundIndex < moveSounds.size
val dollAnimating = actorState.primaryAnimation != null || actorState.activeAnimations.isNotEmpty() ||
targetState.primaryAnimation != null || targetState.activeAnimations.isNotEmpty()
if (animTime >= animDuration || (animTime > 0.5f && scene.isEmpty && !cuesRemain && !dollAnimating)) {
animTime = 0f
tickAccumulatorMs = 0f
nextSoundIndex = 0
nextAnimIndex = 0
resetDolls() // clear leftover animations/cues so the next cycle starts clean
selected?.let { buildScene(it) } // rebuild kills the previous cycle's emitters/particles
}
}
val steps = if (playing) advancePlayback(elapsedMs) else 0
val partial = (tickAccumulatorMs / 50f).coerceIn(0f, 1f)
// Give the scene the current dolls so it can re-aim moves from the real emit locators toward the
// opposing doll's centre (the "target spot"); the substitute target has no locators so its centre
// (mid-height) is the defined aim point.
val aScale = actorSpecies?.let { PokemonSpecies.getByIdentifier(it)?.baseScale } ?: 1.0f
// FloatingState.updatePartialTicks accumulates, so the model animation advances by the same per-frame
// wall-clock delta (in ticks) the particle scene uses, keeping models and particles on one clock.
val advance = if (playing) elapsedMs.toFloat() / 50f else 0f
applySceneAim()
// A misbehaving effect must never throw out of the sim, or it would skip the entire viewport render.
runCatching { scene.update(steps, partial) }.onFailure { Cobblemon.LOGGER.warn("MoveDex scene update failed", it) }
context.enableScissor(left, y + VIEW_Y, left + DETAIL_W, y + VIEW_Y + VIEW_H)
val pose = context.pose()
beginScenePose(pose, left, y)
renderSceneModels(context, pose, advance)
renderSceneParticles(context)
pose.popPose()
// The scene writes depth at GUI z=200, so the overlay controls drawn afterward at the base z would fail
// the LEQUAL test and hide behind it. Clear only the viewport region's depth (the scissor still bounds it).
RenderSystem.clear(GL11.GL_DEPTH_BUFFER_BIT, Minecraft.ON_OSX)
context.disableScissor()
renderViewportDebugText(context, left, y)
}
/**
* Advances the playback clock by [elapsedMs], fires due sound and animation cues, and loops when finished.
* Returns the number of 20 Hz ticks for the particle sim to step.
*/
private fun advancePlayback(elapsedMs: Long): Int {
var steps = 0
tickAccumulatorMs += elapsedMs.toFloat()
while (tickAccumulatorMs >= 50f && steps < 10) {
tickAccumulatorMs -= 50f
steps++
}
animTime += steps / 20f
// Advance the sound index even when muted, so unmuting mid-cycle doesn't replay past cues.
while (nextSoundIndex < moveSounds.size && moveSounds[nextSoundIndex].second <= animTime) {
if (!muted) playMoveSound(moveSounds[nextSoundIndex].first)
nextSoundIndex++
}
// Animation cues are queued now and played after the models render, when addFirstAnimation has a model.
while (nextAnimIndex < animEvents.size && animEvents[nextAnimIndex].second <= animTime) {
val ev = animEvents[nextAnimIndex]
pendingAnims.add(ev.first to ev.third)
nextAnimIndex++
}
loopIfFinished()
return steps
}
/** Restarts the cycle once particles, every cue, and both dolls' bedrock animations have finished. animDuration is the hard cap. */
private fun loopIfFinished() {
val cuesRemain = nextAnimIndex < animEvents.size || nextSoundIndex < moveSounds.size
val dollAnimating = actorState.primaryAnimation != null || actorState.activeAnimations.isNotEmpty() ||
targetState.primaryAnimation != null || targetState.activeAnimations.isNotEmpty()
if (animTime >= animDuration || (animTime > 0.5f && scene.isEmpty && !cuesRemain && !dollAnimating)) {
animTime = 0f
tickAccumulatorMs = 0f
nextSoundIndex = 0
nextAnimIndex = 0
resetDolls()
selected?.let { buildScene(it) }
}
}
/** Aims every targeted effect from its real emit locator toward the substitute target's mid-height centre (it has no locators of its own). */
private fun applySceneAim() {
val (ahh, _) = actorHW()
// Aim at the substitute target's centre: mid-height, and the near (+Z) face — half its width toward the
// attacker from the doll's centre at -separation.
val targetCentreY = TARGET_HEIGHT * 0.5f
val targetFaceZ = -sceneSeparation.toFloat() + TARGET_WIDTH * 0.5f
scene.setAimContext(
actorState, targetState, aScale, 1.0f,
actorState, targetState, actorBaseScale(), 1.0f,
sceneSeparation.toFloat(), -sceneSeparation.toFloat(), ahh * 0.5f, targetCentreY, targetFaceZ
)
// Guard the particle sim: a misbehaving effect must never throw out of here, or it would skip the ENTIRE
// viewport render below (floor + dolls + particles) and blank everything — that's what made Swords Dance
// show nothing at all. Logged so the offending move's actual error is visible.
runCatching { scene.update(steps, partial) }.onFailure { Cobblemon.LOGGER.warn("MoveDex scene update failed", it) }
}
// Advance the doll's bedrock animation by the SAME wall-clock delta (in ticks) the particle scene uses,
// so the model animation and the particle timeline run on one 20 Hz clock and stay in sync (the particles
// fire exactly when the animation reaches that point). FloatingState.updatePartialTicks ACCUMULATES, so
// this must be a per-frame delta, not the in-tick fraction. Both reset together on swap/loop.
val advance = if (playing) (elapsedMs.toFloat() / 50f) else 0f
private fun actorBaseScale(): Float = actorSpecies?.let { PokemonSpecies.getByIdentifier(it)?.baseScale } ?: 1.0f
context.enableScissor(left, y + VIEW_Y, left + DETAIL_W, y + VIEW_Y + VIEW_H)
// Follow-camera: ease the pivot up and the zoom out by just enough to keep a doll thrown upward (Seismic
// Toss launches the target ~15 blocks up) in frame, then settle back as it returns. Uses LAST frame's
// measured rise; manual zoom (sceneZoom) and rotation stay the base this multiplies/offsets. Smoothed so
// resting moves (rise≈0) are unaffected, satisfying "static for normal moves, follow the big ones".
/**
* Eases the follow-camera (lifts the pivot and zooms out just enough to keep an upward-thrown doll in frame,
* from last frame's measured rise) and applies the scene pose. Pushes a pose that [renderDolls] later pops.
*/
private fun beginScenePose(pose: com.mojang.blaze3d.vertex.PoseStack, left: Int, y: Int) {
val baseExtentY = max(1f, fitBox[4] - fitBox[1])
val rise = if (dollRiseY.isFinite()) dollRiseY.coerceIn(0f, 40f) else 0f
val desiredFactor = (baseExtentY / (baseExtentY + rise)).coerceIn(0.2f, 1f)
val desiredPanY = -rise / 2f
val ease = if (playing) 0.18f else 0.5f
// Recover if a prior frame's non-finite rise already poisoned these (NaN is sticky through +=).
// NaN is sticky through +=, so recover if a prior frame's non-finite rise already poisoned these.
if (!followZoomFactor.isFinite()) followZoomFactor = 1f
if (!followPanY.isFinite()) followPanY = 0f
followZoomFactor += (desiredFactor - followZoomFactor) * ease
followPanY += (desiredPanY - followPanY) * ease
val finalScale = (sceneZoom * followZoomFactor).let { if (it.isFinite() && it > 0f) it else sceneZoom }
// Pan so the (rotated) content pivot (raised by the follow offset) lands at the viewport centre.
val pv = Vector3f(fixedPivot[0], fixedPivot[1] + followPanY, fixedPivot[2])
Quaternionf().mul(Axis.XP.rotationDegrees(scenePitch)).mul(Axis.YP.rotationDegrees(sceneYaw)).transform(pv)
val cx = left + DETAIL_W / 2.0
val cy = y + VIEW_Y + VIEW_H / 2.0
// A non-finite pivot (from a degenerate fit box) would make pose.translate NaN/Inf and blank the ENTIRE
// viewport (floor + dolls included). Fall back to no pan so the scene still renders centred.
// A non-finite pivot would make pose.translate NaN and blank the viewport, so fall back to no pan.
val pvx = if (pv.x.isFinite()) pv.x else 0f
val pvy = if (pv.y.isFinite()) pv.y else 0f
val pose = context.pose()
pose.pushPose()
pose.translate(cx - finalScale * pvx, cy - finalScale * pvy, 200.0)
pose.scale(finalScale, finalScale, finalScale)
pose.mulPose(Axis.XP.rotationDegrees(scenePitch))
pose.mulPose(Axis.YP.rotationDegrees(sceneYaw))
}
// Render both dolls at their TRUE world scale (baseScale) so model size matches the particle world
// scale, exactly like battle. The auto-fit camera handles fitting everything in the window.
val actorScale = actorSpecies?.let { PokemonSpecies.getByIdentifier(it)?.baseScale } ?: 1.0f
/**
* Renders the stadium floor and both dolls at true world scale, records the upward rise for next frame's
* follow-camera, and plays this frame's due animation cues.
*/
private fun renderSceneModels(context: GuiGraphics, pose: com.mojang.blaze3d.vertex.PoseStack, advance: Float) {
val actorScale = actorBaseScale()
Lighting.setupForEntityInInventory()
runCatching {
// Stadium floor at the dolls' feet (y=0). Its Z half-extent is derived from the per-move separation so
// the texture's A/B pads (at V ±FLOOR_PAD_V from centre) land EXACTLY under each doll however far the
// differing hitbox volumes push them apart: pad z = ±FLOOR_PAD_V*(2*halfZ) must equal ±separation, so
// halfZ = separation / (2*FLOOR_PAD_V). This is also the plane the scene collides ground particles
// (Bulldoze) against, so they visibly come to rest on it.
// The floor is a SQUARE: halfX == halfZ, so the quad stays symmetric about (0,0) (centred) and BOTH the
// X and Z extents grow together with the separation — i.e. bigger-hitbox mons (which push the dolls
// further apart in Z) enlarge the whole square uniformly while the dolls stay pinned to the same pads,
// so they read as being in the same relative spots regardless of size.
// The floor is a square whose half-extent grows with the separation, so the texture's A/B pads stay
// under each doll however far differing hitbox volumes push them apart. Ground particles collide on it.
val floorHalf = (sceneSeparation / (2.0 * FLOOR_PAD_V)).toFloat()
MoveDexSceneRenderer.renderFloor(context, STADIUM_FLOOR, halfX = floorHalf, halfZ = floorHalf)
// Dolls are separated along the forward (Z) axis — the same axis directional effects fire along —
// so beams/sprays travel from the user doll to the target doll. User is nearer (+Z), facing -Z.
// Dolls are separated along the forward (Z) axis so directional effects travel user (+Z) to target.
pose.pushPose()
pose.translate(0.0, 0.0, sceneSeparation)
MoveDexSceneRenderer.renderModel(context, actorIdentifier(), actorState, actorScale, facingYaw = 0f, partialTicks = advance)
pose.popPose()
val actorRise = MoveDexSceneRenderer.lastRootRiseY // read before the target render overwrites it
val actorRise = MoveDexSceneRenderer.lastRootRiseY
// The target is now a real posable model (a target-block-shaped dummy), so it plays the move's actual
// `target` bedrock animation exactly like a battle defender — the same shared move animations drive its
// root, giving faithful translate/scale/rotation instead of a procedural guess.
pose.pushPose()
pose.translate(0.0, 0.0, -sceneSeparation)
MoveDexSceneRenderer.renderModel(context, TARGET, targetState, targetScale(), facingYaw = 180f, partialTicks = advance)
pose.popPose()
// Record the largest upward rise of either doll for NEXT frame's follow-camera (1-frame lag is fine).
// Guard against NaN/Inf from a misbehaving animation: a non-finite rise would poison followZoomFactor
// (which then stays NaN every frame) → finalScale NaN → pose.scale(NaN) blanks the ENTIRE viewport.
// Largest upward rise of either doll, for next frame's follow-camera; a non-finite value would poison it.
dollRiseY = max(0f, max(actorRise, MoveDexSceneRenderer.lastRootRiseY)).let { if (it.isFinite()) it else 0f }
// Play this frame's due cues on the matching doll, EXACTLY like battle's PlayPosableAnimationHandler:
// addFirstAnimation across the move's ordered names, first match wins, and NO fallback — if a species
// has no animation for the move (e.g. Charmander has no "physical", so Fire Punch resolves nothing) it
// plays no body animation, just like battle. (We resolve across poses because the GUI doll sits in a UI
// pose and can't enter the battle pose where those animations are registered.)
if (pendingAnims.isNotEmpty()) {
pendingAnims.forEach { (names, isTarget) ->
(if (isTarget) targetState else actorState).addFirstAnimationAcrossPoses(names)
}
pendingAnims.clear()
}
playDueAnimations()
if (debugLocators) renderDebugLocators(context, pose, actorScale)
}
Lighting.setupFor3DItems()
}
// The virtual battle camera is the INVERSE of the scene's view rotation (XP(pitch)·YP(yaw)); feeding
// that to each particle's cameraMode makes it billboard to face the viewer, exactly like in-world.
/** Plays this frame's queued cues on the matching doll: first registered animation name wins, no fallback, exactly like battle. */
private fun playDueAnimations() {
if (pendingAnims.isEmpty()) return
pendingAnims.forEach { (names, isTarget) ->
(if (isTarget) targetState else actorState).addFirstAnimationAcrossPoses(names)
}
pendingAnims.clear()
}
/** Renders the scene's particles, feeding each the billboard camera (the inverse of the scene's view rotation) so they face the viewer like in-world. */
private fun renderSceneParticles(context: GuiGraphics) {
val cameraAngle = Quaternionf()
.mul(Axis.XP.rotationDegrees(scenePitch))
.mul(Axis.YP.rotationDegrees(sceneYaw))
.conjugate()
// Particles share the scene pose; "at target" effects emit at B, others at A.
scene.render(
context,
userLocalZ = sceneSeparation.toFloat(),
@@ -686,20 +672,12 @@ class MoveDexView {
cameraPitch = -scenePitch,
actorState = actorState,
targetState = targetState,
actorScale = actorScale,
actorScale = actorBaseScale(),
targetScale = 1.0f
)
}
pose.popPose()
// The 3D scene (dolls + floor) is drawn at GUI z=200 and WRITES depth, so the control buttons drawn
// afterward at the base GUI z would fail the LEQUAL depth test and hide BEHIND the viewport. Clear the
// depth buffer now, while the viewport scissor is still bound (glClear respects the scissor box), so only
// the preview region is reset — every overlay control (play/pause, mute, locator, species box) then renders
// unconditionally on top of the viewport without disturbing the rest of the screen's depth.
RenderSystem.clear(GL11.GL_DEPTH_BUFFER_BIT, Minecraft.ON_OSX)
context.disableScissor()
// Debug readout so tuning values can be reported back (drag = rotate, scroll over viewport = zoom).
private fun renderViewportDebugText(context: GuiGraphics, left: Int, y: Int) {
drawScaledText(
context = context,
text = Component.literal("Yaw ${sceneYaw.toInt()} Pitch ${scenePitch.toInt()} Zoom ${"%.1f".format(sceneZoom)}"),
@@ -713,7 +691,7 @@ class MoveDexView {
/**
* Debug overlay (toggle with L): a red marker at the target block's aim point (the front-face centre every
* move fires at and traveling effects now stop on) and blue markers at each of the actor's real geo locators,
* move fires at and traveling effects stop on) and blue markers at each of the actor's real geo locators,
* placed exactly where the move emits from them (un-flipped vs the root locator, scaled, scene up = -Y).
*/
private fun renderDebugLocators(context: GuiGraphics, pose: com.mojang.blaze3d.vertex.PoseStack, actorScale: Float) {
@@ -726,8 +704,8 @@ class MoveDexView {
pose.translate(0.0, 0.0, sceneSeparation)
actorState.locatorStates.forEach { (_, wrapper) ->
val o = wrapper.getOrigin()
// Match emitAnchor: X is the true model offset (NOT negated — negating mirrored left/right), Y negated
// for the upright render flip, Z forward. Keeps the markers on the same side as the model's parts.
// Match emitAnchor: X is the true model offset (NOT negated, since negating mirrors left/right), Y
// negated for the upright render flip, Z forward. Keeps the markers on the same side as the model's parts.
val mx = (o.x - root.x).toFloat()
val my = -((o.y - root.y).toFloat())
val mz = (o.z - root.z).toFloat()
@@ -809,8 +787,8 @@ class MoveDexView {
val effect = move.effectChances.firstOrNull() ?: 0.0
drawScaledTextJustifiedRight(context = context, text = Component.literal(if (effect > 0) "${effect.toInt()}%" else "—"), x = px + 57, y = py + 32, scale = INFO_SCALE, colour = INFO_TEXT_COLOUR)
// SCROLLABLE description in the panel's right column (px+63..px+131 ≈ 68px), wrapped at the panel scale —
// mirrors the main Pokédex's MoveDescriptionWidget: the mouse wheel over the content area drives contentScroll
// SCROLLABLE description in the panel's right column (px+63..px+131 ≈ 68px), wrapped at the panel scale,
// mirroring the main Pokédex's MoveDescriptionWidget: the mouse wheel over the content area drives contentScroll
// (shared with the Learnset list; reset to 0 on tab switch), and only a window of lines is shown.
val font = Minecraft.getInstance().font
val descLines = font.split(move.description, (65f / INFO_SCALE).toInt())
@@ -875,24 +853,35 @@ class MoveDexView {
}
private fun panel(context: GuiGraphics, left: Int, y: Int, contentTop: Int) {
// No dark box — content draws straight on the panel background texture (kept as a no-op for call sites).
// No dark box: content draws straight on the panel background texture (kept as a no-op for call sites).
}
// ---- input ----
fun mouseClicked(x: Int, y: Int, mouseX: Double, mouseY: Double): Boolean {
// Arm scene-rotation dragging ONLY when the press lands inside the 3D viewport (not the move list,
// controls, or play button) — otherwise the tiny mouse movement while clicking a move in the list
// would spin the scene. mouseDragged checks this flag.
armSceneDrag(x, y, mouseX, mouseY)
clickSearchBar(x, y, mouseX, mouseY)?.let { return it }
clickFilterAndSort(x, y, mouseX, mouseY)?.let { return it }
clickMoveList(x, y, mouseX, mouseY)?.let { return it }
val move = selected ?: return false
val left = x + DETAIL_X
clickViewportControls(move, left, y, mouseX, mouseY)?.let { return it }
clickTabs(left, y, mouseX, mouseY)?.let { return it }
clickLearnsetMethods(move, left, y, mouseX, mouseY)?.let { return it }
return false
}
/**
* Arms scene-rotation dragging only when the press lands inside the viewport, not on the move list or the
* control strip, so the small movement while clicking a list row doesn't spin the scene. [mouseDragged] reads it.
*/
private fun armSceneDrag(x: Int, y: Int, mouseX: Double, mouseY: Double) {
val dl = x + DETAIL_X
// The bottom-left control strip (play, mute, locator-toggle) sits inside the viewport; pressing any of it
// must NOT arm scene-rotation drag.
val onControls = mouseX >= dl + 2 && mouseX <= dl + 44 && mouseY >= y + VIEW_Y + VIEW_H - 12 && mouseY <= y + VIEW_Y + VIEW_H - 2
sceneDragArmed = selected != null && !onControls &&
mouseX >= dl && mouseX <= dl + DETAIL_W && mouseY >= y + VIEW_Y && mouseY <= y + VIEW_Y + VIEW_H
}
// Move-list search bar focus.
private fun clickSearchBar(x: Int, y: Int, mouseX: Double, mouseY: Double): Boolean? {
val sLeft = x + LIST_X
val sTop = y + SEARCH_Y
if (mouseX >= sLeft && mouseX <= sLeft + LIST_W && mouseY >= sTop && mouseY <= sTop + SEARCH_H) {
@@ -900,92 +889,95 @@ class MoveDexView {
return true
}
searchFocused = false
return null
}
// Filter bar: click anywhere on it to cycle ALL -> Physical -> Special -> Status.
private fun clickFilterAndSort(x: Int, y: Int, mouseX: Double, mouseY: Double): Boolean? {
val (fbx, fby, fbw, fbh) = filterBarRect(x, y)
if (mouseX >= fbx && mouseX <= fbx + fbw && mouseY >= fby && mouseY <= fby + fbh) {
filter = Filter.entries[(filter.ordinal + 1) % Filter.entries.size]; scrollRow = 0; return true
}
// Sort buttons (10x10 each).
val scaledSize = (SORT_BTN_SIZE * SCALE).toInt()
sortButtonRects(x, y).forEachIndexed { i, (bx, by) ->
if (mouseX >= bx && mouseX <= bx + scaledSize && mouseY >= by && mouseY <= by + scaledSize) { sort = Sort.entries[i]; scrollRow = 0; return true }
}
return null
}
// Move list.
if (mouseX >= x + LIST_X && mouseX <= x + LIST_X + LIST_W) {
val list = moves()
val end = (scrollRow + VISIBLE_ROWS).coerceAtMost(list.size)
for (i in scrollRow until end) {
val rowY = y + LIST_TOP + (i - scrollRow) * ROW_H
if (mouseY >= rowY && mouseY <= rowY + 12) {
if (MoveDexData.isDiscovered(list[i])) {
selected = list[i]
activeTab = Tab.INFO
contentScroll = 0
learnMethodFilter = null
// Auto-play the move once on selection, then pause at the end of the cycle.
animDuration = MoveDexAnimation.durationFor(list[i])
animEvents = MoveDexAnimation.animationEventsFor(list[i])
nextAnimIndex = 0
moveSounds = MoveDexAnimation.soundsFor(list[i])
nextSoundIndex = 0
animTime = 0f
lastMs = 0L
tickAccumulatorMs = 0f
prepareMove(list[i]) // builds the scene + computes the fixed framing
playing = true
}
return true
}
/** Hit-tests the move list. Returns true on a selectable row, false inside the list column with no row, null otherwise. */
private fun clickMoveList(x: Int, y: Int, mouseX: Double, mouseY: Double): Boolean? {
if (mouseX < x + LIST_X || mouseX > x + LIST_X + LIST_W) return null
val list = moves()
val end = (scrollRow + VISIBLE_ROWS).coerceAtMost(list.size)
for (i in scrollRow until end) {
val rowY = y + LIST_TOP + (i - scrollRow) * ROW_H
if (mouseY >= rowY && mouseY <= rowY + 12) {
if (MoveDexData.isDiscovered(list[i])) selectMove(list[i])
return true
}
return false
}
return false
}
val move = selected ?: return false
val left = x + DETAIL_X
/** Selects [move] and auto-plays it once: builds the scene, computes the fixed framing, and resets the clock. */
private fun selectMove(move: MoveTemplate) {
selected = move
activeTab = Tab.INFO
contentScroll = 0
learnMethodFilter = null
animDuration = MoveDexAnimation.durationFor(move)
animEvents = MoveDexAnimation.animationEventsFor(move)
nextAnimIndex = 0
moveSounds = MoveDexAnimation.soundsFor(move)
nextSoundIndex = 0
animTime = 0f
lastMs = 0L
tickAccumulatorMs = 0f
prepareMove(move)
playing = true
}
// Play/pause. Pressing play after a finished cycle restarts it.
val pbx = left + 2; val pby = y + VIEW_Y + VIEW_H - 12
/** Play/pause (restarts a finished cycle), mute, and the locator-debug toggle, all in the viewport's bottom-left strip. */
private fun clickViewportControls(move: MoveTemplate, left: Int, y: Int, mouseX: Double, mouseY: Double): Boolean? {
val pby = y + VIEW_Y + VIEW_H - 12
val pbx = left + 2
if (mouseX >= pbx && mouseX <= pbx + 12 && mouseY >= pby && mouseY <= pby + 10) {
if (!playing && animTime >= animDuration) { animTime = 0f; lastMs = 0L; tickAccumulatorMs = 0f; nextSoundIndex = 0; move?.let { buildScene(it) } }
if (!playing && animTime >= animDuration) { animTime = 0f; lastMs = 0L; tickAccumulatorMs = 0f; nextSoundIndex = 0; buildScene(move) }
playing = !playing
return true
}
// Mute toggle (speaker icon).
val mbx = left + 16
if (mouseX >= mbx && mouseX <= mbx + 12 && mouseY >= pby && mouseY <= pby + 10) {
muted = !muted
return true
}
// Locator-visibility debug toggle (also bound to the L key).
val lbx = left + 30
if (mouseX >= lbx && mouseX <= lbx + 12 && mouseY >= pby && mouseY <= pby + 10) {
debugLocators = !debugLocators
return true
}
return null
}
// Tabs (icon buttons at the bottom — hit-tested against the same 8x8 rects renderDetail draws).
if (mouseY >= y + TAB_ROW_Y && mouseY <= y + TAB_ROW_Y + TAB_ICON_DISP) {
Tab.entries.forEachIndexed { index, tab ->
val ix = tabIconX(left, index)
if (mouseX >= ix && mouseX <= ix + TAB_ICON_DISP) { activeTab = tab; contentScroll = 0; return true }
}
private fun clickTabs(left: Int, y: Int, mouseX: Double, mouseY: Double): Boolean? {
if (mouseY < y + TAB_ROW_Y || mouseY > y + TAB_ROW_Y + TAB_ICON_DISP) return null
Tab.entries.forEachIndexed { index, tab ->
val ix = tabIconX(left, index)
if (mouseX >= ix && mouseX <= ix + TAB_ICON_DISP) { activeTab = tab; contentScroll = 0; return true }
}
return null
}
// Learnset method sub-buttons.
if (activeTab == Tab.LEARNSET && mouseY >= y + SUBTAB_Y && mouseY <= y + SUBTAB_Y + SUBTAB_H) {
val methods = MoveDexData.learnedBy(move).map { it.method }.distinct().sortedBy { it.order }
val options = listOf<MoveDexData.LearnMethod?>(null) + methods
val buttonW = (DETAIL_W - (options.size - 1) * 2) / options.size
options.forEachIndexed { index, method ->
val tx = left + index * (buttonW + 2)
if (mouseX >= tx && mouseX <= tx + buttonW) { learnMethodFilter = method; contentScroll = 0; return true }
}
private fun clickLearnsetMethods(move: MoveTemplate, left: Int, y: Int, mouseX: Double, mouseY: Double): Boolean? {
if (activeTab != Tab.LEARNSET || mouseY < y + SUBTAB_Y || mouseY > y + SUBTAB_Y + SUBTAB_H) return null
val methods = MoveDexData.learnedBy(move).map { it.method }.distinct().sortedBy { it.order }
val options = listOf<MoveDexData.LearnMethod?>(null) + methods
val buttonW = (DETAIL_W - (options.size - 1) * 2) / options.size
options.forEachIndexed { index, method ->
val tx = left + index * (buttonW + 2)
if (mouseX >= tx && mouseX <= tx + buttonW) { learnMethodFilter = method; contentScroll = 0; return true }
}
return false
return null
}
fun mouseScrolled(x: Int, y: Int, mouseX: Double, mouseY: Double, deltaY: Double): Boolean {
@@ -17,7 +17,7 @@ import net.minecraft.tags.TagKey
/**
* Resolves a WorldDex entry's biome tags into the concrete biomes they cover, via the client's synced
* biome registry — so the listing always reflects the loaded datapacks (including modded biomes added
* biome registry, so the listing always reflects the loaded datapacks (including modded biomes added
* to those tags). Used by each detail pane's "Biomes" tab.
*/
object WorldDexBiomes {
@@ -28,7 +28,7 @@ import net.minecraft.resources.ResourceLocation
*
* Numbered/renamed `.nbt` families collapse into one entry (its variations are cycled in the detail
* pane), and the structure and placed-feature representations of the same thing are deduped by a
* normalized key. Vanilla content is excluded — this is Cobblemon-only.
* normalized key. Vanilla content is excluded; this lists Cobblemon content only.
*/
object WorldDexData {
@@ -71,10 +71,11 @@ object WorldDexData {
fun count(category: Category): Int = entriesOf(category).size
/**
* Bare structure names unlocked for [category] — read from the player's client scanned data (the real
* source, exactly like the BerryDex) plus the temporary TestUnlocked* scaffold. Normalised to the last
* path segment because the id formats differ (habitats are un-prefixed in the scaffold, structures are
* "ruins/x"/"fossils/x", and entry ids are "habitats/x"/"fossils/x") — comparing bare names makes them match.
* Bare structure names unlocked for [category], from the player's client scanned data (the same
* source as the BerryDex) plus the TestUnlocked* test scaffold. Normalised to the last path segment
* because the id formats differ (habitats are un-prefixed in the scaffold, structures are
* "ruins/x"/"fossils/x", and entry ids are "habitats/x"/"fossils/x"), so comparing bare names makes
* them match.
*/
fun unlockedNames(category: Category): Set<String> {
val scannedCategory = when (category) {
@@ -85,7 +86,7 @@ object WorldDexData {
val scanned = CobblemonClient.clientScannedData.scanned
.filterKeys { ScannedDataCategory.categoryOfBucket(it) == scannedCategory }
.values.flatten()
// Temporary scaffold: habitats are their own list; the structures list is shared, split by path prefix.
// Test scaffold: habitats have their own list; the structures list is shared, split by path prefix.
val test = when (category) {
Category.HABITATS -> TestUnlockedHabitats.entries
Category.RUINS -> TestUnlockedStructures.entries.filter { it.path.startsWith("ruins/") }
@@ -94,11 +95,11 @@ object WorldDexData {
return (scanned + test).mapTo(HashSet()) { it.path.substringAfterLast('/') }
}
/** Whether [entry] has been discovered (client scanned data, or the temporary test scaffold). */
/** Whether [entry] has been discovered (client scanned data, or the test scaffold). */
fun isUnlocked(entry: Entry): Boolean =
unlockedNames(entry.category).contains(entry.id.path.substringAfterLast('/'))
/** Discovered-entry count for [category] — the numerator of the WorldDex tracker. */
/** Discovered-entry count for [category], the numerator of the WorldDex tracker. */
fun unlockedCount(category: Category): Int {
val unlocked = unlockedNames(category)
return entriesOf(category).count { unlocked.contains(it.id.path.substringAfterLast('/')) }
@@ -15,7 +15,7 @@ import com.google.gson.JsonObject
import net.minecraft.resources.ResourceLocation
/**
* Resolves the loot a Ruin/Fossil can yield by reading its structure processor list — the same data
* Resolves the loot a Ruin/Fossil can yield by reading its structure processor list, the same data
* that places the loot in-world. A processor rule that converts a block into a chest/suspicious block
* attaches an `append_loot` modifier naming a loot table; we collect those, split them by whether the
* output block is a suspicious (archaeology) block, and expand each loot table into its item ids.
@@ -32,11 +32,9 @@ import kotlin.math.max
* One WorldDex category page (Habitats, Ruins, or Fossils): a scrollable flat list of that category's
* entries on the left, and a detail pane on the right with the drag-rotatable .nbt preview (variations
* cycled with arrows) plus category-specific tabs:
* - Habitats: Spawns (per-habitat spawn pool, by rarity bucket) + Biomes.
* - Ruins: Loot + Biomes.
* - Fossils: Loot + Sus (suspicious gravel/sand finds) + Biomes.
*
* Placeholder flat-colour styling; real Pokédex textures are a later art pass.
* - Habitats: Spawns (per-habitat spawn pool, by rarity bucket) and Biomes.
* - Ruins: Loot and Biomes.
* - Fossils: Loot, Sus (suspicious gravel/sand finds), and Biomes.
*/
class WorldDexView {
companion object {
@@ -51,17 +49,16 @@ class WorldDexView {
private const val DETAIL_X = 166
private const val DETAIL_W = 160
private const val HEADER_Y = 24
private const val HEADER_H = 12
private const val VIEW_Y = 39
private const val VIEW_H = 72
private const val ARROW_W = 10
private const val CONTENT_TOP = 114 // content begins just below the viewport (VIEW_Y+VIEW_H=111)
private const val CONTENT_TOP = 114 // content begins just below the viewport (VIEW_Y + VIEW_H)
private const val SUBTAB_Y = 114 // Spawns' bucket sub-filter buttons sit at the content top
private const val SUBTAB_H = 10
private const val CONTENT_BOTTOM = 174 // content ends above the bottom tab row
private const val TAB_ROW_Y = 178 // info-tab icon row at the BOTTOM (icons 178-186, arrow 174)
private const val TAB_ROW_Y = 178 // info-tab icon row at the bottom
// Bottom-right info tabs reuse the Pokédex tab-row style + best-fit existing tab icons (swap freely later).
// Bottom-right info tabs reuse the Pokédex tab-row style with best-fit existing tab icons.
private val TAB_SELECT_ARROW = cobblemonResource("textures/gui/pokedex/select_arrow.png")
private val TAB_SPECIES = cobblemonResource("textures/gui/pokedex/tab_species.png")
private val TAB_DROPS = cobblemonResource("textures/gui/pokedex/tab_drops.png")
@@ -78,16 +75,12 @@ class WorldDexView {
private const val TAB_ICON_X0 = 2
private const val ROW_TEXT_H = 9
private const val PANEL_BG = 0xCC10303A.toInt()
private const val PLATE = 0xFFE9F6F6.toInt()
private const val PLATE_HOVER = 0xFFC8E8E8.toInt()
private const val PLATE_SELECTED = 0xFFA9DCDC.toInt()
private const val ICON_BG = 0xFF2E6F8C.toInt()
private const val BORDER = 0xFF7EE7E5.toInt()
private const val UNLOCK_DOT = 0xFF49C7C2.toInt()
private const val TEXT_DARK = 0x33586B
private const val INFO_TEXT = 0x606B6E // matches the main Pokédex info-page text colour
private const val PREVIEW_BG = 0xC0101010.toInt()
private fun fallback(key: String, text: String) = Component.translatableWithFallback("cobblemon.ui.worlddex.$key", text)
}
@@ -175,7 +168,7 @@ class WorldDexView {
renderCount(context, x, y)
}
/** [discovered] / [total] for the current category, top-right — mirrors the Berry/Move dex trackers. */
/** Discovered / total for the current category, shown top-right like the Berry/Move dex trackers. */
private fun renderCount(context: GuiGraphics, x: Int, y: Int) {
val text = Component.literal("${WorldDexData.unlockedCount(category)} / ${WorldDexData.count(category)}").bold()
drawScaledTextJustifiedRight(context = context, font = CobblemonResources.DEFAULT_LARGE, text = text, x = x + DETAIL_X + DETAIL_W - 4, y = y + 14, colour = INFO_TEXT)
@@ -204,13 +197,13 @@ class WorldDexView {
val left = x + LIST_X
val selectedRow = entry === selected
val hovered = mouseX in left..(left + LIST_W) && mouseY in rowY..(rowY + ROW_H - 2)
// Reuse the MoveDex move-slot bar art for each list item (top half = selected, bottom half = hover).
// MoveDex move-slot bar art per list item: top half is selected, bottom half is hover.
blitk(matrixStack = context.pose(), texture = MOVE_SLOT, x = left, y = rowY, width = LIST_W, height = 13)
if (selectedRow || hovered) {
blitk(matrixStack = context.pose(), texture = MOVE_SLOT_SELECT, x = left, y = rowY, width = LIST_W, height = 13, textureHeight = 26, vOffset = if (hovered && !selectedRow) 13 else 0)
}
drawScaledText(context = context, text = Component.literal("%04d".format(i + 1)), x = left + 4, y = rowY + 3, colour = TEXT_DARK, scale = 0.5F)
// Undiscovered entries are redacted to "???" (like the BerryDex's undiscovered berries).
// Undiscovered entries are redacted to "???".
val name = if (isUnlocked) entry.name.copy().bold() else Component.literal("???").bold()
drawScaledText(context = context, text = name, x = left + 20, y = rowY + 3, colour = TEXT_DARK, scale = 0.65F)
if (isUnlocked) context.fill(left + LIST_W - 9, rowY + 2, left + LIST_W - 2, rowY + 9, UNLOCK_DOT)
@@ -221,7 +214,7 @@ class WorldDexView {
val entry = selected ?: return
val left = x + DETAIL_X
// Custom right-panel background skin (header bar + tab-area bars). Drawn first, behind all content.
// Background skin first, behind all content.
blitk(matrixStack = context.pose(), texture = INFO_BG, x = left, y = y + HEADER_Y, width = DETAIL_W, height = TAB_ROW_Y + TAB_ICON_DISP + 2 - HEADER_Y)
drawScaledText(context = context, text = Component.literal("%04d".format(selectedNumber)).bold(), x = left + 3, y = y + HEADER_Y + 3, colour = TEXT_DARK, scale = 0.65F)
drawScaledText(context = context, text = entry.name.copy().bold(), x = left + 28, y = y + HEADER_Y + 3, colour = TEXT_DARK, scale = 0.75F)
@@ -235,7 +228,7 @@ class WorldDexView {
val viewLeft = left + ARROW_W + 2
val viewWidth = DETAIL_W - (ARROW_W + 2) * 2
// No dark backdrop — the structure renders directly on the panel background texture for a cutout look.
// The structure renders directly on the panel background texture for a cutout look (no dark backdrop).
StructurePreviewLoader.forEntry(variants[variantIndex])?.let {
StructurePreviewRenderer.render(context, it, viewLeft, y + VIEW_Y, viewWidth, VIEW_H, yaw, pitch, zoom)
}
@@ -248,7 +241,7 @@ class WorldDexView {
renderScaledGuiItemIcon(chestIconStack(), (left + DETAIL_W - 12).toDouble(), (y + HEADER_Y + 1).toDouble(), matrixStack = context.pose(), scale = 0.65)
}
// Category tabs — icon buttons at the BOTTOM, styled like the main Pokédex / MoveDex tab row.
// Category tabs: icon buttons at the bottom, styled like the main Pokédex / MoveDex tab row.
val tabs = tabsFor(category)
tabs.forEachIndexed { index, tab ->
val ix = tabIconX(left, index)
@@ -302,7 +295,6 @@ class WorldDexView {
centeredMessage(context, left, y, CONTENT_TOP, fallback("no_spawns_yet", "No spawns yet"))
return
}
// Rarity bucket sub-buttons.
val buckets = spawns.map { it.bucket }.distinct().sortedBy { WorldDexHabitatSpawns.bucketOrder(it) }
val current = spawnBucket?.takeIf { it in buckets } ?: buckets.first()
val buttonW = (DETAIL_W - (buckets.size - 1) * 2) / buckets.size
@@ -355,7 +347,7 @@ class WorldDexView {
val contentTop = CONTENT_TOP
panel(context, left, y, contentTop)
// Flatten into rows: a tag header followed by its biomes (indented).
val rows = ArrayList<Pair<MutableComponent, Boolean>>() // text, isHeader
val rows = ArrayList<Pair<MutableComponent, Boolean>>() // (text, isHeader)
for (tb in tagBiomes) {
rows.add(tb.tagName.copy().bold() to true)
if (tb.biomes.isEmpty()) rows.add(fallback("no_biomes", "(none loaded)") to false)
@@ -376,7 +368,7 @@ class WorldDexView {
}
private fun panel(context: GuiGraphics, left: Int, y: Int, contentTop: Int) {
// No dark box — content now draws straight on the background texture (kept as a no-op for call sites).
// No-op: content draws straight on the background texture.
}
private fun centeredMessage(context: GuiGraphics, left: Int, y: Int, contentTop: Int, text: MutableComponent) {
@@ -443,15 +443,15 @@ interface GuiParticleScene {
* (e.g. Bulldoze) would otherwise fall through forever. Particle collision treats everything at or below this
* Y as solid floor. It is per-storm because every particle simulates at the scene origin (the doll-height
* offset is applied only at render): a storm emitting at mid-height must have its floor placed that height
* BELOW the origin, or its particles would spawn already inside the slab — fatal for `expire_on_contact`
* effects (Aurora Beam / Psybeam impact bursts vanished on spawn before this).
* BELOW the origin, or its particles would spawn already inside the slab, which is fatal for `expire_on_contact`
* effects (impact bursts would vanish on spawn).
*/
fun guiFloorY(storm: ParticleStorm): Double
/**
* World-space solid box for [storm]'s target (the target block, or the attacker), centred on its live aim
* point, or null if the storm has no target. Lets traveling projectiles/beams collide and stop AT the
* target instead of flying past it — the aim point is built so it maps exactly onto the target's near face.
* target instead of flying past it; the aim point is built so it maps exactly onto the target's near face.
*/
fun guiImpactBox(storm: ParticleStorm): net.minecraft.world.phys.AABB?
}
@@ -49,38 +49,15 @@ class SnowstormParticle(
y: Double,
z: Double,
initialVelocity: Vec3,
//Usually, this will be the storms runtime. But sometimes particles need to have variables different from their emitter, so the VariableStruct will differ
// Usually the storm's runtime, but a particle can hold variables distinct from its emitter, so the VariableStruct may differ.
val runtime: MoLangRuntime,
val matrixWrapper: MatrixWrapper,
var invisible: Boolean = false,
) : Particle(world, x, y, z) {
companion object {
const val MAXIMUM_DISTANCE_CHANGE_PER_TICK_FOR_FRICTION = 0.005
// // ---- Rock Slide debug instrumentation (temporary). Capped so a runaway can't flood the log. ----
// var dbgLines = 0
// const val DBG_CAP = 6000
}
// // True only for Rock Slide storms running inside the MoveDex GUI scene — keeps the firehose off battle/other moves.
// private val dbgOn: Boolean get() = storm.guiScene != null && storm.effect.id.path.contains("rockslide")
//
// /** Logs one line of everything we know about this particle right now, prefixed [phase]. Gated + capped. */
// private fun dbg(phase: String, extra: String = "") {
// if (!dbgOn || dbgLines >= DBG_CAP) return
// dbgLines++
// val floor = storm.guiScene?.guiFloorY(storm) ?: 0.0
// Cobblemon.LOGGER.info(
// "ROCKDBG#{} {} id={} age={}/{} worldY={} aboveFloor={} pos=({},{},{}) vel=({},{},{}) localSpace={} localPos={} collEnabled={} expireOnContact={} removed={} {}",
// dbgLines, "%-16s".format(phase), storm.effect.id.path, age, lifetime,
// "%.3f".format(y), "%.3f".format(y - floor),
// "%.2f".format(x), "%.2f".format(y), "%.2f".format(z),
// "%.4f".format(xd), "%.4f".format(yd), "%.4f".format(zd),
// storm.effect.space.isLocalSpace, storm.effect.space.localPosition,
// runtime.resolveBoolean(storm.effect.particle.collision.enabled),
// storm.effect.particle.collision.expiresOnContact, removed, extra
// )
// }
val sprite = getSpriteFromAtlas()
val particleTextureSheet: ParticleRenderType
@@ -125,7 +102,6 @@ class SnowstormParticle(
}
private fun applyRandoms() {
//Will having these emitter randoms not equal the real emitter randoms be a problem?
runtime.environment.setSimpleVariable("emitter_random_1", DoubleValue(Random.Default.nextDouble()))
runtime.environment.setSimpleVariable("emitter_random_2", DoubleValue(Random.Default.nextDouble()))
runtime.environment.setSimpleVariable("emitter_random_3", DoubleValue(Random.Default.nextDouble()))
@@ -152,11 +128,6 @@ class SnowstormParticle(
}
}
storm.effect.particle.creationEvents.forEach { it.trigger(storm, this) }
// dbg("SPAWN", "initVel=(${"%.3f".format(initialVelocity.x)},${"%.3f".format(initialVelocity.y)},${"%.3f".format(initialVelocity.z)}) " +
// "collRadius=${"%.3f".format(runtime.resolveDouble(storm.effect.particle.collision.radius))} " +
// "motion=${storm.effect.particle.motion::class.simpleName} maxAgeSec=${"%.3f".format(runtime.resolveDouble(storm.effect.particle.maxAge))} " +
// "stormXYZ=(${"%.2f".format(storm.getX())},${"%.2f".format(storm.getY())},${"%.2f".format(storm.getZ())}) " +
// "localX/Y/Z=(${"%.2f".format(localX)},${"%.2f".format(localY)},${"%.2f".format(localZ)}) originPos=(${"%.2f".format(originPos.x)},${"%.2f".format(originPos.y)},${"%.2f".format(originPos.z)})")
}
override fun render(vertexConsumer: VertexConsumer, camera: Camera, tickDelta: Float) {
@@ -264,7 +235,7 @@ class SnowstormParticle(
* In the world, particle quads are built in world axes oriented by [cameraAngle] (the camera's WORLD
* rotation) and the modelview matrix (the camera VIEW rotation = inverse) flattens them to the screen.
* Here, the caller's [poseStack] already applies the scene's view rotation, so [cameraAngle] must be
* its INVERSE — then the two compose to a screen-facing quad exactly as in battle. Particle positions
* its INVERSE, so that the two compose to a screen-facing quad exactly as in battle. Particle positions
* are the local-to-emitter coordinates, placed by the same pose, so they share the dolls' 3D space.
* Full-bright; the caller supplies a [VertexConsumer] bound to the particle atlas + matching blend.
*/
@@ -292,9 +263,9 @@ class SnowstormParticle(
// belongs to). This lets the scene batch every storm of a material into one draw without per-storm
// pose pushes, while a storm and its event-spawned children still map into one consistent space.
val px = (Mth.lerp(tickDelta.toDouble(), xo, x) - sceneOrigin.x).toFloat() + anchorX
// Negate the vertical: the GUI scene pose's +Y points down (the doll model flips internally to stand
// upright; particles have no such flip), so world-up must be negated here to render up. anchorY is the
// upward locator height, so it's subtracted to lift the emit point.
// The GUI scene pose's +Y points down (the doll model flips internally to stand upright; particles have
// no such flip), so world-up is negated here to render up. anchorY is the upward locator height, so it is
// subtracted to lift the emit point.
val py = (-(Mth.lerp(tickDelta.toDouble(), yo, y) - sceneOrigin.y) - anchorY).toFloat()
val pz = (Mth.lerp(tickDelta.toDouble(), zo, z) - sceneOrigin.z + anchorZ).toFloat()
@@ -316,16 +287,14 @@ class SnowstormParticle(
val light = (15 shl 20) or (15 shl 4)
val m = poseStack.last().pose()
// Orient with the SAME world-space inputs battle feeds getRotation — no munging — so every camera mode
// behaves exactly as in battle. The mismatch this scene introduces is purely that it mirrors world-up to
// screen-up by negating Y in the particle POSITION (py). We reconcile that below by mirroring the oriented
// quad's Y for the world-locked modes, rather than distorting the inputs into getRotation.
// Orient with the same world-space inputs battle feeds getRotation, so every camera mode behaves exactly
// as in battle. The only mismatch this scene introduces is that it mirrors world-up to screen-up by
// negating Y in the particle POSITION (py), reconciled below by mirroring the oriented quad's Y for the
// world-locked modes rather than distorting the inputs into getRotation.
val cameraPosition = Vec3(x - forward.x * 64.0, y - forward.y * 64.0, z - forward.z * 64.0)
// ROTATE_Y applies the particle's in-plane spin (roll) in a frame the scene's Y-mirror REVERSES, so an
// authored initial_spin comes out rotated the wrong way — Dragon Claw's aura (spin -135°) and Swords Dance's
// swords (-45°) looked rotated. Negate the roll for this mode so spun rotate_y sprites match battle. A sprite
// with NO spin (Thunder's vertical bolt, roll 0) is unchanged, so this can't re-break it. rotate_xyz applies
// roll in screen space (after cameraAngle) and already reads correctly, so it is left alone.
// ROTATE_Y applies the particle's in-plane spin (roll) in a frame the scene's Y-mirror reverses, so negate
// the roll for this mode so spun rotate_y sprites match battle. A sprite with no spin (roll 0) is unchanged.
// rotate_xyz applies roll in screen space (after cameraAngle) and already reads correctly, so it is left alone.
val rotateY = storm.effect.particle.cameraMode.type == ParticleCameraModeType.ROTATE_Y
val quaternion = storm.effect.particle.cameraMode.getRotation(
matrixWrapper = matrixWrapper,
@@ -340,11 +309,10 @@ class SnowstormParticle(
viewDirection = viewDirection
)
// A camera-facing billboard (rotate_xyz/rotate_y/lookat_y/lookat_xyz) is symmetric under the scene's Y
// mirror, so its world-space orientation still reads correctly. But a mode that locks the quad to a WORLD
// mirror, so its world-space orientation still reads correctly. A mode that locks the quad to a WORLD
// axis or the particle's velocity (lookat_direction, direction_*, emitter planes) must have its oriented
// quad mirrored in Y to match the mirrored position — otherwise the sprite faces against its own travel
// (Thunderbolt's bolts) or sits at the wrong tilt. We mirror the QUAD, not getRotation's inputs, so the
// shared battle code is reused verbatim.
// quad mirrored in Y to match the mirrored position, or the sprite faces against its own travel or sits
// at the wrong tilt. The QUAD is mirrored, not getRotation's inputs, so the shared battle code is reused verbatim.
val worldLocked = when (storm.effect.particle.cameraMode.type) {
ParticleCameraModeType.LOOK_AT_DIRECTION,
ParticleCameraModeType.DIRECTION_X,
@@ -391,9 +359,7 @@ class SnowstormParticle(
runtime.execute(storm.effect.particle.updateExpressions)
angularVelocity = storm.effect.particle.rotation.getAngularVelocity(runtime, -roll.toDouble(), angularVelocity) / 20
// dbg("TICK")
if (age >= lifetime || runtime.resolveBoolean(storm.effect.particle.killExpression)) {
// dbg("EXPIRE-AGE/KILL", "ageHit=${age >= lifetime} killExpr=${runtime.resolveBoolean(storm.effect.particle.killExpression)}")
runExpirationEvents()
remove()
return
@@ -426,10 +392,9 @@ class SnowstormParticle(
oldAxisRotation = axisRotation
prevRotatedLocal = oldAxisRotation.transform(Vector3d(prevLocalX, prevLocalY, prevLocalZ))
//This is a bit of a hack. Technically local rotation is supposed to make it so the particle uses
//the emitter's rotation - but we've made it so the emitter doesn't actually follow the locators rotation, only position.
//So instead of being bound to the emitter, the particle is actually bound to the locator for local rotation.
//If you're messing with this - make sure evo particles still rotate with the pokemon
// Local rotation nominally means the particle uses the emitter's rotation, but the emitter only follows the
// locator's position, not its rotation. So for local rotation the particle is bound to the locator instead of
// the emitter. If you change this, make sure evo particles still rotate with the pokemon.
if (storm.effect.space.localRotation) {
storm.attachedMatrix.matrix.getRotation(axisRotation)
}
@@ -462,8 +427,6 @@ class SnowstormParticle(
var dz = dz
val collEnabled = runtime.resolveBoolean(collision.enabled)
// dbg("MOVE", "dxyz=(${"%.4f".format(dx)},${"%.4f".format(dy)},${"%.4f".format(dz)}) collEnabled=$collEnabled radius=${"%.3f".format(radius)} isLocalSpace=${storm.effect.space.isLocalSpace} " +
// "=> collisionPath=${collEnabled && radius > 0.0 && !storm.effect.space.isLocalSpace}")
if (collEnabled && radius > 0.0 && !storm.effect.space.isLocalSpace) {
hasPhysics = true
@@ -477,37 +440,14 @@ class SnowstormParticle(
dy = newMovement.y
dz = newMovement.z
// if (collidesWithWorld && (dx != 0.0 || dy != 0.0 || dz != 0.0) && dx * dx + dy * dy + dz * dz < 10000) {
// val vec3d = Entity.adjustMovementForCollisions(
// null,
// Vec3d(dx, dy, dz),
// boundingBox,
// world,
// listOf()
// )
//
// }
if (dx != 0.0 || dy != 0.0 || dz != 0.0) {
boundingBox = boundingBox.move(dx, dy, dz)
localX += dx
localY += dy
localZ += dz
}
// if (abs(dy) >= 9.999999747378752E-6 && abs(dy) < 9.999999747378752E-6) {
// field_21507 = true
// }
// onGround = dy != dy && e < 0.0
// if (d != dx) {
// xd = 0.0
// }
// if (dz != dz) {
// zd = 0.0
// }
} else {
hasPhysics = false
// dbg("MOVE-NOCOLLISION", "floor & expire_on_contact are SKIPPED here (collEnabled=$collEnabled radius=${"%.3f".format(radius)} isLocalSpace=${storm.effect.space.isLocalSpace})")
if (dx != 0.0 || dy != 0.0 || dz != 0.0) {
localX += dx
localY += dy
@@ -547,26 +487,22 @@ class SnowstormParticle(
val friction = runtime.resolveDouble(collision.friction)
val expiresOnContact = collision.expiresOnContact
// Handle easy collision cases
// If there are no collisions return the unchanged movement. If the particle expires on contact then remove it.
// In the offscreen GUI scene there are no real blocks (it runs in empty sky), so synthesise a flat ground
// slab at the scene's floor Y: this is what lets Bulldoze-style ground effects slide/stop instead of
// falling through forever, mirroring the battle floor without touching the world.
// If there are no collisions, return the unchanged movement. If the particle expires on contact, remove it.
// The offscreen GUI scene has no real blocks (it runs in empty sky), so synthesise a flat ground slab at the
// scene's floor Y; this lets ground effects slide/stop instead of falling through forever, mirroring the
// battle floor without touching the world.
val worldCollisions = level.getBlockCollisions(null, box.expandTowards(movement))
val collisions: Iterable<VoxelShape> = storm.guiScene?.let { scene ->
val extra = ArrayList<VoxelShape>()
// A thick slab whose TOP face sits exactly at floorY, covering well beyond the particle's reach. Only
// add it when the particle is NOT moving upward away from it: a particle launched UP off the floor
// (Rock Slide's `guide2` ray, which spawns the falling rocks high overhead, spawns AT floor level and
// shoots up with expire_on_contact) must not be killed by the floor on its first tick — else the rocks
// never spawn above the target. A descending/level particle still collides and settles normally.
// CRITICAL: only add a synthesized shape if the swept box ACTUALLY reaches it. The real
// level.getBlockCollisions only returns shapes the box truly intersects, and downstream logic
// (collisions.none(), the expire_on_contact shortcut) treats "list non-empty" as "colliding". If we
// add the floor unconditionally whenever a particle descends, an expire_on_contact particle (Rock
// Slide's falling rocks) sees a permanent candidate and breaks in mid-air the instant the spawn grace
// ends — never falling to the ground. The floor slab's top is floorY, so the box reaches it only once
// its swept minY dips to/below floorY.
// A thick slab whose TOP face sits exactly at floorY, covering well beyond the particle's reach. Add it
// only when the particle is NOT moving upward away from it, so a particle launched UP off the floor
// (with expire_on_contact) is not killed by the floor on its first tick. A descending/level particle
// still collides and settles normally.
// Only add the synthesized shape if the swept box ACTUALLY reaches it: level.getBlockCollisions returns
// only shapes the box truly intersects, and downstream logic (collisions.none(), the expire_on_contact
// shortcut) treats a non-empty list as "colliding". Adding the floor unconditionally on descent would
// give an expire_on_contact particle a permanent candidate and break it in mid-air. The slab's top is
// floorY, so the box reaches it only once its swept minY dips to/below floorY.
val sweep = box.expandTowards(movement)
if (movement.y <= 0.0) {
val floorY = scene.guiFloorY(storm)
@@ -574,8 +510,8 @@ class SnowstormParticle(
extra.add(Shapes.create(AABB(sweep.minX - 64.0, floorY - 64.0, sweep.minZ - 64.0, sweep.maxX + 64.0, floorY, sweep.maxZ + 64.0)))
}
}
// The target's solid box, so traveling effects stop AT the target's face instead of overshooting it —
// but, like the floor, only when the swept box actually touches it (else it'd be a permanent candidate).
// The target's solid box, so traveling effects stop AT the target's face instead of overshooting it,
// but like the floor only when the swept box actually touches it (else it would be a permanent candidate).
scene.guiImpactBox(storm)?.let { ib ->
if (sweep.intersects(ib)) { extra.add(Shapes.create(ib)) }
}
@@ -585,9 +521,8 @@ class SnowstormParticle(
colliding = false
return movement
} else if (expiresOnContact && age <= 2) {
// Grace period: an expire_on_contact particle must not break on the tick(s) it spawns. Rock Slide lofts
// its rocks into the air with a guide ray and they were breaking instantly at the spawn point instead of
// falling to the floor. Let them move a couple ticks to clear the spawn before contact can kill them.
// Spawn grace: an expire_on_contact particle must not break on the tick(s) it spawns. Let it move a
// couple of ticks to clear the spawn point before contact can kill it (e.g. effects lofted off the floor).
return movement
} else if (expiresOnContact) {
runExpirationEvents()
@@ -595,28 +530,22 @@ class SnowstormParticle(
return movement
}
// Initial particle movement and collision type flags
var xMovement = movement.x
var yMovement = movement.y
var zMovement = movement.z
var bouncing = false
var sliding = false
// Determine which horizontal axis to resolve first by favoring the larger movement
// Resolve the horizontal axis holding the larger movement first.
val mostlyIsZMovement = abs(xMovement) < abs(zMovement)
/******************************************************************************************************************************/
/**
* Helper function to resolve a collision along a given axis.
*/
fun resolveAxis(
axis: Direction.Axis,
axisMovement: Double,
originalVelocity: Double,
collisions: Iterable<VoxelShape>
): Double {
// Check for collision on the given axis. If there is no collision then move the box along the axis and return the value of the vector for that axis
// No collision on this axis: move the box along it and return that axis' resolved value.
val resolved = Shapes.collide(axis, box, collisions, axisMovement)
if (resolved != 0.0) {
box = box.move(
@@ -627,7 +556,7 @@ class SnowstormParticle(
return resolved
}
// If the particle is bouncy and is moving fast enough then "bounce" it on the given axis and return
// Bouncy and moving fast enough: bounce on this axis and return.
if (bounciness > 0.0 && abs(originalVelocity) > MAXIMUM_DISTANCE_CHANGE_PER_TICK_FOR_FRICTION) {
val bounced = -1 * bounciness * originalVelocity
when (axis) {
@@ -639,7 +568,7 @@ class SnowstormParticle(
return bounced
}
// If the particle is instead slidey then mark the sliding flag for later slide/friction resolution
// Slidey: mark for later slide/friction resolution.
if (friction > 0.0) sliding = true
when (axis) {
Direction.Axis.X -> xd = 0.0
@@ -648,9 +577,8 @@ class SnowstormParticle(
}
return 0.0
}
/******************************************************************************************************************************/
// Resolve y collision first, then horizontal with the axis that holds the largest portion of the movement coming first
// Resolve Y first, then the horizontal axes (larger-movement axis first).
if (yMovement != 0.0)
yMovement = resolveAxis(Direction.Axis.Y, yMovement, movement.y, collisions)
if (mostlyIsZMovement) {
@@ -661,19 +589,16 @@ class SnowstormParticle(
if (zMovement != 0.0) zMovement = resolveAxis(Direction.Axis.Z, zMovement, movement.z, collisions)
}
// Grab our current projected movement for the tick and the particles current velocity
var newMovement = Vec3(xMovement, yMovement, zMovement)
var velocity = Vec3(xd, yd, zd)
// If the particle is slidey and not bouncy then perform slide/friction resolution
// Slidey and not bouncy: apply slide/friction resolution.
if (sliding && !bouncing) {
// If it's moving slower than the friction per second, time to stop
// Below friction-per-second, stop.
if (velocity.length() * 20 < friction) {
setParticleSpeed(0.0, 0.0, 0.0)
newMovement = Vec3.ZERO
} else {
// Slow down the particle due to friction
val reduced = velocity.subtract(velocity.normalize().scale(friction / 20))
setParticleSpeed(reduced.x, reduced.y, reduced.z)
newMovement = reduced