diff --git a/IWantToHeal-Thor-v1.1.27.apk b/IWantToHeal-Thor-v1.1.27.apk new file mode 100644 index 0000000..3260c52 Binary files /dev/null and b/IWantToHeal-Thor-v1.1.27.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index 495f50e..9a57ddd 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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. diff --git a/public/iwt2/classes/arena/layers/warrior-body.png b/public/iwt2/classes/arena/layers/warrior-body.png new file mode 100644 index 0000000..6e0c456 Binary files /dev/null and b/public/iwt2/classes/arena/layers/warrior-body.png differ diff --git a/public/iwt2/classes/arena/layers/warrior-weapon-axe.png b/public/iwt2/classes/arena/layers/warrior-weapon-axe.png new file mode 100644 index 0000000..bfa74f7 Binary files /dev/null and b/public/iwt2/classes/arena/layers/warrior-weapon-axe.png differ diff --git a/screenshots/iwt2-gear-bonus-actual.png b/screenshots/iwt2-gear-bonus-actual.png new file mode 100644 index 0000000..12df167 Binary files /dev/null and b/screenshots/iwt2-gear-bonus-actual.png differ diff --git a/src/App.css b/src/App.css index 735713c..6858253 100644 --- a/src/App.css +++ b/src/App.css @@ -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); diff --git a/src/components/CombatScreen.tsx b/src/components/CombatScreen.tsx index da95f59..a16b264 100644 --- a/src/components/CombatScreen.tsx +++ b/src/components/CombatScreen.tsx @@ -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({

Game Paused

{dungeon.name}

-

Combat is stopped. Resume the fight or leave the current run.

- - +
diff --git a/src/components/PvpRoguelikeScreen.tsx b/src/components/PvpRoguelikeScreen.tsx index 26a57f3..de738cb 100644 --- a/src/components/PvpRoguelikeScreen.tsx +++ b/src/components/PvpRoguelikeScreen.tsx @@ -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 diff --git a/src/modes/iwt1/IWantToHeal1App.tsx b/src/modes/iwt1/IWantToHeal1App.tsx index 2df753f..2949175 100644 --- a/src/modes/iwt1/IWantToHeal1App.tsx +++ b/src/modes/iwt1/IWantToHeal1App.tsx @@ -860,6 +860,7 @@ function IWantToHeal1App({ onExit={() => { setScreen(combatContentId < 0 ? 'roguelike' : dungeon.contentType === 'raid' ? 'raids' : 'dungeons') }} + onMainMenu={() => setScreen('menu')} onProfileUpdated={setProfile} /> @@ -893,6 +894,10 @@ function IWantToHeal1App({ setRoguelikeVariant('pvp') setScreen('roguelike') }} + onMainMenu={() => { + setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType)) + setScreen('menu') + }} onProfileUpdated={setProfile} profile={profile} /> diff --git a/src/modes/iwt2/IWantToHeal2App.tsx b/src/modes/iwt2/IWantToHeal2App.tsx index e730839..0872c49 100644 --- a/src/modes/iwt2/IWantToHeal2App.tsx +++ b/src/modes/iwt2/IWantToHeal2App.tsx @@ -258,6 +258,7 @@ export function IWantToHeal2App({ }} save={save} onBack={() => setScreen('roguelike')} + onMainMenu={() => setScreen('menu')} onPvpRequeue={() => { setScreen('roguelike') startIwt2RoguelikeRun() diff --git a/src/modes/iwt2/content/bosses.ts b/src/modes/iwt2/content/bosses.ts index 39710ff..2b44b66 100644 --- a/src/modes/iwt2/content/bosses.ts +++ b/src/modes/iwt2/content/bosses.ts @@ -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, diff --git a/src/modes/iwt2/content/classes.ts b/src/modes/iwt2/content/classes.ts index db257b2..e0800d7 100644 --- a/src/modes/iwt2/content/classes.ts +++ b/src/modes/iwt2/content/classes.ts @@ -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 = 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', diff --git a/src/modes/iwt2/render/partyAnimation.ts b/src/modes/iwt2/render/partyAnimation.ts new file mode 100644 index 0000000..66dc57f --- /dev/null +++ b/src/modes/iwt2/render/partyAnimation.ts @@ -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)) +} diff --git a/src/modes/iwt2/render/scenes/BulldromeArenaScene.ts b/src/modes/iwt2/render/scenes/BulldromeArenaScene.ts index 6acc34d..8377c1d 100644 --- a/src/modes/iwt2/render/scenes/BulldromeArenaScene.ts +++ b/src/modes/iwt2/render/scenes/BulldromeArenaScene.ts @@ -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 + accents: Map +} export class BulldromeArenaScene extends Phaser.Scene { private deps: SceneDeps @@ -37,10 +48,14 @@ export class BulldromeArenaScene extends Phaser.Scene { private labels = new Map() private bossSprites = new Map() private partySprites = new Map() + private partyLayerSprites = new Map() private projectileSprites = new Map() + private partyAttackPulses = new Map() + private livePartyEffectIds = new Set() private floatingCombatTexts = new Map() private castGlowEffects = new Set() private lastEntityAnchors = new Map() + 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(), + accents: new Map(), + } + 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() + 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' } diff --git a/src/modes/iwt2/screens/BossArenaScreen.tsx b/src/modes/iwt2/screens/BossArenaScreen.tsx index d229cdc..807aea3 100644 --- a/src/modes/iwt2/screens/BossArenaScreen.tsx +++ b/src/modes/iwt2/screens/BossArenaScreen.tsx @@ -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) => { 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 &&

{pauseCopy}

} diff --git a/src/modes/iwt2/screens/Iwt2ShellScreens.tsx b/src/modes/iwt2/screens/Iwt2ShellScreens.tsx index 5f80f35..d25f155 100644 --- a/src/modes/iwt2/screens/Iwt2ShellScreens.tsx +++ b/src/modes/iwt2/screens/Iwt2ShellScreens.tsx @@ -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({ {IWT2_GEAR_SLOT_LABELS[selectedSlotId]} +{selectedSlot.level} {IWT2_GEAR_STAT_LABELS[selectedRecipe.statId]} from {bossName(selectedRecipe.primaryBossId)} and {bossName(selectedRecipe.secondaryBossId)} coins.

+
+ + Current bonus + {selectedBonus.text} + + + {selectedSlot.level >= 5 ? 'Max rank' : `Upgrade preview +${selectedSlot.level} -> +${nextLevel}`} + {selectedSlot.level >= 5 ? infusionAnchorText(classProgress.slots[selectedSlotId].level) : `${selectedBonus.label}: ${selectedBonus.value} -> ${nextBonus.value}`} + +
{upgradeCosts.length === 0 ? ( Max rank reached. @@ -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) diff --git a/src/modes/iwt2/sim/arena.ts b/src/modes/iwt2/sim/arena.ts index 3542a35..d02b5d2 100644 --- a/src/modes/iwt2/sim/arena.ts +++ b/src/modes/iwt2/sim/arena.ts @@ -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, diff --git a/src/modes/iwt2/sim/equipmentStats.ts b/src/modes/iwt2/sim/equipmentStats.ts index bb9825f..0f412e1 100644 --- a/src/modes/iwt2/sim/equipmentStats.ts +++ b/src/modes/iwt2/sim/equipmentStats.ts @@ -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 diff --git a/src/modes/iwt2/sim/roguelikePressure.ts b/src/modes/iwt2/sim/roguelikePressure.ts index 57b65db..9c33b4e 100644 --- a/src/modes/iwt2/sim/roguelikePressure.ts +++ b/src/modes/iwt2/sim/roguelikePressure.ts @@ -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( diff --git a/src/modes/iwt2/sim/types.ts b/src/modes/iwt2/sim/types.ts index 145ce6e..bdba562 100644 --- a/src/modes/iwt2/sim/types.ts +++ b/src/modes/iwt2/sim/types.ts @@ -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> attackCooldownRemaining: number castSecondsRemaining: number attackReady: boolean @@ -283,6 +285,7 @@ export type Iwt2GroundHazardState = { export type Iwt2ArenaEventType = | 'bossDamaged' | 'healerSpellCast' + | 'partyAttack' | 'partyHealed' | 'partyDamaged' | 'partyStunned' diff --git a/tmp/iwt2-layer-generation/warrior-axe-source.png b/tmp/iwt2-layer-generation/warrior-axe-source.png new file mode 100644 index 0000000..3dfb217 Binary files /dev/null and b/tmp/iwt2-layer-generation/warrior-axe-source.png differ diff --git a/tmp/iwt2-layer-generation/warrior-body-source.png b/tmp/iwt2-layer-generation/warrior-body-source.png new file mode 100644 index 0000000..303edda Binary files /dev/null and b/tmp/iwt2-layer-generation/warrior-body-source.png differ diff --git a/tmp/iwt2-layer-generation/warrior-layer-preview.png b/tmp/iwt2-layer-generation/warrior-layer-preview.png new file mode 100644 index 0000000..a0c87c4 Binary files /dev/null and b/tmp/iwt2-layer-generation/warrior-layer-preview.png differ diff --git a/tmp/iwt2-layer-generation/warrior-runtime-layer-preview.png b/tmp/iwt2-layer-generation/warrior-runtime-layer-preview.png new file mode 100644 index 0000000..6328ede Binary files /dev/null and b/tmp/iwt2-layer-generation/warrior-runtime-layer-preview.png differ