Android build v1.1.27

This commit is contained in:
Warren H
2026-07-06 11:46:53 -04:00
parent de2f86e101
commit d70941f43e
24 changed files with 598 additions and 46 deletions
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 106
versionName "1.1.26"
versionCode 107
versionName "1.1.27"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

+35
View File
@@ -1219,6 +1219,28 @@
padding: 6px 8px;
}
.iwt2-gear-bonus-grid {
display: grid;
gap: 7px;
}
.iwt2-gear-bonus-grid span {
background: #0d1118;
border: 1px solid #29313d;
display: block;
padding: 7px 8px;
}
.iwt2-gear-bonus-grid strong {
color: #f2c96d;
font-size: 0.62rem;
}
.iwt2-gear-bonus-grid small {
color: #e5edf6;
font-size: 0.62rem;
}
.iwt2-gear-cost-list span.met {
color: #9ef0bd;
}
@@ -1623,6 +1645,19 @@
padding: 7px;
}
.iwt2-gear-bonus-grid {
gap: 5px;
}
.iwt2-gear-bonus-grid span {
padding: 5px 6px;
}
.iwt2-gear-bonus-grid strong,
.iwt2-gear-bonus-grid small {
font-size: 0.52rem;
}
.iwt2-infusion-row {
gap: 7px;
grid-template-columns: 28px minmax(0, 1fr);
+6 -4
View File
@@ -266,6 +266,7 @@ export function CombatScreen({
roguelikeAbilityLabelMode = 'ability',
roguelikeEncounterPool,
onExit,
onMainMenu = onExit,
onProfileUpdated,
}: {
difficulty: Difficulty
@@ -279,6 +280,7 @@ export function CombatScreen({
roguelikeAbilityLabelMode?: RoguelikeAbilityLabelMode
roguelikeEncounterPool?: DungeonEncounter[]
onExit: () => void
onMainMenu?: () => void
onProfileUpdated: (profile: CharacterProfile) => void
}) {
const staticEncounters = useMemo(
@@ -1399,10 +1401,10 @@ export function CombatScreen({
<div>
<p className="eyebrow">Game Paused</p>
<h2>{dungeon.name}</h2>
<p>Combat is stopped. Resume the fight or leave the current run.</p>
<button onClick={() => setPaused(false)} type="button">Resume</button>
<button className="secondary-result-button" onClick={onExit} type="button">
Leave {contentName}
<p>Combat is stopped. Continue the fight or return to the main menu.</p>
<button onClick={() => setPaused(false)} type="button">Continue</button>
<button className="secondary-result-button" onClick={onMainMenu} type="button">
Main Menu
</button>
</div>
</div>
+14 -5
View File
@@ -294,6 +294,7 @@ export function PvPRoguelikeScreen({
contentType,
encounterPool,
onExit,
onMainMenu = onExit,
onProfileUpdated,
}: {
profile: CharacterProfile
@@ -301,6 +302,7 @@ export function PvPRoguelikeScreen({
contentType: PvpContentType
encounterPool: DungeonEncounter[]
onExit: () => void
onMainMenu?: () => void
onProfileUpdated: (profile: CharacterProfile) => void
}) {
const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)!
@@ -1423,14 +1425,14 @@ export function PvPRoguelikeScreen({
if (!entry || pvpOverlayEntryDisabled(entry)) return
if (entry.kind === 'queueBack') onExit()
else if (entry.kind === 'pauseResume') setPaused(false)
else if (entry.kind === 'pauseLeave') onExit()
else if (entry.kind === 'pauseLeave') onMainMenu()
else if (entry.kind === 'upgradeBuff') setSelectedBuff(playerBuffChoices[entry.index] ?? null)
else if (entry.kind === 'upgradeDebuff') setSelectedDebuff(playerDebuffChoices[entry.index] ?? null)
else if (entry.kind === 'upgradeContinue') confirmUpgradeChoices()
}
useGameAction((action) => {
if (status === 'queueing' || status === 'round-countdown') {
if (status === 'queueing') {
if (action === 'back' || action === 'pause') onExit()
if (status === 'queueing' && action === 'confirm') openPvpOverlayEntry(activePvpOverlayEntry())
return
@@ -1447,6 +1449,13 @@ export function PvPRoguelikeScreen({
if (action.startsWith('navigate')) movePvpOverlaySelection(action)
return
}
if (status === 'round-countdown') {
if (action === 'back' || action === 'pause') {
setOverlaySelectedIndex(0)
setPaused(true)
}
return
}
if (status === 'upgrade-choice') {
if (action === 'confirm') {
openPvpOverlayEntry(activePvpOverlayEntry())
@@ -1780,16 +1789,16 @@ export function PvPRoguelikeScreen({
onPointerDown={() => setPvpOverlayCursor('pauseResume')}
type="button"
>
Resume
Continue
</button>
<button
className={`secondary-result-button ${pvpOverlayEntrySelected('pauseLeave') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={onExit}
onClick={onMainMenu}
onPointerDown={() => setPvpOverlayCursor('pauseLeave')}
type="button"
>
Leave
Main Menu
</button>
</div>
</div>
+5
View File
@@ -860,6 +860,7 @@ function IWantToHeal1App({
onExit={() => {
setScreen(combatContentId < 0 ? 'roguelike' : dungeon.contentType === 'raid' ? 'raids' : 'dungeons')
}}
onMainMenu={() => setScreen('menu')}
onProfileUpdated={setProfile}
/>
</Suspense>
@@ -893,6 +894,10 @@ function IWantToHeal1App({
setRoguelikeVariant('pvp')
setScreen('roguelike')
}}
onMainMenu={() => {
setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType))
setScreen('menu')
}}
onProfileUpdated={setProfile}
profile={profile}
/>
+1
View File
@@ -258,6 +258,7 @@ export function IWantToHeal2App({
}}
save={save}
onBack={() => setScreen('roguelike')}
onMainMenu={() => setScreen('menu')}
onPvpRequeue={() => {
setScreen('roguelike')
startIwt2RoguelikeRun()
+2 -2
View File
@@ -194,7 +194,7 @@ const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = {
spriteYOffsetScale: -0.1,
color: '#d66a35',
accentColor: '#ffd166',
maxHealth: 400,
maxHealth: 750,
radius: 29,
moveSpeed: 118,
meleeRange: 56,
@@ -224,7 +224,7 @@ const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = {
birdFlightCooldown: 10,
birdFlightWindup: 0.85,
birdFlightSpeed: 360,
birdHealth: 58,
birdHealth: 100,
birdRadius: 16,
birdContactDamage: 26,
birdStunSeconds: 0.75,
+57
View File
@@ -2,12 +2,47 @@ export type Iwt2PlayerClassId = 'healer' | 'paladin' | 'ranger' | 'mage' | 'rogu
export type Iwt2PlayerClassRole = 'healer' | 'tank' | 'damage'
export type Iwt2ClassEquipmentSlot =
| 'weapon'
| 'offhand'
| 'head'
| 'chest'
| 'hands'
| 'legs'
export type Iwt2ClassLayerMotion = {
offsetXScale?: number
offsetYScale?: number
rotation?: number
}
export type Iwt2ClassWeaponLayer = {
id: string
slot: Iwt2ClassEquipmentSlot
url: string
drawOrder: 'behindBody' | 'front'
heightScale: number
offsetXScale: number
offsetYScale: number
originX?: number
originY?: number
attackMotion?: Iwt2ClassLayerMotion
tierAccentEligible?: boolean
}
export type Iwt2ClassLayeredArenaSprite = {
bodyUrl: string
bodyHeightScale?: number
weaponLayers: Iwt2ClassWeaponLayer[]
}
export type Iwt2ClassMetadata = {
id: Iwt2PlayerClassId
name: string
role: Iwt2PlayerClassRole
icon: string
arenaSpriteUrl: string
arenaLayeredSprite?: Iwt2ClassLayeredArenaSprite
uiIconUrl: string
color: string
accentColor: string
@@ -118,6 +153,28 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
role: 'damage',
icon: '⚔',
arenaSpriteUrl: '/iwt2/classes/arena/warrior-chunky.png',
arenaLayeredSprite: {
bodyUrl: '/iwt2/classes/arena/layers/warrior-body.png',
weaponLayers: [
{
id: 'axe',
slot: 'weapon',
url: '/iwt2/classes/arena/layers/warrior-weapon-axe.png',
drawOrder: 'front',
heightScale: 1.72,
offsetXScale: 0.48,
offsetYScale: -2.05,
originX: 0.18,
originY: 0.5,
attackMotion: {
offsetXScale: 0.22,
offsetYScale: -0.18,
rotation: 0.54,
},
tierAccentEligible: true,
},
],
},
uiIconUrl: '/iwt2/classes/ui/warrior-medallion.png',
color: '#ff7675',
accentColor: '#ffd0d0',
+124
View File
@@ -0,0 +1,124 @@
import type { Iwt2PartyEntityState } from '../sim'
import type { Iwt2ClassWeaponLayer } from '../content/classes'
export type PartyAttackPulse = {
startedAt: number
}
export type PartyRenderMotion = {
x: number
y: number
rotation: number
scaleX: number
scaleY: number
tint?: number
}
export type PartyWeaponLayerMotion = {
offsetXScale: number
offsetYScale: number
rotation: number
}
const ATTACK_DURATION_SECONDS = 0.22
const RANGED_ATTACK_DURATION_SECONDS = 0.16
export function partyRenderMotion(
entity: Iwt2PartyEntityState,
timeSeconds: number,
attackPulse?: PartyAttackPulse,
): PartyRenderMotion {
const speed = Math.hypot(entity.velocity.x, entity.velocity.y)
const facingX = entity.facing.x < -0.05 ? -1 : 1
const motion: PartyRenderMotion = {
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.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0) {
return { ...motion, y: 3, rotation: -facingX * 0.12, scaleY: 0.9 }
}
const attackProgress = attackPulse ? attackProgressFor(entity, timeSeconds, attackPulse) : 1
if (attackProgress < 1) {
const strike = Math.sin(attackProgress * Math.PI)
const ranged = entity.projectileSpeed > 0
return {
...motion,
x: facingX * (ranged ? -3 : 4) * strike,
y: -1.5 * strike,
rotation: facingX * (ranged ? -0.05 : 0.09) * strike,
scaleX: 1 + (ranged ? 0.012 : 0.035) * strike,
scaleY: 1 - (ranged ? 0.006 : 0.018) * strike,
tint: ranged ? 0xdff3ff : 0xffe1a6,
}
}
if (entity.castSecondsRemaining > 0) {
const pulse = 0.5 + Math.sin(timeSeconds * Math.PI * 12) * 0.5
return {
...motion,
y: -1 - pulse * 1.5,
scaleX: 1 + pulse * 0.012,
scaleY: 1 + pulse * 0.012,
tint: entity.classId === 'mage' ? 0xefc4ff : 0xb9e3ff,
}
}
if (speed > 10) {
const stride = Math.sin(timeSeconds * Math.PI * 9)
const lift = Math.abs(stride) * Math.min(2.8, speed / 90)
return {
...motion,
y: -lift,
rotation: facingX * 0.025 * Math.min(1, speed / 190),
scaleY: 1 - Math.abs(stride) * 0.01,
}
}
const idle = Math.sin(timeSeconds * Math.PI * 2.2)
return {
...motion,
y: -Math.max(0, idle) * 0.9,
scaleY: 1 + Math.max(0, idle) * 0.008,
}
}
export function isPartyAttackPulseActive(
entity: Iwt2PartyEntityState,
timeSeconds: number,
attackPulse: PartyAttackPulse,
): boolean {
return attackProgressFor(entity, timeSeconds, attackPulse) < 1
}
export function partyWeaponLayerMotion(
entity: Iwt2PartyEntityState,
layer: Iwt2ClassWeaponLayer,
timeSeconds: number,
attackPulse?: PartyAttackPulse,
): PartyWeaponLayerMotion {
if (!attackPulse || entity.health <= 0) return { offsetXScale: 0, offsetYScale: 0, rotation: 0 }
const progress = attackProgressFor(entity, timeSeconds, attackPulse)
if (progress >= 1) return { offsetXScale: 0, offsetYScale: 0, rotation: 0 }
const strike = Math.sin(progress * Math.PI)
const attackMotion = layer.attackMotion
return {
offsetXScale: (attackMotion?.offsetXScale ?? 0) * strike,
offsetYScale: (attackMotion?.offsetYScale ?? 0) * strike,
rotation: (attackMotion?.rotation ?? 0) * strike,
}
}
function attackProgressFor(
entity: Iwt2PartyEntityState,
timeSeconds: number,
attackPulse: PartyAttackPulse,
): number {
const duration = entity.projectileSpeed > 0 ? RANGED_ATTACK_DURATION_SECONDS : ATTACK_DURATION_SECONDS
return Math.max(0, Math.min(1, (timeSeconds - attackPulse.startedAt) / duration))
}
@@ -3,6 +3,12 @@ import type { MovementVector } from '../../../../input'
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../../content/bosses'
import { IWT2_CLASS_METADATA, type Iwt2PlayerClassId } from '../../content/classes'
import { bossRenderMotion } from '../bossAnimation'
import {
isPartyAttackPulseActive,
partyRenderMotion,
partyWeaponLayerMotion,
type PartyAttackPulse,
} from '../partyAnimation'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
@@ -27,6 +33,11 @@ type FloatingCombatTextAnchor = {
position: Iwt2Vec2
radius: number
}
type PartyLayerSprites = {
body: Phaser.GameObjects.Image
weapons: Map<string, Phaser.GameObjects.Image>
accents: Map<string, Phaser.GameObjects.Image>
}
export class BulldromeArenaScene extends Phaser.Scene {
private deps: SceneDeps
@@ -37,10 +48,14 @@ export class BulldromeArenaScene extends Phaser.Scene {
private labels = new Map<string, Phaser.GameObjects.Text>()
private bossSprites = new Map<string, Phaser.GameObjects.Image>()
private partySprites = new Map<string, Phaser.GameObjects.Image>()
private partyLayerSprites = new Map<string, PartyLayerSprites>()
private projectileSprites = new Map<string, Phaser.GameObjects.Image>()
private partyAttackPulses = new Map<string, PartyAttackPulse>()
private livePartyEffectIds = new Set<string>()
private floatingCombatTexts = new Map<number, Phaser.GameObjects.Text>()
private castGlowEffects = new Set<Phaser.GameObjects.Graphics>()
private lastEntityAnchors = new Map<string, FloatingCombatTextAnchor>()
private lastPartyAnimationEventId = 0
private lastFloatingEventId = 0
private lastStateTime = 0
private lastHudPublish = 0
@@ -60,6 +75,12 @@ export class BulldromeArenaScene extends Phaser.Scene {
}
for (const metadata of Object.values(IWT2_CLASS_METADATA)) {
this.load.image(classArenaSpriteKey(metadata.id), metadata.arenaSpriteUrl)
if (metadata.arenaLayeredSprite) {
this.load.image(classArenaBodySpriteKey(metadata.id), metadata.arenaLayeredSprite.bodyUrl)
for (const layer of metadata.arenaLayeredSprite.weaponLayers) {
this.load.image(classArenaLayerSpriteKey(metadata.id, layer.id), layer.url)
}
}
}
this.load.image(projectileSpriteKey('arrow'), '/iwt2/projectiles/ranger-arrow.png')
this.load.image(projectileSpriteKey('fireball'), '/iwt2/projectiles/mage-fireball.png')
@@ -83,6 +104,7 @@ export class BulldromeArenaScene extends Phaser.Scene {
if (!this.arenaGraphics || !this.entityGraphics || !this.telegraphGraphics) return
this.drawArena(state)
this.drawTelegraphs(state)
this.updatePartyAnimations(state)
this.drawEntities(state)
this.drawProjectiles(state)
this.drawFloatingCombatTexts(state)
@@ -160,9 +182,13 @@ export class BulldromeArenaScene extends Phaser.Scene {
graphics.lineStyle(4, 0xfff4a8, 0.95)
graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius + 8)
}
if (this.textures.exists(classArenaSpriteKey(entity.classId))) {
const metadata = IWT2_CLASS_METADATA[entity.classId]
if (metadata.arenaLayeredSprite && this.textures.exists(classArenaBodySpriteKey(entity.classId))) {
drawPartyUnderlay(graphics, entity, color, alpha)
this.drawPartySprite(entity, alpha)
this.drawLayeredPartySprite(entity, alpha, state.time)
} else if (this.textures.exists(classArenaSpriteKey(entity.classId))) {
drawPartyUnderlay(graphics, entity, color, alpha)
this.drawPartySprite(entity, alpha, state.time)
} else {
graphics.fillStyle(Number.parseInt(color.slice(1), 16), alpha)
graphics.fillCircle(entity.position.x, entity.position.y, entity.radius)
@@ -187,6 +213,15 @@ export class BulldromeArenaScene extends Phaser.Scene {
}
}
for (const [id, sprites] of this.partyLayerSprites) {
if (!livePartyIds.has(id)) {
sprites.body.destroy()
for (const sprite of sprites.weapons.values()) sprite.destroy()
for (const sprite of sprites.accents.values()) sprite.destroy()
this.partyLayerSprites.delete(id)
}
}
for (const [id, label] of this.labels) {
if (!liveLabelIds.has(id)) {
label.destroy()
@@ -260,23 +295,203 @@ export class BulldromeArenaScene extends Phaser.Scene {
.setAlpha(alpha)
}
private drawPartySprite(entity: Iwt2PartyEntityState, alpha: number) {
private drawLayeredPartySprite(entity: Iwt2PartyEntityState, alpha: number, timeSeconds: number) {
const metadata = IWT2_CLASS_METADATA[entity.classId].arenaLayeredSprite
if (!metadata) return
let sprites = this.partyLayerSprites.get(entity.id)
if (!sprites) {
sprites = {
body: this.add.image(entity.position.x, entity.position.y, classArenaBodySpriteKey(entity.classId)).setOrigin(0.5, 0.66).setDepth(40),
weapons: new Map<string, Phaser.GameObjects.Image>(),
accents: new Map<string, Phaser.GameObjects.Image>(),
}
this.partyLayerSprites.set(entity.id, sprites)
}
const fallback = this.partySprites.get(entity.id)
if (fallback) {
fallback.destroy()
this.partySprites.delete(entity.id)
}
const baseHeight = entity.radius * (metadata.bodyHeightScale ?? 4.55)
const motion = partyRenderMotion(entity, timeSeconds, this.partyAttackPulses.get(entity.id))
applyMotionTint(sprites.body, motion.tint)
sprites.body
.setTexture(classArenaBodySpriteKey(entity.classId))
.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)
.setRotation(motion.rotation)
.setFlipX(entity.facing.x < -0.05)
.setAlpha(alpha)
const liveLayerIds = new Set<string>()
for (const layer of metadata.weaponLayers) {
liveLayerIds.add(layer.id)
let sprite = sprites.weapons.get(layer.id)
const key = classArenaLayerSpriteKey(entity.classId, layer.id)
if (!sprite) {
sprite = this.add.image(entity.position.x, entity.position.y, key).setOrigin(layer.originX ?? 0.5, layer.originY ?? 0.5)
sprites.weapons.set(layer.id, sprite)
}
const flipX = entity.facing.x < -0.05
const facingSign = flipX ? -1 : 1
const layerMotion = partyWeaponLayerMotion(entity, layer, timeSeconds, this.partyAttackPulses.get(entity.id))
const layerHeight = entity.radius * layer.heightScale
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 displayWidth = layerHeight * (sprite.width / Math.max(1, sprite.height)) * motion.scaleX
const displayHeight = layerHeight * motion.scaleY
const rotation = motion.rotation + facingSign * layerMotion.rotation
this.drawGearTierAccent({
alpha,
displayHeight,
displayWidth,
entity,
flipX,
key,
layer,
layerId: layer.id,
positionX,
positionY,
rotation,
sprites,
})
applyMotionTint(sprite, motion.tint)
sprite
.setTexture(key)
.setDepth(layer.drawOrder === 'behindBody' ? 38 : 42)
.setPosition(positionX, positionY)
.setDisplaySize(displayWidth, displayHeight)
.setRotation(rotation)
.setFlipX(flipX)
.setAlpha(alpha)
}
for (const [id, sprite] of sprites.weapons) {
if (!liveLayerIds.has(id)) {
sprite.destroy()
sprites.weapons.delete(id)
}
}
for (const [id, sprite] of sprites.accents) {
if (!liveLayerIds.has(id)) {
sprite.destroy()
sprites.accents.delete(id)
}
}
}
private drawGearTierAccent({
alpha,
displayHeight,
displayWidth,
entity,
flipX,
key,
layer,
layerId,
positionX,
positionY,
rotation,
sprites,
}: {
alpha: number
displayHeight: number
displayWidth: number
entity: Iwt2PartyEntityState
flipX: boolean
key: string
layer: NonNullable<(typeof IWT2_CLASS_METADATA)[Iwt2PlayerClassId]['arenaLayeredSprite']>['weaponLayers'][number]
layerId: string
positionX: number
positionY: number
rotation: number
sprites: PartyLayerSprites
}) {
const tierColor = gearTierAccentColor(gearLayerLevel(entity, layer.slot))
let accent = sprites.accents.get(layerId)
if (!layer.tierAccentEligible || !tierColor) {
if (accent) accent.setVisible(false)
return
}
if (!accent) {
accent = this.add.image(positionX, positionY, key).setOrigin(layer.originX ?? 0.5, layer.originY ?? 0.5)
sprites.accents.set(layerId, accent)
}
accent
.setVisible(true)
.setTexture(key)
.setDepth(layer.drawOrder === 'behindBody' ? 37 : 41)
.setPosition(positionX, positionY)
.setDisplaySize(displayWidth * 1.08, displayHeight * 1.12)
.setRotation(rotation)
.setFlipX(flipX)
.setTint(tierColor)
.setAlpha(alpha * 0.82)
}
private drawPartySprite(entity: Iwt2PartyEntityState, alpha: number, timeSeconds: number) {
const key = classArenaSpriteKey(entity.classId)
let sprite = this.partySprites.get(entity.id)
if (!sprite) {
sprite = this.add.image(entity.position.x, entity.position.y, key).setOrigin(0.5, 0.66).setDepth(40)
this.partySprites.set(entity.id, sprite)
}
const layered = this.partyLayerSprites.get(entity.id)
if (layered) {
layered.body.destroy()
for (const layerSprite of layered.weapons.values()) layerSprite.destroy()
for (const layerSprite of layered.accents.values()) layerSprite.destroy()
this.partyLayerSprites.delete(entity.id)
}
const displayHeight = entity.radius * 4.55
const motion = partyRenderMotion(entity, timeSeconds, this.partyAttackPulses.get(entity.id))
if (motion.tint) {
sprite.setTint(motion.tint)
} else {
sprite.clearTint()
}
sprite
.setTexture(key)
.setPosition(entity.position.x, entity.position.y + entity.radius * 0.18)
.setDisplaySize(displayHeight * (sprite.width / Math.max(1, sprite.height)), displayHeight)
.setPosition(entity.position.x + motion.x, entity.position.y + entity.radius * 0.18 + motion.y)
.setDisplaySize(
displayHeight * (sprite.width / Math.max(1, sprite.height)) * motion.scaleX,
displayHeight * motion.scaleY,
)
.setRotation(motion.rotation)
.setFlipX(entity.facing.x < -0.05)
.setAlpha(alpha)
}
private updatePartyAnimations(state: Iwt2ArenaState) {
if (state.time < this.lastStateTime) {
this.lastPartyAnimationEventId = 0
this.partyAttackPulses.clear()
}
this.livePartyEffectIds.clear()
for (const member of state.party) this.livePartyEffectIds.add(member.id)
for (const event of state.events) {
if (event.id <= this.lastPartyAnimationEventId) continue
if (event.type === 'partyAttack' && this.livePartyEffectIds.has(event.sourceId)) {
this.partyAttackPulses.set(event.sourceId, { startedAt: event.time })
}
this.lastPartyAnimationEventId = Math.max(this.lastPartyAnimationEventId, event.id)
}
for (const member of state.party) {
const attackPulse = this.partyAttackPulses.get(member.id)
if (attackPulse && !isPartyAttackPulseActive(member, state.time, attackPulse)) {
this.partyAttackPulses.delete(member.id)
}
}
for (const id of this.partyAttackPulses.keys()) {
if (!this.livePartyEffectIds.has(id)) this.partyAttackPulses.delete(id)
}
}
private drawLabel(entity: DrawableEntity) {
let label = this.labels.get(entity.id)
const icon = entity.kind === 'party' ? '!' : entityIcon(entity)
@@ -432,6 +647,30 @@ function entityIcon(entity: DrawableEntity): string {
return IWT2_CLASS_METADATA[entity.classId].icon
}
function applyMotionTint(sprite: Phaser.GameObjects.Image, tint?: number) {
if (tint) {
sprite.setTint(tint)
} else {
sprite.clearTint()
}
}
function gearLayerLevel(entity: Iwt2PartyEntityState, slot: string): number {
if (slot === 'weapon') return entity.gearLevels?.weapon ?? 0
if (slot === 'head') return entity.gearLevels?.helmet ?? 0
if (slot === 'chest') return entity.gearLevels?.chest ?? 0
if (slot === 'legs') return entity.gearLevels?.legs ?? 0
return 0
}
function gearTierAccentColor(level: number): number | undefined {
if (level <= 0) return undefined
if (level <= 2) return 0x55e887
if (level === 3) return 0x58a8ff
if (level === 4) return 0xc56cf0
return 0xffd25f
}
function bossSpriteKey(bossId: Iwt2BossId): string {
return `iwt2-boss-${bossId}`
}
@@ -440,6 +679,14 @@ function classArenaSpriteKey(classId: Iwt2PlayerClassId): string {
return `iwt2-class-${classId}`
}
function classArenaBodySpriteKey(classId: Iwt2PlayerClassId): string {
return `iwt2-class-${classId}-body`
}
function classArenaLayerSpriteKey(classId: Iwt2PlayerClassId, layerId: string): string {
return `iwt2-class-${classId}-${layerId}`
}
function projectileSpriteKey(projectileKind: 'arrow' | 'fireball' | 'magic'): string {
return projectileKind === 'arrow' ? 'iwt2-projectile-arrow' : 'iwt2-projectile-fireball'
}
+25 -20
View File
@@ -72,6 +72,7 @@ type BossArenaScreenProps = {
modeLabel?: string
save: Iwt2Save
onBack: () => void
onMainMenu?: () => void
onPvpRequeue?: () => void
onSaveUpdated: (save: Iwt2Save) => void
roguelikeRun?: {
@@ -84,7 +85,7 @@ type BossArenaScreenProps = {
}
}
export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save, onBack, onPvpRequeue, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) {
export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save, onBack, onMainMenu, onPvpRequeue, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) {
const bossMetadata = IWT2_BOSS_METADATA[bossId]
const pvpRoguelike = roguelikeRun?.variant === 'pvp'
const pveGearActive = roguelikeRun?.variant !== 'pvp'
@@ -208,9 +209,15 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => {
setSelectedOverlayAction('primary')
statusRef.current = nextStatus
setStatus(nextStatus)
}, [])
const resumeArena = useCallback(() => {
statusRef.current = 'playing'
setStatus('playing')
}, [])
const abilities = useMemo(
() => {
const baseAbilities = abilitiesForHealer(
@@ -268,11 +275,16 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
return
}
if (overlayAction === 'menu') {
if (statusRef.current === 'paused') {
const returnToMainMenu = onMainMenu ?? onBack
returnToMainMenu()
return
}
onBack()
return
}
if (statusRef.current === 'paused') {
setStatus('playing')
resumeArena()
return
}
if (statusRef.current === 'victory' && roguelikeRun) {
@@ -280,10 +292,10 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
return
}
resetArena()
}, [onBack, onPvpRequeue, resetArena, roguelikeRun])
}, [onBack, onMainMenu, onPvpRequeue, resetArena, resumeArena, roguelikeRun])
useGameAction((action, device) => {
if (device === 'controller' && statusRef.current !== 'playing') {
if (statusRef.current !== 'playing' && (device === 'controller' || device === 'pc')) {
if (action.startsWith('navigate')) {
moveOverlaySelection(action)
return
@@ -293,10 +305,15 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
return
}
if (action === 'back') {
if (statusRef.current === 'paused') setStatus('playing')
if (statusRef.current === 'paused') resumeArena()
return
}
}
if (action === 'pause' || action === 'back') {
if (statusRef.current === 'playing') showOverlay('paused')
else if (statusRef.current === 'paused') resumeArena()
return
}
if (statusRef.current === 'playing' && action.startsWith('ability')) {
const ability = abilities[IWT2_ABILITY_ACTIONS.indexOf(action)]
if (ability) castAbility(ability)
@@ -325,11 +342,6 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
if (target) setSelectedPartyId(target.id)
return
}
if (device !== 'controller') return
if (action === 'pause' || action === 'back') {
if (statusRef.current === 'playing') showOverlay('paused')
else if (statusRef.current === 'paused') setStatus('playing')
}
})
const onStep = useCallback((movement: MovementVector, dtSeconds: number) => {
@@ -438,12 +450,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
const pauseTitle = arenaPauseTitle(roguelikeRun, modeLabel ?? bossMetadata.name)
const pauseCopy = roguelikeRun?.variant === 'pvp'
? undefined
: 'Combat is stopped. Resume the fight or leave the current run.'
const pauseLeaveLabel = roguelikeRun?.variant === 'pvp'
? 'Leave'
: roguelikeRun
? 'Leave Roguelike'
: `Leave ${modeLabel ?? 'Arena'}`
: 'Combat is stopped. Continue the fight or return to the main menu.'
const dualScreenState = useMemo(
() => buildIwt2DualScreenCombatState({
abilities,
@@ -508,23 +515,21 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
{pauseCopy && <p>{pauseCopy}</p>}
<button
className={selectedOverlayAction === 'primary' ? 'game-selected' : ''}
data-controller-nav="skip"
data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined}
onClick={() => activateOverlayAction('primary')}
onPointerDown={() => setSelectedOverlayAction('primary')}
type="button"
>
Resume
Continue
</button>
<button
className={`secondary-result-button ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined}
onClick={() => activateOverlayAction('menu')}
onPointerDown={() => setSelectedOverlayAction('menu')}
type="button"
>
{pauseLeaveLabel}
Main Menu
</button>
</div>
</div>
@@ -54,6 +54,7 @@ import {
iwt2GearUpgradeCosts,
iwt2InfusionCosts,
isIwt2InfusionUnlocked,
type Iwt2GearStatId,
type Iwt2GearSlotId,
} from '../content/gear'
import {
@@ -2021,6 +2022,9 @@ export function Iwt2GearUpgradeScreen({
const classProgress = save.gearProgress[selectedClassId]
const selectedSlot = classProgress.slots[selectedSlotId]
const selectedRecipe = IWT2_GEAR_SLOT_RECIPES[selectedClassId][selectedSlotId]
const selectedBonus = gearBonusSummary(selectedRecipe.statId, selectedSlot.level, selectedClassId)
const nextLevel = Math.min(5, selectedSlot.level + 1)
const nextBonus = gearBonusSummary(selectedRecipe.statId, nextLevel, selectedClassId)
const selectedClassName = gearClassDisplayName(selectedClassId, save)
const upgradeCosts = iwt2GearUpgradeCosts(selectedClassId, selectedSlotId, selectedSlot.level)
const canUpgrade = selectedSlot.level < 5 && canAffordIwt2Costs(save, upgradeCosts)
@@ -2186,6 +2190,16 @@ export function Iwt2GearUpgradeScreen({
<strong>{IWT2_GEAR_SLOT_LABELS[selectedSlotId]} +{selectedSlot.level}</strong>
<small>{IWT2_GEAR_STAT_LABELS[selectedRecipe.statId]} from {bossName(selectedRecipe.primaryBossId)} and {bossName(selectedRecipe.secondaryBossId)} coins.</small>
</p>
<div className="iwt2-gear-bonus-grid">
<span>
<strong>Current bonus</strong>
<small>{selectedBonus.text}</small>
</span>
<span>
<strong>{selectedSlot.level >= 5 ? 'Max rank' : `Upgrade preview +${selectedSlot.level} -> +${nextLevel}`}</strong>
<small>{selectedSlot.level >= 5 ? infusionAnchorText(classProgress.slots[selectedSlotId].level) : `${selectedBonus.label}: ${selectedBonus.value} -> ${nextBonus.value}`}</small>
</span>
</div>
<div className="iwt2-gear-cost-list">
{upgradeCosts.length === 0 ? (
<span>Max rank reached.</span>
@@ -2245,6 +2259,38 @@ function gearClassSubtitle(classId: Iwt2PlayerClassId, save: Iwt2Save): string {
return role === 'damage' ? 'Damage' : 'Tank'
}
function gearBonusSummary(
statId: Iwt2GearStatId,
level: number,
classId: Iwt2PlayerClassId,
): { label: string, text: string, value: string } {
const bonus = Math.max(0, level)
if (statId === 'maxHealth') return gearBonus('Max health', `+${bonus * 4}%`)
if (statId === 'moveSpeed') return gearBonus('Move speed', `+${formatPercent(bonus * 2.5)}%`)
if (statId === 'damage') return gearBonus('Attack damage', `+${bonus * 4}%`)
if (statId === 'attackCooldown') {
const label = classId === 'healer' ? 'Ability cooldown' : 'Attack cooldown'
return gearBonus(label, `-${bonus * 3}%`)
}
if (statId === 'projectileSpeed') return gearBonus('Projectile speed', `+${bonus * 3}%`)
if (statId === 'hazardDamageTaken') return gearBonus('Hazard damage taken', `-${bonus * 3}%`)
if (statId === 'stunResist') return gearBonus('Stun/knockdown duration', `-${bonus * 8}%`)
if (statId === 'healingPower') return gearBonus('Ability healing', `+${bonus * 4}%`)
return gearBonus('Bonus', '+0%')
}
function gearBonus(label: string, value: string): { label: string, text: string, value: string } {
return { label, text: `${label} ${value}`, value }
}
function infusionAnchorText(level: number): string {
return level >= 5 ? 'Infusion anchor available.' : 'No further bonus.'
}
function formatPercent(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1)
}
function clampToEnabled(actions: Iwt2NavAction[], index: number) {
if (actions.length === 0) return 0
const bounded = Math.min(Math.max(0, index), actions.length - 1)
+14
View File
@@ -846,6 +846,13 @@ function applyPartyAttacks(
damage: member.attackDamage,
remainingSeconds: 1.2,
})
events.push({
id: 0,
time,
type: 'partyAttack',
sourceId: member.id,
targetId: target.id,
})
projectileId += 1
return {
...member,
@@ -876,6 +883,13 @@ function applyPartyAttacks(
targetId: target.id,
value: damage,
})
events.push({
id: 0,
time,
type: 'partyAttack',
sourceId: member.id,
targetId: target.id,
})
if (defeated) {
events.push({
id: 0,
+10 -1
View File
@@ -43,7 +43,16 @@ function applyMemberGearStats(
gearProgress: Iwt2GearProgress,
): Iwt2PartyEntityState {
const classProgress = gearProgress[member.classId]
let next = { ...member }
let next: Iwt2PartyEntityState = {
...member,
gearLevels: {
weapon: classProgress.slots.weapon.level,
helmet: classProgress.slots.helmet.level,
chest: classProgress.slots.chest.level,
legs: classProgress.slots.legs.level,
feet: classProgress.slots.feet.level,
},
}
for (const slotId of IWT2_GEAR_SLOTS) {
const level = classProgress.slots[slotId].level
if (level <= 0) continue
+2 -7
View File
@@ -9,18 +9,13 @@ import { applyPartyDamageToMember, createArenaEvent } from './mechanics'
const ROGUELIKE_PRESSURE_INTERVAL_SECONDS = 5
const ROGUELIKE_PRESSURE_BASE_DAMAGE = 9
const ROGUELIKE_PRESSURE_INITIAL_STAGE_DAMAGE = 2
const ROGUELIKE_DUNGEON_DAMAGE_BASE_SCALE = 1.25
const ROGUELIKE_RAID_DAMAGE_BASE_SCALE = 1.45
const ROGUELIKE_PRESSURE_SOURCE_ID = 'roguelike-pressure'
export function roguelikeIncomingDamageScale(
_stage: number,
contentType: Iwt2RoguelikePressureContentType,
_contentType: Iwt2RoguelikePressureContentType,
): number {
const base = contentType === 'dungeon'
? ROGUELIKE_DUNGEON_DAMAGE_BASE_SCALE
: ROGUELIKE_RAID_DAMAGE_BASE_SCALE
return base
return 1
}
export function createRoguelikePressureState(
+3
View File
@@ -1,5 +1,6 @@
import type { Iwt2BossId } from '../content/bosses'
import type { Iwt2PlayerClassId } from '../content/classes'
import type { Iwt2GearLevel, Iwt2GearSlotId } from '../content/gear'
import type { Iwt2InfusionAbilityId } from '../content/infusionAbilities'
export type Iwt2Vec2 = {
@@ -70,6 +71,7 @@ export type Iwt2PartyEntityState = {
mana: number
maxMana: number
damageDone: number
gearLevels?: Partial<Record<Iwt2GearSlotId, Iwt2GearLevel>>
attackCooldownRemaining: number
castSecondsRemaining: number
attackReady: boolean
@@ -283,6 +285,7 @@ export type Iwt2GroundHazardState = {
export type Iwt2ArenaEventType =
| 'bossDamaged'
| 'healerSpellCast'
| 'partyAttack'
| 'partyHealed'
| 'partyDamaged'
| 'partyStunned'
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB