forked from mirrors/cobblemon
Gui changes and cleanup
This commit is contained in:
@@ -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
|
||||
|
||||
+22
-32
@@ -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)
|
||||
}
|
||||
|
||||
+10
-10
@@ -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()
|
||||
|
||||
+8
-8
@@ -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). */
|
||||
|
||||
+2
-2
@@ -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()
|
||||
|
||||
+104
-126
@@ -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) }
|
||||
|
||||
+7
-13
@@ -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()
|
||||
|
||||
+221
-229
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+9
-8
@@ -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('/')) }
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+14
-22
@@ -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?
|
||||
}
|
||||
|
||||
+42
-117
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user