diff --git a/cake/README.md b/cake/README.md index e169aa06f..cfbb3d52a 100644 --- a/cake/README.md +++ b/cake/README.md @@ -159,6 +159,59 @@ the singleplayer respawn action opens Load Game automatically. remain visible in the menu so an invalid save or failed restore can be retried. Deathmatch does not create autosaves. Coop save/load remains console-only. +## Demo recording and playback + +Both `cake-client` and `cake-fullgame` can record the connected client's view using protocol 34: + +- `record example` — start recording an active live game, remote or locally hosted. +- `stop` — finish and close the recording. Disconnect/shutdown also finishes it. +- `demomap example` — replay the saved recording. + +Recordings are saved under `~/.cake//demos/`; nested names are supported. +Existing recordings are not overwritten: choose a new name for another take. +Recording waits for an accepted full snapshot, requesting one without invalidating +the displayed frame. Map transitions remain in the same recording. An I/O failure +stops recording and reports an error while gameplay continues. A recording stopped +before its first full snapshot has no playable frames. + +Both launchers play classic protocol-26, protocol-31, protocol-32 and protocol-34 client recordings (`.dm2`) +without running a game server. Select a profile containing the recording's maps, +models, and sounds, then open the client console: + +- `demomap example` — play the active profile's saved `demos/example.dm2`, falling back to game data. +- `demomap demos/folder/example.dm2` — play a nested demo, including archive entries. +- `demopause` — toggle pause; opening the menu or suspending the application also pauses playback. +- `demostop` or `disconnect` — stop and return to the browser for browser-started playback, or the console otherwise. + +EOF and playback errors return to the starting browser or console. Browser errors +appear above the retained file listing. Starting another demo replaces the current session. The camera follows the recorded player; movement and mouse look +do not control playback. Opening the console leaves playback running. +Base-game and expansion temporary effects use Cake's rendering pipeline; +visual fidelity varies by effect family. + +The content browser includes installed demos and profile-local recordings. Selecting +a `.dm2` file shows its protocol, game directory, level and available map path, +alongside file size and source. Local recordings take precedence over installed +demos with the same path. Double-click or press Enter to start the selected +demo. Playback returns to the same folder, selected file, scroll position and +thumbnail size on completion, stop or error, including after menu-theme changes. +Escape opens the menu and pauses playback; close the menu to resume. The menu’s +Disconnect action stops playback and returns to the browser. + +Protocol-26 recordings use their shorter frame header; missing suppression counts +are treated as zero. Missing MD2/SP2 config models in protocol-26 demos are warned +about and skipped, since early demos may reference models absent from later game +data. Maps and present-but-malformed assets still require successful loading. +Extended/rerelease protocols and cinematic recordings remain +unsupported. For a protocol-34 interoperability test file, +record a session in Yamagi using `record example`, then `stop`, and copy its +`demos/example.dm2` into the selected profile's game-data directory. + +The hidden-window playback integration tests accept an external recording through +`CAKE_TEST_DEMO=/absolute/path/example.dm2`, alongside `CAKE_HOST_GAME_TESTS=1` +and `CAKE_TEST_BASEDIR`. They run the untouched recording and controlled view +updates in both host configurations; game content and recordings stay outside git. + ## Subsystem Guides - [Desktop video configuration](cake-client/README.md) diff --git a/cake/cake-fullgame/build.gradle.kts b/cake/cake-fullgame/build.gradle.kts index e4abf52fe..9e0a0bd2b 100644 --- a/cake/cake-fullgame/build.gradle.kts +++ b/cake/cake-fullgame/build.gradle.kts @@ -23,9 +23,10 @@ dependencies { tasks.test { // Opt-in flags and content location affect which tests run and what they load. // Changing them must invalidate results that may otherwise contain only skipped tests. - listOf("CAKE_HOST_GAME_TESTS", "CAKE_HOST_GL_TESTS", "CAKE_TEST_BASEDIR").forEach { name -> + listOf("CAKE_HOST_GAME_TESTS", "CAKE_HOST_GL_TESTS", "CAKE_TEST_BASEDIR", "CAKE_TEST_DEMO").forEach { name -> inputs.property(name, providers.environmentVariable(name).orElse("")) } + providers.environmentVariable("CAKE_TEST_DEMO").orNull?.let { inputs.file(it) } if (providers.environmentVariable("CAKE_HOST_GAME_TESTS").orNull == "1") { forkEvery = 1 } diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeDemoPlaybackGlTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeDemoPlaybackGlTest.kt new file mode 100644 index 000000000..d0e914904 --- /dev/null +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeDemoPlaybackGlTest.kt @@ -0,0 +1,255 @@ +package jake2.server + +import com.badlogic.gdx.ApplicationAdapter +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application +import com.badlogic.gdx.backends.lwjgl3.Lwjgl3ApplicationConfiguration +import jake2.qcommon.Defines +import jake2.qcommon.network.demo.DemoReader +import jake2.qcommon.network.messages.server.* +import jake2.qcommon.network.protocol.MessageBodyCodec +import jake2.qcommon.network.protocol.MessageDecodeResult +import jake2.qcommon.sizebuf_t +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder +import jake2.qcommon.exec.Cmd +import jake2.qcommon.network.ConnectionChannel +import org.demoth.cake.Cake +import org.demoth.cake.CakeStartupBootstrap +import org.demoth.cake.fullgame.EmbeddedCakeHost +import org.demoth.cake.hosting.* +import org.demoth.cake.profile.CakeGameProfile +import org.demoth.cake.profile.CakeGameProfileStore +import org.demoth.cake.stages.ingame.Game3dScreen +import org.demoth.cake.stages.ingame.ClientEntityManager +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable +import org.junit.jupiter.api.io.TempDir +import org.junit.jupiter.api.Test +import java.nio.file.Files +import java.nio.file.Path + +/** External protocol-34 recording and installed assets remain outside git. Every application window is hidden. */ +@EnabledIfEnvironmentVariable(named = "CAKE_HOST_GAME_TESTS", matches = "1") +abstract class CakeDemoPlaybackGlTest { + protected abstract val embedded: Boolean + @TempDir lateinit var temp: Path + + @Test + @EnabledIfEnvironmentVariable(named = "CAKE_TEST_DEMO", matches = ".+") + fun `console playback works with remote-only and embedded host without simulation`() { + val basedir = Path.of(requireNotNull(System.getenv("CAKE_TEST_BASEDIR"))) + val demo = Path.of(requireNotNull(System.getenv("CAKE_TEST_DEMO"))) + val content = temp.resolve("content/baseq2") + Files.createDirectories(content.resolve("demos")) + val source = basedir.resolve("baseq2") + Files.walk(source).use { entries -> + entries.filter { !source.relativize(it).startsWith("demos") }.forEach { + val target = content.resolve(source.relativize(it)) + if (Files.isDirectory(it)) Files.createDirectories(target) + else Files.createSymbolicLink(target, it) + } + } + val bytes = Files.readAllBytes(demo) + Files.write(content.resolve("demos/check.dm2"), bytes) + Files.write(content.resolve("demos/motion.dm2"), motionDemo(bytes)) + Files.write(content.resolve("demos/missing-assets.dm2"), missingAssetsDemo()) + Files.write(content.resolve("demos/truncated.dm2"), bytes.copyOf(bytes.size - 5)) + val unsupported = bytes.copyOf().apply { this[5] = 26 } // first record begins with svc_serverdata + protocol. + Files.write(content.resolve("demos/unsupported.dm2"), unsupported) + val previousHome = System.getProperty("user.home") + System.setProperty("user.home", temp.toString()) + var failure: Throwable? = null + val delegate: CakeHost = if (embedded) EmbeddedCakeHost(temp.resolve(".cake")) else UnavailableCakeHost() + var hostUpdates = 0 + val host = object : CakeHost by delegate { + override fun update(elapsedMillis: Long) { hostUpdates++; delegate.update(elapsedMillis) } + } + try { + CakeStartupBootstrap.ensureInitialized() + CakeGameProfileStore().upsertProfile(CakeGameProfile("demo-test", content.parent.toString())) + val startup = CakeStartupBootstrap.bootstrap() + Lwjgl3Application(object : ApplicationAdapter() { + private lateinit var cake: Cake + private var stage = 0 + private var ticks = 0 + private var initialFrame = 0 + private var frameAdvanced = false + private var deadline = System.nanoTime() + 30_000_000_000L + private var updatesAtOpen = 0 + private var snapshotAtPause = 0 + private var timeAtPause = 0 + + override fun create() { + try { + cake = Cake(startup, host = host) + cake.create() + updatesAtOpen = hostUpdates + Cmd.ExecuteString("demomap check") + } catch (error: Throwable) { fail(error) } + } + + override fun render() { + if (failure != null) return + try { + cake.render() + ticks++ + assertTrue(System.nanoTime() < deadline, "Playback timeout at stage $stage") + if (field(cake, "demoPlayback") != null) { + assertEquals(updatesAtOpen, hostUpdates, "Playback must not tick a Host") + assertEquals(HostState.STOPPED, host.state) + assertEquals(1, (field(cake, "netchan") as ConnectionChannel).outgoingSequence) + (delegate as? EmbeddedCakeHost)?.let { + assertTrue(it.server!!.games.isEmpty()) + assertNull(it.udpEndpoint(Defines.NS_CLIENT)) + assertNull(it.udpEndpoint(Defines.NS_SERVER)) + } + } + val screen = field(cake, "game3dScreen") as? Game3dScreen + val entities = screen?.let { field(it, "entityManager") as ClientEntityManager } + val frame = entities?.currentFrame + when (stage) { + 0 -> if (frame?.valid == true && state() == "ACTIVE") { + assertTrue(screen.demoPlayback) + assertTrue(screen.canRenderPresentationFrame()) + initialFrame = frame.serverframe + val camera = field(screen, "camera") as com.badlogic.gdx.graphics.PerspectiveCamera + assertEquals(frame.playerstate.pmove.origin[0] * .125f + frame.playerstate.viewoffset[0], camera.position.x, .01f) + assertEquals(frame.playerstate.pmove.origin[1] * .125f + frame.playerstate.viewoffset[1], camera.position.y, .01f) + Cmd.ExecuteString("demopause") + snapshotAtPause = frame.serverframe + timeAtPause = field(screen, "demoTimeMillis") as Int + next() + } + 1 -> { + assertEquals(snapshotAtPause, frame!!.serverframe) + assertEquals(timeAtPause, field(screen, "demoTimeMillis")) + if (ticks > 10) { Cmd.ExecuteString("demopause"); next() } + } + 2 -> { + if (frame != null) { + frameAdvanced = frameAdvanced || frame.serverframe > initialFrame + } + if (state() == "DISCONNECTED") { + assertTrue(frameAdvanced, "Recorded frames must advance") + assertTrue(initialFrame > 0) + assertEquals(true, field(cake, "consoleVisible")) + Cmd.ExecuteString("demomap motion") + next() + } + } + 3 -> if (state() == "ACTIVE" && ticks > 20) { + val current = frame!!.playerstate + val previous = entities.previousFrame!!.playerstate + val lerp = (entities.lerpAcc * 10).coerceIn(0f, 1f) + val camera = field(screen, "camera") as com.badlogic.gdx.graphics.PerspectiveCamera + val expectedX = (previous.pmove.origin[0] + lerp * (current.pmove.origin[0] - previous.pmove.origin[0])) * .125f + assertEquals(expectedX, camera.position.x, .01f, "Camera must follow recorded movement despite prediction flags allowing local prediction") + val yaw = (previous.viewangles[1] + lerp * (current.viewangles[1] - previous.viewangles[1])) * kotlin.math.PI / 180 + assertEquals(kotlin.math.cos(yaw).toFloat(), camera.direction.x, .001f) + assertEquals(kotlin.math.sin(yaw).toFloat(), camera.direction.y, .001f) + Cmd.ExecuteString("demomap check") // replace an active file session. + next() + } + 4 -> if (state() == "ACTIVE") { + Cmd.ExecuteString("demostop") + assertEquals("DISCONNECTED", state()) + assertNull(field(cake, "game3dScreen")) + Cmd.ExecuteString("demomap unsupported") + next() + } + 5 -> if (state() == "DISCONNECTED") { + assertEquals(true, field(cake, "consoleVisible")) + Cmd.ExecuteString("demomap truncated") + next() + } + 6 -> if (state() == "DISCONNECTED") { + assertNull(field(cake, "demoPlayback")) + assertEquals(true, field(cake, "consoleVisible")) + Cmd.ExecuteString("demomap missing-assets") + next() + } + 7 -> if (state() == "DISCONNECTED") { + assertNull(field(cake, "game3dScreen")) + assertEquals(true, field(cake, "consoleVisible")) + Cmd.ExecuteString("demomap missing") + assertEquals("DISCONNECTED", state()) + Gdx.app.exit() + } + } + } catch (error: Throwable) { fail(error) } + } + + private fun state() = field(cake, "networkState").toString() + private fun next() { stage++; ticks = 0; deadline = System.nanoTime() + 30_000_000_000L } + private fun fail(error: Throwable) { failure = error; Gdx.app.exit() } + override fun dispose() { + try { if (::cake.isInitialized) cake.dispose() } catch (error: Throwable) { failure = failure ?: error } + } + }, Lwjgl3ApplicationConfiguration().apply { + setInitialVisible(false) + disableAudio(true) + setWindowedMode(640, 480) + setForegroundFPS(60) + setOpenGLEmulation(Lwjgl3ApplicationConfiguration.GLEmulation.GL32, 3, 2) + setPauseWhenLostFocus(false) + setPauseWhenMinimized(false) + }) + failure?.let { throw it } + } finally { + host.dispose() + System.setProperty("user.home", previousHome) + } + } + + /** Controlled view deltas supplement the untouched Yamagi interoperability recording. */ + private fun motionDemo(bytes: ByteArray): ByteArray { + val output = ByteArrayOutputStream() + var frame = 0 + DemoReader(ByteArrayInputStream(bytes)).use { reader -> + while (true) { + val record = reader.read() + if (record == DemoReader.Record.End) break + val messages = (MessageBodyCodec.decodeServerBody((record as DemoReader.Record.Body).bytes) as MessageDecodeResult.Decoded).messages + val buffer = sizebuf_t(1400) + messages.forEach { message -> + val modified = if (message is PlayerInfoMessage) { + frame++ + message.copy(origin = PlayerDeltaVector(1000 + frame * 8, 0, 0), + movementFlags = 0, viewOffset = PlayerDeltaVector(0, 0, 0), + viewAngles = PlayerDeltaVector(0, frame * 1000, 0), kickAngles = PlayerDeltaVector(0, 0, 0)) + } else message + modified.writeTo(buffer) + } + output.write(int(buffer.cursize)) + output.write(buffer.data, 0, buffer.cursize) + } + } + output.write(int(-1)) + return output.toByteArray() + } + + private fun missingAssetsDemo(): ByteArray { + val buffer = sizebuf_t(1400) + listOf(ServerDataMessage(34, 1, true, "baseq2", 0, "Missing content"), + ConfigStringMessage(Defines.CS_MODELS + 1, "maps/cake-nonexistent-demo-map.bsp"), + StuffTextMessage("precache\n")).forEach { it.writeTo(buffer) } + return int(buffer.cursize) + buffer.data.copyOf(buffer.cursize) + int(-1) + } + + private fun int(value: Int) = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(value).array() + + private fun field(owner: Any, name: String): Any? = owner.javaClass.getDeclaredField(name).apply { + isAccessible = true + }.get(owner) +} + +class CakeRemoteDemoPlaybackGlTest : CakeDemoPlaybackGlTest() { + override val embedded = false +} + +class CakeHostedDemoPlaybackGlTest : CakeDemoPlaybackGlTest() { + override val embedded = true +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt index 7dbfad6e0..387a77146 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt @@ -14,6 +14,17 @@ import com.badlogic.gdx.graphics.profiling.GLProfiler import com.badlogic.gdx.scenes.scene2d.ui.Skin import com.badlogic.gdx.utils.ScreenUtils import com.badlogic.gdx.utils.viewport.ScreenViewport +import java.io.IOException +import java.io.InputStream +import jake2.qcommon.vfs.VfsEntry +import jake2.qcommon.network.demo.DemoReader +import org.demoth.cake.demo.DemoPlayback +import org.demoth.cake.demo.DemoRecording +import org.demoth.cake.demo.DemoFiles +import org.demoth.cake.demo.DemoMetadata +import jake2.qcommon.network.demo.DemoWriter +import jake2.qcommon.network.protocol.PacketEnvelope +import org.demoth.cake.demo.demoLogicalPath import jake2.qcommon.Com import jake2.qcommon.Defines.* import jake2.qcommon.Globals @@ -129,6 +140,12 @@ class Cake( private var awaitingNewGame = false // Connection and download protocol state. + private var demoRecording: DemoRecording? = null + private val demoFiles = DemoFiles(profilePersistence::writable, fileResolver::openContent) + private data class DemoBrowserReturn(val profileId: String, val gameDirectory: String?) + private var demoBrowserReturn: DemoBrowserReturn? = null + private var demoPlayback: DemoPlayback? = null + private var demoPaused = false private var networkState = DISCONNECTED private val handshake = ClientHandshake(network, ::connectPayload) private val localConnection get() = handshake.isLocal @@ -239,7 +256,9 @@ class Cake( readSavePreview = { profileId, slot -> if (activeGameProfile?.id == profileId) host.readSavePreview(slot) else null }, - activeProfileId = { activeGameProfile?.id }) + activeProfileId = { activeGameProfile?.id }, + listContentEntries = { demoFiles.entries(fileResolver.listContentEntries(), fileResolver.caseSensitive) }, + readDemoMetadata = { entry -> DemoMetadata.read(demoFiles.open(entry)) }) rebuildContentStyledMenuStages(initialStyle) // todo: gather all early logging (which is generated before the console is created) // and put into the console when it's ready @@ -297,6 +316,7 @@ class Cake( // Important: like a svc_reconnect but more lightweight Cmd.AddCommand("reconnect", "Reconnect to the current server") { + if (demoPlayback != null) return@AddCommand Com.Printf("Reconnecting..\n") if (networkState == CONNECTED) { // set on "changing" command @@ -316,6 +336,27 @@ class Cake( } + Cmd.AddCommand("record", "Record a protocol-34 client demo: record ") { args -> + if (args.size != 2) Com.Printf("usage: record \n") else startRecording(args[1]) + } + Cmd.AddCommand("stop", "Finish the current client demo recording") { + if (demoRecording == null) Com.Printf("Not recording a demo\n") else stopRecording() + } + + Cmd.AddCommand("demomap", "Play a protocol-26/32/34 demo: demomap ") { args -> + if (args.size != 2) { + Com.Printf("usage: demomap \n") + } else { + startDemo(args[1]) + } + } + Cmd.AddCommand("demopause", "Pause or resume demo playback") { + if (demoPlayback != null) demoPaused = !demoPaused + } + Cmd.AddCommand("demostop", "Stop demo playback and return to the browser or console") { + if (demoPlayback != null) finishSession() + } + Cmd.AddCommand("connect", "Connect to a remote server") { val server = sanitizeConnectTarget(it) if (server == null) { @@ -383,7 +424,9 @@ class Cake( network.sendConnectionless(to, ConnectionlessCommand.rcon, message) } - Cmd.AddCommand("disconnect", "Disconnect and stop any local server") { disconnect() } + Cmd.AddCommand("disconnect", "Disconnect and stop any local server") { + if (demoPlayback != null) finishSession() else disconnect() + } Cmd.AddCommand("menu_loadgame", "Open the Load Game menu") { menuEventBus.postIntent(MenuIntent.OpenLoadGame) } @@ -435,6 +478,7 @@ class Cake( * see jake2.server.ServerHost#SV_ExecuteUserCommand */ Cmd.AddCommand("cmd", "(internal) Forward a command to the connected server") { + if (demoPlayback != null) return@AddCommand if (networkState != CONNECTED && networkState != ACTIVE) { Com.Warn("Cannot cmd '${it}', not connected") } else { @@ -451,7 +495,7 @@ class Cake( Com.Warn("precache - during the game!\n") // todo: warning return@AddCommand } - val precache_spawncount = it[1].toInt() + val precache_spawncount = it.getOrNull(1)?.toIntOrNull() ?: if (demoPlayback != null) 0 else return@AddCommand val activeScreen = game3dScreen if (activeScreen == null) { @@ -462,7 +506,7 @@ class Cake( activeScreen.precache() } catch (error: RuntimeException) { val missingResource = findMissingResourceFailure(error) - if (missingResource != null && downloads.requestMissingResource( + if (demoPlayback == null && missingResource != null && downloads.requestMissingResource( missingResource.path, fileResolver.gamemod, precache_spawncount, )) { return@AddCommand @@ -473,7 +517,8 @@ class Cake( downloads.precacheCompleted() // we are ready to start the game! - netchan.queueReliable(StringCmdMessage(StringCmdMessage.BEGIN + " " + precache_spawncount + "\n")) + if (demoPlayback == null) + netchan.queueReliable(StringCmdMessage(StringCmdMessage.BEGIN + " " + precache_spawncount + "\n")) } // endregion @@ -493,6 +538,122 @@ class Cake( return result } + private fun startRecording(name: String) { + if (demoRecording != null) { + Com.Warn("Already recording a demo\n") + return + } + if (demoPlayback != null || networkState != ACTIVE || game3dScreen == null) { + Com.Warn("Recording requires an active live game\n") + return + } + try { + val output = demoFiles.create(name) + demoRecording = DemoRecording(DemoWriter(output.stream)) { + checkNotNull(game3dScreen) { "No active game for recording startup" }.demoBootstrap() + } + Com.Printf("Recording to ${output.path}; waiting for a full snapshot\n") + } catch (error: Exception) { + Com.Warn("Could not start recording: ${error.message}\n") + } + } + + private fun stopRecording() { + val recording = demoRecording ?: return + demoRecording = null + try { + recording.close() + Com.Printf("Stopped demo recording\n") + } catch (error: Exception) { + Com.Warn("Could not finish recording: ${error.message}\n") + } + } + + private fun captureDemoBody(recording: DemoRecording, packet: PacketEnvelope.Sequenced, snapshotAccepted: Boolean) { + if (demoRecording !== recording) return + try { + recording.capture(packet, snapshotAccepted) + } catch (error: Exception) { + // Recording failures retire the file owner, while the live game keeps running. + demoRecording = null + try { recording.close() } catch (closing: Exception) { + if (closing !== error) error.addSuppressed(closing) + } + Com.Warn("Demo recording failed: ${error.message}\n") + } + } + + private fun startDemo(name: String) { + val path = demoLogicalPath(name) + if (path == null) { + Com.Warn("Invalid demo path: $name\n") + return + } + val input = try { + demoFiles.open(name) + } catch (error: Exception) { + Com.Warn("${error.message}\n") + return + } + beginDemo(input, path) + } + + private fun startDemo(profileId: String, entry: VfsEntry) { + if (profileId != activeGameProfile?.id) return + val origin = DemoBrowserReturn(profileId, fileResolver.gamemod) + val input = try { + demoFiles.open(entry) + } catch (error: Exception) { + val message = "Could not open demo: ${error.message}" + Com.Warn("$message\n") + menuStages.showContentBrowser(message) + return + } + beginDemo(input, entry.displayPath, origin) + } + + private fun beginDemo(input: InputStream, path: String, origin: DemoBrowserReturn? = null) { + try { + disconnect() + } catch (error: RuntimeException) { + try { input.close() } catch (closing: Exception) { error.addSuppressed(closing) } + throw error + } + menuStages.hideContentPreviews() + demoBrowserReturn = origin + demoPlayback = DemoPlayback(DemoReader(input)) + demoPaused = false + networkState = CONNECTED + consoleVisible = false + menuVisible = false + Com.Printf("Playing $path\n") + } + + private fun updateDemo(deltaSeconds: Float) { + val playback = demoPlayback ?: return + try { + val running = playback.update(if (demoPaused || menuVisible || applicationPaused) 0f else deltaSeconds) { messages -> + parseServerMessage(messages) + Cbuf.Execute() + demoPlayback === playback + } + if (demoPlayback !== playback) return + if (!running) { + Com.Printf("Demo completed\n") + finishSession() + return + } + game3dScreen?.setDemoTime(playback.presentationTimeMillis, playback.interpolationSeconds) + } catch (error: IOException) { + Com.Warn("Demo playback failed: ${error.message}\n") + finishSession("Demo playback failed: ${error.message}") + } catch (error: RuntimeException) { + // Asset loading and applying recorded state can also reject structurally valid bodies. + Com.Warn("Demo playback failed: ${error.message}\n") + finishSession("Demo playback failed: ${error.message}") + } + } + private fun beginConnection(target: NetworkAddress, local: Boolean) { if (!handshake.begin(target)) { Com.Warn("Could not open the client UDP socket\n") @@ -513,8 +674,17 @@ class Cake( /** Reset client state without closing the hosted server or discarding its reconnect traffic. */ private fun resetClientConnection() { + stopRecording() + val playback = demoPlayback + demoBrowserReturn = null + val wasDemo = playback != null + demoPlayback = null + demoPaused = false + try { playback?.close() } catch (error: IOException) { + Com.Warn("Could not close demo: ${error.message}\n") + } Com.Printf("Disconnecting from server...\n") - if (networkState == CONNECTED || networkState == ACTIVE) { + if (!wasDemo && (networkState == CONNECTED || networkState == ACTIVE)) { transmitAndRecord(listOf(StringCmdMessage(StringCmdMessage.DISCONNECT))) } game3dScreen?.stopAudio() @@ -530,14 +700,14 @@ class Cake( } private fun transmitAndRecord(unreliable: Collection?) { - if (netchan.transmit(unreliable)) { + if (demoPlayback == null && netchan.transmit(unreliable)) { networkDebugSampler.recordOutbound(netchan.lastSentSize, Globals.curtime) } } private fun handleMissingResourceFailure(error: MissingResourceException) { Com.Warn("${error.message}\n") - dropToConsole() + finishSession() } private fun findMissingResourceFailure(error: Throwable): MissingResourceException? { @@ -551,11 +721,22 @@ class Cake( return null } - private fun dropToConsole() { + /** Retire the session and return to its browser origin, or to the console for other sessions. */ + private fun finishSession(message: String? = null) { + val origin = demoBrowserReturn.takeIf { demoPlayback != null } disconnect() - menuVisible = false - consoleVisible = true - consoleStage.focus() + if (origin != null && origin.profileId == activeGameProfile?.id) { + fileResolver.gamemod = origin.gameDirectory + refreshContentStyledMenus() + menuStages.showContentBrowser(message) + menuEventBus.postIntent(MenuIntent.OpenContentBrowser) + menuVisible = true + consoleVisible = false + } else { + menuVisible = false + consoleVisible = true + consoleStage.focus() + } updateInputHandlers(consoleVisible, menuVisible) } @@ -602,6 +783,7 @@ class Cake( uiSample.isOpen -> requireNotNull(uiSample.inputProcessor) menuVisible -> menuStages.inputProcessor + demoPlayback != null -> InputAdapter() else -> { // delegate to the game screen game3dScreen?.let { screen -> object : InputProcessor by screen {} } @@ -665,28 +847,32 @@ class Cake( // Match the legacy client ordering: process incoming packets and any stuffed commands first, // then react in the same frame by sending reliable/new packets or a reconnect challenge. - CL_ReadPackets() + if (demoPlayback == null) CL_ReadPackets() Cbuf.Execute() // Commands may have started/stopped a map. Tick once on this thread before sending // new client input; the next frame reads server replies (no second command pump). - host.setPaused(menuVisible || consoleVisible || uiSample.isOpen || applicationPaused) - host.update(hostClock.advance(Gdx.graphics.rawDeltaTime, - Globals.fixedtime.typedValue, Globals.timescale.typedValue)) - // Console/startup commands can start a Host without going through a menu. - // Existing local transitions and remote connections retain their handshake paths. - if (networkState == DISCONNECTED && host.state == HostState.RUNNING) { - awaitingNewGame = false - beginConnection(network.localPeer, local = true) - menuVisible = false - consoleVisible = false - } - if (localConnection && host.state == HostState.STOPPED) { - dropToConsole() - return + if (demoPlayback != null) { + updateDemo(deltaSeconds) + } else { + host.setPaused(menuVisible || consoleVisible || uiSample.isOpen || applicationPaused) + host.update(hostClock.advance(Gdx.graphics.rawDeltaTime, + Globals.fixedtime.typedValue, Globals.timescale.typedValue)) + // Console/startup commands can start a Host without going through a menu. + // Existing local transitions and remote connections retain their handshake paths. + if (networkState == DISCONNECTED && host.state == HostState.RUNNING) { + awaitingNewGame = false + beginConnection(network.localPeer, local = true) + menuVisible = false + consoleVisible = false + } + if (localConnection && host.state == HostState.STOPPED) { + finishSession() + return + } + game3dScreen?.updateLocalInput(deltaSeconds) + runClientCommandSteps(deltaSeconds) + handshake.tick(deltaSeconds) } - game3dScreen?.updateLocalInput(deltaSeconds) - runClientCommandSteps(deltaSeconds) - handshake.tick(deltaSeconds) if (!drawable) return if (uiViewport.setReferenceHeight(uiSize.typedValue)) { @@ -704,7 +890,7 @@ class Cake( netchan.outgoingSequence, Globals.curtime ) - game3dScreen?.render(deltaSeconds) + game3dScreen?.render(if (demoPlayback != null && (demoPaused || menuVisible || applicationPaused)) 0f else deltaSeconds) } val activeScreen = game3dScreen @@ -747,7 +933,7 @@ class Cake( (error is jake2.qcommon.longjmpException && host.availability == HostAvailability.AVAILABLE)) { val startingFromMenu = awaitingNewGame Com.Warn("Local server stopped: ${error.message}\n") - dropToConsole() + finishSession() if (startingFromMenu) { menuEventBus.postIntent(MenuIntent.HostingFailed(error.message ?: "New Game failed")) menuVisible = true @@ -798,7 +984,8 @@ class Cake( ACTIVE -> { queueUserInfoUpdateIfNeeded() - game3dScreen?.buildMoveMessage(netchan.outgoingSequence, currentClientCommandMsec())?.let { + game3dScreen?.buildMoveMessage(netchan.outgoingSequence, currentClientCommandMsec(), + requestFullSnapshot = demoRecording?.needsFullSnapshot == true)?.let { transmitAndRecord(listOf(it)) } } @@ -990,6 +1177,7 @@ class Cake( consoleVisible = false updateInputHandlers(consoleVisible, menuVisible) } + is MenuSignal.PlayDemo -> startDemo(signal.profileId, signal.entry) is MenuSignal.OpenUiSample -> openUiSample(signal.game) MenuSignal.CloseUiSample -> closeUiSample() is MenuSignal.CaptureUiSample -> { @@ -1085,11 +1273,15 @@ class Cake( if (message !is FrameHeaderMessage && message !is PlayerInfoMessage && message !is PacketEntitiesMessage) Com.Printf("Network: Received server msg: $message\n") } - parseServerMessage(body.messages) + val recording = demoRecording + val envelope = networkPacket.sequencedEnvelope() + parseServerMessage(body.messages) { snapshotAccepted -> + if (recording != null) captureDemoBody(recording, envelope, snapshotAccepted) + } } is MessageDecodeResult.Rejected -> { Com.Warn("Bad server payload at ${body.offset}: ${body.detail}\n") - dropToConsole() + finishSession() } } } //else wasn't accepted for some reason @@ -1101,7 +1293,8 @@ class Cake( /** * CL_ParseServerMessage */ - private fun parseServerMessage(messages: Collection) { + private fun parseServerMessage(messages: Collection, onParsed: (Boolean) -> Unit = {}) { + var snapshotAccepted = false // Important ordering quirk: accept() updates netchan ack from packet header before parsing // payload messages. We push those values into prediction first, mirroring old `CL_ReadPackets` // + `CL_pred.CheckPredictionError/PredictMovement` data flow. @@ -1114,13 +1307,15 @@ class Cake( when (msg) { is DisconnectMessage -> { Com.Printf("Server disconnected\n") - dropToConsole() + onParsed(snapshotAccepted) + finishSession() return } // NB: it is not sent during map change (instead - StuffTextMessage.command("reconnect")) // sent on server restart is ReconnectMessage -> { + if (demoPlayback != null) return@forEach Com.Printf("ReconnectMessage: Reconnecting..\n") beginMapTransitionRetainingConfigAssets() networkState = CONNECTING @@ -1131,7 +1326,7 @@ class Cake( if (clDebugStufftext.typedValue) { Com.Printf("svc_stufftext: ${Com.makePrintable(msg.text)}\n") } - val filtered = filterServerStuffText(msg.text) + val filtered = filterServerStuffText(msg.text, demoPlayback = demoPlayback != null) filtered.rejectedCommands.forEach { Com.Warn("Ignoring unexpected server stufftext command: '${Com.makePrintable(it)}'") } @@ -1146,6 +1341,8 @@ class Cake( // new game is starting Cbuf.Execute() resetClientStateForServerData() + game3dScreen?.demoPlayback = demoPlayback != null + if (demoPlayback != null) networkState = CONNECTED netchan.clearQueuedReliable() menuStages.hideContentPreviews() @@ -1160,6 +1357,7 @@ class Cake( // delegate all game-related updates to the game screen is FrameHeaderMessage -> { + if (demoPlayback != null) game3dScreen?.setDemoTime(msg.frameNumber * 100 - 100, 0f) game3dScreen?.processServerFrameHeader(msg) } @@ -1168,6 +1366,7 @@ class Cake( } is DownloadMessage -> { + if (demoPlayback != null) return@forEach if (msg is DownloadMessage.Chunk && msg.data.size > 0) { networkDebugSampler.recordDownload(msg.data.size, Globals.curtime) } @@ -1175,7 +1374,7 @@ class Cake( CakeDownloadSession.Result.Continue -> Unit is CakeDownloadSession.Result.RetryPrecache -> Cbuf.AddText("${StringCmdMessage.PRECACHE} ${result.spawnCount}\n") - CakeDownloadSession.Result.MissingOnServer -> dropToConsole() + CakeDownloadSession.Result.MissingOnServer -> finishSession() } } @@ -1200,7 +1399,10 @@ class Cake( } is PacketEntitiesMessage -> { - if (game3dScreen?.processPacketEntitiesMessage(msg) == true) { + val valid = game3dScreen?.processPacketEntitiesMessage(msg) == true + snapshotAccepted = valid + if (demoPlayback != null && !valid) throw IOException("Demo snapshot depends on an unavailable frame") + if (valid) { if (networkState != ACTIVE) { networkState = ACTIVE Com.Printf("Game started!\n") @@ -1232,6 +1434,7 @@ class Cake( } } + onParsed(snapshotAccepted) } /** @@ -1617,7 +1820,7 @@ class Cake( override fun canDisconnect(): Boolean = networkState != DISCONNECTED || host.state != HostState.STOPPED override fun disconnect() { - this@Cake.disconnect() + if (demoPlayback != null) finishSession() else this@Cake.disconnect() } override fun listProfileIds(): List = listProfileIdsForMenu() diff --git a/cake/core/src/main/kotlin/org/demoth/cake/ServerStuffTextPolicy.kt b/cake/core/src/main/kotlin/org/demoth/cake/ServerStuffTextPolicy.kt index 07db646dc..ba9cfd0f2 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/ServerStuffTextPolicy.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/ServerStuffTextPolicy.kt @@ -10,12 +10,12 @@ internal data class ServerStuffTextFilterResult( val rejectedCommands: List, ) -internal fun filterServerStuffText(text: String): ServerStuffTextFilterResult { +internal fun filterServerStuffText(text: String, demoPlayback: Boolean = false): ServerStuffTextFilterResult { val accepted = mutableListOf() val rejected = mutableListOf() splitStuffTextCommands(text).forEach { command -> - if (isExpectedServerStuffTextCommand(command)) { + if (isExpectedServerStuffTextCommand(command, demoPlayback)) { accepted += command } else { rejected += command @@ -28,7 +28,7 @@ internal fun filterServerStuffText(text: String): ServerStuffTextFilterResult { ) } -private fun isExpectedServerStuffTextCommand(command: String): Boolean { +private fun isExpectedServerStuffTextCommand(command: String, demoPlayback: Boolean): Boolean { val args = Cmd.TokenizeString(command, false) if (args.isEmpty()) { return true @@ -38,7 +38,7 @@ private fun isExpectedServerStuffTextCommand(command: String): Boolean { return when (args[0]) { "changing", "reconnect" -> args.size == 1 "cmd" -> args.size > 1 - "precache" -> args.size == 2 && args[1].toIntOrNull() != null + "precache" -> (demoPlayback && args.size == 1) || (args.size == 2 && args[1].toIntOrNull() != null) "spectator" -> args.size == 2 && (args[1] == "0" || args[1] == "1") else -> false } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/assets/BeamRenderer.kt b/cake/core/src/main/kotlin/org/demoth/cake/assets/BeamRenderer.kt index 9b114361f..87277e18f 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/assets/BeamRenderer.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/assets/BeamRenderer.kt @@ -18,63 +18,78 @@ import com.badlogic.gdx.math.Vector3 import com.badlogic.gdx.utils.Disposable import jake2.qcommon.Defines import jake2.qcommon.Globals +import jake2.qcommon.math.Vector3f import org.demoth.cake.ClientEntity -import kotlin.collections.plusAssign /** - * Isolated renderer for RF_BEAM entities. Keeps beam-specific caches and temporary vectors out - * of [org.demoth.cake.stages.ingame.Game3dScreen]. + * Isolated renderer for RF_BEAM entities and temporary palette lasers. Keeps beam-specific caches + * and temporary vectors out of [org.demoth.cake.stages.ingame.Game3dScreen]. + * Shared libGDX cylinder models are owned here and disposed with the screen, rather than per laser. */ -class BeamRenderer( - private val assetManager: AssetManager +class BeamRenderer internal constructor( + private val paletteColor: (Int) -> Color, + private val createModel: (Material) -> Model, ) : Disposable { - // Cached by (paletteIndex + translucency) to avoid rebuilding identical beam meshes each frame. - private val beamRenderables = mutableMapOf() - // Quake 2 global palette in RGBA8888 format, loaded from q2palette.bin. - private val q2Palette: IntArray by lazy { assetManager.get("q2palette.bin", Any::class.java) as IntArray } - // Temporary vectors/quaternion reused each frame to avoid allocations in beam rendering. - private val beamStart = Vector3() - private val beamEnd = Vector3() - private val beamDirection = Vector3() - private val beamMidpoint = Vector3() - private val beamScale = Vector3() - private val beamRotation = Quaternion() + constructor(assetManager: AssetManager) : this( + paletteColor = { index -> + val palette = assetManager.get("q2palette.bin", Any::class.java) as IntArray + // RGBA8888 palette bytes yield the same RGB as the reference d_8to24table beam path. + Color(palette[index and 255]).apply { a = 1f } + }, + createModel = ::createCylinder, + ) + + private data class Style(val paletteIndex: Int, val alpha: Float) + /** Reusable model and instance for one palette/opacity style. */ + private data class BeamRenderable(val model: Model, val instance: ModelInstance) + private val beamRenderables = mutableMapOf() + private val direction = Vector3() + private val midpoint = Vector3() + private val scale = Vector3() + private val rotation = Quaternion() /** - * Renders a Quake-style beam entity (RF_BEAM) as procedural cylinder geometry. - * - * Beam semantics are taken from network entity state: - * - start point = `origin` - * - end point = `old_origin` - * - diameter = `frame` - * - color bytes = packed in `skinnum` (legacy beam color hack) + * Beam semantics from network entity state: + * origin/old_origin are endpoints, frame is diameter, and skinnum packs four palette colors. + * These are the legacy RF_BEAM conventions consumed by CL_ents.AddPacketEntities. */ fun render(modelBatch: ModelBatch, entity: ClientEntity) { val state = entity.current - beamStart.set(state.origin[0], state.origin[1], state.origin[2]) - beamEnd.set(state.old_origin[0], state.old_origin[1], state.old_origin[2]) - beamDirection.set(beamEnd).sub(beamStart) - val length = beamDirection.len() - if (length <= 0.001f) { - return - } - - beamDirection.scl(1f / length) - beamRotation.setFromCross(Vector3.Y, beamDirection) + val colorShift = Globals.rnd.nextInt(4) * 8 + val paletteIndex = (state.skinnum ushr colorShift) and 255 + val alpha = if (state.renderfx and Defines.RF_TRANSLUCENT != 0) .3f else 1f + prepare(state.origin[0], state.origin[1], state.origin[2], state.old_origin[0], state.old_origin[1], state.old_origin[2], + paletteIndex, state.frame.coerceAtLeast(1).toFloat(), alpha)?.let { modelBatch.render(it) } + } - // In Quake 2, RF_BEAM frame stores the beam diameter. - val radius = state.frame.coerceAtLeast(1) * 0.5f - beamMidpoint.set(beamStart).add(beamEnd).scl(0.5f) - beamScale.set(radius, length, radius) + fun render(modelBatch: ModelBatch, start: Vector3f, end: Vector3f, paletteIndex: Int, diameter: Float, alpha: Float) { + prepare(start, end, paletteIndex, diameter, alpha)?.let { modelBatch.render(it) } + } - // select one of 4 packed beam color bytes at random each draw. - val colorShift = Globals.rnd.nextInt(4) * 8 - val paletteIndex = (state.skinnum ushr colorShift) and 0xFF - val translucent = (state.renderfx and Defines.RF_TRANSLUCENT) != 0 - val beamRenderable = getOrCreateBeamRenderable(paletteIndex, translucent) + /** Returned instances are borrowed: ModelBatch snapshots their transform when submitted. */ + internal fun prepare(start: Vector3f, end: Vector3f, paletteIndex: Int, diameter: Float, alpha: Float): ModelInstance? = + prepare(start.x, start.y, start.z, end.x, end.y, end.z, paletteIndex, diameter, alpha) - beamRenderable.instance.transform.set(beamMidpoint, beamRotation, beamScale) - modelBatch.render(beamRenderable.instance) + private fun prepare(sx: Float, sy: Float, sz: Float, ex: Float, ey: Float, ez: Float, + paletteIndex: Int, diameter: Float, alpha: Float): ModelInstance? { + direction.set(ex - sx, ey - sy, ez - sz) + val length = direction.len() + if (length <= .001f || diameter <= 0f || alpha <= 0f) return null + direction.scl(1f / length) + rotation.setFromCross(Vector3.Y, direction) + midpoint.set((sx + ex) * .5f, (sy + ey) * .5f, (sz + ez) * .5f) + scale.set(diameter * .5f, length, diameter * .5f) + val renderable = beamRenderables.getOrPut(Style(paletteIndex and 255, alpha.coerceAtMost(1f))) { + val color = paletteColor(paletteIndex and 255) + val material = Material(ColorAttribute.createDiffuse(color), + ColorAttribute(ColorAttribute.Emissive, color.r, color.g, color.b, 1f), + DepthTestAttribute(GL20.GL_LEQUAL, 0f, 1f, false), IntAttribute.createCullFace(GL20.GL_NONE)) + if (alpha < 1f) material.set(BlendingAttribute(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA, alpha)) + val model = createModel(material) + BeamRenderable(model, ModelInstance(model)) + } + renderable.instance.transform.set(midpoint, rotation, scale) + return renderable.instance } override fun dispose() { @@ -82,59 +97,13 @@ class BeamRenderer( beamRenderables.clear() } - /** - * Returns a cached beam renderable for a color/translucency pair, creating it on first use. - * - * The base mesh is a unit cylinder aligned with Y axis: - * radius=1, height=1. - * It is transformed per-entity in [render]. - */ - @Suppress("DEPRECATION") - private fun getOrCreateBeamRenderable(paletteIndex: Int, translucent: Boolean): BeamRenderable { - // pack translucency in bit 8, palette index in bits [0..7] - val key = paletteIndex or (if (translucent) 1 shl 8 else 0) - return beamRenderables.getOrPut(key) { - val rgba8888 = q2Palette[paletteIndex] - // Match legacy d_8to24table RGB extraction layout used by beam renderers. - val legacyD8To24 = - ((rgba8888 ushr 24) and 0xFF) or - (((rgba8888 ushr 16) and 0xFF) shl 8) or - (((rgba8888 ushr 8) and 0xFF) shl 16) - val color = Color( - (legacyD8To24 and 0xFF) / 255f, - ((legacyD8To24 ushr 8) and 0xFF) / 255f, - ((legacyD8To24 ushr 16) and 0xFF) / 255f, - 1f - ) - val alpha = if (translucent) 0.3f else 1f - val materialAttributes = mutableListOf( - ColorAttribute.createDiffuse(color), - ColorAttribute(ColorAttribute.Emissive, color.r, color.g, color.b, 1f), - DepthTestAttribute(GL20.GL_LEQUAL, 0f, 1f, false), - IntAttribute.createCullFace(GL20.GL_NONE), - ) - if (translucent) { - materialAttributes += BlendingAttribute(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA, alpha) - } - val material = Material(*materialAttributes.toTypedArray()) - val modelBuilder = ModelBuilder() - modelBuilder.begin() - modelBuilder.part( - "beam", - GL20.GL_TRIANGLES, - (VertexAttributes.Usage.Position or VertexAttributes.Usage.Normal).toLong(), - material - ).cylinder(2f, 1f, 2f, 6, 0f, 360f) - val model = modelBuilder.end() - BeamRenderable(model, ModelInstance(model)) - } + companion object { + /** Unit-radius, unit-height Y cylinder, transformed per draw using libGDX geometry and matrices. */ + @Suppress("DEPRECATION") + private fun createCylinder(material: Material): Model = ModelBuilder().apply { + begin() + part("beam", GL20.GL_TRIANGLES, (VertexAttributes.Usage.Position or VertexAttributes.Usage.Normal).toLong(), material) + .cylinder(2f, 1f, 2f, 6, 0f, 360f) + }.end() } - - /** - * Pair of reusable model + instance used to render beams of one visual style. - */ - private data class BeamRenderable( - val model: Model, - val instance: ModelInstance - ) } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/assets/CakeFileResolver.kt b/cake/core/src/main/kotlin/org/demoth/cake/assets/CakeFileResolver.kt index 400b888a5..8a681759e 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/assets/CakeFileResolver.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/assets/CakeFileResolver.kt @@ -58,6 +58,9 @@ class CakeFileResolver( /** Profile content only, without remote downloads or bundled asset fallbacks. */ internal fun resolveContent(path: String): FileHandle? = vfsAssetSource.resolve(path) + /** Sequential profile content, including archive entries; caller owns the returned stream. */ + internal fun openContent(path: String): java.io.InputStream? = vfsAssetSource.openContent(path) + /** Winning profile entries only; listing does not extract packages or load media. */ fun listContentEntries(): List = vfsAssetSource.listEntries() diff --git a/cake/core/src/main/kotlin/org/demoth/cake/assets/CakeVfsAssetSource.kt b/cake/core/src/main/kotlin/org/demoth/cake/assets/CakeVfsAssetSource.kt index d14019744..d7b488ad7 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/assets/CakeVfsAssetSource.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/assets/CakeVfsAssetSource.kt @@ -81,6 +81,21 @@ internal class CakeVfsAssetSource { return vfs.listEntries() } + /** Open profile content directly, retaining the VFS handle until the caller closes the stream. */ + @Synchronized + fun openContent(path: String): java.io.InputStream? { + val result = vfs.openRead(path, jake2.qcommon.vfs.VfsOpenOptions.DEFAULT) + if (!result.success) return null + val handle = result.value ?: return null + val input = try { handle.inputStream() } catch (error: java.io.IOException) { + try { handle.close() } catch (closeError: java.io.IOException) { error.addSuppressed(closeError) } + throw error + } + return object : java.io.FilterInputStream(input) { + override fun close() = handle.close() + } + } + @Synchronized fun resolve(path: String): FileHandle? { val lookup = vfs.resolve(path) diff --git a/cake/core/src/main/kotlin/org/demoth/cake/config/GameConfiguration.kt b/cake/core/src/main/kotlin/org/demoth/cake/config/GameConfiguration.kt index 32f26ccd4..61e9baa1b 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/config/GameConfiguration.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/config/GameConfiguration.kt @@ -8,6 +8,7 @@ import com.badlogic.gdx.utils.Disposable import com.badlogic.gdx.utils.GdxRuntimeException import jake2.qcommon.Com import jake2.qcommon.Defines.* +import jake2.qcommon.network.messages.server.ConfigStringMessage import jake2.qcommon.exec.Cmd import org.demoth.cake.assets.bsp.BspMapAsset import org.demoth.cake.assets.MissingResourceException @@ -84,6 +85,9 @@ class GameConfiguration( */ var layout: String = "" + /** Protocol-26 demo playback may reference models absent from later game data (Yamagi R_RegisterModel). */ + var allowMissingModelConfigs: Boolean = false + private val configStrings = Array(size) { null } private val trackedLoadedAssets: MutableMap> = mutableMapOf() private val failedAssets: MutableMap = mutableMapOf() @@ -240,6 +244,11 @@ class GameConfiguration( return configStrings.getOrNull(CS_ITEMS + itemIndex)?.value } + /** Owned protocol values for demo startup; resource handles are excluded. */ + fun snapshotConfigStrings(): List = configStrings.mapIndexedNotNull { index, config -> + config?.value?.takeIf { it.isNotEmpty() }?.let { ConfigStringMessage(index, it) } + } + fun getConfigValue(configIndex: Int): String? { return configStrings.getOrNull(configIndex)?.value } @@ -324,7 +333,8 @@ class GameConfiguration( val config = configStrings[i] ?: continue if (!loadConfigResource(i, config)) { val modelPath = config.value - if (modelPath.isNotBlank() && !modelPath.startsWith("*") && !modelPath.startsWith("#")) { + if (modelPath.isNotBlank() && !modelPath.startsWith("*") && !modelPath.startsWith("#") && + modelPath !in failedAssets) { Com.Warn("Failed to load model data for config $modelPath") } } @@ -407,6 +417,17 @@ class GameConfiguration( if (modelPath.isBlank() || modelPath.startsWith("*") || modelPath.startsWith("#")) { return false } + if (allowMissingModelConfigs && + (modelPath.endsWith(".md2", ignoreCase = true) || modelPath.endsWith(".sp2", ignoreCase = true)) && + !assetExists(modelPath)) { + // Yamagi registers these with crash=false. Keep the config/index, but omit absent geometry. + config.resource = null + if (modelPath !in failedAssets) { + failedAssets[modelPath] = "Model absent from game data during old-demo playback" + Com.Warn("Skipping missing demo model: $modelPath") + } + return false + } return when { modelPath.endsWith(".md2", ignoreCase = true) -> { val md2Asset = acquireAsset(modelPath) diff --git a/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoBootstrap.kt b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoBootstrap.kt new file mode 100644 index 000000000..cc7362365 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoBootstrap.kt @@ -0,0 +1,44 @@ +package org.demoth.cake.demo + +import jake2.qcommon.Defines +import jake2.qcommon.network.demo.DemoWriter +import jake2.qcommon.network.messages.server.* +import jake2.qcommon.network.transport.PacketBytes +import jake2.qcommon.sizebuf_t + +/** Owned startup records for a protocol-34 client recording (Yamagi CL_Record_f). */ +class DemoBootstrap( + serverData: ServerDataMessage, + configStrings: Iterable, + baselines: Iterable, +) { + private val records: List + + init { + require(serverData.protocol == Defines.PROTOCOL_VERSION) { "Unsupported recording protocol ${serverData.protocol}" } + require(serverData.playerNumber in 0 until Defines.MAX_CLIENTS) { "Recording requires a player view" } + val messages = buildList { + add(serverData.copy(demo = true)) + addAll(configStrings.filter { it.config.isNotEmpty() }.sortedBy { it.index }) + addAll(baselines.sortedBy { it.entityState.index }) + add(StuffTextMessage("precache\n")) + } + // Yamagi SV_SendClientMessages forwards demo records through Netchan_Transmit. + // Reserve channel header/control space even though .dm2 records contain no channel header. + val buffer = sizebuf_t(Defines.MAX_MSGLEN - 16) + records = buildList { + for (message in messages) { + require(message.size <= buffer.maxsize) { "Demo bootstrap message exceeds record capacity" } + if (buffer.cursize + message.size > buffer.maxsize) { + add(PacketBytes.copyOf(buffer.data, buffer.cursize)) + buffer.cursize = 0 + } + message.writeTo(buffer) + } + if (buffer.cursize > 0) add(PacketBytes.copyOf(buffer.data, buffer.cursize)) + } + } + + /** All records were encoded at construction; later source collection changes cannot affect them. */ + fun writeTo(writer: DemoWriter) { records.forEach(writer::write) } +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoFiles.kt b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoFiles.kt new file mode 100644 index 000000000..78ddb671a --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoFiles.kt @@ -0,0 +1,93 @@ +package org.demoth.cake.demo + +import jake2.qcommon.vfs.VfsOpenOptions +import jake2.qcommon.vfs.VfsWriteOptions +import jake2.qcommon.vfs.WritableFileSystem +import jake2.qcommon.vfs.VfsEntry +import jake2.qcommon.vfs.VfsLayer +import jake2.qcommon.vfs.VfsSource +import jake2.qcommon.vfs.VfsSourceType +import java.io.FilterInputStream +import java.io.FilterOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path +import java.util.Locale + +/** Profile-local recordings take precedence over installed/archived demos with the same logical name. */ +internal class DemoFiles( + private val writable: () -> WritableFileSystem, + private val openContent: (String) -> InputStream?, +) { + data class Output(val path: String, val stream: OutputStream) + + /** Fresh recordings overlay the installed snapshot; no package extraction or media loading. */ + fun entries(installed: List, caseSensitive: Boolean = false): List { + val root = Path.of(writable().writeRoot()).toAbsolutePath().normalize() + val directory = root.resolve("demos") + val merged = installed.associateByTo(linkedMapOf()) { it.normalizedPath } + if (Files.isDirectory(directory)) Files.walk(directory).use { paths -> + paths.filter { Files.isRegularFile(it, LinkOption.NOFOLLOW_LINKS) && it.fileName.toString().endsWith(".dm2", true) } + .sorted().forEach { file -> + val display = root.relativize(file).toString().replace('\\', '/') + val logical = if (caseSensitive) display else display.lowercase(Locale.ROOT) + merged[logical] = VfsEntry(logical, display, VfsLayer.PROFILE_LOOSE, 0, + VfsSource(VfsSourceType.DIRECTORY, root, display, null, false, false), + Files.size(file), Files.getLastModifiedTime(file).toMillis()) + } + } + return merged.values.sortedBy { it.normalizedPath } + } + + /** Open the selected winner exactly, including mixed-case saved names and demos outside demos/. */ + fun open(entry: VfsEntry): InputStream { + if (entry.layer != VfsLayer.PROFILE_LOOSE) + return openContent(entry.normalizedPath) ?: throw IOException("Demo not found: ${entry.displayPath}") + val files = writable() + val root = Path.of(files.writeRoot()).toAbsolutePath().normalize() + if (entry.source.containerPath != root) throw IOException("Demo belongs to another profile") + val handle = files.openReadReal(entry.displayPath, VfsOpenOptions.DEFAULT).value + ?: throw IOException("Demo not found: ${entry.displayPath}") + try { + return object : FilterInputStream(handle.inputStream()) { override fun close() = handle.close() } + } catch (failure: Exception) { + try { handle.close() } catch (closing: Exception) { failure.addSuppressed(closing) } + throw failure + } + } + + fun create(name: String): Output { + val path = demoLogicalPath(name) ?: throw IOException("Invalid demo path: $name") + val files = writable() + // Recording names are explicit; don't silently overwrite a previous take. + val opened = files.openWrite(path, VfsWriteOptions(false, false)) + val handle = opened.value ?: throw IOException(opened.error ?: "Could not create $path") + try { + val output = object : FilterOutputStream(handle.outputStream()) { + override fun write(bytes: ByteArray, offset: Int, length: Int) = out.write(bytes, offset, length) + override fun close() = handle.close() + } + return Output(files.resolveWritePath(path), output) + } catch (failure: Exception) { + try { handle.close() } catch (closing: Exception) { failure.addSuppressed(closing) } + throw failure + } + } + + fun open(name: String): InputStream { + val path = demoLogicalPath(name) ?: throw IOException("Invalid demo path: $name") + val local = writable().openReadReal(path, VfsOpenOptions.DEFAULT).value + if (local != null) { + try { + return object : FilterInputStream(local.inputStream()) { override fun close() = local.close() } + } catch (failure: Exception) { + try { local.close() } catch (closing: Exception) { failure.addSuppressed(closing) } + throw failure + } + } + return openContent(path) ?: throw IOException("Demo not found in active profile: $path") + } +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoMetadata.kt b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoMetadata.kt new file mode 100644 index 000000000..933a9f6f5 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoMetadata.kt @@ -0,0 +1,46 @@ +package org.demoth.cake.demo + +import jake2.qcommon.Defines +import jake2.qcommon.network.demo.DemoMessageDecoder +import jake2.qcommon.network.demo.DemoReader +import jake2.qcommon.network.messages.server.* +import jake2.qcommon.network.protocol.MessageDecodeResult +import java.io.IOException +import java.io.InputStream + +/** Startup information only; reading metadata neither loads game assets nor scans the full recording. */ +data class DemoMetadata(val protocol: Int, val gameDirectory: String, val levelName: String, val mapPath: String?) { + companion object { + const val MAX_STARTUP_RECORDS = 64 + + /** Owns and closes [input] on success, rejection or reaching the bounded startup budget. */ + fun read(input: InputStream): DemoMetadata = DemoReader(input).use { reader -> + val decoder = DemoMessageDecoder() + var server: ServerDataMessage? = null + var map: String? = null + fun result(): DemoMetadata { + val data = server ?: throw IOException("Demo has no server data in its startup records") + return DemoMetadata(data.protocol, data.gameName.ifEmpty { "baseq2" }, data.levelString, map) + } + repeat(MAX_STARTUP_RECORDS) { + when (val record = reader.read()) { + DemoReader.Record.End -> return result() + is DemoReader.Record.Body -> { + val messages = when (val decoded = decoder.decode(record.bytes)) { + is MessageDecodeResult.Decoded -> decoded.messages + is MessageDecodeResult.Rejected -> throw IOException(decoded.detail) + } + for (message in messages) when (message) { + is ServerDataMessage -> server = message + is ConfigStringMessage -> if (message.index == Defines.CS_MODELS + 1) map = message.config + else -> Unit + } + if (server != null && (map != null || messages.any { it is FrameHeaderMessage || it is StuffTextMessage })) + return result() + } + } + } + result() + } + } +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoPath.kt b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoPath.kt new file mode 100644 index 000000000..59579675f --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoPath.kt @@ -0,0 +1,10 @@ +package org.demoth.cake.demo + +/** Console names resolve within the active profile's demos directory, including archived entries. */ +internal fun demoLogicalPath(name: String): String? { + val normalized = name.replace('\\', '/') + if (normalized.isBlank() || normalized.startsWith('/') || ':' in normalized) return null + if (normalized.split('/').any { it.isEmpty() || it == "." || it == ".." }) return null + val path = if (normalized.startsWith("demos/", ignoreCase = true)) normalized else "demos/$normalized" + return if (path.endsWith(".dm2", ignoreCase = true)) path else "$path.dm2" +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoPlayback.kt b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoPlayback.kt new file mode 100644 index 000000000..d15f03151 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoPlayback.kt @@ -0,0 +1,124 @@ +package org.demoth.cake.demo + +import jake2.qcommon.Defines +import jake2.qcommon.network.demo.DemoReader +import jake2.qcommon.network.demo.DemoMessageDecoder +import jake2.qcommon.network.messages.server.FrameHeaderMessage +import jake2.qcommon.network.messages.server.ServerDataMessage +import jake2.qcommon.network.messages.server.ServerMessage +import jake2.qcommon.network.messages.server.PlayerInfoMessage +import jake2.qcommon.network.messages.server.PacketEntitiesMessage +import jake2.qcommon.network.protocol.MessageDecodeResult +import java.io.Closeable +import java.io.IOException + +/** Client-owned playback clock. Neither sockets nor a running game server participate. */ +class DemoPlayback(private val reader: DemoReader) : Closeable { + private data class Pending(val messages: List?, val due: Double, val frameTime: Int?) + private var pending: Pending? = null + private var closed = false + private val decoder = DemoMessageDecoder() + private var serverDataSeen = false + private var originFrame: Int? = null + private var previousFrame: Int? = null + private var originDue = 0.0 + private var lastDue = 0.0 + private var elapsed = 0.0 + private var framePresented = false + private var presentedDue = 0.0 + private var presentedTime = 0 + + /** Absolute recorded render time, one snapshot behind the current frame for interpolation. */ + val presentationTimeMillis: Int + get() = presentedTime - 100 + ((elapsed - presentedDue) * 1000).toInt().coerceIn(0, 100) + val interpolationSeconds: Float + get() = (elapsed - presentedDue).toFloat().coerceIn(0f, .1f) + + /** + * Dispatch whole validated records in order. Returns false on normal completion or explicit close. + * The record budget bounds bootstrap/empty-record work and catch-up after long application stalls. + * Returning false from [dispatch] stops immediately, for example after a recorded disconnect. + */ + fun update(deltaSeconds: Float, dispatch: (List) -> Boolean): Boolean { + if (closed) return false + require(deltaSeconds.isFinite() && deltaSeconds >= 0f) + if (framePresented) elapsed += deltaSeconds + repeat(MAX_RECORDS_PER_UPDATE) { + val next = pending ?: readPending().also { pending = it } + if (next.due > elapsed + 0.000001) return true + pending = null + val messages = next.messages + if (messages == null) { + close() + return false + } + if (!dispatch(messages) || closed) return false + if (next.frameTime != null) { + framePresented = true + presentedTime = next.frameTime + presentedDue = next.due + } + } + return true + } + + private fun readPending(): Pending { + val record = reader.read() + if (record == DemoReader.Record.End) { + if (!serverDataSeen || !framePresented) throw IOException("Demo contains no playable world frames") + return Pending(null, lastDue + .1, null) + } + val body = (record as DemoReader.Record.Body).bytes + val messages = when (val result = decoder.decode(body)) { + is MessageDecodeResult.Decoded -> result.messages + is MessageDecodeResult.Rejected -> throw IOException("Invalid demo message at byte ${reader.offset - body.size + result.offset}: ${result.detail}") + } + if (messages.any { it is ServerDataMessage }) { + for (data in messages.filterIsInstance()) { + if (data.playerNumber == -1) throw IOException("Cinematic demos are not supported") + if (data.playerNumber !in 0 until Defines.MAX_CLIENTS) throw IOException("Invalid demo player slot ${data.playerNumber}") + } + serverDataSeen = true + originFrame = null + previousFrame = null + originDue = if (framePresented) lastDue + .1 else 0.0 + lastDue = originDue + } + if (!serverDataSeen && messages.isNotEmpty()) throw IOException("Demo is missing initial server data") + val frames = messages.filterIsInstance() + if (frames.size > 1) throw IOException("Multiple snapshots in one demo record") + val frame = frames.singleOrNull() + val playerIndex = messages.indexOfFirst { it is PlayerInfoMessage } + val entitiesIndex = messages.indexOfFirst { it is PacketEntitiesMessage } + if (frame == null && (playerIndex >= 0 || entitiesIndex >= 0)) + throw IOException("Demo snapshot data has no frame header") + if (frame != null) { + val headerIndex = messages.indexOf(frame) + if (playerIndex <= headerIndex || entitiesIndex <= playerIndex || + messages.count { it is PlayerInfoMessage } != 1 || messages.count { it is PacketEntitiesMessage } != 1) + throw IOException("Demo contains an incomplete snapshot") + if (previousFrame == null && frame.lastFrame > 0) + throw IOException("Demo does not start with a full snapshot") + if (frame.frameNumber < 0 || frame.frameNumber > Int.MAX_VALUE / 100) + throw IOException("Invalid demo frame number ${frame.frameNumber}") + if (previousFrame != null && frame.frameNumber <= previousFrame!!) + throw IOException("Demo frame numbers do not advance") + if (originFrame == null) originFrame = frame.frameNumber + lastDue = originDue + (frame.frameNumber.toLong() - originFrame!!) / 10.0 + previousFrame = frame.frameNumber + } + return Pending(messages, lastDue, frame?.frameNumber?.times(100)) + } + + override fun close() { + if (!closed) { + closed = true + pending = null + reader.close() + } + } + + companion object { + const val MAX_RECORDS_PER_UPDATE = 64 + } +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoRecording.kt b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoRecording.kt new file mode 100644 index 000000000..a5b0286e9 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/demo/DemoRecording.kt @@ -0,0 +1,94 @@ +package org.demoth.cake.demo + +import jake2.qcommon.Defines +import jake2.qcommon.network.demo.DemoWriter +import jake2.qcommon.network.messages.server.* +import jake2.qcommon.network.protocol.MessageBodyCodec +import jake2.qcommon.network.protocol.MessageDecodeResult +import jake2.qcommon.network.protocol.PacketEnvelope +import jake2.qcommon.network.transport.PacketBytes +import java.io.Closeable +import java.io.IOException + +/** + * Owns recording readiness and output, independently of sockets, a local host, or presentation. + * [bootstrap] snapshots the latest client startup state when a full frame is ready, including + * config/baseline changes received while waiting. The live client supplies reconstruction validity. + */ +class DemoRecording(private val writer: DemoWriter, private val bootstrap: () -> DemoBootstrap) : Closeable { + private var waiting = true + private var closed = false + private val recordedFrames = IntArray(Defines.UPDATE_BACKUP) { -1 } + + /** The command sender must request a full snapshot without invalidating the displayed frame. */ + val needsFullSnapshot: Boolean get() = !closed && waiting + + /** Live server packets retain their exact body; channel headers are never part of .dm2 records. */ + fun capture(packet: PacketEnvelope.Sequenced, snapshotAccepted: Boolean) { + require(packet.qport == null) { "Recording expects a server packet" } + capture(PacketBytes.copyOfRange(packet.bytes.toByteArray(), packet.headerSize, + packet.bytes.size - packet.headerSize), snapshotAccepted) + } + + /** + * Called after the client processes an accepted packet. [snapshotAccepted] must indicate whether + * its snapshot was reconstructed successfully; structural decoding alone cannot establish that. + * Whole recorded bodies are preserved, including messages before/after their snapshot. + */ + fun capture(body: PacketBytes, snapshotAccepted: Boolean) { + check(!closed) { "Demo recording is closed" } + try { + val messages = when (val decoded = MessageBodyCodec.decodeServerBody(body)) { + is MessageDecodeResult.Decoded -> decoded.messages + is MessageDecodeResult.Rejected -> throw IOException("Invalid recording payload: ${decoded.detail}") + } + if (messages.any { it is ServerDataMessage }) { + // Map/session reset: capture fresh startup state with that map's first full snapshot. + waitForFullSnapshot() + return + } + val frames = messages.filterIsInstance() + val frame = frames.singleOrNull() + val player = messages.indexOfFirst { it is PlayerInfoMessage } + val entities = messages.indexOfFirst { it is PacketEntitiesMessage } + if (frames.isNotEmpty() || player >= 0 || entities >= 0) { + val complete = frame != null && messages.indexOf(frame) < player && player < entities && + messages.count { it is PlayerInfoMessage } == 1 && messages.count { it is PacketEntitiesMessage } == 1 + if (!complete || !snapshotAccepted) { + waitForFullSnapshot() + return + } + // A live-valid delta may still depend on a frame received before recording began. + if (frame.lastFrame > 0 && + recordedFrames[frame.lastFrame and (recordedFrames.size - 1)] != frame.lastFrame) { + waitForFullSnapshot() + return + } + } + if (waiting) { + if (frame == null || frame.lastFrame > 0) return + bootstrap().writeTo(writer) + waiting = false + } + writer.write(body) + if (frame != null) recordedFrames[frame.frameNumber and (recordedFrames.size - 1)] = frame.frameNumber + } catch (failure: Exception) { + try { close() } catch (closeFailure: Exception) { + if (closeFailure !== failure) failure.addSuppressed(closeFailure) + } + throw failure + } + } + + private fun waitForFullSnapshot() { + waiting = true + recordedFrames.fill(-1) + } + + override fun close() { + if (!closed) { + closed = true + writer.close() + } + } +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/input/InputManager.kt b/cake/core/src/main/kotlin/org/demoth/cake/input/InputManager.kt index ae26f02c7..4e3beda98 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/input/InputManager.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/input/InputManager.kt @@ -108,7 +108,7 @@ class InputManager( } // called at fixed client command cadence - fun buildMoveMessage(outgoingSequence: Int, currentFrame: ClientFrame, commandMsec: Int): MoveMessage { + fun buildMoveMessage(outgoingSequence: Int, currentFrame: ClientFrame, commandMsec: Int, requestFullSnapshot: Boolean = false): MoveMessage { val nowNanos = nowNanosProvider() // assemble the inputs and commands, then transmit them @@ -157,7 +157,7 @@ class InputManager( // deliver the message return MoveMessage.fromInput( - !currentFrame.valid, + requestFullSnapshot || !currentFrame.valid, currentFrame.serverframe, oldestCmd, oldCmd, diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ContentBrowserStage.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ContentBrowserStage.kt index 0b7e29717..509ace1f3 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ContentBrowserStage.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ContentBrowserStage.kt @@ -9,7 +9,9 @@ import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.scenes.scene2d.Actor import com.badlogic.gdx.utils.viewport.Viewport import jake2.qcommon.vfs.VfsEntry +import org.demoth.cake.demo.DemoMetadata import org.demoth.cake.ui.ContentBrowser +import org.demoth.cake.ui.ContentBrowserNavigation import org.demoth.cake.ui.ContentBrowserStyles import org.demoth.cake.ui.GameUiStyle import org.demoth.cake.ui.ContentPreview @@ -25,11 +27,16 @@ class ContentBrowserStage( private val thumbnails: ImageThumbnails, previewAssets: AssetManager, loadSound: (VfsEntry) -> Sound, + private val readDemoMetadata: (VfsEntry) -> DemoMetadata? = { null }, + private val navigation: ContentBrowserNavigation = ContentBrowserNavigation(), ) : BackNavigableMenuStage(viewport, menuEventBus, MenuIntent.OpenMainMenu) { private val styles = ContentBrowserStyles.fromMenu(style.menuWidgets) private val media = ContentPreview(styles, thumbnails, previewAssets, loadSound) private val title = Label("Content browser", styles.label) private val content = Container().fill() + private val notice = Container().fillX() + private val demoDescriptions = mutableMapOf() + private var profileName: String? = null init { addActor(Table().apply { @@ -39,19 +46,44 @@ class ContentBrowserStage( add(title).growX().left() add(createBackButton(style = styles.button)).right() }).growX().padBottom(8f).row() + add(notice).growX().row() add(content).grow() }) } fun show(entries: List, profileName: String, message: String? = null) { hide() + this.profileName = profileName + val location = navigation.forProfile(profileName) + notice.actor = message?.let { Label(it, styles.label).apply { setWrap(true) } } media.contentPaths = entries.map { it.normalizedPath } title.setText("Content: $profileName (${entries.size} files)") content.actor = if (entries.isEmpty()) { Label(message ?: "No content found in this profile.", styles.label) } else { ContentBrowser(entries, profileName, styles, previewSelection = media::open, loadThumbnail = thumbnails::load, - clearThumbnails = thumbnails::clear, onSelect = media::select, showSource = true) + clearThumbnails = thumbnails::clear, onSelect = media::select, showSource = true, + selectionDetails = ::describeDemo, selectionDetailLines = 2, initialLocation = location, + openEntry = { entry -> + if (entry.normalizedPath.endsWith(".dm2", ignoreCase = true)) { + menuEventBus.postIntent(MenuIntent.PlayDemoRequested(profileName, entry)) + true + } else false + }) + } + } + + private fun describeDemo(entry: VfsEntry): String? { + if (!entry.normalizedPath.endsWith(".dm2", ignoreCase = true)) return null + return demoDescriptions.getOrPut(entry) { + try { + val info = readDemoMetadata(entry) + if (info == null) "Demo metadata unavailable." else + "Demo: protocol ${info.protocol} | Game: ${info.gameDirectory}\nLevel: ${info.levelName}" + + (info.mapPath?.let { " | Map: $it" } ?: "") + } catch (error: Exception) { + "Could not read demo: ${error.message}" + } } } @@ -71,10 +103,14 @@ class ContentBrowserStage( /** Detach actors before releasing their borrowed textures; reopening supplies a new snapshot. */ fun hide() { + val browser = content.actor as? ContentBrowser + profileName?.let { profile -> browser?.let { navigation.remember(profile, it.location()) } } unfocusAll() content.actor = null + notice.actor = null media.clear() media.contentPaths = emptyList() + demoDescriptions.clear() thumbnails.clear() } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/MenuStages.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/MenuStages.kt index 79c60130d..f7f3e33fd 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/MenuStages.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/MenuStages.kt @@ -6,6 +6,8 @@ import com.badlogic.gdx.assets.loaders.FileHandleResolver import com.badlogic.gdx.graphics.Texture import com.badlogic.gdx.utils.Disposable import jake2.qcommon.Com +import jake2.qcommon.vfs.VfsEntry +import org.demoth.cake.demo.DemoMetadata import ktx.assets.TextAssetLoader import org.demoth.cake.assets.* import org.demoth.cake.assets.bsp.BspLoader @@ -14,6 +16,7 @@ import org.demoth.cake.assets.md2.Md2Asset import org.demoth.cake.assets.md2.Md2Loader import org.demoth.cake.ui.GameUiStyle import org.demoth.cake.ui.ImageThumbnails +import org.demoth.cake.ui.ContentBrowserNavigation import org.demoth.cake.ui.UiViewport import org.demoth.cake.ui.menu.MenuEventBus import org.demoth.cake.ui.menu.MenuScreen @@ -28,9 +31,12 @@ class MenuStages( private val fileResolver: CakeFileResolver, private val showHosting: Boolean, private val readSavePreview: (String, String) -> ByteArray? = { _, _ -> null }, + private val listContentEntries: () -> List = fileResolver::listContentEntries, + private val readDemoMetadata: (VfsEntry) -> DemoMetadata? = { null }, private val activeProfileId: () -> String?, ) : Disposable { private val stages = linkedMapOf() + private val browserNavigation = ContentBrowserNavigation() var activeScreen: MenuScreen = MenuScreen.MAIN private set @@ -62,11 +68,11 @@ class MenuStages( MenuScreen.OPTIONS_SECTION -> OptionsSectionStage(viewport, menuEventBus, style) MenuScreen.CONTENT_BROWSER -> ContentBrowserStage(viewport, menuEventBus, style, ImageThumbnails(assetManager, FileHandleResolver(fileResolver::resolveContent)), - createPreviewAssets()) { entry -> + createPreviewAssets(), loadSound = { entry -> val file = requireNotNull(fileResolver.resolveContent(entry.normalizedPath)) { "Missing profile sound" } // Preview sounds are owned separately from the shared asset manager. ConvertingSoundLoader(fileResolver).loadSync(assetManager, entry.normalizedPath, file, null) - } + }, readDemoMetadata = readDemoMetadata, navigation = browserNavigation) } } if (activeScreen == MenuScreen.CONTENT_BROWSER) refreshContentBrowser() @@ -112,14 +118,18 @@ class MenuStages( stages.clear() } - private fun refreshContentBrowser() { + fun showContentBrowser(message: String? = null) { + if (!select(MenuScreen.CONTENT_BROWSER) || message != null) refreshContentBrowser(message) + } + + private fun refreshContentBrowser(message: String? = null) { val profileId = activeProfileId() if (profileId == null) { contentBrowserStage.show(emptyList(), "", "Select a profile to browse its content.") return } try { - contentBrowserStage.show(fileResolver.listContentEntries(), profileId) + contentBrowserStage.show(listContentEntries(), profileId, message) } catch (error: Exception) { Com.Warn("Could not list profile content: ${error.message}") contentBrowserStage.show(emptyList(), profileId, "Could not list profile content.") diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/ClientEntityManager.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/ClientEntityManager.kt index 4ed0423a2..d27f6eb65 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/ClientEntityManager.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/ClientEntityManager.kt @@ -99,6 +99,11 @@ class ClientEntityManager : Disposable { return clientEntities[entityIndex].current.angles.copyOf() } + /** Copy received baselines for demo startup without sharing mutable entity storage. */ + fun snapshotBaselines(): List = clientEntities.mapNotNull { entity -> + entity.baseline.takeIf { it.index > 0 }?.let(::SpawnBaselineMessage) + } + fun processBaselineMessage(msg: SpawnBaselineMessage) { msg.entityState.copyTo(clientEntities[msg.entityState.index].baseline) } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/Game3dScreen.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/Game3dScreen.kt index fa08cbd00..5d2123b42 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/Game3dScreen.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/Game3dScreen.kt @@ -1,5 +1,7 @@ package org.demoth.cake.stages.ingame +import org.demoth.cake.demo.DemoBootstrap + import org.demoth.cake.assets.bsp.BspEntityLightSampler import org.demoth.cake.assets.bsp.BspInlineSurfaceMaterialController import org.demoth.cake.assets.bsp.BspInlineTextureAnimationController @@ -27,6 +29,8 @@ import jake2.qcommon.CM import jake2.qcommon.Com import jake2.qcommon.Defines import jake2.qcommon.Globals +import jake2.qcommon.exec.Cvar +import jake2.qcommon.exec.find import jake2.qcommon.network.messages.client.StringCmdMessage import jake2.qcommon.network.messages.client.MoveMessage import jake2.qcommon.network.messages.server.* @@ -92,7 +96,7 @@ class Game3dScreen( private val prediction by lazy { ClientPrediction(collisionModel, entityManager, gameConfig) } private val dynamicLightSystem = DynamicLightSystem() private val audioSystem: CakeAudioSystem = FireAndForgetCakeAudioSystem( - currentTimeMsProvider = { Globals.curtime }, + currentTimeMsProvider = { presentationTimeMillis }, entityOriginProvider = { entityIndex -> entityManager.getEntityOrigin(entityIndex) }, localPlayerEntityIndexProvider = { val playerIndex = gameConfig.playerConfiguration.playerIndex @@ -110,12 +114,28 @@ class Game3dScreen( private val hudOverlayRenderer by lazy { HudOverlayRenderer(spriteBatch, gameConfig, uiViewport) } private val entityManager = ClientEntityManager() + private val heatbeamGunOffset = Vector3() + private val handedness = Cvar.getInstance().find("hand") private val effectsSystem = ClientEffectsSystem( assetManager = assetManager, entityManager = entityManager, audioSystem = audioSystem, cameraProvider = { camera }, dynamicLightSystem = dynamicLightSystem, + currentTimeMsProvider = { presentationTimeMillis }, + localPlayerEntityIndexProvider = { + gameConfig.playerConfiguration.playerIndex.takeIf { it >= 0 }?.plus(1) + }, + gunOffsetProvider = { + val current = entityManager.currentFrame.playerstate.gunoffset + val previous = entityManager.previousFrame?.playerstate?.gunoffset ?: current + heatbeamGunOffset.set( + previous[0] + (current[0] - previous[0]) * lerpFrac, + previous[1] + (current[1] - previous[1]) * lerpFrac, + previous[2] + (current[2] - previous[2]) * lerpFrac, + ) + }, + handednessProvider = { handedness.typedValue }, ) private val soundMessageHandler = IngameSoundMessageHandler( gameConfig = gameConfig, @@ -123,6 +143,7 @@ class Game3dScreen( effectsSystem = effectsSystem, audioSystem = audioSystem, dynamicLightSystem = dynamicLightSystem, + currentTimeMsProvider = { presentationTimeMillis }, ) private val replicatedEntityEffectCollector = ReplicatedEntityEffectCollector( entityManager = entityManager, @@ -304,7 +325,7 @@ class Game3dScreen( postProcessShader.setUniformf("u_vignetteEnabled", if (RenderTuningCvars.rPostVignette.typedValue) 1f else 0f) postProcessShader.setUniformf("u_vignetteStrength", RenderTuningCvars.rPostVignetteStrength.typedValue) postProcessShader.setUniformf("u_underwaterEnabled", if (underwaterPostEnabled) 1f else 0f) - postProcessShader.setUniformf("u_timeSeconds", Globals.curtime * 0.001f) + postProcessShader.setUniformf("u_timeSeconds", presentationTimeMillis * 0.001f) it.draw(region, 0f, 0f, width, height) } postProcessBatch.shader = previousShader @@ -332,6 +353,17 @@ class Game3dScreen( inputManager.resetMouseLookReference() } + var demoPlayback: Boolean = false + private var demoTimeMillis = 0 + private val presentationTimeMillis: Int + get() = if (demoPlayback) demoTimeMillis else Globals.curtime + + fun setDemoTime(timeMillis: Int, interpolationSeconds: Float) { + demoTimeMillis = timeMillis + entityManager.time = timeMillis + entityManager.lerpAcc = interpolationSeconds + } + override fun render(delta: Float) { if (Gdx.graphics.backBufferWidth <= 0 || Gdx.graphics.backBufferHeight <= 0) return // A screen can be created after the application's last resize callback. @@ -377,7 +409,8 @@ class Game3dScreen( worldPresentationRenderer.render( WorldPresentationRenderer.FrameContext( delta = delta, - currentTimeMs = Globals.curtime, + demoPlayback = demoPlayback, + currentTimeMs = presentationTimeMillis, sceneFrameBuffer = sceneFrameBuffer?.renderTarget, camera = camera, modelBatch = modelBatch, @@ -430,7 +463,7 @@ class Game3dScreen( 0f, 0f, Gdx.graphics.width.toFloat(), Gdx.graphics.height.toFloat(), ) cinematicController.render( - currentTimeMs = Globals.curtime, + currentTimeMs = presentationTimeMillis, spriteBatch = spriteBatch, screenWidth = Gdx.graphics.width.toFloat(), screenHeight = Gdx.graphics.height.toFloat(), @@ -576,7 +609,7 @@ class Game3dScreen( inlineSurfaceMaterialController?.update( modelInstance = entity.modelInstance, inlineModelIndex = inlineModelIndex, - currentTimeMs = Globals.curtime, + currentTimeMs = presentationTimeMillis, lightStyleResolver = ::lightStyleValue, ) } @@ -821,8 +854,8 @@ class Game3dScreen( inputManager.updateLocalInput(deltaTime, entityManager.currentFrame) } - fun buildMoveMessage(outgoingSequence: Int, commandMsec: Int): MoveMessage { - return inputManager.buildMoveMessage(outgoingSequence, entityManager.currentFrame, commandMsec) + fun buildMoveMessage(outgoingSequence: Int, commandMsec: Int, requestFullSnapshot: Boolean = false): MoveMessage { + return inputManager.buildMoveMessage(outgoingSequence, entityManager.currentFrame, commandMsec, requestFullSnapshot) } @Deprecated("Use updateLocalInput + buildMoveMessage") @@ -886,7 +919,7 @@ class Game3dScreen( oldZ = newZ } - val predictionEnabled = (currentState.pmove.pm_flags.toInt() and Defines.PMF_NO_PREDICTION) == 0 + val predictionEnabled = !demoPlayback && (currentState.pmove.pm_flags.toInt() and Defines.PMF_NO_PREDICTION) == 0 val interpolatedX: Float val interpolatedY: Float val interpolatedZ: Float @@ -901,7 +934,7 @@ class Game3dScreen( interpolatedX = prediction.predictedOrigin[0] + viewOffsetX - backLerp * prediction.predictionError[0] interpolatedY = prediction.predictedOrigin[1] + viewOffsetY - backLerp * prediction.predictionError[1] interpolatedZ = prediction.predictedOrigin[2] + viewOffsetZ - backLerp * prediction.predictionError[2] - - prediction.smoothedStepOffset(Globals.curtime) + prediction.smoothedStepOffset(presentationTimeMillis) } else { interpolatedX = oldX + (newX - oldX) * lerp interpolatedY = oldY + (newY - oldY) * lerp @@ -1012,10 +1045,20 @@ class Game3dScreen( // region SERVER MESSAGE PARSING + private var recordingServerData: ServerDataMessage? = null + + /** Snapshot current startup data when the recorder receives its first full frame. */ + fun demoBootstrap(): DemoBootstrap = DemoBootstrap( + checkNotNull(recordingServerData) { "No server data available for recording" }, + gameConfig.snapshotConfigStrings(), entityManager.snapshotBaselines(), + ) + /** * CL_ParseServerData */ override fun processServerDataMessage(msg: ServerDataMessage) { + recordingServerData = msg + gameConfig.allowMissingModelConfigs = demoPlayback && msg.protocol == 26 levelString = msg.levelString // player slot used by prediction/entity visibility/HUD highlighting. gameConfig.playerConfiguration.playerIndex = msg.playerNumber @@ -1024,7 +1067,7 @@ class Game3dScreen( if (msg.playerNumber == -1) { presentationMode = PresentationMode.CINEMATIC presentationRuntime = cinematicPresentationRuntime - cinematicController.begin(levelString, Globals.curtime) + cinematicController.begin(levelString, presentationTimeMillis) } else { presentationMode = PresentationMode.WORLD presentationRuntime = worldPresentationRuntime @@ -1034,7 +1077,7 @@ class Game3dScreen( // Cake owns the shared HUD style and injects it before serverdata handling. val resolvedHudStyle = requireNotNull(hudStyle) { "HUD style must be set before serverdata is processed" } hud?.dispose() - hud = Hud(spriteBatch, resolvedHudStyle, GameConfigLayoutDataProvider(gameConfig)) + hud = Hud(spriteBatch, resolvedHudStyle, GameConfigLayoutDataProvider(gameConfig), currentTimeMsProvider = { presentationTimeMillis }) } /** @@ -1098,7 +1141,7 @@ class Game3dScreen( soundMessageHandler.playEntityEventSounds() // Cross-reference: old `CL_ents.parsePacketEntities` calls `CL_pred.CheckPredictionError` // once a valid frame has been fully reconstructed. - prediction.onServerFrameParsed(entityManager.currentFrame) + if (!demoPlayback) prediction.onServerFrameParsed(entityManager.currentFrame) } return validMessage } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/IngameSoundMessageHandler.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/IngameSoundMessageHandler.kt index dee0cefb6..0b24567f4 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/IngameSoundMessageHandler.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/IngameSoundMessageHandler.kt @@ -28,6 +28,7 @@ internal class IngameSoundMessageHandler( private val effectsSystem: ClientEffectsSystem, private val audioSystem: CakeAudioSystem, private val dynamicLightSystem: DynamicLightSystem, + private val currentTimeMsProvider: () -> Int = { Globals.curtime }, ) { private val loopSoundRequests = mutableListOf() @@ -304,7 +305,7 @@ internal class IngameSoundMessageHandler( green = profile.green, blue = profile.blue, lifetimeMs = profile.lifetimeMs, - currentTimeMs = Globals.curtime, + currentTimeMs = currentTimeMsProvider(), ) } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/WorldPresentationRenderer.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/WorldPresentationRenderer.kt index cd5dd62d3..3cc8a34d3 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/WorldPresentationRenderer.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/WorldPresentationRenderer.kt @@ -52,6 +52,7 @@ internal class WorldPresentationRenderer { */ data class FrameContext( val delta: Float, + val demoPlayback: Boolean = false, val currentTimeMs: Int, val sceneFrameBuffer: FrameBuffer?, val camera: PerspectiveCamera, @@ -104,7 +105,7 @@ internal class WorldPresentationRenderer { // Cross-reference: old frame order in `CL.Frame()` calls `CL_pred.PredictMovement()` // before calculating render view (`CL_ents.CalcViewValues`). - context.prediction.predictMovement( + if (!context.demoPlayback) context.prediction.predictMovement( context.entityManager.currentFrame, context.inputManager, context.gameConfig.playerConfiguration.playerIndex, @@ -125,6 +126,7 @@ internal class WorldPresentationRenderer { context.effectsSystem.update(context.delta, context.entityManager.currentFrame.serverframe) context.replicatedEntityEffectCollector.collectTrails(lerpFrac) context.dynamicLightSystem.beginFrame(context.currentTimeMs, context.delta) + context.effectsSystem.collectDynamicLights() context.replicatedEntityEffectCollector.collectDynamicLights( lerpFrac = lerpFrac, currentTimeMs = context.currentTimeMs, @@ -211,7 +213,7 @@ internal class WorldPresentationRenderer { renderedTranslucentSprites++ } } - context.entityManager.lerpAcc += context.delta + if (!context.demoPlayback) context.entityManager.lerpAcc += context.delta } finally { context.modelBatch.end() } @@ -225,6 +227,7 @@ internal class WorldPresentationRenderer { context.modelBatch.begin(context.camera) try { context.effectsSystem.render(context.modelBatch) + context.effectsSystem.renderLasers(context.modelBatch, context.beamRenderer) } finally { context.modelBatch.end() } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/BeamEffects.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/BeamEffects.kt new file mode 100644 index 000000000..6f313b935 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/BeamEffects.kt @@ -0,0 +1,114 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.math.MathUtils +import com.badlogic.gdx.math.Vector3 +import jake2.qcommon.Defines +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.* +import kotlin.math.ceil +import kotlin.random.Random + +internal enum class BeamKind(val modelPath: String, val segmentLength: Float, val frame: Int = 0, val fullBright: Boolean = false) { + CABLE("models/monsters/parasite/segment/tris.md2", 30f), + GRAPPLE("models/ctf/segment/tris.md2", 30f), + LIGHTNING("models/proj/lightning/tris.md2", 35f, fullBright = true), + HEATBEAM_PLAYER("models/proj/beam/tris.md2", 32f, frame = 1, fullBright = true), + HEATBEAM_MONSTER("models/proj/beam/tris.md2", 32f, frame = 2, fullBright = true), +} + +internal data class BeamSoundRequest(val path: String, val entityIndex: Int, val channel: Int) + +internal fun interface BeamSegmentSink { + fun submit(kind: BeamKind, x: Float, y: Float, z: Float, pitch: Float, yaw: Float, roll: Float) +} + +/** + * Owned, bounded beam state (CL_ParseBeam / CL_ParseLightning / CL_AddBeams). + * Cables refresh by source entity; lightning refreshes by source AND destination. + * Geometry is rebuilt from the current view each frame, independently of new network messages. + */ +internal class BeamEffects( + private val playSound: (BeamSoundRequest) -> Unit, + private val maximumBeams: Int = 32, + private val maximumSegmentsPerBeam: Int = 128, + private val random: Random = Random.Default, +) { + init { require(maximumBeams > 0 && maximumSegmentsPerBeam > 0) } + private data class Key(val source: Int, val destination: Int?) + private data class Beam(val kind: BeamKind, val origin: Vector3f, val end: Vector3f, + val offset: Vector3f, val expiresAt: Long, val roll: Float) + private val beams = LinkedHashMap() + private val start = Vector3() + private val direction = Vector3() + val activeCount: Int get() = beams.size + + /** Returns false for unrelated messages, including heatbeams which have their own presentation. */ + fun process(message: TEMessage, nowMs: Int): Boolean { + val key: Key + val kind: BeamKind + val origin: Vector3f + val end: Vector3f + val offset: Vector3f + when (message) { + is BeamTEMessage -> { + if (message.style != Defines.TE_PARASITE_ATTACK && message.style != Defines.TE_MEDIC_CABLE_ATTACK) return false + key = Key(message.ownerIndex, null); kind = BeamKind.CABLE + origin = message.origin; end = message.destination; offset = Vector3f.zero + } + is BeamOffsetTEMessage -> { + key = Key(message.ownerIndex, null); kind = BeamKind.GRAPPLE + origin = message.origin; end = message.destination; offset = message.offset + } + is LightningTEMessage -> { + key = Key(message.sourceIndex, message.destinationIndex); kind = BeamKind.LIGHTNING + origin = message.origin; end = message.destination; offset = Vector3f.zero + } + else -> return false + } + if (key.source !in 0 until Defines.MAX_EDICTS || + (key.destination != null && key.destination !in 0 until Defines.MAX_EDICTS)) return true + if (kind == BeamKind.LIGHTNING) + playSound(BeamSoundRequest("sound/weapons/tesla.wav", key.source, Defines.CHAN_WEAPON)) + expire(nowMs) + if (key !in beams && beams.size >= maximumBeams) return true + beams[key] = Beam(kind, origin, end, offset, nowMs.toLong() + 200, beams[key]?.roll ?: random.nextFloat() * 360f) + return true + } + + fun submit(nowMs: Int, localPlayerEntity: Int?, viewOrigin: Vector3, sink: BeamSegmentSink) { + expire(nowMs) + for ((key, beam) in beams) { + if (key.source == localPlayerEntity) start.set(viewOrigin).add(0f, 0f, -22f) + else start.set(beam.origin.x, beam.origin.y, beam.origin.z) + start.add(beam.offset.x, beam.offset.y, beam.offset.z) + direction.set(beam.end.x, beam.end.y, beam.end.z).sub(start) + val length = direction.len() + if (length < .001f) continue + direction.scl(1f / length) + val yaw = MathUtils.atan2(direction.y, direction.x) * MathUtils.radiansToDegrees + val pitch = -MathUtils.atan2(direction.z, kotlin.math.sqrt(direction.x * direction.x + direction.y * direction.y)) * MathUtils.radiansToDegrees + val lightning = beam.kind == BeamKind.LIGHTNING + // Lightning models point backwards. Short bolts start at the target so they don't pierce the Tesla mine. + val usableLength = if (lightning) (length - 20f).coerceAtLeast(0f) else length + if (lightning && usableLength <= beam.kind.segmentLength) { + sink.submit(beam.kind, beam.end.x, beam.end.y, beam.end.z, pitch, yaw, roll(beam, nowMs, 0)) + continue + } + val count = ceil(usableLength / beam.kind.segmentLength).toInt().coerceIn(1, maximumSegmentsPerBeam) + val step = if (count > 1) (usableLength - beam.kind.segmentLength) / (count - 1) else 0f + for (index in 0 until count) { + val distance = index * step + sink.submit(beam.kind, start.x + direction.x * distance, start.y + direction.y * distance, + start.z + direction.z * distance, if (lightning) -pitch else pitch, + if (lightning) yaw + 180f else yaw, roll(beam, nowMs, index)) + } + } + } + + fun clear() = beams.clear() + + private fun expire(nowMs: Int) = beams.values.removeAll { nowMs.toLong() >= it.expiresAt } + + private fun roll(beam: Beam, nowMs: Int, index: Int): Float = + (beam.roll + index * 137.5f + if (beam.kind == BeamKind.LIGHTNING) (nowMs % 3000) * .12f else 0f) % 360f +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/BeamModelRenderer.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/BeamModelRenderer.kt new file mode 100644 index 000000000..01d1ac55e --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/BeamModelRenderer.kt @@ -0,0 +1,62 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.g3d.Model +import com.badlogic.gdx.graphics.g3d.ModelBatch +import com.badlogic.gdx.graphics.g3d.ModelInstance +import com.badlogic.gdx.math.Vector3 +import com.badlogic.gdx.utils.Array as GdxArray +import com.badlogic.gdx.utils.Disposable +import com.badlogic.gdx.utils.Pool +import org.demoth.cake.assets.md2.Md2CustomData +import org.demoth.cake.createModelInstance + +/** + * Model-instance submission for beam geometry, using libGDX pools and the existing ModelBatch pass. + * Models belong to the asset catalog. Only instances are pooled here; clearing/disposal never unloads models. + */ +internal class BeamModelRenderer(private val modelProvider: (String) -> Model?) : BeamSegmentSink, Disposable { + private class Instances(val model: Model) { + val active = GdxArray() + val pool = object : Pool(16, 4096) { + override fun newObject(): ModelInstance = createModelInstance(model) + } + } + private val instances = mutableMapOf() + + fun beginFrame() { + instances.values.forEach { + it.pool.freeAll(it.active) + it.active.clear() + } + } + + override fun submit(kind: BeamKind, x: Float, y: Float, z: Float, pitch: Float, yaw: Float, roll: Float) { + val model = modelProvider(kind.modelPath) ?: return // Expansion assets remain optional in base-game profiles. + val group = instances[kind]?.takeIf { it.model === model } ?: Instances(model).also { replacement -> + instances.remove(kind)?.let { it.active.clear(); it.pool.clear() } + instances[kind] = replacement + } + val instance = group.pool.obtain() + instance.transform.idt().rotate(Vector3.Z, yaw).rotate(Vector3.Y, pitch).rotate(Vector3.X, -roll) + .setTranslation(x, y, z) + (instance.userData as? Md2CustomData)?.let { + it.frame1 = kind.frame; it.frame2 = kind.frame; it.interpolation = 0f + if (kind.fullBright) { + it.lightRed = 1f; it.lightGreen = 1f; it.lightBlue = 1f + it.shadeVectorX = 0f; it.shadeVectorY = 0f; it.shadeVectorZ = 0f + } + } + group.active.add(instance) + } + + fun forEachInstance(submit: (ModelInstance) -> Unit) { + instances.values.forEach { group -> group.active.forEach(submit) } + } + + fun render(modelBatch: ModelBatch) = forEachInstance { modelBatch.render(it) } + + override fun dispose() { + instances.values.forEach { it.active.clear(); it.pool.clear() } + instances.clear() + } +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt index 9ec703464..533e12051 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientEffectsSystem.kt @@ -11,6 +11,11 @@ import jake2.qcommon.Defines import jake2.qcommon.Globals import jake2.qcommon.M_Flash import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.LightningTEMessage +import jake2.qcommon.network.messages.server.FlashlightTEMessage +import jake2.qcommon.network.messages.server.ForceWallTEMessage +import jake2.qcommon.network.messages.server.SteamTEMessage +import jake2.qcommon.network.messages.server.WidowBeamOutTEMessage import jake2.qcommon.network.messages.server.BeamOffsetTEMessage import jake2.qcommon.network.messages.server.BeamTEMessage import jake2.qcommon.network.messages.server.MuzzleFlash2Message @@ -25,6 +30,7 @@ import org.demoth.cake.audio.CakeAudioSystem import org.demoth.cake.audio.SoundPlaybackRequest import org.demoth.cake.assets.md2.Md2CustomData import org.demoth.cake.assets.Sp2Renderer +import org.demoth.cake.assets.BeamRenderer import org.demoth.cake.createModelInstance import org.demoth.cake.stages.ingame.ClientEntityManager import org.demoth.cake.stages.ingame.DynamicLightSystem @@ -66,10 +72,14 @@ class ClientEffectsSystem( private val audioSystem: CakeAudioSystem, private val cameraProvider: () -> Camera, private val dynamicLightSystem: DynamicLightSystem? = null, + private val currentTimeMsProvider: () -> Int = { Globals.curtime }, + private val localPlayerEntityIndexProvider: () -> Int? = { null }, + private val gunOffsetProvider: () -> Vector3 = { Vector3.Zero }, + private val handednessProvider: () -> Int = { 0 }, ) : Disposable { private val assetCatalog = EffectAssetCatalog(assetManager) private val spriteRenderer = Sp2Renderer() - private val particleSystem = EffectParticleSystem(assetManager) + private val particleSystem = EffectParticleSystem(assetManager, currentTimeMsProvider) private val q2Palette: IntArray? by lazy { if (assetManager.isLoaded("q2palette.bin", Any::class.java)) { assetManager.get("q2palette.bin", Any::class.java) as? IntArray @@ -77,6 +87,33 @@ class ClientEffectsSystem( null } } + private val steamEffects = SteamEffects(particleSystem.runtime, paletteColor = { index -> + resolvePaletteColor(index, fallback = STEAM_PARTICLE_FALLBACK) + }) + private val beamModels = BeamModelRenderer { path -> assetCatalog.getModel(path)?.model } + private val beamEffects = BeamEffects(playSound = { request -> + assetCatalog.getSound(request.path)?.let { sound -> + audioSystem.play(SoundPlaybackRequest(sound = sound, entityIndex = request.entityIndex, + channel = request.channel, attenuation = Defines.ATTN_NORM.toFloat())) + } + }) + private val heatbeamEffects = HeatbeamEffects(HeatbeamParticles(particleSystem.runtime, paletteColor = { index -> + resolvePaletteColor(index, fallback = HEATBEAM_PARTICLE_FALLBACK) + })) + private val expansionEffects = ExpansionEffects(particleSystem.runtime, + paletteColor = { index -> resolvePaletteColor(index, fallback = STEAM_PARTICLE_FALLBACK) }, + lights = dynamicLightSystem, + playBubbleSound = { position -> playEffectSound("sound/weapons/lashit.wav", toVector3(position)) }) + private val shellEffects = ShellEffects(particleSystem.runtime, paletteColor = { index -> + resolvePaletteColor(index, fallback = EXPLOSION_PARTICLE_FALLBACK) + }) + private val pointParticles = PointParticles(particleSystem.runtime, paletteColor = { index -> + resolvePaletteColor(index, fallback = IMPACT_PARTICLE_COLOR_FALLBACK) + }) + private val impactEffects = ImpactEffects(particleSystem.runtime, + paletteColor = { index -> resolvePaletteColor(index, fallback = IMPACT_PARTICLE_COLOR_FALLBACK) }, + playSound = { request -> playEffectSound(request.path, toVector3(request.origin), attenuation = request.attenuation) }) + private val laserEffects = LaserEffects() // Invariant: only effects owned by this system are stored here and disposed by this system. private val activeEffects = mutableListOf() @@ -126,17 +163,24 @@ class ClientEffectsSystem( /** * Handles temp-entity messages (`svc_temp_entity`) already decoded into typed message classes. * - * The codec validates subtype/layout combinations. Valid styles without an implemented renderer - * are still ignored silently by style branch fall-through. + * The codec validates subtype/layout combinations. Family controllers provide models, particles, + * lights and sounds through the screen-owned presentation runtimes. */ fun processTempEntityMessage(msg: TEMessage) { when (msg) { - is BeamOffsetTEMessage -> handleBeamOffsetMessage(msg) - is BeamTEMessage -> handleBeamMessage(msg) + is BeamTEMessage -> { + val now = currentTimeMsProvider() + if (!heatbeamEffects.process(msg, now)) beamEffects.process(msg, now) + } + is BeamOffsetTEMessage, is LightningTEMessage -> beamEffects.process(msg, currentTimeMsProvider()) is PointDirectionTEMessage -> handlePointDirectionMessage(msg) is TrailTEMessage -> handleTrailMessage(msg) is SplashTEMessage -> handleSplashMessage(msg) is PointTEMessage -> handlePointMessage(msg) + is SteamTEMessage -> steamEffects.process(msg, currentTimeMsProvider()) + // Flashlight and force-wall messages feed the shared light and particle runtimes. + is FlashlightTEMessage, is ForceWallTEMessage -> expansionEffects.process(msg, currentTimeMsProvider()) + is WidowBeamOutTEMessage -> shellEffects.process(msg, currentTimeMsProvider()) } } @@ -174,8 +218,16 @@ class ClientEffectsSystem( @Suppress("UNUSED_PARAMETER") fun update(deltaSeconds: Float, _serverFrame: Int) { - val now = Globals.curtime - particleSystem.update(deltaSeconds, now) + val now = currentTimeMsProvider() + particleSystem.update(now) + laserEffects.update(now) + steamEffects.update(now) + shellEffects.update(now) + beamModels.beginFrame() + val camera = cameraProvider() + val localPlayer = localPlayerEntityIndexProvider() + beamEffects.submit(now, localPlayer, camera.position, beamModels) + heatbeamEffects.submit(now, localPlayer, camera, gunOffsetProvider(), handednessProvider(), beamModels) val iterator = activeEffects.iterator() while (iterator.hasNext()) { val effect = iterator.next() @@ -186,6 +238,19 @@ class ClientEffectsSystem( } } + /** Adds animation-owned lights after the light system's frame reset. */ + fun collectDynamicLights() { + val lights = dynamicLightSystem ?: return + activeEffects.forEach { it.collectDynamicLights(lights) } + } + + /** Submits temporary lasers through the screen-owned RF_BEAM renderer. */ + fun renderLasers(modelBatch: ModelBatch, beamRenderer: BeamRenderer) { + laserEffects.submit(currentTimeMsProvider()) { beam -> + beamRenderer.render(modelBatch, beam.origin, beam.destination, beam.paletteIndex, beam.diameter, beam.alpha) + } + } + /** * Renders model/sprite transient effects via `ModelBatch`. * @@ -194,6 +259,7 @@ class ClientEffectsSystem( * use [renderParticles] for the dedicated particle renderer path. */ fun render(modelBatch: ModelBatch) { + beamModels.render(modelBatch) activeEffects.forEach { effect -> effect.render(modelBatch) } @@ -214,9 +280,15 @@ class ClientEffectsSystem( fun debugParticleRenderStats(): ParticleRenderer.Stats = particleSystem.lastRenderStats() - fun debugActiveEffectCount(): Int = activeEffects.size + fun debugActiveEffectCount(): Int = activeEffects.size + beamEffects.activeCount + heatbeamEffects.activeCount + steamEffects.activeSustainCount + shellEffects.activeSustainCount + laserEffects.activeCount override fun dispose() { + laserEffects.clear() + shellEffects.clear() + heatbeamEffects.clear() + beamEffects.clear() + beamModels.dispose() + steamEffects.clear() activeEffects.forEach { it.dispose() } activeEffects.clear() particleSystem.dispose() @@ -271,122 +343,29 @@ class ClientEffectsSystem( private fun handlePointDirectionMessage(msg: PointDirectionTEMessage) { val position = toVector3(msg.position) + val now = currentTimeMsProvider() + if (steamEffects.processImpact(msg, now)) { + playEffectSound("sound/weapons/lashit.wav", position) + return + } + impactEffects.process(msg, now) when (msg.style) { - Defines.TE_BLOOD, - Defines.TE_MOREBLOOD, - Defines.TE_GREENBLOOD -> { - val paletteBase = when (msg.style) { - Defines.TE_GREENBLOOD -> GREEN_BLOOD_PARTICLE_BASE_COLOR - else -> BLOOD_PARTICLE_BASE_COLOR - } - emitPaletteImpactParticles( - origin = position, - direction = msg.direction.toArray(), - count = when (msg.style) { - Defines.TE_MOREBLOOD -> 250 - Defines.TE_GREENBLOOD -> 30 - else -> 60 - }, - paletteBase = paletteBase, - ) - } - - Defines.TE_GUNSHOT, - Defines.TE_SPARKS, - Defines.TE_BULLET_SPARKS -> { - val (count, paletteBase) = when (msg.style) { - Defines.TE_GUNSHOT -> 40 to GUNSHOT_PARTICLE_BASE_COLOR - else -> 6 to SPARK_PARTICLE_BASE_COLOR - } - emitPaletteImpactParticles( - origin = position, - direction = msg.direction.toArray(), - count = count, - paletteBase = paletteBase, - ) - if (msg.style != Defines.TE_SPARKS) { - spawnSmokeAndFlash(position) - playRandomSound(RICHOCHET_SOUNDS, position) - } - } - - Defines.TE_SCREEN_SPARKS, - Defines.TE_SHIELD_SPARKS -> { - val paletteBase = if (msg.style == Defines.TE_SCREEN_SPARKS) { - SCREEN_SPARK_PARTICLE_BASE_COLOR - } else { - SHIELD_SPARK_PARTICLE_BASE_COLOR - } - emitPaletteImpactParticles( - origin = position, - direction = msg.direction.toArray(), - count = 40, - paletteBase = paletteBase, - ) - playEffectSound("sound/weapons/lashit.wav", position) - } - - Defines.TE_HEATBEAM_SPARKS -> { - emitPaletteImpactParticles( - origin = position, - direction = msg.direction.toArray(), - count = 50, - paletteBase = HEATBEAM_SPARK_PARTICLE_BASE_COLOR, - ) - playEffectSound("sound/weapons/lashit.wav", position) - } - - Defines.TE_HEATBEAM_STEAM -> { - emitPaletteImpactParticles( - origin = position, - direction = msg.direction.toArray(), - count = 20, - paletteBase = HEATBEAM_STEAM_PARTICLE_BASE_COLOR, - ) - playEffectSound("sound/weapons/lashit.wav", position) - } - - Defines.TE_ELECTRIC_SPARKS -> { - emitPaletteImpactParticles( - origin = position, - direction = msg.direction.toArray(), - count = 40, - paletteBase = ELECTRIC_SPARK_PARTICLE_BASE_COLOR, - ) - playEffectSound("sound/weapons/lashit.wav", position) - } - - Defines.TE_SHOTGUN -> { - emitPaletteImpactParticles( - origin = position, - direction = msg.direction.toArray(), - count = 20, - paletteBase = SHOTGUN_PARTICLE_BASE_COLOR, - ) - spawnSmokeAndFlash(position) - } - + Defines.TE_GUNSHOT, Defines.TE_BULLET_SPARKS, Defines.TE_SHOTGUN -> spawnSmokeAndFlash(position) Defines.TE_BLASTER -> { spawnBlasterImpact(position, msg.direction.toArray(), skinIndex = 0) - spawnDynamicLight(position, 150f, 1f, 1f, 0f) - playEffectSound("sound/weapons/lashit.wav", position) } - Defines.TE_BLASTER2 -> { spawnBlasterImpact(position, msg.direction.toArray(), skinIndex = 1) - spawnDynamicLight(position, 150f, 0f, 1f, 0f) - playEffectSound("sound/weapons/lashit.wav", position) } - Defines.TE_FLECHETTE -> { spawnBlasterImpact(position, msg.direction.toArray(), skinIndex = 2) - spawnDynamicLight(position, 150f, 0.19f, 0.41f, 0.75f) - playEffectSound("sound/weapons/lashit.wav", position) } } } private fun handleTrailMessage(msg: TrailTEMessage) { + val now = currentTimeMsProvider() + if (laserEffects.process(msg, now) || expansionEffects.process(msg, now) || impactEffects.process(msg, now)) return val start = toVector3(msg.position) val end = toVector3(msg.destination) when (msg.style) { @@ -405,7 +384,7 @@ class ClientEffectsSystem( speedMin = 0f, speedMax = 8f, spread = 0.35f, - gravity = 45f, + gravity = 0f, sizeMin = 0.28f, sizeMax = 0.75f, lifetimeMinMs = 700, @@ -413,15 +392,6 @@ class ClientEffectsSystem( ) } - Defines.TE_BLUEHYPERBLASTER -> { - emitPaletteImpactParticles( - origin = start, - direction = msg.destination.toArray(), - count = 40, - paletteBase = BLUE_HYPERBLASTER_PARTICLE_BASE_COLOR, - ) - } - Defines.TE_DEBUGTRAIL -> { emitSegmentTrailParticles( start = start, @@ -435,13 +405,13 @@ class ClientEffectsSystem( resolvePaletteColor(colorIndex, fallback = Color(0.15f, 0.95f, 0.45f, 1f)) }, speedMin = 0f, - speedMax = 2f, - spread = 0.2f, + speedMax = 0f, + spread = 0f, gravity = 0f, sizeMin = 0.22f, sizeMax = 0.55f, - lifetimeMinMs = 3500, - lifetimeMaxMs = 5200, + lifetimeMinMs = 10000, + lifetimeMaxMs = 10000, ) } @@ -449,85 +419,32 @@ class ClientEffectsSystem( emitRailTrailParticles(start, end) playEffectSound("sound/weapons/railgf1a.wav", end) } - - Defines.TE_BFG_LASER -> { - activeEffects += LineBeamEffect( - start = start, - end = end, - color = Color(0.2f, 1f, 0.25f, 1f), - spawnTimeMs = Globals.curtime, - durationMs = 110, - radius = 0.8f, - alpha = 0.35f, - ) - } } } private fun handleSplashMessage(msg: SplashTEMessage) { - val position = toVector3(msg.position) - when (msg.style) { - Defines.TE_SPLASH -> { - val splashPaletteBase = LEGACY_SPLASH_COLORS.getOrNull(msg.param) ?: LEGACY_SPLASH_DEFAULT_COLOR - emitPaletteImpactParticles( - origin = position, - direction = msg.direction.toArray(), - count = msg.count.coerceIn(4, 64), - paletteBase = splashPaletteBase, - ) - if (msg.param == Defines.SPLASH_SPARKS) { - playRandomSound(SPARK_SOUNDS, position, Defines.ATTN_STATIC.toFloat()) - } - } - - Defines.TE_LASER_SPARKS -> { - emitLegacyPaletteParticles( - origin = position, - direction = msg.direction.toArray(), - count = msg.count, - paletteIndex = msg.param, - gravity = -320f, - ) - } - - Defines.TE_WELDING_SPARKS -> { - emitLegacyPaletteParticles( - origin = position, - direction = msg.direction.toArray(), - count = msg.count, - paletteIndex = msg.param, - gravity = -320f, - ) - spawnLegacyExplosionEffect( - type = ExplosionType.FLASH, - modelPath = "models/objects/flash/tris.md2", - position = position, - firstFrame = 0, - frameCount = 2, - ) - spawnDynamicLight(position, (100 + Globals.rnd.nextInt(75)).toFloat(), 1f, 1f, 0.3f) - } - - Defines.TE_TUNNEL_SPARKS -> { - emitLegacyPaletteParticles( - origin = position, - direction = msg.direction.toArray(), - count = msg.count, - paletteIndex = msg.param, - gravity = 320f, - ) - } + impactEffects.process(msg, currentTimeMsProvider()) + if (msg.style == Defines.TE_WELDING_SPARKS) { + val position = toVector3(msg.position) + // CL_ParseTEnt uses RF_BEAM as a no-draw flag here: welding adds a light, not a flash model. + activeEffects += ExplosionEffect( + type = ExplosionType.FLASH, spawnTimeMs = currentTimeMsProvider(), frameDurationMs = 100, + firstFrame = 0, frameCount = 2, position = position, + light = ExplosionLight((100 + Globals.rnd.nextInt(75)).toFloat(), Vector3f(1f, 1f, .3f)), + ) } } private fun handlePointMessage(msg: PointTEMessage) { + val now = currentTimeMsProvider() + if (shellEffects.process(msg, now)) return + pointParticles.process(msg, now) + impactEffects.process(msg, now) val position = toVector3(msg.position) when (msg.style) { Defines.TE_EXPLOSION2, Defines.TE_GRENADE_EXPLOSION, Defines.TE_GRENADE_EXPLOSION_WATER -> { - emitLegacyExplosionPaletteParticles(position, count = 128) - spawnDynamicLight(position, 350f, 1f, 0.5f, 0.5f) spawnExplosionModel( modelPath = "models/objects/r_explode/tris.md2", position = position, @@ -542,8 +459,6 @@ class ClientEffectsSystem( } Defines.TE_PLASMA_EXPLOSION -> { - emitLegacyExplosionPaletteParticles(position, count = 96) - spawnDynamicLight(position, 350f, 1f, 0.5f, 0.5f) spawnExplosionModel( modelPath = "models/objects/r_explode/tris.md2", position = position, @@ -558,8 +473,6 @@ class ClientEffectsSystem( Defines.TE_ROCKET_EXPLOSION, Defines.TE_ROCKET_EXPLOSION_WATER, Defines.TE_EXPLOSION1_NP -> { - emitLegacyExplosionPaletteParticles(position, count = if (msg.style == Defines.TE_EXPLOSION1_BIG) 196 else 112) - spawnDynamicLight(position, 350f, 1f, 0.5f, 0.5f) spawnExplosionModel( modelPath = if (msg.style == Defines.TE_EXPLOSION1_BIG) { "models/objects/r_explode2/tris.md2" @@ -578,14 +491,14 @@ class ClientEffectsSystem( } Defines.TE_BFG_EXPLOSION -> { - spawnDynamicLight(position, 350f, 0f, 1f, 0f) spawnExplosionSpriteEffect( type = ExplosionType.POLY, spritePath = "sprites/s_bfg2.sp2", position = position, - firstFrame = 1, + firstFrame = 0, frameCount = 4, startsTranslucent = true, + light = ExplosionLight(350f, Vector3f(0f, 1f, 0f)), ) } @@ -602,8 +515,6 @@ class ClientEffectsSystem( } Defines.TE_PLAIN_EXPLOSION -> { - emitLegacyExplosionPaletteParticles(position, count = 96) - spawnDynamicLight(position, 350f, 1f, 0.5f, 0.5f) spawnExplosionModel( modelPath = "models/objects/r_explode/tris.md2", position = position, @@ -613,22 +524,6 @@ class ClientEffectsSystem( playEffectSound("sound/weapons/rocklx1a.wav", position) } - Defines.TE_CHAINFIST_SMOKE -> { - emitPaletteImpactParticles( - origin = position, - direction = floatArrayOf(0f, 0f, 1f), - count = 20, - paletteBase = CHAINFIST_SMOKE_PARTICLE_BASE_COLOR, - ) - spawnAnimatedModelEffect( - modelPath = "models/objects/smoke/tris.md2", - position = position, - firstFrame = 0, - frameCount = 4, - frameDurationMs = 100, - ) - } - Defines.TE_TRACKER_EXPLOSION -> { spawnDynamicLight(position, 150f, -1f, -1f, -1f, lifetimeMs = 100) playEffectSound("sound/weapons/disrupthit.wav", position) @@ -645,30 +540,6 @@ class ClientEffectsSystem( } } - private fun handleBeamMessage(msg: BeamTEMessage) { - if (msg.style == Defines.TE_PARASITE_ATTACK || msg.style == Defines.TE_MEDIC_CABLE_ATTACK) { - spawnSegmentBeam( - modelPath = "models/monsters/parasite/segment/tris.md2", - start = msg.origin.toArray(), - end = msg.destination.toArray(), - offset = null, - durationMs = 200, - ) - } - } - - private fun handleBeamOffsetMessage(msg: BeamOffsetTEMessage) { - if (msg.style == Defines.TE_GRAPPLE_CABLE) { - spawnSegmentBeam( - modelPath = "models/ctf/segment/tris.md2", - start = msg.origin.toArray(), - end = msg.destination.toArray(), - offset = msg.offset.toArray(), - durationMs = 200, - ) - } - } - private fun computeMuzzleOrigin(entityIndex: Int, flashType: Int): Vector3? { val origin = entityManager.getEntityOrigin(entityIndex) ?: return null val angles = entityManager.getEntityAngles(entityIndex) ?: return null @@ -716,8 +587,13 @@ class ClientEffectsSystem( pitchDeg = pitch, yawDeg = yaw, skinIndex = skinIndex, - startsTranslucent = true, - fullBright = true, + startsTranslucent = skinIndex != 0, + fullBright = skinIndex != 0, + light = ExplosionLight(150f, when (skinIndex) { + 0 -> Vector3f(1f, 1f, 0f) + 1 -> Vector3f(0f, 1f, 0f) + else -> Vector3f(.19f, .41f, .75f) + }), ) } @@ -735,6 +611,7 @@ class ClientEffectsSystem( frameCount = frameCount, yawDeg = Globals.rnd.nextInt(360).toFloat(), fullBright = true, + light = ExplosionLight(350f, Vector3f(1f, .5f, .5f)), ) } @@ -750,8 +627,14 @@ class ClientEffectsSystem( skinIndex: Int? = null, startsTranslucent: Boolean = false, fullBright: Boolean = false, + light: ExplosionLight? = null, ) { - val md2 = assetCatalog.getModel(modelPath) ?: return + val md2 = assetCatalog.getModel(modelPath) + if (md2 == null) { + if (light != null) activeEffects += ExplosionEffect(type, currentTimeMsProvider(), 100, firstFrame, + frameCount, position, startsTranslucent, skinIndex, light) + return + } val instance = createModelInstance(md2.model) (instance.userData as? Md2CustomData)?.let { userData -> if (skinIndex != null) { @@ -769,7 +652,7 @@ class ClientEffectsSystem( activeEffects += ExplosionMd2Effect( modelInstance = instance, type = type, - spawnTimeMs = Globals.curtime, + spawnTimeMs = currentTimeMsProvider(), frameDurationMs = 100, firstFrame = firstFrame, frameCount = frameCount, @@ -780,6 +663,7 @@ class ClientEffectsSystem( baseSkinIndex = skinIndex, fullBright = fullBright, startsTranslucent = startsTranslucent, + light = light, ) } @@ -790,61 +674,29 @@ class ClientEffectsSystem( firstFrame: Int, frameCount: Int, startsTranslucent: Boolean = false, + light: ExplosionLight? = null, ) { - val sprite = assetCatalog.getSprite(spritePath) ?: return + val sprite = assetCatalog.getSprite(spritePath) + if (sprite == null) { + if (light != null) activeEffects += ExplosionEffect(type, currentTimeMsProvider(), 100, firstFrame, + frameCount, position, startsTranslucent, light = light) + return + } activeEffects += ExplosionSpriteEffect( spriteRenderer = spriteRenderer, cameraProvider = cameraProvider, sprite = sprite, type = type, - spawnTimeMs = Globals.curtime, + spawnTimeMs = currentTimeMsProvider(), frameDurationMs = 100, firstFrame = firstFrame, frameCount = frameCount, position = Vector3(position), startsTranslucent = startsTranslucent, + light = light, ) } - private fun spawnSegmentBeam( - modelPath: String, - start: FloatArray?, - end: FloatArray?, - offset: FloatArray?, - durationMs: Int, - ) { - val startVector = toVector3(start) ?: return - val endVector = toVector3(end) ?: return - val offsetVector = toVector3(offset) ?: Vector3.Zero - - val beamStart = Vector3(startVector).add(offsetVector) - val beamDirection = Vector3(endVector).sub(beamStart) - val beamLength = beamDirection.len() - if (beamLength <= 0.001f) { - return - } - - beamDirection.scl(1f / beamLength) - val (pitch, yaw) = computeDirectionAngles(beamDirection) ?: return - - val segmentStep = 30f - var travelled = 0f - while (travelled <= beamLength) { - val segmentPosition = Vector3(beamStart).mulAdd(beamDirection, travelled) - spawnAnimatedModelEffect( - modelPath = modelPath, - position = segmentPosition, - firstFrame = 0, - frameCount = 1, - frameDurationMs = durationMs, - pitchDeg = pitch, - yawDeg = yaw, - rollDeg = Globals.rnd.nextInt(360).toFloat(), - ) - travelled += segmentStep - } - } - private fun spawnAnimatedModelEffect( modelPath: String, position: Vector3, @@ -869,7 +721,7 @@ class ClientEffectsSystem( } activeEffects += AnimatedModelEffect( modelInstance = instance, - spawnTimeMs = Globals.curtime, + spawnTimeMs = currentTimeMsProvider(), frameDurationMs = frameDurationMs, firstFrame = firstFrame, frameCount = frameCount, @@ -897,7 +749,7 @@ class ClientEffectsSystem( spriteRenderer = spriteRenderer, cameraProvider = cameraProvider, sprite = sprite, - spawnTimeMs = Globals.curtime, + spawnTimeMs = currentTimeMsProvider(), frameDurationMs = frameDurationMs, firstFrame = firstFrame, frameCount = frameCount, @@ -953,117 +805,10 @@ class ClientEffectsSystem( blue = blue, lifetimeMs = lifetimeMs, decayPerSecond = decayPerSecond, - currentTimeMs = Globals.curtime, + currentTimeMs = currentTimeMsProvider(), ) } - private fun emitImpactParticles( - origin: Vector3, - direction: FloatArray?, - count: Int, - color: Color, - ) { - particleSystem.emitBurst( - origin = origin, - direction = direction, - count = count, - color = color, - speedMin = 35f, - speedMax = 170f, - spread = 0.85f, - gravity = -320f, - startAlpha = 0.95f, - endAlpha = 0f, - sizeMin = 0.4f, - sizeMax = 1.3f, - lifetimeMinMs = 160, - lifetimeMaxMs = 520, - ) - } - - private fun emitPaletteImpactParticles( - origin: Vector3, - direction: FloatArray?, - count: Int, - paletteBase: Int, - paletteVariants: Int = 8, - ) { - val safeCount = count.coerceIn(1, 512) - val variantCount = paletteVariants.coerceAtLeast(1) - repeat(safeCount) { - val paletteIndex = paletteBase + if (variantCount > 1) { - Globals.rnd.nextInt(variantCount) - } else { - 0 - } - particleSystem.emitBurst( - origin = origin, - direction = direction, - count = 1, - color = resolvePaletteColor(paletteIndex, fallback = IMPACT_PARTICLE_COLOR_FALLBACK), - speedMin = 35f, - speedMax = 170f, - spread = 0.85f, - gravity = -320f, - startAlpha = 0.95f, - endAlpha = 0f, - sizeMin = 0.4f, - sizeMax = 1.3f, - lifetimeMinMs = 160, - lifetimeMaxMs = 520, - ) - } - } - - private fun emitExplosionParticles( - origin: Vector3, - count: Int, - color: Color, - ) { - particleSystem.emitBurst( - origin = origin, - direction = floatArrayOf(0f, 0f, 1f), - count = count, - color = color, - speedMin = 60f, - speedMax = 320f, - spread = 1.2f, - gravity = -210f, - startAlpha = 1f, - endAlpha = 0f, - sizeMin = 0.6f, - sizeMax = 2f, - lifetimeMinMs = 240, - lifetimeMaxMs = 900, - ) - } - - private fun emitLegacyExplosionPaletteParticles( - origin: Vector3, - count: Int, - ) { - val safeCount = count.coerceIn(1, 512) - repeat(safeCount) { - val colorIndex = EXPLOSION_PARTICLE_BASE_COLOR + Globals.rnd.nextInt(EXPLOSION_PARTICLE_VARIANTS) - particleSystem.emitBurst( - origin = origin, - direction = floatArrayOf(0f, 0f, 1f), - count = 1, - color = resolvePaletteColor(colorIndex, fallback = EXPLOSION_PARTICLE_FALLBACK), - speedMin = 60f, - speedMax = 320f, - spread = 1.2f, - gravity = -210f, - startAlpha = 1f, - endAlpha = 0f, - sizeMin = 0.6f, - sizeMax = 2f, - lifetimeMinMs = 240, - lifetimeMaxMs = 900, - ) - } - } - private fun emitBlasterTrail( startX: Float, startY: Float, @@ -1255,31 +1000,6 @@ class ClientEffectsSystem( } } - private fun emitLegacyPaletteParticles( - origin: Vector3, - direction: FloatArray?, - count: Int, - paletteIndex: Int, - gravity: Float, - ) { - particleSystem.emitBurst( - origin = origin, - direction = direction, - count = count.coerceIn(1, 128), - color = resolvePaletteColor(paletteIndex, fallback = Color(1f, 0.85f, 0.4f, 1f)), - speedMin = 8f, - speedMax = 48f, - spread = 0.55f, - gravity = gravity, - startAlpha = 1f, - endAlpha = 0f, - sizeMin = 0.3f, - sizeMax = 1f, - lifetimeMinMs = 180, - lifetimeMaxMs = 560, - ) - } - private fun emitBfgBigExplosionParticles(origin: Vector3) { emitPaletteOmniBurst( origin = origin, @@ -1289,7 +1009,7 @@ class ClientEffectsSystem( speedMin = 20f, speedMax = 220f, spread = 0.15f, - gravity = -320f, + gravity = -80f, sizeMin = 0.38f, sizeMax = 1.2f, lifetimeMinMs = 580, @@ -1378,7 +1098,7 @@ class ClientEffectsSystem( speedMin = 50f, speedMax = 113f, spread = 0.55f, - gravity = -320f, + gravity = -80f, sizeMin = 0.34f, sizeMax = 1.15f, lifetimeMinMs = 300, @@ -1637,12 +1357,6 @@ class ClientEffectsSystem( } } -private val RICHOCHET_SOUNDS = listOf( - "sound/world/ric1.wav", - "sound/world/ric2.wav", - "sound/world/ric3.wav", -) - // Visual target: Quake2 rail trail (blue spiral with bright white center). private const val RAIL_TRAIL_MAX_SAMPLES = 4096 private const val RAIL_TRAIL_SPIRAL_PHASE_STEP = 0.1f @@ -1668,21 +1382,10 @@ private const val RAIL_TRAIL_CORE_COLOR_BASE = 0x00 private const val RAIL_TRAIL_CORE_COLOR_VARIANTS = 16 private val RAIL_TRAIL_CORE_COLOR_FALLBACK = Color(0.94f, 0.94f, 0.94f, 1f) -private const val BLOOD_PARTICLE_BASE_COLOR = 0xE8 -private const val GREEN_BLOOD_PARTICLE_BASE_COLOR = 0xDF -private const val GUNSHOT_PARTICLE_BASE_COLOR = 0x00 -private const val SPARK_PARTICLE_BASE_COLOR = 0xE0 -private const val SCREEN_SPARK_PARTICLE_BASE_COLOR = 0xD0 -private const val SHIELD_SPARK_PARTICLE_BASE_COLOR = 0xB0 -private const val SHOTGUN_PARTICLE_BASE_COLOR = 0x00 -private const val HEATBEAM_SPARK_PARTICLE_BASE_COLOR = 0x08 -private const val HEATBEAM_STEAM_PARTICLE_BASE_COLOR = 0xE0 -private const val ELECTRIC_SPARK_PARTICLE_BASE_COLOR = 0x75 -private const val CHAINFIST_SMOKE_PARTICLE_BASE_COLOR = 0x00 -private const val EXPLOSION_PARTICLE_BASE_COLOR = 0xE0 +private val HEATBEAM_PARTICLE_FALLBACK = Color(1f, .7f, .2f, 1f) +private val STEAM_PARTICLE_FALLBACK = Color(.75f, .75f, .75f, 1f) private val IMPACT_PARTICLE_COLOR_FALLBACK = Color(0.95f, 0.9f, 0.65f, 1f) -private const val EXPLOSION_PARTICLE_VARIANTS = 8 private val EXPLOSION_PARTICLE_FALLBACK = Color(1f, 0.52f, 0.22f, 1f) private data class ParticleTrailSpec( @@ -1708,12 +1411,9 @@ private const val BLASTER_TRAIL_LIFETIME_MIN_MS = 300 private const val BLASTER_TRAIL_LIFETIME_MAX_MS = 500 private const val BLASTER_TRAIL_COLOR_INDEX = 0xE0 private const val BLASTER2_TRAIL_COLOR_INDEX = 0xD0 -private const val BLUE_HYPERBLASTER_PARTICLE_BASE_COLOR = 0xE0 private val BLASTER_TRAIL_COLOR_FALLBACK = Color(1f, 0.87f, 0.35f, 1f) private val BLASTER2_TRAIL_COLOR_FALLBACK = Color(0.3f, 0.95f, 0.3f, 1f) -private val LEGACY_SPLASH_COLORS = intArrayOf(0x00, 0xE0, 0xB0, 0x50, 0xD0, 0xE0, 0xE8) -private const val LEGACY_SPLASH_DEFAULT_COLOR = 0x00 private const val DIMINISH_TRAIL_STEP = 0.5f private const val DIMINISH_TRAIL_HEAVY_ORIGIN_SCALE = 4f @@ -1771,9 +1471,3 @@ private const val RESPAWN_EVENT_COLOR_BASE = 0xE0 private val LOGIN_EVENT_COLOR_FALLBACK = Color(0.35f, 0.92f, 0.45f, 1f) private val LOGOUT_EVENT_COLOR_FALLBACK = Color(0.95f, 0.25f, 0.2f, 1f) private val RESPAWN_EVENT_COLOR_FALLBACK = Color(1f, 0.9f, 0.3f, 1f) - -private val SPARK_SOUNDS = listOf( - "sound/world/spark5.wav", - "sound/world/spark6.wav", - "sound/world/spark7.wav", -) diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientTransientEffect.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientTransientEffect.kt index 63f406dc2..a25f84d84 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientTransientEffect.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ClientTransientEffect.kt @@ -2,6 +2,7 @@ package org.demoth.cake.stages.ingame.effects import com.badlogic.gdx.graphics.g3d.ModelBatch import com.badlogic.gdx.utils.Disposable +import org.demoth.cake.stages.ingame.DynamicLightSystem /** * Runtime effect instance owned by [ClientEffectsSystem]. @@ -26,6 +27,9 @@ interface ClientTransientEffect : Disposable { */ fun render(modelBatch: ModelBatch) + /** Collect frame-local lights after the light system's frame reset and before drawing. */ + fun collectDynamicLights(lights: DynamicLightSystem) = Unit + override fun dispose() { // no-op by default } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectAssetCatalog.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectAssetCatalog.kt index a09825ca8..3208903cb 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectAssetCatalog.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectAssetCatalog.kt @@ -86,10 +86,10 @@ class EffectAssetCatalog( if (assetManager.fileHandleResolver.tryResolveRaw(path) == null) { return } - if (!assetManager.isLoaded(path, type)) { - assetManager.load(path, type) - assetManager.finishLoadingAsset(path) - } + // Acquire a reference even when another screen/config already loaded the asset. + // finishLoadingAsset returns immediately for cached assets, before processing that acquisition. + assetManager.load(path, type) + assetManager.finishLoading() ownedAssetTypes[path] = type } } @@ -103,6 +103,8 @@ private val EFFECT_MD2_MODELS = listOf( "models/monsters/parasite/tip/tris.md2", "models/objects/r_explode/tris.md2", "models/objects/r_explode2/tris.md2", + "models/proj/lightning/tris.md2", + "models/proj/beam/tris.md2", ) private val EFFECT_SP2_MODELS = listOf( @@ -123,6 +125,7 @@ private val EFFECT_SOUNDS = listOf( "sound/weapons/xpld_wat.wav", "sound/misc/bigtele.wav", "sound/weapons/disrupthit.wav", + "sound/weapons/tesla.wav", "sound/infantry/infatck1.wav", "sound/soldier/solatck1.wav", "sound/soldier/solatck2.wav", diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectParticleSystem.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectParticleSystem.kt index 642c875d7..4f7e22a6c 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectParticleSystem.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/EffectParticleSystem.kt @@ -14,9 +14,9 @@ import kotlin.math.min * Lightweight world-space particle simulation/runtime used by temp effects. * * Decision summary: - * - This system owns simulation only; it no longer renders through `ModelBatch`. + * - This facade maps effect emissions to a libGDX particle controller, independently of `ModelBatch`. * - Rendering is delegated to [ParticleRenderer] in a dedicated pass to keep draw calls bounded. - * - Data is stored in SoA arrays for low allocation pressure and stable update cost. + * - libGDX particle channels own storage and keep emission free of per-particle object allocations. * * Practical goals: * - preserve legacy effect readability and per-effect color correctness, @@ -24,32 +24,29 @@ import kotlin.math.min * - favor visual parity and performance over temporary intermediate fixes. * * Notes: - * - removal uses unordered swap-with-last semantics in [removeAt], so particle iteration order is not stable. + * - libGDX removes particles by swapping channel entries, so particle iteration order is not stable. * - live particles are capped by the validated `r_particles` budget; overflow spawns are dropped. */ class EffectParticleSystem( assetManager: AssetManager, + private val currentTimeMsProvider: () -> Int = { Globals.curtime }, ) : Disposable { - private var activeCount = 0 - private var capacity = INITIAL_CAPACITY - private var posX = FloatArray(capacity) - private var posY = FloatArray(capacity) - private var posZ = FloatArray(capacity) - private var velX = FloatArray(capacity) - private var velY = FloatArray(capacity) - private var velZ = FloatArray(capacity) - private var spawnTimeMs = IntArray(capacity) - private var lifetimeMs = IntArray(capacity) - private var startAlpha = FloatArray(capacity) - private var endAlpha = FloatArray(capacity) - private var size = FloatArray(capacity) - private var gravity = FloatArray(capacity) - private var colorR = FloatArray(capacity) - private var colorG = FloatArray(capacity) - private var colorB = FloatArray(capacity) - private var renderMode = ByteArray(capacity) - private var blendMode = ByteArray(capacity) - private val renderer = ParticleRenderer(assetManager) + internal val runtime = GdxParticleRuntime(RenderTuningCvars::particleBudget) + // Simulation and teardown need no GL context until particles are actually drawn. + private val rendererDelegate = lazy { ParticleRenderer(assetManager) } + private val renderer by rendererDelegate + private val tempDirection = Vector3() + private val tempVelocity = Vector3() + private val tempAcceleration = Vector3() + + /** Emits explicit world-space state through libGDX's particle lifetime and storage runtime. */ + fun emit( + position: Vector3, velocity: Vector3, acceleration: Vector3, color: Color, + size: Float, lifetimeMillis: Int, startAlpha: Float = 1f, endAlpha: Float = 0f, + renderMode: ParticleRenderMode = ParticleRenderMode.POINT_SPRITE, + blendMode: ParticleBlendMode = ParticleBlendMode.ALPHA, + ): Boolean = runtime.emit(position, velocity, acceleration, color, size, lifetimeMillis, + currentTimeMsProvider(), startAlpha, endAlpha, renderMode, blendMode) /** * Emits a burst of particles with shared spawn parameters. @@ -88,71 +85,40 @@ class EffectParticleSystem( return } - val remainingBudget = RenderTuningCvars.particleBudget() - activeCount - if (remainingBudget <= 0) { - return - } - + val nowMs = currentTimeMsProvider() + runtime.update(nowMs) + val remainingBudget = RenderTuningCvars.particleBudget() - runtime.liveCount + if (remainingBudget <= 0) return val spawnCount = min(requestedCount, remainingBudget) - ensureCapacity(activeCount + spawnCount) - + tempAcceleration.set(0f, 0f, gravity) repeat(spawnCount) { - val particleIndex = activeCount++ val directionVector = randomDirection(direction, spread) val speed = randomFloat(speedMin, speedMax) - val lifeMs = randomInt(lifetimeMinMs, lifetimeMaxMs) - - posX[particleIndex] = origin.x - posY[particleIndex] = origin.y - posZ[particleIndex] = origin.z - velX[particleIndex] = directionVector.x * speed - velY[particleIndex] = directionVector.y * speed - velZ[particleIndex] = directionVector.z * speed - spawnTimeMs[particleIndex] = Globals.curtime - lifetimeMs[particleIndex] = max(1, lifeMs) - this.startAlpha[particleIndex] = startAlpha - this.endAlpha[particleIndex] = endAlpha - size[particleIndex] = randomFloat(sizeMin, sizeMax).coerceAtLeast(0.1f) - this.gravity[particleIndex] = gravity - colorR[particleIndex] = color.r - colorG[particleIndex] = color.g - colorB[particleIndex] = color.b - this.renderMode[particleIndex] = renderMode.id - this.blendMode[particleIndex] = blendMode.id + val lifeMs = max(1, randomInt(lifetimeMinMs, lifetimeMaxMs)) + tempVelocity.set(directionVector).scl(speed) + runtime.emit( + position = origin, velocity = tempVelocity, acceleration = tempAcceleration, color = color, + size = randomFloat(sizeMin, sizeMax).coerceAtLeast(.1f), + lifetimeMillis = lifeMs, nowMs = nowMs, startAlpha = startAlpha, endAlpha = endAlpha, + mode = renderMode, blend = blendMode, + ) } } /** * Advances simulation for all live particles. * - * Integration model: - * - Euler integration with per-particle Z-gravity, - * - dead particles are removed in O(1) by swap-with-last compaction. + * libGDX handles lifetime and removal; the presentation clock also freezes particle motion when paused. */ - fun update(deltaSeconds: Float, nowMs: Int) { - if (activeCount == 0) { - return - } - var index = 0 - while (index < activeCount) { - val elapsedMs = nowMs - spawnTimeMs[index] - if (elapsedMs >= lifetimeMs[index]) { - removeAt(index) - continue - } - velZ[index] += gravity[index] * deltaSeconds - posX[index] += velX[index] * deltaSeconds - posY[index] += velY[index] * deltaSeconds - posZ[index] += velZ[index] * deltaSeconds - index++ - } + fun update(nowMs: Int) { + runtime.update(nowMs) } /** * Submits all visible particles to the dedicated particle renderer. * * Rendering policy: - * - alpha is evaluated from current normalized lifetime and caller-provided alpha ramp, + * - libGDX normalized lifetime drives the caller-provided alpha ramp, * - fully transparent particles are skipped, * - render/blend mode routing is delegated to [ParticleRenderer]. */ @@ -160,95 +126,20 @@ class EffectParticleSystem( if (!RenderTuningCvars.particlesEnabled()) { return } - if (activeCount == 0) { - return - } - val nowMs = Globals.curtime + if (runtime.liveCount == 0) return renderer.begin(camera) - var index = 0 - while (index < activeCount) { - val life = ((nowMs - spawnTimeMs[index]).toFloat() / lifetimeMs[index].toFloat()).coerceIn(0f, 1f) - val alpha = startAlpha[index] + (endAlpha[index] - startAlpha[index]) * life - if (alpha <= 0f) { - index++ - continue - } - renderer.submit( - mode = ParticleRenderMode.fromId(renderMode[index]), - blend = ParticleBlendMode.fromId(blendMode[index]), - x = posX[index], - y = posY[index], - z = posZ[index], - red = colorR[index], - green = colorG[index], - blue = colorB[index], - alpha = alpha, - size = size[index], - ) - index++ - } + runtime.submit(currentTimeMsProvider(), renderer) renderer.flush() } - fun liveParticleCount(): Int = activeCount + fun liveParticleCount(): Int = runtime.liveCount - fun lastRenderStats(): ParticleRenderer.Stats = renderer.lastStats + fun lastRenderStats(): ParticleRenderer.Stats = + if (rendererDelegate.isInitialized()) renderer.lastStats else ParticleRenderer.Stats() override fun dispose() { - activeCount = 0 - renderer.dispose() - } - - private fun ensureCapacity(requiredCapacity: Int) { - if (requiredCapacity <= capacity) { - return - } - var newCapacity = capacity - while (newCapacity < requiredCapacity) { - newCapacity = newCapacity shl 1 - } - posX = posX.copyOf(newCapacity) - posY = posY.copyOf(newCapacity) - posZ = posZ.copyOf(newCapacity) - velX = velX.copyOf(newCapacity) - velY = velY.copyOf(newCapacity) - velZ = velZ.copyOf(newCapacity) - spawnTimeMs = spawnTimeMs.copyOf(newCapacity) - lifetimeMs = lifetimeMs.copyOf(newCapacity) - startAlpha = startAlpha.copyOf(newCapacity) - endAlpha = endAlpha.copyOf(newCapacity) - size = size.copyOf(newCapacity) - gravity = gravity.copyOf(newCapacity) - colorR = colorR.copyOf(newCapacity) - colorG = colorG.copyOf(newCapacity) - colorB = colorB.copyOf(newCapacity) - renderMode = renderMode.copyOf(newCapacity) - blendMode = blendMode.copyOf(newCapacity) - capacity = newCapacity - } - - private fun removeAt(index: Int) { - val last = activeCount - 1 - if (index != last) { - posX[index] = posX[last] - posY[index] = posY[last] - posZ[index] = posZ[last] - velX[index] = velX[last] - velY[index] = velY[last] - velZ[index] = velZ[last] - spawnTimeMs[index] = spawnTimeMs[last] - lifetimeMs[index] = lifetimeMs[last] - startAlpha[index] = startAlpha[last] - endAlpha[index] = endAlpha[last] - size[index] = size[last] - gravity[index] = gravity[last] - colorR[index] = colorR[last] - colorG[index] = colorG[last] - colorB[index] = colorB[last] - renderMode[index] = renderMode[last] - blendMode[index] = blendMode[last] - } - activeCount = last + runtime.dispose() + if (rendererDelegate.isInitialized()) renderer.dispose() } private fun randomDirection(direction: FloatArray?, spread: Float): Vector3 { @@ -280,10 +171,6 @@ class EffectParticleSystem( return min + Globals.rnd.nextInt(max - min + 1) } - companion object { - private const val INITIAL_CAPACITY = 1024 - private val tempDirection = Vector3() - } } /** diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExpansionEffects.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExpansionEffects.kt new file mode 100644 index 000000000..845c1ed45 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExpansionEffects.kt @@ -0,0 +1,79 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.math.Vector3 +import jake2.qcommon.Defines +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.* +import org.demoth.cake.stages.ingame.DynamicLightSystem +import kotlin.math.ceil +import kotlin.random.Random + +/** One-shot expansion effects: CL_Flashlight, CL_ForceWall and CL_BubbleTrail2. */ +internal class ExpansionEffects( + private val particles: GdxParticleRuntime, + private val paletteColor: (Int) -> Color, + private val lights: DynamicLightSystem?, + private val playBubbleSound: (Vector3f) -> Unit, + private val random: Random = Random.Default, + private val maximumTrailPoints: Int = 1024, +) { + init { require(maximumTrailPoints > 0) } + private val origin = Vector3() + private val direction = Vector3() + private val position = Vector3() + private val velocity = Vector3() + + fun process(message: TEMessage, nowMs: Int): Boolean { + when (message) { + is FlashlightTEMessage -> { + if (message.entityIndex in 0 until Defines.MAX_EDICTS) { + lights?.spawnTransientLight(message.entityIndex, vector(message.position), 400f, 1f, 1f, 1f, + lifetimeMs = 100, currentTimeMs = nowMs) + } + } + is ForceWallTEMessage -> emitTrail(message.origin, message.destination, wallColor = message.color, nowMs = nowMs) + is TrailTEMessage -> { + if (message.style != Defines.TE_BUBBLETRAIL2) return false + emitTrail(message.position, message.destination, wallColor = null, nowMs = nowMs) + playBubbleSound(message.position) + } + else -> return false + } + return true + } + + private fun emitTrail(start: Vector3f, end: Vector3f, wallColor: Int?, nowMs: Int) { + origin.set(start.x, start.y, start.z) + direction.set(end.x, end.y, end.z).sub(origin) + val length = direction.len() + if (length < .001f) return + direction.scl(1f / length) + val spacing = if (wallColor != null) 4f else 8f + val count = ceil(length / spacing).toInt().coerceIn(1, maximumTrailPoints) + val step = length / count + repeat(count) { index -> + if (wallColor != null && random.nextFloat() < .3f) return@repeat + val jitter = if (wallColor != null) 3f else 2f + position.set(origin).mulAdd(direction, index * step) + .add(signedRandom() * jitter, signedRandom() * jitter, signedRandom() * jitter) + val color: Int + val lifetime: Int + if (wallColor != null) { + velocity.set(0f, 0f, -40f + signedRandom() * 10f) + color = wallColor + lifetime = random.nextInt(3000, 3501) + } else { + position.z -= 4f + velocity.set(signedRandom() * 10f, signedRandom() * 10f, 20f + signedRandom() * 10f) + color = 4 + random.nextInt(8) + lifetime = random.nextInt(1000, 1101) + } + if (!particles.emit(position, velocity, Vector3.Zero, paletteColor(color), + size = if (wallColor != null) .6f else .4f, lifetimeMillis = lifetime, nowMs = nowMs)) return + } + } + + private fun signedRandom(): Float = random.nextFloat() * 2f - 1f + private fun vector(value: Vector3f) = position.set(value.x, value.y, value.z) +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionAnimation.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionAnimation.kt new file mode 100644 index 000000000..b826a8b5c --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionAnimation.kt @@ -0,0 +1,43 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.g2d.Animation + +internal data class ExplosionFrame(val previousFrame: Int, val currentFrame: Int, val interpolation: Float, + val alpha: Float, val skin: Int?, val translucent: Boolean) + +/** Shared MD2/sprite timing and presentation rules from CL_AddExplosions, using libGDX frame selection. */ +internal class ExplosionAnimation( + private val type: ExplosionType, + private val spawnTimeMs: Int, + private val frameDurationMs: Int, + private val firstFrame: Int, + private val frameCount: Int, + private val startsTranslucent: Boolean = false, + private val baseSkin: Int? = null, +) { + init { require(frameDurationMs > 0 && frameCount > 0) } + // Use frame units to avoid .1-second float rounding delaying exact millisecond frame boundaries. + private val animation = Animation(1f, *Array(frameCount) { it }) + + fun sample(nowMs: Int): ExplosionFrame? { + val age = (nowMs.toLong() - spawnTimeMs).coerceAtLeast(0) + val elapsedFrames = age.toFloat() / frameDurationMs + if (elapsedFrames >= if (type == ExplosionType.FLASH) 1 else frameCount - 1) return null + val index = animation.getKeyFrameIndex(elapsedFrames) + val fraction = (age % frameDurationMs).toFloat() / frameDurationMs + val alpha = when (type) { + ExplosionType.MISC -> 1f - (index + fraction) / (frameCount - 1) + ExplosionType.FLASH, ExplosionType.MFLASH -> 1f + ExplosionType.POLY -> (16f - index) / 16f + ExplosionType.POLY2 -> (5f - index) / 5f + }.coerceIn(0f, 1f) + val skin = when (type) { + ExplosionType.POLY -> if (index < 10) index / 2 else if (index < 13) 5 else 6 + ExplosionType.POLY2 -> 0 + else -> baseSkin + } + val translucent = startsTranslucent || type == ExplosionType.POLY2 || (type == ExplosionType.POLY && index >= 10) + return ExplosionFrame(firstFrame + index, firstFrame + (index + 1).coerceAtMost(frameCount - 1), + fraction, alpha, skin, translucent) + } +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionEffect.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionEffect.kt new file mode 100644 index 000000000..e0ed7d31a --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionEffect.kt @@ -0,0 +1,33 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.g3d.ModelBatch +import com.badlogic.gdx.math.Vector3 +import jake2.qcommon.math.Vector3f +import org.demoth.cake.stages.ingame.DynamicLightSystem + +internal data class ExplosionLight(val radius: Float, val color: Vector3f) + +/** Shared animation/light owner; also preserves an explosion's light when optional visual assets are absent. */ +internal open class ExplosionEffect( + type: ExplosionType, spawnTimeMs: Int, frameDurationMs: Int, firstFrame: Int, frameCount: Int, + position: Vector3, startsTranslucent: Boolean = false, baseSkin: Int? = null, + private val light: ExplosionLight? = null, +) : ClientTransientEffect { + protected val position = Vector3(position) + private val animation = ExplosionAnimation(type, spawnTimeMs, frameDurationMs, firstFrame, frameCount, startsTranslucent, baseSkin) + protected var frame: ExplosionFrame? = null + private set + + override fun update(nowMs: Int, deltaSeconds: Float): Boolean { + frame = animation.sample(nowMs) + return frame != null + } + + override fun collectDynamicLights(lights: DynamicLightSystem) { + val light = light ?: return + val frame = frame ?: return + lights.addFrameLight(position, light.radius * frame.alpha, light.color.x, light.color.y, light.color.z) + } + + override fun render(modelBatch: ModelBatch) = Unit +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionMd2Effect.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionMd2Effect.kt index 611e69c7a..21c33c0c1 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionMd2Effect.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionMd2Effect.kt @@ -10,7 +10,6 @@ import org.demoth.cake.applyModelOpacity import org.demoth.cake.assets.md2.Md2CustomData import org.demoth.cake.assets.Sp2Asset import org.demoth.cake.assets.Sp2Renderer -import kotlin.math.floor /** * Legacy/Yamagi temp-explosion renderer behavior with enum-based type semantics. @@ -27,122 +26,45 @@ enum class ExplosionType { POLY2, } -class ExplosionMd2Effect( +internal class ExplosionMd2Effect( private val modelInstance: ModelInstance, - private val type: ExplosionType, - private val spawnTimeMs: Int, - private val frameDurationMs: Int, - private val firstFrame: Int, - private val frameCount: Int, - private val position: Vector3, + type: ExplosionType, spawnTimeMs: Int, frameDurationMs: Int, firstFrame: Int, frameCount: Int, + position: Vector3, private val pitchDeg: Float = 0f, private val yawDeg: Float = 0f, private val rollDeg: Float = 0f, - private val baseSkinIndex: Int? = null, + baseSkinIndex: Int? = null, private val fullBright: Boolean = false, - private val startsTranslucent: Boolean = false, -) : ClientTransientEffect { + startsTranslucent: Boolean = false, + light: ExplosionLight? = null, +) : ExplosionEffect(type, spawnTimeMs, frameDurationMs, firstFrame, frameCount, position, startsTranslucent, baseSkinIndex, light) { override fun update(nowMs: Int, deltaSeconds: Float): Boolean { - val elapsedMs = nowMs - spawnTimeMs - if (elapsedMs < 0) { - return true - } - - val durationMs = frameDurationMs.coerceAtLeast(1) - val frameFloat = elapsedMs.toFloat() / durationMs.toFloat() - val frameIndex = floor(frameFloat).toInt() - val nextFrameIndex = (frameIndex + 1).coerceAtMost(frameCount - 1) - val interpolation = (frameFloat - floor(frameFloat)).coerceIn(0f, 1f) - - var skinIndex = baseSkinIndex - var forceTranslucent = startsTranslucent - val rawAlpha = when (type) { - ExplosionType.MISC -> { - if (frameIndex >= frameCount - 1) { - return false - } - val denom = (frameCount - 1).coerceAtLeast(1).toFloat() - 1f - (frameFloat / denom) - } - - ExplosionType.FLASH -> { - if (frameIndex >= 1) { - return false - } - 1f - } - - ExplosionType.MFLASH -> { - if (frameIndex >= frameCount - 1) { - return false - } - 1f - } - - ExplosionType.POLY -> { - if (frameIndex >= frameCount - 1) { - return false - } - val alpha = (16f - frameIndex.toFloat()) / 16f - if (frameIndex < 10) { - skinIndex = (frameIndex shr 1).coerceAtLeast(0) - } else { - forceTranslucent = true - skinIndex = if (frameIndex < 13) 5 else 6 - } - alpha - } - - ExplosionType.POLY2 -> { - if (frameIndex >= frameCount - 1) { - return false - } - forceTranslucent = true - skinIndex = 0 - (5f - frameIndex.toFloat()) / 5f - } - }.coerceIn(0f, 1f) - + if (!super.update(nowMs, deltaSeconds)) return false + val frame = checkNotNull(frame) (modelInstance.userData as? Md2CustomData)?.let { userData -> - userData.frame1 = firstFrame + frameIndex.coerceAtMost(frameCount - 1) - userData.frame2 = firstFrame + nextFrameIndex - userData.interpolation = interpolation - if (skinIndex != null) { - userData.skinIndex = skinIndex - } + userData.frame1 = frame.previousFrame + userData.frame2 = frame.currentFrame + userData.interpolation = frame.interpolation + frame.skin?.let { userData.skinIndex = it } if (fullBright) { // Fullbright path intentionally bypasses directional alias shading. - userData.lightRed = 1f - userData.lightGreen = 1f - userData.lightBlue = 1f - userData.shadeVectorX = 0f - userData.shadeVectorY = 0f - userData.shadeVectorZ = 0f + userData.lightRed = 1f; userData.lightGreen = 1f; userData.lightBlue = 1f + userData.shadeVectorX = 0f; userData.shadeVectorY = 0f; userData.shadeVectorZ = 0f } } - modelInstance.transform.idt() - if (yawDeg != 0f) { - modelInstance.transform.rotate(Vector3.Z, yawDeg) - } - if (pitchDeg != 0f) { - modelInstance.transform.rotate(Vector3.Y, pitchDeg) - } - if (rollDeg != 0f) { - modelInstance.transform.rotate(Vector3.X, -rollDeg) - } + if (yawDeg != 0f) modelInstance.transform.rotate(Vector3.Z, yawDeg) + if (pitchDeg != 0f) modelInstance.transform.rotate(Vector3.Y, pitchDeg) + if (rollDeg != 0f) modelInstance.transform.rotate(Vector3.X, -rollDeg) modelInstance.transform.setTranslation(position) - // Alpha only affects legacy opaque explosions after they become translucent. - // Yamagi GL3 alias path scales translucent model alpha by 0.666. - val effectiveAlpha = if (forceTranslucent) rawAlpha * 0.666f else 1f - applyModelOpacity(modelInstance, effectiveAlpha.coerceIn(0f, 1f), forceTranslucent = forceTranslucent) + // Alpha affects opaque explosions only once they become translucent. + // Yamagi's alias renderer additionally scales alpha by 0.666; libGDX materials use the animation alpha directly. + applyModelOpacity(modelInstance, if (frame.translucent) frame.alpha else 1f, forceTranslucent = frame.translucent) return true } - override fun render(modelBatch: ModelBatch) { - modelBatch.render(modelInstance) - } + override fun render(modelBatch: ModelBatch) { modelBatch.render(modelInstance) } } /** @@ -152,18 +74,15 @@ class ExplosionMd2Effect( * - Jake2: `client/CL_tent.AddExplosions` (`ex_poly` on `sprites/s_bfg2.sp2`). * - Yamagi: `cl_tempentities.c` (`CL_AddExplosions`). */ -class ExplosionSpriteEffect( +internal class ExplosionSpriteEffect( private val spriteRenderer: Sp2Renderer, private val cameraProvider: () -> Camera, private val sprite: Sp2Asset, - private val type: ExplosionType, - private val spawnTimeMs: Int, - private val frameDurationMs: Int, - private val firstFrame: Int, - private val frameCount: Int, + type: ExplosionType, spawnTimeMs: Int, frameDurationMs: Int, firstFrame: Int, frameCount: Int, position: Vector3, - private val startsTranslucent: Boolean = false, -) : ClientTransientEffect { + startsTranslucent: Boolean = false, + light: ExplosionLight? = null, +) : ExplosionEffect(type, spawnTimeMs, frameDurationMs, firstFrame, frameCount, position, startsTranslucent, light = light) { private val entity = ClientEntity("sp2_explosion").apply { spriteAsset = sprite prev.origin = floatArrayOf(position.x, position.y, position.z) @@ -174,67 +93,13 @@ class ExplosionSpriteEffect( } override fun update(nowMs: Int, deltaSeconds: Float): Boolean { - if (frameCount <= 0 || frameDurationMs <= 0 || sprite.frames.isEmpty()) { - return false - } - val elapsedMs = nowMs - spawnTimeMs - if (elapsedMs < 0) { - return true - } - - val frameFloat = elapsedMs.toFloat() / frameDurationMs.toFloat() - val frameIndex = floor(frameFloat).toInt() - - var forceTranslucent = startsTranslucent - val rawAlpha = when (type) { - ExplosionType.MISC -> { - if (frameIndex >= frameCount - 1) { - return false - } - val denom = (frameCount - 1).coerceAtLeast(1).toFloat() - 1f - (frameFloat / denom) - } - - ExplosionType.FLASH -> { - if (frameIndex >= 1) { - return false - } - 1f - } - - ExplosionType.MFLASH -> { - if (frameIndex >= frameCount - 1) { - return false - } - 1f - } - - ExplosionType.POLY -> { - if (frameIndex >= frameCount - 1) { - return false - } - if (frameIndex >= 10) { - forceTranslucent = true - } - (16f - frameIndex.toFloat()) / 16f - } - - ExplosionType.POLY2 -> { - if (frameIndex >= frameCount - 1) { - return false - } - forceTranslucent = true - (5f - frameIndex.toFloat()) / 5f - } - }.coerceIn(0f, 1f) - - entity.resolvedFrame = firstFrame + frameIndex.coerceAtMost(frameCount - 1) - entity.resolvedRenderFx = if (forceTranslucent) Defines.RF_TRANSLUCENT else 0 - entity.alpha = if (forceTranslucent) rawAlpha else 1f + if (!super.update(nowMs, deltaSeconds) || sprite.frames.isEmpty()) return false + val frame = checkNotNull(frame) + entity.resolvedFrame = frame.currentFrame + entity.resolvedRenderFx = if (frame.translucent) Defines.RF_TRANSLUCENT else 0 + entity.alpha = if (frame.translucent) frame.alpha else 1f return true } - override fun render(modelBatch: ModelBatch) { - spriteRenderer.render(modelBatch, entity, cameraProvider(), 1f) - } + override fun render(modelBatch: ModelBatch) { spriteRenderer.render(modelBatch, entity, cameraProvider(), 1f) } } diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/GdxParticleRuntime.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/GdxParticleRuntime.kt new file mode 100644 index 000000000..f6d732598 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/GdxParticleRuntime.kt @@ -0,0 +1,174 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.graphics.g3d.particles.ParallelArray.ChannelDescriptor +import com.badlogic.gdx.graphics.g3d.particles.ParallelArray.FloatChannel +import com.badlogic.gdx.graphics.g3d.particles.ParticleChannels +import com.badlogic.gdx.graphics.g3d.particles.ParticleController +import com.badlogic.gdx.graphics.g3d.particles.emitters.RegularEmitter +import com.badlogic.gdx.graphics.g3d.particles.influencers.Influencer +import com.badlogic.gdx.graphics.g3d.particles.renderers.PointSpriteRenderer +import com.badlogic.gdx.math.Vector3 +import com.badlogic.gdx.utils.Disposable + +/** Bridges evaluated libGDX particle channels to the world's particle draw pass. */ +fun interface ParticleSink { + fun submit(mode: ParticleRenderMode, blend: ParticleBlendMode, + x: Float, y: Float, z: Float, red: Float, green: Float, blue: Float, alpha: Float, size: Float) +} + +/** + * Message-driven particles using libGDX's controller, emitter, lifetime and channel storage. + * The caller supplies presentation time so pause and demo playback need no global clock or graphics context. + * PointSpriteRenderer allocates standard channels here; GPU submission remains in Cake's world draw pass. + */ +internal class GdxParticleRuntime( + private val particleBudget: () -> Int, + initialCapacity: Int = 1024, +) : Disposable { + private val emitter = RegularEmitter().apply { + require(initialCapacity > 0) + maxParticleCount = initialCapacity + emissionMode = RegularEmitter.EmissionMode.Disabled + duration.setLow(1f) + life.setHigh(1000f) + } + private val controller = ParticleController("Cake particles", emitter, PointSpriteRenderer(), BurstInfluencer()).apply { + init() + start() + } + private val position = channel(ParticleChannels.Position) + private val color = channel(ParticleChannels.Color) + private val scale = channel(ParticleChannels.Scale) + private val life = channel(ParticleChannels.Life) + private val motion = channel(MOTION) + private val appearance = channel(APPEARANCE) + private var previousTimeMs: Int? = null + + val liveCount: Int get() = controller.particles.size + + /** Copies inputs; acceleration is physical units/second² and alpha interpolates over the supplied lifetime. */ + fun emit( + position: Vector3, velocity: Vector3, acceleration: Vector3, color: Color, + size: Float, lifetimeMillis: Int, nowMs: Int, startAlpha: Float = 1f, endAlpha: Float = 0f, + mode: ParticleRenderMode = ParticleRenderMode.POINT_SPRITE, + blend: ParticleBlendMode = ParticleBlendMode.ALPHA, + ): Boolean { + require(lifetimeMillis > 0) + require(size.isFinite() && size > 0f && startAlpha.isFinite() && endAlpha.isFinite()) + update(nowMs) + if (liveCount >= particleBudget().coerceAtLeast(0)) return false + if (liveCount == controller.particles.capacity) { + val capacity = maxOf(liveCount + 1, controller.particles.capacity * 2) + controller.particles.setCapacity(capacity) + emitter.maxParticleCount = capacity + } + val index = liveCount + controller.activateParticles(index, 1) + controller.particles.size++ + val p = index * this.position.strideSize + this.position.data[p] = position.x; this.position.data[p + 1] = position.y; this.position.data[p + 2] = position.z + val m = index * motion.strideSize + motion.data[m] = velocity.x; motion.data[m + 1] = velocity.y; motion.data[m + 2] = velocity.z + motion.data[m + 3] = acceleration.x; motion.data[m + 4] = acceleration.y; motion.data[m + 5] = acceleration.z + val c = index * this.color.strideSize + this.color.data[c] = color.r; this.color.data[c + 1] = color.g; this.color.data[c + 2] = color.b + this.color.data[c + 3] = startAlpha.coerceIn(0f, 1f) + scale.data[index] = size + val l = index * life.strideSize + life.data[l + ParticleChannels.CurrentLifeOffset] = lifetimeMillis.toFloat() + life.data[l + ParticleChannels.TotalLifeOffset] = lifetimeMillis.toFloat() + life.data[l + ParticleChannels.LifePercentOffset] = 0f + val a = index * appearance.strideSize + appearance.data[a] = startAlpha; appearance.data[a + 1] = endAlpha + appearance.data[a + 2] = mode.id.toFloat(); appearance.data[a + 3] = blend.id.toFloat() + return true + } + + /** New particles are born after existing particles reach this time, avoiding a premature first-frame update. */ + fun update(nowMs: Int) { + val previous = previousTimeMs + previousTimeMs = nowMs + if (previous == null || nowMs <= previous) return + controller.update((nowMs.toLong() - previous).toFloat() / 1000f) + } + + fun submit(nowMs: Int, sink: ParticleSink) { + update(nowMs) + for (index in 0 until liveCount) { + val p = index * position.strideSize + val c = index * color.strideSize + val a = index * appearance.strideSize + val alpha = color.data[c + 3] + if (alpha <= 0f) continue + sink.submit(ParticleRenderMode.fromId(appearance.data[a + 2].toInt().toByte()), + ParticleBlendMode.fromId(appearance.data[a + 3].toInt().toByte()), + position.data[p], position.data[p + 1], position.data[p + 2], + color.data[c], color.data[c + 1], color.data[c + 2], alpha, scale.data[index]) + } + } + + fun clear() { + controller.end() + controller.start() + previousTimeMs = null + } + + override fun dispose() { + controller.end() + previousTimeMs = null + controller.dispose() + } + + private fun channel(descriptor: ChannelDescriptor): FloatChannel = controller.particles.getChannel(descriptor) + + /** + * Burst messages supply individual velocities, accelerations and palette colors. + * This adapter advances those values and fades alpha from the emitter's normalized life; + * libGDX owns particle expiry and moves every channel together when particles are removed. + */ + private class BurstInfluencer : Influencer() { + private lateinit var position: FloatChannel + private lateinit var motion: FloatChannel + private lateinit var color: FloatChannel + private lateinit var appearance: FloatChannel + private lateinit var life: FloatChannel + private val point = Vector3() + private val velocity = Vector3() + private val acceleration = Vector3() + + override fun allocateChannels() { + position = controller.particles.addChannel(ParticleChannels.Position) + color = controller.particles.addChannel(ParticleChannels.Color) + life = controller.particles.addChannel(ParticleChannels.Life) + motion = controller.particles.addChannel(MOTION) + appearance = controller.particles.addChannel(APPEARANCE) + } + + override fun update() { + val delta = controller.deltaTime + for (index in 0 until controller.particles.size) { + val p = index * position.strideSize + val m = index * motion.strideSize + point.set(position.data[p], position.data[p + 1], position.data[p + 2]) + velocity.set(motion.data[m], motion.data[m + 1], motion.data[m + 2]) + acceleration.set(motion.data[m + 3], motion.data[m + 4], motion.data[m + 5]) + point.mulAdd(velocity, delta).mulAdd(acceleration, .5f * delta * delta) + velocity.mulAdd(acceleration, delta) + position.data[p] = point.x; position.data[p + 1] = point.y; position.data[p + 2] = point.z + motion.data[m] = velocity.x; motion.data[m + 1] = velocity.y; motion.data[m + 2] = velocity.z + val a = index * appearance.strideSize + val progress = life.data[index * life.strideSize + ParticleChannels.LifePercentOffset] + color.data[index * color.strideSize + ParticleChannels.AlphaOffset] = + (appearance.data[a] + (appearance.data[a + 1] - appearance.data[a]) * progress).coerceIn(0f, 1f) + } + } + + override fun copy(): Influencer = BurstInfluencer() + } + + companion object { + private val MOTION = ChannelDescriptor(ParticleChannels.newGlobalId(), Float::class.javaPrimitiveType, 6) + private val APPEARANCE = ChannelDescriptor(ParticleChannels.newGlobalId(), Float::class.javaPrimitiveType, 4) + } +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/HeatbeamEffects.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/HeatbeamEffects.kt new file mode 100644 index 000000000..3c9e801bd --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/HeatbeamEffects.kt @@ -0,0 +1,89 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.Camera +import com.badlogic.gdx.math.MathUtils +import com.badlogic.gdx.math.Vector3 +import jake2.qcommon.Defines +import jake2.qcommon.network.messages.server.BeamTEMessage +import kotlin.math.ceil +import kotlin.math.sqrt + +/** Consumes borrowed per-frame vectors synchronously; implementations must copy anything they retain. */ +internal fun interface HeatbeamParticleSink { + fun emit(local: Boolean, origin: Vector3, direction: Vector3, right: Vector3, up: Vector3, length: Float, nowMs: Int) +} + +/** + * Owned heatbeam state (CL_ParseHeatBeam / CL_AddHeatBeams). Local beams follow the presented + * camera and gun offset; all other beams retain their recorded endpoints. Both live and demo + * views supply the same context. The existing world/view-weapon camera also projects these models. + */ +internal class HeatbeamEffects( + private val particles: HeatbeamParticleSink, + private val maximumBeams: Int = 32, + private val maximumSegmentsPerBeam: Int = 128, +) { + init { require(maximumBeams > 0 && maximumSegmentsPerBeam > 0) } + private data class Beam(val message: BeamTEMessage, val expiresAt: Long, var lastParticlesAt: Int?) + private val beams = LinkedHashMap() + private val origin = Vector3() + private val direction = Vector3() + private val right = Vector3() + private val up = Vector3() + val activeCount: Int get() = beams.size + + fun process(message: BeamTEMessage, nowMs: Int): Boolean { + if (message.style != Defines.TE_HEATBEAM && message.style != Defines.TE_MONSTER_HEATBEAM) return false + if (message.ownerIndex !in 0 until Defines.MAX_EDICTS) return true + expire(nowMs) + if (message.ownerIndex !in beams && beams.size >= maximumBeams) return true + beams[message.ownerIndex] = Beam(message, nowMs.toLong() + 200, beams[message.ownerIndex]?.lastParticlesAt) + return true + } + + fun submit(nowMs: Int, localPlayerEntity: Int?, camera: Camera, gunOffset: Vector3, hand: Int, sink: BeamSegmentSink) { + expire(nowMs) + for ((owner, beam) in beams) { + val message = beam.message + val local = owner == localPlayerEntity && message.style == Defines.TE_HEATBEAM + if (local) { + direction.set(camera.direction).nor() + right.set(direction).crs(camera.up).nor() + up.set(right).crs(direction).nor() + val side = when (hand) { 1 -> -1f; 2 -> 0f; else -> 1f } + // CL_ParseHeatBeam's player offset: right 2, forward 7, up -3; centered weapons sit one unit lower. + origin.set(camera.position).add(gunOffset).mulAdd(right, 2f * side).mulAdd(direction, 7f) + .mulAdd(up, if (hand == 2) -4f else -3f) + } else { + origin.set(message.origin.x, message.origin.y, message.origin.z) + } + val end = message.destination + val dx = end.x - origin.x; val dy = end.y - origin.y; val dz = end.z - origin.z + val length = sqrt(dx * dx + dy * dy + dz * dz) + if (length < .001f) continue + if (!local) { + direction.set(dx, dy, dz).scl(1f / length) + right.set(direction).crs(if (kotlin.math.abs(direction.z) < .9f) Vector3.Z else Vector3.X).nor() + up.set(right).crs(direction).nor() + } + val kind = if (local) BeamKind.HEATBEAM_PLAYER else BeamKind.HEATBEAM_MONSTER + val pitch = MathUtils.atan2(direction.z, sqrt(direction.x * direction.x + direction.y * direction.y)) * MathUtils.radiansToDegrees + val yaw = MathUtils.atan2(direction.y, direction.x) * MathUtils.radiansToDegrees + 180f + val count = ceil(length / kind.segmentLength).toInt().coerceIn(1, maximumSegmentsPerBeam) + val step = if (count > 1) (length - kind.segmentLength) / (count - 1) else 0f + for (index in 0 until count) { + val distance = index * step + sink.submit(kind, origin.x + direction.x * distance, origin.y + direction.y * distance, + origin.z + direction.z * distance, pitch, yaw, (nowMs % 360).toFloat()) + } + // Short overlapping pulses give consistent density without emitting on every render or while paused. + if (beam.lastParticlesAt == null || nowMs.toLong() - beam.lastParticlesAt!! >= 50) { + beam.lastParticlesAt = nowMs + particles.emit(local, origin, direction, right, up, length, nowMs) + } + } + } + + fun clear() = beams.clear() + private fun expire(nowMs: Int) = beams.values.removeAll { nowMs.toLong() >= it.expiresAt } +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/HeatbeamParticles.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/HeatbeamParticles.kt new file mode 100644 index 000000000..bf7e5ff91 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/HeatbeamParticles.kt @@ -0,0 +1,44 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.math.MathUtils +import com.badlogic.gdx.math.Vector3 +import kotlin.random.Random + +/** Brief libGDX particle pulses for CL_Heatbeam rings and CL_MonsterPlasma_Shell. */ +internal class HeatbeamParticles( + private val particles: GdxParticleRuntime, + private val paletteColor: (Int) -> Color, + private val random: Random = Random.Default, +) : HeatbeamParticleSink { + private val position = Vector3() + private val radial = Vector3() + + override fun emit(local: Boolean, origin: Vector3, direction: Vector3, right: Vector3, up: Vector3, length: Float, nowMs: Int) { + if (local) { + val phase = (nowMs % 1000) * .096f % 32f + repeat(5) { ring -> + val distance = 8f + phase + ring * 32f + if (distance > length || distance > 160f) return + val radius = 1.5f * (distance / 10f).coerceAtMost(1f) + repeat(20) { point -> + val angle = point * 18f + position.set(origin).mulAdd(direction, distance).mulAdd(right, MathUtils.cosDeg(angle) * radius) + .mulAdd(up, MathUtils.sinDeg(angle) * radius) + if (!emitParticle(position, 223 - random.nextInt(8), .5f, nowMs)) return + } + } + } else { + repeat(40) { + radial.set(random.nextFloat() * 2f - 1f, random.nextFloat() * 2f - 1f, random.nextFloat() * 2f - 1f) + if (radial.isZero) radial.set(Vector3.Z) + position.set(origin).mulAdd(radial.nor(), 10f) + if (!emitParticle(position, 0xE0, 1f, nowMs)) return + } + } + } + + private fun emitParticle(position: Vector3, color: Int, alpha: Float, nowMs: Int): Boolean = + particles.emit(position, Vector3.Zero, Vector3.Zero, paletteColor(color), size = .5f, + lifetimeMillis = 75, nowMs = nowMs, startAlpha = alpha) +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ImpactEffects.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ImpactEffects.kt new file mode 100644 index 000000000..f4bed325e --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ImpactEffects.kt @@ -0,0 +1,114 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.math.Vector3 +import jake2.qcommon.Defines.* +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.* +import kotlin.random.Random + +internal data class ImpactSoundRequest(val path: String, val origin: Vector3f, val attenuation: Float = ATTN_NORM.toFloat()) + +/** + * Particle and sound components of CL_ParticleEffect/2/3 and CL_BlasterParticles/2. + * Positions scatter along the impact normal; velocities scatter independently, with directed + * velocity for blaster impacts. The outer controller retains model flashes and dynamic lights. + */ +internal class ImpactEffects( + private val particles: GdxParticleRuntime, + private val paletteColor: (Int) -> Color, + private val playSound: (ImpactSoundRequest) -> Unit, + private val random: Random = Random.Default, +) { + private data class Recipe(val count: Int, val palette: Int, val distance: Float = 31f, + val speed: Float = 20f, val forwardSpeed: Float = 0f, + val variants: Int = 8, val gravity: Float = -80f) + private val direction = Vector3() + private val position = Vector3() + private val velocity = Vector3() + private val acceleration = Vector3() + + fun process(message: TEMessage, nowMs: Int): Boolean { + when (message) { + is PointDirectionTEMessage -> { + val recipe = when (message.style) { + TE_BLOOD -> Recipe(60, 0xE8) + TE_MOREBLOOD -> Recipe(250, 0xE8) + TE_GREENBLOOD -> Recipe(30, 0xDF, distance = 7f) + TE_GUNSHOT -> Recipe(40, 0) + TE_SPARKS, TE_BULLET_SPARKS -> Recipe(6, 0xE0) + TE_SCREEN_SPARKS -> Recipe(40, 0xD0) + TE_SHIELD_SPARKS -> Recipe(40, 0xB0) + TE_ELECTRIC_SPARKS -> Recipe(40, 0x75) + TE_SHOTGUN -> Recipe(20, 0) + TE_BLASTER -> blaster(0xE0) + TE_BLASTER2 -> blaster(0xD0) + TE_FLECHETTE -> blaster(0x6F) + else -> return false + } + emit(message.position, message.direction, recipe, nowMs) + when (message.style) { + TE_GUNSHOT, TE_BULLET_SPARKS -> { + val roll = random.nextInt(16) + if (roll in 1..3) playSound(ImpactSoundRequest("sound/world/ric$roll.wav", message.position)) + } + TE_SCREEN_SPARKS, TE_SHIELD_SPARKS, TE_ELECTRIC_SPARKS, TE_BLASTER, TE_BLASTER2, TE_FLECHETTE -> + playSound(ImpactSoundRequest("sound/weapons/lashit.wav", message.position)) + } + } + is SplashTEMessage -> { + val recipe = when (message.style) { + TE_SPLASH -> Recipe(message.count, SPLASH_COLORS.getOrElse(message.param) { 0 }) + TE_LASER_SPARKS, TE_WELDING_SPARKS -> Recipe(message.count, message.param, distance = 7f) + TE_TUNNEL_SPARKS -> Recipe(message.count, message.param, distance = 7f, variants = 1, gravity = 80f) + else -> return false + } + emit(message.position, message.direction, recipe, nowMs) + if (message.style == TE_SPLASH && message.param == SPLASH_SPARKS) { + val sound = when (random.nextInt(4)) { 0 -> 5; 1 -> 6; else -> 7 } + playSound(ImpactSoundRequest("sound/world/spark$sound.wav", message.position, ATTN_STATIC.toFloat())) + } + } + is TrailTEMessage -> { + if (message.style != TE_BLUEHYPERBLASTER) return false + // This subtype transmits a direction with position precision, rather than a trail endpoint. + emit(message.position, message.destination, blaster(0xE0), nowMs) + } + is PointTEMessage -> { + if (message.style != TE_CHAINFIST_SMOKE) return false + emitSmoke(message.position, nowMs) + } + else -> return false + } + return true + } + + private fun blaster(palette: Int) = Recipe(40, palette, distance = 15f, speed = 40f, forwardSpeed = 30f) + + private fun emit(origin: Vector3f, normal: Vector3f, recipe: Recipe, nowMs: Int) { + direction.set(normal.x, normal.y, normal.z).nor() + acceleration.set(0f, 0f, recipe.gravity) + repeat(recipe.count) { + position.set(origin.x + signedRandom() * 4f, origin.y + signedRandom() * 4f, origin.z + signedRandom() * 4f) + .mulAdd(direction, random.nextFloat() * recipe.distance) + velocity.set(signedRandom() * recipe.speed, signedRandom() * recipe.speed, signedRandom() * recipe.speed) + .mulAdd(direction, recipe.forwardSpeed) + val color = (recipe.palette + random.nextInt(recipe.variants)) and 255 + if (!particles.emit(position, velocity, acceleration, paletteColor(color), size = .5f, + lifetimeMillis = random.nextInt(500, 801), nowMs = nowMs)) return + } + } + + private fun emitSmoke(origin: Vector3f, nowMs: Int) { + repeat(20) { + position.set(origin.x + signedRandom() * 2f, origin.y + signedRandom() * 2f, origin.z + signedRandom() * 2f) + velocity.set(signedRandom() * 7f, signedRandom() * 7f, 20f) + if (!particles.emit(position, velocity, Vector3.Zero, paletteColor(random.nextInt(8)), size = .7f, + lifetimeMillis = random.nextInt(500, 801), nowMs = nowMs)) return + } + } + + private fun signedRandom() = random.nextFloat() * 2f - 1f + + companion object { private val SPLASH_COLORS = intArrayOf(0x00, 0xE0, 0xB0, 0x50, 0xD0, 0xE0, 0xE8) } +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/LaserEffects.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/LaserEffects.kt new file mode 100644 index 000000000..445a54ea7 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/LaserEffects.kt @@ -0,0 +1,35 @@ +package org.demoth.cake.stages.ingame.effects + +import jake2.qcommon.Defines +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.TrailTEMessage +import kotlin.random.Random + +internal data class LaserBeam(val origin: Vector3f, val destination: Vector3f, val paletteIndex: Int) { + val diameter: Float get() = 4f + val alpha: Float get() = .3f +} + +/** CL_ParseLaser / CL_AddLasers: short-lived palette beams, submitted through the shared RF_BEAM renderer. */ +internal class LaserEffects(private val random: Random = Random.Default, private val capacity: Int = 64) { + init { require(capacity > 0) } + private data class Entry(val beam: LaserBeam, val expiresAt: Long) + private val entries = ArrayList() + val activeCount: Int get() = entries.size + + fun process(message: TrailTEMessage, nowMs: Int): Boolean { + if (message.style != Defines.TE_BFG_LASER) return false + update(nowMs) + if (entries.size >= capacity || message.position.distance(message.destination) < .001f) return true + entries += Entry(LaserBeam(message.position, message.destination, 0xD0 + random.nextInt(4)), nowMs.toLong() + 100) + return true + } + + fun submit(nowMs: Int, draw: (LaserBeam) -> Unit) { + update(nowMs) + entries.forEach { draw(it.beam) } + } + + fun clear() = entries.clear() + fun update(nowMs: Int) { entries.removeAll { nowMs.toLong() >= it.expiresAt } } +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/LineBeamEffect.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/LineBeamEffect.kt deleted file mode 100644 index d099a7547..000000000 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/LineBeamEffect.kt +++ /dev/null @@ -1,84 +0,0 @@ -package org.demoth.cake.stages.ingame.effects - -import com.badlogic.gdx.graphics.Color -import com.badlogic.gdx.graphics.GL20 -import com.badlogic.gdx.graphics.VertexAttributes -import com.badlogic.gdx.graphics.g3d.Material -import com.badlogic.gdx.graphics.g3d.Model -import com.badlogic.gdx.graphics.g3d.ModelBatch -import com.badlogic.gdx.graphics.g3d.ModelInstance -import com.badlogic.gdx.graphics.g3d.attributes.BlendingAttribute -import com.badlogic.gdx.graphics.g3d.attributes.ColorAttribute -import com.badlogic.gdx.graphics.g3d.attributes.DepthTestAttribute -import com.badlogic.gdx.graphics.g3d.attributes.IntAttribute -import com.badlogic.gdx.graphics.g3d.utils.ModelBuilder -import com.badlogic.gdx.math.Quaternion -import com.badlogic.gdx.math.Vector3 - -/** - * Short-lived beam segment rendered as procedural cylinder geometry. - * - * Used for TE trail effects where no dedicated model/sprite path exists yet in cake - * (for example rail and BFG laser trails). - * - * Resource behavior: - * each instance owns a small generated [Model] and disposes it on expiry. - */ -@Suppress("DEPRECATION") -class LineBeamEffect( - start: Vector3, - end: Vector3, - color: Color, - private val spawnTimeMs: Int, - private val durationMs: Int, - radius: Float = 0.75f, - alpha: Float = 1f, -) : ClientTransientEffect { - private val model: Model - private val instance: ModelInstance - - init { - val direction = Vector3(end).sub(start) - val length = direction.len().coerceAtLeast(0.001f) - direction.scl(1f / length) - - val materialAttributes = mutableListOf( - ColorAttribute.createDiffuse(color), - ColorAttribute(ColorAttribute.Emissive, color.r, color.g, color.b, 1f), - DepthTestAttribute(GL20.GL_LEQUAL, 0f, 1f, false), - IntAttribute.createCullFace(GL20.GL_NONE), - ) - if (alpha < 1f) { - materialAttributes += BlendingAttribute(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA, alpha) - } - - val material = Material(*materialAttributes.toTypedArray()) - val modelBuilder = ModelBuilder() - modelBuilder.begin() - modelBuilder.part( - "beam", - GL20.GL_TRIANGLES, - (VertexAttributes.Usage.Position or VertexAttributes.Usage.Normal).toLong(), - material, - ).cylinder(2f, 1f, 2f, 6, 0f, 360f) - model = modelBuilder.end() - instance = ModelInstance(model) - - val midpoint = Vector3(start).add(end).scl(0.5f) - val rotation = Quaternion().setFromCross(Vector3.Y, direction) - val scale = Vector3(radius, length, radius) - instance.transform.set(midpoint, rotation, scale) - } - - override fun update(nowMs: Int, deltaSeconds: Float): Boolean { - return nowMs - spawnTimeMs < durationMs - } - - override fun render(modelBatch: ModelBatch) { - modelBatch.render(instance) - } - - override fun dispose() { - model.dispose() - } -} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ParticleRenderer.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ParticleRenderer.kt index 50f434e88..f6e6b58ef 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ParticleRenderer.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ParticleRenderer.kt @@ -44,7 +44,7 @@ import kotlin.math.tan */ class ParticleRenderer( private val assetManager: AssetManager, -) : Disposable { +) : Disposable, ParticleSink { data class Stats( val submittedParticles: Int = 0, val drawCalls: Int = 0, @@ -120,7 +120,7 @@ class ParticleRenderer( * * No draw calls happen here; GPU upload/draw is deferred to [flush]. */ - fun submit( + override fun submit( mode: ParticleRenderMode, blend: ParticleBlendMode, x: Float, diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/PointParticles.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/PointParticles.kt new file mode 100644 index 000000000..1025a933c --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/PointParticles.kt @@ -0,0 +1,74 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.math.MathUtils +import com.badlogic.gdx.math.Vector3 +import jake2.qcommon.Defines +import jake2.qcommon.network.messages.server.PointTEMessage +import kotlin.random.Random + +/** Particle components of CL_BigTeleportParticles, CL_ColorExplosionParticles and CL_ExplosionParticles, backed by libGDX. */ +internal class PointParticles( + private val particles: GdxParticleRuntime, + private val paletteColor: (Int) -> Color, + private val random: Random = Random.Default, +) { + private val position = Vector3() + private val velocity = Vector3() + private val acceleration = Vector3() + private val teleportPalette = intArrayOf(16, 104, 168, 144) + + fun process(message: PointTEMessage, nowMs: Int): Boolean { + val explosionCount = when (message.style) { + Defines.TE_EXPLOSION1_BIG, Defines.TE_EXPLOSION1_NP, Defines.TE_PLAIN_EXPLOSION -> 0 + Defines.TE_EXPLOSION2, Defines.TE_GRENADE_EXPLOSION, Defines.TE_GRENADE_EXPLOSION_WATER -> 128 + Defines.TE_EXPLOSION1, Defines.TE_ROCKET_EXPLOSION, Defines.TE_ROCKET_EXPLOSION_WATER -> 112 + Defines.TE_PLASMA_EXPLOSION -> 96 + else -> null + } + if (explosionCount != null) { + repeat(explosionCount) { + position.set(message.position.x + signedRandom() * 16f, message.position.y + signedRandom() * 16f, + message.position.z + signedRandom() * 16f) + velocity.set(signedRandom() * 192f, signedRandom() * 192f, signedRandom() * 192f) + acceleration.set(0f, 0f, -80f) + if (!particles.emit(position, velocity, acceleration, paletteColor(0xE0 + random.nextInt(8)), + size = .6f, lifetimeMillis = random.nextInt(500, 801), nowMs = nowMs)) return true + } + return true + } + val teleport = when (message.style) { + Defines.TE_BOSSTPORT -> true + Defines.TE_TRACKER_EXPLOSION -> false + else -> return false + } + repeat(if (teleport) 768 else 128) { + val color: Int + val lifetime: Int + if (teleport) { + val angle = random.nextFloat() * MathUtils.PI2 + val x = MathUtils.cos(angle); val y = MathUtils.sin(angle) + val radius = random.nextFloat() * 32f + position.set(message.position.x + x * radius, message.position.y + y * radius, + message.position.z + 8f + random.nextFloat() * 90f) + velocity.set(x * (70f + random.nextFloat() * 63f), y * (70f + random.nextFloat() * 63f), + -100f + random.nextFloat() * 31f) + acceleration.set(-x * 100f, -y * 100f, 160f) + color = teleportPalette[random.nextInt(teleportPalette.size)] + lifetime = random.nextInt(1600, 2601) + } else { + position.set(message.position.x + signedRandom() * 16f, message.position.y + signedRandom() * 16f, + message.position.z + signedRandom() * 16f) + velocity.set(signedRandom() * 128f, signedRandom() * 128f, signedRandom() * 128f) + acceleration.set(0f, 0f, -80f) + color = 0 + lifetime = random.nextInt(1500, 2001) + } + if (!particles.emit(position, velocity, acceleration, paletteColor(color), size = .6f, + lifetimeMillis = lifetime, nowMs = nowMs)) return true + } + return true + } + + private fun signedRandom() = random.nextFloat() * 2f - 1f +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md index 08cc3c8ab..0415d8878 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/README.md @@ -30,28 +30,48 @@ Server effect messages / entity-event and trail callbacks WorldPresentationRenderer frame -> effectsSystem.update(): simulate particles, advance/expire effect instances - -> collect replicated trails and lights + -> collect replicated trails + -> reset frame lights, collect explosion and replicated-entity lights -> draw world and entities, then translucent world -> effectsSystem.renderParticles(): dedicated particle pass -> effectsSystem.render(): transient models/sprites/beams through ModelBatch + -> effectsSystem.renderLasers(): palette lasers through the shared BeamRenderer ``` `ClientTransientEffect.update(nowMs, deltaSeconds)` returns false when an instance expires; the system removes and disposes it in that update loop. Update and render -run on the render thread. `AnimatedModelEffect` and `AnimatedSpriteEffect` provide -timed animation; `ExplosionMd2Effect` and `ExplosionSpriteEffect` apply legacy -explosion frame, skin, and alpha rules. Model effects reuse the +run on the render thread. `ExplosionAnimation` uses libGDX `Animation` for shared explosion +frame, skin, and alpha rules. `ExplosionMd2Effect` and `ExplosionSpriteEffect` +apply that state to their visuals. Model effects reuse the [MD2 pipeline](../../../assets/md2/README.md); sprite effects use `Sp2Renderer`. ## Particle Simulation and Rendering -`EffectParticleSystem` stores particle positions, velocities, colour, size, alpha -ramps, and lifetimes in parallel arrays. Updates integrate velocity/gravity and -remove expired particles by swapping in the last live entry. +`EffectParticleSystem` routes burst and explicit particle emission through +`GdxParticleRuntime`. A libGDX `ParticleController` and `RegularEmitter` own particle +channels, lifetime progression and removal. Cake supplies a small influencer for +message-specific velocity, three-axis acceleration and alpha endpoints, while +`ParticleRenderer` consumes the resulting channels in the existing world draw pass. + +The presentation clock drives controller updates, including demo pause. Existing +particles advance to an emission's timestamp before new particles are added, so +short pulses survive their first frame. Emission copies supplied vectors and colors; +callers can reuse scratch values. Motion uses physical acceleration and stable +constant-acceleration integration rather than reproducing reference particle math. + +The runtime is tested directly with synthetic emissions and clocks, without GL, +game data or effect assets. libGDX's [3D particle framework](https://libgdx.com/wiki/graphics/3d/3d-particle-effects) +provides the underlying controller/emitter. The stock `PointSpriteParticleBatch` +and `BillboardParticleBatch` support configurable depth/blending, but their default +shader does not implement Cake's gamma correction, circular cutout or size rule. +Direct replacement would also require splitting the mixed mode/blend channels into +separate batch inputs. The existing drawing adapter retains these engine-specific +requirements; simulation, storage and lifetime remain libGDX-owned. GPU resources +are created on the first particle draw, so dispatch/update/teardown require no GL. `r_particles` supplies the live emission budget: zero or negative values disable emission and drawing, while positive values limit new spawns to the remaining -budget. Overflow emits are dropped; there is no hard upper clamp. Lowering the +validated budget up to `MAX_PARTICLES`. Overflow emits are dropped. Lowering the budget does not immediately remove existing particles; they continue to age out. `ParticleRenderer` uploads dynamic mesh data rather than creating a `ModelInstance` @@ -66,6 +86,136 @@ intensity/overbright multipliers. Shader sources are `assets/shaders/particle_point.vert/.frag` and `particle_billboard.vert/.frag`, loaded through `AssetManager` and the [shared resolver](../../../assets/README.md). +## Persistent Beams and Lightning + +`BeamEffects` owns cable/grapple and lightning state for the screen. Cable messages +refresh by source entity, so parasite/medic/grapple updates replace their previous +beam. Lightning identifies a source/destination pair, allowing multiple targets +from the same source. Each message refreshes a 200 ms lifetime; expired slots are +reclaimed before admitting new beams. State is bounded to 32 beams and geometry to +128 model segments per beam. + +After the player view is updated, beam geometry is rebuilt from current camera +state. Beams originating from the viewed player follow that camera, with the view +height adjustment and transmitted grapple offset. Other origins retain the recorded +world position. This works for live and recorded views without waiting for another +beam message. Lightning uses its dedicated full-bright model and handles short bolts +from the target end to avoid extending through the Tesla mine. Its roll advances +with presentation time and freezes when that clock pauses. + +`BeamModelRenderer` reuses libGDX `ModelInstance` pools and submits them through the +existing `ModelBatch` pass. It owns no model assets; model loading/unloading remains +with the effect asset catalog. Missing optional expansion models are skipped. Tesla +sound requests use the source entity and weapon channel, resolved through the shared +audio system. Screen teardown clears beam state and instance pools before unloading +catalog assets. Resource-free tests cover state, geometry, sound requests, pooled +instance transforms/reuse and borrowed model ownership using empty libGDX models. + +## Player and Monster Heatbeams + +`HeatbeamEffects` owns up to 32 heatbeams, refreshed by source entity with a 200 ms +lifetime. Local player beams follow the presented camera direction and interpolated +replicated gun offset. The `hand` setting shifts the muzzle right, left or to the +center; model/projection mirroring is not used. Remote player and monster beams +retain their recorded world endpoints. All views, including demos, supply the same +presentation context. + +Heatbeam segments reuse the shared model-instance pools with the optional +`models/proj/beam/tris.md2` asset. Player and remote beams select frames 1 and 2, +respectively, use full-bright lighting and roll with presentation time. Segment +counts are capped at 128 per beam. Models share the world/view-weapon camera +projection, so they require no separate weapon-FOV correction. + +`HeatbeamParticles` emits short pulses through the libGDX particle runtime: up to +five moving rings near the local muzzle, or a plasma shell around a remote origin. +Pulses are limited to one per source every 50 ms, live for 75 ms and obey the shared +particle budget. Refreshing messages or rendering repeatedly at a paused timestamp +does not add duplicate pulses. Screen teardown clears heatbeam state, model pools +and the shared particle runtime. Synthetic-camera and particle tests cover these +paths without game assets or a window. + +## Steam and Sustained Emission + +`SteamEffects` uses the shared libGDX particle runtime for instantaneous steam and +repeated steam bursts. Messages supply particle count, normal, palette range and +magnitude. Magnitude controls forward speed, lateral spread and spawn jitter; +particles fade over short lifetimes with a gentle downward acceleration. +Heatbeam impact sparks and steam use this same recipe and retain their impact sound. + +Each screen owns at most 32 steam sustains. Reusing an identifier replaces its +parameters and refreshes its duration. `SustainScheduler` emits immediately on the +next effects update, then on a 100 ms cadence until expiry. A stalled frame emits +at most one burst per active sustain, skipping missed bursts. Repeated updates at +a paused presentation time do not emit again. Nonpositive durations cancel an +identifier. Clearing sustains stops future emission; existing particles finish +fading, or are disposed with the screen's particle runtime during teardown. + +## Other Expansion Effects + +`ExpansionEffects` routes flashlight messages to entity-keyed white dynamic lights +with a 100 ms lifetime. Repeated messages update the same light through +`DynamicLightSystem`, including its normal high-FPS visibility extension. Force +walls emit falling palette-colored particles along their segment; bubble trail 2 +emits a denser rising trail with the impact sound at its start. Segment sampling +has a work cap and all particles obey the shared budget. + +`ShellEffects` uses `SustainScheduler` and the libGDX particle runtime for expanding +widow beam-out and nuke shells. Widow shells expand toward a 45-unit radius over +2.1 seconds and refresh by wire identifier. Nuke shells expand toward 200 units +over one second; their messages have no identifier, so independent explosions +receive local IDs outside the wire's signed-short range. The two kinds share a +32-sustain capacity. Particle pulses run at 50 ms intervals, last 75 ms and use +family-specific palettes, with counts tuned for the existing particle budget. +Paused clocks do not emit again, stalls skip missed pulses, and clearing sustain +state stops future emission while existing particles finish fading. + +Tests use synthetic messages, clocks and palette values with the real light and +particle runtimes to exercise refresh, motion, sound requests, coexistence, +expiry, pause, resource limits and cleanup. No installed content or window is needed. + +## Impacts and Point Bursts + +`ImpactEffects` supplies particle recipes for blood, gunshot, spark, shotgun and +blaster families, including blue-hyperblaster impacts. Spawn positions scatter +along the supplied normal, while velocities scatter independently; blaster +variants also carry directed velocity and their own palette ranges. Chainfist +smoke uses particles only and rises without downward acceleration. Welding sparks +add a short yellow light without a flash model. The outer controller retains model +flashes and dynamic lights. + +Splash, laser, welding and tunnel effects preserve their transmitted counts, +including zero, subject to the shared particle budget. Laser/welding particles +vary within their palette range and fall; tunnel particles retain the supplied +color and accelerate upward. Gunshot/bullet impacts select a ricochet on three +of sixteen sound rolls; spark splashes weight their three sounds 1:1:2. Sounds +remain independent of whether the particle budget permits emission. + +`PointParticles` adds the boss teleport's tall radial column and tracker +explosion's black particle cloud, alongside their existing sound/light components. +Other point/trail families retain dedicated recipes: rail helix/core, green BFG +cloud, teleport bursts and outward widow splash. Ordinary bubbles rise at a +steady velocity, and diagnostic trails remain stationary. These are Cake +approximations, rather than pixel-perfect replicas of reference emission. + +## Explosions and Palette Lasers + +`ExplosionEffect` owns animation state and contributes a frame light whose radius +follows the animation alpha. Lights are collected after the frame-light reset; +expiry removes both the visual and its light. Missing optional visual assets still +retain the animated light. MD2 explosions select frame pairs, skins and translucency +from the shared state; sprite explosions select its current frame. Materials use +standard libGDX alpha blending. `TE_EXPLOSION1_BIG`, `TE_EXPLOSION1_NP` and +`TE_PLAIN_EXPLOSION` do not emit explosion particles. + +`LaserEffects` keeps up to 64 independent BFG laser pulses for 100 ms. Each pulse +chooses a palette color from `0xD0..0xD3`, with diameter 4 and opacity 0.3; its color +stays stable when presentation time pauses. The screen-owned `BeamRenderer` shares +cached libGDX cylinder models with RF_BEAM entities, avoiding per-pulse GPU resource +allocation. Update expires pulses even when no draw occurs. Screen teardown clears +laser state and disposes the shared renderer. +Synthetic tests cover animation, lights, materials, transforms, expiry and ownership +without game assets or a window. + ## Visual Behaviour and Current Limits Particle families select colours from `q2palette.bin`. Useful compatibility anchors: @@ -77,13 +227,70 @@ Particle families select colours from `q2palette.bin`. Useful compatibility anch Fallback colours keep effects visible when palette data is unavailable. `TE_BFG_EXPLOSION` uses the translucent `sprites/s_bfg2.sp2` explosion path; -`TE_BFG_LASER` remains a procedural `LineBeamEffect` approximation. Parasite/medic -and grapple beams use repeated segment models. Visual parity varies by effect -family; unsupported styles inside recognised TE message classes can fall through -silently, while unhandled message classes produce a warning. +`TE_BFG_LASER` uses the shared palette-beam renderer. Parasite/medic and grapple +beams use pooled segment models rebuilt from persistent beam state. Particle +patterns and procedural geometry remain visual approximations of the reference; +base-game effects, live play, pause and map/session transitions still need visual +verification. + +Source references are `client/CL_fx.ParseMuzzleFlash2`, `CL_fx.LogoutEffect`, +`CL_tent.ParseTEnt`, Yamagi's `src/client/cl_tempentities.c` / `cl_effects.c` / +`cl_particles.c`, and Q2PRO's `src/client/tent.c` / `effects.c` in the sibling checkouts. +The comparison targets the classic effects, rather than Q2PRO's extended protocol. +For example, Q2PRO also presents `TE_RAILTRAIL2`; Cake follows Yamagi's supported +set and rejects that type and `TE_FLAME`. Yamagi plays a rocket sound for +`TE_PLAIN_EXPLOSION`, while Q2PRO leaves it silent; Cake retains Yamagi's sound. + +## Protocol-34 Presentation Coverage + +All 54 supported types have a presentation path. Names below omit `TE_`; wire IDs +are included to make the boundary explicit. Optional missing assets, disabled +particles/lights and exhausted budgets can still suppress their respective output. + +| Types (wire IDs) | Presentation owner and components | +| --- | --- | +| GUNSHOT (0), SHOTGUN (4), BULLET_SPARKS (14) | `ImpactEffects` particles; controller smoke/flash; probabilistic ricochet for gunshot/bullet sparks | +| BLOOD (1), GREENBLOOD (26), MOREBLOOD (42) | `ImpactEffects` blood palettes and counts | +| BLASTER (2), BLASTER2 (30), FLECHETTE (55) | `ImpactEffects` directed particles and sound; controller model/skin and animated light | +| RAILTRAIL (3) | Controller helix/core particles and rail sound | +| EXPLOSION1 (5), EXPLOSION2 (6), ROCKET_EXPLOSION (7), GRENADE_EXPLOSION (8), ROCKET_EXPLOSION_WATER (17), GRENADE_EXPLOSION_WATER (18), PLASMA_EXPLOSION (28) | `PointParticles` cloud; controller animated model/light and dry/water sound | +| SPARKS (9) | `ImpactEffects` spark particles | +| SPLASH (10) | `ImpactEffects` transmitted count/palette and spark-splash sound | +| BUBBLETRAIL (11) | Controller rising bubble trail | +| SCREEN_SPARKS (12), SHIELD_SPARKS (13), ELECTRIC_SPARKS (46) | `ImpactEffects` palette particles and impact sound | +| LASER_SPARKS (15), TUNNEL_SPARKS (29) | `ImpactEffects` transmitted count/color, falling/rising particles | +| PARASITE_ATTACK (16), MEDIC_CABLE_ATTACK (19), GRAPPLE_CABLE (24) | `BeamEffects` refreshed cable state and pooled segment models | +| BFG_EXPLOSION (20) | Controller animated sprite and green light | +| BFG_BIGEXPLOSION (21) | Controller green particle cloud | +| BOSSTPORT (22) | `PointParticles` teleport column; controller unattenuated teleport sound | +| BFG_LASER (23) | `LaserEffects` independent pulses; shared `BeamRenderer` geometry | +| WELDING_SPARKS (25) | `ImpactEffects` particles; controller light-only flash | +| BLUEHYPERBLASTER (27) | `ImpactEffects` directed particles; second position field is interpreted as a direction | +| LIGHTNING (33) | `BeamEffects` source/destination-keyed segments and entity-associated Tesla sound | +| DEBUGTRAIL (34) | Controller stationary diagnostic particles | +| PLAIN_EXPLOSION (35), EXPLOSION1_BIG (53), EXPLOSION1_NP (54) | Controller model/light and sound, without explosion particles | +| FLASHLIGHT (36), FORCEWALL (37), BUBBLETRAIL2 (41) | `ExpansionEffects` keyed light, falling wall particles, or rising bubbles plus impact sound | +| HEATBEAM (38), MONSTER_HEATBEAM (39) | `HeatbeamEffects` refreshed attached/world beams; `HeatbeamParticles` rings/shell | +| STEAM (40), HEATBEAM_SPARKS (43), HEATBEAM_STEAM (44) | `SteamEffects` instant/sustained bursts; controller sound for heatbeam impacts | +| CHAINFIST_SMOKE (45) | `ImpactEffects` rising smoke particles | +| TRACKER_EXPLOSION (47) | `PointParticles` black cloud; controller negative light and disruption sound | +| TELEPORT_EFFECT (48), DBALL_GOAL (49), WIDOWSPLASH (52) | Controller teleport burst or outward widow shell particles | +| WIDOWBEAMOUT (50), NUKEBLAST (51) | `ShellEffects` bounded, timed expanding particle pulses | + +`TempEntityPresentationTest` exercises every type through the real dispatcher with +synthetic messages and no optional assets. It checks visible-effect state, paused +updates, expiry without drawing, and a fresh session after teardown. Family tests +cover the detailed particle/beam geometry, timing, sounds, budgets and material +requests; `TemporaryEntityCodecTest` independently checks wire layouts and bounded +rejections. These tests do not validate GPU output or audible playback. + +User-run visual checks still needed after the final changes: -Legacy references are `client/CL_fx.ParseMuzzleFlash2`, `CL_fx.LogoutEffect`, -`CL_tent.ParseTEnt`, and `../yquake2/src/client/cl_effects.c`. +- Base-game gunshot/shotgun/blaster/rail impacts, dry/water explosions and BFG effects. +- Expansion beams, welding and chainfist smoke in live play; the three Rogue demos + were already reported playable and visually good before the final audit fixes. +- Pause/resume during sustained effects, followed by a map change and disconnect/ + reconnect, checking for frozen leftovers, missing assets or duplicated effects. ## Assets and Lifetime @@ -92,9 +299,10 @@ listed MD2 models, SP2 sprites, and sounds that configstrings may not supply. Files absent from raw resolver lookup are skipped; catalog lookups return null for unloaded assets. Present files still pass through the normal loaders. -The catalog records paths visited during precache, reuses already loaded assets, -and unloads its tracked paths during disposal. This tracking is separate from -configuration asset ownership. +The catalog acquires one AssetManager reference per present asset, including +already loaded assets, and releases that reference during disposal. Repeated +precache/dispose calls do not acquire/release twice. This keeps config assets and +overlapping screens independent during map transitions. `ClientEffectsSystem.dispose()` disposes remaining transient instances, particle meshes/shaders, its sprite renderer, and the asset catalog. Shared model/sprite diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ShellEffects.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ShellEffects.kt new file mode 100644 index 000000000..98848705e --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/ShellEffects.kt @@ -0,0 +1,69 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.math.Vector3 +import jake2.qcommon.Defines +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.PointTEMessage +import jake2.qcommon.network.messages.server.TEMessage +import jake2.qcommon.network.messages.server.WidowBeamOutTEMessage +import kotlin.random.Random + +/** CL_Widowbeamout / CL_Nukeblast, presented as bounded expanding particle pulses through libGDX. */ +internal class ShellEffects( + private val particles: GdxParticleRuntime, + private val paletteColor: (Int) -> Color, + private val random: Random = Random.Default, + maximumSustains: Int = 32, +) { + private enum class Kind(val duration: Int, val radius: Float, val count: Int, val palette: List) { + WIDOW(2100, 45f, 128, listOf(16, 104, 168, 144)), + NUKE(1000, 200f, 256, listOf(110, 112, 114, 116)), + } + private data class Shell(val kind: Kind, val origin: Vector3f, val startedAt: Int) + private val sustains = SustainScheduler(maximumSustains) + private var nextNukeId = Short.MAX_VALUE + 1 + private val direction = Vector3() + private val position = Vector3() + val activeSustainCount: Int get() = sustains.activeCount + + fun process(message: TEMessage, nowMs: Int): Boolean { + val kind: Kind + val origin: Vector3f + val id: Int + when (message) { + is WidowBeamOutTEMessage -> { kind = Kind.WIDOW; origin = message.position; id = message.id } + is PointTEMessage -> { + if (message.style != Defines.TE_NUKEBLAST) return false + kind = Kind.NUKE; origin = message.position + // Nuke messages have no identifier. Keep independent explosions outside the wire's signed-short ID range. + id = nextNukeId + nextNukeId = if (nextNukeId == Int.MAX_VALUE) Short.MAX_VALUE + 1 else nextNukeId + 1 + } + else -> return false + } + sustains.start(id, Shell(kind, origin, nowMs), nowMs, kind.duration, intervalMillis = 50) + return true + } + + fun update(nowMs: Int) { + sustains.update(nowMs) pulse@ { shell -> + val progress = ((nowMs.toLong() - shell.startedAt).toFloat() / shell.kind.duration).coerceIn(0f, 1f) + val radius = shell.kind.radius * progress + repeat(shell.kind.count) { + direction.set(random.nextFloat() * 2f - 1f, random.nextFloat() * 2f - 1f, random.nextFloat() * 2f - 1f) + if (direction.isZero) direction.set(Vector3.Z) + position.set(shell.origin.x, shell.origin.y, shell.origin.z).mulAdd(direction.nor(), radius) + val color = shell.kind.palette[random.nextInt(shell.kind.palette.size)] + if (!particles.emit(position, Vector3.Zero, Vector3.Zero, paletteColor(color), size = .6f, + lifetimeMillis = 75, nowMs = nowMs)) return@pulse + } + } + } + + /** Stop future pulses; existing particles remain owned by the shared particle runtime. */ + fun clear() { + sustains.clear() + nextNukeId = Short.MAX_VALUE + 1 + } +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/SteamEffects.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/SteamEffects.kt new file mode 100644 index 000000000..2bbd929c4 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/SteamEffects.kt @@ -0,0 +1,79 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.math.Vector3 +import jake2.qcommon.Defines +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.PointDirectionTEMessage +import jake2.qcommon.network.messages.server.SteamTEMessage +import kotlin.math.abs +import kotlin.random.Random + +/** + * Steam bursts and their message-owned sustain state (CL_ParseSteam / CL_ParticleSteamEffect). + * libGDX owns the resulting particles; clearing this owner stops future bursts without removing particles + * which are already fading. The outer effects system disposes the shared particle runtime at teardown. + */ +internal class SteamEffects( + private val particles: GdxParticleRuntime, + private val paletteColor: (Int) -> Color, + private val random: Random = Random.Default, + maximumSustains: Int = 32, +) { + private val sustains = SustainScheduler(maximumSustains) + private val direction = Vector3() + private val right = Vector3() + private val up = Vector3() + private val position = Vector3() + private val velocity = Vector3() + private val acceleration = Vector3(0f, 0f, -40f) + val activeSustainCount: Int get() = sustains.activeCount + + fun process(message: SteamTEMessage, nowMs: Int) { + if (message.id == -1) { + emitBurst(message.position, message.direction, message.count, message.color, message.magnitude, nowMs) + } else { + sustains.start(message.id, message, nowMs, checkNotNull(message.durationMillis), intervalMillis = 100) + } + } + + /** Heatbeam impact steam and sparks use the steam recipe, and their existing impact sound is played by the caller. */ + fun processImpact(message: PointDirectionTEMessage, nowMs: Int): Boolean { + val (count, color) = when (message.style) { + Defines.TE_HEATBEAM_SPARKS -> 50 to 0x08 + Defines.TE_HEATBEAM_STEAM -> 20 to 0xE0 + else -> return false + } + emitBurst(message.position, message.direction, count, color, magnitude = 60, nowMs = nowMs) + return true + } + + fun update(nowMs: Int) { + sustains.update(nowMs) { message -> + emitBurst(message.position, message.direction, message.count, message.color, message.magnitude, nowMs) + } + } + + fun clear() = sustains.clear() + + private fun emitBurst(origin: Vector3f, normal: Vector3f, count: Int, color: Int, magnitude: Int, nowMs: Int) { + if (count == 0) return + direction.set(normal.x, normal.y, normal.z).nor() + // Build a stable transverse basis with libGDX vectors, including directions parallel to the vertical axis. + right.set(direction).crs(if (abs(direction.z) < .9f) Vector3.Z else Vector3.X).nor() + up.set(right).crs(direction).nor() + val speed = magnitude.toFloat() + val spread = abs(speed) / 3f + val jitter = abs(speed) * .1f + repeat(count) { + position.set(origin.x + signedRandom() * jitter, origin.y + signedRandom() * jitter, origin.z + signedRandom() * jitter) + velocity.set(direction).scl(speed).mulAdd(right, signedRandom() * spread).mulAdd(up, signedRandom() * spread) + val emitted = particles.emit(position, velocity, acceleration, + paletteColor((color + random.nextInt(8)) and 255), size = .4f + random.nextFloat() * .6f, + lifetimeMillis = random.nextInt(500, 801), nowMs = nowMs) + if (!emitted) return // A full/disabled particle budget drops this burst's remainder. + } + } + + private fun signedRandom(): Float = random.nextFloat() * 2f - 1f +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/SustainScheduler.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/SustainScheduler.kt new file mode 100644 index 000000000..c8cd9d068 --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/effects/SustainScheduler.kt @@ -0,0 +1,44 @@ +package org.demoth.cake.stages.ingame.effects + +/** + * Owned scheduling for repeated effect bursts (CL_ProcessSustain), independent of graphics and wall time. + * Identifiers replace existing effects. Each update emits at most once per active effect; missed ticks + * are skipped rather than replayed after a stall. The expiry boundary is exclusive. + */ +internal class SustainScheduler(private val capacity: Int = 32) { + init { require(capacity > 0) } + + private data class Entry(val value: T, val expiresAt: Long, val intervalMillis: Int, var nextAt: Long) + private val entries = LinkedHashMap>() + val activeCount: Int get() = entries.size + + /** A nonpositive duration cancels the identifier. Expired entries never consume admission capacity. */ + fun start(id: Int, value: T, nowMs: Int, durationMillis: Int, intervalMillis: Int): Boolean { + require(intervalMillis > 0) + entries.values.removeAll { nowMs.toLong() >= it.expiresAt } + if (durationMillis <= 0) { + entries.remove(id) + return true + } + if (id !in entries && entries.size >= capacity) return false + entries[id] = Entry(value, nowMs.toLong() + durationMillis, intervalMillis, nowMs.toLong()) + return true + } + + fun update(nowMs: Int, emit: (T) -> Unit) { + val now = nowMs.toLong() + val iterator = entries.values.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + if (now >= entry.expiresAt) { + iterator.remove() + } else if (now >= entry.nextAt) { + // Keep the emission cadence while bounding work to one burst after a missed interval. + entry.nextAt += ((now - entry.nextAt) / entry.intervalMillis + 1) * entry.intervalMillis + emit(entry.value) + } + } + } + + fun clear() = entries.clear() +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/hud/Hud.kt b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/hud/Hud.kt index daf452aa2..299e935c5 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/hud/Hud.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/stages/ingame/hud/Hud.kt @@ -339,6 +339,7 @@ internal class Hud( private val spriteBatch: SpriteBatch, private val style: GameUiStyle, private val dataProvider: LayoutDataProvider, + private val currentTimeMsProvider: () -> Int = { Globals.curtime }, ) : Disposable { private companion object { // Legacy notify defaults from `client/Console` (`NUM_CON_TIMES`, `con_notifytime 3`). @@ -491,7 +492,7 @@ internal class Hud( if (itemIndex != selected) { drawTextIdTech2(textX, textY, line, true, null, screenHeight) } else { - if (((Globals.curtime / 100) and 1) != 0) { + if (((currentTimeMsProvider() / 100) and 1) != 0) { drawTextIdTech2(textX - 8, textY, Char(15).toString(), false, null, screenHeight) } drawTextIdTech2(textX, textY, line, false, null, screenHeight) @@ -535,7 +536,7 @@ internal class Hud( */ fun showPrintMessage(level: Int, text: String) { if (text.isEmpty()) return - val expiresAtMs = Globals.curtime + NOTIFY_TIMEOUT_MS + val expiresAtMs = currentTimeMsProvider() + NOTIFY_TIMEOUT_MS val alt = level == PRINT_CHAT for (line in text.replace('\r', '\n').split('\n')) { if (line.isEmpty()) continue @@ -582,7 +583,7 @@ internal class Hud( } private fun drawNotifyMessages(screenHeight: Int) { - val now = Globals.curtime + val now = currentTimeMsProvider() notifyLines.removeIf { it.expiresAtMs <= now } val recent = notifyLines.takeLast(NOTIFY_MAX_LINES) var y = 0 diff --git a/cake/core/src/main/kotlin/org/demoth/cake/ui/ContentBrowser.kt b/cake/core/src/main/kotlin/org/demoth/cake/ui/ContentBrowser.kt index 610cb7449..b667dc3be 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/ui/ContentBrowser.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/ui/ContentBrowser.kt @@ -53,6 +53,7 @@ internal class ContentDirectory(val path: String, val name: String) { * [loadThumbnail] borrows a texture from the host for this folder's lifetime. * [clearThumbnails] releases the folder's textures when navigating to another folder. * [onSelect] may supply a borrowed inline preview for the selected tile. + * [selectionDetails] supplies optional metadata in the footer; reserve its height with [selectionDetailLines]. */ class ContentBrowser( entries: List, rootName: String, private val styles: ContentBrowserStyles, @@ -61,6 +62,10 @@ class ContentBrowser( private val clearThumbnails: () -> Unit = {}, private val onSelect: (VfsEntry?) -> Actor? = { null }, private val showSource: Boolean = false, + private val selectionDetails: (VfsEntry) -> String? = { null }, + private val selectionDetailLines: Int = 0, + private val openEntry: ((VfsEntry) -> Boolean)? = null, + initialLocation: ContentBrowserLocation? = null, ) : Table() { /** Preserve the standalone viewer's native engine styling and callback contract. */ constructor( @@ -118,7 +123,8 @@ class ContentBrowser( private val gridScroll = scrollPane(grid).apply { setScrollingDisabled(true, false) } private val content = Container(gridScroll).apply { fill() } private val backButton = TextButton("Back to folder", styles.button).apply { isVisible = false } - private var thumbnailWidth = 128f + private var thumbnailWidth = initialLocation?.thumbnailWidth?.coerceIn(64f, 256f) ?: 128f + private var pendingScrollY: Float? = null private val smallerButton = TextButton("-", styles.button) private val largerButton = TextButton("+", styles.button) @@ -138,7 +144,7 @@ class ContentBrowser( add(content).grow().row() // Reserve metadata lines before selection, keeping tiles under the pointer. add(selectionInfo).minWidth(0f).height(selectionInfo.style.font.data.lineHeight * - if (showSource) 3f else 2f).pad(8f).row() + ((if (showSource) 3f else 2f) + selectionDetailLines)).pad(8f).row() } add(SplitPane(scrollPane(tree), listing, false, styles.splitPane).apply { splitAmount = 0.3f }).grow() @@ -162,8 +168,23 @@ class ContentBrowser( override fun changed(event: ChangeEvent, actor: Actor) = resizeThumbnails(32f) }) tree.selection.set(root) + initialLocation?.let { location -> + var path = location.directory + while (path !in nodes && path.isNotEmpty()) path = path.substringBeforeLast('/', "") + val node = nodes.getValue(path) + node.expandTo() + node.isExpanded = true + tree.selection.set(node) + tiles.firstOrNull { it.entry?.normalizedPath == location.selectedPath }?.let(::selectTile) + pendingScrollY = location.scrollY.coerceAtLeast(0f) + } } + fun location(): ContentBrowserLocation = ContentBrowserLocation( + tree.selectedValue?.path.orEmpty(), tiles.firstOrNull { it.isChecked }?.entry?.normalizedPath, + pendingScrollY ?: gridScroll.scrollY, thumbnailWidth, + ) + private fun resizeThumbnails(delta: Float) { thumbnailWidth = (thumbnailWidth + delta).coerceIn(64f, 256f) tiles.forEach { it.resizePreview(thumbnailWidth) } @@ -173,6 +194,10 @@ class ContentBrowser( /** Hosts forward keys here so shortcuts work even without keyboard focus on a tile. */ fun keyDown(keycode: Int): Boolean { if (content.actor !== gridScroll) return false + if (keycode == Input.Keys.ENTER && openEntry != null) { + tiles.firstOrNull { it.isChecked }?.let(::activateTile) + return true + } val delta = when (keycode) { Input.Keys.MINUS -> -32f Input.Keys.EQUALS -> 32f @@ -213,6 +238,13 @@ class ContentBrowser( override fun act(delta: Float) { super.act(delta) + // Scroll bounds are only known after the restored folder has received its first layout. + pendingScrollY?.takeIf { gridScroll.width > 0f && gridScroll.height > 0f }?.let { + gridScroll.validate() + gridScroll.scrollY = it + gridScroll.updateVisualScroll() + pendingScrollY = null + } val loader = loadThumbnail ?: return if (content.actor !== gridScroll || stage == null) return validate() @@ -243,6 +275,7 @@ class ContentBrowser( private fun showDirectory(directory: ContentDirectory) { clearAudition() previewSelection(null) + pendingScrollY = null grid.clearChildren() tiles.clear() clearThumbnails() @@ -264,11 +297,7 @@ class ContentBrowser( grid.addActor(tile) tile.addListener(object : ClickListener() { override fun clicked(event: InputEvent, x: Float, y: Float) { - tiles.forEach { it.isChecked = it === tile } - selectionInfo.setText(entry?.let { - "${it.displayPath}\n${it.size} bytes" + if (showSource) - "\nSource: ${it.layer} / ${it.source.containerPath.fileName}" else "" - } ?: "${directory!!.path}/") + selectTile(tile) if (tapCount == 1) { clearAudition() onSelect(entry)?.let { preview -> @@ -276,22 +305,37 @@ class ContentBrowser( auditionTile = tile } } - if (tapCount != 2) return - if (directory != null) { - val node = nodes.getValue(directory.path) - node.expandTo() - node.isExpanded = true - tree.selection.set(node) - } else { - val preview = previewSelection(entry) ?: return - content.actor = scrollPane(preview) - backButton.isVisible = true - updateSizeButtons() - } + if (tapCount == 2) activateTile(tile) } }) } + private fun selectTile(tile: Tile) { + tiles.forEach { it.isChecked = it === tile } + selectionInfo.setText(tile.entry?.let { + val basic = "${it.displayPath}\n${it.size} bytes" + if (showSource) + "\nSource: ${it.layer} / ${it.source.containerPath.fileName}" else "" + selectionDetails(it)?.let { details -> "$basic\n$details" } ?: basic + } ?: "${tile.directory!!.path}/") + } + + private fun activateTile(tile: Tile) { + val directory = tile.directory + if (directory != null) { + val node = nodes.getValue(directory.path) + node.expandTo() + node.isExpanded = true + tree.selection.set(node) + } else { + val entry = tile.entry ?: return + if (openEntry?.invoke(entry) == true) return + val preview = previewSelection(entry) ?: return + content.actor = scrollPane(preview) + backButton.isVisible = true + updateSizeButtons() + } + } + private fun clearAudition() { auditionTile?.let { it.picture.actor = it.placeholder } auditionTile = null diff --git a/cake/core/src/main/kotlin/org/demoth/cake/ui/ContentBrowserNavigation.kt b/cake/core/src/main/kotlin/org/demoth/cake/ui/ContentBrowserNavigation.kt new file mode 100644 index 000000000..41c08c9ec --- /dev/null +++ b/cake/core/src/main/kotlin/org/demoth/cake/ui/ContentBrowserNavigation.kt @@ -0,0 +1,27 @@ +package org.demoth.cake.ui + +/** Value state survives preview cleanup and menu/style rebuilds without retaining actors or resources. */ +data class ContentBrowserLocation( + val directory: String = "", + val selectedPath: String? = null, + val scrollY: Float = 0f, + val thumbnailWidth: Float = 128f, +) + +class ContentBrowserNavigation { + private var profile: String? = null + private var location: ContentBrowserLocation? = null + + fun forProfile(profileId: String): ContentBrowserLocation? { + if (profile != profileId) { + profile = profileId + location = null + } + return location + } + + fun remember(profileId: String, value: ContentBrowserLocation) { + profile = profileId + location = value + } +} diff --git a/cake/core/src/main/kotlin/org/demoth/cake/ui/GameUiStyle.kt b/cake/core/src/main/kotlin/org/demoth/cake/ui/GameUiStyle.kt index 99e7220bd..344948b90 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/ui/GameUiStyle.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/ui/GameUiStyle.kt @@ -227,8 +227,11 @@ internal fun createMenuWidgetStyles( val contentScrollPaneStyle = ScrollPane.ScrollPaneStyle(baseScrollPaneStyle).also { style -> if (concharsTexture != null) { style.background = containerPanel + style.hScroll = null + style.hScrollKnob = separator style.vScroll = null style.vScrollKnob = separatorVertical + style.corner = null } } val contentListStyle = Scene2dList.ListStyle(baseListStyle).also { style -> diff --git a/cake/core/src/main/kotlin/org/demoth/cake/ui/README.md b/cake/core/src/main/kotlin/org/demoth/cake/ui/README.md index 0edc93e04..97aab6679 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/ui/README.md +++ b/cake/core/src/main/kotlin/org/demoth/cake/ui/README.md @@ -43,6 +43,7 @@ artwork and atlas lines; tree slots match the 8-unit game theme. | Component | Responsibility | |---|---| | `ContentBrowserStage` / `ContentBrowser` | Client layout; shared VFS browsing and back navigation. Hosts supply the root action. | +| `DemoFiles` / `DemoMetadata` | Merge profile recordings with installed demo entries and read bounded startup metadata on selection, without extracting packages or loading game assets. | | `ContentBrowserStyles` | Resolved menu styles; standalone Skin adapter preserves viewer sizing. | | `FileResourcePreview` / `CinematicPreview` | Shared read-only text and CIN widgets; owns the palette reference, cinematic texture/pixmap and audio device. Closing detaches the widget and releases resources. | | `ImageThumbnails` | Progressive visible PCX/WAL/TGA and first-frame CIN loading; owns folder-scoped textures, released on folder change, exit, or profile change. | @@ -56,6 +57,7 @@ artwork and atlas lines; tree slots match the 8-unit game theme. - Preview content resolves within the profile; engine palette/shaders remain available. - Selection, navigation, profile changes, and failures clear preview resources without unloading shared game textures. - Back/Escape restores folder scroll, then ascends folders; only root exits the browser. +- Demo double-click/Enter requests playback through the menu bus. Value-only, profile-scoped bookmarks retain folder/selection/scroll/thumbnail size across playback cleanup and style rebuilds; returning playback errors appear above the listing. | Media | Interaction | |---|---| diff --git a/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuController.kt b/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuController.kt index a7dca6d26..411ff1923 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuController.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuController.kt @@ -223,6 +223,12 @@ class MenuController( refreshState(activeScreen = MenuScreen.CONTENT_BROWSER, formOverride = state.profileEditor.form) } + is MenuIntent.PlayDemoRequested -> { + if (state.activeScreen == MenuScreen.CONTENT_BROWSER && intent.profileId == backend.activeProfileId() && + intent.entry.normalizedPath.endsWith(".dm2", ignoreCase = true)) + bus.postSignal(MenuSignal.PlayDemo(intent.profileId, intent.entry)) + } + is MenuIntent.OpenOptions -> { refreshState( activeScreen = MenuScreen.OPTIONS, diff --git a/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuIntent.kt b/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuIntent.kt index acbbf04ba..bef020bde 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuIntent.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuIntent.kt @@ -1,6 +1,7 @@ package org.demoth.cake.ui.menu import java.nio.file.Path +import jake2.qcommon.vfs.VfsEntry sealed interface MenuIntent { data class OpenUiSample(val game: Boolean? = null) : MenuIntent @@ -29,6 +30,7 @@ sealed interface MenuIntent { data object OpenPlayerSetup : MenuIntent data object OpenOptions : MenuIntent data object OpenContentBrowser : MenuIntent + data class PlayDemoRequested(val profileId: String, val entry: VfsEntry) : MenuIntent data class OpenOptionsSection(val prefix: String) : MenuIntent data object DisconnectRequested : MenuIntent data class SelectProfile(val profileId: String) : MenuIntent diff --git a/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuSignal.kt b/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuSignal.kt index b1d801129..35273cde1 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuSignal.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/ui/menu/MenuSignal.kt @@ -1,6 +1,7 @@ package org.demoth.cake.ui.menu import java.nio.file.Path +import jake2.qcommon.vfs.VfsEntry enum class MenuUiSoundEffect { ENTER_SUBMENU, @@ -11,6 +12,7 @@ enum class MenuUiSoundEffect { sealed interface MenuSignal { data object ShowMenu : MenuSignal data object CloseMenu : MenuSignal + data class PlayDemo(val profileId: String, val entry: VfsEntry) : MenuSignal data class OpenUiSample(val game: Boolean? = null) : MenuSignal data object CloseUiSample : MenuSignal data class CaptureUiSample(val modes: List, val directory: Path) : MenuSignal diff --git a/cake/core/src/test/kotlin/org/demoth/cake/CakeCommandOrderTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/CakeCommandOrderTest.kt index d89c46933..6e1a64671 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/CakeCommandOrderTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/CakeCommandOrderTest.kt @@ -9,7 +9,7 @@ class CakeCommandOrderTest { @Test fun renderExecutesCommandBufferBeforeSendingUpdates() { - val source = readCakeSource() + val source = readCakeSource().substringAfter("override fun render()") val readPacketsIdx = source.indexOf("CL_ReadPackets()") val executeBufferIdx = source.indexOf("Cbuf.Execute()") diff --git a/cake/core/src/test/kotlin/org/demoth/cake/ServerStuffTextPolicyTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/ServerStuffTextPolicyTest.kt index 232c5ba39..fd3e8753e 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/ServerStuffTextPolicyTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/ServerStuffTextPolicyTest.kt @@ -5,6 +5,14 @@ import org.junit.jupiter.api.Test class ServerStuffTextPolicyTest { + @Test + fun argumentlessPrecacheIsOnlyAllowedForDemoPlayback() { + assertEquals(listOf("precache"), filterServerStuffText("precache").rejectedCommands) + val demo = filterServerStuffText("precache; exec bad.cfg; precache nope", demoPlayback = true) + assertEquals(listOf("precache"), demo.acceptedCommands) + assertEquals(listOf("exec bad.cfg", "precache nope"), demo.rejectedCommands) + } + @Test fun acceptsKnownConnectionFlowCommands() { val result = filterServerStuffText("changing\nreconnect\ncmd configstrings 3 0\nprecache 3\nspectator 1") diff --git a/cake/core/src/test/kotlin/org/demoth/cake/assets/BeamRendererTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/assets/BeamRendererTest.kt new file mode 100644 index 000000000..178081dbf --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/assets/BeamRendererTest.kt @@ -0,0 +1,57 @@ +package org.demoth.cake.assets + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.graphics.g3d.Material +import com.badlogic.gdx.graphics.g3d.Model +import com.badlogic.gdx.graphics.g3d.attributes.BlendingAttribute +import com.badlogic.gdx.graphics.g3d.attributes.ColorAttribute +import com.badlogic.gdx.math.Vector3 +import com.badlogic.gdx.utils.GdxNativesLoader +import jake2.qcommon.math.Vector3f +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test + +/** Empty libGDX models exercise transforms/material requests/cache ownership without GL or game assets. */ +class BeamRendererTest { + @Test fun `repeated beams share geometry while preserving palette opacity and diameter`() { + val materials = mutableListOf() + var disposed = 0 + val renderer = BeamRenderer({ index -> Color(index / 255f, .5f, .25f, 1f) }, { material -> + materials += material + object : Model() { override fun dispose() { disposed++; super.dispose() } } + }) + try { + val first = renderer.prepare(Vector3f.zero, Vector3f(0f, 10f, 0f), 0xD0, 4f, .3f)!! + assertEquals(Vector3(0f, 5f, 0f), first.transform.getTranslation(Vector3())) + assertEquals(Vector3(2f, 10f, 2f), first.transform.getScale(Vector3())) + repeat(100) { assertSame(first, renderer.prepare(Vector3f.zero, Vector3f(20f, 0f, 0f), 0xD0, 4f, .3f)) } + assertEquals(1, materials.size) + assertEquals(.3f, (materials.single().get(BlendingAttribute.Type) as BlendingAttribute).opacity) + assertEquals(0xD0 / 255f, (materials.single().get(ColorAttribute.Diffuse) as ColorAttribute).color.r) + assertNotSame(first, renderer.prepare(Vector3f.zero, Vector3f.unitX, 0xD1, 4f, .3f)) + renderer.prepare(Vector3f.zero, Vector3f.unitX, 0xD0, 4f, 1f) + assertEquals(3, materials.size) + assertFalse(materials.last().has(BlendingAttribute.Type)) + } finally { renderer.dispose() } + assertEquals(3, disposed) + } + + @Test fun `vertical and short beams produce finite poses and empty or invisible beams allocate no models`() { + var created = 0 + val renderer = BeamRenderer({ Color.WHITE }, { created++; Model() }) + try { + assertNull(renderer.prepare(Vector3f.zero, Vector3f.zero, 1, 4f, .3f)) + assertNull(renderer.prepare(Vector3f.zero, Vector3f.unitX, 1, 0f, .3f)) + assertNull(renderer.prepare(Vector3f.zero, Vector3f.unitX, 1, 4f, 0f)) + assertEquals(0, created) + for (end in listOf(Vector3f(0f, 0f, 10f), Vector3f(0f, -10f, 0f), Vector3f(.01f, 0f, 0f))) { + val instance = renderer.prepare(Vector3f.zero, end, 1, 4f, .3f)!! + assertTrue(instance.transform.`val`.all { it.isFinite() }) + assertTrue(instance.transform.getTranslation(Vector3()).epsilonEquals(Vector3(end.x, end.y, end.z).scl(.5f), .001f)) + } + } finally { renderer.dispose() } + } + + companion object { @BeforeAll @JvmStatic fun loadMathNatives() { GdxNativesLoader.load() } } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/assets/CakeFileResolverTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/assets/CakeFileResolverTest.kt index 275c2a008..1dc69fbaf 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/assets/CakeFileResolverTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/assets/CakeFileResolverTest.kt @@ -2,6 +2,12 @@ package org.demoth.cake.assets import com.badlogic.gdx.ApplicationListener import com.badlogic.gdx.Gdx +import com.badlogic.gdx.assets.AssetManager +import com.badlogic.gdx.assets.AssetDescriptor +import com.badlogic.gdx.assets.AssetLoaderParameters +import com.badlogic.gdx.assets.loaders.SynchronousAssetLoader +import com.badlogic.gdx.files.FileHandle +import com.badlogic.gdx.utils.GdxRuntimeException import com.badlogic.gdx.backends.headless.HeadlessApplication import com.badlogic.gdx.backends.headless.HeadlessApplicationConfiguration import org.junit.jupiter.api.AfterAll @@ -11,6 +17,10 @@ import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertThrows import org.demoth.cake.CakeCvars +import org.demoth.cake.config.GameConfiguration +import org.demoth.cake.assets.md2.Md2Asset +import org.demoth.cake.assets.bsp.BspMapAsset +import jake2.qcommon.Defines import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir @@ -31,6 +41,59 @@ class CakeFileResolverTest { @TempDir lateinit var temp: Path + @Test + fun oldDemoModelPolicyKeepsMissingModelIndicesButDoesNotRelaxMapOrResolverRequirements() { + setFsDebugLoaders("0") + val resolver = CakeFileResolver(temp.toString()) + val assets = AssetManager(resolver, false) + assets.setLoader(BspMapAsset::class.java, object : SynchronousAssetLoader>(resolver) { + override fun load(manager: AssetManager, fileName: String, file: FileHandle, parameter: AssetLoaderParameters?): BspMapAsset = + error("A missing map must fail during resolution") + override fun getDependencies(fileName: String, file: FileHandle, parameter: AssetLoaderParameters?): com.badlogic.gdx.utils.Array>? = null + }) + try { + val config = GameConfiguration(assets).apply { allowMissingModelConfigs = true } + for ((index, path) in listOf("models/removed.md2", "sprites/removed.sp2").withIndex()) { + val slot = Defines.CS_MODELS + index + 2 + config.applyConfigString(slot, path, loadResource = true) + assertEquals(path, config.getConfigValue(slot)) + assertNull(config[slot]?.resource) + } + assertEquals(0, assets.loadedAssets) + assertThrows(MissingResourceException::class.java) { resolver.resolve("models/removed.md2") } + config.applyConfigString(Defines.CS_MODELS + 1, "maps/required.bsp") + val missingMap = assertThrows(RuntimeException::class.java) { config.loadMapAsset() } + assertTrue(generateSequence(missingMap) { it.cause }.any { + it is MissingResourceException && it.path == "maps/required.bsp" + }) + } finally { assets.dispose() } + } + + @Test + fun missingModelToleranceIsOptInAndPresentButMalformedModelsStillFail() { + setFsDebugLoaders("0") + createFile("baseq2/models/malformed.md2", byteArrayOf(1)) + val resolver = CakeFileResolver(temp.toString()) + val assets = AssetManager(resolver, false) + assets.setLoader(Md2Asset::class.java, object : SynchronousAssetLoader>(resolver) { + override fun load(manager: AssetManager, fileName: String, file: FileHandle, parameter: AssetLoaderParameters?): Md2Asset = + throw GdxRuntimeException("Synthetic malformed model") + override fun getDependencies(fileName: String, file: FileHandle, parameter: AssetLoaderParameters?): com.badlogic.gdx.utils.Array>? = null + }) + try { + val config = GameConfiguration(assets) + val missing = assertThrows(RuntimeException::class.java) { + config.applyConfigString(Defines.CS_MODELS + 2, "models/removed.md2", loadResource = true) + } + assertTrue(generateSequence(missing) { it.cause }.any { it is MissingResourceException }) + config.allowMissingModelConfigs = true + val malformed = assertThrows(RuntimeException::class.java) { + config.applyConfigString(Defines.CS_MODELS + 3, "models/malformed.md2", loadResource = true) + } + assertTrue(generateSequence(malformed) { it.cause }.any { it.message == "Synthetic malformed model" }) + } finally { assets.dispose() } + } + @Test fun contentSnapshotListsWinnersAndChangesWithProfileWithoutBundledFallbacks() { createFile("baseq2/shared.cfg", "base".toByteArray()) @@ -53,6 +116,19 @@ class CakeFileResolverTest { assertTrue(resolver.listContentEntries().isEmpty()) } + @Test + fun streamsDemoContentFromLooseFilesAndArchives() { + val bytes = byteArrayOf(0, 0, 0, 0, -1, -1, -1, -1) + createFile("baseq2/demos/loose.dm2", bytes) + writePak(temp.resolve("baseq2/pak0.pak"), linkedMapOf("demos/packed.dm2" to bytes)) + val resolver = CakeFileResolver(temp.toString()) + for (path in listOf("demos/loose.dm2", "demos/packed.dm2")) { + resolver.openContent(path)!!.use { assertTrue(bytes.contentEquals(it.readBytes())) } + } + assertNull(resolver.openContent("demos/missing.dm2")) + assertNull(resolver.openContent("../outside.dm2")) + } + @Test fun resolvesExactCaseInGamemod() { setFsDebugLoaders("0") diff --git a/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoFilesTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoFilesTest.kt new file mode 100644 index 000000000..d17cf57a7 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoFilesTest.kt @@ -0,0 +1,79 @@ +package org.demoth.cake.demo + +import jake2.qcommon.vfs.DefaultWritableFileSystem +import jake2.qcommon.vfs.VfsEntry +import jake2.qcommon.vfs.VfsLayer +import jake2.qcommon.vfs.VfsSource +import jake2.qcommon.vfs.VfsSourceType +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.ByteArrayInputStream +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path + +class DemoFilesTest { + @TempDir lateinit var root: Path + + @Test fun `recordings are profile local replayable by name and never overwrite earlier takes`() { + var profile = "first" + val files = DemoFiles({ DefaultWritableFileSystem(root.resolve(profile)) }, { null }) + files.create("series/take").let { output -> + assertEquals(root.resolve("first/demos/series/take.dm2").toString(), output.path) + output.stream.use { it.write(byteArrayOf(1, 2, 3)) } + } + files.open("series/take").use { assertArrayEquals(byteArrayOf(1, 2, 3), it.readBytes()) } + assertThrows(IOException::class.java) { files.create("series/take") } + files.open("series/take").use { assertArrayEquals(byteArrayOf(1, 2, 3), it.readBytes()) } + profile = "second" + assertThrows(IOException::class.java) { files.open("series/take") } + files.create("series/take").stream.use { it.write(4) } + files.open("series/take").use { assertArrayEquals(byteArrayOf(4), it.readBytes()) } + } + + @Test fun `local demo wins over installed content and missing local demo falls back to content`() { + val fallbackPaths = mutableListOf() + val files = DemoFiles({ DefaultWritableFileSystem(root) }, { path -> + fallbackPaths += path + if (path == "demos/installed.dm2") ByteArrayInputStream(byteArrayOf(7)) else null + }) + files.open("installed").use { assertEquals(7, it.read()) } + assertEquals(listOf("demos/installed.dm2"), fallbackPaths) + files.create("installed").stream.use { it.write(8) } + files.open("installed").use { assertEquals(8, it.read()) } + assertEquals(1, fallbackPaths.size) + assertThrows(IOException::class.java) { files.open("missing") } + } + + @Test fun `invalid paths do not reach the filesystem or content lookup`() { + val files = DemoFiles({ error("Unexpected write filesystem access") }, { error("Unexpected content lookup") }) + for (path in listOf("", "../escape", "/absolute", "demos/../../escape", "C:\\demo")) { + assertThrows(IOException::class.java) { files.create(path) } + assertThrows(IOException::class.java) { files.open(path) } + } + assertEquals(0L, Files.list(root).use { it.count() }) + } + + @Test fun `browser snapshots overlay installed demos refresh recordings and open the exact selected source`() { + var profile = "first" + val files = DemoFiles({ DefaultWritableFileSystem(root.resolve(profile)) }, { ByteArrayInputStream(byteArrayOf(7)) }) + val installed = VfsEntry("demos/take.dm2", "demos/take.dm2", VfsLayer.BASE_PACK, 0, + VfsSource(VfsSourceType.PACKAGE_ENTRY, root.resolve("pak0.pak"), "demos/take.dm2", "pak", true, false), 12, 0) + assertEquals(listOf(installed), files.entries(listOf(installed))) + files.create("Take").stream.use { it.write(8) } + files.create("series/new").stream.use { it.write(9) } + val entries = files.entries(listOf(installed)) + assertEquals(listOf("demos/series/new.dm2", "demos/take.dm2"), entries.map { it.normalizedPath }) + val selected = entries.last() + assertEquals(VfsLayer.PROFILE_LOOSE, selected.layer) + assertEquals("demos/Take.dm2", selected.displayPath) + assertEquals(1L, selected.size) + files.open(selected).use { assertEquals(8, it.read()) } + files.open(installed).use { assertEquals(7, it.read()) } + assertEquals(3, files.entries(listOf(installed), caseSensitive = true).size) + profile = "second" + assertEquals(listOf(installed), files.entries(listOf(installed))) + assertThrows(IOException::class.java) { files.open(selected) } + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoMetadataTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoMetadataTest.kt new file mode 100644 index 000000000..14672ea1d --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoMetadataTest.kt @@ -0,0 +1,56 @@ +package org.demoth.cake.demo + +import jake2.qcommon.Defines +import jake2.qcommon.network.messages.server.* +import jake2.qcommon.sizebuf_t +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.io.ByteArrayInputStream +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class DemoMetadataTest { + private fun int(value: Int) = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(value).array() + private fun record(vararg messages: ServerMessage): ByteArray { + val buffer = sizebuf_t(Defines.MAX_MSGLEN) + messages.forEach { it.writeTo(buffer) } + return int(buffer.cursize) + buffer.data.copyOf(buffer.cursize) + } + private fun server(protocol: Int) = ServerDataMessage(protocol, 1, true, "", 0, "Installation") + + @Test fun `metadata reads classic startup versions and map across records then leaves gameplay unread`() { + for (protocol in listOf(26, 32, 34)) { + val startup = record(server(protocol)) + record(ConfigStringMessage(Defines.CS_MODELS + 1, "maps/base1.bsp")) + var closes = 0 + val input = object : ByteArrayInputStream(startup + byteArrayOf(1, 2, 3)) { + override fun close() { closes++ } + } + assertEquals(DemoMetadata(protocol, "baseq2", "Installation", "maps/base1.bsp"), DemoMetadata.read(input)) + assertEquals(3, input.available()) + assertEquals(1, closes) + } + } + + @Test fun `header without map can still describe a demo when precache or terminator is reached`() { + for (ending in listOf(record(StuffTextMessage("precache\n")), int(-1))) { + assertEquals(DemoMetadata(34, "baseq2", "Installation", null), + DemoMetadata.read(ByteArrayInputStream(record(server(34)) + ending))) + } + } + + @Test fun `empty records cannot cause an unbounded metadata scan`() { + val input = ByteArrayInputStream(ByteArray(4 * (DemoMetadata.MAX_STARTUP_RECORDS + 10))) + assertThrows(IOException::class.java) { DemoMetadata.read(input) } + assertEquals(40, input.available()) + } + + @Test fun `malformed or unsupported startup fails and closes the stream`() { + for (bytes in listOf(byteArrayOf(1, 2), int(-1), record(server(35)), record(server(34)) + int(-2))) { + var closed = false + val input = object : ByteArrayInputStream(bytes) { override fun close() { closed = true } } + assertThrows(IOException::class.java) { DemoMetadata.read(input) } + assertTrue(closed) + } + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoPathTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoPathTest.kt new file mode 100644 index 000000000..cd84b805b --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoPathTest.kt @@ -0,0 +1,15 @@ +package org.demoth.cake.demo + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class DemoPathTest { + @Test fun `console demo names resolve inside profile demos directory`() { + assertEquals("demos/test.dm2", demoLogicalPath("test")) + assertEquals("demos/test.dm2", demoLogicalPath("demos/test.dm2")) + assertEquals("demos/series/test.dm2", demoLogicalPath("series\\test")) + for (path in listOf("", "/tmp/test", "../test", "demos/../../test", "C:\\demo", "demos//test")) { + assertNull(demoLogicalPath(path), path) + } + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoPlaybackTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoPlaybackTest.kt new file mode 100644 index 000000000..066598f6b --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoPlaybackTest.kt @@ -0,0 +1,179 @@ +package org.demoth.cake.demo + +import jake2.qcommon.network.demo.DemoReader +import jake2.qcommon.network.messages.server.* +import jake2.qcommon.sizebuf_t +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.io.ByteArrayInputStream +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class DemoPlaybackTest { + private fun int(value: Int) = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(value).array() + private fun record(vararg messages: ServerMessage): ByteArray { + val buffer = sizebuf_t(1400) + messages.forEach { it.writeTo(buffer) } + return int(buffer.cursize) + buffer.data.copyOf(buffer.cursize) + } + private fun bootstrap(protocol: Int = 34) = record(ServerDataMessage(protocol, 1, true, "baseq2", 0, "test"), StuffTextMessage("precache\n")) + private fun frame(number: Int) = record(FrameHeaderMessage(number, -1, 0, 0, byteArrayOf()), PlayerInfoMessage(fov = 90), PacketEntitiesMessage(emptyList())) + private fun playback(bytes: ByteArray) = DemoPlayback(DemoReader(ByteArrayInputStream(bytes))) + + @Test fun `bootstrap is immediate then frames follow recorded time and last frame remains visible`() { + playback(bootstrap() + frame(100) + frame(101) + frame(104) + int(-1)).use { demo -> + val frames = mutableListOf() + val dispatch: (List) -> Boolean = { messages -> + frames += messages.filterIsInstance().map { it.frameNumber }; true + } + assertTrue(demo.update(0f, dispatch)) + assertEquals(listOf(100), frames) + assertEquals(9900, demo.presentationTimeMillis) + assertTrue(demo.update(.05f, dispatch)) + assertEquals(9950, demo.presentationTimeMillis) + assertTrue(demo.update(.05f, dispatch)) + assertEquals(listOf(100, 101), frames) + assertEquals(10000, demo.presentationTimeMillis) + assertTrue(demo.update(.3f, dispatch)) + assertEquals(listOf(100, 101, 104), frames) + assertFalse(demo.update(.1f, dispatch)) + } + } + + @Test fun `empty records cannot monopolize a client update`() { + playback(ByteArray(4 * 1000) + bootstrap() + frame(1) + int(-1)).use { demo -> + var dispatched = 0 + assertTrue(demo.update(0f) { dispatched++; true }) + assertEquals(DemoPlayback.MAX_RECORDS_PER_UPDATE, dispatched) + } + } + + @ParameterizedTest + @ValueSource(ints = [31, 32]) + fun `classic startup and frame layouts retain suppression visibility and timing`(protocol: Int) { + // Hand-authored classic layouts from Yamagi CL_ParseServerData/CL_ParseFrame and + // Q2PRO CL_ParseFrame: only protocol 26 omits suppressCount; 31 and 32 follow the 34 layout. + val startup = byteArrayOf(12) + int(protocol) + int(1) + byteArrayOf(1) + + "xatrix\u0000".toByteArray(Charsets.ISO_8859_1) + byteArrayOf(0, 0) + + "legacy32\u0000".toByteArray(Charsets.ISO_8859_1) + fun snapshot(number: Int, last: Int): ByteArray = byteArrayOf(20) + int(number) + int(last) + + byteArrayOf(7, 1, 3, // suppressCount, areaBits length, areaBits + 17, 0, 0, 0, 0, 0, 0, // playerinfo: no changed fields or stats + 18, 0, 0) // packetentities terminator + fun framed(bytes: ByteArray) = int(bytes.size) + bytes + // Version and first snapshot intentionally share a record. + playback(framed(startup + snapshot(100, -1)) + framed(snapshot(101, 100)) + int(-1)).use { demo -> + val messages = mutableListOf() + val dispatch: (List) -> Boolean = { messages += it; true } + assertTrue(demo.update(0f, dispatch)) + val data = messages.filterIsInstance().single() + assertEquals(protocol, data.protocol) + assertEquals("xatrix", data.gameName) + assertEquals("legacy32", data.levelString) + assertEquals(9900, demo.presentationTimeMillis) + repeat(3) { assertTrue(demo.update(0f, dispatch)) } + assertEquals(1, messages.filterIsInstance().size) + assertTrue(demo.update(.1f, dispatch)) + val frames = messages.filterIsInstance() + assertEquals(listOf(100, 101), frames.map { it.frameNumber }) + assertEquals(listOf(-1, 100), frames.map { it.lastFrame }) + frames.forEach { + assertEquals(7, it.suppressCount) + assertArrayEquals(byteArrayOf(3), it.areaBits.toByteArray()) + } + assertFalse(demo.update(.1f, dispatch)) + } + } + + @Test fun `protocol 26 frames omit suppression while playback preserves delta timing and map version changes`() { + fun oldFrame(number: Int, last: Int): ByteArray { + // svc_frame: two ints followed directly by areaBits; no suppressCount in protocol 26. + val bytes = byteArrayOf(20) + int(number) + int(last) + byteArrayOf(1, 3, + 17, 0, 0, 0, 0, 0, 0, 18, 0, 0) + return int(bytes.size) + bytes + } + playback(bootstrap(26) + oldFrame(100, -1) + oldFrame(101, 100) + + bootstrap(32) + frame(5) + bootstrap(26) + oldFrame(1, 0) + int(-1)).use { demo -> + val messages = mutableListOf() + val dispatch: (List) -> Boolean = { messages += it; true } + assertTrue(demo.update(0f, dispatch)) + assertEquals(9900, demo.presentationTimeMillis) + repeat(3) { assertTrue(demo.update(0f, dispatch)) } + assertEquals(1, messages.filterIsInstance().size) + assertTrue(demo.update(.1f, dispatch)) + assertTrue(demo.update(.1f, dispatch)) + assertTrue(demo.update(.1f, dispatch)) + assertFalse(demo.update(.1f, dispatch)) + assertEquals(listOf(26, 32, 26), messages.filterIsInstance().map { it.protocol }) + val frames = messages.filterIsInstance() + assertEquals(listOf(100, 101, 5, 1), frames.map { it.frameNumber }) + assertEquals(listOf(-1, 100, -1, 0), frames.map { it.lastFrame }) + for (frame in frames.filter { it.frameNumber != 5 }) { + assertEquals(0, frame.suppressCount) + assertArrayEquals(byteArrayOf(3), frame.areaBits.toByteArray()) + } + } + } + + @Test fun `map changes can switch between supported classic demo versions`() { + playback(bootstrap(32) + frame(1000) + bootstrap(34) + frame(5) + int(-1)).use { demo -> + val protocols = mutableListOf() + val frames = mutableListOf() + val dispatch: (List) -> Boolean = { messages -> + protocols += messages.filterIsInstance().map { it.protocol } + frames += messages.filterIsInstance().map { it.frameNumber } + true + } + assertTrue(demo.update(0f, dispatch)) + assertTrue(demo.update(.1f, dispatch)) + assertFalse(demo.update(.1f, dispatch)) + assertEquals(listOf(32, 34), protocols) + assertEquals(listOf(1000, 5), frames) + } + } + + @Test fun `unsupported protocol and malformed body dispatch no prefix`() { + for (bytes in listOf(bootstrap(25), bootstrap(33), bootstrap(35), + record(ServerDataMessage(34, 1, true, "baseq2", 0, "test")) + int(2) + byteArrayOf(6, 7))) { + playback(bytes).use { demo -> + assertThrows(IOException::class.java) { demo.update(0f) { true } } + } + } + } + + @Test fun `map bootstrap rebases frame numbers without a new connection`() { + playback(bootstrap() + frame(1000) + bootstrap() + frame(5) + frame(6) + int(-1)).use { demo -> + val frames = mutableListOf() + val dispatch: (List) -> Boolean = { frames += it.filterIsInstance().map { f -> f.frameNumber }; true } + demo.update(0f, dispatch) + assertEquals(listOf(1000), frames) + demo.update(.1f, dispatch) + assertEquals(listOf(1000, 5), frames) + demo.update(.1f, dispatch) + assertEquals(listOf(1000, 5, 6), frames) + } + } + + @Test fun `incomplete snapshots invalid player slots and initial delta frames reject`() { + val header = FrameHeaderMessage(10, -1, 0, 0, byteArrayOf()) + val cases = listOf( + bootstrap() + record(header), + bootstrap() + record(PlayerInfoMessage(), PacketEntitiesMessage(emptyList())), + bootstrap() + record(header.copy(lastFrame = 9), PlayerInfoMessage(), PacketEntitiesMessage(emptyList())), + record(ServerDataMessage(34, 1, true, "baseq2", 999, "test")), + ) + for (bytes in cases) playback(bytes + int(-1)).use { demo -> + assertThrows(IOException::class.java) { demo.update(0f) { true } } + } + } + + @Test fun `closing during dispatch stops all further records`() { + val demo = playback(bootstrap() + frame(1) + frame(2) + int(-1)) + var dispatches = 0 + assertFalse(demo.update(0f) { dispatches++; demo.close(); false }) + assertEquals(1, dispatches) + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoRecordingTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoRecordingTest.kt new file mode 100644 index 000000000..691aab85c --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoRecordingTest.kt @@ -0,0 +1,200 @@ +package org.demoth.cake.demo + +import jake2.qcommon.Defines +import jake2.qcommon.entity_state_t +import jake2.qcommon.network.demo.DemoReader +import jake2.qcommon.network.demo.DemoWriter +import jake2.qcommon.network.messages.server.* +import jake2.qcommon.network.protocol.MessageBodyCodec +import jake2.qcommon.network.protocol.MessageDecodeResult +import jake2.qcommon.network.protocol.ChannelRole +import jake2.qcommon.network.protocol.ReliableChannel +import jake2.qcommon.network.protocol.PrepareSendResult +import jake2.qcommon.network.protocol.PacketCodec +import jake2.qcommon.network.protocol.PacketDecodeResult +import jake2.qcommon.network.protocol.PacketDirection +import jake2.qcommon.network.transport.PacketBytes +import jake2.qcommon.sizebuf_t +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException + +class DemoRecordingTest { + private fun server(map: String = "first") = ServerDataMessage.forClient(7, false, "baseq2", 0, map) + private fun bootstrap(map: String = "first") = DemoBootstrap(server(map), listOf(ConfigStringMessage(0, map)), emptyList()) + private fun body(vararg messages: ServerMessage): PacketBytes { + val buffer = sizebuf_t(Defines.MAX_MSGLEN) + messages.forEach { it.writeTo(buffer) } + return PacketBytes.copyOf(buffer.data, buffer.cursize) + } + private fun frame(number: Int, last: Int = -1) = body( + PrintMessage(2, "before $number"), FrameHeaderMessage(number, last, 0, 0, byteArrayOf()), + PlayerInfoMessage(fov = 90), PacketEntitiesMessage(emptyList()), PrintMessage(2, "after $number")) + private fun records(bytes: ByteArray): List = buildList { + DemoReader(ByteArrayInputStream(bytes)).use { reader -> + while (true) when (val record = reader.read()) { + is DemoReader.Record.Body -> add(record.bytes) + DemoReader.Record.End -> break + } + } + } + private fun decode(body: PacketBytes) = (MessageBodyCodec.decodeServerBody(body) as MessageDecodeResult.Decoded).messages + + @Test fun `bootstrap owns startup state sets demo flag and packs whole messages into bounded records`() { + val config = (0..80).map { ConfigStringMessage(it, "value$it".repeat(6)) }.toMutableList() + config += ConfigStringMessage(81, "") + val engine = entity_state_t(1).apply { modelindex = 3; origin[0] = 32f } + val baseline = SpawnBaselineMessage(engine) + val baselines = mutableListOf(baseline) + val startup = DemoBootstrap(server(), config, baselines) + config.clear(); baselines.clear(); engine.clear() + val output = ByteArrayOutputStream() + DemoWriter(output).use(startup::writeTo) + val bodies = records(output.toByteArray()) + assertTrue(bodies.size > 1) + assertTrue(bodies.all { it.size in 1..(Defines.MAX_MSGLEN - 16) }) + val decoded = bodies.flatMap(::decode) + assertEquals(server().copy(demo = true), decoded.first()) + assertEquals(81, decoded.filterIsInstance().size) + assertEquals((0..80).toList(), decoded.filterIsInstance().map { it.index }) + assertEquals(baseline, decoded.filterIsInstance().single()) + assertEquals(StuffTextMessage("precache\n"), decoded.last()) + } + + @Test fun `bootstrap rejects unsupported startup and oversized messages before writing output`() { + for (protocol in listOf(26, 32)) { + assertThrows(IllegalArgumentException::class.java) { DemoBootstrap(server().copy(protocol = protocol), emptyList(), emptyList()) } + } + assertThrows(IllegalArgumentException::class.java) { DemoBootstrap(server().copy(playerNumber = -1), emptyList(), emptyList()) } + assertThrows(IllegalArgumentException::class.java) { + DemoBootstrap(server(), listOf(ConfigStringMessage(0, "x".repeat(Defines.MAX_MSGLEN))), emptyList()) + } + } + + @Test fun `startup records survive forwarding through a classic server channel without dropped payloads`() { + // Yamagi SV_SendClientMessages forwards each .dm2 body through Netchan_Transmit as unreliable data. + // A record can fit the file format limit yet be dropped when its channel header is added. + val startup = DemoBootstrap(server(), listOf( + ConfigStringMessage(Defines.CS_STATUSBAR, "x".repeat(Defines.MAX_MSGLEN - 20)), + ), listOf(SpawnBaselineMessage(EntityStateSnapshot(index = 1, modelIndex = 3)))) + val output = ByteArrayOutputStream() + DemoWriter(output).use(startup::writeTo) + val expected = records(output.toByteArray()).flatMap(::decode) + val forwarded = records(output.toByteArray()).flatMap { record -> + val channel = ReliableChannel(ChannelRole.SERVER, 0) + // Also leave room for a small reliable control payload alongside the server header. + channel.enqueueReliable(PacketBytes.copyOf(ByteArray(8) { ServerMessageType.svc_nop.type.toByte() })) + val send = channel.prepareSend(record) as PrepareSendResult.Prepared + assertFalse(send.unreliableDropped, "Startup record of ${record.size} bytes was dropped") + assertTrue(send.bytes.size <= Defines.MAX_MSGLEN) + val packet = (PacketCodec.decode(send.bytes, PacketDirection.FROM_SERVER) as PacketDecodeResult.Decoded).envelope + (MessageBodyCodec.decodeServer(packet) as MessageDecodeResult.Decoded).messages + } + assertEquals(expected, forwarded) + assertTrue(forwarded.first() is ServerDataMessage) + assertEquals(StuffTextMessage("precache\n"), forwarded.last()) + } + + @Test fun `recording waits for an accepted complete full snapshot then preserves raw bodies through playback`() { + val output = ByteArrayOutputStream() + var currentMap = "old" + var bootstraps = 0 + val recording = DemoRecording(DemoWriter(output)) { bootstraps++; bootstrap(currentMap) } + val full = frame(100) + val delta = frame(101, 100) + val final = body(PrintMessage(2, "final message")) + recording.use { + assertTrue(it.needsFullSnapshot) + it.capture(body(PrintMessage(2, "not started")), false) + it.capture(frame(99, 98), true) + it.capture(full, false) + it.capture(body(FrameHeaderMessage(100, -1, 0, 0, byteArrayOf())), true) + assertEquals(0, output.size()) + assertEquals(0, bootstraps) + currentMap = "latest" + it.capture(full, true) + assertFalse(it.needsFullSnapshot) + it.capture(delta, true) + it.capture(final, false) + } + assertFalse(recording.needsFullSnapshot) + val saved = records(output.toByteArray()) + assertEquals(listOf(full, delta, final), saved.drop(1)) + assertEquals("latest", (decode(saved.first()).first() as ServerDataMessage).levelString) + val presented = mutableListOf() + DemoPlayback(DemoReader(ByteArrayInputStream(output.toByteArray()))).use { playback -> + val dispatch: (List) -> Boolean = { messages -> + presented += messages.filterIsInstance().map { it.frameNumber }; true + } + assertTrue(playback.update(0f, dispatch)) + assertTrue(playback.update(.1f, dispatch)) + assertFalse(playback.update(.1f, dispatch)) + } + assertEquals(listOf(100, 101), presented) + assertEquals(1, bootstraps) + } + + @Test fun `a map reset or rejected snapshot waits for a fresh full frame and takes fresh startup state`() { + val output = ByteArrayOutputStream() + var map = "first" + DemoRecording(DemoWriter(output)) { bootstrap(map) }.use { recording -> + recording.capture(frame(100), true) + recording.capture(frame(101, 100), false) + assertTrue(recording.needsFullSnapshot) + recording.capture(frame(102, 101), true) + recording.capture(frame(103), true) + map = "second" + recording.capture(body(server(map), ConfigStringMessage(0, map)), false) + assertTrue(recording.needsFullSnapshot) + recording.capture(frame(1, 10), true) + recording.capture(frame(2, 0), true) + } + val decoded = records(output.toByteArray()).flatMap(::decode) + assertEquals(listOf(100, 103, 2), decoded.filterIsInstance().map { it.frameNumber }) + assertEquals(listOf("first", "first", "second"), decoded.filterIsInstance().map { it.levelString }) + } + + @Test fun `live valid deltas must reference frames saved in this recording and its current map`() { + val output = ByteArrayOutputStream() + DemoRecording(DemoWriter(output)) { bootstrap() }.use { recording -> + recording.capture(frame(100), true) + recording.capture(frame(101, 100), true) + recording.capture(frame(102, 100), true) + assertFalse(recording.needsFullSnapshot) + recording.capture(frame(103, 99), true) + assertTrue(recording.needsFullSnapshot) + recording.capture(frame(104, 102), true) + recording.capture(frame(105), true) + recording.capture(body(server("next")), false) + recording.capture(frame(106, 105), true) + assertTrue(recording.needsFullSnapshot) + recording.capture(frame(107), true) + } + val savedFrames = records(output.toByteArray()).flatMap(::decode).filterIsInstance() + assertEquals(listOf(100, 101, 102, 105, 107), savedFrames.map { it.frameNumber }) + } + + @Test fun `malformed bodies write no valid prefix and close the recording`() { + val output = ByteArrayOutputStream() + val recording = DemoRecording(DemoWriter(output)) { bootstrap() } + recording.capture(frame(1), true) + val before = output.size() + val malformed = PacketBytes.copyOf(body(PrintMessage(2, "prefix")).toByteArray() + byteArrayOf(254.toByte())) + assertThrows(IOException::class.java) { recording.capture(malformed, false) } + assertEquals(before + 4, output.size()) + assertThrows(IllegalStateException::class.java) { recording.capture(frame(2), true) } + recording.close() + assertEquals(2, records(output.toByteArray()).size) + } + + @Test fun `stopping before a full frame closes without requesting or fabricating any snapshot`() { + val output = ByteArrayOutputStream() + val recording = DemoRecording(DemoWriter(output)) { error("No full frame ready") } + recording.close() + recording.close() + assertFalse(recording.needsFullSnapshot) + assertTrue(records(output.toByteArray()).isEmpty()) + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoStartupSnapshotTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoStartupSnapshotTest.kt new file mode 100644 index 000000000..898d42a8d --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/demo/DemoStartupSnapshotTest.kt @@ -0,0 +1,40 @@ +package org.demoth.cake.demo + +import com.badlogic.gdx.assets.AssetManager +import com.badlogic.gdx.assets.loaders.FileHandleResolver +import com.badlogic.gdx.files.FileHandle +import jake2.qcommon.entity_state_t +import jake2.qcommon.network.messages.server.SpawnBaselineMessage +import org.demoth.cake.CakeCvars +import org.demoth.cake.config.GameConfiguration +import org.demoth.cake.stages.ingame.ClientEntityManager +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class DemoStartupSnapshotTest { + @Test fun `startup capture copies live configstrings and received baselines without loading resources`() { + CakeCvars.registerEntities() + val assets = AssetManager(FileHandleResolver(::FileHandle), false) + val config = GameConfiguration(assets) + val entities = ClientEntityManager() + try { + config.applyConfigString(0, "first") + config.applyConfigString(1, "") + val baseline = entity_state_t(17).apply { modelindex = 3; origin[0] = 32f } + entities.processBaselineMessage(SpawnBaselineMessage(baseline)) + val configs = config.snapshotConfigStrings() + val baselines = entities.snapshotBaselines() + config.applyConfigString(0, "second") + baseline.modelindex = 4 + baseline.origin[0] = 64f + entities.processBaselineMessage(SpawnBaselineMessage(baseline)) + assertEquals("first", configs.single().config) + assertEquals(17, baselines.single().entityState.index) + assertEquals(3, baselines.single().entityState.modelIndex) + assertEquals(32f, baselines.single().entityState.origin.x) + assertEquals("second", config.snapshotConfigStrings().single().config) + assertEquals(4, entities.snapshotBaselines().single().entityState.modelIndex) + assertEquals(0, assets.loadedAssets) + } finally { entities.dispose(); assets.dispose() } + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/demo/LiveDemoRecordingTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/demo/LiveDemoRecordingTest.kt new file mode 100644 index 000000000..9512c5c29 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/demo/LiveDemoRecordingTest.kt @@ -0,0 +1,137 @@ +package org.demoth.cake.demo + +import com.badlogic.gdx.assets.AssetManager +import com.badlogic.gdx.assets.loaders.FileHandleResolver +import com.badlogic.gdx.files.FileHandle +import jake2.qcommon.Defines +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.demo.DemoReader +import jake2.qcommon.network.demo.DemoWriter +import jake2.qcommon.network.messages.server.* +import jake2.qcommon.network.protocol.* +import jake2.qcommon.network.transport.PacketBytes +import jake2.qcommon.sizebuf_t +import org.demoth.cake.CakeCvars +import org.demoth.cake.config.GameConfiguration +import org.demoth.cake.input.InputManager +import org.demoth.cake.stages.ingame.ClientEntityManager +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream + +/** Real body codecs, startup snapshots and client frame reconstruction; no graphics or game resources. */ +class LiveDemoRecordingTest { + private class Client : AutoCloseable { + val assets = AssetManager(FileHandleResolver(::FileHandle), false) + var config = GameConfiguration(assets) + var entities = ClientEntityManager() + var server: ServerDataMessage? = null + val positions = mutableListOf() + val prints = mutableListOf() + var disconnected = false + + fun apply(messages: List): Boolean { + var accepted = false + for (message in messages) when (message) { + is ServerDataMessage -> { + entities.dispose() + entities = ClientEntityManager() + config = GameConfiguration(assets) + server = message + } + is ConfigStringMessage -> config.applyConfigString(message.index, message.config) + is SpawnBaselineMessage -> entities.processBaselineMessage(message) + is FrameHeaderMessage -> entities.processServerFrameHeader(message) + is PlayerInfoMessage -> entities.processPlayerInfoMessage(message) + is PacketEntitiesMessage -> { + accepted = entities.processPacketEntitiesMessage(message) + if (accepted) positions += entities.getEntityOrigin(1)!!.x + } + is PrintMessage -> prints += message.text + DisconnectMessage -> disconnected = true + else -> Unit + } + return accepted + } + fun bootstrap() = DemoBootstrap(checkNotNull(server), config.snapshotConfigStrings(), entities.snapshotBaselines()) + override fun close() { entities.dispose(); assets.dispose() } + } + + private fun body(messages: List): PacketBytes { + val buffer = sizebuf_t(Defines.MAX_MSGLEN) + messages.forEach { it.writeTo(buffer) } + return PacketBytes.copyOf(buffer.data, buffer.cursize) + } + private fun state(x: Float) = EntityStateSnapshot(1, origin = Vector3f(x, 0f, 0f), modelIndex = 3) + private fun frame(number: Int, last: Int, from: EntityStateSnapshot, to: EntityStateSnapshot) = listOf( + FrameHeaderMessage(number, last, 0, 0, byteArrayOf()), PlayerInfoMessage(fov = 90), + PacketEntitiesMessage(listOf(EntityUpdate.Change(checkNotNull(EntityDeltaCodec.plan(from, to, true, last <= 0)))))) + private fun startup(map: String, baseline: EntityStateSnapshot): List = listOf( + ServerDataMessage.forClient(1, false, "baseq2", 0, map), ConfigStringMessage(0, map), SpawnBaselineMessage(baseline)) + + @Test fun `live packet capture requests a full frame and replays reconstructed entity deltas across maps`() { + CakeCvars.registerEntities() + CakeCvars.registerInput() + val output = ByteArrayOutputStream() + val capturedBodies = mutableListOf() + Client().use { live -> + var sequence = 1 + fun receive(messages: List, recording: DemoRecording? = null) { + val raw = body(messages) + val wire = PacketCodec.encodeSequenced(sequence++, 0, null, raw) + val packet = (PacketCodec.decode(wire, PacketDirection.FROM_SERVER) as PacketDecodeResult.Decoded).envelope as PacketEnvelope.Sequenced + val decoded = (MessageBodyCodec.decodeServer(packet) as MessageDecodeResult.Decoded).messages + val accepted = live.apply(decoded) + recording?.capture(packet, accepted) + if (recording != null && messages.any { it is FrameHeaderMessage } && !recording.needsFullSnapshot) + capturedBodies += raw + } + val firstBaseline = state(32f) + receive(startup("first", firstBaseline)) + receive(frame(50, -1, firstBaseline, state(64f))) + DemoRecording(DemoWriter(output), live::bootstrap).use { recording -> + val input = InputManager(nowNanosProvider = { 1_000_000L }) + input.updateLocalInput(0f, live.entities.currentFrame) + assertTrue(input.buildMoveMessage(1, live.entities.currentFrame, 16, recording.needsFullSnapshot).lastReceivedFrame <= 0) + assertTrue(live.entities.currentFrame.valid) + receive(frame(51, 50, state(64f), state(72f)), recording) + assertTrue(recording.needsFullSnapshot) + receive(frame(52, -1, firstBaseline, state(80f)), recording) + assertEquals(52, input.buildMoveMessage(2, live.entities.currentFrame, 16, recording.needsFullSnapshot).lastReceivedFrame) + receive(frame(53, 52, state(80f), state(96f)), recording) + val nextBaseline = state(128f) + receive(startup("second", nextBaseline), recording) + assertTrue(recording.needsFullSnapshot) + receive(frame(1, -1, nextBaseline, state(144f)), recording) + receive(frame(2, 1, state(144f), state(160f)), recording) + // The application captures the final accepted payload before closing on disconnect. + receive(listOf(PrintMessage(2, "goodbye"), DisconnectMessage), recording) + } + } + val records = buildList { + DemoReader(ByteArrayInputStream(output.toByteArray())).use { reader -> + while (true) when (val next = reader.read()) { + is DemoReader.Record.Body -> add(next.bytes) + DemoReader.Record.End -> break + } + } + } + val savedFrameBodies = records.filter { raw -> + (MessageBodyCodec.decodeServerBody(raw) as MessageDecodeResult.Decoded).messages.any { it is FrameHeaderMessage } + } + assertEquals(capturedBodies, savedFrameBodies, "Channel headers must be stripped without changing payload bytes") + Client().use { replay -> + DemoPlayback(DemoReader(ByteArrayInputStream(output.toByteArray()))).use { playback -> + val dispatch: (List) -> Boolean = { replay.apply(it); true } + var running = playback.update(0f, dispatch) + repeat(10) { if (running) running = playback.update(.1f, dispatch) } + assertFalse(running) + } + assertEquals(listOf(80f, 96f, 144f, 160f), replay.positions) + assertEquals(listOf("goodbye"), replay.prints) + assertTrue(replay.disconnected) + assertEquals("second", replay.config.getConfigValue(0)) + } + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/input/InputManagerDeltaCompressionTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/input/InputManagerDeltaCompressionTest.kt index 8e65c9650..2c2d58e2a 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/input/InputManagerDeltaCompressionTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/input/InputManagerDeltaCompressionTest.kt @@ -37,6 +37,20 @@ class InputManagerDeltaCompressionTest { CakeCvars.registerInput() } + @Test + fun recordingCanRequestFullSnapshotWithoutInvalidatingPresentationOrDiscardingInput() { + val input = InputManager(nowNanosProvider = { 1_000_000L }) + val current = frame(serverframe = 77, valid = true) + input.updateLocalInput(0f, current) + val full = roundTrip(input.buildMoveMessage(5, current, 16, requestFullSnapshot = true), 5) + assertTrue(full.lastReceivedFrame <= 0) + assertTrue(current.valid) + assertEquals(77, current.serverframe) + assertEquals(16, full.newCmd.msec) + val delta = roundTrip(input.buildMoveMessage(6, current, 16), 6) + assertEquals(77, delta.lastReceivedFrame) + } + @Test fun invalidCurrentFrameRequestsFullFrameResync() { val input = InputManager(nowNanosProvider = { 1_000_000L }) diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/BeamEffectsTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/BeamEffectsTest.kt new file mode 100644 index 000000000..10bfe5db8 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/BeamEffectsTest.kt @@ -0,0 +1,103 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.math.Vector3 +import jake2.qcommon.Defines +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import kotlin.random.Random + +class BeamEffectsTest { + private data class Segment(val kind: BeamKind, val position: Vector3, val pitch: Float, val yaw: Float, val roll: Float) + private fun segments(beams: BeamEffects, now: Int, local: Int? = null, view: Vector3 = Vector3.Zero): List = buildList { + beams.submit(now, local, view, BeamSegmentSink { kind, x, y, z, pitch, yaw, roll -> + add(Segment(kind, Vector3(x, y, z), pitch, yaw, roll)) + }) + } + private fun cable(owner: Int = 1, end: Vector3f = Vector3f(90f, 0f, 0f)) = + BeamTEMessage(Defines.TE_PARASITE_ATTACK, owner, Vector3f.zero, end) + + @Test fun `refresh replaces a source beam and extends its lifetime without accumulating segments`() { + val beams = BeamEffects({}) + beams.process(cable(), 0) + assertEquals(3, segments(beams, 0).size) + beams.process(cable(end = Vector3f(60f, 0f, 0f)), 150) + assertEquals(1, beams.activeCount) + assertEquals(2, segments(beams, 200).size) + assertTrue(segments(beams, 350).isEmpty()) + assertEquals(0, beams.activeCount) + } + + @Test fun `lightning retains distinct destinations and issues entity weapon-channel sound requests`() { + val sounds = mutableListOf() + val beams = BeamEffects(sounds::add) + fun lightning(target: Int) = LightningTEMessage(7, target, Vector3f.zero, Vector3f(100f, 0f, 0f)) + beams.process(lightning(8), 0) + beams.process(lightning(9), 0) + beams.process(lightning(8), 100) + assertEquals(2, beams.activeCount) + assertEquals(3, sounds.size) + assertTrue(sounds.all { it.path == "sound/weapons/tesla.wav" && it.entityIndex == 7 && it.channel == Defines.CHAN_WEAPON }) + segments(beams, 200) + assertEquals(1, beams.activeCount) + segments(beams, 300) + assertEquals(0, beams.activeCount) + } + + @Test fun `player-origin cables follow the current view and offset between network updates`() { + val beams = BeamEffects({}) + beams.process(BeamOffsetTEMessage(Defines.TE_GRAPPLE_CABLE, 4, Vector3f(50f, 60f, 70f), + Vector3f(300f, 0f, 0f), Vector3f(1f, 2f, 3f)), 0) + assertEquals(Vector3(11f, 22f, 11f), segments(beams, 0, 4, Vector3(10f, 20f, 30f)).first().position) + assertEquals(Vector3(21f, 32f, 21f), segments(beams, 50, 4, Vector3(20f, 30f, 40f)).first().position) + assertEquals(Vector3(51f, 62f, 73f), segments(beams, 50).first().position) + } + + @Test fun `short and vertical lightning and single-segment cables produce finite transforms`() { + val beams = BeamEffects({}) + beams.process(LightningTEMessage(1, 2, Vector3f.zero, Vector3f(0f, 0f, 10f)), 0) + val bolt = segments(beams, 0).single() + assertEquals(Vector3(0f, 0f, 10f), bolt.position) + assertEquals(-90f, bolt.pitch, .001f) + assertTrue(bolt.yaw.isFinite() && bolt.roll.isFinite()) + beams.clear() + beams.process(cable(end = Vector3f(10f, 0f, 0f)), 0) + assertEquals(Vector3.Zero, segments(beams, 0).single().position) + beams.process(cable(end = Vector3f.zero), 1) + assertTrue(segments(beams, 1).isEmpty()) + } + + @Test fun `lightning geometry reverses long segments and applies its endpoint correction`() { + val beams = BeamEffects({}) + beams.process(LightningTEMessage(1, 2, Vector3f.zero, Vector3f(100f, 0f, 0f)), 0) + val result = segments(beams, 0) + assertEquals(3, result.size) + assertEquals(0f, result.first().position.x) + assertEquals(45f, result.last().position.x) + assertTrue(result.all { it.yaw == 180f }) + } + + @Test fun `state and geometry are bounded expired slots are reusable and clear releases all beams`() { + val beams = BeamEffects({}, maximumBeams = 1, maximumSegmentsPerBeam = 4) + beams.process(cable(end = Vector3f(4000f, 0f, 0f)), 0) + beams.process(cable(owner = 2), 0) + assertEquals(1, beams.activeCount) + assertEquals(4, segments(beams, 0).size) + beams.process(cable(owner = 2), 200) + assertEquals(1, beams.activeCount) + beams.clear() + assertTrue(segments(beams, 200).isEmpty()) + } + + @Test fun `paused clocks retain geometry and unrelated heatbeams and invalid entities add no state`() { + val beams = BeamEffects({}, random = Random(7)) + beams.process(LightningTEMessage(1, 2, Vector3f.zero, Vector3f(100f, 0f, 0f)), 0) + assertEquals(segments(beams, 50), segments(beams, 50)) + beams.clear() + assertFalse(beams.process(BeamTEMessage(Defines.TE_HEATBEAM, 1, Vector3f.zero, Vector3f.unitX), 0)) + beams.process(cable(owner = -1), 0) + beams.process(LightningTEMessage(1, Defines.MAX_EDICTS, Vector3f.zero, Vector3f.unitX), 0) + assertEquals(0, beams.activeCount) + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/BeamModelRendererTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/BeamModelRendererTest.kt new file mode 100644 index 000000000..224dd6094 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/BeamModelRendererTest.kt @@ -0,0 +1,116 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.g3d.Model +import com.badlogic.gdx.graphics.g3d.ModelInstance +import com.badlogic.gdx.math.Vector3 +import com.badlogic.gdx.utils.GdxNativesLoader +import org.demoth.cake.assets.md2.Md2CustomData +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test + +/** Uses empty libGDX models and matrices, without meshes, textures, GL, windows or installed content. */ +class BeamModelRendererTest { + private fun instances(renderer: BeamModelRenderer): List = buildList { renderer.forEachInstance { add(it) } } + + @Test fun `frame rebuilds reuse instances instead of accumulating segments`() { + val model = Model() + val renderer = BeamModelRenderer { model } + try { + renderer.submit(BeamKind.CABLE, 1f, 2f, 3f, 0f, 90f, 0f) + renderer.submit(BeamKind.CABLE, 4f, 5f, 6f, 0f, 0f, 0f) + val initial = instances(renderer).toSet() + assertEquals(2, initial.size) + repeat(100) { + renderer.beginFrame() + renderer.submit(BeamKind.CABLE, 7f, 8f, 9f, 0f, 90f, 0f) + renderer.submit(BeamKind.CABLE, 10f, 11f, 12f, 0f, 0f, 0f) + assertEquals(initial, instances(renderer).toSet()) + } + val first = instances(renderer).first() + assertEquals(Vector3(7f, 8f, 9f), first.transform.getTranslation(Vector3())) + val forward = Vector3.X.cpy().rot(first.transform) + assertTrue(forward.epsilonEquals(Vector3.Y, .0001f)) + renderer.beginFrame() + assertTrue(instances(renderer).isEmpty()) + } finally { renderer.dispose(); model.dispose() } + } + + @Test fun `missing optional assets are skipped and later model replacements discard stale instances`() { + val firstModel = Model() + val secondModel = Model() + var available: Model? = null + val renderer = BeamModelRenderer { available } + try { + renderer.submit(BeamKind.LIGHTNING, 0f, 0f, 0f, 0f, 0f, 0f) + assertTrue(instances(renderer).isEmpty()) + available = firstModel + renderer.submit(BeamKind.LIGHTNING, 0f, 0f, 0f, 0f, 0f, 0f) + val original = instances(renderer).single() + renderer.beginFrame() + available = secondModel + renderer.submit(BeamKind.LIGHTNING, 0f, 0f, 0f, 0f, 0f, 0f) + assertNotSame(original, instances(renderer).single()) + assertSame(secondModel, instances(renderer).single().model) + } finally { renderer.dispose(); firstModel.dispose(); secondModel.dispose() } + } + + @Test fun `lightning restores full-bright frame state when a model instance is reused`() { + val model = Model() + val renderer = BeamModelRenderer { model } + try { + renderer.submit(BeamKind.LIGHTNING, 0f, 0f, 0f, 0f, 0f, 0f) + val instance = instances(renderer).single() + instance.userData = Md2CustomData.empty().apply { + frame1 = 9; frame2 = 10; interpolation = .5f + lightRed = 0f; lightGreen = 0f; lightBlue = 0f + shadeVectorX = 1f; shadeVectorY = 1f; shadeVectorZ = 1f + } + renderer.beginFrame() + renderer.submit(BeamKind.LIGHTNING, 0f, 0f, 0f, 0f, 0f, 0f) + assertSame(instance, instances(renderer).single()) + val data = instance.userData as Md2CustomData + assertEquals(0, data.frame1); assertEquals(0, data.frame2); assertEquals(0f, data.interpolation) + assertEquals(1f, data.lightRed); assertEquals(1f, data.lightGreen); assertEquals(1f, data.lightBlue) + assertEquals(0f, data.shadeVectorX); assertEquals(0f, data.shadeVectorY); assertEquals(0f, data.shadeVectorZ) + } finally { renderer.dispose(); model.dispose() } + } + + @Test fun `disposal releases instances and leaves shared model lifetime to the asset owner`() { + var disposed = false + val model = object : Model() { + override fun dispose() { disposed = true; super.dispose() } + } + val renderer = BeamModelRenderer { model } + renderer.submit(BeamKind.GRAPPLE, 0f, 0f, 0f, 0f, 0f, 0f) + renderer.dispose() + assertTrue(instances(renderer).isEmpty()) + assertFalse(disposed) + model.dispose() + assertTrue(disposed) + } + + @Test fun `heatbeam model instances select their player or remote frame and full-bright lighting`() { + for ((kind, frame) in listOf(BeamKind.HEATBEAM_PLAYER to 1, BeamKind.HEATBEAM_MONSTER to 2)) { + val model = Model() + val renderer = BeamModelRenderer { model } + try { + renderer.submit(kind, 0f, 0f, 0f, 0f, 0f, 0f) + val instance = instances(renderer).single() + instance.userData = Md2CustomData.empty().apply { lightRed = 0f; frame1 = 99 } + renderer.beginFrame() + renderer.submit(kind, 0f, 0f, 0f, 0f, 0f, 30f) + val data = instance.userData as Md2CustomData + assertEquals(frame, data.frame1) + assertEquals(frame, data.frame2) + assertEquals(0f, data.interpolation) + assertEquals(1f, data.lightRed) + assertSame(instance, instances(renderer).single()) + } finally { renderer.dispose(); model.dispose() } + } + } + + companion object { + @BeforeAll @JvmStatic fun loadMathNatives() { GdxNativesLoader.load() } + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/EffectAssetCatalogTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/EffectAssetCatalogTest.kt new file mode 100644 index 000000000..49ae01821 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/EffectAssetCatalogTest.kt @@ -0,0 +1,59 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.assets.AssetDescriptor +import com.badlogic.gdx.assets.AssetLoaderParameters +import com.badlogic.gdx.assets.AssetManager +import com.badlogic.gdx.assets.loaders.FileHandleResolver +import com.badlogic.gdx.assets.loaders.SynchronousAssetLoader +import com.badlogic.gdx.audio.Sound +import com.badlogic.gdx.files.FileHandle +import com.badlogic.gdx.utils.Array as GdxArray +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.lang.reflect.Proxy + +class EffectAssetCatalogTest { + @Test fun `overlapping screens and config owners retain independent references to shared assets`() { + val path = "sound/weapons/lashit.wav" + var loaded = 0 + var disposed = 0 + val resolver = FileHandleResolver { if (it == path) FileHandle(it) else null } + val assets = AssetManager(resolver, false) + assets.setLoader(Sound::class.java, object : SynchronousAssetLoader>(resolver) { + override fun load(manager: AssetManager, fileName: String, file: FileHandle, parameter: AssetLoaderParameters?): Sound { + loaded++ + return Proxy.newProxyInstance(Sound::class.java.classLoader, arrayOf(Sound::class.java)) { _, method, _ -> + if (method.name == "dispose") disposed++ + null + } as Sound + } + override fun getDependencies(fileName: String, file: FileHandle, parameter: AssetLoaderParameters?): GdxArray>? = null + }) + val oldScreen = EffectAssetCatalog(assets) + val newScreen = EffectAssetCatalog(assets) + try { + // Configstrings already own this sound when both screens precache it. + assets.load(path, Sound::class.java) + assets.finishLoading() + oldScreen.precache() + newScreen.precache() + newScreen.precache() + assertEquals(1, loaded) + assertEquals(3, assets.getReferenceCount(path)) + assertSame(oldScreen.getSound(path), newScreen.getSound(path)) + oldScreen.dispose() + oldScreen.dispose() + assertEquals(2, assets.getReferenceCount(path)) + assets.unload(path) + assertNotNull(newScreen.getSound(path)) + assertEquals(0, disposed) + newScreen.dispose() + assertFalse(assets.isLoaded(path)) + assertEquals(1, disposed) + } finally { + oldScreen.dispose() + newScreen.dispose() + assets.dispose() + } + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ExpansionEffectsTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ExpansionEffectsTest.kt new file mode 100644 index 000000000..353f9e9db --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ExpansionEffectsTest.kt @@ -0,0 +1,96 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.math.Vector3 +import jake2.qcommon.Defines +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.* +import org.demoth.cake.stages.ingame.DynamicLightSystem +import org.demoth.cake.stages.ingame.RenderTuningCvars +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import kotlin.random.Random + +class ExpansionEffectsTest { + private class Fixture(budget: Int = 1000, maximumTrailPoints: Int = 1024) : AutoCloseable { + val particles = GdxParticleRuntime({ budget }) + val colors = mutableListOf() + val sounds = mutableListOf() + val lights = DynamicLightSystem() + val effects = ExpansionEffects(particles, { index -> colors += index; Color.WHITE }, lights, + sounds::add, Random(7), maximumTrailPoints) + fun positions(nowMs: Int): List = buildList { + particles.submit(nowMs, ParticleSink { _, _, x, y, z, _, _, _, _, _ -> add(Vector3(x, y, z)) }) + } + override fun close() = particles.dispose() + } + + @Test fun `flashlight refreshes the source keyed light and expires through the shared lighting runtime`() { + RenderTuningCvars.register() + Fixture().use { fixture -> + fixture.effects.process(FlashlightTEMessage(Vector3f(1f, 2f, 3f), 7), 0) + fixture.lights.beginFrame(0, 0f) + val first = fixture.lights.visibleLightsForShader().single() + assertEquals(Vector3(1f, 2f, 3f), first.origin) + assertEquals(400f, first.radius) + assertEquals(1f, first.red); assertEquals(1f, first.green); assertEquals(1f, first.blue) + fixture.effects.process(FlashlightTEMessage(Vector3f(4f, 5f, 6f), 7), 50) + fixture.lights.beginFrame(100, .05f) + assertEquals(Vector3(4f, 5f, 6f), fixture.lights.visibleLightsForShader().single().origin) + fixture.lights.beginFrame(200, .1f) + assertTrue(fixture.lights.visibleLightsForShader().isEmpty()) + fixture.effects.process(FlashlightTEMessage(Vector3f.zero, -1), 200) + fixture.lights.beginFrame(200, 0f) + assertTrue(fixture.lights.visibleLightsForShader().isEmpty()) + } + } + + @Test fun `force wall spreads its palette particles across the segment and lets them fall`() { + Fixture().use { fixture -> + fixture.effects.process(ForceWallTEMessage(Vector3f.zero, Vector3f(80f, 0f, 0f), 37), 0) + val first = fixture.positions(0) + assertTrue(first.size in 1..20) + assertTrue(first.all { it.x in -3f..83f && it.y in -3f..3f && it.z in -3f..3f }) + assertTrue(fixture.colors.all { it == 37 }) + val later = fixture.positions(250) + for ((before, after) in first.zip(later)) { + assertEquals(before.x, after.x) + assertEquals(before.y, after.y) + assertTrue(after.z - before.z in -12.51f..-7.49f) + } + assertTrue(fixture.positions(3501).isEmpty()) + } + } + + @Test fun `bubble trail two is dense rising palette colored and requests its sound at the start`() { + Fixture().use { fixture -> + val message = TrailTEMessage(Defines.TE_BUBBLETRAIL2, Vector3f.zero, Vector3f(32f, 0f, 0f)) + assertTrue(fixture.effects.process(message, 0)) + val first = fixture.positions(0) + assertEquals(4, first.size) + assertTrue(first.all { it.y in -2f..2f && it.z in -6f..-2f }) + assertTrue(fixture.colors.all { it in 4..11 }) + assertEquals(listOf(Vector3f.zero), fixture.sounds) + for ((before, after) in first.zip(fixture.positions(100))) assertTrue(after.z > before.z) + assertTrue(fixture.positions(1101).isEmpty()) + assertFalse(fixture.effects.process(message.copy(style = Defines.TE_RAILTRAIL), 1200)) + } + } + + @Test fun `degenerate segments emit no particles and long trails and budgets have bounded work`() { + Fixture(budget = 3, maximumTrailPoints = 10).use { fixture -> + fixture.effects.process(ForceWallTEMessage(Vector3f.zero, Vector3f.zero, 10), 0) + assertEquals(0, fixture.particles.liveCount) + fixture.effects.process(TrailTEMessage(Defines.TE_BUBBLETRAIL2, Vector3f.zero, Vector3f(10000f, 0f, 0f)), 0) + assertEquals(3, fixture.particles.liveCount) + } + Fixture(maximumTrailPoints = 10).use { fixture -> + fixture.effects.process(TrailTEMessage(Defines.TE_BUBBLETRAIL2, Vector3f.zero, Vector3f(10000f, 0f, 0f)), 0) + assertEquals(10, fixture.particles.liveCount) + } + Fixture(budget = 0).use { fixture -> + fixture.effects.process(ForceWallTEMessage(Vector3f.zero, Vector3f(100f, 0f, 0f), 10), 0) + assertEquals(0, fixture.particles.liveCount) + } + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionAnimationTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionAnimationTest.kt new file mode 100644 index 000000000..1573158c3 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionAnimationTest.kt @@ -0,0 +1,59 @@ +package org.demoth.cake.stages.ingame.effects + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class ExplosionAnimationTest { + @Test fun `model frame pairs interpolate relative to the selected base and expire at the last transition`() { + val animation = ExplosionAnimation(ExplosionType.POLY, 1000, 100, 30, 19) + val first = animation.sample(1000)!! + assertEquals(30, first.previousFrame); assertEquals(31, first.currentFrame) + assertEquals(.5f, animation.sample(1050)!!.interpolation) + assertEquals(31, animation.sample(1100)!!.previousFrame) + assertNotNull(animation.sample(2799)) + assertNull(animation.sample(2800)) + } + + @Test fun `polygon explosion alpha skin and translucency follow the animation together`() { + val animation = ExplosionAnimation(ExplosionType.POLY, 0, 100, 0, 15) + assertEquals(0, animation.sample(0)!!.skin) + assertEquals(.5f, animation.sample(800)!!.alpha) + assertEquals(4, animation.sample(800)!!.skin) + assertFalse(animation.sample(800)!!.translucent) + assertEquals(5, animation.sample(1000)!!.skin) + assertTrue(animation.sample(1000)!!.translucent) + assertEquals(6, animation.sample(1300)!!.skin) + assertNull(animation.sample(1400)) + } + + @Test fun `misc effects fade smoothly and preserve their supplied skin and translucency`() { + val animation = ExplosionAnimation(ExplosionType.MISC, 0, 100, 0, 4, true, 2) + assertEquals(.5f, animation.sample(150)!!.alpha) + assertEquals(2, animation.sample(150)!!.skin) + assertTrue(animation.sample(150)!!.translucent) + assertNull(animation.sample(300)) + } + + @Test fun `flash muzzle flash and second polygon style retain their distinct lifetime and alpha rules`() { + val flash = ExplosionAnimation(ExplosionType.FLASH, 0, 100, 0, 2) + assertEquals(1f, flash.sample(99)!!.alpha) + assertNull(flash.sample(100)) + val oneFrameFlash = ExplosionAnimation(ExplosionType.FLASH, 0, 100, 0, 1) + assertNotNull(oneFrameFlash.sample(99)) + assertNull(oneFrameFlash.sample(100)) + val muzzle = ExplosionAnimation(ExplosionType.MFLASH, 0, 100, 0, 4) + assertEquals(1f, muzzle.sample(200)!!.alpha) + assertNull(muzzle.sample(300)) + val second = ExplosionAnimation(ExplosionType.POLY2, 0, 100, 0, 5) + assertEquals(.6f, second.sample(200)!!.alpha) + assertEquals(0, second.sample(200)!!.skin) + assertTrue(second.sample(200)!!.translucent) + } + + @Test fun `paused clocks and equivalent update paths yield the same frame without advancing state`() { + val animation = ExplosionAnimation(ExplosionType.MISC, 0, 100, 0, 4) + val frame = animation.sample(150) + repeat(100) { assertEquals(frame, animation.sample(150)) } + assertEquals(animation.sample(0), animation.sample(-100)) + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionEffectTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionEffectTest.kt new file mode 100644 index 000000000..b3cc226a8 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ExplosionEffectTest.kt @@ -0,0 +1,81 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.g3d.Material +import com.badlogic.gdx.graphics.g3d.Model +import com.badlogic.gdx.graphics.g3d.ModelInstance +import com.badlogic.gdx.graphics.g3d.attributes.BlendingAttribute +import com.badlogic.gdx.math.Vector3 +import com.badlogic.gdx.utils.GdxNativesLoader +import jake2.qcommon.math.Vector3f +import org.demoth.cake.assets.md2.Md2CustomData +import org.demoth.cake.stages.ingame.DynamicLightSystem +import org.demoth.cake.stages.ingame.RenderTuningCvars +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test + +class ExplosionEffectTest { + @Test fun `lights fade with animation remain stable on pause and disappear when animation ends even without models`() { + val lights = DynamicLightSystem() + val origin = Vector3(10f, 20f, 30f) + val effect = ExplosionEffect(ExplosionType.MISC, 0, 100, 0, 4, origin, + light = ExplosionLight(150f, Vector3f(.2f, .4f, .8f))) + origin.setZero() + fun radius(now: Int): Float? { + effect.update(now, .01f) + lights.beginFrame(now, .01f) + effect.collectDynamicLights(lights) + val result = lights.visibleLightsForShader().singleOrNull() + if (result != null) { + assertEquals(Vector3(10f, 20f, 30f), result.origin) + assertEquals(.2f, result.red); assertEquals(.4f, result.green); assertEquals(.8f, result.blue) + } + return result?.radius + } + assertEquals(150f, radius(0)) + assertEquals(75f, radius(150)) + repeat(10) { assertEquals(75f, radius(150)) } + assertNull(radius(300)) + } + + @Test fun `MD2 frames skin interpolation and stock material opacity use the same animation as the light`() { + val model = Model() + try { + val instance = ModelInstance(model).apply { + materials.add(Material()) + userData = Md2CustomData.empty() + } + val effect = ExplosionMd2Effect(instance, ExplosionType.MISC, 0, 100, 0, 4, Vector3(1f, 2f, 3f), + baseSkinIndex = 2, startsTranslucent = true, light = ExplosionLight(150f, Vector3f.one)) + assertTrue(effect.update(150, .1f)) + val data = instance.userData as Md2CustomData + assertEquals(1, data.frame1); assertEquals(2, data.frame2); assertEquals(.5f, data.interpolation) + assertEquals(2, data.skinIndex) + assertEquals(.5f, (instance.materials.first().get(BlendingAttribute.Type) as BlendingAttribute).opacity) + assertEquals(Vector3(1f, 2f, 3f), instance.transform.getTranslation(Vector3())) + val lights = DynamicLightSystem() + lights.beginFrame(150, 0f) + effect.collectDynamicLights(lights) + assertEquals(75f, lights.visibleLightsForShader().single().radius) + assertFalse(effect.update(300, .1f)) + } finally { model.dispose() } + } + + @Test fun `opaque polygon models become translucent with the selected late skins and alpha`() { + val model = Model() + try { + val instance = ModelInstance(model).apply { materials.add(Material()); userData = Md2CustomData.empty() } + val effect = ExplosionMd2Effect(instance, ExplosionType.POLY, 0, 100, 0, 15, Vector3.Zero, fullBright = true) + effect.update(800, 0f) + assertFalse(instance.materials.first().has(BlendingAttribute.Type)) + assertEquals(4, (instance.userData as Md2CustomData).skinIndex) + effect.update(1300, 0f) + assertEquals(6, (instance.userData as Md2CustomData).skinIndex) + assertEquals(3f / 16f, (instance.materials.first().get(BlendingAttribute.Type) as BlendingAttribute).opacity) + } finally { model.dispose() } + } + + companion object { + @BeforeAll @JvmStatic fun setup() { GdxNativesLoader.load(); RenderTuningCvars.register() } + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/GdxParticleRuntimeTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/GdxParticleRuntimeTest.kt new file mode 100644 index 000000000..2ecd91d84 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/GdxParticleRuntimeTest.kt @@ -0,0 +1,148 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.math.Vector3 +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +/** Engine behavior with the real libGDX controller/emitter; no GL, Cvars, globals or assets. */ +class GdxParticleRuntimeTest { + private data class Sample(val mode: ParticleRenderMode, val blend: ParticleBlendMode, + val position: Vector3, val color: Color, val size: Float) + + private fun samples(runtime: GdxParticleRuntime, nowMs: Int): List = buildList { + runtime.submit(nowMs, ParticleSink { mode, blend, x, y, z, red, green, blue, alpha, size -> + add(Sample(mode, blend, Vector3(x, y, z), Color(red, green, blue, alpha), size)) + }) + } + + private fun movingParticle() = GdxParticleRuntime({ 10 }).apply { + emit(Vector3(1f, 2f, 3f), Vector3(4f, -2f, 8f), Vector3(2f, 3f, -4f), Color.WHITE, + size = 1f, lifetimeMillis = 2000, nowMs = 1000) + } + + @Test fun `explicit motion acts on every axis and fades over the emitter lifetime`() { + val runtime = movingParticle() + try { + val result = samples(runtime, 1500).single() + assertEquals(Vector3(3.25f, 1.375f, 6.5f), result.position) + assertEquals(.75f, result.color.a, .00001f) + } finally { runtime.dispose() } + } + + @Test fun `update frequency does not materially change motion and a paused clock freezes it`() { + val singleStep = movingParticle() + val manySteps = movingParticle() + try { + singleStep.update(2000) + for (now in 1010..2000 step 10) manySteps.update(now) + val expected = samples(singleStep, 2000).single() + val actual = samples(manySteps, 2000).single() + assertTrue(expected.position.epsilonEquals(actual.position, .001f)) + assertEquals(expected.color.a, actual.color.a, .0001f) + repeat(100) { manySteps.update(2000) } + assertEquals(actual, samples(manySteps, 2000).single()) + } finally { singleStep.dispose(); manySteps.dispose() } + } + + @Test fun `particles are not aged before birth and short pulses survive their first frame`() { + val runtime = GdxParticleRuntime({ 10 }) + try { + runtime.update(0) + runtime.emit(Vector3.Zero, Vector3.Zero, Vector3.Zero, Color.RED, + size = 1f, lifetimeMillis = 1, nowMs = 1000) + runtime.update(1000) + assertEquals(1, samples(runtime, 1000).size) + assertTrue(samples(runtime, 1001).isEmpty()) + assertEquals(0, runtime.liveCount) + } finally { runtime.dispose() } + } + + @Test fun `existing particles reach the emission time before a new particle is added`() { + val runtime = GdxParticleRuntime({ 10 }) + try { + runtime.emit(Vector3.Zero, Vector3.X, Vector3.Zero, Color.RED, 1f, 1000, 0) + runtime.emit(Vector3.Zero, Vector3.X, Vector3.Zero, Color.BLUE, 1f, 1000, 500) + val particles = samples(runtime, 500).sortedBy { it.position.x } + assertEquals(0f, particles[0].position.x) + assertEquals(1f, particles[0].color.a) + assertEquals(.5f, particles[1].position.x, .00001f) + assertEquals(.5f, particles[1].color.a, .00001f) + runtime.update(1000) + assertEquals(1, runtime.liveCount) + assertEquals(Color.BLUE.b, samples(runtime, 1000).single().color.b) + } finally { runtime.dispose() } + } + + @Test fun `lifetime expiry is independent of final opacity`() { + val runtime = GdxParticleRuntime({ 10 }) + try { + runtime.emit(Vector3.Zero, Vector3.Zero, Vector3.Zero, Color.WHITE, + 1f, 100, 0, startAlpha = .2f, endAlpha = .8f) + assertEquals(.5f, samples(runtime, 50).single().color.a, .00001f) + assertEquals(1, samples(runtime, 99).size) + assertTrue(samples(runtime, 100).isEmpty()) + } finally { runtime.dispose() } + } + + @Test fun `budgets reject excess emission without destroying particles when reduced`() { + var budget = 2 + val runtime = GdxParticleRuntime({ budget }) + try { + fun emit(nowMs: Int) = runtime.emit(Vector3.Zero, Vector3.Zero, Vector3.Zero, Color.WHITE, 1f, 100, nowMs) + assertTrue(emit(0)); assertTrue(emit(0)); assertFalse(emit(0)) + budget = 1 + assertFalse(emit(0)) + assertEquals(2, runtime.liveCount) + assertTrue(emit(100)) // libGDX expiry frees space before the budget check. + budget = 0 + assertFalse(emit(100)) + assertEquals(1, runtime.liveCount) + runtime.update(200) + assertEquals(0, runtime.liveCount) + } finally { runtime.dispose() } + } + + @Test fun `channel growth and removal retain copied motion colors sizes and rendering modes`() { + val runtime = GdxParticleRuntime({ 10 }, initialCapacity = 1) + try { + val position = Vector3() + val velocity = Vector3() + val acceleration = Vector3() + val color = Color() + repeat(6) { id -> + runtime.emit(position.set(id.toFloat(), 0f, 0f), velocity.set(id.toFloat(), 0f, 0f), + acceleration.set(0f, id.toFloat(), 0f), color.set(id / 10f, .2f, .3f, 1f), + size = id + 1f, lifetimeMillis = if (id % 2 == 0) 10 else 1000, nowMs = 0, + startAlpha = .8f, endAlpha = .2f, + mode = ParticleRenderMode.BILLBOARD_SPRITE, blend = ParticleBlendMode.ADDITIVE) + } + position.setZero(); velocity.setZero(); acceleration.setZero(); color.set(Color.BLACK) + val survivors = samples(runtime, 500).sortedBy { it.size } + assertEquals(listOf(2f, 4f, 6f), survivors.map { it.size }) + for ((sample, id) in survivors.zip(listOf(1, 3, 5))) { + assertEquals(ParticleRenderMode.BILLBOARD_SPRITE, sample.mode) + assertEquals(ParticleBlendMode.ADDITIVE, sample.blend) + assertEquals(id / 10f, sample.color.r) + assertEquals(.2f, sample.color.g) + assertEquals(.3f, sample.color.b) + assertEquals(.5f, sample.color.a, .00001f) + assertEquals(Vector3(id * 1.5f, id * .125f, 0f), sample.position) + } + runtime.update(1000) + assertEquals(0, runtime.liveCount) + } finally { runtime.dispose() } + } + + @Test fun `clear removes pending state and permits a new playback clock`() { + val runtime = movingParticle() + try { + runtime.update(1500) + runtime.clear() + assertEquals(0, runtime.liveCount) + runtime.emit(Vector3.Zero, Vector3.X, Vector3.Zero, Color.WHITE, 1f, 1000, 0) + assertEquals(.5f, samples(runtime, 500).single().position.x, .00001f) + } finally { runtime.dispose() } + assertEquals(0, runtime.liveCount) + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/HeatbeamEffectsTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/HeatbeamEffectsTest.kt new file mode 100644 index 000000000..2fb9d359c --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/HeatbeamEffectsTest.kt @@ -0,0 +1,127 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.PerspectiveCamera +import com.badlogic.gdx.math.Vector3 +import com.badlogic.gdx.utils.GdxNativesLoader +import jake2.qcommon.Defines +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.BeamTEMessage +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test + +class HeatbeamEffectsTest { + private data class Segment(val kind: BeamKind, val origin: Vector3, val pitch: Float, val yaw: Float, val roll: Float) + private fun camera(fov: Float = 90f) = PerspectiveCamera(fov, 800f, 600f).apply { + direction.set(1f, 0f, 0f); up.set(0f, 0f, 1f); update() + } + private fun message(owner: Int = 1, monster: Boolean = false, end: Vector3f = Vector3f(100f, 0f, 0f)) = + BeamTEMessage(if (monster) Defines.TE_MONSTER_HEATBEAM else Defines.TE_HEATBEAM, + owner, Vector3f.zero, end) + private fun segments(effect: HeatbeamEffects, now: Int, local: Int? = null, view: PerspectiveCamera = camera(), + offset: Vector3 = Vector3.Zero, hand: Int = 0): List = buildList { + effect.submit(now, local, view, offset, hand, BeamSegmentSink { kind, x, y, z, pitch, yaw, roll -> + add(Segment(kind, Vector3(x, y, z), pitch, yaw, roll)) + }) + } + private fun effect() = HeatbeamEffects(HeatbeamParticleSink { _, _, _, _, _, _, _ -> }) + + @Test fun `local muzzle follows camera gun offsets and handedness without needing another message`() { + val effect = effect() + effect.process(message(), 0) + val view = camera().apply { position.set(10f, 20f, 30f) } + val gun = Vector3(1f, 2f, 3f) + assertEquals(Vector3(18f, 20f, 30f), segments(effect, 0, 1, view, gun).first().origin) + assertEquals(Vector3(18f, 24f, 30f), segments(effect, 0, 1, view, gun, 1).first().origin) + assertEquals(Vector3(18f, 22f, 29f), segments(effect, 0, 1, view, gun, 2).first().origin) + view.position.add(10f, 0f, 0f) + assertEquals(Vector3(28f, 20f, 30f), segments(effect, 50, 1, view, gun).first().origin) + view.direction.set(0f, 1f, 0f) + assertEquals(Vector3(23f, 29f, 30f), segments(effect, 100, 1, view, gun).first().origin) + } + + @Test fun `monster and remote-player beams use recorded origins and the remote model frame`() { + val effect = effect() + effect.process(message(monster = true), 0) + val monster = segments(effect, 0, 1).first() + assertEquals(Vector3.Zero, monster.origin) + assertEquals(BeamKind.HEATBEAM_MONSTER, monster.kind) + effect.process(message(owner = 2), 0) + assertTrue(segments(effect, 0, 1).all { it.kind == BeamKind.HEATBEAM_MONSTER }) + } + + @Test fun `muzzle uses the active camera projection across field of view changes`() { + val effect = effect() + effect.process(message(), 0) + val projections = listOf(60f, 90f, 120f).map { fov -> + val view = camera(fov) + val first = segments(effect, 0, 1, view).first() + assertEquals(BeamKind.HEATBEAM_PLAYER, first.kind) + assertEquals(Vector3(7f, -2f, -3f), first.origin) + view.project(first.origin.cpy(), 0f, 0f, 800f, 600f).x + } + assertTrue(projections.all { it > 400f }) + assertTrue(projections[0] > projections[1] && projections[1] > projections[2]) + } + + @Test fun `refresh and pause keep one beam and rate-limit particle pulses`() { + var pulses = 0 + val effect = HeatbeamEffects(HeatbeamParticleSink { _, _, _, _, _, _, _ -> pulses++ }) + effect.process(message(), 0) + val initial = segments(effect, 0) + repeat(30) { assertEquals(initial, segments(effect, 0)) } + assertEquals(1, pulses) + effect.process(message(end = Vector3f(200f, 0f, 0f)), 0) + segments(effect, 0) + assertEquals(1, pulses) + segments(effect, 50) + assertEquals(2, pulses) + effect.process(message(), 150) + assertEquals(1, effect.activeCount) + assertTrue(segments(effect, 200).isNotEmpty()) + assertTrue(segments(effect, 350).isEmpty()) + } + + @Test fun `short vertical and zero length beams stay finite and state and geometry stay bounded`() { + val effect = HeatbeamEffects(HeatbeamParticleSink { _, _, _, _, _, _, _ -> }, maximumBeams = 1, maximumSegmentsPerBeam = 4) + effect.process(message(end = Vector3f(0f, 0f, 10f)), 0) + val short = segments(effect, 0).single() + assertEquals(90f, short.pitch, .001f) + assertTrue(short.yaw.isFinite() && short.roll.isFinite()) + effect.process(message(end = Vector3f.zero), 0) + assertTrue(segments(effect, 0).isEmpty()) + effect.process(message(end = Vector3f(4000f, 0f, 0f)), 0) + effect.process(message(owner = 2), 0) + assertEquals(1, effect.activeCount) + assertEquals(4, segments(effect, 0).size) + effect.clear() + assertEquals(0, effect.activeCount) + assertTrue(segments(effect, 0).isEmpty()) + } + + @Test fun `the real particle runtime stays bounded during refresh pause and teardown`() { + val runtime = GdxParticleRuntime({ 1000 }) + try { + val effect = HeatbeamEffects(HeatbeamParticles(runtime, { com.badlogic.gdx.graphics.Color.WHITE })) + effect.process(message(), 0) + val view = camera() + segments(effect, 0, 1, view) + val firstCount = runtime.liveCount + assertTrue(firstCount in 1..100) + repeat(100) { segments(effect, 0, 1, view) } + assertEquals(firstCount, runtime.liveCount) + segments(effect, 50, 1, view) + assertTrue(runtime.liveCount <= 200) + effect.process(message(), 50) + repeat(100) { segments(effect, 50, 1, view) } + assertTrue(runtime.liveCount <= 200) + effect.clear() + segments(effect, 150, 1, view) + runtime.update(150) + assertEquals(0, effect.activeCount) + assertEquals(0, runtime.liveCount) + } finally { runtime.dispose() } + } + + companion object { @BeforeAll @JvmStatic fun loadMathNatives() { GdxNativesLoader.load() } } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/HeatbeamParticlesTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/HeatbeamParticlesTest.kt new file mode 100644 index 000000000..377057cca --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/HeatbeamParticlesTest.kt @@ -0,0 +1,79 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.math.Vector3 +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import kotlin.math.sqrt +import kotlin.random.Random + +class HeatbeamParticlesTest { + private data class Sample(val position: Vector3, val alpha: Float) + private class Fixture(private val budget: Int = 1000) : AutoCloseable { + val runtime = GdxParticleRuntime({ budget }) + val paletteIndices = mutableListOf() + val effect = HeatbeamParticles(runtime, { index -> paletteIndices += index; Color.WHITE }, Random(7)) + fun emit(local: Boolean, length: Float = 500f, now: Int = 0) { + effect.emit(local, Vector3.Zero, Vector3.X, Vector3(0f, -1f, 0f), Vector3.Z, length, now) + } + fun samples(now: Int): List = buildList { + runtime.submit(now, ParticleSink { _, _, x, y, z, _, _, _, alpha, _ -> add(Sample(Vector3(x, y, z), alpha)) }) + } + override fun close() = runtime.dispose() + } + + @Test fun `player rings stay near the beam origin use a narrow radius and fade as short pulses`() { + Fixture().use { fixture -> + fixture.emit(local = true) + val particles = fixture.samples(0) + assertEquals(100, particles.size) + assertTrue(fixture.paletteIndices.all { it in 216..223 }) + assertTrue(particles.all { it.position.x in 8f..160f && it.alpha == .5f }) + assertEquals(5, particles.map { it.position.x }.toSet().size) + assertTrue(particles.all { sqrt(it.position.y * it.position.y + it.position.z * it.position.z) <= 1.51f }) + assertTrue(fixture.samples(50).all { it.alpha in 0f..0.5f }) + assertTrue(fixture.samples(75).isEmpty()) + } + } + + @Test fun `rings respect beam range and advance along the beam with presentation time`() { + Fixture().use { fixture -> + fixture.emit(local = true, length = 5f) + assertEquals(0, fixture.runtime.liveCount) + fixture.emit(local = true, length = 25f) + assertEquals(20, fixture.runtime.liveCount) + assertEquals(8f, fixture.samples(0).first().position.x) + fixture.runtime.clear() + fixture.emit(local = true, length = 25f, now = 50) + assertEquals(12.8f, fixture.samples(50).first().position.x, .001f) + assertTrue(fixture.samples(50).all { it.position.x <= 25f }) + } + } + + @Test fun `monster plasma forms a bounded shell instead of a forward trail`() { + Fixture().use { fixture -> + fixture.emit(local = false) + val particles = fixture.samples(0) + assertEquals(40, particles.size) + assertTrue(particles.all { kotlin.math.abs(it.position.len() - 10f) < .001f && it.alpha == 1f }) + assertTrue(fixture.paletteIndices.all { it == 0xE0 }) + assertEquals(particles.map { it.position }, fixture.samples(50).map { it.position }) + assertTrue(fixture.samples(75).isEmpty()) + } + } + + @Test fun `particle budgets cap rings and shells and disable emission at zero`() { + for (local in listOf(true, false)) { + Fixture(budget = 3).use { fixture -> + fixture.emit(local) + assertEquals(3, fixture.runtime.liveCount) + fixture.emit(local) + assertEquals(3, fixture.runtime.liveCount) + } + Fixture(budget = 0).use { fixture -> + fixture.emit(local) + assertEquals(0, fixture.runtime.liveCount) + } + } + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ImpactEffectsTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ImpactEffectsTest.kt new file mode 100644 index 000000000..e8795209b --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ImpactEffectsTest.kt @@ -0,0 +1,138 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.math.Vector3 +import jake2.qcommon.Defines.* +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.* +import jake2.qcommon.network.protocol.DirectionCodec +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import kotlin.random.Random + +class ImpactEffectsTest { + private class FixedBits(private val value: Int) : Random() { + override fun nextBits(bitCount: Int) = if (bitCount == 0) 0 else value and (-1 ushr (32 - bitCount)) + } + private class Fixture(budget: Int = 1000, random: Random = Random(7)) : AutoCloseable { + val runtime = GdxParticleRuntime({ budget }) + val colors = mutableListOf() + val sounds = mutableListOf() + val effects = ImpactEffects(runtime, { colors += it; Color.WHITE }, sounds::add, random) + fun positions(now: Int): List = buildList { + runtime.submit(now, ParticleSink { _, _, x, y, z, _, _, _, _, _ -> add(Vector3(x, y, z)) }) + } + override fun close() = runtime.dispose() + } + private val direction = DirectionCodec.index(Vector3f.unitX) + private fun impact(style: Int) = PointDirectionTEMessage(style, Vector3f.zero, direction) + + @Test fun `blaster variants emit normal-directed bursts in their own palette with the impact sound`() { + for ((style, palette) in listOf(TE_BLASTER to 0xE0, TE_BLASTER2 to 0xD0, TE_FLECHETTE to 0x6F)) Fixture().use { fixture -> + assertTrue(fixture.effects.process(impact(style), 0)) + val first = fixture.positions(0) + assertTrue(first.isNotEmpty()) + assertTrue(first.all { it.x in -4f..19f && it.y in -4f..4f && it.z in -4f..4f }) + assertTrue(fixture.colors.all { it in palette..palette + 7 }) + for ((before, after) in first.zip(fixture.positions(100))) assertTrue(after.x - before.x in -1.001f..7.001f) + assertEquals(listOf("sound/weapons/lashit.wav"), fixture.sounds.map { it.path }) + assertTrue(fixture.positions(801).isEmpty()) + } + Fixture().use { fixture -> + fixture.effects.process(TrailTEMessage(TE_BLUEHYPERBLASTER, Vector3f.zero, Vector3f.unitX), 0) + assertTrue(fixture.positions(0).isNotEmpty()) + assertTrue(fixture.colors.all { it in 0xE0..0xE7 }) + assertTrue(fixture.sounds.isEmpty()) + } + } + + @Test fun `ordinary impacts scatter positions along the normal and velocities independently`() { + Fixture().use { fixture -> + fixture.effects.process(impact(TE_GUNSHOT), 0) + val first = fixture.positions(0) + assertTrue(first.all { it.x in -4f..35f && it.y in -4f..4f && it.z in -4f..4f }) + val motion = first.zip(fixture.positions(100)).map { (before, after) -> after.x - before.x } + assertTrue(motion.any { it > 0f } && motion.any { it < 0f }) + assertTrue(motion.all { it in -2.001f..2.001f }) + } + } + + @Test fun `all splash families respect zero small and large transmitted counts without private caps`() { + for (style in SplashTEMessage.SUBTYPES) for (count in listOf(0, 1, 64, 128, 255)) Fixture().use { fixture -> + val parameter = if (style == TE_SPLASH) 2 else 200 + fixture.effects.process(SplashTEMessage(style, count, Vector3f.zero, direction, parameter), 0) + assertEquals(count, fixture.runtime.liveCount, "Style $style count $count") + val palette = if (style == TE_SPLASH) 0xB0 else 200 + assertTrue(fixture.colors.all { if (style == TE_TUNNEL_SPARKS) it == palette else it in palette..palette + 7 }) + } + } + + @Test fun `tunnel sparks accelerate upwards while laser and welding sparks fall`() { + for (style in listOf(TE_TUNNEL_SPARKS, TE_LASER_SPARKS, TE_WELDING_SPARKS)) Fixture().use { fixture -> + fixture.effects.process(SplashTEMessage(style, 3, Vector3f.zero, direction, 20), 0) + val first = fixture.positions(0) + val second = fixture.positions(100) + val third = fixture.positions(200) + for (index in first.indices) { + val curvature = third[index].z - 2f * second[index].z + first[index].z + assertTrue(if (style == TE_TUNNEL_SPARKS) curvature > 0f else curvature < 0f) + } + } + } + + @Test fun `gunshot ricochets are occasional and particle budgets do not mute audio`() { + for (style in listOf(TE_GUNSHOT, TE_BULLET_SPARKS)) { + val heard = mutableListOf() + for (roll in 0..15) Fixture(budget = 0, random = FixedBits(roll)).use { fixture -> + fixture.effects.process(impact(style), 0) + assertEquals(if (roll in 1..3) 1 else 0, fixture.sounds.size) + assertTrue(fixture.sounds.all { it.origin == Vector3f.zero && it.attenuation == ATTN_NORM.toFloat() }) + heard += fixture.sounds.map { it.path } + } + assertEquals(listOf("sound/world/ric1.wav", "sound/world/ric2.wav", "sound/world/ric3.wav"), heard) + } + Fixture(budget = 0).use { fixture -> + fixture.effects.process(impact(TE_SPARKS), 0) + fixture.effects.process(impact(TE_SHOTGUN), 0) + assertTrue(fixture.sounds.isEmpty()) + fixture.effects.process(impact(TE_BLASTER), 0) + assertEquals("sound/weapons/lashit.wav", fixture.sounds.single().path) + } + } + + @Test fun `spark splashes use weighted sound variants with static attenuation even at zero count`() { + val heard = mutableListOf() + for (roll in 0..3) Fixture(budget = 0, random = FixedBits(roll)).use { fixture -> + fixture.effects.process(SplashTEMessage(TE_SPLASH, 0, Vector3f.zero, direction, SPLASH_SPARKS), 0) + val sound = fixture.sounds.single() + assertEquals(ATTN_STATIC.toFloat(), sound.attenuation) + heard += sound.path + } + assertEquals(listOf("sound/world/spark5.wav", "sound/world/spark6.wav", "sound/world/spark7.wav", "sound/world/spark7.wav"), heard) + } + + @Test fun `chainfist smoke rises without downward acceleration and particles remain budget limited`() { + Fixture().use { fixture -> + fixture.effects.process(PointTEMessage(TE_CHAINFIST_SMOKE, Vector3f.zero), 0) + val first = fixture.positions(0) + assertTrue(first.isNotEmpty()) + assertTrue(fixture.colors.all { it in 0..7 }) + for ((before, after) in first.zip(fixture.positions(100))) assertEquals(2f, after.z - before.z, .001f) + } + Fixture(budget = 3).use { fixture -> + fixture.effects.process(SplashTEMessage(TE_SPLASH, 255, Vector3f.zero, direction, 0), 0) + assertEquals(3, fixture.runtime.liveCount) + } + } + + @Test fun `directional family coverage excludes only heatbeam steam handled by its own controller`() { + for (style in PointDirectionTEMessage.SUBTYPES - setOf(TE_HEATBEAM_SPARKS, TE_HEATBEAM_STEAM)) Fixture(budget = 0).use { + assertTrue(it.effects.process(impact(style), 0), "Style $style") + } + Fixture().use { + assertFalse(it.effects.process(impact(TE_HEATBEAM_STEAM), 0)) + assertFalse(it.effects.process(PointTEMessage(TE_EXPLOSION1, Vector3f.zero), 0)) + assertFalse(it.effects.process(TrailTEMessage(TE_RAILTRAIL, Vector3f.zero, Vector3f.unitX), 0)) + } + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/LaserEffectsTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/LaserEffectsTest.kt new file mode 100644 index 000000000..5b8cd3863 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/LaserEffectsTest.kt @@ -0,0 +1,48 @@ +package org.demoth.cake.stages.ingame.effects + +import jake2.qcommon.Defines +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.TrailTEMessage +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import kotlin.random.Random + +class LaserEffectsTest { + private fun message(end: Vector3f = Vector3f(100f, 0f, 0f)) = TrailTEMessage(Defines.TE_BFG_LASER, Vector3f.zero, end) + private fun beams(effects: LaserEffects, now: Int): List = buildList { effects.submit(now) { add(it) } } + + @Test fun `BFG lasers keep their endpoints palette diameter and alpha until their hundred millisecond expiry`() { + val effects = LaserEffects(Random(7)) + effects.process(message(), 0) + val first = beams(effects, 0).single() + assertEquals(Vector3f.zero, first.origin) + assertEquals(Vector3f(100f, 0f, 0f), first.destination) + assertTrue(first.paletteIndex in 0xD0..0xD3) + assertEquals(4f, first.diameter) + assertEquals(.3f, first.alpha) + repeat(20) { assertEquals(listOf(first), beams(effects, 50)) } + assertEquals(listOf(first), beams(effects, 99)) + assertTrue(beams(effects, 100).isEmpty()) + } + + @Test fun `overlapping pulses are independent bounded and expired capacity is reusable`() { + val effects = LaserEffects(Random(7), capacity = 2) + effects.process(message(), 0) + effects.process(message(Vector3f.unitZ), 50) + effects.process(message(Vector3f.unitY), 50) + assertEquals(2, effects.activeCount) + assertEquals(2, beams(effects, 50).size) + effects.process(message(Vector3f.unitY), 100) + assertEquals(2, effects.activeCount) + assertEquals(setOf(Vector3f.unitZ, Vector3f.unitY), beams(effects, 100).map { it.destination }.toSet()) + effects.clear() + assertTrue(beams(effects, 100).isEmpty()) + } + + @Test fun `zero length and unrelated trail messages create no laser state`() { + val effects = LaserEffects() + assertTrue(effects.process(message(Vector3f.zero), 0)) + assertFalse(effects.process(message().copy(style = Defines.TE_RAILTRAIL), 0)) + assertEquals(0, effects.activeCount) + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/PointParticlesTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/PointParticlesTest.kt new file mode 100644 index 000000000..e78cec498 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/PointParticlesTest.kt @@ -0,0 +1,84 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.math.Vector3 +import jake2.qcommon.Defines +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.PointTEMessage +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import kotlin.random.Random + +class PointParticlesTest { + private class Fixture(budget: Int = 1000) : AutoCloseable { + val runtime = GdxParticleRuntime({ budget }) + val colors = mutableListOf() + val particles = PointParticles(runtime, { colors += it; Color.WHITE }, Random(12)) + fun positions(now: Int): List = buildList { + runtime.submit(now, ParticleSink { _, _, x, y, z, _, _, _, _, _ -> add(Vector3(x, y, z)) }) + } + override fun close() = runtime.dispose() + } + + @Test fun `boss teleport creates a tall radial column in its palette instead of only sound`() { + Fixture().use { fixture -> + fixture.particles.process(PointTEMessage(Defines.TE_BOSSTPORT, Vector3f.zero), 0) + val start = fixture.positions(0) + assertTrue(start.isNotEmpty()) + assertTrue(start.all { it.z in 8f..98f && it.x * it.x + it.y * it.y <= 1024.01f }) + assertTrue(fixture.colors.all { it in setOf(16, 104, 168, 144) }) + val later = fixture.positions(100) + assertTrue(start.zip(later).all { (before, after) -> after.z < before.z }) + assertTrue(start.zip(later).any { (before, after) -> after.x * after.x + after.y * after.y > before.x * before.x + before.y * before.y }) + assertTrue(fixture.positions(2601).isEmpty()) + } + } + + @Test fun `tracker emits an owned black particle cloud with independent motion and fade`() { + Fixture().use { fixture -> + fixture.particles.process(PointTEMessage(Defines.TE_TRACKER_EXPLOSION, Vector3f(100f, 200f, 300f)), 0) + val initial = fixture.positions(0) + assertTrue(initial.isNotEmpty()) + assertTrue(initial.all { it.x in 84f..116f && it.y in 184f..216f && it.z in 284f..316f }) + assertTrue(fixture.colors.all { it == 0 }) + val later = fixture.positions(100) + assertNotEquals(initial, later) + repeat(10) { assertEquals(later, fixture.positions(100)) } + assertTrue(fixture.positions(2001).isEmpty()) + } + } + + @Test fun `big no-particle and plain explosions omit particles while other explosions retain them`() { + for (style in listOf(Defines.TE_EXPLOSION1_BIG, Defines.TE_EXPLOSION1_NP, Defines.TE_PLAIN_EXPLOSION)) Fixture().use { + assertTrue(it.particles.process(PointTEMessage(style, Vector3f.zero), 0)) + assertEquals(0, it.runtime.liveCount) + assertTrue(it.colors.isEmpty()) + } + for (style in listOf(Defines.TE_EXPLOSION1, Defines.TE_EXPLOSION2, Defines.TE_GRENADE_EXPLOSION, + Defines.TE_GRENADE_EXPLOSION_WATER, Defines.TE_ROCKET_EXPLOSION, Defines.TE_ROCKET_EXPLOSION_WATER, + Defines.TE_PLASMA_EXPLOSION)) Fixture().use { + assertTrue(it.particles.process(PointTEMessage(style, Vector3f.zero), 0)) + assertTrue(it.runtime.liveCount > 0) + assertTrue(it.colors.all { color -> color in 0xE0..0xE7 }) + } + } + + @Test fun `burst work respects disabled and exhausted particle budgets and ignores unrelated point effects`() { + for (style in listOf(Defines.TE_BOSSTPORT, Defines.TE_TRACKER_EXPLOSION)) { + Fixture(3).use { fixture -> + fixture.particles.process(PointTEMessage(style, Vector3f.zero), 0) + assertEquals(3, fixture.runtime.liveCount) + fixture.particles.process(PointTEMessage(style, Vector3f.zero), 0) + assertEquals(3, fixture.runtime.liveCount) + } + Fixture(0).use { fixture -> + fixture.particles.process(PointTEMessage(style, Vector3f.zero), 0) + assertEquals(0, fixture.runtime.liveCount) + } + } + Fixture().use { fixture -> + assertFalse(fixture.particles.process(PointTEMessage(Defines.TE_BFG_EXPLOSION, Vector3f.zero), 0)) + assertEquals(0, fixture.runtime.liveCount) + } + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ShellEffectsTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ShellEffectsTest.kt new file mode 100644 index 000000000..ff2eb8747 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/ShellEffectsTest.kt @@ -0,0 +1,108 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.math.Vector3 +import jake2.qcommon.Defines +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.PointTEMessage +import jake2.qcommon.network.messages.server.WidowBeamOutTEMessage +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import kotlin.random.Random + +class ShellEffectsTest { + private class Fixture(budget: Int = 10000, maximumSustains: Int = 8) : AutoCloseable { + val particles = GdxParticleRuntime({ budget }) + val colors = mutableListOf() + val effects = ShellEffects(particles, { index -> colors += index; Color.WHITE }, Random(7), maximumSustains) + fun advance(nowMs: Int) { particles.update(nowMs); effects.update(nowMs) } + fun positions(nowMs: Int): List = buildList { + particles.submit(nowMs, ParticleSink { _, _, x, y, z, _, _, _, _, _ -> add(Vector3(x, y, z)) }) + } + override fun close() { effects.clear(); particles.dispose() } + } + + @Test fun `widow beam-out expands over its lifetime using its palette and freezes on a paused clock`() { + Fixture().use { fixture -> + fixture.effects.process(WidowBeamOutTEMessage(7, Vector3f.zero), 0) + fixture.advance(0) + val count = fixture.particles.liveCount + assertTrue(count > 0) + assertTrue(fixture.positions(0).all { it.isZero }) + fixture.advance(1050) + val halfway = fixture.positions(1050) + assertEquals(count, halfway.size) // A stall produces one current shell, not a backlog of pulses. + assertTrue(halfway.all { kotlin.math.abs(it.len() - 22.5f) < .001f }) + assertTrue(fixture.colors.all { it in setOf(16, 104, 168, 144) }) + repeat(100) { fixture.advance(1050) } + assertEquals(halfway, fixture.positions(1050)) + fixture.advance(2100) + assertEquals(0, fixture.effects.activeSustainCount) + assertEquals(0, fixture.particles.liveCount) + } + } + + @Test fun `nuke blast expands independently and expires after one second`() { + Fixture().use { fixture -> + fixture.effects.process(PointTEMessage(Defines.TE_NUKEBLAST, Vector3f.zero), 0) + fixture.advance(500) + val halfway = fixture.positions(500) + assertTrue(halfway.isNotEmpty()) + assertTrue(halfway.all { kotlin.math.abs(it.len() - 100f) < .001f }) + assertTrue(fixture.colors.all { it in setOf(110, 112, 114, 116) }) + fixture.advance(1000) + assertEquals(0, fixture.effects.activeSustainCount) + assertTrue(fixture.positions(1000).isEmpty()) + } + } + + @Test fun `nukes coexist with each other and with wire-identified widow effects`() { + Fixture().use { fixture -> + fixture.effects.process(WidowBeamOutTEMessage(21000, Vector3f.zero), 0) + fixture.effects.process(PointTEMessage(Defines.TE_NUKEBLAST, Vector3f(1000f, 0f, 0f)), 0) + fixture.effects.process(PointTEMessage(Defines.TE_NUKEBLAST, Vector3f(2000f, 0f, 0f)), 0) + fixture.advance(0) + assertEquals(3, fixture.effects.activeSustainCount) + val groups = fixture.positions(0).groupBy { it.x } + assertEquals(setOf(0f, 1000f, 2000f), groups.keys) + assertTrue(groups.values.all { it.isNotEmpty() }) + } + } + + @Test fun `repeated widow identifiers replace their origin and refresh lifetime`() { + Fixture().use { fixture -> + fixture.effects.process(WidowBeamOutTEMessage(7, Vector3f.zero), 0) + fixture.advance(1000) + fixture.effects.process(WidowBeamOutTEMessage(7, Vector3f(100f, 0f, 0f)), 1100) + fixture.advance(1100) + assertEquals(1, fixture.effects.activeSustainCount) + assertTrue(fixture.positions(1100).all { it == Vector3(100f, 0f, 0f) }) + fixture.advance(2200) + assertEquals(1, fixture.effects.activeSustainCount) + fixture.advance(3200) + assertEquals(0, fixture.effects.activeSustainCount) + } + } + + @Test fun `sustain capacity particle budgets and teardown bound all future work`() { + Fixture(budget = 3, maximumSustains = 1).use { fixture -> + fixture.effects.process(WidowBeamOutTEMessage(1, Vector3f.zero), 0) + fixture.effects.process(PointTEMessage(Defines.TE_NUKEBLAST, Vector3f.zero), 0) + fixture.advance(0) + assertEquals(1, fixture.effects.activeSustainCount) + assertEquals(3, fixture.particles.liveCount) + fixture.effects.clear() + fixture.advance(100) + assertEquals(0, fixture.effects.activeSustainCount) + assertEquals(0, fixture.particles.liveCount) + } + Fixture(budget = 0).use { fixture -> + assertFalse(fixture.effects.process(PointTEMessage(Defines.TE_EXPLOSION1, Vector3f.zero), 0)) + fixture.effects.process(PointTEMessage(Defines.TE_NUKEBLAST, Vector3f.zero), 0) + fixture.advance(0) + assertEquals(0, fixture.particles.liveCount) + fixture.advance(2000) + assertEquals(0, fixture.effects.activeSustainCount) + } + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/SteamEffectsTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/SteamEffectsTest.kt new file mode 100644 index 000000000..5fb2a7af9 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/SteamEffectsTest.kt @@ -0,0 +1,164 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.math.Vector3 +import jake2.qcommon.Defines +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.PointDirectionTEMessage +import jake2.qcommon.network.messages.server.SteamTEMessage +import jake2.qcommon.network.protocol.DirectionCodec +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import kotlin.random.Random + +/** Real particle-controller behavior with synthetic messages, palette values and presentation clocks. */ +class SteamEffectsTest { + private data class Sample(val position: Vector3, val alpha: Float) + + private class Fixture(initialBudget: Int = 1000) : AutoCloseable { + var budget = initialBudget + val particles = GdxParticleRuntime({ budget }) + val paletteIndices = mutableListOf() + val steam = SteamEffects(particles, { index -> + paletteIndices += index + Color(index / 255f, .5f, .5f, 1f) + }, random = Random(12), maximumSustains = 2) + + fun advance(now: Int) { + particles.update(now) + steam.update(now) + } + + fun samples(now: Int): List = buildList { + particles.submit(now, ParticleSink { _, _, x, y, z, _, _, _, alpha, _ -> add(Sample(Vector3(x, y, z), alpha)) }) + } + + override fun close() { steam.clear(); particles.dispose() } + } + + private val origin = Vector3f(10f, 20f, 30f) + private val direction = DirectionCodec.index(Vector3f.unitX) + private fun message(id: Int = -1, count: Int = 4, magnitude: Int = 100, duration: Int? = null) = + SteamTEMessage(id, count, origin, direction, color = 100, magnitude = magnitude, durationMillis = duration) + + @Test fun `instant steam honors count palette direction magnitude and particle lifetime`() { + Fixture().use { fixture -> + fixture.steam.process(message(), 0) + assertEquals(4, fixture.particles.liveCount) + assertEquals(0, fixture.steam.activeSustainCount) + assertEquals(4, fixture.paletteIndices.size) + assertTrue(fixture.paletteIndices.all { it in 100..107 }) + val initial = fixture.samples(0) + assertTrue(initial.all { it.position.x in 0f..20f && it.position.y in 10f..30f && it.position.z in 20f..40f }) + val advanced = fixture.samples(100) + for ((before, after) in initial.zip(advanced)) { + assertEquals(10f, after.position.x - before.position.x, .001f) + assertTrue(after.alpha in 0f..1f && after.alpha < before.alpha) + } + fixture.advance(801) + assertEquals(0, fixture.particles.liveCount) + } + } + + @Test fun `zero and signed magnitudes preserve directed motion and acceleration`() { + for (magnitude in listOf(-120, 0, 120)) Fixture().use { fixture -> + fixture.steam.process(message(count = 1, magnitude = magnitude), 0) + val first = fixture.samples(0).single().position + val second = fixture.samples(100).single().position + val third = fixture.samples(200).single().position + assertEquals(magnitude * .1f, second.x - first.x, .001f) + // Steam falls under physical acceleration, rather than using the old generic impact burst. + assertEquals(-.4f, third.z - 2f * second.z + first.z, .001f) + } + } + + @Test fun `vertical directions produce finite particles with motion along the supplied normal`() { + Fixture().use { fixture -> + fixture.steam.process(message(count = 1).copy(directionIndex = DirectionCodec.index(Vector3f.unitZ)), 0) + val first = fixture.samples(0).single().position + val second = fixture.samples(100).single().position + assertTrue(second.x.isFinite() && second.y.isFinite() && second.z.isFinite()) + assertEquals(9.8f, second.z - first.z, .001f) + } + } + + @Test fun `sustained steam emits every hundred milliseconds without duplicating paused updates`() { + Fixture().use { fixture -> + fixture.steam.process(message(id = 7, count = 2, duration = 250), 0) + assertEquals(0, fixture.particles.liveCount) + fixture.advance(0) + repeat(30) { fixture.advance(0) } + fixture.advance(99) + assertEquals(2, fixture.particles.liveCount) + fixture.advance(100) + assertEquals(4, fixture.particles.liveCount) + fixture.advance(200) + assertEquals(6, fixture.particles.liveCount) + fixture.advance(250) + assertEquals(0, fixture.steam.activeSustainCount) + fixture.advance(1100) + assertEquals(0, fixture.particles.liveCount) + } + } + + @Test fun `refresh replaces an identifier and clear stops future bursts while particles finish fading`() { + Fixture().use { fixture -> + fixture.steam.process(message(id = 1, count = 2, duration = 1000), 0) + fixture.advance(0) + fixture.steam.process(message(id = 1, count = 3, duration = 1000).copy(position = Vector3f(200f, 0f, 0f)), 50) + fixture.advance(50) + assertEquals(1, fixture.steam.activeSustainCount) + assertEquals(5, fixture.particles.liveCount) + assertEquals(3, fixture.samples(50).count { it.position.x in 190f..210f }) + fixture.steam.clear() + fixture.advance(150) + assertEquals(0, fixture.steam.activeSustainCount) + assertEquals(5, fixture.particles.liveCount) + fixture.advance(1000) + assertEquals(0, fixture.particles.liveCount) + } + } + + @Test fun `stalls skip catchup bursts expired sustains stop and active effects stay bounded`() { + Fixture().use { fixture -> + for (id in 1..3) fixture.steam.process(message(id = id, count = 1, duration = 2000), 0) + assertEquals(2, fixture.steam.activeSustainCount) + fixture.advance(0) + fixture.advance(1025) + assertEquals(2, fixture.particles.liveCount) // Only one fresh burst per admitted sustain. + repeat(20) { fixture.advance(1025) } + assertEquals(2, fixture.particles.liveCount) + fixture.advance(3000) + assertEquals(0, fixture.particles.liveCount) + assertEquals(0, fixture.steam.activeSustainCount) + } + } + + @Test fun `particle budget limits bursts and zero count emits nothing`() { + Fixture(initialBudget = 3).use { fixture -> + fixture.steam.process(message(count = 0), 0) + assertEquals(0, fixture.particles.liveCount) + fixture.steam.process(message(count = 255), 0) + assertEquals(3, fixture.particles.liveCount) + fixture.budget = 0 + fixture.advance(1000) + fixture.steam.process(message(), 1000) + assertEquals(0, fixture.particles.liveCount) + } + } + + @Test fun `heatbeam sparks and steam use directional steam particles with their own counts and palette`() { + for ((style, count, color) in listOf(Triple(Defines.TE_HEATBEAM_SPARKS, 50, 0x08), Triple(Defines.TE_HEATBEAM_STEAM, 20, 0xE0))) { + Fixture().use { fixture -> + assertTrue(fixture.steam.processImpact(PointDirectionTEMessage(style, origin, direction), 0)) + assertEquals(count, fixture.particles.liveCount) + assertTrue(fixture.paletteIndices.all { it in color..color + 7 }) + val initial = fixture.samples(0) + val advanced = fixture.samples(100) + for ((first, second) in initial.zip(advanced)) assertEquals(6f, second.position.x - first.position.x, .001f) + assertFalse(fixture.steam.processImpact(PointDirectionTEMessage(Defines.TE_GUNSHOT, origin, direction), 100)) + assertEquals(count, fixture.particles.liveCount) + } + } + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/SustainSchedulerTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/SustainSchedulerTest.kt new file mode 100644 index 000000000..c594c64c9 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/SustainSchedulerTest.kt @@ -0,0 +1,78 @@ +package org.demoth.cake.stages.ingame.effects + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class SustainSchedulerTest { + @Test fun `bursts start immediately follow cadence and stop at expiry`() { + val scheduler = SustainScheduler() + val bursts = mutableListOf() + scheduler.start(1, "steam", 1000, 300, 100) + scheduler.update(1000, bursts::add) + repeat(20) { scheduler.update(1000, bursts::add) } // A paused clock cannot repeat emission. + scheduler.update(1099, bursts::add) + assertEquals(listOf("steam"), bursts) + scheduler.update(1100, bursts::add) + scheduler.update(1200, bursts::add) + assertEquals(3, bursts.size) + scheduler.update(1300, bursts::add) + assertEquals(3, bursts.size) + assertEquals(0, scheduler.activeCount) + } + + @Test fun `late updates emit once skip missed ticks and retain cadence`() { + val scheduler = SustainScheduler() + val bursts = mutableListOf() + scheduler.start(1, 7, 0, 20000, 100) + scheduler.update(0, bursts::add) + scheduler.update(10025, bursts::add) + assertEquals(listOf(7, 7), bursts) + scheduler.update(10099, bursts::add) + assertEquals(2, bursts.size) + scheduler.update(10100, bursts::add) + assertEquals(3, bursts.size) + scheduler.update(30000, bursts::add) + assertEquals(3, bursts.size) + assertEquals(0, scheduler.activeCount) + } + + @Test fun `reusing an identifier replaces state and refreshes duration even at capacity`() { + val scheduler = SustainScheduler(capacity = 1) + val bursts = mutableListOf() + assertTrue(scheduler.start(1, "old", 0, 200, 100)) + scheduler.update(0, bursts::add) + assertTrue(scheduler.start(1, "new", 150, 300, 100)) + assertFalse(scheduler.start(2, "overflow", 150, 1000, 100)) + scheduler.update(150, bursts::add) + scheduler.update(300, bursts::add) + assertEquals(listOf("old", "new", "new"), bursts) + assertEquals(1, scheduler.activeCount) + scheduler.update(450, bursts::add) + assertEquals(0, scheduler.activeCount) + } + + @Test fun `expired and cancelled entries free slots and clear releases all scheduled emissions`() { + val scheduler = SustainScheduler(capacity = 1) + val bursts = mutableListOf() + scheduler.start(1, 1, 0, 100, 100) + assertTrue(scheduler.start(2, 2, 100, 500, 100)) // Admission prunes expired state without needing update. + assertTrue(scheduler.start(2, 2, 100, 0, 100)) + assertEquals(0, scheduler.activeCount) + scheduler.start(3, 3, 100, 500, 100) + scheduler.start(3, 3, 100, -1, 100) + assertEquals(0, scheduler.activeCount) + scheduler.start(4, 4, 100, 500, 100) + scheduler.clear() + scheduler.update(200, bursts::add) + assertTrue(bursts.isEmpty()) + } + + @Test fun `long protocol durations do not overflow into immediate expiry`() { + val scheduler = SustainScheduler() + var emitted = 0 + scheduler.start(1, 1, 1000, Int.MAX_VALUE, 100) + scheduler.update(2000) { emitted++ } + assertEquals(1, emitted) + assertEquals(1, scheduler.activeCount) + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/TempEntityPresentationTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/TempEntityPresentationTest.kt new file mode 100644 index 000000000..f4fbb91a4 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/stages/ingame/effects/TempEntityPresentationTest.kt @@ -0,0 +1,112 @@ +package org.demoth.cake.stages.ingame.effects + +import com.badlogic.gdx.assets.AssetManager +import com.badlogic.gdx.assets.loaders.FileHandleResolver +import com.badlogic.gdx.files.FileHandle +import com.badlogic.gdx.graphics.PerspectiveCamera +import com.badlogic.gdx.utils.GdxNativesLoader +import jake2.qcommon.Defines.* +import jake2.qcommon.math.Vector3f +import jake2.qcommon.network.messages.server.* +import org.demoth.cake.CakeCvars +import org.demoth.cake.audio.* +import org.demoth.cake.stages.ingame.ClientEntityManager +import org.demoth.cake.stages.ingame.DynamicLightSystem +import org.demoth.cake.stages.ingame.RenderTuningCvars +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.DynamicTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestFactory + +/** Exercises the real dispatcher and frame lifecycle, without render calls, GL or installed assets. */ +class TempEntityPresentationTest { + private class Fixture : AutoCloseable { + var now = 0 + val assets = AssetManager(FileHandleResolver { FileHandle(it) }, false) + val entities = ClientEntityManager() + val lights = DynamicLightSystem() + val effects = ClientEffectsSystem(assets, entities, object : CakeAudioSystem { + override fun beginFrame(listener: ListenerState) = Unit + override fun play(request: SoundPlaybackRequest) = Unit + override fun syncEntityLoopingSounds(requests: Collection) = Unit + override fun endFrame() = Unit + override fun stopAll() = Unit + override fun dispose() = Unit + }, { PerspectiveCamera() }, lights, { now }) + + fun frame(time: Int) { + now = time + effects.update(0f, 0) + lights.beginFrame(now, 0f) + effects.collectDynamicLights() + } + + override fun close() { effects.dispose(); entities.dispose(); assets.dispose() } + } + + private fun messages(): List = buildList { + val start = Vector3f(10f, 20f, 30f) + val end = Vector3f(100f, 40f, 50f) + PointTEMessage.SUBTYPES.forEach { add(PointTEMessage(it, start)) } + PointDirectionTEMessage.SUBTYPES.forEach { add(PointDirectionTEMessage(it, start, 0)) } + TrailTEMessage.SUBTYPES.forEach { add(TrailTEMessage(it, start, end)) } + SplashTEMessage.SUBTYPES.forEach { add(SplashTEMessage(it, 20, start, 0, 1)) } + BeamTEMessage.SUBTYPES.forEach { add(BeamTEMessage(it, 1, start, end)) } + BeamOffsetTEMessage.SUBTYPES.forEach { add(BeamOffsetTEMessage(it, 1, start, end, Vector3f.zero)) } + add(LightningTEMessage(1, 2, start, end)) + add(FlashlightTEMessage(start, 1)) + add(ForceWallTEMessage(start, end, 100)) + add(SteamTEMessage(1, 20, start, 0, 100, 60, 500)) + add(WidowBeamOutTEMessage(1, start)) + } + + @Test fun `fixtures account for every classic supported wire subtype exactly once`() { + assertEquals((0..55).toSet() - setOf(TE_RAILTRAIL2, TE_FLAME), messages().map { it.style }.toSet()) + assertEquals(54, messages().size) + } + + @TestFactory fun `every subtype produces presentation state and expires without further messages or drawing`() = + messages().map { message -> DynamicTest.dynamicTest("temporary entity ${message.style}") { + Fixture().use { fixture -> + fixture.effects.processTempEntityMessage(message) + fixture.frame(0) + val particles = fixture.effects.debugLiveParticleCount() + val effects = fixture.effects.debugActiveEffectCount() + val lights = fixture.lights.visibleLightsForShader().size + assertTrue(particles + effects + lights > 0, "Subtype ${message.style} produced no presentation") + repeat(3) { + fixture.frame(0) + assertEquals(particles, fixture.effects.debugLiveParticleCount(), "Paused emission duplicated particles") + assertEquals(effects, fixture.effects.debugActiveEffectCount()) + assertEquals(lights, fixture.lights.visibleLightsForShader().size) + } + fixture.frame(11_000) + assertEquals(0, fixture.effects.debugActiveEffectCount(), "Effect state outlived its lifetime") + assertEquals(0, fixture.effects.debugLiveParticleCount()) + assertTrue(fixture.lights.visibleLightsForShader().isEmpty()) + } + } } + + @Test fun `teardown clears sustained and beam state and a new session starts empty`() { + Fixture().use { fixture -> + messages().forEach(fixture.effects::processTempEntityMessage) + fixture.frame(0) + assertTrue(fixture.effects.debugActiveEffectCount() > 0) + } + Fixture().use { fixture -> + fixture.frame(0) + assertEquals(0, fixture.effects.debugActiveEffectCount()) + assertEquals(0, fixture.effects.debugLiveParticleCount()) + assertTrue(fixture.lights.visibleLightsForShader().isEmpty()) + } + } + + companion object { + @BeforeAll @JvmStatic fun setup() { + GdxNativesLoader.load() + CakeCvars.registerEntities() + RenderTuningCvars.register() + } + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/ui/ConcharsAtlasGlTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/ui/ConcharsAtlasGlTest.kt index 959ffb17f..7552af25e 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/ui/ConcharsAtlasGlTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/ui/ConcharsAtlasGlTest.kt @@ -95,6 +95,7 @@ class ConcharsAtlasGlTest { assertSame(widgets.containerPanel, browser.background) assertNull(browser.scrollPane.background, "Only the outer browser panel is framed") assertSame(widgets.scrollPane.vScrollKnob, browser.scrollPane.vScrollKnob) + assertSame(widgets.scrollPane.hScrollKnob, browser.scrollPane.hScrollKnob) assertSame(widgets.separatorVertical, browser.splitPane.handle, "Splitter reuses themed separator artwork") } assertEquals(24f, skin.getFont("default").lineHeight, "Raw engine UI retains native font size") @@ -125,6 +126,10 @@ class ConcharsAtlasGlTest { assertSame(quake.tree.selection, quake.list.selection) assertSame(quake.tree.selection, quake.selectBox.listStyle.selection) assertSame(quake.separatorVertical, quake.scrollPane.vScrollKnob) + assertSame(quake.separatorHorizontal, quake.scrollPane.hScrollKnob) + assertSame(quake.separatorHorizontal, quake.selectBox.scrollStyle.hScrollKnob) + assertNull(quake.scrollPane.hScroll) + assertNull(quake.scrollPane.corner) assertSame(quake.separatorVertical, quake.selectBox.scrollStyle.vScrollKnob) assertNull(quake.scrollPane.vScroll, "Game scrollbar has no separate track backdrop") assertNull(quake.selectBox.scrollStyle.vScroll) diff --git a/cake/core/src/test/kotlin/org/demoth/cake/ui/ContentBrowserNavigationTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/ui/ContentBrowserNavigationTest.kt new file mode 100644 index 000000000..4479e1d60 --- /dev/null +++ b/cake/core/src/test/kotlin/org/demoth/cake/ui/ContentBrowserNavigationTest.kt @@ -0,0 +1,16 @@ +package org.demoth.cake.ui + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class ContentBrowserNavigationTest { + @Test fun `bookmark retains only value state for the same profile and resets on profile change`() { + val navigation = ContentBrowserNavigation() + assertNull(navigation.forProfile("base")) + val location = ContentBrowserLocation("demos/series", "demos/series/test.dm2", 250f, 160f) + navigation.remember("base", location) + assertEquals(location, navigation.forProfile("base")) + assertNull(navigation.forProfile("rogue")) + assertNull(navigation.forProfile("base")) + } +} diff --git a/cake/core/src/test/kotlin/org/demoth/cake/ui/MenuStagesGlTest.kt b/cake/core/src/test/kotlin/org/demoth/cake/ui/MenuStagesGlTest.kt index 501de06e3..f61455c94 100644 --- a/cake/core/src/test/kotlin/org/demoth/cake/ui/MenuStagesGlTest.kt +++ b/cake/core/src/test/kotlin/org/demoth/cake/ui/MenuStagesGlTest.kt @@ -74,6 +74,7 @@ class MenuStagesGlTest { verifyHostMapSelection(viewport, style) verifySavedGames(viewport, style) verifySingleplayerHub(viewport, style) + verifyBrowserDemoNavigation(viewport, style) menus.rebuild(style) val main = menus.inputProcessor assertFalse(menus.select(MenuScreen.MAIN)) @@ -151,6 +152,70 @@ class MenuStagesGlTest { } } + private fun verifyBrowserDemoNavigation(viewport: UiViewport, style: GameUiStyle) { + val entries = (0..39).map { number -> + val path = "demos/nested/clip${number.toString().padStart(2, '0')}.dm2" + jake2.qcommon.vfs.VfsEntry(path, path, jake2.qcommon.vfs.VfsLayer.BASE_PACK, 0, + jake2.qcommon.vfs.VfsSource(jake2.qcommon.vfs.VfsSourceType.PACKAGE_ENTRY, + java.nio.file.Path.of("fixture.pak"), path, "pak", true, false), 100, 0) + } + val bus = MenuEventBus() + val assets = AssetManager() + var profile = "profile" + val menus = MenuStages(viewport, bus, assets, CakeFileResolver(), false, + listContentEntries = { entries }, + readDemoMetadata = { org.demoth.cake.demo.DemoMetadata(26, "baseq2", "Test", "maps/test.bsp") }, + activeProfileId = { profile }) + fun stage() = menus.inputProcessor as Stage + fun browser() = descendants(stage().root).filterIsInstance().single() + fun clickTile(name: String, count: Int) { + val tile = descendants(stage().root).filterIsInstance