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
5 changes: 2 additions & 3 deletions games/farfield/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -912,9 +912,8 @@ export function applyCommand(
}
export function rates(s: State) {
return {
alloy:
0.12 + workPower(s, "miners") * 0.55 + workPower(s, "salvage") * 0.35,
energy: 0.25 + workPower(s, "engineers") * 0.7,
alloy: workPower(s, "miners") * 0.55 + workPower(s, "salvage") * 0.35,
energy: workPower(s, "engineers") * 0.7,
food:
workPower(s, "farmers") * 0.8 -
s.workers.filter((w) => w.hp > 0).length * WORKER_FOOD_UPKEEP,
Expand Down
35 changes: 23 additions & 12 deletions games/farfield/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
} from "./order-feedback.ts";
import { ABILITIES, type Ability } from "./combat.ts";
import { GameSelect } from "./GameSelect.tsx";
import { useKeyboardHints } from "./useKeyboardHints.ts";
import { matchResult } from "./results.ts";
import { useEffect, useRef, useState, type ReactNode } from "react";
import type { GameComponentProps } from "@rarefriends/friendsdk/runtime";
Expand Down Expand Up @@ -245,6 +246,9 @@ export default function Game(props: GameComponentProps) {
return <Mission key={props.friendId.toString()} {...props} />;
}
function Mission({ friendId, client, paused }: GameComponentProps) {
const keyboardHints = useKeyboardHints();
const shortcutTitle = (label: string, key: string) =>
keyboardHints ? `${label} (${key})` : label;
const [ready, setReady] = useState(false),
[loadError, setLoadError] = useState(""),
[retry, setRetry] = useState(0),
Expand Down Expand Up @@ -766,8 +770,10 @@ function Mission({ friendId, client, paused }: GameComponentProps) {
</p>
<ul>
<li>
<strong>Build:</strong> B, choose a block, then a building. Click once
to place. R rotates; Escape clears placement. Unfinished blueprints
<strong>Build:</strong> Open Build, choose a block, then a building.
Position the preview and place it. Use Rotate or Cancel while placing.
{keyboardHints && " B opens Build; R rotates; Escape clears placement."}{" "}
Unfinished blueprints
refund 100%; dismantling completed buildings returns 75% and removes
flooring. Units walk clear first; keep a connected path to your core.
</li>
Expand All @@ -778,10 +784,12 @@ function Mission({ friendId, client, paused }: GameComponentProps) {
</li>
<li>
<strong>Fight:</strong> Click a visible enemy or building to attack.
Assign guards with W, then choose Follow Friend or Hold here. Staffed
Assign guards in Workers{keyboardHints && " (W)"}, then choose Follow
Friend or Hold here. Staffed
Defense buildings protect nearby approaches. Red rings show enemy
turret range. Shield (Q): 20 energy, 65% damage reduction for 6s. EMP
(E): 15 energy, disables visible turrets within 6 tiles for 6s.
turret range. Shield{keyboardHints && " (Q)"}: 20 energy, 65% damage
reduction for 6s. EMP{keyboardHints && " (E)"}: 15 energy, disables
visible turrets within 6 tiles for 6s.
</li>
<li>
<strong>Recover:</strong> Retreat to your core, or an Infirmary with a
Expand All @@ -805,6 +813,7 @@ function Mission({ friendId, client, paused }: GameComponentProps) {
data-ended={state.phase === "won" || state.phase === "lost"}
data-dock={dock}
data-reduced={reduced}
data-keyboard-hints={keyboardHints}
aria-label="Farfield station command"
>
<div className="flight-hud">
Expand All @@ -818,7 +827,9 @@ function Mission({ friendId, client, paused }: GameComponentProps) {
<small>ALLOY</small>
</button>
<button
title="Energy powers Shield (Q) and EMP (E)"
title={keyboardHints
? "Energy powers Shield (Q) and EMP (E)"
: "Energy powers Shield and EMP"}
onClick={() => setPanel("help")}
>
<span className="energy">ϟ</span>
Expand Down Expand Up @@ -1140,7 +1151,7 @@ function Mission({ friendId, client, paused }: GameComponentProps) {
<button
onClick={() => setChoosingBuilding(false)}
aria-keyshortcuts="Backspace"
title="Choose a different block (Backspace)"
title={shortcutTitle("Choose a different block", "Backspace")}
>
← Block
</button>
Expand Down Expand Up @@ -1341,7 +1352,7 @@ function Mission({ friendId, client, paused }: GameComponentProps) {
<button
aria-label="Open build panel"
aria-keyshortcuts="B"
title="Build (B)"
title={shortcutTitle("Build", "B")}
aria-expanded={drawer === "build"}
aria-pressed={drawer === "build"}
onClick={() => {
Expand All @@ -1358,7 +1369,7 @@ function Mission({ friendId, client, paused }: GameComponentProps) {
<button
aria-label="Manage crew"
aria-keyshortcuts="W"
title="Workers (W)"
title={shortcutTitle("Workers", "W")}
aria-expanded={drawer === "crew"}
aria-pressed={drawer === "crew"}
onClick={() => {
Expand All @@ -1381,7 +1392,7 @@ function Mission({ friendId, client, paused }: GameComponentProps) {
<button
onClick={rotate}
aria-label="Rotate ↻"
title="Rotate block (R)"
title={shortcutTitle("Rotate block", "R")}
>
<ActionIcon kind="rotate" />
</button>
Expand All @@ -1390,7 +1401,7 @@ function Mission({ friendId, client, paused }: GameComponentProps) {
disabled={!ghost || !!canBuild || busy || halted}
onClick={() => build()}
aria-label="Build ↵"
title={canBuild || "Place building (Enter)"}
title={canBuild || shortcutTitle("Place building", "Enter")}
>
<ActionIcon kind="place" />
</button>
Expand Down Expand Up @@ -1525,7 +1536,7 @@ function Mission({ friendId, client, paused }: GameComponentProps) {
)}
<button
aria-label="Close building details"
title="Clear selection (Esc)"
title={shortcutTitle("Clear selection", "Esc")}
onClick={() => {
setSelected(null);
setGhost(null);
Expand Down
42 changes: 37 additions & 5 deletions games/farfield/playfield.css
Original file line number Diff line number Diff line change
Expand Up @@ -1411,14 +1411,41 @@
flex-shrink: 0;
}

@media (max-width: 1000px) {
@media (max-width: 699px) {
.immersive-game[data-dock="bottom"] .map-controls,
.immersive-game[data-dock="bottom"] .ability-bar {
bottom: max(84px, calc(env(safe-area-inset-bottom) + 78px));
}
}

@media (max-width: 700px) {
/* Landscape phones have room for all three clusters along the bottom edge. */
@media (min-width: 700px) {
.immersive-game[data-dock] .ability-bar,
.immersive-game[data-dock] .map-controls {
bottom: max(12px, env(safe-area-inset-bottom));
}
.immersive-game[data-dock] .ability-bar {
left: max(12px, env(safe-area-inset-left));
transform: none;
}
.immersive-game[data-dock] .map-controls {
right: max(12px, env(safe-area-inset-right));
}
.immersive-game[data-dock="bottom"] .action-dock {
max-width: calc(100% - 440px - env(safe-area-inset-left) - env(safe-area-inset-right));
}
}

@media (max-width: 699px) {
.immersive-game:not([data-dock="bottom"]) .ability-bar {
left: max(8px, env(safe-area-inset-left));
bottom: max(12px, env(safe-area-inset-bottom));
transform: none;
}
.immersive-game:not([data-dock="bottom"]) .map-controls {
right: max(8px, env(safe-area-inset-right));
bottom: max(12px, env(safe-area-inset-bottom));
}
}
/* Combat modes are immediate icon actions beside the abilities. */
.immersive-game .ability-bar {
Expand Down Expand Up @@ -1465,9 +1492,6 @@
.immersive-game .ability-bar {
left: 8px;
}
.immersive-game:not([data-dock="bottom"]) .map-controls {
bottom: 10px;
}
}
@media (pointer: coarse) {
.shape-picker kbd,
Expand Down Expand Up @@ -1553,3 +1577,11 @@
@media (max-width: 600px) {
.sector-status { top: calc(max(8px, env(safe-area-inset-top)) + 54px); }
}

/* Touch play stays uncluttered; actual keyboard use reveals shortcut hints. */
.immersive-game[data-keyboard-hints="false"] kbd {
display: none;
}
.immersive-game[data-keyboard-hints="true"] kbd {
display: inline;
}
38 changes: 38 additions & 0 deletions games/farfield/useKeyboardHints.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useEffect, useState } from "react";

// Browsers do not expose a portable hardware-keyboard connection event.
// On touch devices, show shortcuts only after keyboard use outside text fields.
export function useKeyboardHints() {
const [visible, setVisible] = useState(
() => navigator.maxTouchPoints === 0 && matchMedia("(pointer: fine)").matches,
);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (
!event.isTrusted || !event.code || event.isComposing ||
event.key === "Unidentified"
) return;
const target = event.target;
if (
target instanceof HTMLElement &&
(target.isContentEditable || target.closest("input, textarea, select"))
) return;
setVisible(true);
};
const onPointer = (event: PointerEvent) => {
if (event.pointerType === "touch" || event.pointerType === "pen") setVisible(false);
};
const onBlur = () => {
if (navigator.maxTouchPoints > 0) setVisible(false);
};
window.addEventListener("keydown", onKey, true);
window.addEventListener("pointerdown", onPointer, true);
window.addEventListener("blur", onBlur);
return () => {
window.removeEventListener("keydown", onKey, true);
window.removeEventListener("pointerdown", onPointer, true);
window.removeEventListener("blur", onBlur);
};
}, []);
return visible;
}
2 changes: 1 addition & 1 deletion tests/actors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ test("helpers spawn at the core and only produce after walking to their own work
advance(s, 3);
assert.equal(workPower(s, "miners"), 1);
assert.equal(worker.targetId, s.modules[1].id);
assert.ok(rates(s).alloy > 0.6);
assert.equal(rates(s).alloy, 0.55);
assert.equal(s.roles.miners, 1);
assert.equal(s.roles.builders, 0);
assert.equal(
Expand Down
2 changes: 1 addition & 1 deletion tests/economy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ test("shortage and recovery ramp gradually; farming and Friend work remain produ
s.foodShortage = 1;
Object.assign(s.friend, { task: "miners", working: true, hp: 120 });
near(workPower(s, "miners"), 2.5);
near(rates(s).alloy, 0.12 + 2.5 * 0.55);
near(rates(s).alloy, 2.5 * 0.55);
const farm = start();
production(farm, "garden", "farmers");
farm.food = 0;
Expand Down
Loading
Loading