Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion modules/core/src/main/java/com/jvn/core/vn/DefaultVnInterop.java
Original file line number Diff line number Diff line change
Expand Up @@ -1923,6 +1923,7 @@ private void handleCharacter(String payload, VnScene scene) {
String displaySlot = null;
Easing.Type easingType = null;
long durationMs = 0;
long expressionDurationMs = -1L;
for (int ti = startIdx; ti < toks.length; ti++) {
String tok = toks[ti].trim();
if (tok.isEmpty()) continue;
Expand All @@ -1949,6 +1950,16 @@ private void handleCharacter(String payload, VnScene scene) {
case "ms":
durationMs = Math.max(0L, parseLongSafe(value, durationMs));
break;
case "exprdur":
case "exprduration":
case "expr_duration":
case "expr-duration":
case "expressiondur":
case "expressionduration":
case "expression_duration":
case "expression-duration":
expressionDurationMs = Math.max(0L, parseLongSafe(value, expressionDurationMs));
break;
case "ease":
case "easing":
Easing.Type namedEasing = parseEasingType(value);
Expand All @@ -1975,7 +1986,12 @@ private void handleCharacter(String payload, VnScene scene) {
state.setCharacterDefinedPosition(characterId, position);
}
state.showCharacterAnimated(position, characterId,
expression == null ? "neutral" : expression, null, easingType, durationMs, displaySlot);
expression == null ? "neutral" : expression,
null,
easingType,
durationMs,
displaySlot,
expressionDurationMs);
break;
}
case "show": {
Expand Down
10 changes: 10 additions & 0 deletions modules/core/src/main/java/com/jvn/core/vn/VnNode.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public class VnNode {
private final String displaySlot;
private final Easing.Type moveEasingType;
private final long moveDurationMs;
private final long expressionDurationMs;
private final VnExternalCommand externalCommand;
private final VnParticleCommand particleCommand;
private final String groupTargetId;
Expand All @@ -48,6 +49,7 @@ private VnNode(Builder builder) {
this.displaySlot = builder.displaySlot;
this.moveEasingType = builder.moveEasingType;
this.moveDurationMs = builder.moveDurationMs;
this.expressionDurationMs = builder.expressionDurationMs;
this.externalCommand = builder.externalCommand;
this.particleCommand = builder.particleCommand;
this.groupTargetId = builder.groupTargetId;
Expand All @@ -71,6 +73,12 @@ private VnNode(Builder builder) {
public String getDisplaySlot() { return displaySlot; }
public Easing.Type getMoveEasingType() { return moveEasingType; }
public long getMoveDurationMs() { return moveDurationMs; }
/**
* Expression transition duration for move nodes.
*
* @return milliseconds, or {@code -1} when the runtime default should be used
*/
public long getExpressionDurationMs() { return expressionDurationMs; }
public VnExternalCommand getExternalCommand() { return externalCommand; }
public VnParticleCommand getParticleCommand() { return particleCommand; }
public String getGroupTargetId() { return groupTargetId; }
Expand All @@ -96,6 +104,7 @@ public static class Builder {
private String displaySlot;
private Easing.Type moveEasingType;
private long moveDurationMs;
private long expressionDurationMs = -1L;
private VnExternalCommand externalCommand;
private VnParticleCommand particleCommand;
private String groupTargetId;
Expand All @@ -119,6 +128,7 @@ public static class Builder {
public Builder displaySlot(String slot) { this.displaySlot = slot; return this; }
public Builder moveEasingType(Easing.Type easing) { this.moveEasingType = easing; return this; }
public Builder moveDurationMs(long ms) { this.moveDurationMs = ms; return this; }
public Builder expressionDurationMs(long ms) { this.expressionDurationMs = ms; return this; }
public Builder external(VnExternalCommand cmd) { this.externalCommand = cmd; return this; }
public Builder particleCommand(VnParticleCommand cmd) { this.particleCommand = cmd; return this; }
public Builder groupTargetId(String targetId) { this.groupTargetId = targetId; return this; }
Expand Down
11 changes: 11 additions & 0 deletions modules/core/src/main/java/com/jvn/core/vn/VnScenarioBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -252,13 +252,24 @@ public VnScenarioBuilder move(String characterId,
Easing.Type easingType,
long durationMs,
String displaySlot) {
return move(characterId, position, expression, easingType, durationMs, displaySlot, -1L);
}

public VnScenarioBuilder move(String characterId,
CharacterPosition position,
String expression,
Easing.Type easingType,
long durationMs,
String displaySlot,
long expressionDurationMs) {
VnNode.Builder b = VnNode.builder(VnNodeType.MOVE)
.characterToShow(characterId)
.showPosition(position)
.displaySlot(displaySlot);
if (expression != null) b.showExpression(expression);
if (easingType != null) b.moveEasingType(easingType);
if (durationMs > 0) b.moveDurationMs(durationMs);
if (expressionDurationMs >= 0L) b.expressionDurationMs(expressionDurationMs);
scenarioBuilder.addNode(b.build());
return this;
}
Expand Down
9 changes: 7 additions & 2 deletions modules/core/src/main/java/com/jvn/core/vn/VnScene.java
Original file line number Diff line number Diff line change
Expand Up @@ -782,13 +782,18 @@ private void processMoveNode(VnNode node) {
}
String expr = node.getShowExpression() != null ? node.getShowExpression() : null;
state.showCharacterAnimated(node.getShowPosition(), node.getCharacterToShow(), expr,
null, node.getMoveEasingType(), node.getMoveDurationMs(), node.getDisplaySlot());
null,
node.getMoveEasingType(),
node.getMoveDurationMs(),
node.getDisplaySlot(),
node.getExpressionDurationMs());
} else if (node.getDisplaySlot() != null && node.getShowPosition() != null) {
state.moveDisplaySlotAnimated(node.getDisplaySlot(),
node.getShowPosition(),
node.getShowExpression(),
node.getMoveEasingType(),
node.getMoveDurationMs());
node.getMoveDurationMs(),
node.getExpressionDurationMs());
}
}

Expand Down
80 changes: 60 additions & 20 deletions modules/core/src/main/java/com/jvn/core/vn/VnState.java
Original file line number Diff line number Diff line change
Expand Up @@ -249,18 +249,40 @@ public void showCharacterAnimated(CharacterPosition position,
Easing.Type easingType,
long customDurationMs,
String displaySlot) {
showCharacterAnimated(
position,
characterId,
expression,
layerOrder,
easingType,
customDurationMs,
displaySlot,
-1L);
}

public void showCharacterAnimated(CharacterPosition position,
String characterId,
String expression,
Integer layerOrder,
Easing.Type easingType,
long customDurationMs,
String displaySlot,
long expressionDurationMs) {
String normalizedSlot = normalizeDisplaySlotId(displaySlot);
CharacterPosition baseTarget = fallbackPositionFor(characterId, position);
CharacterPosition target = displayPositionFor(baseTarget, normalizedSlot);
CharacterPosition existingPos = findTargetPosition(characterId, normalizedSlot);
CharacterSlot existingSlot = existingPos == null ? null : visibleCharacters.get(existingPos);
String fallbackExpression = existingSlot != null ? existingSlot.getExpression() : "neutral";
String resolvedExpression = normalizeExpression(expression, fallbackExpression);
long resolvedExpressionDurationMs = expressionDurationMs >= 0L
? expressionDurationMs
: DEFAULT_EXPRESSION_TRANSITION_MS;
int resolvedLayerOrder = resolveLayerOrder(baseTarget, layerOrder, existingSlot != null ? existingSlot.getLayerOrder() : null);

if (existingPos != null && existingSlot != null && existingPos.equals(target)) {
updateVisibleSlotExpression(target, existingSlot, resolvedExpression, resolvedLayerOrder,
DEFAULT_EXPRESSION_TRANSITION_MS, null);
resolvedExpressionDurationMs, null);
pendingExpressionSwitches.remove(target);
ensureCharacterVisual(target);
if (normalizedSlot.isEmpty() && isCharacterGlobalPositionEnabled(characterId)) {
Expand All @@ -281,7 +303,12 @@ public void showCharacterAnimated(CharacterPosition position,
pendingExpressionSwitches.remove(target);
if (!resolvedExpression.equals(movingExpression)) {
pendingExpressionSwitches.put(target, new PendingExpressionSwitch(
characterId, normalizedSlot, resolvedExpression, moveDur, DEFAULT_EXPRESSION_TRANSITION_MS, null));
characterId,
normalizedSlot,
resolvedExpression,
moveDur,
resolvedExpressionDurationMs,
null));
}
if (normalizedSlot.isEmpty()) {
characterDefinedPositions.put(characterId, baseTarget);
Expand Down Expand Up @@ -312,6 +339,15 @@ public boolean moveDisplaySlotAnimated(String displaySlot,
String expression,
Easing.Type easingType,
long customDurationMs) {
return moveDisplaySlotAnimated(displaySlot, position, expression, easingType, customDurationMs, -1L);
}

public boolean moveDisplaySlotAnimated(String displaySlot,
CharacterPosition position,
String expression,
Easing.Type easingType,
long customDurationMs,
long expressionDurationMs) {
CharacterPosition existingPos = findDisplaySlotPosition(displaySlot);
CharacterSlot existingSlot = existingPos == null ? null : visibleCharacters.get(existingPos);
if (existingSlot == null) return false;
Expand All @@ -321,7 +357,8 @@ public boolean moveDisplaySlotAnimated(String displaySlot,
null,
easingType,
customDurationMs,
existingSlot.getDisplaySlot());
existingSlot.getDisplaySlot(),
expressionDurationMs);
return true;
}

Expand Down Expand Up @@ -404,6 +441,26 @@ public void updateCharacterAnimations(long deltaMs) {
}
}

if (!expressionTransitions.isEmpty()) {
var it = expressionTransitions.entrySet().iterator();
while (it.hasNext()) {
var entry = it.next();
String stateKey = entry.getKey();
ExpressionTransition transition = entry.getValue();
if (findStateKeyPosition(stateKey) == null && !detachedCharacters.containsKey(stateKey)) {
it.remove();
continue;
}
transition.update(deltaMs);
if (transition.isFinished()) {
it.remove();
}
}
}

// Start delayed expression swaps after advancing transitions that were
// already active at the beginning of this frame. A newly-started
// crossfade must not consume the movement frame's entire delta.
if (!pendingExpressionSwitches.isEmpty()) {
var it = pendingExpressionSwitches.entrySet().iterator();
while (it.hasNext()) {
Expand All @@ -422,23 +479,6 @@ public void updateCharacterAnimations(long deltaMs) {
it.remove();
}
}

if (!expressionTransitions.isEmpty()) {
var it = expressionTransitions.entrySet().iterator();
while (it.hasNext()) {
var entry = it.next();
String stateKey = entry.getKey();
ExpressionTransition transition = entry.getValue();
if (findStateKeyPosition(stateKey) == null && !detachedCharacters.containsKey(stateKey)) {
it.remove();
continue;
}
transition.update(deltaMs);
if (transition.isFinished()) {
it.remove();
}
}
}
}

public void setCharacterGlobalPositionEnabled(String characterId, boolean enabled) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1616,6 +1616,7 @@ private void parseCommand(String commandBody,
String moveExpr = null;
Easing.Type moveEasing = null;
long moveDur = 0;
long moveExprDur = -1L;

// Parse all tokens
int i = 0;
Expand Down Expand Up @@ -1701,6 +1702,19 @@ private void parseCommand(String commandBody,
moveDur = parseLongValue(option.value(), "[move]", "duration", sourceName, lineNumber, rawLine);
if (moveDur < 0) throw parseError(sourceName, lineNumber, "[move] duration must be >= 0", rawLine);
}
case "exprdur", "exprduration", "expr_duration", "expr-duration",
"expressiondur", "expressionduration", "expression_duration", "expression-duration" -> {
moveExprDur = parseLongValue(
option.value(),
"[move]",
"expression duration",
sourceName,
lineNumber,
rawLine);
if (moveExprDur < 0) {
throw parseError(sourceName, lineNumber, "[move] expression duration must be >= 0", rawLine);
}
}
default -> throw parseError(sourceName, lineNumber, "[move] unknown option: " + option.key(), rawLine);
}
i++;
Expand Down Expand Up @@ -1742,7 +1756,14 @@ private void parseCommand(String commandBody,
throw parseError(sourceName, lineNumber, "[move] expects a character id or slot=...", rawLine);
}

state.builder.move(moveCharId, movePos, moveExpr, moveEasing, moveDur, moveDisplaySlot);
state.builder.move(
moveCharId,
movePos,
moveExpr,
moveEasing,
moveDur,
moveDisplaySlot,
moveExprDur);
return;
}
case "stage": {
Expand Down Expand Up @@ -2629,6 +2650,8 @@ private boolean isNamedOptionToken(String token, String commandName) {
case "move" -> switch (key) {
case "pos", "position", "at", "coord", "coords", "xy",
"expr", "expression", "preset", "ease", "easing", "dur", "duration", "ms",
"exprdur", "exprduration", "expr_duration", "expr-duration",
"expressiondur", "expressionduration", "expression_duration", "expression-duration",
"slot", "as", "instance", "display", "display_slot", "display-slot" -> true;
default -> false;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,75 @@ void characterExpressionCommandAcceptsTransitionDuration() {
assertEquals("talking", transition.getToExpression());
}

@Test
void characterExpressionCommandSupportsInstantAndDefaultSwaps() {
VnScenario scenario = new VnScenarioBuilder("expression_swap_modes")
.addCharacterWithExpressions("lily", "Lily", "neutral.png", "talking.png")
.label("start")
.end()
.build();
VnScene scene = new VnScene(scenario);
scene.getState().showCharacter(CharacterPosition.CENTER, "lily", "neutral");
DefaultVnInterop interop = new DefaultVnInterop();

interop.handle(new VnExternalCommand("char", "lily expression talking dur=0"), scene);
assertEquals("talking", scene.getState().getCharacterExpression("lily"));
assertNull(scene.getState().getExpressionTransition("lily"));

interop.handle(new VnExternalCommand("char", "lily expression neutral"), scene);
assertEquals("neutral", scene.getState().getCharacterExpression("lily"));
assertNotNull(scene.getState().getExpressionTransition("lily"));
}

@Test
void characterMoveAppliesExpressionDurationIndependently() {
VnScenario scenario = new VnScenarioBuilder("independent_expression_duration")
.addCharacterWithExpressions("john", "John", "neutral.png", "talking.png")
.label("start")
.end()
.build();
VnScene scene = new VnScene(scenario);
scene.getState().showCharacter(CharacterPosition.CENTER, "john", "neutral");
scene.getState().setCharacterGlobalPositionEnabled("john", true);
DefaultVnInterop interop = new DefaultVnInterop();

interop.handle(
new VnExternalCommand(
"char",
"john move right expression=talking dur=400 exprDur=120"),
scene);

assertEquals("neutral", scene.getState().getCharacterExpression("john"));
scene.getState().updateCharacterAnimations(400);
assertEquals("talking", scene.getState().getCharacterExpression("john"));
assertNotNull(scene.getState().getExpressionTransition("john"));
scene.getState().updateCharacterAnimations(120);
assertNull(scene.getState().getExpressionTransition("john"));
}

@Test
void characterMoveAcceptsQuotedExpressionWithInstantDuration() {
VnScenario scenario = new VnScenarioBuilder("quoted_expression_duration")
.addCharacterWithExpressions("john", "John", "neutral.png")
.label("start")
.end()
.build();
VnScene scene = new VnScene(scenario);
scene.getState().showCharacter(CharacterPosition.CENTER, "john", "neutral");
scene.getState().setCharacterGlobalPositionEnabled("john", true);
DefaultVnInterop interop = new DefaultVnInterop();

interop.handle(
new VnExternalCommand(
"char",
"john move right expression=\"soft smile\" dur=400 exprDur=0"),
scene);

scene.getState().updateCharacterAnimations(400);
assertEquals("soft smile", scene.getState().getCharacterExpression("john"));
assertNull(scene.getState().getExpressionTransition("john"));
}

@Test
void laterInlineTimelineMoveReplacesEarlierCharacterDisplacement() {
VnScenario scenario = new VnScenarioBuilder("timeline_displacement_latest")
Expand Down
Loading
Loading