728x90
SMALL

원래는 출석 체크만 구현하다 서버에 다른 기능들도 한꺼번에 구현하고 마무리하기로 결정했습니다.
서버에 아래 기능들을 추가하였습니다.

  • 출석체크
    • 메일 접속하여 출석체크를 하면 보상을 지급
    • 세계 시간 기준으로 0시(한국 시간 오전 9시) 마다 초기화
    • 1일차 출석 후, N일 후에 접속하여 출석하면 2일차 출석으로 처리
  • 일일룰렛
    • 하루에 한 번 룰렛을 돌린다.
    • 룰렛을 돌리면 자동차를 지급
    • 이미 가진 자동차일 경우 자동차의 비용만큼 돈을 지급
    • 세계 시간 기준으로 0시(한국 시간 오전 9시) 마다 초기화
  • 자동수집(?)
    • 1분에 1개씩 돈을 수집
    • 오른쪽 하단에 코인 아이콘을 누르면 수집된 돈을 획득
    • 최대 수집량은 스테이지 템플릿에 입력

 

템플릿 추가, 수정

 

Template(Dev).xlsx 파일에 DailyReward 시트를 추가하여 출석 보상 정보를 입력하였습니다.
Type 1일 경우 돈을 보상으로 주고, Type 2일 경우 자동차를 렌덤하게 보상으로 지급하도록 구현할 계획입니다.

 

자동차의 가격은 모두 똑같지 않기 때문에 Car 시트를 수정하여 보상으로 지급할 자동차와 직접구매만 할 수 있는 자동차를 구분하기로 하였습니다.
Car 시트에 Enable Reward 컬럼을 추가하였습니다.

 

룰렛 보상도 Car 시트의 Enable Reward를 이용할 계획이기 때문에 따로 템플릿을 추가하지 않았습니다.

스테이지마다 최대로 돈을 수집할 수 있는 양을 다르게 처리하기 위해 Stage 시트에 Max Collect 컬럼을 추가하였습니다.

 

template_generator.py 파일을 수정하여 변경된 템플릿의 데이터들을 변환하도록 수정하였습니다.

def generate_car(file_path, sheet_name):
    book = load_workbook(file_path, data_only=True)
    sheet = book[sheet_name]
    skip = 0
    temp_group = []
    for row in sheet.rows:
        if skip < 2:
            skip += 1
            continue      
        if row[0].value == None:
            continue
        new_temp = {
            'Id': row[0].value,
            'Name': {
                'Table': row[1].value,
                'Key': row[2].value
            },
            'Icon': row[3].value,
            'Prefab': row[4].value,
            'Cost': int(row[5].value),
            'EnableReward': int(row[6].value)
        }
        temp_group.append(new_temp)
    return temp_group

def generate_daily_reward(file_path, sheet_name):
    book = load_workbook(file_path, data_only=True)
    sheet = book[sheet_name]
    skip = 0
    temp_group = []
    for row in sheet.rows:
        if skip < 1:
            skip += 1
            continue  
        if row[0].value == None:
            continue
        new_temp = {
            'Type': int(row[1].value),
            'Icon': row[2].value,
            'Amount': int(row[3].value),
        }
        temp_group.append(new_temp)
    return temp_group

def generate_stage(file_path, sheet_name):
    book = load_workbook(file_path, data_only=True)
    sheet = book[sheet_name]
    skip = 0
    temp_group = []
    for row in sheet.rows:
        if skip < 1:
            skip += 1
            continue  
        if row[0].value == None:
            continue
        new_temp = {
            'Id': row[0].value,
            'Scene': row[1].value,
            'Distance': float(row[2].value),
            'FareRate': float(row[3].value),
            'MaxCollect': int(row[4].value)
        }
        temp_group.append(new_temp)
    return temp_group

 

서버 구현

 

서버 구현을 하다가 계속 ?연산자 사용하기 귀찮아서 Null 허용 설정 사용 안 함으로 수정하였습니다.

 

템플릿 정보를 저장하는 클래스들을 추가하거나 수정하였습니다.

public enum DailyRewardType
{
    Undefined = 0,
    Coin = 1,
    Car = 2
}

public class DailyRewardTemplate
{
    public DailyRewardType Type
    {
        get;
        set;
    }

