zenno: tooltips on skills

This commit is contained in:
2026-05-25 15:30:32 -04:00
parent a89ef8fc4c
commit 7b45b8761b
3 changed files with 248 additions and 7 deletions

View File

@@ -1,5 +1,16 @@
<script lang="ts">
import { skills, ZERO_SKILL, type Skill } from './data/skill';
import {
ABILITY_SCALE_NAME,
ABILITY_TYPE_FORMAT,
DURATION_SCALE_NAME,
skills,
Target,
TARGET_FORMAT,
tenThousandths,
ZERO_SKILL,
type Ability,
type Skill,
} from './data/skill';
interface CommonProps {
hint?: string;
@@ -10,6 +21,10 @@
let { hint, mention, skill, name }: Props = $props();
function splitCond(c: string): string[] {
return c.replaceAll('&', ' & ').split('@');
}
const s: Readonly<Skill> = $derived.by(() => {
const l =
skill != null ? skills.global.filter((s) => s.skill_id === skill) : skills.global.filter((s) => s.name.includes(name!));
@@ -21,8 +36,62 @@
}
return l[0];
});
const activationClass = $derived(s.activations.length === 1 ? null : 'm2 border rounded-md');
const spanClass = $derived(mention ? 'italic' : 'font-bold');
</script>
<span class={spanClass}>{s.name}</span>
{#snippet condition(disj: string[])}
{#each disj as conj, i (conj)}
<div class="font-mono text-sm">{conj}</div>
{#if i !== disj.length - 1}
<div class="text-xs italic">or</div>
{/if}
{/each}
{/snippet}
{#snippet abilities(abils: Ability[])}
{#each abils as a (a.type)}
<div class="text-sm">
{ABILITY_TYPE_FORMAT[a.type](a.value)}
{ABILITY_SCALE_NAME[a.value_usage]}
{#if a.target && a.target !== Target.Self}
{TARGET_FORMAT[a.target](a.target_value)}
{/if}
</div>
{/each}
{/snippet}
<div class="group relative inline-block">
<div
class="skill-tip invisible absolute bottom-4 -left-1/2 flex w-80 flex-col rounded-lg border px-2 text-center opacity-0 shadow-lg transition-all group-hover:visible group-hover:bottom-6 group-hover:opacity-100"
role="tooltip"
>
<div class="border-b py-1">
{s.description}
</div>
{#each s.activations as act, i (i)}
<div class={activationClass}>
{#if act.precondition}
<div class="border-b py-1">
<div class="text-xs italic">Precondition</div>
{@render condition(splitCond(act.precondition))}
</div>
{/if}
<div class="border-b py-1">
{@render condition(splitCond(act.condition))}
</div>
<div class="py-1">
{@render abilities(act.abilities)}
{#if act.duration}
<div class="text-sm">
for {tenThousandths(act.duration)}s
{DURATION_SCALE_NAME[act.dur_scale]}
</div>
{/if}
</div>
</div>
{/each}
</div>
<span class={spanClass}>{s.name}</span>
</div>

View File

@@ -57,6 +57,24 @@ export interface Skill {
icon_id: number;
}
export enum DurationScale {
Direct = 1,
FrontDistance = 2,
RemainingHP = 3,
IncrementPass = 4,
MidSideBlock = 5,
RemainingHP2 = 7,
}
export const DURATION_SCALE_NAME: Readonly<Record<DurationScale, string>> = {
[DurationScale.Direct]: '',
[DurationScale.FrontDistance]: 'scaling with distance from the front',
[DurationScale.RemainingHP]: 'scaling with remaining HP',
[DurationScale.IncrementPass]: 'increasing with each pass while active',
[DurationScale.MidSideBlock]: 'scaling with mid-race phase blocked side time',
[DurationScale.RemainingHP2]: 'scaling with remaining HP',
} as const;
/**
* Conditions and results of skill activation.
*/
@@ -77,7 +95,7 @@ export interface Activation {
/**
* Special skill duration scaling mode.
*/
dur_scale: 1 | 2 | 3 | 4 | 5 | 7;
dur_scale: DurationScale;
/**
* Skill cooldown in ten thousandths of a second.
* A value of 5000000 indicates that the cooldown is forever.
@@ -90,6 +108,159 @@ export interface Activation {
abilities: Ability[];
}
export function tenThousandths(v: number): string {
const q = v / 10000;
if (v % 10000 === 0) {
return q.toString();
}
const s = q.toFixed(4);
const j = /\.?0+$/.exec(s);
return s.substring(0, j?.index ?? undefined);
}
function plusBonus(v: number): string {
const s = tenThousandths(v);
return v < 0 ? s : '+' + s;
}
export enum AbilityType {
Speed = 1,
Stamina = 2,
Power = 3,
Guts = 4,
Wit = 5,
GreatEscape = 6,
Vision = 8,
HP = 9,
GateDelay = 10,
Frenzy = 13,
AddGateDelay = 14,
CurrentSpeed = 21,
TargetSpeed = 27,
LaneSpeed = 28,
Accel = 31,
LaneChange = 35,
}
export const ABILITY_TYPE_FORMAT: Readonly<Record<AbilityType, (v: number) => string>> = {
[AbilityType.Speed]: (v) => 'Speed ' + plusBonus(v),
[AbilityType.Stamina]: (v) => 'Stamina ' + plusBonus(v),
[AbilityType.Power]: (v) => 'Power ' + plusBonus(v),
[AbilityType.Guts]: (v) => 'Guts ' + plusBonus(v),
[AbilityType.Wit]: (v) => 'Wit ' + plusBonus(v),
[AbilityType.GreatEscape]: () => 'Enable Great Escape',
[AbilityType.Vision]: (v) => plusBonus(v) + 'm Vision',
[AbilityType.HP]: (v) => (v > 0 ? tenThousandths(v * 100) + '% Recovery' : tenThousandths(v * 100) + '% HP Drain'),
[AbilityType.GateDelay]: (v) => tenThousandths(v) + '× Gate Delay',
[AbilityType.Frenzy]: (v) => tenThousandths(v) + 's Frenzy',
[AbilityType.AddGateDelay]: (v) => plusBonus(v) + 's Gate Delay',
[AbilityType.CurrentSpeed]: (v) => plusBonus(v) + ' m/s Current Speed',
[AbilityType.TargetSpeed]: (v) => plusBonus(v) + ' m/s Target Speed',
[AbilityType.LaneSpeed]: (v) => plusBonus(v) + ' CW/s Lane Change Speed',
[AbilityType.Accel]: (v) => plusBonus(v) + ' m/s² Acceleration',
[AbilityType.LaneChange]: (v) => 'Target Lane = ' + tenThousandths(v) + ' CW',
} as const;
export enum AbilityValueUsage {
Direct = 1,
SkillCount = 2,
TeamSpeed = 3,
TeamStamina = 4,
TeamPower = 5,
TeamGuts = 6,
TeamWit = 7,
Random1 = 8,
Random2 = 9,
Climax = 10,
MaxStat = 13,
GreenCount = 14,
DistAdd = 19,
MidRaceSideBlock = 20,
Speed1 = 22,
Speed2 = 23,
ArcPotential = 24,
MaxLead = 25,
}
export const ABILITY_SCALE_NAME: Readonly<Record<AbilityValueUsage, string>> = {
[AbilityValueUsage.Direct]: '',
[AbilityValueUsage.SkillCount]: 'scaling with the number of skills',
[AbilityValueUsage.TeamSpeed]: 'scaling with team Speed',
[AbilityValueUsage.TeamStamina]: 'scaling with team Stamina',
[AbilityValueUsage.TeamPower]: 'scaling with team Power',
[AbilityValueUsage.TeamGuts]: 'scaling with team Guts',
[AbilityValueUsage.TeamWit]: 'scaling with team Wit',
[AbilityValueUsage.Random1]: 'with a random 0× to 0.04× multiplier',
[AbilityValueUsage.Random2]: 'with a random 0× to 0.04× mulitplier',
[AbilityValueUsage.Climax]: 'scaling with the number of races won in training',
[AbilityValueUsage.MaxStat]: 'scaling with the highest raw stat',
[AbilityValueUsage.GreenCount]: 'scaling with the number of Passive skills activated',
[AbilityValueUsage.DistAdd]: 'plus extra when far from the lead',
[AbilityValueUsage.MidRaceSideBlock]: 'scaling with mid-race phase blocked side time',
[AbilityValueUsage.Speed1]: 'scaling with overall speed',
[AbilityValueUsage.Speed2]: 'scaling with overall speed',
[AbilityValueUsage.ArcPotential]: "scaling with L'Arc global potential",
[AbilityValueUsage.MaxLead]: 'scaling with the longest lead obtained in the first ⅔',
};
export enum Target {
Self = 1,
Sympathizers = 2,
InView = 4,
Frontmost = 7,
Ahead = 9,
Behind = 10,
AllTeammates = 11,
Style = 18,
RushingAhead = 19,
RushingBehind = 20,
RushingStyle = 21,
Character = 22,
Triggering = 23,
}
function targetn(v: number, n: string, one?: string): string {
switch (v) {
case 1:
return one != null ? one : `to ${n}`;
case 18:
return `to all ${n}`;
default:
return `to ${v} others ${n}`;
}
}
function stylename(v: number, infix: string): string {
switch (v) {
case 1:
return `to ${infix} Front Runners`;
case 2:
return `to ${infix} Pace Chasers`;
case 3:
return `to ${infix} Late Surgers`;
case 4:
return `to ${infix} End Closers`;
default:
return `to all running unknown style ${v}`;
}
}
export const TARGET_FORMAT: Readonly<Record<Target, (v?: number) => string>> = {
[Target.Self]: () => '',
[Target.Sympathizers]: () => 'to others with Sympathy',
[Target.InView]: (v) => targetn(v!, 'in view'),
[Target.Frontmost]: (v) => targetn(v!, 'at the front', 'the frontmost'),
[Target.Ahead]: (v) => targetn(v!, 'ahead'),
[Target.Behind]: (v) => targetn(v!, 'behind'),
[Target.AllTeammates]: () => 'to all teammates',
[Target.Style]: (v) => stylename(v!, 'all'),
[Target.RushingAhead]: (v) => targetn(v!, 'rushing ahead'),
[Target.RushingBehind]: (v) => targetn(v!, 'rushing behind'),
[Target.RushingStyle]: (v) => stylename(v!, 'all rushing'),
[Target.Character]: (v) => `to character ${v}`,
[Target.Triggering]: () => 'to whosoever triggered this skill',
} as const;
/**
* Effects applied when a skill activates.
*/
@@ -97,11 +268,11 @@ export interface Ability {
/**
* Race mechanic affected by the ability.
*/
type: 1 | 2 | 3 | 4 | 5 | 6 | 8 | 9 | 10 | 13 | 21 | 27 | 28 | 31 | 35;
type: AbilityType;
/**
* Special scaling type of the skill value.
*/
value_usage: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 13 | 14 | 19 | 20 | 22 | 23 | 24 | 25;
value_usage: AbilityValueUsage;
/**
* Amount that the skill modifies the race mechanic in ten thousandths of
* whatever is the appropriate unit.
@@ -110,7 +281,7 @@ export interface Ability {
/**
* Selector for horses targeted by the ability.
*/
target: 1 | 2 | 4 | 7 | 9 | 10 | 11 | 18 | 19 | 20 | 21 | 22 | 23;
target: Target;
/**
* Argument value for the ability target, when appropriate.
*/

View File

@@ -15,7 +15,8 @@ html {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
}
body {
body,
.skill-tip {
background-color: light-dark(var(--color-mist-200), var(--color-mist-800));
color: light-dark(var(--color-amber-950), var(--color-amber-50));
}