diff --git a/.gitignore b/.gitignore index cd2c28d..e46ce96 100644 --- a/.gitignore +++ b/.gitignore @@ -14,12 +14,12 @@ logs/ Thumbs.db # IDEs and editors +.vs .vscode/ .idea/ *.suo *.ntvs* *.njsproj -*.sln # Python __pycache__/ @@ -30,6 +30,8 @@ __pycache__/ dist/ build/ out/ +bin/ +obj/ # Environment variables .env diff --git a/DungeonSlime/.config/dotnet-tools.json b/DungeonSlime/.config/dotnet-tools.json new file mode 100644 index 0000000..fbedee1 --- /dev/null +++ b/DungeonSlime/.config/dotnet-tools.json @@ -0,0 +1,36 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-mgcb": { + "version": "3.8.4", + "commands": [ + "mgcb" + ] + }, + "dotnet-mgcb-editor": { + "version": "3.8.4", + "commands": [ + "mgcb-editor" + ] + }, + "dotnet-mgcb-editor-linux": { + "version": "3.8.4", + "commands": [ + "mgcb-editor-linux" + ] + }, + "dotnet-mgcb-editor-windows": { + "version": "3.8.4", + "commands": [ + "mgcb-editor-windows" + ] + }, + "dotnet-mgcb-editor-mac": { + "version": "3.8.4", + "commands": [ + "mgcb-editor-mac" + ] + } + } +} \ No newline at end of file diff --git a/DungeonSlime/Content/Content.mgcb b/DungeonSlime/Content/Content.mgcb new file mode 100644 index 0000000..3faae57 --- /dev/null +++ b/DungeonSlime/Content/Content.mgcb @@ -0,0 +1,111 @@ + +#----------------------------- Global Properties ----------------------------# + +/outputDir:bin/$(Platform) +/intermediateDir:obj/$(Platform) +/platform:DesktopGL +/config: +/profile:Reach +/compress:False + +#-------------------------------- References --------------------------------# + + +#---------------------------------- Content ---------------------------------# + +#begin audio/bounce.wav +/importer:WavImporter +/processor:SoundEffectProcessor +/processorParam:Quality=Best +/build:audio/bounce.wav + +#begin audio/collect.wav +/importer:WavImporter +/processor:SoundEffectProcessor +/processorParam:Quality=Best +/build:audio/collect.wav + +#begin audio/theme.ogg +/importer:OggImporter +/processor:SongProcessor +/processorParam:Quality=Best +/build:audio/theme.ogg + +#begin audio/ui.wav +/importer:WavImporter +/processor:SoundEffectProcessor +/processorParam:Quality=Best +/build:audio/ui.wav + +#begin effects/grayscaleEffect.fx +/importer:EffectImporter +/processor:EffectProcessor +/processorParam:DebugMode=Auto +/build:effects/grayscaleEffect.fx + +#begin fonts/04B_30_5x.spritefont +/importer:FontDescriptionImporter +/processor:FontDescriptionProcessor +/processorParam:PremultiplyAlpha=True +/processorParam:TextureFormat=Compressed +/build:fonts/04B_30_5x.spritefont + +#begin fonts/04b_30.fnt +/copy:fonts/04b_30.fnt + +#begin fonts/04B_30.spritefont +/importer:FontDescriptionImporter +/processor:FontDescriptionProcessor +/processorParam:PremultiplyAlpha=True +/processorParam:TextureFormat=Compressed +/build:fonts/04B_30.spritefont + +#begin fonts/gothamono.spritefont +/importer:FontDescriptionImporter +/processor:FontDescriptionProcessor +/processorParam:PremultiplyAlpha=True +/processorParam:TextureFormat=Compressed +/build:fonts/gothamono.spritefont + +#begin images/atlas-definition.xml +/copy:images/atlas-definition.xml + +#begin images/atlas.png +/importer:TextureImporter +/processor:TextureProcessor +/processorParam:ColorKeyColor=255,0,255,255 +/processorParam:ColorKeyEnabled=True +/processorParam:GenerateMipmaps=False +/processorParam:PremultiplyAlpha=True +/processorParam:ResizeToPowerOfTwo=False +/processorParam:MakeSquare=False +/processorParam:TextureFormat=Color +/build:images/atlas.png + +#begin images/background-pattern.png +/importer:TextureImporter +/processor:TextureProcessor +/processorParam:ColorKeyColor=255,0,255,255 +/processorParam:ColorKeyEnabled=True +/processorParam:GenerateMipmaps=False +/processorParam:PremultiplyAlpha=True +/processorParam:ResizeToPowerOfTwo=False +/processorParam:MakeSquare=False +/processorParam:TextureFormat=Color +/build:images/background-pattern.png + +#begin images/logo.png +/importer:TextureImporter +/processor:TextureProcessor +/processorParam:ColorKeyColor=255,0,255,255 +/processorParam:ColorKeyEnabled=True +/processorParam:GenerateMipmaps=False +/processorParam:PremultiplyAlpha=True +/processorParam:ResizeToPowerOfTwo=False +/processorParam:MakeSquare=False +/processorParam:TextureFormat=Color +/build:images/logo.png + +#begin images/tilemap-definition.xml +/copy:images/tilemap-definition.xml + diff --git a/DungeonSlime/Content/audio/bounce.wav b/DungeonSlime/Content/audio/bounce.wav new file mode 100644 index 0000000..baa7a47 Binary files /dev/null and b/DungeonSlime/Content/audio/bounce.wav differ diff --git a/DungeonSlime/Content/audio/collect.wav b/DungeonSlime/Content/audio/collect.wav new file mode 100644 index 0000000..506220d Binary files /dev/null and b/DungeonSlime/Content/audio/collect.wav differ diff --git a/DungeonSlime/Content/audio/theme.ogg b/DungeonSlime/Content/audio/theme.ogg new file mode 100644 index 0000000..72e1fd3 Binary files /dev/null and b/DungeonSlime/Content/audio/theme.ogg differ diff --git a/DungeonSlime/Content/audio/ui.wav b/DungeonSlime/Content/audio/ui.wav new file mode 100644 index 0000000..63e8941 Binary files /dev/null and b/DungeonSlime/Content/audio/ui.wav differ diff --git a/DungeonSlime/Content/effects/grayscaleEffect.fx b/DungeonSlime/Content/effects/grayscaleEffect.fx new file mode 100644 index 0000000..b33cbfc --- /dev/null +++ b/DungeonSlime/Content/effects/grayscaleEffect.fx @@ -0,0 +1,53 @@ +#if OPENGL +#define SV_POSITION POSITION +#define VS_SHADERMODEL vs_3_0 +#define PS_SHADERMODEL ps_3_0 +#else +#define VS_SHADERMODEL vs_4_0_level_9_1 +#define PS_SHADERMODEL ps_4_0_level_9_1 +#endif + +Texture2D SpriteTexture; + +// A value between 0 and 1 that controls the intensity of the grayscale effect. +// 0 = full color, 1 = full grayscale. +float Saturation = 1.0; + +sampler2D SpriteTextureSampler = sampler_state +{ + Texture = ; +}; + +struct VertexShaderOutput +{ + float4 Position : SV_POSITION; + float4 Color : COLOR0; + float2 TextureCoordinates : TEXCOORD0; +}; + +float4 MainPS(VertexShaderOutput input) : COLOR +{ + // Sample the texture + float4 color = tex2D(SpriteTextureSampler, input.TextureCoordinates) * input.Color; + + // Calculate the grayscale value based on human perception of colors. + float grayscale = dot(color.rgb, float3(0.3, 0.59, 0.11)); + + // create a grayscale color vector (same value for R, G, and B) + float3 grayscaleColor = float3(grayscale, grayscale, grayscale); + + // Linear interpolation between he grayscale color and the original color's + // rgb values based on the saturation parameter. + float3 finalColor = lerp(grayscale, color.rgb, Saturation); + + // Return the final color with the original alpha value. + return float4(finalColor, color.a); +} + +technique SpriteDrawing +{ + pass P0 + { + PixelShader = compile PS_SHADERMODEL MainPS(); + } +}; \ No newline at end of file diff --git a/DungeonSlime/Content/fonts/04B_30.spritefont b/DungeonSlime/Content/fonts/04B_30.spritefont new file mode 100644 index 0000000..ad4276e --- /dev/null +++ b/DungeonSlime/Content/fonts/04B_30.spritefont @@ -0,0 +1,17 @@ + + + + 04B_30.ttf + 17.5 + 0 + true + + + + + + ~ + + + + diff --git a/DungeonSlime/Content/fonts/04B_30.ttf b/DungeonSlime/Content/fonts/04B_30.ttf new file mode 100644 index 0000000..4b93740 Binary files /dev/null and b/DungeonSlime/Content/fonts/04B_30.ttf differ diff --git a/DungeonSlime/Content/fonts/04B_30_5x.spritefont b/DungeonSlime/Content/fonts/04B_30_5x.spritefont new file mode 100644 index 0000000..0d44d2a --- /dev/null +++ b/DungeonSlime/Content/fonts/04B_30_5x.spritefont @@ -0,0 +1,17 @@ + + + + 04B_30.ttf + 87.5 + 0 + true + + + + + + ~ + + + + diff --git a/DungeonSlime/Content/fonts/04b_30.fnt b/DungeonSlime/Content/fonts/04b_30.fnt new file mode 100644 index 0000000..772f8c5 --- /dev/null +++ b/DungeonSlime/Content/fonts/04b_30.fnt @@ -0,0 +1,99 @@ +info face="04b30" size=35 bold=0 italic=0 charset="" unicode=1 stretchH=100 smooth=0 aa=1 padding=0,0,0,0 spacing=1,1 outline=0 +common lineHeight=35 base=31 scaleW=256 scaleH=512 pages=1 packed=0 alphaChnl=0 redChnl=4 greenChnl=4 blueChnl=4 +page id=0 file="../images/atlas.png" +chars count=95 +char id=32 x=30 y=152 width=3 height=1 xoffset=-1 yoffset=34 xadvance=29 page=0 chnl=15 +char id=33 x=240 y=30 width=14 height=29 xoffset=1 yoffset=2 xadvance=19 page=0 chnl=15 +char id=34 x=102 y=232 width=25 height=15 xoffset=1 yoffset=4 xadvance=29 page=0 chnl=15 +char id=35 x=184 y=0 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=36 x=250 y=150 width=3 height=1 xoffset=-1 yoffset=34 xadvance=29 page=0 chnl=15 +char id=37 x=0 y=34 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=38 x=30 y=150 width=3 height=1 xoffset=-1 yoffset=34 xadvance=29 page=0 chnl=15 +char id=39 x=245 y=202 width=10 height=15 xoffset=1 yoffset=4 xadvance=14 page=0 chnl=15 +char id=40 x=106 y=150 width=16 height=29 xoffset=1 yoffset=2 xadvance=21 page=0 chnl=15 +char id=41 x=123 y=150 width=16 height=29 xoffset=1 yoffset=2 xadvance=21 page=0 chnl=15 +char id=42 x=128 y=232 width=14 height=15 xoffset=1 yoffset=2 xadvance=19 page=0 chnl=15 +char id=43 x=94 y=0 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=44 x=143 y=232 width=10 height=14 xoffset=1 yoffset=19 xadvance=14 page=0 chnl=15 +char id=45 x=154 y=232 width=25 height=11 xoffset=1 yoffset=12 xadvance=29 page=0 chnl=15 +char id=46 x=231 y=228 width=10 height=10 xoffset=1 yoffset=19 xadvance=14 page=0 chnl=15 +char id=47 x=60 y=60 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=48 x=90 y=60 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=49 x=46 y=150 width=19 height=29 xoffset=1 yoffset=2 xadvance=23 page=0 chnl=15 +char id=50 x=150 y=60 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=51 x=180 y=60 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=52 x=210 y=60 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=53 x=0 y=94 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=54 x=180 y=30 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=55 x=60 y=90 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=56 x=90 y=90 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=57 x=120 y=90 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=58 x=234 y=202 width=10 height=25 xoffset=1 yoffset=4 xadvance=14 page=0 chnl=15 +char id=59 x=244 y=0 width=10 height=29 xoffset=1 yoffset=4 xadvance=14 page=0 chnl=15 +char id=60 x=86 y=150 width=19 height=29 xoffset=1 yoffset=2 xadvance=23 page=0 chnl=15 +char id=61 x=182 y=176 width=25 height=25 xoffset=1 yoffset=4 xadvance=29 page=0 chnl=15 +char id=62 x=237 y=120 width=18 height=29 xoffset=1 yoffset=2 xadvance=23 page=0 chnl=15 +char id=63 x=180 y=120 width=28 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=64 x=34 y=150 width=3 height=1 xoffset=-1 yoffset=34 xadvance=29 page=0 chnl=15 +char id=65 x=120 y=120 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=66 x=150 y=120 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=67 x=124 y=0 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=68 x=154 y=0 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=69 x=214 y=0 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=70 x=30 y=30 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=71 x=60 y=30 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=72 x=90 y=30 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=73 x=240 y=90 width=14 height=29 xoffset=1 yoffset=2 xadvance=19 page=0 chnl=15 +char id=74 x=120 y=30 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=75 x=150 y=30 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=76 x=209 y=120 width=27 height=29 xoffset=1 yoffset=2 xadvance=31 page=0 chnl=15 +char id=77 x=30 y=0 width=31 height=29 xoffset=1 yoffset=2 xadvance=35 page=0 chnl=15 +char id=78 x=210 y=30 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=79 x=0 y=64 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=80 x=30 y=60 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=81 x=0 y=0 width=29 height=33 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=82 x=120 y=60 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=83 x=30 y=90 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=84 x=150 y=90 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=85 x=180 y=90 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=86 x=210 y=90 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=87 x=62 y=0 width=31 height=29 xoffset=1 yoffset=2 xadvance=35 page=0 chnl=15 +char id=88 x=0 y=124 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=89 x=30 y=120 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=90 x=60 y=120 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=91 x=240 y=60 width=14 height=29 xoffset=1 yoffset=2 xadvance=19 page=0 chnl=15 +char id=92 x=90 y=120 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=93 x=140 y=150 width=14 height=29 xoffset=1 yoffset=2 xadvance=19 page=0 chnl=15 +char id=94 x=180 y=232 width=16 height=11 xoffset=1 yoffset=4 xadvance=21 page=0 chnl=15 +char id=95 x=0 y=262 width=29 height=10 xoffset=1 yoffset=21 xadvance=33 page=0 chnl=15 +char id=96 x=197 y=228 width=16 height=11 xoffset=1 yoffset=4 xadvance=21 page=0 chnl=15 +char id=97 x=208 y=176 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=98 x=0 y=210 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=99 x=26 y=210 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=100 x=52 y=206 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=101 x=78 y=206 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=102 x=104 y=206 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=103 x=130 y=206 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=104 x=156 y=206 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=105 x=234 y=176 width=12 height=25 xoffset=1 yoffset=6 xadvance=16 page=0 chnl=15 +char id=106 x=182 y=202 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=107 x=208 y=202 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=108 x=78 y=232 width=23 height=25 xoffset=1 yoffset=6 xadvance=27 page=0 chnl=15 +char id=109 x=197 y=150 width=26 height=25 xoffset=1 yoffset=6 xadvance=31 page=0 chnl=15 +char id=110 x=0 y=236 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=111 x=26 y=236 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=112 x=78 y=180 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=113 x=0 y=154 width=25 height=29 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=114 x=52 y=232 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=115 x=224 y=150 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=116 x=0 y=184 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=117 x=26 y=184 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=118 x=52 y=180 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=119 x=170 y=150 width=26 height=25 xoffset=1 yoffset=6 xadvance=31 page=0 chnl=15 +char id=120 x=104 y=180 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=121 x=130 y=180 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=122 x=156 y=180 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=123 x=26 y=154 width=19 height=29 xoffset=1 yoffset=2 xadvance=23 page=0 chnl=15 +char id=124 x=155 y=150 width=14 height=29 xoffset=1 yoffset=2 xadvance=19 page=0 chnl=15 +char id=125 x=66 y=150 width=19 height=29 xoffset=1 yoffset=2 xadvance=23 page=0 chnl=15 +char id=126 x=214 y=228 width=16 height=11 xoffset=1 yoffset=4 xadvance=21 page=0 chnl=15 diff --git a/DungeonSlime/Content/fonts/Gothamonov0.2-Regular.otf b/DungeonSlime/Content/fonts/Gothamonov0.2-Regular.otf new file mode 100644 index 0000000..e4fe73d Binary files /dev/null and b/DungeonSlime/Content/fonts/Gothamonov0.2-Regular.otf differ diff --git a/DungeonSlime/Content/fonts/gothamono.spritefont b/DungeonSlime/Content/fonts/gothamono.spritefont new file mode 100644 index 0000000..ae9430e --- /dev/null +++ b/DungeonSlime/Content/fonts/gothamono.spritefont @@ -0,0 +1,17 @@ + + + + Gothamonov0.2-Regular.otf + 12 + 0 + true + + + + + + ~ + + + + diff --git a/DungeonSlime/Content/images/atlas-definition.xml b/DungeonSlime/Content/images/atlas-definition.xml new file mode 100644 index 0000000..c807c63 --- /dev/null +++ b/DungeonSlime/Content/images/atlas-definition.xml @@ -0,0 +1,34 @@ + + + images/atlas + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DungeonSlime/Content/images/atlas.png b/DungeonSlime/Content/images/atlas.png new file mode 100644 index 0000000..f7def20 Binary files /dev/null and b/DungeonSlime/Content/images/atlas.png differ diff --git a/DungeonSlime/Content/images/background-pattern.png b/DungeonSlime/Content/images/background-pattern.png new file mode 100644 index 0000000..2d8d878 Binary files /dev/null and b/DungeonSlime/Content/images/background-pattern.png differ diff --git a/DungeonSlime/Content/images/logo.png b/DungeonSlime/Content/images/logo.png new file mode 100644 index 0000000..1509036 Binary files /dev/null and b/DungeonSlime/Content/images/logo.png differ diff --git a/DungeonSlime/Content/images/tilemap-definition.xml b/DungeonSlime/Content/images/tilemap-definition.xml new file mode 100644 index 0000000..a17f886 --- /dev/null +++ b/DungeonSlime/Content/images/tilemap-definition.xml @@ -0,0 +1,15 @@ + + + images/atlas + + 00 01 02 01 02 01 02 01 02 01 02 01 02 01 02 03 + 04 05 05 06 05 05 06 05 05 06 05 05 06 05 05 07 + 08 09 09 09 09 09 09 09 09 09 09 09 09 09 09 11 + 04 09 09 09 09 09 09 09 10 09 09 09 09 10 09 07 + 08 09 10 09 09 09 09 09 09 09 09 09 09 09 09 11 + 04 09 09 09 09 09 09 09 09 09 09 09 09 09 09 07 + 08 10 09 09 09 09 09 09 09 09 10 09 09 09 09 11 + 04 09 09 09 09 09 10 09 09 09 09 09 09 09 09 07 + 12 13 14 13 14 13 14 13 14 13 14 13 14 13 14 15 + + diff --git a/DungeonSlime/DungeonSlime.csproj b/DungeonSlime/DungeonSlime.csproj new file mode 100644 index 0000000..1424b93 --- /dev/null +++ b/DungeonSlime/DungeonSlime.csproj @@ -0,0 +1,37 @@ + + + WinExe + net8.0 + Major + false + false + + + app.manifest + Icon.ico + + + + + + + + Icon.ico + + + Icon.bmp + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/DungeonSlime/DungeonSlime.sln b/DungeonSlime/DungeonSlime.sln new file mode 100644 index 0000000..57fe976 --- /dev/null +++ b/DungeonSlime/DungeonSlime.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.13.35919.96 d17.13 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DungeonSlime", "DungeonSlime.csproj", "{8199DA7D-F03B-4663-B5A5-6EE4C96BE025}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SaintGameLibrary", "..\SaintGameLibrary\SaintGameLibrary.csproj", "{23D3E554-ED40-4D99-B83F-53D8BAB13344}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8199DA7D-F03B-4663-B5A5-6EE4C96BE025}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8199DA7D-F03B-4663-B5A5-6EE4C96BE025}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8199DA7D-F03B-4663-B5A5-6EE4C96BE025}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8199DA7D-F03B-4663-B5A5-6EE4C96BE025}.Release|Any CPU.Build.0 = Release|Any CPU + {23D3E554-ED40-4D99-B83F-53D8BAB13344}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {23D3E554-ED40-4D99-B83F-53D8BAB13344}.Debug|Any CPU.Build.0 = Debug|Any CPU + {23D3E554-ED40-4D99-B83F-53D8BAB13344}.Release|Any CPU.ActiveCfg = Release|Any CPU + {23D3E554-ED40-4D99-B83F-53D8BAB13344}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {E486AA15-9B43-488D-8926-A70AEAD9B8A8} + EndGlobalSection +EndGlobal diff --git a/DungeonSlime/Game1.cs b/DungeonSlime/Game1.cs new file mode 100644 index 0000000..ff509f9 --- /dev/null +++ b/DungeonSlime/Game1.cs @@ -0,0 +1,52 @@ +using DungeonSlime.Scenes; +using Gum.Forms; +using Gum.Forms.Controls; +using Microsoft.Xna.Framework.Media; +using MonoGameGum; +using SaintGameLibrary; + +namespace DungeonSlime; + +public class Game1 : Core +{ + private const string GameName = "Dungeon Slime"; + private Song _themeSong; + + public Game1() : base(GameName, 1280, 720, false) + { + } + + protected override void Initialize() + { + base.Initialize(); + Audio.PlaySong(_themeSong); + InitializeGum(); + ChangeScene(new TitleScene()); + } + + protected override void LoadContent() + { + _themeSong = Content.Load("audio/theme"); + } + + private void InitializeGum() + { + GumService.Default.Initialize(this, DefaultVisualsVersion.V2); + GumService.Default.ContentLoader.XnaContentManager = Core.Content; + FrameworkElement.KeyboardsForUiControl.Add(GumService.Default.Keyboard); + FrameworkElement.GamePadsForUiControl.AddRange(GumService.Default.Gamepads); + + FrameworkElement.TabReverseKeyCombos.Add( + new KeyCombo() { PushedKey = Microsoft.Xna.Framework.Input.Keys.Up }); + FrameworkElement.TabKeyCombos.Add( + new KeyCombo() { PushedKey = Microsoft.Xna.Framework.Input.Keys.Down }); + + // The assets created for the UI were done so at 1/4th the size to keep the size of the + // texture atlas small. So we will set the default canvas size to be 1/4th the size of + // the game's resolution then tell gum to zoom in by a factor of 4. + GumService.Default.CanvasWidth = GraphicsDevice.PresentationParameters.BackBufferWidth / 4.0f; + GumService.Default.CanvasHeight = GraphicsDevice.PresentationParameters.BackBufferHeight / 4.0f; + GumService.Default.Renderer.Camera.Zoom = 4.0f; + } + +} diff --git a/DungeonSlime/GameController.cs b/DungeonSlime/GameController.cs new file mode 100644 index 0000000..83ef359 --- /dev/null +++ b/DungeonSlime/GameController.cs @@ -0,0 +1,67 @@ +using Microsoft.Xna.Framework.Input; +using Microsoft.Xna.Framework; +using SaintGameLibrary.Input; +using SaintGameLibrary; + +namespace DungeonSlime; + +public static class GameController +{ + private static KeyboardInfo s_keyboard => Core.Input.Keyboard; + private static GamePadInfo s_gamePad => Core.Input.GamePads[(int)PlayerIndex.One]; + + /// + /// Returns true if the player has triggered the "move up" action. + /// + public static bool MoveUp() => + s_keyboard.WasKeyJustPressed(Keys.Up) || + s_keyboard.WasKeyJustPressed(Keys.W) || + s_gamePad.WasButtonJustPressed(Buttons.DPadUp) || + s_gamePad.WasButtonJustPressed(Buttons.LeftThumbstickUp); + + /// + /// Returns true if the player has triggered the "move down" action. + /// + public static bool MoveDown() => + s_keyboard.WasKeyJustPressed(Keys.Down) || + s_keyboard.WasKeyJustPressed(Keys.S) || + s_gamePad.WasButtonJustPressed(Buttons.DPadDown) || + s_gamePad.WasButtonJustPressed(Buttons.LeftThumbstickDown); + + /// + /// Returns true if the player has triggered the "move left" action. + /// + public static bool MoveLeft() => + s_keyboard.WasKeyJustPressed(Keys.Left) || + s_keyboard.WasKeyJustPressed(Keys.A) || + s_gamePad.WasButtonJustPressed(Buttons.DPadLeft) || + s_gamePad.WasButtonJustPressed(Buttons.LeftThumbstickLeft); + + /// + /// Returns true if the player has triggered the "move right" action. + /// + public static bool MoveRight() => + s_keyboard.WasKeyJustPressed(Keys.Right) || + s_keyboard.WasKeyJustPressed(Keys.D) || + s_gamePad.WasButtonJustPressed(Buttons.DPadRight) || + s_gamePad.WasButtonJustPressed(Buttons.LeftThumbstickRight); + + /// + /// Returns true if the player has triggered the "pause" action. + /// + public static bool Pause() + { + return s_keyboard.WasKeyJustPressed(Keys.Escape) || + s_gamePad.WasButtonJustPressed(Buttons.Start); + } + + /// + /// Returns true if the player has triggered the "action" button, + /// typically used for menu confirmation. + /// + public static bool Action() + { + return s_keyboard.WasKeyJustPressed(Keys.Enter) || + s_gamePad.WasButtonJustPressed(Buttons.A); + } +} diff --git a/DungeonSlime/GameObjects/Bat.cs b/DungeonSlime/GameObjects/Bat.cs new file mode 100644 index 0000000..f361740 --- /dev/null +++ b/DungeonSlime/GameObjects/Bat.cs @@ -0,0 +1,99 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Audio; +using SaintGameLibrary; +using SaintGameLibrary.Graphics; +using System; + +namespace DungeonSlime.GameObjects; + +/// +/// Creates a new Bat using the specified animated sprite and sound effect. +/// +/// The AnimatedSprite ot use when drawing the bat. +/// The sound effect to play when the bat bounces off a wall. +public class Bat(AnimatedSprite sprite, SoundEffect bounceSoundEffect) +{ + private const float MOVEMENT_SPEED = 5.0f; + private Vector2 _velocity; + private AnimatedSprite _sprite = sprite; + private SoundEffect _bounceSoundEffect = bounceSoundEffect; + + /// + /// Gets or Sets the position of the bat. + /// + public Vector2 Position { get; set; } + + /// + /// Randomizes the velocity of the bat. + /// + public void RandomizeVelocity() + { + var angle = (float)(Random.Shared.NextDouble() * MathHelper.TwoPi); + var x = (float)Math.Cos(angle); + var y = (float)Math.Sin(angle); + var direction = new Vector2(x, y); + _velocity = direction * MOVEMENT_SPEED; + } + + /// + /// Handles a bounce event when the bat collides with a wall or boundary. + /// + /// The normal vector of the surface the bat is bouncing against. + public void Bounce(Vector2 normal) + { + var newPosition = Position; + // Adjust the position based on the normal to prevent sticking to walls. + if (normal.X != 0) + { + // We are bouncing off a vertical wall (left/right). + // Move slightly away from the wall in the direction of the normal. + newPosition.X += normal.X * (_sprite.Width * 0.1f); + } + + if (normal.Y != 0) + { + // We are bouncing off a horizontal wall (top/bottom). + // Move slightly way from the wall in the direction of the normal. + newPosition.Y += normal.Y * (_sprite.Height * 0.1f); + } + + // Apply the new position + Position = newPosition; + + // Apply reflection based on the normal. + _velocity = Vector2.Reflect(_velocity, normal); + + // Play the bounce sound effect. + Core.Audio.PlaySoundEffect(_bounceSoundEffect); + } + + /// + /// Returns a Circle value that represents collision bounds of the bat. + /// + /// A Circle value. + public Circle GetBounds() + { + var x = (int)(Position.X + _sprite.Width * 0.5f); + var y = (int)(Position.Y + _sprite.Height * 0.5f); + var radius = (int)(_sprite.Width * 0.25f); + return new Circle(x, y, radius); + } + + /// + /// Updates the bat. + /// + /// A snapshot of the timing values for the current update cycle. + public void Update(GameTime gameTime) + { + _sprite.Update(gameTime); + Position += _velocity; + } + + /// + /// Draws the bat. + /// + public void Draw() + { + _sprite.Draw(Core.SpriteBatch, Position); + } +} diff --git a/DungeonSlime/GameObjects/Slime.cs b/DungeonSlime/GameObjects/Slime.cs new file mode 100644 index 0000000..0f1fc77 --- /dev/null +++ b/DungeonSlime/GameObjects/Slime.cs @@ -0,0 +1,225 @@ +using Microsoft.Xna.Framework; +using SaintGameLibrary; +using SaintGameLibrary.Graphics; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace DungeonSlime.GameObjects; + +/// +/// Creates a new Slime using the specified animated sprite. +/// +/// The AnimatedSprite to use when drawing the slime. +public class Slime(AnimatedSprite sprite) +{ + // A constant value that represents the amount of time to wait between + // movement updates. + private static readonly TimeSpan s_movementTime = TimeSpan.FromMilliseconds(200); + + // The amount of time that has elapsed since the last movement update. + private TimeSpan _movementTimer; + + // Normalized value (0-1) representing progress between movement ticks for visual interpolation + private float _movementProgress; + + // The next direction to apply to the head of the slime chain during the + // next movement update. + private Vector2 _nextDirection; + + // The number of pixels to move the head segment during the movement cycle. + private float _stride; + + // Tracks the segments of the slime chain. + private List _segments; + + // The AnimatedSprite used when drawing each slime segment + private AnimatedSprite _sprite = sprite; + + private Queue _inputBuffer; + private const int MAX_BUFFER_SIZE = 2; + + /// + /// Event that is raised if it is detected that the head segment of the slime + /// has collided with a body segment. + /// + public event EventHandler BodyCollision; + + /// + /// Initializes the slime, can be used to reset it back to an initial state. + /// + /// The position the slime should start at. + /// The total number of pixels to move the head segment during each movement cycle. + public void Initialize(Vector2 startingPosition, float stride) + { + _segments = []; + _stride = stride; + + var head = new SlimeSegment + { + At = startingPosition, + To = startingPosition + new Vector2(_stride, 0), + Direction = Vector2.UnitX + }; + + _segments.Add(head); + _nextDirection = head.Direction; + _movementTimer = TimeSpan.Zero; + + _inputBuffer = new Queue(MAX_BUFFER_SIZE); + } + + private void HandleInput() + { + var potentialNextDirection = Vector2.Zero; + if (GameController.MoveUp()) + { + potentialNextDirection = -Vector2.UnitY; + } + else if (GameController.MoveDown()) + { + potentialNextDirection = Vector2.UnitY; + } + else if (GameController.MoveLeft()) + { + potentialNextDirection = -Vector2.UnitX; + } + else if (GameController.MoveRight()) + { + potentialNextDirection = Vector2.UnitX; + } + + // If a new direction was input, consider adding it to the buffer + if (potentialNextDirection != Vector2.Zero && _inputBuffer.Count < MAX_BUFFER_SIZE) + { + // If the buffer is empty, validate against the current direction; + // otherwise, validate against the last buffered direction + var validateAgainst = _inputBuffer.Count > 0 + ? _inputBuffer.Last() + : _segments[0].Direction; + + // Only allow direction change if it is not reversing the current + // direction. This prevents th slime from backing into itself + var dot = Vector2.Dot(potentialNextDirection, validateAgainst); + if (dot >= 0) + { + _inputBuffer.Enqueue(potentialNextDirection); + } + } + } + + private void Move() + { + if (_inputBuffer.Count > 0) + { + _nextDirection = _inputBuffer.Dequeue(); + } + + var head = _segments[0]; + head.Direction = _nextDirection; + head.At = head.To; + head.To = head.At + head.Direction * _stride; + + // Insert the new adjusted value for the head at the front of the + // segments and remove the tail segment. This effectively moves + // the entire chain forward without needing to loop through every + // segment and update its "at" and "to" positions. + _segments.Insert(0, head); + _segments.RemoveAt(_segments.Count - 1); + + // Iterate through all of the segments except the head and check + // if they are at the same position as the head. If they are, then + // the head is colliding with a body segment and a body collision + // has occurred. + for (var i = 1; i < _segments.Count; i++) + { + var segment = _segments[i]; + if (head.At == segment.At) + { + BodyCollision?.Invoke(this, EventArgs.Empty); + return; + } + } + } + + /// + /// Informs the slime to grow by one segment. + /// + public void Grow() + { + var tail = _segments[_segments.Count - 1]; + + // Create a new tail segment that is positioned a grid cell in the + // reverse direction from the tail moving to the tail. + var newTail = new SlimeSegment(); + newTail.At = tail.To + tail.ReverseDirection * _stride; + newTail.To = tail.At; + newTail.Direction = Vector2.Normalize(tail.At - newTail.At); + + _segments.Add(newTail); + } + + /// + /// Updates the slime. + /// + /// A snapshot of the timing values for the current update cycle. + public void Update(GameTime gameTime) + { + _sprite.Update(gameTime); + HandleInput(); + _movementTimer += gameTime.ElapsedGameTime; + + // If the movement timer has accumulated enough time to be greater than + // the movement time threshold, then perform a full movement. + if (_movementTimer >= s_movementTime) + { + _movementTimer -= s_movementTime; + Move(); + } + + // Update the movement lerp offset amount + _movementProgress = (float)(_movementTimer.TotalSeconds / s_movementTime.TotalSeconds); + } + + /// + /// Draws the slime. + /// + public void Draw() + { + // Iterate through each segment and draw it + foreach (var segment in _segments) + { + // Calculate the visual position of the segment at the moment by + // lerping between its "at" and "to" position by the movement + // offset lerp amount + var pos = Vector2.Lerp(segment.At, segment.To, _movementProgress); + + // Draw the slime sprite at the calculated visual position of this + // segment + _sprite.Draw(Core.SpriteBatch, pos); + } + } + + /// + /// Returns a Circle value that represents collision bounds of the slime. + /// + /// A Circle value. + public Circle GetBounds() + { + var head = _segments[0]; + + // Calculate the visual position of the head at the moment of this + // method call by lerping between the "at" and "to" position by the + // movement offset lerp amount + var pos = Vector2.Lerp(head.At, head.To, _movementProgress); + + // Create the bounds using the calculated visual position of the head. + var bounds = new Circle( + (int)(pos.X + (_sprite.Width * 0.5f)), + (int)(pos.Y + (_sprite.Height * 0.5f)), + (int)(_sprite.Width * 0.5f) + ); + + return bounds; + } +} diff --git a/DungeonSlime/GameObjects/SlimeSegment.cs b/DungeonSlime/GameObjects/SlimeSegment.cs new file mode 100644 index 0000000..ef3b84a --- /dev/null +++ b/DungeonSlime/GameObjects/SlimeSegment.cs @@ -0,0 +1,26 @@ +using Microsoft.Xna.Framework; + +namespace DungeonSlime.GameObjects; + +public struct SlimeSegment +{ + /// + /// The position this slime segment is at before the movement cycle occurs. + /// + public Vector2 At; + + /// + /// The position this slime segment should move to during the next movement cycle. + /// + public Vector2 To; + + /// + /// The direction this slime segment is moving. + /// + public Vector2 Direction; + + /// + /// The opposite direction this slime segment is moving. + /// + public Vector2 ReverseDirection => new(-Direction.X, -Direction.Y); +} diff --git a/DungeonSlime/Icon.bmp b/DungeonSlime/Icon.bmp new file mode 100644 index 0000000..2b48165 Binary files /dev/null and b/DungeonSlime/Icon.bmp differ diff --git a/DungeonSlime/Icon.ico b/DungeonSlime/Icon.ico new file mode 100644 index 0000000..7d9dec1 Binary files /dev/null and b/DungeonSlime/Icon.ico differ diff --git a/DungeonSlime/Program.cs b/DungeonSlime/Program.cs new file mode 100644 index 0000000..d491c40 --- /dev/null +++ b/DungeonSlime/Program.cs @@ -0,0 +1,2 @@ +using var game = new DungeonSlime.Game1(); +game.Run(); diff --git a/DungeonSlime/Scenes/GameScene.cs b/DungeonSlime/Scenes/GameScene.cs new file mode 100644 index 0000000..a84a9b9 --- /dev/null +++ b/DungeonSlime/Scenes/GameScene.cs @@ -0,0 +1,332 @@ +using System; +using DungeonSlime.GameObjects; +using DungeonSlime.UI; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Audio; +using Microsoft.Xna.Framework.Graphics; +using MonoGameGum; +using SaintGameLibrary; +using SaintGameLibrary.Graphics; +using SaintGameLibrary.Scenes; + +namespace DungeonSlime.Scenes; + +public class GameScene : Scene +{ + private Slime _slime; + private Bat _bat; + private Tilemap _tilemap; + private Rectangle _roomBounds; + private SoundEffect _collectSoundEffect; + private int _score; + private GameSceneUI _ui; + private GameState _state; + + private Effect _grayscaleEffect; + private float _saturation = 1.0f; + private const float FADE_SPEED = 0.02f; + + public override void Initialize() + { + base.Initialize(); + Core.ExitOnEscape = false; + + // Create the room bounds by getting the bounds of the screen then + // using the Inflate method to "Deflate" the bounds by the width and + // height of a tile so that the bounds only covers the inside room of + // the dungeon tilemap. + _roomBounds = Core.GraphicsDevice.PresentationParameters.Bounds; + _roomBounds.Inflate(-_tilemap.TileWidth, -_tilemap.TileHeight); + + // Subscribe to the slime's BodyCollision event so that a game over + // can be triggered when this event is raised. + _slime.BodyCollision += OnSlimeBodyCollision; + + // Clear any UI elements from the root element created in previous scenes. + GumService.Default.Root.Children.Clear(); + + // Initialize the user interface for the game scene. + InitializeUI(); + + // Initialize a new game to be played. + InitializeNewGame(); + } + + public override void LoadContent() + { + var atlas = TextureAtlas.FromFile(Core.Content, "images/atlas-definition.xml"); + _tilemap = Tilemap.FromFile(Content, "images/tilemap-definition.xml"); + _tilemap.Scale = new Vector2(4.0f, 4.0f); + + var slimeAnimation = atlas.CreateAnimatedSprite("slime-animation"); + slimeAnimation.Scale = new Vector2(4.0f, 4.0f); + _slime = new Slime(slimeAnimation); + + var bounceSoundEffect = Content.Load("audio/bounce"); + _collectSoundEffect = Content.Load("audio/collect"); + + var batAnimation = atlas.CreateAnimatedSprite("bat-animation"); + batAnimation.Scale = new Vector2(4.0f, 4.0f); + _bat = new Bat(batAnimation, bounceSoundEffect); + + _grayscaleEffect = Content.Load("effects/grayscaleEffect"); + } + + public override void Update(GameTime gameTime) + { + _ui.Update(gameTime); + + if ( _state != GameState.Playing) + { + // The game is either in pause or game over state + // Decrease saturation to create fading grayscale effect + _saturation = Math.Max(0.0f, _saturation - FADE_SPEED); + + if (_state == GameState.GameOver) + { + return; + } + } + + if (GameController.Pause()) + { + TogglePause(); + } + + if (_state == GameState.Paused) + { + return; + } + + _slime.Update(gameTime); + + _bat.Update(gameTime); + + CollisionChecks(); + } + + public override void Draw(GameTime gameTime) + { + Core.GraphicsDevice.Clear(Color.AntiqueWhite); + + if (_state != GameState.Playing) + { + _grayscaleEffect.Parameters["Saturation"].SetValue(_saturation); + Core.SpriteBatch.Begin(samplerState: SamplerState.PointClamp, effect: _grayscaleEffect); + } + else + { + Core.SpriteBatch.Begin(samplerState: SamplerState.PointClamp); + } + + _tilemap.Draw(Core.SpriteBatch); + _slime.Draw(); + _bat.Draw(); + Core.SpriteBatch.End(); + + _ui.Draw(); + } + + #region Private methods + + private void InitializeUI() + { + GumService.Default.Root.Children.Clear(); + + _ui = new GameSceneUI(); + + // Subscribe to the events from the game scene ui. + _ui.ResumeButtonClick += OnResumeButtonClicked; + _ui.RetryButtonClick += OnRetryButtonClicked; + _ui.QuitButtonClick += OnQuitButtonClicked; + } + + private void OnResumeButtonClicked(object sender, EventArgs args) => + _state = GameState.Playing; + + private void OnRetryButtonClicked(object sender, EventArgs args) => + InitializeNewGame(); + + private void OnQuitButtonClicked(object sender, EventArgs args) => + Core.ChangeScene(new TitleScene()); + + private void InitializeNewGame() + { + // Calculate the position for the slime, which will be at the center + // tile of the tile map. + var slimePos = new Vector2(); + slimePos.X = (_tilemap.Columns / 2) * _tilemap.TileWidth; + slimePos.Y = (_tilemap.Rows / 2) * _tilemap.TileHeight; + + // Initialize the slime. + _slime.Initialize(slimePos, _tilemap.TileWidth); + + // Initialize the bat. + _bat.RandomizeVelocity(); + PositionBatAwayFromSlime(); + + _score = 0; + _state = GameState.Playing; + } + + private void CollisionChecks() + { + // Capture the current bounds of the slime and bat. + var slimeBounds = _slime.GetBounds(); + var batBounds = _bat.GetBounds(); + + // FIrst perform a collision check to see if the slime is colliding with + // the bat, which means the slime eats the bat. + if (slimeBounds.Intersects(batBounds)) + { + // Move the bat to a new position away from the slime. + PositionBatAwayFromSlime(); + + // Randomize the velocity of the bat. + _bat.RandomizeVelocity(); + + // Tell the slime to grow. + _slime.Grow(); + + // Increment the score. + _score += 100; + + // Update the score display on the UI. + _ui.UpdateScoreText(_score); + + // Play the collect sound effect. + Core.Audio.PlaySoundEffect(_collectSoundEffect); + } + + // Next check if the slime is colliding with the wall by validating if + // it is within the bounds of the room. If it is outside the room + // bounds, then it collided with a wall which triggers a game over. + if (slimeBounds.Top < _roomBounds.Top || + slimeBounds.Bottom > _roomBounds.Bottom || + slimeBounds.Left < _roomBounds.Left || + slimeBounds.Right > _roomBounds.Right) + { + GameOver(); + return; + } + + // Finally, check if the bat is colliding with a wall by validating if + // it is within the bounds of the room. If it is outside the room + // bounds, then it collided with a wall, and the bat should bounce + // off of that wall. + if (batBounds.Top < _roomBounds.Top) + { + _bat.Bounce(Vector2.UnitY); + } + else if (batBounds.Bottom > _roomBounds.Bottom) + { + _bat.Bounce(-Vector2.UnitY); + } + + if (batBounds.Left < _roomBounds.Left) + { + _bat.Bounce(Vector2.UnitX); + } + else if (batBounds.Right > _roomBounds.Right) + { + _bat.Bounce(-Vector2.UnitX); + } + } + + private void PositionBatAwayFromSlime() + { + // Calculate the position that is in the center of the bounds + // of the room. + var roomCenterX = _roomBounds.X + _roomBounds.Width * 0.5f; + var roomCenterY = _roomBounds.Y + _roomBounds.Height * 0.5f; + var roomCenter = new Vector2(roomCenterX, roomCenterY); + + var slimeBounds = _slime.GetBounds(); + var slimeCenter = new Vector2(slimeBounds.X, slimeBounds.Y); + var centerToSlime = slimeCenter - roomCenter; + + var batBounds = _bat.GetBounds(); + var padding = batBounds.Radius * 2; + var newBatPosition = Vector2.Zero; + if (Math.Abs(centerToSlime.X) > Math.Abs(centerToSlime.Y)) + { + // The slime is closer to either the left or right wall, so the Y + // position will be a random position between the top and bottom + // walls. + newBatPosition.Y = Random.Shared.Next( + _roomBounds.Top + padding, + _roomBounds.Bottom - padding + ); + + if (centerToSlime.X > 0) + { + // The slime is closer to the right side wall, so place the + // bat on the left side wall. + newBatPosition.X = _roomBounds.Left + padding; + } + else + { + // The slime is closer ot the left side wall, so place the + // bat on the right side wall. + newBatPosition.X = _roomBounds.Right - padding * 2; + } + } + else + { + // The slime is closer to either the top or bottom wall, so the X + // position will be a random position between the left and right + // walls. + newBatPosition.X = Random.Shared.Next( + _roomBounds.Left + padding, + _roomBounds.Right - padding + ); + + if (centerToSlime.Y > 0) + { + // The slime is closer to the top wall, so place the bat on the + // bottom wall. + newBatPosition.Y = _roomBounds.Top + padding; + } + else + { + // The slime is closer to the bottom wall, so place the bat on + // the top wall. + newBatPosition.Y = _roomBounds.Bottom - padding * 2; + } + } + + // Assign the new bat position. + _bat.Position = newBatPosition; + } + + private void OnSlimeBodyCollision(object sender, EventArgs args) + { + GameOver(); + } + + private void TogglePause() + { + if (_state == GameState.Paused) + { + // Resume the game + _ui.HidePausePanel(); + _state = GameState.Playing; + } + else + { + // Pause the game + _ui.ShowPausePanel(); + _state = GameState.Paused; + _saturation = 1.0f; + } + } + + private void GameOver() + { + _ui.ShowGameOverPanel(); + _state = GameState.GameOver; + _saturation = 1.0f; + } + + #endregion Private methods +} diff --git a/DungeonSlime/Scenes/TitleScene.cs b/DungeonSlime/Scenes/TitleScene.cs new file mode 100644 index 0000000..139b672 --- /dev/null +++ b/DungeonSlime/Scenes/TitleScene.cs @@ -0,0 +1,240 @@ +using DungeonSlime.UI; +using Gum.Forms.Controls; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Audio; +using Microsoft.Xna.Framework.Graphics; +using MonoGameGum; +using MonoGameGum.GueDeriving; +using SaintGameLibrary; +using SaintGameLibrary.Graphics; +using SaintGameLibrary.Scenes; +using System; + +namespace DungeonSlime.Scenes; + +public class TitleScene : Scene +{ + private const string DUNGEON_TEXT = "Dungeon"; + private const string SLIME_TEXT = "Slime"; + private const string PRESS_ENTER_TEXT = "Press Enter To Start"; + + private SpriteFont _font; + private SpriteFont _font5x; + private Vector2 _dungeonTextPos; + private Vector2 _dungeonTextOrigin; + private Vector2 _slimeTextPos; + private Vector2 _slimeTextOrigin; + private Vector2 _pressEnterPos; + private Vector2 _pressEnterOrigin; + + private Texture2D _backgroundPattern; + private Rectangle _backgroundDestination; + private Vector2 _backgroundOffset; + private float _scrollSpeed = 50.0f; + + private SoundEffect _uiSoundEffect; + private Panel _titleScreenButtonsPanel; + private Panel _optionsPanel; + private AnimatedButton _optionsButton; + private AnimatedButton _optionsBackButton; + private TextureAtlas _atlas; + + public override void Initialize() + { + base.Initialize(); + + Core.ExitOnEscape = true; + + // Set the position and origin for the Dungeon text. + var size = _font5x.MeasureString(DUNGEON_TEXT); + _dungeonTextPos = new Vector2(640, 100); + _dungeonTextOrigin = size * 0.5f; + + // Set the position and origin for the Slime text. + size = _font5x.MeasureString(SLIME_TEXT); + _slimeTextPos = new Vector2(757, 207); + _slimeTextOrigin = size * 0.5f; + + // Set the position and origin for the press enter text. + size = _font.MeasureString(PRESS_ENTER_TEXT); + _pressEnterPos = new Vector2(640, 620); + _pressEnterOrigin = size * 0.5f; + + _backgroundOffset = Vector2.Zero; + _backgroundDestination = Core.GraphicsDevice.PresentationParameters.Bounds; + + InitializeUI(); + } + + public override void LoadContent() + { + _font = Core.Content.Load("fonts/04B_30"); + _font5x = Content.Load("fonts/04B_30_5x"); + _backgroundPattern = Content.Load("images/background-pattern"); + _uiSoundEffect = Core.Content.Load("audio/ui"); + _atlas = TextureAtlas.FromFile(Core.Content, "images/atlas-definition.xml"); + } + + public override void Update(GameTime gameTime) + { + var offset = _scrollSpeed * (float)gameTime.ElapsedGameTime.TotalSeconds; + _backgroundOffset.X -= offset; + _backgroundOffset.Y -= offset; + _backgroundOffset.X %= _backgroundPattern.Width; + _backgroundOffset.Y %= _backgroundPattern.Height; + + GumService.Default.Update(gameTime); + } + + public override void Draw(GameTime gameTime) + { + Core.GraphicsDevice.Clear(new Color(32, 40, 78, 255)); + + // Draw the background pattern first using the PointWrap sampler state. + Core.SpriteBatch.Begin(samplerState: SamplerState.PointWrap); + Core.SpriteBatch.Draw(_backgroundPattern, _backgroundDestination, new Rectangle(_backgroundOffset.ToPoint(), _backgroundDestination.Size), Color.White * 0.5f); + Core.SpriteBatch.End(); + + if (_titleScreenButtonsPanel.IsVisible) + { + Core.SpriteBatch.Begin(samplerState: SamplerState.PointClamp); + var dropShadowColor = Color.Black * 0.5f; + // Draw the Dungeon text slightly offset from it is original position and + // with a transparent color to give it a drop shadow. + Core.SpriteBatch.DrawString(_font5x, DUNGEON_TEXT, _dungeonTextPos + new Vector2(10, 10), dropShadowColor, 0.0f, _dungeonTextOrigin, 1.0f, SpriteEffects.None, 1.0f); + // Draw the Dungeon text on top of that at its original position. + Core.SpriteBatch.DrawString(_font5x, DUNGEON_TEXT, _dungeonTextPos, Color.White, 0.0f, _dungeonTextOrigin, 1.0f, SpriteEffects.None, 1.0f); + // Draw the Slime text slightly offset from it is original position and + // with a transparent color to give it a drop shadow. + Core.SpriteBatch.DrawString(_font5x, SLIME_TEXT, _slimeTextPos + new Vector2(10, 10), dropShadowColor, 0.0f, _slimeTextOrigin, 1.0f, SpriteEffects.None, 1.0f); + // Draw the Slime text on top of that at its original position. + Core.SpriteBatch.DrawString(_font5x, SLIME_TEXT, _slimeTextPos, Color.White, 0.0f, _slimeTextOrigin, 1.0f, SpriteEffects.None, 1.0f); + Core.SpriteBatch.End(); + } + + GumService.Default.Draw(); + } + + + private void CreateTitlePanel() + { + // Create a container to hold all of our buttons + _titleScreenButtonsPanel = new Panel(); + _titleScreenButtonsPanel.Dock(Gum.Wireframe.Dock.Fill); + _titleScreenButtonsPanel.AddToRoot(); + + var startButton = new AnimatedButton(_atlas); + startButton.Anchor(Gum.Wireframe.Anchor.BottomLeft); + startButton.Visual.X = 50; + startButton.Visual.Y = -12; + startButton.Visual.Width = 70; + startButton.Text = "Start"; + startButton.Click += HandleStartClicked; + _titleScreenButtonsPanel.AddChild(startButton); + + _optionsButton = new AnimatedButton(_atlas); + _optionsButton.Anchor(Gum.Wireframe.Anchor.BottomRight); + _optionsButton.Visual.X = -50; + _optionsButton.Visual.Y = -12; + _optionsButton.Visual.Width = 70; + _optionsButton.Text = "Options"; + _optionsButton.Click += HandleOptionsClicked; + _titleScreenButtonsPanel.AddChild(_optionsButton); + + startButton.IsFocused = true; + } + + private void HandleStartClicked(object sender, EventArgs e) + { + Core.Audio.PlaySoundEffect(_uiSoundEffect); + Core.ChangeScene(new GameScene()); + } + + private void HandleOptionsClicked(object sender, EventArgs e) + { + Core.Audio.PlaySoundEffect(_uiSoundEffect); + _titleScreenButtonsPanel.IsVisible = false; + _optionsPanel.IsVisible = true; + _optionsBackButton.IsFocused = true; + } + + private void CreateOptionsPanel() + { + _optionsPanel = new Panel(); + _optionsPanel.Dock(Gum.Wireframe.Dock.Fill); + _optionsPanel.IsVisible = false; + _optionsPanel.AddToRoot(); + + var optionsText = new TextRuntime(); + optionsText.X = 10; + optionsText.Y = 10; + optionsText.Text = "OPTIONS"; + optionsText.UseCustomFont = true; + optionsText.FontScale = 0.5f; + optionsText.CustomFontFile = @"fonts/04b_30.fnt"; + _optionsPanel.AddChild(optionsText); + + var musicSlider = new OptionsSlider(_atlas); + musicSlider.Name = "MusicSlider"; + musicSlider.Text = "MUSIC"; musicSlider.Anchor(Gum.Wireframe.Anchor.Top); + musicSlider.Visual.Y = 30f; + musicSlider.Minimum = 0; + musicSlider.Maximum = 1; + musicSlider.Value = Core.Audio.SongVolume; + musicSlider.SmallChange = .1; + musicSlider.LargeChange = .2; + musicSlider.ValueChanged += HandleMusicSliderValueChanged; + musicSlider.ValueChangeCompleted += HandleMusicSliderValueChangeCompleted; + _optionsPanel.AddChild(musicSlider); + + var sfxSlider = new OptionsSlider(_atlas); + sfxSlider.Anchor(Gum.Wireframe.Anchor.Top); + sfxSlider.Name = "SfxSlider"; + sfxSlider.Text = "SFX"; + sfxSlider.Visual.Y = 93; + sfxSlider.Minimum = 0; + sfxSlider.Maximum = 1; + sfxSlider.Value = Core.Audio.SoundEffectVolume; + sfxSlider.SmallChange = .1; + sfxSlider.LargeChange = .2; + sfxSlider.ValueChanged += HandleSfxSliderChanged; + sfxSlider.ValueChangeCompleted += HandleSfxSliderChangeCompleted; + _optionsPanel.AddChild(sfxSlider); + + _optionsBackButton = new AnimatedButton(_atlas); + _optionsBackButton.Anchor(Gum.Wireframe.Anchor.BottomRight); + _optionsBackButton.Text = "BACK"; + _optionsBackButton.X = -28f; + _optionsBackButton.Y = -10f; + _optionsBackButton.Click += HandleOptionsButtonBack; + _optionsPanel.AddChild(_optionsBackButton); + } + + private void HandleSfxSliderChanged(object sender, EventArgs args) => + Core.Audio.SoundEffectVolume = (float)(sender as Slider).Value; + + private void HandleSfxSliderChangeCompleted(object sender, EventArgs e) => + Core.Audio.PlaySoundEffect(_uiSoundEffect); + + private void HandleMusicSliderValueChanged(object sender, EventArgs args) => + Core.Audio.SongVolume = (float)(sender as Slider).Value; + + private void HandleMusicSliderValueChangeCompleted(object sender, EventArgs args) => + Core.Audio.PlaySoundEffect(_uiSoundEffect); + + private void HandleOptionsButtonBack(object sender, EventArgs e) + { + Core.Audio.PlaySoundEffect(_uiSoundEffect); + _titleScreenButtonsPanel.IsVisible = true; + _optionsPanel.IsVisible = false; + _optionsButton.IsFocused = true; + } + + private void InitializeUI() + { + GumService.Default.Root.Children.Clear(); + CreateTitlePanel(); + CreateOptionsPanel(); + } + +} diff --git a/DungeonSlime/UI/AnimatedButton.cs b/DungeonSlime/UI/AnimatedButton.cs new file mode 100644 index 0000000..4d9c6da --- /dev/null +++ b/DungeonSlime/UI/AnimatedButton.cs @@ -0,0 +1,156 @@ +using Gum.DataTypes; +using Gum.DataTypes.Variables; +using Gum.Forms.Controls; +using Gum.Forms.DefaultVisuals; +using Gum.Graphics.Animation; +using Gum.Managers; +using Microsoft.Xna.Framework.Input; +using SaintGameLibrary.Graphics; +using System; + +namespace DungeonSlime.UI; + +/// +/// A custom button implementation that inherits from Gum's Button class to provide +/// animated visual feedback when focused. +/// +internal class AnimatedButton : Button +{ + /// + /// Creates a new AnimatedButton instance using graphics from the specified texture atlas. + /// + /// The texture atlas containing button graphics and animations + public AnimatedButton(TextureAtlas atlas) + { + var buttonVisual = (ButtonVisual)Visual; + buttonVisual.Height = 14f; + buttonVisual.HeightUnits = DimensionUnitType.Absolute; + buttonVisual.Width = 21f; + buttonVisual.WidthUnits = DimensionUnitType.RelativeToChildren; + + // Get a reference to the nine-slice background to display the button graphics + // A nine-slice allows the button to stretch while preserving corner appearance + var background = buttonVisual.Background; + background.Texture = atlas.Texture; + background.TextureAddress = TextureAddress.Custom; + background.Color = Microsoft.Xna.Framework.Color.White; + + var textInstance = buttonVisual.TextInstance; + textInstance.Text = "START"; + textInstance.Blue = 130; + textInstance.Green = 86; + textInstance.Red = 70; + textInstance.UseCustomFont = true; + textInstance.CustomFontFile = "fonts/04b_30.fnt"; + textInstance.FontScale = 0.25f; + textInstance.Anchor(Gum.Wireframe.Anchor.Center); + textInstance.Width = 0; + textInstance.WidthUnits = DimensionUnitType.RelativeToChildren; + + // Get the texture region for the unfocused button state from the atlas + var unfocusedTextureRegion = atlas.GetRegion("unfocused-button"); + var unfocusedFrame = new AnimationFrame + { + TopCoordinate = unfocusedTextureRegion.TopTextureCoordinate, + BottomCoordinate = unfocusedTextureRegion.BottomTextureCoordinate, + LeftCoordinate = unfocusedTextureRegion.LeftTextureCoordinate, + RightCoordinate = unfocusedTextureRegion.RightTextureCoordinate, + FrameLength = 0.3f, + Texture = unfocusedTextureRegion.Texture + }; + + // Create an animation chain for the unfocused state with a single frame + var unfocusedAnimation = new AnimationChain(); + unfocusedAnimation.Name = nameof(unfocusedAnimation); + unfocusedAnimation.Add(unfocusedFrame); + + // Get the multi-frame animation for the focused button state from the atlas + var focusedAtlasAnimation = atlas.GetAnimation("focused-button-animation"); + + // Create an animation chain for the focused state using all frames from the atlas animation + var focusedAnimation = new AnimationChain(); + focusedAnimation.Name = nameof(focusedAnimation); + foreach (var region in focusedAtlasAnimation.Frames) + { + var frame = new AnimationFrame + { + TopCoordinate = region.TopTextureCoordinate, + BottomCoordinate = region.BottomTextureCoordinate, + LeftCoordinate = region.LeftTextureCoordinate, + RightCoordinate = region.RightTextureCoordinate, + FrameLength = (float)focusedAtlasAnimation.Delay.TotalSeconds, + Texture = region.Texture + }; + + focusedAnimation.Add(frame); + } + + // Assign both animation chains to the nine-slice background + background.AnimationChains = + [ + unfocusedAnimation, + focusedAnimation + ]; + + + // Reset all state to default so we don't have unexpected variable assignments: + buttonVisual.ButtonCategory.ResetAllStates(); + + // Get the enabled (default/unfocused) state + var enabledState = buttonVisual.States.Enabled; + enabledState.Apply = () => + { + // When enabled but not focused, use the unfocused animation + background.CurrentChainName = unfocusedAnimation.Name; + }; + + // Create the focused state + var focusedState = buttonVisual.States.Focused; + focusedState.Apply = () => + { + // When focused, use the focused animation and enable animation playback + background.CurrentChainName = focusedAnimation.Name; + background.Animate = true; + }; + + // Create the highlighted+focused state (for mouse hover while focused) + var highlightedFocused = buttonVisual.States.HighlightedFocused; + highlightedFocused.Apply = focusedState.Apply; + + // Create the highlighted state (for mouse hover) + // by cloning the enabled state since they appear the same + var highlighted = buttonVisual.States.Highlighted; + highlighted.Apply = enabledState.Apply; + + // Add event handlers for keyboard input. + KeyDown += HandleKeyDown; + + // Add event handler for mouse hover focus. + buttonVisual.RollOn += HandleRollOn; + } + + /// + /// Handles keyboard input for navigation between buttons using left/right keys. + /// + private void HandleKeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Keys.Left) + { + // Left arrow navigates to previous control + HandleTab(TabDirection.Up, loop: true); + } + if (e.Key == Keys.Right) + { + // Right arrow navigates to next control + HandleTab(TabDirection.Down, loop: true); + } + } + + /// + /// Automatically focuses the button when the mouse hovers over it. + /// + private void HandleRollOn(object sender, EventArgs e) + { + IsFocused = true; + } +} diff --git a/DungeonSlime/UI/GameSceneUI.cs b/DungeonSlime/UI/GameSceneUI.cs new file mode 100644 index 0000000..1fe6090 --- /dev/null +++ b/DungeonSlime/UI/GameSceneUI.cs @@ -0,0 +1,254 @@ +using Gum.DataTypes; +using Gum.Forms.Controls; +using Gum.Managers; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Audio; +using MonoGameGum; +using MonoGameGum.GueDeriving; +using SaintGameLibrary; +using SaintGameLibrary.Graphics; +using System; + +namespace DungeonSlime.UI; + +public class GameSceneUI : ContainerRuntime +{ + // The string format to use when updating the text for the score display. + private static readonly string s_scoreFormat = "SCORE: {0:D6}"; + private SoundEffect _uiSoundEffect; + private Panel _pausePanel; + private AnimatedButton _resumeButton; + private Panel _gameOverPanel; + private AnimatedButton _retryButton; + private TextRuntime _scoreText; + + /// + /// Event invoked when the Resume button on the Pause panel is clicked. + /// + public event EventHandler ResumeButtonClick; + + /// + /// Event invoked when the Quit button on either the Pause panel or the + /// Game Over panel is clicked. + /// + public event EventHandler QuitButtonClick; + + /// + /// Event invoked when the Retry button on the Game Over panel is clicked. + /// + public event EventHandler RetryButtonClick; + + public GameSceneUI() + { + Dock(Gum.Wireframe.Dock.Fill); + this.AddToRoot(); + + var content = GumService.Default.ContentLoader.XnaContentManager; + var atlas = TextureAtlas.FromFile(content, "images/atlas-definition.xml"); + _uiSoundEffect = content.Load("audio/ui"); + + _scoreText = CreateScoreText(); + AddChild(_scoreText); + + _pausePanel = CreatePausePanel(atlas); + AddChild(_pausePanel.Visual); + + _gameOverPanel = CreateGameOverPanel(atlas); + AddChild(_gameOverPanel.Visual); + } + + private TextRuntime CreateScoreText() + { + var text = new TextRuntime(); + text.Anchor(Gum.Wireframe.Anchor.TopLeft); + text.WidthUnits = DimensionUnitType.RelativeToChildren; + text.X = 20.0f; + text.Y = 5.0f; + text.UseCustomFont = true; + text.CustomFontFile = @"fonts/04b_30.fnt"; + text.FontScale = 0.25f; + text.Text = string.Format(s_scoreFormat, 0); + + return text; + } + + private Panel CreatePausePanel(TextureAtlas atlas) + { + var panel = new Panel(); + panel.Anchor(Gum.Wireframe.Anchor.Center); + panel.Visual.WidthUnits = DimensionUnitType.Absolute; + panel.Visual.HeightUnits = DimensionUnitType.Absolute; + panel.Visual.Width = 264.0f; + panel.Visual.Height = 70.0f; + panel.IsVisible = false; + + var backgroundRegion = atlas.GetRegion("panel-background"); + var background = new NineSliceRuntime(); + background.Dock(Gum.Wireframe.Dock.Fill); + background.Texture = backgroundRegion.Texture; + background.TextureAddress = TextureAddress.Custom; + background.TextureHeight = backgroundRegion.Height; + background.TextureWidth = backgroundRegion.Width; + background.TextureTop = backgroundRegion.SourceRectangle.Top; + background.TextureLeft = backgroundRegion.SourceRectangle.Left; + panel.AddChild(background); + + var text = new TextRuntime(); + text.Text = "PAUSED"; + text.UseCustomFont = true; + text.CustomFontFile = "fonts/04b_30.fnt"; + text.FontScale = 0.5f; + text.X = 10.0f; + text.Y = 10.0f; + panel.AddChild(text); + + _resumeButton = new AnimatedButton(atlas); + _resumeButton.Anchor(Gum.Wireframe.Anchor.BottomLeft); + _resumeButton.Text = "RESUME"; + _resumeButton.Visual.X = 9.0f; + _resumeButton.Visual.Y = -9.0f; + _resumeButton.Click += OnResumeButtonClicked; + _resumeButton.GotFocus += OnElementGotFocus; + panel.AddChild(_resumeButton); + + var quitButton = new AnimatedButton(atlas); + quitButton.Anchor(Gum.Wireframe.Anchor.BottomRight); + quitButton.Text = "QUIT"; + quitButton.Visual.X = -9.0f; + quitButton.Visual.Y = -9.0f; + quitButton.Click += OnQuitButtonClicked; + quitButton.GotFocus += OnElementGotFocus; + panel.AddChild(quitButton); + + return panel; + } + + private Panel CreateGameOverPanel(TextureAtlas atlas) + { + var panel = new Panel(); + panel.Anchor(Gum.Wireframe.Anchor.Center); + panel.Visual.WidthUnits = DimensionUnitType.Absolute; + panel.Visual.HeightUnits = DimensionUnitType.Absolute; + panel.Visual.Width = 264.0f; + panel.Visual.Height = 70.0f; + panel.IsVisible = false; + + var backgroundRegion = atlas.GetRegion("panel-background"); + var background = new NineSliceRuntime(); + background.Dock(Gum.Wireframe.Dock.Fill); + background.Texture = backgroundRegion.Texture; + background.TextureAddress = TextureAddress.Custom; + background.TextureHeight = backgroundRegion.Height; + background.TextureWidth = backgroundRegion.Width; + background.TextureTop = backgroundRegion.SourceRectangle.Top; + background.TextureLeft = backgroundRegion.SourceRectangle.Left; + panel.AddChild(background); + + var text = new TextRuntime(); + text.Text = "GAME OVER"; + text.WidthUnits = DimensionUnitType.RelativeToChildren; + text.UseCustomFont = true; + text.CustomFontFile = "fonts/04b_30.fnt"; + text.FontScale = 0.5f; + text.X = 10.0f; + text.Y = 10.0f; + panel.AddChild(text); + + _retryButton = new AnimatedButton(atlas); + _retryButton.Text = "RETRY"; + _retryButton.Anchor(Gum.Wireframe.Anchor.BottomLeft); + _retryButton.Visual.X = 9.0f; + _retryButton.Visual.Y = -9.0f; + + _retryButton.Click += OnRetryButtonClicked; + _retryButton.GotFocus += OnElementGotFocus; + + panel.AddChild(_retryButton); + + AnimatedButton quitButton = new AnimatedButton(atlas); + quitButton.Anchor(Gum.Wireframe.Anchor.BottomRight); + quitButton.Text = "QUIT"; + quitButton.Visual.X = -9.0f; + quitButton.Visual.Y = -9.0f; + + quitButton.Click += OnQuitButtonClicked; + quitButton.GotFocus += OnElementGotFocus; + + panel.AddChild(quitButton); + + return panel; + } + + private void OnResumeButtonClicked(object sender, EventArgs args) + { + Core.Audio.PlaySoundEffect(_uiSoundEffect); + HidePausePanel(); + ResumeButtonClick?.Invoke(sender, args); + } + + private void OnRetryButtonClicked(object sender, EventArgs args) + { + Core.Audio.PlaySoundEffect(_uiSoundEffect); + HideGameOverPanel(); + RetryButtonClick?.Invoke(sender, args); + } + + private void OnQuitButtonClicked(object sender, EventArgs args) + { + Core.Audio.PlaySoundEffect(_uiSoundEffect); + HidePausePanel(); + HideGameOverPanel(); + QuitButtonClick?.Invoke(sender, args); + } + + private void OnElementGotFocus(object sender, EventArgs args) => + Core.Audio.PlaySoundEffect(_uiSoundEffect); + + /// + /// Updates the text on the score display. + /// + /// The score to display. + public void UpdateScoreText(int score) => + _scoreText.Text = string.Format(s_scoreFormat, score); + + /// + /// Tells the game scene ui to show the pause panel. + /// + public void ShowPausePanel() + { + _pausePanel.IsVisible = true; + _resumeButton.IsFocused = true; + _gameOverPanel.IsVisible = false; + } + + /// + /// Tells the game scene ui to hide the pause panel. + /// + public void HidePausePanel() => _pausePanel.IsVisible = false; + + /// + /// Tells the game scene ui to show the game over panel. + /// + public void ShowGameOverPanel() + { + _gameOverPanel.IsVisible = true; + _retryButton.IsFocused = true; + _pausePanel.IsVisible = false; + } + + /// + /// Tells the game scene ui to hide the game over panel. + /// + public void HideGameOverPanel() => _gameOverPanel.IsVisible = false; + + /// + /// Updates the game scene ui. + /// + /// A snapshot of the timing values for the current update cycle. + public void Update(GameTime gameTime) => GumService.Default.Update(gameTime); + + /// + /// Draws the game scene ui. + /// + public void Draw() => GumService.Default.Draw(); +} diff --git a/DungeonSlime/UI/OptionsSlider.cs b/DungeonSlime/UI/OptionsSlider.cs new file mode 100644 index 0000000..e7621f5 --- /dev/null +++ b/DungeonSlime/UI/OptionsSlider.cs @@ -0,0 +1,254 @@ +using Gum.DataTypes; +using Gum.DataTypes.Variables; +using Gum.Forms.Controls; +using Gum.Managers; +using Microsoft.Xna.Framework; +using MonoGameGum.GueDeriving; +using SaintGameLibrary.Graphics; +using System; + +namespace DungeonSlime.UI; + +/// +/// A custom slider control that inherits from Gum's Slider class. +/// +public class OptionsSlider : Slider +{ + private TextRuntime _textInstance; + private ColoredRectangleRuntime _fillRectangle; + + /// + /// Creates a new OptionsSlider instance using graphics from the specified texture atlas. + /// + /// The texture atlas containing slider graphics. + public OptionsSlider(TextureAtlas atlas) + { + // Create the top-level container for all visual elements + var topLevelContainer = new ContainerRuntime + { + Height = 55f, + Width = 264f + }; + + var backgroundRegion = atlas.GetRegion("panel-background"); + + // Create the background panel that contains everything + var background = new NineSliceRuntime + { + Texture = atlas.Texture, + TextureAddress = TextureAddress.Custom, + TextureHeight = backgroundRegion.Height, + TextureLeft = backgroundRegion.SourceRectangle.Left, + TextureTop = backgroundRegion.SourceRectangle.Top, + TextureWidth = backgroundRegion.Width + }; + background.Dock(Gum.Wireframe.Dock.Fill); + topLevelContainer.AddChild(background); + + // Create the title text element + _textInstance = new TextRuntime + { + CustomFontFile = @"fonts/04b_30.fnt", + UseCustomFont = true, + FontScale = 0.5f, + Text = "Replace Me", + X = 10f, + Y = 10f, + WidthUnits = DimensionUnitType.RelativeToChildren + }; + topLevelContainer.AddChild(_textInstance); + + // Create the container for the slider track and decorative elements + var innerContainer = new ContainerRuntime + { + Height = 13f, + Width = 241f, + X = 10f, + Y = 33f + }; + topLevelContainer.AddChild(innerContainer); + + var offBackgroundRegion = atlas.GetRegion("slider-off-background"); + + // Create the "OFF" side of the slider (left end) + var offBackground = new NineSliceRuntime(); + offBackground.Dock(Gum.Wireframe.Dock.Left); + offBackground.Texture = atlas.Texture; + offBackground.TextureAddress = TextureAddress.Custom; + offBackground.TextureHeight = offBackgroundRegion.Height; + offBackground.TextureLeft = offBackgroundRegion.SourceRectangle.Left; + offBackground.TextureTop = offBackgroundRegion.SourceRectangle.Top; + offBackground.TextureWidth = offBackgroundRegion.Width; + offBackground.Width = 28f; + offBackground.WidthUnits = DimensionUnitType.Absolute; + offBackground.Dock(Gum.Wireframe.Dock.Left); + innerContainer.AddChild(offBackground); + + var middleBackgroundRegion = atlas.GetRegion("slider-middle-background"); + + // Create the middle track portion of the slider + var middleBackground = new NineSliceRuntime(); + middleBackground.Dock(Gum.Wireframe.Dock.FillVertically); + middleBackground.Texture = middleBackgroundRegion.Texture; + middleBackground.TextureAddress = TextureAddress.Custom; + middleBackground.TextureHeight = middleBackgroundRegion.Height; + middleBackground.TextureLeft = middleBackgroundRegion.SourceRectangle.Left; + middleBackground.TextureTop = middleBackgroundRegion.SourceRectangle.Top; + middleBackground.TextureWidth = middleBackgroundRegion.Width; + middleBackground.Width = 179f; + middleBackground.WidthUnits = DimensionUnitType.Absolute; + middleBackground.Dock(Gum.Wireframe.Dock.Left); + middleBackground.X = 27f; + innerContainer.AddChild(middleBackground); + + var maxBackgroundRegion = atlas.GetRegion("slider-max-background"); + + // Create the "MAX" side of the slider (right end) + var maxBackground = new NineSliceRuntime(); + maxBackground.Texture = maxBackgroundRegion.Texture; + maxBackground.TextureAddress = TextureAddress.Custom; + maxBackground.TextureHeight = maxBackgroundRegion.Height; + maxBackground.TextureLeft = maxBackgroundRegion.SourceRectangle.Left; + maxBackground.TextureTop = maxBackgroundRegion.SourceRectangle.Top; + maxBackground.TextureWidth = maxBackgroundRegion.Width; + maxBackground.Width = 36f; + maxBackground.WidthUnits = DimensionUnitType.Absolute; + maxBackground.Dock(Gum.Wireframe.Dock.Right); + innerContainer.AddChild(maxBackground); + + // Create the interactive track that responds to clicks + // The special name "TrackInstance" is required for Slider functionality + var trackInstance = new ContainerRuntime(); + trackInstance.Name = "TrackInstance"; + trackInstance.Dock(Gum.Wireframe.Dock.Fill); + trackInstance.Height = -2f; + trackInstance.Width = -2f; + middleBackground.AddChild(trackInstance); + + // Create the fill rectangle that visually displays the current value + _fillRectangle = new ColoredRectangleRuntime(); + _fillRectangle.Dock(Gum.Wireframe.Dock.Left); + _fillRectangle.Width = 90f; // Default to 90% - will be updated by value changes + _fillRectangle.WidthUnits = DimensionUnitType.PercentageOfParent; + trackInstance.AddChild(_fillRectangle); + + // Add "OFF" text to the left end + var offText = new TextRuntime(); + offText.Red = 70; + offText.Green = 86; + offText.Blue = 130; + offText.CustomFontFile = @"fonts/04b_30.fnt"; + offText.FontScale = 0.25f; + offText.UseCustomFont = true; + offText.Text = "OFF"; + offText.Anchor(Gum.Wireframe.Anchor.Center); + offBackground.AddChild(offText); + + // Add "MAX" text to the right end + var maxText = new TextRuntime(); + maxText.Red = 70; + maxText.Green = 86; + maxText.Blue = 130; + maxText.CustomFontFile = @"fonts/04b_30.fnt"; + maxText.FontScale = 0.25f; + maxText.UseCustomFont = true; + maxText.Text = "MAX"; + maxText.Anchor(Gum.Wireframe.Anchor.Center); + maxBackground.AddChild(maxText); + + // Define colors for focused and unfocused states + var focusedColor = Color.White; + var unfocusedColor = Color.Gray; + + // Create slider state category - Slider.SliderCategoryName is the required name + var sliderCategory = new StateSaveCategory(); + sliderCategory.Name = SliderCategoryName; + topLevelContainer.AddCategory(sliderCategory); + + // Create the enabled (default/unfocused) state + var enabled = new StateSave(); + enabled.Name = EnabledStateName; + enabled.Apply = () => + { + // When enabled but not focused, use gray coloring for all elements + background.Color = unfocusedColor; + _textInstance.Color = unfocusedColor; + offBackground.Color = unfocusedColor; + middleBackground.Color = unfocusedColor; + maxBackground.Color = unfocusedColor; + _fillRectangle.Color = unfocusedColor; + }; + sliderCategory.States.Add(enabled); + + // Create the focused state + var focused = new StateSave(); + focused.Name = FocusedStateName; + focused.Apply = () => + { + // When focused, use white coloring for all elements + background.Color = focusedColor; + _textInstance.Color = focusedColor; + offBackground.Color = focusedColor; + middleBackground.Color = focusedColor; + maxBackground.Color = focusedColor; + _fillRectangle.Color = focusedColor; + }; + sliderCategory.States.Add(focused); + + // Create the highlighted+focused state by cloning the focused state + var highlightedFocused = focused.Clone(); + highlightedFocused.Name = HighlightedFocusedStateName; + sliderCategory.States.Add(highlightedFocused); + + // Create the highlighted state by cloning the enabled state + var highlighted = enabled.Clone(); + highlighted.Name = HighlightedStateName; + sliderCategory.States.Add(highlighted); + + // Assign the configured container as this slider's visual + Visual = topLevelContainer; + + // Enable click-to-point functionality for the slider + // This allows users to click anywhere on the track to jump to that value + IsMoveToPointEnabled = true; + + // Add event handlers + Visual.RollOn += HandleRollOn; + ValueChanged += HandleValueChanged; + ValueChangedByUi += HandleValueChangedByUi; + } + + /// + /// Gets or sets the text label for this slider. + /// + public string Text + { + get => _textInstance.Text; + set => _textInstance.Text = value; + } + + /// + /// Automatically focuses the slider when the user interacts with it + /// + private void HandleValueChangedByUi(object sender, EventArgs e) + { + IsFocused = true; + } + + /// + /// Automatically focuses the slider when the mouse hovers over it + /// + private void HandleRollOn(object sender, EventArgs e) + { + IsFocused = true; + } + + /// + /// Updates the fill rectangle width to visually represent the current value + /// + private void HandleValueChanged(object sender, EventArgs e) + { + var ratio = (Value - Minimum) / (Maximum - Minimum); + _fillRectangle.Width = 100 * (float)ratio; + } +} diff --git a/DungeonSlime/app.manifest b/DungeonSlime/app.manifest new file mode 100644 index 0000000..caf4516 --- /dev/null +++ b/DungeonSlime/app.manifest @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + true/pm + permonitorv2,permonitor + + + + diff --git a/SaintGameLibrary/Audio/AudioController.cs b/SaintGameLibrary/Audio/AudioController.cs new file mode 100644 index 0000000..13cc74c --- /dev/null +++ b/SaintGameLibrary/Audio/AudioController.cs @@ -0,0 +1,258 @@ +using Microsoft.Xna.Framework.Audio; +using Microsoft.Xna.Framework.Media; +using System; +using System.Collections.Generic; + +namespace SaintGameLibrary.Audio; + +public class AudioController : IDisposable +{ + // Tracks sound effect instances created so they can be paused, unpaused, and/or disposed. + private readonly List _activeSoundEffectInstances; + + // Tracks the volume for song playback when muting and unmuting. + private float _previousSongVolume; + + // Tracks the volume for sound effect playback when muting and unmuting. + private float _previousSoundEffectVolume; + + /// + /// Creates a new audio controller instance. + /// + public AudioController() + { + _activeSoundEffectInstances = new List(); + } + + ~AudioController() => Dispose(false); + + /// + /// Gets a value that indicates if audio is muted. + /// + public bool IsMuted { get; private set; } + + /// + /// Gets or Sets the global volume of songs. + /// + /// + /// If IsMuted is true, the getter will always return back 0.0f and the + /// setter will ignore setting the volume. + /// + public float SongVolume + { + get => IsMuted ? 0.0f : MediaPlayer.Volume; + set + { + if (IsMuted) + { + return; + } + + MediaPlayer.Volume = Math.Clamp(value, 0.0f, 1.0f); + } + } + + /// + /// Gets or Sets the global volume of sound effects. + /// + /// + /// If IsMuted is true, the getter will always return back 0.0f and the + /// setter will ignore setting the volume. + /// + public float SoundEffectVolume + { + get => IsMuted ? 0.0f : SoundEffect.MasterVolume; + set + { + if (IsMuted) + { + return; + } + + SoundEffect.MasterVolume = Math.Clamp(value, 0.0f, 1.0f); + } + } + + /// + /// Gets a value that indicates if this audio controller has been disposed. + /// + public bool IsDisposed { get; private set; } + + /// + /// Updates this audio controller. + /// + public void Update() + { + for (var i = _activeSoundEffectInstances.Count - 1; i >= 0; i--) + { + var instance = _activeSoundEffectInstances[i]; + if (instance.State == SoundState.Stopped) + { + if (!instance.IsDisposed) + { + instance.Dispose(); + } + _activeSoundEffectInstances.RemoveAt(i); + } + } + } + + /// + /// Plays the given sound effect. + /// + /// The sound effect to play. + /// The sound effect instance created by this method. + public SoundEffectInstance PlaySoundEffect(SoundEffect soundEffect) => PlaySoundEffect(soundEffect, 1.0f, 1.0f, 0.0f, false); + + /// + /// Plays the given sound effect with the specified properties. + /// + /// The sound effect to play. + /// The volume, ranging from 0.0 (silence) to 1.0 (full volume). + /// The pitch adjustment, ranging from -1.0 (down an octave) to 0.0 (no change) to 1.0 (up an octave). + /// The panning, ranging from -1.0 (left speaker) to 0.0 (centered), 1.0 (right speaker). + /// Whether the the sound effect should loop after playback. + /// The sound effect instance created by playing the sound effect. + /// The sound effect instance created by this method. + public SoundEffectInstance PlaySoundEffect(SoundEffect soundEffect, float volume, float pitch, float pan, bool isLooped) + { + // Create an instance from the sound effect given. + var soundEffectInstance = soundEffect.CreateInstance(); + + // Apply the volume, pitch, pan, and loop values specified. + soundEffectInstance.Volume = volume; + soundEffectInstance.Pitch = pitch; + soundEffectInstance.Pan = pan; + soundEffectInstance.IsLooped = isLooped; + + // Tell the instance to play + soundEffectInstance.Play(); + + // Add it to the active instances for tracking + _activeSoundEffectInstances.Add(soundEffectInstance); + + return soundEffectInstance; + } + + /// + /// Plays the given song. + /// + /// The song to play. + /// Optionally specify if the song should repeat. Default is true. + public void PlaySong(Song song, bool isRepeating = true) + { + // Check if the media player is already playing, if so, stop it. + // If we do not stop it, this could cause issues on some platforms + if (MediaPlayer.State == MediaState.Playing) + { + MediaPlayer.Stop(); + } + + MediaPlayer.Play(song); + MediaPlayer.IsRepeating = isRepeating; + } + + /// + /// Pauses all audio. + /// + public void PauseAudio() + { + // Pause any active songs playing. + MediaPlayer.Pause(); + + // Pause any active sound effects. + foreach (SoundEffectInstance soundEffectInstance in _activeSoundEffectInstances) + { + soundEffectInstance.Pause(); + } + } + + /// + /// Resumes play of all previous paused audio. + /// + public void ResumeAudio() + { + // Resume paused music + MediaPlayer.Resume(); + + // Resume any active sound effects. + foreach (SoundEffectInstance soundEffectInstance in _activeSoundEffectInstances) + { + soundEffectInstance.Resume(); + } + } + + /// + /// Mutes all audio. + /// + public void MuteAudio() + { + // Store the volume so they can be restored during ResumeAudio + _previousSongVolume = MediaPlayer.Volume; + _previousSoundEffectVolume = SoundEffect.MasterVolume; + + // Set all volumes to 0 + MediaPlayer.Volume = 0.0f; + SoundEffect.MasterVolume = 0.0f; + + IsMuted = true; + } + + /// + /// Unmutes all audio to the volume level prior to muting. + /// + public void UnmuteAudio() + { + // Restore the previous volume values. + MediaPlayer.Volume = _previousSongVolume; + SoundEffect.MasterVolume = _previousSoundEffectVolume; + IsMuted = false; + } + + /// + /// Toggles the current audio mute state. + /// + public void ToggleMute() + { + if (IsMuted) + { + UnmuteAudio(); + } + else + { + MuteAudio(); + } + } + + /// + /// Disposes of this audio controller and cleans up resources. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Disposes this audio controller and cleans up resources. + /// + /// Indicates whether managed resources should be disposed. + protected void Dispose(bool disposing) + { + if (IsDisposed) + { + return; + } + + if (disposing) + { + foreach (SoundEffectInstance soundEffectInstance in _activeSoundEffectInstances) + { + soundEffectInstance.Dispose(); + } + _activeSoundEffectInstances.Clear(); + } + + IsDisposed = true; + } +} diff --git a/SaintGameLibrary/Circle.cs b/SaintGameLibrary/Circle.cs new file mode 100644 index 0000000..bcbd319 --- /dev/null +++ b/SaintGameLibrary/Circle.cs @@ -0,0 +1,135 @@ +using Microsoft.Xna.Framework; +using System; + +namespace SaintGameLibrary; + +public readonly struct Circle : IEquatable +{ + private static readonly Circle s_empty = new(); + + /// + /// Creates a new circle with the specified position and radius. + /// + /// The x-coordinate of the center of the circle. + /// The y-coordinate of the center of the circle.. + /// The length from the center of the circle to an edge. + public Circle(int x, int y, int radius) + { + X = x; + Y = y; + Radius = radius; + } + + /// + /// Creates a new circle with the specified position and radius. + /// + /// The center of the circle. + /// The length from the center of the circle to an edge. + public Circle(Point location, int radius) + { + X = location.X; + Y = location.Y; + Radius = radius; + } + + + /// + /// The x-coordinate of the center of this circle. + /// + public readonly int X; + + /// + /// The y-coordinate of the center of this circle. + /// + public readonly int Y; + + /// + /// The length, in pixels, from the center of this circle to the edge. + /// + public readonly int Radius; + + /// + /// Gets the location of the center of this circle. + /// + public readonly Point Location => new Point(X, Y); + + /// + /// Gets a value that indicates whether this circle has a radius of 0 and a location of (0, 0). + /// + public readonly bool IsEmpty => X == 0 && Y == 0 && Radius == 0; + + /// + /// Gets the y-coordinate of the highest point on this circle. + /// + public readonly int Top => Y - Radius; + + /// + /// Gets the y-coordinate of the lowest point on this circle. + /// + public readonly int Bottom => Y + Radius; + + /// + /// Gets the x-coordinate of the leftmost point on this circle. + /// + public readonly int Left => X - Radius; + + /// + /// Gets the x-coordinate of the rightmost point on this circle. + /// + public readonly int Right => X + Radius; + + /// + /// Gets a circle with X=0, Y=0, and Radius=0. + /// + public static Circle Empty => s_empty; + + /// + /// Returns a value that indicates whether the specified circle intersects with this circle. + /// + /// The other circle to check. + /// true if the other circle intersects with this circle; otherwise, false. + public bool Intersects(Circle other) + { + var radiiSquared = (Radius + other.Radius) * (Radius + other.Radius); + var distanceSquared = Vector2.DistanceSquared(Location.ToVector2(), other.Location.ToVector2()); + return distanceSquared < radiiSquared; + } + + /// + /// Returns a value that indicates whether this circle and the specified object are equal + /// + /// The object to compare with this circle. + /// true if this circle and the specified object are equal; otherwise, false. + public override readonly bool Equals(object obj) => obj is Circle other && Equals(other); + + /// + /// Returns a value that indicates whether this circle and the specified circle are equal. + /// + /// The circle to compare with this circle. + /// true if this circle and the specified circle are equal; otherwise, false. + public readonly bool Equals(Circle other) => X == other.X && Y == other.Y && Radius == other.Radius; + + /// + /// Returns the hash code for this circle. + /// + /// The hash code for this circle as a 32-bit signed integer. + public override readonly int GetHashCode() => HashCode.Combine(X, Y, Radius); + + /// + /// Returns a value that indicates if the circle on the left hand side of the equality operator is equal to the + /// circle on the right hand side of the equality operator. + /// + /// The circle on the left hand side of the equality operator. + /// The circle on the right hand side of the equality operator. + /// true if the two circles are equal; otherwise, false. + public static bool operator ==(Circle lhs, Circle rhs) => lhs.Equals(rhs); + + /// + /// Returns a value that indicates if the circle on the left hand side of the inequality operator is not equal to the + /// circle on the right hand side of the inequality operator. + /// + /// The circle on the left hand side of the inequality operator. + /// The circle on the right hand side fo the inequality operator. + /// true if the two circle are not equal; otherwise, false. + public static bool operator !=(Circle lhs, Circle rhs) => !lhs.Equals(rhs); +} diff --git a/SaintGameLibrary/Core.cs b/SaintGameLibrary/Core.cs new file mode 100644 index 0000000..50fc45c --- /dev/null +++ b/SaintGameLibrary/Core.cs @@ -0,0 +1,127 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Content; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using SaintGameLibrary.Audio; +using SaintGameLibrary.Input; +using SaintGameLibrary.Scenes; +using System; + +namespace SaintGameLibrary; + +public class Core : Game +{ + internal static Core s_instance; + + public static Core Instance => s_instance; + + private static Scene s_activeScene; + + private static Scene s_nextScene; + + public static GraphicsDeviceManager Graphics { get; private set; } + + public static new GraphicsDevice GraphicsDevice { get; private set; } + + public static SpriteBatch SpriteBatch { get; private set; } + + public static new ContentManager Content { get; private set; } + + public static InputManager Input { get; private set; } + + public static bool ExitOnEscape { get; set; } + + public static AudioController Audio { get; private set; } + + /// + /// Creates a new Core instance. + /// + /// The title to display in the title bar of the game window. + /// The initial width, in pixels, of the game window. + /// The initial height, in pixels, of the game window. + /// Indicates if the game should start in fullscreen mode. + public Core(string title, int width, int height, bool fullScreen) + { + if (s_instance != null) + { + throw new InvalidOperationException($"Only a single Core instance can be created"); + } + + s_instance = this; + Graphics = new GraphicsDeviceManager(this); + Graphics.PreferredBackBufferWidth = width; + Graphics.PreferredBackBufferHeight = height; + Graphics.IsFullScreen = fullScreen; + Graphics.ApplyChanges(); + Window.Title = title; + Content = base.Content; + Content.RootDirectory = "Content"; + IsMouseVisible = true; + ExitOnEscape = true; + } + + protected override void Initialize() + { + base.Initialize(); + GraphicsDevice = base.GraphicsDevice; + SpriteBatch = new SpriteBatch(GraphicsDevice); + Input = new InputManager(); + Audio = new AudioController(); + } + + protected override void UnloadContent() + { + Audio.Dispose(); + base.UnloadContent(); + } + + protected override void Update(GameTime gameTime) + { + Input.Update(gameTime); + + if (ExitOnEscape && Input.Keyboard.IsKeyDown(Keys.Escape)) + { + Exit(); + } + + if (s_nextScene != null) + { + TransitionScene(); + } + + if (s_activeScene != null) + { + s_activeScene.Update(gameTime); + } + + base.Update(gameTime); + } + + protected override void Draw(GameTime gameTime) + { + // If there is an active scene, draw it. + if (s_activeScene != null) + { + s_activeScene.Draw(gameTime); + } + + base.Draw(gameTime); + } + + public static void ChangeScene(Scene next) + { + if (s_activeScene != next) + { + s_nextScene = next; + } + } + + private static void TransitionScene() + { + s_activeScene?.Dispose(); + GC.Collect(); + s_activeScene = s_nextScene; + s_nextScene = null; + s_activeScene?.Initialize(); + } +} diff --git a/SaintGameLibrary/Graphics/AnimatedSprite.cs b/SaintGameLibrary/Graphics/AnimatedSprite.cs new file mode 100644 index 0000000..5d65605 --- /dev/null +++ b/SaintGameLibrary/Graphics/AnimatedSprite.cs @@ -0,0 +1,62 @@ +using Microsoft.Xna.Framework; +using System; + +namespace SaintGameLibrary.Graphics; + +public class AnimatedSprite : Sprite +{ + private int _currentFrame; + private TimeSpan _elapsed; + private Animation _animation; + + /// + /// Creates a new animated sprite. + /// + public AnimatedSprite() { } + + /// + /// Creates a new animated sprite with the specified frames and delay. + /// + /// The animation for this animated sprite. + public AnimatedSprite(Animation animation) + { + Animation = animation; + } + + + /// + /// Gets or Sets the animation for this animated sprite. + /// + public Animation Animation + { + get => _animation; + set + { + _animation = value; + Region = _animation.Frames[0]; + } + } + + /// + /// Updates this animated sprite. + /// + /// A snapshot of the game timing values provided + /// by the framework. + public void Update(GameTime gameTime) + { + _elapsed += gameTime.ElapsedGameTime; + + if (_elapsed >= _animation.Delay) + { + _elapsed -= _animation.Delay; + _currentFrame++; + + if (_currentFrame >= _animation.Frames.Count) + { + _currentFrame = 0; + } + + Region = _animation.Frames[_currentFrame]; + } + } +} diff --git a/SaintGameLibrary/Graphics/Animation.cs b/SaintGameLibrary/Graphics/Animation.cs new file mode 100644 index 0000000..76bf112 --- /dev/null +++ b/SaintGameLibrary/Graphics/Animation.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; + +namespace SaintGameLibrary.Graphics; + +public class Animation +{ + /// + /// Creates a new animation. + /// + public Animation() + { + Frames = []; + Delay = TimeSpan.FromMilliseconds(100); + } + + /// + /// Creates a new animation with the specified frames and delay. + /// + /// An ordered collection of the frames for this animation. + /// The amount of time to delay between each frame of this animation. + public Animation(List frames, TimeSpan delay) + { + Frames = frames; + Delay = delay; + } + + /// + /// The texture regions that make up the frames of this animation. The order of the regions within the collection + /// are the order that the frames should be displayed in. + /// + public List Frames { get; set; } + + /// + /// The amount of time to delay between each frame before moving to the next frame for this animation. + /// + public TimeSpan Delay { get; set; } + +} diff --git a/SaintGameLibrary/Graphics/Sprite.cs b/SaintGameLibrary/Graphics/Sprite.cs new file mode 100644 index 0000000..b02bb6e --- /dev/null +++ b/SaintGameLibrary/Graphics/Sprite.cs @@ -0,0 +1,104 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; + +namespace SaintGameLibrary.Graphics; + +public class Sprite +{ + /// + /// Creates a new sprite. + /// + public Sprite() { } + + /// + /// Creates a new sprite using the specified source texture region. + /// + /// The texture region to use as the source texture region for this sprite. + public Sprite(TextureRegion region) + { + Region = region; + } + + /// + /// Gets or Sets the source texture region represented by this sprite. + /// + public TextureRegion Region { get; set; } + + /// + /// Gets or Sets the color mask to apply when rendering this sprite. + /// + /// + /// Default value is Color.White + /// + public Color Color { get; set; } = Color.White; + + /// + /// Gets or Sets the amount of rotation, in radians, to apply when rendering this sprite. + /// + /// + /// Default value is 0.0f + /// + public float Rotation { get; set; } = 0.0f; + + /// + /// Gets or Sets the scale factor to apply to the x- and y-axes when rendering this sprite. + /// + /// + /// Default value is Vector2.One + /// + public Vector2 Scale { get; set; } = Vector2.One; + + /// + /// Gets or Sets the xy-coordinate origin point, relative to the top-left corner, of this sprite. + /// + /// + /// Default value is Vector2.Zero + /// + public Vector2 Origin { get; set; } = Vector2.Zero; + + /// + /// Gets or Sets the sprite effects to apply when rendering this sprite. + /// + /// + /// Default value is SpriteEffects.None + /// + public SpriteEffects Effects { get; set; } = SpriteEffects.None; + + /// + /// Gets or Sets the layer depth to apply when rendering this sprite. + /// + /// + /// Default value is 0.0f + /// + public float LayerDepth { get; set; } = 0.0f; + + /// + /// Gets the width, in pixels, of this sprite. + /// + /// + /// Width is calculated by multiplying the width of the source texture region by the x-axis scale factor. + /// + public float Width => Region.Width * Scale.X; + + /// + /// Gets the height, in pixels, of this sprite. + /// + /// + /// Height is calculated by multiplying the height of the source texture region by the y-axis scale factor. + /// + public float Height => Region.Height * Scale.Y; + + /// + /// Sets the origin of this sprite to the center. + /// + public void CenterOrigin() => + Origin = new Vector2(Region.Width, Region.Height) * 0.5f; + + /// + /// Submit this sprite for drawing to the current batch. + /// + /// The SpriteBatch instance used for batching draw calls. + /// The xy-coordinate position to render this sprite at. + public void Draw(SpriteBatch spriteBatch, Vector2 position) => + Region.Draw(spriteBatch, position, Color, Rotation, Origin, Scale, Effects, LayerDepth); +} diff --git a/SaintGameLibrary/Graphics/TextureAtlas.cs b/SaintGameLibrary/Graphics/TextureAtlas.cs new file mode 100644 index 0000000..5bcf695 --- /dev/null +++ b/SaintGameLibrary/Graphics/TextureAtlas.cs @@ -0,0 +1,208 @@ +using Microsoft.Xna.Framework.Content; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using System.Collections.Generic; +using System.IO; +using System.Xml.Linq; +using System.Xml; +using System; + +namespace SaintGameLibrary.Graphics; + +public class TextureAtlas +{ + private Dictionary _regions; + private Dictionary _animations; + + /// + /// Creates a new texture atlas. + /// + public TextureAtlas() + { + _regions = []; + _animations = []; + } + + /// + /// Creates a new texture atlas instance using the given texture. + /// + /// The source texture represented by the texture atlas. + public TextureAtlas(Texture2D texture) + { + Texture = texture; + _regions = []; + _animations = []; + } + + /// + /// Gets or Sets the source texture represented by this texture atlas. + /// + public Texture2D Texture { get; set; } + + /// + /// Creates a new region and adds it to this texture atlas. + /// + /// The name to give the texture region. + /// The top-left x-coordinate position of the region boundary + /// relative to the top-left corner of the source texture boundary. + /// The top-left y-coordinate position of the region boundary + /// relative to the top-left corner of the source texture boundary. + /// The width, in pixels, of the region. + /// The height, in pixels, of the region. + public void AddRegion(string name, int x, int y, int width, int height) + { + TextureRegion region = new TextureRegion(Texture, x, y, width, height); + _regions.Add(name, region); + } + + /// + /// Gets the region from this texture atlas with the specified name. + /// + /// The name of the region to retrieve. + /// The TextureRegion with the specified name. + public TextureRegion GetRegion(string name) + { + return _regions[name]; + } + + /// + /// Removes the region from this texture atlas with the specified name. + /// + /// The name of the region to remove. + /// + public bool RemoveRegion(string name) => _regions.Remove(name); + + /// + /// Removes all regions from this texture atlas. + /// + public void Clear() => _regions.Clear(); + + /// + /// Adds the given animation to this texture atlas with the specified name. + /// + /// The name of the animation to add. + /// The animation to add. + public void AddAnimation(string animationName, Animation animation) => + _animations.Add(animationName, animation); + + /// + /// Gets the animation from this texture atlas with the specified name. + /// + /// The name of the animation to retrieve. + /// The animation with the specified name. + public Animation GetAnimation(string animationName) => _animations[animationName]; + + /// + /// Removes the animation with the specified name from this texture atlas. + /// + /// The name of the animation to remove. + /// true if the animation is removed successfully; otherwise, false. + public bool RemoveAnimation(string animationName) => _animations.Remove(animationName); + + /// + /// Creates a new sprite using the region from this texture atlas with the specified name. + /// + /// The name of the region to create the sprite with. + /// A new Sprite using the texture region with the specified name. + public Sprite CreateSprite(string regionName) => new(GetRegion(regionName)); + + /// + /// Creates a new animated sprite using the animation from this texture atlas with the specified name. + /// + /// The name of the animation to use. + /// A new AnimatedSprite using the animation with the specified name. + public AnimatedSprite CreateAnimatedSprite(string animationName) => new(GetAnimation(animationName)); + + /// + /// Creates a new texture atlas based a texture atlas xml configuration file. + /// + /// The content manager used to load the texture for the atlas. + /// The path to the xml file, relative to the content root directory. + /// The texture atlas created by this method. + public static TextureAtlas FromFile(ContentManager content, string fileName) + { + var atlas = new TextureAtlas(); + var filePath = Path.Combine(content.RootDirectory, fileName); + using var stream = TitleContainer.OpenStream(filePath); + using var reader = XmlReader.Create(stream); + var doc = XDocument.Load(reader); + var root = doc.Root; + + // The element contains the content path for the Texture2D to load. + // So we will retrieve that value then use the content manager to load the texture. + var texturePath = root.Element("Texture").Value; + atlas.Texture = content.Load(texturePath); + + // The element contains individual elements, each one describing + // a different texture region within the atlas. + // + // Example: + // + // + // + // + // + // So we retrieve all of the elements then loop through each one + // and generate a new TextureRegion instance from it and add it to this atlas. + var regions = root.Element("Regions")?.Elements("Region"); + if (regions != null) + { + foreach (var region in regions) + { + var name = region.Attribute("name")?.Value; + var x = int.Parse(region.Attribute("x")?.Value ?? "0"); + var y = int.Parse(region.Attribute("y")?.Value ?? "0"); + var width = int.Parse(region.Attribute("width")?.Value ?? "0"); + var height = int.Parse(region.Attribute("height")?.Value ?? "0"); + + if (!string.IsNullOrEmpty(name)) + { + atlas.AddRegion(name, x, y, width, height); + } + } + } + + // The element contains individual elements, each one describing + // a different animation within the atlas. + // + // Example: + // + // + // + // + // + // + // + // So we retrieve all of the elements then loop through each one + // and generate a new Animation instance from it and add it to this atlas. + var animationElements = root.Element("Animations").Elements("Animation"); + if (animationElements != null) + { + foreach (var animationElement in animationElements) + { + var name = animationElement.Attribute("name")?.Value; + var delayInMilliseconds = float.Parse(animationElement.Attribute("delay")?.Value ?? "0"); + var delay = TimeSpan.FromMilliseconds(delayInMilliseconds); + + var frames = new List(); + + var frameElements = animationElement.Elements("Frame"); + + if (frameElements != null) + { + foreach (var frameElement in frameElements) + { + var regionName = frameElement.Attribute("region").Value; + var region = atlas.GetRegion(regionName); + frames.Add(region); + } + } + + var animation = new Animation(frames, delay); + atlas.AddAnimation(name, animation); + } + } + + return atlas; + } +} diff --git a/SaintGameLibrary/Graphics/TextureRegion.cs b/SaintGameLibrary/Graphics/TextureRegion.cs new file mode 100644 index 0000000..05d4f5d --- /dev/null +++ b/SaintGameLibrary/Graphics/TextureRegion.cs @@ -0,0 +1,108 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; + +namespace SaintGameLibrary.Graphics; + +/// +/// Represents a rectangular region within a texture. +/// +public class TextureRegion +{ + /// + /// Creates a new texture region. + /// + public TextureRegion() { } + + /// + /// Creates a new texture region using the specified source texture. + /// + /// The texture to use as the source texture for this texture region. + /// The x-coordinate position of the upper-left corner of this texture region relative to the upper-left corner of the source texture. + /// The y-coordinate position of the upper-left corner of this texture region relative to the upper-left corner of the source texture. + /// The width, in pixels, of this texture region. + /// The height, in pixels, of this texture region. + public TextureRegion(Texture2D texture, int x, int y, int width, int height) + { + Texture = texture; + SourceRectangle = new Rectangle(x, y, width, height); + } + + /// + /// Gets or Sets the source texture this texture region is part of. + /// + public Texture2D Texture { get; set; } + + /// + /// Gets or Sets the source rectangle boundary of this texture region within the source texture. + /// + public Rectangle SourceRectangle { get; set; } + + /// + /// Gets the width, in pixels, of this texture region. + /// + public int Width => SourceRectangle.Width; + + /// + /// Gets the height, in pixels, of this texture region. + /// + public int Height => SourceRectangle.Height; + + /// + /// Gets the top normalized texture coordinate of this region. + /// + public float TopTextureCoordinate => SourceRectangle.Top / (float)Texture.Height; + + /// + /// Gets the bottom normalized texture coordinate of this region. + /// + public float BottomTextureCoordinate => SourceRectangle.Bottom / (float)Texture.Height; + + /// + /// Gets the left normalized texture coordinate of this region. + /// + public float LeftTextureCoordinate => SourceRectangle.Left / (float)Texture.Width; + + /// + /// Gets the right normalized texture coordinate of this region. + /// + public float RightTextureCoordinate => SourceRectangle.Right / (float)Texture.Width; + + + /// + /// Submit this texture region for drawing in the current batch. + /// + /// The spritebatch instance used for batching draw calls. + /// The xy-coordinate location to draw this texture region on the screen. + /// The color mask to apply when drawing this texture region on screen. + public void Draw(SpriteBatch spriteBatch, Vector2 position, Color color) => + Draw(spriteBatch, position, color, 0.0f, Vector2.Zero, Vector2.One, SpriteEffects.None, 0.0f); + + /// + /// Submit this texture region for drawing in the current batch. + /// + /// The spritebatch instance used for batching draw calls. + /// The xy-coordinate location to draw this texture region on the screen. + /// The color mask to apply when drawing this texture region on screen. + /// The amount of rotation, in radians, to apply when drawing this texture region on screen. + /// The center of rotation, scaling, and position when drawing this texture region on screen. + /// The scale factor to apply when drawing this texture region on screen. + /// Specifies if this texture region should be flipped horizontally, vertically, or both when drawing on screen. + /// The depth of the layer to use when drawing this texture region on screen. + public void Draw(SpriteBatch spriteBatch, Vector2 position, Color color, float rotation, Vector2 origin, float scale, SpriteEffects effects, float layerDepth) => + Draw(spriteBatch, position, color, rotation, origin, new Vector2(scale, scale), effects, layerDepth); + + + /// + /// Submit this texture region for drawing in the current batch. + /// + /// The spritebatch instance used for batching draw calls. + /// The xy-coordinate location to draw this texture region on the screen. + /// The color mask to apply when drawing this texture region on screen. + /// The amount of rotation, in radians, to apply when drawing this texture region on screen. + /// The center of rotation, scaling, and position when drawing this texture region on screen. + /// The amount of scaling to apply to the x- and y-axes when drawing this texture region on screen. + /// Specifies if this texture region should be flipped horizontally, vertically, or both when drawing on screen. + /// The depth of the layer to use when drawing this texture region on screen. + public void Draw(SpriteBatch spriteBatch, Vector2 position, Color color, float rotation, Vector2 origin, Vector2 scale, SpriteEffects effects, float layerDepth) => + spriteBatch.Draw(Texture, position, SourceRectangle, color, rotation, origin, scale, effects, layerDepth); +} diff --git a/SaintGameLibrary/Graphics/Tilemap.cs b/SaintGameLibrary/Graphics/Tilemap.cs new file mode 100644 index 0000000..cd8dc0d --- /dev/null +++ b/SaintGameLibrary/Graphics/Tilemap.cs @@ -0,0 +1,221 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Content; +using Microsoft.Xna.Framework.Graphics; +using System.IO; +using System.Xml.Linq; +using System.Xml; +using System; + +namespace SaintGameLibrary.Graphics; + +public class Tilemap +{ + private readonly Tileset _tileset; + private readonly int[] _tiles; + + /// + /// Creates a new tilemap. + /// + /// The tileset used by this tilemap. + /// The total number of columns in this tilemap. + /// The total number of rows in this tilemap. + public Tilemap(Tileset tileset, int columns, int rows) + { + _tileset = tileset; + _tiles = new int[rows * columns]; + + Rows = rows; + Columns = columns; + Count = Columns * Rows; + Scale = Vector2.One; + } + + + /// + /// Gets the total number of rows in this tilemap. + /// + public int Rows { get; } + + /// + /// Gets the total number of columns in this tilemap. + /// + public int Columns { get; } + + /// + /// Gets the total number of tiles in this tilemap. + /// + public int Count { get; } + + /// + /// Gets or Sets the scale factor to draw each tile at. + /// + public Vector2 Scale { get; set; } + + /// + /// Gets the width, in pixels, each tile is drawn at. + /// + public float TileWidth => _tileset.TileWidth * Scale.X; + + /// + /// Gets the height, in pixels, each tile is drawn at. + /// + public float TileHeight => _tileset.TileHeight * Scale.Y; + + /// + /// Sets the tile at the given index in this tilemap to use the tile from + /// the tileset at the specified tileset id. + /// + /// The index of the tile in this tilemap. + /// The tileset id of the tile from the tileset to use. + public void SetTile(int index, int tilesetID) => _tiles[index] = tilesetID; + + /// + /// Sets the tile at the given column and row in this tilemap to use the tile + /// from the tileset at the specified tileset id. + /// + /// The column of the tile in this tilemap. + /// The row of the tile in this tilemap. + /// The tileset id of the tile from the tileset to use. + public void SetTile(int column, int row, int tilesetID) => SetTile(row * Columns + column, tilesetID); + + /// + /// Gets the texture region of the tile from this tilemap at the specified index. + /// + /// The index of the tile in this tilemap. + /// The texture region of the tile from this tilemap at the specified index. + public TextureRegion GetTile(int index) => _tileset.GetTile(_tiles[index]); + + /// + /// Gets the texture region of the tile frm this tilemap at the specified + /// column and row. + /// + /// The column of the tile in this tilemap. + /// The row of the tile in this tilemap. + /// The texture region of the tile from this tilemap at the specified column and row. + public TextureRegion GetTile(int column, int row) => GetTile(row * Columns + column); + + /// + /// Draws this tilemap using the given sprite batch. + /// + /// The sprite batch used to draw this tilemap. + public void Draw(SpriteBatch spriteBatch) + { + for (var i = 0; i < Count; i++) + { + var tileSetIndex = _tiles[i]; + var tile = _tileset.GetTile(tileSetIndex); + + var x = i % Columns; + var y = i / Columns; + + var position = new Vector2(x * TileWidth, y * TileHeight); + tile.Draw(spriteBatch, position, Color.White, 0.0f, Vector2.Zero, Scale, SpriteEffects.None, 1.0f); + } + } + + /// + /// Creates a new tilemap based on a tilemap xml configuration file. + /// + /// The content manager used to load the texture for the tileset. + /// The path to the xml file, relative to the content root directory. + /// The tilemap created by this method. + public static Tilemap FromFile(ContentManager content, string filename) + { + var filePath = Path.Combine(content.RootDirectory, filename); + + using (var stream = TitleContainer.OpenStream(filePath)) + { + using (var reader = XmlReader.Create(stream)) + { + var doc = XDocument.Load(reader); + var root = doc.Root; + + // The element contains the information about the tileset + // used by the tilemap. + // + // Example + // contentPath + // + // The region attribute represents the x, y, width, and height + // components of the boundary for the texture region within the + // texture at the contentPath specified. + // + // the tileWidth and tileHeight attributes specify the width and + // height of each tile in the tileset. + // + // the contentPath value is the contentPath to the texture to + // load that contains the tileset + var tilesetElement = root.Element("Tileset"); + + var regionAttribute = tilesetElement.Attribute("region").Value; + var split = regionAttribute.Split(" ", StringSplitOptions.RemoveEmptyEntries); + var x = int.Parse(split[0]); + var y = int.Parse(split[1]); + var width = int.Parse(split[2]); + var height = int.Parse(split[3]); + + var tileWidth = int.Parse(tilesetElement.Attribute("tileWidth").Value); + var tileHeight = int.Parse(tilesetElement.Attribute("tileHeight").Value); + var contentPath = tilesetElement.Value; + + // Load the texture 2d at the content path + var texture = content.Load(contentPath); + + // Create the texture region from the texture + var textureRegion = new TextureRegion(texture, x, y, width, height); + + // Create the tileset using the texture region + var tileset = new Tileset(textureRegion, tileWidth, tileHeight); + + // The element contains lines of strings where each line + // represents a row in the tilemap. Each line is a space + // separated string where each element represents a column in that + // row. The value of the column is the id of the tile in the + // tileset to draw for that location. + // + // Example: + // + // 00 01 01 02 + // 03 04 04 05 + // 03 04 04 05 + // 06 07 07 08 + // + var tilesElement = root.Element("Tiles"); + + // Split the value of the tiles data into rows by splitting on + // the new line character + var rows = tilesElement.Value.Trim().Split('\n', StringSplitOptions.RemoveEmptyEntries); + + // Split the value of the first row to determine the total number of columns + var columnCount = rows[0].Split(" ", StringSplitOptions.RemoveEmptyEntries).Length; + + // Create the tilemap + var tilemap = new Tilemap(tileset, columnCount, rows.Length); + + // Process each row + for (var row = 0; row < rows.Length; row++) + { + // Split the row into individual columns + var columns = rows[row].Trim().Split(" ", StringSplitOptions.RemoveEmptyEntries); + + // Process each column of the current row + for (var column = 0; column < columnCount; column++) + { + // Get the tileset index for this location + var tilesetIndex = int.Parse(columns[column]); + + // Get the texture region of that tile from the tileset + var region = tileset.GetTile(tilesetIndex); + + // Add that region to the tilemap at the row and column location + tilemap.SetTile(column, row, tilesetIndex); + } + } + + return tilemap; + } + } + } + + +} diff --git a/SaintGameLibrary/Graphics/Tileset.cs b/SaintGameLibrary/Graphics/Tileset.cs new file mode 100644 index 0000000..a75fd3d --- /dev/null +++ b/SaintGameLibrary/Graphics/Tileset.cs @@ -0,0 +1,74 @@ +namespace SaintGameLibrary.Graphics; + +public class Tileset +{ + private readonly TextureRegion[] _tiles; + + /// + /// Creates a new tileset based on the given texture region with the specified + /// tile width and height. + /// + /// The texture region that contains the tiles for the tileset. + /// The width of each tile in the tileset. + /// The height of each tile in the tileset. + public Tileset(TextureRegion textureRegion, int tileWidth, int tileHeight) + { + TileWidth = tileWidth; + TileHeight = tileHeight; + Columns = textureRegion.Width / tileWidth; + Rows = textureRegion.Height / tileHeight; + Count = Columns * Rows; + + // Create the texture regions that make up each individual tile + _tiles = new TextureRegion[Count]; + + for (var i = 0; i < Count; i++) + { + var x = i % Columns * tileWidth; + var y = i / Columns * tileHeight; + _tiles[i] = new TextureRegion(textureRegion.Texture, textureRegion.SourceRectangle.X + x, textureRegion.SourceRectangle.Y + y, tileWidth, tileHeight); + } + } + + + /// + /// Gets the width, in pixels, of each tile in this tileset. + /// + public int TileWidth { get; } + + /// + /// Gets the height, in pixels, of each tile in this tileset. + /// + public int TileHeight { get; } + + /// + /// Gets the total number of columns in this tileset. + /// + public int Columns { get; } + + /// + /// Gets the total number of rows in this tileset. + /// + public int Rows { get; } + + /// + /// Gets the total number of tiles in this tileset. + /// + public int Count { get; } + + /// + /// Gets the texture region for the tile from this tileset at the given index. + /// + /// The index of the texture region in this tile set. + /// The texture region for the tile form this tileset at the given index. + public TextureRegion GetTile(int index) => _tiles[index]; + + /// + /// Gets the texture region for the tile from this tileset at the given location. + /// + /// The column in this tileset of the texture region. + /// The row in this tileset of the texture region. + /// The texture region for the tile from this tileset at given location. + public TextureRegion GetTile(int column, int row) => GetTile(row * Columns + column); + +} diff --git a/SaintGameLibrary/Input/GamePadInfo.cs b/SaintGameLibrary/Input/GamePadInfo.cs new file mode 100644 index 0000000..df09392 --- /dev/null +++ b/SaintGameLibrary/Input/GamePadInfo.cs @@ -0,0 +1,121 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; +using System; + +namespace SaintGameLibrary.Input; + +/// +/// Creates a new GamePadInfo for the gamepad connected at the specified player index. +/// +/// The index of the player for this gamepad. +public class GamePadInfo(PlayerIndex playerIndex) +{ + private TimeSpan _vibrationTimeRemaining = TimeSpan.Zero; + + /// + /// Gets the index of the player this gamepad is for. + /// + public PlayerIndex PlayerIndex { get; } = playerIndex; + + /// + /// Gets the state of input for this gamepad during the previous update cycle. + /// + public GamePadState PreviousState { get; private set; } = new GamePadState(); + + /// + /// Gets the state of input for this gamepad during the current update cycle. + /// + public GamePadState CurrentState { get; private set; } = GamePad.GetState(playerIndex); + + /// + /// Gets a value that indicates if this gamepad is currently connected. + /// + public bool IsConnected => CurrentState.IsConnected; + + /// + /// Gets the value of the left thumbstick of this gamepad. + /// + public Vector2 LeftThumbStick => CurrentState.ThumbSticks.Left; + + /// + /// Gets the value of the right thumbstick of this gamepad. + /// + public Vector2 RightThumbStick => CurrentState.ThumbSticks.Right; + + /// + /// Gets the value of the left trigger of this gamepad. + /// + public float LeftTrigger => CurrentState.Triggers.Left; + + /// + /// Gets the value of the right trigger of this gamepad. + /// + public float RightTrigger => CurrentState.Triggers.Right; + + /// + /// Updates the state information for this gamepad input. + /// + /// + public void Update(GameTime gameTime) + { + PreviousState = CurrentState; + CurrentState = GamePad.GetState(PlayerIndex); + + if (_vibrationTimeRemaining > TimeSpan.Zero) + { + _vibrationTimeRemaining -= gameTime.ElapsedGameTime; + + if (_vibrationTimeRemaining <= TimeSpan.Zero) + { + StopVibration(); + } + } + } + + /// + /// Returns a value that indicates whether the specified gamepad button is current down. + /// + /// The gamepad button to check. + /// true if the specified gamepad button is currently down; otherwise, false. + public bool IsButtonDown(Buttons button) => CurrentState.IsButtonDown(button); + + /// + /// Returns a value that indicates whether the specified gamepad button is currently up. + /// + /// The gamepad button to check. + /// true if the specified gamepad button is currently up; otherwise, false. + public bool IsButtonUp(Buttons button) => CurrentState.IsButtonUp(button); + + /// + /// Returns a value that indicates whether the specified gamepad button was just pressed on the current frame. + /// + /// The gamepad button to check. + /// true if the specified gamepad button was just pressed on the current frame; otherwise, false. + public bool WasButtonJustPressed(Buttons button) => CurrentState.IsButtonDown(button) && PreviousState.IsButtonUp(button); + + /// + /// Returns a value that indicates whether the specified gamepad button was just released on the current frame. + /// + /// The gamepad button to check. + /// true if the specified gamepad button was just released on the current frame; otherwise, false. + public bool WasButtonJustReleased(Buttons button) => CurrentState.IsButtonUp(button) && PreviousState.IsButtonDown(button); + + /// + /// Sets the vibration for all motors of this gamepad. + /// + /// The strength of the vibration from 0.0f (none) to 1.0f (full). + /// The amount of time the vibration should occur. + public void SetVibration(float strength, TimeSpan time) + { + _vibrationTimeRemaining = time; + GamePad.SetVibration(PlayerIndex, strength, strength); + } + + /// + /// Stops the vibration of all motors for this gamepad. + /// + public void StopVibration() + { + GamePad.SetVibration(PlayerIndex, 0.0f, 0.0f); + } +} diff --git a/SaintGameLibrary/Input/InputManager.cs b/SaintGameLibrary/Input/InputManager.cs new file mode 100644 index 0000000..b537c43 --- /dev/null +++ b/SaintGameLibrary/Input/InputManager.cs @@ -0,0 +1,51 @@ +using Microsoft.Xna.Framework; + +namespace SaintGameLibrary.Input; + +public class InputManager +{ + /// + /// Creates a new InputManager. + /// + public InputManager() + { + Keyboard = new KeyboardInfo(); + Mouse = new MouseInfo(); + + GamePads = new GamePadInfo[4]; + for (int i = 0; i < 4; i++) + { + GamePads[i] = new GamePadInfo((PlayerIndex)i); + } + } + + /// + /// Gets the state information of keyboard input. + /// + public KeyboardInfo Keyboard { get; private set; } + + /// + /// Gets the state information of mouse input. + /// + public MouseInfo Mouse { get; private set; } + + /// + /// Gets the state information of a gamepad. + /// + public GamePadInfo[] GamePads { get; private set; } + + /// + /// Updates the state information for the keyboard, mouse, and gamepad inputs. + /// + /// A snapshot of the timing values for the current frame. + public void Update(GameTime gameTime) + { + Keyboard.Update(); + Mouse.Update(); + + for (int i = 0; i < 4; i++) + { + GamePads[i].Update(gameTime); + } + } +} diff --git a/SaintGameLibrary/Input/KeyboardInfo.cs b/SaintGameLibrary/Input/KeyboardInfo.cs new file mode 100644 index 0000000..5d7c23c --- /dev/null +++ b/SaintGameLibrary/Input/KeyboardInfo.cs @@ -0,0 +1,66 @@ +using Microsoft.Xna.Framework.Input; + +namespace SaintGameLibrary.Input; + +public class KeyboardInfo +{ + /// + /// Creates a new KeyboardInfo. + /// + public KeyboardInfo() + { + PreviousState = new KeyboardState(); + CurrentState = Keyboard.GetState(); + } + + + /// + /// Gets the state of keyboard input during the previous update cycle. + /// + public KeyboardState PreviousState { get; private set; } + + /// + /// Gets the state of keyboard input during the current input cycle. + /// + public KeyboardState CurrentState { get; private set; } + + /// + /// Updates the state information about keyboard input. + /// + public void Update() + { + PreviousState = CurrentState; + CurrentState = Keyboard.GetState(); + } + + /// + /// Returns a value that indicates if the specified key is currently down. + /// + /// The key to check. + /// true if the specified key is currently down; otherwise, false. + public bool IsKeyDown(Keys key) => CurrentState.IsKeyDown(key); + + /// + /// Returns a value that indicates whether the specified key is currently up. + /// + /// The key to check. + /// true if the specified key is currently up; otherwise, false. + public bool IsKeyUp(Keys key) => CurrentState.IsKeyUp(key); + + /// + /// Returns a value that indicates if the specified key was just pressed on the current frame. + /// + /// The key to check. + /// true if the specified key was just pressed on the current frame; otherwise, false. + public bool WasKeyJustPressed(Keys key) => + CurrentState.IsKeyDown(key) && PreviousState.IsKeyUp(key); + + /// + /// Returns a value that indicates if the specified key was just released on the current frame. + /// + /// The key to check. + /// true if the specified key was just released on the current frame; otherwise, false. + public bool WasKeyJustReleased(Keys key) => + CurrentState.IsKeyUp(key) && PreviousState.IsKeyDown(key); + +} diff --git a/SaintGameLibrary/Input/MouseButton.cs b/SaintGameLibrary/Input/MouseButton.cs new file mode 100644 index 0000000..79b150d --- /dev/null +++ b/SaintGameLibrary/Input/MouseButton.cs @@ -0,0 +1,10 @@ +namespace SaintGameLibrary.Input; + +public enum MouseButton +{ + Left, + Middle, + Right, + XButton1, + XButton2 +} diff --git a/SaintGameLibrary/Input/MouseInfo.cs b/SaintGameLibrary/Input/MouseInfo.cs new file mode 100644 index 0000000..3282c2d --- /dev/null +++ b/SaintGameLibrary/Input/MouseInfo.cs @@ -0,0 +1,185 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; + +namespace SaintGameLibrary.Input; + +public class MouseInfo +{ + /// + /// Creates a new MouseInfo. + /// + public MouseInfo() + { + PreviousState = new MouseState(); + CurrentState = Mouse.GetState(); + } + + + /// + /// The state of mouse input during the previous update cycle. + /// + public MouseState PreviousState { get; private set; } + + /// + /// The state of mouse input during the current update cycle. + /// + public MouseState CurrentState { get; private set; } + + /// + /// Gets or Sets the current position of the mouse cursor in screen space. + /// + public Point Position + { + get => CurrentState.Position; + set => SetPosition(value.X, value.Y); + } + + /// + /// Gets or Sets the current x-coordinate position of the mouse cursor in screen space. + /// + public int X + { + get => CurrentState.X; + set => SetPosition(value, CurrentState.Y); + } + + /// + /// Gets or Sets the current y-coordinate position of the mouse cursor in screen space. + /// + public int Y + { + get => CurrentState.Y; + set => SetPosition(CurrentState.X, value); + } + + /// + /// Gets the difference in the mouse cursor position between the previous and current frame. + /// + public Point PositionDelta => CurrentState.Position - PreviousState.Position; + + /// + /// Gets the difference in the mouse cursor x-position between the previous and current frame. + /// + public int XDelta => CurrentState.X - PreviousState.X; + + /// + /// Gets the difference in the mouse cursor y-position between the previous and current frame. + /// + public int YDelta => CurrentState.Y - PreviousState.Y; + + /// + /// Gets a value that indicates if the mouse cursor moved between the previous and current frames. + /// + public bool WasMoved => PositionDelta != Point.Zero; + + /// + /// Gets the cumulative value of the mouse scroll wheel since the start of the game. + /// + public int ScrollWheel => CurrentState.ScrollWheelValue; + + /// + /// Gets the value of the scroll wheel between the previous and current frame. + /// + public int ScrollWheelDelta => CurrentState.ScrollWheelValue - PreviousState.ScrollWheelValue; + + /// + /// Updates the state information about mouse input. + /// + public void Update() + { + PreviousState = CurrentState; + CurrentState = Mouse.GetState(); + } + + /// + /// Returns a value that indicates whether the specified mouse button is currently down. + /// + /// The mouse button to check. + /// true if the specified mouse button is currently down; otherwise, false. + public bool IsButtonDown(MouseButton button) + { + return button switch + { + MouseButton.Left => CurrentState.LeftButton == ButtonState.Pressed, + MouseButton.Middle => CurrentState.MiddleButton == ButtonState.Pressed, + MouseButton.Right => CurrentState.RightButton == ButtonState.Pressed, + MouseButton.XButton1 => CurrentState.XButton1 == ButtonState.Pressed, + MouseButton.XButton2 => CurrentState.XButton2 == ButtonState.Pressed, + _ => false, + }; + } + + /// + /// Returns a value that indicates whether the specified mouse button is current up. + /// + /// The mouse button to check. + /// true if the specified mouse button is currently up; otherwise, false. + public bool IsButtonUp(MouseButton button) + { + return button switch + { + MouseButton.Left => CurrentState.LeftButton == ButtonState.Released, + MouseButton.Middle => CurrentState.MiddleButton == ButtonState.Released, + MouseButton.Right => CurrentState.RightButton == ButtonState.Released, + MouseButton.XButton1 => CurrentState.XButton1 == ButtonState.Released, + MouseButton.XButton2 => CurrentState.XButton2 == ButtonState.Released, + _ => false, + }; + } + + /// + /// Returns a value that indicates whether the specified mouse button was just pressed on the current frame. + /// + /// The mouse button to check. + /// true if the specified mouse button was just pressed on the current frame; otherwise, false. + public bool WasButtonJustPressed(MouseButton button) + { + return button switch + { + MouseButton.Left => CurrentState.LeftButton == ButtonState.Pressed && PreviousState.LeftButton == ButtonState.Released, + MouseButton.Middle => CurrentState.MiddleButton == ButtonState.Pressed && PreviousState.MiddleButton == ButtonState.Released, + MouseButton.Right => CurrentState.RightButton == ButtonState.Pressed && PreviousState.RightButton == ButtonState.Released, + MouseButton.XButton1 => CurrentState.XButton1 == ButtonState.Pressed && PreviousState.XButton1 == ButtonState.Released, + MouseButton.XButton2 => CurrentState.XButton2 == ButtonState.Pressed && PreviousState.XButton2 == ButtonState.Released, + _ => false, + }; + } + + /// + /// Returns a value that indicates whether the specified mouse button was just released on the current frame. + /// + /// The mouse button to check. + /// true if the specified mouse button was just released on the current frame; otherwise, false. + public bool WasButtonJustReleased(MouseButton button) + { + return button switch + { + MouseButton.Left => CurrentState.LeftButton == ButtonState.Released && PreviousState.LeftButton == ButtonState.Pressed, + MouseButton.Middle => CurrentState.MiddleButton == ButtonState.Released && PreviousState.MiddleButton == ButtonState.Pressed, + MouseButton.Right => CurrentState.RightButton == ButtonState.Released && PreviousState.RightButton == ButtonState.Pressed, + MouseButton.XButton1 => CurrentState.XButton1 == ButtonState.Released && PreviousState.XButton1 == ButtonState.Pressed, + MouseButton.XButton2 => CurrentState.XButton2 == ButtonState.Released && PreviousState.XButton2 == ButtonState.Pressed, + _ => false, + }; + } + + /// + /// Sets the current position of the mouse cursor in screen space and updates the CurrentState with the new position. + /// + /// The x-coordinate location of the mouse cursor in screen space. + /// The y-coordinate location of the mouse cursor in screen space. + public void SetPosition(int x, int y) + { + Mouse.SetPosition(x, y); + CurrentState = new MouseState( + x, + y, + CurrentState.ScrollWheelValue, + CurrentState.LeftButton, + CurrentState.MiddleButton, + CurrentState.RightButton, + CurrentState.XButton1, + CurrentState.XButton2 + ); + } +} diff --git a/SaintGameLibrary/SaintGameLibrary.csproj b/SaintGameLibrary/SaintGameLibrary.csproj new file mode 100644 index 0000000..cbb91f8 --- /dev/null +++ b/SaintGameLibrary/SaintGameLibrary.csproj @@ -0,0 +1,10 @@ + + + net8.0 + + + + All + + + \ No newline at end of file diff --git a/SaintGameLibrary/Scenes/GameState.cs b/SaintGameLibrary/Scenes/GameState.cs new file mode 100644 index 0000000..9a434a6 --- /dev/null +++ b/SaintGameLibrary/Scenes/GameState.cs @@ -0,0 +1,8 @@ +namespace SaintGameLibrary.Scenes; + +public enum GameState +{ + Playing, + Paused, + GameOver +} diff --git a/SaintGameLibrary/Scenes/Scene.cs b/SaintGameLibrary/Scenes/Scene.cs new file mode 100644 index 0000000..f18e5d0 --- /dev/null +++ b/SaintGameLibrary/Scenes/Scene.cs @@ -0,0 +1,102 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Content; +using System; + +namespace SaintGameLibrary.Scenes; + +public abstract class Scene : IDisposable +{ + /// + /// Creates a new scene instance. + /// + public Scene() + { + Content = new ContentManager(Core.Content.ServiceProvider) + { + RootDirectory = Core.Content.RootDirectory + }; + } + + ~Scene() => Dispose(false); + + + /// + /// Gets the ContentManager used for loading scene-specific assets. + /// + /// + /// Assets loaded through this ContentManager will be automatically unloaded when this scene ends. + /// + protected ContentManager Content { get; } + + /// + /// Gets a value that indicates if the scene has been disposed of. + /// + public bool IsDisposed { get; private set; } + + /// + /// Initializes the scene. + /// + /// + /// When overriding this in a derived class, ensure that base.Initialize() + /// still called as this is when LoadContent is called. + /// + public virtual void Initialize() + { + LoadContent(); + } + + /// + /// Override to provide logic to load content for the scene. + /// + public virtual void LoadContent() { } + + /// + /// Unloads scene-specific content. + /// + public virtual void UnloadContent() + { + Content.Unload(); + } + + /// + /// Updates this scene. + /// + /// A snapshot of the timing values for the current frame. + public virtual void Update(GameTime gameTime) { } + + /// + /// Draws this scene. + /// + /// A snapshot of the timing values for the current frame. + public virtual void Draw(GameTime gameTime) { } + + /// + /// Disposes of this scene. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Disposes of this scene. + /// + /// ' + /// Indicates whether managed resources should be disposed. This value is only true when called from the main + /// Dispose method. When called from the finalizer, this will be false. + /// + protected virtual void Dispose(bool disposing) + { + if (IsDisposed) + { + return; + } + + if (disposing) + { + UnloadContent(); + Content.Dispose(); + } + } +}