Browse Source
Revert "Add .gitignore and .gitattributes."
Revert "Add .gitignore and .gitattributes."
This reverts commitmaster5c9f574644
. GitOrigin-RevId:277054282d
Colin McMillen
4 years ago
20 changed files with 1017 additions and 0 deletions
-
2README.md
-
25Shared/Camera.cs
-
78Shared/Debug.cs
-
23Shared/FpsCounter.cs
-
62Shared/History.cs
-
8Shared/IDisplay.cs
-
53Shared/Input.cs
-
21Shared/LICENSE.txt
-
175Shared/Player.cs
-
22Shared/Shared.projitems
-
26Shared/Shared.shproj
-
145Shared/SneakGame.cs
-
180Shared/World.cs
-
48SharedTests/HistoryTests.cs
-
20SharedTests/Properties/AssemblyInfo.cs
-
72SharedTests/SharedTests.csproj
-
5SharedTests/packages.config
-
21tools/LICENSE.txt
-
25tools/copybara/copy.bara.sky
-
6tools/copybara/copybara.sh
@ -0,0 +1,2 @@ |
|||
# sneak |
|||
mcmillen's first indie game |
@ -0,0 +1,25 @@ |
|||
using Microsoft.Xna.Framework; |
|||
using System; |
|||
|
|||
// Good background reading, eventually:
|
|||
// https://gamasutra.com/blogs/ItayKeren/20150511/243083/Scroll_Back_The_Theory_and_Practice_of_Cameras_in_SideScrollers.php
|
|||
namespace SemiColinGames { |
|||
class Camera { |
|||
private Rectangle bbox = new Rectangle(0, 0, 1920 / 4, 1080 / 4); |
|||
|
|||
public int Width { get => bbox.Width; } |
|||
public int Height { get => bbox.Height; } |
|||
public int Left { get => bbox.Left; } |
|||
|
|||
public void Update(GameTime time, Point player) { |
|||
int diff = player.X - bbox.Center.X; |
|||
if (Math.Abs(diff) > 16) { |
|||
bbox.Offset((int) (diff * 0.1), 0); |
|||
} |
|||
if (bbox.Left < 0) { |
|||
bbox.Offset(-bbox.Left, 0); |
|||
} |
|||
// Debug.Toast($"p: {player.X}, {player.Y} c: {bbox.Center.X}");
|
|||
} |
|||
} |
|||
} |
@ -0,0 +1,78 @@ |
|||
using Microsoft.Xna.Framework; |
|||
using Microsoft.Xna.Framework.Graphics; |
|||
using System.Collections.Generic; |
|||
|
|||
namespace SemiColinGames { |
|||
static class Debug { |
|||
struct DebugRect { |
|||
public Rectangle rect; |
|||
public Color color; |
|||
|
|||
public DebugRect(Rectangle rect, Color color) { |
|||
this.rect = rect; |
|||
this.color = color; |
|||
} |
|||
} |
|||
|
|||
public static bool Enabled; |
|||
static List<DebugRect> rects = new List<DebugRect>(); |
|||
static Texture2D whiteTexture; |
|||
static string toast = null; |
|||
|
|||
public static void Toast(string s) { |
|||
toast = s; |
|||
} |
|||
|
|||
public static void WriteLine(string s) { |
|||
System.Diagnostics.Debug.WriteLine(s); |
|||
} |
|||
|
|||
public static void WriteLine(string s, params object[] args) { |
|||
System.Diagnostics.Debug.WriteLine(s, args); |
|||
} |
|||
|
|||
public static void Initialize(GraphicsDevice graphics) { |
|||
whiteTexture = new Texture2D(graphics, 1, 1); |
|||
whiteTexture.SetData(new Color[] { Color.White }); |
|||
} |
|||
|
|||
public static void Clear() { |
|||
rects.Clear(); |
|||
} |
|||
|
|||
public static void AddRect(Rectangle rect, Color color) { |
|||
rects.Add(new DebugRect(rect, color)); |
|||
} |
|||
|
|||
public static void DrawToast(SpriteBatch spriteBatch, SpriteFont font) { |
|||
if (toast == null) { |
|||
return; |
|||
} |
|||
spriteBatch.DrawString(font, toast, new Vector2(10, 40), Color.Teal); |
|||
toast = null; |
|||
} |
|||
|
|||
public static void Draw(SpriteBatch spriteBatch, Camera camera) { |
|||
if (!Enabled) { |
|||
return; |
|||
} |
|||
foreach (var debugRect in rects) { |
|||
var rect = debugRect.rect; |
|||
rect.Offset(-camera.Left, 0); |
|||
var color = debugRect.color; |
|||
// top side
|
|||
spriteBatch.Draw( |
|||
whiteTexture, new Rectangle(rect.Left, rect.Top, rect.Width, 1), color); |
|||
// bottom side
|
|||
spriteBatch.Draw( |
|||
whiteTexture, new Rectangle(rect.Left, rect.Bottom - 1, rect.Width, 1), color); |
|||
// left side
|
|||
spriteBatch.Draw( |
|||
whiteTexture, new Rectangle(rect.Left, rect.Top, 1, rect.Height), color); |
|||
// right side
|
|||
spriteBatch.Draw( |
|||
whiteTexture, new Rectangle(rect.Right - 1, rect.Top, 1, rect.Height), color); |
|||
} |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,23 @@ |
|||
using System; |
|||
|
|||
namespace SemiColinGames { |
|||
class FpsCounter { |
|||
private double fps = 0; |
|||
private int[] frameTimes = new int[60]; |
|||
private int idx = 0; |
|||
|
|||
public int Fps { |
|||
get => (int) Math.Ceiling(fps); |
|||
} |
|||
|
|||
public void Update() { |
|||
var now = Environment.TickCount; // ms
|
|||
if (frameTimes[idx] != 0) { |
|||
var timeElapsed = now - frameTimes[idx]; |
|||
fps = 1000.0 * frameTimes.Length / timeElapsed; |
|||
} |
|||
frameTimes[idx] = now; |
|||
idx = (idx + 1) % frameTimes.Length; |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,62 @@ |
|||
using System; |
|||
|
|||
namespace SemiColinGames { |
|||
// A History is a queue of fixed length N that records the N most recent items Add()ed to it.
|
|||
// The mostly-recently-added item is found at index 0; the least-recently-added item is at index
|
|||
// N-1. Items older than the History's size are automatically dropped. The underlying
|
|||
// implementation is a fixed-size circular array; insertion and access are O(1).
|
|||
//
|
|||
// Example:
|
|||
// h = new History<int>(3);
|
|||
// h.Add(2); h.Add(3); h.Add(5);
|
|||
// Console.WriteLine("{0} {1} {2}", h[0], h[1], h[2]); // 5 3 2
|
|||
// h.Add(7);
|
|||
// Console.WriteLine("{0} {1} {2}", h[0], h[1], h[2]); // 7 5 3
|
|||
// h.Add(11); h.Add(13);
|
|||
// Console.WriteLine("{0} {1} {2}", h[0], h[1], h[2]); // 13 11 7
|
|||
class History<T> { |
|||
|
|||
// Backing store for the History's items.
|
|||
private readonly T[] items; |
|||
// Points at the most-recently-inserted item.
|
|||
private int idx = 0; |
|||
|
|||
public History(int length) { |
|||
items = new T[length]; |
|||
} |
|||
|
|||
public void Add(T item) { |
|||
idx++; |
|||
if (idx >= items.Length) { |
|||
idx -= items.Length; |
|||
} |
|||
items[idx] = item; |
|||
} |
|||
|
|||
public int Length { |
|||
get => items.Length; |
|||
} |
|||
|
|||
public T this[int age] { |
|||
get { |
|||
if (age < 0 || age >= items.Length) { |
|||
throw new IndexOutOfRangeException(); |
|||
} |
|||
int lookup = idx - age; |
|||
if (lookup < 0) { |
|||
lookup += items.Length; |
|||
} |
|||
return items[lookup]; |
|||
} |
|||
} |
|||
|
|||
// This creates and populates a new array. It's O(n) and should probably only be used for tests.
|
|||
public T[] ToArray() { |
|||
T[] result = new T[items.Length]; |
|||
for (int i = 0; i < items.Length; i++) { |
|||
result[i] = this[i]; |
|||
} |
|||
return result; |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,8 @@ |
|||
using Microsoft.Xna.Framework; |
|||
|
|||
namespace SemiColinGames { |
|||
public interface IDisplay { |
|||
void Initialize(GameWindow window, GraphicsDeviceManager graphics); |
|||
void SetFullScreen(bool fullScreen); |
|||
} |
|||
} |
@ -0,0 +1,53 @@ |
|||
using Microsoft.Xna.Framework; |
|||
using Microsoft.Xna.Framework.Input; |
|||
|
|||
namespace SemiColinGames { |
|||
struct Input { |
|||
public bool Jump; |
|||
public bool Attack; |
|||
public Vector2 Motion; |
|||
|
|||
public bool Exit; |
|||
public bool FullScreen; |
|||
public bool Debug; |
|||
|
|||
public Input(GamePadState gamePad, KeyboardState keyboard) { |
|||
// First we process normal buttons.
|
|||
Jump = gamePad.IsButtonDown(Buttons.A) || gamePad.IsButtonDown(Buttons.B) || |
|||
keyboard.IsKeyDown(Keys.J); |
|||
Attack = gamePad.IsButtonDown(Buttons.X) || gamePad.IsButtonDown(Buttons.Y) || |
|||
keyboard.IsKeyDown(Keys.K); |
|||
|
|||
// Then special debugging sorts of buttons.
|
|||
Exit = gamePad.IsButtonDown(Buttons.Start) || keyboard.IsKeyDown(Keys.Escape); |
|||
FullScreen = gamePad.IsButtonDown(Buttons.Back) || keyboard.IsKeyDown(Keys.F12) || |
|||
keyboard.IsKeyDown(Keys.OemPlus); |
|||
Debug = gamePad.IsButtonDown(Buttons.LeftShoulder) || keyboard.IsKeyDown(Keys.OemMinus); |
|||
|
|||
// Then potential motion directions. If the player attempts to input opposite directions at
|
|||
// once (up & down or left & right), those inputs cancel out, resulting in no motion.
|
|||
Motion = new Vector2(); |
|||
Vector2 leftStick = gamePad.ThumbSticks.Left; |
|||
bool left = leftStick.X < -0.5 || gamePad.IsButtonDown(Buttons.DPadLeft) || |
|||
keyboard.IsKeyDown(Keys.A); |
|||
bool right = leftStick.X > 0.5 || gamePad.IsButtonDown(Buttons.DPadRight) || |
|||
keyboard.IsKeyDown(Keys.D); |
|||
bool up = leftStick.Y > 0.5 || gamePad.IsButtonDown(Buttons.DPadUp) || |
|||
keyboard.IsKeyDown(Keys.W); |
|||
bool down = leftStick.Y < -0.5 || gamePad.IsButtonDown(Buttons.DPadDown) || |
|||
keyboard.IsKeyDown(Keys.S); |
|||
if (left && !right) { |
|||
Motion.X = -1; |
|||
} |
|||
if (right && !left) { |
|||
Motion.X = 1; |
|||
} |
|||
if (up && !down) { |
|||
Motion.Y = 1; |
|||
} |
|||
if (down && !up) { |
|||
Motion.Y = -1; |
|||
} |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,21 @@ |
|||
MIT License |
|||
|
|||
Copyright (c) 2020 Colin McMillen |
|||
|
|||
Permission is hereby granted, free of charge, to any person obtaining a copy |
|||
of this software and associated documentation files (the "Software"), to deal |
|||
in the Software without restriction, including without limitation the rights |
|||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
|||
copies of the Software, and to permit persons to whom the Software is |
|||
furnished to do so, subject to the following conditions: |
|||
|
|||
The above copyright notice and this permission notice shall be included in all |
|||
copies or substantial portions of the Software. |
|||
|
|||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
|||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
|||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
|||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
|||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
|||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
|||
SOFTWARE. |
@ -0,0 +1,175 @@ |
|||
using Microsoft.Xna.Framework; |
|||
using Microsoft.Xna.Framework.Graphics; |
|||
using System.Collections.Generic; |
|||
|
|||
namespace SemiColinGames { |
|||
class Player { |
|||
enum Facing { Left, Right }; |
|||
enum Pose { Walking, Standing, Crouching, Stretching, SwordSwing, Jumping }; |
|||
|
|||
private const int moveSpeed = 180; |
|||
private const int jumpSpeed = -600; |
|||
private const int gravity = 2400; |
|||
|
|||
private Texture2D texture; |
|||
private const int spriteSize = 48; |
|||
private const int spriteWidth = 7; |
|||
|
|||
private Point position = new Point(64, 16); |
|||
private int jumps = 0; |
|||
private Facing facing = Facing.Right; |
|||
private Pose pose = Pose.Jumping; |
|||
private double swordSwingTime = 0; |
|||
private double jumpTime = 0; |
|||
private float ySpeed = 0; |
|||
|
|||
public Player(Texture2D texture) { |
|||
this.texture = texture; |
|||
} |
|||
|
|||
public Point Position { get { return position; } } |
|||
|
|||
private Rectangle Bbox(Point position) { |
|||
return new Rectangle(position.X - spriteWidth, position.Y - 7, spriteWidth * 2, 26); |
|||
} |
|||
|
|||
public void Update(GameTime time, History<Input> input, List<Rectangle> collisionTargets) { |
|||
Point oldPosition = position; |
|||
Vector2 movement = HandleInput(time, input); |
|||
position = new Point((int) (oldPosition.X + movement.X), (int) (oldPosition.Y + movement.Y)); |
|||
|
|||
Rectangle oldBbox = Bbox(oldPosition); |
|||
Rectangle playerBbox = Bbox(position); |
|||
bool standingOnGround = false; |
|||
|
|||
foreach (var rect in collisionTargets) { |
|||
playerBbox = Bbox(position); |
|||
|
|||
// first we check for left-right collisions...
|
|||
if (playerBbox.Intersects(rect)) { |
|||
if (oldBbox.Right <= rect.Left && playerBbox.Right > rect.Left) { |
|||
position.X = rect.Left - spriteWidth; |
|||
} |
|||
if (oldBbox.Left >= rect.Right && playerBbox.Left < rect.Right) { |
|||
position.X = rect.Right + spriteWidth; |
|||
} |
|||
playerBbox = Bbox(position); |
|||
} |
|||
// after fixing that, we check for hitting our head or hitting the ground.
|
|||
if (playerBbox.Intersects(rect)) { |
|||
if (oldPosition.Y > position.Y) { |
|||
int diff = playerBbox.Top - rect.Bottom; |
|||
position.Y -= diff; |
|||
// TODO: set ySpeed = 0 here so that bonking our head actually reduces hangtime?
|
|||
} else { |
|||
standingOnGround = true; |
|||
int diff = playerBbox.Bottom - rect.Top; |
|||
position.Y -= diff; |
|||
} |
|||
} else { |
|||
playerBbox.Height += 1; |
|||
if (playerBbox.Intersects(rect)) { |
|||
standingOnGround = true; |
|||
Debug.AddRect(rect, Color.Cyan); |
|||
} else { |
|||
Debug.AddRect(rect, Color.Green); |
|||
} |
|||
} |
|||
} |
|||
if (standingOnGround) { |
|||
jumps = 1; |
|||
ySpeed = 0; |
|||
Debug.AddRect(playerBbox, Color.Red); |
|||
} else { |
|||
jumps = 0; |
|||
Debug.AddRect(playerBbox, Color.Orange); |
|||
} |
|||
|
|||
if (movement.X > 0) { |
|||
facing = Facing.Right; |
|||
} else if (movement.X < 0) { |
|||
facing = Facing.Left; |
|||
} |
|||
if (swordSwingTime > 0) { |
|||
pose = Pose.SwordSwing; |
|||
} else if (jumps == 0) { |
|||
pose = Pose.Jumping; |
|||
} else if (movement.X != 0) { |
|||
pose = Pose.Walking; |
|||
} else if (input[0].Motion.Y > 0) { |
|||
pose = Pose.Stretching; |
|||
} else if (input[0].Motion.Y < 0) { |
|||
pose = Pose.Crouching; |
|||
} else { |
|||
pose = Pose.Standing; |
|||
} |
|||
} |
|||
|
|||
// Returns the desired (dx, dy) for the player to move this frame.
|
|||
Vector2 HandleInput(GameTime time, History<Input> input) { |
|||
Vector2 result = new Vector2(); |
|||
result.X = (int) (input[0].Motion.X * moveSpeed * time.ElapsedGameTime.TotalSeconds); |
|||
|
|||
if (input[0].Jump && !input[1].Jump && jumps > 0) { |
|||
jumpTime = 0.3; |
|||
jumps--; |
|||
ySpeed = jumpSpeed; |
|||
} |
|||
|
|||
if (input[0].Attack && !input[1].Attack && swordSwingTime <= 0) { |
|||
swordSwingTime = 0.3; |
|||
} |
|||
|
|||
result.Y = ySpeed * (float) time.ElapsedGameTime.TotalSeconds; |
|||
ySpeed += gravity * (float) time.ElapsedGameTime.TotalSeconds; |
|||
jumpTime -= time.ElapsedGameTime.TotalSeconds; |
|||
swordSwingTime -= time.ElapsedGameTime.TotalSeconds; |
|||
return result; |
|||
} |
|||
|
|||
private int SpriteIndex(Pose pose, GameTime time) { |
|||
int frameNum = (time.TotalGameTime.Milliseconds / 125) % 4; |
|||
if (frameNum == 3) { |
|||
frameNum = 1; |
|||
} |
|||
switch (pose) { |
|||
case Pose.Walking: |
|||
return 6 + frameNum; |
|||
case Pose.Stretching: |
|||
return 18 + frameNum; |
|||
case Pose.Jumping: |
|||
if (jumpTime > 0.2) { |
|||
return 15; |
|||
} else if (jumpTime > 0.1) { |
|||
return 16; |
|||
} else { |
|||
return 17; |
|||
} |
|||
case Pose.SwordSwing: |
|||
if (swordSwingTime > 0.2) { |
|||
return 30; |
|||
} else if (swordSwingTime > 0.1) { |
|||
return 31; |
|||
} else { |
|||
return 32; |
|||
} |
|||
case Pose.Crouching: |
|||
return 25; |
|||
case Pose.Standing: |
|||
default: |
|||
return 7; |
|||
} |
|||
} |
|||
|
|||
public void Draw(SpriteBatch spriteBatch, Camera camera, GameTime time) { |
|||
int index = SpriteIndex(pose, time); |
|||
Rectangle textureSource = new Rectangle(index * spriteSize, 0, spriteSize, spriteSize); |
|||
Vector2 spriteCenter = new Vector2(spriteSize / 2, spriteSize / 2); |
|||
SpriteEffects effect = facing == Facing.Right ? |
|||
SpriteEffects.FlipHorizontally : SpriteEffects.None; |
|||
Vector2 drawPos = new Vector2(position.X - camera.Left, position.Y); |
|||
spriteBatch.Draw(texture, drawPos, textureSource, Color.White, 0f, spriteCenter, |
|||
Vector2.One, effect, 0f); |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,22 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> |
|||
<PropertyGroup> |
|||
<MSBuildAllProjects>$(MSBuildAllProjects);$(MSBuildThisFileFullPath)</MSBuildAllProjects> |
|||
<HasSharedItems>true</HasSharedItems> |
|||
<SharedGUID>2785994a-a14f-424e-8e77-2e464d28747f</SharedGUID> |
|||
</PropertyGroup> |
|||
<PropertyGroup Label="Configuration"> |
|||
<Import_RootNamespace>SemiColinGames</Import_RootNamespace> |
|||
</PropertyGroup> |
|||
<ItemGroup> |
|||
<Compile Include="$(MSBuildThisFileDirectory)Camera.cs" /> |
|||
<Compile Include="$(MSBuildThisFileDirectory)Input.cs" /> |
|||
<Compile Include="$(MSBuildThisFileDirectory)Debug.cs" /> |
|||
<Compile Include="$(MSBuildThisFileDirectory)FpsCounter.cs" /> |
|||
<Compile Include="$(MSBuildThisFileDirectory)History.cs" /> |
|||
<Compile Include="$(MSBuildThisFileDirectory)IDisplay.cs" /> |
|||
<Compile Include="$(MSBuildThisFileDirectory)Player.cs" /> |
|||
<Compile Include="$(MSBuildThisFileDirectory)SneakGame.cs" /> |
|||
<Compile Include="$(MSBuildThisFileDirectory)World.cs" /> |
|||
</ItemGroup> |
|||
</Project> |
@ -0,0 +1,26 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> |
|||
<PropertyGroup Label="Globals"> |
|||
<ProjectGuid>2785994a-a14f-424e-8e77-2e464d28747f</ProjectGuid> |
|||
<MinimumVisualStudioVersion>14.0</MinimumVisualStudioVersion> |
|||
</PropertyGroup> |
|||
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" /> |
|||
<Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\CodeSharing\Microsoft.CodeSharing.Common.Default.props" /> |
|||
<Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\CodeSharing\Microsoft.CodeSharing.Common.props" /> |
|||
<PropertyGroup /> |
|||
<ItemGroup> |
|||
<Compile Include="Camera.cs" /> |
|||
<Compile Include="Debug.cs" /> |
|||
<Compile Include="FpsCounter.cs" /> |
|||
<Compile Include="History.cs" /> |
|||
<Compile Include="IDisplay.cs" /> |
|||
<Compile Include="SneakGame.cs" /> |
|||
<Compile Include="Player.cs" /> |
|||
<Compile Include="World.cs" /> |
|||
</ItemGroup> |
|||
<ItemGroup> |
|||
<None Include="Shared.projitems" /> |
|||
</ItemGroup> |
|||
<Import Project="Shared.projitems" Label="Shared" /> |
|||
<Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\CodeSharing\Microsoft.CodeSharing.CSharp.targets" /> |
|||
</Project> |
@ -0,0 +1,145 @@ |
|||
using Microsoft.Xna.Framework; |
|||
using Microsoft.Xna.Framework.Graphics; |
|||
using Microsoft.Xna.Framework.Input; |
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
|
|||
namespace SemiColinGames { |
|||
public class SneakGame : Game { |
|||
GraphicsDeviceManager graphics; |
|||
RenderTarget2D renderTarget; |
|||
|
|||
SpriteBatch spriteBatch; |
|||
SpriteFont font; |
|||
bool fullScreen = false; |
|||
IDisplay display; |
|||
|
|||
History<Input> input = new History<Input>(2); |
|||
|
|||
FpsCounter fpsCounter = new FpsCounter(); |
|||
Texture2D grasslandBg1; |
|||
Texture2D grasslandBg2; |
|||
|
|||
Player player; |
|||
World world; |
|||
Camera camera = new Camera(); |
|||
|
|||
public SneakGame() { |
|||
graphics = new GraphicsDeviceManager(this); |
|||
IsMouseVisible = true; |
|||
Content.RootDirectory = "Content"; |
|||
} |
|||
|
|||
// Performs initialization that's needed before starting to run.
|
|||
protected override void Initialize() { |
|||
display = (IDisplay) Services.GetService(typeof(IDisplay)); |
|||
display.Initialize(Window, graphics); |
|||
display.SetFullScreen(fullScreen); |
|||
|
|||
Debug.Initialize(GraphicsDevice); |
|||
|
|||
renderTarget = new RenderTarget2D( |
|||
GraphicsDevice, camera.Width, camera.Height, false /* mipmap */, |
|||
GraphicsDevice.PresentationParameters.BackBufferFormat, DepthFormat.Depth24); |
|||
|
|||
base.Initialize(); |
|||
} |
|||
|
|||
// Called once per game. Loads all game content.
|
|||
protected override void LoadContent() { |
|||
spriteBatch = new SpriteBatch(GraphicsDevice); |
|||
font = Content.Load<SpriteFont>("font"); |
|||
|
|||
player = new Player(Content.Load<Texture2D>("player_1x")); |
|||
world = new World(Content.Load<Texture2D>("grassland")); |
|||
grasslandBg1 = Content.Load<Texture2D>("grassland_bg1"); |
|||
grasslandBg2 = Content.Load<Texture2D>("grassland_bg2"); |
|||
} |
|||
|
|||
// Called once per game. Unloads all game content.
|
|||
protected override void UnloadContent() { |
|||
} |
|||
|
|||
// Updates the game world.
|
|||
protected override void Update(GameTime gameTime) { |
|||
Debug.Clear(); |
|||
|
|||
input.Add(new Input(GamePad.GetState(PlayerIndex.One), Keyboard.GetState())); |
|||
|
|||
if (input[0].Exit) { |
|||
Exit(); |
|||
} |
|||
|
|||
if (input[0].FullScreen && !input[1].FullScreen) { |
|||
fullScreen = !fullScreen; |
|||
display.SetFullScreen(fullScreen); |
|||
} |
|||
|
|||
if (input[0].Debug && !input[1].Debug) { |
|||
Debug.Enabled = !Debug.Enabled; |
|||
} |
|||
|
|||
List<Rectangle> collisionTargets = world.CollisionTargets(); |
|||
player.Update(gameTime, input, collisionTargets); |
|||
|
|||
camera.Update(gameTime, player.Position); |
|||
|
|||
base.Update(gameTime); |
|||
} |
|||
|
|||
// Called when the game should draw itself.
|
|||
protected override void Draw(GameTime gameTime) { |
|||
// We need to update the FPS counter in Draw() since Update() might get called more
|
|||
// frequently, especially when gameTime.IsRunningSlowly.
|
|||
fpsCounter.Update(); |
|||
|
|||
// Draw scene to RenderTarget.
|
|||
GraphicsDevice.SetRenderTarget(renderTarget); |
|||
GraphicsDevice.Clear(Color.CornflowerBlue); |
|||
spriteBatch.Begin(SpriteSortMode.Deferred, null, SamplerState.LinearWrap, null, null); |
|||
|
|||
// Draw background.
|
|||
Rectangle bgSource = new Rectangle( |
|||
(int) (camera.Left * 0.25), 0, camera.Width, camera.Height); |
|||
Rectangle bgTarget = new Rectangle(0, 0, camera.Width, camera.Height); |
|||
spriteBatch.Draw(grasslandBg2, bgTarget, bgSource, Color.White); |
|||
bgSource = new Rectangle( |
|||
(int) (camera.Left * 0.5), 0, camera.Width, camera.Height); |
|||
spriteBatch.Draw(grasslandBg1, bgTarget, bgSource, Color.White); |
|||
|
|||
// Draw player.
|
|||
player.Draw(spriteBatch, camera, gameTime); |
|||
|
|||
// Draw foreground tiles.
|
|||
world.Draw(spriteBatch, camera); |
|||
|
|||
// Draw debug rects.
|
|||
Debug.Draw(spriteBatch, camera); |
|||
|
|||
// Aaaaand we're done.
|
|||
spriteBatch.End(); |
|||
|
|||
// Draw RenderTarget to screen.
|
|||
GraphicsDevice.SetRenderTarget(null); |
|||
GraphicsDevice.Clear(Color.Black); |
|||
spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, |
|||
SamplerState.PointClamp, DepthStencilState.Default, |
|||
RasterizerState.CullNone); |
|||
Rectangle drawRect = new Rectangle( |
|||
0, 0, GraphicsDevice.Viewport.Width, GraphicsDevice.Viewport.Height); |
|||
spriteBatch.Draw(renderTarget, drawRect, Color.White); |
|||
|
|||
if (Debug.Enabled) { |
|||
string fpsText = $"{GraphicsDevice.Viewport.Width}x{GraphicsDevice.Viewport.Height}, " + |
|||
$"{fpsCounter.Fps} FPS"; |
|||
spriteBatch.DrawString(font, fpsText, new Vector2(10, 10), Color.Teal); |
|||
Debug.DrawToast(spriteBatch, font); |
|||
} |
|||
|
|||
spriteBatch.End(); |
|||
|
|||
base.Draw(gameTime); |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,180 @@ |
|||
using Microsoft.Xna.Framework; |
|||
using Microsoft.Xna.Framework.Graphics; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
|
|||
namespace SemiColinGames { |
|||
enum Terrain { |
|||
Empty, |
|||
Grass, |
|||
GrassL, |
|||
GrassR, |
|||
Rock, |
|||
RockL, |
|||
RockR, |
|||
Water, |
|||
Block |
|||
} |
|||
|
|||
class Tile { |
|||
Texture2D texture; |
|||
Terrain terrain; |
|||
Rectangle position; |
|||
|
|||
public Tile(Texture2D texture, Terrain terrain, Rectangle position) { |
|||
this.texture = texture; |
|||
this.terrain = terrain; |
|||
this.position = position; |
|||
} |
|||
|
|||
public Rectangle Position { get { return position; } } |
|||
public Terrain Terrain { get { return terrain; } } |
|||
|
|||
public void Draw(SpriteBatch spriteBatch, Camera camera) { |
|||
int size = World.TileSize; |
|||
Vector2 drawPos = new Vector2(position.Left - camera.Left, position.Top); |
|||
switch (terrain) { |
|||
case Terrain.Grass: { |
|||
Rectangle source = new Rectangle(3 * size, 0 * size, size, size); |
|||
spriteBatch.Draw(texture, drawPos, source, Color.White); |
|||
break; |
|||
} |
|||
case Terrain.GrassL: { |
|||
Rectangle source = new Rectangle(2 * size, 0 * size, size, size); |
|||
spriteBatch.Draw(texture, drawPos, source, Color.White); |
|||
break; |
|||
} |
|||
case Terrain.GrassR: { |
|||
Rectangle source = new Rectangle(4 * size, 0 * size, size, size); |
|||
spriteBatch.Draw(texture, drawPos, source, Color.White); |
|||
break; |
|||
} |
|||
case Terrain.Rock: { |
|||
Rectangle source = new Rectangle(3 * size, 1 * size, size, size); |
|||
spriteBatch.Draw(texture, drawPos, source, Color.White); |
|||
break; |
|||
} |
|||
case Terrain.RockL: { |
|||
Rectangle source = new Rectangle(1 * size, 2 * size, size, size); |
|||
spriteBatch.Draw(texture, drawPos, source, Color.White); |
|||
break; |
|||
} |
|||
case Terrain.RockR: { |
|||
Rectangle source = new Rectangle(5 * size, 2 * size, size, size); |
|||
spriteBatch.Draw(texture, drawPos, source, Color.White); |
|||
break; |
|||
} |
|||
case Terrain.Water: { |
|||
Rectangle source = new Rectangle(9 * size, 2 * size, size, size); |
|||
spriteBatch.Draw(texture, drawPos, source, Color.White); |
|||
break; |
|||
} |
|||
case Terrain.Block: { |
|||
Rectangle source = new Rectangle(6 * size, 3 * size, size, size); |
|||
spriteBatch.Draw(texture, drawPos, source, Color.White); |
|||
break; |
|||
} |
|||
case Terrain.Empty: |
|||
default: |
|||
break; |
|||
} |
|||
} |
|||
} |
|||
|
|||
class World { |
|||
|
|||
public const int TileSize = 16; |
|||
int width; |
|||
int height; |
|||
Tile[,] tiles; |
|||
|
|||
public int Width { get; } |
|||
public int Height { get; } |
|||
|
|||
string worldString = @"
|
|||
|
|||
|
|||
|
|||
|
|||
X |
|||
. |
|||
X <======> <==X X <=> <XX> XX . |
|||
XXX . |
|||
XXXX . |
|||
XXXXX . |
|||
X <X=X> <> <> <X> = <> X X X X <> X X XX X <=X> XXXXXX . |
|||
<> [] [] XX XX XXX XX XXXXXXX . |
|||
<> [] [] [] XXX XXX XXXX XXX <> <> XXXXXXXX |
|||
[]12345678[]123456[]123456789[]1234567890 123456 123456 12345 1234 12345 1234 123XXXX XXXX1234XXXXX XXXX1234[]123 1234567[]XXXXXXXXX12345678 |
|||
===========================..========..======..=========..=========> <=============> <==============================================================> <=======..==============..============================== |
|||
...................................................................] [.............] [..............................................................] [.......................................................";
|
|||
|
|||
public World(Texture2D texture) { |
|||
string[] worldDesc = worldString.Split('\n'); |
|||
width = worldDesc.AsQueryable().Max(a => a.Length); |
|||
height = worldDesc.Length; |
|||
Debug.WriteLine("world size: {0}x{1}", width, height); |
|||
tiles = new Tile[width, height]; |
|||
for (int j = 0; j < height; j++) { |
|||
for (int i = 0; i < width; i++) { |
|||
Terrain terrain = Terrain.Empty; |
|||
if (i < worldDesc[j].Length) { |
|||
switch (worldDesc[j][i]) { |
|||
case '=': |
|||
terrain = Terrain.Grass; |
|||
break; |
|||
case '<': |
|||
terrain = Terrain.GrassL; |
|||
break; |
|||
case '>': |
|||
terrain = Terrain.GrassR; |
|||
break; |
|||
case '.': |
|||
terrain = Terrain.Rock; |
|||
break; |
|||
case '[': |
|||
terrain = Terrain.RockL; |
|||
break; |
|||
case ']': |
|||
terrain = Terrain.RockR; |
|||
break; |
|||
case '~': |
|||
terrain = Terrain.Water; |
|||
break; |
|||
case 'X': |
|||
terrain = Terrain.Block; |
|||
break; |
|||
case ' ': |
|||
default: |
|||
terrain = Terrain.Empty; |
|||
break; |
|||
} |
|||
} |
|||
var position = new Rectangle(i * TileSize, j * TileSize, TileSize, TileSize); |
|||
tiles[i, j] = new Tile(texture, terrain, position); |
|||
} |
|||
} |
|||
} |
|||
|
|||
public void Draw(SpriteBatch spriteBatch, Camera camera) { |
|||
for (int j = 0; j < height; j++) { |
|||
for (int i = 0; i < width; i++) { |
|||
tiles[i, j].Draw(spriteBatch, camera); |
|||
} |
|||
} |
|||
} |
|||
|
|||
public List<Rectangle> CollisionTargets() { |
|||
var result = new List<Rectangle>(); |
|||
for (int j = 0; j < height; j++) { |
|||
for (int i = 0; i < width; i++) { |
|||
var t = tiles[i, j]; |
|||
if (t.Terrain != Terrain.Empty) { |
|||
result.Add(t.Position); |
|||
} |
|||
} |
|||
} |
|||
return result; |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,48 @@ |
|||
using Microsoft.VisualStudio.TestTools.UnitTesting; |
|||
using System; |
|||
using System.Collections.Generic; |
|||
|
|||
namespace SemiColinGames.Tests { |
|||
[TestClass] |
|||
public class HistoryTests { |
|||
[TestMethod] |
|||
public void TestLength() { |
|||
var h = new History<int>(3); |
|||
Assert.AreEqual(3, h.Length); |
|||
} |
|||
|
|||
[TestMethod] |
|||
public void TestGetFromEmpty() { |
|||
var ints = new History<int>(3); |
|||
Assert.AreEqual(0, ints[0]); |
|||
Assert.AreEqual(0, ints[1]); |
|||
Assert.AreEqual(0, ints[2]); |
|||
|
|||
var objects = new History<Object>(1); |
|||
Assert.AreEqual(null, objects[0]); |
|||
} |
|||
|
|||
[TestMethod] |
|||
public void TestAdds() { |
|||
var h = new History<int>(3); |
|||
Assert.AreEqual("0 0 0", String.Join(" ", h.ToArray())); |
|||
h.Add(2); |
|||
Assert.AreEqual("2 0 0", String.Join(" ", h.ToArray())); |
|||
h.Add(3); |
|||
h.Add(5); |
|||
Assert.AreEqual("5 3 2", String.Join(" ", h.ToArray())); |
|||
h.Add(7); |
|||
Assert.AreEqual("7 5 3", String.Join(" ", h.ToArray())); |
|||
h.Add(11); |
|||
h.Add(13); |
|||
Assert.AreEqual("13 11 7", String.Join(" ", h.ToArray())); |
|||
} |
|||
|
|||
[TestMethod] |
|||
public void TestThrowsExceptions() { |
|||
var h = new History<int>(3); |
|||
Assert.ThrowsException<IndexOutOfRangeException>(() => h[-1]); |
|||
Assert.ThrowsException<IndexOutOfRangeException>(() => h[3]); |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,20 @@ |
|||
using System.Reflection; |
|||
using System.Runtime.CompilerServices; |
|||
using System.Runtime.InteropServices; |
|||
|
|||
[assembly: AssemblyTitle("SharedTests")] |
|||
[assembly: AssemblyDescription("")] |
|||
[assembly: AssemblyConfiguration("")] |
|||
[assembly: AssemblyCompany("")] |
|||
[assembly: AssemblyProduct("SharedTests")] |
|||
[assembly: AssemblyCopyright("Copyright © 2020")] |
|||
[assembly: AssemblyTrademark("")] |
|||
[assembly: AssemblyCulture("")] |
|||
|
|||
[assembly: ComVisible(false)] |
|||
|
|||
[assembly: Guid("c86694a5-dd99-4421-aa2c-1230f11c10f8")] |
|||
|
|||
// [assembly: AssemblyVersion("1.0.*")]
|
|||
[assembly: AssemblyVersion("1.0.0.0")] |
|||
[assembly: AssemblyFileVersion("1.0.0.0")] |
@ -0,0 +1,72 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<Project ToolsVersion="15.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> |
|||
<Import Project="..\packages\MSTest.TestAdapter.2.0.0\build\net45\MSTest.TestAdapter.props" Condition="Exists('..\packages\MSTest.TestAdapter.2.0.0\build\net45\MSTest.TestAdapter.props')" /> |
|||
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" /> |
|||
<PropertyGroup> |
|||
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration> |
|||
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform> |
|||
<ProjectGuid>{C86694A5-DD99-4421-AA2C-1230F11C10F8}</ProjectGuid> |
|||
<OutputType>Library</OutputType> |
|||
<AppDesignerFolder>Properties</AppDesignerFolder> |
|||
<RootNamespace>SharedTests</RootNamespace> |
|||
<AssemblyName>SharedTests</AssemblyName> |
|||
<TargetFrameworkVersion>v4.7.2</TargetFrameworkVersion> |
|||
<FileAlignment>512</FileAlignment> |
|||
<ProjectTypeGuids>{3AC096D0-A1C2-E12C-1390-A8335801FDAB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids> |
|||
<VisualStudioVersion Condition="'$(VisualStudioVersion)' == ''">15.0</VisualStudioVersion> |
|||
<VSToolsPath Condition="'$(VSToolsPath)' == ''">$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)</VSToolsPath> |
|||
<ReferencePath>$(ProgramFiles)\Common Files\microsoft shared\VSTT\$(VisualStudioVersion)\UITestExtensionPackages</ReferencePath> |
|||
<IsCodedUITest>False</IsCodedUITest> |
|||
<TestProjectType>UnitTest</TestProjectType> |
|||
<NuGetPackageImportStamp> |
|||
</NuGetPackageImportStamp> |
|||
</PropertyGroup> |
|||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' "> |
|||
<DebugSymbols>true</DebugSymbols> |
|||
<DebugType>full</DebugType> |
|||
<Optimize>false</Optimize> |
|||
<OutputPath>bin\Debug\</OutputPath> |
|||
<DefineConstants>DEBUG;TRACE</DefineConstants> |
|||
<ErrorReport>prompt</ErrorReport> |
|||
<WarningLevel>4</WarningLevel> |
|||
</PropertyGroup> |
|||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' "> |
|||
<DebugType>pdbonly</DebugType> |
|||
<Optimize>true</Optimize> |
|||
<OutputPath>bin\Release\</OutputPath> |
|||
<DefineConstants>TRACE</DefineConstants> |
|||
<ErrorReport>prompt</ErrorReport> |
|||
<WarningLevel>4</WarningLevel> |
|||
</PropertyGroup> |
|||
<ItemGroup> |
|||
<Reference Include="Microsoft.VisualStudio.TestPlatform.TestFramework, Version=14.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL"> |
|||
<HintPath>..\packages\MSTest.TestFramework.2.0.0\lib\net45\Microsoft.VisualStudio.TestPlatform.TestFramework.dll</HintPath> |
|||
</Reference> |
|||
<Reference Include="Microsoft.VisualStudio.TestPlatform.TestFramework.Extensions, Version=14.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL"> |
|||
<HintPath>..\packages\MSTest.TestFramework.2.0.0\lib\net45\Microsoft.VisualStudio.TestPlatform.TestFramework.Extensions.dll</HintPath> |
|||
</Reference> |
|||
<Reference Include="MonoGame.Framework, Version=3.7.1.189, Culture=neutral, processorArchitecture=MSIL"> |
|||
<SpecificVersion>False</SpecificVersion> |
|||
</Reference> |
|||
<Reference Include="System" /> |
|||
<Reference Include="System.Core" /> |
|||
</ItemGroup> |
|||
<ItemGroup> |
|||
<Compile Include="HistoryTests.cs" /> |
|||
<Compile Include="Properties\AssemblyInfo.cs" /> |
|||
</ItemGroup> |
|||
<ItemGroup> |
|||
<None Include="packages.config" /> |
|||
</ItemGroup> |
|||
<Import Project="..\Shared\Shared.projitems" Label="Shared" /> |
|||
<Import Project="$(VSToolsPath)\TeamTest\Microsoft.TestTools.targets" Condition="Exists('$(VSToolsPath)\TeamTest\Microsoft.TestTools.targets')" /> |
|||
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> |
|||
<Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> |
|||
<PropertyGroup> |
|||
<ErrorText>This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}.</ErrorText> |
|||
</PropertyGroup> |
|||
<Error Condition="!Exists('..\packages\MSTest.TestAdapter.2.0.0\build\net45\MSTest.TestAdapter.props')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\MSTest.TestAdapter.2.0.0\build\net45\MSTest.TestAdapter.props'))" /> |
|||
<Error Condition="!Exists('..\packages\MSTest.TestAdapter.2.0.0\build\net45\MSTest.TestAdapter.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\MSTest.TestAdapter.2.0.0\build\net45\MSTest.TestAdapter.targets'))" /> |
|||
</Target> |
|||
<Import Project="..\packages\MSTest.TestAdapter.2.0.0\build\net45\MSTest.TestAdapter.targets" Condition="Exists('..\packages\MSTest.TestAdapter.2.0.0\build\net45\MSTest.TestAdapter.targets')" /> |
|||
</Project> |
@ -0,0 +1,5 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<packages> |
|||
<package id="MSTest.TestAdapter" version="2.0.0" targetFramework="net472" /> |
|||
<package id="MSTest.TestFramework" version="2.0.0" targetFramework="net472" /> |
|||
</packages> |
@ -0,0 +1,21 @@ |
|||
MIT License |
|||
|
|||
Copyright (c) 2020 Colin McMillen |
|||
|
|||
Permission is hereby granted, free of charge, to any person obtaining a copy |
|||
of this software and associated documentation files (the "Software"), to deal |
|||
in the Software without restriction, including without limitation the rights |
|||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
|||
copies of the Software, and to permit persons to whom the Software is |
|||
furnished to do so, subject to the following conditions: |
|||
|
|||
The above copyright notice and this permission notice shall be included in all |
|||
copies or substantial portions of the Software. |
|||
|
|||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
|||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
|||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
|||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
|||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
|||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
|||
SOFTWARE. |
@ -0,0 +1,25 @@ |
|||
sourceUrl = "git@github.com:mcmillen/sneak.git" |
|||
destinationUrl = "git@github.com:mcmillen/sneak-public.git" |
|||
|
|||
core.workflow( |
|||
name = "default", |
|||
mode = "ITERATIVE", |
|||
origin = git.origin( |
|||
url = sourceUrl, |
|||
ref = "master", |
|||
), |
|||
destination = git.destination( |
|||
url = destinationUrl, |
|||
fetch = "master", |
|||
push = "master", |
|||
), |
|||
# Change path to the folder you want to publish publicly |
|||
origin_files = glob(["README.md", "tools/**", "Shared/**", "SharedTests/**", "Jumpy.Shared/**"]), |
|||
|
|||
authoring = authoring.pass_thru("Unknown Author <nobody@semicolin.games>"), |
|||
|
|||
# Change the path here to the folder you want to publish publicly |
|||
#transformations = [ |
|||
# core.move("", ""), |
|||
#], |
|||
) |
@ -0,0 +1,6 @@ |
|||
#/bin/sh |
|||
|
|||
SCRIPT=$(readlink -f "$0") |
|||
SCRIPTPATH=$(dirname "$SCRIPT") |
|||
|
|||
java -jar ~/bin/copybara_deploy.jar migrate $SCRIPTPATH/copy.bara.sky |
Write
Preview
Loading…
Cancel
Save
Reference in new issue