Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f633e5aaa | ||
|
|
e9ee7bcd70 | ||
|
|
90930b71aa | ||
|
|
4766a34cd3 | ||
|
|
8ffa6db317 | ||
|
|
1fa1c8c070 |
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 116
|
versionCode 122
|
||||||
versionName "1.1.35"
|
versionName "1.1.41"
|
||||||
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.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
+529
@@ -236,6 +236,14 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iwt2-arena-loading {
|
||||||
|
align-items: center;
|
||||||
|
color: var(--muted);
|
||||||
|
display: flex;
|
||||||
|
font-family: var(--pixel-font);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.iwt2-phaser-host canvas {
|
.iwt2-phaser-host canvas {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -1429,6 +1437,36 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1000px) and (max-height: 620px) {
|
@media (max-width: 1000px) and (max-height: 620px) {
|
||||||
|
.pause-screen.iwt2-arena-overlay {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pause-screen.iwt2-arena-overlay > .iwt2-result-panel {
|
||||||
|
gap: 8px;
|
||||||
|
max-height: calc(100dvh - 20px);
|
||||||
|
max-width: 460px;
|
||||||
|
min-width: min(460px, calc(100vw - 20px));
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-result-panel .iwt2-result-crest {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-result-panel h1 {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-result-summary {
|
||||||
|
gap: 6px;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-result-summary span {
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.iwt2-arena-layout {
|
.iwt2-arena-layout {
|
||||||
grid-template-columns: 154px minmax(0, 1fr);
|
grid-template-columns: 154px minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
@@ -1873,6 +1911,497 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* IWT2 startup save gateway */
|
||||||
|
.save-gateway-shell {
|
||||||
|
align-items: stretch;
|
||||||
|
height: 100dvh;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-gateway-panel {
|
||||||
|
background: rgba(17, 19, 25, 0.96);
|
||||||
|
border: 3px solid #08090c;
|
||||||
|
box-shadow: 8px 8px 0 #050609;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto auto;
|
||||||
|
margin: auto;
|
||||||
|
max-width: 1080px;
|
||||||
|
min-height: min(512px, calc(100dvh - 28px));
|
||||||
|
outline: 2px solid #4a4653;
|
||||||
|
padding: 16px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-gateway-heading {
|
||||||
|
align-items: end;
|
||||||
|
border-bottom: 2px solid #34343d;
|
||||||
|
display: flex;
|
||||||
|
gap: 22px;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 2px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-gateway-heading h1 {
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--pixel-font);
|
||||||
|
font-size: clamp(24px, 4vw, 38px);
|
||||||
|
line-height: 1;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-gateway-heading > p {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.25;
|
||||||
|
margin: 0;
|
||||||
|
max-width: 440px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-option-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-option-card {
|
||||||
|
background: #151921;
|
||||||
|
border: 2px solid #090a0d;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
outline: 2px solid #3a4250;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.local-save-card {
|
||||||
|
border-top-color: #5eaf7e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-save-card {
|
||||||
|
border-top-color: #d8af58;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-save-card {
|
||||||
|
border-top-color: #5799db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-option-heading {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-option-heading > span {
|
||||||
|
align-items: center;
|
||||||
|
background: #202938;
|
||||||
|
border: 2px solid #090a0d;
|
||||||
|
color: var(--gold);
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 42px;
|
||||||
|
font-family: var(--pixel-font);
|
||||||
|
height: 42px;
|
||||||
|
justify-content: center;
|
||||||
|
outline: 1px solid #4c5667;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-save-panel .save-option-heading > span {
|
||||||
|
color: #80bdf2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-option-heading h2 {
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--pixel-font);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.15;
|
||||||
|
margin: 3px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-character-name {
|
||||||
|
color: var(--ink);
|
||||||
|
display: block;
|
||||||
|
font-size: 19px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-metadata {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-metadata > div {
|
||||||
|
background: #101219;
|
||||||
|
border-left: 3px solid #4b5363;
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-metadata dt {
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: var(--pixel-font);
|
||||||
|
font-size: 7px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-metadata dd {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.15;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-empty-state {
|
||||||
|
align-items: center;
|
||||||
|
background: #101219;
|
||||||
|
border-left: 3px solid #4b5363;
|
||||||
|
color: var(--muted);
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.25;
|
||||||
|
margin: 0;
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-continue-button,
|
||||||
|
.save-secondary-button {
|
||||||
|
border: 2px solid #090a0d;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--pixel-font);
|
||||||
|
font-size: 8px;
|
||||||
|
line-height: 1.2;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-save-actions {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-save-actions .save-continue-button {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-create-form {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 9px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-create-form label,
|
||||||
|
.online-save-destination {
|
||||||
|
color: var(--muted);
|
||||||
|
display: grid;
|
||||||
|
font-family: var(--pixel-font);
|
||||||
|
font-size: 7px;
|
||||||
|
gap: 5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-create-form input,
|
||||||
|
.online-save-destination select {
|
||||||
|
background: #0e1016;
|
||||||
|
border: 2px solid #090a0d;
|
||||||
|
color: var(--ink);
|
||||||
|
font: 16px var(--body-font);
|
||||||
|
min-height: 38px;
|
||||||
|
outline: 2px solid #3e3d47;
|
||||||
|
padding: 7px 9px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-create-form > small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-save-panel {
|
||||||
|
align-items: center;
|
||||||
|
background: #131821;
|
||||||
|
border: 2px solid #090a0d;
|
||||||
|
border-top-color: #5799db;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: 130px minmax(260px, 1fr) 160px minmax(170px, 210px);
|
||||||
|
min-width: 0;
|
||||||
|
outline: 2px solid #3a4250;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-save-heading {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-save-summary {
|
||||||
|
align-items: center;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: minmax(75px, 0.7fr) minmax(0, 1.8fr);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-save-summary .save-character-name {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-save-summary .save-metadata {
|
||||||
|
gap: 5px;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-save-summary .save-metadata > div {
|
||||||
|
padding: 5px 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-save-summary .save-metadata dd {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-save-summary .save-empty-state {
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-continue-button {
|
||||||
|
background: var(--gold);
|
||||||
|
color: #19150e;
|
||||||
|
margin-top: auto;
|
||||||
|
outline: 2px solid #816630;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-continue-button.danger {
|
||||||
|
background: var(--red-bright);
|
||||||
|
color: #fff6ee;
|
||||||
|
outline-color: #8d2b38;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-continue-button:disabled,
|
||||||
|
.save-secondary-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
filter: grayscale(0.8);
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-secondary-button {
|
||||||
|
background: #242630;
|
||||||
|
color: var(--muted);
|
||||||
|
outline: 2px solid #4b4855;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-save-login {
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
grid-column: 2 / -1;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr)) minmax(180px, 0.75fr);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-save-login label {
|
||||||
|
color: var(--muted);
|
||||||
|
display: grid;
|
||||||
|
font-family: var(--pixel-font);
|
||||||
|
font-size: 7px;
|
||||||
|
gap: 5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-save-login input {
|
||||||
|
background: #0e1016;
|
||||||
|
border: 2px solid #090a0d;
|
||||||
|
color: var(--ink);
|
||||||
|
font: 16px var(--body-font);
|
||||||
|
min-height: 38px;
|
||||||
|
outline: 2px solid #3e3d47;
|
||||||
|
padding: 7px 9px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-save-actions {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-save-actions .save-continue-button {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-gateway-status {
|
||||||
|
color: #a9d7b8;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: 0;
|
||||||
|
min-height: 16px;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-gateway-status.error {
|
||||||
|
color: #ff8190;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.auth-shell.save-gateway-shell {
|
||||||
|
align-items: flex-start;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-gateway-panel {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-gateway-heading {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-gateway-heading > p {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-option-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-save-panel {
|
||||||
|
align-items: stretch;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-save-login {
|
||||||
|
grid-column: auto;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-save-summary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1000px) and (max-height: 620px) {
|
||||||
|
.save-gateway-shell {
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-gateway-panel {
|
||||||
|
gap: 9px;
|
||||||
|
min-height: calc(100dvh - 20px);
|
||||||
|
padding: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-gateway-heading {
|
||||||
|
padding-bottom: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-gateway-heading h1 {
|
||||||
|
font-size: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-gateway-heading > p {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-option-grid {
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-option-card {
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-option-heading > span {
|
||||||
|
flex-basis: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-option-heading h2 {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-character-name {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-metadata {
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-metadata > div {
|
||||||
|
padding: 5px 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-metadata dd {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-continue-button,
|
||||||
|
.save-secondary-button {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-save-actions {
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-create-form {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-create-form input,
|
||||||
|
.online-save-destination select {
|
||||||
|
font-size: 14px;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 5px 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-create-form > small {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-save-panel {
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: 115px minmax(230px, 1fr) 145px minmax(160px, 190px);
|
||||||
|
padding: 7px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-save-summary {
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-save-login {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-save-login input {
|
||||||
|
font-size: 14px;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 5px 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-gateway-status {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.combat-touch-lock-status {
|
.combat-touch-lock-status {
|
||||||
background: #101216;
|
background: #101216;
|
||||||
border: 2px solid #d9b55a;
|
border: 2px solid #d9b55a;
|
||||||
|
|||||||
+27
-141
@@ -1,156 +1,42 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { lazy, Suspense, useState } from 'react'
|
||||||
import { AuthScreen } from './components/AuthScreen'
|
import { AuthScreen } from './components/AuthScreen'
|
||||||
import IWantToHeal1App from './modes/iwt1/IWantToHeal1App'
|
import type { Iwt2LocalSaveSlot, Iwt2Save } from './modes/iwt2/save/iwt2Repository'
|
||||||
import { IWantToHeal2App } from './modes/iwt2/IWantToHeal2App'
|
|
||||||
import { useGameAction } from './input'
|
|
||||||
import {
|
|
||||||
loadAuthSession,
|
|
||||||
type AuthSession,
|
|
||||||
} from './profile'
|
|
||||||
|
|
||||||
type GameVersion = 'iwt1' | 'iwt2'
|
const LazyIWantToHeal2App = lazy(() => import('./modes/iwt2/IWantToHeal2App').then((module) => ({
|
||||||
|
default: module.IWantToHeal2App,
|
||||||
|
})))
|
||||||
|
|
||||||
const GAME_OPTIONS: Array<{
|
type Iwt2Launch = {
|
||||||
version: GameVersion
|
localSlot: Iwt2LocalSaveSlot
|
||||||
title: string
|
onlineBackupsAvailable: boolean
|
||||||
label: string
|
save: Iwt2Save
|
||||||
description: string
|
}
|
||||||
glyph: string
|
|
||||||
}> = [
|
|
||||||
{
|
|
||||||
version: 'iwt1',
|
|
||||||
title: 'I Want To Heal 1',
|
|
||||||
label: 'Classic Healer Runs',
|
|
||||||
description: 'Original dungeon, raid, roguelike, PvP, gear, talents, and collection progression.',
|
|
||||||
glyph: 'I',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
version: 'iwt2',
|
|
||||||
title: 'I Want To Heal 2',
|
|
||||||
label: '2D Boss Arena',
|
|
||||||
description: 'Move through boss arenas with a visible party, analog movement, projectiles, and separate progression.',
|
|
||||||
glyph: 'II',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [selectedVersion, setSelectedVersion] = useState<GameVersion | null>(null)
|
const [launch, setLaunch] = useState<Iwt2Launch | null>(null)
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
||||||
const [authSession, setAuthSession] = useState<AuthSession | null>(null)
|
|
||||||
const [authChecked, setAuthChecked] = useState(false)
|
|
||||||
const [serverMessage, setServerMessage] = useState('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (launch) {
|
||||||
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) => {
|
|
||||||
if (!authSession || selectedVersion || device !== 'controller') return
|
|
||||||
if (action === 'navigateLeft' || action === 'navigateUp') {
|
|
||||||
setSelectedIndex((current) => Math.max(0, current - 1))
|
|
||||||
} else if (action === 'navigateRight' || action === 'navigateDown') {
|
|
||||||
setSelectedIndex((current) => Math.min(GAME_OPTIONS.length - 1, current + 1))
|
|
||||||
} else if (action === 'confirm') {
|
|
||||||
setSelectedVersion(GAME_OPTIONS[selectedIndex].version)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!authChecked) {
|
|
||||||
return (
|
return (
|
||||||
<main className="game-shell">
|
<Suspense fallback={<Iwt2LaunchFallback />}>
|
||||||
<section className="message-panel">
|
<LazyIWantToHeal2App
|
||||||
<p className="eyebrow">Opening Chronicle</p>
|
initialSave={launch.save}
|
||||||
<h1>Loading...</h1>
|
localSlot={launch.localSlot}
|
||||||
</section>
|
onlineBackupsAvailable={launch.onlineBackupsAvailable}
|
||||||
</main>
|
onExitToSaveSelect={() => setLaunch(null)}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!authSession) {
|
return <AuthScreen onContinue={setLaunch} />
|
||||||
return (
|
}
|
||||||
<AuthScreen
|
|
||||||
onAuthenticated={acceptSession}
|
|
||||||
serverMessage={serverMessage}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedVersion === 'iwt1') {
|
|
||||||
return (
|
|
||||||
<IWantToHeal1App
|
|
||||||
initialSession={authSession}
|
|
||||||
onAuthenticationCleared={clearAuthSession}
|
|
||||||
onBackToGameSelect={() => setSelectedVersion(null)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedVersion === 'iwt2') {
|
|
||||||
return (
|
|
||||||
<IWantToHeal2App
|
|
||||||
onlineBackupsAvailable={authSession.account?.id !== -1}
|
|
||||||
onBackToGameSelect={() => setSelectedVersion(null)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
function Iwt2LaunchFallback() {
|
||||||
return (
|
return (
|
||||||
<main className="game-shell game-version-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<section className="game-version-screen" data-game-nav-active="true">
|
<section className="message-panel" aria-live="polite">
|
||||||
<div className="game-version-heading">
|
<p className="eyebrow">I Want To Heal 2</p>
|
||||||
<p className="eyebrow">Select Game</p>
|
<h1>Loading...</h1>
|
||||||
<h1>I Want To Heal</h1>
|
|
||||||
</div>
|
|
||||||
<div className="game-version-grid">
|
|
||||||
{GAME_OPTIONS.map((option, index) => (
|
|
||||||
<button
|
|
||||||
className={`game-version-card ${selectedIndex === index ? 'game-selected' : ''}`}
|
|
||||||
data-controller-nav="skip"
|
|
||||||
data-game-selected={selectedIndex === index ? 'true' : undefined}
|
|
||||||
key={option.version}
|
|
||||||
onClick={() => setSelectedVersion(option.version)}
|
|
||||||
onPointerDown={() => setSelectedIndex(index)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span>{option.glyph}</span>
|
|
||||||
<div>
|
|
||||||
<strong>{option.title}</strong>
|
|
||||||
<small>{option.label}</small>
|
|
||||||
<p>{option.description}</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
+381
-153
@@ -1,192 +1,420 @@
|
|||||||
import { useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
|
loadAuthSession,
|
||||||
loginAccount,
|
loginAccount,
|
||||||
registerAccount,
|
logoutAccount,
|
||||||
type AuthSession,
|
type Account,
|
||||||
} from '../profile'
|
} from '../profile'
|
||||||
|
import { selectOfflineMode, selectOnlineMode } from '../gameRepository'
|
||||||
import {
|
import {
|
||||||
createOfflineCharacter,
|
createDefaultIwt2Save,
|
||||||
hasOfflineCharacter,
|
IWT2_LOCAL_SAVE_SLOTS,
|
||||||
resumeOfflineCharacter,
|
loadIwt2OnlineSave,
|
||||||
selectOnlineMode,
|
loadIwt2SaveSlots,
|
||||||
} from '../gameRepository'
|
replaceIwt2Save,
|
||||||
|
selectActiveIwt2SaveSlot,
|
||||||
|
type Iwt2LocalSaveSlot,
|
||||||
|
type Iwt2LocalSaveSlots,
|
||||||
|
type Iwt2Save,
|
||||||
|
} from '../modes/iwt2/save/iwt2Repository'
|
||||||
|
|
||||||
type Props = {
|
type Iwt2Launch = {
|
||||||
onAuthenticated: (session: AuthSession) => void
|
localSlot: Iwt2LocalSaveSlot
|
||||||
serverMessage?: string
|
onlineBackupsAvailable: boolean
|
||||||
|
save: Iwt2Save
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthScreen({ onAuthenticated, serverMessage = '' }: Props) {
|
type Props = {
|
||||||
const [mode, setMode] = useState<'login' | 'register'>('login')
|
onContinue: (launch: Iwt2Launch) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const IWT2_CHARACTER_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9 -]{1,17}$/
|
||||||
|
|
||||||
|
function formatSaveTimestamp(updatedAt: number) {
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(new Date(updatedAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
function SaveMetadata({ save }: { save: Iwt2Save }) {
|
||||||
|
return (
|
||||||
|
<dl className="save-metadata">
|
||||||
|
<div>
|
||||||
|
<dt>Last updated</dt>
|
||||||
|
<dd>
|
||||||
|
<time dateTime={new Date(save.updatedAt).toISOString()}>
|
||||||
|
{formatSaveTimestamp(save.updatedAt)}
|
||||||
|
</time>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Character XP</dt>
|
||||||
|
<dd>{save.character.experience.toLocaleString()} XP</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstEmptySlot(saves: Iwt2LocalSaveSlots): Iwt2LocalSaveSlot {
|
||||||
|
return IWT2_LOCAL_SAVE_SLOTS.find((slot) => !saves[slot]) ?? 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthScreen({ onContinue }: Props) {
|
||||||
|
const [localSaves] = useState(loadIwt2SaveSlots)
|
||||||
|
const [editingSlot, setEditingSlot] = useState<Iwt2LocalSaveSlot | null>(null)
|
||||||
|
const [characterName, setCharacterName] = useState('')
|
||||||
|
const [account, setAccount] = useState<Account | null>(null)
|
||||||
|
const [onlineSave, setOnlineSave] = useState<Iwt2Save | null>(null)
|
||||||
|
const [onlineDestinationSlot, setOnlineDestinationSlot] = useState<Iwt2LocalSaveSlot>(
|
||||||
|
() => firstEmptySlot(localSaves),
|
||||||
|
)
|
||||||
|
const [confirmOnlineDestination, setConfirmOnlineDestination] = useState<Iwt2LocalSaveSlot | null>(null)
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [characterName, setCharacterName] = useState('')
|
const [checkingSession, setCheckingSession] = useState(true)
|
||||||
const [offlineName, setOfflineName] = useState('')
|
const [checkingOnlineSave, setCheckingOnlineSave] = useState(false)
|
||||||
const [busy, setBusy] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('Choose a slot or sign in to inspect the online save.')
|
||||||
const offlineCharacterExists = hasOfflineCharacter()
|
const [messageIsError, setMessageIsError] = useState(false)
|
||||||
|
|
||||||
async function submit(event: React.FormEvent) {
|
const inspectOnlineSave = useCallback(async (cancelled: () => boolean = () => false) => {
|
||||||
|
setCheckingOnlineSave(true)
|
||||||
|
try {
|
||||||
|
const result = await loadIwt2OnlineSave()
|
||||||
|
if (cancelled()) return
|
||||||
|
setOnlineSave(result.save)
|
||||||
|
setMessage(result.save
|
||||||
|
? 'Online save ready. Load it below, or continue a local slot and upload it from Cloud Backup.'
|
||||||
|
: 'No online save yet. Continue a local slot, then use Cloud Backup to upload it.')
|
||||||
|
setMessageIsError(false)
|
||||||
|
} catch (reason) {
|
||||||
|
if (cancelled()) return
|
||||||
|
setOnlineSave(null)
|
||||||
|
setMessage(reason instanceof Error ? reason.message : 'Unable to check online save.')
|
||||||
|
setMessageIsError(true)
|
||||||
|
} finally {
|
||||||
|
if (!cancelled()) setCheckingOnlineSave(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
selectOnlineMode()
|
||||||
|
loadAuthSession()
|
||||||
|
.then((session) => {
|
||||||
|
if (cancelled || !session.account) return
|
||||||
|
setAccount(session.account)
|
||||||
|
return inspectOnlineSave(() => cancelled)
|
||||||
|
})
|
||||||
|
.catch((reason: unknown) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setMessage(
|
||||||
|
reason instanceof Error
|
||||||
|
? `${reason.message} Local slots remain available.`
|
||||||
|
: 'Online service unavailable. Local slots remain available.',
|
||||||
|
)
|
||||||
|
setMessageIsError(true)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setCheckingSession(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [inspectOnlineSave])
|
||||||
|
|
||||||
|
function continueLocal(slot: Iwt2LocalSaveSlot) {
|
||||||
|
const save = localSaves[slot]
|
||||||
|
if (!save) return
|
||||||
|
selectActiveIwt2SaveSlot(slot)
|
||||||
|
selectOfflineMode()
|
||||||
|
onContinue({ localSlot: slot, save, onlineBackupsAvailable: Boolean(account) })
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCharacterEditor(slot: Iwt2LocalSaveSlot) {
|
||||||
|
setEditingSlot(slot)
|
||||||
|
setCharacterName('')
|
||||||
|
setMessage(localSaves[slot]
|
||||||
|
? `Creating a new character will replace Slot ${slot}.`
|
||||||
|
: `Enter a name for the new character in Slot ${slot}.`)
|
||||||
|
setMessageIsError(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCharacter(event: React.FormEvent, slot: Iwt2LocalSaveSlot) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setBusy(true)
|
const name = characterName.trim()
|
||||||
setMessage('')
|
if (!IWT2_CHARACTER_NAME_PATTERN.test(name)) {
|
||||||
|
setMessage('Character name must be 2–18 characters, start with a letter, and use letters, numbers, spaces, or hyphens.')
|
||||||
|
setMessageIsError(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const save = createDefaultIwt2Save(name)
|
||||||
|
replaceIwt2Save(save, slot)
|
||||||
|
selectActiveIwt2SaveSlot(slot)
|
||||||
|
selectOfflineMode()
|
||||||
|
onContinue({ localSlot: slot, save, onlineBackupsAvailable: Boolean(account) })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signIn(event: React.FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
setSubmitting(true)
|
||||||
|
setMessage('Signing in and checking online save...')
|
||||||
|
setMessageIsError(false)
|
||||||
try {
|
try {
|
||||||
selectOnlineMode()
|
selectOnlineMode()
|
||||||
const session = mode === 'login'
|
const session = await loginAccount(username, password)
|
||||||
? await loginAccount(username, password)
|
if (!session.account) throw new Error('Account session was not returned.')
|
||||||
: await registerAccount(username, password, characterName)
|
setAccount(session.account)
|
||||||
onAuthenticated(session)
|
await inspectOnlineSave()
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setMessage(reason instanceof Error ? reason.message : 'Unable to authenticate.')
|
setAccount(null)
|
||||||
|
setOnlineSave(null)
|
||||||
|
setMessage(reason instanceof Error ? reason.message : 'Unable to sign in.')
|
||||||
|
setMessageIsError(true)
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function beginOffline() {
|
function continueOnline() {
|
||||||
setMessage('')
|
if (!account || !onlineSave) return
|
||||||
|
const destinationSave = localSaves[onlineDestinationSlot]
|
||||||
|
if (destinationSave && confirmOnlineDestination !== onlineDestinationSlot) {
|
||||||
|
setConfirmOnlineDestination(onlineDestinationSlot)
|
||||||
|
setMessage(`Online save will replace ${destinationSave.character.name} in Slot ${onlineDestinationSlot}. Select again to confirm.`)
|
||||||
|
setMessageIsError(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
replaceIwt2Save(onlineSave, onlineDestinationSlot)
|
||||||
|
selectActiveIwt2SaveSlot(onlineDestinationSlot)
|
||||||
|
selectOnlineMode()
|
||||||
|
onContinue({
|
||||||
|
localSlot: onlineDestinationSlot,
|
||||||
|
onlineBackupsAvailable: true,
|
||||||
|
save: onlineSave,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDifferentAccount() {
|
||||||
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
onAuthenticated(createOfflineCharacter(offlineName))
|
await logoutAccount()
|
||||||
|
setAccount(null)
|
||||||
|
setOnlineSave(null)
|
||||||
|
setPassword('')
|
||||||
|
setMessage('Signed out. Enter account credentials to load another online save.')
|
||||||
|
setMessageIsError(false)
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setMessage(reason instanceof Error ? reason.message : 'Unable to create an offline character.')
|
setMessage(reason instanceof Error ? reason.message : 'Unable to sign out.')
|
||||||
|
setMessageIsError(true)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resumeOffline() {
|
const onlineBusy = checkingSession || checkingOnlineSave || submitting
|
||||||
const session = resumeOfflineCharacter()
|
|
||||||
if (session) onAuthenticated(session)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="auth-shell">
|
<main className="auth-shell save-gateway-shell">
|
||||||
<section className="auth-panel">
|
<section className="save-gateway-panel">
|
||||||
<div className="auth-brand">
|
<header className="save-gateway-heading">
|
||||||
<p className="eyebrow">Healer RPG</p>
|
<div>
|
||||||
<h1>I want to Heal</h1>
|
<p className="eyebrow">I Want To Heal 2</p>
|
||||||
<p>
|
<h1>Choose Character</h1>
|
||||||
Build your healer, master each dungeon, and compete for the most
|
</div>
|
||||||
efficient clears.
|
<p>Three local slots. Each character keeps separate progress, timestamp, and XP.</p>
|
||||||
</p>
|
</header>
|
||||||
|
|
||||||
|
<div className="save-option-grid">
|
||||||
|
{IWT2_LOCAL_SAVE_SLOTS.map((slot) => {
|
||||||
|
const save = localSaves[slot]
|
||||||
|
const editing = editingSlot === slot
|
||||||
|
return (
|
||||||
|
<article className={`save-option-card local-save-card ${save ? '' : 'empty'}`} key={slot}>
|
||||||
|
<div className="save-option-heading">
|
||||||
|
<span aria-hidden="true">{slot}</span>
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Local Save</p>
|
||||||
|
<h2>Slot {slot}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<form className="character-create-form" onSubmit={(event) => createCharacter(event, slot)}>
|
||||||
|
<label>
|
||||||
|
Character Name
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
maxLength={18}
|
||||||
|
minLength={2}
|
||||||
|
onChange={(event) => setCharacterName(event.target.value)}
|
||||||
|
pattern="[A-Za-z][A-Za-z0-9 \-]{1,17}"
|
||||||
|
placeholder="Mira"
|
||||||
|
required
|
||||||
|
value={characterName}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<small>
|
||||||
|
{save ? `Replaces ${save.character.name} in this slot.` : 'Creates a fresh Level 1 character.'}
|
||||||
|
</small>
|
||||||
|
<div className="slot-save-actions">
|
||||||
|
<button
|
||||||
|
className={`save-continue-button ${save ? 'danger' : ''}`}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{save ? `Replace Slot ${slot}` : `Create in Slot ${slot}`}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="save-secondary-button"
|
||||||
|
onClick={() => setEditingSlot(null)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : save ? (
|
||||||
|
<>
|
||||||
|
<strong className="save-character-name">{save.character.name}</strong>
|
||||||
|
<SaveMetadata save={save} />
|
||||||
|
<div className="slot-save-actions">
|
||||||
|
<button
|
||||||
|
aria-label={`Continue ${save.character.name} in Slot ${slot}`}
|
||||||
|
className="save-continue-button"
|
||||||
|
onClick={() => continueLocal(slot)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Continue Offline
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label={`Create new character in Slot ${slot}`}
|
||||||
|
className="save-secondary-button"
|
||||||
|
onClick={() => openCharacterEditor(slot)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
New Character
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="save-empty-state">Empty slot</p>
|
||||||
|
<button
|
||||||
|
aria-label={`Create character in Slot ${slot}`}
|
||||||
|
className="save-continue-button"
|
||||||
|
onClick={() => openCharacterEditor(slot)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Create Character
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="auth-card">
|
<section className="online-save-panel">
|
||||||
<div className="auth-tabs">
|
<div className="save-option-heading online-save-heading">
|
||||||
<button
|
<span aria-hidden="true">O</span>
|
||||||
className={mode === 'login' ? 'selected' : ''}
|
<div>
|
||||||
onClick={() => {
|
<p className="eyebrow">Account</p>
|
||||||
setMode('login')
|
<h2>Online Save</h2>
|
||||||
setMessage('')
|
</div>
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Sign In
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={mode === 'register' ? 'selected' : ''}
|
|
||||||
onClick={() => {
|
|
||||||
setMode('register')
|
|
||||||
setMessage('')
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Create Account
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={submit}>
|
{account ? (
|
||||||
<label>
|
<>
|
||||||
Username
|
<div className="online-save-summary">
|
||||||
<input
|
<strong className="save-character-name">
|
||||||
autoComplete="username"
|
{onlineSave?.character.name ?? account.username}
|
||||||
maxLength={20}
|
</strong>
|
||||||
minLength={3}
|
{onlineSave ? (
|
||||||
onChange={(event) => setUsername(event.target.value)}
|
<SaveMetadata save={onlineSave} />
|
||||||
pattern="[A-Za-z0-9_]+"
|
) : (
|
||||||
required
|
<p className="save-empty-state">
|
||||||
value={username}
|
{onlineBusy ? 'Checking online save...' : 'No online IWT2 save found.'}
|
||||||
/>
|
</p>
|
||||||
</label>
|
)}
|
||||||
{mode === 'register' && (
|
</div>
|
||||||
|
<label className="online-save-destination">
|
||||||
|
Copy Into
|
||||||
|
<select
|
||||||
|
disabled={onlineBusy || !onlineSave}
|
||||||
|
onChange={(event) => {
|
||||||
|
setOnlineDestinationSlot(Number(event.target.value) as Iwt2LocalSaveSlot)
|
||||||
|
setConfirmOnlineDestination(null)
|
||||||
|
}}
|
||||||
|
value={onlineDestinationSlot}
|
||||||
|
>
|
||||||
|
{IWT2_LOCAL_SAVE_SLOTS.map((slot) => (
|
||||||
|
<option key={slot} value={slot}>
|
||||||
|
Slot {slot} — {localSaves[slot]?.character.name ?? 'Empty'}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="online-save-actions">
|
||||||
|
<button
|
||||||
|
className={`save-continue-button ${confirmOnlineDestination === onlineDestinationSlot ? 'danger' : ''}`}
|
||||||
|
disabled={onlineBusy || !onlineSave}
|
||||||
|
onClick={continueOnline}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{confirmOnlineDestination === onlineDestinationSlot
|
||||||
|
? `Confirm Replace Slot ${onlineDestinationSlot}`
|
||||||
|
: `Load Into Slot ${onlineDestinationSlot}`}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="save-secondary-button"
|
||||||
|
disabled={onlineBusy}
|
||||||
|
onClick={() => { void handleDifferentAccount() }}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Switch Account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<form className="online-save-login" onSubmit={signIn}>
|
||||||
<label>
|
<label>
|
||||||
Character Name
|
Username
|
||||||
<input
|
<input
|
||||||
autoComplete="nickname"
|
autoComplete="username"
|
||||||
maxLength={20}
|
maxLength={20}
|
||||||
minLength={2}
|
minLength={3}
|
||||||
onChange={(event) => setCharacterName(event.target.value)}
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
pattern="[A-Za-z0-9_]+"
|
||||||
required
|
required
|
||||||
value={characterName}
|
value={username}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
)}
|
<label>
|
||||||
<label>
|
Password
|
||||||
Password
|
<input
|
||||||
<input
|
autoComplete="current-password"
|
||||||
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
maxLength={128}
|
||||||
maxLength={128}
|
minLength={10}
|
||||||
minLength={10}
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
required
|
||||||
required
|
type="password"
|
||||||
type="password"
|
value={password}
|
||||||
value={password}
|
/>
|
||||||
/>
|
</label>
|
||||||
</label>
|
<button className="save-continue-button" disabled={onlineBusy} type="submit">
|
||||||
<button className="primary-button" disabled={busy} type="submit">
|
{onlineBusy ? 'Checking Account...' : 'Sign In & Check Save'}
|
||||||
{busy
|
|
||||||
? 'Working...'
|
|
||||||
: mode === 'login'
|
|
||||||
? 'Enter Chronicle'
|
|
||||||
: 'Begin Adventure'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p className={`auth-message ${message ? 'error' : ''}`}>
|
|
||||||
{message || serverMessage || (
|
|
||||||
mode === 'register'
|
|
||||||
? 'The first account keeps the current local character and save.'
|
|
||||||
: 'Sign in to continue your character.'
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="offline-divider"><span>or</span></div>
|
|
||||||
|
|
||||||
<section className="offline-entry">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">Local Save</p>
|
|
||||||
<h2>Play Offline</h2>
|
|
||||||
<p>
|
|
||||||
No account or connection required. Offline progress stays on
|
|
||||||
this device.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{offlineCharacterExists && (
|
|
||||||
<button
|
|
||||||
className="offline-resume-button"
|
|
||||||
onClick={resumeOffline}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Continue Offline Character
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
</form>
|
||||||
<label>
|
)}
|
||||||
{offlineCharacterExists ? 'New Character Name' : 'Character Name'}
|
</section>
|
||||||
<input
|
|
||||||
maxLength={20}
|
<p
|
||||||
minLength={2}
|
aria-live="polite"
|
||||||
onChange={(event) => setOfflineName(event.target.value)}
|
className={`save-gateway-status ${messageIsError ? 'error' : ''}`}
|
||||||
placeholder="Mira"
|
>
|
||||||
value={offlineName}
|
{message}
|
||||||
/>
|
</p>
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
className="text-button offline-new-button"
|
|
||||||
onClick={beginOffline}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{offlineCharacterExists ? 'Replace Offline Character' : 'Begin Offline Adventure'}
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1841,6 +1841,10 @@ export function selectOnlineMode() {
|
|||||||
writeMode('online')
|
writeMode('online')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function selectOfflineMode() {
|
||||||
|
writeMode('offline-local')
|
||||||
|
}
|
||||||
|
|
||||||
export function createOfflineCharacter(characterName: string): AuthSession {
|
export function createOfflineCharacter(characterName: string): AuthSession {
|
||||||
const name = characterName.trim() || 'Mira'
|
const name = characterName.trim() || 'Mira'
|
||||||
if (!/^[A-Za-z][A-Za-z0-9 '-]{1,19}$/.test(name)) {
|
if (!/^[A-Za-z][A-Za-z0-9 '-]{1,19}$/.test(name)) {
|
||||||
|
|||||||
+5
-1
@@ -1043,7 +1043,11 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
<div className="controller-keyboard-heading">
|
<div className="controller-keyboard-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Controller Keyboard</p>
|
<p className="eyebrow">Controller Keyboard</p>
|
||||||
<strong>{keyboardInput.value || 'Enter text'}</strong>
|
<strong>
|
||||||
|
{keyboardInput.type === 'password'
|
||||||
|
? '•'.repeat(keyboardInput.value.length) || 'Enter password'
|
||||||
|
: keyboardInput.value || 'Enter text'}
|
||||||
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={closeKeyboard} type="button">Done</button>
|
<button onClick={closeKeyboard} type="button">Done</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,11 +22,7 @@ import {
|
|||||||
Iwt2RoguelikeUpgradeScreen,
|
Iwt2RoguelikeUpgradeScreen,
|
||||||
Iwt2SettingsScreen,
|
Iwt2SettingsScreen,
|
||||||
} from './screens/Iwt2ShellScreens'
|
} from './screens/Iwt2ShellScreens'
|
||||||
import {
|
import { writeIwt2Save, type Iwt2LocalSaveSlot, type Iwt2Save } from './save/iwt2Repository'
|
||||||
loadIwt2Save,
|
|
||||||
writeIwt2Save,
|
|
||||||
type Iwt2Save,
|
|
||||||
} from './save/iwt2Repository'
|
|
||||||
import { IWT2_BOSS_METADATA, type Iwt2BossId } from './content/bosses'
|
import { IWT2_BOSS_METADATA, type Iwt2BossId } from './content/bosses'
|
||||||
import { iwt2BossCoinRewardFor } from './content/bossRewards'
|
import { iwt2BossCoinRewardFor } from './content/bossRewards'
|
||||||
import {
|
import {
|
||||||
@@ -76,9 +72,11 @@ type Iwt2Screen =
|
|||||||
const IWT2_MENU_COLUMNS = 2
|
const IWT2_MENU_COLUMNS = 2
|
||||||
const IWT2_ROGUELIKE_CHOICE_COUNT = 3
|
const IWT2_ROGUELIKE_CHOICE_COUNT = 3
|
||||||
const IWT2_PVP_FIRST_BUFF_EXTRA_TARGET_CHANCE = 0.65
|
const IWT2_PVP_FIRST_BUFF_EXTRA_TARGET_CHANCE = 0.65
|
||||||
|
const IWT2_ROGUELIKE_GREEN_COIN_BOSS_THRESHOLD = 5
|
||||||
|
|
||||||
type Iwt2RoguelikeRunState = {
|
type Iwt2RoguelikeRunState = {
|
||||||
bossIds: Iwt2BossId[]
|
bossIds: Iwt2BossId[]
|
||||||
|
bossesDefeated: number
|
||||||
buffs: Iwt2RoguelikeSelfBuffId[]
|
buffs: Iwt2RoguelikeSelfBuffId[]
|
||||||
contentType: Iwt2RoguelikeContentType
|
contentType: Iwt2RoguelikeContentType
|
||||||
debuffs: Iwt2RoguelikeOpponentDebuffId[]
|
debuffs: Iwt2RoguelikeOpponentDebuffId[]
|
||||||
@@ -138,8 +136,8 @@ const MENU_ITEMS: Array<{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
screen: 'cloud-save',
|
screen: 'cloud-save',
|
||||||
title: 'Backup Slot',
|
title: 'Cloud Backup',
|
||||||
description: 'Choose local, online, or fresh IWT2 progress.',
|
description: 'Compare the active local slot with your online backup.',
|
||||||
glyph: 'C',
|
glyph: 'C',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -151,15 +149,19 @@ const MENU_ITEMS: Array<{
|
|||||||
]
|
]
|
||||||
|
|
||||||
export function IWantToHeal2App({
|
export function IWantToHeal2App({
|
||||||
|
initialSave,
|
||||||
|
localSlot,
|
||||||
onlineBackupsAvailable,
|
onlineBackupsAvailable,
|
||||||
onBackToGameSelect,
|
onExitToSaveSelect,
|
||||||
}: {
|
}: {
|
||||||
|
initialSave: Iwt2Save
|
||||||
|
localSlot: Iwt2LocalSaveSlot
|
||||||
onlineBackupsAvailable: boolean
|
onlineBackupsAvailable: boolean
|
||||||
onBackToGameSelect: () => void
|
onExitToSaveSelect: () => void
|
||||||
}) {
|
}) {
|
||||||
const { enabled: dualScreenEnabled } = useDualScreen()
|
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>(initialSave)
|
||||||
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 [arenaModeLabel, setArenaModeLabel] = useState('Dungeon')
|
||||||
@@ -174,8 +176,8 @@ export function IWantToHeal2App({
|
|||||||
const cancelPvpQueueRef = useRef<(() => void) | null>(null)
|
const cancelPvpQueueRef = useRef<(() => void) | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
writeIwt2Save(save)
|
writeIwt2Save(save, localSlot)
|
||||||
}, [save])
|
}, [localSlot, save])
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
cancelPvpQueueRef.current?.()
|
cancelPvpQueueRef.current?.()
|
||||||
@@ -203,7 +205,7 @@ export function IWantToHeal2App({
|
|||||||
useGameAction((action, device) => {
|
useGameAction((action, device) => {
|
||||||
if (screen !== 'menu' || device !== 'controller') return
|
if (screen !== 'menu' || device !== 'controller') return
|
||||||
if (action === 'back') {
|
if (action === 'back') {
|
||||||
onBackToGameSelect()
|
onExitToSaveSelect()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (action === 'confirm') {
|
if (action === 'confirm') {
|
||||||
@@ -256,10 +258,13 @@ export function IWantToHeal2App({
|
|||||||
buffs: roguelikeRun.buffs,
|
buffs: roguelikeRun.buffs,
|
||||||
contentType: roguelikeRun.contentType,
|
contentType: roguelikeRun.contentType,
|
||||||
debuffs: roguelikeRun.debuffs,
|
debuffs: roguelikeRun.debuffs,
|
||||||
|
greenCoinThreshold: IWT2_ROGUELIKE_GREEN_COIN_BOSS_THRESHOLD,
|
||||||
|
bossesDefeated: roguelikeRun.bossesDefeated,
|
||||||
onVictory: () => {
|
onVictory: () => {
|
||||||
setRoguelikeRun((current) => current
|
setRoguelikeRun((current) => current
|
||||||
? {
|
? {
|
||||||
...current,
|
...current,
|
||||||
|
bossesDefeated: current.bossesDefeated + current.bossIds.length,
|
||||||
...buildRoguelikeChoices(save, current.variant),
|
...buildRoguelikeChoices(save, current.variant),
|
||||||
}
|
}
|
||||||
: current)
|
: current)
|
||||||
@@ -283,7 +288,7 @@ export function IWantToHeal2App({
|
|||||||
if (screen === 'roguelike-upgrade' && roguelikeRun) {
|
if (screen === 'roguelike-upgrade' && roguelikeRun) {
|
||||||
return (
|
return (
|
||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
|
||||||
<Iwt2RoguelikeUpgradeScreen
|
<Iwt2RoguelikeUpgradeScreen
|
||||||
activeBuffSummary={summarizeIwt2Buffs(save, roguelikeRun.buffs)}
|
activeBuffSummary={summarizeIwt2Buffs(save, roguelikeRun.buffs)}
|
||||||
activeDebuffSummary={summarizeIwt2Debuffs(save, roguelikeRun.debuffs)}
|
activeDebuffSummary={summarizeIwt2Debuffs(save, roguelikeRun.debuffs)}
|
||||||
@@ -307,7 +312,7 @@ export function IWantToHeal2App({
|
|||||||
if (screen === 'dungeons') {
|
if (screen === 'dungeons') {
|
||||||
return (
|
return (
|
||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
|
||||||
<Iwt2DungeonsScreen
|
<Iwt2DungeonsScreen
|
||||||
difficultySlug={selectedDungeonDifficultySlug}
|
difficultySlug={selectedDungeonDifficultySlug}
|
||||||
save={save}
|
save={save}
|
||||||
@@ -328,7 +333,7 @@ export function IWantToHeal2App({
|
|||||||
if (screen === 'roguelike') {
|
if (screen === 'roguelike') {
|
||||||
return (
|
return (
|
||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
|
||||||
<Iwt2RoguelikeScreen
|
<Iwt2RoguelikeScreen
|
||||||
contentType={roguelikeContentType}
|
contentType={roguelikeContentType}
|
||||||
variant={roguelikeVariant}
|
variant={roguelikeVariant}
|
||||||
@@ -358,7 +363,7 @@ export function IWantToHeal2App({
|
|||||||
if (screen === 'raids') {
|
if (screen === 'raids') {
|
||||||
return (
|
return (
|
||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
|
||||||
<Iwt2ModeScreen
|
<Iwt2ModeScreen
|
||||||
difficultySlug={selectedRaidDifficultySlug}
|
difficultySlug={selectedRaidDifficultySlug}
|
||||||
mode="Raids"
|
mode="Raids"
|
||||||
@@ -392,7 +397,7 @@ export function IWantToHeal2App({
|
|||||||
save={save}
|
save={save}
|
||||||
title="Gear Upgrade"
|
title="Gear Upgrade"
|
||||||
onBack={() => setScreen('menu')}
|
onBack={() => setScreen('menu')}
|
||||||
onBackToGameSelect={onBackToGameSelect}
|
onExitToSaveSelect={onExitToSaveSelect}
|
||||||
/>
|
/>
|
||||||
<Iwt2GearUpgradeScreen
|
<Iwt2GearUpgradeScreen
|
||||||
save={save}
|
save={save}
|
||||||
@@ -406,7 +411,7 @@ export function IWantToHeal2App({
|
|||||||
if (screen === 'customize-character') {
|
if (screen === 'customize-character') {
|
||||||
return (
|
return (
|
||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
|
||||||
<Iwt2CustomizeCharacterScreen
|
<Iwt2CustomizeCharacterScreen
|
||||||
save={save}
|
save={save}
|
||||||
onBack={() => setScreen('menu')}
|
onBack={() => setScreen('menu')}
|
||||||
@@ -419,7 +424,7 @@ export function IWantToHeal2App({
|
|||||||
if (screen === 'cloud-save') {
|
if (screen === 'cloud-save') {
|
||||||
return (
|
return (
|
||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
|
||||||
<Iwt2CloudSaveScreen
|
<Iwt2CloudSaveScreen
|
||||||
save={save}
|
save={save}
|
||||||
onlineBackupsAvailable={onlineBackupsAvailable}
|
onlineBackupsAvailable={onlineBackupsAvailable}
|
||||||
@@ -433,7 +438,7 @@ export function IWantToHeal2App({
|
|||||||
if (screen === 'settings') {
|
if (screen === 'settings') {
|
||||||
return (
|
return (
|
||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
|
||||||
<Iwt2SettingsScreen onBack={() => setScreen('menu')} />
|
<Iwt2SettingsScreen onBack={() => setScreen('menu')} />
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
@@ -441,7 +446,7 @@ export function IWantToHeal2App({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
|
||||||
|
|
||||||
{screen === 'menu' && (
|
{screen === 'menu' && (
|
||||||
<section className="iwt2-menu-screen" data-game-nav-active="true">
|
<section className="iwt2-menu-screen" data-game-nav-active="true">
|
||||||
@@ -534,7 +539,8 @@ function createRoguelikeRun(
|
|||||||
contentType: Iwt2RoguelikeContentType,
|
contentType: Iwt2RoguelikeContentType,
|
||||||
): Iwt2RoguelikeRunState {
|
): Iwt2RoguelikeRunState {
|
||||||
return {
|
return {
|
||||||
bossIds: createRoguelikeBossPair(variant, contentType, 1),
|
bossIds: createRoguelikeBossPair(variant, contentType, 1, 0),
|
||||||
|
bossesDefeated: 0,
|
||||||
buffs: [],
|
buffs: [],
|
||||||
contentType,
|
contentType,
|
||||||
debuffs: [],
|
debuffs: [],
|
||||||
@@ -602,7 +608,7 @@ function applyRoguelikeChoice(
|
|||||||
return {
|
return {
|
||||||
...run,
|
...run,
|
||||||
...nextBase,
|
...nextBase,
|
||||||
bossIds: createRoguelikeBossPair(run.variant, run.contentType, run.stage + 1),
|
bossIds: createRoguelikeBossPair(run.variant, run.contentType, run.stage + 1, run.bossesDefeated),
|
||||||
...buildRoguelikeChoices(save, run.variant),
|
...buildRoguelikeChoices(save, run.variant),
|
||||||
stage: run.stage + 1,
|
stage: run.stage + 1,
|
||||||
}
|
}
|
||||||
@@ -612,6 +618,7 @@ function createRoguelikeBossPair(
|
|||||||
variant: Iwt2RoguelikeVariant,
|
variant: Iwt2RoguelikeVariant,
|
||||||
contentType: Iwt2RoguelikeContentType,
|
contentType: Iwt2RoguelikeContentType,
|
||||||
stage: number,
|
stage: number,
|
||||||
|
bossesDefeated: number,
|
||||||
): Iwt2BossId[] {
|
): Iwt2BossId[] {
|
||||||
const weightedProgressionEnabled = variant === 'pve'
|
const weightedProgressionEnabled = variant === 'pve'
|
||||||
? IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED
|
? IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED
|
||||||
@@ -619,11 +626,12 @@ function createRoguelikeBossPair(
|
|||||||
? IWT2_PVP_STADIUM_WEIGHTED_BOSS_PROGRESSION_ENABLED
|
? IWT2_PVP_STADIUM_WEIGHTED_BOSS_PROGRESSION_ENABLED
|
||||||
: IWT2_PVP_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED
|
: IWT2_PVP_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED
|
||||||
const bossPool = roguelikeBossPoolFor(variant, contentType)
|
const bossPool = roguelikeBossPoolFor(variant, contentType)
|
||||||
|
const count = bossesDefeated >= IWT2_ROGUELIKE_GREEN_COIN_BOSS_THRESHOLD ? 3 : 2
|
||||||
|
|
||||||
if (weightedProgressionEnabled) {
|
if (weightedProgressionEnabled) {
|
||||||
return createIwt2WeightedRoguelikeBossPair(stage, Math.random, { bossPool })
|
return createIwt2WeightedRoguelikeBossPair(stage, Math.random, { bossPool, count })
|
||||||
}
|
}
|
||||||
return createUniformIwt2RoguelikeBossPair(Math.random, { bossPool })
|
return createUniformIwt2RoguelikeBossPair(Math.random, { bossPool, count })
|
||||||
}
|
}
|
||||||
|
|
||||||
function roguelikeBossPoolFor(
|
function roguelikeBossPoolFor(
|
||||||
@@ -684,19 +692,19 @@ function isExtraTargetBuff(choice: Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId>)
|
|||||||
|
|
||||||
function Iwt2Header({
|
function Iwt2Header({
|
||||||
onBack,
|
onBack,
|
||||||
onBackToGameSelect,
|
onExitToSaveSelect,
|
||||||
save,
|
save,
|
||||||
title,
|
title,
|
||||||
}: {
|
}: {
|
||||||
onBack?: () => void
|
onBack?: () => void
|
||||||
onBackToGameSelect: () => void
|
onExitToSaveSelect: () => void
|
||||||
save: Iwt2Save
|
save: Iwt2Save
|
||||||
title?: string
|
title?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<header className="topbar app-header">
|
<header className="topbar app-header">
|
||||||
<button className="brand-button" onClick={onBackToGameSelect} type="button">
|
<button className="brand-button" onClick={onExitToSaveSelect} type="button">
|
||||||
<strong>Games</strong>
|
<strong>Saves</strong>
|
||||||
</button>
|
</button>
|
||||||
{title && <strong className="iwt2-header-title">{title}</strong>}
|
{title && <strong className="iwt2-header-title">{title}</strong>}
|
||||||
<div className="character-summary">
|
<div className="character-summary">
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import type { Iwt2BossId } from './bosses'
|
|||||||
import type { Iwt2PlayerClassId } from './classes'
|
import type { Iwt2PlayerClassId } from './classes'
|
||||||
import { iwt2BossCoinRewardFor } from './bossRewards'
|
import { iwt2BossCoinRewardFor } from './bossRewards'
|
||||||
import { IWT2_INFUSION_ABILITIES, type Iwt2InfusionAbilityId } from './infusionAbilities'
|
import { IWT2_INFUSION_ABILITIES, type Iwt2InfusionAbilityId } from './infusionAbilities'
|
||||||
|
import type { Iwt2RoguelikeSelfBuffId } from './roguelike'
|
||||||
export { IWT2_INFUSION_ABILITIES, iwt2InfusionAbilitiesForClass } from './infusionAbilities'
|
export { IWT2_INFUSION_ABILITIES, iwt2InfusionAbilitiesForClass } from './infusionAbilities'
|
||||||
export type { Iwt2InfusionAbility, Iwt2InfusionAbilityId } from './infusionAbilities'
|
export type { Iwt2InfusionAbility, Iwt2InfusionAbilityId } from './infusionAbilities'
|
||||||
|
|
||||||
export type Iwt2GearSlotId = 'weapon' | 'helmet' | 'chest' | 'legs' | 'feet'
|
export type Iwt2GearSlotId = 'weapon' | 'helmet' | 'chest' | 'legs' | 'feet'
|
||||||
export type Iwt2GearLevel = 0 | 1 | 2 | 3 | 4 | 5
|
export type Iwt2GearLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10
|
||||||
|
export type Iwt2PassiveInfusionId = Exclude<Iwt2RoguelikeSelfBuffId, 'revive-party-members'>
|
||||||
export type Iwt2GearStatId =
|
export type Iwt2GearStatId =
|
||||||
| 'maxHealth'
|
| 'maxHealth'
|
||||||
| 'moveSpeed'
|
| 'moveSpeed'
|
||||||
@@ -24,6 +26,7 @@ export type Iwt2GearSlotProgress = {
|
|||||||
export type Iwt2ClassGearProgress = {
|
export type Iwt2ClassGearProgress = {
|
||||||
slots: Record<Iwt2GearSlotId, Iwt2GearSlotProgress>
|
slots: Record<Iwt2GearSlotId, Iwt2GearSlotProgress>
|
||||||
infusionAbilityId: Iwt2InfusionAbilityId | null
|
infusionAbilityId: Iwt2InfusionAbilityId | null
|
||||||
|
passiveInfusionId: Iwt2PassiveInfusionId | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Iwt2GearProgress = Record<Iwt2PlayerClassId, Iwt2ClassGearProgress>
|
export type Iwt2GearProgress = Record<Iwt2PlayerClassId, Iwt2ClassGearProgress>
|
||||||
@@ -43,6 +46,9 @@ export type Iwt2GearSlotRecipe = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const IWT2_GEAR_SLOTS: Iwt2GearSlotId[] = ['weapon', 'helmet', 'chest', 'legs', 'feet']
|
export const IWT2_GEAR_SLOTS: Iwt2GearSlotId[] = ['weapon', 'helmet', 'chest', 'legs', 'feet']
|
||||||
|
export const IWT2_MAX_GEAR_LEVEL: Iwt2GearLevel = 10
|
||||||
|
export const IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL: Iwt2GearLevel = 5
|
||||||
|
export const IWT2_PASSIVE_INFUSION_MIN_GEAR_LEVEL: Iwt2GearLevel = 10
|
||||||
|
|
||||||
export const IWT2_GEAR_SLOT_LABELS: Record<Iwt2GearSlotId, string> = {
|
export const IWT2_GEAR_SLOT_LABELS: Record<Iwt2GearSlotId, string> = {
|
||||||
weapon: 'Weapon',
|
weapon: 'Weapon',
|
||||||
@@ -120,7 +126,13 @@ export function createDefaultIwt2GearProgress(): Iwt2GearProgress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isIwt2InfusionUnlocked(progress: Iwt2ClassGearProgress): boolean {
|
export function isIwt2InfusionUnlocked(progress: Iwt2ClassGearProgress): boolean {
|
||||||
return Object.values(progress.slots).some((slot) => slot.level >= 5)
|
return Object.values(progress.slots).some((slot) => slot.level >= IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isIwt2PassiveInfusionUnlocked(progress: Iwt2GearProgress): boolean {
|
||||||
|
return Object.values(progress).some((classProgress) => (
|
||||||
|
Object.values(classProgress.slots).some((slot) => slot.level >= IWT2_PASSIVE_INFUSION_MIN_GEAR_LEVEL)
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function iwt2GearUpgradeCosts(
|
export function iwt2GearUpgradeCosts(
|
||||||
@@ -128,7 +140,7 @@ export function iwt2GearUpgradeCosts(
|
|||||||
slotId: Iwt2GearSlotId,
|
slotId: Iwt2GearSlotId,
|
||||||
currentLevel: Iwt2GearLevel,
|
currentLevel: Iwt2GearLevel,
|
||||||
): Iwt2GearUpgradeCost[] {
|
): Iwt2GearUpgradeCost[] {
|
||||||
if (currentLevel >= 5) return []
|
if (currentLevel >= IWT2_MAX_GEAR_LEVEL) return []
|
||||||
const recipe = IWT2_GEAR_SLOT_RECIPES[classId][slotId]
|
const recipe = IWT2_GEAR_SLOT_RECIPES[classId][slotId]
|
||||||
const nextLevel = (currentLevel + 1) as Exclude<Iwt2GearLevel, 0>
|
const nextLevel = (currentLevel + 1) as Exclude<Iwt2GearLevel, 0>
|
||||||
const slug = upgradeDifficultySlug(nextLevel)
|
const slug = upgradeDifficultySlug(nextLevel)
|
||||||
@@ -147,10 +159,15 @@ export function iwt2GearUpgradeCosts(
|
|||||||
{ itemId: primary.id, itemName: primary.name, quantity: 4 },
|
{ itemId: primary.id, itemName: primary.name, quantity: 4 },
|
||||||
{ itemId: secondary.id, itemName: secondary.name, quantity: 3 },
|
{ itemId: secondary.id, itemName: secondary.name, quantity: 3 },
|
||||||
]
|
]
|
||||||
return [
|
if (nextLevel === 5) return [
|
||||||
{ itemId: primary.id, itemName: primary.name, quantity: 5 },
|
{ itemId: primary.id, itemName: primary.name, quantity: 5 },
|
||||||
{ itemId: secondary.id, itemName: secondary.name, quantity: 4 },
|
{ itemId: secondary.id, itemName: secondary.name, quantity: 4 },
|
||||||
]
|
]
|
||||||
|
const overcap = nextLevel - 5
|
||||||
|
return [
|
||||||
|
{ itemId: primary.id, itemName: primary.name, quantity: 5 + overcap },
|
||||||
|
{ itemId: secondary.id, itemName: secondary.name, quantity: 4 + overcap },
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function iwt2InfusionCosts(
|
export function iwt2InfusionCosts(
|
||||||
@@ -178,6 +195,7 @@ function createDefaultClassGearProgress(): Iwt2ClassGearProgress {
|
|||||||
feet: { level: 0 },
|
feet: { level: 0 },
|
||||||
},
|
},
|
||||||
infusionAbilityId: null,
|
infusionAbilityId: null,
|
||||||
|
passiveInfusionId: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +211,7 @@ function slotRecipe(
|
|||||||
|
|
||||||
function upgradeDifficultySlug(level: Exclude<Iwt2GearLevel, 0>): string {
|
function upgradeDifficultySlug(level: Exclude<Iwt2GearLevel, 0>): string {
|
||||||
if (level <= 2) return 'initiate'
|
if (level <= 2) return 'initiate'
|
||||||
|
if (level >= 6) return 'veteran'
|
||||||
if (level === 3) return 'veteran'
|
if (level === 3) return 'veteran'
|
||||||
if (level === 4) return 'champion'
|
if (level === 4) return 'champion'
|
||||||
return 'mythic'
|
return 'mythic'
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export function createIwt2PvpNormalizedGearProgress(
|
|||||||
IWT2_GEAR_SLOTS.map((slotId) => [slotId, { level: config.gearLevel }]),
|
IWT2_GEAR_SLOTS.map((slotId) => [slotId, { level: config.gearLevel }]),
|
||||||
),
|
),
|
||||||
infusionAbilityId: null,
|
infusionAbilityId: null,
|
||||||
|
passiveInfusionId: null,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
) as Iwt2GearProgress
|
) as Iwt2GearProgress
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type TierWeight = {
|
|||||||
|
|
||||||
type Iwt2RoguelikeBossPoolOptions = {
|
type Iwt2RoguelikeBossPoolOptions = {
|
||||||
bossPool?: readonly Iwt2BossId[]
|
bossPool?: readonly Iwt2BossId[]
|
||||||
|
count?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const IWT2_ROGUELIKE_BOSS_TIERS: Record<Iwt2RoguelikeBossTier, readonly Iwt2BossId[]> = {
|
const IWT2_ROGUELIKE_BOSS_TIERS: Record<Iwt2RoguelikeBossTier, readonly Iwt2BossId[]> = {
|
||||||
@@ -67,16 +68,17 @@ export function createIwt2WeightedRoguelikeBossPair(
|
|||||||
const choices: Iwt2BossId[] = []
|
const choices: Iwt2BossId[] = []
|
||||||
const maxThreat = maxThreatForStage(stage)
|
const maxThreat = maxThreatForStage(stage)
|
||||||
const bossPool = normalizeBossPool(options.bossPool)
|
const bossPool = normalizeBossPool(options.bossPool)
|
||||||
|
const count = normalizeBossCount(options.count)
|
||||||
|
|
||||||
while (choices.length < 2) {
|
while (choices.length < count) {
|
||||||
const next = chooseWeightedBossForStage(stage, choices, maxThreat, random, bossPool)
|
const next = chooseWeightedBossForStage(stage, choices, maxThreat, random, bossPool)
|
||||||
if (!next) break
|
if (!next) break
|
||||||
choices.push(next)
|
choices.push(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
return choices.length === 2
|
return choices.length === count
|
||||||
? choices
|
? choices
|
||||||
: createUniformIwt2RoguelikeBossPair(random, { bossPool })
|
: createUniformIwt2RoguelikeBossPair(random, { bossPool, count })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createUniformIwt2RoguelikeBossPair(
|
export function createUniformIwt2RoguelikeBossPair(
|
||||||
@@ -84,8 +86,9 @@ export function createUniformIwt2RoguelikeBossPair(
|
|||||||
options: Iwt2RoguelikeBossPoolOptions = {},
|
options: Iwt2RoguelikeBossPoolOptions = {},
|
||||||
): Iwt2BossId[] {
|
): Iwt2BossId[] {
|
||||||
const pool = normalizeBossPool(options.bossPool)
|
const pool = normalizeBossPool(options.bossPool)
|
||||||
|
const count = normalizeBossCount(options.count)
|
||||||
const choices: Iwt2BossId[] = []
|
const choices: Iwt2BossId[] = []
|
||||||
while (pool.length > 0 && choices.length < 2) {
|
while (pool.length > 0 && choices.length < count) {
|
||||||
const index = randomIndex(pool.length, random)
|
const index = randomIndex(pool.length, random)
|
||||||
const [choice] = pool.splice(index, 1)
|
const [choice] = pool.splice(index, 1)
|
||||||
if (choice) choices.push(choice)
|
if (choice) choices.push(choice)
|
||||||
@@ -192,7 +195,7 @@ function maxThreatForStage(stage: number): number {
|
|||||||
if (safeStage <= 2) return 3
|
if (safeStage <= 2) return 3
|
||||||
if (safeStage === 3) return 4
|
if (safeStage === 3) return 4
|
||||||
if (safeStage <= 5) return 5
|
if (safeStage <= 5) return 5
|
||||||
return 6
|
return 8
|
||||||
}
|
}
|
||||||
|
|
||||||
function threatForBoss(bossId: Iwt2BossId): number {
|
function threatForBoss(bossId: Iwt2BossId): number {
|
||||||
@@ -203,6 +206,10 @@ function randomIndex(length: number, random: () => number): number {
|
|||||||
return Math.min(length - 1, Math.floor(safeRandom(random) * length))
|
return Math.min(length - 1, Math.floor(safeRandom(random) * length))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeBossCount(count: number | undefined): number {
|
||||||
|
return Math.max(1, Math.min(3, Math.floor(count ?? 2)))
|
||||||
|
}
|
||||||
|
|
||||||
function safeRandom(random: () => number): number {
|
function safeRandom(random: () => number): number {
|
||||||
const value = random()
|
const value = random()
|
||||||
return Number.isFinite(value) ? Math.min(0.999999999, Math.max(0, value)) : 0
|
return Number.isFinite(value) ? Math.min(0.999999999, Math.max(0, value)) : 0
|
||||||
|
|||||||
@@ -4,15 +4,24 @@ import type { MovementVector } from '../../../input'
|
|||||||
import { BulldromeArenaScene } from './scenes/BulldromeArenaScene'
|
import { BulldromeArenaScene } from './scenes/BulldromeArenaScene'
|
||||||
import type { Iwt2ArenaState } from '../sim/arenaState'
|
import type { Iwt2ArenaState } from '../sim/arenaState'
|
||||||
|
|
||||||
|
const IWT2_ARENA_FPS = 60
|
||||||
|
|
||||||
type PhaserArenaProps = {
|
type PhaserArenaProps = {
|
||||||
|
active: boolean
|
||||||
movementRef: MutableRefObject<MovementVector>
|
movementRef: MutableRefObject<MovementVector>
|
||||||
selectedPartyIdRef: MutableRefObject<string>
|
selectedPartyIdRef: MutableRefObject<string>
|
||||||
stateRef: MutableRefObject<Iwt2ArenaState>
|
stateRef: MutableRefObject<Iwt2ArenaState>
|
||||||
onStep: (movement: MovementVector, dtSeconds: number) => Iwt2ArenaState
|
onStep: (movement: MovementVector, dtSeconds: number) => Iwt2ArenaState
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef }: PhaserArenaProps) {
|
export function PhaserArena({ active, movementRef, onStep, selectedPartyIdRef, stateRef }: PhaserArenaProps) {
|
||||||
|
const gameRef = useRef<Phaser.Game | null>(null)
|
||||||
const hostRef = useRef<HTMLDivElement | null>(null)
|
const hostRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const onStepRef = useRef(onStep)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onStepRef.current = onStep
|
||||||
|
}, [onStep])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hostRef.current) return undefined
|
if (!hostRef.current) return undefined
|
||||||
@@ -21,7 +30,7 @@ export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef
|
|||||||
getMovement: () => movementRef.current,
|
getMovement: () => movementRef.current,
|
||||||
getSelectedPartyId: () => selectedPartyIdRef.current,
|
getSelectedPartyId: () => selectedPartyIdRef.current,
|
||||||
getState: () => stateRef.current,
|
getState: () => stateRef.current,
|
||||||
step: onStep,
|
step: (movement, dtSeconds) => onStepRef.current(movement, dtSeconds),
|
||||||
})
|
})
|
||||||
const game = new Phaser.Game({
|
const game = new Phaser.Game({
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
@@ -30,17 +39,36 @@ export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef
|
|||||||
height: initialState.bounds.height,
|
height: initialState.bounds.height,
|
||||||
backgroundColor: '#10141b',
|
backgroundColor: '#10141b',
|
||||||
pixelArt: false,
|
pixelArt: false,
|
||||||
|
fps: {
|
||||||
|
target: IWT2_ARENA_FPS,
|
||||||
|
limit: IWT2_ARENA_FPS,
|
||||||
|
},
|
||||||
scale: {
|
scale: {
|
||||||
mode: Phaser.Scale.FIT,
|
mode: Phaser.Scale.FIT,
|
||||||
autoCenter: Phaser.Scale.CENTER_BOTH,
|
autoCenter: Phaser.Scale.CENTER_BOTH,
|
||||||
},
|
},
|
||||||
scene,
|
scene,
|
||||||
})
|
})
|
||||||
|
gameRef.current = game
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
if (gameRef.current === game) gameRef.current = null
|
||||||
game.destroy(true)
|
game.destroy(true)
|
||||||
}
|
}
|
||||||
}, [movementRef, onStep, selectedPartyIdRef, stateRef])
|
}, [movementRef, selectedPartyIdRef, stateRef])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const game = gameRef.current
|
||||||
|
if (!game?.loop.started) return
|
||||||
|
if (active) {
|
||||||
|
if (!game.loop.running) {
|
||||||
|
game.loop.resetDelta()
|
||||||
|
game.loop.wake()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
game.loop.sleep()
|
||||||
|
}, [active])
|
||||||
|
|
||||||
return <div className="iwt2-phaser-host" ref={hostRef} />
|
return <div className="iwt2-phaser-host" ref={hostRef} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,20 +52,17 @@ const BOSS_PROFILES: Partial<Record<Iwt2BossEntityState['bossId'], BossMotionPro
|
|||||||
const WARM_TINT = 0xffc88a
|
const WARM_TINT = 0xffc88a
|
||||||
const IMPACT_TINT = 0xff9b5f
|
const IMPACT_TINT = 0xff9b5f
|
||||||
|
|
||||||
export function bossRenderMotion(entity: Iwt2BossEntityState, timeSeconds: number): BossRenderMotion {
|
export function bossRenderMotion(
|
||||||
|
entity: Iwt2BossEntityState,
|
||||||
|
timeSeconds: number,
|
||||||
|
motion: BossRenderMotion = createBossRenderMotion(),
|
||||||
|
): BossRenderMotion {
|
||||||
const profile = BOSS_PROFILES[entity.bossId] ?? DEFAULT_PROFILE
|
const profile = BOSS_PROFILES[entity.bossId] ?? DEFAULT_PROFILE
|
||||||
const phase = String(entity.attackPhase)
|
const phase = String(entity.attackPhase)
|
||||||
const speed = Math.hypot(entity.velocity.x, entity.velocity.y)
|
const speed = Math.hypot(entity.velocity.x, entity.velocity.y)
|
||||||
const moving = speed > 8 || phase === 'relocating'
|
const moving = speed > 8 || phase === 'relocating'
|
||||||
const facingX = entity.facing.x < -0.05 ? -1 : 1
|
const facingX = entity.facing.x < -0.05 ? -1 : 1
|
||||||
|
resetBossRenderMotion(motion)
|
||||||
const motion: BossRenderMotion = {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
scaleX: 1,
|
|
||||||
scaleY: 1,
|
|
||||||
rotation: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isActionPhase(phase, 'windup')) return windupMotion(motion, entity, phase, timeSeconds, facingX)
|
if (isActionPhase(phase, 'windup')) return windupMotion(motion, entity, phase, timeSeconds, facingX)
|
||||||
if (isBurstPhase(phase)) return burstMotion(motion, timeSeconds, facingX)
|
if (isBurstPhase(phase)) return burstMotion(motion, timeSeconds, facingX)
|
||||||
@@ -76,12 +73,10 @@ export function bossRenderMotion(entity: Iwt2BossEntityState, timeSeconds: numbe
|
|||||||
|
|
||||||
function idleMotion(motion: BossRenderMotion, profile: BossMotionProfile, timeSeconds: number): BossRenderMotion {
|
function idleMotion(motion: BossRenderMotion, profile: BossMotionProfile, timeSeconds: number): BossRenderMotion {
|
||||||
const wave = Math.sin(timeSeconds * profile.idleFrequency * Math.PI * 2)
|
const wave = Math.sin(timeSeconds * profile.idleFrequency * Math.PI * 2)
|
||||||
return {
|
motion.y = -Math.max(0, wave) * profile.idleBob
|
||||||
...motion,
|
motion.scaleY = 1 + Math.max(0, wave) * 0.025
|
||||||
y: -Math.max(0, wave) * profile.idleBob,
|
motion.scaleX = 1 - Math.max(0, wave) * 0.01
|
||||||
scaleY: 1 + Math.max(0, wave) * 0.025,
|
return motion
|
||||||
scaleX: 1 - Math.max(0, wave) * 0.01,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function movingMotion(
|
function movingMotion(
|
||||||
@@ -94,13 +89,11 @@ function movingMotion(
|
|||||||
const speedFactor = Math.min(1, Math.hypot(entity.velocity.x, entity.velocity.y) / 180)
|
const speedFactor = Math.min(1, Math.hypot(entity.velocity.x, entity.velocity.y) / 180)
|
||||||
const stride = Math.sin(timeSeconds * profile.moveFrequency * Math.PI * 2)
|
const stride = Math.sin(timeSeconds * profile.moveFrequency * Math.PI * 2)
|
||||||
const lift = Math.abs(stride) * profile.moveBob
|
const lift = Math.abs(stride) * profile.moveBob
|
||||||
return {
|
motion.y = -lift
|
||||||
...motion,
|
motion.scaleX = 1 + 0.012 * speedFactor
|
||||||
y: -lift,
|
motion.scaleY = 1 - 0.009 * Math.abs(stride)
|
||||||
scaleX: 1 + 0.012 * speedFactor,
|
motion.rotation = facingX * 0.025 * speedFactor
|
||||||
scaleY: 1 - 0.009 * Math.abs(stride),
|
return motion
|
||||||
rotation: facingX * 0.025 * speedFactor,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function windupMotion(
|
function windupMotion(
|
||||||
@@ -112,28 +105,24 @@ function windupMotion(
|
|||||||
): BossRenderMotion {
|
): BossRenderMotion {
|
||||||
const pulse = 0.5 + Math.sin(timeSeconds * Math.PI * 9) * 0.5
|
const pulse = 0.5 + Math.sin(timeSeconds * Math.PI * 9) * 0.5
|
||||||
const isSlam = phase.includes('slam') || phase.includes('quake') || phase.includes('shatter')
|
const isSlam = phase.includes('slam') || phase.includes('quake') || phase.includes('shatter')
|
||||||
return {
|
motion.x = -facingX * (isSlam ? 1.5 : 3)
|
||||||
...motion,
|
motion.y = isSlam ? -2 : 2
|
||||||
x: -facingX * (isSlam ? 1.5 : 3),
|
motion.scaleX = 1.035
|
||||||
y: isSlam ? -2 : 2,
|
motion.scaleY = 0.965
|
||||||
scaleX: 1.035,
|
motion.rotation = -facingX * 0.02
|
||||||
scaleY: 0.965,
|
motion.tint = pulse > 0.45 || entity.phaseSecondsRemaining < 0.18 ? WARM_TINT : undefined
|
||||||
rotation: -facingX * 0.02,
|
return motion
|
||||||
tint: pulse > 0.45 || entity.phaseSecondsRemaining < 0.18 ? WARM_TINT : undefined,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function burstMotion(motion: BossRenderMotion, timeSeconds: number, facingX: number): BossRenderMotion {
|
function burstMotion(motion: BossRenderMotion, timeSeconds: number, facingX: number): BossRenderMotion {
|
||||||
const shake = Math.sin(timeSeconds * Math.PI * 38) * 1.8
|
const shake = Math.sin(timeSeconds * Math.PI * 38) * 1.8
|
||||||
return {
|
motion.x = facingX * 3 + shake
|
||||||
...motion,
|
motion.y = Math.cos(timeSeconds * Math.PI * 42) * 1.2
|
||||||
x: facingX * 3 + shake,
|
motion.scaleX = 1.045
|
||||||
y: Math.cos(timeSeconds * Math.PI * 42) * 1.2,
|
motion.scaleY = 0.96
|
||||||
scaleX: 1.045,
|
motion.rotation = facingX * 0.018
|
||||||
scaleY: 0.96,
|
motion.tint = WARM_TINT
|
||||||
rotation: facingX * 0.018,
|
return motion
|
||||||
tint: WARM_TINT,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function recoverMotion(
|
function recoverMotion(
|
||||||
@@ -145,13 +134,24 @@ function recoverMotion(
|
|||||||
const settle = Math.max(0, Math.min(1, entity.phaseSecondsRemaining / 0.45))
|
const settle = Math.max(0, Math.min(1, entity.phaseSecondsRemaining / 0.45))
|
||||||
const impactPulse = Math.abs(Math.sin(timeSeconds * Math.PI * 11)) * settle
|
const impactPulse = Math.abs(Math.sin(timeSeconds * Math.PI * 11)) * settle
|
||||||
const pop = profile.impactScale * impactPulse
|
const pop = profile.impactScale * impactPulse
|
||||||
return {
|
motion.y = -2 * impactPulse
|
||||||
...motion,
|
motion.scaleX = 1 + pop
|
||||||
y: -2 * impactPulse,
|
motion.scaleY = 1 + pop * 0.45
|
||||||
scaleX: 1 + pop,
|
motion.tint = impactPulse > 0.45 ? IMPACT_TINT : undefined
|
||||||
scaleY: 1 + pop * 0.45,
|
return motion
|
||||||
tint: impactPulse > 0.45 ? IMPACT_TINT : undefined,
|
}
|
||||||
}
|
|
||||||
|
function createBossRenderMotion(): BossRenderMotion {
|
||||||
|
return { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetBossRenderMotion(motion: BossRenderMotion) {
|
||||||
|
motion.x = 0
|
||||||
|
motion.y = 0
|
||||||
|
motion.scaleX = 1
|
||||||
|
motion.scaleY = 1
|
||||||
|
motion.rotation = 0
|
||||||
|
motion.tint = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function isActionPhase(phase: string, suffix: string): boolean {
|
function isActionPhase(phase: string, suffix: string): boolean {
|
||||||
|
|||||||
@@ -27,65 +27,59 @@ export function partyRenderMotion(
|
|||||||
entity: Iwt2PartyEntityState,
|
entity: Iwt2PartyEntityState,
|
||||||
timeSeconds: number,
|
timeSeconds: number,
|
||||||
attackPulse?: PartyAttackPulse,
|
attackPulse?: PartyAttackPulse,
|
||||||
|
motion: PartyRenderMotion = createPartyRenderMotion(),
|
||||||
): PartyRenderMotion {
|
): PartyRenderMotion {
|
||||||
const speed = Math.hypot(entity.velocity.x, entity.velocity.y)
|
const speed = Math.hypot(entity.velocity.x, entity.velocity.y)
|
||||||
const facingX = entity.facing.x < -0.05 ? -1 : 1
|
const facingX = entity.facing.x < -0.05 ? -1 : 1
|
||||||
const motion: PartyRenderMotion = {
|
resetPartyRenderMotion(motion)
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
rotation: 0,
|
|
||||||
scaleX: 1,
|
|
||||||
scaleY: 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entity.health <= 0) return { ...motion, rotation: facingX * 0.08, scaleY: 0.92 }
|
if (entity.health <= 0) {
|
||||||
|
motion.rotation = facingX * 0.08
|
||||||
|
motion.scaleY = 0.92
|
||||||
|
return motion
|
||||||
|
}
|
||||||
if (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0) {
|
if (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0) {
|
||||||
return { ...motion, y: 3, rotation: -facingX * 0.12, scaleY: 0.9 }
|
motion.y = 3
|
||||||
|
motion.rotation = -facingX * 0.12
|
||||||
|
motion.scaleY = 0.9
|
||||||
|
return motion
|
||||||
}
|
}
|
||||||
|
|
||||||
const attackProgress = attackPulse ? attackProgressFor(entity, timeSeconds, attackPulse) : 1
|
const attackProgress = attackPulse ? attackProgressFor(entity, timeSeconds, attackPulse) : 1
|
||||||
if (attackProgress < 1) {
|
if (attackProgress < 1) {
|
||||||
const strike = Math.sin(attackProgress * Math.PI)
|
const strike = Math.sin(attackProgress * Math.PI)
|
||||||
const ranged = entity.projectileSpeed > 0
|
const ranged = entity.projectileSpeed > 0
|
||||||
return {
|
motion.x = facingX * (ranged ? -3 : 4) * strike
|
||||||
...motion,
|
motion.y = -1.5 * strike
|
||||||
x: facingX * (ranged ? -3 : 4) * strike,
|
motion.rotation = facingX * (ranged ? -0.05 : 0.09) * strike
|
||||||
y: -1.5 * strike,
|
motion.scaleX = 1 + (ranged ? 0.012 : 0.035) * strike
|
||||||
rotation: facingX * (ranged ? -0.05 : 0.09) * strike,
|
motion.scaleY = 1 - (ranged ? 0.006 : 0.018) * strike
|
||||||
scaleX: 1 + (ranged ? 0.012 : 0.035) * strike,
|
motion.tint = ranged ? 0xdff3ff : 0xffe1a6
|
||||||
scaleY: 1 - (ranged ? 0.006 : 0.018) * strike,
|
return motion
|
||||||
tint: ranged ? 0xdff3ff : 0xffe1a6,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entity.castSecondsRemaining > 0) {
|
if (entity.castSecondsRemaining > 0) {
|
||||||
const pulse = 0.5 + Math.sin(timeSeconds * Math.PI * 12) * 0.5
|
const pulse = 0.5 + Math.sin(timeSeconds * Math.PI * 12) * 0.5
|
||||||
return {
|
motion.y = -1 - pulse * 1.5
|
||||||
...motion,
|
motion.scaleX = 1 + pulse * 0.012
|
||||||
y: -1 - pulse * 1.5,
|
motion.scaleY = 1 + pulse * 0.012
|
||||||
scaleX: 1 + pulse * 0.012,
|
motion.tint = entity.classId === 'mage' ? 0xefc4ff : 0xb9e3ff
|
||||||
scaleY: 1 + pulse * 0.012,
|
return motion
|
||||||
tint: entity.classId === 'mage' ? 0xefc4ff : 0xb9e3ff,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (speed > 10) {
|
if (speed > 10) {
|
||||||
const stride = Math.sin(timeSeconds * Math.PI * 9)
|
const stride = Math.sin(timeSeconds * Math.PI * 9)
|
||||||
const lift = Math.abs(stride) * Math.min(2.8, speed / 90)
|
const lift = Math.abs(stride) * Math.min(2.8, speed / 90)
|
||||||
return {
|
motion.y = -lift
|
||||||
...motion,
|
motion.rotation = facingX * 0.025 * Math.min(1, speed / 190)
|
||||||
y: -lift,
|
motion.scaleY = 1 - Math.abs(stride) * 0.01
|
||||||
rotation: facingX * 0.025 * Math.min(1, speed / 190),
|
return motion
|
||||||
scaleY: 1 - Math.abs(stride) * 0.01,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const idle = Math.sin(timeSeconds * Math.PI * 2.2)
|
const idle = Math.sin(timeSeconds * Math.PI * 2.2)
|
||||||
return {
|
motion.y = -Math.max(0, idle) * 0.9
|
||||||
...motion,
|
motion.scaleY = 1 + Math.max(0, idle) * 0.008
|
||||||
y: -Math.max(0, idle) * 0.9,
|
return motion
|
||||||
scaleY: 1 + Math.max(0, idle) * 0.008,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isPartyAttackPulseActive(
|
export function isPartyAttackPulseActive(
|
||||||
@@ -101,17 +95,41 @@ export function partyWeaponLayerMotion(
|
|||||||
layer: Iwt2ClassWeaponLayer,
|
layer: Iwt2ClassWeaponLayer,
|
||||||
timeSeconds: number,
|
timeSeconds: number,
|
||||||
attackPulse?: PartyAttackPulse,
|
attackPulse?: PartyAttackPulse,
|
||||||
|
motion: PartyWeaponLayerMotion = createPartyWeaponLayerMotion(),
|
||||||
): PartyWeaponLayerMotion {
|
): PartyWeaponLayerMotion {
|
||||||
if (!attackPulse || entity.health <= 0) return { offsetXScale: 0, offsetYScale: 0, rotation: 0 }
|
resetPartyWeaponLayerMotion(motion)
|
||||||
|
if (!attackPulse || entity.health <= 0) return motion
|
||||||
const progress = attackProgressFor(entity, timeSeconds, attackPulse)
|
const progress = attackProgressFor(entity, timeSeconds, attackPulse)
|
||||||
if (progress >= 1) return { offsetXScale: 0, offsetYScale: 0, rotation: 0 }
|
if (progress >= 1) return motion
|
||||||
const strike = Math.sin(progress * Math.PI)
|
const strike = Math.sin(progress * Math.PI)
|
||||||
const attackMotion = layer.attackMotion
|
const attackMotion = layer.attackMotion
|
||||||
return {
|
motion.offsetXScale = (attackMotion?.offsetXScale ?? 0) * strike
|
||||||
offsetXScale: (attackMotion?.offsetXScale ?? 0) * strike,
|
motion.offsetYScale = (attackMotion?.offsetYScale ?? 0) * strike
|
||||||
offsetYScale: (attackMotion?.offsetYScale ?? 0) * strike,
|
motion.rotation = (attackMotion?.rotation ?? 0) * strike
|
||||||
rotation: (attackMotion?.rotation ?? 0) * strike,
|
return motion
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createPartyRenderMotion(): PartyRenderMotion {
|
||||||
|
return { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPartyRenderMotion(motion: PartyRenderMotion) {
|
||||||
|
motion.x = 0
|
||||||
|
motion.y = 0
|
||||||
|
motion.rotation = 0
|
||||||
|
motion.scaleX = 1
|
||||||
|
motion.scaleY = 1
|
||||||
|
motion.tint = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPartyWeaponLayerMotion(): PartyWeaponLayerMotion {
|
||||||
|
return { offsetXScale: 0, offsetYScale: 0, rotation: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPartyWeaponLayerMotion(motion: PartyWeaponLayerMotion) {
|
||||||
|
motion.offsetXScale = 0
|
||||||
|
motion.offsetYScale = 0
|
||||||
|
motion.rotation = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function attackProgressFor(
|
function attackProgressFor(
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import Phaser from 'phaser'
|
|||||||
import type { MovementVector } from '../../../../input'
|
import type { MovementVector } from '../../../../input'
|
||||||
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../../content/bosses'
|
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../../content/bosses'
|
||||||
import { IWT2_CLASS_METADATA, type Iwt2PlayerClassId } from '../../content/classes'
|
import { IWT2_CLASS_METADATA, type Iwt2PlayerClassId } from '../../content/classes'
|
||||||
import { bossRenderMotion } from '../bossAnimation'
|
import { bossRenderMotion, type BossRenderMotion } from '../bossAnimation'
|
||||||
import {
|
import {
|
||||||
isPartyAttackPulseActive,
|
isPartyAttackPulseActive,
|
||||||
partyRenderMotion,
|
partyRenderMotion,
|
||||||
partyWeaponLayerMotion,
|
partyWeaponLayerMotion,
|
||||||
type PartyAttackPulse,
|
type PartyAttackPulse,
|
||||||
|
type PartyRenderMotion,
|
||||||
|
type PartyWeaponLayerMotion,
|
||||||
} from '../partyAnimation'
|
} from '../partyAnimation'
|
||||||
import type {
|
import type {
|
||||||
Iwt2ArenaEvent,
|
Iwt2ArenaEvent,
|
||||||
@@ -41,10 +43,15 @@ type PartyLayerSprites = {
|
|||||||
|
|
||||||
export class BulldromeArenaScene extends Phaser.Scene {
|
export class BulldromeArenaScene extends Phaser.Scene {
|
||||||
private deps: SceneDeps
|
private deps: SceneDeps
|
||||||
|
private drawableEntities: DrawableEntity[] = []
|
||||||
private arenaGraphics?: Phaser.GameObjects.Graphics
|
private arenaGraphics?: Phaser.GameObjects.Graphics
|
||||||
private bossUnderlayGraphics?: Phaser.GameObjects.Graphics
|
private bossUnderlayGraphics?: Phaser.GameObjects.Graphics
|
||||||
private entityGraphics?: Phaser.GameObjects.Graphics
|
private entityGraphics?: Phaser.GameObjects.Graphics
|
||||||
private telegraphGraphics?: Phaser.GameObjects.Graphics
|
private telegraphGraphics?: Phaser.GameObjects.Graphics
|
||||||
|
private liveBossIds = new Set<string>()
|
||||||
|
private liveLabelIds = new Set<string>()
|
||||||
|
private livePartyIds = new Set<string>()
|
||||||
|
private liveProjectileIds = new Set<string>()
|
||||||
private labels = new Map<string, Phaser.GameObjects.Text>()
|
private labels = new Map<string, Phaser.GameObjects.Text>()
|
||||||
private bossSprites = new Map<string, Phaser.GameObjects.Image>()
|
private bossSprites = new Map<string, Phaser.GameObjects.Image>()
|
||||||
private partySprites = new Map<string, Phaser.GameObjects.Image>()
|
private partySprites = new Map<string, Phaser.GameObjects.Image>()
|
||||||
@@ -54,11 +61,15 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
private livePartyEffectIds = new Set<string>()
|
private livePartyEffectIds = new Set<string>()
|
||||||
private floatingCombatTexts = new Map<number, Phaser.GameObjects.Text>()
|
private floatingCombatTexts = new Map<number, Phaser.GameObjects.Text>()
|
||||||
private castGlowEffects = new Set<Phaser.GameObjects.Graphics>()
|
private castGlowEffects = new Set<Phaser.GameObjects.Graphics>()
|
||||||
|
private currentEntityAnchors = new Map<string, FloatingCombatTextAnchor>()
|
||||||
private lastEntityAnchors = new Map<string, FloatingCombatTextAnchor>()
|
private lastEntityAnchors = new Map<string, FloatingCombatTextAnchor>()
|
||||||
|
private liveEntityAnchorIds = new Set<string>()
|
||||||
|
private bossMotionScratch: BossRenderMotion = { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0 }
|
||||||
|
private partyMotionScratch: PartyRenderMotion = { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0 }
|
||||||
|
private weaponMotionScratch: PartyWeaponLayerMotion = { offsetXScale: 0, offsetYScale: 0, rotation: 0 }
|
||||||
private lastPartyAnimationEventId = 0
|
private lastPartyAnimationEventId = 0
|
||||||
private lastFloatingEventId = 0
|
private lastFloatingEventId = 0
|
||||||
private lastStateTime = 0
|
private lastStateTime = 0
|
||||||
private lastHudPublish = 0
|
|
||||||
|
|
||||||
constructor(deps: SceneDeps) {
|
constructor(deps: SceneDeps) {
|
||||||
super('bulldrome-arena')
|
super('bulldrome-arena')
|
||||||
@@ -66,7 +77,9 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
preload() {
|
preload() {
|
||||||
for (const metadata of Object.values(IWT2_BOSS_METADATA)) {
|
const encounterBossIds = new Set(this.deps.getState().bosses.map((boss) => boss.bossId))
|
||||||
|
for (const bossId of encounterBossIds) {
|
||||||
|
const metadata = IWT2_BOSS_METADATA[bossId]
|
||||||
if (metadata.spriteUrl.endsWith('.svg')) {
|
if (metadata.spriteUrl.endsWith('.svg')) {
|
||||||
this.load.svg(bossSpriteKey(metadata.id), metadata.spriteUrl, { width: 144, height: 144 })
|
this.load.svg(bossSpriteKey(metadata.id), metadata.spriteUrl, { width: 144, height: 144 })
|
||||||
} else {
|
} else {
|
||||||
@@ -92,7 +105,9 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
this.telegraphGraphics = this.add.graphics().setDepth(10)
|
this.telegraphGraphics = this.add.graphics().setDepth(10)
|
||||||
this.bossUnderlayGraphics = this.add.graphics().setDepth(20)
|
this.bossUnderlayGraphics = this.add.graphics().setDepth(20)
|
||||||
this.entityGraphics = this.add.graphics().setDepth(35)
|
this.entityGraphics = this.add.graphics().setDepth(35)
|
||||||
this.drawState(this.deps.getState())
|
const state = this.deps.getState()
|
||||||
|
this.drawArena(state)
|
||||||
|
this.drawState(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
update(_: number, delta: number) {
|
update(_: number, delta: number) {
|
||||||
@@ -102,13 +117,11 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
|
|
||||||
private drawState(state: Iwt2ArenaState) {
|
private drawState(state: Iwt2ArenaState) {
|
||||||
if (!this.arenaGraphics || !this.entityGraphics || !this.telegraphGraphics) return
|
if (!this.arenaGraphics || !this.entityGraphics || !this.telegraphGraphics) return
|
||||||
this.drawArena(state)
|
|
||||||
this.drawTelegraphs(state)
|
this.drawTelegraphs(state)
|
||||||
this.updatePartyAnimations(state)
|
this.updatePartyAnimations(state)
|
||||||
this.drawEntities(state)
|
this.drawEntities(state)
|
||||||
this.drawProjectiles(state)
|
this.drawProjectiles(state)
|
||||||
this.drawFloatingCombatTexts(state)
|
this.drawFloatingCombatTexts(state)
|
||||||
this.lastHudPublish += 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private drawArena(state: Iwt2ArenaState) {
|
private drawArena(state: Iwt2ArenaState) {
|
||||||
@@ -140,12 +153,30 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
bossUnderlay.clear()
|
bossUnderlay.clear()
|
||||||
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.bosses]
|
const entities = this.drawableEntities
|
||||||
const liveLabelIds = new Set<string>(entities.filter((entity) => shouldDrawLabel(entity)).map((entity) => entity.id))
|
const liveBossIds = this.liveBossIds
|
||||||
const liveBossIds = new Set<string>(state.bosses.map((boss) => boss.id))
|
const liveLabelIds = this.liveLabelIds
|
||||||
const livePartyIds = new Set<string>(state.party.map((member) => member.id))
|
const livePartyIds = this.livePartyIds
|
||||||
|
entities.length = 0
|
||||||
|
liveBossIds.clear()
|
||||||
|
liveLabelIds.clear()
|
||||||
|
livePartyIds.clear()
|
||||||
|
for (const boss of state.bosses) {
|
||||||
|
entities.push(boss)
|
||||||
|
liveBossIds.add(boss.id)
|
||||||
|
if (shouldDrawLabel(boss)) liveLabelIds.add(boss.id)
|
||||||
|
}
|
||||||
|
for (const add of state.hostileAdds) {
|
||||||
|
entities.push(add)
|
||||||
|
if (shouldDrawLabel(add)) liveLabelIds.add(add.id)
|
||||||
|
}
|
||||||
|
for (const member of state.party) {
|
||||||
|
entities.push(member)
|
||||||
|
livePartyIds.add(member.id)
|
||||||
|
if (shouldDrawLabel(member)) liveLabelIds.add(member.id)
|
||||||
|
}
|
||||||
|
|
||||||
for (const entity of entities.sort(entitySort)) {
|
for (const entity of entities) {
|
||||||
const stunned = entity.kind === 'party' && (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0)
|
const stunned = entity.kind === 'party' && (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0)
|
||||||
const alpha = entity.health <= 0 ? 0.35 : stunned ? 0.55 : 1
|
const alpha = entity.health <= 0 ? 0.35 : stunned ? 0.55 : 1
|
||||||
const color = entityColor(entity)
|
const color = entityColor(entity)
|
||||||
@@ -232,8 +263,10 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
|
|
||||||
private drawProjectiles(state: Iwt2ArenaState) {
|
private drawProjectiles(state: Iwt2ArenaState) {
|
||||||
const graphics = this.entityGraphics!
|
const graphics = this.entityGraphics!
|
||||||
const liveProjectileIds = new Set(state.projectiles.map((projectile) => projectile.id))
|
const liveProjectileIds = this.liveProjectileIds
|
||||||
|
liveProjectileIds.clear()
|
||||||
for (const projectile of state.projectiles) {
|
for (const projectile of state.projectiles) {
|
||||||
|
liveProjectileIds.add(projectile.id)
|
||||||
const spriteKey = projectileSpriteKey(projectile.projectileKind)
|
const spriteKey = projectileSpriteKey(projectile.projectileKind)
|
||||||
if (this.textures.exists(spriteKey)) {
|
if (this.textures.exists(spriteKey)) {
|
||||||
let sprite = this.projectileSprites.get(projectile.id)
|
let sprite = this.projectileSprites.get(projectile.id)
|
||||||
@@ -244,7 +277,6 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
const velocityAngle = Math.atan2(projectile.velocity.y, projectile.velocity.x)
|
const velocityAngle = Math.atan2(projectile.velocity.y, projectile.velocity.x)
|
||||||
const size = projectileDisplaySize(projectile.projectileKind, projectile.radius)
|
const size = projectileDisplaySize(projectile.projectileKind, projectile.radius)
|
||||||
sprite
|
sprite
|
||||||
.setTexture(spriteKey)
|
|
||||||
.setPosition(projectile.position.x, projectile.position.y)
|
.setPosition(projectile.position.x, projectile.position.y)
|
||||||
.setRotation(velocityAngle)
|
.setRotation(velocityAngle)
|
||||||
.setDisplaySize(size.width, size.height)
|
.setDisplaySize(size.width, size.height)
|
||||||
@@ -280,14 +312,13 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
const width = entity.radius * (metadata.spriteWidthScale ?? 3.35)
|
const width = entity.radius * (metadata.spriteWidthScale ?? 3.35)
|
||||||
const height = entity.radius * (metadata.spriteHeightScale ?? 3.35)
|
const height = entity.radius * (metadata.spriteHeightScale ?? 3.35)
|
||||||
const yOffset = entity.radius * (metadata.spriteYOffsetScale ?? 0)
|
const yOffset = entity.radius * (metadata.spriteYOffsetScale ?? 0)
|
||||||
const motion = bossRenderMotion(entity, timeSeconds)
|
const motion = bossRenderMotion(entity, timeSeconds, this.bossMotionScratch)
|
||||||
if (motion.tint) {
|
if (motion.tint) {
|
||||||
sprite.setTint(motion.tint)
|
sprite.setTint(motion.tint)
|
||||||
} else {
|
} else {
|
||||||
sprite.clearTint()
|
sprite.clearTint()
|
||||||
}
|
}
|
||||||
sprite
|
sprite
|
||||||
.setTexture(key)
|
|
||||||
.setPosition(entity.position.x + motion.x, entity.position.y + yOffset + motion.y)
|
.setPosition(entity.position.x + motion.x, entity.position.y + yOffset + motion.y)
|
||||||
.setDisplaySize(width * motion.scaleX, height * motion.scaleY)
|
.setDisplaySize(width * motion.scaleX, height * motion.scaleY)
|
||||||
.setRotation(rotation + motion.rotation)
|
.setRotation(rotation + motion.rotation)
|
||||||
@@ -314,10 +345,9 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseHeight = entity.radius * (metadata.bodyHeightScale ?? 4.55)
|
const baseHeight = entity.radius * (metadata.bodyHeightScale ?? 4.55)
|
||||||
const motion = partyRenderMotion(entity, timeSeconds, this.partyAttackPulses.get(entity.id))
|
const motion = partyRenderMotion(entity, timeSeconds, this.partyAttackPulses.get(entity.id), this.partyMotionScratch)
|
||||||
applyMotionTint(sprites.body, motion.tint)
|
applyMotionTint(sprites.body, motion.tint)
|
||||||
sprites.body
|
sprites.body
|
||||||
.setTexture(classArenaBodySpriteKey(entity.classId))
|
|
||||||
.setPosition(entity.position.x + motion.x, entity.position.y + entity.radius * 0.18 + motion.y)
|
.setPosition(entity.position.x + motion.x, entity.position.y + entity.radius * 0.18 + motion.y)
|
||||||
.setDisplaySize(baseHeight * (sprites.body.width / Math.max(1, sprites.body.height)) * motion.scaleX, baseHeight * motion.scaleY)
|
.setDisplaySize(baseHeight * (sprites.body.width / Math.max(1, sprites.body.height)) * motion.scaleX, baseHeight * motion.scaleY)
|
||||||
.setRotation(motion.rotation)
|
.setRotation(motion.rotation)
|
||||||
@@ -335,7 +365,13 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
const flipX = entity.facing.x < -0.05
|
const flipX = entity.facing.x < -0.05
|
||||||
const facingSign = flipX ? -1 : 1
|
const facingSign = flipX ? -1 : 1
|
||||||
const layerMotion = partyWeaponLayerMotion(entity, layer, timeSeconds, this.partyAttackPulses.get(entity.id))
|
const layerMotion = partyWeaponLayerMotion(
|
||||||
|
entity,
|
||||||
|
layer,
|
||||||
|
timeSeconds,
|
||||||
|
this.partyAttackPulses.get(entity.id),
|
||||||
|
this.weaponMotionScratch,
|
||||||
|
)
|
||||||
const layerHeight = entity.radius * layer.heightScale
|
const layerHeight = entity.radius * layer.heightScale
|
||||||
const positionX = entity.position.x + motion.x + facingSign * entity.radius * (layer.offsetXScale + layerMotion.offsetXScale)
|
const positionX = entity.position.x + motion.x + facingSign * entity.radius * (layer.offsetXScale + layerMotion.offsetXScale)
|
||||||
const positionY = entity.position.y + entity.radius * 0.18 + motion.y + entity.radius * (layer.offsetYScale + layerMotion.offsetYScale)
|
const positionY = entity.position.y + entity.radius * 0.18 + motion.y + entity.radius * (layer.offsetYScale + layerMotion.offsetYScale)
|
||||||
@@ -358,7 +394,6 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
})
|
})
|
||||||
applyMotionTint(sprite, motion.tint)
|
applyMotionTint(sprite, motion.tint)
|
||||||
sprite
|
sprite
|
||||||
.setTexture(key)
|
|
||||||
.setDepth(layer.drawOrder === 'behindBody' ? 38 : 42)
|
.setDepth(layer.drawOrder === 'behindBody' ? 38 : 42)
|
||||||
.setPosition(positionX, positionY)
|
.setPosition(positionX, positionY)
|
||||||
.setDisplaySize(displayWidth, displayHeight)
|
.setDisplaySize(displayWidth, displayHeight)
|
||||||
@@ -420,7 +455,6 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
accent
|
accent
|
||||||
.setVisible(true)
|
.setVisible(true)
|
||||||
.setTexture(key)
|
|
||||||
.setDepth(layer.drawOrder === 'behindBody' ? 37 : 41)
|
.setDepth(layer.drawOrder === 'behindBody' ? 37 : 41)
|
||||||
.setPosition(positionX, positionY)
|
.setPosition(positionX, positionY)
|
||||||
.setDisplaySize(displayWidth * 1.08, displayHeight * 1.12)
|
.setDisplaySize(displayWidth * 1.08, displayHeight * 1.12)
|
||||||
@@ -446,14 +480,13 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const displayHeight = entity.radius * 4.55
|
const displayHeight = entity.radius * 4.55
|
||||||
const motion = partyRenderMotion(entity, timeSeconds, this.partyAttackPulses.get(entity.id))
|
const motion = partyRenderMotion(entity, timeSeconds, this.partyAttackPulses.get(entity.id), this.partyMotionScratch)
|
||||||
if (motion.tint) {
|
if (motion.tint) {
|
||||||
sprite.setTint(motion.tint)
|
sprite.setTint(motion.tint)
|
||||||
} else {
|
} else {
|
||||||
sprite.clearTint()
|
sprite.clearTint()
|
||||||
}
|
}
|
||||||
sprite
|
sprite
|
||||||
.setTexture(key)
|
|
||||||
.setPosition(entity.position.x + motion.x, entity.position.y + entity.radius * 0.18 + motion.y)
|
.setPosition(entity.position.x + motion.x, entity.position.y + entity.radius * 0.18 + motion.y)
|
||||||
.setDisplaySize(
|
.setDisplaySize(
|
||||||
displayHeight * (sprite.width / Math.max(1, sprite.height)) * motion.scaleX,
|
displayHeight * (sprite.width / Math.max(1, sprite.height)) * motion.scaleX,
|
||||||
@@ -541,6 +574,8 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private drawFloatingCombatTexts(state: Iwt2ArenaState) {
|
private drawFloatingCombatTexts(state: Iwt2ArenaState) {
|
||||||
|
const currentEntityAnchors = this.currentEntityAnchors
|
||||||
|
updateEntityAnchors(currentEntityAnchors, this.liveEntityAnchorIds, state)
|
||||||
if (state.time < this.lastStateTime) {
|
if (state.time < this.lastStateTime) {
|
||||||
this.lastFloatingEventId = 0
|
this.lastFloatingEventId = 0
|
||||||
for (const text of this.floatingCombatTexts.values()) text.destroy()
|
for (const text of this.floatingCombatTexts.values()) text.destroy()
|
||||||
@@ -557,17 +592,21 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
this.lastFloatingEventId = Math.max(this.lastFloatingEventId, event.id)
|
this.lastFloatingEventId = Math.max(this.lastFloatingEventId, event.id)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
this.spawnFloatingCombatText(event, state)
|
this.spawnFloatingCombatText(event, currentEntityAnchors)
|
||||||
this.lastFloatingEventId = Math.max(this.lastFloatingEventId, event.id)
|
this.lastFloatingEventId = Math.max(this.lastFloatingEventId, event.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastStateTime = state.time
|
this.lastStateTime = state.time
|
||||||
this.lastEntityAnchors = entityAnchors(state)
|
this.currentEntityAnchors = this.lastEntityAnchors
|
||||||
|
this.lastEntityAnchors = currentEntityAnchors
|
||||||
}
|
}
|
||||||
|
|
||||||
private spawnFloatingCombatText(event: Iwt2ArenaEvent, state: Iwt2ArenaState) {
|
private spawnFloatingCombatText(
|
||||||
|
event: Iwt2ArenaEvent,
|
||||||
|
currentEntityAnchors: Map<string, FloatingCombatTextAnchor>,
|
||||||
|
) {
|
||||||
const anchor = event.targetId
|
const anchor = event.targetId
|
||||||
? entityAnchors(state).get(event.targetId) ?? this.lastEntityAnchors.get(event.targetId)
|
? currentEntityAnchors.get(event.targetId) ?? this.lastEntityAnchors.get(event.targetId)
|
||||||
: undefined
|
: undefined
|
||||||
if (!anchor || !event.value || event.value <= 0) return
|
if (!anchor || !event.value || event.value <= 0) return
|
||||||
|
|
||||||
@@ -630,11 +669,6 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function entitySort(a: DrawableEntity, b: DrawableEntity): number {
|
|
||||||
const rank = { boss: 0, hostileAdd: 1, party: 2 } satisfies Record<DrawableEntity['kind'], number>
|
|
||||||
return rank[a.kind] - rank[b.kind]
|
|
||||||
}
|
|
||||||
|
|
||||||
function entityColor(entity: DrawableEntity): string {
|
function entityColor(entity: DrawableEntity): string {
|
||||||
if (entity.kind === 'boss') return IWT2_BOSS_METADATA[entity.bossId].color
|
if (entity.kind === 'boss') return IWT2_BOSS_METADATA[entity.bossId].color
|
||||||
if (entity.kind === 'hostileAdd') return '#f0b84f'
|
if (entity.kind === 'hostileAdd') return '#f0b84f'
|
||||||
@@ -710,16 +744,38 @@ function shouldSpawnFloatingCombatText(event: Iwt2ArenaEvent): boolean {
|
|||||||
&& event.targetId !== undefined
|
&& event.targetId !== undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function entityAnchors(state: Iwt2ArenaState): Map<string, FloatingCombatTextAnchor> {
|
function updateEntityAnchors(
|
||||||
const anchors = new Map<string, FloatingCombatTextAnchor>()
|
anchors: Map<string, FloatingCombatTextAnchor>,
|
||||||
for (const entity of [...state.party, ...state.hostileAdds, ...state.bosses]) {
|
liveIds: Set<string>,
|
||||||
|
state: Iwt2ArenaState,
|
||||||
|
) {
|
||||||
|
liveIds.clear()
|
||||||
|
for (const entity of state.party) updateEntityAnchor(anchors, liveIds, entity)
|
||||||
|
for (const entity of state.hostileAdds) updateEntityAnchor(anchors, liveIds, entity)
|
||||||
|
for (const entity of state.bosses) updateEntityAnchor(anchors, liveIds, entity)
|
||||||
|
for (const id of anchors.keys()) {
|
||||||
|
if (!liveIds.has(id)) anchors.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEntityAnchor(
|
||||||
|
anchors: Map<string, FloatingCombatTextAnchor>,
|
||||||
|
liveIds: Set<string>,
|
||||||
|
entity: DrawableEntity,
|
||||||
|
) {
|
||||||
|
liveIds.add(entity.id)
|
||||||
|
const anchor = anchors.get(entity.id)
|
||||||
|
if (!anchor) {
|
||||||
anchors.set(entity.id, {
|
anchors.set(entity.id, {
|
||||||
kind: entity.kind,
|
kind: entity.kind,
|
||||||
position: { ...entity.position },
|
position: entity.position,
|
||||||
radius: entity.radius,
|
radius: entity.radius,
|
||||||
})
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
return anchors
|
anchor.kind = entity.kind
|
||||||
|
anchor.position = entity.position
|
||||||
|
anchor.radius = entity.radius
|
||||||
}
|
}
|
||||||
|
|
||||||
function floatingTextYOffset(anchor: FloatingCombatTextAnchor): number {
|
function floatingTextYOffset(anchor: FloatingCombatTextAnchor): number {
|
||||||
@@ -792,9 +848,7 @@ function drawIndicator(graphics: Phaser.GameObjects.Graphics, indicator: Iwt2Are
|
|||||||
graphics.fillStyle(color, indicator.fillAlpha ?? (active ? 0.16 : 0.09))
|
graphics.fillStyle(color, indicator.fillAlpha ?? (active ? 0.16 : 0.09))
|
||||||
|
|
||||||
if (indicator.kind === 'lane') {
|
if (indicator.kind === 'lane') {
|
||||||
const points = laneDangerPolygon(indicator.start, indicator.end, indicator.width)
|
drawLaneDangerPath(graphics, indicator.start, indicator.end, indicator.width)
|
||||||
graphics.fillPoints(points, true)
|
|
||||||
graphics.strokePoints(points, true)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -805,27 +859,25 @@ function drawIndicator(graphics: Phaser.GameObjects.Graphics, indicator: Iwt2Are
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (indicator.kind === 'cone') {
|
if (indicator.kind === 'cone') {
|
||||||
const points = coneDangerPolygon(
|
drawConeDangerPath(
|
||||||
|
graphics,
|
||||||
indicator.origin,
|
indicator.origin,
|
||||||
indicator.direction,
|
indicator.direction,
|
||||||
indicator.range,
|
indicator.range,
|
||||||
indicator.angleRadians,
|
indicator.angleRadians,
|
||||||
)
|
)
|
||||||
graphics.fillPoints(points, true)
|
|
||||||
graphics.strokePoints(points, true)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (indicator.kind === 'arc') {
|
if (indicator.kind === 'arc') {
|
||||||
const points = arcDangerPolygon(
|
drawArcDangerPath(
|
||||||
|
graphics,
|
||||||
indicator.position,
|
indicator.position,
|
||||||
indicator.direction,
|
indicator.direction,
|
||||||
indicator.innerRadius,
|
indicator.innerRadius,
|
||||||
indicator.outerRadius,
|
indicator.outerRadius,
|
||||||
indicator.angleRadians,
|
indicator.angleRadians,
|
||||||
)
|
)
|
||||||
graphics.fillPoints(points, true)
|
|
||||||
graphics.strokePoints(points, true)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -835,7 +887,8 @@ function drawIndicator(graphics: Phaser.GameObjects.Graphics, indicator: Iwt2Are
|
|||||||
graphics.strokeCircle(indicator.position.x, indicator.position.y, indicator.innerRadius)
|
graphics.strokeCircle(indicator.position.x, indicator.position.y, indicator.innerRadius)
|
||||||
}
|
}
|
||||||
|
|
||||||
function arcDangerPolygon(
|
function drawArcDangerPath(
|
||||||
|
graphics: Phaser.GameObjects.Graphics,
|
||||||
position: Iwt2Vec2,
|
position: Iwt2Vec2,
|
||||||
direction: Iwt2Vec2,
|
direction: Iwt2Vec2,
|
||||||
innerRadius: number,
|
innerRadius: number,
|
||||||
@@ -845,43 +898,50 @@ function arcDangerPolygon(
|
|||||||
const baseAngle = Math.atan2(direction.y, direction.x)
|
const baseAngle = Math.atan2(direction.y, direction.x)
|
||||||
const halfAngle = angleRadians / 2
|
const halfAngle = angleRadians / 2
|
||||||
const segmentCount = 18
|
const segmentCount = 18
|
||||||
const points: Phaser.Math.Vector2[] = []
|
graphics.beginPath()
|
||||||
for (let index = 0; index <= segmentCount; index += 1) {
|
for (let index = 0; index <= segmentCount; index += 1) {
|
||||||
const t = index / segmentCount
|
const t = index / segmentCount
|
||||||
const angle = baseAngle - halfAngle + angleRadians * t
|
const angle = baseAngle - halfAngle + angleRadians * t
|
||||||
points.push(new Phaser.Math.Vector2(
|
const x = position.x + Math.cos(angle) * outerRadius
|
||||||
position.x + Math.cos(angle) * outerRadius,
|
const y = position.y + Math.sin(angle) * outerRadius
|
||||||
position.y + Math.sin(angle) * outerRadius,
|
if (index === 0) graphics.moveTo(x, y)
|
||||||
))
|
else graphics.lineTo(x, y)
|
||||||
}
|
}
|
||||||
for (let index = segmentCount; index >= 0; index -= 1) {
|
for (let index = segmentCount; index >= 0; index -= 1) {
|
||||||
const t = index / segmentCount
|
const t = index / segmentCount
|
||||||
const angle = baseAngle - halfAngle + angleRadians * t
|
const angle = baseAngle - halfAngle + angleRadians * t
|
||||||
points.push(new Phaser.Math.Vector2(
|
graphics.lineTo(
|
||||||
position.x + Math.cos(angle) * innerRadius,
|
position.x + Math.cos(angle) * innerRadius,
|
||||||
position.y + Math.sin(angle) * innerRadius,
|
position.y + Math.sin(angle) * innerRadius,
|
||||||
))
|
)
|
||||||
}
|
}
|
||||||
return points
|
graphics.closePath().fillPath().strokePath()
|
||||||
}
|
}
|
||||||
|
|
||||||
function laneDangerPolygon(start: Iwt2Vec2, end: Iwt2Vec2, halfWidth: number) {
|
function drawLaneDangerPath(
|
||||||
|
graphics: Phaser.GameObjects.Graphics,
|
||||||
|
start: Iwt2Vec2,
|
||||||
|
end: Iwt2Vec2,
|
||||||
|
halfWidth: number,
|
||||||
|
) {
|
||||||
const dx = end.x - start.x
|
const dx = end.x - start.x
|
||||||
const dy = end.y - start.y
|
const dy = end.y - start.y
|
||||||
const length = Math.max(1, Math.hypot(dx, dy))
|
const length = Math.max(1, Math.hypot(dx, dy))
|
||||||
const normal = {
|
const normalX = (-dy / length) * halfWidth
|
||||||
x: (-dy / length) * halfWidth,
|
const normalY = (dx / length) * halfWidth
|
||||||
y: (dx / length) * halfWidth,
|
graphics
|
||||||
}
|
.beginPath()
|
||||||
return [
|
.moveTo(start.x + normalX, start.y + normalY)
|
||||||
new Phaser.Math.Vector2(start.x + normal.x, start.y + normal.y),
|
.lineTo(end.x + normalX, end.y + normalY)
|
||||||
new Phaser.Math.Vector2(end.x + normal.x, end.y + normal.y),
|
.lineTo(end.x - normalX, end.y - normalY)
|
||||||
new Phaser.Math.Vector2(end.x - normal.x, end.y - normal.y),
|
.lineTo(start.x - normalX, start.y - normalY)
|
||||||
new Phaser.Math.Vector2(start.x - normal.x, start.y - normal.y),
|
.closePath()
|
||||||
]
|
.fillPath()
|
||||||
|
.strokePath()
|
||||||
}
|
}
|
||||||
|
|
||||||
function coneDangerPolygon(
|
function drawConeDangerPath(
|
||||||
|
graphics: Phaser.GameObjects.Graphics,
|
||||||
origin: Iwt2Vec2,
|
origin: Iwt2Vec2,
|
||||||
direction: Iwt2Vec2,
|
direction: Iwt2Vec2,
|
||||||
range: number,
|
range: number,
|
||||||
@@ -889,15 +949,15 @@ function coneDangerPolygon(
|
|||||||
) {
|
) {
|
||||||
const baseAngle = Math.atan2(direction.y, direction.x)
|
const baseAngle = Math.atan2(direction.y, direction.x)
|
||||||
const halfAngle = angleRadians / 2
|
const halfAngle = angleRadians / 2
|
||||||
const points = [new Phaser.Math.Vector2(origin.x, origin.y)]
|
|
||||||
const segmentCount = 14
|
const segmentCount = 14
|
||||||
|
graphics.beginPath().moveTo(origin.x, origin.y)
|
||||||
for (let index = 0; index <= segmentCount; index += 1) {
|
for (let index = 0; index <= segmentCount; index += 1) {
|
||||||
const t = index / segmentCount
|
const t = index / segmentCount
|
||||||
const angle = baseAngle - halfAngle + angleRadians * t
|
const angle = baseAngle - halfAngle + angleRadians * t
|
||||||
points.push(new Phaser.Math.Vector2(
|
graphics.lineTo(
|
||||||
origin.x + Math.cos(angle) * range,
|
origin.x + Math.cos(angle) * range,
|
||||||
origin.y + Math.sin(angle) * range,
|
origin.y + Math.sin(angle) * range,
|
||||||
))
|
)
|
||||||
}
|
}
|
||||||
return points
|
graphics.closePath().fillPath().strokePath()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,17 @@ import {
|
|||||||
import type { Iwt2BossId } from '../content/bosses'
|
import type { Iwt2BossId } from '../content/bosses'
|
||||||
import {
|
import {
|
||||||
createDefaultIwt2GearProgress,
|
createDefaultIwt2GearProgress,
|
||||||
|
IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL,
|
||||||
IWT2_GEAR_SLOTS,
|
IWT2_GEAR_SLOTS,
|
||||||
|
IWT2_MAX_GEAR_LEVEL,
|
||||||
iwt2GearUpgradeCosts,
|
iwt2GearUpgradeCosts,
|
||||||
iwt2InfusionCosts,
|
iwt2InfusionCosts,
|
||||||
isIwt2InfusionUnlocked,
|
isIwt2InfusionUnlocked,
|
||||||
|
isIwt2PassiveInfusionUnlocked,
|
||||||
type Iwt2GearLevel,
|
type Iwt2GearLevel,
|
||||||
type Iwt2GearProgress,
|
type Iwt2GearProgress,
|
||||||
type Iwt2GearSlotId,
|
type Iwt2GearSlotId,
|
||||||
|
type Iwt2PassiveInfusionId,
|
||||||
} from '../content/gear'
|
} from '../content/gear'
|
||||||
import {
|
import {
|
||||||
IWT2_INFUSION_ABILITIES,
|
IWT2_INFUSION_ABILITIES,
|
||||||
@@ -97,14 +101,20 @@ export type Iwt2OnlineSaveResult = {
|
|||||||
save: Iwt2Save | null
|
save: Iwt2Save | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const IWT2_SAVE_KEY = 'i-want-to-heal-2:save:v1'
|
export const IWT2_LOCAL_SAVE_SLOTS = [1, 2, 3] as const
|
||||||
|
export type Iwt2LocalSaveSlot = (typeof IWT2_LOCAL_SAVE_SLOTS)[number]
|
||||||
|
export type Iwt2LocalSaveSlots = Record<Iwt2LocalSaveSlot, Iwt2Save | null>
|
||||||
|
|
||||||
export function createDefaultIwt2Save(): Iwt2Save {
|
const IWT2_LEGACY_SAVE_KEY = 'i-want-to-heal-2:save:v1'
|
||||||
|
const IWT2_SAVE_MIGRATION_KEY = 'i-want-to-heal-2:save-slots:migrated:v1'
|
||||||
|
const IWT2_ACTIVE_SAVE_SLOT_KEY = 'i-want-to-heal-2:active-save-slot:v1'
|
||||||
|
|
||||||
|
export function createDefaultIwt2Save(characterName = 'Healer'): Iwt2Save {
|
||||||
return {
|
return {
|
||||||
version: 2,
|
version: 2,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
character: {
|
character: {
|
||||||
name: 'Healer',
|
name: normalizeCharacterName(characterName),
|
||||||
level: 1,
|
level: 1,
|
||||||
experience: 0,
|
experience: 0,
|
||||||
healerStyle: 'dawnweaver',
|
healerStyle: 'dawnweaver',
|
||||||
@@ -126,7 +136,11 @@ function normalizeSave(value: unknown): Iwt2Save {
|
|||||||
if (candidate.version !== 1 && candidate.version !== 2) return createDefaultIwt2Save()
|
if (candidate.version !== 1 && candidate.version !== 2) return createDefaultIwt2Save()
|
||||||
return {
|
return {
|
||||||
version: 2,
|
version: 2,
|
||||||
updatedAt: typeof candidate.updatedAt === 'number' ? candidate.updatedAt : Date.now(),
|
updatedAt: typeof candidate.updatedAt === 'number'
|
||||||
|
&& Number.isFinite(candidate.updatedAt)
|
||||||
|
&& candidate.updatedAt > 0
|
||||||
|
? candidate.updatedAt
|
||||||
|
: Date.now(),
|
||||||
character: {
|
character: {
|
||||||
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)),
|
||||||
@@ -145,11 +159,51 @@ function normalizeSave(value: unknown): Iwt2Save {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadIwt2Save(): Iwt2Save {
|
function iwt2SaveSlotKey(slot: Iwt2LocalSaveSlot) {
|
||||||
|
return `i-want-to-heal-2:save:slot-${slot}:v1`
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateLegacyIwt2Save() {
|
||||||
|
if (window.localStorage.getItem(IWT2_SAVE_MIGRATION_KEY)) return
|
||||||
|
const legacySave = window.localStorage.getItem(IWT2_LEGACY_SAVE_KEY)
|
||||||
|
const firstSlotKey = iwt2SaveSlotKey(1)
|
||||||
|
if (legacySave && !window.localStorage.getItem(firstSlotKey)) {
|
||||||
|
window.localStorage.setItem(firstSlotKey, legacySave)
|
||||||
|
}
|
||||||
|
window.localStorage.setItem(IWT2_SAVE_MIGRATION_KEY, 'true')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activeIwt2SaveSlot(): Iwt2LocalSaveSlot {
|
||||||
|
const stored = Number(window.localStorage.getItem(IWT2_ACTIVE_SAVE_SLOT_KEY))
|
||||||
|
return IWT2_LOCAL_SAVE_SLOTS.includes(stored as Iwt2LocalSaveSlot)
|
||||||
|
? stored as Iwt2LocalSaveSlot
|
||||||
|
: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectActiveIwt2SaveSlot(slot: Iwt2LocalSaveSlot) {
|
||||||
|
window.localStorage.setItem(IWT2_ACTIVE_SAVE_SLOT_KEY, String(slot))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadIwt2Save(slot = activeIwt2SaveSlot()): Iwt2Save {
|
||||||
|
return loadExistingIwt2Save(slot) ?? createDefaultIwt2Save()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadExistingIwt2Save(slot = activeIwt2SaveSlot()): Iwt2Save | null {
|
||||||
|
migrateLegacyIwt2Save()
|
||||||
|
const serialized = window.localStorage.getItem(iwt2SaveSlotKey(slot))
|
||||||
|
if (!serialized) return null
|
||||||
try {
|
try {
|
||||||
return normalizeSave(JSON.parse(window.localStorage.getItem(IWT2_SAVE_KEY) ?? 'null'))
|
return normalizeSave(JSON.parse(serialized))
|
||||||
} catch {
|
} catch {
|
||||||
return createDefaultIwt2Save()
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadIwt2SaveSlots(): Iwt2LocalSaveSlots {
|
||||||
|
return {
|
||||||
|
1: loadExistingIwt2Save(1),
|
||||||
|
2: loadExistingIwt2Save(2),
|
||||||
|
3: loadExistingIwt2Save(3),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,11 +225,12 @@ export async function writeIwt2OnlineSave(save: Iwt2Save): Promise<Iwt2OnlineSav
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writeIwt2Save(save: Iwt2Save) {
|
export function writeIwt2Save(save: Iwt2Save, slot = activeIwt2SaveSlot()) {
|
||||||
window.localStorage.setItem(IWT2_SAVE_KEY, JSON.stringify({
|
replaceIwt2Save(save, slot)
|
||||||
...save,
|
}
|
||||||
updatedAt: Date.now(),
|
|
||||||
}))
|
export function replaceIwt2Save(save: Iwt2Save, slot = activeIwt2SaveSlot()) {
|
||||||
|
window.localStorage.setItem(iwt2SaveSlotKey(slot), JSON.stringify(normalizeSave(save)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function recordIwt2BossKill(
|
export function recordIwt2BossKill(
|
||||||
@@ -263,7 +318,7 @@ export function upgradeIwt2GearSlot(
|
|||||||
): Iwt2Save {
|
): Iwt2Save {
|
||||||
const classProgress = save.gearProgress[classId]
|
const classProgress = save.gearProgress[classId]
|
||||||
const slot = classProgress.slots[slotId]
|
const slot = classProgress.slots[slotId]
|
||||||
if (slot.level >= 5) throw new Error('Gear slot already at +5.')
|
if (slot.level >= IWT2_MAX_GEAR_LEVEL) throw new Error(`Gear slot already at +${IWT2_MAX_GEAR_LEVEL}.`)
|
||||||
const costs = iwt2GearUpgradeCosts(classId, slotId, slot.level)
|
const costs = iwt2GearUpgradeCosts(classId, slotId, slot.level)
|
||||||
const inventory = spendInventoryCosts(save.inventory, costs)
|
const inventory = spendInventoryCosts(save.inventory, costs)
|
||||||
return {
|
return {
|
||||||
@@ -294,8 +349,10 @@ export function setIwt2InfusionAbility(
|
|||||||
const classProgress = save.gearProgress[classId]
|
const classProgress = save.gearProgress[classId]
|
||||||
const ability = IWT2_INFUSION_ABILITIES[abilityId]
|
const ability = IWT2_INFUSION_ABILITIES[abilityId]
|
||||||
if (!ability || ability.classId !== classId) throw new Error('Ability is not available for this class.')
|
if (!ability || ability.classId !== classId) throw new Error('Ability is not available for this class.')
|
||||||
if (!isIwt2InfusionUnlocked(classProgress)) throw new Error('Upgrade any gear slot to +5 first.')
|
if (!isIwt2InfusionUnlocked(classProgress)) throw new Error(`Upgrade any gear slot to +${IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL} first.`)
|
||||||
if (classProgress.slots[slotId].level < 5) throw new Error('Select a +5 gear slot to anchor the infusion cost.')
|
if (classProgress.slots[slotId].level < IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL) {
|
||||||
|
throw new Error(`Select a +${IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL} gear slot to anchor the infusion cost.`)
|
||||||
|
}
|
||||||
if (classProgress.infusionAbilityId === abilityId) return save
|
if (classProgress.infusionAbilityId === abilityId) return save
|
||||||
const inventory = spendInventoryCosts(save.inventory, iwt2InfusionCosts(classId, slotId, abilityId))
|
const inventory = spendInventoryCosts(save.inventory, iwt2InfusionCosts(classId, slotId, abilityId))
|
||||||
return {
|
return {
|
||||||
@@ -312,6 +369,27 @@ export function setIwt2InfusionAbility(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setIwt2PassiveInfusion(
|
||||||
|
save: Iwt2Save,
|
||||||
|
passiveInfusionId: Iwt2PassiveInfusionId,
|
||||||
|
): Iwt2Save {
|
||||||
|
if (!isIwt2PassiveInfusionUnlocked(save.gearProgress)) {
|
||||||
|
throw new Error(`Upgrade any gear slot to +${IWT2_MAX_GEAR_LEVEL} first.`)
|
||||||
|
}
|
||||||
|
if (save.gearProgress.healer.passiveInfusionId === passiveInfusionId) return save
|
||||||
|
return {
|
||||||
|
...save,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
gearProgress: {
|
||||||
|
...save.gearProgress,
|
||||||
|
healer: {
|
||||||
|
...save.gearProgress.healer,
|
||||||
|
passiveInfusionId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function canAffordIwt2Costs(save: Iwt2Save, costs: Array<{ itemId: string, quantity: number }>): boolean {
|
export function canAffordIwt2Costs(save: Iwt2Save, costs: Array<{ itemId: string, quantity: number }>): boolean {
|
||||||
return costs.every((cost) => inventoryQuantity(save.inventory, cost.itemId) >= cost.quantity)
|
return costs.every((cost) => inventoryQuantity(save.inventory, cost.itemId) >= cost.quantity)
|
||||||
}
|
}
|
||||||
@@ -450,6 +528,7 @@ function normalizeGearProgress(value: unknown): Iwt2GearProgress {
|
|||||||
const classProgress = rawClassProgress as {
|
const classProgress = rawClassProgress as {
|
||||||
slots?: Partial<Record<Iwt2GearSlotId, { level?: unknown }>>
|
slots?: Partial<Record<Iwt2GearSlotId, { level?: unknown }>>
|
||||||
infusionAbilityId?: unknown
|
infusionAbilityId?: unknown
|
||||||
|
passiveInfusionId?: unknown
|
||||||
}
|
}
|
||||||
for (const slotId of IWT2_GEAR_SLOTS) {
|
for (const slotId of IWT2_GEAR_SLOTS) {
|
||||||
next[classId].slots[slotId] = {
|
next[classId].slots[slotId] = {
|
||||||
@@ -461,6 +540,10 @@ function normalizeGearProgress(value: unknown): Iwt2GearProgress {
|
|||||||
&& isIwt2InfusionUnlocked(next[classId])
|
&& isIwt2InfusionUnlocked(next[classId])
|
||||||
? infusionAbilityId as Iwt2InfusionAbilityId
|
? infusionAbilityId as Iwt2InfusionAbilityId
|
||||||
: null
|
: null
|
||||||
|
next[classId].passiveInfusionId = classId === 'healer'
|
||||||
|
&& isIwt2PassiveInfusionUnlocked(next)
|
||||||
|
? asPassiveInfusionId(classProgress.passiveInfusionId)
|
||||||
|
: null
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
@@ -470,10 +553,15 @@ function cloneGearProgress(progress: Iwt2GearProgress): Iwt2GearProgress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function asGearLevel(value: unknown): Iwt2GearLevel {
|
function asGearLevel(value: unknown): Iwt2GearLevel {
|
||||||
const level = Math.max(0, Math.min(5, Math.floor(Number(value) || 0)))
|
const level = Math.max(0, Math.min(IWT2_MAX_GEAR_LEVEL, Math.floor(Number(value) || 0)))
|
||||||
return level as Iwt2GearLevel
|
return level as Iwt2GearLevel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asPassiveInfusionId(value: unknown): Iwt2PassiveInfusionId | null {
|
||||||
|
if (typeof value !== 'string' || value === 'revive-party-members') return null
|
||||||
|
return value as Iwt2PassiveInfusionId
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeInventory(value: unknown): Iwt2InventoryItem[] {
|
function normalizeInventory(value: unknown): Iwt2InventoryItem[] {
|
||||||
if (!Array.isArray(value)) return []
|
if (!Array.isArray(value)) return []
|
||||||
const byId = new Map<string, Iwt2InventoryItem>()
|
const byId = new Map<string, Iwt2InventoryItem>()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
|
import { lazy, Suspense, 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 {
|
import {
|
||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
} from '../sim/arenaState'
|
} from '../sim/arenaState'
|
||||||
import type { Iwt2ArenaBounds, Iwt2EntityId } from '../sim'
|
import type { Iwt2ArenaBounds, Iwt2EntityId } from '../sim'
|
||||||
import { castIwt2HealerAbility } from '../sim'
|
import { castIwt2HealerAbility } from '../sim'
|
||||||
import { PhaserArena } from '../render/PhaserArena'
|
|
||||||
import {
|
import {
|
||||||
recordIwt2BossKillReward,
|
recordIwt2BossKillReward,
|
||||||
type Iwt2BossDropAward,
|
type Iwt2BossDropAward,
|
||||||
@@ -47,7 +46,12 @@ import {
|
|||||||
} from '../content/roguelike'
|
} from '../content/roguelike'
|
||||||
import { createIwt2PvpNormalizedGearProgress } from '../content/pvpGearNormalization'
|
import { createIwt2PvpNormalizedGearProgress } from '../content/pvpGearNormalization'
|
||||||
|
|
||||||
|
const PhaserArena = lazy(() => import('../render/PhaserArena').then((module) => ({
|
||||||
|
default: module.PhaserArena,
|
||||||
|
})))
|
||||||
|
|
||||||
type ArenaStatus = 'playing' | 'paused' | 'victory' | 'defeat'
|
type ArenaStatus = 'playing' | 'paused' | 'victory' | 'defeat'
|
||||||
|
type PvpResultReason = 'opponent-defeated' | null
|
||||||
type OverlayAction = 'primary' | 'requeue' | 'menu'
|
type OverlayAction = 'primary' | 'requeue' | 'menu'
|
||||||
type OverlayNavEntry = {
|
type OverlayNavEntry = {
|
||||||
action: OverlayAction
|
action: OverlayAction
|
||||||
@@ -55,10 +59,14 @@ type OverlayNavEntry = {
|
|||||||
column: number
|
column: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
|
const PAUSE_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
|
||||||
{ action: 'primary', row: 0, column: 0 },
|
{ action: 'primary', row: 0, column: 0 },
|
||||||
{ action: 'menu', row: 1, column: 0 },
|
{ action: 'menu', row: 1, column: 0 },
|
||||||
]
|
]
|
||||||
|
const RESULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
|
||||||
|
{ action: 'primary', row: 0, column: 0 },
|
||||||
|
{ action: 'menu', row: 0, column: 1 },
|
||||||
|
]
|
||||||
const PVP_RESULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
|
const PVP_RESULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
|
||||||
{ action: 'primary', row: 0, column: 0 },
|
{ action: 'primary', row: 0, column: 0 },
|
||||||
{ action: 'requeue', row: 0, column: 1 },
|
{ action: 'requeue', row: 0, column: 1 },
|
||||||
@@ -70,6 +78,7 @@ const IWT2_TOP_PARTY_RAIL_WIDTH = 172
|
|||||||
const IWT2_THOR_TOP_PARTY_RAIL_WIDTH = 154
|
const IWT2_THOR_TOP_PARTY_RAIL_WIDTH = 154
|
||||||
const IWT2_THOR_TOP_BREAKPOINT_WIDTH = 1000
|
const IWT2_THOR_TOP_BREAKPOINT_WIDTH = 1000
|
||||||
const IWT2_THOR_TOP_BREAKPOINT_HEIGHT = 620
|
const IWT2_THOR_TOP_BREAKPOINT_HEIGHT = 620
|
||||||
|
const IWT2_HUD_PUBLISH_INTERVAL_SECONDS = 0.1
|
||||||
|
|
||||||
type BossArenaScreenProps = {
|
type BossArenaScreenProps = {
|
||||||
bossId: Iwt2BossId
|
bossId: Iwt2BossId
|
||||||
@@ -82,9 +91,11 @@ type BossArenaScreenProps = {
|
|||||||
onPvpRequeue?: () => void
|
onPvpRequeue?: () => void
|
||||||
onSaveUpdated: (save: Iwt2Save) => void
|
onSaveUpdated: (save: Iwt2Save) => void
|
||||||
roguelikeRun?: {
|
roguelikeRun?: {
|
||||||
|
bossesDefeated: number
|
||||||
buffs: Iwt2RoguelikeSelfBuffId[]
|
buffs: Iwt2RoguelikeSelfBuffId[]
|
||||||
contentType: Iwt2RoguelikeContentType
|
contentType: Iwt2RoguelikeContentType
|
||||||
debuffs: Iwt2RoguelikeOpponentDebuffId[]
|
debuffs: Iwt2RoguelikeOpponentDebuffId[]
|
||||||
|
greenCoinThreshold: number
|
||||||
onVictory: () => void
|
onVictory: () => void
|
||||||
stage: number
|
stage: number
|
||||||
variant: Iwt2RoguelikeVariant
|
variant: Iwt2RoguelikeVariant
|
||||||
@@ -141,6 +152,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
))
|
))
|
||||||
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 [pvpResultReason, setPvpResultReason] = useState<PvpResultReason>(null)
|
||||||
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 [dropAwards, setDropAwards] = useState<Iwt2BossDropAward[]>([])
|
const [dropAwards, setDropAwards] = useState<Iwt2BossDropAward[]>([])
|
||||||
@@ -153,7 +165,6 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
const saveRef = useRef(save)
|
const saveRef = useRef(save)
|
||||||
const recordedKillIdsRef = useRef<Set<Iwt2BossId>>(new Set())
|
const recordedKillIdsRef = useRef<Set<Iwt2BossId>>(new Set())
|
||||||
const lastPublishTimeRef = useRef(0)
|
const lastPublishTimeRef = useRef(0)
|
||||||
const lastHudSignatureRef = useRef('')
|
|
||||||
const abilityCooldownsRef = useRef<Record<string, number>>({})
|
const abilityCooldownsRef = useRef<Record<string, number>>({})
|
||||||
const movementRef = useMovementVectorRef(status === 'playing')
|
const movementRef = useMovementVectorRef(status === 'playing')
|
||||||
const {
|
const {
|
||||||
@@ -165,14 +176,6 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
const { enabled: dualScreenEnabled } = useDualScreen()
|
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||||
const activeBindings = bindings[lastDevice]
|
const activeBindings = bindings[lastDevice]
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
stateRef.current = arenaState
|
|
||||||
}, [arenaState])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
opponentStateRef.current = opponentArenaState
|
|
||||||
}, [opponentArenaState])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
statusRef.current = status
|
statusRef.current = status
|
||||||
}, [status])
|
}, [status])
|
||||||
@@ -215,7 +218,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
: null
|
: null
|
||||||
recordedKillIdsRef.current = new Set()
|
recordedKillIdsRef.current = new Set()
|
||||||
abilityCooldownsRef.current = {}
|
abilityCooldownsRef.current = {}
|
||||||
lastHudSignatureRef.current = arenaHudSignature(next)
|
lastPublishTimeRef.current = next.time
|
||||||
stateRef.current = next
|
stateRef.current = next
|
||||||
opponentStateRef.current = nextOpponentState
|
opponentStateRef.current = nextOpponentState
|
||||||
setArenaState(next)
|
setArenaState(next)
|
||||||
@@ -224,16 +227,19 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
setDropAwards([])
|
setDropAwards([])
|
||||||
setPetAwards([])
|
setPetAwards([])
|
||||||
setSelectedOverlayAction('primary')
|
setSelectedOverlayAction('primary')
|
||||||
|
setPvpResultReason(null)
|
||||||
setStatus('playing')
|
setStatus('playing')
|
||||||
}, [activeGearProgress, arenaBounds, bossId, bossIds, combinedBossHealthScale, combinedDamageScale, pvpRoguelike, roguelikeBuffs, roguelikeContentType, roguelikeStage])
|
}, [activeGearProgress, arenaBounds, bossId, bossIds, combinedBossHealthScale, combinedDamageScale, pvpRoguelike, roguelikeBuffs, roguelikeContentType, roguelikeStage])
|
||||||
|
|
||||||
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => {
|
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>, nextPvpResultReason: PvpResultReason = null) => {
|
||||||
setSelectedOverlayAction('primary')
|
setSelectedOverlayAction('primary')
|
||||||
|
setPvpResultReason(nextPvpResultReason)
|
||||||
statusRef.current = nextStatus
|
statusRef.current = nextStatus
|
||||||
setStatus(nextStatus)
|
setStatus(nextStatus)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const resumeArena = useCallback(() => {
|
const resumeArena = useCallback(() => {
|
||||||
|
setPvpResultReason(null)
|
||||||
statusRef.current = 'playing'
|
statusRef.current = 'playing'
|
||||||
setStatus('playing')
|
setStatus('playing')
|
||||||
}, [])
|
}, [])
|
||||||
@@ -247,9 +253,12 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
const gearAbilities = activeGearProgress
|
const gearAbilities = activeGearProgress
|
||||||
? applyIwt2PveGearToHealerAbilities(baseAbilities, activeGearProgress)
|
? applyIwt2PveGearToHealerAbilities(baseAbilities, activeGearProgress)
|
||||||
: baseAbilities
|
: baseAbilities
|
||||||
|
const passiveInfusionBuffs = activeGearProgress?.healer.passiveInfusionId
|
||||||
|
? [activeGearProgress.healer.passiveInfusionId]
|
||||||
|
: []
|
||||||
return applyRoguelikeModifiers(
|
return applyRoguelikeModifiers(
|
||||||
gearAbilities,
|
gearAbilities,
|
||||||
roguelikeRun?.buffs ?? [],
|
[...passiveInfusionBuffs, ...(roguelikeRun?.buffs ?? [])],
|
||||||
roguelikeRun?.debuffs ?? [],
|
roguelikeRun?.debuffs ?? [],
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -268,7 +277,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
}
|
}
|
||||||
abilityCooldownsRef.current = nextCooldowns
|
abilityCooldownsRef.current = nextCooldowns
|
||||||
stateRef.current = result.state
|
stateRef.current = result.state
|
||||||
lastHudSignatureRef.current = arenaHudSignature(result.state)
|
lastPublishTimeRef.current = result.state.time
|
||||||
setAbilityCooldowns(nextCooldowns)
|
setAbilityCooldowns(nextCooldowns)
|
||||||
setArenaState(result.state)
|
setArenaState(result.state)
|
||||||
}, [])
|
}, [])
|
||||||
@@ -331,7 +340,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (action === 'back') {
|
if (action === 'back') {
|
||||||
if (statusRef.current === 'paused') resumeArena()
|
if (device === 'pc' && statusRef.current === 'paused') resumeArena()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -402,7 +411,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
for (const defeatedBoss of newlyDefeatedBosses) {
|
for (const defeatedBoss of newlyDefeatedBosses) {
|
||||||
nextRecordedIds.add(defeatedBoss.bossId)
|
nextRecordedIds.add(defeatedBoss.bossId)
|
||||||
const reward = recordIwt2BossKillReward(updatedSave, defeatedBoss.bossId, {
|
const reward = recordIwt2BossKillReward(updatedSave, defeatedBoss.bossId, {
|
||||||
difficultySlug,
|
difficultySlug: rewardDifficultySlugForKill(roguelikeRun, newDropAwards.length, difficultySlug),
|
||||||
experienceMultiplier,
|
experienceMultiplier,
|
||||||
})
|
})
|
||||||
updatedSave = reward.save
|
updatedSave = reward.save
|
||||||
@@ -416,25 +425,26 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
onSaveUpdated(updatedSave)
|
onSaveUpdated(updatedSave)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (next.bosses.every((boss) => boss.health <= 0)) {
|
const playerCleared = next.bosses.every((boss) => boss.health <= 0)
|
||||||
if (pvpRoguelike && roguelikeRun) {
|
const playerDefeated = next.party.every((member) => member.health <= 0)
|
||||||
statusRef.current = 'victory'
|
const opponentCleared = Boolean(nextOpponentState?.bosses.every((boss) => boss.health <= 0))
|
||||||
setStatus('victory')
|
const opponentDefeated = Boolean(nextOpponentState?.party.every((member) => member.health <= 0))
|
||||||
roguelikeRun.onVictory()
|
if (!pvpRoguelike && playerCleared) {
|
||||||
} else {
|
showOverlay('victory')
|
||||||
showOverlay('victory')
|
} else if (pvpRoguelike && playerDefeated) {
|
||||||
}
|
showOverlay('defeat')
|
||||||
} else if (next.party.every((member) => member.health <= 0)) {
|
} else if (pvpRoguelike && playerCleared && opponentDefeated) {
|
||||||
|
showOverlay('victory', 'opponent-defeated')
|
||||||
|
} else if (pvpRoguelike && playerCleared && opponentCleared && roguelikeRun) {
|
||||||
|
statusRef.current = 'victory'
|
||||||
|
setStatus('victory')
|
||||||
|
roguelikeRun.onVictory()
|
||||||
|
} else if (!pvpRoguelike && playerDefeated) {
|
||||||
showOverlay('defeat')
|
showOverlay('defeat')
|
||||||
}
|
}
|
||||||
|
|
||||||
const hudSignature = arenaHudSignature(next)
|
const fightEnded = statusRef.current !== 'playing'
|
||||||
if (
|
if (fightEnded || next.time - lastPublishTimeRef.current >= IWT2_HUD_PUBLISH_INTERVAL_SECONDS) {
|
||||||
hudSignature !== lastHudSignatureRef.current
|
|
||||||
|| next.time - lastPublishTimeRef.current >= 0.08
|
|
||||||
|| next.bosses.some((boss) => boss.health <= 0)
|
|
||||||
) {
|
|
||||||
lastHudSignatureRef.current = hudSignature
|
|
||||||
lastPublishTimeRef.current = next.time
|
lastPublishTimeRef.current = next.time
|
||||||
setArenaState(next)
|
setArenaState(next)
|
||||||
if (pvpRoguelike) setOpponentArenaState(nextOpponentState)
|
if (pvpRoguelike) setOpponentArenaState(nextOpponentState)
|
||||||
@@ -460,7 +470,9 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
? 'Rematch'
|
? 'Rematch'
|
||||||
: 'Restart'
|
: 'Restart'
|
||||||
const overlayTitle = status === 'victory'
|
const overlayTitle = status === 'victory'
|
||||||
? `${bossTitle} Down`
|
? pvpResultReason === 'opponent-defeated'
|
||||||
|
? 'CPU Rival Falls'
|
||||||
|
: `${bossTitle} Down`
|
||||||
: status === 'defeat'
|
: status === 'defeat'
|
||||||
? 'Party Defeated'
|
? 'Party Defeated'
|
||||||
: 'Arena Paused'
|
: 'Arena Paused'
|
||||||
@@ -528,12 +540,15 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
targetBindings={targetBindings}
|
targetBindings={targetBindings}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PhaserArena
|
<Suspense fallback={<div className="iwt2-phaser-host iwt2-arena-loading" role="status">Loading arena...</div>}>
|
||||||
movementRef={movementRef}
|
<PhaserArena
|
||||||
onStep={onStep}
|
active={status === 'playing'}
|
||||||
selectedPartyIdRef={selectedPartyIdRef}
|
movementRef={movementRef}
|
||||||
stateRef={stateRef}
|
onStep={onStep}
|
||||||
/>
|
selectedPartyIdRef={selectedPartyIdRef}
|
||||||
|
stateRef={stateRef}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
{status === 'paused' && (
|
{status === 'paused' && (
|
||||||
<div className="pause-screen iwt2-arena-overlay is-paused" data-game-nav-active="true" role="dialog" aria-modal="true">
|
<div className="pause-screen iwt2-arena-overlay is-paused" data-game-nav-active="true" role="dialog" aria-modal="true">
|
||||||
<div>
|
<div>
|
||||||
@@ -670,29 +685,21 @@ function roguelikeBossHealthScale(stage: number): number {
|
|||||||
return 1 + Math.max(0, stage - 1) * 0.1
|
return 1 + Math.max(0, stage - 1) * 0.1
|
||||||
}
|
}
|
||||||
|
|
||||||
function overlayNavEntriesFor(status: ArenaStatus, pvpRoguelike: boolean): OverlayNavEntry[] {
|
function rewardDifficultySlugForKill(
|
||||||
if (pvpRoguelike && (status === 'victory' || status === 'defeat')) return PVP_RESULT_OVERLAY_NAV_ENTRIES
|
roguelikeRun: BossArenaScreenProps['roguelikeRun'] | undefined,
|
||||||
return DEFAULT_OVERLAY_NAV_ENTRIES
|
defeatedEarlierThisArena: number,
|
||||||
|
fallbackSlug: string,
|
||||||
|
): string {
|
||||||
|
if (!roguelikeRun) return fallbackSlug
|
||||||
|
return roguelikeRun.bossesDefeated + defeatedEarlierThisArena >= roguelikeRun.greenCoinThreshold
|
||||||
|
? 'veteran'
|
||||||
|
: fallbackSlug
|
||||||
}
|
}
|
||||||
|
|
||||||
function arenaHudSignature(state: Iwt2ArenaState): string {
|
function overlayNavEntriesFor(status: ArenaStatus, pvpRoguelike: boolean): OverlayNavEntry[] {
|
||||||
return [
|
if (status === 'paused') return PAUSE_OVERLAY_NAV_ENTRIES
|
||||||
...state.bosses.map((boss) => [
|
if (pvpRoguelike && (status === 'victory' || status === 'defeat')) return PVP_RESULT_OVERLAY_NAV_ENTRIES
|
||||||
boss.id,
|
return RESULT_OVERLAY_NAV_ENTRIES
|
||||||
Math.ceil(boss.health),
|
|
||||||
boss.attackPhase,
|
|
||||||
].join(':')),
|
|
||||||
state.hostileAdds.length,
|
|
||||||
state.hazards.length,
|
|
||||||
...state.party.map((member) => [
|
|
||||||
member.id,
|
|
||||||
Math.ceil(member.health),
|
|
||||||
Math.ceil(member.shield),
|
|
||||||
Math.ceil(member.mana),
|
|
||||||
Math.ceil(Math.max(member.status.stunnedSeconds, member.status.knockedDownSeconds) * 10),
|
|
||||||
member.hotEffects.map((effect) => `${effect.id}:${Math.ceil(effect.remainingSeconds)}:${Math.ceil(effect.nextTickInSeconds * 10)}`).join(','),
|
|
||||||
].join(':')),
|
|
||||||
].join('|')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBossEncounterTitle(bosses: Iwt2ArenaState['bosses']): string {
|
function formatBossEncounterTitle(bosses: Iwt2ArenaState['bosses']): string {
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import {
|
|||||||
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../../../dualScreen'
|
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../../../dualScreen'
|
||||||
import { getGameMode } from '../../../gameRepository'
|
import { getGameMode } from '../../../gameRepository'
|
||||||
import {
|
import {
|
||||||
createDefaultIwt2Save,
|
|
||||||
loadIwt2OnlineSave,
|
loadIwt2OnlineSave,
|
||||||
setIwt2InfusionAbility,
|
setIwt2InfusionAbility,
|
||||||
|
setIwt2PassiveInfusion,
|
||||||
updateIwt2CharacterSettings,
|
updateIwt2CharacterSettings,
|
||||||
upgradeIwt2GearSlot,
|
upgradeIwt2GearSlot,
|
||||||
canAffordIwt2Costs,
|
canAffordIwt2Costs,
|
||||||
@@ -40,6 +40,8 @@ import {
|
|||||||
IWT2_HEALER_ORDER,
|
IWT2_HEALER_ORDER,
|
||||||
} from '../content/healerAbilities'
|
} from '../content/healerAbilities'
|
||||||
import {
|
import {
|
||||||
|
buildIwt2SelfBuffChoices,
|
||||||
|
IWT2_REVIVE_PARTY_CHOICE,
|
||||||
type Iwt2RoguelikeChoice,
|
type Iwt2RoguelikeChoice,
|
||||||
type Iwt2RoguelikeContentType,
|
type Iwt2RoguelikeContentType,
|
||||||
type Iwt2RoguelikeOpponentDebuffId,
|
type Iwt2RoguelikeOpponentDebuffId,
|
||||||
@@ -51,9 +53,13 @@ import {
|
|||||||
IWT2_GEAR_SLOT_RECIPES,
|
IWT2_GEAR_SLOT_RECIPES,
|
||||||
IWT2_GEAR_SLOTS,
|
IWT2_GEAR_SLOTS,
|
||||||
IWT2_GEAR_STAT_LABELS,
|
IWT2_GEAR_STAT_LABELS,
|
||||||
|
IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL,
|
||||||
|
IWT2_MAX_GEAR_LEVEL,
|
||||||
iwt2GearUpgradeCosts,
|
iwt2GearUpgradeCosts,
|
||||||
iwt2InfusionCosts,
|
iwt2InfusionCosts,
|
||||||
isIwt2InfusionUnlocked,
|
isIwt2InfusionUnlocked,
|
||||||
|
isIwt2PassiveInfusionUnlocked,
|
||||||
|
type Iwt2PassiveInfusionId,
|
||||||
type Iwt2GearStatId,
|
type Iwt2GearStatId,
|
||||||
type Iwt2GearSlotId,
|
type Iwt2GearSlotId,
|
||||||
} from '../content/gear'
|
} from '../content/gear'
|
||||||
@@ -144,6 +150,7 @@ type Iwt2GearNavEntry =
|
|||||||
| { kind: 'slot', key: string, row: number, column: number, slotId: Iwt2GearSlotId }
|
| { kind: 'slot', key: string, row: number, column: number, slotId: Iwt2GearSlotId }
|
||||||
| { kind: 'upgrade', key: string, row: number, column: number, disabled: boolean }
|
| { kind: 'upgrade', key: string, row: number, column: number, disabled: boolean }
|
||||||
| { kind: 'infusion', key: string, row: number, column: number, abilityId: Iwt2InfusionAbilityId, disabled: boolean }
|
| { kind: 'infusion', key: string, row: number, column: number, abilityId: Iwt2InfusionAbilityId, disabled: boolean }
|
||||||
|
| { kind: 'passiveInfusion', key: string, row: number, column: number, passiveId: Iwt2PassiveInfusionId, disabled: boolean }
|
||||||
|
|
||||||
const IWT2_HUNTER_PROFILE_DROP_COLUMNS = 6
|
const IWT2_HUNTER_PROFILE_DROP_COLUMNS = 6
|
||||||
const IWT2_NAME_MAX_LENGTH = 18
|
const IWT2_NAME_MAX_LENGTH = 18
|
||||||
@@ -1879,7 +1886,9 @@ export function Iwt2CloudSaveScreen({
|
|||||||
const [onlineSave, setOnlineSave] = useState<Iwt2Save | null>(null)
|
const [onlineSave, setOnlineSave] = useState<Iwt2Save | null>(null)
|
||||||
const [syncingOnlineSave, setSyncingOnlineSave] = useState(false)
|
const [syncingOnlineSave, setSyncingOnlineSave] = useState(false)
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
const statusMessage = message || (!onlineBackupsAvailable ? 'Online save unavailable in offline mode.' : '')
|
const statusMessage = message || (!onlineBackupsAvailable
|
||||||
|
? 'Sign in at character select to upload or download this local save.'
|
||||||
|
: '')
|
||||||
|
|
||||||
const checkOnlineSave = useCallback(async (isCancelled: () => boolean) => {
|
const checkOnlineSave = useCallback(async (isCancelled: () => boolean) => {
|
||||||
setSyncingOnlineSave(true)
|
setSyncingOnlineSave(true)
|
||||||
@@ -1916,7 +1925,7 @@ export function Iwt2CloudSaveScreen({
|
|||||||
|
|
||||||
const handleUseLocalSave = useCallback(async () => {
|
const handleUseLocalSave = useCallback(async () => {
|
||||||
if (!onlineBackupsAvailable) {
|
if (!onlineBackupsAvailable) {
|
||||||
setMessage('Using local save. Sign in online to update the server copy.')
|
setMessage('Using local save. Sign in at character select to update the server copy.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setSyncingOnlineSave(true)
|
setSyncingOnlineSave(true)
|
||||||
@@ -1938,11 +1947,6 @@ export function Iwt2CloudSaveScreen({
|
|||||||
setMessage('Local save now uses online progress.')
|
setMessage('Local save now uses online progress.')
|
||||||
}, [onlineBackupsAvailable, onlineSave, onSaveUpdated])
|
}, [onlineBackupsAvailable, onlineSave, onSaveUpdated])
|
||||||
|
|
||||||
const handleUseNewSave = useCallback(() => {
|
|
||||||
onSaveUpdated(createDefaultIwt2Save())
|
|
||||||
setMessage('Started a new local IWT2 save.')
|
|
||||||
}, [onSaveUpdated])
|
|
||||||
|
|
||||||
const actions = useMemo<Iwt2NavAction[]>(() => withBackAction([
|
const actions = useMemo<Iwt2NavAction[]>(() => withBackAction([
|
||||||
{
|
{
|
||||||
key: 'use-local',
|
key: 'use-local',
|
||||||
@@ -1962,16 +1966,8 @@ export function Iwt2CloudSaveScreen({
|
|||||||
disabled: syncingOnlineSave || !onlineBackupsAvailable || !onlineSave,
|
disabled: syncingOnlineSave || !onlineBackupsAvailable || !onlineSave,
|
||||||
onConfirm: handleUseOnlineSave,
|
onConfirm: handleUseOnlineSave,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'new-save',
|
|
||||||
label: 'Use New Save File',
|
|
||||||
detail: 'Start over with a fresh IWT2 character. Online save is not overwritten until you choose local save.',
|
|
||||||
value: 'New',
|
|
||||||
onConfirm: handleUseNewSave,
|
|
||||||
},
|
|
||||||
], onBack), [
|
], onBack), [
|
||||||
handleUseLocalSave,
|
handleUseLocalSave,
|
||||||
handleUseNewSave,
|
|
||||||
handleUseOnlineSave,
|
handleUseOnlineSave,
|
||||||
onlineBackupsAvailable,
|
onlineBackupsAvailable,
|
||||||
onlineSave,
|
onlineSave,
|
||||||
@@ -1981,7 +1977,7 @@ export function Iwt2CloudSaveScreen({
|
|||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Iwt2ScreenShell title="Backup Slot" onBack={onBack}>
|
<Iwt2ScreenShell title="Cloud Backup" onBack={onBack}>
|
||||||
{statusMessage && <p className="iwt2-screen-note">{statusMessage}</p>}
|
{statusMessage && <p className="iwt2-screen-note">{statusMessage}</p>}
|
||||||
<Iwt2ActionList actions={actions} />
|
<Iwt2ActionList actions={actions} />
|
||||||
</Iwt2ScreenShell>
|
</Iwt2ScreenShell>
|
||||||
@@ -2005,16 +2001,22 @@ export function Iwt2GearUpgradeScreen({
|
|||||||
const selectedSlot = classProgress.slots[selectedSlotId]
|
const selectedSlot = classProgress.slots[selectedSlotId]
|
||||||
const selectedRecipe = IWT2_GEAR_SLOT_RECIPES[selectedClassId][selectedSlotId]
|
const selectedRecipe = IWT2_GEAR_SLOT_RECIPES[selectedClassId][selectedSlotId]
|
||||||
const selectedBonus = gearBonusSummary(selectedRecipe.statId, selectedSlot.level, selectedClassId)
|
const selectedBonus = gearBonusSummary(selectedRecipe.statId, selectedSlot.level, selectedClassId)
|
||||||
const nextLevel = Math.min(5, selectedSlot.level + 1)
|
const nextLevel = Math.min(IWT2_MAX_GEAR_LEVEL, selectedSlot.level + 1)
|
||||||
const nextBonus = gearBonusSummary(selectedRecipe.statId, nextLevel, selectedClassId)
|
const nextBonus = gearBonusSummary(selectedRecipe.statId, nextLevel, selectedClassId)
|
||||||
const selectedClassName = gearClassDisplayName(selectedClassId, save)
|
const selectedClassName = gearClassDisplayName(selectedClassId, save)
|
||||||
const upgradeCosts = iwt2GearUpgradeCosts(selectedClassId, selectedSlotId, selectedSlot.level)
|
const upgradeCosts = iwt2GearUpgradeCosts(selectedClassId, selectedSlotId, selectedSlot.level)
|
||||||
const canUpgrade = selectedSlot.level < 5 && canAffordIwt2Costs(save, upgradeCosts)
|
const canUpgrade = selectedSlot.level < IWT2_MAX_GEAR_LEVEL && canAffordIwt2Costs(save, upgradeCosts)
|
||||||
const infusionUnlocked = isIwt2InfusionUnlocked(classProgress)
|
const infusionUnlocked = isIwt2InfusionUnlocked(classProgress)
|
||||||
const infusionAnchorSlot = selectedSlot.level >= 5
|
const passiveInfusionUnlocked = isIwt2PassiveInfusionUnlocked(save.gearProgress)
|
||||||
|
const infusionAnchorSlot = selectedSlot.level >= IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL
|
||||||
? selectedSlotId
|
? selectedSlotId
|
||||||
: IWT2_GEAR_SLOTS.find((slotId) => classProgress.slots[slotId].level >= 5) ?? selectedSlotId
|
: IWT2_GEAR_SLOTS.find((slotId) => classProgress.slots[slotId].level >= IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL) ?? selectedSlotId
|
||||||
const infusionAbilities = iwt2InfusionAbilitiesForClass(selectedClassId)
|
const infusionAbilities = iwt2InfusionAbilitiesForClass(selectedClassId)
|
||||||
|
const passiveInfusionChoices = useMemo<Array<Iwt2RoguelikeChoice<Iwt2PassiveInfusionId>>>(() => {
|
||||||
|
if (selectedClassId !== 'healer') return []
|
||||||
|
return buildIwt2SelfBuffChoices(abilitiesForHealer(save.character.healerStyle))
|
||||||
|
.filter((choice): choice is Iwt2RoguelikeChoice<Iwt2PassiveInfusionId> => choice.id !== IWT2_REVIVE_PARTY_CHOICE.id)
|
||||||
|
}, [save.character.healerStyle, selectedClassId])
|
||||||
const navEntries = useMemo<Iwt2GearNavEntry[]>(() => {
|
const navEntries = useMemo<Iwt2GearNavEntry[]>(() => {
|
||||||
const entries: Iwt2GearNavEntry[] = [{ kind: 'back', key: 'back', row: 0, column: 0 }]
|
const entries: Iwt2GearNavEntry[] = [{ kind: 'back', key: 'back', row: 0, column: 0 }]
|
||||||
IWT2_PARTY_ORDER.forEach((classId, index) => {
|
IWT2_PARTY_ORDER.forEach((classId, index) => {
|
||||||
@@ -2036,8 +2038,19 @@ export function Iwt2GearUpgradeScreen({
|
|||||||
disabled: selected || !infusionUnlocked || !canAffordIwt2Costs(save, costs),
|
disabled: selected || !infusionUnlocked || !canAffordIwt2Costs(save, costs),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
passiveInfusionChoices.forEach((choice, index) => {
|
||||||
|
const selected = classProgress.passiveInfusionId === choice.id
|
||||||
|
entries.push({
|
||||||
|
kind: 'passiveInfusion',
|
||||||
|
key: `passive:${choice.id}`,
|
||||||
|
row: infusionAbilities.length + index + 1,
|
||||||
|
column: 2,
|
||||||
|
passiveId: choice.id,
|
||||||
|
disabled: selected || !passiveInfusionUnlocked,
|
||||||
|
})
|
||||||
|
})
|
||||||
return entries
|
return entries
|
||||||
}, [canUpgrade, classProgress.infusionAbilityId, infusionAbilities, infusionAnchorSlot, infusionUnlocked, save, selectedClassId])
|
}, [canUpgrade, classProgress.infusionAbilityId, classProgress.passiveInfusionId, infusionAbilities, infusionAnchorSlot, infusionUnlocked, passiveInfusionChoices, passiveInfusionUnlocked, save, selectedClassId])
|
||||||
|
|
||||||
const activeEntry = navEntries[Math.min(selectedIndex, navEntries.length - 1)] ?? navEntries[0]
|
const activeEntry = navEntries[Math.min(selectedIndex, navEntries.length - 1)] ?? navEntries[0]
|
||||||
|
|
||||||
@@ -2089,6 +2102,18 @@ export function Iwt2GearUpgradeScreen({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
setMessage(error instanceof Error ? error.message : 'Infusion failed.')
|
setMessage(error instanceof Error ? error.message : 'Infusion failed.')
|
||||||
}
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (entry.kind === 'passiveInfusion') {
|
||||||
|
if (entry.disabled) return
|
||||||
|
try {
|
||||||
|
const nextSave = setIwt2PassiveInfusion(save, entry.passiveId)
|
||||||
|
onSaveUpdated(nextSave)
|
||||||
|
const passiveName = passiveInfusionChoices.find((choice) => choice.id === entry.passiveId)?.name ?? 'Passive'
|
||||||
|
setMessage(`${passiveName} set as passive infusion.`)
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error instanceof Error ? error.message : 'Passive infusion failed.')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2112,6 +2137,7 @@ export function Iwt2GearUpgradeScreen({
|
|||||||
const selected = selectedClassId === classId
|
const selected = selectedClassId === classId
|
||||||
const focused = activeEntry?.kind === 'class' && activeEntry.classId === classId
|
const focused = activeEntry?.kind === 'class' && activeEntry.classId === classId
|
||||||
const classInfusion = save.gearProgress[classId].infusionAbilityId
|
const classInfusion = save.gearProgress[classId].infusionAbilityId
|
||||||
|
const passiveInfusion = classId === 'healer' ? save.gearProgress.healer.passiveInfusionId : null
|
||||||
const highest = Math.max(...IWT2_GEAR_SLOTS.map((slotId) => save.gearProgress[classId].slots[slotId].level))
|
const highest = Math.max(...IWT2_GEAR_SLOTS.map((slotId) => save.gearProgress[classId].slots[slotId].level))
|
||||||
const className = gearClassDisplayName(classId, save)
|
const className = gearClassDisplayName(classId, save)
|
||||||
const classSubtitle = gearClassSubtitle(classId, save)
|
const classSubtitle = gearClassSubtitle(classId, save)
|
||||||
@@ -2129,7 +2155,7 @@ export function Iwt2GearUpgradeScreen({
|
|||||||
<div>
|
<div>
|
||||||
<strong>{className}</strong>
|
<strong>{className}</strong>
|
||||||
<small>{classSubtitle}</small>
|
<small>{classSubtitle}</small>
|
||||||
<small>Top +{highest}{classInfusion ? ' | Slot 6 set' : ''}</small>
|
<small>Top +{highest}{classInfusion ? ' | Active set' : ''}{passiveInfusion ? ' | Passive set' : ''}</small>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
@@ -2172,7 +2198,7 @@ export function Iwt2GearUpgradeScreen({
|
|||||||
onPointerDown={() => selectGearEntry(navEntries, setSelectedIndex, 'upgrade')}
|
onPointerDown={() => selectGearEntry(navEntries, setSelectedIndex, 'upgrade')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Upgrade to +{Math.min(5, selectedSlot.level + 1)}
|
Upgrade to +{Math.min(IWT2_MAX_GEAR_LEVEL, selectedSlot.level + 1)}
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -2188,8 +2214,8 @@ export function Iwt2GearUpgradeScreen({
|
|||||||
<small>{selectedBonus.text}</small>
|
<small>{selectedBonus.text}</small>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<strong>{selectedSlot.level >= 5 ? 'Max rank' : `Upgrade preview +${selectedSlot.level} -> +${nextLevel}`}</strong>
|
<strong>{selectedSlot.level >= IWT2_MAX_GEAR_LEVEL ? 'Max rank' : `Upgrade preview +${selectedSlot.level} -> +${nextLevel}`}</strong>
|
||||||
<small>{selectedSlot.level >= 5 ? infusionAnchorText(classProgress.slots[selectedSlotId].level) : `${selectedBonus.label}: ${selectedBonus.value} -> ${nextBonus.value}`}</small>
|
<small>{selectedSlot.level >= IWT2_MAX_GEAR_LEVEL ? infusionAnchorText(classProgress.slots[selectedSlotId].level) : `${selectedBonus.label}: ${selectedBonus.value} -> ${nextBonus.value}`}</small>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="iwt2-gear-cost-list">
|
<div className="iwt2-gear-cost-list">
|
||||||
@@ -2227,12 +2253,40 @@ export function Iwt2GearUpgradeScreen({
|
|||||||
<div>
|
<div>
|
||||||
<strong>{ability.name}</strong>
|
<strong>{ability.name}</strong>
|
||||||
<small>{ability.description}</small>
|
<small>{ability.description}</small>
|
||||||
<small>{selected ? 'Selected' : infusionUnlocked ? infusionCostText(save, costs) : 'Unlock: any slot to +5'}</small>
|
<small>{selected ? 'Selected' : infusionUnlocked ? infusionCostText(save, costs) : `Unlock: any ${selectedClassName} slot to +${IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL}`}</small>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{selectedClassId === 'healer' && (
|
||||||
|
<div className="iwt2-infusion-list">
|
||||||
|
{passiveInfusionChoices.map((choice) => {
|
||||||
|
const selected = classProgress.passiveInfusionId === choice.id
|
||||||
|
const focused = activeEntry?.kind === 'passiveInfusion' && activeEntry.passiveId === choice.id
|
||||||
|
const disabled = selected || !passiveInfusionUnlocked
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`iwt2-infusion-row ${selected ? 'active' : ''} ${focused ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={focused ? 'true' : undefined}
|
||||||
|
disabled={disabled}
|
||||||
|
key={choice.id}
|
||||||
|
onClick={() => activateEntry({ kind: 'passiveInfusion', key: `passive:${choice.id}`, row: 0, column: 2, passiveId: choice.id, disabled })}
|
||||||
|
onPointerDown={() => selectGearEntry(navEntries, setSelectedIndex, `passive:${choice.id}`)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span>P</span>
|
||||||
|
<div>
|
||||||
|
<strong>{choice.name}</strong>
|
||||||
|
<small>{choice.description}</small>
|
||||||
|
<small>{selected ? 'Selected passive' : passiveInfusionUnlocked ? 'Passive infusion' : `Unlock: any gear slot to +${IWT2_MAX_GEAR_LEVEL}`}</small>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<footer className="iwt2-gear-message">{message}</footer>
|
<footer className="iwt2-gear-message">{message}</footer>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -2276,7 +2330,9 @@ function gearBonus(label: string, value: string): { label: string, text: string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
function infusionAnchorText(level: number): string {
|
function infusionAnchorText(level: number): string {
|
||||||
return level >= 5 ? 'Infusion anchor available.' : 'No further bonus.'
|
if (level >= IWT2_MAX_GEAR_LEVEL) return 'Active and passive infusion anchors available.'
|
||||||
|
if (level >= IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL) return 'Active infusion anchor available.'
|
||||||
|
return 'No infusion anchor.'
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPercent(value: number): string {
|
function formatPercent(value: number): string {
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ function initialBossSecondaryCooldown(bossId: Iwt2BossId): number {
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
const inputMove = {
|
const inputMove = {
|
||||||
x: clampInputAxis(input.moveX),
|
x: clampInputAxis(input.moveX),
|
||||||
@@ -283,10 +283,18 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
|
|||||||
nextAddId: bossResult.nextAddId ?? state.nextAddId,
|
nextAddId: bossResult.nextAddId ?? state.nextAddId,
|
||||||
nextHazardId: projectileResult.nextHazardId,
|
nextHazardId: projectileResult.nextHazardId,
|
||||||
nextEventId: state.nextEventId + nextEvents.length,
|
nextEventId: state.nextEventId + nextEvents.length,
|
||||||
events: [...state.events, ...nextEvents].slice(-MAX_EVENTS),
|
events: appendEventHistory(state.events, nextEvents),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendEventHistory(previous: Iwt2ArenaEvent[], next: Iwt2ArenaEvent[]): Iwt2ArenaEvent[] {
|
||||||
|
if (next.length === 0) return previous
|
||||||
|
const overflow = Math.max(0, previous.length + next.length - MAX_EVENTS)
|
||||||
|
return overflow > 0
|
||||||
|
? [...previous.slice(overflow), ...next]
|
||||||
|
: [...previous, ...next]
|
||||||
|
}
|
||||||
|
|
||||||
function tickPartyHotEffects(
|
function tickPartyHotEffects(
|
||||||
party: Iwt2PartyEntityState[],
|
party: Iwt2PartyEntityState[],
|
||||||
dt: number,
|
dt: number,
|
||||||
|
|||||||
@@ -1,60 +1,16 @@
|
|||||||
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
|
import type { Iwt2BossId } from '../content/bosses'
|
||||||
import { IWT2_CLASS_METADATA } from '../content/classes'
|
|
||||||
import {
|
import {
|
||||||
createInitialIwt2ArenaState as createCoreIwt2ArenaState,
|
createInitialIwt2ArenaState as createCoreIwt2ArenaState,
|
||||||
tickIwt2Arena as tickCoreIwt2Arena,
|
tickIwt2Arena as tickCoreIwt2Arena,
|
||||||
} from './arena'
|
} from './arena'
|
||||||
import type {
|
import type {
|
||||||
Iwt2ArenaIndicator,
|
|
||||||
Iwt2ArenaInput,
|
Iwt2ArenaInput,
|
||||||
Iwt2ArenaBounds,
|
Iwt2ArenaBounds,
|
||||||
Iwt2ArenaState as Iwt2CoreArenaState,
|
Iwt2ArenaState as Iwt2CoreArenaState,
|
||||||
Iwt2BossEntityState,
|
|
||||||
Iwt2HostileAddState,
|
|
||||||
Iwt2PartyEntityState,
|
|
||||||
Iwt2RoguelikePressureState,
|
Iwt2RoguelikePressureState,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
export type Iwt2ArenaEntityKind = 'player' | 'party' | 'boss' | 'projectile' | 'hostileAdd'
|
export type Iwt2ArenaState = Iwt2CoreArenaState
|
||||||
|
|
||||||
export type Iwt2ArenaEntity = {
|
|
||||||
id: string
|
|
||||||
kind: Iwt2ArenaEntityKind
|
|
||||||
icon: string
|
|
||||||
color: string
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
radius: number
|
|
||||||
health: number
|
|
||||||
maxHealth: number
|
|
||||||
stunnedFor: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Iwt2ArenaTelegraph =
|
|
||||||
| {
|
|
||||||
kind: 'charge'
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
width: number
|
|
||||||
height: number
|
|
||||||
active: boolean
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
kind: 'slam'
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
radius: number
|
|
||||||
active: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Iwt2ArenaState = Iwt2CoreArenaState & {
|
|
||||||
arena: {
|
|
||||||
width: number
|
|
||||||
height: number
|
|
||||||
}
|
|
||||||
entities: Iwt2ArenaEntity[]
|
|
||||||
telegraphs: Iwt2ArenaTelegraph[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createInitialIwt2ArenaState(
|
export function createInitialIwt2ArenaState(
|
||||||
bossId?: Iwt2BossId,
|
bossId?: Iwt2BossId,
|
||||||
@@ -64,94 +20,9 @@ export function createInitialIwt2ArenaState(
|
|||||||
roguelikePressure?: Iwt2RoguelikePressureState,
|
roguelikePressure?: Iwt2RoguelikePressureState,
|
||||||
bounds?: Iwt2ArenaBounds,
|
bounds?: Iwt2ArenaBounds,
|
||||||
): Iwt2ArenaState {
|
): Iwt2ArenaState {
|
||||||
return decorateArenaState(createCoreIwt2ArenaState(bossId, bossIds, bossHealthScale, partyDamageTakenScale, roguelikePressure, bounds))
|
return createCoreIwt2ArenaState(bossId, bossIds, bossHealthScale, partyDamageTakenScale, roguelikePressure, bounds)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
|
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
|
||||||
return decorateArenaState(tickCoreIwt2Arena(state, input, dt))
|
return tickCoreIwt2Arena(state, input, dt)
|
||||||
}
|
|
||||||
|
|
||||||
export function decorateArenaState(state: Iwt2CoreArenaState): Iwt2ArenaState {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
arena: { ...state.bounds },
|
|
||||||
entities: [
|
|
||||||
...state.party.map(toArenaEntity),
|
|
||||||
...state.hostileAdds.map(toHostileAddArenaEntity),
|
|
||||||
...state.bosses.map(toBossArenaEntity),
|
|
||||||
],
|
|
||||||
telegraphs: createTelegraphs(state.indicators),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toHostileAddArenaEntity(add: Iwt2HostileAddState): Iwt2ArenaEntity {
|
|
||||||
return {
|
|
||||||
id: add.id,
|
|
||||||
kind: 'hostileAdd',
|
|
||||||
icon: 'v',
|
|
||||||
color: '#f0b84f',
|
|
||||||
x: add.position.x,
|
|
||||||
y: add.position.y,
|
|
||||||
radius: add.radius,
|
|
||||||
health: add.health,
|
|
||||||
maxHealth: add.maxHealth,
|
|
||||||
stunnedFor: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toArenaEntity(member: Iwt2PartyEntityState): Iwt2ArenaEntity {
|
|
||||||
const metadata = IWT2_CLASS_METADATA[member.classId]
|
|
||||||
return {
|
|
||||||
id: member.id,
|
|
||||||
kind: member.aiRole === 'player' ? 'player' : 'party',
|
|
||||||
icon: metadata.icon,
|
|
||||||
color: metadata.color,
|
|
||||||
x: member.position.x,
|
|
||||||
y: member.position.y,
|
|
||||||
radius: member.radius,
|
|
||||||
health: member.health,
|
|
||||||
maxHealth: member.maxHealth,
|
|
||||||
stunnedFor: Math.max(member.status.stunnedSeconds, member.status.knockedDownSeconds),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toBossArenaEntity(boss: Iwt2BossEntityState): Iwt2ArenaEntity {
|
|
||||||
const metadata = IWT2_BOSS_METADATA[boss.bossId]
|
|
||||||
return {
|
|
||||||
id: boss.id,
|
|
||||||
kind: 'boss',
|
|
||||||
icon: metadata.icon,
|
|
||||||
color: metadata.color,
|
|
||||||
x: boss.position.x,
|
|
||||||
y: boss.position.y,
|
|
||||||
radius: boss.radius,
|
|
||||||
health: boss.health,
|
|
||||||
maxHealth: boss.maxHealth,
|
|
||||||
stunnedFor: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTelegraphs(indicators: Iwt2ArenaIndicator[]): Iwt2ArenaTelegraph[] {
|
|
||||||
return indicators.flatMap((indicator): Iwt2ArenaTelegraph[] => {
|
|
||||||
if (indicator.kind === 'lane') {
|
|
||||||
return [{
|
|
||||||
active: indicator.phase === 'active',
|
|
||||||
height: indicator.width * 2,
|
|
||||||
kind: 'charge' as const,
|
|
||||||
width: Math.max(16, Math.hypot(indicator.end.x - indicator.start.x, indicator.end.y - indicator.start.y)),
|
|
||||||
x: Math.min(indicator.start.x, indicator.end.x),
|
|
||||||
y: Math.min(indicator.start.y, indicator.end.y) - indicator.width,
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
if (indicator.kind === 'circle') {
|
|
||||||
return [{
|
|
||||||
active: indicator.phase === 'active',
|
|
||||||
kind: 'slam' as const,
|
|
||||||
radius: indicator.radius,
|
|
||||||
x: indicator.position.x,
|
|
||||||
y: indicator.position.y,
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user