Release v0.1.14 2026-07-13
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "i-want-to-heal",
|
||||
"private": true,
|
||||
"version": "0.1.13",
|
||||
"version": "0.1.14",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"predev": "node scripts/sync_basis_transcoder.mjs",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, upcomingEnc
|
||||
import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../game/roguelike";
|
||||
import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
|
||||
import type { BottomTab, PartyMember } from "../game/types";
|
||||
import { useFrontendStore } from "../frontend/store";
|
||||
import { useActiveHunter, useFrontendStore } from "../frontend/store";
|
||||
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
||||
|
||||
function RewardSummary() {
|
||||
@@ -192,6 +192,7 @@ function BriefingPanel() {
|
||||
}
|
||||
|
||||
function EndPanel({ onExit }: { onExit?: () => void }) {
|
||||
const hunter = useActiveHunter();
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const runMode = useGameStore((state) => state.runMode);
|
||||
const round = useGameStore((state) => state.round);
|
||||
@@ -208,6 +209,8 @@ function EndPanel({ onExit }: { onExit?: () => void }) {
|
||||
const totalMax = party.reduce((sum, member) => sum + member.maxHp, 0);
|
||||
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
|
||||
const endlessDefeat = phase === "defeat" && endlessMode;
|
||||
const endlessHighScore = hunter?.stats.highestRogueTrialsEndlessKills ?? 0;
|
||||
const endlessHighScoreLabel = `${endlessHighScore} ${endlessHighScore === 1 ? "boss" : "bosses"}`;
|
||||
return (
|
||||
<div className={`end-panel end-${phase}`}>
|
||||
<span className="end-mark">{phase === "victory" ? "✦" : "×"}</span>
|
||||
@@ -225,7 +228,8 @@ function EndPanel({ onExit }: { onExit?: () => void }) {
|
||||
onFocus={() => setEndlessChoiceSelection("continue")}
|
||||
onPointerEnter={() => setEndlessChoiceSelection("continue")}
|
||||
onClick={startRogueTrialsEndless}
|
||||
>Continue Endless</button>
|
||||
aria-label={`Endless Mode. Current high score: ${endlessHighScoreLabel}.`}
|
||||
><span>Endless Mode</span><small>High score · {endlessHighScoreLabel}</small></button>
|
||||
<button
|
||||
className={`secondary ${endlessChoiceSelection === "quit" ? "is-controller-focused" : ""}`}
|
||||
onFocus={() => setEndlessChoiceSelection("quit")}
|
||||
|
||||
@@ -147,7 +147,7 @@ function PhaseOverlay() {
|
||||
<span>{eyebrow}</span>
|
||||
<h1>{title}</h1>
|
||||
<p>{copy}</p>
|
||||
<small>{phase === "briefing" ? "Begin from lower display" : showEndlessChoice ? "Choose Continue or Quit on lower display" : "Restart from lower display"}</small>
|
||||
<small>{phase === "briefing" ? "Begin from lower display" : showEndlessChoice ? "Choose Endless Mode or Quit on lower display" : "Restart from lower display"}</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ export const BULL_CHARGE = {
|
||||
|
||||
export const BULL_POUNCE = {
|
||||
stackDuration: 5,
|
||||
stackRadius: 2.2,
|
||||
stackRadius: 3.2,
|
||||
sharedDamage: 200,
|
||||
leapDuration: 0.55,
|
||||
cooldown: 4,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ARENA_CENTER } from "./arena";
|
||||
import { createBaseMotion, returnBossToArenaCenter } from "./bosses/shared";
|
||||
import { BULL_POUNCE } from "./bosses/mechanicPool";
|
||||
import { createBaseMotion, createCircleHazard, returnBossToArenaCenter } from "./bosses/shared";
|
||||
import { freshParty } from "./data";
|
||||
import { distance, pointToSegmentDistance } from "./geometry";
|
||||
import { combatFormation, updatePartyPositions } from "./partyBehaviors";
|
||||
import type { MemberId, WorldPosition } from "./types";
|
||||
|
||||
@@ -36,4 +38,54 @@ describe("party boss positioning", () => {
|
||||
expect(next.brann[1]).toBeGreaterThan(sharedStart[1]);
|
||||
expect(next.vale[1]).toBeLessThan(sharedStart[1]);
|
||||
});
|
||||
|
||||
it("keeps AI allies inside the enlarged pounce stack while avoiding another boss's danger", () => {
|
||||
expect(BULL_POUNCE.stackRadius).toBe(3.2);
|
||||
const initialStackCenter: WorldPosition = [0, 0];
|
||||
let positions: Record<MemberId, WorldPosition> = {
|
||||
aelia: [...initialStackCenter],
|
||||
brann: [...initialStackCenter],
|
||||
nia: [...initialStackCenter],
|
||||
orin: [...initialStackCenter],
|
||||
vale: [...initialStackCenter],
|
||||
};
|
||||
const dangerMotion = {
|
||||
...createBaseMotion("crownshard-golem"),
|
||||
mode: "mantis_line_telegraph" as const,
|
||||
hazards: [createCircleHazard({
|
||||
id: "overlapping-impact",
|
||||
kind: "crownfall",
|
||||
center: initialStackCenter,
|
||||
radius: 1.2,
|
||||
activatesAt: 2,
|
||||
duration: 4,
|
||||
damage: 30,
|
||||
})],
|
||||
slashLanes: [{
|
||||
id: "overlapping-lane",
|
||||
start: [0, -5] as WorldPosition,
|
||||
end: [0, 5] as WorldPosition,
|
||||
width: 1.2,
|
||||
damage: 25,
|
||||
}],
|
||||
};
|
||||
const pounceMotion = {
|
||||
...createBaseMotion("bulldrome"),
|
||||
mode: "stacking" as const,
|
||||
pounceTargetId: "brann" as const,
|
||||
pounceCenter: initialStackCenter,
|
||||
};
|
||||
|
||||
for (let step = 0; step < 20; step += 1) {
|
||||
positions = updatePartyPositions(positions, [dangerMotion, pounceMotion], freshParty(), 1 + step * 0.1, 0.1);
|
||||
pounceMotion.pounceCenter = [...positions.brann];
|
||||
}
|
||||
|
||||
for (const memberId of ["brann", "nia", "orin", "vale"] as const) {
|
||||
expect(distance(positions[memberId], pounceMotion.pounceCenter)).toBeLessThan(BULL_POUNCE.stackRadius);
|
||||
expect(distance(positions[memberId], initialStackCenter)).toBeGreaterThanOrEqual(1.2 + 0.55);
|
||||
expect(pointToSegmentDistance(positions[memberId], dangerMotion.slashLanes[0].start, dangerMotion.slashLanes[0].end))
|
||||
.toBeGreaterThanOrEqual(1.2 * 0.5 + 0.7);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+132
-8
@@ -1,6 +1,6 @@
|
||||
import { clampToArena } from "./arena";
|
||||
import { BULL_CHARGE, SKY_SWEEPER_BREATH } from "./bosses/mechanicPool";
|
||||
import { moveToward, pointOutsideCircle, pointOutsideLane, pointToSegmentDistance } from "./geometry";
|
||||
import { ARENA_CENTER, ARENA_RADIUS, clampToArena } from "./arena";
|
||||
import { BULL_CHARGE, BULL_POUNCE, SKY_SWEEPER_BREATH } from "./bosses/mechanicPool";
|
||||
import { angularDistance, moveToward, pointOutsideCircle, pointOutsideLane, pointToSegmentDistance } from "./geometry";
|
||||
import type { BossMotionState, MemberId, PartyMember, WorldPosition } from "./types";
|
||||
|
||||
export type AiMemberId = Exclude<MemberId, "aelia">;
|
||||
@@ -10,6 +10,7 @@ export interface PartyBehaviorContext {
|
||||
current: WorldPosition;
|
||||
formationTarget: WorldPosition;
|
||||
bossMotion: BossMotionState;
|
||||
bossMotions: readonly BossMotionState[];
|
||||
partyPositions: Record<MemberId, WorldPosition>;
|
||||
time: number;
|
||||
}
|
||||
@@ -27,6 +28,13 @@ export interface PartyBehavior {
|
||||
const AI_MEMBER_IDS: readonly AiMemberId[] = ["brann", "nia", "orin", "vale"];
|
||||
const MOVE_SPEEDS: Record<AiMemberId, number> = { brann: 1.45, nia: 1.2, orin: 1.1, vale: 2.2 };
|
||||
const EVADE_SIDES: Record<AiMemberId, -1 | 1> = { brann: -1, nia: -1, orin: 1, vale: 1 };
|
||||
const HAZARD_LOOKAHEAD = 2.2;
|
||||
const CIRCLE_HAZARD_CLEARANCE = 0.55;
|
||||
const LANE_HAZARD_CLEARANCE = 0.7;
|
||||
const STACK_EDGE_PADDING = 0.35;
|
||||
const STACK_SAMPLE_RINGS = 3;
|
||||
const STACK_SAMPLES_PER_RING = 24;
|
||||
const STACK_CENTER_OFFSET: WorldPosition = [0, 0];
|
||||
const STACK_OFFSETS: Record<AiMemberId, WorldPosition> = {
|
||||
brann: [-0.55, 0],
|
||||
nia: [0.45, 0.4],
|
||||
@@ -59,15 +67,130 @@ export function combatFormation(boss: WorldPosition): Record<AiMemberId, WorldPo
|
||||
};
|
||||
}
|
||||
|
||||
function circleDanger(
|
||||
x: number,
|
||||
z: number,
|
||||
center: WorldPosition,
|
||||
radius: number,
|
||||
innerRadius = 0,
|
||||
) {
|
||||
const distance = Math.hypot(x - center[0], z - center[1]);
|
||||
const unsafeInnerRadius = Math.max(0, innerRadius - CIRCLE_HAZARD_CLEARANCE);
|
||||
const unsafeOuterRadius = radius + CIRCLE_HAZARD_CLEARANCE;
|
||||
if (distance >= unsafeOuterRadius || (unsafeInnerRadius > 0 && distance <= unsafeInnerRadius)) return 0;
|
||||
const penetration = unsafeInnerRadius > 0
|
||||
? Math.min(distance - unsafeInnerRadius, unsafeOuterRadius - distance)
|
||||
: unsafeOuterRadius - distance;
|
||||
return 1 + Math.max(0, penetration);
|
||||
}
|
||||
|
||||
function laneDanger(x: number, z: number, start: WorldPosition, end: WorldPosition, width: number) {
|
||||
const clearance = width * 0.5 + LANE_HAZARD_CLEARANCE;
|
||||
const laneX = end[0] - start[0];
|
||||
const laneZ = end[1] - start[1];
|
||||
const lengthSquared = laneX * laneX + laneZ * laneZ;
|
||||
const projection = lengthSquared < 0.0001
|
||||
? 0
|
||||
: Math.max(0, Math.min(1, ((x - start[0]) * laneX + (z - start[1]) * laneZ) / lengthSquared));
|
||||
const distance = Math.hypot(x - (start[0] + projection * laneX), z - (start[1] + projection * laneZ));
|
||||
return distance < clearance ? 1 + clearance - distance : 0;
|
||||
}
|
||||
|
||||
function bossDangerAt(x: number, z: number, bossMotions: readonly BossMotionState[], time: number) {
|
||||
let danger = 0;
|
||||
for (const motion of bossMotions) {
|
||||
for (const hazard of motion.hazards) {
|
||||
if (hazard.resolved || hazard.expiresAt <= time || hazard.activatesAt - time > HAZARD_LOOKAHEAD) continue;
|
||||
danger += circleDanger(x, z, hazard.center, hazard.radius, hazard.innerRadius);
|
||||
}
|
||||
|
||||
for (const telegraph of motion.poolTelegraphs) {
|
||||
if (telegraph.resolved || telegraph.expiresAt <= time || telegraph.activatesAt - time > HAZARD_LOOKAHEAD) continue;
|
||||
if (telegraph.kind === "soak" || telegraph.kind === "memory" || telegraph.kind === "soul-siphon") continue;
|
||||
if (telegraph.kind === "beam" && telegraph.start && telegraph.end) {
|
||||
danger += laneDanger(x, z, telegraph.start, telegraph.end, telegraph.width ?? 0);
|
||||
} else {
|
||||
danger += circleDanger(x, z, telegraph.center, telegraph.radius, telegraph.innerRadius);
|
||||
}
|
||||
}
|
||||
|
||||
for (const lane of motion.slashLanes) {
|
||||
danger += laneDanger(x, z, lane.start, lane.end, lane.width);
|
||||
}
|
||||
|
||||
if (motion.mode === "breath_telegraph" || motion.mode === "breath_sweeping") {
|
||||
const dx = x - motion.position[0];
|
||||
const dz = z - motion.position[1];
|
||||
const distance = Math.hypot(dx, dz);
|
||||
const angle = Math.atan2(dx, dz);
|
||||
const breathAngle = motion.mode === "breath_telegraph" ? motion.breathStartAngle : motion.breathAngle;
|
||||
const radialPenetration = SKY_SWEEPER_BREATH.range + CIRCLE_HAZARD_CLEARANCE - distance;
|
||||
const angularPenetration = SKY_SWEEPER_BREATH.halfAngle + 0.08 - angularDistance(angle, breathAngle);
|
||||
if (radialPenetration > 0 && angularPenetration > 0) {
|
||||
danger += 1 + Math.min(radialPenetration, angularPenetration * Math.max(1, distance));
|
||||
}
|
||||
}
|
||||
}
|
||||
return danger;
|
||||
}
|
||||
|
||||
function safeStackTarget(
|
||||
memberId: AiMemberId,
|
||||
current: WorldPosition,
|
||||
center: WorldPosition,
|
||||
preferredOffset: WorldPosition,
|
||||
stackRadius: number,
|
||||
bossMotions: readonly BossMotionState[],
|
||||
time: number,
|
||||
) {
|
||||
const preferred = clampToArena([center[0] + preferredOffset[0], center[1] + preferredOffset[1]]);
|
||||
const preferredDanger = bossDangerAt(preferred[0], preferred[1], bossMotions, time);
|
||||
if (preferredDanger === 0) return preferred;
|
||||
|
||||
const insideRadius = Math.max(0, stackRadius - STACK_EDGE_PADDING);
|
||||
let bestX = preferred[0];
|
||||
let bestZ = preferred[1];
|
||||
let bestScore = preferredDanger * 1_000
|
||||
+ Math.hypot(bestX - current[0], bestZ - current[1]) * 0.05;
|
||||
const angleStep = Math.PI * 2 / STACK_SAMPLES_PER_RING;
|
||||
const memberPhase = AI_MEMBER_IDS.indexOf(memberId) * angleStep * 0.5;
|
||||
|
||||
for (let ring = 1; ring <= STACK_SAMPLE_RINGS; ring += 1) {
|
||||
const sampleRadius = insideRadius * ring / STACK_SAMPLE_RINGS;
|
||||
for (let sample = 0; sample < STACK_SAMPLES_PER_RING; sample += 1) {
|
||||
const angle = memberPhase + sample * angleStep;
|
||||
let candidateX = center[0] + Math.sin(angle) * sampleRadius;
|
||||
let candidateZ = center[1] + Math.cos(angle) * sampleRadius;
|
||||
const arenaX = candidateX - ARENA_CENTER[0];
|
||||
const arenaZ = candidateZ - ARENA_CENTER[1];
|
||||
const arenaDistance = Math.hypot(arenaX, arenaZ);
|
||||
if (arenaDistance > ARENA_RADIUS) {
|
||||
const scale = ARENA_RADIUS / arenaDistance;
|
||||
candidateX = ARENA_CENTER[0] + arenaX * scale;
|
||||
candidateZ = ARENA_CENTER[1] + arenaZ * scale;
|
||||
}
|
||||
|
||||
const danger = bossDangerAt(candidateX, candidateZ, bossMotions, time);
|
||||
const preferredDistance = Math.hypot(candidateX - preferred[0], candidateZ - preferred[1]);
|
||||
const travelDistance = Math.hypot(candidateX - current[0], candidateZ - current[1]);
|
||||
const score = danger * 1_000 + preferredDistance + travelDistance * 0.05;
|
||||
if (score >= bestScore) continue;
|
||||
bestX = candidateX;
|
||||
bestZ = candidateZ;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
return [bestX, bestZ] as WorldPosition;
|
||||
}
|
||||
|
||||
export const stackForPounceBehavior: PartyBehavior = {
|
||||
id: "stack-for-pounce",
|
||||
decide: ({ memberId, bossMotion }) => {
|
||||
decide: ({ memberId, current, bossMotion, bossMotions, time }) => {
|
||||
if (bossMotion.mode !== "stacking") return null;
|
||||
const offset = STACK_OFFSETS[memberId];
|
||||
const offset = memberId === bossMotion.pounceTargetId ? STACK_CENTER_OFFSET : STACK_OFFSETS[memberId];
|
||||
return {
|
||||
target: memberId === bossMotion.pounceTargetId
|
||||
? [bossMotion.pounceCenter[0], bossMotion.pounceCenter[1]]
|
||||
: [bossMotion.pounceCenter[0] + offset[0], bossMotion.pounceCenter[1] + offset[1]],
|
||||
target: safeStackTarget(memberId, current, bossMotion.pounceCenter, offset, BULL_POUNCE.stackRadius, bossMotions, time),
|
||||
speed: 3.4,
|
||||
};
|
||||
},
|
||||
@@ -311,6 +434,7 @@ export function updatePartyPositions(
|
||||
current: next[memberId],
|
||||
formationTarget: formation[memberId],
|
||||
bossMotion,
|
||||
bossMotions: activeMotions,
|
||||
partyPositions: next,
|
||||
time,
|
||||
});
|
||||
|
||||
@@ -850,10 +850,29 @@ describe("Rogue Trials", () => {
|
||||
expect(replaced.phase).toBe("combat");
|
||||
expect(replaced.boss.hp).toBe(replaced.boss.maxHp);
|
||||
expect(replaced.bossInstanceId).not.toBe(defeatedInstanceId);
|
||||
expect(replaced.boss.nextMeleeAt).toBeGreaterThan(replaced.time);
|
||||
expect(replaced.bossMotion.nextMechanicAt).toBeGreaterThan(replaced.time);
|
||||
expect(replaced.endlessBossKills).toBe(1);
|
||||
expect(new Set([replaced.boss.id, ...replaced.additionalBosses.map((entry) => entry.boss.id)])).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("schedules endless attacks from the transition time instead of replaying overdue attacks", () => {
|
||||
useGameStore.setState({ round: 5, phase: "victory", time: 180 });
|
||||
|
||||
expect(useGameStore.getState().startRogueTrialsEndless()).toBe(true);
|
||||
const started = useGameStore.getState();
|
||||
const bosses = [
|
||||
{ boss: started.boss, motion: started.bossMotion },
|
||||
...started.additionalBosses,
|
||||
];
|
||||
expect(bosses.every((entry) => entry.boss.nextMeleeAt > started.time)).toBe(true);
|
||||
expect(bosses.every((entry) => entry.motion.nextMechanicAt > started.time)).toBe(true);
|
||||
|
||||
const startingTankHp = started.party.find((member) => member.id === "brann")!.hp;
|
||||
useGameStore.getState().tick(0.05);
|
||||
expect(useGameStore.getState().party.find((member) => member.id === "brann")!.hp).toBe(startingTankHp);
|
||||
});
|
||||
|
||||
it("keeps endless mode running until the whole party falls", () => {
|
||||
useGameStore.setState((state) => ({
|
||||
round: 5,
|
||||
|
||||
+7
-6
@@ -156,7 +156,7 @@ const normalizeBossIds = (bossIds: BossId | readonly BossId[] = "bulldrome"): Bo
|
||||
return normalizeEncounterBossIds(requested);
|
||||
};
|
||||
|
||||
function createEncounterMotion(bossId: BossId, index: number, count: number): BossMotionState {
|
||||
function createEncounterMotion(bossId: BossId, index: number, count: number, startsAt = 0): BossMotionState {
|
||||
const motion = cloneMotion(createBossMotionState(bossId));
|
||||
const offset = count === 3 ? [-3.7, 0, 3.7][index] : count === 2 ? [-2.65, 2.65][index] : 0;
|
||||
motion.formationOffsetX = offset;
|
||||
@@ -165,17 +165,17 @@ function createEncounterMotion(bossId: BossId, index: number, count: number): Bo
|
||||
motion.chargeEnd[0] += offset;
|
||||
motion.pounceCenter[0] += offset;
|
||||
const stagger = index * 2.4;
|
||||
if (Number.isFinite(motion.nextMechanicAt)) motion.nextMechanicAt += stagger;
|
||||
if (Number.isFinite(motion.nextMechanicAt)) motion.nextMechanicAt += startsAt + stagger;
|
||||
return constrainBossMotion(motion);
|
||||
}
|
||||
|
||||
function createEncounterBoss(bossId: BossId, index: number, count: number, healthMultiplier: number): AdditionalBossState {
|
||||
function createEncounterBoss(bossId: BossId, index: number, count: number, healthMultiplier: number, startsAt = 0): AdditionalBossState {
|
||||
const boss = createBossState(bossId);
|
||||
boss.maxHp = Math.round(boss.maxHp * healthMultiplier);
|
||||
boss.hp = boss.maxHp;
|
||||
const stagger = index * 0.8;
|
||||
if (Number.isFinite(boss.nextMeleeAt)) boss.nextMeleeAt += stagger;
|
||||
return { instanceId: `boss-${index}-${bossId}`, boss, motion: createEncounterMotion(bossId, index, count) };
|
||||
if (Number.isFinite(boss.nextMeleeAt)) boss.nextMeleeAt += startsAt + stagger;
|
||||
return { instanceId: `boss-${index}-${bossId}`, boss, motion: createEncounterMotion(bossId, index, count, startsAt) };
|
||||
}
|
||||
|
||||
const freshPartyPositions = (bossIds: readonly BossId[]): Record<MemberId, WorldPosition> => {
|
||||
@@ -466,7 +466,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
const difficulty = DIFFICULTY_BY_SLUG[state.difficultySlug];
|
||||
const healthMultiplier = bossHealthMultiplier(ROGUE_TRIALS_TRIO_ROUND) * difficulty.healthMultiplier;
|
||||
const encounterBosses = bossIds.map((bossId, index) => {
|
||||
const entry = createEncounterBoss(bossId, index, bossIds.length, healthMultiplier);
|
||||
const entry = createEncounterBoss(bossId, index, bossIds.length, healthMultiplier, state.time);
|
||||
return { ...entry, instanceId: `endless-${index + 1}-${bossId}` };
|
||||
});
|
||||
const primary = encounterBosses[0];
|
||||
@@ -698,6 +698,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
index,
|
||||
slots.length,
|
||||
bossHealthMultiplier(ROGUE_TRIALS_TRIO_ROUND) * difficulty.healthMultiplier,
|
||||
time,
|
||||
);
|
||||
slots[index] = { ...replacement, instanceId: `endless-${endlessSpawnSequence}-${replacementId}` };
|
||||
combatLog = addLog(combatLog, time, `${replacement.boss.name} replaces the fallen boss.`, "danger");
|
||||
|
||||
@@ -1077,6 +1077,8 @@ button:focus-visible {
|
||||
.end-actions button { padding: 9px 18px; border: 1px solid var(--gold); color: #13170f; background: var(--gold); font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; cursor: pointer; }
|
||||
.end-actions button.secondary { color: #9eafa8; background: transparent; border-color: #43564f; }
|
||||
.end-actions button.is-controller-focused { outline: 2px solid #fff1b6; outline-offset: 2px; }
|
||||
.endless-choice-actions button:first-child { display: grid; gap: 3px; min-width: 155px; }
|
||||
.endless-choice-actions button:first-child small { font-size: 7px; font-weight: 600; letter-spacing: 0.06em; opacity: 0.72; }
|
||||
|
||||
.buff-draft {
|
||||
height: 100%;
|
||||
|
||||
Reference in New Issue
Block a user