Към съдържанието
wannacode

Кръв, рани и ефекти

Накратко: Ефектът е нещо, което остава върху съществото няколко хода: има момент при слагане, момент всеки ход и момент при изтичане, а правилото при повторение казва дали второто слагане опреснява, натрупва или се пренебрегва.

Проблем

Един разрез, три хода

Вълк те одраска и от раната тече кръв. Тя не боли веднъж: боли всеки ход, докато раната не се затвори. Втори разрез не започва ново кървене, а добавя рана към същото. А когато животът падне под четвърт, ръката трепери и ударите са по-неточни.

Ето три хода от истинската игра: Ива срещу вълка водач.

=== Бой ===
Ива, варварин ур.2
Ж [###########-] 62/67 М [########] 5/5
кръвx3:2
1) Вълк водач [########] 36/36
Бой! Срещу теб: вълк водач.
Вълк водач се хвърля със зъбите напред.
Вълк водач драска Ива (2).
Ива кърви.
Ива кърви по-силно (рани: 3).
Ива губи кръв (3).
1 удар 2 умение 3 предмет 4 бягство

Под лентата на живота стои кръвx3:2: кървене с три рани (x3) и още два хода. Дневникът казва какво става: ефектът се слага, кърви по-силно (рани: 3) и животът пада с 3. Ето откъде са тези числа.

Това е ефект: нещо, което остава върху съществото няколко хода и действа в тях. Отровата, горенето, зашеметяването, възстановяването, яростта и щитът са от същия вид. За да ги има всичките, ефектът трябва да умее пет неща: три момента (при слагане, всеки ход, при изтичане), правило при повторение, промяна на показателите, следа на картата и тежка рана. Урокът ги строи едно по едно на малки програми, а после показва същото в играта.

По-точно, за любопитните

Кръвта в играта е механика, не картина. Кървенето е ефект с числа: рани, ходове, щета. Дневникът на боя казва силата на удара с думи („драска“, „разсича“) и кървенето с едно изречение, без описания на насилие заради самото насилие. Играта е за ученици и за възрастни и остава такава. Кръвта е нужна, защото дава на играча решение: превързване или бягство, изчакване или втори удар.

Идеята

Ефектът има три момента

Какво прави отровата? Три различни неща в три различни момента: когато я сложат („Отрова влиза в кръвта“), всеки ход (щета) и когато изтече („Действието ѝ отминава“). Затова ефектът е клас с три метода, които по подразбиране не правят нищо: onApply, onTurn и onExpire. Всеки вид ефект променя само това, което му трябва.

static class Effect {
final String id;
int turns;
int power;
int stacks = 1;
Effect(String id, int turns, int power) {
this.id = id;
this.turns = turns;
this.power = power;
}
String stackRule() {
return "refresh";
}
int maxStacks() {
return 1;
}
int modifier(String stat) {
return 0;
}
void onApply(Creature target, Context ctx) {
ctx.say(t("fx.on." + this.id, target.name, 0));
}
void onTurn(Creature target, Context ctx) {}
void onExpire(Creature target, Context ctx) {
ctx.say(t("fx.off." + this.id, target.name, 0));
}
String label() {
String name = t("fxs." + this.id, "", 0);
return this.stacks > 1 ? name + "x" + this.stacks + ":" + this.turns : name + ":" + this.turns;
}
}
static class PoisonEffect extends Effect {
PoisonEffect(String id, int turns, int power) {
super(id, turns, power);
}
@Override
void onTurn(Creature target, Context ctx) {
ctx.say(t("fx.tick.poison", target.name, this.power));
hurt(ctx, target, this.power);
}
}
static class RegenEffect extends Effect {
RegenEffect(String id, int turns, int power) {
super(id, turns, power);
}
@Override
void onTurn(Creature target, Context ctx) {
int healed = target.heal(this.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++) {
System.out.println("Ход " + turn);
tickEffects(hero, log);
System.out.println(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 Context {
void say(String text);
void addBlood(int amount);
}
static class Log implements Context {
int blood = 0;
public void say(String text) {
System.out.println(text);
}
public void addBlood(int amount) {
blood += amount;
}
}
// Тихият дневник не пише, но брои кръвта.
static class Quiet implements Context {
int blood = 0;
public void say(String text) {}
public void addBlood(int amount) {
blood += amount;
}
}
static class Creature {
final String name;
final int maxHp;
int hp;
final Map<String, Integer> base;
List<Effect> effects = new ArrayList<>();
Creature(String name, int maxHp, Map<String, Integer> base) {
this.name = name;
this.maxHp = maxHp;
this.hp = maxHp;
this.base = base;
}
boolean isAlive() {
return this.hp > 0;
}
int stat(String stat) {
int value = this.base.get(stat);
for (Effect effect : this.effects) value += effect.modifier(stat);
return value < 0 ? 0 : value;
}
// Под 25% живот раната е тежка: ударите стават по-неточни.
boolean isHeavilyWounded() {
return this.hp * 4 < this.maxHp;
}
int accuracy() {
return 75 + this.stat("agility") * 2 - (this.isHeavilyWounded() ? 15 : 0);
}
int heal(int amount) {
int before = this.hp;
this.hp = Math.min(this.maxHp, this.hp + amount);
return this.hp - before;
}
Effect findEffect(String id) {
for (Effect effect : this.effects) {
if (effect.id.equals(id)) return effect;
}
return null;
}
void removeEffect(String id) {
List<Effect> kept = new ArrayList<>();
for (Effect effect : this.effects) {
if (!effect.id.equals(id)) kept.add(effect);
}
this.effects = kept;
}
}
static Map<String, Integer> stats(int strength, int defense, int agility) {
return Map.of("strength", strength, "defense", defense, "agility", agility);
}
// Текстовете са данни: ключ и шаблон. {name} е името, {n} е числото.
static final Map<String, String> TEXT = Map.ofEntries(
Map.entry("fx.on.bleed", "{name} кърви."),
Map.entry("fx.on.poison", "Отрова влиза в кръвта на {name}."),
Map.entry("fx.on.burn", "{name} се запалва."),
Map.entry("fx.on.stun", "{name} губи равновесие."),
Map.entry("fx.on.regen", "Силите на {name} започват да се връщат."),
Map.entry("fx.on.rage", "{name} изпада в ярост!"),
Map.entry("fx.on.shield", "Щит пази {name}."),
Map.entry("fx.more.bleed", "{name} кърви по-силно (рани: {n})."),
Map.entry("fx.tick.bleed", "{name} губи кръв ({n})."),
Map.entry("fx.tick.poison", "Отровата изгаря {name} ({n})."),
Map.entry("fx.tick.burn", "Пламъците хапят {name} ({n})."),
Map.entry("fx.tick.regen", "{name} се възстановява (+{n})."),
Map.entry("fx.off.bleed", "Раните на {name} спират да кървят."),
Map.entry("fx.off.poison", "Действието на „отрова“ върху {name} отминава."),
Map.entry("fx.off.burn", "Действието на „горене“ върху {name} отминава."),
Map.entry("fx.off.regen", "Действието на „възстановяване“ върху {name} отминава."),
Map.entry("fx.off.rage", "Действието на „ярост“ върху {name} отминава."),
Map.entry("fx.off.shield", "Действието на „щит“ върху {name} отминава."),
Map.entry("fxs.bleed", "кръв"),
Map.entry("fxs.poison", "отр"),
Map.entry("fxs.burn", "огън"),
Map.entry("fxs.stun", "зашем"),
Map.entry("fxs.regen", "рег"),
Map.entry("fxs.rage", "ярост"),
Map.entry("fxs.shield", "щит"),
Map.entry("combat.wounded", "{name}: тежка рана!"));
static String t(String key, String name, int n) {
return TEXT.get(key).replace("{name}", name).replace("{n}", String.valueOf(n));
}

Quiet е дневник, който мълчи, но брои кръвта. В играта той е за врагове извън екрана: кървят и умират, без да пишат на играча. Съществото и показателите му са от урока за съществата; тук ни трябват stat, heal, findEffect и removeEffect.

В играта е същото. Разликата е само в текстовете: канонът има един общ шаблон Действието на {ефект} върху {име} отминава и взема името на ефекта от таблицата, а нашата таблица има по един ред за ефект.

static class Effect {
String id;
int turns;
int power;
int stacks;
Effect(String id, int turns, int power) {
this.id = id;
this.turns = turns;
this.power = power;
this.stacks = 1;
}
String stackRule() {
return "refresh";
}
int maxStacks() {
return 1;
}
int modifier(String stat) {
return 0;
}
void onApply(Creature target, Context ctx) {
ctx.say(t("fx.on." + this.id, "name", target.name));
}
void onTurn(Creature target, Context ctx) {}
void onExpire(Creature target, Context ctx) {
ctx.say(t("fx.off", "name", target.name, "effect", t("fx." + this.id)));
}
String label() {
String name = t("fxs." + this.id);
return this.stacks > 1 ? name + "x" + this.stacks + ":" + this.turns : name + ":" + this.turns;
}
}
static class PoisonEffect extends Effect {
PoisonEffect(String id, int turns, int power) {
super(id, turns, power);
}
@Override
void onTurn(Creature target, Context ctx) {
ctx.say(t("fx.tick.poison", "name", target.name, "damage", this.power));
hurt(ctx, target, this.power);
}
}

Идеята

Какво става, когато ефектът се слага втори път

Вълкът вече е отровен и му бият още една отрова. Или вече кърви и го одраскват пак. Или вече е зашеметен и някой го удря с тояга. Трите случая не са еднакви, затова всеки ефект носи правило при повторение:

  • опресни (refresh, по подразбиране): по-дългото от двете времена и по-голямата сила остават. Отровата, горенето, възстановяването, яростта и щитът са такива;
  • натрупай (stack): добавя се още един слой, до maxStacks(). Кървенето е такова;
  • пренебрегни (ignore): второто слагане не прави нищо. Зашеметяването е такова: иначе трима врагове с тояги заключват героя завинаги.

Правилото се чете от ефекта, който вече е на съществото (existing), и се прилага от applyEffect. Функцията връща true, ако нещо се е променило:

// Слага ефект според правилото му; връща дали нещо се е променило.
static boolean applyEffect(Creature target, Effect effect, Context 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.equals("ignore")) return false;
if (rule.equals("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, Context ctx) {
for (Effect effect : new ArrayList<>(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 = List.of(
makeEffect("poison", 3, 2),
makeEffect("poison", 5, 1),
makeEffect("bleed", 3, 1),
makeEffect("bleed", 3, 1),
makeEffect("stun", 1, 0),
makeEffect("stun", 2, 0));
for (Effect effect : incoming) {
String label = effect.label();
boolean changed = applyEffect(goblin, effect, log);
System.out.println(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 boolean applyEffect(Creature target, Effect effect, Context 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.equals("ignore")) return false;
    if (rule.equals("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;
}

В играта функцията има още два реда най-отгоре. Ефект върху мъртво същество не се слага, а някои същества са имунни (костеният скелет няма кръв за кървене). Вторият ред пази от безкрайно зашеметяване: след зашеметяване следващият ход е сигурен.

// Слага ефект според правилото му; връща дали нещо се е променило.
static boolean applyEffect(Creature target, Effect effect, Context ctx) {
if (!target.isAlive() || target.isImmune(effect.id)) return false;
if (effect.id.equals("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.equals("ignore")) return false;
if (rule.equals("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;
}
// Ход на ефектите: всеки действа, после губи ход; изтеклите се махат.
static void tickEffects(Creature creature, Context ctx) {
for (Effect effect : new ArrayList<>(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 рани.
static class BleedEffect extends Effect {
BleedEffect(int turns, int stacks) {
super("bleed", turns, 1);
this.stacks = stacks;
}
@Override
String stackRule() {
return "stack";
}
@Override
int maxStacks() {
return 5;
}
@Override
void onTurn(Creature target, Context ctx) {
int damage = this.stacks * this.power;
ctx.say(t("fx.tick.bleed", target.name, damage));
hurt(ctx, target, damage);
ctx.addBlood(damage);
}
@Override
void onExpire(Creature target, Context ctx) {
ctx.say(t("fx.off.bleed", target.name, 0));
}
}

Конструкторът взема ходове и рани, не сила: силата на една рана е винаги 1. Затова makeEffect("bleed", 3, 2) значи „кървене за три хода с две рани“: третото число при кървенето са раните. Така враг със „разкъсване“ слага две рани наведнъж.

// Всяка рана губи 1 живот на ход, до 5 рани.
static class BleedEffect extends Effect {
BleedEffect(int turns, int stacks) {
super("bleed", turns, 1);
this.stacks = stacks;
}
@Override
String stackRule() {
return "stack";
}
@Override
int maxStacks() {
return 5;
}
@Override
void onTurn(Creature target, Context ctx) {
int damage = this.stacks * this.power;
ctx.say(t("fx.tick.bleed", target.name, damage));
hurt(ctx, target, damage);
ctx.addBlood(damage);
}
@Override
void onExpire(Creature target, Context ctx) {
ctx.say(t("fx.off.bleed", target.name, 0));
}
}
// Слага ефект според правилото му; връща дали нещо се е променило.
static boolean applyEffect(Creature target, Effect effect, Context 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.equals("ignore")) return false;
if (rule.equals("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, Context ctx) {
for (Effect effect : new ArrayList<>(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(Context ctx, Creature target, int amount) {
boolean wasHeavy = target.isHeavilyWounded();
target.hp = Math.max(0, target.hp - amount);
if (target.isAlive() && !wasHeavy && target.isHeavilyWounded()) ctx.say(t("combat.wounded", target.name, 0));
}
for (int turn = 1; turn <= 5; turn++) {
System.out.println("Ход " + turn);
if (turn <= 3) applyEffect(wolf, makeEffect("bleed", 3, 1), log);
tickEffects(wolf, log);
System.out.println(status(wolf));
}
Стъпка по стъпка, с думи
  1. Вълкът има 15 живота. Кървенето е ефект с правило stack: всеки нов разрез е още една рана (до 5), а ходовете се връщат на 3. Три удара, пет хода кръв.
  2. Ход 1: първият удар. Вълкът още не кърви, затова applyEffect слага нов ефект (onApply). После ходът на ефектите взема 1 × 1 = 1: живот 14, остават 2 хода.
  3. Ход 2: вторият удар. Ефектът вече има правило stack: раните стават 2, ходовете се връщат на 3. Щетата е 2 × 1 = 2: живот 12.
  4. Ход 3: третият удар, 3 рани. Една рана струва 1 живот на ход, три рани струват 3. Живот 9. Повече удари няма, но кръвта остава.
  5. Ход 4: без удар. tickEffects пак взема 3 × 1 = 3 и сваля един ход: остава 1. Живот 6.
  6. Ход 5: последната кръв, 3. Животът пада до 3, под 25% от 15: hurt казва „тежка рана“. Ходовете свършват и ефектът се маха (onExpire).
  7. Същите три удара с правило 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++) {
System.out.println("Ход " + turn);
if (turn <= 3) applyEffect(wolf, makeEffect("bleed", 3, 1), log);
tickEffects(wolf, log);
System.out.println(status(wolf));
}
System.out.println("Пролята кръв: " + 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 живот на ход, три
// хода. Пролятата кръв се брои за статистиката, а кървящо същество, което се движи, оставя следа.
static class BleedEffect extends Effect {
BleedEffect(int turns, int stacks) {
super("bleed", turns, 1);
this.stacks = stacks;
}
@Override
String stackRule() {
return "stack";
}
@Override
int maxStacks() {
return 5;
}
@Override
void onTurn(Creature target, Context ctx) {
int damage = this.stacks * this.power;
ctx.say(t("fx.tick.bleed", "name", target.name, "damage", damage));
hurt(ctx, target, damage);
ctx.addBlood(damage);
}
@Override
void onExpire(Creature target, Context 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);
System.out.println("Живот " + hero.hp);
}
Отговор

Пробвай

Намери грешката

Колега пипна onTurn на кървенето. Програмата с вълка печата това, а животът пада по 1 на ход, макар че раните са три:

// Всяка рана губи 1 живот на ход, до 5 рани.
static class BleedEffect extends Effect {
BleedEffect(int turns, int stacks) {
super("bleed", turns, 1);
this.stacks = stacks;
}
@Override
String stackRule() {
return "stack";
}
@Override
int maxStacks() {
return 5;
}
@Override
void onTurn(Creature target, Context ctx) {
int damage = this.power;
ctx.say(t("fx.tick.bleed", target.name, damage));
hurt(ctx, target, damage);
ctx.addBlood(damage);
}
@Override
void onExpire(Creature target, Context ctx) {
ctx.say(t("fx.off.bleed", target.name, 0));
}
}
Изход
Ход 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. Кой ред пресмята щетата?

Избери реда с грешката.

Идеята

Ефекти, които менят показателите

Не всеки ефект действа всеки ход. Яростта дава сила и взима защита, докато трае, а щитът дава защита. Те не правят нищо в onTurn. Вместо това показателят на съществото се смята от база и ефекти:

показател = база на съществото + екипировка + промените от ефектите

Всеки ефект има метод modifier(stat), който връща колко добавя към даден показател (0 по подразбиране). Съществото събира отговорите при всяко питане за показател и не пази резултата. Затова ефектът се маха от списъка и показателят се връща сам:

static class RageEffect extends Effect {
RageEffect(String id, int turns, int power) {
super(id, turns, power);
}
@Override
int modifier(String stat) {
if (stat.equals("strength")) return this.power;
if (stat.equals("defense")) return -2;
return 0;
}
}
static class ShieldEffect extends Effect {
ShieldEffect(String id, int turns, int power) {
super(id, turns, power);
}
@Override
int modifier(String stat) {
return stat.equals("defense") ? this.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);
System.out.println(statLine(hero));
for (int turn = 1; turn <= 3; turn++) {
System.out.println("Ход " + turn);
tickEffects(hero, log);
System.out.println(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: за това е задачата в края на урока.

В играта са същите два ефекта:

static class RageEffect extends Effect {
RageEffect(String id, int turns, int power) {
super(id, turns, power);
}
@Override
int modifier(String stat) {
if (stat.equals("strength")) return this.power;
if (stat.equals("defense")) return -2;
return 0;
}
}
static class ShieldEffect extends Effect {
ShieldEffect(String id, int turns, int power) {
super(id, turns, power);
}
@Override
int modifier(String stat) {
return stat.equals("defense") ? this.power : 0;
}
}
По-точно, за любопитните

Играта има и други промяни на показатели: DodgeEffect (отскок: бягане от удар), WeakEffect (слабост от вик на враг) и FuryEffect (гняв на Пазача, втората фаза). Всички са по десетина реда. Различното при FuryEffect е, че трае 999 хода: до края на боя.

Идеята

Следата от кръв

Кървящият вълк оставя следа по картата. Тя е част от играта: куестът „Кървавата следа“ те води по нея до леговището на вълка водач.

Кой слага капката? Не ефектът. Ефектът знае колко кръв се лее (ctx.addBlood(damage)), но не знае къде стои съществото. Знае го онзи, който го мести. Затова движението пита hasEffect("bleed") и слага капка в клетката, която съществото напуска. Нивото пази капките в отделен слой над пода (decor) и слага кръв само на чист под: трупът и предметите остават видими.

static class Level {
final String floor;
final char[] decor;
Level(String floor) {
this.floor = floor;
this.decor = new char[floor.length()];
Arrays.fill(this.decor, ' ');
}
// Кръв се слага само на чист под.
void addBlood(int x) {
if (this.decor[x] == ' ' && this.floor.charAt(x) == '.') this.decor[x] = ',';
}
String draw(int walkerX) {
char[] row = new char[this.floor.length()];
for (int x = 0; x < row.length; x++) {
char glyph = this.floor.charAt(x);
if (this.decor[x] != ' ') glyph = this.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);
System.out.println(level.draw(x));
for (int step = 1; step <= 5; step++) {
if (wolf.findEffect("bleed") != null) level.addBlood(x);
x++;
tickEffects(wolf, quiet);
System.out.println(level.draw(x));
}
System.out.println("Пролята кръв: " + quiet.blood);
Изход
#w..........#
#,w.........#
#,,w........#
#,,,w.......#
#,,,.w......#
#,,,..w.....#
Пролята кръв: 3

Капка има точно на клетките, които вълкът е напуснал, докато е кървял. След третия ход кървенето изтича и следата спира. Тихият Quiet е тук нарочно: тази сметка няма нужда от съобщения.

В играта движението е същото. Врагът слага кръв при всяка стъпка (moveEnemy), а героят при stepHero, и двамата с едно и също условие. Ето враговата стъпка и клетката, която приема кръвта:

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;
}
// Кръвта не покрива трупа: трупът остава видим.
void addBlood(int x, int y) {
if (this.decor[y][x] == ' ' && this.isWalkable(x, y)) this.decor[y][x] = ',';
}

Ето следата в играта: капките , водят през галерията към леговището на вълка.

####.##### #########,### Горната галерия
#.# Ниво 1 от 3
####,####
......,...# Наоколо:
.......,..# L вълк водач (спи)
.......,..# w вълк кръвник (спи
######@###
.....
.....,...
........,L.
w..........
.........
#####
Ива, варварин ур.1 опит 19/20 злато 12
Ж [############] 48/48 М [########] 4/4
„Кървавата следа“: Следвай кръвта до леговището.
Вратата изскърцва и се отваря.
Виждаш: вълк водач.

Идеята

Тежката рана

Когато животът падне под четвърт, раната е тежка: ударите на съществото стават по-неточни. Правилото е в две малки места: isHeavilyWounded и точността.

// Под 25% живот раната е тежка: ударите стават по-неточни.
boolean isHeavilyWounded() {
return this.hp * 4 < this.maxHp();
}
int accuracy() {
return 75 + this.stat("agility") * 2 + this.rawStat("accuracy") - (this.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);
System.out.println(report(hero));
for (int turn = 1; turn <= 3; turn++) {
System.out.println("Ход " + turn);
tickEffects(hero, log);
System.out.println(report(hero));
}
hero.heal(10);
System.out.println(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(Context ctx, Creature target, int amount) {
boolean wasHeavy = target.isHeavilyWounded();
target.hp = Math.max(0, target.hp - amount);
if (target.isAlive() && !wasHeavy && target.isHeavilyWounded()) ctx.say(t("combat.wounded", target.name, 0));
}

В хода 3 животът пада още, но надписът не се повтаря. Накрая heal(10) връща живота на 15 и точността пак е 95. Тежката рана не е ефект в списъка: тя се смята от живота и изчезва, щом животът се качи.

boolean isHeavilyWounded() {
    return this.hp *  < this.maxHp;
}
По-точно, за любопитните

Силата на удара се казва с думи според дела от живота на целта, който отнема. Прагове: под 10% „драска“, до 25% „удря“, до 45% „разсича“, над това „смазва“. Затова кървенето е тихо (малки числа), а първият удар на Пазача се усеща като удар.

// Думата за силата на удара зависи от това каква част от живота на целта отнема.
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, защото тиктакането го маха (то трае един ход).

// Началото на хода: ефектите действат, презарежданията намаляват. Зашеметеният пропуска.
boolean beginTurn(Creature actor) {
actor.energy -= 100;
boolean stunned = actor.hasEffect("stun");
tickEffects(actor, this);
actor.tickCooldowns();
this.handleDeaths();
if (!actor.isAlive()) return false;
// След зашеметяване следващият ход е сигурен: иначе трима врагове с тояги заключват героя.
actor.stunProof = stunned;
if (stunned) {
this.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. Колко живот е върнат след три хода, ако животът не е пълен?

Тест

Провери се

1. Вълкът има 3 рани и кърви. Колко живот губи на ход и защо?
2. Героят е зашеметен и втори враг го зашеметява пак. Какво прави правилото ignore?
3. Ефект с turns = 3 е сложен в ход 1 и нищо не го опреснява. Колко пъти ще действа onTurn?
4. Ловкостта е 10, а животът е 9 от 40. Каква е точността?
5. Кой слага капката кръв на пода: ефектът кървене или ходещото същество?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·