    public int Amount
    {
        get;
        set;
    }
}

public class CarTemplate
{
    public string Id
    {
        get;
        set;
    }
    public int Cost
    {
        get;
        set;
    }
    [JsonConverter(typeof(BooleanConverter))]
    public bool EnableReward
    {
        get;
        set;
    }
}

 

MS에서 제공하는 JSON 라이브러리는 정수를 bool 값으로 자동 변환하지 않기 때문에 BooleanConverter를 추가하였습니다.

public class BooleanConverter : JsonConverter<bool>
{
    public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        switch (reader.TokenType)
        {
            case JsonTokenType.True:
                return true;
            case JsonTokenType.False:
                return false;
            case JsonTokenType.String:
                return reader.GetString()?.ToLower() switch
                {
                    "true" => true,
                    "false" => false,
                    _ => throw new JsonException()
                };
            case JsonTokenType.Number:
                return reader.GetInt16() != 0;
            default:
                throw new JsonException();
        }
    }

    public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options)
    {
        writer.WriteBooleanValue(value);
    }
}

 

UserModel에 새로운 프로퍼티를 추가하였습니다.

  • DailyCarRewards 일일보상으로 지급할 자동차
  • DailyRewardedAtUtc 출석보상 받은 날
  • NumberOfAttendance 출석횟수
  • CoinCollectedAtUtc 코인 자동수집 보상 받은 날
  • RouletteCarRewards 룰렛보상으로 지급할 자동차
  • RouletteSpunAtUtc 룰렛 돌린 날
    public class UserModel
    {
      [BsonId]
      [BsonRepresentation(BsonType.ObjectId)]
      public string Id
      {
          get;
          set;
      }
      public string Nickname
      {
          get;
          set;
      }
      public required string Email
      {
          get;
          set;
      }
      public required string Password
      {
          get;
          set;
      }
      public long Coin
      {
          get;
          set;
      }
      public List<string> Cars
      {
          get;
          set;
      }
      [BsonElement("CurrentCar")]
      [JsonPropertyName("CurrentCar")]
      public string CurrentCarId
      {
          get;
          set;
      }
      [BsonElement("CurrentStage")]
      [JsonPropertyName("CurrentStage")]
      public int CurrentStageIndex
      {
          get;
          set;
      }
      public Dictionary<string, string> DailyCarRewards
      {
          get;
          set;
      }
      [BsonDateTimeOptions(Kind = DateTimeKind.Utc)]
      public DateTime DailyRewardedAtUtc
      {
          get;
          set;
      }
      public short NumberOfAttendance
      {
          get;
          set;
      }
      [BsonDateTimeOptions(Kind = DateTimeKind.Utc)]
      public DateTime CoinCollectedAtUtc
      {
          get;
          set;
      }
      public List<string> RouletteCarRewards
      {
          get;
          set;
      }
      [BsonDateTimeOptions(Kind = DateTimeKind.Utc)]
      public DateTime RouletteSpunAtUtc
      {
          get;
          set;
      }
    }

 

UserController 클래스에서 Get 메소드를 수정한 후 Attendance, SpinRoulette, CollectCoin을 수정하였습니다.
Get은 이미 계정이 등록되어 새로운 기능에 필요한 데이터가 없는 유저들의 정보도 자동 추가되도록 처리하였습니다.
새로 추가한 기능들은 서버와 클라이언트가 통신하는 시간 때문에 오차가 발생할 수 있어 클라이언트에서 작업한 시간을 서버로 보내주도록 구현하였습니다.
서버에서는 클라이언트에서 보낸 시간을 저장하도록 처리하였습니다.
서버가 클라이언트에서 작업했던 시간만 저장해두면 클라이언트에서 시간조작을 하여 보상을 미리 받을 수는 있어도 이미 받았던 보상을 또 받을 수는 없을 거라고 생각하여 다른 작업은 하지 않았습니다.
룰렛은 서버에서 보상을 고른 후 클라이언트에 인덱스값만 전달하도록 구현하였습니다.

