mirror of
https://codeberg.org/demostf/frontend.git
synced 2026-06-03 18:24:12 +02:00
kill search
This commit is contained in:
parent
78d8d4eb9f
commit
72c4b6ee08
10 changed files with 413 additions and 117 deletions
BIN
images/kill_icons/dragons_fury.png
Normal file
BIN
images/kill_icons/dragons_fury.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1,008 B After Width: | Height: | Size: 1.1 KiB |
|
|
@ -15,6 +15,8 @@ import {getMapBoundaries} from "./MapBoundries";
|
|||
import {createEffect, createSignal, untrack} from "solid-js";
|
||||
import {Session, StateUpdate} from "./Session";
|
||||
import {DemoHead} from "../../header";
|
||||
import {EventSearch} from "./EventSearch";
|
||||
import {Event} from "./Data/Parser";
|
||||
|
||||
export interface AnalyseProps {
|
||||
header: DemoHead;
|
||||
|
|
@ -37,10 +39,13 @@ export const Analyser = (props: AnalyseProps) => {
|
|||
const [clients, setClients] = createSignal<number>(0);
|
||||
const [helpOpen, setHelpOpen] = createSignal<boolean>(false);
|
||||
const [gotoOpen, setGotoOpen] = createSignal<boolean>(false);
|
||||
const [searchOpen, setSearchOpen] = createSignal<boolean>(false);
|
||||
const [search, setSearch] = createSignal<string>('');
|
||||
const [gotoInput, setGotoInput] = createSignal<number>(0);
|
||||
const closeDialogs = () => {
|
||||
setHelpOpen(false);
|
||||
setGotoOpen(false);
|
||||
setSearchOpen(false);
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
|
|
@ -48,6 +53,8 @@ export const Analyser = (props: AnalyseProps) => {
|
|||
|
||||
untrack(() => {
|
||||
if (e) {
|
||||
const dialogOpen = searchOpen() | gotoOpen();
|
||||
if (!dialogOpen) {
|
||||
if (e.key === '.') {
|
||||
seek(1);
|
||||
e.preventDefault();
|
||||
|
|
@ -71,11 +78,20 @@ export const Analyser = (props: AnalyseProps) => {
|
|||
if (e.key === '?') {
|
||||
setHelpOpen(true);
|
||||
setGotoOpen(false);
|
||||
setSearchOpen(false);
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
if (!inShared && e.getModifierState("Control") && e.key === 'g') {
|
||||
setHelpOpen(false);
|
||||
setGotoOpen(true);
|
||||
setSearchOpen(false);
|
||||
e.preventDefault();
|
||||
}
|
||||
if (!inShared && e.getModifierState("Control") && e.key === 'f') {
|
||||
setHelpOpen(false);
|
||||
setGotoOpen(false);
|
||||
setSearchOpen(true);
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
|
|
@ -213,6 +229,7 @@ export const Analyser = (props: AnalyseProps) => {
|
|||
const buildings = () => parser.getBuildingsAtTick(tick());
|
||||
const projectiles = () => parser.getProjectilesAtTick(tick());
|
||||
const kills = parser.getKills();
|
||||
const events = parser.getEvents();
|
||||
const playButtonText = () => (playing()) ? '⏸' : '▶️';
|
||||
const inShared = session && !session.isOwner();
|
||||
const isShared = () => sessionName() !== '';
|
||||
|
|
@ -263,7 +280,7 @@ export const Analyser = (props: AnalyseProps) => {
|
|||
})}
|
||||
disabled={inShared}/>
|
||||
</div>
|
||||
<Modal class="help" isOpen={helpOpen()} onCloseRequest={() => setHelpOpen(false)}
|
||||
<Modal isOpen={helpOpen()} onCloseRequest={() => setHelpOpen(false)}
|
||||
closeOnOutsideClick={true} overlayClass="modal-overlay" contentClass="modal-content">
|
||||
<h4>Keyboard Shortcuts</h4>
|
||||
<table class="shortcuts">
|
||||
|
|
@ -310,7 +327,7 @@ export const Analyser = (props: AnalyseProps) => {
|
|||
</tbody>
|
||||
</table>
|
||||
</Modal>
|
||||
<Modal class="goto" isOpen={gotoOpen()} onCloseRequest={() => setGotoOpen(false)}
|
||||
<Modal isOpen={gotoOpen()} onCloseRequest={() => setGotoOpen(false)}
|
||||
closeOnOutsideClick={true} overlayClass="modal-overlay" contentClass="modal-content">
|
||||
<h4>Goto Tick</h4>
|
||||
<form use:formSubmit={gotoTickSubmitted} class="goto">
|
||||
|
|
@ -319,6 +336,16 @@ export const Analyser = (props: AnalyseProps) => {
|
|||
ref={autofocus} autofocus type="text" inputmode="numeric" min={0} max={lastTick - 1}/>
|
||||
</form>
|
||||
</Modal>
|
||||
<Modal isOpen={searchOpen()} onCloseRequest={() => setSearchOpen(false)}
|
||||
closeOnOutsideClick={true} overlayClass="modal-overlay" contentClass="modal-content">
|
||||
<EventSearch
|
||||
players={players()}
|
||||
search={search()}
|
||||
onSearch={setSearch}
|
||||
events={events}
|
||||
onSelect={(event: Event) => setTickNow(event.tick)}
|
||||
></EventSearch>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,13 @@
|
|||
import {BuildingState, Kill, ParsedDemo, PlayerState, ProjectileState, ProjectileType, WorldBoundaries} from "./Parser";
|
||||
import {
|
||||
BuildingState,
|
||||
Event,
|
||||
Kill,
|
||||
ParsedDemo,
|
||||
PlayerState,
|
||||
ProjectileState,
|
||||
ProjectileType,
|
||||
WorldBoundaries
|
||||
} from "./Parser";
|
||||
|
||||
function getCacheBuster(): string {
|
||||
const url = document.querySelector('script[src*="viewer"]').attributes.src.value;
|
||||
|
|
@ -33,7 +42,18 @@ export class AsyncParser {
|
|||
const cachedData: ParsedDemo = event.data.demo;
|
||||
console.log(`packed data: ${(cachedData.data.length / (1024 * 1024)).toFixed(1)}MB`);
|
||||
this.world = cachedData.world;
|
||||
this.demo = new ParsedDemo(cachedData.playerCount, cachedData.buildingCount, cachedData.projectileCount, cachedData.world, cachedData.header, cachedData.data, cachedData.kills, cachedData.playerInfo, cachedData.tickCount);
|
||||
this.demo = new ParsedDemo(
|
||||
cachedData.playerCount,
|
||||
cachedData.buildingCount,
|
||||
cachedData.projectileCount,
|
||||
cachedData.world,
|
||||
cachedData.header,
|
||||
cachedData.data,
|
||||
cachedData.kills,
|
||||
cachedData.playerInfo,
|
||||
cachedData.events,
|
||||
cachedData.tickCount
|
||||
);
|
||||
resolve(this.demo);
|
||||
}
|
||||
}
|
||||
|
|
@ -76,4 +96,8 @@ export class AsyncParser {
|
|||
getKills(): Kill[] {
|
||||
return this.demo.kills
|
||||
}
|
||||
|
||||
getEvents(): Event[] {
|
||||
return this.demo.events
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,14 @@ export async function parseDemo(bytes: Uint8Array, progressCallback: (progress:
|
|||
let map = get_map(state);
|
||||
let data = get_data(state);
|
||||
|
||||
let events = kills.map((kill: Kill) => {
|
||||
return {
|
||||
tick: kill.tick,
|
||||
type: "kill",
|
||||
kill
|
||||
} as Event
|
||||
});
|
||||
|
||||
return new ParsedDemo(
|
||||
playerCount,
|
||||
buildingCount,
|
||||
|
|
@ -75,6 +83,7 @@ export async function parseDemo(bytes: Uint8Array, progressCallback: (progress:
|
|||
data,
|
||||
kills,
|
||||
playerInfo,
|
||||
events,
|
||||
tickCount,
|
||||
);
|
||||
}
|
||||
|
|
@ -206,8 +215,20 @@ export class ParsedDemo {
|
|||
public readonly tickCount: number;
|
||||
public readonly kills: Kill[];
|
||||
public readonly playerInfo: PlayerInfo[];
|
||||
public readonly events: Event[];
|
||||
|
||||
constructor(playerCount: number, buildingCount: number, projectileCount: number, world: WorldBoundaries, header: Header, data: Uint8Array, kills: Kill[], playerInfo: PlayerInfo[], tickCount: number) {
|
||||
constructor(
|
||||
playerCount: number,
|
||||
buildingCount: number,
|
||||
projectileCount: number,
|
||||
world: WorldBoundaries,
|
||||
header: Header,
|
||||
data: Uint8Array,
|
||||
kills: Kill[],
|
||||
playerInfo: PlayerInfo[],
|
||||
events: Event[],
|
||||
tickCount: number
|
||||
) {
|
||||
this.playerCount = playerCount;
|
||||
this.buildingCount = buildingCount;
|
||||
this.projectileCount = projectileCount;
|
||||
|
|
@ -216,6 +237,7 @@ export class ParsedDemo {
|
|||
this.data = data;
|
||||
this.kills = kills;
|
||||
this.playerInfo = playerInfo;
|
||||
this.events = events;
|
||||
this.tickCount = tickCount;
|
||||
}
|
||||
|
||||
|
|
@ -309,3 +331,11 @@ function unpackProjectile(bytes: Uint8Array, base: number, world: WorldBoundarie
|
|||
projectileType,
|
||||
}
|
||||
}
|
||||
|
||||
export type KillEvent = {
|
||||
type: "kill";
|
||||
tick: number,
|
||||
kill: Kill,
|
||||
}
|
||||
|
||||
export type Event = KillEvent;
|
||||
137
script/viewer/Analyse/EventSearch.tsx
Normal file
137
script/viewer/Analyse/EventSearch.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import {Event, Kill, PlayerState} from "./Data/Parser";
|
||||
import {createEffect, createSignal, For, Show, untrack} from "solid-js";
|
||||
import {getPlayer, KillIcon, PlayerName, PlayerNames} from "./Render/KillFeed";
|
||||
import {autofocus} from "@solid-primitives/autofocus";
|
||||
import {useKeyDownEvent} from "@solid-primitives/keyboard";
|
||||
|
||||
export interface EventSearchProps {
|
||||
events: Event[];
|
||||
players: PlayerState[];
|
||||
onSearch: (string) => void;
|
||||
search: string;
|
||||
selectedEvent: number;
|
||||
onSelect: (event: Event) => void;
|
||||
}
|
||||
|
||||
export function EventSearch(props: EventSearchProps) {
|
||||
const keyEvent = useKeyDownEvent();
|
||||
const [selected, setSelected] = createSignal<number>(0);
|
||||
|
||||
const events = () => filterEvents(props.events, props.players, props.search);
|
||||
|
||||
createEffect(() => {
|
||||
const e = keyEvent();
|
||||
|
||||
untrack(() => {
|
||||
if (e) {
|
||||
const seekSelected = (offset) => {
|
||||
const target = Math.max(0, Math.min(selected() + offset, events().length - 1));
|
||||
setSelected(target);
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
seekSelected(-1);
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
seekSelected(1);
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
props.onSelect(events()[selected()]);
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return (<div class="event-search">
|
||||
<input type="text" ref={autofocus} autofocus value={props.search}
|
||||
onInput={(e) => props.onSearch(e.target.value)}/>
|
||||
<table class="event-list">
|
||||
<For each={events()}>{(event, i) =>
|
||||
<EventView event={event} highlighted={i() == selected()} players={props.players}/>
|
||||
}</For>
|
||||
</table>
|
||||
</div>)
|
||||
}
|
||||
|
||||
interface EventViewProps {
|
||||
event: Event;
|
||||
highlighted: boolean,
|
||||
players: PlayerState[];
|
||||
}
|
||||
|
||||
function EventView(props: EventViewProps) {
|
||||
let row;
|
||||
const highlightClass = () => ` ${props.highlighted ? 'highlighted' : ''}`;
|
||||
createEffect(() => {
|
||||
if (props.highlighted) {
|
||||
row.scrollIntoView(false);
|
||||
}
|
||||
})
|
||||
return (
|
||||
<tr ref={row} class={props.event.type + highlightClass()}>
|
||||
<Show when={props.event.type == "kill"}>
|
||||
<KillView kill={props.event.kill} players={props.players}/>
|
||||
</Show>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
interface KillViewProps {
|
||||
kill: Kill;
|
||||
players: PlayerState[];
|
||||
}
|
||||
|
||||
function KillView(props: KillViewProps) {
|
||||
const attacker = getPlayer(props.players, props.kill.attacker);
|
||||
const assister = getPlayer(props.players, props.kill.assister);
|
||||
let victim = getPlayer(props.players, props.kill.victim);
|
||||
|
||||
return <>
|
||||
<td class="kill-source">
|
||||
<PlayerNames players={[attacker, assister]}/>
|
||||
</td>
|
||||
<td class="kill-icon">
|
||||
<KillIcon kill={props.kill}/>
|
||||
</td>
|
||||
<td className="kill-target">
|
||||
<PlayerName player={victim}/>
|
||||
</td>
|
||||
</>
|
||||
}
|
||||
|
||||
function filterEvents(events: Event[], players: PlayerState[], query: string): Event[] {
|
||||
if (query === '') {
|
||||
return events;
|
||||
}
|
||||
query = query.toLowerCase();
|
||||
let filteredEvents = [].concat(events);
|
||||
let queryParts = query.split(' ').filter(part => part.length > 0);
|
||||
for (const queryPart of queryParts) {
|
||||
const playersForPart = findPlayers(players, queryPart);
|
||||
filteredEvents = filteredEvents.filter(event => eventMatches(event, playersForPart, queryPart));
|
||||
}
|
||||
return filteredEvents;
|
||||
}
|
||||
|
||||
function findPlayers(players: PlayerState[], queryPart: string): number[] {
|
||||
return players.flatMap(player => {
|
||||
if (player.info.name.toLowerCase().includes(queryPart)) {
|
||||
return [player.info.userId]
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function eventMatches(event: Event, matchedPlayers: number[], queryPart: string): boolean {
|
||||
if (event.type === "kill") {
|
||||
const kill = event.kill;
|
||||
return matchedPlayers.includes(kill.attacker) ||
|
||||
matchedPlayers.includes(kill.assister) ||
|
||||
matchedPlayers.includes(kill.victim);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import {Kill, PlayerState} from "../Data/Parser";
|
||||
import {killAlias} from "./killAlias";
|
||||
import {For, Show} from "solid-js";
|
||||
|
||||
export interface KillFeedProps {
|
||||
kills: Kill[],
|
||||
|
|
@ -11,11 +12,11 @@ export function KillFeed(props: KillFeedProps) {
|
|||
const {kills} = props;
|
||||
const relevantKills = () => kills.filter(kill => kill.tick <= props.tick && kill.tick >= (props.tick - 30 * 10));
|
||||
|
||||
return <div class="killfeed">
|
||||
return <ul class="killfeed">
|
||||
<For each={relevantKills()}>{(kill) =>
|
||||
<KillFeedItem kill={kill} players={props.players}/>
|
||||
}</For>
|
||||
</div>
|
||||
</ul>
|
||||
}
|
||||
|
||||
const teamMap = {
|
||||
|
|
@ -24,11 +25,29 @@ const teamMap = {
|
|||
3: 'blue'
|
||||
};
|
||||
|
||||
export function KillFeedItem({kill, players}: { kill: Kill, players: PlayerState[] }) {
|
||||
const alias = killAlias[kill.weapon] ? killAlias[kill.weapon] : kill.weapon;
|
||||
const attacker = getPlayer(players, kill.attacker);
|
||||
const assister = getPlayer(players, kill.assister);
|
||||
let victim = getPlayer(players, kill.victim);
|
||||
interface KillFeedItemProps {
|
||||
kill: Kill;
|
||||
players: PlayerState[];
|
||||
}
|
||||
|
||||
export function KillFeedItem(props: KillFeedItemProps) {
|
||||
const attacker = getPlayer(props.players, props.kill.attacker);
|
||||
const assister = getPlayer(props.players, props.kill.assister);
|
||||
let victim = getPlayer(props.players, props.kill.victim);
|
||||
|
||||
return <li class="kill">
|
||||
<PlayerNames players={[attacker, assister]}/>
|
||||
<KillIcon kill={props.kill}/>
|
||||
<PlayerName player={victim}/>
|
||||
</li>
|
||||
}
|
||||
|
||||
interface KillIconProps {
|
||||
kill: Kill;
|
||||
}
|
||||
|
||||
export function KillIcon(props: KillIconProps) {
|
||||
const alias = killAlias[props.kill.weapon] ? killAlias[props.kill.weapon] : props.kill.weapon;
|
||||
let killIcon;
|
||||
try {
|
||||
killIcon = `/images/kill_icons/${alias}.png`;
|
||||
|
|
@ -36,33 +55,35 @@ export function KillFeedItem({kill, players}: { kill: Kill, players: PlayerState
|
|||
console.log(alias);
|
||||
killIcon = `/images/kill_icons/skull.png`;
|
||||
}
|
||||
if (!victim) {
|
||||
victim = {
|
||||
team: 0,
|
||||
info: {
|
||||
name: 'Missing player'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return <div class="kill">
|
||||
{(attacker && kill.attacker !== kill.victim) ?
|
||||
<span class={"player " + teamMap[attacker.team]}>
|
||||
{attacker.info.name}
|
||||
</span> : ''}
|
||||
{(assister && kill.assister !== kill.victim) ?
|
||||
<span class={teamMap[assister.team]}>﹢</span> : ''}
|
||||
{(assister && kill.assister !== kill.victim) ?
|
||||
(<span class={"player " + teamMap[assister.team]}>
|
||||
{assister.info.name}
|
||||
</span>) : ''}
|
||||
<img src={killIcon} class={`kill-icon ${kill.weapon}`}/>
|
||||
<span class={"player " + teamMap[victim.team]}>
|
||||
{victim.info.name}
|
||||
</span>
|
||||
</div>
|
||||
return <img src={killIcon} class={`kill-icon ${props.kill.weapon}`}/>
|
||||
}
|
||||
|
||||
function getPlayer(players: PlayerState[], entityId: number): PlayerState {
|
||||
interface PlayerNameProps {
|
||||
player: PlayerState | null
|
||||
}
|
||||
|
||||
export function PlayerName(props: PlayerNameProps) {
|
||||
return <Show when={props.player}>
|
||||
<span className={"player " + teamMap[props.player.team]}>
|
||||
{props.player.info.name}
|
||||
</span>
|
||||
</Show>
|
||||
}
|
||||
|
||||
interface PlayerNamesProps {
|
||||
players: (PlayerState | null)[]
|
||||
}
|
||||
|
||||
export function PlayerNames(props: PlayerNamesProps) {
|
||||
return <For each={props.players}>{(player, i) => <>
|
||||
<Show when={i() > 0 && player}>
|
||||
<span className={teamMap[player.team]}>+</span>
|
||||
</Show>
|
||||
<PlayerName player={player}/>
|
||||
</>}</For>
|
||||
}
|
||||
|
||||
export function getPlayer(players: PlayerState[], entityId: number): PlayerState | null {
|
||||
return players.find(player => player.info.userId == entityId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,5 +34,6 @@ export const killAlias = {
|
|||
'loose_cannon_explosion': 'loose_cannon',
|
||||
'samrevolver': 'big_kill',
|
||||
'long_heatmaker': 'huo-long_heater',
|
||||
'pep_pistol': 'pistol'
|
||||
'pep_pistol': 'pistol',
|
||||
'ai_flamethrower': 'flamethrower',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
@import 'viewer/Player.css';
|
||||
@import 'viewer/PlayerSpec.css';
|
||||
@import 'viewer/Timeline.css';
|
||||
@import 'viewer/EventSearch.css';
|
||||
|
||||
progress {
|
||||
width: 100%;
|
||||
|
|
|
|||
55
style/pages/viewer/EventSearch.css
Normal file
55
style/pages/viewer/EventSearch.css
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
div.event-search {
|
||||
width: 700px;
|
||||
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
table.event-list {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
line-height: 34px;
|
||||
table-layout: fixed;
|
||||
|
||||
tr.highlighted {
|
||||
background: #3a3a3a;
|
||||
}
|
||||
|
||||
td {
|
||||
vertical-align: top;
|
||||
padding: 0 3px;
|
||||
}
|
||||
|
||||
.kill {
|
||||
& .red {
|
||||
color: #a75d50;
|
||||
}
|
||||
|
||||
& .blue {
|
||||
color: #5b818f;
|
||||
}
|
||||
|
||||
td {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
td.kill-icon {
|
||||
width: 60px;
|
||||
|
||||
img {
|
||||
max-width: 60px;
|
||||
max-height: 25px;
|
||||
height: auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
td.kill-source {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue