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
16 changes: 9 additions & 7 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -38,38 +38,40 @@
<title>cssQuake - Powered by PolyCSS</title>
<meta
name="description"
content="Play Quake in your browser with no install. cssQuake renders the game as inspectable HTML and CSS powered by PolyCSS."
content="Play a browser Quake port rendered as inspectable HTML and CSS 3D geometry through PolyCSS, with no WebGL or canvas renderer."
/>
<meta name="robots" content="index, follow" />
<link rel="canonical" href="https://cssquake.com/" />
<meta property="og:site_name" content="cssQuake" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://cssquake.com/" />
<meta property="og:title" content="cssQuake - Play Quake in Your Browser" />
<meta property="og:title" content="cssQuake - Powered by PolyCSS" />
<meta
property="og:description"
content="Play Quake in your browser with no install. cssQuake renders the game as inspectable HTML and CSS powered by PolyCSS."
content="Play a browser Quake port rendered as inspectable HTML and CSS 3D geometry through PolyCSS, with no WebGL or canvas renderer."
/>
<meta property="og:image" content="https://cssquake.com/assets/cssquake-social.webp" />
<meta property="og:image:type" content="image/webp" />
<meta property="og:image:width" content="2075" />
<meta property="og:image:height" content="1000" />
<meta property="og:image:alt" content="cssQuake gameplay running in the browser with inspectable HTML and CSS geometry." />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="cssQuake - Play Quake in Your Browser" />
<meta name="twitter:title" content="cssQuake - Powered by PolyCSS" />
<meta
name="twitter:description"
content="Play Quake in your browser with no install. cssQuake renders the game as inspectable HTML and CSS powered by PolyCSS."
content="Play a browser Quake port rendered as inspectable HTML and CSS 3D geometry through PolyCSS, with no WebGL or canvas renderer."
/>
<meta name="twitter:image" content="https://cssquake.com/assets/cssquake-social.webp" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"@type": ["VideoGame", "WebApplication"],
"name": "cssQuake",
"url": "https://cssquake.com/",
"description": "Play Quake in your browser with no install. cssQuake renders the game as inspectable HTML and CSS powered by PolyCSS.",
"description": "Play a browser Quake port rendered as inspectable HTML and CSS 3D geometry through PolyCSS, with no WebGL or canvas renderer.",
"applicationCategory": "GameApplication",
"gamePlatform": "Web browser",
"playMode": ["SinglePlayer", "MultiPlayer"],
"operatingSystem": "Any",
"image": "https://cssquake.com/assets/cssquake-social.webp",
"codeRepository": "https://github.com/LayoutitStudio/cssQuake"
Expand Down
416 changes: 392 additions & 24 deletions src/App.ts

Large diffs are not rendered by default.

14 changes: 12 additions & 2 deletions src/runtime/app/assetWarmupFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export interface QuakeAssetWarmupFlowOptions {
isDisposed(): boolean;
onPickupModelLibrary(library: QuakePickupModelLibrary): void;
onProgramMetadata(metadata: QuakeProgramMetadata): void;
shouldSpawnPickup?(entity: QuakeEntity): boolean;
shouldSpawnShootable?(entity: QuakeEntity): boolean;
}

export interface QuakeAssetWarmupFlow {
Expand Down Expand Up @@ -95,13 +97,13 @@ export function createQuakeAssetWarmupFlow(options: QuakeAssetWarmupFlowOptions)
const entitiesByIndex = new Map(scene.entities.map((entity) => [entity.index, entity]));
const pickupEntities = sceneEntitiesForIndexes(entitiesByIndex, runtime.pickupEntityIndexes);
for (const entity of pickupEntities) {
if (!shouldSpawnQuakeEntityForCurrentGame(entity)) continue;
if (!shouldSpawnPickup(entity)) continue;
const modelPath = quakePickupModelPath(entity, currentProgramMetadata, scene.gameLogic);
if (modelPath) pickupModelPaths.add(modelPath);
}
const shootableEntities = sceneEntitiesForIndexes(entitiesByIndex, runtime.shootableEntityIndexes);
for (const entity of shootableEntities) {
if (!shouldSpawnQuakeEntityForCurrentGame(entity)) continue;
if (!shouldSpawnShootable(entity)) continue;
const modelPath = quakeShootableModelPath(entity, currentProgramMetadata);
if (modelPath) monsterModelPaths.add(modelPath);
}
Expand All @@ -121,6 +123,14 @@ export function createQuakeAssetWarmupFlow(options: QuakeAssetWarmupFlowOptions)
]);
}

