Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,35 @@ All notable changes to wc3-forge are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.0.5] - 2026-07-06

### Added

- **Keyboard shortcuts + cheat sheet.** World Editor function keys now work:
**F4** opens the Trigger Editor, **F6** the Object Editor, and **F2 / F3 /
F8** switch the viewport editor mode (Doodad / Terrain / Region). Press **`?`**
(or Help → Keyboard Shortcuts) for an overlay listing every binding.
Previously the menu advertised F4/F6 but the keys did nothing.

### Fixed

- **Reforged custom object data now loads (units, items, abilities, …).** In
Reforged, a custom object's data is split across two tables per kind:
`war3map.w3*` holds the gameplay fields and `war3mapSkin.w3*` holds the
art/skin overrides — **Name**, **Model File**, **Icon**, **Tooltip**.
wc3-forge read only the first, so every custom unit loaded the right count
but displayed the base type's name + default model. The skin companion tables
are now loaded and merged on top of the base (and preserved losslessly on
save), so custom units show their real name, model, and icon.
- **Custom-skin units render with their overridden model.** A custom unit that
swaps its Model File (e.g. a Peasant-based unit using another model) now
renders that model in the viewport and the Object Editor preview instead of
falling back to the base type's model.
- **Terrain palette populates on first entry.** The ground-tile grid now loads
the moment Terrain mode opens (it could previously show "No ground tiles." on
a map full of terrain), and the empty state names the actual reason — a map
with no terrain file vs. a load error you can retry.

## [1.0.4] - 2026-06-04

### Added
Expand Down
136 changes: 136 additions & 0 deletions frontend/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,46 @@
// tree+field-table render off the DOM until it's actually wanted.
let showObjectEditor: boolean = $state(false)
let objectEditorInitialId: string | null = $state(null)
// Keyboard-shortcuts cheat sheet overlay. Toggled by '?' (Shift+/) or the
// Help menu — the discoverable index of every binding, aimed at World Editor
// veterans hunting for the F-keys.
let showShortcuts: boolean = $state(false)
// Static content for the cheat sheet. Keep in lockstep with the bindings in
// onGlobalKeyDown (+ the camera-pan keys in camera.ts).
const shortcutGroups: { title: string; items: { label: string; keys: string[] }[] }[] = [
{ title: 'Editors', items: [
{ label: 'Object Editor', keys: ['F6'] },
{ label: 'Trigger Editor', keys: ['F4'] },
{ label: 'Map Info', keys: ['Ctrl+Shift+I'] },
] },
{ title: 'Editor mode', items: [
{ label: 'Doodad / Unit mode', keys: ['F2'] },
{ label: 'Terrain mode', keys: ['F3'] },
{ label: 'Region mode', keys: ['F8'] },
{ label: 'Cycle modes', keys: ['Tab', 'Shift+Tab'] },
] },
{ title: 'Edit', items: [
{ label: 'Save', keys: ['Ctrl+S'] },
{ label: 'Undo', keys: ['Ctrl+Z'] },
{ label: 'Redo', keys: ['Ctrl+Shift+Z'] },
{ label: 'Delete selection', keys: ['Del', 'Backspace'] },
] },
{ title: 'Transform', items: [
{ label: 'Move', keys: ['1'] },
{ label: 'Rotate', keys: ['2'] },
{ label: 'Scale', keys: ['3'] },
] },
{ title: 'View & camera', items: [
{ label: 'Pan camera', keys: ['WASD', 'Arrows'] },
{ label: 'Frame selection', keys: ['F'] },
{ label: 'Minimap', keys: ['M'] },
{ label: 'Diagnostics', keys: ['F9'] },
{ label: 'Agent Console', keys: ['Ctrl+`'] },
] },
{ title: 'Help', items: [
{ label: 'This cheat sheet', keys: ['?'] },
] },
]
function openObjectEditor(id: string | null = null) {
if (!status.loaded) return
objectEditorInitialId = id
Expand Down Expand Up @@ -1486,6 +1526,43 @@
e.preventDefault()
toggleDiagnostics()
}
// Editor / mode function keys — World Editor muscle memory. Function keys
// aren't text input, so no input-focus gate; bare (no modifiers). F4 →
// Trigger Editor, F6 → Object Editor (matches the classic WE F-key layout
// and the shortcut labels already shown in the menu — previously those
// labels were advertised but the keys were never wired). openTriggerEditor
// / openObjectEditor self-gate on a loaded map.
if (e.key === 'F4' && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey) {
e.preventDefault()
openTriggerEditor()
}
if (e.key === 'F6' && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey) {
e.preventDefault()
openObjectEditor()
}
// F2 / F3 / F8 switch the viewport editor mode (Doodad / Terrain / Region).
// Like the Tab cycle, these need a loaded map (modes are inert without one).
if ((e.key === 'F2' || e.key === 'F3' || e.key === 'F8')
&& !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey) {
if (!status.loaded) return
e.preventDefault()
setEditorMode(e.key === 'F2' ? 'doodad' : e.key === 'F3' ? 'terrain' : 'region')
}
// Keyboard-shortcuts cheat sheet: '?' (Shift+/ on US layouts). Toggles the
// overlay listing every binding. Skip while typing so '?' still types into
// a field. Escape (handled in the overlay) also dismisses it.
if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) {
const tgt = e.target as HTMLElement | null
const tagName = tgt?.tagName?.toLowerCase()
if (tagName === 'input' || tagName === 'textarea' || tgt?.isContentEditable) return
e.preventDefault()
showShortcuts = !showShortcuts
}
// Escape closes the shortcuts overlay when it's the topmost surface.
if (e.key === 'Escape' && showShortcuts) {
e.preventDefault()
showShortcuts = false
}
// Frame-selected hotkey: bare 'f'. Centers the camera on the current
// selection's centroid and zooms in to fit. Matches the Blender / Maya
// / Unity muscle memory. Same input-focus gate as 'M' so typing in the
Expand Down Expand Up @@ -3109,6 +3186,14 @@
<DropdownMenu.Shortcut>v{appVersion}</DropdownMenu.Shortcut>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={runMenuAction(() => { showShortcuts = true })}
title="Show every keyboard shortcut."
>
<span class="flex-1">Keyboard Shortcuts…</span>
<DropdownMenu.Shortcut>?</DropdownMenu.Shortcut>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={runMenuAction(() => { showAboutDialog = true })}
title="About wc3-forge — version, author, and credits."
Expand Down Expand Up @@ -3779,6 +3864,57 @@