[HttpGet]
[ProducesResponseType<UserResponse>(200)]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public async Task<ActionResult<UserResponse>> Get()
{
    var userId = ClaimHelper.FindNameIdentifier(User);
    if (string.IsNullOrEmpty(userId))
        return Unauthorized();

    var user = await userRepository.Get(userId);
    if (user == null)
        return Forbid();

    var utcToday = DateTime.UtcNow.Date;
    var rand = new Random((int)DateTime.Now.Ticks);
    var cars = await templateService.GetCars();
    var carsForReward = cars
        .Where(e => e.EnableReward)
        .OrderBy(e => rand.Next())
        .ToList();
    var carIndex = 0;
    var dailyRewards = await templateService.GetDailyRewards();
    var needUpdate = false;

    // 자동차 지급
    if (user.Cars == null || user.Cars.Count == 0)
    {
        var newCar = cars?.FirstOrDefault(e => e.Cost == 0);
        if (newCar != null)
        {
            user.CurrentCarId = newCar?.Id;
            user.Cars = [ user.CurrentCarId! ];
            needUpdate = true;
        }
    }

    // 출석보상 생성
    // 어제 모든 보상을 다 받았다면 보상생성
    var rewardedAt = user.DailyRewardedAtUtc;
    if (
        rewardedAt <= DateTime.UnixEpoch ||
        (user.NumberOfAttendance >= dailyRewards.Count && utcToday > rewardedAt)
    )
    {
        user.NumberOfAttendance = 0;
        if (user.DailyCarRewards == null)
            user.DailyCarRewards = new();
        else
            user.DailyCarRewards.Clear();
        for (int i = 0; i < dailyRewards.Count; i++)
        {
            if (dailyRewards[i].Type == DailyRewardType.Car)
            {
                user.DailyCarRewards[i.ToString()] = carsForReward[carIndex].Id;
                carIndex = (carIndex + 1) % carsForReward.Count;
            }
        }
        // 출석체크하기 전까지 계속 데이터 갱신하지 않도록 처리
        if (rewardedAt <= DateTime.UnixEpoch)
            user.DailyRewardedAtUtc = utcToday.AddYears(-1);
        needUpdate = true;
    }

    if (user.CoinCollectedAtUtc <= DateTime.UnixEpoch)
    {
        user.CoinCollectedAtUtc = DateTime.UtcNow;
        needUpdate = true;
    }

    // 룰렛 보상 생성
    // 어제 룰렛을 돌렸다면 보상생성
    rewardedAt = user.RouletteSpunAtUtc;
    if (rewardedAt <= DateTime.UnixEpoch || utcToday > rewardedAt)
    {
        if (user.RouletteCarRewards == null)
            user.RouletteCarRewards = new();
        else
            user.RouletteCarRewards.Clear();
        const int rouletteCount = 6;
        for (int i = 0; i < rouletteCount; i++)
        {
            user.RouletteCarRewards.Add(carsForReward[carIndex].Id);
            carIndex = (carIndex + 1) % carsForReward.Count;
        }
        // 룰렛 돌리기 전까지 계속 데이터 갱신하지 않도록 처리
        if (rewardedAt <= DateTime.UnixEpoch)
            user.RouletteSpunAtUtc = utcToday.AddYears(-1);
        needUpdate = true;
    }

    if (needUpdate)
        _ = userRepository.Update(userId, user);

    return Ok(new UserResponse(user));
}

[HttpPut("Attendance")]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public async Task<IActionResult> Attendance([FromBody] DateRequest body)
{
    var userId = ClaimHelper.FindNameIdentifier(User);
    if (string.IsNullOrEmpty(userId))
        return Unauthorized();

    var user = await userRepository.Get(userId);
    if (user == null)
        return Forbid();

    var rewards = await templateService.GetDailyRewards();
    // 출석 완료함
    if (user.NumberOfAttendance >= rewards.Count)
        return StatusCode(StatusCodes.Status410Gone);

    // 오늘 이미 출석함
    if (body.UtcDate >= user.DailyRewardedAtUtc.Date.AddDays(1))
        return StatusCode(StatusCodes.Status410Gone);

    var r = rewards[user.NumberOfAttendance];
    if (r.Type == DailyRewardType.Coin)
    {
        // 돈 지급
        user.Coin += r.Amount;
    }
    else
    {
        var cars = await templateService.GetCars();
        // 미리 지급할 자동차를 지급
        if (!user.DailyCarRewards.TryGetValue(user.NumberOfAttendance.ToString(), out var carId))
            carId = cars.FirstOrDefault(e => e.EnableReward).Id;
        if (user.Cars.Contains(carId))
        {
            // 이미 가지고 있으면, 자동차 가격만큼 돈을 지급
            var car = cars.Find(e => e.Id == carId);
            user.Coin += car.Cost;
        }
        else
        {
            user.Cars.Add(carId);
        }
    }
    ++user.NumberOfAttendance;
    user.DailyRewardedAtUtc = body.UtcDate;
    await userRepository.Update(userId, user);

    return NoContent();
}

