Кръв, рани и ефекти
Накратко: Ефектът е нещо, което остава върху съществото няколко хода: има момент при слагане, момент всеки ход и момент при изтичане, а правилото при повторение казва дали второто слагане опреснява, натрупва или се пренебрегва.
Проблем
Един разрез, три хода
Вълк те одраска и от раната тече кръв. Тя не боли веднъж: боли всеки ход, докато раната не се затвори. Втори разрез не започва ново кървене, а добавя рана към същото. А когато животът падне под четвърт, ръката трепери и ударите са по-неточни.
Ето три хода от истинската игра: Ива срещу вълка водач.
=== Бой ===Ива, варварин ур.2 Ж [###########-] 62/67 М [########] 5/5 кръвx3:21) Вълк водач [########] 36/36 Бой! Срещу теб: вълк водач. Вълк водач се хвърля със зъбите напред. Вълк водач драска Ива (2). Ива кърви. Ива кърви по-силно (рани: 3). Ива губи кръв (3).1 удар 2 умение 3 предмет 4 бягствоПод лентата на живота стои кръвx3:2: кървене с три рани (x3) и още два хода. Дневникът казва какво става: ефектът се слага, кърви по-силно (рани: 3) и животът пада с 3. Ето откъде са тези числа.
Това е ефект: нещо, което остава върху съществото няколко хода и действа в тях. Отровата, горенето, зашеметяването, възстановяването, яростта и щитът са от същия вид. За да ги има всичките, ефектът трябва да умее пет неща: три момента (при слагане, всеки ход, при изтичане), правило при повторение, промяна на показателите, следа на картата и тежка рана. Урокът ги строи едно по едно на малки програми, а после показва същото в играта.
По-точно, за любопитните
Кръвта в играта е механика, не картина. Кървенето е ефект с числа: рани, ходове, щета. Дневникът на боя казва силата на удара с думи („драска“, „разсича“) и кървенето с едно изречение, без описания на насилие заради самото насилие. Играта е за ученици и за възрастни и остава такава. Кръвта е нужна, защото дава на играча решение: превързване или бягство, изчакване или втори удар.
Идеята
Ефектът има три момента
Какво прави отровата? Три различни неща в три различни момента: когато я сложат („Отрова влиза в кръвта“), всеки ход (щета) и когато изтече („Действието ѝ отминава“). Затова ефектът е клас с три метода, които по подразбиране не правят нищо: onApply, onTurn и onExpire. Всеки вид ефект променя само това, което му трябва.
class Effect{ public string Id; public int Turns; public int Power; public int Stacks;
public Effect(string id, int turns, int power) { Id = id; Turns = turns; Power = power; Stacks = 1; }
public virtual string StackRule() { return "refresh"; }
public virtual int MaxStacks() { return 1; }
public virtual int Modifier(string stat) { return 0; }
public virtual void OnApply(Creature target, IEffectContext ctx) { ctx.Say(T("fx.on." + Id, target.Name)); }
public virtual void OnTurn(Creature target, IEffectContext ctx) { }
public virtual void OnExpire(Creature target, IEffectContext ctx) { ctx.Say(T("fx.off." + Id, target.Name)); }
public virtual string Label() { string name = T("fxs." + Id); return Stacks > 1 ? name + "x" + Stacks + ":" + Turns : name + ":" + Turns; }}
class PoisonEffect : Effect{ public PoisonEffect(string id, int turns, int power) : base(id, turns, power) { }
public override void OnTurn(Creature target, IEffectContext ctx) { ctx.Say(T("fx.tick.poison", target.Name, Power)); Hurt(ctx, target, Power); }}
class RegenEffect : Effect{ public RegenEffect(string id, int turns, int power) : base(id, turns, power) { }
public override void OnTurn(Creature target, IEffectContext ctx) { int healed = target.Heal(Power); if (healed > 0) ctx.Say(T("fx.tick.regen", target.Name, healed)); }}Класът пази още id (по него се намира текстът и ефектът в списъка), turns (колко хода остават), power (силата) и stacks (рани, слоеве). label() прави кратък надпис като отр:2 за лентата в боя.
Ефектите са на съществото в списък effects. Всеки ход tickEffects минава през списъка: ефектът действа (onTurn), после губи един ход, а когато ходовете свършат, се маха и казва onExpire. Програмата слага отрова за 3 хода със сила 2 и възстановяване за 2 хода със сила 1 на същата Ива:
Creature hero = new Creature("Ива", 20, Stats(10, 4, 10));Log log = new Log();ApplyEffect(hero, MakeEffect("poison", 3, 2), log);ApplyEffect(hero, MakeEffect("regen", 2, 1), log);for (int turn = 1; turn <= 3; turn++) { Console.WriteLine("Ход " + turn); TickEffects(hero, log); Console.WriteLine(Status(hero));}Отрова влиза в кръвта на Ива.Силите на Ива започват да се връщат.Ход 1Отровата изгаря Ива (2).Ива се възстановява (+1).Живот 19/20 [отр:2 рег:1]Ход 2Отровата изгаря Ива (2).Ива се възстановява (+1).Действието на „възстановяване“ върху Ива отминава.Живот 18/20 [отр:1]Ход 3Отровата изгаря Ива (2).Действието на „отрова“ върху Ива отминава.Живот 16/20Отровата действа три пъти (ходове 1, 2 и 3) и изтича в третия. Възстановяването действа два пъти и изтича във втория. Двата ефекта вървят едновременно, всеки със своите ходове. Ето защо turns = 3 значи „три действия“, а не „докато мине третият ход“.
ctx е дневникът: има say(текст) и addBlood(колко). В боя това е бойният дневник, на картата е самата игра. Така един и същ ефект работи и в двата случая, а дневникът решава къде отива текстът. Двата класа и самото същество са в блока по-долу.
Effect вижда hurt и t като свободни функции, както в канона.По-точно, за любопитните
Дневникът, съществото и текстовете. Текстовете на ефектите са данни, не код: ключ и шаблон, като в канона (t('fx.on.' + this.id, …)). Така същият двигател пише на български, английски, немски и испански.
interface IEffectContext{ void Say(string text);
void AddBlood(int amount);}
class Log : IEffectContext{ public int Blood = 0;
public void Say(string text) { Console.WriteLine(text); }
public void AddBlood(int amount) { Blood += amount; }}
// Тихият дневник не пише, но брои кръвта.class Quiet : IEffectContext{ public int Blood = 0;
public void Say(string text) { }
public void AddBlood(int amount) { Blood += amount; }}class Creature{ public string Name; public int MaxHp; public int Hp; public Dictionary<string, int> Base; public List<Effect> Effects = new();
public Creature(string name, int maxHp, Dictionary<string, int> baseStats) { Name = name; MaxHp = maxHp; Hp = maxHp; Base = baseStats; }
public bool IsAlive() { return Hp > 0; }
public int Stat(string stat) { int value = Base[stat]; foreach (Effect effect in Effects) value += effect.Modifier(stat); return value < 0 ? 0 : value; }
// Под 25% живот раната е тежка: ударите стават по-неточни. public bool IsHeavilyWounded() { return Hp * 4 < MaxHp; }
public int Accuracy() { return 75 + Stat("agility") * 2 - (IsHeavilyWounded() ? 15 : 0); }
public int Heal(int amount) { int before = Hp; Hp = Math.Min(MaxHp, Hp + amount); return Hp - before; }
public Effect FindEffect(string id) { foreach (Effect effect in Effects) { if (effect.Id == id) return effect; } return null; }
public void RemoveEffect(string id) { List<Effect> kept = new(); foreach (Effect effect in Effects) { if (effect.Id != id) kept.Add(effect); } Effects = kept; }}
static Dictionary<string, int> Stats(int strength, int defense, int agility){ return new Dictionary<string, int> { ["strength"] = strength, ["defense"] = defense, ["agility"] = agility };}// Текстовете са данни: ключ и шаблон. {name} е името, {n} е числото.static readonly Dictionary<string, string> Text = new(){ ["fx.on.bleed"] = "{name} кърви.", ["fx.on.poison"] = "Отрова влиза в кръвта на {name}.", ["fx.on.burn"] = "{name} се запалва.", ["fx.on.stun"] = "{name} губи равновесие.", ["fx.on.regen"] = "Силите на {name} започват да се връщат.", ["fx.on.rage"] = "{name} изпада в ярост!", ["fx.on.shield"] = "Щит пази {name}.", ["fx.more.bleed"] = "{name} кърви по-силно (рани: {n}).", ["fx.tick.bleed"] = "{name} губи кръв ({n}).", ["fx.tick.poison"] = "Отровата изгаря {name} ({n}).", ["fx.tick.burn"] = "Пламъците хапят {name} ({n}).", ["fx.tick.regen"] = "{name} се възстановява (+{n}).", ["fx.off.bleed"] = "Раните на {name} спират да кървят.", ["fx.off.poison"] = "Действието на „отрова“ върху {name} отминава.", ["fx.off.burn"] = "Действието на „горене“ върху {name} отминава.", ["fx.off.regen"] = "Действието на „възстановяване“ върху {name} отминава.", ["fx.off.rage"] = "Действието на „ярост“ върху {name} отминава.", ["fx.off.shield"] = "Действието на „щит“ върху {name} отминава.", ["fxs.bleed"] = "кръв", ["fxs.poison"] = "отр", ["fxs.burn"] = "огън", ["fxs.stun"] = "зашем", ["fxs.regen"] = "рег", ["fxs.rage"] = "ярост", ["fxs.shield"] = "щит", ["combat.wounded"] = "{name}: тежка рана!",};
static string T(string key, string name = "", int n = 0){ return Text[key].Replace("{name}", name).Replace("{n}", n.ToString());}Quiet е дневник, който мълчи, но брои кръвта. В играта той е за врагове извън екрана: кървят и умират, без да пишат на играча. Съществото и показателите му са от урока за съществата; тук ни трябват stat, heal, findEffect и removeEffect.
В играта е същото. Разликата е само в текстовете: канонът има един общ шаблон Действието на {ефект} върху {име} отминава и взема името на ефекта от таблицата, а нашата таблица има по един ред за ефект.
class Effect{ public string Id; public int Turns; public int Power; public int Stacks;
public Effect(string id, int turns, int power) { Id = id; Turns = turns; Power = power; Stacks = 1; }
public virtual string StackRule() { return "refresh"; }
public virtual int MaxStacks() { return 1; }
public virtual int Modifier(string stat) { return 0; }
public virtual void OnApply(Creature target, IEffectContext ctx) { ctx.Say(T("fx.on." + Id, ("name", target.Name))); }
public virtual void OnTurn(Creature target, IEffectContext ctx) { }
public virtual void OnExpire(Creature target, IEffectContext ctx) { ctx.Say(T("fx.off", ("name", target.Name), ("effect", T("fx." + Id)))); }
public virtual string Label() { string name = T("fxs." + Id); return Stacks > 1 ? name + "x" + Stacks + ":" + Turns : name + ":" + Turns; }}class PoisonEffect : Effect{ public PoisonEffect(string id, int turns, int power) : base(id, turns, power) { }
public override void OnTurn(Creature target, IEffectContext ctx) { ctx.Say(T("fx.tick.poison", ("name", target.Name), ("damage", Power))); Hurt(ctx, target, Power); }}Идеята
Какво става, когато ефектът се слага втори път
Вълкът вече е отровен и му бият още една отрова. Или вече кърви и го одраскват пак. Или вече е зашеметен и някой го удря с тояга. Трите случая не са еднакви, затова всеки ефект носи правило при повторение:
- опресни (
refresh, по подразбиране): по-дългото от двете времена и по-голямата сила остават. Отровата, горенето, възстановяването, яростта и щитът са такива; - натрупай (
stack): добавя се още един слой, доmaxStacks(). Кървенето е такова; - пренебрегни (
ignore): второто слагане не прави нищо. Зашеметяването е такова: иначе трима врагове с тояги заключват героя завинаги.
Правилото се чете от ефекта, който вече е на съществото (existing), и се прилага от applyEffect. Функцията връща true, ако нещо се е променило:
// Слага ефект според правилото му; връща дали нещо се е променило.static bool ApplyEffect(Creature target, Effect effect, IEffectContext ctx){ if (!target.IsAlive()) return false; Effect existing = target.FindEffect(effect.Id); if (existing == null) { target.Effects.Add(effect); effect.OnApply(target, ctx); return true; } string rule = existing.StackRule(); if (rule == "ignore") return false; if (rule == "stack") { existing.Stacks = Math.Min(existing.MaxStacks(), existing.Stacks + effect.Stacks); ctx.Say(T("fx.more." + effect.Id, target.Name, existing.Stacks)); } existing.Turns = Math.Max(existing.Turns, effect.Turns); existing.Power = Math.Max(existing.Power, effect.Power); return true;}
// Всеки ефект действа, после губи ход; изтеклите се махат.static void TickEffects(Creature creature, IEffectContext ctx){ foreach (Effect effect in new List<Effect>(creature.Effects)) { if (!creature.IsAlive()) return; effect.OnTurn(creature, ctx); effect.Turns -= 1; if (effect.Turns <= 0) { creature.RemoveEffect(effect.Id); effect.OnExpire(creature, ctx); } }}Програмата слага шест ефекта на един гоблин и за всеки печата дали нещо се е променило и какво има на гоблина след това:
Creature goblin = new Creature("Гоблин", 40, Stats(8, 2, 6));Log log = new Log();List<Effect> incoming = new(){ MakeEffect("poison", 3, 2), MakeEffect("poison", 5, 1), MakeEffect("bleed", 3, 1), MakeEffect("bleed", 3, 1), MakeEffect("stun", 1, 0), MakeEffect("stun", 2, 0),};foreach (Effect effect in incoming){ string label = effect.Label(); bool changed = ApplyEffect(goblin, effect, log); Console.WriteLine(label + " -> " + (changed ? "да" : "не") + " " + Status(goblin));}Отрова влиза в кръвта на Гоблин.отр:3 -> да Живот 40/40 [отр:3]отр:5 -> да Живот 40/40 [отр:5]Гоблин кърви.кръв:3 -> да Живот 40/40 [отр:5 кръв:3]Гоблин кърви по-силно (рани: 2).кръв:3 -> да Живот 40/40 [отр:5 кръвx2:3]Гоблин губи равновесие.зашем:1 -> да Живот 40/40 [отр:5 кръвx2:3 зашем:1]зашем:2 -> не Живот 40/40 [отр:5 кръвx2:3 зашем:1]Втората отрова удължава първата (отр:3 става отр:5), без ново съобщение. Втората кръв е нова рана: кръвx2:3. Второто зашеметяване връща не и нищо не се мени.
// Слага ефект според правилото му; връща дали нещо се е променило.
static bool ApplyEffect(Creature target, Effect effect, IEffectContext ctx)
{
if (!target.IsAlive()) return false;
Effect existing = target.FindEffect(effect.Id);
if (existing == null)
{
target.Effects.Add(effect);
effect.OnApply(target, ctx);
return true;
}
string rule = existing.StackRule();
if (rule == "ignore") return false;
if (rule == "stack")
{
existing.Stacks = Math.Min(, existing.Stacks + effect.Stacks);
ctx.Say(T("fx.more." + effect.Id, target.Name, existing.Stacks));
}
existing.Turns = Math.Max(existing.Turns, effect.Turns);
existing.Power = Math.Max(existing.Power, effect.Power);
return true;
}Раните не растат без край. Най-много слоеве казва самият ефект с maxStacks(): за кървенето това е 5, за всички останали е 1. Затова новият брой е по-малкото от maxStacks() и stacks + effect.stacks.
В играта функцията има още два реда най-отгоре. Ефект върху мъртво същество не се слага, а някои същества са имунни (костеният скелет няма кръв за кървене). Вторият ред пази от безкрайно зашеметяване: след зашеметяване следващият ход е сигурен.
// Слага ефект според правилото му; връща дали нещо се е променило.public static bool ApplyEffect(Creature target, Effect effect, IEffectContext ctx){ if (!target.IsAlive() || target.IsImmune(effect.Id)) return false; if (effect.Id == "stun" && target.StunProof) return false; Effect? existing = target.FindEffect(effect.Id); if (existing == null) { target.Effects.Add(effect); effect.OnApply(target, ctx); return true; } string rule = existing.StackRule(); if (rule == "ignore") return false; if (rule == "stack") { existing.Stacks = Math.Min(existing.MaxStacks(), existing.Stacks + effect.Stacks); ctx.Say(T("fx.more." + effect.Id, ("name", target.Name), ("stacks", existing.Stacks))); } existing.Turns = Math.Max(existing.Turns, effect.Turns); existing.Power = Math.Max(existing.Power, effect.Power); return true;}// Ход на ефектите: всеки действа, после губи ход; изтеклите се махат.public static void TickEffects(Creature creature, IEffectContext ctx){ foreach (Effect effect in new List<Effect>(creature.Effects)) { if (!creature.IsAlive()) return; effect.OnTurn(creature, ctx); effect.Turns -= 1; if (effect.Turns <= 0) { creature.RemoveEffect(effect.Id); effect.OnExpire(creature, ctx); } }}По-точно, за любопитните
Правилото е метод на ефекта, а не поле: нов ефект с stackRule() { return "stack" } и maxStacks() { return 3 } е цялата разлика. Тази идея се нарича полиморфизъм: applyEffect не знае какъв е ефектът, пита го. Алтернативата е switch (effect.id) в applyEffect, който расте с всеки нов ефект и пази правилата далеч от ефекта, на когото принадлежат.
Виж го
Кървене: рани, които се трупат
Кървенето е ефектът, за който е правилото stack. Всеки нов разрез е още една рана (най-много 5), всяка рана губи 1 живот на ход, а ходовете се връщат на 3. Щетата на хода е броят на раните по силата на една рана:
// Всяка рана губи 1 живот на ход, до 5 рани.class BleedEffect : Effect{ public BleedEffect(int turns, int stacks) : base("bleed", turns, 1) { Stacks = stacks; }
public override string StackRule() { return "stack"; }
public override int MaxStacks() { return 5; }
public override void OnTurn(Creature target, IEffectContext ctx) { int damage = Stacks * Power; ctx.Say(T("fx.tick.bleed", target.Name, damage)); Hurt(ctx, target, damage); ctx.AddBlood(damage); }
public override void OnExpire(Creature target, IEffectContext ctx) { ctx.Say(T("fx.off.bleed", target.Name)); }}Конструкторът взема ходове и рани, не сила: силата на една рана е винаги 1. Затова makeEffect("bleed", 3, 2) значи „кървене за три хода с две рани“: третото число при кървенето са раните. Така враг със „разкъсване“ слага две рани наведнъж.
// Всяка рана губи 1 живот на ход, до 5 рани.class BleedEffect : Effect{ public BleedEffect(int turns, int stacks) : base("bleed", turns, 1) { Stacks = stacks; }
public override string StackRule() { return "stack"; }
public override int MaxStacks() { return 5; }
public override void OnTurn(Creature target, IEffectContext ctx) { int damage = Stacks * Power; ctx.Say(T("fx.tick.bleed", target.Name, damage)); Hurt(ctx, target, damage); ctx.AddBlood(damage); }
public override void OnExpire(Creature target, IEffectContext ctx) { ctx.Say(T("fx.off.bleed", target.Name)); }}
// Слага ефект според правилото му; връща дали нещо се е променило.static bool ApplyEffect(Creature target, Effect effect, IEffectContext ctx){ if (!target.IsAlive()) return false; Effect existing = target.FindEffect(effect.Id); if (existing == null) { target.Effects.Add(effect); effect.OnApply(target, ctx); return true; } string rule = existing.StackRule(); if (rule == "ignore") return false; if (rule == "stack") { existing.Stacks = Math.Min(existing.MaxStacks(), existing.Stacks + effect.Stacks); ctx.Say(T("fx.more." + effect.Id, target.Name, existing.Stacks)); } existing.Turns = Math.Max(existing.Turns, effect.Turns); existing.Power = Math.Max(existing.Power, effect.Power); return true;}
// Всеки ефект действа, после губи ход; изтеклите се махат.static void TickEffects(Creature creature, IEffectContext ctx){ foreach (Effect effect in new List<Effect>(creature.Effects)) { if (!creature.IsAlive()) return; effect.OnTurn(creature, ctx); effect.Turns -= 1; if (effect.Turns <= 0) { creature.RemoveEffect(effect.Id); effect.OnExpire(creature, ctx); } }}
// Щета извън удара: не пада под нула и казва, ако раната е станала тежка.static void Hurt(IEffectContext ctx, Creature target, int amount){ bool wasHeavy = target.IsHeavilyWounded(); target.Hp = Math.Max(0, target.Hp - amount); if (target.IsAlive() && !wasHeavy && target.IsHeavilyWounded()) ctx.Say(T("combat.wounded", target.Name));}
for (int turn = 1; turn <= 5; turn++) { Console.WriteLine("Ход " + turn); if (turn <= 3) ApplyEffect(wolf, MakeEffect("bleed", 3, 1), log); TickEffects(wolf, log); Console.WriteLine(Status(wolf));}Стъпка по стъпка, с думи
- Вълкът има 15 живота. Кървенето е ефект с правило stack: всеки нов разрез е още една рана (до 5), а ходовете се връщат на 3. Три удара, пет хода кръв.
- Ход 1: първият удар. Вълкът още не кърви, затова applyEffect слага нов ефект (onApply). После ходът на ефектите взема 1 × 1 = 1: живот 14, остават 2 хода.
- Ход 2: вторият удар. Ефектът вече има правило stack: раните стават 2, ходовете се връщат на 3. Щетата е 2 × 1 = 2: живот 12.
- Ход 3: третият удар, 3 рани. Една рана струва 1 живот на ход, три рани струват 3. Живот 9. Повече удари няма, но кръвта остава.
- Ход 4: без удар. tickEffects пак взема 3 × 1 = 3 и сваля един ход: остава 1. Живот 6.
- Ход 5: последната кръв, 3. Животът пада до 3, под 25% от 15: hurt казва „тежка рана“. Ходовете свършват и ефектът се маха (onExpire).
- Същите три удара с правило refresh (без натрупване) биха взели само 5 живота; със stack взеха 12 от 15. Пролята кръв: 12.
Програмата удря вълка в три хода подред (ходове 1–3), а в другите два само се тиктака. Животът на вълка е 15:
Creature wolf = new Creature("Вълк", 15, Stats(8, 2, 6));Log log = new Log();for (int turn = 1; turn <= 5; turn++) { Console.WriteLine("Ход " + turn); if (turn <= 3) ApplyEffect(wolf, MakeEffect("bleed", 3, 1), log); TickEffects(wolf, log); Console.WriteLine(Status(wolf));}Console.WriteLine("Пролята кръв: " + log.Blood);Ход 1Вълк кърви.Вълк губи кръв (1).Живот 14/15 [кръв:2]Ход 2Вълк кърви по-силно (рани: 2).Вълк губи кръв (2).Живот 12/15 [кръвx2:2]Ход 3Вълк кърви по-силно (рани: 3).Вълк губи кръв (3).Живот 9/15 [кръвx3:2]Ход 4Вълк губи кръв (3).Живот 6/15 [кръвx3:1]Ход 5Вълк губи кръв (3).Вълк: тежка рана!Раните на Вълк спират да кървят.Живот 3/15Пролята кръв: 12Щетата на хода расте 1, 2, 3, без ударите да са по-силни: раните се събират. В хода 3 раните са три и ходовете са пак 3, затова кървенето продължава още два хода по 3. Общо 1 + 2 + 3 + 3 + 3 = 12. Пролятата кръв е точно взетият живот (15 − 3 = 12), защото addBlood брои щетата на кървенето. В хода 5 животът пада под четвърт и се появява „тежка рана“ (последните карти).
Ако кървенето не се трупаше, а само се опресняваше, същите три удара щяха да вземат по 1 на ход и общо 5 живота (5 хода кръв по 1). С натрупване са 12. Така една малка промяна в правилото прави оръжията с острие опасни.
Откъде идват раните в играта? От бойната система: ударът на оръжие с острие слага рана при критичен удар или в 35% от случаите, а „разкъсването“ на враг слага две наведнъж. Класът в играта е този:
// #region bleeding// Кървенето се натрупва: всеки нов разрез е още една рана (до 5), всяка губи 1 живот на ход, три// хода. Пролятата кръв се брои за статистиката, а кървящо същество, което се движи, оставя следа.class BleedEffect : Effect{ public BleedEffect(int turns, int stacks) : base("bleed", turns, 1) { Stacks = stacks; }
public override string StackRule() { return "stack"; }
public override int MaxStacks() { return 5; }
public override void OnTurn(Creature target, IEffectContext ctx) { int damage = Stacks * Power; ctx.Say(T("fx.tick.bleed", ("name", target.Name), ("damage", damage))); Hurt(ctx, target, damage); ctx.AddBlood(damage); }
public override void OnExpire(Creature target, IEffectContext ctx) { ctx.Say(T("fx.off.bleed", ("name", target.Name))); }}По-точно, за любопитните
В канона BleedEffect и кървенето на героя са един и същ клас. Затова противоотровата маха отровата, кървенето и горенето с removeEffect, а бавното възстановяване на героя (всеки трети ход +1 живот) не действа, докато кърви или е отровен: worldTurn пита hasEffect.
Пробвай
Предвиди изхода
Героят кърви с една рана. След първия ход получава още един разрез с две рани. Ефектите работят с Quiet, затова се печата само животът. Какво печата програмата?
Creature hero = new Creature("Ива", 20, Stats(10, 4, 10));Quiet quiet = new Quiet();ApplyEffect(hero, MakeEffect("bleed", 3, 1), quiet);for (int turn = 1; turn <= 4; turn++) { if (turn == 2) ApplyEffect(hero, MakeEffect("bleed", 3, 2), quiet); TickEffects(hero, quiet); Console.WriteLine("Живот " + hero.Hp);}Кървенето е с правило stack: след първия ход раната е една (живот 19), после се добавят две и раните стават три, ходовете се връщат на 3. Следват три хода по 3: 16, 13 и 10. Отговорът 19, 18, 17, 16 е опресняването (раната остава една, ходовете се връщат на 3), а 19, 18, 17, 17 е ignore (второто слагане се изхвърля).
Пробвай
Намери грешката
Колега пипна onTurn на кървенето. Програмата с вълка печата това, а животът пада по 1 на ход, макар че раните са три:
// Всяка рана губи 1 живот на ход, до 5 рани.class BleedEffect : Effect{ public BleedEffect(int turns, int stacks) : base("bleed", turns, 1) { Stacks = stacks; }
public override string StackRule() { return "stack"; }
public override int MaxStacks() { return 5; }
public override void OnTurn(Creature target, IEffectContext ctx) { int damage = Power; ctx.Say(T("fx.tick.bleed", target.Name, damage)); Hurt(ctx, target, damage); ctx.AddBlood(damage); }
public override void OnExpire(Creature target, IEffectContext ctx) { ctx.Say(T("fx.off.bleed", target.Name)); }}Ход 1Вълк кърви.Вълк губи кръв (1).Живот 14/15 [кръв:2]Ход 2Вълк кърви по-силно (рани: 2).Вълк губи кръв (1).Живот 13/15 [кръвx2:2]Ход 3Вълк кърви по-силно (рани: 3).Вълк губи кръв (1).Живот 12/15 [кръвx3:2]Ход 4Вълк губи кръв (1).Живот 11/15 [кръвx3:1]Ход 5Вълк губи кръв (1).Раните на Вълк спират да кървят.Живот 10/15Пролята кръв: 5Надписът казва рани: 3, а „губи кръв“ стои на 1. Кой ред пресмята щетата?
Избери реда с грешката.
Щетата е power, а не stacks × power. Раните се броят, надписът ги показва, но животът не ги вижда: три рани взимат колкото една. Правилната сметка е damage = stacks × power. Грешката е коварна: нищо не се чупи, applyEffect трупа, label() показва раните, а разликата личи само в числата.
Идеята
Ефекти, които менят показателите
Не всеки ефект действа всеки ход. Яростта дава сила и взима защита, докато трае, а щитът дава защита. Те не правят нищо в onTurn. Вместо това показателят на съществото се смята от база и ефекти:
показател = база на съществото + екипировка + промените от ефектитеВсеки ефект има метод modifier(stat), който връща колко добавя към даден показател (0 по подразбиране). Съществото събира отговорите при всяко питане за показател и не пази резултата. Затова ефектът се маха от списъка и показателят се връща сам:
class RageEffect : Effect{ public RageEffect(string id, int turns, int power) : base(id, turns, power) { }
public override int Modifier(string stat) { if (stat == "strength") return Power; if (stat == "defense") return -2; return 0; }}
class ShieldEffect : Effect{ public ShieldEffect(string id, int turns, int power) : base(id, turns, power) { }
public override int Modifier(string stat) { return stat == "defense" ? Power : 0; }}Програмата дава на Ива ярост за 3 хода със сила 4 и щит за 2 хода със сила 5, и после печата показателите:
Creature hero = new Creature("Ива", 30, Stats(10, 4, 10));Log log = new Log();ApplyEffect(hero, MakeEffect("rage", 3, 4), log);ApplyEffect(hero, MakeEffect("shield", 2, 5), log);Console.WriteLine(StatLine(hero));for (int turn = 1; turn <= 3; turn++) { Console.WriteLine("Ход " + turn); TickEffects(hero, log); Console.WriteLine(StatLine(hero));}Ива изпада в ярост!Щит пази Ива.сила 14, защита 7Ход 1сила 14, защита 7Ход 2Действието на „щит“ върху Ива отминава.сила 14, защита 2Ход 3Действието на „ярост“ върху Ива отминава.сила 10, защита 4Сила 14 е 10 + 4. Защитата е 4 − 2 (ярост) + 5 (щит) = 7. Щом щитът изтече, остава 4 − 2 = 2, а когато изтече и яростта, всичко се връща на основата. Никой не „връща“ нищо: показателят се смята наново при всяко питане.
Две подробности. stat не пада под 0: със слабост и без броня защитата е 0, не отрицателна. А ефект, който се трупа и дава показател, трябва да умножи със stacks в modifier, както кървенето умножава в onTurn: за това е задачата в края на урока.
В играта са същите два ефекта:
class RageEffect : Effect{ public RageEffect(string id, int turns, int power) : base(id, turns, power) { }
public override int Modifier(string stat) { if (stat == "strength") return Power; if (stat == "defense") return -2; return 0; }}class ShieldEffect : Effect{ public ShieldEffect(string id, int turns, int power) : base(id, turns, power) { }
public override int Modifier(string stat) { return stat == "defense" ? Power : 0; }}По-точно, за любопитните
Играта има и други промяни на показатели: DodgeEffect (отскок: бягане от удар), WeakEffect (слабост от вик на враг) и FuryEffect (гняв на Пазача, втората фаза). Всички са по десетина реда. Различното при FuryEffect е, че трае 999 хода: до края на боя.
Идеята
Следата от кръв
Кървящият вълк оставя следа по картата. Тя е част от играта: куестът „Кървавата следа“ те води по нея до леговището на вълка водач.
Кой слага капката? Не ефектът. Ефектът знае колко кръв се лее (ctx.addBlood(damage)), но не знае къде стои съществото. Знае го онзи, който го мести. Затова движението пита hasEffect("bleed") и слага капка в клетката, която съществото напуска. Нивото пази капките в отделен слой над пода (decor) и слага кръв само на чист под: трупът и предметите остават видими.
class Level{ public string Floor; public char[] Decor;
public Level(string floor) { Floor = floor; Decor = new char[floor.Length]; Array.Fill(Decor, ' '); }
// Кръв се слага само на чист под. public void AddBlood(int x) { if (Decor[x] == ' ' && Floor[x] == '.') Decor[x] = ','; }
public string Draw(int walkerX) { char[] row = new char[Floor.Length]; for (int x = 0; x < row.Length; x++) { char glyph = Floor[x]; if (Decor[x] != ' ') glyph = Decor[x]; if (x == walkerX) glyph = 'w'; row[x] = glyph; } return new string(row); }}Програмата пуска ранен вълк по коридор. Раната е на три хода, така че капките са три, а после вълкът върви чист:
Level level = new Level("#...........#");Creature wolf = new Creature("Вълк", 15, Stats(8, 2, 6));Quiet quiet = new Quiet();int x = 1;ApplyEffect(wolf, MakeEffect("bleed", 3, 1), quiet);Console.WriteLine(level.Draw(x));for (int step = 1; step <= 5; step++) { if (wolf.FindEffect("bleed") != null) level.AddBlood(x); x++; TickEffects(wolf, quiet); Console.WriteLine(level.Draw(x));}Console.WriteLine("Пролята кръв: " + quiet.Blood);#w..........##,w.........##,,w........##,,,w.......##,,,.w......##,,,..w.....#Пролята кръв: 3Капка има точно на клетките, които вълкът е напуснал, докато е кървял. След третия ход кървенето изтича и следата спира. Тихият Quiet е тук нарочно: тази сметка няма нужда от съобщения.
В играта движението е същото. Врагът слага кръв при всяка стъпка (moveEnemy), а героят при stepHero, и двамата с едно и също условие. Ето враговата стъпка и клетката, която приема кръвта:
public static void MoveEnemy(Game game, Level level, Enemy enemy, int x, int y){ if (enemy.HasEffect("bleed")) level.AddBlood(enemy.X, enemy.Y); enemy.X = x; enemy.Y = y;}// Кръвта не покрива трупа: трупът остава видим.public void AddBlood(int x, int y){ if (Decor[y][x] == ' ' && IsWalkable(x, y)) Decor[y][x] = ',';}Ето следата в играта: капките , водят през галерията към леговището на вълка.
####.##### #########,### Горната галерия #.# Ниво 1 от 3 ####,#### ......,...# Наоколо: .......,..# L вълк водач (спи) .......,..# w вълк кръвник (спи ######@### ..... .....,... ........,L. w.......... ......... #####Ива, варварин ур.1 опит 19/20 злато 12Ж [############] 48/48 М [########] 4/4„Кървавата следа“: Следвай кръвта до леговището.Вратата изскърцва и се отваря.Виждаш: вълк водач.Идеята
Тежката рана
Когато животът падне под четвърт, раната е тежка: ударите на съществото стават по-неточни. Правилото е в две малки места: isHeavilyWounded и точността.
// Под 25% живот раната е тежка: ударите стават по-неточни.public bool IsHeavilyWounded(){ return Hp * 4 < MaxHp();}
public int Accuracy(){ return 75 + Stat("agility") * 2 + RawStat("accuracy") - (IsHeavilyWounded() ? 15 : 0);}Под 25% е hp × 4 < maxHp (без дроби и без деление). Точността е 75 плюс два пъти ловкостта, а при тежка рана още 15 по-малко. Програмата отравя Ива, която започва с 14 от 40 живота. На втория ход отровата я смъква под границата:
Creature hero = new Creature("Ива", 40, Stats(10, 4, 10));hero.Hp = 14;Log log = new Log();ApplyEffect(hero, MakeEffect("poison", 3, 3), log);Console.WriteLine(Report(hero));for (int turn = 1; turn <= 3; turn++) { Console.WriteLine("Ход " + turn); TickEffects(hero, log); Console.WriteLine(Report(hero));}hero.Heal(10);Console.WriteLine(Report(hero));Отрова влиза в кръвта на Ива.Живот 14/40 [отр:3] точност 95Ход 1Отровата изгаря Ива (3).Живот 11/40 [отр:2] точност 95Ход 2Отровата изгаря Ива (3).Ива: тежка рана!Живот 8/40 [отр:1] точност 80Ход 3Отровата изгаря Ива (3).Действието на „отрова“ върху Ива отминава.Живот 5/40 точност 80Живот 15/40 точност 95В хода 2 животът е 8 и 8 × 4 = 32 е под 40, затова точността пада от 95 на 80. Съобщението „тежка рана“ идва точно веднъж. Това прави hurt: пита преди щетата (wasHeavy) и казва нещо само когато преминаването е станало сега.
// Щета извън удара: не пада под нула и казва, ако раната е станала тежка.static void Hurt(IEffectContext ctx, Creature target, int amount){ bool wasHeavy = target.IsHeavilyWounded(); target.Hp = Math.Max(0, target.Hp - amount); if (target.IsAlive() && !wasHeavy && target.IsHeavilyWounded()) ctx.Say(T("combat.wounded", target.Name));}В хода 3 животът пада още, но надписът не се повтаря. Накрая heal(10) връща живота на 15 и точността пак е 95. Тежката рана не е ефект в списъка: тя се смята от живота и изчезва, щом животът се качи.
public bool IsHeavilyWounded()
{
return Hp * < MaxHp;
}Под 25% е същото като hp × 4 < maxHp. Умножаваме, вместо да делим maxHp / 4, за да останем в цели числа: така няма закръгляне и резултатът е еднакъв на четирите езика.
По-точно, за любопитните
Силата на удара се казва с думи според дела от живота на целта, който отнема. Прагове: под 10% „драска“, до 25% „удря“, до 45% „разсича“, над това „смазва“. Затова кървенето е тихо (малки числа), а първият удар на Пазача се усеща като удар.
// Думата за силата на удара зависи от това каква част от живота на целта отнема.public static string DamageWord(int damage, int maxHp){ int share = Percent(damage, maxHp); if (share < 10) return T("verb.scratch"); if (share < 25) return T("verb.hit"); if (share < 45) return T("verb.cut"); return T("verb.crush");}По-точно
Кой вика tickEffects
Ефектите нямат собствен часовник: някой трябва да вика tickEffects. В боя това е началото на хода на всяко същество. Зашеметеният пропуска хода си. beginTurn запомня дали има зашеметяване преди tickEffects, защото тиктакането го маха (то трае един ход).
// Началото на хода: ефектите действат, презарежданията намаляват. Зашеметеният пропуска.public bool BeginTurn(Creature actor){ actor.Energy -= 100; bool stunned = actor.HasEffect("stun"); TickEffects(actor, this); actor.TickCooldowns(); HandleDeaths(); if (!actor.IsAlive()) return false; // След зашеметяване следващият ход е сигурен: иначе трима врагове с тояги заключват героя. actor.StunProof = stunned; if (stunned) { Say(T("combat.stunned", ("name", actor.Name))); return false; } return true;}На картата извън боя ходът е светът (worldTurn): героят действа и ефектите на героя тиктакат след всяка негова стъпка, а после тиктакат ефектите на всеки враг. Враг, който е извън екрана, се тика със Quiet: ако изкърви до смърт, никой не го вижда, а плячка не пада.
Така един ход на ефектите е едно и също нещо на две места. Ефектът не разбира къде се вика, защото всичко, което му трябва, е в target и ctx. Затова добавянето на нов ефект е един клас и един ред във makeEffect: бойната система и картата не се пипат. Следващият урок, действията, е за ефектите, които умения слагат на другите.
Упражнение
Студ
Реши задачата в редактора си.
Студ. Добави ефект chill. Правилото му е stack с най-много 3 слоя, а всеки слой взима 2 от ловкостта. Ловкостта се смята през modifier, така че трябва да се умножи по слоевете. Работи върху кода от урока: Effect, Creature, makeEffect, applyEffect и tickEffects.
Добави и текстовете на ефекта в таблицата с текстове:
fx.on.chill: Студ се впива в {name}.fx.more.chill: {name} изстива още (слоеве: {n}).fx.off.chill: Действието на „студ“ върху {name} отминава.fxs.chill: студПрограмата прави Ива (живот 30, сила 10, защита 4, ловкост 10) и печата report (състоянието и точността, както в програмата за тежката рана). Първо печата състоянието без ефекти. После четири пъти слага makeEffect("chill", 2, 1) и след всяко слагане печата състоянието. Накрая прави два хода със tickEffects и печата „Ход“ и състоянието след всеки. Изходът е:
Живот 30/30 точност 95Студ се впива в Ива.Живот 30/30 [студ:2] точност 91Ива изстива още (слоеве: 2).Живот 30/30 [студx2:2] точност 87Ива изстива още (слоеве: 3).Живот 30/30 [студx3:2] точност 83Ива изстива още (слоеве: 3).Живот 30/30 [студx3:2] точност 83Ход 1Живот 30/30 [студx3:1] точност 83Ход 2Действието на „студ“ върху Ива отминава.Живот 30/30 точност 95Точността пада с 4 на слой (2 ловкост × 2). Четвъртото слагане остава на 3 слоя, но казва „слоеве: 3“ и връща ходовете на 2. След двата хода студът изтича и точността е пак 95.
Още задачи
- Огнена стена. Ефектът
burnвече е в кода. Направи го да се трупа до 3 пъти и покажи как се променя щетата. - Имунитет. Добави на съществото
immune(множество отid). Какво трябва да върнеapplyEffect, и каква е разликата между имунитет и правилотоignore? - Превързване. Направи отвара, която маха кървенето и слага
regenза 3 хода със сила 2. Колко живот е върнат след три хода, ако животът не е пълен?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Полиморфизъм · Интерфейси · Списъци · Dictionary и HashSet
- Задачи по темата: Хрониките на Арканум