1.5.1: refined office, chat in the app language, current OpenAI models, own connector keys
Build Release Installers / Release for the tag (push) Successful in 16s
Build Release Installers / Windows installer (NSIS x64) (push) Successful in 1m48s
Build Release Installers / Linux packages (AppImage + deb x64) (push) Successful in 2m21s
CI / Typecheck, lint, tests, e2e (push) Successful in 3m52s
Build Release Installers / macOS installers (dmg + zip, x64 + arm64) (push) Successful in 7m15s
Build Release Installers / Release for the tag (push) Successful in 16s
Build Release Installers / Windows installer (NSIS x64) (push) Successful in 1m48s
Build Release Installers / Linux packages (AppImage + deb x64) (push) Successful in 2m21s
CI / Typecheck, lint, tests, e2e (push) Successful in 3m52s
Build Release Installers / macOS installers (dmg + zip, x64 + arm64) (push) Successful in 7m15s
- Home: the goal title box is two line boxes tall (2.8em), so a third line no longer peeks out. - Chat messages, reply fallbacks and assignment DMs follow the app display language; tasks and documents keep the working language. - OpenAI and GPT-account model lists match OpenAI's current line-up; default gpt-6.1-sol. - Onboarding connectors step takes the operator's Composio and logo.dev keys, then lists the toolkits the Composio project enables; without a key Connectors leaves the sidebar. - Google Antigravity removed end to end (migration 111), with the remote-revocation path only it used. - Office: 2x pixel people with outlines, rim light, walk, typing and blink; dithered real-clock lighting; detailed furniture and floors. - Per-agent model picker lists the provider's whole model list, live when connected. - Chat wake context breaks same-millisecond ties by insertion order; the mid-run inbox dedupes by message id. - The stored "clock" theme reads back instead of falling to "system". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
48dfddffef
commit
245ff66336
79 files changed
+1301
-1035
No files matched your search
@@ -12,7 +12,7 @@ import { StartupScreen } from "./StartupScreen";
|
||||
import type { Focus } from "../pages/types";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Shell } from "../shared/Shell";
|
||||
import { ROUTE_IDS, type RouteId } from "../shared/routes";
|
||||
import { connectorsEnabled, ROUTE_IDS, type RouteId } from "../shared/routes";
|
||||
import { AvatarDirectoryContext, type AvatarLook } from "../shared/ui";
|
||||
import { Onboarding } from "./Onboarding";
|
||||
import { useSnapshot } from "./useSnapshot";
|
||||
@@ -77,7 +77,8 @@ export function App() {
|
||||
return <StartupScreen failure={syncFailure} onRetry={() => void actions.reload().catch(() => undefined)} />;
|
||||
}
|
||||
seedViewState(snapshot.viewState);
|
||||
const route = routeChoice ?? readViewState("route", isRoute) ?? "home";
|
||||
const chosenRoute = routeChoice ?? readViewState("route", isRoute) ?? "home";
|
||||
const route = chosenRoute === "connectors" && !connectorsEnabled(snapshot) ? "home" : chosenRoute;
|
||||
// A company exists from Launch on, but its setup is unfinished until the connectors step is done; that is stored, so a restart resumes it.
|
||||
const unfinished = snapshot.organizations.find((organization) => organization.setupCompletedAt === null) ?? null;
|
||||
if (snapshot.organizations.length === 0 || creatingOrg || unfinished) {
|
||||
|
||||
@@ -198,9 +198,24 @@ export function Onboarding({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{step === "connectors" ? (
|
||||
<div className="onboarding-panel">
|
||||
<div key={step} className="step-view">
|
||||
{bootstrappedOrgId && (
|
||||
<ConnectorsStep
|
||||
orgId={bootstrappedOrgId}
|
||||
snapshot={snapshot}
|
||||
actions={actions}
|
||||
onFinish={() => finishSetup(bootstrappedOrgId)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{error && <div style={{ color: "var(--red)", marginTop: 16, fontSize: "var(--fs-body)" }}>{error}</div>}
|
||||
</div>
|
||||
) : (
|
||||
<form className="onboarding-panel" onSubmit={submit} noValidate>
|
||||
<div key={step} className="step-view">
|
||||
{restored && step !== "connectors" && (
|
||||
{restored && (
|
||||
<p role="status" style={{ fontSize: "var(--fs-body)", color: "var(--text-2)", margin: "0 0 12px" }}>
|
||||
{t("Your progress was restored. Keys are never saved before launch, so enter yours again.")}
|
||||
</p>
|
||||
@@ -240,17 +255,10 @@ export function Onboarding({
|
||||
onBack={back("mode")}
|
||||
/>
|
||||
)}
|
||||
{step === "connectors" && bootstrappedOrgId && (
|
||||
<ConnectorsStep
|
||||
orgId={bootstrappedOrgId}
|
||||
snapshot={snapshot}
|
||||
actions={actions}
|
||||
onFinish={() => finishSetup(bootstrappedOrgId)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{error && <div style={{ color: "var(--red)", marginTop: 16, fontSize: "var(--fs-body)" }}>{error}</div>}
|
||||
</form>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,14 +6,16 @@ import type { AppSnapshot } from "../../../shared/domain";
|
||||
import { Button } from "../shared/ui";
|
||||
import { ToolkitCatalog } from "../pages/connectors/ToolkitCatalog";
|
||||
import { useConnectorCatalog } from "../pages/connectors/useConnectorCatalog";
|
||||
import { IntegrationKeyRow } from "../pages/settings/IntegrationKeysPanel";
|
||||
import { connectorsEnabled } from "../shared/routes";
|
||||
import { DEFAULT_CONNECTOR_ACCOUNT_LABEL } from "../../../shared/commands";
|
||||
import type { SnapshotActions } from "./useSnapshot";
|
||||
import { t } from "../i18n/i18n";
|
||||
|
||||
/**
|
||||
* The post-launch onboarding step: the company exists, so connections can be
|
||||
* made against it. Connecting is optional — Finish enters the app either way
|
||||
* and the Connectors page carries the same catalog.
|
||||
* The post-launch onboarding step: the operator saves their own Composio and
|
||||
* logo.dev keys, then connects the toolkits their Composio project enables.
|
||||
* Skipping leaves Connectors out of the app until a Composio key is saved.
|
||||
*/
|
||||
export function ConnectorsStep({
|
||||
orgId,
|
||||
@@ -26,6 +28,21 @@ export function ConnectorsStep({
|
||||
actions: SnapshotActions;
|
||||
onFinish: () => void;
|
||||
}) {
|
||||
const enabled = connectorsEnabled(snapshot);
|
||||
const composioSavedAt = snapshot.integrationKeys.find((key) => key.name === "composio")?.updatedAt;
|
||||
return (
|
||||
<div className="form-grid">
|
||||
<h2 className="page-h1">{t("Connectors")}</h2>
|
||||
{snapshot.integrationKeys.map((status) => <IntegrationKeyRow key={status.name} status={status} actions={actions} />)}
|
||||
{enabled && <ConnectorChoices key={composioSavedAt} orgId={orgId} snapshot={snapshot} actions={actions} />}
|
||||
<div className="modal-actions" style={{ marginTop: 4 }}>
|
||||
<Button tone="primary" onClick={onFinish}>{enabled ? t("Finish") : t("Skip")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectorChoices({ orgId, snapshot, actions }: { orgId: string; snapshot: AppSnapshot; actions: SnapshotActions }) {
|
||||
const { catalog, busySlug, connectError, signIn, accountSync, syncAccounts, stopWaiting, checkAgain, connect, retry } = useConnectorCatalog(orgId, actions);
|
||||
const connectedSlugs = useMemo(
|
||||
() =>
|
||||
@@ -38,8 +55,7 @@ export function ConnectorsStep({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="form-grid">
|
||||
<h2 className="page-h1">{t("Connectors")}</h2>
|
||||
<>
|
||||
{catalog.phase === "error" && <ErrorText message={catalog.message} action={{ label: t("Retry"), onClick: retry }} />}
|
||||
{catalog.phase === "ready" && (
|
||||
<div style={{ maxHeight: 420, overflowY: "auto", paddingRight: 4 }}>
|
||||
@@ -54,9 +70,6 @@ export function ConnectorsStep({
|
||||
{signIn && <SignInStatus signIn={signIn} onStop={stopWaiting} onCheckAgain={checkAgain} />}
|
||||
<AccountSyncNote sync={accountSync} onRetry={syncAccounts} />
|
||||
{connectError && <ErrorText message={connectError} />}
|
||||
<div className="modal-actions" style={{ marginTop: 4 }}>
|
||||
<Button tone="primary" onClick={onFinish}>{t("Finish")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -13,8 +13,7 @@ curl -o <provider-key>.png "https://img.logo.dev/<domain>?token=<publishable-tok
|
||||
Domain per file: openai.com, anthropic.com, claude.ai (claude-pro), grok.com
|
||||
(xai), groq.com, mistral.ai, cerebras.ai, openrouter.ai, moonshot.ai (kimi;
|
||||
also used for kimi-code — kimi.com is not in logo.dev's index), moonshot.cn
|
||||
(kimi-cn), github.com (github-copilot), antigravity.google
|
||||
(google-antigravity), chatgpt.com (gpt-account), azure.microsoft.com
|
||||
(kimi-cn), github.com (github-copilot), chatgpt.com (gpt-account), azure.microsoft.com
|
||||
(azure-openai), aws.amazon.com (aws-bedrock), cloud.google.com (vertex-ai).
|
||||
The custom-openai / custom-anthropic endpoints carry no official brand and
|
||||
render as letter tiles.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 6.2 KiB |
@@ -134,7 +134,6 @@ export const zh: Record<string, string> = {
|
||||
"Resume": "恢复",
|
||||
"Missing this run": "本次运行缺少的能力",
|
||||
"Disconnect": "断开连接",
|
||||
"Revokes the sign-in at {provider} and erases it from this computer.": "在 {provider} 撤销登录授权,并从本机删除。",
|
||||
"Erases the key from this computer. {provider} keeps accepting it until you revoke it in your account.": "仅从本机删除密钥。在你于 {provider} 账号中撤销之前,该密钥仍然有效。",
|
||||
"API key (if the server asks for one)": "API 密钥(服务需要时填写)",
|
||||
"Checking...": "检查中...",
|
||||
@@ -212,8 +211,6 @@ export const zh: Record<string, string> = {
|
||||
"Powers the Connectors page and agent connector tools. Create one at platform.composio.dev.": "用于连接器页面和 Agent 的连接器工具。在 platform.composio.dev 创建。",
|
||||
"logo.dev publishable key": "logo.dev 公开密钥",
|
||||
"Shows brand logos for connectors. Create one at logo.dev.": "为连接器显示品牌图标。在 logo.dev 创建。",
|
||||
"Google Antigravity OAuth client secret": "Google Antigravity OAuth 客户端密钥",
|
||||
"Needed to sign in to and refresh the Google Antigravity provider.": "登录和续期 Google Antigravity 时需要。",
|
||||
"Saved": "已保存",
|
||||
"Not set": "未设置",
|
||||
"Replace key": "替换密钥",
|
||||
@@ -365,6 +362,7 @@ export const zh: Record<string, string> = {
|
||||
"Filter risk": "筛选风险",
|
||||
"Filter tasks": "筛选任务",
|
||||
"Finish": "完成",
|
||||
"Skip": "跳过",
|
||||
"Foreground": "前台",
|
||||
"General": "通用",
|
||||
"Get models": "获取模型",
|
||||
|
||||
@@ -115,7 +115,7 @@ function GoalCard({
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="card-label" style={{ marginBottom: 12 }}>{t("Quarter goal")}</div>
|
||||
<div style={{ fontSize: "var(--fs-body)", lineHeight: 1.4, display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden", minHeight: 44 }}>
|
||||
<div style={{ fontSize: "var(--fs-body)", lineHeight: 1.4, display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden", minHeight: "2.8em" }}>
|
||||
{goal ? goal.title : t("Set the first goal for {organizationName}.", { organizationName })}
|
||||
</div>
|
||||
<div className="row" style={{ alignItems: "baseline", gap: 8, marginTop: 16 }}>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { OfficeSimulation, cameraFor, type MoveInput } from "./office/simulation
|
||||
import { AgentDetailCard } from "./office/AgentDetailCard";
|
||||
import { officeStateLabel } from "../i18n/labels";
|
||||
import { cardPositionFor, CARD_FALLBACK_H } from "./office/agent-card-position";
|
||||
import { isoFrame, isoPixelSize, pickAt, toIso, type IsoFrame } from "./office/iso";
|
||||
import { artScale, isoFrame, isoPixelSize, pickAt, toIso, type IsoFrame } from "./office/iso";
|
||||
import { dayPart, season } from "../shared/palette-clock";
|
||||
import { pixelScale } from "../shared/pixel-scale";
|
||||
import type { Camera, OfficeAgentInput } from "./office/office-types";
|
||||
@@ -119,6 +119,7 @@ export function OfficePage({ snapshot }: PageProps) {
|
||||
let raf = 0;
|
||||
let lastFrame = 0;
|
||||
const scene = document.createElement("canvas");
|
||||
const lightMap = document.createElement("canvas");
|
||||
|
||||
const tick = (now: number) => {
|
||||
if (!running) return;
|
||||
@@ -180,11 +181,12 @@ export function OfficePage({ snapshot }: PageProps) {
|
||||
});
|
||||
}
|
||||
const clock = new Date();
|
||||
renderOffice(ctx, scene, {
|
||||
renderOffice(ctx, scene, lightMap, {
|
||||
frame,
|
||||
plan: sim.plan,
|
||||
camera,
|
||||
pixel,
|
||||
art: artScale(dpr),
|
||||
fontScale: pixelScale(dpr),
|
||||
viewW,
|
||||
viewH,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AvatarField } from "../shared/AvatarField";
|
||||
import { TopbarActions } from "../shared/TopbarActions";
|
||||
import { Dialog } from "../shared/Dialog";
|
||||
import { OrgChartTree } from "./team/OrgChartTree";
|
||||
import { AgentModelField } from "./team/AgentModelField";
|
||||
import type { AgentRecord, EmployeeRecord, ProviderCatalogEntry, ProviderRecord } from "../../../shared/domain";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId, money } from "./types";
|
||||
@@ -151,10 +152,12 @@ export function TeamPage({ snapshot, actions, focus }: PageProps) {
|
||||
{providers.map((provider) => <option key={provider.id} value={provider.id}>{provider.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("Model")}>
|
||||
<input className="input" name="modelId" list="hire-model-suggestions" value={hireModelId} disabled={!hireProviderId} placeholder={providers.find((provider) => provider.id === hireProviderId)?.model ?? ""} onChange={(event) => setHireModelId(event.currentTarget.value)} />
|
||||
</Field>
|
||||
<ModelSuggestions id="hire-model-suggestions" providerId={hireProviderId} providers={providers} catalog={snapshot.providerCatalog} />
|
||||
<AgentModelField
|
||||
provider={providers.find((provider) => provider.id === hireProviderId) ?? null}
|
||||
entry={catalogEntry(snapshot.providerCatalog, providers, hireProviderId)}
|
||||
value={hireModelId}
|
||||
onChange={setHireModelId}
|
||||
/>
|
||||
<p style={{ fontSize: "var(--fs-body)", color: "var(--text-3)", margin: 0 }}>{t("Leave the model blank to use the provider's default. A changed model applies from the agent's next session.")}</p>
|
||||
<Field label={t("Manager")}>
|
||||
<select className="input" name="managerAgentId" defaultValue="">
|
||||
@@ -260,7 +263,6 @@ function buildRoster(
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** The placeholder names the default of the provider picked now, which is the model a blank field will follow. */
|
||||
function EditModelFields({ agent, providers, catalog }: { agent: AgentRecord; providers: ProviderRecord[]; catalog: ProviderCatalogEntry[] }) {
|
||||
const [providerId, setProviderId] = useState(agent.modelProviderId ?? "");
|
||||
const [modelId, setModelId] = useState(agent.modelId ?? "");
|
||||
@@ -272,23 +274,20 @@ function EditModelFields({ agent, providers, catalog }: { agent: AgentRecord; pr
|
||||
{providers.map((provider) => <option key={provider.id} value={provider.id}>{provider.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("Model")}>
|
||||
<input className="input" name="modelId" value={modelId} onChange={(event) => setModelId(event.currentTarget.value)} disabled={!providerId} placeholder={providers.find((provider) => provider.id === providerId)?.model ?? ""} list="edit-model-suggestions" />
|
||||
</Field>
|
||||
<ModelSuggestions id="edit-model-suggestions" providerId={providerId} providers={providers} catalog={catalog} />
|
||||
<AgentModelField
|
||||
provider={providers.find((provider) => provider.id === providerId) ?? null}
|
||||
entry={catalogEntry(catalog, providers, providerId)}
|
||||
value={modelId}
|
||||
onChange={setModelId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Suggests only the picked provider's models; a model from another provider would mean nothing here. */
|
||||
function ModelSuggestions({ id, providerId, providers, catalog }: { id: string; providerId: string; providers: ProviderRecord[]; catalog: ProviderCatalogEntry[] }) {
|
||||
/** The catalog entry of the provider picked now. */
|
||||
function catalogEntry(catalog: ProviderCatalogEntry[], providers: ProviderRecord[], providerId: string): ProviderCatalogEntry | null {
|
||||
const providerKey = providers.find((provider) => provider.id === providerId)?.providerKey;
|
||||
const models = catalog.find((entry) => entry.key === providerKey)?.models ?? [];
|
||||
return (
|
||||
<datalist id={id}>
|
||||
{models.map((model) => <option key={model.id} value={model.id}>{model.name}</option>)}
|
||||
</datalist>
|
||||
);
|
||||
return catalog.find((entry) => entry.key === providerKey) ?? null;
|
||||
}
|
||||
|
||||
function RosterRow({ row, last, onEdit }: { row: RosterEntry; last: boolean; onEdit: (() => void) | null }) {
|
||||
|
||||
@@ -57,13 +57,21 @@ export function drawBox(ctx: CanvasRenderingContext2D, frame: IsoFrame, foot: Re
|
||||
polygon(ctx, colors.south, [toIso(frame, { x: x0, y: y1 }, z), toIso(frame, { x: x1, y: y1 }, z), toIso(frame, { x: x1, y: y1 }, top), toIso(frame, { x: x0, y: y1 }, top)]);
|
||||
polygon(ctx, colors.east, [toIso(frame, { x: x1, y: y1 }, z), toIso(frame, { x: x1, y: y0 }, z), toIso(frame, { x: x1, y: y0 }, top), toIso(frame, { x: x1, y: y1 }, top)]);
|
||||
polygon(ctx, colors.top, [toIso(frame, { x: x0, y: y0 }, top), toIso(frame, { x: x1, y: y0 }, top), toIso(frame, { x: x1, y: y1 }, top), toIso(frame, { x: x0, y: y1 }, top)]);
|
||||
if (height <= 0) return;
|
||||
const rim = toIso(frame, { x: x1, y: y1 }, top);
|
||||
polygon(ctx, "rgba(255,255,255,0.2)", [toIso(frame, { x: x0, y: y1 }, top), rim, { x: rim.x, y: rim.y + 0.5 }, toIso(frame, { x: x0, y: y1 }, top - 0.5)]);
|
||||
polygon(ctx, "rgba(255,255,255,0.14)", [rim, toIso(frame, { x: x1, y: y0 }, top), toIso(frame, { x: x1, y: y0 }, top - 0.5), { x: rim.x, y: rim.y + 0.5 }]);
|
||||
const base = toIso(frame, { x: x1, y: y1 }, z);
|
||||
polygon(ctx, "rgba(255,255,255,0.1)", [{ x: rim.x - 0.5, y: rim.y }, rim, base, { x: base.x - 0.5, y: base.y }]);
|
||||
}
|
||||
|
||||
function polygon(ctx: CanvasRenderingContext2D, color: string, points: Vec[]): void {
|
||||
const art = Math.max(1, Math.round(ctx.getTransform().a));
|
||||
const snap = (value: number) => Math.round(value * art) / art;
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(Math.round(points[0]!.x), Math.round(points[0]!.y));
|
||||
for (const point of points.slice(1)) ctx.lineTo(Math.round(point.x), Math.round(point.y));
|
||||
ctx.moveTo(snap(points[0]!.x), snap(points[0]!.y));
|
||||
for (const point of points.slice(1)) ctx.lineTo(snap(point.x), snap(point.y));
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
@@ -86,3 +94,8 @@ export function pickAt<T extends Vec>(frame: IsoFrame, people: T[], point: Vec):
|
||||
export function isoPixelSize(devicePixelRatio: number): number {
|
||||
return Math.max(1, Math.round(2 * devicePixelRatio)) / devicePixelRatio;
|
||||
}
|
||||
|
||||
/** Scene pixels per iso pixel: two when an iso pixel spans an even number of device pixels, so detail stays on whole device pixels. */
|
||||
export function artScale(devicePixelRatio: number): number {
|
||||
return Math.max(1, Math.round(2 * devicePixelRatio)) % 2 === 0 ? 2 : 1;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { OfficeState } from "./agent-state";
|
||||
import type { Camera, FloorPlan, Rect, Vec } from "./office-types";
|
||||
import type { DayPart } from "../../shared/palette-clock";
|
||||
import { BACK_WALL_H, toIso, type IsoFrame } from "./iso";
|
||||
import { WALL } from "./floor-plan";
|
||||
|
||||
export interface Light {
|
||||
at: Vec;
|
||||
z: number;
|
||||
/** Reach in iso pixels. */
|
||||
radius: number;
|
||||
rgb: string;
|
||||
strength: number;
|
||||
}
|
||||
|
||||
const AMBIENT: Record<DayPart, string> = { day: "rgb(236,232,224)", dawn: "rgb(214,190,196)", dusk: "rgb(222,184,150)", night: "rgb(58,68,112)" };
|
||||
const LAMPS_ON: Record<DayPart, number> = { day: 0.18, dawn: 0.6, dusk: 0.8, night: 1 };
|
||||
const SCREEN_RGB: Record<OfficeState, string> = { running: "88,230,170", queued: "160,170,200", cooldown: "240,170,70", blocked: "240,80,80", idle: "110,130,170" };
|
||||
const WARM = "255,206,140";
|
||||
const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];
|
||||
const LEVELS = 12;
|
||||
|
||||
/** Every lamp and screen in the office: monitors glow in their agent's state colour, rooms and the open floor have warm lamps. */
|
||||
export function sceneLights(plan: FloorPlan, screens: Map<Rect, OfficeState>): Light[] {
|
||||
const lights: Light[] = [];
|
||||
for (const [desk, state] of screens) {
|
||||
lights.push({ at: { x: desk.x + desk.w / 2, y: desk.y + 14 }, z: 20, radius: 26, rgb: SCREEN_RGB[state], strength: state === "idle" ? 0.35 : 0.8 });
|
||||
}
|
||||
for (const desk of plan.desks) lights.push({ at: { x: desk.bounds.x + desk.bounds.w / 2, y: desk.bounds.y + 60 }, z: 0, radius: 58, rgb: WARM, strength: 0.7 });
|
||||
for (const room of plan.rooms) {
|
||||
const b = room.bounds;
|
||||
const rgb = room.kind === "restroom" ? "205,228,255" : WARM;
|
||||
lights.push({ at: { x: b.x + b.w / 2, y: b.y + b.h / 2 }, z: 0, radius: Math.max(b.w, b.h) * 0.42, rgb, strength: 0.85 });
|
||||
}
|
||||
for (let x = 120; x < plan.world.w; x += 220) lights.push({ at: { x, y: 320 }, z: 0, radius: 64, rgb: WARM, strength: 0.75 });
|
||||
return lights;
|
||||
}
|
||||
|
||||
/** Sunlit floor under each window, longer and slanted toward evening or morning; none at night. */
|
||||
export function sunPatches(windows: Rect[], clock: Date): Vec[][] {
|
||||
const hour = clock.getHours() + clock.getMinutes() / 60;
|
||||
if (hour < 6.5 || hour > 18.5) return [];
|
||||
const reach = 1.1 + Math.abs(hour - 12.5) * 0.35;
|
||||
const slant = (12.5 - hour) * 0.18;
|
||||
return windows.map((glass) => {
|
||||
const low = (BACK_WALL_H - glass.y - glass.h) * reach;
|
||||
const high = (BACK_WALL_H - glass.y) * reach;
|
||||
return [
|
||||
{ x: glass.x + slant * low, y: WALL + low },
|
||||
{ x: glass.x + glass.w + slant * low, y: WALL + low },
|
||||
{ x: glass.x + glass.w + slant * high, y: WALL + high },
|
||||
{ x: glass.x + slant * high, y: WALL + high },
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** Bands a light map into a few levels with a 4×4 ordered dither, so light falls off in pixels, not gradients. */
|
||||
export function ditherLevels(data: Uint8ClampedArray, width: number): void {
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const pixel = i / 4;
|
||||
const threshold = BAYER[((Math.floor(pixel / width) & 3) << 2) | (pixel % width & 3)]! / 16 - 0.5;
|
||||
for (let c = 0; c < 3; c++) {
|
||||
const level = Math.round((data[i + c]! / 255) * LEVELS + threshold);
|
||||
data[i + c] = (Math.max(0, Math.min(LEVELS, level)) / LEVELS) * 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface LightingInput {
|
||||
frame: IsoFrame;
|
||||
camera: Camera;
|
||||
/** Scene pixels per iso pixel. */
|
||||
art: number;
|
||||
dayPart: DayPart;
|
||||
lights: Light[];
|
||||
patches: Vec[][];
|
||||
timeMs: number;
|
||||
}
|
||||
|
||||
/** Multiplies the finished scene by a light map drawn at iso resolution: ambient for the hour, lamps and screens added, sun through the windows. */
|
||||
export function applyLighting(scene: CanvasRenderingContext2D, map: HTMLCanvasElement, input: LightingInput): void {
|
||||
const w = Math.ceil(scene.canvas.width / input.art);
|
||||
const h = Math.ceil(scene.canvas.height / input.art);
|
||||
if (map.width !== w || map.height !== h) {
|
||||
map.width = w;
|
||||
map.height = h;
|
||||
}
|
||||
const ctx = map.getContext("2d", { willReadFrequently: true });
|
||||
if (!ctx) throw new Error("The light map canvas has no 2D context.");
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
ctx.fillStyle = AMBIENT[input.dayPart];
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.globalCompositeOperation = "lighter";
|
||||
const lamps = LAMPS_ON[input.dayPart];
|
||||
const view = (p: Vec, z = 0) => {
|
||||
const iso = toIso(input.frame, p, z);
|
||||
return { x: iso.x - input.camera.x, y: iso.y - input.camera.y };
|
||||
};
|
||||
for (const light of input.lights) {
|
||||
const at = view(light.at, light.z);
|
||||
if (at.x < -light.radius || at.y < -light.radius || at.x > w + light.radius || at.y > h + light.radius) continue;
|
||||
const glow = ctx.createRadialGradient(at.x, at.y, 0, at.x, at.y, light.radius);
|
||||
glow.addColorStop(0, `rgba(${light.rgb},${0.55 * light.strength * lamps})`);
|
||||
glow.addColorStop(1, `rgba(${light.rgb},0)`);
|
||||
ctx.fillStyle = glow;
|
||||
ctx.fillRect(at.x - light.radius, at.y - light.radius, light.radius * 2, light.radius * 2);
|
||||
}
|
||||
ctx.fillStyle = input.dayPart === "day" ? "rgba(255,240,205,0.16)" : "rgba(255,196,140,0.12)";
|
||||
for (const patch of input.patches) {
|
||||
ctx.beginPath();
|
||||
patch.map((p) => view(p)).forEach((p, index) => (index === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y)));
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
const image = ctx.getImageData(0, 0, w, h);
|
||||
ditherLevels(image.data, w);
|
||||
ctx.putImageData(image, 0, 0);
|
||||
scene.save();
|
||||
scene.setTransform(input.art, 0, 0, input.art, 0, 0);
|
||||
scene.imageSmoothingEnabled = false;
|
||||
scene.globalCompositeOperation = "multiply";
|
||||
scene.drawImage(map, 0, 0);
|
||||
scene.globalCompositeOperation = "source-over";
|
||||
drawDust(scene, input, view);
|
||||
scene.restore();
|
||||
}
|
||||
|
||||
/** Dust motes drifting up through the sunlit patches. */
|
||||
function drawDust(scene: CanvasRenderingContext2D, input: LightingInput, view: (p: Vec, z?: number) => Vec): void {
|
||||
scene.fillStyle = "rgba(255,246,220,0.75)";
|
||||
input.patches.forEach((patch, index) => {
|
||||
const centre = { x: (patch[0]!.x + patch[2]!.x) / 2, y: (patch[0]!.y + patch[2]!.y) / 2 };
|
||||
for (let mote = 0; mote < 5; mote++) {
|
||||
const seed = index * 7 + mote * 13;
|
||||
const rise = ((input.timeMs / 90 + seed * 11) % 40);
|
||||
const drift = Math.sin(input.timeMs / 1400 + seed) * 6;
|
||||
const at = view({ x: centre.x + ((seed * 37) % 50) - 25 + drift, y: centre.y + ((seed * 17) % 30) - 15 }, rise);
|
||||
scene.fillRect(Math.round(at.x * 2) / 2, Math.round(at.y * 2) / 2, 0.5, 0.5);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Camera, FloorPlan, Rect } from "./office-types";
|
||||
import type { OfficeState } from "./agent-state";
|
||||
import { drawBackWalls, drawGround, sceneDrawables, type Drawable } from "./scene-renderer";
|
||||
import { drawBackWalls, drawContactShadows, drawGround, sceneDrawables, windowRects, type Drawable } from "./scene-renderer";
|
||||
import { depthOf, toIso, type IsoFrame } from "./iso";
|
||||
import { applyLighting, sceneLights, sunPatches } from "./lighting";
|
||||
import { skyScene, weatherFor } from "./sky";
|
||||
import { drawPerson, type Carrying, type Facing, type SpriteAppearance } from "./sprites";
|
||||
import type { DayPart, Season } from "../../shared/palette-clock";
|
||||
@@ -30,6 +31,8 @@ export interface OfficeFrame {
|
||||
camera: Camera;
|
||||
/** CSS px per iso pixel. */
|
||||
pixel: number;
|
||||
/** Scene pixels per iso pixel (artScale). */
|
||||
art: number;
|
||||
/** CSS px per pixel-font pixel, for the crisp text drawn over the scene. */
|
||||
fontScale: number;
|
||||
viewW: number;
|
||||
@@ -50,24 +53,16 @@ const STATE_MARK: Partial<Record<OfficeState, string>> = {
|
||||
blocked: "#ef4444",
|
||||
};
|
||||
|
||||
/** The day part washes over the whole floor: warm at dawn and dusk, blue at night. */
|
||||
const DAY_TINT: Record<DayPart, string | null> = {
|
||||
dawn: "#f3d9d6",
|
||||
day: null,
|
||||
dusk: "#f0c6a0",
|
||||
night: "#7d8bc0",
|
||||
};
|
||||
|
||||
export function hashString(value: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < value.length; i++) hash = (hash * 31 + value.charCodeAt(i)) | 0;
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
/** Draws the scene at iso-pixel resolution on `scene`, depth-sorted, then scales it up unsmoothed onto `ctx` and writes labels in the pixel font. */
|
||||
export function renderOffice(ctx: CanvasRenderingContext2D, scene: HTMLCanvasElement, input: OfficeFrame): void {
|
||||
const sceneW = Math.ceil(input.viewW / input.pixel);
|
||||
const sceneH = Math.ceil(input.viewH / input.pixel);
|
||||
/** Draws the scene at art resolution on `scene`, depth-sorted and lit for the hour, then scales it up unsmoothed onto `ctx` and writes labels in the pixel font. */
|
||||
export function renderOffice(ctx: CanvasRenderingContext2D, scene: HTMLCanvasElement, lightMap: HTMLCanvasElement, input: OfficeFrame): void {
|
||||
const sceneW = Math.ceil((input.viewW / input.pixel) * input.art);
|
||||
const sceneH = Math.ceil((input.viewH / input.pixel) * input.art);
|
||||
if (scene.width !== sceneW || scene.height !== sceneH) {
|
||||
scene.width = sceneW;
|
||||
scene.height = sceneH;
|
||||
@@ -75,29 +70,39 @@ export function renderOffice(ctx: CanvasRenderingContext2D, scene: HTMLCanvasEle
|
||||
const sctx = scene.getContext("2d");
|
||||
if (!sctx) throw new Error("The office scene canvas has no 2D context.");
|
||||
sctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
sctx.imageSmoothingEnabled = false;
|
||||
sctx.fillStyle = "#14161c";
|
||||
sctx.fillRect(0, 0, sceneW, sceneH);
|
||||
sctx.translate(-Math.round(input.camera.x), -Math.round(input.camera.y));
|
||||
const camera = snappedCamera(input);
|
||||
sctx.setTransform(input.art, 0, 0, input.art, -camera.x * input.art, -camera.y * input.art);
|
||||
|
||||
drawGround(sctx, input.frame, input.plan);
|
||||
drawBackWalls(sctx, input.frame, input.plan, { sky: skyScene(input.clock), weather: weatherFor(input.clock), clock: input.clock, timeMs: input.timeMs });
|
||||
const screens = new Map(input.people.filter((person) => person.desk).map((person) => [person.desk!, person.state]));
|
||||
const items: Drawable[] = [...sceneDrawables(input.frame, input.plan, input.season, screens, input.timeMs), ...input.people.map((person) => personDrawable(input, person))];
|
||||
const scenery = sceneDrawables(input.frame, input.plan, input.season, screens, input.timeMs);
|
||||
drawContactShadows(sctx, input.frame, scenery);
|
||||
const items: Drawable[] = [...scenery, ...input.people.map((person) => personDrawable(input, person))];
|
||||
items.sort((a, b) => a.depth - b.depth);
|
||||
for (const item of items) item.draw(sctx);
|
||||
|
||||
const tint = DAY_TINT[input.dayPart];
|
||||
if (tint) {
|
||||
sctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
sctx.globalCompositeOperation = "multiply";
|
||||
sctx.fillStyle = tint;
|
||||
sctx.fillRect(0, 0, sceneW, sceneH);
|
||||
sctx.globalCompositeOperation = "source-over";
|
||||
}
|
||||
applyLighting(sctx, lightMap, {
|
||||
frame: input.frame,
|
||||
camera,
|
||||
art: input.art,
|
||||
dayPart: input.dayPart,
|
||||
lights: sceneLights(input.plan, screens),
|
||||
patches: sunPatches(input.plan.rooms.flatMap(windowRects), input.clock),
|
||||
timeMs: input.timeMs,
|
||||
});
|
||||
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(scene, 0, 0, sceneW * input.pixel, sceneH * input.pixel);
|
||||
drawLabels(ctx, input);
|
||||
ctx.drawImage(scene, 0, 0, (sceneW * input.pixel) / input.art, (sceneH * input.pixel) / input.art);
|
||||
drawLabels(ctx, input, camera);
|
||||
}
|
||||
|
||||
/** The camera on the scene's pixel grid, so the scene and the labels over it move together. */
|
||||
function snappedCamera(input: OfficeFrame): Camera {
|
||||
return { x: Math.round(input.camera.x * input.art) / input.art, y: Math.round(input.camera.y * input.art) / input.art };
|
||||
}
|
||||
|
||||
function personDrawable(input: OfficeFrame, person: RenderPerson): Drawable {
|
||||
@@ -106,12 +111,7 @@ function personDrawable(input: OfficeFrame, person: RenderPerson): Drawable {
|
||||
depth: depthOf(person) + 0.5,
|
||||
draw: (ctx) => {
|
||||
const feet = toIso(input.frame, person);
|
||||
if (person.id === input.selectedId) {
|
||||
ctx.fillStyle = "rgba(94,106,210,0.9)";
|
||||
ctx.fillRect(Math.round(feet.x) - 12, Math.round(feet.y), 24, 2);
|
||||
ctx.fillRect(Math.round(feet.x) - 14, Math.round(feet.y) - 2, 2, 2);
|
||||
ctx.fillRect(Math.round(feet.x) + 12, Math.round(feet.y) - 2, 2, 2);
|
||||
}
|
||||
if (person.id === input.selectedId) drawSelection(ctx, feet.x, feet.y, input.timeMs);
|
||||
const working = person.state === "running" && person.walkPhase === 0;
|
||||
drawPerson(ctx, feet.x, feet.y, person.appearance, {
|
||||
facing: person.facing,
|
||||
@@ -121,24 +121,47 @@ function personDrawable(input: OfficeFrame, person: RenderPerson): Drawable {
|
||||
timeMs: input.timeMs,
|
||||
animSeed: person.animSeed,
|
||||
}, person.isCeo);
|
||||
const dot = STATE_MARK[person.state];
|
||||
if (dot) {
|
||||
ctx.fillStyle = dot;
|
||||
ctx.fillRect(Math.round(feet.x) + 8, Math.round(feet.y) - 40, 4, 4);
|
||||
}
|
||||
const gem = STATE_MARK[person.state];
|
||||
if (gem) drawStateGem(ctx, feet.x, feet.y - 38 + Math.round(Math.sin(input.timeMs / 420 + person.animSeed) * 2) / 2, gem);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A small faceted gem floating over the head, in the state's colour. */
|
||||
function drawStateGem(ctx: CanvasRenderingContext2D, x: number, y: number, color: string): void {
|
||||
const cx = Math.round(x * 2) / 2;
|
||||
const rows = [0.5, 1.5, 2.5, 2.5, 1.5, 1, 0.5];
|
||||
ctx.fillStyle = "rgba(20,18,24,0.85)";
|
||||
rows.forEach((half, row) => ctx.fillRect(cx - half - 0.5, y + row * 0.5 - 0.5, half * 2 + 1, 1));
|
||||
ctx.fillStyle = color;
|
||||
rows.forEach((half, row) => ctx.fillRect(cx - half, y + row * 0.5, half * 2, 0.5));
|
||||
ctx.fillStyle = "rgba(255,255,255,0.7)";
|
||||
ctx.fillRect(cx - 1, y + 1, 0.5, 0.5);
|
||||
ctx.fillRect(cx - 0.5, y + 0.5, 0.5, 0.5);
|
||||
}
|
||||
|
||||
/** A pixel ring around the selected person's feet, breathing in and out. */
|
||||
function drawSelection(ctx: CanvasRenderingContext2D, x: number, y: number, timeMs: number): void {
|
||||
const grow = Math.round((Math.sin(timeMs / 260) + 1) * 0.5) * 0.5;
|
||||
const cx = Math.round(x * 2) / 2;
|
||||
const cy = Math.round(y * 2) / 2;
|
||||
const rx = 9 + grow;
|
||||
ctx.fillStyle = "rgba(124,138,240,0.95)";
|
||||
for (let step = 0; step < 48; step++) {
|
||||
const angle = (step / 48) * 2 * Math.PI;
|
||||
ctx.fillRect(Math.round((cx + Math.cos(angle) * rx) * 2) / 2, Math.round((cy + Math.sin(angle) * rx * 0.5) * 2) / 2, 0.5, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
/** Names under people and room signs over doors, in the pixel font at screen resolution so they stay crisp. */
|
||||
function drawLabels(ctx: CanvasRenderingContext2D, input: OfficeFrame): void {
|
||||
function drawLabels(ctx: CanvasRenderingContext2D, input: OfficeFrame, camera: Camera): void {
|
||||
const size = 12 * input.fontScale;
|
||||
ctx.font = `${size}px "Fusion Pixel"`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
const screen = (x: number, y: number, z = 0) => {
|
||||
const iso = toIso(input.frame, { x, y }, z);
|
||||
return { x: Math.round((iso.x - Math.round(input.camera.x)) * input.pixel), y: Math.round((iso.y - Math.round(input.camera.y)) * input.pixel) };
|
||||
return { x: Math.round((iso.x - camera.x) * input.pixel), y: Math.round((iso.y - camera.y) * input.pixel) };
|
||||
};
|
||||
for (const room of input.plan.rooms) {
|
||||
const at = screen(room.doorCx, room.bounds.y + room.bounds.h, 22);
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { finishSprite, mix, PixelGrid, tone } from "./pixel-grid";
|
||||
import type { Carrying, Facing, SpriteAppearance } from "./sprites";
|
||||
|
||||
/** Sprite canvas in art pixels (two per iso pixel); the feet rest on ANCHOR. */
|
||||
export const ART_W = 40;
|
||||
export const ART_H = 80;
|
||||
export const ANCHOR = { x: 20, y: 74 };
|
||||
|
||||
/** One drawn pose: walk step 0-3 (-1 standing), typing hands 0/1 (-1 not typing), eyes shut mid-blink. */
|
||||
export interface PersonFrame {
|
||||
facing: Facing;
|
||||
step: number;
|
||||
typing: number;
|
||||
blink: boolean;
|
||||
carrying: Carrying;
|
||||
}
|
||||
|
||||
const EYE = "#2a2430";
|
||||
const WHITE = "#fbfaf6";
|
||||
const SHIRT_WHITE = "#ecebe6";
|
||||
const BELT = "#2b2a30";
|
||||
const BUCKLE = "#c9b27a";
|
||||
const MOUTH = "#9c4d48";
|
||||
const BLUSH = "#e88a8a";
|
||||
const BLAZER = "#37415c";
|
||||
const BLAZER_CEO = "#262c3d";
|
||||
|
||||
export function personArt(look: SpriteAppearance, frame: PersonFrame, isCeo: boolean): PixelGrid {
|
||||
const grid = new PixelGrid(ART_W, ART_H);
|
||||
const side = frame.facing === "east" || frame.facing === "west";
|
||||
const lift = frame.step === 1 || frame.step === 3 ? -1 : 0;
|
||||
if (side) drawSide(grid, look, frame, isCeo, lift);
|
||||
else drawFrontOrBack(grid, look, frame, isCeo, lift, frame.facing === "south");
|
||||
finishSprite(grid);
|
||||
return frame.facing === "west" ? grid.mirrored() : grid;
|
||||
}
|
||||
|
||||
function jacketOf(look: SpriteAppearance, isCeo: boolean): string {
|
||||
return look.outfit === "blazer" ? (isCeo ? BLAZER_CEO : BLAZER) : look.shirt;
|
||||
}
|
||||
|
||||
function drawFrontOrBack(g: PixelGrid, look: SpriteAppearance, frame: PersonFrame, isCeo: boolean, lift: number, front: boolean): void {
|
||||
const leftUp = frame.step === 0 ? 2 : 0;
|
||||
const rightUp = frame.step === 2 ? 2 : 0;
|
||||
legs(g, look, 12, 53, 7, leftUp, front);
|
||||
legs(g, look, 21, 53, 7, rightUp, front);
|
||||
g.rect(19, 53, 2, 4, look.pants);
|
||||
const y = lift;
|
||||
const body = jacketOf(look, isCeo);
|
||||
g.rect(11, 33 + y, 18, 18, body);
|
||||
g.clear(11, 33 + y);
|
||||
g.clear(28, 33 + y);
|
||||
g.rect(11, 51 + y, 18, 2, BELT);
|
||||
if (front) g.rect(19, 51 + y, 2, 2, BUCKLE);
|
||||
const swing = frame.step < 0 ? 0 : frame.step === 0 ? 1 : frame.step === 2 ? -1 : 0;
|
||||
if (frame.typing >= 0) typingArms(g, look, body, y, frame.typing);
|
||||
else {
|
||||
arm(g, look, body, 8, 34 + y + swing);
|
||||
arm(g, look, body, 29, 34 + y - swing);
|
||||
}
|
||||
if (front) frontOutfit(g, look, body, y);
|
||||
else backOutfit(g, look, body, y);
|
||||
g.rect(17, 31 + y, 6, 2, tone(look.skin, 0.84));
|
||||
head(g, look, frame, y, front);
|
||||
if (frame.carrying && frame.typing < 0) carried(g, frame.carrying, 31, 44 + y - swing);
|
||||
}
|
||||
|
||||
function legs(g: PixelGrid, look: SpriteAppearance, x: number, top: number, w: number, up: number, front: boolean): void {
|
||||
g.rect(x, top, w, 17 - up, look.pants);
|
||||
g.rect(x, top + 6, 1, 8 - up, tone(look.pants, 0.85));
|
||||
const shoeY = 70 - up;
|
||||
g.rect(x - 1, shoeY, w + 1, 3, look.shoes);
|
||||
g.rect(x - 1, shoeY + 3, w + 1, 1, tone(look.shoes, 0.6));
|
||||
if (front) g.rect(x, shoeY, 2, 1, tone(look.shoes, 1.4));
|
||||
}
|
||||
|
||||
function arm(g: PixelGrid, look: SpriteAppearance, body: string, x: number, top: number): void {
|
||||
const sleeve = look.outfit === "tee" ? 6 : 14;
|
||||
g.rect(x, top, 3, sleeve, body);
|
||||
g.rect(x, top + sleeve, 3, 15 - sleeve, look.skin);
|
||||
if (look.outfit === "blazer") g.rect(x, top + 13, 3, 1, SHIRT_WHITE);
|
||||
if (look.outfit === "hoodie") g.rect(x, top + 13, 3, 1, tone(body, 0.78));
|
||||
g.rect(x, top + 15, 3, 3, look.skin);
|
||||
g.px(x + 1, top + 17, tone(look.skin, 0.82));
|
||||
}
|
||||
|
||||
function typingArms(g: PixelGrid, look: SpriteAppearance, body: string, y: number, beat: number): void {
|
||||
for (const [x, inward, offset] of [[8, 1, beat], [29, -1, 1 - beat]] as const) {
|
||||
g.rect(x, 34 + y, 3, 9, body);
|
||||
g.rect(x + inward * 2, 41 + y - offset, 3, 4, body);
|
||||
g.rect(x + inward * 3, 44 + y - offset, 3, 2, look.skin);
|
||||
}
|
||||
}
|
||||
|
||||
function frontOutfit(g: PixelGrid, look: SpriteAppearance, body: string, y: number): void {
|
||||
if (look.outfit === "tee") {
|
||||
g.rect(18, 33 + y, 4, 1, look.skin);
|
||||
g.rect(19, 34 + y, 2, 1, look.skin);
|
||||
g.rect(22, 38 + y, 3, 2, look.accent);
|
||||
g.rect(11, 50 + y, 18, 1, tone(body, 0.85));
|
||||
return;
|
||||
}
|
||||
if (look.outfit === "hoodie") {
|
||||
g.rect(13, 33 + y, 14, 2, tone(body, 0.78));
|
||||
g.rect(17, 35 + y, 1, 5, WHITE);
|
||||
g.rect(22, 35 + y, 1, 5, WHITE);
|
||||
g.rect(14, 43 + y, 12, 6, tone(body, 0.88));
|
||||
g.rect(14, 43 + y, 12, 1, tone(body, 0.74));
|
||||
return;
|
||||
}
|
||||
for (let row = 0; row < 12; row++) {
|
||||
const half = Math.max(1, 3 - Math.floor(row / 3));
|
||||
g.rect(20 - half, 33 + y + row, half * 2, 1, SHIRT_WHITE);
|
||||
g.px(19 - half, 33 + y + row, tone(body, 0.72));
|
||||
g.px(20 + half, 33 + y + row, tone(body, 0.72));
|
||||
}
|
||||
g.rect(19, 34 + y, 2, 1, tone(look.accent, 0.8));
|
||||
g.rect(19, 35 + y, 2, 9, look.accent);
|
||||
g.px(19, 44 + y, tone(look.accent, 0.8));
|
||||
g.px(21, 47 + y, "#d8d0b8");
|
||||
g.px(21, 49 + y, "#d8d0b8");
|
||||
g.rect(24, 37 + y, 2, 1, look.accent);
|
||||
}
|
||||
|
||||
function backOutfit(g: PixelGrid, look: SpriteAppearance, body: string, y: number): void {
|
||||
if (look.outfit === "hoodie") g.rect(14, 33 + y, 12, 5, tone(body, 0.82));
|
||||
if (look.outfit === "blazer") g.rect(19, 44 + y, 1, 7, tone(body, 0.7));
|
||||
if (look.outfit === "tee") g.rect(11, 50 + y, 18, 1, tone(body, 0.85));
|
||||
}
|
||||
|
||||
function head(g: PixelGrid, look: SpriteAppearance, frame: PersonFrame, y: number, front: boolean): void {
|
||||
const top = 12 + y;
|
||||
g.rect(11, top, 18, 19, look.skin);
|
||||
for (const [x, dy] of [[11, 0], [12, 0], [11, 1], [28, 0], [27, 0], [28, 1], [11, 18], [28, 18]] as const) g.clear(x, top + dy);
|
||||
g.rect(10, top + 8, 1, 4, tone(look.skin, 0.9));
|
||||
g.rect(29, top + 8, 1, 4, tone(look.skin, 0.9));
|
||||
if (front) {
|
||||
face(g, look, frame.blink, top);
|
||||
frontHair(g, look, top);
|
||||
} else backHair(g, look, top);
|
||||
}
|
||||
|
||||
function face(g: PixelGrid, look: SpriteAppearance, blink: boolean, top: number): void {
|
||||
const brow = tone(look.hair, 0.8);
|
||||
g.rect(13, top + 6, 3, 1, brow);
|
||||
g.rect(24, top + 6, 3, 1, brow);
|
||||
if (look.glasses) {
|
||||
const lens = mix(look.skin, "#9fd0f0", 0.35);
|
||||
for (const x of [13, 23]) {
|
||||
g.rect(x, top + 7, 4, 5, "#1c1c22");
|
||||
g.rect(x + 1, top + 8, 2, 3, lens);
|
||||
}
|
||||
g.rect(17, top + 8, 6, 1, "#1c1c22");
|
||||
}
|
||||
for (const x of [14, 24]) {
|
||||
if (blink) g.rect(x, top + 10, 2, 1, EYE);
|
||||
else {
|
||||
g.rect(x, top + 8, 2, 3, EYE);
|
||||
g.px(x + 1, top + 8, WHITE);
|
||||
}
|
||||
}
|
||||
g.px(20, top + 12, tone(look.skin, 0.8));
|
||||
g.rect(18, top + 15, 4, 1, MOUTH);
|
||||
g.px(17, top + 14, mix(look.skin, MOUTH, 0.5));
|
||||
g.px(22, top + 14, mix(look.skin, MOUTH, 0.5));
|
||||
const blush = mix(look.skin, BLUSH, 0.45);
|
||||
g.rect(13, top + 13, 2, 1, blush);
|
||||
g.rect(25, top + 13, 2, 1, blush);
|
||||
}
|
||||
|
||||
function cap(g: PixelGrid, look: SpriteAppearance, top: number, rows: number): void {
|
||||
g.rect(12, top - 2, 16, 1, look.hair);
|
||||
g.rect(11, top - 1, 18, rows + 1, look.hair);
|
||||
g.rect(14, top - 1, 4, 1, tone(look.hair, 1.4));
|
||||
}
|
||||
|
||||
function frontHair(g: PixelGrid, look: SpriteAppearance, top: number): void {
|
||||
const hair = look.hair;
|
||||
switch (look.hairStyle) {
|
||||
case "bald":
|
||||
g.rect(15, top + 1, 3, 1, tone(look.skin, 1.12));
|
||||
return;
|
||||
case "buzz":
|
||||
g.rect(12, top - 1, 16, 3, hair);
|
||||
g.rect(11, top + 2, 1, 4, hair);
|
||||
g.rect(28, top + 2, 1, 4, hair);
|
||||
return;
|
||||
case "short":
|
||||
cap(g, look, top, 4);
|
||||
g.rect(12, top + 4, 7, 1, hair);
|
||||
g.rect(11, top + 4, 2, 5, hair);
|
||||
g.rect(27, top + 4, 2, 5, hair);
|
||||
return;
|
||||
case "bob":
|
||||
cap(g, look, top, 5);
|
||||
g.rect(9, top + 2, 4, 14, hair);
|
||||
g.rect(27, top + 2, 4, 14, hair);
|
||||
g.rect(13, top + 5, 14, 1, tone(hair, 0.85));
|
||||
return;
|
||||
case "long":
|
||||
cap(g, look, top, 4);
|
||||
g.rect(9, top + 2, 4, 25, hair);
|
||||
g.rect(27, top + 2, 4, 25, hair);
|
||||
g.rect(13, top + 4, 8, 1, hair);
|
||||
g.rect(10, top + 8, 1, 14, tone(hair, 1.25));
|
||||
return;
|
||||
case "ponytail":
|
||||
cap(g, look, top, 4);
|
||||
g.rect(21, top + 4, 6, 1, hair);
|
||||
g.rect(29, top + 2, 3, 12, hair);
|
||||
g.rect(29, top + 2, 3, 1, tone(look.accent, 0.9));
|
||||
return;
|
||||
case "curly":
|
||||
cap(g, look, top - 1, 5);
|
||||
for (let x = 10; x <= 29; x += 2) g.rect(x, top - 4 + (x % 4 === 0 ? 0 : 1), 2, 2, hair);
|
||||
for (let row = 2; row < 12; row += 2) {
|
||||
g.rect(9 + (row % 4 === 0 ? 0 : 1), top + row, 3, 2, hair);
|
||||
g.rect(28 - (row % 4 === 0 ? 0 : 1), top + row, 3, 2, hair);
|
||||
}
|
||||
g.rect(13, top + 4, 14, 1, tone(hair, 0.85));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function backHair(g: PixelGrid, look: SpriteAppearance, top: number): void {
|
||||
const hair = look.hair;
|
||||
if (look.hairStyle === "bald") return;
|
||||
const depth = { buzz: 10, short: 14, bob: 18, long: 18, ponytail: 15, curly: 15 }[look.hairStyle];
|
||||
g.rect(12, top - 2, 16, 1, hair);
|
||||
g.rect(11, top - 1, 18, depth, hair);
|
||||
g.rect(14, top, 5, 1, tone(hair, 1.4));
|
||||
if (look.hairStyle === "long") g.rect(10, top + 14, 20, 12, hair);
|
||||
if (look.hairStyle === "bob") g.rect(10, top + 12, 20, 4, hair);
|
||||
if (look.hairStyle === "ponytail") {
|
||||
g.rect(18, top + 13, 4, 1, look.accent);
|
||||
g.rect(18, top + 14, 4, 11, hair);
|
||||
g.rect(19, top + 25, 2, 2, hair);
|
||||
}
|
||||
if (look.hairStyle === "curly") for (let x = 11; x <= 28; x += 3) g.rect(x, top + 14, 2, 2, hair);
|
||||
}
|
||||
|
||||
function drawSide(g: PixelGrid, look: SpriteAppearance, frame: PersonFrame, isCeo: boolean, lift: number): void {
|
||||
const stride = frame.step === 0 ? 3 : frame.step === 2 ? -3 : 0;
|
||||
sideLeg(g, look, 15 - stride, 0.82);
|
||||
sideLeg(g, look, 20 + stride, 1);
|
||||
const y = lift;
|
||||
const body = jacketOf(look, isCeo);
|
||||
g.rect(13, 33 + y, 14, 18, body);
|
||||
g.clear(13, 33 + y);
|
||||
g.clear(26, 33 + y);
|
||||
g.rect(13, 51 + y, 14, 2, BELT);
|
||||
if (look.outfit === "hoodie") g.rect(13, 33 + y, 5, 6, tone(body, 0.8));
|
||||
if (look.outfit === "blazer") {
|
||||
g.rect(24, 33 + y, 3, 9, SHIRT_WHITE);
|
||||
g.rect(25, 35 + y, 2, 8, look.accent);
|
||||
}
|
||||
if (look.outfit === "tee") g.rect(22, 38 + y, 3, 2, look.accent);
|
||||
const swing = frame.typing >= 0 ? 4 : -stride;
|
||||
arm(g, look, body, 18 + Math.round(swing / 2), 34 + y);
|
||||
g.rect(17, 31 + y, 5, 2, tone(look.skin, 0.84));
|
||||
sideHead(g, look, frame.blink, 12 + y);
|
||||
if (frame.carrying && frame.typing < 0) carried(g, frame.carrying, 22 + Math.round(swing / 2), 46 + y);
|
||||
}
|
||||
|
||||
function sideLeg(g: PixelGrid, look: SpriteAppearance, x: number, shade: number): void {
|
||||
g.rect(x, 53, 5, 17, tone(look.pants, shade));
|
||||
g.rect(x, 70, 7, 3, tone(look.shoes, shade));
|
||||
g.rect(x, 73, 7, 1, tone(look.shoes, 0.6 * shade));
|
||||
}
|
||||
|
||||
function sideHead(g: PixelGrid, look: SpriteAppearance, blink: boolean, top: number): void {
|
||||
g.rect(12, top, 16, 19, look.skin);
|
||||
for (const [x, dy] of [[12, 0], [13, 0], [12, 1], [27, 0], [26, 0], [27, 1], [12, 18], [27, 18]] as const) g.clear(x, top + dy);
|
||||
g.rect(28, top + 10, 1, 3, look.skin);
|
||||
g.rect(17, top + 8, 2, 4, tone(look.skin, 0.86));
|
||||
g.rect(21, top + 6, 4, 1, tone(look.hair, 0.8));
|
||||
if (blink) g.rect(22, top + 10, 2, 1, EYE);
|
||||
else {
|
||||
g.rect(22, top + 8, 2, 3, EYE);
|
||||
g.px(23, top + 8, WHITE);
|
||||
}
|
||||
if (look.glasses) {
|
||||
g.rect(21, top + 7, 4, 1, "#1c1c22");
|
||||
g.rect(21, top + 11, 4, 1, "#1c1c22");
|
||||
g.rect(16, top + 8, 5, 1, "#1c1c22");
|
||||
}
|
||||
g.rect(24, top + 15, 3, 1, MOUTH);
|
||||
g.rect(24, top + 13, 2, 1, mix(look.skin, BLUSH, 0.45));
|
||||
if (look.hairStyle === "bald") return;
|
||||
const hair = look.hair;
|
||||
const back = { buzz: 8, short: 12, bob: 16, long: 16, ponytail: 12, curly: 14 }[look.hairStyle];
|
||||
g.rect(13, top - 2, 13, 1, hair);
|
||||
g.rect(12, top - 1, 15, look.hairStyle === "buzz" ? 3 : 5, hair);
|
||||
g.rect(11, top, 7, back, hair);
|
||||
g.rect(15, top - 1, 5, 1, tone(hair, 1.4));
|
||||
if (look.hairStyle === "long") g.rect(10, top + 12, 8, 14, hair);
|
||||
if (look.hairStyle === "bob") g.rect(25, top + 4, 2, 2, hair);
|
||||
if (look.hairStyle === "ponytail") g.rect(8, top + 4, 4, 12, hair);
|
||||
if (look.hairStyle === "curly") for (let row = 0; row < 14; row += 3) g.rect(10, top + row, 2, 2, hair);
|
||||
}
|
||||
|
||||
function carried(g: PixelGrid, item: NonNullable<Carrying>, x: number, y: number): void {
|
||||
if (item === "coffee") {
|
||||
g.rect(x, y, 4, 5, WHITE);
|
||||
g.rect(x, y, 4, 1, "#6b4a2f");
|
||||
g.px(x + 4, y + 2, WHITE);
|
||||
return;
|
||||
}
|
||||
g.rect(x, y + 1, 5, 4, "#c9553e");
|
||||
g.rect(x + 1, y + 2, 3, 2, "#e8b84a");
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/** An RGBA pixel buffer drawn without anti-aliasing, so procedural art stays exact. */
|
||||
export class PixelGrid {
|
||||
readonly data: Uint8ClampedArray<ArrayBuffer>;
|
||||
|
||||
constructor(readonly w: number, readonly h: number) {
|
||||
this.data = new Uint8ClampedArray(new ArrayBuffer(w * h * 4));
|
||||
}
|
||||
|
||||
rect(x: number, y: number, w: number, h: number, color: string): void {
|
||||
const [r, g, b] = rgb(color);
|
||||
for (let py = Math.max(0, y); py < Math.min(this.h, y + h); py++) {
|
||||
for (let px = Math.max(0, x); px < Math.min(this.w, x + w); px++) this.set(px, py, r, g, b, 255);
|
||||
}
|
||||
}
|
||||
|
||||
px(x: number, y: number, color: string): void {
|
||||
this.rect(x, y, 1, 1, color);
|
||||
}
|
||||
|
||||
clear(x: number, y: number): void {
|
||||
if (x >= 0 && y >= 0 && x < this.w && y < this.h) this.data[(y * this.w + x) * 4 + 3] = 0;
|
||||
}
|
||||
|
||||
alpha(x: number, y: number): number {
|
||||
return x < 0 || y < 0 || x >= this.w || y >= this.h ? 0 : this.data[(y * this.w + x) * 4 + 3]!;
|
||||
}
|
||||
|
||||
mirrored(): PixelGrid {
|
||||
const out = new PixelGrid(this.w, this.h);
|
||||
for (let y = 0; y < this.h; y++) {
|
||||
for (let x = 0; x < this.w; x++) {
|
||||
const from = (y * this.w + x) * 4;
|
||||
out.data.set(this.data.subarray(from, from + 4), (y * this.w + (this.w - 1 - x)) * 4);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private set(x: number, y: number, r: number, g: number, b: number, a: number): void {
|
||||
const at = (y * this.w + x) * 4;
|
||||
this.data[at] = r;
|
||||
this.data[at + 1] = g;
|
||||
this.data[at + 2] = b;
|
||||
this.data[at + 3] = a;
|
||||
}
|
||||
}
|
||||
|
||||
const NEIGHBOURS = [[1, 0], [0, 1], [-1, 0], [0, -1]] as const;
|
||||
|
||||
/** Light from the top left: rim-lit upper-left edges, shaded lower-right edges, then a selective outline in a dark tone of the colour it borders. */
|
||||
export function finishSprite(grid: PixelGrid): void {
|
||||
const source = grid.data.slice();
|
||||
const opaque = (x: number, y: number) => x >= 0 && y >= 0 && x < grid.w && y < grid.h && source[(y * grid.w + x) * 4 + 3]! > 0;
|
||||
for (let y = 0; y < grid.h; y++) {
|
||||
for (let x = 0; x < grid.w; x++) {
|
||||
const at = (y * grid.w + x) * 4;
|
||||
if (opaque(x, y)) {
|
||||
const factor = !opaque(x - 1, y) || !opaque(x, y - 1) ? 1.14 : !opaque(x + 1, y) || !opaque(x, y + 1) ? 0.84 : 1;
|
||||
for (let c = 0; c < 3; c++) grid.data[at + c] = source[at + c]! * factor;
|
||||
continue;
|
||||
}
|
||||
for (const [dx, dy] of NEIGHBOURS) {
|
||||
if (!opaque(x + dx, y + dy)) continue;
|
||||
const from = ((y + dy) * grid.w + x + dx) * 4;
|
||||
for (let c = 0; c < 3; c++) grid.data[at + c] = source[from + c]! * 0.32;
|
||||
grid.data[at + 3] = 255;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Mixes two colours; `amount` 0 keeps the first. */
|
||||
export function mix(a: string, b: string, amount: number): string {
|
||||
const [ar, ag, ab] = rgb(a);
|
||||
const [br, bg, bb] = rgb(b);
|
||||
const channel = (from: number, to: number) => Math.round(from + (to - from) * amount);
|
||||
return hex(channel(ar, br), channel(ag, bg), channel(ab, bb));
|
||||
}
|
||||
|
||||
/** Scales a colour's channels; below 1 darkens. */
|
||||
export function tone(color: string, factor: number): string {
|
||||
const [r, g, b] = rgb(color);
|
||||
const channel = (value: number) => Math.max(0, Math.min(255, Math.round(value * factor)));
|
||||
return hex(channel(r), channel(g), channel(b));
|
||||
}
|
||||
|
||||
function rgb(color: string): [number, number, number] {
|
||||
if (!/^#[0-9a-f]{6}$/i.test(color)) throw new Error(`Sprite colours are #rrggbb, not "${color}".`);
|
||||
const value = Number.parseInt(color.slice(1), 16);
|
||||
return [(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff];
|
||||
}
|
||||
|
||||
function hex(r: number, g: number, b: number): string {
|
||||
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, "0")}`;
|
||||
}
|
||||
@@ -12,9 +12,10 @@ export interface Drawable {
|
||||
draw: (ctx: CanvasRenderingContext2D) => void;
|
||||
}
|
||||
|
||||
/** A piece of the scene and the plan footprint it sorts by. */
|
||||
/** A piece of the scene and the plan footprint it sorts by; grounded pieces cast a contact shadow. */
|
||||
export interface SceneDrawable extends Drawable {
|
||||
foot: Rect;
|
||||
grounded: boolean;
|
||||
}
|
||||
|
||||
/** Interior walls are cut away to this height so rooms stay visible. */
|
||||
@@ -23,7 +24,6 @@ const INNER_WALL_H = 14;
|
||||
const PIECE = 32;
|
||||
|
||||
const PALETTE = {
|
||||
wood: "#b08a62",
|
||||
woodSeam: "#9c7852",
|
||||
corridor: "#a8815c",
|
||||
wall: "#6b5f52",
|
||||
@@ -45,6 +45,8 @@ const PALETTE = {
|
||||
panelSeam: "#5d5246",
|
||||
} as const;
|
||||
|
||||
const PLANKS = ["#b08a62", "#b8936a", "#a9845c", "#b48e65"];
|
||||
|
||||
const LEAVES: Record<Season, { leaf: string; light: string }> = {
|
||||
spring: { leaf: "#5f9a52", light: "#e9a3c2" },
|
||||
summer: { leaf: "#3f7f3a", light: "#6fb25c" },
|
||||
@@ -62,15 +64,27 @@ export function drawGround(ctx: CanvasRenderingContext2D, frame: IsoFrame, plan:
|
||||
ctx.save();
|
||||
setGroundTransform(ctx, frame);
|
||||
const { w, h } = plan.world;
|
||||
ctx.fillStyle = PALETTE.wood;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.fillStyle = PALETTE.woodSeam;
|
||||
ctx.beginPath();
|
||||
ctx.rect(0, 0, w, h);
|
||||
ctx.clip();
|
||||
for (let y = 0; y < h; y += 16) {
|
||||
for (let x = (y / 16) % 2 === 0 ? -48 : 0; x < w; x += 96) {
|
||||
const grain = (Math.imul(x + 977, 31) ^ Math.imul(y + 131, 17)) >>> 0;
|
||||
ctx.fillStyle = PLANKS[grain % PLANKS.length]!;
|
||||
ctx.fillRect(x, y, 96, 16);
|
||||
ctx.fillStyle = PALETTE.woodSeam;
|
||||
ctx.fillRect(x + 8 + (grain % 40), y + 4 + (grain % 3) * 3, 22 + (grain % 17), 1);
|
||||
ctx.fillRect(x + 50 + (grain % 23), y + 10 - (grain % 2) * 4, 14, 1);
|
||||
ctx.fillRect(x, y, 1, 16);
|
||||
}
|
||||
ctx.fillStyle = PALETTE.woodSeam;
|
||||
ctx.fillRect(0, y, w, 1);
|
||||
for (let x = (y / 16) % 2 === 0 ? 0 : 48; x < w; x += 96) ctx.fillRect(x, y, 1, 16);
|
||||
}
|
||||
ctx.fillStyle = PALETTE.corridor;
|
||||
ctx.fillRect(WALL, 288, w - 2 * WALL, 64);
|
||||
ctx.fillStyle = "#93704f";
|
||||
ctx.fillRect(WALL, 292, w - 2 * WALL, 1);
|
||||
ctx.fillRect(WALL, 347, w - 2 * WALL, 1);
|
||||
for (const room of plan.rooms) drawRoomFloor(ctx, room);
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -83,6 +97,10 @@ function drawRoomFloor(ctx: CanvasRenderingContext2D, room: RoomSpec): void {
|
||||
ctx.strokeStyle = room.kind === "ceo" ? PALETTE.ceoCarpetEdge : PALETTE.meetingCarpetEdge;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(b.x + 6, b.y + 6, b.w - 12, b.h - 12);
|
||||
ctx.fillStyle = room.kind === "ceo" ? "#66749e" : "#769a82";
|
||||
for (let ty = b.y + 16; ty < b.y + b.h - 12; ty += 12) {
|
||||
for (let tx = b.x + 16 + (((ty - b.y) / 12) % 2) * 6; tx < b.x + b.w - 12; tx += 12) ctx.fillRect(tx, ty, 2, 2);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const [tileA, tileB] = room.kind === "snack" ? [PALETTE.snackTileA, PALETTE.snackTileB] : [PALETTE.restTileA, PALETTE.restTileB];
|
||||
@@ -92,6 +110,18 @@ function drawRoomFloor(ctx: CanvasRenderingContext2D, room: RoomSpec): void {
|
||||
ctx.fillRect(b.x + tx, b.y + ty, Math.min(16, b.w - tx), Math.min(16, b.h - ty));
|
||||
}
|
||||
}
|
||||
ctx.fillStyle = "rgba(255,255,255,0.28)";
|
||||
for (let ty = 0; ty < b.h; ty += 16) ctx.fillRect(b.x, b.y + ty, b.w, 1);
|
||||
for (let tx = 0; tx < b.w; tx += 16) ctx.fillRect(b.x + tx, b.y, 1, b.h);
|
||||
}
|
||||
|
||||
/** Soft shadows on the floor where furniture and walls meet it, cast toward the south-east. */
|
||||
export function drawContactShadows(ctx: CanvasRenderingContext2D, frame: IsoFrame, items: SceneDrawable[]): void {
|
||||
ctx.save();
|
||||
setGroundTransform(ctx, frame);
|
||||
ctx.fillStyle = "rgba(28,18,10,0.2)";
|
||||
for (const item of items) if (item.grounded) ctx.fillRect(item.foot.x + 1, item.foot.y + 1, item.foot.w + 3, item.foot.h + 3);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/** What the back walls show: the sky and weather outside, and the time for the wall clock. */
|
||||
@@ -115,6 +145,10 @@ export function drawBackWalls(ctx: CanvasRenderingContext2D, frame: IsoFrame, pl
|
||||
ctx.fillRect(0, 0, w, 2);
|
||||
ctx.fillStyle = PALETTE.panelSeam;
|
||||
for (let x = 32; x < w; x += 32) ctx.fillRect(x, 2, 1, BACK_WALL_H - 5);
|
||||
ctx.fillStyle = "#62574b";
|
||||
ctx.fillRect(0, BACK_WALL_H - 14, w, 11);
|
||||
ctx.fillStyle = "#857766";
|
||||
ctx.fillRect(0, BACK_WALL_H - 15, w, 1);
|
||||
ctx.fillStyle = PALETTE.baseboard;
|
||||
ctx.fillRect(0, BACK_WALL_H - 3, w, 3);
|
||||
for (const room of plan.rooms) {
|
||||
@@ -126,6 +160,14 @@ export function drawBackWalls(ctx: CanvasRenderingContext2D, frame: IsoFrame, pl
|
||||
drawWeather(ctx, rect, outside.weather, outside.timeMs, seed);
|
||||
ctx.fillStyle = PALETTE.frame;
|
||||
ctx.fillRect(rect.x + rect.w / 2 - 1, rect.y, 2, rect.h);
|
||||
ctx.fillStyle = "#9c8f7c";
|
||||
ctx.fillRect(rect.x - 3, rect.y + rect.h + 2, rect.w + 6, 1);
|
||||
ctx.fillStyle = "#b9a98f";
|
||||
ctx.fillRect(rect.x - 7, rect.y - 3, 5, rect.h + 6);
|
||||
ctx.fillRect(rect.x + rect.w + 2, rect.y - 3, 5, rect.h + 6);
|
||||
ctx.fillStyle = "#a39278";
|
||||
ctx.fillRect(rect.x - 5, rect.y - 3, 1, rect.h + 6);
|
||||
ctx.fillRect(rect.x + rect.w + 4, rect.y - 3, 1, rect.h + 6);
|
||||
});
|
||||
}
|
||||
drawWallArt(ctx);
|
||||
@@ -167,7 +209,7 @@ function drawWallArt(ctx: CanvasRenderingContext2D): void {
|
||||
|
||||
/** A block sorted by its footprint centre; one resting on `support` touches exactly one piece of it (overhanging its outer edge is fine) and sorts just after that piece. */
|
||||
export function blockDrawable(frame: IsoFrame, foot: Rect, height: number, colors: BoxColors, z = 0, support?: Rect): SceneDrawable {
|
||||
return { foot, depth: support ? restingDepth(foot, support) : centerDepth(foot), draw: (ctx) => drawBox(ctx, frame, foot, z, height, colors) };
|
||||
return { foot, grounded: z === 0 && !support, depth: support ? restingDepth(foot, support) : centerDepth(foot), draw: (ctx) => drawBox(ctx, frame, foot, z, height, colors) };
|
||||
}
|
||||
|
||||
function restingDepth(foot: Rect, support: Rect): number {
|
||||
@@ -210,7 +252,7 @@ export function sceneDrawables(frame: IsoFrame, plan: FloorPlan, season: Season,
|
||||
block(counter, 16, faces("#8f8578"));
|
||||
block({ x: 700, y: 20, w: 24, h: 20 }, 22, faces("#d5dade"));
|
||||
const machine = { x: 726, y: 44, w: 14, h: 14 };
|
||||
items.push({ foot: machine, depth: restingDepth(machine, counter), draw: (ctx) => drawCoffeeMachine(ctx, frame, machine, 16, timeMs) });
|
||||
items.push({ foot: machine, grounded: false, depth: restingDepth(machine, counter), draw: (ctx) => drawCoffeeMachine(ctx, frame, machine, 16, timeMs) });
|
||||
block({ x: 792, y: 46, w: 12, h: 10 }, 8, faces("#c9a86a"), 16, counter);
|
||||
block({ x: 826, y: 46, w: 12, h: 10 }, 10, faces("#b3543f"), 16, counter);
|
||||
block({ x: 748, y: 170, w: 40, h: 40 }, 12, faces("#94897b"));
|
||||
@@ -220,33 +262,61 @@ export function sceneDrawables(frame: IsoFrame, plan: FloorPlan, season: Season,
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const cx = 1084 + i * 40;
|
||||
const toilet = { x: cx - 6, y: WALL + 2, w: 12, h: 16 };
|
||||
items.push({ foot: toilet, depth: centerDepth(toilet), draw: (ctx) => drawToilet(ctx, frame, cx, WALL + 2) });
|
||||
items.push({ foot: toilet, grounded: true, depth: centerDepth(toilet), draw: (ctx) => drawToilet(ctx, frame, cx, WALL + 2) });
|
||||
}
|
||||
for (let i = 0; i <= 3; i++) block({ x: 1063 + i * 40, y: WALL, w: 2, h: 48 }, INNER_WALL_H, faces("#7d8a99"));
|
||||
const sinks = { x: 928, y: 32, w: 96, h: 32 };
|
||||
block(sinks, 14, { top: "#c6d2de", south: "#a7b4c2", east: "#909eae" });
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const basin = { x: sinks.x + 2 + i * 64, y: sinks.y + 8, w: 28, h: 16 };
|
||||
items.push({ foot: basin, depth: restingDepth(basin, sinks), draw: (ctx) => drawBasin(ctx, frame, basin, 14) });
|
||||
items.push({ foot: basin, grounded: false, depth: restingDepth(basin, sinks), draw: (ctx) => drawBasin(ctx, frame, basin, 14) });
|
||||
}
|
||||
for (const [cx, cy] of [[312, 300], [632, 300], [904, 300]] as const) plant(cx, cy);
|
||||
for (const desk of plan.desks) deskBlocks(desk.bounds, false, screens.get(desk.bounds) ?? "idle", timeMs).forEach((item) => items.push(item(frame)));
|
||||
return items;
|
||||
}
|
||||
|
||||
/** A desk, its monitor and its chair, each a separate block so a seated person sorts between them. */
|
||||
/** A desk with its monitor, keyboard and clutter, and the office chair in front of it, each a separate block so a seated person sorts between them. */
|
||||
function deskBlocks(desk: Rect, executive: boolean, screen: OfficeState, timeMs: number): Array<(frame: IsoFrame) => SceneDrawable> {
|
||||
const monitor = deskMonitor(desk);
|
||||
return [
|
||||
const middle = desk.x + Math.floor(desk.w / 2 / PIECE) * PIECE;
|
||||
const seed = Math.round(desk.x + desk.y);
|
||||
const on = (foot: Rect, height: number, colors: BoxColors, z = 14) => (frame: IsoFrame) => blockDrawable(frame, foot, height, colors, z, desk);
|
||||
const parts: Array<(frame: IsoFrame) => SceneDrawable> = [
|
||||
...split(desk).map((piece) => (frame: IsoFrame) => blockDrawable(frame, piece, 14, faces(executive ? PALETTE.deskExec : PALETTE.desk))),
|
||||
on({ x: monitor.x + 11, y: monitor.y + 1, w: 4, h: 4 }, 2, faces("#3a3e45")),
|
||||
(frame) => {
|
||||
const shell = blockDrawable(frame, monitor, 12, { top: PALETTE.monitor, south: PALETTE.monitor, east: shade(PALETTE.monitor, 0.7) }, 14, desk);
|
||||
return { foot: monitor, depth: shell.depth, draw: (ctx) => {
|
||||
const shell = blockDrawable(frame, monitor, 12, { top: PALETTE.monitor, south: PALETTE.monitor, east: shade(PALETTE.monitor, 0.7) }, 16, desk);
|
||||
return { foot: monitor, grounded: false, depth: shell.depth, draw: (ctx) => {
|
||||
shell.draw(ctx);
|
||||
drawMonitorScreen(ctx, frame, monitor, 14, 12, screen, timeMs, Math.round(desk.x + desk.y));
|
||||
drawMonitorScreen(ctx, frame, monitor, 16, 12, screen, timeMs, seed);
|
||||
} };
|
||||
},
|
||||
(frame) => blockDrawable(frame, { x: desk.x + desk.w / 2 - 8, y: desk.y + desk.h + 6, w: 16, h: 12 }, 9, faces(PALETTE.chair)),
|
||||
on({ x: middle + 5, y: desk.y + 16, w: 20, h: 6 }, 1, { top: "#d9dce0", south: "#b8bcc2", east: "#a6abb2" }),
|
||||
on({ x: middle + 27, y: desk.y + 18, w: 3, h: 4 }, 1, faces("#cfd3d8")),
|
||||
...chairBlocks(desk.x + desk.w / 2, desk.y + desk.h + 6),
|
||||
];
|
||||
if (seed % 2 === 0) parts.push(on({ x: desk.x + 10, y: desk.y + 10, w: 5, h: 5 }, 5, { top: "#6b4a2f", south: "#e8e3d8", east: "#cfc9bc" }));
|
||||
if (seed % 3 === 0) parts.push(on({ x: desk.x + desk.w - 14, y: desk.y + 8, w: 10, h: 12 }, 2, faces("#f4f1ea")));
|
||||
if (seed % 3 === 1) {
|
||||
parts.push(on({ x: desk.x + desk.w - 13, y: desk.y + 6, w: 8, h: 8 }, 4, faces("#c9693f")));
|
||||
parts.push(on({ x: desk.x + desk.w - 14, y: desk.y + 5, w: 10, h: 10 }, 6, faces("#4f8a45"), 18));
|
||||
}
|
||||
if (executive) {
|
||||
parts.push(on({ x: desk.x + 8, y: desk.y + 6, w: 6, h: 6 }, 1, faces("#b89448")));
|
||||
parts.push(on({ x: desk.x + 10, y: desk.y + 8, w: 2, h: 2 }, 10, faces("#b89448"), 15));
|
||||
parts.push(on({ x: desk.x + 7, y: desk.y + 5, w: 8, h: 8 }, 4, faces("#2f5d4a"), 25));
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** An office chair: five-star base, gas lift, cushioned seat, and a backrest on the desk side that the sitter covers. */
|
||||
function chairBlocks(cx: number, y: number): Array<(frame: IsoFrame) => SceneDrawable> {
|
||||
return [
|
||||
(frame) => blockDrawable(frame, { x: cx - 7, y: y + 1, w: 14, h: 10 }, 2, faces("#2d3036")),
|
||||
(frame) => blockDrawable(frame, { x: cx - 1, y: y + 5, w: 2, h: 2 }, 5, faces("#5d6168"), 2),
|
||||
(frame) => blockDrawable(frame, { x: cx - 8, y, w: 16, h: 11 }, 3, faces("#4a5060"), 7),
|
||||
(frame) => blockDrawable(frame, { x: cx - 8, y, w: 16, h: 3 }, 11, faces(PALETTE.chair), 8),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Deterministic pixel people. Every agent gets a stable appearance derived
|
||||
* from identity (skin tone, hair style and color, outfit, glasses), drawn
|
||||
* procedurally with facing-aware variants and a two-frame walk cycle, so no
|
||||
* two colleagues read as clones and nobody ships binary sprite sheets.
|
||||
* from identity; poses are drawn at twice the scene's pixel density by
|
||||
* person-art.ts, finished with rim light and a selective outline, and cached.
|
||||
*/
|
||||
import { ANCHOR, ART_H, ART_W, personArt, type PersonFrame } from "./person-art";
|
||||
|
||||
export type Facing = "north" | "south" | "east" | "west";
|
||||
export type HairStyle = "buzz" | "short" | "bob" | "long" | "ponytail" | "curly" | "bald";
|
||||
@@ -18,13 +18,15 @@ export interface SpriteAppearance {
|
||||
shirt: string;
|
||||
accent: string;
|
||||
glasses: boolean;
|
||||
pants: string;
|
||||
shoes: string;
|
||||
}
|
||||
|
||||
export interface PersonPose {
|
||||
facing: Facing;
|
||||
/** Radians accumulated while walking; 0 means standing still. */
|
||||
walkPhase: number;
|
||||
/** Seated work bob (used at desks). */
|
||||
/** Typing at a desk. */
|
||||
working: boolean;
|
||||
carrying: Carrying;
|
||||
timeMs: number;
|
||||
@@ -39,10 +41,8 @@ const SHIRT_COLORS = [
|
||||
"#4fb8b8", "#d97b5b", "#7ba95a", "#c4566f", "#6a7dd9",
|
||||
];
|
||||
const ACCENT_COLORS = ["#e5c65a", "#d95b5b", "#5bd9c0", "#c9c9d4", "#8ad95b"];
|
||||
const BLAZER = "#37415c";
|
||||
const BLAZER_CEO = "#2c3448";
|
||||
const PANTS = "#3a3f47";
|
||||
const SHOES = "#26292e";
|
||||
const PANTS_COLORS = ["#3a3f47", "#2f4a6b", "#6b5a45", "#4a4a52", "#2b2d33"];
|
||||
const SHOE_COLORS = ["#26292e", "#5a3a26", "#e8e6e0", "#26292e"];
|
||||
|
||||
export function hashIdentity(value: string): number {
|
||||
let hash = 2166136261;
|
||||
@@ -67,10 +67,50 @@ export function appearanceFor(id: string, isCeo: boolean): SpriteAppearance {
|
||||
shirt: pick(SHIRT_COLORS, hash, 13),
|
||||
accent: pick(ACCENT_COLORS, hash, 17),
|
||||
glasses: (hash >>> 5) % 100 < 28,
|
||||
pants: isCeo ? "#22252c" : pick(PANTS_COLORS, hash, 19),
|
||||
shoes: isCeo ? "#1b1c20" : pick(SHOE_COLORS, hash, 23),
|
||||
};
|
||||
}
|
||||
|
||||
/** Draws a person anchored at foot-center (x, y). Total height ~34px. */
|
||||
/** The pose frame a moment shows: four walk steps, two typing beats, a blink every few seconds. */
|
||||
export function frameFor(pose: PersonPose): PersonFrame {
|
||||
const turn = 2 * Math.PI;
|
||||
const step = pose.walkPhase === 0 ? -1 : Math.floor(((((pose.walkPhase % turn) + turn) % turn) / turn) * 4);
|
||||
const blinkEvery = 3200 + (pose.animSeed % 7) * 300;
|
||||
return {
|
||||
facing: pose.facing,
|
||||
step,
|
||||
typing: pose.working ? Math.floor(pose.timeMs / 180 + pose.animSeed) % 2 : -1,
|
||||
blink: (pose.timeMs + pose.animSeed * 97) % blinkEvery < 120,
|
||||
carrying: pose.carrying,
|
||||
};
|
||||
}
|
||||
|
||||
/** Most recently drawn pose canvases; the least recent go first past the cap. */
|
||||
const cache = new Map<string, HTMLCanvasElement>();
|
||||
const CACHE_CAP = 2048;
|
||||
|
||||
function spriteFor(appearance: SpriteAppearance, frame: PersonFrame, isCeo: boolean): HTMLCanvasElement {
|
||||
const key = `${JSON.stringify(appearance)}|${isCeo}|${frame.facing}|${frame.step}|${frame.typing}|${frame.blink}|${frame.carrying}`;
|
||||
const cached = cache.get(key);
|
||||
if (cached) {
|
||||
cache.delete(key);
|
||||
cache.set(key, cached);
|
||||
return cached;
|
||||
}
|
||||
const grid = personArt(appearance, frame, isCeo);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = ART_W;
|
||||
canvas.height = ART_H;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("A sprite canvas has no 2D context.");
|
||||
ctx.putImageData(new ImageData(grid.data, ART_W, ART_H), 0, 0);
|
||||
cache.set(key, canvas);
|
||||
if (cache.size > CACHE_CAP) cache.delete(cache.keys().next().value!);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/** Draws a person standing at foot-centre (x, y) in scene pixels; the sprite is half its art size there. */
|
||||
export function drawPerson(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
@@ -79,194 +119,12 @@ export function drawPerson(
|
||||
pose: PersonPose,
|
||||
isCeo: boolean,
|
||||
): void {
|
||||
const px = Math.round(x);
|
||||
const walking = pose.walkPhase !== 0;
|
||||
const stride = walking ? Math.sin(pose.walkPhase) * 2.5 : 0;
|
||||
const bob = pose.working
|
||||
? Math.sin(pose.timeMs * 0.003 + pose.animSeed) * 1.2
|
||||
: walking ? Math.abs(Math.sin(pose.walkPhase)) * -1 : 0;
|
||||
const py = Math.round(y + bob);
|
||||
|
||||
ctx.fillStyle = "rgba(24,18,12,0.28)";
|
||||
ctx.fillRect(px - 8, Math.round(y) + 1, 16, 4);
|
||||
|
||||
drawLegs(ctx, px, py, stride);
|
||||
drawTorso(ctx, px, py, appearance, pose, isCeo);
|
||||
drawHead(ctx, px, py, appearance, pose.facing);
|
||||
if (pose.carrying) drawCarriedItem(ctx, px, py, pose.carrying, pose.facing);
|
||||
}
|
||||
|
||||
function drawLegs(ctx: CanvasRenderingContext2D, x: number, y: number, stride: number): void {
|
||||
const left = Math.round(stride);
|
||||
const right = Math.round(-stride);
|
||||
ctx.fillStyle = PANTS;
|
||||
ctx.fillRect(x - 5, y - 8 + Math.max(0, -left), 4, 8 - Math.abs(Math.min(left, 0)));
|
||||
ctx.fillRect(x + 1, y - 8 + Math.max(0, -right), 4, 8 - Math.abs(Math.min(right, 0)));
|
||||
ctx.fillStyle = SHOES;
|
||||
ctx.fillRect(x - 6, y - 2 + (left > 0 ? -left : 0), 5, 3);
|
||||
ctx.fillRect(x + 1, y - 2 + (right > 0 ? -right : 0), 5, 3);
|
||||
}
|
||||
|
||||
function drawTorso(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
appearance: SpriteAppearance,
|
||||
pose: PersonPose,
|
||||
isCeo: boolean,
|
||||
): void {
|
||||
const top = y - 20;
|
||||
if (appearance.outfit === "blazer") {
|
||||
ctx.fillStyle = isCeo ? BLAZER_CEO : BLAZER;
|
||||
ctx.fillRect(x - 8, top, 16, 12);
|
||||
if (pose.facing !== "north") {
|
||||
ctx.fillStyle = "#e8e6df";
|
||||
ctx.fillRect(x - 2, top, 4, 7);
|
||||
ctx.fillStyle = appearance.accent;
|
||||
ctx.fillRect(x - 1, top + 1, 2, 6);
|
||||
}
|
||||
ctx.fillStyle = isCeo ? BLAZER_CEO : BLAZER;
|
||||
ctx.fillRect(x - 10, top + 1, 2, 9);
|
||||
ctx.fillRect(x + 8, top + 1, 2, 9);
|
||||
ctx.fillStyle = appearance.skin;
|
||||
ctx.fillRect(x - 10, top + 10, 2, 2);
|
||||
ctx.fillRect(x + 8, top + 10, 2, 2);
|
||||
return;
|
||||
}
|
||||
if (appearance.outfit === "hoodie") {
|
||||
ctx.fillStyle = appearance.shirt;
|
||||
ctx.fillRect(x - 8, top, 16, 12);
|
||||
ctx.fillRect(x - 10, top + 1, 2, 9);
|
||||
ctx.fillRect(x + 8, top + 1, 2, 9);
|
||||
ctx.fillStyle = shade(appearance.shirt);
|
||||
ctx.fillRect(x - 8, top, 16, 3);
|
||||
if (pose.facing !== "north") ctx.fillRect(x - 4, top + 7, 8, 4);
|
||||
ctx.fillStyle = appearance.skin;
|
||||
ctx.fillRect(x - 10, top + 10, 2, 2);
|
||||
ctx.fillRect(x + 8, top + 10, 2, 2);
|
||||
return;
|
||||
}
|
||||
ctx.fillStyle = appearance.shirt;
|
||||
ctx.fillRect(x - 8, top, 16, 12);
|
||||
ctx.fillRect(x - 10, top + 1, 2, 4);
|
||||
ctx.fillRect(x + 8, top + 1, 2, 4);
|
||||
ctx.fillStyle = appearance.skin;
|
||||
ctx.fillRect(x - 10, top + 5, 2, 7);
|
||||
ctx.fillRect(x + 8, top + 5, 2, 7);
|
||||
}
|
||||
|
||||
function drawHead(ctx: CanvasRenderingContext2D, x: number, y: number, appearance: SpriteAppearance, facing: Facing): void {
|
||||
const top = y - 33;
|
||||
ctx.fillStyle = appearance.skin;
|
||||
ctx.fillRect(x - 7, top, 14, 13);
|
||||
|
||||
if (facing === "north") {
|
||||
if (appearance.hairStyle !== "bald") {
|
||||
ctx.fillStyle = appearance.hair;
|
||||
ctx.fillRect(x - 7, top, 14, 9);
|
||||
}
|
||||
drawHairEdges(ctx, x, y, appearance, facing);
|
||||
return;
|
||||
}
|
||||
|
||||
drawHairEdges(ctx, x, y, appearance, facing);
|
||||
ctx.fillStyle = "#23232a";
|
||||
if (facing === "south") {
|
||||
ctx.fillRect(x - 4, top + 6, 2, 2);
|
||||
ctx.fillRect(x + 2, top + 6, 2, 2);
|
||||
} else if (facing === "east") {
|
||||
ctx.fillRect(x + 3, top + 6, 2, 2);
|
||||
} else {
|
||||
ctx.fillRect(x - 5, top + 6, 2, 2);
|
||||
}
|
||||
if (appearance.glasses) drawGlasses(ctx, x, top, facing);
|
||||
}
|
||||
|
||||
function drawHairEdges(ctx: CanvasRenderingContext2D, x: number, y: number, appearance: SpriteAppearance, facing: Facing): void {
|
||||
const top = y - 33;
|
||||
if (appearance.hairStyle === "bald") return;
|
||||
ctx.fillStyle = appearance.hair;
|
||||
switch (appearance.hairStyle) {
|
||||
case "buzz":
|
||||
ctx.fillRect(x - 7, top, 14, 3);
|
||||
break;
|
||||
case "short":
|
||||
ctx.fillRect(x - 7, top, 14, 4);
|
||||
ctx.fillRect(x - 7, top + 4, 2, 3);
|
||||
ctx.fillRect(x + 5, top + 4, 2, 3);
|
||||
break;
|
||||
case "bob":
|
||||
ctx.fillRect(x - 7, top, 14, 4);
|
||||
ctx.fillRect(x - 8, top + 2, 2, 9);
|
||||
ctx.fillRect(x + 6, top + 2, 2, 9);
|
||||
break;
|
||||
case "long":
|
||||
ctx.fillRect(x - 7, top, 14, 4);
|
||||
ctx.fillRect(x - 9, top + 2, 3, 15);
|
||||
ctx.fillRect(x + 6, top + 2, 3, 15);
|
||||
break;
|
||||
case "ponytail":
|
||||
ctx.fillRect(x - 7, top, 14, 4);
|
||||
if (facing === "north") ctx.fillRect(x - 2, top + 4, 4, 10);
|
||||
else if (facing === "east") ctx.fillRect(x - 9, top + 3, 3, 8);
|
||||
else if (facing === "west") ctx.fillRect(x + 6, top + 3, 3, 8);
|
||||
else ctx.fillRect(x + 6, top + 1, 2, 5);
|
||||
break;
|
||||
case "curly":
|
||||
ctx.fillRect(x - 7, top, 14, 4);
|
||||
for (let i = 0; i < 7; i++) ctx.fillRect(x - 7 + i * 2, top + 3 + (i % 2), 2, 2);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function drawGlasses(ctx: CanvasRenderingContext2D, x: number, headTop: number, facing: Facing): void {
|
||||
ctx.fillStyle = "#1c1c22";
|
||||
if (facing === "south") {
|
||||
ctx.fillRect(x - 5, headTop + 5, 4, 1);
|
||||
ctx.fillRect(x + 1, headTop + 5, 4, 1);
|
||||
ctx.fillRect(x - 5, headTop + 8, 4, 1);
|
||||
ctx.fillRect(x + 1, headTop + 8, 4, 1);
|
||||
ctx.fillRect(x - 5, headTop + 5, 1, 4);
|
||||
ctx.fillRect(x - 2, headTop + 5, 1, 4);
|
||||
ctx.fillRect(x + 1, headTop + 5, 1, 4);
|
||||
ctx.fillRect(x + 4, headTop + 5, 1, 4);
|
||||
ctx.fillRect(x - 1, headTop + 6, 2, 1);
|
||||
} else if (facing === "east") {
|
||||
ctx.fillRect(x + 2, headTop + 5, 4, 1);
|
||||
ctx.fillRect(x + 2, headTop + 8, 4, 1);
|
||||
ctx.fillRect(x + 2, headTop + 5, 1, 4);
|
||||
ctx.fillRect(x + 5, headTop + 5, 1, 4);
|
||||
} else if (facing === "west") {
|
||||
ctx.fillRect(x - 6, headTop + 5, 4, 1);
|
||||
ctx.fillRect(x - 6, headTop + 8, 4, 1);
|
||||
ctx.fillRect(x - 6, headTop + 5, 1, 4);
|
||||
ctx.fillRect(x - 3, headTop + 5, 1, 4);
|
||||
}
|
||||
}
|
||||
|
||||
function drawCarriedItem(ctx: CanvasRenderingContext2D, x: number, y: number, item: Carrying, facing: Facing): void {
|
||||
const side = facing === "west" ? -12 : 10;
|
||||
const ix = x + side;
|
||||
const iy = y - 14;
|
||||
if (item === "coffee") {
|
||||
ctx.fillStyle = "#f0ece2";
|
||||
ctx.fillRect(ix, iy, 4, 5);
|
||||
ctx.fillStyle = "#6b4a2f";
|
||||
ctx.fillRect(ix, iy, 4, 1);
|
||||
ctx.fillStyle = "rgba(240,240,245,0.7)";
|
||||
ctx.fillRect(ix + 1, iy - 3, 1, 2);
|
||||
} else {
|
||||
ctx.fillStyle = "#c9553e";
|
||||
ctx.fillRect(ix, iy, 5, 4);
|
||||
ctx.fillStyle = "#e8b84a";
|
||||
ctx.fillRect(ix + 1, iy + 1, 3, 2);
|
||||
}
|
||||
}
|
||||
|
||||
function shade(hex: string): string {
|
||||
const value = parseInt(hex.slice(1), 16);
|
||||
const r = Math.max(0, (value >> 16) - 36);
|
||||
const g = Math.max(0, ((value >> 8) & 0xff) - 36);
|
||||
const b = Math.max(0, (value & 0xff) - 36);
|
||||
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, "0")}`;
|
||||
const frame = frameFor(pose);
|
||||
const breath = frame.step < 0 && frame.typing < 0 && Math.floor(pose.timeMs / 1100 + pose.animSeed) % 3 === 0 ? -0.5 : 0;
|
||||
const ax = Math.round(x * 2) / 2;
|
||||
const ay = Math.round(y * 2) / 2;
|
||||
ctx.fillStyle = "rgba(24,18,12,0.3)";
|
||||
ctx.fillRect(ax - 6, ay - 0.5, 12, 1.5);
|
||||
ctx.fillRect(ax - 4, ay + 1, 8, 1);
|
||||
ctx.drawImage(spriteFor(appearance, frame, isCeo), ax - ANCHOR.x / 2, ay + breath - ANCHOR.y / 2, ART_W / 2, ART_H / 2);
|
||||
}
|
||||
@@ -5,11 +5,10 @@ import type { SnapshotActions } from "../../app/useSnapshot";
|
||||
import { Dialog } from "../../shared/Dialog";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
/** Shows what a disconnect stops before it happens, then its outcome, including a provider that refused to revoke. */
|
||||
export function DisconnectProviderDialog({ orgId, provider, revokesRemotely, actions, onClose }: {
|
||||
/** Shows what a disconnect stops before it happens, then its outcome. */
|
||||
export function DisconnectProviderDialog({ orgId, provider, actions, onClose }: {
|
||||
orgId: string;
|
||||
provider: ProviderRecord;
|
||||
revokesRemotely: boolean;
|
||||
actions: SnapshotActions;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
@@ -55,9 +54,7 @@ export function DisconnectProviderDialog({ orgId, provider, revokesRemotely, act
|
||||
{impact.agents.length > 0 && <li>{t("Open tasks assigned to them: {count}.", { count: impact.openTasks })}</li>}
|
||||
<li>{t("Running sessions that stop now: {count}.", { count: impact.runningRuns })}</li>
|
||||
<li>{t("Queued sessions that will fail when they start: {count}.", { count: impact.queuedRuns })}</li>
|
||||
<li>{revokesRemotely
|
||||
? t("Revokes the sign-in at {provider} and erases it from this computer.", { provider: provider.label })
|
||||
: t("Erases the key from this computer. {provider} keeps accepting it until you revoke it in your account.", { provider: provider.label })}</li>
|
||||
<li>{t("Erases the key from this computer. {provider} keeps accepting it until you revoke it in your account.", { provider: provider.label })}</li>
|
||||
</ul>
|
||||
)}
|
||||
{state.kind === "error" && <div className="form-error" role="alert">{state.message}</div>}
|
||||
|
||||
@@ -16,11 +16,6 @@ const KEY_COPY: Record<IntegrationKeyName, { label: string; hint: string; placeh
|
||||
hint: t("Shows brand logos for connectors. Create one at logo.dev."),
|
||||
placeholder: "pk_…",
|
||||
},
|
||||
google_antigravity_client_secret: {
|
||||
label: t("Google Antigravity OAuth client secret"),
|
||||
hint: t("Needed to sign in to and refresh the Google Antigravity provider."),
|
||||
placeholder: "GOCSPX-…",
|
||||
},
|
||||
};
|
||||
|
||||
export function IntegrationKeysPanel({ keys, companyName, actions }: { keys: IntegrationKeyStatus[]; companyName: string; actions: SnapshotActions }) {
|
||||
@@ -31,12 +26,12 @@ export function IntegrationKeysPanel({ keys, companyName, actions }: { keys: Int
|
||||
<p style={{ margin: "0 0 24px", color: "var(--text-2)" }}>
|
||||
{t("Keys are stored on this device, encrypted by the operating system, and shared by every company.")}
|
||||
</p>
|
||||
{keys.map((status) => <KeyRow key={status.name} status={status} actions={actions} />)}
|
||||
{keys.map((status) => <IntegrationKeyRow key={status.name} status={status} actions={actions} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyRow({ status, actions }: { status: IntegrationKeyStatus; actions: SnapshotActions }) {
|
||||
export function IntegrationKeyRow({ status, actions }: { status: IntegrationKeyStatus; actions: SnapshotActions }) {
|
||||
const copy = KEY_COPY[status.name];
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -93,7 +93,6 @@ export function ProvidersPanel({ orgId, companyName, employeeId, providers, auth
|
||||
onManage={() => setActive(provider)}
|
||||
onChangeModel={provider.providerKey === "azure-openai" ? null : () => openModel(provider)}
|
||||
onRevoke={() => setDisconnecting(provider)}
|
||||
revokesRemotely={requireCatalogEntry(catalog, provider.providerKey).revokesRemotely}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -151,7 +150,6 @@ export function ProvidersPanel({ orgId, companyName, employeeId, providers, auth
|
||||
key={disconnecting.id}
|
||||
orgId={orgId}
|
||||
provider={disconnecting}
|
||||
revokesRemotely={requireCatalogEntry(catalog, disconnecting.providerKey).revokesRemotely}
|
||||
actions={actions}
|
||||
onClose={() => setDisconnecting(null)}
|
||||
/>
|
||||
@@ -235,7 +233,6 @@ function ProviderRow({
|
||||
onManage,
|
||||
onChangeModel,
|
||||
onRevoke,
|
||||
revokesRemotely,
|
||||
}: {
|
||||
provider: ProviderRecord;
|
||||
authProfile: ProviderAuthProfileRecord | null;
|
||||
@@ -245,7 +242,6 @@ function ProviderRow({
|
||||
/** Null where the model lives in the credential (Azure's deployment). */
|
||||
onChangeModel: (() => void) | null;
|
||||
onRevoke: () => void;
|
||||
revokesRemotely: boolean;
|
||||
}) {
|
||||
const boundCount = agents.filter((agent) => agent.modelProviderId === provider.id).length;
|
||||
return (
|
||||
@@ -279,9 +275,7 @@ function ProviderRow({
|
||||
<Button
|
||||
tone="ghost"
|
||||
onClick={onRevoke}
|
||||
title={revokesRemotely
|
||||
? t("Revokes the sign-in at {provider} and erases it from this computer.", { provider: provider.label })
|
||||
: t("Erases the key from this computer. {provider} keeps accepting it until you revoke it in your account.", { provider: provider.label })}
|
||||
title={t("Erases the key from this computer. {provider} keeps accepting it until you revoke it in your account.", { provider: provider.label })}
|
||||
>
|
||||
{t("Disconnect")}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ProviderCatalogEntry, ProviderRecord } from "../../../../shared/domain";
|
||||
import type { ProviderModelEntry } from "../../../../shared/provider-models";
|
||||
import { Field } from "../../shared/ui";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
type Fetched = { providerId: string; models: ProviderModelEntry[] } | { providerId: string; error: string };
|
||||
|
||||
/** An agent's model: the provider's whole list (live from a connected provider, else its catalog), blank meaning the provider's default. */
|
||||
export function AgentModelField({ provider, entry, value, onChange }: {
|
||||
provider: ProviderRecord | null;
|
||||
entry: ProviderCatalogEntry | null;
|
||||
value: string;
|
||||
onChange: (model: string) => void;
|
||||
}) {
|
||||
const live = Boolean(provider && entry?.supportsLiveModels && provider.credentialId && provider.enabled && provider.authState === "configured");
|
||||
const [fetched, setFetched] = useState<Fetched | null>(null);
|
||||
const providerId = provider?.id ?? null;
|
||||
const providerKey = provider?.providerKey ?? null;
|
||||
const orgId = provider?.orgId ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!live || !providerId || !providerKey || !orgId) return;
|
||||
let current = true;
|
||||
void window.agentCompany.listProviderModels({ providerKey, orgId, providerId }).then((result) => {
|
||||
if (!current) return;
|
||||
setFetched(result.ok ? { providerId, models: result.data } : { providerId, error: result.error.message });
|
||||
});
|
||||
return () => {
|
||||
current = false;
|
||||
};
|
||||
}, [live, providerId, providerKey, orgId]);
|
||||
|
||||
const mine = fetched && fetched.providerId === providerId ? fetched : null;
|
||||
const models = mine && "models" in mine ? mine.models : entry?.models ?? [];
|
||||
const known = models.some((model) => model.id === value);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field label={t("Model")}>
|
||||
{!provider || models.length === 0 ? (
|
||||
<input className="input" name="modelId" value={value} disabled={!provider} placeholder={provider?.model ?? ""} onChange={(event) => onChange(event.currentTarget.value)} />
|
||||
) : (
|
||||
<select className="input" name="modelId" value={value} onChange={(event) => onChange(event.currentTarget.value)}>
|
||||
<option value="">{t("{model} (provider default)", { model: provider.model ?? "" })}</option>
|
||||
{value && !known && <option value={value}>{value}</option>}
|
||||
{models.map((model) => <option key={model.id} value={model.id}>{model.name === model.id ? model.id : `${model.name} · ${model.id}`}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
{live && !mine && <div style={{ color: "var(--text-3)" }}>{t("Loading models...")}</div>}
|
||||
{mine && "error" in mine && <div className="form-error" role="alert">{mine.error}</div>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import openrouterLogo from "../assets/provider-logos/openrouter.png";
|
||||
import kimiLogo from "../assets/provider-logos/kimi.png";
|
||||
import kimiCnLogo from "../assets/provider-logos/kimi-cn.png";
|
||||
import githubCopilotLogo from "../assets/provider-logos/github-copilot.png";
|
||||
import googleAntigravityLogo from "../assets/provider-logos/google-antigravity.png";
|
||||
import gptAccountLogo from "../assets/provider-logos/gpt-account.png";
|
||||
import azureOpenAiLogo from "../assets/provider-logos/azure-openai.png";
|
||||
import awsBedrockLogo from "../assets/provider-logos/aws-bedrock.png";
|
||||
@@ -35,7 +34,6 @@ const PROVIDER_LOGOS: Record<string, string> = {
|
||||
"kimi-cn": kimiCnLogo,
|
||||
"kimi-code": kimiLogo,
|
||||
"github-copilot": githubCopilotLogo,
|
||||
"google-antigravity": googleAntigravityLogo,
|
||||
"gpt-account": gptAccountLogo,
|
||||
"azure-openai": azureOpenAiLogo,
|
||||
"aws-bedrock": awsBedrockLogo,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Focus } from "../pages/types";
|
||||
import type { RouteId } from "./routes";
|
||||
import { connectorsEnabled, type RouteId } from "./routes";
|
||||
import { shortcutLabel } from "../i18n/labels";
|
||||
import { isBusinessTask } from "../pages/work/task-helpers";
|
||||
import { unreadBySpace } from "../pages/chat/chat-helpers";
|
||||
@@ -98,7 +98,7 @@ export function Shell({
|
||||
<span className="search-kbd">{shortcutLabel("K")}</span>
|
||||
</button>
|
||||
<nav className="nav">
|
||||
{allNav.map((item) => (
|
||||
{allNav.filter((item) => item.id !== "connectors" || connectorsEnabled(snapshot)).map((item) => (
|
||||
<NavButton key={item.id} item={item} active={route === item.id} badge={badges[item.id]} onSelect={setRoute} />
|
||||
))}
|
||||
</nav>
|
||||
|
||||
@@ -1,2 +1,9 @@
|
||||
import type { AppSnapshot } from "../../../shared/domain";
|
||||
|
||||
export const ROUTE_IDS = ["home", "team", "work", "chat", "files", "library", "runs", "approvals", "connectors", "office", "settings"] as const;
|
||||
export type RouteId = (typeof ROUTE_IDS)[number];
|
||||
|
||||
/** Connectors exist once a Composio key is saved. */
|
||||
export function connectorsEnabled(snapshot: AppSnapshot): boolean {
|
||||
return snapshot.integrationKeys.some((key) => key.name === "composio" && key.configured);
|
||||
}
|
||||
Reference in new issue
Block a user