[HttpPut("SpinRoulette")]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public async Task<ActionResult<RouletteResponse>> SpinRoulette([FromBody] DateRequest body)
{
    var userId = ClaimHelper.FindNameIdentifier(User);
    if (string.IsNullOrEmpty(userId))
        return Unauthorized();

    var user = await userRepository.Get(userId);
    if (user == null)
        return Forbid();

    // 오늘 이미 룰렛 돌림
    if (body.UtcDate >= user.RouletteSpunAtUtc.Date.AddDays(1))
        return StatusCode(StatusCodes.Status410Gone);

    var cars = await templateService.GetCars();
    var index = new Random((int)DateTime.Now.Ticks).Next(user.RouletteCarRewards.Count);
    var car = cars.Find(e => e.Id == user.RouletteCarRewards[index]);
    if (car == null)
        return NotFound();

    if (user.Cars.Contains(car.Id))
        user.Coin += car.Cost;
    else
        user.Cars.Add(car.Id);
    user.RouletteSpunAtUtc = body.UtcDate;
    await userRepository.Update(userId, user);

    return Ok(new RouletteResponse
    {
        Index = index
    });
}

[HttpPut("CollectCoin")]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public async Task<IActionResult> CollectCoin([FromBody] DateRequest body)
{
    var userId = ClaimHelper.FindNameIdentifier(User);
    if (string.IsNullOrEmpty(userId))
        return Unauthorized();

    var user = await userRepository.Get(userId);
    if (user == null)
        return Forbid();

    if (body.UtcDate <= user.CoinCollectedAtUtc)
        return StatusCode(StatusCodes.Status410Gone);

    var stages = await templateService.GetStages();
    var stage = stages[user.CurrentStageIndex];
    int minutes = (int)(body.UtcDate - user.CoinCollectedAtUtc).TotalMinutes;
    if (minutes <= 0)
        return NoContent();

    user.Coin += Math.Min(minutes, stage.MaxCollect);
    user.CoinCollectedAtUtc = body.UtcDate;
    await userRepository.Update(userId, user);

    return NoContent();
}

 

클라이언트 통신 구현

 

UserModel을 출석, 룰렛, 수집 기능을 처리할 때 필요한 프로퍼티들을 추가하였습니다.

public class UserModel
{
    public string Nickname
    {
        get;
        set;
    }
    public long Coin
    {
        get;
        set;
    }
    public List<CarTemplate> Cars
    {
        get;
        set;
    }
    public CarTemplate CurrentCar
    {
        get;
        set;
    }
    public StageTemplate CurrentStage
    {
        get;
        set;
    }
    public Dictionary<int, CarTemplate> DailyCarRewards
    {
        get;
        set;
    }
    public DateTime DailyRewardedAtUtc
    {
        get;
        set;
    }
    public short NumberOfAttendance
    {
        get;
        set;
    }
    public DateTime CoinCollectedAtUtc
    {
        get;
        set;
    }

    public List<CarTemplate> RouletteCarRewards
    {
        get;
        set;
    }
    public DateTime RouletteSpunAtUtc
    {
        get;
        set;
    }
}

 

UserService 클래스를 Load 메소드를 수정한 후 Attendance, SpinRoulette, CollectCoin을 수정하였습니다.
룰렛은 서버에서 응답해주기 전까지 보상으로 뭘 받을지 알 수 없기 때문에 서버와의 통신을 완료하면 클라이언트 정보를 수정하도록 구현하였습니다.

