This commit is contained in:
Robin Appelman 2024-11-22 22:17:21 +01:00
commit b08267a08c
10 changed files with 318 additions and 304 deletions

2
Cargo.lock generated
View file

@ -953,7 +953,7 @@ dependencies = [
[[package]] [[package]]
name = "demostf-frontend" name = "demostf-frontend"
version = "1.0.0" version = "1.0.1"
dependencies = [ dependencies = [
"async-session", "async-session",
"axum", "axum",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "demostf-frontend" name = "demostf-frontend"
version = "1.0.0" version = "1.0.1"
edition = "2021" edition = "2021"
[dependencies] [dependencies]

30
flake.lock generated
View file

@ -22,17 +22,14 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1731933812, "lastModified": 1732198790,
"narHash": "sha256-PeuRDDq1DcHxbOmXWb3nWMp5PqWUn5PSKimNbUzWEaQ=", "narHash": "sha256-VNTFKcX52PRh3I88ofYTyBWCtqVQB3fVIMO64HHs3+0=",
"owner": "nix-community", "path": "/home/robin/Projects/flakelight",
"repo": "flakelight", "type": "path"
"rev": "f4b26f683be5c9ac1ec05f968dbbcb35f4bb6346",
"type": "github"
}, },
"original": { "original": {
"owner": "nix-community", "path": "/home/robin/Projects/flakelight",
"repo": "flakelight", "type": "path"
"type": "github"
} }
}, },
"mill-scale": { "mill-scale": {
@ -44,17 +41,14 @@
"rust-overlay": "rust-overlay" "rust-overlay": "rust-overlay"
}, },
"locked": { "locked": {
"lastModified": 1732197065, "lastModified": 1732212005,
"narHash": "sha256-Lyosd/rJHFp1xnWPNhukQkW1hFtIiIiVDxQ+fcVypgI=", "narHash": "sha256-t2+yKoxZe2JkvyHkJAAyyLS6N8yzyx0o95DuzHqv7JA=",
"owner": "icewind1991", "path": "/home/robin/Projects/mill-scale",
"repo": "mill-scale", "type": "path"
"rev": "8051d162308a80dde168b7efe012bfa1363be4ba",
"type": "github"
}, },
"original": { "original": {
"owner": "icewind1991", "path": "/home/robin/Projects/mill-scale",
"repo": "mill-scale", "type": "path"
"type": "github"
} }
}, },
"nixpkgs": { "nixpkgs": {

View file

@ -2,11 +2,13 @@
inputs = { inputs = {
nixpkgs.url = "nixpkgs/nixos-24.05"; nixpkgs.url = "nixpkgs/nixos-24.05";
flakelight = { flakelight = {
url = "github:nix-community/flakelight"; # url = "github:nix-community/flakelight";
url = "path:/home/robin/Projects/flakelight";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
mill-scale = { mill-scale = {
url = "github:icewind1991/mill-scale"; # url = "github:icewind1991/mill-scale";
url = "path:/home/robin/Projects/mill-scale";
inputs.flakelight.follows = "flakelight"; inputs.flakelight.follows = "flakelight";
}; };
npmlock2nix = { npmlock2nix = {
@ -37,6 +39,8 @@
]; ];
toolchain = pkgs: pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml; toolchain = pkgs: pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
tools = pkgs: with pkgs; [ tools = pkgs: with pkgs; [
bacon
cargo-edit
nodejs nodejs
nodePackages.svgo nodePackages.svgo
typescript typescript

View file

@ -1,26 +1,29 @@
export interface AnalyseMenuProps { export interface AnalyseMenuProps {
sessionName: string; sessionName: string;
onShare: Function; onShare: Function;
canShare: boolean; canShare: boolean;
isShared: boolean; isShared: boolean;
} }
export function AnalyseMenu({sessionName, onShare, canShare, isShared}:AnalyseMenuProps) { export function AnalyseMenu(props: AnalyseMenuProps) {
const loc = () => window.location.toString().replace(/\#.*/, '') + '#' + props.sessionName;
const shareText = () => (props.isShared) ?
<input class="share-text" value={loc()} readOnly={true}
title="Use this link to join the current session"
style={{width: `${(loc().length * 8)}px`}}
onFocus={(event) => {
(event.target as HTMLInputElement).select()
}}/> : '';
const loc = window.location.toString().replace(/\#.+/, '') + '#' + sessionName; const shareButton = () => (props.canShare) ? <div class="analyse-menu">
const shareText = (isShared) ? <button class="share-session" title="Start a shared session"
<input class="share-text" value={loc} readOnly={true} onClick={() => {
title="Use this link to join the current session" props.onShare()
style={{width: `${(loc.length*8)}px`}} }}/>
onFocus={(event)=>{(event.target as HTMLInputElement).select()}}/> : ''; {shareText}
</div> : '';
const shareButton = (canShare) ? <div class="analyse-menu"> return (<div>
<button class="share-session" title="Start a shared session" {shareButton}
onClick={()=>{onShare()}}/> </div>)
{shareText}
</div>: '';
return (<div>
{shareButton}
</div>)
} }

View file

@ -12,178 +12,182 @@ import {Session, StateUpdate} from "./Session";
import {DemoHead} from "../../header"; import {DemoHead} from "../../header";
export interface AnalyseProps { export interface AnalyseProps {
header: DemoHead; header: DemoHead;
isStored: boolean; isStored: boolean;
parser: AsyncParser; parser: AsyncParser;
} }
export const Analyser = (props: AnalyseProps) => { export const Analyser = (props: AnalyseProps) => {
const parser = props.parser; const parser = props.parser;
const intervalPerTick = props.header.duration / props.header.ticks; const intervalPerTick = props.header.duration / props.header.ticks;
const [tick, setTick] = createSignal<number>(0); const [tick, setTick] = createSignal<number>(0);
const [scale, setScale] = createSignal<number>(1); const [scale, setScale] = createSignal<number>(1);
const [playing, setPlaying] = createSignal<boolean>(false); const [playing, setPlaying] = createSignal<boolean>(false);
const [sessionName, setSessionName] = createSignal<string>(""); const [sessionName, setSessionName] = createSignal<string>("");
let lastFrameTime = 0; let lastFrameTime = 0;
let playStartTick = 0; let playStartTick = 0;
let playStartTime = 0; let playStartTime = 0;
const onUpdate = (update: StateUpdate) => { const onUpdate = (update: StateUpdate) => {
if (update["tick"]) { if (update["tick"]) {
setTick(update["tick"]); setTick(update["tick"]);
} }
if (update["playing"]) { if (update.hasOwnProperty("playing")) {
setPlaying(update["playing"]); if (update["playing"]) {
} play();
} } else {
pause();
}
}
}
let session: Session | null = null; let session: Session | null = null;
if (props.isStored && window.location.hash) { if (props.isStored && window.location.hash) {
const parsed = parseInt(window.location.hash.substr(1), 10); const parsed = parseInt(window.location.hash.substr(1), 10);
if (('#' + parsed) === window.location.hash) { if (('#' + parsed) === window.location.hash) {
setTick(Math.floor(parsed)); setTick(Math.floor(parsed));
} else { } else {
const name = window.location.hash.substring(1); const name = window.location.hash.substring(1);
Session.join(name, onUpdate); session = Session.join(name, onUpdate);
setSessionName(name); setSessionName(name);
} }
} }
const map = parser.demo.header.map; const map = parser.demo.header.map;
const backgroundBoundaries = getMapBoundaries(map); const backgroundBoundaries = getMapBoundaries(map);
if (!backgroundBoundaries) { if (!backgroundBoundaries) {
throw new Error(`Map not supported "${map}".`); throw new Error(`Map not supported "${map}".`);
} }
const worldSize = { const worldSize = {
width: backgroundBoundaries.boundary_max.x - backgroundBoundaries.boundary_min.x, width: backgroundBoundaries.boundary_max.x - backgroundBoundaries.boundary_min.x,
height: backgroundBoundaries.boundary_max.y - backgroundBoundaries.boundary_min.y, height: backgroundBoundaries.boundary_max.y - backgroundBoundaries.boundary_min.y,
}; };
const setTickNow = (tick) => { const setTickNow = (tick) => {
lastFrameTime = 0; lastFrameTime = 0;
playStartTick = tick; playStartTick = tick;
playStartTime = window.performance.now(); playStartTime = window.performance.now();
setTick(tick); setTick(tick);
setHash(tick); setHash(tick);
if (session) { if (session) {
session.update({tick}); session.update({tick});
} }
} }
const pause = () => { const pause = () => {
setPlaying(false); setPlaying(false);
lastFrameTime = 0; lastFrameTime = 0;
if (session) { if (session) {
session.update({playing: false}); session.update({playing: false});
} }
} }
const play = () => { const play = () => {
playStartTick = tick(); playStartTick = tick();
playStartTime = window.performance.now(); playStartTime = window.performance.now();
setPlaying(true); setPlaying(true);
requestAnimationFrame(animFrame); requestAnimationFrame(animFrame);
if (session) { if (session) {
session.update({playing: false}); session.update({playing: true});
} }
} }
const togglePlay = () => { const togglePlay = () => {
if (playing()) { if (playing()) {
pause(); pause();
} else { } else {
play(); play();
} }
} }
const syncPlayTick = debounce(500, () => { const syncPlayTick = throttle(2500, () => {
if (session) { if (session) {
session.update({ session.update({
playing: playing(), playing: playing(),
tick: tick(), tick: tick(),
}); });
} }
}); });
const setHash = debounce(250, (tick) => { const setHash = debounce(250, (tick) => {
if (!session && props.isStored) { if (!session && props.isStored) {
history.replaceState('', '', '#' + tick); history.replaceState('', '', '#' + tick);
} }
}); });
const animFrame = (timestamp:number) => { const animFrame = (timestamp: number) => {
const timePassed = (timestamp - playStartTime) / 1000; const timePassed = (timestamp - playStartTime) / 1000;
const targetTick = playStartTick + (Math.round(timePassed / intervalPerTick)); const targetTick = playStartTick + (Math.round(timePassed / intervalPerTick));
lastFrameTime = timestamp; lastFrameTime = timestamp;
if (targetTick >= (parser.demo.tick - 1)) { if (targetTick >= (parser.demo.tick - 1)) {
pause(); pause();
} }
setHash(targetTick); setHash(targetTick);
setTick(targetTick); setTick(targetTick);
syncPlayTick(); syncPlayTick();
if (playing()) { if (playing()) {
requestAnimationFrame(animFrame); requestAnimationFrame(animFrame);
} }
} }
const players = () => parser.getPlayersAtTick(tick()); const players = () => parser.getPlayersAtTick(tick());
const buildings = () => parser.getBuildingsAtTick(tick()); const buildings = () => parser.getBuildingsAtTick(tick());
const kills = parser.getKills(); const kills = parser.getKills();
const playButtonText = () => (playing()) ? '⏸' : '▶️'; const playButtonText = () => (playing()) ? '⏸' : '▶️';
const disabled = session && !session.isOwner(); const disabled = session && !session.isOwner();
const isShared = () => sessionName() !== ''; const isShared = () => sessionName() !== '';
console.log(intervalPerTick); console.log(intervalPerTick);
const timeTitle = () => `${tickToTime(tick(), intervalPerTick)} (tick ${tick()})`; const timeTitle = () => `${tickToTime(tick(), intervalPerTick)} (tick ${tick()})`;
return ( return (
<div> <div>
<div class="map-holder"> <div class="map-holder">
<MapContainer contentSize={worldSize} <MapContainer contentSize={worldSize}
onScale={setScale}> onScale={setScale}>
<MapRender size={worldSize} <MapRender size={worldSize}
players={players()} players={players()}
buildings={buildings()} buildings={buildings()}
header={props.header} header={props.header}
world={backgroundBoundaries} world={backgroundBoundaries}
scale={scale()}/> scale={scale()}/>
</MapContainer> </MapContainer>
<AnalyseMenu sessionName={sessionName()} <AnalyseMenu sessionName={sessionName()}
onShare={() => { onShare={() => {
session = Session.create({ session = Session.create({
tick: tick(), tick: tick(),
playing: playing() playing: playing()
}); });
setSessionName(session.sessionName); setSessionName(session.sessionName);
}} }}
canShare={props.isStored && !disabled} canShare={props.isStored && !disabled}
isShared={isShared()} isShared={isShared()}
/> />
<SpecHUD parser={parser} tick={tick()} <SpecHUD parser={parser} tick={tick()}
players={players()} kills={kills}/> players={players()} kills={kills}/>
</div> </div>
<div class="time-control" <div class="time-control"
title={timeTitle()}> title={timeTitle()}>
<input class="play-pause-button" type="button" <input class="play-pause-button" type="button"
value={playButtonText()} value={playButtonText()}
disabled={disabled} disabled={disabled}
onClick={togglePlay} onClick={togglePlay}
/> />
<Timeline parser={parser} tick={tick()} <Timeline parser={parser} tick={tick()}
onSetTick={throttle(50, (tick) => { onSetTick={throttle(50, (tick) => {
setTickNow(tick); setTickNow(tick);
})} })}
disabled={disabled}/> disabled={disabled}/>
</div> </div>
</div> </div>
); );
} }
function tickToTime(tick: number, intervalPerTick: number): string { function tickToTime(tick: number, intervalPerTick: number): string {
let seconds = Math.floor(tick * intervalPerTick); let seconds = Math.floor(tick * intervalPerTick);
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;
} }

View file

@ -14,6 +14,7 @@ export class Session {
this.sessionName = name; this.sessionName = name;
this.initialState = initialState; this.initialState = initialState;
this.onState = onState; this.onState = onState;
this.open();
} }
public static create(state: PlaybackState): Session { public static create(state: PlaybackState): Session {
@ -98,7 +99,7 @@ export class Session {
tick: update["tick"] tick: update["tick"]
})); }));
} }
if (update["playing"]) { if (update.hasOwnProperty("playing")) {
this.socket.send(JSON.stringify({ this.socket.send(JSON.stringify({
type: 'play', type: 'play',
session: this.sessionName, session: this.sessionName,

View file

@ -293,10 +293,10 @@ impl Render for DemoFormat {
MapMode::Ultiduo => "Ultiduo", MapMode::Ultiduo => "Ultiduo",
MapMode::Bball => "BBall", MapMode::Bball => "BBall",
MapMode::Other => match self.player_count { MapMode::Other => match self.player_count {
17 | 18 | 19 => "HL", 17..=19 => "HL",
15 | 14 => "Prolander", 14..=15 => "Prolander",
13 | 12 | 11 => "6v6", 11..=13 => "6v6",
7 | 8 | 9 => "4v4", 7..=9 => "4v4",
_ => "Other", _ => "Other",
}, },
}; };

View file

@ -1,42 +1,42 @@
.analyse-menu { .analyse-menu {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
opacity: 0.5; opacity: 0.5;
transition: all 0.5s; transition: all 0.5s;
&:hover { &:hover {
opacity: 1; opacity: 1;
}
& .share-session {
background: transparent;
color: var(--primary-color);
font-size: 200%;
width: 32px;
height: 32px;
margin: 10px;
border: none;
cursor: pointer;
background-image: url("images/link_white.svg");
background-size: contain;
&:active, &:focus {
outline: none;
} }
}
& .share-text { & .share-session {
color: var(--primary-color);
background-color: var(--text-secondary);
padding: 5px;
margin-top: 0;
border: 1px #888 solid;
border-radius: 5px;
&:active, &:focus { background: transparent;
outline: none; color: var(--primary-color);
font-size: 200%;
width: 32px;
height: 32px;
margin: 10px;
border: none;
cursor: pointer;
background-image: url("inline://images//link_white.svg");
background-size: contain;
&:active, &:focus {
outline: none;
}
}
& .share-text {
color: var(--primary-color);
background-color: var(--text-secondary);
padding: 5px;
margin-top: 0;
border: 1px #888 solid;
border-radius: 5px;
&:active, &:focus {
outline: none;
}
} }
}
} }

View file

@ -1,75 +1,83 @@
.map-holder { .map-holder {
position: fixed; position: fixed;
top: 32px; top: 32px;
left: 0; left: 0;
width: 100%; width: 100%;
height: calc(100% - 32px - 100px); height: calc(100% - 32px - 100px);
} }
.time-control { .time-control {
position: fixed; position: fixed;
bottom: 0;
left: 0;
width: 100%;
height: 100px;
background-color: var(--primary-color-accent);
& .timeline {
position: absolute;
bottom: 0;
left: 64px;
width: calc(100% - 64px);
}
& .play-pause-button {
position: absolute;
bottom: 0; bottom: 0;
left: 0; left: 0;
width: 100%;
height: 100px; height: 100px;
width: 64px; background-color: var(--primary-color-accent);
background-color: transparent;
color: black;
font-size: 200%;
border: none;
&:focus, &:active { & .timeline {
outline: none; position: absolute;
bottom: 0;
left: 64px;
width: calc(100% - 64px);
& .timeline-progress[disabled] {
opacity: 0.2;
}
}
& .play-pause-button {
position: absolute;
bottom: 0;
left: 0;
height: 100px;
width: 64px;
background-color: transparent;
color: black;
font-size: 200%;
border: none;
&:focus, &:active {
outline: none;
}
&[disabled] {
opacity: 0.2;
}
} }
}
} }
.error-holder { .error-holder {
.error-image { .error-image {
text-align: center; text-align: center;
padding: 20px; padding: 20px;
font-size: 250%; font-size: 250%;
&::after { &::after {
display: block; display: block;
content: ''; content: '';
background-size: contain; background-size: contain;
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: 50% 50%; background-position: 50% 50%;
background-image: url('images/teleporter.png'); background-image: url('images/teleporter.png');
width: 100%; width: 100%;
height: 500px; height: 500px;
margin: 50px 0; margin: 50px 0;
}
} }
}
& .error { & .error {
background-color: #FF9494; background-color: #FF9494;
line-height: 32px; line-height: 32px;
margin: 0 -30px; margin: 0 -30px;
padding: 32px; padding: 32px;
padding-left: 74px; padding-left: 74px;
background-image: url('images/error.png'); background-image: url('images/error.png');
background-size: 32px; background-size: 32px;
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: 32px 32px; background-position: 32px 32px;
& .error-hint { & .error-hint {
margin-top: 32px; margin-top: 32px;
}
} }
}
} }