Картата и гледането
Накратко: Полето на видимост е множеството клетки, които героят вижда: до всяка клетка в обхвата се прекарва линия на Bresenham, стената я спира, а видяното веднъж остава на картата като мъгла.
Проблем
Шефът се вижда от входа
Първото ниво на мината е 50 × 20 клетки, а картата на екрана е 40 × 13. Цялото ниво не се събира. И да се събираше, не бива да се вижда цялото: ако от входа виждаш Пазача на дълбините и всеки сандък, няма какво да откриваш.
Затова играта показва само това, което героят вижда, и помни видяното. Ето първия кадър, когато героят слезе в мината (от минаването, с което се проверява играта):
### Горната галерия#@.....# Ниво 1 от 3#......+ ......# Наоколо: ...... никой ######
Ива, варварин ур.1 опит 0/20 злато 5Ж [############] 48/48 М [########] 4/4Нов куест: „Кървавата следа“.Нов куест: „Сребро за ковачката“.Ти си в „Горната галерия“, ниво 1.Стаята и вратата се виждат. Коридорът и залата зад вратата ги няма, макар че са на пет-шест клетки. За този кадър трябват пет неща: карта от текст, движение със сблъсък, камера, поле на видимост и мъгла. Урокът ги строи едно по едно на малко ниво 16 × 7, а после показва същото място в играта.
Идеята
Нивото е текст
Нивото на урока е 16 × 7:
#################......#.......##......+.......##...~~@#....>..##...~~.#.......##......#.......#################Всеки знак е клетка: # стена, . под, + затворена врата, ~ вода, > стълби надолу, @ героят. Кодът не знае каква е формата на нивото. Той чете текста. Ново ниво е нов текст, без нито ред нов код.
За движението и гледането клетката трябва да отговори на два въпроса: минава ли се и спира ли погледа. Двата отговора не вървят заедно. През водата се вижда, но не се минава. Затворената врата и спира погледа, и не пуска. Затова плочката е запис със знак, име и двата отговора, а таблицата от плочки е данни:
record Tile(char Glyph, string Name, bool Walkable, bool Opaque);
class Level{ public static readonly List<Tile> Tiles = [ new('#', "стена", false, true), new('.', "под", true, false), new('+', "врата", false, true), new('\'', "отворена врата", true, false), new('~', "вода", false, false), new('>', "стълби", true, false), ];
public readonly int Width; public readonly int Height; public readonly Tile[,] Cells; public int HeroX; public int HeroY;
public Level(string text) { string[] rows = text.Split('\n'); Height = rows.Length; Width = rows[0].Length; Cells = new Tile[Height, Width]; for (int y = 0; y < Height; y++) { for (int x = 0; x < Width; x++) { char glyph = rows[y][x]; if (glyph == '@') { HeroX = x; HeroY = y; glyph = '.'; } Cells[y, x] = TileOf(glyph); } } }
public static Tile TileOf(char glyph) { foreach (Tile tile in Tiles) { if (tile.Glyph == glyph) return tile; } throw new ArgumentException("Непозната клетка: " + glyph); }
public bool InBounds(int x, int y) => x >= 0 && y >= 0 && x < Width && y < Height;
// Извън картата е стена: никой не излиза и никой не гледа навън. public Tile At(int x, int y) => InBounds(x, y) ? Cells[y, x] : Tiles[0];}const string MAP = """ ################ #......#.......# #......+.......# #...~~@#....>..# #...~~.#.......# #......#.......# ################ """;
var level = new Level(MAP);Console.WriteLine("Нивото е " + level.Width + " × " + level.Height + ", героят е на (" + level.HeroX + ", " + level.HeroY + ").");foreach (Tile tile in Level.Tiles){ int count = 0; foreach (Tile cell in level.Cells) { if (cell == tile) count++; } if (count == 0) continue; string walk = tile.Walkable ? "да" : "не"; string sight = tile.Opaque ? "да" : "не"; Console.WriteLine(tile.Glyph + " " + tile.Name + ": " + count + ", минава се: " + walk + ", спира погледа: " + sight);}Нивото е 16 × 7, героят е на (6, 3).# стена: 46, минава се: не, спира погледа: да. под: 60, минава се: да, спира погледа: не+ врата: 1, минава се: не, спира погледа: да~ вода: 4, минава се: не, спира погледа: не> стълби: 1, минава се: да, спира погледа: неAt(x, y) връща стена за всичко извън картата. Така нито движението, нито гледането трябва да проверяват границите: никой не излиза от картата, защото навън е стена.
Tile[,]: един правоъгълен масив с индекс [y, x]. Канонът ползва масив от масиви ([y][x]), както в другите езици; за програмите тук правоъгълният е по-кратък.По-точно, за любопитните
В решетката има 112 клетки, но плочките са само шест обекта. Всяка клетка пази препратка към една от тях, затова броенето по-горе сравнява с ==. Когато вратата се отвори, клетката просто започва да сочи друга плочка. Това е шаблонът „споделени леки обекти“ (flyweight): общото (името, свойствата) е на едно място, а мястото на картата е в решетката.
В канона клетките са знаци, а свойствата са два малки метода: isWalkable и isOpaque. Идеята е същата, формата е по-проста, защото видовете са осем и свойствата са две. Ако добавиш още (цена на движение, шум, светлина), таблицата пак печели.
Идеята
Нивата в играта
Трите нива на мината са в един текст в кода на играта. Всяко започва с ред level|1|… (дълбочината и името на четирите езика), после идват редовете на картата, легендата и end. Ето първото ниво без заглавния ред:
###################################################<.....#............#........1.#..~~~~~.......2..##......+....r.......+......g...#..~~~~~..........##......#............#..........+..~~~~~..........##......#............#..........#.........,.......#########.........r..############........,........#########............############........,........###############.#########################,#######################.#########################.#######################.#########################,############....#.3............#########.........,...#########.4..?.........s....+..................,..#########....#...g..........#########..........,..##############..............###################+######################.###############........5555555....###......................######............,......###..................r...######..............,L...###.>....................######......w............###......7...............######.................6.###################################################legend|1|item|potion_heal|1legend|2|gold|25|0legend|3|item|antidote|1legend|4|item|ring_life|1legend|5|marker|lair|0legend|6|gold|30|0legend|7|item|potion_heal|1endОсвен плочките има още знаци. Буквите (r, g, s, L) са врагове от таблицата на враговете. Цифрите са от легендата под картата: предмет, злато, знак за куест. < е мястото, където героят слиза. ? е тайна врата, която изглежда като стена. , е кръв по пода.
// Картата е текст: редовете между „level|…“ и „legend|…“ са самата карта, легендата казва какво// значи всяка цифра. Тук само се четат определенията; живото ниво се строи в BuildLevel.class LegendEntry{ public string Kind = ""; public string Value = ""; public int Amount;}
class MapDef : Named{ public int Depth; public List<string> Rows = new List<string>(); public Dictionary<char, LegendEntry> Legend = new Dictionary<char, LegendEntry>();}
static partial class Arcanum{ public static void LoadMaps() { MapDef? current = null; foreach (string rawLine in MAPS_DATA.Split('\n')) { string line = TrimSpaces(rawLine); if (line.Length == 0) continue; if (line.StartsWith("level|", StringComparison.Ordinal)) { string[] cells = line.Split('|'); current = new MapDef { Depth = ToInt(cells[1]), Bg = cells[2], En = cells[3], De = cells[4], Es = cells[5], Rows = new List<string>(), Legend = new Dictionary<char, LegendEntry>() }; } else if (line.StartsWith("legend|", StringComparison.Ordinal)) { string[] cells = line.Split('|'); current!.Legend[cells[1][0]] = new LegendEntry { Kind = cells[2], Value = cells[3], Amount = ToInt(cells[4]) }; } else if (line == "end") { MAP_DEFS.Add(current!); current = null; } else { current!.Rows.Add(line); } } }}Четенето е на две стъпки. loadMaps само разделя текста на определения. Живото ниво се строи с buildLevel при нова игра (и при зареждане): буквите стават врагове, цифрите стават предмети, а под тях остава под. Затова в решетката на нивото няма гоблин. Съществата са отделен списък и се движат по картата, а картата стои.
Идеята
Ходът е молба
Най-простият ход е heroX += dx. С него героят минава през стени и излиза от картата. Ходът трябва да е молба: „искам да вляза там“. Клетката, в която иска да влезе, решава:
// Ходът е молба: клетката, в която искаш да влезеш, решава.static string Step(Level level, int dx, int dy){ int x = level.HeroX + dx; int y = level.HeroY + dy; Tile target = level.At(x, y); if (target.Glyph == '+') { level.Cells[y, x] = Level.TileOf('\''); return "Вратата изскърцва и се отваря."; } if (!target.Walkable) return "Не минаваш: " + target.Name + "."; level.HeroX = x; level.HeroY = y; return "";}- стена или вода: не, и казва защо;
- затворена врата: отваря се (става
'), но героят остава на мястото си. Отварянето е ходът; - всичко друго: героят влиза.
Програмата чете команди w a s d (горе, ляво, долу, дясно), по една на ред, и след всяка рисува нивото. Входът е d a w d d d:
var level = new Level(MAP);string? line;while ((line = Console.ReadLine()) != null){ string command = line.Trim(); if (command.Length != 1) continue; int dir = MOVES.IndexOf(command[0]); if (dir < 0) continue; string message = Step(level, DX[dir], DY[dir]); Console.WriteLine(message == "" ? "> " + command : "> " + command + " " + message); PrintLevel(level);}> d Не минаваш: стена.#################......#.......##......+.......##...~~@#....>..##...~~.#.......##......#.......#################> a Не минаваш: вода.#################......#.......##......+.......##...~~@#....>..##...~~.#.......##......#.......#################> w#################......#.......##.....@+.......##...~~.#....>..##...~~.#.......##......#.......#################> d Вратата изскърцва и се отваря.#################......#.......##.....@'.......##...~~.#....>..##...~~.#.......##......#.......#################> d#################......#.......##......@.......##...~~.#....>..##...~~.#.......##......#.......#################> d#################......#.......##......'@......##...~~.#....>..##...~~.#.......##......#.......#################Първите два хода са в стена и във вода: героят стои. После тръгва нагоре, отваря вратата и минава.
В играта стъпката прави повече неща и редът им е важен:
// Стъпка на героя: бой, разговор, врата, тайник, стена или движение.public string StepHero(int dx, int dy){ Level level = Level()!; Hero hero = Hero; int x = hero.X + dx; int y = hero.Y + dy; Enemy? enemy = level.EnemyAt(x, y); if (enemy != null) { StartBattle(enemy); return "stop"; } Npc? npc = level.NpcAt(x, y); if (npc != null) { Stack.Push(new DialogScreen(this, npc.Id)); return "stop"; } char tile = level.Tile(x, y); if (tile == '+') { level.Tiles[y][x] = '\''; Say(T("map.door")); WorldTurn(); return Stack.Changes.Count > 0 ? "stop" : "acted"; } if (tile == '?') { level.Tiles[y][x] = '+'; Say(T("map.secret")); WorldTurn(); return Stack.Changes.Count > 0 ? "stop" : "acted"; } if (!level.IsWalkable(x, y)) { Say(T(tile == '~' ? "map.water" : "map.wall")); return "blocked"; } if (hero.HasEffect("bleed")) level.AddBlood(hero.X, hero.Y); hero.X = x; hero.Y = y; Bus.Publish(new GameEvent { Type = "TileEntered", Depth = Depth, X = x, Y = y, Decor = level.Decor[y][x], Marker = level.Markers[y][x] }); List<FloorItem> here = level.ItemsAt(x, y); if (here.Count > 0) { List<string> names = new List<string>(); foreach (FloorItem item in here) names.Add(ItemLabel(item.Id, item.Count)); Say(T("map.itemsHere", ("items", string.Join(", ", names)))); } WorldTurn(); return Stack.Changes.Count > 0 ? "stop" : "moved";}Първо се пита за враг: врагът стои върху под, и ако плочката решаваше първа, героят щеше да влезе в него. После за човек (разговор), врата, тайна врата, и чак тогава за плочката. Когато героят наистина се премести, кърви ли, оставя кръв по пода, събитието TileEntered казва на куестовете къде е, а накрая светът прави своя ход.
Блъскането в стена връща blocked и не вика worldTurn: грешката на играча не струва ход и враговете не се приближават. Вратата струва ход.
По-точно, за любопитните
Тук водата просто не пуска. В много игри плитката вода пуска, но бавно: всяка плочка има цена на движение (подът 1, плитката вода 2, блатото 3), и героят трупа енергия, докато стигне цената, като енергията в боя (урокът за бойната система). Тогава walkable става число вместо да/не. Търсенето на път също трябва да знае цените: тогава BFS не стига и трябва Dijkstra.
Идеята
Камерата
Нивото на играта е 50 × 20, а изгледът е 40 × 13. Камерата е светът, видян през прозорец: cameraX и cameraY са клетката от света, която е в горния ляв ъгъл на екрана. От тях двете посоки са едно събиране:
- от света към екрана:
screenX = x − cameraX; - от екрана към света:
x = cameraX + screenX.
Камерата следва героя: героят е в средата, затова cameraX = heroX − VIEW_W / 2. Близо до ръба на картата това дава отрицателно число или изглед, който излиза от картата. Затова камерата се затяга (clamp) между 0 и width − VIEW_W. Там изгледът спира, а героят тръгва към ръба на екрана.
const int VIEW_W = 9;const int VIEW_H = 5;
static int Clamp(int value, int low, int high){ if (value < low) return low; if (value > high) return high; return value;}
static void PrintView(Level level){ int cameraX = Clamp(level.HeroX - VIEW_W / 2, 0, level.Width - VIEW_W); int cameraY = Clamp(level.HeroY - VIEW_H / 2, 0, level.Height - VIEW_H); Console.WriteLine($"герой ({level.HeroX}, {level.HeroY}), камера ({cameraX}, {cameraY}), на екрана ({level.HeroX - cameraX}, {level.HeroY - cameraY})"); for (int viewY = 0; viewY < VIEW_H; viewY++) { var row = new char[VIEW_W]; for (int viewX = 0; viewX < VIEW_W; viewX++) { int x = cameraX + viewX; int y = cameraY + viewY; row[viewX] = x == level.HeroX && y == level.HeroY ? '@' : level.At(x, y).Glyph; } Console.WriteLine(new string(row)); }}Програмата ползва изглед 9 × 5 върху нивото 16 × 7 и печата изгледа след всяка отсечка от пътя на героя:
var level = new Level(MAP);foreach (string leg in new[] { "", "wdddd", "ddddd", "sss" }){ foreach (char command in leg) { int dir = MOVES.IndexOf(command); Step(level, DX[dir], DY[dir]); } PrintView(level);}герой (6, 3), камера (2, 1), на екрана (4, 2).....#........+.....~~@#.....~~.#........#...герой (9, 2), камера (5, 0), на екрана (4, 2)#########..#........'.@....~.#....>.~.#......герой (14, 2), камера (7, 0), на екрана (7, 2)##########.......#'......@##....>..##.......#герой (14, 5), камера (7, 2), на екрана (7, 3)'.......##....>..##.......##......@##########Първите два изгледа са центрирани: героят е на (4, 2) на екрана. В третия камерата е стигнала дясното ограничение 16 − 9 = 7 и спира, а героят продължава към ръба. В четвъртия камерата е спряла и отдолу.
VIEW_W / 2 е цяло деление: 9 / 2 = 4.int cameraX = Clamp(level.HeroX - VIEW_W / 2, 0, );Най-дясната камера е тази, при която десният ръб на изгледа е на десния ръб на картата: width − VIEW_W. За 16 × 7 и изглед 9 това е 7: изгледът показва колони от 7 до 15.
По-точно, за любопитните
Ако нивото е по-тясно от изгледа, width − VIEW_W е отрицателно, и според мястото на героя clamp връща 0 или отрицателно число: камерата скача или излиза отляво на картата. В канона това не се случва, защото и трите нива са 50 × 20, но в игра с малки стаи нивото се центрира в изгледа: cameraX = (width − VIEW_W) / 2.
Камерата, която се мести с всяка стъпка, кара целия екран да подскача. Много игри държат мъртва зона: камерата мърда едва когато героят излезе от правоъгълник в средата. В конзолата, ход по ход, това рядко трябва; в игра с плавно движение е задължително.
В играта е същото с 40 × 13. Ето кадър по-надолу в първото ниво: камерата е слязла, затова горните редове на нивото не се виждат:
########............############........ Горната галерия #............# #........ Ниво 1 от 3 #######.##### ######### # Наоколо: ###### ########## никой ........#########......... ...%.%@.'................. ........#########......... ........# ########## ######## . ... .... ....Ива, варварин ур.2 опит 40/55 злато 30Ж [############] 57/57 М [########] 5/5Гоблин копиеносец те забелязва!Победа! Опит от боя: 21.Ива стига ниво 2: ново умение: Вихър (клон Ярост).Идеята
Първият опит: радиус
Героят трябва да вижда около себе си. Първата идея е радиус: виждаш всяка клетка, която е по-близо от 6.
const int RADIUS = 6;
static void RadiusOnly(Level level, bool[,] visible, bool[,] seen){ int originX = level.HeroX; int originY = level.HeroY; for (int y = 0; y < level.Height; y++) { for (int x = 0; x < level.Width; x++) visible[y, x] = false; } for (int y = originY - RADIUS; y <= originY + RADIUS; y++) { for (int x = originX - RADIUS; x <= originX + RADIUS; x++) { if (!level.InBounds(x, y)) continue; int dx = x - originX; int dy = y - originY; if (dx * dx + dy * dy > RADIUS * RADIUS + RADIUS) continue; visible[y, x] = true; seen[y, x] = true; } }}visible е „виждам сега“. seen е „виждал съм някога“ и се ползва по-късно за мъглата. Ето какво вижда героят от старта:
var level = new Level(MAP);var visible = new bool[level.Height, level.Width];var seen = new bool[level.Height, level.Width];RadiusOnly(level, visible, seen);Report(level, visible, seen);герой (6, 3): виждаш 87, помниш 87| ########### ||#......#..... ||#......+..... ||#...~~@#....> ||#...~~.#..... ||#......#..... || ########### |Виждат се 87 клетки и сред тях стълбите (>) зад стената. Затворената врата не пречи. Радиусът знае колко е далеч клетката, но не и какво има между нея и героя. Това е шефът, който се вижда от входа.
По-точно, за любопитните
Защо условието е dx² + dy² ≤ R² + R, а не ≤ R²? При ≤ R² от кръга стърчат четири самотни клетки: (R, 0), (0, R) и симетричните им. Добавката + R го закръгля: за цели числа dx² + dy² ≤ R² + R е същото като разстояние до центъра на клетката под R + ½, защото (R + ½)² = R² + R + ¼. Без корен квадратен и без дробни числа.
Идеята
Права линия: Bresenham
Една клетка се вижда, ако правата линия от героя до нея не минава през стена. На решетка правата линия е поредица от клетки, и трябва да решиш кои. Алгоритъмът на Bresenham го прави само с цели числа: събиране, изваждане и умножение по 2.
Идеята: тръгваш от началото и на всяка стъпка правиш крачка по x, по y или и по двете. Коя, казва error: колко се е отклонила линията от истинската права. Когато отклонението по едната ос стане голямо, правиш крачка по нея и го намаляваш.
static List<(int X, int Y)> Line(int x0, int y0, int x1, int y1){ var cells = new List<(int X, int Y)>(); int dx = Math.Abs(x1 - x0); int dy = -Math.Abs(y1 - y0); int stepX = x0 < x1 ? 1 : -1; int stepY = y0 < y1 ? 1 : -1; int error = dx + dy; int x = x0; int y = y0; while (true) { cells.Add((x, y)); if (x == x1 && y == y1) return cells; int doubled = 2 * error; if (doubled >= dy) { error += dy; x += stepX; } if (doubled <= dx) { error += dx; y += stepY; } }}Програмата чертае линията от A = (0, 0) до B = (8, 3), а после и обратно, от B до A:
var there = Line(0, 0, 8, 3);var back = Line(8, 3, 0, 0);Console.WriteLine("A → B: " + Format(there));Console.WriteLine("B → A: " + Format(back));var picture = new char[4, 9];for (int y = 0; y < 4; y++){ for (int x = 0; x < 9; x++) picture[y, x] = '.';}foreach (var (x, y) in there) picture[y, x] = '*';picture[0, 0] = 'A';picture[3, 8] = 'B';for (int y = 0; y < 4; y++){ var row = new char[9]; for (int x = 0; x < 9; x++) row[x] = picture[y, x]; Console.WriteLine(new string(row));}A → B: 0,0 1,0 2,1 3,1 4,2 5,2 6,2 7,3 8,3B → A: 8,3 7,3 6,2 5,2 4,1 3,1 2,1 1,0 0,0A*.........**.........***.........*BДвете посоки минават почти през същите клетки, но не съвсем: отиване минава през 4,2, а връщане през 4,1. На средата грешката е точно наравно, и алгоритъмът избира едната страна. Обратно изборът е другата. Запомни това: ще ни трябва в слабостите на метода.
По-точно, за любопитните
Откъде е error = dx + dy (с dy отрицателно)? Правата през двата края е f(x, y) = 0, където f е линейна, а знакът на f казва от коя страна си. error е f, умножено така, че да е цяло число, в точката, която би била следващата. Проверките 2 · error ≥ dy и 2 · error ≤ dx питат дали крачката по x, съответно по y, приближава линията. Затова алгоритъмът върви и в осемте посоки и не дели никъде.
Джак Брезенхам го измисля в IBM през 1962 г. за плотер: машината мести писалката само с една стъпка, а деленето е скъпо. Днес деленето не е скъпо, но целите числа дават един и същ резултат на всеки компютър и всеки език, а това за нашата игра е задължително.
Виж го
Поле на видимост и мъгла
Сега двете се събират. За всяка клетка в радиуса тръгва линия на Bresenham. Линията проверява две неща на всяка крачка: стигнахме ли и стена ли е тук. Редът им е важен. Ако е стигнала целта, клетката се вижда, дори да е стена. Ако по пътя преди целта има стена, линията спира: клетката не се вижда.
const int RADIUS = 6;
static bool LineOfSight(Level level, int x0, int y0, int x1, int y1){ int dx = Math.Abs(x1 - x0); int dy = -Math.Abs(y1 - y0); int stepX = x0 < x1 ? 1 : -1; int stepY = y0 < y1 ? 1 : -1; int error = dx + dy; int x = x0; int y = y0; while (true) { if (x == x1 && y == y1) return true; if ((x != x0 || y != y0) && level.At(x, y).Opaque) return false; int doubled = 2 * error; if (doubled >= dy) { error += dy; x += stepX; } if (doubled <= dx) { error += dx; y += stepY; } }}
static void ComputeFov(Level level, bool[,] visible, bool[,] seen){ int originX = level.HeroX; int originY = level.HeroY; for (int y = 0; y < level.Height; y++) { for (int x = 0; x < level.Width; x++) visible[y, x] = false; } for (int y = originY - RADIUS; y <= originY + RADIUS; y++) { for (int x = originX - RADIUS; x <= originX + RADIUS; x++) { if (!level.InBounds(x, y)) continue; int dx = x - originX; int dy = y - originY; if (dx * dx + dy * dy > RADIUS * RADIUS + RADIUS) continue; if (LineOfSight(level, originX, originY, x, y)) { visible[y, x] = true; seen[y, x] = true; } } }}static bool LineOfSight(Level level, int x0, int y0, int x1, int y1){ int dx = Math.Abs(x1 - x0); int dy = -Math.Abs(y1 - y0); int stepX = x0 < x1 ? 1 : -1; int stepY = y0 < y1 ? 1 : -1; int error = dx + dy; int x = x0; int y = y0; while (true) { if (x == x1 && y == y1) return true; if ((x != x0 || y != y0) && level.At(x, y).Opaque) return false; int doubled = 2 * error; if (doubled >= dy) { error += dy; x += stepX; } if (doubled <= dx) { error += dx; y += stepY; } }}
static void ComputeFov(Level level, bool[,] visible, bool[,] seen){ int originX = level.HeroX; int originY = level.HeroY; for (int y = 0; y < level.Height; y++) { for (int x = 0; x < level.Width; x++) visible[y, x] = false; } for (int y = originY - RADIUS; y <= originY + RADIUS; y++) { for (int x = originX - RADIUS; x <= originX + RADIUS; x++) { if (!level.InBounds(x, y)) continue; int dx = x - originX; int dy = y - originY; if (dx * dx + dy * dy > RADIUS * RADIUS + RADIUS) continue; if (LineOfSight(level, originX, originY, x, y)) { visible[y, x] = true; seen[y, x] = true; } } }}Стъпка по стъпка, с думи
- Героят е на (6, 3). Полето се смята наново всеки ход и първо всяка клетка става невидима. Още нищо не е видяно: цялото ниво е непознато.
- Кандидатите са клетките в радиус 6: dx² + dy² ≤ 6² + 6. Те са 87, заедно със стаята зад стената. Радиусът не знае за стени.
- Към всеки кандидат тръгва права линия (Bresenham). Тя спира на първата стена по пътя, но стига до нея, затова самата стена се вижда. 37 линии спират по-рано (сивите).
- От (6, 3) виждаш 50 клетки и всички влизат в seen. Стаята вдясно е в радиуса, но затворената врата и стената я крият: стълбите остават непознати.
- Героят отваря вратата и минава (wdddddd). Всеки ход visible се чисти, а seen остава: видяното, което вече не виждаш, става мъгла.
- От (11, 2) линиите минават през отворената врата. В лявата стая се вижда само тесен клин: стената крие останалото.
- Виждаш 63, помниш 109 от 112 клетки. Мъглата пази формата на лявата стая, но не и какво става в нея сега.
Всеки ход visible се чисти и се смята наново, а seen само се пълни. Програмата рисува видимото със знака му, видяното, което вече не се вижда, с :, а непознатото празно. Първо от старта, после след 7 хода (горе, вратата, пет пъти надясно):
var level = new Level(MAP);var visible = new bool[level.Height, level.Width];var seen = new bool[level.Height, level.Width];ComputeFov(level, visible, seen);Report(level, visible, seen);foreach (char command in "wdddddd"){ int dir = MOVES.IndexOf(command); Step(level, DX[dir], DY[dir]); ComputeFov(level, visible, seen);}Report(level, visible, seen);герой (6, 3): виждаш 50, помниш 50| ###### ||#...... ||#......+ ||#...~~@# ||#...~~.# ||#...... || ###### |герой (11, 2): виждаш 63, помниш 109| :::::::####### ||:::::::#.......#||:::::..'...@...#||:::::::#....>..#||:::::::#.......#||:::::::#.......#|| ::::::#########|Отначало стълбите не се виждат: стената и затворената врата ги крият. След като героят мине, лявата стая е мъгла. Той знае каква е, но не вижда какво става в нея: враг, който влезе там, няма да се появи на екрана.
В играта полето е същият код, на същото място в хода:
// Поле на видимост: клетка се вижда, ако е в радиуса и линията до нея (Bresenham) не минава// през стена или затворена врата. Видяното се помни: това е мъглата.static partial class Arcanum{ public const int FOV_RADIUS = 6;
public static bool LineOfSight(Level level, int x0, int y0, int x1, int y1) { int dx = Math.Abs(x1 - x0); int dy = -Math.Abs(y1 - y0); int stepX = x0 < x1 ? 1 : -1; int stepY = y0 < y1 ? 1 : -1; int error = dx + dy; int x = x0; int y = y0; while (true) { if (x == x1 && y == y1) return true; if ((x != x0 || y != y0) && level.IsOpaque(x, y)) return false; int doubled = 2 * error; if (doubled >= dy) { error += dy; x += stepX; } if (doubled <= dx) { error += dx; y += stepY; } } }
public static void ComputeFov(Level level, int originX, int originY) { for (int y = 0; y < level.Height; y++) { for (int x = 0; x < level.Width; x++) level.Visible[y][x] = false; } for (int y = originY - FOV_RADIUS; y <= originY + FOV_RADIUS; y++) { for (int x = originX - FOV_RADIUS; x <= originX + FOV_RADIUS; x++) { if (!level.InBounds(x, y)) continue; int dx = x - originX; int dy = y - originY; if (dx * dx + dy * dy > FOV_RADIUS * FOV_RADIUS + FOV_RADIUS) continue; if (LineOfSight(level, originX, originY, x, y)) { level.Visible[y][x] = true; level.Seen[y][x] = true; } } } }}MapScreen го вика в update, след всяка команда. Рисуването на картата пък показва камерата и мъглата наведнъж: клетка, която никога не е видяна, се прескача (continue), видимата е в цвета си, а видяната е сива:
public override void Draw(CellBuffer buffer){ Game game = Game; Level level = game.Level()!; Hero hero = game.Hero; int cameraX = Clamp(hero.X - VIEW_WIDTH / 2, 0, level.Width - VIEW_WIDTH); int cameraY = Clamp(hero.Y - VIEW_HEIGHT / 2, 0, level.Height - VIEW_HEIGHT); for (int viewY = 0; viewY < VIEW_HEIGHT; viewY++) { for (int viewX = 0; viewX < VIEW_WIDTH; viewX++) { int x = cameraX + viewX; int y = cameraY + viewY; if (!level.Seen[y][x]) continue; Glyph cell = CellLook(level, x, y); buffer.Put(viewX, viewY, cell.Ch, level.Visible[y][x] ? cell.Color : "gray"); } } buffer.Put(hero.X - cameraX, hero.Y - cameraY, '@', "white"); DrawSidePanel(buffer, level); DrawHud(buffer, game, VIEW_HEIGHT); DrawMessages(buffer, game, VIEW_HEIGHT + 2, 3);}Ето кадъра, в който героят отваря вратата към голямата зала на първото ниво. През отворената врата се вижда клин от залата, а в него гигантски плъх:
######## Горната галерия#<.....# .... Ниво 1 от 3#.....@'....r#......# .... Наоколо:#......# .. r гигантски плъх########
Ива, варварин ур.1 опит 0/20 злато 5Ж [############] 48/48 М [########] 4/4Вратата изскърцва и се отваря.Гигантски плъх те забелязва!Виждаш: гигантски плъх.По-точно, за любопитните
В обикновения терминал (--plain) видяното и видимото изглеждат еднакво: знакът е същият, а цветът липсва. В терминал с ANSI цветове видяното е сиво. Затова програмата в урока ползва : за мъглата: да се вижда разликата и без цветове.
Колко струва? Кръгът с радиус 6 има 137 клетки, а линията до всяка е най-много 6 крачки: под 1000 проверки на ход. Това е O(r³) на ход: r² клетки по r крачки.
Пробвай
Предвиди изхода
Малка стая с колона в средата. Героят е вляво от колоната, а радиусът стига до всяка клетка. Какво печата програмата?
string room = """ ####### #.....# #@.#..# #.....# ####### """;var level = new Level(room);var visible = new bool[level.Height, level.Width];var seen = new bool[level.Height, level.Width];ComputeFov(level, visible, seen);PrintSight(level, visible, seen);Колоната хвърля сянка: клетките зад нея вдясно не се виждат, а сянката се разширява към ъглите. Стените се виждат, освен тези, до които линията минава през колоната или през друга стена. Изходът, в който се вижда всичко, е само с радиус; изходът без стени е с размените проверки от следващата задача.
Пробвай
Намери грешката
Колега пипна линията и сега от старта програмата печата това:
static bool LineOfSight(Level level, int x0, int y0, int x1, int y1){ int dx = Math.Abs(x1 - x0); int dy = -Math.Abs(y1 - y0); int stepX = x0 < x1 ? 1 : -1; int stepY = y0 < y1 ? 1 : -1; int error = dx + dy; int x = x0; int y = y0; while (true) { if ((x != x0 || y != y0) && level.At(x, y).Opaque) return false; if (x == x1 && y == y1) return true; int doubled = 2 * error; if (doubled >= dy) { error += dy; x += stepX; } if (doubled <= dx) { error += dx; y += stepY; } }}герой (6, 3): виждаш 30, помниш 30| || ...... || ...... || ...~~@ || ...~~. || ...... || |Подът се вижда, вратата и стените са изчезнали, а през водата е както преди. Кой ред проверява твърде рано?
Избери реда с грешката.
Проверката за стена е преди проверката „стигнахме ли“. Когато линията стигне до стена, която е самата цел, първо пита „стена ли е тук“ и връща „не се вижда“. Затова изчезват точно стените и затворената врата. Правилният ред е: първо „стигнахме ли“, после „стена ли е по пътя“. Тогава линията стига до стената и спира след нея.
По-точно
Слабостите на простия метод
Линия до всяка клетка е най-простото поле на видимост. Има три известни слабости, и всичките се виждат в нашата игра.
Дупки до стените. Погледни пак първия кадър от старта: героят е до вратата, а стената на (7, 1) и (7, 5) не се вижда, макар че подът до нея се вижда. Линията до (7, 1) прави първата си крачка по диагонал и стъпва на вратата (7, 2): спира. Същото е в кадъра от мината в началото на урока: героят е до лявата стена и тя има дупка. Когато гледаш стена почти по дължината ѝ, линията опира в съседната клетка от същата стена.
Асиметрия. От A виждаш B, а от B не виждаш A, защото линията обратно минава през други клетки (видя го с 4,2 и 4,1). На първото ниво на мината такива двойки клетки са 89 от 17248: от (12, 4) се вижда (14, 8), а обратно не. В канона това има последствия. Врагът гледа със своя линия, от себе си към героя (enemySeesHero, до 8 клетки), а ти виждаш с полето от героя. Затова гоблин може да те забележи, преди ти да го видиш. Играта поне не издава повече, отколкото виждаш: съобщението, че врагът се е събудил, се пише само ако той е във видимото.
По-евтиният вариант има дупки в залите. Вместо линия до всяка клетка може да пускаш линии само до ръба на кръга и да отбелязваш всичко по пътя им. Това е O(r²) вместо O(r³). Но лъчите се разминават с разстоянието и между тях остават непокрити клетки: при радиус 6 те са 8 от 137, а при радиус 20 са 80 от 1313. В голяма зала това са дупки в пода и в далечните стени.
По-добрите алгоритми. Shadowcasting обхожда осемте сектора около героя ред по ред и пази „сенките“ като интервали от наклони. Всяка клетка се поглежда веднъж, няма дупки, а има и симетрично shadowcasting, при което A вижда B точно когато B вижда A. Permissive FOV казва, че клетката се вижда, ако някоя права от някоя точка на клетката на героя до някоя точка на целта е свободна. Той е симетричен по определение и вижда повече около ъглите.
Защо канонът пази простия. Нивата са 50 × 20, а радиусът е 6: под 1000 проверки на ход, нищо за който и да е компютър. Целите числа дават един и същ резултат на четирите езика, а това е проверката на портовете. И се обяснява в една карта. Ако играта стане с големи открити пространства и далечно гледане, тогава смяната на computeFov е на едно място.
Упражнение
Тайната врата
Реши задачата в редактора си.
Тайна врата. Добави плочка ?: изглежда като стена (рисува се #), не се минава и спира погледа. Когато героят се блъсне в нея, тя става затворена врата + и програмата пише „Стената кънти на кухо... Тайна врата!“. Следващото блъскане я отваря, както всяка врата. Нивото е:
#################......#.......##......+.......##...~~@#....>..##...~~.?.......##......#.......#################С вход s d d d d (по един на ред) изходът е:
> s#################......#.......##......+.......##...~~.#....>..##...~~@#.......##......#.......#################> d Стената кънти на кухо... Тайна врата!#################......#.......##......+.......##...~~.#....>..##...~~@+.......##......#.......#################> d Вратата изскърцва и се отваря.#################......#.......##......+.......##...~~.#....>..##...~~@'.......##......#.......#################> d#################......#.......##......+.......##...~~.#....>..##...~~.@.......##......#.......#################> d#################......#.......##......+.......##...~~.#....>..##...~~.'@......##......#.......#################Още задачи
- Факла. Направи радиуса параметър. Героят с факла вижда на 6, без факла на 2. Каква част от лявата стая вижда от старта без факла?
- Изгледът и мъглата заедно. Събери камерата и полето: изглед 9 × 5 върху нивото, който показва видимото, видяното (
:) и непознатото, кактоdrawв играта. - Кой кого вижда. Напиши програма, която за нивото на урока брои двойките подови клетки, от които едната вижда другата, а обратното не.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Изключения · Хвърляне и свои изключения · Списъци · record, switch и шаблони
- Задачи по темата: Хрониките на Арканум