diff --git a/launcher/app/src/main/java/com/skarm/launcher/GameActivity.kt b/launcher/app/src/main/java/com/skarm/launcher/GameActivity.kt index 09a035f..62e4918 100644 --- a/launcher/app/src/main/java/com/skarm/launcher/GameActivity.kt +++ b/launcher/app/src/main/java/com/skarm/launcher/GameActivity.kt @@ -98,6 +98,19 @@ class GameActivity : AppCompatActivity(), SurfaceHolder.Callback, refreshGamepadPresence() binding.btnKeyboard.setOnClickListener { toggleSoftKeyboard() } + binding.btnEditLayout.setOnClickListener { binding.touchOverlay.toggleEditMode() } + + binding.touchOverlay.opacityChangeListener = { opacity -> + val minOpacity = 0.2f + val finalOpacity = Math.max(opacity, minOpacity) + binding.btnKeyboard.alpha = finalOpacity + binding.btnEditLayout.alpha = finalOpacity + } + + // Initially trigger the opacity listener to set the correct starting opacity + binding.touchOverlay.opacityChangeListener?.invoke( + com.skarm.launcher.touch.TouchControlManager.loadLayout(this).globalOpacity + ) // Make SK's News/wiki/forum links open the system browser. Done before // startJvm (in surfaceChanged) so binDir is ready to thread into PATH. @@ -148,6 +161,9 @@ class GameActivity : AppCompatActivity(), SurfaceHolder.Callback, */ @SuppressLint("ClickableViewAccessibility") private fun wireTouchInput() { + // We now handle standard game touches directly in the TouchOverlay to allow + // the custom touch controls to intercept first. The overlay will pass unhandled + // touches to this listener on the SurfaceView. surface.setOnTouchListener { _, event -> val x = event.x.toInt() val y = event.y.toInt() @@ -592,7 +608,7 @@ class GameActivity : AppCompatActivity(), SurfaceHolder.Callback, .show() } - private companion object { + companion object { const val TAG = "GameActivity" // Abstract-namespace socket the xdg-open shim relays URLs over. Must match diff --git a/launcher/app/src/main/java/com/skarm/launcher/touch/TouchControlModels.kt b/launcher/app/src/main/java/com/skarm/launcher/touch/TouchControlModels.kt new file mode 100644 index 0000000..fc16bd5 --- /dev/null +++ b/launcher/app/src/main/java/com/skarm/launcher/touch/TouchControlModels.kt @@ -0,0 +1,174 @@ +package com.skarm.launcher.touch + +import android.content.Context +import android.content.SharedPreferences +import android.view.KeyEvent +import com.skarm.launcher.GameActivity +import org.json.JSONArray +import org.json.JSONObject + +enum class ControlType { + BUTTON, + JOYSTICK_LEFT, + JOYSTICK_RIGHT +} + +data class ControlNode( + val id: String, + val type: ControlType, + var xPercent: Float, // 0.0 to 1.0 (relative to screen width) + var yPercent: Float, // 0.0 to 1.0 (relative to screen height) + var scale: Float = 1.0f, + var visible: Boolean = true, + + // For Buttons + val buttonCode: Int = -1, // GameActivity.GP_BTN_* or axis for triggers + val isAxisTrigger: Boolean = false, // True if buttonCode represents an axis (like LTrig) + val isToggle: Boolean = false, // True for the Strafe button + val label: String = "" +) + +data class TouchLayoutData( + var globalOpacity: Float = 0.5f, + var controlsEnabled: Boolean = true, + val nodes: MutableList = mutableListOf() +) + +object TouchControlManager { + private const val PREFS_NAME = "touch_controls_prefs" + private const val KEY_LAYOUT_DATA = "layout_data" + + // Axis codes for triggers, mapped to GameActivity constants + const val AXIS_LTRIGGER = 4 + const val AXIS_RTRIGGER = 5 + + // Button codes from GameActivity + const val GP_BTN_A = 0 + const val GP_BTN_B = 1 + const val GP_BTN_X = 2 + const val GP_BTN_Y = 3 + const val GP_BTN_LEFT_BUMPER = 4 + const val GP_BTN_RIGHT_BUMPER = 5 + const val GP_BTN_BACK = 6 + const val GP_BTN_START = 7 + const val GP_BTN_GUIDE = 8 + const val GP_BTN_LEFT_THUMB = 9 + const val GP_BTN_RIGHT_THUMB = 10 + const val GP_BTN_DPAD_UP = 11 + const val GP_BTN_DPAD_RIGHT = 12 + const val GP_BTN_DPAD_DOWN = 13 + const val GP_BTN_DPAD_LEFT = 14 + + fun loadLayout(context: Context): TouchLayoutData { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val jsonStr = prefs.getString(KEY_LAYOUT_DATA, null) + + if (jsonStr.isNullOrEmpty()) { + return createDefaultLayout() + } + + return try { + val json = JSONObject(jsonStr) + val layout = TouchLayoutData( + globalOpacity = json.optDouble("globalOpacity", 0.5).toFloat(), + controlsEnabled = json.optBoolean("controlsEnabled", true) + ) + + val nodesArray = json.getJSONArray("nodes") + for (i in 0 until nodesArray.length()) { + val nodeObj = nodesArray.getJSONObject(i) + val type = ControlType.valueOf(nodeObj.getString("type")) + layout.nodes.add(ControlNode( + id = nodeObj.getString("id"), + type = type, + xPercent = nodeObj.getDouble("xPercent").toFloat(), + yPercent = nodeObj.getDouble("yPercent").toFloat(), + scale = nodeObj.optDouble("scale", 1.0).toFloat(), + visible = nodeObj.optBoolean("visible", true), + buttonCode = nodeObj.optInt("buttonCode", -1), + isAxisTrigger = nodeObj.optBoolean("isAxisTrigger", false), + isToggle = nodeObj.optBoolean("isToggle", false), + label = nodeObj.optString("label", "") + )) + } + layout + } catch (e: Exception) { + e.printStackTrace() + createDefaultLayout() + } + } + + fun saveLayout(context: Context, layout: TouchLayoutData) { + val json = JSONObject().apply { + put("globalOpacity", layout.globalOpacity.toDouble()) + put("controlsEnabled", layout.controlsEnabled) + + val nodesArray = JSONArray() + for (node in layout.nodes) { + val nodeObj = JSONObject().apply { + put("id", node.id) + put("type", node.type.name) + put("xPercent", node.xPercent.toDouble()) + put("yPercent", node.yPercent.toDouble()) + put("scale", node.scale.toDouble()) + put("visible", node.visible) + put("buttonCode", node.buttonCode) + put("isAxisTrigger", node.isAxisTrigger) + put("isToggle", node.isToggle) + put("label", node.label) + } + nodesArray.put(nodeObj) + } + put("nodes", nodesArray) + } + + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putString(KEY_LAYOUT_DATA, json.toString()) + .apply() + } + + private fun createDefaultLayout(): TouchLayoutData { + val layout = TouchLayoutData() + + // Left Joystick (Move) + layout.nodes.add(ControlNode("joy_move", ControlType.JOYSTICK_LEFT, 0.15f, 0.7f)) + + // Right Joystick (Face) - Tap to Primary Attack handled in Joystick implementation + layout.nodes.add(ControlNode("joy_face", ControlType.JOYSTICK_RIGHT, 0.85f, 0.7f)) + + // Strafe (R3) - toggle button offset to the lower right of Movement Circle pad + layout.nodes.add(ControlNode("btn_strafe", ControlType.BUTTON, 0.28f, 0.85f, buttonCode = GP_BTN_RIGHT_THUMB, isToggle = true, label = "Strafe")) + + // Defend (LTrig) - hold button offset to lower left of Facing Circle pad + layout.nodes.add(ControlNode("btn_defend", ControlType.BUTTON, 0.72f, 0.85f, buttonCode = AXIS_LTRIGGER, isAxisTrigger = true, label = "Defend")) + + // Dodge (L3) - tap button offset above the Facing Circle pad + layout.nodes.add(ControlNode("btn_dodge", ControlType.BUTTON, 0.78f, 0.45f, buttonCode = GP_BTN_LEFT_THUMB, label = "Dodge")) + + // ShieldBash (Y) - tap button offset above the Facing Circle pad + layout.nodes.add(ControlNode("btn_shieldbash", ControlType.BUTTON, 0.88f, 0.45f, buttonCode = GP_BTN_Y, label = "Bash")) + + // Prev Weap (L1) - Up glyph arrow button offset to the right of the Facing Circle pad + layout.nodes.add(ControlNode("btn_prevweap", ControlType.BUTTON, 0.95f, 0.55f, buttonCode = GP_BTN_LEFT_BUMPER, label = "↑")) + + // Next Weap (R1) - Down glyph arrow button offset to the right of the Facing Circle pad + layout.nodes.add(ControlNode("btn_nextweap", ControlType.BUTTON, 0.95f, 0.85f, buttonCode = GP_BTN_RIGHT_BUMPER, label = "↓")) + + // Bottom centered buttons in a row from left to right by default + val centerStartX = 0.35f + val bottomY = 0.9f + val gap = 0.08f + + layout.nodes.add(ControlNode("btn_ab1", ControlType.BUTTON, centerStartX + gap * 0, bottomY, buttonCode = GP_BTN_A, label = "A1")) + layout.nodes.add(ControlNode("btn_ab2", ControlType.BUTTON, centerStartX + gap * 1, bottomY, buttonCode = GP_BTN_B, label = "A2")) + layout.nodes.add(ControlNode("btn_ab3", ControlType.BUTTON, centerStartX + gap * 2, bottomY, buttonCode = GP_BTN_X, label = "A3")) + + layout.nodes.add(ControlNode("btn_item1", ControlType.BUTTON, centerStartX + gap * 3, bottomY, buttonCode = GP_BTN_DPAD_UP, label = "I1")) + layout.nodes.add(ControlNode("btn_item2", ControlType.BUTTON, centerStartX + gap * 4, bottomY, buttonCode = GP_BTN_DPAD_RIGHT, label = "I2")) + layout.nodes.add(ControlNode("btn_item3", ControlType.BUTTON, centerStartX + gap * 5, bottomY, buttonCode = GP_BTN_DPAD_DOWN, label = "I3")) + layout.nodes.add(ControlNode("btn_item4", ControlType.BUTTON, centerStartX + gap * 6, bottomY, buttonCode = GP_BTN_DPAD_LEFT, label = "I4")) + + return layout + } +} diff --git a/launcher/app/src/main/java/com/skarm/launcher/touch/TouchControlOverlay.kt b/launcher/app/src/main/java/com/skarm/launcher/touch/TouchControlOverlay.kt new file mode 100644 index 0000000..39411fd --- /dev/null +++ b/launcher/app/src/main/java/com/skarm/launcher/touch/TouchControlOverlay.kt @@ -0,0 +1,339 @@ +package com.skarm.launcher.touch + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Color +import android.util.AttributeSet +import android.view.Gravity +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.SeekBar +import android.widget.Switch +import android.widget.TextView + +class TouchControlOverlay @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + private var layoutData: TouchLayoutData = TouchControlManager.loadLayout(context) + private val controlViews = mutableListOf() + private var inEditMode = false + + // Edit state + private var selectedView: BaseTouchControl? = null + private var dX = 0f + private var dY = 0f + + // Editor UI Panel + private val editorPanel: LinearLayout + + init { + // Build editor panel + editorPanel = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + setBackgroundColor(Color.parseColor("#80000000")) + setPadding(32, 32, 32, 32) + visibility = View.GONE + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.CENTER) + + // Global Settings + val globalTitle = TextView(context).apply { text = "Global Settings"; setTextColor(Color.WHITE) } + addView(globalTitle) + + val enableSwitch = Switch(context).apply { + text = "Enable Controls" + setTextColor(Color.WHITE) + isChecked = layoutData.controlsEnabled + setOnCheckedChangeListener { _, isChecked -> + layoutData.controlsEnabled = isChecked + updateVisibility() + } + } + addView(enableSwitch) + + val opacityLabel = TextView(context).apply { text = "Opacity"; setTextColor(Color.WHITE) } + addView(opacityLabel) + val opacitySlider = SeekBar(context).apply { + max = 100 + progress = (layoutData.globalOpacity * 100).toInt() + setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + layoutData.globalOpacity = progress / 100f + updateOpacity() + } + override fun onStartTrackingTouch(seekBar: SeekBar?) {} + override fun onStopTrackingTouch(seekBar: SeekBar?) {} + }) + } + addView(opacitySlider) + + // Separator + addView(View(context).apply { + setBackgroundColor(Color.LTGRAY) + layoutParams = LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, 2).apply { + setMargins(0, 16, 0, 16) + } + }) + + // Node Settings (Hidden by default) + val nodeTitle = TextView(context).apply { + text = "Node Settings" + setTextColor(Color.WHITE) + tag = "nodeTitle" + visibility = View.GONE + } + addView(nodeTitle) + + val visibleSwitch = Switch(context).apply { + text = "Visible" + setTextColor(Color.WHITE) + tag = "visibleSwitch" + visibility = View.GONE + setOnCheckedChangeListener { _, isChecked -> + selectedView?.let { + it.node.visible = isChecked + it.alpha = if (isChecked) layoutData.globalOpacity else 0.3f // keep visible in edit mode + } + } + } + addView(visibleSwitch) + + val scaleLabel = TextView(context).apply { + text = "Scale" + setTextColor(Color.WHITE) + tag = "scaleLabel" + visibility = View.GONE + } + addView(scaleLabel) + + val scaleSlider = SeekBar(context).apply { + max = 200 // 0.5x to 2.5x + tag = "scaleSlider" + visibility = View.GONE + setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + selectedView?.let { + val scale = 0.5f + (progress / 100f) + it.node.scale = scale + requestLayout() + } + } + override fun onStartTrackingTouch(seekBar: SeekBar?) {} + override fun onStopTrackingTouch(seekBar: SeekBar?) {} + }) + } + addView(scaleSlider) + } + + buildControls() + addView(editorPanel) + } + + private fun buildControls() { + controlViews.forEach { removeView(it) } + controlViews.clear() + + for (node in layoutData.nodes) { + val view = when (node.type) { + ControlType.JOYSTICK_LEFT, ControlType.JOYSTICK_RIGHT -> TouchJoystickView(context, node) + ControlType.BUTTON -> TouchButtonView(context, node) + } + addView(view) + controlViews.add(view) + } + + updateVisibility() + updateOpacity() + } + + private fun updateVisibility() { + val enabled = layoutData.controlsEnabled + for (view in controlViews) { + if (inEditMode) { + view.visibility = View.VISIBLE + view.alpha = if (view.node.visible) layoutData.globalOpacity else 0.3f + } else { + view.visibility = if (enabled && view.node.visible) View.VISIBLE else View.GONE + view.alpha = layoutData.globalOpacity + } + } + } + + private fun updateOpacity() { + val opacity = layoutData.globalOpacity + for (view in controlViews) { + if (inEditMode && !view.node.visible) { + view.alpha = 0.3f + } else { + view.alpha = opacity + } + } + + // Notify Activity if there's a listener to update static buttons (Keyboard, Gear) + opacityChangeListener?.invoke(opacity) + } + + var opacityChangeListener: ((Float) -> Unit)? = null + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + + val w = right - left + val h = bottom - top + + // Base sizes + val baseJoySize = minOf(w, h) * 0.3f + val baseBtnSize = minOf(w, h) * 0.15f + + for (view in controlViews) { + val node = view.node + val size = (if (node.type == ControlType.BUTTON) baseBtnSize else baseJoySize) * node.scale + + val cx = w * node.xPercent + val cy = h * node.yPercent + + val l = (cx - size / 2).toInt() + val t = (cy - size / 2).toInt() + val r = (cx + size / 2).toInt() + val b = (cy + size / 2).toInt() + + view.layout(l, t, r, b) + } + + // Ensure editor panel is brought to front and centered + editorPanel.bringToFront() + } + + fun toggleEditMode() { + inEditMode = !inEditMode + editorPanel.visibility = if (inEditMode) View.VISIBLE else View.GONE + + if (!inEditMode) { + // Save layout when exiting edit mode + TouchControlManager.saveLayout(context, layoutData) + selectedView = null + } + + for (view in controlViews) { + view.inEditMode = inEditMode + view.isSelectedNode = false + view.invalidate() + } + + updateVisibility() + updateEditorPanel() + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + if (!inEditMode) { + // In play mode, let the overlay dispatch touches down to the controls, + // or pass through to the game surface if no control was hit. + var handled = false + for (i in controlViews.indices.reversed()) { + val view = controlViews[i] + if (view.visibility == View.VISIBLE && isPointInsideView(event.x, event.y, view)) { + val viewEvent = MotionEvent.obtain(event) + viewEvent.offsetLocation(-view.left.toFloat(), -view.top.toFloat()) + handled = view.dispatchTouchEvent(viewEvent) || handled + viewEvent.recycle() + } + } + return handled + } + + // --- Edit Mode Logic --- + val action = event.actionMasked + + when (action) { + MotionEvent.ACTION_DOWN -> { + // Find tapped view + var tappedView: BaseTouchControl? = null + for (i in controlViews.indices.reversed()) { + val view = controlViews[i] + if (isPointInsideView(event.x, event.y, view)) { + tappedView = view + break + } + } + + if (tappedView != null) { + selectView(tappedView) + dX = tappedView.x - event.x + dY = tappedView.y - event.y + } else { + // Tap on empty space deselects + selectView(null) + } + return true + } + MotionEvent.ACTION_MOVE -> { + selectedView?.let { view -> + var newX = event.x + dX + var newY = event.y + dY + + // Constrain to screen + newX = newX.coerceIn(0f, width.toFloat() - view.width) + newY = newY.coerceIn(0f, height.toFloat() - view.height) + + view.x = newX + view.y = newY + + // Update node percent + view.node.xPercent = (newX + view.width / 2f) / width + view.node.yPercent = (newY + view.height / 2f) / height + } + return true + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + // Done dragging + return true + } + } + return false + } + + private fun isPointInsideView(x: Float, y: Float, view: View): Boolean { + return x >= view.left && x <= view.right && y >= view.top && y <= view.bottom + } + + private fun selectView(view: BaseTouchControl?) { + selectedView?.isSelectedNode = false + selectedView?.invalidate() + + selectedView = view + + selectedView?.isSelectedNode = true + selectedView?.invalidate() + + updateEditorPanel() + } + + private fun updateEditorPanel() { + val nodeTitle = editorPanel.findViewWithTag("nodeTitle") + val visibleSwitch = editorPanel.findViewWithTag("visibleSwitch") + val scaleLabel = editorPanel.findViewWithTag("scaleLabel") + val scaleSlider = editorPanel.findViewWithTag("scaleSlider") + + if (selectedView != null) { + val node = selectedView!!.node + nodeTitle.visibility = View.VISIBLE + nodeTitle.text = "Settings: ${node.label.ifEmpty { node.id }}" + + visibleSwitch.visibility = View.VISIBLE + visibleSwitch.isChecked = node.visible + + scaleLabel.visibility = View.VISIBLE + scaleSlider.visibility = View.VISIBLE + scaleSlider.progress = ((node.scale - 0.5f) * 100).toInt() + } else { + nodeTitle.visibility = View.GONE + visibleSwitch.visibility = View.GONE + scaleLabel.visibility = View.GONE + scaleSlider.visibility = View.GONE + } + } +} diff --git a/launcher/app/src/main/java/com/skarm/launcher/touch/TouchControlViews.kt b/launcher/app/src/main/java/com/skarm/launcher/touch/TouchControlViews.kt new file mode 100644 index 0000000..f7243ea --- /dev/null +++ b/launcher/app/src/main/java/com/skarm/launcher/touch/TouchControlViews.kt @@ -0,0 +1,288 @@ +package com.skarm.launcher.touch + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.view.MotionEvent +import android.view.View +import com.skarm.launcher.GameActivity +import com.skarm.launcher.NativeBridge +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.min +import kotlin.math.sin +import kotlin.math.sqrt + +abstract class BaseTouchControl(context: Context, val node: ControlNode) : View(context) { + var inEditMode: Boolean = false + var isSelectedNode: Boolean = false + + private val editPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 4f + pathEffect = android.graphics.DashPathEffect(floatArrayOf(10f, 10f), 0f) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + if (inEditMode) { + editPaint.color = if (isSelectedNode) Color.YELLOW else Color.GRAY + canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), editPaint) + } + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + if (inEditMode) return false // Overlay handles edit touches + return handleGameTouch(event) + } + + abstract fun handleGameTouch(event: MotionEvent): Boolean +} + +class TouchJoystickView(context: Context, node: ControlNode) : BaseTouchControl(context, node) { + private val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.DKGRAY + style = Paint.Style.FILL + alpha = 100 + } + + private val knobPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.LTGRAY + style = Paint.Style.FILL + alpha = 200 + } + + private var knobX = 0f + private var knobY = 0f + private var isDragging = false + private var pointerId = -1 + + // Specific mapping info + private val axisX = if (node.type == ControlType.JOYSTICK_LEFT) GameActivity.GP_AXIS_LEFT_X else GameActivity.GP_AXIS_RIGHT_X + private val axisY = if (node.type == ControlType.JOYSTICK_LEFT) GameActivity.GP_AXIS_LEFT_Y else GameActivity.GP_AXIS_RIGHT_Y + + private var tapStartTime = 0L + private var lastTapTime = 0L + private var isHoldingAttack = false + + // Holding previous aim direction briefly on release + private var releaseAimTask: Runnable? = null + private var lastReportedX = 0f + private var lastReportedY = 0f + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + resetKnob() + } + + private fun resetKnob() { + knobX = width / 2f + knobY = height / 2f + if (node.type == ControlType.JOYSTICK_RIGHT) { + // Keep the last reported direction for 250ms, then zero it out + releaseAimTask?.let { removeCallbacks(it) } + val task = Runnable { reportAxis(0f, 0f) } + releaseAimTask = task + postDelayed(task, 250) + } else { + reportAxis(0f, 0f) + } + invalidate() + } + + override fun onDraw(canvas: Canvas) { + val radius = min(width, height) / 2f + val centerX = width / 2f + val centerY = height / 2f + + canvas.drawCircle(centerX, centerY, radius, bgPaint) + canvas.drawCircle(knobX, knobY, radius * 0.4f, knobPaint) + + super.onDraw(canvas) + } + + override fun handleGameTouch(event: MotionEvent): Boolean { + val action = event.actionMasked + val pointerIndex = event.actionIndex + + when (action) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> { + if (!isDragging) { + val x = event.getX(pointerIndex) + val y = event.getY(pointerIndex) + val radius = min(width, height) / 2f + val dx = x - width / 2f + val dy = y - height / 2f + + if (sqrt((dx * dx + dy * dy).toDouble()) <= radius) { + isDragging = true + pointerId = event.getPointerId(pointerIndex) + + releaseAimTask?.let { removeCallbacks(it) } + + if (node.type == ControlType.JOYSTICK_RIGHT) { + val now = System.currentTimeMillis() + if (now - lastTapTime < 250) { + isHoldingAttack = true + NativeBridge.onGamepadAxis(TouchControlManager.AXIS_RTRIGGER, 1f) + } + tapStartTime = now + } + + updateKnob(x, y) + } + } + } + MotionEvent.ACTION_MOVE -> { + if (isDragging) { + val index = event.findPointerIndex(pointerId) + if (index >= 0) { + updateKnob(event.getX(index), event.getY(index)) + } + } + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP, MotionEvent.ACTION_CANCEL -> { + if (isDragging && event.getPointerId(pointerIndex) == pointerId) { + isDragging = false + pointerId = -1 + resetKnob() + + if (node.type == ControlType.JOYSTICK_RIGHT) { + if (isHoldingAttack) { + isHoldingAttack = false + NativeBridge.onGamepadAxis(TouchControlManager.AXIS_RTRIGGER, -1f) + } else if ((System.currentTimeMillis() - tapStartTime) < 200) { + // Simulate right trigger tap for Primary Attack + NativeBridge.onGamepadAxis(TouchControlManager.AXIS_RTRIGGER, 1f) + postDelayed({ NativeBridge.onGamepadAxis(TouchControlManager.AXIS_RTRIGGER, -1f) }, 50) + lastTapTime = System.currentTimeMillis() + } else { + lastTapTime = 0L // Was a drag, don't trigger double tap + } + } + } + } + } + return isDragging + } + + private fun updateKnob(x: Float, y: Float) { + val centerX = width / 2f + val centerY = height / 2f + val radius = min(width, height) / 2f + val maxDist = radius * 0.6f + + val dx = x - centerX + val dy = y - centerY + val dist = sqrt((dx * dx + dy * dy).toDouble()).toFloat() + + if (dist <= maxDist) { + knobX = x + knobY = y + } else { + val angle = atan2(dy.toDouble(), dx.toDouble()) + knobX = centerX + (maxDist * cos(angle)).toFloat() + knobY = centerY + (maxDist * sin(angle)).toFloat() + } + + val normX = (knobX - centerX) / maxDist + val normY = (knobY - centerY) / maxDist + + // If holding attack, report the tap direction even before the finger moves far + reportAxis(normX, normY) + invalidate() + } + + private fun reportAxis(x: Float, y: Float) { + lastReportedX = x + lastReportedY = y + NativeBridge.onGamepadAxis(axisX, x) + NativeBridge.onGamepadAxis(axisY, y) + } + +} + +class TouchButtonView(context: Context, node: ControlNode) : BaseTouchControl(context, node) { + private val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.DKGRAY + style = Paint.Style.FILL + alpha = 150 + } + + private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + textAlign = Paint.Align.CENTER + textSize = 40f + } + + private var isPressedState = false + private var isToggledOn = false + + override fun onDraw(canvas: Canvas) { + val centerX = width / 2f + val centerY = height / 2f + val size = min(width, height).toFloat() + + val rect = RectF( + centerX - size / 2f, + centerY - size / 2f, + centerX + size / 2f, + centerY + size / 2f + ) + + bgPaint.color = if (isPressedState || isToggledOn) Color.LTGRAY else Color.DKGRAY + + // Use a rounded square instead of a circle + val cornerRadius = size * 0.25f + canvas.drawRoundRect(rect, cornerRadius, cornerRadius, bgPaint) + + // Draw label + if (node.label.isNotEmpty()) { + val metrics = textPaint.fontMetrics + val baseline = centerY - (metrics.ascent + metrics.descent) / 2 + canvas.drawText(node.label, centerX, baseline, textPaint) + } + + super.onDraw(canvas) + } + + override fun handleGameTouch(event: MotionEvent): Boolean { + val action = event.actionMasked + + when (action) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> { + isPressedState = true + if (node.isToggle) { + isToggledOn = !isToggledOn + reportInput(isToggledOn) + } else { + reportInput(true) + } + invalidate() + return true + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP, MotionEvent.ACTION_CANCEL -> { + isPressedState = false + if (!node.isToggle) { + reportInput(false) + } + invalidate() + return true + } + } + return false + } + + private fun reportInput(pressed: Boolean) { + if (node.isAxisTrigger) { + val v = if (pressed) 1f else -1f + NativeBridge.onGamepadAxis(node.buttonCode, v) + } else { + NativeBridge.onGamepadButton(node.buttonCode, pressed) + } + } +} diff --git a/launcher/app/src/main/res/layout/activity_game.xml b/launcher/app/src/main/res/layout/activity_game.xml index dd1fdd7..a572e4c 100644 --- a/launcher/app/src/main/res/layout/activity_game.xml +++ b/launcher/app/src/main/res/layout/activity_game.xml @@ -1,6 +1,7 @@ @@ -10,16 +11,40 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> - - + + + + android:orientation="horizontal" + android:layout_margin="12dp"> + + + + + +