function shouldSpawnPickup(entity: QuakeEntity): boolean {
return options.shouldSpawnPickup?.(entity) ?? shouldSpawnQuakeEntityForCurrentGame(entity);
}

function shouldSpawnShootable(entity: QuakeEntity): boolean {
return options.shouldSpawnShootable?.(entity) ?? shouldSpawnQuakeEntityForCurrentGame(entity);
}

return {
loadPickupModels,
loadProgramMetadata,
Expand Down
4 changes: 4 additions & 0 deletions src/runtime/app/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { createPolyFirstPersonControls, createPolyScene } from "@layoutit/p
import type { QuakeEntity, QuakeScene } from "../../types/quake";
import type { QuakeCollisionWorld } from "../collision";
import type { createQuakeSoundController } from "../audio";
import type { QuakeDamageableBrushFlow } from "./damageableBrushFlow";
import type { createQuakeMenuController } from "../menu";
import type { createQuakeMoversController } from "../movers";
import type { createQuakePickupController } from "../pickups";
Expand Down Expand Up @@ -34,6 +35,7 @@ export interface QuakeAppRuntimeContext {
readonly sceneElement: HTMLElement;
readonly controllers: {
readonly audio: QuakeAppAudioController;
readonly damageableBrushes: QuakeDamageableBrushFlow;
readonly menu: QuakeAppMenuController;
readonly movers: QuakeAppMoversController;
readonly pickups: () => QuakeAppPickupController;
Expand All @@ -56,6 +58,8 @@ export interface QuakeAppRuntimeContext {
};
readonly gameplay: {
readonly isPaused: () => boolean;
readonly resumeForDebugInput: () => void;
readonly runWithDebugInput: <T>(callback: () => T) => T;
readonly setPaused: (paused: boolean) => void;
readonly isPlayerDead: () => boolean;
readonly isStarted: () => boolean;
Expand Down
15 changes: 13 additions & 2 deletions src/runtime/app/debugApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface QuakeAppDebugApiOptions {
mapExists(mapName: string): boolean;
pointToPoly(point: { x: number; y: number; z: number }): Vec3;
renderOrigin(): Vec3;
requestMultiplayerPickup(entityIndex: number): boolean;
setCollisionBypassUntil(until: number): void;
setMultiplayerInputPaused(paused: boolean): boolean;
syncHud(): void;
Expand Down Expand Up @@ -56,6 +57,7 @@ function createQuakeAppDebugRuntime({
mapExists,
pointToPoly,
renderOrigin,
requestMultiplayerPickup,
setCollisionBypassUntil,
setMultiplayerInputPaused,
syncHud,
Expand Down Expand Up @@ -84,6 +86,7 @@ function createQuakeAppDebugRuntime({
setOrigin: (origin) => runtime.controllers.player().setDebugOrigin(origin),
},
currentMapName: runtime.session.currentMapName,
damageableBrushesStats: () => runtime.controllers.damageableBrushes.snapshot(),
damagePlayer: (amount, context) => runtime.controllers.player().damage(amount, context),
damageWeaponTarget: (entityIndex, amount) =>
runtime.controllers.shootables.debugDamageWeaponTarget(entityIndex, amount),
Expand Down Expand Up @@ -114,8 +117,14 @@ function createQuakeAppDebugRuntime({
runtime.controllers.shootables.debugSetEnemyProjectileCaptureEnabled(enabled),
enemyProjectileTraceStep: (dtMs) => runtime.controllers.shootables.debugStepEnemyProjectiles(dtMs),
entities: runtime.session.entities,
fireWeapon: () => runtime.controllers.weapons.fire(),
fireWeaponDebug: (options) => runtime.controllers.weapons.debugFireProjectile(options),
fireWeapon: () => {
runtime.gameplay.resumeForDebugInput();
return runtime.gameplay.runWithDebugInput(() => runtime.controllers.weapons.fire());
},
fireWeaponDebug: (options) => {
runtime.gameplay.resumeForDebugInput();
return runtime.gameplay.runWithDebugInput(() => runtime.controllers.weapons.debugFireProjectile(options));
},
fireballEmittersCount,
fireballsCount,
floorAt: (x, y, maxZ, minZ) =>
Expand Down Expand Up @@ -146,11 +155,13 @@ function createQuakeAppDebugRuntime({
playerMoveDebug: () => runtime.controllers.player().debugMovement(),
pointToPoly,
renderOrigin,
requestMultiplayerPickup,
projectileImpact: (weapon, entityIndex, origin, directDamage) =>
runtime.controllers.weapons.debugProjectileImpact(weapon, entityIndex, origin, directDamage),
projectileTraceCapture: () => runtime.controllers.weapons.debugProjectileCapture(),
projectileTraceClear: () => runtime.controllers.weapons.debugClearProjectileCapture(),
projectileTraceEnabled: (enabled) => runtime.controllers.weapons.debugSetProjectileCaptureEnabled(enabled),
runWithDebugInput: runtime.gameplay.runWithDebugInput,
setUnmountedAi: (enabled) => runtime.controllers.shootables.setUnmountedAiEnabled(enabled),
setCollisionBypassUntil,
setShootableOrigin: (entityIndex, origin) => runtime.controllers.shootables.debugSetOrigin(entityIndex, origin),
Expand Down
1 change: 1 addition & 0 deletions src/runtime/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@ export const QUAKE_DOOR_TRIGGER_Z = 8 * QUAKE_COLLISION_UNIT_SCALE;
export const QUAKE_SPAWNFLAG_NOT_EASY = 256;
export const QUAKE_SPAWNFLAG_NOT_MEDIUM = 512;
export const QUAKE_SPAWNFLAG_NOT_HARD = 1024;
export const QUAKE_SPAWNFLAG_NOT_DEATHMATCH = 2048;
export const QUAKE_SINGLE_PLAYER_SKILL = 0;
62 changes: 39 additions & 23 deletions src/runtime/debug/quakeDebug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { QuakePlayerDamageContext } from "../player";
import type { QuakeMoversDebugStats } from "../movers";
import type { QuakePickupDebugStats } from "../pickups";
import type { QuakeCanDamageResult } from "../shootables/damage";
import type { CssQuakeDamageableBrushProgressSnapshot } from "../saveLoad";
import type {
QuakeEnemyProjectileDebugCapture,
QuakeShootableEnemyAcquisitionDebugResult,
Expand Down Expand Up @@ -40,6 +41,7 @@ export interface QuakeDebugHooks {
): QuakeCanDamageResult | null;
contentsAt(x: number, y: number, z: number): number | null;
copyViewUrl(): Promise<string>;
damageableBrushesStats(): CssQuakeDamageableBrushProgressSnapshot;
damage(amount?: number, inflictorX?: number, inflictorY?: number, inflictorZ?: number): boolean;
damageWeaponTarget(entityIndex: number, amount?: number): boolean;
debugMountEntity(entityIndex: number): boolean;
Expand Down Expand Up @@ -79,6 +81,7 @@ export interface QuakeDebugHooks {
focusEntity(entityIndex: number, distance?: number, rotX?: number, rotY?: number): boolean;
loadMap(mapName: string): Promise<boolean>;
getWeaponTuning(): QuakeResolvedViewmodelTuning;
requestMultiplayerPickup(entityIndex: number): boolean;
resetWeaponTuning(): QuakeResolvedViewmodelTuning;
setExpandedLogicalCombat(enabled: boolean): boolean;
setMountedEnemyAcquisition(enabled: boolean): boolean;
Expand Down Expand Up @@ -154,6 +157,7 @@ export interface QuakeDebugRuntime {
currentMapName(): string;
contentsAt(point: { x: number; y: number; z: number }): number | null;
activateEntity(entityIndex: number, sourceEntityIndex?: number): boolean;
damageableBrushesStats(): CssQuakeDamageableBrushProgressSnapshot;
damagePlayer(amount: number, context?: QuakePlayerDamageContext): boolean;
damageWeaponTarget(entityIndex: number, amount: number): boolean;
debugMountEntity(entityIndex: number): boolean;
Expand All @@ -171,7 +175,7 @@ export interface QuakeDebugRuntime {
enemyProjectileTraceEnabled(enabled: boolean): void;
enemyProjectileTraceStep(dtMs?: number): QuakeEnemyProjectileDebugCapture;
entities(): ReadonlyMap<number, QuakeEntity>;
fireWeapon(): void;
fireWeapon(): boolean;
fireWeaponDebug(options?: QuakeWeaponProjectileDebugFireOptions): boolean;
fireballEmittersCount(): number;
fireballsCount(): number;
Expand Down Expand Up @@ -200,6 +204,7 @@ export interface QuakeDebugRuntime {
playerMoveDebug(): Record<string, unknown>;
pointToPoly(point: { x: number; y: number; z: number }): Vec3;
renderOrigin(): Vec3;
requestMultiplayerPickup(entityIndex: number): boolean;
projectileImpact(
weapon: QuakeWeaponId,
entityIndex: number,
Expand All @@ -209,6 +214,7 @@ export interface QuakeDebugRuntime {
projectileTraceCapture(): QuakeWeaponProjectileDebugCapture;
projectileTraceClear(): void;
projectileTraceEnabled(enabled: boolean): void;
runWithDebugInput<T>(callback: () => T): T;
setCollisionBypassUntil(until: number): void;
setShootableOrigin(entityIndex: number, origin: Vec3): boolean;
setShootableYaw(entityIndex: number, yaw: number): boolean;
Expand Down Expand Up @@ -242,6 +248,7 @@ export function installQuakeDebugHooks(enabled: boolean, runtime: QuakeDebugRunt
canDamageQuakeDebugTrace(runtime, inflictorX, inflictorY, inflictorZ, targetX, targetY, targetZ),
contentsAt: (x, y, z) => contentsAtQuakeDebugPoint(runtime, x, y, z),
copyViewUrl: () => runtime.copyViewUrl(),
damageableBrushesStats: () => runtime.damageableBrushesStats(),
damage: (amount, inflictorX, inflictorY, inflictorZ) =>
damageQuakeDebugPlayer(runtime, amount, inflictorX, inflictorY, inflictorZ),
damageWeaponTarget: (entityIndex, amount) =>
Expand Down Expand Up @@ -270,6 +277,7 @@ export function installQuakeDebugHooks(enabled: boolean, runtime: QuakeDebugRunt
loadMap: (mapName) => loadQuakeDebugMap(runtime, mapName),
projectileImpact: (weapon, entityIndex, x, y, z, directDamage) =>
projectileImpactQuakeDebugWeapon(runtime, weapon, entityIndex, x, y, z, directDamage),
requestMultiplayerPickup: (entityIndex) => requestQuakeDebugMultiplayerPickup(runtime, entityIndex),
resetWeaponTuning: () => runtime.resetWeaponTuning(),
setExpandedLogicalCombat: (enabled) => setQuakeDebugExpandedLogicalCombat(runtime, enabled),
setMountedEnemyAcquisition: (enabled) => setQuakeDebugMountedEnemyAcquisition(runtime, enabled),
Expand Down Expand Up @@ -314,6 +322,13 @@ function touchQuakeDebugEntity(runtime: QuakeDebugRuntime, entityIndex: number):
return runtime.touchEntity(entityIndex);
}

function requestQuakeDebugMultiplayerPickup(runtime: QuakeDebugRuntime, entityIndex: number): boolean {
if (runtime.isLoading() || !runtime.hasCurrentScene()) return false;
if (!Number.isInteger(entityIndex)) return false;
runtime.hideMainMenu();
return runtime.requestMultiplayerPickup(entityIndex);
}

function syncQuakeDebugMultiplayerPose(runtime: QuakeDebugRuntime): boolean {
if (runtime.isLoading() || !runtime.hasCurrentScene()) return false;
runtime.hideMainMenu();
Expand Down Expand Up @@ -525,8 +540,7 @@ async function loadQuakeDebugMap(runtime: QuakeDebugRuntime, mapName: string): P
function fireQuakeDebugWeapon(runtime: QuakeDebugRuntime): boolean {
if (runtime.isLoading() || !runtime.hasCurrentScene()) return false;
runtime.hideMainMenu();
runtime.fireWeapon();
return true;
return runtime.runWithDebugInput(() => runtime.fireWeapon());
}

function startQuakeDebugGameplayRecording(runtime: QuakeDebugRuntime): boolean {
Expand All @@ -548,30 +562,32 @@ async function fireProjectileTraceQuakeDebugWeapon(
if (directDamage !== undefined && !Number.isFinite(directDamage)) return null;
if (!Number.isFinite(timeoutMs)) return null;
runtime.hideMainMenu();
runtime.projectileTraceClear();
runtime.projectileTraceEnabled(true);
const fired = runtime.fireWeaponDebug({ directDamage });
if (!fired) {
const capture = runtime.projectileTraceCapture();
runtime.projectileTraceEnabled(false);
return { capture, fired };
}

const deadline = performance.now() + Math.max(1, Math.min(10_000, timeoutMs));
while (performance.now() < deadline) {
const capture = runtime.projectileTraceCapture();
const removed = capture.events.some((event) => event.type === "remove");
const impacted = capture.events.some((event) => event.type === "impact" && event.impactResult === "remove");
if ((removed && impacted) || (impacted && capture.activeCount === 0)) {
return await runtime.runWithDebugInput(async () => {
runtime.projectileTraceClear();
runtime.projectileTraceEnabled(true);
const fired = runtime.fireWeaponDebug({ directDamage });
if (!fired) {
const capture = runtime.projectileTraceCapture();
runtime.projectileTraceEnabled(false);
return { capture, fired };
}
await nextAnimationFrame();
}

const capture = runtime.projectileTraceCapture();
runtime.projectileTraceEnabled(false);
return { capture, fired };
const deadline = performance.now() + Math.max(1, Math.min(10_000, timeoutMs));
while (performance.now() < deadline) {
const capture = runtime.projectileTraceCapture();
const removed = capture.events.some((event) => event.type === "remove");
const impacted = capture.events.some((event) => event.type === "impact" && event.impactResult === "remove");
if ((removed && impacted) || (impacted && capture.activeCount === 0)) {
runtime.projectileTraceEnabled(false);
return { capture, fired };
}
await nextAnimationFrame();
}

const capture = runtime.projectileTraceCapture();
runtime.projectileTraceEnabled(false);
return { capture, fired };
});
}

function nextAnimationFrame(): Promise<void> {
Expand Down
26 changes: 22 additions & 4 deletions src/runtime/entities.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { QuakeEntity } from "../types/quake";
import {
QUAKE_SINGLE_PLAYER_SKILL,
QUAKE_SPAWNFLAG_NOT_DEATHMATCH,
QUAKE_SPAWNFLAG_NOT_EASY,
QUAKE_SPAWNFLAG_NOT_HARD,
QUAKE_SPAWNFLAG_NOT_MEDIUM,
Expand All @@ -15,11 +16,28 @@ export function quakeEntitySpawnflags(entity: QuakeEntity): number {
return Math.trunc(quakeEntityNumber(entity, "spawnflags", 0));
}

export interface QuakeEntitySpawnMode {
deathmatch?: boolean;
skill?: number;
}

export function shouldSpawnQuakeEntityForCurrentGame(entity: QuakeEntity): boolean {
return shouldSpawnQuakeEntityForGameMode(entity, { skill: QUAKE_SINGLE_PLAYER_SKILL });
}

export function shouldSpawnQuakeEntityForGameMode(
entity: QuakeEntity,
mode: QuakeEntitySpawnMode = {},
): boolean {
const spawnflags = quakeEntitySpawnflags(entity);
if (QUAKE_SINGLE_PLAYER_SKILL <= 0 && (spawnflags & QUAKE_SPAWNFLAG_NOT_EASY)) return false;
if (QUAKE_SINGLE_PLAYER_SKILL === 1 && (spawnflags & QUAKE_SPAWNFLAG_NOT_MEDIUM)) return false;
if (QUAKE_SINGLE_PLAYER_SKILL >= 2 && (spawnflags & QUAKE_SPAWNFLAG_NOT_HARD)) return false;
if (mode.deathmatch === true) {
return (spawnflags & QUAKE_SPAWNFLAG_NOT_DEATHMATCH) === 0;
}
const skill = typeof mode.skill === "number" && Number.isFinite(mode.skill)
? mode.skill
: QUAKE_SINGLE_PLAYER_SKILL;
if (skill <= 0 && (spawnflags & QUAKE_SPAWNFLAG_NOT_EASY)) return false;
if (skill === 1 && (spawnflags & QUAKE_SPAWNFLAG_NOT_MEDIUM)) return false;
if (skill >= 2 && (spawnflags & QUAKE_SPAWNFLAG_NOT_HARD)) return false;
return true;
}

9 changes: 8 additions & 1 deletion src/runtime/multiplayer/authority.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ export type QuakeMultiplayerClientAuthorityResult =
export const QUAKE_MULTIPLAYER_DEFAULT_CLIENT_MESSAGE_INTERVAL_MS = {
"client.hello": 250,
"client.presence": 0,
"client.input": 10,
"client.input": 0,
"client.inputBatch": 0,
"client.fire": 25,
"client.damage": 100,
"client.pickup": 150,
Expand Down Expand Up @@ -127,6 +128,7 @@ export function quakeMultiplayerClientIdForEnvelope(
case "client.hello":
case "client.presence":
case "client.input":
case "client.inputBatch":
case "client.fire":
case "client.damage":
case "client.pickup":
Expand All @@ -146,6 +148,11 @@ function quakeMultiplayerClientIntentSequence(
switch (message.type) {
case "client.input":
return { key: "input", sequence: message.payload.input.inputSequence };
case "client.inputBatch":
return {
key: "input",
sequence: message.payload.inputs[message.payload.inputs.length - 1]?.inputSequence ?? 0,
};
case "client.pose":
return { key: "pose", sequence: message.payload.pose.poseSequence };
case "client.fire":
Expand Down
Loading
Loading