frontend: improve launch UI and refactor stream to new page
This commit is contained in:
@@ -40,5 +40,8 @@
|
|||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"esbuild"
|
"esbuild"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"svelte-loading-spinners": "^0.3.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+9
@@ -7,6 +7,10 @@ settings:
|
|||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
|
dependencies:
|
||||||
|
svelte-loading-spinners:
|
||||||
|
specifier: ^0.3.6
|
||||||
|
version: 0.3.6
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@eslint/compat':
|
'@eslint/compat':
|
||||||
specifier: ^1.2.5
|
specifier: ^1.2.5
|
||||||
@@ -1307,6 +1311,9 @@ packages:
|
|||||||
svelte:
|
svelte:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
svelte-loading-spinners@0.3.6:
|
||||||
|
resolution: {integrity: sha512-mthHQ2TwiwzTWzbFry3CBnVEfzqPOD9WkVw84OfSYzHRq6N9wgQ+yv37u81uPeuLU/ZOIPqhujpXquB1aol5ZQ==}
|
||||||
|
|
||||||
svelte@5.36.12:
|
svelte@5.36.12:
|
||||||
resolution: {integrity: sha512-c3mWT+b0yBLl3gPGSHiy4pdSQCsPNTjLC0tVoOhrGJ6PPfCzD/RQpAmAfJtQZ304CAae2ph+L3C4aqds3R3seQ==}
|
resolution: {integrity: sha512-c3mWT+b0yBLl3gPGSHiy4pdSQCsPNTjLC0tVoOhrGJ6PPfCzD/RQpAmAfJtQZ304CAae2ph+L3C4aqds3R3seQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -2480,6 +2487,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
svelte: 5.36.12
|
svelte: 5.36.12
|
||||||
|
|
||||||
|
svelte-loading-spinners@0.3.6: {}
|
||||||
|
|
||||||
svelte@5.36.12:
|
svelte@5.36.12:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ampproject/remapping': 2.3.0
|
'@ampproject/remapping': 2.3.0
|
||||||
|
|||||||
@@ -1,35 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Covers from './Covers.svelte';
|
import Covers from './Covers.svelte';
|
||||||
import Stream from './Stream.svelte';
|
|
||||||
import welcome from '$lib/images/svelte-welcome.webp';
|
import welcome from '$lib/images/svelte-welcome.webp';
|
||||||
import welcomeFallback from '$lib/images/svelte-welcome.png';
|
import welcomeFallback from '$lib/images/svelte-welcome.png';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!--
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Home</title>
|
<title>Home</title>
|
||||||
<meta name="description" content="Svelte demo app" />
|
<meta name="description" content="Svelte demo app" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
-->
|
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<!--<h1>
|
|
||||||
<span class="welcome">
|
|
||||||
<picture>
|
|
||||||
<source srcset={welcome} type="image/webp" />
|
|
||||||
<img src={welcomeFallback} alt="Welcome" />
|
|
||||||
</picture>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
to your new<br />SvelteKit app
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<h2>
|
|
||||||
try editing <strong>src/routes/+page.svelte</strong>
|
|
||||||
</h2>-->
|
|
||||||
|
|
||||||
<Covers />
|
<Covers />
|
||||||
<Stream />
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -40,24 +21,4 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
flex: 0.6;
|
flex: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.welcome {
|
|
||||||
display: block;
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 0;
|
|
||||||
padding: 0 0 calc(100% * 495 / 2048) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.welcome img {
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
top: 0;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,43 +1,68 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLButtonAttributes } from 'svelte/elements';
|
import type { HTMLButtonAttributes } from 'svelte/elements';
|
||||||
import { startStream } from './connect';
|
import { getStreamData } from './getStreamData';
|
||||||
import { type App } from './apps';
|
import { type App } from './apps';
|
||||||
|
import { Circle } from 'svelte-loading-spinners';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { streamStore } from './stores/streamStore.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
app: App;
|
app: App;
|
||||||
server_name: string;
|
server_name: string;
|
||||||
|
tab_index: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { app, server_name }: Props = $props();
|
let { app, server_name, tab_index }: Props = $props();
|
||||||
|
|
||||||
|
let loading = $state(false);
|
||||||
|
|
||||||
async function streamApp() {
|
async function streamApp() {
|
||||||
await startStream(app, server_name);
|
loading = true;
|
||||||
|
|
||||||
|
console.log(`Getting stream data for ${app.title} on ${server_name}`);
|
||||||
|
let streamData = await getStreamData(app.id, server_name);
|
||||||
|
streamStore.Url = streamData.Url;
|
||||||
|
streamStore.CertHash = streamData.CertHash;
|
||||||
|
|
||||||
|
console.log(`Stream data retrieved. Navigating to /stream.`);
|
||||||
|
await goto('/stream');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="app-box" on:click={streamApp}>
|
{#if loading}
|
||||||
<div class="app-artwork">
|
<div class="app-box">
|
||||||
<div class="play-button"></div>
|
<div class="app-artwork">
|
||||||
|
<div class="app-loading-box">
|
||||||
|
<Circle size="60" color="#FF3E00" unit="px" duration="1s" />
|
||||||
|
</div>
|
||||||
|
{#if app.active}
|
||||||
|
<div class="running-pill">Running</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="app-title">{app.title}</div>
|
||||||
|
<div class="app-server">{server_name}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="app-title">{app.title}</div>
|
{:else}
|
||||||
<div class="app-server">{server_name}</div>
|
<div class="app-box" onclick={streamApp} onkeydown={streamApp} role="button" tabindex={tab_index}>
|
||||||
</div>
|
<div class="app-artwork">
|
||||||
|
{#if app.active}
|
||||||
|
<div class="app-control-box">
|
||||||
|
<div class="resume-button"></div>
|
||||||
|
<div class="stop-button"></div>
|
||||||
|
</div>
|
||||||
|
<div class="running-pill">Running</div>
|
||||||
|
{:else}
|
||||||
|
<div class="app-control-box">
|
||||||
|
<div class="resume-button"></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="app-title">{app.title}</div>
|
||||||
|
<div class="app-server">{server_name}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
body {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
margin: 20px;
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.apps-container {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 20px;
|
|
||||||
padding: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-box {
|
.app-box {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 200px;
|
width: 200px;
|
||||||
@@ -53,7 +78,7 @@
|
|||||||
|
|
||||||
.app-artwork {
|
.app-artwork {
|
||||||
width: 200px;
|
width: 200px;
|
||||||
height: 200px;
|
height: 280px;
|
||||||
background-color: #333;
|
background-color: #333;
|
||||||
border: 2px solid #555;
|
border: 2px solid #555;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -66,6 +91,73 @@
|
|||||||
border-color: #00aaff;
|
border-color: #00aaff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-loading-box,
|
||||||
|
.app-control-box {
|
||||||
|
width: 200px;
|
||||||
|
height: 60px;
|
||||||
|
position: relative;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(0%, -50%);
|
||||||
|
opacity: 0;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-loading-box {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-box:hover .app-control-box {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stop-button,
|
||||||
|
.resume-button {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
background-color: rgba(0, 170, 255, 0.9);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resume-button::after {
|
||||||
|
content: '';
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 20px solid white;
|
||||||
|
border-top: 12px solid transparent;
|
||||||
|
border-bottom: 12px solid transparent;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stop-button::after {
|
||||||
|
content: '';
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.running-pill {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 12px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background-color: #22c55e;
|
||||||
|
color: white;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
.app-title {
|
.app-title {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 10px 0 5px 0;
|
margin: 10px 0 5px 0;
|
||||||
@@ -82,52 +174,4 @@
|
|||||||
color: #aaa;
|
color: #aaa;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.play-button {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
background-color: rgba(0, 170, 255, 0.9);
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-button::after {
|
|
||||||
content: '';
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
border-left: 20px solid white;
|
|
||||||
border-top: 12px solid transparent;
|
|
||||||
border-bottom: 12px solid transparent;
|
|
||||||
margin-left: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-box:hover .play-button {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-box.clicked {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 18px;
|
|
||||||
margin-top: 50px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
color: #ff4444;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 18px;
|
|
||||||
margin-top: 50px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Cover from './Cover.svelte';
|
import Cover from './Cover.svelte';
|
||||||
import { fetchApps } from './apps';
|
import { fetchApps } from './apps';
|
||||||
|
import type { App } from './apps';
|
||||||
interface App {
|
|
||||||
title: string;
|
|
||||||
id: number;
|
|
||||||
hdr_supported: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AppsResponse {
|
interface AppsResponse {
|
||||||
apps: Record<string, App[]>;
|
apps: Record<string, App[]>;
|
||||||
@@ -14,15 +9,6 @@
|
|||||||
|
|
||||||
let appsPromise: Promise<AppsResponse>;
|
let appsPromise: Promise<AppsResponse>;
|
||||||
|
|
||||||
//async function fetchApps() {
|
|
||||||
// console.log('apps');
|
|
||||||
// const response = await fetch('/api/apps');
|
|
||||||
// console.log(response);
|
|
||||||
// const data = (await response.json()) as AppsResponse;
|
|
||||||
// console.log(data);
|
|
||||||
// return data;
|
|
||||||
//}
|
|
||||||
|
|
||||||
appsPromise = fetchApps();
|
appsPromise = fetchApps();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -31,8 +17,8 @@
|
|||||||
{:then resp}
|
{:then resp}
|
||||||
<div class="apps-container">
|
<div class="apps-container">
|
||||||
{#each Object.entries(resp.apps) as [server_name, apps]}
|
{#each Object.entries(resp.apps) as [server_name, apps]}
|
||||||
{#each apps as app}
|
{#each apps as app, tab_index}
|
||||||
<Cover {app} {server_name}></Cover>
|
<Cover {app} {server_name} {tab_index}></Cover>
|
||||||
{/each}
|
{/each}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Cover from './Cover.svelte';
|
|
||||||
import { fetchApps } from './apps';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { id }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<canvas id="canvas"></canvas>
|
|
||||||
|
|
||||||
<!--{#await appsPromise}
|
|
||||||
<p>Loading...</p>
|
|
||||||
{:then resp}
|
|
||||||
<div class="apps-container">
|
|
||||||
{#each Object.entries(resp.apps) as [server_name, apps]}
|
|
||||||
{#each apps as app}
|
|
||||||
<Cover {app} {server_name}></Cover>
|
|
||||||
{/each}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:catch error}
|
|
||||||
<p>Error: {error.message}</p>
|
|
||||||
{/await}-->
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.apps-container {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 20px;
|
|
||||||
padding: 20px 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -2,6 +2,7 @@ export interface App {
|
|||||||
title: string;
|
title: string;
|
||||||
id: number;
|
id: number;
|
||||||
hdr_supported: boolean;
|
hdr_supported: boolean;
|
||||||
|
active: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppsResponse {
|
export interface AppsResponse {
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
//Setup {
|
||||||
|
// video_format: VideoFormat,
|
||||||
|
// width: u64,
|
||||||
|
// height: u64,
|
||||||
|
// redraw_rate: u64,
|
||||||
|
// dr_flags: i32,
|
||||||
|
//},
|
||||||
|
//DecodeUnit {
|
||||||
|
// frame_number: u64,
|
||||||
|
// frame_type: FrameType,
|
||||||
|
|
||||||
|
// host_processing_latency: u16,
|
||||||
|
// receieve_time_ms: u64,
|
||||||
|
// enqueue_time_ms: u64,
|
||||||
|
// presentation_time: u64,
|
||||||
|
|
||||||
|
// full_length: usize,
|
||||||
|
// //buffers: Vec<Buffer>,
|
||||||
|
// buffer: Buffer,
|
||||||
|
// index: u64,
|
||||||
|
|
||||||
|
// hdr_active: bool,
|
||||||
|
// colorspace: u8,
|
||||||
|
//},
|
||||||
|
|
||||||
|
|
||||||
|
type StreamData = {
|
||||||
|
Url: string,
|
||||||
|
CertHash: Array<number>,
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStreamData(appId: number, server_name: string): Promise<StreamData> {
|
||||||
|
try {
|
||||||
|
// Create the POST request payload
|
||||||
|
const payload = {
|
||||||
|
id: appId,
|
||||||
|
server: server_name,
|
||||||
|
server_mode: {
|
||||||
|
fps: 60,
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
},
|
||||||
|
stream_config: {
|
||||||
|
bitrate_kbps: 5120,
|
||||||
|
mode: {
|
||||||
|
fps: 60,
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make POST request to start stream
|
||||||
|
const response = await fetch('/api/stream/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}: ${await response.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamDataResp = await response.json();
|
||||||
|
console.log('Stream started:', streamDataResp);
|
||||||
|
|
||||||
|
let streamData: StreamData = { Url: streamDataResp.url, CertHash: streamDataResp.cert_hash };
|
||||||
|
return streamData;
|
||||||
|
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting stream data: ', error);
|
||||||
|
throw new Error('Failed to start stream: ' + error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export const streamStore = $state({
|
||||||
|
Url: '',
|
||||||
|
CertHash: [0],
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Stream from './Stream.svelte';
|
||||||
|
import { streamStore } from '../stores/streamStore.svelte';
|
||||||
|
|
||||||
|
$: url = streamStore.Url;
|
||||||
|
$: certHash = streamStore.CertHash;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Stream</title>
|
||||||
|
<meta name="description" content="Streaming game" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<Stream {url} {certHash} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex: 0.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { streamUrl } from './stream';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
url: string;
|
||||||
|
certHash: Array<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { url, certHash }: Props = $props();
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
async function startStream() {
|
||||||
|
console.log(`Connecting to stream at ${url} with cert_hash ${certHash}`);
|
||||||
|
await streamUrl(url, certHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await startStream();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<canvas id="gamestream-canvas" width="1280" height="720"></canvas>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
</style>
|
||||||
@@ -1,32 +1,3 @@
|
|||||||
import { type App } from './apps'
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
//Setup {
|
|
||||||
// video_format: VideoFormat,
|
|
||||||
// width: u64,
|
|
||||||
// height: u64,
|
|
||||||
// redraw_rate: u64,
|
|
||||||
// dr_flags: i32,
|
|
||||||
//},
|
|
||||||
//DecodeUnit {
|
|
||||||
// frame_number: u64,
|
|
||||||
// frame_type: FrameType,
|
|
||||||
|
|
||||||
// host_processing_latency: u16,
|
|
||||||
// receieve_time_ms: u64,
|
|
||||||
// enqueue_time_ms: u64,
|
|
||||||
// presentation_time: u64,
|
|
||||||
|
|
||||||
// full_length: usize,
|
|
||||||
// //buffers: Vec<Buffer>,
|
|
||||||
// buffer: Buffer,
|
|
||||||
// index: u64,
|
|
||||||
|
|
||||||
// hdr_active: bool,
|
|
||||||
// colorspace: u8,
|
|
||||||
//},
|
|
||||||
|
|
||||||
type Setup = {
|
type Setup = {
|
||||||
video_format: string,
|
video_format: string,
|
||||||
width: number,
|
width: number,
|
||||||
@@ -55,59 +26,9 @@ type DecodeUnitPacket = {
|
|||||||
DecodeUnit: DecodeUnit
|
DecodeUnit: DecodeUnit
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startStream(app: App, server_name: string) {
|
|
||||||
try {
|
|
||||||
console.log(`Starting stream for ${app.title} on ${server_name}`);
|
|
||||||
|
|
||||||
// Create the POST request payload
|
|
||||||
const payload = {
|
|
||||||
id: app.id,
|
|
||||||
server: server_name,
|
|
||||||
server_mode: {
|
|
||||||
fps: 60,
|
|
||||||
height: 1280,
|
|
||||||
width: 720
|
|
||||||
},
|
|
||||||
stream_config: {
|
|
||||||
bitrate_kbps: 5120,
|
|
||||||
mode: {
|
|
||||||
fps: 60,
|
|
||||||
height: 1280,
|
|
||||||
width: 720
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Make POST request to start stream
|
|
||||||
const response = await fetch('/api/stream/start', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}: ${await response.text()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const streamData = await response.json();
|
|
||||||
console.log('Stream started:', streamData);
|
|
||||||
|
|
||||||
if (streamData.url && streamData.cert_hash) {
|
|
||||||
await connectToStream(streamData.url, streamData.cert_hash);
|
|
||||||
} else {
|
|
||||||
throw new Error('Response was missing required parameters');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error starting stream:', error);
|
|
||||||
alert('Failed to start stream: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export async function connectToStream(url: string, cert_hash: Array<number>) {
|
export async function streamUrl(url: string, cert_hash: Array<number>) {
|
||||||
const buffer = new Uint8Array(cert_hash);
|
const buffer = new Uint8Array(cert_hash);
|
||||||
console.log('Hash: ', buffer);
|
console.log('Hash: ', buffer);
|
||||||
try {
|
try {
|
||||||
@@ -174,7 +95,7 @@ export async function connectToStream(url: string, cert_hash: Array<number>) {
|
|||||||
|
|
||||||
let unparsedData = new Uint8Array();
|
let unparsedData = new Uint8Array();
|
||||||
|
|
||||||
const canvas: HTMLCanvasElement | null = <HTMLCanvasElement>document.getElementById('canvas');
|
const canvas: HTMLCanvasElement | null = <HTMLCanvasElement>document.getElementById('gamestream-canvas');
|
||||||
if (canvas == null) {
|
if (canvas == null) {
|
||||||
throw new Error(`Could not find canvas`);
|
throw new Error(`Could not find canvas`);
|
||||||
}
|
}
|
||||||
@@ -208,7 +129,6 @@ export async function connectToStream(url: string, cert_hash: Array<number>) {
|
|||||||
unparsedData = remainingData;
|
unparsedData = remainingData;
|
||||||
|
|
||||||
for (let i = 0; i < packets.length; i++) {
|
for (let i = 0; i < packets.length; i++) {
|
||||||
console.log(packets[i]);
|
|
||||||
if (Object.hasOwn(packets[i], "Setup")) {
|
if (Object.hasOwn(packets[i], "Setup")) {
|
||||||
let packet = packets[i] as SetupPacket;
|
let packet = packets[i] as SetupPacket;
|
||||||
|
|
||||||
@@ -244,7 +164,6 @@ export async function connectToStream(url: string, cert_hash: Array<number>) {
|
|||||||
type: frame_type,
|
type: frame_type,
|
||||||
data: new Uint8Array(packet.DecodeUnit.buffer.data),
|
data: new Uint8Array(packet.DecodeUnit.buffer.data),
|
||||||
});
|
});
|
||||||
console.log(chunk);
|
|
||||||
|
|
||||||
videoDecoder.decode(chunk);
|
videoDecoder.decode(chunk);
|
||||||
|
|
||||||
@@ -255,10 +174,8 @@ export async function connectToStream(url: string, cert_hash: Array<number>) {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//console.log(`Received: ${new TextDecoder().decode(value)}`);
|
|
||||||
|
|
||||||
|
|
||||||
//readData();
|
|
||||||
|
|
||||||
// Handle connection close
|
// Handle connection close
|
||||||
transport.closed.then(() => {
|
transport.closed.then(() => {
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { fail } from '@sveltejs/kit';
|
|
||||||
import { Game } from './game';
|
|
||||||
import type { PageServerLoad, Actions } from './$types';
|
|
||||||
|
|
||||||
export const load = (({ cookies }) => {
|
|
||||||
const game = new Game(cookies.get('sverdle'));
|
|
||||||
|
|
||||||
return {
|
|
||||||
/**
|
|
||||||
* The player's guessed words so far
|
|
||||||
*/
|
|
||||||
guesses: game.guesses,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An array of strings like '__x_c' corresponding to the guesses, where 'x' means
|
|
||||||
* an exact match, and 'c' means a close match (right letter, wrong place)
|
|
||||||
*/
|
|
||||||
answers: game.answers,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The correct answer, revealed if the game is over
|
|
||||||
*/
|
|
||||||
answer: game.answers.length >= 6 ? game.answer : null
|
|
||||||
};
|
|
||||||
}) satisfies PageServerLoad;
|
|
||||||
|
|
||||||
export const actions = {
|
|
||||||
/**
|
|
||||||
* Modify game state in reaction to a keypress. If client-side JavaScript
|
|
||||||
* is available, this will happen in the browser instead of here
|
|
||||||
*/
|
|
||||||
update: async ({ request, cookies }) => {
|
|
||||||
const game = new Game(cookies.get('sverdle'));
|
|
||||||
|
|
||||||
const data = await request.formData();
|
|
||||||
const key = data.get('key');
|
|
||||||
|
|
||||||
const i = game.answers.length;
|
|
||||||
|
|
||||||
if (key === 'backspace') {
|
|
||||||
game.guesses[i] = game.guesses[i].slice(0, -1);
|
|
||||||
} else {
|
|
||||||
game.guesses[i] += key;
|
|
||||||
}
|
|
||||||
|
|
||||||
cookies.set('sverdle', game.toString(), { path: '/' });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Modify game state in reaction to a guessed word. This logic always runs on
|
|
||||||
* the server, so that people can't cheat by peeking at the JavaScript
|
|
||||||
*/
|
|
||||||
enter: async ({ request, cookies }) => {
|
|
||||||
const game = new Game(cookies.get('sverdle'));
|
|
||||||
|
|
||||||
const data = await request.formData();
|
|
||||||
const guess = data.getAll('guess') as string[];
|
|
||||||
|
|
||||||
if (!game.enter(guess)) {
|
|
||||||
return fail(400, { badGuess: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
cookies.set('sverdle', game.toString(), { path: '/' });
|
|
||||||
},
|
|
||||||
|
|
||||||
restart: async ({ cookies }) => {
|
|
||||||
cookies.delete('sverdle', { path: '/' });
|
|
||||||
}
|
|
||||||
} satisfies Actions;
|
|
||||||
@@ -1,411 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
import { confetti } from '@neoconfetti/svelte';
|
|
||||||
import type { ActionData, PageData } from './$types';
|
|
||||||
import { MediaQuery } from 'svelte/reactivity';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
data: PageData;
|
|
||||||
form: ActionData;
|
|
||||||
}
|
|
||||||
let { data, form = $bindable() }: Props = $props();
|
|
||||||
|
|
||||||
/** Whether the user prefers reduced motion */
|
|
||||||
const reducedMotion = new MediaQuery('(prefers-reduced-motion: reduce)');
|
|
||||||
|
|
||||||
/** Whether or not the user has won */
|
|
||||||
let won = $derived(data.answers.at(-1) === 'xxxxx');
|
|
||||||
|
|
||||||
/** The index of the current guess */
|
|
||||||
let i = $derived(won ? -1 : data.answers.length);
|
|
||||||
|
|
||||||
/** The current guess */
|
|
||||||
let currentGuess = $derived(data.guesses[i] || '');
|
|
||||||
|
|
||||||
/** Whether the current guess can be submitted */
|
|
||||||
let submittable = $derived(currentGuess.length === 5);
|
|
||||||
|
|
||||||
const { classnames, description } = $derived.by(() => {
|
|
||||||
/**
|
|
||||||
* A map of classnames for all letters that have been guessed,
|
|
||||||
* used for styling the keyboard
|
|
||||||
*/
|
|
||||||
let classnames: Record<string, 'exact' | 'close' | 'missing'> = {};
|
|
||||||
/**
|
|
||||||
* A map of descriptions for all letters that have been guessed,
|
|
||||||
* used for adding text for assistive technology (e.g. screen readers)
|
|
||||||
*/
|
|
||||||
let description: Record<string, string> = {};
|
|
||||||
data.answers.forEach((answer, i) => {
|
|
||||||
const guess = data.guesses[i];
|
|
||||||
for (let i = 0; i < 5; i += 1) {
|
|
||||||
const letter = guess[i];
|
|
||||||
if (answer[i] === 'x') {
|
|
||||||
classnames[letter] = 'exact';
|
|
||||||
description[letter] = 'correct';
|
|
||||||
} else if (!classnames[letter]) {
|
|
||||||
classnames[letter] = answer[i] === 'c' ? 'close' : 'missing';
|
|
||||||
description[letter] = answer[i] === 'c' ? 'present' : 'absent';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return { classnames, description };
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Modify the game state without making a trip to the server,
|
|
||||||
* if client-side JavaScript is enabled
|
|
||||||
*/
|
|
||||||
function update(event: MouseEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
const key = (event.target as HTMLButtonElement).getAttribute(
|
|
||||||
'data-key'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (key === 'backspace') {
|
|
||||||
currentGuess = currentGuess.slice(0, -1);
|
|
||||||
if (form?.badGuess) form.badGuess = false;
|
|
||||||
} else if (currentGuess.length < 5) {
|
|
||||||
currentGuess += key;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trigger form logic in response to a keydown event, so that
|
|
||||||
* desktop users can use the keyboard to play the game
|
|
||||||
*/
|
|
||||||
function keydown(event: KeyboardEvent) {
|
|
||||||
if (event.metaKey) return;
|
|
||||||
|
|
||||||
if (event.key === 'Enter' && !submittable) return;
|
|
||||||
|
|
||||||
document
|
|
||||||
.querySelector(`[data-key="${event.key}" i]`)
|
|
||||||
?.dispatchEvent(new MouseEvent('click', { cancelable: true, bubbles: true }));
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:window onkeydown={keydown} />
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Sverdle</title>
|
|
||||||
<meta name="description" content="A Wordle clone written in SvelteKit" />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<h1 class="visually-hidden">Sverdle</h1>
|
|
||||||
|
|
||||||
<form
|
|
||||||
method="post"
|
|
||||||
action="?/enter"
|
|
||||||
use:enhance={() => {
|
|
||||||
// prevent default callback from resetting the form
|
|
||||||
return ({ update }) => {
|
|
||||||
update({ reset: false });
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<a class="how-to-play" href="/sverdle/how-to-play">How to play</a>
|
|
||||||
|
|
||||||
<div class="grid" class:playing={!won} class:bad-guess={form?.badGuess}>
|
|
||||||
{#each Array.from(Array(6).keys()) as row (row)}
|
|
||||||
{@const current = row === i}
|
|
||||||
<h2 class="visually-hidden">Row {row + 1}</h2>
|
|
||||||
<div class="row" class:current>
|
|
||||||
{#each Array.from(Array(5).keys()) as column (column)}
|
|
||||||
{@const guess = current ? currentGuess : data.guesses[row]}
|
|
||||||
{@const answer = data.answers[row]?.[column]}
|
|
||||||
{@const value = guess?.[column] ?? ''}
|
|
||||||
{@const selected = current && column === guess.length}
|
|
||||||
{@const exact = answer === 'x'}
|
|
||||||
{@const close = answer === 'c'}
|
|
||||||
{@const missing = answer === '_'}
|
|
||||||
<div class="letter" class:exact class:close class:missing class:selected>
|
|
||||||
{value}
|
|
||||||
<span class="visually-hidden">
|
|
||||||
{#if exact}
|
|
||||||
(correct)
|
|
||||||
{:else if close}
|
|
||||||
(present)
|
|
||||||
{:else if missing}
|
|
||||||
(absent)
|
|
||||||
{:else}
|
|
||||||
empty
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
<input name="guess" disabled={!current} type="hidden" {value} />
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="controls">
|
|
||||||
{#if won || data.answers.length >= 6}
|
|
||||||
{#if !won && data.answer}
|
|
||||||
<p>the answer was "{data.answer}"</p>
|
|
||||||
{/if}
|
|
||||||
<button data-key="enter" class="restart selected" formaction="?/restart">
|
|
||||||
{won ? 'you won :)' : `game over :(`} play again?
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<div class="keyboard">
|
|
||||||
<button data-key="enter" class:selected={submittable} disabled={!submittable}>enter</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onclick={update}
|
|
||||||
data-key="backspace"
|
|
||||||
formaction="?/update"
|
|
||||||
name="key"
|
|
||||||
value="backspace"
|
|
||||||
>
|
|
||||||
back
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#each ['qwertyuiop', 'asdfghjkl', 'zxcvbnm'] as row (row)}
|
|
||||||
<div class="row">
|
|
||||||
{#each row as letter, index (index)}
|
|
||||||
<button
|
|
||||||
onclick={update}
|
|
||||||
data-key={letter}
|
|
||||||
class={classnames[letter]}
|
|
||||||
disabled={submittable}
|
|
||||||
formaction="?/update"
|
|
||||||
name="key"
|
|
||||||
value={letter}
|
|
||||||
aria-label="{letter} {description[letter] || ''}"
|
|
||||||
>
|
|
||||||
{letter}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{#if won}
|
|
||||||
<div
|
|
||||||
style="position: absolute; left: 50%; top: 30%"
|
|
||||||
use:confetti={{
|
|
||||||
particleCount: reducedMotion.current ? 0 : undefined,
|
|
||||||
force: 0.7,
|
|
||||||
stageWidth: window.innerWidth,
|
|
||||||
stageHeight: window.innerHeight,
|
|
||||||
colors: ['#ff3e00', '#40b3ff', '#676778']
|
|
||||||
}}
|
|
||||||
></div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
form {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 1rem;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.how-to-play {
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.how-to-play::before {
|
|
||||||
content: 'i';
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 0.8em;
|
|
||||||
font-weight: 900;
|
|
||||||
width: 1em;
|
|
||||||
height: 1em;
|
|
||||||
padding: 0.2em;
|
|
||||||
line-height: 1;
|
|
||||||
border: 1.5px solid var(--color-text);
|
|
||||||
border-radius: 50%;
|
|
||||||
text-align: center;
|
|
||||||
margin: 0 0.5em 0 0;
|
|
||||||
position: relative;
|
|
||||||
top: -0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
--width: min(100vw, 40vh, 380px);
|
|
||||||
max-width: var(--width);
|
|
||||||
align-self: center;
|
|
||||||
justify-self: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid .row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(5, 1fr);
|
|
||||||
grid-gap: 0.2rem;
|
|
||||||
margin: 0 0 0.2rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
.grid.bad-guess .row.current {
|
|
||||||
animation: wiggle 0.5s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid.playing .row.current {
|
|
||||||
filter: drop-shadow(3px 3px 10px var(--color-bg-0));
|
|
||||||
}
|
|
||||||
|
|
||||||
.letter {
|
|
||||||
aspect-ratio: 1;
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
text-align: center;
|
|
||||||
box-sizing: border-box;
|
|
||||||
text-transform: lowercase;
|
|
||||||
border: none;
|
|
||||||
font-size: calc(0.08 * var(--width));
|
|
||||||
border-radius: 2px;
|
|
||||||
background: white;
|
|
||||||
margin: 0;
|
|
||||||
color: rgba(0, 0, 0, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.letter.missing {
|
|
||||||
background: rgba(255, 255, 255, 0.5);
|
|
||||||
color: rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.letter.exact {
|
|
||||||
background: var(--color-theme-2);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.letter.close {
|
|
||||||
border: 2px solid var(--color-theme-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected {
|
|
||||||
outline: 2px solid var(--color-theme-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
|
||||||
text-align: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: min(18vh, 10rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard {
|
|
||||||
--gap: 0.2rem;
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--gap);
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard .row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.2rem;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard button,
|
|
||||||
.keyboard button:disabled {
|
|
||||||
--size: min(8vw, 4vh, 40px);
|
|
||||||
background-color: white;
|
|
||||||
color: black;
|
|
||||||
width: var(--size);
|
|
||||||
border: none;
|
|
||||||
border-radius: 2px;
|
|
||||||
font-size: calc(var(--size) * 0.5);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard button.exact {
|
|
||||||
background: var(--color-theme-2);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard button.missing {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard button.close {
|
|
||||||
border: 2px solid var(--color-theme-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard button:focus {
|
|
||||||
background: var(--color-theme-1);
|
|
||||||
color: white;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard button[data-key='enter'],
|
|
||||||
.keyboard button[data-key='backspace'] {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
width: calc(1.5 * var(--size));
|
|
||||||
height: calc(1 / 3 * (100% - 2 * var(--gap)));
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-size: calc(0.3 * var(--size));
|
|
||||||
padding-top: calc(0.15 * var(--size));
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard button[data-key='enter'] {
|
|
||||||
right: calc(50% + 3.5 * var(--size) + 0.8rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard button[data-key='backspace'] {
|
|
||||||
left: calc(50% + 3.5 * var(--size) + 0.8rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard button[data-key='enter']:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.restart {
|
|
||||||
width: 100%;
|
|
||||||
padding: 1rem;
|
|
||||||
background: rgba(255, 255, 255, 0.5);
|
|
||||||
border-radius: 2px;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.restart:focus,
|
|
||||||
.restart:hover {
|
|
||||||
background: var(--color-theme-1);
|
|
||||||
color: white;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes wiggle {
|
|
||||||
0% {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
10% {
|
|
||||||
transform: translateX(-2px);
|
|
||||||
}
|
|
||||||
30% {
|
|
||||||
transform: translateX(4px);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translateX(-6px);
|
|
||||||
}
|
|
||||||
70% {
|
|
||||||
transform: translateX(+4px);
|
|
||||||
}
|
|
||||||
90% {
|
|
||||||
transform: translateX(-2px);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { words, allowed } from './words.server';
|
|
||||||
|
|
||||||
export class Game {
|
|
||||||
index: number;
|
|
||||||
guesses: string[];
|
|
||||||
answers: string[];
|
|
||||||
answer: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a game object from the player's cookie, or initialise a new game
|
|
||||||
*/
|
|
||||||
constructor(serialized: string | undefined = undefined) {
|
|
||||||
if (serialized) {
|
|
||||||
const [index, guesses, answers] = serialized.split('-');
|
|
||||||
|
|
||||||
this.index = +index;
|
|
||||||
this.guesses = guesses ? guesses.split(' ') : [];
|
|
||||||
this.answers = answers ? answers.split(' ') : [];
|
|
||||||
} else {
|
|
||||||
this.index = Math.floor(Math.random() * words.length);
|
|
||||||
this.guesses = ['', '', '', '', '', ''];
|
|
||||||
this.answers = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
this.answer = words[this.index];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update game state based on a guess of a five-letter word. Returns
|
|
||||||
* true if the guess was valid, false otherwise
|
|
||||||
*/
|
|
||||||
enter(letters: string[]) {
|
|
||||||
const word = letters.join('');
|
|
||||||
const valid = allowed.has(word);
|
|
||||||
|
|
||||||
if (!valid) return false;
|
|
||||||
|
|
||||||
this.guesses[this.answers.length] = word;
|
|
||||||
|
|
||||||
const available = Array.from(this.answer);
|
|
||||||
const answer = Array(5).fill('_');
|
|
||||||
|
|
||||||
// first, find exact matches
|
|
||||||
for (let i = 0; i < 5; i += 1) {
|
|
||||||
if (letters[i] === available[i]) {
|
|
||||||
answer[i] = 'x';
|
|
||||||
available[i] = ' ';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// then find close matches (this has to happen
|
|
||||||
// in a second step, otherwise an early close
|
|
||||||
// match can prevent a later exact match)
|
|
||||||
for (let i = 0; i < 5; i += 1) {
|
|
||||||
if (answer[i] === '_') {
|
|
||||||
const index = available.indexOf(letters[i]);
|
|
||||||
if (index !== -1) {
|
|
||||||
answer[i] = 'c';
|
|
||||||
available[index] = ' ';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.answers.push(answer.join(''));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serialize game state so it can be set as a cookie
|
|
||||||
*/
|
|
||||||
toString() {
|
|
||||||
return `${this.index}-${this.guesses.join(' ')}-${this.answers.join(' ')}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
<svelte:head>
|
|
||||||
<title>How to play Sverdle</title>
|
|
||||||
<meta name="description" content="How to play Sverdle" />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="text-column">
|
|
||||||
<h1>How to play Sverdle</h1>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
Sverdle is a clone of <a href="https://www.nytimes.com/games/wordle/index.html">Wordle</a>, the
|
|
||||||
word guessing game. To play, enter a five-letter English word. For example:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="example">
|
|
||||||
<span class="close">r</span>
|
|
||||||
<span class="missing">i</span>
|
|
||||||
<span class="close">t</span>
|
|
||||||
<span class="missing">z</span>
|
|
||||||
<span class="exact">y</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
The <span class="exact">y</span> is in the right place. <span class="close">r</span> and
|
|
||||||
<span class="close">t</span>
|
|
||||||
are the right letters, but in the wrong place. The other letters are wrong, and can be discarded.
|
|
||||||
Let's make another guess:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="example">
|
|
||||||
<span class="exact">p</span>
|
|
||||||
<span class="exact">a</span>
|
|
||||||
<span class="exact">r</span>
|
|
||||||
<span class="exact">t</span>
|
|
||||||
<span class="exact">y</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>This time we guessed right! You have <strong>six</strong> guesses to get the word.</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
Unlike the original Wordle, Sverdle runs on the server instead of in the browser, making it
|
|
||||||
impossible to cheat. It uses <code><form></code> and cookies to submit data, meaning you can
|
|
||||||
even play with JavaScript disabled!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
span {
|
|
||||||
display: inline-flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 0.8em;
|
|
||||||
width: 2.4em;
|
|
||||||
height: 2.4em;
|
|
||||||
background-color: white;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border-radius: 2px;
|
|
||||||
border-width: 2px;
|
|
||||||
color: rgba(0, 0, 0, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.missing {
|
|
||||||
background: rgba(255, 255, 255, 0.5);
|
|
||||||
color: rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.close {
|
|
||||||
border-style: solid;
|
|
||||||
border-color: var(--color-theme-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.exact {
|
|
||||||
background: var(--color-theme-2);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.example {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-start;
|
|
||||||
margin: 1rem 0;
|
|
||||||
gap: 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.example span {
|
|
||||||
font-size: 1.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
p span {
|
|
||||||
position: relative;
|
|
||||||
border-width: 1px;
|
|
||||||
border-radius: 1px;
|
|
||||||
font-size: 0.4em;
|
|
||||||
transform: scale(2) translate(0, -10%);
|
|
||||||
margin: 0 1em;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { dev } from '$app/environment';
|
|
||||||
|
|
||||||
// we don't need any JS on this page, though we'll load
|
|
||||||
// it in dev so that we get hot module replacement
|
|
||||||
export const csr = dev;
|
|
||||||
|
|
||||||
// since there's no dynamic data here, we can prerender
|
|
||||||
// it so that it gets served as a static asset in production
|
|
||||||
export const prerender = true;
|
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user