Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ contracts/cache/
# Keep run-latest.json, ignore timestamped run files
contracts/broadcast/**/*
!contracts/broadcast/**/run-latest.json
# Local dev seed scripts
contracts/script/SeedArena.s.sol

# ============================================================
# MCP server build
Expand Down
10 changes: 0 additions & 10 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 37 additions & 3 deletions frontend/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,48 @@ body.arena-route {
}

@keyframes arena-hit {
0% { transform: scale(1); filter: brightness(1); }
35% { transform: scale(1.15); filter: brightness(2) drop-shadow(0 0 6px #f97316); }
100% { transform: scale(1); filter: brightness(1); }
0% { box-shadow: 0 0 0 0 rgba(239,68,68,0.8); }
100% { box-shadow: 0 0 0 16px rgba(239,68,68,0); }
}
.animate-arena-hit {
animation: arena-hit 0.45s ease-out 1;
}

@keyframes arena-attack-left {
0%,100% { transform: translateX(0); }
50% { transform: translateX(24px); }
}
@keyframes arena-attack-right {
0%,100% { transform: translateX(0); }
50% { transform: translateX(-24px); }
}
@keyframes arena-damage-float {
0% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-40px); }
}
@keyframes arena-enter-left {
0% { opacity: 0; transform: translateX(-60px); }
100% { opacity: 1; transform: translateX(0); }
}
@keyframes arena-enter-right {
0% { opacity: 0; transform: translateX(60px); }
100% { opacity: 1; transform: translateX(0); }
}

@keyframes arena-stamp {
0% { transform: scale(3) rotate(-20deg); opacity: 0; }
55% { transform: scale(0.88) rotate(-7deg); opacity: 1; }
75% { transform: scale(1.06) rotate(-9deg); opacity: 1; }
100% { transform: scale(1) rotate(-8deg); opacity: 1; }
}
.animate-arena-stamp { animation: arena-stamp 0.55s cubic-bezier(0.25, 0.46, 0.45, 0.94) 1 both; }

.animate-arena-attack-left { animation: arena-attack-left 0.3s ease-in-out 1; }
.animate-arena-attack-right { animation: arena-attack-right 0.3s ease-in-out 1; }
.animate-arena-damage-float { animation: arena-damage-float 0.8s ease-out 1 forwards; }
.animate-arena-enter-left { animation: arena-enter-left 0.45s ease-out 1 both; }
.animate-arena-enter-right { animation: arena-enter-right 0.45s ease-out 1 both; }

@keyframes arena-slidein {
0% { transform: translateX(20px); opacity: 0; }
100% { transform: translateX(0); opacity: 1; }
Expand Down
90 changes: 90 additions & 0 deletions frontend/src/components/arena/BattleLog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
'use client';

import { useEffect, useRef } from 'react';
import { ArenaSimulation, ArenaMatch, ArenaGhost } from '../../store/useArenaStore';
import { getUnit } from '../../lib/arenaUnits';

type Props = {
sim: ArenaSimulation;
match: ArenaMatch;
ghosts: Record<number, ArenaGhost>;
turnIndex: number;
};

type LogLine = {
turnNum: number;
atkLabel: string;
defLabel: string;
damage: number;
ko: boolean;
attackerSide: 0 | 1;
isCurrent: boolean;
};

export function BattleLog({ sim, match, ghosts, turnIndex }: Props) {
const highlightRef = useRef<HTMLDivElement | null>(null);

useEffect(() => {
highlightRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}, [turnIndex]);

const lines: LogLine[] = sim.turns.map((t, i) => {
const atkBench = t.attackerSide === 0 ? match.attackerBench : match.defenderBench;
const defBench = t.attackerSide === 0 ? match.defenderBench : match.attackerBench;
const atkAgentId = t.attackerSide === 0 ? match.attackerId : match.defenderId;
const defAgentId = t.attackerSide === 0 ? match.defenderId : match.attackerId;

const atkUnit = getUnit(atkBench[t.attackerSlot]);
const defUnit = getUnit(defBench[t.defenderSlot]);
const atkAgentName = ghosts[atkAgentId]?.agentName ?? `#${atkAgentId}`;
const defAgentName = ghosts[defAgentId]?.agentName ?? `#${defAgentId}`;

return {
turnNum: i + 1,
atkLabel: `${atkAgentName} ${atkUnit?.name ?? '?'}`,
defLabel: `${defAgentName} ${defUnit?.name ?? '?'}`,
damage: t.damage,
ko: t.defenderDied,
attackerSide: t.attackerSide,
isCurrent: i === turnIndex - 1,
};
});

// Newest first.
const reversed = [...lines].reverse();

return (
<div className="mt-3 rounded border border-zinc-800 bg-zinc-950 overflow-hidden">
<div className="px-3 py-1.5 text-[10px] uppercase tracking-widest text-zinc-500 border-b border-zinc-800 font-semibold">
Battle Log
</div>
<div className="h-[120px] overflow-y-auto no-scrollbar">
{reversed.map((line) => (
<div
key={line.turnNum}
ref={line.isCurrent ? highlightRef : null}
className={[
'px-3 py-0.5 font-mono text-[11px] border-b border-zinc-900 last:border-0',
line.isCurrent ? 'bg-zinc-800 text-white' : 'text-zinc-500',
].join(' ')}
>
<span className={line.attackerSide === 0 ? 'text-sky-400' : 'text-rose-400'}>
T{line.turnNum}
</span>
<span className="text-zinc-600"> │ </span>
<span className={line.isCurrent ? 'text-zinc-200' : 'text-zinc-400'}>
{line.atkLabel}
</span>
<span className="text-zinc-600"> → </span>
<span className={line.isCurrent ? 'text-zinc-200' : 'text-zinc-400'}>
{line.defLabel}
</span>
<span className="text-zinc-600"> │ </span>
<span className="text-orange-400">{line.damage} dmg</span>
{line.ko && <span className="ml-1 text-rose-500 font-semibold"> │ KO!</span>}
</div>
))}
</div>
</div>
);
}
Loading
Loading