EotB – Dekaler

Denna del i serien om spelet ”Eye of the Beholder” kommer att bli en blandning av artikel och blogg. Varför jag säger detta är då jag ännu inte löst problemet med dekaler riktigt helt än. Mer om det senare.

Jag verkligen älskar detta gamla spel. Visst var det nyskapande när det kom men grafiken är riktig snygg pixel-art som står sig än idag. Att jag inte ger upp på detta så lätt beror på att jag alltid ville komma åt grafiken för att kunna göra egna banor och spel i samma anda. Tyvärr hade jag inte rätt kunskaper o teknik då det begav sig.

Dekaler

Dekaler i spelet är all extra grafik som läggs ovanpå väggar och golv för att skapa mer variation och stämning. Det tar också betydligt mindre utrymme, både lagringsplats och minnesutrymme, att skapa dekaler än att skapa hela väggar som ser annorlunda ut. Studera följande bilder:

Bilden till vänster är en vägg utan dekaler och bilden till höger är en vägg med dekalen av ett avloppsgaller.

Men varifrån kommer dekalerna? hur laddas dem? och framförallt hur ritas de ut?

Grafiken

Letar man runt i PAK-filerna så hittar man ganska snabbt tre grafikfiler som innehåller grafiken till dekalerna på Level 1. Det är i filerna ”brick1”, ”brick2” och ”brick3”.

Rektanglarna som är plottade i bilderna har jag ritat för att visuellt kunna klura ut hur data för dekalerna hänger ihop med grafiken i bilderna.

Tar vi exemplet ovan så verkar dekalen som används vara dekal id #23. Siffrorna kommer från den data om dekaler som spelet har.

Nu syns inte numret 23 så värst tydligt i bilden då jag gått igenom alla dekaler till level 1 och ritat dem i ordning över grafiken med olika färger på rektanglar. Det visar sig att samma dekaler används flera gånger. Något vi får förklara lite tydligare längre fram. Men den senaste dekalen var id #27 och ritades med rosa färg.

Det vi kan ana är också att dekalen bara är halv! Man har sparat resurser genom att tydligen halvera symmetriska dekaler och rita med två gånger.

Men vi kanske ska backa bandet lite till den data som vi har att tillgå.

Data för banan

Data för banorna finns i separata inf-filer och maz-filer. För level 1 är det filen ”LEVEL1.INF” samt filen ”LEVEL1.MAZ”. Kort kan man säga att maz-filerna innehåller rutnätet av block som utgör själva kartan. Där varje block har information om varje sida; north, west, south och east. Informationen är såklart nummer som indikerar vilken typ av vägg som gäller för respektive sida. Det vanligaste är siffror som indikerar någon av de typer som finns specificerade i banans VMP-fil. För level 1 finns det 6 olika typer.

Strukturen får att hålla information om varje block finns i filen MazeBlock.cs.

MazeBlock.cs
using MonoEye.Common;

namespace MonoEye
{
    public class MazeBlock
    {
        public int North { get; private set; }
        public int West { get; private set; }
        public int South { get; private set; }
        public int East { get; private set; }

        private int[] _directions;

        public MazeBlock(int N, int W, int S, int E)
        {
            North = N;
            West = W;
            South = S;
            East = E;

            _directions = new[] { N, E, S, W };
        }

        public int GetFace(Vector2Int direction, int offset)
        {
            int index = 0;

            if (direction.X == 1)
                index = 1;
            else if (direction.Y == 1)
                index = 2;
            else if (direction.X == -1)
                index = 3;

            return _directions[(index + offset) % 4];
        }
    }
}

Det är kanske inte mycket och säga om denna fil. Varje riktning sparas som jag nämnde tidigare. Riktningarna läggs också i ett fält för att lättare beräkna vilken sida man är intresserad av.

Metoden GetFace(..) räknar fram vilken sida som är aktuell beroende på väderstreck (direction) och rotation för de ”vypositioner” som grafikmotorn jobbar med (offset). Varje ”vyposition” är en sida på något block som är i synfältet. Eftersom du kan vända/rotera dig i banan så ändras väderstrecket (direction) men rotationen på blocken i förhållande till hur du står är konstant. Förutsatt att vi har typerna i fältet i en ordning som motsvara väderstrecken. Lite klurigt kanske men i praktiken en mycket enkel beräkning på vilken sida av ett block jag faktiskt tittar på givet väderstrecket och vypositionen som håller på att ritas ut.