public async UniTask<HttpStatusCode> Load()
{
    var res = await http.Get<UserResponse>("User");
    if (!res.Item1.IsSuccess())
    {
        Debug.LogWarning($"Load user failed. - {res.Item1}");
        return res.Item1;
    }
    User = new()
    {
        Nickname = res.Item2.Nickname,
        Coin = res.Item2.Coin,
        Cars = res.Item2.Cars
            .Select(id => templateService.Cars.Find(e => e.Id == id))
            .ToList(),
        CurrentCar = templateService.Cars
            .Find(e => e.Id == res.Item2.CurrentCar),
        CurrentStage = templateService.Stages[res.Item2.CurrentStage],
        DailyCarRewards = new(res.Item2.DailyCarRewards.Select(
            e => new KeyValuePair<int, CarTemplate>(
                int.Parse(e.Key),
                templateService.Cars.Find(c => c.Id == e.Value)
            )
        )),
        DailyRewardedAtUtc = res.Item2.DailyRewardedAt,
        NumberOfAttendance = res.Item2.NumberOfAttendance,
        CoinCollectedAtUtc = res.Item2.CoinCollectedAt,
        RouletteCarRewards = res.Item2.RouletteCarRewards
            .Select(id => templateService.Cars.Find(e => e.Id == id))
            .ToList(),
        RouletteSpunAtUtc = res.Item2.RouletteSpunAt
    };
    return res.Item1;
}

public async void Attendance()
{
    var now = DateTime.UtcNow;
    if (User.NumberOfAttendance >= templateService.DailyRewards.Count)
        return;
    if (now >= User.DailyRewardedAtUtc.Date.AddDays(1))
        return;
    var reward = templateService.DailyRewards[User.NumberOfAttendance];
    if (reward.Type == DailyRewardType.Coin)
    {
        User.Coin += reward.Amount;
    }
    else
    {
        User.DailyCarRewards.TryGetValue(User.NumberOfAttendance, out var carTemp);
        if (User.Cars.Contains(carTemp))
            User.Coin += carTemp.Cost;
        else
            User.Cars.Add(carTemp);
    }
    ++User.NumberOfAttendance;
    User.DailyRewardedAtUtc = now;

    var res = await http.Put($"User/Attendance", new DateRequest
    {
        UtcDate = now
    });
    if (!res.IsSuccess())
    {
        Debug.LogWarning($"Attendance failed. - {res}");
        UserUpdateFailed?.Invoke(this, EventArgs.Empty);
    }
}

public async UniTask<int> SpinRoulette()
{
    var now = DateTime.UtcNow;
    var res = await http.Put<RouletteResponse>("User/SpinRoulette", new DateRequest
    {
        UtcDate = now
    });
    if (!res.Item1.IsSuccess())
    {
        Debug.LogWarning($"Spin roulette failed. - {res}");
        UserUpdateFailed?.Invoke(this, EventArgs.Empty);
        return -1;
    }
    User.RouletteSpunAtUtc = now;
    var car = User.RouletteCarRewards[res.Item2.Index];
    if (User.Cars.Contains(car))
        User.Coin += car.Cost;
    else
        User.Cars.Add(car);
    return res.Item2.Index;
}

public async void CollectCoin()
{
    var now = DateTime.UtcNow;
    if (now <= User.CoinCollectedAtUtc)
        return;

    var minutes = (int)(now - User.CoinCollectedAtUtc).TotalMinutes;
    if (minutes <= 0)
        return;

    User.CoinCollectedAtUtc = now;
    User.Coin += Math.Min(minutes, User.CurrentStage.MaxCoin);
    var res = await http.Put($"User/CollectCoin", new DateRequest
    {
        UtcDate = now
    });
    if (!res.IsSuccess())
    {
        Debug.LogWarning($"Collect coin failed. - {res}");
        UserUpdateFailed?.Invoke(this, EventArgs.Empty);
    }
}

 

구현한 기능들이 잘 되는지는 실제 UI작업을 할 때 테스트하면서 확인하도록 하겠습니다.

 

지난주부터 어제까지는 밖으로 자주 돌아다녀 8일 만에 글을 게시하였습니다.

깃 허브 저장소 : taxi-game-3d-unity

728x90
LIST

+ Recent posts