9.7 KiB
Terrarum
A modular 2D side-scrolling tilemap platformer engine and game, built on LibGDX with Kotlin/Java. GPL-3.0.
Build & Run
- IDE: IntelliJ IDEA (project files:
Terrarum_renewed.iml,TerrarumBuild.iml) - JDK: 21
- Language: Kotlin + Java mixed; Kotlin is primary,
App.javaandFrameBufferManager.javaare Java - Dependencies: All libraries are in
lib/(fat-jar approach, no Maven/Gradle). Includes LibGDX, GraalVM JS (modified), dyn4j (Vector2 only), custom bitmap font lib - Entry point:
net.torvald.terrarum.Principii.main()(Java) which launchesApp(the LibGDXApplicationListener) - Distribution:
buildapp/scripts produce per-platform bundles with jlink'd runtimes - Assets:
assets/for development,assets_release/for distribution. Custom.tevdarchive format for release assets (AssetCache.kt)
Project Structure
src/net/torvald/terrarum/
App.java -- Main application class (LibGDX ApplicationListener). GL thread, render loop, splash screen
CommonResourcePool.kt -- Thread-safe GL resource loading with dispatch queues
ModMgr.kt -- Module manager. Scans/loads game modules, provides getGdxFile/getJavaClass
IngameInstance.kt -- Base class for game screens (show/hide/render/resize lifecycle)
Terrarum.kt -- Game-level singleton (ingame instance management, extension functions)
GameUpdateGovernor.kt -- Update/render tick governors (ConsistentUpdateRate, Anarchy)
MusicService.kt -- Music playback management
modulebasegame/
EntryPoint.kt -- Basegame module entry point (registers items, fixtures, blocks, weather)
TitleScreen.kt -- Title screen (demo world rendering, async loading)
TerrarumIngame.kt -- Main gameplay screen
IngameRenderer.kt -- World rendering pipeline (FBO composition, lightmap, blur, shadows)
BuildingMaker.kt -- Building editor screen
worlddrawer/
WorldCamera.kt -- Camera position tracking (follows player, interpolated movement)
LightmapRenderer.kt -- Per-tile RGB+UV light calculation and rasterisation
BlocksDrawer.kt -- Tile rendering
FeaturesDrawer.kt -- Wire/feature overlay rendering
gamecontroller/
IME.kt -- Input Method Engine (GraalVM JS keyboard layouts, loaded on daemon thread)
weather/
WeatherMixer.kt -- Weather state machine, skybox rendering
SkyboxModelHosek.kt -- Physically-based sky model
Architecture: Threading & GL Dispatch
All OpenGL calls (Texture, ShaderProgram, FrameBuffer creation) must happen on the GL thread. The codebase uses a dispatch mechanism to safely create GL resources from background threads.
CommonResourcePool (the dispatch hub)
Background Thread GL Thread (App.render)
| |
|-- addToLoadingList("name", { Texture() })|
|-- loadAll() |
| | |
| +-- if on GL thread: run directly |
| +-- if not: enqueue to |
| glDispatchQueue + latch.await() |
| CommonResourcePool.update()
| |-- poll glDispatchQueue
| | run loadfun, latch.countDown()
| |-- poll glRunnableQueue
| | run block, latch.countDown()
| |-- poll slowLoadingQueue (one per tick)
| |
+-- latch released, continues <------------+
Key methods:
loadAll()-- batch load; blocks background thread until GL thread processesloadAllSlowly()-- timesliced; one resource per tick viaupdate()runOnGLThread { }-- run arbitrary block on GL thread, blocking callerupdate()-- called every tick fromApp.render(), processes all queuesgetOrPut(name, loadfun, killfun)-- thread-safe get-or-create with GL dispatch
App.java Boot Sequence
create()
-> postInit() [GL thread, synchronous]
|-- load shaders, fonts, audio device, IME
|-- CommonResourcePool.setGLThread(currentThread)
|-- spawn Terrarum-PostInitLoader thread:
| ModMgr.invoke() // module entry points (dispatched to GL via runOnGLThread)
| CommonResourcePool.loadAllSlowly() // timesliced resource loading
| loadingThreadDone = true
+-- return (non-blocking)
render() loop [GL thread]
while currentScreen == null:
|-- drawSplash()
|-- CommonResourcePool.update() // process GL dispatch queues
|-- when loadingThreadDone && loaded:
| postLoadInit() // tile atlas, audio mixer, Terrarum.initialise()
| setScreen(titleScreen) // transitions to title screen
TitleScreen Async Loading
TitleScreen.show() returns immediately after starting a background thread. The splash screen continues displaying until all loading completes.
show()
|-- quick GL setup (viewport, input processor, FBO, savegame list)
|-- spawn Terrarum-TitleScreenLoader thread:
| load demo world, compute camera nodes, bogoflops, audio reset
| backgroundLoadDone = true
+-- return
renderImpl() [GL thread, every frame]
if !loadDone:
|-- App.drawSplash()
|-- processGLLoadStep() // state machine, one step per frame:
| 0: wait for backgroundLoadDone
| 1: SkyboxModelHosek.loadlut()
| 2: IngameRenderer.setRenderedWorld + WeatherMixer init
| 3: load halfgrad texture
| 4: UIFakeGradOverlay
| 5: UIFakeBlurOverlay
| 6: UIRemoCon
| 7: MusicService.enterScene, gameUpdateGovernor.reset(), loadDone=true
else:
normal title screen rendering (world + UI via IngameRenderer)
IME Loading
IME.kt object init {} spawns a Terrarum-IMELoader daemon thread for GraalVM JS context binding and key layout/IME file scanning. Icon texture loading remains synchronous (requires GL thread).
ModMgr Class Initialisation
ModMgr.kt object init {} only does metadata loading and class instantiation (fast). The heavy invoke() method runs entry points wrapped in CommonResourcePool.runOnGLThread { } to avoid GL calls on the wrong thread. This separation prevents <clinit> lock deadlocks where a background thread holding the class init lock blocks on a GL dispatch latch while the GL thread tries to access the same class.
Architecture: Rendering Pipeline
IngameRenderer
Singleton object. Lazily initialised on first invoke() call via invokeInit().
Render path (per frame):
LightmapRenderer.recalculate()-- every 3 frames, computes per-tile RGBA light valuesprepLightmapRGBA()-- Kawase blur on lightmap intolightmapFboBlocksDrawer.renderData()-- prepare tile draw datadrawToRGB()-- render world tiles + actors intofboRGB(with shadow FBOs)drawToA()-- render alpha/glow channel- Composite: sky -> world -> light multiply -> emissive blend -> vibrancy -> UI
- Output to
App.renderFBO, thenTerrarumPostProcessor.draw()applies final effects
Critical ordering in resize(): BlocksDrawer.resize() and LightmapRenderer.resize() must be called before lightmapFbo creation, because lightmapFbo dimensions derive from LightmapRenderer.lightBuffer size.
WorldCamera
Follows an ActorWithBody (player or camera actor). Position interpolated per frame. WorldCamera.x/y = top-left corner of visible area. gdxCamX/Y = centre of visible area. moveCameraToWorldCoord() positions the IngameRenderer camera for world-space drawing; setCameraPosition(0,0) positions it for screen-space drawing.
FBO Extension Functions
FrameBuffer.inAction(camera, batch) { }-- bind FBO, set camera to FBO dimensions (Y-down), run block, restore camera to screen dimensionsFrameBuffer.inActionF(camera, batch) { }-- same but Y-up (for flipped FBO content)
Both save/restore camera.position and call setToOrtho with App.scr.wf/hf on exit.
Key Conventions
- GL thread safety: Any code creating
Texture,TextureRegion,TextureRegionPack,ShaderProgram,FrameBuffer, orPixmapmust run on the GL thread. UseCommonResourcePool.runOnGLThread { }when on a background thread. - Kotlin
objectsingletons: First access triggers<clinit>. Keepinit {}blocks fast (no blocking, no GL dispatch). Defer heavy work to explicitinvoke()methods. ConcurrentHashMapcannot store null values. UseHashMapfor maps that need nullable values (e.g.poolKillFun).@Volatilefor cross-thread boolean flags (loadDone,backgroundLoadDone, etc.)lateinit varguards: Use::prop.isInitializedchecks inresize()anddispose()for properties set during async loading steps.ConsistentUpdateRate: Accumulates delta time; may callupdateFunctionmultiple times beforerenderFunction. Call.reset()before transitioning to active rendering to avoid catch-up storms.- Textures: Use TGA format. Images with semitransparency must be TGA; opaque images may be PNG.
- Coordinate system: Y-down (camera
setToOrtho(true, ...)).setCameraPosition(0, 0)places origin at screen top-left, which is not LibGDX nor OpenGL works natively, hence the existence ofFlippingSpriteBatch. If the user reports renders are flipped vertically, try draw()/drawFlipped() accordingly. - ROUNDWORLD: World wraps horizontally.
WorldCamera.xusesfmod worldWidth. Lightmap and tile drawing account for wrapping.