En typisk bana är 32×32 block. Koden för hur maz-filen läses in hittar du på GitHub. Numera ligger MonoEye på GitHub så kod som inte är så intressant att diskutera länkar jag istället direkt till GitHub.

Men hur var det nu med dekalerna?

Jo det visar sig att vissa block har typer som inte återfinns i VMP-filen. Vi kan kalla de typer som finns i VMP-filen för ”standardtyper” och övriga för ”väggtyper”. Dessa väggtyper är väggar med dekaler.

Information om banan och dess dekaler, monster och en hel del annat finns i filen ”LEVEL1.INF”. Att läsa in detta format tog sin lilla tid men mycket handlade om att söka ledtrådar på internet och trial & error. Koden till att läsa in inf-filer hittar du på GitHub. Det intressanta för oss är beskrivningen av väggtyperna som såklart finns i filen. Strukturen för att läsa väggtyperna ser ut som:

WallMapping
    public struct WallMapping
    {
        public byte WallMappingIndex;
        public byte WallID;
        public byte DecorationID;
        public byte WallType;
        public byte WallPassabilityType;
        public string Texture;
    }

Här länkas nummer som överstiger standardtyper av väggar ihop med rätt väggtyp via WallMappingIndex. Alltså om ett nummer för ett block på banan har ett högre värde än förväntade standardtyper så är det istället ett index som ska sökas upp i inf-filens lista på väggtyper.

WallID är vilken standardtyp som den här speciella väggtypen bygger på. DecorationID pekar sedan ut vilken dekal som ska användas på väggen.

WallType är en egenskap som jag ännu inte undersökt närmare. I dagsläget vet jag inte vad det är för information. Om någon läser detta och känner till mer om detta så hör gärna av er!

WallPassabilityType har som namnet antyder något med om väggen går att passera eller ej. Ännu inte helt klart vilka värden som kan förväntas här.

Texture är namnet på grafik-filen som hör till dekalen. För level 1 är det någon av brick1, brick2 eller brick3.

Data för dekaler

Data för dekalerna finns för level 1 i filen ”BRICK.DAT”. Här finns alla data som beskriver dekalernas grafik och placering. Koden för att läsa in dat-filer finns på GitHub. Dat-filen innehåller en lista på alla dekaler samt en lista på dekalerna rektanglar. Strukturen för att lagra data om varje dekal ser ut som:

Decoration.cs
namespace MonoEye.Common
{
    public class Decoration
    {
        public byte[] RectangleIndices;
        public byte LinkToNextDecoration;
        public byte Flags;
        public int[] XCoords;
        public int[] YCoords;

        public Decoration()
        {
            RectangleIndices = new byte[10];
            XCoords = new int[10];
            YCoords = new int[10];
        }
    }
}

Varje dekal har en läkning till nästa dekal, LinkToNextDecoration. Tanken är att man fortsätter tills värdet på nästa dekal är 0. Flags indikerar t.ex. om dekalen ska spegelvändas. Här finns det också en hel del värden som jag ännu inte listat ut vad de betyder.

För RectangleIndices, XCoords, YCoords så finns det 10 positioner för varje dekal. Varje dekal kan ritas ut i 10 olika varianter beroende på var i vyn och vilken vyposition som ska ritas ut. Dekalen har via RectangleIndices en utpekad rektangel i listan för alla rektanglar. X– och YCoords anger en offset för hur dekalen ska ritas upp i vypositionen. Dessutom har varje vyposition en statisk offset för hur dekalerna ska placeras samt information om dekalen ska spegelvändas.

En intressant detalj är att alla X-koordinater i rektanglar och offset och liknande ska multipliceras med 8 för korrekt pixel-värde. Den enkla förklaringen är att man vill lagra dessa som en byte (0..255) för mindre utrymme. Bredden på skärmen för ett spel vid denna tid är annars 320 pixlar och ryms därför inte i en byte på 8-bitar.

All denna information om vypositionerna kompletteras i WallRenderData.cs.

