`decimals` only reached `formatNumber`, so it changed the label and nothing else: typing 640.5 into a field with `decimals: 0` stored 640.5 and displayed 641. The two existing users got away with it because rounding happened outside the widget (`normalizeFrameRect`, the slice dialog), which their comments had to point out. `NumberField.clampValue` now rounds before it clamps, and every path that sets a value goes through it (typing, scrubbing, arrow keys, the constructor), so the stored number matches the shown one and `min`/`max` stay exact. The rounding is `roundTo` in math.ts, which `snapToStep` already did inline. With that safe, camera `View width`/`View height` and text `Font size` become integers – they were showing 640.000 and 32.000. Everything else that looked integral is not: positions, sprite and collider sizes and zoom are all scrubbed, and `z` is a summed coordinate (walk.ts), not a layer. Verified remotely against the RTX 4070 host: 270 checks, all passed (step 10 gained a check that the field text has no decimal point and survives a reopen). `npm run check` green, 407 tests.
194 lines
7.4 KiB
TypeScript
194 lines
7.4 KiB
TypeScript
import type { Asset } from '../model/Asset';
|
||
import { type AssetType, isPlaceableType } from '../model/AssetTypes';
|
||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||
|
||
/** The inspector is generated from these entries, so a new property is one line in a schema below. */
|
||
export type PropertyDef =
|
||
/**
|
||
* `decimals` belongs only on properties that are integral by meaning (counts, sizes in whole
|
||
* pixels), not on geometry: dragging produces fractions, and the field rounds the value away
|
||
* rather than hiding it.
|
||
*/
|
||
| {
|
||
key: string;
|
||
label: string;
|
||
kind: 'number';
|
||
default: number;
|
||
min?: number;
|
||
max?: number;
|
||
step?: number;
|
||
decimals?: number;
|
||
}
|
||
| { key: string; label: string; kind: 'text'; default: string }
|
||
| { key: string; label: string; kind: 'color'; default: number }
|
||
| { key: string; label: string; kind: 'boolean'; default: boolean }
|
||
| { key: string; label: string; kind: 'enum'; default: string; options: { value: string; label: string }[] }
|
||
/** A keyboard key name (`KeyboardEvent.key`), captured by pressing it. */
|
||
| { key: string; label: string; kind: 'key'; default: string }
|
||
/**
|
||
* An asset id (`''` = none); without `assetTypes` the picker offers every placeable asset.
|
||
* Ids are not validated on load, so a reference can dangle and readers must cope.
|
||
*/
|
||
| { key: string; label: string; kind: 'asset'; default: string; assetTypes?: readonly AssetType[] }
|
||
/**
|
||
* The id of a texture asset or of one of its frames. Its own kind rather than an `asset`
|
||
* reference, because a frame is not an asset and the picker has to offer both. May dangle.
|
||
*/
|
||
| { key: string; label: string; kind: 'texture'; default: string };
|
||
|
||
export type PropertySchema = readonly PropertyDef[];
|
||
|
||
export const VARIABLE_KINDS = ['number', 'boolean', 'text'] as const;
|
||
export type VariableKind = (typeof VARIABLE_KINDS)[number];
|
||
|
||
/**
|
||
* Properties every placeable asset has. A disabled object is not drawn, does not collide and its
|
||
* behavior does not run – and the same goes for everything parented below it.
|
||
*/
|
||
export const COMMON_PROPS: PropertySchema = [
|
||
{ key: 'enabled', label: 'Enabled', kind: 'boolean', default: true },
|
||
];
|
||
|
||
const COMMON_KEYS = new Set(COMMON_PROPS.map((d) => d.key));
|
||
|
||
export function isCommonProp(key: string): boolean {
|
||
return COMMON_KEYS.has(key);
|
||
}
|
||
|
||
/** Position is deliberately absent: it belongs to scene objects, not to asset types. */
|
||
const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||
scene: [{ key: 'backgroundColor', label: 'Background', kind: 'color', default: 0x2a2d34 }],
|
||
camera: [
|
||
// A view is a pixel raster, so half a pixel of it means nothing.
|
||
{ key: 'width', label: 'View width', kind: 'number', default: 640, min: 16, step: 1, decimals: 0 },
|
||
{ key: 'height', label: 'View height', kind: 'number', default: 360, min: 16, step: 1, decimals: 0 },
|
||
{ key: 'zoom', label: 'Zoom', kind: 'number', default: 1, min: 0.1, max: 10, step: 0.1 },
|
||
],
|
||
sprite: [
|
||
{ key: 'width', label: 'Width', kind: 'number', default: 100, min: 1, step: 1 },
|
||
{ key: 'height', label: 'Height', kind: 'number', default: 100, min: 1, step: 1 },
|
||
// The fill while there is no picture, and a tint multiplier once there is one: white = no tint.
|
||
{ key: 'color', label: 'Color', kind: 'color', default: 0xffffff },
|
||
{ key: 'texture', label: 'Texture', kind: 'texture', default: '' },
|
||
],
|
||
// The picture itself is intrinsic data on the asset (see TextureAsset), not a property.
|
||
texture: [
|
||
{
|
||
key: 'filter',
|
||
label: 'Filter',
|
||
kind: 'enum',
|
||
default: 'linear',
|
||
options: [
|
||
{ value: 'linear', label: 'Smooth' },
|
||
{ value: 'nearest', label: 'Pixel art' },
|
||
],
|
||
},
|
||
],
|
||
// Behaviors declare their own properties (variables) per asset – see `schemaForAsset`.
|
||
behavior: [],
|
||
collider: [
|
||
{
|
||
key: 'shape',
|
||
label: 'Shape',
|
||
kind: 'enum',
|
||
default: 'box',
|
||
options: [
|
||
{ value: 'box', label: 'Box' },
|
||
{ value: 'circle', label: 'Circle' },
|
||
],
|
||
},
|
||
{ key: 'width', label: 'Width', kind: 'number', default: 100, min: 1, step: 1 },
|
||
{ key: 'height', label: 'Height', kind: 'number', default: 100, min: 1, step: 1 },
|
||
{ key: 'radius', label: 'Radius', kind: 'number', default: 50, min: 1, step: 1 },
|
||
{ key: 'isTrigger', label: 'Trigger (no blocking)', kind: 'boolean', default: false },
|
||
],
|
||
// A container is pure structure: its content is the template, not a property.
|
||
container: [],
|
||
text: [
|
||
{ key: 'text', label: 'Text', kind: 'text', default: 'Text' },
|
||
{ key: 'fontSize', label: 'Font size', kind: 'number', default: 32, min: 1, step: 1, decimals: 0 },
|
||
{ key: 'color', label: 'Color', kind: 'color', default: 0xffffff },
|
||
{
|
||
key: 'align',
|
||
label: 'Align',
|
||
kind: 'enum',
|
||
default: 'center',
|
||
options: [
|
||
{ value: 'left', label: 'Left' },
|
||
{ value: 'center', label: 'Center' },
|
||
{ value: 'right', label: 'Right' },
|
||
],
|
||
},
|
||
],
|
||
};
|
||
|
||
const FULL_SCHEMAS: Record<AssetType, PropertySchema> = Object.fromEntries(
|
||
(Object.keys(ASSET_SCHEMAS) as AssetType[]).map((type) => [
|
||
type,
|
||
isPlaceableType(type) ? [...COMMON_PROPS, ...ASSET_SCHEMAS[type]] : ASSET_SCHEMAS[type],
|
||
]),
|
||
) as Record<AssetType, PropertySchema>;
|
||
|
||
export function schemaFor(type: AssetType): PropertySchema {
|
||
return FULL_SCHEMAS[type];
|
||
}
|
||
|
||
/** The type schema, or for behaviors their declared variables. */
|
||
export function schemaForAsset(asset: Asset): PropertySchema {
|
||
return asset.type === 'behavior' ? [...COMMON_PROPS, ...asset.variables] : FULL_SCHEMAS[asset.type];
|
||
}
|
||
|
||
export function propDef(asset: Asset, key: string): PropertyDef | undefined {
|
||
return schemaForAsset(asset).find((d) => d.key === key);
|
||
}
|
||
|
||
/** A fresh bag of the type's default values (callers may mutate it). */
|
||
export function defaultsFor(type: AssetType): PropertyBag {
|
||
return defaultsOf(FULL_SCHEMAS[type]);
|
||
}
|
||
|
||
export function defaultsOf(schema: PropertySchema): PropertyBag {
|
||
const bag: PropertyBag = {};
|
||
for (const def of schema) bag[def.key] = def.default;
|
||
return bag;
|
||
}
|
||
|
||
export function variableDef(key: string, kind: VariableKind, defaultValue?: PropertyValue): PropertyDef {
|
||
switch (kind) {
|
||
case 'number':
|
||
return { key, label: key, kind, default: typeof defaultValue === 'number' ? defaultValue : 0 };
|
||
case 'boolean':
|
||
return { key, label: key, kind, default: Boolean(defaultValue ?? false) };
|
||
case 'text':
|
||
return { key, label: key, kind, default: defaultValue === undefined ? '' : String(defaultValue) };
|
||
}
|
||
}
|
||
|
||
/** Clamps / coerces a raw value so it satisfies the definition. */
|
||
export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyValue {
|
||
switch (def.kind) {
|
||
case 'number': {
|
||
let n = typeof value === 'number' ? value : Number(value);
|
||
if (!Number.isFinite(n)) n = def.default;
|
||
if (def.min !== undefined) n = Math.max(def.min, n);
|
||
if (def.max !== undefined) n = Math.min(def.max, n);
|
||
return n;
|
||
}
|
||
case 'color': {
|
||
const n = typeof value === 'number' ? value : parseInt(String(value).replace('#', ''), 16);
|
||
return Number.isFinite(n) ? Math.max(0, Math.min(0xffffff, Math.floor(n))) : def.default;
|
||
}
|
||
case 'boolean':
|
||
return Boolean(value);
|
||
case 'text':
|
||
case 'key':
|
||
case 'asset':
|
||
case 'texture':
|
||
return String(value);
|
||
case 'enum': {
|
||
const s = String(value);
|
||
return def.options.some((o) => o.value === s) ? s : def.default;
|
||
}
|
||
}
|
||
}
|