polats Claude Opus 4.8 (1M context) commited on
Commit
46e3cc5
·
1 Parent(s): 72082c8

Phase 5: wire emotes into the Game + settings toggle + asset

Browse files

Pass the emote sheet URL into comboBattler, fire a level-up heart via heroEmote, add
a Gameplay > 'Show character emotes' toggle, copy the emote sheet to web/assets
(served at /sprites/emotes.png) and add it to build.sh. Rebuilt comboBattler bundle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files changed (5) hide show
  1. build.sh +3 -0
  2. web/assets/emotes.png +0 -0
  3. web/comboBattler.js +162 -1
  4. web/settingsPanel.js +13 -0
  5. web/tiny.js +2 -2
build.sh CHANGED
@@ -65,6 +65,9 @@ cp "$AB/public/assets/characters.json" web/assets/characters.json
65
  cp "$AB/public/assets/effects.json" web/assets/effects.json
66
  cp "$AB/public/classes.json" web/assets/classes.json
67
  cp "$AB/public/enemies.json" web/assets/enemies.json
 
 
 
68
  # Map assets: dump the manifest (MAP_ASSET_URLS — assembled from the map renderers + configs in
69
  # src/render/mapConfigs.js so it can't drift) and curate exactly those PNGs (tilesets / props /
70
  # premade-scene layers / interior+tower skins, ~105 files). No whole-pack copy.
 
65
  cp "$AB/public/assets/effects.json" web/assets/effects.json
66
  cp "$AB/public/classes.json" web/assets/classes.json
67
  cp "$AB/public/enemies.json" web/assets/enemies.json
68
+ # Emote sheet (Minifantasy UI Overhaul "Character_Emotions") for the on-map condition emotes.
69
+ # Served at /sprites/emotes.png; comboBattler slices it as an 8×8 grid (src/render/emotes.js).
70
+ cp "$AB/public/assets/minifantasy/Minifantasy_UI _Overhaul_v1.0/_Minifantasy_UI_Overhaul_Assets/_General_UI_Resources/Character_Emotions/_Emotions.png" web/assets/emotes.png
71
  # Map assets: dump the manifest (MAP_ASSET_URLS — assembled from the map renderers + configs in
72
  # src/render/mapConfigs.js so it can't drift) and curate exactly those PNGs (tilesets / props /
73
  # premade-scene layers / interior+tower skins, ~105 files). No whole-pack copy.
web/assets/emotes.png ADDED
web/comboBattler.js CHANGED
@@ -5903,6 +5903,103 @@ function step(b, dt) {
5903
  }
5904
  }
5905
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5906
  // ../auto-battler/src/render/comboBattler.js
5907
  var TILE8 = 8;
5908
  var SPRITE_TILES = 0.95;
@@ -5958,6 +6055,9 @@ function mountComboBattler(pixi, host, opts = {}) {
5958
  return { forgottenPlains: e, orc: e, necropolis: e };
5959
  })();
5960
  let battle = null, R = null, combatRoot = null, rings = null, markers = null, navDbg = null, spawnFn = null, dead = false;
 
 
 
5961
  const depthScale = { v: 2 };
5962
  let offTick = null, keyHandlers = null, tapHandlers = null, controlsEl = null, alive = true;
5963
  const keys = { x: 0, y: 0 };