WallRenderData
       public static readonly IList<WallRenderData> Data =
            new ReadOnlyCollection<WallRenderData>(new[]
            {
                new WallRenderData(104, 66, 5, 1, 2, 0, 3, 3, 1,-1,  0),/* A-east */
                new WallRenderData(102, 68, 5, 3, 0, 0, 3, 2, 1, 9,  0),/* B-east */
                new WallRenderData(97,  74, 5, 1, 0, 0, 3, 1, 1, 7,  0),/* C-east */
                new WallRenderData(97,  79, 5, 1, 0, 1, 3,-1, 3, 7,  0),/* E-west */
                new WallRenderData(102, 83, 5, 3, 0, 1, 3,-2, 3, 9,  0),/* F-west */
                new WallRenderData(104, 87, 5, 1, 2, 1, 3,-3, 3,-1,  0),/* G-west */
                new WallRenderData(133, 66, 5, 2, 4, 0, 3, 2, 2, 3,-12),/* B-south */
                new WallRenderData(129, 68, 5, 6, 0, 0, 3, 1, 2, 3, -6),/* C-south */
                new WallRenderData(129, 74, 5, 6, 0, 0, 3, 0, 2, 3,  0),/* D-south */
                new WallRenderData(129, 80, 5, 6, 0, 0, 3,-1, 2, 3,  6),/* E-south */
                new WallRenderData(129, 86, 5, 2, 4, 0, 3,-2, 2, 3, 12),/* F-south */
                new WallRenderData(117, 66, 6, 2, 0, 0, 2, 2, 1, 8,  0),/* H-east */
                new WallRenderData(81,  50, 8, 2, 0, 0, 2, 1, 1, 6,  0),/* I-east */
                new WallRenderData(81,  58, 8, 2, 0, 1, 2,-1, 3, 6,  0),/* K-west */
                new WallRenderData(117, 86, 6, 2, 0, 1, 2,-2, 3, 8,  0),/* L-west */
                new WallRenderData(163, 44, 8, 6, 4, 0, 2, 1, 2, 2,-10),/* I-south */
                new WallRenderData(159, 50, 8,10, 0, 0, 2, 0, 2, 2,  0),/* J-south */
                new WallRenderData(159, 60, 8, 6, 4, 0, 2,-1, 2, 2, 10),/* K-south */
                new WallRenderData(45,  25,12, 3, 0, 0, 1, 1, 1, 5,  0),/* M-east */
                new WallRenderData(45,  38,12, 3, 0, 1, 1,-1, 3, 5,  0), /* O-west */
                new WallRenderData(252, 22,12, 3,13, 0, 1, 1, 2, 1,-16),/* M-south */
                new WallRenderData(239, 41,12, 3,13, 0, 1,-1, 2, 1, 16),/* O-south */
                new WallRenderData(239, 25,12,16, 0, 0, 1, 0, 2, 1,  0),/* N-south */
                new WallRenderData(0,    0,15, 3, 0, 0, 0, 1, 1, 4,  0),/* P-east */
                new WallRenderData(0,   19,15, 3, 0, 1, 0,-1, 3, 4,  0),/* Q-west */
            });

Den näst sista siffran för varje WallRenderData indikerar vilken position av dekal 0..9 som ska användas för respektive vyposition. Den sista siffran anger en förskjutning i X-led som ska multipliceras med 8 för korrekt pixel-position.

Positionerna är utritade för varje vyposition som röda siffror i bilden nedan.

Värdet -1 indikerar att det inte finns några dekaler på dessa positioner. Troligtvis används eventuellt värde 0 för att visa något på golvet precis där du står, t.ex. en tryckplatta. Det är bara en gissning än så länge.

I datan för varje dekal så är det ofta att värdet 255 används för att indikera att en viss dekal inte har någon grafik som ska användas för en viss position. Vi kan åter igen titta lite på dekal #23. Det visar sig att index på rektanglar som ska användas för denna dekal är:

WallType 62, data decorationId 23
INDEX 62 - texture brick1 - decorationId 23 flags - 0 - linktoNext 55
skipping pos 0 index 255
pos index 1 X=0 Y=158 W=32 H=31
pos index 2 X=64 Y=158 W=24 H=18
pos index 3 X=104 Y=158 W=16 H=11
pos index 4 X=136 Y=158 W=24 H=33
pos index 5 X=160 Y=158 W=16 H=30
pos index 6 X=176 Y=158 W=16 H=19
skipping pos 7 index 255
pos index 8 X=192 Y=158 W=16 H=14
skipping pos 9 index 255

