optimization + hero atk balance

master
Denis Ranneft 1 month ago
parent 9f2fc34316
commit f593691e45

@ -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))
}
}

@ -261,13 +261,9 @@ func (h *Hero) EffectiveAttack() int {
func (h *Hero) EffectiveAttackAt(now time.Time) int { func (h *Hero) EffectiveAttackAt(now time.Time) int {
bonuses := h.activeStatBonuses(now) bonuses := h.activeStatBonuses(now)
effectiveStrength := h.Strength + bonuses.strengthBonus effectiveStrength := h.Strength + bonuses.strengthBonus
effectiveAgility := h.Agility + bonuses.agilityBonus
if chest := h.Gear[SlotChest]; chest != nil {
effectiveAgility += chest.AgilityBonus
}
gearAttack, _ := h.gearPrimaryBonuses() 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 := float64(atk)
atkF *= bonuses.attackMultiplier atkF *= bonuses.attackMultiplier
if atkF < 1 { if atkF < 1 {

@ -0,0 +1,7 @@
package tuning
import "math"
func GetMultiplierByStrength(str int) float64 {
return 1.1 - 0.002 * math.Log(float64(str))
}

@ -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 (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 (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); 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;

@ -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 { buildWorldTerrainContext, proceduralTerrain, townsApiToInfluences } from '../game/procedural';
import type { Town } from '../game/types'; import type { Town } from '../game/types';
import { useT } from '../i18n'; import { useT } from '../i18n';
@ -15,15 +15,30 @@ interface MinimapProps {
/** 0 = свернуто, 1 = маленькая, 2 = большая */ /** 0 = свернуто, 1 = маленькая, 2 = большая */
type MapMode = 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 ---- // ---- Constants ----
const SIZE_SMALL = 120; const SIZE_SMALL = 120;
const SIZE_LARGE = 240; const SIZE_LARGE = 240;
/** Each pixel represents this many world units */ /** World units per minimap pixel (10× coarser than legacy 10) */
const WORLD_UNITS_PER_PX = 10; const WORLD_UNITS_PER_PX = 100;
/** Only redraw when hero has moved at least this many world units */
const REDRAW_THRESHOLD = 5; /**
* 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<string, string> = { const TERRAIN_BG: Record<string, string> = {
grass: '#3a7a28', grass: '#3a7a28',
@ -103,73 +118,163 @@ function modeLabel(mode: MapMode, mapStr: string): string {
return `${mapStr} L`; return `${mapStr} L`;
} }
function bakeTerrainSlice(
w: number,
h: number,
centerX: number,
centerY: number,
miniCtx: ReturnType<typeof buildWorldTerrainContext> | 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 ---- // ---- Component ----
export function Minimap({ heroX, heroY, towns, routeWaypoints }: MinimapProps) { export function Minimap({ heroX, heroY, towns, routeWaypoints }: MinimapProps) {
const tr = useT(); const tr = useT();
const [mode, setMode] = useState<MapMode>(1); const [mode, setMode] = useState<MapMode>(1);
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const lastDrawPos = useRef<{ x: number; y: number }>({ x: NaN, y: NaN }); const terrainCacheRef = useRef<TerrainSliceCache | null>(null);
const lastHeroTile = useRef<{ tx: number; ty: number } | null>(null);
const lastRouteKey = useRef<string>('');
const lastTownsKey = useRef<string>('');
const size = mode === 2 ? SIZE_LARGE : SIZE_SMALL; const size = mode === 2 ? SIZE_LARGE : SIZE_SMALL;
const collapsed = mode === 0; const collapsed = mode === 0;
useEffect(() => { const routeKey = useMemo(
if (!collapsed) { () =>
lastDrawPos.current = { x: NaN, y: NaN };
lastHeroTile.current = null;
lastRouteKey.current = '';
lastTownsKey.current = '';
}
}, [collapsed, size]);
useEffect(() => {
if (collapsed) return;
const routeKey =
routeWaypoints && routeWaypoints.length >= 2 routeWaypoints && routeWaypoints.length >= 2
? routeWaypoints.map((p) => `${p.x},${p.y}`).join(';') ? routeWaypoints.map((p) => `${p.x},${p.y}`).join(';')
: ''; : '',
const routeChanged = routeKey !== lastRouteKey.current; [routeWaypoints],
);
const townsKey = const townsKey = useMemo(
() =>
towns.length === 0 towns.length === 0
? '' ? ''
: towns.map((t) => `${t.id}:${t.worldX}:${t.worldY}`).join(';'); : towns.map((t) => `${t.id}:${t.worldX}:${t.worldY}:${t.radius}:${t.biome}:${t.levelMin}`).join('|'),
const townsChanged = townsKey !== lastTownsKey.current; [towns],
);
const tileX = Math.floor(heroX);
const tileY = Math.floor(heroY); const worldKey = `${townsKey}#${routeKey}`;
const last = lastDrawPos.current;
const dx = Math.abs(heroX - last.x); const miniCtx = useMemo(() => {
const dy = Math.abs(heroY - last.y); if (towns.length === 0) return null;
const lt = lastHeroTile.current; const activeRoute =
const tileChanged = !lt || lt.tx !== tileX || lt.ty !== tileY; routeWaypoints && routeWaypoints.length >= 2 ? routeWaypoints : null;
if ( return buildWorldTerrainContext(townsApiToInfluences(towns), activeRoute);
!routeChanged && }, [worldKey, towns, routeWaypoints]);
!townsChanged &&
!tileChanged && useEffect(() => {
dx < REDRAW_THRESHOLD && if (collapsed) return;
dy < REDRAW_THRESHOLD
) {
return;
}
lastRouteKey.current = routeKey;
lastTownsKey.current = townsKey;
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
if (!ctx) return; if (!ctx) return;
lastDrawPos.current = { x: heroX, y: heroY };
lastHeroTile.current = { tx: tileX, ty: tileY };
const w = canvas.width; const w = canvas.width;
const h = canvas.height; 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 cx = w / 2;
const cy = h / 2; const cy = h / 2;
const minDim = Math.min(w, h); 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 heroR = Math.max(3, Math.round(minDim * 0.035));
const glowR = Math.max(8, Math.round(minDim * 0.09)); const glowR = Math.max(8, Math.round(minDim * 0.09));
const activeRoute = const unitsPerPx = WORLD_UNITS_PER_PX;
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);
}
ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)'; ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)';
ctx.lineWidth = 0.5; ctx.lineWidth = 0.5;
for (let gx = 0; gx < w; gx += gridStep) { 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) { 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) { for (const town of towns) {
const relX = (town.worldX - heroX) / WORLD_UNITS_PER_PX; let px = cx + (town.worldX - heroX) / unitsPerPx;
const relY = (town.worldY - heroY) / WORLD_UNITS_PER_PX; let py = cy + (town.worldY - heroY) / unitsPerPx;
let px = cx + relX;
let py = cy + relY;
const clamped = const clamped =
px < margin || px > w - margin || py < margin || py > h - margin; 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.strokeStyle = '#ffffff';
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
ctx.stroke(); ctx.stroke();
}, [heroX, heroY, towns, routeWaypoints, collapsed, size]); }, [heroX, heroY, towns, collapsed, size, worldKey, miniCtx]);
return ( return (
<div style={containerStyle}> <div style={containerStyle}>

Loading…
Cancel
Save