Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81d65bc381 | ||
|
|
bdae0007a1 | ||
|
|
c052b086f8 | ||
|
|
c2a14bac7d | ||
|
|
0d269a6041 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,8 +7,8 @@ android {
|
|||||||
applicationId "com.warren.iwanttoheal"
|
applicationId "com.warren.iwanttoheal"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 94
|
versionCode 99
|
||||||
versionName "1.1.15"
|
versionName "1.1.20"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -164,6 +164,9 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Set<String> currentTokens = new HashSet<>();
|
Set<String> currentTokens = new HashSet<>();
|
||||||
|
float leftStickX = event.getAxisValue(MotionEvent.AXIS_X);
|
||||||
|
float leftStickY = event.getAxisValue(MotionEvent.AXIS_Y);
|
||||||
|
dispatchNativeControllerMotion(leftStickX, leftStickY);
|
||||||
addAxisTokens(
|
addAxisTokens(
|
||||||
currentTokens,
|
currentTokens,
|
||||||
event.getAxisValue(MotionEvent.AXIS_HAT_X),
|
event.getAxisValue(MotionEvent.AXIS_HAT_X),
|
||||||
@@ -178,13 +181,13 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
|||||||
);
|
);
|
||||||
addAxisTokens(
|
addAxisTokens(
|
||||||
currentTokens,
|
currentTokens,
|
||||||
event.getAxisValue(MotionEvent.AXIS_X),
|
leftStickX,
|
||||||
"Axis0-",
|
"Axis0-",
|
||||||
"Axis0+"
|
"Axis0+"
|
||||||
);
|
);
|
||||||
addAxisTokens(
|
addAxisTokens(
|
||||||
currentTokens,
|
currentTokens,
|
||||||
event.getAxisValue(MotionEvent.AXIS_Y),
|
leftStickY,
|
||||||
"Axis1-",
|
"Axis1-",
|
||||||
"Axis1+"
|
"Axis1+"
|
||||||
);
|
);
|
||||||
@@ -240,6 +243,19 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void dispatchNativeControllerMotion(float x, float y) {
|
||||||
|
if (bridge == null || bridge.getWebView() == null) return;
|
||||||
|
String script =
|
||||||
|
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller-motion',"
|
||||||
|
+ "{detail:{x:" + x + ",y:" + y + "}}));";
|
||||||
|
bridge.getWebView().post(
|
||||||
|
() -> {
|
||||||
|
bridge.getWebView().requestFocus();
|
||||||
|
bridge.getWebView().evaluateJavascript(script, null);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private boolean shouldThrottleDpad(String token) {
|
private boolean shouldThrottleDpad(String token) {
|
||||||
int buttonIndex = Integer.parseInt(token.substring("Button".length()));
|
int buttonIndex = Integer.parseInt(token.substring("Button".length()));
|
||||||
long now = SystemClock.uptimeMillis();
|
long now = SystemClock.uptimeMillis();
|
||||||
|
|||||||
+470
-14
@@ -145,6 +145,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-boss-hud {
|
.iwt2-boss-hud {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
min-width: 310px;
|
min-width: 310px;
|
||||||
padding: 8px 12px 10px;
|
padding: 8px 12px 10px;
|
||||||
@@ -155,7 +157,7 @@
|
|||||||
z-index: 4;
|
z-index: 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-boss-hud > div:first-child {
|
.iwt2-boss-hud-row > div:first-child {
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -630,23 +632,44 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-profile-grid,
|
.iwt2-profile-grid,
|
||||||
.iwt2-settings-panel {
|
.iwt2-settings-panel,
|
||||||
|
.iwt2-customize-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-action-list {
|
.iwt2-loadout-preview {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-loadout-preview > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-loadout-preview h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-loadout-preview span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-action-list,
|
||||||
|
.iwt2-info-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-action-row {
|
.iwt2-action-row,
|
||||||
|
.iwt2-info-row {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: #10141b;
|
background: #10141b;
|
||||||
border: 2px solid #090a0d;
|
border: 2px solid #090a0d;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
cursor: pointer;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
@@ -656,12 +679,22 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-action-row strong {
|
.iwt2-action-row {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-info-row {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-action-row strong,
|
||||||
|
.iwt2-info-row strong {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-action-row small {
|
.iwt2-action-row small,
|
||||||
|
.iwt2-info-row small {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
@@ -669,12 +702,23 @@
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-action-row em {
|
.iwt2-action-row em,
|
||||||
|
.iwt2-info-row em {
|
||||||
color: #fff4a8;
|
color: #fff4a8;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iwt2-action-row:disabled {
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-action-row:disabled em {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
.iwt2-action-row.selected,
|
.iwt2-action-row.selected,
|
||||||
.iwt2-menu-card.game-selected {
|
.iwt2-menu-card.game-selected {
|
||||||
background: #1c2633;
|
background: #1c2633;
|
||||||
@@ -708,6 +752,13 @@
|
|||||||
grid-template-rows: auto auto minmax(0, 1fr);
|
grid-template-rows: auto auto minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iwt2-bottom-display .dual-control-chip {
|
||||||
|
background: var(--panel-light);
|
||||||
|
border: 2px solid #090a0d;
|
||||||
|
outline: 2px solid #494756;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.game-version-grid,
|
.game-version-grid,
|
||||||
.iwt2-menu-grid {
|
.iwt2-menu-grid {
|
||||||
@@ -725,7 +776,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-profile-grid,
|
.iwt2-profile-grid,
|
||||||
.iwt2-settings-panel {
|
.iwt2-settings-panel,
|
||||||
|
.iwt2-customize-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -787,6 +839,285 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1000px) and (max-height: 620px) {
|
||||||
|
.game-shell.iwt2-shell {
|
||||||
|
padding: 6px 0;
|
||||||
|
width: min(100%, calc(100% - 20px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-shell .app-header {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-shell .brand-button strong {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-shell .character-summary {
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-shell .character-summary strong {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-shell .character-summary small {
|
||||||
|
font-size: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-menu-screen {
|
||||||
|
flex: 1;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-menu-heading {
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-menu-heading .eyebrow {
|
||||||
|
font-size: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-menu-heading h1 {
|
||||||
|
font-size: 1.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-menu-grid {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-menu-card {
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: 42px minmax(0, 1fr);
|
||||||
|
min-height: 92px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-menu-card > span {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
height: 42px;
|
||||||
|
width: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-menu-card strong {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-menu-card small {
|
||||||
|
font-size: 0.64rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-screen-shell {
|
||||||
|
flex: 1;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
max-width: none;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-screen-shell .screen-heading {
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-screen-shell .screen-heading .eyebrow {
|
||||||
|
font-size: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-screen-shell .screen-heading h1 {
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-screen-shell .back-button {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 5px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-action-list,
|
||||||
|
.iwt2-info-list {
|
||||||
|
gap: 7px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-action-row,
|
||||||
|
.iwt2-info-row {
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 54px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-action-row strong,
|
||||||
|
.iwt2-info-row strong {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-action-row small,
|
||||||
|
.iwt2-info-row small {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-profile-grid,
|
||||||
|
.iwt2-settings-panel,
|
||||||
|
.iwt2-customize-layout {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-settings-panel,
|
||||||
|
.iwt2-customize-layout {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-settings-panel > *,
|
||||||
|
.iwt2-customize-layout > *,
|
||||||
|
.iwt2-loadout-preview {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-loadout-preview {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-loadout-preview h2 {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-loadout-preview span {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-controller-preview {
|
||||||
|
min-height: 112px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-boss-hud {
|
||||||
|
min-width: 280px;
|
||||||
|
padding: 6px 10px 8px;
|
||||||
|
top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-boss-hud strong {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-boss-phase {
|
||||||
|
font-size: 0.5rem;
|
||||||
|
height: 10px;
|
||||||
|
line-height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-party-list {
|
||||||
|
gap: 5px;
|
||||||
|
left: 10px;
|
||||||
|
max-height: calc(100% - 84px);
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 4px;
|
||||||
|
top: 10px;
|
||||||
|
width: 154px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-party-row {
|
||||||
|
gap: 5px;
|
||||||
|
grid-template-columns: 20px minmax(0, 1fr);
|
||||||
|
height: 62px;
|
||||||
|
min-height: 62px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-party-row.has-target-binding {
|
||||||
|
grid-template-columns: 24px 20px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-party-row > span,
|
||||||
|
.iwt2-party-row .iwt2-party-target-key {
|
||||||
|
height: 20px;
|
||||||
|
min-width: 20px;
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-party-row .iwt2-party-target-key .controller-face-icon {
|
||||||
|
height: 16px;
|
||||||
|
min-width: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-party-row-title {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-party-row strong {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-party-row small {
|
||||||
|
font-size: 0.52rem;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-party-row .iwt2-bar {
|
||||||
|
height: 7px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-party-effects {
|
||||||
|
height: 10px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-effect-badge {
|
||||||
|
font-size: 0.44rem;
|
||||||
|
line-height: 9px;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-party-damage {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-ability-bar {
|
||||||
|
bottom: 8px;
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: repeat(6, 36px);
|
||||||
|
max-width: 256px;
|
||||||
|
width: 256px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-ability-button {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-ability-button > span {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-ability-button strong {
|
||||||
|
font-size: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-ability-button i {
|
||||||
|
font-size: 0.46rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.combat-touch-lock-status {
|
.combat-touch-lock-status {
|
||||||
background: #101216;
|
background: #101216;
|
||||||
border: 2px solid #d9b55a;
|
border: 2px solid #d9b55a;
|
||||||
@@ -1808,7 +2139,7 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pvp-opponent-bottom-display {
|
.pvp-opponent-bottom-display {
|
||||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dual-controls-header,
|
.dual-controls-header,
|
||||||
@@ -1843,6 +2174,7 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dual-opponent-progress,
|
.dual-opponent-progress,
|
||||||
|
.dual-opponent-arena,
|
||||||
.dual-opponent-effects {
|
.dual-opponent-effects {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 3px solid #0c0d11;
|
border: 3px solid #0c0d11;
|
||||||
@@ -1866,6 +2198,53 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
|
|||||||
height: 22px;
|
height: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dual-opponent-arena {
|
||||||
|
padding: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-arena-field {
|
||||||
|
background:
|
||||||
|
linear-gradient(rgba(23, 29, 36, 0.96), rgba(23, 29, 36, 0.96)),
|
||||||
|
repeating-linear-gradient(0deg, transparent 0 39px, rgba(90, 108, 126, 0.28) 40px),
|
||||||
|
repeating-linear-gradient(90deg, transparent 0 39px, rgba(90, 108, 126, 0.28) 40px);
|
||||||
|
border: 2px solid #3c4a59;
|
||||||
|
border-radius: 6px;
|
||||||
|
max-height: 138px;
|
||||||
|
min-height: 112px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-arena-entity {
|
||||||
|
align-items: center;
|
||||||
|
border: 2px solid #0a0c10;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #fff7df;
|
||||||
|
display: flex;
|
||||||
|
font-family: ui-monospace, Consolas, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1;
|
||||||
|
min-height: 12px;
|
||||||
|
min-width: 12px;
|
||||||
|
position: absolute;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-arena-entity.boss {
|
||||||
|
border-color: #fff0b8;
|
||||||
|
border-radius: 45%;
|
||||||
|
font-size: 12px;
|
||||||
|
min-height: 18px;
|
||||||
|
min-width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-arena-entity.healer {
|
||||||
|
box-shadow: 0 0 0 3px rgba(255, 244, 168, 0.84);
|
||||||
|
}
|
||||||
|
|
||||||
.dual-opponent-party-grid {
|
.dual-opponent-party-grid {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 3px solid #0c0d11;
|
border: 3px solid #0c0d11;
|
||||||
@@ -2038,7 +2417,8 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dual-controls-targets button {
|
.dual-controls-targets button,
|
||||||
|
.dual-controls-targets .dual-control-chip {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -2050,7 +2430,8 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
|
|||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.dual-controls-targets.direct button {
|
.dual-controls-targets.direct button,
|
||||||
|
.dual-controls-targets.direct .dual-control-chip {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
min-height: 34px;
|
min-height: 34px;
|
||||||
}
|
}
|
||||||
@@ -2196,7 +2577,9 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dual-controls-targets button,
|
.dual-controls-targets button,
|
||||||
.dual-controls-targets.direct button {
|
.dual-controls-targets.direct button,
|
||||||
|
.dual-controls-targets .dual-control-chip,
|
||||||
|
.dual-controls-targets.direct .dual-control-chip {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
min-height: 30px;
|
min-height: 30px;
|
||||||
padding: 4px 5px;
|
padding: 4px 5px;
|
||||||
@@ -2234,7 +2617,7 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pvp-opponent-bottom-display {
|
.pvp-opponent-bottom-display {
|
||||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dual-opponent-progress {
|
.dual-opponent-progress {
|
||||||
@@ -2257,6 +2640,16 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
|
|||||||
height: 16px;
|
height: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dual-opponent-arena {
|
||||||
|
border-width: 2px;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-arena-field {
|
||||||
|
max-height: 112px;
|
||||||
|
min-height: 92px;
|
||||||
|
}
|
||||||
|
|
||||||
.dual-opponent-party-grid {
|
.dual-opponent-party-grid {
|
||||||
border-width: 2px;
|
border-width: 2px;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -6998,6 +7391,15 @@ h2 {
|
|||||||
opacity: 0.48;
|
opacity: 0.48;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iwt2-bottom-display .iwt2-bottom-spell {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-bottom-display .iwt2-bottom-spell:hover {
|
||||||
|
outline-color: #494756;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.spell kbd {
|
.spell kbd {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: #090a0d;
|
background: #090a0d;
|
||||||
@@ -11513,3 +11915,57 @@ h2 {
|
|||||||
height: 20px;
|
height: 20px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1000px) and (max-height: 620px) {
|
||||||
|
.iwt2-screen-shell.content-screen {
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
max-width: none;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-screen-shell.content-screen > .screen-heading {
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-screen-shell.content-screen > .screen-heading .eyebrow {
|
||||||
|
font-size: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-screen-shell.content-screen > .screen-heading h1 {
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-screen-shell .iwt2-action-list,
|
||||||
|
.iwt2-screen-shell .iwt2-info-list,
|
||||||
|
.iwt2-settings-panel .iwt2-action-list,
|
||||||
|
.iwt2-customize-layout .iwt2-action-list,
|
||||||
|
.iwt2-loadout-preview .iwt2-info-list {
|
||||||
|
gap: 4px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-screen-shell.content-screen > .iwt2-action-list,
|
||||||
|
.iwt2-screen-shell.content-screen > .iwt2-info-list {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-action-row:not(:has(small)),
|
||||||
|
.iwt2-info-row:not(:has(small)) {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-settings-panel,
|
||||||
|
.iwt2-customize-layout,
|
||||||
|
.iwt2-settings-panel > *,
|
||||||
|
.iwt2-customize-layout > *,
|
||||||
|
.iwt2-loadout-preview {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+71
-3
@@ -1,7 +1,12 @@
|
|||||||
import { useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { AuthScreen } from './components/AuthScreen'
|
||||||
import IWantToHeal1App from './modes/iwt1/IWantToHeal1App'
|
import IWantToHeal1App from './modes/iwt1/IWantToHeal1App'
|
||||||
import { IWantToHeal2App } from './modes/iwt2/IWantToHeal2App'
|
import { IWantToHeal2App } from './modes/iwt2/IWantToHeal2App'
|
||||||
import { useGameAction } from './input'
|
import { useGameAction } from './input'
|
||||||
|
import {
|
||||||
|
loadAuthSession,
|
||||||
|
type AuthSession,
|
||||||
|
} from './profile'
|
||||||
|
|
||||||
type GameVersion = 'iwt1' | 'iwt2'
|
type GameVersion = 'iwt1' | 'iwt2'
|
||||||
|
|
||||||
@@ -31,9 +36,46 @@ const GAME_OPTIONS: Array<{
|
|||||||
function App() {
|
function App() {
|
||||||
const [selectedVersion, setSelectedVersion] = useState<GameVersion | null>(null)
|
const [selectedVersion, setSelectedVersion] = useState<GameVersion | null>(null)
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
|
const [authSession, setAuthSession] = useState<AuthSession | null>(null)
|
||||||
|
const [authChecked, setAuthChecked] = useState(false)
|
||||||
|
const [serverMessage, setServerMessage] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
loadAuthSession()
|
||||||
|
.then((session) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setAuthSession(session.account && session.profile ? session : null)
|
||||||
|
})
|
||||||
|
.catch((reason: unknown) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setServerMessage(
|
||||||
|
reason instanceof Error
|
||||||
|
? `${reason.message} Offline play is still available.`
|
||||||
|
: 'Unable to reach the server. Offline play is still available.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setAuthChecked(true)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const acceptSession = useCallback((session: AuthSession) => {
|
||||||
|
setAuthSession(session)
|
||||||
|
setSelectedVersion(null)
|
||||||
|
setServerMessage('')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearAuthSession = useCallback(() => {
|
||||||
|
setAuthSession(null)
|
||||||
|
setSelectedVersion(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
useGameAction((action, device) => {
|
useGameAction((action, device) => {
|
||||||
if (selectedVersion || device !== 'controller') return
|
if (!authSession || selectedVersion || device !== 'controller') return
|
||||||
if (action === 'navigateLeft' || action === 'navigateUp') {
|
if (action === 'navigateLeft' || action === 'navigateUp') {
|
||||||
setSelectedIndex((current) => Math.max(0, current - 1))
|
setSelectedIndex((current) => Math.max(0, current - 1))
|
||||||
} else if (action === 'navigateRight' || action === 'navigateDown') {
|
} else if (action === 'navigateRight' || action === 'navigateDown') {
|
||||||
@@ -43,8 +85,34 @@ function App() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!authChecked) {
|
||||||
|
return (
|
||||||
|
<main className="game-shell">
|
||||||
|
<section className="message-panel">
|
||||||
|
<p className="eyebrow">Opening Chronicle</p>
|
||||||
|
<h1>Loading...</h1>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authSession) {
|
||||||
|
return (
|
||||||
|
<AuthScreen
|
||||||
|
onAuthenticated={acceptSession}
|
||||||
|
serverMessage={serverMessage}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedVersion === 'iwt1') {
|
if (selectedVersion === 'iwt1') {
|
||||||
return <IWantToHeal1App onBackToGameSelect={() => setSelectedVersion(null)} />
|
return (
|
||||||
|
<IWantToHeal1App
|
||||||
|
initialSession={authSession}
|
||||||
|
onAuthenticationCleared={clearAuthSession}
|
||||||
|
onBackToGameSelect={() => setSelectedVersion(null)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedVersion === 'iwt2') {
|
if (selectedVersion === 'iwt2') {
|
||||||
|
|||||||
@@ -1396,16 +1396,13 @@ function applyIwt2AdminOverrides(
|
|||||||
metadata: Record<Iwt2BossId, Iwt2BossMetadata>,
|
metadata: Record<Iwt2BossId, Iwt2BossMetadata>,
|
||||||
overrides: Iwt2BalanceOverrides,
|
overrides: Iwt2BalanceOverrides,
|
||||||
): Record<Iwt2BossId, Iwt2BossMetadata> {
|
): Record<Iwt2BossId, Iwt2BossMetadata> {
|
||||||
return {
|
return (Object.keys(metadata) as Iwt2BossId[]).reduce((nextMetadata, bossId) => ({
|
||||||
bulldrome: {
|
...nextMetadata,
|
||||||
...metadata.bulldrome,
|
[bossId]: {
|
||||||
...overrides.bosses?.bulldrome,
|
...metadata[bossId],
|
||||||
|
...overrides.bosses?.[bossId],
|
||||||
},
|
},
|
||||||
'yian-kut-ku': {
|
}), {} as Record<Iwt2BossId, Iwt2BossMetadata>)
|
||||||
...metadata['yian-kut-ku'],
|
|
||||||
...overrides.bosses?.['yian-kut-ku'],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createIwt2AdminRows(
|
function createIwt2AdminRows(
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ export type DualScreenCombatState = {
|
|||||||
opponentClassName?: string
|
opponentClassName?: string
|
||||||
opponentParty?: PartyMember[]
|
opponentParty?: PartyMember[]
|
||||||
opponentEnemyHealth?: number
|
opponentEnemyHealth?: number
|
||||||
|
opponentArena?: {
|
||||||
|
bounds: { width: number, height: number }
|
||||||
|
bosses: Array<{ id: string, name: string, icon: string, color: string, x: number, y: number, radius: number, health: number, maxHealth: number }>
|
||||||
|
party: Array<{ id: string, icon: string, color: string, x: number, y: number, radius: number, health: number, maxHealth: number, isHealer: boolean }>
|
||||||
|
}
|
||||||
opponentResource?: number
|
opponentResource?: number
|
||||||
opponentMaxResource?: number
|
opponentMaxResource?: number
|
||||||
opponentResourceName?: string
|
opponentResourceName?: string
|
||||||
@@ -638,6 +643,48 @@ export function DualScreenBottomDisplay() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{state.opponentArena && (
|
||||||
|
<section className="dual-opponent-arena" aria-label="Opponent arena">
|
||||||
|
<div
|
||||||
|
className="dual-opponent-arena-field"
|
||||||
|
style={{ aspectRatio: `${state.opponentArena.bounds.width} / ${state.opponentArena.bounds.height}` }}
|
||||||
|
>
|
||||||
|
{state.opponentArena.bosses.map((boss) => (
|
||||||
|
<div
|
||||||
|
className="dual-opponent-arena-entity boss"
|
||||||
|
key={boss.id}
|
||||||
|
style={{
|
||||||
|
backgroundColor: boss.color,
|
||||||
|
height: `${Math.max(8, (boss.radius / state.opponentArena!.bounds.height) * 100)}%`,
|
||||||
|
left: `${(boss.x / state.opponentArena!.bounds.width) * 100}%`,
|
||||||
|
top: `${(boss.y / state.opponentArena!.bounds.height) * 100}%`,
|
||||||
|
width: `${Math.max(8, (boss.radius / state.opponentArena!.bounds.width) * 100)}%`,
|
||||||
|
}}
|
||||||
|
title={`${boss.name} ${Math.ceil(boss.health)} / ${boss.maxHealth}`}
|
||||||
|
>
|
||||||
|
{boss.icon}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{state.opponentArena.party.map((member) => (
|
||||||
|
<div
|
||||||
|
className={`dual-opponent-arena-entity party ${member.isHealer ? 'healer' : ''}`}
|
||||||
|
key={member.id}
|
||||||
|
style={{
|
||||||
|
backgroundColor: member.color,
|
||||||
|
height: `${Math.max(5, (member.radius / state.opponentArena!.bounds.height) * 100)}%`,
|
||||||
|
left: `${(member.x / state.opponentArena!.bounds.width) * 100}%`,
|
||||||
|
opacity: member.health > 0 ? 1 : 0.35,
|
||||||
|
top: `${(member.y / state.opponentArena!.bounds.height) * 100}%`,
|
||||||
|
width: `${Math.max(5, (member.radius / state.opponentArena!.bounds.width) * 100)}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{member.icon}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className={`dual-opponent-party-grid ${state.opponentParty.length > 6 ? 'raid' : ''}`}>
|
<section className={`dual-opponent-party-grid ${state.opponentParty.length > 6 ? 'raid' : ''}`}>
|
||||||
{state.opponentParty.map((member) => (
|
{state.opponentParty.map((member) => (
|
||||||
<PartyMemberFrame
|
<PartyMemberFrame
|
||||||
|
|||||||
+23
-3
@@ -134,6 +134,7 @@ const STORAGE_KEY = 'ashen-halls-input-bindings-v1'
|
|||||||
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
|
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
|
||||||
const GAME_ACTION_EVENT = 'ashen-halls-game-action'
|
const GAME_ACTION_EVENT = 'ashen-halls-game-action'
|
||||||
const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller'
|
const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller'
|
||||||
|
const NATIVE_CONTROLLER_MOTION_EVENT = 'ashen-halls-native-controller-motion'
|
||||||
const FOCUSABLE_SELECTOR = 'button:not(:disabled):not([data-controller-nav="skip"]), input:not(:disabled):not([data-controller-nav="skip"]), select:not(:disabled):not([data-controller-nav="skip"]), textarea:not(:disabled):not([data-controller-nav="skip"]), [tabindex]:not([tabindex="-1"]):not([data-controller-nav="skip"])'
|
const FOCUSABLE_SELECTOR = 'button:not(:disabled):not([data-controller-nav="skip"]), input:not(:disabled):not([data-controller-nav="skip"]), select:not(:disabled):not([data-controller-nav="skip"]), textarea:not(:disabled):not([data-controller-nav="skip"]), [tabindex]:not([tabindex="-1"]):not([data-controller-nav="skip"])'
|
||||||
const MAIN_CONTENT_SELECTOR = '.auth-shell, .menu-screen, .content-screen, .dungeon-run-screen, .dual-bottom-display, .iwt2-bottom-display'
|
const MAIN_CONTENT_SELECTOR = '.auth-shell, .menu-screen, .content-screen, .dungeon-run-screen, .dual-bottom-display, .iwt2-bottom-display'
|
||||||
const HEADER_CONTENT_SELECTOR = '.app-header'
|
const HEADER_CONTENT_SELECTOR = '.app-header'
|
||||||
@@ -142,6 +143,7 @@ const GAMEPAD_MENU_POLL_MS = 1000 / 30
|
|||||||
const GAMEPAD_BROWSER_DISCONNECTED_POLL_MS = 250
|
const GAMEPAD_BROWSER_DISCONNECTED_POLL_MS = 250
|
||||||
const CONTROLLER_REPEAT_INITIAL_MS = 260
|
const CONTROLLER_REPEAT_INITIAL_MS = 260
|
||||||
const CONTROLLER_REPEAT_MS = 85
|
const CONTROLLER_REPEAT_MS = 85
|
||||||
|
const NATIVE_STICK_TIMEOUT_MS = 140
|
||||||
const DPAD_NAV_ACTIONS: Partial<Record<string, InputAction>> = {
|
const DPAD_NAV_ACTIONS: Partial<Record<string, InputAction>> = {
|
||||||
Button12: 'navigateUp',
|
Button12: 'navigateUp',
|
||||||
Button13: 'navigateDown',
|
Button13: 'navigateDown',
|
||||||
@@ -1087,6 +1089,8 @@ export function useMovementVectorRef(enabled = true) {
|
|||||||
|
|
||||||
let frame = 0
|
let frame = 0
|
||||||
const pressedKeys = new Set<string>()
|
const pressedKeys = new Set<string>()
|
||||||
|
let nativeStick: MovementVector = { x: 0, y: 0 }
|
||||||
|
let nativeStickUpdatedAt = 0
|
||||||
const keyboardVector = () => normalizedVector(
|
const keyboardVector = () => normalizedVector(
|
||||||
(pressedKeys.has('KeyD') ? 1 : 0) - (pressedKeys.has('KeyA') ? 1 : 0),
|
(pressedKeys.has('KeyD') ? 1 : 0) - (pressedKeys.has('KeyA') ? 1 : 0),
|
||||||
(pressedKeys.has('KeyS') ? 1 : 0) - (pressedKeys.has('KeyW') ? 1 : 0),
|
(pressedKeys.has('KeyS') ? 1 : 0) - (pressedKeys.has('KeyW') ? 1 : 0),
|
||||||
@@ -1094,12 +1098,26 @@ export function useMovementVectorRef(enabled = true) {
|
|||||||
const updateMovement = () => {
|
const updateMovement = () => {
|
||||||
const gamepad = firstConnectedGamepad()
|
const gamepad = firstConnectedGamepad()
|
||||||
const stick = gamepad ? applyStickDeadzone(gamepad.axes[0] ?? 0, gamepad.axes[1] ?? 0) : { x: 0, y: 0 }
|
const stick = gamepad ? applyStickDeadzone(gamepad.axes[0] ?? 0, gamepad.axes[1] ?? 0) : { x: 0, y: 0 }
|
||||||
|
const native = performance.now() - nativeStickUpdatedAt <= NATIVE_STICK_TIMEOUT_MS
|
||||||
|
? applyStickDeadzone(nativeStick.x, nativeStick.y)
|
||||||
|
: { x: 0, y: 0 }
|
||||||
const keyboard = keyboardVector()
|
const keyboard = keyboardVector()
|
||||||
movementRef.current = Math.hypot(stick.x, stick.y) > 0
|
movementRef.current = Math.hypot(native.x, native.y) > 0
|
||||||
? stick
|
? native
|
||||||
: keyboard
|
: Math.hypot(stick.x, stick.y) > 0
|
||||||
|
? stick
|
||||||
|
: keyboard
|
||||||
frame = window.requestAnimationFrame(updateMovement)
|
frame = window.requestAnimationFrame(updateMovement)
|
||||||
}
|
}
|
||||||
|
const onNativeMotion = (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent<Partial<MovementVector>>).detail
|
||||||
|
nativeStick = {
|
||||||
|
x: Number(detail.x) || 0,
|
||||||
|
y: Number(detail.y) || 0,
|
||||||
|
}
|
||||||
|
nativeStickUpdatedAt = performance.now()
|
||||||
|
document.documentElement.dataset.inputDevice = 'controller'
|
||||||
|
}
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
if (isTextInput(document.activeElement)) return
|
if (isTextInput(document.activeElement)) return
|
||||||
if (!['KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(event.code)) return
|
if (!['KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(event.code)) return
|
||||||
@@ -1113,12 +1131,14 @@ export function useMovementVectorRef(enabled = true) {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.addEventListener(NATIVE_CONTROLLER_MOTION_EVENT, onNativeMotion)
|
||||||
window.addEventListener('keydown', onKeyDown)
|
window.addEventListener('keydown', onKeyDown)
|
||||||
window.addEventListener('keyup', onKeyUp)
|
window.addEventListener('keyup', onKeyUp)
|
||||||
frame = window.requestAnimationFrame(updateMovement)
|
frame = window.requestAnimationFrame(updateMovement)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.cancelAnimationFrame(frame)
|
window.cancelAnimationFrame(frame)
|
||||||
|
window.removeEventListener(NATIVE_CONTROLLER_MOTION_EVENT, onNativeMotion)
|
||||||
window.removeEventListener('keydown', onKeyDown)
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
window.removeEventListener('keyup', onKeyUp)
|
window.removeEventListener('keyup', onKeyUp)
|
||||||
movementRef.current = { x: 0, y: 0 }
|
movementRef.current = { x: 0, y: 0 }
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { lazy, Suspense, useEffect, useMemo, useState } from 'react'
|
import { lazy, Suspense, useEffect, useMemo, useState } from 'react'
|
||||||
import { AuthScreen } from '../../components/AuthScreen'
|
|
||||||
import {
|
import {
|
||||||
loadCpuPvpLeaderboard,
|
loadCpuPvpLeaderboard,
|
||||||
type CpuPvpLeaderboardEntry,
|
type CpuPvpLeaderboardEntry,
|
||||||
@@ -138,11 +137,21 @@ function ScreenLoading() {
|
|||||||
|
|
||||||
type RoguelikeVariant = 'pve' | 'pvp'
|
type RoguelikeVariant = 'pve' | 'pvp'
|
||||||
|
|
||||||
function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => void }) {
|
type IWantToHeal1AppProps = {
|
||||||
|
initialSession: AuthSession
|
||||||
|
onAuthenticationCleared: () => void
|
||||||
|
onBackToGameSelect: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function IWantToHeal1App({
|
||||||
|
initialSession,
|
||||||
|
onAuthenticationCleared,
|
||||||
|
onBackToGameSelect,
|
||||||
|
}: IWantToHeal1AppProps) {
|
||||||
const { enabled: dualScreenEnabled } = useDualScreen()
|
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||||
const [screen, setScreen] = useState<Screen>('menu')
|
const [screen, setScreen] = useState<Screen>('menu')
|
||||||
const [account, setAccount] = useState<Account | null>(null)
|
const [account, setAccount] = useState<Account | null>(initialSession.account)
|
||||||
const [profile, setProfile] = useState<CharacterProfile | null>(null)
|
const [profile, setProfile] = useState<CharacterProfile | null>(initialSession.profile)
|
||||||
const [authChecked, setAuthChecked] = useState(false)
|
const [authChecked, setAuthChecked] = useState(false)
|
||||||
const [gameMode, setGameMode] = useState<GameMode>(getGameMode())
|
const [gameMode, setGameMode] = useState<GameMode>(getGameMode())
|
||||||
const [serverMessage, setServerMessage] = useState('')
|
const [serverMessage, setServerMessage] = useState('')
|
||||||
@@ -186,6 +195,10 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
|
|||||||
.finally(() => setAuthChecked(true))
|
.finally(() => setAuthChecked(true))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (authChecked && (!account || !profile)) onAuthenticationCleared()
|
||||||
|
}, [account, authChecked, onAuthenticationCleared, profile])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleModeChange = (event: Event) => {
|
const handleModeChange = (event: Event) => {
|
||||||
const nextMode = (event as CustomEvent<GameMode>).detail
|
const nextMode = (event as CustomEvent<GameMode>).detail
|
||||||
@@ -350,18 +363,6 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
|
|||||||
[leaderboardCategory, selectedActivityOption?.leaderboards, selectedDifficultyOption?.id],
|
[leaderboardCategory, selectedActivityOption?.leaderboards, selectedDifficultyOption?.id],
|
||||||
)
|
)
|
||||||
|
|
||||||
function acceptSession(session: AuthSession) {
|
|
||||||
setAccount(session.account)
|
|
||||||
setProfile(session.profile)
|
|
||||||
setGameMode(getGameMode())
|
|
||||||
setScreen('menu')
|
|
||||||
setError('')
|
|
||||||
setServerMessage('')
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
focusFirstControl()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function signOut() {
|
async function signOut() {
|
||||||
try {
|
try {
|
||||||
await logoutAccount()
|
await logoutAccount()
|
||||||
@@ -371,6 +372,7 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
|
|||||||
setScreen('menu')
|
setScreen('menu')
|
||||||
setSyncMessage('')
|
setSyncMessage('')
|
||||||
setSyncComparison(null)
|
setSyncComparison(null)
|
||||||
|
onAuthenticationCleared()
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setError(reason instanceof Error ? reason.message : 'Unable to sign out.')
|
setError(reason instanceof Error ? reason.message : 'Unable to sign out.')
|
||||||
}
|
}
|
||||||
@@ -832,10 +834,13 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
|
|||||||
|
|
||||||
if (!account || !profile) {
|
if (!account || !profile) {
|
||||||
return (
|
return (
|
||||||
<AuthScreen
|
<main className="game-shell">
|
||||||
onAuthenticated={acceptSession}
|
<section className="message-panel">
|
||||||
serverMessage={serverMessage}
|
<p className="eyebrow">Opening Chronicle</p>
|
||||||
/>
|
<h1>Returning to Sign In...</h1>
|
||||||
|
{serverMessage && <p>{serverMessage}</p>}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,24 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useGameAction } from '../../input'
|
import { useGameAction } from '../../input'
|
||||||
|
import { getGameMode } from '../../gameRepository'
|
||||||
|
import { startPvpQueueWithCpuFallback } from '../../pvpQueueLifecycle'
|
||||||
|
import { summarizeChoiceStacks } from '../../combat/roguelikeUpgrades'
|
||||||
|
import {
|
||||||
|
useDualScreen,
|
||||||
|
useDualScreenSetupPublisher,
|
||||||
|
useDualScreenWorkshopPublisher,
|
||||||
|
type DualScreenSetupState,
|
||||||
|
type DualScreenWorkshopState,
|
||||||
|
} from '../../dualScreen'
|
||||||
import { BossArenaScreen } from './screens/BossArenaScreen'
|
import { BossArenaScreen } from './screens/BossArenaScreen'
|
||||||
import {
|
import {
|
||||||
Iwt2CloudSaveScreen,
|
Iwt2CloudSaveScreen,
|
||||||
Iwt2CustomizeCharacterScreen,
|
Iwt2CustomizeCharacterScreen,
|
||||||
Iwt2DungeonsScreen,
|
Iwt2DungeonsScreen,
|
||||||
Iwt2HunterProfileScreen,
|
Iwt2HunterProfileScreen,
|
||||||
Iwt2ModePlaceholderScreen,
|
Iwt2ModeScreen,
|
||||||
|
Iwt2RoguelikeScreen,
|
||||||
|
Iwt2RoguelikeUpgradeScreen,
|
||||||
Iwt2SettingsScreen,
|
Iwt2SettingsScreen,
|
||||||
} from './screens/Iwt2ShellScreens'
|
} from './screens/Iwt2ShellScreens'
|
||||||
import {
|
import {
|
||||||
@@ -14,7 +26,18 @@ import {
|
|||||||
writeIwt2Save,
|
writeIwt2Save,
|
||||||
type Iwt2Save,
|
type Iwt2Save,
|
||||||
} from './save/iwt2Repository'
|
} from './save/iwt2Repository'
|
||||||
import type { Iwt2BossId } from './content/bosses'
|
import { IWT2_BOSS_METADATA, type Iwt2BossId } from './content/bosses'
|
||||||
|
import { abilitiesForHealer, IWT2_HEALER_METADATA } from './content/healerAbilities'
|
||||||
|
import {
|
||||||
|
buildIwt2OpponentDebuffChoices,
|
||||||
|
buildIwt2SelfBuffChoices,
|
||||||
|
IWT2_REVIVE_PARTY_CHOICE,
|
||||||
|
type Iwt2RoguelikeChoice,
|
||||||
|
type Iwt2RoguelikeContentType,
|
||||||
|
type Iwt2RoguelikeOpponentDebuffId,
|
||||||
|
type Iwt2RoguelikeSelfBuffId,
|
||||||
|
type Iwt2RoguelikeVariant,
|
||||||
|
} from './content/roguelike'
|
||||||
|
|
||||||
type Iwt2Screen =
|
type Iwt2Screen =
|
||||||
| 'menu'
|
| 'menu'
|
||||||
@@ -23,12 +46,25 @@ type Iwt2Screen =
|
|||||||
| 'dungeons'
|
| 'dungeons'
|
||||||
| 'raids'
|
| 'raids'
|
||||||
| 'roguelike'
|
| 'roguelike'
|
||||||
| 'pvp'
|
| 'roguelike-arena'
|
||||||
|
| 'roguelike-upgrade'
|
||||||
| 'hunter-profile'
|
| 'hunter-profile'
|
||||||
| 'customize-character'
|
| 'customize-character'
|
||||||
| 'settings'
|
| 'settings'
|
||||||
|
|
||||||
const IWT2_MENU_COLUMNS = 4
|
const IWT2_MENU_COLUMNS = 4
|
||||||
|
const IWT2_ROGUELIKE_CHOICE_COUNT = 3
|
||||||
|
|
||||||
|
type Iwt2RoguelikeRunState = {
|
||||||
|
bossIds: Iwt2BossId[]
|
||||||
|
buffs: Iwt2RoguelikeSelfBuffId[]
|
||||||
|
contentType: Iwt2RoguelikeContentType
|
||||||
|
debuffs: Iwt2RoguelikeOpponentDebuffId[]
|
||||||
|
debuffChoices: Array<Iwt2RoguelikeChoice<Iwt2RoguelikeOpponentDebuffId>>
|
||||||
|
selfChoices: Array<Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId>>
|
||||||
|
stage: number
|
||||||
|
variant: Iwt2RoguelikeVariant
|
||||||
|
}
|
||||||
|
|
||||||
const MENU_ITEMS: Array<{
|
const MENU_ITEMS: Array<{
|
||||||
screen: Iwt2Screen
|
screen: Iwt2Screen
|
||||||
@@ -38,32 +74,32 @@ const MENU_ITEMS: Array<{
|
|||||||
}> = [
|
}> = [
|
||||||
{
|
{
|
||||||
screen: 'cloud-save',
|
screen: 'cloud-save',
|
||||||
title: 'Cloud Save',
|
title: 'Backup Slot',
|
||||||
description: 'Account sync shell for IWT2 progression and local save status.',
|
description: 'Save or restore the isolated IWT2 progress slot.',
|
||||||
glyph: 'C',
|
glyph: 'C',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
screen: 'dungeons',
|
screen: 'dungeons',
|
||||||
title: 'Dungeons',
|
title: 'Dungeons',
|
||||||
description: 'Queue into IWT2 boss arenas. Bulldrome and Yian Kut Ku are playable slices.',
|
description: 'Queue into Bulldrome, Yian Kut Ku, Great Jaggi, and Khezu boss arenas.',
|
||||||
glyph: 'D',
|
glyph: 'D',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
screen: 'raids',
|
screen: 'raids',
|
||||||
title: 'Raids',
|
title: 'Raids',
|
||||||
description: 'Large-party encounter shell for future multi-group boss fights.',
|
description: 'Open raid assignments built from active IWT2 boss mechanics.',
|
||||||
glyph: 'R',
|
glyph: 'R',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
screen: 'roguelike',
|
screen: 'roguelike',
|
||||||
title: 'Roguelike',
|
title: 'Roguelike',
|
||||||
description: 'Run-based IWT2 progression shell for room chains and reward drafts.',
|
description: 'Draft upgrades through escalating random encounters.',
|
||||||
glyph: 'G',
|
glyph: 'L',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
screen: 'pvp',
|
screen: 'roguelike',
|
||||||
title: 'PvP',
|
title: 'PvP',
|
||||||
description: 'Competitive healing shell with controller targeting preserved.',
|
description: 'Race another healer through roguelike encounters with buffs and sabotage.',
|
||||||
glyph: 'P',
|
glyph: 'P',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -75,7 +111,7 @@ const MENU_ITEMS: Array<{
|
|||||||
{
|
{
|
||||||
screen: 'customize-character',
|
screen: 'customize-character',
|
||||||
title: 'Customize Character',
|
title: 'Customize Character',
|
||||||
description: 'IWT2-only character identity and cosmetic setup shell.',
|
description: 'Choose healer kit, armor palette, and IWT2 hunter callsign.',
|
||||||
glyph: 'K',
|
glyph: 'K',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -87,15 +123,38 @@ const MENU_ITEMS: Array<{
|
|||||||
]
|
]
|
||||||
|
|
||||||
export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () => void }) {
|
export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () => void }) {
|
||||||
|
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||||
const [screen, setScreen] = useState<Iwt2Screen>('menu')
|
const [screen, setScreen] = useState<Iwt2Screen>('menu')
|
||||||
const [save, setSave] = useState<Iwt2Save>(loadIwt2Save)
|
const [save, setSave] = useState<Iwt2Save>(loadIwt2Save)
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
const [selectedBossId, setSelectedBossId] = useState<Iwt2BossId>('bulldrome')
|
const [selectedBossId, setSelectedBossId] = useState<Iwt2BossId>('bulldrome')
|
||||||
|
const [arenaModeLabel, setArenaModeLabel] = useState('Dungeon')
|
||||||
|
const [roguelikeVariant, setRoguelikeVariant] = useState<Iwt2RoguelikeVariant>('pve')
|
||||||
|
const [roguelikeContentType, setRoguelikeContentType] = useState<Iwt2RoguelikeContentType>('dungeon')
|
||||||
|
const [roguelikeRun, setRoguelikeRun] = useState<Iwt2RoguelikeRunState | null>(null)
|
||||||
|
const [pvpQueueMessage, setPvpQueueMessage] = useState('')
|
||||||
|
const cancelPvpQueueRef = useRef<(() => void) | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
writeIwt2Save(save)
|
writeIwt2Save(save)
|
||||||
}, [save])
|
}, [save])
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
cancelPvpQueueRef.current?.()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setupDualScreenState = useMemo<DualScreenSetupState | null>(
|
||||||
|
() => buildIwt2SetupDualScreenState(screen, selectedBossId, save),
|
||||||
|
[save, screen, selectedBossId],
|
||||||
|
)
|
||||||
|
const workshopDualScreenState = useMemo<DualScreenWorkshopState | null>(
|
||||||
|
() => buildIwt2WorkshopDualScreenState(screen, save),
|
||||||
|
[save, screen],
|
||||||
|
)
|
||||||
|
|
||||||
|
useDualScreenSetupPublisher(setupDualScreenState, dualScreenEnabled)
|
||||||
|
useDualScreenWorkshopPublisher(workshopDualScreenState, dualScreenEnabled)
|
||||||
|
|
||||||
useGameAction((action, device) => {
|
useGameAction((action, device) => {
|
||||||
if (screen !== 'menu' || device !== 'controller') return
|
if (screen !== 'menu' || device !== 'controller') return
|
||||||
if (action === 'back') {
|
if (action === 'back') {
|
||||||
@@ -103,7 +162,7 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (action === 'confirm') {
|
if (action === 'confirm') {
|
||||||
setScreen(MENU_ITEMS[selectedIndex].screen)
|
openMenuItem(MENU_ITEMS[selectedIndex])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (action === 'navigateUp' || action === 'navigateLeft') {
|
if (action === 'navigateUp' || action === 'navigateLeft') {
|
||||||
@@ -115,10 +174,25 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function openMenuItem(item: (typeof MENU_ITEMS)[number]) {
|
||||||
|
if (item.title === 'PvP') {
|
||||||
|
setRoguelikeVariant('pvp')
|
||||||
|
setScreen('roguelike')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (item.title === 'Roguelike') {
|
||||||
|
setRoguelikeVariant('pve')
|
||||||
|
setRoguelikeContentType((current) => current === 'stadium' ? 'dungeon' : current)
|
||||||
|
}
|
||||||
|
setScreen(item.screen)
|
||||||
|
}
|
||||||
|
|
||||||
if (screen === 'arena') {
|
if (screen === 'arena') {
|
||||||
return (
|
return (
|
||||||
<BossArenaScreen
|
<BossArenaScreen
|
||||||
bossId={selectedBossId}
|
bossId={selectedBossId}
|
||||||
|
key={`arena-${selectedBossId}-${arenaModeLabel}`}
|
||||||
|
modeLabel={arenaModeLabel}
|
||||||
save={save}
|
save={save}
|
||||||
onBack={() => setScreen('menu')}
|
onBack={() => setScreen('menu')}
|
||||||
onSaveUpdated={setSave}
|
onSaveUpdated={setSave}
|
||||||
@@ -126,6 +200,59 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (screen === 'roguelike-arena' && roguelikeRun) {
|
||||||
|
return (
|
||||||
|
<BossArenaScreen
|
||||||
|
bossIds={roguelikeRun.bossIds}
|
||||||
|
bossId={selectedBossId}
|
||||||
|
key={`roguelike-${roguelikeRun.variant}-${roguelikeRun.contentType}-${roguelikeRun.stage}-${roguelikeRun.bossIds.join('-')}`}
|
||||||
|
roguelikeRun={{
|
||||||
|
buffs: roguelikeRun.buffs,
|
||||||
|
contentType: roguelikeRun.contentType,
|
||||||
|
debuffs: roguelikeRun.debuffs,
|
||||||
|
onVictory: () => {
|
||||||
|
setRoguelikeRun((current) => current
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
...buildRoguelikeChoices(save, current.variant),
|
||||||
|
}
|
||||||
|
: current)
|
||||||
|
setScreen('roguelike-upgrade')
|
||||||
|
},
|
||||||
|
stage: roguelikeRun.stage,
|
||||||
|
variant: roguelikeRun.variant,
|
||||||
|
}}
|
||||||
|
save={save}
|
||||||
|
onBack={() => setScreen('roguelike')}
|
||||||
|
onSaveUpdated={setSave}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (screen === 'roguelike-upgrade' && roguelikeRun) {
|
||||||
|
return (
|
||||||
|
<main className="game-shell iwt2-shell">
|
||||||
|
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||||
|
<Iwt2RoguelikeUpgradeScreen
|
||||||
|
activeBuffSummary={summarizeIwt2Buffs(save, roguelikeRun.buffs)}
|
||||||
|
activeDebuffSummary={summarizeIwt2Debuffs(save, roguelikeRun.debuffs)}
|
||||||
|
contentType={roguelikeRun.contentType}
|
||||||
|
debuffChoices={roguelikeRun.debuffChoices}
|
||||||
|
selfChoices={roguelikeRun.selfChoices}
|
||||||
|
stage={roguelikeRun.stage}
|
||||||
|
variant={roguelikeRun.variant}
|
||||||
|
onBack={() => setScreen('roguelike')}
|
||||||
|
onChoose={(buffId, debuffId) => {
|
||||||
|
const nextRun = applyRoguelikeChoice(roguelikeRun, buffId, debuffId, save)
|
||||||
|
setRoguelikeRun(nextRun)
|
||||||
|
setSelectedBossId(nextRun.bossIds[0] ?? 'bulldrome')
|
||||||
|
setScreen('roguelike-arena')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (screen === 'dungeons') {
|
if (screen === 'dungeons') {
|
||||||
return (
|
return (
|
||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
@@ -133,6 +260,7 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
|||||||
<Iwt2DungeonsScreen
|
<Iwt2DungeonsScreen
|
||||||
onBack={() => setScreen('menu')}
|
onBack={() => setScreen('menu')}
|
||||||
onOpenBoss={(bossId) => {
|
onOpenBoss={(bossId) => {
|
||||||
|
setArenaModeLabel('Dungeon')
|
||||||
setSelectedBossId(bossId)
|
setSelectedBossId(bossId)
|
||||||
setScreen('arena')
|
setScreen('arena')
|
||||||
}}
|
}}
|
||||||
@@ -141,12 +269,46 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (screen === 'raids' || screen === 'roguelike' || screen === 'pvp') {
|
if (screen === 'roguelike') {
|
||||||
const modeName = screen === 'raids' ? 'Raids' : screen === 'roguelike' ? 'Roguelike' : 'PvP'
|
|
||||||
return (
|
return (
|
||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||||
<Iwt2ModePlaceholderScreen mode={modeName} onBack={() => setScreen('menu')} />
|
<Iwt2RoguelikeScreen
|
||||||
|
contentType={roguelikeContentType}
|
||||||
|
variant={roguelikeVariant}
|
||||||
|
onBack={() => setScreen('menu')}
|
||||||
|
onContentTypeChange={setRoguelikeContentType}
|
||||||
|
onStart={() => {
|
||||||
|
startIwt2RoguelikeRun()
|
||||||
|
}}
|
||||||
|
onVariantChange={(nextVariant) => {
|
||||||
|
cancelPvpQueueRef.current?.()
|
||||||
|
cancelPvpQueueRef.current = null
|
||||||
|
setPvpQueueMessage('')
|
||||||
|
setRoguelikeVariant(nextVariant)
|
||||||
|
if (nextVariant === 'pve' && roguelikeContentType === 'stadium') {
|
||||||
|
setRoguelikeContentType('dungeon')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
queueMessage={pvpQueueMessage}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (screen === 'raids') {
|
||||||
|
return (
|
||||||
|
<main className="game-shell iwt2-shell">
|
||||||
|
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||||
|
<Iwt2ModeScreen
|
||||||
|
mode="Raids"
|
||||||
|
onBack={() => setScreen('menu')}
|
||||||
|
onOpenBoss={(bossId) => {
|
||||||
|
setArenaModeLabel('Raid')
|
||||||
|
setSelectedBossId(bossId)
|
||||||
|
setScreen('arena')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -164,7 +326,11 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
|||||||
return (
|
return (
|
||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||||
<Iwt2CustomizeCharacterScreen save={save} onBack={() => setScreen('menu')} />
|
<Iwt2CustomizeCharacterScreen
|
||||||
|
save={save}
|
||||||
|
onBack={() => setScreen('menu')}
|
||||||
|
onSaveUpdated={setSave}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -173,7 +339,11 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
|||||||
return (
|
return (
|
||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||||
<Iwt2CloudSaveScreen save={save} onBack={() => setScreen('menu')} />
|
<Iwt2CloudSaveScreen
|
||||||
|
save={save}
|
||||||
|
onBack={() => setScreen('menu')}
|
||||||
|
onSaveUpdated={setSave}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -202,8 +372,9 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
|||||||
<button
|
<button
|
||||||
className={`iwt2-menu-card ${selectedIndex === index ? 'game-selected' : ''}`}
|
className={`iwt2-menu-card ${selectedIndex === index ? 'game-selected' : ''}`}
|
||||||
data-controller-nav="skip"
|
data-controller-nav="skip"
|
||||||
key={item.screen}
|
data-game-selected={selectedIndex === index ? 'true' : undefined}
|
||||||
onClick={() => setScreen(item.screen)}
|
key={`${item.screen}-${item.title}`}
|
||||||
|
onClick={() => openMenuItem(item)}
|
||||||
onPointerDown={() => setSelectedIndex(index)}
|
onPointerDown={() => setSelectedIndex(index)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@@ -219,6 +390,147 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function startIwt2RoguelikeRun() {
|
||||||
|
cancelPvpQueueRef.current?.()
|
||||||
|
cancelPvpQueueRef.current = null
|
||||||
|
setPvpQueueMessage('')
|
||||||
|
if (roguelikeVariant !== 'pvp') {
|
||||||
|
beginIwt2RoguelikeArena(roguelikeVariant, roguelikeContentType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const startStage = 1
|
||||||
|
cancelPvpQueueRef.current = startPvpQueueWithCpuFallback<unknown>({
|
||||||
|
contentType: roguelikeContentType,
|
||||||
|
startStage,
|
||||||
|
gameMode: getGameMode(),
|
||||||
|
liveMatchActive: () => false,
|
||||||
|
onSearching: setPvpQueueMessage,
|
||||||
|
onCpuMatch: (_difficulty, message) => {
|
||||||
|
cancelPvpQueueRef.current = null
|
||||||
|
setPvpQueueMessage(message)
|
||||||
|
beginIwt2RoguelikeArena('pvp', roguelikeContentType)
|
||||||
|
},
|
||||||
|
onLiveMatch: (...liveMatchArgs) => {
|
||||||
|
const message = liveMatchArgs[2]
|
||||||
|
cancelPvpQueueRef.current = null
|
||||||
|
setPvpQueueMessage(message)
|
||||||
|
beginIwt2RoguelikeArena('pvp', roguelikeContentType)
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
offline: (difficulty) => `Offline mode. CPU ${difficulty} enters IWT2 ${formatRoguelikeContentType(roguelikeContentType)}.`,
|
||||||
|
searching: `Searching IWT2 ${formatRoguelikeContentType(roguelikeContentType)} queue for 5s.`,
|
||||||
|
notFound: (difficulty) => `No IWT2 opponent found after 5s. CPU ${difficulty} steps in.`,
|
||||||
|
unavailable: (difficulty) => `PvP server unavailable. CPU ${difficulty} steps in.`,
|
||||||
|
liveFound: () => `Opponent found. Starting IWT2 ${formatRoguelikeContentType(roguelikeContentType)} race.`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginIwt2RoguelikeArena(
|
||||||
|
variant: Iwt2RoguelikeVariant,
|
||||||
|
contentType: Iwt2RoguelikeContentType,
|
||||||
|
) {
|
||||||
|
const nextRun = createRoguelikeRun(save, variant, contentType)
|
||||||
|
setRoguelikeRun(nextRun)
|
||||||
|
setSelectedBossId(nextRun.bossIds[0] ?? 'bulldrome')
|
||||||
|
setScreen('roguelike-arena')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRoguelikeContentType(contentType: Iwt2RoguelikeContentType) {
|
||||||
|
if (contentType === 'raid') return 'Raid'
|
||||||
|
if (contentType === 'stadium') return 'Stadium'
|
||||||
|
return 'Dungeon'
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRoguelikeRun(
|
||||||
|
save: Iwt2Save,
|
||||||
|
variant: Iwt2RoguelikeVariant,
|
||||||
|
contentType: Iwt2RoguelikeContentType,
|
||||||
|
): Iwt2RoguelikeRunState {
|
||||||
|
return {
|
||||||
|
bossIds: createRandomRoguelikeBossPair(),
|
||||||
|
buffs: [],
|
||||||
|
contentType,
|
||||||
|
debuffs: [],
|
||||||
|
...buildRoguelikeChoices(save, variant),
|
||||||
|
stage: 1,
|
||||||
|
variant,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRoguelikeChoices(save: Iwt2Save, variant: Iwt2RoguelikeVariant) {
|
||||||
|
const abilities = abilitiesForHealer(save.character.healerStyle)
|
||||||
|
const selfCatalog = [IWT2_REVIVE_PARTY_CHOICE, ...buildIwt2SelfBuffChoices(abilities)]
|
||||||
|
const debuffCatalog = buildIwt2OpponentDebuffChoices(abilities)
|
||||||
|
return {
|
||||||
|
selfChoices: chooseRunChoices(selfCatalog, IWT2_ROGUELIKE_CHOICE_COUNT),
|
||||||
|
debuffChoices: variant === 'pvp'
|
||||||
|
? chooseRunChoices(debuffCatalog, IWT2_ROGUELIKE_CHOICE_COUNT)
|
||||||
|
: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeIwt2Buffs(save: Iwt2Save, buffs: Iwt2RoguelikeSelfBuffId[]) {
|
||||||
|
if (buffs.length === 0) return ''
|
||||||
|
const abilities = abilitiesForHealer(save.character.healerStyle)
|
||||||
|
return summarizeChoiceStacks(
|
||||||
|
buffs,
|
||||||
|
[IWT2_REVIVE_PARTY_CHOICE, ...buildIwt2SelfBuffChoices(abilities)],
|
||||||
|
'None',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeIwt2Debuffs(save: Iwt2Save, debuffs: Iwt2RoguelikeOpponentDebuffId[]) {
|
||||||
|
if (debuffs.length === 0) return ''
|
||||||
|
const abilities = abilitiesForHealer(save.character.healerStyle)
|
||||||
|
return summarizeChoiceStacks(
|
||||||
|
debuffs,
|
||||||
|
buildIwt2OpponentDebuffChoices(abilities),
|
||||||
|
'None',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRoguelikeChoice(
|
||||||
|
run: Iwt2RoguelikeRunState,
|
||||||
|
buffId: Iwt2RoguelikeSelfBuffId,
|
||||||
|
debuffId: Iwt2RoguelikeOpponentDebuffId | undefined,
|
||||||
|
save: Iwt2Save,
|
||||||
|
): Iwt2RoguelikeRunState {
|
||||||
|
const nextDebuffs = debuffId ? [...run.debuffs, debuffId] : run.debuffs
|
||||||
|
const nextBase = buffId === IWT2_REVIVE_PARTY_CHOICE.id
|
||||||
|
? {
|
||||||
|
buffs: run.buffs,
|
||||||
|
debuffs: nextDebuffs.slice(1),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
buffs: [...run.buffs, buffId],
|
||||||
|
debuffs: nextDebuffs,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...run,
|
||||||
|
...nextBase,
|
||||||
|
bossIds: createRandomRoguelikeBossPair(),
|
||||||
|
...buildRoguelikeChoices(save, run.variant),
|
||||||
|
stage: run.stage + 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRandomRoguelikeBossPair(): Iwt2BossId[] {
|
||||||
|
const pool = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]
|
||||||
|
return chooseRunChoices(pool, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseRunChoices<T>(items: readonly T[], count: number): T[] {
|
||||||
|
const pool = [...items]
|
||||||
|
const choices: T[] = []
|
||||||
|
while (pool.length > 0 && choices.length < count) {
|
||||||
|
const index = Math.floor(Math.random() * pool.length)
|
||||||
|
const [choice] = pool.splice(index, 1)
|
||||||
|
if (choice) choices.push(choice)
|
||||||
|
}
|
||||||
|
return choices
|
||||||
}
|
}
|
||||||
|
|
||||||
function Iwt2Header({
|
function Iwt2Header({
|
||||||
@@ -241,3 +553,90 @@ function Iwt2Header({
|
|||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildIwt2SetupDualScreenState(
|
||||||
|
screen: Iwt2Screen,
|
||||||
|
selectedBossId: Iwt2BossId,
|
||||||
|
save: Iwt2Save,
|
||||||
|
): DualScreenSetupState | null {
|
||||||
|
if (screen !== 'dungeons' && screen !== 'raids') return null
|
||||||
|
const boss = IWT2_BOSS_METADATA[selectedBossId]
|
||||||
|
const raid = screen === 'raids'
|
||||||
|
return {
|
||||||
|
contentType: raid ? 'raid' : 'dungeon',
|
||||||
|
description: raid
|
||||||
|
? `${boss.name} raid assignment. Tank holds aggro while party moves around modular boss mechanics.`
|
||||||
|
: `${boss.name} arena. Heal the party through melee pressure, telegraphs, hazards, and stun recovery.`,
|
||||||
|
difficultyName: `IWT2 Level ${save.character.level}`,
|
||||||
|
experience: 125,
|
||||||
|
initials: boss.icon,
|
||||||
|
itemLevel: save.character.level,
|
||||||
|
stats: {
|
||||||
|
damage: `${boss.meleeDamage}`,
|
||||||
|
health: `${boss.maxHealth}`,
|
||||||
|
loot: 'IWT2',
|
||||||
|
xp: '125',
|
||||||
|
},
|
||||||
|
subtitle: `${raid ? 'Raid' : 'Dungeon'} | 6 Players | ${IWT2_HEALER_METADATA[save.character.healerStyle].name}`,
|
||||||
|
title: raid ? `${boss.name} Raid` : `${boss.name} Arena`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIwt2WorkshopDualScreenState(
|
||||||
|
screen: Iwt2Screen,
|
||||||
|
save: Iwt2Save,
|
||||||
|
): DualScreenWorkshopState | null {
|
||||||
|
if (screen === 'hunter-profile') {
|
||||||
|
return {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
glyph: 'H',
|
||||||
|
meta: `${save.character.experience} XP`,
|
||||||
|
status: `Level ${save.character.level}`,
|
||||||
|
title: save.character.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
glyph: IWT2_HEALER_METADATA[save.character.healerStyle].icon,
|
||||||
|
meta: IWT2_HEALER_METADATA[save.character.healerStyle].description,
|
||||||
|
status: 'Class',
|
||||||
|
title: IWT2_HEALER_METADATA[save.character.healerStyle].name,
|
||||||
|
},
|
||||||
|
...(Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]).map((bossId) => ({
|
||||||
|
glyph: IWT2_BOSS_METADATA[bossId].icon,
|
||||||
|
meta: `${save.collectionLog.bossKills[bossId] ?? 0} kills`,
|
||||||
|
status: `${save.collectionLog.dropsFound[bossDropIdForAppSummary(bossId)] ?? 0} drops`,
|
||||||
|
title: IWT2_BOSS_METADATA[bossId].name,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
mode: 'collection',
|
||||||
|
subtitle: 'IWT2 inventory and collection log',
|
||||||
|
summary: `${save.inventory.length} inventory slots`,
|
||||||
|
title: 'Hunter Profile',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (screen === 'customize-character') {
|
||||||
|
const healer = IWT2_HEALER_METADATA[save.character.healerStyle]
|
||||||
|
return {
|
||||||
|
items: abilitiesForHealer(save.character.healerStyle).map((ability) => ({
|
||||||
|
glyph: ability.icon,
|
||||||
|
meta: `${ability.manaCost} Mana | ${ability.cooldownSeconds}s cooldown`,
|
||||||
|
status: `Slot ${ability.slot}`,
|
||||||
|
title: ability.name,
|
||||||
|
})),
|
||||||
|
mode: 'class',
|
||||||
|
subtitle: healer.description,
|
||||||
|
summary: healer.name,
|
||||||
|
title: 'Customize Character',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function bossDropIdForAppSummary(bossId: Iwt2BossId): string {
|
||||||
|
if (bossId === 'yian-kut-ku') return 'yian-kut-ku-scale'
|
||||||
|
if (bossId === 'great-jaggi') return 'great-jaggi-hide'
|
||||||
|
if (bossId === 'khezu') return 'khezu-pearl'
|
||||||
|
return 'raw-bulldrome-coin'
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { ControllerBindingLabel } from '../../components/ControllerIcons'
|
|||||||
import { DEFAULT_BINDINGS, useInput } from '../../input'
|
import { DEFAULT_BINDINGS, useInput } from '../../input'
|
||||||
import { IWT2_CLASS_METADATA, IWT2_PARTY_ORDER } from './content/classes'
|
import { IWT2_CLASS_METADATA, IWT2_PARTY_ORDER } from './content/classes'
|
||||||
import { IWT2_ABILITY_ACTIONS, IWT2_TARGET_ACTIONS } from './content/controls'
|
import { IWT2_ABILITY_ACTIONS, IWT2_TARGET_ACTIONS } from './content/controls'
|
||||||
import { abilitiesForHealer } from './content/healerAbilities'
|
import { abilitiesForHealer, IWT2_HEALER_METADATA } from './content/healerAbilities'
|
||||||
|
import { loadIwt2Save } from './save/iwt2Repository'
|
||||||
|
|
||||||
export function Iwt2BottomDisplay() {
|
export function Iwt2BottomDisplay() {
|
||||||
const {
|
const {
|
||||||
@@ -13,7 +14,9 @@ export function Iwt2BottomDisplay() {
|
|||||||
const activeBindings = lastDevice === 'controller'
|
const activeBindings = lastDevice === 'controller'
|
||||||
? bindings.controller
|
? bindings.controller
|
||||||
: DEFAULT_BINDINGS.controller
|
: DEFAULT_BINDINGS.controller
|
||||||
const abilities = abilitiesForHealer('field_medic')
|
const save = loadIwt2Save()
|
||||||
|
const abilities = abilitiesForHealer(save.character.healerStyle)
|
||||||
|
const healer = IWT2_HEALER_METADATA[save.character.healerStyle]
|
||||||
const partyTargets = IWT2_PARTY_ORDER.map((classId) => IWT2_CLASS_METADATA[classId])
|
const partyTargets = IWT2_PARTY_ORDER.map((classId) => IWT2_CLASS_METADATA[classId])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -21,7 +24,7 @@ export function Iwt2BottomDisplay() {
|
|||||||
<section className="dual-controls-resource iwt2-bottom-resource">
|
<section className="dual-controls-resource iwt2-bottom-resource">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">I Want To Heal 2</p>
|
<p className="eyebrow">I Want To Heal 2</p>
|
||||||
<strong>Field Medic</strong>
|
<strong>{healer.name}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="dual-controls-mana">
|
<div className="dual-controls-mana">
|
||||||
<span>Mana 100 / 100</span>
|
<span>Mana 100 / 100</span>
|
||||||
@@ -34,23 +37,23 @@ export function Iwt2BottomDisplay() {
|
|||||||
const action = IWT2_TARGET_ACTIONS[index] ?? 'targetParty1'
|
const action = IWT2_TARGET_ACTIONS[index] ?? 'targetParty1'
|
||||||
const label = target.id === 'healer' ? 'Player' : target.name.replace(' Tank', '')
|
const label = target.id === 'healer' ? 'Player' : target.name.replace(' Tank', '')
|
||||||
return (
|
return (
|
||||||
<button type="button" key={target.id}>
|
<div className="dual-control-chip" key={target.id}>
|
||||||
<ControllerBindingLabel
|
<ControllerBindingLabel
|
||||||
binding={activeBindings[action]}
|
binding={activeBindings[action]}
|
||||||
iconStyle="playstation"
|
iconStyle="playstation"
|
||||||
/>{' '}
|
/>{' '}
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button type="button">
|
<div className="dual-control-chip">
|
||||||
<ControllerBindingLabel binding="Button12" iconStyle="playstation" /> Previous Target
|
<ControllerBindingLabel binding="Button12" iconStyle="playstation" /> Previous Target
|
||||||
</button>
|
</div>
|
||||||
<button type="button">
|
<div className="dual-control-chip">
|
||||||
Next Target <ControllerBindingLabel binding="Button13" iconStyle="playstation" />
|
Next Target <ControllerBindingLabel binding="Button13" iconStyle="playstation" />
|
||||||
</button>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -58,7 +61,7 @@ export function Iwt2BottomDisplay() {
|
|||||||
{abilities.map((ability, index) => {
|
{abilities.map((ability, index) => {
|
||||||
const action = IWT2_ABILITY_ACTIONS[index] ?? 'ability1'
|
const action = IWT2_ABILITY_ACTIONS[index] ?? 'ability1'
|
||||||
return (
|
return (
|
||||||
<button className="spell iwt2-bottom-spell" key={ability.id} type="button">
|
<div className="spell iwt2-bottom-spell" key={ability.id}>
|
||||||
<kbd>
|
<kbd>
|
||||||
<ControllerBindingLabel
|
<ControllerBindingLabel
|
||||||
binding={activeBindings[action]}
|
binding={activeBindings[action]}
|
||||||
@@ -69,7 +72,7 @@ export function Iwt2BottomDisplay() {
|
|||||||
<span className={`spell-icon spell-${ability.kind}`}>{ability.icon}</span>
|
<span className={`spell-icon spell-${ability.kind}`}>{ability.icon}</span>
|
||||||
<strong>{ability.name}</strong>
|
<strong>{ability.name}</strong>
|
||||||
<small>{ability.manaCost} Mana</small>
|
<small>{ability.manaCost} Mana</small>
|
||||||
</button>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -2,16 +2,23 @@ import type { Iwt2BossEntityState } from '../sim'
|
|||||||
import { ArenaBar } from './ArenaBars'
|
import { ArenaBar } from './ArenaBars'
|
||||||
import { IWT2_BOSS_METADATA } from '../content/bosses'
|
import { IWT2_BOSS_METADATA } from '../content/bosses'
|
||||||
|
|
||||||
export function BossHud({ boss }: { boss: Iwt2BossEntityState }) {
|
export function BossHud({ boss, bosses }: { boss?: Iwt2BossEntityState, bosses?: Iwt2BossEntityState[] }) {
|
||||||
const metadata = IWT2_BOSS_METADATA[boss.bossId]
|
const entries = bosses ?? (boss ? [boss] : [])
|
||||||
return (
|
return (
|
||||||
<div className="iwt2-boss-hud">
|
<div className="iwt2-boss-hud">
|
||||||
<div>
|
{entries.map((entry) => {
|
||||||
<strong>{metadata.name}</strong>
|
const metadata = IWT2_BOSS_METADATA[entry.bossId]
|
||||||
<small>{Math.ceil(boss.health)} / {boss.maxHealth} HP</small>
|
return (
|
||||||
</div>
|
<div className="iwt2-boss-hud-row" key={entry.id}>
|
||||||
<small className="iwt2-boss-phase">{boss.attackPhase}</small>
|
<div>
|
||||||
<ArenaBar className="boss" current={boss.health} max={boss.maxHealth} />
|
<strong>{metadata.name}</strong>
|
||||||
|
<small>{Math.ceil(entry.health)} / {entry.maxHealth} HP</small>
|
||||||
|
</div>
|
||||||
|
<small className="iwt2-boss-phase">{entry.attackPhase}</small>
|
||||||
|
<ArenaBar className="boss" current={entry.health} max={entry.maxHealth} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function PartyFrames({
|
|||||||
<button
|
<button
|
||||||
className={`iwt2-party-row ${targetBinding ? 'has-target-binding' : ''} ${selected ? 'game-selected selected' : ''}`}
|
className={`iwt2-party-row ${targetBinding ? 'has-target-binding' : ''} ${selected ? 'game-selected selected' : ''}`}
|
||||||
data-controller-nav="skip"
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected ? 'true' : undefined}
|
||||||
key={member.id}
|
key={member.id}
|
||||||
onClick={() => onTarget(member.id)}
|
onClick={() => onTarget(member.id)}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { IWT2_BALANCE_OVERRIDES } from './balanceOverrides'
|
import { IWT2_BALANCE_OVERRIDES } from './balanceOverrides'
|
||||||
|
|
||||||
export type Iwt2BossId = 'bulldrome' | 'yian-kut-ku'
|
export type Iwt2BossId = 'bulldrome' | 'yian-kut-ku' | 'great-jaggi' | 'khezu'
|
||||||
|
|
||||||
export type Iwt2BalanceOverrides = {
|
export type Iwt2BalanceOverrides = {
|
||||||
bosses?: Partial<Record<Iwt2BossId, Partial<Pick<Iwt2BossMetadata, 'maxHealth' | 'birdHealth'>>>>
|
bosses?: Partial<Record<Iwt2BossId, Partial<Pick<Iwt2BossMetadata, 'maxHealth' | 'birdHealth'>>>>
|
||||||
@@ -46,6 +46,24 @@ export type Iwt2BossMetadata = {
|
|||||||
birdRadius?: number
|
birdRadius?: number
|
||||||
birdContactDamage?: number
|
birdContactDamage?: number
|
||||||
birdStunSeconds?: number
|
birdStunSeconds?: number
|
||||||
|
packHowlCooldown?: number
|
||||||
|
packHowlWindup?: number
|
||||||
|
packLaneDamage?: number
|
||||||
|
packLaneStunSeconds?: number
|
||||||
|
packLaneWidth?: number
|
||||||
|
packLaneCount?: number
|
||||||
|
thunderRingCooldown?: number
|
||||||
|
thunderRingWindup?: number
|
||||||
|
thunderRingInnerRadius?: number
|
||||||
|
thunderRingOuterRadius?: number
|
||||||
|
thunderRingDamage?: number
|
||||||
|
thunderRingStunSeconds?: number
|
||||||
|
lightningStrikeCooldown?: number
|
||||||
|
lightningStrikeWindup?: number
|
||||||
|
lightningStrikeRadius?: number
|
||||||
|
lightningStrikeDamage?: number
|
||||||
|
lightningStrikeStunSeconds?: number
|
||||||
|
lightningStrikeCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_BULLDROME_BOSS_METADATA: Iwt2BossMetadata = {
|
const DEFAULT_BULLDROME_BOSS_METADATA: Iwt2BossMetadata = {
|
||||||
@@ -116,6 +134,76 @@ const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
birdStunSeconds: 0.75,
|
birdStunSeconds: 0.75,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_GREAT_JAGGI_BOSS_METADATA: Iwt2BossMetadata = {
|
||||||
|
id: 'great-jaggi',
|
||||||
|
name: 'Great Jaggi',
|
||||||
|
icon: 'J',
|
||||||
|
color: '#3f8f73',
|
||||||
|
accentColor: '#b8f0aa',
|
||||||
|
maxHealth: 620,
|
||||||
|
radius: 27,
|
||||||
|
moveSpeed: 146,
|
||||||
|
meleeRange: 52,
|
||||||
|
meleeDamage: 7,
|
||||||
|
meleeCooldown: 0.82,
|
||||||
|
chargeCooldown: 0,
|
||||||
|
chargeWindup: 0,
|
||||||
|
chargeSpeed: 0,
|
||||||
|
chargeDamage: 0,
|
||||||
|
chargeStunSeconds: 0,
|
||||||
|
chargeLength: 0,
|
||||||
|
chargeOvershoot: 0,
|
||||||
|
chargeWidth: 0,
|
||||||
|
slamWindup: 0,
|
||||||
|
slamRadius: 0,
|
||||||
|
slamDamage: 0,
|
||||||
|
slamStunSeconds: 0,
|
||||||
|
packHowlCooldown: 5.8,
|
||||||
|
packHowlWindup: 0.95,
|
||||||
|
packLaneDamage: 15,
|
||||||
|
packLaneStunSeconds: 0.45,
|
||||||
|
packLaneWidth: 24,
|
||||||
|
packLaneCount: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_KHEZU_BOSS_METADATA: Iwt2BossMetadata = {
|
||||||
|
id: 'khezu',
|
||||||
|
name: 'Khezu',
|
||||||
|
icon: 'K',
|
||||||
|
color: '#d8d7c9',
|
||||||
|
accentColor: '#77d9ff',
|
||||||
|
maxHealth: 760,
|
||||||
|
radius: 30,
|
||||||
|
moveSpeed: 92,
|
||||||
|
meleeRange: 58,
|
||||||
|
meleeDamage: 10,
|
||||||
|
meleeCooldown: 1.15,
|
||||||
|
chargeCooldown: 0,
|
||||||
|
chargeWindup: 0,
|
||||||
|
chargeSpeed: 0,
|
||||||
|
chargeDamage: 0,
|
||||||
|
chargeStunSeconds: 0,
|
||||||
|
chargeLength: 0,
|
||||||
|
chargeOvershoot: 0,
|
||||||
|
chargeWidth: 0,
|
||||||
|
slamWindup: 0,
|
||||||
|
slamRadius: 0,
|
||||||
|
slamDamage: 0,
|
||||||
|
slamStunSeconds: 0,
|
||||||
|
thunderRingCooldown: 7.2,
|
||||||
|
thunderRingWindup: 0.9,
|
||||||
|
thunderRingInnerRadius: 70,
|
||||||
|
thunderRingOuterRadius: 172,
|
||||||
|
thunderRingDamage: 22,
|
||||||
|
thunderRingStunSeconds: 0.7,
|
||||||
|
lightningStrikeCooldown: 4.2,
|
||||||
|
lightningStrikeWindup: 0.78,
|
||||||
|
lightningStrikeRadius: 46,
|
||||||
|
lightningStrikeDamage: 17,
|
||||||
|
lightningStrikeStunSeconds: 0.55,
|
||||||
|
lightningStrikeCount: 2,
|
||||||
|
}
|
||||||
|
|
||||||
function applyBossOverrides(metadata: Iwt2BossMetadata): Iwt2BossMetadata {
|
function applyBossOverrides(metadata: Iwt2BossMetadata): Iwt2BossMetadata {
|
||||||
const override = IWT2_BALANCE_OVERRIDES.bosses?.[metadata.id]
|
const override = IWT2_BALANCE_OVERRIDES.bosses?.[metadata.id]
|
||||||
if (!override) return metadata
|
if (!override) return metadata
|
||||||
@@ -127,8 +215,12 @@ function applyBossOverrides(metadata: Iwt2BossMetadata): Iwt2BossMetadata {
|
|||||||
|
|
||||||
export const BULLDROME_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_BULLDROME_BOSS_METADATA)
|
export const BULLDROME_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_BULLDROME_BOSS_METADATA)
|
||||||
export const YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_YIAN_KUT_KU_BOSS_METADATA)
|
export const YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_YIAN_KUT_KU_BOSS_METADATA)
|
||||||
|
export const GREAT_JAGGI_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_GREAT_JAGGI_BOSS_METADATA)
|
||||||
|
export const KHEZU_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_KHEZU_BOSS_METADATA)
|
||||||
|
|
||||||
export const IWT2_BOSS_METADATA: Record<Iwt2BossId, Iwt2BossMetadata> = {
|
export const IWT2_BOSS_METADATA: Record<Iwt2BossId, Iwt2BossMetadata> = {
|
||||||
bulldrome: BULLDROME_BOSS_METADATA,
|
bulldrome: BULLDROME_BOSS_METADATA,
|
||||||
'yian-kut-ku': YIAN_KUT_KU_BOSS_METADATA,
|
'yian-kut-ku': YIAN_KUT_KU_BOSS_METADATA,
|
||||||
|
'great-jaggi': GREAT_JAGGI_BOSS_METADATA,
|
||||||
|
khezu: KHEZU_BOSS_METADATA,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import type { Spell } from '../../../game'
|
import type { Spell } from '../../../game'
|
||||||
import type { Ability } from '../../../profile'
|
|
||||||
import { toCombatSpell } from '../../../combat/rules'
|
|
||||||
|
|
||||||
export type Iwt2AbilityTarget = 'party-member' | 'self' | 'ground'
|
export type Iwt2AbilityTarget = 'party-member' | 'self' | 'ground'
|
||||||
|
export type Iwt2HealerId = 'dawnweaver' | 'lifebinder' | 'runesage'
|
||||||
|
|
||||||
|
export type Iwt2HealerMetadata = {
|
||||||
|
id: Iwt2HealerId
|
||||||
|
name: string
|
||||||
|
icon: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
export type Iwt2HealerAbility = {
|
export type Iwt2HealerAbility = {
|
||||||
id: string
|
id: string
|
||||||
healerId: string
|
healerId: Iwt2HealerId
|
||||||
slot: number
|
slot: number
|
||||||
name: string
|
name: string
|
||||||
icon: string
|
icon: string
|
||||||
@@ -16,110 +22,95 @@ export type Iwt2HealerAbility = {
|
|||||||
manaCost: number
|
manaCost: number
|
||||||
cooldownSeconds: number
|
cooldownSeconds: number
|
||||||
target: Iwt2AbilityTarget
|
target: Iwt2AbilityTarget
|
||||||
|
extraTargets?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const IWT1_DAWNWEAVER_ABILITIES: Ability[] = [
|
export const IWT2_HEALER_METADATA: Record<Iwt2HealerId, Iwt2HealerMetadata> = {
|
||||||
{
|
dawnweaver: {
|
||||||
id: 1,
|
id: 'dawnweaver',
|
||||||
classId: 1,
|
name: 'Dawnweaver',
|
||||||
slug: 'mend',
|
icon: '+',
|
||||||
name: 'Mend',
|
description: 'Direct heals, radiant group recovery, shields, and cleanse.',
|
||||||
spellType: 'direct_heal',
|
|
||||||
cost: 5,
|
|
||||||
cooldown: 0.5,
|
|
||||||
power: 30,
|
|
||||||
unlockLevel: 1,
|
|
||||||
glyph: '+',
|
|
||||||
description: 'A fast, efficient single-target heal.',
|
|
||||||
},
|
},
|
||||||
{
|
lifebinder: {
|
||||||
id: 2,
|
id: 'lifebinder',
|
||||||
classId: 1,
|
name: 'Lifebinder',
|
||||||
slug: 'renew',
|
icon: '~',
|
||||||
name: 'Renew',
|
description: 'Verdant healing over time, barkskin shielding, and steady group recovery.',
|
||||||
spellType: 'heal_over_time',
|
|
||||||
cost: 7,
|
|
||||||
cooldown: 0.5,
|
|
||||||
power: 12,
|
|
||||||
unlockLevel: 1,
|
|
||||||
glyph: '~',
|
|
||||||
description: 'Heals now and continues healing over time.',
|
|
||||||
},
|
},
|
||||||
{
|
runesage: {
|
||||||
id: 3,
|
id: 'runesage',
|
||||||
classId: 1,
|
name: 'Runesage',
|
||||||
slug: 'radiance',
|
icon: 'R',
|
||||||
name: 'Radiance',
|
description: 'Rune-scripted mends, concordance healing, aegis shielding, and unraveling cleanse.',
|
||||||
spellType: 'party_heal',
|
|
||||||
cost: 12,
|
|
||||||
cooldown: 8,
|
|
||||||
power: 18,
|
|
||||||
unlockLevel: 1,
|
|
||||||
glyph: '*',
|
|
||||||
description: 'Restores health to up to 4 injured party members.',
|
|
||||||
},
|
},
|
||||||
{
|
}
|
||||||
id: 4,
|
|
||||||
classId: 1,
|
|
||||||
slug: 'sun-ward',
|
|
||||||
name: 'Sun Ward',
|
|
||||||
spellType: 'absorb',
|
|
||||||
cost: 8,
|
|
||||||
cooldown: 7,
|
|
||||||
power: 36,
|
|
||||||
unlockLevel: 1,
|
|
||||||
glyph: 'O',
|
|
||||||
description: 'Places a damage-absorbing shield on your target.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
classId: 1,
|
|
||||||
slug: 'purify',
|
|
||||||
name: 'Purify',
|
|
||||||
spellType: 'cleanse',
|
|
||||||
cost: 5,
|
|
||||||
cooldown: 5,
|
|
||||||
power: 10,
|
|
||||||
unlockLevel: 1,
|
|
||||||
glyph: 'x',
|
|
||||||
description: 'Removes a harmful effect and restores health.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
classId: 1,
|
|
||||||
slug: 'dawn-burst',
|
|
||||||
name: 'Dawn Burst',
|
|
||||||
spellType: 'party_heal',
|
|
||||||
cost: 16,
|
|
||||||
cooldown: 12,
|
|
||||||
power: 28,
|
|
||||||
unlockLevel: 5,
|
|
||||||
glyph: 'D',
|
|
||||||
description: 'A brilliant wave of healing for up to 4 injured allies.',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function toIwt2Ability(ability: Ability, index: number): Iwt2HealerAbility {
|
export const IWT2_HEALER_ORDER: Iwt2HealerId[] = ['dawnweaver', 'lifebinder', 'runesage']
|
||||||
const spell = toCombatSpell(ability, String(index + 1))
|
|
||||||
|
export const IWT2_HEALER_ABILITIES: Record<Iwt2HealerId, Iwt2HealerAbility[]> = {
|
||||||
|
dawnweaver: [
|
||||||
|
createAbility('dawnweaver', 1, 'mend', 'Mend', '+', 'direct', 30, 5, 0.5),
|
||||||
|
createAbility('dawnweaver', 2, 'renew', 'Renew', '~', 'hot', 12, 7, 0.5, 'heal_over_time'),
|
||||||
|
createAbility('dawnweaver', 3, 'radiance', 'Radiance', '*', 'group', 18, 12, 8),
|
||||||
|
createAbility('dawnweaver', 4, 'sun-ward', 'Sun Ward', 'O', 'shield', 36, 8, 7, 'shield'),
|
||||||
|
createAbility('dawnweaver', 5, 'purify', 'Purify', 'x', 'cleanse', 10, 5, 5, 'cleanse'),
|
||||||
|
createAbility('dawnweaver', 6, 'dawn-burst', 'Dawn Burst', 'D', 'group', 28, 16, 12),
|
||||||
|
],
|
||||||
|
lifebinder: [
|
||||||
|
createAbility('lifebinder', 1, 'verdant-touch', 'Verdant Touch', '+', 'direct', 24, 4, 0.55),
|
||||||
|
createAbility('lifebinder', 2, 'seed-of-life', 'Seed of Life', '~', 'hot', 16, 9, 1, 'heal_over_time'),
|
||||||
|
createAbility('lifebinder', 3, 'wild-growth', 'Wild Growth', '*', 'group', 15, 11, 7.5),
|
||||||
|
createAbility('lifebinder', 4, 'barkskin', 'Barkskin', 'O', 'shield', 46, 10, 5.5, 'shield'),
|
||||||
|
createAbility('lifebinder', 5, 'purging-sap', 'Purging Sap', 'x', 'cleanse', 12, 6, 4.5, 'cleanse'),
|
||||||
|
createAbility('lifebinder', 6, 'ancient-grove', 'Ancient Grove', 'G', 'shield', 72, 18, 12, 'shield'),
|
||||||
|
],
|
||||||
|
runesage: [
|
||||||
|
createAbility('runesage', 1, 'etched-mend', 'Etched Mend', '+', 'direct', 22, 3, 0.35),
|
||||||
|
createAbility('runesage', 2, 'mending-rune', 'Mending Rune', '~', 'hot', 10, 5, 0.45, 'heal_over_time'),
|
||||||
|
createAbility('runesage', 3, 'concordance', 'Concordance', '*', 'group', 16, 9, 5.5),
|
||||||
|
createAbility('runesage', 4, 'aegis-script', 'Aegis Script', 'O', 'shield', 28, 6, 4.5, 'shield'),
|
||||||
|
createAbility('runesage', 5, 'unravel', 'Unravel', 'x', 'cleanse', 8, 4, 3.5, 'cleanse'),
|
||||||
|
createAbility('runesage', 6, 'grand-design', 'Grand Design', 'R', 'group', 22, 13, 8.5),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function abilitiesForHealer(healerId: Iwt2HealerId | string) {
|
||||||
|
return IWT2_HEALER_ABILITIES[asIwt2HealerId(healerId)]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asIwt2HealerId(value: unknown): Iwt2HealerId {
|
||||||
|
if (value === 'field_medic') return 'dawnweaver'
|
||||||
|
if (value === 'ward_sage') return 'lifebinder'
|
||||||
|
if (value === 'storm_chanter') return 'runesage'
|
||||||
|
return IWT2_HEALER_ORDER.includes(value as Iwt2HealerId)
|
||||||
|
? value as Iwt2HealerId
|
||||||
|
: 'dawnweaver'
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAbility(
|
||||||
|
healerId: Iwt2HealerId,
|
||||||
|
slot: number,
|
||||||
|
slug: string,
|
||||||
|
name: string,
|
||||||
|
icon: string,
|
||||||
|
kind: Spell['kind'],
|
||||||
|
power: number,
|
||||||
|
manaCost: number,
|
||||||
|
cooldownSeconds: number,
|
||||||
|
effectType?: string,
|
||||||
|
): Iwt2HealerAbility {
|
||||||
return {
|
return {
|
||||||
id: spell.id,
|
id: `${healerId}-${slug}`,
|
||||||
healerId: 'field_medic',
|
healerId,
|
||||||
slot: index + 1,
|
slot,
|
||||||
name: spell.name,
|
name,
|
||||||
icon: spell.glyph,
|
icon,
|
||||||
kind: spell.kind,
|
kind,
|
||||||
power: spell.power,
|
power,
|
||||||
effectType: spell.effectType,
|
effectType,
|
||||||
manaCost: spell.cost,
|
manaCost,
|
||||||
cooldownSeconds: spell.cooldown,
|
cooldownSeconds,
|
||||||
target: 'party-member',
|
target: 'party-member',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const IWT2_HEALER_ABILITIES: Record<string, Iwt2HealerAbility[]> = {
|
|
||||||
field_medic: IWT1_DAWNWEAVER_ABILITIES.map(toIwt2Ability),
|
|
||||||
}
|
|
||||||
|
|
||||||
export function abilitiesForHealer(healerId: string) {
|
|
||||||
return IWT2_HEALER_ABILITIES[healerId] ?? IWT2_HEALER_ABILITIES.field_medic
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
buildOpponentSlotDebuffChoices,
|
||||||
|
buildSelfSlotUpgradeChoices,
|
||||||
|
} from '../../../combat/roguelikeUpgrades'
|
||||||
|
import type { Spell } from '../../../game'
|
||||||
|
import { type Iwt2HealerAbility } from './healerAbilities'
|
||||||
|
|
||||||
|
export type Iwt2RoguelikeVariant = 'pve' | 'pvp'
|
||||||
|
export type Iwt2RoguelikeContentType = 'dungeon' | 'raid' | 'stadium'
|
||||||
|
export type Iwt2RoguelikeSlot = '1' | '2' | '3' | '4' | '5'
|
||||||
|
|
||||||
|
export type Iwt2RoguelikeSelfBuffId =
|
||||||
|
| 'revive-party-members'
|
||||||
|
| `slot${Iwt2RoguelikeSlot}-extra-target`
|
||||||
|
| `slot${Iwt2RoguelikeSlot}-cost-down`
|
||||||
|
| `slot${Iwt2RoguelikeSlot}-cooldown-down`
|
||||||
|
|
||||||
|
export type Iwt2RoguelikeOpponentDebuffId =
|
||||||
|
| `opp-slot${Iwt2RoguelikeSlot}-cost-up`
|
||||||
|
| `opp-slot${Iwt2RoguelikeSlot}-cooldown-up`
|
||||||
|
|
||||||
|
export type Iwt2RoguelikeChoice<T extends string> = {
|
||||||
|
id: T
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IWT2_ROGUELIKE_SLOTS: readonly Iwt2RoguelikeSlot[] = ['1', '2', '3', '4', '5']
|
||||||
|
|
||||||
|
export const IWT2_REVIVE_PARTY_CHOICE: Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId> = {
|
||||||
|
id: 'revive-party-members',
|
||||||
|
name: 'Revive Party Members',
|
||||||
|
description: 'Revive fallen party members before the next IWT2 arena.',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function iwt2AbilityToSpell(ability: Iwt2HealerAbility): Spell {
|
||||||
|
return {
|
||||||
|
id: ability.id,
|
||||||
|
key: String(ability.slot),
|
||||||
|
name: ability.name,
|
||||||
|
description: ability.name,
|
||||||
|
cost: ability.manaCost,
|
||||||
|
cooldown: ability.cooldownSeconds,
|
||||||
|
power: ability.power,
|
||||||
|
glyph: ability.icon,
|
||||||
|
kind: ability.kind,
|
||||||
|
effectType: ability.effectType,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildIwt2SelfBuffChoices(abilities: Iwt2HealerAbility[]) {
|
||||||
|
return buildSelfSlotUpgradeChoices<Iwt2RoguelikeSelfBuffId>({
|
||||||
|
slots: IWT2_ROGUELIKE_SLOTS,
|
||||||
|
spells: abilities.map(iwt2AbilityToSpell),
|
||||||
|
labelMode: 'ability',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildIwt2OpponentDebuffChoices(abilities: Iwt2HealerAbility[]) {
|
||||||
|
return buildOpponentSlotDebuffChoices<Iwt2RoguelikeOpponentDebuffId>({
|
||||||
|
slots: IWT2_ROGUELIKE_SLOTS,
|
||||||
|
spells: abilities.map(iwt2AbilityToSpell),
|
||||||
|
labelMode: 'ability',
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -83,7 +83,7 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
const graphics = this.entityGraphics!
|
const graphics = this.entityGraphics!
|
||||||
graphics.clear()
|
graphics.clear()
|
||||||
for (const hazard of state.hazards) drawHazard(graphics, hazard)
|
for (const hazard of state.hazards) drawHazard(graphics, hazard)
|
||||||
const entities: DrawableEntity[] = [...state.party, ...state.hostileAdds, state.boss]
|
const entities: DrawableEntity[] = [...state.party, ...state.hostileAdds, ...state.bosses]
|
||||||
const liveIds = new Set<string>(entities.map((entity) => entity.id))
|
const liveIds = new Set<string>(entities.map((entity) => entity.id))
|
||||||
|
|
||||||
for (const entity of entities.sort(entitySort)) {
|
for (const entity of entities.sort(entitySort)) {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { asIwt2HealerId, type Iwt2HealerId } from '../content/healerAbilities'
|
||||||
|
|
||||||
export type Iwt2InventoryItem = {
|
export type Iwt2InventoryItem = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -5,21 +7,35 @@ export type Iwt2InventoryItem = {
|
|||||||
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
|
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Iwt2ArmorPaletteId = 'guild_green' | 'sun_gold' | 'ember_red' | 'moon_blue'
|
||||||
|
|
||||||
export type Iwt2CollectionLog = {
|
export type Iwt2CollectionLog = {
|
||||||
bossKills: Record<string, number>
|
bossKills: Record<string, number>
|
||||||
dropsFound: Record<string, number>
|
dropsFound: Record<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Iwt2Character = {
|
||||||
|
name: string
|
||||||
|
level: number
|
||||||
|
experience: number
|
||||||
|
healerStyle: Iwt2HealerId
|
||||||
|
armorPalette: Iwt2ArmorPaletteId
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Iwt2CloudSlot = {
|
||||||
|
savedAt: number
|
||||||
|
character: Iwt2Character
|
||||||
|
inventory: Iwt2InventoryItem[]
|
||||||
|
collectionLog: Iwt2CollectionLog
|
||||||
|
}
|
||||||
|
|
||||||
export type Iwt2Save = {
|
export type Iwt2Save = {
|
||||||
version: 1
|
version: 1
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
character: {
|
character: Iwt2Character
|
||||||
name: string
|
|
||||||
level: number
|
|
||||||
experience: number
|
|
||||||
}
|
|
||||||
inventory: Iwt2InventoryItem[]
|
inventory: Iwt2InventoryItem[]
|
||||||
collectionLog: Iwt2CollectionLog
|
collectionLog: Iwt2CollectionLog
|
||||||
|
cloudSlot?: Iwt2CloudSlot
|
||||||
}
|
}
|
||||||
|
|
||||||
const IWT2_SAVE_KEY = 'i-want-to-heal-2:save:v1'
|
const IWT2_SAVE_KEY = 'i-want-to-heal-2:save:v1'
|
||||||
@@ -32,6 +48,8 @@ export function createDefaultIwt2Save(): Iwt2Save {
|
|||||||
name: 'Healer',
|
name: 'Healer',
|
||||||
level: 1,
|
level: 1,
|
||||||
experience: 0,
|
experience: 0,
|
||||||
|
healerStyle: 'dawnweaver',
|
||||||
|
armorPalette: 'guild_green',
|
||||||
},
|
},
|
||||||
inventory: [],
|
inventory: [],
|
||||||
collectionLog: {
|
collectionLog: {
|
||||||
@@ -52,12 +70,15 @@ function normalizeSave(value: unknown): Iwt2Save {
|
|||||||
name: candidate.character?.name || 'Healer',
|
name: candidate.character?.name || 'Healer',
|
||||||
level: Math.max(1, Math.floor(candidate.character?.level ?? 1)),
|
level: Math.max(1, Math.floor(candidate.character?.level ?? 1)),
|
||||||
experience: Math.max(0, Math.floor(candidate.character?.experience ?? 0)),
|
experience: Math.max(0, Math.floor(candidate.character?.experience ?? 0)),
|
||||||
|
healerStyle: asIwt2HealerId(candidate.character?.healerStyle),
|
||||||
|
armorPalette: asIwt2ArmorPaletteId(candidate.character?.armorPalette),
|
||||||
},
|
},
|
||||||
inventory: Array.isArray(candidate.inventory) ? candidate.inventory : [],
|
inventory: Array.isArray(candidate.inventory) ? candidate.inventory : [],
|
||||||
collectionLog: {
|
collectionLog: {
|
||||||
bossKills: candidate.collectionLog?.bossKills ?? {},
|
bossKills: candidate.collectionLog?.bossKills ?? {},
|
||||||
dropsFound: candidate.collectionLog?.dropsFound ?? {},
|
dropsFound: candidate.collectionLog?.dropsFound ?? {},
|
||||||
},
|
},
|
||||||
|
cloudSlot: normalizeCloudSlot(candidate.cloudSlot),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +130,91 @@ export function recordIwt2BossKill(save: Iwt2Save, bossId: string): Iwt2Save {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateIwt2CharacterSettings(
|
||||||
|
save: Iwt2Save,
|
||||||
|
settings: Partial<Pick<Iwt2Save['character'], 'name' | 'healerStyle' | 'armorPalette'>>,
|
||||||
|
): Iwt2Save {
|
||||||
|
return {
|
||||||
|
...save,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
character: {
|
||||||
|
...save.character,
|
||||||
|
...settings,
|
||||||
|
name: normalizeCharacterName(settings.name ?? save.character.name),
|
||||||
|
healerStyle: asIwt2HealerId(settings.healerStyle ?? save.character.healerStyle),
|
||||||
|
armorPalette: asIwt2ArmorPaletteId(settings.armorPalette ?? save.character.armorPalette),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function snapshotIwt2CloudSlot(save: Iwt2Save): Iwt2Save {
|
||||||
|
return {
|
||||||
|
...save,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
cloudSlot: {
|
||||||
|
savedAt: Date.now(),
|
||||||
|
character: { ...save.character },
|
||||||
|
inventory: save.inventory.map((item) => ({ ...item })),
|
||||||
|
collectionLog: {
|
||||||
|
bossKills: { ...save.collectionLog.bossKills },
|
||||||
|
dropsFound: { ...save.collectionLog.dropsFound },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreIwt2CloudSlot(save: Iwt2Save): Iwt2Save {
|
||||||
|
if (!save.cloudSlot) return save
|
||||||
|
return {
|
||||||
|
...save,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
character: { ...save.cloudSlot.character },
|
||||||
|
inventory: save.cloudSlot.inventory.map((item) => ({ ...item })),
|
||||||
|
collectionLog: {
|
||||||
|
bossKills: { ...save.cloudSlot.collectionLog.bossKills },
|
||||||
|
dropsFound: { ...save.cloudSlot.collectionLog.dropsFound },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function bossDropFor(bossId: string): { id: string, name: string } {
|
function bossDropFor(bossId: string): { id: string, name: string } {
|
||||||
if (bossId === 'yian-kut-ku') return { id: 'yian-kut-ku-scale', name: 'Yian Kut Ku Scale' }
|
if (bossId === 'yian-kut-ku') return { id: 'yian-kut-ku-scale', name: 'Yian Kut Ku Scale' }
|
||||||
|
if (bossId === 'great-jaggi') return { id: 'great-jaggi-hide', name: 'Great Jaggi Hide' }
|
||||||
|
if (bossId === 'khezu') return { id: 'khezu-pearl', name: 'Khezu Pearl' }
|
||||||
return { id: 'raw-bulldrome-coin', name: 'Raw Bulldrome Coin' }
|
return { id: 'raw-bulldrome-coin', name: 'Raw Bulldrome Coin' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asIwt2ArmorPaletteId(value: unknown): Iwt2ArmorPaletteId {
|
||||||
|
return value === 'sun_gold'
|
||||||
|
|| value === 'ember_red'
|
||||||
|
|| value === 'moon_blue'
|
||||||
|
|| value === 'guild_green'
|
||||||
|
? value
|
||||||
|
: 'guild_green'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCharacterName(name: string): string {
|
||||||
|
const trimmed = name.trim().slice(0, 18)
|
||||||
|
return trimmed || 'Healer'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCloudSlot(value: unknown): Iwt2CloudSlot | undefined {
|
||||||
|
if (!value || typeof value !== 'object') return undefined
|
||||||
|
const candidate = value as Partial<Iwt2CloudSlot>
|
||||||
|
if (!candidate.character || !candidate.collectionLog) return undefined
|
||||||
|
return {
|
||||||
|
savedAt: typeof candidate.savedAt === 'number' ? candidate.savedAt : Date.now(),
|
||||||
|
character: {
|
||||||
|
name: normalizeCharacterName(candidate.character.name ?? 'Healer'),
|
||||||
|
level: Math.max(1, Math.floor(candidate.character.level ?? 1)),
|
||||||
|
experience: Math.max(0, Math.floor(candidate.character.experience ?? 0)),
|
||||||
|
healerStyle: asIwt2HealerId(candidate.character.healerStyle),
|
||||||
|
armorPalette: asIwt2ArmorPaletteId(candidate.character.armorPalette),
|
||||||
|
},
|
||||||
|
inventory: Array.isArray(candidate.inventory) ? candidate.inventory : [],
|
||||||
|
collectionLog: {
|
||||||
|
bossKills: candidate.collectionLog.bossKills ?? {},
|
||||||
|
dropsFound: candidate.collectionLog.dropsFound ?? {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
|
||||||
import type { MovementVector } from '../../../input'
|
import type { MovementVector } from '../../../input'
|
||||||
import { useGameAction, useInput, useMovementVectorRef } from '../../../input'
|
import { useGameAction, useInput, useMovementVectorRef } from '../../../input'
|
||||||
|
import {
|
||||||
|
useDualScreen,
|
||||||
|
useDualScreenPublisher,
|
||||||
|
type DualScreenCombatState,
|
||||||
|
} from '../../../dualScreen'
|
||||||
|
import type { PartyMember, Role, Spell } from '../../../game'
|
||||||
import {
|
import {
|
||||||
createInitialIwt2ArenaState,
|
createInitialIwt2ArenaState,
|
||||||
tickIwt2Arena,
|
tickIwt2Arena,
|
||||||
@@ -16,32 +22,65 @@ import {
|
|||||||
import { AbilityBar } from '../components/AbilityBar'
|
import { AbilityBar } from '../components/AbilityBar'
|
||||||
import { BossHud } from '../components/BossHud'
|
import { BossHud } from '../components/BossHud'
|
||||||
import { PartyFrames } from '../components/PartyFrames'
|
import { PartyFrames } from '../components/PartyFrames'
|
||||||
|
import { IWT2_CLASS_METADATA } from '../content/classes'
|
||||||
import { IWT2_ABILITY_ACTIONS, IWT2_TARGET_ACTIONS } from '../content/controls'
|
import { IWT2_ABILITY_ACTIONS, IWT2_TARGET_ACTIONS } from '../content/controls'
|
||||||
import { abilitiesForHealer, type Iwt2HealerAbility } from '../content/healerAbilities'
|
import { abilitiesForHealer, type Iwt2HealerAbility } from '../content/healerAbilities'
|
||||||
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
|
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
|
||||||
|
import type {
|
||||||
|
Iwt2RoguelikeContentType,
|
||||||
|
Iwt2RoguelikeOpponentDebuffId,
|
||||||
|
Iwt2RoguelikeSelfBuffId,
|
||||||
|
Iwt2RoguelikeVariant,
|
||||||
|
} from '../content/roguelike'
|
||||||
|
|
||||||
type ArenaStatus = 'playing' | 'paused' | 'victory' | 'defeat'
|
type ArenaStatus = 'playing' | 'paused' | 'victory' | 'defeat'
|
||||||
type OverlayAction = 'primary' | 'menu'
|
type OverlayAction = 'primary' | 'menu'
|
||||||
|
type OverlayNavEntry = {
|
||||||
|
action: OverlayAction
|
||||||
|
row: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
|
||||||
|
{ action: 'primary', row: 0 },
|
||||||
|
{ action: 'menu', row: 1 },
|
||||||
|
]
|
||||||
|
|
||||||
type BossArenaScreenProps = {
|
type BossArenaScreenProps = {
|
||||||
bossId: Iwt2BossId
|
bossId: Iwt2BossId
|
||||||
|
bossIds?: Iwt2BossId[]
|
||||||
|
modeLabel?: string
|
||||||
save: Iwt2Save
|
save: Iwt2Save
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
onSaveUpdated: (save: Iwt2Save) => void
|
onSaveUpdated: (save: Iwt2Save) => void
|
||||||
|
roguelikeRun?: {
|
||||||
|
buffs: Iwt2RoguelikeSelfBuffId[]
|
||||||
|
contentType: Iwt2RoguelikeContentType
|
||||||
|
debuffs: Iwt2RoguelikeOpponentDebuffId[]
|
||||||
|
onVictory: () => void
|
||||||
|
stage: number
|
||||||
|
variant: Iwt2RoguelikeVariant
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossArenaScreenProps) {
|
export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) {
|
||||||
const bossMetadata = IWT2_BOSS_METADATA[bossId]
|
const bossMetadata = IWT2_BOSS_METADATA[bossId]
|
||||||
const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createInitialIwt2ArenaState(bossId))
|
const pvpRoguelike = roguelikeRun?.variant === 'pvp'
|
||||||
|
const bossHealthScale = roguelikeRun ? roguelikeBossHealthScale(roguelikeRun.stage) : 1
|
||||||
|
const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale))
|
||||||
|
const [opponentArenaState, setOpponentArenaState] = useState<Iwt2ArenaState | null>(() => (
|
||||||
|
pvpRoguelike ? createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale) : null
|
||||||
|
))
|
||||||
const [abilityCooldowns, setAbilityCooldowns] = useState<Record<string, number>>({})
|
const [abilityCooldowns, setAbilityCooldowns] = useState<Record<string, number>>({})
|
||||||
const [status, setStatus] = useState<ArenaStatus>('playing')
|
const [status, setStatus] = useState<ArenaStatus>('playing')
|
||||||
const [selectedOverlayAction, setSelectedOverlayAction] = useState<OverlayAction>('primary')
|
const [selectedOverlayAction, setSelectedOverlayAction] = useState<OverlayAction>('primary')
|
||||||
const [selectedPartyId, setSelectedPartyId] = useState<Iwt2EntityId>('player-healer')
|
const [selectedPartyId, setSelectedPartyId] = useState<Iwt2EntityId>('player-healer')
|
||||||
const stateRef = useRef(arenaState)
|
const stateRef = useRef(arenaState)
|
||||||
|
const opponentStateRef = useRef<Iwt2ArenaState | null>(opponentArenaState)
|
||||||
const statusRef = useRef(status)
|
const statusRef = useRef(status)
|
||||||
|
const selectedOverlayActionRef = useRef<OverlayAction>(selectedOverlayAction)
|
||||||
const selectedPartyIdRef = useRef<Iwt2EntityId>(selectedPartyId)
|
const selectedPartyIdRef = useRef<Iwt2EntityId>(selectedPartyId)
|
||||||
const saveRef = useRef(save)
|
const saveRef = useRef(save)
|
||||||
const killRecordedRef = useRef(false)
|
const recordedKillIdsRef = useRef<Set<Iwt2BossId>>(new Set())
|
||||||
const lastPublishTimeRef = useRef(0)
|
const lastPublishTimeRef = useRef(0)
|
||||||
const lastHudSignatureRef = useRef('')
|
const lastHudSignatureRef = useRef('')
|
||||||
const abilityCooldownsRef = useRef<Record<string, number>>({})
|
const abilityCooldownsRef = useRef<Record<string, number>>({})
|
||||||
@@ -52,16 +91,25 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre
|
|||||||
directPartyTargeting,
|
directPartyTargeting,
|
||||||
lastDevice,
|
lastDevice,
|
||||||
} = useInput()
|
} = useInput()
|
||||||
|
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||||
const activeBindings = bindings[lastDevice]
|
const activeBindings = bindings[lastDevice]
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
stateRef.current = arenaState
|
stateRef.current = arenaState
|
||||||
}, [arenaState])
|
}, [arenaState])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
opponentStateRef.current = opponentArenaState
|
||||||
|
}, [opponentArenaState])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
statusRef.current = status
|
statusRef.current = status
|
||||||
}, [status])
|
}, [status])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedOverlayActionRef.current = selectedOverlayAction
|
||||||
|
}, [selectedOverlayAction])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
selectedPartyIdRef.current = selectedPartyId
|
selectedPartyIdRef.current = selectedPartyId
|
||||||
}, [selectedPartyId])
|
}, [selectedPartyId])
|
||||||
@@ -71,16 +119,19 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre
|
|||||||
}, [save])
|
}, [save])
|
||||||
|
|
||||||
const resetArena = useCallback(() => {
|
const resetArena = useCallback(() => {
|
||||||
const next = createInitialIwt2ArenaState(bossId)
|
const next = createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale)
|
||||||
killRecordedRef.current = false
|
const nextOpponentState = pvpRoguelike ? createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale) : null
|
||||||
|
recordedKillIdsRef.current = new Set()
|
||||||
abilityCooldownsRef.current = {}
|
abilityCooldownsRef.current = {}
|
||||||
lastHudSignatureRef.current = arenaHudSignature(next)
|
lastHudSignatureRef.current = arenaHudSignature(next)
|
||||||
stateRef.current = next
|
stateRef.current = next
|
||||||
|
opponentStateRef.current = nextOpponentState
|
||||||
setArenaState(next)
|
setArenaState(next)
|
||||||
|
setOpponentArenaState(nextOpponentState)
|
||||||
setAbilityCooldowns({})
|
setAbilityCooldowns({})
|
||||||
setSelectedOverlayAction('primary')
|
setSelectedOverlayAction('primary')
|
||||||
setStatus('playing')
|
setStatus('playing')
|
||||||
}, [bossId])
|
}, [bossHealthScale, bossId, bossIds, pvpRoguelike])
|
||||||
|
|
||||||
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => {
|
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => {
|
||||||
setSelectedOverlayAction('primary')
|
setSelectedOverlayAction('primary')
|
||||||
@@ -88,8 +139,12 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const abilities = useMemo(
|
const abilities = useMemo(
|
||||||
() => abilitiesForHealer('field_medic'),
|
() => applyRoguelikeModifiers(
|
||||||
[],
|
abilitiesForHealer(save.character.healerStyle),
|
||||||
|
roguelikeRun?.buffs ?? [],
|
||||||
|
roguelikeRun?.debuffs ?? [],
|
||||||
|
),
|
||||||
|
[roguelikeRun?.buffs, roguelikeRun?.debuffs, save.character.healerStyle],
|
||||||
)
|
)
|
||||||
|
|
||||||
const castAbility = useCallback((ability: Iwt2HealerAbility) => {
|
const castAbility = useCallback((ability: Iwt2HealerAbility) => {
|
||||||
@@ -109,21 +164,49 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre
|
|||||||
setArenaState(result.state)
|
setArenaState(result.state)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const moveOverlaySelection = useCallback((action: string) => {
|
||||||
|
setSelectedOverlayAction((current) => {
|
||||||
|
const active = OVERLAY_NAV_ENTRIES.find((entry) => entry.action === current) ?? OVERLAY_NAV_ENTRIES[0]
|
||||||
|
const candidates = OVERLAY_NAV_ENTRIES.filter((entry) => {
|
||||||
|
if (entry.action === current) return false
|
||||||
|
if (action === 'navigateUp') return entry.row < active.row
|
||||||
|
if (action === 'navigateDown') return entry.row > active.row
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if (candidates.length === 0) return current
|
||||||
|
candidates.sort((a, b) => Math.abs(a.row - active.row) - Math.abs(b.row - active.row))
|
||||||
|
return candidates[0]?.action ?? current
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const activateOverlayAction = useCallback((overlayAction = selectedOverlayActionRef.current) => {
|
||||||
|
if (overlayAction === 'menu') {
|
||||||
|
onBack()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (statusRef.current === 'paused') {
|
||||||
|
setStatus('playing')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (statusRef.current === 'victory' && roguelikeRun) {
|
||||||
|
roguelikeRun.onVictory()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resetArena()
|
||||||
|
}, [onBack, resetArena, roguelikeRun])
|
||||||
|
|
||||||
useGameAction((action, device) => {
|
useGameAction((action, device) => {
|
||||||
if (device === 'controller' && statusRef.current !== 'playing') {
|
if (device === 'controller' && statusRef.current !== 'playing') {
|
||||||
if (action.startsWith('navigate')) {
|
if (action.startsWith('navigate')) {
|
||||||
setSelectedOverlayAction((current) => current === 'primary' ? 'menu' : 'primary')
|
moveOverlaySelection(action)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (action === 'confirm') {
|
if (action === 'confirm') {
|
||||||
if (selectedOverlayAction === 'menu') onBack()
|
activateOverlayAction()
|
||||||
else if (statusRef.current === 'paused') setStatus('playing')
|
|
||||||
else resetArena()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (action === 'back') {
|
if (action === 'back') {
|
||||||
if (statusRef.current === 'paused') setStatus('playing')
|
if (statusRef.current === 'paused') setStatus('playing')
|
||||||
else onBack()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,12 +253,34 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre
|
|||||||
moveY: movement.y,
|
moveY: movement.y,
|
||||||
}, dtSeconds)
|
}, dtSeconds)
|
||||||
stateRef.current = next
|
stateRef.current = next
|
||||||
|
let nextOpponentState = opponentStateRef.current
|
||||||
|
if (pvpRoguelike && nextOpponentState) {
|
||||||
|
const opponentMovement = iwt2OpponentMovement(nextOpponentState)
|
||||||
|
nextOpponentState = tickIwt2Arena(
|
||||||
|
nextOpponentState,
|
||||||
|
{
|
||||||
|
moveX: opponentMovement.x,
|
||||||
|
moveY: opponentMovement.y,
|
||||||
|
},
|
||||||
|
dtSeconds,
|
||||||
|
)
|
||||||
|
opponentStateRef.current = nextOpponentState
|
||||||
|
}
|
||||||
|
|
||||||
if (next.boss.health <= 0 && !killRecordedRef.current) {
|
const newlyDefeatedBosses = next.bosses.filter((boss) => boss.health <= 0 && !recordedKillIdsRef.current.has(boss.bossId))
|
||||||
killRecordedRef.current = true
|
if (newlyDefeatedBosses.length > 0) {
|
||||||
const updatedSave = recordIwt2BossKill(saveRef.current, bossId)
|
const nextRecordedIds = new Set(recordedKillIdsRef.current)
|
||||||
|
let updatedSave = saveRef.current
|
||||||
|
for (const defeatedBoss of newlyDefeatedBosses) {
|
||||||
|
nextRecordedIds.add(defeatedBoss.bossId)
|
||||||
|
updatedSave = recordIwt2BossKill(updatedSave, defeatedBoss.bossId)
|
||||||
|
}
|
||||||
|
recordedKillIdsRef.current = nextRecordedIds
|
||||||
saveRef.current = updatedSave
|
saveRef.current = updatedSave
|
||||||
onSaveUpdated(updatedSave)
|
onSaveUpdated(updatedSave)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next.bosses.every((boss) => boss.health <= 0)) {
|
||||||
showOverlay('victory')
|
showOverlay('victory')
|
||||||
} else if (next.party.every((member) => member.health <= 0)) {
|
} else if (next.party.every((member) => member.health <= 0)) {
|
||||||
showOverlay('defeat')
|
showOverlay('defeat')
|
||||||
@@ -185,15 +290,16 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre
|
|||||||
if (
|
if (
|
||||||
hudSignature !== lastHudSignatureRef.current
|
hudSignature !== lastHudSignatureRef.current
|
||||||
|| next.time - lastPublishTimeRef.current >= 0.08
|
|| next.time - lastPublishTimeRef.current >= 0.08
|
||||||
|| next.boss.health <= 0
|
|| next.bosses.some((boss) => boss.health <= 0)
|
||||||
) {
|
) {
|
||||||
lastHudSignatureRef.current = hudSignature
|
lastHudSignatureRef.current = hudSignature
|
||||||
lastPublishTimeRef.current = next.time
|
lastPublishTimeRef.current = next.time
|
||||||
setArenaState(next)
|
setArenaState(next)
|
||||||
|
if (pvpRoguelike) setOpponentArenaState(nextOpponentState)
|
||||||
setAbilityCooldowns(abilityCooldownsRef.current)
|
setAbilityCooldowns(abilityCooldownsRef.current)
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
}, [bossId, onSaveUpdated, showOverlay])
|
}, [onSaveUpdated, pvpRoguelike, showOverlay])
|
||||||
|
|
||||||
const targetBindings = directPartyTargeting
|
const targetBindings = directPartyTargeting
|
||||||
? IWT2_TARGET_ACTIONS.map((action) => activeBindings[action] ?? null)
|
? IWT2_TARGET_ACTIONS.map((action) => activeBindings[action] ?? null)
|
||||||
@@ -201,9 +307,15 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre
|
|||||||
const playerMana = arenaState.party.find((member) => member.id === 'player-healer')?.mana ?? 0
|
const playerMana = arenaState.party.find((member) => member.id === 'player-healer')?.mana ?? 0
|
||||||
const alivePartyCount = arenaState.party.filter((member) => member.health > 0).length
|
const alivePartyCount = arenaState.party.filter((member) => member.health > 0).length
|
||||||
const totalPartyDamage = arenaState.party.reduce((total, member) => total + member.damageDone, 0)
|
const totalPartyDamage = arenaState.party.reduce((total, member) => total + member.damageDone, 0)
|
||||||
const overlayPrimaryLabel = status === 'paused' ? 'Resume' : 'Restart'
|
const defeatedBossCount = arenaState.bosses.filter((boss) => boss.health <= 0).length
|
||||||
|
const bossTitle = formatBossEncounterTitle(arenaState.bosses)
|
||||||
|
const overlayPrimaryLabel = status === 'paused'
|
||||||
|
? 'Resume'
|
||||||
|
: status === 'victory' && roguelikeRun
|
||||||
|
? 'Choose Upgrade'
|
||||||
|
: 'Restart'
|
||||||
const overlayTitle = status === 'victory'
|
const overlayTitle = status === 'victory'
|
||||||
? `${bossMetadata.name} Down`
|
? `${bossTitle} Down`
|
||||||
: status === 'defeat'
|
: status === 'defeat'
|
||||||
? 'Party Defeated'
|
? 'Party Defeated'
|
||||||
: 'Arena Paused'
|
: 'Arena Paused'
|
||||||
@@ -217,6 +329,47 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre
|
|||||||
: status === 'defeat'
|
: status === 'defeat'
|
||||||
? 'is-defeat'
|
? 'is-defeat'
|
||||||
: 'is-paused'
|
: 'is-paused'
|
||||||
|
const pauseTitle = arenaPauseTitle(roguelikeRun, modeLabel ?? bossMetadata.name)
|
||||||
|
const pauseCopy = roguelikeRun?.variant === 'pvp'
|
||||||
|
? undefined
|
||||||
|
: 'Combat is stopped. Resume the fight or leave the current run.'
|
||||||
|
const pauseLeaveLabel = roguelikeRun?.variant === 'pvp'
|
||||||
|
? 'Leave'
|
||||||
|
: roguelikeRun
|
||||||
|
? 'Leave Roguelike'
|
||||||
|
: `Leave ${modeLabel ?? 'Arena'}`
|
||||||
|
const dualScreenState = useMemo(
|
||||||
|
() => buildIwt2DualScreenCombatState({
|
||||||
|
abilities,
|
||||||
|
arenaState,
|
||||||
|
bindings: activeBindings,
|
||||||
|
cooldowns: abilityCooldowns,
|
||||||
|
controllerIconStyle,
|
||||||
|
directPartyTargeting,
|
||||||
|
modeLabel: modeLabel ?? 'Arena',
|
||||||
|
opponentArenaState,
|
||||||
|
playerMana,
|
||||||
|
roguelikeRun,
|
||||||
|
selectedPartyId,
|
||||||
|
status,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
abilities,
|
||||||
|
activeBindings,
|
||||||
|
arenaState,
|
||||||
|
abilityCooldowns,
|
||||||
|
controllerIconStyle,
|
||||||
|
directPartyTargeting,
|
||||||
|
modeLabel,
|
||||||
|
opponentArenaState,
|
||||||
|
playerMana,
|
||||||
|
roguelikeRun,
|
||||||
|
selectedPartyId,
|
||||||
|
status,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
useDualScreenPublisher(dualScreenState, dualScreenEnabled)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
@@ -226,7 +379,7 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre
|
|||||||
>
|
>
|
||||||
<section className="iwt2-arena-layout">
|
<section className="iwt2-arena-layout">
|
||||||
<div className="iwt2-arena-stage">
|
<div className="iwt2-arena-stage">
|
||||||
<BossHud boss={arenaState.boss} />
|
<BossHud bosses={arenaState.bosses} />
|
||||||
<PartyFrames
|
<PartyFrames
|
||||||
controllerIconStyle={controllerIconStyle}
|
controllerIconStyle={controllerIconStyle}
|
||||||
onTarget={setSelectedPartyId}
|
onTarget={setSelectedPartyId}
|
||||||
@@ -241,42 +394,79 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre
|
|||||||
selectedPartyIdRef={selectedPartyIdRef}
|
selectedPartyIdRef={selectedPartyIdRef}
|
||||||
stateRef={stateRef}
|
stateRef={stateRef}
|
||||||
/>
|
/>
|
||||||
{status !== 'playing' && (
|
{status === 'paused' && (
|
||||||
|
<div className="pause-screen iwt2-arena-overlay is-paused" data-game-nav-active="true" role="dialog" aria-modal="true">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Paused</p>
|
||||||
|
<h2>{pauseTitle}</h2>
|
||||||
|
{pauseCopy && <p>{pauseCopy}</p>}
|
||||||
|
<button
|
||||||
|
className={selectedOverlayAction === 'primary' ? 'game-selected' : ''}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined}
|
||||||
|
onClick={() => activateOverlayAction('primary')}
|
||||||
|
onPointerDown={() => setSelectedOverlayAction('primary')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Resume
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`secondary-result-button ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined}
|
||||||
|
onClick={() => activateOverlayAction('menu')}
|
||||||
|
onPointerDown={() => setSelectedOverlayAction('menu')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{pauseLeaveLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status !== 'playing' && status !== 'paused' && (
|
||||||
<div className={`pause-screen iwt2-arena-overlay ${overlayTone}`} data-game-nav-active="true">
|
<div className={`pause-screen iwt2-arena-overlay ${overlayTone}`} data-game-nav-active="true">
|
||||||
<div className="iwt2-result-panel">
|
<div className="iwt2-result-panel">
|
||||||
<div className="iwt2-result-crest" style={{ '--boss-color': bossMetadata.color, '--boss-accent': bossMetadata.accentColor } as CSSProperties}>
|
<div className="iwt2-result-crest" style={{ '--boss-color': bossMetadata.color, '--boss-accent': bossMetadata.accentColor } as CSSProperties}>
|
||||||
<span>{bossMetadata.icon}</span>
|
<span>{arenaState.bosses.map((boss) => IWT2_BOSS_METADATA[boss.bossId].icon).join('')}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="eyebrow">{overlayEyebrow}</p>
|
<p className="eyebrow">{overlayEyebrow}</p>
|
||||||
<h1>{overlayTitle}</h1>
|
<h1>{overlayTitle}</h1>
|
||||||
{status !== 'paused' && (
|
{roguelikeRun && (
|
||||||
<div className="iwt2-result-summary" aria-label="Arena result summary">
|
<p className="iwt2-result-hint">
|
||||||
<span>
|
{roguelikeRun.variant.toUpperCase()} {formatRoguelikeContentType(roguelikeRun.contentType)} Stage {roguelikeRun.stage}
|
||||||
<strong>{formatArenaTime(arenaState.time)}</strong>
|
</p>
|
||||||
Clear
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<strong>{alivePartyCount}/{arenaState.party.length}</strong>
|
|
||||||
Standing
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<strong>{Math.round(totalPartyDamage)}</strong>
|
|
||||||
Damage
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
<div className="iwt2-result-summary" aria-label="Arena result summary">
|
||||||
|
<span>
|
||||||
|
<strong>{formatArenaTime(arenaState.time)}</strong>
|
||||||
|
Clear
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong>{alivePartyCount}/{arenaState.party.length}</strong>
|
||||||
|
Standing
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong>{defeatedBossCount}/{arenaState.bosses.length}</strong>
|
||||||
|
Bosses
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong>{Math.round(totalPartyDamage)}</strong>
|
||||||
|
Damage
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{status === 'victory' && (
|
{status === 'victory' && (
|
||||||
<div className="iwt2-result-reward">
|
<div className="iwt2-result-reward">
|
||||||
<span>+125 XP</span>
|
<span>+{arenaState.bosses.length * 125} XP</span>
|
||||||
<span>Common carve</span>
|
<span>{arenaState.bosses.length} carves</span>
|
||||||
<span>Log +1</span>
|
<span>Log +{arenaState.bosses.length}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="iwt2-overlay-actions">
|
<div className="iwt2-overlay-actions">
|
||||||
<button
|
<button
|
||||||
className={`iwt2-result-button is-primary ${selectedOverlayAction === 'primary' ? 'game-selected' : ''}`}
|
className={`iwt2-result-button is-primary ${selectedOverlayAction === 'primary' ? 'game-selected' : ''}`}
|
||||||
data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined}
|
data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined}
|
||||||
onClick={status === 'paused' ? () => setStatus('playing') : resetArena}
|
onClick={() => activateOverlayAction('primary')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{overlayPrimaryLabel}
|
{overlayPrimaryLabel}
|
||||||
@@ -284,14 +474,14 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre
|
|||||||
<button
|
<button
|
||||||
className={`iwt2-result-button is-secondary ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
|
className={`iwt2-result-button is-secondary ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
|
||||||
data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined}
|
data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined}
|
||||||
onClick={onBack}
|
onClick={() => activateOverlayAction('menu')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Menu
|
Menu
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{lastDevice === 'controller' && (
|
{lastDevice === 'controller' && (
|
||||||
<small className="iwt2-result-hint">D-pad selects, A confirms, B exits</small>
|
<small className="iwt2-result-hint">D-pad selects, A confirms</small>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -316,11 +506,17 @@ function formatArenaTime(seconds: number): string {
|
|||||||
return `${minutes}:${remainder.toString().padStart(2, '0')}`
|
return `${minutes}:${remainder.toString().padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function roguelikeBossHealthScale(stage: number): number {
|
||||||
|
return 0.5 + Math.max(0, stage - 1) * 0.1
|
||||||
|
}
|
||||||
|
|
||||||
function arenaHudSignature(state: Iwt2ArenaState): string {
|
function arenaHudSignature(state: Iwt2ArenaState): string {
|
||||||
return [
|
return [
|
||||||
state.boss.id,
|
...state.bosses.map((boss) => [
|
||||||
Math.ceil(state.boss.health),
|
boss.id,
|
||||||
state.boss.attackPhase,
|
Math.ceil(boss.health),
|
||||||
|
boss.attackPhase,
|
||||||
|
].join(':')),
|
||||||
state.hostileAdds.length,
|
state.hostileAdds.length,
|
||||||
state.hazards.length,
|
state.hazards.length,
|
||||||
...state.party.map((member) => [
|
...state.party.map((member) => [
|
||||||
@@ -334,6 +530,12 @@ function arenaHudSignature(state: Iwt2ArenaState): string {
|
|||||||
].join('|')
|
].join('|')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBossEncounterTitle(bosses: Iwt2ArenaState['bosses']): string {
|
||||||
|
return bosses
|
||||||
|
.map((boss) => IWT2_BOSS_METADATA[boss.bossId].name)
|
||||||
|
.join(' + ')
|
||||||
|
}
|
||||||
|
|
||||||
function nextTargetId(state: Iwt2ArenaState, currentId: Iwt2EntityId, direction: -1 | 1): Iwt2EntityId {
|
function nextTargetId(state: Iwt2ArenaState, currentId: Iwt2EntityId, direction: -1 | 1): Iwt2EntityId {
|
||||||
const living = state.party.filter((member) => member.health > 0)
|
const living = state.party.filter((member) => member.health > 0)
|
||||||
const targets = living.length > 0 ? living : state.party
|
const targets = living.length > 0 ? living : state.party
|
||||||
@@ -352,3 +554,250 @@ function tickCooldowns(cooldowns: Record<string, number>, dt: number) {
|
|||||||
}
|
}
|
||||||
return changed ? next : cooldowns
|
return changed ? next : cooldowns
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyRoguelikeModifiers(
|
||||||
|
abilities: Iwt2HealerAbility[],
|
||||||
|
buffs: Iwt2RoguelikeSelfBuffId[],
|
||||||
|
debuffs: Iwt2RoguelikeOpponentDebuffId[],
|
||||||
|
): Iwt2HealerAbility[] {
|
||||||
|
if (buffs.length === 0 && debuffs.length === 0) return abilities
|
||||||
|
return abilities.map((ability) => {
|
||||||
|
const slot = String(ability.slot)
|
||||||
|
const costDown = countStacks(buffs, `slot${slot}-cost-down`)
|
||||||
|
const costUp = countStacks(debuffs, `opp-slot${slot}-cost-up`)
|
||||||
|
const cooldownDown = countStacks(buffs, `slot${slot}-cooldown-down`)
|
||||||
|
const cooldownUp = countStacks(debuffs, `opp-slot${slot}-cooldown-up`)
|
||||||
|
const extraTargets = countStacks(buffs, `slot${slot}-extra-target`)
|
||||||
|
if (costDown === 0 && costUp === 0 && cooldownDown === 0 && cooldownUp === 0 && extraTargets === 0) return ability
|
||||||
|
return {
|
||||||
|
...ability,
|
||||||
|
cooldownSeconds: roundModifier(ability.cooldownSeconds * 0.75 ** cooldownDown * 1.25 ** cooldownUp),
|
||||||
|
extraTargets: (ability.extraTargets ?? 0) + extraTargets,
|
||||||
|
manaCost: Math.max(1, Math.ceil(ability.manaCost * 0.75 ** costDown * 1.25 ** costUp)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function countStacks(items: readonly string[], id: string) {
|
||||||
|
return items.filter((item) => item === id).length
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundModifier(value: number) {
|
||||||
|
return Math.max(0.1, Math.round(value * 100) / 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRoguelikeContentType(contentType: Iwt2RoguelikeContentType) {
|
||||||
|
if (contentType === 'raid') return 'Raid'
|
||||||
|
if (contentType === 'stadium') return 'Stadium'
|
||||||
|
return 'Dungeon'
|
||||||
|
}
|
||||||
|
|
||||||
|
function arenaPauseTitle(
|
||||||
|
roguelikeRun: BossArenaScreenProps['roguelikeRun'] | undefined,
|
||||||
|
fallbackTitle: string,
|
||||||
|
) {
|
||||||
|
if (!roguelikeRun) return fallbackTitle
|
||||||
|
if (roguelikeRun.contentType === 'stadium') return 'Stadium'
|
||||||
|
if (roguelikeRun.variant === 'pvp') {
|
||||||
|
return roguelikeRun.contentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'
|
||||||
|
}
|
||||||
|
return roguelikeRun.contentType === 'raid' ? 'Raid Roguelike' : 'Dungeon Roguelike'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIwt2DualScreenCombatState({
|
||||||
|
abilities,
|
||||||
|
arenaState,
|
||||||
|
bindings,
|
||||||
|
cooldowns,
|
||||||
|
controllerIconStyle,
|
||||||
|
directPartyTargeting,
|
||||||
|
modeLabel,
|
||||||
|
opponentArenaState,
|
||||||
|
playerMana,
|
||||||
|
roguelikeRun,
|
||||||
|
selectedPartyId,
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
abilities: Iwt2HealerAbility[]
|
||||||
|
arenaState: Iwt2ArenaState
|
||||||
|
bindings: DualScreenCombatState['bindings']
|
||||||
|
cooldowns: Record<string, number>
|
||||||
|
controllerIconStyle: DualScreenCombatState['controllerIconStyle']
|
||||||
|
directPartyTargeting: boolean
|
||||||
|
modeLabel: string
|
||||||
|
opponentArenaState: Iwt2ArenaState | null
|
||||||
|
playerMana: number
|
||||||
|
roguelikeRun: BossArenaScreenProps['roguelikeRun'] | undefined
|
||||||
|
selectedPartyId: Iwt2EntityId
|
||||||
|
status: ArenaStatus
|
||||||
|
}): DualScreenCombatState {
|
||||||
|
const opponentHealer = opponentArenaState?.party.find((member) => member.id === 'player-healer')
|
||||||
|
const arenaBossHealth = totalBossHealth(arenaState)
|
||||||
|
const arenaBossMaxHealth = totalBossMaxHealth(arenaState)
|
||||||
|
const pvpOpponentState = roguelikeRun?.variant === 'pvp' && opponentArenaState
|
||||||
|
? {
|
||||||
|
opponentBuffSummary: `Stage ${roguelikeRun.stage}`,
|
||||||
|
opponentClassName: `CPU Healer | ${formatRoguelikeContentType(roguelikeRun.contentType)}`,
|
||||||
|
opponentDebuffSummary: formatIwt2DebuffSummary(roguelikeRun.debuffs),
|
||||||
|
opponentArena: toDualScreenOpponentArena(opponentArenaState),
|
||||||
|
opponentEnemyHealth: totalBossHealth(opponentArenaState),
|
||||||
|
opponentMaxResource: opponentHealer?.maxMana ?? 100,
|
||||||
|
opponentName: 'CPU Rival',
|
||||||
|
opponentParty: opponentArenaState.party.map((member) => toDualScreenPartyMember(member, true)),
|
||||||
|
opponentResource: opponentHealer?.mana ?? 100,
|
||||||
|
opponentResourceName: 'Mana',
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
return {
|
||||||
|
...pvpOpponentState,
|
||||||
|
bindings,
|
||||||
|
contentName: modeLabel,
|
||||||
|
controllerIconStyle,
|
||||||
|
difficultyName: 'IWT2',
|
||||||
|
directPartyTargeting,
|
||||||
|
dungeonName: `${formatBossEncounterTitle(arenaState.bosses)} Arena`,
|
||||||
|
encounterCount: 1,
|
||||||
|
encounterDescription: arenaState.boss.attackPhase,
|
||||||
|
encounterHealth: arenaBossHealth,
|
||||||
|
encounterIndex: 0,
|
||||||
|
encounterIsBoss: true,
|
||||||
|
encounterMaxHealth: arenaBossMaxHealth,
|
||||||
|
encounterName: formatBossEncounterTitle(arenaState.bosses),
|
||||||
|
floatingTexts: [],
|
||||||
|
maxResource: 100,
|
||||||
|
party: arenaState.party.map((member) => toDualScreenPartyMember(member)),
|
||||||
|
partySize: arenaState.party.length,
|
||||||
|
paused: status === 'paused',
|
||||||
|
playerIsAlive: (arenaState.party.find((member) => member.id === 'player-healer')?.health ?? 0) > 0,
|
||||||
|
resource: playerMana,
|
||||||
|
resourceName: 'Mana',
|
||||||
|
selectedId: selectedPartyId,
|
||||||
|
speedMultiplier: 1,
|
||||||
|
spells: abilities.map((ability, slotIndex) => toDualScreenSpell(ability, slotIndex, cooldowns[ability.id] ?? 0)),
|
||||||
|
status: status === 'victory' ? 'won' : status === 'defeat' ? 'lost' : 'playing',
|
||||||
|
targetGroup: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function totalBossHealth(state: Iwt2ArenaState): number {
|
||||||
|
return state.bosses.reduce((total, boss) => total + Math.max(0, boss.health), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function totalBossMaxHealth(state: Iwt2ArenaState): number {
|
||||||
|
return state.bosses.reduce((total, boss) => total + boss.maxHealth, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDualScreenOpponentArena(state: Iwt2ArenaState): NonNullable<DualScreenCombatState['opponentArena']> {
|
||||||
|
return {
|
||||||
|
bounds: state.bounds,
|
||||||
|
bosses: state.bosses.map((boss) => {
|
||||||
|
const metadata = IWT2_BOSS_METADATA[boss.bossId]
|
||||||
|
return {
|
||||||
|
id: boss.id,
|
||||||
|
name: metadata.name,
|
||||||
|
icon: metadata.icon,
|
||||||
|
color: metadata.color,
|
||||||
|
x: boss.position.x,
|
||||||
|
y: boss.position.y,
|
||||||
|
radius: boss.radius,
|
||||||
|
health: boss.health,
|
||||||
|
maxHealth: boss.maxHealth,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
party: state.party.map((member) => {
|
||||||
|
const metadata = IWT2_CLASS_METADATA[member.classId]
|
||||||
|
return {
|
||||||
|
id: member.id,
|
||||||
|
icon: metadata.icon,
|
||||||
|
color: metadata.color,
|
||||||
|
x: member.position.x,
|
||||||
|
y: member.position.y,
|
||||||
|
radius: member.radius,
|
||||||
|
health: member.health,
|
||||||
|
maxHealth: member.maxHealth,
|
||||||
|
isHealer: member.id === 'player-healer',
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDualScreenPartyMember(member: Iwt2ArenaState['party'][number], opponent = false): PartyMember {
|
||||||
|
const metadata = IWT2_CLASS_METADATA[member.classId]
|
||||||
|
return {
|
||||||
|
bounceHeals: [],
|
||||||
|
health: member.health,
|
||||||
|
hotEffects: member.hotEffects.map((effect) => ({
|
||||||
|
id: effect.id,
|
||||||
|
label: effect.label,
|
||||||
|
power: effect.power,
|
||||||
|
spellId: effect.id,
|
||||||
|
ticks: Math.ceil(effect.remainingSeconds),
|
||||||
|
})),
|
||||||
|
hotTicks: member.hotEffects.length,
|
||||||
|
id: opponent ? `opponent-${member.id}` : member.id,
|
||||||
|
maxHealth: member.maxHealth,
|
||||||
|
name: opponent ? opponentPartyName(metadata.name) : metadata.name,
|
||||||
|
role: toDualScreenRole(metadata.role),
|
||||||
|
shield: member.shield,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function opponentPartyName(name: string): string {
|
||||||
|
if (name === 'Player Healer') return 'CPU Healer'
|
||||||
|
return `CPU ${name.replace(' Tank', '')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDualScreenRole(role: (typeof IWT2_CLASS_METADATA)[keyof typeof IWT2_CLASS_METADATA]['role']): Role {
|
||||||
|
if (role === 'tank') return 'Tank'
|
||||||
|
if (role === 'healer') return 'Healer'
|
||||||
|
return 'Damage'
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDualScreenSpell(
|
||||||
|
ability: Iwt2HealerAbility,
|
||||||
|
slotIndex: number,
|
||||||
|
remaining: number,
|
||||||
|
): Spell & { slotIndex: number; remaining: number } {
|
||||||
|
return {
|
||||||
|
cooldown: ability.cooldownSeconds,
|
||||||
|
cost: ability.manaCost,
|
||||||
|
description: 'IWT2 arena ability',
|
||||||
|
effectType: ability.effectType,
|
||||||
|
glyph: ability.icon,
|
||||||
|
id: ability.id,
|
||||||
|
key: String(ability.slot),
|
||||||
|
kind: ability.kind,
|
||||||
|
name: ability.name,
|
||||||
|
power: ability.power,
|
||||||
|
remaining,
|
||||||
|
slotIndex,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function iwt2OpponentMovement(state: Iwt2ArenaState): MovementVector {
|
||||||
|
const healer = state.party.find((member) => member.id === 'player-healer')
|
||||||
|
if (!healer || healer.health <= 0) return { x: 0, y: 0 }
|
||||||
|
const orbitSeconds = Math.max(1, state.time)
|
||||||
|
return normalizedArenaMovement({
|
||||||
|
x: Math.cos(orbitSeconds * 0.75),
|
||||||
|
y: Math.sin(orbitSeconds * 0.75) * 0.55,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedArenaMovement(vector: MovementVector): MovementVector {
|
||||||
|
const magnitude = Math.hypot(vector.x, vector.y)
|
||||||
|
if (magnitude <= 1) return vector
|
||||||
|
return {
|
||||||
|
x: vector.x / magnitude,
|
||||||
|
y: vector.y / magnitude,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIwt2DebuffSummary(debuffs: readonly string[]): string {
|
||||||
|
if (debuffs.length === 0) return 'none'
|
||||||
|
return debuffs
|
||||||
|
.map((debuff) => debuff
|
||||||
|
.replace('opp-', '')
|
||||||
|
.replaceAll('-', ' '))
|
||||||
|
.join(', ')
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+175
-62
@@ -2,8 +2,10 @@ import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
|
|||||||
import { IWT2_CLASS_METADATA } from '../content/classes'
|
import { IWT2_CLASS_METADATA } from '../content/classes'
|
||||||
import type {
|
import type {
|
||||||
Iwt2ArenaEvent,
|
Iwt2ArenaEvent,
|
||||||
|
Iwt2ArenaIndicator,
|
||||||
Iwt2ArenaInput,
|
Iwt2ArenaInput,
|
||||||
Iwt2ArenaState,
|
Iwt2ArenaState,
|
||||||
|
Iwt2BossEntityState,
|
||||||
Iwt2EntityId,
|
Iwt2EntityId,
|
||||||
Iwt2GroundHazardState,
|
Iwt2GroundHazardState,
|
||||||
Iwt2HostileAddState,
|
Iwt2HostileAddState,
|
||||||
@@ -33,6 +35,7 @@ const MAX_DT = 1 / 15
|
|||||||
const MAX_EVENTS = 80
|
const MAX_EVENTS = 80
|
||||||
const HEALER_MANA_REGEN_PER_SECOND = 3
|
const HEALER_MANA_REGEN_PER_SECOND = 3
|
||||||
const BOSS_PROJECTILE_BOUNCE_COOLDOWN_SECONDS = 0.22
|
const BOSS_PROJECTILE_BOUNCE_COOLDOWN_SECONDS = 0.22
|
||||||
|
const IWT2_ARENA_BOSS_IDS: Iwt2BossId[] = ['bulldrome', 'yian-kut-ku', 'great-jaggi', 'khezu']
|
||||||
|
|
||||||
type InitialPartyMember = {
|
type InitialPartyMember = {
|
||||||
id: Iwt2PartyEntityId
|
id: Iwt2PartyEntityId
|
||||||
@@ -53,8 +56,13 @@ const INITIAL_PARTY: InitialPartyMember[] = [
|
|||||||
{ id: 'warrior', classId: 'warrior', aiRole: 'melee', x: 515, y: 310, preferredOffset: { x: -18, y: 64 }, decisionOffset: 0.24 },
|
{ id: 'warrior', classId: 'warrior', aiRole: 'melee', x: 515, y: 310, preferredOffset: { x: -18, y: 64 }, decisionOffset: 0.24 },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function createInitialIwt2ArenaState(bossId: Iwt2BossId = 'bulldrome'): Iwt2ArenaState {
|
export function createInitialIwt2ArenaState(
|
||||||
const bossMetadata = IWT2_BOSS_METADATA[bossId]
|
bossId: Iwt2BossId = 'bulldrome',
|
||||||
|
bossIds?: Iwt2BossId[],
|
||||||
|
bossHealthScale = 1,
|
||||||
|
): Iwt2ArenaState {
|
||||||
|
const initialBossIds = bossIds?.length ? bossIds.slice(0, 2) : chooseInitialBossIds(bossId)
|
||||||
|
const bosses = initialBossIds.map((id, index) => createBossEntity(id, index, bossHealthScale))
|
||||||
return {
|
return {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
time: 0,
|
time: 0,
|
||||||
@@ -64,31 +72,8 @@ export function createInitialIwt2ArenaState(bossId: Iwt2BossId = 'bulldrome'): I
|
|||||||
hostileAdds: [],
|
hostileAdds: [],
|
||||||
hazards: [],
|
hazards: [],
|
||||||
indicators: [],
|
indicators: [],
|
||||||
boss: {
|
boss: bosses[0],
|
||||||
id: bossId,
|
bosses,
|
||||||
kind: 'boss',
|
|
||||||
bossId,
|
|
||||||
position: { x: 640, y: 250 },
|
|
||||||
velocity: { x: 0, y: 0 },
|
|
||||||
facing: { x: -1, y: 0 },
|
|
||||||
radius: bossMetadata.radius,
|
|
||||||
health: bossMetadata.maxHealth,
|
|
||||||
maxHealth: bossMetadata.maxHealth,
|
|
||||||
meleeCooldownRemaining: 0.6,
|
|
||||||
chargeCooldownRemaining: bossId === 'bulldrome' ? 2 : 0,
|
|
||||||
chargeCount: 0,
|
|
||||||
attackPhase: 'idle',
|
|
||||||
phaseSecondsRemaining: 0,
|
|
||||||
chargeStart: { x: 640, y: 250 },
|
|
||||||
chargeEnd: { x: 640, y: 250 },
|
|
||||||
chargeHitEntityIds: [],
|
|
||||||
slamApplied: false,
|
|
||||||
wallContactSeconds: 0,
|
|
||||||
relocateTarget: { x: DEFAULT_ARENA_WIDTH * 0.58, y: DEFAULT_ARENA_HEIGHT * 0.5 },
|
|
||||||
fireballCooldownRemaining: bossId === 'yian-kut-ku' ? 1.2 : 0,
|
|
||||||
fireballTarget: { x: 320, y: 250 },
|
|
||||||
birdWaveThresholdsTriggered: [],
|
|
||||||
},
|
|
||||||
nextEventId: 1,
|
nextEventId: 1,
|
||||||
nextProjectileId: 1,
|
nextProjectileId: 1,
|
||||||
nextAddId: 1,
|
nextAddId: 1,
|
||||||
@@ -97,6 +82,65 @@ export function createInitialIwt2ArenaState(bossId: Iwt2BossId = 'bulldrome'): I
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chooseInitialBossIds(primaryBossId: Iwt2BossId): Iwt2BossId[] {
|
||||||
|
const remaining = IWT2_ARENA_BOSS_IDS.filter((id) => id !== primaryBossId)
|
||||||
|
const random = remaining[Math.floor(Math.random() * remaining.length)] ?? primaryBossId
|
||||||
|
return [primaryBossId, random]
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBossEntity(bossId: Iwt2BossId, index: number, healthScale: number): Iwt2BossEntityState {
|
||||||
|
const bossMetadata = IWT2_BOSS_METADATA[bossId]
|
||||||
|
const position = initialBossPosition(index)
|
||||||
|
const maxHealth = Math.max(1, Math.round(bossMetadata.maxHealth * Math.max(0.01, healthScale)))
|
||||||
|
return {
|
||||||
|
id: bossId,
|
||||||
|
kind: 'boss',
|
||||||
|
bossId,
|
||||||
|
position,
|
||||||
|
velocity: { x: 0, y: 0 },
|
||||||
|
facing: { x: -1, y: 0 },
|
||||||
|
radius: bossMetadata.radius,
|
||||||
|
health: maxHealth,
|
||||||
|
maxHealth,
|
||||||
|
meleeCooldownRemaining: 0.6 + index * 0.25,
|
||||||
|
chargeCooldownRemaining: initialBossSpecialCooldown(bossId) + index * 0.7,
|
||||||
|
chargeCount: 0,
|
||||||
|
attackPhase: 'idle',
|
||||||
|
phaseSecondsRemaining: 0,
|
||||||
|
chargeStart: { ...position },
|
||||||
|
chargeEnd: { ...position },
|
||||||
|
chargeHitEntityIds: [],
|
||||||
|
slamApplied: false,
|
||||||
|
wallContactSeconds: 0,
|
||||||
|
relocateTarget: { x: DEFAULT_ARENA_WIDTH * 0.58, y: DEFAULT_ARENA_HEIGHT * 0.5 + (index === 0 ? -64 : 64) },
|
||||||
|
fireballCooldownRemaining: initialBossSecondaryCooldown(bossId) + index * 0.7,
|
||||||
|
fireballTarget: { x: 320, y: 250 },
|
||||||
|
birdWaveThresholdsTriggered: [],
|
||||||
|
mechanicLanes: [],
|
||||||
|
mechanicCircles: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialBossPosition(index: number) {
|
||||||
|
return {
|
||||||
|
x: index === 0 ? 660 : 760,
|
||||||
|
y: index === 0 ? 190 : 345,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialBossSpecialCooldown(bossId: Iwt2BossId): number {
|
||||||
|
if (bossId === 'bulldrome') return 2
|
||||||
|
if (bossId === 'great-jaggi') return 2.4
|
||||||
|
if (bossId === 'khezu') return 3
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialBossSecondaryCooldown(bossId: Iwt2BossId): number {
|
||||||
|
if (bossId === 'yian-kut-ku') return 1.2
|
||||||
|
if (bossId === 'khezu') return 1.6
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
|
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
|
||||||
const step = Math.max(0, Math.min(dt, MAX_DT))
|
const step = Math.max(0, Math.min(dt, MAX_DT))
|
||||||
if (step <= 0) return { ...state, events: [...state.events] }
|
if (step <= 0) return { ...state, events: [...state.events] }
|
||||||
@@ -113,13 +157,13 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
|
|||||||
}
|
}
|
||||||
const separatedState = {
|
const separatedState = {
|
||||||
...baseState,
|
...baseState,
|
||||||
party: separatePartyFromBoss(baseState.party, baseState),
|
party: separatePartyFromBosses(baseState.party, baseState),
|
||||||
}
|
}
|
||||||
const bossResult = tickBoss(separatedState, step)
|
const bossResult = tickBosses(separatedState, step)
|
||||||
const projectileResult = advanceProjectiles(
|
const projectileResult = advanceProjectiles(
|
||||||
[...separatedState.projectiles, ...(bossResult.projectiles ?? [])],
|
[...separatedState.projectiles, ...(bossResult.projectiles ?? [])],
|
||||||
bossResult.party,
|
bossResult.party,
|
||||||
bossResult.boss,
|
bossResult.bosses,
|
||||||
bossResult.hostileAdds ?? separatedState.hostileAdds,
|
bossResult.hostileAdds ?? separatedState.hostileAdds,
|
||||||
bossResult.hazards ?? separatedState.hazards,
|
bossResult.hazards ?? separatedState.hazards,
|
||||||
bossResult.nextHazardId ?? state.nextHazardId,
|
bossResult.nextHazardId ?? state.nextHazardId,
|
||||||
@@ -136,7 +180,7 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
|
|||||||
})
|
})
|
||||||
const damageResult = applyPartyAttacks(
|
const damageResult = applyPartyAttacks(
|
||||||
hazardResult.party,
|
hazardResult.party,
|
||||||
projectileResult.boss,
|
projectileResult.bosses,
|
||||||
projectileResult.hostileAdds,
|
projectileResult.hostileAdds,
|
||||||
separatedState.time,
|
separatedState.time,
|
||||||
projectileResult.nextProjectileId,
|
projectileResult.nextProjectileId,
|
||||||
@@ -149,7 +193,8 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
|
|||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
...separatedState,
|
...separatedState,
|
||||||
boss: damageResult.boss,
|
boss: getPrimaryBoss(damageResult.bosses),
|
||||||
|
bosses: damageResult.bosses,
|
||||||
indicators: [...bossResult.indicators, ...createHazardIndicators(hazardResult.hazards)],
|
indicators: [...bossResult.indicators, ...createHazardIndicators(hazardResult.hazards)],
|
||||||
party: finalParty,
|
party: finalParty,
|
||||||
hostileAdds: damageResult.hostileAdds,
|
hostileAdds: damageResult.hostileAdds,
|
||||||
@@ -259,14 +304,69 @@ function clampInputAxis(value: number): number {
|
|||||||
return Math.min(1, Math.max(-1, value))
|
return Math.min(1, Math.max(-1, value))
|
||||||
}
|
}
|
||||||
|
|
||||||
function separatePartyFromBoss(party: Iwt2PartyEntityState[], state: Iwt2ArenaState): Iwt2PartyEntityState[] {
|
function tickBosses(state: Iwt2ArenaState, dt: number) {
|
||||||
|
let nextParty = state.party
|
||||||
|
let nextHostileAdds = state.hostileAdds
|
||||||
|
let nextHazards = state.hazards
|
||||||
|
let nextProjectileId = state.nextProjectileId
|
||||||
|
let nextAddId = state.nextAddId
|
||||||
|
let nextHazardId = state.nextHazardId
|
||||||
|
const bosses: Iwt2BossEntityState[] = []
|
||||||
|
const projectiles: Iwt2ProjectileEntityState[] = []
|
||||||
|
const events: Iwt2ArenaEvent[] = []
|
||||||
|
const indicators: Iwt2ArenaIndicator[] = []
|
||||||
|
|
||||||
|
for (const boss of state.bosses) {
|
||||||
|
const bossState = {
|
||||||
|
...state,
|
||||||
|
boss,
|
||||||
|
party: nextParty,
|
||||||
|
hostileAdds: nextHostileAdds,
|
||||||
|
hazards: nextHazards,
|
||||||
|
nextProjectileId,
|
||||||
|
nextAddId,
|
||||||
|
nextHazardId,
|
||||||
|
}
|
||||||
|
const result = tickBoss(bossState, dt)
|
||||||
|
bosses.push(result.boss)
|
||||||
|
nextParty = result.party
|
||||||
|
nextHostileAdds = result.hostileAdds ?? nextHostileAdds
|
||||||
|
nextHazards = result.hazards ?? nextHazards
|
||||||
|
nextProjectileId = result.nextProjectileId ?? nextProjectileId
|
||||||
|
nextAddId = result.nextAddId ?? nextAddId
|
||||||
|
nextHazardId = result.nextHazardId ?? nextHazardId
|
||||||
|
projectiles.push(...(result.projectiles ?? []))
|
||||||
|
events.push(...result.events)
|
||||||
|
indicators.push(...result.indicators)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bosses,
|
||||||
|
boss: getPrimaryBoss(bosses),
|
||||||
|
party: nextParty,
|
||||||
|
hostileAdds: nextHostileAdds,
|
||||||
|
hazards: nextHazards,
|
||||||
|
projectiles,
|
||||||
|
events,
|
||||||
|
indicators,
|
||||||
|
nextAddId,
|
||||||
|
nextHazardId,
|
||||||
|
nextProjectileId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function separatePartyFromBosses(party: Iwt2PartyEntityState[], state: Iwt2ArenaState): Iwt2PartyEntityState[] {
|
||||||
return party.map((member) => {
|
return party.map((member) => {
|
||||||
if (member.health <= 0) return member
|
if (member.health <= 0) return member
|
||||||
const position = separateCircles(
|
let position = member.position
|
||||||
{ position: member.position, radius: member.radius },
|
for (const boss of state.bosses) {
|
||||||
{ position: state.boss.position, radius: state.boss.radius },
|
if (boss.health <= 0) continue
|
||||||
state.bounds,
|
position = separateCircles(
|
||||||
)
|
{ position, radius: member.radius },
|
||||||
|
{ position: boss.position, radius: boss.radius },
|
||||||
|
state.bounds,
|
||||||
|
)
|
||||||
|
}
|
||||||
return { ...member, position }
|
return { ...member, position }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -274,7 +374,7 @@ function separatePartyFromBoss(party: Iwt2PartyEntityState[], state: Iwt2ArenaSt
|
|||||||
function advanceProjectiles(
|
function advanceProjectiles(
|
||||||
projectiles: Iwt2ProjectileEntityState[],
|
projectiles: Iwt2ProjectileEntityState[],
|
||||||
party: Iwt2PartyEntityState[],
|
party: Iwt2PartyEntityState[],
|
||||||
boss: Iwt2ArenaState['boss'],
|
bosses: Iwt2BossEntityState[],
|
||||||
hostileAdds: Iwt2HostileAddState[],
|
hostileAdds: Iwt2HostileAddState[],
|
||||||
hazards: Iwt2GroundHazardState[],
|
hazards: Iwt2GroundHazardState[],
|
||||||
nextHazardId: number,
|
nextHazardId: number,
|
||||||
@@ -283,7 +383,7 @@ function advanceProjectiles(
|
|||||||
time: number,
|
time: number,
|
||||||
dt: number,
|
dt: number,
|
||||||
): {
|
): {
|
||||||
boss: Iwt2ArenaState['boss']
|
bosses: Iwt2BossEntityState[]
|
||||||
party: Iwt2PartyEntityState[]
|
party: Iwt2PartyEntityState[]
|
||||||
hostileAdds: Iwt2HostileAddState[]
|
hostileAdds: Iwt2HostileAddState[]
|
||||||
hazards: Iwt2GroundHazardState[]
|
hazards: Iwt2GroundHazardState[]
|
||||||
@@ -293,7 +393,7 @@ function advanceProjectiles(
|
|||||||
events: Iwt2ArenaEvent[]
|
events: Iwt2ArenaEvent[]
|
||||||
} {
|
} {
|
||||||
const events: Iwt2ArenaEvent[] = []
|
const events: Iwt2ArenaEvent[] = []
|
||||||
let nextBoss = boss
|
let nextBosses = bosses
|
||||||
let nextParty = party
|
let nextParty = party
|
||||||
let nextHostileAdds = hostileAdds
|
let nextHostileAdds = hostileAdds
|
||||||
let nextHazards = hazards
|
let nextHazards = hazards
|
||||||
@@ -319,7 +419,7 @@ function advanceProjectiles(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextBoss.health <= 0 && nextHostileAdds.every((add) => add.health <= 0)) continue
|
if (nextBosses.every((boss) => boss.health <= 0) && nextHostileAdds.every((add) => add.health <= 0)) continue
|
||||||
const nextPosition = {
|
const nextPosition = {
|
||||||
x: projectile.position.x + projectile.velocity.x * dt,
|
x: projectile.position.x + projectile.velocity.x * dt,
|
||||||
y: projectile.position.y + projectile.velocity.y * dt,
|
y: projectile.position.y + projectile.velocity.y * dt,
|
||||||
@@ -356,28 +456,31 @@ function advanceProjectiles(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (distanceVec2(nextPosition, nextBoss.position) <= nextBoss.radius + projectile.radius) {
|
const hitBoss = nextBosses.find((boss) => (
|
||||||
const damage = Math.min(projectile.damage, nextBoss.health)
|
boss.health > 0
|
||||||
nextBoss = {
|
&& distanceVec2(nextPosition, boss.position) <= boss.radius + projectile.radius
|
||||||
...nextBoss,
|
))
|
||||||
health: Math.max(0, nextBoss.health - damage),
|
if (hitBoss) {
|
||||||
}
|
const damage = Math.min(projectile.damage, hitBoss.health)
|
||||||
|
nextBosses = nextBosses.map((boss) => boss.id === hitBoss.id
|
||||||
|
? { ...boss, health: Math.max(0, boss.health - damage) }
|
||||||
|
: boss)
|
||||||
nextParty = addDamageDone(nextParty, projectile.sourceId, damage)
|
nextParty = addDamageDone(nextParty, projectile.sourceId, damage)
|
||||||
events.push({
|
events.push({
|
||||||
id: 0,
|
id: 0,
|
||||||
time,
|
time,
|
||||||
type: 'bossDamaged',
|
type: 'bossDamaged',
|
||||||
sourceId: projectile.sourceId,
|
sourceId: projectile.sourceId,
|
||||||
targetId: nextBoss.id,
|
targetId: hitBoss.id,
|
||||||
value: damage,
|
value: damage,
|
||||||
})
|
})
|
||||||
if (nextBoss.health <= 0) {
|
if (hitBoss.health - damage <= 0) {
|
||||||
events.push({
|
events.push({
|
||||||
id: 0,
|
id: 0,
|
||||||
time,
|
time,
|
||||||
type: 'entityDefeated',
|
type: 'entityDefeated',
|
||||||
sourceId: projectile.sourceId,
|
sourceId: projectile.sourceId,
|
||||||
targetId: nextBoss.id,
|
targetId: hitBoss.id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -390,7 +493,7 @@ function advanceProjectiles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
boss: nextBoss,
|
bosses: nextBosses,
|
||||||
party: nextParty,
|
party: nextParty,
|
||||||
hostileAdds: nextHostileAdds,
|
hostileAdds: nextHostileAdds,
|
||||||
hazards: nextHazards,
|
hazards: nextHazards,
|
||||||
@@ -521,12 +624,12 @@ function advanceBossProjectile({
|
|||||||
|
|
||||||
function applyPartyAttacks(
|
function applyPartyAttacks(
|
||||||
party: Iwt2PartyEntityState[],
|
party: Iwt2PartyEntityState[],
|
||||||
boss: Iwt2ArenaState['boss'],
|
bosses: Iwt2BossEntityState[],
|
||||||
hostileAdds: Iwt2HostileAddState[],
|
hostileAdds: Iwt2HostileAddState[],
|
||||||
time: number,
|
time: number,
|
||||||
nextProjectileId: number,
|
nextProjectileId: number,
|
||||||
): {
|
): {
|
||||||
boss: Iwt2ArenaState['boss']
|
bosses: Iwt2BossEntityState[]
|
||||||
party: Iwt2PartyEntityState[]
|
party: Iwt2PartyEntityState[]
|
||||||
hostileAdds: Iwt2HostileAddState[]
|
hostileAdds: Iwt2HostileAddState[]
|
||||||
projectiles: Iwt2ProjectileEntityState[]
|
projectiles: Iwt2ProjectileEntityState[]
|
||||||
@@ -535,13 +638,13 @@ function applyPartyAttacks(
|
|||||||
} {
|
} {
|
||||||
const events: Iwt2ArenaEvent[] = []
|
const events: Iwt2ArenaEvent[] = []
|
||||||
const projectiles: Iwt2ProjectileEntityState[] = []
|
const projectiles: Iwt2ProjectileEntityState[] = []
|
||||||
let nextBoss = boss
|
let nextBosses = bosses
|
||||||
let nextHostileAdds = hostileAdds
|
let nextHostileAdds = hostileAdds
|
||||||
let projectileId = nextProjectileId
|
let projectileId = nextProjectileId
|
||||||
const nextParty = party.map((member) => {
|
const nextParty = party.map((member) => {
|
||||||
const target = getPriorityAttackTarget(member, nextBoss, nextHostileAdds)
|
const target = getPriorityAttackTarget(member, nextBosses, nextHostileAdds)
|
||||||
if (!target) return member
|
if (!target) return member
|
||||||
if (!canPartyMemberHitTarget(member, target.position)) return member
|
if (!canPartyMemberHitTarget(member, target.position, target.radius)) return member
|
||||||
const metadata = IWT2_CLASS_METADATA[member.classId]
|
const metadata = IWT2_CLASS_METADATA[member.classId]
|
||||||
if (metadata.projectileSpeed > 0) {
|
if (metadata.projectileSpeed > 0) {
|
||||||
if (!member.attackReady) return member
|
if (!member.attackReady) return member
|
||||||
@@ -572,7 +675,9 @@ function applyPartyAttacks(
|
|||||||
if (member.attackCooldownRemaining > 0) return member
|
if (member.attackCooldownRemaining > 0) return member
|
||||||
const damage = Math.min(metadata.attackDamage, target.health)
|
const damage = Math.min(metadata.attackDamage, target.health)
|
||||||
if (target.kind === 'boss') {
|
if (target.kind === 'boss') {
|
||||||
nextBoss = { ...nextBoss, health: Math.max(0, nextBoss.health - damage) }
|
nextBosses = nextBosses.map((boss) => boss.id === target.id
|
||||||
|
? { ...boss, health: Math.max(0, boss.health - damage) }
|
||||||
|
: boss)
|
||||||
} else {
|
} else {
|
||||||
nextHostileAdds = nextHostileAdds.map((add) => add.id === target.id
|
nextHostileAdds = nextHostileAdds.map((add) => add.id === target.id
|
||||||
? { ...add, health: Math.max(0, add.health - damage) }
|
? { ...add, health: Math.max(0, add.health - damage) }
|
||||||
@@ -607,7 +712,7 @@ function applyPartyAttacks(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
boss: nextBoss,
|
bosses: nextBosses,
|
||||||
party: nextParty,
|
party: nextParty,
|
||||||
hostileAdds: nextHostileAdds.filter((add) => add.health > 0),
|
hostileAdds: nextHostileAdds.filter((add) => add.health > 0),
|
||||||
projectiles,
|
projectiles,
|
||||||
@@ -618,9 +723,9 @@ function applyPartyAttacks(
|
|||||||
|
|
||||||
function getPriorityAttackTarget(
|
function getPriorityAttackTarget(
|
||||||
member: Iwt2PartyEntityState,
|
member: Iwt2PartyEntityState,
|
||||||
boss: Iwt2ArenaState['boss'],
|
bosses: Iwt2BossEntityState[],
|
||||||
hostileAdds: Iwt2HostileAddState[],
|
hostileAdds: Iwt2HostileAddState[],
|
||||||
): (Iwt2ArenaState['boss'] | Iwt2HostileAddState) | undefined {
|
): (Iwt2BossEntityState | Iwt2HostileAddState) | undefined {
|
||||||
if (member.health <= 0) return undefined
|
if (member.health <= 0) return undefined
|
||||||
const livingAdds = hostileAdds.filter((add) => add.health > 0)
|
const livingAdds = hostileAdds.filter((add) => add.health > 0)
|
||||||
if (livingAdds.length > 0) {
|
if (livingAdds.length > 0) {
|
||||||
@@ -628,7 +733,15 @@ function getPriorityAttackTarget(
|
|||||||
distanceVec2(member.position, add.position) < distanceVec2(member.position, best.position) ? add : best
|
distanceVec2(member.position, add.position) < distanceVec2(member.position, best.position) ? add : best
|
||||||
), livingAdds[0])
|
), livingAdds[0])
|
||||||
}
|
}
|
||||||
return boss.health > 0 ? boss : undefined
|
const livingBosses = bosses.filter((boss) => boss.health > 0)
|
||||||
|
if (livingBosses.length === 0) return undefined
|
||||||
|
return livingBosses.reduce((best, boss) => (
|
||||||
|
distanceVec2(member.position, boss.position) < distanceVec2(member.position, best.position) ? boss : best
|
||||||
|
), livingBosses[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrimaryBoss(bosses: Iwt2BossEntityState[]): Iwt2BossEntityState {
|
||||||
|
return bosses.find((boss) => boss.health > 0) ?? bosses[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
function addDamageDone(
|
function addDamageDone(
|
||||||
|
|||||||
@@ -54,8 +54,12 @@ export type Iwt2ArenaState = Iwt2CoreArenaState & {
|
|||||||
telegraphs: Iwt2ArenaTelegraph[]
|
telegraphs: Iwt2ArenaTelegraph[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createInitialIwt2ArenaState(bossId?: Iwt2BossId): Iwt2ArenaState {
|
export function createInitialIwt2ArenaState(
|
||||||
return decorateArenaState(createCoreIwt2ArenaState(bossId))
|
bossId?: Iwt2BossId,
|
||||||
|
bossIds?: Iwt2BossId[],
|
||||||
|
bossHealthScale?: number,
|
||||||
|
): Iwt2ArenaState {
|
||||||
|
return decorateArenaState(createCoreIwt2ArenaState(bossId, bossIds, bossHealthScale))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
|
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
|
||||||
@@ -69,7 +73,7 @@ export function decorateArenaState(state: Iwt2CoreArenaState): Iwt2ArenaState {
|
|||||||
entities: [
|
entities: [
|
||||||
...state.party.map(toArenaEntity),
|
...state.party.map(toArenaEntity),
|
||||||
...state.hostileAdds.map(toHostileAddArenaEntity),
|
...state.hostileAdds.map(toHostileAddArenaEntity),
|
||||||
toBossArenaEntity(state.boss),
|
...state.bosses.map(toBossArenaEntity),
|
||||||
],
|
],
|
||||||
telegraphs: createTelegraphs(state.indicators),
|
telegraphs: createTelegraphs(state.indicators),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import type {
|
|||||||
Iwt2ProjectileEntityState,
|
Iwt2ProjectileEntityState,
|
||||||
Iwt2Vec2,
|
Iwt2Vec2,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
import { tickGreatJaggi } from './greatJaggiAi'
|
||||||
|
import { tickKhezu } from './khezuAi'
|
||||||
import { tickYianKutKu } from './yianKutKuAi'
|
import { tickYianKutKu } from './yianKutKuAi'
|
||||||
import {
|
import {
|
||||||
applyPartyDamageInShape,
|
applyPartyDamageInShape,
|
||||||
@@ -44,6 +46,8 @@ export type Iwt2BossTickResult = {
|
|||||||
|
|
||||||
export function tickBoss(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
|
export function tickBoss(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
|
||||||
if (state.boss.bossId === 'yian-kut-ku') return tickYianKutKu(state, dt)
|
if (state.boss.bossId === 'yian-kut-ku') return tickYianKutKu(state, dt)
|
||||||
|
if (state.boss.bossId === 'great-jaggi') return tickGreatJaggi(state, dt)
|
||||||
|
if (state.boss.bossId === 'khezu') return tickKhezu(state, dt)
|
||||||
return tickBulldrome(state, dt)
|
return tickBulldrome(state, dt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { GREAT_JAGGI_BOSS_METADATA } from '../content/bosses'
|
||||||
|
import type { Iwt2BossTickResult } from './bossAi'
|
||||||
|
import type {
|
||||||
|
Iwt2ArenaEvent,
|
||||||
|
Iwt2ArenaIndicator,
|
||||||
|
Iwt2ArenaState,
|
||||||
|
Iwt2BossEntityState,
|
||||||
|
Iwt2MechanicLaneState,
|
||||||
|
Iwt2PartyEntityState,
|
||||||
|
} from './types'
|
||||||
|
import {
|
||||||
|
applyPartyDamageInShape,
|
||||||
|
createLaneIndicator,
|
||||||
|
indicatorPhaseFromAttack,
|
||||||
|
} from './mechanics'
|
||||||
|
import {
|
||||||
|
clamp,
|
||||||
|
clampVec2ToArena,
|
||||||
|
distanceVec2,
|
||||||
|
moveToward,
|
||||||
|
scaleVec2,
|
||||||
|
subtractVec2,
|
||||||
|
withFallbackFacing,
|
||||||
|
} from './vector'
|
||||||
|
|
||||||
|
export function tickGreatJaggi(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
|
||||||
|
const events: Iwt2ArenaEvent[] = []
|
||||||
|
let party = state.party
|
||||||
|
let boss = {
|
||||||
|
...state.boss,
|
||||||
|
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
|
||||||
|
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
|
||||||
|
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
|
||||||
|
velocity: { x: 0, y: 0 },
|
||||||
|
}
|
||||||
|
const target = getBossTarget(party)
|
||||||
|
|
||||||
|
if (boss.health <= 0 || !target) return withGreatJaggiIndicators({ boss, party, events })
|
||||||
|
|
||||||
|
if (boss.attackPhase === 'packHowlWindup') {
|
||||||
|
boss = {
|
||||||
|
...boss,
|
||||||
|
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
|
||||||
|
}
|
||||||
|
if (boss.phaseSecondsRemaining <= 0) {
|
||||||
|
const result = applyPackLanes(party, boss, state.time + dt)
|
||||||
|
party = result.party
|
||||||
|
events.push(...result.events)
|
||||||
|
boss = {
|
||||||
|
...boss,
|
||||||
|
attackPhase: 'packHowlRecover',
|
||||||
|
phaseSecondsRemaining: 0.45,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return withGreatJaggiIndicators({ boss, party, events })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boss.attackPhase === 'packHowlRecover') {
|
||||||
|
if (boss.phaseSecondsRemaining <= 0) {
|
||||||
|
boss = {
|
||||||
|
...boss,
|
||||||
|
attackPhase: 'idle',
|
||||||
|
mechanicLanes: [],
|
||||||
|
phaseSecondsRemaining: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return withGreatJaggiIndicators({ boss, party, events })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boss.chargeCooldownRemaining <= 0) {
|
||||||
|
boss = {
|
||||||
|
...boss,
|
||||||
|
attackPhase: 'packHowlWindup',
|
||||||
|
chargeCooldownRemaining: GREAT_JAGGI_BOSS_METADATA.packHowlCooldown!,
|
||||||
|
mechanicLanes: createPackLanes(state, party),
|
||||||
|
phaseSecondsRemaining: GREAT_JAGGI_BOSS_METADATA.packHowlWindup!,
|
||||||
|
velocity: { x: 0, y: 0 },
|
||||||
|
}
|
||||||
|
return withGreatJaggiIndicators({ boss, party, events })
|
||||||
|
}
|
||||||
|
|
||||||
|
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
|
||||||
|
party = meleeResult.party
|
||||||
|
events.push(...meleeResult.events)
|
||||||
|
boss = meleeResult.boss
|
||||||
|
if (events.length > 0) return withGreatJaggiIndicators({ boss, party, events })
|
||||||
|
|
||||||
|
const nextPosition = clampVec2ToArena(
|
||||||
|
moveToward(boss.position, target.position, GREAT_JAGGI_BOSS_METADATA.moveSpeed * dt),
|
||||||
|
boss.radius,
|
||||||
|
state.bounds,
|
||||||
|
)
|
||||||
|
boss = {
|
||||||
|
...boss,
|
||||||
|
position: nextPosition,
|
||||||
|
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
|
||||||
|
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
|
||||||
|
}
|
||||||
|
return withGreatJaggiIndicators({ boss, party, events })
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPackLanes(state: Iwt2ArenaState, party: Iwt2PartyEntityState[]): Iwt2MechanicLaneState[] {
|
||||||
|
const living = party.filter((member) => member.health > 0)
|
||||||
|
const count = GREAT_JAGGI_BOSS_METADATA.packLaneCount!
|
||||||
|
const lanes: Iwt2MechanicLaneState[] = []
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const target = living[(index * 2) % Math.max(1, living.length)]
|
||||||
|
const baseY = target
|
||||||
|
? target.position.y
|
||||||
|
: state.bounds.height * ((index + 1) / (count + 1))
|
||||||
|
const slope = index % 2 === 0 ? 54 : -54
|
||||||
|
const y = clamp(baseY + (index - 1) * 28, 54, state.bounds.height - 54)
|
||||||
|
lanes.push({
|
||||||
|
id: `pack-lane-${index}`,
|
||||||
|
start: { x: -28, y: clamp(y - slope, 36, state.bounds.height - 36) },
|
||||||
|
end: { x: state.bounds.width + 28, y: clamp(y + slope, 36, state.bounds.height - 36) },
|
||||||
|
width: GREAT_JAGGI_BOSS_METADATA.packLaneWidth!,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return lanes
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPackLanes(
|
||||||
|
party: Iwt2PartyEntityState[],
|
||||||
|
boss: Iwt2BossEntityState,
|
||||||
|
time: number,
|
||||||
|
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
||||||
|
let nextParty = party
|
||||||
|
const events: Iwt2ArenaEvent[] = []
|
||||||
|
const hitEntityIds: string[] = []
|
||||||
|
for (const lane of boss.mechanicLanes) {
|
||||||
|
const result = applyPartyDamageInShape(nextParty, {
|
||||||
|
kind: 'lane',
|
||||||
|
start: lane.start,
|
||||||
|
end: lane.end,
|
||||||
|
width: lane.width,
|
||||||
|
}, {
|
||||||
|
damage: GREAT_JAGGI_BOSS_METADATA.packLaneDamage!,
|
||||||
|
excludedEntityIds: hitEntityIds,
|
||||||
|
knockdownSeconds: 0,
|
||||||
|
sourceId: boss.id,
|
||||||
|
stunSeconds: GREAT_JAGGI_BOSS_METADATA.packLaneStunSeconds!,
|
||||||
|
time,
|
||||||
|
})
|
||||||
|
nextParty = result.party
|
||||||
|
hitEntityIds.push(...result.hitEntityIds)
|
||||||
|
events.push(...result.events)
|
||||||
|
}
|
||||||
|
return { party: nextParty, events }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
|
||||||
|
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
|
||||||
|
if (livingTank) return livingTank
|
||||||
|
return party.find((member) => member.health > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeApplyMelee(
|
||||||
|
party: Iwt2PartyEntityState[],
|
||||||
|
boss: Iwt2BossEntityState,
|
||||||
|
target: Iwt2PartyEntityState,
|
||||||
|
time: number,
|
||||||
|
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
||||||
|
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
|
||||||
|
if (distanceVec2(boss.position, target.position) > GREAT_JAGGI_BOSS_METADATA.meleeRange + target.radius) {
|
||||||
|
return { boss, party, events: [] }
|
||||||
|
}
|
||||||
|
const result = applyPartyDamageInShape(party, {
|
||||||
|
kind: 'circle',
|
||||||
|
position: boss.position,
|
||||||
|
radius: GREAT_JAGGI_BOSS_METADATA.meleeRange,
|
||||||
|
}, {
|
||||||
|
damage: GREAT_JAGGI_BOSS_METADATA.meleeDamage,
|
||||||
|
sourceId: boss.id,
|
||||||
|
time,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
boss: { ...boss, meleeCooldownRemaining: GREAT_JAGGI_BOSS_METADATA.meleeCooldown },
|
||||||
|
party: result.party,
|
||||||
|
events: result.events,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withGreatJaggiIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
indicators: createGreatJaggiIndicators(result.boss),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGreatJaggiIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
|
||||||
|
if (
|
||||||
|
boss.attackPhase !== 'packHowlWindup'
|
||||||
|
&& boss.attackPhase !== 'packHowlRecover'
|
||||||
|
) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return boss.mechanicLanes.map((lane) => createLaneIndicator({
|
||||||
|
color: boss.attackPhase === 'packHowlRecover' ? '#f05b4f' : '#b8f0aa',
|
||||||
|
end: lane.end,
|
||||||
|
id: `${boss.id}:${lane.id}`,
|
||||||
|
mechanicId: 'great-jaggi-pack-lane',
|
||||||
|
phase: indicatorPhaseFromAttack(
|
||||||
|
boss.attackPhase === 'packHowlWindup',
|
||||||
|
boss.attackPhase === 'packHowlRecover',
|
||||||
|
),
|
||||||
|
sourceId: boss.id,
|
||||||
|
start: lane.start,
|
||||||
|
width: lane.width,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -63,14 +63,22 @@ function targetIdsForAbility(
|
|||||||
): Iwt2EntityId[] {
|
): Iwt2EntityId[] {
|
||||||
const living = party.filter((member) => member.health > 0)
|
const living = party.filter((member) => member.health > 0)
|
||||||
if (living.length === 0) return []
|
if (living.length === 0) return []
|
||||||
|
const extraTargets = Math.max(0, Math.floor(ability.extraTargets ?? 0))
|
||||||
if (ability.kind === 'group') {
|
if (ability.kind === 'group') {
|
||||||
return [...living]
|
return [...living]
|
||||||
.sort((a, b) => healthRatio(a) - healthRatio(b))
|
.sort((a, b) => healthRatio(a) - healthRatio(b))
|
||||||
.slice(0, 4)
|
.slice(0, 4 + extraTargets)
|
||||||
.map((member) => member.id)
|
.map((member) => member.id)
|
||||||
}
|
}
|
||||||
const selected = living.find((member) => member.id === selectedTargetId)
|
const selected = living.find((member) => member.id === selectedTargetId)
|
||||||
return [selected?.id ?? living[0].id]
|
const primaryId = selected?.id ?? living[0].id
|
||||||
|
if (extraTargets === 0) return [primaryId]
|
||||||
|
const additionalTargets = living
|
||||||
|
.filter((member) => member.id !== primaryId)
|
||||||
|
.sort((a, b) => healthRatio(a) - healthRatio(b))
|
||||||
|
.slice(0, extraTargets)
|
||||||
|
.map((member) => member.id)
|
||||||
|
return [primaryId, ...additionalTargets]
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyAbilityToMember(member: Iwt2PartyEntityState, ability: Iwt2HealerAbility): Iwt2PartyEntityState {
|
function applyAbilityToMember(member: Iwt2PartyEntityState, ability: Iwt2HealerAbility): Iwt2PartyEntityState {
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import { KHEZU_BOSS_METADATA } from '../content/bosses'
|
||||||
|
import type { Iwt2BossTickResult } from './bossAi'
|
||||||
|
import type {
|
||||||
|
Iwt2ArenaEvent,
|
||||||
|
Iwt2ArenaIndicator,
|
||||||
|
Iwt2ArenaState,
|
||||||
|
Iwt2BossEntityState,
|
||||||
|
Iwt2MechanicCircleState,
|
||||||
|
Iwt2PartyEntityState,
|
||||||
|
} from './types'
|
||||||
|
import {
|
||||||
|
applyPartyDamageInShape,
|
||||||
|
createCircleIndicator,
|
||||||
|
createDonutIndicator,
|
||||||
|
indicatorPhaseFromAttack,
|
||||||
|
} from './mechanics'
|
||||||
|
import {
|
||||||
|
clampVec2ToArena,
|
||||||
|
distanceVec2,
|
||||||
|
moveToward,
|
||||||
|
scaleVec2,
|
||||||
|
subtractVec2,
|
||||||
|
withFallbackFacing,
|
||||||
|
} from './vector'
|
||||||
|
|
||||||
|
export function tickKhezu(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
|
||||||
|
const events: Iwt2ArenaEvent[] = []
|
||||||
|
let party = state.party
|
||||||
|
let boss = {
|
||||||
|
...state.boss,
|
||||||
|
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
|
||||||
|
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
|
||||||
|
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
|
||||||
|
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
|
||||||
|
velocity: { x: 0, y: 0 },
|
||||||
|
}
|
||||||
|
const target = getBossTarget(party)
|
||||||
|
|
||||||
|
if (boss.health <= 0 || !target) return withKhezuIndicators({ boss, party, events })
|
||||||
|
|
||||||
|
if (boss.attackPhase === 'thunderRingWindup') {
|
||||||
|
boss = {
|
||||||
|
...boss,
|
||||||
|
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
|
||||||
|
}
|
||||||
|
if (boss.phaseSecondsRemaining <= 0) {
|
||||||
|
const result = applyThunderRing(party, boss, state.time + dt)
|
||||||
|
party = result.party
|
||||||
|
events.push(...result.events)
|
||||||
|
boss = {
|
||||||
|
...boss,
|
||||||
|
attackPhase: 'thunderRingRecover',
|
||||||
|
phaseSecondsRemaining: 0.45,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return withKhezuIndicators({ boss, party, events })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boss.attackPhase === 'lightningStrikeWindup') {
|
||||||
|
if (boss.phaseSecondsRemaining <= 0) {
|
||||||
|
const result = applyLightningStrikes(party, boss, state.time + dt)
|
||||||
|
party = result.party
|
||||||
|
events.push(...result.events)
|
||||||
|
boss = {
|
||||||
|
...boss,
|
||||||
|
attackPhase: 'lightningStrikeRecover',
|
||||||
|
phaseSecondsRemaining: 0.38,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return withKhezuIndicators({ boss, party, events })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boss.attackPhase === 'thunderRingRecover' || boss.attackPhase === 'lightningStrikeRecover') {
|
||||||
|
if (boss.phaseSecondsRemaining <= 0) {
|
||||||
|
boss = {
|
||||||
|
...boss,
|
||||||
|
attackPhase: 'idle',
|
||||||
|
mechanicCircles: [],
|
||||||
|
phaseSecondsRemaining: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return withKhezuIndicators({ boss, party, events })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boss.chargeCooldownRemaining <= 0) {
|
||||||
|
boss = {
|
||||||
|
...boss,
|
||||||
|
attackPhase: 'thunderRingWindup',
|
||||||
|
chargeCooldownRemaining: KHEZU_BOSS_METADATA.thunderRingCooldown!,
|
||||||
|
phaseSecondsRemaining: KHEZU_BOSS_METADATA.thunderRingWindup!,
|
||||||
|
velocity: { x: 0, y: 0 },
|
||||||
|
}
|
||||||
|
return withKhezuIndicators({ boss, party, events })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boss.fireballCooldownRemaining <= 0) {
|
||||||
|
boss = {
|
||||||
|
...boss,
|
||||||
|
attackPhase: 'lightningStrikeWindup',
|
||||||
|
fireballCooldownRemaining: KHEZU_BOSS_METADATA.lightningStrikeCooldown!,
|
||||||
|
mechanicCircles: createLightningTargets(party),
|
||||||
|
phaseSecondsRemaining: KHEZU_BOSS_METADATA.lightningStrikeWindup!,
|
||||||
|
velocity: { x: 0, y: 0 },
|
||||||
|
}
|
||||||
|
return withKhezuIndicators({ boss, party, events })
|
||||||
|
}
|
||||||
|
|
||||||
|
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
|
||||||
|
party = meleeResult.party
|
||||||
|
events.push(...meleeResult.events)
|
||||||
|
boss = meleeResult.boss
|
||||||
|
if (events.length > 0) return withKhezuIndicators({ boss, party, events })
|
||||||
|
|
||||||
|
const nextPosition = clampVec2ToArena(
|
||||||
|
moveToward(boss.position, target.position, KHEZU_BOSS_METADATA.moveSpeed * dt),
|
||||||
|
boss.radius,
|
||||||
|
state.bounds,
|
||||||
|
)
|
||||||
|
boss = {
|
||||||
|
...boss,
|
||||||
|
position: nextPosition,
|
||||||
|
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
|
||||||
|
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
|
||||||
|
}
|
||||||
|
return withKhezuIndicators({ boss, party, events })
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLightningTargets(party: Iwt2PartyEntityState[]): Iwt2MechanicCircleState[] {
|
||||||
|
return [...party]
|
||||||
|
.filter((member) => member.health > 0)
|
||||||
|
.sort((a, b) => (a.health / a.maxHealth) - (b.health / b.maxHealth))
|
||||||
|
.slice(0, KHEZU_BOSS_METADATA.lightningStrikeCount!)
|
||||||
|
.map((member, index) => ({
|
||||||
|
id: `lightning-${index}`,
|
||||||
|
position: { ...member.position },
|
||||||
|
radius: KHEZU_BOSS_METADATA.lightningStrikeRadius!,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyThunderRing(
|
||||||
|
party: Iwt2PartyEntityState[],
|
||||||
|
boss: Iwt2BossEntityState,
|
||||||
|
time: number,
|
||||||
|
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
||||||
|
const result = applyPartyDamageInShape(party, {
|
||||||
|
kind: 'donut',
|
||||||
|
position: boss.position,
|
||||||
|
innerRadius: KHEZU_BOSS_METADATA.thunderRingInnerRadius!,
|
||||||
|
outerRadius: KHEZU_BOSS_METADATA.thunderRingOuterRadius!,
|
||||||
|
}, {
|
||||||
|
damage: KHEZU_BOSS_METADATA.thunderRingDamage!,
|
||||||
|
knockdownSeconds: 0,
|
||||||
|
sourceId: boss.id,
|
||||||
|
stunSeconds: KHEZU_BOSS_METADATA.thunderRingStunSeconds!,
|
||||||
|
time,
|
||||||
|
})
|
||||||
|
return { party: result.party, events: result.events }
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLightningStrikes(
|
||||||
|
party: Iwt2PartyEntityState[],
|
||||||
|
boss: Iwt2BossEntityState,
|
||||||
|
time: number,
|
||||||
|
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
||||||
|
let nextParty = party
|
||||||
|
const events: Iwt2ArenaEvent[] = []
|
||||||
|
for (const circle of boss.mechanicCircles) {
|
||||||
|
const result = applyPartyDamageInShape(nextParty, {
|
||||||
|
kind: 'circle',
|
||||||
|
position: circle.position,
|
||||||
|
radius: circle.radius,
|
||||||
|
}, {
|
||||||
|
damage: KHEZU_BOSS_METADATA.lightningStrikeDamage!,
|
||||||
|
knockdownSeconds: 0,
|
||||||
|
sourceId: boss.id,
|
||||||
|
stunSeconds: KHEZU_BOSS_METADATA.lightningStrikeStunSeconds!,
|
||||||
|
time,
|
||||||
|
})
|
||||||
|
nextParty = result.party
|
||||||
|
events.push(...result.events)
|
||||||
|
}
|
||||||
|
return { party: nextParty, events }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
|
||||||
|
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
|
||||||
|
if (livingTank) return livingTank
|
||||||
|
return party.find((member) => member.health > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeApplyMelee(
|
||||||
|
party: Iwt2PartyEntityState[],
|
||||||
|
boss: Iwt2BossEntityState,
|
||||||
|
target: Iwt2PartyEntityState,
|
||||||
|
time: number,
|
||||||
|
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
||||||
|
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
|
||||||
|
if (distanceVec2(boss.position, target.position) > KHEZU_BOSS_METADATA.meleeRange + target.radius) {
|
||||||
|
return { boss, party, events: [] }
|
||||||
|
}
|
||||||
|
const result = applyPartyDamageInShape(party, {
|
||||||
|
kind: 'circle',
|
||||||
|
position: boss.position,
|
||||||
|
radius: KHEZU_BOSS_METADATA.meleeRange,
|
||||||
|
}, {
|
||||||
|
damage: KHEZU_BOSS_METADATA.meleeDamage,
|
||||||
|
sourceId: boss.id,
|
||||||
|
time,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
boss: { ...boss, meleeCooldownRemaining: KHEZU_BOSS_METADATA.meleeCooldown },
|
||||||
|
party: result.party,
|
||||||
|
events: result.events,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withKhezuIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
indicators: createKhezuIndicators(result.boss),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createKhezuIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
|
||||||
|
const indicators: Iwt2ArenaIndicator[] = []
|
||||||
|
if (boss.attackPhase === 'thunderRingWindup' || boss.attackPhase === 'thunderRingRecover') {
|
||||||
|
indicators.push(createDonutIndicator({
|
||||||
|
color: '#77d9ff',
|
||||||
|
id: `${boss.id}:thunder-ring`,
|
||||||
|
innerRadius: KHEZU_BOSS_METADATA.thunderRingInnerRadius!,
|
||||||
|
mechanicId: 'khezu-thunder-ring',
|
||||||
|
outerRadius: KHEZU_BOSS_METADATA.thunderRingOuterRadius!,
|
||||||
|
phase: indicatorPhaseFromAttack(
|
||||||
|
boss.attackPhase === 'thunderRingWindup',
|
||||||
|
boss.attackPhase === 'thunderRingRecover',
|
||||||
|
),
|
||||||
|
position: boss.position,
|
||||||
|
sourceId: boss.id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
if (boss.attackPhase === 'lightningStrikeWindup' || boss.attackPhase === 'lightningStrikeRecover') {
|
||||||
|
for (const circle of boss.mechanicCircles) {
|
||||||
|
indicators.push(createCircleIndicator({
|
||||||
|
color: '#9be8ff',
|
||||||
|
id: `${boss.id}:${circle.id}`,
|
||||||
|
mechanicId: 'khezu-lightning-strike',
|
||||||
|
phase: indicatorPhaseFromAttack(
|
||||||
|
boss.attackPhase === 'lightningStrikeWindup',
|
||||||
|
boss.attackPhase === 'lightningStrikeRecover',
|
||||||
|
),
|
||||||
|
position: circle.position,
|
||||||
|
radius: circle.radius,
|
||||||
|
sourceId: boss.id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return indicators
|
||||||
|
}
|
||||||
@@ -51,7 +51,14 @@ export type Iwt2CircleHitShape = {
|
|||||||
radius: number
|
radius: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Iwt2HitShape = Iwt2LaneHitShape | Iwt2CircleHitShape
|
export type Iwt2DonutHitShape = {
|
||||||
|
kind: 'donut'
|
||||||
|
position: Iwt2Vec2
|
||||||
|
innerRadius: number
|
||||||
|
outerRadius: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Iwt2HitShape = Iwt2LaneHitShape | Iwt2CircleHitShape | Iwt2DonutHitShape
|
||||||
|
|
||||||
export function createArenaEvent(
|
export function createArenaEvent(
|
||||||
id: number,
|
id: number,
|
||||||
@@ -155,7 +162,9 @@ export function partyMemberIntersectsShape(member: Iwt2PartyEntityState, shape:
|
|||||||
shape.width,
|
shape.width,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return distanceVec2(member.position, shape.position) <= shape.radius + member.radius
|
const distance = distanceVec2(member.position, shape.position)
|
||||||
|
if (shape.kind === 'circle') return distance <= shape.radius + member.radius
|
||||||
|
return distance <= shape.outerRadius + member.radius && distance >= Math.max(0, shape.innerRadius - member.radius)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createLaneIndicator({
|
export function createLaneIndicator({
|
||||||
|
|||||||
+117
-32
@@ -1,5 +1,5 @@
|
|||||||
import { IWT2_CLASS_METADATA } from '../content/classes'
|
import { IWT2_CLASS_METADATA } from '../content/classes'
|
||||||
import { BULLDROME_BOSS_METADATA } from '../content/bosses'
|
import { BULLDROME_BOSS_METADATA, IWT2_BOSS_METADATA } from '../content/bosses'
|
||||||
import type { Iwt2ArenaState, Iwt2HostileAddState, Iwt2PartyEntityState, Iwt2Vec2 } from './types'
|
import type { Iwt2ArenaState, Iwt2HostileAddState, Iwt2PartyEntityState, Iwt2Vec2 } from './types'
|
||||||
import {
|
import {
|
||||||
addVec2,
|
addVec2,
|
||||||
@@ -14,6 +14,13 @@ import {
|
|||||||
withFallbackFacing,
|
withFallbackFacing,
|
||||||
} from './vector'
|
} from './vector'
|
||||||
|
|
||||||
|
const NO_CENTER_LEASH_BOSS_IDS = new Set<string>()
|
||||||
|
const CENTER_LEASH_START_DISTANCE = 230
|
||||||
|
const CENTER_LEASH_WALL_MARGIN = 96
|
||||||
|
const CENTER_LEASH_EXTRA_DISTANCE = 36
|
||||||
|
const PARTY_ARRIVAL_RADIUS = 3.5
|
||||||
|
const PARTY_SAFE_DESTINATION_PADDING = 34
|
||||||
|
|
||||||
export function tickPartyMember(
|
export function tickPartyMember(
|
||||||
member: Iwt2PartyEntityState,
|
member: Iwt2PartyEntityState,
|
||||||
state: Iwt2ArenaState,
|
state: Iwt2ArenaState,
|
||||||
@@ -63,7 +70,7 @@ export function tickPartyMember(
|
|||||||
const canCast = member.aiRole === 'ranged'
|
const canCast = member.aiRole === 'ranged'
|
||||||
&& attackCooldownRemaining <= 0
|
&& attackCooldownRemaining <= 0
|
||||||
&& !!attackTarget
|
&& !!attackTarget
|
||||||
&& canPartyMemberHitTarget({ ...member, attackCooldownRemaining, castSecondsRemaining }, attackTarget.position)
|
&& canPartyMemberHitTarget({ ...member, attackCooldownRemaining, castSecondsRemaining }, attackTarget.position, attackTarget.radius)
|
||||||
if (!dangerDestination && member.aiRole === 'ranged' && (member.castSecondsRemaining > 0 || canCast)) {
|
if (!dangerDestination && member.aiRole === 'ranged' && (member.castSecondsRemaining > 0 || canCast)) {
|
||||||
const nextCastSecondsRemaining = member.castSecondsRemaining > 0
|
const nextCastSecondsRemaining = member.castSecondsRemaining > 0
|
||||||
? castSecondsRemaining
|
? castSecondsRemaining
|
||||||
@@ -71,7 +78,7 @@ export function tickPartyMember(
|
|||||||
return {
|
return {
|
||||||
...member,
|
...member,
|
||||||
velocity: { x: 0, y: 0 },
|
velocity: { x: 0, y: 0 },
|
||||||
facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? state.boss.position, member.position), member.facing),
|
facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? getPrimaryBoss(state).position, member.position), member.facing),
|
||||||
attackCooldownRemaining,
|
attackCooldownRemaining,
|
||||||
castSecondsRemaining: nextCastSecondsRemaining,
|
castSecondsRemaining: nextCastSecondsRemaining,
|
||||||
attackReady: member.castSecondsRemaining > 0 && nextCastSecondsRemaining <= 0,
|
attackReady: member.castSecondsRemaining > 0 && nextCastSecondsRemaining <= 0,
|
||||||
@@ -80,15 +87,21 @@ export function tickPartyMember(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const decisionSecondsRemaining = Math.max(0, member.decisionSecondsRemaining - dt)
|
const decisionSecondsRemaining = Math.max(0, member.decisionSecondsRemaining - dt)
|
||||||
const desired = dangerDestination ?? getPartyDesiredPosition(member, state, decisionSecondsRemaining)
|
const desired = dangerDestination ?? getPartyDesiredPosition(member, state)
|
||||||
const maxDistance = metadata.moveSpeed * dt
|
const maxDistance = metadata.moveSpeed * dt
|
||||||
const position = clampVec2ToArena(moveToward(member.position, desired, maxDistance), member.radius, state.bounds)
|
const distanceToDesired = distanceVec2(member.position, desired)
|
||||||
const velocity = scaleVec2(subtractVec2(position, member.position), dt > 0 ? 1 / dt : 0)
|
const movingToDesired = distanceToDesired > PARTY_ARRIVAL_RADIUS
|
||||||
|
const position = movingToDesired
|
||||||
|
? clampVec2ToArena(moveToward(member.position, desired, maxDistance), member.radius, state.bounds)
|
||||||
|
: member.position
|
||||||
|
const velocity = movingToDesired
|
||||||
|
? scaleVec2(subtractVec2(position, member.position), dt > 0 ? 1 / dt : 0)
|
||||||
|
: { x: 0, y: 0 }
|
||||||
return {
|
return {
|
||||||
...member,
|
...member,
|
||||||
position,
|
position,
|
||||||
velocity,
|
velocity,
|
||||||
facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? state.boss.position, position), member.facing),
|
facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? getPrimaryBoss(state).position, position), member.facing),
|
||||||
attackCooldownRemaining,
|
attackCooldownRemaining,
|
||||||
castSecondsRemaining: 0,
|
castSecondsRemaining: 0,
|
||||||
attackReady: false,
|
attackReady: false,
|
||||||
@@ -99,51 +112,116 @@ export function tickPartyMember(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canPartyMemberHitTarget(member: Iwt2PartyEntityState, targetPosition: Iwt2Vec2): boolean {
|
export function canPartyMemberHitTarget(member: Iwt2PartyEntityState, targetPosition: Iwt2Vec2, targetRadius = 0): boolean {
|
||||||
if (member.health <= 0) return false
|
if (member.health <= 0) return false
|
||||||
if (member.status.stunnedSeconds > 0 || member.status.knockedDownSeconds > 0) return false
|
if (member.status.stunnedSeconds > 0 || member.status.knockedDownSeconds > 0) return false
|
||||||
const metadata = IWT2_CLASS_METADATA[member.classId]
|
const metadata = IWT2_CLASS_METADATA[member.classId]
|
||||||
if (metadata.attackDamage <= 0) return false
|
if (metadata.attackDamage <= 0) return false
|
||||||
return distanceVec2(member.position, targetPosition) <= metadata.attackRange + member.radius
|
return distanceVec2(member.position, targetPosition) <= metadata.attackRange + member.radius + targetRadius
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPartyDesiredPosition(
|
function getPartyDesiredPosition(
|
||||||
member: Iwt2PartyEntityState,
|
member: Iwt2PartyEntityState,
|
||||||
state: Iwt2ArenaState,
|
state: Iwt2ArenaState,
|
||||||
decisionSecondsRemaining: number,
|
|
||||||
): Iwt2Vec2 {
|
): Iwt2Vec2 {
|
||||||
const drift = decisionSecondsRemaining <= 0 ? decisionDrift(member, state.time) : { x: 0, y: 0 }
|
const drift = decisionDrift(member, state.time)
|
||||||
const attackTarget = getPriorityAttackTarget(member, state)
|
const attackTarget = getPriorityAttackTarget(member, state)
|
||||||
const anchor = attackTarget?.position ?? state.boss.position
|
const anchor = attackTarget?.position ?? getPrimaryBoss(state).position
|
||||||
return {
|
const centerLeash = getTankCenterLeashPosition(member, state)
|
||||||
|
if (centerLeash) return centerLeash
|
||||||
|
return clampPartyDestination({
|
||||||
x: anchor.x + member.preferredOffset.x + drift.x,
|
x: anchor.x + member.preferredOffset.x + drift.x,
|
||||||
y: anchor.y + member.preferredOffset.y + drift.y,
|
y: anchor.y + member.preferredOffset.y + drift.y,
|
||||||
|
}, member, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampPartyDestination(
|
||||||
|
position: Iwt2Vec2,
|
||||||
|
member: Iwt2PartyEntityState,
|
||||||
|
state: Iwt2ArenaState,
|
||||||
|
): Iwt2Vec2 {
|
||||||
|
return clampVec2ToArena(position, member.radius + PARTY_SAFE_DESTINATION_PADDING, state.bounds)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTankCenterLeashPosition(
|
||||||
|
member: Iwt2PartyEntityState,
|
||||||
|
state: Iwt2ArenaState,
|
||||||
|
): Iwt2Vec2 | null {
|
||||||
|
if (member.aiRole !== 'tank') return null
|
||||||
|
const boss = getCenterLeashBoss(member, state)
|
||||||
|
if (!boss) return null
|
||||||
|
|
||||||
|
const center = arenaCenter(state)
|
||||||
|
const bossMetadata = IWT2_BOSS_METADATA[boss.bossId]
|
||||||
|
const towardCenter = withFallbackFacing(subtractVec2(center, boss.position), {
|
||||||
|
x: boss.position.x < center.x ? 1 : -1,
|
||||||
|
y: boss.position.y < center.y ? 0.35 : -0.35,
|
||||||
|
})
|
||||||
|
const leashDistance = bossMetadata.meleeRange + boss.radius + member.radius + CENTER_LEASH_EXTRA_DISTANCE
|
||||||
|
return clampVec2ToArena(
|
||||||
|
addVec2(boss.position, scaleVec2(towardCenter, leashDistance)),
|
||||||
|
member.radius,
|
||||||
|
state.bounds,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCenterLeashBoss(
|
||||||
|
member: Iwt2PartyEntityState,
|
||||||
|
state: Iwt2ArenaState,
|
||||||
|
): Iwt2ArenaState['boss'] | null {
|
||||||
|
const center = arenaCenter(state)
|
||||||
|
const candidates = state.bosses.filter((boss) => {
|
||||||
|
if (boss.health <= 0 || NO_CENTER_LEASH_BOSS_IDS.has(boss.bossId)) return false
|
||||||
|
return isBossNearWall(boss, state) || distanceVec2(boss.position, center) >= CENTER_LEASH_START_DISTANCE
|
||||||
|
})
|
||||||
|
if (candidates.length === 0) return null
|
||||||
|
return candidates.reduce((best, boss) => (
|
||||||
|
distanceVec2(member.position, boss.position) < distanceVec2(member.position, best.position) ? boss : best
|
||||||
|
), candidates[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
|
||||||
|
return {
|
||||||
|
x: state.bounds.width * 0.5,
|
||||||
|
y: state.bounds.height * 0.5,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBossNearWall(boss: Iwt2ArenaState['boss'], state: Iwt2ArenaState): boolean {
|
||||||
|
const margin = boss.radius + CENTER_LEASH_WALL_MARGIN
|
||||||
|
return (
|
||||||
|
boss.position.x <= margin
|
||||||
|
|| boss.position.x >= state.bounds.width - margin
|
||||||
|
|| boss.position.y <= margin
|
||||||
|
|| boss.position.y >= state.bounds.height - margin
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function getDangerAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null {
|
function getDangerAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null {
|
||||||
const boss = state.boss
|
|
||||||
const hazardEscape = getHazardAvoidancePosition(member, state)
|
const hazardEscape = getHazardAvoidancePosition(member, state)
|
||||||
if (hazardEscape) {
|
if (hazardEscape) {
|
||||||
return hazardEscape
|
return hazardEscape
|
||||||
}
|
}
|
||||||
|
|
||||||
if (boss.bossId === 'bulldrome' && (boss.attackPhase === 'slamWindup' || boss.attackPhase === 'slamRecover')) {
|
for (const boss of state.bosses) {
|
||||||
const distance = distanceVec2(member.position, boss.position)
|
if (boss.health <= 0) continue
|
||||||
const dangerRadius = BULLDROME_BOSS_METADATA.slamRadius + member.radius + 34
|
if (boss.bossId === 'bulldrome' && (boss.attackPhase === 'slamWindup' || boss.attackPhase === 'slamRecover')) {
|
||||||
if (distance < dangerRadius) {
|
const distance = distanceVec2(member.position, boss.position)
|
||||||
const away = normalizeVec2(subtractVec2(member.position, boss.position))
|
const dangerRadius = BULLDROME_BOSS_METADATA.slamRadius + member.radius + 34
|
||||||
return clampVec2ToArena(addVec2(member.position, scaleVec2(away, dangerRadius - distance + 40)), member.radius, state.bounds)
|
if (distance < dangerRadius) {
|
||||||
|
const away = normalizeVec2(subtractVec2(member.position, boss.position))
|
||||||
|
return clampVec2ToArena(addVec2(member.position, scaleVec2(away, dangerRadius - distance + 40)), member.radius, state.bounds)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (boss.bossId === 'bulldrome' && (boss.attackPhase === 'chargeWindup' || boss.attackPhase === 'charging')) {
|
if (boss.bossId === 'bulldrome' && (boss.attackPhase === 'chargeWindup' || boss.attackPhase === 'charging')) {
|
||||||
const danger = chargeDanger(member.position, state)
|
const danger = chargeDanger(member.position, boss)
|
||||||
if (danger.inside || danger.ahead) {
|
if (danger.inside || danger.ahead) {
|
||||||
const perpendicular = { x: -danger.direction.y, y: danger.direction.x }
|
const perpendicular = { x: -danger.direction.y, y: danger.direction.x }
|
||||||
const side = dotVec2(subtractVec2(member.position, boss.chargeStart), perpendicular) >= 0 ? 1 : -1
|
const side = dotVec2(subtractVec2(member.position, boss.chargeStart), perpendicular) >= 0 ? 1 : -1
|
||||||
const escape = addVec2(member.position, scaleVec2(perpendicular, side * (boss.radius * 2.8 + member.radius)))
|
const escape = addVec2(member.position, scaleVec2(perpendicular, side * (boss.radius * 2.8 + member.radius)))
|
||||||
return clampVec2ToArena(escape, member.radius, state.bounds)
|
return clampVec2ToArena(escape, member.radius, state.bounds)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +238,7 @@ function getHazardAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2Are
|
|||||||
const dangerRadius = hazard.radius + member.radius + 46
|
const dangerRadius = hazard.radius + member.radius + 46
|
||||||
if (distance >= dangerRadius) continue
|
if (distance >= dangerRadius) continue
|
||||||
|
|
||||||
const fallback = withFallbackFacing(subtractVec2(member.position, state.boss.position), {
|
const fallback = withFallbackFacing(subtractVec2(member.position, getPrimaryBoss(state).position), {
|
||||||
x: member.position.x < state.bounds.width * 0.5 ? -1 : 1,
|
x: member.position.x < state.bounds.width * 0.5 ? -1 : 1,
|
||||||
y: member.position.y < state.bounds.height * 0.5 ? -0.35 : 0.35,
|
y: member.position.y < state.bounds.height * 0.5 ? -0.35 : 0.35,
|
||||||
})
|
})
|
||||||
@@ -194,11 +272,14 @@ function getPriorityAttackTarget(
|
|||||||
distanceVec2(member.position, add.position) < distanceVec2(member.position, best.position) ? add : best
|
distanceVec2(member.position, add.position) < distanceVec2(member.position, best.position) ? add : best
|
||||||
), livingAdds[0])
|
), livingAdds[0])
|
||||||
}
|
}
|
||||||
return state.boss.health > 0 ? state.boss : undefined
|
const livingBosses = state.bosses.filter((boss) => boss.health > 0)
|
||||||
|
if (livingBosses.length === 0) return undefined
|
||||||
|
return livingBosses.reduce((best, boss) => (
|
||||||
|
distanceVec2(member.position, boss.position) < distanceVec2(member.position, best.position) ? boss : best
|
||||||
|
), livingBosses[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
function chargeDanger(position: Iwt2Vec2, state: Iwt2ArenaState) {
|
function chargeDanger(position: Iwt2Vec2, boss: Iwt2ArenaState['boss']) {
|
||||||
const boss = state.boss
|
|
||||||
const segment = subtractVec2(boss.chargeEnd, boss.chargeStart)
|
const segment = subtractVec2(boss.chargeEnd, boss.chargeStart)
|
||||||
const lengthSq = Math.max(1, lengthSqVec2(segment))
|
const lengthSq = Math.max(1, lengthSqVec2(segment))
|
||||||
const direction = normalizeVec2(segment)
|
const direction = normalizeVec2(segment)
|
||||||
@@ -214,6 +295,10 @@ function chargeDanger(position: Iwt2Vec2, state: Iwt2ArenaState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPrimaryBoss(state: Iwt2ArenaState) {
|
||||||
|
return state.bosses.find((boss) => boss.health > 0) ?? state.bosses[0] ?? state.boss
|
||||||
|
}
|
||||||
|
|
||||||
function decisionDrift(member: Iwt2PartyEntityState, time: number): Iwt2Vec2 {
|
function decisionDrift(member: Iwt2PartyEntityState, time: number): Iwt2Vec2 {
|
||||||
const seed = member.id.length * 17
|
const seed = member.id.length * 17
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -94,6 +94,12 @@ export type Iwt2BossAttackPhase =
|
|||||||
| 'fireballRecover'
|
| 'fireballRecover'
|
||||||
| 'birdSummonWindup'
|
| 'birdSummonWindup'
|
||||||
| 'birdSummonRecover'
|
| 'birdSummonRecover'
|
||||||
|
| 'packHowlWindup'
|
||||||
|
| 'packHowlRecover'
|
||||||
|
| 'thunderRingWindup'
|
||||||
|
| 'thunderRingRecover'
|
||||||
|
| 'lightningStrikeWindup'
|
||||||
|
| 'lightningStrikeRecover'
|
||||||
|
|
||||||
export type Iwt2HostileAddAttackPhase =
|
export type Iwt2HostileAddAttackPhase =
|
||||||
| 'idle'
|
| 'idle'
|
||||||
@@ -125,6 +131,21 @@ export type Iwt2BossEntityState = {
|
|||||||
fireballCooldownRemaining: number
|
fireballCooldownRemaining: number
|
||||||
fireballTarget: Iwt2Vec2
|
fireballTarget: Iwt2Vec2
|
||||||
birdWaveThresholdsTriggered: number[]
|
birdWaveThresholdsTriggered: number[]
|
||||||
|
mechanicLanes: Iwt2MechanicLaneState[]
|
||||||
|
mechanicCircles: Iwt2MechanicCircleState[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Iwt2MechanicLaneState = {
|
||||||
|
id: string
|
||||||
|
start: Iwt2Vec2
|
||||||
|
end: Iwt2Vec2
|
||||||
|
width: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Iwt2MechanicCircleState = {
|
||||||
|
id: string
|
||||||
|
position: Iwt2Vec2
|
||||||
|
radius: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Iwt2HostileAddState = {
|
export type Iwt2HostileAddState = {
|
||||||
@@ -235,6 +256,7 @@ export type Iwt2ArenaState = {
|
|||||||
hostileAdds: Iwt2HostileAddState[]
|
hostileAdds: Iwt2HostileAddState[]
|
||||||
hazards: Iwt2GroundHazardState[]
|
hazards: Iwt2GroundHazardState[]
|
||||||
boss: Iwt2BossEntityState
|
boss: Iwt2BossEntityState
|
||||||
|
bosses: Iwt2BossEntityState[]
|
||||||
indicators: Iwt2ArenaIndicator[]
|
indicators: Iwt2ArenaIndicator[]
|
||||||
nextEventId: number
|
nextEventId: number
|
||||||
nextProjectileId: number
|
nextProjectileId: number
|
||||||
|
|||||||
Reference in New Issue
Block a user