Add LAN car_select flow: real event handling, GameEvents JNI bridge, live car/upgrade/color capture, Compose UI example
Fixes the synthetic car_select jump for cold sessions, makes the loadout exit chain safe for real (non-synthetic) events, and adds a native->Kotlin GameEvents bridge (onMapLoaded/onRaceStarted/onRaceEnded/onUpgradesAccepted/ onCarSelected) so both the UI layer and a future native RatNet client can learn what the player picked - car id, accepted upgrades, and paint color (name + RGBA) are all resolved live from the game's own engine state rather than a static extracted table, so they stay correct for any car added later. Includes a Jetpack Compose overlay as a worked example of a UI-side GameEventListener consumer. Full investigation history, root causes, and the several dead ends ruled out along the way are documented in PROGRESS.md (cont. 30-63b). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
package com.ea.ironmonkey
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import nfs.mod.mpcore.GameEventListener
|
||||
import nfs.mod.mpcore.GameEvents
|
||||
|
||||
// Example UI-layer consumer of GameEvents.onCarSelected (see PROGRESS.md
|
||||
// cont.63/63b for how carId/colorName/colorR-G-B-A get resolved natively).
|
||||
// Bundles the four separate color components the JNI bridge hands over into
|
||||
// one entity carrying a real Compose Color, since that's what any Composable
|
||||
// actually wants to draw with - the raw ints only exist because they're what
|
||||
// crosses the JNI boundary cleanly.
|
||||
data class CarSelection(
|
||||
val carId: String,
|
||||
val colorName: String,
|
||||
val color: Color,
|
||||
)
|
||||
|
||||
// Tiny reactive holder + GameEventListener adapter: translates the raw
|
||||
// (carId, colorName, r, g, b, a) callback into a CarSelection and exposes it
|
||||
// as Compose state. Kept separate from CarSelection itself so a non-Compose
|
||||
// consumer (or the future native RatNet client, per the user's own
|
||||
// architecture note) could register its own GameEventListener directly
|
||||
// against the same raw callback without depending on this class at all.
|
||||
object CarSelectionState : GameEventListener {
|
||||
var current by mutableStateOf<CarSelection?>(null)
|
||||
private set
|
||||
|
||||
override fun onCarSelected(
|
||||
carId: String,
|
||||
colorName: String,
|
||||
colorR: Int,
|
||||
colorG: Int,
|
||||
colorB: Int,
|
||||
colorA: Int,
|
||||
) {
|
||||
current = CarSelection(
|
||||
carId = carId,
|
||||
colorName = colorName,
|
||||
color = Color(red = colorR, green = colorG, blue = colorB, alpha = colorA),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Small overlay badge: car id + resolved color name + a swatch painted with
|
||||
// the actual Compose Color. Shows nothing until the player has confirmed a
|
||||
// car at least once this session.
|
||||
@Composable
|
||||
fun CarSelectionBadge(selection: CarSelection?, modifier: Modifier = Modifier) {
|
||||
AnimatedVisibility(visible = selection != null, modifier = modifier) {
|
||||
val s = selection ?: return@AnimatedVisibility
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
|
||||
tonalElevation = 4.dp,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.background(color = s.color, shape = RoundedCornerShape(4.dp))
|
||||
)
|
||||
Text(
|
||||
text = "${s.carId} – ${s.colorName}",
|
||||
modifier = Modifier.padding(start = 8.dp).width(220.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Call once, e.g. from GameActivityMain.onCreate, to start receiving
|
||||
// onCarSelected events into CarSelectionState.
|
||||
fun registerCarSelectionListener() {
|
||||
GameEvents.register(CarSelectionState)
|
||||
}
|
||||
@@ -4,24 +4,31 @@ import android.annotation.SuppressLint
|
||||
import android.app.ActivityManager
|
||||
import android.app.AlertDialog
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.AssetManager
|
||||
import android.content.res.Configuration
|
||||
import android.hardware.SensorManager
|
||||
import android.media.AudioManager
|
||||
import android.view.InputDevice
|
||||
import android.opengl.GLES20
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.PowerManager.WakeLock
|
||||
import android.os.SystemClock
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.Gravity
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.FrameLayout
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import com.ea.EAIO.EAIO
|
||||
import com.ea.EAMIO.StorageDirectory
|
||||
import com.ea.ironmonkey.Log.d
|
||||
@@ -31,7 +38,9 @@ import com.ea.ironmonkey.ObbHelper.getObbFileName
|
||||
import com.ea.ironmonkey.domain.AssetLocationType
|
||||
import com.ea.nimble.ApplicationLifecycle
|
||||
import com.ea.nimble.Global
|
||||
import nfs.mod.mpcore.GameInput
|
||||
import nfs.mod.mpcore.MultiplayerCore.loadCore
|
||||
import nfs.mod.mpcore.SyntheticInputDispatcher
|
||||
import org.fmod.FMODAudioDevice
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
@@ -67,6 +76,27 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
|
||||
private var isSleepModeEnabled = true
|
||||
private var mRotation = 0
|
||||
|
||||
// cont.44 DEBUG: fires MultiplayerCore.triggerCarSelectTest() on
|
||||
// demand via `adb shell am broadcast -a nfs.mod.mpcore.TEST_OPEN_CARSELECT
|
||||
// -p com.ea.games.nfs13_mod` - stands in for a future real lobby-overlay
|
||||
// "select car" button, letting the on-demand car_select-opening call be
|
||||
// exercised at an arbitrary moment (not just automatically at boot) for
|
||||
// live testing. See PROGRESS.md cont.44.
|
||||
private val carSelectTestReceiver = object : android.content.BroadcastReceiver() {
|
||||
override fun onReceive(context: android.content.Context?, intent: Intent?) {
|
||||
nfs.mod.mpcore.MultiplayerCore.triggerCarSelectTest()
|
||||
}
|
||||
}
|
||||
|
||||
// cont.48 DEBUG: fires the EXPERIMENTAL true-direct-jump variant via
|
||||
// `adb shell am broadcast -a nfs.mod.mpcore.TEST_TRUE_DIRECT_CARSELECT
|
||||
// -p com.ea.games.nfs13_mod`. See PROGRESS.md cont.48.
|
||||
private val trueDirectCarSelectTestReceiver = object : android.content.BroadcastReceiver() {
|
||||
override fun onReceive(context: android.content.Context?, intent: Intent?) {
|
||||
nfs.mod.mpcore.MultiplayerCore.triggerTrueDirectCarSelectJump()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateRequestedOrientation(i: Int) {
|
||||
}
|
||||
|
||||
@@ -236,12 +266,20 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
|
||||
updateRequestedOrientation(rotation)
|
||||
accelerometer = Accelerometer(sensorManager, defaultSensor, rotation)
|
||||
gameGLSurfaceView = GameGLSurfaceView(this)
|
||||
// mpcore lives in a separate Gradle module with no dependency on
|
||||
// `app` (app depends on mpcore, not the other way - a reverse
|
||||
// reference here would be circular), so it can't call back into this
|
||||
// Activity by type. Hand it a plain interface implementation instead.
|
||||
GameInput.dispatcher = object : SyntheticInputDispatcher {
|
||||
override fun dispatchTap(x: Float, y: Float) = dispatchSyntheticTap(x, y)
|
||||
}
|
||||
gameRenderer = GameRenderer(this)
|
||||
gameRenderer.setDrawFrameListener(this)
|
||||
gameGLSurfaceView.setRenderer(gameRenderer)
|
||||
runLoop = RunLoop(gameGLSurfaceView)
|
||||
mFrameLayout = FrameLayout(this)
|
||||
mFrameLayout.addView(gameGLSurfaceView)
|
||||
mFrameLayout.addView(buildCarSelectionOverlay())
|
||||
setContentView(mFrameLayout)
|
||||
System.loadLibrary("fmodex")
|
||||
System.loadLibrary("fmodevent")
|
||||
@@ -249,6 +287,18 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
|
||||
System.loadLibrary(Global.NIMBLE_ID)
|
||||
System.loadLibrary("app")
|
||||
loadCore()
|
||||
// RECEIVER_EXPORTED: needs to be reachable from an external `adb
|
||||
// shell am broadcast` sender (there's no in-app sender for this
|
||||
// debug-only trigger), and ContextCompat handles the API 33+
|
||||
// required-flag requirement transparently across minSdk 27.
|
||||
androidx.core.content.ContextCompat.registerReceiver(
|
||||
this, carSelectTestReceiver, IntentFilter("nfs.mod.mpcore.TEST_OPEN_CARSELECT"),
|
||||
androidx.core.content.ContextCompat.RECEIVER_EXPORTED
|
||||
)
|
||||
androidx.core.content.ContextCompat.registerReceiver(
|
||||
this, trueDirectCarSelectTestReceiver, IntentFilter("nfs.mod.mpcore.TEST_TRUE_DIRECT_CARSELECT"),
|
||||
androidx.core.content.ContextCompat.RECEIVER_EXPORTED
|
||||
)
|
||||
d(TAG, "Init EAIO/EAMIO")
|
||||
EAIO.Startup(this)
|
||||
StorageDirectory.Startup(this)
|
||||
@@ -414,6 +464,8 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
|
||||
|
||||
public override fun onDestroy() {
|
||||
i(TAG, "onDestroy")
|
||||
unregisterReceiver(carSelectTestReceiver)
|
||||
unregisterReceiver(trueDirectCarSelectTestReceiver)
|
||||
super.onDestroy()
|
||||
if (state == 8) {
|
||||
ApplicationLifecycle.onActivityDestroy(this)
|
||||
@@ -525,6 +577,37 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
|
||||
return gameGLSurfaceView
|
||||
}
|
||||
|
||||
// Synthesizes a tap (ACTION_DOWN + ACTION_UP) directly into the game's
|
||||
// own GLSurfaceView, exactly as if the player had touched the screen at
|
||||
// (x, y). Used by mpcore's GameEvents-driven automation (see
|
||||
// ANALYSIS/PROGRESS.md - "trigger the native car-select Flow screen
|
||||
// without needing FlowManager's internal API") to replay a known-working
|
||||
// tap sequence instead of reverse-engineering the transition call.
|
||||
// No special permission needed: this dispatches into our own view tree,
|
||||
// it isn't a system-wide input injection.
|
||||
fun dispatchSyntheticTap(x: Float, y: Float) {
|
||||
i(TAG, "dispatchSyntheticTap: ($x, $y) on $gameGLSurfaceView")
|
||||
val downTime = SystemClock.uptimeMillis()
|
||||
val down = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, x, y, 0)
|
||||
// GameGLSurfaceView.onTouchEvent() branches on event.getSource() (see
|
||||
// its processTouchScreenEvent/processTouchPadEvent split) - a
|
||||
// MotionEvent.obtain()'d event defaults to SOURCE_UNKNOWN (0), which
|
||||
// matches neither branch, so the View layer happily "consumes" it
|
||||
// (dispatchTouchEvent returns true) while the native touch handler
|
||||
// never actually runs. Must set this explicitly.
|
||||
down.source = InputDevice.SOURCE_TOUCHSCREEN
|
||||
val downConsumed = gameGLSurfaceView.dispatchTouchEvent(down)
|
||||
i(TAG, "dispatchSyntheticTap: ACTION_DOWN consumed=$downConsumed")
|
||||
down.recycle()
|
||||
handler.postDelayed({
|
||||
val upTime = SystemClock.uptimeMillis()
|
||||
val up = MotionEvent.obtain(downTime, upTime, MotionEvent.ACTION_UP, x, y, 0)
|
||||
up.source = InputDevice.SOURCE_TOUCHSCREEN
|
||||
gameGLSurfaceView.dispatchTouchEvent(up)
|
||||
up.recycle()
|
||||
}, 60)
|
||||
}
|
||||
|
||||
fun GetViewRoot() = window.decorView.getRootView().parent
|
||||
|
||||
fun CallGC() {
|
||||
@@ -587,7 +670,28 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
|
||||
return displayMetrics
|
||||
}
|
||||
|
||||
|
||||
// Example UI-layer consumer for GameEvents.onCarSelected (PROGRESS.md
|
||||
// cont.63b) - a small ComposeView overlay, top-start corner, drawn above
|
||||
// the game's own GLSurfaceView. Registers CarSelectionState as a
|
||||
// GameEventListener so it starts updating as soon as the player
|
||||
// confirms a car; the badge itself only appears once that first fires.
|
||||
private fun buildCarSelectionOverlay(): ComposeView {
|
||||
registerCarSelectionListener()
|
||||
return ComposeView(this).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||
Gravity.TOP or Gravity.START,
|
||||
).apply { setMargins(24, 24, 24, 24) }
|
||||
setContent {
|
||||
MaterialTheme {
|
||||
CarSelectionBadge(selection = CarSelectionState.current)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun setContentView(view: View) {
|
||||
d(TAG, "setContentView($view)")
|
||||
if (view === mFrameLayout) {
|
||||
|
||||
Reference in New Issue
Block a user