Завершить state machine персонажа

This commit is contained in:
2026-08-19 03:20:24 +03:00
parent 0943caa3cc
commit 62ee6877f9
12 changed files with 463 additions and 8 deletions
@@ -0,0 +1,70 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import android.app.Instrumentation;
import androidx.test.core.app.ActivityScenario;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
@RunWith(AndroidJUnit4.class)
public final class CharacterStateIntegrationTest {
@Test
public void gameViewPublishesMovementAndCommandStates() throws Exception {
Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
try (ActivityScenario<MainActivity> activity =
ActivityScenario.launch(MainActivity.class)) {
GameView[] view = new GameView[1];
activity.onActivity(current -> view[0] = current.gameViewForAutomation());
instrumentation.runOnMainSync(() -> view[0].startLevelForAutomation(1));
awaitState(instrumentation, view[0], CharacterStateMachine.State.IDLE, 3_000L);
instrumentation.runOnMainSync(
() -> view[0].setAutomationInput(false, true, false));
awaitState(instrumentation, view[0], CharacterStateMachine.State.RUN, 3_000L);
instrumentation.runOnMainSync(
() -> view[0].setAutomationInput(true, false, false));
awaitState(instrumentation, view[0], CharacterStateMachine.State.TURN, 3_000L);
instrumentation.runOnMainSync(
() -> view[0].setAutomationInput(false, true, true));
awaitState(instrumentation, view[0], CharacterStateMachine.State.JUMP, 3_000L);
instrumentation.runOnMainSync(
() -> view[0].setAutomationInput(false, true, false));
awaitState(instrumentation, view[0], CharacterStateMachine.State.FALL, 3_000L);
awaitState(instrumentation, view[0], CharacterStateMachine.State.LAND, 3_000L);
instrumentation.runOnMainSync(view[0]::triggerInteractionForAutomation);
assertEquals(CharacterStateMachine.State.INTERACT,
state(instrumentation, view[0]));
instrumentation.runOnMainSync(view[0]::triggerStunForAutomation);
assertEquals(CharacterStateMachine.State.STUNNED,
state(instrumentation, view[0]));
}
}
private static void awaitState(Instrumentation instrumentation, GameView view,
CharacterStateMachine.State expected,
long timeoutMillis) throws Exception {
long deadline = System.currentTimeMillis() + timeoutMillis;
CharacterStateMachine.State current = state(instrumentation, view);
while (System.currentTimeMillis() < deadline) {
current = state(instrumentation, view);
if (current == expected) return;
Thread.sleep(24L);
}
throw new AssertionError("Expected " + expected + " but was " + current);
}
private static CharacterStateMachine.State state(
Instrumentation instrumentation, GameView view) {
CharacterStateMachine.State[] result = new CharacterStateMachine.State[1];
instrumentation.runOnMainSync(
() -> result[0] = view.characterStateForAutomation());
return result[0];
}
}
@@ -0,0 +1,100 @@
package com.projectsacrifice.game;
/** Deterministic animation state derived from the character's public physics facts. */
public final class CharacterStateMachine {
public enum State {
IDLE,
RUN,
JUMP,
FALL,
TURN,
LAND,
INTERACT,
STUNNED,
DEATH
}
private final PhysicsConfig physics;
private State state = State.IDLE;
private float transientSeconds;
private int facing = 1;
private boolean wasOnGround;
public CharacterStateMachine(PhysicsConfig physics) {
if (physics == null) throw new IllegalArgumentException("physics is required");
this.physics = physics;
}
public void reset(boolean alive, boolean onGround, float velocityX,
float velocityY, int facing) {
state = baseState(alive, onGround, velocityX, velocityY);
transientSeconds = 0f;
this.facing = normalizeFacing(facing);
wasOnGround = onGround;
}
public void observe(float deltaSeconds, boolean alive, boolean onGround,
float velocityX, float velocityY, int facing) {
float safeDelta = Math.max(0f, deltaSeconds);
int normalizedFacing = normalizeFacing(facing);
if (!alive) {
state = State.DEATH;
transientSeconds = 0f;
this.facing = normalizedFacing;
wasOnGround = onGround;
return;
}
if ((state == State.TURN || state == State.LAND || state == State.INTERACT
|| state == State.STUNNED)
&& transientSeconds > 0f) {
transientSeconds = Math.max(0f, transientSeconds - safeDelta);
this.facing = normalizedFacing;
wasOnGround = onGround;
if (transientSeconds > 0f && onGround) return;
}
if (onGround && !wasOnGround) {
state = State.LAND;
transientSeconds = physics.landSeconds();
this.facing = normalizedFacing;
wasOnGround = true;
return;
}
if (onGround && normalizedFacing != this.facing) {
state = State.TURN;
transientSeconds = physics.turnSeconds();
this.facing = normalizedFacing;
wasOnGround = true;
return;
}
this.facing = normalizedFacing;
wasOnGround = onGround;
state = baseState(alive, onGround, velocityX, velocityY);
}
public State state() {
return state;
}
public void beginInteraction() {
if (state == State.DEATH || state == State.STUNNED) return;
state = State.INTERACT;
transientSeconds = physics.interactSeconds();
}
public void stun() {
if (state == State.DEATH) return;
state = State.STUNNED;
transientSeconds = physics.stunnedSeconds();
}
private static State baseState(boolean alive, boolean onGround,
float velocityX, float velocityY) {
if (!alive) return State.DEATH;
if (!onGround) return velocityY < 0f ? State.JUMP : State.FALL;
return Math.abs(velocityX) >= 30f ? State.RUN : State.IDLE;
}
private static int normalizeFacing(int value) {
return value < 0 ? -1 : 1;
}
}
@@ -233,6 +233,8 @@ public final class GameView extends View implements Runnable {
private final ProceduralAudioEngine audioEngine;
private final SparseArray<PointF> pointers = new SparseArray<>();
private final Player player = new Player();
private final CharacterStateMachine characterStateMachine =
new CharacterStateMachine(PHYSICS);
private final ArrayList<Corpse> corpses = new ArrayList<>();
private final ArrayList<PlatformState> platformStates = new ArrayList<>();
private final Map<String, Boolean> toggleSignals = new HashMap<>();
@@ -427,6 +429,8 @@ public final class GameView extends View implements Runnable {
player.jumpBuffer = 0f;
player.supportIndex = -1;
player.facing = 1;
characterStateMachine.reset(player.alive, player.onGround,
player.vx, player.vy, player.facing);
if (deaths > 0) audioEngine.play(AudioCue.RESPAWN_VOICE);
}
@@ -496,6 +500,10 @@ public final class GameView extends View implements Runnable {
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
}
if (contact && !previous) audioEngine.play(AudioCue.SWITCH);
if (contact && !previous && player.alive
&& intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, item.box)) {
characterStateMachine.beginInteraction();
}
previousSwitchContact.put(contactKey, contact);
}
}
@@ -594,6 +602,8 @@ public final class GameView extends View implements Runnable {
movePlayerVertical(player.vy * dt);
player.x = GameMath.clamp(player.x, 0f, level.worldWidth - PLAYER_WIDTH);
if (player.y > VIEW_HEIGHT + 180f) kill(DeathType.PIT, null);
characterStateMachine.observe(dt, player.alive, player.onGround,
player.vx, player.vy, player.facing);
}
private void movePlayerHorizontal(float amount) {
@@ -757,6 +767,8 @@ public final class GameView extends View implements Runnable {
player.alive = false;
player.vx = player.vy = 0f;
player.supportIndex = -1;
characterStateMachine.observe(0f, false, player.onGround,
player.vx, player.vy, player.facing);
deaths++;
respawnTimer = RESPAWN_SECONDS;
emitHaptic(Build.VERSION.SDK_INT >= 27
@@ -893,8 +905,7 @@ public final class GameView extends View implements Runnable {
for (Corpse corpse : corpses) drawCorpse(canvas, corpse);
VolunteerRig.Skin skin = VolunteerRig.Skin.forLevel(level.number);
if (player.alive) {
VolunteerRig.Motion motion = VolunteerRig.aliveMotion(
player.onGround, player.vx, player.vy);
VolunteerRig.Motion motion = VolunteerRig.motionFor(characterStateMachine.state());
volunteerRenderer.draw(canvas, paint, stroke, path,
player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT,
skin, motion, volunteerPhase(motion), player.facing, 1f,
@@ -2339,6 +2350,8 @@ public final class GameView extends View implements Runnable {
player.jumpBuffer = savedPlayer.jumpBufferSeconds();
player.supportIndex = savedPlayer.supportIndex();
player.facing = savedPlayer.facing();
characterStateMachine.reset(player.alive, player.onGround,
player.vx, player.vy, player.facing);
corpses.clear();
for (GameSessionSnapshot.BodyData savedBody : snapshot.bodies()) {
@@ -2427,4 +2440,16 @@ public final class GameView extends View implements Runnable {
player.vy
);
}
public CharacterStateMachine.State characterStateForAutomation() {
return characterStateMachine.state();
}
public void triggerInteractionForAutomation() {
characterStateMachine.beginInteraction();
}
public void triggerStunForAutomation() {
characterStateMachine.stun();
}
}
@@ -4,7 +4,7 @@ package com.projectsacrifice.game;
public final class PhysicsConfig {
private static final PhysicsConfig RELEASE = new PhysicsConfig(
1, 1f / 60f, 1_800f, 370f, 880f,
0.13f, 0.14f, 0.78f, 16);
0.13f, 0.14f, 0.78f, 0.10f, 0.12f, 0.24f, 0.32f, 16);
private final int version;
private final float fixedStepSeconds;
@@ -14,12 +14,17 @@ public final class PhysicsConfig {
private final float coyoteSeconds;
private final float jumpBufferSeconds;
private final float respawnSeconds;
private final float turnSeconds;
private final float landSeconds;
private final float interactSeconds;
private final float stunnedSeconds;
private final int maximumBodies;
private PhysicsConfig(int version, float fixedStepSeconds, float gravity,
float runSpeed, float jumpSpeed, float coyoteSeconds,
float jumpBufferSeconds, float respawnSeconds,
int maximumBodies) {
float turnSeconds, float landSeconds, float interactSeconds,
float stunnedSeconds, int maximumBodies) {
this.version = version;
this.fixedStepSeconds = fixedStepSeconds;
this.gravity = gravity;
@@ -28,6 +33,10 @@ public final class PhysicsConfig {
this.coyoteSeconds = coyoteSeconds;
this.jumpBufferSeconds = jumpBufferSeconds;
this.respawnSeconds = respawnSeconds;
this.turnSeconds = turnSeconds;
this.landSeconds = landSeconds;
this.interactSeconds = interactSeconds;
this.stunnedSeconds = stunnedSeconds;
this.maximumBodies = maximumBodies;
}
@@ -40,5 +49,9 @@ public final class PhysicsConfig {
public float coyoteSeconds() { return coyoteSeconds; }
public float jumpBufferSeconds() { return jumpBufferSeconds; }
public float respawnSeconds() { return respawnSeconds; }
public float turnSeconds() { return turnSeconds; }
public float landSeconds() { return landSeconds; }
public float interactSeconds() { return interactSeconds; }
public float stunnedSeconds() { return stunnedSeconds; }
public int maximumBodies() { return maximumBodies; }
}
@@ -49,8 +49,13 @@ public final class VolunteerRig {
public enum Motion {
IDLE,
RUN,
TURN,
JUMP,
FALL,
LAND,
INTERACT,
STUNNED,
DEATH,
DEAD,
PINNED,
FROZEN,
@@ -117,6 +122,12 @@ public final class VolunteerRig {
return Math.abs(horizontalVelocity) >= 30f ? Motion.RUN : Motion.IDLE;
}
public static Motion motionFor(CharacterStateMachine.State state) {
CharacterStateMachine.State safeState = state == null
? CharacterStateMachine.State.IDLE : state;
return Motion.valueOf(safeState.name());
}
public static Motion bodyMotion(boolean frozen, boolean conductive, boolean pinned) {
if (frozen) return Motion.FROZEN;
if (conductive) return Motion.CONDUCTIVE;
@@ -156,6 +167,14 @@ public final class VolunteerRig {
xs[Joint.RIGHT_FOOT.ordinal()] += swing * 0.18f;
ys[Joint.ROOT.ordinal()] -= Math.abs(swing) * 0.025f;
break;
case TURN:
xs[Joint.LEFT_HAND.ordinal()] = 0.72f;
xs[Joint.RIGHT_HAND.ordinal()] = 0.28f;
ys[Joint.LEFT_HAND.ordinal()] = 0.55f;
ys[Joint.RIGHT_HAND.ordinal()] = 0.55f;
xs[Joint.LEFT_KNEE.ordinal()] = 0.48f;
xs[Joint.RIGHT_KNEE.ordinal()] = 0.52f;
break;
case JUMP:
xs[Joint.LEFT_HAND.ordinal()] = 0.16f;
xs[Joint.RIGHT_HAND.ordinal()] = 0.84f;
@@ -174,6 +193,28 @@ public final class VolunteerRig {
xs[Joint.LEFT_FOOT.ordinal()] = 0.23f;
xs[Joint.RIGHT_FOOT.ordinal()] = 0.77f;
break;
case LAND:
ys[Joint.ROOT.ordinal()] = 0.91f;
ys[Joint.HIPS.ordinal()] = 0.74f;
ys[Joint.LEFT_KNEE.ordinal()] = 0.87f;
ys[Joint.RIGHT_KNEE.ordinal()] = 0.87f;
xs[Joint.LEFT_HAND.ordinal()] = 0.18f;
xs[Joint.RIGHT_HAND.ordinal()] = 0.82f;
break;
case INTERACT:
xs[Joint.RIGHT_HAND.ordinal()] = 0.98f;
ys[Joint.RIGHT_HAND.ordinal()] = 0.47f;
xs[Joint.LEFT_HAND.ordinal()] = 0.45f;
ys[Joint.LEFT_HAND.ordinal()] = 0.54f;
break;
case STUNNED:
xs[Joint.LEFT_HAND.ordinal()] = 0.36f;
xs[Joint.RIGHT_HAND.ordinal()] = 0.64f;
ys[Joint.LEFT_HAND.ordinal()] = 0.22f;
ys[Joint.RIGHT_HAND.ordinal()] = 0.22f;
xs[Joint.HEAD.ordinal()] += swing * 0.04f;
break;
case DEATH:
case DEAD:
case PINNED:
case FROZEN:
@@ -196,7 +237,8 @@ public final class VolunteerRig {
if (facing < 0) {
for (int i = 0; i < xs.length; i++) xs[i] = 1f - xs[i];
}
boolean visorLit = safeMotion != Motion.DEAD
boolean visorLit = safeMotion != Motion.DEATH
&& safeMotion != Motion.DEAD
&& safeMotion != Motion.PINNED
&& safeMotion != Motion.FROZEN
&& safeMotion != Motion.CONDUCTIVE;
@@ -0,0 +1,109 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import org.junit.Test;
public final class CharacterStateMachineTest {
@Test
public void groundedVelocitySelectsIdleAndRunDeterministically() {
CharacterStateMachine machine = new CharacterStateMachine(PhysicsConfig.release());
machine.reset(true, true, 0f, 0f, 1);
assertEquals(CharacterStateMachine.State.IDLE, machine.state());
machine.observe(1f / 60f, true, true, 370f, 0f, 1);
assertEquals(CharacterStateMachine.State.RUN, machine.state());
machine.observe(1f / 60f, true, true, 0f, 0f, 1);
assertEquals(CharacterStateMachine.State.IDLE, machine.state());
}
@Test
public void verticalMotionSelectsJumpFallAndDeathHasPriority() {
CharacterStateMachine machine = new CharacterStateMachine(PhysicsConfig.release());
machine.reset(true, false, 120f, -300f, 1);
assertEquals(CharacterStateMachine.State.JUMP, machine.state());
machine.observe(1f / 60f, true, false, 120f, 80f, 1);
assertEquals(CharacterStateMachine.State.FALL, machine.state());
machine.observe(1f / 60f, false, true, 0f, 0f, 1);
assertEquals(CharacterStateMachine.State.DEATH, machine.state());
}
@Test
public void changingFacingEmitsOneConfiguredTurnWindow() {
PhysicsConfig physics = PhysicsConfig.release();
CharacterStateMachine machine = new CharacterStateMachine(physics);
machine.reset(true, true, physics.runSpeed(), 0f, 1);
machine.observe(physics.fixedStepSeconds(), true, true,
-physics.runSpeed(), 0f, -1);
assertEquals(CharacterStateMachine.State.TURN, machine.state());
machine.observe(physics.turnSeconds() + physics.fixedStepSeconds(),
true, true, -physics.runSpeed(), 0f, -1);
assertEquals(CharacterStateMachine.State.RUN, machine.state());
}
@Test
public void firstGroundedFrameAfterFallingEmitsConfiguredLandWindow() {
PhysicsConfig physics = PhysicsConfig.release();
CharacterStateMachine machine = new CharacterStateMachine(physics);
machine.reset(true, false, 0f, 300f, 1);
machine.observe(physics.fixedStepSeconds(), true, true, 0f, 0f, 1);
assertEquals(CharacterStateMachine.State.LAND, machine.state());
machine.observe(physics.landSeconds() + physics.fixedStepSeconds(),
true, true, 0f, 0f, 1);
assertEquals(CharacterStateMachine.State.IDLE, machine.state());
}
@Test
public void interactionIsAConfiguredCommandAndDeathCancelsIt() {
PhysicsConfig physics = PhysicsConfig.release();
CharacterStateMachine machine = new CharacterStateMachine(physics);
machine.reset(true, true, 0f, 0f, 1);
machine.beginInteraction();
assertEquals(CharacterStateMachine.State.INTERACT, machine.state());
machine.observe(physics.interactSeconds() * 0.5f,
true, true, 0f, 0f, 1);
assertEquals(CharacterStateMachine.State.INTERACT, machine.state());
machine.observe(physics.fixedStepSeconds(), false, true, 0f, 0f, 1);
assertEquals(CharacterStateMachine.State.DEATH, machine.state());
}
@Test
public void stunOverridesInteractionThenReturnsToObservedMovement() {
PhysicsConfig physics = PhysicsConfig.release();
CharacterStateMachine machine = new CharacterStateMachine(physics);
machine.reset(true, true, physics.runSpeed(), 0f, 1);
machine.beginInteraction();
machine.stun();
assertEquals(CharacterStateMachine.State.STUNNED, machine.state());
machine.observe(physics.stunnedSeconds() * 0.5f,
true, true, physics.runSpeed(), 0f, 1);
assertEquals(CharacterStateMachine.State.STUNNED, machine.state());
machine.observe(physics.stunnedSeconds(),
true, true, physics.runSpeed(), 0f, 1);
assertEquals(CharacterStateMachine.State.RUN, machine.state());
}
@Test
public void interactionCannotDowngradeAnActiveStun() {
CharacterStateMachine machine = new CharacterStateMachine(PhysicsConfig.release());
machine.reset(true, true, 0f, 0f, 1);
machine.stun();
machine.beginInteraction();
assertEquals(CharacterStateMachine.State.STUNNED, machine.state());
}
}
@@ -18,6 +18,10 @@ public final class PhysicsConfigTest {
assertEquals(0.13f, physics.coyoteSeconds(), 0f);
assertEquals(0.14f, physics.jumpBufferSeconds(), 0f);
assertEquals(0.78f, physics.respawnSeconds(), 0f);
assertEquals(0.10f, physics.turnSeconds(), 0f);
assertEquals(0.12f, physics.landSeconds(), 0f);
assertEquals(0.24f, physics.interactSeconds(), 0f);
assertEquals(0.32f, physics.stunnedSeconds(), 0f);
assertTrue(physics.maximumBodies() >= 10);
}
}
@@ -84,4 +84,32 @@ public final class VolunteerRigTest {
assertEquals(VolunteerRig.Motion.DEAD,
VolunteerRig.bodyMotion(false, false, false));
}
@Test
public void everyLivingCharacterStateHasAnExplicitAnimationMotion() {
for (CharacterStateMachine.State state : CharacterStateMachine.State.values()) {
assertEquals(state.name(), VolunteerRig.motionFor(state).name());
}
}
@Test
public void transientCharacterStatesHaveNonColorPoseSignals() {
VolunteerRig.Skin skin = VolunteerRig.Skin.BRASS_SCOUT;
VolunteerRig.Pose idle = VolunteerRig.pose(skin, VolunteerRig.Motion.IDLE, 0.25f, 1);
VolunteerRig.Pose turn = VolunteerRig.pose(skin, VolunteerRig.Motion.TURN, 0.25f, 1);
VolunteerRig.Pose land = VolunteerRig.pose(skin, VolunteerRig.Motion.LAND, 0.25f, 1);
VolunteerRig.Pose interact = VolunteerRig.pose(
skin, VolunteerRig.Motion.INTERACT, 0.25f, 1);
VolunteerRig.Pose stunned = VolunteerRig.pose(
skin, VolunteerRig.Motion.STUNNED, 0.25f, 1);
assertNotEquals(idle.x(VolunteerRig.Joint.LEFT_HAND),
turn.x(VolunteerRig.Joint.LEFT_HAND), 0.001f);
assertNotEquals(idle.y(VolunteerRig.Joint.ROOT),
land.y(VolunteerRig.Joint.ROOT), 0.001f);
assertNotEquals(idle.x(VolunteerRig.Joint.RIGHT_HAND),
interact.x(VolunteerRig.Joint.RIGHT_HAND), 0.001f);
assertNotEquals(idle.y(VolunteerRig.Joint.LEFT_HAND),
stunned.y(VolunteerRig.Joint.LEFT_HAND), 0.001f);
}
}