@@ -6213,11 +6313,51 @@ function mountComboBattler(pixi, host, opts = {}) {
6213
  R.updateFloats(dtMS);
6214
  R.drawProjectiles(battle);
6215
  R.processLog(battle, cursor);
 
6216
  drawRings();
6217
  drawMarkers();
6218
  drawNav();
6219
  emit();
6220
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6221
  function drawRings() {
6222
  const p = pa();
6223
  rings.clear();
@@ -6292,6 +6432,12 @@ function mountComboBattler(pixi, host, opts = {}) {
6292
  } catch {
6293
  }
6294
  navDbg = null;
 
 
 
 
 
 
6295
  try {
6296
  combatRoot?.destroy({ children: true });
6297
  } catch {
@@ -6365,6 +6511,20 @@ function mountComboBattler(pixi, host, opts = {}) {
6365
  R = await createCombatRenderer({ pixi, defsById, layers: { units, fx, projLayer: proj }, coords: { mapX: (x) => x, mapY: (y) => y, depthOf: () => depthScale.v }, getBattle: () => battle });
6366
  if (!alive) return;
6367
  for (const a of battle.actors) a.attackTimer = 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6368
  let foeN = 0;
6369
  const deadAt = /* @__PURE__ */ new Map();
6370
  const spawnAcc = { t: SPAWN_INTERVAL };
@@ -6389,6 +6549,7 @@ function mountComboBattler(pixi, host, opts = {}) {
6389
  if (!a.alive) {
6390
  if (!deadAt.has(a.id)) {
6391
  deadAt.set(a.id, battle.t);
 
6392
  try {
6393
  opts.onEnemyDefeated?.({ id: a.id, name: a.name, profession: a.profession, tier: a.tier, x: a.x, y: a.y, t: battle.t });
6394
  } catch {
@@ -6488,7 +6649,7 @@ function mountComboBattler(pixi, host, opts = {}) {
6488
  const p = pa();
6489
  return p ? { name: p.name, profession: p.profession, hp: Math.round(p.hp), maxHp: Math.round(p.maxHp), skills: (p.bar || []).map((s) => s.name) } : null;
6490
  }
6491
- const ctrl = { ready, selectHero, getSpawnWorld, getSnapshot, getHero, resize, onChange, destroy, map, walkable: (wx, wy) => roamWalkable(wx, wy) };
6492
  if (typeof window !== "undefined") {
6493
  window.__comboSnap = () => ctrl.getSnapshot();
6494
  window.__combo = ctrl;
 
5903
  }
5904
  }
5905
 
5906
+ // ../auto-battler/src/render/emotes.js
5907
+ var CELL = {
5908
+ happy: [6, 0],
5909
+ // smile — killed a foe / level-up payoff
5910
+ angry: [4, 2],
5911
+ // grr — took a hit
5912
+ surprised: [2, 3],
5913
+ // o-mouth
5914
+ dizzy: [5, 3],
5915
+ // red X eyes — stunned / dead
5916
+ sick: [2, 4],
5917
+ // queasy — bleeding/poison/burning
5918
+ sweat: [2, 5],
5919
+ // nervous drop — low HP
5920
+ love: [6, 5],
5921
+ // ❤ — level-up
5922
+ sleepy: [7, 5]
5923
+ // ♪ — idle, out of combat
5924
+ };
5925
+ async function createEmoteLayer(pixi, parent, { url, unit, sizeTiles = 1.3, headTiles = 1.15 }) {
5926
+ await pixi.Assets.load(url);
5927
+ const base = pixi.Texture.from(url);
5928
+ try {
5929
+ base.source.scaleMode = "nearest";
5930
+ } catch {
5931
+ }
5932
+ const sub4 = (c, r) => new pixi.Texture({ source: base.source, frame: new pixi.Rectangle(8 + 16 * c, 8 + 16 * r, 8, 8) });
5933
+ const tex = {};
5934
+ for (const k in CELL) tex[k] = sub4(CELL[k][0], CELL[k][1]);
5935
+ const layer = new pixi.Container();
5936
+ layer.zIndex = 5e8;
5937
+ parent.addChild(layer);
5938
+ const scale = unit * sizeTiles / 8;
5939
+ const active = /* @__PURE__ */ new Map();
5940
+ const enabled = () => {
5941
+ try {
5942
+ return localStorage.getItem("tinyarmy.emotes") !== "0";
5943
+ } catch {
5944
+ return true;
5945
+ }
5946
+ };
5947
+ function show(actorId, key, now, secs = 1.4) {
5948
+ if (!enabled() || !tex[key]) return;
5949
+ let e = active.get(actorId);
5950
+ if (!e) {
5951
+ const spr = new pixi.Sprite(tex[key]);
5952
+ spr.anchor.set(0.5, 1);
5953
+ layer.addChild(spr);
5954
+ e = { spr };
5955
+ active.set(actorId, e);
5956
+ }
5957
+ e.spr.texture = tex[key];
5958
+ e.until = now + secs;
5959
+ e.born = now;
5960
+ }
5961
+ function update(now, posOf) {
5962
+ for (const [id, e] of [...active]) {
5963
+ if (now >= e.until) {
5964
+ e.spr.destroy();
5965
+ active.delete(id);
5966
+ continue;
5967
+ }
5968
+ const p = posOf(id);
5969
+ if (!p) {
5970
+ e.spr.visible = false;
5971
+ continue;
5972
+ }
5973
+ const age = now - e.born, pop = Math.min(1, age / 0.1);
5974
+ e.spr.visible = true;
5975
+ e.spr.scale.set(scale * (0.5 + 0.5 * pop));
5976
+ e.spr.x = p.x;
5977
+ e.spr.y = p.y - headTiles * unit - Math.sin(Math.min(age, 0.25) / 0.25 * Math.PI) * 0.3 * unit;
5978
+ }
5979
+ }
5980
+ function clear(actorId) {
5981
+ const e = active.get(actorId);
5982
+ if (e) {
5983
+ e.spr.destroy();
5984
+ active.delete(actorId);
5985
+ }
5986
+ }
5987
+ function destroy() {
5988
+ for (const [, e] of active) {
5989
+ try {
5990
+ e.spr.destroy();
5991
+ } catch {
5992
+ }
5993
+ }
5994
+ active.clear();
5995
+ try {
5996
+ layer.destroy({ children: true });
5997
+ } catch {
5998
+ }
5999
+ }
6000
+ return { show, update, clear, destroy, keys: Object.keys(CELL) };
6001
+ }
6002
+
6003
  // ../auto-battler/src/render/comboBattler.js
6004
  var TILE8 = 8;
6005
  var SPRITE_TILES = 0.95;
 
6055
  return { forgottenPlains: e, orc: e, necropolis: e };
6056
  })();
6057
  let battle = null, R = null, combatRoot = null, rings = null, markers = null, navDbg = null, spawnFn = null, dead = false;
6058
+ let emoteLayer = null;
6059
+ const emoPrev = /* @__PURE__ */ new Map();
6060
+ let idleEmoAcc = 0;
6061
  const depthScale = { v: 2 };
6062
  let offTick = null, keyHandlers = null, tapHandlers = null, controlsEl = null, alive = true;
6063
  const keys = { x: 0, y: 0 };
 
6313
  R.updateFloats(dtMS);
6314
  R.drawProjectiles(battle);
6315
  R.processLog(battle, cursor);
6316
+ updateEmotes(dt);
6317
  drawRings();
6318
  drawMarkers();
6319
  drawNav();
6320
  emit();
6321
  }
6322
+ const AFFLICT = ["bleeding", "poison", "burning", "deepWound"];
6323
+ function updateEmotes(dt) {
6324
+ if (!emoteLayer || !battle) return;
6325
+ const now = battle.t;
6326
+ for (const a of battle.actors) {
6327
+ const prev = emoPrev.get(a.id) || { hp: a.hp, low: false, conds: /* @__PURE__ */ new Set(), kd: 0, alive: true };
6328
+ if (a.alive) {
6329
+ if (a.hp < prev.hp - 0.5) emoteLayer.show(a.id, "angry", now, 0.85);
6330
+ const low = a.maxHp ? a.hp / a.maxHp < 0.33 : false;
6331
+ if (low && !prev.low) emoteLayer.show(a.id, "sweat", now, 1.4);
6332
+ const conds = new Set((a.conds || []).map((c) => c.type));
6333
+ for (const t of conds) if (!prev.conds.has(t) && AFFLICT.includes(t)) {
6334
+ emoteLayer.show(a.id, "sick", now, 1.2);
6335
+ break;
6336
+ }
6337
+ if (a.kd > now && a.kd > prev.kd) emoteLayer.show(a.id, "dizzy", now, 1);
6338
+ emoPrev.set(a.id, { hp: a.hp, low, conds, kd: a.kd, alive: true });
6339
+ } else {
6340
+ if (prev.alive) emoteLayer.show(a.id, "dizzy", now, 1.3);
6341
+ emoPrev.set(a.id, { ...prev, alive: false, hp: a.hp, conds: /* @__PURE__ */ new Set() });
6342
+ }
6343
+ }
6344
+ const p = pa();
6345
+ if (p && p.alive && nearestFoeDist() === Infinity) {
6346
+ idleEmoAcc += dt;
6347
+ if (idleEmoAcc > 6) {
6348
+ emoteLayer.show(p.id, "sleepy", now, 1.6);
6349
+ idleEmoAcc = 0;
6350
+ }
6351
+ } else idleEmoAcc = 0;
6352
+ emoteLayer.update(now, (id) => {
6353
+ const a = battle.actors.find((x) => x.id === id);
6354
+ return a ? { x: a.x, y: a.y } : null;
6355
+ });
6356
+ }
6357
+ function heroEmote(key) {
6358
+ const p = pa();
6359
+ if (emoteLayer && p && battle) emoteLayer.show(p.id, key, battle.t, 1.6);
6360
+ }
6361
  function drawRings() {
6362
  const p = pa();
6363
  rings.clear();
 
6432
  } catch {
6433
  }
6434
  navDbg = null;
6435
+ try {
6436
+ emoteLayer?.destroy();
6437
+ } catch {
6438
+ }
6439
+ emoteLayer = null;
6440
+ emoPrev.clear();
6441
  try {
6442
  combatRoot?.destroy({ children: true });
6443
  } catch {
 
6511
  R = await createCombatRenderer({ pixi, defsById, layers: { units, fx, projLayer: proj }, coords: { mapX: (x) => x, mapY: (y) => y, depthOf: () => depthScale.v }, getBattle: () => battle });
6512
  if (!alive) return;
6513
  for (const a of battle.actors) a.attackTimer = 0;
6514
+ emoPrev.clear();
6515
+ idleEmoAcc = 0;
6516
+ if (opts.emoteSheetUrl) {
6517
+ try {
6518
+ emoteLayer = await createEmoteLayer(pixi, combatRoot, { url: opts.emoteSheetUrl, unit: TILE8 / G });
6519
+ } catch {
6520
+ emoteLayer = null;
6521
+ }
6522
+ }
6523
+ if (!alive) {
6524
+ emoteLayer?.destroy();
6525
+ emoteLayer = null;
6526
+ return;
6527
+ }
6528
  let foeN = 0;
6529
  const deadAt = /* @__PURE__ */ new Map();
6530
  const spawnAcc = { t: SPAWN_INTERVAL };
 
6549
  if (!a.alive) {
6550
  if (!deadAt.has(a.id)) {
6551
  deadAt.set(a.id, battle.t);
6552
+ heroEmote("happy");
6553
  try {
6554
  opts.onEnemyDefeated?.({ id: a.id, name: a.name, profession: a.profession, tier: a.tier, x: a.x, y: a.y, t: battle.t });
6555
  } catch {
 
6649
  const p = pa();
6650
  return p ? { name: p.name, profession: p.profession, hp: Math.round(p.hp), maxHp: Math.round(p.maxHp), skills: (p.bar || []).map((s) => s.name) } : null;
6651
  }
6652
+ const ctrl = { ready, selectHero, getSpawnWorld, getSnapshot, getHero, heroEmote, resize, onChange, destroy, map, walkable: (wx, wy) => roamWalkable(wx, wy) };
6653
  if (typeof window !== "undefined") {
6654
  window.__comboSnap = () => ctrl.getSnapshot();
6655
  window.__combo = ctrl;
web/settingsPanel.js CHANGED
@@ -37,6 +37,16 @@ function injectSection(sample, id, title, intro, mountFn) {
37
  mountFn(host)
38
  }
39
 
 
 
 
 
 
 
 
 
 
 
40
  export function mountSettingsPanel() {
41
  const tryInject = () => {
42
  const sample = [...document.querySelectorAll('.banner-wrap')].find((e) => /Display Theme/i.test(e.textContent))
@@ -64,6 +74,9 @@ export function mountSettingsPanel() {
64
  'Nemotron 3 Nano (NVIDIA) runs via NVIDIA NIM; Mellum2 (JetBrains) runs as a ' +
65
  'ZeroGPU sidecar and falls back to Nemotron (NIM) if its sidecar is unavailable.',
66
  mountCodingModelBar)
 
 
 
67
  }
68
  new MutationObserver(tryInject).observe(document.body, { childList: true, subtree: true })
69
  tryInject()
 
37
  mountFn(host)
38
  }
39
 
40
+ // Gameplay toggles. Currently just emotes on/off, persisted to the same localStorage flag the
41
+ // emote layer reads ('tinyarmy.emotes', default on → only '0' disables).
42
+ function mountGameplayBar(host) {
43
+ const cb = el('input', { type: 'checkbox' })
44
+ try { cb.checked = localStorage.getItem('tinyarmy.emotes') !== '0' } catch { cb.checked = true }
45
+ cb.addEventListener('change', () => { try { localStorage.setItem('tinyarmy.emotes', cb.checked ? '1' : '0') } catch { /* ignore */ } })
46
+ const lab = el('label', { class: 'tac-set-row', style: 'display:flex;align-items:center;gap:8px;cursor:pointer' }, [cb, 'Show character emotes'])
47
+ host.append(lab)
48
+ }
49
+
50
  export function mountSettingsPanel() {
51
  const tryInject = () => {
52
  const sample = [...document.querySelectorAll('.banner-wrap')].find((e) => /Display Theme/i.test(e.textContent))
 
74
  'Nemotron 3 Nano (NVIDIA) runs via NVIDIA NIM; Mellum2 (JetBrains) runs as a ' +
75
  'ZeroGPU sidecar and falls back to Nemotron (NIM) if its sidecar is unavailable.',
76
  mountCodingModelBar)
77
+ injectSection(sample, 'tac-gameplay-settings', 'Gameplay',
78
+ 'On-map flourishes during the Game. Emotes pop little reaction bubbles above heroes ' +
79
+ 'and enemies when they take a hit, get afflicted, drop low, or score a kill.', mountGameplayBar)
80
  }
81
  new MutationObserver(tryInject).observe(document.body, { childList: true, subtree: true })
82
  tryInject()
web/tiny.js CHANGED
@@ -352,7 +352,7 @@ whenEl('battle-stage', async (el) => {
352
  // Award XP to the active hero when it defeats a foe (the loop fires this once per kill).
353
  // Declared as a closure so it always reads the *current* hero + freshest stored progression.
354
  const onEnemyDefeated = (enemy) => { try { awardKill(enemy) } catch { /* never break combat */ } }
355
- comboCtrl = mountComboBattler(PIXI, el, { seed: 1, rosters, onEnemyDefeated })
356
  await comboCtrl.ready
357
  const FALLBACK = { name: 'Fighter', unitClass: 'Warrior' }
358
  const OVERVIEW_ZOOM = 0.45, GAMEPLAY_ZOOM = 2.5
@@ -382,7 +382,7 @@ whenEl('battle-stage', async (el) => {
382
  if (res.leveledUp) events = appendEvent(events, 'level_up', { level: prog.level, ts: Date.now() })
383
  patchPersona(id, { progression: prog, events })
384
  currentHero = getPersona(id)
385
- if (res.leveledUp) showLevelUp(prog)
386
  if (sheetOpen) renderSheet()
387
  }
388
  // A brief level-up banner over the canvas (mobile-friendly: centered, auto-dismiss).
 
352
  // Award XP to the active hero when it defeats a foe (the loop fires this once per kill).
353
  // Declared as a closure so it always reads the *current* hero + freshest stored progression.
354
  const onEnemyDefeated = (enemy) => { try { awardKill(enemy) } catch { /* never break combat */ } }
355
+ comboCtrl = mountComboBattler(PIXI, el, { seed: 1, rosters, onEnemyDefeated, emoteSheetUrl: '/sprites/emotes.png' })
356
  await comboCtrl.ready
357
  const FALLBACK = { name: 'Fighter', unitClass: 'Warrior' }
358
  const OVERVIEW_ZOOM = 0.45, GAMEPLAY_ZOOM = 2.5
 
382
  if (res.leveledUp) events = appendEvent(events, 'level_up', { level: prog.level, ts: Date.now() })
383
  patchPersona(id, { progression: prog, events })
384
  currentHero = getPersona(id)
385
+ if (res.leveledUp) { showLevelUp(prog); comboCtrl?.heroEmote?.('love') }
386
  if (sheetOpen) renderSheet()
387
  }
388
  // A brief level-up banner over the canvas (mobile-friendly: centered, auto-dismiss).