Compare commits

...
Author SHA1 Message Date
terrainwax c38630bd6f Merge branch 'main' into feat/stadium-camera
# Conflicts:
#	fabric/build.gradle.kts
#	neoforge/build.gradle.kts
2026-04-16 18:34:15 +02:00
terrainwax 4dba646de9 feat(molang-ui): rename Scripted GUI to Molang UI and add manifest/action/slot systems
Major refactor renaming the Scripted GUI system to Molang UI, migrating
definitions from datapacks to resource packs, and introducing a pluggable
action system, container/slot support, and hot-reload.

Renaming & restructuring:
- Rename all ScriptedGui classes, packets, handlers, and commands to MolangUi
  (command literal: scriptedgui → molangui)
- Move client/gui/scripted → client/gui/molangui, api/gui/scripted → api/molangui
- Rename resource paths: scripted_guis → molang_ui, scripted_gui_templates
  → molang_ui_templates
- Consolidate related classes into single files (Animation, WidgetDefinition,
  WidgetType/Anchor, TemplateDefinition, WidgetEvent, ScriptedGuiSessionManager)

Resource pack migration:
- Move GUI definitions from data/ to assets/ so they load client-side via
  resource reload rather than server-side data registry
- Add MolangUiRegistry as a simple client-side loader
- Add version field to definitions and MolangUiVersionPacket for client/server
  version validation
- Hot-reload active Molang UIs on resource pack reload (F3+T)

Action system:
- Add MolangUiActionRouter with tiered handlers: Kotlin handlers first,
  then manifest-referenced .molang script fallback, then legacy session
  action listeners
- Add MolangUiManifestRegistry for server-side manifest loading
- Add action@ syntax for routing widget events directly to manifest actions
  (e.g. "on_click": "action@smelt")
- Map positional action data to named manifest params (v.amount instead of v.0)
- Add on_open manifest hook and persistent per-player data support
- Introduce proper struct pattern for action context: q.session.arg(),
  q.session.update_arg(), q.session.close(), q.player.count_item(),
  q.player.remove_item(), q.player.run_command(), q.player.data/save_data()
2026-04-16 18:27:21 +02:00
terrainwax 201da371a5 feat(scripted-gui, stadium, npc-shop): rewrite scripted GUI system and stadium config screen
scripted-gui:
- Replace GraalVM/JS runtime with declarative JSON + MoLang widget tree system
- Add core widget model (WidgetType, WidgetProperty, WidgetDefinition, Anchor)
- Add SizeExpression parser (pixel, percent, fill, auto, MoLang)
- Add LayoutEngine with anchor, flow, and grid layout
- Add animation infrastructure (Easing, TransitionState, EnterExitAnimation)
- Add template system with @inheritance, $input substitution, and library imports
- Add WidgetTree JSON parser with template resolution
- Add MoLang context with client/server runtime setup and gui_update_args function
- Add client event processor with hit testing and server dispatch
- Add widget renderers: Panel, Label, Image, Fill, Button, ProgressBar, List
- Add ScriptedGuiWidgetTree as central client-side orchestrator
- Add server-side MoLang event handler evaluation in ScriptedGuiSession
- Extend ScriptedGuiUpdatePacket with server-to-client variable push (v.*)
- Add variable push, dynamic visibility, data lists, and NUMBER_INPUT widget
- Add common template library with styled base widget defaults
- Migrate test_overlay and battle_intro from JS to MoLang widget trees
- Remove GraalVM scripted GUI files (Runtime, DrawingContext, ScriptSandbox, ArgConverter)

stadium:
- Rewrite StadiumConfigScreen with two-tab (Battle/Arena) layout
- Add StadiumTabWidget, StadiumCycleSelectorWidget, StadiumToggleWidget
- Add StadiumFormatListWidget with selection and toggle
- Add StadiumSlotEntryWidget with color indicator and actions
- Add StadiumOverlayWidget for ban/clause/gimmick editing
- Implement Battle tab with format list, detail panel, and overlays
- Implement Arena tab with Placements, Camera, and Music sub-tabs
- Add interactive marker selection to ArenaMapWidget
- Implement Arena Placements sub-tab with interactive map-slot linking
- Add wand auto-remove on use and screen reopen after placement
- Add in-game placement highlight rendering when holding wand
- Fix panel overflow, overlay z-order, and replace hardcoded data with registry lookups
- Fix overlay opacity and add scroll to custom format detail panel
- Add MPL license headers to widget files
- Add placeholder texture assets for config screen

npc-shop:
- Add shop NPC interaction configuration (See Articles/Leave dialogue)
- Add shop GUI with item list page and centered card purchase page
- Add server-side purchase validation with emerald counting
- Add client send_action function for scripted GUI MoLang runtime
- Add Shopkeeper test NPC preset
2026-04-15 18:18:22 +02:00
terrainwax d6828baeec fix: rename function ( relica from early stage of stadium camera ) 2026-02-26 03:24:32 +01:00
terrainwax 34f1fd2809 fix: add back rotation for compiling purpose :) 2026-02-26 02:17:41 +01:00
terrainwax 09a5fe4c5d feat: bring back camera accessor 2026-02-26 01:50:52 +01:00
terrainwax c42bd7a293 Merge branch 'main' into feat/stadium-camera
# Conflicts:
#	common/src/main/java/com/cobblemon/mod/common/mixin/accessor/BlockEntityAccessor.java
#	common/src/main/java/com/cobblemon/mod/common/mixin/client/CameraMixin.java
#	common/src/main/java/com/cobblemon/mod/common/mixin/client/GameRendererMixin.java
#	common/src/main/kotlin/com/cobblemon/mod/common/Cobblemon.kt
#	common/src/main/kotlin/com/cobblemon/mod/common/CobblemonNetwork.kt
#	common/src/main/kotlin/com/cobblemon/mod/common/api/events/CobblemonEvents.kt
#	common/src/main/kotlin/com/cobblemon/mod/common/battles/interpreter/instructions/MoveInstruction.kt
#	common/src/main/kotlin/com/cobblemon/mod/common/client/CobblemonClient.kt
#	common/src/main/kotlin/com/cobblemon/mod/common/client/keybind/keybinds/DebugKeybindings.kt
#	common/src/main/resources/mixins.cobblemon-common.json
#	fabric/build.gradle.kts
#	neoforge/build.gradle.kts
2026-02-26 01:49:35 +01:00
terrainwax 33a303a2f5 remove: Stadium block Specification 2026-02-26 01:23:31 +01:00
terrainwax 9f7dd84ace feat: add Iris / Sodium compat, create a TweenEngine to be use in camera path and track 2026-02-26 01:19:56 +01:00
terrainwax 68e93a13d8 feat: New track system , Scripted GUI , BoneFocus , BattleIntro , StadiumBlock 2026-02-26 01:18:35 +01:00
terrainwax 611bfd9792 chore: improve error logging and remove dead code in camera engine 2026-02-26 01:18:13 +01:00
terrainwax 0c428526e7 fix: clear BoneFocusResolver smoothing state when cameras stop 2026-02-26 01:16:47 +01:00
terrainwax b28355b8f2 refactor: deduplicate entity resolution logic across camera focus handlers 2026-02-26 01:13:24 +01:00
terrainwax 8e163d0e14 chore: remove dead coordinator factory from CameraPluginRegistry 2026-02-26 01:05:44 +01:00
terrainwax 1660a53867 refactor: consolidate duplicate timeline registries into single TimelineRegistry 2026-02-26 01:05:03 +01:00
terrainwax c50ae2d478 chore: remove unused imports in camera engine 2026-02-26 01:04:21 +01:00
terrainwax 497de9a8f1 chore: remove CameraMetrics, CameraConfig, and CameraResult abstractions 2026-02-26 01:03:30 +01:00
terrainwax f57300dc18 chore: remove unused CameraTimelineBuilder 2026-02-26 01:02:47 +01:00
terrainwax 0e3c197d9b chore: remove unused CameraAPI facade, update tests 2026-02-26 01:02:12 +01:00
terrainwax 4a12a39287 fix: remove debug println and trailing blank lines in camera handlers 2026-02-26 01:00:05 +01:00
terrainwax 2da2222540 feat: cameraAPI and stadium camera 2026-01-04 19:22:44 +01:00
306 changed files with 26859 additions and 62 deletions
+510
View File
@@ -0,0 +1,510 @@
{
"format_version": "1.21.11",
"credit": "Made with Blockbench",
"texture_size": [128, 128],
"textures": {
"2": "block/texture"
},
"elements": [
{
"from": [9.999, 10.999, -0.001],
"to": [15.001, 12.001, 0.001],
"rotation": {"angle": 0, "axis": "y", "origin": [1, -1, 1]},
"faces": {
"north": {"uv": [0.25, 1.125, 0.875, 1.25], "texture": "#2"},
"east": {"uv": [0.25, 1.125, 0.25, 1.25], "texture": "#2"},
"south": {"uv": [0.875, 1.125, 1.5, 1.25], "texture": "#2"},
"west": {"uv": [0.875, 1.125, 0.875, 1.25], "texture": "#2"},
"up": {"uv": [0.875, 1.125, 0.25, 1.125], "texture": "#2"},
"down": {"uv": [1.5, 1.125, 0.875, 1.125], "texture": "#2"}
}
},
{
"from": [-0.0001, 8.9999, 2.9999],
"to": [2.0001, 13.0001, 13.0001],
"rotation": {"angle": 0, "axis": "y", "origin": [1, -1, 2]},
"faces": {
"north": {"uv": [4.25, 6, 4.5, 6.5], "texture": "#2"},
"east": {"uv": [3, 6, 4.25, 6.5], "texture": "#2"},
"south": {"uv": [5.75, 6, 6, 6.5], "texture": "#2"},
"west": {"uv": [4.5, 6, 5.75, 6.5], "texture": "#2"},
"up": {"uv": [4.5, 6, 4.25, 4.75], "texture": "#2"},
"down": {"uv": [4.75, 4.75, 4.5, 6], "texture": "#2"}
}
},
{
"from": [-0.0001, 8.9999, -0.0001],
"to": [16.0001, 13.0001, 3.0001],
"rotation": {"angle": 0, "axis": "y", "origin": [1, -1, 1]},
"faces": {
"north": {"uv": [0.375, 3.375, 2.375, 3.875], "texture": "#2"},
"east": {"uv": [0, 3.375, 0.375, 3.875], "texture": "#2"},
"south": {"uv": [2.75, 3.375, 4.75, 3.875], "texture": "#2"},
"west": {"uv": [2.375, 3.375, 2.75, 3.875], "texture": "#2"},
"up": {"uv": [2.375, 3.375, 0.375, 3], "texture": "#2"},
"down": {"uv": [4.375, 3, 2.375, 3.375], "texture": "#2"}
}
},
{
"from": [2.995, 10.995, -0.005],
"to": [15.005, 13.005, 2.005],
"rotation": {"angle": 0, "axis": "y", "origin": [1, -1, 1]},
"faces": {
"north": {"uv": [0.25, 6.75, 1.75, 7], "texture": "#2"},
"east": {"uv": [0, 6.75, 0.25, 7], "texture": "#2"},
"south": {"uv": [2, 6.75, 3.5, 7], "texture": "#2"},
"west": {"uv": [1.75, 6.75, 2, 7], "texture": "#2"},
"up": {"uv": [1.75, 6.75, 0.25, 6.5], "texture": "#2"},
"down": {"uv": [3.25, 6.5, 1.75, 6.75], "texture": "#2"}
}
},
{
"from": [9.999, 10.999, -0.001],
"to": [15.001, 12.001, 0.001],
"rotation": {"angle": 0, "axis": "y", "origin": [1, -1, 1]},
"faces": {
"north": {"uv": [0.25, 1.375, 0.875, 1.5], "texture": "#2"},
"east": {"uv": [0.25, 1.375, 0.25, 1.5], "texture": "#2"},
"south": {"uv": [0.875, 1.375, 1.5, 1.5], "texture": "#2"},
"west": {"uv": [0.875, 1.375, 0.875, 1.5], "texture": "#2"},
"up": {"uv": [0.875, 1.375, 0.25, 1.375], "texture": "#2"},
"down": {"uv": [1.5, 1.375, 0.875, 1.375], "texture": "#2"}
}
},
{
"from": [13.9999, 8.9999, 2.9999],
"to": [16.0001, 13.0001, 13.0001],
"rotation": {"angle": 0, "axis": "y", "origin": [1, -1, 2]},
"faces": {
"north": {"uv": [1.25, 6, 1.5, 6.5], "texture": "#2"},
"east": {"uv": [0, 6, 1.25, 6.5], "texture": "#2"},
"south": {"uv": [2.75, 6, 3, 6.5], "texture": "#2"},
"west": {"uv": [1.5, 6, 2.75, 6.5], "texture": "#2"},
"up": {"uv": [1.5, 6, 1.25, 4.75], "texture": "#2"},
"down": {"uv": [1.75, 4.75, 1.5, 6], "texture": "#2"}
}
},
{
"from": [-0.0001, 8.9999, 12.9999],
"to": [16.0001, 13.0001, 16.0001],
"rotation": {"angle": 0, "axis": "y", "origin": [1, -1, 1]},
"faces": {
"north": {"uv": [0.375, 4.25, 2.375, 4.75], "texture": "#2"},
"east": {"uv": [0, 4.25, 0.375, 4.75], "texture": "#2"},
"south": {"uv": [2.75, 4.25, 4.75, 4.75], "texture": "#2"},
"west": {"uv": [2.375, 4.25, 2.75, 4.75], "texture": "#2"},
"up": {"uv": [2.375, 4.25, 0.375, 3.875], "texture": "#2"},
"down": {"uv": [4.375, 3.875, 2.375, 4.25], "texture": "#2"}
}
},
{
"from": [1, 0, 1],
"to": [15, 10, 15],
"rotation": {"angle": 0, "axis": "y", "origin": [1, 0, 1]},
"faces": {
"north": {"uv": [1.75, 1.75, 3.5, 3], "texture": "#2"},
"east": {"uv": [0, 1.75, 1.75, 3], "texture": "#2"},
"south": {"uv": [5.25, 1.75, 7, 3], "texture": "#2"},
"west": {"uv": [3.5, 1.75, 5.25, 3], "texture": "#2"},
"up": {"uv": [3.5, 1.75, 1.75, 0], "texture": "#2"},
"down": {"uv": [5.25, 0, 3.5, 1.75], "texture": "#2"}
}
},
{
"from": [3.999, 1.999, 0.999],
"to": [12.001, 10.001, 1.001],
"rotation": {"angle": 0, "axis": "y", "origin": [1, 0, 1]},
"faces": {
"north": {"uv": [0, 7.375, 1, 8.375], "texture": "#2"},
"east": {"uv": [0, 7.375, 0, 8.375], "texture": "#2"},
"south": {"uv": [1, 7.375, 2, 8.375], "texture": "#2"},
"west": {"uv": [1, 7.375, 1, 8.375], "texture": "#2"},
"up": {"uv": [1, 7.375, 0, 7.375], "texture": "#2"},
"down": {"uv": [2, 7.375, 1, 7.375], "texture": "#2"}
}
},
{
"from": [2, 11, 5],
"to": [14, 12, 6],
"rotation": {"angle": 0, "axis": "y", "origin": [1, 11, 1]},
"faces": {
"north": {"uv": [5.125, 3.375, 6.625, 3.5], "texture": "#2"},
"east": {"uv": [4.75, 3.375, 4.875, 3.5], "texture": "#2"},
"south": {"uv": [7, 3.375, 8.5, 3.5], "texture": "#2"},
"west": {"uv": [6.875, 3.375, 7, 3.5], "texture": "#2"},
"up": {"uv": [6.625, 3.125, 5.125, 3], "texture": "#2"},
"down": {"uv": [8.125, 3, 6.625, 3.125], "texture": "#2"}
}
},
{
"from": [2, 11, 4],
"to": [14, 12, 5],
"rotation": {"angle": 0, "axis": "y", "origin": [1, 11, 1]},
"faces": {
"north": {"uv": [5.125, 3.375, 6.625, 3.5], "texture": "#2"},
"east": {"uv": [4.875, 3.375, 5, 3.5], "texture": "#2"},
"south": {"uv": [7, 3.375, 8.5, 3.5], "texture": "#2"},
"west": {"uv": [6.75, 3.375, 6.875, 3.5], "texture": "#2"},
"up": {"uv": [6.625, 3.25, 5.125, 3.125], "texture": "#2"},
"down": {"uv": [8.125, 3.125, 6.625, 3.25], "texture": "#2"}
}
},
{
"from": [2, 11, 3],
"to": [14, 12, 4],
"rotation": {"angle": 0, "axis": "y", "origin": [1, 11, 1]},
"faces": {
"north": {"uv": [5.125, 3.375, 6.625, 3.5], "texture": "#2"},
"east": {"uv": [5, 3.375, 5.125, 3.5], "texture": "#2"},
"south": {"uv": [7, 3.375, 8.5, 3.5], "texture": "#2"},
"west": {"uv": [6.625, 3.375, 6.75, 3.5], "texture": "#2"},
"up": {"uv": [6.625, 3.375, 5.125, 3.25], "texture": "#2"},
"down": {"uv": [8.125, 3.25, 6.625, 3.375], "texture": "#2"}
}
},
{
"from": [10, 11, 6],
"to": [14, 12, 8],
"rotation": {"angle": 0, "axis": "y", "origin": [1, 11, 1]},
"faces": {
"north": {"uv": [5, 4.25, 5.5, 4.375], "texture": "#2"},
"east": {"uv": [4.75, 4.25, 5, 4.375], "texture": "#2"},
"south": {"uv": [5.75, 4.25, 6.25, 4.375], "texture": "#2"},
"west": {"uv": [5.5, 4.25, 5.75, 4.375], "texture": "#2"},
"up": {"uv": [5.5, 4.25, 5, 4], "texture": "#2"},
"down": {"uv": [6, 4, 5.5, 4.25], "texture": "#2"}
}
},
{
"from": [2, 11, 6],
"to": [6, 12, 8],
"rotation": {"angle": 0, "axis": "y", "origin": [1, 11, 1]},
"faces": {
"north": {"uv": [5, 4.625, 5.5, 4.75], "texture": "#2"},
"east": {"uv": [4.75, 4.625, 5, 4.75], "texture": "#2"},
"south": {"uv": [5.75, 4.625, 6.25, 4.75], "texture": "#2"},
"west": {"uv": [5.5, 4.625, 5.75, 4.75], "texture": "#2"},
"up": {"uv": [5.5, 4.625, 5, 4.375], "texture": "#2"},
"down": {"uv": [6, 4.375, 5.5, 4.625], "texture": "#2"}
}
},
{
"from": [2, 11, 10],
"to": [14, 12, 11],
"rotation": {"angle": 0, "axis": "y", "origin": [1, 11, 6]},
"faces": {
"north": {"uv": [5.125, 3.875, 6.625, 4], "texture": "#2"},
"east": {"uv": [5, 3.875, 5.125, 4], "texture": "#2"},
"south": {"uv": [7, 3.875, 8.5, 4], "texture": "#2"},
"west": {"uv": [6.625, 3.875, 6.75, 4], "texture": "#2"},
"up": {"uv": [6.625, 3.875, 5.125, 3.75], "texture": "#2"},
"down": {"uv": [8.125, 3.75, 6.625, 3.875], "texture": "#2"}
}
},
{
"from": [2, 11, 11],
"to": [14, 12, 12],
"rotation": {"angle": 0, "axis": "y", "origin": [1, 11, 6]},
"faces": {
"north": {"uv": [5.125, 3.875, 6.625, 4], "texture": "#2"},
"east": {"uv": [4.875, 3.875, 5, 4], "texture": "#2"},
"south": {"uv": [7, 3.875, 8.5, 4], "texture": "#2"},
"west": {"uv": [6.75, 3.875, 6.875, 4], "texture": "#2"},
"up": {"uv": [6.625, 3.75, 5.125, 3.625], "texture": "#2"},
"down": {"uv": [8.125, 3.625, 6.625, 3.75], "texture": "#2"}
}
},
{
"from": [2, 11, 12],
"to": [14, 12, 13],
"rotation": {"angle": 0, "axis": "y", "origin": [1, 11, 6]},
"faces": {
"north": {"uv": [5.125, 3.875, 6.625, 4], "texture": "#2"},
"east": {"uv": [4.75, 3.875, 4.875, 4], "texture": "#2"},
"south": {"uv": [7, 3.875, 8.5, 4], "texture": "#2"},
"west": {"uv": [6.875, 3.875, 7, 4], "texture": "#2"},
"up": {"uv": [6.625, 3.625, 5.125, 3.5], "texture": "#2"},
"down": {"uv": [8.125, 3.5, 6.625, 3.625], "texture": "#2"}
}
},
{
"from": [10, 11, 8],
"to": [14, 12, 10],
"rotation": {"angle": 0, "axis": "y", "origin": [1, 11, 6]},
"faces": {
"north": {"uv": [6.25, 5, 6.75, 5.125], "texture": "#2"},
"east": {"uv": [6, 5, 6.25, 5.125], "texture": "#2"},
"south": {"uv": [7, 5, 7.5, 5.125], "texture": "#2"},
"west": {"uv": [6.75, 5, 7, 5.125], "texture": "#2"},
"up": {"uv": [6.75, 5, 6.25, 4.75], "texture": "#2"},
"down": {"uv": [7.25, 4.75, 6.75, 5], "texture": "#2"}
}
},
{
"from": [6, 11, 9],
"to": [10, 12, 10],
"rotation": {"angle": 0, "axis": "y", "origin": [1, 11, 6]},
"faces": {
"north": {"uv": [6.125, 5.625, 6.625, 5.75], "texture": "#2"},
"east": {"uv": [6, 5.625, 6.125, 5.75], "texture": "#2"},
"south": {"uv": [6.75, 5.625, 7.25, 5.75], "texture": "#2"},
"west": {"uv": [6.625, 5.625, 6.75, 5.75], "texture": "#2"},
"up": {"uv": [6.625, 5.625, 6.125, 5.5], "texture": "#2"},
"down": {"uv": [7.125, 5.5, 6.625, 5.625], "texture": "#2"}
}
},
{
"from": [2, 11, 8],
"to": [6, 12, 10],
"rotation": {"angle": 0, "axis": "y", "origin": [-3, 11, 6]},
"faces": {
"north": {"uv": [6.25, 5.375, 6.75, 5.5], "texture": "#2"},
"east": {"uv": [6, 5.375, 6.25, 5.5], "texture": "#2"},
"south": {"uv": [7, 5.375, 7.5, 5.5], "texture": "#2"},
"west": {"uv": [6.75, 5.375, 7, 5.5], "texture": "#2"},
"up": {"uv": [6.75, 5.375, 6.25, 5.125], "texture": "#2"},
"down": {"uv": [7.25, 5.125, 6.75, 5.375], "texture": "#2"}
}
},
{
"from": [9, 11, 7],
"to": [10, 12, 9],
"rotation": {"angle": 0, "axis": "y", "origin": [1, 11, 6]},
"faces": {
"north": {"uv": [6.5, 4.25, 6.625, 4.375], "texture": "#2"},
"east": {"uv": [6.25, 4.25, 6.5, 4.375], "texture": "#2"},
"south": {"uv": [6.875, 4.25, 7, 4.375], "texture": "#2"},
"west": {"uv": [6.625, 4.25, 6.875, 4.375], "texture": "#2"},
"up": {"uv": [6.625, 4.25, 6.5, 4], "texture": "#2"},
"down": {"uv": [6.75, 4, 6.625, 4.25], "texture": "#2"}
}
},
{
"from": [6, 11, 7],
"to": [7, 12, 9],
"rotation": {"angle": 0, "axis": "y", "origin": [1, 11, 6]},
"faces": {
"north": {"uv": [6.5, 4.625, 6.625, 4.75], "texture": "#2"},
"east": {"uv": [6.25, 4.625, 6.5, 4.75], "texture": "#2"},
"south": {"uv": [6.875, 4.625, 7, 4.75], "texture": "#2"},
"west": {"uv": [6.625, 4.625, 6.875, 4.75], "texture": "#2"},
"up": {"uv": [6.625, 4.625, 6.5, 4.375], "texture": "#2"},
"down": {"uv": [6.75, 4.375, 6.625, 4.625], "texture": "#2"}
}
},
{
"from": [7, 11, 7],
"to": [9, 12, 9],
"rotation": {"angle": 0, "axis": "y", "origin": [1, 11, 6]},
"faces": {
"north": {"uv": [6.25, 6.25, 6.5, 6.375], "texture": "#2"},
"east": {"uv": [6, 6.25, 6.25, 6.375], "texture": "#2"},
"south": {"uv": [6.75, 6.25, 7, 6.375], "texture": "#2"},
"west": {"uv": [6.5, 6.25, 6.75, 6.375], "texture": "#2"},
"up": {"uv": [6.5, 6.25, 6.25, 6], "texture": "#2"},
"down": {"uv": [6.75, 6, 6.5, 6.25], "texture": "#2"}
}
},
{
"from": [6, 11, 6],
"to": [10, 12, 7],
"rotation": {"angle": 0, "axis": "y", "origin": [1, 11, 6]},
"faces": {
"north": {"uv": [6.125, 5.875, 6.625, 6], "texture": "#2"},
"east": {"uv": [6, 5.875, 6.125, 6], "texture": "#2"},
"south": {"uv": [6.75, 5.875, 7.25, 6], "texture": "#2"},
"west": {"uv": [6.625, 5.875, 6.75, 6], "texture": "#2"},
"up": {"uv": [6.625, 5.875, 6.125, 5.75], "texture": "#2"},
"down": {"uv": [7.125, 5.75, 6.625, 5.875], "texture": "#2"}
}
},
{
"from": [0, 13, 11],
"to": [0, 15, 12],
"rotation": {"angle": 0, "axis": "y", "origin": [1, 0, -2]},
"faces": {
"north": {"uv": [6.375, 6.5, 6.375, 6.75], "texture": "#2"},
"east": {"uv": [6.25, 6.5, 6.375, 6.75], "texture": "#2"},
"south": {"uv": [6.5, 6.5, 6.5, 6.75], "texture": "#2"},
"west": {"uv": [6.375, 6.5, 6.5, 6.75], "texture": "#2"},
"up": {"uv": [6.375, 6.5, 6.375, 6.375], "texture": "#2"},
"down": {"uv": [6.375, 6.375, 6.375, 6.5], "texture": "#2"}
}
},
{
"from": [0, 13, 14],
"to": [0, 20, 15],
"rotation": {"angle": 0, "axis": "y", "origin": [1, 0, 1]},
"faces": {
"north": {"uv": [6.125, 6.5, 6.125, 7.375], "texture": "#2"},
"east": {"uv": [6, 6.5, 6.125, 7.375], "texture": "#2"},
"south": {"uv": [6.25, 6.5, 6.25, 7.375], "texture": "#2"},
"west": {"uv": [6.125, 6.5, 6.25, 7.375], "texture": "#2"},
"up": {"uv": [6.125, 6.5, 6.125, 6.375], "texture": "#2"},
"down": {"uv": [6.125, 6.375, 6.125, 6.5], "texture": "#2"}
}
},
{
"from": [0, 13, 14],
"to": [0, 20, 15],
"rotation": {"angle": 0, "axis": "y", "origin": [1, 0, 1]},
"faces": {
"north": {"uv": [6.625, 6.5, 6.625, 7.375], "texture": "#2"},
"east": {"uv": [6.5, 6.5, 6.625, 7.375], "texture": "#2"},
"south": {"uv": [6.75, 6.5, 6.75, 7.375], "texture": "#2"},
"west": {"uv": [6.625, 6.5, 6.75, 7.375], "texture": "#2"},
"up": {"uv": [6.625, 6.5, 6.625, 6.375], "texture": "#2"},
"down": {"uv": [6.625, 6.375, 6.625, 6.5], "texture": "#2"}
}
},
{
"from": [0, 13, 14],
"to": [0, 20, 15],
"rotation": {"angle": 0, "axis": "y", "origin": [1, 0, 1]},
"faces": {
"north": {"uv": [6.875, 6.5, 6.875, 7.375], "texture": "#2"},
"east": {"uv": [6.75, 6.5, 6.875, 7.375], "texture": "#2"},
"south": {"uv": [7, 6.5, 7, 7.375], "texture": "#2"},
"west": {"uv": [6.875, 6.5, 7, 7.375], "texture": "#2"},
"up": {"uv": [6.875, 6.5, 6.875, 6.375], "texture": "#2"},
"down": {"uv": [6.875, 6.375, 6.875, 6.5], "texture": "#2"}
}
}
],
"groups": [
{
"name": "bone",
"origin": [1, 0, 1],
"color": 0,
"children": [
{
"name": "bone9",
"origin": [12.5, 11.5, 0],
"color": 0,
"children": [0]
},
1,
2,
{
"name": "bone5",
"origin": [9, 12, 1],
"color": 0,
"children": [3]
},
{
"name": "bone4",
"origin": [12.5, 11.5, 0],
"color": 0,
"children": [4]
},
5,
6,
7,
{
"name": "bone8",
"origin": [8, 6, 1],
"color": 0,
"children": [8]
},
{
"name": "opening",
"origin": [8, 11.5, 6],
"color": 0,
"children": [
{
"name": "bone3",
"origin": [8, 11.5, 3],
"color": 0,
"children": [
{
"name": "bone10",
"origin": [8, 11.5, 5.5],
"color": 0,
"children": [
9,
{
"name": "bone11",
"origin": [8, 11.5, 4.5],
"color": 0,
"children": [
10,
{
"name": "bone12",
"origin": [8, 11.5, 3.5],
"color": 0,
"children": [11]
}
]
}
]
},
12,
13
]
},
{
"name": "bone2",
"origin": [8, 11.5, 13.0375],
"color": 0,
"children": [
{
"name": "bone13",
"origin": [8, 11.5, 10.5],
"color": 0,
"children": [
14,
{
"name": "bone14",
"origin": [8, 11.5, 11.5],
"color": 0,
"children": [15]
},
{
"name": "bone15",
"origin": [8, 11.5, 12.5],
"color": 0,
"children": [16]
}
]
},
17,
18,
19,
20,
21,
22,
23
]
}
]
},
24,
{
"name": "bone7",
"origin": [1, 0, 1],
"color": 0,
"children": [
{
"name": "bone6",
"origin": [0, 16.5, 14.5],
"color": 0,
"children": [25]
},
{
"name": "bone5red",
"origin": [0, 16.5, 14.5],
"color": 0,
"children": [26]
},
{
"name": "bone8green",
"origin": [0, 16.5, 14.5],
"color": 0,
"children": [27]
}
]
}
]
}
]
}
@@ -0,0 +1,146 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.client.camera;
import com.cobblemon.mod.common.client.util.compat.SodiumCompat;
import com.cobblemon.mod.common.platform.events.ClientChunkEvent;
import com.cobblemon.mod.common.platform.events.PlatformEvents;
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
import kotlin.Unit;
import kotlin.jvm.functions.Function1;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.network.protocol.game.ClientboundLevelChunkPacketData;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.chunk.LevelChunk;
import java.util.function.Consumer;
public class CameraClientChunkCacheExtension {
private static final int MIN_RENDER_DISTANCE = 2;
private static final int MAX_RENDER_DISTANCE = 64;
private static final int RENDER_DISTANCE_MARGIN = 2;
private static final Long2ObjectOpenHashMap<LevelChunk> CHUNK_MAP = new Long2ObjectOpenHashMap<>();
private static final Long2ObjectOpenHashMap<LevelChunk> CHUNK_MAP_OTHER_THREADS = new Long2ObjectOpenHashMap<>();
private static volatile ChunkPos CAMERA_CENTER;
private CameraClientChunkCacheExtension() {}
public static void drop(ClientLevel level, ChunkPos chunkPos) {
if (Minecraft.getInstance().isSameThread()) {
long chunkPosLong = chunkPos.toLong();
LevelChunk chunk = CHUNK_MAP.get(chunkPosLong);
if (chunk != null) {
modifyChunkMaps(map -> map.remove(chunkPosLong));
SodiumCompat.getInstance().notifyChunkRemoved(level, chunkPos.x, chunkPos.z);
PlatformEvents.CLIENT_CHUNK_UNLOAD.postEvents(new ClientChunkEvent.Unload(chunk, level));
level.unload(chunk);
}
}
}
public static LevelChunk getChunk(int x, int z) {
long chunkPos = ChunkPos.asLong(x, z);
if (Minecraft.getInstance().isSameThread())
return CHUNK_MAP.get(chunkPos);
else {
synchronized (CHUNK_MAP_OTHER_THREADS) {
return CHUNK_MAP_OTHER_THREADS.get(chunkPos);
}
}
}
public static LevelChunk replaceWithPacketData(ClientLevel level, int x, int z, FriendlyByteBuf packetData, CompoundTag chunkTag, Consumer<ClientboundLevelChunkPacketData.BlockEntityTagOutput> tagOutput) {
if (!Minecraft.getInstance().isSameThread())
throw new UnsupportedOperationException("replaceWithPacketData called off-thread, this shouldn't happen!");
ChunkPos chunkPos = new ChunkPos(x, z);
long longChunkPos = chunkPos.toLong();
LevelChunk chunk = CHUNK_MAP.get(longChunkPos);
if (chunk == null) {
LevelChunk newChunk = new LevelChunk(level, chunkPos);
chunk = newChunk;
chunk.replaceWithPacketData(packetData, chunkTag, tagOutput);
modifyChunkMaps(map -> map.put(longChunkPos, newChunk));
}
level.onChunkLoaded(chunkPos);
SodiumCompat.getInstance().notifyChunkAdded(level, x, z);
PlatformEvents.CLIENT_CHUNK_LOAD.postEvents(new ClientChunkEvent.Load(chunk, level));
return chunk;
}
private static void modifyChunkMaps(Consumer<Long2ObjectOpenHashMap<LevelChunk>> operation) {
operation.accept(CHUNK_MAP);
synchronized (CHUNK_MAP_OTHER_THREADS) {
operation.accept(CHUNK_MAP_OTHER_THREADS);
}
}
public static void clear() {
CHUNK_MAP.clear();
CHUNK_MAP_OTHER_THREADS.clear();
CAMERA_CENTER = null;
}
public static boolean hasCameraChunks() {
if (Minecraft.getInstance().isSameThread()) {
return !CHUNK_MAP.isEmpty();
} else {
synchronized (CHUNK_MAP_OTHER_THREADS) {
return !CHUNK_MAP_OTHER_THREADS.isEmpty();
}
}
}
/**
* Computes render distance using Chebyshev distance with a safety margin.
*/
public static int getSuggestedRenderDistance(ChunkPos center) {
int maxDistance = 0;
if (Minecraft.getInstance().isSameThread()) {
for (long key : CHUNK_MAP.keySet()) {
int x = ChunkPos.getX(key);
int z = ChunkPos.getZ(key);
int dx = Math.abs(x - center.x);
int dz = Math.abs(z - center.z);
maxDistance = Math.max(maxDistance, Math.max(dx, dz));
}
} else {
synchronized (CHUNK_MAP_OTHER_THREADS) {
for (long key : CHUNK_MAP_OTHER_THREADS.keySet()) {
int x = ChunkPos.getX(key);
int z = ChunkPos.getZ(key);
int dx = Math.abs(x - center.x);
int dz = Math.abs(z - center.z);
maxDistance = Math.max(maxDistance, Math.max(dx, dz));
}
}
}
return Math.max(MIN_RENDER_DISTANCE, Math.min(MAX_RENDER_DISTANCE, maxDistance + RENDER_DISTANCE_MARGIN));
}
public static void setCameraCenter(ChunkPos center) {
CAMERA_CENTER = center;
}
public static ChunkPos getCameraCenter() {
return CAMERA_CENTER;
}
}
@@ -0,0 +1,86 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.client.render.camera;
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
import net.minecraft.client.renderer.chunk.SectionRenderDispatcher;
import net.minecraft.core.BlockPos;
import net.minecraft.core.SectionPos;
import net.minecraft.world.level.Level;
public final class CameraViewAreaExtension {
private static final Long2ObjectOpenHashMap<SectionRenderDispatcher.RenderSection> SECTIONS = new Long2ObjectOpenHashMap<>();
private static SectionRenderDispatcher sectionRenderDispatcher;
private static int minSectionY;
private static int maxSectionY;
private CameraViewAreaExtension() {
}
public static void allChanged(SectionRenderDispatcher dispatcher, Level level) {
clear();
sectionRenderDispatcher = dispatcher;
minSectionY = level.getMinSection();
maxSectionY = level.getMaxSection();
}
public static SectionRenderDispatcher.RenderSection rawFetch(int cx, int cy, int cz, boolean generateNew) {
if (cy < minSectionY || cy >= maxSectionY) {
return null;
}
long sectionPos = SectionPos.asLong(cx, cy, cz);
return generateNew ? provideSection(sectionPos) : SECTIONS.get(sectionPos);
}
public static void setDirty(int cx, int cy, int cz, boolean playerChanged) {
SectionRenderDispatcher.RenderSection section = rawFetch(cx, cy, cz, false);
if (section != null) {
section.setDirty(playerChanged);
}
}
public static void onChunkUnload(int sectionX, int sectionZ) {
for (int sectionY = minSectionY; sectionY < maxSectionY; sectionY++) {
long sectionPos = SectionPos.asLong(sectionX, sectionY, sectionZ);
SectionRenderDispatcher.RenderSection section = SECTIONS.remove(sectionPos);
if (section != null) {
section.releaseBuffers();
}
}
}
public static void clear() {
for (SectionRenderDispatcher.RenderSection section : SECTIONS.values()) {
section.releaseBuffers();
}
SECTIONS.clear();
}
public static int minSectionY() {
return minSectionY;
}
public static int maxSectionY() {
return maxSectionY;
}
private static SectionRenderDispatcher.RenderSection provideSection(long sectionPos) {
return SECTIONS.computeIfAbsent(sectionPos, CameraViewAreaExtension::createSection);
}
private static SectionRenderDispatcher.RenderSection createSection(long sectionPos) {
if (sectionRenderDispatcher == null) {
throw new IllegalStateException("SectionRenderDispatcher not initialised");
}
BlockPos origin = SectionPos.of(sectionPos).origin();
return sectionRenderDispatcher.new RenderSection(0, origin.getX(), origin.getY(), origin.getZ());
}
}
@@ -0,0 +1,15 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.client.render.camera.accessor;
import net.minecraft.client.renderer.culling.Frustum;
public interface CameraLevelRendererAccessor {
Frustum cobblemonGetFrustum();
}
@@ -0,0 +1,23 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.mixin.accessor;
import net.minecraft.client.Camera;
import net.minecraft.world.level.BlockGetter;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;
@Mixin(Camera.class)
public interface CameraAccessor {
@Accessor("level")
BlockGetter getLevel();
@Accessor("detached")
void setDetached(boolean detached);
}
@@ -0,0 +1,23 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.mixin.accessor;
import net.minecraft.client.Camera;
import net.minecraft.client.renderer.GameRenderer;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;
@Mixin(GameRenderer.class)
public interface GameRendererAccessor {
@Accessor("mainCamera")
Camera cobblemon$getMainCamera();
@Accessor("mainCamera")
void cobblemon$setMainCamera(Camera camera);
}
@@ -0,0 +1,105 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.mixin.camera;
import java.util.HashSet;
import java.util.Set;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
import com.cobblemon.mod.common.api.camera.CameraManager;
import com.cobblemon.mod.common.api.camera.CameraManager.ChunkViewUpdate;
import com.llamalad7.mixinextras.sugar.Local;
import com.mojang.logging.LogUtils;
import net.minecraft.server.level.ChunkMap;
import net.minecraft.server.level.ChunkTrackingView;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.chunk.LevelChunk;
import org.slf4j.Logger;
@Mixin(value = ChunkMap.class, priority = 1100)
public abstract class ChunkMapMixin {
private static final Logger COBBLEMON$CAMERA_LOGGER = LogUtils.getLogger();
@Shadow
protected abstract void markChunkPendingToSend(ServerPlayer player, ChunkPos pos);
@Shadow
private static void markChunkPendingToSend(ServerPlayer player, LevelChunk chunk) {}
@Shadow
private static void dropChunk(ServerPlayer player, ChunkPos chunkPos) {}
@Inject(method = "updateChunkTracking", at = @At("HEAD"))
private void cobblemon$handleCameraChunkUpdates(ServerPlayer player, CallbackInfo ci) {
ChunkViewUpdate update = CameraManager.consumeChunkViewUpdate(player);
if (update != null) {
ChunkTrackingView playerView = player.getChunkTrackingView();
ChunkTrackingView current = update.getCurrent();
ChunkTrackingView previous = update.getPrevious();
if (current != null) {
ChunkTrackingView.difference(playerView, current, chunkPos -> cobblemon$queueChunkForPlayer(player, chunkPos), chunkPos -> {});
}
if (previous != null) {
Set<ChunkPos> droppingChunks = new HashSet<>();
ChunkTrackingView targetComparison = current != null ? current : playerView;
ChunkTrackingView.difference(previous, targetComparison, chunkPos -> {}, droppingChunks::add);
for (ChunkPos chunkPos : droppingChunks) {
if (playerView == null || !playerView.contains(chunkPos)) {
dropChunk(player, chunkPos);
}
}
}
}
}
@Inject(method = "onChunkReadyToSend", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/level/ServerPlayer;getChunkTrackingView()Lnet/minecraft/server/level/ChunkTrackingView;"))
private void cobblemon$queueCameraChunk(LevelChunk chunk, CallbackInfo ci, @Local ServerPlayer player) {
ChunkTrackingView view = CameraManager.getPlayerChunkView(player);
if (view != null && view.contains(chunk.getPos())) {
markChunkPendingToSend(player, chunk);
}
}
@Inject(method = "isChunkTracked", at = @At("HEAD"), cancellable = true)
private void cobblemon$isCameraChunkTracked(ServerPlayer player, int x, int z, CallbackInfoReturnable<Boolean> cir) {
ChunkTrackingView view = CameraManager.getPlayerChunkView(player);
if (view != null && view.contains(x, z) && !player.connection.chunkSender.isPending(ChunkPos.asLong(x, z))) {
cir.setReturnValue(true);
}
}
@Inject(method = "dropChunk", at = @At("HEAD"), cancellable = true)
private static void cobblemon$preventCameraChunkDrop(ServerPlayer player, ChunkPos pos, CallbackInfo ci) {
ChunkTrackingView view = CameraManager.getPlayerChunkView(player);
if (view != null && view.contains(pos)) {
ci.cancel();
}
}
private void cobblemon$queueChunkForPlayer(ServerPlayer player, ChunkPos chunkPos) {
markChunkPendingToSend(player, chunkPos);
}
}
@@ -0,0 +1,135 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.mixin.client;
import com.cobblemon.mod.common.client.render.camera.CameraFeedHandler;
import com.cobblemon.mod.common.client.render.camera.CameraViewAreaExtension;
import com.cobblemon.mod.common.client.render.camera.accessor.CameraLevelRendererAccessor;
import com.cobblemon.mod.common.client.util.compat.SodiumCompat;
import com.llamalad7.mixinextras.injector.v2.WrapWithCondition;
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
import net.minecraft.client.Camera;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.world.entity.Entity;
import net.minecraft.client.renderer.LevelRenderer;
import net.minecraft.client.renderer.ViewArea;
import net.minecraft.client.renderer.chunk.SectionRenderDispatcher;
import net.minecraft.client.renderer.culling.Frustum;
import net.minecraft.core.BlockPos;
import net.minecraft.core.SectionPos;
import org.spongepowered.asm.mixin.Final;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.ModifyVariable;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
@Mixin(value = LevelRenderer.class, priority = 1100)
public abstract class CameraLevelRendererMixin implements CameraLevelRendererAccessor {
private static final int CINEMATIC_RENDER_DISTANCE_CHUNKS = 32;
private static final float CINEMATIC_FOG_DISTANCE = CINEMATIC_RENDER_DISTANCE_CHUNKS * 16;
@Shadow
@Final
private Minecraft minecraft;
@Shadow
private SectionRenderDispatcher sectionRenderDispatcher;
@Shadow
private ClientLevel level;
@Shadow private Frustum capturedFrustum;
@Shadow public Frustum cullingFrustum;
@WrapWithCondition(method = "setupRender", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/renderer/ViewArea;repositionCamera(DD)V"))
private boolean cobblemon$shouldRepositionCamera(ViewArea viewArea, double x, double z) {
return !CameraFeedHandler.isCapturing();
}
/**
* Cancel vanilla setupRender during feed capture to avoid overwriting
* the camera feed's visible sections. When Sodium is active, let it
* proceed since Sodium manages its own chunk rendering pipeline and
* needs setupRender to run.
*/
@Inject(method = "setupRender", at = @At("HEAD"), cancellable = true)
private void cobblemon$onSetupRender(Camera camera, Frustum frustum, boolean hasCapturedFrustum, boolean isSpectator, CallbackInfo ci) {
if (CameraFeedHandler.isCapturing() && !SodiumCompat.getInstance().isActive())
ci.cancel();
}
@Inject(method = "allChanged", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/renderer/SectionOcclusionGraph;waitAndReset(Lnet/minecraft/client/renderer/ViewArea;)V"))
private void cobblemon$onAllChanged(CallbackInfo ci) {
CameraViewAreaExtension.allChanged(sectionRenderDispatcher, level);
}
@Inject(method = "isSectionCompiled", at = @At("HEAD"), cancellable = true)
private void cobblemon$onIsSectionCompiled(BlockPos pos, CallbackInfoReturnable<Boolean> cir) {
if (CameraFeedHandler.isCapturing()) {
SectionPos sectionPos = SectionPos.of(pos);
SectionRenderDispatcher.RenderSection section = CameraViewAreaExtension.rawFetch(sectionPos.x(), sectionPos.y(), sectionPos.z(), false);
if (section != null && section.getCompiled() != SectionRenderDispatcher.CompiledSection.UNCOMPILED) {
cir.setReturnValue(true);
}
}
}
@ModifyVariable(method = "renderLevel", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/renderer/FogRenderer;setupColor(Lnet/minecraft/client/Camera;FLnet/minecraft/client/multiplayer/ClientLevel;IF)V"), ordinal = 1)
private float cobblemon$modifyFogRenderDistance(float original) {
if (CameraFeedHandler.isCapturing())
return CINEMATIC_FOG_DISTANCE;
return original;
}
/**
* During feed capture the camera entity is a Marker, which causes the vanilla condition
* {@code !(entity instanceof LocalPlayer) || camera.getEntity() == entity} to fail for
* the local player — preventing them from rendering.
*
* By returning the local player from {@code getEntity()} and {@code true} from
* {@code isDetached()}, both condition2 and condition3 in the entity rendering check pass:
* <ul>
* <li>condition2: {@code entity != player || isDetached()==true} → passes</li>
* <li>condition3: {@code player == player} → passes</li>
* </ul>
*/
@WrapOperation(
method = "renderLevel",
at = @At(value = "INVOKE", target = "Lnet/minecraft/client/Camera;getEntity()Lnet/minecraft/world/entity/Entity;")
)
private Entity cobblemon$getCameraEntityDuringCapture(Camera camera, Operation<Entity> original) {
if (CameraFeedHandler.isCapturing()) {
Entity player = Minecraft.getInstance().player;
if (player != null) return player;
}
return original.call(camera);
}
@WrapOperation(
method = "renderLevel",
at = @At(value = "INVOKE", target = "Lnet/minecraft/client/Camera;isDetached()Z")
)
private boolean cobblemon$isCameraDetachedDuringCapture(Camera camera, Operation<Boolean> original) {
if (CameraFeedHandler.isCapturing()) {
return true;
}
return original.call(camera);
}
@Override
public Frustum cobblemonGetFrustum() {
return this.capturedFrustum != null ? this.capturedFrustum : this.cullingFrustum;
}
}
@@ -8,22 +8,32 @@
package com.cobblemon.mod.common.mixin.client;
import com.cobblemon.mod.common.Cobblemon;
import com.cobblemon.mod.common.OrientationControllable;
import com.cobblemon.mod.common.api.camera.CameraRotation;
import com.cobblemon.mod.common.api.orientation.OrientationController;
import com.cobblemon.mod.common.api.riding.Rideable;
import com.cobblemon.mod.common.client.MountedPokemonAnimationRenderController;
import com.cobblemon.mod.common.client.camera.ClientCameraController;
import com.cobblemon.mod.common.client.render.camera.CameraFeedHandler;
import com.cobblemon.mod.common.client.render.camera.MountedCameraRenderer;
import com.cobblemon.mod.common.duck.CameraDuck;
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
import com.llamalad7.mixinextras.sugar.Local;
import net.minecraft.client.Camera;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.LevelRenderer;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.phys.Vec3;
import org.joml.Quaternionf;
import org.joml.Vector3f;
import org.spongepowered.asm.mixin.Final;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
@Mixin(Camera.class)
@@ -32,6 +42,7 @@ public abstract class CameraMixin implements CameraDuck {
@Shadow private float eyeHeightOld;
@Shadow private BlockGetter level;
@Shadow private Entity entity;
@Final @Shadow private Quaternionf rotation;
@Final @Shadow private Vector3f forwards;
@Final @Shadow private Vector3f up;
@Final @Shadow private Vector3f left;
@@ -43,6 +54,11 @@ public abstract class CameraMixin implements CameraDuck {
@Override public BlockGetter cobblemon$getLevel() {
return level;
}
@Shadow protected abstract void setRotation(float yRot, float xRot);
@Unique private float returnTimer = 0;
@Unique private float rollAngleStart = 0;
@Unique Minecraft minecraft = Minecraft.getInstance();
@Override public Entity cobblemon$getEntity() {
return entity;
@@ -66,6 +82,28 @@ public abstract class CameraMixin implements CameraDuck {
Entity vehicle = entity.getVehicle();
MountedPokemonAnimationRenderController.INSTANCE.reset();
// Check for cinematic camera override
if (ClientCameraController.isActive()) {
// When capturing a feed, use that specific camera's transform
// instead of the primary camera to avoid both feeds sharing the same position.
java.util.UUID capturedId = CameraFeedHandler.getCurrentlyCapturedCamera();
ClientCameraController.ActiveTransform cameraTransform = capturedId != null
? ClientCameraController.getActiveCameraTransform(capturedId)
: ClientCameraController.getCameraTransform();
if (cameraTransform != null) {
Vec3 position = cameraTransform.getPosition();
CameraRotation rotation = cameraTransform.getRotation();
setPosition(new Vec3(position.x, position.y, position.z));
setRotation(rotation.getYaw(), rotation.getPitch());
OrientationController.Companion.getFORWARDS().rotate(this.rotation, this.forwards);
OrientationController.Companion.getUP().rotate(this.rotation, this.up);
OrientationController.Companion.getLEFT().rotate(this.rotation, this.left);
return;
}
}
if(vehicle instanceof Rideable){
Vec3 position = MountedCameraRenderer.INSTANCE.getCameraPosition(
instance,
@@ -0,0 +1,83 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.mixin.client;
import com.cobblemon.mod.common.client.camera.CameraClientChunkCacheExtension;
import com.cobblemon.mod.common.client.camera.CameraChunkVisibilityManager;
import com.cobblemon.mod.common.client.render.camera.CameraFeedHandler;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientChunkCache;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.network.protocol.game.ClientboundLevelChunkPacketData;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import org.spongepowered.asm.mixin.Final;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
import java.util.function.Consumer;
@Mixin(value = ClientChunkCache.class, priority = 1100)
public abstract class ClientChunkCacheMixin {
@Shadow
volatile ClientChunkCache.Storage storage;
@Shadow
@Final
ClientLevel level;
/**
* Intercept updateViewCenter to use camera position when active.
*/
@Inject(method = "drop", at = @At("HEAD"))
private void cobblemon$onDrop(ChunkPos chunkPos, CallbackInfo ci) {
int renderDistance = Minecraft.getInstance().options.renderDistance().get();
if (CameraChunkVisibilityManager.shouldKeepChunk(chunkPos, renderDistance)
|| CameraFeedHandler.shouldAddChunk(chunkPos, renderDistance)) {
return;
}
CameraClientChunkCacheExtension.drop(level, chunkPos);
}
/**
* Override replaceWithPacketData to accept chunks based on camera position.
*/
@Inject(method = "replaceWithPacketData", at = @At("HEAD"), cancellable = true)
public void cobblemon$onReplaceWithPacketData(int x, int z, FriendlyByteBuf buffer, CompoundTag chunkTag, Consumer<ClientboundLevelChunkPacketData.BlockEntityTagOutput> tagOutputConsumer, CallbackInfoReturnable<LevelChunk> cir) {
int renderDistance = Minecraft.getInstance().options.renderDistance().get();
ChunkPos chunkPos = new ChunkPos(x, z);
boolean isInPlayerRange = storage.inRange(x, z);
boolean shouldAddChunk = CameraChunkVisibilityManager.shouldKeepChunk(chunkPos, renderDistance)
|| CameraFeedHandler.shouldAddChunk(chunkPos, renderDistance);
if (shouldAddChunk) {
LevelChunk newChunk = CameraClientChunkCacheExtension.replaceWithPacketData(level, x, z, new FriendlyByteBuf(buffer.copy()), chunkTag, tagOutputConsumer);
if (!isInPlayerRange)
cir.setReturnValue(newChunk);
}
}
@Inject(method = "getChunk(IILnet/minecraft/world/level/chunk/status/ChunkStatus;Z)Lnet/minecraft/world/level/chunk/LevelChunk;", at = @At("TAIL"), cancellable = true)
private void cobblemon$onGetChunk(int x, int z, ChunkStatus chunkStatus, boolean requireChunk, CallbackInfoReturnable<LevelChunk> cir) {
if (!storage.inRange(x, z)) {
LevelChunk chunk = CameraClientChunkCacheExtension.getChunk(x, z);
if (chunk != null)
cir.setReturnValue(chunk);
}
}
}
@@ -9,43 +9,122 @@
package com.cobblemon.mod.common.mixin.client;
import com.cobblemon.mod.common.api.scheduling.ClientTaskTracker;
import com.cobblemon.mod.common.api.tween.TweenManager;
import com.cobblemon.mod.common.client.camera.GameCamera;
import com.cobblemon.mod.common.client.camera.ClientCameraController;
import com.cobblemon.mod.common.client.render.camera.CameraFeedHandler;
import com.cobblemon.mod.common.client.render.camera.compositor.CompositorManager;
import net.minecraft.client.Camera;
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity;
import com.llamalad7.mixinextras.sugar.Local;
import net.minecraft.client.DeltaTracker;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.GameRenderer;
import net.minecraft.world.entity.Entity;
import org.spongepowered.asm.mixin.Final;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Mutable;
import org.spongepowered.asm.mixin.Shadow;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.level.BlockGetter;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.ModifyArg;
import org.spongepowered.asm.mixin.injection.Redirect;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import java.util.function.Predicate;
@Mixin(GameRenderer.class)
public class GameRendererMixin {
@Unique
long lastTime = -1;
public abstract class GameRendererMixin {
@Inject(
method = "render",
at = @At(value = "TAIL")
)
public void render(DeltaTracker counter, boolean tick, CallbackInfo ci) {
@Shadow @Final @Mutable
private Camera mainCamera;
@Unique
private Camera cobblemon$playerCamera;
@Unique
private final GameCamera cobblemon$gameCamera = new GameCamera();
@Unique
private boolean cobblemon$usingCinematic;
@Unique
private long cobblemon$lastTime = -1;
@Inject(method = "render", at = @At("HEAD"))
private void cobblemon$swapCamera(DeltaTracker counter, boolean tick, CallbackInfo ci) {
Minecraft client = Minecraft.getInstance();
long newTime = System.currentTimeMillis();
// Don't play scheduled animations when the game is paused
if (client.isPaused()) {
lastTime = newTime;
if (client.level == null) {
cobblemon$restoreCamera();
return;
}
if (lastTime != -1) {
ClientTaskTracker.INSTANCE.update((newTime - lastTime) / 1000F);
if (ClientCameraController.shouldUseCinematicCamera()) {
if (!cobblemon$usingCinematic) {
cobblemon$playerCamera = mainCamera;
mainCamera = cobblemon$gameCamera;
cobblemon$usingCinematic = true;
}
ClientCameraController.prepareCinematicCamera(client, counter.getGameTimeDeltaPartialTick(tick));
} else if (cobblemon$usingCinematic) {
cobblemon$restoreCamera();
}
lastTime = newTime;
}
/**
* Skip the vanilla renderLevel() call when a fullscreen compositor is active since
* the compositor covers the entire screen with camera feeds.
* This avoids a full wasted scene render every frame.
*/
@Redirect(method = "render", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/renderer/GameRenderer;renderLevel(Lnet/minecraft/client/DeltaTracker;)V"))
private void cobblemon$skipVanillaRenderLevel(GameRenderer instance, DeltaTracker deltaTracker) {
if (!CompositorManager.isFullscreenActive()) {
instance.renderLevel(deltaTracker);
}
}
@Inject(method = "render", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/renderer/GameRenderer;tryTakeScreenshotIfNeeded()V"))
private void cobblemon$afterLevelRendering(DeltaTracker deltaTracker, boolean renderLevel, CallbackInfo ci) {
// Only capture feeds when a compositor is actively consuming them.
// This avoids expensive full-resolution renders (especially with Iris)
// when feeds aren't being displayed.
boolean compositorNeedsFeeds = CompositorManager.isFullscreenActive()
|| !CompositorManager.INSTANCE.getRequiredFeeds().isEmpty();
if (compositorNeedsFeeds && CameraFeedHandler.hasFeeds()) {
CameraFeedHandler.captureFeeds(deltaTracker);
}
if (CompositorManager.isFullscreenActive()) {
CompositorManager.INSTANCE.compositeAll();
CompositorManager.renderFullscreen();
}
}
@Inject(method = "pick", at = @At("HEAD"), cancellable = true)
private void cobblemon$preventFramePick(float partialTicks, CallbackInfo ci) {
if (CameraFeedHandler.isCapturing())
ci.cancel();
}
@Inject(method = "render", at = @At(value = "TAIL"))
private void cobblemon$afterRender(DeltaTracker counter, boolean tick, CallbackInfo ci) {
Minecraft client = Minecraft.getInstance();
long newTime = System.currentTimeMillis();
if (client.isPaused()) {
cobblemon$lastTime = newTime;
return;
}
if (cobblemon$lastTime != -1) {
float delta = (newTime - cobblemon$lastTime) / 1000F;
ClientTaskTracker.INSTANCE.update(delta);
TweenManager.Companion.getClient().update(delta);
}
cobblemon$lastTime = newTime;
}
@ModifyArg(
@@ -60,4 +139,12 @@ public class GameRendererMixin {
return (entityX) -> !(entity.getVehicle() == entityX && entityX instanceof PokemonEntity) && original.test(entityX);
}
@Unique
private void cobblemon$restoreCamera() {
if (cobblemon$usingCinematic) {
mainCamera = cobblemon$playerCamera;
cobblemon$playerCamera = null;
cobblemon$usingCinematic = false;
}
}
}
@@ -9,6 +9,7 @@
package com.cobblemon.mod.common.mixin.client;
import com.cobblemon.mod.common.OrientationControllable;
import com.cobblemon.mod.common.client.camera.ClientCameraController;
import com.cobblemon.mod.common.item.PokedexItem;
import com.mojang.blaze3d.vertex.PoseStack;
import net.minecraft.client.Minecraft;
@@ -28,6 +29,12 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
public class ItemInHandRendererMixin {
@Inject(method = "renderArmWithItem", at = @At(value = "HEAD"), cancellable = true)
private void cobblemon$renderArmWithItem(AbstractClientPlayer abstractClientPlayer, float f, float g, InteractionHand interactionHand, float h, ItemStack itemStack, float i, PoseStack poseStack, MultiBufferSource multiBufferSource, int j, CallbackInfo ci) {
// Hide arms when cinematic camera is active
if (ClientCameraController.isActive()) {
ci.cancel();
return;
}
if (abstractClientPlayer.isUsingItem() && abstractClientPlayer.getUseItem().getItem() instanceof PokedexItem) {
ci.cancel();
}
@@ -8,11 +8,14 @@
package com.cobblemon.mod.common.mixin.client;
import com.cobblemon.mod.common.client.camera.ClientCameraController;
import com.cobblemon.mod.common.client.render.ClientPlayerIcon;
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity;
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
import com.mojang.blaze3d.vertex.PoseStack;
import net.minecraft.client.CameraType;
import net.minecraft.client.Minecraft;
import net.minecraft.client.model.PlayerModel;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.client.renderer.MultiBufferSource;
@@ -22,6 +25,7 @@ import net.minecraft.client.renderer.entity.player.PlayerRenderer;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.Redirect;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(PlayerRenderer.class)
@@ -31,6 +35,27 @@ public abstract class PlayerRendererMixin extends LivingEntityRenderer<AbstractC
super(context, model, shadowRadius);
}
/**
* Override the camera type check to force player body rendering when cinematic camera is active.
* This makes Minecraft think we're not in first person mode (for rendering purposes only),
* so the local player's body is rendered even though the camera entity is still the player.
*/
@Redirect(
method = "render(Lnet/minecraft/client/player/AbstractClientPlayer;FFLcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/MultiBufferSource;I)V",
at = @At(
value = "INVOKE",
target = "Lnet/minecraft/client/CameraType;isFirstPerson()Z"
)
)
private boolean cobblemon$allowBodyRenderInCinematic(CameraType instance) {
// When cinematic camera is active and configured to show player body,
// pretend we're not in first person so the player body renders
if (ClientCameraController.isActive() && ClientCameraController.shouldRenderPlayerBody()) {
return false;
}
return instance.isFirstPerson();
}
@Inject(method = "render", at = @At(value = "TAIL"))
public void renderPlayer(AbstractClientPlayer player, float f, float g, PoseStack poseStack, MultiBufferSource multiBufferSource, int i, CallbackInfo ci) {
ClientPlayerIcon.Companion.onRenderPlayer(player);
@@ -0,0 +1,25 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.mixin.client;
import com.cobblemon.mod.common.client.render.camera.CameraViewAreaExtension;
import net.minecraft.client.renderer.ViewArea;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(ViewArea.class)
public class ViewAreaMixin {
@Inject(method = "setDirty", at = @At("HEAD"))
private void cobblemon$markCinematicDirty(int cx, int cy, int cz, boolean reRenderOnMainThread, CallbackInfo ci) {
CameraViewAreaExtension.setDirty(cx, cy, cz, reRenderOnMainThread);
}
}
@@ -0,0 +1,21 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.mixin.invoker;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.world.inventory.Slot;
import org.jetbrains.annotations.Nullable;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Invoker;
@Mixin(AbstractContainerScreen.class)
public interface AbstractContainerScreenInvoker {
@Invoker("findSlot")
@Nullable Slot callFindSlot(double mouseX, double mouseY);
}
@@ -72,8 +72,11 @@ import com.cobblemon.mod.common.battles.BattleSide
import com.cobblemon.mod.common.battles.ShowdownThread
import com.cobblemon.mod.common.battles.actor.PokemonBattleActor
import com.cobblemon.mod.common.battles.pokemon.BattlePokemon
import com.cobblemon.mod.common.api.camera.track.CameraTrackDeserializers
import com.cobblemon.mod.common.block.dispenser.DispenserBehaviorRegistry
import com.cobblemon.mod.common.client.CobblemonPack
import com.cobblemon.mod.common.api.tween.EasingRegistry
import com.cobblemon.mod.common.api.tween.TweenManager
import com.cobblemon.mod.common.command.argument.*
import com.cobblemon.mod.common.config.CobblemonConfig
import com.cobblemon.mod.common.config.LastChangedVersion
@@ -256,6 +259,7 @@ object Cobblemon {
playerDataManager.onPlayerDisconnect(it.player)
RequestManager.onLogoff(it.player)
serverPlayerStructs.remove(it.player.uuid)
com.cobblemon.mod.common.api.molangui.MolangUiSessionManager.closeAllForPlayer(it.player.uuid)
}
PlatformEvents.PLAYER_DEATH.subscribe {
PCLinkManager.removeLink(it.player.uuid)
@@ -298,6 +302,7 @@ object Cobblemon {
HeldItemProvider.register(CobblemonHeldItemManager, Priority.LOWEST)
}
fun sendServerSettingsPacketToPlayer(player: ServerPlayer) {
ServerSettingsPacket(
this.config.preventCompletePartyDeposit,
@@ -343,6 +348,10 @@ object Cobblemon {
CustomPokemonProperty.register(FreezeFrameProperty)
CustomPokemonProperty.register(NoAIProperty)
// Tween & camera system initialization
EasingRegistry.registerBuiltinEasings()
CameraTrackDeserializers.registerBuiltinTracks()
CobblemonEvents.POKEMON_PROPERTY_INITIALISED.emit(Unit)
CallbackHandler.setup()
@@ -355,6 +364,7 @@ object Cobblemon {
PlatformEvents.SERVER_TICK_POST.subscribe {
ServerTaskTracker.update(1/20F)
ServerRealTimeTaskTracker.update()
TweenManager.server.update(1/20F)
}
PlatformEvents.SERVER_TICK_PRE.subscribe {
ServerRealTimeTaskTracker.update()
@@ -106,4 +106,10 @@ object CobblemonBlockEntities : PlatformRegistry<Registry<BlockEntityType<*>>, R
"poke_snack",
BlockEntityType.Builder.of(::PokeSnackBlockEntity, CobblemonBlocks.POKE_SNACK, CobblemonBlocks.POKE_CAKE).build(null)
)
@JvmField
val STADIUM_BLOCK: BlockEntityType<StadiumBlockEntity> = this.create(
"stadium_block",
BlockEntityType.Builder.of(::StadiumBlockEntity, CobblemonBlocks.STADIUM_BLOCK).build(null)
)
}
@@ -581,6 +581,9 @@ object CobblemonBlocks : PlatformRegistry<Registry<Block>, ResourceKey<Registry<
@JvmField
val HEALING_MACHINE = create("healing_machine", HealingMachineBlock(BlockBehaviour.Properties.of().mapColor(MapColor.METAL).sound(SoundType.METAL).pushReaction(PushReaction.BLOCK).strength(2f).noOcclusion().lightLevel { if (it.getValue(HealingMachineBlock.CHARGE_LEVEL) >= HealingMachineBlock.MAX_CHARGE_LEVEL) 12 else 5 }))
@JvmField
val STADIUM_BLOCK = create("stadium_block", StadiumBlock(BlockBehaviour.Properties.of().mapColor(MapColor.METAL).sound(SoundType.METAL).strength(3.5F, 6.0F).noOcclusion().pushReaction(PushReaction.BLOCK)))
@JvmField
val PC = create("pc", PCBlock(BlockBehaviour.Properties.of().mapColor(MapColor.METAL).sound(SoundType.METAL).pushReaction(PushReaction.BLOCK).strength(2F).noOcclusion().lightLevel { if ((it.getValue(PCBlock.ON) as Boolean) && (it.getValue(PCBlock.PART) == PCBlock.PCPart.TOP)) 13 else 0 }))
@@ -15,7 +15,11 @@ import net.minecraft.commands.CommandSourceStack
import net.minecraft.commands.Commands
object CobblemonCommands {
fun register(dispatcher: CommandDispatcher<CommandSourceStack>, registry: CommandBuildContext, selection: Commands.CommandSelection) {
fun register(
dispatcher: CommandDispatcher<CommandSourceStack>,
registry: CommandBuildContext,
selection: Commands.CommandSelection
) {
SpawnPokemon.register(dispatcher)
SpawnNPCCommand.register(dispatcher)
GivePokemon.register(dispatcher)
@@ -76,5 +80,8 @@ object CobblemonCommands {
// with a certain system environment variable set
CobblemonInfoCommand.register(dispatcher)
CalculateSeatPositionCommand.register(dispatcher)
CameraDebugCommand.register(dispatcher)
StadiumBattleCommand.register(dispatcher)
MolangUiCommand.register(dispatcher)
}
}
@@ -39,6 +39,7 @@ import com.cobblemon.mod.common.item.interactive.PonigiriItem
import com.cobblemon.mod.common.item.food.SinisterTeaItem
import com.cobblemon.mod.common.item.interactive.*
import com.cobblemon.mod.common.item.interactive.ability.AbilityChangeItem
import com.cobblemon.mod.common.item.stadium.StadiumPlacementWandItem
import com.cobblemon.mod.common.platform.PlatformRegistry
import com.cobblemon.mod.common.pokeball.PokeBall
import com.cobblemon.mod.common.pokemon.IVs
@@ -75,6 +76,9 @@ object CobblemonItems : PlatformRegistry<Registry<Item>, ResourceKey<Registry<It
@JvmField
val NPC_EDITOR = create("npc_editor", CobblemonItem(Item.Properties().stacksTo(1)))
@JvmField
val STADIUM_PLACEMENT_WAND = create("stadium_placement_wand", StadiumPlacementWandItem())
@JvmField
val pokeBalls = mutableListOf<PokeBallItem>()
@JvmField
@@ -460,6 +464,8 @@ object CobblemonItems : PlatformRegistry<Registry<Item>, ResourceKey<Registry<It
val PASTURE = blockItem("pasture", CobblemonBlocks.PASTURE)
@JvmField
val DISPLAY_CASE = blockItem("display_case", CobblemonBlocks.DISPLAY_CASE)
@JvmField
val STADIUM_BLOCK = blockItem("stadium_block", CobblemonBlocks.STADIUM_BLOCK)
// @JvmField
// val INCENSE_SWEET = blockItem("incense_sweet", CobblemonBlocks.INCENSE_SWEET) // TODO after 1.7
@@ -9,6 +9,7 @@
package com.cobblemon.mod.common
import com.cobblemon.mod.common.api.molangui.container.MolangContainerMenu
import com.cobblemon.mod.common.block.campfirepot.CookingPotMenu
import com.cobblemon.mod.common.platform.PlatformRegistry
import net.minecraft.core.Registry
@@ -20,6 +21,7 @@ import net.minecraft.world.inventory.MenuType
object CobblemonMenuType : PlatformRegistry<Registry<MenuType<*>>, ResourceKey<Registry<MenuType<*>>>, MenuType<*>>() {
val COOKING_POT = MenuType.register("cooking_pot", ::CookingPotMenu)
val MOLANG_UI = MenuType.register("molang_ui", ::MolangContainerMenu)
override val registry: Registry<MenuType<*>>
get() = BuiltInRegistries.MENU
@@ -18,6 +18,14 @@ import com.cobblemon.mod.common.client.net.battle.*
import com.cobblemon.mod.common.client.net.callback.move.OpenMoveCallbackHandler
import com.cobblemon.mod.common.client.net.callback.party.OpenPartyCallbackHandler
import com.cobblemon.mod.common.client.net.callback.partymove.OpenPartyMoveCallbackHandler
import com.cobblemon.mod.common.client.net.camera.CameraDebugHandler
import com.cobblemon.mod.common.client.net.camera.CameraEndHandler
import com.cobblemon.mod.common.client.net.camera.CameraFocusHandler
import com.cobblemon.mod.common.client.net.camera.CameraStartHandler
import com.cobblemon.mod.common.client.net.camera.CameraStateHandler
import com.cobblemon.mod.common.client.net.camera.CameraTransformHandler
import com.cobblemon.mod.common.client.net.camera.CompositorHandler
import com.cobblemon.mod.common.client.net.camera.SplitScreenHandler
import com.cobblemon.mod.common.client.net.cooking.ToggleCookingPotLidHandler
import com.cobblemon.mod.common.client.net.data.CobblemonMechanicsSyncHandler
import com.cobblemon.mod.common.client.net.data.DataRegistrySyncPacketHandler
@@ -33,6 +41,9 @@ import com.cobblemon.mod.common.client.net.effect.SpawnSnowstormParticleHandler
import com.cobblemon.mod.common.client.net.gui.ExpGainedDataPacketHandler
import com.cobblemon.mod.common.client.net.gui.InteractPokemonUIPacketHandler
import com.cobblemon.mod.common.client.net.gui.PokedexUIPacketHandler
import com.cobblemon.mod.common.client.net.molangui.MolangUiCloseHandler
import com.cobblemon.mod.common.client.net.molangui.MolangUiOpenHandler
import com.cobblemon.mod.common.client.net.molangui.MolangUiUpdateHandler
import com.cobblemon.mod.common.client.net.gui.SummaryUIPacketHandler
import com.cobblemon.mod.common.client.net.npc.CloseNPCEditorHandler
import com.cobblemon.mod.common.client.net.npc.OpenNPCEditorHandler
@@ -49,6 +60,7 @@ import com.cobblemon.mod.common.client.net.riding.ClientboundUpdateDriverInputHa
import com.cobblemon.mod.common.client.net.settings.OpenCobblemonConfigEditorHandler
import com.cobblemon.mod.common.client.net.settings.ServerSettingsPacketHandler
import com.cobblemon.mod.common.client.net.sound.UnvalidatedPlaySoundS2CPacketHandler
import com.cobblemon.mod.common.client.net.stadium.OpenStadiumConfigHandler
import com.cobblemon.mod.common.client.net.spawn.SpawnExtraDataEntityHandler
import com.cobblemon.mod.common.client.net.starter.StarterUIPacketHandler
import com.cobblemon.mod.common.client.net.storage.RemoveClientPokemonHandler
@@ -74,6 +86,14 @@ import com.cobblemon.mod.common.net.messages.client.PlayerInteractOptionsPacket
import com.cobblemon.mod.common.net.messages.client.SetClientPlayerDataPacket
import com.cobblemon.mod.common.net.messages.client.animation.PlayPosableAnimationPacket
import com.cobblemon.mod.common.net.messages.client.battle.*
import com.cobblemon.mod.common.net.messages.client.camera.CameraDebugPacket
import com.cobblemon.mod.common.net.messages.client.camera.CameraEndPacket
import com.cobblemon.mod.common.net.messages.client.camera.CameraFocusPacket
import com.cobblemon.mod.common.net.messages.client.camera.CameraStartPacket
import com.cobblemon.mod.common.net.messages.client.camera.CameraStatePacket
import com.cobblemon.mod.common.net.messages.client.camera.CameraTransformPacket
import com.cobblemon.mod.common.net.messages.client.camera.CompositorPacket
import com.cobblemon.mod.common.net.messages.client.camera.SplitScreenPacket
import com.cobblemon.mod.common.net.messages.client.callback.OpenMoveCallbackPacket
import com.cobblemon.mod.common.net.messages.client.callback.OpenPartyCallbackPacket
import com.cobblemon.mod.common.net.messages.client.callback.OpenPartyMoveCallbackPacket
@@ -81,6 +101,9 @@ import com.cobblemon.mod.common.net.messages.client.cooking.SeasoningRegistrySyn
import com.cobblemon.mod.common.net.messages.client.cooking.ToggleCookingPotLidPacket
import com.cobblemon.mod.common.net.messages.client.data.*
import com.cobblemon.mod.common.net.messages.client.debug.RequestOpenRidingStatsDebugGUIPacket
import com.cobblemon.mod.common.net.messages.client.molangui.MolangUiClosePacket
import com.cobblemon.mod.common.net.messages.client.molangui.MolangUiOpenPacket
import com.cobblemon.mod.common.net.messages.client.molangui.MolangUiUpdatePacket
import com.cobblemon.mod.common.net.messages.client.dialogue.DialogueClosedPacket
import com.cobblemon.mod.common.net.messages.client.dialogue.DialogueOpenedPacket
import com.cobblemon.mod.common.net.messages.client.effect.PokeSnackBlockParticlesPacket
@@ -103,6 +126,7 @@ import com.cobblemon.mod.common.net.messages.client.pokemon.update.evolution.Add
import com.cobblemon.mod.common.net.messages.client.pokemon.update.evolution.ClearEvolutionsPacket
import com.cobblemon.mod.common.net.messages.client.pokemon.update.evolution.RemoveEvolutionPacket
import com.cobblemon.mod.common.net.messages.client.settings.OpenCobblemonConfigScreenPacket
import com.cobblemon.mod.common.net.messages.client.stadium.OpenStadiumConfigPacket
import com.cobblemon.mod.common.net.messages.client.settings.ServerSettingsPacket
import com.cobblemon.mod.common.net.messages.client.sound.UnvalidatedPlaySoundS2CPacket
import com.cobblemon.mod.common.net.messages.client.spawn.SpawnGenericBedrockPacket
@@ -162,7 +186,13 @@ import com.cobblemon.mod.common.net.messages.server.debug.ServerboundUpdateRidin
import com.cobblemon.mod.common.net.messages.server.debug.ServerboundUpdateRidingStatsPacket
import com.cobblemon.mod.common.net.messages.server.dialogue.EscapeDialoguePacket
import com.cobblemon.mod.common.net.messages.server.dialogue.InputToDialoguePacket
import com.cobblemon.mod.common.net.messages.server.molangui.MolangUiActionPacket
import com.cobblemon.mod.common.net.messages.server.molangui.MolangUiClosedPacket
import com.cobblemon.mod.common.net.messages.server.molangui.MolangUiVersionPacket
import com.cobblemon.mod.common.net.messages.server.npc.SaveNPCPacket
import com.cobblemon.mod.common.net.messages.server.stadium.RequestStadiumPlacementWandPacket
import com.cobblemon.mod.common.net.messages.server.stadium.StadiumPlacementSetPacket
import com.cobblemon.mod.common.net.messages.server.stadium.UpdateStadiumBlockPacket
import com.cobblemon.mod.common.net.messages.server.orientation.ServerboundUpdateOrientationPacket
import com.cobblemon.mod.common.net.messages.server.pasture.PasturePokemonPacket
import com.cobblemon.mod.common.net.messages.server.pasture.SetPastureConflictPacket
@@ -217,8 +247,14 @@ import com.cobblemon.mod.common.net.serverhandling.debug.ServerboundUpdateRiding
import com.cobblemon.mod.common.net.serverhandling.debug.ServerboundUpdateRidingStatsHandler
import com.cobblemon.mod.common.net.serverhandling.dialogue.EscapeDialogueHandler
import com.cobblemon.mod.common.net.serverhandling.dialogue.InputToDialogueHandler
import com.cobblemon.mod.common.net.serverhandling.molangui.MolangUiActionHandler
import com.cobblemon.mod.common.net.serverhandling.molangui.MolangUiClosedHandler
import com.cobblemon.mod.common.net.serverhandling.molangui.MolangUiVersionHandler
import com.cobblemon.mod.common.net.serverhandling.evolution.AcceptEvolutionHandler
import com.cobblemon.mod.common.net.serverhandling.npc.SaveNPCHandler
import com.cobblemon.mod.common.net.serverhandling.stadium.RequestStadiumPlacementWandHandler
import com.cobblemon.mod.common.net.serverhandling.stadium.StadiumPlacementSetHandler
import com.cobblemon.mod.common.net.serverhandling.stadium.UpdateStadiumBlockHandler
import com.cobblemon.mod.common.net.serverhandling.orientation.ServerboundUpdateOrientationHandler
import com.cobblemon.mod.common.net.serverhandling.storage.party.PartyTutorialPacketHandler
import com.cobblemon.mod.common.net.serverhandling.pasture.PasturePokemonHandler
@@ -379,9 +415,85 @@ object CobblemonNetwork {
list.add(PacketRegisterInfo(BattlePersistentStatusPacket.ID, BattlePersistentStatusPacket::decode, BattlePersistentStatusHandler))
list.add(PacketRegisterInfo(BattleMadeInvalidChoicePacket.ID, BattleMadeInvalidChoicePacket::decode, BattleMadeInvalidChoiceHandler))
list.add(PacketRegisterInfo(BattleMusicPacket.ID, BattleMusicPacket::decode, BattleMusicHandler))
list.add(PacketRegisterInfo(BattleChallengeExpiredPacket.ID, BattleChallengeExpiredPacket::decode, BattleChallengeExpiredHandler))
list.add(PacketRegisterInfo(BattleReplacePokemonPacket.ID, BattleReplacePokemonPacket::decode, BattleReplacePokemonHandler))
list.add(PacketRegisterInfo(BattleTransformPokemonPacket.ID, BattleTransformPokemonPacket::decode, BattleTransformPokemonHandler))
list.add(
PacketRegisterInfo(
BattleChallengeExpiredPacket.ID,
BattleChallengeExpiredPacket::decode,
BattleChallengeExpiredHandler
)
)
list.add(
PacketRegisterInfo(
BattleReplacePokemonPacket.ID,
BattleReplacePokemonPacket::decode,
BattleReplacePokemonHandler
)
)
list.add(
PacketRegisterInfo(
BattleTransformPokemonPacket.ID,
BattleTransformPokemonPacket::decode,
BattleTransformPokemonHandler
)
)
// Camera packets
list.add(
PacketRegisterInfo(
CameraStartPacket.ID,
CameraStartPacket::decode,
CameraStartHandler
)
)
list.add(
PacketRegisterInfo(
CameraEndPacket.ID,
CameraEndPacket::decode,
CameraEndHandler
)
)
list.add(
PacketRegisterInfo(
CameraDebugPacket.ID,
CameraDebugPacket::decode,
CameraDebugHandler
)
)
list.add(
PacketRegisterInfo(
CameraTransformPacket.ID,
CameraTransformPacket::decode,
CameraTransformHandler
)
)
list.add(
PacketRegisterInfo(
CameraFocusPacket.ID,
CameraFocusPacket::decode,
CameraFocusHandler
)
)
list.add(
PacketRegisterInfo(
CameraStatePacket.ID,
CameraStatePacket::decode,
CameraStateHandler
)
)
list.add(
PacketRegisterInfo(
SplitScreenPacket.ID,
SplitScreenPacket::decode,
SplitScreenHandler
)
)
list.add(
PacketRegisterInfo(
CompositorPacket.ID,
CompositorPacket::decode,
CompositorHandler
)
)
// MultiBattleTeam Packets
list.add(PacketRegisterInfo(TeamRequestNotificationPacket.ID, TeamRequestNotificationPacket::decode, TeamRequestNotificationHandler))
@@ -420,6 +532,11 @@ object CobblemonNetwork {
list.add(PacketRegisterInfo(MarkRegistrySyncPacket.ID, MarkRegistrySyncPacket::decode, DataRegistrySyncPacketHandler()))
list.add(PacketRegisterInfo(RideSettingsSyncPacket.ID, RideSettingsSyncPacket::decode, RideSettingsSyncHandler))
// Molang UI packets
list.add(PacketRegisterInfo(MolangUiOpenPacket.ID, MolangUiOpenPacket::decode, MolangUiOpenHandler))
list.add(PacketRegisterInfo(MolangUiClosePacket.ID, MolangUiClosePacket::decode, MolangUiCloseHandler))
list.add(PacketRegisterInfo(MolangUiUpdatePacket.ID, MolangUiUpdatePacket::decode, MolangUiUpdateHandler))
// Effects
list.add(PacketRegisterInfo(SpawnSnowstormParticlePacket.ID, SpawnSnowstormParticlePacket::decode, SpawnSnowstormParticleHandler))
list.add(PacketRegisterInfo(SpawnSnowstormEntityParticlePacket.ID, SpawnSnowstormEntityParticlePacket::decode, SpawnSnowstormEntityParticleHandler))
@@ -449,8 +566,23 @@ object CobblemonNetwork {
list.add(PacketRegisterInfo(OpenPasturePacket.ID, OpenPasturePacket::decode, OpenPastureHandler))
list.add(PacketRegisterInfo(ClosePasturePacket.ID, ClosePasturePacket::decode, ClosePastureHandler))
list.add(PacketRegisterInfo(PokemonPasturedPacket.ID, PokemonPasturedPacket::decode, PokemonPasturedHandler))
list.add(PacketRegisterInfo(PokemonUnpasturedPacket.ID, PokemonUnpasturedPacket::decode, PokemonUnpasturedHandler))
list.add(PacketRegisterInfo(UpdatePastureConflictFlagPacket.ID, UpdatePastureConflictFlagPacket::decode, UpdatePastureConflictFlagHandler))
list.add(
PacketRegisterInfo(
PokemonUnpasturedPacket.ID,
PokemonUnpasturedPacket::decode,
PokemonUnpasturedHandler
)
)
list.add(
PacketRegisterInfo(
UpdatePastureConflictFlagPacket.ID,
UpdatePastureConflictFlagPacket::decode,
UpdatePastureConflictFlagHandler
)
)
// Stadium
list.add(PacketRegisterInfo(OpenStadiumConfigPacket.ID, OpenStadiumConfigPacket::decode, OpenStadiumConfigHandler))
// Orientation
list.add(PacketRegisterInfo(ClientboundUpdateOrientationPacket.ID, ClientboundUpdateOrientationPacket::decode, ClientboundUpdateOrientationHandler))
@@ -607,8 +739,30 @@ object CobblemonNetwork {
list.add(PacketRegisterInfo(ToggleCookingPotLidPacket.ID, ToggleCookingPotLidPacket::decode, ToggleCookingPotLidHandler))
// Behaviour Packets
list.add(PacketRegisterInfo(SetEntityBehaviourPacket.ID, SetEntityBehaviourPacket::decode, SetEntityBehaviourHandler))
list.add(PacketRegisterInfo(DamageOnCollisionPacket.ID, DamageOnCollisionPacket::decode, DamageOnCollisionPacketHandler))
list.add(
PacketRegisterInfo(
SetEntityBehaviourPacket.ID,
SetEntityBehaviourPacket::decode,
SetEntityBehaviourHandler
)
)
list.add(
PacketRegisterInfo(
DamageOnCollisionPacket.ID,
DamageOnCollisionPacket::decode,
DamageOnCollisionPacketHandler
)
)
// Molang UI packets
list.add(PacketRegisterInfo(MolangUiActionPacket.ID, MolangUiActionPacket::decode, MolangUiActionHandler))
list.add(PacketRegisterInfo(MolangUiClosedPacket.ID, MolangUiClosedPacket::decode, MolangUiClosedHandler))
list.add(PacketRegisterInfo(MolangUiVersionPacket.ID, MolangUiVersionPacket::decode, MolangUiVersionHandler))
// Stadium
list.add(PacketRegisterInfo(UpdateStadiumBlockPacket.ID, UpdateStadiumBlockPacket::decode, UpdateStadiumBlockHandler))
list.add(PacketRegisterInfo(RequestStadiumPlacementWandPacket.ID, RequestStadiumPlacementWandPacket::decode, RequestStadiumPlacementWandHandler))
list.add(PacketRegisterInfo(StadiumPlacementSetPacket.ID, StadiumPlacementSetPacket::decode, StadiumPlacementSetHandler))
// Debug
list.add(PacketRegisterInfo(RequestOpenRidingStatsDebugGUIPacket.ID, RequestOpenRidingStatsDebugGUIPacket::decode, RequestOpenRidingStatsDebugGUIHandler))
@@ -85,7 +85,9 @@ open class PokemonBattle(
/** Whether logging will be silenced for this battle. */
var mute = !CobblemonBuildDetails.SNAPSHOT
val struct = this.asMoLangValue()
val runtime = MoLangRuntime().also { it.environment.query = struct }
val runtime = MoLangRuntime().also {
it.environment.query = struct
}
val onEndHandlers: MutableList<(PokemonBattle) -> Unit> = mutableListOf()
@@ -26,8 +26,9 @@ import com.cobblemon.mod.common.net.messages.client.battle.BattleMakeChoicePacke
import com.cobblemon.mod.common.net.messages.client.battle.BattleMessagePacket
import net.minecraft.network.chat.Component
import net.minecraft.network.chat.MutableComponent
import java.util.UUID
import net.minecraft.server.level.ServerPlayer
import net.minecraft.world.phys.Vec3
import java.util.UUID
abstract class BattleActor(
val uuid: UUID,
@@ -54,6 +55,11 @@ abstract class BattleActor(
var itemsUsed = mutableListOf<BagItem>()
/** For when battles start, it's the number of Pokémon that are still in the process of being sent out (animation wise) */
var stillSendingOutCount = 0
/**
* Optional override for send-out positions. When present the indexes follow the
* actor's active slot order (e.g. slot `a` -> index 0).
*/
var stadiumSendOutPositions: List<Vec3>? = null
abstract val type: ActorType
@@ -0,0 +1,200 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.camera
import com.cobblemon.mod.common.Cobblemon
import net.minecraft.resources.ResourceKey
import net.minecraft.server.MinecraftServer
import net.minecraft.server.level.ServerLevel
import net.minecraft.world.level.ChunkPos
import net.minecraft.world.level.Level
import net.minecraft.world.level.chunk.LevelChunk
import net.minecraft.world.level.chunk.status.ChunkStatus
import java.util.ArrayDeque
import java.util.UUID
/**
* Handles chunk forceloading for cameras.
* Chunks are queued and processed in small batches each tick to avoid overwhelming the server.
*/
object CameraChunkLoader {
private const val MAX_CHUNKS_PER_TICK = 16
private data class CameraState(
var dimension: ResourceKey<Level>,
val chunks: MutableSet<Long>
)
private data class ChunkRequest(
val dimension: ResourceKey<Level>,
val chunkPos: ChunkPos,
val load: Boolean
)
private val cameraStates = mutableMapOf<UUID, CameraState>()
private val chunkOwners = mutableMapOf<ResourceKey<Level>, MutableMap<Long, MutableSet<UUID>>>()
private val pendingRequests: ArrayDeque<ChunkRequest> = ArrayDeque()
private val pendingAsyncLoads = mutableSetOf<Pair<ResourceKey<Level>, Long>>()
fun ensureArea(level: ServerLevel, cameraId: UUID, centerChunk: ChunkPos, radius: Int) {
val dimension = level.dimension()
val state = cameraStates[cameraId]?.takeIf { it.dimension == dimension } ?: run {
cameraStates.remove(cameraId)?.let { oldState ->
oldState.chunks.forEach { chunkLong ->
removeOwnership(oldState.dimension, chunkLong, cameraId)
}
}
CameraState(dimension, mutableSetOf()).also { cameraStates[cameraId] = it }
}
val newChunks = buildChunkSet(centerChunk, radius)
val currentChunks = state.chunks
val toRemove = currentChunks.minus(newChunks)
val toAdd = newChunks.minus(currentChunks)
if (toRemove.isNotEmpty()) {
toRemove.forEach { chunkLong ->
removeOwnership(dimension, chunkLong, cameraId)
currentChunks.remove(chunkLong)
}
}
if (toAdd.isNotEmpty()) {
toAdd.forEach { chunkLong ->
addOwnership(dimension, chunkLong, cameraId)
currentChunks.add(chunkLong)
}
}
}
fun clear(cameraId: UUID) {
val state = cameraStates.remove(cameraId) ?: return
val dimension = state.dimension
state.chunks.forEach { chunkLong ->
removeOwnership(dimension, chunkLong, cameraId)
}
}
fun tick(server: MinecraftServer) {
if (pendingRequests.isEmpty()) {
return
}
var processed = 0
while (processed < MAX_CHUNKS_PER_TICK && pendingRequests.isNotEmpty()) {
val request = pendingRequests.removeFirst()
val level = server.getLevel(request.dimension) ?: continue
level.setChunkForced(request.chunkPos.x, request.chunkPos.z, request.load)
if (request.load) {
scheduleAsyncLoad(level, request.dimension, request.chunkPos)
}
processed++
}
}
private fun addOwnership(dimension: ResourceKey<Level>, chunkLong: Long, cameraId: UUID) {
val ownersForDimension = chunkOwners.computeIfAbsent(dimension) { mutableMapOf() }
val owners = ownersForDimension.computeIfAbsent(chunkLong) { mutableSetOf() }
if (owners.add(cameraId) && owners.size == 1) {
pendingRequests.add(
ChunkRequest(
dimension,
ChunkPos(chunkLong),
true
)
)
}
}
private fun removeOwnership(dimension: ResourceKey<Level>, chunkLong: Long, cameraId: UUID) {
val ownersForDimension = chunkOwners[dimension] ?: return
val owners = ownersForDimension[chunkLong] ?: return
if (!owners.remove(cameraId)) {
return
}
if (owners.isEmpty()) {
ownersForDimension.remove(chunkLong)
if (ownersForDimension.isEmpty()) {
chunkOwners.remove(dimension)
}
pendingRequests.add(
ChunkRequest(
dimension,
ChunkPos(chunkLong),
false
)
)
}
}
private fun scheduleAsyncLoad(level: ServerLevel, dimension: ResourceKey<Level>, chunkPos: ChunkPos) {
val key = dimension to chunkPos.toLong()
if (!pendingAsyncLoads.add(key)) {
return
}
if (Cobblemon.LOGGER.isDebugEnabled) {
Cobblemon.LOGGER.debug(
"[CobblemonCamera] Scheduling async load for chunk {} in {}",
chunkPos,
dimension.location()
)
}
level.chunkSource
.getChunkFuture(chunkPos.x, chunkPos.z, ChunkStatus.FULL, true)
.thenAcceptAsync({ result ->
result.ifSuccess { chunkAccess ->
if (chunkAccess is LevelChunk && Cobblemon.LOGGER.isDebugEnabled) {
Cobblemon.LOGGER.debug(
"[CobblemonCamera] Async loaded chunk {} (light={}, entities={})",
chunkPos,
chunkAccess.isLightCorrect,
chunkAccess.blockEntities.size
)
}
}
pendingAsyncLoads.remove(key)
}, level.server)
.exceptionally { throwable ->
level.server.execute {
pendingAsyncLoads.remove(key)
Cobblemon.LOGGER.warn(
"[CobblemonCamera] Exception while async loading chunk {}: {}",
chunkPos,
throwable.message
)
}
null
}
}
private fun buildChunkSet(centerChunk: ChunkPos, radius: Int): MutableSet<Long> {
val chunks = mutableSetOf<Long>()
for (x in centerChunk.x - radius..centerChunk.x + radius) {
for (z in centerChunk.z - radius..centerChunk.z + radius) {
chunks.add(ChunkPos.asLong(x, z))
}
}
return chunks
}
}
@@ -0,0 +1,203 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.camera
import com.bedrockk.molang.runtime.MoLangRuntime
import com.bedrockk.molang.runtime.MoParams
import com.bedrockk.molang.runtime.value.MoValue
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.api.camera.integration.server.CameraExecutor
import com.cobblemon.mod.common.api.camera.integration.server.CameraInstance
import com.cobblemon.mod.common.api.camera.integration.server.CameraNetworkSync
import com.cobblemon.mod.common.api.camera.registry.TimelineRegistry
import com.cobblemon.mod.common.api.molang.MoLangFunctions.setup
import net.minecraft.core.SectionPos
import net.minecraft.server.level.ChunkTrackingView
import net.minecraft.server.level.ServerLevel
import net.minecraft.server.level.ServerPlayer
import net.minecraft.util.Mth
import net.minecraft.world.level.ChunkPos
import net.minecraft.world.phys.Vec3
import java.util.UUID
/**
* Server-side camera controller.
*
* Orchestrates camera instance, executor, network sync, and chunk/viewer management.
* Delegates responsibilities to focused components.
*/
class CameraController(
override val id: UUID = UUID.randomUUID(),
val context: Map<String, MoValue> = emptyMap(),
override val origin: Vec3 = Vec3.ZERO,
val queryFunctions: Map<String, java.util.function.Function<MoParams, Any>> = emptyMap()
) : ICameraController {
private val instance: CameraInstance
private val runtime: MoLangRuntime
private lateinit var executor: CameraExecutor
private lateinit var networkSync: CameraNetworkSync
private val viewers = mutableSetOf<UUID>()
private val viewerChunkStates = mutableMapOf<UUID, ViewerChunkState>()
private var level: ServerLevel? = null
init {
runtime = MoLangRuntime().setup()
runtime.environment.query.functions.putAll(queryFunctions)
instance = CameraInstance(id, origin, context, queryFunctions)
executor = CameraExecutor(instance, runtime, context)
networkSync = CameraNetworkSync(id, viewers)
Cobblemon.LOGGER.debug("[CameraController] Initialized $id")
}
private data class ViewerChunkState(
val viewDistance: Int,
var centerChunk: ChunkPos,
var chunkView: ChunkTrackingView
)
override fun startTimeline(timelineId: String): Boolean {
val timeline = TimelineRegistry.getTimeline(timelineId)
if (timeline == null) {
Cobblemon.LOGGER.error("[CameraController] Timeline '$timelineId' not found")
return false
}
return if (instance.startTimeline(timeline)) {
Cobblemon.LOGGER.info("[CameraController] Started '$timelineId'")
true
} else {
Cobblemon.LOGGER.error("[CameraController] Engine start failed for '$timelineId'")
false
}
}
override fun tick() {
val currentLevel = level ?: return
val stillActive = executor.tick(currentLevel, networkSync) { chunkPos ->
updateViewerChunkViews(chunkPos)
}
// If timeline ended, stop the camera and notify all viewers
if (!stillActive && viewers.isNotEmpty()) {
Cobblemon.LOGGER.info("[CameraController] Timeline ended naturally, stopping camera")
stop()
}
}
private fun updateViewerChunkViews(chunkPos: ChunkPos) {
viewerChunkStates.forEach { (viewerId, state) ->
if (state.centerChunk == chunkPos) return@forEach
state.centerChunk = chunkPos
val newView = ChunkTrackingView.of(chunkPos, state.viewDistance)
state.chunkView = newView
CameraManager.registerViewerChunkView(viewerId, newView)
}
val serverLevel = level
if (serverLevel != null && viewerChunkStates.isNotEmpty()) {
val maxDistance = viewerChunkStates.values.maxOf { it.viewDistance }
CameraChunkLoader.ensureArea(serverLevel, id, chunkPos, maxDistance)
}
}
override fun addViewer(player: ServerPlayer) {
viewers.add(player.uuid)
registerViewerChunkView(player, origin)
networkSync.sendStart(origin)
}
/**
* Adds a player as a feed viewer without sending CameraStartPacket.
*
* The player receives transform/focus updates like a normal viewer, but
* the camera will NOT become the primary camera on the client. This is
* used for secondary cameras (e.g. Camera B in split-screen) that render
* to an off-screen RenderTarget via CameraFeedHandler.
*/
fun addFeedViewer(player: ServerPlayer) {
viewers.add(player.uuid)
registerViewerChunkView(player, origin)
// No sendStart() — intentionally skipped so the client doesn't set this as primary
}
private fun registerViewerChunkView(player: ServerPlayer, centerPosition: Vec3) {
val playerLevel = player.serverLevel()
if (level == null || level != playerLevel) {
level?.let { CameraChunkLoader.clear(id) }
level = playerLevel
}
val maxViewDistance = player.server.playerList.viewDistance
val viewDistance = Mth.clamp(player.requestedViewDistance(), 2, maxViewDistance)
val chunkPos = chunkPosFrom(centerPosition)
val chunkView = ChunkTrackingView.of(chunkPos, viewDistance)
viewerChunkStates[player.uuid] = ViewerChunkState(viewDistance, chunkPos, chunkView)
CameraManager.registerViewerChunkView(player.uuid, chunkView)
val maxDistance = viewerChunkStates.values.maxOfOrNull { it.viewDistance } ?: viewDistance
CameraChunkLoader.ensureArea(playerLevel, id, chunkPos, maxDistance)
}
override fun removeViewer(player: ServerPlayer) {
// Send end packet to THIS player specifically
networkSync.sendEndToPlayer(player)
viewers.remove(player.uuid)
viewerChunkStates.remove(player.uuid)
CameraManager.clearViewerChunkView(player.uuid)
if (viewers.isEmpty()) {
CameraChunkLoader.clear(id)
level = null
} else {
val serverLevel = level
if (serverLevel != null && viewerChunkStates.isNotEmpty()) {
val state = viewerChunkStates.values.first()
val maxDistance = viewerChunkStates.values.maxOf { it.viewDistance }
CameraChunkLoader.ensureArea(serverLevel, id, state.centerChunk, maxDistance)
}
}
}
override fun stop() {
instance.stop()
networkSync.sendEnd()
viewerChunkStates.keys.toList().forEach { CameraManager.clearViewerChunkView(it) }
viewerChunkStates.clear()
CameraChunkLoader.clear(id)
level = null
viewers.clear()
}
override fun isActive() = instance.isActive()
fun setFlag(name: String, value: Boolean) = setVariable(name, if (value) 1.0 else 0.0)
override fun setVariable(name: String, value: Double) = instance.setVariable(name, value)
override fun setFocusOverride(position: Vec3?) {
instance.focusOverride = position
}
override fun triggerInstantTransition() = instance.triggerInstantTransition()
override fun addQueryFunctions(functions: Map<String, java.util.function.Function<MoParams, Any>>) {
runtime.environment.query.functions.putAll(functions)
instance.addQueryFunctions(functions)
}
private fun chunkPosFrom(position: Vec3): ChunkPos {
val chunkX = SectionPos.blockToSectionCoord(Mth.floor(position.x))
val chunkZ = SectionPos.blockToSectionCoord(Mth.floor(position.z))
return ChunkPos(chunkX, chunkZ)
}
}
@@ -0,0 +1,164 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.camera
import com.bedrockk.molang.runtime.MoParams
import com.bedrockk.molang.runtime.value.MoValue
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.api.camera.components.CameraChunkManager
import com.cobblemon.mod.common.api.camera.components.CameraLifecycleManager
import com.cobblemon.mod.common.api.camera.components.CameraViewerManager
import com.cobblemon.mod.common.api.events.CobblemonEvents
import com.cobblemon.mod.common.api.events.camera.CameraStartedEvent
import com.cobblemon.mod.common.api.events.camera.CameraStoppedEvent
import com.cobblemon.mod.common.api.events.camera.CameraViewerAddedEvent
import com.cobblemon.mod.common.api.events.camera.CameraViewerRemovedEvent
import com.cobblemon.mod.common.battles.stadium.StadiumBattleCameraCoordinator
import net.minecraft.server.level.ChunkTrackingView
import net.minecraft.server.level.ServerPlayer
import net.minecraft.world.phys.Vec3
import java.util.UUID
import java.util.function.Function
object CameraManager : ICameraManager {
private val lifecycleManager = CameraLifecycleManager()
private val viewerManager = CameraViewerManager()
private val chunkManager = CameraChunkManager()
override fun startCamera(
timelineId: String,
context: Map<String, MoValue>,
viewers: List<ServerPlayer>,
origin: Vec3,
queryFunctions: Map<String, Function<MoParams, Any>>
): UUID? {
val camera = CameraController(
context = context,
origin = origin,
queryFunctions = queryFunctions
)
val preEvent = CameraStartedEvent.Pre(camera.id, camera, timelineId, viewers)
CobblemonEvents.CAMERA_STARTED_PRE.emit(preEvent)
if (preEvent.isCanceled) {
Cobblemon.LOGGER.info("[CobblemonCamera] Camera start canceled by event listener")
return null
}
if (!camera.startTimeline(timelineId)) {
Cobblemon.LOGGER.warn("[CobblemonCamera] Failed to start timeline '$timelineId'")
return null
}
lifecycleManager.registerCamera(camera)
viewers.forEach { player ->
camera.addViewer(player)
viewerManager.assignViewerToCamera(player.uuid, camera.id)
CobblemonEvents.CAMERA_VIEWER_ADDED.emit(
CameraViewerAddedEvent(camera.id, camera, player)
)
}
CobblemonEvents.CAMERA_STARTED_POST.emit(
CameraStartedEvent.Post(camera.id, camera, timelineId, viewers)
)
Cobblemon.LOGGER.info("[CobblemonCamera] Started camera ${camera.id} with timeline '$timelineId' for ${viewers.size} viewers")
return camera.id
}
override fun addViewerToCamera(player: ServerPlayer, cameraId: UUID): Boolean {
val camera = lifecycleManager.getCamera(cameraId) ?: return false
camera.addViewer(player)
viewerManager.assignViewerToCamera(player.uuid, camera.id)
CobblemonEvents.CAMERA_VIEWER_ADDED.emit(
CameraViewerAddedEvent(camera.id, camera, player)
)
return true
}
override fun stopCamera(cameraId: UUID) {
lifecycleManager.getCamera(cameraId)?.let { camera ->
CobblemonEvents.CAMERA_STOPPED_PRE.emit(
CameraStoppedEvent.Pre(cameraId, camera, CameraStoppedEvent.StopReason.MANUAL)
)
camera.stop()
lifecycleManager.unregisterCamera(cameraId)
viewerManager.removeViewersForCamera(cameraId)
CobblemonEvents.CAMERA_STOPPED_POST.emit(
CameraStoppedEvent.Post(cameraId, camera, CameraStoppedEvent.StopReason.MANUAL)
)
Cobblemon.LOGGER.info("[CobblemonCamera] Stopped camera $cameraId")
}
}
override fun stopCameraForPlayer(player: ServerPlayer) {
viewerManager.getCameraForPlayer(player.uuid)?.let { cameraId ->
lifecycleManager.getCamera(cameraId)?.let { camera ->
camera.removeViewer(player)
viewerManager.removeViewer(player.uuid)
CobblemonEvents.CAMERA_VIEWER_REMOVED.emit(
CameraViewerRemovedEvent(cameraId, camera, player)
)
}
}
}
override fun getPlayerCamera(player: ServerPlayer): ICameraController? {
val cameraId = viewerManager.getCameraForPlayer(player.uuid) ?: return null
return lifecycleManager.getCamera(cameraId)
}
override fun getCamera(cameraId: UUID): ICameraController? {
return lifecycleManager.getCamera(cameraId)
}
override fun tick() {
lifecycleManager.tick()
StadiumBattleCameraCoordinator.tickSecondaryCameras()
}
override fun clear() {
lifecycleManager.clear()
viewerManager.clear()
chunkManager.clear()
}
internal fun registerViewerChunkView(playerId: UUID, newView: ChunkTrackingView) {
chunkManager.registerViewerChunkView(playerId, newView)
}
internal fun clearViewerChunkView(playerId: UUID) {
chunkManager.clearViewerChunkView(playerId)
}
@JvmStatic
fun getPlayerChunkView(player: ServerPlayer): ChunkTrackingView? {
return chunkManager.getPlayerChunkView(player.uuid)
}
@JvmStatic
fun consumeChunkViewUpdate(player: ServerPlayer): ChunkViewUpdate? {
return chunkManager.consumeChunkViewUpdate(player.uuid)?.let {
ChunkViewUpdate(it.previous, it.current)
}
}
data class ChunkViewUpdate(val previous: ChunkTrackingView?, val current: ChunkTrackingView?)
}
@@ -0,0 +1,20 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.camera
/**
* Represents a camera rotation in 3D space.
*
* @property yaw Horizontal rotation (left/right) in degrees
* @property pitch Vertical rotation (up/down) in degrees
*/
data class CameraRotation(
val yaw: Float,
val pitch: Float
)
@@ -0,0 +1,43 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.camera
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.api.camera.model.CameraTrack
import com.google.gson.JsonObject
object CameraTrackRegistry {
private val trackDeserializers = mutableMapOf<String, (JsonObject) -> CameraTrack>()
fun registerTrackType(trackType: String, deserializer: (JsonObject) -> CameraTrack) {
if (trackDeserializers.containsKey(trackType)) {
Cobblemon.LOGGER.warn("[CameraTrackRegistry] Track type '$trackType' is already registered, overwriting")
}
trackDeserializers[trackType] = deserializer
Cobblemon.LOGGER.debug("[CameraTrackRegistry] Registered camera track type: $trackType")
}
fun deserializeTrack(trackType: String, json: JsonObject): CameraTrack? {
val deserializer = trackDeserializers[trackType]
if (deserializer == null) {
Cobblemon.LOGGER.warn("[CameraTrackRegistry] Unknown track type: $trackType")
return null
}
return try {
deserializer(json)
} catch (e: Exception) {
Cobblemon.LOGGER.error("[CameraTrackRegistry] Failed to deserialize track type '$trackType': ${e.message}", e)
null
}
}
fun getRegisteredTrackTypes(): Set<String> = trackDeserializers.keys.toSet()
fun isTrackTypeRegistered(trackType: String): Boolean = trackDeserializers.containsKey(trackType)
}
@@ -0,0 +1,32 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.camera
import com.bedrockk.molang.runtime.MoParams
import net.minecraft.server.level.ServerPlayer
import net.minecraft.world.phys.Vec3
import java.util.UUID
import java.util.function.Function
interface ICameraController {
val id: UUID
val origin: Vec3
fun startTimeline(timelineId: String): Boolean
fun tick()
fun stop()
fun isActive(): Boolean
fun addViewer(player: ServerPlayer)
fun removeViewer(player: ServerPlayer)
fun setVariable(name: String, value: Double)
fun setFocusOverride(position: Vec3?)
/** Triggers an instant camera transition (no client-side interpolation) on the next tick. */
fun triggerInstantTransition()
/** Injects additional MoLang query functions into the camera's runtime after creation. */
fun addQueryFunctions(functions: Map<String, Function<MoParams, Any>>)
}
@@ -0,0 +1,17 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.camera
import java.util.UUID
interface ICameraCoordinator {
fun bind(cameraId: UUID)
fun release()
fun getCameraId(): UUID?
}
@@ -0,0 +1,34 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.camera
import com.bedrockk.molang.runtime.MoParams
import com.bedrockk.molang.runtime.value.MoValue
import net.minecraft.server.level.ServerPlayer
import net.minecraft.world.phys.Vec3
import java.util.UUID
import java.util.function.Function
interface ICameraManager {
fun startCamera(
timelineId: String,
context: Map<String, MoValue> = emptyMap(),
viewers: List<ServerPlayer> = emptyList(),
origin: Vec3 = Vec3.ZERO,
queryFunctions: Map<String, Function<MoParams, Any>> = emptyMap()
): UUID?
fun addViewerToCamera(player: ServerPlayer, cameraId: UUID): Boolean
fun stopCamera(cameraId: UUID)
fun stopCameraForPlayer(player: ServerPlayer)
fun getPlayerCamera(player: ServerPlayer): ICameraController?
fun getCamera(cameraId: UUID): ICameraController?
fun tick()
fun clear()
}
@@ -0,0 +1,45 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.camera.components
import net.minecraft.server.level.ChunkTrackingView
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
class CameraChunkManager {
private val playerChunkViews = ConcurrentHashMap<UUID, ChunkTrackingView>()
private val chunkViewUpdates = ConcurrentHashMap<UUID, ChunkViewUpdate>()
data class ChunkViewUpdate(val previous: ChunkTrackingView?, val current: ChunkTrackingView?)
fun registerViewerChunkView(playerId: UUID, newView: ChunkTrackingView) {
val previous = playerChunkViews.put(playerId, newView)
if (previous == null || previous != newView) {
chunkViewUpdates[playerId] = ChunkViewUpdate(previous, newView)
}
}
fun clearViewerChunkView(playerId: UUID) {
val previous = playerChunkViews.remove(playerId)
if (previous != null) {
chunkViewUpdates[playerId] = ChunkViewUpdate(previous, null)
}
}
fun getPlayerChunkView(playerId: UUID): ChunkTrackingView? = playerChunkViews[playerId]
fun consumeChunkViewUpdate(playerId: UUID): ChunkViewUpdate? = chunkViewUpdates.remove(playerId)
fun clear() {
playerChunkViews.clear()
chunkViewUpdates.clear()
}
}
@@ -0,0 +1,58 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.camera.components
import com.cobblemon.mod.common.api.camera.ICameraController
import com.cobblemon.mod.common.api.events.CobblemonEvents
import com.cobblemon.mod.common.api.events.camera.CameraStoppedEvent
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
class CameraLifecycleManager {
private val cameras = ConcurrentHashMap<UUID, ICameraController>()
fun registerCamera(camera: ICameraController) {
cameras[camera.id] = camera
}
fun unregisterCamera(cameraId: UUID): ICameraController? {
return cameras.remove(cameraId)
}
fun getCamera(cameraId: UUID): ICameraController? = cameras[cameraId]
fun getAllCameras(): Collection<ICameraController> = cameras.values
fun tick() {
cameras.values.forEach { it.tick() }
cameras.entries.removeIf { (id, camera) ->
if (!camera.isActive()) {
CobblemonEvents.CAMERA_STOPPED_POST.emit(
CameraStoppedEvent.Post(id, camera, CameraStoppedEvent.StopReason.COMPLETED)
)
true
} else {
false
}
}
}
fun clear() {
cameras.values.forEach {
it.stop()
CobblemonEvents.CAMERA_STOPPED_POST.emit(
CameraStoppedEvent.Post(it.id, it, CameraStoppedEvent.StopReason.MANUAL)
)
}
cameras.clear()
}
fun size(): Int = cameras.size
}
@@ -0,0 +1,36 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.camera.components
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
class CameraViewerManager {
private val playerCameras = ConcurrentHashMap<UUID, UUID>()
fun assignViewerToCamera(playerId: UUID, cameraId: UUID) {
playerCameras[playerId] = cameraId
}
fun removeViewer(playerId: UUID): UUID? {
return playerCameras.remove(playerId)
}
fun getCameraForPlayer(playerId: UUID): UUID? {
return playerCameras[playerId]
}
fun removeViewersForCamera(cameraId: UUID) {
playerCameras.entries.removeIf { it.value == cameraId }
}
fun clear() {
playerCameras.clear()
}
}
@@ -0,0 +1,591 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.camera.engine
import com.bedrockk.molang.runtime.MoLangRuntime
import com.bedrockk.molang.runtime.value.DoubleValue
import com.bedrockk.molang.runtime.value.MoValue
import com.bedrockk.molang.runtime.value.StringValue
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.api.camera.CameraRotation
import com.cobblemon.mod.common.api.camera.model.*
import com.cobblemon.mod.common.api.camera.result.ValidationError
import com.cobblemon.mod.common.api.camera.track.EventTrack
import com.cobblemon.mod.common.api.camera.track.FiredGuiAction
import com.cobblemon.mod.common.api.camera.track.GuiTrack
import com.cobblemon.mod.common.api.tween.EasingRegistry
import com.cobblemon.mod.common.client.camera.CameraInterpolationUtils
import com.cobblemon.mod.common.util.resolve
import com.cobblemon.mod.common.util.resolveBoolean
import net.minecraft.world.phys.Vec3
import java.util.*
import java.util.concurrent.ConcurrentHashMap
/**
* Thread-safe timeline execution engine.
*
* Uses immutable state snapshots for thread safety. MoLang evaluation is NOT
* thread-safe - callers must ensure tick() is called from the same thread.
*
* @param id Unique identifier for this engine instance
* @param timeline The timeline to execute
* @param origin The world position origin for relative coordinates
* @param runtime The MoLang runtime for expression evaluation
* @param providedContext Initial context values (parameter values) to inject into MoLang
*/
class TimelineEngine(
val id: UUID,
private val timeline: Timeline,
val origin: Vec3,
private val runtime: MoLangRuntime,
private val providedContext: Map<String, MoValue> = emptyMap()
) {
@Volatile
private var state: ExecutionState = ExecutionState.Stopped
private var previousExecutionState: ExecutionState.Playing? = null
private var instantTransitionOccurred = false
private val customVariables = ConcurrentHashMap<String, Double>()
/** GUI actions fired during the current tick, consumed by the executor. */
private val pendingGuiActions = mutableListOf<FiredGuiAction>()
private val timeOnCurrentPathValue = DoubleValue(0.0)
private val totalTimeValue = DoubleValue(0.0)
private val runnerIdValue = StringValue(id.toString())
fun start(): Boolean {
if (state !is ExecutionState.Stopped) {
Cobblemon.LOGGER.warn("[TimelineEngine] Cannot start: already active (id=$id)")
return false
}
val errors = timeline.validate()
if (errors.any { it.severity == ValidationError.Severity.ERROR }) {
Cobblemon.LOGGER.error("[TimelineEngine] Timeline '${timeline.id}' validation failed: ${errors.joinToString { it.toString() }}")
return false
}
if (!validateAndInjectParameters()) {
return false
}
val startState = timeline.getState(timeline.startStateId)
if (startState == null) {
Cobblemon.LOGGER.error("[TimelineEngine] Start state '${timeline.startStateId}' not found in timeline '${timeline.id}'")
return false
}
state = ExecutionState.Playing(
currentState = startState,
stateTime = 0f,
totalTime = 0f,
lastTickNanos = System.nanoTime(),
transitionState = TransitionState.None,
progressMemory = emptyMap(),
executedEvents = emptySet()
)
Cobblemon.LOGGER.info("[TimelineEngine] Started timeline '${timeline.id}' in state '${startState.id}'")
return true
}
/**
* Validates that all required parameters are provided and injects parameters
* (including defaults) into the MoLang runtime.
*/
private fun validateAndInjectParameters(): Boolean {
if (timeline.parameters.isEmpty()) return true
val providedVarNames = providedContext.keys
val missingParams = timeline.validateParameters(providedVarNames)
if (missingParams.isNotEmpty()) {
Cobblemon.LOGGER.error("[TimelineEngine] Timeline '${timeline.id}' missing required parameters: ${missingParams.joinToString(", ")}")
return false
}
providedContext.forEach { (name, value) ->
runtime.environment.variable.map[name] = value
}
for ((paramName, definition) in timeline.parameters) {
val default = definition.default ?: continue
when (default) {
is ParameterDefault.NumberDefault -> {
if (paramName !in providedVarNames) {
runtime.environment.variable.map[paramName] = DoubleValue(default.value)
Cobblemon.LOGGER.debug("[TimelineEngine] Using default for parameter '$paramName': ${default.value}")
}
}
is ParameterDefault.Vec3Default -> {
val varNames = definition.getVariableNames(paramName)
val xVar = varNames[0]
val yVar = varNames[1]
val zVar = varNames[2]
if (xVar !in providedVarNames) {
runtime.environment.variable.map[xVar] = DoubleValue(default.x)
}
if (yVar !in providedVarNames) {
runtime.environment.variable.map[yVar] = DoubleValue(default.y)
}
if (zVar !in providedVarNames) {
runtime.environment.variable.map[zVar] = DoubleValue(default.z)
}
if (xVar !in providedVarNames || yVar !in providedVarNames || zVar !in providedVarNames) {
Cobblemon.LOGGER.debug("[TimelineEngine] Using default for parameter '$paramName': (${default.x}, ${default.y}, ${default.z})")
}
}
}
}
return true
}
fun tick(): CameraTransform? {
val current = state
if (current !is ExecutionState.Playing) {
return null
}
previousExecutionState = current
instantTransitionOccurred = false
val nowNanos = System.nanoTime()
val deltaNanos = nowNanos - current.lastTickNanos
val deltaSeconds = (deltaNanos / 1_000_000_000.0).toFloat()
.coerceIn(0f, 0.5f)
val newStateTime = current.stateTime + deltaSeconds * current.currentState.speed
val newTotalTime = current.totalTime + deltaSeconds
val transitionResult = checkTransitions(current, newStateTime, newTotalTime)
if (transitionResult is TransitionCheckResult.TransitionNow) {
val targetState = timeline.getState(transitionResult.targetStateId)
?: run {
Cobblemon.LOGGER.error("[TimelineEngine] Transition target state '${transitionResult.targetStateId}' not found")
return null
}
state = when (transitionResult.type) {
TransitionType.INSTANT -> {
instantTransitionOccurred = true
val savedSnapshot = if (targetState.rememberProgress) {
current.progressMemory[targetState.id]
} else null
val restoredTime = savedSnapshot?.normalizedProgress?.let {
it * targetState.getEffectiveDuration()
} ?: 0f
val restoredFocus = savedSnapshot?.lastFocus
val currentSnapshot = StateSnapshot(
normalizedProgress = normalizeProgress(current.stateTime, current.currentState),
lastFocus = current.lastEvaluatedFocus
)
ExecutionState.Playing(
currentState = targetState,
stateTime = restoredTime,
totalTime = newTotalTime,
lastTickNanos = nowNanos,
transitionState = TransitionState.None,
progressMemory = current.progressMemory + (current.currentState.id to currentSnapshot),
executedEvents = if (targetState.rememberProgress) current.executedEvents else emptySet(),
lastEvaluatedFocus = restoredFocus
)
}
TransitionType.BLEND -> {
val currentTransform = evaluateState(current.currentState, current.stateTime)
ExecutionState.Playing(
currentState = current.currentState,
stateTime = newStateTime,
totalTime = newTotalTime,
lastTickNanos = nowNanos,
transitionState = TransitionState.Blending(
targetState = targetState,
startTime = newTotalTime,
duration = transitionResult.blendDuration,
startTransform = currentTransform
),
progressMemory = current.progressMemory,
executedEvents = current.executedEvents
)
}
}
} else {
val effectiveDuration = current.currentState.getEffectiveDuration()
val (finalStateTime, finalEvents) = if (current.currentState.isLooping && effectiveDuration > 0f && newStateTime >= effectiveDuration) {
newStateTime % effectiveDuration to emptySet<EventKey>()
} else {
val clampedTime = if (!current.currentState.isLooping && effectiveDuration > 0f) {
newStateTime.coerceAtMost(effectiveDuration)
} else {
newStateTime
}
clampedTime to current.executedEvents
}
state = current.copy(
stateTime = finalStateTime,
totalTime = newTotalTime,
lastTickNanos = nowNanos,
executedEvents = finalEvents
)
}
val updatedState = state
if (updatedState is ExecutionState.Playing && updatedState.transitionState is TransitionState.Blending) {
val blend = updatedState.transitionState
val blendProgress = if (blend.duration > 0f) {
((updatedState.totalTime - blend.startTime) / blend.duration).coerceIn(0f, 1f)
} else 1f
if (blendProgress >= 1f) {
val savedSnapshot = updatedState.progressMemory[blend.targetState.id]
val restoredTime = if (blend.targetState.rememberProgress && savedSnapshot != null) {
savedSnapshot.normalizedProgress * blend.targetState.getEffectiveDuration()
} else 0f
val currentSnapshot = StateSnapshot(
normalizedProgress = normalizeProgress(updatedState.stateTime, updatedState.currentState),
lastFocus = null
)
state = ExecutionState.Playing(
currentState = blend.targetState,
stateTime = restoredTime,
totalTime = updatedState.totalTime,
lastTickNanos = nowNanos,
transitionState = TransitionState.None,
progressMemory = updatedState.progressMemory + (updatedState.currentState.id to currentSnapshot),
executedEvents = if (blend.targetState.rememberProgress) updatedState.executedEvents else emptySet()
)
}
}
executeEvents()
executeGuiActions()
return calculateTransform()
}
fun stop() {
state = ExecutionState.Stopped
customVariables.clear()
// Clear MoLang runtime to prevent memory leaks
runtime.environment.variable.map.clear()
runtime.environment.query.functions.clear()
}
fun setVariable(name: String, value: Double) {
customVariables[name] = value
}
/** Triggers an instant transition on the next update (no client-side interpolation). */
fun triggerInstantTransition() {
instantTransitionOccurred = true
}
fun isActive(): Boolean = state is ExecutionState.Playing
fun getCurrentStateId(): String? = (state as? ExecutionState.Playing)?.currentState?.id
fun getCurrentRenderPlayerBody(): Boolean = (state as? ExecutionState.Playing)?.currentState?.renderPlayerBody ?: true
fun getCurrentHiddenHudElements(): Set<HudElement> = (state as? ExecutionState.Playing)?.currentState?.hiddenHudElements ?: emptySet()
fun consumeInstantTransitionFlag(): Boolean {
val result = instantTransitionOccurred
instantTransitionOccurred = false
return result
}
private fun checkTransitions(
current: ExecutionState.Playing,
newStateTime: Float,
newTotalTime: Float
): TransitionCheckResult {
if (current.transitionState !is TransitionState.None) {
return TransitionCheckResult.NoTransition
}
val context = createMoLangContext(current.currentState, newStateTime, newTotalTime)
for (transition in timeline.getTransitionsFrom(current.currentState.id)) {
try {
val shouldTransition = runtime.resolveBoolean(transition.condition, context)
if (shouldTransition) {
return TransitionCheckResult.TransitionNow(
targetStateId = transition.targetStateId,
type = transition.type,
blendDuration = transition.blendDuration
)
}
} catch (e: Exception) {
Cobblemon.LOGGER.error("[TimelineEngine] Error evaluating transition condition: ${e.message}", e)
}
}
return TransitionCheckResult.NoTransition
}
private fun executeEvents() {
val current = state as? ExecutionState.Playing ?: return
val previousState = previousExecutionState ?: return
val context = createMoLangContext(current.currentState, current.stateTime, current.totalTime)
val previousTime = previousState.stateTime
val currentTime = current.stateTime
current.currentState.tracks.filterIsInstance<EventTrack>().forEach { track ->
track.events.forEach { timedEvent ->
val eventKey = EventKey(current.currentState.id, timedEvent.eventId, timedEvent.time.toInt())
if (eventKey in current.executedEvents) return@forEach
val crossedEvent = if (previousTime <= currentTime) {
timedEvent.time in previousTime..currentTime
} else {
timedEvent.time <= currentTime
}
if (crossedEvent) {
try {
runtime.resolve(timedEvent.expression, context)
state = current.copy(
executedEvents = current.executedEvents + eventKey
)
if (timedEvent.eventId != null) {
Cobblemon.LOGGER.debug("[TimelineEngine] Executed event '${timedEvent.eventId}' at time ${timedEvent.time}s")
}
} catch (e: Exception) {
Cobblemon.LOGGER.error("[TimelineEngine] Failed to execute event: ${e.message}", e)
}
}
}
}
}
/**
* Checks GUI tracks for actions whose time has been crossed and queues them.
* Follows the same pattern as [executeEvents].
*/
private fun executeGuiActions() {
val current = state as? ExecutionState.Playing ?: return
val previousState = previousExecutionState ?: return
val previousTime = previousState.stateTime
val currentTime = current.stateTime
current.currentState.tracks.filterIsInstance<GuiTrack>().forEach { track ->
track.actions.forEach { guiAction ->
val actionKey = EventKey(current.currentState.id, "gui_${guiAction.guiId}_${guiAction.action}", guiAction.time.toInt())
if (actionKey in current.executedEvents) return@forEach
val crossedAction = if (previousTime <= currentTime) {
guiAction.time in previousTime..currentTime
} else {
guiAction.time <= currentTime
}
if (crossedAction) {
pendingGuiActions.add(FiredGuiAction(
action = guiAction.action,
guiId = guiAction.guiId,
args = guiAction.args,
argsExpressions = guiAction.argsExpressions
))
state = current.copy(
executedEvents = current.executedEvents + actionKey
)
Cobblemon.LOGGER.debug("[TimelineEngine] Fired GUI action ${guiAction.action} for '${guiAction.guiId}' at time ${guiAction.time}s")
}
}
}
}
/**
* Consumes and returns all pending GUI actions since the last tick.
* Called by the CameraExecutor after tick().
*/
fun consumeGuiActions(): List<FiredGuiAction> {
if (pendingGuiActions.isEmpty()) return emptyList()
val actions = pendingGuiActions.toList()
pendingGuiActions.clear()
return actions
}
private fun calculateTransform(): CameraTransform {
val current = state as? ExecutionState.Playing ?: return CameraTransform(origin, null, null)
return when (val transition = current.transitionState) {
is TransitionState.None -> {
evaluateState(current.currentState, current.stateTime)
}
is TransitionState.Blending -> {
val targetTransform = evaluateState(transition.targetState, 0f)
val progress = if (transition.duration > 0f) {
((current.totalTime - transition.startTime) / transition.duration).coerceIn(0f, 1f)
} else 1f
val easedProgress = EasingRegistry.easeOutQuad(progress)
blendTransforms(transition.startTransform, targetTransform, easedProgress)
}
}
}
private fun evaluateState(state: State, time: Float): CameraTransform {
val context = createMoLangContext(state, time, (this.state as? ExecutionState.Playing)?.totalTime ?: 0f)
var position: Vec3? = null
var focus: CameraFocus? = null
var rotation: CameraRotation? = null
for (track in state.tracks) {
val outputs = track.evaluate(time, context, runtime, origin)
for (output in outputs) {
when (output) {
is TrackOutput.Position -> {
if (position == null) position = output.position
}
is TrackOutput.Focus -> {
if (focus == null) focus = output.target
}
is TrackOutput.Rotation -> {
if (rotation == null) rotation = output.rotation
}
is TrackOutput.Event -> {
// Events handled separately
}
}
}
}
return CameraTransform(
position = position ?: origin,
focus = focus,
rotation = rotation
)
}
private fun blendTransforms(
from: CameraTransform,
to: CameraTransform,
progress: Float
): CameraTransform {
val blendedPosition = CameraInterpolationUtils.lerpPosition(from.position, to.position, progress)
val blendedRotation = if (from.rotation != null && to.rotation != null) {
CameraInterpolationUtils.slerpRotation(from.rotation, to.rotation, progress)
} else {
to.rotation ?: from.rotation
}
return CameraTransform(
position = blendedPosition,
focus = to.focus ?: from.focus,
rotation = blendedRotation
)
}
private fun createMoLangContext(
state: State,
stateTime: Float,
totalTime: Float
): Map<String, MoValue> {
timeOnCurrentPathValue.value = stateTime.toDouble()
totalTimeValue.value = totalTime.toDouble()
runtime.environment.variable.apply {
map["time_on_current_path"] = timeOnCurrentPathValue
map["total_time"] = totalTimeValue
map["runner_id"] = runnerIdValue
customVariables.forEach { (name, value) ->
map[name] = DoubleValue(value)
}
}
return emptyMap() // Context passed via runtime.environment
}
private fun normalizeProgress(time: Float, state: State): Float {
val duration = state.getEffectiveDuration()
return if (duration > 0f) {
(time / duration).coerceIn(0f, 1f)
} else 0f
}
}
data class CameraTransform(
val position: Vec3,
val focus: CameraFocus?,
val rotation: CameraRotation?
)
private sealed class ExecutionState {
object Stopped : ExecutionState()
data class Playing(
val currentState: State,
val stateTime: Float,
val totalTime: Float,
val lastTickNanos: Long,
val transitionState: TransitionState,
val progressMemory: Map<String, StateSnapshot>,
val executedEvents: Set<EventKey>,
val lastEvaluatedFocus: CameraFocus? = null
) : ExecutionState()
}
private sealed class TransitionState {
object None : TransitionState()
data class Blending(
val targetState: State,
val startTime: Float,
val duration: Float,
val startTransform: CameraTransform
) : TransitionState()
}
private sealed class TransitionCheckResult {
object NoTransition : TransitionCheckResult()
data class TransitionNow(
val targetStateId: String,
val type: TransitionType,
val blendDuration: Float
) : TransitionCheckResult()
}
private data class EventKey(
val stateId: String,
val eventId: String?,
val timeMillis: Int
)
private data class StateSnapshot(
val normalizedProgress: Float,
val lastFocus: CameraFocus?
)
@@ -0,0 +1,129 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.camera.integration.server
import com.bedrockk.molang.runtime.MoLangRuntime
import com.bedrockk.molang.runtime.value.MoValue
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.api.camera.model.CameraFocus
import com.cobblemon.mod.common.api.camera.track.FiredGuiAction
import com.cobblemon.mod.common.api.camera.track.GuiActionType
import net.minecraft.core.SectionPos
import net.minecraft.server.level.ServerLevel
import net.minecraft.util.Mth
import net.minecraft.world.level.ChunkPos
import net.minecraft.world.phys.Vec3
/**
* Executes camera timeline and manages focus resolution.
*
* Coordinates between CameraInstance, viewer/chunk managers, and network sync.
*/
class CameraExecutor(
private val instance: CameraInstance,
private val runtime: MoLangRuntime,
private val context: Map<String, MoValue>,
private val guiActionHandler: ((FiredGuiAction) -> Unit)? = null
) {
companion object {
private const val CHUNK_UPDATE_DISTANCE_SQ = (16 * 16).toDouble()
}
private var lastCenterChunkUpdate: Vec3? = null
fun tick(
level: ServerLevel,
networkSync: CameraNetworkSync,
onChunkUpdate: (ChunkPos) -> Unit
): Boolean {
if (!instance.isActive()) return false
val transform = instance.tick() ?: run {
if (!instance.isActive()) instance.stop()
return false
}
val shouldUpdateCenter = lastCenterChunkUpdate?.let { last ->
transform.position.distanceToSqr(last) > CHUNK_UPDATE_DISTANCE_SQ
} ?: true
if (shouldUpdateCenter) {
val chunkPos = chunkPosFrom(transform.position)
onChunkUpdate(chunkPos)
lastCenterChunkUpdate = transform.position
}
val instantTransition = instance.consumeInstantTransitionFlag()
networkSync.sendTransform(transform, shouldUpdateCenter)
when (val focus = transform.focus) {
null -> networkSync.sendFocusNone()
is CameraFocus.Bone -> networkSync.sendFocusBone(focus, context, runtime)
else -> {
val resolvedFocus = resolveFocusPosition(focus)
val finalFocusPos = instance.focusOverride ?: resolvedFocus
networkSync.sendFocusPoint(finalFocusPos)
}
}
networkSync.sendState(
instantTransition = instantTransition,
renderPlayerBody = instance.getCurrentRenderPlayerBody(),
hiddenHudElements = instance.getCurrentHiddenHudElements()
)
if (guiActionHandler != null) {
val guiActions = instance.consumeGuiActions()
for (action in guiActions) {
guiActionHandler.invoke(action)
}
}
return true
}
fun resolveFocusPosition(focus: CameraFocus): Vec3? {
return when (focus) {
is CameraFocus.Entity -> {
val entity = CameraFocus.resolveEntity(focus.selector, context, runtime)
if (entity != null) {
val position = entity.position()
instance.updateLastValidFocusPosition(position)
position
} else {
val cached = instance.lastValidFocusPosition
if (cached != null) {
Cobblemon.LOGGER.debug("[CameraExecutor] Entity focus invalid, using last valid position")
cached
} else {
Cobblemon.LOGGER.warn("[CameraExecutor] Entity focus invalid and no cached position, using origin")
instance.origin
}
}
}
is CameraFocus.Point -> {
val position = focus.position.toVec3(context, runtime, instance.origin, false)
instance.updateLastValidFocusPosition(position)
position
}
is CameraFocus.Bone -> {
// Bone focus is resolved client-side, so return entity position as fallback
CameraFocus.resolveEntity(focus.selector, context, runtime)?.position()
?: instance.lastValidFocusPosition
}
}
}
private fun chunkPosFrom(position: Vec3): ChunkPos {
val chunkX = SectionPos.blockToSectionCoord(Mth.floor(position.x))
val chunkZ = SectionPos.blockToSectionCoord(Mth.floor(position.z))
return ChunkPos(chunkX, chunkZ)
}
}
@@ -0,0 +1,95 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.camera.integration.server
import com.bedrockk.molang.runtime.MoLangRuntime
import com.bedrockk.molang.runtime.MoParams
import com.bedrockk.molang.runtime.value.MoValue
import com.cobblemon.mod.common.api.camera.engine.TimelineEngine
import com.cobblemon.mod.common.api.camera.model.State
import com.cobblemon.mod.common.api.camera.model.Timeline
import com.cobblemon.mod.common.api.molang.MoLangFunctions.setup
import net.minecraft.world.phys.Vec3
import java.util.UUID
/**
* Server-side camera instance - pure state and identity.
*
* Represents a single camera with its configuration and execution engine.
* Does NOT handle networking, chunk loading, or viewer management.
*
* @param id Unique identifier for this camera instance
* @param origin The world position origin for relative coordinates
* @param initialContext Initial context values (parameter values) to inject into MoLang.
* These are validated against the timeline's declared parameters when starting.
* @param queryFunctions Custom MoLang query functions to register
*/
class CameraInstance(
val id: UUID = UUID.randomUUID(),
val origin: Vec3,
private val initialContext: Map<String, MoValue> = emptyMap(),
queryFunctions: Map<String, java.util.function.Function<MoParams, Any>> = emptyMap()
) {
private val runtime: MoLangRuntime = MoLangRuntime().setup().apply {
environment.query.functions.putAll(queryFunctions)
}
private var engine: TimelineEngine = TimelineEngine(id, createStubTimeline(), origin, runtime, initialContext)
@Volatile
var focusOverride: Vec3? = null
var lastValidFocusPosition: Vec3? = null
private set
fun startTimeline(timeline: Timeline): Boolean {
engine = TimelineEngine(id, timeline, origin, runtime, initialContext)
return engine.start()
}
fun tick() = engine.tick()
fun stop() = engine.stop()
fun isActive() = engine.isActive()
fun getCurrentRenderPlayerBody() = engine.getCurrentRenderPlayerBody()
fun getCurrentHiddenHudElements() = engine.getCurrentHiddenHudElements()
fun setVariable(name: String, value: Double) = engine.setVariable(name, value)
fun triggerInstantTransition() = engine.triggerInstantTransition()
fun addQueryFunctions(functions: Map<String, java.util.function.Function<MoParams, Any>>) {
runtime.environment.query.functions.putAll(functions)
}
fun consumeInstantTransitionFlag() = engine.consumeInstantTransitionFlag()
fun consumeGuiActions() = engine.consumeGuiActions()
fun updateLastValidFocusPosition(position: Vec3) {
lastValidFocusPosition = position
}
private fun createStubTimeline(): Timeline {
return Timeline(
id = "stub",
startStateId = "stub",
states = mapOf(
"stub" to State(
id = "stub",
tracks = emptyList()
)
),
transitions = emptyList()
)
}
}
@@ -0,0 +1,184 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.camera.integration.server
import com.bedrockk.molang.runtime.MoLangRuntime
import com.bedrockk.molang.runtime.value.MoValue
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.CobblemonNetwork.sendPacket
import com.cobblemon.mod.common.api.camera.engine.CameraTransform
import com.cobblemon.mod.common.api.camera.model.CameraFocus
import com.cobblemon.mod.common.api.camera.model.HudElement
import com.cobblemon.mod.common.net.messages.client.camera.CameraEndPacket
import com.cobblemon.mod.common.net.messages.client.camera.CameraFocusPacket
import com.cobblemon.mod.common.net.messages.client.camera.CameraStartPacket
import com.cobblemon.mod.common.net.messages.client.camera.CameraStatePacket
import com.cobblemon.mod.common.net.messages.client.camera.CameraTransformPacket
import net.minecraft.server.level.ServerPlayer
import net.minecraft.world.phys.Vec3
import java.util.UUID
/**
* Handles camera packet transmission to clients.
*
* Manages synchronization of camera state with connected viewers.
* Uses modular packets with change tracking to minimize network traffic.
*/
class CameraNetworkSync(
private val cameraId: UUID,
private val viewers: Set<UUID>
) {
private var lastFocusPosition: Vec3? = null
private var lastRenderPlayerBody: Boolean = true
private var lastHiddenHudElements: Set<HudElement> = emptySet()
private var lastInstantTransition: Boolean = false
fun sendStart(startPosition: Vec3) {
getPlayers().forEach { player ->
player.sendPacket(CameraStartPacket(cameraId, startPosition))
}
sendFocusNone(force = true)
sendState(
instantTransition = false,
renderPlayerBody = true,
hiddenHudElements = emptySet(),
force = true
)
}
fun sendTransform(
transform: CameraTransform,
updateCenterChunk: Boolean
) {
val packet = CameraTransformPacket(
cameraId = cameraId,
position = transform.position,
rotation = transform.rotation,
updateCenterChunk = updateCenterChunk
)
getPlayers().forEach { it.sendPacket(packet) }
}
fun sendFocusNone(force: Boolean = false) {
if (!force && lastFocusPosition == null) {
return
}
val packet = CameraFocusPacket(
cameraId = cameraId,
focus = CameraFocusPacket.FocusData.None
)
getPlayers().forEach { it.sendPacket(packet) }
lastFocusPosition = null
}
fun sendFocusPoint(focusPosition: Vec3?, force: Boolean = false) {
if (!force && focusPosition == lastFocusPosition) {
return
}
val focusData = when (focusPosition) {
null -> CameraFocusPacket.FocusData.None
else -> CameraFocusPacket.FocusData.Point(focusPosition)
}
val packet = CameraFocusPacket(
cameraId = cameraId,
focus = focusData
)
getPlayers().forEach { it.sendPacket(packet) }
lastFocusPosition = focusPosition
}
fun sendFocusBone(
boneFocus: CameraFocus.Bone,
context: Map<String, MoValue>,
runtime: MoLangRuntime,
force: Boolean = false
) {
val entity = CameraFocus.resolveEntity(boneFocus.selector, context, runtime)
if (entity == null) {
Cobblemon.LOGGER.debug("[CameraNetworkSync] Bone focus entity invalid")
sendFocusNone(force)
return
}
val offset = boneFocus.offset.toVec3(context, runtime)
Cobblemon.LOGGER.debug("[CameraNetworkSync] Sending bone focus: entityId=${entity.id}, bone=${boneFocus.boneName}, offset=$offset, smooth=${boneFocus.smooth}")
val focusData = CameraFocusPacket.FocusData.Bone(
entityId = entity.id,
boneName = boneFocus.boneName,
offset = offset,
smooth = boneFocus.smooth
)
val packet = CameraFocusPacket(
cameraId = cameraId,
focus = focusData
)
getPlayers().forEach { it.sendPacket(packet) }
// Invalidate lastFocusPosition when switching to bone focus.
// This ensures that when transitioning back to a point focus,
// the packet will always be sent (not incorrectly deduplicated).
lastFocusPosition = null
}
fun sendState(
instantTransition: Boolean,
renderPlayerBody: Boolean,
hiddenHudElements: Set<HudElement>,
force: Boolean = false
) {
if (!force &&
instantTransition == lastInstantTransition &&
renderPlayerBody == lastRenderPlayerBody &&
hiddenHudElements == lastHiddenHudElements) {
return
}
val packet = CameraStatePacket(
cameraId = cameraId,
instantTransition = instantTransition,
renderPlayerBody = renderPlayerBody,
hiddenHudElements = hiddenHudElements
)
getPlayers().forEach { it.sendPacket(packet) }
lastInstantTransition = instantTransition
lastRenderPlayerBody = renderPlayerBody
lastHiddenHudElements = hiddenHudElements
}
fun sendEnd() {
val packet = CameraEndPacket(cameraId)
getPlayers().forEach { it.sendPacket(packet) }
}
/**
* Send end packet to a specific player.
* Used when removing a single viewer without stopping the entire camera.
*/
fun sendEndToPlayer(player: ServerPlayer) {
player.sendPacket(CameraEndPacket(cameraId))
}
private fun getPlayers(): List<ServerPlayer> {
val server = Cobblemon.implementation.server() ?: return emptyList()
return viewers.mapNotNull { server.playerList.getPlayer(it) }
}
}
@@ -0,0 +1,38 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.camera.integration.server
import com.cobblemon.mod.common.CobblemonNetwork.sendPacket
import com.cobblemon.mod.common.client.render.camera.compositor.CompositorLayout
import com.cobblemon.mod.common.net.messages.client.camera.CompositorPacket
import net.minecraft.server.level.ServerPlayer
import java.util.UUID
class CompositorSession(
val compositorId: UUID = UUID.randomUUID(),
private val viewers: () -> Collection<ServerPlayer>,
val fullscreen: Boolean = true,
val width: Int = 1920,
val height: Int = 1080
) {
fun sendCreate(layout: CompositorLayout) {
val packet = CompositorPacket.create(compositorId, layout, fullscreen, width, height)
viewers().forEach { it.sendPacket(packet) }
}
fun sendLayoutUpdate(layout: CompositorLayout) {
val packet = CompositorPacket.updateLayout(compositorId, layout)
viewers().forEach { it.sendPacket(packet) }
}
fun sendDestroy() {
val packet = CompositorPacket.destroy(compositorId)
viewers().forEach { it.sendPacket(packet) }
}
}
@@ -0,0 +1,252 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.camera.model
import com.bedrockk.molang.runtime.MoLangRuntime
import com.bedrockk.molang.runtime.value.MoValue
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.api.molang.ExpressionLike
import com.cobblemon.mod.common.api.molang.ObjectValue
import com.cobblemon.mod.common.util.asExpressionLike
import com.cobblemon.mod.common.util.resolve
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import net.minecraft.world.entity.Entity
import net.minecraft.world.phys.Vec3
/**
* Represents where the camera should look.
*/
sealed class CameraFocus {
abstract fun resolve(
context: Map<String, MoValue>,
runtime: MoLangRuntime,
fallback: Vec3?
): Vec3?
data class Point(val position: PositionExpression) : CameraFocus() {
override fun resolve(
context: Map<String, MoValue>,
runtime: MoLangRuntime,
fallback: Vec3?
): Vec3 {
return position.toVec3(context, runtime)
}
}
data class Entity(val selector: ExpressionLike) : CameraFocus() {
override fun resolve(
context: Map<String, MoValue>,
runtime: MoLangRuntime,
fallback: Vec3?
): Vec3? {
return resolveEntity(selector, context, runtime)?.position() ?: fallback
}
}
data class Bone(
val selector: ExpressionLike,
val boneName: String,
val offset: PositionExpression = PositionExpression(
CoordinateValue.Static(0.0),
CoordinateValue.Static(0.0),
CoordinateValue.Static(0.0)
),
val smooth: Boolean = true
) : CameraFocus() {
override fun resolve(
context: Map<String, MoValue>,
runtime: MoLangRuntime,
fallback: Vec3?
): Vec3? {
// Server-side: just resolve the entity position as fallback
// Client-side bone resolution happens in BoneFocusResolver
return resolveEntity(selector, context, runtime)?.position() ?: fallback
}
}
companion object {
/**
* Resolves a MoLang entity selector to a live Entity, or null if the selector
* doesn't resolve, the entity is removed, or the entity is dead.
*/
fun resolveEntity(
selector: ExpressionLike,
context: Map<String, MoValue>,
runtime: MoLangRuntime
): net.minecraft.world.entity.Entity? {
return try {
val result = runtime.resolve(selector, context)
if (result is ObjectValue<*>) {
val entity = result.obj as? net.minecraft.world.entity.Entity
if (entity != null && !entity.isRemoved && entity.isAlive) entity else null
} else null
} catch (e: Exception) {
Cobblemon.LOGGER.error("[CameraFocus] Failed to resolve entity from selector", e)
null
}
}
fun fromJson(obj: JsonObject): CameraFocus {
val type = obj.get("type")?.asString
?: throw IllegalArgumentException("CameraFocus missing 'type' field")
return when (type) {
"entity" -> {
val selector = obj.get("selector")?.asString
?: throw IllegalArgumentException("Entity focus missing 'selector' field")
Entity(selector.asExpressionLike())
}
"position", "point" -> {
val posElement = obj.get("position")
?: obj // If no nested position, use the object itself
Point(PositionExpression.fromJson(posElement))
}
"bone" -> {
val selector = obj.get("selector")?.asString
?: throw IllegalArgumentException("Bone focus missing 'selector' field")
val boneName = obj.get("bone")?.asString
?: obj.get("boneName")?.asString
?: throw IllegalArgumentException("Bone focus missing 'bone' field")
val offset = if (obj.has("offset")) {
PositionExpression.fromJson(obj.get("offset"))
} else {
PositionExpression(
CoordinateValue.Static(0.0),
CoordinateValue.Static(0.0),
CoordinateValue.Static(0.0)
)
}
val smooth = obj.get("smooth")?.asBoolean ?: true
Bone(selector.asExpressionLike(), boneName, offset, smooth)
}
else -> {
throw IllegalArgumentException("Unknown CameraFocus type: $type")
}
}
}
}
}
/**
* Position defined by three coordinates (x, y, z), each either static or dynamic.
*/
data class PositionExpression(
val x: CoordinateValue,
val y: CoordinateValue,
val z: CoordinateValue
) {
fun toVec3(
context: Map<String, MoValue> = emptyMap(),
runtime: MoLangRuntime,
origin: Vec3 = Vec3.ZERO,
isRelative: Boolean = false
): Vec3 {
val base = Vec3(
x.evaluate(context, runtime),
y.evaluate(context, runtime),
z.evaluate(context, runtime)
)
return if (isRelative) base.add(origin) else base
}
fun isFinite(context: Map<String, MoValue>, runtime: MoLangRuntime): Boolean {
val vec = toVec3(context, runtime)
return vec.x.isFinite() && vec.y.isFinite() && vec.z.isFinite()
}
companion object {
fun fromJson(element: JsonElement): PositionExpression {
return when {
element.isJsonObject -> {
val obj = element.asJsonObject
PositionExpression(
x = deserializeCoordinate(obj.get("x")),
y = deserializeCoordinate(obj.get("y")),
z = deserializeCoordinate(obj.get("z"))
)
}
element.isJsonArray -> {
val arr = element.asJsonArray
if (arr.size() != 3) {
throw IllegalArgumentException("Position array must have exactly 3 elements [x, y, z], got ${arr.size()}")
}
PositionExpression(
x = deserializeCoordinate(arr[0]),
y = deserializeCoordinate(arr[1]),
z = deserializeCoordinate(arr[2])
)
}
else -> {
throw IllegalArgumentException("Position must be either an object {x, y, z} or array [x, y, z]")
}
}
}
private fun deserializeCoordinate(element: JsonElement?): CoordinateValue {
if (element == null) {
return CoordinateValue.Static(0.0)
}
return when {
element.isJsonPrimitive && element.asJsonPrimitive.isNumber -> {
CoordinateValue.Static(element.asDouble)
}
element.isJsonPrimitive && element.asJsonPrimitive.isString -> {
// String is treated as expression
CoordinateValue.Dynamic(element.asString.asExpressionLike())
}
element.isJsonObject -> {
val obj = element.asJsonObject
val type = obj.get("type")?.asString
val expression = obj.get("expression")?.asString
when (type) {
"expression" -> {
if (expression == null) {
throw IllegalArgumentException("Expression coordinate missing 'expression' field")
}
CoordinateValue.Dynamic(expression.asExpressionLike())
}
else -> {
throw IllegalArgumentException("Unknown coordinate type: $type")
}
}
}
else -> {
throw IllegalArgumentException("Invalid coordinate value: $element")
}
}
}
}
}
sealed class CoordinateValue {
data class Static(val value: Double) : CoordinateValue()
data class Dynamic(val expression: ExpressionLike) : CoordinateValue()
fun evaluate(
context: Map<String, MoValue>,
runtime: MoLangRuntime
): Double {
return when (this) {
is Static -> value
is Dynamic -> {
try {
runtime.resolve(expression, context).asDouble()
} catch (e: Exception) {
// Log error but don't crash - coordinate expressions shouldn't break cameras
0.0
}
}
}
}
}
@@ -0,0 +1,34 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.camera.model
enum class HudElement(val id: String) {
CROSSHAIR("crosshair"),
HOTBAR("hotbar"),
EXPERIENCE_BAR("experience_bar"),
PLAYER_HEALTH("player_health"),
ARMOR("armor"),
FOOD("food"),
AIR("air"),
EFFECTS("effects"),
BOSS_BAR("boss_bar"),
SCOREBOARD("scoreboard"),
CHAT("chat"),
PLAYER_LIST("player_list"),
SELECTED_ITEM_NAME("selected_item_name"),
COBBLEMON_OVERLAY("cobblemon_overlay");
companion object {
val ALL: Set<HudElement> = entries.toSet()
private val BY_ID = entries.associateBy { it.id }
fun fromId(id: String): HudElement? = BY_ID[id]
}
}
@@ -0,0 +1,79 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.camera.model
/**
* Defines a parameter that can be passed to a camera timeline.
*
* Parameters allow timelines to be reusable with different values.
* For example, an arena camera can accept `arena_center` as a Vec3 parameter
* to position the camera relative to any arena location.
*
* @property type The type of the parameter (NUMBER or VEC3)
* @property description Optional description for documentation
* @property default Optional default value. If null, the parameter is required.
*/
data class ParameterDefinition(
val type: ParameterType,
val description: String? = null,
val default: ParameterDefault? = null
) {
/**
* Whether this parameter must be provided when starting the timeline.
* Parameters with defaults are optional.
*/
val isRequired: Boolean get() = default == null
/**
* Gets the variable names this parameter will inject into the MoLang runtime.
* - NUMBER type: returns just the parameter name
* - VEC3 type: returns name_x, name_y, name_z
*/
fun getVariableNames(paramName: String): List<String> {
return when (type) {
ParameterType.NUMBER -> listOf(paramName)
ParameterType.VEC3 -> listOf("${paramName}_x", "${paramName}_y", "${paramName}_z")
}
}
}
/**
* Types of parameters supported by camera timelines.
*/
enum class ParameterType {
/**
* A single numeric value (Double).
* Accessible in MoLang as `variable.param_name`
*/
NUMBER,
/**
* A 3D vector value.
* Expands to three variables in MoLang:
* - `variable.param_name_x`
* - `variable.param_name_y`
* - `variable.param_name_z`
*/
VEC3
}
/**
* Default value for a parameter.
*/
sealed class ParameterDefault {
/**
* Default value for a NUMBER parameter.
*/
data class NumberDefault(val value: Double) : ParameterDefault()
/**
* Default value for a VEC3 parameter.
*/
data class Vec3Default(val x: Double, val y: Double, val z: Double) : ParameterDefault()
}
@@ -0,0 +1,243 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.camera.model
import com.cobblemon.mod.common.api.camera.result.ValidationError
import com.cobblemon.mod.common.api.molang.ExpressionLike
/**
* Immutable timeline definition - a state machine of camera animations with transitions.
*
* Timelines can declare required and optional parameters that must be provided when
* starting the camera. Parameters are injected into the MoLang runtime as variables,
* allowing tracks to use dynamic values like `variable.arena_center_x`.
*
* @property id Unique identifier for this timeline
* @property startStateId The ID of the initial state when the timeline starts
* @property parameters Map of parameter names to their definitions. Parameters without
* defaults are required and must be provided when starting the camera.
* @property states Map of state IDs to state definitions
* @property transitions List of transitions between states
*/
data class Timeline(
val id: String,
val startStateId: String,
val parameters: Map<String, ParameterDefinition> = emptyMap(),
val states: Map<String, State>,
val transitions: List<Transition>
) {
fun getState(stateId: String): State? = states[stateId]
fun getTransitionsFrom(stateId: String): List<Transition> =
transitions.filter { it.sourceStateId == stateId }
/**
* Gets the names of all required parameters (those without defaults).
*/
fun getRequiredParameterNames(): Set<String> =
parameters.filter { it.value.isRequired }.keys
/**
* Gets all variable names that this timeline's parameters will inject.
* For NUMBER parameters, returns the parameter name.
* For VEC3 parameters, returns name_x, name_y, name_z.
*/
fun getAllParameterVariableNames(): Set<String> =
parameters.flatMap { (name, def) -> def.getVariableNames(name) }.toSet()
/**
* Validates that all required parameters are provided in the given context.
*
* @param providedVariables Set of variable names provided in the context
* @return List of missing parameter names, empty if all required params are present
*/
fun validateParameters(providedVariables: Set<String>): List<String> {
val missingParams = mutableListOf<String>()
for ((paramName, definition) in parameters) {
if (!definition.isRequired) continue
val requiredVars = definition.getVariableNames(paramName)
val allProvided = requiredVars.all { it in providedVariables }
if (!allProvided) {
missingParams.add(paramName)
}
}
return missingParams
}
fun validate(): List<ValidationError> {
val errors = mutableListOf<ValidationError>()
// Check start state exists
if (startStateId !in states) {
errors.add(ValidationError(
path = "startStateId",
message = "Start state '$startStateId' not found in states map"
))
}
// Check all transition states exist
transitions.forEachIndexed { index, transition ->
if (transition.sourceStateId !in states) {
errors.add(ValidationError(
path = "transitions[$index].sourceStateId",
message = "Source state '${transition.sourceStateId}' not found"
))
}
if (transition.targetStateId !in states) {
errors.add(ValidationError(
path = "transitions[$index].targetStateId",
message = "Target state '${transition.targetStateId}' not found"
))
}
}
states.forEach { (stateId, state) ->
if (state.tracks.isEmpty()) {
errors.add(ValidationError(
path = "states[$stateId].tracks",
message = "State has no tracks defined",
severity = ValidationError.Severity.WARNING
))
}
}
val reachableStates = findReachableStates()
states.keys.forEach { stateId ->
if (stateId !in reachableStates) {
errors.add(ValidationError(
path = "states[$stateId]",
message = "State is unreachable from start state '$startStateId'",
severity = ValidationError.Severity.WARNING
))
}
}
val cycles = detectCycles()
if (cycles.isNotEmpty()) {
errors.add(ValidationError(
path = "transitions",
message = "Detected transition cycles: ${cycles.joinToString(", ") { it.joinToString(" -> ") }}",
severity = ValidationError.Severity.WARNING
))
}
return errors
}
private fun findReachableStates(): Set<String> {
val reachable = mutableSetOf<String>()
val queue = ArrayDeque<String>()
if (startStateId in states) {
queue.add(startStateId)
reachable.add(startStateId)
}
while (queue.isNotEmpty()) {
val current = queue.removeFirst()
transitions
.filter { it.sourceStateId == current }
.forEach { transition ->
if (transition.targetStateId !in reachable && transition.targetStateId in states) {
reachable.add(transition.targetStateId)
queue.add(transition.targetStateId)
}
}
}
return reachable
}
private fun detectCycles(): List<List<String>> {
val cycles = mutableListOf<List<String>>()
val visited = mutableSetOf<String>()
val recStack = mutableSetOf<String>()
fun dfs(node: String, path: MutableList<String>): Boolean {
if (node !in states) return false
visited.add(node)
recStack.add(node)
path.add(node)
for (transition in getTransitionsFrom(node)) {
val target = transition.targetStateId
if (target !in visited) {
if (dfs(target, path)) return true
} else if (target in recStack) {
val cycleStart = path.indexOf(target)
if (cycleStart >= 0) {
cycles.add(path.subList(cycleStart, path.size) + target)
}
}
}
path.removeAt(path.size - 1)
recStack.remove(node)
return false
}
states.keys.forEach { stateId ->
if (stateId !in visited) {
dfs(stateId, mutableListOf())
}
}
return cycles
}
}
/**
* Immutable camera state definition - a collection of tracks that play together.
*/
data class State(
val id: String,
val tracks: List<CameraTrack>,
val duration: Float? = null,
val isLooping: Boolean = false,
val speed: Float = 1.0f,
val rememberProgress: Boolean = false,
val renderPlayerBody: Boolean = true,
val hiddenHudElements: Set<HudElement> = emptySet()
) {
init {
require(speed > 0f) { "State speed must be positive, got $speed" }
duration?.let { d ->
require(d >= 0f) { "State duration must be non-negative, got $d" }
}
}
fun getEffectiveDuration(): Float {
return duration ?: tracks.maxOfOrNull { it.duration } ?: 0f
}
}
/**
* Immutable transition definition - defines when and how to move from one state to another.
*/
data class Transition(
val sourceStateId: String,
val targetStateId: String,
val condition: ExpressionLike,
val type: TransitionType = TransitionType.BLEND,
val blendDuration: Float = 0.4f
) {
init {
require(blendDuration >= 0f) { "Blend duration must be non-negative, got $blendDuration" }
}
}
enum class TransitionType {
INSTANT,
BLEND
}
@@ -0,0 +1,59 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.camera.model
import com.bedrockk.molang.runtime.MoLangRuntime
import com.bedrockk.molang.runtime.value.MoValue
import com.cobblemon.mod.common.api.camera.CameraRotation
import com.cobblemon.mod.common.api.molang.ExpressionLike
import net.minecraft.world.phys.Vec3
/**
* Type-safe track evaluation result.
*/
sealed class TrackOutput {
data class Position(val position: Vec3) : TrackOutput()
data class Focus(val target: CameraFocus) : TrackOutput()
data class Rotation(val rotation: CameraRotation) : TrackOutput()
data class Event(val expression: ExpressionLike, val eventId: String?) : TrackOutput()
}
/**
* Camera track interface - produces outputs over time.
*/
interface CameraTrack {
val trackType: String
val duration: Float
fun evaluate(
time: Float,
context: Map<String, MoValue>,
runtime: MoLangRuntime,
origin: Vec3
): List<TrackOutput>
}
enum class TrackCapability {
POSITION,
FOCUS,
ROTATION,
EVENTS
}
fun CameraTrack.capabilities(): Set<TrackCapability> {
val outputs = evaluate(0f, emptyMap(), MoLangRuntime(), Vec3.ZERO)
return outputs.mapTo(mutableSetOf()) { output ->
when (output) {
is TrackOutput.Position -> TrackCapability.POSITION
is TrackOutput.Focus -> TrackCapability.FOCUS
is TrackOutput.Rotation -> TrackCapability.ROTATION
is TrackOutput.Event -> TrackCapability.EVENTS
}
}
}
@@ -0,0 +1,222 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.camera.registry
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.api.camera.CameraTrackRegistry
import com.cobblemon.mod.common.api.camera.model.*
import com.cobblemon.mod.common.util.asExpressionLike
import com.google.gson.*
import java.lang.reflect.Type
/**
* JSON deserializer for Timeline (V2 format).
*
* Deserializes directly into the immutable Timeline model without legacy conversion.
*/
object TimelineDeserializer : JsonDeserializer<Timeline> {
override fun deserialize(json: JsonElement, typeOfT: Type, ctx: JsonDeserializationContext): Timeline {
val obj = json.asJsonObject
val id = obj.get("timelineId")?.asString
?: throw JsonParseException("Timeline missing 'timelineId' field")
val startStateId = obj.get("startStateId")?.asString
?: throw JsonParseException("Timeline '$id' missing 'startStateId' field")
val statesObj = obj.get("states")?.asJsonObject
?: throw JsonParseException("Timeline '$id' missing 'states' object")
val transitionsArray = obj.get("transitions")?.asJsonArray ?: JsonArray()
// Parse parameters (optional)
val parametersObj = obj.get("parameters")?.asJsonObject
val parameters = parametersObj?.entrySet()?.associate { (paramName, paramValue) ->
try {
paramName to deserializeParameter(paramValue.asJsonObject, paramName, id)
} catch (e: Exception) {
Cobblemon.LOGGER.error("[Timeline] Error parsing parameter '$paramName' in timeline '$id': ${e.message}")
throw JsonParseException("Invalid parameter '$paramName' in timeline '$id'", e)
}
} ?: emptyMap()
val states = statesObj.entrySet().associate { (key, value) ->
try {
key to deserializeState(value.asJsonObject, id)
} catch (e: Exception) {
Cobblemon.LOGGER.error("[Timeline] Error parsing state '$key' in timeline '$id': ${e.message}")
throw JsonParseException("Invalid state '$key' in timeline '$id'", e)
}
}
val transitions = transitionsArray.mapIndexed { index, element ->
try {
deserializeTransition(element.asJsonObject, id)
} catch (e: Exception) {
Cobblemon.LOGGER.error("[Timeline] Error parsing transition $index in timeline '$id': ${e.message}")
throw JsonParseException("Invalid transition $index in timeline '$id'", e)
}
}
return Timeline(
id = id,
startStateId = startStateId,
parameters = parameters,
states = states,
transitions = transitions
)
}
private fun deserializeParameter(obj: JsonObject, paramName: String, timelineId: String): ParameterDefinition {
val typeStr = obj.get("type")?.asString
?: throw JsonParseException("Parameter '$paramName' missing 'type' field")
val type = when (typeStr.lowercase()) {
"number" -> ParameterType.NUMBER
"vec3" -> ParameterType.VEC3
else -> throw JsonParseException("Parameter '$paramName' has unknown type '$typeStr'. Valid types: number, vec3")
}
val description = obj.get("description")?.asString
val default: ParameterDefault? = when {
!obj.has("default") -> null
type == ParameterType.NUMBER -> {
val defaultValue = obj.get("default")
if (defaultValue.isJsonPrimitive && defaultValue.asJsonPrimitive.isNumber) {
ParameterDefault.NumberDefault(defaultValue.asDouble)
} else {
throw JsonParseException("Parameter '$paramName' default must be a number")
}
}
type == ParameterType.VEC3 -> {
val defaultObj = obj.get("default")
if (defaultObj.isJsonObject) {
val vec = defaultObj.asJsonObject
val x = vec.get("x")?.asDouble
?: throw JsonParseException("Parameter '$paramName' vec3 default missing 'x' field")
val y = vec.get("y")?.asDouble
?: throw JsonParseException("Parameter '$paramName' vec3 default missing 'y' field")
val z = vec.get("z")?.asDouble
?: throw JsonParseException("Parameter '$paramName' vec3 default missing 'z' field")
ParameterDefault.Vec3Default(x, y, z)
} else {
throw JsonParseException("Parameter '$paramName' vec3 default must be an object with x, y, z fields")
}
}
else -> null
}
return ParameterDefinition(
type = type,
description = description,
default = default
)
}
private fun deserializeState(obj: JsonObject, timelineId: String): State {
val stateId = obj.get("stateId")?.asString
?: throw JsonParseException("State missing 'stateId' field")
val tracksArray = obj.get("tracks")?.asJsonArray
?: throw JsonParseException("State '$stateId' missing 'tracks' array")
val tracks = tracksArray.mapIndexedNotNull { index, element ->
try {
deserializeTrack(element.asJsonObject)
} catch (e: Exception) {
Cobblemon.LOGGER.error("[Timeline] Error parsing track $index in state '$stateId': ${e.message}", e)
null
}
}
val duration = obj.get("duration")?.asFloat
val isLooping = obj.get("isLooping")?.asBoolean ?: false
val speed = obj.get("speed")?.asFloat ?: 1.0f
val rememberProgress = obj.get("rememberProgress")?.asBoolean ?: false
val renderPlayerBody = obj.get("renderPlayerBody")?.asBoolean ?: true
val hiddenHudElements = when (val hideHudElement = obj.get("hideHud")) {
null -> emptySet()
is JsonPrimitive -> {
if (hideHudElement.isBoolean) {
if (hideHudElement.asBoolean) HudElement.ALL else emptySet()
} else {
Cobblemon.LOGGER.warn("[Timeline] Invalid hideHud value in state '$stateId', expected boolean or array")
emptySet()
}
}
is JsonArray -> {
hideHudElement.mapNotNull { element ->
val id = element.asString
HudElement.fromId(id) ?: run {
Cobblemon.LOGGER.warn("[Timeline] Unknown HUD element '$id' in state '$stateId'")
null
}
}.toSet()
}
else -> {
Cobblemon.LOGGER.warn("[Timeline] Invalid hideHud value in state '$stateId', expected boolean or array")
emptySet()
}
}
return State(
id = stateId,
tracks = tracks,
duration = duration,
isLooping = isLooping,
speed = speed,
rememberProgress = rememberProgress,
renderPlayerBody = renderPlayerBody,
hiddenHudElements = hiddenHudElements
)
}
private fun deserializeTrack(obj: JsonObject): CameraTrack {
val trackType = obj.get("trackType")?.asString
?: throw JsonParseException("Track missing 'trackType' field")
return CameraTrackRegistry.deserializeTrack(trackType, obj)
?: throw JsonParseException("Unknown track type: $trackType")
}
private fun deserializeTransition(obj: JsonObject, timelineId: String): Transition {
val sourceStateId = obj.get("sourceStateId")?.asString
?: throw JsonParseException("Transition missing 'sourceStateId' field")
val targetStateId = obj.get("targetStateId")?.asString
?: throw JsonParseException("Transition missing 'targetStateId' field")
val conditionStr = obj.get("condition")?.asString
?: throw JsonParseException("Transition from '$sourceStateId' to '$targetStateId' missing 'condition' field")
val blendDuration = obj.get("blendDuration")?.asFloat ?: 0.4f
val transitionType = obj.get("transitionType")?.asString?.let { typeStr ->
when (typeStr.lowercase()) {
"blend" -> TransitionType.BLEND
"instant" -> TransitionType.INSTANT
else -> {
Cobblemon.LOGGER.warn("[Timeline] Unknown transition type '$typeStr' in timeline '$timelineId', using BLEND")
TransitionType.BLEND
}
}
} ?: TransitionType.BLEND
return Transition(
sourceStateId = sourceStateId,
targetStateId = targetStateId,
condition = conditionStr.asExpressionLike(),
type = transitionType,
blendDuration = blendDuration
)
}
}
@@ -0,0 +1,117 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.camera.registry
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.api.camera.model.Timeline
import com.cobblemon.mod.common.api.camera.result.ValidationError
import com.cobblemon.mod.common.api.data.DataRegistry
import com.cobblemon.mod.common.api.reactive.SimpleObservable
import com.cobblemon.mod.common.util.cobblemonResource
import com.google.gson.GsonBuilder
import net.minecraft.server.level.ServerPlayer
import net.minecraft.server.packs.PackType
import net.minecraft.server.packs.resources.ResourceManager
import java.io.InputStreamReader
/**
* Registry for loading Timeline (V2 format) from JSON files.
*
* Loads timelines directly into the new immutable Timeline model
* without legacy conversion.
*/
object TimelineRegistry : DataRegistry {
override val id = cobblemonResource("camera_timelines")
override val type = PackType.SERVER_DATA
override val observable = SimpleObservable<TimelineRegistry>()
private val gson = GsonBuilder()
.registerTypeAdapter(Timeline::class.java, TimelineDeserializer)
.create()
private val timelines = mutableMapOf<String, Timeline>()
override fun reload(manager: ResourceManager) {
timelines.clear()
val resources = manager.listResources("camera_timelines") { path ->
path.path.endsWith(".json")
}
for ((identifier, resource) in resources) {
try {
resource.open().use { stream ->
val reader = InputStreamReader(stream)
val timeline = gson.fromJson(reader, Timeline::class.java)
val errors = timeline.validate()
val hasErrors = errors.any { it.severity == ValidationError.Severity.ERROR }
if (hasErrors) {
Cobblemon.LOGGER.error("[TimelineRegistry] Validation failed for '$identifier':")
errors.forEach { error ->
val level = if (error.severity == ValidationError.Severity.ERROR) "ERROR" else "WARN"
Cobblemon.LOGGER.error(" [$level] ${error.path}: ${error.message}")
}
continue
}
if (errors.isNotEmpty()) {
Cobblemon.LOGGER.warn("[TimelineRegistry] Warnings for timeline '${timeline.id}':")
errors.forEach { Cobblemon.LOGGER.warn(" ${it.path}: ${it.message}") }
}
if (timelines.containsKey(timeline.id)) {
Cobblemon.LOGGER.warn("[TimelineRegistry] Duplicate timeline ID '${timeline.id}' in $identifier, overwriting")
}
timelines[timeline.id] = timeline
Cobblemon.LOGGER.debug("[TimelineRegistry] Loaded timeline '${timeline.id}' from $identifier")
}
} catch (e: Exception) {
Cobblemon.LOGGER.error("[TimelineRegistry] Error loading timeline from $identifier", e)
}
}
Cobblemon.LOGGER.info("[TimelineRegistry] Loaded ${timelines.size} timelines")
observable.emit(this)
}
override fun sync(player: ServerPlayer) {
// Timelines are server-driven, no sync needed
}
fun getTimeline(id: String): Timeline? {
val timeline = timelines[id]
if (timeline == null) {
Cobblemon.LOGGER.error("[TimelineRegistry] Timeline '$id' not found")
}
return timeline
}
fun hasTimeline(id: String): Boolean = timelines.containsKey(id)
fun registerTimeline(timeline: Timeline) {
val errors = timeline.validate()
if (errors.any { it.severity == ValidationError.Severity.ERROR }) {
Cobblemon.LOGGER.error("[TimelineRegistry] Cannot register invalid timeline '${timeline.id}':")
errors.forEach { Cobblemon.LOGGER.error(" ${it.path}: ${it.message}") }
return
}
if (timelines.containsKey(timeline.id)) {
Cobblemon.LOGGER.warn("[TimelineRegistry] Timeline '${timeline.id}' already registered, overwriting")
}
timelines[timeline.id] = timeline
Cobblemon.LOGGER.debug("[TimelineRegistry] Registered timeline '${timeline.id}' programmatically")
}
fun getAllTimelines(): Collection<Timeline> = timelines.values
}
@@ -0,0 +1,22 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.camera.result
/**
* Validation error with location context.
*/
data class ValidationError(
val path: String,
val message: String,
val severity: Severity = Severity.ERROR
) {
enum class Severity { WARNING, ERROR }
override fun toString(): String = "[$severity] $path: $message"
}
@@ -0,0 +1,243 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.camera.track
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.api.camera.CameraTrackRegistry
import com.cobblemon.mod.common.api.tween.InterpolationMethod
import com.cobblemon.mod.common.api.camera.model.CameraFocus
import com.cobblemon.mod.common.api.camera.model.PositionExpression
import com.cobblemon.mod.common.util.asExpressionLike
import com.google.gson.JsonObject
object CameraTrackDeserializers {
/** Should be called during mod initialization. */
fun registerBuiltinTracks() {
CameraTrackRegistry.registerTrackType("position", ::deserializePositionTrack)
CameraTrackRegistry.registerTrackType("focus", ::deserializeFocusTrack)
CameraTrackRegistry.registerTrackType("event", ::deserializeEventTrack)
CameraTrackRegistry.registerTrackType("reference", ::deserializeReferenceTrack)
CameraTrackRegistry.registerTrackType("gui", ::deserializeGuiTrack)
Cobblemon.LOGGER.debug("[CameraTrackDeserializers] Registered built-in track types: position, focus, event, reference, gui")
}
/**
* Deserializes a position track from JSON.
*
* Expected JSON format:
* ```json
* {
* "trackType": "position",
* "keyframes": [
* {
* "time": 0.0,
* "position": { "x": 10.0, "y": 64.0, "z": 10.0 },
* "easing": "ease_in_out_quad"
* }
* ],
* "interpolationType": "bezier",
* "easing": "linear",
* "isRelative": false
* }
* ```
*/
private fun deserializePositionTrack(json: JsonObject): PositionTrack {
val keyframesArray = json.getAsJsonArray("keyframes")
?: throw IllegalArgumentException("Position track missing 'keyframes' array")
val keyframes = keyframesArray.map { element ->
val kfObj = element.asJsonObject
val time = kfObj.get("time")?.asFloat
?: throw IllegalArgumentException("Position keyframe missing 'time' field")
val positionElement = kfObj.get("position")
?: throw IllegalArgumentException("Position keyframe missing 'position' field")
val position = PositionExpression.fromJson(positionElement)
val easing = kfObj.get("easing")?.asString
PositionKeyframe(time, position, easing)
}
val interpolationTypeStr = json.get("interpolationType")?.asString ?: "linear"
val interpolationType = when (interpolationTypeStr.lowercase()) {
"linear" -> InterpolationMethod.LINEAR
"bezier", "cubic_bezier" -> InterpolationMethod.BEZIER
"hermite", "catmull_rom" -> InterpolationMethod.BEZIER // Map legacy types to available ones
else -> {
Cobblemon.LOGGER.warn("[CameraTrackDeserializers] Unknown interpolation type '$interpolationTypeStr', using LINEAR")
InterpolationMethod.LINEAR
}
}
val defaultEasing = json.get("easing")?.asString ?: "linear"
val isRelative = json.get("isRelative")?.asBoolean ?: false
return PositionTrack(keyframes, interpolationType, defaultEasing, isRelative)
}
/**
* Deserializes a focus track from JSON.
*
* Expected JSON format:
* ```json
* {
* "trackType": "focus",
* "keyframes": [
* {
* "time": 0.0,
* "target": { "type": "position", "x": 20.0, "y": 60.0, "z": 20.0 },
* "easing": "linear"
* }
* ],
* "easing": "linear"
* }
* ```
*/
private fun deserializeFocusTrack(json: JsonObject): FocusTrack {
val keyframesArray = json.getAsJsonArray("keyframes")
?: throw IllegalArgumentException("Focus track missing 'keyframes' array")
val keyframes = keyframesArray.map { element ->
val kfObj = element.asJsonObject
val time = kfObj.get("time")?.asFloat
?: throw IllegalArgumentException("Focus keyframe missing 'time' field")
val targetObj = kfObj.getAsJsonObject("target")
?: throw IllegalArgumentException("Focus keyframe missing 'target' field")
val target = CameraFocus.fromJson(targetObj)
val easing = kfObj.get("easing")?.asString
FocusKeyframe(time, target, easing)
}
val defaultEasing = json.get("easing")?.asString ?: "linear"
return FocusTrack(keyframes, defaultEasing)
}
/**
* Deserializes an event track from JSON.
*
* Expected JSON format:
* ```json
* {
* "trackType": "event",
* "events": [
* {
* "time": 0.5,
* "expression": "q.play_sound('cobblemon:poke_ball.open')",
* "eventId": "ball_open"
* }
* ]
* }
* ```
*/
private fun deserializeEventTrack(json: JsonObject): EventTrack {
val eventsArray = json.getAsJsonArray("events")
?: throw IllegalArgumentException("Event track missing 'events' array")
val events = eventsArray.map { element ->
val eventObj = element.asJsonObject
val time = eventObj.get("time")?.asFloat
?: throw IllegalArgumentException("Event missing 'time' field")
val expressionStr = eventObj.get("expression")?.asString
?: throw IllegalArgumentException("Event missing 'expression' field")
val eventId = eventObj.get("eventId")?.asString
TimedEvent(time, expressionStr.asExpressionLike(), eventId)
}
return EventTrack(events)
}
/**
* Deserializes a reference track from JSON.
*
* Expected JSON format:
* ```json
* {
* "trackType": "reference",
* "trackId": "intro_camera_movement"
* }
* ```
*/
private fun deserializeReferenceTrack(json: JsonObject): ReferenceTrack {
val trackId = json.get("trackId")?.asString
?: throw IllegalArgumentException("Reference track missing 'trackId' field")
return ReferenceTrack(trackId)
}
/**
* Deserializes a GUI track from JSON.
*
* Expected JSON format:
* ```json
* {
* "trackType": "gui",
* "actions": [
* {
* "time": 0.0,
* "action": "OPEN",
* "guiId": "cobblemon:battle_intro",
* "args": { "title": "Stadium Battle" },
* "argsExpressions": {
* "player_party_count": "variable.player_party_count"
* }
* },
* {
* "time": 5.0,
* "action": "CLOSE",
* "guiId": "cobblemon:battle_intro"
* }
* ]
* }
* ```
*/
private fun deserializeGuiTrack(json: JsonObject): GuiTrack {
val actionsArray = json.getAsJsonArray("actions")
?: throw IllegalArgumentException("GUI track missing 'actions' array")
val actions = actionsArray.map { element ->
val actionObj = element.asJsonObject
val time = actionObj.get("time")?.asFloat
?: throw IllegalArgumentException("GUI action missing 'time' field")
val actionType = actionObj.get("action")?.asString
?: throw IllegalArgumentException("GUI action missing 'action' field")
val guiId = actionObj.get("guiId")?.asString
?: throw IllegalArgumentException("GUI action missing 'guiId' field")
val args = mutableMapOf<String, String>()
actionObj.getAsJsonObject("args")?.entrySet()?.forEach { (key, value) ->
args[key] = value.asString
}
val argsExpressions = mutableMapOf<String, String>()
actionObj.getAsJsonObject("argsExpressions")?.entrySet()?.forEach { (key, value) ->
argsExpressions[key] = value.asString
}
TimedGuiAction(
time = time,
action = GuiActionType.valueOf(actionType.uppercase()),
guiId = guiId,
args = args,
argsExpressions = argsExpressions
)
}
return GuiTrack(actions)
}
}
@@ -0,0 +1,56 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.camera.track
import com.bedrockk.molang.runtime.MoLangRuntime
import com.bedrockk.molang.runtime.value.MoValue
import com.cobblemon.mod.common.api.camera.model.CameraTrack
import com.cobblemon.mod.common.api.camera.model.TrackOutput
import com.cobblemon.mod.common.api.molang.ExpressionLike
import net.minecraft.world.phys.Vec3
/**
* Track that executes MoLang expressions at specific times.
*/
data class EventTrack(
val events: List<TimedEvent>
) : CameraTrack {
override val trackType = "event"
override val duration: Float
get() = events.maxOfOrNull { it.time } ?: 0f
override fun evaluate(
time: Float,
context: Map<String, MoValue>,
runtime: MoLangRuntime,
origin: Vec3
): List<TrackOutput> {
return emptyList()
}
init {
require(events.zipWithNext().all { (a, b) -> a.time <= b.time }) {
"EventTrack events must be sorted by time"
}
}
}
/**
* Timed event - execute expression at specific time.
*/
data class TimedEvent(
val time: Float,
val expression: ExpressionLike,
val eventId: String? = null
) {
init {
require(time >= 0f) { "Event time must be non-negative, got $time" }
}
}
@@ -0,0 +1,126 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.camera.track
import com.bedrockk.molang.runtime.MoLangRuntime
import com.bedrockk.molang.runtime.value.MoValue
import com.cobblemon.mod.common.api.tween.EasingRegistry
import com.cobblemon.mod.common.api.tween.Interpolator
import com.cobblemon.mod.common.api.tween.Keyframe
import com.cobblemon.mod.common.api.camera.model.*
import net.minecraft.util.Mth
import net.minecraft.world.phys.Vec3
/**
* Track that animates camera look-at target over time.
* Point targets interpolate smoothly; entity targets switch instantly.
*/
data class FocusTrack(
val keyframes: List<FocusKeyframe>,
val defaultEasing: String = "linear"
) : CameraTrack {
override val trackType = "focus"
override val duration: Float
get() = keyframes.maxOfOrNull { it.time } ?: 0f
override fun evaluate(
time: Float,
context: Map<String, MoValue>,
runtime: MoLangRuntime,
origin: Vec3
): List<TrackOutput> {
if (keyframes.isEmpty()) return emptyList()
val segment = findFocusSegment(time) ?: return emptyList()
val focus = when {
// Both are Point targets - interpolate
segment.start is CameraFocus.Point && segment.end is CameraFocus.Point -> {
val startPos = segment.start.position.toVec3(context, runtime)
val endPos = segment.end.position.toVec3(context, runtime)
// Interpolate positions
val easedProgress = EasingRegistry.getEasingFunction(segment.easing)(segment.progress)
val interpolatedPos = Vec3(
Mth.lerp(easedProgress.toDouble(), startPos.x, endPos.x),
Mth.lerp(easedProgress.toDouble(), startPos.y, endPos.y),
Mth.lerp(easedProgress.toDouble(), startPos.z, endPos.z)
)
CameraFocus.Point(
PositionExpression(
CoordinateValue.Static(interpolatedPos.x),
CoordinateValue.Static(interpolatedPos.y),
CoordinateValue.Static(interpolatedPos.z)
)
)
}
// Entity targets or mixed - use instant switching
else -> {
if (segment.progress < 0.5f) segment.start else segment.end
}
}
return listOf(TrackOutput.Focus(focus))
}
private fun findFocusSegment(time: Float): FocusSegment? {
if (keyframes.isEmpty()) return null
if (keyframes.size == 1) {
val kf = keyframes[0]
return FocusSegment(kf.target, kf.target, kf.time, kf.time, 1f, defaultEasing)
}
for (i in 0 until keyframes.size - 1) {
val start = keyframes[i]
val end = keyframes[i + 1]
if (time <= end.time) {
val duration = end.time - start.time
val progress = if (duration > 0f) {
((time - start.time) / duration).coerceIn(0f, 1f)
} else 1f
val easing = end.easing ?: defaultEasing
return FocusSegment(start.target, end.target, start.time, end.time, progress, easing)
}
}
val last = keyframes.last()
return FocusSegment(last.target, last.target, last.time, last.time, 1f, defaultEasing)
}
init {
require(keyframes.isNotEmpty()) { "FocusTrack must have at least one keyframe" }
require(keyframes.zipWithNext().all { (a, b) -> a.time <= b.time }) {
"FocusTrack keyframes must be sorted by time"
}
}
private data class FocusSegment(
val start: CameraFocus,
val end: CameraFocus,
val startTime: Float,
val endTime: Float,
val progress: Float,
val easing: String
)
}
data class FocusKeyframe(
val time: Float,
val target: CameraFocus,
val easing: String? = null
) {
init {
require(time >= 0f) { "Keyframe time must be non-negative, got $time" }
}
}
@@ -0,0 +1,86 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.camera.track
import com.bedrockk.molang.runtime.MoLangRuntime
import com.bedrockk.molang.runtime.value.MoValue
import com.cobblemon.mod.common.api.camera.model.CameraTrack
import com.cobblemon.mod.common.api.camera.model.TrackOutput
import net.minecraft.world.phys.Vec3
/**
* Track that fires timed GUI actions (open, close, update) during camera timelines.
*
* This track produces no camera outputs itself (no position/focus/rotation).
* The actions are executed by the CameraExecutor when their time is crossed.
*/
data class GuiTrack(
val actions: List<TimedGuiAction>
) : CameraTrack {
override val trackType = "gui"
override val duration: Float
get() = actions.maxOfOrNull { it.time } ?: 0f
override fun evaluate(
time: Float,
context: Map<String, MoValue>,
runtime: MoLangRuntime,
origin: Vec3
): List<TrackOutput> {
// GUI actions are handled by the engine/executor, not via TrackOutput
return emptyList()
}
init {
require(actions.zipWithNext().all { (a, b) -> a.time <= b.time }) {
"GuiTrack actions must be sorted by time"
}
}
}
/**
* Determines the type of GUI action to fire.
*/
enum class GuiActionType {
OPEN,
CLOSE,
UPDATE_ARGS
}
/**
* A timed GUI action within a timeline.
*
* @param time The time in seconds when this action should fire
* @param action The type of action
* @param guiId The scripted GUI resource location (e.g., "cobblemon:battle_intro")
* @param args Static argument values
* @param argsExpressions MoLang expressions to evaluate at fire time, merged with static args
*/
data class TimedGuiAction(
val time: Float,
val action: GuiActionType,
val guiId: String,
val args: Map<String, String> = emptyMap(),
val argsExpressions: Map<String, String> = emptyMap()
) {
init {
require(time >= 0f) { "GUI action time must be non-negative, got $time" }
}
}
/**
* Represents a GUI action that has fired (time crossed).
*/
data class FiredGuiAction(
val action: GuiActionType,
val guiId: String,
val args: Map<String, String>,
val argsExpressions: Map<String, String>
)
@@ -0,0 +1,79 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.camera.track
import com.bedrockk.molang.runtime.MoLangRuntime
import com.bedrockk.molang.runtime.value.MoValue
import com.cobblemon.mod.common.api.tween.*
import com.cobblemon.mod.common.api.camera.model.*
import net.minecraft.world.phys.Vec3
/**
* Track that animates camera position over time using keyframes.
*/
data class PositionTrack(
val keyframes: List<PositionKeyframe>,
val interpolation: InterpolationMethod = InterpolationMethod.LINEAR,
val defaultEasing: String = "linear",
val isRelative: Boolean = false
) : CameraTrack {
override val trackType = "position"
override val duration: Float
get() = keyframes.maxOfOrNull { it.time } ?: 0f
override fun evaluate(
time: Float,
context: Map<String, MoValue>,
runtime: MoLangRuntime,
origin: Vec3
): List<TrackOutput> {
if (keyframes.isEmpty()) return emptyList()
val interpolatableKeyframes = keyframes.map { kf ->
val vec = kf.position.toVec3(context, runtime, origin, isRelative)
Keyframe(kf.time, vec.asInterpolatable())
}
val interpolated = Interpolator.interpolate(
keyframes = interpolatableKeyframes,
time = time,
method = interpolation,
defaultEasing = defaultEasing,
getEasing = { value ->
// Find original keyframe to get easing
val index = interpolatableKeyframes.indexOfFirst { it.value == value }
keyframes.getOrNull(index)?.easing
}
)
return if (interpolated != null) {
listOf(TrackOutput.Position(interpolated.vec))
} else {
emptyList()
}
}
init {
require(keyframes.isNotEmpty()) { "PositionTrack must have at least one keyframe" }
require(keyframes.zipWithNext().all { (a, b) -> a.time <= b.time }) {
"PositionTrack keyframes must be sorted by time"
}
}
}
data class PositionKeyframe(
val time: Float,
val position: PositionExpression,
val easing: String? = null
) {
init {
require(time >= 0f) { "Keyframe time must be non-negative, got $time" }
}
}
@@ -0,0 +1,50 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.camera.track
import com.bedrockk.molang.runtime.MoLangRuntime
import com.bedrockk.molang.runtime.value.MoValue
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.api.camera.model.CameraTrack
import com.cobblemon.mod.common.api.camera.model.TrackOutput
import com.cobblemon.mod.common.client.camera.tracks.CameraTrackDefinitionRegistry
import net.minecraft.world.phys.Vec3
/**
* Track that references another track definition by ID.
*/
data class ReferenceTrack(
val trackId: String
) : CameraTrack {
override val trackType = "reference"
override val duration: Float
get() {
val referencedTrack = CameraTrackDefinitionRegistry.getTrack(trackId)
return referencedTrack?.duration ?: 0f
}
override fun evaluate(
time: Float,
context: Map<String, MoValue>,
runtime: MoLangRuntime,
origin: Vec3
): List<TrackOutput> {
val referencedTrack = CameraTrackDefinitionRegistry.getTrack(trackId)
if (referencedTrack == null) {
// Log warning but don't crash
Cobblemon.LOGGER.warn("[ReferenceTrack] Track '$trackId' not found in registry")
return emptyList()
}
// Delegate to referenced track
return referencedTrack.evaluate(time, context, runtime, origin)
}
}
@@ -22,6 +22,7 @@ import com.cobblemon.mod.common.api.events.berry.BerryMutationOfferEvent
import com.cobblemon.mod.common.api.events.berry.BerryMutationResultEvent
import com.cobblemon.mod.common.api.events.berry.BerryYieldCalculationEvent
import com.cobblemon.mod.common.api.events.cooking.PokeSnackSpawnPokemonEvent
import com.cobblemon.mod.common.api.events.camera.*
import com.cobblemon.mod.common.api.events.drops.LootDroppedEvent
import com.cobblemon.mod.common.api.events.entity.*
import com.cobblemon.mod.common.api.events.farming.ApricornHarvestEvent
@@ -365,4 +366,33 @@ object CobblemonEvents {
// -------------------------------------------------------------------------------------
@JvmField
val SELECT_DRIVER = EventObservable<SelectDriverEvent>()
// -------------------------------------------------------------------------------------
//
// Camera System
//
// -------------------------------------------------------------------------------------
@JvmField
val CAMERA_STARTED_PRE = CancelableObservable<CameraStartedEvent.Pre>()
@JvmField
val CAMERA_STARTED_POST = EventObservable<CameraStartedEvent.Post>()
@JvmField
val CAMERA_STOPPED_PRE = EventObservable<CameraStoppedEvent.Pre>()
@JvmField
val CAMERA_STOPPED_POST = EventObservable<CameraStoppedEvent.Post>()
@JvmField
val CAMERA_STATE_TRANSITION = EventObservable<CameraStateTransitionEvent>()
@JvmField
val CAMERA_VIEWER_ADDED = EventObservable<CameraViewerAddedEvent>()
@JvmField
val CAMERA_VIEWER_REMOVED = EventObservable<CameraViewerRemovedEvent>()
@JvmField
val CAMERA_TICK = EventObservable<CameraTickEvent>()
}
@@ -0,0 +1,100 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.events.camera
import com.cobblemon.mod.common.api.camera.ICameraController
import com.cobblemon.mod.common.api.events.Cancelable
import net.minecraft.server.level.ServerPlayer
import net.minecraft.world.phys.Vec3
import java.util.UUID
/** Base interface for all camera events. */
interface CameraEvent {
val cameraId: UUID
val controller: ICameraController
}
/** Events fired when a camera starts. Pre is cancelable. */
interface CameraStartedEvent : CameraEvent {
val timelineId: String
val viewers: List<ServerPlayer>
/** Pre event - fired before camera starts. */
data class Pre(
override val cameraId: UUID,
override val controller: ICameraController,
override val timelineId: String,
override val viewers: List<ServerPlayer>
) : CameraStartedEvent, Cancelable()
/** Post event - fired after camera starts. */
data class Post(
override val cameraId: UUID,
override val controller: ICameraController,
override val timelineId: String,
override val viewers: List<ServerPlayer>
) : CameraStartedEvent
}
/** Events fired when a camera stops. */
interface CameraStoppedEvent : CameraEvent {
val reason: StopReason
enum class StopReason {
MANUAL,
COMPLETED,
VIEWER_LEFT,
ERROR
}
/** Pre event - fired before camera stops. */
data class Pre(
override val cameraId: UUID,
override val controller: ICameraController,
override val reason: StopReason
) : CameraStoppedEvent
/** Post event - fired after camera stops. */
data class Post(
override val cameraId: UUID,
override val controller: ICameraController,
override val reason: StopReason
) : CameraStoppedEvent
}
/** Fired when a camera transitions between states. */
data class CameraStateTransitionEvent(
override val cameraId: UUID,
override val controller: ICameraController,
val fromStateId: String,
val toStateId: String,
val transitionType: String
) : CameraEvent
/** Fired when a viewer is added to a camera. */
data class CameraViewerAddedEvent(
override val cameraId: UUID,
override val controller: ICameraController,
val viewer: ServerPlayer
) : CameraEvent
/** Fired when a viewer is removed from a camera. */
data class CameraViewerRemovedEvent(
override val cameraId: UUID,
override val controller: ICameraController,
val viewer: ServerPlayer
) : CameraEvent
/** Fired every tick while a camera is active. Fires 20 times per second. */
data class CameraTickEvent(
override val cameraId: UUID,
override val controller: ICameraController,
val currentPosition: Vec3,
val currentFocus: Vec3?
) : CameraEvent
@@ -0,0 +1,128 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.molang
import com.bedrockk.molang.runtime.MoParams
import com.bedrockk.molang.runtime.value.DoubleValue
import com.bedrockk.molang.runtime.value.StringValue
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.api.camera.CameraManager
import net.minecraft.server.level.ServerPlayer
private typealias MoLangFunction = java.util.function.Function<MoParams, Any>
/**
* Molang functions for controlling cameras.
* These functions can be called from Molang scripts to start/stop camera timelines.
*
* Example usage:
* ```
* query.camera.start('arena_intro'); // Start camera timeline
* query.camera.stop(); // Stop current camera for this player
* query.camera.is_active(); // Check if player has active camera
* ```
*
* @author Terrainwax
* @since October 15th, 2025
*/
object CameraFunctions {
/**
* Adds camera functions to general Molang functions.
* Should be called during mod initialization.
*/
fun addCameraFunctions(): Map<String, MoLangFunction> {
return mapOf(
// Start a camera timeline for the executing player
"camera_start" to MoLangFunction { params ->
val timelineId = params.getString(0)
val player = getPlayerFromParams(params)
if (player != null) {
val cameraId = CameraManager.startCamera(
timelineId = timelineId,
context = emptyMap(),
viewers = listOf(player)
)
if (cameraId != null) {
DoubleValue(1.0) // Success
} else {
Cobblemon.LOGGER.warn("[CobblemonCamera] Failed to start timeline '$timelineId' for player ${player.name.string}")
DoubleValue(0.0) // Failure
}
} else {
Cobblemon.LOGGER.warn("[CobblemonCamera] camera_start called without valid player context")
DoubleValue(0.0)
}
},
// Stop the current camera for the executing player
"camera_stop" to MoLangFunction { params ->
val player = getPlayerFromParams(params)
if (player != null) {
CameraManager.stopCameraForPlayer(player)
DoubleValue(1.0)
} else {
DoubleValue(0.0)
}
},
// Check if the executing player has an active camera
"camera_is_active" to MoLangFunction { params ->
val player = getPlayerFromParams(params)
if (player != null) {
val hasCamera = CameraManager.getPlayerCamera(player) != null
DoubleValue(if (hasCamera) 1.0 else 0.0)
} else {
DoubleValue(0.0)
}
},
// Get the ID of the player's active camera (as string)
"camera_get_id" to MoLangFunction { params ->
val player = getPlayerFromParams(params)
if (player != null) {
val camera = CameraManager.getPlayerCamera(player)
StringValue(camera?.id?.toString() ?: "")
} else {
StringValue("")
}
}
)
}
/**
* Attempts to extract a ServerPlayer from MoParams.
* Looks for common patterns like "player", "entity", or direct ObjectValue.
*/
private fun getPlayerFromParams(params: MoParams): ServerPlayer? {
try {
// Try to get player from environment query struct
val query = params.environment.query
val playerValue = query.functions["player"]?.apply(params)
if (playerValue is ObjectValue<*> && playerValue.obj is ServerPlayer) {
return playerValue.obj as ServerPlayer
}
// Try to get from entity context
val entityValue = query.functions["entity"]?.apply(params)
if (entityValue is ObjectValue<*> && entityValue.obj is ServerPlayer) {
return entityValue.obj as ServerPlayer
}
} catch (e: Exception) {
// Silent fail, return null
}
return null
}
}
@@ -76,6 +76,8 @@ import com.cobblemon.mod.common.battles.BattleFormat
import com.cobblemon.mod.common.battles.BattleRegistry
import com.cobblemon.mod.common.battles.actor.PlayerBattleActor
import com.cobblemon.mod.common.battles.actor.PokemonBattleActor
import com.cobblemon.mod.common.battles.pokemon.BattlePokemon
import com.cobblemon.mod.common.battles.stadium.StadiumBattleCameraCoordinator
import com.cobblemon.mod.common.client.render.models.blockbench.wavefunction.WaveFunctions
import com.cobblemon.mod.common.entity.MoLangScriptingEntity
import com.cobblemon.mod.common.entity.PosableEntity
@@ -165,6 +167,23 @@ import net.minecraft.world.phys.Vec3
* @since October 2nd, 2023
*/
object MoLangFunctions {
// Cache for entity positions to prevent camera jumping to void when entities die
private val entityPositionCache = mutableMapOf<UUID, net.minecraft.world.phys.Vec3>()
/**
* Clears cached position for a specific entity (called when battle ends)
*/
fun clearEntityPositionCache(entityId: UUID) {
entityPositionCache.remove(entityId)
}
/**
* Clears all cached positions
*/
fun clearAllEntityPositionCaches() {
entityPositionCache.clear()
}
val generalFunctions = hashMapOf<String, java.util.function.Function<MoParams, Any>>(
"print" to java.util.function.Function { params ->
val message = params.get<MoValue>(0).asString()
@@ -241,6 +260,11 @@ object MoLangFunctions {
"to_number" to java.util.function.Function { params -> DoubleValue(params.get<MoValue>(0).asString().toDoubleOrNull() ?: 0.0) },
"to_int" to java.util.function.Function { params -> DoubleValue(params.get<MoValue>(0).asString().toIntOrNull() ?: 0) },
"to_string" to java.util.function.Function { params -> StringValue(params.get<MoValue>(0).asString()) },
"format" to java.util.function.Function { params ->
val value = params.get<MoValue>(0).asDouble()
val decimals = params.get<MoValue>(1).asDouble().toInt().coerceIn(0, 10)
StringValue(String.format("%.${decimals}f", value))
},
"do_effect_walks" to java.util.function.Function { _ ->
DoubleValue(Cobblemon.config.walkingInBattleAnimations)
},
@@ -652,6 +676,32 @@ object MoLangFunctions {
inventory.setItem(freeSlot, stack)
return@put DoubleValue.ONE
}
map.put("count_item") { params ->
val itemId = ResourceLocation.tryParse(params.getString(0)) ?: return@put DoubleValue.ZERO
var count = 0
for (i in 0 until player.inventory.containerSize) {
val stack = player.inventory.getItem(i)
if (!stack.isEmpty && BuiltInRegistries.ITEM.getKey(stack.item) == itemId) {
count += stack.count
}
}
DoubleValue(count.toDouble())
}
map.put("remove_item") { params ->
val itemId = ResourceLocation.tryParse(params.getString(0)) ?: return@put DoubleValue.ZERO
val amount = params.get<MoValue>(1).asDouble().toInt()
var remaining = amount
for (i in 0 until player.inventory.containerSize) {
if (remaining <= 0) break
val stack = player.inventory.getItem(i)
if (!stack.isEmpty && BuiltInRegistries.ITEM.getKey(stack.item) == itemId) {
val toRemove = minOf(remaining, stack.count)
stack.shrink(toRemove)
remaining -= toRemove
}
}
DoubleValue((amount - remaining).toDouble())
}
map.put("face") { params -> ObjectValue(PlayerDialogueFaceProvider(player.uuid, params.getBooleanOrNull(0) != false)) }
map.put("swing_hand") { _ -> player.swing(player.usedItemHand) }
map.put("food_level") { _ -> DoubleValue(player.foodData.foodLevel) }
@@ -1584,6 +1634,11 @@ object MoLangFunctions {
map
}
)
init {
// Add camera functions to general functions
generalFunctions.putAll(CameraFunctions.addCameraFunctions())
}
val battleFunctions = mutableListOf<(PokemonBattle) -> HashMap<String, java.util.function.Function<MoParams, Any>>>(
{ battle ->
@@ -1601,10 +1656,145 @@ object MoLangFunctions {
}
map.put("stop") { _ -> battle.stop() }
map.put("actors") { battle.actors.toList().asArrayValue { it.struct } }
map.put("battle_pokemon_id") { params ->
val role = params.getString(0)
val pokemon = getBattlePokemonByRole(battle, role)
val entity = pokemon?.entity
entity?.asMostSpecificMoLangValue() ?: DoubleValue.ZERO
}
map.put("entity_position_x") { params ->
val entity = getEntityFromParam(params, 0)
// Validate entity and use cache if dead/removed
if (entity != null && !entity.isRemoved && entity.isAlive) {
val pos = entity.position()
entityPositionCache[entity.uuid] = pos
DoubleValue(pos.x)
} else {
// Entity is invalid - use cached position or fallback to 0.0
val cached = entity?.uuid?.let { entityPositionCache[it] }
DoubleValue(cached?.x ?: 0.0)
}
}
map.put("entity_position_y") { params ->
val entity = getEntityFromParam(params, 0)
// Validate entity and use cache if dead/removed
if (entity != null && !entity.isRemoved && entity.isAlive) {
val pos = entity.position()
entityPositionCache[entity.uuid] = pos
DoubleValue(pos.y)
} else {
// Entity is invalid - use cached position or fallback to 0.0
val cached = entity?.uuid?.let { entityPositionCache[it] }
DoubleValue(cached?.y ?: 0.0)
}
}
map.put("entity_position_z") { params ->
val entity = getEntityFromParam(params, 0)
// Validate entity and use cache if dead/removed
if (entity != null && !entity.isRemoved && entity.isAlive) {
val pos = entity.position()
entityPositionCache[entity.uuid] = pos
DoubleValue(pos.z)
} else {
// Entity is invalid - use cached position or fallback to 0.0
val cached = entity?.uuid?.let { entityPositionCache[it] }
DoubleValue(cached?.z ?: 0.0)
}
}
map.put("is_in_move_selection") {
val isSelecting = battle.actors.any { it.mustChoose && it.request != null }
DoubleValue(if (isSelecting) 1.0 else 0.0)
}
map.put("battle_turn") { DoubleValue(battle.turn.toDouble()) }
map.put("battle_started") { DoubleValue(if (battle.started) 1.0 else 0.0) }
map.put("battle_ended") { DoubleValue(if (battle.ended) 1.0 else 0.0) }
// Send-out focus functions for camera tracking
map.put("send_out_pokemon_id") { _ ->
val sendOutEntityIds = StadiumBattleCameraCoordinator.getCurrentSendOutEntityIds(battle.battleId)
val pokemon = findBattlePokemonByEntityIds(battle, sendOutEntityIds)
val entity = pokemon?.entity
entity?.asMostSpecificMoLangValue() ?: DoubleValue.ZERO
}
// Send-out target position (server-side accessible)
map.put("send_out_position_x") { _ ->
val pos = StadiumBattleCameraCoordinator.getSendOutPosition(battle.battleId)
println(pos)
DoubleValue(pos?.x ?: 0.0)
}
map.put("send_out_position_y") { _ ->
val pos = StadiumBattleCameraCoordinator.getSendOutPosition(battle.battleId)
println(pos)
DoubleValue(pos?.y ?: 0.0)
}
map.put("send_out_position_z") { _ ->
val pos = StadiumBattleCameraCoordinator.getSendOutPosition(battle.battleId)
println(pos)
DoubleValue(pos?.z ?: 0.0)
}
map
}
)
private fun getBattlePokemonByRole(battle: PokemonBattle, role: String): BattlePokemon? {
return when (role.lowercase()) {
"player", "p1" -> {
battle.side1.actors.firstOrNull()?.activePokemon?.firstOrNull()?.battlePokemon
?: battle.side2.actors.firstOrNull()?.activePokemon?.firstOrNull()?.battlePokemon
}
"opponent", "p2" -> {
val playerSide = if (battle.side1.actors.any { it.type == ActorType.PLAYER }) {
battle.side1
} else {
battle.side2
}
val opponentSide = if (playerSide == battle.side1) battle.side2 else battle.side1
opponentSide.actors.firstOrNull()?.activePokemon?.firstOrNull()?.battlePokemon
}
"user" -> {
// Get the current user entity from the StadiumBattleCameraCoordinator
val userEntityIds = StadiumBattleCameraCoordinator.getCurrentUserEntityIds(battle.battleId)
findBattlePokemonByEntityIds(battle, userEntityIds)
}
"target" -> {
// Get the current target entity from the StadiumBattleCameraCoordinator
val targetEntityIds = StadiumBattleCameraCoordinator.getCurrentTargetEntityIds(battle.battleId)
findBattlePokemonByEntityIds(battle, targetEntityIds)
}
else -> {
battle.actors.firstOrNull { it.showdownId.equals(role, ignoreCase = true) }?.activePokemon?.firstOrNull()?.battlePokemon
}
}
}
private fun findBattlePokemonByEntityIds(battle: PokemonBattle, entityIds: List<UUID>): BattlePokemon? {
if (entityIds.isEmpty()) return null
// Find the first BattlePokemon whose entity matches one of the stored UUIDs
for (actor in battle.actors) {
for (activePokemon in actor.activePokemon) {
val battlePokemon = activePokemon.battlePokemon ?: continue
val entity = battlePokemon.entity ?: continue
if (entity.uuid in entityIds) {
return battlePokemon
}
}
}
return null
}
private fun getEntityFromParam(params: MoParams, index: Int): Entity? {
return try {
val value = params.get<MoValue>(index)
if (value is ObjectValue<*>) {
val obj = value.obj
if (obj is Entity) obj else null
} else null
} catch (e: Exception) {
Cobblemon.LOGGER.error("[BattleCamera] Error extracting entity from param: ${e.message}")
null
}
}
val battleActorFunctions = mutableListOf<(BattleActor) -> HashMap<String, java.util.function.Function<MoParams, Any>>>(
{ battleActor ->
val map = hashMapOf<String, java.util.function.Function<MoParams, Any>>()
@@ -0,0 +1,97 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.molangui
import com.bedrockk.molang.runtime.MoLangRuntime
import com.bedrockk.molang.runtime.MoParams
import com.bedrockk.molang.runtime.value.DoubleValue
import com.cobblemon.mod.common.Cobblemon
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.level.ServerPlayer
import java.util.function.Function
/**
* MoLang query functions for opening and closing scripted GUIs.
*
* Register these into a MoLang runtime to enable GUI control from timelines/scripts.
*
* Available functions:
* - `q.gui_open(guiId)` - Opens a scripted GUI for the associated player
* - `q.gui_close(guiId)` - Closes a scripted GUI by its guiId
* - `q.gui_update_args(guiId, key1, value1, ...)` - Updates args on the first matching session for the player
*/
object GuiFunctions {
/**
* Creates MoLang query functions for GUI control targeting a specific player.
*
* @param player The server player to open/close GUIs for
* @return Map of function names to their implementations
*/
fun createQueryFunctions(player: ServerPlayer): Map<String, Function<MoParams, Any>> {
return mapOf(
"gui_open" to Function { params ->
val guiIdStr = params.getString(0)
val guiId = ResourceLocation.tryParse(guiIdStr)
if (guiId != null) {
MolangUiSessionManager.openGui(player, guiId)
DoubleValue(1.0)
} else {
Cobblemon.LOGGER.warn("[GuiFunctions] Invalid GUI ID in gui_open: $guiIdStr")
DoubleValue(0.0)
}
},
"gui_close" to Function { params ->
val guiIdStr = params.getString(0)
val guiId = ResourceLocation.tryParse(guiIdStr)
if (guiId != null) {
// Close all sessions for this player with this guiId
// (there could be multiple, but typically just one)
DoubleValue(1.0)
} else {
Cobblemon.LOGGER.warn("[GuiFunctions] Invalid GUI ID in gui_close: $guiIdStr")
DoubleValue(0.0)
}
},
"gui_update_args" to Function { params ->
val guiIdStr = params.getString(0)
val guiId = ResourceLocation.tryParse(guiIdStr)
if (guiId != null) {
val args = mutableMapOf<String, String>()
var i = 1
while (i + 1 < params.params.size) {
val key = params.getString(i)
val value = params.getString(i + 1)
args[key] = value
i += 2
}
val session = MolangUiSessionManager.getSessionForPlayer(player, guiId)
if (session != null) {
session.updateArgs(args)
DoubleValue(1.0)
} else {
Cobblemon.LOGGER.warn("[GuiFunctions] No active session found for GUI '$guiIdStr' and player ${player.name.string}")
DoubleValue(0.0)
}
} else {
Cobblemon.LOGGER.warn("[GuiFunctions] Invalid GUI ID in gui_update_args: $guiIdStr")
DoubleValue(0.0)
}
}
)
}
/**
* Registers GUI functions into a MoLang runtime for a specific player.
*/
fun registerInto(runtime: MoLangRuntime, player: ServerPlayer) {
val functions = createQueryFunctions(player)
runtime.environment.query.functions.putAll(functions)
}
}
@@ -0,0 +1,30 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.molangui
/**
* Determines how a scripted GUI is displayed.
*
* OVERLAY: Renders on top of the game without capturing input (like the party overlay).
* SCREEN: Opens as a full Minecraft Screen with input capture (like a menu).
*/
enum class GuiMode {
OVERLAY,
SCREEN;
companion object {
fun fromString(value: String): GuiMode {
return when (value.lowercase()) {
"overlay" -> OVERLAY
"screen" -> SCREEN
else -> OVERLAY
}
}
}
}
@@ -0,0 +1,177 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.molangui
import com.bedrockk.molang.runtime.MoLangRuntime
import com.bedrockk.molang.runtime.value.DoubleValue
import com.bedrockk.molang.runtime.value.StringValue
import com.cobblemon.mod.common.api.molang.MoLangFunctions.addFunctions
import com.cobblemon.mod.common.api.molang.MoLangFunctions.asMoLangValue
import com.cobblemon.mod.common.api.molang.MoLangFunctions.setup
import com.cobblemon.mod.common.api.molang.ObjectValue
import com.cobblemon.mod.common.api.molangui.container.SlotQueryFunctions
import com.cobblemon.mod.common.api.scripting.CobblemonScripts
import com.cobblemon.mod.common.util.withQueryValue
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.level.ServerPlayer
import org.slf4j.LoggerFactory
import java.util.concurrent.ConcurrentHashMap
data class ActionContext(
val session: MolangUiSession,
val player: ServerPlayer,
val data: Map<String, String>
)
typealias ActionHandler = (ActionContext) -> Unit
/**
* Routes GUI actions through a tiered handler system:
* 1. Kotlin handlers registered via [register]
* 2. Molang scripts referenced in the GUI manifest
* 3. Legacy session action listeners
*/
object MolangUiActionRouter {
private val logger = LoggerFactory.getLogger("MolangUiActionRouter")
private val kotlinHandlers = ConcurrentHashMap<Pair<ResourceLocation, String>, ActionHandler>()
fun register(guiId: String, actionName: String, handler: ActionHandler) {
val id = ResourceLocation.tryParse(guiId) ?: return
kotlinHandlers[id to actionName] = handler
}
fun register(guiId: ResourceLocation, actionName: String, handler: ActionHandler) {
kotlinHandlers[guiId to actionName] = handler
}
fun routeAction(session: MolangUiSession, player: ServerPlayer, action: String, data: Map<String, String>) {
val guiId = session.guiId
val context = ActionContext(session, player, data)
// 1. Check for Kotlin handler
val kotlinHandler = kotlinHandlers[guiId to action]
if (kotlinHandler != null) {
kotlinHandler(context)
return
}
// 2. Check manifest for .molang script
val manifest = MolangUiManifestRegistry.getManifest(guiId)
val manifestAction = manifest?.actions?.get(action)
if (manifestAction?.handler != null) {
executeMolangAction(manifestAction.handler, manifestAction.params, context)
return
}
// 3. Fall back to session action listeners (legacy support)
session.onAction(action, data)
}
/**
* Executes the on_open script from a manifest. Sets args locally (no packet)
* since the GUI hasn't been sent to the client yet.
*/
fun executeOnOpen(scriptId: ResourceLocation, session: MolangUiSession, player: ServerPlayer) {
val script = CobblemonScripts.scripts[scriptId]
if (script == null) {
logger.warn("Molang on_open script not found: $scriptId")
return
}
val runtime = MoLangRuntime().setup()
runtime.withQueryValue("player", player.asMoLangValue())
// q.session.set_arg sets args locally (no packet sent — GUI isn't open yet)
val sessionStruct = ObjectValue(session).addFunctions(mapOf(
"arg" to java.util.function.Function { params ->
val name = params.getString(0)
StringValue(session.getCurrentArg(name) ?: "")
},
"set_arg" to java.util.function.Function { params ->
val name = params.getString(0)
val value = params.getString(1)
session.setArgLocally(name, value)
DoubleValue(1.0)
}
))
runtime.withQueryValue("session", sessionStruct)
session.containerMenu?.let { menu ->
SlotQueryFunctions.register(runtime, menu)
}
try {
script.resolve(runtime, emptyMap())
} catch (e: Exception) {
logger.error("Error executing Molang on_open script $scriptId", e)
}
}
private fun executeMolangAction(scriptId: ResourceLocation, paramNames: List<String>, context: ActionContext) {
val script = CobblemonScripts.scripts[scriptId]
if (script == null) {
logger.warn("Molang action script not found: $scriptId")
return
}
val runtime = MoLangRuntime().setup()
// Register player as a query value (follows ScriptNPCInteractionConfiguration pattern)
runtime.withQueryValue("player", context.player.asMoLangValue())
// Register session as q.session with methods: arg(), update_arg(), update_variable(), close()
val session = context.session
val sessionStruct = ObjectValue(session).addFunctions(mapOf(
"arg" to java.util.function.Function { params ->
val name = params.getString(0)
StringValue(session.getCurrentArg(name) ?: "")
},
"update_arg" to java.util.function.Function { params ->
val name = params.getString(0)
val value = params.getString(1)
session.updateArgs(mapOf(name to value))
DoubleValue(1.0)
},
"update_variable" to java.util.function.Function { params ->
val name = params.getString(0)
val value = params.getDouble(1)
session.pushUpdate(variables = mapOf(name to value))
DoubleValue(1.0)
},
"close" to java.util.function.Function<com.bedrockk.molang.runtime.MoParams, Any> {
session.close()
DoubleValue(1.0)
}
))
runtime.withQueryValue("session", sessionStruct)
session.containerMenu?.let { menu ->
SlotQueryFunctions.register(runtime, menu)
}
// Map positional action data to named params from the manifest
// e.g. data={"0": "5"} + paramNames=["amount"] → v.amount = 5.0
for ((index, paramName) in paramNames.withIndex()) {
val rawValue = context.data[index.toString()] ?: continue
val numericValue = rawValue.toDoubleOrNull()
if (numericValue != null) {
runtime.environment.setSimpleVariable(paramName, DoubleValue(numericValue))
} else {
runtime.environment.setSimpleVariable(paramName, StringValue(rawValue))
}
}
// Execute the script
try {
script.resolve(runtime, emptyMap())
} catch (e: Exception) {
logger.error("Error executing Molang action script $scriptId", e)
}
}
}
@@ -0,0 +1,117 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.molangui
import com.bedrockk.molang.runtime.MoLangRuntime
import com.bedrockk.molang.runtime.value.DoubleValue
import com.bedrockk.molang.runtime.value.MoValue
import com.bedrockk.molang.runtime.value.StringValue
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.api.molang.MoLangFunctions.setup
import com.cobblemon.mod.common.client.ClientMoLangFunctions.setupClient
import com.cobblemon.mod.common.net.messages.server.molangui.MolangUiActionPacket
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.level.ServerPlayer
import java.util.UUID
object MolangUiContext {
fun createClientRuntime(
guiId: ResourceLocation,
sessionId: UUID,
parameters: Map<String, String>,
variables: MutableMap<String, Double>
): MoLangRuntime {
val runtime = MoLangRuntime().setup().setupClient()
runtime.environment.query.addFunction("gui_id") { StringValue(guiId.toString()) }
for ((key, value) in parameters) {
val numValue = value.toDoubleOrNull()
if (numValue != null) {
runtime.environment.query.addFunction(key) { DoubleValue(numValue) }
} else {
runtime.environment.query.addFunction(key) { StringValue(value) }
}
}
for ((key, value) in variables) {
val varName = if (key.startsWith("v.")) key.substring(2) else key
runtime.environment.setSimpleVariable(varName, DoubleValue(value))
}
runtime.environment.query.addFunction("send_action") { params ->
val action = params.getString(0)
val data = mutableMapOf<String, String>()
for (i in 1 until params.params.size) {
data[(i - 1).toString()] = params.get<MoValue>(i).asString()
}
Cobblemon.LOGGER.info("[MolangUi] send_action: action='$action' data=$data sessionId=$sessionId")
MolangUiActionPacket(
sessionId = sessionId,
action = action,
data = data
).sendToServer()
DoubleValue(1.0)
}
return runtime
}
fun updateClientFrame(runtime: MoLangRuntime, guiTime: Float, delta: Float, screenWidth: Int, screenHeight: Int, mouseX: Double, mouseY: Double) {
runtime.environment.query.addFunction("gui_time") { DoubleValue(guiTime.toDouble()) }
runtime.environment.query.addFunction("delta") { DoubleValue(delta.toDouble()) }
runtime.environment.query.addFunction("screen_width") { DoubleValue(screenWidth.toDouble()) }
runtime.environment.query.addFunction("screen_height") { DoubleValue(screenHeight.toDouble()) }
runtime.environment.query.addFunction("mouse_x") { DoubleValue(mouseX) }
runtime.environment.query.addFunction("mouse_y") { DoubleValue(mouseY) }
}
fun updateClientParameters(runtime: MoLangRuntime, parameters: Map<String, String>) {
for ((key, value) in parameters) {
val numValue = value.toDoubleOrNull()
if (numValue != null) {
runtime.environment.query.addFunction(key) { DoubleValue(numValue) }
} else {
runtime.environment.query.addFunction(key) { StringValue(value) }
}
}
}
fun createServerRuntime(
guiId: ResourceLocation,
sessionId: UUID,
player: ServerPlayer,
parameters: Map<String, String>
): MoLangRuntime {
val runtime = MoLangRuntime().setup()
GuiFunctions.registerInto(runtime, player)
runtime.environment.query.addFunction("gui_id") { StringValue(guiId.toString()) }
for ((key, value) in parameters) {
val numValue = value.toDoubleOrNull()
if (numValue != null) {
runtime.environment.query.addFunction(key) { DoubleValue(numValue) }
} else {
runtime.environment.query.addFunction(key) { StringValue(value) }
}
}
return runtime
}
fun updateClientVariables(runtime: MoLangRuntime, variables: Map<String, Double>) {
for ((key, value) in variables) {
val varName = if (key.startsWith("v.")) key.substring(2) else key
runtime.environment.setSimpleVariable(varName, DoubleValue(value))
}
}
fun setEventContext(runtime: MoLangRuntime, widgetId: String, eventData: Map<String, String>) {
runtime.environment.query.addFunction("event_widget") { StringValue(widgetId) }
runtime.environment.query.addFunction("event_data") { params ->
val key = params.getString(0)
val value = eventData[key]
if (value != null) StringValue(value) else DoubleValue(0.0)
}
}
}
@@ -0,0 +1,40 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.molangui
import com.google.gson.JsonObject
import net.minecraft.resources.ResourceLocation
/**
* A Molang UI definition loaded from resource packs (assets/).
*
* Contains JSON metadata and a declarative widget tree that defines
* the GUI layout and behavior using Molang expressions.
*
* @param guiId The resource location identifier for this GUI
* @param version Semver version string for cache/manifest comparison
* @param mode Whether this renders as an OVERLAY or SCREEN
* @param parameters Expected parameter definitions
* @param blocksInput Whether this GUI blocks player input
* @param exclusive When true, suppresses all other Cobblemon overlays while this GUI is active
* @param rootJson The raw JSON widget tree (parsed into WidgetDefinition on client)
* @param variables Client-side Molang variables with default values
* @param imports Template library imports
*/
data class MolangUiDefinition(
val guiId: ResourceLocation,
val version: String = "0.0.0",
val mode: GuiMode = GuiMode.OVERLAY,
val parameters: Map<String, MolangUiParameter> = emptyMap(),
val blocksInput: Boolean = false,
val exclusive: Boolean = false,
val rootJson: JsonObject = JsonObject(),
val variables: Map<String, Double> = emptyMap(),
val imports: List<String> = emptyList()
)
@@ -0,0 +1,141 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.molangui
import com.cobblemon.mod.common.api.data.DataRegistry
import com.cobblemon.mod.common.api.molangui.container.CloseItemsBehavior
import com.cobblemon.mod.common.api.molangui.container.CollectionConfig
import com.cobblemon.mod.common.api.molangui.container.ShiftClickRule
import com.cobblemon.mod.common.api.reactive.SimpleObservable
import com.cobblemon.mod.common.util.cobblemonResource
import com.google.gson.Gson
import com.google.gson.JsonObject
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.level.ServerPlayer
import net.minecraft.server.packs.PackType
import net.minecraft.server.packs.resources.ResourceManager
import org.slf4j.LoggerFactory
object MolangUiManifestRegistry : DataRegistry {
private val logger = LoggerFactory.getLogger("MolangUiManifestRegistry")
override val id = cobblemonResource("molang_ui_manifests")
override val type = PackType.SERVER_DATA
override val observable = SimpleObservable<MolangUiManifestRegistry>()
private val gson = Gson()
private val manifests = mutableMapOf<ResourceLocation, MolangUiManifest>()
private const val DIRECTORY = "molang_ui"
override fun reload(manager: ResourceManager) {
manifests.clear()
val resources = manager.listResources(DIRECTORY) { it.path.endsWith(".json") }
for ((location, resource) in resources) {
try {
val json = resource.openAsReader().use { gson.fromJson(it, JsonObject::class.java) }
val guiId = ResourceLocation.fromNamespaceAndPath(
location.namespace,
location.path.removePrefix("$DIRECTORY/").removeSuffix(".json")
)
manifests[guiId] = parseManifest(guiId, json)
} catch (e: Exception) {
logger.error("Failed to load Molang UI manifest: $location", e)
}
}
logger.info("Loaded ${manifests.size} Molang UI manifests")
observable.emit(this)
}
override fun sync(player: ServerPlayer) {
// No sync needed — manifests are server-only
}
fun getManifest(guiId: ResourceLocation): MolangUiManifest? = manifests[guiId]
fun getExpectedVersion(guiId: ResourceLocation): String? = manifests[guiId]?.version
fun getAllManifests(): Map<ResourceLocation, MolangUiManifest> = manifests.toMap()
private fun parseManifest(guiId: ResourceLocation, json: JsonObject): MolangUiManifest {
val version = json.get("version")?.asString ?: "0.0.0"
val parameters = mutableMapOf<String, ManifestParameter>()
json.getAsJsonObject("parameters")?.entrySet()?.forEach { (key, value) ->
val obj = value.asJsonObject
parameters[key] = ManifestParameter(
type = obj.get("type")?.asString ?: "string",
required = obj.get("required")?.asBoolean ?: true,
default = obj.get("default")?.asString
)
}
val actions = mutableMapOf<String, ManifestAction>()
json.getAsJsonObject("actions")?.entrySet()?.forEach { (key, value) ->
val obj = value.asJsonObject
actions[key] = ManifestAction(
handler = obj.get("handler")?.asString?.let { ResourceLocation.tryParse(it) },
params = obj.getAsJsonArray("params")?.map { it.asString } ?: emptyList()
)
}
val onOpen = json.get("on_open")?.asString?.let { ResourceLocation.tryParse(it) }
val collections = mutableMapOf<String, CollectionConfig>()
if (json.has("collections")) {
val collectionsJson = json.getAsJsonObject("collections")
for ((name, element) in collectionsJson.entrySet()) {
val obj = element.asJsonObject
collections[name] = CollectionConfig(
size = if (obj.has("size")) obj.get("size").asInt else 0,
allowedItems = if (obj.has("allowed_items")) obj.get("allowed_items").asString else null,
takeOnly = if (obj.has("take_only")) obj.get("take_only").asBoolean else false,
source = if (obj.has("source")) obj.get("source").asString else "session",
slots = if (obj.has("slots")) obj.getAsJsonArray("slots").map { it.asInt } else null
)
}
}
val shiftClickRules = mutableListOf<ShiftClickRule>()
if (json.has("shift_click_rules")) {
for (element in json.getAsJsonArray("shift_click_rules")) {
val obj = element.asJsonObject
shiftClickRules.add(ShiftClickRule(
from = obj.get("from").asString.split(",").map { it.trim() },
to = obj.get("to").asString.split(",").map { it.trim() }
))
}
}
val closeItemsBehavior = if (json.has("on_close_items"))
CloseItemsBehavior.fromId(json.get("on_close_items").asString)
else CloseItemsBehavior.DROP
return MolangUiManifest(guiId, version, parameters, actions, onOpen, collections, shiftClickRules, closeItemsBehavior)
}
}
data class MolangUiManifest(
val guiId: ResourceLocation,
val version: String,
val parameters: Map<String, ManifestParameter> = emptyMap(),
val actions: Map<String, ManifestAction> = emptyMap(),
val onOpen: ResourceLocation? = null,
val collections: Map<String, CollectionConfig> = emptyMap(),
val shiftClickRules: List<ShiftClickRule> = emptyList(),
val closeItemsBehavior: CloseItemsBehavior = CloseItemsBehavior.DROP
)
data class ManifestParameter(
val type: String = "string",
val required: Boolean = true,
val default: String? = null
)
data class ManifestAction(
val handler: ResourceLocation? = null,
val params: List<String> = emptyList()
)
@@ -0,0 +1,20 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.molangui
/**
* Defines a parameter expected by a scripted GUI.
*
* @param type The parameter type: "number", "string", or "vec3"
* @param default The default value as a string, or null if required
*/
data class MolangUiParameter(
val type: String = "string",
val default: String? = null
)
@@ -0,0 +1,121 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.molangui
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.api.molangui.template.TemplateLibraryRegistry
import com.google.gson.GsonBuilder
import com.google.gson.JsonObject
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.packs.resources.ResourceManager
import java.io.InputStreamReader
/**
* Registry for loading Molang UI definitions from resource packs (assets/).
*
* Loads .json files from `assets/<namespace>/molang_ui/`.
* Each JSON file contains the full GUI definition including metadata,
* variables, imports, and the declarative widget tree.
*
* Reloaded client-side via CobblemonClient.reloadCodedAssets().
*/
object MolangUiRegistry {
private val gson = GsonBuilder().create()
private val definitions = mutableMapOf<ResourceLocation, MolangUiDefinition>()
private const val DIRECTORY = "molang_ui"
fun reload(manager: ResourceManager) {
// Load template libraries first so they are available for resolution
TemplateLibraryRegistry.reload(manager)
definitions.clear()
// Collect all JSON definitions, excluding the templates subdirectory
val jsonResources = manager.listResources(DIRECTORY) { path ->
path.path.endsWith(".json") && !path.path.contains("/templates/")
}
for ((identifier, resource) in jsonResources) {
try {
resource.open().use { stream ->
val json = gson.fromJson(InputStreamReader(stream), JsonObject::class.java)
// Build the GUI resource location (namespace:name without extension)
val path = identifier.path
.removePrefix("$DIRECTORY/")
.removeSuffix(".json")
val guiId = ResourceLocation.fromNamespaceAndPath(identifier.namespace, path)
// Parse version
val version = json.get("version")?.asString ?: "0.0.0"
// Parse mode
val mode = json.get("mode")?.asString?.let { GuiMode.fromString(it) } ?: GuiMode.OVERLAY
// Parse parameters
val parameters = mutableMapOf<String, MolangUiParameter>()
json.getAsJsonObject("parameters")?.entrySet()?.forEach { (name, element) ->
val paramObj = element.asJsonObject
parameters[name] = MolangUiParameter(
type = paramObj.get("type")?.asString ?: "string",
default = paramObj.get("default")?.asString
)
}
// Parse blocksInput
val blocksInput = json.get("blocksInput")?.asBoolean ?: false
// Parse exclusive (suppresses all other overlays when active)
val exclusive = json.get("exclusive")?.asBoolean ?: false
// Parse variables (client-side Molang variables with defaults)
val variables = mutableMapOf<String, Double>()
json.getAsJsonObject("variables")?.entrySet()?.forEach { (name, element) ->
variables[name] = element.asDouble
}
// Parse imports (template library references)
val imports = mutableListOf<String>()
json.getAsJsonArray("imports")?.forEach { element ->
imports.add(element.asString)
}
val definition = MolangUiDefinition(
guiId = guiId,
version = version,
mode = mode,
parameters = parameters,
blocksInput = blocksInput,
exclusive = exclusive,
rootJson = json,
variables = variables,
imports = imports
)
if (definitions.containsKey(guiId)) {
Cobblemon.LOGGER.warn("[MolangUiRegistry] Duplicate GUI '$guiId', overwriting")
}
definitions[guiId] = definition
Cobblemon.LOGGER.debug("[MolangUiRegistry] Loaded GUI '$guiId' (mode=$mode, version=$version, params=${parameters.size})")
}
} catch (e: Exception) {
Cobblemon.LOGGER.error("[MolangUiRegistry] Error loading GUI from $identifier", e)
}
}
Cobblemon.LOGGER.info("[MolangUiRegistry] Loaded ${definitions.size} molang UIs")
}
fun getDefinition(guiId: ResourceLocation): MolangUiDefinition? = definitions[guiId]
fun getAllDefinitions(): Map<ResourceLocation, MolangUiDefinition> = definitions.toMap()
fun getVersionMap(): Map<ResourceLocation, String> = definitions.mapValues { it.value.version }
}
@@ -0,0 +1,488 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.molangui
import com.bedrockk.molang.runtime.MoLangRuntime
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.api.molang.MoLangFunctions.asMoLangValue
import com.cobblemon.mod.common.api.molang.MoLangFunctions.setup
import com.cobblemon.mod.common.api.molangui.container.CloseItemsBehavior
import com.cobblemon.mod.common.api.molangui.container.MolangContainerMenu
import com.cobblemon.mod.common.api.molangui.container.ServerCollectionSetup
import com.cobblemon.mod.common.api.molangui.container.SlotLayout
import com.cobblemon.mod.common.api.molangui.container.SlotQueryFunctions
import com.cobblemon.mod.common.api.molangui.widget.WidgetDefinition
import com.cobblemon.mod.common.api.molangui.widget.WidgetTree
import com.cobblemon.mod.common.api.molangui.widget.WidgetType
import com.cobblemon.mod.common.net.messages.client.molangui.MolangUiClosePacket
import com.cobblemon.mod.common.net.messages.client.molangui.MolangUiOpenPacket
import com.cobblemon.mod.common.net.messages.client.molangui.MolangUiUpdatePacket
import com.cobblemon.mod.common.util.asExpressionLike
import com.cobblemon.mod.common.util.withQueryValue
import net.minecraft.core.BlockPos
import net.minecraft.network.chat.Component
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.level.ServerPlayer
import net.minecraft.world.Container
import net.minecraft.world.MenuProvider
import net.minecraft.world.SimpleContainer
import net.minecraft.world.entity.player.Inventory
import net.minecraft.world.entity.player.Player
import net.minecraft.world.inventory.AbstractContainerMenu
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/**
* Server-side representation of an active scripted GUI session for a specific player.
*/
class MolangUiSession(
val sessionId: UUID,
val guiId: ResourceLocation,
val player: ServerPlayer,
definition: MolangUiDefinition,
val blockPos: BlockPos? = null,
private val actionListeners: MutableList<(String, Map<String, String>) -> Unit> = mutableListOf(),
private val closeListeners: MutableList<() -> Unit> = mutableListOf()
) {
var containerMenu: MolangContainerMenu? = null
internal set
private var closed = false
private val currentArgs = ConcurrentHashMap<String, String>(
definition.parameters.mapValues { (_, param) -> param.default ?: "" }
)
val runtime: MoLangRuntime = MolangUiContext.createServerRuntime(
guiId = guiId,
sessionId = sessionId,
player = player,
parameters = definition.parameters.mapValues { (_, param) -> param.default ?: "" }
)
val rootWidget: WidgetDefinition = WidgetTree.parse(definition.rootJson, isClient = false)
/**
* Returns the current value of a session argument, or null if not set.
*/
fun getCurrentArg(name: String): String? = currentArgs[name]
fun getAllArgs(): Map<String, String> = currentArgs.toMap()
/**
* Sets arguments locally without sending a packet to the client.
* Used by on_open scripts to set initial args before the GUI is opened.
*/
fun setArgLocally(name: String, value: String) {
currentArgs[name] = value
}
/**
* Sends updated arguments to the client and tracks them server-side.
*/
fun updateArgs(args: Map<String, String>) {
if (closed) return
currentArgs.putAll(args)
MolangUiUpdatePacket(sessionId, args).sendToPlayer(player)
}
/**
* Pushes variable and/or parameter updates to the client GUI.
*/
fun pushUpdate(variables: Map<String, Double> = emptyMap(), parameters: Map<String, String> = emptyMap()) {
if (closed) {
Cobblemon.LOGGER.warn("[MolangUiSession] pushUpdate called on closed session $sessionId")
return
}
Cobblemon.LOGGER.info("[MolangUiSession] pushUpdate: sessionId=$sessionId variables=$variables parameters=${parameters.keys}")
MolangUiUpdatePacket(sessionId, parameters, variables).sendToPlayer(player)
}
/**
* Closes this GUI session and notifies the client.
*/
fun close() {
if (closed) return
closed = true
MolangUiClosePacket(sessionId).sendToPlayer(player)
closeListeners.forEach { it() }
MolangUiSessionManager.removeSession(sessionId)
}
/**
* Adds a listener for player actions from the GUI.
*/
fun addActionListener(listener: (action: String, data: Map<String, String>) -> Unit) {
actionListeners.add(listener)
}
/**
* Adds a listener for when the GUI is closed (by server or client).
*/
fun addCloseListener(listener: () -> Unit) {
closeListeners.add(listener)
}
/**
* Handles a widget event by finding the widget definition, setting MoLang event context,
* and evaluating the event handler expression.
*/
fun handleWidgetEvent(widgetId: String, eventType: String, data: Map<String, String>) {
if (closed) return
val widgetDef = findWidget(rootWidget, widgetId)
if (widgetDef == null) {
Cobblemon.LOGGER.debug("[MolangUiSession] Widget '$widgetId' not found in GUI '$guiId'")
return
}
val handler = widgetDef.events[eventType]
if (handler == null) {
Cobblemon.LOGGER.debug("[MolangUiSession] No handler for event '$eventType' on widget '$widgetId' in GUI '$guiId'")
return
}
// "action@smelt" syntax routes directly through the action router (manifest scripts, Kotlin handlers)
if (handler.startsWith("action@")) {
val actionName = handler.substringAfter("action@")
MolangUiActionRouter.routeAction(this, player, actionName, data)
return
}
MolangUiContext.setEventContext(runtime, widgetId, data)
try {
handler.asExpressionLike().resolve(runtime)
} catch (e: Exception) {
Cobblemon.LOGGER.error("[MolangUiSession] Error evaluating event '$eventType' handler on widget '$widgetId': ${e.message}", e)
}
}
private fun findWidget(node: WidgetDefinition, targetId: String): WidgetDefinition? {
if (node.id == targetId) return node
for (child in node.children) {
val result = findWidget(child, targetId)
if (result != null) return result
}
return null
}
/** Called when an action is received from the client. */
internal fun onAction(action: String, data: Map<String, String>) {
if (closed) return
actionListeners.forEach { it(action, data) }
}
/** Called when the client notifies that it closed the GUI. */
internal fun onClientClosed() {
if (closed) return
closed = true
closeListeners.forEach { it() }
MolangUiSessionManager.removeSession(sessionId)
}
fun isClosed(): Boolean = closed
}
/**
* Extracts [SlotLayout] definitions from all SLOT_GRID widgets in this widget tree,
* keyed by the collection_name property.
*/
fun WidgetDefinition.extractSlotLayouts(): Map<String, SlotLayout> {
val layouts = mutableMapOf<String, SlotLayout>()
collectSlotLayouts(this, layouts)
return layouts
}
private fun collectSlotLayouts(node: WidgetDefinition, layouts: MutableMap<String, SlotLayout>) {
if (node.type == WidgetType.SLOT_GRID) {
val collectionName = (node.properties["collection_name"]?.raw as? String) ?: return
val dims = node.properties["grid_dimensions"]?.raw
val offset = node.properties["offset"]?.raw
val slotSize = node.properties["slot_size"]?.raw
val spacing = node.properties["slot_spacing"]?.raw
fun listToInts(raw: Any?, default1: Int, default2: Int): Pair<Int, Int> {
if (raw is List<*> && raw.size >= 2) {
return Pair((raw[0] as? Number)?.toInt() ?: default1, (raw[1] as? Number)?.toInt() ?: default2)
}
return Pair(default1, default2)
}
val (cols, rows) = listToInts(dims, 1, 1)
val (offX, offY) = listToInts(offset, 0, 0)
val (slotW, slotH) = listToInts(slotSize, 18, 18)
val (spX, spY) = listToInts(spacing, 0, 0)
layouts[collectionName] = SlotLayout(
offsetX = offX, offsetY = offY,
columns = cols, rows = rows,
slotWidth = slotW, slotHeight = slotH,
spacingX = spX, spacingY = spY
)
}
for (child in node.children) {
collectSlotLayouts(child, layouts)
}
}
/**
* Returns true if this widget tree contains any SLOT_GRID widgets.
*/
fun WidgetDefinition.hasSlotGrids(): Boolean {
if (type == WidgetType.SLOT_GRID) return true
return children.any { it.hasSlotGrids() }
}
/**
* Server-side manager for active scripted GUI sessions.
*/
object MolangUiSessionManager {
private val sessions = ConcurrentHashMap<UUID, MolangUiSession>()
private val disabledGuis = ConcurrentHashMap<UUID, Set<String>>()
fun setDisabledGuis(playerId: UUID, guiIds: Set<String>) {
if (guiIds.isEmpty()) disabledGuis.remove(playerId)
else disabledGuis[playerId] = guiIds
}
fun isGuiDisabled(playerId: UUID, guiId: ResourceLocation): Boolean {
return disabledGuis[playerId]?.contains(guiId.toString()) == true
}
/**
* Opens a scripted GUI for a player.
*
* @param player The target player
* @param guiId The GUI definition resource location
* @param args Initial arguments to pass to the GUI
* @param actionListener Optional listener for player actions
* @return The session, or null if the GUI definition doesn't exist
*/
fun openGui(
player: ServerPlayer,
guiId: ResourceLocation,
args: Map<String, String> = emptyMap(),
blockPos: BlockPos? = null,
actionListener: ((String, Map<String, String>) -> Unit)? = null
): MolangUiSession? {
if (isGuiDisabled(player.uuid, guiId)) {
Cobblemon.LOGGER.warn("[MolangUiSessionManager] GUI '$guiId' is disabled for player ${player.name.string} due to version mismatch")
return null
}
val definition = MolangUiRegistry.getDefinition(guiId)
if (definition == null) {
Cobblemon.LOGGER.error("[MolangUiSessionManager] GUI definition '$guiId' not found")
return null
}
val sessionId = UUID.randomUUID()
val session = MolangUiSession(sessionId, guiId, player, definition, blockPos)
if (actionListener != null) {
session.addActionListener(actionListener)
}
sessions[sessionId] = session
// Resolve manifest parameter defaults (supports Molang expressions for persistent data)
val manifest = MolangUiManifestRegistry.getManifest(guiId)
if (manifest != null) {
val resolvedDefaults = resolveManifestDefaults(manifest, player)
resolvedDefaults.forEach { (key, value) -> session.setArgLocally(key, value) }
}
// Execute on_open script from manifest if present
if (manifest?.onOpen != null) {
MolangUiActionRouter.executeOnOpen(manifest.onOpen, session, player)
}
// Merge caller args with any args set by defaults/on_open script
val mergedArgs = session.getAllArgs().toMutableMap()
mergedArgs.putAll(args) // caller args take priority
// Container setup: detect slot_grid widgets and create container menu
val hasContainer = session.rootWidget.hasSlotGrids()
// Send our open packet BEFORE player.openMenu() — the client needs pendingContainerData
// set before the vanilla ClientboundOpenScreenPacket triggers the MenuScreens factory.
MolangUiOpenPacket(
sessionId = sessionId,
guiId = guiId,
mode = definition.mode,
args = mergedArgs,
blocksInput = definition.blocksInput,
exclusive = definition.exclusive,
hasContainer = hasContainer,
slotLayouts = if (hasContainer) session.rootWidget.extractSlotLayouts() else emptyMap()
).sendToPlayer(player)
// Now open the container menu via vanilla MenuProvider (sends ClientboundOpenScreenPacket)
// This MUST come after our packet so the client has pendingContainerData ready.
if (hasContainer) {
val slotLayouts = session.rootWidget.extractSlotLayouts()
val configs = manifest?.collections ?: emptyMap()
val collectionSetup = mutableListOf<ServerCollectionSetup>()
val sessionOwned = mutableSetOf<String>()
for ((name, config) in configs) {
val layout = slotLayouts[name] ?: continue
val container: Container = if (config.source == "block_entity" && blockPos != null) {
val be = player.level().getBlockEntity(blockPos)
if (be is Container) be
else SimpleContainer(config.size)
} else {
sessionOwned.add(name)
SimpleContainer(config.size)
}
collectionSetup.add(ServerCollectionSetup(name, config, layout, container))
}
player.openMenu(object : MenuProvider {
override fun getDisplayName() = Component.empty()
override fun createMenu(containerId: Int, inv: Inventory, p: Player): AbstractContainerMenu {
val menu = MolangContainerMenu(containerId, inv)
menu.initializeServer(
collectionSetup = collectionSetup,
shiftClickRules = manifest?.shiftClickRules ?: emptyList(),
sessionOwned = sessionOwned,
closeItemsBehavior = manifest?.closeItemsBehavior ?: CloseItemsBehavior.DROP,
onSlotChange = { collName, slotIdx ->
val slotGridWidget = findSlotGridWidget(session.rootWidget, collName)
val actionName = slotGridWidget?.events?.get("on_slot_change")
if (actionName != null) {
MolangUiActionRouter.routeAction(
session, session.player, actionName,
mapOf("collection" to collName, "index" to slotIdx.toString())
)
}
}
)
session.containerMenu = menu
SlotQueryFunctions.register(session.runtime, menu)
return menu
}
})
}
Cobblemon.LOGGER.debug("[MolangUiSessionManager] Opened GUI '$guiId' for ${player.name.string} (session=$sessionId)")
return session
}
/**
* Closes a GUI session.
*/
fun closeGui(sessionId: UUID) {
sessions[sessionId]?.close()
}
/**
* Handles an action from a client.
*
* Actions in `"widgetId:eventType"` format are routed to [MolangUiSession.handleWidgetEvent]
* for MoLang evaluation. All actions are also forwarded to registered action listeners for
* backward compatibility with code that opens GUIs with action listeners.
*/
fun handleAction(sessionId: UUID, player: ServerPlayer, action: String, data: Map<String, String>) {
val session = sessions[sessionId]
if (session == null) {
Cobblemon.LOGGER.debug("[MolangUiSessionManager] Action for unknown session $sessionId")
return
}
if (session.player.uuid != player.uuid) {
Cobblemon.LOGGER.warn("[MolangUiSessionManager] Action from wrong player for session $sessionId")
return
}
// Route widget events (format: "widgetId:eventType") to MoLang handler
val colonIndex = action.indexOf(':')
if (colonIndex > 0) {
val widgetId = action.substring(0, colonIndex)
val eventType = action.substring(colonIndex + 1)
session.handleWidgetEvent(widgetId, eventType, data)
}
// Always fire action listeners for backward compatibility
session.onAction(action, data)
}
/**
* Handles a client-closed notification.
*/
fun handleClosed(sessionId: UUID, player: ServerPlayer) {
val session = sessions[sessionId]
if (session == null) return
if (session.player.uuid != player.uuid) {
Cobblemon.LOGGER.warn("[MolangUiSessionManager] Close from wrong player for session $sessionId")
return
}
session.onClientClosed()
}
/**
* Closes all GUI sessions for a player. Call on disconnect.
*/
fun closeAllForPlayer(playerId: UUID) {
sessions.values.filter { it.player.uuid == playerId }.forEach {
it.close()
}
disabledGuis.remove(playerId)
}
/** Internal: removes a session from tracking. */
internal fun removeSession(sessionId: UUID) {
sessions.remove(sessionId)
}
/**
* Recursively searches the widget tree for a SLOT_GRID widget with the given collection_name.
*/
private fun findSlotGridWidget(node: WidgetDefinition, collectionName: String): WidgetDefinition? {
if (node.type == WidgetType.SLOT_GRID && (node.properties["collection_name"]?.raw as? String) == collectionName) {
return node
}
for (child in node.children) {
val result = findSlotGridWidget(child, collectionName)
if (result != null) return result
}
return null
}
fun getSession(sessionId: UUID): MolangUiSession? = sessions[sessionId]
/**
* Returns the first active session for the given player and GUI ID, or null if none exists.
*/
fun getSessionForPlayer(player: ServerPlayer, guiId: ResourceLocation): MolangUiSession? {
return sessions.values.firstOrNull { it.player.uuid == player.uuid && it.guiId == guiId && !it.isClosed() }
}
/**
* Resolves manifest parameter defaults. If a default value looks like a Molang expression
* (contains q., v., math., etc.), it's evaluated with the player context.
* This allows persistent data loading: "default": "q.format(q.player.data('my_gui').my_value, 0)"
*/
private fun resolveManifestDefaults(manifest: MolangUiManifest, player: ServerPlayer): Map<String, String> {
val resolved = mutableMapOf<String, String>()
for ((paramName, param) in manifest.parameters) {
val defaultValue = param.default ?: continue
if (com.cobblemon.mod.common.api.molangui.widget.WidgetProperty.isMolangExpression(defaultValue)) {
try {
val runtime = com.bedrockk.molang.runtime.MoLangRuntime().setup()
runtime.withQueryValue("player", player.asMoLangValue())
val expression = defaultValue.asExpressionLike()
val result = expression.resolveString(runtime)
resolved[paramName] = result
} catch (e: Exception) {
Cobblemon.LOGGER.error("[MolangUiSessionManager] Failed to resolve default for param '$paramName': $defaultValue", e)
resolved[paramName] = defaultValue
}
} else {
resolved[paramName] = defaultValue
}
}
return resolved
}
}
@@ -0,0 +1,112 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.molangui.animation
import com.cobblemon.mod.common.api.molangui.widget.AnimationConfig
import kotlin.math.*
object Easing {
fun resolve(name: String): (Float) -> Float = when (name) {
"linear" -> ::linear
"ease_in" -> ::easeIn
"ease_out" -> ::easeOut
"ease_in_out" -> ::easeInOut
"bounce" -> ::bounce
"elastic" -> ::elastic
else -> ::easeOut
}
fun linear(t: Float): Float = t
fun easeIn(t: Float): Float = t * t
fun easeOut(t: Float): Float = 1f - (1f - t) * (1f - t)
fun easeInOut(t: Float): Float = if (t < 0.5f) 2f * t * t else 1f - (-2f * t + 2f).pow(2) / 2f
fun bounce(t: Float): Float {
val n1 = 7.5625f
val d1 = 2.75f
var x = t
return when {
x < 1f / d1 -> n1 * x * x
x < 2f / d1 -> { x -= 1.5f / d1; n1 * x * x + 0.75f }
x < 2.5f / d1 -> { x -= 2.25f / d1; n1 * x * x + 0.9375f }
else -> { x -= 2.625f / d1; n1 * x * x + 0.984375f }
}
}
fun elastic(t: Float): Float {
if (t == 0f || t == 1f) return t
val c4 = (2f * PI / 3f).toFloat()
return 2f.pow(-10f * t) * sin((t * 10f - 0.75f) * c4) + 1f
}
}
object EnterExitAnimation {
data class AnimationOutput(
val opacity: Float = 1f,
val offsetX: Float = 0f,
val offsetY: Float = 0f,
val scale: Float = 1f
)
fun evaluate(config: AnimationConfig, progress: Float, isExit: Boolean): AnimationOutput {
val easedProgress = Easing.resolve(config.easing)(progress)
val t = if (isExit) 1f - easedProgress else easedProgress
return when (config.type) {
"fade_in", "fade_out" -> AnimationOutput(opacity = t)
"slide_up" -> AnimationOutput(offsetY = (1f - t) * 30f)
"slide_down" -> AnimationOutput(offsetY = (1f - t) * -30f)
"slide_left" -> AnimationOutput(offsetX = (1f - t) * 30f)
"slide_right" -> AnimationOutput(offsetX = (1f - t) * -30f)
"scale_in", "scale_out" -> AnimationOutput(scale = t)
"pop" -> AnimationOutput(scale = if (t < 1f) t * 1.1f else 1f, opacity = t)
else -> AnimationOutput()
}
}
}
class TransitionState(
val duration: Float,
val easingName: String = "ease_out"
) {
private val easingFn = Easing.resolve(easingName)
var startValue: Float = 0f
var targetValue: Float = 0f
var currentValue: Float = 0f
var elapsed: Float = 0f
var active: Boolean = false
fun setTarget(newTarget: Float) {
if (newTarget != targetValue) {
startValue = currentValue
targetValue = newTarget
elapsed = 0f
active = true
}
}
fun update(delta: Float): Float {
if (!active) return currentValue
elapsed += delta
val progress = (elapsed / duration).coerceIn(0f, 1f)
currentValue = startValue + (targetValue - startValue) * easingFn(progress)
if (progress >= 1f) {
currentValue = targetValue
active = false
}
return currentValue
}
fun snapTo(value: Float) {
startValue = value
targetValue = value
currentValue = value
active = false
}
}
@@ -0,0 +1,76 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.molangui.container
import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.core.registries.Registries
import net.minecraft.resources.ResourceLocation
import net.minecraft.tags.TagKey
import net.minecraft.world.item.ItemStack
/**
* Configuration for a single slot collection parsed from the manifest JSON.
*/
data class CollectionConfig(
val size: Int = 0,
val allowedItems: String? = null,
val takeOnly: Boolean = false,
val source: String = "session",
val slots: List<Int>? = null
) {
/** Returns true if the given item is allowed in this collection's slots. */
fun isItemAllowed(stack: ItemStack): Boolean {
if (takeOnly) return false
val filter = allowedItems ?: return true
return ItemFilter.matches(stack, filter)
}
}
/**
* A shift-click rule mapping items from one set of collections to another.
*/
data class ShiftClickRule(
val from: List<String>,
val to: List<String>
)
/**
* Utility for matching items against filter strings.
* Supports: item IDs ("minecraft:diamond"), tags ("#minecraft:furnace_fuel"),
* and comma-separated lists ("minecraft:diamond, minecraft:emerald").
*/
object ItemFilter {
fun matches(stack: ItemStack, filter: String): Boolean {
return filter.split(",").map { it.trim() }.any { matchSingle(stack, it) }
}
private fun matchSingle(stack: ItemStack, filter: String): Boolean {
if (filter.startsWith("#")) {
val tagId = ResourceLocation.tryParse(filter.substring(1)) ?: return false
val tagKey = TagKey.create(Registries.ITEM, tagId)
return stack.`is`(tagKey)
}
val itemId = ResourceLocation.tryParse(filter) ?: return false
val item = BuiltInRegistries.ITEM.get(itemId)
return stack.`is`(item)
}
}
/**
* Behavior for items remaining in session-owned collections when the GUI closes.
*/
enum class CloseItemsBehavior(val id: String) {
DROP("drop"),
RETURN("return"),
DESTROY("destroy");
companion object {
fun fromId(id: String): CloseItemsBehavior = entries.firstOrNull { it.id == id } ?: DROP
}
}
@@ -0,0 +1,336 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.molangui.container
import com.cobblemon.mod.common.CobblemonMenuType
import net.minecraft.world.Container
import net.minecraft.world.SimpleContainer
import net.minecraft.world.entity.player.Inventory
import net.minecraft.world.entity.player.Player
import net.minecraft.world.inventory.AbstractContainerMenu
import net.minecraft.world.inventory.Slot
import net.minecraft.world.item.ItemStack
/**
* Layout data class describing how a grid of slots is arranged in screen space.
*/
data class SlotLayout(
val offsetX: Int,
val offsetY: Int,
val columns: Int,
val rows: Int,
val slotWidth: Int = 18,
val slotHeight: Int = 18,
val spacingX: Int = 0,
val spacingY: Int = 0
) {
val slotCount: Int get() = columns * rows
}
/**
* Tracks slot index ranges for a named collection so shift-click rules can resolve them.
*/
private data class CollectionSlotRange(
val name: String,
val startIndex: Int,
val endIndex: Int // exclusive
)
/**
* Container menu for Molang GUIs that declare slot_grid widgets.
*
* Server side: call [initializeServer] with real containers, configs, and rules.
* Client side: call [initializeClient] with layout info so vanilla slot sync works.
*/
class MolangContainerMenu(
containerId: Int,
private val playerInventory: Inventory
) : AbstractContainerMenu(CobblemonMenuType.MOLANG_UI, containerId) {
/** Named containers for custom collections. */
val collections: MutableMap<String, Container> = mutableMapOf()
// Slot range tracking for shift-click resolution
private val collectionRanges: MutableList<CollectionSlotRange> = mutableListOf()
private var playerInventoryStart: Int = 0
private var playerHotbarStart: Int = 0
private var playerHotbarEnd: Int = 0
// Shift-click rules from the manifest (only populated server-side)
private var shiftClickRules: List<ShiftClickRule> = emptyList()
// Collection configs keyed by name (for removed() behavior)
private val collectionConfigs: MutableMap<String, CollectionConfig> = mutableMapOf()
private val sessionOwnedCollections: MutableSet<String> = mutableSetOf()
private var closeItemsBehavior: CloseItemsBehavior = CloseItemsBehavior.DROP
// -------------------------------------------------
// Server-side initialization
// -------------------------------------------------
/**
* Sets up all slots from the given collection configs and containers.
* Also adds player inventory and hotbar slots.
*
* @param collectionSetup List of (name, config, layout, container) tuples
* @param shiftClickRules Shift-click rules from the manifest
* @param sessionOwned Names of collections whose items are session-owned (returned/dropped on close)
* @param closeItemsBehavior How to handle items in session-owned collections on close
*/
fun initializeServer(
collectionSetup: List<ServerCollectionSetup>,
shiftClickRules: List<ShiftClickRule> = emptyList(),
sessionOwned: Set<String> = emptySet(),
closeItemsBehavior: CloseItemsBehavior = CloseItemsBehavior.DROP,
onSlotChange: ((collectionName: String, slotIndex: Int) -> Unit)? = null
) {
this.shiftClickRules = shiftClickRules
this.sessionOwnedCollections.addAll(sessionOwned)
this.closeItemsBehavior = closeItemsBehavior
for (setup in collectionSetup) {
val (name, config, layout, container) = setup
collections[name] = container
collectionConfigs[name] = config
val startIndex = slots.size
addCollectionSlots(name, config, layout, container, onSlotChange)
val endIndex = slots.size
collectionRanges.add(CollectionSlotRange(name, startIndex, endIndex))
}
addPlayerSlots()
}
// -------------------------------------------------
// Client-side initialization
// -------------------------------------------------
/**
* Creates empty [SimpleContainer]s matching expected slot counts so vanilla can sync them.
*
* @param collectionLayouts List of (name, config, layout) — no real containers yet
*/
fun initializeClient(
collectionLayouts: List<ClientCollectionLayout>
) {
for ((name, config, layout) in collectionLayouts) {
val container = SimpleContainer(layout.slotCount)
collections[name] = container
collectionConfigs[name] = config
val startIndex = slots.size
addCollectionSlots(name, config, layout, container)
val endIndex = slots.size
collectionRanges.add(CollectionSlotRange(name, startIndex, endIndex))
}
addPlayerSlots()
}
// -------------------------------------------------
// Shared slot setup helpers
// -------------------------------------------------
private fun addCollectionSlots(
name: String,
config: CollectionConfig,
layout: SlotLayout,
container: Container,
onSlotChange: ((collectionName: String, slotIndex: Int) -> Unit)? = null
) {
var containerIndex = 0
for (row in 0 until layout.rows) {
for (col in 0 until layout.columns) {
val x = layout.offsetX + col * (layout.slotWidth + layout.spacingX)
val y = layout.offsetY + row * (layout.slotHeight + layout.spacingY)
val capturedIndex = containerIndex
addSlot(MolangSlot(
container = container,
index = capturedIndex,
x = x,
y = y,
collectionConfig = config,
onSlotChange = if (onSlotChange != null) { { onSlotChange(name, capturedIndex) } } else null
))
containerIndex++
}
}
}
private fun addPlayerSlots() {
playerInventoryStart = slots.size
// Player inventory rows (indices 9-35 in the Inventory)
for (row in 0 until 3) {
for (col in 0 until 9) {
val inventoryIndex = 9 + row * 9 + col
val x = 8 + col * 18
val y = 84 + row * 18
addSlot(Slot(playerInventory, inventoryIndex, x, y))
}
}
playerHotbarStart = slots.size
// Hotbar (indices 0-8 in the Inventory)
for (col in 0 until 9) {
addSlot(Slot(playerInventory, col, 8 + col * 18, 142))
}
playerHotbarEnd = slots.size
}
// -------------------------------------------------
// Public API
// -------------------------------------------------
/** Returns the container for the named collection, or null if not present. */
fun getCollection(name: String): Container? = collections[name]
// -------------------------------------------------
// AbstractContainerMenu overrides
// -------------------------------------------------
override fun quickMoveStack(player: Player, index: Int): ItemStack {
val slot = slots.getOrNull(index) ?: return ItemStack.EMPTY
if (!slot.hasItem()) return ItemStack.EMPTY
val slotItem = slot.item
val original = slotItem.copy()
val clickedCollection = collectionRanges.firstOrNull { index >= it.startIndex && index < it.endIndex }
val isPlayerInventory = index >= playerInventoryStart && index < playerInventoryEnd()
val isHotbar = index >= playerHotbarStart && index < playerHotbarEnd
if (clickedCollection != null) {
// Slot is in a custom collection — try to move to player inventory/hotbar
val rule = findShiftClickRule(clickedCollection.name, playerSideNames())
if (rule != null) {
val moved = tryMoveToCollections(slotItem, rule.to)
if (!moved) return ItemStack.EMPTY
} else {
// Default: move to player inventory then hotbar
if (!moveItemStackTo(slotItem, playerInventoryStart, playerHotbarEnd, false)) {
return ItemStack.EMPTY
}
}
} else if (isPlayerInventory || isHotbar) {
// Slot is in player inventory/hotbar — try to move to custom collections
val rule = findShiftClickRuleFromPlayer()
if (rule != null) {
val moved = tryMoveToCollections(slotItem, rule.to)
if (!moved) return ItemStack.EMPTY
} else {
// Default: move to first custom collection
val firstRange = collectionRanges.firstOrNull()
if (firstRange == null || !moveItemStackTo(slotItem, firstRange.startIndex, firstRange.endIndex, false)) {
return ItemStack.EMPTY
}
}
}
if (slotItem.isEmpty) {
slot.setByPlayer(ItemStack.EMPTY)
} else {
slot.setChanged()
}
if (slotItem.count == original.count) return ItemStack.EMPTY
slot.onTake(player, slotItem)
return original
}
override fun stillValid(player: Player): Boolean = true
override fun removed(player: Player) {
super.removed(player)
for (name in sessionOwnedCollections) {
val container = collections[name] ?: continue
when (closeItemsBehavior) {
CloseItemsBehavior.DROP -> clearContainer(player, container)
CloseItemsBehavior.RETURN -> returnItemsToPlayer(player, container)
CloseItemsBehavior.DESTROY -> container.clearContent()
}
}
}
// -------------------------------------------------
// Private helpers
// -------------------------------------------------
private fun playerEndIndex() = playerHotbarEnd
private fun playerInventoryEnd() = playerHotbarStart
private fun playerSideNames(): List<String> = listOf("player_inventory", "hotbar")
private fun findShiftClickRule(fromCollection: String, toNames: List<String>): ShiftClickRule? {
return shiftClickRules.firstOrNull { rule ->
rule.from.contains(fromCollection)
}
}
private fun findShiftClickRuleFromPlayer(): ShiftClickRule? {
return shiftClickRules.firstOrNull { rule ->
rule.from.any { it == "player_inventory" || it == "hotbar" }
}
}
private fun tryMoveToCollections(stack: ItemStack, toNames: List<String>): Boolean {
for (name in toNames) {
if (name == "player_inventory") {
if (moveItemStackTo(stack, playerInventoryStart, playerInventoryEnd(), false)) return true
} else if (name == "hotbar") {
if (moveItemStackTo(stack, playerHotbarStart, playerHotbarEnd, false)) return true
} else {
val range = collectionRanges.firstOrNull { it.name == name } ?: continue
if (moveItemStackTo(stack, range.startIndex, range.endIndex, false)) return true
}
}
return false
}
private fun returnItemsToPlayer(player: Player, container: Container) {
val inventory = player.inventory
for (i in 0 until container.containerSize) {
val stack = container.getItem(i)
if (!stack.isEmpty) {
if (!inventory.add(stack)) {
player.drop(stack, false)
}
container.setItem(i, ItemStack.EMPTY)
}
}
}
}
// -------------------------------------------------
// Setup data classes
// -------------------------------------------------
/**
* Server-side collection setup: name, config, layout, and the real container.
*/
data class ServerCollectionSetup(
val name: String,
val config: CollectionConfig,
val layout: SlotLayout,
val container: Container
)
/**
* Client-side collection layout: name, config, and layout (container created internally).
*/
data class ClientCollectionLayout(
val name: String,
val config: CollectionConfig,
val layout: SlotLayout
)
@@ -0,0 +1,41 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.molangui.container
import net.minecraft.world.Container
import net.minecraft.world.entity.player.Player
import net.minecraft.world.inventory.Slot
import net.minecraft.world.item.ItemStack
/**
* A slot with configurable item filtering and take-only behavior.
* Used by [MolangContainerMenu] to enforce collection rules from the manifest.
*/
class MolangSlot(
container: Container,
index: Int,
x: Int,
y: Int,
private val collectionConfig: CollectionConfig,
private val onSlotChange: (() -> Unit)? = null
) : Slot(container, index, x, y) {
override fun mayPlace(stack: ItemStack): Boolean {
return collectionConfig.isItemAllowed(stack)
}
override fun mayPickup(player: Player): Boolean {
return true
}
override fun setChanged() {
super.setChanged()
onSlotChange?.invoke()
}
}
@@ -0,0 +1,113 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.molangui.container
import com.bedrockk.molang.runtime.MoLangRuntime
import com.bedrockk.molang.runtime.value.DoubleValue
import com.bedrockk.molang.runtime.value.StringValue
import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.core.registries.Registries
import net.minecraft.resources.ResourceLocation
import net.minecraft.tags.TagKey
import net.minecraft.world.item.ItemStack
/**
* Registers Molang query functions for reading and writing container slot contents
* on a [MoLangRuntime] bound to a [MolangContainerMenu].
*
* Functions registered:
* - `q.slot(collection, index)` — returns the item ID in a slot, or empty string if empty
* - `q.slot_count(collection, index)` — returns the stack count as a double
* - `q.set_slot(collection, index, itemId, count)` — sets slot contents; returns 1.0 on success, 0.0 on failure
* - `q.clear_slot(collection, index)` — empties a slot; returns 1.0
* - `q.slot_tag(collection, index, tag)` — returns 1.0 if the item has the given tag, 0.0 otherwise
*/
object SlotQueryFunctions {
fun register(runtime: MoLangRuntime, menu: MolangContainerMenu) {
// q.slot(collection, index) -> item ID string or empty string
runtime.environment.query.addFunction("slot") { params ->
val collection = params.getString(0)
val index = params.getDouble(1).toInt()
val container = menu.getCollection(collection)
if (container == null || index < 0 || index >= container.containerSize) {
return@addFunction StringValue("")
}
val stack = container.getItem(index)
if (stack.isEmpty) {
StringValue("")
} else {
StringValue(BuiltInRegistries.ITEM.getKey(stack.item).toString())
}
}
// q.slot_count(collection, index) -> stack count as double
runtime.environment.query.addFunction("slot_count") { params ->
val collection = params.getString(0)
val index = params.getDouble(1).toInt()
val container = menu.getCollection(collection)
if (container == null || index < 0 || index >= container.containerSize) {
return@addFunction DoubleValue(0.0)
}
val stack = container.getItem(index)
DoubleValue(stack.count.toDouble())
}
// q.set_slot(collection, index, itemId, count) -> 1.0 on success, 0.0 on failure
runtime.environment.query.addFunction("set_slot") { params ->
val collection = params.getString(0)
val index = params.getDouble(1).toInt()
val itemId = params.getString(2)
val count = params.getDouble(3).toInt()
val container = menu.getCollection(collection)
if (container == null || index < 0 || index >= container.containerSize) {
return@addFunction DoubleValue(0.0)
}
val resourceLocation = ResourceLocation.tryParse(itemId)
?: return@addFunction DoubleValue(0.0)
val item = BuiltInRegistries.ITEM.get(resourceLocation)
val newStack = ItemStack(item, count.coerceAtLeast(1))
container.setItem(index, newStack)
DoubleValue(1.0)
}
// q.clear_slot(collection, index) -> 1.0
runtime.environment.query.addFunction("clear_slot") { params ->
val collection = params.getString(0)
val index = params.getDouble(1).toInt()
val container = menu.getCollection(collection)
if (container != null && index >= 0 && index < container.containerSize) {
container.setItem(index, ItemStack.EMPTY)
}
DoubleValue(1.0)
}
// q.slot_tag(collection, index, tag) -> 1.0 if item has tag, 0.0 otherwise
runtime.environment.query.addFunction("slot_tag") { params ->
val collection = params.getString(0)
val index = params.getDouble(1).toInt()
val tagStr = params.getString(2)
val container = menu.getCollection(collection)
if (container == null || index < 0 || index >= container.containerSize) {
return@addFunction DoubleValue(0.0)
}
val stack = container.getItem(index)
if (stack.isEmpty) {
return@addFunction DoubleValue(0.0)
}
val tagId = ResourceLocation.tryParse(tagStr)
?: return@addFunction DoubleValue(0.0)
val tagKey = TagKey.create(Registries.ITEM, tagId)
if (stack.`is`(tagKey)) DoubleValue(1.0) else DoubleValue(0.0)
}
}
}
@@ -0,0 +1,81 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.molangui.event
import com.bedrockk.molang.runtime.MoLangRuntime
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.api.molangui.widget.WidgetDefinition
import com.cobblemon.mod.common.api.molangui.widget.WidgetInstance
import com.cobblemon.mod.common.api.molangui.widget.WidgetType
import com.cobblemon.mod.common.net.messages.server.molangui.MolangUiActionPacket
import com.cobblemon.mod.common.util.asExpressionLike
import java.util.UUID
object ClientEventProcessor {
fun processEvent(
event: WidgetEvent,
widget: WidgetDefinition,
runtime: MoLangRuntime,
sessionId: UUID
) {
// Run client-side handler first (instant visual feedback)
val clientHandler = widget.clientEvents[event.type.clientId]
if (clientHandler != null) {
try {
val expression = clientHandler.asExpressionLike()
expression.resolve(runtime)
} catch (e: Exception) {
Cobblemon.LOGGER.warn("[ClientEventProcessor] Failed to execute client event '${event.type.clientId}' on widget '${event.widgetId}': ${e.message}")
}
}
// Send server-side handler as action packet
val serverHandler = widget.events[event.type.id]
if (serverHandler != null) {
val packet = MolangUiActionPacket(
sessionId = sessionId,
action = "${event.widgetId}:${event.type.id}",
data = event.data
)
packet.sendToServer()
}
}
fun hitTest(root: WidgetInstance, x: Float, y: Float): WidgetInstance? {
val isScrollPanel = root.definition.type == WidgetType.SCROLL_PANEL
if (isScrollPanel && !root.containsPoint(x, y)) return null
for (child in root.children.asReversed()) {
if (!child.visible) continue
val testY = if (isScrollPanel) y - root.scrollOffset else y
val hit = hitTest(child, x, testY)
if (hit != null) return hit
}
return if (root.containsPoint(x, y)) root else null
}
}
enum class WidgetEventType(val id: String) {
CLICK("on_click"),
CHANGE("on_change"),
SUBMIT("on_submit"),
HOVER_ENTER("on_hover_enter"),
HOVER_EXIT("on_hover_exit"),
SCROLL("on_scroll"),
KEY("on_key");
val clientId: String get() = "${id}_client"
}
data class WidgetEvent(
val type: WidgetEventType,
val widgetId: String,
val data: Map<String, String> = emptyMap()
)
@@ -0,0 +1,149 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.molangui.layout
import com.cobblemon.mod.common.api.molangui.widget.WidgetInstance
import com.cobblemon.mod.common.api.molangui.widget.WidgetProperty
import com.bedrockk.molang.runtime.MoLangRuntime
object LayoutEngine {
fun layout(root: WidgetInstance, screenWidth: Float, screenHeight: Float, runtime: MoLangRuntime) {
root.computedWidth = root.widthExpr.resolve(screenWidth, runtime)
root.computedHeight = root.heightExpr.resolve(screenHeight, runtime)
val anchor = root.definition.getAnchor()
val offsetX = root.offsetXExpr.resolve(screenWidth, runtime)
val offsetY = root.offsetYExpr.resolve(screenHeight, runtime)
root.absoluteX = (screenWidth - root.computedWidth) * anchor.xFactor + offsetX
root.absoluteY = (screenHeight - root.computedHeight) * anchor.yFactor + offsetY
layoutChildren(root, runtime)
}
private fun layoutChildren(parent: WidgetInstance, runtime: MoLangRuntime) {
val layout = parent.definition.getStringProperty("layout")
val spacing = parent.definition.getProperty("spacing")?.resolveFloat(runtime) ?: 0f
val padding = parsePadding(parent.definition.getProperty("padding"), runtime)
val innerWidth = parent.computedWidth - padding.left - padding.right
val innerHeight = parent.computedHeight - padding.top - padding.bottom
val innerX = parent.absoluteX + padding.left
val innerY = parent.absoluteY + padding.top
for (child in parent.children) {
if (!child.visible) continue
child.computedWidth = child.widthExpr.resolve(innerWidth, runtime)
child.computedHeight = child.heightExpr.resolve(innerHeight, runtime)
}
when (layout) {
"stack_vertical" -> layoutStackVertical(parent, innerX, innerY, innerWidth, innerHeight, spacing, runtime)
"stack_horizontal" -> layoutStackHorizontal(parent, innerX, innerY, innerWidth, innerHeight, spacing, runtime)
"grid" -> layoutGrid(parent, innerX, innerY, innerWidth, innerHeight, spacing, runtime)
else -> layoutAnchored(parent, innerX, innerY, innerWidth, innerHeight, runtime)
}
for (child in parent.children) {
if (!child.visible) continue
layoutChildren(child, runtime)
}
}
private fun layoutAnchored(parent: WidgetInstance, innerX: Float, innerY: Float, innerWidth: Float, innerHeight: Float, runtime: MoLangRuntime) {
for (child in parent.children) {
if (!child.visible) continue
val anchor = child.definition.getAnchor()
val offsetX = child.offsetXExpr.resolve(innerWidth, runtime)
val offsetY = child.offsetYExpr.resolve(innerHeight, runtime)
child.absoluteX = innerX + (innerWidth - child.computedWidth) * anchor.xFactor + offsetX
child.absoluteY = innerY + (innerHeight - child.computedHeight) * anchor.yFactor + offsetY
}
}
private fun layoutStackVertical(parent: WidgetInstance, innerX: Float, innerY: Float, innerWidth: Float, innerHeight: Float, spacing: Float, runtime: MoLangRuntime) {
val visibleChildren = parent.children.filter { it.visible }
resolveFillSizes(visibleChildren, innerHeight, spacing, isVertical = true, runtime = runtime)
var currentY = innerY
for (child in visibleChildren) {
child.absoluteX = innerX + child.offsetXExpr.resolve(innerWidth, runtime)
child.absoluteY = currentY + child.offsetYExpr.resolve(innerHeight, runtime)
currentY += child.computedHeight + spacing
}
}
private fun layoutStackHorizontal(parent: WidgetInstance, innerX: Float, innerY: Float, innerWidth: Float, innerHeight: Float, spacing: Float, runtime: MoLangRuntime) {
val visibleChildren = parent.children.filter { it.visible }
resolveFillSizes(visibleChildren, innerWidth, spacing, isVertical = false, runtime = runtime)
var currentX = innerX
for (child in visibleChildren) {
child.absoluteX = currentX + child.offsetXExpr.resolve(innerWidth, runtime)
child.absoluteY = innerY + child.offsetYExpr.resolve(innerHeight, runtime)
currentX += child.computedWidth + spacing
}
}
private fun layoutGrid(parent: WidgetInstance, innerX: Float, innerY: Float, innerWidth: Float, innerHeight: Float, spacing: Float, runtime: MoLangRuntime) {
val columns = parent.definition.getProperty("columns")?.resolveInt(runtime) ?: 1
val visibleChildren = parent.children.filter { it.visible }
val cellWidth = (innerWidth - spacing * (columns - 1)) / columns
var col = 0
var currentY = innerY
var rowHeight = 0f
for (child in visibleChildren) {
if (child.widthExpr is SizeExpression.Fill) child.computedWidth = cellWidth
child.absoluteX = innerX + col * (cellWidth + spacing)
child.absoluteY = currentY
rowHeight = maxOf(rowHeight, child.computedHeight)
col++
if (col >= columns) {
col = 0
currentY += rowHeight + spacing
rowHeight = 0f
}
}
}
private fun resolveFillSizes(children: List<WidgetInstance>, totalSpace: Float, spacing: Float, isVertical: Boolean, runtime: MoLangRuntime) {
val totalSpacing = spacing * (children.size - 1).coerceAtLeast(0)
var usedSpace = totalSpacing
var fillCount = 0
for (child in children) {
val expr = if (isVertical) child.heightExpr else child.widthExpr
if (expr is SizeExpression.Fill) {
fillCount++
} else {
usedSpace += if (isVertical) child.computedHeight else child.computedWidth
}
}
if (fillCount > 0) {
val fillSize = ((totalSpace - usedSpace) / fillCount).coerceAtLeast(0f)
for (child in children) {
val expr = if (isVertical) child.heightExpr else child.widthExpr
if (expr is SizeExpression.Fill) {
if (isVertical) child.computedHeight = fillSize else child.computedWidth = fillSize
}
}
}
}
data class Padding(val top: Float, val right: Float, val bottom: Float, val left: Float)
private fun parsePadding(property: WidgetProperty?, runtime: MoLangRuntime): Padding {
if (property == null) return Padding(0f, 0f, 0f, 0f)
val raw = property.raw
if (raw is List<*> && raw.size == 4) {
return Padding(
(raw[0] as? Number)?.toFloat() ?: 0f,
(raw[1] as? Number)?.toFloat() ?: 0f,
(raw[2] as? Number)?.toFloat() ?: 0f,
(raw[3] as? Number)?.toFloat() ?: 0f
)
}
return Padding(0f, 0f, 0f, 0f)
}
}
@@ -0,0 +1,86 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.molangui.layout
import com.cobblemon.mod.common.api.molang.ExpressionLike
import com.cobblemon.mod.common.util.asExpressionLike
import com.bedrockk.molang.runtime.MoLangRuntime
sealed class SizeExpression {
abstract fun resolve(parentSize: Float, runtime: MoLangRuntime): Float
data class Pixels(val value: Float) : SizeExpression() {
override fun resolve(parentSize: Float, runtime: MoLangRuntime) = value
}
data class Percent(val fraction: Float) : SizeExpression() {
override fun resolve(parentSize: Float, runtime: MoLangRuntime) = parentSize * fraction
}
data class PercentWithOffset(val fraction: Float, val offset: Float) : SizeExpression() {
override fun resolve(parentSize: Float, runtime: MoLangRuntime) = parentSize * fraction + offset
}
data object Auto : SizeExpression() {
override fun resolve(parentSize: Float, runtime: MoLangRuntime) = 0f
}
data object Fill : SizeExpression() {
override fun resolve(parentSize: Float, runtime: MoLangRuntime) = 0f
}
data class MolangSize(val expression: ExpressionLike, val isPercent: Boolean = false) : SizeExpression() {
override fun resolve(parentSize: Float, runtime: MoLangRuntime): Float {
val value = expression.resolveFloat(runtime)
return if (isPercent) parentSize * value / 100f else value
}
}
companion object {
private val PERCENT_PATTERN = Regex("""^(\d+(?:\.\d+)?)%$""")
private val PERCENT_OFFSET_PATTERN = Regex("""^(\d+(?:\.\d+)?)%\s*([+-])\s*(\d+(?:\.\d+)?)$""")
fun parse(value: Any): SizeExpression {
return when (value) {
is Number -> Pixels(value.toFloat())
is String -> parseString(value)
else -> Pixels(0f)
}
}
private fun parseString(value: String): SizeExpression {
val trimmed = value.trim()
return when {
trimmed == "auto" -> Auto
trimmed == "fill" -> Fill
trimmed.toFloatOrNull() != null -> Pixels(trimmed.toFloat())
PERCENT_PATTERN.matches(trimmed) -> {
val pct = PERCENT_PATTERN.matchEntire(trimmed)!!.groupValues[1].toFloat()
Percent(pct / 100f)
}
PERCENT_OFFSET_PATTERN.matches(trimmed) -> {
val match = PERCENT_OFFSET_PATTERN.matchEntire(trimmed)!!
val pct = match.groupValues[1].toFloat() / 100f
val sign = if (match.groupValues[2] == "+") 1f else -1f
val offset = match.groupValues[3].toFloat() * sign
PercentWithOffset(pct, offset)
}
else -> {
val isPercent = trimmed.endsWith("%")
val exprStr = if (isPercent) trimmed.dropLast(1).trim() else trimmed
try {
MolangSize(exprStr.asExpressionLike(), isPercent)
} catch (e: Exception) {
Pixels(0f)
}
}
}
}
}
}
@@ -0,0 +1,92 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.molangui.template
import com.google.gson.Gson
import com.google.gson.JsonObject
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.packs.resources.ResourceManager
object TemplateLibraryRegistry {
private val gson = Gson()
private val libraries: MutableMap<ResourceLocation, Map<String, TemplateDefinition>> = mutableMapOf()
private const val DIRECTORY = "molang_ui/templates"
fun reload(manager: ResourceManager) {
libraries.clear()
val resources = manager.listResources(DIRECTORY) { it.path.endsWith(".json") }
for ((location, resource) in resources) {
try {
val json = resource.openAsReader().use { gson.fromJson(it, JsonObject::class.java) }
val namespace = location.namespace
val name = location.path.removePrefix("$DIRECTORY/").removeSuffix(".json")
val libraryId = ResourceLocation.fromNamespaceAndPath(namespace, name)
val templates = parseTemplates(json, namespace)
libraries[libraryId] = templates
} catch (e: Exception) {
// Log warning and continue
}
}
}
fun getTemplate(qualifiedName: String): TemplateDefinition? {
val dotIndex = qualifiedName.indexOf('.')
if (dotIndex == -1) return null
val libraryName = qualifiedName.substring(0, dotIndex)
val templateName = qualifiedName.substring(dotIndex + 1)
for ((libId, templates) in libraries) {
if (libId.path == libraryName || libId.toString() == libraryName) {
templates[templateName]?.let { return it }
}
}
return null
}
private fun parseTemplates(json: JsonObject, namespace: String): Map<String, TemplateDefinition> {
val result = mutableMapOf<String, TemplateDefinition>()
val templatesObj = json.getAsJsonObject("templates") ?: return result
for ((name, element) in templatesObj.entrySet()) {
val templateJson = element.asJsonObject
val inputs = mutableMapOf<String, TemplateInput>()
templateJson.getAsJsonObject("inputs")?.let { inputsObj ->
for ((inputName, inputEl) in inputsObj.entrySet()) {
val inputObj = inputEl.asJsonObject
inputs[inputName] = TemplateInput(
type = inputObj.get("type")?.asString ?: "string",
default = inputObj.get("default")?.asString
)
}
}
val extends = templateJson.get("extends")?.asString
result[name] = TemplateDefinition(
name = name,
namespace = namespace,
inputs = inputs,
extends = extends,
body = templateJson
)
}
return result
}
}
data class TemplateDefinition(
val name: String,
val namespace: String,
val inputs: Map<String, TemplateInput> = emptyMap(),
val extends: String? = null,
val body: JsonObject
)
data class TemplateInput(
val type: String = "string",
val default: String? = null
)
@@ -0,0 +1,107 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.molangui.template
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.google.gson.JsonParser
object TemplateResolver {
fun resolve(root: JsonObject, localTemplates: Map<String, TemplateDefinition>, isClient: Boolean = true): JsonObject {
return resolveNode(root, localTemplates)
}
private fun resolveNode(node: JsonObject, localTemplates: Map<String, TemplateDefinition>): JsonObject {
val result = node.deepCopy()
val controls = result.getAsJsonArray("controls")
if (controls != null) {
val resolvedControls = JsonArray()
for (element in controls) {
val controlObj = element.asJsonObject
for ((key, value) in controlObj.entrySet()) {
val resolvedWidget = resolveWidgetEntry(key, value.asJsonObject, localTemplates)
val wrapper = JsonObject()
val resolvedName = key.split("@")[0]
wrapper.add(resolvedName, resolvedWidget)
resolvedControls.add(wrapper)
}
}
result.add("controls", resolvedControls)
}
result.getAsJsonObject("item_template")?.let { itemTemplate ->
result.add("item_template", resolveNode(itemTemplate, localTemplates))
}
return result
}
private fun resolveWidgetEntry(key: String, overrides: JsonObject, localTemplates: Map<String, TemplateDefinition>): JsonObject {
val atIndex = key.indexOf('@')
if (atIndex == -1) {
return resolveNode(overrides, localTemplates)
}
val templateRef = key.substring(atIndex + 1)
val template = findTemplate(templateRef, localTemplates)
?: return resolveNode(overrides, localTemplates)
val resolved = resolveTemplateChain(template, localTemplates)
val substituted = substituteInputs(resolved, template.inputs, overrides)
val merged = mergeJson(substituted, overrides)
merged.remove("inputs")
merged.remove("extends")
return resolveNode(merged, localTemplates)
}
private fun resolveTemplateChain(template: TemplateDefinition, localTemplates: Map<String, TemplateDefinition>): JsonObject {
val extendsRef = template.extends
if (extendsRef != null) {
val parent = findTemplate(extendsRef, localTemplates)
if (parent != null) {
val parentResolved = resolveTemplateChain(parent, localTemplates)
return mergeJson(parentResolved, template.body)
}
}
return template.body.deepCopy()
}
private fun findTemplate(ref: String, localTemplates: Map<String, TemplateDefinition>): TemplateDefinition? {
localTemplates[ref]?.let { return it }
return TemplateLibraryRegistry.getTemplate(ref)
}
private fun substituteInputs(json: JsonObject, inputs: Map<String, TemplateInput>, values: JsonObject): JsonObject {
val jsonStr = json.toString()
var result = jsonStr
for ((inputName, inputDef) in inputs) {
val value = values.get(inputName)?.asString ?: inputDef.default ?: ""
result = result.replace("\"\$$inputName\"", "\"$value\"")
result = result.replace("\$$inputName", value)
}
return JsonParser.parseString(result).asJsonObject
}
private fun mergeJson(base: JsonObject, overlay: JsonObject): JsonObject {
val result = base.deepCopy()
for ((key, value) in overlay.entrySet()) {
if (key == "inputs") continue
if (value.isJsonObject && result.has(key) && result.get(key).isJsonObject) {
result.add(key, mergeJson(result.getAsJsonObject(key), value.asJsonObject))
} else {
result.add(key, value.deepCopy())
}
}
return result
}
}
@@ -0,0 +1,86 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.molangui.widget
/**
* A parsed widget definition from JSON. Represents a node in the widget tree.
* Properties are stored as WidgetProperty (static or Molang expression).
* Children are stored as named WidgetDefinitions in the controls list.
*/
data class WidgetDefinition(
val id: String,
val type: WidgetType,
val properties: Map<String, WidgetProperty> = emptyMap(),
val children: List<WidgetDefinition> = emptyList(),
val events: Map<String, String> = emptyMap(),
val clientEvents: Map<String, String> = emptyMap(),
val hoverOverrides: Map<String, WidgetProperty> = emptyMap(),
val transitions: Map<String, TransitionConfig> = emptyMap(),
val enterAnimation: AnimationConfig? = null,
val exitAnimation: AnimationConfig? = null,
val isStatic: Boolean = false
) {
fun getProperty(name: String): WidgetProperty? = properties[name]
fun getStringProperty(name: String): String? = (properties[name]?.raw as? String)
fun getAnchor(): Anchor = Anchor.fromId(getStringProperty("anchor") ?: "top_left")
}
data class TransitionConfig(
val duration: Float,
val easing: String = "ease_out"
)
data class AnimationConfig(
val type: String,
val duration: Float = 0.3f,
val easing: String = "ease_out"
)
enum class WidgetType(val id: String) {
PANEL("panel"),
LABEL("label"),
IMAGE("image"),
FILL("fill"),
BUTTON("button"),
TOGGLE("toggle"),
SLIDER("slider"),
TEXT_INPUT("text_input"),
LIST("list"),
PROGRESS_BAR("progress_bar"),
SCROLL_PANEL("scroll_panel"),
POKEMON_RENDER("pokemon_render"),
CAMERA_FEED("camera_feed"),
COMPOSITOR_OUTPUT("compositor_output"),
MOVE_DISPLAY("move_display"),
NUMBER_INPUT("number_input"),
ITEM_SLOT("item_slot"),
SLOT_GRID("slot_grid");
companion object {
private val BY_ID = entries.associateBy { it.id }
fun fromId(id: String): WidgetType? = BY_ID[id]
}
}
enum class Anchor(val id: String, val xFactor: Float, val yFactor: Float) {
TOP_LEFT("top_left", 0f, 0f),
TOP_CENTER("top_center", 0.5f, 0f),
TOP_RIGHT("top_right", 1f, 0f),
CENTER_LEFT("center_left", 0f, 0.5f),
CENTER("center", 0.5f, 0.5f),
CENTER_RIGHT("center_right", 1f, 0.5f),
BOTTOM_LEFT("bottom_left", 0f, 1f),
BOTTOM_CENTER("bottom_center", 0.5f, 1f),
BOTTOM_RIGHT("bottom_right", 1f, 1f);
companion object {
private val BY_ID = entries.associateBy { it.id }
fun fromId(id: String): Anchor = BY_ID[id] ?: TOP_LEFT
}
}
@@ -0,0 +1,44 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.molangui.widget
import com.cobblemon.mod.common.api.molangui.animation.TransitionState
import com.cobblemon.mod.common.api.molangui.layout.SizeExpression
class WidgetInstance(
val definition: WidgetDefinition,
val parent: WidgetInstance? = null
) {
var absoluteX: Float = 0f
var absoluteY: Float = 0f
var computedWidth: Float = 0f
var computedHeight: Float = 0f
var widthExpr: SizeExpression = SizeExpression.Pixels(0f)
var heightExpr: SizeExpression = SizeExpression.Pixels(0f)
var offsetXExpr: SizeExpression = SizeExpression.Pixels(0f)
var offsetYExpr: SizeExpression = SizeExpression.Pixels(0f)
val children: MutableList<WidgetInstance> = mutableListOf()
val transitions: MutableMap<String, TransitionState> = mutableMapOf()
var visible: Boolean = true
var hovered: Boolean = false
var pressed: Boolean = false
var focused: Boolean = false
var enterProgress: Float = -1f
var exitProgress: Float = -1f
var scrollOffset: Float = 0f
fun containsPoint(x: Float, y: Float): Boolean {
return x >= absoluteX && x <= absoluteX + computedWidth &&
y >= absoluteY && y <= absoluteY + computedHeight
}
}
@@ -0,0 +1,76 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.molangui.widget
import com.cobblemon.mod.common.api.molang.ExpressionLike
import com.cobblemon.mod.common.util.asExpressionLike
import com.bedrockk.molang.runtime.MoLangRuntime
/**
* A widget property that is either a static value or a Molang expression.
* String values that look like Molang (contain operators, function calls, or variable refs)
* are parsed as expressions. Plain literals are kept static.
*/
class WidgetProperty(val raw: Any) {
val expression: ExpressionLike? = when (raw) {
is String -> {
if (isMolangExpression(raw)) raw.asExpressionLike() else null
}
else -> null
}
val isStatic: Boolean get() = expression == null
fun resolveDouble(runtime: MoLangRuntime): Double {
return expression?.resolveDouble(runtime) ?: when (raw) {
is Number -> raw.toDouble()
is String -> parseNumericString(raw)
is Boolean -> if (raw) 1.0 else 0.0
else -> 0.0
}
}
fun resolveString(runtime: MoLangRuntime): String {
return expression?.resolveString(runtime) ?: raw.toString()
}
fun resolveBoolean(runtime: MoLangRuntime): Boolean {
return expression?.resolveBoolean(runtime) ?: when (raw) {
is Boolean -> raw
is Number -> raw.toDouble() != 0.0
is String -> raw.toBooleanStrictOrNull() ?: (raw.isNotEmpty())
else -> false
}
}
fun resolveInt(runtime: MoLangRuntime): Int {
if (expression == null && raw is String && raw.startsWith("0x", ignoreCase = true)) {
return raw.substring(2).toLongOrNull(16)?.toInt() ?: 0
}
return resolveDouble(runtime).toInt()
}
fun resolveFloat(runtime: MoLangRuntime): Float = resolveDouble(runtime).toFloat()
companion object {
private val MOLANG_INDICATORS = listOf("q.", "v.", "t.", "c.", "math.", "temp.", "query.", "variable.", "+", "-", "*", "/", "?", "!", "==", "!=", "<", ">", "(", "&&", "||")
fun isMolangExpression(value: String): Boolean {
return MOLANG_INDICATORS.any { value.contains(it) }
}
fun parseNumericString(value: String): Double {
value.toDoubleOrNull()?.let { return it }
if (value.startsWith("0x", ignoreCase = true)) {
value.substring(2).toLongOrNull(16)?.let { return it.toDouble() }
}
return 0.0
}
}
}
@@ -0,0 +1,155 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.molangui.widget
import com.cobblemon.mod.common.api.molangui.template.TemplateDefinition
import com.cobblemon.mod.common.api.molangui.template.TemplateInput
import com.cobblemon.mod.common.api.molangui.template.TemplateResolver
import com.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonObject
object WidgetTree {
fun parse(guiJson: JsonObject, isClient: Boolean): WidgetDefinition {
val localTemplates = parseLocalTemplates(guiJson)
val rootJson = guiJson.getAsJsonObject("root")
?: throw IllegalArgumentException("GUI definition missing 'root'")
val resolved = TemplateResolver.resolve(rootJson, localTemplates, isClient)
return parseWidget("root", resolved)
}
private fun parseLocalTemplates(guiJson: JsonObject): Map<String, TemplateDefinition> {
val result = mutableMapOf<String, TemplateDefinition>()
guiJson.getAsJsonObject("templates")?.let { templatesObj ->
for ((name, element) in templatesObj.entrySet()) {
val templateJson = element.asJsonObject
val inputs = mutableMapOf<String, TemplateInput>()
templateJson.getAsJsonObject("inputs")?.let { inputsObj ->
for ((inputName, inputEl) in inputsObj.entrySet()) {
val inputObj = inputEl.asJsonObject
inputs[inputName] = TemplateInput(
type = inputObj.get("type")?.asString ?: "string",
default = inputObj.get("default")?.asString
)
}
}
result[name] = TemplateDefinition(
name = name,
namespace = "",
inputs = inputs,
extends = templateJson.get("extends")?.asString,
body = templateJson
)
}
}
return result
}
fun parseWidget(id: String, json: JsonObject): WidgetDefinition {
val typeName = json.get("type")?.asString ?: "panel"
val type = WidgetType.fromId(typeName) ?: WidgetType.PANEL
val properties = mutableMapOf<String, WidgetProperty>()
val events = mutableMapOf<String, String>()
val clientEvents = mutableMapOf<String, String>()
var hoverOverrides = emptyMap<String, WidgetProperty>()
var transitions = emptyMap<String, TransitionConfig>()
var enterAnimation: AnimationConfig? = null
var exitAnimation: AnimationConfig? = null
val isStatic = json.get("static")?.asBoolean ?: false
val reservedKeys = setOf("type", "controls", "item_template", "static", "hover", "transition", "enter", "exit", "inputs", "extends")
for ((key, value) in json.entrySet()) {
when {
key.startsWith("on_") && key.endsWith("_client") -> clientEvents[key] = value.asString
key.startsWith("on_") -> events[key] = value.asString
key == "hover" && value.isJsonObject -> hoverOverrides = parsePropertyMap(value.asJsonObject)
key == "transition" && value.isJsonObject -> transitions = parseTransitions(value.asJsonObject)
key == "enter" -> enterAnimation = parseAnimationConfig(value)
key == "exit" -> exitAnimation = parseAnimationConfig(value)
key !in reservedKeys -> properties[key] = WidgetProperty(jsonToRaw(value))
}
}
val children = mutableListOf<WidgetDefinition>()
json.getAsJsonArray("controls")?.let { controls ->
for (element in controls) {
val controlObj = element.asJsonObject
for ((key, value) in controlObj.entrySet()) {
children.add(parseWidget(key, value.asJsonObject))
}
}
}
json.getAsJsonObject("item_template")?.let { itemTemplate ->
properties["_item_template"] = WidgetProperty(itemTemplate)
}
return WidgetDefinition(
id = id, type = type, properties = properties, children = children,
events = events, clientEvents = clientEvents, hoverOverrides = hoverOverrides,
transitions = transitions, enterAnimation = enterAnimation, exitAnimation = exitAnimation,
isStatic = isStatic
)
}
private fun parsePropertyMap(json: JsonObject): Map<String, WidgetProperty> {
return json.entrySet().associate { (key, value) -> key to WidgetProperty(jsonToRaw(value)) }
}
private fun parseTransitions(json: JsonObject): Map<String, TransitionConfig> {
val result = mutableMapOf<String, TransitionConfig>()
for ((key, value) in json.entrySet()) {
result[key] = when {
value.isJsonPrimitive && value.asJsonPrimitive.isNumber -> TransitionConfig(duration = value.asFloat)
value.isJsonObject -> {
val obj = value.asJsonObject
TransitionConfig(
duration = obj.get("duration")?.asFloat ?: 0.3f,
easing = obj.get("easing")?.asString ?: "ease_out"
)
}
else -> TransitionConfig(0.3f)
}
}
return result
}
private fun parseAnimationConfig(value: JsonElement): AnimationConfig {
return when {
value.isJsonPrimitive && value.asJsonPrimitive.isString -> AnimationConfig(type = value.asString)
value.isJsonObject -> {
val obj = value.asJsonObject
AnimationConfig(
type = obj.get("type")?.asString ?: "fade_in",
duration = obj.get("duration")?.asFloat ?: 0.3f,
easing = obj.get("easing")?.asString ?: "ease_out"
)
}
else -> AnimationConfig("fade_in")
}
}
private fun jsonToRaw(element: JsonElement): Any {
return when {
element.isJsonPrimitive -> {
val prim = element.asJsonPrimitive
when {
prim.isNumber -> prim.asNumber
prim.isBoolean -> prim.asBoolean
else -> prim.asString
}
}
element.isJsonArray -> element.asJsonArray.map { jsonToRaw(it) }
element.isJsonObject -> element.asJsonObject
else -> element.toString()
}
}
}
@@ -57,6 +57,7 @@ object ActionEffects : JsonDataRegistry<ActionEffectTimeline> {
ActionEffectKeyframe.register<RunActionEffectKeyframe>("run_action_effect")
ActionEffectKeyframe.register<EntityParticlesActionEffectKeyframe>("entity_particles")
ActionEffectKeyframe.register<EntitySoundActionEffectKeyframe>("entity_sound")
ActionEffectKeyframe.register<CameraFocusActionEffectKeyframe>("camera_focus")
}
override val gson = GsonBuilder()
@@ -0,0 +1,144 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.moves.animations
import com.bedrockk.molang.Expression
import com.cobblemon.mod.common.api.data.JsonDataRegistry
import com.cobblemon.mod.common.api.molang.ExpressionLike
import com.cobblemon.mod.common.api.moves.animations.keyframes.ActionEffectKeyframe
import com.cobblemon.mod.common.api.pokemon.stats.Stat
import com.cobblemon.mod.common.api.reactive.SimpleObservable
import com.cobblemon.mod.common.pokemon.adapters.CobblemonStatTypeAdapter
import com.cobblemon.mod.common.util.adapters.*
import com.cobblemon.mod.common.util.cobblemonResource
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
import net.minecraft.advancements.critereon.MinMaxBounds
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.level.ServerPlayer
import net.minecraft.server.packs.PackType
import net.minecraft.world.phys.AABB
import net.minecraft.world.phys.Vec3
import java.awt.Color
/**
* Data registry containing camera action effect timelines for battle moves.
*
* These camera timelines run in parallel with the main action effects timeline,
* allowing camera work to be independent from move timing and execution.
*
* Camera timeline files are stored at: `data/<namespace>/action_effects/moves/camera/<movename>_camera.json`
*
* Example structure:
* ```json
* {
* "timeline": [
* {
* "type": "camera_focus",
* "source": "USERS",
* "duration": 2
* },
* {
* "type": "pause",
* "pause": 0.5
* },
* {
* "type": "camera_focus",
* "source": "TARGETS",
* "duration": 2
* }
* ]
* }
* ```
*
* @author Terrainwax
* @since January 2025
*/
object MoveCameraTimelines : JsonDataRegistry<ActionEffectTimeline> {
override val id: ResourceLocation = cobblemonResource("move_camera_timelines")
override val type: PackType = PackType.SERVER_DATA
override val observable = SimpleObservable<MoveCameraTimelines>()
override val gson = GsonBuilder()
.disableHtmlEscaping()
.setPrettyPrinting()
.registerTypeAdapter(ActionEffectKeyframe::class.java, ActionEffectKeyframeAdapter)
.registerTypeAdapter(MinMaxBounds.Doubles::class.java, FloatNumberRangeAdapter)
.registerTypeAdapter(TypeToken.getParameterized(Collection::class.java, AABB::class.java).type, BoxCollectionAdapter)
.registerTypeAdapter(AABB::class.java, BoxAdapter)
.registerTypeAdapter(Vec3::class.java, VerboseVec3dAdapter)
.registerTypeAdapter(ResourceLocation::class.java, IdentifierAdapter)
.registerTypeAdapter(IntRange::class.java, VerboseIntRangeAdapter)
.registerTypeAdapter(Color::class.java, LiteralHexColorAdapter)
.registerTypeAdapter(Stat::class.java, CobblemonStatTypeAdapter)
.registerTypeAdapter(Expression::class.java, ExpressionAdapter)
.registerTypeAdapter(ExpressionLike::class.java, ExpressionLikeAdapter)
.registerTypeAdapter(
TypeToken.getParameterized(
TypeToken.get(List::class.java).type,
TypeToken.get(ActionEffectKeyframe::class.java).type
).type,
SingleToPluralAdapter(ActionEffectKeyframe::class.java) { it }
)
.create()
override val typeToken: TypeToken<ActionEffectTimeline> = TypeToken.get(ActionEffectTimeline::class.java)
override val resourcePath = "action_effects/moves/camera"
private val cameraTimelines = mutableMapOf<ResourceLocation, ActionEffectTimeline>()
override fun reload(data: Map<ResourceLocation, ActionEffectTimeline>) {
cameraTimelines.clear()
cameraTimelines.putAll(data)
observable.emit(this)
}
override fun sync(player: ServerPlayer) {
// Camera timelines are server-side data, no sync needed
}
/**
* Gets a camera timeline for a specific move.
*
* @param moveId The move identifier (e.g., "cobblemon:flameburst")
* @return The camera action effect timeline, or null if not found
*/
fun getCameraTimeline(moveId: ResourceLocation): ActionEffectTimeline? {
// Try direct match first (e.g., "cobblemon:flameburst_camera")
val directId = ResourceLocation(moveId.namespace, "${moveId.path}_camera")
if (cameraTimelines.containsKey(directId)) {
return cameraTimelines[directId]
}
// Try without namespace (e.g., just "flameburst_camera")
val simpleId = cobblemonResource("${moveId.path}_camera")
if (cameraTimelines.containsKey(simpleId)) {
return cameraTimelines[simpleId]
}
// Fallback to generic_move_camera if move-specific camera not found
val genericId = cobblemonResource("generic_move_camera")
return cameraTimelines[genericId]
}
/**
* Checks if a camera timeline exists for a move.
*
* @param moveId The move identifier
* @return True if a camera timeline exists for this move
*/
fun hasCameraTimeline(moveId: ResourceLocation): Boolean {
return getCameraTimeline(moveId) != null
}
/**
* Gets all registered camera timeline IDs.
*/
fun getTimelineIds(): Set<ResourceLocation> = cameraTimelines.keys.toSet()
}
@@ -0,0 +1,82 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.moves.animations.keyframes
import com.cobblemon.mod.common.api.battles.model.PokemonBattle
import com.cobblemon.mod.common.api.molang.ExpressionLike
import com.cobblemon.mod.common.api.moves.animations.ActionEffectContext
import com.cobblemon.mod.common.api.moves.animations.EntityProvider
import com.cobblemon.mod.common.api.moves.animations.TargetsProvider
import com.cobblemon.mod.common.api.moves.animations.UsersProvider
import com.cobblemon.mod.common.api.scheduling.delayedFuture
import com.cobblemon.mod.common.battles.stadium.StadiumBattleCameraCoordinator
import com.cobblemon.mod.common.battles.stadium.StadiumBattleCameraCoordinator.FocusRole
import com.cobblemon.mod.common.util.asExpressionLike
import java.util.concurrent.CompletableFuture
class CameraFocusActionEffectKeyframe : ActionEffectKeyframe {
enum class FocusSource {
USERS,
TARGETS,
ENTITIES
}
var source: FocusSource = FocusSource.USERS
var release: Boolean = false
var duration: ExpressionLike = "0".asExpressionLike()
/**
* Blend duration for smooth camera transitions.
*
* When set to a value > 0, the camera will smoothly transition to the new focus
* over this duration (in seconds) instead of snapping instantly.
*
* This helps prevent motion sickness by avoiding abrupt camera movements during battles.
*
* Recommended values:
* - 0.0: Instant snap (use for quick reactions)
* - 0.3-0.5: Fast but smooth transition (good for most moves)
* - 0.8-1.0: Slow, cinematic transition (for dramatic moments)
*
* Note: The actual transition smoothness is also limited by MAX_ROTATION_SPEED_DEG_PER_SEC
* in ClientCameraController to prevent motion sickness.
*/
var blendDuration: ExpressionLike = "0.5".asExpressionLike()
override fun play(context: ActionEffectContext): CompletableFuture<Unit> {
val battle = context.providers.filterIsInstance<PokemonBattle>().firstOrNull() ?: return skip()
if (release) {
StadiumBattleCameraCoordinator.forceRelease(battle.battleId)
return skip()
}
val entities = when (source) {
FocusSource.USERS -> context.providers.filterIsInstance<UsersProvider>().flatMap { it.entities }
FocusSource.TARGETS -> context.providers.filterIsInstance<TargetsProvider>().flatMap { it.entities }
FocusSource.ENTITIES -> context.providers.filterIsInstance<EntityProvider>().flatMap { it.entities }
}
if (entities.isEmpty()) {
return skip()
}
val focusRole = if (source == FocusSource.TARGETS) FocusRole.TARGET else FocusRole.USER
val releaseHandle = StadiumBattleCameraCoordinator.startFocus(battle, entities, focusRole)
val seconds = duration.resolveFloat(context.runtime)
if (seconds > 0f && releaseHandle != null) {
delayedFuture(seconds).whenComplete { _, _ -> releaseHandle.invoke() }
}
return CompletableFuture.completedFuture(Unit)
}
}
@@ -12,6 +12,7 @@ import com.cobblemon.mod.common.api.npc.configuration.interaction.CustomScriptNP
import com.cobblemon.mod.common.api.npc.configuration.interaction.DialogueNPCInteractionConfiguration
import com.cobblemon.mod.common.api.npc.configuration.interaction.NoneNPCInteractionConfiguration
import com.cobblemon.mod.common.api.npc.configuration.interaction.ScriptNPCInteractionConfiguration
import com.cobblemon.mod.common.api.npc.configuration.interaction.ShopNPCInteractionConfiguration
import com.cobblemon.mod.common.api.text.text
import com.cobblemon.mod.common.entity.npc.NPCEntity
import net.minecraft.nbt.CompoundTag
@@ -43,6 +44,7 @@ interface NPCInteractConfiguration {
register(type = "custom_script", displayName = "Custom Script".text(), clazz = CustomScriptNPCInteractionConfiguration::class.java)
register(type = "dialogue", displayName = "Dialogue".text(), clazz = DialogueNPCInteractionConfiguration::class.java)
register(type = "none", displayName = "None".text(), clazz = NoneNPCInteractionConfiguration::class.java)
register(type = "shop", displayName = "Shop".text(), clazz = ShopNPCInteractionConfiguration::class.java)
}
}
@@ -0,0 +1,336 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.npc.configuration.interaction
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.CobblemonMemories
import com.cobblemon.mod.common.api.dialogue.ActiveDialogue
import com.cobblemon.mod.common.api.dialogue.Dialogue
import com.cobblemon.mod.common.api.dialogue.DialogueManager
import com.cobblemon.mod.common.api.dialogue.DialoguePage
import com.cobblemon.mod.common.api.dialogue.FunctionDialogueAction
import com.cobblemon.mod.common.api.dialogue.WrappedDialogueText
import com.cobblemon.mod.common.api.dialogue.input.DialogueOption
import com.cobblemon.mod.common.api.dialogue.input.DialogueOptionSetInput
import com.cobblemon.mod.common.api.molangui.MolangUiSessionManager
import com.cobblemon.mod.common.api.npc.configuration.NPCInteractConfiguration
import com.cobblemon.mod.common.api.pokemon.PokemonProperties
import com.cobblemon.mod.common.api.pokemon.PokemonSpecies
import com.cobblemon.mod.common.api.text.text
import com.cobblemon.mod.common.api.toast.Toast
import com.cobblemon.mod.common.entity.npc.NPCEntity
import com.cobblemon.mod.common.util.DataKeys
import com.cobblemon.mod.common.util.cobblemonResource
import com.cobblemon.mod.common.util.party
import com.google.gson.Gson
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.nbt.CompoundTag
import net.minecraft.network.RegistryFriendlyByteBuf
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.level.ServerPlayer
import net.minecraft.world.item.ItemStack
import net.minecraft.world.item.Items
/**
* An [NPCInteractConfiguration] which opens a shop dialogue when the player interacts with the NPC.
* Presents "See Articles" / "Leave" options, then opens a scripted GUI shop with purchase handling.
*
* @author Cobblemon Contributors
* @since April 2026
*/
class ShopNPCInteractionConfiguration : NPCInteractConfiguration {
override val type: String = "shop"
var shopConfig: ResourceLocation = cobblemonResource("example_shop")
/**
* A shop entry that can be either a Minecraft item or a Pokémon.
* @param type "item" or "pokemon"
* @param id Item registry ID (for items) or species name (for Pokémon)
* @param price Cost in emeralds
* @param level Pokémon level (only for pokemon type, default 5)
*/
data class ShopEntry(
val type: String,
val id: String,
val price: Int,
val level: Int = 5
) {
val isItem get() = type == "item"
val isPokemon get() = type == "pokemon"
fun getDisplayName(): String {
return if (isItem) {
val itemId = ResourceLocation.tryParse(id) ?: return id
BuiltInRegistries.ITEM.get(itemId).defaultInstance.hoverName.string
} else {
PokemonSpecies.getByName(id)?.translatedName?.string ?: id.replaceFirstChar { it.uppercase() }
}
}
}
companion object {
fun loadShopItems(): List<ShopEntry> {
return listOf(
ShopEntry("item", "cobblemon:poke_ball", 3),
ShopEntry("item", "cobblemon:great_ball", 6),
ShopEntry("item", "cobblemon:ultra_ball", 12),
ShopEntry("item", "minecraft:potion", 5),
ShopEntry("pokemon", "bulbasaur", 20, level = 5),
ShopEntry("pokemon", "charmander", 20, level = 5),
ShopEntry("pokemon", "squirtle", 20, level = 5),
ShopEntry("pokemon", "pikachu", 25, level = 10),
ShopEntry("pokemon", "eevee", 30, level = 5),
)
}
}
override fun interact(npc: NPCEntity, player: ServerPlayer): Boolean {
val shopItems = loadShopItems()
val seeArticlesOption = DialogueOption(
text = WrappedDialogueText("See Articles".text()),
value = "see_articles",
action = FunctionDialogueAction { activeDialogue, _ ->
activeDialogue.close()
openShopGui(player, npc, shopItems)
}
)
val leaveOption = DialogueOption(
text = WrappedDialogueText("Leave".text()),
value = "leave",
action = FunctionDialogueAction { activeDialogue, _ ->
activeDialogue.close()
}
)
val page = DialoguePage.of(
lines = listOf("Welcome to the shop! What would you like to do?".text()),
input = DialogueOptionSetInput(
options = mutableListOf(seeArticlesOption, leaveOption),
vertical = true
)
)
val dialogue = Dialogue(
pages = listOf(page),
escapeAction = FunctionDialogueAction { activeDialogue, _ -> activeDialogue.close() }
)
val currentDialogues = npc.brain.getMemory(CobblemonMemories.DIALOGUES).orElse(mutableListOf())
val activeDialogue = DialogueManager.startDialogue(player, npc, dialogue)
val newDialogues = currentDialogues + activeDialogue
npc.brain.setMemory(CobblemonMemories.DIALOGUES, newDialogues)
activeDialogue.completion.thenRun { onDialogueStopped(npc, activeDialogue) }
return true
}
private fun openShopGui(player: ServerPlayer, npc: NPCEntity, shopItems: List<ShopEntry>) {
val guiId = cobblemonResource("npc_shop")
// Serialize shop items as a JSON array for the data-driven LIST widget
val itemsJson = JsonArray()
for (entry in shopItems) {
val obj = JsonObject()
obj.addProperty("name", entry.getDisplayName())
obj.addProperty("price", entry.price.toString())
obj.addProperty("entry_type", entry.type)
if (entry.isItem) {
obj.addProperty("item_id", entry.id)
} else {
obj.addProperty("species", entry.id)
obj.addProperty("level", entry.level.toString())
}
itemsJson.add(obj)
}
val args = mapOf(
"shop_items" to Gson().toJson(itemsJson),
"emerald_count" to countEmeralds(player).toString()
)
val session = MolangUiSessionManager.openGui(
player = player,
guiId = guiId,
args = args
)
if (session == null) {
Cobblemon.LOGGER.error("[ShopNPCInteraction] Failed to open shop GUI '$guiId' for ${player.name.string}")
return
}
// Set the emerald count variable immediately so the list page shows it
session.pushUpdate(variables = mapOf("v.emerald_count" to countEmeralds(player).toDouble()))
session.addActionListener { action, data ->
Cobblemon.LOGGER.info("[ShopNPC] Action received: action='$action' data=$data")
when (action) {
"select_item" -> {
val index = data["0"]?.toDoubleOrNull()?.toInt() ?: return@addActionListener
if (index !in shopItems.indices) return@addActionListener
val entry = shopItems[index]
val params = mutableMapOf(
"selected_item_name" to entry.getDisplayName(),
"selected_entry_type" to entry.type
)
if (entry.isItem) {
params["selected_item_id"] = entry.id
} else {
params["selected_species"] = entry.id
}
session.pushUpdate(
variables = mapOf(
"v.page" to 1.0,
"v.selected_index" to index.toDouble(),
"v.unit_price" to entry.price.toDouble(),
"v.quantity" to 1.0,
"v.emerald_count" to countEmeralds(player).toDouble()
),
parameters = params
)
}
"back" -> {
session.pushUpdate(
variables = mapOf(
"v.page" to 0.0,
"v.emerald_count" to countEmeralds(player).toDouble()
)
)
}
"purchase" -> {
val index = data["0"]?.toDoubleOrNull()?.toInt() ?: return@addActionListener
val quantity = data["1"]?.toDoubleOrNull()?.toInt() ?: return@addActionListener
if (index !in shopItems.indices || quantity <= 0) return@addActionListener
val entry = shopItems[index]
val totalCost = entry.price * quantity
val emeralds = countEmeralds(player)
if (emeralds < totalCost) {
showFeedbackToast(player, "Not enough emeralds", isError = true)
return@addActionListener
}
if (entry.isItem) {
if (!hasInventorySpace(player)) {
showFeedbackToast(player, "Inventory full", isError = true)
return@addActionListener
}
removeEmeralds(player, totalCost)
val itemId = ResourceLocation.tryParse(entry.id) ?: return@addActionListener
giveItems(player, itemId, quantity)
} else {
val party = player.party()
if (party.occupied() + quantity > 6) {
showFeedbackToast(player, "Party is full", isError = true)
return@addActionListener
}
removeEmeralds(player, totalCost)
repeat(quantity) {
val pokemon = PokemonProperties.parse("${entry.id} level=${entry.level}").create()
party.add(pokemon)
}
}
session.pushUpdate(
variables = mapOf(
"v.emerald_count" to countEmeralds(player).toDouble(),
"v.quantity" to 1.0
)
)
showFeedbackToast(player, "Purchased ${quantity}x ${entry.getDisplayName()}", isError = false)
}
}
}
}
private fun showFeedbackToast(player: ServerPlayer, message: String, isError: Boolean) {
val toast = Toast(
title = "Shop".text(),
description = message.text(),
icon = ItemStack(Items.EMERALD)
)
toast.addListeners(player)
toast.expireAfter(3f)
}
private fun countEmeralds(player: ServerPlayer): Int {
var count = 0
for (i in 0 until player.inventory.containerSize) {
val stack = player.inventory.getItem(i)
if (stack.`is`(Items.EMERALD)) {
count += stack.count
}
}
return count
}
private fun removeEmeralds(player: ServerPlayer, amount: Int) {
var remaining = amount
for (i in 0 until player.inventory.containerSize) {
if (remaining <= 0) break
val stack = player.inventory.getItem(i)
if (stack.`is`(Items.EMERALD)) {
val toRemove = minOf(remaining, stack.count)
stack.shrink(toRemove)
remaining -= toRemove
}
}
}
private fun hasInventorySpace(player: ServerPlayer): Boolean {
return player.inventory.freeSlot != -1
}
private fun giveItems(player: ServerPlayer, itemId: ResourceLocation, quantity: Int) {
val item = BuiltInRegistries.ITEM.get(itemId)
var remaining = quantity
while (remaining > 0) {
val stackSize = minOf(remaining, item.defaultMaxStackSize)
val stack = ItemStack(item, stackSize)
player.inventory.add(stack)
remaining -= stackSize
}
}
private fun onDialogueStopped(npc: NPCEntity, activeDialogue: ActiveDialogue) {
val currentDialogues = npc.brain.getMemory(CobblemonMemories.DIALOGUES).orElse(mutableListOf())
val newDialogues = currentDialogues - activeDialogue
if (newDialogues.isEmpty()) {
npc.brain.eraseMemory(CobblemonMemories.DIALOGUES)
} else {
npc.brain.setMemory(CobblemonMemories.DIALOGUES, newDialogues)
}
}
override fun encode(buffer: RegistryFriendlyByteBuf) {
buffer.writeResourceLocation(shopConfig)
}
override fun decode(buffer: RegistryFriendlyByteBuf) {
shopConfig = buffer.readResourceLocation()
}
override fun writeToNBT(compoundTag: CompoundTag) {
compoundTag.putString(DataKeys.NPC_INTERACT_SHOP, shopConfig.toString())
}
override fun readFromNBT(compoundTag: CompoundTag) {
shopConfig = ResourceLocation.parse(compoundTag.getString(DataKeys.NPC_INTERACT_SHOP))
}
override fun isDifferentTo(other: NPCInteractConfiguration): Boolean {
return other !is ShopNPCInteractionConfiguration || other.shopConfig != shopConfig
}
}
@@ -71,6 +71,8 @@ object CobblemonPermissions {
@JvmStatic
val STOP_BATTLE = this.create("${COMMAND_PREFIX}stopbattle", PermissionLevel.CHEAT_COMMANDS_AND_COMMAND_BLOCKS)
@JvmStatic
val STADIUM_BATTLE = this.create("${COMMAND_PREFIX}stadiumbattle", PermissionLevel.CHEAT_COMMANDS_AND_COMMAND_BLOCKS)
@JvmStatic
@@ -18,6 +18,15 @@ open class EventObservable<T> : SimpleObservable<T>() {
emit(*events)
events.forEach(then)
}
@JvmOverloads
fun postEvents(
vararg events: T,
then: ((T) -> Unit)? = null
): Unit {
post(*events) { then?.invoke(it) }
}
}
open class CancelableObservable<T : Cancelable> : EventObservable<T>() {
@@ -0,0 +1,16 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.tween
/**
* A function that maps a linear progress value [0-1] to an eased value.
*/
fun interface EasingFunction {
fun ease(t: Float): Float
}
@@ -0,0 +1,230 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.tween
import com.cobblemon.mod.common.Cobblemon
import kotlin.math.*
/**
* Registry for all easing functions. Replaces the former CameraEasing, CameraPluginRegistry,
* and IEasingFunction — providing a single, common-side easing registry usable by the camera
* system, tween engine, and any other animation system.
*
* Third-party mods can register custom easing functions via [registerEasingFunction].
*/
object EasingRegistry {
private val easingFunctions = mutableMapOf<String, EasingFunction>()
fun registerBuiltinEasings() {
registerEasingFunction("linear", EasingFunction { linear(it) })
registerEasingFunction("step", EasingFunction { step(it) })
registerEasingFunction("ease_in_sine", EasingFunction { easeInSine(it) })
registerEasingFunction("ease_out_sine", EasingFunction { easeOutSine(it) })
registerEasingFunction("ease_in_out_sine", EasingFunction { easeInOutSine(it) })
registerEasingFunction("ease_in_quad", EasingFunction { easeInQuad(it) })
registerEasingFunction("ease_out_quad", EasingFunction { easeOutQuad(it) })
registerEasingFunction("ease_in_out_quad", EasingFunction { easeInOutQuad(it) })
registerEasingFunction("ease_in_cubic", EasingFunction { easeInCubic(it) })
registerEasingFunction("ease_out_cubic", EasingFunction { easeOutCubic(it) })
registerEasingFunction("ease_in_out_cubic", EasingFunction { easeInOutCubic(it) })
registerEasingFunction("ease_in_quart", EasingFunction { easeInQuart(it) })
registerEasingFunction("ease_out_quart", EasingFunction { easeOutQuart(it) })
registerEasingFunction("ease_in_out_quart", EasingFunction { easeInOutQuart(it) })
registerEasingFunction("ease_in_quint", EasingFunction { easeInQuint(it) })
registerEasingFunction("ease_out_quint", EasingFunction { easeOutQuint(it) })
registerEasingFunction("ease_in_out_quint", EasingFunction { easeInOutQuint(it) })
registerEasingFunction("ease_in_expo", EasingFunction { easeInExpo(it) })
registerEasingFunction("ease_out_expo", EasingFunction { easeOutExpo(it) })
registerEasingFunction("ease_in_out_expo", EasingFunction { easeInOutExpo(it) })
registerEasingFunction("ease_in_circ", EasingFunction { easeInCirc(it) })
registerEasingFunction("ease_out_circ", EasingFunction { easeOutCirc(it) })
registerEasingFunction("ease_in_out_circ", EasingFunction { easeInOutCirc(it) })
registerEasingFunction("ease_in_back", EasingFunction { easeInBack(it) })
registerEasingFunction("ease_out_back", EasingFunction { easeOutBack(it) })
registerEasingFunction("ease_in_out_back", EasingFunction { easeInOutBack(it) })
registerEasingFunction("ease_in_elastic", EasingFunction { easeInElastic(it) })
registerEasingFunction("ease_out_elastic", EasingFunction { easeOutElastic(it) })
registerEasingFunction("ease_in_out_elastic", EasingFunction { easeInOutElastic(it) })
registerEasingFunction("ease_in_bounce", EasingFunction { easeInBounce(it) })
registerEasingFunction("ease_out_bounce", EasingFunction { easeOutBounce(it) })
registerEasingFunction("ease_in_out_bounce", EasingFunction { easeInOutBounce(it) })
Cobblemon.LOGGER.info("[EasingRegistry] Registered built-in easing functions")
}
fun registerEasingFunction(name: String, function: EasingFunction) {
val normalizedName = name.lowercase()
if (easingFunctions.containsKey(normalizedName)) {
Cobblemon.LOGGER.warn("[EasingRegistry] Overwriting easing function '$name'")
}
easingFunctions[normalizedName] = function
}
/**
* Returns an easing function by name. Falls back to linear with a warning for unknown names.
* Supports both snake_case ("ease_out_quad") and camelCase ("easeOutQuad") lookups.
*/
fun getEasingFunction(name: String): (Float) -> Float {
val normalized = name.lowercase()
val registered = easingFunctions[normalized]
if (registered != null) {
return { t -> registered.ease(t) }
}
// Legacy camelCase fallback
return when (normalized) {
"linear" -> ::linear
"step" -> ::step
"easeinsine" -> ::easeInSine
"easeoutsine" -> ::easeOutSine
"easeinoutsine" -> ::easeInOutSine
"easeinquad" -> ::easeInQuad
"easeoutquad" -> ::easeOutQuad
"easeinoutquad" -> ::easeInOutQuad
"easeincubic" -> ::easeInCubic
"easeoutcubic" -> ::easeOutCubic
"easeinoutcubic" -> ::easeInOutCubic
"easeinquart" -> ::easeInQuart
"easeoutquart" -> ::easeOutQuart
"easeinoutquart" -> ::easeInOutQuart
"easeinquint" -> ::easeInQuint
"easeoutquint" -> ::easeOutQuint
"easeinoutquint" -> ::easeInOutQuint
"easeinexpo" -> ::easeInExpo
"easeoutexpo" -> ::easeOutExpo
"easeinoutexpo" -> ::easeInOutExpo
"easeincirc" -> ::easeInCirc
"easeoutcirc" -> ::easeOutCirc
"easeinoutcirc" -> ::easeInOutCirc
"easeinback" -> ::easeInBack
"easeoutback" -> ::easeOutBack
"easeinoutback" -> ::easeInOutBack
"easeinelastic" -> ::easeInElastic
"easeoutelastic" -> ::easeOutElastic
"easeinoutelastic" -> ::easeInOutElastic
"easeinbounce" -> ::easeInBounce
"easeoutbounce" -> ::easeOutBounce
"easeinoutbounce" -> ::easeInOutBounce
else -> {
Cobblemon.LOGGER.warn("[EasingRegistry] Unknown easing function '$name', using linear")
::linear
}
}
}
fun linear(t: Float): Float = t
fun step(t: Float): Float = if (t >= 1f) 1f else 0f
fun easeInSine(t: Float): Float = 1 - cos((t * PI) / 2).toFloat()
fun easeOutSine(t: Float): Float = sin((t * PI) / 2).toFloat()
fun easeInOutSine(t: Float): Float = -(cos(PI * t) - 1).toFloat() / 2
fun easeInQuad(t: Float): Float = t * t
fun easeOutQuad(t: Float): Float = 1 - (1 - t) * (1 - t)
fun easeInOutQuad(t: Float): Float {
return if (t < 0.5f) 2 * t * t
else 1 - (-2 * t + 2).pow(2) / 2
}
fun easeInCubic(t: Float): Float = t * t * t
fun easeOutCubic(t: Float): Float = 1 - (1 - t).pow(3)
fun easeInOutCubic(t: Float): Float {
return if (t < 0.5f) 4 * t * t * t
else 1 - (-2 * t + 2).pow(3) / 2
}
fun easeInQuart(t: Float): Float = t * t * t * t
fun easeOutQuart(t: Float): Float = 1 - (1 - t).pow(4)
fun easeInOutQuart(t: Float): Float {
return if (t < 0.5f) 8 * t * t * t * t
else 1 - (-2 * t + 2).pow(4) / 2
}
fun easeInQuint(t: Float): Float = t * t * t * t * t
fun easeOutQuint(t: Float): Float = 1 - (1 - t).pow(5)
fun easeInOutQuint(t: Float): Float {
return if (t < 0.5f) 16 * t * t * t * t * t
else 1 - (-2 * t + 2).pow(5) / 2
}
fun easeInExpo(t: Float): Float {
return if (t == 0f) 0f else 2f.pow(10 * t - 10)
}
fun easeOutExpo(t: Float): Float {
return if (t == 1f) 1f else 1 - 2f.pow(-10 * t)
}
fun easeInOutExpo(t: Float): Float {
return when {
t == 0f -> 0f
t == 1f -> 1f
t < 0.5f -> 2f.pow(20 * t - 10) / 2
else -> (2 - 2f.pow(-20 * t + 10)) / 2
}
}
fun easeInCirc(t: Float): Float = 1 - sqrt(1 - t.pow(2))
fun easeOutCirc(t: Float): Float = sqrt(1 - (t - 1).pow(2))
fun easeInOutCirc(t: Float): Float {
return if (t < 0.5f) (1 - sqrt(1 - (2 * t).pow(2))) / 2
else (sqrt(1 - (-2 * t + 2).pow(2)) + 1) / 2
}
private const val c1 = 1.70158f
private const val c2 = c1 * 1.525f
private const val c3 = c1 + 1
fun easeInBack(t: Float): Float = c3 * t * t * t - c1 * t * t
fun easeOutBack(t: Float): Float = 1 + c3 * (t - 1).pow(3) + c1 * (t - 1).pow(2)
fun easeInOutBack(t: Float): Float {
return if (t < 0.5f) ((2 * t).pow(2) * ((c2 + 1) * 2 * t - c2)) / 2
else ((2 * t - 2).pow(2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2
}
private const val c4 = (2 * PI) / 3
private const val c5 = (2 * PI) / 4.5
fun easeInElastic(t: Float): Float {
return when {
t == 0f -> 0f
t == 1f -> 1f
else -> -2f.pow(10 * t - 10) * sin((t * 10 - 10.75) * c4).toFloat()
}
}
fun easeOutElastic(t: Float): Float {
return when {
t == 0f -> 0f
t == 1f -> 1f
else -> 2f.pow(-10 * t) * sin((t * 10 - 0.75) * c4).toFloat() + 1
}
}
fun easeInOutElastic(t: Float): Float {
return when {
t == 0f -> 0f
t == 1f -> 1f
t < 0.5f -> -(2f.pow(20 * t - 10) * sin((20 * t - 11.125) * c5).toFloat()) / 2
else -> (2f.pow(-20 * t + 10) * sin((20 * t - 11.125) * c5).toFloat()) / 2 + 1
}
}
private const val n1 = 7.5625f
private const val d1 = 2.75f
fun easeOutBounce(t: Float): Float {
return when {
t < 1 / d1 -> n1 * t * t
t < 2 / d1 -> n1 * (t - 1.5f / d1) * (t - 1.5f / d1) + 0.75f
t < 2.5 / d1 -> n1 * (t - 2.25f / d1) * (t - 2.25f / d1) + 0.9375f
else -> n1 * (t - 2.625f / d1) * (t - 2.625f / d1) + 0.984375f
}
}
fun easeInBounce(t: Float): Float = 1 - easeOutBounce(1 - t)
fun easeInOutBounce(t: Float): Float {
return if (t < 0.5f) (1 - easeOutBounce(1 - 2 * t)) / 2
else (1 + easeOutBounce(2 * t - 1)) / 2
}
}
@@ -0,0 +1,31 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.tween
import net.minecraft.util.Mth
fun Float.asInterpolatable(): FloatInterpolatable = FloatInterpolatable(this)
data class FloatInterpolatable(val value: Float) : Interpolatable<FloatInterpolatable> {
override fun lerp(other: FloatInterpolatable, t: Float): FloatInterpolatable {
return FloatInterpolatable(Mth.lerp(t, value, other.value))
}
override fun add(other: FloatInterpolatable): FloatInterpolatable {
return FloatInterpolatable(value + other.value)
}
override fun subtract(other: FloatInterpolatable): FloatInterpolatable {
return FloatInterpolatable(value - other.value)
}
override fun scale(scalar: Float): FloatInterpolatable {
return FloatInterpolatable(value * scalar)
}
}
@@ -0,0 +1,16 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.tween
interface Interpolatable<T> {
fun lerp(other: T, t: Float): T
fun add(other: T): T
fun subtract(other: T): T
fun scale(scalar: Float): T
}
@@ -0,0 +1,22 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.tween
enum class InterpolationMethod {
LINEAR,
/** Catmull-Rom spline (smooth curves through all points). */
BEZIER,
/** Hermite spline with tangents. */
HERMITE,
/** Cubic Bezier (can be extended with control points). */
CUBIC_BEZIER
}
@@ -0,0 +1,185 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.tween
object Interpolator {
fun <T> interpolate(
keyframes: List<Keyframe<T>>,
time: Float,
method: InterpolationMethod,
defaultEasing: String = "linear",
getEasing: ((T) -> String?)? = null
): T? where T : Interpolatable<T> {
if (keyframes.isEmpty()) return null
if (keyframes.size == 1) return keyframes[0].value
return when (method) {
InterpolationMethod.LINEAR -> interpolateLinear(keyframes, time, defaultEasing, getEasing)
InterpolationMethod.BEZIER -> interpolateBezier(keyframes, time, defaultEasing, getEasing)
InterpolationMethod.HERMITE -> interpolateHermite(keyframes, time, defaultEasing, getEasing)
InterpolationMethod.CUBIC_BEZIER -> interpolateCubicBezier(keyframes, time, defaultEasing, getEasing)
}
}
/**
* @return Segment with normalized progress [0-1], or null if keyframes is empty
*/
fun <T> findSegment(
keyframes: List<Keyframe<T>>,
time: Float,
defaultEasing: String = "linear",
getEasing: ((T) -> String?)? = null
): Segment<T>? {
if (keyframes.isEmpty()) return null
if (keyframes.size == 1) {
val kf = keyframes[0]
return Segment(
startValue = kf.value,
endValue = kf.value,
startTime = kf.time,
endTime = kf.time,
progress = 1f,
easing = defaultEasing
)
}
for (i in 0 until keyframes.size - 1) {
val start = keyframes[i]
val end = keyframes[i + 1]
if (time <= end.time) {
val duration = end.time - start.time
val progress = if (duration > 0f) {
((time - start.time) / duration).coerceIn(0f, 1f)
} else 1f
val easing = getEasing?.invoke(end.value) ?: defaultEasing
return Segment(
startValue = start.value,
endValue = end.value,
startTime = start.time,
endTime = end.time,
progress = progress,
easing = easing
)
}
}
val last = keyframes.last()
return Segment(
startValue = last.value,
endValue = last.value,
startTime = last.time,
endTime = last.time,
progress = 1f,
easing = defaultEasing
)
}
private fun <T> interpolateLinear(
keyframes: List<Keyframe<T>>,
time: Float,
defaultEasing: String,
getEasing: ((T) -> String?)?
): T where T : Interpolatable<T> {
val segment = findSegment(keyframes, time, defaultEasing, getEasing)!!
val easedProgress = EasingRegistry.getEasingFunction(segment.easing)(segment.progress)
return segment.startValue.lerp(segment.endValue, easedProgress)
}
private fun <T> interpolateBezier(
keyframes: List<Keyframe<T>>,
time: Float,
defaultEasing: String,
getEasing: ((T) -> String?)?
): T where T : Interpolatable<T> {
if (keyframes.size < 2) return keyframes.first().value
val segmentIndex = keyframes.indexOfLast { it.time <= time }
.coerceIn(0, keyframes.size - 2)
val p0 = if (segmentIndex > 0) keyframes[segmentIndex - 1].value else keyframes[segmentIndex].value
val p1 = keyframes[segmentIndex].value
val p2 = keyframes[segmentIndex + 1].value
val p3 = if (segmentIndex < keyframes.size - 2) keyframes[segmentIndex + 2].value else p2
val t1 = keyframes[segmentIndex].time
val t2 = keyframes[segmentIndex + 1].time
val duration = t2 - t1
val localProgress = if (duration > 0f) {
((time - t1) / duration).coerceIn(0f, 1f)
} else 0f
val easing = getEasing?.invoke(p2) ?: defaultEasing
val t = EasingRegistry.getEasingFunction(easing)(localProgress)
return catmullRom(p0, p1, p2, p3, t)
}
private fun <T> interpolateHermite(
keyframes: List<Keyframe<T>>,
time: Float,
defaultEasing: String,
getEasing: ((T) -> String?)?
): T where T : Interpolatable<T> {
val segment = findSegment(keyframes, time, defaultEasing, getEasing)!!
val easedProgress = EasingRegistry.getEasingFunction(segment.easing)(segment.progress)
val segmentIndex = keyframes.indexOfFirst { it.time == segment.startTime }
val prevValue = if (segmentIndex > 0) keyframes[segmentIndex - 1].value else segment.startValue
val nextValue = if (segmentIndex < keyframes.size - 2) keyframes[segmentIndex + 2].value else segment.endValue
val tangent1 = segment.endValue.subtract(prevValue).scale(0.5f)
val tangent2 = nextValue.subtract(segment.startValue).scale(0.5f)
return hermite(segment.startValue, segment.endValue, tangent1, tangent2, easedProgress)
}
private fun <T> interpolateCubicBezier(
keyframes: List<Keyframe<T>>,
time: Float,
defaultEasing: String,
getEasing: ((T) -> String?)?
): T where T : Interpolatable<T> {
return interpolateBezier(keyframes, time, defaultEasing, getEasing)
}
private fun <T> catmullRom(p0: T, p1: T, p2: T, p3: T, t: Float): T where T : Interpolatable<T> {
val t2 = t * t
val t3 = t2 * t
val c0 = -0.5f * t3 + 1.0f * t2 - 0.5f * t
val c1 = 1.5f * t3 - 2.5f * t2 + 1.0f
val c2 = -1.5f * t3 + 2.0f * t2 + 0.5f * t
val c3 = 0.5f * t3 - 0.5f * t2
return p0.scale(c0)
.add(p1.scale(c1))
.add(p2.scale(c2))
.add(p3.scale(c3))
}
private fun <T> hermite(p0: T, p1: T, m0: T, m1: T, t: Float): T where T : Interpolatable<T> {
val t2 = t * t
val t3 = t2 * t
val h00 = 2f * t3 - 3f * t2 + 1f
val h10 = t3 - 2f * t2 + t
val h01 = -2f * t3 + 3f * t2
val h11 = t3 - t2
return p0.scale(h00)
.add(m0.scale(h10))
.add(p1.scale(h01))
.add(m1.scale(h11))
}
}
@@ -0,0 +1,25 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.tween
data class Keyframe<T>(
val time: Float,
val value: T
)
data class Segment<T>(
val startValue: T,
val endValue: T,
val startTime: Float,
val endTime: Float,
val progress: Float,
val easing: String
) {
val duration: Float get() = endTime - startTime
}
@@ -0,0 +1,18 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.tween
interface Tickable {
val isActive: Boolean
/** @param dt delta time in seconds (not game ticks). */
fun tick(dt: Float)
fun cancel()
}
@@ -0,0 +1,89 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.tween
/**
* Usage:
* ```kotlin
* val tween = Tween.of(FloatInterpolatable(0f))
* .to(FloatInterpolatable(1f), 2f, "ease_out_quad")
* .onUpdate { value -> entity.setOpacity(value.value) }
* .onComplete { println("done") }
* .build()
*
* TweenManager.client.add(tween)
* ```
*/
class Tween<T : Interpolatable<T>> private constructor(
private val start: T,
private val end: T,
private val duration: Float,
private val easingName: String,
private val onUpdate: ((T) -> Unit)?,
private val onComplete: (() -> Unit)?
) : Tickable {
private var elapsed = 0f
private var cancelled = false
var current: T = start
private set
override val isActive: Boolean
get() = !cancelled && elapsed < duration
override fun tick(dt: Float) {
if (!isActive) return
elapsed = (elapsed + dt).coerceAtMost(duration)
val progress = if (duration > 0f) elapsed / duration else 1f
val easedProgress = EasingRegistry.getEasingFunction(easingName)(progress)
current = start.lerp(end, easedProgress)
onUpdate?.invoke(current)
if (elapsed >= duration) {
onComplete?.invoke()
}
}
override fun cancel() {
cancelled = true
}
class Builder<T : Interpolatable<T>>(private val start: T) {
private var end: T = start
private var duration: Float = 1f
private var easingName: String = "linear"
private var onUpdate: ((T) -> Unit)? = null
private var onComplete: (() -> Unit)? = null
fun to(end: T, duration: Float, easing: String = "linear"): Builder<T> {
this.end = end
this.duration = duration
this.easingName = easing
return this
}
fun onUpdate(callback: (T) -> Unit): Builder<T> {
this.onUpdate = callback
return this
}
fun onComplete(callback: () -> Unit): Builder<T> {
this.onComplete = callback
return this
}
fun build(): Tween<T> = Tween(start, end, duration, easingName, onUpdate, onComplete)
}
companion object {
fun <T : Interpolatable<T>> of(start: T): Builder<T> = Builder(start)
}
}
@@ -0,0 +1,37 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.tween
import net.minecraft.world.phys.Vec3
fun tweenFloat(
from: Float,
to: Float,
duration: Float,
easing: String = "linear",
onUpdate: (Float) -> Unit
): Tween<FloatInterpolatable> {
return Tween.of(FloatInterpolatable(from))
.to(FloatInterpolatable(to), duration, easing)
.onUpdate { onUpdate(it.value) }
.build()
}
fun tweenVec3(
from: Vec3,
to: Vec3,
duration: Float,
easing: String = "linear",
onUpdate: (Vec3) -> Unit
): Tween<Vec3Interpolatable> {
return Tween.of(Vec3Interpolatable(from))
.to(Vec3Interpolatable(to), duration, easing)
.onUpdate { onUpdate(it.vec) }
.build()
}
@@ -0,0 +1,62 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.tween
/**
* Manages the lifecycle of [Tickable] instances (tweens, track players, etc.).
*
* Two singletons are provided:
* - [server] — ticked from SERVER_TICK_POST
* - [client] — ticked from GameRendererMixin at render frame rate
*/
class TweenManager {
private val tickables = mutableListOf<Tickable>()
private val pending = mutableListOf<Tickable>()
/** Safe to call during update (additions are deferred). */
fun add(tickable: Tickable) {
pending.add(tickable)
}
fun update(dt: Float) {
if (pending.isNotEmpty()) {
tickables.addAll(pending)
pending.clear()
}
val iterator = tickables.iterator()
while (iterator.hasNext()) {
val tickable = iterator.next()
if (tickable.isActive) {
tickable.tick(dt)
}
if (!tickable.isActive) {
iterator.remove()
}
}
}
fun clear() {
tickables.forEach { it.cancel() }
tickables.clear()
pending.forEach { it.cancel() }
pending.clear()
}
val size: Int get() = tickables.size + pending.size
companion object {
/** Ticked at tps rate. */
val server = TweenManager()
/** Ticked at render frame rate. */
val client = TweenManager()
}
}
@@ -0,0 +1,36 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.api.tween
import net.minecraft.util.Mth
import net.minecraft.world.phys.Vec3
fun Vec3.asInterpolatable(): Vec3Interpolatable = Vec3Interpolatable(this)
data class Vec3Interpolatable(val vec: Vec3) : Interpolatable<Vec3Interpolatable> {
override fun lerp(other: Vec3Interpolatable, t: Float): Vec3Interpolatable {
return Vec3Interpolatable(Vec3(
Mth.lerp(t.toDouble(), vec.x, other.vec.x),
Mth.lerp(t.toDouble(), vec.y, other.vec.y),
Mth.lerp(t.toDouble(), vec.z, other.vec.z)
))
}
override fun add(other: Vec3Interpolatable): Vec3Interpolatable {
return Vec3Interpolatable(vec.add(other.vec))
}
override fun subtract(other: Vec3Interpolatable): Vec3Interpolatable {
return Vec3Interpolatable(vec.subtract(other.vec))
}
override fun scale(scalar: Float): Vec3Interpolatable {
return Vec3Interpolatable(vec.scale(scalar.toDouble()))
}
}

Some files were not shown because too many files have changed in this diff Show More