<BridgeConsole bind:open={bridgeConsoleOpen} />

{#if showShortcuts}
<!-- Keyboard-shortcuts cheat sheet. Toggled by '?' / the Help menu — the
discoverable index of every binding (World Editor veterans expect the
F-keys). Backdrop click, the × button, '?', or Escape dismiss it. -->
<div
class="fixed inset-0 z-[200] flex items-center justify-center bg-black/60 p-4"
role="button"
tabindex="-1"
aria-label="Close keyboard shortcuts"
onclick={() => (showShortcuts = false)}
>
<div
class="max-h-[85vh] w-full max-w-2xl overflow-y-auto rounded-lg border border-border bg-background p-5 shadow-xl"
role="dialog"
tabindex="-1"
aria-modal="true"
aria-label="Keyboard shortcuts"
onclick={(e) => e.stopPropagation()}
>
<div class="mb-3 flex items-center justify-between">
<h2 class="text-base font-semibold">Keyboard Shortcuts</h2>
<button type="button" class="rounded px-2 py-0.5 text-muted-foreground hover:bg-muted"
title="Close" onclick={() => (showShortcuts = false)}>×</button>
</div>
<div class="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2">
{#each shortcutGroups as group}
<div>
<h3 class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{group.title}</h3>
<dl class="space-y-1">
{#each group.items as item}
<div class="flex items-baseline justify-between gap-3">
<dt class="text-sm">{item.label}</dt>
<dd class="shrink-0 space-x-0.5 text-xs text-muted-foreground">
{#each item.keys as key, i}
<kbd class="rounded border border-border bg-muted px-1.5 py-0.5 font-mono">{key}</kbd>{#if i < item.keys.length - 1}<span class="text-muted-foreground">/</span>{/if}
{/each}
</dd>
</div>
{/each}
</dl>
</div>
{/each}
</div>
<p class="mt-4 text-xs text-muted-foreground">
Press <kbd class="rounded border border-border bg-muted px-1 py-0.5 font-mono">?</kbd>
or <kbd class="rounded border border-border bg-muted px-1 py-0.5 font-mono">Esc</kbd> to close.
</p>
</div>
</div>
{/if}

{#if showMapInfoEditor}
<MapInfoEditor
bind:open={showMapInfoEditor}
Expand Down
35 changes: 34 additions & 1 deletion frontend/src/TerrainPalette.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
// average RGB swatch colors, and texture path stems).
import { GetTerrain, GetTerrainPaletteThumbs } from '../wailsjs/go/main/App.js'
import { EventsOn } from '../wailsjs/runtime/runtime.js'
import { onMount } from 'svelte'
import MountainIcon from '@lucide/svelte/icons/mountain'
import XIcon from '@lucide/svelte/icons/x'

Expand Down Expand Up @@ -63,6 +64,11 @@
let loading = $state(false)
let ground: TileEntry[] = $state([])
let cliffs: TileEntry[] = $state([])
// Distinguishes the empty-grid reasons so the panel doesn't show the same
// misleading "No ground tiles" for every case: 'none' = loaded fine (a truly
// empty palette is rare but real), 'noterrain' = the map has no war3map.w3e,
// 'failed' = the fetch errored (offer a retry).
let loadError: 'none' | 'noterrain' | 'failed' = $state('none')

// Armed state.
let activeTool: TerrainTool | null = $state(null)
Expand All @@ -86,6 +92,7 @@

async function refresh() {
loading = true
loadError = 'none'
try {
// Real tile thumbnails (data-URL PNGs, Go-baked + cached) alongside the
// terrain DTO; fetched in parallel since the thumbnail decode is the slow
Expand Down Expand Up @@ -118,14 +125,27 @@
// Default the selected tiles so the first paint/cliff click just works.
if (!selectedGround && ground.length) selectedGround = ground[0].fourcc
if (!selectedCliff && cliffs.length) selectedCliff = cliffs[0].fourcc
} catch {
} catch (e) {
// GetTerrain rejects with "no terrain loaded" when the open map has no
// war3map.w3e; anything else is an unexpected fetch failure. Classify so
// the empty-state message can tell them apart instead of always claiming
// "No ground tiles."
const msg = e instanceof Error ? e.message : String(e)
loadError = /no terrain/i.test(msg) ? 'noterrain' : 'failed'
ground = []
cliffs = []
} finally {
loading = false
}
}

// First paint: the panel mounts already-open (Terrain mode shows it
// immediately), and the map-changed event that also triggers a refresh has
// usually already fired by the time the user enters Terrain mode — so without
// this the grid stayed empty ("No ground tiles") on first entry even for a
// map full of terrain.
onMount(() => { void refresh() })

function toggleOpen() {
open = !open
if (open) {
Expand Down Expand Up @@ -231,6 +251,10 @@
<div class="section-title">Texture</div>
{#if loading}
<div class="muted">Loading tiles…</div>
{:else if loadError === 'noterrain'}
<div class="muted">This map has no terrain file (war3map.w3e).</div>
{:else if loadError === 'failed'}
<div class="muted">Couldn’t load tiles. <button type="button" class="retry-link" onclick={() => void refresh()}>Retry</button></div>
{:else if ground.length === 0}
<div class="muted">No ground tiles.</div>
{:else}
Expand Down Expand Up @@ -455,6 +479,15 @@
letter-spacing: 0.04em; color: var(--muted-foreground); margin-bottom: 6px;
}
.muted { font-size: 0.75rem; color: var(--muted-foreground); padding: 4px 0; }
.retry-link {
background: none;
border: none;
padding: 0;
color: var(--primary, #60a5fa);
font: inherit;
text-decoration: underline;
cursor: pointer;
}

.tile-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; }
.tile {
Expand Down
13 changes: 11 additions & 2 deletions frontend/src/scene-instances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1682,8 +1682,12 @@ export function createScene(canvas: HTMLCanvasElement, reforged: boolean = false
hoveredUnit = null
}

// Unit type index: stock-only and process-stable (we don't yet apply
// per-map w3u modifications), so cache for process lifetime.
// Unit type index: carries per-map w3u + war3mapSkin.w3u overrides (display
// name, model file, icon), so it is per-MAP, not process-stable. loadMap()
// nulls the cache below so the next getUnitTypes() re-fetches the current
// map's merged index; within a single load the cache lets concurrent
// placeUnit calls share one fetch. (Mirrors getDoodadTypes, which re-fetches
// per load for the same reason.)
let unitTypeIndexCache: Record<string, UnitTypeInfo> | null = null
async function getUnitTypes(): Promise<Record<string, UnitTypeInfo>> {
if (!unitTypeIndexCache) {
Expand Down Expand Up @@ -3509,6 +3513,11 @@ export function createScene(canvas: HTMLCanvasElement, reforged: boolean = false
// tallies accumulated up to the failure point.
sceneDiag.loadingMap = true
sceneDiag.loadGeneration++
// The unit type index is per-map (it folds in this map's w3u +
// war3mapSkin.w3u name/model/icon overrides), so drop the prior map's
// cached copy — the getUnitTypes() call below re-fetches the current
// map's merged index.
unitTypeIndexCache = null
const loadStartTs = performance.now()
try {
clearInstances()
Expand Down
22 changes: 14 additions & 8 deletions internal/forge/handlers_objects.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,13 @@ func normalizeIconPath(p string) string {
// "war3mapImported/foo.mdl") so the .mdl fallback is load-bearing for
// custom-content maps even when the asset handler's sibling swap fires.
func unitModelPath(fields map[string]string) (string, []string) {
for _, k := range []string{"file", "file:hd", "file:sd"} {
// "umdl" first: the "file" column is shared across several unit FourCCs, so
// a per-map Model File override (umdl) is stored under its own FourCC-keyed
// slot (see ObjectMetadata.storeCol), not under "file". Without checking it,
// the preview shows the base model even though the override is loaded — the
// remaining half of the war3mapSkin.w3u custom-model fix. Falls through to
// the stock "file"/"file:hd"/"file:sd" columns when no override is present.
for _, k := range []string{"umdl", "file", "file:hd", "file:sd"} {
v := strings.TrimSpace(fields[k])
if v == "" {
continue
Expand Down Expand Up @@ -203,15 +209,15 @@ func unitIconArt(fields map[string]string) string {
type objectsUnitsListEntity struct {
ID string `json:"id"`
Name string `json:"name"`
Race string `json:"race"` // raw lowercase tag ("human"); frontend titlecases via shared lookup
RaceLabel string `json:"race_label"` // pre-titled label ("Human")
Kind string `json:"kind"` // "unit" | "hero" | "building" | "special"
Category string `json:"category"` // human-readable bucket combining race + kind
Race string `json:"race"` // raw lowercase tag ("human"); frontend titlecases via shared lookup
RaceLabel string `json:"race_label"` // pre-titled label ("Human")
Kind string `json:"kind"` // "unit" | "hero" | "building" | "special"
Category string `json:"category"` // human-readable bucket combining race + kind
IsCustom bool `json:"is_custom"`
IsEdited bool `json:"is_edited"`
BaseID string `json:"base_id,omitempty"` // only set for customs
Campaign bool `json:"campaign"` // hides under "Campaign" subtree when true
IconArt string `json:"icon_art"` // command-button icon path stem
Campaign bool `json:"campaign"` // hides under "Campaign" subtree when true
IconArt string `json:"icon_art"` // command-button icon path stem
}

type objectsUnitsGetParams struct {
Expand All @@ -231,7 +237,7 @@ type objectsUnitsField struct {
// safe to fall back to Display when an old client doesn't know about this
// field. Empty when Value is empty.
DisplayRaw string `json:"display_raw"`
Overridden bool `json:"overridden"` // true if this came from the per-map shadow
Overridden bool `json:"overridden"` // true if this came from the per-map shadow
// Levels carries per-level override values for leveled (opt-format) kinds:
// level (1-based) → raw value. Empty/omitted for non-leveled fields and for
// fields with no per-level overrides. The base/level-0 value stays in Value.
Expand Down
Loading
Loading