Kopplar vi datan om dekalen och dess rektanglar till grafiken så får vi

Rita upp allt

Koden för att rita upp dekalerna handlar om att undersöka om det är en väggtyp som har dekaler och sedan rita ut alla dekalerna som hör till väggtypen. Med reservation för att beräkningarna på speglade dekaler och positionen på länkade dekaler inte är helt korrekt så är koden:

Rita dekaler och vägggar
if (wallType > 6)
{
	// problem level 1 missing wallType 8 (?)
	if (!_infFile.WallMappings.ContainsKey(wallType))
		continue;

	//wallmappaing
	var mapping = _infFile.WallMappings[wallType];
	var decId = mapping.DecorationID;

	if (vp.DecorationOffset < 0)
		continue;

	if (mapping.WallID > 0)
		_spriteBatch.Draw(vp.Texture[mapping.WallID - 1], vp.DrawPosition * _scale, null, 
                 Color.White, 0f, Vector2.Zero, _scale, SpriteEffects.None, 0f);

	while (decId != 0 && decId != 0xFF)
	{
		var dec = _datFile.Decorations[decId];
		var rectIndex = dec.RectangleIndices[vp.DecorationOffset];

		if (rectIndex == 0xFF)
			break;

		var rect = _datFile.DecorationRectangles[rectIndex];
		var x = dec.XCoords[vp.DecorationOffset];
		var y = dec.YCoords[vp.DecorationOffset];

		var calculatedX = x + vp.DecorationXDelta * 8;
		var effect = SpriteEffects.None;

		//decoration flipped?
		if ((dec.Flags & 0x01) != 0)
		{
			calculatedX = calculatedX + rect.Width;
			effect = SpriteEffects.FlipHorizontally;
		}

		//wall flipped?
		if(vp.FlippedDecoration == 1)
		{
			calculatedX = 22*8 - calculatedX - rect.Width;
			effect = SpriteEffects.FlipHorizontally;
		}

		_spriteBatch.Draw(_decorationTextures[mapping.Texture], new Vector2(calculatedX, y) * _scale,
			new Rectangle(rect.X, rect.Y, rect.Width, rect.Height), Color.White, 0f, Vector2.Zero, _scale, effect, 0f);

		decId = dec.LinkToNextDecoration;
	}
}
else
	_spriteBatch.Draw(vp.Texture[wallType - 1], vp.DrawPosition * _scale, null, Color.White, 0f, 
           Vector2.Zero, _scale, SpriteEffects.None, 0f);

Jämförelser

För att göra riktiga jämförelser så installerade jag SCUMMVM som är en open source emulator för gamla spel. Det visade sig att den klarar av att emulera både Eye of the Beholder 1 & 2 samt  Lands of Lore och några titlar till.

Sedan finns ju också möjligheten att titta på källkoden till SCUMMVM och lära sig saker den vägen. Problemet är bara att det är inte helt lätt att hitta i den massiva kodbasen då motorn är generell för flera titlar men också för flera gamla plattformar som t.ex. Sega och Amiga. Motorn till Eye of the Beholder finns under engine/kyra på GitHub.

Gör vi en jämförelse på det första avloppgallret på nära håll.

Problem

Det finns dock en del problem som jag inte löst. Står man ett steg ifrån så ser det inte bra ut. Något är fel.

Det konstiga är att dekalens rektangel [2] för vypositionen är större än grafiken. När den sedan ritas ut så blir den i praktiken också överlappad. Om någon kan bringa klarhet i detta så hör av dig!

Ett annat mysterium är att level 1 har en väggtyp #8 som inte återfinns i listan över väggtyper!? Också en indikation på att det finns utrymme för förbättringar i tolkningen antagligen.

Avslutning

Vill ni bidra eller på annat vis engagera er i projektet så hör gärna av er. Koden finns numera på GitHub och kommer att uppdateras efterhand.

Målet med artiklarna för Eye of the Beholder är att ge spelet det erkännande som spelet förtjänar. Kanske också inspirera yngre generationer att testa och lära sig hur spel konstruerades förr i tiden.

Scroll to top