From f593691e45bbfb44f933dcb0bca6dbe246937f6c Mon Sep 17 00:00:00 2001 From: Denis Ranneft Date: Mon, 6 Apr 2026 01:58:07 +0300 Subject: [PATCH] optimization + hero atk balance --- backend/cmd/calc/main.go | 45 ++++ backend/internal/model/hero.go | 8 +- backend/internal/tuning/formulae.go | 7 + backend/migrations/000006b_enemy_data.sql | 2 + frontend/src/ui/Minimap.tsx | 252 ++++++++++++++-------- 5 files changed, 222 insertions(+), 92 deletions(-) create mode 100644 backend/cmd/calc/main.go create mode 100644 backend/internal/tuning/formulae.go diff --git a/backend/cmd/calc/main.go b/backend/cmd/calc/main.go new file mode 100644 index 0000000..5a073e3 --- /dev/null +++ b/backend/cmd/calc/main.go @@ -0,0 +1,45 @@ +package main + +import ( + "flag" + "fmt" + "math" +) + + +func main() { + var ( + gearAtk = flag.Int("gearAtk", 10, "gear attack rate") + maxLvl = flag.Int("maxLvl", 50, "max hero level") + startAtk = flag.Int("startAtk", 10, "max hero level") + ) + flag.Parse() + + effectiveStrength := 1 + baseAtk := *startAtk + + for i := 1; i <= *maxLvl; i++ { + + if i%2 == 0 { + effectiveStrength++ + } + if i%3 == 0 { + baseAtk++ + } + //multb := 1 - float64(effectiveStrength*(1 - i/(i+1)))/(float64(effectiveStrength+100)) + + //mult1 := 1 + (1 - 0.15*math.Pow(float64(effectiveStrength), 0.1)) + mult2 := 1 + math.Exp(-0.05*float64(effectiveStrength-1)) + mult1 := 1.1 - 0.002*math.Log(float64(effectiveStrength)) + effectiveAtk1 := int(math.Round(float64(baseAtk + *gearAtk) * mult1)) + effectiveAtk2 := int(math.Round(float64(baseAtk + *gearAtk) * mult2)) + + fmt.Println(fmt.Sprintf("Atk1 %d Atk2 %d base %d gear %d Lvl %d Str %d Mult %.3f, %.3f", effectiveAtk1, effectiveAtk2, baseAtk, *gearAtk, i, effectiveStrength, mult1, mult2)) + } + +} + + + + + diff --git a/backend/internal/model/hero.go b/backend/internal/model/hero.go index 5bba636..0de1de7 100644 --- a/backend/internal/model/hero.go +++ b/backend/internal/model/hero.go @@ -261,13 +261,9 @@ func (h *Hero) EffectiveAttack() int { func (h *Hero) EffectiveAttackAt(now time.Time) int { bonuses := h.activeStatBonuses(now) effectiveStrength := h.Strength + bonuses.strengthBonus - effectiveAgility := h.Agility + bonuses.agilityBonus - if chest := h.Gear[SlotChest]; chest != nil { - effectiveAgility += chest.AgilityBonus - } - gearAttack, _ := h.gearPrimaryBonuses() - atk := h.Attack + effectiveStrength*2 + effectiveAgility/4 + gearAttack + mult := tuning.GetMultiplierByStrength(effectiveStrength) + atk := int(math.Round(float64(h.Attack + gearAttack) * mult)) atkF := float64(atk) atkF *= bonuses.attackMultiplier if atkF < 1 { diff --git a/backend/internal/tuning/formulae.go b/backend/internal/tuning/formulae.go new file mode 100644 index 0000000..5db07fb --- /dev/null +++ b/backend/internal/tuning/formulae.go @@ -0,0 +1,7 @@ +package tuning + +import "math" + +func GetMultiplierByStrength(str int) float64 { + return 1.1 - 0.002 * math.Log(float64(str)) +} diff --git a/backend/migrations/000006b_enemy_data.sql b/backend/migrations/000006b_enemy_data.sql index 7cc32ff..157322c 100644 --- a/backend/migrations/000006b_enemy_data.sql +++ b/backend/migrations/000006b_enemy_data.sql @@ -218,3 +218,5 @@ INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (218, 'shade_l19_20_swamp', 'shade', 'swamp', 'Bog Cursed Shade', 212, 212, 45, 3, 1.0000, 0.0500, 19, 20, 27, 1, ARRAY['slow','dodge']::text[], false, now(), 19, 0.3, 5, 8.4320, 3.0680, 1.4950, 2.0, 1.2); INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (219, 'shade_l21_22_volcanic', 'shade', 'volcanic', 'Rogue Ember Shade', 200, 200, 43, 3, 1.0100, 0.0500, 21, 22, 29, 1, ARRAY['slow']::text[], false, now(), 21, 0.3, 5, 8.9760, 3.2240, 1.5600, 2.0, 1.2); INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (220, 'shade_l21_22_astral', 'shade', 'astral', 'Astral Rogue Shade', 221, 221, 47, 3, 1.0100, 0.0500, 21, 22, 30, 1, ARRAY['slow','dodge']::text[], false, now(), 21, 0.3, 5, 8.9760, 3.2240, 1.5600, 2.0, 1.2); + +UPDATE public.enemies SET max_hero_level_diff = 2; \ No newline at end of file diff --git a/frontend/src/ui/Minimap.tsx b/frontend/src/ui/Minimap.tsx index e13a114..551b5ca 100644 --- a/frontend/src/ui/Minimap.tsx +++ b/frontend/src/ui/Minimap.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type CSSProperties } from 'react'; +import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import { buildWorldTerrainContext, proceduralTerrain, townsApiToInfluences } from '../game/procedural'; import type { Town } from '../game/types'; import { useT } from '../i18n'; @@ -15,15 +15,30 @@ interface MinimapProps { /** 0 = свернуто, 1 = маленькая, 2 = большая */ type MapMode = 0 | 1 | 2; +/** Cached terrain layer (viewport-sized); rebuilt only when view in world space meaningfully shifts */ +type TerrainSliceCache = { + w: number; + h: number; + centerX: number; + centerY: number; + worldKey: string; + /** Offscreen terrain only (no UI) */ + canvas: HTMLCanvasElement; +}; + // ---- Constants ---- const SIZE_SMALL = 120; const SIZE_LARGE = 240; -/** Each pixel represents this many world units */ -const WORLD_UNITS_PER_PX = 10; -/** Only redraw when hero has moved at least this many world units */ -const REDRAW_THRESHOLD = 5; +/** World units per minimap pixel (10× coarser than legacy 10) */ +const WORLD_UNITS_PER_PX = 100; + +/** + * Repaint terrain when hero moved this many world units since last bake. + * ~0.5 minimap pixel so sub-pixel pan stays cheap; full terrain bake is the hotspot. + */ +const TERRAIN_REBAKE_THRESHOLD = WORLD_UNITS_PER_PX * 0.45; const TERRAIN_BG: Record = { grass: '#3a7a28', @@ -103,73 +118,163 @@ function modeLabel(mode: MapMode, mapStr: string): string { return `${mapStr} L`; } +function bakeTerrainSlice( + w: number, + h: number, + centerX: number, + centerY: number, + miniCtx: ReturnType | null, +): HTMLCanvasElement { + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d'); + if (!ctx) return canvas; + + const cx = w / 2; + const cy = h / 2; + const scale = WORLD_UNITS_PER_PX; + + if (!miniCtx || miniCtx.towns.length === 0) { + const terrain = proceduralTerrain(Math.floor(centerX), Math.floor(centerY), null); + ctx.fillStyle = TERRAIN_BG[terrain] ?? DEFAULT_BG; + ctx.fillRect(0, 0, w, h); + return canvas; + } + + const img = ctx.createImageData(w, h); + const data = img.data; + let q = 0; + for (let py = 0; py < h; py++) { + const wy = Math.floor(centerY + (py - cy) * scale); + for (let px = 0; px < w; px++) { + const wx = Math.floor(centerX + (px - cx) * scale); + const terrain = proceduralTerrain(wx, wy, miniCtx); + const rgb = TERRAIN_RGB.get(terrain) ?? DEFAULT_RGB; + data[q++] = rgb[0]; + data[q++] = rgb[1]; + data[q++] = rgb[2]; + data[q++] = 255; + } + } + ctx.putImageData(img, 0, 0); + return canvas; +} + +function terrainCacheNeedsRebake( + cache: TerrainSliceCache | null, + w: number, + h: number, + heroX: number, + heroY: number, + worldKey: string, +): boolean { + if (!cache) return true; + if (cache.w !== w || cache.h !== h) return true; + if (cache.worldKey !== worldKey) return true; + if ( + Math.abs(heroX - cache.centerX) >= TERRAIN_REBAKE_THRESHOLD || + Math.abs(heroY - cache.centerY) >= TERRAIN_REBAKE_THRESHOLD + ) { + return true; + } + return false; +} + +/** Sample baked terrain while scrolling so the hero stays centered between rebakes */ +function drawTerrainFromCache( + ctx: CanvasRenderingContext2D, + cache: TerrainSliceCache, + heroX: number, + heroY: number, + dw: number, + dh: number, +): void { + const u = WORLD_UNITS_PER_PX; + const { centerX: tcx, centerY: tcy, canvas: off } = cache; + const ow = off.width; + const oh = off.height; + + ctx.fillStyle = DEFAULT_BG; + ctx.fillRect(0, 0, dw, dh); + + const sx = (heroX - tcx) / u; + const sy = (heroY - tcy) / u; + const srcX0 = Math.max(0, Math.floor(sx)); + const srcY0 = Math.max(0, Math.floor(sy)); + const srcX1 = Math.min(ow, Math.ceil(sx + dw)); + const srcY1 = Math.min(oh, Math.ceil(sy + dh)); + if (srcX0 < srcX1 && srcY0 < srcY1) { + const destX = srcX0 - sx; + const destY = srcY0 - sy; + const iw = srcX1 - srcX0; + const ih = srcY1 - srcY0; + ctx.drawImage(off, srcX0, srcY0, iw, ih, destX, destY, iw, ih); + } +} + // ---- Component ---- export function Minimap({ heroX, heroY, towns, routeWaypoints }: MinimapProps) { const tr = useT(); const [mode, setMode] = useState(1); const canvasRef = useRef(null); - const lastDrawPos = useRef<{ x: number; y: number }>({ x: NaN, y: NaN }); - const lastHeroTile = useRef<{ tx: number; ty: number } | null>(null); - const lastRouteKey = useRef(''); - const lastTownsKey = useRef(''); + const terrainCacheRef = useRef(null); const size = mode === 2 ? SIZE_LARGE : SIZE_SMALL; const collapsed = mode === 0; - useEffect(() => { - if (!collapsed) { - lastDrawPos.current = { x: NaN, y: NaN }; - lastHeroTile.current = null; - lastRouteKey.current = ''; - lastTownsKey.current = ''; - } - }, [collapsed, size]); - - useEffect(() => { - if (collapsed) return; - - const routeKey = + const routeKey = useMemo( + () => routeWaypoints && routeWaypoints.length >= 2 ? routeWaypoints.map((p) => `${p.x},${p.y}`).join(';') - : ''; - const routeChanged = routeKey !== lastRouteKey.current; + : '', + [routeWaypoints], + ); - const townsKey = + const townsKey = useMemo( + () => towns.length === 0 ? '' - : towns.map((t) => `${t.id}:${t.worldX}:${t.worldY}`).join(';'); - const townsChanged = townsKey !== lastTownsKey.current; - - const tileX = Math.floor(heroX); - const tileY = Math.floor(heroY); - const last = lastDrawPos.current; - const dx = Math.abs(heroX - last.x); - const dy = Math.abs(heroY - last.y); - const lt = lastHeroTile.current; - const tileChanged = !lt || lt.tx !== tileX || lt.ty !== tileY; - if ( - !routeChanged && - !townsChanged && - !tileChanged && - dx < REDRAW_THRESHOLD && - dy < REDRAW_THRESHOLD - ) { - return; - } - lastRouteKey.current = routeKey; - lastTownsKey.current = townsKey; + : towns.map((t) => `${t.id}:${t.worldX}:${t.worldY}:${t.radius}:${t.biome}:${t.levelMin}`).join('|'), + [towns], + ); + + const worldKey = `${townsKey}#${routeKey}`; + + const miniCtx = useMemo(() => { + if (towns.length === 0) return null; + const activeRoute = + routeWaypoints && routeWaypoints.length >= 2 ? routeWaypoints : null; + return buildWorldTerrainContext(townsApiToInfluences(towns), activeRoute); + }, [worldKey, towns, routeWaypoints]); + + useEffect(() => { + if (collapsed) return; const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; - lastDrawPos.current = { x: heroX, y: heroY }; - lastHeroTile.current = { tx: tileX, ty: tileY }; - const w = canvas.width; const h = canvas.height; + + let cache = terrainCacheRef.current; + if (terrainCacheNeedsRebake(cache, w, h, heroX, heroY, worldKey)) { + cache = { + w, + h, + centerX: heroX, + centerY: heroY, + worldKey, + canvas: bakeTerrainSlice(w, h, heroX, heroY, miniCtx), + }; + terrainCacheRef.current = cache; + } + + drawTerrainFromCache(ctx, cache!, heroX, heroY, w, h); + const cx = w / 2; const cy = h / 2; const minDim = Math.min(w, h); @@ -180,51 +285,26 @@ export function Minimap({ heroX, heroY, towns, routeWaypoints }: MinimapProps) { const heroR = Math.max(3, Math.round(minDim * 0.035)); const glowR = Math.max(8, Math.round(minDim * 0.09)); - const activeRoute = - routeWaypoints && routeWaypoints.length >= 2 ? routeWaypoints : null; - const miniCtx = - towns.length === 0 - ? null - : buildWorldTerrainContext(townsApiToInfluences(towns), activeRoute); - - if (!miniCtx || miniCtx.towns.length === 0) { - const terrain = proceduralTerrain(tileX, tileY, null); - ctx.fillStyle = TERRAIN_BG[terrain] ?? DEFAULT_BG; - ctx.fillRect(0, 0, w, h); - } else { - const img = ctx.createImageData(w, h); - const data = img.data; - let q = 0; - const scale = WORLD_UNITS_PER_PX; - for (let py = 0; py < h; py++) { - const wy = Math.floor(heroY + (py - cy) * scale); - for (let px = 0; px < w; px++) { - const wx = Math.floor(heroX + (px - cx) * scale); - const terrain = proceduralTerrain(wx, wy, miniCtx); - const rgb = TERRAIN_RGB.get(terrain) ?? DEFAULT_RGB; - data[q++] = rgb[0]; - data[q++] = rgb[1]; - data[q++] = rgb[2]; - data[q++] = 255; - } - } - ctx.putImageData(img, 0, 0); - } + const unitsPerPx = WORLD_UNITS_PER_PX; ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)'; ctx.lineWidth = 0.5; for (let gx = 0; gx < w; gx += gridStep) { - ctx.beginPath(); ctx.moveTo(gx, 0); ctx.lineTo(gx, h); ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(gx, 0); + ctx.lineTo(gx, h); + ctx.stroke(); } for (let gy = 0; gy < h; gy += gridStep) { - ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(w, gy); ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(0, gy); + ctx.lineTo(w, gy); + ctx.stroke(); } for (const town of towns) { - const relX = (town.worldX - heroX) / WORLD_UNITS_PER_PX; - const relY = (town.worldY - heroY) / WORLD_UNITS_PER_PX; - let px = cx + relX; - let py = cy + relY; + let px = cx + (town.worldX - heroX) / unitsPerPx; + let py = cy + (town.worldY - heroY) / unitsPerPx; const clamped = px < margin || px > w - margin || py < margin || py > h - margin; @@ -267,7 +347,7 @@ export function Minimap({ heroX, heroY, towns, routeWaypoints }: MinimapProps) { ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 1.5; ctx.stroke(); - }, [heroX, heroY, towns, routeWaypoints, collapsed, size]); + }, [heroX, heroY, towns, collapsed, size, worldKey, miniCtx]); return (